From b03bee2590d88092fbc7e5fe95bbe1675a582be8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=EB=AF=BC?= Date: Mon, 28 Sep 2026 03:29:31 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20#360=20STOMP=20=EB=AC=BC=EB=A6=AC?= =?UTF-8?q?=20=EC=84=B8=EC=85=98=20=EC=B6=94=EC=A0=81=20=EA=B8=B0=EB=B0=98?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../websocket/StompSessionAuthorization.java | 30 +++++ .../auth/websocket/StompSessionRegistry.java | 119 ++++++++++++++++++ .../StompSessionTrackingDecoratorFactory.java | 51 ++++++++ .../global/config/WebSocketConfig.java | 12 +- .../websocket/StompSessionRegistryTest.java | 117 +++++++++++++++++ 5 files changed, 328 insertions(+), 1 deletion(-) create mode 100644 backend/src/main/java/com/opensource/docgrid/domain/auth/websocket/StompSessionAuthorization.java create mode 100644 backend/src/main/java/com/opensource/docgrid/domain/auth/websocket/StompSessionRegistry.java create mode 100644 backend/src/main/java/com/opensource/docgrid/domain/auth/websocket/StompSessionTrackingDecoratorFactory.java create mode 100644 backend/src/test/java/com/opensource/docgrid/domain/auth/websocket/StompSessionRegistryTest.java diff --git a/backend/src/main/java/com/opensource/docgrid/domain/auth/websocket/StompSessionAuthorization.java b/backend/src/main/java/com/opensource/docgrid/domain/auth/websocket/StompSessionAuthorization.java new file mode 100644 index 00000000..669550fd --- /dev/null +++ b/backend/src/main/java/com/opensource/docgrid/domain/auth/websocket/StompSessionAuthorization.java @@ -0,0 +1,30 @@ +package com.opensource.docgrid.domain.auth.websocket; + +import java.time.Instant; +import java.util.Objects; +import java.util.Set; + +/** + * STOMP CONNECT가 성공한 시점의 token 식별자·만료 시각·역할 snapshot을 세션 수명 검증에 전달한다. + * + *

