From 59e18c9026c932d15252f1363bfde79dfe25fc42 Mon Sep 17 00:00:00 2001 From: Dimo-2562 Date: Fri, 3 Jul 2026 15:39:48 +0900 Subject: [PATCH 1/4] =?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 5f912937cf283870b699125bef055d80cd774fe9 Mon Sep 17 00:00:00 2001 From: Dimo-2562 Date: Fri, 3 Jul 2026 15:46:24 +0900 Subject: [PATCH 2/4] =?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 5936a0b6dcb452272d19bfb0085f6d2cdce1a6d8 Mon Sep 17 00:00:00 2001 From: kanghana1 Date: Sun, 5 Jul 2026 13:27:45 +0900 Subject: [PATCH 3/4] =?UTF-8?q?refactor:=20=EC=B1=84=ED=8C=85=EA=B3=BC=20f?= =?UTF-8?q?cm=20=EC=8A=A4=EB=A0=88=EB=93=9C=ED=92=80=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ChatMessageSendEventListener.java | 2 +- .../ChatNotificationEventListener.java | 2 +- .../ChatRoomListUpdateEventListener.java | 2 +- .../ChatRoomRedisCleanupListener.java | 2 +- .../ChatSubscriptionEventListener.java | 2 +- .../ChatUnreadStatusUpdateEventListener.java | 2 +- .../PartyChatSystemMessageListener.java | 2 +- .../events/NotificationEventListener.java | 2 +- .../demo/global/config/AsyncConfig.java | 65 ++++++++++++++++++- src/main/resources/application.yml | 14 ++++ .../ChatUnreadStatusUpdateEventFlowTest.java | 8 ++- .../events/NotificationEventListenerTest.java | 9 +-- .../demo/global/config/AsyncConfigTest.java | 31 +++++++-- 13 files changed, 123 insertions(+), 20 deletions(-) diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/listener/ChatMessageSendEventListener.java b/src/main/java/umc/cockple/demo/domain/chat/service/listener/ChatMessageSendEventListener.java index b468ce103..076197925 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/service/listener/ChatMessageSendEventListener.java +++ b/src/main/java/umc/cockple/demo/domain/chat/service/listener/ChatMessageSendEventListener.java @@ -16,7 +16,7 @@ public class ChatMessageSendEventListener { private final ChatSendService chatSendService; @EventListener - @Async + @Async("chatExecutor") public void handleChatMessageSend(ChatMessageSendEvent event) { log.info("메시지 전송 이벤트 처리 - 채팅방: {}, 발신자: {}", event.chatRoomId(), event.senderId()); diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/listener/ChatNotificationEventListener.java b/src/main/java/umc/cockple/demo/domain/chat/service/listener/ChatNotificationEventListener.java index 1051d1a75..83b695198 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/service/listener/ChatNotificationEventListener.java +++ b/src/main/java/umc/cockple/demo/domain/chat/service/listener/ChatNotificationEventListener.java @@ -17,7 +17,7 @@ public class ChatNotificationEventListener { private final ChatPushNotificationService chatPushNotificationService; @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - @Async + @Async("notificationExecutor") public void handleChatNotification(ChatNotificationEvent event) { log.info("채팅 알림 이벤트 처리 - 채팅방: {}, 발신자: {}", event.chatRoomId(), event.senderId()); try { diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/listener/ChatRoomListUpdateEventListener.java b/src/main/java/umc/cockple/demo/domain/chat/service/listener/ChatRoomListUpdateEventListener.java index 9f324c0a1..921ed92a4 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/service/listener/ChatRoomListUpdateEventListener.java +++ b/src/main/java/umc/cockple/demo/domain/chat/service/listener/ChatRoomListUpdateEventListener.java @@ -24,7 +24,7 @@ public class ChatRoomListUpdateEventListener { private final ChatRoomListUpdateBroadcaster chatRoomListUpdateBroadcaster; @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - @Async + @Async("chatExecutor") public void handleChatRoomListUpdate(ChatRoomListUpdateEvent event) { log.info("채팅방 목록 업데이트 이벤트 처리 시작 - 채팅방: {}", event.chatRoomId()); diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/listener/ChatRoomRedisCleanupListener.java b/src/main/java/umc/cockple/demo/domain/chat/service/listener/ChatRoomRedisCleanupListener.java index 454cec914..264516471 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/service/listener/ChatRoomRedisCleanupListener.java +++ b/src/main/java/umc/cockple/demo/domain/chat/service/listener/ChatRoomRedisCleanupListener.java @@ -21,7 +21,7 @@ public class ChatRoomRedisCleanupListener { private final ChatListSubscriptionStore chatListSubscriptionStore; @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - @Async + @Async("chatExecutor") public void handleChatRoomRedisCleanup(ChatRoomRedisCleanupEvent event) { Long chatRoomId = event.chatRoomId(); log.info("[채팅방 Redis 정리 시작] - chatRoomId: {}", chatRoomId); diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/listener/ChatSubscriptionEventListener.java b/src/main/java/umc/cockple/demo/domain/chat/service/listener/ChatSubscriptionEventListener.java index 61bb29bb6..8bb50713b 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/service/listener/ChatSubscriptionEventListener.java +++ b/src/main/java/umc/cockple/demo/domain/chat/service/listener/ChatSubscriptionEventListener.java @@ -49,7 +49,7 @@ public void handleChatRoomSubscription(ChatRoomSubscriptionEvent event) { } @EventListener - @Async + @Async("chatExecutor") public void handleChatListSubscription(ChatListSubscriptionEvent event) { log.info("채팅방 목록 구독 이벤트 처리 시작 - 멤버: {}, 액션: {}, 채팅방 수: {}", event.memberId(), event.action(), event.chatRoomIds().size()); diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/listener/ChatUnreadStatusUpdateEventListener.java b/src/main/java/umc/cockple/demo/domain/chat/service/listener/ChatUnreadStatusUpdateEventListener.java index 4aba4aa3d..414704dcd 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/service/listener/ChatUnreadStatusUpdateEventListener.java +++ b/src/main/java/umc/cockple/demo/domain/chat/service/listener/ChatUnreadStatusUpdateEventListener.java @@ -30,7 +30,7 @@ public class ChatUnreadStatusUpdateEventListener { private final ChatSessionRegistry chatSessionRegistry; @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - @Async + @Async("chatExecutor") public void handleChatUnreadStatusUpdate(ChatUnreadStatusUpdateEvent event) { log.info("채팅 안읽음 상태 업데이트 이벤트 처리 시작 - 대상자: {}명", event.targetMemberIds().size()); diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/listener/PartyChatSystemMessageListener.java b/src/main/java/umc/cockple/demo/domain/chat/service/listener/PartyChatSystemMessageListener.java index ad7a10ea3..c88ff845c 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/service/listener/PartyChatSystemMessageListener.java +++ b/src/main/java/umc/cockple/demo/domain/chat/service/listener/PartyChatSystemMessageListener.java @@ -15,7 +15,7 @@ public class PartyChatSystemMessageListener { private final ChatSendService chatSendService; @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - @Async + @Async("chatExecutor") public void handlePartyMemberChanged(PartyMemberJoinedEvent event) { switch (event.action()) { case JOINED -> chatSendService.sendSystemMessage(event.partyId(), diff --git a/src/main/java/umc/cockple/demo/domain/notification/events/NotificationEventListener.java b/src/main/java/umc/cockple/demo/domain/notification/events/NotificationEventListener.java index 8af12a451..9b0a772f0 100644 --- a/src/main/java/umc/cockple/demo/domain/notification/events/NotificationEventListener.java +++ b/src/main/java/umc/cockple/demo/domain/notification/events/NotificationEventListener.java @@ -21,7 +21,7 @@ public class NotificationEventListener { private final MemberRepository memberRepository; @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - @Async + @Async("notificationExecutor") @Transactional(readOnly = true, propagation = Propagation.REQUIRES_NEW) public void handleNotification(NotificationEvent event) { log.info("[NOTIFICATION] FCM 전송 이벤트 처리 - memberId: {}", event.memberId()); 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 e9e74d1a7..b12f9f431 100644 --- a/src/main/java/umc/cockple/demo/global/config/AsyncConfig.java +++ b/src/main/java/umc/cockple/demo/global/config/AsyncConfig.java @@ -1,5 +1,6 @@ package umc.cockple.demo.global.config; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfiguration; import org.springframework.boot.task.ThreadPoolTaskExecutorBuilder; import org.springframework.context.annotation.Bean; @@ -9,16 +10,30 @@ import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import umc.cockple.demo.global.logging.MdcTaskDecorator; +import java.util.concurrent.ThreadPoolExecutor; + +/** + * 지연 특성별로 벌크헤드(bulkhead) 격리 + * {@code chatExecutor} — 실시간/저지연 + * {@code notificationExecutor} — 외부 FCM HTTP 호출 + * {@code applicationTaskExecutor} — qualifier 없는 @Async의 안전한 기본 착지점 + */ @Configuration @EnableAsync public class AsyncConfig { + /** Spring이 qualifier 없는 @Async의 기본 executor를 찾는 이름. */ + private static final String DEFAULT_TASK_EXECUTOR_BEAN_NAME = "taskExecutor"; + @Bean public TaskDecorator mdcTaskDecorator() { return new MdcTaskDecorator(); } - @Bean(name = TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME) + @Bean(name = { + TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME, + DEFAULT_TASK_EXECUTOR_BEAN_NAME + }) public ThreadPoolTaskExecutor applicationTaskExecutor( ThreadPoolTaskExecutorBuilder builder, TaskDecorator mdcTaskDecorator @@ -28,4 +43,52 @@ public ThreadPoolTaskExecutor applicationTaskExecutor( .taskDecorator(mdcTaskDecorator) .build(); } + + /** + * 실시간 채팅용 풀-> 짧은 내부 I/O 작업을 저지연으로 처리 + */ + @Bean("chatExecutor") + public ThreadPoolTaskExecutor chatExecutor( + @Value("${async.chat.core-size:8}") int coreSize, + @Value("${async.chat.max-size:16}") int maxSize, + @Value("${async.chat.queue-capacity:500}") int queueCapacity, + @Value("${async.chat.await-termination-seconds:30}") int awaitTerminationSeconds, + TaskDecorator mdcTaskDecorator + ) { + return buildExecutor("cockple-chat-", coreSize, maxSize, queueCapacity, + awaitTerminationSeconds, mdcTaskDecorator); + } + + /** + * FCM 알림용 풀. 외부 Firebase HTTP 호출을 채팅과 격리 + * 큐를 크게 잡되 유한하게 두어 Firebase 장애 시 무한 백로그(OOM)를 방지 + */ + @Bean("notificationExecutor") + public ThreadPoolTaskExecutor notificationExecutor( + @Value("${async.notification.core-size:4}") int coreSize, + @Value("${async.notification.max-size:12}") int maxSize, + @Value("${async.notification.queue-capacity:1000}") int queueCapacity, + @Value("${async.notification.await-termination-seconds:30}") int awaitTerminationSeconds, + TaskDecorator mdcTaskDecorator + ) { + return buildExecutor("cockple-noti-", coreSize, maxSize, queueCapacity, + awaitTerminationSeconds, mdcTaskDecorator); + } + + private ThreadPoolTaskExecutor buildExecutor(String threadNamePrefix, int coreSize, int maxSize, + int queueCapacity, int awaitTerminationSeconds, + TaskDecorator mdcTaskDecorator) { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(coreSize); + executor.setMaxPoolSize(maxSize); + executor.setQueueCapacity(queueCapacity); + executor.setThreadNamePrefix(threadNamePrefix); + executor.setTaskDecorator(mdcTaskDecorator); + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + // graceful shutdown: 진행 중인 작업이 끝날 때까지 대기 + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(awaitTerminationSeconds); + // 스프링이 빈 초기화 시 afterPropertiesSet()으로 initialize()를 호출한다. + return executor; + } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 258ef9061..5607a9dbf 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -85,6 +85,20 @@ jwt: access-token-validity: 900000 refresh-token-validity: 1209600000 +async: + # 실시간 채팅 풀 (저지연 내부 I/O) + chat: + core-size: 8 + max-size: 16 + queue-capacity: 500 + await-termination-seconds: 30 + # FCM 알림 풀 + notification: + core-size: 4 + max-size: 12 + queue-capacity: 1000 + await-termination-seconds: 30 + logging: level: org.hibernate.SQL: WARN diff --git a/src/test/java/umc/cockple/demo/domain/chat/service/listener/ChatUnreadStatusUpdateEventFlowTest.java b/src/test/java/umc/cockple/demo/domain/chat/service/listener/ChatUnreadStatusUpdateEventFlowTest.java index f82b0821b..721d21368 100644 --- a/src/test/java/umc/cockple/demo/domain/chat/service/listener/ChatUnreadStatusUpdateEventFlowTest.java +++ b/src/test/java/umc/cockple/demo/domain/chat/service/listener/ChatUnreadStatusUpdateEventFlowTest.java @@ -7,9 +7,9 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Primary; import org.springframework.core.task.SyncTaskExecutor; import org.springframework.core.task.TaskExecutor; +import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.web.socket.WebSocketSession; import umc.cockple.demo.domain.chat.domain.ChatMessage; @@ -50,14 +50,16 @@ import static org.mockito.Mockito.times; @DisplayName("ChatUnreadStatusUpdateEvent after-commit flow") +// 채팅 @Async 풀(chatExecutor)을 SyncTaskExecutor로 덮어써 동기 실행하려면 빈 오버라이드 허용 필요 +@TestPropertySource(properties = "spring.main.allow-bean-definition-overriding=true") class ChatUnreadStatusUpdateEventFlowTest extends IntegrationTestBase { + // handleChatUnreadStatusUpdate의 @Async("chatExecutor")를 동기로 실행해 테스트 타이밍 문제를 제거 @TestConfiguration static class SyncAsyncConfig { @Bean - @Primary - public TaskExecutor taskExecutor() { + public TaskExecutor chatExecutor() { return new SyncTaskExecutor(); } } diff --git a/src/test/java/umc/cockple/demo/domain/notification/events/NotificationEventListenerTest.java b/src/test/java/umc/cockple/demo/domain/notification/events/NotificationEventListenerTest.java index acfff275d..9d8edc11c 100644 --- a/src/test/java/umc/cockple/demo/domain/notification/events/NotificationEventListenerTest.java +++ b/src/test/java/umc/cockple/demo/domain/notification/events/NotificationEventListenerTest.java @@ -8,9 +8,9 @@ import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Primary; import org.springframework.core.task.SyncTaskExecutor; import org.springframework.core.task.TaskExecutor; +import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.transaction.support.TransactionTemplate; @@ -27,14 +27,15 @@ import static org.mockito.Mockito.never; @DisplayName("NotificationEventListener") +// 알림 @Async 풀(notificationExecutor)을 SyncTaskExecutor로 덮어써 동기 실행하려면 빈 오버라이드 허용 필요 +@TestPropertySource(properties = "spring.main.allow-bean-definition-overriding=true") class NotificationEventListenerTest extends IntegrationTestBase { - // @Async를 동기로 실행해 테스트 스레드와 타이밍 문제를 제거 + // handleNotification의 @Async("notificationExecutor")를 동기로 실행해 테스트 타이밍 문제를 제거 @TestConfiguration static class SyncAsyncConfig { @Bean - @Primary - public TaskExecutor taskExecutor() { + public TaskExecutor notificationExecutor() { return new SyncTaskExecutor(); } } diff --git a/src/test/java/umc/cockple/demo/global/config/AsyncConfigTest.java b/src/test/java/umc/cockple/demo/global/config/AsyncConfigTest.java index 0a248daee..a725ba7a1 100644 --- a/src/test/java/umc/cockple/demo/global/config/AsyncConfigTest.java +++ b/src/test/java/umc/cockple/demo/global/config/AsyncConfigTest.java @@ -16,6 +16,8 @@ class AsyncConfigTest { + private final AsyncConfig config = new AsyncConfig(); + @AfterEach void tearDown() { MDC.clear(); @@ -24,17 +26,38 @@ void tearDown() { @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 + config.mdcTaskDecorator() ); - executor.initialize(); + assertPropagatesMdcAndPreventsLeak(executor); + } + + @Test + @DisplayName("chatExecutor는 MDC를 비동기 작업에 전파하고 다음 작업으로 누수하지 않는다") + void chatExecutorPropagatesMdcAndPreventsLeak() throws Exception { + ThreadPoolTaskExecutor executor = config.chatExecutor(1, 1, 1, 1, config.mdcTaskDecorator()); + + assertPropagatesMdcAndPreventsLeak(executor); + } + + @Test + @DisplayName("notificationExecutor는 MDC를 비동기 작업에 전파하고 다음 작업으로 누수하지 않는다") + void notificationExecutorPropagatesMdcAndPreventsLeak() throws Exception { + ThreadPoolTaskExecutor executor = config.notificationExecutor(1, 1, 1, 1, config.mdcTaskDecorator()); + + assertPropagatesMdcAndPreventsLeak(executor); + } + + /** + * 풀에서 도는 작업에 requestId(MDC)가 전파되고, 스레드 재사용 시 이전 컨텍스트가 누수되지 않는지 검증한다. + */ + private void assertPropagatesMdcAndPreventsLeak(ThreadPoolTaskExecutor executor) throws Exception { + executor.initialize(); try { MDC.put(MdcLoggingFilter.REQUEST_ID, "async-request"); CompletableFuture firstTask = new CompletableFuture<>(); From 0a0ab2cca33d61c6a0eff380cdca3b6f0de91d11 Mon Sep 17 00:00:00 2001 From: kanghana1 Date: Mon, 6 Jul 2026 22:42:52 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20=ED=8F=AC=ED=99=94=EB=A1=9C=20?= =?UTF-8?q?=EA=B1=B0=EB=B6=80=EB=90=9C=20=EC=9E=91=EC=97=85=EC=9D=80=20=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EB=A7=8C=20=EC=B0=8D=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- result-bulkhead-before.json | 197 ++++++++++++++++++ .../demo/global/config/AsyncConfig.java | 24 ++- 2 files changed, 217 insertions(+), 4 deletions(-) create mode 100644 result-bulkhead-before.json diff --git a/result-bulkhead-before.json b/result-bulkhead-before.json new file mode 100644 index 000000000..7aeddc6b1 --- /dev/null +++ b/result-bulkhead-before.json @@ -0,0 +1,197 @@ +{ + "root_group": { + "name": "", + "path": "", + "id": "d41d8cd98f00b204e9800998ecf8427e", + "groups": {}, + "checks": { + "[notif] status 200": { + "passes": 1567, + "fails": 0, + "name": "[notif] status 200", + "path": "::[notif] status 200", + "id": "9fc740afe9cf21a0327678441a348978" + }, + "[ws] status 101": { + "name": "[ws] status 101", + "path": "::[ws] status 101", + "id": "3cc6802624f263d3928ed6de98f3c352", + "passes": 2, + "fails": 0 + } + } + }, + "metrics": { + "ws_connecting": { + "max": 1155.4575, + "p(90)": 1155.34769, + "p(95)": 1155.402595, + "avg": 1154.90845, + "min": 1154.3594, + "med": 1154.90845 + }, + "ws_msgs_sent": { + "count": 1651, + "rate": 4.985553421339417 + }, + "ws_msgs_received": { + "count": 3304, + "rate": 9.977146277471492 + }, + "http_reqs": { + "count": 1567, + "rate": 4.731897160047769 + }, + "http_req_receiving": { + "p(90)": 0.7683200000000002, + "p(95)": 0.9485099999999989, + "avg": 0.21188544990427544, + "min": 0, + "med": 0, + "max": 6.9107 + }, + "checks": { + "passes": 1569, + "fails": 0, + "value": 1 + }, + "vus_max": { + "value": 12, + "min": 12, + "max": 12 + }, + "chat_messages_received": { + "count": 1648, + "rate": 4.97649426915043 + }, + "iterations": { + "count": 1569, + "rate": 4.737936594840427 + }, + "ws_sessions": { + "count": 2, + "rate": 0.006039434792658289 + }, + "http_req_duration{expected_response:true}": { + "p(90)": 2076.8168400000004, + "p(95)": 2721.69289, + "avg": 1151.817254243779, + "min": 431.0899, + "med": 977.7864, + "max": 4974.7035 + }, + "http_req_blocked": { + "p(90)": 0, + "p(95)": 0, + "avg": 1.902208615188258, + "min": 0, + "med": 0, + "max": 315.6305 + }, + "http_req_connecting": { + "max": 148.6131, + "p(90)": 0, + "p(95)": 0, + "avg": 0.895541671984684, + "min": 0, + "med": 0 + }, + "chat_messages_sent": { + "count": 1649, + "rate": 4.979513986546759 + }, + "http_req_sending": { + "med": 0, + "max": 3.377, + "p(90)": 0.6262200000000001, + "p(95)": 0.7184799999999996, + "avg": 0.17865022335673258, + "min": 0 + }, + "vus": { + "value": 2, + "min": 2, + "max": 12 + }, + "data_sent": { + "count": 251316, + "rate": 758.9032971758552 + }, + "http_req_waiting": { + "p(95)": 2721.5110299999997, + "avg": 1151.4267185705173, + "min": 430.2286, + "med": 977.2512, + "max": 4974.1908, + "p(90)": 2076.08822 + }, + "chat_e2e_latency": { + "min": 350, + "med": 404, + "max": 5422, + "p(90)": 3371.499999999998, + "p(95)": 4390.65, + "avg": 937.6231796116505, + "thresholds": { + "p(95)<3000": true + } + }, + "notif_req_duration": { + "med": 977.7864, + "max": 4974.7035, + "p(90)": 2076.8168400000004, + "p(95)": 2721.69289, + "avg": 1151.817254243779, + "min": 431.0899 + }, + "ws_session_duration": { + "min": 331155.5802, + "med": 331155.89955, + "max": 331156.2189, + "p(90)": 331156.15502999997, + "p(95)": 331156.186965, + "avg": 331155.89955 + }, + "notif_errors": { + "passes": 0, + "fails": 1567, + "thresholds": { + "rate<0.10": false + }, + "value": 0 + }, + "http_req_failed": { + "fails": 1567, + "passes": 0, + "value": 0 + }, + "iteration_duration": { + "avg": 1574.7665509878918, + "min": 431.6325, + "med": 982.5982, + "max": 331156.2189, + "p(90)": 2088.631680000001, + "p(95)": 2724.6312 + }, + "http_req_duration": { + "p(90)": 2076.8168400000004, + "p(95)": 2721.69289, + "avg": 1151.817254243779, + "min": 431.0899, + "med": 977.7864, + "max": 4974.7035 + }, + "data_received": { + "count": 1517803, + "rate": 4583.336123300564 + }, + "http_req_tls_handshaking": { + "max": 181.0315, + "p(90)": 0, + "p(95)": 0, + "avg": 1.003557562220804, + "min": 0, + "med": 0 + } + } +} \ No newline at end of file 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 b12f9f431..9e77abc83 100644 --- a/src/main/java/umc/cockple/demo/global/config/AsyncConfig.java +++ b/src/main/java/umc/cockple/demo/global/config/AsyncConfig.java @@ -1,5 +1,6 @@ package umc.cockple.demo.global.config; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfiguration; import org.springframework.boot.task.ThreadPoolTaskExecutorBuilder; @@ -10,6 +11,7 @@ import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import umc.cockple.demo.global.logging.MdcTaskDecorator; +import java.util.concurrent.RejectedExecutionHandler; import java.util.concurrent.ThreadPoolExecutor; /** @@ -20,6 +22,7 @@ */ @Configuration @EnableAsync +@Slf4j public class AsyncConfig { /** Spring이 qualifier 없는 @Async의 기본 executor를 찾는 이름. */ @@ -55,8 +58,9 @@ public ThreadPoolTaskExecutor chatExecutor( @Value("${async.chat.await-termination-seconds:30}") int awaitTerminationSeconds, TaskDecorator mdcTaskDecorator ) { + // 채팅은 유실 방지가 우선 -> 포화 시 호출 스레드에서 실행해 백프레셔를 건다. return buildExecutor("cockple-chat-", coreSize, maxSize, queueCapacity, - awaitTerminationSeconds, mdcTaskDecorator); + awaitTerminationSeconds, mdcTaskDecorator, new ThreadPoolExecutor.CallerRunsPolicy()); } /** @@ -71,20 +75,32 @@ public ThreadPoolTaskExecutor notificationExecutor( @Value("${async.notification.await-termination-seconds:30}") int awaitTerminationSeconds, TaskDecorator mdcTaskDecorator ) { + return buildExecutor("cockple-noti-", coreSize, maxSize, queueCapacity, - awaitTerminationSeconds, mdcTaskDecorator); + awaitTerminationSeconds, mdcTaskDecorator, logAndDiscardPolicy()); + } + + /** + * 큐/스레드 포화로 거부된 작업을 예외 전파 없이 버림 + */ + private RejectedExecutionHandler logAndDiscardPolicy() { + // TODO: 알림 재전송 도입 시, 여기서 drop 대신 outbox/DLQ에 적재해 재시도 훅으로 사용 + return (runnable, executor) -> + log.warn("[NOTIFICATION] 알림 풀 포화로 작업 drop - poolSize={}, activeCount={}, queueSize={}", + executor.getPoolSize(), executor.getActiveCount(), executor.getQueue().size()); } private ThreadPoolTaskExecutor buildExecutor(String threadNamePrefix, int coreSize, int maxSize, int queueCapacity, int awaitTerminationSeconds, - TaskDecorator mdcTaskDecorator) { + TaskDecorator mdcTaskDecorator, + RejectedExecutionHandler rejectedExecutionHandler) { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(coreSize); executor.setMaxPoolSize(maxSize); executor.setQueueCapacity(queueCapacity); executor.setThreadNamePrefix(threadNamePrefix); executor.setTaskDecorator(mdcTaskDecorator); - executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + executor.setRejectedExecutionHandler(rejectedExecutionHandler); // graceful shutdown: 진행 중인 작업이 끝날 때까지 대기 executor.setWaitForTasksToCompleteOnShutdown(true); executor.setAwaitTerminationSeconds(awaitTerminationSeconds);