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 92% rename from claude.md rename to 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 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/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/StompProperties.java b/src/main/java/com/example/solidconnection/chat/config/StompProperties.java index ce9663c72..8d037ee87 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,22 @@ 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 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) { @@ -20,4 +33,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..a22e32bc0 --- /dev/null +++ b/src/main/java/com/example/solidconnection/chat/config/StompSessionManager.java @@ -0,0 +1,130 @@ +package com.example.solidconnection.chat.config; + +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; +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; +import org.springframework.web.socket.WebSocketSession; + +@Slf4j +@Component +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 Set connectedSessions = ConcurrentHashMap.newKeySet(); + private final Set subscribedSessions = ConcurrentHashMap.newKeySet(); + 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 synchronized void registerSession(WebSocketSession session) { + sessions.put(session.getId(), session); + connectedSessions.remove(session.getId()); + subscribedSessions.remove(session.getId()); + scheduleSubscriptionTimeout(session.getId()); + } + + 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( + () -> closeUnsubscribedSession(sessionId, taskToken), + timeoutAt + ); + SubscriptionTimeoutTask previousTask = subscriptionTimeoutTasks.put( + sessionId, + new SubscriptionTimeoutTask(taskToken, timeoutTask) + ); + cancel(previousTask); + } + + 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 null; + } + + WebSocketSession session = sessions.remove(sessionId); + connectedSessions.remove(sessionId); + subscribedSessions.remove(sessionId); + return 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 | RuntimeException e) { + log.warn("Failed to close unsubscribed STOMP session. sessionId={}", sessionId, e); + } + } + + public synchronized void completeSubscription(String sessionId) { + if (sessions.containsKey(sessionId)) { + subscribedSessions.add(sessionId); + } + cancel(subscriptionTimeoutTasks.remove(sessionId)); + } + + public synchronized void removeSession(String sessionId) { + connectedSessions.remove(sessionId); + subscribedSessions.remove(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..2c2444bc1 --- /dev/null +++ b/src/main/java/com/example/solidconnection/chat/config/StompSessionTimeoutConfig.java @@ -0,0 +1,26 @@ +package com.example.solidconnection.chat.config; + +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 +public class StompSessionTimeoutConfig { + + @Bean(defaultCandidate = false) + public ThreadPoolTaskScheduler stompSubscriptionTimeoutScheduler() { + ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); + scheduler.setPoolSize(1); + scheduler.setThreadNamePrefix("stomp-subscription-timeout-"); + scheduler.setRemoveOnCancelPolicy(true); + scheduler.setWaitForTasksToCompleteOnShutdown(false); + return scheduler; + } + + @Bean(defaultCandidate = false) + public TaskExecutor stompSessionCloseExecutor() { + return new VirtualThreadTaskExecutor("stomp-session-close-"); + } +} 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/StompHandlerTest.java b/src/test/java/com/example/solidconnection/chat/config/StompHandlerTest.java new file mode 100644 index 000000000..121a1f15e --- /dev/null +++ b/src/test/java/com/example/solidconnection/chat/config/StompHandlerTest.java @@ -0,0 +1,42 @@ +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.Nested; +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 { + + @Nested + @DisplayName("채팅방 구독") + class 채팅방을_구독한다 { + + @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 new file mode 100644 index 000000000..c1693a051 --- /dev/null +++ b/src/test/java/com/example/solidconnection/chat/config/StompSessionManagerTest.java @@ -0,0 +1,194 @@ +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; +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; + +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.core.task.TaskExecutor; +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); + TaskExecutor directExecutor = Runnable::run; + stompSessionManager = new StompSessionManager(scheduler, directExecutor, stompProperties); + + given(session.getId()).willReturn(SESSION_ID); + } + + @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); + ArgumentCaptor timeoutActionCaptor = ArgumentCaptor.forClass(Runnable.class); + then(scheduler).should().schedule(timeoutActionCaptor.capture(), any(Instant.class)); + + // 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)); + } + + @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 + 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 첫_CONNECT에서만_구독_제한시간을_다시_계산한다() throws Exception { + // given + doReturn(timeoutTask, renewedTimeoutTask) + .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(), timeoutAtCaptor.capture()); + assertThat(timeoutAtCaptor.getAllValues().getLast()) + .isBetween( + beforeConnect.plus(SUBSCRIPTION_TIMEOUT), + afterConnect.plus(SUBSCRIPTION_TIMEOUT) + ); + 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 구독이_완료된_뒤에는_지연된_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 + 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)); + } + + private static class RecordingTaskExecutor implements TaskExecutor { + + private Runnable task; + + @Override + public void execute(Runnable task) { + this.task = task; + } + } +} 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..616436204 --- /dev/null +++ b/src/test/java/com/example/solidconnection/chat/config/StompSessionTimeoutConfigTest.java @@ -0,0 +1,45 @@ +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("stompSubscriptionTimeoutScheduler"); + + // then + assertThat(beanDefinition.isDefaultCandidate()).isFalse(); + } + } + + @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); + } + } +} 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