운영 중인 서비스에서 장애 원인을 좇다 보면 로그가 텍스트 한 줄로 흩어져 있어 특정 요청만 뽑아보기 어려운 경우가 많다. 톰캣 접근 로그와 애플리케이션 로그를 grep으로 맞춰 보다가 시간만 쓴 기억이 다들 있을 것이다. Spring Boot 로그를 JSON으로 구조화하고 요청마다 상관관계 ID를 심어두면 이런 상황이 한결 편해진다. 예전에 톰캣 AccessLogValve 패턴만 손봐서 버티던 로그를, 이제는 애플리케이션 레벨에서 구조화하는 방식으로 정리해 본다.
결론부터 말하자면, logstash-logback-encoder를 의존성에 추가하고 logback-spring.xml에서 인코더를 LogstashEncoder로 바꾸면 로그가 JSON 한 줄로 나온다. 여기에 서블릿 필터에서 MDC.put("requestId", ...)로 ID를 심으면, 그 요청이 남긴 모든 로그에 같은 requestId 필드가 붙는다. Loki에서는 | json | requestId="...", Kibana에서는 requestId: "..."로 한 요청의 로그만 골라낼 수 있다.
1. MDC와 구조화 로깅이 무엇인가
MDC(Mapped Diagnostic Context)는 SLF4J와 Logback이 스레드마다 들고 있는 키-값 맵이다. MDC.put("key", "value")로 값을 넣으면, 이후 같은 스레드에서 찍는 모든 로그에 그 값이 따라붙는다. 요청 진입 시점에 한 번 심어두면 그 요청을 처리하는 동안의 로그가 전부 같은 ID를 갖게 되는 구조다.
구조화 로깅은 로그를 사람이 읽는 문장이 아니라 기계가 파싱하는 필드 집합으로 남기는 방식이다. 2026-09-03 10:15:23 INFO order created 대신 {"level":"INFO","message":"order created","requestId":"..."}처럼 남긴다. Loki, Elasticsearch 같은 수집기가 필드 단위로 색인하므로 level=ERROR AND requestId="..." 같은 조회가 정확해진다.
아래에서는 버전 요구사항, 의존성, logback-spring.xml 설정, MDC 필터, 그리고 Loki/ELK 조회를 차례로 살펴본다.
2. 버전 요구사항 - Spring Boot 버전에 인코더를 맞춰야 한다
가장 먼저 걸리는 지점이 버전 궁합이다. logstash-logback-encoder는 Logback과 Jackson에 하한을 두고 있어서, Spring Boot가 물고 오는 기본 버전보다 인코더가 앞서 있으면 클래스 로딩 단계에서 실패한다.
공식 README 기준 최신 계열의 요구사항은 다음과 같다. 버전 번호는 저장소 상태에 따라 다를 수 있으니 릴리스 노트를 다시 확인하는 편이 정확하다.
- 8.0: Java 11 이상,
logback-core/logback-classic1.5.0 이상,jackson-databind/core/annotations2.17.0 이상. - 9.0: Jackson 3 기반으로 넘어갔고 Java 17 이상을 요구한다(2025년 10월 릴리스).
- 7.4: Java 8 이상, Logback 1.3/1.4 계열, Jackson 2.x. 구형 스택과 물릴 때 쓴다.
여기서 자주 막힌다. Spring Boot 3.2는 Logback 1.4.x, Jackson 2.15를 기본으로 물고 온다. 이 상태에서 인코더 8.0을 넣으면 요구 버전(Logback 1.5, Jackson 2.17) 미달이라 실패한다. Spring Boot 3.3 이상이라야 Logback 1.5.x와 Jackson 2.17 이상이 기본으로 딸려 오므로 8.0과 맞는다. 정리하면 이렇다.
Spring Boot 3.2.x -> logstash-logback-encoder 7.4 (Logback 1.4 / Jackson 2.15) Spring Boot 3.3.x+ -> logstash-logback-encoder 8.0 (Logback 1.5 / Jackson 2.17) Java 17 + Jackson 3 -> logstash-logback-encoder 9.0
확신이 안 서면 실제로 물려 온 버전을 확인하고 인코더를 고르면 된다.
$ ./mvnw dependency:tree | grep -E 'logback|jackson-databind' [INFO] +- ch.qos.logback:logback-classic:jar:1.5.6:compile [INFO] | \- ch.qos.logback:logback-core:jar:1.5.6:compile [INFO] +- com.fasterxml.jackson.core:jackson-databind:jar:2.17.1:compile
Logback 1.5, Jackson 2.17이 보이면 8.0을 써도 된다. 1.4나 2.15가 보이면 7.4로 내리거나 Spring Boot를 올린다.
3. 의존성 추가
인코더 하나만 추가하면 된다. Jackson과 Logback은 spring-boot-starter가 이미 들고 있으니 따로 넣지 않는다.
<!-- pom.xml (Spring Boot 3.3+ 기준) -->
<dependency>
<groupId>net.logstash.logback</groupId>
<artifactId>logstash-logback-encoder</artifactId>
<version>8.0</version>
</dependency>
Gradle이라면 implementation 'net.logstash.logback:logstash-logback-encoder:8.0' 한 줄이다.
4. logback-spring.xml로 JSON 출력 켜기
Spring Boot에서는 파일 이름을 logback.xml이 아니라 logback-spring.xml로 두는 것이 좋다. -spring이 붙어야 <springProfile> 같은 Spring 확장 태그를 쓸 수 있다. 위치는 src/main/resources/logback-spring.xml이다.
운영에서는 JSON, 로컬 개발에서는 사람이 읽기 좋은 텍스트로 나누는 구성을 자주 쓴다. 프로파일로 분기한다.
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<!-- 개발: 기존 텍스트 패턴 유지 -->
<springProfile name="local,dev">
<include resource="org/springframework/boot/logging/logback/base.xml"/>
</springProfile>
<!-- 운영: JSON 한 줄 -->
<springProfile name="prod">
<appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<customFields>{"app":"order-service","env":"prod"}</customFields>
<includeMdcKeyName>requestId</includeMdcKeyName>
<includeMdcKeyName>txId</includeMdcKeyName>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="JSON"/>
</root>
</springProfile>
</configuration>
customFields는 모든 로그에 공통으로 붙는 상수 필드다. 어느 서비스, 어느 환경에서 나온 로그인지 표시할 때 쓴다. ConsoleAppender로 표준 출력에 뿌리는 이유는, 컨테이너나 systemd 환경에서 로그 수집기(Promtail, Filebeat)가 표준 출력을 그대로 긁어가는 구성이 흔하기 때문이다. 파일로 남기고 싶으면 ConsoleAppender를 RollingFileAppender로 바꾸고 같은 인코더를 넣으면 된다.
4-1. MDC는 기본 포함, 필요한 키만 남기기
LogstashEncoder는 MDC에 들어 있는 값을 기본으로 전부 JSON에 포함한다. <includeMdcKeyName>을 하나라도 지정하면 그 키들만 남기는 화이트리스트로 동작한다. MDC에 이런저런 값이 쌓이는 코드베이스라면 필요한 키만 명시하는 편이 로그가 깔끔하다. 반대로 전부 내보내되 특정 키만 빼려면 <excludeMdcKeyName>을 쓰고, MDC 전체를 끄려면 <includeMdc>false</includeMdc>를 준다.
5. 출력 확인
SPRING_PROFILES_ACTIVE=prod로 띄우고 로그 한 줄을 찍어보면 아래 형태로 나온다. 실제로는 개행 없이 한 줄이지만 여기서는 읽기 좋게 폈다.
{
"@timestamp": "2026-09-03T10:15:23.456+09:00",
"@version": "1",
"message": "order created",
"logger_name": "com.example.order.OrderService",
"thread_name": "http-nio-8080-exec-1",
"level": "INFO",
"level_value": 20000,
"app": "order-service",
"env": "prod",
"requestId": "b1f2c3d4-..."
}
@timestamp, @version, message, logger_name, thread_name, level, level_value는 인코더가 자동으로 채우는 기본 필드다. app, env는 customFields, requestId는 뒤에서 심을 MDC 값이다. 예외가 발생하면 stack_trace 필드에 스택트레이스가 통째로 들어가므로, 텍스트 로그처럼 여러 줄로 쪼개져 수집기에서 이벤트가 갈라지는 문제가 사라진다.
6. MDC로 요청 상관관계 ID 심기
이제 요청마다 ID를 넣을 차례다. 서블릿 필터에서 요청 진입 시 MDC에 값을 넣고, 끝나면 반드시 지운다. 프록시나 게이트웨이가 이미 X-Request-Id를 실어 보냈으면 그 값을 이어받고, 없으면 새로 만든다. 응답 헤더에도 되돌려 주면 클라이언트나 프런트 프록시 로그와도 맞출 수 있다.
@Component
public class MdcFilter extends OncePerRequestFilter {
private static final String HEADER = "X-Request-Id";
@Override
protected void doFilterInternal(HttpServletRequest req,
HttpServletResponse res,
FilterChain chain)
throws ServletException, IOException {
String requestId = req.getHeader(HEADER);
if (requestId == null || requestId.isBlank()) {
requestId = UUID.randomUUID().toString();
}
MDC.put("requestId", requestId);
res.setHeader(HEADER, requestId);
try {
chain.doFilter(req, res);
} finally {
MDC.clear(); // 스레드 재사용 대비, 반드시 정리
}
}
}
finally의 MDC.clear()가 핵심이다. 톰캣은 스레드풀을 재사용하므로, 정리하지 않으면 다음 요청이 그 스레드를 물려받아 이전 요청의 requestId를 그대로 찍는다. 실제로 이걸 빼먹으면 서로 다른 요청 로그에 같은 ID가 붙어 추적이 오히려 꼬인다. 가상 스레드를 쓰더라도 풀 재사용 여부와 무관하게 clear()는 넣어두는 편이 안전하다.
비즈니스 로직 안에서 트랜잭션 단위 ID를 하나 더 붙이고 싶으면 같은 방식으로 MDC.put("txId", ...)를 쓰고, 블록이 끝나면 MDC.remove("txId")로 지운다.
6-1. Micrometer Tracing과 함께 쓰면 traceId가 자동으로 들어온다
직접 필터를 만들지 않아도 되는 경우가 있다. micrometer-tracing-bridge-brave 같은 트레이싱 브리지를 넣으면, Spring Boot가 요청마다 traceId와 spanId를 MDC에 자동으로 심는다. 이때는 <includeMdcKeyName>traceId</includeMdcKeyName>만 추가하면 분산 추적 ID가 JSON 로그에 그대로 실린다. 서비스 내부 상관관계는 위의 requestId로, 서비스 간 추적은 traceId로 나눠 쓰는 구성이 무난하다.
7. 로그 한 건에 추가 필드 붙이기
특정 로그 한 줄에만 필드를 더하고 싶을 때는 MDC 대신 StructuredArguments나 마커를 쓴다. message는 그대로 두고 JSON에 필드만 추가된다.
import static net.logstash.logback.argument.StructuredArguments.keyValue;
log.info("order created", keyValue("orderId", order.getId()),
keyValue("amount", order.getAmount()));
이렇게 찍으면 JSON에 "orderId": 1234, "amount": 59000이 필드로 붙는다. 금액이나 ID처럼 나중에 필드로 조회할 값은 문자열 안에 끼워 넣지 말고 이 방식으로 빼는 편이 조회에 유리하다.
8. Loki와 ELK에서 한 요청 추적하기
로그가 JSON으로 나오면 수집기 쪽 조회가 단순해진다. Loki에서는 LogQL의 json 파서로 필드를 풀고 requestId로 거른다.
{app="order-service"} | json | requestId="b1f2c3d4-..."
이 한 줄이면 그 요청이 남긴 모든 로그가 시간순으로 모인다. 에러만 보려면 | json | level="ERROR"를 붙이면 된다.
Elasticsearch/Kibana(ELK)라면 Filebeat나 Logstash가 JSON을 파싱해 각 필드를 문서 필드로 색인한다. Kibana Discover의 KQL에서는 이렇게 조회한다.
requestId : "b1f2c3d4-..." and level : "ERROR"
여기서 주의할 점이 하나 있다. Filebeat로 표준 출력을 긁을 때 JSON 파싱을 켜지 않으면 로그 한 줄 전체가 message 문자열로 들어가 필드 조회가 안 된다. Filebeat라면 json.keys_under_root: true와 json.add_error_key: true를 켜서 JSON을 문서 최상위 필드로 풀어줘야 한다.
9. 자주 걸리는 함정
- 버전 미달로 뜨자마자 죽는다. Spring Boot 3.2에 인코더 8.0을 물리면 Logback/Jackson 하한 미달로 실패한다.
dependency:tree로 확인하고 2절 표대로 맞춘다. - MDC를 안 지워 ID가 섞인다.
finally { MDC.clear(); }를 빼먹으면 스레드풀 재사용 때문에 다른 요청에 이전 ID가 붙는다. - @Async, 별도 스레드로 넘어가면 MDC가 끊긴다. MDC는 스레드 로컬이라 새 스레드에는 값이 넘어가지 않는다.
TaskDecorator로 부모 스레드의MDC.getCopyOfContextMap()을 복사해 넘겨야 이어진다. - logback.xml로 두면 springProfile이 안 먹는다. 파일명을
logback-spring.xml로 해야 Spring 확장 태그가 동작한다. - 수집기에서 JSON 파싱을 안 켰다. Loki는
| json, Filebeat는json.keys_under_root를 켜야 필드로 색인된다. 안 켜면 전부message한 덩어리로 들어간다.
10. 정리
인코더를 Spring Boot 버전에 맞춰 넣고(3.3+는 8.0, 3.2는 7.4), logback-spring.xml에서 LogstashEncoder로 JSON을 뽑은 뒤, 서블릿 필터에서 MDC.put("requestId", ...)로 상관관계 ID를 심고 finally에서 지운다. 이 세 가지만 지키면 Loki의 | json | requestId="..."나 Kibana의 requestId: "..."로 한 요청의 로그를 끝까지 따라갈 수 있다. 서비스 간 추적까지 필요하면 Micrometer Tracing을 얹어 traceId를 함께 실으면 된다.