구조화 로깅이 왜 플랫폼 차원의 과제가 됐나
구조화 로깅은 처음엔 포맷을 바꾸는 일처럼 보인다. 문자열 로그를 JSON으로 바꾸면 끝나는 것처럼 느껴지기 때문이다. 하지만 실제로는 어떤 필드를 공통으로 남길지, 서비스 간 요청을 어떤 키로 연결할지, 비동기 작업에서도 그 문맥을 유지할지까지 함께 정해야 했다.
그래서 이 작업은 로깅 설정 변경이 아니라 플랫폼 기준을 고정하는 일에 더 가까웠다.
왜 공통 로그 필드가 먼저였나
여러 서비스가 같은 요청 흐름에 참여하는데 로그 형식이 모두 다르면, 장애 분석은 결국 사람의 기억과 추측에 의존하게 된다. 어느 서비스는 요청 경로를 남기고, 어느 서비스는 사용자 식별자를 남기고, 어느 서비스는 에러 코드만 남기면 검색이 아니라 해석이 필요해진다.
핵심은 JSON 채택 자체보다 어떤 공통 필드를 어디까지 강제할 것인가였다.
| 질문 | 전환 전 | 전환 후 |
|---|---|---|
| 어떤 요청의 로그인가 | 로그 문장을 읽어야 추정 가능 | 요청 경로, trace ID로 검색 가능 |
| 누가 유발한 작업인가 | 일부 서비스에서만 기록 | 사용자 맥락 필드를 공통화 |
| 여러 서비스를 어떻게 연결하나 | 시간순 추측 의존 | W3C trace context로 연결 |
Application log와 Access log 모두 JSON으로
이 전환에서 특히 중요했던 건 application log만이 아니라 access log도 함께 JSON으로 표준화한 점이다. 두 로그 모두 같은 trace_id와 span_id를 공유하기 때문에, 하나의 Splunk 쿼리로 요청의 전체 생명주기를 추적할 수 있게 됐다.
Application Log
Application log는 JSON으로 출력되며, OTEL Agent가 MDC에 자동 주입한 trace_id와 span_id가 포함된다.
{
"log_type": "application",
"level": "INFO",
"logger_name": "c.n.g.idm.service.IdentityService",
"message": "User activated successfully",
"userName": "[email protected]",
"uid": "abc-123",
"uri": "/api/v1/users/john.doe/activate",
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
"span_id": "00f067aa0ba902b7"
}log_type 필드가 application과 access를 구분하므로, 같은 trace ID로 검색하면 두 로그가 함께 나온다. 필요하면 log_type으로 필터링해서 한쪽만 볼 수도 있다.
W3C Trace Context는 어떻게 연결되나
서비스 간 요청을 연결하려면 표준화된 추적 키가 필요하다. W3C Trace Context는 traceparent 헤더를 통해 이 역할을 한다.
traceparent 헤더의 구조
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
── ──────────────────────────────── ──────────────── ──
ver trace_id (32자) parent_id (16자) flags| 필드 | 설명 |
|---|---|
version | 항상 00 (현재 버전) |
trace_id | 요청의 전체 흐름을 식별하는 32자리 hex. 서비스를 넘어도 동일 |
parent_id | 현재 작업 단위를 식별하는 16자리 hex. 서비스마다 새로 생성 |
trace_flags | 01이면 샘플링됨, 00이면 샘플링 안 됨 |
로그에 trace context가 도달하는 경로
Splunk OTEL Java Agent가 HTTP 요청의 traceparent 헤더에서 trace_id와 span_id를 추출해 SLF4J MDC에 자동으로 넣어준다. 이후 application log는 MDC에서 직접, access log는 TraceParentJsonProvider를 통해 같은 값을 JSON에 기록한다.
이 구조 덕분에 어떤 요청이든 trace_id 하나로 application log(비즈니스 로직)와 access log(HTTP 메타데이터)를 모두 찾을 수 있다.
로그 중앙화와 쿼리 표준화
로그를 JSON으로 바꾸는 것만으로는 부족했다. 그 로그가 어디로 흘러가고, 어떻게 검색되는지까지 표준화해야 했다.
ECS 태스크의 로그 드라이버를 Splunk 로깅 드라이버로 설정하면, 컨테이너의 stdout으로 출력된 JSON 로그가 Splunk HEC(HTTP Event Collector)를 통해 자동으로 수집된다. 애플리케이션은 stdout에 JSON을 쓰기만 하면 되고, 수집과 전송은 인프라가 처리한다.
Splunk의 JSON 자동 파싱
Splunk는 JSON 형식의 로그를 자동으로 파싱해서 각 필드를 검색 가능한 키-값으로 인덱싱한다. 별도의 파싱 규칙이나 정규식을 정의할 필요가 없다.
index=container_logs sourcetype="ecs-service-*"
trace_id="4bf92f3577b34da6a3ce929d0e0e4736"이 쿼리 하나로 해당 trace의 application log와 access log가 모두 검색된다. log_type으로 필터링하면 한쪽만 볼 수 있다.
index=container_logs sourcetype="ecs-service-*"
trace_id="4bf92f3577b34da6a3ce929d0e0e4736"
| where log_type="application"index=container_logs sourcetype="ecs-service-*"
userName="[email protected]"
| stats count by log_type, uri, statusCodeAdmin Portal의 모든 서비스(identity-service, auth, export-service, audit-log, preferences)에 동일한 로깅 표준을 적용했다. 이로 인해 얻어진 것:
- 검색 쿼리 표준화: 서비스가 달라도 같은 필드명(
userName,trace_id,uri)으로 검색 가능 - 크로스 서비스 분석: trace ID 하나로 여러 서비스의 요청 흐름을 이어서 추적
- 이벤트 추적 일원화: application log에서 감사 이벤트를, access log에서 요청 메타데이터를 함께 조회
구조화 로깅에서 실제로 정한 것
공통 필드 표준
서비스 이름, 레벨, 요청 경로, 사용자 맥락 같은 공통 필드를 먼저 맞췄다.
W3C Trace Context 통합
OTEL Agent가 traceparent 헤더에서 trace_id/span_id를 추출해 MDC에 자동 주입하고, application log와 access log 모두에 동일한 필드로 출력되도록 했다.
시스템 검색을 먼저 고려한 필드명
로그를 읽는 사람보다 검색하는 시스템을 먼저 고려해 필드명을 snake_case로 고정했다.
log_type 분리
application과 access를 log_type 필드로 구분해, 같은 수집 파이프라인에서 성격별 필터링이 가능하게 했다. Splunk에서 log_type=application으로 비즈니스 로직만, log_type=access로 HTTP 메타데이터만 볼 수 있다.
로깅 도구의 진화: LogstashEncoder에서 Spring Boot Structured Logging으로
구조화 로깅을 처음 도입했을 때는 LogstashEncoder를 사용했다. Logback 설정 파일에 XML로 인코더를 정의하고, customFields로 log_type 같은 고정 필드를 추가하는 방식이었다.
Phase 1: LogstashEncoder
logback-spring.xml에 LogstashEncoder를 설정해야 했다. Access log에는 별도의 logback-access.xml과 LogstashAccessEncoder가 필요했다.
<configuration>
<springProfile name="!local">
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<customFields>{"log_type":"application"}</customFields>
</encoder>
</appender>
</springProfile>
</configuration><configuration>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LogstashAccessEncoder">
<customFields>{"log_type":"access"}</customFields>
<provider class="com.nike.platform.logging.TraceParentJsonProvider"/>
<requestHeaderFilter>
<include>X-Forwarded-For</include>
<include>User-Agent</include>
</requestHeaderFilter>
</encoder>
</appender>
</configuration>별도 의존성(logstash-logback-encoder)이 필요했고, XML 설정의 변경은 IDE 자동완성이 잘 안 되는 영역이라 실수가 잦았다.
| 비교 | LogstashEncoder (Phase 1) | Spring Boot 3.4+ (Phase 2) |
|---|---|---|
| 설정 방식 | logback-spring.xml (XML) | application.properties 한 줄 |
| 추가 의존성 | logstash-logback-encoder 필요 | 불필요 |
| 지원 포맷 | Logstash JSON | Logstash, ECS, GELF |
| 커스텀 필드 | XML의 customFields 또는 JsonProvider 확장 | addKeyValue() fluent API, MDC 자동 포함 |
| Access log | 별도 logback-access.xml + LogstashAccessEncoder | 동일 (access log는 여전히 Logback Access 설정 필요) |
Access log는 Spring Boot의 Structured Logging 범위 밖이기 때문에, logback-access.xml과 LogstashAccessEncoder는 Phase 2에서도 유지된다. 달라진 건 application log 쪽이다.
여기서 끝나지 않았다
구조화 로깅을 붙이면 곧바로 드러나는 문제가 하나 있다. 동기 요청 안에서는 잘 남던 필드가 비동기 작업으로 넘어가면 사라진다는 점이다. 실제로 이 시리즈에서도 그 문제가 바로 다음 단계에서 드러났다.
구조화 로깅은 가시성을 높였고, 그 결과 이전에는 흐릿하게 지나가던 비동기 문맥 손실이 더 명확하게 보이기 시작했다.
이 전환이 운영에 남긴 변화
- 장애 원인을 찾을 때 로그 문장을 읽는 시간이 줄었다.
- 여러 서비스를 가로지르는 요청 추적이 가능해졌다.
- 이후 Audit Event와 성능 분석에서도 같은 문맥 키를 재사용할 수 있었다.
log_type필터링으로 application log와 access log를 선택적으로 조회할 수 있게 됐다.
가장 큰 변화는 “로그를 남긴다”에서 “로그를 조회할 수 있다”로 기준이 바뀐 점이다. 포맷이 아니라 운영 질문에 답할 수 있느냐가 중요해진 것이다. 다만 필드가 늘수록 로그 볼륨과 비용을 계속 의식해야 하고, 서비스마다 같은 로깅 설정을 넣어야 표준화가 유지된다. 인프라 레벨(ECS 로그 드라이버, Splunk 수집 설정 등)은 Terraform으로 한번에 적용할 수 있었지만, 애플리케이션 레벨 설정은 각 서비스 레포에서 개별 반영이 필요했다. 비동기 작업과 큐 소비에서의 문맥 유실은 MDCPreservingTaskDecorator를 ThreadPoolTaskExecutor에 등록하는 방식으로 해결했으며, 다음 글에서 자세히 다룬다. Access log는 Spring Boot Structured Logging의 네이티브 범위에 포함되지 않아 logback-access.xml에 LogstashAccessEncoder를 별도로 설정해야 한다.
구조화 로깅은 보기 좋은 로그를 만드는 일이 아니라, 운영 질문을 공통 형식으로 남기는 일이다. Application log와 access log를 모두 JSON으로 표준화하고, trace context로 연결하고, 중앙 시스템에서 검색 가능하게 만들고 나면, 다음 단계에서는 왜 비동기 작업에서 그 문맥이 끊기는지가 더 또렷하게 보이기 시작한다.
다음 글에서는 바로 그 지점 — 비동기 스레드로 넘어가며 요청 문맥이 사라지는 문제를 어떻게 잡았는지 이어서 본다.