Skip to content
Published on

관측 가능성 완전 정복: 로그·트레이싱·LLM 모니터링

Authors

들어가며 — 관측 가능성이란 무엇인가

시스템이 느려졌습니다. 사용자는 "결제가 안 된다"고 합니다. 그런데 서버는 멀쩡히 돌아가고, CPU도 정상이고, 에러 로그도 딱히 없습니다. 대체 어디가 문제일까요? 이 질문에 답하는 능력이 바로 **관측 가능성(observability)**입니다.

관측 가능성은 모니터링과 자주 혼동됩니다. 모니터링이 "미리 정해 둔 지표가 정상 범위인가"를 보는 것이라면, 관측 가능성은 "시스템 바깥에서 던지는 임의의 질문에 내부 상태로 답할 수 있는가"입니다. 전자는 이미 아는 문제(known unknowns)를 감시하고, 후자는 예상치 못한 문제(unknown unknowns)를 파고듭니다.

이 능력을 만드는 재료가 흔히 **세 기둥(three pillars)**이라 불리는 로그, 메트릭, 트레이스입니다. 이 글은 세 기둥을 하나씩 짚고, 로그 저장소(Loki vs OpenSearch)와 분산 트레이싱(OpenTelemetry, Jaeger, Tempo)의 선택지를 대조한 뒤, 요즘 새로 떠오른 LLM 관측 가능성(Langfuse 등)까지 이어서 정리합니다.

세 기둥 — 로그, 메트릭, 트레이스

세 신호는 같은 사건을 서로 다른 각도에서 기록합니다.

  • 로그(logs): 특정 시점에 일어난 개별 사건의 기록입니다. "12:03:11에 주문 4821 결제 실패, 이유: 카드 거절." 가장 상세하지만 양이 폭발하기 쉽습니다.
  • 메트릭(metrics): 시간에 따라 집계된 수치입니다. "초당 요청 수", "p99 지연시간", "에러율". 저장이 싸고 대시보드·알림에 적합하지만, 개별 사건의 맥락은 없습니다.
  • 트레이스(traces): 하나의 요청이 여러 서비스를 거치는 전체 여정입니다. API 게이트웨이에서 시작해 주문 서비스, 결제 서비스, DB를 지나며 각 구간이 얼마나 걸렸는지 보여줍니다.

여기에 최근 **연속 프로파일링(continuous profiling)**이 네 번째 기둥으로 자주 거론됩니다. 프로덕션에서 CPU·메모리를 함수 단위로 상시 샘플링해, "어느 함수가 CPU를 먹는가"를 트레이스보다 더 깊은 코드 레벨에서 답합니다. Grafana Pyroscope, Parca 같은 도구가 대표적입니다.

핵심은 세 신호를 따로 보는 것이 아니라 엮어서 보는 것입니다. 메트릭에서 "에러율이 튀었다"를 발견하고, 그 시점의 트레이스로 넘어가 "결제 서비스가 느렸다"를 확인하고, 그 트레이스에 걸린 로그로 "카드사 타임아웃"이라는 원인을 읽습니다. 이 흐름이 관측 가능성의 정수입니다.

신호는 어떻게 연결되나 — trace_id와 exemplar

세 신호를 엮는 접착제는 상관관계 식별자(correlation ID), 그중에서도 trace_id입니다.

가장 실용적인 연결은 로그에 trace_id를 심는 것입니다. 구조화 로그의 필드로 trace_id를 함께 남기면, 특정 트레이스를 보다가 "이 요청이 남긴 로그만" 필터링할 수 있습니다. 반대로 에러 로그를 보다가 그 trace_id로 전체 트레이스를 열어 볼 수도 있습니다.

{
  "timestamp": "2026-07-03T12:03:11.482Z",
  "level": "ERROR",
  "service": "payment-service",
  "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
  "span_id": "00f067aa0ba902b7",
  "message": "card declined",
  "order_id": 4821
}

