Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions src/main/java/com/matzip/common/logging/MdcFilter.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
4 changes: 4 additions & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ spring:
jdbc.time_zone: Asia/Seoul


logging:
pattern:
level: "%5p [%X{traceId:-}]"

server:
port: 8080

Expand Down
86 changes: 86 additions & 0 deletions src/test/java/com/matzip/common/logging/MdcFilterTest.java
Original file line number Diff line number Diff line change
@@ -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<String> captured = new AtomicReference<>();
FilterChain chain = (req, res) -> captured.set(MDC.get(MdcFilter.TRACE_ID));

filter.doFilter(request, new MockHttpServletResponse(), chain);

return captured.get();
}
}
Loading