Notification
새로운 알림이 없습니다.

[GCP] CMDB 시스템 구축(2)

GCP, CMDB

1편에서는 GCP 조직 전체를 한 화면에 모으는 CMDB의 큰 그림을 그렸습니다. 2편은 그 심장인 Go 백엔드를 다룹니다. 다만 코드를 순서대로 훑는 대신, SRE·DevOps 리드가 실제 운영에서 신경 쓰는 지점을 축으로 정리했습니다. 리소스를 어떻게 긁어오느냐보다 "누가 언제 무엇을 바꿨는지 5초 만에 답할 수 있는가", "이 도구가 GCP 할당량을 태우지는 않는가", "비용 이상치와 보안 드리프트를 사람이 눈으로 찾지 않아도 되는가" 같은 질문에 초점을 맞췄습니다.

다루는 범위는 넓습니다. 조직 전체 리소스 수집, 변경 추적과 감사, 저부하 서빙 설계, 자연어 질의(AI), 비용 가시성, 지속적 컴플라이언스까지 한 편에 담았습니다.


0. CMDB가 실제로 푸는 운영 문제

리드 입장에서 CMDB는 "리소스 목록을 보여주는 도구"가 아닙니다. 아래 네 가지 운영 질문에 답하는 도구입니다.

  • 감사(Audit) — 특정 방화벽 규칙이 언제, 어떻게 바뀌었나? 인시던트가 터졌을 때 변경 타임라인을 즉시 재구성할 수 있나?
  • 가시성 — 프로젝트가 수십 개로 흩어져 있어도 전체를 하나의 진실 원천(single source of truth)으로 볼 수 있나?
  • 비용 — 어느 프로젝트·서비스가 돈을 새고 있는지 월말 청구서를 받기 전에 알 수 있나?
  • 컴플라이언스 — 퍼블릭 버킷, 90일 넘은 서비스 계정 키, HA 없는 DB 같은 위험을 상시로 잡아내나?

이 네 가지가 백엔드 설계의 뼈대이고, 아래 섹션들이 각각에 대응합니다.


1. 조직 전체 리소스 수집 파이프라인

모든 것의 출발점은 Cloud Asset Inventory API입니다. 프로젝트마다 API를 따로 호출하지 않고, 조직(또는 폴더) 단위 parent 하나로 40종 이상의 리소스를 한 번에 긁어옵니다. 이것이 "프로젝트가 몇 개든 전체를 한눈에"를 떠받치는 기술적 근거입니다.

// fetchAssets(): 조직 parent 아래 전체 리소스를 페이지네이션으로 수집
req := &assetpb.ListAssetsRequest{
    Parent:      parent,              // "organizations/123456789" 또는 "projects/my-project"
    AssetTypes:  assetTypes,          // 40+ 타입 (아래)
    ContentType: assetpb.ContentType_RESOURCE,
    PageSize:    500,
}

it := assetClient.ListAssets(ctx, req)
for {
    asset, err := it.Next()
    if err == iterator.Done {
        break
    }
    // 타입별 파서로 분기
    parseAndAppend(asset)
}

수집 대상은 Compute(Instance·Disk·Address·InstanceTemplate 등), Network(VPC·Firewall·ForwardingRule·SecurityPolicy 등), Container(GKE·Cloud Run), Database(Cloud SQL·AlloyDB), Storage, IAM, Pub/Sub, Vertex AI까지 40종을 넘습니다.

protobuf 응답을 Go struct로

Asset Inventory는 리소스마다 스키마가 다른 protobuf를 돌려줍니다. 이걸 protojson.Marshal로 JSON화한 뒤, 타입별 파서(parse_compute.go, parse_network.go 등)가 각자 필요한 필드만 뽑아 struct로 만듭니다. 핵심은 union 패턴입니다. 하나의 Asset은 공통 헤더에 타입별 스펙 포인터를 옵션으로 붙이는 구조라, 어떤 리소스든 같은 슬라이스에 담깁니다.

type Asset struct {
    AssetHeader                    // ID, Name, Type, ProjectID, LastUpdate...
    Compute *ComputeSpec `json:",omitempty"`
    SQL     *SQLSpec     `json:",omitempty"`
    GKE     *GKESpec     `json:",omitempty"`
    Run     *RunSpec     `json:",omitempty"`
    // ... 타입별 스펙은 해당 리소스일 때만 채워짐
    RawData []byte       `json:"-"`   // 원본 JSON, 변경 diff에 사용
}

RawData에 원본을 통째로 들고 있다는 점이 포인트입니다. 파싱해서 대시보드에 보여주는 필드와, 나중에 변경 비교에 쓸 원본을 분리해 둔 겁니다.