메트릭과 트레이스는 **exemplar(견본)**로 연결됩니다. exemplar는 메트릭 데이터 포인트에 대표 trace_id를 하나 붙여 두는 기법입니다. 예를 들어 "p99 지연시간" 히스토그램의 느린 버킷에 실제 느렸던 요청의 trace_id를 달아 두면, 대시보드에서 그래프의 튀는 지점을 클릭해 바로 그 트레이스로 점프할 수 있습니다. 메트릭(무엇이)에서 트레이스(왜)로 넘어가는 다리입니다.

이 연결이 되도록 만드는 것이 관측 가능성 설계의 절반입니다. 신호가 아무리 많아도 서로 이어지지 않으면, 문제를 만났을 때 세 개의 분리된 창을 눈으로 대조하는 고통이 남습니다.

로그 — Loki vs OpenSearch

로그를 어디에 어떻게 저장하느냐는 비용과 검색 경험을 크게 가릅니다. 대표적인 두 진영이 Grafana Loki와 OpenSearch(구 Elasticsearch 계열)입니다.

Loki — "로그계의 Prometheus"

Grafana Loki의 철학은 한 문장으로 "로그 본문은 인덱싱하지 않는다"입니다. Loki는 로그의 전체 텍스트에 역색인을 만들지 않고, 대신 **레이블(label)**만 인덱싱합니다. 로그 본문 자체는 압축해 값싼 오브젝트 스토리지(S3, GCS 등)에 그대로 던져 둡니다. 그래서 Prometheus가 메트릭을 레이블로 다루듯, Loki는 로그를 레이블로 다룹니다. "로그계의 Prometheus"라는 별명이 여기서 나옵니다.

질의 언어는 LogQL입니다. 먼저 레이블로 로그 스트림을 좁힌 뒤, 그 안에서 텍스트를 필터링합니다.

{app="payment-service", level="error"} |= "card declined" | json | order_id="4821"

여기서 앞의 중괄호 부분이 레이블 셀렉터(인덱스가 걸린 부분)이고, 뒤의 |=부터가 본문 필터입니다. 이 구조 덕분에 인덱스가 작아 저장 비용이 낮고 운영이 가볍습니다. 대신 레이블로 충분히 좁히지 못한 채 방대한 기간을 전문검색하면 느려질 수 있습니다. 레이블 설계가 성패를 가릅니다.

주의할 함정은 높은 카디널리티(high cardinality) 레이블입니다. user_id나 trace_id처럼 값의 종류가 폭발하는 것을 레이블로 쓰면, 스트림이 수백만 개로 쪼개져 Loki가 급격히 느려집니다. 그런 값은 레이블이 아니라 로그 본문에 넣고 필터로 찾아야 합니다.

OpenSearch — 강력한 전문검색

OpenSearch(그리고 그 뿌리인 Elasticsearch)는 정반대 접근입니다. 로그의 거의 모든 필드에 **역색인(inverted index)**을 만듭니다. 덕분에 임의의 필드로 복잡한 전문검색, 집계, 패싯 분석을 빠르게 할 수 있고, Kibana/OpenSearch Dashboards라는 강력한 탐색 UI가 붙습니다.

같은 질의를 OpenSearch 쿼리 DSL로 쓰면 이런 모습입니다.

{
  "query": {
    "bool": {
      "must": [
        { "match": { "service": "payment-service" } },
        { "match": { "message": "card declined" } },
        { "term": { "order_id": 4821 } }
      ]
    }
  }
}

대가는 무게입니다. 모든 것을 인덱싱하니 저장 공간과 메모리를 많이 쓰고, 클러스터 운영(샤드, 힙, 리밸런싱)도 손이 많이 갑니다. 로그 양이 커질수록 인프라 비용이 가파르게 오릅니다.

