Spring Boot에서 MDC를 활용한 요청별 로그 추적 및 바디 로깅

송현진·2025년 4월 9일
0

Spring Boot

목록 보기
11/23

목표

요청(request)과 응답(response)의 traceId를 로그에 자동 포함하고 요청 본문(JSON, multipart/form-data)과 응답 바디를 전문 로깅하며 멀티 스레드 환경에서도 traceId 유지 및 추적 가능하게 만들었다.

❓ MDC란?

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 + Interceptor 둘 다 필요할까 ?

Filter는 DispatcherSevlet 이전에 실행되기 때문에 InputStream 캐싱 후 바디 확인이 가능하다. 하지만 Interceptor는 Controller 전/후로 실행되서 Body에 직접 접근 할 수가 없다. 그렇기 때문에 요청 바디 JSON 로깅과 traceId 설정은 Filter에서 하고 multipart 파라미터 분석, 응답 Body 로깅은 Interceptor에서 하도록 했다.

CachingRequestFilter – JSON 요청 바디 + traceId 설정

@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");
    }
}

💡비밀번호는 보안되어야 할 요소이기 때문에 마스킹을 적용해줬다.

LoggingInterceptor – Multipart 요청 & 응답 바디 로깅

@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를 통해 한 요청의 흐름을 끝까지 추적할 수 있게 되서 디버깅 등 로그 분석이 훨씬 수월해졌다.

profile
개발자가 되고 싶은 취준생

0개의 댓글