언제 수집하나

수집은 세 가지 경로로 돕니다. 리드가 볼 때 중요한 건 "손이 안 가는 자동화"와 "필요할 때 즉시 갱신"이 둘 다 되는지입니다.

  • 주기 스캔 — 기본 1시간 간격 티커(refreshInterval)로 백그라운드에서 자동 갱신.
  • Cloud Scheduler 트리거/scheduler/update-cache 엔드포인트를 X-Scheduler-Token으로 검증해, 스케줄러가 원하는 주기로 강제 갱신.
  • 수동 갱신 — 인증된 관리자가 /api/admin/update-cache로 즉시 트리거(배포 직후 확인 등).

2. 변경 추적 = 감사(Audit)의 핵심

여기가 이 백엔드의 하이라이트이자, SRE·DevOps 리드가 가장 눈여겨볼 부분입니다. 새벽 3시에 방화벽이 0.0.0.0/0으로 열렸다면, 언제 어떤 필드가 무엇에서 무엇으로 바뀌었는지 즉답할 수 있어야 합니다.

스냅샷 비교

매 수집마다 이전 스냅샷과 현재 스냅샷을 리소스 ID로 매핑해 비교합니다.

  • 새 스냅샷에만 있으면 → CREATE
  • 양쪽에 있는데 의미 있는 필드가 바뀌면 → UPDATE
  • 이전 스냅샷에만 있으면 → DELETE

노이즈를 걸러내는 스마트 변경 감지

순진하게 diff를 뜨면 GCP가 자동으로 만지는 필드 때문에 변경이 아닌데도 변경으로 잡힙니다. etag, fingerprint, updateTime 같은 것들이죠. 이걸 그대로 기록하면 알림 피로가 쌓이고, 정작 중요한 변경이 노이즈에 묻힙니다. 그래서 비교 전에 무의미한 필드를 통째로 제외합니다.

// 변경 이력에서 제외하는 노이즈 필드 (GCP가 자동으로 바꾸는 것들)
var ignoredFields = map[string]bool{
    "kind": true, "selfLink": true, "etag": true, "fingerprint": true,
    "creationTimestamp": true, "updateTime": true, "lastUpdateTime": true,
    "logConfig": true, "labels": true, "resourceVersion": true,
    "uid": true, "managedFields": true, "observedGeneration": true,
}

여기에 더해, 타입별로 대시보드에 실제로 보이는 필드만 변경으로 인정합니다. Compute라면 머신타입·상태·존·네트워크 인터페이스·태그, Cloud Run이라면 CPU·메모리 한도와 min/max 스케일이 그런 필드입니다. hasMeaningfulChanges()가 이 판정을 담당합니다.

결정적(deterministic) diff

JSON은 키 순서가 보장되지 않아서, 내용이 같아도 순서만 달라 "변경됨"으로 오인될 수 있습니다. 그래서 비교 전에 키를 정렬해 canonical 형태로 만든 뒤(marshalSorted()) diff를 뜹니다. 직전 UPDATE와 완전히 동일하면 기록을 건너뛰어 중복도 막습니다.

// 기록되는 변경 이력 레코드 (예시)
{
  "asset_type": "compute.googleapis.com/Firewall",
  "asset_id":   "projects/my-project/global/firewalls/allow-ssh",
  "operation":  "UPDATE",
  "user_email": "system",
  "diff": {
    "sourceRanges": {
      "before": ["10.0.0.0/8"],
      "after":  ["0.0.0.0/0"]
    }
  },
  "created_at": "2025-06-15T03:12:00Z"
}

PostgreSQL에 남기는 감사 로그

변경은 asset_history 테이블에 append-only로 쌓입니다. 리소스별 순번, 작업 종류, 변경 주체, JSONB diff, 시각을 남깁니다.

CREATE TABLE asset_history (
    asset_type  TEXT,
    asset_id    TEXT,
    id          BIGINT,        -- 리소스별 순번
    operation   TEXT,          -- CREATE | UPDATE | DELETE
    user_email  TEXT,
    diff        JSONB,         -- {필드: {before, after}}
    created_at  TIMESTAMPTZ
);

감사 공백을 메우는 합성 이력

DNS 레코드, Secret, 버킷처럼 네이티브 변경 이력을 API로 노출하지 않는 리소스도 있습니다. 이런 리소스는 현재 상태에서 합성 CREATE 이력을 만들어(createSyntheticHistory()) 실제 생성 시각과 함께 남깁니다. 덕분에 "이력이 있는 리소스"와 "이력이 없는 리소스"가 섞여 감사에 구멍이 나는 일을 막습니다.


3. 낮은 운영 부하로 설계된 서빙 계층

