diff --git a/src/main/java/com/matzip/common/logging/MdcFilter.java b/src/main/java/com/matzip/common/logging/MdcFilter.java new file mode 100644 index 0000000..7401914 --- /dev/null +++ b/src/main/java/com/matzip/common/logging/MdcFilter.java @@ -0,0 +1,61 @@ +package com.matzip.common.logging; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.MDC; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.UUID; +import java.util.regex.Pattern; + +/** + * 요청마다 traceId를 MDC에 심어 모든 로그에 노출한다. + * - nginx가 발급한 X-Request-Id를 우선 사용(없으면 생성)해 nginx와 앱 로그를 같은 id로 연결한다. + * - Spring Security 필터체인보다 먼저 실행되도록 최우선 순서로 등록한다(401·예외 로그에도 traceId가 남도록) + */ +@Component +@Order(Ordered.HIGHEST_PRECEDENCE) +public class MdcFilter extends OncePerRequestFilter { + + public static final String TRACE_ID = "traceId"; + + private static final String REQUEST_ID_HEADER = "X-Request-Id"; + private static final int MAX_TRACE_ID_LENGTH = 64; + private static final Pattern VALID_TRACE_ID = Pattern.compile("[A-Za-z0-9-]+"); + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + try { + MDC.put(TRACE_ID, resolveTraceId(request)); + filterChain.doFilter(request, response); + } finally { + MDC.clear(); + } + } + + private String resolveTraceId(HttpServletRequest request) { + String headerValue = request.getHeader(REQUEST_ID_HEADER); + if (isValid(headerValue)) { + return headerValue; + } + return generateTraceId(); + } + + private boolean isValid(String value) { + return StringUtils.hasText(value) + && value.length() <= MAX_TRACE_ID_LENGTH + && VALID_TRACE_ID.matcher(value).matches(); + } + + private String generateTraceId() { + return UUID.randomUUID().toString().substring(0, 8); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index c8fc532..a2f71a6 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -12,6 +12,10 @@ spring: jdbc.time_zone: Asia/Seoul +logging: + pattern: + level: "%5p [%X{traceId:-}]" + server: port: 8080 diff --git a/src/test/java/com/matzip/common/logging/MdcFilterTest.java b/src/test/java/com/matzip/common/logging/MdcFilterTest.java new file mode 100644 index 0000000..6cff7b7 --- /dev/null +++ b/src/test/java/com/matzip/common/logging/MdcFilterTest.java @@ -0,0 +1,86 @@ +package com.matzip.common.logging; + +import jakarta.servlet.FilterChain; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.slf4j.MDC; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; + +class MdcFilterTest { + + private static final String REQUEST_ID_HEADER = "X-Request-Id"; + + private final MdcFilter filter = new MdcFilter(); + + @AfterEach + void clearMdc() { + MDC.clear(); + } + + @Test + void 체인_실행_중_traceId가_MDC에_세팅된다() throws Exception { + String captured = runFilterAndCaptureTraceId(new MockHttpServletRequest()); + + assertThat(captured).isNotBlank(); + } + + @Test + void 필터_종료_후_MDC가_정리된다() throws Exception { + FilterChain chain = (req, res) -> { + }; + + filter.doFilter(new MockHttpServletRequest(), new MockHttpServletResponse(), chain); + + assertThat(MDC.get(MdcFilter.TRACE_ID)).isNull(); + } + + @Test + void 유효한_X_Request_Id_헤더는_그대로_사용한다() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader(REQUEST_ID_HEADER, "nginx-req-123"); + + String captured = runFilterAndCaptureTraceId(request); + + assertThat(captured).isEqualTo("nginx-req-123"); + } + + @Test + void 헤더가_없으면_traceId를_생성한다() throws Exception { + String captured = runFilterAndCaptureTraceId(new MockHttpServletRequest()); + + assertThat(captured).isNotBlank(); + assertThat(captured).matches("[A-Za-z0-9-]+"); + } + + @ParameterizedTest(name = "부적합 헤더값[{0}]은 생성값으로 대체된다") + @ValueSource(strings = { + "bad id", // 공백(허용되지 않는 문자) + "inject\nlog", // 개행(로그 인젝션 시도) + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" // 길이 초과(65자) + }) + void 부적합한_헤더값은_생성값으로_대체한다(String invalidHeaderValue) throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader(REQUEST_ID_HEADER, invalidHeaderValue); + + String captured = runFilterAndCaptureTraceId(request); + + assertThat(captured).isNotEqualTo(invalidHeaderValue); + assertThat(captured).matches("[A-Za-z0-9-]+"); + } + + private String runFilterAndCaptureTraceId(MockHttpServletRequest request) throws Exception { + AtomicReference captured = new AtomicReference<>(); + FilterChain chain = (req, res) -> captured.set(MDC.get(MdcFilter.TRACE_ID)); + + filter.doFilter(request, new MockHttpServletResponse(), chain); + + return captured.get(); + } +}