웹 애플리케이션 운영 중 가장 곤혹스러운 순간은 수많은 사용자의 로그가 한 파일에 뒤섞여 찍힐 때다. 특정 사용자가 겪은 오류의 원인을 파악하려면 모래사장에서 바늘을 찾는 고통이 따른다.
이를 해결하는 핵심 도구가 'TraceId'다. 오늘은 단순히 개념을 넘어, 코드를 기반으로 TraceId 시스템을 실무에 어떻게 적용하고 활용하는지 상세히 정리해 보았다.
1. 주요 특징 및 핵심 로직: 왜 TraceId인가?
TraceId 시스템의 핵심은 "로그를 읽기 좋은 문장이 아니라 분석 가능한 데이터로 취급하는 것"에 있다. 개별 로그는 파편화된 정보일 뿐이지만, 여기에 traceId라는 공통 분모를 부여하면 비로소 하나의 '트랜잭션 흐름'으로 연결된다.

시스템을 지탱하는 세 가지 논리적 축은 다음과 같다.
- MDC (Mapped Diagnostic Context): 현재 실행 중인 스레드에 특정 값을 컨텍스트로 저장하여, 별도의 파라미터 전달 없이도 로그 출력 시 해당 값을 자동으로 포함시킨다.
- Filter 기반 할당: 모든 요청의 최전방(Filter)에서 ID를 부여하여, 컨트롤러 진입 전부터 예외 처리 단계까지 전 구간을 추적한다.
- 검증 및 정규화: 클라이언트가 보낸 비정상적인 헤더 값에 오염되지 않도록 안전한 UUID로 치환하여 로그 신뢰성을 확보한다.
2. 코드로 보는 TraceId의 핵심 로직
실무에서 가장 중요한 TraceIdFilter.java의 로직을 단계별로 쪼개어 분석해 보자. 이 필터는 @Order(Ordered.HIGHEST_PRECEDENCE) 설정을 통해 시스템의 가장 앞단에서 동작하며 모든 로그의 근간이 된다.
🔍 TraceIdFilter.java 전체 코드
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class TraceIdFilter extends OncePerRequestFilter {
public static final String TRACE_ID_KEY = "traceId";
private static final String TRACE_ID_HEADER = "X-Trace-Id";
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// [STEP 1] 기존 ID 백업
String previousTraceId = MDC.get(TRACE_ID_KEY);
// [STEP 2] ID 정규화 및 생성
String traceId = normalizeTraceId(request.getHeader(TRACE_ID_HEADER));
// [STEP 3] MDC 주입 및 응답 헤더 설정
MDC.put(TRACE_ID_KEY, traceId);
response.setHeader(TRACE_ID_HEADER, traceId);
try {
filterChain.doFilter(request, response);
} finally {
// [STEP 4] 자원 정리
if (Objects.nonNull(previousTraceId)) {
MDC.put(TRACE_ID_KEY, previousTraceId);
} else {
MDC.remove(TRACE_ID_KEY);
}
}
}
private String normalizeTraceId(String candidate) {
if (candidate == null || !TRACE_ID_PATTERN.matcher(candidate.trim()).matches()) {
return UUID.randomUUID().toString();
}
return candidate.trim();
}
}
🔍 세부 로직
📌 STEP 0. 클래스 설정 및 어노테이션 (문지기 설정)
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class TraceIdFilter extends OncePerRequestFilter {
@Component를 통해 스프링 빈으로 등록하며, @Order(Ordered.HIGHEST_PRECEDENCE)를 사용하여 시스템 내 최우선 순위를 부여한다. 이는 보안 필터나 인터셉터에서 발생하는 초기 로그까지 모두 추적하기 위함이다. 또한 OncePerRequestFilter를 상속하여 내부 포워딩 시에도 중복 실행 없이 단 한 번의 요청 단위 처리를 보장한다.
📌 STEP 1. 혹시 모를 중첩 호출에 대비해 기존 ID 보관하기
String previousTraceId = MDC.get(TRACE_ID_KEY);
가장 먼저 현재 스레드의 MDC에 기존 값이 존재하는지 확인한다.
내부 포워딩이나 비동기 작업 후 재진입 등 하나의 스레드에서 필터가 중첩 실행될 가능성이 존재하기 때문이다.
기존 맥락을 변수에 백업해두어야 작업 종료 시점에 이전 상태로 안전하게 복구가 가능하다.
📌 STEP 2. 클라이언트의 값 검증하고 안전한 ID 생성하기

String traceId = normalizeTraceId(request.getHeader(TRACE_ID_HEADER));
클라이언트가 보낸 헤더 값을 읽어오되, 이를 맹목적으로 신뢰하지 않는다.
악의적인 사용자가 비정상적으로 긴 문자열이나 개행 문자를 섞어 보내는 '로그 인젝션(Log Injection)' 공격을 시도할 수 있기 때문이다. 정규식 검증을 통과하지 못한 값은 즉시 폐기하고 서버에서 생성한 안전한 UUID로 치환한다.
📌 STEP 3. 로그와 응답 헤더에 TraceId 심어주기
MDC.put(TRACE_ID_KEY, traceId);
response.setHeader(TRACE_ID_HEADER, traceId);
준비된 ID를 MDC에 저장한다.
이 시점 이후 발생하는 모든 로그에는 해당 ID가 자동으로 포함된다.
또한 클라이언트 응답 헤더에도 이 값을 실어 보낸다.
이는 장애 발생 시 사용자가 제보한 ID만으로 서버 로그 수백만 건 중 해당 요청만 정확히 솎아낼 수 있는 강력한 디버깅 수단이 된다.
📌 STEP 4. 가장 중요한 뒷정리, 스레드 풀 오염 방지
finally {
if (Objects.nonNull(previousTraceId)) {
MDC.put(TRACE_ID_KEY, previousTraceId);
} else {
MDC.remove(TRACE_ID_KEY);
}
}

작업이 끝났다면 반드시 finally 블록에서 MDC를 정리해야 한다.
톰캣(Tomcat)과 같은 서블릿 컨테이너는 스레드를 재사용하는 스레드 풀(Thread Pool) 기반으로 동작한다.
만약 사용 완료된 스레드의 MDC를 비워주지 않으면, 해당 스레드가 다음 사용자의 요청을 처리할 때 이전 사용자의 TraceId를 그대로 노출하는 데이터 오염이 발생한다.
🔍 예외 처리와의 연동: GlobalExceptionHandler.java
비즈니스 로직에서 예외가 발생하더라도 MDC에 저장된 traceId는 finally 블록이 실행되기 전까지 살아있다. 따라서 예외 처리 시점에 남기는 로그는 장애 분석의 결정적인 증거가 된다.
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleException(Exception e) {
// MDC 컨텍스트 덕분에 로그 레이아웃 설정(%X{traceId})에 의해 자동으로 ID가 출력됨
log.error("[ERROR] 예기치 못한 시스템 오류 발생: {}", e.getMessage(), e);
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("INTERNAL_SERVER_ERROR", "서버 내부 오류가 발생했습니다."));
}
}
3. 실무 팁 및 주의사항
- Logback 패턴 설정: MDC에 값을 넣는다고 자동으로 로그에 찍히지는 않는다. logback-spring.xml 파일의 레이아웃 패턴에 반드시 %X{traceId} 형식을 추가해주어야 한다.
- 비동기 처리(@Async)의 함정: MDC는 ThreadLocal 기반이므로 새로운 스레드가 생성되는 @Async 로직에서는 부모의 traceId가 전달되지 않는다. 이 경우 TaskDecorator를 구현해 컨텍스트를 수동으로 전파해주는 작업이 필요하다.
- 성능 측정 연동: 필터 시작 시각을 기록해두었다가 finally에서 처리 시간을 계산해 로그를 남기면, 특정 traceId가 처리되는 데 걸린 전체 시간(ms)을 데이터로 남길 수 있어 성능 모니터링에 매우 유리하다.
4. 마무리
TraceId는 백엔드 시스템에 대한 '관측 가능성(Observability)'을 확보하는 최소한의 장치다.
흩어져 있던 텍스트 로그들이 traceId라는 고리로 연결될 때 비로소 로그는 강력한 디버깅 수단이 된다.
'Spring > Common' 카테고리의 다른 글
| [Spring] 빈 생명주기 콜백: 애플리케이션의 시작과 종료 관리 (0) | 2026.05.10 |
|---|---|
| [Spring] 스프링 싱글톤 컨테이너: 왜 모든 빈은 '하나'여야 할까? (0) | 2026.05.09 |
| [Spring] 스프링 프레임워크와 객체 지향의 본질: 역할과 구현의 분리 (0) | 2026.05.04 |
| [Spring] 엔티티 메타데이터 자동화: JPA Auditing을 활용한 일관된 데이터 관리 (0) | 2026.04.25 |
| [Spring] 효율적인 예외 처리: GlobalExceptionHandler로 사용자 경험과 운영 효율성 잡기 (0) | 2026.04.20 |