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/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/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..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
@@ -1,12 +1,23 @@
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;
import lombok.RequiredArgsConstructor;
+/**
+ * 로그아웃한 access token의 jti를 Redis에 보관하고 단건·일괄 폐기 여부 조회를 제공한다.
+ *
+ * HTTP 인증은 단건 조회를 사용하고, 열린 STOMP 세션 재검증은 네트워크 왕복이 세션 수만큼
+ * 늘지 않도록 MGET 기반 일괄 조회를 사용한다. Redis 장애에 대한 fail-open·fail-closed 결정은 각
+ * 호출 경로가 자신의 가용성 요구에 맞게 담당한다.
+ */
@Component
@RequiredArgsConstructor
public class TokenBlacklistService {
@@ -22,4 +33,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/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..30d0357b
--- /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 | RuntimeException 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/StompSessionRevalidationScheduler.java b/backend/src/main/java/com/opensource/docgrid/domain/auth/websocket/StompSessionRevalidationScheduler.java
new file mode 100644
index 00000000..0a571faa
--- /dev/null
+++ b/backend/src/main/java/com/opensource/docgrid/domain/auth/websocket/StompSessionRevalidationScheduler.java
@@ -0,0 +1,214 @@
+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);
+ List roleCandidates = new ArrayList<>();
+ for (SessionSnapshot session : candidates) {
+ if (blacklistedJtis.contains(session.authorization().jti())) {
+ close(session, CloseReason.BLACKLISTED);
+ } 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);
+ }
+ }
+ } 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/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/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/integration/StompSessionLifecycleIntegrationTest.java b/backend/src/test/java/com/opensource/docgrid/domain/auth/integration/StompSessionLifecycleIntegrationTest.java
new file mode 100644
index 00000000..3365900f
--- /dev/null
+++ b/backend/src/test/java/com/opensource/docgrid/domain/auth/integration/StompSessionLifecycleIntegrationTest.java
@@ -0,0 +1,263 @@
+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");
+ // 전체 테스트 실행에서 Spring context별 idle connection 누적이 PostgreSQL 한도를 잠식하지 않게 제한한다.
+ registry.add("spring.datasource.hikari.maximum-pool-size", () -> "2");
+ registry.add("spring.datasource.hikari.minimum-idle", () -> "0");
+ }
+
+ @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;
+ }
+}
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/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")
+ );
+ }
+}
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..4312775e
--- /dev/null
+++ b/backend/src/test/java/com/opensource/docgrid/domain/auth/websocket/StompSessionRevalidationSchedulerTest.java
@@ -0,0 +1,236 @@
+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 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(2L)))
+ .willReturn(List.of(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");
+ then(userRoleRepository).should().findAllWithRoleByUserIdIn(List.of(2L));
+ 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("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() {
+ // 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();
+ }
+}
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, 검사 간격만큼의 반영 지연, 인증 전 물리
+연결 제한이다. 이들은 현재 세션 재검증이 해결하는 범위와 분리된 운영·전송 계층 문제다.