언제 무엇을 고를까

  • Loki: 비용에 민감하고, 로그를 주로 "레이블로 좁혀 최근 구간을 훑는" 방식으로 쓰며, 이미 Grafana·Prometheus 생태계를 쓰고 있다면 자연스럽습니다.
  • OpenSearch: 임의 필드로 강력한 전문검색과 복잡한 집계·분석이 자주 필요하고(보안 로그 분석, 감사, SIEM 등), 그만한 인프라 비용을 감당할 수 있다면 강력합니다.

구조화 로깅과 로그 레벨

어느 저장소를 쓰든 공통 원칙이 있습니다. 로그는 사람이 읽는 문장이 아니라 **기계가 파싱할 구조화된 데이터(JSON)**로 남기는 것이 좋습니다. "2026-07-03 payment failed for order 4821" 같은 문자열보다, 위에서 본 것처럼 필드가 나뉜 JSON이 필터·집계에 압도적으로 유리합니다.

로그 레벨(DEBUG, INFO, WARN, ERROR)은 소음을 통제하는 첫 번째 다이얼입니다. 그리고 트래픽이 큰 서비스라면 샘플링이 필수입니다. 성공한 요청의 INFO 로그를 100% 남길 필요는 없습니다. 예를 들어 정상 요청은 1%만 샘플링하고, 에러는 100% 남기는 식으로, 비용과 정보량의 균형을 잡습니다.

분산 트레이싱 — OpenTelemetry가 표준이 되다

마이크로서비스에서 하나의 사용자 요청은 수많은 서비스를 거칩니다. 이 여정을 재구성하는 것이 분산 트레이싱이고, 이 분야의 사실상 표준이 **OpenTelemetry(OTel)**입니다.

왜 OpenTelemetry인가

OpenTelemetry는 CNCF 산하의 벤더 중립 표준입니다. 예전에는 관측 도구마다 계측 SDK가 따로여서, 백엔드를 바꾸면 코드의 계측을 전부 갈아엎어야 했습니다. OTel은 이 계측 계층을 표준화했습니다. 코드는 OTel로 한 번만 계측하고, 데이터를 어디로 보낼지(Jaeger, Tempo, 상용 벤더 등)는 나중에 익스포터 설정만 바꿔 정합니다. 잠금(lock-in)을 푸는 것이 핵심 가치입니다.

트레이싱의 기본 개념은 이렇습니다.

  • 트레이스(trace): 하나의 요청 전체 여정. 고유한 trace_id로 식별됩니다.
  • 스팬(span): 트레이스를 이루는 하나의 작업 단위(예: "DB 쿼리", "결제 API 호출"). 시작·종료 시각을 가집니다.
  • 부모-자식 관계: 스팬은 중첩됩니다. "주문 처리" 스팬 안에 "재고 확인", "결제" 스팬이 자식으로 들어갑니다. 이 트리가 트레이스의 형태를 만듭니다.
  • 속성(attributes)과 이벤트(events): 스팬에 붙이는 키-값 메타데이터(예: http.method, db.system)와 스팬 수명 중 일어난 시점 기록입니다.

컨텍스트 전파 — traceparent 헤더

서비스 A가 서비스 B를 호출할 때, 두 서비스의 스팬이 같은 트레이스로 이어지려면 trace_id가 넘어가야 합니다. 이 **컨텍스트 전파(context propagation)**의 표준이 W3C Trace Context, 즉 HTTP 요청에 실리는 traceparent 헤더입니다. 값은 대략 버전, trace_id, 부모 span_id, 플래그를 하이픈으로 이은 형태입니다.

traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
             │  │                                │                │
          버전  trace_id (128비트)              부모 span_id      플래그(샘플됨)

수신 서비스는 이 헤더를 읽어 자기 스팬을 같은 trace_id 아래 자식으로 붙입니다. 이렇게 서비스 경계를 넘어 트레이스가 하나로 이어집니다.

OTel Collector와 계측 방식