CMDB 자체가 GCP API 할당량을 태우거나 응답이 느리면, 도구를 도입한 보람이 없습니다. 리소스 목록 API는 가장 자주 호출되는 엔드포인트라 특히 신경 써서 설계했습니다.

락프리 캐시 + ETag

수집한 리소스는 atomic.Value에 JSON으로 통째로 보관합니다. 뮤텍스 없이 읽을 수 있으니, 동시 요청이 몰려도 락 경합이 없습니다. 매 갱신마다 SHA256 해시로 ETag를 만들어 브라우저 캐싱(304)에 활용합니다.

var (
    cachedJSON atomic.Value  // []byte
    cachedETag atomic.Value  // string
)

func updateCacheAndETag(assets []Asset) {
    jsonData, _ := json.Marshal(assets)
    hash := sha256.Sum256(jsonData)
    etag := fmt.Sprintf("\"%x\"", hash[:8])
    cachedJSON.Store(jsonData)
    cachedETag.Store(etag)
}

요청이 오면 If-None-Match 헤더를 저장된 ETag와 비교해, 같으면 본문 없이 304를 돌려줍니다. 매번 GCP를 다시 부르지 않으니 할당량도, 지연도, 대역폭도 아낍니다.

func cachedAssetsHandler(c *gin.Context) {
    etag := cachedETag.Load().(string)
    c.Header("ETag", etag)
    c.Header("Cache-Control", "max-age=60")
    if c.GetHeader("If-None-Match") == etag {
        c.Status(http.StatusNotModified)   // 304
        return
    }
    c.Data(http.StatusOK, "application/json", cachedJSON.Load().([]byte))
}

메모리 풀과 커넥션 풀

수천 개 리소스를 매시간 파싱·직렬화하다 보면 GC 압력이 큽니다. sync.Pool로 파싱용 맵과 []Asset 슬라이스를 재사용해 할당을 줄였습니다. 갱신은 CompareAndSwap 기반 큐(용량 1)로 감싸, 동시에 두 번 도는 일이 없도록 했습니다. DB 커넥션 풀도 보수적으로 잡았습니다.

db.SetMaxOpenConns(25)
db.SetMaxIdleConns(10)
db.SetConnMaxLifetime(5 * time.Minute)
db.SetConnMaxIdleTime(30 * time.Second)

4. 온콜을 위한 자연어 질의 (AI 챗봇)

온콜 엔지니어가 새벽에 콘솔 탭을 헤매는 대신 "퍼블릭 버킷 목록 보여줘"라고 물으면 되도록, Vertex AI(Gemini) 챗봇을 붙였습니다. 리드 관점에서 중요한 건 두 가지입니다. 답이 실제 CMDB 데이터에 근거하는가, 그리고 AI가 죽어도 시스템이 버티는가.

벡터 DB 없는 RAG

거창한 벡터 스토어 없이도 잘 동작합니다. 먼저 질문을 키워드로 분류(classifyQuestion())해 비용·네트워크·자산·일반 중 하나로 라우팅하고, 그 카테고리에 맞는 리소스만 골라(filterAssetsByQuestion()) 텍스트 요약으로 만든 뒤 프롬프트에 넣습니다. 응답이 길어지지 않게 15건으로 제한하고, "더 보기"를 유도합니다.

// 질문을 카테고리로 분류해 필요한 데이터만 프롬프트에 주입
func classifyQuestion(q string) Route {
    switch {
    case containsAny(q, "비용", "요금", "billing", "cost"):
        return RouteCostAnalysis
    case containsAny(q, "vpc", "firewall", "network", "로드밸런서"):
        return RouteNetworkDiag
    case containsAny(q, "vm", "compute", "sql", "gke", "머신"):
        return RouteAssetQuery
    default:
        return RouteGeneral
    }
}

Graceful degradation

AI 클라이언트는 첫 요청 때 지연 초기화(lazy init)하고, 초기화나 호출이 실패해도 가공하지 않은 원본 데이터를 그대로 반환합니다. AI가 SPOF(단일 장애점)가 되지 않게 한 겁니다. 시스템 프롬프트는 "제공된 데이터만 사용하고 없는 리소스를 지어내지 말 것"을 못박아 할루시네이션을 억제합니다. 응답은 SSE로 스트리밍해 체감 지연을 줄입니다.

🤖 AI Assistant
퍼블릭 버킷 목록 보여줘
compliance-query
공개 접근이 열린 버킷 2개를 찾았습니다.
버킷노출프로젝트
public-assetsallUsersweb-prod
legacy-backupallAuthenticatedUsersdata-platform
온콜 엔지니어가 자연어로 컴플라이언스를 질의한 예시 — 공개 노출 버킷을 즉시 표로 응답

