LabHub

블로그

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

한국어English日本語中文

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

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

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

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

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

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

여기에 최근 연속 프로파일링(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 } }
      ]
    }
  }
}

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

언제 무엇을 고를까

구조화 로깅과 로그 레벨

어느 저장소를 쓰든 공통 원칙이 있습니다. 로그는 사람이 읽는 문장이 아니라 기계가 파싱할 구조화된 데이터(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)을 푸는 것이 핵심 가치입니다.

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

컨텍스트 전파 — 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에서 중앙집중식으로 관리합니다.

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

최소한의 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

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

샘플링 — head vs tail

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

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

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

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가 주는 핵심 기능은 이렇습니다.

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

Langfuse만 있는 것은 아닙니다.

고르는 기준은 대체로 이렇습니다. 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를 실어 두면 일반 트레이스와도 이을 수 있습니다.

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

결정 체크리스트

마치며

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

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

참고 자료

댓글

아직 댓글이 없습니다.

로그인하면 댓글을 쓸 수 있습니다