로그는 왜 구조여야 하는가 — Observability 로깅의 설계 철학
텍스트 grep에서 JSON 필드 검색까지, 구조화 로그가 장애 대응 속도를 어떻게 바꾸는지 — MDC 전파, Loki 인덱스 설계, 동적 레벨 변경까지 추적한다.
- 01 Observability는 왜 Monitoring과 다른가
- 02 Java Agent는 코드 한 줄 없이 어떻게 계측하는가
- 03 분산 추적은 어떻게 서비스 경계를 넘는가
- 04 Prometheus는 왜 Pull 방식인가
- 05 로그는 왜 구조여야 하는가 — Observability 로깅의 설계 철학
- 06 관찰 가능성 스택은 어떻게 하나로 연결되는가
- 07 Observability는 왜 세 신호가 함께여야 하는가
새벽 3시 장애 알림. grep "ERROR" /var/log/app.log — 수백만 줄을 뒤진다. 수 분이 지난다. 원인을 찾지 못한다. 로그가 없어서가 아니다. 로그가 검색할 수 없는 형태이기 때문이다. 구조화 로그는 단순히 “예쁜 형식”이 아니다 — 이 선택 하나가 장애 대응 속도와 시스템 관찰 가능성의 전부를 결정한다. 그렇다면 로그를 구조화한다는 것은 정확히 무엇을 바꾸는 일인가?
텍스트 로그의 한계
텍스트 로그는 사람이 읽기 위해 설계됐다. 기계는 읽을 수 없다. grep은 정규식으로 줄 전체를 스캔한다 — orderId가 어느 위치에 있는지 모른 채. 100만 줄 중 특정 주문 번호의 에러를 찾으려면 전체를 뒤져야 한다.
JSON 로그는 반대다. 모든 정보가 이름 붙은 필드로 저장된다.
{
"timestamp": "2024-01-15T09:23:45.123Z",
"level": "ERROR",
"service": "order-service",
"message": "주문 처리 실패",
"orderId": "99437",
"traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
"duration": 234
}
Loki에서 {service="order-service"} | json | level="ERROR" | orderId="99437"으로 100ms 안에 결과를 돌려받는다. 텍스트 grep이 수 분 걸리던 작업이다.
필수 필드는 최소 여섯 가지다 — timestamp, level, service, message, traceId, spanId. traceId와 spanId는 단순한 메타데이터가 아니다. 로그와 분산 트레이스를 연결하는 열쇠다.
MDC — traceId를 모든 로그에 자동으로
traceId를 모든 로그에 수동으로 넣는 것은 불가능하다. MDC(Mapped Diagnostic Context)가 이 문제를 해결한다.
MDC는 스레드 로컬 Map이다. MDC.put("traceId", "abc123")을 한 번 호출하면, 이후 같은 스레드에서 출력되는 모든 로그에 traceId 필드가 자동으로 포함된다. Logback이 LoggingEvent를 직렬화할 때 MDC Map을 JSON 필드로 병합한다.
Spring Boot 3.x + Micrometer Tracing 조합에서는 이 작업이 자동이다. OTel Agent도 마찬가지다 — Span이 시작될 때 traceId와 spanId를 MDC에 삽입하고, Span이 끝날 때 제거한다. 코드 한 줄 없이 모든 로그에 트레이스 컨텍스트가 붙는다.
단, @Async나 CompletableFuture는 새 스레드를 만들기 때문에 MDC가 전달되지 않는다.
// MDC가 유실되는 패턴
CompletableFuture.runAsync(() -> {
log.info("비동기 처리"); // traceId 없음
});
// MDC를 명시적으로 전파
Map<String, String> mdcCopy = MDC.getCopyOfContextMap();
CompletableFuture.runAsync(() -> {
MDC.setContextMap(mdcCopy);
try {
log.info("비동기 처리"); // traceId 있음
} finally {
MDC.clear();
}
});
finally { MDC.clear() }는 선택이 아니다. 스레드풀이 스레드를 재사용할 때 이전 요청의 traceId가 다음 요청 로그에 섞이는 것을 막는다.
Loki — 무엇을 인덱싱하고 무엇을 스캔하는가
Grafana Loki는 로그 내용을 인덱싱하지 않는다. 레이블만 인덱싱한다. 이 선택이 Elasticsearch 대비 스토리지 비용을 약 20배 낮추는 이유다.
Loki의 데이터 모델은 Prometheus와 동형이다.
Prometheus: {레이블 집합} → [(timestamp, float64)]
Loki: {레이블 집합} → [(timestamp, log string)]
{service="order-service", env="prod"} 같은 레이블 조합이 스트림을 결정한다. Loki는 이 스트림 ID만 인덱싱하고, 로그 내용은 스냅피(snappy) 압축 청크에 순차 저장한다.
쿼리 실행 순서가 성능을 결정한다.
{service="order-service"} | json | level="ERROR" | duration > 1000
- 레이블 인덱스 조회 → 스트림 ID (빠름, O(1))
- 시간 범위에 해당하는 청크 선택
- 청크 압축 해제 + JSON 파싱
level,duration필터 적용
레이블 선택자 범위가 넓을수록 스캔할 청크가 많아진다. {} (레이블 없음)으로 시작하면 전체 로그를 뒤진다.
traceId나 userId를 Loki 레이블로 사용하면 스트림이 폭발한다. 수백만 종류의 레이블 값 = 수백만 개의 스트림 = Loki 인덱스 OOM. 이 값들은 JSON 필드로 저장하고 | json | traceId="..." 형태로 검색해야 한다. 레이블은 낮은 카디널리티(service, env, level)만 사용한다.
로그 수집 파이프라인 — 유실 없이 전달하기
서비스가 Loki에 직접 로그를 push하면 Loki 장애 시 로그가 유실된다. 서비스가 재시도 로직을 직접 구현해야 한다. 이 책임은 수집기에게 위임해야 한다.
표준 패턴은 서비스 → stdout/파일 → 수집기 → Loki다. 수집기(Promtail, Filebeat, Fluentd)가 오프셋을 추적하고, 버퍼를 관리하고, Loki 장애 시 재시도한다.
Kubernetes에서는 DaemonSet으로 노드당 수집기 1개를 배포한다. 핵심은 오프셋 파일을 영구 볼륨에 마운트하는 것이다. Pod가 재시작돼도 이전에 읽은 위치부터 이어서 수집한다.
Loki 전용 스택이라면 Promtail이 가장 단순하다. 복잡한 변환이나 다중 목적지가 필요하면 Fluentd, 리소스 제약이 있으면 Fluent Bit을 선택한다.
트레이드오프 — 구조화 로그의 대가
구조화 로그는 공짜가 아니다.
JSON 로그는 텍스트 로그보다 4~10배 크다. 그러나 Loki의 snappy 압축이 약 10:1 비율로 상쇄한다. 실제 스토리지 비용 차이는 크지 않다.
개발 환경 가독성은 나빠진다. {"level":"INFO","message":"주문 처리 시작","orderId":"99437"}은 INFO - 주문 처리 시작 orderId=99437보다 읽기 어렵다. Spring Profile로 분리한다 — 운영은 JSON, 개발은 텍스트 패턴.
비동기 Appender를 쓰지 않으면 로깅이 메인 스레드를 점유한다. DEBUG 레벨에서 처리량이 최대 25% 감소하는 주된 이유다. Log4j2 AsyncLogger나 Logback AsyncAppender로 로그 I/O를 별도 스레드로 분리하면 DEBUG 부하를 5% 수준으로 낮출 수 있다.
동적 로그 레벨 변경은 이 비용을 관리하는 핵심 도구다. Spring Boot Actuator의 /actuator/loggers API로 재배포 없이 특정 패키지만 DEBUG로 전환할 수 있다.
# 결제 패키지만 DEBUG 전환 (즉시 적용)
curl -X POST http://service:8080/actuator/loggers/com.example.payment \
-H "Content-Type: application/json" \
-d '{"configuredLevel": "DEBUG"}'
# 원상 복구 (상위 레벨 상속)
curl -X POST http://service:8080/actuator/loggers/com.example.payment \
-d '{"configuredLevel": null}'
변경은 JVM 메모리에만 저장된다. 재시작 시 application.yml 설정으로 초기화된다 — 실수로 DEBUG를 켜둬도 Pod 재시작이 자연스러운 복원점이 된다. 복원을 보장하려면 sleep 300 && curl -d '{"configuredLevel": null}' ...를 백그라운드로 예약하는 것이 안전하다.
정리
- 구조화 로그(JSON)는 검색 속도 문제를 해결한다. 텍스트 grep의 수 분 → Loki JSON 필터의 수백 ms.
- MDC가
traceId를 모든 로그에 자동으로 전파한다. OTel Agent나 Micrometer Tracing이 있으면 별도 코드 없이 동작한다. 비동기 경계에서만 수동 전파가 필요하다. - Loki는 레이블만 인덱싱한다. 레이블 카디널리티를 낮게 유지하고,
traceId·userId는 JSON 필드로만 사용한다. - 동적 레벨 변경으로 장애 시 특정 패키지만 DEBUG로 전환하고, 원인 파악 후 즉시 복원한다.
다음 글에서는 이 로그들이 Grafana에서 메트릭·트레이스와 어떻게 연결되는지, 그리고 Loki derivedField 설정으로 traceId 클릭 한 번에 Tempo로 이동하는 드릴다운을 구성한다.