5. 비용 가시성 (FinOps)

월말 청구서를 받고 놀라는 대신, 비용을 상시로 들여다봅니다. GCP 빌링을 BigQuery로 export해 두고, 백엔드가 이를 프로젝트별·서비스별·기간별로 집계합니다.

  • 다중 테이블 UNION — 여러 빌링 export 테이블을 UNION ALL로 묶어 조직 전체를 한 번에 집계.
  • 순비용 계산 — 크레딧을 반영해 SUM(cost) + SUM(credits)로 실제 지불 비용을 산출.
  • 기간 유연성 — 일·주·월 단위 집계를 파라미터(@invoiceMonth 등)로 처리.
  • 기간 인지 캐시sync.Map에 "기간타입:YYYYMM" 키로 캐싱하고 주기적으로 갱신.
-- 프로젝트·서비스별 순비용 (크레딧 반영), 파라미터 바인딩
SELECT
  project.id                       AS project_id,
  service.description              AS service,
  SUM(cost) + SUM(IFNULL(c.amount, 0)) AS net_cost
FROM billing_export_table
LEFT JOIN UNNEST(credits) AS c
WHERE invoice.month = @invoiceMonth
GROUP BY project_id, service
ORDER BY net_cost DESC

결과는 상위 프로젝트·서비스와 시계열 추이로 시각화됩니다. 리드는 이 화면 하나로 "이번 달 어느 프로젝트가 튀는지"를 즉시 봅니다.

이번 달 비용 요약 2026-06 · 예시 데이터
$42,180 ▲ 8.3% 전월 대비
프로젝트별 비용 상위 5
web-prod
$18,900
data-platform
$11,540
ml-training
$7,220
staging
$2,980
shared-infra
$1,540
상위 서비스  ·  Compute Engine 46%  ·  BigQuery 19%  ·  Cloud SQL 12%  ·  GKE 9%
프로젝트·서비스별 비용을 크레딧 반영 순비용으로 집계한 요약 화면(예시)

6. 지속적 컴플라이언스

보안 점검을 분기에 한 번 사람이 돌리는 대신, CMDB가 상시로 자동 점검합니다. CIS Benchmark 성격의 체크를 수집된 리소스에 적용하고, 통과/실패/경고로 판정해 점수를 냅니다. 감사 대비와 설정 드리프트 탐지가 동시에 됩니다.

점검 항목 무엇을 보는가
퍼블릭 방화벽 0.0.0.0/0 · ::/0을 허용하는 규칙 탐지
서비스 계정 키 사용자 관리 키 중 90일 초과 경고
Storage 버킷 퍼블릭 읽기 · 인증 사용자 읽기 노출 여부
Cloud SQL 퍼블릭 IP 미노출, HA, 자동 백업, SSL 강제
Compute 디스크 암호화, 불필요한 퍼블릭 IP 여부
GKE 네트워크 정책, RBAC, Workload Identity
네트워크 과도하게 열린 규칙, 프라이빗 구성

각 결과는 상태(PASS·FAIL·WARNING), 심각도(Critical~Low), 해당 리소스 수, 개선 권고를 담습니다. 리드는 점수 하나로 조직의 보안 태세를 가늠하고, 실패 항목만 파고들면 됩니다.

컴플라이언스 대시보드
CIS Benchmark 체크 목록과 심각도 — 실제 점검 결과·점수는 가림 처리

7. 비밀은 어떻게 다루나

CMDB는 내부 DB 계정 같은 민감 정보도 다룹니다. 비밀번호는 평문으로 두지 않고 AES-256-GCM으로 암호화해 PostgreSQL에 저장하며, 암호화 키는 코드에 박지 않고 Secret Manager에서 가져옵니다. 암호화마다 난수 nonce를 써서 같은 값이라도 매번 다른 암호문이 됩니다. 사소해 보여도 리드가 꼭 확인하는 부분입니다.


다음 편 예고

3편(마지막 편)에서는 사용자가 실제로 마주하는 프론트엔드와, 이 모든 걸 안정적으로 굴리는 운영을 다룹니다.

  • 관측성 — OpenTelemetry 분산 추적, DB 쿼리 계측(tracedQuery), 비용을 고려한 샘플링 전략
  • 인증 — Google OAuth2 + JWT(HttpOnly 쿠키) 흐름
  • 배포 — Docker 멀티스테이지, Jenkins CI, ArgoCD GitOps로 Cloud Run에 올리기

CMDB GCP
Stan Cloud
Stan Cloud
An avid cloud engineering Fan
대화 참여하기
댓글 쓰기