[GCP] CMDB 시스템 구축(3)
1편에서 전체 그림을, 2편에서 Go 백엔드의 수집·추적·서빙을 다뤘습니다.
마지막 3편은 이 시스템을 프로덕션에서 안정적으로 굴리는 운영에 초점을 맞춥니다.
핵심은 관측성(분산 추적)과 배포 두 가지입니다.
프론트엔드와 인증은 DevOps 관점에서 필요한 만큼만 짚고 넘어가겠습니다.
1. 프론트엔드는 운영 관점에서 단순하다
운영하는 입장에서 프론트엔드는 손이 많이 가지 않습니다. React SPA를 빌드해 정적 자산으로 만든 뒤, 경량 컨테이너가 이를 서빙할 뿐입니다. 서버에 상태를 두지 않는(stateless) 구조라 Cloud Run에서 트래픽에 따라 자유롭게 확장·축소되고, 백엔드 API는 인증 쿠키를 자동으로 동봉해 호출합니다. 배포·스케일 관점에서는 프론트엔드를 "정적 파일을 뿌리는 컨테이너 하나"로 다루면 됩니다.
2. 인증 — OAuth2 + HttpOnly 쿠키
로그인은 Google OAuth2로 처리하고, 세션은 JWT를 HttpOnly 쿠키에 담아 관리합니다. 프론트엔드는 토큰을 직접 만지지 않고, 보호된 라우트에 들어갈 때 /auth/me로 세션 유효성만 확인합니다.
[프론트엔드] [백엔드] [Google]
│ │ │
│─ /auth/google/login ───────▶│─ redirect ────────────────▶│
│◀──────────── Google 로그인 페이지 ──────────────────────│
│ │◀─ callback?code=xxx ───────│
│◀─ Set-Cookie(HttpOnly) + redirect /categories ──────────│
DevOps 관점에서 챙길 지점은 쿠키 설정입니다.
- HttpOnly — 자바스크립트에서 읽을 수 없어, XSS로 스크립트가 주입돼도 세션 토큰을 훔쳐 가지 못합니다.
- SameSite / Secure — 프로덕션은
Secure+SameSite=None, 개발은SameSite=Lax로 분기. - 일괄 검증 — 모든
/api/*요청은authMiddleware가 쿠키의 JWT를 검증한 뒤에야 통과합니다.
3. 관측성 — 프론트에서 DB까지 하나의 추적으로
"버튼을 눌렀는데 느리다"는 신고가 들어왔을 때, 프론트엔드 클릭부터 백엔드 핸들러, PostgreSQL 쿼리까지 하나의 트레이스로 이어 보는 것이 목표입니다. OpenTelemetry로 이를 구현했습니다.
프론트엔드는 "전파 전용"
프론트엔드는 트레이스를 직접 수집·전송하지 않습니다. 대신 W3C Trace Context를 백엔드로 넘겨주는 역할만 맡습니다. 트레이스 저장 비용은 백엔드 한 곳에서 지고, 프론트는 요청에 traceparent 헤더만 심어 보내는 가벼운 구성입니다.
// tracing.js — 익스포터 없이 전파(propagation)만
const provider = new WebTracerProvider();
provider.register({ contextManager: new ZoneContextManager() });
propagation.setGlobalPropagator(new W3CTraceContextPropagator());
// axiosConfig.js — 모든 요청에 트레이스 컨텍스트 주입
propagation.inject(context.active(), carrier);
config.headers = { ...config.headers, ...carrier };
currentSpan.setAttribute('peer.service', 'cmdb-backend'); // 토폴로지 연결
백엔드는 미들웨어 한 줄로 전 구간 추적
백엔드는 OTLP HTTP 익스포터로 트레이스를 Jaeger나 Grafana Tempo로 내보냅니다. 서비스 이름과 버전은 Cloud Run이 주입하는 K_SERVICE·K_REVISION에서 자동으로 읽고, 들어온 요청 헤더에서 트레이스 컨텍스트를 이어받아(Extract) HTTP 요청마다 스팬을 엽니다.
DB 호출도 tracedQuery·tracedExec 헬퍼로 감싸 전부 계측했습니다. 그래서 하나의 트레이스 안에서 "HTTP 요청 → 핸들러 → SQL 쿼리"가 부모-자식 스팬으로 연결됩니다.
// observability.go — DB 쿼리 계측
func tracedQuery(ctx context.Context, db *sql.DB, query string, args ...interface{}) (*sql.Rows, error) {
ctx, span := tracer.Start(ctx, "db.query",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(
attribute.String("db.system", "postgresql"),
attribute.String("db.statement", query),
attribute.String("peer.service", "cmdb-postgres"),
),
)
defer span.End()
return db.QueryContext(ctx, query, args...)
}
결과적으로 트레이스 하나가 이렇게 이어집니다.
Trace: GET /api/history
└─ [frontend] axios GET /api/history (traceparent 전파)
└─ [backend] GET /api/history span (server)
└─ db.query SELECT ... FROM asset_history span (client → postgresql)
비용을 고려한 샘플링
프로덕션에서 모든 요청을 100% 추적하면 저장 비용과 부하가 만만치 않습니다. 그래서 환경변수 OTEL_TRACES_SAMPLE_RATE로 샘플링 전략을 바꿀 수 있게 했습니다. 기본값은 5%이고, 에러는 별도로 챙깁니다.
// observability.go — 환경에 따라 샘플러 선택
func getSampler() sdktrace.Sampler {
switch os.Getenv("OTEL_TRACES_SAMPLE_RATE") {
case "always":
return sdktrace.AlwaysSample() // 전량 (디버깅용)
case "never":
return sdktrace.NeverSample() // 비활성
case "errors":
return sdktrace.ParentBased(sdktrace.TraceIDRatioBased(0.1)) // 10% + 에러
default:
return sdktrace.ParentBased(sdktrace.TraceIDRatioBased(0.05)) // 기본 5%
}
}
ParentBased라 부모 스팬의 샘플링 결정을 따라가므로, 한 트레이스가 프론트와 백엔드에서 따로 잘리지 않고 통째로 남거나 통째로 버려집니다.
4. 배포 — 멀티스테이지 Docker + Jenkins + ArgoCD
프론트와 백엔드 모두 멀티스테이지 Docker 빌드로 경량 이미지를 만듭니다. 빌드 도구는 빌더 스테이지에만 두고, 실행 스테이지에는 결과물만 복사해 이미지 크기와 공격 표면을 줄였습니다. 두 이미지 모두 non-root 사용자로 실행합니다.
백엔드 — 정적 바이너리
Go는 CGO_ENABLED=0으로 정적 컴파일해 단일 바이너리로 만들고, -ldflags "-s -w"로 디버그 심볼을 떼어 냅니다. 실행 스테이지는 alpine에 바이너리 하나만 얹어 이미지가 ~20MB 수준입니다.
# 1) 빌더 — 정적 바이너리 FROM .../go:1.26.1-alpine AS builder WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY . . ENV CGO_ENABLED=0 GOOS=linux GOARCH=amd64 RUN go build -trimpath -ldflags "-s -w" -o /app/main # 2) 런타임 — alpine + 바이너리 하나 FROM alpine:3.19 RUN apk add --no-cache ca-certificates tzdata COPY --from=builder /app/main /usr/local/bin/main RUN adduser -u 65532 -S nonroot && ... USER nonroot CMD ["/usr/local/bin/main"]
프론트엔드 — 빌드 후 정적 서빙
프론트는 빌더에서 npm ci(캐시 마운트) 후 npm run build하고 devDependencies를 npm prune으로 걷어 냅니다. 런타임은 alpine에서 경량 Express(server.js)가 빌드 산출물을 서빙하며 ~50MB 수준입니다.
CI/CD 파이프라인
Jenkins가 빌드부터 배포 준비까지 자동화하고, 실제 배포는 ArgoCD의 GitOps로 Cloud Run에 반영합니다. 중간에 승인 단계를 둬서, 이미지를 푸시한 뒤 사람이 확인하고 배포 전략을 선택하게 했습니다.
Cleanup → Git Clone → Init Version → Build Docker Image → Push (Artifact Registry) → Get Current Revision → 승인 대기(Approval) → 배포 전략 선택 → ArgoCD Image Tag Update ──▶ Cloud Run
- Artifact Registry에 이미지를 태그와 함께 푸시
- 승인 단계에서 현재 리비전을 확인하고 배포 여부·전략을 결정
- ArgoCD가 이미지 태그 변경을 감지해 Cloud Run에 GitOps 방식으로 배포
- 런타임은 Cloud Run(asia-northeast3) — 트래픽에 따라 자동 확장·축소
마치며
세 편에 걸쳐 GCP 리소스 40종을 한곳에서 조회·추적·질의하는 CMDB를 훑었습니다. 정리하면 이렇습니다.
- 1편 — 왜 만들었나, 그리고 전체 아키텍처
- 2편 — 백엔드의 수집·변경 추적·저부하 서빙, 그리고 AI·빌링·컴플라이언스
- 3편 — 운영: 관측성과 배포
돌아보면, 도구를 만든 것 자체보다 운영을 처음부터 설계에 넣은 것이 더 오래 남았습니다. 분산 추적을 나중에 붙이려면 코드 곳곳을 다시 헤집어야 하지만, DB 헬퍼 한 겹과 미들웨어 한 줄로 처음부터 깔아 두니 장애 대응이 한결 수월했습니다. 배포도 마찬가지여서, 승인 단계를 낀 GitOps 파이프라인 덕분에 "누가 언제 무엇을 올렸는지"가 늘 명확했습니다.
거창한 서드파티 도구 없이도, 조직 전체의 리소스를 한 화면에서 보고 변경을 감사하며 자연어로 질의하는 도구를 직접 만들 수 있었습니다. 같은 고민을 하는 분들에게 이 시리즈가 작은 참고가 되면 좋겠습니다.