From c8cb5e2ab4c4377d02b06b8685fbaaaf57c996c9 Mon Sep 17 00:00:00 2001 From: Dimo-2562 Date: Fri, 3 Jul 2026 15:39:48 +0900 Subject: [PATCH 01/17] =?UTF-8?q?feat:=20MDC=20=ED=95=84=ED=84=B0=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20JWT=20=ED=95=84=ED=84=B0?= =?UTF-8?q?=EC=97=90=EC=84=9C=20MEMBER=5FID=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20=EC=82=AD=EC=A0=9C=20=EB=A1=9C=EC=A7=81=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../demo/global/logging/MdcLoggingFilter.java | 60 +++++++++++++ .../filter/JwtAuthenticationFilter.java | 23 +++-- .../global/logging/MdcLoggingFilterTest.java | 75 ++++++++++++++++ .../JwtAuthenticationFilterMdcTest.java | 89 +++++++++++++++++++ 4 files changed, 239 insertions(+), 8 deletions(-) create mode 100644 src/main/java/umc/cockple/demo/global/logging/MdcLoggingFilter.java create mode 100644 src/test/java/umc/cockple/demo/global/logging/MdcLoggingFilterTest.java create mode 100644 src/test/java/umc/cockple/demo/global/security/filter/JwtAuthenticationFilterMdcTest.java diff --git a/src/main/java/umc/cockple/demo/global/logging/MdcLoggingFilter.java b/src/main/java/umc/cockple/demo/global/logging/MdcLoggingFilter.java new file mode 100644 index 000000000..095208234 --- /dev/null +++ b/src/main/java/umc/cockple/demo/global/logging/MdcLoggingFilter.java @@ -0,0 +1,60 @@ +package umc.cockple.demo.global.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.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.UUID; + +@Component +@Order(Ordered.HIGHEST_PRECEDENCE) +public class MdcLoggingFilter extends OncePerRequestFilter { + + public static final String REQUEST_ID = "requestId"; + public static final String METHOD = "method"; + public static final String URI = "uri"; + public static final String CLIENT_IP = "clientIp"; + private static final String REQUEST_ID_HEADER = "X-Request-Id"; + private static final String FORWARDED_FOR_HEADER = "X-Forwarded-For"; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + try { + MDC.put(REQUEST_ID, resolveRequestId(request)); + MDC.put(METHOD, request.getMethod()); + MDC.put(URI, request.getRequestURI()); + MDC.put(CLIENT_IP, resolveClientIp(request)); + + filterChain.doFilter(request, response); + } finally { + MDC.remove(REQUEST_ID); + MDC.remove(METHOD); + MDC.remove(URI); + MDC.remove(CLIENT_IP); + } + } + + private String resolveRequestId(HttpServletRequest request) { + String requestId = request.getHeader(REQUEST_ID_HEADER); + if (requestId == null || requestId.isBlank()) { + return UUID.randomUUID().toString(); + } + return requestId.trim(); + } + + private String resolveClientIp(HttpServletRequest request) { + String forwardedFor = request.getHeader(FORWARDED_FOR_HEADER); + if (forwardedFor != null && !forwardedFor.isBlank()) { + return forwardedFor.split(",")[0].trim(); + } + return request.getRemoteAddr(); + } +} diff --git a/src/main/java/umc/cockple/demo/global/security/filter/JwtAuthenticationFilter.java b/src/main/java/umc/cockple/demo/global/security/filter/JwtAuthenticationFilter.java index 6fb22a186..0adf72f77 100644 --- a/src/main/java/umc/cockple/demo/global/security/filter/JwtAuthenticationFilter.java +++ b/src/main/java/umc/cockple/demo/global/security/filter/JwtAuthenticationFilter.java @@ -6,6 +6,7 @@ import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.slf4j.MDC; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; @@ -26,6 +27,8 @@ @Slf4j public class JwtAuthenticationFilter extends OncePerRequestFilter { + public static final String MEMBER_ID = "memberId"; + private final JwtTokenProvider jwtTokenProvider; private final MemberRepository memberRepository; private final RestAuthenticationEntryPoint restEntryPoint; @@ -33,16 +36,17 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - String token = resolveToken(request); - - // token이 null -> 로그 찍고 그대로 진행 - if (token == null) { - log.trace("Authorization 헤더에 토큰 없음"); - filterChain.doFilter(request, response); - return; - } + MDC.remove(MEMBER_ID); try { + String token = resolveToken(request); + + // token이 null -> 로그 찍고 그대로 진행 + if (token == null) { + log.trace("Authorization 헤더에 토큰 없음"); + filterChain.doFilter(request, response); + return; + } if (!jwtTokenProvider.validateToken(token)) { throw new MemberException(MemberErrorCode.INVALID_TOKEN); @@ -50,6 +54,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse Long memberId = jwtTokenProvider.getUserId(token); + MDC.put(MEMBER_ID, String.valueOf(memberId)); Member member = memberRepository.findById(memberId) .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); @@ -71,6 +76,8 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse } catch (RuntimeException e) { // 혹시 남아있는 경우에도 401로 변환 SecurityContextHolder.clearContext(); restEntryPoint.commence(request, response, new BadCredentialsException(e.getMessage() == null ? "UNAUTHORIZED" : e.getMessage())); + } finally { + MDC.remove(MEMBER_ID); } } diff --git a/src/test/java/umc/cockple/demo/global/logging/MdcLoggingFilterTest.java b/src/test/java/umc/cockple/demo/global/logging/MdcLoggingFilterTest.java new file mode 100644 index 000000000..81ea6a1c4 --- /dev/null +++ b/src/test/java/umc/cockple/demo/global/logging/MdcLoggingFilterTest.java @@ -0,0 +1,75 @@ +package umc.cockple.demo.global.logging; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.slf4j.MDC; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +class MdcLoggingFilterTest { + + private final MdcLoggingFilter filter = new MdcLoggingFilter(); + + @AfterEach + void tearDown() { + MDC.clear(); + } + + @Test + @DisplayName("X-Request-Id가 있으면 요청 MDC에 재사용하고 종료 후 정리한다") + void reuseRequestIdAndClearMdc() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/exercises"); + request.addHeader("X-Request-Id", "request-123"); + request.addHeader("X-Forwarded-For", "203.0.113.10, 10.0.0.1"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + AtomicReference requestId = new AtomicReference<>(); + AtomicReference method = new AtomicReference<>(); + AtomicReference uri = new AtomicReference<>(); + AtomicReference clientIp = new AtomicReference<>(); + + filter.doFilter(request, response, (servletRequest, servletResponse) -> { + requestId.set(MDC.get(MdcLoggingFilter.REQUEST_ID)); + method.set(MDC.get(MdcLoggingFilter.METHOD)); + uri.set(MDC.get(MdcLoggingFilter.URI)); + clientIp.set(MDC.get(MdcLoggingFilter.CLIENT_IP)); + }); + + assertThat(requestId.get()).isEqualTo("request-123"); + assertThat(method.get()).isEqualTo("POST"); + assertThat(uri.get()).isEqualTo("/api/exercises"); + assertThat(clientIp.get()).isEqualTo("203.0.113.10"); + assertThat(MDC.get(MdcLoggingFilter.REQUEST_ID)).isNull(); + assertThat(MDC.get(MdcLoggingFilter.METHOD)).isNull(); + assertThat(MDC.get(MdcLoggingFilter.URI)).isNull(); + assertThat(MDC.get(MdcLoggingFilter.CLIENT_IP)).isNull(); + } + + @Test + @DisplayName("X-Request-Id가 없으면 UUID를 생성한다") + void generateRequestIdWhenHeaderIsMissing() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/members/me"); + request.setRemoteAddr("127.0.0.1"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + AtomicReference requestId = new AtomicReference<>(); + AtomicReference clientIp = new AtomicReference<>(); + + filter.doFilter(request, response, (servletRequest, servletResponse) -> { + requestId.set(MDC.get(MdcLoggingFilter.REQUEST_ID)); + clientIp.set(MDC.get(MdcLoggingFilter.CLIENT_IP)); + }); + + assertThatCode(() -> UUID.fromString(requestId.get())).doesNotThrowAnyException(); + assertThat(clientIp.get()).isEqualTo("127.0.0.1"); + assertThat(MDC.get(MdcLoggingFilter.REQUEST_ID)).isNull(); + assertThat(MDC.get(MdcLoggingFilter.CLIENT_IP)).isNull(); + } +} diff --git a/src/test/java/umc/cockple/demo/global/security/filter/JwtAuthenticationFilterMdcTest.java b/src/test/java/umc/cockple/demo/global/security/filter/JwtAuthenticationFilterMdcTest.java new file mode 100644 index 000000000..b9efb3908 --- /dev/null +++ b/src/test/java/umc/cockple/demo/global/security/filter/JwtAuthenticationFilterMdcTest.java @@ -0,0 +1,89 @@ +package umc.cockple.demo.global.security.filter; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.slf4j.MDC; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.enums.MemberStatus; +import umc.cockple.demo.domain.member.repository.MemberRepository; +import umc.cockple.demo.global.exception.RestAuthenticationEntryPoint; +import umc.cockple.demo.global.jwt.domain.JwtTokenProvider; + +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verifyNoInteractions; + +@ExtendWith(MockitoExtension.class) +class JwtAuthenticationFilterMdcTest { + + @Mock + private JwtTokenProvider jwtTokenProvider; + + @Mock + private MemberRepository memberRepository; + + @Mock + private RestAuthenticationEntryPoint restEntryPoint; + + @AfterEach + void tearDown() { + MDC.clear(); + } + + @Test + @DisplayName("인증 성공 시 요청 처리 동안 memberId를 MDC에 넣고 종료 후 정리한다") + void putMemberIdDuringAuthenticatedRequestAndClearAfterwards() throws Exception { + JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtTokenProvider, memberRepository, restEntryPoint); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/members/me"); + request.addHeader("Authorization", "Bearer token"); + MockHttpServletResponse response = new MockHttpServletResponse(); + Member member = Member.builder() + .id(7L) + .nickname("member7") + .isActive(MemberStatus.ACTIVE) + .socialId(700L) + .build(); + AtomicReference memberIdInChain = new AtomicReference<>(); + + given(jwtTokenProvider.validateToken("token")).willReturn(true); + given(jwtTokenProvider.getUserId("token")).willReturn(7L); + given(memberRepository.findById(7L)).willReturn(Optional.of(member)); + given(jwtTokenProvider.getAuthentication("token")) + .willReturn(new UsernamePasswordAuthenticationToken("member7", "")); + + filter.doFilter(request, response, (servletRequest, servletResponse) -> + memberIdInChain.set(MDC.get(JwtAuthenticationFilter.MEMBER_ID)) + ); + + assertThat(memberIdInChain.get()).isEqualTo("7"); + assertThat(MDC.get(JwtAuthenticationFilter.MEMBER_ID)).isNull(); + } + + @Test + @DisplayName("토큰이 없는 요청은 이전 memberId MDC를 제거한 상태로 통과시키고 종료 후에도 정리한다") + void clearStaleMemberIdWhenRequestHasNoToken() throws Exception { + JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtTokenProvider, memberRepository, restEntryPoint); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/public"); + MockHttpServletResponse response = new MockHttpServletResponse(); + AtomicReference memberIdInChain = new AtomicReference<>(); + MDC.put(JwtAuthenticationFilter.MEMBER_ID, "stale-member"); + + filter.doFilter(request, response, (servletRequest, servletResponse) -> + memberIdInChain.set(MDC.get(JwtAuthenticationFilter.MEMBER_ID)) + ); + + assertThat(memberIdInChain.get()).isNull(); + assertThat(MDC.get(JwtAuthenticationFilter.MEMBER_ID)).isNull(); + verifyNoInteractions(jwtTokenProvider, memberRepository, restEntryPoint); + } +} From 60ec4ee876c54f8a22e06f298edce739e77d2df0 Mon Sep 17 00:00:00 2001 From: Dimo-2562 Date: Fri, 3 Jul 2026 15:46:24 +0900 Subject: [PATCH 02/17] =?UTF-8?q?feat:=20=EB=B9=84=EB=8F=99=EA=B8=B0=20?= =?UTF-8?q?=EC=8A=A4=EB=A0=88=EB=93=9C=EC=9D=98=20MDC=20=EC=A0=84=ED=8C=8C?= =?UTF-8?q?=EB=A5=BC=20=EC=9C=84=ED=95=B4=20=EC=84=A4=EC=A0=95=20=EB=B0=8F?= =?UTF-8?q?=20TaskDecorator=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../demo/global/config/AsyncConfig.java | 26 +++++++- .../demo/global/logging/MdcTaskDecorator.java | 32 ++++++++++ .../demo/global/config/AsyncConfigTest.java | 54 ++++++++++++++++ .../global/logging/MdcTaskDecoratorTest.java | 63 +++++++++++++++++++ 4 files changed, 173 insertions(+), 2 deletions(-) create mode 100644 src/main/java/umc/cockple/demo/global/logging/MdcTaskDecorator.java create mode 100644 src/test/java/umc/cockple/demo/global/config/AsyncConfigTest.java create mode 100644 src/test/java/umc/cockple/demo/global/logging/MdcTaskDecoratorTest.java diff --git a/src/main/java/umc/cockple/demo/global/config/AsyncConfig.java b/src/main/java/umc/cockple/demo/global/config/AsyncConfig.java index 50d862fcc..e9e74d1a7 100644 --- a/src/main/java/umc/cockple/demo/global/config/AsyncConfig.java +++ b/src/main/java/umc/cockple/demo/global/config/AsyncConfig.java @@ -1,9 +1,31 @@ package umc.cockple.demo.global.config; +import org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfiguration; +import org.springframework.boot.task.ThreadPoolTaskExecutorBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.task.TaskDecorator; import org.springframework.scheduling.annotation.EnableAsync; -import org.springframework.stereotype.Component; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import umc.cockple.demo.global.logging.MdcTaskDecorator; -@Component +@Configuration @EnableAsync public class AsyncConfig { + + @Bean + public TaskDecorator mdcTaskDecorator() { + return new MdcTaskDecorator(); + } + + @Bean(name = TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME) + public ThreadPoolTaskExecutor applicationTaskExecutor( + ThreadPoolTaskExecutorBuilder builder, + TaskDecorator mdcTaskDecorator + ) { + return builder + .threadNamePrefix("cockple-async-") + .taskDecorator(mdcTaskDecorator) + .build(); + } } diff --git a/src/main/java/umc/cockple/demo/global/logging/MdcTaskDecorator.java b/src/main/java/umc/cockple/demo/global/logging/MdcTaskDecorator.java new file mode 100644 index 000000000..dffcd8415 --- /dev/null +++ b/src/main/java/umc/cockple/demo/global/logging/MdcTaskDecorator.java @@ -0,0 +1,32 @@ +package umc.cockple.demo.global.logging; + +import org.slf4j.MDC; +import org.springframework.core.task.TaskDecorator; + +import java.util.Map; + +public class MdcTaskDecorator implements TaskDecorator { + + @Override + public Runnable decorate(Runnable runnable) { + Map parentContext = MDC.getCopyOfContextMap(); + + return () -> { + Map previousContext = MDC.getCopyOfContextMap(); + try { + setContext(parentContext); + runnable.run(); + } finally { + setContext(previousContext); + } + }; + } + + private void setContext(Map context) { + if (context == null || context.isEmpty()) { + MDC.clear(); + return; + } + MDC.setContextMap(context); + } +} diff --git a/src/test/java/umc/cockple/demo/global/config/AsyncConfigTest.java b/src/test/java/umc/cockple/demo/global/config/AsyncConfigTest.java new file mode 100644 index 000000000..0a248daee --- /dev/null +++ b/src/test/java/umc/cockple/demo/global/config/AsyncConfigTest.java @@ -0,0 +1,54 @@ +package umc.cockple.demo.global.config; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.slf4j.MDC; +import org.springframework.boot.task.ThreadPoolTaskExecutorBuilder; +import org.springframework.core.task.TaskDecorator; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import umc.cockple.demo.global.logging.MdcLoggingFilter; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +class AsyncConfigTest { + + @AfterEach + void tearDown() { + MDC.clear(); + } + + @Test + @DisplayName("applicationTaskExecutor는 MDC를 비동기 작업에 전파하고 다음 작업으로 누수하지 않는다") + void applicationTaskExecutorPropagatesMdcAndPreventsLeak() throws Exception { + AsyncConfig config = new AsyncConfig(); + TaskDecorator taskDecorator = config.mdcTaskDecorator(); + ThreadPoolTaskExecutor executor = config.applicationTaskExecutor( + new ThreadPoolTaskExecutorBuilder() + .corePoolSize(1) + .maxPoolSize(1) + .queueCapacity(1), + taskDecorator + ); + executor.initialize(); + + try { + MDC.put(MdcLoggingFilter.REQUEST_ID, "async-request"); + CompletableFuture firstTask = new CompletableFuture<>(); + executor.execute(() -> firstTask.complete(MDC.get(MdcLoggingFilter.REQUEST_ID))); + + assertThat(firstTask.get(3, TimeUnit.SECONDS)).isEqualTo("async-request"); + + MDC.clear(); + CompletableFuture secondTask = new CompletableFuture<>(); + executor.execute(() -> secondTask.complete(MDC.get(MdcLoggingFilter.REQUEST_ID))); + + assertThat(secondTask.get(3, TimeUnit.SECONDS)).isNull(); + } finally { + executor.shutdown(); + } + } +} diff --git a/src/test/java/umc/cockple/demo/global/logging/MdcTaskDecoratorTest.java b/src/test/java/umc/cockple/demo/global/logging/MdcTaskDecoratorTest.java new file mode 100644 index 000000000..93172c94a --- /dev/null +++ b/src/test/java/umc/cockple/demo/global/logging/MdcTaskDecoratorTest.java @@ -0,0 +1,63 @@ +package umc.cockple.demo.global.logging; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.slf4j.MDC; +import umc.cockple.demo.global.security.filter.JwtAuthenticationFilter; + +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; + +class MdcTaskDecoratorTest { + + private final MdcTaskDecorator decorator = new MdcTaskDecorator(); + + @AfterEach + void tearDown() { + MDC.clear(); + } + + @Test + @DisplayName("decorate 시점의 MDC를 작업 실행 동안 전파하고 이전 MDC를 복구한다") + void copyParentMdcAndRestorePreviousMdc() { + MDC.put(MdcLoggingFilter.REQUEST_ID, "request-1"); + MDC.put(JwtAuthenticationFilter.MEMBER_ID, "7"); + + AtomicReference requestIdInTask = new AtomicReference<>(); + AtomicReference memberIdInTask = new AtomicReference<>(); + Runnable decorated = decorator.decorate(() -> { + requestIdInTask.set(MDC.get(MdcLoggingFilter.REQUEST_ID)); + memberIdInTask.set(MDC.get(JwtAuthenticationFilter.MEMBER_ID)); + }); + + MDC.clear(); + MDC.put(MdcLoggingFilter.REQUEST_ID, "worker-before"); + + decorated.run(); + + assertThat(requestIdInTask.get()).isEqualTo("request-1"); + assertThat(memberIdInTask.get()).isEqualTo("7"); + assertThat(MDC.get(MdcLoggingFilter.REQUEST_ID)).isEqualTo("worker-before"); + assertThat(MDC.get(JwtAuthenticationFilter.MEMBER_ID)).isNull(); + } + + @Test + @DisplayName("decorate 시점에 MDC가 없으면 작업 실행 동안 기존 worker MDC를 비운 뒤 복구한다") + void clearWorkerMdcWhenParentMdcIsEmptyAndRestoreAfterwards() { + MDC.clear(); + + AtomicReference requestIdInTask = new AtomicReference<>(); + Runnable decorated = decorator.decorate(() -> + requestIdInTask.set(MDC.get(MdcLoggingFilter.REQUEST_ID)) + ); + + MDC.put(MdcLoggingFilter.REQUEST_ID, "worker-before"); + + decorated.run(); + + assertThat(requestIdInTask.get()).isNull(); + assertThat(MDC.get(MdcLoggingFilter.REQUEST_ID)).isEqualTo("worker-before"); + } +} From 8b3724556e637104e90ddb35830801f997200d4c Mon Sep 17 00:00:00 2001 From: Dimo-2562 Date: Fri, 3 Jul 2026 15:54:13 +0900 Subject: [PATCH 03/17] =?UTF-8?q?chore:=20Logback=20=EA=B8=B0=EB=B0=98=20?= =?UTF-8?q?=EC=BD=98=EC=86=94=20=EB=B0=8F=20=ED=8C=8C=EC=9D=BC=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 + src/main/resources/logback-spring.xml | 76 +++++++++++++++ .../LogbackSpringConfigurationTest.java | 97 +++++++++++++++++++ 3 files changed, 176 insertions(+) create mode 100644 src/main/resources/logback-spring.xml create mode 100644 src/test/java/umc/cockple/demo/global/logging/LogbackSpringConfigurationTest.java diff --git a/.gitignore b/.gitignore index aeff3891d..5add0cde6 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,6 @@ src/main/resources/firebase/*.json cockple_dump.sql k6 + +### logs ### +logs/ diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 000000000..c6e063e5d --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + ${LOG_PATTERN} + UTF-8 + + + + + ${APP_LOG_FILE} + + ${LOG_PATTERN} + UTF-8 + + + ${LOG_PATH}/application.%d{yyyy-MM-dd}.%i.log.gz + 100MB + 30 + 3GB + + + + + ${WEBSOCKET_LOG_FILE} + + ${LOG_PATTERN} + UTF-8 + + + ${LOG_PATH}/websocket.%d{yyyy-MM-dd}.%i.log.gz + 50MB + 14 + 1GB + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/test/java/umc/cockple/demo/global/logging/LogbackSpringConfigurationTest.java b/src/test/java/umc/cockple/demo/global/logging/LogbackSpringConfigurationTest.java new file mode 100644 index 000000000..3b7737a09 --- /dev/null +++ b/src/test/java/umc/cockple/demo/global/logging/LogbackSpringConfigurationTest.java @@ -0,0 +1,97 @@ +package umc.cockple.demo.global.logging; + +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.joran.JoranConfigurator; +import ch.qos.logback.core.Appender; +import ch.qos.logback.core.status.Status; +import ch.qos.logback.core.status.StatusUtil; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.boot.logging.LoggingInitializationContext; +import org.springframework.core.io.ClassPathResource; +import org.springframework.mock.env.MockEnvironment; + +import java.lang.reflect.Constructor; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class LogbackSpringConfigurationTest { + + private static final String WEBSOCKET_LOGGER = "umc.cockple.demo.domain.chat.presentation.websocket"; + private static final String WEBSOCKET_SERVICE_LOGGER = "umc.cockple.demo.domain.chat.service.websocket"; + private static final String SPRING_WEBSOCKET_LOGGER = "org.springframework.web.socket"; + + @TempDir + Path logPath; + + @Test + @DisplayName("prod 프로필에서는 WebSocket 로그가 파일 appender로만 라우팅된다") + void prodProfileRoutesWebSocketLogsToFileOnly() throws Exception { + LoggerContext context = configure("prod"); + + try { + assertThat(appenderNames(context.getLogger(WEBSOCKET_LOGGER))).containsExactly("WEBSOCKET_FILE"); + assertThat(context.getLogger(WEBSOCKET_LOGGER).isAdditive()).isFalse(); + assertThat(appenderNames(context.getLogger(WEBSOCKET_SERVICE_LOGGER))).containsExactly("WEBSOCKET_FILE"); + assertThat(appenderNames(context.getLogger(SPRING_WEBSOCKET_LOGGER))).containsExactly("WEBSOCKET_FILE"); + assertThat(appenderNames(context.getLogger(Logger.ROOT_LOGGER_NAME))) + .containsExactlyInAnyOrder("CONSOLE", "APPLICATION_FILE"); + } finally { + context.stop(); + } + } + + @Test + @DisplayName("local 프로필에서는 WebSocket 로그가 콘솔과 파일 appender에 함께 라우팅된다") + void localProfileRoutesWebSocketLogsToConsoleAndFile() throws Exception { + LoggerContext context = configure("local"); + + try { + assertThat(appenderNames(context.getLogger(WEBSOCKET_LOGGER))) + .containsExactlyInAnyOrder("CONSOLE", "WEBSOCKET_FILE"); + assertThat(context.getLogger(WEBSOCKET_LOGGER).isAdditive()).isFalse(); + assertThat(appenderNames(context.getLogger(WEBSOCKET_SERVICE_LOGGER))) + .containsExactlyInAnyOrder("CONSOLE", "WEBSOCKET_FILE"); + assertThat(appenderNames(context.getLogger(Logger.ROOT_LOGGER_NAME))) + .containsExactlyInAnyOrder("CONSOLE", "APPLICATION_FILE"); + } finally { + context.stop(); + } + } + + private LoggerContext configure(String profile) throws Exception { + LoggerContext context = new LoggerContext(); + context.putProperty("LOG_PATH", logPath.toString()); + + MockEnvironment environment = new MockEnvironment(); + environment.setActiveProfiles(profile); + LoggingInitializationContext initializationContext = new LoggingInitializationContext(environment); + + Constructor constructor = Class.forName("org.springframework.boot.logging.logback.SpringBootJoranConfigurator") + .getDeclaredConstructor(LoggingInitializationContext.class); + constructor.setAccessible(true); + JoranConfigurator configurator = (JoranConfigurator) constructor.newInstance(initializationContext); + configurator.setContext(context); + configurator.doConfigure(new ClassPathResource("logback-spring.xml").getFile()); + + StatusUtil statusUtil = new StatusUtil(context); + assertThat(statusUtil.getHighestLevel(0)).isLessThan(Status.ERROR); + return context; + } + + private List appenderNames(Logger logger) { + List names = new ArrayList<>(); + Iterator> iterator = logger.iteratorForAppenders(); + while (iterator.hasNext()) { + Appender appender = iterator.next(); + names.add(appender.getName()); + } + return names; + } +} From 0eb42ced0d5020f6f50564c1c7fda239c24d81e1 Mon Sep 17 00:00:00 2001 From: Dimo-2562 Date: Fri, 3 Jul 2026 15:56:16 +0900 Subject: [PATCH 04/17] =?UTF-8?q?chore:=20=EC=BD=98=EC=86=94=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=97=90=EB=8A=94=20=EC=83=89=EC=83=81=EC=9D=84=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=ED=95=98=EC=97=AC=20=EA=B0=80=EC=8B=9C?= =?UTF-8?q?=EC=84=B1=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/logback-spring.xml | 9 +++++---- .../LogbackSpringConfigurationTest.java | 20 +++++++++++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index c6e063e5d..3d486780c 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -5,11 +5,12 @@ - + + - ${LOG_PATTERN} + ${CONSOLE_LOG_PATTERN} UTF-8 @@ -17,7 +18,7 @@ ${APP_LOG_FILE} - ${LOG_PATTERN} + ${FILE_LOG_PATTERN} UTF-8 @@ -31,7 +32,7 @@ ${WEBSOCKET_LOG_FILE} - ${LOG_PATTERN} + ${FILE_LOG_PATTERN} UTF-8 diff --git a/src/test/java/umc/cockple/demo/global/logging/LogbackSpringConfigurationTest.java b/src/test/java/umc/cockple/demo/global/logging/LogbackSpringConfigurationTest.java index 3b7737a09..fe1051c9e 100644 --- a/src/test/java/umc/cockple/demo/global/logging/LogbackSpringConfigurationTest.java +++ b/src/test/java/umc/cockple/demo/global/logging/LogbackSpringConfigurationTest.java @@ -2,8 +2,10 @@ import ch.qos.logback.classic.Logger; import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.encoder.PatternLayoutEncoder; import ch.qos.logback.classic.joran.JoranConfigurator; import ch.qos.logback.core.Appender; +import ch.qos.logback.core.OutputStreamAppender; import ch.qos.logback.core.status.Status; import ch.qos.logback.core.status.StatusUtil; import org.junit.jupiter.api.DisplayName; @@ -42,6 +44,9 @@ void prodProfileRoutesWebSocketLogsToFileOnly() throws Exception { assertThat(appenderNames(context.getLogger(SPRING_WEBSOCKET_LOGGER))).containsExactly("WEBSOCKET_FILE"); assertThat(appenderNames(context.getLogger(Logger.ROOT_LOGGER_NAME))) .containsExactlyInAnyOrder("CONSOLE", "APPLICATION_FILE"); + assertThat(appenderPattern(context, "CONSOLE")).contains("%clr", "%highlight"); + assertThat(appenderPattern(context, "APPLICATION_FILE")).doesNotContain("%clr", "%highlight"); + assertThat(appenderPattern(context, "WEBSOCKET_FILE")).doesNotContain("%clr", "%highlight"); } finally { context.stop(); } @@ -60,6 +65,9 @@ void localProfileRoutesWebSocketLogsToConsoleAndFile() throws Exception { .containsExactlyInAnyOrder("CONSOLE", "WEBSOCKET_FILE"); assertThat(appenderNames(context.getLogger(Logger.ROOT_LOGGER_NAME))) .containsExactlyInAnyOrder("CONSOLE", "APPLICATION_FILE"); + assertThat(appenderPattern(context, "CONSOLE")).contains("%clr", "%highlight"); + assertThat(appenderPattern(context, "APPLICATION_FILE")).doesNotContain("%clr", "%highlight"); + assertThat(appenderPattern(context, "WEBSOCKET_FILE")).doesNotContain("%clr", "%highlight"); } finally { context.stop(); } @@ -94,4 +102,16 @@ private List appenderNames(Logger logger) { } return names; } + + private String appenderPattern(LoggerContext context, String appenderName) { + Appender appender = context.getLogger(Logger.ROOT_LOGGER_NAME).getAppender(appenderName); + if (appender == null) { + appender = context.getLogger(WEBSOCKET_LOGGER).getAppender(appenderName); + } + assertThat(appender).isInstanceOf(OutputStreamAppender.class); + OutputStreamAppender outputStreamAppender = (OutputStreamAppender) appender; + assertThat(outputStreamAppender.getEncoder()).isInstanceOf(PatternLayoutEncoder.class); + PatternLayoutEncoder encoder = (PatternLayoutEncoder) outputStreamAppender.getEncoder(); + return encoder.getPattern(); + } } From bafd0e616fcbb4f56689f6416bfe6ce5486dd03e Mon Sep 17 00:00:00 2001 From: Dimo-2562 Date: Fri, 3 Jul 2026 16:01:11 +0900 Subject: [PATCH 05/17] =?UTF-8?q?chore:=20=EB=A1=9C=EA=B7=B8=20I/O?= =?UTF-8?q?=EB=A5=BC=20=EB=B9=84=EB=8F=99=EA=B8=B0=EB=A1=9C=20=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/logback-spring.xml | 28 ++++++-- .../LogbackSpringConfigurationTest.java | 70 ++++++++++++++++--- 2 files changed, 80 insertions(+), 18 deletions(-) diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index 3d486780c..ac02256d5 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -43,35 +43,49 @@ + + 0 + 1024 + false + + + + + 0 + 2048 + false + + + - + - + - + - + - + - + - + diff --git a/src/test/java/umc/cockple/demo/global/logging/LogbackSpringConfigurationTest.java b/src/test/java/umc/cockple/demo/global/logging/LogbackSpringConfigurationTest.java index fe1051c9e..435760a45 100644 --- a/src/test/java/umc/cockple/demo/global/logging/LogbackSpringConfigurationTest.java +++ b/src/test/java/umc/cockple/demo/global/logging/LogbackSpringConfigurationTest.java @@ -2,6 +2,7 @@ import ch.qos.logback.classic.Logger; import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.AsyncAppender; import ch.qos.logback.classic.encoder.PatternLayoutEncoder; import ch.qos.logback.classic.joran.JoranConfigurator; import ch.qos.logback.core.Appender; @@ -38,15 +39,17 @@ void prodProfileRoutesWebSocketLogsToFileOnly() throws Exception { LoggerContext context = configure("prod"); try { - assertThat(appenderNames(context.getLogger(WEBSOCKET_LOGGER))).containsExactly("WEBSOCKET_FILE"); + assertThat(appenderNames(context.getLogger(WEBSOCKET_LOGGER))).containsExactly("ASYNC_WEBSOCKET_FILE"); assertThat(context.getLogger(WEBSOCKET_LOGGER).isAdditive()).isFalse(); - assertThat(appenderNames(context.getLogger(WEBSOCKET_SERVICE_LOGGER))).containsExactly("WEBSOCKET_FILE"); - assertThat(appenderNames(context.getLogger(SPRING_WEBSOCKET_LOGGER))).containsExactly("WEBSOCKET_FILE"); + assertThat(appenderNames(context.getLogger(WEBSOCKET_SERVICE_LOGGER))).containsExactly("ASYNC_WEBSOCKET_FILE"); + assertThat(appenderNames(context.getLogger(SPRING_WEBSOCKET_LOGGER))).containsExactly("ASYNC_WEBSOCKET_FILE"); assertThat(appenderNames(context.getLogger(Logger.ROOT_LOGGER_NAME))) - .containsExactlyInAnyOrder("CONSOLE", "APPLICATION_FILE"); + .containsExactlyInAnyOrder("CONSOLE", "ASYNC_APPLICATION_FILE"); assertThat(appenderPattern(context, "CONSOLE")).contains("%clr", "%highlight"); assertThat(appenderPattern(context, "APPLICATION_FILE")).doesNotContain("%clr", "%highlight"); assertThat(appenderPattern(context, "WEBSOCKET_FILE")).doesNotContain("%clr", "%highlight"); + assertAsyncAppender(context, "ASYNC_APPLICATION_FILE", "APPLICATION_FILE", 1024); + assertAsyncAppender(context, "ASYNC_WEBSOCKET_FILE", "WEBSOCKET_FILE", 2048); } finally { context.stop(); } @@ -59,15 +62,17 @@ void localProfileRoutesWebSocketLogsToConsoleAndFile() throws Exception { try { assertThat(appenderNames(context.getLogger(WEBSOCKET_LOGGER))) - .containsExactlyInAnyOrder("CONSOLE", "WEBSOCKET_FILE"); + .containsExactlyInAnyOrder("CONSOLE", "ASYNC_WEBSOCKET_FILE"); assertThat(context.getLogger(WEBSOCKET_LOGGER).isAdditive()).isFalse(); assertThat(appenderNames(context.getLogger(WEBSOCKET_SERVICE_LOGGER))) - .containsExactlyInAnyOrder("CONSOLE", "WEBSOCKET_FILE"); + .containsExactlyInAnyOrder("CONSOLE", "ASYNC_WEBSOCKET_FILE"); assertThat(appenderNames(context.getLogger(Logger.ROOT_LOGGER_NAME))) - .containsExactlyInAnyOrder("CONSOLE", "APPLICATION_FILE"); + .containsExactlyInAnyOrder("CONSOLE", "ASYNC_APPLICATION_FILE"); assertThat(appenderPattern(context, "CONSOLE")).contains("%clr", "%highlight"); assertThat(appenderPattern(context, "APPLICATION_FILE")).doesNotContain("%clr", "%highlight"); assertThat(appenderPattern(context, "WEBSOCKET_FILE")).doesNotContain("%clr", "%highlight"); + assertAsyncAppender(context, "ASYNC_APPLICATION_FILE", "APPLICATION_FILE", 1024); + assertAsyncAppender(context, "ASYNC_WEBSOCKET_FILE", "WEBSOCKET_FILE", 2048); } finally { context.stop(); } @@ -104,14 +109,57 @@ private List appenderNames(Logger logger) { } private String appenderPattern(LoggerContext context, String appenderName) { - Appender appender = context.getLogger(Logger.ROOT_LOGGER_NAME).getAppender(appenderName); - if (appender == null) { - appender = context.getLogger(WEBSOCKET_LOGGER).getAppender(appenderName); - } + Appender appender = findAppender(context, appenderName); assertThat(appender).isInstanceOf(OutputStreamAppender.class); OutputStreamAppender outputStreamAppender = (OutputStreamAppender) appender; assertThat(outputStreamAppender.getEncoder()).isInstanceOf(PatternLayoutEncoder.class); PatternLayoutEncoder encoder = (PatternLayoutEncoder) outputStreamAppender.getEncoder(); return encoder.getPattern(); } + + private void assertAsyncAppender( + LoggerContext context, + String asyncAppenderName, + String nestedAppenderName, + int queueSize + ) { + Appender appender = findAppender(context, asyncAppenderName); + assertThat(appender).isInstanceOf(AsyncAppender.class); + AsyncAppender asyncAppender = (AsyncAppender) appender; + assertThat(asyncAppender.getQueueSize()).isEqualTo(queueSize); + assertThat(asyncAppender.getDiscardingThreshold()).isZero(); + assertThat(asyncAppender.isIncludeCallerData()).isFalse(); + assertThat(asyncAppender.getAppender(nestedAppenderName)).isNotNull(); + } + + private Appender findAppender(LoggerContext context, String appenderName) { + List loggers = List.of( + context.getLogger(Logger.ROOT_LOGGER_NAME), + context.getLogger(WEBSOCKET_LOGGER), + context.getLogger(WEBSOCKET_SERVICE_LOGGER), + context.getLogger(SPRING_WEBSOCKET_LOGGER) + ); + + for (Logger logger : loggers) { + Appender directAppender = logger.getAppender(appenderName); + if (directAppender != null) { + return directAppender; + } + } + + for (Logger logger : loggers) { + Iterator> iterator = logger.iteratorForAppenders(); + while (iterator.hasNext()) { + Appender appender = iterator.next(); + if (appender instanceof AsyncAppender asyncAppender) { + Appender nestedAppender = asyncAppender.getAppender(appenderName); + if (nestedAppender != null) { + return nestedAppender; + } + } + } + } + + return null; + } } From 84ba1e82a325bec31ee73c6ef07a31233b66d8a4 Mon Sep 17 00:00:00 2001 From: Dimo-2562 Date: Fri, 3 Jul 2026 16:05:18 +0900 Subject: [PATCH 06/17] =?UTF-8?q?chore:=20=EC=97=90=EB=9F=AC=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EB=8A=94=20=EB=B3=84=EB=8F=84=EB=A1=9C=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EC=A0=80=EC=9E=A5=ED=95=98=EC=97=AC=20=EC=9A=B4?= =?UTF-8?q?=EC=98=81=20=ED=8E=B8=EB=A6=AC=EC=84=B1=20=ED=96=A5=EC=83=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/logback-spring.xml | 32 +++++++++++++++++ .../LogbackSpringConfigurationTest.java | 36 +++++++++++++++---- 2 files changed, 61 insertions(+), 7 deletions(-) diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index ac02256d5..48e364017 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -5,6 +5,7 @@ + @@ -43,6 +44,23 @@ + + ${ERROR_LOG_FILE} + + ERROR + + + ${FILE_LOG_PATTERN} + UTF-8 + + + ${LOG_PATH}/error.%d{yyyy-MM-dd}.%i.log.gz + 50MB + 30 + 1GB + + + 0 1024 @@ -57,35 +75,49 @@ + + 0 + 512 + false + + + + + + + + + + diff --git a/src/test/java/umc/cockple/demo/global/logging/LogbackSpringConfigurationTest.java b/src/test/java/umc/cockple/demo/global/logging/LogbackSpringConfigurationTest.java index 435760a45..a3eec557f 100644 --- a/src/test/java/umc/cockple/demo/global/logging/LogbackSpringConfigurationTest.java +++ b/src/test/java/umc/cockple/demo/global/logging/LogbackSpringConfigurationTest.java @@ -4,9 +4,11 @@ import ch.qos.logback.classic.LoggerContext; import ch.qos.logback.classic.AsyncAppender; import ch.qos.logback.classic.encoder.PatternLayoutEncoder; +import ch.qos.logback.classic.filter.ThresholdFilter; import ch.qos.logback.classic.joran.JoranConfigurator; import ch.qos.logback.core.Appender; import ch.qos.logback.core.OutputStreamAppender; +import ch.qos.logback.core.spi.FilterAttachable; import ch.qos.logback.core.status.Status; import ch.qos.logback.core.status.StatusUtil; import org.junit.jupiter.api.DisplayName; @@ -39,17 +41,23 @@ void prodProfileRoutesWebSocketLogsToFileOnly() throws Exception { LoggerContext context = configure("prod"); try { - assertThat(appenderNames(context.getLogger(WEBSOCKET_LOGGER))).containsExactly("ASYNC_WEBSOCKET_FILE"); + assertThat(appenderNames(context.getLogger(WEBSOCKET_LOGGER))) + .containsExactlyInAnyOrder("ASYNC_WEBSOCKET_FILE", "ASYNC_ERROR_FILE"); assertThat(context.getLogger(WEBSOCKET_LOGGER).isAdditive()).isFalse(); - assertThat(appenderNames(context.getLogger(WEBSOCKET_SERVICE_LOGGER))).containsExactly("ASYNC_WEBSOCKET_FILE"); - assertThat(appenderNames(context.getLogger(SPRING_WEBSOCKET_LOGGER))).containsExactly("ASYNC_WEBSOCKET_FILE"); + assertThat(appenderNames(context.getLogger(WEBSOCKET_SERVICE_LOGGER))) + .containsExactlyInAnyOrder("ASYNC_WEBSOCKET_FILE", "ASYNC_ERROR_FILE"); + assertThat(appenderNames(context.getLogger(SPRING_WEBSOCKET_LOGGER))) + .containsExactlyInAnyOrder("ASYNC_WEBSOCKET_FILE", "ASYNC_ERROR_FILE"); assertThat(appenderNames(context.getLogger(Logger.ROOT_LOGGER_NAME))) - .containsExactlyInAnyOrder("CONSOLE", "ASYNC_APPLICATION_FILE"); + .containsExactlyInAnyOrder("CONSOLE", "ASYNC_APPLICATION_FILE", "ASYNC_ERROR_FILE"); assertThat(appenderPattern(context, "CONSOLE")).contains("%clr", "%highlight"); assertThat(appenderPattern(context, "APPLICATION_FILE")).doesNotContain("%clr", "%highlight"); assertThat(appenderPattern(context, "WEBSOCKET_FILE")).doesNotContain("%clr", "%highlight"); + assertThat(appenderPattern(context, "ERROR_FILE")).doesNotContain("%clr", "%highlight"); assertAsyncAppender(context, "ASYNC_APPLICATION_FILE", "APPLICATION_FILE", 1024); assertAsyncAppender(context, "ASYNC_WEBSOCKET_FILE", "WEBSOCKET_FILE", 2048); + assertAsyncAppender(context, "ASYNC_ERROR_FILE", "ERROR_FILE", 512); + assertErrorThresholdFilter(context); } finally { context.stop(); } @@ -62,17 +70,20 @@ void localProfileRoutesWebSocketLogsToConsoleAndFile() throws Exception { try { assertThat(appenderNames(context.getLogger(WEBSOCKET_LOGGER))) - .containsExactlyInAnyOrder("CONSOLE", "ASYNC_WEBSOCKET_FILE"); + .containsExactlyInAnyOrder("CONSOLE", "ASYNC_WEBSOCKET_FILE", "ASYNC_ERROR_FILE"); assertThat(context.getLogger(WEBSOCKET_LOGGER).isAdditive()).isFalse(); assertThat(appenderNames(context.getLogger(WEBSOCKET_SERVICE_LOGGER))) - .containsExactlyInAnyOrder("CONSOLE", "ASYNC_WEBSOCKET_FILE"); + .containsExactlyInAnyOrder("CONSOLE", "ASYNC_WEBSOCKET_FILE", "ASYNC_ERROR_FILE"); assertThat(appenderNames(context.getLogger(Logger.ROOT_LOGGER_NAME))) - .containsExactlyInAnyOrder("CONSOLE", "ASYNC_APPLICATION_FILE"); + .containsExactlyInAnyOrder("CONSOLE", "ASYNC_APPLICATION_FILE", "ASYNC_ERROR_FILE"); assertThat(appenderPattern(context, "CONSOLE")).contains("%clr", "%highlight"); assertThat(appenderPattern(context, "APPLICATION_FILE")).doesNotContain("%clr", "%highlight"); assertThat(appenderPattern(context, "WEBSOCKET_FILE")).doesNotContain("%clr", "%highlight"); + assertThat(appenderPattern(context, "ERROR_FILE")).doesNotContain("%clr", "%highlight"); assertAsyncAppender(context, "ASYNC_APPLICATION_FILE", "APPLICATION_FILE", 1024); assertAsyncAppender(context, "ASYNC_WEBSOCKET_FILE", "WEBSOCKET_FILE", 2048); + assertAsyncAppender(context, "ASYNC_ERROR_FILE", "ERROR_FILE", 512); + assertErrorThresholdFilter(context); } finally { context.stop(); } @@ -132,6 +143,17 @@ private void assertAsyncAppender( assertThat(asyncAppender.getAppender(nestedAppenderName)).isNotNull(); } + @SuppressWarnings("unchecked") + private void assertErrorThresholdFilter(LoggerContext context) { + Appender errorAppender = findAppender(context, "ERROR_FILE"); + assertThat(errorAppender).isInstanceOf(FilterAttachable.class); + FilterAttachable filterAttachable = + (FilterAttachable) errorAppender; + assertThat(filterAttachable.getCopyOfAttachedFiltersList()) + .hasSize(1) + .allMatch(ThresholdFilter.class::isInstance); + } + private Appender findAppender(LoggerContext context, String appenderName) { List loggers = List.of( context.getLogger(Logger.ROOT_LOGGER_NAME), From 946ab99d8145a88b6aade42d0d43fc63b627782f Mon Sep 17 00:00:00 2001 From: Dimo-2562 Date: Fri, 3 Jul 2026 16:11:02 +0900 Subject: [PATCH 07/17] =?UTF-8?q?feat:=20=EC=9B=B9=EC=86=8C=EC=BC=93?= =?UTF-8?q?=EC=97=90=EB=8F=84=20MDC=EB=A5=BC=20=EB=B3=84=EB=8F=84=EB=A1=9C?= =?UTF-8?q?=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../websocket/ChatWebSocketHandler.java | 62 ++++++++------- .../ChatWebSocketRequestDispatcher.java | 38 +++++----- .../JWTWebSocketAuthInterceptor.java | 34 +++++---- .../websocket/WebSocketMdcSupport.java | 76 +++++++++++++++++++ .../websocket/ChatWebSocketHandlerTest.java | 21 +++++ .../ChatWebSocketRequestDispatcherTest.java | 21 +++++ .../JWTWebSocketAuthInterceptorTest.java | 64 ++++++++++++++++ .../websocket/WebSocketMdcSupportTest.java | 63 +++++++++++++++ 8 files changed, 320 insertions(+), 59 deletions(-) create mode 100644 src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/WebSocketMdcSupport.java create mode 100644 src/test/java/umc/cockple/demo/domain/chat/presentation/websocket/JWTWebSocketAuthInterceptorTest.java create mode 100644 src/test/java/umc/cockple/demo/domain/chat/presentation/websocket/WebSocketMdcSupportTest.java diff --git a/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/ChatWebSocketHandler.java b/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/ChatWebSocketHandler.java index aa40bf221..99f218010 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/ChatWebSocketHandler.java +++ b/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/ChatWebSocketHandler.java @@ -23,55 +23,63 @@ public class ChatWebSocketHandler extends TextWebSocketHandler { @Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { - log.info("웹소켓 연결 성공"); + try (WebSocketMdcSupport.MdcScope ignored = WebSocketMdcSupport.open(session)) { + log.info("웹소켓 연결 성공"); - try { - Long memberId = (Long) session.getAttributes().get("memberId"); - Boolean authenticated = (Boolean) session.getAttributes().get("authenticated"); + try { + Long memberId = (Long) session.getAttributes().get("memberId"); + Boolean authenticated = (Boolean) session.getAttributes().get("authenticated"); - if (memberId != null && Boolean.TRUE.equals(authenticated)) { - MemberConnectionInfo memberInfo = memberQueryService.getMemberConnectionInfo(memberId); - session.getAttributes().put("memberName", memberInfo.memberName()); + if (memberId != null && Boolean.TRUE.equals(authenticated)) { + MemberConnectionInfo memberInfo = memberQueryService.getMemberConnectionInfo(memberId); + session.getAttributes().put("memberName", memberInfo.memberName()); - sessionRegistry.register(memberId, session); - log.info("사용자 연결 완료 - memberId: {}, 세션 ID: {}", memberId, session.getId()); + sessionRegistry.register(memberId, session); + log.info("사용자 연결 완료 - memberId: {}, 세션 ID: {}", memberId, session.getId()); - webSocketResponseSender.sendConnectionSuccessMessage(session, memberInfo); - } else { - log.warn("memberId를 찾을 수 없습니다. 세션을 종료합니다."); + webSocketResponseSender.sendConnectionSuccessMessage(session, memberInfo); + } else { + log.warn("memberId를 찾을 수 없습니다. 세션을 종료합니다."); + session.close(); + } + } catch (Exception e) { + log.error("WebSocket 연결 처리 중 오류 발생", e); session.close(); } - } catch (Exception e) { - log.error("WebSocket 연결 처리 중 오류 발생", e); - session.close(); } } @Override public void handleTextMessage(WebSocketSession session, TextMessage message) { - requestDispatcher.dispatch(session, message.getPayload()); + try (WebSocketMdcSupport.MdcScope ignored = WebSocketMdcSupport.open(session)) { + requestDispatcher.dispatch(session, message.getPayload()); + } } @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status){ - Long memberId = (Long) session.getAttributes().get("memberId"); + try (WebSocketMdcSupport.MdcScope ignored = WebSocketMdcSupport.open(session)) { + Long memberId = (Long) session.getAttributes().get("memberId"); - log.info("웹소켓 연결 종료"); - log.info("세션 ID: {}, 사용자 ID: {}, 종료 상태: {}", session.getId(), memberId, status); + log.info("웹소켓 연결 종료"); + log.info("세션 ID: {}, 사용자 ID: {}, 종료 상태: {}", session.getId(), memberId, status); - if (memberId != null) { - sessionRegistry.remove(memberId, session); - log.info("사용자 세션 정리 완료 - memberId: {}", memberId); + if (memberId != null) { + sessionRegistry.remove(memberId, session); + log.info("사용자 세션 정리 완료 - memberId: {}", memberId); + } } } @Override public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception { - Long memberId = (Long) session.getAttributes().get("memberId"); - if (isShutdownRelatedError(exception)) { - log.debug("서버 종료 관련 WebSocket 전송 오류 (정상) - 세션: {}, 사용자: {}", session.getId(), memberId); - } else { - log.error("WebSocket 전송 오류 발생 - 세션 ID: {}, 사용자 ID: {}", session.getId(), memberId, exception); + try (WebSocketMdcSupport.MdcScope ignored = WebSocketMdcSupport.open(session)) { + Long memberId = (Long) session.getAttributes().get("memberId"); + if (isShutdownRelatedError(exception)) { + log.debug("서버 종료 관련 WebSocket 전송 오류 (정상) - 세션: {}, 사용자: {}", session.getId(), memberId); + } else { + log.error("WebSocket 전송 오류 발생 - 세션 ID: {}, 사용자 ID: {}", session.getId(), memberId, exception); + } } } diff --git a/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/ChatWebSocketRequestDispatcher.java b/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/ChatWebSocketRequestDispatcher.java index 6bf9fe6cf..654c39bd0 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/ChatWebSocketRequestDispatcher.java +++ b/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/ChatWebSocketRequestDispatcher.java @@ -17,25 +17,27 @@ public class ChatWebSocketRequestDispatcher { private final ChatWebSocketCommandHandler commandHandler; public void dispatch(WebSocketSession session, String payload) { - log.info("메시지 수신 - 세션 ID: {}, payloadSize: {}", session.getId(), payload == null ? 0 : payload.length()); - - try { - WebSocketMessageDTO.Request request = objectMapper.readValue( - payload, WebSocketMessageDTO.Request.class - ); - - Long memberId = (Long) session.getAttributes().get("memberId"); - if (memberId == null) { - webSocketResponseSender.sendErrorMessage(session, "UNAUTHORIZED", "인증되지 않은 사용자입니다."); - return; + try (WebSocketMdcSupport.MdcScope ignored = WebSocketMdcSupport.open(session)) { + log.info("메시지 수신 - 세션 ID: {}, payloadSize: {}", session.getId(), payload == null ? 0 : payload.length()); + + try { + WebSocketMessageDTO.Request request = objectMapper.readValue( + payload, WebSocketMessageDTO.Request.class + ); + + Long memberId = (Long) session.getAttributes().get("memberId"); + if (memberId == null) { + webSocketResponseSender.sendErrorMessage(session, "UNAUTHORIZED", "인증되지 않은 사용자입니다."); + return; + } + + log.info("메시지 타입: {}, 채팅방 ID: {}, 사용자 ID: {}", request.type(), session.getId(), memberId); + commandHandler.handle(session, request, memberId); + + } catch (Exception e) { + log.error("메시지 처리 중 에러 발생", e); + webSocketResponseSender.sendErrorMessage(session, "PROCESSING_ERROR", "메시지 처리 중 오류가 발생했습니다."); } - - log.info("메시지 타입: {}, 채팅방 ID: {}, 사용자 ID: {}", request.type(), session.getId(), memberId); - commandHandler.handle(session, request, memberId); - - } catch (Exception e) { - log.error("메시지 처리 중 에러 발생", e); - webSocketResponseSender.sendErrorMessage(session, "PROCESSING_ERROR", "메시지 처리 중 오류가 발생했습니다."); } } } diff --git a/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/JWTWebSocketAuthInterceptor.java b/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/JWTWebSocketAuthInterceptor.java index 3f3f4b43b..e71a60d3c 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/JWTWebSocketAuthInterceptor.java +++ b/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/JWTWebSocketAuthInterceptor.java @@ -21,31 +21,37 @@ public class JWTWebSocketAuthInterceptor implements HandshakeInterceptor { @Override public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map attributes) throws Exception { - log.debug("JWT 기반 WebSocket 인증 시작"); + try (WebSocketMdcSupport.MdcScope ignored = WebSocketMdcSupport.open((Long) null)) { + log.debug("JWT 기반 WebSocket 인증 시작"); - try { - String token = extractTokenFromRequest(request); + try { + String token = extractTokenFromRequest(request); - if (isInvalidToken(token)) return false; + if (isInvalidToken(token)) return false; - Long memberId = jwtTokenProvider.getUserId(token); + Long memberId = jwtTokenProvider.getUserId(token); - attributes.put("memberId", memberId); - attributes.put("authenticated", true); + attributes.put("memberId", memberId); + attributes.put("authenticated", true); - log.info("JWT 인증 성공 - memberId: {}", memberId); - return true; - } catch (Exception e) { - log.error("JWT 인증 처리 중 오류 발생", e); - return false; + try (WebSocketMdcSupport.MdcScope memberScope = WebSocketMdcSupport.open(memberId)) { + log.info("JWT 인증 성공 - memberId: {}", memberId); + } + return true; + } catch (Exception e) { + log.error("JWT 인증 처리 중 오류 발생", e); + return false; + } } } @Override public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) { - if (exception != null) { - log.error("JWT HandshakeInterceptor 실행 중 오류", exception); + try (WebSocketMdcSupport.MdcScope ignored = WebSocketMdcSupport.open((Long) null)) { + if (exception != null) { + log.error("JWT HandshakeInterceptor 실행 중 오류", exception); + } } } diff --git a/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/WebSocketMdcSupport.java b/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/WebSocketMdcSupport.java new file mode 100644 index 000000000..61a6952f0 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/WebSocketMdcSupport.java @@ -0,0 +1,76 @@ +package umc.cockple.demo.domain.chat.presentation.websocket; + +import org.slf4j.MDC; +import org.springframework.web.socket.WebSocketSession; +import umc.cockple.demo.global.security.filter.JwtAuthenticationFilter; + +import java.util.Map; + +public final class WebSocketMdcSupport { + + public static final String WS_SESSION_ID = "wsSessionId"; + + private WebSocketMdcSupport() { + } + + public static MdcScope open(WebSocketSession session) { + Map previousContext = MDC.getCopyOfContextMap(); + putSessionMdc(session); + return new MdcScope(previousContext); + } + + public static MdcScope open(Long memberId) { + Map previousContext = MDC.getCopyOfContextMap(); + putMemberId(memberId); + return new MdcScope(previousContext); + } + + private static void putSessionMdc(WebSocketSession session) { + if (session == null) { + MDC.remove(WS_SESSION_ID); + MDC.remove(JwtAuthenticationFilter.MEMBER_ID); + return; + } + + String sessionId = session.getId(); + if (sessionId == null || sessionId.isBlank()) { + MDC.remove(WS_SESSION_ID); + } else { + MDC.put(WS_SESSION_ID, sessionId); + } + + Map attributes = session.getAttributes(); + Object memberId = attributes == null ? null : attributes.get(JwtAuthenticationFilter.MEMBER_ID); + if (memberId == null) { + MDC.remove(JwtAuthenticationFilter.MEMBER_ID); + } else { + MDC.put(JwtAuthenticationFilter.MEMBER_ID, String.valueOf(memberId)); + } + } + + private static void putMemberId(Long memberId) { + if (memberId == null) { + MDC.remove(JwtAuthenticationFilter.MEMBER_ID); + } else { + MDC.put(JwtAuthenticationFilter.MEMBER_ID, String.valueOf(memberId)); + } + MDC.remove(WS_SESSION_ID); + } + + public static final class MdcScope implements AutoCloseable { + private final Map previousContext; + + private MdcScope(Map previousContext) { + this.previousContext = previousContext; + } + + @Override + public void close() { + if (previousContext == null || previousContext.isEmpty()) { + MDC.clear(); + return; + } + MDC.setContextMap(previousContext); + } + } +} diff --git a/src/test/java/umc/cockple/demo/domain/chat/presentation/websocket/ChatWebSocketHandlerTest.java b/src/test/java/umc/cockple/demo/domain/chat/presentation/websocket/ChatWebSocketHandlerTest.java index dab5123ba..6719cc102 100644 --- a/src/test/java/umc/cockple/demo/domain/chat/presentation/websocket/ChatWebSocketHandlerTest.java +++ b/src/test/java/umc/cockple/demo/domain/chat/presentation/websocket/ChatWebSocketHandlerTest.java @@ -1,21 +1,27 @@ package umc.cockple.demo.domain.chat.presentation.websocket; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.slf4j.MDC; import org.springframework.web.socket.CloseStatus; import org.springframework.web.socket.WebSocketSession; import umc.cockple.demo.domain.chat.presentation.websocket.session.WebSocketSessionRegistry; import umc.cockple.demo.domain.member.service.MemberQueryService; +import umc.cockple.demo.global.logging.MdcLoggingFilter; +import umc.cockple.demo.global.security.filter.JwtAuthenticationFilter; import java.util.HashMap; import java.util.Map; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; +import static org.mockito.BDDMockito.willAnswer; @ExtendWith(MockitoExtension.class) @DisplayName("ChatWebSocketHandler") @@ -39,6 +45,11 @@ void setUp() { ); } + @AfterEach + void tearDown() { + MDC.clear(); + } + @Test @DisplayName("연결 종료 시 닫힌 세션과 일치할 때만 세션 저장소에서 제거한다") void afterConnectionClosed_removesClosedSessionByIdentity() { @@ -48,11 +59,21 @@ void afterConnectionClosed_removesClosedSessionByIdentity() { attributes.put("memberId", memberId); given(session.getAttributes()).willReturn(attributes); given(session.getId()).willReturn("session-1"); + MDC.put(MdcLoggingFilter.REQUEST_ID, "request-1"); + willAnswer(invocation -> { + assertThat(MDC.get(JwtAuthenticationFilter.MEMBER_ID)).isEqualTo("10"); + assertThat(MDC.get(WebSocketMdcSupport.WS_SESSION_ID)).isEqualTo("session-1"); + assertThat(MDC.get(MdcLoggingFilter.REQUEST_ID)).isEqualTo("request-1"); + return null; + }).given(sessionRegistry).remove(memberId, session); // when handler.afterConnectionClosed(session, CloseStatus.NORMAL); // then then(sessionRegistry).should().remove(memberId, session); + assertThat(MDC.get(MdcLoggingFilter.REQUEST_ID)).isEqualTo("request-1"); + assertThat(MDC.get(JwtAuthenticationFilter.MEMBER_ID)).isNull(); + assertThat(MDC.get(WebSocketMdcSupport.WS_SESSION_ID)).isNull(); } } diff --git a/src/test/java/umc/cockple/demo/domain/chat/presentation/websocket/ChatWebSocketRequestDispatcherTest.java b/src/test/java/umc/cockple/demo/domain/chat/presentation/websocket/ChatWebSocketRequestDispatcherTest.java index e7464ff40..bac250796 100644 --- a/src/test/java/umc/cockple/demo/domain/chat/presentation/websocket/ChatWebSocketRequestDispatcherTest.java +++ b/src/test/java/umc/cockple/demo/domain/chat/presentation/websocket/ChatWebSocketRequestDispatcherTest.java @@ -1,6 +1,7 @@ package umc.cockple.demo.domain.chat.presentation.websocket; import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -8,17 +9,22 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.slf4j.MDC; import org.springframework.web.socket.WebSocketSession; import umc.cockple.demo.domain.chat.dto.WebSocketMessageDTO; import umc.cockple.demo.domain.chat.enums.WebSocketMessageType; +import umc.cockple.demo.global.logging.MdcLoggingFilter; +import umc.cockple.demo.global.security.filter.JwtAuthenticationFilter; import java.util.HashMap; import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.then; +import static org.mockito.BDDMockito.willAnswer; @ExtendWith(MockitoExtension.class) @DisplayName("ChatWebSocketRequestDispatcher") @@ -39,6 +45,11 @@ void setUp() { ); } + @AfterEach + void tearDown() { + MDC.clear(); + } + @Test @DisplayName("인증된 요청을 파싱해 command handler에 위임한다") void dispatch_delegatesAuthenticatedRequestToCommandHandler() { @@ -49,6 +60,13 @@ void dispatch_delegatesAuthenticatedRequestToCommandHandler() { attributes.put("memberId", memberId); given(session.getAttributes()).willReturn(attributes); given(session.getId()).willReturn("session-1"); + MDC.put(MdcLoggingFilter.REQUEST_ID, "request-1"); + willAnswer(invocation -> { + assertThat(MDC.get(JwtAuthenticationFilter.MEMBER_ID)).isEqualTo("10"); + assertThat(MDC.get(WebSocketMdcSupport.WS_SESSION_ID)).isEqualTo("session-1"); + assertThat(MDC.get(MdcLoggingFilter.REQUEST_ID)).isEqualTo("request-1"); + return null; + }).given(commandHandler).handle(eq(session), any(WebSocketMessageDTO.Request.class), eq(memberId)); String payload = """ {"type":"SEND","chatRoomId":20,"content":"hello","images":[]} @@ -68,6 +86,9 @@ void dispatch_delegatesAuthenticatedRequestToCommandHandler() { assertThat(request.images()).isEmpty(); then(webSocketResponseSender).shouldHaveNoInteractions(); + assertThat(MDC.get(MdcLoggingFilter.REQUEST_ID)).isEqualTo("request-1"); + assertThat(MDC.get(JwtAuthenticationFilter.MEMBER_ID)).isNull(); + assertThat(MDC.get(WebSocketMdcSupport.WS_SESSION_ID)).isNull(); } @Test diff --git a/src/test/java/umc/cockple/demo/domain/chat/presentation/websocket/JWTWebSocketAuthInterceptorTest.java b/src/test/java/umc/cockple/demo/domain/chat/presentation/websocket/JWTWebSocketAuthInterceptorTest.java new file mode 100644 index 000000000..9b5177354 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/chat/presentation/websocket/JWTWebSocketAuthInterceptorTest.java @@ -0,0 +1,64 @@ +package umc.cockple.demo.domain.chat.presentation.websocket; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.slf4j.MDC; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.web.socket.WebSocketHandler; +import umc.cockple.demo.global.logging.MdcLoggingFilter; +import umc.cockple.demo.global.security.filter.JwtAuthenticationFilter; +import umc.cockple.demo.global.jwt.domain.JwtTokenProvider; + +import java.net.URI; +import java.util.HashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +@DisplayName("JWTWebSocketAuthInterceptor") +class JWTWebSocketAuthInterceptorTest { + + @Mock private JwtTokenProvider jwtTokenProvider; + @Mock private ServerHttpRequest request; + @Mock private ServerHttpResponse response; + @Mock private WebSocketHandler wsHandler; + + private JWTWebSocketAuthInterceptor interceptor; + + @BeforeEach + void setUp() { + interceptor = new JWTWebSocketAuthInterceptor(jwtTokenProvider); + } + + @AfterEach + void tearDown() { + MDC.clear(); + } + + @Test + @DisplayName("handshake 인증 성공 시 세션 attributes에 memberId를 저장하고 MDC를 복구한다") + void beforeHandshakeStoresMemberIdAndRestoresMdc() throws Exception { + Map attributes = new HashMap<>(); + given(request.getURI()).willReturn(URI.create("/ws/chats?token=access-token")); + given(jwtTokenProvider.validateToken("access-token")).willReturn(true); + given(jwtTokenProvider.getUserId("access-token")).willReturn(10L); + MDC.put(MdcLoggingFilter.REQUEST_ID, "request-1"); + + boolean result = interceptor.beforeHandshake(request, response, wsHandler, attributes); + + assertThat(result).isTrue(); + assertThat(attributes).containsEntry(JwtAuthenticationFilter.MEMBER_ID, 10L); + assertThat(attributes).containsEntry("authenticated", true); + assertThat(MDC.get(MdcLoggingFilter.REQUEST_ID)).isEqualTo("request-1"); + assertThat(MDC.get(JwtAuthenticationFilter.MEMBER_ID)).isNull(); + assertThat(MDC.get(WebSocketMdcSupport.WS_SESSION_ID)).isNull(); + } +} diff --git a/src/test/java/umc/cockple/demo/domain/chat/presentation/websocket/WebSocketMdcSupportTest.java b/src/test/java/umc/cockple/demo/domain/chat/presentation/websocket/WebSocketMdcSupportTest.java new file mode 100644 index 000000000..7eb3b329e --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/chat/presentation/websocket/WebSocketMdcSupportTest.java @@ -0,0 +1,63 @@ +package umc.cockple.demo.domain.chat.presentation.websocket; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.slf4j.MDC; +import org.springframework.web.socket.WebSocketSession; +import umc.cockple.demo.global.logging.MdcLoggingFilter; +import umc.cockple.demo.global.security.filter.JwtAuthenticationFilter; + +import java.util.HashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +@DisplayName("WebSocketMdcSupport") +class WebSocketMdcSupportTest { + + @AfterEach + void tearDown() { + MDC.clear(); + } + + @Test + @DisplayName("세션 scope 동안 memberId와 wsSessionId를 MDC에 넣고 종료 후 이전 MDC를 복구한다") + void openSessionMdcAndRestorePreviousContext() { + WebSocketSession session = mock(WebSocketSession.class); + Map attributes = new HashMap<>(); + attributes.put(JwtAuthenticationFilter.MEMBER_ID, 10L); + given(session.getId()).willReturn("session-1"); + given(session.getAttributes()).willReturn(attributes); + MDC.put(MdcLoggingFilter.REQUEST_ID, "request-1"); + + try (WebSocketMdcSupport.MdcScope ignored = WebSocketMdcSupport.open(session)) { + assertThat(MDC.get(JwtAuthenticationFilter.MEMBER_ID)).isEqualTo("10"); + assertThat(MDC.get(WebSocketMdcSupport.WS_SESSION_ID)).isEqualTo("session-1"); + assertThat(MDC.get(MdcLoggingFilter.REQUEST_ID)).isEqualTo("request-1"); + } + + assertThat(MDC.get(MdcLoggingFilter.REQUEST_ID)).isEqualTo("request-1"); + assertThat(MDC.get(JwtAuthenticationFilter.MEMBER_ID)).isNull(); + assertThat(MDC.get(WebSocketMdcSupport.WS_SESSION_ID)).isNull(); + } + + @Test + @DisplayName("memberId scope는 wsSessionId를 비우고 종료 후 이전 MDC를 복구한다") + void openMemberMdcAndClearWebSocketSessionId() { + MDC.put(MdcLoggingFilter.REQUEST_ID, "request-1"); + MDC.put(WebSocketMdcSupport.WS_SESSION_ID, "stale-session"); + + try (WebSocketMdcSupport.MdcScope ignored = WebSocketMdcSupport.open(20L)) { + assertThat(MDC.get(JwtAuthenticationFilter.MEMBER_ID)).isEqualTo("20"); + assertThat(MDC.get(WebSocketMdcSupport.WS_SESSION_ID)).isNull(); + assertThat(MDC.get(MdcLoggingFilter.REQUEST_ID)).isEqualTo("request-1"); + } + + assertThat(MDC.get(MdcLoggingFilter.REQUEST_ID)).isEqualTo("request-1"); + assertThat(MDC.get(WebSocketMdcSupport.WS_SESSION_ID)).isEqualTo("stale-session"); + assertThat(MDC.get(JwtAuthenticationFilter.MEMBER_ID)).isNull(); + } +} From bccb818cb95267890e39ea2d1b83da0299c75683 Mon Sep 17 00:00:00 2001 From: Dimo-2562 Date: Fri, 3 Jul 2026 16:13:45 +0900 Subject: [PATCH 08/17] =?UTF-8?q?chore:=20=EB=8F=84=EC=BB=A4=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EC=A6=88=20volume=20=EC=84=A4=EC=A0=95=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20log=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yml | 6 ++++++ scripts/deploy.sh | 2 ++ 2 files changed, 8 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index 2a883bb6a..83d66a619 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -56,6 +56,7 @@ services: environment: JAVA_TOOL_OPTIONS: "-Xms768m -Xmx768m" SPRING_PROFILES_ACTIVE: prod + LOG_PATH: /logs DB_PASSWORD: ${DB_PASSWORD} GCS_BUCKET: ${GCS_BUCKET} KAKAO_CLIENT_ID: ${KAKAO_CLIENT_ID} @@ -64,6 +65,8 @@ services: KAKAO_ADMIN_KEY: ${KAKAO_ADMIN_KEY} JWT_SECRET_KEY: ${JWT_SECRET_KEY} FIREBASE_SERVICE_ACCOUNT_KEY: ${FIREBASE_SERVICE_ACCOUNT_KEY} + volumes: + - ./logs/prod:/logs depends_on: mysql: condition: service_healthy @@ -79,6 +82,7 @@ services: environment: JAVA_TOOL_OPTIONS: "-Xms128m -Xmx512m" SPRING_PROFILES_ACTIVE: staging + LOG_PATH: /logs DB_PASSWORD: ${DB_PASSWORD} GCS_BUCKET: ${GCS_BUCKET} KAKAO_CLIENT_ID: ${KAKAO_CLIENT_ID} @@ -87,6 +91,8 @@ services: KAKAO_ADMIN_KEY: ${KAKAO_ADMIN_KEY} JWT_SECRET_KEY: ${JWT_SECRET_KEY} FIREBASE_SERVICE_ACCOUNT_KEY: ${FIREBASE_SERVICE_ACCOUNT_KEY} + volumes: + - ./logs/staging:/logs depends_on: mysql: condition: service_healthy diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 6e9a5fa70..b93152af5 100644 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -30,6 +30,8 @@ EOF echo "${FIREBASE_SERVICE_ACCOUNT_KEY}" > /home/ubuntu/cockple/firebase-service-account.json +mkdir -p /home/ubuntu/cockple/logs/prod /home/ubuntu/cockple/logs/staging + echo "=== 배포 전 상태 ===" sudo docker ps From 1c51c5b42e1468a6bde43e52c9fdb922a0ba70cd Mon Sep 17 00:00:00 2001 From: Dimo-2562 Date: Fri, 3 Jul 2026 16:34:26 +0900 Subject: [PATCH 09/17] =?UTF-8?q?chore:=20local,=20staging,=20prod?= =?UTF-8?q?=EB=A5=BC=20=EC=A0=9C=EC=99=B8=ED=95=98=EA=B3=A0=EB=8A=94=20?= =?UTF-8?q?=EC=83=89=EC=83=81=EC=9D=84=20=EC=97=86=EC=95=A0=20xml=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EC=98=A4=EB=A5=98=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/logback-spring.xml | 23 ++++++--- .../LogbackSpringConfigurationTest.java | 48 ++++++++++++++++++- 2 files changed, 64 insertions(+), 7 deletions(-) diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index 48e364017..85e463ba2 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -9,12 +9,23 @@ - - - ${CONSOLE_LOG_PATTERN} - UTF-8 - - + + + + ${CONSOLE_LOG_PATTERN} + UTF-8 + + + + + + + + ${FILE_LOG_PATTERN} + UTF-8 + + + ${APP_LOG_FILE} diff --git a/src/test/java/umc/cockple/demo/global/logging/LogbackSpringConfigurationTest.java b/src/test/java/umc/cockple/demo/global/logging/LogbackSpringConfigurationTest.java index a3eec557f..c04714fcc 100644 --- a/src/test/java/umc/cockple/demo/global/logging/LogbackSpringConfigurationTest.java +++ b/src/test/java/umc/cockple/demo/global/logging/LogbackSpringConfigurationTest.java @@ -89,6 +89,46 @@ void localProfileRoutesWebSocketLogsToConsoleAndFile() throws Exception { } } + @Test + @DisplayName("test 프로필에서는 XML 테스트 리포트 보호를 위해 콘솔 색상을 사용하지 않는다") + void testProfileUsesPlainConsolePattern() throws Exception { + LoggerContext context = configure("test"); + + try { + assertThat(appenderNames(context.getLogger(Logger.ROOT_LOGGER_NAME))) + .containsExactlyInAnyOrder("CONSOLE", "ASYNC_APPLICATION_FILE", "ASYNC_ERROR_FILE"); + assertThat(appenderPattern(context, "CONSOLE")).doesNotContain("%clr", "%highlight"); + assertThat(appenderPattern(context, "APPLICATION_FILE")).doesNotContain("%clr", "%highlight"); + assertThat(appenderPattern(context, "ERROR_FILE")).doesNotContain("%clr", "%highlight"); + } finally { + context.stop(); + } + } + + @Test + @DisplayName("명시 런타임 프로필이 아니면 기본 콘솔 패턴은 색상을 사용하지 않는다") + void defaultProfileUsesPlainConsolePattern() throws Exception { + LoggerContext context = configure(); + + try { + assertThat(appenderNames(context.getLogger(Logger.ROOT_LOGGER_NAME))) + .containsExactlyInAnyOrder("CONSOLE", "ASYNC_APPLICATION_FILE", "ASYNC_ERROR_FILE"); + assertThat(appenderPattern(context, "CONSOLE")).doesNotContain("%clr", "%highlight"); + } finally { + context.stop(); + } + } + + private LoggerContext configure() throws Exception { + LoggerContext context = new LoggerContext(); + context.putProperty("LOG_PATH", logPath.toString()); + + MockEnvironment environment = new MockEnvironment(); + LoggingInitializationContext initializationContext = new LoggingInitializationContext(environment); + configureContext(context, initializationContext); + return context; + } + private LoggerContext configure(String profile) throws Exception { LoggerContext context = new LoggerContext(); context.putProperty("LOG_PATH", logPath.toString()); @@ -96,7 +136,14 @@ private LoggerContext configure(String profile) throws Exception { MockEnvironment environment = new MockEnvironment(); environment.setActiveProfiles(profile); LoggingInitializationContext initializationContext = new LoggingInitializationContext(environment); + configureContext(context, initializationContext); + return context; + } + private void configureContext( + LoggerContext context, + LoggingInitializationContext initializationContext + ) throws Exception { Constructor constructor = Class.forName("org.springframework.boot.logging.logback.SpringBootJoranConfigurator") .getDeclaredConstructor(LoggingInitializationContext.class); constructor.setAccessible(true); @@ -106,7 +153,6 @@ private LoggerContext configure(String profile) throws Exception { StatusUtil statusUtil = new StatusUtil(context); assertThat(statusUtil.getHighestLevel(0)).isLessThan(Status.ERROR); - return context; } private List appenderNames(Logger logger) { From 1aada5dc7fd7bc840ee53b3f9783c112fee98242 Mon Sep 17 00:00:00 2001 From: Dimo-2562 Date: Fri, 3 Jul 2026 16:36:13 +0900 Subject: [PATCH 10/17] =?UTF-8?q?docs:=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20Aut?= =?UTF-8?q?horization=20code=EB=A5=BC=20=EB=A1=9C=EA=B7=B8=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=A0=9C=EA=B1=B0=ED=95=98=EC=97=AC=20=EB=B3=B4?= =?UTF-8?q?=EC=95=88=20=EC=9D=B4=EC=8A=88=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../umc/cockple/demo/global/oauth2/domain/KakaoClient.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/java/umc/cockple/demo/global/oauth2/domain/KakaoClient.java b/src/main/java/umc/cockple/demo/global/oauth2/domain/KakaoClient.java index 4e69943e0..6c75704c2 100644 --- a/src/main/java/umc/cockple/demo/global/oauth2/domain/KakaoClient.java +++ b/src/main/java/umc/cockple/demo/global/oauth2/domain/KakaoClient.java @@ -52,8 +52,7 @@ public String getAccessToken(String code) { formData.with("client_secret", clientSecret); } - log.info("Redirect URI used: {}", redirectUri); - log.info("Auth Code: {}", code); + log.debug("Redirect URI used for Kakao OAuth token request: {}", redirectUri); JsonNode response = webClient.post() .uri(tokenUri) .contentType(MediaType.APPLICATION_FORM_URLENCODED) From 5fdecb019136b81767cb8a038e936d8e957dfa36 Mon Sep 17 00:00:00 2001 From: Dimo-2562 Date: Fri, 3 Jul 2026 16:37:45 +0900 Subject: [PATCH 11/17] =?UTF-8?q?chore:=20=EC=97=90=EB=9F=AC=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=97=90=EC=84=9C=EB=8A=94=20ThresholdFilter=EB=A5=BC?= =?UTF-8?q?=20=ED=86=B5=ED=95=B4=20Error=20=EB=A1=9C=EA=B7=B8=EB=A7=8C=20?= =?UTF-8?q?=EB=B0=9B=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/logback-spring.xml | 3 +++ .../logging/LogbackSpringConfigurationTest.java | 14 ++++++++------ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index 85e463ba2..044dac0e8 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -87,6 +87,9 @@ + + ERROR + 0 512 false diff --git a/src/test/java/umc/cockple/demo/global/logging/LogbackSpringConfigurationTest.java b/src/test/java/umc/cockple/demo/global/logging/LogbackSpringConfigurationTest.java index c04714fcc..c5881e7e7 100644 --- a/src/test/java/umc/cockple/demo/global/logging/LogbackSpringConfigurationTest.java +++ b/src/test/java/umc/cockple/demo/global/logging/LogbackSpringConfigurationTest.java @@ -57,7 +57,8 @@ void prodProfileRoutesWebSocketLogsToFileOnly() throws Exception { assertAsyncAppender(context, "ASYNC_APPLICATION_FILE", "APPLICATION_FILE", 1024); assertAsyncAppender(context, "ASYNC_WEBSOCKET_FILE", "WEBSOCKET_FILE", 2048); assertAsyncAppender(context, "ASYNC_ERROR_FILE", "ERROR_FILE", 512); - assertErrorThresholdFilter(context); + assertThresholdFilter(context, "ASYNC_ERROR_FILE"); + assertThresholdFilter(context, "ERROR_FILE"); } finally { context.stop(); } @@ -83,7 +84,8 @@ void localProfileRoutesWebSocketLogsToConsoleAndFile() throws Exception { assertAsyncAppender(context, "ASYNC_APPLICATION_FILE", "APPLICATION_FILE", 1024); assertAsyncAppender(context, "ASYNC_WEBSOCKET_FILE", "WEBSOCKET_FILE", 2048); assertAsyncAppender(context, "ASYNC_ERROR_FILE", "ERROR_FILE", 512); - assertErrorThresholdFilter(context); + assertThresholdFilter(context, "ASYNC_ERROR_FILE"); + assertThresholdFilter(context, "ERROR_FILE"); } finally { context.stop(); } @@ -190,11 +192,11 @@ private void assertAsyncAppender( } @SuppressWarnings("unchecked") - private void assertErrorThresholdFilter(LoggerContext context) { - Appender errorAppender = findAppender(context, "ERROR_FILE"); - assertThat(errorAppender).isInstanceOf(FilterAttachable.class); + private void assertThresholdFilter(LoggerContext context, String appenderName) { + Appender appender = findAppender(context, appenderName); + assertThat(appender).isInstanceOf(FilterAttachable.class); FilterAttachable filterAttachable = - (FilterAttachable) errorAppender; + (FilterAttachable) appender; assertThat(filterAttachable.getCopyOfAttachedFiltersList()) .hasSize(1) .allMatch(ThresholdFilter.class::isInstance); From 83aede0be5eab5b8dc84a263cfd0bcecd0891790 Mon Sep 17 00:00:00 2001 From: Dimo-2562 Date: Fri, 3 Jul 2026 16:41:13 +0900 Subject: [PATCH 12/17] =?UTF-8?q?chore:=20Error=20=EC=A0=9C=EC=99=B8=20App?= =?UTF-8?q?ender=EB=8A=94=20block=20=EB=90=98=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=ED=95=98=EA=B3=A0,=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=20=EB=A0=88=EB=B2=A8=EC=9D=84=20debug=EB=A1=9C=20=EB=82=AE?= =?UTF-8?q?=EC=B6=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ChatWebSocketRequestDispatcher.java | 4 ++-- src/main/resources/logback-spring.xml | 2 ++ .../logging/LogbackSpringConfigurationTest.java | 16 +++++++++------- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/ChatWebSocketRequestDispatcher.java b/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/ChatWebSocketRequestDispatcher.java index 654c39bd0..7aa7af119 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/ChatWebSocketRequestDispatcher.java +++ b/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/ChatWebSocketRequestDispatcher.java @@ -18,7 +18,7 @@ public class ChatWebSocketRequestDispatcher { public void dispatch(WebSocketSession session, String payload) { try (WebSocketMdcSupport.MdcScope ignored = WebSocketMdcSupport.open(session)) { - log.info("메시지 수신 - 세션 ID: {}, payloadSize: {}", session.getId(), payload == null ? 0 : payload.length()); + log.debug("메시지 수신 - 세션 ID: {}, payloadSize: {}", session.getId(), payload == null ? 0 : payload.length()); try { WebSocketMessageDTO.Request request = objectMapper.readValue( @@ -31,7 +31,7 @@ public void dispatch(WebSocketSession session, String payload) { return; } - log.info("메시지 타입: {}, 채팅방 ID: {}, 사용자 ID: {}", request.type(), session.getId(), memberId); + log.debug("메시지 타입: {}, 채팅방 ID: {}, 사용자 ID: {}", request.type(), session.getId(), memberId); commandHandler.handle(session, request, memberId); } catch (Exception e) { diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index 044dac0e8..047b11d22 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -75,6 +75,7 @@ 0 1024 + true false @@ -82,6 +83,7 @@ 0 2048 + true false diff --git a/src/test/java/umc/cockple/demo/global/logging/LogbackSpringConfigurationTest.java b/src/test/java/umc/cockple/demo/global/logging/LogbackSpringConfigurationTest.java index c5881e7e7..4d4c5c5af 100644 --- a/src/test/java/umc/cockple/demo/global/logging/LogbackSpringConfigurationTest.java +++ b/src/test/java/umc/cockple/demo/global/logging/LogbackSpringConfigurationTest.java @@ -54,9 +54,9 @@ void prodProfileRoutesWebSocketLogsToFileOnly() throws Exception { assertThat(appenderPattern(context, "APPLICATION_FILE")).doesNotContain("%clr", "%highlight"); assertThat(appenderPattern(context, "WEBSOCKET_FILE")).doesNotContain("%clr", "%highlight"); assertThat(appenderPattern(context, "ERROR_FILE")).doesNotContain("%clr", "%highlight"); - assertAsyncAppender(context, "ASYNC_APPLICATION_FILE", "APPLICATION_FILE", 1024); - assertAsyncAppender(context, "ASYNC_WEBSOCKET_FILE", "WEBSOCKET_FILE", 2048); - assertAsyncAppender(context, "ASYNC_ERROR_FILE", "ERROR_FILE", 512); + assertAsyncAppender(context, "ASYNC_APPLICATION_FILE", "APPLICATION_FILE", 1024, true); + assertAsyncAppender(context, "ASYNC_WEBSOCKET_FILE", "WEBSOCKET_FILE", 2048, true); + assertAsyncAppender(context, "ASYNC_ERROR_FILE", "ERROR_FILE", 512, false); assertThresholdFilter(context, "ASYNC_ERROR_FILE"); assertThresholdFilter(context, "ERROR_FILE"); } finally { @@ -81,9 +81,9 @@ void localProfileRoutesWebSocketLogsToConsoleAndFile() throws Exception { assertThat(appenderPattern(context, "APPLICATION_FILE")).doesNotContain("%clr", "%highlight"); assertThat(appenderPattern(context, "WEBSOCKET_FILE")).doesNotContain("%clr", "%highlight"); assertThat(appenderPattern(context, "ERROR_FILE")).doesNotContain("%clr", "%highlight"); - assertAsyncAppender(context, "ASYNC_APPLICATION_FILE", "APPLICATION_FILE", 1024); - assertAsyncAppender(context, "ASYNC_WEBSOCKET_FILE", "WEBSOCKET_FILE", 2048); - assertAsyncAppender(context, "ASYNC_ERROR_FILE", "ERROR_FILE", 512); + assertAsyncAppender(context, "ASYNC_APPLICATION_FILE", "APPLICATION_FILE", 1024, true); + assertAsyncAppender(context, "ASYNC_WEBSOCKET_FILE", "WEBSOCKET_FILE", 2048, true); + assertAsyncAppender(context, "ASYNC_ERROR_FILE", "ERROR_FILE", 512, false); assertThresholdFilter(context, "ASYNC_ERROR_FILE"); assertThresholdFilter(context, "ERROR_FILE"); } finally { @@ -180,13 +180,15 @@ private void assertAsyncAppender( LoggerContext context, String asyncAppenderName, String nestedAppenderName, - int queueSize + int queueSize, + boolean neverBlock ) { Appender appender = findAppender(context, asyncAppenderName); assertThat(appender).isInstanceOf(AsyncAppender.class); AsyncAppender asyncAppender = (AsyncAppender) appender; assertThat(asyncAppender.getQueueSize()).isEqualTo(queueSize); assertThat(asyncAppender.getDiscardingThreshold()).isZero(); + assertThat(asyncAppender.isNeverBlock()).isEqualTo(neverBlock); assertThat(asyncAppender.isIncludeCallerData()).isFalse(); assertThat(asyncAppender.getAppender(nestedAppenderName)).isNotNull(); } From fdd6f3e4ba7424e8b3e1fad00b4212cc5bcb1b24 Mon Sep 17 00:00:00 2001 From: Dimo-2562 Date: Fri, 3 Jul 2026 16:43:52 +0900 Subject: [PATCH 13/17] =?UTF-8?q?chore:=20=EB=88=84=EB=9D=BD=EB=90=9C=20HT?= =?UTF-8?q?TP=20=EB=A9=94=EC=86=8C=EB=93=9C,=20uri,=20clientIp=EB=8F=84=20?= =?UTF-8?q?=EB=A1=9C=EA=B9=85=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/logback-spring.xml | 4 ++-- .../logging/LogbackSpringConfigurationTest.java | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index 047b11d22..3d6584bbf 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -6,8 +6,8 @@ - - + + diff --git a/src/test/java/umc/cockple/demo/global/logging/LogbackSpringConfigurationTest.java b/src/test/java/umc/cockple/demo/global/logging/LogbackSpringConfigurationTest.java index 4d4c5c5af..b4e57f840 100644 --- a/src/test/java/umc/cockple/demo/global/logging/LogbackSpringConfigurationTest.java +++ b/src/test/java/umc/cockple/demo/global/logging/LogbackSpringConfigurationTest.java @@ -54,6 +54,10 @@ void prodProfileRoutesWebSocketLogsToFileOnly() throws Exception { assertThat(appenderPattern(context, "APPLICATION_FILE")).doesNotContain("%clr", "%highlight"); assertThat(appenderPattern(context, "WEBSOCKET_FILE")).doesNotContain("%clr", "%highlight"); assertThat(appenderPattern(context, "ERROR_FILE")).doesNotContain("%clr", "%highlight"); + assertHttpMdcPattern(appenderPattern(context, "CONSOLE")); + assertHttpMdcPattern(appenderPattern(context, "APPLICATION_FILE")); + assertHttpMdcPattern(appenderPattern(context, "WEBSOCKET_FILE")); + assertHttpMdcPattern(appenderPattern(context, "ERROR_FILE")); assertAsyncAppender(context, "ASYNC_APPLICATION_FILE", "APPLICATION_FILE", 1024, true); assertAsyncAppender(context, "ASYNC_WEBSOCKET_FILE", "WEBSOCKET_FILE", 2048, true); assertAsyncAppender(context, "ASYNC_ERROR_FILE", "ERROR_FILE", 512, false); @@ -81,6 +85,10 @@ void localProfileRoutesWebSocketLogsToConsoleAndFile() throws Exception { assertThat(appenderPattern(context, "APPLICATION_FILE")).doesNotContain("%clr", "%highlight"); assertThat(appenderPattern(context, "WEBSOCKET_FILE")).doesNotContain("%clr", "%highlight"); assertThat(appenderPattern(context, "ERROR_FILE")).doesNotContain("%clr", "%highlight"); + assertHttpMdcPattern(appenderPattern(context, "CONSOLE")); + assertHttpMdcPattern(appenderPattern(context, "APPLICATION_FILE")); + assertHttpMdcPattern(appenderPattern(context, "WEBSOCKET_FILE")); + assertHttpMdcPattern(appenderPattern(context, "ERROR_FILE")); assertAsyncAppender(context, "ASYNC_APPLICATION_FILE", "APPLICATION_FILE", 1024, true); assertAsyncAppender(context, "ASYNC_WEBSOCKET_FILE", "WEBSOCKET_FILE", 2048, true); assertAsyncAppender(context, "ASYNC_ERROR_FILE", "ERROR_FILE", 512, false); @@ -102,6 +110,9 @@ void testProfileUsesPlainConsolePattern() throws Exception { assertThat(appenderPattern(context, "CONSOLE")).doesNotContain("%clr", "%highlight"); assertThat(appenderPattern(context, "APPLICATION_FILE")).doesNotContain("%clr", "%highlight"); assertThat(appenderPattern(context, "ERROR_FILE")).doesNotContain("%clr", "%highlight"); + assertHttpMdcPattern(appenderPattern(context, "CONSOLE")); + assertHttpMdcPattern(appenderPattern(context, "APPLICATION_FILE")); + assertHttpMdcPattern(appenderPattern(context, "ERROR_FILE")); } finally { context.stop(); } @@ -116,6 +127,7 @@ void defaultProfileUsesPlainConsolePattern() throws Exception { assertThat(appenderNames(context.getLogger(Logger.ROOT_LOGGER_NAME))) .containsExactlyInAnyOrder("CONSOLE", "ASYNC_APPLICATION_FILE", "ASYNC_ERROR_FILE"); assertThat(appenderPattern(context, "CONSOLE")).doesNotContain("%clr", "%highlight"); + assertHttpMdcPattern(appenderPattern(context, "CONSOLE")); } finally { context.stop(); } @@ -176,6 +188,10 @@ private String appenderPattern(LoggerContext context, String appenderName) { return encoder.getPattern(); } + private void assertHttpMdcPattern(String pattern) { + assertThat(pattern).contains("%X{method:-}", "%X{uri:-}", "%X{clientIp:-}"); + } + private void assertAsyncAppender( LoggerContext context, String asyncAppenderName, From 4563cad743bdcc62766a77adf7e173d9880749b3 Mon Sep 17 00:00:00 2001 From: Dimo-2562 Date: Fri, 3 Jul 2026 16:49:41 +0900 Subject: [PATCH 14/17] =?UTF-8?q?test:=20MDC=20=EB=A1=9C=EA=B7=B8=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EA=B2=80=EC=A6=9D=EC=9D=84=20?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=ED=95=B4=EC=84=9C=20=ED=95=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../websocket/ChatWebSocketHandlerTest.java | 21 ------------------- .../ChatWebSocketRequestDispatcherTest.java | 20 ------------------ 2 files changed, 41 deletions(-) diff --git a/src/test/java/umc/cockple/demo/domain/chat/presentation/websocket/ChatWebSocketHandlerTest.java b/src/test/java/umc/cockple/demo/domain/chat/presentation/websocket/ChatWebSocketHandlerTest.java index 6719cc102..dab5123ba 100644 --- a/src/test/java/umc/cockple/demo/domain/chat/presentation/websocket/ChatWebSocketHandlerTest.java +++ b/src/test/java/umc/cockple/demo/domain/chat/presentation/websocket/ChatWebSocketHandlerTest.java @@ -1,27 +1,21 @@ package umc.cockple.demo.domain.chat.presentation.websocket; -import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.slf4j.MDC; import org.springframework.web.socket.CloseStatus; import org.springframework.web.socket.WebSocketSession; import umc.cockple.demo.domain.chat.presentation.websocket.session.WebSocketSessionRegistry; import umc.cockple.demo.domain.member.service.MemberQueryService; -import umc.cockple.demo.global.logging.MdcLoggingFilter; -import umc.cockple.demo.global.security.filter.JwtAuthenticationFilter; import java.util.HashMap; import java.util.Map; -import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; -import static org.mockito.BDDMockito.willAnswer; @ExtendWith(MockitoExtension.class) @DisplayName("ChatWebSocketHandler") @@ -45,11 +39,6 @@ void setUp() { ); } - @AfterEach - void tearDown() { - MDC.clear(); - } - @Test @DisplayName("연결 종료 시 닫힌 세션과 일치할 때만 세션 저장소에서 제거한다") void afterConnectionClosed_removesClosedSessionByIdentity() { @@ -59,21 +48,11 @@ void afterConnectionClosed_removesClosedSessionByIdentity() { attributes.put("memberId", memberId); given(session.getAttributes()).willReturn(attributes); given(session.getId()).willReturn("session-1"); - MDC.put(MdcLoggingFilter.REQUEST_ID, "request-1"); - willAnswer(invocation -> { - assertThat(MDC.get(JwtAuthenticationFilter.MEMBER_ID)).isEqualTo("10"); - assertThat(MDC.get(WebSocketMdcSupport.WS_SESSION_ID)).isEqualTo("session-1"); - assertThat(MDC.get(MdcLoggingFilter.REQUEST_ID)).isEqualTo("request-1"); - return null; - }).given(sessionRegistry).remove(memberId, session); // when handler.afterConnectionClosed(session, CloseStatus.NORMAL); // then then(sessionRegistry).should().remove(memberId, session); - assertThat(MDC.get(MdcLoggingFilter.REQUEST_ID)).isEqualTo("request-1"); - assertThat(MDC.get(JwtAuthenticationFilter.MEMBER_ID)).isNull(); - assertThat(MDC.get(WebSocketMdcSupport.WS_SESSION_ID)).isNull(); } } diff --git a/src/test/java/umc/cockple/demo/domain/chat/presentation/websocket/ChatWebSocketRequestDispatcherTest.java b/src/test/java/umc/cockple/demo/domain/chat/presentation/websocket/ChatWebSocketRequestDispatcherTest.java index bac250796..467334a9a 100644 --- a/src/test/java/umc/cockple/demo/domain/chat/presentation/websocket/ChatWebSocketRequestDispatcherTest.java +++ b/src/test/java/umc/cockple/demo/domain/chat/presentation/websocket/ChatWebSocketRequestDispatcherTest.java @@ -1,7 +1,6 @@ package umc.cockple.demo.domain.chat.presentation.websocket; import com.fasterxml.jackson.databind.ObjectMapper; -import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -9,12 +8,9 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.slf4j.MDC; import org.springframework.web.socket.WebSocketSession; import umc.cockple.demo.domain.chat.dto.WebSocketMessageDTO; import umc.cockple.demo.domain.chat.enums.WebSocketMessageType; -import umc.cockple.demo.global.logging.MdcLoggingFilter; -import umc.cockple.demo.global.security.filter.JwtAuthenticationFilter; import java.util.HashMap; import java.util.Map; @@ -24,7 +20,6 @@ import static org.mockito.BDDMockito.given; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.then; -import static org.mockito.BDDMockito.willAnswer; @ExtendWith(MockitoExtension.class) @DisplayName("ChatWebSocketRequestDispatcher") @@ -45,11 +40,6 @@ void setUp() { ); } - @AfterEach - void tearDown() { - MDC.clear(); - } - @Test @DisplayName("인증된 요청을 파싱해 command handler에 위임한다") void dispatch_delegatesAuthenticatedRequestToCommandHandler() { @@ -60,13 +50,6 @@ void dispatch_delegatesAuthenticatedRequestToCommandHandler() { attributes.put("memberId", memberId); given(session.getAttributes()).willReturn(attributes); given(session.getId()).willReturn("session-1"); - MDC.put(MdcLoggingFilter.REQUEST_ID, "request-1"); - willAnswer(invocation -> { - assertThat(MDC.get(JwtAuthenticationFilter.MEMBER_ID)).isEqualTo("10"); - assertThat(MDC.get(WebSocketMdcSupport.WS_SESSION_ID)).isEqualTo("session-1"); - assertThat(MDC.get(MdcLoggingFilter.REQUEST_ID)).isEqualTo("request-1"); - return null; - }).given(commandHandler).handle(eq(session), any(WebSocketMessageDTO.Request.class), eq(memberId)); String payload = """ {"type":"SEND","chatRoomId":20,"content":"hello","images":[]} @@ -86,9 +69,6 @@ void dispatch_delegatesAuthenticatedRequestToCommandHandler() { assertThat(request.images()).isEmpty(); then(webSocketResponseSender).shouldHaveNoInteractions(); - assertThat(MDC.get(MdcLoggingFilter.REQUEST_ID)).isEqualTo("request-1"); - assertThat(MDC.get(JwtAuthenticationFilter.MEMBER_ID)).isNull(); - assertThat(MDC.get(WebSocketMdcSupport.WS_SESSION_ID)).isNull(); } @Test From 39ab997457d0c90c0f0ab4dd1d0408b5589f6455 Mon Sep 17 00:00:00 2001 From: Dimo-2562 Date: Fri, 3 Jul 2026 17:00:35 +0900 Subject: [PATCH 15/17] =?UTF-8?q?chore:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=A4=84=EB=B0=94=EA=BF=88=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../filter/JwtAuthenticationFilter.java | 188 +++++++++--------- 1 file changed, 92 insertions(+), 96 deletions(-) diff --git a/src/main/java/umc/cockple/demo/global/security/filter/JwtAuthenticationFilter.java b/src/main/java/umc/cockple/demo/global/security/filter/JwtAuthenticationFilter.java index 0adf72f77..193394400 100644 --- a/src/main/java/umc/cockple/demo/global/security/filter/JwtAuthenticationFilter.java +++ b/src/main/java/umc/cockple/demo/global/security/filter/JwtAuthenticationFilter.java @@ -1,96 +1,92 @@ -package umc.cockple.demo.global.security.filter; - -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.slf4j.MDC; -import org.springframework.security.authentication.BadCredentialsException; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.stereotype.Component; -import org.springframework.web.filter.OncePerRequestFilter; -import umc.cockple.demo.domain.member.domain.Member; -import umc.cockple.demo.domain.member.enums.MemberStatus; -import umc.cockple.demo.domain.member.exception.MemberErrorCode; -import umc.cockple.demo.domain.member.exception.MemberException; -import umc.cockple.demo.domain.member.repository.MemberRepository; -import umc.cockple.demo.global.exception.RestAuthenticationEntryPoint; -import umc.cockple.demo.global.jwt.domain.JwtTokenProvider; - -import java.io.IOException; - -@RequiredArgsConstructor -@Component -@Slf4j -public class JwtAuthenticationFilter extends OncePerRequestFilter { - - public static final String MEMBER_ID = "memberId"; - - private final JwtTokenProvider jwtTokenProvider; - private final MemberRepository memberRepository; - private final RestAuthenticationEntryPoint restEntryPoint; - - @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - - MDC.remove(MEMBER_ID); - - try { - String token = resolveToken(request); - - // token이 null -> 로그 찍고 그대로 진행 - if (token == null) { - log.trace("Authorization 헤더에 토큰 없음"); - filterChain.doFilter(request, response); - return; - } - - if (!jwtTokenProvider.validateToken(token)) { - throw new MemberException(MemberErrorCode.INVALID_TOKEN); - } - - - Long memberId = jwtTokenProvider.getUserId(token); - MDC.put(MEMBER_ID, String.valueOf(memberId)); - Member member = memberRepository.findById(memberId) - .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); - - // 탈퇴 회원 검증 - if (member.getIsActive() == MemberStatus.INACTIVE) { - throw new MemberException(MemberErrorCode.ALREADY_WITHDRAW); - } - - Authentication auth = jwtTokenProvider.getAuthentication(token); - SecurityContextHolder.getContext().setAuthentication(auth); - log.debug("인증 정보 SecurityContext에 저장 완료: {}", auth.getName()); - - filterChain.doFilter(request, response); - - } catch (MemberException e) { - SecurityContextHolder.clearContext(); - restEntryPoint.commence(request, response, new BadCredentialsException(e.getMessage() == null ? "UNAUTHORIZED" : e.getMessage())); - - } catch (RuntimeException e) { // 혹시 남아있는 경우에도 401로 변환 - SecurityContextHolder.clearContext(); - restEntryPoint.commence(request, response, new BadCredentialsException(e.getMessage() == null ? "UNAUTHORIZED" : e.getMessage())); - } finally { - MDC.remove(MEMBER_ID); - } - - } - - - - private String resolveToken(HttpServletRequest request) { - String bearerToken = request.getHeader("Authorization"); - if (bearerToken != null && bearerToken.startsWith("Bearer ")) { - return bearerToken.substring(7); - } - - return null; - } - -} +package umc.cockple.demo.global.security.filter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.slf4j.MDC; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.enums.MemberStatus; +import umc.cockple.demo.domain.member.exception.MemberErrorCode; +import umc.cockple.demo.domain.member.exception.MemberException; +import umc.cockple.demo.domain.member.repository.MemberRepository; +import umc.cockple.demo.global.exception.RestAuthenticationEntryPoint; +import umc.cockple.demo.global.jwt.domain.JwtTokenProvider; + +import java.io.IOException; + +@RequiredArgsConstructor +@Component +@Slf4j +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + public static final String MEMBER_ID = "memberId"; + + private final JwtTokenProvider jwtTokenProvider; + private final MemberRepository memberRepository; + private final RestAuthenticationEntryPoint restEntryPoint; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + + MDC.remove(MEMBER_ID); + + try { + String token = resolveToken(request); + + // token이 null -> 로그 찍고 그대로 진행 + if (token == null) { + log.trace("Authorization 헤더에 토큰 없음"); + filterChain.doFilter(request, response); + return; + } + + if (!jwtTokenProvider.validateToken(token)) { + throw new MemberException(MemberErrorCode.INVALID_TOKEN); + } + + Long memberId = jwtTokenProvider.getUserId(token); + MDC.put(MEMBER_ID, String.valueOf(memberId)); + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); + + // 탈퇴 회원 검증 + if (member.getIsActive() == MemberStatus.INACTIVE) { + throw new MemberException(MemberErrorCode.ALREADY_WITHDRAW); + } + + Authentication auth = jwtTokenProvider.getAuthentication(token); + SecurityContextHolder.getContext().setAuthentication(auth); + log.debug("인증 정보 SecurityContext에 저장 완료: {}", auth.getName()); + + filterChain.doFilter(request, response); + + } catch (MemberException e) { + SecurityContextHolder.clearContext(); + restEntryPoint.commence(request, response, new BadCredentialsException(e.getMessage() == null ? "UNAUTHORIZED" : e.getMessage())); + + } catch (RuntimeException e) { // 혹시 남아있는 경우에도 401로 변환 + SecurityContextHolder.clearContext(); + restEntryPoint.commence(request, response, new BadCredentialsException(e.getMessage() == null ? "UNAUTHORIZED" : e.getMessage())); + } finally { + MDC.remove(MEMBER_ID); + } + + } + + private String resolveToken(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + if (bearerToken != null && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); + } + + return null; + } +} From 748883956e32488104e7779f59f56a6b2d1fc647 Mon Sep 17 00:00:00 2001 From: Dimo-2562 Date: Fri, 3 Jul 2026 17:20:52 +0900 Subject: [PATCH 16/17] =?UTF-8?q?chore:=20=ED=86=A0=ED=81=B0=20=EC=97=86?= =?UTF-8?q?=EB=8A=94=20=EC=9A=94=EC=B2=AD=EC=9D=98=20=EA=B2=BD=EC=9A=B0=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=20=EC=97=90=EB=9F=AC=EA=B0=80=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EC=97=90=EB=9F=AC=EB=A1=9C=20=EC=9E=98=EB=AA=BB=20?= =?UTF-8?q?=EB=B3=80=ED=99=98=EB=90=A0=20=EC=88=98=20=EC=9E=88=EC=9C=BC?= =?UTF-8?q?=EB=AF=80=EB=A1=9C=20=EB=B3=84=EB=8F=84=20try-catch=EB=A1=9C=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../filter/JwtAuthenticationFilter.java | 46 +++++++++---------- .../JwtAuthenticationFilterMdcTest.java | 19 ++++++++ 2 files changed, 42 insertions(+), 23 deletions(-) diff --git a/src/main/java/umc/cockple/demo/global/security/filter/JwtAuthenticationFilter.java b/src/main/java/umc/cockple/demo/global/security/filter/JwtAuthenticationFilter.java index 193394400..f58ac28fa 100644 --- a/src/main/java/umc/cockple/demo/global/security/filter/JwtAuthenticationFilter.java +++ b/src/main/java/umc/cockple/demo/global/security/filter/JwtAuthenticationFilter.java @@ -37,10 +37,8 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { MDC.remove(MEMBER_ID); - try { String token = resolveToken(request); - // token이 null -> 로그 찍고 그대로 진행 if (token == null) { log.trace("Authorization 헤더에 토큰 없음"); @@ -48,33 +46,35 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse return; } - if (!jwtTokenProvider.validateToken(token)) { - throw new MemberException(MemberErrorCode.INVALID_TOKEN); - } + try { + if (!jwtTokenProvider.validateToken(token)) { + throw new MemberException(MemberErrorCode.INVALID_TOKEN); + } - Long memberId = jwtTokenProvider.getUserId(token); - MDC.put(MEMBER_ID, String.valueOf(memberId)); - Member member = memberRepository.findById(memberId) - .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); + Long memberId = jwtTokenProvider.getUserId(token); + MDC.put(MEMBER_ID, String.valueOf(memberId)); + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); - // 탈퇴 회원 검증 - if (member.getIsActive() == MemberStatus.INACTIVE) { - throw new MemberException(MemberErrorCode.ALREADY_WITHDRAW); - } + // 탈퇴 회원 검증 + if (member.getIsActive() == MemberStatus.INACTIVE) { + throw new MemberException(MemberErrorCode.ALREADY_WITHDRAW); + } - Authentication auth = jwtTokenProvider.getAuthentication(token); - SecurityContextHolder.getContext().setAuthentication(auth); - log.debug("인증 정보 SecurityContext에 저장 완료: {}", auth.getName()); + Authentication auth = jwtTokenProvider.getAuthentication(token); + SecurityContextHolder.getContext().setAuthentication(auth); + log.debug("인증 정보 SecurityContext에 저장 완료: {}", auth.getName()); - filterChain.doFilter(request, response); + filterChain.doFilter(request, response); - } catch (MemberException e) { - SecurityContextHolder.clearContext(); - restEntryPoint.commence(request, response, new BadCredentialsException(e.getMessage() == null ? "UNAUTHORIZED" : e.getMessage())); + } catch (MemberException e) { + SecurityContextHolder.clearContext(); + restEntryPoint.commence(request, response, new BadCredentialsException(e.getMessage() == null ? "UNAUTHORIZED" : e.getMessage())); - } catch (RuntimeException e) { // 혹시 남아있는 경우에도 401로 변환 - SecurityContextHolder.clearContext(); - restEntryPoint.commence(request, response, new BadCredentialsException(e.getMessage() == null ? "UNAUTHORIZED" : e.getMessage())); + } catch (RuntimeException e) { // 혹시 남아있는 경우에도 401로 변환 + SecurityContextHolder.clearContext(); + restEntryPoint.commence(request, response, new BadCredentialsException(e.getMessage() == null ? "UNAUTHORIZED" : e.getMessage())); + } } finally { MDC.remove(MEMBER_ID); } diff --git a/src/test/java/umc/cockple/demo/global/security/filter/JwtAuthenticationFilterMdcTest.java b/src/test/java/umc/cockple/demo/global/security/filter/JwtAuthenticationFilterMdcTest.java index b9efb3908..2c30969c8 100644 --- a/src/test/java/umc/cockple/demo/global/security/filter/JwtAuthenticationFilterMdcTest.java +++ b/src/test/java/umc/cockple/demo/global/security/filter/JwtAuthenticationFilterMdcTest.java @@ -20,6 +20,7 @@ import java.util.concurrent.atomic.AtomicReference; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.verifyNoInteractions; @@ -86,4 +87,22 @@ void clearStaleMemberIdWhenRequestHasNoToken() throws Exception { assertThat(MDC.get(JwtAuthenticationFilter.MEMBER_ID)).isNull(); verifyNoInteractions(jwtTokenProvider, memberRepository, restEntryPoint); } + + @Test + @DisplayName("토큰이 없는 요청의 downstream 예외는 인증 실패로 변환하지 않고 전파한다") + void propagateDownstreamExceptionWhenRequestHasNoToken() { + JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtTokenProvider, memberRepository, restEntryPoint); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/public"); + MockHttpServletResponse response = new MockHttpServletResponse(); + MDC.put(JwtAuthenticationFilter.MEMBER_ID, "stale-member"); + + assertThatThrownBy(() -> filter.doFilter(request, response, (servletRequest, servletResponse) -> { + throw new IllegalStateException("downstream failure"); + })) + .isInstanceOf(IllegalStateException.class) + .hasMessage("downstream failure"); + + assertThat(MDC.get(JwtAuthenticationFilter.MEMBER_ID)).isNull(); + verifyNoInteractions(jwtTokenProvider, memberRepository, restEntryPoint); + } } From 1539e9f728bd85038806e92927892ed39920c18e Mon Sep 17 00:00:00 2001 From: Dimo-2562 Date: Fri, 3 Jul 2026 17:22:40 +0900 Subject: [PATCH 17/17] =?UTF-8?q?chore:=20Ai=EB=A1=9C=20=EC=9D=B8=ED=95=B4?= =?UTF-8?q?=20=EC=A4=84=EB=B0=94=EA=BF=88=EC=9D=B4=20=EC=9E=98=EB=AA=BB=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=EB=90=9C=20=EA=B1=B8=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/oauth2/domain/KakaoClient.java | 194 +++++++++--------- 1 file changed, 97 insertions(+), 97 deletions(-) diff --git a/src/main/java/umc/cockple/demo/global/oauth2/domain/KakaoClient.java b/src/main/java/umc/cockple/demo/global/oauth2/domain/KakaoClient.java index 6c75704c2..e7ee42db3 100644 --- a/src/main/java/umc/cockple/demo/global/oauth2/domain/KakaoClient.java +++ b/src/main/java/umc/cockple/demo/global/oauth2/domain/KakaoClient.java @@ -1,97 +1,97 @@ -package umc.cockple.demo.global.oauth2.domain; - -import com.fasterxml.jackson.databind.JsonNode; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; -import org.springframework.stereotype.Component; -import org.springframework.web.reactive.function.BodyInserter; -import org.springframework.web.reactive.function.BodyInserters; -import org.springframework.web.reactive.function.client.WebClient; -import umc.cockple.demo.global.oauth2.domain.info.KakaoClientInfo; - -@Component -@Slf4j -@RequiredArgsConstructor -public class KakaoClient { - - private final WebClient webClient = WebClient.create(); - - @Value("${kakao.client-id}") - private String clientId; - - @Value("${kakao.client-secret:}") - private String clientSecret; - - @Value("${kakao.redirect-uri}") - private String redirectUri; - - @Value("${kakao.token-uri}") - private String tokenUri; - - @Value("${kakao.user-info-uri}") - private String userInfoUri; - - @Value("${kakao.unlink-uri}") - private String unlinkUri; - - @Value("${kakao.admin-key}") - private String adminKey; - - - // 인가코드로 AccessToken 요청하기 - public String getAccessToken(String code) { - BodyInserters.FormInserter formData = BodyInserters.fromFormData("grant_type", "authorization_code") - .with("client_id", clientId) - .with("redirect_uri", redirectUri) - .with("code", code); - - if (clientSecret != null && !clientSecret.isBlank()) { - formData.with("client_secret", clientSecret); - } - - log.debug("Redirect URI used for Kakao OAuth token request: {}", redirectUri); - JsonNode response = webClient.post() - .uri(tokenUri) - .contentType(MediaType.APPLICATION_FORM_URLENCODED) - .body(formData) - .retrieve() - .bodyToMono(JsonNode.class) - .block(); - - return response.get("access_token").asText(); - } - - // access 토큰으로 사용자 정보 요청 - public KakaoClientInfo getClientInfo(String accessToken) { - JsonNode response = webClient.get() - .uri(userInfoUri) - .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) - .retrieve() - .bodyToMono(JsonNode.class) - .block(); - - Long kakaoId = response.get("id").asLong(); - String nickname = response.path("properties").path("nickname").asText(); - - return new KakaoClientInfo(kakaoId, nickname); - } - - // 회원 탈퇴를 위해 카카오와 연결 끊기 - public void unlinkByAdmin(Long socialId) { - webClient.post() - .uri(unlinkUri) - .header(HttpHeaders.AUTHORIZATION, "KakaoAK " + adminKey) - .contentType(MediaType.APPLICATION_FORM_URLENCODED) - .body(BodyInserters.fromFormData("target_id_type", "user_id") - .with("target_id", String.valueOf(socialId))) - .retrieve() - .toBodilessEntity() - .block() - ; - - log.info("[KAKAO] unlink OK. kakaoUserId={}", socialId); - } -} +package umc.cockple.demo.global.oauth2.domain; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.BodyInserter; +import org.springframework.web.reactive.function.BodyInserters; +import org.springframework.web.reactive.function.client.WebClient; +import umc.cockple.demo.global.oauth2.domain.info.KakaoClientInfo; + +@Component +@Slf4j +@RequiredArgsConstructor +public class KakaoClient { + + private final WebClient webClient = WebClient.create(); + + @Value("${kakao.client-id}") + private String clientId; + + @Value("${kakao.client-secret:}") + private String clientSecret; + + @Value("${kakao.redirect-uri}") + private String redirectUri; + + @Value("${kakao.token-uri}") + private String tokenUri; + + @Value("${kakao.user-info-uri}") + private String userInfoUri; + + @Value("${kakao.unlink-uri}") + private String unlinkUri; + + @Value("${kakao.admin-key}") + private String adminKey; + + + // 인가코드로 AccessToken 요청하기 + public String getAccessToken(String code) { + BodyInserters.FormInserter formData = BodyInserters.fromFormData("grant_type", "authorization_code") + .with("client_id", clientId) + .with("redirect_uri", redirectUri) + .with("code", code); + + if (clientSecret != null && !clientSecret.isBlank()) { + formData.with("client_secret", clientSecret); + } + + log.debug("Redirect URI used for Kakao OAuth token request: {}", redirectUri); + JsonNode response = webClient.post() + .uri(tokenUri) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(formData) + .retrieve() + .bodyToMono(JsonNode.class) + .block(); + + return response.get("access_token").asText(); + } + + // access 토큰으로 사용자 정보 요청 + public KakaoClientInfo getClientInfo(String accessToken) { + JsonNode response = webClient.get() + .uri(userInfoUri) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .retrieve() + .bodyToMono(JsonNode.class) + .block(); + + Long kakaoId = response.get("id").asLong(); + String nickname = response.path("properties").path("nickname").asText(); + + return new KakaoClientInfo(kakaoId, nickname); + } + + // 회원 탈퇴를 위해 카카오와 연결 끊기 + public void unlinkByAdmin(Long socialId) { + webClient.post() + .uri(unlinkUri) + .header(HttpHeaders.AUTHORIZATION, "KakaoAK " + adminKey) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(BodyInserters.fromFormData("target_id_type", "user_id") + .with("target_id", String.valueOf(socialId))) + .retrieve() + .toBodilessEntity() + .block() + ; + + log.info("[KAKAO] unlink OK. kakaoUserId={}", socialId); + } +}