From 66901671fea30d8f95c4a969bd3629385395f216 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EB=AF=B8?= Date: Tue, 11 Aug 2026 16:59:19 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EC=9A=94=EC=B2=AD=20=EC=B6=94?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=20MDC=20traceId/userId=20=EB=A1=9C=EA=B9=85?= =?UTF-8?q?=20main=EC=97=90=20=ED=8F=AC=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dev에서 검증된 MdcLoggingFilter(요청마다 8자리 traceId 발급)와 logback-spring.xml(콘솔+롤링파일 appender, 로그 패턴에 traceId 포함)을 main에 반영한다. ELK 연동 없이 순수 Logback 설정이라 prod에 넣어도 안전하다. MdcLoggingFilter는 JWT 헤더에서 best-effort로 userId도 채우는데, @UserId 파라미터로 실제 인증 검증을 거친 요청은 UserIdResolver가 더 정확한 값으로 덮어써야 해서 그쪽에도 MDC.put을 같이 추가했다 (dev에서 이미 이렇게 짝을 이루던 설계). --- .../resolver/userId/UserIdResolver.java | 6 +- .../config/logging/MdcLoggingFilter.java | 68 +++++++++++++++++++ src/main/resources/logback-spring.xml | 30 ++++++++ .../resolver/userId/UserIdResolverTest.java | 13 +++- 4 files changed, 114 insertions(+), 3 deletions(-) create mode 100644 src/main/java/org/runnect/server/config/logging/MdcLoggingFilter.java create mode 100644 src/main/resources/logback-spring.xml diff --git a/src/main/java/org/runnect/server/common/resolver/userId/UserIdResolver.java b/src/main/java/org/runnect/server/common/resolver/userId/UserIdResolver.java index 55770c6..cedff32 100644 --- a/src/main/java/org/runnect/server/common/resolver/userId/UserIdResolver.java +++ b/src/main/java/org/runnect/server/common/resolver/userId/UserIdResolver.java @@ -4,6 +4,7 @@ import org.runnect.server.common.constant.TokenStatus; import org.runnect.server.common.constant.ErrorStatus; import lombok.RequiredArgsConstructor; +import org.slf4j.MDC; import org.runnect.server.common.module.check.TypeChecker; import org.runnect.server.user.exception.authException.InvalidAccessTokenException; import org.runnect.server.user.exception.authException.NullAccessTokenException; @@ -76,6 +77,7 @@ public Object resolveArgument(@NotNull MethodParameter parameter, ModelAndViewCo && request.getMethod().equals("GET") && VISITOR_POSSIBLE_URLS.contains(removeLastPathSegment(request.getRequestURI()))){ // 방문자모드 허용 api에 대한 요청이 맞는지 검증 + MDC.put("userId", String.valueOf(VISITOR_ID)); return VISITOR_ID; } @@ -92,7 +94,9 @@ public Object resolveArgument(@NotNull MethodParameter parameter, ModelAndViewCo final String tokenContents = jwtService.getJwtContents(accessToken); try { - return Long.parseLong(tokenContents); + Long userId = Long.parseLong(tokenContents); + MDC.put("userId", String.valueOf(userId)); + return userId; } catch (NumberFormatException e) { throw new NotFoundUserException(ErrorStatus.NOT_FOUND_USER_EXCEPTION, ErrorStatus.NOT_FOUND_USER_EXCEPTION.getMessage()); } diff --git a/src/main/java/org/runnect/server/config/logging/MdcLoggingFilter.java b/src/main/java/org/runnect/server/config/logging/MdcLoggingFilter.java new file mode 100644 index 0000000..9d90126 --- /dev/null +++ b/src/main/java/org/runnect/server/config/logging/MdcLoggingFilter.java @@ -0,0 +1,68 @@ +package org.runnect.server.config.logging; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.runnect.server.common.constant.TokenStatus; +import org.runnect.server.config.jwt.JwtService; +import org.slf4j.MDC; +import org.springframework.stereotype.Component; + +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import java.io.IOException; +import java.util.UUID; + +/** + * 요청마다 traceId를 발급해 MDC에 담아둔다. + * 로그 패턴에 %X{traceId}를 포함시키면(logback-spring.xml), 여러 요청이 뒤섞인 로그에서도 + * 같은 traceId로 특정 요청의 흐름만 추적할 수 있다. + * + * userId는 @UserId 파라미터가 없는 요청(토큰 재발급, 배너 등)에서도 로그에 남도록 + * 여기서 best-effort로 한 번 더 채운다. 실제 인증 검증/방문자 모드 처리는 UserIdResolver가 + * 맡고, 그 결과가 이후 이 값을 덮어쓴다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class MdcLoggingFilter implements Filter { + + private static final String TRACE_ID_KEY = "traceId"; + private static final int TRACE_ID_LENGTH = 8; + private static final String USER_ID_KEY = "userId"; + + private final JwtService jwtService; + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + String traceId = UUID.randomUUID().toString().substring(0, TRACE_ID_LENGTH); + try { + MDC.put(TRACE_ID_KEY, traceId); + HttpServletRequest httpRequest = (HttpServletRequest) request; + populateUserIdBestEffort(httpRequest); + log.info("{} {}", httpRequest.getMethod(), httpRequest.getRequestURI()); + chain.doFilter(request, response); + } finally { + MDC.remove(TRACE_ID_KEY); + MDC.remove(USER_ID_KEY); + } + } + + private void populateUserIdBestEffort(HttpServletRequest request) { + String accessToken = request.getHeader("accessToken"); + if (accessToken == null) { + return; + } + try { + if (jwtService.verifyToken(accessToken) == TokenStatus.TOKEN_VALID) { + MDC.put(USER_ID_KEY, jwtService.getJwtContents(accessToken)); + } + } catch (RuntimeException e) { + // 로깅 목적의 best-effort 파싱이므로 실패해도 요청 처리는 계속 진행한다. + } + } +} diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..3a1dba5 --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,30 @@ + + + + + + + + + ${LOG_PATTERN} + + + + + ${LOG_DIR}/runnect-server.log + + ${LOG_PATTERN} + + + ${LOG_DIR}/runnect-server.%d{yyyy-MM-dd}.log.gz + 30 + + + + + + + + + diff --git a/src/test/java/org/runnect/server/common/resolver/userId/UserIdResolverTest.java b/src/test/java/org/runnect/server/common/resolver/userId/UserIdResolverTest.java index 371c4a0..acf2471 100644 --- a/src/test/java/org/runnect/server/common/resolver/userId/UserIdResolverTest.java +++ b/src/test/java/org/runnect/server/common/resolver/userId/UserIdResolverTest.java @@ -7,6 +7,7 @@ import static org.mockito.Mockito.when; import javax.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.runnect.server.common.constant.TokenStatus; @@ -15,6 +16,7 @@ import org.runnect.server.user.exception.authException.NullAccessTokenException; import org.runnect.server.user.exception.authException.TimeExpiredAccessTokenException; import org.runnect.server.user.exception.userException.NotFoundUserException; +import org.slf4j.MDC; import org.springframework.core.MethodParameter; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.web.context.request.NativeWebRequest; @@ -36,6 +38,11 @@ void setUp() { methodParameter = mock(MethodParameter.class); } + @AfterEach + void tearDown() { + MDC.clear(); + } + private NativeWebRequest webRequestWith(String accessToken, String refreshToken, String method, String uri) { HttpServletRequest servletRequest = mock(HttpServletRequest.class); when(servletRequest.getHeader("accessToken")).thenReturn(accessToken); @@ -65,12 +72,13 @@ private NativeWebRequest webRequestWith(String accessToken, String refreshToken, } @Test - void 방문자_모드_허용_URL이면_VISITOR_ID를_반환한다() { + void 방문자_모드_허용_URL이면_VISITOR_ID를_반환하고_MDC에_채운다() { NativeWebRequest webRequest = webRequestWith("visitor", "visitor", "GET", "/api/public-course/123"); Object result = userIdResolver.resolveArgument(methodParameter, null, webRequest, null); assertThat(result).isEqualTo(VISITOR_ID); + assertThat(MDC.get("userId")).isEqualTo(String.valueOf(VISITOR_ID)); } @Test @@ -92,7 +100,7 @@ private NativeWebRequest webRequestWith(String accessToken, String refreshToken, } @Test - void 유효한_토큰이면_userId를_반환한다() { + void 유효한_토큰이면_userId를_반환하고_MDC에_채운다() { when(jwtService.verifyToken("valid")).thenReturn(TokenStatus.TOKEN_VALID); when(jwtService.getJwtContents("valid")).thenReturn("42"); NativeWebRequest webRequest = webRequestWith("valid", "refresh", "GET", "/api/user"); @@ -100,6 +108,7 @@ private NativeWebRequest webRequestWith(String accessToken, String refreshToken, Object result = userIdResolver.resolveArgument(methodParameter, null, webRequest, null); assertThat(result).isEqualTo(42L); + assertThat(MDC.get("userId")).isEqualTo("42"); } @Test From ba945005c0d631b5653edc0cc38a9988be0f69b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EB=AF=B8?= Date: Tue, 11 Aug 2026 17:00:56 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20CI/CD=EC=9D=98=20resources=20?= =?UTF-8?q?=EB=94=94=EB=A0=89=ED=84=B0=EB=A6=AC=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=EC=9D=84=20mkdir=20-p=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit logback-spring.xml이 main에 추가되면서 src/main/resources 디렉터리가 이미 git에 존재하게 됐다. 기존 mkdir(비-p)은 디렉터리가 이미 있으면 실패해서 CI/CD가 깨졌다 (mkdir: cannot create directory: File exists). --- .github/workflows/prod-cd.yml | 2 +- .github/workflows/prod-ci.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/prod-cd.yml b/.github/workflows/prod-cd.yml index d881cfa..d91527f 100644 --- a/.github/workflows/prod-cd.yml +++ b/.github/workflows/prod-cd.yml @@ -35,7 +35,7 @@ jobs: - name: make application.properties 파일 생성 run: | ## create application.yml - mkdir ./src/main/resources + mkdir -p ./src/main/resources cd ./src/main/resources # application.yml 파일 생성 diff --git a/.github/workflows/prod-ci.yml b/.github/workflows/prod-ci.yml index 4216629..e8ef38d 100644 --- a/.github/workflows/prod-ci.yml +++ b/.github/workflows/prod-ci.yml @@ -50,7 +50,7 @@ jobs: - name: make application.properties 파일 생성 run: | ## create application.yml - mkdir ./src/main/resources + mkdir -p ./src/main/resources cd ./src/main/resources # application.yml 파일 생성