OTel Collector는 애플리케이션과 백엔드 사이에 두는 독립 프로세스입니다. 여러 서비스가 보낸 텔레메트리를 받아서(receive), 가공하고(process: 배치, 샘플링, 속성 편집), 원하는 백엔드로 내보냅니다(export). 앱은 Collector 하나만 알면 되고, 백엔드 교체·다중 전송·샘플링 정책을 Collector에서 중앙집중식으로 관리합니다.

계측에는 두 방식이 있습니다.

  • 자동 계측(auto-instrumentation): 언어 에이전트가 인기 라이브러리(HTTP 서버, DB 드라이버, gRPC 등)를 자동으로 후킹해 스팬을 만들어 줍니다. 코드 수정이 거의 없어 빠르게 시작할 수 있습니다.
  • 수동 계측(manual instrumentation): SDK로 직접 스팬을 열고 속성을 붙입니다. 비즈니스 로직의 의미 있는 구간을 세밀하게 잡을 때 필요합니다.

최소한의 OTel SDK 초기화는 대략 이런 모습입니다(Node.js 예).

const { NodeSDK } = require("@opentelemetry/sdk-node");
const {
  getNodeAutoInstrumentations,
} = require("@opentelemetry/auto-instrumentations-node");
const {
  OTLPTraceExporter,
} = require("@opentelemetry/exporter-trace-otlp-http");

const sdk = new NodeSDK({
  traceExporter: new OTLPTraceExporter({
    url: "http://otel-collector:4318/v1/traces",
  }),
  instrumentations: [getNodeAutoInstrumentations()],
});

sdk.start();

백엔드 — Jaeger와 Tempo

수집된 트레이스는 트레이싱 백엔드에 저장·조회합니다.

  • Jaeger: Uber에서 출발한 성숙한 오픈소스 트레이싱 시스템입니다. 자체 UI로 트레이스 검색·의존성 그래프·타임라인을 제공합니다.
  • Grafana Tempo: Loki의 트레이스판에 해당합니다. 트레이스를 인덱싱하지 않고 값싼 오브젝트 스토리지에 통째로 저장해 비용을 크게 낮춥니다. trace_id로 조회하는 것이 기본이며, Loki·Prometheus와 Grafana에서 매끄럽게 연동됩니다.

샘플링 — head vs tail

트레이스를 100% 저장하면 양과 비용이 감당이 안 됩니다. 그래서 샘플링을 합니다.

  • 헤드 샘플링(head-based): 트레이스 시작 시점에 "이건 저장, 저건 버림"을 즉시 결정합니다. 예를 들어 "1%만 저장". 단순하고 싸지만, 정작 문제 있는(느리거나 에러 난) 트레이스를 버릴 수 있습니다.
  • 테일 샘플링(tail-based): 트레이스가 끝난 뒤 전체를 보고 결정합니다. "에러가 있거나 p99보다 느린 트레이스는 무조건 저장" 같은 규칙을 걸 수 있어, 정상 트래픽은 버리고 문제 트래픽은 확실히 남깁니다. 대신 트레이스가 끝날 때까지 버퍼링해야 하므로 Collector에 부하와 메모리가 더 듭니다.

LLM 관측 가능성 — 왜 특별한 도구가 필요한가

여기서 요즘 급부상한 새 영역으로 넘어갑니다. LLM 애플리케이션(챗봇, RAG, 에이전트)은 기존 관측 도구만으로는 부족합니다. 이유가 분명합니다.

  • 비결정성: 같은 입력에도 출력이 매번 다릅니다. "성공/실패"라는 이진 판단으로는 품질을 잡을 수 없습니다.
  • 프롬프트·버전 드리프트: 프롬프트를 한 줄 바꿨더니 품질이 무너지는 일이 흔합니다. 어떤 프롬프트 버전이 어떤 결과를 냈는지 추적해야 합니다.
  • 비용: LLM 호출은 토큰 단위로 과금됩니다. 어떤 요청이, 어떤 체인 단계가 토큰을 얼마나 먹는지 봐야 비용을 통제합니다.
  • 품질 평가: 지연시간이 정상이어도 답이 틀리거나 헛것을 지어낼(hallucination) 수 있습니다. 정답 여부를 별도로 평가(eval)해야 합니다.
  • RAG 디버깅: 답이 이상할 때, 원인이 검색(retrieval)이 엉뚱한 문서를 물어온 탓인지, 생성(generation)이 문서를 무시한 탓인지 구분해야 합니다.

