
AI 시대를 위한 관측 가능성(Observability) 설계 — 애플리케이션 / 인프라 / CI / LLM, 각각의 형태에 맞게 (Part
요약
AI가 프로덕션 환경을 정확히 이해할 수 있도록 애플리케이션, 인프라, CI, LLM의 관측 가능성 데이터를 형태화(shaping)하는 설계 전략을 다룹니다. 가공되지 않은 로그를 그대로 전달하는 대신, AI의 답변 품질을 높이기 위해 데이터를 재구성하는 방법론을 제시합니다.
핵심 포인트
- AI가 소비하기 전 데이터의 형태화(shaping)가 필수적임
- 가공되지 않은 로그는 컨텍스트 윈도우 초과 및 환각을 유발함
- 모니터링 영역을 애플리케이션, 인프라, CI, LLM의 4개 축으로 구분
- AI가 답변하고자 하는 질문의 성격에 맞춰 데이터 구조를 설계해야 함
AI 지원 공개: 이 기사는 Claude의 도움을 받아 초안이 작성되었습니다. 모든 기술적 내용, 설계 결정, 코드 참조 및 스크린샷은 제가 airCloset에서 설계하고 운영하는 프로덕션 시스템을 반영하며, 문장은 출판 전 제가 직접 수정하였습니다.
안녕하세요, airCloset의 CTO인 Ryan입니다.
이전 시리즈인 code-graph 심층 분석 (Part 2)에서, 저는 46개의 리포지토리로 구성된 코드베이스를 AI가 의미론적으로 검색할 수 있도록 만드는 방법에 대해 작성했습니다. 해당 글에서 제가 미처 해결하지 못하고 남겨두었던 마지막 문제는 바로 동적 분석(dynamic analysis)의 부재였습니다:
그래프에 존재하는 것은 "이 에지가 정적으로 존재한다"는 사실입니다. 이 에지가 실제 프로덕션 환경에서 얼마나 자주 사용되는지는 기록되지 않습니다.
정적인 사실을 제공하는 그래프는 하나의 문제입니다. AI에게 현재 프로덕션에서 실제로 어떤 일이 일어나고 있는지를 알려주는 것은 별개의 문제입니다. 따라서 제가 정적 그래프에 적용했던 것과 동일한 형태화(shaping) 규율이 관측 가능성(observability) 스택에도 적용되어야 합니다.
이 포스트는 그 이야기의 전반부입니다. 저는 이를 두 부분으로 나누었습니다. Part 1(본 포스트)은 네 가지 서로 다른 모니터링 영역(애플리케이션 / 인프라 / CI / LLM)을 어떻게 형태화하는지를 다룹니다. Part 2는 PII(개인정보) 처리, 통합 인터페이스, 그리고 Self-Healing(자가 치유)에 대해 다루며 일주일 후에 공개될 예정입니다.
"AI에게 관측 가능하다"는 것은 도대체 무엇을 의미하는가?
code-graph 시리즈를 통해 얻은 가장 큰 교훈은 다음과 같습니다: AI가 소비하기 전에 데이터가 먼저 형태화(shaped)되어야 한다는 것입니다. 46개의 리포지토리에 있는 소스 코드를 모델에 그대로 던져버리면 컨텍스트 윈도우(context window)를 초과하게 되고 환각(hallucination)을 유발합니다. 그래서 우리는 정적 분석을 그래프로 만들고, 경계 노드(boundary nodes)에 의미를 부여하며, 그래프 간의 SAME_ENTITY 조인(join)을 수행하는 방식으로 데이터를 형태화한 후에야 비로소 전달했습니다.
관측 가능성(observability) 스택도 정확히 동일한 문제를 가지고 있습니다. 가공되지 않은 프로덕션 로그를 AI에게 그대로 던져주면 다음과 같은 결과가 나타납니다:
- 컨텍스트 윈도우 (context window)를 압도해버리는 엄청난 로그 양
- 모델이 에러와 노이즈를 구분할 방법이 없음
- 서로 연결되지 않는 메트릭 (metrics), 로그 (logs), 트레이스 (traces)
- "현재 우리가 얼마를 쓰고 있는가"와 같이 가공되지 않은 로그로는 전혀 답할 수 없는 질문들
다시 말해, AI가 사용하기 전에 로그의 형태를 재구성해야 합니다. 도메인은 다르지만 동일한 문제입니다.
문제는 올바른 형태가 AI가 무엇을 답변하기를 원하는가에 달려 있다는 점입니다. cortex (사내 AI 플랫폼)에서 저는 모니터링 범위를 네 가지 축으로 나누고, 각 축이 고유한 형태를 갖도록 설정했습니다.
참고: 여기서 "cortex"는 airCloset의 내부 AI 플랫폼 코드명을 의미합니다. Snowflake Cortex, Palo Alto Networks Cortex 등과는 무관합니다.
| 모니터링 대상 | AI가 답변해주길 원하는 것 | 형태 |
|---|---|---|
| 애플리케이션 (Application) | "지금 프로덕션에서 무슨 일이 일어나고 있는가?" (탐색) | 로그 (log) + 트레이스 (trace) |
| ... |
"모든 것을 OTel을 통해 밀어넣고 Loki에 전부 쏟아붓는다"는 것도 하나의 선택지입니다. 하지만 그렇게 하는 순간, 당신은 하나의 백엔드(backend)에 완전히 다른 종류의 질문들을 던지게 됩니다. 실시간 "현재 지출액이 얼마인가"와 "SQL을 통한 팀별 월간 비용 내역" 같은 질문을 동시에 던지는 것이며, 그중 하나는 반드시 성능 저하를 겪게 될 것입니다. 저는 목적에 따라 분리하는 선택을 했습니다.
네 가지 축을 하나씩 살펴보겠습니다. 애플리케이션과 인프라는 기초적인 부분이므로 짧게 언급하겠습니다. CI와 LLM은 AI 시대의 설계 판단이 실제로 드러나는 지점이므로, 이 부분들을 깊이 있게 다루겠습니다.
애플리케이션 (Application) — OTel + Loki + Tempo, 표준 스택
기초는 평범합니다. 모든 cortex 애플리케이션은 OpenTelemetry를 통해 계측(instrumented)되어 있으며, 트레이스(traces)는 Tempo로, 로그(logs)는 Loki로, 메트릭(metrics)은 Mimir로 전송됩니다. 이는 표준적인 Grafana Cloud 설정입니다.
여기에는 특별한 비결이 없습니다. 중요한 것은 규율(discipline)입니다. 즉, 모든 애플리케이션이 동일한 형태의 로그(logs)와 트레이스(traces)를 방출해야 한다는 점입니다. 이러한 통일성 덕분에 나중에 AI가 MCP를 통해 {service_name="<service>"} |~ "error"와 같은 쿼리를 실행하고 여러 서비스에 걸쳐 조사할 수 있게 됩니다.
실제 계측(instrumentation)에 대해서는 AI Harness Series Part 4 (Self-Healing)에서 다루었으므로, 세부 사항은 해당 글을 참고하시기 바랍니다. 반복할 가치가 있는 핵심은 다음과 같습니다. 적절하게 구축된 표준 OTel(OpenTelemetry) 스택은 향후 등장할 모든 AI 기반 기술의 전제 조건입니다.
인프라(Infrastructure) — Cloud Run / BigQuery / Pub/Sub 메트릭, 모두 Mimir로 통합
cortex는 GCP 위에서 실행되며 Cloud Run, Cloud Run Jobs, BigQuery, Pub/Sub, Cloud Tasks 및 기타 일반적인 서비스들을 하나로 엮습니다. 각 GCP 리소스의 메트릭(CPU, 메모리, 실행 횟수, 지연 시간(latency), 큐 체류 시간(queue dwell time) 등)은 Cloud Monitoring을 통해 Mimir로 흐릅니다.
이 부분 역시 특별한 것은 없습니다. 단지 표준적인 GCP 메트릭들을 하나의 Mimir 인스턴스로 수집할 뿐입니다. 하지만 이러한 "단일 지점"이라는 특성은 나중에 빛을 발합니다. 모든 데이터가 단일 저장소에서 쿼리 가능하기 때문에, AI는 "지난주에 어떤 서비스가 CPU를 가장 많이 사용했는가?" 또는 "큐가 막힌 워커(worker)가 있는가?"와 같은 질문에 자연스럽게 답할 수 있습니다. MCP는 거기서 데이터를 가져옵니다.
기초 단계는 여기까지입니다. 표준적인 관측 가능성(observability) 스택에 대해서는 다른 곳에 잘 문서화되어 있습니다. 세부 사항이 궁금하다면 Grafana와 OpenTelemetry의 문서를 읽어보시기 바랍니다.
AI 시대의 흥미로운 설계 판단은 다음 두 축인 CI와 LLM에 담겨 있습니다.
CI — Webhook Push가 아닌 사후 Pull 방식을 통한 Loki로의 로그 전송
cortex는 GitHub Actions에서 CI를 실행하며, 저는 모든 CI 로그를 Grafana Loki로 전송합니다.
"왜 그러죠? GitHub Actions에는 이미 이를 위한 훌륭한 UI가 있는데 말입니다"라는 질문은 합리적입니다. 그 이유는 구체적입니다:
- 모든 조사 과정에서 AI가 GitHub Actions API를 호출하게 하는 것은 속도가 느리고 인증(auth) 부하가 큽니다. Loki로 한 번 인제스션(Ingesting)해 두면 AI가 이를 애드혹(ad-hoc)으로 쿼리할 수 있습니다.
- 하나의 Loki 인스턴스에 CI 로그와 애플리케이션 로그를 함께 보관하므로, 이들을 **교차 쿼리(cross-query)**할 수 있습니다.
- LogQL 알람(alerts)은 CI 실패를 구조화된 신호(signal)로 변환합니다.
- AI는 자연어로 "지난주 이후로 실패한 테스트가 있나요?"라고 물을 수 있습니다.
하지만 전송(shipping) 메커니즘은 독특합니다. Cortex가 선택한 방식은 다음과 같습니다:
CI 실행 내부에서 로그를 푸시(push)하지 마세요. 실행이 완료된 후, GitHub API에서 로그를 풀(pull)해 오세요.
구체적으로는 다음과 같습니다:
- Test 작업이 종료되면
workflow_run이벤트가 발생합니다. - 로그 전송을 전담하는 **별도의 워크플로우(separate workflow)**가 트리거됩니다.
- 해당 워크플로우가 GitHub API(
/repos/.../actions/jobs/.../logs)에서 로그를 가져옵니다(pull). - OTLP
/v1/logs를 통해 구조화된 JSON(job / status / ref / pr / commit / output 등) 형태로 Grafana Cloud에 전송(ship)합니다.
{service_name="ci", ref="main", status="failure"}로 필터링하면, 메인 브랜치의 CI 실패 사례만 깔끔하게 얻을 수 있습니다.
푸시(push) 대신 풀(pull)을 사용하는 이유:
- CI 실행과 관측 가능성(observability)이 분리(decouple)됩니다. 로그 전송에 실패하더라도 테스트 실행에는 영향을 주지 않습니다. 또한 전송 과정을 독립적으로 재시도하거나 다시 실행(replay)할 수 있습니다.
- PR 코드가 API 키에 접근할 경로가 없습니다. 전송 워크플로우는 기본 브랜치(default-branch) 컨텍스트에서 실행되며, 포크(fork)된 PR이 가져온 것이 아닌 베이스 리포지토리의 시크릿(secrets)을 사용합니다. 테스트 워크플로우 자체는 Grafana API 키에 절대 접근하지 않습니다. 이는 "유출되지 않을 것이라고 믿는" 수준이 아니라 구조적인 보장입니다.
- 전송 실패 자체가 관측 가능해집니다. 만약 전송 기능이 CI 내부에 있다면, 전송 버그가 발생했을 때 관측 가능성 스택 자체가 침묵하게 되어 이를 알아차릴 수 없습니다. 두 과정을 분리하면, 전송 워크플로우의 성공/실패 여부 자체가 알람을 보낼 수 있는 대상이 됩니다.
메인 브랜치(main-branch)의 실패가 나타나는 즉시, LogQL 알람이 발생하고 Slack으로 알림이 전송됩니다. 이것이 제가 Part 2에서 다룰 Self-Healing (자가 치유)의 트리거가 됩니다.
LLM — Gemini와 Claude Code, 두 가지 서로 다른 형태
마지막 축은 LLM 관측 가능성(Observability)입니다. cortex는 Gemini API와 Claude Code(Anthropic의 공식 CLI)를 모두 헤비하게 사용하며, 두 가지 모두 비용이 발생합니다. 제가 이들을 서로 다른 형태로 설계한 이유는 단순히 "어떤 종류의 질문인가"의 문제가 아니라, **어디에서 계측(instrument)할 수 있는가 — 즉, 계측 지점(instrumentation locus)**의 문제입니다.
- Gemini — 호출 코드(calling code)를 제가 소유하고 있으므로, 모든 호출을 공통 헬퍼(helper)로 감싸고 인라인(inline)으로 메트릭(metrics)을 방출할 수 있습니다. Prometheus가 자연스러운 선택입니다.
- Claude Code — 외부 CLI입니다. 내부에서 호출을 감쌀 수 없습니다. 사용 기록은 사후에 레코드(records) 형태로 나타납니다. 구조화된 저장소(BigQuery)가 자연스러운 선택입니다.
질문의 프레임워크가 "실시간 vs SQL 집계"로 나뉘는 것은 계측할 수 있는 위치에 따른 결과이지, 원인이 아닙니다. 이 점을 명확히 한 상태에서, 각각이 어떻게 구현되는지 살펴보겠습니다.
Gemini — Prometheus, 클라이언트 측 추정을 통한 실시간 비용 가시화
cortex는 db-graph 테이블 설명 생성, code-graph 필드 타입 추론, 일반 컨텍스트 생성 등 모든 곳에 Gemini를 사용합니다. 제가 보고 싶은 것은 지연 없이 지금 당장 무엇이 비용을 많이 쓰고 있는가입니다. 통제 불능의 프롬프트나 배치 작업(batch job)이 시작되었을 때, 내일 나오는 청구 보고서까지 기다리고 싶지 않습니다.
따라서 모든 Gemini 호출은 호출당 4개의 메트릭을 방출하는 공통 래퍼(traceGeminiCall)를 거칩니다:
gemini.tokens.total— 누적 토큰 (레이블:model/service/type=prompt|completion)gemini.requests.total— 요청 횟수 (레이블:model/service/status)gemini.request.duration— 지연 시간 히스토그램 (latency histogram)gemini.cost.usd— 추정 비용 (레이블:model/service)
의견이 갈리는 설계 선택 사항은 다음과 같습니다: 누가 비용을 계산하는가? 두 가지 옵션이 있습니다:
- A. 사후에 Google Cloud Billing API로부터 가져오기 (Pull from Google Cloud Billing API after the fact) — 정확하지만, 과금 데이터가 몇 시간에서 하루 정도 지연되며, 태스크별 비용 세분화 (per-task cost granularity)가 불가능함
- B. 토큰 수 × 가격표를 사용하여 클라이언트 측에서 계산하기 (Compute client-side from token counts × a price table) — 즉각적이며, 사용자가 직접 태스크별 세분화 정보를 부여할 수 있지만, 가격표를 계속 관리해야 함
저는 B를 선택했습니다. 가격표는 GEMINI_PRICING이라는 상수에 저장되며, Google이 가격을 변경할 때마다 수동으로 업데이트합니다. 단순히 gemini-3-flash / gemini-3-pro 각각의 입력/출력 단위 가격을 담고 있습니다. 복잡한 방식은 아닙니다.
B를 선택한 진짜 이유는 단순히 속도 때문이 아니라, 바로 태스크별 세분화 (per-task granularity) 때문입니다:
- 귀속(attribute)시킬 수 없는 것은 튜닝할 수 없습니다. Cloud Billing (BigQuery (BQ) 내보내기 포함)은 SKU, 프로젝트, 리소스 라벨(resource label) 단위로 데이터를 나누어 주지만, 호출 지점의 컨텍스트(call-site context) 단위로는 나누어 주지 않습니다. 실제로 비용을 절감하고 싶은 대상은 "db-graph 테이블 설명 생성에 $X 소요", "code-graph 필드 타입 추론에 $Y 소요", "특정 프롬프트 하나에 $Z 소요"와 같은 것입니다. 즉, 호출 지점 컨텍스트 단위의 세분화가 필요한데, 일반적인 과금 시스템은 이를 제공하지 않습니다. 클라이언트 측 래퍼(client-side wrapping)를 사용하면
service/model/ 호출 지점 컨텍스트를 라벨(label)로 붙일 수 있으며, 나중에 PromQL을 통해 이 중 어떤 것으로든 데이터를 슬라이싱(slice)할 수 있습니다. - 실시간 가시성(Real-time visibility)은 보너스입니다. 통제 불능 상태의 프롬프트나 배치(batch) 작업은 내일의 과금 데이터를 기다려주지 않습니다.
- 가격표 유지보수는 가볍습니다 (Google은 가격을 자주 변경하지 않음). 따라서 관리 비용은 미미합니다.
- Cloud Billing API 인증, 데이터 가져오기, 정규화(normalization), 팬아웃(fan-out) 작업은 그 자체로 관리해야 할 별도의 무거운 파이프라인이 됩니다.
그다음 저는 gemini_cost_usd_USD_total을 누적 Prometheus 카운터(cumulative Prometheus counter)로 방출합니다. (usd_USD가 중복되는 이유는 OTel 메터 이름인 gemini.cost.usd가 Prometheus 익스포터(exporter) 변환 과정에서 단위인 USD와 결합되었기 때문입니다.) 그러면 PromQL을 통해 "지난 1시간 동안 얼마를 썼는가?"라는 질문에 sum(increase(gemini_cost_usd_USD_total[1h]))로 직접 답할 수 있습니다. 시간당 $1가 넘으면 Slack으로 'info' 심각도(severity) 알람이 울리도록 설정합니다. 아주 간단하죠.
질문이 "지금 당장"에 관한 것이라면 Prometheus가 정답입니다.
Claude Code — BigQuery로 전송, SQL 집계(Aggregation)를 위해 구축됨
회사의 모든 개발자가 Claude Code를 사용합니다. 비용도 발생합니다. 저는 누가, 얼마나, 어떤 리포지토리(repo)에서, 몇 개의 토큰을 사용했는지 알고 싶습니다.
의견이 갈렸던 질문은 이것이었습니다: "Claude Code 사용량도 Loki로 보내야 할까요?"
답은: 아니요, BigQuery로 보냅니다.
이유는 무엇일까요? Claude Code 사용량은 근본적으로 **구조화된 원장 (structured ledger)**이기 때문입니다:
email— 사용자repository— 사용된 리포지토리timestamp— 시점input_tokens/output_tokenscache_creation_input_tokens/cache_read_input_tokens— 프롬프트 캐시 (prompt-cache) 효율성 포함
그리고 여러분이 던지고 싶은 질문들은 다음과 같은 형태일 것입니다:
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기