JWT 원문과 이메일은 보관하지 않는다. 역할은 순서와 중복에 영향을 받지 않도록 불변 Set으로 + * 정규화하며, 이후 검사에서 Redis blacklist와 DB의 현재 역할을 이 snapshot과 비교한다. + */ +public record StompSessionAuthorization( + Long userId, + String jti, + Instant expiresAt, + Set roles +) { + + public StompSessionAuthorization { + Objects.requireNonNull(userId, "userId는 필수입니다."); + Objects.requireNonNull(jti, "jti는 필수입니다."); + Objects.requireNonNull(expiresAt, "expiresAt은 필수입니다."); + roles = Set.copyOf(Objects.requireNonNull(roles, "roles는 필수입니다.")); + } + + public boolean isExpired(Instant now) { + return !now.isBefore(expiresAt); + } +} diff --git a/backend/src/main/java/com/opensource/docgrid/domain/auth/websocket/StompSessionRegistry.java b/backend/src/main/java/com/opensource/docgrid/domain/auth/websocket/StompSessionRegistry.java new file mode 100644 index 00000000..8c04ccc4 --- /dev/null +++ b/backend/src/main/java/com/opensource/docgrid/domain/auth/websocket/StompSessionRegistry.java @@ -0,0 +1,119 @@ +package com.opensource.docgrid.domain.auth.websocket; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +import org.springframework.stereotype.Component; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.WebSocketSession; + +import lombok.extern.slf4j.Slf4j; + +/** + * 현재 Backend 인스턴스가 소유한 물리 WebSocket 연결과 STOMP 인증 snapshot을 함께 관리한다. + * + *

물리 연결은 WebSocket decorator가 먼저 등록하고, CONNECT 인증이 끝난 뒤 같은 sessionId에 + * 인증 snapshot을 결합한다. 주기 검사는 인증 완료 세션만 읽으며, 종료와 인증이 경합해도 하나의 + * ConcurrentMap entry를 기준으로 정리해 닫힌 연결이 다시 등록되는 것을 막는다. + * + *

이 registry는 로컬 전송 자원만 관리한다. 여러 Backend 인스턴스는 각자 자신의 registry를 + * 검사하고 공통 Redis·DB 상태를 읽으므로 분산 session registry나 lock이 필요하지 않다. + */ +@Component +@Slf4j +public class StompSessionRegistry { + + private static final CloseStatus AUTHORIZATION_INVALID = CloseStatus.POLICY_VIOLATION; + + private final ConcurrentMap sessions = new ConcurrentHashMap<>(); + + public void registerTransport(WebSocketSession session) { + SessionState previous = sessions.putIfAbsent(session.getId(), new SessionState(session)); + if (previous != null) { + throw new IllegalStateException("이미 등록된 WebSocket sessionId입니다."); + } + } + + public boolean authenticate(String sessionId, StompSessionAuthorization authorization) { + return sessions.computeIfPresent(sessionId, (ignored, state) -> { + state.authenticate(authorization); + return state; + }) != null; + } + + public void remove(String sessionId) { + sessions.remove(sessionId); + } + + public List authenticatedSessions() { + List snapshots = new ArrayList<>(); + sessions.forEach((sessionId, state) -> { + if (!state.session().isOpen()) { + sessions.remove(sessionId, state); + return; + } + StompSessionAuthorization authorization = state.authorization(); + if (authorization != null) { + snapshots.add(new SessionSnapshot(sessionId, authorization)); + } + }); + return List.copyOf(snapshots); + } + + public int authenticatedSessionCount() { + return (int) sessions.values().stream() + .filter(state -> state.session().isOpen() && state.authorization() != null) + .count(); + } + + public boolean close(String sessionId) { + SessionState state = sessions.get(sessionId); + if (state == null) { + return false; + } + if (!state.session().isOpen()) { + sessions.remove(sessionId, state); + return false; + } + + try { + state.session().close(AUTHORIZATION_INVALID); + sessions.remove(sessionId, state); + return true; + } catch (IOException exception) { + // 추적 정보를 남겨 다음 검사에서 다시 닫을 수 있게 한다. 식별 정보는 로그에 노출하지 않는다. + log.warn("유효하지 않은 STOMP WebSocket 세션 종료에 실패했습니다: {}", exception.getMessage()); + return false; + } + } + + /** 주기 검사에 필요한 sessionId와 불변 인증 snapshot만 노출하는 조회 경계다. */ + public record SessionSnapshot(String sessionId, StompSessionAuthorization authorization) { + } + + /** 물리 연결과 CONNECT 이후 추가되는 인증 snapshot을 하나의 map entry에 보관한다. */ + private static final class SessionState { + + private final WebSocketSession session; + private volatile StompSessionAuthorization authorization; + + private SessionState(WebSocketSession session) { + this.session = session; + } + + private WebSocketSession session() { + return session; + } + + private StompSessionAuthorization authorization() { + return authorization; + } + + private void authenticate(StompSessionAuthorization authorization) { + this.authorization = authorization; + } + } +} diff --git a/backend/src/main/java/com/opensource/docgrid/domain/auth/websocket/StompSessionTrackingDecoratorFactory.java b/backend/src/main/java/com/opensource/docgrid/domain/auth/websocket/StompSessionTrackingDecoratorFactory.java new file mode 100644 index 00000000..5a53063b --- /dev/null +++ b/backend/src/main/java/com/opensource/docgrid/domain/auth/websocket/StompSessionTrackingDecoratorFactory.java @@ -0,0 +1,51 @@ +package com.opensource.docgrid.domain.auth.websocket; + +import org.springframework.stereotype.Component; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.handler.WebSocketHandlerDecorator; +import org.springframework.web.socket.handler.WebSocketHandlerDecoratorFactory; + +import lombok.RequiredArgsConstructor; + +/** + * Spring STOMP 계층 밖의 물리 WebSocketSession을 수명주기 registry에 연결하는 전송 계층 adapter다. + * + *

Spring 6.2의 STOMP sessionId는 이 WebSocketSession id에서 만들어지므로 CONNECT interceptor가 + * 같은 식별자로 인증 snapshot을 결합할 수 있다. 연결 설정이 실패하거나 정상 종료돼도 registry + * entry가 남지 않도록 delegate 호출의 실패·종료 경계에서 정리한다. + */ +@Component +@RequiredArgsConstructor +public class StompSessionTrackingDecoratorFactory implements WebSocketHandlerDecoratorFactory { + + private final StompSessionRegistry stompSessionRegistry; + + @Override + public WebSocketHandler decorate(WebSocketHandler handler) { + return new WebSocketHandlerDecorator(handler) { + @Override + public void afterConnectionEstablished(WebSocketSession session) throws Exception { + // 1. STOMP CONNECT보다 물리 연결이 먼저 생기므로 transport를 먼저 등록한다. + stompSessionRegistry.registerTransport(session); + try { + super.afterConnectionEstablished(session); + } catch (Exception exception) { + stompSessionRegistry.remove(session.getId()); + throw exception; + } + } + + @Override + public void afterConnectionClosed(WebSocketSession session, org.springframework.web.socket.CloseStatus status) + throws Exception { + try { + super.afterConnectionClosed(session, status); + } finally { + // 2. 네트워크 종료와 서버 강제 종료 모두 같은 경로에서 추적 정보를 제거한다. + stompSessionRegistry.remove(session.getId()); + } + } + }; + } +} diff --git a/backend/src/main/java/com/opensource/docgrid/global/config/WebSocketConfig.java b/backend/src/main/java/com/opensource/docgrid/global/config/WebSocketConfig.java index 44496453..e7f7c7de 100644 --- a/backend/src/main/java/com/opensource/docgrid/global/config/WebSocketConfig.java +++ b/backend/src/main/java/com/opensource/docgrid/global/config/WebSocketConfig.java @@ -6,9 +6,11 @@ import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; import org.springframework.web.socket.config.annotation.StompEndpointRegistry; import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; +import org.springframework.web.socket.config.annotation.WebSocketTransportRegistration; import com.opensource.docgrid.domain.auth.jwt.StompAuthChannelInterceptor; import com.opensource.docgrid.domain.auth.websocket.StompDestinationAuthorizationInterceptor; +import com.opensource.docgrid.domain.auth.websocket.StompSessionTrackingDecoratorFactory; import lombok.RequiredArgsConstructor; @@ -17,7 +19,8 @@ * *

인증·인가는 이 설정이 아니라 {@link StompAuthChannelInterceptor}(CONNECT 시점 인증)와 * {@link StompDestinationAuthorizationInterceptor}(SUBSCRIBE·SEND 시점 인가)가 담당한다. - * 이 클래스는 전송 계층 구성(endpoint·broker·origin)과 두 Interceptor의 등록 순서만 책임진다. + * 이 클래스는 전송 계층 구성(endpoint·broker·origin), 물리 세션 추적과 두 Interceptor의 등록 순서만 + * 책임진다. * *

{@code /queue}는 RAG 답변 개인 알림({@code convertAndSendToUser})의 broker 내부 목적지로 쓰인다. * client는 {@code /user/queue/rag-answer}만 구독할 수 있고, 실제 {@code /queue}와 pattern 접근은 @@ -30,6 +33,7 @@ public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { private final StompAuthChannelInterceptor stompAuthChannelInterceptor; private final StompDestinationAuthorizationInterceptor stompDestinationAuthorizationInterceptor; + private final StompSessionTrackingDecoratorFactory stompSessionTrackingDecoratorFactory; @Override public void registerStompEndpoints(StompEndpointRegistry registry) { @@ -43,6 +47,12 @@ public void configureMessageBroker(MessageBrokerRegistry registry) { registry.enableSimpleBroker("/topic", "/queue"); } + @Override + public void configureWebSocketTransport(WebSocketTransportRegistration registration) { + // CONNECT 전에 생성되는 물리 세션을 보관해야 이후 인증 snapshot과 같은 sessionId로 결합할 수 있다. + registration.addDecoratorFactory(stompSessionTrackingDecoratorFactory); + } + @Override public void configureClientInboundChannel(ChannelRegistration registration) { // 1. StompAuthChannelInterceptor가 CONNECT 프레임의 JWT를 검증하고 세션에 Principal을 부착한다. diff --git a/backend/src/test/java/com/opensource/docgrid/domain/auth/websocket/StompSessionRegistryTest.java b/backend/src/test/java/com/opensource/docgrid/domain/auth/websocket/StompSessionRegistryTest.java new file mode 100644 index 00000000..5075fe02 --- /dev/null +++ b/backend/src/test/java/com/opensource/docgrid/domain/auth/websocket/StompSessionRegistryTest.java @@ -0,0 +1,117 @@ +package com.opensource.docgrid.domain.auth.websocket; + +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.BDDMockito.then; + +import java.io.IOException; +import java.time.Instant; +import java.util.Set; + +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.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.WebSocketSession; + +/** + * 물리 WebSocket 연결과 인증 snapshot의 결합, 종료 및 실패 시 재시도 계약을 검증한다. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("STOMP 세션 Registry 단위 테스트") +class StompSessionRegistryTest { + + private static final String SESSION_ID = "session-1"; + + @Mock + private WebSocketSession session; + + private final StompSessionRegistry registry = new StompSessionRegistry(); + + @Test + @DisplayName("물리 연결과 인증 snapshot이 모두 등록된 세션만 검사 대상으로 반환한다") + void authenticatedSessions_returnsOnlyAuthenticatedOpenSessions() { + // Given + given(session.getId()).willReturn(SESSION_ID); + given(session.isOpen()).willReturn(true); + StompSessionAuthorization authorization = authorization(); + + // When + registry.registerTransport(session); + boolean authenticated = registry.authenticate(SESSION_ID, authorization); + + // Then + assertThat(authenticated).isTrue(); + assertThat(registry.authenticatedSessions()) + .containsExactly(new StompSessionRegistry.SessionSnapshot(SESSION_ID, authorization)); + assertThat(registry.authenticatedSessionCount()).isEqualTo(1); + } + + @Test + @DisplayName("물리 연결이 없는 sessionId에는 인증 snapshot을 등록하지 않는다") + void authenticate_returnsFalse_whenTransportMissing() { + assertThat(registry.authenticate(SESSION_ID, authorization())).isFalse(); + assertThat(registry.authenticatedSessions()).isEmpty(); + } + + @Test + @DisplayName("유효하지 않은 세션을 정책 위반 상태로 닫고 registry에서 제거한다") + void close_closesTransportAndRemovesSession() throws Exception { + // Given + given(session.getId()).willReturn(SESSION_ID); + given(session.isOpen()).willReturn(true); + registry.registerTransport(session); + registry.authenticate(SESSION_ID, authorization()); + + // When + boolean closed = registry.close(SESSION_ID); + + // Then + assertThat(closed).isTrue(); + then(session).should().close(CloseStatus.POLICY_VIOLATION); + assertThat(registry.authenticatedSessions()).isEmpty(); + } + + @Test + @DisplayName("세션 종료가 실패하면 추적 정보를 유지해 다음 검사에서 재시도할 수 있다") + void close_keepsSession_whenTransportCloseFails() throws Exception { + // Given + given(session.getId()).willReturn(SESSION_ID); + given(session.isOpen()).willReturn(true); + registry.registerTransport(session); + registry.authenticate(SESSION_ID, authorization()); + org.mockito.BDDMockito.willThrow(new IOException("close failed")) + .given(session).close(CloseStatus.POLICY_VIOLATION); + + // When + boolean closed = registry.close(SESSION_ID); + + // Then + assertThat(closed).isFalse(); + assertThat(registry.authenticatedSessionCount()).isEqualTo(1); + } + + @Test + @DisplayName("같은 물리 sessionId를 중복 등록하면 기존 연결을 덮어쓰지 않는다") + void registerTransport_rejectsDuplicateSessionId() { + // Given + given(session.getId()).willReturn(SESSION_ID); + registry.registerTransport(session); + + // When & Then + assertThatThrownBy(() -> registry.registerTransport(session)) + .isInstanceOf(IllegalStateException.class); + } + + private StompSessionAuthorization authorization() { + return new StompSessionAuthorization( + 1L, + "jti-1", + Instant.parse("2026-09-28T00:00:00Z"), + Set.of("USER") + ); + } +} From 81b255c4d4466d81a93823e2c7fd607a37947f30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=EB=AF=BC?= Date: Mon, 28 Sep 2026 03:35:39 +0900 Subject: [PATCH 2/8] =?UTF-8?q?fix:=20#360=20=EC=97=B4=EB=A6=B0=20STOMP=20?= =?UTF-8?q?=EC=84=B8=EC=85=98=20=EC=9D=B8=EC=A6=9D=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EC=9E=AC=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 3 + .../auth/jwt/StompAuthChannelInterceptor.java | 31 ++- .../auth/jwt/TokenBlacklistService.java | 34 +++ .../auth/websocket/StompSessionRegistry.java | 2 +- .../StompSessionRevalidationScheduler.java | 206 ++++++++++++++++++ backend/src/main/resources/application.yml | 8 + .../jwt/StompAuthChannelInterceptorTest.java | 64 +++++- .../auth/jwt/TokenBlacklistServiceTest.java | 33 +++ ...StompSessionRevalidationSchedulerTest.java | 204 +++++++++++++++++ 9 files changed, 580 insertions(+), 5 deletions(-) create mode 100644 backend/src/main/java/com/opensource/docgrid/domain/auth/websocket/StompSessionRevalidationScheduler.java create mode 100644 backend/src/test/java/com/opensource/docgrid/domain/auth/websocket/StompSessionRevalidationSchedulerTest.java diff --git a/.env.example b/.env.example index faa2006e..883a4c44 100644 --- a/.env.example +++ b/.env.example @@ -93,3 +93,6 @@ MINIO_SECRET_KEY=minioadmin1234 # REDIS_PASSWORD= # REDIS_TIMEOUT=1s # REDIS_CONNECT_TIMEOUT=1s +# 열린 STOMP 세션의 token 만료·blacklist·역할 변경을 다시 확인하는 주기와 일괄 조회 크기입니다. +# STOMP_SESSION_REVALIDATION_INTERVAL=5s +# STOMP_SESSION_REVALIDATION_BATCH_SIZE=500 diff --git a/backend/src/main/java/com/opensource/docgrid/domain/auth/jwt/StompAuthChannelInterceptor.java b/backend/src/main/java/com/opensource/docgrid/domain/auth/jwt/StompAuthChannelInterceptor.java index 58711766..e793e6f7 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/auth/jwt/StompAuthChannelInterceptor.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/auth/jwt/StompAuthChannelInterceptor.java @@ -1,6 +1,9 @@ package com.opensource.docgrid.domain.auth.jwt; +import java.time.Instant; +import java.util.Date; import java.util.List; +import java.util.Set; import org.springframework.messaging.Message; import org.springframework.messaging.MessageChannel; @@ -14,6 +17,9 @@ import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; +import com.opensource.docgrid.domain.auth.websocket.StompSessionAuthorization; +import com.opensource.docgrid.domain.auth.websocket.StompSessionRegistry; + import io.jsonwebtoken.Claims; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -41,8 +47,8 @@ * *

HTTP는 요청 하나로 끝나 매번 {@code SecurityContextHolder}를 새로 채우지만, WebSocket은 연결이 * 오래 유지되는 세션이라 {@code accessor.setUser()}로 세션 자체에 Principal을 붙여 이후 프레임에서 - * 재사용한다. 이 검증은 신규 연결에만 적용되며 이미 열린 세션의 로그아웃·만료 반영은 별도 세션 수명 - * 주기에서 다룬다. + * 재사용한다. 연결 당시의 jti·만료 시각·role snapshot은 {@code StompSessionRegistry}에도 등록하고, + * 별도 재검증 작업이 열린 세션의 로그아웃·만료·역할 변경을 확인한다. * *

이때 Accessor는 반드시 {@link MessageHeaderAccessor#getAccessor}로 가져와야 한다. * {@code StompHeaderAccessor.wrap(message)}는 검증 전용 복사본이라 그 위에 {@code setUser()}를 @@ -61,6 +67,7 @@ public class StompAuthChannelInterceptor implements ChannelInterceptor { private final JwtProvider jwtProvider; private final TokenBlacklistService tokenBlacklistService; private final RoleAuthorityService roleAuthorityService; + private final StompSessionRegistry stompSessionRegistry; @Override public Message preSend(Message message, MessageChannel channel) { @@ -81,9 +88,16 @@ public Message preSend(Message message, MessageChannel channel) { throw invalidAuthentication(); } - // 4. 거부되지 않은 토큰에 대해서만 현재 권한을 조회하고 세션 Principal을 만든다. + // 4. 수명 검증에 필요한 식별자가 없으면 추적할 수 없는 연결이므로 거부한다. String email = claims.getSubject(); Long userId = claims.get("userId", Long.class); + Date expiration = claims.getExpiration(); + String sessionId = accessor.getSessionId(); + if (userId == null || expiration == null || !StringUtils.hasText(sessionId)) { + throw invalidAuthentication(); + } + + // 5. 거부되지 않은 토큰에 대해서만 현재 권한을 조회하고 세션 Principal을 만든다. List roles = roleAuthorityService.getRoles(userId); List authorities = roles.stream() .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) @@ -92,6 +106,17 @@ public Message preSend(Message message, MessageChannel channel) { UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(email, null, authorities); authentication.setDetails(userId); + + // 6. 물리 연결이 이미 추적 중일 때만 인증을 완료해 검사에서 빠지는 세션을 만들지 않는다. + StompSessionAuthorization authorization = new StompSessionAuthorization( + userId, + jti, + Instant.ofEpochMilli(expiration.getTime()), + Set.copyOf(roles) + ); + if (!stompSessionRegistry.authenticate(sessionId, authorization)) { + throw invalidAuthentication(); + } accessor.setUser(authentication); } diff --git a/backend/src/main/java/com/opensource/docgrid/domain/auth/jwt/TokenBlacklistService.java b/backend/src/main/java/com/opensource/docgrid/domain/auth/jwt/TokenBlacklistService.java index a47134fd..5a69fa16 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/auth/jwt/TokenBlacklistService.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/auth/jwt/TokenBlacklistService.java @@ -1,6 +1,10 @@ package com.opensource.docgrid.domain.auth.jwt; import java.time.Duration; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Set; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Component; @@ -22,4 +26,34 @@ public void blacklist(String jti, long ttlSeconds) { public boolean isBlacklisted(String jti) { return Boolean.TRUE.equals(redisTemplate.hasKey(KEY_PREFIX + jti)); } + + /** + * 여러 STOMP 세션의 token 폐기 상태를 Redis MGET 한 번으로 확인한다. + * + *

응답 누락을 정상 token으로 오인하면 기존 연결이 계속 살아남으므로, Redis가 요청 key와 같은 + * 개수의 결과를 주지 않으면 검증 실패로 처리한다. 호출자는 WebSocket fail-closed 정책에 따라 + * 검사 대상 세션을 종료한다. + */ + public Set findBlacklistedJtis(Collection jtis) { + List distinctJtis = jtis.stream().distinct().toList(); + if (distinctJtis.isEmpty()) { + return Set.of(); + } + + List keys = distinctJtis.stream() + .map(jti -> KEY_PREFIX + jti) + .toList(); + List values = redisTemplate.opsForValue().multiGet(keys); + if (values == null || values.size() != keys.size()) { + throw new IllegalStateException("Redis blacklist 일괄 조회 결과가 완전하지 않습니다."); + } + + Set blacklisted = new HashSet<>(); + for (int index = 0; index < distinctJtis.size(); index++) { + if (values.get(index) != null) { + blacklisted.add(distinctJtis.get(index)); + } + } + return Set.copyOf(blacklisted); + } } diff --git a/backend/src/main/java/com/opensource/docgrid/domain/auth/websocket/StompSessionRegistry.java b/backend/src/main/java/com/opensource/docgrid/domain/auth/websocket/StompSessionRegistry.java index 8c04ccc4..30d0357b 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/auth/websocket/StompSessionRegistry.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/auth/websocket/StompSessionRegistry.java @@ -83,7 +83,7 @@ public boolean close(String sessionId) { state.session().close(AUTHORIZATION_INVALID); sessions.remove(sessionId, state); return true; - } catch (IOException exception) { + } catch (IOException | RuntimeException exception) { // 추적 정보를 남겨 다음 검사에서 다시 닫을 수 있게 한다. 식별 정보는 로그에 노출하지 않는다. log.warn("유효하지 않은 STOMP WebSocket 세션 종료에 실패했습니다: {}", exception.getMessage()); return false; diff --git a/backend/src/main/java/com/opensource/docgrid/domain/auth/websocket/StompSessionRevalidationScheduler.java b/backend/src/main/java/com/opensource/docgrid/domain/auth/websocket/StompSessionRevalidationScheduler.java new file mode 100644 index 00000000..baecbac6 --- /dev/null +++ b/backend/src/main/java/com/opensource/docgrid/domain/auth/websocket/StompSessionRevalidationScheduler.java @@ -0,0 +1,206 @@ +package com.opensource.docgrid.domain.auth.websocket; + +import java.time.Clock; +import java.time.Instant; +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import com.opensource.docgrid.domain.auth.jwt.TokenBlacklistService; +import com.opensource.docgrid.domain.auth.websocket.StompSessionRegistry.SessionSnapshot; +import com.opensource.docgrid.domain.user.entity.UserRole; +import com.opensource.docgrid.domain.user.repository.UserRoleRepository; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.MeterRegistry; +import lombok.extern.slf4j.Slf4j; + +/** + * 열린 STOMP 세션의 token 만료·blacklist·현재 역할을 주기적으로 일괄 재검증한다. + * + *

각 Backend 인스턴스는 자신이 보유한 물리 세션만 검사한다. blacklist는 Redis MGET, 역할은 + * userId IN query로 batch 조회해 세션 수만큼 외부 호출이 늘어나는 것을 막는다. Redis 또는 DB 상태를 + * 확인할 수 없으면 WebSocket을 fail-closed로 종료하며 프런트는 기존 REST polling으로 전환한다. + */ +@Component +@Slf4j +public class StompSessionRevalidationScheduler { + + private static final String CLOSED_METRIC = "docgrid.stomp.sessions.closed"; + + private final StompSessionRegistry stompSessionRegistry; + private final TokenBlacklistService tokenBlacklistService; + private final UserRoleRepository userRoleRepository; + private final Clock clock; + private final int batchSize; + private final Map closeCounters; + + public StompSessionRevalidationScheduler( + StompSessionRegistry stompSessionRegistry, + TokenBlacklistService tokenBlacklistService, + UserRoleRepository userRoleRepository, + Clock clock, + MeterRegistry meterRegistry, + @Value("${auth.stomp.session-revalidation.batch-size:500}") int batchSize + ) { + if (batchSize <= 0) { + throw new IllegalArgumentException("STOMP 세션 재검증 batch-size는 1 이상이어야 합니다."); + } + this.stompSessionRegistry = stompSessionRegistry; + this.tokenBlacklistService = tokenBlacklistService; + this.userRoleRepository = userRoleRepository; + this.clock = clock; + this.batchSize = batchSize; + this.closeCounters = registerCloseCounters(meterRegistry); + + Gauge.builder( + "docgrid.stomp.sessions.active", + stompSessionRegistry, + StompSessionRegistry::authenticatedSessionCount + ).description("Authenticated STOMP WebSocket sessions owned by this backend instance") + .register(meterRegistry); + } + + @Scheduled( + fixedDelayString = "${auth.stomp.session-revalidation.interval:5s}", + initialDelayString = "${auth.stomp.session-revalidation.interval:5s}" + ) + public void revalidate() { + List sessions = stompSessionRegistry.authenticatedSessions(); + if (sessions.isEmpty()) { + return; + } + + // 1. JWT 만료는 외부 조회 없이 먼저 제거해 Redis·DB 검사 대상을 줄인다. + Instant now = clock.instant(); + List candidates = new ArrayList<>(); + for (SessionSnapshot session : sessions) { + if (session.authorization().isExpired(now)) { + close(session, CloseReason.EXPIRED); + } else { + candidates.add(session); + } + } + if (candidates.isEmpty()) { + return; + } + + // 2. Redis와 DB 중 하나라도 검증할 수 없으면 기존 연결도 신규 CONNECT와 같이 fail-closed한다. + try { + Set blacklistedJtis = findBlacklistedJtis(candidates); + Map> currentRoles = findCurrentRoles(candidates); + + // 3. token 폐기와 역할 snapshot 변경을 같은 검사 주기에서 확정한다. + for (SessionSnapshot session : candidates) { + StompSessionAuthorization authorization = session.authorization(); + if (blacklistedJtis.contains(authorization.jti())) { + close(session, CloseReason.BLACKLISTED); + continue; + } + Set roles = currentRoles.getOrDefault(authorization.userId(), Set.of()); + if (!authorization.roles().equals(roles)) { + close(session, CloseReason.ROLES_CHANGED); + } + } + } catch (RuntimeException exception) { + int closed = closeAll(candidates, CloseReason.VALIDATION_FAILED); + log.error( + "STOMP 세션 인증 상태를 확인할 수 없어 연결을 종료했습니다. closed={}: {}", + closed, + exception.getMessage() + ); + } + } + + private Set findBlacklistedJtis(List sessions) { + List jtis = sessions.stream() + .map(session -> session.authorization().jti()) + .distinct() + .toList(); + Set blacklisted = new HashSet<>(); + for (List batch : batches(jtis)) { + blacklisted.addAll(tokenBlacklistService.findBlacklistedJtis(batch)); + } + return blacklisted; + } + + private Map> findCurrentRoles(List sessions) { + List userIds = sessions.stream() + .map(session -> session.authorization().userId()) + .distinct() + .toList(); + Map> rolesByUserId = new HashMap<>(); + for (List batch : batches(userIds)) { + List userRoles = userRoleRepository.findAllWithRoleByUserIdIn(batch); + for (UserRole userRole : userRoles) { + rolesByUserId.computeIfAbsent(userRole.getUser().getId(), ignored -> new HashSet<>()) + .add(userRole.getRole().getCode()); + } + } + rolesByUserId.replaceAll((ignored, roles) -> Set.copyOf(roles)); + return rolesByUserId; + } + + private List> batches(List values) { + List> batches = new ArrayList<>(); + for (int start = 0; start < values.size(); start += batchSize) { + batches.add(values.subList(start, Math.min(start + batchSize, values.size()))); + } + return batches; + } + + private int closeAll(List sessions, CloseReason reason) { + int closed = 0; + for (SessionSnapshot session : sessions) { + if (close(session, reason)) { + closed++; + } + } + return closed; + } + + private boolean close(SessionSnapshot session, CloseReason reason) { + boolean closed = stompSessionRegistry.close(session.sessionId()); + if (closed) { + closeCounters.get(reason).increment(); + } + return closed; + } + + private Map registerCloseCounters(MeterRegistry meterRegistry) { + Map counters = new EnumMap<>(CloseReason.class); + for (CloseReason reason : CloseReason.values()) { + counters.put( + reason, + Counter.builder(CLOSED_METRIC) + .description("STOMP sessions closed after authorization revalidation") + .tag("reason", reason.label) + .register(meterRegistry) + ); + } + return counters; + } + + /** Metric tag를 고정된 네 값으로 제한하는 세션 종료 분류다. */ + private enum CloseReason { + EXPIRED("expired"), + BLACKLISTED("blacklisted"), + ROLES_CHANGED("roles_changed"), + VALIDATION_FAILED("validation_failed"); + + private final String label; + + CloseReason(String label) { + this.label = label; + } + } +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index f237c493..a57f65f5 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -139,6 +139,14 @@ jwt: secret: ${JWT_SECRET} expiration: ${JWT_EXPIRATION:3600} +auth: + stomp: + session-revalidation: + # 이미 열린 연결에도 logout·token 만료·역할 변경을 반영하는 최대 검사 간격이다. + interval: ${STOMP_SESSION_REVALIDATION_INTERVAL:5s} + # Redis MGET과 역할 IN query가 한 번에 처리할 고유 token·사용자 상한이다. + batch-size: ${STOMP_SESSION_REVALIDATION_BATCH_SIZE:500} + embedding: server: base-url: ${EMBEDDING_SERVER_URL:http://localhost:8000} diff --git a/backend/src/test/java/com/opensource/docgrid/domain/auth/jwt/StompAuthChannelInterceptorTest.java b/backend/src/test/java/com/opensource/docgrid/domain/auth/jwt/StompAuthChannelInterceptorTest.java index 597a23e4..86fa1f82 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/auth/jwt/StompAuthChannelInterceptorTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/auth/jwt/StompAuthChannelInterceptorTest.java @@ -2,11 +2,15 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.mock; import static org.mockito.BDDMockito.then; import java.security.Principal; +import java.time.Instant; +import java.util.Date; import java.util.List; import org.junit.jupiter.api.BeforeEach; @@ -26,8 +30,14 @@ import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; +import com.opensource.docgrid.domain.auth.websocket.StompSessionAuthorization; +import com.opensource.docgrid.domain.auth.websocket.StompSessionRegistry; + import io.jsonwebtoken.Claims; +/** + * STOMP CONNECT·STOMP 명령의 JWT, blacklist, 역할 조회와 세션 수명 snapshot 등록 계약을 검증한다. + */ @ExtendWith(MockitoExtension.class) @DisplayName("StompAuthChannelInterceptor 단위 테스트") class StompAuthChannelInterceptorTest { @@ -35,13 +45,19 @@ class StompAuthChannelInterceptorTest { @Mock private JwtProvider jwtProvider; @Mock private TokenBlacklistService tokenBlacklistService; @Mock private RoleAuthorityService roleAuthorityService; + @Mock private StompSessionRegistry stompSessionRegistry; @Mock private MessageChannel channel; private StompAuthChannelInterceptor interceptor; @BeforeEach void setUp() { - interceptor = new StompAuthChannelInterceptor(jwtProvider, tokenBlacklistService, roleAuthorityService); + interceptor = new StompAuthChannelInterceptor( + jwtProvider, + tokenBlacklistService, + roleAuthorityService, + stompSessionRegistry + ); } @ParameterizedTest @@ -53,9 +69,12 @@ void preSend_attachesPrincipal_whenTokenValid(StompCommand command) { given(claims.getSubject()).willReturn("admin@example.com"); given(claims.get("userId", Long.class)).willReturn(1L); given(claims.get("jti", String.class)).willReturn("valid-jti"); + given(claims.getExpiration()).willReturn(Date.from(Instant.parse("2026-09-28T01:00:00Z"))); given(jwtProvider.getClaimsIfValid("valid-token")).willReturn(claims); given(tokenBlacklistService.isBlacklisted("valid-jti")).willReturn(false); given(roleAuthorityService.getRoles(1L)).willReturn(List.of("ADMIN")); + given(stompSessionRegistry.authenticate(eq("stomp-session"), any(StompSessionAuthorization.class))) + .willReturn(true); Message connectMessage = connectMessage(command, "Bearer valid-token"); @@ -71,6 +90,8 @@ void preSend_attachesPrincipal_whenTokenValid(StompCommand command) { .extracting(GrantedAuthority::getAuthority) .containsExactly("ROLE_ADMIN"); then(tokenBlacklistService).should().isBlacklisted("valid-jti"); + then(stompSessionRegistry).should() + .authenticate(eq("stomp-session"), any(StompSessionAuthorization.class)); } @ParameterizedTest @@ -150,6 +171,46 @@ void preSend_throws_whenBlacklistCheckFails() { then(roleAuthorityService).shouldHaveNoInteractions(); } + @Test + @DisplayName("예외 케이스: 만료 시각이 없는 token은 세션 수명을 추적할 수 없어 거부한다") + void preSend_throws_whenExpirationMissing() { + // Given + Claims claims = mock(Claims.class); + given(claims.getSubject()).willReturn("missing-expiration@example.com"); + given(claims.get("userId", Long.class)).willReturn(1L); + given(claims.get("jti", String.class)).willReturn("missing-expiration-jti"); + given(jwtProvider.getClaimsIfValid("missing-expiration-token")).willReturn(claims); + given(tokenBlacklistService.isBlacklisted("missing-expiration-jti")).willReturn(false); + Message connectMessage = connectMessage("Bearer missing-expiration-token"); + + // When & Then + assertThatThrownBy(() -> interceptor.preSend(connectMessage, channel)) + .isInstanceOf(AccessDeniedException.class); + then(roleAuthorityService).shouldHaveNoInteractions(); + then(stompSessionRegistry).shouldHaveNoInteractions(); + } + + @Test + @DisplayName("예외 케이스: 물리 연결을 추적할 수 없으면 인증된 세션으로 등록하지 않는다") + void preSend_throws_whenTransportSessionMissing() { + // Given + Claims claims = mock(Claims.class); + given(claims.getSubject()).willReturn("missing-transport@example.com"); + given(claims.get("userId", Long.class)).willReturn(1L); + given(claims.get("jti", String.class)).willReturn("missing-transport-jti"); + given(claims.getExpiration()).willReturn(Date.from(Instant.parse("2026-09-28T01:00:00Z"))); + given(jwtProvider.getClaimsIfValid("missing-transport-token")).willReturn(claims); + given(tokenBlacklistService.isBlacklisted("missing-transport-jti")).willReturn(false); + given(roleAuthorityService.getRoles(1L)).willReturn(List.of("USER")); + given(stompSessionRegistry.authenticate(eq("stomp-session"), any(StompSessionAuthorization.class))) + .willReturn(false); + Message connectMessage = connectMessage("Bearer missing-transport-token"); + + // When & Then + assertThatThrownBy(() -> interceptor.preSend(connectMessage, channel)) + .isInstanceOf(AccessDeniedException.class); + } + @Test @DisplayName("CONNECT가 아닌 프레임은 검증 없이 통과시킨다") void preSend_skipsValidation_forNonConnectFrames() { @@ -174,6 +235,7 @@ private Message connectMessage(String authorizationHeader) { private Message connectMessage(StompCommand command, String authorizationHeader) { StompHeaderAccessor accessor = StompHeaderAccessor.create(command); + accessor.setSessionId("stomp-session"); if (authorizationHeader != null) { accessor.setNativeHeader("Authorization", authorizationHeader); } diff --git a/backend/src/test/java/com/opensource/docgrid/domain/auth/jwt/TokenBlacklistServiceTest.java b/backend/src/test/java/com/opensource/docgrid/domain/auth/jwt/TokenBlacklistServiceTest.java index 10b3ba45..c1e24ab2 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/auth/jwt/TokenBlacklistServiceTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/auth/jwt/TokenBlacklistServiceTest.java @@ -1,10 +1,14 @@ package com.opensource.docgrid.domain.auth.jwt; 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.BDDMockito.then; import java.time.Duration; +import java.util.Arrays; +import java.util.List; +import java.util.Set; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -53,4 +57,33 @@ void isBlacklisted_returnsFalse_whenKeyMissing() { assertThat(tokenBlacklistService.isBlacklisted("test-jti")).isFalse(); } + + @Test + @DisplayName("여러 jti의 blacklist 상태를 MGET 한 번으로 조회한다") + void findBlacklistedJtis_returnsOnlyExistingKeys() { + // Given + List keys = List.of("auth:blacklist:jti-1", "auth:blacklist:jti-2"); + given(redisTemplate.opsForValue()).willReturn(valueOperations); + given(valueOperations.multiGet(keys)).willReturn(Arrays.asList(null, "1")); + + // When + Set result = tokenBlacklistService.findBlacklistedJtis(List.of("jti-1", "jti-2", "jti-2")); + + // Then + assertThat(result).containsExactly("jti-2"); + then(valueOperations).should().multiGet(keys); + } + + @Test + @DisplayName("Redis MGET 응답이 불완전하면 정상 token으로 오인하지 않고 실패한다") + void findBlacklistedJtis_throws_whenResponseIncomplete() { + // Given + List keys = List.of("auth:blacklist:jti-1", "auth:blacklist:jti-2"); + given(redisTemplate.opsForValue()).willReturn(valueOperations); + given(valueOperations.multiGet(keys)).willReturn(List.of("1")); + + // When & Then + assertThatThrownBy(() -> tokenBlacklistService.findBlacklistedJtis(List.of("jti-1", "jti-2"))) + .isInstanceOf(IllegalStateException.class); + } } diff --git a/backend/src/test/java/com/opensource/docgrid/domain/auth/websocket/StompSessionRevalidationSchedulerTest.java b/backend/src/test/java/com/opensource/docgrid/domain/auth/websocket/StompSessionRevalidationSchedulerTest.java new file mode 100644 index 00000000..0d3841aa --- /dev/null +++ b/backend/src/test/java/com/opensource/docgrid/domain/auth/websocket/StompSessionRevalidationSchedulerTest.java @@ -0,0 +1,204 @@ +package com.opensource.docgrid.domain.auth.websocket; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.mock; +import static org.mockito.BDDMockito.then; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Set; + +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 com.opensource.docgrid.domain.auth.jwt.TokenBlacklistService; +import com.opensource.docgrid.domain.auth.websocket.StompSessionRegistry.SessionSnapshot; +import com.opensource.docgrid.domain.user.entity.Role; +import com.opensource.docgrid.domain.user.entity.User; +import com.opensource.docgrid.domain.user.entity.UserRole; +import com.opensource.docgrid.domain.user.repository.UserRoleRepository; + +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; + +/** + * 열린 STOMP 세션의 만료·blacklist·역할 변경과 batch fail-closed 판단을 단위 수준에서 검증한다. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("STOMP 세션 재검증 Scheduler 단위 테스트") +class StompSessionRevalidationSchedulerTest { + + private static final Instant NOW = Instant.parse("2026-09-28T00:00:00Z"); + + @Mock private StompSessionRegistry stompSessionRegistry; + @Mock private TokenBlacklistService tokenBlacklistService; + @Mock private UserRoleRepository userRoleRepository; + + private SimpleMeterRegistry meterRegistry; + private StompSessionRevalidationScheduler scheduler; + + @BeforeEach + void setUp() { + meterRegistry = new SimpleMeterRegistry(); + scheduler = new StompSessionRevalidationScheduler( + stompSessionRegistry, + tokenBlacklistService, + userRoleRepository, + Clock.fixed(NOW, ZoneOffset.UTC), + meterRegistry, + 2 + ); + } + + @Test + @DisplayName("만료된 세션은 외부 저장소를 조회하지 않고 종료한다") + void revalidate_closesExpiredSession_withoutExternalLookup() { + // Given + SessionSnapshot expired = session("expired", 1L, "jti-1", NOW, "USER"); + given(stompSessionRegistry.authenticatedSessions()).willReturn(List.of(expired)); + given(stompSessionRegistry.close("expired")).willReturn(true); + + // When + scheduler.revalidate(); + + // Then + then(stompSessionRegistry).should().close("expired"); + then(tokenBlacklistService).shouldHaveNoInteractions(); + then(userRoleRepository).shouldHaveNoInteractions(); + assertThat(closedCount("expired")).isEqualTo(1.0); + } + + @Test + @DisplayName("blacklist에 등록된 세션만 종료하고 현재 역할이 같은 세션은 유지한다") + void revalidate_closesBlacklistedSession_andKeepsValidSession() { + // Given + SessionSnapshot revoked = session("revoked", 1L, "jti-1", NOW.plusSeconds(60), "USER"); + SessionSnapshot valid = session("valid", 2L, "jti-2", NOW.plusSeconds(60), "USER"); + UserRole firstRole = userRole(1L, "USER"); + UserRole secondRole = userRole(2L, "USER"); + given(stompSessionRegistry.authenticatedSessions()).willReturn(List.of(revoked, valid)); + given(tokenBlacklistService.findBlacklistedJtis(List.of("jti-1", "jti-2"))) + .willReturn(Set.of("jti-1")); + given(userRoleRepository.findAllWithRoleByUserIdIn(List.of(1L, 2L))) + .willReturn(List.of(firstRole, secondRole)); + given(stompSessionRegistry.close("revoked")).willReturn(true); + + // When + scheduler.revalidate(); + + // Then + then(stompSessionRegistry).should().close("revoked"); + then(stompSessionRegistry).should(org.mockito.Mockito.never()).close("valid"); + assertThat(closedCount("blacklisted")).isEqualTo(1.0); + } + + @Test + @DisplayName("DB의 현재 역할이 연결 당시 snapshot과 다르면 세션을 종료한다") + void revalidate_closesSession_whenRolesChanged() { + // Given + SessionSnapshot session = session("admin", 1L, "jti-1", NOW.plusSeconds(60), "ADMIN", "USER"); + UserRole currentRole = userRole(1L, "USER"); + given(stompSessionRegistry.authenticatedSessions()).willReturn(List.of(session)); + given(tokenBlacklistService.findBlacklistedJtis(List.of("jti-1"))).willReturn(Set.of()); + given(userRoleRepository.findAllWithRoleByUserIdIn(List.of(1L))) + .willReturn(List.of(currentRole)); + given(stompSessionRegistry.close("admin")).willReturn(true); + + // When + scheduler.revalidate(); + + // Then + then(stompSessionRegistry).should().close("admin"); + assertThat(closedCount("roles_changed")).isEqualTo(1.0); + } + + @Test + @DisplayName("Redis 검증 실패 시 검사 대상 세션을 모두 fail-closed한다") + void revalidate_closesAllCandidates_whenBlacklistLookupFails() { + // Given + SessionSnapshot first = session("first", 1L, "jti-1", NOW.plusSeconds(60), "USER"); + SessionSnapshot second = session("second", 2L, "jti-2", NOW.plusSeconds(60), "USER"); + given(stompSessionRegistry.authenticatedSessions()).willReturn(List.of(first, second)); + given(tokenBlacklistService.findBlacklistedJtis(List.of("jti-1", "jti-2"))) + .willThrow(new RuntimeException("redis down")); + given(stompSessionRegistry.close("first")).willReturn(true); + given(stompSessionRegistry.close("second")).willReturn(true); + + // When + scheduler.revalidate(); + + // Then + then(stompSessionRegistry).should().close("first"); + then(stompSessionRegistry).should().close("second"); + then(userRoleRepository).shouldHaveNoInteractions(); + assertThat(closedCount("validation_failed")).isEqualTo(2.0); + } + + @Test + @DisplayName("고유 token과 사용자가 batch-size를 넘으면 Redis와 DB를 같은 크기로 나눠 조회한다") + void revalidate_chunksBlacklistAndRoleQueries() { + // Given + List sessions = List.of( + session("one", 1L, "jti-1", NOW.plusSeconds(60), "USER"), + session("two", 2L, "jti-2", NOW.plusSeconds(60), "USER"), + session("three", 3L, "jti-3", NOW.plusSeconds(60), "USER") + ); + UserRole firstRole = userRole(1L, "USER"); + UserRole secondRole = userRole(2L, "USER"); + UserRole thirdRole = userRole(3L, "USER"); + given(stompSessionRegistry.authenticatedSessions()).willReturn(sessions); + given(tokenBlacklistService.findBlacklistedJtis(List.of("jti-1", "jti-2"))).willReturn(Set.of()); + given(tokenBlacklistService.findBlacklistedJtis(List.of("jti-3"))).willReturn(Set.of()); + given(userRoleRepository.findAllWithRoleByUserIdIn(List.of(1L, 2L))) + .willReturn(List.of(firstRole, secondRole)); + given(userRoleRepository.findAllWithRoleByUserIdIn(List.of(3L))) + .willReturn(List.of(thirdRole)); + + // When + scheduler.revalidate(); + + // Then + then(tokenBlacklistService).should().findBlacklistedJtis(List.of("jti-1", "jti-2")); + then(tokenBlacklistService).should().findBlacklistedJtis(List.of("jti-3")); + then(userRoleRepository).should().findAllWithRoleByUserIdIn(List.of(1L, 2L)); + then(userRoleRepository).should().findAllWithRoleByUserIdIn(List.of(3L)); + then(stompSessionRegistry).should(org.mockito.Mockito.never()).close(org.mockito.ArgumentMatchers.anyString()); + } + + private SessionSnapshot session( + String sessionId, + Long userId, + String jti, + Instant expiresAt, + String... roles + ) { + return new SessionSnapshot( + sessionId, + new StompSessionAuthorization(userId, jti, expiresAt, Set.of(roles)) + ); + } + + private UserRole userRole(Long userId, String roleCode) { + User user = mock(User.class); + Role role = mock(Role.class); + UserRole userRole = mock(UserRole.class); + given(user.getId()).willReturn(userId); + given(role.getCode()).willReturn(roleCode); + given(userRole.getUser()).willReturn(user); + given(userRole.getRole()).willReturn(role); + return userRole; + } + + private double closedCount(String reason) { + return meterRegistry.find("docgrid.stomp.sessions.closed") + .tag("reason", reason) + .counter() + .count(); + } +} From b486f47dfea3bb15b19e6a5629c3ef1515453001 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=EB=AF=BC?= Date: Mon, 28 Sep 2026 03:35:48 +0900 Subject: [PATCH 3/8] =?UTF-8?q?test:=20#360=20STOMP=20=EC=84=B8=EC=85=98?= =?UTF-8?q?=20=ED=8F=90=EA=B8=B0=EB=A5=BC=20=EC=8B=A4=EC=A0=9C=20WebSocket?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../StompSessionLifecycleIntegrationTest.java | 260 ++++++++++++++++++ 1 file changed, 260 insertions(+) create mode 100644 backend/src/test/java/com/opensource/docgrid/domain/auth/integration/StompSessionLifecycleIntegrationTest.java diff --git a/backend/src/test/java/com/opensource/docgrid/domain/auth/integration/StompSessionLifecycleIntegrationTest.java b/backend/src/test/java/com/opensource/docgrid/domain/auth/integration/StompSessionLifecycleIntegrationTest.java new file mode 100644 index 00000000..f9217894 --- /dev/null +++ b/backend/src/test/java/com/opensource/docgrid/domain/auth/integration/StompSessionLifecycleIntegrationTest.java @@ -0,0 +1,260 @@ +package com.opensource.docgrid.domain.auth.integration; + +import static org.awaitility.Awaitility.await; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.mock; + +import java.lang.reflect.Type; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.messaging.converter.MappingJackson2MessageConverter; +import org.springframework.messaging.simp.stomp.StompFrameHandler; +import org.springframework.messaging.simp.stomp.StompHeaders; +import org.springframework.messaging.simp.stomp.StompSession; +import org.springframework.messaging.simp.stomp.StompSessionHandlerAdapter; +import org.springframework.messaging.simp.user.SimpUser; +import org.springframework.messaging.simp.user.SimpUserRegistry; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.web.socket.WebSocketHttpHeaders; +import org.springframework.web.socket.client.standard.StandardWebSocketClient; +import org.springframework.web.socket.messaging.WebSocketStompClient; + +import com.opensource.docgrid.domain.auth.jwt.JwtProvider; +import com.opensource.docgrid.domain.auth.jwt.RoleAuthorityService; +import com.opensource.docgrid.domain.auth.jwt.TokenBlacklistService; +import com.opensource.docgrid.domain.rag.controller.RagWebSocketController; +import com.opensource.docgrid.domain.user.entity.Role; +import com.opensource.docgrid.domain.user.entity.User; +import com.opensource.docgrid.domain.user.entity.UserRole; +import com.opensource.docgrid.domain.user.repository.UserRoleRepository; + +import com.fasterxml.jackson.databind.JsonNode; + +import io.jsonwebtoken.Claims; + +/** + * 실제 WebSocket 연결이 CONNECT 이후의 token 폐기 상태까지 반영해 broker 구독에서 제거되는지 검증한다. + */ +@Tag("integration") +@ActiveProfiles("test") +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@DisplayName("STOMP 세션 수명주기 통합 테스트") +class StompSessionLifecycleIntegrationTest { + + private static final Long USER_ID = 91L; + private static final String USER_EMAIL = "stomp-lifecycle-user@example.com"; + private static final String RAG_ANSWER_QUEUE = "/user/queue/rag-answer"; + private static final long TIMEOUT_SECONDS = 3; + private static final String JWT_SECRET = "docgrid-stomp-session-lifecycle-integration-test-secret-2026"; + + @LocalServerPort + private int port; + + @Autowired + private JwtProvider jwtProvider; + + @Autowired + private TokenBlacklistService tokenBlacklistService; + + @Autowired + private SimpUserRegistry simpUserRegistry; + + @Autowired + private RagWebSocketController ragWebSocketController; + + @MockitoBean + private RoleAuthorityService roleAuthorityService; + + @MockitoBean + private UserRoleRepository userRoleRepository; + + private final AtomicReference> currentRoles = new AtomicReference<>(); + private WebSocketStompClient stompClient; + + @DynamicPropertySource + static void configureProperties(DynamicPropertyRegistry registry) { + registry.add("jwt.secret", () -> JWT_SECRET); + registry.add("auth.stomp.session-revalidation.interval", () -> "100ms"); + } + + @BeforeEach + void setUp() { + stompClient = new WebSocketStompClient(new StandardWebSocketClient()); + stompClient.setMessageConverter(new MappingJackson2MessageConverter()); + currentRoles.set(List.of("USER")); + given(roleAuthorityService.getRoles(USER_ID)).willAnswer(ignored -> currentRoles.get()); + given(userRoleRepository.findAllWithRoleByUserIdIn(anyList())).willAnswer(ignored -> currentRoles.get().stream() + .map(roleCode -> userRole(USER_ID, roleCode)) + .toList()); + } + + @AfterEach + void tearDown() { + stompClient.stop(); + } + + @Test + @DisplayName("연결 뒤 token을 blacklist에 등록하면 기존 구독 세션도 종료한다") + void closesExistingSession_whenConnectedTokenBecomesBlacklisted() throws Exception { + // Given + String token = jwtProvider.generateToken(USER_ID, USER_EMAIL); + Claims claims = jwtProvider.getClaimsIfValid(token); + StompSession session = connect(token); + BlockingQueue events = subscribeRag(session); + awaitSubscription(); + + try { + // When + tokenBlacklistService.blacklist(claims.get("jti", String.class), 60L); + + // Then + await().atMost(Duration.ofSeconds(TIMEOUT_SECONDS)) + .until(() -> simpUserRegistry.getUser(USER_EMAIL) == null); + ragWebSocketController.notifyAnswerReady(USER_EMAIL, 42L); + assertThat(events.poll(300, TimeUnit.MILLISECONDS)).isNull(); + } finally { + if (session.isConnected()) { + session.disconnect(); + } + } + } + + @Test + @DisplayName("연결 뒤 JWT가 만료되면 기존 구독 세션을 종료한다") + void closesExistingSession_whenTokenExpires() throws Exception { + // Given + JwtProvider shortLivedJwtProvider = new JwtProvider(JWT_SECRET, 1L); + StompSession session = connect(shortLivedJwtProvider.generateToken(USER_ID, USER_EMAIL)); + subscribeRag(session); + awaitSubscription(); + + try { + // When & Then + await().atMost(Duration.ofSeconds(TIMEOUT_SECONDS)) + .until(() -> simpUserRegistry.getUser(USER_EMAIL) == null); + } finally { + if (session.isConnected()) { + session.disconnect(); + } + } + } + + @Test + @DisplayName("연결 뒤 역할이 변경되면 오래된 Principal을 가진 기존 세션을 종료한다") + void closesExistingSession_whenRolesChange() throws Exception { + // Given + currentRoles.set(List.of("USER", "ADMIN")); + StompSession session = connect(jwtProvider.generateToken(USER_ID, USER_EMAIL)); + subscribeRag(session); + awaitSubscription(); + + try { + // When + currentRoles.set(List.of("USER")); + + // Then + await().atMost(Duration.ofSeconds(TIMEOUT_SECONDS)) + .until(() -> simpUserRegistry.getUser(USER_EMAIL) == null); + } finally { + if (session.isConnected()) { + session.disconnect(); + } + } + } + + @Test + @DisplayName("인증 상태가 그대로인 세션은 여러 검사 주기 뒤에도 push를 받는다") + void keepsSessionAndDeliversPush_whenAuthorizationRemainsValid() throws Exception { + // Given + StompSession session = connect(jwtProvider.generateToken(USER_ID, USER_EMAIL)); + BlockingQueue events = subscribeRag(session); + awaitSubscription(); + + try { + // When — 100ms 검사 주기를 여러 번 지난 뒤 서버 push를 보낸다. + await().pollDelay(Duration.ofMillis(500)).until(() -> true); + ragWebSocketController.notifyAnswerReady(USER_EMAIL, 77L); + + // Then + JsonNode event = events.poll(TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertThat(event).isNotNull(); + assertThat(event.path("queryId").asLong()).isEqualTo(77L); + assertThat(simpUserRegistry.getUser(USER_EMAIL)).isNotNull(); + } finally { + if (session.isConnected()) { + session.disconnect(); + } + } + } + + private StompSession connect(String token) throws Exception { + StompHeaders connectHeaders = new StompHeaders(); + connectHeaders.add("Authorization", "Bearer " + token); + return stompClient + .connectAsync( + webSocketUrl(), + (WebSocketHttpHeaders) null, + connectHeaders, + new StompSessionHandlerAdapter() { } + ) + .get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + } + + private BlockingQueue subscribeRag(StompSession session) { + BlockingQueue events = new LinkedBlockingQueue<>(); + session.subscribe(RAG_ANSWER_QUEUE, new StompFrameHandler() { + @Override + public Type getPayloadType(StompHeaders headers) { + return JsonNode.class; + } + + @Override + public void handleFrame(StompHeaders headers, Object payload) { + events.add((JsonNode) payload); + } + }); + return events; + } + + private void awaitSubscription() { + await().atMost(Duration.ofSeconds(TIMEOUT_SECONDS)).until(() -> { + SimpUser user = simpUserRegistry.getUser(USER_EMAIL); + return user != null && user.getSessions().stream() + .flatMap(session -> session.getSubscriptions().stream()) + .anyMatch(subscription -> RAG_ANSWER_QUEUE.equals(subscription.getDestination())); + }); + } + + private String webSocketUrl() { + return "ws://localhost:" + port + "/ws/websocket"; + } + + private UserRole userRole(Long userId, String roleCode) { + User user = mock(User.class); + Role role = mock(Role.class); + UserRole userRole = mock(UserRole.class); + given(user.getId()).willReturn(userId); + given(role.getCode()).willReturn(roleCode); + given(userRole.getUser()).willReturn(user); + given(userRole.getRole()).willReturn(role); + return userRole; + } +} From fb32ef788e139e2ca28b89f0a01f06bc427679ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=EB=AF=BC?= Date: Mon, 28 Sep 2026 03:37:38 +0900 Subject: [PATCH 4/8] =?UTF-8?q?perf:=20#360=20=ED=8F=90=EA=B8=B0=20?= =?UTF-8?q?=EC=84=B8=EC=85=98=EC=9D=98=20=EC=97=AD=ED=95=A0=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=A0=9C=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/StompSessionSchedulingConfig.java | 15 ++++++++++++++ .../StompSessionRevalidationScheduler.java | 20 +++++++++++++------ ...StompSessionRevalidationSchedulerTest.java | 6 +++--- 3 files changed, 32 insertions(+), 9 deletions(-) create mode 100644 backend/src/main/java/com/opensource/docgrid/domain/auth/config/StompSessionSchedulingConfig.java diff --git a/backend/src/main/java/com/opensource/docgrid/domain/auth/config/StompSessionSchedulingConfig.java b/backend/src/main/java/com/opensource/docgrid/domain/auth/config/StompSessionSchedulingConfig.java new file mode 100644 index 00000000..a6baac4e --- /dev/null +++ b/backend/src/main/java/com/opensource/docgrid/domain/auth/config/StompSessionSchedulingConfig.java @@ -0,0 +1,15 @@ +package com.opensource.docgrid.domain.auth.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +/** + * 열린 STOMP 세션 재검증 스케줄러를 다른 도메인의 Worker·Dashboard 설정과 독립적으로 활성화한다. + * + *

{@code @EnableScheduling}을 여러 설정에서 선언해도 Spring은 하나의 scheduling infrastructure로 + * 처리한다. 인증 수명 검증이 다른 기능의 활성화 여부에 따라 조용히 멈추지 않도록 별도 경계를 둔다. + */ +@Configuration +@EnableScheduling +public class StompSessionSchedulingConfig { +} diff --git a/backend/src/main/java/com/opensource/docgrid/domain/auth/websocket/StompSessionRevalidationScheduler.java b/backend/src/main/java/com/opensource/docgrid/domain/auth/websocket/StompSessionRevalidationScheduler.java index baecbac6..0a571faa 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/auth/websocket/StompSessionRevalidationScheduler.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/auth/websocket/StompSessionRevalidationScheduler.java @@ -97,15 +97,23 @@ public void revalidate() { // 2. Redis와 DB 중 하나라도 검증할 수 없으면 기존 연결도 신규 CONNECT와 같이 fail-closed한다. try { Set blacklistedJtis = findBlacklistedJtis(candidates); - Map> currentRoles = findCurrentRoles(candidates); - - // 3. token 폐기와 역할 snapshot 변경을 같은 검사 주기에서 확정한다. + List roleCandidates = new ArrayList<>(); for (SessionSnapshot session : candidates) { - StompSessionAuthorization authorization = session.authorization(); - if (blacklistedJtis.contains(authorization.jti())) { + if (blacklistedJtis.contains(session.authorization().jti())) { close(session, CloseReason.BLACKLISTED); - continue; + } else { + roleCandidates.add(session); } + } + if (roleCandidates.isEmpty()) { + return; + } + + Map> currentRoles = findCurrentRoles(roleCandidates); + + // 3. 폐기되지 않은 token만 DB의 현재 역할과 연결 당시 snapshot을 비교한다. + for (SessionSnapshot session : roleCandidates) { + StompSessionAuthorization authorization = session.authorization(); Set roles = currentRoles.getOrDefault(authorization.userId(), Set.of()); if (!authorization.roles().equals(roles)) { close(session, CloseReason.ROLES_CHANGED); diff --git a/backend/src/test/java/com/opensource/docgrid/domain/auth/websocket/StompSessionRevalidationSchedulerTest.java b/backend/src/test/java/com/opensource/docgrid/domain/auth/websocket/StompSessionRevalidationSchedulerTest.java index 0d3841aa..86451618 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/auth/websocket/StompSessionRevalidationSchedulerTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/auth/websocket/StompSessionRevalidationSchedulerTest.java @@ -80,13 +80,12 @@ void revalidate_closesBlacklistedSession_andKeepsValidSession() { // Given SessionSnapshot revoked = session("revoked", 1L, "jti-1", NOW.plusSeconds(60), "USER"); SessionSnapshot valid = session("valid", 2L, "jti-2", NOW.plusSeconds(60), "USER"); - UserRole firstRole = userRole(1L, "USER"); UserRole secondRole = userRole(2L, "USER"); given(stompSessionRegistry.authenticatedSessions()).willReturn(List.of(revoked, valid)); given(tokenBlacklistService.findBlacklistedJtis(List.of("jti-1", "jti-2"))) .willReturn(Set.of("jti-1")); - given(userRoleRepository.findAllWithRoleByUserIdIn(List.of(1L, 2L))) - .willReturn(List.of(firstRole, secondRole)); + given(userRoleRepository.findAllWithRoleByUserIdIn(List.of(2L))) + .willReturn(List.of(secondRole)); given(stompSessionRegistry.close("revoked")).willReturn(true); // When @@ -95,6 +94,7 @@ void revalidate_closesBlacklistedSession_andKeepsValidSession() { // Then then(stompSessionRegistry).should().close("revoked"); then(stompSessionRegistry).should(org.mockito.Mockito.never()).close("valid"); + then(userRoleRepository).should().findAllWithRoleByUserIdIn(List.of(2L)); assertThat(closedCount("blacklisted")).isEqualTo(1.0); } From 23f1be5c40a9de73f3e774637f4cd9fdc7583862 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=EB=AF=BC?= Date: Mon, 28 Sep 2026 03:42:19 +0900 Subject: [PATCH 5/8] =?UTF-8?q?test:=20#360=20WebSocket=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20DB=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20=EC=83=81=ED=95=9C=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/integration/StompSessionLifecycleIntegrationTest.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/backend/src/test/java/com/opensource/docgrid/domain/auth/integration/StompSessionLifecycleIntegrationTest.java b/backend/src/test/java/com/opensource/docgrid/domain/auth/integration/StompSessionLifecycleIntegrationTest.java index f9217894..3365900f 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/auth/integration/StompSessionLifecycleIntegrationTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/auth/integration/StompSessionLifecycleIntegrationTest.java @@ -93,6 +93,9 @@ class StompSessionLifecycleIntegrationTest { static void configureProperties(DynamicPropertyRegistry registry) { registry.add("jwt.secret", () -> JWT_SECRET); registry.add("auth.stomp.session-revalidation.interval", () -> "100ms"); + // 전체 테스트 실행에서 Spring context별 idle connection 누적이 PostgreSQL 한도를 잠식하지 않게 제한한다. + registry.add("spring.datasource.hikari.maximum-pool-size", () -> "2"); + registry.add("spring.datasource.hikari.minimum-idle", () -> "0"); } @BeforeEach From 3d5ede19a85a4e696f574ea2028d3fe34b80e758 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=EB=AF=BC?= Date: Mon, 28 Sep 2026 03:46:04 +0900 Subject: [PATCH 6/8] =?UTF-8?q?test:=20#360=20=EC=9E=AC=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=20=EC=8B=A4=ED=8C=A8=EC=99=80=20=ED=99=9C=EC=84=B1=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=20=EB=A9=94=ED=8A=B8=EB=A6=AD=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...StompSessionRevalidationSchedulerTest.java | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/backend/src/test/java/com/opensource/docgrid/domain/auth/websocket/StompSessionRevalidationSchedulerTest.java b/backend/src/test/java/com/opensource/docgrid/domain/auth/websocket/StompSessionRevalidationSchedulerTest.java index 86451618..4312775e 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/auth/websocket/StompSessionRevalidationSchedulerTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/auth/websocket/StompSessionRevalidationSchedulerTest.java @@ -140,6 +140,38 @@ void revalidate_closesAllCandidates_whenBlacklistLookupFails() { assertThat(closedCount("validation_failed")).isEqualTo(2.0); } + @Test + @DisplayName("DB 역할 검증 실패 시 검사 대상 세션을 fail-closed한다") + void revalidate_closesAllCandidates_whenRoleLookupFails() { + // Given + SessionSnapshot session = session("first", 1L, "jti-1", NOW.plusSeconds(60), "USER"); + given(stompSessionRegistry.authenticatedSessions()).willReturn(List.of(session)); + given(tokenBlacklistService.findBlacklistedJtis(List.of("jti-1"))).willReturn(Set.of()); + given(userRoleRepository.findAllWithRoleByUserIdIn(List.of(1L))) + .willThrow(new RuntimeException("database down")); + given(stompSessionRegistry.close("first")).willReturn(true); + + // When + scheduler.revalidate(); + + // Then + then(stompSessionRegistry).should().close("first"); + assertThat(closedCount("validation_failed")).isEqualTo(1.0); + } + + @Test + @DisplayName("활성 세션 Gauge는 registry의 현재 인증 세션 수를 읽는다") + void activeSessionGauge_readsCurrentRegistryCount() { + // Given + given(stompSessionRegistry.authenticatedSessionCount()).willReturn(3); + + // When + double activeSessions = meterRegistry.get("docgrid.stomp.sessions.active").gauge().value(); + + // Then + assertThat(activeSessions).isEqualTo(3.0); + } + @Test @DisplayName("고유 token과 사용자가 batch-size를 넘으면 Redis와 DB를 같은 크기로 나눠 조회한다") void revalidate_chunksBlacklistAndRoleQueries() { From d7bb55f5d250ce65a5be0b03591900a0fc626aa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=EB=AF=BC?= Date: Mon, 28 Sep 2026 03:48:00 +0900 Subject: [PATCH 7/8] =?UTF-8?q?docs:=20#360=20=EB=B8=94=EB=9E=99=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=A1=B0=ED=9A=8C=20=EC=B1=85=EC=9E=84=20?= =?UTF-8?q?=EB=AA=85=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../docgrid/domain/auth/jwt/TokenBlacklistService.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/backend/src/main/java/com/opensource/docgrid/domain/auth/jwt/TokenBlacklistService.java b/backend/src/main/java/com/opensource/docgrid/domain/auth/jwt/TokenBlacklistService.java index 5a69fa16..82ef0d58 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/auth/jwt/TokenBlacklistService.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/auth/jwt/TokenBlacklistService.java @@ -11,6 +11,13 @@ import lombok.RequiredArgsConstructor; +/** + * 로그아웃한 access token의 jti를 Redis에 보관하고 단건·일괄 폐기 여부 조회를 제공한다. + * + *

HTTP 인증은 단건 조회를 사용하고, 열린 STOMP 세션 재검증은 네트워크 왕복이 세션 수만큼 + * 늘지 않도록 MGET 기반 일괄 조회를 사용한다. Redis 장애에 대한 fail-open·fail-closed 결정은 각 + * 호출 경로가 자신의 가용성 요구에 맞게 담당한다. + */ @Component @RequiredArgsConstructor public class TokenBlacklistService { From ebc785ac48c3020068f51b9eb17c3a8488103c0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=EB=AF=BC?= Date: Mon, 28 Sep 2026 03:48:00 +0900 Subject: [PATCH 8/8] =?UTF-8?q?docs:=20#360=20STOMP=20=EC=84=B8=EC=85=98?= =?UTF-8?q?=20=EC=9E=AC=EA=B2=80=EC=A6=9D=20=EC=84=A4=EA=B3=84=EC=99=80=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...gimin-#356-stomp-connect-authentication.md | 2 +- ...in-#358-stomp-destination-authorization.md | 3 +- .../gimin-#360-stomp-session-lifecycle.md | 175 ++++++++++++++++++ ...58-stomp-destination-authorization-test.md | 4 +- ...gimin-#360-stomp-session-lifecycle-test.md | 153 +++++++++++++++ 5 files changed, 333 insertions(+), 4 deletions(-) create mode 100644 docs/design/gimin-#360-stomp-session-lifecycle.md create mode 100644 docs/test-results/gimin-#360-stomp-session-lifecycle-test.md diff --git a/docs/design/gimin-#356-stomp-connect-authentication.md b/docs/design/gimin-#356-stomp-connect-authentication.md index a40cf4f1..848d3c8e 100644 --- a/docs/design/gimin-#356-stomp-connect-authentication.md +++ b/docs/design/gimin-#356-stomp-connect-authentication.md @@ -171,7 +171,7 @@ Principal 타입까지 확인한다. ## 7. 남아 있는 범위 -- 로그아웃·token 만료 후 기존 session을 종료하는 session lifecycle 정책 +- 로그아웃·token 만료 후 기존 session을 종료하는 session lifecycle 정책(후속 #360에서 해결) - dashboard destination의 pattern·wildcard subscription 권한 규칙(후속 #358에서 해결) - 로그아웃 blacklist 기록 자체가 실패했을 때의 전달 보장 - Redis 재시작으로 blacklist가 유실되지 않도록 하는 persistence·운영 정책 diff --git a/docs/design/gimin-#358-stomp-destination-authorization.md b/docs/design/gimin-#358-stomp-destination-authorization.md index cb3a9c0a..b825c13a 100644 --- a/docs/design/gimin-#358-stomp-destination-authorization.md +++ b/docs/design/gimin-#358-stomp-destination-authorization.md @@ -160,4 +160,5 @@ client message 전체 거부가 서버 push를 막지 않는다. command 문자 `onclose` 뒤 REST polling으로 전환하므로 기능은 유지된다. - 새 destination은 허용 목록을 명시적으로 확장하기 전까지 거부된다. 이는 누락된 인가 정책으로 새 채널이 열리는 것을 막기 위한 의도된 기본값이다. -- 연결 뒤 역할 변경, token 만료, 로그아웃을 기존 session에 즉시 반영하는 lifecycle 문제는 남는다. +- 연결 뒤 역할 변경, token 만료, 로그아웃을 기존 session에 반영하는 lifecycle 문제는 후속 #360에서 + 주기 재검증 방식으로 해결했다. diff --git a/docs/design/gimin-#360-stomp-session-lifecycle.md b/docs/design/gimin-#360-stomp-session-lifecycle.md new file mode 100644 index 00000000..9503685d --- /dev/null +++ b/docs/design/gimin-#360-stomp-session-lifecycle.md @@ -0,0 +1,175 @@ +# 열린 STOMP 세션 인증 상태 재검증 설계 (#360) + +closes #360 + +## 1. 배경 + +DocGrid는 STOMP CONNECT에서 JWT의 서명·만료와 Redis blacklist를 확인하고, DB의 현재 역할로 +`Authentication` Principal을 만든다. 이후 SUBSCRIBE 인가는 이 Principal을 재사용한다. + +이 방식은 연결 순간의 인증은 보호하지만, WebSocket 연결이 오래 유지되는 동안 바뀐 상태를 Principal에 +반영하지 못한다. HTTP 요청은 매번 인증 필터를 다시 지나지만, 열린 STOMP 세션은 새 요청 없이 같은 +Principal을 계속 사용하기 때문이다. + +## 2. 문제 상황 + +기존 동작에서는 다음 세 경우 모두 연결 당시 권한이 남았다. + +```text +유효한 JWT로 CONNECT +→ 서버가 Principal을 session에 저장 +→ 로그아웃 / JWT 만료 / 관리자 역할 회수 +→ 기존 session은 계속 구독 상태 유지 +→ 연결 당시 권한으로 push 수신 +``` + +- 로그아웃은 jti를 Redis blacklist에 넣지만 이미 열린 세션은 다시 조회하지 않았다. +- JWT 만료는 CONNECT 때만 검사해 만료 시각이 지난 뒤에도 물리 연결이 유지됐다. +- 역할 변경은 DB와 Redis 역할 캐시를 갱신하지만 세션 Principal의 authority는 바뀌지 않았다. + +프런트가 새로 연결할 때는 최신 상태가 적용되지만, 사용자가 페이지를 닫거나 네트워크가 끊길 때까지 기존 +연결이 살아 있을 수 있어 인증 경계가 연결 수명 전체에 적용되지 않았다. + +## 3. 목표와 비목표 + +### 목표 + +- 열린 STOMP 세션에 JWT 만료, blacklist 등록, 현재 역할 변경을 반영한다. +- 세션 수만큼 Redis·DB를 호출하지 않고 일괄 조회한다. +- Redis 또는 DB에서 상태를 확인할 수 없으면 열린 연결도 fail-closed로 종료한다. +- 여러 Backend 인스턴스에서 별도 분산 lock 없이 각 인스턴스가 소유한 연결을 안전하게 검사한다. +- 활성 세션과 종료 이유를 Metric으로 관찰할 수 있게 한다. + +### 비목표 + +- blacklist 기록 자체의 전달 보장과 Redis persistence 정책은 변경하지 않는다. +- STOMP frame rate limit, message 크기 제한, CONNECT를 보내지 않은 물리 연결의 timeout은 다루지 않는다. +- Redis Pub/Sub을 추가해 상태 변경 순간에 즉시 연결을 끊지 않는다. +- HTTP 인증의 Redis 장애 정책은 변경하지 않는다. + +## 4. 핵심 설계 + +### 4.1 물리 WebSocket 연결을 먼저 추적한다 + +강제로 연결을 닫으려면 STOMP의 논리 session 정보만으로는 부족하고 실제 `WebSocketSession`이 필요하다. +`StompSessionTrackingDecoratorFactory`를 transport에 등록해 물리 연결이 성립하는 순간 sessionId와 +`WebSocketSession`을 `StompSessionRegistry`에 넣는다. + +```text +1. WebSocket transport 연결 성립 + → registry에 물리 session 등록 +2. STOMP CONNECT 인증 성공 + → 같은 sessionId에 인증 snapshot 결합 +3. 네트워크 종료 또는 서버 강제 종료 + → registry entry 제거 +``` + +CONNECT 인증은 물리 session이 registry에 있을 때만 성공시킨다. 따라서 추적에서 빠진 인증 세션이 +생기지 않는다. transport 설정 실패와 종료는 `finally` 경계에서 entry를 정리한다. + +### 4.2 JWT 원문 대신 최소 인증 snapshot을 저장한다 + +연결마다 다음 값만 불변 snapshot으로 보관한다. + +| 값 | 용도 | +|---|---| +| `userId` | 현재 역할 일괄 조회 | +| `jti` | Redis blacklist 일괄 조회 | +| `expiresAt` | 외부 조회 없는 만료 판정 | +| `roles` | 연결 당시 역할과 현재 역할 비교 | + +JWT 원문과 이메일은 registry에 보관하지 않는다. 역할은 순서와 중복에 영향을 받지 않도록 `Set`으로 +정규화한다. + +### 4.3 고정 지연 스케줄러가 세 단계로 재검증한다 + +기본 5초 간격의 `StompSessionRevalidationScheduler`가 인증 완료 세션의 snapshot을 읽는다. + +```text +1. expiresAt <= now + → Redis·DB 조회 없이 1008 Policy Violation으로 종료 + +2. 남은 jti를 Redis MGET으로 조회 + → blacklist에 있는 세션 종료 + +3. 남은 userId의 현재 역할을 DB IN query로 조회 + → snapshot과 다르면 세션 종료 +``` + +blacklist로 이미 폐기된 세션은 역할 조회 대상에서 제외한다. 같은 jti와 userId는 중복 제거하고 기본 +500개씩 나눠 조회한다. 세션이 `N`, 고유 사용자가 `U`, 고유 jti가 `J`라면 한 검사 주기의 외부 호출은 +최대 `ceil(J / 500)`번의 Redis MGET과 `ceil(U / 500)`번의 DB IN query다. + +역할은 `RoleAuthorityService`의 짧은 Redis 캐시를 거치지 않고 DB에서 직접 읽는다. 재검증의 목적이 +연결 당시 값과 source of truth를 비교하는 것이므로 오래된 cache snapshot을 다시 비교하지 않는다. + +### 4.4 검증 불가능 상태는 fail-closed로 처리한다 + +Redis MGET 결과가 요청 key 수와 다르거나 Redis·DB 조회가 실패하면 검사 대상 연결을 모두 종료한다. +WebSocket push는 프런트의 기존 REST polling fallback으로 기능을 이어갈 수 있고, 검증하지 못한 연결을 +장시간 유지하는 것보다 권한 경계를 닫는 편이 안전하다. + +한 세션의 `close()`가 실패하면 registry에서 제거하지 않는다. 다음 검사에서 다시 시도할 수 있게 하기 +위해서다. 이미 물리적으로 닫힌 세션은 snapshot을 만들 때 정리한다. + +### 4.5 인스턴스별 registry로 수평 확장을 유지한다 + +물리 WebSocket 연결은 연결을 받은 Backend 인스턴스만 닫을 수 있다. 각 인스턴스는 로컬 registry를 +검사하되 모든 인스턴스가 공유하는 Redis blacklist와 DB 역할을 읽는다. 그래서 분산 session registry, +leader election, scheduler lock이 필요하지 않다. + +Redis Pub/Sub은 이벤트 유실과 재구독 시점의 상태 복구를 별도로 해결해야 한다. 주기 검사는 상태 자체를 +다시 읽으므로 일시적인 이벤트 유실 개념이 없고, 권한 반영 지연의 상한은 검사 간격과 조회 시간이다. + +## 5. 설정과 관측성 + +| 설정 | 기본값 | 의미 | +|---|---:|---| +| `STOMP_SESSION_REVALIDATION_INTERVAL` | `5s` | 한 검사 종료 뒤 다음 검사까지의 지연 | +| `STOMP_SESSION_REVALIDATION_BATCH_SIZE` | `500` | Redis MGET·DB IN query 한 번의 고유 key 상한 | + +| Metric | tag | 의미 | +|---|---|---| +| `docgrid.stomp.sessions.active` | 없음 | 현재 인스턴스의 열린 인증 세션 수 | +| `docgrid.stomp.sessions.closed` | `reason` | 재검증으로 종료한 누적 세션 수 | + +`reason`은 `expired`, `blacklisted`, `roles_changed`, `validation_failed` 네 값으로 고정해 Metric label의 +cardinality가 사용자 수나 token 수에 따라 늘지 않게 한다. + +## 6. 경합과 실패 경계 + +- 검사 snapshot을 만든 뒤 client가 먼저 연결을 닫으면 `close()`는 열린 상태를 다시 확인하고 아무 작업도 + 하지 않는다. +- 검사와 역할 변경 transaction이 겹치면 DB에 커밋된 상태만 보며, 다음 주기에 최종 상태를 반영한다. +- 같은 사용자의 여러 세션은 역할을 한 번 조회하고 각 snapshot을 독립적으로 비교한다. +- 서버 종료와 강제 종료가 겹쳐도 `ConcurrentMap.remove(key, value)`로 같은 entry만 제거한다. +- validation failure 중 일부 세션 종료가 실패해도 나머지 세션 처리를 계속하고 실패한 entry는 재시도한다. + +## 7. 변경 범위 + +| 파일 | 변경 내용 | +|---|---| +| `StompSessionAuthorization.java` | 연결 당시 최소 인증 snapshot | +| `StompSessionRegistry.java` | 물리 연결·snapshot 결합, 조회와 강제 종료 | +| `StompSessionTrackingDecoratorFactory.java` | transport 연결 등록·해제 | +| `StompSessionRevalidationScheduler.java` | 만료·blacklist·역할 일괄 재검증과 Metric | +| `StompSessionSchedulingConfig.java` | 인증 수명 검사의 독립적인 scheduling 활성화 | +| `StompAuthChannelInterceptor.java` | CONNECT 성공 시 snapshot 등록 | +| `TokenBlacklistService.java` | Redis MGET 기반 blacklist 일괄 조회 | +| `WebSocketConfig.java` | transport decorator 등록 | +| `application.yml`, `.env.example` | 검사 간격·batch 설정 | + +## 8. 검증 전략 + +단위 테스트는 registry 경합·종료 실패, MGET 결과, batch 분할, 만료 우선 처리, fail-closed와 종료 사유 +Metric을 검증한다. 실제 WebSocket 통합 테스트는 연결 뒤 blacklist 등록, JWT 만료, 역할 변경이 세션을 +종료하는지 확인하고, 상태가 바뀌지 않은 연결은 여러 검사 주기 뒤에도 push를 받는지 확인한다. + +## 9. 남아 있는 범위 + +- 기본 5초보다 더 짧은 즉시 폐기가 필요하면 상태 변경 이벤트를 보조 신호로 추가할 수 있다. 최종 + 정합성 검사는 현재 주기 작업을 유지해야 한다. +- 로그아웃 과정에서 blacklist 기록 자체가 실패하거나 Redis 재시작으로 key가 유실되면 이 검사도 + 폐기 사실을 알 수 없다. 이는 blacklist 저장 신뢰성의 별도 문제다. +- 인증 전 물리 연결을 장시간 유지하는 client 제어는 handshake·CONNECT timeout 또는 연결 제한으로 + 별도 설계해야 한다. diff --git a/docs/test-results/gimin-#358-stomp-destination-authorization-test.md b/docs/test-results/gimin-#358-stomp-destination-authorization-test.md index e12537b1..5949d279 100644 --- a/docs/test-results/gimin-#358-stomp-destination-authorization-test.md +++ b/docs/test-results/gimin-#358-stomp-destination-authorization-test.md @@ -157,5 +157,5 @@ BUILD SUCCESSFUL in 1s - 연결 인증 interceptor가 먼저 등록돼 SUBSCRIBE 시점에는 검증된 Principal이 존재한다. - 문자열 동등 비교만 수행하므로 추가 DB·Redis 호출과 의미 있는 성능 비용이 없다. -남은 제한은 이미 열린 session의 역할 변경·로그아웃·token 만료 반영이다. 이 문제는 연결 수명 정책이 -필요하므로 #358 범위에 포함하지 않았다. +#358 시점에 남아 있던 열린 session의 역할 변경·로그아웃·token 만료 반영은 연결 수명 정책이 필요한 +별도 문제였고, 후속 #360에서 주기 재검증 방식으로 해결했다. diff --git a/docs/test-results/gimin-#360-stomp-session-lifecycle-test.md b/docs/test-results/gimin-#360-stomp-session-lifecycle-test.md new file mode 100644 index 00000000..135abe9f --- /dev/null +++ b/docs/test-results/gimin-#360-stomp-session-lifecycle-test.md @@ -0,0 +1,153 @@ +# 열린 STOMP 세션 인증 상태 재검증 테스트 결과 (#360) + +## 1. 검증 목적 + +- CONNECT 뒤 로그아웃, JWT 만료, 역할 변경이 기존 물리 WebSocket 연결을 종료하는지 확인한다. +- 상태가 바뀌지 않은 정상 세션이 반복 검사 뒤에도 유지되고 RAG push를 받는지 확인한다. +- 세션 수만큼 Redis·DB를 호출하지 않고 설정한 batch 단위로 조회하는지 확인한다. +- Redis·DB 검증 실패 시 열린 연결도 fail-closed되는지 확인한다. +- 기존 연결 인증, 목적지 인가, HTTP JWT 인증과 역할 변경 경로에 회귀가 없는지 확인한다. + +## 2. 실행 환경 + +| 항목 | 값 | +|---|---| +| 실행 일시 | 2026-09-28 KST | +| branch | `fix/360` | +| Spring Boot | 3.5.16 | +| Spring Messaging | 6.2.19 | +| PostgreSQL | `docgrid-postgres17`, host port `55433` | +| Redis | `docgrid-redis`, host port `6379` | +| profile | `test` | + +JWT secret은 실행 환경에만 test 전용 값으로 주입했고 저장소에는 기록하지 않았다. 통합 테스트의 재검증 +간격은 빠른 검증을 위해 `100ms`로 덮어썼다. + +## 3. 변경 전 실패 재현 + +실제 WebSocket으로 연결한 뒤 같은 JWT의 jti를 blacklist에 등록하고 `SimpUserRegistry`에서 사용자가 +사라질 때까지 최대 3초를 기다리는 테스트를 생산 코드 변경 전에 실행했다. + +```text +1 test completed, 1 failed +ConditionTimeoutException: condition was not fulfilled within 3 seconds +``` + +신규 CONNECT 차단은 이미 동작했지만, blacklist 등록 뒤 기존 세션을 닫는 경로가 없어 timeout이 발생했다. +이는 mock 호출만 검사한 결과가 아니라 실제 transport·STOMP broker를 통과한 재현이다. + +## 4. 단위 테스트 + +| 테스트 | 건수 | 검증 내용 | 결과 | +|---|---:|---|---| +| `StompSessionRegistryTest` | 5 | 등록·인증 결합, 정상 종료, 이미 닫힌 세션, 종료 실패 재시도 | PASS | +| `StompSessionRevalidationSchedulerTest` | 7 | 만료, blacklist, 역할 변경, batch 조회, Redis·DB failure, Gauge | PASS | +| `StompAuthChannelInterceptorTest` | 11 | snapshot 필수 값과 registry 결합을 포함한 CONNECT 인증 | PASS | +| `TokenBlacklistServiceTest` | 5 | MGET 매핑, 중복 제거, 빈 입력, 불완전 응답 | PASS | +| 합계 | 28 | | PASS | + +종료 Counter의 reason과 활성 session Gauge도 단위 테스트에서 함께 검증했다. + +## 5. 실제 WebSocket 통합 테스트 + +```bash +JWT_SECRET={test-only-secret} DB_PORT=55433 \ + ./backend/gradlew -p backend test \ + --tests 'com.opensource.docgrid.domain.auth.integration.StompSessionLifecycleIntegrationTest' +``` + +```text +4 tests completed, 0 failed +BUILD SUCCESSFUL in 10s +``` + +| 시나리오 | 실제 결과 | +|---|---| +| 연결한 token의 jti를 Redis blacklist에 등록 | 3초 안에 사용자 session 제거, 이후 RAG event 미수신 | +| 1초 수명의 JWT로 연결 | 만료 뒤 session 제거 | +| 연결 당시 역할을 DB 기준으로 변경 | 오래된 Principal을 가진 session 제거 | +| 인증 상태를 유지한 채 여러 `100ms` 검사 주기 경과 | session 유지, 대상 RAG event 수신 | + +## 6. 일괄 조회와 주요 회귀 테스트 + +batch size보다 많은 서로 다른 jti·userId를 만든 단위 테스트에서 다음 호출 수를 확인했다. + +```text +Redis 호출 = ceil(고유 jti 수 / batch-size) +DB 호출 = ceil(blacklist 제외 고유 userId 수 / batch-size) +``` + +blacklist로 종료된 세션은 DB 역할 조회에서 제외된다. 개별 세션마다 `hasKey`와 역할 query를 실행하는 +N+1 호출은 발생하지 않는다. + +다음 관련 테스트 10개 클래스를 함께 검증했고 전체 테스트 XML에서도 같은 결과를 다시 확인했다. + +| 범위 | 건수 | 결과 | +|---|---:|---| +| session registry·재검증·CONNECT·blacklist 단위 | 28 | PASS | +| session lifecycle 실제 WebSocket | 4 | PASS | +| 기존 STOMP 연결 인증 | 4 | PASS | +| 기존 STOMP 목적지 인가 | 5 | PASS | +| dashboard WebSocket | 4 | PASS | +| HTTP JWT 인증 | 3 | PASS | +| 역할 변경 command | 5 | PASS | +| 합계 | 53 | PASS | + +## 7. 전체 Backend 검증과 테스트 인프라 보정 + +첫 전체 실행은 다음 결과로 실패했다. + +```text +1225 tests completed, 5 failed +org.postgresql.util.PSQLException: FATAL: sorry, too many clients already +``` + +5건 모두 assertion 실패가 아니라 Flyway 초기화 전에 PostgreSQL 연결을 만들지 못한 context 초기화 +실패였다. 전체 테스트가 여러 Spring context의 Hikari pool을 보관하는 상황에서 새 WebSocket 통합 테스트 +context가 기본 최대 10개 연결을 추가한 것이 원인이었다. + +제품 설정은 바꾸지 않고 새 테스트 context에만 다음 상한을 적용했다. + +```text +spring.datasource.hikari.maximum-pool-size=2 +spring.datasource.hikari.minimum-idle=0 +``` + +통합 테스트를 다시 통과한 뒤 전체 Backend 테스트를 재실행했다. + +```bash +JWT_SECRET={test-only-secret} DB_PORT=55433 \ + ./backend/gradlew -p backend test +``` + +Gradle XML test report 합산 결과: + +```text +1236 tests completed, 0 failed, 0 skipped +BUILD SUCCESSFUL in 1m 7s +``` + +패키징 검증: + +```bash +JWT_SECRET={test-only-secret} DB_PORT=55433 \ + ./backend/gradlew -p backend build +``` + +```text +BUILD SUCCESSFUL in 1s +``` + +## 8. 코드 재검토 결과 + +- physical session을 CONNECT 전에 등록하고 인증 완료 뒤 snapshot을 결합해 추적에서 빠지는 인증 세션을 + 만들지 않는다. +- JWT 원문·이메일을 registry와 로그에 저장하지 않는다. +- 만료를 먼저 처리하고 blacklist 세션을 역할 조회에서 제외해 불필요한 외부 호출을 줄인다. +- 역할 비교는 `Set`으로 수행해 DB 정렬이나 중복에 따라 정상 세션이 닫히지 않는다. +- Redis·DB 오류는 기존 세션에도 fail-closed를 적용하고, 개별 `close()` 실패는 다음 주기에 재시도한다. +- 각 Backend가 자신이 가진 물리 연결만 닫으므로 여러 인스턴스에서 scheduler lock이 필요하지 않다. +- 정상 세션 유지와 실제 push 수신을 함께 검증해 scheduler가 모든 연결을 잘못 닫는 회귀를 방지한다. + +남은 제한은 blacklist 기록 자체의 실패·Redis persistence, 검사 간격만큼의 반영 지연, 인증 전 물리 +연결 제한이다. 이들은 현재 세션 재검증이 해결하는 범위와 분리된 운영·전송 계층 문제다.