Langfuse — LLM 호출의 트레이스

Langfuse는 이 문제를 정면으로 다루는 오픈소스 LLM 관측 플랫폼입니다. 개념적으로는 분산 트레이싱과 닮았습니다. 하나의 사용자 상호작용을 하나의 트레이스로 잡고, 그 안의 각 단계를 중첩된 스팬으로 기록합니다. 다만 스팬의 내용이 LLM에 특화되어 있습니다. 각 LLM 호출마다 프롬프트, 완성 결과, 토큰 사용량, 비용, 지연시간을 담고, 체인·에이전트·검색 단계를 부모-자식으로 중첩합니다.

RAG 파이프라인 하나의 트레이스 형태를 아주 단순화하면 이렇습니다.

{
  "trace_id": "t_9f2c",
  "name": "rag-chat",
  "input": "환불 정책이 어떻게 되나요?",
  "spans": [
    {
      "name": "retrieval",
      "type": "retriever",
      "input": "환불 정책",
      "output": ["doc-12", "doc-45"],
      "latency_ms": 82
    },
    {
      "name": "llm-answer",
      "type": "generation",
      "model": "claude-x",
      "prompt_tokens": 1240,
      "completion_tokens": 180,
      "cost_usd": 0.0042,
      "latency_ms": 1310
    }
  ],
  "scores": [{ "name": "helpfulness", "value": 0.9 }]
}

Langfuse가 주는 핵심 기능은 이렇습니다.

  • 중첩 트레이스: 체인·에이전트·툴 호출·검색을 계층 구조로 시각화합니다. 에이전트가 어떤 도구를 몇 번 불렀는지 한눈에 보입니다.
  • 프롬프트 버전 관리: 프롬프트를 버전별로 저장·배포하고, 어떤 버전이 어떤 트레이스를 냈는지 연결합니다.
  • 평가와 점수(evals/scores): 트레이스에 점수를 붙입니다. LLM-as-a-judge 자동 평가, 규칙 기반 점수, 사람 라벨링을 모두 담을 수 있습니다.
  • 사용자 피드백: 최종 사용자의 좋아요/싫어요 같은 피드백을 해당 트레이스에 연결해 실제 품질 신호로 씁니다.

다른 선택지들 — LangSmith, Helicone, Phoenix

Langfuse만 있는 것은 아닙니다.

  • LangSmith: LangChain 팀이 만든 상용 플랫폼입니다. LangChain/LangGraph와 긴밀하게 통합되고, 트레이싱·평가·데이터셋 관리가 촘촘합니다.
  • Helicone: 프록시 방식이 특징입니다. LLM API 앞에 프록시로 얹으면 코드 변경을 최소화하면서 로깅·캐싱·비용 추적을 붙일 수 있습니다.
  • Arize Phoenix: 오픈소스이며 OpenTelemetry 기반(OpenInference)이라는 점이 강점입니다. 트레이싱과 평가에 더해, 임베딩·드리프트 분석 같은 ML 관측 기능이 강합니다.

고르는 기준은 대체로 이렇습니다. LangChain 중심이면 LangSmith, 코드 변경 없이 빠르게 붙이려면 Helicone, 오픈소스와 OTel 표준을 중시하면 Phoenix나 Langfuse가 자연스럽습니다.

하나로 합치기 — OpenTelemetry 중심 스택

