Spring에서 traceId를 Filter와 Interceptor로 남기는 방법
운영 로그를 요청 단위로 묶어 보기 위해 traceId를 남길 때, Filter와 Interceptor 중 어디에 두는 게 좋은지와 MDC 정리 방법을 함께 적었습니다.
운영 로그를 보다 보면 가장 아쉬운 순간이 있습니다.
같은 요청에서 나온 로그를 한 번에 묶어 보고 싶은데, 서비스 로그와 리포지토리 로그와 예외 로그가 서로 이어지지 않을 때입니다.
특히 요청이 많은 서비스에서는 "이 로그가 어느 요청에서 나온 거였지?"를 놓치는 순간부터 로그가 금방 읽기 어려워집니다.
그래서 어느 시점부터는 요청 단위 식별자인 traceId를 꼭 남기려고 했습니다.
처음에는 그냥 서비스 메서드 안에서 UUID를 만들어 로그에 같이 넣으려고 했습니다.
그런데 이 방식은 금방 한계가 보였습니다.
- 모든 서비스 메서드에 똑같은 코드가 들어가고
- 어떤 요청은 들어가고 어떤 요청은 빠지고
- 컨트롤러 바깥에서 찍히는 로그는 연결되지 않았습니다
결국 traceId는 비즈니스 로직 안이 아니라, 요청이 들어오는 초입에서 공통으로 심는 편이 맞다고 정리했습니다.
가장 단순한 방식은 MDC였다
제가 가장 먼저 쓴 건 MDC였습니다.
String traceId = UUID.randomUUID().toString();
MDC.put("traceId", traceId);그리고 Logback 패턴에 이 값을 넣었습니다.
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level traceId=%X{traceId} %logger - %msg%n</pattern>이렇게만 해도 로그는 꽤 읽기 쉬워집니다.
2026-03-13 10:15:12.214 [http-nio-8080-exec-3] INFO traceId=2f4f... order.OrderService - order created
2026-03-13 10:15:12.268 [http-nio-8080-exec-3] WARN traceId=2f4f... payment.PaymentClient - timeout문제는 어디에서 MDC.put()를 호출할 것인가였습니다.
저는 먼저 Filter에 넣는 편이 더 낫다고 느꼈다
요청이 애플리케이션으로 들어오는 가장 앞단에서 심고 싶다면 Filter가 제일 자연스럽습니다.
@Component
public class TraceIdFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
String traceId = UUID.randomUUID().toString();
try {
MDC.put("traceId", traceId);
response.setHeader("X-Trace-Id", traceId);
filterChain.doFilter(request, response);
} finally {
MDC.remove("traceId");
}
}
}이 방식이 좋았던 이유는 분명했습니다.
- 컨트롤러까지 가지 못한 예외도 잡힌다
- 서블릿 필터 체인 전체에서 공통으로 쓸 수 있다
- 요청이 들어오는 가장 앞쪽에서 값을 심을 수 있다
운영 로그를 요청 단위로 묶으려면 "최대한 이른 시점"에 값을 심는 게 훨씬 안정적이었습니다.
그래서 저는 보통 traceId는 Filter 쪽이 더 잘 맞는다고 느꼈습니다.
중요한 건 finally에서 꼭 정리하는 것이었다
이건 실제로 한 번 겪고 나서 더 조심하게 된 부분입니다.
MDC.put()만 하고 remove()나 clear()를 빼먹으면, 같은 스레드를 재사용하는 다음 요청에 값이 남아 있을 수 있습니다.
그러면 로그가 완전히 섞여버립니다.
그래서 traceId를 넣을 때는 거의 습관처럼 finally에서 정리합니다.
try {
MDC.put("traceId", traceId);
filterChain.doFilter(request, response);
} finally {
MDC.remove("traceId");
}이 한 줄이 빠지면 로그는 금방 믿기 어려워집니다.
응답 헤더에도 같이 내려주는 편이 꽤 편했다
처음에는 서버 로그에만 남기면 충분하다고 생각했습니다.
그런데 프론트나 QA가 특정 요청을 문의할 때 응답 헤더에 traceId가 함께 있으면 훨씬 빨리 찾을 수 있었습니다.
response.setHeader("X-Trace-Id", traceId);예를 들어 프론트에서 "이 요청이 이상했다"고 할 때, 개발자도구에서 X-Trace-Id를 복사해서 로그에서 바로 검색할 수 있습니다.
작은 차이 같지만 운영 대응에서는 꽤 유용했습니다.
Interceptor로 넣는 방법도 가능하다
traceId를 꼭 Filter에만 넣어야 하는 건 아닙니다.
Spring MVC 기준으로는 HandlerInterceptor를 써도 됩니다.
@Component
public class TraceIdInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(
HttpServletRequest request,
HttpServletResponse response,
Object handler
) {
String traceId = UUID.randomUUID().toString();
MDC.put("traceId", traceId);
response.setHeader("X-Trace-Id", traceId);
return true;
}
@Override
public void afterCompletion(
HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex
) {
MDC.remove("traceId");
}
}그리고 설정에서 등록합니다.
@Configuration
public class WebConfig implements WebMvcConfigurer {
private final TraceIdInterceptor traceIdInterceptor;
public WebConfig(TraceIdInterceptor traceIdInterceptor) {
this.traceIdInterceptor = traceIdInterceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(traceIdInterceptor);
}
}Interceptor 방식이 좋은 점도 있습니다.
- Spring MVC 흐름 안에서 이해하기 쉽고
- 특정 경로만 선택적으로 적용하기 편하고
- 컨트롤러 레벨 로직과 더 가깝게 다룰 수 있습니다
그럼 Filter와 Interceptor 중 무엇이 나을까
제 기준에서는 용도를 이렇게 나눕니다.
- 요청 전체를 공통 추적하고 싶다 ->
Filter - MVC 핸들러 기준으로 제어하고 싶다 ->
Interceptor
traceId처럼 "이 요청이 들어온 순간부터 전체를 묶고 싶다"는 목적이라면 저는 Filter가 더 잘 맞았습니다.
반대로 특정 API 그룹만 별도 추적하거나, MVC 레벨 조건 분기가 더 중요하면 Interceptor도 충분히 괜찮습니다.
즉, 둘 중 하나만 정답이라기보다 어디부터 어디까지 추적하고 싶은가가 더 중요했습니다.
외부에서 이미 traceId를 주는 경우도 있다
서비스가 하나만 있을 때는 서버 안에서 UUID를 새로 만들어도 충분합니다.
하지만 게이트웨이나 프록시, 다른 upstream 서비스가 이미 요청 ID를 붙여서 내려주는 경우도 있습니다.
이럴 때는 무조건 새로 만들기보다, 먼저 헤더에서 읽고 없을 때만 생성하는 쪽이 더 좋았습니다.
String traceId = request.getHeader("X-Trace-Id");
if (traceId == null || traceId.isBlank()) {
traceId = UUID.randomUUID().toString();
}그래야 여러 시스템을 거치는 요청에서도 ID가 끊기지 않습니다.
비동기 작업에서는 그대로 안 이어질 수 있다
이건 traceId를 넣고 나서 한 번쯤 꼭 만나게 되는 문제였습니다.
동기 요청에서는 잘 보이는데, @Async나 별도 스레드 풀로 넘기는 순간 MDC 값이 그대로 안 따라가는 경우가 있습니다.
그래서 비동기 작업까지 traceId를 이어가고 싶다면 TaskDecorator나 별도 컨텍스트 전파 방식까지 같이 봐야 합니다.
즉, Filter나 Interceptor로 시작하는 건 좋지만, 그걸로 모든 실행 흐름이 자동 연결된다고 생각하면 아쉬운 지점이 생깁니다.
지금은 이렇게 정리해서 쓴다
지금은 보통 아래 순서로 정리합니다.
- Filter에서 traceId를 생성하거나 헤더에서 읽는다
MDC.put("traceId", ...)로 심는다- 응답 헤더에도 함께 내려준다
finally또는afterCompletion에서 꼭 제거한다- 비동기 작업이 있다면 전파 방식은 별도로 본다
이렇게만 해도 운영 로그는 훨씬 읽기 쉬워졌습니다.
마무리
traceId는 거창한 기능보다, 로그를 다시 읽을 수 있게 만드는 최소한의 장치에 가까웠습니다.
특히 운영 이슈를 따라갈 때는 코드 몇 줄보다도 "같은 요청 로그를 한 번에 볼 수 있다"는 점이 훨씬 크게 느껴졌습니다.
개인적으로는 traceId를 넣고 나서야 비로소 로그가 줄글이 아니라 흐름으로 보이기 시작했습니다.
운영 로그를 더 읽기 쉽게 만들고 싶다면, 가장 먼저 붙여볼 만한 기능이라고 생각합니다.