요청(request)과 응답(response)의 traceId
를 로그에 자동 포함하고 요청 본문(JSON, multipart/form-data)과 응답 바디를 전문 로깅하며 멀티 스레드 환경에서도 traceId 유지 및 추적 가능하게 만들었다.
MDC (Mapped Diagnostic Context) 는 로그의 컨텍스트 정보를 현재 쓰레드 단위를 Map<String, String>
형태로 저장해주는 기능이다. 내부적으로는 ThreadLocal<Map<String, String>>
구조로 동작하며, 로그 출력 시 해당 쓰레드의 컨텍스트 값을 자동으로 가져와 로그에 함께 출력해준다.
즉, "현재 쓰레드에서 발생하는 모든 로그에 공통 정보를 붙이고 싶을 때" 사용하는 기능이다. 예를 들어 요청마다 고유한 traceId
를 설정하면, 전체 요청 흐름을 로그로 추적할 수 있다.
사용 방식
// 값 저장 (주로 Filter나 AOP 등 진입 지점에서 사용)
MDC.put("traceId", UUID.randomUUID().toString());
// 값 조회 (Interceptor 등)
String traceId = MDC.get("traceId");
// 특정 키 제거
MDC.remove("traceId");
// 전체 제거 (요청 완료 시 반드시 호출)
MDC.clear();
Logback
설정에 %X{traceId}
를 추가하면 로그마다 자동으로 traceId
가 붙는다.
<property name="LOG_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %highlight(%5level) %cyan(%logger) - %magenta(%msg) traceId=%X{traceId}%n"/>
이 설정대로 로그를 찍으면 아래와 같은 로그가 출력된다.
컴포넌트 | 역할 |
---|---|
CachingRequestFilter | 요청/응답 바디 캐싱 및 traceId MDC 설정 |
LoggingInterceptor | 요청 상세 (multipart param, 이미지 포함 여부 등) + 응답 바디 로깅 |
Filter는 DispatcherSevlet 이전에 실행되기 때문에 InputStream
캐싱 후 바디 확인이 가능하다. 하지만 Interceptor는 Controller 전/후로 실행되서 Body에 직접 접근 할 수가 없다. 그렇기 때문에 요청 바디 JSON 로깅과 traceId
설정은 Filter에서 하고 multipart 파라미터 분석, 응답 Body 로깅은 Interceptor에서 하도록 했다.
@Slf4j
@Component
public class CachingRequestFilter extends OncePerRequestFilter {
private static final String TRACE_ID = "traceId";
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
ContentCachingRequestWrapper wrappedRequest = new ContentCachingRequestWrapper(request);
ContentCachingResponseWrapper wrappedResponse = new ContentCachingResponseWrapper(response);
String traceId = UUID.randomUUID().toString();
MDC.put(TRACE_ID, traceId);
wrappedRequest.setAttribute(TRACE_ID, traceId);
try {
filterChain.doFilter(wrappedRequest, wrappedResponse);
logRequestBody(wrappedRequest, traceId);
} finally {
wrappedResponse.copyBodyToResponse();
MDC.clear();
}
}
private void logRequestBody(ContentCachingRequestWrapper request, String traceId) {
MDC.put(TRACE_ID, traceId);
String contentType = request.getContentType();
if (request.getContentLength() == 0) {
log.info("✅ RequestBody: [{}] empty body", traceId);
return;
}
if (contentType != null && contentType.contains("application/json")) {
String body = new String(request.getContentAsByteArray(), StandardCharsets.UTF_8);
String maskedBody = maskSensitiveFields(body);
log.info("✅ RequestBody: [{}] {}", traceId, maskedBody);
} else {
log.info("✅ RequestBody: [{}] Unsupported or missing content-type: {}", traceId, contentType);
}
}
private String maskSensitiveFields(String body) {
return body.replaceAll("(?i)(\"password\"\\s*:\\s*\")[^\"]*(\")", "$1***$2");
}
}
💡비밀번호는 보안되어야 할 요소이기 때문에 마스킹을 적용해줬다.
@Slf4j
@Component
public class LoggingInterceptor implements HandlerInterceptor {
private static final String TRACE_ID = "traceId";
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String traceId = MDC.get(TRACE_ID);
String method = request.getMethod();
String uri = request.getRequestURI();
String logMessage;
if (request instanceof MultipartHttpServletRequest multipartRequest) {
logMessage = logMultipartRequest(multipartRequest, traceId);
} else if (request.getContentType() != null && request.getContentType().contains("application/json")) {
logMessage = "[Body was logged by Filter]";
} else {
logMessage = "[Unsupported or unknown content type: " + request.getContentType() + "]";
}
log.info("✅ Multipart-Request: [{}] {} {}\n{}", traceId, method, uri, logMessage);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
String traceId = MDC.get(TRACE_ID);
if (response instanceof ContentCachingResponseWrapper wrapper) {
String responseBody = new String(wrapper.getContentAsByteArray(), StandardCharsets.UTF_8);
log.info("✅ ResponseBody: [{}] {} {}\nBody: {}", traceId, response.getStatus(), response.getContentType(), responseBody);
} else {
log.warn("Response body unavailable — wrapper missing");
}
MDC.clear();
}
private String logMultipartRequest(MultipartHttpServletRequest multipartRequest, String traceId) {
Map<String, String> paramMap = multipartRequest.getParameterMap().entrySet().stream()
.collect(Collectors.toMap(
Map.Entry::getKey,
entry -> entry.getValue().length > 0 ? entry.getValue()[0] : ""
));
boolean hasImage = multipartRequest.getMultiFileMap().values().stream()
.flatMap(List::stream)
.peek(file -> log.debug("▶️ file key: {}, name: {}, size: {}, contentType: {}, isEmpty: {}",
file.getName(), file.getOriginalFilename(), file.getSize(), file.getContentType(), file.isEmpty()))
.anyMatch(file ->
!file.isEmpty() && isImageFile(file.getOriginalFilename())
);
return String.format("Form Params: %s\nImage Included: %s", paramMap, hasImage);
}
private boolean isImageFile(String filename) {
if (filename == null) return false;
String lowered = filename.toLowerCase();
return lowered.endsWith(".jpg") || lowered.endsWith(".jpeg") ||
lowered.endsWith(".png") || lowered.endsWith(".gif") ||
lowered.endsWith(".bmp") || lowered.endsWith(".webp");
}
}
처음엔 Interceptor만으로도 요청/응답 바디를 모두 로깅할 수 있을 거라 생각했다. 실제로 multipart 요청과 응답 바디는 Interceptor에서 잘 처리됐지만, JSON 요청 바디는 찍히지 않았다.
이유를 찾아보니, Interceptor는 Controller 실행 전후에 동작하기 때문에, 이미 @RequestBody
로 바디를 읽어간 이후라 body는 소모된 상태였다. 즉, getInputStream()
은 이미 한 번 사용된 셈이라 Interceptor에서는 내용을 다시 읽을 수 없었다.
그래서 구조를 바꿨다. Filter에서 ContentCachingRequestWrapper
로 request를 미리 감싸고 JSON 요청 바디는 Filter에서 마스킹 처리해서 전문 로깅했다. 그리고 Interceptor에서는 multipart/form-data 요청 파라미터에 대한 요청 바디와 이미지 포함 여부를 출력하고 응답 바디를 전문 로깅처리했다.
이렇게 역할을 나누니 모든 요청 유형에 대해 안정적으로 로깅할 수 있었고 traceId를 통해 한 요청의 흐름을 끝까지 추적할 수 있게 되서 디버깅 등 로그 분석이 훨씬 수월해졌다.