이제 전체를 하나의 그림으로 엮어 봅시다. 요즘 흔한 구성은 OpenTelemetry를 허브로 두는 것입니다.

  앱(서비스들) ── OTel SDK ──▶ [ OTel Collector ]
              ┌─────────────────────┼─────────────────────┐
              ▼                     ▼                     ▼
        [ Loki (로그) ]     [ Tempo (트레이스) ]   [ Prometheus (메트릭) ]
              └───────────── Grafana 로 통합 조회 ──────────────┘

  LLM 호출 ─────────────────▶ [ Langfuse (LLM 트레이스·비용·평가) ]

핵심 원리는 앞서 강조한 trace_id 상관관계입니다. 앱은 OTel로 계측해 Collector로 보내고, Collector가 로그는 Loki, 트레이스는 Tempo, 메트릭은 Prometheus로 나눠 보냅니다. Grafana에서 셋을 한 화면에 놓고, trace_id로 로그↔트레이스를, exemplar로 메트릭→트레이스를 오갑니다. LLM 부분은 Langfuse가 별도로 잡되, 같은 trace_id를 실어 두면 일반 트레이스와도 이을 수 있습니다.

자주 걸리는 함정 — 비용과 카디널리티

  • 카디널리티 폭발: 메트릭 레이블이나 Loki 레이블에 user_id, request_id, trace_id처럼 값이 무한히 늘어나는 것을 넣으면 시계열·스트림이 폭증해 저장소가 무너집니다. 고카디널리티 값은 로그 본문이나 트레이스 속성에 넣고, 레이블은 종류가 제한된 것(서비스명, 지역, 상태코드 등)만 씁니다.
  • 무분별한 100% 저장: 모든 로그·트레이스를 전량 저장하면 비용이 감당 안 됩니다. 정상은 샘플링하고 문제(에러·느린 요청)는 확실히 남기는 정책을 초기에 세웁니다.
  • 연결되지 않은 신호: trace_id를 로그에 심지 않으면 세 기둥이 따로 놉니다. 계측 초기에 trace_id 주입을 반드시 넣으세요.
  • LLM 비용 방치: 프롬프트가 길어지거나 재시도가 늘면 토큰 비용이 조용히 폭증합니다. Langfuse 같은 도구로 요청·단계별 비용을 상시 관찰합니다.

결정 체크리스트

  • 로그 저장소: 비용·레이블 중심이면 Loki, 강력한 전문검색·분석이면 OpenSearch.
  • 계측: 새로 시작한다면 처음부터 OpenTelemetry로. 벤더 잠금을 피할 수 있습니다.
  • 트레이스 백엔드: 저비용·Grafana 생태계면 Tempo, 독립적이고 성숙한 UI면 Jaeger.
  • 샘플링: 문제 트레이스를 놓치기 싫다면 테일 샘플링, 단순·저비용이면 헤드 샘플링.
  • LLM: LLM 앱을 운영한다면 별도로 Langfuse/LangSmith/Helicone/Phoenix 중 하나를 반드시 도입.
  • 상관관계: 무엇을 쓰든 trace_id를 모든 로그에 심어 신호를 잇기.

마치며

관측 가능성은 도구 하나로 끝나는 것이 아니라, 로그·메트릭·트레이스라는 세 기둥(그리고 떠오르는 프로파일링)을 trace_id로 엮어 "임의의 질문에 답할 수 있는 시스템"을 만드는 일입니다. 로그는 Loki와 OpenSearch가 비용과 검색력의 양극을 이루고, 트레이싱은 OpenTelemetry가 표준을 통일하며 Jaeger·Tempo가 이를 받아 주고, 그 위에 LLM이라는 새 층을 Langfuse 같은 도구가 채워 갑니다.

핵심은 "무엇이 최고인가"가 아니라 "무엇이 이 시스템에 맞는가", 그리고 무엇보다 "신호들이 서로 연결되어 있는가"입니다. 세 기둥을 각각 잘 세우고 trace_id로 단단히 이어 두면, 다음번 새벽 3시의 장애에서 세 개의 창을 눈으로 대조하는 대신, 한 번의 클릭으로 원인까지 내려갈 수 있습니다.

참고 자료