From 9e46fcb2d7f7cd15515c36486fc5449ca402e47a Mon Sep 17 00:00:00 2001 From: Hexeong <123macanic@naver.com> Date: Wed, 28 Oct 2026 17:17:37 +0900 Subject: [PATCH 1/7] =?UTF-8?q?docs:=20=EC=97=90=EC=9D=B4=EC=A0=84?= =?UTF-8?q?=ED=8A=B8=20=EC=A7=80=EC=B9=A8=EC=9D=84=20CLAUDE.md=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=BC=EC=9B=90=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 389 +---------------------------------------- claude.md => CLAUDE.md | 0 2 files changed, 5 insertions(+), 384 deletions(-) rename claude.md => CLAUDE.md (100%) diff --git a/AGENTS.md b/AGENTS.md index 9d85f6083..365e6ba63 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,388 +1,9 @@ # AGENTS.md -이 파일은 Codex가 solid-connect-server 저장소에서 작업할 때 참고하는 가이드입니다. +Codex는 이 저장소에서 작업을 시작하기 전에 저장소 루트의 `CLAUDE.md` 전체를 반드시 읽고, +해당 내용을 저장소 공통 지침으로 따른다. -## 프로젝트 개요 +`CLAUDE.md`가 없거나 읽을 수 없으면 작업을 진행하지 말고 사용자에게 알린다. -Solid Connect Server는 교환학생 준비생을 위해 대학 정보, 멘토 매칭, 모의지원 기능 등을 제공하는 교환학생 지원 통합 플랫폼입니다. - -- **언어**: Java 21 -- **프레임워크**: Spring Boot 3.5.11 -- **빌드 도구**: Gradle -- **데이터베이스**: MySQL (주), Redis (캐싱) -- **마이그레이션**: Flyway - ---- - -## 빌드 및 개발 명령어 - -### Gradle 빌드 명령어 - -```bash -# 전체 빌드 -./gradlew build - -# 테스트 실행 -./gradlew test - -# 특정 테스트만 실행 -./gradlew test --tests ChatServiceTest - -# 애플리케이션 실행 -./gradlew bootRun - -# 로컬 개발 환경 시작 (MySQL, Redis) -docker-compose -f docker-compose.local.yml up -d -``` - -### 프로필별 실행 - -```bash -# 로컬 개발 환경 -./gradlew bootRun --args='--spring.profiles.active=local' - -# 개발 환경 -./gradlew bootRun --args='--spring.profiles.active=dev' - -# 운영 환경 -./gradlew bootRun --args='--spring.profiles.active=prod' -``` - ---- - -## 프로젝트 구조 - -``` -solid-connect-server/ -├── src/ -│ ├── main/ -│ │ ├── java/com/example/solidconnection/ -│ │ │ ├── [domain]/ # 도메인별 폴더 -│ │ │ │ ├── controller/ # REST API 엔드포인트 -│ │ │ │ ├── service/ # 비즈니스 로직 -│ │ │ │ ├── domain/ # JPA Entity -│ │ │ │ ├── repository/ # 데이터 접근 계층 -│ │ │ │ └── dto/ # DTO (Request/Response) -│ │ │ └── common/ # 공통 기능 -│ │ │ ├── exception/ # 커스텀 예외 -│ │ │ ├── config/ # Spring 설정 -│ │ │ └── util/ # 유틸리티 -│ │ └── resources/ -│ │ ├── db/migration/ # Flyway 마이그레이션 -│ │ └── application*.yml # 설정 파일 -│ └── test/ -│ └── java/com/example/solidconnection/ -│ ├── [domain]/fixture/ # 테스트 Fixture -│ ├── [domain]/service/ # 서비스 테스트 -│ ├── support/ # 테스트 설정 -│ └── ... -├── docker-compose.local.yml # 로컬 컨테이너 -├── docker-compose.dev.yml # 개발 컨테이너 -├── docker-compose.prod.yml # 운영 컨테이너 -├── Dockerfile # 이미지 빌드 -├── build.gradle # Gradle 설정 -``` - ---- - -## 아키텍처 - -### 계층형 아키텍처 (Layered Architecture) - -각 계층은 자신의 바로 아래 계층만 참조할 수 있습니다. - -``` -Controller → Service → Repository/Domain -``` - -**각 계층의 역할:** - -- **Controller**: HTTP 요청 처리, 입력값 검증, 응답 포맷팅 -- **Service**: 비즈니스 로직 처리, DTO 변환, 트랜잭션 관리 -- **Repository**: 데이터 접근 계층, DB 쿼리 작성 -- **Domain (Entity)**: JPA 엔티티, 도메인 모델 - -**주요 규칙:** - -- ✅ 역계층 참조 금지 (예: Repository에서 Service 참조 불가) -- ✅ Service는 Repository를 주입받아 사용 -- ✅ Controller는 Service를 주입받아 사용 -- ✅ Entity는 도메인 로직만 포함 -- ✅ DTO는 요청/응답 시에만 사용 - -### 패키지 구조 - -``` -[domain]/ -├── controller/ # REST API 엔드포인트 -├── service/ # 비즈니스 로직 (Service) -├── domain/ # JPA Entity -├── repository/ # 데이터 접근 계층 (Repository) -└── dto/ # DTO (Request/Response) -``` - ---- - -## 개발 컨벤션 - -### 코드 스타일 - -프로젝트의 개발 컨벤션을 따릅니다: [개발-컨벤션-정리](https://github.com/solid-connection/solid-connect-server/wiki/개발-컨벤션-정리) - -**주요 규칙:** - -- **클래스 선언 전 줄바꿈**: 클래스 정의 앞에 빈 줄 필수 -- **파일 끝 줄바꿈**: 모든 파일은 개행 문자로 종료 -- **와일드카드 import 금지**: 명시적 import만 사용 -- **파라미터 줄바꿈**: Controller는 필수, 3개 이상의 파라미터가 있으면 줄바꿈 -- **private 메서드 위치**: 호출하는 public 메서드 바로 아래 위치 -- **원시 타입 사용**: null이 아닌 값은 `int`, `long` 등 원시 타입 사용, nullable은 Wrapper 사용 -- **JPA @Column**: Entity의 모든 필드에 `@Column` 속성과 필드명 지정 - -### 네이밍 컨벤션 - -```java -// DTO 변환 -// 다중 파라미터: of() 메서드 -public static UserDto of(User user, Profile profile) { ...} - -// 단일 파라미터: from() 메서드 -public static UserDto from(User user) { ...} - -// API 요청/응답 -// XXXRequest, XXXResponse 형식 -public class UserCreateRequest { ... -} - -public class UserCreateResponse { ... -} - -// REST API 엔드포인트 -// kebab-case 사용 -@GetMapping("/user-profile") // O -@GetMapping("/userProfile") // X -``` - ---- - -## 기술 스택 상세 - -### Core Framework - -- **Spring Boot 3.5.11**: 스프링 부트 -- **Spring Security**: JWT 기반 인증 -- **Spring Data JPA**: ORM -- **QueryDSL**: 동적 쿼리 생성 - -### 데이터베이스 - -- **MySQL**: 주 데이터베이스 -- **Redis**: 캐싱 저장소 -- **Flyway**: 데이터베이스 버전 관리 - -### 모니터링 & 보안 - -- **Spring Boot Actuator**: 애플리케이션 모니터링 -- **Prometheus**: 메트릭 수집 -- **Sentry**: 에러 추적 -- **JWT**: JWT 토큰 관리 - -### 개발 도구 - -- **Lombok**: 보일러플레이트 코드 감소 -- **AWS S3 SDK**: 파일 저장소 -- **WebSocket**: 실시간 통신 -- **TestContainers**: 통합 테스트용 컨테이너 - ---- - -## 테스트 코드 작성 - -테스트 작성 시 `/test` skill을 참고하세요. (테스트 관련 작업 시 자동으로 로드됩니다) - -- `@TestContainerSpringBootTest` 기반 통합 테스트 -- FixtureBuilder + Fixture 패턴으로 테스트 데이터 생성 -- 한국어 메서드명, Given-When-Then 구조, @Nested 그룹화 - ---- - -## Git 커밋 컨벤션 - -### 형식 - -``` -: - -[optional body] -``` - -### Type 목록 - -``` -feat: 새로운 기능 추가 -fix: 버그 수정 -refactor: 코드 리팩토링 (기능 변경 없음) -docs: 문서 변경 -test: 테스트 추가/수정 -chore: 빌드 설정, 패키지 관리 -perf: 성능 개선 -``` - -### 예제 - -```bash -# 기능 추가 -feat: 대학 검색 기능 추가 - -# 버그 수정 -fix: 채팅방 조회 시 정렬 버그 수정 - -# 리팩토링 -refactor: ChatService 메서드 분리 - -# 테스트 추가 -test: ChatService 테스트 케이스 추가 - -# 브랜치명 -refactor/529-shortening-cd-time -``` - ---- - -## 데이터베이스 마이그레이션 - -### Flyway 사용 - -모든 DB 스키마 변경사항은 Flyway로 관리합니다. - -**위치:** `src/main/resources/db/migration/` - -**파일명 형식:** `V{VERSION}__{DESCRIPTION}.sql` - -``` -V1__init_schema.sql -V2__add_chat_table.sql -V3__add_user_role_column.sql -``` - -### 마이그레이션 추가 - -1. `V{next_version}__{description}.sql` 파일 생성 -2. SQL 작성 -3. `./gradlew build` 시 자동 검증 (flywayValidate) - -**주의:** 한 번 배포된 마이그레이션은 수정 불가 (새 버전으로 생성) - ---- - -## 데이터베이스 접근 - -### JPA Entity - -```java - -@Entity -@Table(name = "chat_room") -public class ChatRoom { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "id") - private Long id; - - @Column(name = "is_group", nullable = false) - private boolean isGroup; - - @Column(name = "mentoring_id", nullable = true) - private Long mentoringId; -} -``` - -**규칙:** - -- `@Column` 필수 (모든 필드) -- 필드명과 DB 컬럼명 일치 -- nullable 명시 - -### Repository - -```java -public interface ChatRoomRepository extends JpaRepository { - - Optional findByMentoringId(Long mentoringId); - - List findByIsGroup(boolean isGroup); -} -``` - ---- - -## 주요 파일 위치 - -| 파일/폴더 | 설명 | -|----------------------------------------------|---------------| -| `src/main/java/com/example/solidconnection/` | 메인 소스 코드 | -| `src/test/java/com/example/solidconnection/` | 테스트 코드 | -| `src/main/resources/db/migration/` | Flyway 마이그레이션 | -| `src/main/resources/application.yml` | 공통 설정 | -| `docker-compose.*.yml` | 환경별 도커 설정 | -| `build.gradle` | Gradle 빌드 설정 | - ---- - -### 프로필 - -- **local**: Development with embedded Tomcat -- **dev**: Development server (stage.solid-connection.com) -- **prod**: Production server (solid-connection.com) - ---- - -## 자주하는 작업 - -### 새 기능 추가 - -1. Entity 생성 (`src/main/java/.../domain/`) -2. Repository 작성 (`src/main/java/.../repository/`) -3. Service 구현 (`src/main/java/.../service/`) -4. Controller 작성 (`src/main/java/.../controller/`) -5. DTO 정의 (`src/main/java/.../dto/`) -6. Flyway 마이그레이션 작성 -7. 테스트 코드 작성 - -### 테스트 작성 - -1. FixtureBuilder 생성 (필요시) -2. Fixture 편의 메서드 추가 (필요시) -3. 테스트 클래스 작성 (`*Test.java`) -4. @Nested로 테스트 그룹화 -5. Given-When-Then 구조로 작성 -6. `./gradlew test` 실행 - -### DB 스키마 변경 - -1. `V{next}__{description}.sql` 파일 생성 -2. 마이그레이션 SQL 작성 -3. Entity 업데이트 (필요시) -4. 테스트 실행 - ---- - -## 참고 자료 - -- **개발 컨벤션**: https://github.com/solid-connection/solid-connect-server/wiki/개발-컨벤션-정리 -- **테스트 가이드**: `test.md` 파일 참고 -- **Spring Boot**: https://spring.io/projects/spring-boot -- **JPA**: https://spring.io/projects/spring-data-jpa -- **TestContainers**: https://www.testcontainers.org/ -- **Flyway**: https://flywaydb.org/ - ---- - -## 주의사항 - -1. **Flyway 마이그레이션은 되돌릴 수 없음** - 신중하게 작성 -2. **QueryDSL Q클래스는 자동 생성** - 수동 수정 금지 -3. **테스트는 독립적** - 테스트 간 데이터 공유 불가 -4. **환경별 설정 분리** - application-local.yml, application-dev.yml, application-prod.yml -5. **한국어 메서드명** - 테스트 가독성 향상을 위해 사용 +`AGENTS.local.md`가 존재하면 해당 파일도 읽고 개인 로컬 추가 지침으로 따른다. +`CLAUDE.md`와 `AGENTS.local.md`가 충돌하면 `AGENTS.local.md`를 우선한다. diff --git a/claude.md b/CLAUDE.md similarity index 100% rename from claude.md rename to CLAUDE.md From 18d04ac7a42c6ac934957e39ed1b0a5d9d477047 Mon Sep 17 00:00:00 2001 From: Hexeong <123macanic@naver.com> Date: Wed, 28 Oct 2026 17:40:40 +0900 Subject: [PATCH 2/7] =?UTF-8?q?refactor:=20=EB=AF=B8=EA=B5=AC=EB=8F=85=20S?= =?UTF-8?q?TOMP=20=EC=84=B8=EC=85=98=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/config/StompEventListener.java | 17 ++- .../chat/config/StompProperties.java | 9 +- .../chat/config/StompSessionManager.java | 88 ++++++++++++ .../config/StompSessionTimeoutConfig.java | 19 +++ .../chat/config/StompWebSocketConfig.java | 7 + .../WebSocketSessionDecoratorFactory.java | 41 ++++++ .../resources/config/application-variable.yml | 1 + .../chat/config/StompSessionManagerTest.java | 127 ++++++++++++++++++ .../websocket/WebSocketHandshakeTest.java | 57 ++++++++ src/test/resources/application.yml | 1 + 10 files changed, 360 insertions(+), 7 deletions(-) create mode 100644 src/main/java/com/example/solidconnection/chat/config/StompSessionManager.java create mode 100644 src/main/java/com/example/solidconnection/chat/config/StompSessionTimeoutConfig.java create mode 100644 src/main/java/com/example/solidconnection/chat/config/WebSocketSessionDecoratorFactory.java create mode 100644 src/test/java/com/example/solidconnection/chat/config/StompSessionManagerTest.java diff --git a/src/main/java/com/example/solidconnection/chat/config/StompEventListener.java b/src/main/java/com/example/solidconnection/chat/config/StompEventListener.java index 1064eef3a..bbef3506a 100644 --- a/src/main/java/com/example/solidconnection/chat/config/StompEventListener.java +++ b/src/main/java/com/example/solidconnection/chat/config/StompEventListener.java @@ -1,27 +1,34 @@ package com.example.solidconnection.chat.config; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; +import lombok.RequiredArgsConstructor; import org.springframework.context.event.EventListener; import org.springframework.messaging.simp.stomp.StompHeaderAccessor; import org.springframework.stereotype.Component; import org.springframework.web.socket.messaging.SessionConnectEvent; import org.springframework.web.socket.messaging.SessionDisconnectEvent; +import org.springframework.web.socket.messaging.SessionSubscribeEvent; @Component +@RequiredArgsConstructor public class StompEventListener { - private final Set sessions = ConcurrentHashMap.newKeySet(); + private final StompSessionManager stompSessionManager; @EventListener public void connectHandle(SessionConnectEvent event) { StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage()); - sessions.add(accessor.getSessionId()); + stompSessionManager.startSubscriptionTimeout(accessor.getSessionId()); + } + + @EventListener + public void subscribeHandle(SessionSubscribeEvent event) { + StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage()); + stompSessionManager.completeSubscription(accessor.getSessionId()); } @EventListener public void disconnectHandle(SessionDisconnectEvent event) { StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage()); - sessions.remove(accessor.getSessionId()); + stompSessionManager.removeSession(accessor.getSessionId()); } } diff --git a/src/main/java/com/example/solidconnection/chat/config/StompProperties.java b/src/main/java/com/example/solidconnection/chat/config/StompProperties.java index ce9663c72..4c5c0612f 100644 --- a/src/main/java/com/example/solidconnection/chat/config/StompProperties.java +++ b/src/main/java/com/example/solidconnection/chat/config/StompProperties.java @@ -1,9 +1,14 @@ package com.example.solidconnection.chat.config; +import java.time.Duration; import org.springframework.boot.context.properties.ConfigurationProperties; @ConfigurationProperties(prefix = "websocket") -public record StompProperties(ThreadPool threadPool, HeartbeatProperties heartbeat) { +public record StompProperties( + ThreadPool threadPool, + HeartbeatProperties heartbeat, + Duration subscriptionTimeout +) { public record ThreadPool(InboundProperties inbound, OutboundProperties outbound) { @@ -20,4 +25,4 @@ public record OutboundProperties(int corePoolSize, int maxPoolSize) { public record HeartbeatProperties(long serverInterval, long clientInterval) { } -} \ No newline at end of file +} diff --git a/src/main/java/com/example/solidconnection/chat/config/StompSessionManager.java b/src/main/java/com/example/solidconnection/chat/config/StompSessionManager.java new file mode 100644 index 000000000..5697343df --- /dev/null +++ b/src/main/java/com/example/solidconnection/chat/config/StompSessionManager.java @@ -0,0 +1,88 @@ +package com.example.solidconnection.chat.config; + +import java.io.IOException; +import java.time.Instant; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ScheduledFuture; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.WebSocketSession; + +@Slf4j +@Component +@RequiredArgsConstructor +public class StompSessionManager { + + private static final CloseStatus SUBSCRIPTION_TIMEOUT_STATUS = CloseStatus.POLICY_VIOLATION + .withReason("STOMP subscription timeout"); + + private final Map sessions = new ConcurrentHashMap<>(); + private final Map subscriptionTimeoutTasks = new ConcurrentHashMap<>(); + private final TaskScheduler stompSubscriptionTimeoutScheduler; + private final StompProperties stompProperties; + + public void registerSession(WebSocketSession session) { + sessions.put(session.getId(), session); + startSubscriptionTimeout(session.getId()); + } + + public void startSubscriptionTimeout(String sessionId) { + Object taskToken = new Object(); + Instant timeoutAt = Instant.now().plus(stompProperties.subscriptionTimeout()); + ScheduledFuture timeoutTask = stompSubscriptionTimeoutScheduler.schedule( + () -> closeUnsubscribedSession(sessionId, taskToken), + timeoutAt + ); + SubscriptionTimeoutTask previousTask = subscriptionTimeoutTasks.put( + sessionId, + new SubscriptionTimeoutTask(taskToken, timeoutTask) + ); + cancel(previousTask); + } + + private void closeUnsubscribedSession(String sessionId, Object taskToken) { + SubscriptionTimeoutTask timeoutTask = subscriptionTimeoutTasks.get(sessionId); + if (timeoutTask == null + || timeoutTask.token() != taskToken + || !subscriptionTimeoutTasks.remove(sessionId, timeoutTask)) { + return; + } + + WebSocketSession session = sessions.remove(sessionId); + if (session == null || !session.isOpen()) { + return; + } + + try { + session.close(SUBSCRIPTION_TIMEOUT_STATUS); + log.warn("Closed STOMP session because no subscription was received. sessionId={}", sessionId); + } catch (IOException e) { + log.warn("Failed to close unsubscribed STOMP session. sessionId={}", sessionId, e); + } + } + + public void completeSubscription(String sessionId) { + cancel(subscriptionTimeoutTasks.remove(sessionId)); + } + + public void removeSession(String sessionId) { + cancel(subscriptionTimeoutTasks.remove(sessionId)); + sessions.remove(sessionId); + } + + private void cancel(SubscriptionTimeoutTask timeoutTask) { + if (timeoutTask != null) { + timeoutTask.future().cancel(false); + } + } + + private record SubscriptionTimeoutTask( + Object token, + ScheduledFuture future + ) { + } +} diff --git a/src/main/java/com/example/solidconnection/chat/config/StompSessionTimeoutConfig.java b/src/main/java/com/example/solidconnection/chat/config/StompSessionTimeoutConfig.java new file mode 100644 index 000000000..62c3bb0e9 --- /dev/null +++ b/src/main/java/com/example/solidconnection/chat/config/StompSessionTimeoutConfig.java @@ -0,0 +1,19 @@ +package com.example.solidconnection.chat.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; + +@Configuration +public class StompSessionTimeoutConfig { + + @Bean + public ThreadPoolTaskScheduler stompSubscriptionTimeoutScheduler() { + ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); + scheduler.setPoolSize(1); + scheduler.setThreadNamePrefix("stomp-subscription-timeout-"); + scheduler.setRemoveOnCancelPolicy(true); + scheduler.setWaitForTasksToCompleteOnShutdown(false); + return scheduler; + } +} diff --git a/src/main/java/com/example/solidconnection/chat/config/StompWebSocketConfig.java b/src/main/java/com/example/solidconnection/chat/config/StompWebSocketConfig.java index c7631172d..781f78f13 100644 --- a/src/main/java/com/example/solidconnection/chat/config/StompWebSocketConfig.java +++ b/src/main/java/com/example/solidconnection/chat/config/StompWebSocketConfig.java @@ -13,6 +13,7 @@ import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketTransportRegistration; import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; @Configuration @@ -25,6 +26,7 @@ public class StompWebSocketConfig implements WebSocketMessageBrokerConfigurer { private final CorsProperties corsProperties; private final WebSocketHandshakeInterceptor webSocketHandshakeInterceptor; private final CustomHandshakeHandler customHandshakeHandler; + private final WebSocketSessionDecoratorFactory webSocketSessionDecoratorFactory; private final Optional webSocketLoggingInterceptor; @Override @@ -55,6 +57,11 @@ public void configureClientOutboundChannel(ChannelRegistration registration) { registration.taskExecutor().corePoolSize(outboundProperties.corePoolSize()).maxPoolSize(outboundProperties.maxPoolSize()); } + @Override + public void configureWebSocketTransport(WebSocketTransportRegistration registration) { + registration.addDecoratorFactory(webSocketSessionDecoratorFactory); + } + @Override public void configureMessageBroker(MessageBrokerRegistry registry) { ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); diff --git a/src/main/java/com/example/solidconnection/chat/config/WebSocketSessionDecoratorFactory.java b/src/main/java/com/example/solidconnection/chat/config/WebSocketSessionDecoratorFactory.java new file mode 100644 index 000000000..f4c2d130b --- /dev/null +++ b/src/main/java/com/example/solidconnection/chat/config/WebSocketSessionDecoratorFactory.java @@ -0,0 +1,41 @@ +package com.example.solidconnection.chat.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.CloseStatus; +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; + +@Component +@RequiredArgsConstructor +public class WebSocketSessionDecoratorFactory implements WebSocketHandlerDecoratorFactory { + + private final StompSessionManager stompSessionManager; + + @Override + public WebSocketHandler decorate(WebSocketHandler handler) { + return new WebSocketHandlerDecorator(handler) { + @Override + public void afterConnectionEstablished(WebSocketSession session) throws Exception { + stompSessionManager.registerSession(session); + try { + super.afterConnectionEstablished(session); + } catch (Exception e) { + stompSessionManager.removeSession(session.getId()); + throw e; + } + } + + @Override + public void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus) throws Exception { + try { + super.afterConnectionClosed(session, closeStatus); + } finally { + stompSessionManager.removeSession(session.getId()); + } + } + }; + } +} diff --git a/src/main/resources/config/application-variable.yml b/src/main/resources/config/application-variable.yml index a661e92c2..c8c1c974a 100644 --- a/src/main/resources/config/application-variable.yml +++ b/src/main/resources/config/application-variable.yml @@ -4,6 +4,7 @@ view: delay: 3000 websocket: + subscription-timeout: 30s thread-pool: inbound: core-pool-size: 6 diff --git a/src/test/java/com/example/solidconnection/chat/config/StompSessionManagerTest.java b/src/test/java/com/example/solidconnection/chat/config/StompSessionManagerTest.java new file mode 100644 index 000000000..078a0a42a --- /dev/null +++ b/src/test/java/com/example/solidconnection/chat/config/StompSessionManagerTest.java @@ -0,0 +1,127 @@ +package com.example.solidconnection.chat.config; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; + +import java.time.Duration; +import java.time.Instant; +import java.util.concurrent.ScheduledFuture; +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.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.WebSocketSession; + +@ExtendWith(MockitoExtension.class) +@DisplayName("STOMP 세션 관리자 테스트") +class StompSessionManagerTest { + + private static final String SESSION_ID = "session-id"; + private static final Duration SUBSCRIPTION_TIMEOUT = Duration.ofSeconds(30); + + @Mock + private TaskScheduler scheduler; + + @Mock + private ScheduledFuture timeoutTask; + + @Mock + private ScheduledFuture renewedTimeoutTask; + + @Mock + private WebSocketSession session; + + private StompSessionManager stompSessionManager; + + @BeforeEach + void setUp() { + StompProperties stompProperties = new StompProperties(null, null, SUBSCRIPTION_TIMEOUT); + stompSessionManager = new StompSessionManager(scheduler, stompProperties); + + given(session.getId()).willReturn(SESSION_ID); + } + + @Test + void 제한시간까지_구독하지_않으면_세션을_종료한다() throws Exception { + // given + given(session.isOpen()).willReturn(true); + doReturn(timeoutTask).when(scheduler).schedule(any(Runnable.class), any(Instant.class)); + stompSessionManager.registerSession(session); + ArgumentCaptor timeoutActionCaptor = ArgumentCaptor.forClass(Runnable.class); + then(scheduler).should().schedule(timeoutActionCaptor.capture(), any(Instant.class)); + + // when + timeoutActionCaptor.getValue().run(); + + // then + then(session).should().close(any(CloseStatus.class)); + } + + @Test + void 제한시간_안에_구독하면_종료_작업을_취소한다() throws Exception { + // given + doReturn(timeoutTask).when(scheduler).schedule(any(Runnable.class), any(Instant.class)); + stompSessionManager.registerSession(session); + ArgumentCaptor timeoutActionCaptor = ArgumentCaptor.forClass(Runnable.class); + then(scheduler).should().schedule(timeoutActionCaptor.capture(), any(Instant.class)); + + // when + stompSessionManager.completeSubscription(SESSION_ID); + timeoutActionCaptor.getValue().run(); + + // then + then(timeoutTask).should().cancel(false); + then(session).should(never()).close(any(CloseStatus.class)); + } + + @Test + void STOMP_연결이_수립되면_구독_제한시간을_다시_계산하고_이전_작업은_무시한다() throws Exception { + // given + doReturn(timeoutTask, renewedTimeoutTask) + .when(scheduler).schedule(any(Runnable.class), any(Instant.class)); + stompSessionManager.registerSession(session); + ArgumentCaptor timeoutActionCaptor = ArgumentCaptor.forClass(Runnable.class); + + // when + stompSessionManager.startSubscriptionTimeout(SESSION_ID); + then(scheduler).should(times(2)).schedule(timeoutActionCaptor.capture(), any(Instant.class)); + timeoutActionCaptor.getAllValues().getFirst().run(); + + // then + then(timeoutTask).should().cancel(false); + then(session).should(never()).close(any(CloseStatus.class)); + + // when + stompSessionManager.completeSubscription(SESSION_ID); + + // then + then(renewedTimeoutTask).should().cancel(false); + } + + @Test + void 연결이_종료되면_예약_작업과_세션을_정리한다() throws Exception { + // given + doReturn(timeoutTask).when(scheduler).schedule(any(Runnable.class), any(Instant.class)); + stompSessionManager.registerSession(session); + ArgumentCaptor timeoutActionCaptor = ArgumentCaptor.forClass(Runnable.class); + then(scheduler).should().schedule(timeoutActionCaptor.capture(), any(Instant.class)); + + // when + stompSessionManager.removeSession(SESSION_ID); + stompSessionManager.removeSession(SESSION_ID); + timeoutActionCaptor.getValue().run(); + + // then + then(timeoutTask).should().cancel(false); + then(session).should(never()).close(any(CloseStatus.class)); + } +} diff --git a/src/test/java/com/example/solidconnection/websocket/WebSocketHandshakeTest.java b/src/test/java/com/example/solidconnection/websocket/WebSocketHandshakeTest.java index 2bde3c0c6..0b12fc5ec 100644 --- a/src/test/java/com/example/solidconnection/websocket/WebSocketHandshakeTest.java +++ b/src/test/java/com/example/solidconnection/websocket/WebSocketHandshakeTest.java @@ -3,12 +3,18 @@ import static java.util.concurrent.TimeUnit.SECONDS; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.awaitility.Awaitility.await; import com.example.solidconnection.auth.domain.AccessToken; import com.example.solidconnection.auth.service.AuthTokenProvider; +import com.example.solidconnection.chat.domain.ChatRoom; +import com.example.solidconnection.chat.fixture.ChatParticipantFixture; +import com.example.solidconnection.chat.fixture.ChatRoomFixture; import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.siteuser.fixture.SiteUserFixture; import com.example.solidconnection.support.TestContainerSpringBootTest; +import java.lang.reflect.Type; +import java.time.Duration; import java.util.List; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; @@ -20,6 +26,8 @@ import org.springframework.beans.factory.annotation.Autowired; 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.web.client.HttpClientErrorException; @@ -45,6 +53,12 @@ class WebSocketHandshakeTest { @Autowired private SiteUserFixture siteUserFixture; + @Autowired + private ChatRoomFixture chatRoomFixture; + + @Autowired + private ChatParticipantFixture chatParticipantFixture; + @BeforeEach void setUp() { this.url = String.format("http://localhost:%d/connect", port); @@ -78,6 +92,49 @@ class WebSocket_핸드셰이크_및_STOMP_세션_수립_테스트 { assertThat(stompSession.isConnected()).isTrue(); } + @Test + void 연결_후_구독하지_않으면_제한시간_뒤_연결을_종료한다() throws Exception { + // given + SiteUser user = siteUserFixture.사용자(); + AccessToken accessToken = authTokenProvider.generateAccessToken(user); + String tokenUrl = url + "?token=" + accessToken.token(); + stompSession = stompClient.connectAsync(tokenUrl, new StompSessionHandlerAdapter() { + }).get(5, SECONDS); + + // when & then + await().atMost(Duration.ofSeconds(3)) + .untilAsserted(() -> assertThat(stompSession.isConnected()).isFalse()); + } + + @Test + void 연결_후_제한시간_안에_구독하면_연결을_유지한다() throws Exception { + // given + SiteUser user = siteUserFixture.사용자(); + ChatRoom chatRoom = chatRoomFixture.채팅방(false); + chatParticipantFixture.참여자(user.getId(), chatRoom); + AccessToken accessToken = authTokenProvider.generateAccessToken(user); + String tokenUrl = url + "?token=" + accessToken.token(); + stompSession = stompClient.connectAsync(tokenUrl, new StompSessionHandlerAdapter() { + }).get(5, SECONDS); + + // when + stompSession.subscribe("/topic/chat/" + chatRoom.getId(), new StompFrameHandler() { + @Override + public Type getPayloadType(StompHeaders headers) { + return Object.class; + } + + @Override + public void handleFrame(StompHeaders headers, Object payload) { + } + }); + + // then + await().during(Duration.ofSeconds(1)) + .atMost(Duration.ofSeconds(2)) + .untilAsserted(() -> assertThat(stompSession.isConnected()).isTrue()); + } + @Test void 인증되지_않은_사용자는_핸드셰이크를_실패한다() { // when & then diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index b5e44befc..3f8cbeaff 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -50,6 +50,7 @@ view: enabled: false delay: 3000 websocket: + subscription-timeout: 300ms thread-pool: inbound: core-pool-size: 8 From 769613befcf8d846c734112c831b78941fc351a9 Mon Sep 17 00:00:00 2001 From: Hexeong <123macanic@naver.com> Date: Wed, 28 Oct 2026 17:55:03 +0900 Subject: [PATCH 3/7] =?UTF-8?q?fix:=20STOMP=20=EC=84=B8=EC=85=98=20?= =?UTF-8?q?=EC=A2=85=EB=A3=8C=20=EC=9E=91=EC=97=85=20=EA=B2=A9=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/config/StompSessionManager.java | 21 ++++++++++++++++--- .../config/StompSessionTimeoutConfig.java | 7 +++++++ .../chat/config/StompSessionManagerTest.java | 21 ++++++++++++++++++- 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/example/solidconnection/chat/config/StompSessionManager.java b/src/main/java/com/example/solidconnection/chat/config/StompSessionManager.java index 5697343df..a764c9170 100644 --- a/src/main/java/com/example/solidconnection/chat/config/StompSessionManager.java +++ b/src/main/java/com/example/solidconnection/chat/config/StompSessionManager.java @@ -5,8 +5,9 @@ import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ScheduledFuture; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.core.task.TaskExecutor; import org.springframework.scheduling.TaskScheduler; import org.springframework.stereotype.Component; import org.springframework.web.socket.CloseStatus; @@ -14,7 +15,6 @@ @Slf4j @Component -@RequiredArgsConstructor public class StompSessionManager { private static final CloseStatus SUBSCRIPTION_TIMEOUT_STATUS = CloseStatus.POLICY_VIOLATION @@ -23,8 +23,19 @@ public class StompSessionManager { private final Map sessions = new ConcurrentHashMap<>(); private final Map subscriptionTimeoutTasks = new ConcurrentHashMap<>(); private final TaskScheduler stompSubscriptionTimeoutScheduler; + private final TaskExecutor stompSessionCloseExecutor; private final StompProperties stompProperties; + public StompSessionManager( + @Qualifier("stompSubscriptionTimeoutScheduler") TaskScheduler stompSubscriptionTimeoutScheduler, + @Qualifier("stompSessionCloseExecutor") TaskExecutor stompSessionCloseExecutor, + StompProperties stompProperties + ) { + this.stompSubscriptionTimeoutScheduler = stompSubscriptionTimeoutScheduler; + this.stompSessionCloseExecutor = stompSessionCloseExecutor; + this.stompProperties = stompProperties; + } + public void registerSession(WebSocketSession session) { sessions.put(session.getId(), session); startSubscriptionTimeout(session.getId()); @@ -57,10 +68,14 @@ private void closeUnsubscribedSession(String sessionId, Object taskToken) { return; } + stompSessionCloseExecutor.execute(() -> closeSession(sessionId, session)); + } + + private void closeSession(String sessionId, WebSocketSession session) { try { session.close(SUBSCRIPTION_TIMEOUT_STATUS); log.warn("Closed STOMP session because no subscription was received. sessionId={}", sessionId); - } catch (IOException e) { + } catch (IOException | RuntimeException e) { log.warn("Failed to close unsubscribed STOMP session. sessionId={}", sessionId, e); } } diff --git a/src/main/java/com/example/solidconnection/chat/config/StompSessionTimeoutConfig.java b/src/main/java/com/example/solidconnection/chat/config/StompSessionTimeoutConfig.java index 62c3bb0e9..147c74794 100644 --- a/src/main/java/com/example/solidconnection/chat/config/StompSessionTimeoutConfig.java +++ b/src/main/java/com/example/solidconnection/chat/config/StompSessionTimeoutConfig.java @@ -2,6 +2,8 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.task.TaskExecutor; +import org.springframework.core.task.VirtualThreadTaskExecutor; import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; @Configuration @@ -16,4 +18,9 @@ public ThreadPoolTaskScheduler stompSubscriptionTimeoutScheduler() { scheduler.setWaitForTasksToCompleteOnShutdown(false); return scheduler; } + + @Bean + public TaskExecutor stompSessionCloseExecutor() { + return new VirtualThreadTaskExecutor("stomp-session-close-"); + } } diff --git a/src/test/java/com/example/solidconnection/chat/config/StompSessionManagerTest.java b/src/test/java/com/example/solidconnection/chat/config/StompSessionManagerTest.java index 078a0a42a..8354498f0 100644 --- a/src/test/java/com/example/solidconnection/chat/config/StompSessionManagerTest.java +++ b/src/test/java/com/example/solidconnection/chat/config/StompSessionManagerTest.java @@ -1,9 +1,11 @@ package com.example.solidconnection.chat.config; +import static org.assertj.core.api.Assertions.assertThatCode; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; @@ -17,6 +19,7 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.task.TaskExecutor; import org.springframework.scheduling.TaskScheduler; import org.springframework.web.socket.CloseStatus; import org.springframework.web.socket.WebSocketSession; @@ -45,7 +48,8 @@ class StompSessionManagerTest { @BeforeEach void setUp() { StompProperties stompProperties = new StompProperties(null, null, SUBSCRIPTION_TIMEOUT); - stompSessionManager = new StompSessionManager(scheduler, stompProperties); + TaskExecutor directExecutor = Runnable::run; + stompSessionManager = new StompSessionManager(scheduler, directExecutor, stompProperties); given(session.getId()).willReturn(SESSION_ID); } @@ -66,6 +70,21 @@ void setUp() { then(session).should().close(any(CloseStatus.class)); } + @Test + void 세션_종료_실패는_타임아웃_작업으로_전파하지_않는다() throws Exception { + // given + given(session.isOpen()).willReturn(true); + doReturn(timeoutTask).when(scheduler).schedule(any(Runnable.class), any(Instant.class)); + doThrow(new IllegalStateException("close failed")) + .when(session).close(any(CloseStatus.class)); + stompSessionManager.registerSession(session); + ArgumentCaptor timeoutActionCaptor = ArgumentCaptor.forClass(Runnable.class); + then(scheduler).should().schedule(timeoutActionCaptor.capture(), any(Instant.class)); + + // when & then + assertThatCode(timeoutActionCaptor.getValue()::run).doesNotThrowAnyException(); + } + @Test void 제한시간_안에_구독하면_종료_작업을_취소한다() throws Exception { // given From 218ca146d8e977091d8d4b993a41c712f769cd52 Mon Sep 17 00:00:00 2001 From: Hexeong <123macanic@naver.com> Date: Thu, 29 Oct 2026 06:38:32 +0900 Subject: [PATCH 4/7] =?UTF-8?q?fix:=20STOMP=20=EC=A2=85=EB=A3=8C=20?= =?UTF-8?q?=EC=8B=A4=ED=96=89=EA=B8=B0=20=EA=B8=B0=EB=B3=B8=20=ED=9B=84?= =?UTF-8?q?=EB=B3=B4=20=EC=A0=9C=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/StompSessionTimeoutConfig.java | 2 +- .../config/StompSessionTimeoutConfigTest.java | 30 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 src/test/java/com/example/solidconnection/chat/config/StompSessionTimeoutConfigTest.java diff --git a/src/main/java/com/example/solidconnection/chat/config/StompSessionTimeoutConfig.java b/src/main/java/com/example/solidconnection/chat/config/StompSessionTimeoutConfig.java index 147c74794..9a1ad8fc5 100644 --- a/src/main/java/com/example/solidconnection/chat/config/StompSessionTimeoutConfig.java +++ b/src/main/java/com/example/solidconnection/chat/config/StompSessionTimeoutConfig.java @@ -19,7 +19,7 @@ public ThreadPoolTaskScheduler stompSubscriptionTimeoutScheduler() { return scheduler; } - @Bean + @Bean(defaultCandidate = false) public TaskExecutor stompSessionCloseExecutor() { return new VirtualThreadTaskExecutor("stomp-session-close-"); } diff --git a/src/test/java/com/example/solidconnection/chat/config/StompSessionTimeoutConfigTest.java b/src/test/java/com/example/solidconnection/chat/config/StompSessionTimeoutConfigTest.java new file mode 100644 index 000000000..22a2733e5 --- /dev/null +++ b/src/test/java/com/example/solidconnection/chat/config/StompSessionTimeoutConfigTest.java @@ -0,0 +1,30 @@ +package com.example.solidconnection.chat.config; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.support.AbstractBeanDefinition; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.core.task.VirtualThreadTaskExecutor; + +@DisplayName("STOMP 세션 타임아웃 설정 테스트") +class StompSessionTimeoutConfigTest { + + @Test + void 세션_종료_실행기는_기본_실행기_후보에서_제외한다() { + // given + try (AnnotationConfigApplicationContext context = + new AnnotationConfigApplicationContext(StompSessionTimeoutConfig.class)) { + + // when + var beanDefinition = (AbstractBeanDefinition) context.getBeanFactory() + .getBeanDefinition("stompSessionCloseExecutor"); + var taskExecutor = context.getBean("stompSessionCloseExecutor"); + + // then + assertThat(beanDefinition.isDefaultCandidate()).isFalse(); + assertThat(taskExecutor).isInstanceOf(VirtualThreadTaskExecutor.class); + } + } +} From 737c16e169e45896417b436229dc6b1efa691d76 Mon Sep 17 00:00:00 2001 From: Hexeong <123macanic@naver.com> Date: Thu, 29 Oct 2026 11:05:59 +0900 Subject: [PATCH 5/7] =?UTF-8?q?fix:=20STOMP=20=EA=B5=AC=EB=8F=85=20?= =?UTF-8?q?=ED=83=80=EC=9E=84=EC=95=84=EC=9B=83=20=EC=9A=B0=ED=9A=8C=20?= =?UTF-8?q?=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/config/StompHandler.java | 4 ++ .../chat/config/StompSessionManager.java | 49 ++++++++++++++----- .../config/StompSessionTimeoutConfig.java | 2 +- .../chat/config/StompHandlerTest.java | 36 ++++++++++++++ .../chat/config/StompSessionManagerTest.java | 18 ++++++- .../config/StompSessionTimeoutConfigTest.java | 15 ++++++ 6 files changed, 111 insertions(+), 13 deletions(-) create mode 100644 src/test/java/com/example/solidconnection/chat/config/StompHandlerTest.java diff --git a/src/main/java/com/example/solidconnection/chat/config/StompHandler.java b/src/main/java/com/example/solidconnection/chat/config/StompHandler.java index 2e99bf9c4..4b78f8a6c 100644 --- a/src/main/java/com/example/solidconnection/chat/config/StompHandler.java +++ b/src/main/java/com/example/solidconnection/chat/config/StompHandler.java @@ -1,6 +1,7 @@ package com.example.solidconnection.chat.config; import static com.example.solidconnection.common.exception.ErrorCode.AUTHENTICATION_FAILED; +import static com.example.solidconnection.common.exception.ErrorCode.INVALID_INPUT; import com.example.solidconnection.chat.service.ChatService; import com.example.solidconnection.common.exception.CustomException; @@ -41,6 +42,9 @@ public Message preSend(Message message, MessageChannel channel) { if (user == null) { throw new CustomException(AUTHENTICATION_FAILED); } + if (accessor.getSubscriptionId() == null) { + throw new CustomException(INVALID_INPUT, "SUBSCRIBE id is required"); + } TokenAuthentication tokenAuthentication = (TokenAuthentication) user; SiteUserDetails siteUserDetails = (SiteUserDetails) tokenAuthentication.getPrincipal(); diff --git a/src/main/java/com/example/solidconnection/chat/config/StompSessionManager.java b/src/main/java/com/example/solidconnection/chat/config/StompSessionManager.java index a764c9170..a22e32bc0 100644 --- a/src/main/java/com/example/solidconnection/chat/config/StompSessionManager.java +++ b/src/main/java/com/example/solidconnection/chat/config/StompSessionManager.java @@ -3,6 +3,7 @@ import java.io.IOException; import java.time.Instant; import java.util.Map; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ScheduledFuture; import lombok.extern.slf4j.Slf4j; @@ -22,6 +23,8 @@ public class StompSessionManager { private final Map sessions = new ConcurrentHashMap<>(); private final Map subscriptionTimeoutTasks = new ConcurrentHashMap<>(); + private final Set connectedSessions = ConcurrentHashMap.newKeySet(); + private final Set subscribedSessions = ConcurrentHashMap.newKeySet(); private final TaskScheduler stompSubscriptionTimeoutScheduler; private final TaskExecutor stompSessionCloseExecutor; private final StompProperties stompProperties; @@ -36,12 +39,24 @@ public StompSessionManager( this.stompProperties = stompProperties; } - public void registerSession(WebSocketSession session) { + public synchronized void registerSession(WebSocketSession session) { sessions.put(session.getId(), session); - startSubscriptionTimeout(session.getId()); + connectedSessions.remove(session.getId()); + subscribedSessions.remove(session.getId()); + scheduleSubscriptionTimeout(session.getId()); } - public void startSubscriptionTimeout(String sessionId) { + public synchronized void startSubscriptionTimeout(String sessionId) { + if (!sessions.containsKey(sessionId) + || subscribedSessions.contains(sessionId) + || !connectedSessions.add(sessionId)) { + return; + } + + scheduleSubscriptionTimeout(sessionId); + } + + private void scheduleSubscriptionTimeout(String sessionId) { Object taskToken = new Object(); Instant timeoutAt = Instant.now().plus(stompProperties.subscriptionTimeout()); ScheduledFuture timeoutTask = stompSubscriptionTimeoutScheduler.schedule( @@ -56,19 +71,26 @@ public void startSubscriptionTimeout(String sessionId) { } private void closeUnsubscribedSession(String sessionId, Object taskToken) { + WebSocketSession session = removeUnsubscribedSession(sessionId, taskToken); + if (session == null || !session.isOpen()) { + return; + } + + stompSessionCloseExecutor.execute(() -> closeSession(sessionId, session)); + } + + private synchronized WebSocketSession removeUnsubscribedSession(String sessionId, Object taskToken) { SubscriptionTimeoutTask timeoutTask = subscriptionTimeoutTasks.get(sessionId); if (timeoutTask == null || timeoutTask.token() != taskToken || !subscriptionTimeoutTasks.remove(sessionId, timeoutTask)) { - return; + return null; } WebSocketSession session = sessions.remove(sessionId); - if (session == null || !session.isOpen()) { - return; - } - - stompSessionCloseExecutor.execute(() -> closeSession(sessionId, session)); + connectedSessions.remove(sessionId); + subscribedSessions.remove(sessionId); + return session; } private void closeSession(String sessionId, WebSocketSession session) { @@ -80,11 +102,16 @@ private void closeSession(String sessionId, WebSocketSession session) { } } - public void completeSubscription(String sessionId) { + public synchronized void completeSubscription(String sessionId) { + if (sessions.containsKey(sessionId)) { + subscribedSessions.add(sessionId); + } cancel(subscriptionTimeoutTasks.remove(sessionId)); } - public void removeSession(String sessionId) { + public synchronized void removeSession(String sessionId) { + connectedSessions.remove(sessionId); + subscribedSessions.remove(sessionId); cancel(subscriptionTimeoutTasks.remove(sessionId)); sessions.remove(sessionId); } diff --git a/src/main/java/com/example/solidconnection/chat/config/StompSessionTimeoutConfig.java b/src/main/java/com/example/solidconnection/chat/config/StompSessionTimeoutConfig.java index 9a1ad8fc5..2c2444bc1 100644 --- a/src/main/java/com/example/solidconnection/chat/config/StompSessionTimeoutConfig.java +++ b/src/main/java/com/example/solidconnection/chat/config/StompSessionTimeoutConfig.java @@ -9,7 +9,7 @@ @Configuration public class StompSessionTimeoutConfig { - @Bean + @Bean(defaultCandidate = false) public ThreadPoolTaskScheduler stompSubscriptionTimeoutScheduler() { ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); scheduler.setPoolSize(1); diff --git a/src/test/java/com/example/solidconnection/chat/config/StompHandlerTest.java b/src/test/java/com/example/solidconnection/chat/config/StompHandlerTest.java new file mode 100644 index 000000000..3ec83c830 --- /dev/null +++ b/src/test/java/com/example/solidconnection/chat/config/StompHandlerTest.java @@ -0,0 +1,36 @@ +package com.example.solidconnection.chat.config; + +import static com.example.solidconnection.common.exception.ErrorCode.INVALID_INPUT; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.example.solidconnection.chat.service.ChatService; +import com.example.solidconnection.common.exception.CustomException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.support.MessageBuilder; + +@DisplayName("STOMP 메시지 인터셉터 테스트") +class StompHandlerTest { + + @Test + void 구독_ID가_없는_SUBSCRIBE는_거부한다() { + // given + ChatService chatService = org.mockito.Mockito.mock(ChatService.class); + MessageChannel messageChannel = org.mockito.Mockito.mock(MessageChannel.class); + StompHandler stompHandler = new StompHandler(chatService); + StompHeaderAccessor accessor = StompHeaderAccessor.create(StompCommand.SUBSCRIBE); + accessor.setUser(() -> "user"); + accessor.setDestination("/topic/chat/1"); + Message message = MessageBuilder.createMessage(new byte[0], accessor.getMessageHeaders()); + + // when & then + assertThatThrownBy(() -> stompHandler.preSend(message, messageChannel)) + .isInstanceOfSatisfying(CustomException.class, + exception -> org.assertj.core.api.Assertions.assertThat(exception.getErrorCode()) + .isEqualTo(INVALID_INPUT)); + } +} diff --git a/src/test/java/com/example/solidconnection/chat/config/StompSessionManagerTest.java b/src/test/java/com/example/solidconnection/chat/config/StompSessionManagerTest.java index 8354498f0..3f7d30ab9 100644 --- a/src/test/java/com/example/solidconnection/chat/config/StompSessionManagerTest.java +++ b/src/test/java/com/example/solidconnection/chat/config/StompSessionManagerTest.java @@ -103,7 +103,7 @@ void setUp() { } @Test - void STOMP_연결이_수립되면_구독_제한시간을_다시_계산하고_이전_작업은_무시한다() throws Exception { + void 첫_CONNECT에서만_구독_제한시간을_다시_계산한다() throws Exception { // given doReturn(timeoutTask, renewedTimeoutTask) .when(scheduler).schedule(any(Runnable.class), any(Instant.class)); @@ -112,6 +112,7 @@ void setUp() { // when stompSessionManager.startSubscriptionTimeout(SESSION_ID); + stompSessionManager.startSubscriptionTimeout(SESSION_ID); then(scheduler).should(times(2)).schedule(timeoutActionCaptor.capture(), any(Instant.class)); timeoutActionCaptor.getAllValues().getFirst().run(); @@ -126,6 +127,21 @@ void setUp() { then(renewedTimeoutTask).should().cancel(false); } + @Test + void 구독이_완료된_뒤에는_지연된_CONNECT가_타임아웃을_등록하지_않는다() { + // given + doReturn(timeoutTask).when(scheduler).schedule(any(Runnable.class), any(Instant.class)); + stompSessionManager.registerSession(session); + stompSessionManager.completeSubscription(SESSION_ID); + + // when + stompSessionManager.startSubscriptionTimeout(SESSION_ID); + + // then + then(scheduler).should(times(1)).schedule(any(Runnable.class), any(Instant.class)); + then(timeoutTask).should().cancel(false); + } + @Test void 연결이_종료되면_예약_작업과_세션을_정리한다() throws Exception { // given diff --git a/src/test/java/com/example/solidconnection/chat/config/StompSessionTimeoutConfigTest.java b/src/test/java/com/example/solidconnection/chat/config/StompSessionTimeoutConfigTest.java index 22a2733e5..616436204 100644 --- a/src/test/java/com/example/solidconnection/chat/config/StompSessionTimeoutConfigTest.java +++ b/src/test/java/com/example/solidconnection/chat/config/StompSessionTimeoutConfigTest.java @@ -11,6 +11,21 @@ @DisplayName("STOMP 세션 타임아웃 설정 테스트") class StompSessionTimeoutConfigTest { + @Test + void 구독_타임아웃_스케줄러는_기본_실행기_후보에서_제외한다() { + // given + try (AnnotationConfigApplicationContext context = + new AnnotationConfigApplicationContext(StompSessionTimeoutConfig.class)) { + + // when + var beanDefinition = (AbstractBeanDefinition) context.getBeanFactory() + .getBeanDefinition("stompSubscriptionTimeoutScheduler"); + + // then + assertThat(beanDefinition.isDefaultCandidate()).isFalse(); + } + } + @Test void 세션_종료_실행기는_기본_실행기_후보에서_제외한다() { // given From 0e7ae89af19fcf108839ff6e4c4389a009c26b30 Mon Sep 17 00:00:00 2001 From: Hexeong <123macanic@naver.com> Date: Thu, 29 Oct 2026 11:34:25 +0900 Subject: [PATCH 6/7] =?UTF-8?q?fix:=20STOMP=20=EA=B5=AC=EB=8F=85=20?= =?UTF-8?q?=ED=83=80=EC=9E=84=EC=95=84=EC=9B=83=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/config/StompProperties.java | 8 +++ .../chat/config/StompHandlerTest.java | 36 ++++++++------ .../chat/config/StompPropertiesTest.java | 49 +++++++++++++++++++ .../chat/config/StompSessionManagerTest.java | 34 ++++++++++++- 4 files changed, 111 insertions(+), 16 deletions(-) create mode 100644 src/test/java/com/example/solidconnection/chat/config/StompPropertiesTest.java diff --git a/src/main/java/com/example/solidconnection/chat/config/StompProperties.java b/src/main/java/com/example/solidconnection/chat/config/StompProperties.java index 4c5c0612f..8d037ee87 100644 --- a/src/main/java/com/example/solidconnection/chat/config/StompProperties.java +++ b/src/main/java/com/example/solidconnection/chat/config/StompProperties.java @@ -10,6 +10,14 @@ public record StompProperties( Duration subscriptionTimeout ) { + public StompProperties { + if (subscriptionTimeout == null + || subscriptionTimeout.isZero() + || subscriptionTimeout.isNegative()) { + throw new IllegalArgumentException("websocket.subscription-timeout must be a positive duration"); + } + } + public record ThreadPool(InboundProperties inbound, OutboundProperties outbound) { } diff --git a/src/test/java/com/example/solidconnection/chat/config/StompHandlerTest.java b/src/test/java/com/example/solidconnection/chat/config/StompHandlerTest.java index 3ec83c830..121a1f15e 100644 --- a/src/test/java/com/example/solidconnection/chat/config/StompHandlerTest.java +++ b/src/test/java/com/example/solidconnection/chat/config/StompHandlerTest.java @@ -6,6 +6,7 @@ import com.example.solidconnection.chat.service.ChatService; import com.example.solidconnection.common.exception.CustomException; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.messaging.Message; import org.springframework.messaging.MessageChannel; @@ -16,21 +17,26 @@ @DisplayName("STOMP 메시지 인터셉터 테스트") class StompHandlerTest { - @Test - void 구독_ID가_없는_SUBSCRIBE는_거부한다() { - // given - ChatService chatService = org.mockito.Mockito.mock(ChatService.class); - MessageChannel messageChannel = org.mockito.Mockito.mock(MessageChannel.class); - StompHandler stompHandler = new StompHandler(chatService); - StompHeaderAccessor accessor = StompHeaderAccessor.create(StompCommand.SUBSCRIBE); - accessor.setUser(() -> "user"); - accessor.setDestination("/topic/chat/1"); - Message message = MessageBuilder.createMessage(new byte[0], accessor.getMessageHeaders()); + @Nested + @DisplayName("채팅방 구독") + class 채팅방을_구독한다 { - // when & then - assertThatThrownBy(() -> stompHandler.preSend(message, messageChannel)) - .isInstanceOfSatisfying(CustomException.class, - exception -> org.assertj.core.api.Assertions.assertThat(exception.getErrorCode()) - .isEqualTo(INVALID_INPUT)); + @Test + void 구독_ID가_없는_SUBSCRIBE는_거부한다() { + // given + ChatService chatService = org.mockito.Mockito.mock(ChatService.class); + MessageChannel messageChannel = org.mockito.Mockito.mock(MessageChannel.class); + StompHandler stompHandler = new StompHandler(chatService); + StompHeaderAccessor accessor = StompHeaderAccessor.create(StompCommand.SUBSCRIBE); + accessor.setUser(() -> "user"); + accessor.setDestination("/topic/chat/1"); + Message message = MessageBuilder.createMessage(new byte[0], accessor.getMessageHeaders()); + + // when & then + assertThatThrownBy(() -> stompHandler.preSend(message, messageChannel)) + .isInstanceOfSatisfying(CustomException.class, + exception -> org.assertj.core.api.Assertions.assertThat(exception.getErrorCode()) + .isEqualTo(INVALID_INPUT)); + } } } diff --git a/src/test/java/com/example/solidconnection/chat/config/StompPropertiesTest.java b/src/test/java/com/example/solidconnection/chat/config/StompPropertiesTest.java new file mode 100644 index 000000000..29b6f3ba1 --- /dev/null +++ b/src/test/java/com/example/solidconnection/chat/config/StompPropertiesTest.java @@ -0,0 +1,49 @@ +package com.example.solidconnection.chat.config; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("STOMP 설정 테스트") +class StompPropertiesTest { + + @Nested + @DisplayName("구독 제한시간 검증") + class 구독_제한시간을_검증한다 { + + @Test + void 양수이면_설정을_생성한다() { + // when & then + assertThatCode(() -> new StompProperties(null, null, Duration.ofSeconds(30))) + .doesNotThrowAnyException(); + } + + @Test + void 값이_없으면_설정_생성을_거부한다() { + // when & then + assertThatThrownBy(() -> new StompProperties(null, null, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("websocket.subscription-timeout must be a positive duration"); + } + + @Test + void 영이면_설정_생성을_거부한다() { + // when & then + assertThatThrownBy(() -> new StompProperties(null, null, Duration.ZERO)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("websocket.subscription-timeout must be a positive duration"); + } + + @Test + void 음수이면_설정_생성을_거부한다() { + // when & then + assertThatThrownBy(() -> new StompProperties(null, null, Duration.ofSeconds(-1))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("websocket.subscription-timeout must be a positive duration"); + } + } +} diff --git a/src/test/java/com/example/solidconnection/chat/config/StompSessionManagerTest.java b/src/test/java/com/example/solidconnection/chat/config/StompSessionManagerTest.java index 3f7d30ab9..c1693a051 100644 --- a/src/test/java/com/example/solidconnection/chat/config/StompSessionManagerTest.java +++ b/src/test/java/com/example/solidconnection/chat/config/StompSessionManagerTest.java @@ -1,5 +1,6 @@ package com.example.solidconnection.chat.config; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; @@ -57,6 +58,12 @@ void setUp() { @Test void 제한시간까지_구독하지_않으면_세션을_종료한다() throws Exception { // given + RecordingTaskExecutor recordingTaskExecutor = new RecordingTaskExecutor(); + stompSessionManager = new StompSessionManager( + scheduler, + recordingTaskExecutor, + new StompProperties(null, null, SUBSCRIPTION_TIMEOUT) + ); given(session.isOpen()).willReturn(true); doReturn(timeoutTask).when(scheduler).schedule(any(Runnable.class), any(Instant.class)); stompSessionManager.registerSession(session); @@ -66,6 +73,13 @@ void setUp() { // when timeoutActionCaptor.getValue().run(); + // then + assertThat(recordingTaskExecutor.task).isNotNull(); + then(session).should(never()).close(any(CloseStatus.class)); + + // when + recordingTaskExecutor.task.run(); + // then then(session).should().close(any(CloseStatus.class)); } @@ -109,11 +123,19 @@ void setUp() { .when(scheduler).schedule(any(Runnable.class), any(Instant.class)); stompSessionManager.registerSession(session); ArgumentCaptor timeoutActionCaptor = ArgumentCaptor.forClass(Runnable.class); + ArgumentCaptor timeoutAtCaptor = ArgumentCaptor.forClass(Instant.class); // when + Instant beforeConnect = Instant.now(); stompSessionManager.startSubscriptionTimeout(SESSION_ID); + Instant afterConnect = Instant.now(); stompSessionManager.startSubscriptionTimeout(SESSION_ID); - then(scheduler).should(times(2)).schedule(timeoutActionCaptor.capture(), any(Instant.class)); + then(scheduler).should(times(2)).schedule(timeoutActionCaptor.capture(), timeoutAtCaptor.capture()); + assertThat(timeoutAtCaptor.getAllValues().getLast()) + .isBetween( + beforeConnect.plus(SUBSCRIPTION_TIMEOUT), + afterConnect.plus(SUBSCRIPTION_TIMEOUT) + ); timeoutActionCaptor.getAllValues().getFirst().run(); // then @@ -159,4 +181,14 @@ void setUp() { then(timeoutTask).should().cancel(false); then(session).should(never()).close(any(CloseStatus.class)); } + + private static class RecordingTaskExecutor implements TaskExecutor { + + private Runnable task; + + @Override + public void execute(Runnable task) { + this.task = task; + } + } } From af1529df99dd150b950b1adb3143a557b46b88f8 Mon Sep 17 00:00:00 2001 From: Hexeong <123macanic@naver.com> Date: Thu, 29 Oct 2026 11:46:59 +0900 Subject: [PATCH 7/7] =?UTF-8?q?docs:=20CLAUDE=20=EB=A1=9C=EC=BB=AC=20?= =?UTF-8?q?=EC=A7=80=EC=B9=A8=20=EC=95=88=EB=82=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b0534492d..60d102093 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,6 +2,8 @@ 이 파일은 Claude Code가 solid-connect-server 저장소에서 작업할 때 참고하는 가이드입니다. +> 해당 지시문서가 위치한 폴더에 [CLAUDE.local.md](http://CLAUDE.local.md) 문서 파일이 있다면 해당 파일을 읽어 Claude Code 개인 지침을 확인하세요. + ## 프로젝트 개요 Solid Connect Server는 교환학생 준비생을 위해 대학 정보, 멘토 매칭, 모의지원 기능 등을 제공하는 교환학생 지원 통합 플랫폼입니다. @@ -180,7 +182,7 @@ public class UserCreateResponse { ... - **Redis**: 캐싱 저장소 - **Flyway**: 데이터베이스 버전 관리 -### 모니터링 & 보안 +### 모니터링 & 보안 - **Spring Boot Actuator**: 애플리케이션 모니터링 - **Prometheus**: 메트릭 수집 @@ -319,8 +321,9 @@ public interface ChatRoomRepository extends JpaRepository { ## 주요 파일 위치 + | 파일/폴더 | 설명 | -|----------------------------------------------|---------------| +| -------------------------------------------- | ------------- | | `src/main/java/com/example/solidconnection/` | 메인 소스 코드 | | `src/test/java/com/example/solidconnection/` | 테스트 코드 | | `src/main/resources/db/migration/` | Flyway 마이그레이션 | @@ -328,6 +331,7 @@ public interface ChatRoomRepository extends JpaRepository { | `docker-compose.*.yml` | 환경별 도커 설정 | | `build.gradle` | Gradle 빌드 설정 | + --- ### 프로필 @@ -370,12 +374,12 @@ public interface ChatRoomRepository extends JpaRepository { ## 참고 자료 -- **개발 컨벤션**: https://github.com/solid-connection/solid-connect-server/wiki/개발-컨벤션-정리 +- **개발 컨벤션**: [https://github.com/solid-connection/solid-connect-server/wiki/개발-컨벤션-정리](https://github.com/solid-connection/solid-connect-server/wiki/개발-컨벤션-정리) - **테스트 가이드**: `test.md` 파일 참고 -- **Spring Boot**: https://spring.io/projects/spring-boot -- **JPA**: https://spring.io/projects/spring-data-jpa -- **TestContainers**: https://www.testcontainers.org/ -- **Flyway**: https://flywaydb.org/ +- **Spring Boot**: [https://spring.io/projects/spring-boot](https://spring.io/projects/spring-boot) +- **JPA**: [https://spring.io/projects/spring-data-jpa](https://spring.io/projects/spring-data-jpa) +- **TestContainers**: [https://www.testcontainers.org/](https://www.testcontainers.org/) +- **Flyway**: [https://flywaydb.org/](https://flywaydb.org/) --- @@ -385,4 +389,4 @@ public interface ChatRoomRepository extends JpaRepository { 2. **QueryDSL Q클래스는 자동 생성** - 수동 수정 금지 3. **테스트는 독립적** - 테스트 간 데이터 공유 불가 4. **환경별 설정 분리** - application-local.yml, application-dev.yml, application-prod.yml -5. **한국어 메서드명** - 테스트 가독성 향상을 위해 사용 +5. **한국어 메서드명** - 테스트 가독성 향상을 위해 사용 \ No newline at end of file