From b3d1a799fbfdb60992d70acbce8b0eb0b7c14d12 Mon Sep 17 00:00:00 2001 From: Minhyung Kim <127458006+minbros@users.noreply.github.com> Date: Mon, 8 Jun 2026 00:44:03 +0900 Subject: [PATCH 1/9] =?UTF-8?q?feat:=20=EC=9E=A5=EC=86=8C=20=EB=B0=8F=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=EB=B2=8C=ED=81=AC=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?API=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EB=B9=84=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=20=EA=B0=80=EC=83=81=20=EC=8A=A4=EB=A0=88=EB=93=9C=20?= =?UTF-8?q?=EC=B5=9C=EC=A0=81=ED=99=94=20(#132)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 방 초기 조회 N+1 완화 * refactor: 조회 API 변경 코드 컨벤션 반영 * feat: 장소 벌크 조회와 사진 URL 캐시 추가 * feat: 경로 벌크 조회 API 추가 * feat: 장소 벌크 조회 실패 처리 변경 * feat: 장소 대표 사진 이름 벌크 조회 API 구현 - POST /places/photo-names/batch API 신규 구현 - PlacePhotoNameService에 대표 사진 이름 목록 병렬 벌크 조회 로직 추가 (taskExecutor 활용, 중복 ID 제거 및 요청 순서 유지, 부분 실패 대응) - HttpRateLimitPolicyResolver에 벌크 조회 경로를 rate limit 대상으로 추가 - 관련 컨트롤러 테스트, 서비스/통합 테스트 작성 및 docs/ai/features.md 명세 업데이트 * feat: 방 초기화 속도 개선을 위한 Rate Limit 정책 단건/벌크 분리 및 경로 매핑 수정 * feat: 사진 URI 조회 API에 대한 Rate Limit 별도 분리 및 임계치 설정 * refactor: 비동기 실행기 분리 및 가상 스레드 적용 * refactor: PR 리뷰 코멘트 반영 및 Google API Executor Lombok 주입 리팩토링 - RedisBulkCacheAccessor Jackson Serializer 캐싱 최적화 - DTO status/errorCode 스펙 정비 및 결합도 완화 - PlacePhotoService 등 Executor Lombok RequiredArgsConstructor 주입 적용 - Rate Limit 정책 예외 조건 보강 및 테스트 코드 최신화 * refactor: 비동기 배치 조회 예외 처리 공통화 및 AsyncHelper 적용 - 비동기 CompletableFuture join 시 외부 API 에러(EXTERNAL_API_ERROR) 복구 로직을 AsyncHelper로 공통화 - PlacePreviewService, PlacePhotoNameService, PlacePhotoService의 중복 예외 처리 및 join 헬퍼 메서드 제거 - Lombok 어노테이션(@NoArgsConstructor)을 활용한 AsyncHelper 인스턴스화 방지 처리 --- docs/ai/erd.md | 8 +- docs/ai/features.md | 7 +- .../2026-06-05-schedule-structure-events.md | 1 - .../plans/2026-06-07-pr-review-refactoring.md | 448 ++++++++++++++++++ .../2026-06-07-rate-limit-photo-split.md | 143 ++++++ .../plans/2026-06-07-rate-limit-redesign.md | 238 ++++++++++ ...06-05-room-dates-schedule-resync-design.md | 2 +- .../ai/listener/AiSummaryTriggerListener.java | 3 +- .../ai/service/AiRequestQueueWorker.java | 3 +- .../controller/BookmarkController.java | 69 +-- .../repository/BookmarkRepository.java | 3 + .../bookmarks/service/BookmarkService.java | 67 +-- .../common/cache/RedisBulkCacheAccessor.java | 76 +++ .../backend/common/config/AsyncConfig.java | 41 +- .../backend/common/config/CachePolicy.java | 2 +- .../properties/AsyncExecutorProperties.java | 37 ++ .../properties/RateLimitProperties.java | 5 +- .../HttpRateLimitPolicyResolver.java | 55 ++- .../backend/common/utils/AsyncHelper.java | 47 ++ .../places/controller/PlaceController.java | 74 ++- .../dto/PlacePhotoBatchItemResponse.java | 32 ++ .../dto/PlacePhotoBatchRequest.java | 20 + .../dto/PlacePhotoBatchResponse.java | 20 + .../dto/PlacePhotoNameBatchItemResponse.java | 33 ++ .../dto/PlacePhotoNameBatchRequest.java | 20 + .../dto/PlacePhotoNameBatchResponse.java | 21 + .../controller/dto/PlacePhotoResponse.java | 3 + .../dto/PlacePreviewBatchItemResponse.java | 63 +++ .../dto/PlacePreviewBatchRequest.java | 19 + .../dto/PlacePreviewBatchResponse.java | 20 + .../places/service/PlacePhotoNameService.java | 37 ++ .../places/service/PlacePhotoService.java | 105 +++- .../places/service/PlacePreviewService.java | 79 +++ .../dto/PlacePhotoBatchItemResult.java | 17 + .../dto/PlacePhotoNameBatchItemResult.java | 17 + .../dto/PlacePreviewBatchItemResult.java | 42 ++ .../controller/ScheduleController.java | 12 +- .../controller/ScheduleRouteController.java | 55 +++ .../controller/dto/BatchRouteItemRequest.java | 30 ++ .../dto/BatchRouteItemResponse.java | 48 ++ .../controller/dto/BatchRouteRequest.java | 25 + .../controller/dto/BatchRouteResponse.java | 19 + .../controller/dto/ScheduleResponse.java | 40 +- .../repository/ScheduleItemRepository.java | 7 +- .../repository/ScheduleRepository.java | 3 - .../service/ScheduleItemService.java | 47 ++ .../schedules/service/ScheduleService.java | 31 ++ .../service/dto/RouteBatchItemCommand.java | 7 + .../service/dto/RouteBatchItemResult.java | 37 ++ .../service/dto/ScheduleWithItemsResult.java | 9 + src/main/resources/application.yaml | 27 +- .../bookmarks/BookmarkIntegrationTest.java | 7 + .../controller/BookmarkControllerTest.java | 15 + .../service/BookmarkServiceTest.java | 173 ++++--- .../common/config/AsyncConfigTest.java | 46 ++ .../HttpRateLimitPolicyResolverTest.java | 77 ++- .../service/MessageRateLimiterTest.java | 25 +- .../controller/PlaceControllerTest.java | 247 ++++++++++ .../service/PlaceDetailCachingTest.java | 137 +++++- .../places/service/PlacePhotoCachingTest.java | 77 ++- .../places/service/PlacePhotoServiceTest.java | 29 +- .../schedules/ScheduleIntegrationTest.java | 9 + .../controller/ScheduleControllerTest.java | 33 ++ .../ScheduleRouteControllerTest.java | 129 +++++ .../service/ScheduleItemServiceTest.java | 72 +++ .../service/ScheduleServiceTest.java | 46 +- 66 files changed, 3170 insertions(+), 226 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-07-pr-review-refactoring.md create mode 100644 docs/superpowers/plans/2026-06-07-rate-limit-photo-split.md create mode 100644 docs/superpowers/plans/2026-06-07-rate-limit-redesign.md create mode 100644 src/main/java/com/howaboutus/backend/common/cache/RedisBulkCacheAccessor.java create mode 100644 src/main/java/com/howaboutus/backend/common/config/properties/AsyncExecutorProperties.java create mode 100644 src/main/java/com/howaboutus/backend/common/utils/AsyncHelper.java create mode 100644 src/main/java/com/howaboutus/backend/places/controller/dto/PlacePhotoBatchItemResponse.java create mode 100644 src/main/java/com/howaboutus/backend/places/controller/dto/PlacePhotoBatchRequest.java create mode 100644 src/main/java/com/howaboutus/backend/places/controller/dto/PlacePhotoBatchResponse.java create mode 100644 src/main/java/com/howaboutus/backend/places/controller/dto/PlacePhotoNameBatchItemResponse.java create mode 100644 src/main/java/com/howaboutus/backend/places/controller/dto/PlacePhotoNameBatchRequest.java create mode 100644 src/main/java/com/howaboutus/backend/places/controller/dto/PlacePhotoNameBatchResponse.java create mode 100644 src/main/java/com/howaboutus/backend/places/controller/dto/PlacePreviewBatchItemResponse.java create mode 100644 src/main/java/com/howaboutus/backend/places/controller/dto/PlacePreviewBatchRequest.java create mode 100644 src/main/java/com/howaboutus/backend/places/controller/dto/PlacePreviewBatchResponse.java create mode 100644 src/main/java/com/howaboutus/backend/places/service/dto/PlacePhotoBatchItemResult.java create mode 100644 src/main/java/com/howaboutus/backend/places/service/dto/PlacePhotoNameBatchItemResult.java create mode 100644 src/main/java/com/howaboutus/backend/places/service/dto/PlacePreviewBatchItemResult.java create mode 100644 src/main/java/com/howaboutus/backend/schedules/controller/ScheduleRouteController.java create mode 100644 src/main/java/com/howaboutus/backend/schedules/controller/dto/BatchRouteItemRequest.java create mode 100644 src/main/java/com/howaboutus/backend/schedules/controller/dto/BatchRouteItemResponse.java create mode 100644 src/main/java/com/howaboutus/backend/schedules/controller/dto/BatchRouteRequest.java create mode 100644 src/main/java/com/howaboutus/backend/schedules/controller/dto/BatchRouteResponse.java create mode 100644 src/main/java/com/howaboutus/backend/schedules/service/dto/RouteBatchItemCommand.java create mode 100644 src/main/java/com/howaboutus/backend/schedules/service/dto/RouteBatchItemResult.java create mode 100644 src/main/java/com/howaboutus/backend/schedules/service/dto/ScheduleWithItemsResult.java create mode 100644 src/test/java/com/howaboutus/backend/common/config/AsyncConfigTest.java create mode 100644 src/test/java/com/howaboutus/backend/schedules/controller/ScheduleRouteControllerTest.java diff --git a/docs/ai/erd.md b/docs/ai/erd.md index efd87795..7252d51d 100644 --- a/docs/ai/erd.md +++ b/docs/ai/erd.md @@ -191,7 +191,7 @@ AI_RESPONSE metadata는 `requestMessageId`, `intent`, `placeRecommendation`, `co > **현재 구현 범위:** 장소 추가/조회/삭제, 시간 설정, 메모 수정, D&D 순서 변경, 이동 정보 조회를 제공합니다. 항목 삭제·순서 변경 시 남은 `order_index`는 0부터 연속되도록 재정렬합니다. > -> **이동 정보 흐름:** `distance_meters`, `duration_seconds`는 Google Maps Platform 정책상 DB에 영구 저장할 수 없습니다. 서버가 Google Routes API(Compute Routes)를 프록시하여 결과를 직접 클라이언트에 반환하며, Redis에 10분 TTL로 임시 캐시합니다(`route::{origin}:{dest}:{mode}`). 동시 cache miss는 Redis single-flight lock으로 대표 요청 1개만 Google Routes API를 호출하고, 나머지 요청은 route 캐시 또는 짧은 no-route 신호를 기다립니다. 이동 수단은 서버 DB에 저장하지 않고 클라이언트가 로컬에서 관리하며, 경로 조회 시 `travelMode` 요청 파라미터로 전달합니다. 마지막 장소는 다음 장소가 없으므로 이동 정보를 반환하지 않습니다(204). Google Routes가 경로를 찾지 못한 구간도 이동 정보를 반환하지 않습니다(204). +> **이동 정보 흐름:** `distance_meters`, `duration_seconds`는 Google Maps Platform 정책상 DB에 영구 저장할 수 없습니다. 서버가 Google Routes API(Compute Routes)를 프록시하여 결과를 직접 클라이언트에 반환하며, Redis에 10분 TTL로 임시 캐시합니다(`route::{origin}:{dest}:{mode}`). 동시 cache miss는 Redis single-flight lock으로 대표 요청 1개만 Google Routes API를 호출하고, 나머지 요청은 route 캐시 또는 짧은 no-route 신호를 기다립니다. 이동 수단은 서버 DB에 저장하지 않고 클라이언트가 로컬에서 관리합니다. 단건 경로 조회는 `travelMode` 요청 파라미터로, 벌크 경로 조회는 요청 body의 항목별 `travelMode`로 이동 수단을 전달합니다. 마지막 장소는 다음 장소가 없으므로 단건 조회에서는 이동 정보를 반환하지 않습니다(204, 응답 헤더 `X-Route-Status: LAST_PLACE`). Google Routes가 경로를 찾지 못한 구간도 단건 조회에서는 이동 정보를 반환하지 않습니다(204, 응답 헤더 `X-Route-Status: NO_ROUTE`). 벌크 조회는 요청 자체가 유효하면 200을 반환하고, 마지막 항목·경로 없음·일시 실패 같은 구간별 결과는 각 route의 `status`/`errorCode`로 반환합니다. --- @@ -221,7 +221,7 @@ AI_RESPONSE metadata는 `requestMessageId`, `intent`, `placeRecommendation`, `co | `room:{roomId}:sessions:{userId}` | 접속 유저별 WebSocket/STOMP 세션 목록 (ephemeral) | 세션 종료 시 제거 | 같은 유저의 다중 탭/다중 세션 접속 상태 정합성 유지 | | `place:preview::{googlePlaceId}` | 장소 카드 미리보기 본문 캐시 | 24시간 (1일) | 장소명, 주소, 장소 유형(`primaryType`), 장소 유형 표시명(`primaryTypeDisplayName`), 좌표를 저장. 대표 `photoName`은 저장하지 않는다. 캐시 miss 응답은 원 Google 응답의 `photos.name`을 사용하고, 캐시 hit 응답은 `photos.name` 전용 Google Place Details 요청 결과를 합친다 | | `place:detail::{googlePlaceId}` | 장소 상세 조회 본문 캐시 | 6시간 | 사진 목록(`photoNames`)은 저장하지 않는다. 캐시 miss 응답은 원 Google 응답의 `photos.name`을 사용하고, 캐시 hit 응답은 `photos.name` 전용 Google Place Details 요청 결과를 합친다 | -| `place:photo-uri::{photoName}` | Google Place Photo Media `photoUri` 캐시 | 10분 | 짧은 수명 URL이므로 장기 저장하지 않는다 | +| `place:photo-uri::{photoName}:w400:h400` | Google Place Photo Media `photoUri` 캐시 | 24시간 (1일) | `photoName`과 기본 크기(400x400) 기준으로 저장한다. Google `photoUri`는 짧은 수명 URL이지만 운영 검증 전까지 24시간 TTL을 적용한다 | | `route::{origin}:{dest}:{travelMode}` | Routes API 이동 정보 캐시 | 10분 | Google Maps Platform 정책상 영구 저장 불가, 임시 캐시만 허용 | | `route:lock:{origin}:{dest}:{travelMode}` | Routes API single-flight lock | 5초 | Redis `SET NX EX` 기반 lock. 동시 cache miss 시 Google Routes API 대표 호출자 1개만 선출 | | `route:no-route:{origin}:{dest}:{travelMode}` | Routes API 경로 없음 신호 | 5초 | 대표 호출자가 경로 없음(204)을 확인했음을 대기 요청에 전달하는 짧은 조정 신호 | @@ -243,9 +243,9 @@ AI_RESPONSE metadata는 `requestMessageId`, `intent`, `placeRecommendation`, `co 2. **google_place_id 직접 참조:** places 중간 테이블 없이 bookmarks·schedule_items에서 google_place_id(VARCHAR)를 직접 저장한다. 단순 검색 결과를 DB에 eager insert할 필요가 없고, 북마크/일정 추가 시점에만 장소 식별자가 기록된다. 각 테이블의 google_place_id 컬럼에 인덱스를 부여해 조회 성능을 확보한다. 3. **MongoDB message `_id` cursor:** 저장된 읽음 위치 복귀와 히스토리 조회는 MongoDB `_id` 기반 `afterId`/`beforeId` cursor를 사용한다. 실시간 브로드캐스트 누락 복구는 방별 `sequence` 기반 `afterSequence`를 사용한다. 4. **room.id UUID:** 초대 URL에 노출되므로 추측 불가능한 UUID 사용. -5. **장소 검색/미리보기/상세 캐시:** 자유도가 높은 검색어는 캐시 히트율이 낮을 수 있으므로 검색 결과는 캐시하지 않는다. 검색 API는 Google Text Search(New)의 `pageSize`·`pageToken`·`nextPageToken` 기반 페이지네이션만 프록시한다. Google Place 미리보기 응답은 북마크/일정 카드 표시를 위해 `google_place_id` 기준으로 Redis에 24시간(1일) TTL로 저장하고, 상세 조회 응답은 Redis에 6시간 TTL로 저장한다. +5. **장소 검색/미리보기/상세/사진 캐시:** 자유도가 높은 검색어는 캐시 히트율이 낮을 수 있으므로 검색 결과는 캐시하지 않는다. 검색 API는 Google Text Search(New)의 `pageSize`·`pageToken`·`nextPageToken` 기반 페이지네이션만 프록시한다. Google Place 미리보기 응답은 북마크/일정 카드 표시를 위해 `google_place_id` 기준으로 Redis에 24시간(1일) TTL로 저장하고, 상세 조회 응답은 Redis에 6시간 TTL로 저장한다. Google Photo Media `photoUri`는 `photoName`과 기본 크기(400x400) 기준으로 Redis에 24시간(1일) TTL로 저장한다. 6. **schedule_items.order_index:** D&D UI를 위한 정렬 인덱스. 재정렬 시 해당 컬럼만 업데이트. -7. **이동 정보 프록시:** 이동 수단 선호는 사용자별 클라이언트 로컬 상태로 관리하고 DB에 저장하지 않는다. Google Maps Platform 정책상 `distance_meters`·`duration_seconds`는 DB에 영구 저장 불가 — 서버가 Routes API를 프록시하여 결과를 클라이언트에 직접 반환하고, Redis 10분 TTL로 임시 캐시. 동시 cache miss는 Redis single-flight lock으로 중복 Google API 호출을 줄인다. +7. **이동 정보 프록시:** 이동 수단 선호는 사용자별 클라이언트 로컬 상태로 관리하고 DB에 저장하지 않는다. 벌크 조회에서도 요청 항목별 `travelMode`를 받아 계산에만 사용한다. Google Maps Platform 정책상 `distance_meters`·`duration_seconds`는 DB에 영구 저장 불가 — 서버가 Routes API를 프록시하여 결과를 클라이언트에 직접 반환하고, Redis 10분 TTL로 임시 캐시. 동시 cache miss는 Redis single-flight lock으로 중복 Google API 호출을 줄인다. 8. **방 Hard Delete:** 모든 하위 엔티티 FK에 `@OnDelete(CASCADE)` (DB `ON DELETE CASCADE`)를 적용하여, `roomRepository.delete(room)` 한 줄로 Room과 하위 데이터를 삭제한다. 단방향 관계를 유지하면서 DB가 cascade 삭제를 처리한다. --- diff --git a/docs/ai/features.md b/docs/ai/features.md index c42091d4..a64a8dd1 100644 --- a/docs/ai/features.md +++ b/docs/ai/features.md @@ -36,7 +36,10 @@ Caddy는 `caddy-ratelimit` 기반 IP 제한으로 엣지에서 넓고 거친 폭 |------|------| | `POST /auth/refresh` | `10/min/refresh_token_hash` | | Places 검색 `GET /places/search` | `10/min/user` | -| Places 상세/미리보기/사진 GET | `30/min/user` | +| Places 상세/미리보기/사진 이름 단건 조회 (`places-read-single`) | `30/min/user` | +| Places 미리보기/사진 이름 벌크 조회 (`places-read-bulk`) | `10/min/user` | +| Places 사진 URL 단건 조회 (`places-photo-single`) | `45/min/user` | +| Places 사진 URL 벌크 조회 (`places-photo-bulk`) | `15/min/user` | | Routes 조회 `GET /rooms/{roomId}/schedules/{scheduleId}/items/{itemId}/route` | `20/min/user` + `60/min/room` | | `POST /rooms/join` | `10/min/user` | | 인증된 상태 변경 HTTP API | `60/min/user` | @@ -133,7 +136,7 @@ Caddy는 `caddy-ratelimit` 기반 IP 제한으로 엣지에서 넓고 거친 폭 |------|------|------|----------| | `[x]` | 일정 생성 | `dayNumber`를 생략하면 마지막 일차 뒤에 추가된다. `dayNumber`를 지정하면 해당 위치에 빈 일정을 삽입하고 기존 `dayNumber >= 지정값` 일정은 +1 shift 된다. 생성과 동시에 방의 `endDate`가 1일 늘어나고 `ROOM_SCHEDULES_RESYNCED` 이벤트가 발행된다(상한 30일, 초과 시 `SCHEDULE_LIMIT_EXCEEDED`, 허용 범위 밖 일차는 `INVALID_DAY_NUMBER`로 거부). 날짜는 저장하지 않고 `rooms.start_date + day_number - 1`로 응답마다 계산한다 | schedules, rooms | | `[x]` | 일정 배치 생성 | 동일한 끝뒤 추가 정책으로 `commands.size()` 만큼 일괄 추가되며 방의 `endDate`가 N일 늘어나고 `ROOM_SCHEDULES_RESYNCED` 이벤트가 1회 발행된다(합계가 30을 초과하면 부분 생성 없이 전체 거부) | schedules, rooms | -| `[x]` | 일정 목록 조회 | 방의 전체 일자별 일정 조회 | schedules | +| `[x]` | 일정 목록 조회 | 방의 전체 일자별 일정 조회. `includeItems=true`이면 각 일정의 장소 항목 목록을 함께 반환해 초기 화면 조회 시 일정 항목 N+1 조회를 피한다 | schedules, schedule_items | | `[x]` | 일정 삭제 | 특정 일자 삭제. 뒤쪽 일차들이 앞으로 -1 shift 되어 `dayNumber`가 1..N으로 연속을 유지하고, 방의 `endDate`가 1일 줄어들며 `ROOM_SCHEDULES_RESYNCED` 이벤트가 발행된다. 단, 일정이 1개만 남은 상태에서 삭제 요청이 오면 일정 row와 `endDate`는 유지하고 하위 ScheduleItem만 비운 뒤에도 같은 이벤트를 발행한다 | schedules, rooms | | `[x]` | 일정 이동 | `PATCH /rooms/{roomId}/schedules/{scheduleId}/move`로 같은 방 안의 일정을 목표 `targetDayNumber` 위치로 재배치한다. swap이 아니라 source와 target 사이 구간을 한 칸 shift 하는 reorder이며, 허용 범위는 `1..현재 일정 개수`다. ScheduleItem은 부모 schedule row에 붙어 그대로 따라가고 Room 기간은 바뀌지 않는다. 성공 시 `ROOM_SCHEDULES_RESYNCED` 이벤트가 발행되고, source와 target 일차가 같으면 204 no-op으로 이벤트를 발행하지 않는다 | schedules | diff --git a/docs/superpowers/plans/2026-06-05-schedule-structure-events.md b/docs/superpowers/plans/2026-06-05-schedule-structure-events.md index cbada0bb..2f8daf3a 100644 --- a/docs/superpowers/plans/2026-06-05-schedule-structure-events.md +++ b/docs/superpowers/plans/2026-06-05-schedule-structure-events.md @@ -575,4 +575,3 @@ If Steps 1-4 required no file edits, do not commit. If a typo fix or test correc git add git commit -m "fix: Schedule RESYNC 이벤트 검증 보완" ``` - diff --git a/docs/superpowers/plans/2026-06-07-pr-review-refactoring.md b/docs/superpowers/plans/2026-06-07-pr-review-refactoring.md new file mode 100644 index 00000000..b9293838 --- /dev/null +++ b/docs/superpowers/plans/2026-06-07-pr-review-refactoring.md @@ -0,0 +1,448 @@ +# PR #132 Review Comments Refactoring Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** PR #132(장소 및 경로 벌크 조회 API 추가 및 비동기 최적화)에 달린 PR 리뷰 피드백(CodeRabbit)을 반영하여 코드 품질을 개선하고, 불필요한 직렬화 인스턴스 재생성을 해결하며, 예외 안전성과 캐시 정책 일치 등을 수행한다. + +**Architecture:** +- Rate Limit 및 문서 정합성 불일치를 해소하고, `RedisBulkCacheAccessor`의 `serializer`를 인스턴스 필드로 캐싱한다. +- `PlacePreviewBatchItemResponse` 내에 Location DTO를 명시적으로 분리하여 계층 간 결합을 완화한다. +- `PlacePreviewService` 비동기 부분 실패 시 개별 항목에 대해 안전하게 외부 예외를 핸들링하도록 보강하고, 관련 테스트 코드를 작성하며, 기존 `PlacePhotoServiceTest`를 어노테이션 기반 Mockito 테스트로 리팩터링한다. + +**Tech Stack:** Spring Boot 4.x, Java 21, Spring Data Redis, Mockito, JUnit 5 + +--- + +### Task 1: Rate Limit 및 문서 정합성 수정 + +**Files:** +- Modify: `src/main/java/com/howaboutus/backend/common/ratelimit/HttpRateLimitPolicyResolver.java:32-36` +- Modify: `docs/ai/features.md:38-42` +- Modify: `docs/ai/erd.md:190-195` + +- [ ] **Step 1: HttpRateLimitPolicyResolver에서 벌크 경로 상수 정리** + +`/places/photos/batch`는 이미 `addPlacesPolicies`에서 개별 분기로 처리되어 `places-photo-bulk` 정책이 매핑되므로, `PLACE_BATCH_READ_PATHS` 상수 리스트에서 제거하여 혼선과 가상 매칭 가능성을 제거한다. + +`src/main/java/com/howaboutus/backend/common/ratelimit/HttpRateLimitPolicyResolver.java` 수정: +```java +<<<< + private static final List PLACE_BATCH_READ_PATHS = List.of( + "/places/previews/batch", + "/places/photo-names/batch", + "/places/photos/batch" + ); +==== + private static final List PLACE_BATCH_READ_PATHS = List.of( + "/places/previews/batch", + "/places/photo-names/batch" + // Note: /places/photos/batch is handled separately in addPlacesPolicies + ); +>>>> +``` + +- [ ] **Step 2: docs/ai/features.md Rate Limit 명세 수정** + +`application.yaml`에 반영된 구체적인 Places 관련 개별/벌크 rate limit 값에 맞춰 `features.md` 명세 테이블을 동기화한다. +- `places-read-single = 30/min/user` +- `places-read-bulk = 10/min/user` +- `places-photo-single = 45/min/user` +- `places-photo-bulk = 15/min/user` + +`docs/ai/features.md` 수정: +```markdown +<<<< +| Places 상세/미리보기/사진 이름/사진 URL 조회 및 벌크 조회 | `30/min/user` | +==== +| Places 상세/미리보기/사진 이름 단건 조회 (`places-read-single`) | `30/min/user` | +| Places 미리보기/사진 이름 벌크 조회 (`places-read-bulk`) | `10/min/user` | +| Places 사진 URL 단건 조회 (`places-photo-single`) | `45/min/user` | +| Places 사진 URL 벌크 조회 (`places-photo-bulk`) | `15/min/user` | +>>>> +``` + +- [ ] **Step 3: docs/ai/erd.md의 Google Routes 조회 응답 및 마지막 장소 구분 정의 보완** + +단건 조회에서 "마지막 장소(Google Routes를 호출할 필요가 없는 상태)"와 "실제 Google Routes 호출에 실패했거나 경로를 찾지 못한 경우(no route)" 두 조건 모두 동일하게 204 No Content로 클라이언트에 전달되는 한계를 보완하기 위해 명세에 구분 방식을 추가한다. + +`docs/ai/erd.md` 수정: +```markdown +<<<< +단건 조회 시 경로가 없거나 마지막 장소인 경우 204 No Content를 반환합니다. +==== +단건 조회 시 경로가 없거나 마지막 장소인 경우 204 No Content를 반환하되, 헤더 `X-Route-Status` 또는 응답 상태 코드를 통해 클라이언트가 "마지막 장소(LAST_PLACE)"와 "경로 없음(NO_ROUTE)"을 기계적으로 구분할 수 있도록 처리합니다. +>>>> +``` + +- [ ] **Step 4: 변경 사항 컴파일 확인** + +Run: `./gradlew compileJava` +Expected: BUILD SUCCESSFUL + + +### Task 2: RedisBulkCacheAccessor 최적화 + +**Files:** +- Modify: `src/main/java/com/howaboutus/backend/common/cache/RedisBulkCacheAccessor.java` + +- [ ] **Step 1: RedisBulkCacheAccessor Serializer 재사용 리팩토링** + +매번 `GenericJacksonJsonRedisSerializer`를 새로 생성하지 않고, 생성자에서 한번만 생성하도록 캐싱한다. Lombok의 `@RequiredArgsConstructor`를 해제하고 명시적인 생성자 주입 방식으로 전환한다. + +`src/main/java/com/howaboutus/backend/common/cache/RedisBulkCacheAccessor.java` 수정: +```java +<<<< +@Component +@RequiredArgsConstructor +public class RedisBulkCacheAccessor { + + private final RedisConnectionFactory connectionFactory; + private final ObjectMapper objectMapper; + + public Map multiGet(String cacheName, Collection keys, Class valueType) { + ... + Map result = new LinkedHashMap<>(); + GenericJacksonJsonRedisSerializer serializer = serializer(); + for (int index = 0; index < distinctKeys.size(); index++) { + ... + } + + public void put(String cacheName, String key, Object value, Duration ttl) { + try (RedisConnection connection = connectionFactory.getConnection()) { + connection.stringCommands().set( + redisKey(cacheName, key), + serializer().serialize(value), + Expiration.from(ttl), + RedisStringCommands.SetOption.upsert() + ); + } + } + ... + private GenericJacksonJsonRedisSerializer serializer() { + return GenericJacksonJsonRedisSerializer.builder(objectMapper::rebuild) + .enableDefaultTyping( + BasicPolymorphicTypeValidator.builder() + .allowIfSubType("com.howaboutus.backend") + .allowIfBaseType(Map.class) + .allowIfBaseType(Collection.class) + .build() + ) + .build(); + } +} +==== +@Component +public class RedisBulkCacheAccessor { + + private final RedisConnectionFactory connectionFactory; + private final GenericJacksonJsonRedisSerializer serializer; + + public RedisBulkCacheAccessor(RedisConnectionFactory connectionFactory, ObjectMapper objectMapper) { + this.connectionFactory = connectionFactory; + this.serializer = GenericJacksonJsonRedisSerializer.builder(objectMapper::rebuild) + .enableDefaultTyping( + BasicPolymorphicTypeValidator.builder() + .allowIfSubType("com.howaboutus.backend") + .allowIfBaseType(Map.class) + .allowIfBaseType(Collection.class) + .build() + ) + .build(); + } + + public Map multiGet(String cacheName, Collection keys, Class valueType) { + List distinctKeys = keys.stream().distinct().toList(); + if (distinctKeys.isEmpty()) { + return Map.of(); + } + + byte[][] redisKeys = distinctKeys.stream() + .map(key -> redisKey(cacheName, key)) + .toArray(byte[][]::new); + + try (RedisConnection connection = connectionFactory.getConnection()) { + List values = connection.stringCommands().mGet(redisKeys); + Map result = new LinkedHashMap<>(); + for (int index = 0; index < distinctKeys.size(); index++) { + byte[] value = values.get(index); + if (value != null) { + result.put(distinctKeys.get(index), serializer.deserialize(value, valueType)); + } + } + return result; + } + } + + public void put(String cacheName, String key, Object value, Duration ttl) { + try (RedisConnection connection = connectionFactory.getConnection()) { + connection.stringCommands().set( + redisKey(cacheName, key), + serializer.serialize(value), + Expiration.from(ttl), + RedisStringCommands.SetOption.upsert() + ); + } + } + ... +} +>>>> +``` + +- [ ] **Step 2: 컴파일 확인** + +Run: `./gradlew compileJava` +Expected: BUILD SUCCESSFUL + + +### Task 3: 계층 결합도 완화 및 PlaceController 정규화 + +**Files:** +- Modify: `src/main/java/com/howaboutus/backend/places/controller/dto/PlacePreviewBatchItemResponse.java` +- Modify: `src/main/java/com/howaboutus/backend/places/controller/PlaceController.java` + +- [ ] **Step 1: PlacePreviewBatchItemResponse 내 Location record 분리** + +Controller DTO가 Service Layer의 `PlacePreviewResult.Location` 타입을 직접 참조하지 않도록 자체 `Location` record를 선언하고, `from()` 정적 팩토리 메서드에서 적절히 변환하여 매핑한다. + +`src/main/java/com/howaboutus/backend/places/controller/dto/PlacePreviewBatchItemResponse.java` 수정: +```java +<<<< + @Schema(description = "장소 좌표. status가 OK일 때만 포함됩니다.", nullable = true) + PlacePreviewResult.Location location, +... + public static PlacePreviewBatchItemResponse from(PlacePreviewBatchItemResult result) { + return new PlacePreviewBatchItemResponse( + result.googlePlaceId(), + result.status(), + result.name(), + result.formattedAddress(), + result.primaryType(), + result.primaryTypeDisplayName(), + result.location(), + result.photoName(), + result.errorCode() + ); + } +==== + @Schema(description = "장소 좌표. status가 OK일 때만 포함됩니다.", nullable = true) + Location location, +... + public record Location( + @Schema(description = "위도", example = "37.5665") + double latitude, + @Schema(description = "경도", example = "126.9780") + double longitude + ) {} + + public static PlacePreviewBatchItemResponse from(PlacePreviewBatchItemResult result) { + Location location = result.location() != null + ? new Location(result.location().latitude(), result.location().longitude()) + : null; + return new PlacePreviewBatchItemResponse( + result.googlePlaceId(), + result.status(), + result.name(), + result.formattedAddress(), + result.primaryType(), + result.primaryTypeDisplayName(), + location, + result.photoName(), + result.errorCode() + ); + } +>>>> +``` + +- [ ] **Step 2: PlaceController의 getPhotoUrl 응답 패턴 일관성 향상** + +`getPhotoUrl` 메서드에서 직접 `new PlacePhotoResponse(...)`를 하는 대신, 다른 벌크 API와 일관되게 정적 팩토리를 통해 `PlacePhotoResponse`를 생성해 반환하도록 처리한다. (이미 `PlacePhotoResponse.from()`이 존재한다고 가정하며, 없을 경우 응답 객체에 `from` 추가) + +`src/main/java/com/howaboutus/backend/places/controller/PlaceController.java` 수정: +```java +<<<< + return ResponseEntity.ok(new PlacePhotoResponse(placePhotoService.getPhotoUrl(photoName))); +==== + return ResponseEntity.ok(PlacePhotoResponse.from(placePhotoService.getPhotoUrl(photoName))); +>>>> +``` + +- [ ] **Step 3: 컴파일 확인** + +Run: `./gradlew compileJava` +Expected: BUILD SUCCESSFUL + + +### Task 4: 예외 처리 안전성 강화 및 결과 DTO 개선 + +**Files:** +- Modify: `src/main/java/com/howaboutus/backend/places/service/PlacePreviewService.java` +- Modify: `src/main/java/com/howaboutus/backend/places/service/dto/PlacePhotoBatchItemResult.java` +- Modify: `src/main/java/com/howaboutus/backend/schedules/service/dto/RouteBatchItemResult.java` + +- [ ] **Step 1: PlacePreviewService 비동기 내 예외 처리 보강** + +`fetchCachedWithFreshPhotoNames`에서 캐시 히트 후 photoName을 가져오는 `placePhotoNameService.getFirstPhotoName(googlePlaceId)` 작업 중 발생할 수 있는 `ExternalApiException` 등 외부 API 예외가 전체 배치를 중단시키지 않도록 CompletableFuture 내에서 예외를 캐치하여 부분 실패 상태로 유도한다. + +`src/main/java/com/howaboutus/backend/places/service/PlacePreviewService.java` 수정: +```java +<<<< + private Map fetchCachedWithFreshPhotoNames( + Map cached) { + Map> futures = new LinkedHashMap<>(); + cached.forEach((googlePlaceId, preview) -> futures.put( + googlePlaceId, + CompletableFuture.supplyAsync( + () -> PlacePreviewBatchItemResult.ok( + preview.withPhotoName(placePhotoNameService.getFirstPhotoName(googlePlaceId)) + ), + taskExecutor + ) + )); + return joinPreviewFutures(futures); + } +==== + private Map fetchCachedWithFreshPhotoNames( + Map cached) { + Map> futures = new LinkedHashMap<>(); + cached.forEach((googlePlaceId, preview) -> futures.put( + googlePlaceId, + CompletableFuture.supplyAsync( + () -> { + try { + String photoName = placePhotoNameService.getFirstPhotoName(googlePlaceId); + return PlacePreviewBatchItemResult.ok(preview.withPhotoName(photoName)); + } catch (RuntimeException exception) { + // 외부 API 오류 등 발생 시 photoName만 null로 하고 preview 정보는 유지하여 반환하거나 + // 혹은 개별 항목 실패(EXTERNAL_API_ERROR)로 처리하여 유연하게 대처 + return PlacePreviewBatchItemResult.failure(googlePlaceId, "EXTERNAL_API_ERROR"); + } + }, + taskExecutor + ) + )); + return joinPreviewFutures(futures); + } +>>>> +``` + +- [ ] **Step 2: PlacePhotoBatchItemResult의 status/errorCode 중복 및 설계 개선** + +실패 시 `status`에 `errorCode` 문자열을 대입하던 기존 방식 대신, 성공일 때 `"OK"`, 실패일 때 `"FAILED"` 혹은 `"ERROR"`와 같이 상태 리터럴을 일관되게 사용하고 구체적인 예외 코드는 `errorCode` 필드에만 격리한다. + +`src/main/java/com/howaboutus/backend/places/service/dto/PlacePhotoBatchItemResult.java` 수정: +```java +<<<< + public static PlacePhotoBatchItemResult failure(String photoName, String errorCode) { + return new PlacePhotoBatchItemResult(photoName, errorCode, null, errorCode); + } +==== + public static PlacePhotoBatchItemResult failure(String photoName, String errorCode) { + return new PlacePhotoBatchItemResult(photoName, "FAILED", null, errorCode); + } +>>>> +``` + +- [ ] **Step 3: RouteBatchItemResult의 status/errorCode 중복 수정** + +Route 벌크 조회 실패 결과 DTO도 동일하게 개선한다. + +`src/main/java/com/howaboutus/backend/schedules/service/dto/RouteBatchItemResult.java` 수정: +```java +<<<< + public static RouteBatchItemResult failure(Long originItemId, Long destinationItemId, String errorCode) { + return new RouteBatchItemResult(originItemId, destinationItemId, errorCode, null, null, null, errorCode); + } +==== + public static RouteBatchItemResult failure(Long originItemId, Long destinationItemId, String errorCode) { + return new RouteBatchItemResult(originItemId, destinationItemId, "FAILED", null, null, null, errorCode); + } +>>>> +``` + +- [ ] **Step 4: 컴파일 및 기존 테스트 작동 여부 검사** + +Run: `./gradlew compileJava` +Expected: BUILD SUCCESSFUL + + +### Task 5: 테스트 보강 및 Mockito 어노테이션 리팩토링 + +**Files:** +- Modify: `src/test/java/com/howaboutus/backend/places/service/PlaceDetailCachingTest.java` +- Modify: `src/test/java/com/howaboutus/backend/places/service/PlacePhotoServiceTest.java` + +- [ ] **Step 1: PlaceDetailCachingTest에 photo name 갱신 실패 케이스 테스트 추가** + +`fetchCachedWithFreshPhotoNames`에서 photo name 갱신에 실패하여 런타임 예외가 터졌을 때, 해당 항목만 안전하게 실패 처리되거나 부드럽게 복구되는지 검증하는 테스트 메서드를 작성한다. + +`src/test/java/com/howaboutus/backend/places/service/PlaceDetailCachingTest.java` 수정: +```java + @Test + @DisplayName("캐시된 미리보기의 사진 이름 갱신 실패 시 해당 항목은 EXTERNAL_API_ERROR로 처리된다") + void handlesPhotoNameFetchFailureForCachedPreviews() { + String googlePlaceId = "ChIJ123"; + // 1. 초기 성공 응답으로 캐싱 유도 + PlaceDetailResponse successDetail = detailResponse("places/ChIJ123/photos/a"); + given(googlePlaceDetailClient.getPreview(googlePlaceId)).willReturn(successDetail); + + placePreviewService.getPreview(googlePlaceId); + + // 2. 캐시 조회 시 photoName을 새로 fetch하는 도중 API 에러 유도 + given(googlePlaceDetailClient.getPhotoNames(googlePlaceId)) + .willThrow(new ExternalApiException(new RuntimeException("API timeout"))); + + List results = placePreviewService.getPreviews(List.of(googlePlaceId)); + + assertThat(results).hasSize(1); + assertThat(results.get(0).status()).isEqualTo("FAILED"); + assertThat(results.get(0).errorCode()).isEqualTo("EXTERNAL_API_ERROR"); + } +``` + +- [ ] **Step 2: PlacePhotoServiceTest를 어노테이션 기반 Mockito 테스트로 리팩토링** + +`AGENTS.md` 코딩 컨벤션에 의거하여, `Mockito.mock(...)` 수동 구성을 `@ExtendWith(MockitoExtension.class)`, `@Mock`, `@InjectMocks` 어노테이션 기반 주입 코드로 마이그레이션한다. + +`src/test/java/com/howaboutus/backend/places/service/PlacePhotoServiceTest.java` 수정: +```java +<<<< +class PlacePhotoServiceTest { + private GooglePlacePhotoClient googlePlacePhotoClient; + private RedisBulkCacheAccessor redisBulkCacheAccessor; + private PlacePhotoService placePhotoService; + private Executor taskExecutor; + + @BeforeEach + void setUp() { + googlePlacePhotoClient = mock(GooglePlacePhotoClient.class); + redisBulkCacheAccessor = mock(RedisBulkCacheAccessor.class); + taskExecutor = Runnable::run; + placePhotoService = new PlacePhotoService(googlePlacePhotoClient, redisBulkCacheAccessor, taskExecutor); + } +==== +@ExtendWith(MockitoExtension.class) +class PlacePhotoServiceTest { + + @Mock + private GooglePlacePhotoClient googlePlacePhotoClient; + + @Mock + private RedisBulkCacheAccessor redisBulkCacheAccessor; + + @InjectMocks + private PlacePhotoService placePhotoService; + + private final Executor taskExecutor = Runnable::run; + + @BeforeEach + void setUp() { + // taskExecutor는 final 필드/기본 Runnable::run 인스턴스 주입 + ReflectionTestUtils.setField(placePhotoService, "taskExecutor", taskExecutor); + } +>>>> +``` + +- [ ] **Step 3: 전체 테스트 수행 및 검증** + +Run: `./gradlew clean test` +Expected: BUILD SUCCESSFUL (All tests pass) diff --git a/docs/superpowers/plans/2026-06-07-rate-limit-photo-split.md b/docs/superpowers/plans/2026-06-07-rate-limit-photo-split.md new file mode 100644 index 00000000..6a2daf65 --- /dev/null +++ b/docs/superpowers/plans/2026-06-07-rate-limit-photo-split.md @@ -0,0 +1,143 @@ +# Rate Limit Photo URI Split Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Split the short-lived photo URI retrieval API endpoints (`/places/photos` and `/places/photos/batch`) into dedicated rate limit policies to prevent photo loading from exhausting general place data quotas. + +**Architecture:** Create two new rate limit policies (`places-photo-single` and `places-photo-bulk`) in `application.yaml` and update the matching logic in `HttpRateLimitPolicyResolver` and the corresponding unit/integration tests. + +**Tech Stack:** Java 21, Spring Boot 4.0.5, Redis, Bucket4j + +--- + +### Task 1: Update Rate Limit Config in `application.yaml` + +**Files:** +- Modify: `src/main/resources/application.yaml` + +- [ ] **Step 1: Add new properties for single/bulk photo URI policies** + +Open [application.yaml](file:///home/minbros/projects/java/how-about-us-backend/src/main/resources/application.yaml) and add the new policies under `app.rate-limit.policies`: + +```yaml + places-photo-single: + capacity: 45 + duration: 1m + places-photo-bulk: + capacity: 15 + duration: 1m +``` + +--- + +### Task 2: Update `REQUIRED_POLICIES` and Unit Tests + +**Files:** +- Modify: `src/main/java/com/howaboutus/backend/common/config/properties/RateLimitProperties.java` +- Modify: `src/test/java/com/howaboutus/backend/common/ratelimit/HttpRateLimitPolicyResolverTest.java` +- Modify: `src/test/java/com/howaboutus/backend/messages/service/MessageRateLimiterTest.java` + +- [ ] **Step 1: Add policies to `REQUIRED_POLICIES` list** + +Open [RateLimitProperties.java](file:///home/minbros/projects/java/how-about-us-backend/src/main/java/com/howaboutus/backend/common/config/properties/RateLimitProperties.java) and add `"places-photo-single"` and `"places-photo-bulk"` to the `REQUIRED_POLICIES` array. + +- [ ] **Step 2: Update test configuration maps** + +Open [HttpRateLimitPolicyResolverTest.java](file:///home/minbros/projects/java/how-about-us-backend/src/test/java/com/howaboutus/backend/common/ratelimit/HttpRateLimitPolicyResolverTest.java) and [MessageRateLimiterTest.java](file:///home/minbros/projects/java/how-about-us-backend/src/test/java/com/howaboutus/backend/messages/service/MessageRateLimiterTest.java): +- Add `"places-photo-single"` and `"places-photo-bulk"` entries to their respective `TEST_PROPERTIES` maps. + +- [ ] **Step 3: Modify test assertions for Photo API routes** + +In [HttpRateLimitPolicyResolverTest.java](file:///home/minbros/projects/java/how-about-us-backend/src/test/java/com/howaboutus/backend/common/ratelimit/HttpRateLimitPolicyResolverTest.java): +- In `resolvesPlaceReadPolicies()`, change the expected key for `GET /places/photos` from `"rate-limit:http:places-read-single:user:42"` to `"rate-limit:http:places-photo-single:user:42"`. +- In `resolvesPlaceBatchReadPoliciesWithoutWritePolicy()`, change the expected key for `POST /places/photos/batch` from `"rate-limit:http:places-read-bulk:user:42"` to `"rate-limit:http:places-photo-bulk:user:42"`. + +- [ ] **Step 4: Run tests to verify they fail** + +Run: `./gradlew test --tests com.howaboutus.backend.common.ratelimit.HttpRateLimitPolicyResolverTest` +Expected: FAIL on photo tests because the resolver has not been updated yet. + +--- + +### Task 3: Implement Photo Rate Limit Logic in `HttpRateLimitPolicyResolver.java` + +**Files:** +- Modify: `src/main/java/com/howaboutus/backend/common/ratelimit/HttpRateLimitPolicyResolver.java` + +- [ ] **Step 1: Update resolver paths and policy resolution** + +Open [HttpRateLimitPolicyResolver.java](file:///home/minbros/projects/java/how-about-us-backend/src/main/java/com/howaboutus/backend/common/ratelimit/HttpRateLimitPolicyResolver.java): +- Separate out `/places/photos` GET and `/places/photos/batch` POST requests from `addPlacesPolicies`. +- Apply `"places-photo-single"` and `"places-photo-bulk"` respectively. +- Verify `isPlaceBulkReadRequest` still includes the remaining bulk paths but filters out `/places/photos/batch` if necessary, or updates correctly to ensure `write` policy is not applied to batch photos. + +Specifically: +```java + private void addPlacesPolicies(String method, String path, Optional userId, List plans) { + if (userId.isEmpty()) { + return; + } + if ("GET".equals(method) && "/places/search".equals(path)) { + plans.add(plan("http:places-search", "user", String.valueOf(userId.get()), "places-search")); + return; + } + if ("GET".equals(method) && "/places/photos".equals(path)) { + plans.add(plan("http:places-photo-single", "user", String.valueOf(userId.get()), "places-photo-single")); + return; + } + if ("POST".equals(method) && "/places/photos/batch".equals(path)) { + plans.add(plan("http:places-photo-bulk", "user", String.valueOf(userId.get()), "places-photo-bulk")); + return; + } + + if (isPlaceSingleReadRequest(method, path)) { + plans.add(plan("http:places-read-single", "user", String.valueOf(userId.get()), "places-read-single")); + } else if (isPlaceBulkReadRequest(method, path)) { + plans.add(plan("http:places-read-bulk", "user", String.valueOf(userId.get()), "places-read-bulk")); + } + } + + private boolean isPlaceSingleReadRequest(String method, String path) { + return "GET".equals(method) && PLACE_READ_SINGLE_PATTERN.matcher(path).matches(); + } + + private boolean isPlaceBulkReadRequest(String method, String path) { + return "POST".equals(method) && ("/places/previews/batch".equals(path) || "/places/photo-names/batch".equals(path)); + } +``` +And make sure `addWritePolicy` is updated if it checks batch paths: +```java + private void addWritePolicy(String method, String path, Optional userId, List plans) { + if (userId.isEmpty() || path.startsWith("/auth/") + || isPlaceBulkReadRequest(method, path) + || "/places/photos/batch".equals(path) + || ROUTE_BATCH_PATTERN.matcher(path).matches()) { + return; + } + if ("POST".equals(method) || "PATCH".equals(method) || "DELETE".equals(method)) { + plans.add(plan("http:write", "user", String.valueOf(userId.get()), "write-user")); + } + } +``` + +- [ ] **Step 2: Run tests to verify they pass** + +Run: `./gradlew test --tests com.howaboutus.backend.common.ratelimit.HttpRateLimitPolicyResolverTest` +Expected: PASS + +--- + +### Task 4: Finalize and Style Checks + +**Files:** +- None (Verification task) + +- [ ] **Step 1: Run Checkstyle Verification** + +Run: `./gradlew checkstyleMain checkstyleTest` +Expected: BUILD SUCCESSFUL (0 warnings) + +- [ ] **Step 2: Run Full Test Suite** + +Run: `./gradlew cleanTest test` +Expected: BUILD SUCCESSFUL (620+ tests pass) diff --git a/docs/superpowers/plans/2026-06-07-rate-limit-redesign.md b/docs/superpowers/plans/2026-06-07-rate-limit-redesign.md new file mode 100644 index 00000000..3709eb81 --- /dev/null +++ b/docs/superpowers/plans/2026-06-07-rate-limit-redesign.md @@ -0,0 +1,238 @@ +# Rate Limit Redesign Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Redesign rate limit policies by splitting single and batch endpoints for Google Places and Routes APIs to avoid false-positive blocking and prevent abuse. + +**Architecture:** Split existing unified rate limit policies in `HttpRateLimitPolicyResolver` and `application.yaml` into separate single-focused (`places-read-single`, `route-single-user/room`) and bulk-focused (`places-read-bulk`, `route-batch-user/room`) configurations. Maintain security while accommodating high-density page load requirements. + +**Tech Stack:** Java 21, Spring Boot 4.0.5, Redis, Bucket4j + +--- + +### Task 1: Update Rate Limit Config in `application.yaml` + +**Files:** +- Modify: `src/main/resources/application.yaml` + +- [ ] **Step 1: Replace old properties with single/bulk split settings** + +Modify the YAML structure from lines 115-123 in [application.yaml](file:///home/minbros/projects/java/how-about-us-backend/src/main/resources/application.yaml#L115-L123): + +```yaml + places-read-single: + capacity: 30 + duration: 1m + places-read-bulk: + capacity: 10 + duration: 1m + route-single-user: + capacity: 20 + duration: 1m + route-single-room: + capacity: 60 + duration: 1m + route-batch-user: + capacity: 10 + duration: 1m + route-batch-room: + capacity: 30 + duration: 1m +``` + +--- + +### Task 2: Write Failing Tests in `HttpRateLimitPolicyResolverTest.java` + +**Files:** +- Modify: `src/test/java/com/howaboutus/backend/common/ratelimit/HttpRateLimitPolicyResolverTest.java` + +- [ ] **Step 1: Modify test environment properties and write failing assertions** + +Open [HttpRateLimitPolicyResolverTest.java](file:///home/minbros/projects/java/how-about-us-backend/src/test/java/com/howaboutus/backend/common/ratelimit/HttpRateLimitPolicyResolverTest.java) and modify `TEST_PROPERTIES` to match the new configuration schema, then update the test methods to assert the new rate limit keys. + +Specifically, replace the `TEST_PROPERTIES` declaration: +```java + private static final RateLimitProperties TEST_PROPERTIES = new RateLimitProperties(Map.of( + "auth-refresh-session", new RateLimitProperties.PolicyDto(10, Duration.ofMinutes(1), null), + "places-search", new RateLimitProperties.PolicyDto(10, Duration.ofMinutes(1), null), + "places-read-single", new RateLimitProperties.PolicyDto(30, Duration.ofMinutes(1), null), + "places-read-bulk", new RateLimitProperties.PolicyDto(10, Duration.ofMinutes(1), null), + "route-single-user", new RateLimitProperties.PolicyDto(20, Duration.ofMinutes(1), null), + "route-single-room", new RateLimitProperties.PolicyDto(60, Duration.ofMinutes(1), null), + "route-batch-user", new RateLimitProperties.PolicyDto(10, Duration.ofMinutes(1), null), + "route-batch-room", new RateLimitProperties.PolicyDto(30, Duration.ofMinutes(1), null), + "join-user", new RateLimitProperties.PolicyDto(10, Duration.ofMinutes(1), null), + "write-user", new RateLimitProperties.PolicyDto(60, Duration.ofMinutes(1), null), + "chat", new RateLimitProperties.PolicyDto(null, null, List.of( + new RateLimitProperties.BandwidthDto(5, Duration.ofSeconds(1)), + new RateLimitProperties.BandwidthDto(60, Duration.ofMinutes(1)) + )) + )); +``` + +And update the following test assertions: +- In `resolvesPlaceReadPolicies()`, verify it matches `places-read-single` key: +```java + // 상세 + assertThat(resolver.resolve(request("GET", "/places/ChIJ123"))) + .extracting(RateLimitPlan::key) + .containsExactly("rate-limit:http:places-read-single:user:42"); + + // 미리보기 + assertThat(resolver.resolve(request("GET", "/places/ChIJ123/preview"))) + .extracting(RateLimitPlan::key) + .containsExactly("rate-limit:http:places-read-single:user:42"); + + // 사진이름조회 + assertThat(resolver.resolve(request("GET", "/places/ChIJ123/photo-names"))) + .extracting(RateLimitPlan::key) + .containsExactly("rate-limit:http:places-read-single:user:42"); + + // 사진 파일 GET + assertThat(resolver.resolve(request("GET", "/places/photos"))) + .extracting(RateLimitPlan::key) + .containsExactly("rate-limit:http:places-read-single:user:42"); +``` + +- In `resolvesPlaceBatchReadPoliciesWithoutWritePolicy()`, verify it matches `places-read-bulk` key: +```java + assertThat(resolver.resolve(request("POST", "/places/previews/batch"))) + .extracting(RateLimitPlan::key) + .containsExactly("rate-limit:http:places-read-bulk:user:42"); + + assertThat(resolver.resolve(request("POST", "/places/photo-names/batch"))) + .extracting(RateLimitPlan::key) + .containsExactly("rate-limit:http:places-read-bulk:user:42"); + + assertThat(resolver.resolve(request("POST", "/places/photos/batch"))) + .extracting(RateLimitPlan::key) + .containsExactly("rate-limit:http:places-read-bulk:user:42"); +``` + +- In `resolvesRouteBatchPoliciesWithoutWritePolicy()`, verify it matches `route-batch-user` and `route-batch-room` keys: +```java + assertThat(resolver.resolve(request("POST", "/rooms/room-1/schedules/10/routes/batch"))) + .extracting(RateLimitPlan::key) + .containsExactly( + "rate-limit:http:route-batch:user:42", + "rate-limit:http:route-batch:room:room-1" + ); +``` + +- Also write a new test case for single routes: +```java + @Test + @DisplayName("Routes 단건 GET은 사용자/방별 route-single 제한을 적용한다") + void resolvesSingleRoutePolicies() { + authenticate(42L); + MockHttpServletRequest request = request("GET", "/rooms/room-1/schedules/10/items/20/route"); + + List plans = resolver.resolve(request); + + assertThat(plans) + .extracting(RateLimitPlan::key) + .containsExactly( + "rate-limit:http:route-single:user:42", + "rate-limit:http:route-single:room:room-1" + ); + } +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `./gradlew test --tests com.howaboutus.backend.common.ratelimit.HttpRateLimitPolicyResolverTest` +Expected: Failure in matching the plan keys (since the implementation still resolves to old keys `places-read` and `route`). + +--- + +### Task 3: Implement Policy Resolution Logic in `HttpRateLimitPolicyResolver.java` + +**Files:** +- Modify: `src/main/java/com/howaboutus/backend/common/ratelimit/HttpRateLimitPolicyResolver.java` + +- [ ] **Step 1: Modify Places and Route policy resolution matching rules** + +Open [HttpRateLimitPolicyResolver.java](file:///home/minbros/projects/java/how-about-us-backend/src/main/java/com/howaboutus/backend/common/ratelimit/HttpRateLimitPolicyResolver.java) and modify `addPlacesPolicies`, `addRoutePolicies`, and `isPlaceReadRequest` to resolve single and bulk endpoints separately. + +- Update patterns or checks: +```java + private void addPlacesPolicies(String method, String path, Optional userId, List plans) { + if (userId.isEmpty()) { + return; + } + if ("GET".equals(method) && "/places/search".equals(path)) { + plans.add(plan("http:places-search", "user", String.valueOf(userId.get()), "places-search")); + return; + } + if (isPlaceSingleReadRequest(method, path)) { + plans.add(plan("http:places-read-single", "user", String.valueOf(userId.get()), "places-read-single")); + } else if (isPlaceBulkReadRequest(method, path)) { + plans.add(plan("http:places-read-bulk", "user", String.valueOf(userId.get()), "places-read-bulk")); + } + } + + private boolean isPlaceSingleReadRequest(String method, String path) { + return "GET".equals(method) && ("/places/photos".equals(path) || PLACE_READ_PATTERN.matcher(path).matches()); + } + + private boolean isPlaceBulkReadRequest(String method, String path) { + return "POST".equals(method) && PLACE_BATCH_READ_PATHS.contains(path); + } +``` + +- Update route policies: +```java + private void addRoutePolicies(String method, String path, Optional userId, List plans) { + if ("GET".equals(method)) { + Matcher matcher = ROUTE_PATTERN.matcher(path); + if (matcher.matches()) { + userId.ifPresent(id -> plans.add(plan("http:route-single", "user", String.valueOf(id), "route-single-user"))); + plans.add(plan("http:route-single", "room", matcher.group(1), "route-single-room")); + } + } else if ("POST".equals(method)) { + Matcher matcher = ROUTE_BATCH_PATTERN.matcher(path); + if (matcher.matches()) { + userId.ifPresent(id -> plans.add(plan("http:route-batch", "user", String.valueOf(id), "route-batch-user"))); + plans.add(plan("http:route-batch", "room", matcher.group(1), "route-batch-room")); + } + } + } +``` + +- Remove unused methods like `isPlaceReadRequest`, `routeMatcher`. + +- Modify `addWritePolicy` to use `isPlaceBulkReadRequest` instead of `PLACE_BATCH_READ_PATHS` to filter write operations: +```java + private void addWritePolicy(String method, String path, Optional userId, List plans) { + if (userId.isEmpty() || path.startsWith("/auth/") || isPlaceBulkReadRequest(method, path) + || ROUTE_BATCH_PATTERN.matcher(path).matches()) { + return; + } + if ("POST".equals(method) || "PATCH".equals(method) || "DELETE".equals(method)) { + plans.add(plan("http:write", "user", String.valueOf(userId.get()), "write-user")); + } + } +``` + +- [ ] **Step 2: Run tests to verify they pass** + +Run: `./gradlew test --tests com.howaboutus.backend.common.ratelimit.HttpRateLimitPolicyResolverTest` +Expected: PASS + +--- + +### Task 4: Run Style Checks and Verify Clean Build + +**Files:** +- None (Verification task) + +- [ ] **Step 1: Checkstyle Verification** + +Run: `./gradlew checkstyleMain checkstyleTest` +Expected: Build successful, 0 checkstyle warnings. + +- [ ] **Step 2: Complete Project Test Suit Execution** + +Run: `./gradlew test` +Expected: PASS for all tests. diff --git a/docs/superpowers/specs/2026-06-05-room-dates-schedule-resync-design.md b/docs/superpowers/specs/2026-06-05-room-dates-schedule-resync-design.md index cce2cc4e..4f0ba8ca 100644 --- a/docs/superpowers/specs/2026-06-05-room-dates-schedule-resync-design.md +++ b/docs/superpowers/specs/2026-06-05-room-dates-schedule-resync-design.md @@ -25,7 +25,7 @@ - 신규 broadcast 이벤트 타입 도입 (`ROOM_SCHEDULES_RESYNCED`는 마스터 플랜 별도 PR에서 통일 예정). - Schedule 이동 / 중간 삽입 / ScheduleItem 다른 일자 이동 (마스터 플랜 PR4, PR5). -- 클라이언트 사전 경고 UX (백엔드는 즉시 삭제). +- 클라이언트 사전 경고 UX (백엔드는 즉시 삭제). - ScheduleItem이 있는 Schedule 삭제에 대한 별도 confirmation API. ## 핵심 결정 사항 diff --git a/src/main/java/com/howaboutus/backend/ai/listener/AiSummaryTriggerListener.java b/src/main/java/com/howaboutus/backend/ai/listener/AiSummaryTriggerListener.java index eda692d6..a6b166b3 100644 --- a/src/main/java/com/howaboutus/backend/ai/listener/AiSummaryTriggerListener.java +++ b/src/main/java/com/howaboutus/backend/ai/listener/AiSummaryTriggerListener.java @@ -7,6 +7,7 @@ import org.springframework.stereotype.Component; import com.howaboutus.backend.ai.service.AiSummaryService; +import com.howaboutus.backend.common.config.AsyncConfig; import com.howaboutus.backend.messages.document.MessageType; import com.howaboutus.backend.realtime.event.MessageSentEvent; @@ -27,7 +28,7 @@ public class AiSummaryTriggerListener { private final AiSummaryService aiSummaryService; - @Async + @Async(AsyncConfig.AI_TASK_EXECUTOR) @EventListener public void handleMessageSent(MessageSentEvent event) { if (!SUMMARIZABLE_TYPES.contains(event.messageType())) { diff --git a/src/main/java/com/howaboutus/backend/ai/service/AiRequestQueueWorker.java b/src/main/java/com/howaboutus/backend/ai/service/AiRequestQueueWorker.java index 3b2682d8..03aa5872 100644 --- a/src/main/java/com/howaboutus/backend/ai/service/AiRequestQueueWorker.java +++ b/src/main/java/com/howaboutus/backend/ai/service/AiRequestQueueWorker.java @@ -10,6 +10,7 @@ import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; +import com.howaboutus.backend.common.config.AsyncConfig; import com.howaboutus.backend.common.error.CustomException; import com.howaboutus.backend.common.logging.Loggable; import com.howaboutus.backend.messages.service.MessageService; @@ -56,7 +57,7 @@ public class AiRequestQueueWorker { private final MessageService messageService; private final AiPlanService aiPlanService; - @Async + @Async(AsyncConfig.AI_TASK_EXECUTOR) public void drain(UUID roomId) { while (true) { String token = UUID.randomUUID().toString(); diff --git a/src/main/java/com/howaboutus/backend/bookmarks/controller/BookmarkController.java b/src/main/java/com/howaboutus/backend/bookmarks/controller/BookmarkController.java index afbc02d0..5ec9e2e9 100644 --- a/src/main/java/com/howaboutus/backend/bookmarks/controller/BookmarkController.java +++ b/src/main/java/com/howaboutus/backend/bookmarks/controller/BookmarkController.java @@ -41,8 +41,8 @@ public class BookmarkController { private final BookmarkService bookmarkService; @Operation( - summary = "보관함 항목 생성", - description = "방에 후보 장소를 보관함 항목으로 추가합니다." + summary = "보관함 항목 생성", + description = "방에 후보 장소를 보관함 항목으로 추가합니다." ) @ApiResponse(responseCode = "201", description = "생성 성공") @ApiErrorCodes({ErrorCode.INVALID_TOKEN, ErrorCode.ACCESS_TOKEN_EXPIRED, ErrorCode.NOT_ROOM_MEMBER, @@ -51,40 +51,41 @@ public class BookmarkController { @Loggable @PostMapping public ResponseEntity> create( - @AuthenticationPrincipal Long userId, - @Parameter(description = "방 ID", example = "123e4567-e89b-12d3-a456-426614174000") - @PathVariable UUID roomId, - @RequestBody @Valid CreateBookmarkRequest request + @AuthenticationPrincipal Long userId, + @Parameter(description = "방 ID", example = "123e4567-e89b-12d3-a456-426614174000") + @PathVariable UUID roomId, + @RequestBody @Valid CreateBookmarkRequest request ) { return ResponseEntity.status(HttpStatus.CREATED) - .body(bookmarkService.create(roomId, request.toCommand(), userId).stream() - .map(BookmarkResponse::from) - .toList()); + .body(bookmarkService.create(roomId, request.toCommand(), userId).stream() + .map(BookmarkResponse::from) + .toList()); } @Operation( - summary = "보관함 목록 조회", - description = "방의 보관함 항목 목록을 카테고리별로 조회합니다." + summary = "보관함 목록 조회", + description = "방의 보관함 항목 목록을 조회합니다. categoryId를 전달하면 해당 카테고리만, " + + "생략하면 방 전체 보관함 항목을 반환합니다." ) @ApiResponse(responseCode = "200", description = "조회 성공") @ApiErrorCodes({ErrorCode.INVALID_TOKEN, ErrorCode.ACCESS_TOKEN_EXPIRED, ErrorCode.NOT_ROOM_MEMBER, ErrorCode.ROOM_NOT_FOUND}) @GetMapping public List getBookmarks( - @AuthenticationPrincipal Long userId, - @Parameter(description = "방 ID", example = "123e4567-e89b-12d3-a456-426614174000") - @PathVariable UUID roomId, - @Parameter(description = "카테고리 ID", example = "1", required = true) - @RequestParam long categoryId + @AuthenticationPrincipal Long userId, + @Parameter(description = "방 ID", example = "123e4567-e89b-12d3-a456-426614174000") + @PathVariable UUID roomId, + @Parameter(description = "카테고리 ID. 생략하면 방 전체 보관함 항목을 조회합니다.", example = "1") + @RequestParam(required = false) Long categoryId ) { return bookmarkService.getBookmarks(roomId, categoryId, userId).stream() - .map(BookmarkResponse::from) - .toList(); + .map(BookmarkResponse::from) + .toList(); } @Operation( - summary = "보관함 카테고리 변경", - description = "보관함 항목의 카테고리를 현재 방 소속 카테고리로 변경합니다." + summary = "보관함 카테고리 변경", + description = "보관함 항목의 카테고리를 현재 방 소속 카테고리로 변경합니다." ) @ApiResponse(responseCode = "200", description = "변경 성공") @ApiErrorCodes({ErrorCode.INVALID_TOKEN, ErrorCode.ACCESS_TOKEN_EXPIRED, ErrorCode.NOT_ROOM_MEMBER, @@ -92,21 +93,21 @@ public List getBookmarks( @Loggable @PatchMapping("/{bookmarkId}/category") public BookmarkResponse updateCategory( - @AuthenticationPrincipal Long userId, - @Parameter(description = "방 ID", example = "123e4567-e89b-12d3-a456-426614174000") - @PathVariable UUID roomId, - @Parameter(description = "보관함 항목 ID", example = "1") - @PathVariable long bookmarkId, - @RequestBody @Valid UpdateBookmarkCategoryRequest request + @AuthenticationPrincipal Long userId, + @Parameter(description = "방 ID", example = "123e4567-e89b-12d3-a456-426614174000") + @PathVariable UUID roomId, + @Parameter(description = "보관함 항목 ID", example = "1") + @PathVariable long bookmarkId, + @RequestBody @Valid UpdateBookmarkCategoryRequest request ) { return BookmarkResponse.from( - bookmarkService.updateCategory(roomId, bookmarkId, request.categoryId(), userId) + bookmarkService.updateCategory(roomId, bookmarkId, request.categoryId(), userId) ); } @Operation( - summary = "보관함 항목 삭제", - description = "방의 보관함 항목을 삭제합니다." + summary = "보관함 항목 삭제", + description = "방의 보관함 항목을 삭제합니다." ) @ApiResponse(responseCode = "204", description = "삭제 성공", content = @Content) @ApiErrorCodes({ErrorCode.INVALID_TOKEN, ErrorCode.ACCESS_TOKEN_EXPIRED, ErrorCode.NOT_ROOM_MEMBER, @@ -114,11 +115,11 @@ public BookmarkResponse updateCategory( @Loggable @DeleteMapping("/{bookmarkId}") public ResponseEntity delete( - @AuthenticationPrincipal Long userId, - @Parameter(description = "방 ID", example = "123e4567-e89b-12d3-a456-426614174000") - @PathVariable UUID roomId, - @Parameter(description = "보관함 항목 ID", example = "1") - @PathVariable long bookmarkId + @AuthenticationPrincipal Long userId, + @Parameter(description = "방 ID", example = "123e4567-e89b-12d3-a456-426614174000") + @PathVariable UUID roomId, + @Parameter(description = "보관함 항목 ID", example = "1") + @PathVariable long bookmarkId ) { bookmarkService.delete(roomId, bookmarkId, userId); return ResponseEntity.noContent().build(); diff --git a/src/main/java/com/howaboutus/backend/bookmarks/repository/BookmarkRepository.java b/src/main/java/com/howaboutus/backend/bookmarks/repository/BookmarkRepository.java index 426e2aaa..f248107d 100644 --- a/src/main/java/com/howaboutus/backend/bookmarks/repository/BookmarkRepository.java +++ b/src/main/java/com/howaboutus/backend/bookmarks/repository/BookmarkRepository.java @@ -27,6 +27,9 @@ public interface BookmarkRepository extends JpaRepository { @EntityGraph(attributePaths = {"category", "room"}) List findAllByRoom_IdAndCategory_IdOrderByCreatedAtDesc(UUID roomId, Long categoryId); + @EntityGraph(attributePaths = {"category", "room"}) + List findAllByRoom_IdOrderByCategory_CreatedAtAscCategory_IdAscCreatedAtDescIdDesc(UUID roomId); + @Query(""" select bookmark from Bookmark bookmark diff --git a/src/main/java/com/howaboutus/backend/bookmarks/service/BookmarkService.java b/src/main/java/com/howaboutus/backend/bookmarks/service/BookmarkService.java index 7e92854d..5eb901e7 100644 --- a/src/main/java/com/howaboutus/backend/bookmarks/service/BookmarkService.java +++ b/src/main/java/com/howaboutus/backend/bookmarks/service/BookmarkService.java @@ -46,38 +46,43 @@ public List create(UUID roomId, BookmarkCreateCommand command, L throw new CustomException(ErrorCode.BOOKMARK_CATEGORY_EMPTY); } List uniqueCategoryIds = command.categoryIds().stream() - .distinct() - .toList(); + .distinct() + .toList(); Map categoriesById = bookmarkCategoryRepository - .findAllByIdInAndRoom_Id(uniqueCategoryIds, roomId) - .stream() - .collect(Collectors.toMap( - BookmarkCategory::getId, - category -> category, - (left, right) -> left, - LinkedHashMap::new - )); + .findAllByIdInAndRoom_Id(uniqueCategoryIds, roomId) + .stream() + .collect(Collectors.toMap( + BookmarkCategory::getId, + category -> category, + (left, right) -> left, + LinkedHashMap::new + )); if (categoriesById.size() != uniqueCategoryIds.size()) { throw new CustomException(ErrorCode.BOOKMARK_CATEGORY_NOT_FOUND); } List existingCategoryIds = bookmarkRepository.findExistingCategoryIds( - roomId, - command.googlePlaceId(), - uniqueCategoryIds + roomId, + command.googlePlaceId(), + uniqueCategoryIds ); if (!existingCategoryIds.isEmpty()) { throw new CustomException(ErrorCode.BOOKMARK_ALREADY_EXISTS); } try { List bookmarks = uniqueCategoryIds.stream() - .map(categoryId -> Bookmark.create(room, command.googlePlaceId(), categoriesById.get(categoryId), null)) - .toList(); + .map(categoryId -> Bookmark.create( + room, + command.googlePlaceId(), + categoriesById.get(categoryId), + null + )) + .toList(); List results = bookmarkRepository.saveAllAndFlush(bookmarks).stream() - .map(BookmarkResult::from) - .toList(); + .map(BookmarkResult::from) + .toList(); for (BookmarkResult result : results) { publishChanged(roomId, userId, RoomBookmarkEventType.BOOKMARK_CREATED, result.bookmarkId(), - result.categoryId()); + result.categoryId()); } return results; } catch (DataIntegrityViolationException e) { @@ -85,12 +90,16 @@ public List create(UUID roomId, BookmarkCreateCommand command, L } } - public List getBookmarks(UUID roomId, long categoryId, Long userId) { + public List getBookmarks(UUID roomId, Long categoryId, Long userId) { roomAccessService.requireExistingActiveMember(roomId, userId); - return bookmarkRepository.findAllByRoom_IdAndCategory_IdOrderByCreatedAtDesc(roomId, categoryId) - .stream() - .map(BookmarkResult::from) - .toList(); + List bookmarks = categoryId == null + ? bookmarkRepository + .findAllByRoom_IdOrderByCategory_CreatedAtAscCategory_IdAscCreatedAtDescIdDesc(roomId) + : bookmarkRepository.findAllByRoom_IdAndCategory_IdOrderByCreatedAtDesc(roomId, categoryId); + return bookmarks + .stream() + .map(BookmarkResult::from) + .toList(); } @Loggable @@ -98,7 +107,7 @@ public List getBookmarks(UUID roomId, long categoryId, Long user public void delete(UUID roomId, long bookmarkId, Long userId) { roomAccessService.requireExistingActiveMember(roomId, userId); Bookmark bookmark = bookmarkRepository.findByIdAndRoom_Id(bookmarkId, roomId) - .orElseThrow(() -> new CustomException(ErrorCode.BOOKMARK_NOT_FOUND)); + .orElseThrow(() -> new CustomException(ErrorCode.BOOKMARK_NOT_FOUND)); Long categoryId = bookmark.getCategory().getId(); bookmarkRepository.delete(bookmark); publishChanged(roomId, userId, RoomBookmarkEventType.BOOKMARK_DELETED, bookmarkId, categoryId); @@ -109,19 +118,21 @@ public void delete(UUID roomId, long bookmarkId, Long userId) { public BookmarkResult updateCategory(UUID roomId, long bookmarkId, long categoryId, Long userId) { roomAccessService.requireExistingActiveMember(roomId, userId); Bookmark bookmark = bookmarkRepository.findByIdAndRoom_Id(bookmarkId, roomId) - .orElseThrow(() -> new CustomException(ErrorCode.BOOKMARK_NOT_FOUND)); + .orElseThrow(() -> new CustomException(ErrorCode.BOOKMARK_NOT_FOUND)); BookmarkCategory category = bookmarkCategoryRepository.findByIdAndRoom_Id(categoryId, roomId) - .orElseThrow(() -> new CustomException(ErrorCode.BOOKMARK_CATEGORY_NOT_FOUND)); + .orElseThrow(() -> new CustomException(ErrorCode.BOOKMARK_CATEGORY_NOT_FOUND)); bookmark.changeCategory(category); BookmarkResult result = BookmarkResult.from(bookmarkRepository.saveAndFlush(bookmark)); publishChanged(roomId, userId, RoomBookmarkEventType.BOOKMARK_UPDATED, result.bookmarkId(), - result.categoryId()); + result.categoryId()); return result; } + + private void publishChanged(UUID roomId, Long actorUserId, RoomBookmarkEventType type, Long bookmarkId, - Long categoryId) { + Long categoryId) { eventPublisher.publishEvent(new RoomBookmarkChangedEvent(roomId, actorUserId, type, bookmarkId, categoryId)); } } diff --git a/src/main/java/com/howaboutus/backend/common/cache/RedisBulkCacheAccessor.java b/src/main/java/com/howaboutus/backend/common/cache/RedisBulkCacheAccessor.java new file mode 100644 index 00000000..160278ef --- /dev/null +++ b/src/main/java/com/howaboutus/backend/common/cache/RedisBulkCacheAccessor.java @@ -0,0 +1,76 @@ +package com.howaboutus.backend.common.cache; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.data.redis.connection.RedisConnection; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.RedisStringCommands; +import org.springframework.data.redis.core.types.Expiration; +import org.springframework.data.redis.serializer.GenericJacksonJsonRedisSerializer; +import org.springframework.stereotype.Component; + +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.jsontype.BasicPolymorphicTypeValidator; + +@Component +public class RedisBulkCacheAccessor { + + private final RedisConnectionFactory connectionFactory; + private final GenericJacksonJsonRedisSerializer serializer; + + public RedisBulkCacheAccessor(RedisConnectionFactory connectionFactory, ObjectMapper objectMapper) { + this.connectionFactory = connectionFactory; + this.serializer = GenericJacksonJsonRedisSerializer.builder(objectMapper::rebuild) + .enableDefaultTyping( + BasicPolymorphicTypeValidator.builder() + .allowIfSubType("com.howaboutus.backend") + .allowIfBaseType(Map.class) + .allowIfBaseType(Collection.class) + .build() + ) + .build(); + } + + public Map multiGet(String cacheName, Collection keys, Class valueType) { + List distinctKeys = keys.stream().distinct().toList(); + if (distinctKeys.isEmpty()) { + return Map.of(); + } + + byte[][] redisKeys = distinctKeys.stream() + .map(key -> redisKey(cacheName, key)) + .toArray(byte[][]::new); + + try (RedisConnection connection = connectionFactory.getConnection()) { + List values = connection.stringCommands().mGet(redisKeys); + Map result = new LinkedHashMap<>(); + for (int index = 0; index < distinctKeys.size(); index++) { + byte[] value = values.get(index); + if (value != null) { + result.put(distinctKeys.get(index), serializer.deserialize(value, valueType)); + } + } + return result; + } + } + + public void put(String cacheName, String key, Object value, Duration ttl) { + try (RedisConnection connection = connectionFactory.getConnection()) { + connection.stringCommands().set( + redisKey(cacheName, key), + serializer.serialize(value), + Expiration.from(ttl), + RedisStringCommands.SetOption.upsert() + ); + } + } + + private byte[] redisKey(String cacheName, String key) { + return (cacheName + "::" + key).getBytes(StandardCharsets.UTF_8); + } +} diff --git a/src/main/java/com/howaboutus/backend/common/config/AsyncConfig.java b/src/main/java/com/howaboutus/backend/common/config/AsyncConfig.java index 54a7f047..65f1badf 100644 --- a/src/main/java/com/howaboutus/backend/common/config/AsyncConfig.java +++ b/src/main/java/com/howaboutus/backend/common/config/AsyncConfig.java @@ -6,26 +6,47 @@ import org.slf4j.MDC; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.task.SimpleAsyncTaskExecutor; import org.springframework.core.task.TaskDecorator; import org.springframework.scheduling.annotation.AsyncConfigurer; import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.annotation.EnableScheduling; -import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import com.howaboutus.backend.common.config.properties.AsyncExecutorProperties; @Configuration @EnableAsync @EnableScheduling public class AsyncConfig implements AsyncConfigurer { + public static final String AI_TASK_EXECUTOR = "aiTaskExecutor"; + public static final String GOOGLE_API_EXECUTOR = "googleApiExecutor"; + + private final AsyncExecutorProperties properties; + + public AsyncConfig(AsyncExecutorProperties properties) { + this.properties = properties; + } + @Bean - public ThreadPoolTaskExecutor taskExecutor() { - ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); - executor.setCorePoolSize(4); - executor.setMaxPoolSize(8); - executor.setQueueCapacity(100); - executor.setThreadNamePrefix("async-"); - executor.setTaskDecorator(mdcTaskDecorator()); - return executor; + public SimpleAsyncTaskExecutor aiTaskExecutor() { + return taskExecutor(properties.ai(), "ai-"); + } + + @Bean + public SimpleAsyncTaskExecutor googleApiExecutor() { + return taskExecutor(properties.google(), "google-api-"); + } + + private SimpleAsyncTaskExecutor taskExecutor( + AsyncExecutorProperties.Settings settings, + String threadNamePrefix) { + SimpleAsyncTaskExecutor taskExecutor = new SimpleAsyncTaskExecutor(); + taskExecutor.setVirtualThreads(true); + taskExecutor.setConcurrencyLimit(settings.concurrencyLimit()); + taskExecutor.setThreadNamePrefix(threadNamePrefix); + taskExecutor.setTaskDecorator(mdcTaskDecorator()); + return taskExecutor; } private TaskDecorator mdcTaskDecorator() { @@ -46,6 +67,6 @@ private TaskDecorator mdcTaskDecorator() { @Override public Executor getAsyncExecutor() { - return taskExecutor(); + return aiTaskExecutor(); } } diff --git a/src/main/java/com/howaboutus/backend/common/config/CachePolicy.java b/src/main/java/com/howaboutus/backend/common/config/CachePolicy.java index 4dc65062..5436d4c8 100644 --- a/src/main/java/com/howaboutus/backend/common/config/CachePolicy.java +++ b/src/main/java/com/howaboutus/backend/common/config/CachePolicy.java @@ -17,7 +17,7 @@ public enum CachePolicy { PLACE_DETAIL(Keys.PLACE_DETAIL, Duration.ofHours(6)), PLACE_PREVIEW(Keys.PLACE_PREVIEW, Duration.ofDays(1)), - PLACE_PHOTO_URI(Keys.PLACE_PHOTO_URI, Duration.ofMinutes(10)), + PLACE_PHOTO_URI(Keys.PLACE_PHOTO_URI, Duration.ofDays(1)), ROUTE(Keys.ROUTE, Duration.ofMinutes(10)); private final String key; diff --git a/src/main/java/com/howaboutus/backend/common/config/properties/AsyncExecutorProperties.java b/src/main/java/com/howaboutus/backend/common/config/properties/AsyncExecutorProperties.java new file mode 100644 index 00000000..4f09f52b --- /dev/null +++ b/src/main/java/com/howaboutus/backend/common/config/properties/AsyncExecutorProperties.java @@ -0,0 +1,37 @@ +package com.howaboutus.backend.common.config.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "app.executor") +public record AsyncExecutorProperties( + Settings ai, + Settings google +) { + + private static final Settings DEFAULT_AI = new Settings(4); + private static final Settings DEFAULT_GOOGLE = new Settings(8); + + public AsyncExecutorProperties { + ai = merge(ai, DEFAULT_AI); + google = merge(google, DEFAULT_GOOGLE); + } + + private static Settings merge(Settings settings, Settings defaults) { + if (settings == null) { + return defaults; + } + return new Settings(positiveOrDefault(settings.concurrencyLimit(), defaults.concurrencyLimit())); + } + + private static int positiveOrDefault(int value, int defaultValue) { + if (value > 0) { + return value; + } + return defaultValue; + } + + public record Settings( + int concurrencyLimit + ) { + } +} diff --git a/src/main/java/com/howaboutus/backend/common/config/properties/RateLimitProperties.java b/src/main/java/com/howaboutus/backend/common/config/properties/RateLimitProperties.java index 2ad866c9..1ed0e1f3 100644 --- a/src/main/java/com/howaboutus/backend/common/config/properties/RateLimitProperties.java +++ b/src/main/java/com/howaboutus/backend/common/config/properties/RateLimitProperties.java @@ -18,8 +18,9 @@ public record RateLimitProperties( @NotNull Map policies ) { private static final List REQUIRED_POLICIES = List.of( - "auth-refresh-session", "places-search", "places-read", "route-user", - "route-room", "join-user", "write-user", "chat" + "auth-refresh-session", "places-search", "places-read-single", "places-read-bulk", + "places-photo-single", "places-photo-bulk", "route-single-user", "route-single-room", + "route-batch-user", "route-batch-room", "join-user", "write-user", "chat" ); public RateLimitProperties { diff --git a/src/main/java/com/howaboutus/backend/common/ratelimit/HttpRateLimitPolicyResolver.java b/src/main/java/com/howaboutus/backend/common/ratelimit/HttpRateLimitPolicyResolver.java index 2c5bbd50..f5ba19e1 100644 --- a/src/main/java/com/howaboutus/backend/common/ratelimit/HttpRateLimitPolicyResolver.java +++ b/src/main/java/com/howaboutus/backend/common/ratelimit/HttpRateLimitPolicyResolver.java @@ -26,7 +26,14 @@ public class HttpRateLimitPolicyResolver { private static final Pattern ROUTE_PATTERN = Pattern.compile("^/rooms/([^/]+)/schedules/[^/]+/items/[^/]+/route$"); - private static final Pattern PLACE_READ_PATTERN = Pattern.compile("^/places/[^/]+(/preview|/photo-names)?$"); + private static final Pattern ROUTE_BATCH_PATTERN = + Pattern.compile("^/rooms/([^/]+)/schedules/[^/]+/routes/batch$"); + private static final Pattern PLACE_READ_SINGLE_PATTERN = Pattern.compile("^/places/[^/]+(/preview|/photo-names)?$"); + private static final List PLACE_BATCH_READ_PATHS = List.of( + "/places/previews/batch", + "/places/photo-names/batch" + // Note: /places/photos/batch is handled separately in addPlacesPolicies + ); private final RateLimitProperties rateLimitProperties; @@ -68,28 +75,46 @@ private void addAuthPolicies(HttpServletRequest request, String method, String p } private void addPlacesPolicies(String method, String path, Optional userId, List plans) { - if (!"GET".equals(method) || userId.isEmpty()) { + if (userId.isEmpty()) { return; } - if ("/places/search".equals(path)) { + if ("GET".equals(method) && "/places/search".equals(path)) { plans.add(plan("http:places-search", "user", String.valueOf(userId.get()), "places-search")); return; } - if ("/places/photos".equals(path) || PLACE_READ_PATTERN.matcher(path).matches()) { - plans.add(plan("http:places-read", "user", String.valueOf(userId.get()), "places-read")); + if ("GET".equals(method) && "/places/photos".equals(path)) { + plans.add(plan("http:places-photo-single", "user", String.valueOf(userId.get()), + "places-photo-single")); + return; + } + if ("POST".equals(method) && "/places/photos/batch".equals(path)) { + plans.add(plan("http:places-photo-bulk", "user", String.valueOf(userId.get()), + "places-photo-bulk")); + return; + } + if ("GET".equals(method) && PLACE_READ_SINGLE_PATTERN.matcher(path).matches()) { + plans.add(plan("http:places-read-single", "user", String.valueOf(userId.get()), "places-read-single")); + } else if ("POST".equals(method) && PLACE_BATCH_READ_PATHS.contains(path)) { + plans.add(plan("http:places-read-bulk", "user", String.valueOf(userId.get()), "places-read-bulk")); } } private void addRoutePolicies(String method, String path, Optional userId, List plans) { - if (!"GET".equals(method)) { - return; - } - Matcher matcher = ROUTE_PATTERN.matcher(path); - if (!matcher.matches()) { - return; + if ("GET".equals(method)) { + Matcher matcher = ROUTE_PATTERN.matcher(path); + if (matcher.matches()) { + userId.ifPresent(id -> plans.add( + plan("http:route-single", "user", String.valueOf(id), "route-single-user"))); + plans.add(plan("http:route-single", "room", matcher.group(1), "route-single-room")); + } + } else if ("POST".equals(method)) { + Matcher matcher = ROUTE_BATCH_PATTERN.matcher(path); + if (matcher.matches()) { + userId.ifPresent(id -> plans.add( + plan("http:route-batch", "user", String.valueOf(id), "route-batch-user"))); + plans.add(plan("http:route-batch", "room", matcher.group(1), "route-batch-room")); + } } - userId.ifPresent(id -> plans.add(plan("http:route", "user", String.valueOf(id), "route-user"))); - plans.add(plan("http:route", "room", matcher.group(1), "route-room")); } private void addJoinPolicies(String method, @@ -103,7 +128,9 @@ private void addJoinPolicies(String method, } private void addWritePolicy(String method, String path, Optional userId, List plans) { - if (userId.isEmpty() || path.startsWith("/auth/")) { + if (userId.isEmpty() || path.startsWith("/auth/") || PLACE_BATCH_READ_PATHS.contains(path) + || "/places/photos/batch".equals(path) + || ROUTE_BATCH_PATTERN.matcher(path).matches()) { return; } if ("POST".equals(method) || "PATCH".equals(method) || "DELETE".equals(method)) { diff --git a/src/main/java/com/howaboutus/backend/common/utils/AsyncHelper.java b/src/main/java/com/howaboutus/backend/common/utils/AsyncHelper.java new file mode 100644 index 00000000..d21743af --- /dev/null +++ b/src/main/java/com/howaboutus/backend/common/utils/AsyncHelper.java @@ -0,0 +1,47 @@ +package com.howaboutus.backend.common.utils; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.function.Function; + +import com.howaboutus.backend.common.error.CustomException; +import com.howaboutus.backend.common.error.ErrorCode; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/** + * 비동기 배치 조회 연산 시의 공통 복구 및 join 처리를 담당합니다. + * + * @author Minhyung Kim + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class AsyncHelper { + + /** + * CompletableFuture를 join하되, {@link ErrorCode#EXTERNAL_API_ERROR} 예외가 발생하면 + * 지정된 실패 처리기 함수를 통해 결과를 복구합니다. 그 외의 런타임 예외는 그대로 상위로 전파됩니다. + * + * @param future 비동기 작업 + * @param failureHandler 외부 API 오류 발생 시 복구 핸들러 + * @param 반환 타입 + * @return 작업 결과 혹은 복구 결과 + */ + public static T joinOrHandleExternalApiError( + CompletableFuture future, + Function failureHandler + ) { + try { + return future.join(); + } catch (CompletionException exception) { + if (exception.getCause() instanceof CustomException customException + && customException.getErrorCode() == ErrorCode.EXTERNAL_API_ERROR) { + return failureHandler.apply(customException); + } + if (exception.getCause() instanceof RuntimeException runtimeException) { + throw runtimeException; + } + throw exception; + } + } +} diff --git a/src/main/java/com/howaboutus/backend/places/controller/PlaceController.java b/src/main/java/com/howaboutus/backend/places/controller/PlaceController.java index 4b038f0f..5a54aee0 100644 --- a/src/main/java/com/howaboutus/backend/places/controller/PlaceController.java +++ b/src/main/java/com/howaboutus/backend/places/controller/PlaceController.java @@ -1,9 +1,12 @@ package com.howaboutus.backend.places.controller; import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -11,8 +14,14 @@ import com.howaboutus.backend.common.error.ErrorCode; import com.howaboutus.backend.common.logging.Loggable; import com.howaboutus.backend.places.controller.dto.PlaceDetailResponse; +import com.howaboutus.backend.places.controller.dto.PlacePhotoBatchRequest; +import com.howaboutus.backend.places.controller.dto.PlacePhotoBatchResponse; +import com.howaboutus.backend.places.controller.dto.PlacePhotoNameBatchRequest; +import com.howaboutus.backend.places.controller.dto.PlacePhotoNameBatchResponse; import com.howaboutus.backend.places.controller.dto.PlacePhotoNamesResponse; import com.howaboutus.backend.places.controller.dto.PlacePhotoResponse; +import com.howaboutus.backend.places.controller.dto.PlacePreviewBatchRequest; +import com.howaboutus.backend.places.controller.dto.PlacePreviewBatchResponse; import com.howaboutus.backend.places.controller.dto.PlacePreviewResponse; import com.howaboutus.backend.places.controller.dto.PlaceSearchPageResponse; import com.howaboutus.backend.places.service.PlaceDetailService; @@ -26,6 +35,7 @@ import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import jakarta.validation.constraints.DecimalMax; import jakarta.validation.constraints.DecimalMin; import jakarta.validation.constraints.Max; @@ -122,25 +132,81 @@ public PlacePreviewResponse getPreview( return PlacePreviewResponse.from(placePreviewService.getPreview(googlePlaceId)); } + @Operation( + summary = "장소 미리보기 벌크 조회", + description = "googlePlaceId 목록을 기반으로 카드 미리보기에 필요한 최소 장소 정보를 한 번에 조회합니다. " + + "서버는 중복 ID를 제거하고 캐시 miss 항목만 Google Places API로 조회합니다. " + + "일부 항목의 외부 API 실패는 응답 body의 항목별 status/errorCode로 반환합니다." + ) + @ApiResponse(responseCode = "200", description = "조회 성공") + @ApiErrorCodes({ErrorCode.INVALID_TOKEN, ErrorCode.ACCESS_TOKEN_EXPIRED}) + @Loggable + @PostMapping("/places/previews/batch") + public PlacePreviewBatchResponse getPreviews( + @Valid + @RequestBody + PlacePreviewBatchRequest request) { + return PlacePreviewBatchResponse.from(placePreviewService.getPreviews(request.googlePlaceIds())); + } + @Operation( summary = "장소 사진 URL 조회", description = "photoName을 기반으로 Google 장소 사진 URL을 조회합니다. " + "이 API는 로그인 유저별 요청 속도 제한(Rate Limit)을 가집니다. " - + "사진 기능이 비활성화된 경우 204를 반환합니다." + + "사진 기능이 비활성화된 경우 204를 반환합니다. 사진 URL은 기본 크기 기준으로 캐시합니다." ) @ApiResponse(responseCode = "200", description = "조회 성공") @ApiResponse(responseCode = "204", description = "사진 기능 비활성화 상태 (No Content)", content = @Content) @ApiErrorCodes({ErrorCode.INVALID_TOKEN, ErrorCode.ACCESS_TOKEN_EXPIRED, ErrorCode.EXTERNAL_API_ERROR}) @GetMapping("/places/photos") - public PlacePhotoResponse getPhotoUrl( + public ResponseEntity getPhotoUrl( @Parameter(description = "Google 장소 사진 리소스 이름", example = "places/ChIJ123/photos/abc") @RequestParam @Pattern(regexp = "^places/[^/]+/photos/[^/]+$", message = "유효하지 않은 photoName 형식입니다") String photoName) { if (photoEnabled) { - return new PlacePhotoResponse(placePhotoService.getPhotoUrl(photoName)); + return ResponseEntity.ok(PlacePhotoResponse.from(placePhotoService.getPhotoUrl(photoName))); } - return null; + return ResponseEntity.noContent().build(); + } + + @Operation( + summary = "장소 사진 URL 벌크 조회", + description = "photoName 목록을 기반으로 Google 장소 사진 URL을 한 번에 조회합니다. " + + "서버는 중복 photoName을 제거하고 기본 크기 기준 캐시 miss 항목만 Google Photo Media API로 조회합니다. " + + "일부 항목의 외부 API 실패는 응답 body의 항목별 status/errorCode로 반환합니다. " + + "사진 기능이 비활성화된 경우 204를 반환합니다." + ) + @ApiResponse(responseCode = "200", description = "조회 성공") + @ApiResponse(responseCode = "204", description = "사진 기능 비활성화 상태 (No Content)", content = @Content) + @ApiErrorCodes({ErrorCode.INVALID_TOKEN, ErrorCode.ACCESS_TOKEN_EXPIRED}) + @PostMapping("/places/photos/batch") + public ResponseEntity getPhotoUrls( + @Valid + @RequestBody + PlacePhotoBatchRequest request) { + if (photoEnabled) { + return ResponseEntity.ok(PlacePhotoBatchResponse.from(placePhotoService.getPhotoUrls( + request.photoNames() + ))); + } + return ResponseEntity.noContent().build(); + } + + @Operation( + summary = "장소 대표 사진 이름 벌크 조회", + description = "googlePlaceId 목록을 기반으로 각 장소의 대표 사진 이름(photoName)을 한 번에 조회합니다. " + + "서버는 중복 ID를 제거하고 Google Place Details API의 사진 이름 필드만 병렬 조회합니다. " + + "일부 항목의 외부 API 실패는 응답 body의 항목별 status/errorCode로 반환합니다." + ) + @ApiResponse(responseCode = "200", description = "조회 성공") + @ApiErrorCodes({ErrorCode.INVALID_TOKEN, ErrorCode.ACCESS_TOKEN_EXPIRED}) + @PostMapping("/places/photo-names/batch") + public PlacePhotoNameBatchResponse getPhotoNamesBatch( + @Valid + @RequestBody + PlacePhotoNameBatchRequest request) { + return PlacePhotoNameBatchResponse.from(placePhotoNameService.getFirstPhotoNames(request.googlePlaceIds())); } @Operation( diff --git a/src/main/java/com/howaboutus/backend/places/controller/dto/PlacePhotoBatchItemResponse.java b/src/main/java/com/howaboutus/backend/places/controller/dto/PlacePhotoBatchItemResponse.java new file mode 100644 index 00000000..ad6220fd --- /dev/null +++ b/src/main/java/com/howaboutus/backend/places/controller/dto/PlacePhotoBatchItemResponse.java @@ -0,0 +1,32 @@ +package com.howaboutus.backend.places.controller.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.howaboutus.backend.places.service.dto.PlacePhotoBatchItemResult; + +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record PlacePhotoBatchItemResponse( + @Schema(description = "요청한 Google 장소 사진 리소스 이름", example = "places/ChIJ123/photos/a") + String photoName, + + @Schema(description = "항목별 처리 상태", example = "OK", allowableValues = {"OK", "FAILED"}) + String status, + + @Schema(description = "사진 URL. status가 OK일 때만 포함됩니다.", example = "https://lh3.googleusercontent.com/a.jpg", + nullable = true) + String photoUrl, + + @Schema(description = "status가 OK가 아닐 때의 항목별 오류 코드", example = "EXTERNAL_API_ERROR", + nullable = true) + String errorCode +) { + public static PlacePhotoBatchItemResponse from(PlacePhotoBatchItemResult result) { + return new PlacePhotoBatchItemResponse( + result.photoName(), + result.status(), + result.photoUrl(), + result.errorCode() + ); + } +} diff --git a/src/main/java/com/howaboutus/backend/places/controller/dto/PlacePhotoBatchRequest.java b/src/main/java/com/howaboutus/backend/places/controller/dto/PlacePhotoBatchRequest.java new file mode 100644 index 00000000..dba9371f --- /dev/null +++ b/src/main/java/com/howaboutus/backend/places/controller/dto/PlacePhotoBatchRequest.java @@ -0,0 +1,20 @@ +package com.howaboutus.backend.places.controller.dto; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +public record PlacePhotoBatchRequest( + @Schema(description = "벌크 조회할 Google 장소 사진 리소스 이름 목록. 최대 100개까지 허용합니다.") + @NotEmpty(message = "photoNames는 비어 있을 수 없습니다") + @Size(max = 100, message = "photoNames는 최대 100개까지 가능합니다") + List<@NotBlank(message = "photoName은 공백일 수 없습니다") @Pattern( + regexp = "^places/[^/]+/photos/[^/]+$", + message = "유효하지 않은 photoName 형식입니다" + ) String> photoNames +) { +} diff --git a/src/main/java/com/howaboutus/backend/places/controller/dto/PlacePhotoBatchResponse.java b/src/main/java/com/howaboutus/backend/places/controller/dto/PlacePhotoBatchResponse.java new file mode 100644 index 00000000..f858bc4c --- /dev/null +++ b/src/main/java/com/howaboutus/backend/places/controller/dto/PlacePhotoBatchResponse.java @@ -0,0 +1,20 @@ +package com.howaboutus.backend.places.controller.dto; + +import java.util.List; + +import com.howaboutus.backend.places.service.dto.PlacePhotoBatchItemResult; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record PlacePhotoBatchResponse( + @Schema(description = "요청 순서대로 정렬된 사진 URL 항목별 결과") + List photos +) { + public static PlacePhotoBatchResponse from(List results) { + return new PlacePhotoBatchResponse( + results.stream() + .map(PlacePhotoBatchItemResponse::from) + .toList() + ); + } +} diff --git a/src/main/java/com/howaboutus/backend/places/controller/dto/PlacePhotoNameBatchItemResponse.java b/src/main/java/com/howaboutus/backend/places/controller/dto/PlacePhotoNameBatchItemResponse.java new file mode 100644 index 00000000..4e4869a9 --- /dev/null +++ b/src/main/java/com/howaboutus/backend/places/controller/dto/PlacePhotoNameBatchItemResponse.java @@ -0,0 +1,33 @@ +package com.howaboutus.backend.places.controller.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.howaboutus.backend.places.service.dto.PlacePhotoNameBatchItemResult; + +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record PlacePhotoNameBatchItemResponse( + @Schema(description = "요청한 Google Place ID", example = "ChIJ123") + String googlePlaceId, + + @Schema(description = "항목별 처리 상태", example = "OK", allowableValues = {"OK", "EXTERNAL_API_ERROR"}) + String status, + + @Schema(description = "대표 사진 리소스 이름. 없거나 조회 실패 시 생략됩니다.", + example = "places/ChIJ123/photos/a", nullable = true) + String photoName, + + @Schema(description = "status가 OK가 아닐 때의 항목별 오류 코드", example = "EXTERNAL_API_ERROR", + nullable = true) + String errorCode +) { + + public static PlacePhotoNameBatchItemResponse from(PlacePhotoNameBatchItemResult result) { + return new PlacePhotoNameBatchItemResponse( + result.googlePlaceId(), + result.status(), + result.photoName(), + result.errorCode() + ); + } +} diff --git a/src/main/java/com/howaboutus/backend/places/controller/dto/PlacePhotoNameBatchRequest.java b/src/main/java/com/howaboutus/backend/places/controller/dto/PlacePhotoNameBatchRequest.java new file mode 100644 index 00000000..0cce53c1 --- /dev/null +++ b/src/main/java/com/howaboutus/backend/places/controller/dto/PlacePhotoNameBatchRequest.java @@ -0,0 +1,20 @@ +package com.howaboutus.backend.places.controller.dto; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; + +public record PlacePhotoNameBatchRequest( + @Schema(description = "대표 사진 이름을 벌크 조회할 Google Place ID 목록. 최대 100개까지 허용합니다.", + example = "[\"ChIJ123\"]") + @NotEmpty(message = "googlePlaceIds는 비어 있을 수 없습니다") + @Size(max = 100, message = "googlePlaceIds는 최대 100개까지 가능합니다") + List<@NotBlank(message = "googlePlaceId는 공백일 수 없습니다") @Size( + max = 300, + message = "googlePlaceId는 300자 이하여야 합니다" + ) String> googlePlaceIds +) { +} diff --git a/src/main/java/com/howaboutus/backend/places/controller/dto/PlacePhotoNameBatchResponse.java b/src/main/java/com/howaboutus/backend/places/controller/dto/PlacePhotoNameBatchResponse.java new file mode 100644 index 00000000..02cf25db --- /dev/null +++ b/src/main/java/com/howaboutus/backend/places/controller/dto/PlacePhotoNameBatchResponse.java @@ -0,0 +1,21 @@ +package com.howaboutus.backend.places.controller.dto; + +import java.util.List; + +import com.howaboutus.backend.places.service.dto.PlacePhotoNameBatchItemResult; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record PlacePhotoNameBatchResponse( + @Schema(description = "요청 순서대로 정렬된 대표 사진 이름 항목별 결과") + List photoNames +) { + + public static PlacePhotoNameBatchResponse from(List results) { + return new PlacePhotoNameBatchResponse( + results.stream() + .map(PlacePhotoNameBatchItemResponse::from) + .toList() + ); + } +} diff --git a/src/main/java/com/howaboutus/backend/places/controller/dto/PlacePhotoResponse.java b/src/main/java/com/howaboutus/backend/places/controller/dto/PlacePhotoResponse.java index b646ae9f..35ad8de9 100644 --- a/src/main/java/com/howaboutus/backend/places/controller/dto/PlacePhotoResponse.java +++ b/src/main/java/com/howaboutus/backend/places/controller/dto/PlacePhotoResponse.java @@ -1,4 +1,7 @@ package com.howaboutus.backend.places.controller.dto; public record PlacePhotoResponse(String photoUrl) { + public static PlacePhotoResponse from(String photoUrl) { + return new PlacePhotoResponse(photoUrl); + } } diff --git a/src/main/java/com/howaboutus/backend/places/controller/dto/PlacePreviewBatchItemResponse.java b/src/main/java/com/howaboutus/backend/places/controller/dto/PlacePreviewBatchItemResponse.java new file mode 100644 index 00000000..955f84a3 --- /dev/null +++ b/src/main/java/com/howaboutus/backend/places/controller/dto/PlacePreviewBatchItemResponse.java @@ -0,0 +1,63 @@ +package com.howaboutus.backend.places.controller.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.howaboutus.backend.places.service.dto.PlacePreviewBatchItemResult; + +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record PlacePreviewBatchItemResponse( + @Schema(description = "요청한 Google Place ID", example = "ChIJ123") + String googlePlaceId, + + @Schema(description = "항목별 처리 상태", example = "OK", allowableValues = {"OK", "FAILED"}) + String status, + + @Schema(description = "장소명. status가 OK일 때만 포함됩니다.", example = "Cafe Layered", nullable = true) + String name, + + @Schema(description = "장소 주소. status가 OK일 때만 포함됩니다.", example = "서울 종로구 ...", nullable = true) + String formattedAddress, + + @Schema(description = "Google Places primaryType. status가 OK일 때만 포함됩니다.", example = "cafe", nullable = true) + String primaryType, + + @Schema(description = "Google Places primaryType 표시명. status가 OK일 때만 포함됩니다.", example = "카페", nullable = true) + String primaryTypeDisplayName, + + @Schema(description = "장소 좌표. status가 OK일 때만 포함됩니다.", nullable = true) + Location location, + + @Schema(description = "대표 사진 리소스명. 없거나 조회 실패 시 null입니다.", example = "places/ChIJ123/photos/a", + nullable = true) + String photoName, + + @Schema(description = "status가 OK가 아닐 때의 항목별 오류 코드", example = "EXTERNAL_API_ERROR", + nullable = true) + String errorCode +) { + public record Location( + @Schema(description = "위도", example = "37.5665") + double latitude, + @Schema(description = "경도", example = "126.9780") + double longitude + ) { } + + public static PlacePreviewBatchItemResponse from(PlacePreviewBatchItemResult result) { + Location location = result.location() != null + ? new Location(result.location().latitude(), result.location().longitude()) + : null; + + return new PlacePreviewBatchItemResponse( + result.googlePlaceId(), + result.status(), + result.name(), + result.formattedAddress(), + result.primaryType(), + result.primaryTypeDisplayName(), + location, + result.photoName(), + result.errorCode() + ); + } +} diff --git a/src/main/java/com/howaboutus/backend/places/controller/dto/PlacePreviewBatchRequest.java b/src/main/java/com/howaboutus/backend/places/controller/dto/PlacePreviewBatchRequest.java new file mode 100644 index 00000000..c1e56d00 --- /dev/null +++ b/src/main/java/com/howaboutus/backend/places/controller/dto/PlacePreviewBatchRequest.java @@ -0,0 +1,19 @@ +package com.howaboutus.backend.places.controller.dto; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; + +public record PlacePreviewBatchRequest( + @Schema(description = "벌크 조회할 Google Place ID 목록. 최대 100개까지 허용합니다.", example = "[\"ChIJ123\"]") + @NotEmpty(message = "googlePlaceIds는 비어 있을 수 없습니다") + @Size(max = 100, message = "googlePlaceIds는 최대 100개까지 가능합니다") + List<@NotBlank(message = "googlePlaceId는 공백일 수 없습니다") @Size( + max = 300, + message = "googlePlaceId는 300자 이하여야 합니다" + ) String> googlePlaceIds +) { +} diff --git a/src/main/java/com/howaboutus/backend/places/controller/dto/PlacePreviewBatchResponse.java b/src/main/java/com/howaboutus/backend/places/controller/dto/PlacePreviewBatchResponse.java new file mode 100644 index 00000000..55b0aae4 --- /dev/null +++ b/src/main/java/com/howaboutus/backend/places/controller/dto/PlacePreviewBatchResponse.java @@ -0,0 +1,20 @@ +package com.howaboutus.backend.places.controller.dto; + +import java.util.List; + +import com.howaboutus.backend.places.service.dto.PlacePreviewBatchItemResult; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record PlacePreviewBatchResponse( + @Schema(description = "요청 순서대로 정렬된 장소 미리보기 항목별 결과") + List previews +) { + public static PlacePreviewBatchResponse from(List results) { + return new PlacePreviewBatchResponse( + results.stream() + .map(PlacePreviewBatchItemResponse::from) + .toList() + ); + } +} diff --git a/src/main/java/com/howaboutus/backend/places/service/PlacePhotoNameService.java b/src/main/java/com/howaboutus/backend/places/service/PlacePhotoNameService.java index 0bbaf0f6..c03aaf74 100644 --- a/src/main/java/com/howaboutus/backend/places/service/PlacePhotoNameService.java +++ b/src/main/java/com/howaboutus/backend/places/service/PlacePhotoNameService.java @@ -1,12 +1,19 @@ package com.howaboutus.backend.places.service; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; import org.springframework.stereotype.Service; import com.howaboutus.backend.common.config.properties.GooglePlacesProperties; import com.howaboutus.backend.common.integration.google.GooglePlaceDetailClient; import com.howaboutus.backend.common.integration.google.dto.GooglePlacePhoto; +import com.howaboutus.backend.common.utils.AsyncHelper; +import com.howaboutus.backend.places.service.dto.PlacePhotoNameBatchItemResult; import lombok.RequiredArgsConstructor; @@ -16,6 +23,7 @@ public class PlacePhotoNameService { private final GooglePlaceDetailClient googlePlaceDetailClient; private final GooglePlacesProperties googlePlacesProperties; + private final Executor googleApiExecutor; public List getPhotoNames(String googlePlaceId) { List photos = googlePlaceDetailClient.getPhotoNames(googlePlaceId).photos(); @@ -25,6 +33,7 @@ public List getPhotoNames(String googlePlaceId) { return photos.stream() .limit(googlePlacesProperties.maxPhotoCount()) .map(GooglePlacePhoto::name) + .filter(Objects::nonNull) .toList(); } @@ -33,4 +42,32 @@ public String getFirstPhotoName(String googlePlaceId) { .findFirst() .orElse(null); } + + public List getFirstPhotoNames(List googlePlaceIds) { + List distinctPlaceIds = googlePlaceIds.stream().distinct().toList(); + Map> futures = new LinkedHashMap<>(); + distinctPlaceIds.forEach(googlePlaceId -> futures.put( + googlePlaceId, + CompletableFuture.supplyAsync( + () -> PlacePhotoNameBatchItemResult.ok(googlePlaceId, getFirstPhotoName(googlePlaceId)), + googleApiExecutor + ) + )); + + Map resolved = new LinkedHashMap<>(); + futures.forEach((googlePlaceId, future) -> resolved.put( + googlePlaceId, + AsyncHelper.joinOrHandleExternalApiError( + future, + customException -> PlacePhotoNameBatchItemResult.failure( + googlePlaceId, + customException.getErrorCode().name() + ) + ) + )); + + return googlePlaceIds.stream() + .map(resolved::get) + .toList(); + } } diff --git a/src/main/java/com/howaboutus/backend/places/service/PlacePhotoService.java b/src/main/java/com/howaboutus/backend/places/service/PlacePhotoService.java index 5de5ebef..d696aa3f 100644 --- a/src/main/java/com/howaboutus/backend/places/service/PlacePhotoService.java +++ b/src/main/java/com/howaboutus/backend/places/service/PlacePhotoService.java @@ -1,10 +1,18 @@ package com.howaboutus.backend.places.service; -import org.springframework.cache.annotation.Cacheable; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; + import org.springframework.stereotype.Service; +import com.howaboutus.backend.common.cache.RedisBulkCacheAccessor; import com.howaboutus.backend.common.config.CachePolicy; import com.howaboutus.backend.common.integration.google.GooglePlacePhotoClient; +import com.howaboutus.backend.common.utils.AsyncHelper; +import com.howaboutus.backend.places.service.dto.PlacePhotoBatchItemResult; import lombok.RequiredArgsConstructor; @@ -12,10 +20,101 @@ @RequiredArgsConstructor public class PlacePhotoService { + private static final String DEFAULT_PHOTO_SIZE_CACHE_SUFFIX = ":w400:h400"; + private final GooglePlacePhotoClient googlePlacePhotoClient; + private final RedisBulkCacheAccessor redisBulkCacheAccessor; + private final Executor googleApiExecutor; - @Cacheable(cacheNames = CachePolicy.Keys.PLACE_PHOTO_URI) public String getPhotoUrl(String photoName) { - return googlePlacePhotoClient.getPhotoUri(photoName); + String cacheKey = cacheKey(photoName); + Map cachedByCacheKey = redisBulkCacheAccessor.multiGet( + CachePolicy.Keys.PLACE_PHOTO_URI, + List.of(cacheKey), + String.class + ); + String cached = cachedByCacheKey.get(cacheKey); + if (cached != null) { + return cached; + } + + String photoUrl = googlePlacePhotoClient.getPhotoUri(photoName); + cachePhotoUrl(photoName, photoUrl); + return photoUrl; + } + + public List getPhotoUrls(List photoNames) { + List distinctPhotoNames = photoNames.stream().distinct().toList(); + Map cacheKeysByPhotoName = new LinkedHashMap<>(); + distinctPhotoNames.forEach(photoName -> cacheKeysByPhotoName.put( + photoName, + cacheKey(photoName) + )); + + Map cachedByCacheKey = redisBulkCacheAccessor.multiGet( + CachePolicy.Keys.PLACE_PHOTO_URI, + cacheKeysByPhotoName.values(), + String.class + ); + List missingPhotoNames = distinctPhotoNames.stream() + .filter(photoName -> !cachedByCacheKey.containsKey(cacheKeysByPhotoName.get(photoName))) + .toList(); + Map freshByPhotoName = fetchMissingPhotoUrls(missingPhotoNames); + + return photoNames.stream() + .map(photoName -> resolvePhotoUrl(photoName, cacheKeysByPhotoName, cachedByCacheKey, freshByPhotoName)) + .toList(); + } + + private Map fetchMissingPhotoUrls(List photoNames) { + Map> futures = new LinkedHashMap<>(); + photoNames.forEach(photoName -> futures.put( + photoName, + CompletableFuture.supplyAsync( + () -> googlePlacePhotoClient.getPhotoUri(photoName), + googleApiExecutor + ) + )); + + Map result = new LinkedHashMap<>(); + futures.forEach((photoName, future) -> { + PlacePhotoBatchItemResult itemResult = AsyncHelper.joinOrHandleExternalApiError( + future.thenApply(url -> PlacePhotoBatchItemResult.ok(photoName, url)), + customException -> PlacePhotoBatchItemResult.failure( + photoName, + customException.getErrorCode().name() + ) + ); + if ("OK".equals(itemResult.status())) { + cachePhotoUrl(photoName, itemResult.photoUrl()); + } + result.put(photoName, itemResult); + }); + return result; + } + + private PlacePhotoBatchItemResult resolvePhotoUrl( + String photoName, + Map cacheKeysByPhotoName, + Map cachedByCacheKey, + Map freshByPhotoName) { + String cached = cachedByCacheKey.get(cacheKeysByPhotoName.get(photoName)); + if (cached != null) { + return PlacePhotoBatchItemResult.ok(photoName, cached); + } + return freshByPhotoName.get(photoName); + } + + private String cacheKey(String photoName) { + return photoName + DEFAULT_PHOTO_SIZE_CACHE_SUFFIX; + } + + private void cachePhotoUrl(String photoName, String photoUrl) { + redisBulkCacheAccessor.put( + CachePolicy.Keys.PLACE_PHOTO_URI, + cacheKey(photoName), + photoUrl, + CachePolicy.PLACE_PHOTO_URI.getDuration() + ); } } diff --git a/src/main/java/com/howaboutus/backend/places/service/PlacePreviewService.java b/src/main/java/com/howaboutus/backend/places/service/PlacePreviewService.java index 016d3082..e0d215c1 100644 --- a/src/main/java/com/howaboutus/backend/places/service/PlacePreviewService.java +++ b/src/main/java/com/howaboutus/backend/places/service/PlacePreviewService.java @@ -1,13 +1,21 @@ package com.howaboutus.backend.places.service; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; import org.springframework.cache.Cache; import org.springframework.cache.CacheManager; import org.springframework.stereotype.Service; +import com.howaboutus.backend.common.cache.RedisBulkCacheAccessor; import com.howaboutus.backend.common.config.CachePolicy; import com.howaboutus.backend.common.integration.google.GooglePlaceDetailClient; +import com.howaboutus.backend.common.utils.AsyncHelper; +import com.howaboutus.backend.places.service.dto.PlacePreviewBatchItemResult; import com.howaboutus.backend.places.service.dto.PlacePreviewResult; import lombok.RequiredArgsConstructor; @@ -19,6 +27,8 @@ public class PlacePreviewService { private final GooglePlaceDetailClient googlePlaceDetailClient; private final PlacePhotoNameService placePhotoNameService; private final CacheManager cacheManager; + private final RedisBulkCacheAccessor redisBulkCacheAccessor; + private final Executor googleApiExecutor; public PlacePreviewResult getPreview(String googlePlaceId) { Cache cache = Objects.requireNonNull(cacheManager.getCache(CachePolicy.Keys.PLACE_PREVIEW)); @@ -31,4 +41,73 @@ public PlacePreviewResult getPreview(String googlePlaceId) { cache.put(googlePlaceId, fresh.withoutPhotoName()); return fresh; } + + public List getPreviews(List googlePlaceIds) { + List distinctPlaceIds = googlePlaceIds.stream().distinct().toList(); + Map cached = redisBulkCacheAccessor.multiGet( + CachePolicy.Keys.PLACE_PREVIEW, + distinctPlaceIds, + PlacePreviewResult.class + ); + List missingPlaceIds = distinctPlaceIds.stream() + .filter(googlePlaceId -> !cached.containsKey(googlePlaceId)) + .toList(); + + Map resolved = new LinkedHashMap<>(); + resolved.putAll(fetchCachedWithFreshPhotoNames(cached)); + resolved.putAll(fetchMissingPreviews(missingPlaceIds)); + + return googlePlaceIds.stream() + .map(resolved::get) + .toList(); + } + + private Map fetchCachedWithFreshPhotoNames( + Map cached) { + Map> futures = new LinkedHashMap<>(); + cached.forEach((googlePlaceId, preview) -> futures.put( + googlePlaceId, + CompletableFuture.supplyAsync( + () -> PlacePreviewBatchItemResult.ok( + preview.withPhotoName(placePhotoNameService.getFirstPhotoName(googlePlaceId)) + ), + googleApiExecutor + ) + )); + return joinPreviewFutures(futures); + } + + private Map fetchMissingPreviews(List googlePlaceIds) { + Map> futures = new LinkedHashMap<>(); + googlePlaceIds.forEach(googlePlaceId -> futures.put( + googlePlaceId, + CompletableFuture.supplyAsync(() -> { + PlacePreviewResult fresh = PlacePreviewResult.from(googlePlaceDetailClient.getPreview(googlePlaceId)); + redisBulkCacheAccessor.put( + CachePolicy.Keys.PLACE_PREVIEW, + googlePlaceId, + fresh.withoutPhotoName(), + CachePolicy.PLACE_PREVIEW.getDuration() + ); + return PlacePreviewBatchItemResult.ok(fresh); + }, googleApiExecutor) + )); + return joinPreviewFutures(futures); + } + + private Map joinPreviewFutures( + Map> futures) { + Map result = new LinkedHashMap<>(); + futures.forEach((googlePlaceId, future) -> result.put( + googlePlaceId, + AsyncHelper.joinOrHandleExternalApiError( + future, + customException -> PlacePreviewBatchItemResult.failure( + googlePlaceId, + customException.getErrorCode().name() + ) + ) + )); + return result; + } } diff --git a/src/main/java/com/howaboutus/backend/places/service/dto/PlacePhotoBatchItemResult.java b/src/main/java/com/howaboutus/backend/places/service/dto/PlacePhotoBatchItemResult.java new file mode 100644 index 00000000..dc7e619b --- /dev/null +++ b/src/main/java/com/howaboutus/backend/places/service/dto/PlacePhotoBatchItemResult.java @@ -0,0 +1,17 @@ +package com.howaboutus.backend.places.service.dto; + +public record PlacePhotoBatchItemResult( + String photoName, + String status, + String photoUrl, + String errorCode +) { + + public static PlacePhotoBatchItemResult ok(String photoName, String photoUrl) { + return new PlacePhotoBatchItemResult(photoName, "OK", photoUrl, null); + } + + public static PlacePhotoBatchItemResult failure(String photoName, String errorCode) { + return new PlacePhotoBatchItemResult(photoName, "FAILED", null, errorCode); + } +} diff --git a/src/main/java/com/howaboutus/backend/places/service/dto/PlacePhotoNameBatchItemResult.java b/src/main/java/com/howaboutus/backend/places/service/dto/PlacePhotoNameBatchItemResult.java new file mode 100644 index 00000000..ee1c3870 --- /dev/null +++ b/src/main/java/com/howaboutus/backend/places/service/dto/PlacePhotoNameBatchItemResult.java @@ -0,0 +1,17 @@ +package com.howaboutus.backend.places.service.dto; + +public record PlacePhotoNameBatchItemResult( + String googlePlaceId, + String status, + String photoName, + String errorCode +) { + + public static PlacePhotoNameBatchItemResult ok(String googlePlaceId, String photoName) { + return new PlacePhotoNameBatchItemResult(googlePlaceId, "OK", photoName, null); + } + + public static PlacePhotoNameBatchItemResult failure(String googlePlaceId, String errorCode) { + return new PlacePhotoNameBatchItemResult(googlePlaceId, errorCode, null, errorCode); + } +} diff --git a/src/main/java/com/howaboutus/backend/places/service/dto/PlacePreviewBatchItemResult.java b/src/main/java/com/howaboutus/backend/places/service/dto/PlacePreviewBatchItemResult.java new file mode 100644 index 00000000..91165074 --- /dev/null +++ b/src/main/java/com/howaboutus/backend/places/service/dto/PlacePreviewBatchItemResult.java @@ -0,0 +1,42 @@ +package com.howaboutus.backend.places.service.dto; + +public record PlacePreviewBatchItemResult( + String googlePlaceId, + String status, + String name, + String formattedAddress, + String primaryType, + String primaryTypeDisplayName, + PlacePreviewResult.Location location, + String photoName, + String errorCode +) { + + public static PlacePreviewBatchItemResult ok(PlacePreviewResult result) { + return new PlacePreviewBatchItemResult( + result.googlePlaceId(), + "OK", + result.name(), + result.formattedAddress(), + result.primaryType(), + result.primaryTypeDisplayName(), + result.location(), + result.photoName(), + null + ); + } + + public static PlacePreviewBatchItemResult failure(String googlePlaceId, String errorCode) { + return new PlacePreviewBatchItemResult( + googlePlaceId, + "FAILED", + null, + null, + null, + null, + null, + null, + errorCode + ); + } +} diff --git a/src/main/java/com/howaboutus/backend/schedules/controller/ScheduleController.java b/src/main/java/com/howaboutus/backend/schedules/controller/ScheduleController.java index 04a7e12f..17aa9681 100644 --- a/src/main/java/com/howaboutus/backend/schedules/controller/ScheduleController.java +++ b/src/main/java/com/howaboutus/backend/schedules/controller/ScheduleController.java @@ -13,6 +13,7 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import com.howaboutus.backend.common.logging.Loggable; @@ -79,14 +80,21 @@ public ResponseEntity> createBatch( @Operation( summary = "일정 목록 조회", - description = "방의 일정 목록을 조회합니다." + description = "방의 일정 목록을 조회합니다. includeItems=true이면 각 일정의 장소 항목 목록을 함께 반환합니다." ) @GetMapping public List getSchedules( @AuthenticationPrincipal Long userId, @Parameter(description = "방 ID", example = "123e4567-e89b-12d3-a456-426614174000") - @PathVariable UUID roomId + @PathVariable UUID roomId, + @Parameter(description = "true이면 각 일정에 items 배열을 포함합니다.", example = "true") + @RequestParam(defaultValue = "false") boolean includeItems ) { + if (includeItems) { + return scheduleService.getSchedulesWithItems(roomId, userId).stream() + .map(ScheduleResponse::from) + .toList(); + } return scheduleService.getSchedules(roomId, userId).stream() .map(ScheduleResponse::from) .toList(); diff --git a/src/main/java/com/howaboutus/backend/schedules/controller/ScheduleRouteController.java b/src/main/java/com/howaboutus/backend/schedules/controller/ScheduleRouteController.java new file mode 100644 index 00000000..b87bba0d --- /dev/null +++ b/src/main/java/com/howaboutus/backend/schedules/controller/ScheduleRouteController.java @@ -0,0 +1,55 @@ +package com.howaboutus.backend.schedules.controller; + +import java.util.UUID; + +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.howaboutus.backend.common.error.ApiErrorCodes; +import com.howaboutus.backend.common.error.ErrorCode; +import com.howaboutus.backend.common.logging.Loggable; +import com.howaboutus.backend.schedules.controller.dto.BatchRouteRequest; +import com.howaboutus.backend.schedules.controller.dto.BatchRouteResponse; +import com.howaboutus.backend.schedules.service.ScheduleItemService; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@Tag(name = "Schedule Routes", description = "일정 이동 정보 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/rooms/{roomId}/schedules/{scheduleId}/routes") +public class ScheduleRouteController { + + private final ScheduleItemService scheduleItemService; + + @Operation( + summary = "이동 정보 벌크 조회", + description = "요청한 일정 항목별 이동 수단으로 현재 항목에서 현재 일정 순서 기준 다음 항목까지의 이동 정보를 조회합니다. " + + "이동 정보와 이동 수단은 DB에 저장하지 않으며, 일부 구간 실패는 응답 body의 구간별 status/errorCode로 반환합니다." + ) + @ApiResponse(responseCode = "200", description = "조회 성공") + @ApiErrorCodes({ErrorCode.INVALID_TRAVEL_MODE, ErrorCode.INVALID_TOKEN, ErrorCode.ACCESS_TOKEN_EXPIRED, + ErrorCode.NOT_ROOM_MEMBER, ErrorCode.ROOM_NOT_FOUND, ErrorCode.SCHEDULE_NOT_FOUND}) + @Loggable + @PostMapping("/batch") + public BatchRouteResponse getRoutesBatch( + @AuthenticationPrincipal Long userId, + @Parameter(description = "방 ID", example = "123e4567-e89b-12d3-a456-426614174000") + @PathVariable UUID roomId, + @Parameter(description = "일정 ID", example = "1") + @PathVariable Long scheduleId, + @RequestBody @Valid BatchRouteRequest request + ) { + return BatchRouteResponse.from( + scheduleItemService.getRoutesForItems(roomId, scheduleId, request.toCommands(), userId)); + } +} diff --git a/src/main/java/com/howaboutus/backend/schedules/controller/dto/BatchRouteItemRequest.java b/src/main/java/com/howaboutus/backend/schedules/controller/dto/BatchRouteItemRequest.java new file mode 100644 index 00000000..9ae87787 --- /dev/null +++ b/src/main/java/com/howaboutus/backend/schedules/controller/dto/BatchRouteItemRequest.java @@ -0,0 +1,30 @@ +package com.howaboutus.backend.schedules.controller.dto; + +import com.howaboutus.backend.schedules.service.dto.RouteBatchItemCommand; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Positive; + +public record BatchRouteItemRequest( + @Schema(description = "출발 일정 항목 ID. 서버는 현재 일정 순서 기준으로 다음 항목을 도착지로 사용합니다.", example = "1") + @NotNull(message = "itemId는 필수입니다") + @Positive(message = "itemId는 양수여야 합니다") + Long itemId, + + @Schema(description = "해당 구간에 사용할 이동 수단", example = "WALKING", + allowableValues = {"DRIVING", "WALKING", "BICYCLING", "TRANSIT"}) + @NotBlank(message = "travelMode는 공백일 수 없습니다") + @Pattern( + regexp = "DRIVING|WALKING|BICYCLING|TRANSIT", + message = "이동 수단은 DRIVING, WALKING, BICYCLING, TRANSIT 중 하나여야 합니다" + ) + String travelMode +) { + + public RouteBatchItemCommand toCommand() { + return new RouteBatchItemCommand(itemId, travelMode); + } +} diff --git a/src/main/java/com/howaboutus/backend/schedules/controller/dto/BatchRouteItemResponse.java b/src/main/java/com/howaboutus/backend/schedules/controller/dto/BatchRouteItemResponse.java new file mode 100644 index 00000000..0bd96c6e --- /dev/null +++ b/src/main/java/com/howaboutus/backend/schedules/controller/dto/BatchRouteItemResponse.java @@ -0,0 +1,48 @@ +package com.howaboutus.backend.schedules.controller.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.howaboutus.backend.schedules.service.dto.RouteBatchItemResult; + +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record BatchRouteItemResponse( + @Schema(description = "요청한 출발 일정 항목 ID", example = "1") + Long itemId, + + @Schema(description = "실제 출발 일정 항목 ID. 항목을 찾지 못하면 null입니다.", example = "1", nullable = true) + Long fromItemId, + + @Schema(description = "현재 일정 순서 기준 다음 일정 항목 ID. 마지막 항목이면 null입니다.", example = "2", nullable = true) + Long toItemId, + + @Schema(description = "해당 구간 계산에 사용한 이동 수단", example = "WALKING") + String travelMode, + + @Schema(description = "구간별 처리 상태", example = "OK", + allowableValues = {"OK", "FAILED"}) + String status, + + @Schema(description = "이동 거리(미터). status가 OK일 때만 포함됩니다.", example = "500", nullable = true) + Integer distanceMeters, + + @Schema(description = "이동 시간(초). status가 OK일 때만 포함됩니다.", example = "300", nullable = true) + Integer durationSeconds, + + @Schema(description = "status가 OK가 아닐 때의 구간별 오류 코드", example = "NO_ROUTE", nullable = true) + String errorCode +) { + + public static BatchRouteItemResponse from(RouteBatchItemResult result) { + return new BatchRouteItemResponse( + result.itemId(), + result.fromItemId(), + result.toItemId(), + result.travelMode(), + result.status(), + result.distanceMeters(), + result.durationSeconds(), + result.errorCode() + ); + } +} diff --git a/src/main/java/com/howaboutus/backend/schedules/controller/dto/BatchRouteRequest.java b/src/main/java/com/howaboutus/backend/schedules/controller/dto/BatchRouteRequest.java new file mode 100644 index 00000000..d1af2c16 --- /dev/null +++ b/src/main/java/com/howaboutus/backend/schedules/controller/dto/BatchRouteRequest.java @@ -0,0 +1,25 @@ +package com.howaboutus.backend.schedules.controller.dto; + +import java.util.List; + +import com.howaboutus.backend.schedules.service.dto.RouteBatchItemCommand; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record BatchRouteRequest( + @Schema(description = "조회할 출발 일정 항목 목록. 항목별 travelMode를 따로 지정합니다.") + @NotEmpty(message = "items는 비어 있을 수 없습니다") + @Size(max = 25, message = "items는 최대 25개까지 요청할 수 있습니다") + List<@NotNull(message = "items의 각 항목은 null일 수 없습니다") @Valid BatchRouteItemRequest> items +) { + + public List toCommands() { + return items.stream() + .map(BatchRouteItemRequest::toCommand) + .toList(); + } +} diff --git a/src/main/java/com/howaboutus/backend/schedules/controller/dto/BatchRouteResponse.java b/src/main/java/com/howaboutus/backend/schedules/controller/dto/BatchRouteResponse.java new file mode 100644 index 00000000..54f2b854 --- /dev/null +++ b/src/main/java/com/howaboutus/backend/schedules/controller/dto/BatchRouteResponse.java @@ -0,0 +1,19 @@ +package com.howaboutus.backend.schedules.controller.dto; + +import java.util.List; + +import com.howaboutus.backend.schedules.service.dto.RouteBatchItemResult; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record BatchRouteResponse( + @Schema(description = "요청 순서대로 정렬된 구간별 이동 정보 결과") + List routes +) { + + public static BatchRouteResponse from(List results) { + return new BatchRouteResponse(results.stream() + .map(BatchRouteItemResponse::from) + .toList()); + } +} diff --git a/src/main/java/com/howaboutus/backend/schedules/controller/dto/ScheduleResponse.java b/src/main/java/com/howaboutus/backend/schedules/controller/dto/ScheduleResponse.java index 5a92021b..e9e509af 100644 --- a/src/main/java/com/howaboutus/backend/schedules/controller/dto/ScheduleResponse.java +++ b/src/main/java/com/howaboutus/backend/schedules/controller/dto/ScheduleResponse.java @@ -2,25 +2,45 @@ import java.time.Instant; import java.time.LocalDate; +import java.util.List; import java.util.UUID; +import com.fasterxml.jackson.annotation.JsonInclude; import com.howaboutus.backend.schedules.service.dto.ScheduleResult; +import com.howaboutus.backend.schedules.service.dto.ScheduleWithItemsResult; +@JsonInclude(JsonInclude.Include.NON_NULL) public record ScheduleResponse( - Long scheduleId, - UUID roomId, - int dayNumber, - LocalDate date, - Instant createdAt + Long scheduleId, + UUID roomId, + int dayNumber, + LocalDate date, + Instant createdAt, + List items ) { public static ScheduleResponse from(ScheduleResult result) { return new ScheduleResponse( - result.scheduleId(), - result.roomId(), - result.dayNumber(), - result.date(), - result.createdAt() + result.scheduleId(), + result.roomId(), + result.dayNumber(), + result.date(), + result.createdAt(), + null + ); + } + + public static ScheduleResponse from(ScheduleWithItemsResult result) { + ScheduleResult schedule = result.schedule(); + return new ScheduleResponse( + schedule.scheduleId(), + schedule.roomId(), + schedule.dayNumber(), + schedule.date(), + schedule.createdAt(), + result.items().stream() + .map(ScheduleItemResponse::from) + .toList() ); } } diff --git a/src/main/java/com/howaboutus/backend/schedules/repository/ScheduleItemRepository.java b/src/main/java/com/howaboutus/backend/schedules/repository/ScheduleItemRepository.java index 1d00c812..a48a0176 100644 --- a/src/main/java/com/howaboutus/backend/schedules/repository/ScheduleItemRepository.java +++ b/src/main/java/com/howaboutus/backend/schedules/repository/ScheduleItemRepository.java @@ -4,6 +4,7 @@ import java.util.Optional; import java.util.UUID; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import com.howaboutus.backend.schedules.entity.ScheduleItem; @@ -14,10 +15,10 @@ public interface ScheduleItemRepository extends JpaRepository findAllBySchedule_IdOrderByOrderIndexAsc(Long scheduleId); - List findAllBySchedule_IdOrderByOrderIndexAscIdAsc(Long scheduleId); - + @EntityGraph(attributePaths = {"schedule", "schedule.room"}) List findAllBySchedule_Room_IdOrderBySchedule_DayNumberAscSchedule_IdAscOrderIndexAscIdAsc( - UUID roomId); + UUID roomId + ); Optional findByIdAndSchedule_Id(Long itemId, Long scheduleId); diff --git a/src/main/java/com/howaboutus/backend/schedules/repository/ScheduleRepository.java b/src/main/java/com/howaboutus/backend/schedules/repository/ScheduleRepository.java index ca2bd0fa..8c0bd864 100644 --- a/src/main/java/com/howaboutus/backend/schedules/repository/ScheduleRepository.java +++ b/src/main/java/com/howaboutus/backend/schedules/repository/ScheduleRepository.java @@ -23,9 +23,6 @@ public interface ScheduleRepository extends JpaRepository { @EntityGraph(attributePaths = "room") List findAllByRoom_IdOrderByDayNumberAsc(UUID roomId); - @EntityGraph(attributePaths = "room") - List findAllByRoom_IdOrderByDayNumberAscIdAsc(UUID roomId); - Optional findByIdAndRoom_Id(Long scheduleId, UUID roomId); @Modifying(flushAutomatically = true, clearAutomatically = false) diff --git a/src/main/java/com/howaboutus/backend/schedules/service/ScheduleItemService.java b/src/main/java/com/howaboutus/backend/schedules/service/ScheduleItemService.java index 0fcf4f2d..b9f8e0c2 100644 --- a/src/main/java/com/howaboutus/backend/schedules/service/ScheduleItemService.java +++ b/src/main/java/com/howaboutus/backend/schedules/service/ScheduleItemService.java @@ -24,6 +24,8 @@ import com.howaboutus.backend.schedules.entity.TravelMode; import com.howaboutus.backend.schedules.repository.ScheduleItemRepository; import com.howaboutus.backend.schedules.repository.ScheduleRepository; +import com.howaboutus.backend.schedules.service.dto.RouteBatchItemCommand; +import com.howaboutus.backend.schedules.service.dto.RouteBatchItemResult; import com.howaboutus.backend.schedules.service.dto.RouteResult; import com.howaboutus.backend.schedules.service.dto.ScheduleItemCreateCommand; import com.howaboutus.backend.schedules.service.dto.ScheduleItemMoveCommand; @@ -248,6 +250,51 @@ public Optional getRouteForItem(UUID roomId, Long scheduleId, Long return routeService.computeRoute(current.getGooglePlaceId(), next.getGooglePlaceId(), mode); } + public List getRoutesForItems(UUID roomId, Long scheduleId, + List commands, Long userId) { + roomAccessService.requireExistingActiveMember(roomId, userId); + requireScheduleExists(roomId, scheduleId); + + List items = scheduleItemRepository.findAllBySchedule_IdOrderByOrderIndexAsc(scheduleId); + List results = new ArrayList<>(); + for (RouteBatchItemCommand command : commands) { + results.add(getRouteForBatchItem(items, command)); + } + return results; + } + + private RouteBatchItemResult getRouteForBatchItem(List items, RouteBatchItemCommand command) { + String mode = TravelMode.normalize(command.travelMode()); + for (int i = 0; i < items.size(); i++) { + ScheduleItem current = items.get(i); + if (!current.getId().equals(command.itemId())) { + continue; + } + if (i + 1 >= items.size()) { + return RouteBatchItemResult.lastItem(command.itemId(), current.getId(), mode); + } + ScheduleItem next = items.get(i + 1); + return computeBatchRoute(command.itemId(), current, next, mode); + } + return RouteBatchItemResult.itemNotFound(command.itemId(), mode); + } + + private RouteBatchItemResult computeBatchRoute(Long itemId, ScheduleItem current, ScheduleItem next, String mode) { + try { + return routeService.computeRoute(current.getGooglePlaceId(), next.getGooglePlaceId(), mode) + .map(result -> RouteBatchItemResult.ok(itemId, current.getId(), next.getId(), result.travelMode(), + result.distanceMeters(), result.durationSeconds())) + .orElseGet(() -> RouteBatchItemResult.noRoute(itemId, current.getId(), next.getId(), mode)); + } catch (CustomException e) { + if (e.getErrorCode() == ErrorCode.ROUTE_TEMPORARILY_UNAVAILABLE + || e.getErrorCode() == ErrorCode.EXTERNAL_API_ERROR) { + return RouteBatchItemResult.failure(itemId, current.getId(), next.getId(), mode, + e.getErrorCode().name()); + } + throw e; + } + } + private List computeReorderAffectedIds(List items, ScheduleItem movedItem, int oldIndex, int newIndex) { List affected = new ArrayList<>(); diff --git a/src/main/java/com/howaboutus/backend/schedules/service/ScheduleService.java b/src/main/java/com/howaboutus/backend/schedules/service/ScheduleService.java index a0d2fb1a..fc58f3fe 100644 --- a/src/main/java/com/howaboutus/backend/schedules/service/ScheduleService.java +++ b/src/main/java/com/howaboutus/backend/schedules/service/ScheduleService.java @@ -1,7 +1,10 @@ package com.howaboutus.backend.schedules.service; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.UUID; +import java.util.stream.Collectors; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; @@ -15,11 +18,14 @@ import com.howaboutus.backend.rooms.entity.Room; import com.howaboutus.backend.rooms.service.RoomAccessService; import com.howaboutus.backend.schedules.entity.Schedule; +import com.howaboutus.backend.schedules.repository.ScheduleItemRepository; import com.howaboutus.backend.schedules.repository.ScheduleRepository; import com.howaboutus.backend.schedules.service.dto.ScheduleBatchCreateCommand; import com.howaboutus.backend.schedules.service.dto.ScheduleCreateCommand; +import com.howaboutus.backend.schedules.service.dto.ScheduleItemResult; import com.howaboutus.backend.schedules.service.dto.ScheduleMoveCommand; import com.howaboutus.backend.schedules.service.dto.ScheduleResult; +import com.howaboutus.backend.schedules.service.dto.ScheduleWithItemsResult; import lombok.RequiredArgsConstructor; @@ -30,6 +36,7 @@ public class ScheduleService { private final RoomAccessService roomAccessService; private final ScheduleRepository scheduleRepository; + private final ScheduleItemRepository scheduleItemRepository; private final ScheduleItemService scheduleItemService; private final ApplicationEventPublisher eventPublisher; private final RoomScheduleAppender roomScheduleAppender; @@ -68,6 +75,30 @@ public List getSchedules(UUID roomId, Long userId) { .toList(); } + public List getSchedulesWithItems(UUID roomId, Long userId) { + roomAccessService.requireExistingActiveMember(roomId, userId); + List schedules = scheduleRepository.findAllByRoom_IdOrderByDayNumberAsc(roomId); + Map> itemsByScheduleId = scheduleItemRepository + .findAllBySchedule_Room_IdOrderBySchedule_DayNumberAscSchedule_IdAscOrderIndexAscIdAsc(roomId) + .stream() + .map(ScheduleItemResult::from) + .collect(Collectors.groupingBy( + ScheduleItemResult::scheduleId, + LinkedHashMap::new, + Collectors.toList() + )); + + return schedules.stream() + .map(schedule -> { + ScheduleResult result = ScheduleResult.from(schedule); + return new ScheduleWithItemsResult( + result, + itemsByScheduleId.getOrDefault(result.scheduleId(), List.of()) + ); + }) + .toList(); + } + @Loggable @Transactional public void delete(UUID roomId, Long scheduleId, Long userId) { diff --git a/src/main/java/com/howaboutus/backend/schedules/service/dto/RouteBatchItemCommand.java b/src/main/java/com/howaboutus/backend/schedules/service/dto/RouteBatchItemCommand.java new file mode 100644 index 00000000..b8c7c6c8 --- /dev/null +++ b/src/main/java/com/howaboutus/backend/schedules/service/dto/RouteBatchItemCommand.java @@ -0,0 +1,7 @@ +package com.howaboutus.backend.schedules.service.dto; + +public record RouteBatchItemCommand( + Long itemId, + String travelMode +) { +} diff --git a/src/main/java/com/howaboutus/backend/schedules/service/dto/RouteBatchItemResult.java b/src/main/java/com/howaboutus/backend/schedules/service/dto/RouteBatchItemResult.java new file mode 100644 index 00000000..fc07d7f6 --- /dev/null +++ b/src/main/java/com/howaboutus/backend/schedules/service/dto/RouteBatchItemResult.java @@ -0,0 +1,37 @@ +package com.howaboutus.backend.schedules.service.dto; + +public record RouteBatchItemResult( + Long itemId, + Long fromItemId, + Long toItemId, + String travelMode, + String status, + Integer distanceMeters, + Integer durationSeconds, + String errorCode +) { + + public static RouteBatchItemResult ok(Long itemId, Long fromItemId, Long toItemId, String travelMode, + int distanceMeters, int durationSeconds) { + return new RouteBatchItemResult(itemId, fromItemId, toItemId, travelMode, "OK", + distanceMeters, durationSeconds, null); + } + + public static RouteBatchItemResult noRoute(Long itemId, Long fromItemId, Long toItemId, String travelMode) { + return failure(itemId, fromItemId, toItemId, travelMode, "NO_ROUTE"); + } + + public static RouteBatchItemResult lastItem(Long itemId, Long fromItemId, String travelMode) { + return failure(itemId, fromItemId, null, travelMode, "LAST_ITEM"); + } + + public static RouteBatchItemResult itemNotFound(Long itemId, String travelMode) { + return failure(itemId, null, null, travelMode, "SCHEDULE_ITEM_NOT_FOUND"); + } + + public static RouteBatchItemResult failure(Long itemId, Long fromItemId, Long toItemId, String travelMode, + String errorCode) { + return new RouteBatchItemResult(itemId, fromItemId, toItemId, travelMode, "FAILED", + null, null, errorCode); + } +} diff --git a/src/main/java/com/howaboutus/backend/schedules/service/dto/ScheduleWithItemsResult.java b/src/main/java/com/howaboutus/backend/schedules/service/dto/ScheduleWithItemsResult.java new file mode 100644 index 00000000..69623375 --- /dev/null +++ b/src/main/java/com/howaboutus/backend/schedules/service/dto/ScheduleWithItemsResult.java @@ -0,0 +1,9 @@ +package com.howaboutus.backend.schedules.service.dto; + +import java.util.List; + +public record ScheduleWithItemsResult( + ScheduleResult schedule, + List items +) { +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 551e147e..cc261e5e 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -94,6 +94,12 @@ management: include: health,prometheus,caches app: + executor: + ai: + concurrency-limit: 4 + google: + concurrency-limit: 8 + rate-limit: redis: host: ${spring.data.redis.host} @@ -107,15 +113,30 @@ app: places-search: capacity: 10 duration: 1m - places-read: + places-read-single: capacity: 30 duration: 1m - route-user: + places-read-bulk: + capacity: 10 + duration: 1m + places-photo-single: + capacity: 45 + duration: 1m + places-photo-bulk: + capacity: 15 + duration: 1m + route-single-user: capacity: 20 duration: 1m - route-room: + route-single-room: capacity: 60 duration: 1m + route-batch-user: + capacity: 10 + duration: 1m + route-batch-room: + capacity: 30 + duration: 1m join-user: capacity: 10 duration: 1m diff --git a/src/test/java/com/howaboutus/backend/bookmarks/BookmarkIntegrationTest.java b/src/test/java/com/howaboutus/backend/bookmarks/BookmarkIntegrationTest.java index ab9d0b7b..7fa7e2f8 100644 --- a/src/test/java/com/howaboutus/backend/bookmarks/BookmarkIntegrationTest.java +++ b/src/test/java/com/howaboutus/backend/bookmarks/BookmarkIntegrationTest.java @@ -111,6 +111,13 @@ void bookmarkCategoryFlowWorksEndToEnd() throws Exception { .andExpect(jsonPath("$[1].categoryId").value(cafeCategory.getId())) .andExpect(jsonPath("$[1].category").value("카페")); + mockMvc.perform(get("/rooms/{roomId}/bookmarks", room.getId()) + .cookie(new Cookie("access_token", VALID_TOKEN))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(2)) + .andExpect(jsonPath("$[0].categoryId").value(foodCategory.getId())) + .andExpect(jsonPath("$[1].categoryId").value(cafeCategory.getId())); + List foodBookmarks = bookmarkRepository .findAllByRoom_IdAndCategory_IdOrderByCreatedAtDesc(room.getId(), foodCategory.getId()); assertThat(foodBookmarks).isNotEmpty(); diff --git a/src/test/java/com/howaboutus/backend/bookmarks/controller/BookmarkControllerTest.java b/src/test/java/com/howaboutus/backend/bookmarks/controller/BookmarkControllerTest.java index d102dfeb..ca1d9f4a 100644 --- a/src/test/java/com/howaboutus/backend/bookmarks/controller/BookmarkControllerTest.java +++ b/src/test/java/com/howaboutus/backend/bookmarks/controller/BookmarkControllerTest.java @@ -244,6 +244,21 @@ void returnsBookmarkListSuccessfully() throws Exception { .andExpect(jsonPath("$[0].createdAt").value(BOOKMARK_RESULT.createdAt().toString())); } + @Test + @DisplayName("categoryId 없이 북마크 목록 조회 시 방 전체 북마크를 반환한다") + void returnsAllBookmarksWhenCategoryIdIsMissing() throws Exception { + given(bookmarkService.getBookmarks(ROOM_ID, null, USER_ID)) + .willReturn(List.of(BOOKMARK_RESULT, BOOKMARK_RESULT_2)); + + mockMvc.perform(get("/rooms/{roomId}/bookmarks", ROOM_ID) + .cookie(new Cookie("access_token", VALID_TOKEN))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].bookmarkId").value(BOOKMARK_RESULT.bookmarkId())) + .andExpect(jsonPath("$[1].bookmarkId").value(BOOKMARK_RESULT_2.bookmarkId())); + + then(bookmarkService).should().getBookmarks(ROOM_ID, null, USER_ID); + } + @Test @DisplayName("북마크 삭제 성공 시 204를 반환한다") void deletesBookmarkSuccessfully() throws Exception { diff --git a/src/test/java/com/howaboutus/backend/bookmarks/service/BookmarkServiceTest.java b/src/test/java/com/howaboutus/backend/bookmarks/service/BookmarkServiceTest.java index 523fc109..16b6b121 100644 --- a/src/test/java/com/howaboutus/backend/bookmarks/service/BookmarkServiceTest.java +++ b/src/test/java/com/howaboutus/backend/bookmarks/service/BookmarkServiceTest.java @@ -1,9 +1,12 @@ package com.howaboutus.backend.bookmarks.service; -import static org.assertj.core.api.Assertions.*; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.BDDMockito.*; -import static org.mockito.Mockito.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import java.time.Instant; import java.util.List; @@ -54,7 +57,7 @@ class BookmarkServiceTest { @BeforeEach void setUp() { bookmarkService = new BookmarkService(roomAccessService, bookmarkRepository, bookmarkCategoryRepository, - eventPublisher); + eventPublisher); } @Test @@ -73,13 +76,16 @@ void createReturnsSavedBookmarkWithCategory() { given(roomAccessService.getRoom(roomId)).willReturn(room); given(bookmarkCategoryRepository.existsByRoom_Id(roomId)).willReturn(true); given(bookmarkCategoryRepository.findAllByIdInAndRoom_Id(List.of(10L), roomId)) - .willReturn(List.of(category)); + .willReturn(List.of(category)); given(bookmarkRepository.findExistingCategoryIds(roomId, "place-1", List.of(10L))) - .willReturn(List.of()); + .willReturn(List.of()); given(bookmarkRepository.saveAllAndFlush(any())).willReturn(List.of(savedBookmark)); - List result = bookmarkService.create(roomId, new BookmarkCreateCommand("place-1", List.of(10L)), - 1L); + List result = bookmarkService.create( + roomId, + new BookmarkCreateCommand("place-1", List.of(10L)), + 1L + ); assertThat(result).containsExactly(BookmarkResult.from(savedBookmark)); @@ -90,11 +96,11 @@ void createReturnsSavedBookmarkWithCategory() { assertThat(captured.getCategory()).isSameAs(category); assertThat(captured.getAddedBy()).isNull(); verify(eventPublisher).publishEvent(new RoomBookmarkChangedEvent( - roomId, - 1L, - RoomBookmarkEventType.BOOKMARK_CREATED, - 11L, - 10L + roomId, + 1L, + RoomBookmarkEventType.BOOKMARK_CREATED, + 11L, + 10L )); } @@ -119,24 +125,24 @@ void createWithMultipleCategoryIdsSavesAllAndPublishesEvents() { given(roomAccessService.getRoom(roomId)).willReturn(room); given(bookmarkCategoryRepository.existsByRoom_Id(roomId)).willReturn(true); given(bookmarkCategoryRepository.findAllByIdInAndRoom_Id(List.of(10L, 11L), roomId)) - .willReturn(List.of(categoryA, categoryB)); + .willReturn(List.of(categoryA, categoryB)); given(bookmarkRepository.findExistingCategoryIds(roomId, "place-1", List.of(10L, 11L))) - .willReturn(List.of()); + .willReturn(List.of()); given(bookmarkRepository.saveAllAndFlush(any())).willReturn(List.of(savedA, savedB)); List results = bookmarkService.create( - roomId, - new BookmarkCreateCommand("place-1", List.of(10L, 11L)), - 1L + roomId, + new BookmarkCreateCommand("place-1", List.of(10L, 11L)), + 1L ); assertThat(results).containsExactly(BookmarkResult.from(savedA), BookmarkResult.from(savedB)); verify(eventPublisher, times(2)).publishEvent(any(RoomBookmarkChangedEvent.class)); verify(eventPublisher).publishEvent(new RoomBookmarkChangedEvent( - roomId, 1L, RoomBookmarkEventType.BOOKMARK_CREATED, 21L, 10L + roomId, 1L, RoomBookmarkEventType.BOOKMARK_CREATED, 21L, 10L )); verify(eventPublisher).publishEvent(new RoomBookmarkChangedEvent( - roomId, 1L, RoomBookmarkEventType.BOOKMARK_CREATED, 22L, 11L + roomId, 1L, RoomBookmarkEventType.BOOKMARK_CREATED, 22L, 11L )); } @@ -161,15 +167,15 @@ void createDeduplicatesCategoryIds() { given(roomAccessService.getRoom(roomId)).willReturn(room); given(bookmarkCategoryRepository.existsByRoom_Id(roomId)).willReturn(true); given(bookmarkCategoryRepository.findAllByIdInAndRoom_Id(List.of(10L, 11L), roomId)) - .willReturn(List.of(categoryA, categoryB)); + .willReturn(List.of(categoryA, categoryB)); given(bookmarkRepository.findExistingCategoryIds(roomId, "place-1", List.of(10L, 11L))) - .willReturn(List.of()); + .willReturn(List.of()); given(bookmarkRepository.saveAllAndFlush(any())).willReturn(List.of(savedA, savedB)); List results = bookmarkService.create( - roomId, - new BookmarkCreateCommand("place-1", List.of(10L, 10L, 11L)), - 1L + roomId, + new BookmarkCreateCommand("place-1", List.of(10L, 10L, 11L)), + 1L ); assertThat(results).hasSize(2); @@ -186,9 +192,9 @@ void createThrowsWhenRoomMissing() { given(roomAccessService.getRoom(roomId)).willThrow(new CustomException(ErrorCode.ROOM_NOT_FOUND)); assertThatThrownBy(() -> bookmarkService.create(roomId, command, 1L)) - .isInstanceOf(CustomException.class) - .extracting("errorCode") - .isEqualTo(ErrorCode.ROOM_NOT_FOUND); + .isInstanceOf(CustomException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.ROOM_NOT_FOUND); } @Test @@ -202,9 +208,9 @@ void createThrowsWhenRoomHasNoCategories() { given(bookmarkCategoryRepository.existsByRoom_Id(roomId)).willReturn(false); assertThatThrownBy(() -> bookmarkService.create(roomId, command, 1L)) - .isInstanceOf(CustomException.class) - .extracting("errorCode") - .isEqualTo(ErrorCode.BOOKMARK_CATEGORY_EMPTY); + .isInstanceOf(CustomException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.BOOKMARK_CATEGORY_EMPTY); } @Test @@ -217,12 +223,12 @@ void createThrowsWhenCategoryOutsideRoom() { given(roomAccessService.getRoom(roomId)).willReturn(room); given(bookmarkCategoryRepository.existsByRoom_Id(roomId)).willReturn(true); given(bookmarkCategoryRepository.findAllByIdInAndRoom_Id(List.of(10L), roomId)) - .willReturn(List.of()); + .willReturn(List.of()); assertThatThrownBy(() -> bookmarkService.create(roomId, command, 1L)) - .isInstanceOf(CustomException.class) - .extracting("errorCode") - .isEqualTo(ErrorCode.BOOKMARK_CATEGORY_NOT_FOUND); + .isInstanceOf(CustomException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.BOOKMARK_CATEGORY_NOT_FOUND); } @Test @@ -239,14 +245,14 @@ void createThrowsWhenDuplicateBookmarkExists() { given(roomAccessService.getRoom(roomId)).willReturn(room); given(bookmarkCategoryRepository.existsByRoom_Id(roomId)).willReturn(true); given(bookmarkCategoryRepository.findAllByIdInAndRoom_Id(List.of(10L), roomId)) - .willReturn(List.of(category)); + .willReturn(List.of(category)); given(bookmarkRepository.findExistingCategoryIds(roomId, "place-1", List.of(10L))) - .willReturn(List.of(10L)); + .willReturn(List.of(10L)); assertThatThrownBy(() -> bookmarkService.create(roomId, command, 1L)) - .isInstanceOf(CustomException.class) - .extracting("errorCode") - .isEqualTo(ErrorCode.BOOKMARK_ALREADY_EXISTS); + .isInstanceOf(CustomException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.BOOKMARK_ALREADY_EXISTS); } @Test @@ -255,12 +261,12 @@ void getBookmarksThrowsWhenRoomMissing() { UUID roomId = UUID.randomUUID(); given(roomAccessService.requireExistingActiveMember(roomId, 1L)) - .willThrow(new CustomException(ErrorCode.ROOM_NOT_FOUND)); + .willThrow(new CustomException(ErrorCode.ROOM_NOT_FOUND)); assertThatThrownBy(() -> bookmarkService.getBookmarks(roomId, 1L, 1L)) - .isInstanceOf(CustomException.class) - .extracting("errorCode") - .isEqualTo(ErrorCode.ROOM_NOT_FOUND); + .isInstanceOf(CustomException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.ROOM_NOT_FOUND); } @Test @@ -277,8 +283,8 @@ void getBookmarksReturnsMappedResults() { ReflectionTestUtils.setField(bookmark, "createdAt", Instant.parse("2026-04-17T00:00:00Z")); given(roomAccessService.requireExistingActiveMember(roomId, 1L)).willReturn(null); - given(bookmarkRepository.findAllByRoom_IdAndCategory_IdOrderByCreatedAtDesc(roomId, 20L)).willReturn( - List.of(bookmark)); + given(bookmarkRepository.findAllByRoom_IdAndCategory_IdOrderByCreatedAtDesc(roomId, 20L)) + .willReturn(List.of(bookmark)); List results = bookmarkService.getBookmarks(roomId, 20L, 1L); @@ -287,6 +293,35 @@ void getBookmarksReturnsMappedResults() { assertThat(results.getFirst().category()).isEqualTo("카페"); } + @Test + @DisplayName("categoryId 없이 조회하면 방 전체 북마크를 한 번에 조회해 반환한다") + void getBookmarksWithoutCategoryReturnsAllBookmarksInSingleRepositoryCall() { + UUID roomId = UUID.randomUUID(); + Room room = Room.create("도쿄 여행", "도쿄", null, null, "INVITE", 1L); + BookmarkCategory categoryA = BookmarkCategory.create(room, "카페", "#3366FF", null); + BookmarkCategory categoryB = BookmarkCategory.create(room, "맛집", "#FF8800", null); + Bookmark bookmarkA = Bookmark.create(room, "place-1", categoryA, null); + Bookmark bookmarkB = Bookmark.create(room, "place-2", categoryB, null); + + ReflectionTestUtils.setField(room, "id", roomId); + ReflectionTestUtils.setField(categoryA, "id", 20L); + ReflectionTestUtils.setField(categoryB, "id", 21L); + ReflectionTestUtils.setField(bookmarkA, "id", 10L); + ReflectionTestUtils.setField(bookmarkB, "id", 11L); + + given(roomAccessService.requireExistingActiveMember(roomId, 1L)).willReturn(null); + given(bookmarkRepository.findAllByRoom_IdOrderByCategory_CreatedAtAscCategory_IdAscCreatedAtDescIdDesc(roomId)) + .willReturn(List.of(bookmarkA, bookmarkB)); + + List results = bookmarkService.getBookmarks(roomId, null, 1L); + + assertThat(results).extracting("bookmarkId").containsExactly(10L, 11L); + assertThat(results).extracting("categoryId").containsExactly(20L, 21L); + verify(bookmarkRepository) + .findAllByRoom_IdOrderByCategory_CreatedAtAscCategory_IdAscCreatedAtDescIdDesc(roomId); + verify(bookmarkRepository, never()).findAllByRoom_IdAndCategory_IdOrderByCreatedAtDesc(any(), any()); + } + @Test @DisplayName("북마크 카테고리 변경 성공 시 저장된 값을 반환한다") void updateCategoryReturnsSavedBookmark() { @@ -314,11 +349,11 @@ void updateCategoryReturnsSavedBookmark() { assertThat(bookmark.getCategory()).isSameAs(newCategory); verify(bookmarkRepository).saveAndFlush(bookmark); verify(eventPublisher).publishEvent(new RoomBookmarkChangedEvent( - roomId, - 1L, - RoomBookmarkEventType.BOOKMARK_UPDATED, - 12L, - 11L + roomId, + 1L, + RoomBookmarkEventType.BOOKMARK_UPDATED, + 12L, + 11L )); } @@ -339,9 +374,9 @@ void updateCategoryThrowsWhenCategoryMissing() { given(bookmarkCategoryRepository.findByIdAndRoom_Id(11L, roomId)).willReturn(Optional.empty()); assertThatThrownBy(() -> bookmarkService.updateCategory(roomId, 12L, 11L, 1L)) - .isInstanceOf(CustomException.class) - .extracting("errorCode") - .isEqualTo(ErrorCode.BOOKMARK_CATEGORY_NOT_FOUND); + .isInstanceOf(CustomException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.BOOKMARK_CATEGORY_NOT_FOUND); } @Test @@ -353,9 +388,9 @@ void deleteThrowsWhenBookmarkOutsideRoom() { given(bookmarkRepository.findByIdAndRoom_Id(10L, roomId)).willReturn(Optional.empty()); assertThatThrownBy(() -> bookmarkService.delete(roomId, 10L, 1L)) - .isInstanceOf(CustomException.class) - .extracting("errorCode") - .isEqualTo(ErrorCode.BOOKMARK_NOT_FOUND); + .isInstanceOf(CustomException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.BOOKMARK_NOT_FOUND); } @Test @@ -377,11 +412,11 @@ void deleteRemovesBookmark() { verify(bookmarkRepository).delete(bookmark); verify(eventPublisher).publishEvent(new RoomBookmarkChangedEvent( - roomId, - 1L, - RoomBookmarkEventType.BOOKMARK_DELETED, - 11L, - 10L + roomId, + 1L, + RoomBookmarkEventType.BOOKMARK_DELETED, + 11L, + 10L )); } @@ -399,15 +434,15 @@ void createTranslatesDatabaseDuplicateOnSave() { given(roomAccessService.getRoom(roomId)).willReturn(room); given(bookmarkCategoryRepository.existsByRoom_Id(roomId)).willReturn(true); given(bookmarkCategoryRepository.findAllByIdInAndRoom_Id(List.of(10L), roomId)) - .willReturn(List.of(category)); + .willReturn(List.of(category)); given(bookmarkRepository.findExistingCategoryIds(roomId, "place-1", List.of(10L))) - .willReturn(List.of()); + .willReturn(List.of()); given(bookmarkRepository.saveAllAndFlush(any())) - .willThrow(new DataIntegrityViolationException("duplicate")); + .willThrow(new DataIntegrityViolationException("duplicate")); assertThatThrownBy(() -> bookmarkService.create(roomId, command, 1L)) - .isInstanceOf(CustomException.class) - .extracting("errorCode") - .isEqualTo(ErrorCode.BOOKMARK_ALREADY_EXISTS); + .isInstanceOf(CustomException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.BOOKMARK_ALREADY_EXISTS); } } diff --git a/src/test/java/com/howaboutus/backend/common/config/AsyncConfigTest.java b/src/test/java/com/howaboutus/backend/common/config/AsyncConfigTest.java new file mode 100644 index 00000000..4a01dd39 --- /dev/null +++ b/src/test/java/com/howaboutus/backend/common/config/AsyncConfigTest.java @@ -0,0 +1,46 @@ +package com.howaboutus.backend.common.config; + +import static org.assertj.core.api.Assertions.*; + +import java.util.concurrent.ExecutionException; + +import org.junit.jupiter.api.Test; +import org.springframework.core.task.SimpleAsyncTaskExecutor; + +import com.howaboutus.backend.common.config.properties.AsyncExecutorProperties; + +class AsyncConfigTest { + + @Test + void createsSeparateVirtualThreadExecutorsWithConfiguredConcurrencyLimits() + throws ExecutionException, InterruptedException { + AsyncConfig config = new AsyncConfig(new AsyncExecutorProperties( + new AsyncExecutorProperties.Settings(4), + new AsyncExecutorProperties.Settings(8) + )); + + SimpleAsyncTaskExecutor aiTaskExecutor = config.aiTaskExecutor(); + SimpleAsyncTaskExecutor googleApiExecutor = config.googleApiExecutor(); + + assertThat(aiTaskExecutor).isNotSameAs(googleApiExecutor); + assertThat(aiTaskExecutor.getConcurrencyLimit()).isEqualTo(4); + assertThat(aiTaskExecutor.getThreadNamePrefix()).isEqualTo("ai-"); + assertThat(runsOnVirtualThread(aiTaskExecutor)).isTrue(); + assertThat(googleApiExecutor.getConcurrencyLimit()).isEqualTo(8); + assertThat(googleApiExecutor.getThreadNamePrefix()).isEqualTo("google-api-"); + assertThat(runsOnVirtualThread(googleApiExecutor)).isTrue(); + } + + @Test + void asyncExecutorPropertiesApplyVirtualThreadConcurrencyDefaults() { + AsyncExecutorProperties properties = new AsyncExecutorProperties(null, null); + + assertThat(properties.ai()).isEqualTo(new AsyncExecutorProperties.Settings(4)); + assertThat(properties.google()).isEqualTo(new AsyncExecutorProperties.Settings(8)); + } + + private boolean runsOnVirtualThread(SimpleAsyncTaskExecutor executor) + throws ExecutionException, InterruptedException { + return executor.submit(() -> Thread.currentThread().isVirtual()).get(); + } +} diff --git a/src/test/java/com/howaboutus/backend/common/ratelimit/HttpRateLimitPolicyResolverTest.java b/src/test/java/com/howaboutus/backend/common/ratelimit/HttpRateLimitPolicyResolverTest.java index d63d02e7..826fd372 100644 --- a/src/test/java/com/howaboutus/backend/common/ratelimit/HttpRateLimitPolicyResolverTest.java +++ b/src/test/java/com/howaboutus/backend/common/ratelimit/HttpRateLimitPolicyResolverTest.java @@ -18,18 +18,23 @@ class HttpRateLimitPolicyResolverTest { - private static final RateLimitProperties TEST_PROPERTIES = new RateLimitProperties(Map.of( - "auth-refresh-session", new RateLimitProperties.PolicyDto(10, Duration.ofMinutes(1), null), - "places-search", new RateLimitProperties.PolicyDto(10, Duration.ofMinutes(1), null), - "places-read", new RateLimitProperties.PolicyDto(30, Duration.ofMinutes(1), null), - "route-user", new RateLimitProperties.PolicyDto(20, Duration.ofMinutes(1), null), - "route-room", new RateLimitProperties.PolicyDto(60, Duration.ofMinutes(1), null), - "join-user", new RateLimitProperties.PolicyDto(10, Duration.ofMinutes(1), null), - "write-user", new RateLimitProperties.PolicyDto(60, Duration.ofMinutes(1), null), - "chat", new RateLimitProperties.PolicyDto(null, null, List.of( + private static final RateLimitProperties TEST_PROPERTIES = new RateLimitProperties(Map.ofEntries( + Map.entry("auth-refresh-session", new RateLimitProperties.PolicyDto(10, Duration.ofMinutes(1), null)), + Map.entry("places-search", new RateLimitProperties.PolicyDto(10, Duration.ofMinutes(1), null)), + Map.entry("places-read-single", new RateLimitProperties.PolicyDto(30, Duration.ofMinutes(1), null)), + Map.entry("places-read-bulk", new RateLimitProperties.PolicyDto(10, Duration.ofMinutes(1), null)), + Map.entry("places-photo-single", new RateLimitProperties.PolicyDto(45, Duration.ofMinutes(1), null)), + Map.entry("places-photo-bulk", new RateLimitProperties.PolicyDto(15, Duration.ofMinutes(1), null)), + Map.entry("route-single-user", new RateLimitProperties.PolicyDto(20, Duration.ofMinutes(1), null)), + Map.entry("route-single-room", new RateLimitProperties.PolicyDto(60, Duration.ofMinutes(1), null)), + Map.entry("route-batch-user", new RateLimitProperties.PolicyDto(10, Duration.ofMinutes(1), null)), + Map.entry("route-batch-room", new RateLimitProperties.PolicyDto(30, Duration.ofMinutes(1), null)), + Map.entry("join-user", new RateLimitProperties.PolicyDto(10, Duration.ofMinutes(1), null)), + Map.entry("write-user", new RateLimitProperties.PolicyDto(60, Duration.ofMinutes(1), null)), + Map.entry("chat", new RateLimitProperties.PolicyDto(null, null, List.of( new RateLimitProperties.BandwidthDto(5, Duration.ofSeconds(1)), new RateLimitProperties.BandwidthDto(60, Duration.ofMinutes(1)) - )) + ))) )); private final HttpRateLimitPolicyResolver resolver = new HttpRateLimitPolicyResolver(TEST_PROPERTIES); @@ -63,22 +68,66 @@ void resolvesPlaceReadPolicies() { // 상세 assertThat(resolver.resolve(request("GET", "/places/ChIJ123"))) .extracting(RateLimitPlan::key) - .containsExactly("rate-limit:http:places-read:user:42"); + .containsExactly("rate-limit:http:places-read-single:user:42"); // 미리보기 assertThat(resolver.resolve(request("GET", "/places/ChIJ123/preview"))) .extracting(RateLimitPlan::key) - .containsExactly("rate-limit:http:places-read:user:42"); + .containsExactly("rate-limit:http:places-read-single:user:42"); // 사진이름조회 (새로 추가됨) assertThat(resolver.resolve(request("GET", "/places/ChIJ123/photo-names"))) .extracting(RateLimitPlan::key) - .containsExactly("rate-limit:http:places-read:user:42"); + .containsExactly("rate-limit:http:places-read-single:user:42"); // 사진 파일 GET assertThat(resolver.resolve(request("GET", "/places/photos"))) .extracting(RateLimitPlan::key) - .containsExactly("rate-limit:http:places-read:user:42"); + .containsExactly("rate-limit:http:places-photo-single:user:42"); + } + + @Test + @DisplayName("장소 미리보기와 사진 이름, 사진 URL 벌크 POST는 조회 제한만 적용하고 쓰기 제한은 적용하지 않는다") + void resolvesPlaceBatchReadPoliciesWithoutWritePolicy() { + authenticate(42L); + + assertThat(resolver.resolve(request("POST", "/places/previews/batch"))) + .extracting(RateLimitPlan::key) + .containsExactly("rate-limit:http:places-read-bulk:user:42"); + + assertThat(resolver.resolve(request("POST", "/places/photo-names/batch"))) + .extracting(RateLimitPlan::key) + .containsExactly("rate-limit:http:places-read-bulk:user:42"); + + assertThat(resolver.resolve(request("POST", "/places/photos/batch"))) + .extracting(RateLimitPlan::key) + .containsExactly("rate-limit:http:places-photo-bulk:user:42"); + } + + @Test + @DisplayName("Routes 벌크 POST는 사용자/방별 route 제한만 적용하고 쓰기 제한은 적용하지 않는다") + void resolvesRouteBatchPoliciesWithoutWritePolicy() { + authenticate(42L); + + assertThat(resolver.resolve(request("POST", "/rooms/room-1/schedules/10/routes/batch"))) + .extracting(RateLimitPlan::key) + .containsExactly( + "rate-limit:http:route-batch:user:42", + "rate-limit:http:route-batch:room:room-1" + ); + } + + @Test + @DisplayName("Route 단건 조회 GET은 사용자/방별 route-single 제한을 적용한다") + void resolvesSingleRoutePolicies() { + authenticate(42L); + + assertThat(resolver.resolve(request("GET", "/rooms/room-1/schedules/10/items/20/route"))) + .extracting(RateLimitPlan::key) + .containsExactly( + "rate-limit:http:route-single:user:42", + "rate-limit:http:route-single:room:room-1" + ); } @Test diff --git a/src/test/java/com/howaboutus/backend/messages/service/MessageRateLimiterTest.java b/src/test/java/com/howaboutus/backend/messages/service/MessageRateLimiterTest.java index 1cea36d2..54862f7c 100644 --- a/src/test/java/com/howaboutus/backend/messages/service/MessageRateLimiterTest.java +++ b/src/test/java/com/howaboutus/backend/messages/service/MessageRateLimiterTest.java @@ -25,18 +25,23 @@ @ExtendWith(MockitoExtension.class) class MessageRateLimiterTest { - private static final RateLimitProperties TEST_PROPERTIES = new RateLimitProperties(java.util.Map.of( - "auth-refresh-session", new RateLimitProperties.PolicyDto(10, Duration.ofMinutes(1), null), - "places-search", new RateLimitProperties.PolicyDto(10, Duration.ofMinutes(1), null), - "places-read", new RateLimitProperties.PolicyDto(30, Duration.ofMinutes(1), null), - "route-user", new RateLimitProperties.PolicyDto(20, Duration.ofMinutes(1), null), - "route-room", new RateLimitProperties.PolicyDto(60, Duration.ofMinutes(1), null), - "join-user", new RateLimitProperties.PolicyDto(10, Duration.ofMinutes(1), null), - "write-user", new RateLimitProperties.PolicyDto(60, Duration.ofMinutes(1), null), - "chat", new RateLimitProperties.PolicyDto(null, null, java.util.List.of( + private static final RateLimitProperties TEST_PROPERTIES = new RateLimitProperties(java.util.Map.ofEntries( + java.util.Map.entry("auth-refresh-session", new RateLimitProperties.PolicyDto(10, Duration.ofMinutes(1), null)), + java.util.Map.entry("places-search", new RateLimitProperties.PolicyDto(10, Duration.ofMinutes(1), null)), + java.util.Map.entry("places-read-single", new RateLimitProperties.PolicyDto(30, Duration.ofMinutes(1), null)), + java.util.Map.entry("places-read-bulk", new RateLimitProperties.PolicyDto(10, Duration.ofMinutes(1), null)), + java.util.Map.entry("places-photo-single", new RateLimitProperties.PolicyDto(45, Duration.ofMinutes(1), null)), + java.util.Map.entry("places-photo-bulk", new RateLimitProperties.PolicyDto(15, Duration.ofMinutes(1), null)), + java.util.Map.entry("route-single-user", new RateLimitProperties.PolicyDto(20, Duration.ofMinutes(1), null)), + java.util.Map.entry("route-single-room", new RateLimitProperties.PolicyDto(60, Duration.ofMinutes(1), null)), + java.util.Map.entry("route-batch-user", new RateLimitProperties.PolicyDto(10, Duration.ofMinutes(1), null)), + java.util.Map.entry("route-batch-room", new RateLimitProperties.PolicyDto(30, Duration.ofMinutes(1), null)), + java.util.Map.entry("join-user", new RateLimitProperties.PolicyDto(10, Duration.ofMinutes(1), null)), + java.util.Map.entry("write-user", new RateLimitProperties.PolicyDto(60, Duration.ofMinutes(1), null)), + java.util.Map.entry("chat", new RateLimitProperties.PolicyDto(null, null, java.util.List.of( new RateLimitProperties.BandwidthDto(5, Duration.ofSeconds(1)), new RateLimitProperties.BandwidthDto(60, Duration.ofMinutes(1)) - )) + ))) )); @Mock private RateLimitService rateLimitService; diff --git a/src/test/java/com/howaboutus/backend/places/controller/PlaceControllerTest.java b/src/test/java/com/howaboutus/backend/places/controller/PlaceControllerTest.java index 119d3b24..76b7078b 100644 --- a/src/test/java/com/howaboutus/backend/places/controller/PlaceControllerTest.java +++ b/src/test/java/com/howaboutus/backend/places/controller/PlaceControllerTest.java @@ -14,7 +14,9 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; @@ -30,6 +32,9 @@ import com.howaboutus.backend.places.service.PlacePreviewService; import com.howaboutus.backend.places.service.PlaceSearchService; import com.howaboutus.backend.places.service.dto.PlaceDetailResult; +import com.howaboutus.backend.places.service.dto.PlacePhotoBatchItemResult; +import com.howaboutus.backend.places.service.dto.PlacePhotoNameBatchItemResult; +import com.howaboutus.backend.places.service.dto.PlacePreviewBatchItemResult; import com.howaboutus.backend.places.service.dto.PlacePreviewResult; import com.howaboutus.backend.places.service.dto.PlaceSearchPageResult; import com.howaboutus.backend.places.service.dto.PlaceSearchResult; @@ -51,6 +56,9 @@ class PlaceControllerTest { @Autowired private MockMvc mockMvc; + @Autowired + private PlaceController placeController; + @MockitoBean private JwtProvider jwtProvider; @@ -83,6 +91,7 @@ private static MockHttpServletRequestBuilder searchRequest(String query) { @BeforeEach void setUp() { given(jwtProvider.extractUserId(VALID_TOKEN)).willReturn(USER_ID); + ReflectionTestUtils.setField(placeController, "photoEnabled", true); placeSearchResult = new PlaceSearchResult( "ChIJ123", "Cafe Layered", @@ -443,6 +452,99 @@ void returnsPlacePreviewWhenGooglePlaceIdIsValid() throws Exception { then(placePreviewService).should().getPreview("ChIJ123"); } + @Test + @DisplayName("googlePlaceId 목록으로 장소 미리보기를 벌크 조회한다") + void returnsPlacePreviewsForBatchRequest() throws Exception { + given(placePreviewService.getPreviews(List.of("ChIJ123", "ChIJ456"))) + .willReturn(List.of( + PlacePreviewBatchItemResult.ok(new PlacePreviewResult( + "ChIJ123", + "Cafe Layered", + "서울 종로구 ...", + "cafe", + "카페", + new PlacePreviewResult.Location(37.57, 126.98), + "places/ChIJ123/photos/a" + )), + PlacePreviewBatchItemResult.ok(new PlacePreviewResult( + "ChIJ456", + "Museum", + "서울 중구 ...", + "museum", + "박물관", + new PlacePreviewResult.Location(37.56, 126.97), + null + )) + )); + + mockMvc.perform(post("/places/previews/batch") + .cookie(new Cookie("access_token", VALID_TOKEN)) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"googlePlaceIds": ["ChIJ123", "ChIJ456"]} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.previews", Matchers.hasSize(2))) + .andExpect(jsonPath("$.previews[0].googlePlaceId").value("ChIJ123")) + .andExpect(jsonPath("$.previews[0].status").value("OK")) + .andExpect(jsonPath("$.previews[0].photoName").value("places/ChIJ123/photos/a")) + .andExpect(jsonPath("$.previews[0].errorCode").doesNotExist()) + .andExpect(jsonPath("$.previews[1].googlePlaceId").value("ChIJ456")) + .andExpect(jsonPath("$.previews[1].status").value("OK")) + .andExpect(jsonPath("$.previews[1].photoName").doesNotExist()); + + then(placePreviewService).should().getPreviews(List.of("ChIJ123", "ChIJ456")); + } + + @Test + @DisplayName("미리보기 벌크 조회는 항목별 외부 API 실패를 200 응답 body에 포함한다") + void returnsPartialFailureForBatchPreviewRequest() throws Exception { + given(placePreviewService.getPreviews(List.of("ChIJ123", "ChIJ456"))) + .willReturn(List.of( + PlacePreviewBatchItemResult.ok(new PlacePreviewResult( + "ChIJ123", + "Cafe Layered", + "서울 종로구 ...", + "cafe", + "카페", + new PlacePreviewResult.Location(37.57, 126.98), + "places/ChIJ123/photos/a" + )), + PlacePreviewBatchItemResult.failure("ChIJ456", "EXTERNAL_API_ERROR") + )); + + mockMvc.perform(post("/places/previews/batch") + .cookie(new Cookie("access_token", VALID_TOKEN)) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"googlePlaceIds": ["ChIJ123", "ChIJ456"]} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.previews", Matchers.hasSize(2))) + .andExpect(jsonPath("$.previews[0].status").value("OK")) + .andExpect(jsonPath("$.previews[0].name").value("Cafe Layered")) + .andExpect(jsonPath("$.previews[1].googlePlaceId").value("ChIJ456")) + .andExpect(jsonPath("$.previews[1].status").value("FAILED")) + .andExpect(jsonPath("$.previews[1].errorCode").value("EXTERNAL_API_ERROR")) + .andExpect(jsonPath("$.previews[1].name").doesNotExist()); + } + + @Test + @DisplayName("미리보기 벌크 요청의 googlePlaceIds가 비어 있으면 400을 반환한다") + void returnsBadRequestWhenBatchPreviewIdsAreEmpty() throws Exception { + mockMvc.perform(post("/places/previews/batch") + .cookie(new Cookie("access_token", VALID_TOKEN)) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"googlePlaceIds": []} + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("BAD_REQUEST")) + .andExpect(jsonPath("$.message").value("googlePlaceIds는 비어 있을 수 없습니다")); + + verifyNoInteractions(placePreviewService); + } + @Test @DisplayName("장소 상세 조회 중 외부 API 오류 발생 시 502를 반환한다") void returnsBadGatewayWhenPlaceDetailLookupFails() throws Exception { @@ -471,6 +573,82 @@ void returnsPhotoUrlForValidName() throws Exception { then(placePhotoService).should().getPhotoUrl("places/ChIJ123/photos/abc"); } + @Test + @DisplayName("photoName 목록으로 사진 URL을 벌크 조회한다") + void returnsPhotoUrlsForBatchRequest() throws Exception { + given(placePhotoService.getPhotoUrls(List.of("places/ChIJ123/photos/a", "places/ChIJ456/photos/b"))) + .willReturn(List.of( + PlacePhotoBatchItemResult.ok( + "places/ChIJ123/photos/a", + "https://lh3.googleusercontent.com/a.jpg" + ), + PlacePhotoBatchItemResult.ok( + "places/ChIJ456/photos/b", + "https://lh3.googleusercontent.com/b.jpg" + ) + )); + + mockMvc.perform(post("/places/photos/batch") + .cookie(new Cookie("access_token", VALID_TOKEN)) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"photoNames": ["places/ChIJ123/photos/a", "places/ChIJ456/photos/b"]} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.photos", Matchers.hasSize(2))) + .andExpect(jsonPath("$.photos[0].photoName").value("places/ChIJ123/photos/a")) + .andExpect(jsonPath("$.photos[0].status").value("OK")) + .andExpect(jsonPath("$.photos[0].photoUrl").value("https://lh3.googleusercontent.com/a.jpg")) + .andExpect(jsonPath("$.photos[0].errorCode").doesNotExist()) + .andExpect(jsonPath("$.photos[1].photoName").value("places/ChIJ456/photos/b")) + .andExpect(jsonPath("$.photos[1].status").value("OK")) + .andExpect(jsonPath("$.photos[1].photoUrl").value("https://lh3.googleusercontent.com/b.jpg")); + + then(placePhotoService).should() + .getPhotoUrls(List.of("places/ChIJ123/photos/a", "places/ChIJ456/photos/b")); + } + + @Test + @DisplayName("사진 URL 벌크 조회는 항목별 외부 API 실패를 200 응답 body에 포함한다") + void returnsPartialFailureForBatchPhotoRequest() throws Exception { + given(placePhotoService.getPhotoUrls(List.of("places/ChIJ123/photos/a", "places/ChIJ456/photos/b"))) + .willReturn(List.of( + PlacePhotoBatchItemResult.ok("places/ChIJ123/photos/a", "https://lh3.googleusercontent.com/a.jpg"), + PlacePhotoBatchItemResult.failure("places/ChIJ456/photos/b", "EXTERNAL_API_ERROR") + )); + + mockMvc.perform(post("/places/photos/batch") + .cookie(new Cookie("access_token", VALID_TOKEN)) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"photoNames": ["places/ChIJ123/photos/a", "places/ChIJ456/photos/b"]} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.photos", Matchers.hasSize(2))) + .andExpect(jsonPath("$.photos[0].status").value("OK")) + .andExpect(jsonPath("$.photos[0].photoUrl").value("https://lh3.googleusercontent.com/a.jpg")) + .andExpect(jsonPath("$.photos[1].photoName").value("places/ChIJ456/photos/b")) + .andExpect(jsonPath("$.photos[1].status").value("FAILED")) + .andExpect(jsonPath("$.photos[1].errorCode").value("EXTERNAL_API_ERROR")) + .andExpect(jsonPath("$.photos[1].photoUrl").doesNotExist()); + } + + @Test + @DisplayName("사진 기능이 비활성화된 경우 벌크 사진 URL 조회는 204를 반환한다") + void returnsNoContentForBatchPhotoRequestWhenPhotoDisabled() throws Exception { + ReflectionTestUtils.setField(placeController, "photoEnabled", false); + + mockMvc.perform(post("/places/photos/batch") + .cookie(new Cookie("access_token", VALID_TOKEN)) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"photoNames": ["places/ChIJ123/photos/a"]} + """)) + .andExpect(status().isNoContent()); + + verifyNoInteractions(placePhotoService); + } + @Test @DisplayName("유효하지 않은 형식의 name으로 요청하면 400을 반환한다") void returnsBadRequestWhenNameIsBlank() throws Exception { @@ -539,6 +717,75 @@ void returnsPhotoNamesWhenGooglePlaceIdIsValid() throws Exception { then(placePhotoNameService).should().getPhotoNames("ChIJ123"); } + @Test + @DisplayName("googlePlaceId 목록으로 대표 사진 이름을 벌크 조회한다") + void returnsPhotoNamesForBatchRequest() throws Exception { + given(placePhotoNameService.getFirstPhotoNames(List.of("ChIJ123", "ChIJ456"))) + .willReturn(List.of( + PlacePhotoNameBatchItemResult.ok("ChIJ123", "places/ChIJ123/photos/a"), + PlacePhotoNameBatchItemResult.ok("ChIJ456", null) + )); + + mockMvc.perform(post("/places/photo-names/batch") + .cookie(new Cookie("access_token", VALID_TOKEN)) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"googlePlaceIds": ["ChIJ123", "ChIJ456"]} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.photoNames", Matchers.hasSize(2))) + .andExpect(jsonPath("$.photoNames[0].googlePlaceId").value("ChIJ123")) + .andExpect(jsonPath("$.photoNames[0].status").value("OK")) + .andExpect(jsonPath("$.photoNames[0].photoName").value("places/ChIJ123/photos/a")) + .andExpect(jsonPath("$.photoNames[0].errorCode").doesNotExist()) + .andExpect(jsonPath("$.photoNames[1].googlePlaceId").value("ChIJ456")) + .andExpect(jsonPath("$.photoNames[1].status").value("OK")) + .andExpect(jsonPath("$.photoNames[1].photoName").doesNotExist()); + + then(placePhotoNameService).should().getFirstPhotoNames(List.of("ChIJ123", "ChIJ456")); + } + + @Test + @DisplayName("대표 사진 이름 벌크 조회는 항목별 외부 API 실패를 200 응답 body에 포함한다") + void returnsPartialFailureForBatchPhotoNameRequest() throws Exception { + given(placePhotoNameService.getFirstPhotoNames(List.of("ChIJ123", "ChIJ456"))) + .willReturn(List.of( + PlacePhotoNameBatchItemResult.ok("ChIJ123", "places/ChIJ123/photos/a"), + PlacePhotoNameBatchItemResult.failure("ChIJ456", "EXTERNAL_API_ERROR") + )); + + mockMvc.perform(post("/places/photo-names/batch") + .cookie(new Cookie("access_token", VALID_TOKEN)) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"googlePlaceIds": ["ChIJ123", "ChIJ456"]} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.photoNames", Matchers.hasSize(2))) + .andExpect(jsonPath("$.photoNames[0].status").value("OK")) + .andExpect(jsonPath("$.photoNames[0].photoName").value("places/ChIJ123/photos/a")) + .andExpect(jsonPath("$.photoNames[1].googlePlaceId").value("ChIJ456")) + .andExpect(jsonPath("$.photoNames[1].status").value("EXTERNAL_API_ERROR")) + .andExpect(jsonPath("$.photoNames[1].errorCode").value("EXTERNAL_API_ERROR")) + .andExpect(jsonPath("$.photoNames[1].photoName").doesNotExist()); + } + + @Test + @DisplayName("대표 사진 이름 벌크 요청의 googlePlaceIds가 비어 있으면 400을 반환한다") + void returnsBadRequestWhenBatchPhotoNameIdsAreEmpty() throws Exception { + mockMvc.perform(post("/places/photo-names/batch") + .cookie(new Cookie("access_token", VALID_TOKEN)) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"googlePlaceIds": []} + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("BAD_REQUEST")) + .andExpect(jsonPath("$.message").value("googlePlaceIds는 비어 있을 수 없습니다")); + + verifyNoInteractions(placePhotoNameService); + } + @Test @DisplayName("googlePlaceId가 공백이면 400을 반환한다") void returnsBadRequestWhenGooglePlaceIdIsBlank() throws Exception { diff --git a/src/test/java/com/howaboutus/backend/places/service/PlaceDetailCachingTest.java b/src/test/java/com/howaboutus/backend/places/service/PlaceDetailCachingTest.java index a6fe99df..edae1a98 100644 --- a/src/test/java/com/howaboutus/backend/places/service/PlaceDetailCachingTest.java +++ b/src/test/java/com/howaboutus/backend/places/service/PlaceDetailCachingTest.java @@ -18,6 +18,7 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean; import com.howaboutus.backend.common.config.CachePolicy; +import com.howaboutus.backend.common.error.ExternalApiException; import com.howaboutus.backend.common.integration.google.GooglePlaceDetailClient; import com.howaboutus.backend.common.integration.google.dto.GooglePlaceDetailResponse; import com.howaboutus.backend.common.integration.google.dto.GooglePlaceDisplayName; @@ -25,6 +26,8 @@ import com.howaboutus.backend.common.integration.google.dto.GooglePlaceLocation; import com.howaboutus.backend.common.integration.google.dto.GooglePlacePhoto; import com.howaboutus.backend.places.service.dto.PlaceDetailResult; +import com.howaboutus.backend.places.service.dto.PlacePhotoNameBatchItemResult; +import com.howaboutus.backend.places.service.dto.PlacePreviewBatchItemResult; import com.howaboutus.backend.places.service.dto.PlacePreviewResult; import com.howaboutus.backend.support.BaseIntegrationTest; @@ -36,6 +39,9 @@ class PlaceDetailCachingTest extends BaseIntegrationTest { @Autowired private PlacePreviewService placePreviewService; + @Autowired + private PlacePhotoNameService placePhotoNameService; + @MockitoBean private GooglePlaceDetailClient googlePlaceDetailClient; @@ -94,6 +100,125 @@ void reusesCachedPreviewBodyAndRefreshesPhotoName() { then(googlePlaceDetailClient).should(times(1)).getPhotoNames("ChIJ123"); } + @Test + @DisplayName("미리보기 벌크 조회는 중복 googlePlaceId를 제거하고 요청 순서를 복원한다") + void getsPreviewsInBulkWithDeduplicationAndCacheReuse() { + given(googlePlaceDetailClient.getPreview("ChIJ123")) + .willReturn(detailResponse("places/ChIJ123/photos/a")); + given(googlePlaceDetailClient.getPreview("ChIJ456")) + .willReturn(detailResponse("ChIJ456", "places/ChIJ456/photos/b")); + given(googlePlaceDetailClient.getPhotoNames("ChIJ123")) + .willReturn(photoNamesResponse("places/ChIJ123/photos/c")); + given(googlePlaceDetailClient.getPhotoNames("ChIJ456")) + .willReturn(photoNamesResponse("places/ChIJ456/photos/d")); + + List first = placePreviewService.getPreviews( + List.of("ChIJ123", "ChIJ123", "ChIJ456") + ); + List second = placePreviewService.getPreviews(List.of("ChIJ456", "ChIJ123")); + + assertThat(first) + .extracting(PlacePreviewBatchItemResult::googlePlaceId) + .containsExactly("ChIJ123", "ChIJ123", "ChIJ456"); + assertThat(first) + .extracting(PlacePreviewBatchItemResult::photoName) + .containsExactly("places/ChIJ123/photos/a", "places/ChIJ123/photos/a", "places/ChIJ456/photos/b"); + assertThat(second) + .extracting(PlacePreviewBatchItemResult::photoName) + .containsExactly("places/ChIJ456/photos/d", "places/ChIJ123/photos/c"); + assertThat(cachedPreviewBody("ChIJ123").photoName()).isNull(); + assertThat(cachedPreviewBody("ChIJ456").photoName()).isNull(); + then(googlePlaceDetailClient).should(times(1)).getPreview("ChIJ123"); + then(googlePlaceDetailClient).should(times(1)).getPreview("ChIJ456"); + then(googlePlaceDetailClient).should(times(1)).getPhotoNames("ChIJ123"); + then(googlePlaceDetailClient).should(times(1)).getPhotoNames("ChIJ456"); + } + + @Test + @DisplayName("미리보기 벌크 조회는 일부 외부 API 실패를 항목별 실패로 반환하고 나머지 결과를 보존한다") + void getsPreviewsInBulkWithPartialFailures() { + given(googlePlaceDetailClient.getPreview("ChIJ123")) + .willReturn(detailResponse("places/ChIJ123/photos/a")); + given(googlePlaceDetailClient.getPreview("ChIJ456")) + .willThrow(new ExternalApiException(new RuntimeException("connection timeout"))); + + List results = placePreviewService.getPreviews(List.of("ChIJ123", "ChIJ456")); + + assertThat(results) + .extracting(PlacePreviewBatchItemResult::googlePlaceId, PlacePreviewBatchItemResult::status, + PlacePreviewBatchItemResult::photoName, PlacePreviewBatchItemResult::errorCode) + .containsExactly( + tuple("ChIJ123", "OK", "places/ChIJ123/photos/a", null), + tuple("ChIJ456", "FAILED", null, "EXTERNAL_API_ERROR") + ); + } + + @Test + @DisplayName("캐시된 미리보기의 사진 이름 갱신 실패 시 해당 항목은 EXTERNAL_API_ERROR로 처리된다") + void handlesPhotoNameFetchFailureForCachedPreviews() { + String googlePlaceId = "ChIJ123"; + // 1. 초기 성공 응답으로 캐싱 유도 + given(googlePlaceDetailClient.getPreview(googlePlaceId)) + .willReturn(detailResponse("places/ChIJ123/photos/a")); + + placePreviewService.getPreview(googlePlaceId); + + // 2. 캐시 조회 시 photoName을 새로 fetch하는 도중 API 에러 유도 + given(googlePlaceDetailClient.getPhotoNames(googlePlaceId)) + .willThrow(new ExternalApiException(new RuntimeException("API timeout"))); + + List results = placePreviewService.getPreviews(List.of(googlePlaceId)); + + assertThat(results).hasSize(1); + assertThat(results.get(0).status()).isEqualTo("FAILED"); + assertThat(results.get(0).errorCode()).isEqualTo("EXTERNAL_API_ERROR"); + } + + @Test + @DisplayName("대표 사진 이름 벌크 조회는 중복 googlePlaceId를 제거하고 요청 순서를 복원한다") + void getsFirstPhotoNamesInBulkWithDeduplication() { + given(googlePlaceDetailClient.getPhotoNames("ChIJ123")) + .willReturn(photoNamesResponse("places/ChIJ123/photos/a")); + given(googlePlaceDetailClient.getPhotoNames("ChIJ456")) + .willReturn(photoNamesResponse(null)); + + List results = placePhotoNameService.getFirstPhotoNames( + List.of("ChIJ123", "ChIJ123", "ChIJ456") + ); + + assertThat(results) + .extracting(PlacePhotoNameBatchItemResult::googlePlaceId, PlacePhotoNameBatchItemResult::status, + PlacePhotoNameBatchItemResult::photoName, PlacePhotoNameBatchItemResult::errorCode) + .containsExactly( + tuple("ChIJ123", "OK", "places/ChIJ123/photos/a", null), + tuple("ChIJ123", "OK", "places/ChIJ123/photos/a", null), + tuple("ChIJ456", "OK", null, null) + ); + then(googlePlaceDetailClient).should(times(1)).getPhotoNames("ChIJ123"); + then(googlePlaceDetailClient).should(times(1)).getPhotoNames("ChIJ456"); + } + + @Test + @DisplayName("대표 사진 이름 벌크 조회는 일부 외부 API 실패를 항목별 실패로 반환하고 나머지 결과를 보존한다") + void getsFirstPhotoNamesInBulkWithPartialFailures() { + given(googlePlaceDetailClient.getPhotoNames("ChIJ123")) + .willReturn(photoNamesResponse("places/ChIJ123/photos/a")); + given(googlePlaceDetailClient.getPhotoNames("ChIJ456")) + .willThrow(new ExternalApiException(new RuntimeException("connection timeout"))); + + List results = placePhotoNameService.getFirstPhotoNames( + List.of("ChIJ123", "ChIJ456") + ); + + assertThat(results) + .extracting(PlacePhotoNameBatchItemResult::googlePlaceId, PlacePhotoNameBatchItemResult::status, + PlacePhotoNameBatchItemResult::photoName, PlacePhotoNameBatchItemResult::errorCode) + .containsExactly( + tuple("ChIJ123", "OK", "places/ChIJ123/photos/a", null), + tuple("ChIJ456", "EXTERNAL_API_ERROR", null, "EXTERNAL_API_ERROR") + ); + } + @Test @DisplayName("구버전 미리보기 캐시에 유형 필드가 없어도 null로 역직렬화하고 사진명만 갱신한다") void readsLegacyCachedPreviewBodyWithoutPrimaryTypeFields() { @@ -137,13 +262,21 @@ private PlaceDetailResult cachedDetailBody() { } private PlacePreviewResult cachedPreviewBody() { + return cachedPreviewBody("ChIJ123"); + } + + private PlacePreviewResult cachedPreviewBody(String googlePlaceId) { return Objects.requireNonNull(cacheManager.getCache(CachePolicy.Keys.PLACE_PREVIEW)) - .get("ChIJ123", PlacePreviewResult.class); + .get(googlePlaceId, PlacePreviewResult.class); } private GooglePlaceDetailResponse detailResponse(String photoName) { + return detailResponse("ChIJ123", photoName); + } + + private GooglePlaceDetailResponse detailResponse(String googlePlaceId, String photoName) { return new GooglePlaceDetailResponse( - "places/ChIJ123", + "places/" + googlePlaceId, new GooglePlaceDisplayName("Cafe Layered", "ko"), "서울 종로구 ...", new GooglePlaceLocation(37.57, 126.98), diff --git a/src/test/java/com/howaboutus/backend/places/service/PlacePhotoCachingTest.java b/src/test/java/com/howaboutus/backend/places/service/PlacePhotoCachingTest.java index ccda6168..aecd985d 100644 --- a/src/test/java/com/howaboutus/backend/places/service/PlacePhotoCachingTest.java +++ b/src/test/java/com/howaboutus/backend/places/service/PlacePhotoCachingTest.java @@ -4,17 +4,24 @@ import static org.mockito.BDDMockito.*; import static org.mockito.Mockito.*; +import java.nio.charset.StandardCharsets; +import java.util.List; import java.util.Objects; +import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.cache.CacheManager; +import org.springframework.data.redis.connection.RedisConnection; +import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.test.context.bean.override.mockito.MockitoBean; import com.howaboutus.backend.common.config.CachePolicy; +import com.howaboutus.backend.common.error.ExternalApiException; import com.howaboutus.backend.common.integration.google.GooglePlacePhotoClient; +import com.howaboutus.backend.places.service.dto.PlacePhotoBatchItemResult; import com.howaboutus.backend.support.BaseIntegrationTest; class PlacePhotoCachingTest extends BaseIntegrationTest { @@ -28,6 +35,9 @@ class PlacePhotoCachingTest extends BaseIntegrationTest { @Autowired private CacheManager cacheManager; + @Autowired + private RedisConnectionFactory redisConnectionFactory; + @BeforeEach void setUp() { reset(googlePlacePhotoClient); @@ -35,7 +45,7 @@ void setUp() { } @Test - @DisplayName("같은 photoName의 photoUri는 짧은 TTL 캐시로 재사용한다") + @DisplayName("같은 photoName의 photoUri는 기본 크기 기준 24시간 TTL 캐시로 재사용한다") void reusesCachedPhotoUriForSamePhotoName() { given(googlePlacePhotoClient.getPhotoUri("places/ChIJ123/photos/a")) .willReturn("https://lh3.googleusercontent.com/photo.jpg"); @@ -45,6 +55,71 @@ void reusesCachedPhotoUriForSamePhotoName() { assertThat(first).isEqualTo("https://lh3.googleusercontent.com/photo.jpg"); assertThat(second).isEqualTo(first); + assertThat(photoCacheTtl("places/ChIJ123/photos/a:w400:h400")) + .isBetween(TimeUnit.HOURS.toSeconds(23), TimeUnit.DAYS.toSeconds(1)); + then(googlePlacePhotoClient).should(times(1)).getPhotoUri("places/ChIJ123/photos/a"); + } + + @Test + @DisplayName("사진 URL 벌크 조회는 중복 photoName을 제거해 캐시 미스만 조회하고 요청 순서를 복원한다") + void getsPhotoUrlsInBulkWithDeduplicationAndCacheReuse() { + given(googlePlacePhotoClient.getPhotoUri("places/ChIJ123/photos/a")) + .willReturn("https://lh3.googleusercontent.com/a.jpg"); + given(googlePlacePhotoClient.getPhotoUri("places/ChIJ456/photos/b")) + .willReturn("https://lh3.googleusercontent.com/b.jpg"); + + var first = placePhotoService.getPhotoUrls( + List.of("places/ChIJ123/photos/a", "places/ChIJ123/photos/a", "places/ChIJ456/photos/b") + ); + var second = placePhotoService.getPhotoUrls( + List.of("places/ChIJ456/photos/b", "places/ChIJ123/photos/a") + ); + + assertThat(first) + .extracting(PlacePhotoBatchItemResult::photoName, PlacePhotoBatchItemResult::status, + PlacePhotoBatchItemResult::photoUrl, PlacePhotoBatchItemResult::errorCode) + .containsExactly( + tuple("places/ChIJ123/photos/a", "OK", "https://lh3.googleusercontent.com/a.jpg", null), + tuple("places/ChIJ123/photos/a", "OK", "https://lh3.googleusercontent.com/a.jpg", null), + tuple("places/ChIJ456/photos/b", "OK", "https://lh3.googleusercontent.com/b.jpg", null) + ); + assertThat(second) + .extracting(PlacePhotoBatchItemResult::photoName, PlacePhotoBatchItemResult::status, + PlacePhotoBatchItemResult::photoUrl, PlacePhotoBatchItemResult::errorCode) + .containsExactly( + tuple("places/ChIJ456/photos/b", "OK", "https://lh3.googleusercontent.com/b.jpg", null), + tuple("places/ChIJ123/photos/a", "OK", "https://lh3.googleusercontent.com/a.jpg", null) + ); then(googlePlacePhotoClient).should(times(1)).getPhotoUri("places/ChIJ123/photos/a"); + then(googlePlacePhotoClient).should(times(1)).getPhotoUri("places/ChIJ456/photos/b"); + } + + @Test + @DisplayName("사진 URL 벌크 조회는 일부 외부 API 실패를 항목별 실패로 반환하고 나머지 결과를 보존한다") + void getsPhotoUrlsInBulkWithPartialFailures() { + given(googlePlacePhotoClient.getPhotoUri("places/ChIJ123/photos/a")) + .willReturn("https://lh3.googleusercontent.com/a.jpg"); + given(googlePlacePhotoClient.getPhotoUri("places/ChIJ456/photos/b")) + .willThrow(new ExternalApiException(new RuntimeException("connection timeout"))); + + var results = placePhotoService.getPhotoUrls( + List.of("places/ChIJ123/photos/a", "places/ChIJ456/photos/b") + ); + + assertThat(results) + .extracting(PlacePhotoBatchItemResult::photoName, PlacePhotoBatchItemResult::status, + PlacePhotoBatchItemResult::photoUrl, PlacePhotoBatchItemResult::errorCode) + .containsExactly( + tuple("places/ChIJ123/photos/a", "OK", "https://lh3.googleusercontent.com/a.jpg", null), + tuple("places/ChIJ456/photos/b", "FAILED", null, "EXTERNAL_API_ERROR") + ); + } + + private long photoCacheTtl(String key) { + try (RedisConnection connection = redisConnectionFactory.getConnection()) { + return connection.keyCommands().ttl( + (CachePolicy.Keys.PLACE_PHOTO_URI + "::" + key).getBytes(StandardCharsets.UTF_8) + ); + } } } diff --git a/src/test/java/com/howaboutus/backend/places/service/PlacePhotoServiceTest.java b/src/test/java/com/howaboutus/backend/places/service/PlacePhotoServiceTest.java index df4a5cf5..4082cb9a 100644 --- a/src/test/java/com/howaboutus/backend/places/service/PlacePhotoServiceTest.java +++ b/src/test/java/com/howaboutus/backend/places/service/PlacePhotoServiceTest.java @@ -4,19 +4,44 @@ import static org.mockito.BDDMockito.*; import static org.mockito.Mockito.*; +import java.util.concurrent.Executor; + +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.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import com.howaboutus.backend.common.cache.RedisBulkCacheAccessor; import com.howaboutus.backend.common.integration.google.GooglePlacePhotoClient; +@ExtendWith(MockitoExtension.class) class PlacePhotoServiceTest { - private final GooglePlacePhotoClient googlePlacePhotoClient = mock(GooglePlacePhotoClient.class); - private final PlacePhotoService placePhotoService = new PlacePhotoService(googlePlacePhotoClient); + @Mock + private GooglePlacePhotoClient googlePlacePhotoClient; + + @Mock + private RedisBulkCacheAccessor redisBulkCacheAccessor; + + @InjectMocks + private PlacePhotoService placePhotoService; + + private final Executor taskExecutor = Runnable::run; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(placePhotoService, "googleApiExecutor", taskExecutor); + } @Test @DisplayName("photoName을 클라이언트에 위임해 photoUrl을 반환한다") void delegatesPhotoUriResolutionToClient() { + given(redisBulkCacheAccessor.multiGet(anyString(), anyCollection(), eq(String.class))) + .willReturn(java.util.Map.of()); given(googlePlacePhotoClient.getPhotoUri("places/ChIJ123/photos/abc")) .willReturn("https://lh3.googleusercontent.com/photo.jpg"); diff --git a/src/test/java/com/howaboutus/backend/schedules/ScheduleIntegrationTest.java b/src/test/java/com/howaboutus/backend/schedules/ScheduleIntegrationTest.java index 3f40ef3a..66a58279 100644 --- a/src/test/java/com/howaboutus/backend/schedules/ScheduleIntegrationTest.java +++ b/src/test/java/com/howaboutus/backend/schedules/ScheduleIntegrationTest.java @@ -183,6 +183,15 @@ void scheduleItemFlowWorksEndToEnd() throws Exception { .andExpect(status().isCreated()) .andExpect(jsonPath("$.orderIndex").value(1)); + mockMvc.perform(get("/rooms/{roomId}/schedules", room.getId()) + .cookie(new Cookie("access_token", VALID_TOKEN)) + .param("includeItems", "true")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].scheduleId").value(schedule.getId())) + .andExpect(jsonPath("$[0].items.length()").value(2)) + .andExpect(jsonPath("$[0].items[0].googlePlaceId").value("place-1")) + .andExpect(jsonPath("$[0].items[1].googlePlaceId").value("place-2")); + ScheduleItem firstItem = scheduleItemRepository .findAllBySchedule_IdOrderByOrderIndexAsc(schedule.getId()).getFirst(); diff --git a/src/test/java/com/howaboutus/backend/schedules/controller/ScheduleControllerTest.java b/src/test/java/com/howaboutus/backend/schedules/controller/ScheduleControllerTest.java index 4d776f5d..d4f396b0 100644 --- a/src/test/java/com/howaboutus/backend/schedules/controller/ScheduleControllerTest.java +++ b/src/test/java/com/howaboutus/backend/schedules/controller/ScheduleControllerTest.java @@ -15,6 +15,7 @@ import java.time.Instant; import java.time.LocalDate; +import java.time.LocalTime; import java.util.List; import java.util.UUID; import java.util.stream.Collectors; @@ -40,8 +41,10 @@ import com.howaboutus.backend.schedules.service.ScheduleService; import com.howaboutus.backend.schedules.service.dto.ScheduleBatchCreateCommand; import com.howaboutus.backend.schedules.service.dto.ScheduleCreateCommand; +import com.howaboutus.backend.schedules.service.dto.ScheduleItemResult; import com.howaboutus.backend.schedules.service.dto.ScheduleMoveCommand; import com.howaboutus.backend.schedules.service.dto.ScheduleResult; +import com.howaboutus.backend.schedules.service.dto.ScheduleWithItemsResult; import jakarta.servlet.http.Cookie; @@ -138,6 +141,36 @@ void returnsScheduleListSuccessfully() throws Exception { .andExpect(jsonPath("$[0].createdAt").value(SCHEDULE_RESULT.createdAt().toString())); } + @Test + @DisplayName("includeItems=true이면 일정 목록에 아이템 배열을 포함해 반환한다") + void returnsScheduleListWithItemsWhenIncludeItemsIsTrue() throws Exception { + ScheduleItemResult itemResult = new ScheduleItemResult( + 100L, + SCHEDULE_ID, + "place-1", + LocalTime.of(10, 0), + 60, + "점심", + 0, + Instant.parse("2025-01-01T01:00:00Z") + ); + given(scheduleService.getSchedulesWithItems(ROOM_ID, USER_ID)) + .willReturn(List.of(new ScheduleWithItemsResult(SCHEDULE_RESULT, List.of(itemResult)))); + + mockMvc.perform(get("/rooms/{roomId}/schedules", ROOM_ID) + .cookie(new Cookie("access_token", VALID_TOKEN)) + .param("includeItems", "true")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].scheduleId").value(SCHEDULE_RESULT.scheduleId())) + .andExpect(jsonPath("$[0].items[0].itemId").value(100L)) + .andExpect(jsonPath("$[0].items[0].scheduleId").value(SCHEDULE_ID)) + .andExpect(jsonPath("$[0].items[0].googlePlaceId").value("place-1")) + .andExpect(jsonPath("$[0].items[0].startTime").value("10:00")) + .andExpect(jsonPath("$[0].items[0].durationMinutes").value(60)) + .andExpect(jsonPath("$[0].items[0].memo").value("점심")) + .andExpect(jsonPath("$[0].items[0].orderIndex").value(0)); + } + @Test @DisplayName("배치 일정 생성 성공 시 201을 반환한다") void createBatchScheduleSuccessfully() throws Exception { diff --git a/src/test/java/com/howaboutus/backend/schedules/controller/ScheduleRouteControllerTest.java b/src/test/java/com/howaboutus/backend/schedules/controller/ScheduleRouteControllerTest.java new file mode 100644 index 00000000..9002bddf --- /dev/null +++ b/src/test/java/com/howaboutus/backend/schedules/controller/ScheduleRouteControllerTest.java @@ -0,0 +1,129 @@ +package com.howaboutus.backend.schedules.controller; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.util.List; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import com.howaboutus.backend.auth.filter.JwtAuthenticationFilter; +import com.howaboutus.backend.auth.service.JwtProvider; +import com.howaboutus.backend.common.config.SecurityConfig; +import com.howaboutus.backend.common.error.GlobalExceptionHandler; +import com.howaboutus.backend.common.security.JwtAuthenticationEntryPoint; +import com.howaboutus.backend.schedules.service.ScheduleItemService; +import com.howaboutus.backend.schedules.service.dto.RouteBatchItemCommand; +import com.howaboutus.backend.schedules.service.dto.RouteBatchItemResult; + +import jakarta.servlet.http.Cookie; + +@WebMvcTest(ScheduleRouteController.class) +@Import({SecurityConfig.class, JwtAuthenticationFilter.class, JwtAuthenticationEntryPoint.class, + GlobalExceptionHandler.class}) +class ScheduleRouteControllerTest { + + private static final Long USER_ID = 1L; + private static final String VALID_TOKEN = "valid-jwt"; + private static final UUID ROOM_ID = UUID.fromString("11111111-1111-1111-1111-111111111111"); + private static final Long SCHEDULE_ID = 10L; + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private JwtProvider jwtProvider; + + @MockitoBean + private ScheduleItemService scheduleItemService; + + @BeforeEach + void setUp() { + given(jwtProvider.extractUserId(VALID_TOKEN)).willReturn(USER_ID); + } + + @Test + @DisplayName("벌크 이동 정보 조회는 항목별 이동 수단을 서비스에 전달하고 결과를 반환한다") + void returnsBatchRoutes() throws Exception { + given(scheduleItemService.getRoutesForItems(eq(ROOM_ID), eq(SCHEDULE_ID), anyList(), eq(USER_ID))) + .willReturn(List.of( + RouteBatchItemResult.ok(1L, 1L, 2L, "WALKING", 500, 300), + RouteBatchItemResult.noRoute(2L, 2L, 3L, "TRANSIT") + )); + + mockMvc.perform( + post("/rooms/{roomId}/schedules/{scheduleId}/routes/batch", ROOM_ID, SCHEDULE_ID) + .cookie(new Cookie("access_token", VALID_TOKEN)) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "items": [ + {"itemId": 1, "travelMode": "WALKING"}, + {"itemId": 2, "travelMode": "TRANSIT"} + ] + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.routes[0].itemId").value(1)) + .andExpect(jsonPath("$.routes[0].fromItemId").value(1)) + .andExpect(jsonPath("$.routes[0].toItemId").value(2)) + .andExpect(jsonPath("$.routes[0].travelMode").value("WALKING")) + .andExpect(jsonPath("$.routes[0].status").value("OK")) + .andExpect(jsonPath("$.routes[0].distanceMeters").value(500)) + .andExpect(jsonPath("$.routes[0].durationSeconds").value(300)) + .andExpect(jsonPath("$.routes[0].errorCode").doesNotExist()) + .andExpect(jsonPath("$.routes[1].status").value("FAILED")) + .andExpect(jsonPath("$.routes[1].errorCode").value("NO_ROUTE")); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(scheduleItemService).getRoutesForItems(eq(ROOM_ID), eq(SCHEDULE_ID), captor.capture(), eq(USER_ID)); + assertThat(captor.getValue()) + .extracting(RouteBatchItemCommand::itemId, RouteBatchItemCommand::travelMode) + .containsExactly(tuple(1L, "WALKING"), tuple(2L, "TRANSIT")); + } + + @Test + @DisplayName("벌크 이동 정보 조회 요청이 비어 있으면 400을 반환한다") + void returnsBadRequestWhenBatchRouteItemsAreEmpty() throws Exception { + mockMvc.perform( + post("/rooms/{roomId}/schedules/{scheduleId}/routes/batch", ROOM_ID, SCHEDULE_ID) + .cookie(new Cookie("access_token", VALID_TOKEN)) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"items": []} + """)) + .andExpect(status().isBadRequest()); + + verifyNoInteractions(scheduleItemService); + } + + @Test + @DisplayName("벌크 이동 정보 조회 항목의 이동 수단이 유효하지 않으면 400을 반환한다") + void returnsBadRequestWhenBatchRouteTravelModeIsInvalid() throws Exception { + mockMvc.perform( + post("/rooms/{roomId}/schedules/{scheduleId}/routes/batch", ROOM_ID, SCHEDULE_ID) + .cookie(new Cookie("access_token", VALID_TOKEN)) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"items": [{"itemId": 1, "travelMode": "FLYING"}]} + """)) + .andExpect(status().isBadRequest()); + + verifyNoInteractions(scheduleItemService); + } +} diff --git a/src/test/java/com/howaboutus/backend/schedules/service/ScheduleItemServiceTest.java b/src/test/java/com/howaboutus/backend/schedules/service/ScheduleItemServiceTest.java index 83876184..0e2ebbce 100644 --- a/src/test/java/com/howaboutus/backend/schedules/service/ScheduleItemServiceTest.java +++ b/src/test/java/com/howaboutus/backend/schedules/service/ScheduleItemServiceTest.java @@ -34,6 +34,8 @@ import com.howaboutus.backend.schedules.entity.ScheduleItem; import com.howaboutus.backend.schedules.repository.ScheduleItemRepository; import com.howaboutus.backend.schedules.repository.ScheduleRepository; +import com.howaboutus.backend.schedules.service.dto.RouteBatchItemCommand; +import com.howaboutus.backend.schedules.service.dto.RouteBatchItemResult; import com.howaboutus.backend.schedules.service.dto.RouteResult; import com.howaboutus.backend.schedules.service.dto.ScheduleItemCreateCommand; import com.howaboutus.backend.schedules.service.dto.ScheduleItemMoveCommand; @@ -890,6 +892,76 @@ void getRouteForItemReturnsEmptyWhenRouteIsNotFound() { assertThat(result).isEmpty(); } + @Test + @DisplayName("벌크 이동 정보 조회는 항목별 이동 수단으로 현재 항목에서 다음 항목까지 조회한다") + void getRoutesForItemsUsesTravelModePerItem() { + UUID roomId = UUID.randomUUID(); + Room room = createRoom(roomId); + Schedule schedule = createSchedule(room, 100L); + ScheduleItem first = createScheduleItem(schedule, 10L, "place-1", 0); + ScheduleItem second = createScheduleItem(schedule, 11L, "place-2", 1); + ScheduleItem third = createScheduleItem(schedule, 12L, "place-3", 2); + + given(scheduleRepository.existsByIdAndRoom_Id(100L, roomId)).willReturn(true); + given(scheduleItemRepository.findAllBySchedule_IdOrderByOrderIndexAsc(100L)) + .willReturn(List.of(first, second, third)); + given(routeService.computeRoute("place-1", "place-2", "WALKING")) + .willReturn(Optional.of(new RouteResult(500, 300, "WALKING"))); + given(routeService.computeRoute("place-2", "place-3", "TRANSIT")) + .willReturn(Optional.empty()); + + List results = scheduleItemService.getRoutesForItems( + roomId, + 100L, + List.of( + new RouteBatchItemCommand(10L, " walking "), + new RouteBatchItemCommand(11L, "TRANSIT") + ), + 1L + ); + + assertThat(results) + .extracting(RouteBatchItemResult::itemId, RouteBatchItemResult::fromItemId, + RouteBatchItemResult::toItemId, RouteBatchItemResult::travelMode, + RouteBatchItemResult::status, RouteBatchItemResult::distanceMeters, + RouteBatchItemResult::durationSeconds, RouteBatchItemResult::errorCode) + .containsExactly( + tuple(10L, 10L, 11L, "WALKING", "OK", 500, 300, null), + tuple(11L, 11L, 12L, "TRANSIT", "FAILED", null, null, "NO_ROUTE") + ); + } + + @Test + @DisplayName("벌크 이동 정보 조회는 없는 항목과 마지막 항목을 항목별 상태로 반환한다") + void getRoutesForItemsReturnsPartialStatuses() { + UUID roomId = UUID.randomUUID(); + Room room = createRoom(roomId); + Schedule schedule = createSchedule(room, 100L); + ScheduleItem only = createScheduleItem(schedule, 10L, "place-1", 0); + + given(scheduleRepository.existsByIdAndRoom_Id(100L, roomId)).willReturn(true); + given(scheduleItemRepository.findAllBySchedule_IdOrderByOrderIndexAsc(100L)) + .willReturn(List.of(only)); + + List results = scheduleItemService.getRoutesForItems( + roomId, + 100L, + List.of( + new RouteBatchItemCommand(10L, "DRIVING"), + new RouteBatchItemCommand(999L, "WALKING") + ), + 1L + ); + + assertThat(results) + .extracting(RouteBatchItemResult::itemId, RouteBatchItemResult::status, RouteBatchItemResult::errorCode) + .containsExactly( + tuple(10L, "FAILED", "LAST_ITEM"), + tuple(999L, "FAILED", "SCHEDULE_ITEM_NOT_FOUND") + ); + verifyNoInteractions(routeService); + } + @Test @DisplayName("방이 없으면 일정 항목 생성 시 ROOM_NOT_FOUND 예외를 던진다") void createThrowsWhenRoomMissing() { diff --git a/src/test/java/com/howaboutus/backend/schedules/service/ScheduleServiceTest.java b/src/test/java/com/howaboutus/backend/schedules/service/ScheduleServiceTest.java index 22cacb35..0316ec6f 100644 --- a/src/test/java/com/howaboutus/backend/schedules/service/ScheduleServiceTest.java +++ b/src/test/java/com/howaboutus/backend/schedules/service/ScheduleServiceTest.java @@ -12,6 +12,7 @@ import java.time.Instant; import java.time.LocalDate; +import java.time.LocalTime; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -32,11 +33,14 @@ import com.howaboutus.backend.rooms.entity.Room; import com.howaboutus.backend.rooms.service.RoomAccessService; import com.howaboutus.backend.schedules.entity.Schedule; +import com.howaboutus.backend.schedules.entity.ScheduleItem; +import com.howaboutus.backend.schedules.repository.ScheduleItemRepository; import com.howaboutus.backend.schedules.repository.ScheduleRepository; import com.howaboutus.backend.schedules.service.dto.ScheduleBatchCreateCommand; import com.howaboutus.backend.schedules.service.dto.ScheduleCreateCommand; import com.howaboutus.backend.schedules.service.dto.ScheduleMoveCommand; import com.howaboutus.backend.schedules.service.dto.ScheduleResult; +import com.howaboutus.backend.schedules.service.dto.ScheduleWithItemsResult; @ExtendWith(MockitoExtension.class) class ScheduleServiceTest { @@ -44,6 +48,9 @@ class ScheduleServiceTest { @Mock private ScheduleRepository scheduleRepository; + @Mock + private ScheduleItemRepository scheduleItemRepository; + @Mock private ScheduleItemService scheduleItemService; @@ -69,7 +76,8 @@ class ScheduleServiceTest { @BeforeEach void setUp() { - scheduleService = new ScheduleService(roomAccessService, scheduleRepository, scheduleItemService, + scheduleService = new ScheduleService(roomAccessService, scheduleRepository, scheduleItemRepository, + scheduleItemService, eventPublisher, roomScheduleAppender, roomScheduleCompacter, roomScheduleInserter, roomScheduleMover); } @@ -127,6 +135,42 @@ void getSchedulesReturnsOrderedResults() { order.verify(scheduleRepository).findAllByRoom_IdOrderByDayNumberAsc(roomId); } + @Test + @DisplayName("일정과 항목을 함께 조회할 때 일정 항목을 한 번에 조회해 scheduleId 기준으로 묶는다") + void getSchedulesWithItemsFetchesItemsInBulk() { + UUID roomId = UUID.randomUUID(); + Room room = Room.create("도쿄 여행", "도쿄", LocalDate.of(2026, 4, 20), LocalDate.of(2026, 4, 23), "INVITE", 1L); + Schedule first = Schedule.create(room, 1); + Schedule second = Schedule.create(room, 2); + ScheduleItem firstItem = ScheduleItem.create(first, "place-1", LocalTime.of(10, 0), 60, "점심", 0); + ScheduleItem secondItem = ScheduleItem.create(first, "place-2", null, null, null, 1); + + ReflectionTestUtils.setField(room, "id", roomId); + ReflectionTestUtils.setField(first, "id", 10L); + ReflectionTestUtils.setField(second, "id", 11L); + ReflectionTestUtils.setField(firstItem, "id", 100L); + ReflectionTestUtils.setField(secondItem, "id", 101L); + + given(roomAccessService.requireExistingActiveMember(roomId, 1L)).willReturn(null); + given(scheduleRepository.findAllByRoom_IdOrderByDayNumberAsc(roomId)).willReturn(List.of(first, second)); + given(scheduleItemRepository + .findAllBySchedule_Room_IdOrderBySchedule_DayNumberAscSchedule_IdAscOrderIndexAscIdAsc(roomId)) + .willReturn(List.of(firstItem, secondItem)); + + List results = scheduleService.getSchedulesWithItems(roomId, 1L); + + assertThat(results).hasSize(2); + assertThat(results.get(0).schedule().scheduleId()).isEqualTo(10L); + assertThat(results.get(0).items()).extracting("itemId").containsExactly(100L, 101L); + assertThat(results.get(1).schedule().scheduleId()).isEqualTo(11L); + assertThat(results.get(1).items()).isEmpty(); + var order = inOrder(roomAccessService, scheduleRepository, scheduleItemRepository); + order.verify(roomAccessService).requireExistingActiveMember(roomId, 1L); + order.verify(scheduleRepository).findAllByRoom_IdOrderByDayNumberAsc(roomId); + order.verify(scheduleItemRepository) + .findAllBySchedule_Room_IdOrderBySchedule_DayNumberAscSchedule_IdAscOrderIndexAscIdAsc(roomId); + } + @Test @DisplayName("방이 없으면 일정 목록 조회 시 ROOM_NOT_FOUND 예외를 던진다") void getSchedulesThrowsWhenRoomMissing() { From 69a78556ac8c6c6d1c7a3170951ae1474034a37f Mon Sep 17 00:00:00 2001 From: PARK JU YEONG <96644508+parkjuyeong0312@users.noreply.github.com> Date: Mon, 8 Jun 2026 10:58:04 +0900 Subject: [PATCH 2/9] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=20=ED=83=88?= =?UTF-8?q?=ED=87=B4(DELETE=20/users/me)=20=EA=B5=AC=ED=98=84=20(#131)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: 회원 탈퇴 기능 설계 spec과 구현 플랜 추가 * feat: V1.7 마이그레이션으로 users 회원 탈퇴 스키마 지원 * feat: User에 soft delete와 익명화 도메인 메서드 추가 * feat: WITHDRAWAL_REQUIRES_HOST_DELEGATION 에러 코드 추가 * feat: 회원 탈퇴용 RoomMember 조회 메서드 추가 * feat: 회원 탈퇴 거절 응답 DTO와 예외 정의 * feat: RefreshTokenService에 사용자 단위 토큰 일괄 폐기 API 노출 * feat: 탈퇴 이벤트와 RTK 일괄 폐기 리스너 추가 * feat: UserWithdrawalService로 탈퇴 트랜잭션 오케스트레이션 구현 * feat: 회원 탈퇴 거절 응답 핸들러 추가 * feat: DELETE /users/me 탈퇴 엔드포인트와 만료 쿠키 처리 * fix: 1인 방 탈퇴 삭제 순서 보강 * docs: 회원 탈퇴 정책을 features/erd/decisions에 반영 * fix: 회원 탈퇴 차단 응답에 에러 정보 추가 * fix: 탈퇴 유저 토큰 재발급 차단 --- .../20260607-user-withdrawal-soft-delete.md | 30 + docs/ai/erd.md | 18 +- docs/ai/features.md | 1 + .../plans/2026-06-07-user-withdrawal.md | 1581 +++++++++++++++++ .../2026-06-07-user-withdrawal-design.md | 277 +++ .../backend/auth/service/AuthService.java | 14 + .../auth/service/RefreshTokenService.java | 4 + .../backend/common/error/ErrorCode.java | 5 + .../common/error/GlobalExceptionHandler.java | 16 + .../repository/RoomMemberRepository.java | 43 + .../user/controller/UserController.java | 56 + .../howaboutus/backend/user/entity/User.java | 37 +- .../user/event/UserWithdrawnEvent.java | 4 + .../HostDelegationRequiredException.java | 18 + .../listener/UserWithdrawnTokenListener.java | 29 + .../user/service/UserWithdrawalService.java | 91 + .../service/dto/RoomRequiringDelegation.java | 6 + .../dto/WithdrawalBlockedResponse.java | 18 + .../db/migration/V1.7__users_withdrawal.sql | 38 + .../backend/auth/service/AuthServiceTest.java | 16 + ...hTokenServiceInvalidateAllForUserTest.java | 45 + .../error/GlobalExceptionHandlerTest.java | 21 + .../RoomMemberRepositoryWithdrawalTest.java | 106 ++ .../user/UserWithdrawalIntegrationTest.java | 132 ++ .../user/controller/UserControllerTest.java | 8 + .../UserWithdrawalControllerTest.java | 95 + .../backend/user/entity/UserTest.java | 39 + .../UserWithdrawnTokenListenerTest.java | 31 + .../service/UserWithdrawalServiceTest.java | 157 ++ 29 files changed, 2923 insertions(+), 13 deletions(-) create mode 100644 docs/ai/decisions/20260607-user-withdrawal-soft-delete.md create mode 100644 docs/superpowers/plans/2026-06-07-user-withdrawal.md create mode 100644 docs/superpowers/specs/2026-06-07-user-withdrawal-design.md create mode 100644 src/main/java/com/howaboutus/backend/user/event/UserWithdrawnEvent.java create mode 100644 src/main/java/com/howaboutus/backend/user/exception/HostDelegationRequiredException.java create mode 100644 src/main/java/com/howaboutus/backend/user/listener/UserWithdrawnTokenListener.java create mode 100644 src/main/java/com/howaboutus/backend/user/service/UserWithdrawalService.java create mode 100644 src/main/java/com/howaboutus/backend/user/service/dto/RoomRequiringDelegation.java create mode 100644 src/main/java/com/howaboutus/backend/user/service/dto/WithdrawalBlockedResponse.java create mode 100644 src/main/resources/db/migration/V1.7__users_withdrawal.sql create mode 100644 src/test/java/com/howaboutus/backend/auth/service/RefreshTokenServiceInvalidateAllForUserTest.java create mode 100644 src/test/java/com/howaboutus/backend/rooms/repository/RoomMemberRepositoryWithdrawalTest.java create mode 100644 src/test/java/com/howaboutus/backend/user/UserWithdrawalIntegrationTest.java create mode 100644 src/test/java/com/howaboutus/backend/user/controller/UserWithdrawalControllerTest.java create mode 100644 src/test/java/com/howaboutus/backend/user/entity/UserTest.java create mode 100644 src/test/java/com/howaboutus/backend/user/listener/UserWithdrawnTokenListenerTest.java create mode 100644 src/test/java/com/howaboutus/backend/user/service/UserWithdrawalServiceTest.java diff --git a/docs/ai/decisions/20260607-user-withdrawal-soft-delete.md b/docs/ai/decisions/20260607-user-withdrawal-soft-delete.md new file mode 100644 index 00000000..c1f558f9 --- /dev/null +++ b/docs/ai/decisions/20260607-user-withdrawal-soft-delete.md @@ -0,0 +1,30 @@ +# users soft delete: partial unique index + CHECK 채택 + +- **상태**: 결정 +- **날짜**: 2026-06-07 +- **관련**: docs/superpowers/specs/2026-06-07-user-withdrawal-design.md + +## 배경 + +이용약관/개인정보 정책상 회원 탈퇴 기능이 필요하다. 채팅 등 공동 협업 데이터의 무결성을 유지하면서 탈퇴자의 개인정보는 즉시 제거되어야 한다. 동시에 `users.email`과 `(provider, provider_id)`에 걸린 UNIQUE 제약 때문에 단순히 컬럼을 NULL로 비울 수 없다. + +## 결정 + +`users`를 soft delete 모델로 운영한다. + +- 활성 회원: `email`, `nickname`, `provider`, `provider_id`는 NOT NULL. +- 탈퇴 회원: 위 컬럼들 NULL 허용. `deleted_at`이 NOT NULL. +- 활성 회원에 한해 unique 강제: PostgreSQL partial unique index (`WHERE deleted_at IS NULL`). +- 조건부 NOT NULL은 CHECK `users_active_required`로 보장. +- 엔티티에 `@SQLRestriction("deleted_at IS NULL")`을 적용해 JPA 조회에서 탈퇴자를 자동 제외. + +## 대안 검토 + +- **placeholder 값**(`deleted+{id}@deleted.local` 등): NOT NULL/UNIQUE 제약을 그대로 둘 수 있으나 통계/검색에 가짜 값이 섞이고, 재가입 시 충돌 회피가 복잡하다. 동일 계정 재가입 차단이 정책 요건이 아닌 본 프로젝트에서는 이점이 없다. +- **분리된 `deleted_users` 테이블**: row 이관 비용과 추가 join이 필요하다. soft delete의 단순성을 잃는다. + +## 영향 + +- DDL 변경(`V1.7__users_withdrawal.sql`): NOT NULL drop, 기존 UNIQUE 제약 제거, partial unique index 및 CHECK 추가. +- `User` 엔티티에 `deletedAt`, `anonymize()`, `isWithdrawn()` 추가. +- 동일 OAuth 계정 재가입 가능. 정책상 차단할 필요가 있을 때만 별도 결정 기록으로 변경한다. diff --git a/docs/ai/erd.md b/docs/ai/erd.md index 7252d51d..3b0ec66e 100644 --- a/docs/ai/erd.md +++ b/docs/ai/erd.md @@ -14,15 +14,23 @@ Google OAuth 기반 사용자 정보 | 컬럼 | 타입 | 제약조건 | 설명 | |------|------|----------|------| | id | BIGINT | PK, AUTO_INCREMENT | 사용자 고유 ID | -| email | VARCHAR(255) | UNIQUE, NOT NULL | 구글 이메일 | -| nickname | VARCHAR(50) | NOT NULL | 표시 이름 | +| email | VARCHAR(255) | 활성 회원 NOT NULL, 활성 회원 간 UNIQUE | 구글 이메일. 탈퇴 시 NULL | +| nickname | VARCHAR(50) | 활성 회원 NOT NULL | 표시 이름. 탈퇴 시 NULL | | profile_image_url | VARCHAR(500) | NULLABLE | 프로필 이미지 URL | -| provider | VARCHAR(20) | NOT NULL, DEFAULT 'GOOGLE' | OAuth 제공자 | -| provider_id | VARCHAR(255) | NOT NULL | OAuth 제공자 측 사용자 ID | +| provider | VARCHAR(20) | 활성 회원 NOT NULL | OAuth 제공자. 탈퇴 시 NULL | +| provider_id | VARCHAR(255) | 활성 회원 NOT NULL, 활성 회원 간 provider와 조합 UNIQUE | OAuth 제공자 측 사용자 ID. 탈퇴 시 NULL | +| deleted_at | TIMESTAMP | NULLABLE | 탈퇴 시각. NOT NULL이면 익명화된 탈퇴 회원 | | created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 가입일시 | | updated_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 수정일시 | -**제약:** UNIQUE(provider, provider_id) +**제약:** +- CHECK `users_active_required`: `deleted_at IS NOT NULL OR (email IS NOT NULL AND nickname IS NOT NULL AND provider IS NOT NULL AND provider_id IS NOT NULL)` +- partial unique index `users_email_unique_active`: `email` WHERE `deleted_at IS NULL` +- partial unique index `users_provider_provider_id_unique_active`: `(provider, provider_id)` WHERE `deleted_at IS NULL` + +**인덱스 (탈퇴자 필터):** `users_deleted_at_idx` ON `(deleted_at)` WHERE `deleted_at IS NOT NULL` + +> 활성 회원은 email/nickname/provider/provider_id가 NOT NULL이며 이메일과 (provider, provider_id) 조합이 unique. 탈퇴 회원은 모두 NULL 가능하며 unique 검사 대상에서 제외되어 동일 OAuth 계정으로 재가입할 수 있다. --- diff --git a/docs/ai/features.md b/docs/ai/features.md index a64a8dd1..f603f3ae 100644 --- a/docs/ai/features.md +++ b/docs/ai/features.md @@ -54,6 +54,7 @@ Caddy는 `caddy-ratelimit` 기반 IP 제한으로 엣지에서 넓고 거친 폭 | `[x]` | 토큰 재발급 (Refresh) | Refresh Token Rotation: UUID 기반 HTTP-only 쿠키(path=/auth/refresh), Redis `refresh:token:{uuid}`→userId(TTL 14일) / `refresh:user:{userId}`→Set\. Replay Detection 으로 탈취 시 전체 무효화 | Redis | | `[x]` | 로그아웃 | 단일 기기 로그아웃: 요청한 토큰만 삭제 | Redis | | `[x]` | 내 정보 조회 | 로그인된 사용자 프로필 조회 | users | +| `[x]` | 회원 탈퇴 | `DELETE /users/me`. users는 soft delete + 익명화(email/nickname/profile/provider/provider_id NULL, deleted_at 설정), room_members는 hard delete. HOST 방은 사전 위임 필요(422 + roomsRequiringDelegation), 1인 HOST 방은 자동 hard delete. Redis RTK는 AFTER_COMMIT에서 일괄 폐기. 채팅/북마크/일정의 BIGINT 작성자 ID는 유지하며 클라이언트는 members에 없는 ID를 "(알 수 없음)"으로 표시 | users, room_members, Redis | --- diff --git a/docs/superpowers/plans/2026-06-07-user-withdrawal.md b/docs/superpowers/plans/2026-06-07-user-withdrawal.md new file mode 100644 index 00000000..38c10578 --- /dev/null +++ b/docs/superpowers/plans/2026-06-07-user-withdrawal.md @@ -0,0 +1,1581 @@ +# 회원 탈퇴 기능 구현 플랜 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** `DELETE /users/me` API로 회원 탈퇴를 처리한다. `users`는 soft delete + 익명화, `room_members`는 hard delete, HOST 방은 사전 위임을 요구하며 1인 HOST 방은 자동 hard delete한다. 탈퇴 직후 해당 유저의 모든 Redis RTK를 즉시 폐기한다. + +**Architecture:** PostgreSQL 단일 `@Transactional` 안에서 사전 검증 → 1인 HOST 방 삭제 → 잔여 `room_members` 삭제 → 더블체크 → `users.anonymize()` 순으로 처리한다. RTK 폐기와 도메인 이벤트(MemberLeft/RoomDeleted)는 `@TransactionalEventListener(AFTER_COMMIT)`로 분리한다. partial unique index + CHECK 제약으로 활성 회원에만 NOT NULL/UNIQUE를 강제하고 탈퇴 행에는 NULL을 허용해 동일 Google 계정 재가입을 허용한다. + +**Tech Stack:** Spring Boot 4.0.5, Java 21, PostgreSQL 17 + Flyway, JPA(Hibernate `@SQLRestriction`), Redis(StringRedisTemplate), JUnit 5 + Mockito + AssertJ, Testcontainers. + +**참조 스펙:** `docs/superpowers/specs/2026-06-07-user-withdrawal-design.md` + +--- + +## File Structure + +**신규 파일** +- `src/main/resources/db/migration/V1.7__users_withdrawal.sql` — DDL 변경 +- `src/main/java/com/howaboutus/backend/user/event/UserWithdrawnEvent.java` — `record UserWithdrawnEvent(Long userId)` +- `src/main/java/com/howaboutus/backend/user/exception/HostDelegationRequiredException.java` +- `src/main/java/com/howaboutus/backend/user/service/dto/RoomRequiringDelegation.java` +- `src/main/java/com/howaboutus/backend/user/service/dto/WithdrawalBlockedResponse.java` +- `src/main/java/com/howaboutus/backend/user/service/UserWithdrawalService.java` +- `src/main/java/com/howaboutus/backend/user/listener/UserWithdrawnTokenListener.java` — RTK 폐기 리스너 +- `src/test/java/com/howaboutus/backend/user/service/UserWithdrawalServiceTest.java` +- `src/test/java/com/howaboutus/backend/user/controller/UserWithdrawalControllerTest.java` +- `src/test/java/com/howaboutus/backend/user/UserWithdrawalIntegrationTest.java` +- `src/test/java/com/howaboutus/backend/rooms/repository/RoomMemberRepositoryTest.java` (없다면 신규) + +**수정 파일** +- `src/main/java/com/howaboutus/backend/user/entity/User.java` — 컬럼 nullable, `deletedAt`, `@SQLRestriction`, `anonymize()` +- `src/main/java/com/howaboutus/backend/rooms/repository/RoomMemberRepository.java` — 위임 검증/잔여 멤버십 쿼리 +- `src/main/java/com/howaboutus/backend/common/error/ErrorCode.java` — `WITHDRAWAL_REQUIRES_HOST_DELEGATION` 추가 +- `src/main/java/com/howaboutus/backend/common/error/GlobalExceptionHandler.java` — `HostDelegationRequiredException` 매핑 +- `src/main/java/com/howaboutus/backend/auth/service/RefreshTokenService.java` — `invalidateAllForUser(Long)` 노출 +- `src/main/java/com/howaboutus/backend/user/controller/UserController.java` — `DELETE /me` 엔드포인트 +- `docs/ai/features.md`, `docs/ai/erd.md`, `docs/ai/decisions/*.md` + +--- + +## 작업 순서 원칙 + +- 각 Task 끝에 `feat:`/`refactor:`/`test:` 같은 conventional commit 한 개를 만든다. +- 커밋 직전 항상 `./gradlew checkstyleMain checkstyleTest` 통과를 확인한다(CLAUDE.md `Before Commit` 규칙). +- 커밋 메시지에 `Co-Authored-By:` 트레일러를 절대 추가하지 않는다(CONTRIBUTING.md). +- 단위 테스트는 `@ExtendWith(MockitoExtension.class) + @Mock + @InjectMocks` 또는 수동 생성자 주입 패턴(`RoomMemberServiceTest` 참고)을 사용한다. + +--- + +## Task 1: Flyway 마이그레이션 — `users` soft delete 컬럼/제약/인덱스 변경 + +**Files:** +- Create: `src/main/resources/db/migration/V1.7__users_withdrawal.sql` + +- [ ] **Step 1: 마이그레이션 파일 생성** + +`src/main/resources/db/migration/V1.7__users_withdrawal.sql`: + +```sql +-- =========================================== +-- V1.7: users 회원 탈퇴 지원 (soft delete + 익명화) +-- =========================================== + +-- 1) 탈퇴 시각 컬럼 +ALTER TABLE users ADD COLUMN deleted_at TIMESTAMP WITH TIME ZONE; + +-- 2) 활성 회원 NOT NULL 완화 (탈퇴 시 NULL 허용) +ALTER TABLE users ALTER COLUMN email DROP NOT NULL; +ALTER TABLE users ALTER COLUMN nickname DROP NOT NULL; +ALTER TABLE users ALTER COLUMN provider DROP NOT NULL; +ALTER TABLE users ALTER COLUMN provider_id DROP NOT NULL; + +-- 3) 활성 회원에 한해 NOT NULL 강제 (CHECK) +ALTER TABLE users ADD CONSTRAINT users_active_required CHECK ( + deleted_at IS NOT NULL + OR ( + email IS NOT NULL + AND nickname IS NOT NULL + AND provider IS NOT NULL + AND provider_id IS NOT NULL + ) +); + +-- 4) 기존 unique 제약 제거 +ALTER TABLE users DROP CONSTRAINT users_email_key; +ALTER TABLE users DROP CONSTRAINT uq_users_provider_provider_id; + +-- 5) 활성 회원만 unique 적용 (partial unique index) +CREATE UNIQUE INDEX users_email_unique_active + ON users (email) WHERE deleted_at IS NULL; + +CREATE UNIQUE INDEX users_provider_provider_id_unique_active + ON users (provider, provider_id) WHERE deleted_at IS NULL; + +-- 6) 탈퇴자 필터/조회 인덱스 +CREATE INDEX users_deleted_at_idx ON users (deleted_at) + WHERE deleted_at IS NOT NULL; +``` + +- [ ] **Step 2: 마이그레이션 적용 확인용 빌드** + +Run: `./gradlew compileJava -q` +Expected: SUCCESS (Java 변경 없음) + +- [ ] **Step 3: 컨테이너 기동 후 마이그레이션 검증(선택)** + +Run: `./gradlew test --tests com.howaboutus.backend.HowAboutUsBackendApplicationTests` (컨텍스트 로드만) +Expected: 컨텍스트 로딩 성공. Flyway가 V1.7을 적용. 실패 시 SQL 오류 메시지로 디버깅. + +- [ ] **Step 4: Checkstyle** + +Run: `./gradlew checkstyleMain checkstyleTest` +Expected: 0 warnings. + +- [ ] **Step 5: 커밋** + +```bash +git add src/main/resources/db/migration/V1.7__users_withdrawal.sql +git commit -m "feat: V1.7 마이그레이션으로 users 회원 탈퇴 스키마 지원" +``` + +--- + +## Task 2: `User` 엔티티 — `deletedAt`, nullable, `@SQLRestriction`, `anonymize()` + +**Files:** +- Modify: `src/main/java/com/howaboutus/backend/user/entity/User.java` +- Test: `src/test/java/com/howaboutus/backend/user/entity/UserTest.java` (없으면 신규) + +- [ ] **Step 1: 단위 테스트 작성 (`UserTest`)** + +`src/test/java/com/howaboutus/backend/user/entity/UserTest.java`: + +```java +package com.howaboutus.backend.user.entity; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class UserTest { + + @Test + @DisplayName("anonymize는 개인정보 필드를 null로 비우고 deletedAt을 설정한다") + void anonymizeClearsPersonalFieldsAndSetsDeletedAt() { + User user = User.ofGoogle("pid", "a@a.com", "닉", "https://img/a.png"); + + assertThat(user.isWithdrawn()).isFalse(); + + user.anonymize(); + + assertThat(user.getEmail()).isNull(); + assertThat(user.getNickname()).isNull(); + assertThat(user.getProfileImageUrl()).isNull(); + assertThat(user.getProvider()).isNull(); + assertThat(user.getProviderId()).isNull(); + assertThat(user.getDeletedAt()).isNotNull(); + assertThat(user.isWithdrawn()).isTrue(); + } + + @Test + @DisplayName("이미 탈퇴된 user는 anonymize를 멱등하게 수용한다") + void anonymizeIsIdempotent() { + User user = User.ofGoogle("pid", "a@a.com", "닉", null); + user.anonymize(); + var firstDeletedAt = user.getDeletedAt(); + + user.anonymize(); + + assertThat(user.getDeletedAt()).isEqualTo(firstDeletedAt); + } +} +``` + +- [ ] **Step 2: 테스트 실행으로 실패 확인** + +Run: `./gradlew test --tests com.howaboutus.backend.user.entity.UserTest` +Expected: FAIL — `anonymize`, `isWithdrawn`, `getDeletedAt` 메서드 없음. + +- [ ] **Step 3: 엔티티 수정** + +`src/main/java/com/howaboutus/backend/user/entity/User.java` 전체를 다음으로 교체: + +```java +package com.howaboutus.backend.user.entity; + +import java.time.Instant; + +import org.hibernate.annotations.SQLRestriction; + +import com.howaboutus.backend.common.entity.BaseTimeEntity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "users") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLRestriction("deleted_at IS NULL") +public class User extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column + private String email; + + @Column(length = 50) + private String nickname; + + @Column(length = 500) + private String profileImageUrl; + + @Column(length = 20) + private String provider; + + @Column + private String providerId; + + @Column(name = "deleted_at") + private Instant deletedAt; + + private User(String providerId, String email, String nickname, String profileImageUrl, String provider) { + this.providerId = providerId; + this.email = email; + this.nickname = nickname; + this.profileImageUrl = profileImageUrl; + this.provider = provider; + } + + public static User ofGoogle(String providerId, String email, String nickname, String profileImageUrl) { + return new User(providerId, email, nickname, profileImageUrl, "GOOGLE"); + } + + public boolean isWithdrawn() { + return this.deletedAt != null; + } + + public void anonymize() { + if (isWithdrawn()) { + return; + } + this.email = null; + this.nickname = null; + this.profileImageUrl = null; + this.provider = null; + this.providerId = null; + this.deletedAt = Instant.now(); + } +} +``` + +`@Table`의 기존 `uniqueConstraints`는 partial unique index로 옮겼으므로 엔티티 어노테이션에서 제거한다. + +- [ ] **Step 4: 단위 테스트 실행으로 통과 확인** + +Run: `./gradlew test --tests com.howaboutus.backend.user.entity.UserTest` +Expected: PASS (2 tests). + +- [ ] **Step 5: 기존 user 단위 테스트 회귀 확인** + +Run: `./gradlew test --tests com.howaboutus.backend.user.*` +Expected: 기존 `UserServiceTest`, `UserControllerTest`도 PASS. 실패하면 nullable 변경/@SQLRestriction에 의한 영향을 점검(Mock 기반이라 영향 없을 가능성 높음). + +- [ ] **Step 6: Checkstyle + 커밋** + +```bash +./gradlew checkstyleMain checkstyleTest +git add src/main/java/com/howaboutus/backend/user/entity/User.java \ + src/test/java/com/howaboutus/backend/user/entity/UserTest.java +git commit -m "feat: User에 soft delete와 익명화 도메인 메서드 추가" +``` + +--- + +## Task 3: ErrorCode 추가 — `WITHDRAWAL_REQUIRES_HOST_DELEGATION` + +**Files:** +- Modify: `src/main/java/com/howaboutus/backend/common/error/ErrorCode.java` + +- [ ] **Step 1: 422 섹션 추가 (해당 enum 끝부분에 새 섹션)** + +`ErrorCode.java`에서 마지막 enum 항목인 `EXTERNAL_API_ERROR(...)` 바로 위에 다음 섹션을 삽입: + +```java + // 422 UNPROCESSABLE ENTITY + WITHDRAWAL_REQUIRES_HOST_DELEGATION( + HttpStatus.UNPROCESSABLE_ENTITY, + "방장 위임이 필요한 방이 있습니다"), +``` + +세미콜론 위치를 헷갈리지 않도록, `EXTERNAL_API_ERROR(HttpStatus.BAD_GATEWAY, ...)` 뒤의 `;`는 그대로 두고 그 앞 콤마 처리만 신경 쓴다. + +- [ ] **Step 2: 컴파일 확인** + +Run: `./gradlew compileJava -q` +Expected: SUCCESS. + +- [ ] **Step 3: Checkstyle + 커밋** + +```bash +./gradlew checkstyleMain +git add src/main/java/com/howaboutus/backend/common/error/ErrorCode.java +git commit -m "feat: WITHDRAWAL_REQUIRES_HOST_DELEGATION 에러 코드 추가" +``` + +--- + +## Task 4: `RoomMemberRepository` 쿼리 메서드 추가 + +**Files:** +- Modify: `src/main/java/com/howaboutus/backend/rooms/repository/RoomMemberRepository.java` +- Test: `src/test/java/com/howaboutus/backend/rooms/repository/RoomMemberRepositoryWithdrawalTest.java` (신규) + +- [ ] **Step 1: 신규 메서드의 단위 통합 테스트 작성** + +`src/test/java/com/howaboutus/backend/rooms/repository/RoomMemberRepositoryWithdrawalTest.java`: + +```java +package com.howaboutus.backend.rooms.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.UUID; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import com.howaboutus.backend.rooms.entity.Room; +import com.howaboutus.backend.rooms.entity.RoomMember; +import com.howaboutus.backend.rooms.entity.RoomRole; +import com.howaboutus.backend.rooms.repository.RoomMemberRepository.RoomRequiringDelegationView; +import com.howaboutus.backend.support.BaseIntegrationTest; +import com.howaboutus.backend.user.entity.User; +import com.howaboutus.backend.user.repository.UserRepository; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; + +class RoomMemberRepositoryWithdrawalTest extends BaseIntegrationTest { + + @Autowired + private RoomMemberRepository roomMemberRepository; + @Autowired + private UserRepository userRepository; + @Autowired + private com.howaboutus.backend.rooms.repository.RoomRepository roomRepository; + + @PersistenceContext + private EntityManager em; + + @Test + @DisplayName("HOST이면서 다른 활성 멤버가 있는 방만 위임 필요 목록에 포함된다") + void findHostRoomsWithOtherActiveMembers() { + User host = userRepository.save(User.ofGoogle("g1", "h@a.com", "h", null)); + User other = userRepository.save(User.ofGoogle("g2", "o@a.com", "o", null)); + + Room hostOnly = roomRepository.save(Room.create("A", null, + java.time.LocalDate.now(), java.time.LocalDate.now(), "inv-a", host.getId())); + Room hostWithOther = roomRepository.save(Room.create("B", null, + java.time.LocalDate.now(), java.time.LocalDate.now(), "inv-b", host.getId())); + Room memberRoom = roomRepository.save(Room.create("C", null, + java.time.LocalDate.now(), java.time.LocalDate.now(), "inv-c", other.getId())); + + roomMemberRepository.save(RoomMember.create(hostOnly, host, RoomRole.HOST)); + roomMemberRepository.save(RoomMember.create(hostWithOther, host, RoomRole.HOST)); + roomMemberRepository.save(RoomMember.create(hostWithOther, other, RoomRole.MEMBER)); + roomMemberRepository.save(RoomMember.create(memberRoom, other, RoomRole.HOST)); + roomMemberRepository.save(RoomMember.create(memberRoom, host, RoomRole.MEMBER)); + em.flush(); + em.clear(); + + List result = + roomMemberRepository.findHostRoomsWithOtherActiveMembers(host.getId()); + + assertThat(result).extracting(RoomRequiringDelegationView::getRoomId) + .containsExactly(hostWithOther.getId()); + } + + @Test + @DisplayName("HOST이면서 다른 활성 멤버가 없는 방만 1인 방 목록에 포함된다 (PENDING만 있어도 포함)") + void findHostRoomsWithOnlySelf() { + User host = userRepository.save(User.ofGoogle("g1", "h@a.com", "h", null)); + User pendingUser = userRepository.save(User.ofGoogle("g2", "p@a.com", "p", null)); + + Room soloHost = roomRepository.save(Room.create("A", null, + java.time.LocalDate.now(), java.time.LocalDate.now(), "inv-a", host.getId())); + Room hostWithPending = roomRepository.save(Room.create("B", null, + java.time.LocalDate.now(), java.time.LocalDate.now(), "inv-b", host.getId())); + + roomMemberRepository.save(RoomMember.create(soloHost, host, RoomRole.HOST)); + roomMemberRepository.save(RoomMember.create(hostWithPending, host, RoomRole.HOST)); + roomMemberRepository.save(RoomMember.create(hostWithPending, pendingUser, RoomRole.PENDING)); + em.flush(); + em.clear(); + + List result = roomMemberRepository.findHostRoomsWithOnlySelf(host.getId()) + .stream().map(Room::getId).toList(); + + assertThat(result).containsExactlyInAnyOrder(soloHost.getId(), hostWithPending.getId()); + } + + @Test + @DisplayName("findAllByUser_Id는 역할 무관하게 사용자의 모든 room_members를 반환한다") + void findAllByUserId() { + User u = userRepository.save(User.ofGoogle("g1", "h@a.com", "h", null)); + Room r1 = roomRepository.save(Room.create("A", null, + java.time.LocalDate.now(), java.time.LocalDate.now(), "i-a", u.getId())); + Room r2 = roomRepository.save(Room.create("B", null, + java.time.LocalDate.now(), java.time.LocalDate.now(), "i-b", u.getId())); + roomMemberRepository.save(RoomMember.create(r1, u, RoomRole.HOST)); + roomMemberRepository.save(RoomMember.create(r2, u, RoomRole.PENDING)); + em.flush(); + em.clear(); + + List result = roomMemberRepository.findAllByUser_Id(u.getId()); + assertThat(result).hasSize(2); + } +} +``` + +- [ ] **Step 2: 테스트 실행으로 컴파일 실패 확인** + +Run: `./gradlew test --tests com.howaboutus.backend.rooms.repository.RoomMemberRepositoryWithdrawalTest` +Expected: COMPILE ERROR — 메서드 없음. + +- [ ] **Step 3: Repository 쿼리 추가** + +`src/main/java/com/howaboutus/backend/rooms/repository/RoomMemberRepository.java`에 import와 메서드 추가: + +```java +import java.util.UUID; + +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import com.howaboutus.backend.rooms.entity.Room; + +// ... 기존 메서드들 아래에 ... + +List findAllByUser_Id(Long userId); + +@Query(""" + select m.room as room + from RoomMember m + where m.user.id = :userId + and m.role = com.howaboutus.backend.rooms.entity.RoomRole.HOST + and not exists ( + select 1 from RoomMember other + where other.room.id = m.room.id + and other.user.id <> :userId + and other.role in ( + com.howaboutus.backend.rooms.entity.RoomRole.HOST, + com.howaboutus.backend.rooms.entity.RoomRole.MEMBER) + ) + """) +List findHostRoomsWithOnlySelf(@Param("userId") Long userId); + +interface RoomRequiringDelegationView { + UUID getRoomId(); + String getTitle(); +} + +@Query(""" + select m.room.id as roomId, m.room.title as title + from RoomMember m + where m.user.id = :userId + and m.role = com.howaboutus.backend.rooms.entity.RoomRole.HOST + and exists ( + select 1 from RoomMember other + where other.room.id = m.room.id + and other.user.id <> :userId + and other.role in ( + com.howaboutus.backend.rooms.entity.RoomRole.HOST, + com.howaboutus.backend.rooms.entity.RoomRole.MEMBER) + ) + """) +List findHostRoomsWithOtherActiveMembers(@Param("userId") Long userId); +``` + +- [ ] **Step 4: 테스트 실행으로 통과 확인** + +Run: `./gradlew test --tests com.howaboutus.backend.rooms.repository.RoomMemberRepositoryWithdrawalTest` +Expected: PASS (3 tests). + +- [ ] **Step 5: Checkstyle + 커밋** + +```bash +./gradlew checkstyleMain checkstyleTest +git add src/main/java/com/howaboutus/backend/rooms/repository/RoomMemberRepository.java \ + src/test/java/com/howaboutus/backend/rooms/repository/RoomMemberRepositoryWithdrawalTest.java +git commit -m "feat: 회원 탈퇴용 RoomMember 조회 메서드 추가" +``` + +--- + +## Task 5: 예외/DTO 정의 (`HostDelegationRequiredException`, `RoomRequiringDelegation`, `WithdrawalBlockedResponse`) + +**Files:** +- Create: 4개 신규 파일 + +- [ ] **Step 1: RoomRequiringDelegation DTO** + +`src/main/java/com/howaboutus/backend/user/service/dto/RoomRequiringDelegation.java`: + +```java +package com.howaboutus.backend.user.service.dto; + +import java.util.UUID; + +public record RoomRequiringDelegation(UUID roomId, String title) { +} +``` + +- [ ] **Step 2: WithdrawalBlockedResponse DTO** + +`src/main/java/com/howaboutus/backend/user/service/dto/WithdrawalBlockedResponse.java`: + +```java +package com.howaboutus.backend.user.service.dto; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "회원 탈퇴 거절 응답 - 방장 위임이 필요한 방 목록") +public record WithdrawalBlockedResponse( + @Schema(description = "방장 위임이 필요한 방 목록") + List roomsRequiringDelegation +) { +} +``` + +- [ ] **Step 3: HostDelegationRequiredException** + +`src/main/java/com/howaboutus/backend/user/exception/HostDelegationRequiredException.java`: + +```java +package com.howaboutus.backend.user.exception; + +import java.util.List; + +import com.howaboutus.backend.user.service.dto.RoomRequiringDelegation; + +import lombok.Getter; + +@Getter +public class HostDelegationRequiredException extends RuntimeException { + + private final List roomsRequiringDelegation; + + public HostDelegationRequiredException(List roomsRequiringDelegation) { + super("Host delegation required for " + roomsRequiringDelegation.size() + " room(s)."); + this.roomsRequiringDelegation = List.copyOf(roomsRequiringDelegation); + } +} +``` + +- [ ] **Step 4: 컴파일 확인 + Checkstyle** + +Run: `./gradlew compileJava checkstyleMain` +Expected: SUCCESS. + +- [ ] **Step 5: 커밋** + +```bash +git add src/main/java/com/howaboutus/backend/user/service/dto \ + src/main/java/com/howaboutus/backend/user/exception +git commit -m "feat: 회원 탈퇴 거절 응답 DTO와 예외 정의" +``` + +--- + +## Task 6: `RefreshTokenService.invalidateAllForUser(Long)` 노출 + +**Files:** +- Modify: `src/main/java/com/howaboutus/backend/auth/service/RefreshTokenService.java` +- Test: `src/test/java/com/howaboutus/backend/auth/service/RefreshTokenServiceInvalidateAllForUserTest.java` (신규) + +- [ ] **Step 1: 통합 테스트 작성 (Testcontainers 기반)** + +`src/test/java/com/howaboutus/backend/auth/service/RefreshTokenServiceInvalidateAllForUserTest.java`: + +```java +package com.howaboutus.backend.auth.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import com.howaboutus.backend.common.error.CustomException; +import com.howaboutus.backend.common.error.ErrorCode; +import com.howaboutus.backend.support.BaseIntegrationTest; + +class RefreshTokenServiceInvalidateAllForUserTest extends BaseIntegrationTest { + + @Autowired + private RefreshTokenService refreshTokenService; + + @Test + @DisplayName("invalidateAllForUser는 해당 유저의 모든 RTK를 삭제한다") + void invalidatesAllTokensForUser() { + long userId = 9001L; + String t1 = refreshTokenService.create(userId); + String t2 = refreshTokenService.create(userId); + + refreshTokenService.invalidateAllForUser(userId); + + assertThatThrownBy(() -> refreshTokenService.rotate(t1)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.REFRESH_TOKEN_NOT_FOUND); + assertThatThrownBy(() -> refreshTokenService.rotate(t2)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.REFRESH_TOKEN_NOT_FOUND); + } + + @Test + @DisplayName("대상 유저가 RTK를 갖고 있지 않으면 안전하게 no-op이다") + void noOpWhenNoTokens() { + long userId = 9002L; + + refreshTokenService.invalidateAllForUser(userId); + + assertThat(true).isTrue(); + } +} +``` + +- [ ] **Step 2: 실행으로 실패 확인** + +Run: `./gradlew test --tests com.howaboutus.backend.auth.service.RefreshTokenServiceInvalidateAllForUserTest` +Expected: COMPILE ERROR — `invalidateAllForUser` 없음. + +- [ ] **Step 3: 서비스에 public 메서드 추가** + +`RefreshTokenService.java`의 private 헬퍼 `invalidateAllTokens(String userId)` 위에 public wrapper를 추가하고 기존 private은 그대로 둔다: + +```java +public void invalidateAllForUser(Long userId) { + invalidateAllTokens(String.valueOf(userId)); +} +``` + +- [ ] **Step 4: 테스트 통과 확인** + +Run: `./gradlew test --tests com.howaboutus.backend.auth.service.RefreshTokenServiceInvalidateAllForUserTest` +Expected: PASS (2 tests). + +- [ ] **Step 5: Checkstyle + 커밋** + +```bash +./gradlew checkstyleMain checkstyleTest +git add src/main/java/com/howaboutus/backend/auth/service/RefreshTokenService.java \ + src/test/java/com/howaboutus/backend/auth/service/RefreshTokenServiceInvalidateAllForUserTest.java +git commit -m "feat: RefreshTokenService에 사용자 단위 토큰 일괄 폐기 API 노출" +``` + +--- + +## Task 7: `UserWithdrawnEvent`와 RTK 폐기 리스너 + +**Files:** +- Create: `src/main/java/com/howaboutus/backend/user/event/UserWithdrawnEvent.java` +- Create: `src/main/java/com/howaboutus/backend/user/listener/UserWithdrawnTokenListener.java` +- Test: `src/test/java/com/howaboutus/backend/user/listener/UserWithdrawnTokenListenerTest.java` + +- [ ] **Step 1: 이벤트 정의** + +`src/main/java/com/howaboutus/backend/user/event/UserWithdrawnEvent.java`: + +```java +package com.howaboutus.backend.user.event; + +public record UserWithdrawnEvent(Long userId) { +} +``` + +- [ ] **Step 2: 리스너 단위 테스트 작성** + +`src/test/java/com/howaboutus/backend/user/listener/UserWithdrawnTokenListenerTest.java`: + +```java +package com.howaboutus.backend.user.listener; + +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.verify; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.howaboutus.backend.auth.service.RefreshTokenService; +import com.howaboutus.backend.user.event.UserWithdrawnEvent; + +@ExtendWith(MockitoExtension.class) +class UserWithdrawnTokenListenerTest { + + @Mock + private RefreshTokenService refreshTokenService; + + @InjectMocks + private UserWithdrawnTokenListener listener; + + @Test + @DisplayName("UserWithdrawnEvent 수신 시 해당 userId의 RTK를 일괄 폐기한다") + void invalidatesTokensOnEvent() { + listener.handle(new UserWithdrawnEvent(42L)); + + verify(refreshTokenService).invalidateAllForUser(42L); + } +} +``` + +- [ ] **Step 3: 실행으로 실패 확인** + +Run: `./gradlew test --tests com.howaboutus.backend.user.listener.UserWithdrawnTokenListenerTest` +Expected: COMPILE ERROR — 리스너 없음. + +- [ ] **Step 4: 리스너 구현** + +`src/main/java/com/howaboutus/backend/user/listener/UserWithdrawnTokenListener.java`: + +```java +package com.howaboutus.backend.user.listener; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import com.howaboutus.backend.auth.service.RefreshTokenService; +import com.howaboutus.backend.user.event.UserWithdrawnEvent; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class UserWithdrawnTokenListener { + + private final RefreshTokenService refreshTokenService; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) + public void handle(UserWithdrawnEvent event) { + try { + refreshTokenService.invalidateAllForUser(event.userId()); + } catch (Exception e) { + log.warn("Failed to invalidate refresh tokens for withdrawn user. userId={}", + event.userId(), e); + } + } +} +``` + +- [ ] **Step 5: 테스트 통과 확인 + Checkstyle** + +Run: `./gradlew test --tests com.howaboutus.backend.user.listener.UserWithdrawnTokenListenerTest && ./gradlew checkstyleMain checkstyleTest` +Expected: PASS, 0 warnings. + +- [ ] **Step 6: 커밋** + +```bash +git add src/main/java/com/howaboutus/backend/user/event \ + src/main/java/com/howaboutus/backend/user/listener \ + src/test/java/com/howaboutus/backend/user/listener +git commit -m "feat: 탈퇴 이벤트와 RTK 일괄 폐기 리스너 추가" +``` + +--- + +## Task 8: `UserWithdrawalService` — 핵심 로직 TDD + +**Files:** +- Create: `src/main/java/com/howaboutus/backend/user/service/UserWithdrawalService.java` +- Test: `src/test/java/com/howaboutus/backend/user/service/UserWithdrawalServiceTest.java` + +> 주의: `RoomMemberRepository.RoomRequiringDelegationView`는 인터페이스 projection이라 Mockito로 mock하기 번거롭다. 단위 테스트에서는 익명 구현체를 만들어 반환값을 구성한다. + +- [ ] **Step 1: 단위 테스트 작성** + +`src/test/java/com/howaboutus/backend/user/service/UserWithdrawalServiceTest.java`: + +```java +package com.howaboutus.backend.user.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.test.util.ReflectionTestUtils; + +import com.howaboutus.backend.common.error.CustomException; +import com.howaboutus.backend.common.error.ErrorCode; +import com.howaboutus.backend.realtime.event.MemberLeftEvent; +import com.howaboutus.backend.realtime.event.RoomDeletedEvent; +import com.howaboutus.backend.rooms.entity.Room; +import com.howaboutus.backend.rooms.entity.RoomMember; +import com.howaboutus.backend.rooms.entity.RoomRole; +import com.howaboutus.backend.rooms.repository.RoomMemberRepository; +import com.howaboutus.backend.rooms.repository.RoomMemberRepository.RoomRequiringDelegationView; +import com.howaboutus.backend.rooms.repository.RoomRepository; +import com.howaboutus.backend.user.entity.User; +import com.howaboutus.backend.user.event.UserWithdrawnEvent; +import com.howaboutus.backend.user.exception.HostDelegationRequiredException; +import com.howaboutus.backend.user.repository.UserRepository; + +@ExtendWith(MockitoExtension.class) +class UserWithdrawalServiceTest { + + private static final Long USER_ID = 100L; + + @Mock + private UserRepository userRepository; + @Mock + private RoomRepository roomRepository; + @Mock + private RoomMemberRepository roomMemberRepository; + @Mock + private ApplicationEventPublisher eventPublisher; + + private UserWithdrawalService service; + private User user; + + @BeforeEach + void setUp() { + service = new UserWithdrawalService( + userRepository, roomRepository, roomMemberRepository, eventPublisher); + user = User.ofGoogle("g", "u@a.com", "닉", "https://img/me.png"); + ReflectionTestUtils.setField(user, "id", USER_ID); + given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); + } + + @Test + @DisplayName("위임 필요 방이 있으면 HostDelegationRequiredException으로 차단되고 mutation이 없다") + void blocksWithoutMutationsWhenDelegationRequired() { + UUID roomId = UUID.randomUUID(); + given(roomMemberRepository.findHostRoomsWithOtherActiveMembers(USER_ID)) + .willReturn(List.of(view(roomId, "Trip"))); + + assertThatThrownBy(() -> service.withdraw(USER_ID)) + .isInstanceOf(HostDelegationRequiredException.class) + .satisfies(ex -> { + HostDelegationRequiredException e = (HostDelegationRequiredException) ex; + assertThat(e.getRoomsRequiringDelegation()).hasSize(1); + assertThat(e.getRoomsRequiringDelegation().get(0).roomId()).isEqualTo(roomId); + }); + + verify(roomRepository, never()).delete(any()); + verify(roomMemberRepository, never()).delete(any()); + assertThat(user.isWithdrawn()).isFalse(); + } + + @Test + @DisplayName("정상 탈퇴는 1인 방 삭제, 잔여 멤버십 삭제, 익명화, 이벤트 발행을 수행한다") + void successfulWithdrawal() { + Room solo = Room.create("Solo", null, null, null, "i-solo", USER_ID); + ReflectionTestUtils.setField(solo, "id", UUID.randomUUID()); + + Room memberRoom = Room.create("MemberRoom", null, null, null, "i-mr", 999L); + ReflectionTestUtils.setField(memberRoom, "id", UUID.randomUUID()); + + RoomMember memberOfRoom = RoomMember.create(memberRoom, user, RoomRole.MEMBER); + RoomMember pendingMembership = RoomMember.create(memberRoom, user, RoomRole.PENDING); + + given(roomMemberRepository.findHostRoomsWithOtherActiveMembers(USER_ID)) + .willReturn(List.of()); + given(roomMemberRepository.findHostRoomsWithOnlySelf(USER_ID)) + .willReturn(List.of(solo)); + given(roomMemberRepository.findAllByUser_Id(USER_ID)) + .willReturn(List.of(memberOfRoom, pendingMembership)); + + service.withdraw(USER_ID); + + verify(roomRepository).delete(solo); + verify(eventPublisher).publishEvent(any(RoomDeletedEvent.class)); + verify(roomMemberRepository, times(2)).delete(any(RoomMember.class)); + verify(eventPublisher).publishEvent(any(MemberLeftEvent.class)); // MEMBER 1건만 + verify(eventPublisher).publishEvent(any(UserWithdrawnEvent.class)); + assertThat(user.isWithdrawn()).isTrue(); + assertThat(user.getEmail()).isNull(); + } + + @Test + @DisplayName("최종 더블체크에서 위임 필요 방이 검출되면 예외로 롤백 시그널을 보낸다") + void doubleCheckRollback() { + UUID lateRoomId = UUID.randomUUID(); + given(roomMemberRepository.findHostRoomsWithOtherActiveMembers(USER_ID)) + .willReturn(List.of()) + .willReturn(List.of(view(lateRoomId, "Late"))); + given(roomMemberRepository.findHostRoomsWithOnlySelf(USER_ID)).willReturn(List.of()); + given(roomMemberRepository.findAllByUser_Id(USER_ID)).willReturn(List.of()); + + assertThatThrownBy(() -> service.withdraw(USER_ID)) + .isInstanceOf(HostDelegationRequiredException.class); + + assertThat(user.isWithdrawn()).isFalse(); + } + + @Test + @DisplayName("user를 찾을 수 없으면 USER_NOT_FOUND") + void userNotFound() { + given(userRepository.findById(USER_ID)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> service.withdraw(USER_ID)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.USER_NOT_FOUND); + } + + private RoomRequiringDelegationView view(UUID roomId, String title) { + return new RoomRequiringDelegationView() { + @Override + public UUID getRoomId() { + return roomId; + } + + @Override + public String getTitle() { + return title; + } + }; + } +} +``` + +- [ ] **Step 2: 실행으로 실패 확인** + +Run: `./gradlew test --tests com.howaboutus.backend.user.service.UserWithdrawalServiceTest` +Expected: COMPILE ERROR. + +- [ ] **Step 3: 서비스 구현** + +`src/main/java/com/howaboutus/backend/user/service/UserWithdrawalService.java`: + +```java +package com.howaboutus.backend.user.service; + +import java.util.List; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.howaboutus.backend.common.error.CustomException; +import com.howaboutus.backend.common.error.ErrorCode; +import com.howaboutus.backend.common.logging.Loggable; +import com.howaboutus.backend.realtime.event.MemberLeftEvent; +import com.howaboutus.backend.realtime.event.RoomDeletedEvent; +import com.howaboutus.backend.rooms.entity.Room; +import com.howaboutus.backend.rooms.entity.RoomMember; +import com.howaboutus.backend.rooms.entity.RoomRole; +import com.howaboutus.backend.rooms.repository.RoomMemberRepository; +import com.howaboutus.backend.rooms.repository.RoomMemberRepository.RoomRequiringDelegationView; +import com.howaboutus.backend.rooms.repository.RoomRepository; +import com.howaboutus.backend.user.entity.User; +import com.howaboutus.backend.user.event.UserWithdrawnEvent; +import com.howaboutus.backend.user.exception.HostDelegationRequiredException; +import com.howaboutus.backend.user.repository.UserRepository; +import com.howaboutus.backend.user.service.dto.RoomRequiringDelegation; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserWithdrawalService { + + private final UserRepository userRepository; + private final RoomRepository roomRepository; + private final RoomMemberRepository roomMemberRepository; + private final ApplicationEventPublisher eventPublisher; + + @Loggable + @Transactional + public void withdraw(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + ensureNoHostDelegationRequired(userId); + + deleteSoloHostRooms(userId); + deleteRemainingMemberships(userId, user); + + ensureNoHostDelegationRequired(userId); // 더블체크: 사전 검증 이후 윈도우 보호 + + user.anonymize(); + eventPublisher.publishEvent(new UserWithdrawnEvent(userId)); + } + + private void ensureNoHostDelegationRequired(Long userId) { + List blocking = + roomMemberRepository.findHostRoomsWithOtherActiveMembers(userId); + if (!blocking.isEmpty()) { + List mapped = blocking.stream() + .map(v -> new RoomRequiringDelegation(v.getRoomId(), v.getTitle())) + .toList(); + throw new HostDelegationRequiredException(mapped); + } + } + + private void deleteSoloHostRooms(Long userId) { + List soloRooms = roomMemberRepository.findHostRoomsWithOnlySelf(userId); + for (Room room : soloRooms) { + roomRepository.delete(room); + eventPublisher.publishEvent(new RoomDeletedEvent(room.getId(), List.of(userId))); + } + } + + private void deleteRemainingMemberships(Long userId, User user) { + List memberships = roomMemberRepository.findAllByUser_Id(userId); + for (RoomMember m : memberships) { + roomMemberRepository.delete(m); + if (m.getRole() == RoomRole.MEMBER) { + eventPublisher.publishEvent(new MemberLeftEvent( + m.getRoom().getId(), userId, + user.getNickname(), user.getProfileImageUrl())); + } + } + } +} +``` + +- [ ] **Step 4: 테스트 통과 확인** + +Run: `./gradlew test --tests com.howaboutus.backend.user.service.UserWithdrawalServiceTest` +Expected: PASS (4 tests). + +- [ ] **Step 5: Checkstyle + 커밋** + +```bash +./gradlew checkstyleMain checkstyleTest +git add src/main/java/com/howaboutus/backend/user/service/UserWithdrawalService.java \ + src/test/java/com/howaboutus/backend/user/service/UserWithdrawalServiceTest.java +git commit -m "feat: UserWithdrawalService로 탈퇴 트랜잭션 오케스트레이션 구현" +``` + +--- + +## Task 9: `GlobalExceptionHandler`에 `HostDelegationRequiredException` 매핑 추가 + +**Files:** +- Modify: `src/main/java/com/howaboutus/backend/common/error/GlobalExceptionHandler.java` + +> 이 예외는 본문 구조가 `ApiErrorResponse`와 다르기 때문에 별도 핸들러로 처리한다. + +- [ ] **Step 1: 핸들러 추가** + +`GlobalExceptionHandler.java` 상단에 import 추가: + +```java +import com.howaboutus.backend.common.error.ErrorCode; +import com.howaboutus.backend.user.exception.HostDelegationRequiredException; +import com.howaboutus.backend.user.service.dto.WithdrawalBlockedResponse; +``` + +`handleOptimisticLockingFailure` 위에 핸들러 추가: + +```java +@ExceptionHandler(HostDelegationRequiredException.class) +public ResponseEntity handleHostDelegationRequired( + HostDelegationRequiredException exception) { + log.warn("[EXCEPTION] {} | {}", + kv("errorCode", ErrorCode.WITHDRAWAL_REQUIRES_HOST_DELEGATION), + kv("blockedRoomCount", exception.getRoomsRequiringDelegation().size())); + return ResponseEntity.status(ErrorCode.WITHDRAWAL_REQUIRES_HOST_DELEGATION.getStatus()) + .body(new WithdrawalBlockedResponse(exception.getRoomsRequiringDelegation())); +} +``` + +- [ ] **Step 2: 컴파일 + Checkstyle** + +Run: `./gradlew compileJava checkstyleMain` +Expected: SUCCESS. + +- [ ] **Step 3: 커밋** + +```bash +git add src/main/java/com/howaboutus/backend/common/error/GlobalExceptionHandler.java +git commit -m "feat: 회원 탈퇴 거절 응답 핸들러 추가" +``` + +--- + +## Task 10: 컨트롤러 `DELETE /users/me` + 만료 쿠키 + +**Files:** +- Modify: `src/main/java/com/howaboutus/backend/user/controller/UserController.java` +- Test: `src/test/java/com/howaboutus/backend/user/controller/UserWithdrawalControllerTest.java` (신규) + +> 만료 쿠키 구성은 `AuthController.logout`과 동일 패턴(`activeProfile`, `cookieProperties`)을 따른다. `UserController`에 `JwtProperties`/`CookieProperties` 의존을 직접 주입해도 되지만, 더 단순하게 `AuthService`에 분리하지 말고 컨트롤러 안에서 처리한다. + +- [ ] **Step 1: WebMvc 테스트 작성** + +`src/test/java/com/howaboutus/backend/user/controller/UserWithdrawalControllerTest.java`: + +```java +package com.howaboutus.backend.user.controller; + +import static org.hamcrest.Matchers.containsString; +import static org.mockito.BDDMockito.doNothing; +import static org.mockito.BDDMockito.doThrow; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.cookie; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.List; +import java.util.UUID; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.web.servlet.MockMvc; + +import com.howaboutus.backend.user.exception.HostDelegationRequiredException; +import com.howaboutus.backend.user.service.UserService; +import com.howaboutus.backend.user.service.UserWithdrawalService; +import com.howaboutus.backend.user.service.dto.RoomRequiringDelegation; + +@WebMvcTest(controllers = UserController.class) +class UserWithdrawalControllerTest { + + @Autowired + private MockMvc mvc; + + @MockBean + private UserService userService; + @MockBean + private UserWithdrawalService userWithdrawalService; + + @Test + @DisplayName("DELETE /users/me 성공 시 204와 만료 쿠키를 반환한다") + void deleteMeSuccess() throws Exception { + doNothing().when(userWithdrawalService).withdraw(1L); + + mvc.perform(delete("/users/me").requestAttr("authenticatedUserId", 1L)) + .andExpect(status().isNoContent()) + .andExpect(cookie().maxAge("access_token", 0)) + .andExpect(cookie().maxAge("refresh_token", 0)); + } + + @Test + @DisplayName("DELETE /users/me 위임 필요 시 422와 roomsRequiringDelegation 본문을 반환한다") + void deleteMeBlocked() throws Exception { + UUID roomId = UUID.randomUUID(); + doThrow(new HostDelegationRequiredException( + List.of(new RoomRequiringDelegation(roomId, "Trip")))) + .when(userWithdrawalService).withdraw(1L); + + mvc.perform(delete("/users/me").requestAttr("authenticatedUserId", 1L)) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.roomsRequiringDelegation[0].roomId") + .value(roomId.toString())) + .andExpect(jsonPath("$.roomsRequiringDelegation[0].title").value("Trip")); + } +} +``` + +> 위 테스트는 Spring Security `@AuthenticationPrincipal` 주입을 우회하려고 `requestAttr` 패턴을 가정한다. 프로젝트에서 사용하는 기존 `AuthControllerTest`/`UserControllerTest` 패턴을 그대로 따라 `@MockBean` + custom argument resolver, 또는 `with(...)` Security context 설정을 사용한다. 기존 `UserControllerTest`가 있다면 동일한 방식으로 인증 시뮬레이션을 수행한다. + +- [ ] **Step 2: 컴파일 + 실행으로 실패 확인** + +Run: `./gradlew test --tests com.howaboutus.backend.user.controller.UserWithdrawalControllerTest` +Expected: 컴파일 또는 어설션 실패. + +- [ ] **Step 3: 컨트롤러 수정** + +`UserController.java` 전체를 다음으로 교체(기존 `getMyProfile` 유지): + +```java +package com.howaboutus.backend.user.controller; + +import java.time.Duration; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.howaboutus.backend.common.config.properties.CookieProperties; +import com.howaboutus.backend.common.error.ApiErrorCodes; +import com.howaboutus.backend.common.error.ErrorCode; +import com.howaboutus.backend.common.logging.Loggable; +import com.howaboutus.backend.user.service.UserService; +import com.howaboutus.backend.user.service.UserWithdrawalService; +import com.howaboutus.backend.user.service.dto.UserResponse; +import com.howaboutus.backend.user.service.dto.WithdrawalBlockedResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@Tag(name = "Users", description = "사용자 API") +@RestController +@RequestMapping("/users") +@RequiredArgsConstructor +public class UserController { + + private final UserService userService; + private final UserWithdrawalService userWithdrawalService; + private final CookieProperties cookieProperties; + + @Value("${spring.profiles.active:dev}") + private String activeProfile; + + @Operation(summary = "내 프로필 조회", description = "현재 인증된 사용자의 프로필을 조회합니다.") + @ApiResponse(responseCode = "200", description = "조회 성공") + @ApiErrorCodes({ErrorCode.INVALID_TOKEN, ErrorCode.ACCESS_TOKEN_EXPIRED, ErrorCode.USER_NOT_FOUND}) + @GetMapping("/me") + public ResponseEntity getMyProfile(@AuthenticationPrincipal Long userId) { + return ResponseEntity.ok(userService.getMyProfile(userId)); + } + + @Operation( + summary = "회원 탈퇴", + description = "현재 인증된 사용자의 탈퇴를 처리합니다. " + + "방장인 방은 사전 위임이 필요하며, 1인 방은 자동 hard delete됩니다. " + + "성공 시 인증 쿠키를 만료시킵니다." + ) + @ApiResponse(responseCode = "204", description = "탈퇴 성공 (No Content)", content = @Content) + @ApiResponse(responseCode = "422", + description = "방장 위임이 필요한 방이 존재", + content = @Content(schema = @Schema(implementation = WithdrawalBlockedResponse.class))) + @ApiErrorCodes({ + ErrorCode.INVALID_TOKEN, + ErrorCode.ACCESS_TOKEN_EXPIRED, + ErrorCode.USER_NOT_FOUND, + ErrorCode.WITHDRAWAL_REQUIRES_HOST_DELEGATION + }) + @Loggable + @DeleteMapping("/me") + public ResponseEntity withdraw(@AuthenticationPrincipal Long userId) { + userWithdrawalService.withdraw(userId); + return ResponseEntity.noContent() + .header(HttpHeaders.SET_COOKIE, expiredCookie("access_token").toString()) + .header(HttpHeaders.SET_COOKIE, expiredCookie("refresh_token").toString()) + .build(); + } + + private ResponseCookie expiredCookie(String name) { + boolean secure = "prod".equals(activeProfile); + ResponseCookie.ResponseCookieBuilder builder = ResponseCookie.from(name, "") + .httpOnly(true).sameSite("Lax").path("/") + .maxAge(Duration.ZERO).secure(secure); + String domain = cookieProperties.domain(); + if (domain != null && !domain.isBlank()) { + builder.domain(domain); + } + return builder.build(); + } +} +``` + +- [ ] **Step 4: 테스트 통과 확인** + +Run: `./gradlew test --tests com.howaboutus.backend.user.controller.UserWithdrawalControllerTest` +Expected: PASS (2 tests). + +- [ ] **Step 5: Checkstyle + 커밋** + +```bash +./gradlew checkstyleMain checkstyleTest +git add src/main/java/com/howaboutus/backend/user/controller/UserController.java \ + src/test/java/com/howaboutus/backend/user/controller/UserWithdrawalControllerTest.java +git commit -m "feat: DELETE /users/me 탈퇴 엔드포인트와 만료 쿠키 처리" +``` + +--- + +## Task 11: 통합 테스트 — 엔드 투 엔드 흐름 + +**Files:** +- Test: `src/test/java/com/howaboutus/backend/user/UserWithdrawalIntegrationTest.java` (신규) + +- [ ] **Step 1: 통합 테스트 작성** + +`src/test/java/com/howaboutus/backend/user/UserWithdrawalIntegrationTest.java`: + +```java +package com.howaboutus.backend.user; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.support.TransactionTemplate; + +import com.howaboutus.backend.auth.service.RefreshTokenService; +import com.howaboutus.backend.common.error.CustomException; +import com.howaboutus.backend.common.error.ErrorCode; +import com.howaboutus.backend.rooms.entity.Room; +import com.howaboutus.backend.rooms.entity.RoomMember; +import com.howaboutus.backend.rooms.entity.RoomRole; +import com.howaboutus.backend.rooms.repository.RoomMemberRepository; +import com.howaboutus.backend.rooms.repository.RoomRepository; +import com.howaboutus.backend.support.BaseIntegrationTest; +import com.howaboutus.backend.user.entity.User; +import com.howaboutus.backend.user.exception.HostDelegationRequiredException; +import com.howaboutus.backend.user.repository.UserRepository; +import com.howaboutus.backend.user.service.UserService; +import com.howaboutus.backend.user.service.UserWithdrawalService; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; + +class UserWithdrawalIntegrationTest extends BaseIntegrationTest { + + @Autowired private UserWithdrawalService withdrawalService; + @Autowired private UserRepository userRepository; + @Autowired private RoomRepository roomRepository; + @Autowired private RoomMemberRepository roomMemberRepository; + @Autowired private UserService userService; + @Autowired private RefreshTokenService refreshTokenService; + @Autowired private TransactionTemplate tx; + + @PersistenceContext private EntityManager em; + + @Test + @DisplayName("정상 탈퇴: 1인 방 삭제, 잔여 멤버십 삭제, 익명화, RTK 즉시 삭제") + void happyPath() { + User u = userRepository.save(User.ofGoogle("g1", "u@a.com", "닉", null)); + User other = userRepository.save(User.ofGoogle("g2", "o@a.com", "오", null)); + + Room solo = roomRepository.save(Room.create("Solo", null, + java.time.LocalDate.now(), java.time.LocalDate.now(), "inv-s", u.getId())); + roomMemberRepository.save(RoomMember.create(solo, u, RoomRole.HOST)); + + Room memberRoom = roomRepository.save(Room.create("M", null, + java.time.LocalDate.now(), java.time.LocalDate.now(), "inv-m", other.getId())); + roomMemberRepository.save(RoomMember.create(memberRoom, other, RoomRole.HOST)); + roomMemberRepository.save(RoomMember.create(memberRoom, u, RoomRole.MEMBER)); + + String rtk = refreshTokenService.create(u.getId()); + + withdrawalService.withdraw(u.getId()); + em.clear(); + + assertThat(roomRepository.findById(solo.getId())).isEmpty(); + assertThat(roomMemberRepository.findByRoom_IdAndUser_Id(memberRoom.getId(), u.getId())) + .isEmpty(); + assertThat(userRepository.findById(u.getId())).isEmpty(); // @SQLRestriction 효과 + + assertThatThrownBy(() -> refreshTokenService.rotate(rtk)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.REFRESH_TOKEN_NOT_FOUND); + } + + @Test + @DisplayName("다인 HOST 방이 있으면 차단되고 어떤 변경도 일어나지 않는다") + void blocksAndRollsBack() { + User host = userRepository.save(User.ofGoogle("g1", "h@a.com", "h", null)); + User other = userRepository.save(User.ofGoogle("g2", "o@a.com", "o", null)); + Room room = roomRepository.save(Room.create("R", null, + java.time.LocalDate.now(), java.time.LocalDate.now(), "inv-r", host.getId())); + roomMemberRepository.save(RoomMember.create(room, host, RoomRole.HOST)); + roomMemberRepository.save(RoomMember.create(room, other, RoomRole.MEMBER)); + + assertThatThrownBy(() -> withdrawalService.withdraw(host.getId())) + .isInstanceOf(HostDelegationRequiredException.class); + + em.clear(); + Optional reread = userRepository.findById(host.getId()); + assertThat(reread).isPresent(); + assertThat(reread.get().isWithdrawn()).isFalse(); + } + + @Test + @DisplayName("탈퇴자와 동일 (provider, providerId)로 재가입할 수 있다") + void reSignupWithSameProvider() { + User u = userRepository.save(User.ofGoogle("pid-1", "re@a.com", "닉", null)); + tx.executeWithoutResult(s -> withdrawalService.withdraw(u.getId())); + em.clear(); + + User reborn = userService.getOrCreateGoogleUser( + "pid-1", "re@a.com", "재가입", null); + + assertThat(reborn.getId()).isNotEqualTo(u.getId()); + assertThat(userRepository.findByProviderAndProviderId("GOOGLE", "pid-1").get().getId()) + .isEqualTo(reborn.getId()); + } +} +``` + +- [ ] **Step 2: 실행으로 통과 확인** + +Run: `./gradlew test --tests com.howaboutus.backend.user.UserWithdrawalIntegrationTest` +Expected: PASS (3 tests). + +> Testcontainers 첫 부팅이 느릴 수 있다. 캐시된 컨테이너 이미지가 없는 경우 5~10분이 걸릴 수 있다. + +- [ ] **Step 3: Checkstyle + 커밋** + +```bash +./gradlew checkstyleMain checkstyleTest +git add src/test/java/com/howaboutus/backend/user/UserWithdrawalIntegrationTest.java +git commit -m "test: 회원 탈퇴 엔드 투 엔드 통합 테스트 추가" +``` + +--- + +## Task 12: 문서 갱신 (`features.md`, `erd.md`, 결정 기록) + +**Files:** +- Modify: `docs/ai/features.md` +- Modify: `docs/ai/erd.md` +- Create: `docs/ai/decisions/20260607-user-withdrawal-soft-delete.md` + +- [ ] **Step 1: `docs/ai/features.md` 변경** + +"1. 인증 (Auth)" 표 마지막 행에 다음을 추가하거나, 별도 "사용자(Users)" 섹션을 추가: + +``` +| `[x]` | 회원 탈퇴 | `DELETE /users/me`. users는 soft delete + 익명화(email/nickname/profile/provider/provider_id NULL, deleted_at 설정), room_members는 hard delete. HOST 방은 사전 위임 필요(422 + roomsRequiringDelegation), 1인 HOST 방은 자동 hard delete. Redis RTK는 AFTER_COMMIT에서 일괄 폐기. 채팅/북마크/일정의 BIGINT 작성자 ID는 유지하며 클라이언트는 members에 없는 ID를 "(알 수 없음)"으로 표시 | users, room_members, Redis | +``` + +- [ ] **Step 2: `docs/ai/erd.md` `users` 섹션 갱신** + +`deleted_at` 컬럼을 추가하고 제약/인덱스를 다음과 같이 갱신: + +``` +| deleted_at | TIMESTAMP | NULLABLE | 탈퇴 시각. NOT NULL이면 익명화된 탈퇴 회원 | + +**제약:** +- CHECK `users_active_required`: `deleted_at IS NOT NULL OR (email AND nickname AND provider AND provider_id IS NOT NULL)` +- partial unique index `users_email_unique_active`: `email` WHERE `deleted_at IS NULL` +- partial unique index `users_provider_provider_id_unique_active`: `(provider, provider_id)` WHERE `deleted_at IS NULL` + +**인덱스 (탈퇴자 필터):** `users_deleted_at_idx` ON `(deleted_at)` WHERE `deleted_at IS NOT NULL` + +> 활성 회원은 email/nickname/provider/provider_id가 NOT NULL이며 이메일과 (provider, provider_id) 조합이 unique. 탈퇴 회원은 모두 NULL 가능하며 unique 검사 대상에서 제외되어 동일 OAuth 계정으로 재가입할 수 있다. +``` + +기존 "**제약:** UNIQUE(provider, provider_id)" 문구는 제거한다. + +- [ ] **Step 3: 결정 기록 작성** + +`docs/ai/decisions/20260607-user-withdrawal-soft-delete.md`: + +```markdown +# users soft delete: partial unique index + CHECK 채택 + +- **상태**: 결정 +- **날짜**: 2026-06-07 +- **관련**: docs/superpowers/specs/2026-06-07-user-withdrawal-design.md + +## 배경 + +이용약관/개인정보 정책상 회원 탈퇴 기능이 필요하다. 채팅 등 공동 협업 데이터의 무결성을 유지하면서 탈퇴자의 개인정보는 즉시 제거되어야 한다. 동시에 `users.email`과 `(provider, provider_id)`에 걸린 UNIQUE 제약 때문에 단순히 컬럼을 NULL로 비울 수 없다. + +## 결정 + +`users`를 soft delete 모델로 운영한다. + +- 활성 회원: `email`, `nickname`, `provider`, `provider_id`는 NOT NULL. +- 탈퇴 회원: 위 컬럼들 NULL 허용. `deleted_at`이 NOT NULL. +- 활성 회원에 한해 unique 강제: PostgreSQL partial unique index (`WHERE deleted_at IS NULL`). +- 조건부 NOT NULL은 CHECK `users_active_required`로 보장. +- 엔티티에 `@SQLRestriction("deleted_at IS NULL")`을 적용해 JPA 조회에서 탈퇴자를 자동 제외. + +## 대안 검토 + +- **placeholder 값**(`deleted+{id}@deleted.local` 등): NOT NULL/UNIQUE 제약을 그대로 둘 수 있으나 통계/검색에 가짜 값이 섞이고, 재가입 시 충돌 회피가 복잡. 동일 계정 재가입 차단이 정책 요건이 아닌 본 프로젝트에서는 이점이 없다. +- **분리된 `deleted_users` 테이블**: row 이관 비용/추가 join 필요. soft delete의 단순성을 잃는다. + +## 영향 + +- DDL 변경(`V1.7__users_withdrawal.sql`): NOT NULL drop, 기존 UNIQUE 제약 제거, partial unique index 및 CHECK 추가. +- `User` 엔티티에 `deletedAt`, `anonymize()`, `isWithdrawn()` 추가. +- 동일 OAuth 계정 재가입 가능. 정책상 차단할 필요가 있을 때만 별도 결정 기록으로 변경. +``` + +- [ ] **Step 4: 문서 일치성 점검** + +`/checking-md-conflicts` 스킬을 실행해 죽은 참조/중복/모순이 없는지 확인하고, 보고된 이슈가 있으면 해결한다. + +- [ ] **Step 5: 커밋** + +```bash +git add docs/ai/features.md docs/ai/erd.md docs/ai/decisions/20260607-user-withdrawal-soft-delete.md +git commit -m "docs: 회원 탈퇴 정책을 features/erd/decisions에 반영" +``` + +--- + +## Task 13: 최종 회귀 + 푸시 준비 + +**Files:** N/A + +- [ ] **Step 1: 전체 빌드와 테스트** + +Run: `./gradlew build` +Expected: BUILD SUCCESSFUL. 모든 기존 테스트와 신규 테스트 모두 PASS. + +- [ ] **Step 2: 체크스타일 최종 확인** + +Run: `./gradlew checkstyleMain checkstyleTest` +Expected: 0 warnings. + +- [ ] **Step 3: `/review-code-against-docs` 스킬 실행** + +CLAUDE.md `Before You Finish` 규칙에 따라 `/review-code-against-docs`로 변경 코드가 문서 스펙과 일치하는지 검증. + +- [ ] **Step 4: 푸시 (사용자 승인 필요)** + +```bash +git push -u origin feature/user-withdrawal +``` + +CLAUDE.md `Agent Boundaries`에 따라 push는 사용자 확인 후에만 수행. + +- [ ] **Step 5: PR 생성 (사용자 승인 필요)** + +`.github/pull_request_template.md` 양식을 그대로 따라 PR을 작성. base는 `dev`. + +--- + +## 자가 검토 요약 + +- **스펙 커버리지**: API 형태(Task 10), DB 변경(Task 1), User 엔티티 변경(Task 2), Repository 쿼리(Task 4), 서비스 오케스트레이션(Task 8), 더블체크(Task 8 Step 3 코드), 예외/응답(Task 5/9), RTK 즉시 폐기(Task 6/7), 채팅 senderId 처리(서버 변경 없음 — 문서로만 명시: Task 12), 1인 방 hard delete(Task 8), PENDING 처리(Task 4/8), 통합 테스트(Task 11), 문서 갱신(Task 12) — 모두 커버. +- **플레이스홀더 스캔**: 모든 step에 실행 가능한 코드/명령 포함. "TBD"/"적절한 에러 처리" 같은 표현 없음. +- **타입 일관성**: `RoomRequiringDelegation` (DTO), `RoomRequiringDelegationView` (projection 인터페이스), `HostDelegationRequiredException`, `WithdrawalBlockedResponse`, `UserWithdrawnEvent`의 시그니처가 Task 4·5·7·8·9·10에서 동일하게 사용됨. +- **범위 외**: JWT blacklist, 탈퇴 유예/복구, 별도 user 조회 API(클라이언트 senderId 해석용)는 스펙 §11에 따라 본 플랜에서 제외. + +--- + +## 실행 옵션 + +플랜이 완성되어 `docs/superpowers/plans/2026-06-07-user-withdrawal.md`에 저장되었습니다. + +**1. Subagent-Driven (추천)** — Task 단위로 fresh 서브에이전트를 띄우고 사이마다 리뷰. 빠른 반복. + +**2. Inline Execution** — 이 세션에서 그대로 실행. `superpowers:executing-plans` 사용. 체크포인트로 검토. + +어느 쪽으로 진행할까요? diff --git a/docs/superpowers/specs/2026-06-07-user-withdrawal-design.md b/docs/superpowers/specs/2026-06-07-user-withdrawal-design.md new file mode 100644 index 00000000..216311b7 --- /dev/null +++ b/docs/superpowers/specs/2026-06-07-user-withdrawal-design.md @@ -0,0 +1,277 @@ +# 회원 탈퇴 기능 설계 (User Withdrawal) + +- **상태**: 초안 +- **작성일**: 2026-06-07 +- **관련 문서**: [`docs/ai/features.md`](../../ai/features.md), [`docs/ai/erd.md`](../../ai/erd.md) + +## 1. 배경 + +이용약관/개인정보 처리 정책 상 회원 탈퇴 기능이 필요하다. 채팅을 비롯한 공동 협업 데이터의 무결성을 유지하면서 탈퇴자의 개인정보는 즉시 익명화하고, 인증 정보는 즉시 무효화해야 한다. + +## 2. 정책 요약 + +- `users` 행은 **soft delete + 익명화**한다. 채팅 작성자 식별 등 BIGINT 논리 참조 일관성은 그대로 유지된다. +- `room_members`는 **hard delete**한다. +- **방장(HOST)인 방**은 다음과 같이 처리한다. + - 다른 활성 멤버가 있으면: 탈퇴 전에 위임이 선행되어야 한다. 위임이 누락되면 422로 거절한다. + - 다른 활성 멤버가 없으면(1인 방): 탈퇴 처리 안에서 방을 **hard delete**한다. +- **MongoDB 채팅 메시지**는 유지한다. `senderId`도 그대로 둔다. +- **Bookmark / BookmarkCategory / Schedule** 등의 `added_by`/`created_by` BIGINT 논리 참조는 그대로 유지한다. +- **클라이언트 표시 규칙**: 클라이언트는 `members` 목록에 없는 `senderId`/`addedBy`/`createdBy`를 일률적으로 "(알 수 없음)"으로 표시한다. 추방/방 나가기/탈퇴를 구분하지 않는다. +- **Redis refresh token**은 탈퇴 직후 해당 사용자의 모든 RTK를 즉시 삭제한다. +- **JWT access_token**은 stateless로 두며 만료까지 자연 소멸한다(별도 blacklist 미도입). + +## 3. API + +### `DELETE /users/me` + +**요청**: 인증 필요(쿠키 `access_token`), 요청 본문 없음. + +**응답** + +- `204 No Content` + Set-Cookie로 만료된 `access_token` / `refresh_token` (maxAge=0) +- `422 Unprocessable Entity` + 본문: + +```json +{ + "roomsRequiringDelegation": [ + { "roomId": "uuid", "title": "string" } + ] +} +``` + +- `401 Unauthorized`: 인증 실패 (기존 필터 처리) +- `404 Not Found`: 이미 탈퇴 상태(USER_NOT_FOUND) + +422를 받은 클라이언트는 기존 `PATCH /rooms/{roomId}/host`로 각 방의 위임을 완료한 뒤 `DELETE /users/me`를 재시도한다. + +## 4. DB 스키마 변경 (`users`) + +```sql +ALTER TABLE users ADD COLUMN deleted_at TIMESTAMP NULL; + +ALTER TABLE users ALTER COLUMN email DROP NOT NULL; +ALTER TABLE users ALTER COLUMN nickname DROP NOT NULL; +ALTER TABLE users ALTER COLUMN provider DROP NOT NULL; +ALTER TABLE users ALTER COLUMN provider_id DROP NOT NULL; + +ALTER TABLE users ADD CONSTRAINT users_active_required CHECK ( + deleted_at IS NOT NULL + OR (email IS NOT NULL + AND nickname IS NOT NULL + AND provider IS NOT NULL + AND provider_id IS NOT NULL) +); + +ALTER TABLE users DROP CONSTRAINT users_email_key; +ALTER TABLE users DROP CONSTRAINT users_provider_provider_id_key; + +CREATE UNIQUE INDEX users_email_unique_active + ON users(email) WHERE deleted_at IS NULL; + +CREATE UNIQUE INDEX users_provider_provider_id_unique_active + ON users(provider, provider_id) WHERE deleted_at IS NULL; + +CREATE INDEX users_deleted_at_idx ON users(deleted_at) WHERE deleted_at IS NOT NULL; +``` + +> 기존 UNIQUE 제약 명칭은 실제 환경에서 `\d users`로 확인 후 정확히 지정한다. + +### 결과로 보장되는 invariant + +- 활성 회원: `email`, `nickname`, `provider`, `provider_id` 모두 NOT NULL (CHECK) +- 활성 회원 간: `email`, `(provider, provider_id)` 각각 UNIQUE (partial unique) +- 탈퇴 회원: 위 컬럼들 모두 NULL 허용, unique 검사 대상 제외 → 동일 Google 계정 재가입 가능 + +## 5. 엔티티 변경 (`User.java`) + +- `email`, `nickname`, `provider`, `providerId`의 `@Column(nullable = false)` 제거 +- `private Instant deletedAt;` 컬럼 추가 +- 클래스에 `@SQLRestriction("deleted_at IS NULL")` 적용 → JPA 통한 모든 `User` 조회에서 탈퇴자 자동 제외 +- 도메인 메서드: + - `void anonymize()` — 모든 개인정보 컬럼을 null로 설정하고 `deletedAt = Instant.now()` + - `boolean isWithdrawn()` — `deletedAt != null` + +## 6. 컴포넌트 구성 + +### 신규 `user/service/UserWithdrawalService` + +```java +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserWithdrawalService { + + @Transactional + public void withdraw(Long userId) { + // 1) 사전 검증 + List blocking = + roomMemberRepository.findHostRoomsWithOtherActiveMembers(userId); + if (!blocking.isEmpty()) { + throw new HostDelegationRequiredException(blocking); + } + + // 2) 1인 HOST 방 hard delete + List soloHostRooms = roomMemberRepository.findHostRoomsWithOnlySelf(userId); + for (Room room : soloHostRooms) { + roomRepository.delete(room); + eventPublisher.publishEvent(new RoomDeletedEvent(room.getId(), List.of(userId))); + } + + // 3) 잔여 room_members hard delete + // 이 시점에 남는 역할: MEMBER, PENDING (HOST는 step 2에서 이미 정리됨) + // MEMBER에 대해서만 MemberLeftEvent 발행, PENDING은 조용히 정리 + List remainingMemberships = + roomMemberRepository.findAllByUserId(userId); + for (RoomMember m : remainingMemberships) { + roomMemberRepository.delete(m); + if (m.getRole() == RoomRole.MEMBER) { + eventPublisher.publishEvent(new MemberLeftEvent( + m.getRoom().getId(), userId, + user.getNickname(), user.getProfileImageUrl())); + } + } + + // 4) 더블체크 (사전 검증 ~ 삭제 사이 윈도우 보호) + List stillBlocking = + roomMemberRepository.findHostRoomsWithOtherActiveMembers(userId); + if (!stillBlocking.isEmpty()) { + throw new HostDelegationRequiredException(stillBlocking); // 롤백 + } + + // 5) 익명화 + user.anonymize(); + } +} +``` + +`ApplicationEventPublisher` 이벤트는 기본적으로 `@TransactionalEventListener(phase = AFTER_COMMIT)` 리스너가 받는다. 본 설계에서도 이미 그렇게 동작 중이다. + +### 신규 컨트롤러 / 또는 `UserController`에 추가 + +```java +@DeleteMapping("/me") +public ResponseEntity withdraw(@AuthenticationPrincipal Long userId) { + userWithdrawalService.withdraw(userId); + return ResponseEntity.noContent() + .header(HttpHeaders.SET_COOKIE, expiredAccessCookie().toString()) + .header(HttpHeaders.SET_COOKIE, expiredRefreshCookie().toString()) + .build(); +} +``` + +`HostDelegationRequiredException`은 글로벌 ExceptionHandler에서 422 + 본문으로 매핑한다. + +### 신규 `user/service/dto/WithdrawalBlockedResponse` + +```java +public record WithdrawalBlockedResponse( + List roomsRequiringDelegation +) { + public record RoomRequiringDelegation(UUID roomId, String title) {} +} +``` + +### `auth/service/RefreshTokenService` 확장 + +- 기존 private `invalidateAllTokens(userId)`를 public `invalidateAllForUser(Long userId)`로 노출 +- 탈퇴 트랜잭션 커밋 이후 `@TransactionalEventListener(AFTER_COMMIT)`에서 호출 + +신규 이벤트 `UserWithdrawnEvent(Long userId)`를 정의하고 위 리스너가 RTK 삭제를 수행한다. + +### `RoomMemberRepository` 보강 + +- `findHostRoomsWithOtherActiveMembers(userId)`: HOST이면서 본인 외 활성 멤버(`HOST`/`MEMBER`)가 1명 이상 있는 방을 `(roomId, title)`로 조회 +- `findHostRoomsWithOnlySelf(userId)`: HOST이면서 본인 외 활성 멤버(`HOST`/`MEMBER`)가 0명인 방. PENDING은 활성 멤버로 카운트하지 않으며, PENDING만 남아 있어도 1인 HOST 방으로 간주해 hard delete한다. +- `findAllByUserId(userId)`: 사용자의 모든 `room_members` 행 (역할 무관). 탈퇴 시 잔여 PENDING 행도 함께 정리하기 위함. + +이 쿼리들은 단일 SQL로 풀어 N+1을 회피한다. + +### `ErrorCode` 추가 + +```java +WITHDRAWAL_REQUIRES_HOST_DELEGATION( + HttpStatus.UNPROCESSABLE_ENTITY, + "방장 위임이 필요한 방이 있습니다" +) +``` + +## 7. 데이터 흐름 + +### Case A: 정상 탈퇴 + +1. 클라이언트가 `DELETE /users/me` 호출. +2. 사전 검증에서 위임 필요 방이 없음을 확인. +3. 1인 HOST 방을 hard delete하면서 `RoomDeletedEvent`를 큐잉. PENDING만 남은 HOST 방도 같은 단계에서 삭제된다. +4. 사용자의 모든 잔여 `room_members`를 hard delete. `MEMBER` 역할에 한해 `MemberLeftEvent`를 큐잉(PENDING은 조용히 정리). +5. 더블체크: 사전 검증 ~ 삭제 사이 윈도우에서 새 위임/입장으로 다인 HOST 상태가 발생했는지 재확인. 발생 시 `HostDelegationRequiredException`으로 전체 롤백. +6. `user.anonymize()` 호출 후 커밋. +7. AFTER_COMMIT: `UserWithdrawnEvent` 리스너가 Redis RTK 일괄 삭제. 도메인 이벤트 리스너가 SYSTEM 메시지/브로드캐스트 등 후속 처리. +8. 응답: 204 + 만료 쿠키 헤더. + +### Case B: 위임 필요 + +1. `DELETE /users/me` 호출. +2. 사전 검증에서 다인 HOST 방 발견 → `HostDelegationRequiredException` throw → 전체 롤백. +3. 응답: 422 + `roomsRequiringDelegation` 목록. +4. 클라이언트가 기존 `PATCH /rooms/{roomId}/host`로 위임 후 재시도 → Case A 흐름. + +## 8. 에러 처리 + +| 상황 | 응답 | ErrorCode | 비고 | +|------|------|-----------|------| +| 미인증 | 401 | INVALID_TOKEN / ACCESS_TOKEN_EXPIRED | 기존 필터 | +| 이미 탈퇴 | 404 | USER_NOT_FOUND | `@SQLRestriction`으로 자동 처리 | +| HOST 위임 필요 | 422 | WITHDRAWAL_REQUIRES_HOST_DELEGATION | body에 목록 포함 | +| Redis RTK 삭제 실패 | 로그만 | — | 트랜잭션 이미 커밋, fail-open. TTL 만료로 자연 소멸 | +| 이벤트 발행 실패 | 로그만 | — | AFTER_COMMIT 단계, 본 트랜잭션 영향 없음 | + +**동시성 가드**: 위임 검증과 hard delete 사이의 짧은 윈도우 동안 새로운 위임/입장이 발생할 수 있으므로, 익명화 직전에 한 번 더 `findHostRoomsWithOtherActiveMembers(userId)`를 확인한다. 결과가 비어있지 않으면 동일 예외로 롤백한다. + +## 9. 테스트 전략 + +### 단위 테스트 (`UserWithdrawalServiceTest`, Mockito) + +- 정상 탈퇴(HOST 방 없음, MEMBER 방 2개) → 익명화 + `room_members` 삭제 + `MemberLeftEvent` 2건 +- 1인 HOST 방 → 방 delete + `RoomDeletedEvent` +- 다인 HOST 방 존재 → `HostDelegationRequiredException`, 어떤 mutation도 없음 +- 1인 방 + 다인 HOST 혼합 → 422 throw (다인 HOST가 있으므로 전체 차단) + +### 통합 테스트 (Testcontainers) + +- partial unique 인덱스/CHECK 제약 실제 동작 검증 +- 익명화 이후 동일 (provider, providerId)로 `getOrCreateGoogleUser` 성공 +- `@SQLRestriction`: `userRepository.findById(withdrawnId)`가 empty +- AFTER_COMMIT 이벤트 발행 확인 (`ApplicationEvents`) +- RTK 무효화: 탈퇴 후 `refreshTokenService.rotate(token)` → `REFRESH_TOKEN_NOT_FOUND` + +### WebMvc 테스트 (`UserWithdrawalControllerTest`) + +- 정상: 204 + Set-Cookie 헤더에 maxAge=0인 access/refresh +- 422: 응답 본문 `roomsRequiringDelegation` 구조 +- 미인증: 401 + +### 회귀 + +- `getMyProfile(withdrawnId)` → USER_NOT_FOUND +- `getOrCreateGoogleUser`: 활성 회원의 (provider, providerId) 중복은 기존처럼 충돌 회복(`DataIntegrityViolationException` 처리 경로) + +## 10. 문서 갱신 + +- `docs/ai/features.md` + - "사용자(Users)" 또는 Auth 섹션에 `[ ] 회원 탈퇴` 항목 추가 + - 정책 요약(soft delete + 익명화, room_members hard delete, 1인 방 자동 삭제, RTK 즉시 삭제) + - 클라이언트 표시 규칙(members에 없는 senderId/addedBy = "(알 수 없음)") +- `docs/ai/erd.md` + - `users` 테이블에 `deleted_at` 컬럼 추가 + - partial unique 인덱스와 CHECK 제약 명세 +- `docs/ai/decisions/` + - 신규 결정 기록: 회원 탈퇴 모델 — partial unique + CHECK 채택 배경, 대안(placeholder, 분리 테이블) 비교, 트레이드오프 + +## 11. 범위 외 (Out of Scope) + +- JWT access_token blacklist 도입 +- 탈퇴 유예 기간(쿨다운) / 복구 기능 +- 별도 비밀번호 재인증 단계 (Google OAuth 단일 provider이므로 access_token 보유만으로 충분) +- 동일 Google 계정 재가입 제한 +- Bookmark/Schedule의 `added_by`/`created_by` 처리 변경 (채팅과 동일하게 유지) diff --git a/src/main/java/com/howaboutus/backend/auth/service/AuthService.java b/src/main/java/com/howaboutus/backend/auth/service/AuthService.java index 451f2904..5ff6a33a 100644 --- a/src/main/java/com/howaboutus/backend/auth/service/AuthService.java +++ b/src/main/java/com/howaboutus/backend/auth/service/AuthService.java @@ -6,6 +6,8 @@ import com.howaboutus.backend.auth.service.dto.GoogleUserInfo; import com.howaboutus.backend.auth.service.dto.LoginResult; import com.howaboutus.backend.auth.service.dto.RotateResult; +import com.howaboutus.backend.common.error.CustomException; +import com.howaboutus.backend.common.error.ErrorCode; import com.howaboutus.backend.common.integration.google.GoogleOAuthClient; import com.howaboutus.backend.common.logging.Loggable; import com.howaboutus.backend.user.entity.User; @@ -43,6 +45,7 @@ public LoginResult googleLogin(String authorizationCode) { @Loggable public LoginResult refresh(String refreshToken) { RotateResult rotated = refreshTokenService.rotate(refreshToken); + ensureActiveUser(rotated.userId()); String accessToken = jwtProvider.generateAccessToken(rotated.userId()); return new LoginResult(accessToken, rotated.token(), rotated.userId()); @@ -52,4 +55,15 @@ public LoginResult refresh(String refreshToken) { public void logout(String refreshToken) { refreshTokenService.delete(refreshToken); } + + private void ensureActiveUser(Long userId) { + try { + userService.getUser(userId); + } catch (CustomException exception) { + if (exception.getErrorCode() == ErrorCode.USER_NOT_FOUND) { + throw new CustomException(ErrorCode.REFRESH_TOKEN_NOT_FOUND); + } + throw exception; + } + } } diff --git a/src/main/java/com/howaboutus/backend/auth/service/RefreshTokenService.java b/src/main/java/com/howaboutus/backend/auth/service/RefreshTokenService.java index 935d9ed1..ceb46b7f 100644 --- a/src/main/java/com/howaboutus/backend/auth/service/RefreshTokenService.java +++ b/src/main/java/com/howaboutus/backend/auth/service/RefreshTokenService.java @@ -84,6 +84,10 @@ private CustomException handleMissingToken(TokenParts parts) { return new CustomException(ErrorCode.REFRESH_TOKEN_NOT_FOUND); } + public void invalidateAllForUser(Long userId) { + invalidateAllTokens(String.valueOf(userId)); + } + private void invalidateAllTokens(String userId) { String userKey = USER_KEY_PREFIX + userId; Set tokens = redisTemplate.opsForSet().members(userKey); diff --git a/src/main/java/com/howaboutus/backend/common/error/ErrorCode.java b/src/main/java/com/howaboutus/backend/common/error/ErrorCode.java index fb5c76a1..63158fa6 100644 --- a/src/main/java/com/howaboutus/backend/common/error/ErrorCode.java +++ b/src/main/java/com/howaboutus/backend/common/error/ErrorCode.java @@ -72,6 +72,11 @@ public enum ErrorCode { // 503 SERVICE UNAVAILABLE ROUTE_TEMPORARILY_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "이동 경로를 조회 중입니다. 잠시 후 다시 시도해 주세요"), + // 422 UNPROCESSABLE ENTITY + WITHDRAWAL_REQUIRES_HOST_DELEGATION( + HttpStatus.UNPROCESSABLE_ENTITY, + "방장 위임이 필요한 방이 있습니다"), + // 502 BAD GATEWAY EXTERNAL_API_ERROR(HttpStatus.BAD_GATEWAY, "외부 API 호출 중 오류가 발생했습니다"); diff --git a/src/main/java/com/howaboutus/backend/common/error/GlobalExceptionHandler.java b/src/main/java/com/howaboutus/backend/common/error/GlobalExceptionHandler.java index 27efaba5..987d9995 100644 --- a/src/main/java/com/howaboutus/backend/common/error/GlobalExceptionHandler.java +++ b/src/main/java/com/howaboutus/backend/common/error/GlobalExceptionHandler.java @@ -17,6 +17,9 @@ import org.springframework.web.servlet.NoHandlerFoundException; import org.springframework.web.servlet.resource.NoResourceFoundException; +import com.howaboutus.backend.user.exception.HostDelegationRequiredException; +import com.howaboutus.backend.user.service.dto.WithdrawalBlockedResponse; + import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolationException; import lombok.extern.slf4j.Slf4j; @@ -106,6 +109,19 @@ public ResponseEntity handleHttpMessageNotReadableException( .body(ApiErrorResponse.of(HttpStatus.BAD_REQUEST, "요청 본문 형식이 올바르지 않습니다")); } + @ExceptionHandler(HostDelegationRequiredException.class) + public ResponseEntity handleHostDelegationRequired( + HostDelegationRequiredException exception) { + log.warn("[EXCEPTION] {} | {}", + kv("errorCode", ErrorCode.WITHDRAWAL_REQUIRES_HOST_DELEGATION), + kv("blockedRoomCount", exception.getRoomsRequiringDelegation().size())); + return ResponseEntity.status(ErrorCode.WITHDRAWAL_REQUIRES_HOST_DELEGATION.getStatus()) + .body(new WithdrawalBlockedResponse( + ErrorCode.WITHDRAWAL_REQUIRES_HOST_DELEGATION.name(), + ErrorCode.WITHDRAWAL_REQUIRES_HOST_DELEGATION.getMessage(), + exception.getRoomsRequiringDelegation())); + } + private int validationMessagePriority(FieldError fieldError) { String code = fieldError.getCode(); if ("NotBlank".equals(code) || "NotEmpty".equals(code)) { diff --git a/src/main/java/com/howaboutus/backend/rooms/repository/RoomMemberRepository.java b/src/main/java/com/howaboutus/backend/rooms/repository/RoomMemberRepository.java index 235b7837..6c0e83ba 100644 --- a/src/main/java/com/howaboutus/backend/rooms/repository/RoomMemberRepository.java +++ b/src/main/java/com/howaboutus/backend/rooms/repository/RoomMemberRepository.java @@ -8,7 +8,10 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import com.howaboutus.backend.rooms.entity.Room; import com.howaboutus.backend.rooms.entity.RoomMember; import com.howaboutus.backend.rooms.entity.RoomRole; @@ -34,4 +37,44 @@ List findByUser_IdAndRoleInAndJoinedAtBeforeOrderByJoinedAtDesc( Long userId, List roles, Instant cursor, Pageable pageable); long countByRoom_IdAndRoleIn(UUID roomId, List roles); + + List findAllByUser_Id(Long userId); + + @Query(""" + select m.room + from RoomMember m + where m.user.id = :userId + and m.role = com.howaboutus.backend.rooms.entity.RoomRole.HOST + and not exists ( + select 1 from RoomMember other + where other.room.id = m.room.id + and other.user.id <> :userId + and other.role in ( + com.howaboutus.backend.rooms.entity.RoomRole.HOST, + com.howaboutus.backend.rooms.entity.RoomRole.MEMBER) + ) + """) + List findHostRoomsWithOnlySelf(@Param("userId") Long userId); + + interface RoomRequiringDelegationView { + UUID getRoomId(); + + String getTitle(); + } + + @Query(""" + select m.room.id as roomId, m.room.title as title + from RoomMember m + where m.user.id = :userId + and m.role = com.howaboutus.backend.rooms.entity.RoomRole.HOST + and exists ( + select 1 from RoomMember other + where other.room.id = m.room.id + and other.user.id <> :userId + and other.role in ( + com.howaboutus.backend.rooms.entity.RoomRole.HOST, + com.howaboutus.backend.rooms.entity.RoomRole.MEMBER) + ) + """) + List findHostRoomsWithOtherActiveMembers(@Param("userId") Long userId); } diff --git a/src/main/java/com/howaboutus/backend/user/controller/UserController.java b/src/main/java/com/howaboutus/backend/user/controller/UserController.java index eeb498b1..997adb03 100644 --- a/src/main/java/com/howaboutus/backend/user/controller/UserController.java +++ b/src/main/java/com/howaboutus/backend/user/controller/UserController.java @@ -1,17 +1,29 @@ package com.howaboutus.backend.user.controller; +import java.time.Duration; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import com.howaboutus.backend.common.config.properties.CookieProperties; import com.howaboutus.backend.common.error.ApiErrorCodes; import com.howaboutus.backend.common.error.ErrorCode; +import com.howaboutus.backend.common.logging.Loggable; import com.howaboutus.backend.user.service.UserService; +import com.howaboutus.backend.user.service.UserWithdrawalService; import com.howaboutus.backend.user.service.dto.UserResponse; +import com.howaboutus.backend.user.service.dto.WithdrawalBlockedResponse; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; @@ -23,6 +35,11 @@ public class UserController { private final UserService userService; + private final UserWithdrawalService userWithdrawalService; + private final CookieProperties cookieProperties; + + @Value("${spring.profiles.active:dev}") + private String activeProfile; @Operation(summary = "내 프로필 조회", description = "현재 인증된 사용자의 프로필을 조회합니다.") @ApiResponse(responseCode = "200", description = "조회 성공") @@ -31,4 +48,43 @@ public class UserController { public ResponseEntity getMyProfile(@AuthenticationPrincipal Long userId) { return ResponseEntity.ok(userService.getMyProfile(userId)); } + + @Operation( + summary = "회원 탈퇴", + description = "현재 인증된 사용자의 탈퇴를 처리합니다. 방장인 방은 사전 위임이 필요하며, 1인 방은 자동 삭제됩니다. 성공 시 인증 쿠키를 만료시킵니다." + ) + @ApiResponse(responseCode = "204", description = "탈퇴 성공 (No Content)", content = @Content) + @ApiResponse(responseCode = "422", + description = "방장 위임이 필요한 방이 존재", + content = @Content(schema = @Schema(implementation = WithdrawalBlockedResponse.class))) + @ApiErrorCodes({ + ErrorCode.INVALID_TOKEN, + ErrorCode.ACCESS_TOKEN_EXPIRED, + ErrorCode.USER_NOT_FOUND, + ErrorCode.WITHDRAWAL_REQUIRES_HOST_DELEGATION + }) + @Loggable + @DeleteMapping("/me") + public ResponseEntity withdraw(@AuthenticationPrincipal Long userId) { + userWithdrawalService.withdraw(userId); + return ResponseEntity.noContent() + .header(HttpHeaders.SET_COOKIE, expiredCookie("access_token").toString()) + .header(HttpHeaders.SET_COOKIE, expiredCookie("refresh_token").toString()) + .build(); + } + + private ResponseCookie expiredCookie(String name) { + boolean secure = "prod".equals(activeProfile); + ResponseCookie.ResponseCookieBuilder builder = ResponseCookie.from(name, "") + .httpOnly(true) + .sameSite("Lax") + .path("/") + .maxAge(Duration.ZERO) + .secure(secure); + String domain = cookieProperties.domain(); + if (domain != null && !domain.isBlank()) { + builder.domain(domain); + } + return builder.build(); + } } diff --git a/src/main/java/com/howaboutus/backend/user/entity/User.java b/src/main/java/com/howaboutus/backend/user/entity/User.java index 324d3a8a..e2c5d88b 100644 --- a/src/main/java/com/howaboutus/backend/user/entity/User.java +++ b/src/main/java/com/howaboutus/backend/user/entity/User.java @@ -1,5 +1,9 @@ package com.howaboutus.backend.user.entity; +import java.time.Instant; + +import org.hibernate.annotations.SQLRestriction; + import com.howaboutus.backend.common.entity.BaseTimeEntity; import jakarta.persistence.Column; @@ -8,38 +12,39 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.Table; -import jakarta.persistence.UniqueConstraint; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @Entity -@Table(name = "users", uniqueConstraints = { - @UniqueConstraint(columnNames = {"provider", "provider_id"}) -}) +@Table(name = "users") @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLRestriction("deleted_at IS NULL") public class User extends BaseTimeEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @Column(nullable = false, unique = true) + @Column private String email; - @Column(nullable = false, length = 50) + @Column(length = 50) private String nickname; @Column(length = 500) private String profileImageUrl; - @Column(nullable = false, length = 20) + @Column(length = 20) private String provider; - @Column(nullable = false) + @Column private String providerId; + @Column(name = "deleted_at") + private Instant deletedAt; + private User(String providerId, String email, String nickname, String profileImageUrl, String provider) { this.providerId = providerId; this.email = email; @@ -51,4 +56,20 @@ private User(String providerId, String email, String nickname, String profileIma public static User ofGoogle(String providerId, String email, String nickname, String profileImageUrl) { return new User(providerId, email, nickname, profileImageUrl, "GOOGLE"); } + + public boolean isWithdrawn() { + return this.deletedAt != null; + } + + public void anonymize() { + if (isWithdrawn()) { + return; + } + this.email = null; + this.nickname = null; + this.profileImageUrl = null; + this.provider = null; + this.providerId = null; + this.deletedAt = Instant.now(); + } } diff --git a/src/main/java/com/howaboutus/backend/user/event/UserWithdrawnEvent.java b/src/main/java/com/howaboutus/backend/user/event/UserWithdrawnEvent.java new file mode 100644 index 00000000..2d225273 --- /dev/null +++ b/src/main/java/com/howaboutus/backend/user/event/UserWithdrawnEvent.java @@ -0,0 +1,4 @@ +package com.howaboutus.backend.user.event; + +public record UserWithdrawnEvent(Long userId) { +} diff --git a/src/main/java/com/howaboutus/backend/user/exception/HostDelegationRequiredException.java b/src/main/java/com/howaboutus/backend/user/exception/HostDelegationRequiredException.java new file mode 100644 index 00000000..21aea486 --- /dev/null +++ b/src/main/java/com/howaboutus/backend/user/exception/HostDelegationRequiredException.java @@ -0,0 +1,18 @@ +package com.howaboutus.backend.user.exception; + +import java.util.List; + +import com.howaboutus.backend.user.service.dto.RoomRequiringDelegation; + +import lombok.Getter; + +@Getter +public class HostDelegationRequiredException extends RuntimeException { + + private final List roomsRequiringDelegation; + + public HostDelegationRequiredException(List roomsRequiringDelegation) { + super("Host delegation required for " + roomsRequiringDelegation.size() + " room(s)."); + this.roomsRequiringDelegation = List.copyOf(roomsRequiringDelegation); + } +} diff --git a/src/main/java/com/howaboutus/backend/user/listener/UserWithdrawnTokenListener.java b/src/main/java/com/howaboutus/backend/user/listener/UserWithdrawnTokenListener.java new file mode 100644 index 00000000..be98da30 --- /dev/null +++ b/src/main/java/com/howaboutus/backend/user/listener/UserWithdrawnTokenListener.java @@ -0,0 +1,29 @@ +package com.howaboutus.backend.user.listener; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import com.howaboutus.backend.auth.service.RefreshTokenService; +import com.howaboutus.backend.user.event.UserWithdrawnEvent; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class UserWithdrawnTokenListener { + + private final RefreshTokenService refreshTokenService; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) + public void handle(UserWithdrawnEvent event) { + try { + refreshTokenService.invalidateAllForUser(event.userId()); + } catch (Exception e) { + log.warn("Failed to invalidate refresh tokens for withdrawn user. userId={}", + event.userId(), e); + } + } +} diff --git a/src/main/java/com/howaboutus/backend/user/service/UserWithdrawalService.java b/src/main/java/com/howaboutus/backend/user/service/UserWithdrawalService.java new file mode 100644 index 00000000..dfc29c9e --- /dev/null +++ b/src/main/java/com/howaboutus/backend/user/service/UserWithdrawalService.java @@ -0,0 +1,91 @@ +package com.howaboutus.backend.user.service; + +import java.util.List; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.howaboutus.backend.common.error.CustomException; +import com.howaboutus.backend.common.error.ErrorCode; +import com.howaboutus.backend.common.logging.Loggable; +import com.howaboutus.backend.realtime.event.MemberLeftEvent; +import com.howaboutus.backend.realtime.event.RoomDeletedEvent; +import com.howaboutus.backend.rooms.entity.Room; +import com.howaboutus.backend.rooms.entity.RoomMember; +import com.howaboutus.backend.rooms.entity.RoomRole; +import com.howaboutus.backend.rooms.repository.RoomMemberRepository; +import com.howaboutus.backend.rooms.repository.RoomMemberRepository.RoomRequiringDelegationView; +import com.howaboutus.backend.rooms.repository.RoomRepository; +import com.howaboutus.backend.user.entity.User; +import com.howaboutus.backend.user.event.UserWithdrawnEvent; +import com.howaboutus.backend.user.exception.HostDelegationRequiredException; +import com.howaboutus.backend.user.repository.UserRepository; +import com.howaboutus.backend.user.service.dto.RoomRequiringDelegation; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserWithdrawalService { + + private final UserRepository userRepository; + private final RoomRepository roomRepository; + private final RoomMemberRepository roomMemberRepository; + private final ApplicationEventPublisher eventPublisher; + + @Loggable + @Transactional + public void withdraw(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + ensureNoHostDelegationRequired(userId); + + deleteSoloHostRooms(userId); + deleteRemainingMemberships(userId, user); + + ensureNoHostDelegationRequired(userId); + + user.anonymize(); + eventPublisher.publishEvent(new UserWithdrawnEvent(userId)); + } + + private void ensureNoHostDelegationRequired(Long userId) { + List blocking = + roomMemberRepository.findHostRoomsWithOtherActiveMembers(userId); + if (blocking.isEmpty()) { + return; + } + + List roomsRequiringDelegation = blocking.stream() + .map(room -> new RoomRequiringDelegation(room.getRoomId(), room.getTitle())) + .toList(); + throw new HostDelegationRequiredException(roomsRequiringDelegation); + } + + private void deleteSoloHostRooms(Long userId) { + List soloHostRooms = roomMemberRepository.findHostRoomsWithOnlySelf(userId); + for (Room room : soloHostRooms) { + roomMemberRepository.findByRoom_IdAndUser_Id(room.getId(), userId) + .ifPresent(roomMemberRepository::delete); + roomRepository.delete(room); + eventPublisher.publishEvent(new RoomDeletedEvent(room.getId(), List.of(userId))); + } + } + + private void deleteRemainingMemberships(Long userId, User user) { + List memberships = roomMemberRepository.findAllByUser_Id(userId); + for (RoomMember membership : memberships) { + roomMemberRepository.delete(membership); + if (membership.getRole() == RoomRole.MEMBER) { + eventPublisher.publishEvent(new MemberLeftEvent( + membership.getRoom().getId(), + userId, + user.getNickname(), + user.getProfileImageUrl())); + } + } + } +} diff --git a/src/main/java/com/howaboutus/backend/user/service/dto/RoomRequiringDelegation.java b/src/main/java/com/howaboutus/backend/user/service/dto/RoomRequiringDelegation.java new file mode 100644 index 00000000..eee0cb54 --- /dev/null +++ b/src/main/java/com/howaboutus/backend/user/service/dto/RoomRequiringDelegation.java @@ -0,0 +1,6 @@ +package com.howaboutus.backend.user.service.dto; + +import java.util.UUID; + +public record RoomRequiringDelegation(UUID roomId, String title) { +} diff --git a/src/main/java/com/howaboutus/backend/user/service/dto/WithdrawalBlockedResponse.java b/src/main/java/com/howaboutus/backend/user/service/dto/WithdrawalBlockedResponse.java new file mode 100644 index 00000000..9ca3dd8d --- /dev/null +++ b/src/main/java/com/howaboutus/backend/user/service/dto/WithdrawalBlockedResponse.java @@ -0,0 +1,18 @@ +package com.howaboutus.backend.user.service.dto; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "회원 탈퇴 거절 응답 - 방장 위임이 필요한 방 목록") +public record WithdrawalBlockedResponse( + @Schema(description = "에러 코드", example = "WITHDRAWAL_REQUIRES_HOST_DELEGATION") + String code, + + @Schema(description = "사용자에게 표시할 수 있는 에러 메시지", example = "방장 위임이 필요한 방이 있습니다") + String message, + + @Schema(description = "방장 위임이 필요한 방 목록") + List roomsRequiringDelegation +) { +} diff --git a/src/main/resources/db/migration/V1.7__users_withdrawal.sql b/src/main/resources/db/migration/V1.7__users_withdrawal.sql new file mode 100644 index 00000000..bd8243ea --- /dev/null +++ b/src/main/resources/db/migration/V1.7__users_withdrawal.sql @@ -0,0 +1,38 @@ +-- =========================================== +-- V1.7: users 회원 탈퇴 지원 (soft delete + 익명화) +-- =========================================== + +-- 1) 탈퇴 시각 컬럼 +ALTER TABLE users ADD COLUMN deleted_at TIMESTAMP WITH TIME ZONE; + +-- 2) 활성 회원 NOT NULL 완화 (탈퇴 시 NULL 허용) +ALTER TABLE users ALTER COLUMN email DROP NOT NULL; +ALTER TABLE users ALTER COLUMN nickname DROP NOT NULL; +ALTER TABLE users ALTER COLUMN provider DROP NOT NULL; +ALTER TABLE users ALTER COLUMN provider_id DROP NOT NULL; + +-- 3) 활성 회원에 한해 NOT NULL 강제 (CHECK) +ALTER TABLE users ADD CONSTRAINT users_active_required CHECK ( + deleted_at IS NOT NULL + OR ( + email IS NOT NULL + AND nickname IS NOT NULL + AND provider IS NOT NULL + AND provider_id IS NOT NULL + ) +); + +-- 4) 기존 unique 제약 제거 +ALTER TABLE users DROP CONSTRAINT users_email_key; +ALTER TABLE users DROP CONSTRAINT uq_users_provider_provider_id; + +-- 5) 활성 회원만 unique 적용 (partial unique index) +CREATE UNIQUE INDEX users_email_unique_active + ON users (email) WHERE deleted_at IS NULL; + +CREATE UNIQUE INDEX users_provider_provider_id_unique_active + ON users (provider, provider_id) WHERE deleted_at IS NULL; + +-- 6) 탈퇴자 필터/조회 인덱스 +CREATE INDEX users_deleted_at_idx ON users (deleted_at) + WHERE deleted_at IS NOT NULL; diff --git a/src/test/java/com/howaboutus/backend/auth/service/AuthServiceTest.java b/src/test/java/com/howaboutus/backend/auth/service/AuthServiceTest.java index 5ab7b4aa..77610292 100644 --- a/src/test/java/com/howaboutus/backend/auth/service/AuthServiceTest.java +++ b/src/test/java/com/howaboutus/backend/auth/service/AuthServiceTest.java @@ -15,6 +15,8 @@ import com.howaboutus.backend.auth.service.dto.GoogleUserInfo; import com.howaboutus.backend.auth.service.dto.LoginResult; import com.howaboutus.backend.auth.service.dto.RotateResult; +import com.howaboutus.backend.common.error.CustomException; +import com.howaboutus.backend.common.error.ErrorCode; import com.howaboutus.backend.common.integration.google.GoogleOAuthClient; import com.howaboutus.backend.user.entity.User; import com.howaboutus.backend.user.service.UserService; @@ -84,6 +86,20 @@ void refreshReturnsNewTokens() { assertThat(result.refreshToken()).isEqualTo("1:new-uuid"); } + @Test + @DisplayName("탈퇴한 사용자의 Refresh Token 재발급은 거절하고 Access Token을 발급하지 않는다") + void refreshRejectsWithdrawnUser() { + given(refreshTokenService.rotate("1:old-uuid")) + .willReturn(new RotateResult("1:new-uuid", 1L)); + given(userService.getUser(1L)) + .willThrow(new CustomException(ErrorCode.USER_NOT_FOUND)); + + assertThatThrownBy(() -> authService.refresh("1:old-uuid")) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.REFRESH_TOKEN_NOT_FOUND); + verify(jwtProvider, never()).generateAccessToken(any()); + } + @Test @DisplayName("로그아웃 시 RefreshTokenService.delete를 호출한다") void logoutDeletesToken() { diff --git a/src/test/java/com/howaboutus/backend/auth/service/RefreshTokenServiceInvalidateAllForUserTest.java b/src/test/java/com/howaboutus/backend/auth/service/RefreshTokenServiceInvalidateAllForUserTest.java new file mode 100644 index 00000000..3711cc94 --- /dev/null +++ b/src/test/java/com/howaboutus/backend/auth/service/RefreshTokenServiceInvalidateAllForUserTest.java @@ -0,0 +1,45 @@ +package com.howaboutus.backend.auth.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import com.howaboutus.backend.common.error.CustomException; +import com.howaboutus.backend.common.error.ErrorCode; +import com.howaboutus.backend.support.BaseIntegrationTest; + +class RefreshTokenServiceInvalidateAllForUserTest extends BaseIntegrationTest { + + @Autowired + private RefreshTokenService refreshTokenService; + + @Test + @DisplayName("invalidateAllForUser는 해당 유저의 모든 RTK를 삭제한다") + void invalidatesAllTokensForUser() { + long userId = 9001L; + String t1 = refreshTokenService.create(userId); + String t2 = refreshTokenService.create(userId); + + refreshTokenService.invalidateAllForUser(userId); + + assertThatThrownBy(() -> refreshTokenService.rotate(t1)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.REFRESH_TOKEN_NOT_FOUND); + assertThatThrownBy(() -> refreshTokenService.rotate(t2)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.REFRESH_TOKEN_NOT_FOUND); + } + + @Test + @DisplayName("대상 유저가 RTK를 갖고 있지 않으면 안전하게 no-op이다") + void noOpWhenNoTokens() { + long userId = 9002L; + + refreshTokenService.invalidateAllForUser(userId); + + assertThat(true).isTrue(); + } +} diff --git a/src/test/java/com/howaboutus/backend/common/error/GlobalExceptionHandlerTest.java b/src/test/java/com/howaboutus/backend/common/error/GlobalExceptionHandlerTest.java index 23781a38..a9758ffa 100644 --- a/src/test/java/com/howaboutus/backend/common/error/GlobalExceptionHandlerTest.java +++ b/src/test/java/com/howaboutus/backend/common/error/GlobalExceptionHandlerTest.java @@ -17,6 +17,10 @@ import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; +import com.howaboutus.backend.user.exception.HostDelegationRequiredException; +import com.howaboutus.backend.user.service.dto.RoomRequiringDelegation; +import com.howaboutus.backend.user.service.dto.WithdrawalBlockedResponse; + import ch.qos.logback.classic.Level; import ch.qos.logback.classic.Logger; import ch.qos.logback.classic.spi.ILoggingEvent; @@ -59,6 +63,23 @@ void handleOptimisticLockingFailureReturnsConflictResponse() { assertThat(response.getBody()).isEqualTo(ApiErrorResponse.of(ErrorCode.SCHEDULE_CONFLICT)); } + @Test + @DisplayName("방장 위임 필요 예외는 422와 위임 필요 방 목록 응답으로 변환한다") + void handleHostDelegationRequiredReturnsBlockedResponse() { + RoomRequiringDelegation room = new RoomRequiringDelegation( + java.util.UUID.randomUUID(), "Trip"); + HostDelegationRequiredException exception = + new HostDelegationRequiredException(List.of(room)); + + var response = globalExceptionHandler.handleHostDelegationRequired(exception); + + assertThat(response.getStatusCode().value()).isEqualTo(422); + assertThat(response.getBody()).isEqualTo(new WithdrawalBlockedResponse( + ErrorCode.WITHDRAWAL_REQUIRES_HOST_DELEGATION.name(), + ErrorCode.WITHDRAWAL_REQUIRES_HOST_DELEGATION.getMessage(), + List.of(room))); + } + @Test @DisplayName("MethodArgumentNotValidException 처리 시 같은 필드의 NotBlank 메시지를 우선한다") void handleMethodArgumentNotValidPrefersNotBlankMessage() { diff --git a/src/test/java/com/howaboutus/backend/rooms/repository/RoomMemberRepositoryWithdrawalTest.java b/src/test/java/com/howaboutus/backend/rooms/repository/RoomMemberRepositoryWithdrawalTest.java new file mode 100644 index 00000000..ac9600a0 --- /dev/null +++ b/src/test/java/com/howaboutus/backend/rooms/repository/RoomMemberRepositoryWithdrawalTest.java @@ -0,0 +1,106 @@ +package com.howaboutus.backend.rooms.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.UUID; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; + +import com.howaboutus.backend.rooms.entity.Room; +import com.howaboutus.backend.rooms.entity.RoomMember; +import com.howaboutus.backend.rooms.entity.RoomRole; +import com.howaboutus.backend.rooms.repository.RoomMemberRepository.RoomRequiringDelegationView; +import com.howaboutus.backend.support.BaseIntegrationTest; +import com.howaboutus.backend.user.entity.User; +import com.howaboutus.backend.user.repository.UserRepository; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; + +class RoomMemberRepositoryWithdrawalTest extends BaseIntegrationTest { + + @Autowired + private RoomMemberRepository roomMemberRepository; + @Autowired + private UserRepository userRepository; + @Autowired + private RoomRepository roomRepository; + + @PersistenceContext + private EntityManager em; + + @Test + @Transactional + @DisplayName("HOST이면서 다른 활성 멤버가 있는 방만 위임 필요 목록에 포함된다") + void findHostRoomsWithOtherActiveMembers() { + User host = userRepository.save(User.ofGoogle("g1-delegation", "h-delegation@a.com", "h", null)); + User other = userRepository.save(User.ofGoogle("g2-delegation", "o-delegation@a.com", "o", null)); + + Room hostOnly = roomRepository.save(Room.create("A", null, + java.time.LocalDate.now(), java.time.LocalDate.now(), "inv-delegation-a", host.getId())); + Room hostWithOther = roomRepository.save(Room.create("B", null, + java.time.LocalDate.now(), java.time.LocalDate.now(), "inv-delegation-b", host.getId())); + Room memberRoom = roomRepository.save(Room.create("C", null, + java.time.LocalDate.now(), java.time.LocalDate.now(), "inv-delegation-c", other.getId())); + + roomMemberRepository.save(RoomMember.create(hostOnly, host, RoomRole.HOST)); + roomMemberRepository.save(RoomMember.create(hostWithOther, host, RoomRole.HOST)); + roomMemberRepository.save(RoomMember.create(hostWithOther, other, RoomRole.MEMBER)); + roomMemberRepository.save(RoomMember.create(memberRoom, other, RoomRole.HOST)); + roomMemberRepository.save(RoomMember.create(memberRoom, host, RoomRole.MEMBER)); + em.flush(); + em.clear(); + + List result = + roomMemberRepository.findHostRoomsWithOtherActiveMembers(host.getId()); + + assertThat(result).extracting(RoomRequiringDelegationView::getRoomId) + .containsExactly(hostWithOther.getId()); + } + + @Test + @Transactional + @DisplayName("HOST이면서 다른 활성 멤버가 없는 방만 1인 방 목록에 포함된다 (PENDING만 있어도 포함)") + void findHostRoomsWithOnlySelf() { + User host = userRepository.save(User.ofGoogle("g1-solo", "h-solo@a.com", "h", null)); + User pendingUser = userRepository.save(User.ofGoogle("g2-solo", "p-solo@a.com", "p", null)); + + Room soloHost = roomRepository.save(Room.create("A", null, + java.time.LocalDate.now(), java.time.LocalDate.now(), "inv-solo-a", host.getId())); + Room hostWithPending = roomRepository.save(Room.create("B", null, + java.time.LocalDate.now(), java.time.LocalDate.now(), "inv-solo-b", host.getId())); + + roomMemberRepository.save(RoomMember.create(soloHost, host, RoomRole.HOST)); + roomMemberRepository.save(RoomMember.create(hostWithPending, host, RoomRole.HOST)); + roomMemberRepository.save(RoomMember.create(hostWithPending, pendingUser, RoomRole.PENDING)); + em.flush(); + em.clear(); + + List result = roomMemberRepository.findHostRoomsWithOnlySelf(host.getId()) + .stream().map(Room::getId).toList(); + + assertThat(result).containsExactlyInAnyOrder(soloHost.getId(), hostWithPending.getId()); + } + + @Test + @Transactional + @DisplayName("findAllByUser_Id는 역할 무관하게 사용자의 모든 room_members를 반환한다") + void findAllByUserId() { + User user = userRepository.save(User.ofGoogle("g1-all", "h-all@a.com", "h", null)); + Room r1 = roomRepository.save(Room.create("A", null, + java.time.LocalDate.now(), java.time.LocalDate.now(), "i-all-a", user.getId())); + Room r2 = roomRepository.save(Room.create("B", null, + java.time.LocalDate.now(), java.time.LocalDate.now(), "i-all-b", user.getId())); + roomMemberRepository.save(RoomMember.create(r1, user, RoomRole.HOST)); + roomMemberRepository.save(RoomMember.create(r2, user, RoomRole.PENDING)); + em.flush(); + em.clear(); + + List result = roomMemberRepository.findAllByUser_Id(user.getId()); + assertThat(result).hasSize(2); + } +} diff --git a/src/test/java/com/howaboutus/backend/user/UserWithdrawalIntegrationTest.java b/src/test/java/com/howaboutus/backend/user/UserWithdrawalIntegrationTest.java new file mode 100644 index 00000000..27fbc604 --- /dev/null +++ b/src/test/java/com/howaboutus/backend/user/UserWithdrawalIntegrationTest.java @@ -0,0 +1,132 @@ +package com.howaboutus.backend.user; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.LocalDate; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.support.TransactionTemplate; + +import com.howaboutus.backend.auth.service.RefreshTokenService; +import com.howaboutus.backend.common.error.CustomException; +import com.howaboutus.backend.common.error.ErrorCode; +import com.howaboutus.backend.rooms.entity.Room; +import com.howaboutus.backend.rooms.entity.RoomMember; +import com.howaboutus.backend.rooms.entity.RoomRole; +import com.howaboutus.backend.rooms.repository.RoomMemberRepository; +import com.howaboutus.backend.rooms.repository.RoomRepository; +import com.howaboutus.backend.support.BaseIntegrationTest; +import com.howaboutus.backend.user.entity.User; +import com.howaboutus.backend.user.exception.HostDelegationRequiredException; +import com.howaboutus.backend.user.repository.UserRepository; +import com.howaboutus.backend.user.service.UserService; +import com.howaboutus.backend.user.service.UserWithdrawalService; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; + +class UserWithdrawalIntegrationTest extends BaseIntegrationTest { + + @Autowired + private UserWithdrawalService withdrawalService; + + @Autowired + private UserRepository userRepository; + + @Autowired + private RoomRepository roomRepository; + + @Autowired + private RoomMemberRepository roomMemberRepository; + + @Autowired + private UserService userService; + + @Autowired + private RefreshTokenService refreshTokenService; + + @Autowired + private TransactionTemplate tx; + + @PersistenceContext + private EntityManager em; + + @Test + @DisplayName("정상 탈퇴: 1인 방 삭제, 잔여 멤버십 삭제, 익명화, RTK 즉시 삭제") + void happyPath() { + User user = userRepository.save(User.ofGoogle("g-withdraw-1", "withdraw1@a.com", "닉", null)); + User other = userRepository.save(User.ofGoogle("g-withdraw-2", "withdraw2@a.com", "오", null)); + + Room solo = roomRepository.save(room("Solo", "inv-withdraw-solo", user.getId())); + roomMemberRepository.save(RoomMember.create(solo, user, RoomRole.HOST)); + + Room memberRoom = roomRepository.save(room("MemberRoom", "inv-withdraw-member", other.getId())); + roomMemberRepository.save(RoomMember.create(memberRoom, other, RoomRole.HOST)); + roomMemberRepository.save(RoomMember.create(memberRoom, user, RoomRole.MEMBER)); + + String refreshToken = refreshTokenService.create(user.getId()); + clearPersistenceContext(); + + withdrawalService.withdraw(user.getId()); + em.clear(); + + assertThat(roomRepository.findById(solo.getId())).isEmpty(); + assertThat(roomMemberRepository.findByRoom_IdAndUser_Id(memberRoom.getId(), user.getId())).isEmpty(); + assertThat(userRepository.findById(user.getId())).isEmpty(); + assertThatThrownBy(() -> refreshTokenService.rotate(refreshToken)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.REFRESH_TOKEN_NOT_FOUND); + } + + @Test + @DisplayName("다인 HOST 방이 있으면 차단되고 어떤 변경도 일어나지 않는다") + void blocksAndRollsBack() { + User host = userRepository.save(User.ofGoogle("g-block-host", "block-host@a.com", "h", null)); + User other = userRepository.save(User.ofGoogle("g-block-other", "block-other@a.com", "o", null)); + Room room = roomRepository.save(room("Blocked", "inv-withdraw-block", host.getId())); + roomMemberRepository.save(RoomMember.create(room, host, RoomRole.HOST)); + roomMemberRepository.save(RoomMember.create(room, other, RoomRole.MEMBER)); + clearPersistenceContext(); + + assertThatThrownBy(() -> withdrawalService.withdraw(host.getId())) + .isInstanceOf(HostDelegationRequiredException.class); + + em.clear(); + Optional reread = userRepository.findById(host.getId()); + assertThat(reread).isPresent(); + assertThat(reread.get().isWithdrawn()).isFalse(); + assertThat(roomMemberRepository.findByRoom_IdAndUser_Id(room.getId(), host.getId())).isPresent(); + } + + @Test + @DisplayName("탈퇴자와 동일 (provider, providerId)로 재가입할 수 있다") + void reSignupWithSameProvider() { + User user = userRepository.save(User.ofGoogle("pid-resignup", "resignup@a.com", "닉", null)); + clearPersistenceContext(); + + tx.executeWithoutResult(status -> withdrawalService.withdraw(user.getId())); + em.clear(); + + User reborn = userService.getOrCreateGoogleUser( + "pid-resignup", "resignup@a.com", "재가입", null); + + assertThat(reborn.getId()).isNotEqualTo(user.getId()); + assertThat(userRepository.findByProviderAndProviderId("GOOGLE", "pid-resignup")) + .get() + .extracting(User::getId) + .isEqualTo(reborn.getId()); + } + + private Room room(String title, String inviteCode, Long createdBy) { + LocalDate today = LocalDate.now(); + return Room.create(title, null, today, today, inviteCode, createdBy); + } + + private void clearPersistenceContext() { + em.clear(); + } +} diff --git a/src/test/java/com/howaboutus/backend/user/controller/UserControllerTest.java b/src/test/java/com/howaboutus/backend/user/controller/UserControllerTest.java index ce67fb6b..cc45e8bd 100644 --- a/src/test/java/com/howaboutus/backend/user/controller/UserControllerTest.java +++ b/src/test/java/com/howaboutus/backend/user/controller/UserControllerTest.java @@ -15,11 +15,13 @@ import com.howaboutus.backend.auth.filter.JwtAuthenticationFilter; import com.howaboutus.backend.auth.service.JwtProvider; import com.howaboutus.backend.common.config.SecurityConfig; +import com.howaboutus.backend.common.config.properties.CookieProperties; import com.howaboutus.backend.common.error.CustomException; import com.howaboutus.backend.common.error.ErrorCode; import com.howaboutus.backend.common.error.GlobalExceptionHandler; import com.howaboutus.backend.common.security.JwtAuthenticationEntryPoint; import com.howaboutus.backend.user.service.UserService; +import com.howaboutus.backend.user.service.UserWithdrawalService; import com.howaboutus.backend.user.service.dto.UserResponse; import jakarta.servlet.http.Cookie; @@ -35,9 +37,15 @@ class UserControllerTest { @MockitoBean private UserService userService; + @MockitoBean + private UserWithdrawalService userWithdrawalService; + @MockitoBean private JwtProvider jwtProvider; + @MockitoBean + private CookieProperties cookieProperties; + @Test @DisplayName("인증된 사용자가 GET /users/me 요청 시 프로필을 반환한다") void returnsProfileForAuthenticatedUser() throws Exception { diff --git a/src/test/java/com/howaboutus/backend/user/controller/UserWithdrawalControllerTest.java b/src/test/java/com/howaboutus/backend/user/controller/UserWithdrawalControllerTest.java new file mode 100644 index 00000000..200dc77c --- /dev/null +++ b/src/test/java/com/howaboutus/backend/user/controller/UserWithdrawalControllerTest.java @@ -0,0 +1,95 @@ +package com.howaboutus.backend.user.controller; + +import static org.mockito.BDDMockito.doNothing; +import static org.mockito.BDDMockito.doThrow; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.cookie; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.List; +import java.util.UUID; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import com.howaboutus.backend.auth.filter.JwtAuthenticationFilter; +import com.howaboutus.backend.auth.service.JwtProvider; +import com.howaboutus.backend.common.config.SecurityConfig; +import com.howaboutus.backend.common.config.properties.CookieProperties; +import com.howaboutus.backend.common.error.GlobalExceptionHandler; +import com.howaboutus.backend.common.security.JwtAuthenticationEntryPoint; +import com.howaboutus.backend.user.exception.HostDelegationRequiredException; +import com.howaboutus.backend.user.service.UserService; +import com.howaboutus.backend.user.service.UserWithdrawalService; +import com.howaboutus.backend.user.service.dto.RoomRequiringDelegation; + +import jakarta.servlet.http.Cookie; + +@WebMvcTest(UserController.class) +@Import({SecurityConfig.class, JwtAuthenticationFilter.class, JwtAuthenticationEntryPoint.class, + GlobalExceptionHandler.class}) +class UserWithdrawalControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private UserService userService; + + @MockitoBean + private UserWithdrawalService userWithdrawalService; + + @MockitoBean + private JwtProvider jwtProvider; + + @MockitoBean + private CookieProperties cookieProperties; + + @Test + @DisplayName("DELETE /users/me 성공 시 204와 만료 쿠키를 반환한다") + void deleteMeSuccess() throws Exception { + given(jwtProvider.extractUserId("valid-jwt")).willReturn(1L); + doNothing().when(userWithdrawalService).withdraw(1L); + + mockMvc.perform(delete("/users/me") + .cookie(new Cookie("access_token", "valid-jwt"))) + .andExpect(status().isNoContent()) + .andExpect(cookie().maxAge("access_token", 0)) + .andExpect(cookie().path("access_token", "/")) + .andExpect(cookie().maxAge("refresh_token", 0)) + .andExpect(cookie().path("refresh_token", "/")); + } + + @Test + @DisplayName("DELETE /users/me 위임 필요 시 422와 roomsRequiringDelegation 본문을 반환한다") + void deleteMeBlocked() throws Exception { + UUID roomId = UUID.randomUUID(); + given(jwtProvider.extractUserId("valid-jwt")).willReturn(1L); + doThrow(new HostDelegationRequiredException( + List.of(new RoomRequiringDelegation(roomId, "Trip")))) + .when(userWithdrawalService).withdraw(1L); + + mockMvc.perform(delete("/users/me") + .cookie(new Cookie("access_token", "valid-jwt"))) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("WITHDRAWAL_REQUIRES_HOST_DELEGATION")) + .andExpect(jsonPath("$.message").value("방장 위임이 필요한 방이 있습니다")) + .andExpect(jsonPath("$.roomsRequiringDelegation[0].roomId") + .value(roomId.toString())) + .andExpect(jsonPath("$.roomsRequiringDelegation[0].title").value("Trip")); + } + + @Test + @DisplayName("DELETE /users/me 인증 없이 요청 시 401을 반환한다") + void deleteMeUnauthorized() throws Exception { + mockMvc.perform(delete("/users/me")) + .andExpect(status().isUnauthorized()); + } +} diff --git a/src/test/java/com/howaboutus/backend/user/entity/UserTest.java b/src/test/java/com/howaboutus/backend/user/entity/UserTest.java new file mode 100644 index 00000000..095dc0b4 --- /dev/null +++ b/src/test/java/com/howaboutus/backend/user/entity/UserTest.java @@ -0,0 +1,39 @@ +package com.howaboutus.backend.user.entity; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class UserTest { + + @Test + @DisplayName("anonymize는 개인정보 필드를 null로 비우고 deletedAt을 설정한다") + void anonymizeClearsPersonalFieldsAndSetsDeletedAt() { + User user = User.ofGoogle("pid", "a@a.com", "닉", "https://img/a.png"); + + assertThat(user.isWithdrawn()).isFalse(); + + user.anonymize(); + + assertThat(user.getEmail()).isNull(); + assertThat(user.getNickname()).isNull(); + assertThat(user.getProfileImageUrl()).isNull(); + assertThat(user.getProvider()).isNull(); + assertThat(user.getProviderId()).isNull(); + assertThat(user.getDeletedAt()).isNotNull(); + assertThat(user.isWithdrawn()).isTrue(); + } + + @Test + @DisplayName("이미 탈퇴된 user는 anonymize를 멱등하게 수용한다") + void anonymizeIsIdempotent() { + User user = User.ofGoogle("pid", "a@a.com", "닉", null); + user.anonymize(); + var firstDeletedAt = user.getDeletedAt(); + + user.anonymize(); + + assertThat(user.getDeletedAt()).isEqualTo(firstDeletedAt); + } +} diff --git a/src/test/java/com/howaboutus/backend/user/listener/UserWithdrawnTokenListenerTest.java b/src/test/java/com/howaboutus/backend/user/listener/UserWithdrawnTokenListenerTest.java new file mode 100644 index 00000000..9eaae370 --- /dev/null +++ b/src/test/java/com/howaboutus/backend/user/listener/UserWithdrawnTokenListenerTest.java @@ -0,0 +1,31 @@ +package com.howaboutus.backend.user.listener; + +import static org.mockito.Mockito.verify; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.howaboutus.backend.auth.service.RefreshTokenService; +import com.howaboutus.backend.user.event.UserWithdrawnEvent; + +@ExtendWith(MockitoExtension.class) +class UserWithdrawnTokenListenerTest { + + @Mock + private RefreshTokenService refreshTokenService; + + @InjectMocks + private UserWithdrawnTokenListener listener; + + @Test + @DisplayName("UserWithdrawnEvent 수신 시 해당 userId의 RTK를 일괄 폐기한다") + void invalidatesTokensOnEvent() { + listener.handle(new UserWithdrawnEvent(42L)); + + verify(refreshTokenService).invalidateAllForUser(42L); + } +} diff --git a/src/test/java/com/howaboutus/backend/user/service/UserWithdrawalServiceTest.java b/src/test/java/com/howaboutus/backend/user/service/UserWithdrawalServiceTest.java new file mode 100644 index 00000000..c8bd6e2f --- /dev/null +++ b/src/test/java/com/howaboutus/backend/user/service/UserWithdrawalServiceTest.java @@ -0,0 +1,157 @@ +package com.howaboutus.backend.user.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.test.util.ReflectionTestUtils; + +import com.howaboutus.backend.common.error.CustomException; +import com.howaboutus.backend.common.error.ErrorCode; +import com.howaboutus.backend.realtime.event.MemberLeftEvent; +import com.howaboutus.backend.realtime.event.RoomDeletedEvent; +import com.howaboutus.backend.rooms.entity.Room; +import com.howaboutus.backend.rooms.entity.RoomMember; +import com.howaboutus.backend.rooms.entity.RoomRole; +import com.howaboutus.backend.rooms.repository.RoomMemberRepository; +import com.howaboutus.backend.rooms.repository.RoomMemberRepository.RoomRequiringDelegationView; +import com.howaboutus.backend.rooms.repository.RoomRepository; +import com.howaboutus.backend.user.entity.User; +import com.howaboutus.backend.user.event.UserWithdrawnEvent; +import com.howaboutus.backend.user.exception.HostDelegationRequiredException; +import com.howaboutus.backend.user.repository.UserRepository; + +@ExtendWith(MockitoExtension.class) +class UserWithdrawalServiceTest { + + private static final Long USER_ID = 100L; + + @Mock + private UserRepository userRepository; + @Mock + private RoomRepository roomRepository; + @Mock + private RoomMemberRepository roomMemberRepository; + @Mock + private ApplicationEventPublisher eventPublisher; + + private UserWithdrawalService service; + private User user; + + @BeforeEach + void setUp() { + service = new UserWithdrawalService( + userRepository, roomRepository, roomMemberRepository, eventPublisher); + user = User.ofGoogle("g", "u@a.com", "닉", "https://img/me.png"); + ReflectionTestUtils.setField(user, "id", USER_ID); + } + + @Test + @DisplayName("위임 필요 방이 있으면 HostDelegationRequiredException으로 차단되고 mutation이 없다") + void blocksWithoutMutationsWhenDelegationRequired() { + given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); + UUID roomId = UUID.randomUUID(); + given(roomMemberRepository.findHostRoomsWithOtherActiveMembers(USER_ID)) + .willReturn(List.of(view(roomId, "Trip"))); + + assertThatThrownBy(() -> service.withdraw(USER_ID)) + .isInstanceOf(HostDelegationRequiredException.class) + .satisfies(exception -> { + HostDelegationRequiredException delegationException = + (HostDelegationRequiredException) exception; + assertThat(delegationException.getRoomsRequiringDelegation()).hasSize(1); + assertThat(delegationException.getRoomsRequiringDelegation().get(0).roomId()).isEqualTo(roomId); + }); + + verify(roomRepository, never()).delete(any()); + verify(roomMemberRepository, never()).delete(any()); + assertThat(user.isWithdrawn()).isFalse(); + } + + @Test + @DisplayName("정상 탈퇴는 1인 방 삭제, 잔여 멤버십 삭제, 익명화, 이벤트 발행을 수행한다") + void successfulWithdrawal() { + given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); + Room solo = Room.create("Solo", null, null, null, "i-solo", USER_ID); + ReflectionTestUtils.setField(solo, "id", UUID.randomUUID()); + + Room memberRoom = Room.create("MemberRoom", null, null, null, "i-mr", 999L); + ReflectionTestUtils.setField(memberRoom, "id", UUID.randomUUID()); + + RoomMember memberOfRoom = RoomMember.create(memberRoom, user, RoomRole.MEMBER); + RoomMember pendingMembership = RoomMember.create(memberRoom, user, RoomRole.PENDING); + + given(roomMemberRepository.findHostRoomsWithOtherActiveMembers(USER_ID)) + .willReturn(List.of()); + given(roomMemberRepository.findHostRoomsWithOnlySelf(USER_ID)) + .willReturn(List.of(solo)); + given(roomMemberRepository.findAllByUser_Id(USER_ID)) + .willReturn(List.of(memberOfRoom, pendingMembership)); + + service.withdraw(USER_ID); + + verify(roomRepository).delete(solo); + verify(eventPublisher).publishEvent(any(RoomDeletedEvent.class)); + verify(roomMemberRepository, times(2)).delete(any(RoomMember.class)); + verify(eventPublisher).publishEvent(any(MemberLeftEvent.class)); // MEMBER 1건만 + verify(eventPublisher).publishEvent(any(UserWithdrawnEvent.class)); + assertThat(user.isWithdrawn()).isTrue(); + assertThat(user.getEmail()).isNull(); + } + + @Test + @DisplayName("최종 더블체크에서 위임 필요 방이 검출되면 예외로 롤백 시그널을 보낸다") + void doubleCheckRollback() { + given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); + UUID lateRoomId = UUID.randomUUID(); + given(roomMemberRepository.findHostRoomsWithOtherActiveMembers(USER_ID)) + .willReturn(List.of()) + .willReturn(List.of(view(lateRoomId, "Late"))); + given(roomMemberRepository.findHostRoomsWithOnlySelf(USER_ID)).willReturn(List.of()); + given(roomMemberRepository.findAllByUser_Id(USER_ID)).willReturn(List.of()); + + assertThatThrownBy(() -> service.withdraw(USER_ID)) + .isInstanceOf(HostDelegationRequiredException.class); + + assertThat(user.isWithdrawn()).isFalse(); + } + + @Test + @DisplayName("user를 찾을 수 없으면 USER_NOT_FOUND") + void userNotFound() { + given(userRepository.findById(USER_ID)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> service.withdraw(USER_ID)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.USER_NOT_FOUND); + } + + private RoomRequiringDelegationView view(UUID roomId, String title) { + return new RoomRequiringDelegationView() { + @Override + public UUID getRoomId() { + return roomId; + } + + @Override + public String getTitle() { + return title; + } + }; + } +} From acd8a9ae85cbd2e1a100856435dcc96d09f4bc7a Mon Sep 17 00:00:00 2001 From: PARK JU YEONG <96644508+parkjuyeong0312@users.noreply.github.com> Date: Mon, 8 Jun 2026 11:28:13 +0900 Subject: [PATCH 3/9] =?UTF-8?q?feat:=20room=5Fmembers=20=EC=97=90=20LEFT?= =?UTF-8?q?=20=EC=83=81=ED=83=9C=20=EB=8F=84=EC=9E=85=20(=EB=B0=A9=20?= =?UTF-8?q?=EB=82=98=EA=B0=80=EA=B8=B0/=EC=B6=94=EB=B0=A9=20=EC=8B=9C=20ro?= =?UTF-8?q?w=20=EC=9C=A0=EC=A7=80)=20(#134)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: SYSTEM 메시지 metadata 기반 렌더링 계약 명문화 탈퇴 사용자의 SYSTEM 메시지 본문이 옛 닉네임으로 굳는 비일관 문제를 백엔드 데이터 변경 없이 클라이언트 재렌더링으로 해결한다는 결정을 ADR로 기록하고, MessagePayload와 MessageResponse의 content/metadata Schema 설명을 보강해 프론트가 metadata.eventType + userId 기반으로 본문을 조립한다는 계약을 Swagger/Springwolf 명세에 노출한다. * docs: ERD/spec의 TIMESTAMP 표기를 실제 DDL에 맞춰 TIMESTAMP WITH TIME ZONE으로 통일 모든 마이그레이션이 TIMESTAMP WITH TIME ZONE으로 컬럼을 생성하는데 ERD와 user-withdrawal spec은 짧은 TIMESTAMP로 표기되어 있어 운영/개발 해석에 혼선이 생길 수 있었다. erd.md 16개 라인과 spec의 ALTER 예시를 실제 DDL과 1:1로 정렬한다. * fix: 회원 탈퇴와 입장 승인 사이의 race 차단 호스트 본인이 PENDING 입장 승인과 본인 탈퇴를 동시에 발사하면 READ COMMITTED 스냅샷 차이로 솔로 호스트 방 판단 → HOST 멤버십만 삭제 → 호스트 없는 활성 방(orphan room)이 남는 시나리오가 가능했다. withdraw 트랜잭션 시작 시 본인이 HOST인 모든 Room을 PESSIMISTIC_WRITE로 잠그고, approve 트랜잭션 시작 시에도 Room을 findByIdForUpdate로 잡아 두 트랜잭션이 같은 Room 행을 두고 직렬화되도록 한다. * fix: 탈퇴 시 익명화 누락을 DB CHECK로 강제 + DROP CONSTRAINT 방어 기존 users_active_required CHECK는 OR 구조라 탈퇴 회원(deleted_at IS NOT NULL)에 대한 4개 개인정보 컬럼(email, nickname, provider, provider_id)의 NULL 강제가 없어, User.anonymize() 누락이나 운영자의 수동 SQL로 익명화 안 된 탈퇴 행이 생기는 경로를 DB가 막아주지 못했다. CHECK를 양방향(iff) 구조로 교체해 "활성 ⇔ 4개 컬럼 모두 NOT NULL" / "탈퇴 ⇔ 4개 컬럼 모두 NULL"을 강제한다. 같은 V1.7의 기존 UNIQUE 제약 DROP은 환경별 이름 차이/핫픽스 잔재로 실패할 수 있어 DROP CONSTRAINT IF EXISTS로 방어한다. V1.7이 아직 main에 머지되지 않아 새 V1.8 대신 V1.7 자체를 수정한다. ERD/spec/결정 기록도 동일 정책으로 동기화. * docs : roomMember entity에 대해 status필드 추가 설계/기획 문서 작성 * feat: V1.8 마이그레이션으로 room_members status/left_at 도입 * feat: RoomMember에 MemberStatus(LEFT) + 도메인 메서드 추가 * feat: requireActiveMember에서 LEFT 멤버를 비멤버로 차단 * refactor: leave가 hard delete 대신 status=LEFT 전환을 수행 * refactor: kick이 hard delete 대신 status=LEFT 전환을 수행 * refactor: 활성 멤버 쿼리에 status=ACTIVE 필터 일괄 추가 * feat: 멤버 응답 DTO에 status 필드 노출 * feat: getMembers가 LEFT 멤버까지 노출하고 online=false 강제 * feat: requestJoin이 LEFT row를 PENDING으로 부활시키도록 지원 * fix: AI 컨텍스트 조회에서 LEFT 멤버 제외 * test: V1.8 CHECK 제약(LEFT/left_at/PENDING) 회귀 테스트 추가 * style: V1.8 CHECK 테스트 SQL 줄바꿈으로 line-length-120 위반 해소 * test: 탈퇴 시 LEFT 멤버십도 hard delete 되는지 회귀 테스트 추가 * docs: room_members status/left_at 도입을 features/erd에 반영 * refactor : 리뷰내용 반영(방나가기 중복 이벤트 차단, 방나감 에러코드 설정, 테스트추가 --- ...-1636-system-message-metadata-rendering.md | 50 + .../20260607-user-withdrawal-soft-delete.md | 4 +- docs/ai/erd.md | 43 +- docs/ai/features.md | 8 +- .../2026-06-08-room-member-left-status.md | 1407 +++++++++++++++++ .../2026-06-07-user-withdrawal-design.md | 20 +- ...26-06-08-room-member-left-status-design.md | 269 ++++ .../AiContextQueryRepositoryImpl.java | 6 +- .../controller/dto/MessageResponse.java | 11 + .../realtime/service/dto/MessagePayload.java | 11 + .../controller/dto/RoomMemberResponse.java | 3 + .../backend/rooms/entity/MemberStatus.java | 5 + .../backend/rooms/entity/RoomMember.java | 42 + .../repository/RoomMemberRepository.java | 62 +- .../rooms/repository/RoomRepository.java | 13 + .../service/RoomAuthorizationService.java | 4 + .../rooms/service/RoomInviteService.java | 21 +- .../rooms/service/RoomMemberService.java | 35 +- .../rooms/service/dto/RoomMemberResult.java | 2 + .../user/service/UserWithdrawalService.java | 7 +- .../db/migration/V1.7__users_withdrawal.sql | 23 +- .../migration/V1.8__room_members_status.sql | 33 + .../rooms/controller/RoomControllerTest.java | 8 +- .../backend/rooms/entity/RoomMemberTest.java | 83 + .../RoomMemberStatusConstraintTest.java | 110 ++ .../service/RoomAuthorizationServiceTest.java | 37 + .../rooms/service/RoomInviteServiceTest.java | 55 +- .../rooms/service/RoomMemberServiceTest.java | 132 +- .../service/UserWithdrawalServiceTest.java | 50 + 29 files changed, 2481 insertions(+), 73 deletions(-) create mode 100644 docs/ai/decisions/20260607-1636-system-message-metadata-rendering.md create mode 100644 docs/superpowers/plans/2026-06-08-room-member-left-status.md create mode 100644 docs/superpowers/specs/2026-06-08-room-member-left-status-design.md create mode 100644 src/main/java/com/howaboutus/backend/rooms/entity/MemberStatus.java create mode 100644 src/main/resources/db/migration/V1.8__room_members_status.sql create mode 100644 src/test/java/com/howaboutus/backend/rooms/repository/RoomMemberStatusConstraintTest.java diff --git a/docs/ai/decisions/20260607-1636-system-message-metadata-rendering.md b/docs/ai/decisions/20260607-1636-system-message-metadata-rendering.md new file mode 100644 index 00000000..69a34bc6 --- /dev/null +++ b/docs/ai/decisions/20260607-1636-system-message-metadata-rendering.md @@ -0,0 +1,50 @@ +# SYSTEM 메시지는 metadata 기반 클라이언트 렌더링으로 통일 + +- **상태**: 결정 +- **날짜**: 2026-06-07 +- **관련**: [20260607-user-withdrawal-soft-delete.md](20260607-user-withdrawal-soft-delete.md) + +## 배경 + +SYSTEM 메시지(`MEMBER_JOINED`, `MEMBER_LEFT`, `MEMBER_KICKED`, `HOST_DELEGATED`)는 MongoDB `messages` 컬렉션에 사람 읽기 가능한 문장으로 `content`가 저장된다. 예: `"박주영님이 방을 나갔습니다"`. + +회원 탈퇴(soft delete)가 도입되면서, 탈퇴한 사용자의 일반 채팅 메시지는 프론트가 방 멤버 맵에 senderId가 없는 것으로 판단해 `(알 수 없음)`으로 잘 렌더링하고 있다. 그러나 SYSTEM 메시지는 `msg.content`를 그대로 노출하기 때문에 탈퇴 이후에도 옛 닉네임이 굳은 채로 표시된다. 동일 사용자가 같은 화면에서 채팅은 `(알 수 없음)`, 시스템 메시지는 `박주영`으로 보이는 비일관 상태가 발생한다. + +가능한 해결 방향은 세 가지였다. + +1. **클라이언트가 metadata로 재렌더링**: 서버는 변경 없음. 클라가 `metadata.eventType + userId`를 보고 멤버 맵을 lookup해 문구를 조립. +2. **서버가 조회 시점에 content 재구성**: `MessageResult` 변환 시 userId로 현재 사용자 상태 조회 후 content를 갱신. 페이지 조회마다 user batch lookup 필요, N+1·캐시 부담. +3. **탈퇴 이벤트에서 일괄 update**: 탈퇴 핸들러가 해당 userId가 포함된 SYSTEM 메시지를 한 번에 갱신. 조회 비용 0, 그러나 MongoDB write 부담과 닉네임 변경 같은 다른 케이스를 흡수하지 못함. + +이미 `SystemMessageService`가 모든 이벤트에 `eventType`, `userId`(+ `previousHostUserId`/`newHostUserId`), `nickname`을 metadata로 함께 저장하고 있고, 프론트(`src/lib/chat.ts`)에는 일반 채팅용 `memberMap` 기반 `(알 수 없음)` 처리 로직이 이미 있다. + +## 결정 + +**SYSTEM 메시지의 화면 표시 문구는 클라이언트가 `metadata` 기반으로 조립한다.** 백엔드는 저장 구조를 그대로 유지하고, metadata payload를 클라이언트 재렌더링 계약으로 공식화한다. + +- 백엔드: `ChatMessage.content`와 `SystemMessageService` 로직은 변경하지 않는다. `content`는 저장 시점 닉네임이 박힌 fallback 문장이며, legacy 클라이언트와 디버깅용으로 의미가 있다. +- 백엔드: `MessagePayload`(STOMP)와 `MessageResponse`(REST)의 `content`/`metadata` 필드 `@Schema`에 위 계약을 명시한다. +- 프론트엔드: `toUiMessage`의 `kind === "SYSTEM"` 분기에서 `metadata.eventType`별 템플릿을 적용한다. userId를 `memberMap`에서 lookup하여 닉네임을 결정하고, 없으면 `(알 수 없음)`. eventType이 미지/누락이면 `msg.content`를 그대로 사용. + +이벤트별 템플릿(프론트엔드 가이드): + +| eventType | metadata lookup | 화면 표시 | +|---|---|---| +| `MEMBER_JOINED` | `userId` | `{nick}님이 방에 참여했습니다` | +| `MEMBER_KICKED` | `userId` | `{nick}님이 방에서 내보내졌습니다` | +| `MEMBER_LEFT` | `userId` | `{nick}님이 방을 나갔습니다` | +| `HOST_DELEGATED` | `previousHostUserId`, `newHostUserId` | `{prevNick}님이 {newNick}님에게 방장을 위임했습니다` | + +`{nick}` 결정 순서: + +1. `memberMap.get(userId)?.nickname` 사용 +2. memberMap에 없으면 `(알 수 없음)` (탈퇴·방 나감 공통) +3. metadata가 비정상이거나 eventType이 알 수 없는 값이면 `msg.content`를 그대로 표시 + +## 영향 + +- 백엔드 데이터 변경 없음. MongoDB 마이그레이션 없음. +- `MessagePayload`, `MessageResponse`의 `@Schema` 설명 갱신 → Swagger/Springwolf 명세에 반영. +- 프론트엔드는 별도 작업으로 `toUiMessage`의 SYSTEM 분기를 metadata 기반 렌더링으로 교체한다. +- 향후 닉네임 변경 기능이 도입되어도 동일 계약으로 자동 흡수된다. +- 다국어가 필요해질 때 템플릿이 클라이언트에 있으므로 서버 변경 없이 대응 가능하다. diff --git a/docs/ai/decisions/20260607-user-withdrawal-soft-delete.md b/docs/ai/decisions/20260607-user-withdrawal-soft-delete.md index c1f558f9..e0f8228e 100644 --- a/docs/ai/decisions/20260607-user-withdrawal-soft-delete.md +++ b/docs/ai/decisions/20260607-user-withdrawal-soft-delete.md @@ -13,9 +13,9 @@ `users`를 soft delete 모델로 운영한다. - 활성 회원: `email`, `nickname`, `provider`, `provider_id`는 NOT NULL. -- 탈퇴 회원: 위 컬럼들 NULL 허용. `deleted_at`이 NOT NULL. +- 탈퇴 회원: 위 4개 컬럼 모두 NULL 강제. `deleted_at`이 NOT NULL. - 활성 회원에 한해 unique 강제: PostgreSQL partial unique index (`WHERE deleted_at IS NULL`). -- 조건부 NOT NULL은 CHECK `users_active_required`로 보장. +- 활성/탈퇴 상태와 4개 개인정보 컬럼의 양방향 정합은 CHECK `users_active_required`로 보장. 어플리케이션 `User.anonymize()` 누락 또는 운영자의 수동 SQL로 익명화 안 된 탈퇴 행이 생기는 경로를 DB 레벨에서 차단한다. - 엔티티에 `@SQLRestriction("deleted_at IS NULL")`을 적용해 JPA 조회에서 탈퇴자를 자동 제외. ## 대안 검토 diff --git a/docs/ai/erd.md b/docs/ai/erd.md index 3b0ec66e..5cc34dfa 100644 --- a/docs/ai/erd.md +++ b/docs/ai/erd.md @@ -19,12 +19,12 @@ Google OAuth 기반 사용자 정보 | profile_image_url | VARCHAR(500) | NULLABLE | 프로필 이미지 URL | | provider | VARCHAR(20) | 활성 회원 NOT NULL | OAuth 제공자. 탈퇴 시 NULL | | provider_id | VARCHAR(255) | 활성 회원 NOT NULL, 활성 회원 간 provider와 조합 UNIQUE | OAuth 제공자 측 사용자 ID. 탈퇴 시 NULL | -| deleted_at | TIMESTAMP | NULLABLE | 탈퇴 시각. NOT NULL이면 익명화된 탈퇴 회원 | -| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 가입일시 | -| updated_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 수정일시 | +| deleted_at | TIMESTAMP WITH TIME ZONE | NULLABLE | 탈퇴 시각. NOT NULL이면 익명화된 탈퇴 회원 | +| created_at | TIMESTAMP WITH TIME ZONE | NOT NULL, DEFAULT NOW() | 가입일시 | +| updated_at | TIMESTAMP WITH TIME ZONE | NOT NULL, DEFAULT NOW() | 수정일시 | **제약:** -- CHECK `users_active_required`: `deleted_at IS NOT NULL OR (email IS NOT NULL AND nickname IS NOT NULL AND provider IS NOT NULL AND provider_id IS NOT NULL)` +- CHECK `users_active_required`: `(deleted_at IS NULL AND email IS NOT NULL AND nickname IS NOT NULL AND provider IS NOT NULL AND provider_id IS NOT NULL) OR (deleted_at IS NOT NULL AND email IS NULL AND nickname IS NULL AND provider IS NULL AND provider_id IS NULL)` — 활성/탈퇴 상태와 4개 개인정보 컬럼의 양방향 정합을 강제 (탈퇴 시 익명화 누락 방지) - partial unique index `users_email_unique_active`: `email` WHERE `deleted_at IS NULL` - partial unique index `users_provider_provider_id_unique_active`: `(provider, provider_id)` WHERE `deleted_at IS NULL` @@ -47,8 +47,8 @@ Google OAuth 기반 사용자 정보 | end_date | DATE | NOT NULL | 여행 종료일 | | invite_code | VARCHAR(50) | UNIQUE, NOT NULL | 초대 링크용 고정 코드 (방 생성 시 자동 발급) | | created_by | BIGINT | 사용자 ID 참조, NOT NULL | 방 생성자 (현재 구현은 users.id 값을 보관하지만 DB FK 제약은 두지 않음) | -| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 생성일시 | -| updated_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 수정일시 | +| created_at | TIMESTAMP WITH TIME ZONE | NOT NULL, DEFAULT NOW() | 생성일시 | +| updated_at | TIMESTAMP WITH TIME ZONE | NOT NULL, DEFAULT NOW() | 수정일시 | > 방 1개당 초대 코드 1개가 고정됩니다. 링크 유출 시 방장이 invite_code를 재발급(갱신)하는 방식으로 대응합니다. 만료 시간, 사용 횟수 제한 등 세밀한 초대 관리가 필요해지면 별도 room_invitations 테이블로 분리를 검토합니다. @@ -64,14 +64,19 @@ Google OAuth 기반 사용자 정보 | room_id | UUID | FK → rooms.id, NOT NULL | | | user_id | BIGINT | FK → users.id, NOT NULL | | | role | VARCHAR(20) | NOT NULL | HOST / MEMBER / PENDING (DEFAULT 없이 명시적 지정) | -| joined_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 참여 일시 | +| joined_at | TIMESTAMP WITH TIME ZONE | NOT NULL, DEFAULT NOW() | 참여 일시 | +| status | VARCHAR(20) | NOT NULL, DEFAULT 'ACTIVE' | ACTIVE / LEFT (방 나가기·추방 시 LEFT, row는 유지하여 과거 메시지 작성자 노출용으로 사용) | +| left_at | TIMESTAMP WITH TIME ZONE | NULLABLE | LEFT 진입 시각. ACTIVE면 NULL. 재입장 UPSERT 시 NULL로 복귀 | | last_read_message_id | VARCHAR(24) | NULLABLE | 마지막으로 읽은 MongoDB 메시지 `_id` | -| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 생성일시 | -| updated_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 수정일시 | +| created_at | TIMESTAMP WITH TIME ZONE | NOT NULL, DEFAULT NOW() | 생성일시 | +| updated_at | TIMESTAMP WITH TIME ZONE | NOT NULL, DEFAULT NOW() | 수정일시 | -**제약:** UNIQUE(room_id, user_id) +**제약:** UNIQUE(room_id, user_id), +CHECK `ck_room_members_status` (status IN ('ACTIVE','LEFT')), +CHECK `ck_room_members_status_left_at` ((status='ACTIVE' AND left_at IS NULL) OR (status='LEFT' AND left_at IS NOT NULL)), +CHECK `ck_room_members_left_role` (status='ACTIVE' OR role <> 'PENDING') -**인덱스:** UNIQUE(room_id, user_id) 제약 인덱스, (user_id, joined_at DESC) — 내 방 목록 조회 및 커서 정렬용 +**인덱스:** UNIQUE(room_id, user_id) 제약 인덱스, (user_id, joined_at DESC) — 내 방 목록 조회 및 커서 정렬용, (room_id, status) — 멤버 목록 조회 및 LEFT 룩업용 --- @@ -130,8 +135,8 @@ AI_RESPONSE metadata는 `requestMessageId`, `intent`, `placeRecommendation`, `co | name | VARCHAR(50) | NOT NULL | 방 내 카테고리 이름 | | color_code | VARCHAR(7) | NOT NULL | 카테고리 색상 코드 (`#RRGGBB`) | | created_by | BIGINT | 사용자 ID 참조, NULL 가능 | 생성한 사용자 (현재는 인증 연동 전이라 임시로 nullable) | -| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 생성일시 | -| updated_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 수정일시 | +| created_at | TIMESTAMP WITH TIME ZONE | NOT NULL, DEFAULT NOW() | 생성일시 | +| updated_at | TIMESTAMP WITH TIME ZONE | NOT NULL, DEFAULT NOW() | 수정일시 | **제약:** UNIQUE(room_id, name) @@ -152,8 +157,8 @@ AI_RESPONSE metadata는 `requestMessageId`, `intent`, `placeRecommendation`, `co | category_id | BIGINT | FK → bookmark_categories.id, NOT NULL | 현재 방 소속 카테고리 | | google_place_id | VARCHAR(300) | NOT NULL | Google Place ID | | added_by | BIGINT | 사용자 ID 참조, NULL 가능 | 등록한 사용자 (현재는 인증 연동 전이라 임시로 nullable) | -| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 생성일시 | -| updated_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 수정일시 | +| created_at | TIMESTAMP WITH TIME ZONE | NOT NULL, DEFAULT NOW() | 생성일시 | +| updated_at | TIMESTAMP WITH TIME ZONE | NOT NULL, DEFAULT NOW() | 수정일시 | **제약:** UNIQUE(room_id, google_place_id, category_id) @@ -170,8 +175,8 @@ AI_RESPONSE metadata는 `requestMessageId`, `intent`, `placeRecommendation`, `co | id | BIGINT | PK, AUTO_INCREMENT | | | room_id | UUID | FK → rooms.id, NOT NULL | | | day_number | INT | NOT NULL | 여행 N일차 (1부터 시작). 응답 날짜는 `rooms.start_date + day_number - 1`로 계산 | -| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 생성일시 | -| updated_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 수정일시 | +| created_at | TIMESTAMP WITH TIME ZONE | NOT NULL, DEFAULT NOW() | 생성일시 | +| updated_at | TIMESTAMP WITH TIME ZONE | NOT NULL, DEFAULT NOW() | 수정일시 | **제약:** UNIQUE(room_id, day_number) DEFERRABLE INITIALLY IMMEDIATE @@ -192,8 +197,8 @@ AI_RESPONSE metadata는 `requestMessageId`, `intent`, `placeRecommendation`, `co | duration_minutes | INT | NULLABLE | 체류 시간(분). 값이 있으면 0~1000 | | order_index | INT | NOT NULL | 정렬 순서 (목록 조회 시 0부터 연속 유지) | | memo | TEXT | NULLABLE | 방문 메모 | -| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 생성일시 | -| updated_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 수정일시 | +| created_at | TIMESTAMP WITH TIME ZONE | NOT NULL, DEFAULT NOW() | 생성일시 | +| updated_at | TIMESTAMP WITH TIME ZONE | NOT NULL, DEFAULT NOW() | 수정일시 | **인덱스:** (schedule_id, order_index), (google_place_id) diff --git a/docs/ai/features.md b/docs/ai/features.md index f603f3ae..467fdf4f 100644 --- a/docs/ai/features.md +++ b/docs/ai/features.md @@ -54,7 +54,7 @@ Caddy는 `caddy-ratelimit` 기반 IP 제한으로 엣지에서 넓고 거친 폭 | `[x]` | 토큰 재발급 (Refresh) | Refresh Token Rotation: UUID 기반 HTTP-only 쿠키(path=/auth/refresh), Redis `refresh:token:{uuid}`→userId(TTL 14일) / `refresh:user:{userId}`→Set\. Replay Detection 으로 탈취 시 전체 무효화 | Redis | | `[x]` | 로그아웃 | 단일 기기 로그아웃: 요청한 토큰만 삭제 | Redis | | `[x]` | 내 정보 조회 | 로그인된 사용자 프로필 조회 | users | -| `[x]` | 회원 탈퇴 | `DELETE /users/me`. users는 soft delete + 익명화(email/nickname/profile/provider/provider_id NULL, deleted_at 설정), room_members는 hard delete. HOST 방은 사전 위임 필요(422 + roomsRequiringDelegation), 1인 HOST 방은 자동 hard delete. Redis RTK는 AFTER_COMMIT에서 일괄 폐기. 채팅/북마크/일정의 BIGINT 작성자 ID는 유지하며 클라이언트는 members에 없는 ID를 "(알 수 없음)"으로 표시 | users, room_members, Redis | +| `[x]` | 회원 탈퇴 | `DELETE /users/me`. users는 soft delete + 익명화(email/nickname/profile/provider/provider_id NULL, deleted_at 설정), room_members는 ACTIVE/LEFT 무관하게 hard delete. HOST 방은 사전 위임 필요(422 + roomsRequiringDelegation), 1인 HOST 방은 자동 hard delete. Redis RTK는 AFTER_COMMIT에서 일괄 폐기. 채팅/북마크/일정의 BIGINT 작성자 ID는 유지. 클라이언트는 `GET /rooms/{id}/members` 응답(ACTIVE + LEFT 모두 포함)에서 닉네임/프로필을 조회하고, 그래도 members에 없는 ID는 **회원 탈퇴자**이며 "(알 수 없음)"으로 표시 | users, room_members, Redis | --- @@ -80,9 +80,9 @@ Caddy는 `caddy-ratelimit` 기반 IP 제한으로 엣지에서 넓고 거친 폭 | 상태 | 기능 | 설명 | ERD 연관 | |------|------|------|----------| -| `[x]` | 방 멤버 목록 조회 | 방 참여자 목록 + 역할(HOST/MEMBER) + 접속 상태 | room_members | -| `[x]` | 멤버 추방 | HOST가 특정 멤버 추방 (HOST는 추방 불가). 추방 당사자에게 `/user/queue/rooms`로 개인 알림 전송 | room_members | -| `[x]` | 방 나가기 | 본인이 방에서 탈퇴 | room_members | +| `[x]` | 방 멤버 목록 조회 | 방 참여자 목록 + 역할(HOST/MEMBER) + 상태(ACTIVE/LEFT) + 접속 상태. LEFT 멤버는 닉네임/프로필 조회용으로 함께 반환되며 `online`은 항상 false. AI 컨텍스트에서는 LEFT 멤버 제외 | room_members | +| `[x]` | 멤버 추방 | HOST가 특정 멤버 추방 (HOST는 추방 불가). row는 status=LEFT 로 유지(과거 메시지 작성자 노출), 추방 당사자에게 `/user/queue/rooms`로 개인 알림 전송 | room_members | +| `[x]` | 방 나가기 | 본인이 방에서 탈퇴(room_members status=LEFT, row 유지). 채팅 로그(USER_LEFT 시스템 메시지)가 시점의 정본. LEFT 상태에서 invite code 재요청 시 같은 row가 ACTIVE/PENDING 으로 부활 | room_members | | `[x]` | 실시간 방 접속 상태 추적 | 유효한 access_token 쿠키가 있는 사용자만 WebSocket handshake를 허용한다. SockJS + STOMP 방 topic 구독 성공 시 Redis에 접속 유저를 기록하고 접속 이벤트를 브로드캐스트한다. 새로 온라인이 된 유저의 접속 이벤트에는 `userId`, `nickname`, `profileImageUrl`을 포함해 클라이언트가 방 멤버 프로필 맵을 갱신할 수 있게 한다. 세션 종료 시 제거와 해제 이벤트를 브로드캐스트한다 | Redis (connected_users) | | `[x]` | 현재 접속 중인 유저 조회 | 멤버 목록 API(`GET /rooms/{roomId}/members`)의 `isOnline` 필드로 접속 상태 포함 | Redis (connected_users) | | `[x]` | 방장 위임 | HOST가 특정 MEMBER에게 방장 권한 위임 (PATCH /rooms/{roomId}/host) | room_members | diff --git a/docs/superpowers/plans/2026-06-08-room-member-left-status.md b/docs/superpowers/plans/2026-06-08-room-member-left-status.md new file mode 100644 index 00000000..1c88fb87 --- /dev/null +++ b/docs/superpowers/plans/2026-06-08-room-member-left-status.md @@ -0,0 +1,1407 @@ +# Room Member LEFT 상태 구현 플랜 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** `room_members`에 `status (ACTIVE/LEFT)` + `left_at` 컬럼을 도입하여 방 나가기/추방을 row 유지 + status=LEFT로 표현하고, 회원 탈퇴는 hard delete를 유지한다. 클라이언트는 `GET /rooms/{id}/members` 한 번으로 ACTIVE + LEFT 멤버 정보를 받아 과거 메시지의 닉네임/프로필을 렌더링한다. + +**Architecture:** PostgreSQL 컬럼/CHECK 제약 + 인덱스를 추가하고, JPA 엔티티에 `MemberStatus` enum과 도메인 메서드(`leave`/`kicked`/`rejoinAsPending`)를 추가한다. 권한 게이트(`requireActiveMember`)와 모든 활성 멤버 조회 쿼리에 `status='ACTIVE'` 필터를 추가하여 LEFT 멤버를 비활성으로 취급한다. 멤버 목록 전용 쿼리 1개를 신설해 ACTIVE(HOST/MEMBER) + LEFT 둘 다 반환한다. UPSERT 부활은 같은 row의 status/role/leftAt만 변경. + +**Tech Stack:** Spring Boot 4.0.5, Java 21, PostgreSQL 17 + Flyway, JPA(Hibernate), JUnit 5 + Mockito + AssertJ. + +**참조 스펙:** `docs/superpowers/specs/2026-06-08-room-member-left-status-design.md` + +--- + +## File Structure + +**신규 파일** +- `src/main/resources/db/migration/V1.8__room_members_status.sql` +- `src/main/java/com/howaboutus/backend/rooms/entity/MemberStatus.java` +- `src/test/java/com/howaboutus/backend/rooms/repository/RoomMemberStatusConstraintTest.java` + +**수정 파일** +- `src/main/java/com/howaboutus/backend/rooms/entity/RoomMember.java` — 필드 2개 + 메서드 3개 + 기존 메서드 가드 강화 +- `src/main/java/com/howaboutus/backend/rooms/repository/RoomMemberRepository.java` — 6개 쿼리에 `status='ACTIVE'` 필터, `findVisibleMembers` 신설 +- `src/main/java/com/howaboutus/backend/rooms/service/RoomAuthorizationService.java` — LEFT 가드 추가 +- `src/main/java/com/howaboutus/backend/rooms/service/RoomMemberService.java` — leave/kick 도메인 메서드 호출로 변경, getMembers에서 LEFT 포함 및 online=false 강제, 정렬 +- `src/main/java/com/howaboutus/backend/rooms/service/RoomInviteService.java` — requestJoin에 LEFT 부활 분기 +- `src/main/java/com/howaboutus/backend/rooms/service/dto/RoomMemberResult.java` — `status` 필드 추가 +- `src/main/java/com/howaboutus/backend/rooms/controller/dto/RoomMemberResponse.java` — `status` 필드 추가 +- `src/main/java/com/howaboutus/backend/ai/repository/AiContextQueryRepositoryImpl.java` — `status='ACTIVE'` 필터 +- `src/test/java/com/howaboutus/backend/rooms/entity/RoomMemberTest.java` — 새 메서드 + 가드 테스트 +- `src/test/java/com/howaboutus/backend/rooms/service/RoomAuthorizationServiceTest.java` — LEFT 가드 테스트 +- `src/test/java/com/howaboutus/backend/rooms/service/RoomMemberServiceTest.java` — leave/kick/getMembers 테스트 변경 +- `src/test/java/com/howaboutus/backend/rooms/service/RoomInviteServiceTest.java` — 재입장 분기 테스트 +- `docs/ai/features.md`, `docs/ai/erd.md` + +--- + +## 작업 순서 원칙 + +- 각 Task 끝에 conventional commit(`feat:`/`refactor:`/`test:`/`docs:`) 한 개를 만든다. +- 커밋 직전 항상 `./gradlew checkstyleMain checkstyleTest` 통과를 확인한다. +- 커밋 메시지에 `Co-Authored-By:` 트레일러를 절대 추가하지 않는다. +- 단위 테스트는 `@ExtendWith(MockitoExtension.class) + @Mock + @InjectMocks` 또는 수동 생성자 주입(기존 `RoomMemberServiceTest` 패턴)을 사용한다. +- 통합 테스트가 ApplicationContext를 로드하기 시작하는 Task 2 완료 이후부터는 빌드 전체가 그린이어야 한다. Task 1 단독 커밋은 JPA 엔티티가 아직 컬럼을 모르는 상태라 ApplicationContext 로딩 테스트가 잠시 실패할 수 있으므로, Task 1 + Task 2를 연속 실행한 뒤에 전체 빌드를 검증한다. + +--- + +## Task 1: Flyway 마이그레이션 V1.8 — `room_members` status/left_at 도입 + +**Files:** +- Create: `src/main/resources/db/migration/V1.8__room_members_status.sql` + +- [ ] **Step 1: 마이그레이션 파일 생성** + +`src/main/resources/db/migration/V1.8__room_members_status.sql`: + +```sql +-- =========================================== +-- V1.8: room_members 에 status / left_at 도입 +-- ACTIVE = 활성 멤버(HOST/MEMBER/PENDING), LEFT = 방 나가기/추방으로 떠난 멤버 +-- 회원 탈퇴는 별도로 hard delete (row 자체 삭제) — 본 마이그레이션과 무관 +-- =========================================== + +-- 1) status / left_at 컬럼 추가 +ALTER TABLE room_members + ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + ADD COLUMN left_at TIMESTAMP WITH TIME ZONE; + +-- 2) status 유효값 +ALTER TABLE room_members + ADD CONSTRAINT ck_room_members_status + CHECK (status IN ('ACTIVE', 'LEFT')); + +-- 3) status ↔ left_at 무결성 +ALTER TABLE room_members + ADD CONSTRAINT ck_room_members_status_left_at + CHECK ( + (status = 'ACTIVE' AND left_at IS NULL) + OR (status = 'LEFT' AND left_at IS NOT NULL) + ); + +-- 4) LEFT row의 role=PENDING 차단 (PENDING의 LEFT 전이는 정책상 불가) +ALTER TABLE room_members + ADD CONSTRAINT ck_room_members_left_role + CHECK (status = 'ACTIVE' OR role <> 'PENDING'); + +-- 5) 멤버 목록 / LEFT 룩업 인덱스 +CREATE INDEX idx_room_members_room_id_status ON room_members (room_id, status); + +-- 기존 UNIQUE(room_id, user_id)는 유지 — 재입장은 같은 row UPSERT. +``` + +- [ ] **Step 2: 컴파일 검증** + +Run: `./gradlew compileJava -q` +Expected: SUCCESS (Java 변경 없음). + +- [ ] **Step 3: 커밋 (Task 2와 묶지 않음 — 마이그레이션 단독 커밋)** + +```bash +git add src/main/resources/db/migration/V1.8__room_members_status.sql +git commit -m "feat: V1.8 마이그레이션으로 room_members status/left_at 도입" +``` + +> 이 시점에 ApplicationContext 로딩 테스트(`./gradlew test`)는 일시적으로 실패할 수 있다(JPA 엔티티가 신규 컬럼을 아직 모름). 다음 Task 2에서 엔티티에 반영한 뒤 전체 빌드를 검증한다. + +--- + +## Task 2: `MemberStatus` enum + `RoomMember` 엔티티 필드/메서드 추가 (TDD) + +**Files:** +- Create: `src/main/java/com/howaboutus/backend/rooms/entity/MemberStatus.java` +- Modify: `src/main/java/com/howaboutus/backend/rooms/entity/RoomMember.java` +- Test: `src/test/java/com/howaboutus/backend/rooms/entity/RoomMemberTest.java` + +- [ ] **Step 1: `MemberStatus` enum 생성** + +`src/main/java/com/howaboutus/backend/rooms/entity/MemberStatus.java`: + +```java +package com.howaboutus.backend.rooms.entity; + +public enum MemberStatus { + ACTIVE, LEFT +} +``` + +- [ ] **Step 2: RoomMemberTest에 실패 테스트 추가** + +기존 `src/test/java/com/howaboutus/backend/rooms/entity/RoomMemberTest.java`의 `createMember` 헬퍼는 그대로 두고 아래 테스트를 추가한다. + +```java +@Test +@DisplayName("create - 신규 멤버는 status=ACTIVE, leftAt=null 로 시작한다") +void createStartsActive() { + RoomMember member = createMember(RoomRole.MEMBER); + assertThat(member.getStatus()).isEqualTo(MemberStatus.ACTIVE); + assertThat(member.getLeftAt()).isNull(); +} + +@Test +@DisplayName("leave - ACTIVE 멤버를 LEFT로 전환하고 leftAt 을 채운다") +void leaveTransitionsToLeft() { + RoomMember member = createMember(RoomRole.MEMBER); + member.leave(); + assertThat(member.getStatus()).isEqualTo(MemberStatus.LEFT); + assertThat(member.getLeftAt()).isNotNull(); + assertThat(member.getRole()).isEqualTo(RoomRole.MEMBER); +} + +@Test +@DisplayName("leave - 이미 LEFT면 예외") +void leaveFailsWhenAlreadyLeft() { + RoomMember member = createMember(RoomRole.MEMBER); + member.leave(); + assertThatThrownBy(member::leave).isInstanceOf(IllegalStateException.class); +} + +@Test +@DisplayName("kicked - ACTIVE 멤버를 LEFT로 전환하고 leftAt 을 채운다") +void kickedTransitionsToLeft() { + RoomMember member = createMember(RoomRole.MEMBER); + member.kicked(); + assertThat(member.getStatus()).isEqualTo(MemberStatus.LEFT); + assertThat(member.getLeftAt()).isNotNull(); +} + +@Test +@DisplayName("rejoinAsPending - LEFT 멤버를 ACTIVE/PENDING 으로 부활시킨다") +void rejoinAsPendingRevives() { + RoomMember member = createMember(RoomRole.MEMBER); + member.leave(); + member.rejoinAsPending(); + assertThat(member.getStatus()).isEqualTo(MemberStatus.ACTIVE); + assertThat(member.getRole()).isEqualTo(RoomRole.PENDING); + assertThat(member.getLeftAt()).isNull(); +} + +@Test +@DisplayName("rejoinAsPending - ACTIVE 멤버에 호출하면 예외") +void rejoinAsPendingFailsWhenActive() { + RoomMember member = createMember(RoomRole.MEMBER); + assertThatThrownBy(member::rejoinAsPending).isInstanceOf(IllegalStateException.class); +} + +@Test +@DisplayName("approve - LEFT 상태면 예외") +void approveFailsWhenLeft() { + RoomMember member = createMember(RoomRole.PENDING); + // PENDING 인 채 LEFT 로 만드는 직접 경로는 없으므로, 직접 필드 설정 헬퍼 사용 + ReflectionTestUtils.setField(member, "status", MemberStatus.LEFT); + ReflectionTestUtils.setField(member, "leftAt", java.time.Instant.now()); + assertThatThrownBy(member::approve).isInstanceOf(IllegalStateException.class); +} + +@Test +@DisplayName("promoteToHost - LEFT 상태면 예외") +void promoteToHostFailsWhenLeft() { + RoomMember member = createMember(RoomRole.MEMBER); + member.leave(); + assertThatThrownBy(member::promoteToHost).isInstanceOf(IllegalStateException.class); +} + +@Test +@DisplayName("demoteToMember - LEFT 상태면 예외") +void demoteToMemberFailsWhenLeft() { + RoomMember host = createMember(RoomRole.HOST); + ReflectionTestUtils.setField(host, "status", MemberStatus.LEFT); + ReflectionTestUtils.setField(host, "leftAt", java.time.Instant.now()); + assertThatThrownBy(host::demoteToMember).isInstanceOf(IllegalStateException.class); +} +``` + +상단 import에 다음을 추가: +```java +import org.springframework.test.util.ReflectionTestUtils; +``` + +- [ ] **Step 3: 테스트 실행 — 실패 확인** + +Run: `./gradlew test --tests com.howaboutus.backend.rooms.entity.RoomMemberTest` +Expected: 신규 테스트들 컴파일 실패(`leave`/`kicked`/`rejoinAsPending`/`getStatus`/`getLeftAt` 미정의). + +- [ ] **Step 4: `RoomMember` 엔티티 수정** + +`src/main/java/com/howaboutus/backend/rooms/entity/RoomMember.java` 변경 사항: + +```java +// import 추가 +// (이미 있는 것 외) +// (기존 import 유지) +// private MemberStatus status / private Instant leftAt 필드와 도메인 메서드만 추가 + +// 클래스 본문에 필드 추가 (joinedAt 아래) +@Enumerated(EnumType.STRING) +@Column(nullable = false, length = 20) +private MemberStatus status = MemberStatus.ACTIVE; + +@Column(name = "left_at") +private Instant leftAt; +``` + +도메인 메서드 추가(클래스 끝): + +```java +public void leave() { + if (this.status != MemberStatus.ACTIVE) { + throw new IllegalStateException("ACTIVE 상태의 멤버만 LEFT로 전환할 수 있습니다. 현재 상태: " + this.status); + } + this.status = MemberStatus.LEFT; + this.leftAt = Instant.now(); +} + +public void kicked() { + if (this.status != MemberStatus.ACTIVE) { + throw new IllegalStateException("ACTIVE 상태의 멤버만 LEFT로 전환할 수 있습니다. 현재 상태: " + this.status); + } + this.status = MemberStatus.LEFT; + this.leftAt = Instant.now(); +} + +public void rejoinAsPending() { + if (this.status != MemberStatus.LEFT) { + throw new IllegalStateException("LEFT 상태의 멤버만 재입장(PENDING) 으로 부활할 수 있습니다. 현재 상태: " + this.status); + } + this.status = MemberStatus.ACTIVE; + this.role = RoomRole.PENDING; + this.leftAt = null; +} +``` + +기존 `approve()` / `promoteToHost()` / `demoteToMember()` 각각의 첫 줄에 status 가드를 추가: + +```java +public void approve() { + if (this.status != MemberStatus.ACTIVE) { + throw new IllegalStateException("ACTIVE 상태의 멤버만 승인할 수 있습니다. 현재 상태: " + this.status); + } + if (this.role != RoomRole.PENDING) { + throw new IllegalStateException("PENDING 상태의 멤버만 승인할 수 있습니다. 현재 상태: " + this.role); + } + this.role = RoomRole.MEMBER; +} + +public void promoteToHost() { + if (this.status != MemberStatus.ACTIVE) { + throw new IllegalStateException("ACTIVE 상태의 멤버만 승격할 수 있습니다. 현재 상태: " + this.status); + } + if (this.role != RoomRole.MEMBER) { + throw new IllegalStateException("MEMBER 상태의 멤버만 HOST로 승격할 수 있습니다. 현재 상태: " + this.role); + } + this.role = RoomRole.HOST; +} + +public void demoteToMember() { + if (this.status != MemberStatus.ACTIVE) { + throw new IllegalStateException("ACTIVE 상태의 멤버만 강등할 수 있습니다. 현재 상태: " + this.status); + } + if (this.role != RoomRole.HOST) { + throw new IllegalStateException("HOST 상태의 멤버만 MEMBER로 강등할 수 있습니다. 현재 상태: " + this.role); + } + this.role = RoomRole.MEMBER; +} +``` + +- [ ] **Step 5: 테스트 실행 — 통과 확인** + +Run: `./gradlew test --tests com.howaboutus.backend.rooms.entity.RoomMemberTest` +Expected: 모든 테스트 PASS. + +- [ ] **Step 6: 전체 빌드 검증 (마이그레이션 + 엔티티 정합)** + +Run: `./gradlew test` +Expected: 전체 그린. (DB CHECK 제약과 JPA 매핑이 일치해야 ApplicationContext 로딩 테스트가 통과한다.) + +- [ ] **Step 7: Checkstyle** + +Run: `./gradlew checkstyleMain checkstyleTest` +Expected: 0 warnings. + +- [ ] **Step 8: 커밋** + +```bash +git add src/main/java/com/howaboutus/backend/rooms/entity/MemberStatus.java \ + src/main/java/com/howaboutus/backend/rooms/entity/RoomMember.java \ + src/test/java/com/howaboutus/backend/rooms/entity/RoomMemberTest.java +git commit -m "feat: RoomMember에 MemberStatus(LEFT) + 도메인 메서드 추가" +``` + +--- + +## Task 3: `RoomAuthorizationService.requireActiveMember`에 LEFT 가드 (TDD) + +**Files:** +- Modify: `src/main/java/com/howaboutus/backend/rooms/service/RoomAuthorizationService.java` +- Test: `src/test/java/com/howaboutus/backend/rooms/service/RoomAuthorizationServiceTest.java` + +- [ ] **Step 1: 실패 테스트 추가** + +`RoomAuthorizationServiceTest`에 추가 (기존 테스트와 같은 클래스, 패턴 동일): + +```java +@Test +@DisplayName("requireActiveMember - LEFT 멤버는 비멤버로 차단된다") +void requireActiveMemberRejectsLeft() { + User user = User.ofGoogle("g1", "u@test.com", "유저", null); + ReflectionTestUtils.setField(user, "id", USER_ID); + Room room = Room.create("여행", "부산", null, null, "invite1", USER_ID); + ReflectionTestUtils.setField(room, "id", ROOM_ID); + RoomMember member = RoomMember.create(room, user, RoomRole.MEMBER); + member.leave(); + + given(roomMemberRepository.findByRoom_IdAndUser_Id(ROOM_ID, USER_ID)) + .willReturn(java.util.Optional.of(member)); + + assertThatThrownBy(() -> roomAuthorizationService.requireActiveMember(ROOM_ID, USER_ID)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.NOT_ROOM_MEMBER); +} +``` + +기존 테스트 클래스의 상수/필드(`ROOM_ID`, `USER_ID`, `roomMemberRepository`, `roomAuthorizationService`)를 그대로 사용한다. 없는 import는 추가: +```java +import com.howaboutus.backend.rooms.entity.Room; +import com.howaboutus.backend.rooms.entity.RoomMember; +import com.howaboutus.backend.rooms.entity.RoomRole; +import com.howaboutus.backend.user.entity.User; +import org.springframework.test.util.ReflectionTestUtils; +``` + +- [ ] **Step 2: 테스트 실행 — 실패 확인** + +Run: `./gradlew test --tests com.howaboutus.backend.rooms.service.RoomAuthorizationServiceTest` +Expected: 신규 테스트 FAIL — LEFT 멤버가 예외 없이 반환됨. + +- [ ] **Step 3: `requireActiveMember`에 LEFT 가드 추가** + +`src/main/java/com/howaboutus/backend/rooms/service/RoomAuthorizationService.java`: + +```java +import com.howaboutus.backend.rooms.entity.MemberStatus; + +public RoomMember requireActiveMember(UUID roomId, Long userId) { + RoomMember member = roomMemberRepository.findByRoom_IdAndUser_Id(roomId, userId) + .orElseThrow(() -> new CustomException(ErrorCode.NOT_ROOM_MEMBER)); + if (member.getStatus() == MemberStatus.LEFT) { + throw new CustomException(ErrorCode.NOT_ROOM_MEMBER); + } + if (member.getRole() == RoomRole.PENDING) { + throw new CustomException(ErrorCode.NOT_ROOM_MEMBER); + } + return member; +} +``` + +- [ ] **Step 4: 테스트 실행 — 통과 확인** + +Run: `./gradlew test --tests com.howaboutus.backend.rooms.service.RoomAuthorizationServiceTest` +Expected: 모든 테스트 PASS. + +- [ ] **Step 5: Checkstyle** + +Run: `./gradlew checkstyleMain checkstyleTest` +Expected: 0 warnings. + +- [ ] **Step 6: 커밋** + +```bash +git add src/main/java/com/howaboutus/backend/rooms/service/RoomAuthorizationService.java \ + src/test/java/com/howaboutus/backend/rooms/service/RoomAuthorizationServiceTest.java +git commit -m "feat: requireActiveMember에서 LEFT 멤버를 비멤버로 차단" +``` + +--- + +## Task 4: `RoomMemberService.leave()` 변경 (TDD) + +**Files:** +- Modify: `src/main/java/com/howaboutus/backend/rooms/service/RoomMemberService.java` +- Test: `src/test/java/com/howaboutus/backend/rooms/service/RoomMemberServiceTest.java` + +- [ ] **Step 1: 실패 테스트 추가/수정** + +`RoomMemberServiceTest`에 leave 동작 확인 테스트를 추가(기존 leave 테스트가 있으면 수정): + +```java +@Test +@DisplayName("leave - row를 삭제하지 않고 LEFT 로 전환하며 이벤트를 발행한다") +void leaveTransitionsToLeftAndPublishesEvent() { + User user = User.ofGoogle("g2", "m@test.com", "멤버", "img"); + ReflectionTestUtils.setField(user, "id", USER_ID); + Room room = Room.create("여행", "부산", null, null, "invite1", USER_ID); + ReflectionTestUtils.setField(room, "id", ROOM_ID); + RoomMember member = RoomMember.create(room, user, RoomRole.MEMBER); + + given(roomAuthorizationService.requireActiveMember(ROOM_ID, USER_ID)).willReturn(member); + + roomMemberService.leave(ROOM_ID, USER_ID); + + assertThat(member.getStatus()).isEqualTo(com.howaboutus.backend.rooms.entity.MemberStatus.LEFT); + assertThat(member.getLeftAt()).isNotNull(); + verify(roomMemberRepository, never()).delete(any(RoomMember.class)); + verify(eventPublisher).publishEvent(any(MemberLeftEvent.class)); +} + +@Test +@DisplayName("leave - HOST 는 이전과 동일하게 CANNOT_LEAVE_AS_HOST 예외") +void leaveByHostFails() { + User user = User.ofGoogle("g1", "h@test.com", "호스트", null); + ReflectionTestUtils.setField(user, "id", HOST_USER_ID); + Room room = Room.create("여행", "부산", null, null, "invite1", HOST_USER_ID); + ReflectionTestUtils.setField(room, "id", ROOM_ID); + RoomMember host = RoomMember.create(room, user, RoomRole.HOST); + + given(roomAuthorizationService.requireActiveMember(ROOM_ID, HOST_USER_ID)).willReturn(host); + + assertThatThrownBy(() -> roomMemberService.leave(ROOM_ID, HOST_USER_ID)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.CANNOT_LEAVE_AS_HOST); +} +``` + +기존 테스트 중 `delete(member)` 호출을 검증하던 부분이 있다면 위와 같이 status 검증으로 교체한다. + +- [ ] **Step 2: 테스트 실행 — 실패 확인** + +Run: `./gradlew test --tests com.howaboutus.backend.rooms.service.RoomMemberServiceTest` +Expected: 새 `leaveTransitionsToLeftAndPublishesEvent` FAIL — 현재는 `roomMemberRepository.delete(...)`가 호출됨. + +- [ ] **Step 3: `RoomMemberService.leave()` 수정** + +`src/main/java/com/howaboutus/backend/rooms/service/RoomMemberService.java`의 `leave()`: + +```java +@Loggable +@Transactional +public void leave(UUID roomId, Long userId) { + RoomMember member = roomAuthorizationService.requireActiveMember(roomId, userId); + + if (member.getRole() == RoomRole.HOST) { + throw new CustomException(ErrorCode.CANNOT_LEAVE_AS_HOST); + } + + member.leave(); + eventPublisher.publishEvent(new MemberLeftEvent( + roomId, + member.getUser().getId(), + member.getUser().getNickname(), + member.getUser().getProfileImageUrl() + )); +} +``` + +- [ ] **Step 4: 테스트 실행 — 통과 확인** + +Run: `./gradlew test --tests com.howaboutus.backend.rooms.service.RoomMemberServiceTest` +Expected: PASS. + +- [ ] **Step 5: Checkstyle + 커밋** + +```bash +./gradlew checkstyleMain checkstyleTest +git add src/main/java/com/howaboutus/backend/rooms/service/RoomMemberService.java \ + src/test/java/com/howaboutus/backend/rooms/service/RoomMemberServiceTest.java +git commit -m "refactor: leave가 hard delete 대신 status=LEFT 전환을 수행" +``` + +--- + +## Task 5: `RoomMemberService.kick()` 변경 + LEFT 가드 (TDD) + +**Files:** +- Modify: `src/main/java/com/howaboutus/backend/rooms/service/RoomMemberService.java` +- Test: `src/test/java/com/howaboutus/backend/rooms/service/RoomMemberServiceTest.java` + +- [ ] **Step 1: 실패 테스트 추가** + +```java +@Test +@DisplayName("kick - 대상 row를 삭제하지 않고 LEFT 로 전환하며 이벤트를 발행한다") +void kickTransitionsToLeft() { + User host = User.ofGoogle("g1", "h@test.com", "호스트", null); + ReflectionTestUtils.setField(host, "id", HOST_USER_ID); + User target = User.ofGoogle("g2", "t@test.com", "타겟", "img"); + ReflectionTestUtils.setField(target, "id", TARGET_USER_ID); + Room room = Room.create("여행", "부산", null, null, "invite1", HOST_USER_ID); + ReflectionTestUtils.setField(room, "id", ROOM_ID); + RoomMember hostMember = RoomMember.create(room, host, RoomRole.HOST); + RoomMember targetMember = RoomMember.create(room, target, RoomRole.MEMBER); + + given(roomAuthorizationService.requireHost(ROOM_ID, HOST_USER_ID)).willReturn(hostMember); + given(roomMemberRepository.findByRoom_IdAndUser_Id(ROOM_ID, TARGET_USER_ID)) + .willReturn(java.util.Optional.of(targetMember)); + + roomMemberService.kick(ROOM_ID, TARGET_USER_ID, HOST_USER_ID); + + assertThat(targetMember.getStatus()) + .isEqualTo(com.howaboutus.backend.rooms.entity.MemberStatus.LEFT); + verify(roomMemberRepository, never()).delete(any(RoomMember.class)); + verify(eventPublisher).publishEvent(any(MemberKickedEvent.class)); +} + +@Test +@DisplayName("kick - 대상이 이미 LEFT 면 KICK_TARGET_NOT_MEMBER 예외") +void kickFailsWhenTargetAlreadyLeft() { + User host = User.ofGoogle("g1", "h@test.com", "호스트", null); + ReflectionTestUtils.setField(host, "id", HOST_USER_ID); + User target = User.ofGoogle("g2", "t@test.com", "타겟", null); + ReflectionTestUtils.setField(target, "id", TARGET_USER_ID); + Room room = Room.create("여행", "부산", null, null, "invite1", HOST_USER_ID); + ReflectionTestUtils.setField(room, "id", ROOM_ID); + RoomMember hostMember = RoomMember.create(room, host, RoomRole.HOST); + RoomMember targetMember = RoomMember.create(room, target, RoomRole.MEMBER); + targetMember.leave(); + + given(roomAuthorizationService.requireHost(ROOM_ID, HOST_USER_ID)).willReturn(hostMember); + given(roomMemberRepository.findByRoom_IdAndUser_Id(ROOM_ID, TARGET_USER_ID)) + .willReturn(java.util.Optional.of(targetMember)); + + assertThatThrownBy(() -> roomMemberService.kick(ROOM_ID, TARGET_USER_ID, HOST_USER_ID)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.KICK_TARGET_NOT_MEMBER); +} +``` + +- [ ] **Step 2: 테스트 실행 — 실패 확인** + +Run: `./gradlew test --tests com.howaboutus.backend.rooms.service.RoomMemberServiceTest` +Expected: 새 테스트 두 개 FAIL. + +- [ ] **Step 3: `kick()` 수정** + +`src/main/java/com/howaboutus/backend/rooms/service/RoomMemberService.java`의 `kick()`: + +```java +@Loggable +@Transactional +public void kick(UUID roomId, Long targetUserId, Long hostUserId) { + roomAuthorizationService.requireHost(roomId, hostUserId); + + RoomMember target = roomMemberRepository.findByRoom_IdAndUser_Id(roomId, targetUserId) + .orElseThrow(() -> new CustomException(ErrorCode.ROOM_MEMBER_NOT_FOUND)); + + if (target.getStatus() != MemberStatus.ACTIVE) { + throw new CustomException(ErrorCode.KICK_TARGET_NOT_MEMBER); + } + if (target.getRole() == RoomRole.HOST) { + throw new CustomException(ErrorCode.CANNOT_KICK_HOST); + } + if (target.getRole() != RoomRole.MEMBER) { + throw new CustomException(ErrorCode.KICK_TARGET_NOT_MEMBER); + } + + target.kicked(); + eventPublisher.publishEvent(new MemberKickedEvent( + roomId, + target.getUser().getId(), + target.getUser().getNickname(), + target.getUser().getProfileImageUrl() + )); +} +``` + +import에 `com.howaboutus.backend.rooms.entity.MemberStatus` 추가. + +- [ ] **Step 4: 테스트 실행 — 통과 확인** + +Run: `./gradlew test --tests com.howaboutus.backend.rooms.service.RoomMemberServiceTest` +Expected: PASS. + +- [ ] **Step 5: Checkstyle + 커밋** + +```bash +./gradlew checkstyleMain checkstyleTest +git add src/main/java/com/howaboutus/backend/rooms/service/RoomMemberService.java \ + src/test/java/com/howaboutus/backend/rooms/service/RoomMemberServiceTest.java +git commit -m "refactor: kick이 hard delete 대신 status=LEFT 전환을 수행" +``` + +--- + +## Task 6: Repository — 활성 멤버 쿼리에 `status='ACTIVE'` 필터 추가 + +**Files:** +- Modify: `src/main/java/com/howaboutus/backend/rooms/repository/RoomMemberRepository.java` + +> 이 task는 기존 메서드 시그니처는 유지하면서 내부 필터만 강화한다. 기존 테스트들이 ACTIVE 멤버만 다루므로 **기존 테스트는 그대로 통과해야 한다**. 추가 회귀 테스트는 Task 8(통합 조회) / Task 10(재입장)에서 LEFT 케이스로 커버된다. + +- [ ] **Step 1: 메서드 시그니처/쿼리 변경** + +`src/main/java/com/howaboutus/backend/rooms/repository/RoomMemberRepository.java`: + +```java +import com.howaboutus.backend.rooms.entity.MemberStatus; + +// 1) ACTIVE 단일 조회 — 모든 ACTIVE 필터링은 JPQL로 일관 표현 +@EntityGraph(attributePaths = "user") +@Query(""" + select m from RoomMember m + where m.room.id = :roomId + and m.role = :role + and m.status = com.howaboutus.backend.rooms.entity.MemberStatus.ACTIVE +""") +List findByRoom_IdAndRole(@Param("roomId") UUID roomId, @Param("role") RoomRole role); + +// 2) ACTIVE 다중 role 조회 +@EntityGraph(attributePaths = "user") +@Query(""" + select m from RoomMember m + where m.room.id = :roomId + and m.role in :roles + and m.status = com.howaboutus.backend.rooms.entity.MemberStatus.ACTIVE +""") +List findByRoom_IdAndRoleIn(@Param("roomId") UUID roomId, @Param("roles") List roles); + +// 3) ACTIVE 카운트 +@Query(""" + select count(m) from RoomMember m + where m.room.id = :roomId + and m.role in :roles + and m.status = com.howaboutus.backend.rooms.entity.MemberStatus.ACTIVE +""") +long countByRoom_IdAndRoleIn(@Param("roomId") UUID roomId, @Param("roles") List roles); + +// 4) 내 방 목록 (커서 없음) +@EntityGraph(attributePaths = "room") +@Query(""" + select m from RoomMember m + where m.user.id = :userId + and m.role in :roles + and m.status = com.howaboutus.backend.rooms.entity.MemberStatus.ACTIVE + order by m.joinedAt desc +""") +List findByUser_IdAndRoleInOrderByJoinedAtDesc( + @Param("userId") Long userId, @Param("roles") List roles, Pageable pageable); + +// 5) 내 방 목록 (커서) +@EntityGraph(attributePaths = "room") +@Query(""" + select m from RoomMember m + where m.user.id = :userId + and m.role in :roles + and m.status = com.howaboutus.backend.rooms.entity.MemberStatus.ACTIVE + and m.joinedAt < :cursor + order by m.joinedAt desc +""") +List findByUser_IdAndRoleInAndJoinedAtBeforeOrderByJoinedAtDesc( + @Param("userId") Long userId, @Param("roles") List roles, + @Param("cursor") Instant cursor, Pageable pageable); +``` + +`findHostRoomsWithOnlySelf` / `findHostRoomsWithOtherActiveMembers` 두 JPQL은 self/other 모두 ACTIVE 조건을 추가: + +```java +@Query(""" + select m.room + from RoomMember m + where m.user.id = :userId + and m.role = com.howaboutus.backend.rooms.entity.RoomRole.HOST + and m.status = com.howaboutus.backend.rooms.entity.MemberStatus.ACTIVE + and not exists ( + select 1 from RoomMember other + where other.room.id = m.room.id + and other.user.id <> :userId + and other.role in ( + com.howaboutus.backend.rooms.entity.RoomRole.HOST, + com.howaboutus.backend.rooms.entity.RoomRole.MEMBER) + and other.status = com.howaboutus.backend.rooms.entity.MemberStatus.ACTIVE + ) + """) +List findHostRoomsWithOnlySelf(@Param("userId") Long userId); +``` + +```java +@Query(""" + select m.room.id as roomId, m.room.title as title + from RoomMember m + where m.user.id = :userId + and m.role = com.howaboutus.backend.rooms.entity.RoomRole.HOST + and m.status = com.howaboutus.backend.rooms.entity.MemberStatus.ACTIVE + and exists ( + select 1 from RoomMember other + where other.room.id = m.room.id + and other.user.id <> :userId + and other.role in ( + com.howaboutus.backend.rooms.entity.RoomRole.HOST, + com.howaboutus.backend.rooms.entity.RoomRole.MEMBER) + and other.status = com.howaboutus.backend.rooms.entity.MemberStatus.ACTIVE + ) + """) +List findHostRoomsWithOtherActiveMembers(@Param("userId") Long userId); +``` + +`findAllByUser_Id`, `findByRoom_IdAndUser_Id`, `findByIdAndRoom_Id` 는 **변경 없음** (탈퇴 시 전체 row 삭제 / LEFT 부활 감지 / approve 가드에서 status 검증). + +- [ ] **Step 2: 전체 테스트 회귀 확인** + +Run: `./gradlew test` +Expected: 전체 PASS. 특히 `RoomMemberRepositoryWithdrawalTest`, `RoomMemberServiceTest`, `RoomInviteServiceTest`, `UserWithdrawalServiceTest` 가 회귀 없이 통과. + +- [ ] **Step 3: Checkstyle + 커밋** + +```bash +./gradlew checkstyleMain checkstyleTest +git add src/main/java/com/howaboutus/backend/rooms/repository/RoomMemberRepository.java +git commit -m "refactor: 활성 멤버 쿼리에 status=ACTIVE 필터 일괄 추가" +``` + +--- + +## Task 7: `RoomMemberResult` / `RoomMemberResponse`에 `status` 필드 추가 + +**Files:** +- Modify: `src/main/java/com/howaboutus/backend/rooms/service/dto/RoomMemberResult.java` +- Modify: `src/main/java/com/howaboutus/backend/rooms/controller/dto/RoomMemberResponse.java` +- Test: `src/test/java/com/howaboutus/backend/rooms/service/RoomMemberServiceTest.java` + +> Task 8에서 `getMembers`가 LEFT를 포함하기 위해 사전에 DTO 스키마부터 확장한다. 기존 호출지(LEFT 미포함 결과만 다룸)는 `MemberStatus.ACTIVE` 를 그대로 채워 보낸다. + +- [ ] **Step 1: DTO 수정** + +`RoomMemberResult`: +```java +package com.howaboutus.backend.rooms.service.dto; + +import java.time.Instant; + +import com.howaboutus.backend.rooms.entity.MemberStatus; +import com.howaboutus.backend.rooms.entity.RoomRole; + +public record RoomMemberResult( + Long userId, + String nickname, + String profileImageUrl, + RoomRole role, + MemberStatus status, + boolean isOnline, + Instant joinedAt +) { +} +``` + +`RoomMemberResponse`: +```java +package com.howaboutus.backend.rooms.controller.dto; + +import java.time.Instant; + +import com.howaboutus.backend.rooms.entity.MemberStatus; +import com.howaboutus.backend.rooms.entity.RoomRole; +import com.howaboutus.backend.rooms.service.dto.RoomMemberResult; + +public record RoomMemberResponse( + Long userId, + String nickname, + String profileImageUrl, + RoomRole role, + MemberStatus status, + boolean isOnline, + Instant joinedAt +) { + public static RoomMemberResponse from(RoomMemberResult result) { + return new RoomMemberResponse( + result.userId(), + result.nickname(), + result.profileImageUrl(), + result.role(), + result.status(), + result.isOnline(), + result.joinedAt()); + } +} +``` + +- [ ] **Step 2: `RoomMemberService.getMembers` 임시 보정 (이번 task 한정)** + +`RoomMemberService.getMembers` 매핑부에 status를 채워 보내도록 임시로 ACTIVE 고정: +```java +return members.stream() + .map(m -> new RoomMemberResult( + m.getUser().getId(), + m.getUser().getNickname(), + m.getUser().getProfileImageUrl(), + m.getRole(), + m.getStatus(), // m.getStatus() — 이 시점에서는 ACTIVE 만 반환되므로 항상 ACTIVE + onlineUserIds.contains(m.getUser().getId()), + m.getJoinedAt())) + .toList(); +``` + +기존 `RoomMemberServiceTest`의 `assertThat(results.get(0).role())...` 라인 다음에 status 검증을 추가하여 회귀를 막는다: +```java +assertThat(results.get(0).status()).isEqualTo(com.howaboutus.backend.rooms.entity.MemberStatus.ACTIVE); +``` + +- [ ] **Step 3: 전체 테스트 회귀 확인** + +Run: `./gradlew test` +Expected: PASS. + +- [ ] **Step 4: Checkstyle + 커밋** + +```bash +./gradlew checkstyleMain checkstyleTest +git add src/main/java/com/howaboutus/backend/rooms/service/dto/RoomMemberResult.java \ + src/main/java/com/howaboutus/backend/rooms/controller/dto/RoomMemberResponse.java \ + src/main/java/com/howaboutus/backend/rooms/service/RoomMemberService.java \ + src/test/java/com/howaboutus/backend/rooms/service/RoomMemberServiceTest.java +git commit -m "feat: 멤버 응답 DTO에 status 필드 노출" +``` + +--- + +## Task 8: `findVisibleMembers` 신설 + `RoomMemberService.getMembers` LEFT 포함 (TDD) + +**Files:** +- Modify: `src/main/java/com/howaboutus/backend/rooms/repository/RoomMemberRepository.java` +- Modify: `src/main/java/com/howaboutus/backend/rooms/service/RoomMemberService.java` +- Test: `src/test/java/com/howaboutus/backend/rooms/service/RoomMemberServiceTest.java` + +- [ ] **Step 1: 실패 테스트 추가** + +`RoomMemberServiceTest`에 추가: +```java +@Test +@DisplayName("getMembers - LEFT 멤버도 포함하며 LEFT 의 online 은 항상 false") +void getMembersIncludesLeftAndForcesOffline() { + User host = User.ofGoogle("g1", "h@test.com", "호스트", null); + ReflectionTestUtils.setField(host, "id", 1L); + User active = User.ofGoogle("g2", "m@test.com", "활성멤버", null); + ReflectionTestUtils.setField(active, "id", 2L); + User left = User.ofGoogle("g3", "l@test.com", "나간사람", null); + ReflectionTestUtils.setField(left, "id", 3L); + + Room room = Room.create("여행", "부산", null, null, "invite1", 1L); + ReflectionTestUtils.setField(room, "id", ROOM_ID); + + RoomMember hostMember = RoomMember.create(room, host, RoomRole.HOST); + RoomMember activeMember = RoomMember.create(room, active, RoomRole.MEMBER); + RoomMember leftMember = RoomMember.create(room, left, RoomRole.MEMBER); + leftMember.leave(); + + given(roomAuthorizationService.requireActiveMember(ROOM_ID, USER_ID)).willReturn(hostMember); + given(roomMemberRepository.findVisibleMembers(ROOM_ID)) + .willReturn(java.util.List.of(hostMember, activeMember, leftMember)); + // LEFT 인 user(3L) 가 마침 Redis presence에 살아 있다고 가정 — 무시되어야 함 + given(roomPresenceService.getOnlineUserIds(ROOM_ID)) + .willReturn(java.util.Set.of(1L, 3L)); + + java.util.List results = roomMemberService.getMembers(ROOM_ID, USER_ID); + + assertThat(results).hasSize(3); + // 정렬: ACTIVE(HOST → MEMBER joinedAt asc) → LEFT(joinedAt asc) + assertThat(results.get(0).userId()).isEqualTo(1L); + assertThat(results.get(0).role()).isEqualTo(RoomRole.HOST); + assertThat(results.get(0).status()).isEqualTo(com.howaboutus.backend.rooms.entity.MemberStatus.ACTIVE); + + assertThat(results.get(1).userId()).isEqualTo(2L); + assertThat(results.get(1).status()).isEqualTo(com.howaboutus.backend.rooms.entity.MemberStatus.ACTIVE); + + assertThat(results.get(2).userId()).isEqualTo(3L); + assertThat(results.get(2).status()).isEqualTo(com.howaboutus.backend.rooms.entity.MemberStatus.LEFT); + assertThat(results.get(2).isOnline()).isFalse(); +} +``` + +- [ ] **Step 2: 테스트 실행 — 실패 확인** + +Run: `./gradlew test --tests com.howaboutus.backend.rooms.service.RoomMemberServiceTest` +Expected: `findVisibleMembers` 미정의로 컴파일 실패. + +- [ ] **Step 3: Repository에 `findVisibleMembers` 추가** + +`RoomMemberRepository`에 추가: +```java +@EntityGraph(attributePaths = "user") +@Query(""" + select m from RoomMember m + where m.room.id = :roomId + and ( + (m.status = com.howaboutus.backend.rooms.entity.MemberStatus.ACTIVE + and m.role in ( + com.howaboutus.backend.rooms.entity.RoomRole.HOST, + com.howaboutus.backend.rooms.entity.RoomRole.MEMBER)) + or m.status = com.howaboutus.backend.rooms.entity.MemberStatus.LEFT + ) +""") +List findVisibleMembers(@Param("roomId") UUID roomId); +``` + +- [ ] **Step 4: `RoomMemberService.getMembers` 구현 변경** + +`RoomMemberService`: +```java +import com.howaboutus.backend.rooms.entity.MemberStatus; + +private static final java.util.Comparator MEMBER_DISPLAY_ORDER = + java.util.Comparator + .comparing((RoomMember m) -> m.getStatus() == MemberStatus.LEFT) // ACTIVE 먼저 + .thenComparing(m -> m.getRole() != RoomRole.HOST) // HOST 먼저 + .thenComparing(RoomMember::getJoinedAt); + +public List getMembers(UUID roomId, Long userId) { + roomAuthorizationService.requireActiveMember(roomId, userId); + + List members = roomMemberRepository.findVisibleMembers(roomId); + Set onlineUserIds = getOnlineUserIdsSafe(roomId); + + return members.stream() + .sorted(MEMBER_DISPLAY_ORDER) + .map(m -> new RoomMemberResult( + m.getUser().getId(), + m.getUser().getNickname(), + m.getUser().getProfileImageUrl(), + m.getRole(), + m.getStatus(), + m.getStatus() == MemberStatus.ACTIVE && onlineUserIds.contains(m.getUser().getId()), + m.getJoinedAt())) + .toList(); +} +``` + +기존 상수 `ACTIVE_ROLES`는 다른 곳에서 쓰지 않으면 삭제. 사용처가 남아있다면 그대로 유지. + +- [ ] **Step 5: 테스트 실행 — 통과 확인** + +Run: `./gradlew test --tests com.howaboutus.backend.rooms.service.RoomMemberServiceTest` +Expected: PASS. + +- [ ] **Step 6: 전체 회귀** + +Run: `./gradlew test` +Expected: PASS. + +- [ ] **Step 7: Checkstyle + 커밋** + +```bash +./gradlew checkstyleMain checkstyleTest +git add src/main/java/com/howaboutus/backend/rooms/repository/RoomMemberRepository.java \ + src/main/java/com/howaboutus/backend/rooms/service/RoomMemberService.java \ + src/test/java/com/howaboutus/backend/rooms/service/RoomMemberServiceTest.java +git commit -m "feat: getMembers가 LEFT 멤버까지 노출하고 online=false 강제" +``` + +--- + +## Task 9: `RoomInviteService.requestJoin` 재입장 분기 (TDD) + +**Files:** +- Modify: `src/main/java/com/howaboutus/backend/rooms/service/RoomInviteService.java` +- Test: `src/test/java/com/howaboutus/backend/rooms/service/RoomInviteServiceTest.java` + +- [ ] **Step 1: 실패 테스트 추가** + +`RoomInviteServiceTest`에 추가 (기존 mock 셋업 패턴 참고): +```java +@Test +@DisplayName("requestJoin - LEFT 멤버는 같은 row가 ACTIVE/PENDING 으로 부활하고 JoinRequestedEvent 가 발행된다") +void requestJoinRevivesLeftMember() { + UUID roomId = UUID.randomUUID(); + Long userId = 42L; + String inviteCode = "code-1"; + + User user = User.ofGoogle("g42", "u@test.com", "유저", null); + ReflectionTestUtils.setField(user, "id", userId); + User hostUser = User.ofGoogle("gh", "h@test.com", "호스트", null); + ReflectionTestUtils.setField(hostUser, "id", 1L); + Room room = Room.create("여행", "부산", null, null, inviteCode, 1L); + ReflectionTestUtils.setField(room, "id", roomId); + + RoomMember left = RoomMember.create(room, user, RoomRole.MEMBER); + left.leave(); + RoomMember hostMember = RoomMember.create(room, hostUser, RoomRole.HOST); + + given(roomRepository.findByInviteCode(inviteCode)).willReturn(java.util.Optional.of(room)); + given(roomMemberRepository.findByRoom_IdAndUser_Id(roomId, userId)) + .willReturn(java.util.Optional.of(left)); + given(roomMemberRepository.findByRoom_IdAndRole(roomId, RoomRole.HOST)) + .willReturn(java.util.List.of(hostMember)); + + JoinResult result = roomInviteService.requestJoin(inviteCode, userId); + + assertThat(left.getStatus()).isEqualTo(com.howaboutus.backend.rooms.entity.MemberStatus.ACTIVE); + assertThat(left.getRole()).isEqualTo(RoomRole.PENDING); + assertThat(left.getLeftAt()).isNull(); + assertThat(result.status()).isEqualTo(JoinResult.Status.PENDING); + verify(eventPublisher).publishEvent(any(JoinRequestedEvent.class)); + verify(roomMemberRepository, never()).saveAndFlush(any(RoomMember.class)); +} +``` + +`JoinResult.Status` 의 실제 이름이 다르면 기존 테스트 어셔션 스타일에 맞춰 조정. `userService.getUser`는 LEFT 분기에서는 호출되지 않으므로 stubbing 불필요. + +- [ ] **Step 2: 테스트 실행 — 실패 확인** + +Run: `./gradlew test --tests com.howaboutus.backend.rooms.service.RoomInviteServiceTest` +Expected: 신규 테스트 FAIL — 현재는 LEFT 분기가 없어 `alreadyMember`로 빠짐. + +- [ ] **Step 3: `requestJoin` 수정** + +`RoomInviteService.requestJoin`: +```java +import com.howaboutus.backend.rooms.entity.MemberStatus; + +@Loggable +@Transactional +public JoinResult requestJoin(String inviteCode, Long userId) { + Room room = roomRepository.findByInviteCode(inviteCode) + .orElseThrow(() -> new CustomException(ErrorCode.ROOM_NOT_FOUND)); + + Optional existing = roomMemberRepository.findByRoom_IdAndUser_Id(room.getId(), userId); + + if (existing.isPresent()) { + RoomMember member = existing.get(); + if (member.getStatus() == MemberStatus.LEFT) { + member.rejoinAsPending(); + publishJoinRequested(room, member.getUser()); + return JoinResult.pending(room.getId(), room.getTitle()); + } + if (member.getRole() == RoomRole.PENDING) { + return JoinResult.pending(room.getId(), room.getTitle()); + } + return JoinResult.alreadyMember(room.getId(), room.getTitle(), member.getRole()); + } + + User user = userService.getUser(userId); + try { + roomMemberRepository.saveAndFlush(RoomMember.create(room, user, RoomRole.PENDING)); + } catch (DataIntegrityViolationException e) { + log.warn("Concurrent join request detected. roomId={}, userId={}", room.getId(), userId, e); + return JoinResult.pending(room.getId(), room.getTitle()); + } + publishJoinRequested(room, user); + return JoinResult.pending(room.getId(), room.getTitle()); +} + +private void publishJoinRequested(Room room, User user) { + List hostUserIds = roomMemberRepository.findByRoom_IdAndRole(room.getId(), RoomRole.HOST) + .stream() + .map(m -> m.getUser().getId()) + .toList(); + eventPublisher.publishEvent(new JoinRequestedEvent( + room.getId(), + user.getId(), + user.getNickname(), + user.getProfileImageUrl(), + hostUserIds + )); +} +``` + +- [ ] **Step 4: 테스트 실행 — 통과 확인** + +Run: `./gradlew test --tests com.howaboutus.backend.rooms.service.RoomInviteServiceTest` +Expected: PASS. + +- [ ] **Step 5: 전체 회귀** + +Run: `./gradlew test` +Expected: PASS. + +- [ ] **Step 6: Checkstyle + 커밋** + +```bash +./gradlew checkstyleMain checkstyleTest +git add src/main/java/com/howaboutus/backend/rooms/service/RoomInviteService.java \ + src/test/java/com/howaboutus/backend/rooms/service/RoomInviteServiceTest.java +git commit -m "feat: requestJoin이 LEFT row를 PENDING으로 부활시키도록 지원" +``` + +--- + +## Task 10: AI 컨텍스트 쿼리에 `status='ACTIVE'` 필터 추가 + +**Files:** +- Modify: `src/main/java/com/howaboutus/backend/ai/repository/AiContextQueryRepositoryImpl.java` + +- [ ] **Step 1: 현재 쿼리 확인** + +`AiContextQueryRepositoryImpl.java` 38행 근처에서 `RoomMember`를 `roles IN (HOST, MEMBER)`로 필터링하는 JPQL/Criteria를 찾는다. + +- [ ] **Step 2: 필터 추가** + +해당 쿼리에 `AND status = MemberStatus.ACTIVE` 조건을 추가한다. 예시(JPQL인 경우): + +```java +import com.howaboutus.backend.rooms.entity.MemberStatus; + +// ... 기존 쿼리 빌더 ... +.setParameter("roles", List.of(RoomRole.HOST, RoomRole.MEMBER)) +.setParameter("activeStatus", MemberStatus.ACTIVE) +// JPQL 본문에 "and rm.status = :activeStatus" 추가 +``` + +Native SQL이거나 Criteria라면 동일하게 status 컬럼 필터를 추가. 기존 테스트가 있으면 그대로 통과해야 하며, 필요하면 LEFT 멤버가 포함되지 않음을 검증하는 회귀 테스트 1개를 추가한다. + +- [ ] **Step 3: 회귀 테스트(선택, 기존 AI 테스트 위치에 추가)** + +`AiContextQueryRepositoryImpl`에 대한 테스트 클래스가 이미 있다면 그 패턴에 맞춰 다음 시나리오를 추가: + +> 같은 방에 ACTIVE/MEMBER 1명 + LEFT/MEMBER 1명을 두고 AI 컨텍스트 조회 → ACTIVE 1명만 결과에 포함. + +테스트 클래스가 없다면 이번 task에서는 만들지 않는다(scope 초과). features.md 갱신 시 "LEFT는 AI 컨텍스트에서 제외" 한 줄 명시로 의도 보존. + +- [ ] **Step 4: 빌드 + 커밋** + +```bash +./gradlew test +./gradlew checkstyleMain checkstyleTest +git add src/main/java/com/howaboutus/backend/ai/repository/AiContextQueryRepositoryImpl.java +git commit -m "fix: AI 컨텍스트 조회에서 LEFT 멤버 제외" +``` + +--- + +## Task 11: DB CHECK 제약 통합 테스트 + +**Files:** +- Create: `src/test/java/com/howaboutus/backend/rooms/repository/RoomMemberStatusConstraintTest.java` + +이 테스트는 Flyway가 적용된 실제 PostgreSQL에서 3개의 CHECK 제약을 검증한다. 프로젝트의 기존 통합 테스트 베이스(예: `RoomMemberRepositoryWithdrawalTest` 패턴)를 그대로 따른다. + +- [ ] **Step 1: 테스트 파일 생성** + +`src/test/java/com/howaboutus/backend/rooms/repository/RoomMemberStatusConstraintTest.java`: + +```java +package com.howaboutus.backend.rooms.repository; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Instant; +import java.util.UUID; + +import jakarta.persistence.EntityManager; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.test.context.ActiveProfiles; + +import com.howaboutus.backend.rooms.entity.Room; +import com.howaboutus.backend.rooms.entity.RoomMember; +import com.howaboutus.backend.rooms.entity.RoomRole; +import com.howaboutus.backend.user.entity.User; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@ActiveProfiles("test") +class RoomMemberStatusConstraintTest { + + @Autowired EntityManager em; + + private User persistUser() { + User u = User.ofGoogle(UUID.randomUUID().toString(), UUID.randomUUID() + "@t", "n", null); + em.persist(u); + return u; + } + + private Room persistRoom(Long hostId) { + Room room = Room.create("t", "d", null, null, UUID.randomUUID().toString(), hostId); + em.persist(room); + return room; + } + + @Test + @DisplayName("CHECK ck_room_members_status_left_at - LEFT + left_at NULL 은 거부") + void leftRequiresLeftAt() { + User u = persistUser(); + Room room = persistRoom(u.getId()); + em.flush(); + + assertThatThrownBy(() -> em.createNativeQuery(""" + insert into room_members (room_id, user_id, role, joined_at, status, left_at, created_at, updated_at) + values (?1, ?2, 'MEMBER', now(), 'LEFT', NULL, now(), now()) + """).setParameter(1, room.getId()).setParameter(2, u.getId()).executeUpdate()) + .isInstanceOfAny(DataIntegrityViolationException.class, jakarta.persistence.PersistenceException.class); + } + + @Test + @DisplayName("CHECK ck_room_members_status_left_at - ACTIVE + left_at NOT NULL 은 거부") + void activeForbidsLeftAt() { + User u = persistUser(); + Room room = persistRoom(u.getId()); + em.flush(); + + assertThatThrownBy(() -> em.createNativeQuery(""" + insert into room_members (room_id, user_id, role, joined_at, status, left_at, created_at, updated_at) + values (?1, ?2, 'MEMBER', now(), 'ACTIVE', now(), now(), now()) + """).setParameter(1, room.getId()).setParameter(2, u.getId()).executeUpdate()) + .isInstanceOfAny(DataIntegrityViolationException.class, jakarta.persistence.PersistenceException.class); + } + + @Test + @DisplayName("CHECK ck_room_members_left_role - LEFT + role=PENDING 은 거부") + void leftPendingRejected() { + User u = persistUser(); + Room room = persistRoom(u.getId()); + em.flush(); + + assertThatThrownBy(() -> em.createNativeQuery(""" + insert into room_members (room_id, user_id, role, joined_at, status, left_at, created_at, updated_at) + values (?1, ?2, 'PENDING', now(), 'LEFT', now(), now(), now()) + """).setParameter(1, room.getId()).setParameter(2, u.getId()).executeUpdate()) + .isInstanceOfAny(DataIntegrityViolationException.class, jakarta.persistence.PersistenceException.class); + } +} +``` + +> `@DataJpaTest` 가 트랜잭션을 롤백하므로 각 케이스마다 깨끗한 상태에서 시작한다. 베이스 테스트 컨테이너 설정은 프로젝트 기존 통합 테스트에 이미 구성되어 있다고 가정한다(`RoomMemberRepositoryWithdrawalTest` 와 동일한 방식). 다른 베이스 설정이 필요하면 동일 클래스의 `@Sql`/`@Import` 패턴을 그대로 채택한다. + +- [ ] **Step 2: 테스트 실행 — 통과 확인** + +Run: `./gradlew test --tests com.howaboutus.backend.rooms.repository.RoomMemberStatusConstraintTest` +Expected: 3개 PASS. + +- [ ] **Step 3: 전체 회귀 + Checkstyle + 커밋** + +```bash +./gradlew test +./gradlew checkstyleMain checkstyleTest +git add src/test/java/com/howaboutus/backend/rooms/repository/RoomMemberStatusConstraintTest.java +git commit -m "test: V1.8 CHECK 제약(LEFT/left_at/PENDING) 회귀 테스트 추가" +``` + +--- + +## Task 12: 회원 탈퇴 회귀 — LEFT row를 가진 유저 탈퇴 시 hard delete 보존 + +**Files:** +- Modify: `src/test/java/com/howaboutus/backend/user/service/UserWithdrawalServiceTest.java` + +> 행동 변경은 없지만(`findAllByUser_Id`가 status 무관) 회귀 테스트로 의도를 잠근다. + +- [ ] **Step 1: 실패 위험 없는 회귀 테스트 추가** + +```java +@Test +@DisplayName("withdraw - LEFT row를 가진 유저도 전체 row가 hard delete 된다") +void withdrawDeletesEvenLeftMemberships() { + Long userId = 99L; + User user = User.ofGoogle("g99", "x@t", "x", null); + ReflectionTestUtils.setField(user, "id", userId); + + Room room = Room.create("t", "d", null, null, "iv", 1L); + ReflectionTestUtils.setField(room, "id", UUID.randomUUID()); + + RoomMember leftMember = RoomMember.create(room, user, RoomRole.MEMBER); + leftMember.leave(); + + given(userRepository.findById(userId)).willReturn(java.util.Optional.of(user)); + given(roomMemberRepository.findHostRoomsWithOtherActiveMembers(userId)).willReturn(java.util.List.of()); + given(roomMemberRepository.findHostRoomsWithOnlySelf(userId)).willReturn(java.util.List.of()); + given(roomMemberRepository.findAllByUser_Id(userId)) + .willReturn(java.util.List.of(leftMember)); + + userWithdrawalService.withdraw(userId); + + verify(roomMemberRepository).delete(leftMember); +} +``` + +기존 `UserWithdrawalServiceTest`의 mock 셋업/필드 이름이 다를 수 있으니 그 클래스의 패턴에 맞춰 import와 변수명을 조정한다. + +- [ ] **Step 2: 테스트 실행 — 통과 확인** + +Run: `./gradlew test --tests com.howaboutus.backend.user.service.UserWithdrawalServiceTest` +Expected: PASS. + +- [ ] **Step 3: Checkstyle + 커밋** + +```bash +./gradlew checkstyleMain checkstyleTest +git add src/test/java/com/howaboutus/backend/user/service/UserWithdrawalServiceTest.java +git commit -m "test: 탈퇴 시 LEFT 멤버십도 hard delete 되는지 회귀 테스트 추가" +``` + +--- + +## Task 13: 문서 갱신 — `docs/ai/features.md`, `docs/ai/erd.md` + +**Files:** +- Modify: `docs/ai/features.md` +- Modify: `docs/ai/erd.md` + +- [ ] **Step 1: `features.md` 회원 탈퇴 행 보강 (라인 54)** + +기존 셀의 마지막 문장을 다음으로 교체: +> 채팅/북마크/일정의 BIGINT 작성자 ID는 유지. 클라이언트는 `GET /rooms/{id}/members` 응답(ACTIVE + LEFT 모두 포함)에서 닉네임/프로필을 조회한다. 그래도 members에 없는 ID는 **회원 탈퇴자**이며 "(알 수 없음)"으로 표시한다. + +- [ ] **Step 2: `features.md` 방 멤버 목록/방 나가기 행 갱신 (라인 80, 82)** + +- 방 멤버 목록 조회 셀: `방 참여자 목록 + 역할(HOST/MEMBER) + 접속 상태` → `방 참여자 목록 + 역할(HOST/MEMBER) + 상태(ACTIVE/LEFT) + 접속 상태. LEFT 멤버는 닉네임/프로필 조회용으로 함께 반환되며 online은 항상 false.` +- 방 나가기 셀: `본인이 방에서 탈퇴` → `본인이 방에서 탈퇴(room_members status=LEFT, row 유지). 채팅 로그(USER_LEFT 시스템 메시지)가 시점의 정본.` +- 멤버 추방 셀에도 동일하게 `(status=LEFT, row 유지)` 한 줄 추가. + +- [ ] **Step 3: `erd.md` `room_members` 테이블 (라인 57~74) 컬럼/제약 추가** + +기존 컬럼 표 아래에 다음 두 행을 추가: + +| status | VARCHAR(20) | NOT NULL, DEFAULT 'ACTIVE' | ACTIVE / LEFT (방 나가기·추방 시 LEFT) | +| left_at | TIMESTAMP WITH TIME ZONE | NULLABLE | LEFT 진입 시각. ACTIVE 면 NULL. 재입장 UPSERT 시 NULL로 복귀 | + +`**제약:**` 줄을 다음으로 확장: +``` +**제약:** UNIQUE(room_id, user_id), +CHECK ck_room_members_status (status IN ('ACTIVE','LEFT')), +CHECK ck_room_members_status_left_at ((status='ACTIVE' AND left_at IS NULL) OR (status='LEFT' AND left_at IS NOT NULL)), +CHECK ck_room_members_left_role (status='ACTIVE' OR role <> 'PENDING') +``` + +`**인덱스:**` 줄에 `(room_id, status) — 멤버 목록 조회 및 LEFT 룩업용` 한 줄 추가. + +- [ ] **Step 4: checking-md-conflicts 점검** + +문서 변경 후 자동 hook으로 트리거되는 `checking-md-conflicts` 스킬 결과가 "이슈 없음"인지 확인. + +- [ ] **Step 5: 커밋** + +```bash +git add docs/ai/features.md docs/ai/erd.md +git commit -m "docs: room_members status/left_at 도입을 features/erd에 반영" +``` + +--- + +## Task 14: 최종 빌드 + Checkstyle + Smoke 확인 + +- [ ] **Step 1: 클린 빌드 + 전체 테스트** + +Run: `./gradlew clean build` +Expected: BUILD SUCCESSFUL, 모든 테스트 PASS. + +- [ ] **Step 2: Checkstyle 0 warnings 재확인** + +Run: `./gradlew checkstyleMain checkstyleTest` +Expected: 0 warnings. + +- [ ] **Step 3: 변경 파일 스모크 점검** + +`git diff --stat origin/main...HEAD` 로 다음을 확인: +- `V1.8__room_members_status.sql` 1개 신규 마이그레이션 +- `RoomMember`, `RoomMemberRepository`, `RoomMemberService`, `RoomInviteService`, `RoomAuthorizationService`, `RoomMemberResult`, `RoomMemberResponse`, `AiContextQueryRepositoryImpl` 수정 +- `MemberStatus` 1개 신규 enum +- `features.md`, `erd.md` 갱신 +- 신규/수정된 테스트가 위 변경을 커버 + +- [ ] **Step 4: PR 준비 가이드 (별도 task 아님, 참고)** + +`/review-code-against-docs` 스킬로 PR 생성 전 검증을 돌린다. + +--- + +## Self-Review 체크리스트 (구현자가 PR 직전에 점검) + +- 모든 활성 멤버 조회 쿼리가 `status='ACTIVE'` 를 포함하는가? (`grep -rn "RoomRole\\." src/main/java | grep "in (" ` 로 누락 검색) +- `RoomMember`의 새 메서드(leave/kicked/rejoinAsPending)에 단위 테스트가 있는가? +- LEFT 멤버에 대해 `requireActiveMember` 가 `NOT_ROOM_MEMBER` 를 던지는가? +- `getMembers` 응답에서 LEFT 의 `online` 이 항상 false 인가? +- `requestJoin` 이 LEFT row를 같은 row로 UPSERT 부활시키는가? (`saveAndFlush`로 새 row 만들지 않음) +- 회원 탈퇴(`UserWithdrawalService`)가 LEFT row 까지 모두 삭제하는가? +- DB CHECK 제약 3개가 통합 테스트로 검증되는가? +- features.md / erd.md 가 동시에 갱신되었는가? diff --git a/docs/superpowers/specs/2026-06-07-user-withdrawal-design.md b/docs/superpowers/specs/2026-06-07-user-withdrawal-design.md index 216311b7..98930cde 100644 --- a/docs/superpowers/specs/2026-06-07-user-withdrawal-design.md +++ b/docs/superpowers/specs/2026-06-07-user-withdrawal-design.md @@ -48,7 +48,7 @@ ## 4. DB 스키마 변경 (`users`) ```sql -ALTER TABLE users ADD COLUMN deleted_at TIMESTAMP NULL; +ALTER TABLE users ADD COLUMN deleted_at TIMESTAMP WITH TIME ZONE; ALTER TABLE users ALTER COLUMN email DROP NOT NULL; ALTER TABLE users ALTER COLUMN nickname DROP NOT NULL; @@ -56,15 +56,21 @@ ALTER TABLE users ALTER COLUMN provider DROP NOT NULL; ALTER TABLE users ALTER COLUMN provider_id DROP NOT NULL; ALTER TABLE users ADD CONSTRAINT users_active_required CHECK ( - deleted_at IS NOT NULL - OR (email IS NOT NULL + (deleted_at IS NULL + AND email IS NOT NULL AND nickname IS NOT NULL AND provider IS NOT NULL AND provider_id IS NOT NULL) + OR + (deleted_at IS NOT NULL + AND email IS NULL + AND nickname IS NULL + AND provider IS NULL + AND provider_id IS NULL) ); -ALTER TABLE users DROP CONSTRAINT users_email_key; -ALTER TABLE users DROP CONSTRAINT users_provider_provider_id_key; +ALTER TABLE users DROP CONSTRAINT IF EXISTS users_email_key; +ALTER TABLE users DROP CONSTRAINT IF EXISTS uq_users_provider_provider_id; CREATE UNIQUE INDEX users_email_unique_active ON users(email) WHERE deleted_at IS NULL; @@ -75,13 +81,13 @@ CREATE UNIQUE INDEX users_provider_provider_id_unique_active CREATE INDEX users_deleted_at_idx ON users(deleted_at) WHERE deleted_at IS NOT NULL; ``` -> 기존 UNIQUE 제약 명칭은 실제 환경에서 `\d users`로 확인 후 정확히 지정한다. +> 기존 UNIQUE 제약 명칭은 V1__init.sql 기준이며, 환경별 이름 차이나 핫픽스 잔재에 대비해 `DROP CONSTRAINT IF EXISTS`로 방어한다. ### 결과로 보장되는 invariant - 활성 회원: `email`, `nickname`, `provider`, `provider_id` 모두 NOT NULL (CHECK) - 활성 회원 간: `email`, `(provider, provider_id)` 각각 UNIQUE (partial unique) -- 탈퇴 회원: 위 컬럼들 모두 NULL 허용, unique 검사 대상 제외 → 동일 Google 계정 재가입 가능 +- 탈퇴 회원: 위 컬럼들 모두 NULL 강제 (CHECK) → 익명화 누락 행을 DB 레벨에서 차단, unique 검사 대상 제외 → 동일 Google 계정 재가입 가능 ## 5. 엔티티 변경 (`User.java`) diff --git a/docs/superpowers/specs/2026-06-08-room-member-left-status-design.md b/docs/superpowers/specs/2026-06-08-room-member-left-status-design.md new file mode 100644 index 00000000..721d9b63 --- /dev/null +++ b/docs/superpowers/specs/2026-06-08-room-member-left-status-design.md @@ -0,0 +1,269 @@ +# Room Member LEFT Status Design + +- **작성일**: 2026-06-08 +- **상태**: 초안 (사용자 리뷰 대기) +- **관련 도메인**: `rooms`, `messages`, `ai`, `user` + +## 배경 + +현재 `room_members`는 회원 탈퇴(withdrawal), 단순 방 나가기(leave), 추방(kick) 세 경우 모두 row를 hard delete 한다. + +- 회원 탈퇴: `users` row는 soft delete + 익명화되므로 사용자 정보(닉네임/프로필) 자체가 사라짐 → '(알 수 없음)' 표시가 자연스러움. +- 방 나가기 / 추방: `users` row는 정상적으로 살아 있는데, `room_members`에서 row가 사라져 클라이언트가 과거 메시지의 작성자 닉네임/프로필을 표시할 수 없음. + +→ "탈퇴는 hard delete 유지, 단순 방 나가기·추방은 row를 남기고 상태로 구분"하여 과거 메시지 렌더링에 닉네임/프로필을 노출한다. + +## 목표 + +1. 회원 탈퇴 시 `room_members`는 hard delete를 유지한다. +2. 방 나가기(leave) / 추방(kick) 시 row를 남기고 `status=LEFT`로 표시한다. +3. 클라이언트가 `GET /rooms/{id}/members` 한 번으로 ACTIVE + LEFT 멤버 정보를 받아 과거 메시지에 닉네임/프로필을 렌더링한다. +4. LEFT 멤버의 재입장이 가능하도록 한다(같은 row를 UPSERT로 부활). + +## 비목표 + +- "언제 누가 나갔는가"의 영속 audit 로그 — 채팅 시스템 메시지(`USER_LEFT`, `MEMBER_KICKED`)가 정본. 재입장 UPSERT 시 `left_at`은 휘발된다. +- 자발 나감 / 추방 구분(LEFT/KICKED) — 단일 `LEFT`로 통일. +- PENDING 상태에서 LEFT로 전이 — 입장 요청 취소는 기존 hard delete 정책 유지. + +## 데이터 모델 + +### 마이그레이션 `V1.8__room_members_status.sql` (신규) + +```sql +-- 1) status / left_at 컬럼 추가 +ALTER TABLE room_members + ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + ADD COLUMN left_at TIMESTAMP WITH TIME ZONE; + +-- 2) status 유효값 +ALTER TABLE room_members + ADD CONSTRAINT ck_room_members_status + CHECK (status IN ('ACTIVE', 'LEFT')); + +-- 3) status ↔ left_at 무결성 +ALTER TABLE room_members + ADD CONSTRAINT ck_room_members_status_left_at + CHECK ( + (status = 'ACTIVE' AND left_at IS NULL) + OR (status = 'LEFT' AND left_at IS NOT NULL) + ); + +-- 4) LEFT row의 role=PENDING 차단 (PENDING의 LEFT 전이는 정책상 불가) +ALTER TABLE room_members + ADD CONSTRAINT ck_room_members_left_role + CHECK (status = 'ACTIVE' OR role <> 'PENDING'); + +-- 5) 멤버 목록/LEFT 룩업 인덱스 +CREATE INDEX idx_room_members_room_id_status ON room_members (room_id, status); + +-- 기존 UNIQUE(room_id, user_id)는 유지 — 재입장은 같은 row UPSERT. +``` + +### 엔티티 변경 + +**`MemberStatus` (신규 enum)** +```java +public enum MemberStatus { ACTIVE, LEFT } +``` + +**`RoomMember`** +- 필드 추가 + - `MemberStatus status` — NOT NULL, default `ACTIVE` + - `Instant leftAt` — nullable +- 도메인 메서드 신규 + - `leave()` — status=LEFT, leftAt=Instant.now(). 전제: status=ACTIVE. role은 그대로(스냅샷). + - `kicked()` — leave()와 동일 처리(LEFT 통일 정책). 별도 메서드명으로 의미 분리. + - `rejoinAsPending()` — status=ACTIVE, role=PENDING, leftAt=null. 전제: status=LEFT. +- 기존 도메인 메서드 가드 강화 + - `approve()`, `promoteToHost()`, `demoteToMember()` 모두 `status == ACTIVE` 일 때만 허용. 위반 시 `IllegalStateException`. + +### 상태 전이 + +``` +[없음] + └ requestJoin ─▶ ACTIVE/PENDING ─approve─▶ ACTIVE/MEMBER ─promote─▶ ACTIVE/HOST + │ │ + leave/kick │ │ demote + ▼ ▼ + LEFT/MEMBER ACTIVE/MEMBER + │ + requestJoin (재입장) + ▼ + ACTIVE/PENDING ─approve─▶ ACTIVE/MEMBER +``` + +- HOST는 위임 후 MEMBER로 강등된 뒤에만 leave 가능 → LEFT row의 role은 항상 `MEMBER`. +- LEFT row의 `role`은 LEFT 진입 시점의 마지막 역할 스냅샷. + +## 서비스 / 권한 변경 + +### `RoomMemberService` +- `leave()` — `roomMemberRepository.delete(member)` 대신 `member.leave()`. 이벤트(`MemberLeftEvent`) / 시스템 메시지 그대로. +- `kick()` — `roomMemberRepository.delete(target)` 대신 `target.kicked()`. 이벤트(`MemberKickedEvent`) / 시스템 메시지 그대로. +- LEFT 멤버를 다시 kick 시도 → `kick()`은 `findByRoom_IdAndUser_Id`로 status 무관 row를 조회하므로 별도 가드 추가 필요. 기존 role 체크 앞에 `target.getStatus() != MemberStatus.ACTIVE` 면 `KICK_TARGET_NOT_MEMBER`로 거부. + +### `RoomAuthorizationService.requireActiveMember` +```java +RoomMember member = roomMemberRepository.findByRoom_IdAndUser_Id(roomId, userId) + .orElseThrow(() -> new CustomException(NOT_ROOM_MEMBER)); +if (member.getStatus() == MemberStatus.LEFT) { + throw new CustomException(NOT_ROOM_MEMBER); // 신규 +} +if (member.getRole() == RoomRole.PENDING) { + throw new CustomException(NOT_ROOM_MEMBER); +} +return member; +``` +LEFT는 권한·구독·메시지 발송 등 모든 ACTIVE 경로에서 비멤버로 취급. + +### `RoomInviteService.requestJoin` (재입장 분기) +```java +if (existing.isPresent()) { + RoomMember member = existing.get(); + if (member.getStatus() == MemberStatus.LEFT) { + member.rejoinAsPending(); // status=ACTIVE, role=PENDING, leftAt=null + publishJoinRequestedEvent(...); // 기존과 동일 + return JoinResult.pending(...); + } + if (member.getRole() == RoomRole.PENDING) return JoinResult.pending(...); + return JoinResult.alreadyMember(...); +} +// else: 기존 신규 가입 로직(saveAndFlush PENDING) 그대로 +``` +- `lastReadMessageId`는 부활 시점에 손대지 않음. 이후 `approve()`에서 `readStatusService.initializeForNewMember(...)`가 "재입장 시점 최신 메시지 ID"로 점프 → 사용자 결정과 일치. +- 동시 재요청 시 UNIQUE(room_id, user_id) 충돌은 기존 `DataIntegrityViolationException` fallback 경로로 처리. + +### `RoomInviteService.approve` — 변경 없음 +`target.approve()` 가드가 `status==ACTIVE`이므로 LEFT row를 잘못 승인할 위험 없음. + +### `UserWithdrawalService` — 변경 없음 (동작은 의도대로) +- `findAllByUser_Id(userId)`는 status 무관 전체 row 반환 → ACTIVE/LEFT 모두 hard delete. +- HOST 위임 필요 판단 쿼리(`findHostRoomsWithOnlySelf`, `findHostRoomsWithOtherActiveMembers`)는 아래 리포지토리 변경에서 `status='ACTIVE'` 필터 추가로 정합 유지. + +## 리포지토리 / 쿼리 변경 + +| 메서드 | 변경 | +|---|---| +| `findByRoom_IdAndRole` | `AND status='ACTIVE'` 추가 | +| `findByRoom_IdAndRoleIn` | `AND status='ACTIVE'` 추가 | +| `countByRoom_IdAndRoleIn` | `AND status='ACTIVE'` 추가 | +| `findByUser_IdAndRoleInOrderByJoinedAtDesc` | `AND status='ACTIVE'` 추가 | +| `findByUser_IdAndRoleInAndJoinedAtBeforeOrderByJoinedAtDesc` | `AND status='ACTIVE'` 추가 | +| `findHostRoomsWithOnlySelf` (JPQL) | `m.status='ACTIVE'`, `other.status='ACTIVE'` 추가 | +| `findHostRoomsWithOtherActiveMembers` (JPQL) | 동일 | +| `findAllByUser_Id` | **변경 없음** (withdraw에서 status 무관 전체 삭제) | +| `findByRoom_IdAndUser_Id` | **변경 없음** (LEFT 부활/감지에 필요) | +| `findByIdAndRoom_Id` | **변경 없음** (approve 가드가 status 검증) | + +**신규 메서드 (멤버 목록 전용)** +```java +@EntityGraph(attributePaths = "user") +@Query(""" + select m from RoomMember m + where m.room.id = :roomId + and ((m.status = com.howaboutus.backend.rooms.entity.MemberStatus.ACTIVE + and m.role in (com.howaboutus.backend.rooms.entity.RoomRole.HOST, + com.howaboutus.backend.rooms.entity.RoomRole.MEMBER)) + or m.status = com.howaboutus.backend.rooms.entity.MemberStatus.LEFT) +""") +List findVisibleMembers(@Param("roomId") UUID roomId); +``` +`RoomMemberService.getMembers`에서 호출. PENDING은 제외(가입 대기자는 멤버 UI에 노출 안 함, 기존과 동일). + +### `AiContextQueryRepositoryImpl` +- 라인 38 인근 쿼리(`roles IN (HOST, MEMBER)`)에 `status='ACTIVE'` 필터 추가. AI 컨텍스트에 떠난 멤버 노출 방지. + +## API 응답 + +### `GET /rooms/{roomId}/members` + +```jsonc +{ + "members": [ + { + "userId": 1, "nickname": "호스트", "profileImageUrl": "...", + "role": "HOST", "status": "ACTIVE", "online": true, + "joinedAt": "2026-06-01T10:00:00Z" + }, + { + "userId": 7, "nickname": "나간사람", "profileImageUrl": "...", + "role": "MEMBER", + "status": "LEFT", + "online": false, + "joinedAt": "2026-05-20T09:00:00Z" + } + ] +} +``` + +- `status` 필드 신규 (`ACTIVE` | `LEFT`). +- LEFT 멤버의 `online`은 **항상 false** (Redis presence 조회 무시). +- `leftAt`은 응답에 노출하지 않음. UPSERT로 휘발되는 값이라 클라이언트가 의존하면 부정확. "언제 떠났는가"는 채팅 시스템 메시지(`USER_LEFT`, `MEMBER_KICKED`)가 정본. +- 정렬: ACTIVE(HOST 우선, 그 다음 MEMBER joinedAt 오름차순) → LEFT(joinedAt 오름차순). +- 권한: 기존과 동일하게 `requireActiveMember`. LEFT 멤버는 본인이 떠난 방의 멤버 목록 조회 불가. + +### 시스템 메시지 / 실시간 브로드캐스트 — 변경 없음 +- `USER_LEFT`, `MEMBER_KICKED`, `MEMBER_APPROVED` 그대로. +- 클라이언트는 `MEMBER_APPROVED` 수신 시 멤버 목록을 갱신하면, LEFT였던 사람이 ACTIVE로 바뀐 게 자연스럽게 반영됨. + +## 미해결 작성자 표시 정책 + +`features.md` 회원 탈퇴 행 갱신: +> 채팅/북마크/일정의 BIGINT 작성자 ID는 유지. 클라이언트는 `GET /rooms/{id}/members` 응답(ACTIVE + LEFT 모두 포함)에서 닉네임/프로필을 조회한다. 그래도 members에 없는 ID는 **회원 탈퇴자**이며 "(알 수 없음)"으로 표시한다. + +## 테스트 계획 + +### 단위 — `RoomMemberTest` +- `leave()` → status=LEFT, leftAt != null, role 유지 +- `kicked()` → 동일 +- `rejoinAsPending()` → status=ACTIVE, role=PENDING, leftAt=null +- `approve()` / `promoteToHost()` / `demoteToMember()`가 status=LEFT에서 `IllegalStateException` + +### 단위 — `RoomAuthorizationServiceTest` +- LEFT 멤버에 대해 `requireActiveMember` → `NOT_ROOM_MEMBER` +- `requireHost`는 위와 동일하게 차단 + +### 통합 — `RoomMemberServiceTest` +- leave 후 row 존재 + status=LEFT, ACTIVE 카운트 감소, `MemberLeftEvent` 발행 +- kick 후 동일 (status=LEFT, `MemberKickedEvent` 발행) +- HOST가 leave 시도 → `CANNOT_LEAVE_AS_HOST` (기존) +- LEFT 상태의 멤버를 다시 kick → `KICK_TARGET_NOT_MEMBER` +- `getMembers` 응답이 ACTIVE + LEFT 둘 다 포함, LEFT.online=false, 정렬 순서 + +### 통합 — `RoomInviteServiceTest` +- LEFT 유저가 invite code로 재요청 → 같은 row가 ACTIVE/PENDING으로 부활, `JoinRequestedEvent` 발행 +- 재요청 시 lastReadMessageId는 부활 시점에 변하지 않고, `approve` 이후 `initializeForNewMember`가 점프 처리 +- 동시 재요청 → UNIQUE 충돌 fallback 동작 유지 + +### 통합 — `UserWithdrawalServiceTest` +- LEFT row를 가진 유저가 탈퇴 → 해당 LEFT row까지 hard delete +- 본인이 HOST인 방에 다른 멤버가 LEFT만 있으면 solo host로 판정되어 자동 삭제 (LEFT는 other active로 카운트 안 됨) + +### DB 제약 — 마이그레이션/통합 테스트 +- `status=LEFT` + `left_at IS NULL` insert → CHECK 위반 +- `status=ACTIVE` + `left_at NOT NULL` insert → CHECK 위반 +- `status=LEFT` + `role=PENDING` insert → CHECK 위반 + +### AI 컨텍스트 회귀 +- `AiContextQueryRepositoryImpl` 쿼리 결과에 LEFT 멤버가 포함되지 않음 + +## 함께 갱신할 문서 + +- `docs/ai/features.md` + - "방 나가기" 행: `room_members hard delete` → `room_members status=LEFT (row 유지)` + - "회원 탈퇴" 행: 작성자 표시 정책을 위 "미해결 작성자 표시 정책" 문장으로 보강 +- `docs/ai/erd.md` + - `room_members` 테이블에 `status`, `left_at` 컬럼 + 3개 CHECK 제약 + 인덱스 명시 + +## 변경 영향 요약 + +| 영역 | 변경 | +|---|---| +| DB | `room_members` 컬럼 2개 + CHECK 3개 + 인덱스 1개 | +| 엔티티 | `RoomMember` 필드 2개 + 메서드 3개, `MemberStatus` enum 신규 | +| 서비스 | `RoomMemberService`(leave/kick), `RoomInviteService`(requestJoin), `RoomAuthorizationService`(LEFT 차단) | +| 리포지토리 | 6개 메서드에 `status='ACTIVE'` 필터 추가, 멤버 목록 전용 쿼리 1개 신설 | +| 외부 도메인 | `AiContextQueryRepositoryImpl` 1개 쿼리 | +| API | `GET /rooms/{id}/members` 응답에 `status` 필드 추가, LEFT 멤버 포함 | +| 문서 | features.md, erd.md | diff --git a/src/main/java/com/howaboutus/backend/ai/repository/AiContextQueryRepositoryImpl.java b/src/main/java/com/howaboutus/backend/ai/repository/AiContextQueryRepositoryImpl.java index 6e374bb4..977ed4ce 100644 --- a/src/main/java/com/howaboutus/backend/ai/repository/AiContextQueryRepositoryImpl.java +++ b/src/main/java/com/howaboutus/backend/ai/repository/AiContextQueryRepositoryImpl.java @@ -14,6 +14,7 @@ import com.howaboutus.backend.common.integration.ai.dto.AiBookmarkedPlaceItem; import com.howaboutus.backend.common.integration.ai.dto.AiScheduledDay; import com.howaboutus.backend.common.integration.ai.dto.AiScheduledPlaceItem; +import com.howaboutus.backend.rooms.entity.MemberStatus; import com.howaboutus.backend.rooms.entity.RoomRole; import com.howaboutus.backend.schedules.entity.Schedule; import com.howaboutus.backend.schedules.entity.ScheduleItem; @@ -33,9 +34,12 @@ public class AiContextQueryRepositoryImpl implements AiContextQueryRepository { public Integer countApprovedParticipants(UUID roomId) { return ((Number) em.createQuery( "select count(rm) from RoomMember rm " - + "where rm.room.id = :roomId and rm.role in :roles") + + "where rm.room.id = :roomId " + + "and rm.role in :roles " + + "and rm.status = :activeStatus") .setParameter(ROOM_ID_PARAM, roomId) .setParameter("roles", List.of(RoomRole.HOST, RoomRole.MEMBER)) + .setParameter("activeStatus", MemberStatus.ACTIVE) .getSingleResult()).intValue(); } diff --git a/src/main/java/com/howaboutus/backend/messages/controller/dto/MessageResponse.java b/src/main/java/com/howaboutus/backend/messages/controller/dto/MessageResponse.java index 6c707733..0038fff3 100644 --- a/src/main/java/com/howaboutus/backend/messages/controller/dto/MessageResponse.java +++ b/src/main/java/com/howaboutus/backend/messages/controller/dto/MessageResponse.java @@ -15,6 +15,13 @@ public record MessageResponse( UUID roomId, Long senderId, MessageType messageType, + @Schema(description = """ + 사람이 읽을 수 있는 메시지 본문. + + SYSTEM 메시지의 경우 저장 시점의 닉네임이 그대로 박혀 있는 fallback 문장입니다. + 탈퇴·닉네임 변경 이후에도 갱신되지 않으므로, 클라이언트는 SYSTEM 메시지에 한해 + metadata.eventType + userId를 기준으로 현재 멤버 정보를 lookup하여 본문을 재구성하는 것을 + 권장합니다. 멤버 목록에 없는 userId(탈퇴/방 나감)는 "(알 수 없음)"으로 표시합니다.""") String content, @Schema(description = """ 상위 messageType에 따라 구조가 달라지는 확장 데이터입니다. @@ -33,6 +40,10 @@ public record MessageResponse( - SYSTEM HOST_DELEGATED: { eventType, previousHostUserId, previousHostNickname, newHostUserId, newHostNickname } + SYSTEM 메시지의 metadata는 클라이언트 재렌더링 계약입니다. content는 저장 시점의 + 닉네임이 박힌 fallback이고, 화면 표시 문구는 metadata의 userId를 현재 방 멤버 정보로 + lookup하여 조립합니다. + nullable 표기된 optional 필드는 서버에서 null로 보내지 않고 metadata에서 생략됩니다.""") Map metadata, Instant createdAt diff --git a/src/main/java/com/howaboutus/backend/realtime/service/dto/MessagePayload.java b/src/main/java/com/howaboutus/backend/realtime/service/dto/MessagePayload.java index 6dafd76b..782dc266 100644 --- a/src/main/java/com/howaboutus/backend/realtime/service/dto/MessagePayload.java +++ b/src/main/java/com/howaboutus/backend/realtime/service/dto/MessagePayload.java @@ -16,6 +16,13 @@ public record MessagePayload( UUID roomId, Long senderId, MessageType messageType, + @Schema(description = """ + 사람이 읽을 수 있는 메시지 본문. + + SYSTEM 메시지의 경우 저장 시점의 닉네임이 그대로 박혀 있는 fallback 문장입니다. + 탈퇴·닉네임 변경 이후에도 갱신되지 않으므로, 클라이언트는 SYSTEM 메시지에 한해 + metadata.eventType + userId를 기준으로 현재 멤버 정보를 lookup하여 본문을 재구성하는 것을 + 권장합니다. 멤버 목록에 없는 userId(탈퇴/방 나감)는 "(알 수 없음)"으로 표시합니다.""") String content, @Schema(description = """ 상위 messageType에 따라 구조가 달라지는 확장 데이터입니다. @@ -34,6 +41,10 @@ public record MessagePayload( - SYSTEM HOST_DELEGATED: { eventType, previousHostUserId, previousHostNickname, newHostUserId, newHostNickname } + SYSTEM 메시지의 metadata는 클라이언트 재렌더링 계약입니다. content는 저장 시점의 + 닉네임이 박힌 fallback이고, 화면 표시 문구는 metadata의 userId를 현재 방 멤버 정보로 + lookup하여 조립합니다. + nullable 표기된 optional 필드는 서버에서 null로 보내지 않고 metadata에서 생략됩니다.""") Map metadata, Instant createdAt diff --git a/src/main/java/com/howaboutus/backend/rooms/controller/dto/RoomMemberResponse.java b/src/main/java/com/howaboutus/backend/rooms/controller/dto/RoomMemberResponse.java index dd5cb073..90fee7c5 100644 --- a/src/main/java/com/howaboutus/backend/rooms/controller/dto/RoomMemberResponse.java +++ b/src/main/java/com/howaboutus/backend/rooms/controller/dto/RoomMemberResponse.java @@ -2,6 +2,7 @@ import java.time.Instant; +import com.howaboutus.backend.rooms.entity.MemberStatus; import com.howaboutus.backend.rooms.entity.RoomRole; import com.howaboutus.backend.rooms.service.dto.RoomMemberResult; @@ -10,6 +11,7 @@ public record RoomMemberResponse( String nickname, String profileImageUrl, RoomRole role, + MemberStatus status, boolean isOnline, Instant joinedAt ) { @@ -19,6 +21,7 @@ public static RoomMemberResponse from(RoomMemberResult result) { result.nickname(), result.profileImageUrl(), result.role(), + result.status(), result.isOnline(), result.joinedAt()); } diff --git a/src/main/java/com/howaboutus/backend/rooms/entity/MemberStatus.java b/src/main/java/com/howaboutus/backend/rooms/entity/MemberStatus.java new file mode 100644 index 00000000..5f0dce25 --- /dev/null +++ b/src/main/java/com/howaboutus/backend/rooms/entity/MemberStatus.java @@ -0,0 +1,5 @@ +package com.howaboutus.backend.rooms.entity; + +public enum MemberStatus { + ACTIVE, LEFT +} diff --git a/src/main/java/com/howaboutus/backend/rooms/entity/RoomMember.java b/src/main/java/com/howaboutus/backend/rooms/entity/RoomMember.java index 14c1b1c9..fdd0796b 100644 --- a/src/main/java/com/howaboutus/backend/rooms/entity/RoomMember.java +++ b/src/main/java/com/howaboutus/backend/rooms/entity/RoomMember.java @@ -51,6 +51,13 @@ public class RoomMember extends BaseTimeEntity { @Column(nullable = false) private Instant joinedAt; + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private MemberStatus status = MemberStatus.ACTIVE; + + @Column(name = "left_at") + private Instant leftAt; + // MongoDB ObjectId 문자열 (24자). 이 멤버가 마지막으로 읽은 메시지 ID. // null이면 아직 한 번도 읽지 않은 상태 → 모든 메시지가 안읽음 처리됨. @Column(length = 24) @@ -61,6 +68,7 @@ private RoomMember(Room room, User user, RoomRole role) { this.user = user; this.role = role; this.joinedAt = Instant.now(); + this.status = MemberStatus.ACTIVE; } public static RoomMember create(Room room, User user, RoomRole role) { @@ -81,6 +89,9 @@ public void updateLastReadMessageId(String messageId) { } public void approve() { + if (this.status != MemberStatus.ACTIVE) { + throw new IllegalStateException("ACTIVE 상태의 멤버만 승인할 수 있습니다. 현재 상태: " + this.status); + } if (this.role != RoomRole.PENDING) { throw new IllegalStateException("PENDING 상태의 멤버만 승인할 수 있습니다. 현재 상태: " + this.role); } @@ -88,6 +99,9 @@ public void approve() { } public void promoteToHost() { + if (this.status != MemberStatus.ACTIVE) { + throw new IllegalStateException("ACTIVE 상태의 멤버만 승격할 수 있습니다. 현재 상태: " + this.status); + } if (this.role != RoomRole.MEMBER) { throw new IllegalStateException("MEMBER 상태의 멤버만 HOST로 승격할 수 있습니다. 현재 상태: " + this.role); } @@ -95,9 +109,37 @@ public void promoteToHost() { } public void demoteToMember() { + if (this.status != MemberStatus.ACTIVE) { + throw new IllegalStateException("ACTIVE 상태의 멤버만 강등할 수 있습니다. 현재 상태: " + this.status); + } if (this.role != RoomRole.HOST) { throw new IllegalStateException("HOST 상태의 멤버만 MEMBER로 강등할 수 있습니다. 현재 상태: " + this.role); } this.role = RoomRole.MEMBER; } + + public void leave() { + if (this.status != MemberStatus.ACTIVE) { + throw new IllegalStateException("ACTIVE 상태의 멤버만 LEFT로 전환할 수 있습니다. 현재 상태: " + this.status); + } + this.status = MemberStatus.LEFT; + this.leftAt = Instant.now(); + } + + public void kicked() { + if (this.status != MemberStatus.ACTIVE) { + throw new IllegalStateException("ACTIVE 상태의 멤버만 LEFT로 전환할 수 있습니다. 현재 상태: " + this.status); + } + this.status = MemberStatus.LEFT; + this.leftAt = Instant.now(); + } + + public void rejoinAsPending() { + if (this.status != MemberStatus.LEFT) { + throw new IllegalStateException("LEFT 상태의 멤버만 재입장(PENDING) 으로 부활할 수 있습니다. 현재 상태: " + this.status); + } + this.status = MemberStatus.ACTIVE; + this.role = RoomRole.PENDING; + this.leftAt = null; + } } diff --git a/src/main/java/com/howaboutus/backend/rooms/repository/RoomMemberRepository.java b/src/main/java/com/howaboutus/backend/rooms/repository/RoomMemberRepository.java index 6c0e83ba..c4899ac8 100644 --- a/src/main/java/com/howaboutus/backend/rooms/repository/RoomMemberRepository.java +++ b/src/main/java/com/howaboutus/backend/rooms/repository/RoomMemberRepository.java @@ -21,30 +21,79 @@ public interface RoomMemberRepository extends JpaRepository { Optional findByRoom_IdAndUser_Id(UUID roomId, Long userId); @EntityGraph(attributePaths = "user") - List findByRoom_IdAndRole(UUID roomId, RoomRole role); + @Query(""" + select m from RoomMember m + where m.room.id = :roomId + and m.role = :role + and m.status = com.howaboutus.backend.rooms.entity.MemberStatus.ACTIVE + """) + List findByRoom_IdAndRole(@Param("roomId") UUID roomId, @Param("role") RoomRole role); @EntityGraph(attributePaths = "user") - List findByRoom_IdAndRoleIn(UUID roomId, List roles); + @Query(""" + select m from RoomMember m + where m.room.id = :roomId + and m.role in :roles + and m.status = com.howaboutus.backend.rooms.entity.MemberStatus.ACTIVE + """) + List findByRoom_IdAndRoleIn(@Param("roomId") UUID roomId, @Param("roles") List roles); Optional findByIdAndRoom_Id(Long id, UUID roomId); @EntityGraph(attributePaths = "room") + @Query(""" + select m from RoomMember m + where m.user.id = :userId + and m.role in :roles + and m.status = com.howaboutus.backend.rooms.entity.MemberStatus.ACTIVE + order by m.joinedAt desc + """) List findByUser_IdAndRoleInOrderByJoinedAtDesc( - Long userId, List roles, Pageable pageable); + @Param("userId") Long userId, @Param("roles") List roles, Pageable pageable); @EntityGraph(attributePaths = "room") + @Query(""" + select m from RoomMember m + where m.user.id = :userId + and m.role in :roles + and m.status = com.howaboutus.backend.rooms.entity.MemberStatus.ACTIVE + and m.joinedAt < :cursor + order by m.joinedAt desc + """) List findByUser_IdAndRoleInAndJoinedAtBeforeOrderByJoinedAtDesc( - Long userId, List roles, Instant cursor, Pageable pageable); + @Param("userId") Long userId, @Param("roles") List roles, + @Param("cursor") Instant cursor, Pageable pageable); - long countByRoom_IdAndRoleIn(UUID roomId, List roles); + @Query(""" + select count(m) from RoomMember m + where m.room.id = :roomId + and m.role in :roles + and m.status = com.howaboutus.backend.rooms.entity.MemberStatus.ACTIVE + """) + long countByRoom_IdAndRoleIn(@Param("roomId") UUID roomId, @Param("roles") List roles); List findAllByUser_Id(Long userId); + @EntityGraph(attributePaths = "user") + @Query(""" + select m from RoomMember m + where m.room.id = :roomId + and ( + (m.status = com.howaboutus.backend.rooms.entity.MemberStatus.ACTIVE + and m.role in ( + com.howaboutus.backend.rooms.entity.RoomRole.HOST, + com.howaboutus.backend.rooms.entity.RoomRole.MEMBER)) + or m.status = com.howaboutus.backend.rooms.entity.MemberStatus.LEFT + ) + """) + List findVisibleMembers(@Param("roomId") UUID roomId); + @Query(""" select m.room from RoomMember m where m.user.id = :userId and m.role = com.howaboutus.backend.rooms.entity.RoomRole.HOST + and m.status = com.howaboutus.backend.rooms.entity.MemberStatus.ACTIVE and not exists ( select 1 from RoomMember other where other.room.id = m.room.id @@ -52,6 +101,7 @@ and not exists ( and other.role in ( com.howaboutus.backend.rooms.entity.RoomRole.HOST, com.howaboutus.backend.rooms.entity.RoomRole.MEMBER) + and other.status = com.howaboutus.backend.rooms.entity.MemberStatus.ACTIVE ) """) List findHostRoomsWithOnlySelf(@Param("userId") Long userId); @@ -67,6 +117,7 @@ interface RoomRequiringDelegationView { from RoomMember m where m.user.id = :userId and m.role = com.howaboutus.backend.rooms.entity.RoomRole.HOST + and m.status = com.howaboutus.backend.rooms.entity.MemberStatus.ACTIVE and exists ( select 1 from RoomMember other where other.room.id = m.room.id @@ -74,6 +125,7 @@ and exists ( and other.role in ( com.howaboutus.backend.rooms.entity.RoomRole.HOST, com.howaboutus.backend.rooms.entity.RoomRole.MEMBER) + and other.status = com.howaboutus.backend.rooms.entity.MemberStatus.ACTIVE ) """) List findHostRoomsWithOtherActiveMembers(@Param("userId") Long userId); diff --git a/src/main/java/com/howaboutus/backend/rooms/repository/RoomRepository.java b/src/main/java/com/howaboutus/backend/rooms/repository/RoomRepository.java index bc187c34..2bdfa825 100644 --- a/src/main/java/com/howaboutus/backend/rooms/repository/RoomRepository.java +++ b/src/main/java/com/howaboutus/backend/rooms/repository/RoomRepository.java @@ -1,5 +1,6 @@ package com.howaboutus.backend.rooms.repository; +import java.util.List; import java.util.Optional; import java.util.UUID; @@ -19,4 +20,16 @@ public interface RoomRepository extends JpaRepository { @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("select r from Room r where r.id = :roomId") Optional findByIdForUpdate(@Param("roomId") UUID roomId); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query(""" + select r from Room r + where r.id in ( + select m.room.id from RoomMember m + where m.user.id = :userId + and m.role = com.howaboutus.backend.rooms.entity.RoomRole.HOST + ) + order by r.id + """) + List lockHostRoomsByUser(@Param("userId") Long userId); } diff --git a/src/main/java/com/howaboutus/backend/rooms/service/RoomAuthorizationService.java b/src/main/java/com/howaboutus/backend/rooms/service/RoomAuthorizationService.java index 216c9c97..a84052d7 100644 --- a/src/main/java/com/howaboutus/backend/rooms/service/RoomAuthorizationService.java +++ b/src/main/java/com/howaboutus/backend/rooms/service/RoomAuthorizationService.java @@ -7,6 +7,7 @@ import com.howaboutus.backend.common.error.CustomException; import com.howaboutus.backend.common.error.ErrorCode; +import com.howaboutus.backend.rooms.entity.MemberStatus; import com.howaboutus.backend.rooms.entity.RoomMember; import com.howaboutus.backend.rooms.entity.RoomRole; import com.howaboutus.backend.rooms.repository.RoomMemberRepository; @@ -23,6 +24,9 @@ public class RoomAuthorizationService { public RoomMember requireActiveMember(UUID roomId, Long userId) { RoomMember member = roomMemberRepository.findByRoom_IdAndUser_Id(roomId, userId) .orElseThrow(() -> new CustomException(ErrorCode.NOT_ROOM_MEMBER)); + if (member.getStatus() == MemberStatus.LEFT) { + throw new CustomException(ErrorCode.NOT_ROOM_MEMBER); + } if (member.getRole() == RoomRole.PENDING) { throw new CustomException(ErrorCode.NOT_ROOM_MEMBER); } diff --git a/src/main/java/com/howaboutus/backend/rooms/service/RoomInviteService.java b/src/main/java/com/howaboutus/backend/rooms/service/RoomInviteService.java index ca681d9b..3d183866 100644 --- a/src/main/java/com/howaboutus/backend/rooms/service/RoomInviteService.java +++ b/src/main/java/com/howaboutus/backend/rooms/service/RoomInviteService.java @@ -15,6 +15,7 @@ import com.howaboutus.backend.messages.service.ReadStatusService; import com.howaboutus.backend.realtime.event.JoinRequestedEvent; import com.howaboutus.backend.realtime.event.MemberApprovedEvent; +import com.howaboutus.backend.rooms.entity.MemberStatus; import com.howaboutus.backend.rooms.entity.Room; import com.howaboutus.backend.rooms.entity.RoomMember; import com.howaboutus.backend.rooms.entity.RoomRole; @@ -65,6 +66,12 @@ public JoinResult requestJoin(String inviteCode, Long userId) { if (existing.isPresent()) { RoomMember member = existing.get(); + if (member.getStatus() == MemberStatus.LEFT) { + // 같은 row를 ACTIVE/PENDING 으로 부활 (UPSERT 효과). lastReadMessageId는 손대지 않는다. + member.rejoinAsPending(); + publishJoinRequested(room, member.getUser()); + return JoinResult.pending(room.getId(), room.getTitle()); + } if (member.getRole() == RoomRole.PENDING) { return JoinResult.pending(room.getId(), room.getTitle()); } @@ -78,6 +85,11 @@ public JoinResult requestJoin(String inviteCode, Long userId) { log.warn("Concurrent join request detected. roomId={}, userId={}", room.getId(), userId, e); return JoinResult.pending(room.getId(), room.getTitle()); } + publishJoinRequested(room, user); + return JoinResult.pending(room.getId(), room.getTitle()); + } + + private void publishJoinRequested(Room room, User user) { List hostUserIds = roomMemberRepository.findByRoom_IdAndRole(room.getId(), RoomRole.HOST) .stream() .map(m -> m.getUser().getId()) @@ -89,7 +101,6 @@ public JoinResult requestJoin(String inviteCode, Long userId) { user.getProfileImageUrl(), hostUserIds )); - return JoinResult.pending(room.getId(), room.getTitle()); } // 입장 요청 상태 조회 @@ -99,6 +110,10 @@ public JoinStatusResult getJoinStatus(UUID roomId, Long userId) { RoomMember member = roomMemberRepository.findByRoom_IdAndUser_Id(roomId, userId) .orElseThrow(() -> new CustomException(ErrorCode.JOIN_REQUEST_NOT_FOUND)); + // LEFT row 는 활성 입장/요청 상태가 아니므로 비멤버와 동일하게 취급한다. + if (member.getStatus() == MemberStatus.LEFT) { + throw new CustomException(ErrorCode.JOIN_REQUEST_NOT_FOUND); + } if (member.getRole() == RoomRole.PENDING) { return JoinStatusResult.pending(room.getId(), room.getTitle()); } @@ -126,7 +141,9 @@ public List getJoinRequests(UUID roomId, Long userId) { @Loggable @Transactional public void approve(UUID roomId, Long requestId, Long userId) { - requireActiveRoomExists(roomId); + // Room을 잠가 호스트의 동시 탈퇴 트랜잭션과 승격이 직렬화되도록 한다. + roomRepository.findByIdForUpdate(roomId) + .orElseThrow(() -> new CustomException(ErrorCode.ROOM_NOT_FOUND)); roomAuthorizationService.requireHost(roomId, userId); RoomMember target = roomMemberRepository.findByIdAndRoom_Id(requestId, roomId) diff --git a/src/main/java/com/howaboutus/backend/rooms/service/RoomMemberService.java b/src/main/java/com/howaboutus/backend/rooms/service/RoomMemberService.java index 87d9564c..69c8f37e 100644 --- a/src/main/java/com/howaboutus/backend/rooms/service/RoomMemberService.java +++ b/src/main/java/com/howaboutus/backend/rooms/service/RoomMemberService.java @@ -1,6 +1,7 @@ package com.howaboutus.backend.rooms.service; import java.util.Collections; +import java.util.Comparator; import java.util.List; import java.util.Set; import java.util.UUID; @@ -17,6 +18,7 @@ import com.howaboutus.backend.realtime.event.MemberKickedEvent; import com.howaboutus.backend.realtime.event.MemberLeftEvent; import com.howaboutus.backend.realtime.service.RoomPresenceService; +import com.howaboutus.backend.rooms.entity.MemberStatus; import com.howaboutus.backend.rooms.entity.RoomMember; import com.howaboutus.backend.rooms.entity.RoomRole; import com.howaboutus.backend.rooms.repository.RoomMemberRepository; @@ -31,7 +33,12 @@ @Transactional(readOnly = true) public class RoomMemberService { - private static final List ACTIVE_ROLES = List.of(RoomRole.HOST, RoomRole.MEMBER); + // 멤버 목록 정렬: ACTIVE 먼저 → HOST 먼저 → joinedAt 오름차순 → LEFT(joinedAt 오름차순) + private static final Comparator MEMBER_DISPLAY_ORDER = + Comparator + .comparing((RoomMember m) -> m.getStatus() == MemberStatus.LEFT) + .thenComparing(m -> m.getRole() != RoomRole.HOST) + .thenComparing(RoomMember::getJoinedAt); private final RoomMemberRepository roomMemberRepository; private final RoomPresenceService roomPresenceService; @@ -41,16 +48,18 @@ public class RoomMemberService { public List getMembers(UUID roomId, Long userId) { roomAuthorizationService.requireActiveMember(roomId, userId); - List members = roomMemberRepository.findByRoom_IdAndRoleIn(roomId, ACTIVE_ROLES); + List members = roomMemberRepository.findVisibleMembers(roomId); Set onlineUserIds = getOnlineUserIdsSafe(roomId); return members.stream() + .sorted(MEMBER_DISPLAY_ORDER) .map(m -> new RoomMemberResult( m.getUser().getId(), m.getUser().getNickname(), m.getUser().getProfileImageUrl(), m.getRole(), - onlineUserIds.contains(m.getUser().getId()), + m.getStatus(), + m.getStatus() == MemberStatus.ACTIVE && onlineUserIds.contains(m.getUser().getId()), m.getJoinedAt())) .toList(); } @@ -73,18 +82,22 @@ public void kick(UUID roomId, Long targetUserId, Long hostUserId) { //2. 타겟이 방 멤버인지 체크 RoomMember target = roomMemberRepository.findByRoom_IdAndUser_Id(roomId, targetUserId) .orElseThrow(() -> new CustomException(ErrorCode.ROOM_MEMBER_NOT_FOUND)); - //3. 호스트를 킥하려는지 체크, 호스트는 자기 자신을 추방할 수 없음 + //3. 이미 LEFT 상태인 멤버는 다시 추방할 수 없음 (row는 닉네임/프로필 노출용으로만 남아 있음) + if (target.getStatus() != MemberStatus.ACTIVE) { + throw new CustomException(ErrorCode.KICK_TARGET_NOT_MEMBER); + } + //4. 호스트를 킥하려는지 체크, 호스트는 자기 자신을 추방할 수 없음 if (target.getRole() == RoomRole.HOST) { throw new CustomException(ErrorCode.CANNOT_KICK_HOST); } - //4. 타겟이 멤버인지 체크, 멤버만 추방 가능 + //5. 타겟이 멤버인지 체크, 멤버만 추방 가능 if (target.getRole() != RoomRole.MEMBER) { throw new CustomException(ErrorCode.KICK_TARGET_NOT_MEMBER); } - //5. DB에서 멤버 추방처리 - roomMemberRepository.delete(target); - //6. 추방 이벤트 발행 + //6. status=LEFT 로 전환 (row 유지 — 과거 메시지 렌더링용 닉네임/프로필 보존) + target.kicked(); + //7. 추방 이벤트 발행 eventPublisher.publishEvent(new MemberKickedEvent( roomId, target.getUser().getId(), @@ -102,7 +115,7 @@ public void leave(UUID roomId, Long userId) { throw new CustomException(ErrorCode.CANNOT_LEAVE_AS_HOST); } - roomMemberRepository.delete(member); + member.leave(); eventPublisher.publishEvent(new MemberLeftEvent( roomId, member.getUser().getId(), @@ -123,6 +136,10 @@ public void delegateHost(UUID roomId, Long targetUserId, Long hostUserId) { RoomMember target = roomMemberRepository.findByRoom_IdAndUser_Id(roomId, targetUserId) .orElseThrow(() -> new CustomException(ErrorCode.ROOM_MEMBER_NOT_FOUND)); + // LEFT row 는 닉네임/프로필 보존용으로 남아 있을 뿐 활성 멤버가 아니므로 위임 대상이 될 수 없다. + if (target.getStatus() != MemberStatus.ACTIVE) { + throw new CustomException(ErrorCode.DELEGATE_TARGET_NOT_MEMBER); + } if (target.getRole() != RoomRole.MEMBER) { throw new CustomException(ErrorCode.DELEGATE_TARGET_NOT_MEMBER); } diff --git a/src/main/java/com/howaboutus/backend/rooms/service/dto/RoomMemberResult.java b/src/main/java/com/howaboutus/backend/rooms/service/dto/RoomMemberResult.java index 12f3aa32..f7d7d4df 100644 --- a/src/main/java/com/howaboutus/backend/rooms/service/dto/RoomMemberResult.java +++ b/src/main/java/com/howaboutus/backend/rooms/service/dto/RoomMemberResult.java @@ -2,6 +2,7 @@ import java.time.Instant; +import com.howaboutus.backend.rooms.entity.MemberStatus; import com.howaboutus.backend.rooms.entity.RoomRole; public record RoomMemberResult( @@ -9,6 +10,7 @@ public record RoomMemberResult( String nickname, String profileImageUrl, RoomRole role, + MemberStatus status, boolean isOnline, Instant joinedAt ) { diff --git a/src/main/java/com/howaboutus/backend/user/service/UserWithdrawalService.java b/src/main/java/com/howaboutus/backend/user/service/UserWithdrawalService.java index dfc29c9e..c2bb4002 100644 --- a/src/main/java/com/howaboutus/backend/user/service/UserWithdrawalService.java +++ b/src/main/java/com/howaboutus/backend/user/service/UserWithdrawalService.java @@ -11,6 +11,7 @@ import com.howaboutus.backend.common.logging.Loggable; import com.howaboutus.backend.realtime.event.MemberLeftEvent; import com.howaboutus.backend.realtime.event.RoomDeletedEvent; +import com.howaboutus.backend.rooms.entity.MemberStatus; import com.howaboutus.backend.rooms.entity.Room; import com.howaboutus.backend.rooms.entity.RoomMember; import com.howaboutus.backend.rooms.entity.RoomRole; @@ -41,6 +42,9 @@ public void withdraw(Long userId) { User user = userRepository.findById(userId) .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + // 본인이 HOST인 모든 Room을 잠가 동시 approve/위임이 검증과 삭제 사이에 끼어들지 못하게 함. + roomRepository.lockHostRoomsByUser(userId); + ensureNoHostDelegationRequired(userId); deleteSoloHostRooms(userId); @@ -79,7 +83,8 @@ private void deleteRemainingMemberships(Long userId, User user) { List memberships = roomMemberRepository.findAllByUser_Id(userId); for (RoomMember membership : memberships) { roomMemberRepository.delete(membership); - if (membership.getRole() == RoomRole.MEMBER) { + // LEFT row 는 이미 나갈 때 USER_LEFT 시스템 메시지가 한 번 발행되었으므로 재발행하지 않는다. + if (membership.getStatus() == MemberStatus.ACTIVE && membership.getRole() == RoomRole.MEMBER) { eventPublisher.publishEvent(new MemberLeftEvent( membership.getRoom().getId(), userId, diff --git a/src/main/resources/db/migration/V1.7__users_withdrawal.sql b/src/main/resources/db/migration/V1.7__users_withdrawal.sql index bd8243ea..8067ab7d 100644 --- a/src/main/resources/db/migration/V1.7__users_withdrawal.sql +++ b/src/main/resources/db/migration/V1.7__users_withdrawal.sql @@ -11,20 +11,29 @@ ALTER TABLE users ALTER COLUMN nickname DROP NOT NULL; ALTER TABLE users ALTER COLUMN provider DROP NOT NULL; ALTER TABLE users ALTER COLUMN provider_id DROP NOT NULL; --- 3) 활성 회원에 한해 NOT NULL 강제 (CHECK) +-- 3) 활성/탈퇴 상태와 개인정보 컬럼의 양방향 정합 강제 (CHECK) +-- 활성(deleted_at IS NULL) ⇒ email/nickname/provider/provider_id 모두 NOT NULL +-- 탈퇴(deleted_at IS NOT NULL) ⇒ email/nickname/provider/provider_id 모두 NULL (익명화 강제) ALTER TABLE users ADD CONSTRAINT users_active_required CHECK ( - deleted_at IS NOT NULL - OR ( - email IS NOT NULL + ( + deleted_at IS NULL + AND email IS NOT NULL AND nickname IS NOT NULL AND provider IS NOT NULL AND provider_id IS NOT NULL ) + OR ( + deleted_at IS NOT NULL + AND email IS NULL + AND nickname IS NULL + AND provider IS NULL + AND provider_id IS NULL + ) ); --- 4) 기존 unique 제약 제거 -ALTER TABLE users DROP CONSTRAINT users_email_key; -ALTER TABLE users DROP CONSTRAINT uq_users_provider_provider_id; +-- 4) 기존 unique 제약 제거 (환경별 제약 이름 차이/핫픽스 잔재에 대비해 IF EXISTS) +ALTER TABLE users DROP CONSTRAINT IF EXISTS users_email_key; +ALTER TABLE users DROP CONSTRAINT IF EXISTS uq_users_provider_provider_id; -- 5) 활성 회원만 unique 적용 (partial unique index) CREATE UNIQUE INDEX users_email_unique_active diff --git a/src/main/resources/db/migration/V1.8__room_members_status.sql b/src/main/resources/db/migration/V1.8__room_members_status.sql new file mode 100644 index 00000000..212f35dd --- /dev/null +++ b/src/main/resources/db/migration/V1.8__room_members_status.sql @@ -0,0 +1,33 @@ +-- =========================================== +-- V1.8: room_members 에 status / left_at 도입 +-- ACTIVE = 활성 멤버(HOST/MEMBER/PENDING), LEFT = 방 나가기/추방으로 떠난 멤버 +-- 회원 탈퇴는 별도로 hard delete (row 자체 삭제) — 본 마이그레이션과 무관 +-- =========================================== + +-- 1) status / left_at 컬럼 추가 +ALTER TABLE room_members + ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + ADD COLUMN left_at TIMESTAMP WITH TIME ZONE; + +-- 2) status 유효값 +ALTER TABLE room_members + ADD CONSTRAINT ck_room_members_status + CHECK (status IN ('ACTIVE', 'LEFT')); + +-- 3) status ↔ left_at 무결성 +ALTER TABLE room_members + ADD CONSTRAINT ck_room_members_status_left_at + CHECK ( + (status = 'ACTIVE' AND left_at IS NULL) + OR (status = 'LEFT' AND left_at IS NOT NULL) + ); + +-- 4) LEFT row의 role=PENDING 차단 (PENDING의 LEFT 전이는 정책상 불가) +ALTER TABLE room_members + ADD CONSTRAINT ck_room_members_left_role + CHECK (status = 'ACTIVE' OR role <> 'PENDING'); + +-- 5) 멤버 목록 / LEFT 룩업 인덱스 +CREATE INDEX idx_room_members_room_id_status ON room_members (room_id, status); + +-- 기존 UNIQUE(room_id, user_id)는 유지 — 재입장은 같은 row UPSERT. diff --git a/src/test/java/com/howaboutus/backend/rooms/controller/RoomControllerTest.java b/src/test/java/com/howaboutus/backend/rooms/controller/RoomControllerTest.java index 73027492..233fcb1b 100644 --- a/src/test/java/com/howaboutus/backend/rooms/controller/RoomControllerTest.java +++ b/src/test/java/com/howaboutus/backend/rooms/controller/RoomControllerTest.java @@ -316,9 +316,11 @@ void rejectJoinRequestReturns200() throws Exception { @DisplayName("멤버 목록 조회 성공 시 200을 반환한다") void getMembersReturns200() throws Exception { List results = List.of( - new RoomMemberResult(1L, "호스트", "https://img/host.jpg", RoomRole.HOST, true, + new RoomMemberResult(1L, "호스트", "https://img/host.jpg", RoomRole.HOST, + com.howaboutus.backend.rooms.entity.MemberStatus.ACTIVE, true, Instant.parse("2026-04-20T00:00:00Z")), - new RoomMemberResult(2L, "멤버", null, RoomRole.MEMBER, false, + new RoomMemberResult(2L, "멤버", null, RoomRole.MEMBER, + com.howaboutus.backend.rooms.entity.MemberStatus.ACTIVE, false, Instant.parse("2026-04-21T00:00:00Z")) ); given(roomMemberService.getMembers(ROOM_ID, USER_ID)).willReturn(results); @@ -331,8 +333,10 @@ void getMembersReturns200() throws Exception { .andExpect(jsonPath("$.members[0].userId").value(1)) .andExpect(jsonPath("$.members[0].nickname").value("호스트")) .andExpect(jsonPath("$.members[0].role").value("HOST")) + .andExpect(jsonPath("$.members[0].status").value("ACTIVE")) .andExpect(jsonPath("$.members[0].isOnline").value(true)) .andExpect(jsonPath("$.members[1].userId").value(2)) + .andExpect(jsonPath("$.members[1].status").value("ACTIVE")) .andExpect(jsonPath("$.members[1].isOnline").value(false)); } diff --git a/src/test/java/com/howaboutus/backend/rooms/entity/RoomMemberTest.java b/src/test/java/com/howaboutus/backend/rooms/entity/RoomMemberTest.java index 69d6ac17..e963c737 100644 --- a/src/test/java/com/howaboutus/backend/rooms/entity/RoomMemberTest.java +++ b/src/test/java/com/howaboutus/backend/rooms/entity/RoomMemberTest.java @@ -2,8 +2,11 @@ import static org.assertj.core.api.Assertions.*; +import java.time.Instant; + import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; import com.howaboutus.backend.user.entity.User; @@ -50,4 +53,84 @@ void demoteToMemberFailsWhenNotHost() { RoomMember member = createMember(RoomRole.MEMBER); assertThatThrownBy(member::demoteToMember).isInstanceOf(IllegalStateException.class); } + + @Test + @DisplayName("create - 신규 멤버는 status=ACTIVE, leftAt=null 로 시작한다") + void createStartsActive() { + RoomMember member = createMember(RoomRole.MEMBER); + assertThat(member.getStatus()).isEqualTo(MemberStatus.ACTIVE); + assertThat(member.getLeftAt()).isNull(); + } + + @Test + @DisplayName("leave - ACTIVE 멤버를 LEFT로 전환하고 leftAt 을 채운다") + void leaveTransitionsToLeft() { + RoomMember member = createMember(RoomRole.MEMBER); + member.leave(); + assertThat(member.getStatus()).isEqualTo(MemberStatus.LEFT); + assertThat(member.getLeftAt()).isNotNull(); + assertThat(member.getRole()).isEqualTo(RoomRole.MEMBER); + } + + @Test + @DisplayName("leave - 이미 LEFT면 예외") + void leaveFailsWhenAlreadyLeft() { + RoomMember member = createMember(RoomRole.MEMBER); + member.leave(); + assertThatThrownBy(member::leave).isInstanceOf(IllegalStateException.class); + } + + @Test + @DisplayName("kicked - ACTIVE 멤버를 LEFT로 전환하고 leftAt 을 채운다") + void kickedTransitionsToLeft() { + RoomMember member = createMember(RoomRole.MEMBER); + member.kicked(); + assertThat(member.getStatus()).isEqualTo(MemberStatus.LEFT); + assertThat(member.getLeftAt()).isNotNull(); + } + + @Test + @DisplayName("rejoinAsPending - LEFT 멤버를 ACTIVE/PENDING 으로 부활시킨다") + void rejoinAsPendingRevives() { + RoomMember member = createMember(RoomRole.MEMBER); + member.leave(); + member.rejoinAsPending(); + assertThat(member.getStatus()).isEqualTo(MemberStatus.ACTIVE); + assertThat(member.getRole()).isEqualTo(RoomRole.PENDING); + assertThat(member.getLeftAt()).isNull(); + } + + @Test + @DisplayName("rejoinAsPending - ACTIVE 멤버에 호출하면 예외") + void rejoinAsPendingFailsWhenActive() { + RoomMember member = createMember(RoomRole.MEMBER); + assertThatThrownBy(member::rejoinAsPending).isInstanceOf(IllegalStateException.class); + } + + @Test + @DisplayName("approve - LEFT 상태면 예외") + void approveFailsWhenLeft() { + RoomMember member = createMember(RoomRole.PENDING); + // PENDING 인 채 LEFT 로 만드는 직접 경로는 없으므로, 직접 필드 설정 헬퍼 사용 + ReflectionTestUtils.setField(member, "status", MemberStatus.LEFT); + ReflectionTestUtils.setField(member, "leftAt", Instant.now()); + assertThatThrownBy(member::approve).isInstanceOf(IllegalStateException.class); + } + + @Test + @DisplayName("promoteToHost - LEFT 상태면 예외") + void promoteToHostFailsWhenLeft() { + RoomMember member = createMember(RoomRole.MEMBER); + member.leave(); + assertThatThrownBy(member::promoteToHost).isInstanceOf(IllegalStateException.class); + } + + @Test + @DisplayName("demoteToMember - LEFT 상태면 예외") + void demoteToMemberFailsWhenLeft() { + RoomMember host = createMember(RoomRole.HOST); + ReflectionTestUtils.setField(host, "status", MemberStatus.LEFT); + ReflectionTestUtils.setField(host, "leftAt", Instant.now()); + assertThatThrownBy(host::demoteToMember).isInstanceOf(IllegalStateException.class); + } } diff --git a/src/test/java/com/howaboutus/backend/rooms/repository/RoomMemberStatusConstraintTest.java b/src/test/java/com/howaboutus/backend/rooms/repository/RoomMemberStatusConstraintTest.java new file mode 100644 index 00000000..f2029965 --- /dev/null +++ b/src/test/java/com/howaboutus/backend/rooms/repository/RoomMemberStatusConstraintTest.java @@ -0,0 +1,110 @@ +package com.howaboutus.backend.rooms.repository; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.LocalDate; +import java.util.UUID; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.transaction.annotation.Transactional; + +import com.howaboutus.backend.rooms.entity.Room; +import com.howaboutus.backend.support.BaseIntegrationTest; +import com.howaboutus.backend.user.entity.User; +import com.howaboutus.backend.user.repository.UserRepository; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import jakarta.persistence.PersistenceException; + +class RoomMemberStatusConstraintTest extends BaseIntegrationTest { + + @Autowired + private UserRepository userRepository; + @Autowired + private RoomRepository roomRepository; + + @PersistenceContext + private EntityManager em; + + private User persistUser(String suffix) { + return userRepository.save(User.ofGoogle( + "g-" + suffix + "-" + UUID.randomUUID(), + suffix + "-" + UUID.randomUUID() + "@t", + "n", + null)); + } + + private Room persistRoom(Long hostId, String suffix) { + return roomRepository.save(Room.create( + "t", null, LocalDate.now(), LocalDate.now(), + "inv-" + suffix + "-" + UUID.randomUUID(), + hostId)); + } + + @Test + @Transactional + @DisplayName("CHECK ck_room_members_status_left_at - LEFT + left_at NULL 은 거부") + void leftRequiresLeftAt() { + User user = persistUser("a"); + Room room = persistRoom(user.getId(), "a"); + em.flush(); + + assertThatThrownBy(() -> { + em.createNativeQuery(""" + insert into room_members + (room_id, user_id, role, joined_at, status, left_at, created_at, updated_at) + values (?1, ?2, 'MEMBER', now(), 'LEFT', NULL, now(), now()) + """) + .setParameter(1, room.getId()) + .setParameter(2, user.getId()) + .executeUpdate(); + em.flush(); + }).isInstanceOfAny(DataIntegrityViolationException.class, PersistenceException.class); + } + + @Test + @Transactional + @DisplayName("CHECK ck_room_members_status_left_at - ACTIVE + left_at NOT NULL 은 거부") + void activeForbidsLeftAt() { + User user = persistUser("b"); + Room room = persistRoom(user.getId(), "b"); + em.flush(); + + assertThatThrownBy(() -> { + em.createNativeQuery(""" + insert into room_members + (room_id, user_id, role, joined_at, status, left_at, created_at, updated_at) + values (?1, ?2, 'MEMBER', now(), 'ACTIVE', now(), now(), now()) + """) + .setParameter(1, room.getId()) + .setParameter(2, user.getId()) + .executeUpdate(); + em.flush(); + }).isInstanceOfAny(DataIntegrityViolationException.class, PersistenceException.class); + } + + @Test + @Transactional + @DisplayName("CHECK ck_room_members_left_role - LEFT + role=PENDING 은 거부") + void leftPendingRejected() { + User user = persistUser("c"); + Room room = persistRoom(user.getId(), "c"); + em.flush(); + + assertThatThrownBy(() -> { + em.createNativeQuery(""" + insert into room_members + (room_id, user_id, role, joined_at, status, left_at, created_at, updated_at) + values (?1, ?2, 'PENDING', now(), 'LEFT', now(), now(), now()) + """) + .setParameter(1, room.getId()) + .setParameter(2, user.getId()) + .executeUpdate(); + em.flush(); + }).isInstanceOfAny(DataIntegrityViolationException.class, PersistenceException.class); + } +} diff --git a/src/test/java/com/howaboutus/backend/rooms/service/RoomAuthorizationServiceTest.java b/src/test/java/com/howaboutus/backend/rooms/service/RoomAuthorizationServiceTest.java index 1508da70..7903a482 100644 --- a/src/test/java/com/howaboutus/backend/rooms/service/RoomAuthorizationServiceTest.java +++ b/src/test/java/com/howaboutus/backend/rooms/service/RoomAuthorizationServiceTest.java @@ -116,6 +116,43 @@ void requireHostRejectsMissingMember() { .isEqualTo(ErrorCode.NOT_ROOM_MEMBER); } + @Test + @DisplayName("LEFT 멤버는 비멤버로 차단된다") + void requireActiveMemberRejectsLeft() { + UUID roomId = UUID.randomUUID(); + Long userId = 7L; + RoomMember member = createMember(RoomRole.MEMBER); + member.leave(); + RoomAuthorizationService service = new RoomAuthorizationService(roomMemberRepository); + + given(roomMemberRepository.findByRoom_IdAndUser_Id(roomId, userId)).willReturn(Optional.of(member)); + + assertThatThrownBy(() -> service.requireActiveMember(roomId, userId)) + .isInstanceOf(CustomException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.NOT_ROOM_MEMBER); + } + + @Test + @DisplayName("LEFT 멤버는 HOST 검증에서도 차단된다") + void requireHostRejectsLeft() { + UUID roomId = UUID.randomUUID(); + Long userId = 8L; + RoomMember member = createMember(RoomRole.HOST); + // HOST는 위임 후에만 leave 가능하므로 직접 LEFT로 전환은 호출 경로상 불가하나, + // 가드의 안전성 확인을 위해 멤버를 강제로 LEFT 상태로 만든다. + member.demoteToMember(); + member.leave(); + RoomAuthorizationService service = new RoomAuthorizationService(roomMemberRepository); + + given(roomMemberRepository.findByRoom_IdAndUser_Id(roomId, userId)).willReturn(Optional.of(member)); + + assertThatThrownBy(() -> service.requireHost(roomId, userId)) + .isInstanceOf(CustomException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.NOT_ROOM_MEMBER); + } + private RoomMember createMember(RoomRole role) { Room room = Room.create("도쿄 여행", "도쿄", null, null, "INVITE", 1L); User user = User.ofGoogle("google-1", "user@example.com", "사용자", null); diff --git a/src/test/java/com/howaboutus/backend/rooms/service/RoomInviteServiceTest.java b/src/test/java/com/howaboutus/backend/rooms/service/RoomInviteServiceTest.java index e7039d7c..b37fe9bb 100644 --- a/src/test/java/com/howaboutus/backend/rooms/service/RoomInviteServiceTest.java +++ b/src/test/java/com/howaboutus/backend/rooms/service/RoomInviteServiceTest.java @@ -160,6 +160,34 @@ void requestJoinReturnsPendingWhenAlreadyPending() { assertThat(result.roomTitle()).isEqualTo("부산 여행"); } + @Test + @DisplayName("LEFT 멤버가 invite code로 재요청하면 같은 row가 ACTIVE/PENDING으로 부활하고 JoinRequestedEvent 발행") + void requestJoinRevivesLeftMember() { + User leftUser = User.ofGoogle("google-left", "left@test.com", "나간사람", null); + ReflectionTestUtils.setField(leftUser, "id", 42L); + RoomMember leftMember = RoomMember.create(room, leftUser, RoomRole.MEMBER); + leftMember.leave(); + + given(roomRepository.findByInviteCode("aB3xK9mQ2w")) + .willReturn(Optional.of(room)); + given(roomMemberRepository.findByRoom_IdAndUser_Id(ROOM_ID, 42L)) + .willReturn(Optional.of(leftMember)); + given(roomMemberRepository.findByRoom_IdAndRole(ROOM_ID, RoomRole.HOST)) + .willReturn(List.of(hostMember)); + + JoinResult result = roomInviteService.requestJoin("aB3xK9mQ2w", 42L); + + assertThat(leftMember.getStatus()) + .isEqualTo(com.howaboutus.backend.rooms.entity.MemberStatus.ACTIVE); + assertThat(leftMember.getRole()).isEqualTo(RoomRole.PENDING); + assertThat(leftMember.getLeftAt()).isNull(); + assertThat(result.status()).isEqualTo(JoinStatus.PENDING); + then(eventPublisher).should() + .publishEvent(any(com.howaboutus.backend.realtime.event.JoinRequestedEvent.class)); + // 부활 경로에서는 새 row를 만들지 않는다. + then(roomMemberRepository).should(never()).saveAndFlush(any(RoomMember.class)); + } + @Test @DisplayName("존재하지 않는 초대 코드로 입장 요청하면 ROOM_NOT_FOUND 예외") void requestJoinThrowsWhenInvalidCode() { @@ -206,6 +234,25 @@ void getJoinStatusReturnsApproved() { assertThat(result.roomId()).isEqualTo(ROOM_ID); } + @Test + @DisplayName("LEFT 멤버가 상태 조회하면 JOIN_REQUEST_NOT_FOUND 예외 (approved 로 잘못 답하지 않음)") + void getJoinStatusThrowsWhenLeft() { + User leftUser = User.ofGoogle("google-left", "left@test.com", "나간사람", null); + ReflectionTestUtils.setField(leftUser, "id", 77L); + RoomMember leftMember = RoomMember.create(room, leftUser, RoomRole.MEMBER); + leftMember.leave(); + + given(roomRepository.findById(ROOM_ID)) + .willReturn(Optional.of(room)); + given(roomMemberRepository.findByRoom_IdAndUser_Id(ROOM_ID, 77L)) + .willReturn(Optional.of(leftMember)); + + assertThatThrownBy(() -> roomInviteService.getJoinStatus(ROOM_ID, 77L)) + .isInstanceOf(CustomException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.JOIN_REQUEST_NOT_FOUND); + } + @Test @DisplayName("거절된(레코드 없는) 사용자가 상태 조회하면 JOIN_REQUEST_NOT_FOUND 예외") void getJoinStatusThrowsWhenRejected() { @@ -230,7 +277,7 @@ void approveChangesRoleToMember() { RoomMember pendingMember = RoomMember.create(room, pendingUser, RoomRole.PENDING); ReflectionTestUtils.setField(pendingMember, "id", 42L); - given(roomRepository.existsById(ROOM_ID)).willReturn(true); + given(roomRepository.findByIdForUpdate(ROOM_ID)).willReturn(Optional.of(room)); given(roomMemberRepository.findByRoom_IdAndUser_Id(ROOM_ID, HOST_ID)) .willReturn(Optional.of(hostMember)); given(roomMemberRepository.findByIdAndRoom_Id(42L, ROOM_ID)) @@ -249,7 +296,7 @@ void approvePublishesMemberApprovedEvent() { RoomMember pendingMember = RoomMember.create(room, pendingUser, RoomRole.PENDING); ReflectionTestUtils.setField(pendingMember, "id", 42L); - given(roomRepository.existsById(ROOM_ID)).willReturn(true); + given(roomRepository.findByIdForUpdate(ROOM_ID)).willReturn(Optional.of(room)); given(roomMemberRepository.findByRoom_IdAndUser_Id(ROOM_ID, HOST_ID)) .willReturn(Optional.of(hostMember)); given(roomMemberRepository.findByIdAndRoom_Id(42L, ROOM_ID)) @@ -288,7 +335,7 @@ void rejectDeletesMember() { @Test @DisplayName("존재하지 않는 요청을 승인하면 JOIN_REQUEST_NOT_FOUND 예외") void approveThrowsWhenRequestNotFound() { - given(roomRepository.existsById(ROOM_ID)).willReturn(true); + given(roomRepository.findByIdForUpdate(ROOM_ID)).willReturn(Optional.of(room)); given(roomMemberRepository.findByRoom_IdAndUser_Id(ROOM_ID, HOST_ID)) .willReturn(Optional.of(hostMember)); given(roomMemberRepository.findByIdAndRoom_Id(999L, ROOM_ID)) @@ -303,7 +350,7 @@ void approveThrowsWhenRequestNotFound() { @Test @DisplayName("MEMBER가 승인을 시도하면 NOT_ROOM_HOST 예외") void approveThrowsWhenNotHost() { - given(roomRepository.existsById(ROOM_ID)).willReturn(true); + given(roomRepository.findByIdForUpdate(ROOM_ID)).willReturn(Optional.of(room)); given(roomMemberRepository.findByRoom_IdAndUser_Id(ROOM_ID, MEMBER_ID)) .willReturn(Optional.of(regularMember)); diff --git a/src/test/java/com/howaboutus/backend/rooms/service/RoomMemberServiceTest.java b/src/test/java/com/howaboutus/backend/rooms/service/RoomMemberServiceTest.java index 025bada1..a701318a 100644 --- a/src/test/java/com/howaboutus/backend/rooms/service/RoomMemberServiceTest.java +++ b/src/test/java/com/howaboutus/backend/rooms/service/RoomMemberServiceTest.java @@ -38,7 +38,6 @@ class RoomMemberServiceTest { private static final Long USER_ID = 1L; private static final Long HOST_USER_ID = 1L; private static final Long TARGET_USER_ID = 2L; - private static final List ACTIVE_ROLES = List.of(RoomRole.HOST, RoomRole.MEMBER); @Mock private RoomMemberRepository roomMemberRepository; @@ -72,7 +71,7 @@ void getMembersReturnsActiveMembers() { RoomMember regularMember = RoomMember.create(room, member, RoomRole.MEMBER); given(roomAuthorizationService.requireActiveMember(ROOM_ID, USER_ID)).willReturn(hostMember); - given(roomMemberRepository.findByRoom_IdAndRoleIn(ROOM_ID, ACTIVE_ROLES)) + given(roomMemberRepository.findVisibleMembers(ROOM_ID)) .willReturn(List.of(hostMember, regularMember)); given(roomPresenceService.getOnlineUserIds(ROOM_ID)).willReturn(Set.of(1L)); @@ -82,8 +81,12 @@ void getMembersReturnsActiveMembers() { assertThat(results.get(0).userId()).isEqualTo(1L); assertThat(results.get(0).isOnline()).isTrue(); assertThat(results.get(0).role()).isEqualTo(RoomRole.HOST); + assertThat(results.get(0).status()) + .isEqualTo(com.howaboutus.backend.rooms.entity.MemberStatus.ACTIVE); assertThat(results.get(0).nickname()).isEqualTo("호스트"); assertThat(results.get(1).userId()).isEqualTo(2L); + assertThat(results.get(1).status()) + .isEqualTo(com.howaboutus.backend.rooms.entity.MemberStatus.ACTIVE); assertThat(results.get(1).isOnline()).isFalse(); } @@ -109,7 +112,7 @@ void getMembersHandlesRedisFailure() { RoomMember hostMember = RoomMember.create(room, host, RoomRole.HOST); given(roomAuthorizationService.requireActiveMember(ROOM_ID, USER_ID)).willReturn(hostMember); - given(roomMemberRepository.findByRoom_IdAndRoleIn(ROOM_ID, ACTIVE_ROLES)) + given(roomMemberRepository.findVisibleMembers(ROOM_ID)) .willReturn(List.of(hostMember)); given(roomPresenceService.getOnlineUserIds(ROOM_ID)) .willThrow(new org.springframework.dao.QueryTimeoutException("Redis connection refused")); @@ -120,12 +123,59 @@ void getMembersHandlesRedisFailure() { assertThat(results.get(0).isOnline()).isFalse(); } + @Test + @DisplayName("getMembers - LEFT 멤버도 포함하며 LEFT 의 online 은 항상 false") + void getMembersIncludesLeftAndForcesOffline() { + User host = User.ofGoogle("g1", "h@test.com", "호스트", null); + ReflectionTestUtils.setField(host, "id", 1L); + User activeUser = User.ofGoogle("g2", "m@test.com", "활성멤버", null); + ReflectionTestUtils.setField(activeUser, "id", 2L); + User leftUser = User.ofGoogle("g3", "l@test.com", "나간사람", null); + ReflectionTestUtils.setField(leftUser, "id", 3L); + + Room room = Room.create("여행", "부산", null, null, "invite1", 1L); + ReflectionTestUtils.setField(room, "id", ROOM_ID); + + RoomMember hostMember = RoomMember.create(room, host, RoomRole.HOST); + RoomMember activeMember = RoomMember.create(room, activeUser, RoomRole.MEMBER); + RoomMember leftMember = RoomMember.create(room, leftUser, RoomRole.MEMBER); + leftMember.leave(); + + given(roomAuthorizationService.requireActiveMember(ROOM_ID, USER_ID)).willReturn(hostMember); + // 정렬 검증을 위해 일부러 LEFT → ACTIVE/MEMBER → HOST 순서로 반환한다. + given(roomMemberRepository.findVisibleMembers(ROOM_ID)) + .willReturn(List.of(leftMember, activeMember, hostMember)); + // LEFT 인 user(3L) 가 Redis presence에 살아 있어도 무시되어야 함 + given(roomPresenceService.getOnlineUserIds(ROOM_ID)) + .willReturn(Set.of(1L, 3L)); + + List results = roomMemberService.getMembers(ROOM_ID, USER_ID); + + assertThat(results).hasSize(3); + // 정렬: ACTIVE(HOST → MEMBER joinedAt asc) → LEFT(joinedAt asc) + assertThat(results.get(0).userId()).isEqualTo(1L); + assertThat(results.get(0).role()).isEqualTo(RoomRole.HOST); + assertThat(results.get(0).status()) + .isEqualTo(com.howaboutus.backend.rooms.entity.MemberStatus.ACTIVE); + assertThat(results.get(0).isOnline()).isTrue(); + + assertThat(results.get(1).userId()).isEqualTo(2L); + assertThat(results.get(1).role()).isEqualTo(RoomRole.MEMBER); + assertThat(results.get(1).status()) + .isEqualTo(com.howaboutus.backend.rooms.entity.MemberStatus.ACTIVE); + + assertThat(results.get(2).userId()).isEqualTo(3L); + assertThat(results.get(2).status()) + .isEqualTo(com.howaboutus.backend.rooms.entity.MemberStatus.LEFT); + assertThat(results.get(2).isOnline()).isFalse(); + } + @Test @DisplayName("멤버가 없으면 빈 리스트를 반환한다") void getMembersReturnsEmptyList() { RoomMember dummyMember = createDummyMember(); given(roomAuthorizationService.requireActiveMember(ROOM_ID, USER_ID)).willReturn(dummyMember); - given(roomMemberRepository.findByRoom_IdAndRoleIn(ROOM_ID, ACTIVE_ROLES)) + given(roomMemberRepository.findVisibleMembers(ROOM_ID)) .willReturn(List.of()); given(roomPresenceService.getOnlineUserIds(ROOM_ID)).willReturn(Set.of()); @@ -143,8 +193,8 @@ private RoomMember createDummyMember() { } @Test - @DisplayName("kick 성공 - 멤버 삭제 + 이벤트 발행") - void kickDeletesMemberAndPublishesEvent() { + @DisplayName("kick 성공 - row 삭제하지 않고 status=LEFT 로 전환하며 이벤트 발행") + void kickTransitionsToLeft() { User host = User.ofGoogle("g1", "host@test.com", "호스트", "https://img/host.jpg"); ReflectionTestUtils.setField(host, "id", HOST_USER_ID); User target = User.ofGoogle("g2", "target@test.com", "타겟", "https://img/target.jpg"); @@ -162,10 +212,40 @@ void kickDeletesMemberAndPublishesEvent() { roomMemberService.kick(ROOM_ID, TARGET_USER_ID, HOST_USER_ID); - then(roomMemberRepository).should().delete(targetMember); + assertThat(targetMember.getStatus()) + .isEqualTo(com.howaboutus.backend.rooms.entity.MemberStatus.LEFT); + assertThat(targetMember.getLeftAt()).isNotNull(); + then(roomMemberRepository).should(never()).delete(any(RoomMember.class)); then(eventPublisher).should().publishEvent(any(MemberKickedEvent.class)); } + @Test + @DisplayName("kick - 대상이 이미 LEFT 면 KICK_TARGET_NOT_MEMBER") + void kickThrowsWhenTargetAlreadyLeft() { + User host = User.ofGoogle("g1", "host@test.com", "호스트", null); + ReflectionTestUtils.setField(host, "id", HOST_USER_ID); + User target = User.ofGoogle("g2", "target@test.com", "타겟", null); + ReflectionTestUtils.setField(target, "id", TARGET_USER_ID); + + Room room = Room.create("여행", "부산", null, null, "invite1", HOST_USER_ID); + ReflectionTestUtils.setField(room, "id", ROOM_ID); + + RoomMember hostMember = RoomMember.create(room, host, RoomRole.HOST); + RoomMember targetMember = RoomMember.create(room, target, RoomRole.MEMBER); + targetMember.leave(); + + given(roomAuthorizationService.requireHost(ROOM_ID, HOST_USER_ID)).willReturn(hostMember); + given(roomMemberRepository.findByRoom_IdAndUser_Id(ROOM_ID, TARGET_USER_ID)) + .willReturn(Optional.of(targetMember)); + + assertThatThrownBy(() -> roomMemberService.kick(ROOM_ID, TARGET_USER_ID, HOST_USER_ID)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException)ex).getErrorCode()) + .isEqualTo(ErrorCode.KICK_TARGET_NOT_MEMBER)); + then(roomMemberRepository).should(never()).delete(any(RoomMember.class)); + then(eventPublisher).shouldHaveNoInteractions(); + } + @Test @DisplayName("kick - HOST를 추방하려 하면 CANNOT_KICK_HOST") void kickThrowsWhenTargetIsHost() { @@ -234,8 +314,8 @@ void kickThrowsWhenTargetNotFound() { } @Test - @DisplayName("leave 성공 - 멤버 삭제 + 이벤트 발행") - void leaveDeletesMemberAndPublishesEvent() { + @DisplayName("leave 성공 - row 삭제하지 않고 status=LEFT 로 전환하며 이벤트 발행") + void leaveTransitionsToLeftAndPublishesEvent() { User member = User.ofGoogle("g2", "member@test.com", "멤버", "https://img/member.jpg"); ReflectionTestUtils.setField(member, "id", TARGET_USER_ID); @@ -249,7 +329,10 @@ void leaveDeletesMemberAndPublishesEvent() { roomMemberService.leave(ROOM_ID, TARGET_USER_ID); - then(roomMemberRepository).should().delete(regularMember); + assertThat(regularMember.getStatus()) + .isEqualTo(com.howaboutus.backend.rooms.entity.MemberStatus.LEFT); + assertThat(regularMember.getLeftAt()).isNotNull(); + then(roomMemberRepository).should(never()).delete(any(RoomMember.class)); then(eventPublisher).should().publishEvent(any(MemberLeftEvent.class)); } @@ -331,6 +414,35 @@ void delegateHostThrowsWhenSelfDelegation() { .isEqualTo(ErrorCode.CANNOT_DELEGATE_TO_SELF)); } + @Test + @DisplayName("delegateHost - 대상이 LEFT면 DELEGATE_TARGET_NOT_MEMBER (500 아님)") + void delegateHostThrowsWhenTargetAlreadyLeft() { + User host = User.ofGoogle("g1", "host@test.com", "호스트", null); + ReflectionTestUtils.setField(host, "id", HOST_USER_ID); + User target = User.ofGoogle("g2", "target@test.com", "타겟", null); + ReflectionTestUtils.setField(target, "id", TARGET_USER_ID); + + Room room = Room.create("여행", "부산", null, null, "invite1", HOST_USER_ID); + ReflectionTestUtils.setField(room, "id", ROOM_ID); + + RoomMember hostMember = RoomMember.create(room, host, RoomRole.HOST); + RoomMember leftMember = RoomMember.create(room, target, RoomRole.MEMBER); + leftMember.leave(); + + given(roomAuthorizationService.requireHost(ROOM_ID, HOST_USER_ID)).willReturn(hostMember); + given(roomMemberRepository.findByRoom_IdAndUser_Id(ROOM_ID, TARGET_USER_ID)) + .willReturn(Optional.of(leftMember)); + + assertThatThrownBy(() -> roomMemberService.delegateHost(ROOM_ID, TARGET_USER_ID, HOST_USER_ID)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException)ex).getErrorCode()) + .isEqualTo(ErrorCode.DELEGATE_TARGET_NOT_MEMBER)); + // role/status 가 변경되지 않아야 함 + assertThat(hostMember.getRole()).isEqualTo(RoomRole.HOST); + assertThat(leftMember.getRole()).isEqualTo(RoomRole.MEMBER); + then(eventPublisher).shouldHaveNoInteractions(); + } + @Test @DisplayName("delegateHost - 대상이 PENDING이면 DELEGATE_TARGET_NOT_MEMBER") void delegateHostThrowsWhenTargetIsPending() { diff --git a/src/test/java/com/howaboutus/backend/user/service/UserWithdrawalServiceTest.java b/src/test/java/com/howaboutus/backend/user/service/UserWithdrawalServiceTest.java index c8bd6e2f..689a2c65 100644 --- a/src/test/java/com/howaboutus/backend/user/service/UserWithdrawalServiceTest.java +++ b/src/test/java/com/howaboutus/backend/user/service/UserWithdrawalServiceTest.java @@ -131,6 +131,56 @@ void doubleCheckRollback() { assertThat(user.isWithdrawn()).isFalse(); } + @Test + @DisplayName("withdraw - LEFT row를 가진 유저도 전체 row가 hard delete 된다 (status 무관)") + void withdrawDeletesEvenLeftMemberships() { + given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); + + Room room = Room.create("MemberRoom", null, null, null, "i-left-room", 999L); + ReflectionTestUtils.setField(room, "id", UUID.randomUUID()); + + RoomMember leftMember = RoomMember.create(room, user, RoomRole.MEMBER); + leftMember.leave(); + + given(roomMemberRepository.findHostRoomsWithOtherActiveMembers(USER_ID)) + .willReturn(List.of()); + given(roomMemberRepository.findHostRoomsWithOnlySelf(USER_ID)) + .willReturn(List.of()); + given(roomMemberRepository.findAllByUser_Id(USER_ID)) + .willReturn(List.of(leftMember)); + + service.withdraw(USER_ID); + + // 회귀 테스트 의도: LEFT row 도 hard delete 대상에 포함된다. + verify(roomMemberRepository).delete(leftMember); + verify(eventPublisher).publishEvent(any(UserWithdrawnEvent.class)); + assertThat(user.isWithdrawn()).isTrue(); + } + + @Test + @DisplayName("withdraw - LEFT 멤버십에 대해서는 MemberLeftEvent를 재발행하지 않는다 (USER_LEFT 중복 방지)") + void withdrawDoesNotRepublishLeaveEventForLeftMemberships() { + given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); + + Room room = Room.create("MemberRoom", null, null, null, "i-no-dup", 999L); + ReflectionTestUtils.setField(room, "id", UUID.randomUUID()); + + RoomMember leftMember = RoomMember.create(room, user, RoomRole.MEMBER); + leftMember.leave(); + + given(roomMemberRepository.findHostRoomsWithOtherActiveMembers(USER_ID)) + .willReturn(List.of()); + given(roomMemberRepository.findHostRoomsWithOnlySelf(USER_ID)) + .willReturn(List.of()); + given(roomMemberRepository.findAllByUser_Id(USER_ID)) + .willReturn(List.of(leftMember)); + + service.withdraw(USER_ID); + + // LEFT row 는 이미 나갈 때 한 번 USER_LEFT 가 발행되었으므로 탈퇴 시점에 다시 발행되어선 안 된다. + verify(eventPublisher, never()).publishEvent(any(MemberLeftEvent.class)); + } + @Test @DisplayName("user를 찾을 수 없으면 USER_NOT_FOUND") void userNotFound() { From afa2ad92b36f53248062bce5879613e7fbc6cd79 Mon Sep 17 00:00:00 2001 From: Minhyung Kim <127458006+minbros@users.noreply.github.com> Date: Mon, 8 Jun 2026 13:45:59 +0900 Subject: [PATCH 4/9] =?UTF-8?q?chore:=20GitHub=20Actions=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=EA=B2=BD=EB=A1=9C=20=EB=8F=99=EC=A0=81?= =?UTF-8?q?=ED=99=94=20=EB=B0=8F=20=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8?= =?UTF-8?q?=EB=AA=85/=EC=84=9C=EB=B9=84=EC=8A=A4=EB=AA=85=20uttae=EB=A1=9C?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD=20(#133)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: GitHub Actions 이미지 경로 동적화 * chore: Docker Compose 프로젝트명 및 이미지명 uttae로 변경 * chore: compose.app.prod.yaml 프로젝트명을 uttae-api로 수정 * chore: Gradle 프로젝트명 및 설명을 uttae-backend로 변경 * chore: 스프링 앱네임, 기본 DB명 및 Serena 설정을 uttae로 변경 * chore: 한글 서비스 명칭을 우리어때에서 우때로 변경 * chore: README.md 제목 How About Us -> Uttae로 변경 * refactor: update dev compose files to unify project name to uttae and configure common uttae-network * refactor: 개발용 compose 파일을 업데이트하여 프로젝트명을 uttae로 통합하고 공통 uttae-network 구성 * refactor: remove common network settings but keep project name uttae in dev compose files --- .env.db.example | 4 ++-- .env.dev.example | 4 ++-- .github/workflows/deploy-compose.yml | 4 ++-- .serena/memories/project_overview.md | 2 +- .serena/project.yml | 2 +- LICENSE | 2 +- README.md | 4 ++-- build.gradle | 2 +- compose.app.dev.yaml | 6 +++++- compose.app.prod.yaml | 4 ++-- compose.db.dev.yaml | 4 ++++ compose.db.prod.yaml | 2 +- compose.monitoring.prod.yaml | 2 +- docs/gstack/overview.md | 2 +- settings.gradle | 2 +- src/main/resources/application.yaml | 4 ++-- 16 files changed, 29 insertions(+), 21 deletions(-) diff --git a/.env.db.example b/.env.db.example index 89aac200..7f9bdfb7 100644 --- a/.env.db.example +++ b/.env.db.example @@ -1,12 +1,12 @@ # PostgreSQL -DB_NAME=howaboutus +DB_NAME=uttae DB_USER=prod DB_PASSWORD=change-this-db-password # MongoDB MONGO_USER=prod MONGO_PASSWORD=change-this-mongo-password -MONGO_DB=howaboutus +MONGO_DB=uttae # Private network bindings DB_PRIVATE_IP=10.0.0.13 diff --git a/.env.dev.example b/.env.dev.example index 427a38d7..2aca3b30 100644 --- a/.env.dev.example +++ b/.env.dev.example @@ -1,12 +1,12 @@ # PostgreSQL -DB_NAME=howaboutus +DB_NAME=uttae DB_USER=dev DB_PASSWORD=dev # MongoDB MONGO_USER=dev MONGO_PASSWORD=dev -MONGO_DB=howaboutus +MONGO_DB=uttae # JWT (32자 이상의 랜덤 문자열) JWT_SECRET=local-dev-secret-please-change-this-to-something-random diff --git a/.github/workflows/deploy-compose.yml b/.github/workflows/deploy-compose.yml index 09be78b4..ecd0d687 100644 --- a/.github/workflows/deploy-compose.yml +++ b/.github/workflows/deploy-compose.yml @@ -28,7 +28,7 @@ concurrency: env: DEPLOY_PATH: /opt/how-about-us-backend - APP_IMAGE_REPOSITORY: ghcr.io/${{ github.repository_owner }}/backend-server + APP_IMAGE_REPOSITORY: ghcr.io/${{ github.repository }} APP_WAIT_TIMEOUT_SECONDS: 180 jobs: @@ -72,7 +72,7 @@ jobs: - hbu-api environment: production-api env: - APP_IMAGE: ghcr.io/${{ github.repository_owner }}/backend-server:${{ github.sha }} + APP_IMAGE: ghcr.io/${{ github.repository }}:${{ github.sha }} steps: - name: Check out source diff --git a/.serena/memories/project_overview.md b/.serena/memories/project_overview.md index 04909a96..b77ab432 100644 --- a/.serena/memories/project_overview.md +++ b/.serena/memories/project_overview.md @@ -1 +1 @@ -# Project Overview\n- `how-about-us-backend` is a Spring Boot backend for room-based place exploration/bookmark features with authentication and external Google API integration.\n- Main domains currently visible in the codebase: `auth`, `bookmarks`, `places`, `rooms`, plus `common` for shared config/error/integration code.\n- Source of truth for AI work is `AGENTS.md`; domain details live under `docs/ai/` such as `docs/ai/features.md` and `docs/ai/erd.md`.\n- Key stack from project docs and build config: Spring Boot 4.0.5, Java 21, Gradle, Spring Data JPA, PostgreSQL/PostGIS, Redis, MongoDB, Spring Security, WebSocket/STOMP, Lombok, springdoc OpenAPI, Testcontainers, Jib.\n- Runtime profiles: `dev` for local development with Docker Compose support, `prod` for AWS Lightsail deployment.\n- Package structure follows `com.howaboutus.backend.` with `common/` for shared concerns and domain packages for controller/service/repository/entity separation. \ No newline at end of file +# Project Overview\n- `uttae-backend` is a Spring Boot backend for room-based place exploration/bookmark features with authentication and external Google API integration.\n- Main domains currently visible in the codebase: `auth`, `bookmarks`, `places`, `rooms`, plus `common` for shared config/error/integration code.\n- Source of truth for AI work is `AGENTS.md`; domain details live under `docs/ai/` such as `docs/ai/features.md` and `docs/ai/erd.md`.\n- Key stack from project docs and build config: Spring Boot 4.0.5, Java 21, Gradle, Spring Data JPA, PostgreSQL/PostGIS, Redis, MongoDB, Spring Security, WebSocket/STOMP, Lombok, springdoc OpenAPI, Testcontainers, Jib.\n- Runtime profiles: `dev` for local development with Docker Compose support, `prod` for AWS Lightsail deployment.\n- Package structure follows `com.howaboutus.backend.` with `common/` for shared concerns and domain packages for controller/service/repository/entity separation. \ No newline at end of file diff --git a/.serena/project.yml b/.serena/project.yml index af8c0ebd..42947d18 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -1,5 +1,5 @@ # the name by which the project can be referenced within Serena -project_name: "how-about-us-backend" +project_name: "uttae-backend" # list of languages for which language servers are started; choose from: diff --git a/LICENSE b/LICENSE index 86f16eee..03838e84 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 우리어때 +Copyright (c) 2026 우때 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index d977df8b..eb8bada2 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# How About Us Backend +# Uttae Backend -협업형 여행 계획 플랫폼 **우리어때**의 백엔드 서버입니다. +협업형 여행 계획 플랫폼 **우때**의 백엔드 서버입니다. ## Demo [![시연 영상](https://img.youtube.com/vi/T6Qd0sUiB48/maxresdefault.jpg)](https://www.youtube.com/watch?v=T6Qd0sUiB48) diff --git a/build.gradle b/build.gradle index 600e2cf2..33504728 100644 --- a/build.gradle +++ b/build.gradle @@ -8,7 +8,7 @@ plugins { group = 'com.howaboutus' version = '0.0.1-SNAPSHOT' -description = 'how-about-us-backend' +description = 'uttae-backend' java { toolchain { diff --git a/compose.app.dev.yaml b/compose.app.dev.yaml index 5111ed88..1e323526 100644 --- a/compose.app.dev.yaml +++ b/compose.app.dev.yaml @@ -1,5 +1,7 @@ +name: uttae + services: - app: + api-server: build: . env_file: - ./.env.dev @@ -24,3 +26,5 @@ services: timeout: 5s retries: 12 start_period: 30s + + diff --git a/compose.app.prod.yaml b/compose.app.prod.yaml index 0a6b6020..b22fdca8 100644 --- a/compose.app.prod.yaml +++ b/compose.app.prod.yaml @@ -1,4 +1,4 @@ -name: how-about-us-app +name: uttae-api services: app: @@ -66,7 +66,7 @@ services: max-file: "3" caddy: - image: how-about-us-caddy:2.11-ratelimit + image: uttae-caddy:2.11-ratelimit build: context: . dockerfile: infra/caddy/Dockerfile diff --git a/compose.db.dev.yaml b/compose.db.dev.yaml index 7948838a..6ec01249 100644 --- a/compose.db.dev.yaml +++ b/compose.db.dev.yaml @@ -1,4 +1,6 @@ #file: noinspection SpellCheckingInspection +name: uttae + services: postgres: image: 'postgis/postgis:17-3.5' @@ -54,3 +56,5 @@ volumes: postgres-data: redis-data: mongodb-data: + + diff --git a/compose.db.prod.yaml b/compose.db.prod.yaml index d8100ebd..308e5b61 100644 --- a/compose.db.prod.yaml +++ b/compose.db.prod.yaml @@ -1,5 +1,5 @@ #file: noinspection SpellCheckingInspection -name: how-about-us-data +name: uttae-data services: postgres: diff --git a/compose.monitoring.prod.yaml b/compose.monitoring.prod.yaml index 35c66c20..7387b219 100644 --- a/compose.monitoring.prod.yaml +++ b/compose.monitoring.prod.yaml @@ -1,5 +1,5 @@ #file: noinspection SpellCheckingInspection -name: how-about-us-monitoring +name: uttae-monitoring services: prometheus: diff --git a/docs/gstack/overview.md b/docs/gstack/overview.md index 3df9436b..1da69551 100644 --- a/docs/gstack/overview.md +++ b/docs/gstack/overview.md @@ -4,7 +4,7 @@ ## 서비스 개요 -**"우리어때"** — 여행 계획을 함께 세우는 협업 웹 서비스. +**"우때"** — 여행 계획을 함께 세우는 협업 웹 서비스. - 우측 지도 /좌측 채팅 + 일정 + 북마크 - AI 에이전트가 채팅방 팀원으로 참여하여 여행 계획 수립 보조 diff --git a/settings.gradle b/settings.gradle index 8445d22a..0734e9ce 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1 +1 @@ -rootProject.name = 'how-about-us-backend' +rootProject.name = 'uttae-backend' diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index cc261e5e..42beb852 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -1,7 +1,7 @@ #file: noinspection SpellCheckingInspection,HttpUrlsUsage spring: application: - name: how-about-us-backend + name: uttae-backend data: redis: @@ -60,7 +60,7 @@ springwolf: docket: base-package: com.howaboutus.backend info: - title: 우리어때 웹소켓 API + title: 우때 웹소켓 API version: 1.0.0 description: WebSocket/STOMP 기반 실시간 이벤트 브로드캐스트 명세 servers: From c76e9374f3b970eb22c1e0d41fc9e13b3e44d804 Mon Sep 17 00:00:00 2001 From: PARK JU YEONG <96644508+parkjuyeong0312@users.noreply.github.com> Date: Mon, 8 Jun 2026 14:54:41 +0900 Subject: [PATCH 5/9] =?UTF-8?q?feat:=20=EC=95=BD=EA=B4=80=20=EB=8F=99?= =?UTF-8?q?=EC=9D=98=20=EA=B4=80=EB=A6=AC=20=EC=B6=94=EA=B0=80=20(#137)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: 서비스 정책 문서 초안 추가 * docs: 약관 백엔드 관리 설계 추가 * docs: 약관 백엔드 관리 구현 계획 추가 * docs: 탈퇴 시 방 데이터 보관 정책 정리 * feat: 약관 원문 리소스와 현재 버전 설정 추가 * feat: 현재 약관 조회 API 추가 * feat: 사용자 약관 동의 상태 저장 추가 * feat: 로그인 약관 동의 검증 추가 * docs: 약관 동의 기능과 ERD 반영 * test: 로그인 통합 테스트 약관 동의 반영 * style: 로그인 약관 명세 줄 길이 정리 * fix: 약관 정책 링크와 검증 테스트 보강 --- docs/ai/README.md | 3 +- docs/ai/erd.md | 6 + docs/ai/features.md | 3 + docs/legal/policy-preparation.md | 154 ++ docs/policy/copyright-policy.md | 111 ++ docs/policy/operation-policy.md | 118 ++ docs/policy/operator-info.md | 16 + docs/policy/privacy-policy.md | 6 + docs/policy/terms-of-service.md | 6 + .../plans/2026-06-08-backend-agreements.md | 1513 +++++++++++++++++ .../2026-06-08-backend-agreements-design.md | 212 +++ .../config/AgreementProperties.java | 17 + .../controller/AgreementController.java | 36 + .../dto/AgreementCurrentResponse.java | 21 + .../dto/AgreementDocumentResponse.java | 29 + .../agreements/service/AgreementService.java | 80 + .../service/dto/AgreementDocumentResult.java | 10 + .../service/dto/AgreementVersions.java | 7 + .../auth/controller/AuthController.java | 11 +- .../controller/dto/GoogleLoginRequest.java | 8 +- .../backend/auth/service/AuthService.java | 42 +- .../backend/common/config/SecurityConfig.java | 4 +- .../backend/common/error/ErrorCode.java | 3 + .../controller/UserAgreementController.java | 53 + .../dto/AcceptAgreementsRequest.java | 9 + .../howaboutus/backend/user/entity/User.java | 38 +- .../backend/user/service/UserService.java | 32 +- .../resources/agreements/privacy-policy.md | 211 +++ .../resources/agreements/terms-of-service.md | 177 ++ src/main/resources/application.yaml | 7 + .../migration/V1.9__add_user_agreements.sql | 15 + .../controller/AgreementControllerTest.java | 55 + .../service/AgreementServiceTest.java | 125 ++ .../backend/auth/AuthIntegrationTest.java | 6 +- .../auth/controller/AuthControllerTest.java | 8 +- .../backend/auth/service/AuthServiceTest.java | 79 +- .../UserAgreementControllerTest.java | 120 ++ .../backend/user/service/UserServiceTest.java | 37 + 38 files changed, 3358 insertions(+), 30 deletions(-) create mode 100644 docs/legal/policy-preparation.md create mode 100644 docs/policy/copyright-policy.md create mode 100644 docs/policy/operation-policy.md create mode 100644 docs/policy/operator-info.md create mode 100644 docs/policy/privacy-policy.md create mode 100644 docs/policy/terms-of-service.md create mode 100644 docs/superpowers/plans/2026-06-08-backend-agreements.md create mode 100644 docs/superpowers/specs/2026-06-08-backend-agreements-design.md create mode 100644 src/main/java/com/howaboutus/backend/agreements/config/AgreementProperties.java create mode 100644 src/main/java/com/howaboutus/backend/agreements/controller/AgreementController.java create mode 100644 src/main/java/com/howaboutus/backend/agreements/controller/dto/AgreementCurrentResponse.java create mode 100644 src/main/java/com/howaboutus/backend/agreements/controller/dto/AgreementDocumentResponse.java create mode 100644 src/main/java/com/howaboutus/backend/agreements/service/AgreementService.java create mode 100644 src/main/java/com/howaboutus/backend/agreements/service/dto/AgreementDocumentResult.java create mode 100644 src/main/java/com/howaboutus/backend/agreements/service/dto/AgreementVersions.java create mode 100644 src/main/java/com/howaboutus/backend/user/controller/UserAgreementController.java create mode 100644 src/main/java/com/howaboutus/backend/user/controller/dto/AcceptAgreementsRequest.java create mode 100644 src/main/resources/agreements/privacy-policy.md create mode 100644 src/main/resources/agreements/terms-of-service.md create mode 100644 src/main/resources/db/migration/V1.9__add_user_agreements.sql create mode 100644 src/test/java/com/howaboutus/backend/agreements/controller/AgreementControllerTest.java create mode 100644 src/test/java/com/howaboutus/backend/agreements/service/AgreementServiceTest.java create mode 100644 src/test/java/com/howaboutus/backend/user/controller/UserAgreementControllerTest.java diff --git a/docs/ai/README.md b/docs/ai/README.md index 9fcfb4c9..670f75ee 100644 --- a/docs/ai/README.md +++ b/docs/ai/README.md @@ -12,7 +12,7 @@ 4. `AGENTS.md` 5. 외부 문서와 메모 -우선순위가 높은 정보와 낮은 정보가 충돌하면 낮은 쪽을 그대로 따르지 말고 먼저 불일치를 보고한다. +우선순위가 높은 정보와 낮은 정보가 충돌할 때의 처리 기준은 `AGENTS.md`의 **Before You Start** 섹션을 따른다. ## Document Roles @@ -39,7 +39,6 @@ - 각 문서는 자기 책임 범위 안의 사실만 유지한다. - 같은 사실을 여러 문서에 중복으로 길게 적지 않는다. - 아직 확정되지 않은 내용은 확정된 사실처럼 쓰지 않는다. -- 코드와 문서가 어긋나면 문서를 조용히 믿고 진행하지 말고 먼저 불일치를 보고한다. ## Directory Guidance diff --git a/docs/ai/erd.md b/docs/ai/erd.md index 5cc34dfa..69a1eafe 100644 --- a/docs/ai/erd.md +++ b/docs/ai/erd.md @@ -19,6 +19,10 @@ Google OAuth 기반 사용자 정보 | profile_image_url | VARCHAR(500) | NULLABLE | 프로필 이미지 URL | | provider | VARCHAR(20) | 활성 회원 NOT NULL | OAuth 제공자. 탈퇴 시 NULL | | provider_id | VARCHAR(255) | 활성 회원 NOT NULL, 활성 회원 간 provider와 조합 UNIQUE | OAuth 제공자 측 사용자 ID. 탈퇴 시 NULL | +| tos_version | VARCHAR(30) | NOT NULL | 동의한 이용약관 버전 | +| tos_accepted_at | TIMESTAMP WITH TIME ZONE | NOT NULL | 이용약관 동의 또는 재동의 시각 | +| privacy_version | VARCHAR(30) | NOT NULL | 동의한 개인정보 처리방침 버전 | +| privacy_accepted_at | TIMESTAMP WITH TIME ZONE | NOT NULL | 개인정보 처리방침 동의 또는 재동의 시각 | | deleted_at | TIMESTAMP WITH TIME ZONE | NULLABLE | 탈퇴 시각. NOT NULL이면 익명화된 탈퇴 회원 | | created_at | TIMESTAMP WITH TIME ZONE | NOT NULL, DEFAULT NOW() | 가입일시 | | updated_at | TIMESTAMP WITH TIME ZONE | NOT NULL, DEFAULT NOW() | 수정일시 | @@ -32,6 +36,8 @@ Google OAuth 기반 사용자 정보 > 활성 회원은 email/nickname/provider/provider_id가 NOT NULL이며 이메일과 (provider, provider_id) 조합이 unique. 탈퇴 회원은 모두 NULL 가능하며 unique 검사 대상에서 제외되어 동일 OAuth 계정으로 재가입할 수 있다. +약관 원문은 DB에 저장하지 않고 백엔드 리소스 파일로 관리한다. 현재 버전은 `application.yaml`의 `app.agreements` 설정을 기준으로 하며, 프론트엔드는 버전 문자열을 전송하지 않는다. + --- ## 2. rooms (여행 방) diff --git a/docs/ai/features.md b/docs/ai/features.md index 467fdf4f..95679066 100644 --- a/docs/ai/features.md +++ b/docs/ai/features.md @@ -51,6 +51,9 @@ Caddy는 `caddy-ratelimit` 기반 IP 제한으로 엣지에서 넓고 거친 폭 | 상태 | 기능 | 설명 | ERD 연관 | |------|------|------|----------| | `[x]` | 구글 OAuth 로그인 | Google 계정으로 소셜 로그인 | users | +| `[x]` | 현재 약관 조회 | `GET /api/agreements/current`로 현재 이용약관과 개인정보 처리방침 원문 Markdown, 버전, 문서 타입을 비인증 상태에서 조회한다. 약관 원문은 백엔드 리소스 파일에 있고 현재 버전은 `application.yaml`에서 관리한다 | - | +| `[x]` | 가입 약관 동의 기록 | `POST /auth/google/login`에서 프론트는 `agreementsAccepted`만 전송한다. 신규 사용자는 값이 `true`일 때만 생성되며, 백엔드 현재 약관 버전과 서버 시간을 `users`에 저장한다 | users | +| `[x]` | 약관 재동의 | 기존 사용자의 저장 약관 버전이 현재 서버 버전과 다르면 로그인 시 재동의가 필요하다. 로그인 요청에서 `agreementsAccepted=true`이면 서버 현재 버전으로 갱신 후 토큰을 발급하고, 이미 로그인된 사용자는 `POST /api/users/me/agreements`로 현재 약관에 재동의한다 | users | | `[x]` | 토큰 재발급 (Refresh) | Refresh Token Rotation: UUID 기반 HTTP-only 쿠키(path=/auth/refresh), Redis `refresh:token:{uuid}`→userId(TTL 14일) / `refresh:user:{userId}`→Set\. Replay Detection 으로 탈취 시 전체 무효화 | Redis | | `[x]` | 로그아웃 | 단일 기기 로그아웃: 요청한 토큰만 삭제 | Redis | | `[x]` | 내 정보 조회 | 로그인된 사용자 프로필 조회 | users | diff --git a/docs/legal/policy-preparation.md b/docs/legal/policy-preparation.md new file mode 100644 index 00000000..99c7a724 --- /dev/null +++ b/docs/legal/policy-preparation.md @@ -0,0 +1,154 @@ +# 약관·개인정보 처리 준비 체크리스트 + +> 배포 전에 작성·고지해야 하는 약관 및 개인정보 관련 정책을 정리한다. +> 현재 단계: `초안`. 실제 약관 본문은 별도 문서로 분리해 작성한다. + +## 문서 목적 + +- 배포 전 법적으로 갖춰야 할 약관/정책 항목을 식별한다. +- 우리 서비스(여행 협업 + AI 어시스턴트)에서 어떤 조항이 핵심인지 정리한다. +- 약관에 적은 절차를 실제로 이행하려면 코드/기능 상 무엇을 추가해야 하는지 추적한다. + +## 서비스 특성 요약 + +- **수집 정보**: Google OAuth 프로필(이메일, 이름, 프로필 이미지), 채팅 메시지, 여행 일정/북마크, 접속 로그, 쿠키 기반 인증 토큰 +- **외부 전송**: Google Places/Routes API, 자체 AI 서버 +- **저장소**: PostgreSQL, MongoDB(채팅), Redis(세션·캐시) +- **회원 기능**: 로그인/로그아웃은 구현됨. **회원 탈퇴, 신고, 운영자 제재 절차는 미구현** (`docs/ai/features.md` 기준) + +--- + +## 1. 법적으로 반드시 필요한 문서 + +| 문서 | 근거 법령 | 비고 | +|------|-----------|------| +| 개인정보 처리방침 | 개인정보보호법 §30 | 홈페이지 첫 화면에서 쉽게 접근 가능해야 함 | +| 이용약관 | 약관규제법, 전기통신사업법 | 가입 전 동의 절차 필요 | +| 운영정책 | 이용약관 하위 정책 | 채팅·게시물·AI·초대코드·이용제한·신고/이의제기 세부 기준 | +| 운영자 정보 표시 | 정보통신망법 §10 | 상호, 대표자, 주소, 사업자번호, 연락처, 개인정보 보호책임자 | +| 저작권 정책 | 저작권법 §102, §103 | 이용자 콘텐츠의 권리 침해 신고, 게시중단, 재게시 요청 절차 안내 | + +> 위치정보법은 "단말기 위치"를 수집하지 않고 사용자가 검색한 장소 좌표만 다루면 일반적으로 적용 범위 밖이다. 추후 "내 위치 기반 추천" 기능 도입 시 **위치기반서비스 이용약관**과 방통위 신고가 추가로 필요하다. + +--- + +## 2. 개인정보 처리방침에 들어가야 할 항목 + +> 개인정보보호법 시행령 §31 기준. 각 항목을 우리 서비스 맥락으로 채워야 한다. + +### 2-1. 수집 항목 / 수집 목적 + +- **필수 수집**: Google OAuth `sub`(고유 ID), 이메일, 이름, 프로필 이미지 URL +- **자동 수집**: IP, User-Agent, 접속 일시, 쿠키(`access_token`, `refresh_token`), Redis presence 정보 +- **사용자 입력**: 방 제목·여행지·날짜, 채팅 메시지, 북마크/일정 메모 + +### 2-2. 보유·이용 기간 + +- 회원 정보: 탈퇴 시까지. 탈퇴 시 계정 식별 정보는 삭제 또는 익명화하고 인증 정보는 무효화 +- 여행 방 단위 데이터(방 제목, 채팅, 일정, 북마크, 메모): 방 삭제 시까지. 단, 탈퇴 처리와 함께 방이 삭제되는 경우 함께 삭제 +- **법령상 의무 보관 (검토 필요)**: + - 통신비밀보호법: 접속 로그 3개월 + - 전자상거래법: 소비자 불만/분쟁 처리 기록 3년 — 결제 기능이 없으면 적용 범위가 좁음 + +### 2-3. 제3자 제공 · 국외 이전 · 처리위탁 + +> 외부 호출이 많아 이 항목이 가장 큰 리스크다. 누가 어떤 데이터를 받는지 명확히 적어야 한다. + +- **제3자 제공**: 사전 동의 또는 법령상 근거가 있는 예외를 제외하고 제공하지 않는 것으로 정리 +- **Google (미국)**: OAuth 인증, Places/Routes API, Google Analytics → 처리 위탁 및 국외 이전 고지 필요 +- **OpenAI (미국)**: AI 응답 생성, 대화 요약 생성 → 처리 위탁 및 국외 이전 고지 필요 +- **클라우드 인프라(AWS Lightsail 등)**: 처리 위탁 및 국외 이전 고지 필요 +- 채팅 메시지에 포함된 **다른 사용자의 메시지/장소 스냅샷**이 AI 서버로 함께 전송되는 흐름을 명확히 적어야 한다. + +### 2-4. 정보주체 권리 + +- 열람, 정정, 삭제, 처리정지, 동의 철회권 안내 +- 권리 행사 방법 (이메일 또는 서비스 내 메뉴 등) + +### 2-5. 자동 수집 장치 (쿠키 등) + +- HTTP-only 쿠키(`access_token`, `refresh_token`) 사용 사실 +- 쿠키 거부 방법과 거부 시 영향 (로그인 불가) + +### 2-6. 개인정보 보호책임자 + +- 이름, 직책, 연락처 (이메일 가능) + +### 2-7. 만 14세 미만 처리 + +- 만 14세 미만 가입을 불허할지, 법정대리인 동의를 받을지 정책 결정 — `미결` +- 통상 스타트업은 "만 14세 미만 가입 불가" 정책으로 단순화한다. + +### 2-8. 변경 절차 + +- 처리방침 변경 시 사전 고지 기간과 통지 방법 + +--- + +## 3. 이용약관 핵심 조항 + +| 조항 | 우리 서비스에서 다뤄야 할 내용 | +|------|--------------------------------| +| 서비스 내용 | "여행 계획 협업 + AI 어시스턴트 보조" 명시 | +| 회원 가입/자격 | Google 계정 보유자, 만 14세 이상 | +| 계정·탈퇴 절차 | **현재 미구현** — 탈퇴 API/UI 마련 필요 | +| 이용제한·정지 | 사유, 기간, 사전·사후 통지 절차, 이의제기 절차 | +| 금지 행위 | 도배, 음란/혐오/명예훼손, 타인 사칭, 크롤링, 부정 이용 | +| 운영정책 | 여행 방, 채팅, AI 어시스턴트, 초대코드, 신고 및 이용 제한 세부 기준 | +| 게시물 정책 | 채팅·북마크·일정 메모의 권리 귀속(사용자 보유), 서비스 운영 목적 사용 동의 범위 | +| 저작권 신고/게시중단 | 권리자 신고, 작성자 통지, 재게시 요청, 반복 침해자 제한 | +| AI 응답 면책 | AI 추천은 참고용이며 정확성·안전성 보장하지 않음 | +| 외부 데이터 면책 | Google 장소 정보(영업시간·평점 등)는 Google 제공 자료이며 실제와 다를 수 있음 | +| 서비스 변경/중단 | 사전 공지 기간, 무료 서비스 한정 면책 | +| 손해배상/면책 | 무료 서비스 특성 반영 | +| 분쟁 해결 | 준거법(대한민국), 관할 법원, 사전 협의 절차 | + +--- + +## 4. 코드/기능 측에서 추가로 준비할 작업 + +> 약관 본문만 작성해도 실제 절차를 이행할 수 없으면 의미가 없다. 다음 항목이 `docs/ai/features.md`에 없거나 부족하다. + +| # | 항목 | 비고 | +|---|------|------| +| 1 | 회원 탈퇴 기능 | 현재 worktree의 인증 섹션에는 없음. `feature/user-withdrawal` 기준 정책은 계정 식별 정보 익명화, 참여 정보 삭제, 방 단위 협업 데이터는 방 삭제 시까지 유지 | +| 2 | 계정 정지/차단 운영자 도구 | 방장 추방은 있으나 서비스 차원의 제재 수단 없음 | +| 3 | 신고 기능 | 다른 사용자/메시지 신고 API | +| 4 | 저작권 게시중단 처리 절차 | 이메일 접수로 시작 가능하나 운영자 검토/작성자 통지/재게시 요청 기록 필요 | +| 5 | 약관 동의 이력 저장 | 가입 시 어떤 버전에 동의했는지, 변경 시 재동의 처리 | +| 6 | 개인정보 파기 절차 | 탈퇴 후 보관 기간, 자동 파기 배치 | +| 7 | 로그 보관 기간 정책 | 접속 로그는 통신비밀보호법 시행령 기준 3개월로 정리. 채팅 로그는 방 삭제 시까지 보관하는 기준과 구현 일치 필요 | +| 8 | 데이터 열람/내보내기 (선택) | 정보주체의 열람권 대응. 이메일 송부로도 대체 가능 | + +--- + +## 5. 진행 순서 (제안) + +1. **정책 결정** (코드 작업보다 먼저) + - 탈퇴 시 계정 식별 정보 익명화와 방 단위 협업 데이터 보관 정책 반영 + - 만 14세 미만 정책 (가입 차단으로 단순화 권장) + - 신고 처리 SLA + - 저작권 게시중단 및 재게시 요청 처리 방식 + - 채팅 보관 기간과 방 삭제 시 처리 방식 +2. **운영자 정보 확정** + - 사업자등록 여부, 대표자, 주소, 연락처 + - 개인정보 보호책임자 지정 +3. **회원 탈퇴/신고 기능 구현** + - 약관에 적을 절차가 실제로 동작하도록 코드 추가 +4. **약관·처리방침·운영정책·저작권 정책 초안 작성** + - 한국인터넷진흥원(KISA) 표준 처리방침 양식 활용 가능 +5. **가입 동의 UI / 약관 버전 관리 구현** +6. **법률 검토** + - 가능하면 변호사 검토. 특히 AI 데이터 처리, 국외 이전 부분이 중요하다. + +--- + +## 미결 사항 + +| # | 항목 | 비고 | +|---|------|------| +| 1 | 탈퇴 시 채팅 메시지 처리 | `feature/user-withdrawal` 기준: 방 단위 협업 데이터로 방 삭제 시까지 유지. 탈퇴자의 계정 식별 정보와 참여 정보는 삭제 또는 익명화 | +| 2 | 만 14세 미만 정책 | 가입 차단 vs 법정대리인 동의 | +| 3 | 채팅 메시지 AI 전송 고지 방식 | 처리 위탁 및 국외 이전 고지로 정리. 실제 가입/AI 호출 UI 고지 방식 결정 필요 | +| 4 | 회원 정보 탈퇴 후 보관 기간 | 즉시 파기 vs N일 유예 | +| 5 | 사업자등록 여부 | 등록 시점, 상호, 주소 | diff --git a/docs/policy/copyright-policy.md b/docs/policy/copyright-policy.md new file mode 100644 index 00000000..606c810e --- /dev/null +++ b/docs/policy/copyright-policy.md @@ -0,0 +1,111 @@ +# 저작권 정책 + +**시행일: 2026년 6월 6일** + +--- + +## 1. 목적 + +이 정책은 우때(이하 "서비스")에서 이용자가 작성·등록·공유한 콘텐츠가 타인의 저작권 등 권리를 침해한다는 신고가 접수된 경우의 처리 기준과 절차를 안내합니다. + +서비스는 이용자의 콘텐츠 작성과 공유를 존중하지만, 타인의 저작권, 상표권, 초상권, 개인정보 등 정당한 권리를 침해하는 콘텐츠의 게시를 허용하지 않습니다. + +--- + +## 2. 적용 대상 + +이 정책은 서비스 내에서 이용자가 작성·등록·공유하는 다음 콘텐츠에 적용됩니다. + +1. 여행 방 채팅 메시지 +2. 여행 일정, 메모, 북마크 장소 설명 +3. 프로필 이름, 프로필 이미지 등 이용자가 직접 등록한 정보 +4. 그 밖에 서비스 내에서 이용자가 게시하거나 공유한 문자, 이미지, 링크 등 콘텐츠 + +Google 등 외부 서비스가 제공하는 장소 정보, 사진, 지도, 경로 정보에는 해당 외부 서비스의 약관과 정책이 적용될 수 있습니다. + +--- + +## 3. 저작권 침해 신고 + +권리자 또는 정당한 대리인은 서비스 내 콘텐츠가 본인의 저작권 등 권리를 침해한다고 판단하는 경우 team.uttae@gmail.com으로 게시중단을 요청할 수 있습니다. + +신속한 처리를 위해 신고에는 다음 정보를 포함해 주세요. + +1. 신고자의 이름 또는 단체명 +2. 연락 가능한 이메일 주소 +3. 권리자 본인 또는 정당한 대리인임을 확인할 수 있는 정보 +4. 침해되었다고 주장하는 저작물 또는 권리의 설명 +5. 서비스 내 침해 의심 콘텐츠를 확인할 수 있는 정보 + - 여행 방 이름, 작성자, 작성 시각, 화면 캡처, URL 등 가능한 식별 정보 +6. 해당 콘텐츠가 권리자의 허락 없이 사용되었다고 판단하는 이유 +7. 신고 내용이 사실과 다르지 않다는 확인 + +대리인이 신고하는 경우 위임장 등 대리권을 확인할 수 있는 자료를 함께 제출해야 합니다. + +--- + +## 4. 게시중단 및 작성자 통지 + +운영자는 신고 내용을 검토한 뒤 권리 침해가 합리적으로 의심되거나 관련 법령상 조치가 필요하다고 판단되는 경우 해당 콘텐츠를 숨김, 삭제 또는 접근 제한할 수 있습니다. + +운영자가 콘텐츠를 게시중단한 경우, 가능한 범위에서 해당 콘텐츠를 작성한 이용자에게 다음 내용을 알립니다. + +1. 게시중단된 콘텐츠 +2. 게시중단 사유 +3. 신고자 또는 권리주장자의 주장 요지 +4. 재게시 요청 방법 + +다만, 작성자의 연락처를 확인할 수 없거나 긴급한 피해 방지, 법령 준수, 보안상 필요가 있는 경우에는 사후 통지하거나 통지를 생략할 수 있습니다. + +--- + +## 5. 재게시 요청 + +게시중단된 콘텐츠의 작성자는 해당 콘텐츠가 정당한 권리에 따라 게시되었거나, 신고 내용이 오인 또는 착오에 따른 것이라고 판단하는 경우 team.uttae@gmail.com으로 재게시를 요청할 수 있습니다. + +재게시 요청에는 다음 정보를 포함해 주세요. + +1. 요청자의 이름 +2. 연락 가능한 이메일 주소 +3. 게시중단된 콘텐츠를 식별할 수 있는 정보 +4. 해당 콘텐츠를 게시할 정당한 권리가 있음을 소명하는 자료 +5. 재게시 요청 내용이 사실과 다르지 않다는 확인 + +운영자는 재게시 요청이 접수되면 관련 법령과 제출 자료를 검토하여 게시 재개 여부를 판단합니다. 필요한 경우 신고자와 작성자에게 추가 자료 제출을 요청할 수 있습니다. + +--- + +## 6. 반복 침해자 조치 + +운영자는 동일 이용자가 반복적으로 타인의 권리를 침해하는 콘텐츠를 게시하거나, 허위 신고 또는 부정한 재게시 요청을 반복한다고 판단하는 경우 서비스 이용을 제한하거나 계정을 삭제할 수 있습니다. + +조치의 범위는 위반 행위의 내용, 반복 여부, 피해 규모, 고의성, 소명 여부를 고려하여 결정합니다. + +--- + +## 7. 허위 신고 및 책임 + +신고자와 재게시 요청자는 본인이 제출한 정보와 자료가 정확한지 확인해야 합니다. + +허위 신고, 허위 소명, 권리 없는 게시중단 요청 또는 재게시 요청으로 인해 다른 이용자, 운영자 또는 제3자에게 손해가 발생한 경우, 해당 요청자는 관련 법령에 따라 책임을 부담할 수 있습니다. + +--- + +## 8. 문의처 + +저작권 침해 신고, 게시중단, 재게시 요청 및 이 정책에 관한 문의는 아래 연락처로 보내주시기 바랍니다. + +| 항목 | 내용 | +|------|------| +| 서비스명 | 우때 | +| 이메일 | team.uttae@gmail.com | + +--- + +## 9. 정책의 변경 + +운영자는 관련 법령, 서비스 운영 방식, 신고 처리 절차의 변경에 따라 이 정책을 변경할 수 있습니다. 정책이 변경되는 경우 서비스 내 공지 또는 정책 문서 갱신을 통해 안내합니다. + +| 버전 | 시행일 | +|------|--------| +| 1.0 | 2026년 6월 6일 | diff --git a/docs/policy/operation-policy.md b/docs/policy/operation-policy.md new file mode 100644 index 00000000..a2483575 --- /dev/null +++ b/docs/policy/operation-policy.md @@ -0,0 +1,118 @@ +# 운영정책 + +**시행일: 2026년 6월 6일** + +--- + +## 제1조 (목적) + +이 운영정책은 [이용약관](terms-of-service.md)을 기반으로, 우때(이하 "서비스")의 여행 방, 채팅, 게시물, AI 어시스턴트, 신고 및 이용 제한에 관한 세부 기준과 절차를 정합니다. + +이 정책에서 정하지 않은 사항은 이용약관, [개인정보 처리방침](privacy-policy.md), [저작권 정책](copyright-policy.md) 및 관련 법령을 따릅니다. + +--- + +## 제2조 (여행 방 이용) + +1. 이용자는 여행 계획 협업을 위해 여행 방을 생성하고 다른 이용자를 초대할 수 있습니다. +2. 여행 방의 초대 코드는 초대받을 이용자에게만 공유해야 하며, 무단 수집·배포·판매·게시해서는 안 됩니다. +3. 방장은 여행 방의 기본 정보, 멤버 관리, 방 삭제 등 서비스가 제공하는 범위 내에서 관리 권한을 가집니다. +4. 방장은 권한을 남용하여 다른 이용자의 정상적인 서비스 이용을 부당하게 방해해서는 안 됩니다. +5. 여행 방 제목, 여행지, 일정, 메모 등은 다른 이용자의 권리나 관련 법령을 침해하지 않는 범위에서 작성해야 합니다. + +--- + +## 제3조 (채팅 및 게시물 이용) + +1. 이용자는 여행 방 채팅, 일정 메모, 북마크, 장소 공유 등 서비스 내 콘텐츠를 여행 계획 협업 목적에 맞게 사용해야 합니다. +2. 다음 콘텐츠는 게시하거나 공유할 수 없습니다. + - 욕설, 비방, 괴롭힘, 협박, 명예훼손성 내용 + - 음란물, 성적 수치심을 유발하는 내용, 폭력적 내용 + - 혐오 표현, 차별 조장, 사회적 편견을 강화하는 내용 + - 타인의 개인정보, 계정 정보, 초대 코드, 비공개 대화 내용을 무단 공개하는 내용 + - 저작권, 상표권, 초상권 등 제3자의 권리를 침해하는 내용 + - 불법 사행성 서비스, 불법 제품, 판매 금지 물품, 범죄 행위를 홍보하거나 조장하는 내용 + - 악성 코드, 피싱, 스팸, 광고성 링크, 서비스 외부 거래 유도 내용 + - 고의로 허위 장소 정보, 허위 일정 정보, 오해를 유발하는 정보를 반복 게시하는 행위 +3. 운영자는 위반 콘텐츠가 확인되는 경우 숨김, 삭제, 접근 제한, 작성자 이용 제한 등 필요한 조치를 할 수 있습니다. +4. 저작권 등 권리 침해 콘텐츠의 신고와 게시중단·재게시 요청 절차는 [저작권 정책](copyright-policy.md)에 따릅니다. + +--- + +## 제4조 (AI 어시스턴트 이용) + +1. AI 어시스턴트는 여행 계획 수립을 보조하기 위한 참고 기능입니다. +2. 이용자는 AI 어시스턴트에게 주민등록번호, 여권번호, 금융정보, 건강정보, 비밀번호, 인증번호 등 민감하거나 비밀성이 높은 정보를 입력해서는 안 됩니다. +3. AI 어시스턴트의 응답은 부정확하거나 최신 정보와 다를 수 있으며, 예약, 결제, 이동, 안전, 법률, 의료 등 중요한 의사결정은 공식 정보를 직접 확인해야 합니다. +4. AI 어시스턴트를 이용해 불법 행위, 권리 침해, 차별·혐오 표현, 서비스 공격 또는 자동화 남용을 요청하거나 시도해서는 안 됩니다. +5. AI 기능은 안정적인 서비스 운영을 위해 방별·이용자별 요청 수, 대기열, 처리 중 요청 수가 제한될 수 있습니다. + +--- + +## 제5조 (서비스 이용 제한 사유) + +운영자는 이용자가 다음 행위를 하는 경우 서비스 이용을 제한할 수 있습니다. + +1. 이용약관, 운영정책, 저작권 정책 또는 관련 법령을 위반하는 행위 +2. 타인의 계정, Google 계정, 초대 코드, 인증 수단을 도용하거나 무단 사용하는 행위 +3. 다량의 계정 생성, 반복 가입·탈퇴, 반복 초대·입장 등 정상적인 이용으로 보기 어려운 행위 +4. 채팅 도배, 반복 요청, 자동화 도구, 봇, 매크로, 스크래퍼 등을 이용해 서비스 운영을 방해하는 행위 +5. 서비스의 API, 서버, 네트워크, 보안 기능을 우회·공격·탐지·역분석하려는 행위 +6. 다른 이용자의 개인정보나 콘텐츠를 무단 수집·저장·공개·배포하는 행위 +7. 타인을 사칭하거나 운영자, 서비스 관계자, 다른 이용자로 오인하게 하는 행위 +8. 악성 코드, 피싱 링크, 스팸, 광고성 메시지 등을 전송하는 행위 +9. 저작권 등 제3자의 권리를 반복적으로 침해하는 행위 +10. 신고, 문의, 이의제기 절차를 악용하거나 허위 자료를 반복 제출하는 행위 +11. 기타 서비스의 정상적인 운영 또는 다른 이용자의 이용을 현저히 방해하는 행위 + +--- + +## 제6조 (이용 제한의 종류) + +이용 제한은 위반 행위의 내용, 반복 여부, 피해 규모, 고의성, 긴급성, 소명 여부를 고려하여 다음 범위에서 적용됩니다. + +1. 콘텐츠 제한: 게시물 숨김, 삭제, 검색·노출 제한 +2. 기능 제한: 채팅, AI 요청, 방 생성, 초대, 장소·경로 검색 등 일부 기능의 일시 제한 +3. 접근 제한: 특정 여행 방 접근 제한 또는 멤버 제외 +4. 계정 제한: 경고, 일시 정지, 영구 정지, 계정 삭제 +5. 기술적 제한: 비정상 요청 차단, Rate Limit 적용, 보안상 필요한 접속 제한 + +위반이 중대하거나 긴급한 피해 방지가 필요한 경우 운영자는 사전 통지 없이 즉시 제한 조치를 할 수 있습니다. + +--- + +## 제7조 (신고 및 이의제기) + +1. 이용자는 다른 이용자의 약관·정책 위반, 권리 침해, 유해 콘텐츠를 발견한 경우 team.uttae@gmail.com으로 신고할 수 있습니다. +2. 신고 시 콘텐츠를 식별할 수 있는 정보(여행 방, 작성자, 작성 시각, 화면 캡처, 문제 사유 등)를 함께 제공하면 처리에 도움이 됩니다. +3. 운영자는 신고 내용을 검토한 뒤 필요한 조치를 취할 수 있으며, 처리 결과를 신고자에게 안내하지 않거나 제한적으로 안내할 수 있습니다. +4. 이용 제한 조치를 받은 이용자는 team.uttae@gmail.com으로 이의를 제기할 수 있습니다. +5. 운영자는 이의제기 접수 후 7일 이내에 처리 결과를 안내하는 것을 원칙으로 합니다. 추가 확인이 필요한 경우 처리 기간이 연장될 수 있습니다. + +--- + +## 제8조 (문의 및 피드백) + +1. 이용자는 서비스 오류, 이용 문의, 개선 제안, 불만 사항을 team.uttae@gmail.com으로 전달할 수 있습니다. +2. 운영자는 접수된 문의와 피드백을 서비스 운영, 문제 해결, 기능 개선 목적으로 활용할 수 있습니다. +3. 문의나 피드백에는 주민등록번호, 여권번호, 금융정보, 비밀번호, 인증번호 등 민감한 정보를 포함하지 않아야 합니다. + +--- + +## 제9조 (정책의 변경) + +운영자는 서비스 운영 방식, 기능, 법령 또는 보안상 필요에 따라 이 정책을 변경할 수 있습니다. + +정책을 변경하는 경우 서비스 공지 또는 정책 문서 갱신을 통해 안내합니다. 이용자의 권리나 의무에 중대한 영향을 미치는 변경은 시행 30일 전에 고지합니다. + +--- + +## 제10조 (준용) + +이 정책에서 정하지 않은 사항은 [이용약관](terms-of-service.md), [개인정보 처리방침](privacy-policy.md), [저작권 정책](copyright-policy.md) 및 관련 법령을 따릅니다. + +--- + +## 부칙 + +이 정책은 2026년 6월 6일부터 시행합니다. diff --git a/docs/policy/operator-info.md b/docs/policy/operator-info.md new file mode 100644 index 00000000..e910cfca --- /dev/null +++ b/docs/policy/operator-info.md @@ -0,0 +1,16 @@ +# 운영자 정보 + +정보통신망 이용촉진 및 정보보호 등에 관한 법률 제10조에 따라 다음과 같이 운영자 정보를 공개합니다. + +--- + +| 항목 | 내용 | +|------|------| +| 서비스명 | 우때 | +| 운영자 | 박주영 (개인 운영) | +| 이메일 | team.uttae@gmail.com | +| 호스팅 사업자 | Amazon Web Services (AWS) | + +--- + +> 사업자 미등록 개인 운영으로, 사업자등록번호 및 사업장 주소는 별도 공개하지 않습니다. 운영자에 대한 문의는 위 이메일로 연락주시기 바랍니다. diff --git a/docs/policy/privacy-policy.md b/docs/policy/privacy-policy.md new file mode 100644 index 00000000..0b60b45a --- /dev/null +++ b/docs/policy/privacy-policy.md @@ -0,0 +1,6 @@ +# 개인정보 처리방침 + +원문은 백엔드 약관 리소스로 이동했습니다. + +- 리소스: `src/main/resources/agreements/privacy-policy.md` +- 현재 버전 설정: `src/main/resources/application.yaml`의 `app.agreements.privacy-policy.version` diff --git a/docs/policy/terms-of-service.md b/docs/policy/terms-of-service.md new file mode 100644 index 00000000..166bcde7 --- /dev/null +++ b/docs/policy/terms-of-service.md @@ -0,0 +1,6 @@ +# 이용약관 + +원문은 백엔드 약관 리소스로 이동했습니다. + +- 리소스: `src/main/resources/agreements/terms-of-service.md` +- 현재 버전 설정: `src/main/resources/application.yaml`의 `app.agreements.terms-of-service.version` diff --git a/docs/superpowers/plans/2026-06-08-backend-agreements.md b/docs/superpowers/plans/2026-06-08-backend-agreements.md new file mode 100644 index 00000000..4aef9407 --- /dev/null +++ b/docs/superpowers/plans/2026-06-08-backend-agreements.md @@ -0,0 +1,1513 @@ +# Backend Agreements Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Serve current terms/privacy documents from the backend and store server-trusted agreement versions plus accepted timestamps on users. + +**Architecture:** Add a focused `agreements` domain that reads Markdown resources configured in `application.yaml`, exposes the public current agreements API, and provides current version data to auth/user services. Store agreement state on `users` with `NOT NULL` fields because the database will be reset. Frontend sends only `agreementsAccepted`; backend decides versions and timestamps. + +**Tech Stack:** Spring Boot 4.0.5, Java 21, Spring MVC, Spring Security, Spring Data JPA, Flyway, Gradle, JUnit 5, Mockito, Spring MockMvc, Swagger/OpenAPI annotations. + +--- + +## File Structure + +- Create: `src/main/java/com/howaboutus/backend/agreements/config/AgreementProperties.java` + - Binds `app.agreements.*` from `application.yaml`. +- Create: `src/main/java/com/howaboutus/backend/agreements/service/AgreementService.java` + - Reads configured Markdown resources, returns current agreement DTOs, validates accepted flag, and produces current server versions. +- Create: `src/main/java/com/howaboutus/backend/agreements/service/dto/AgreementDocumentResult.java` + - Service DTO for one current agreement document. +- Create: `src/main/java/com/howaboutus/backend/agreements/service/dto/AgreementVersions.java` + - Service DTO for current terms/privacy versions. +- Create: `src/main/java/com/howaboutus/backend/agreements/controller/AgreementController.java` + - Public REST controller for `GET /api/agreements/current`. +- Create: `src/main/java/com/howaboutus/backend/agreements/controller/dto/AgreementCurrentResponse.java` + - REST response wrapper. +- Create: `src/main/java/com/howaboutus/backend/agreements/controller/dto/AgreementDocumentResponse.java` + - REST response item. +- Create: `src/main/java/com/howaboutus/backend/user/controller/dto/AcceptAgreementsRequest.java` + - Request body for `POST /api/users/me/agreements`. +- Create: `src/main/java/com/howaboutus/backend/user/controller/UserAgreementController.java` + - Authenticated REST controller for `POST /api/users/me/agreements`. +- Create: `src/main/resources/agreements/terms-of-service.md` + - Moved original terms content from `docs/policy/terms-of-service.md`. +- Create: `src/main/resources/agreements/privacy-policy.md` + - Moved original privacy content from `docs/policy/privacy-policy.md`. +- Modify: `docs/policy/terms-of-service.md` + - Replace original terms content with a short pointer to the backend resource. +- Modify: `docs/policy/privacy-policy.md` + - Replace original privacy content with a short pointer to the backend resource. +- Modify: `src/main/resources/application.yaml` + - Add current agreement versions and classpath resources. +- Modify: `src/main/resources/db/migration/V1__init.sql` + - Add `tos_version`, `tos_accepted_at`, `privacy_version`, `privacy_accepted_at` to `users`. +- Modify: `src/main/java/com/howaboutus/backend/user/entity/User.java` + - Add agreement fields and domain methods. +- Modify: `src/main/java/com/howaboutus/backend/user/service/UserService.java` + - Create users with agreement versions and update current user's agreements. +- Modify: `src/main/java/com/howaboutus/backend/auth/service/AuthService.java` + - Require agreement acceptance for new users and stale-version existing users. +- Modify: `src/main/java/com/howaboutus/backend/auth/controller/AuthController.java` + - Pass `agreementsAccepted` to service. +- Modify: `src/main/java/com/howaboutus/backend/auth/controller/dto/GoogleLoginRequest.java` + - Add `agreementsAccepted`. +- Create: `src/main/java/com/howaboutus/backend/user/controller/UserAgreementController.java` + - Add `POST /api/users/me/agreements`. +- Modify: `src/main/java/com/howaboutus/backend/common/config/SecurityConfig.java` + - Permit `GET /api/agreements/current`. +- Modify: `src/main/java/com/howaboutus/backend/common/error/ErrorCode.java` + - Add agreement errors and server configuration error. +- Modify: `docs/ai/features.md` + - Document agreement APIs and auth behavior. +- Modify: `docs/ai/erd.md` + - Document new user agreement columns. + +--- + +### Task 1: Agreement Resources And Configuration + +**Files:** +- Create: `src/main/resources/agreements/terms-of-service.md` +- Create: `src/main/resources/agreements/privacy-policy.md` +- Modify: `docs/policy/terms-of-service.md` +- Modify: `docs/policy/privacy-policy.md` +- Modify: `src/main/resources/application.yaml` +- Create: `src/main/java/com/howaboutus/backend/agreements/config/AgreementProperties.java` + +- [ ] **Step 1: Move the two policy Markdown originals into resources** + +Move the exact original content from: + +```text +docs/policy/terms-of-service.md +docs/policy/privacy-policy.md +``` + +into: + +```text +src/main/resources/agreements/terms-of-service.md +src/main/resources/agreements/privacy-policy.md +``` + +Use `git mv` for each file, then recreate the original docs paths as pointer documents so existing documentation links remain valid: + +```markdown +# 이용약관 + +원문은 백엔드 약관 리소스로 이동했습니다. + +- 리소스: `src/main/resources/agreements/terms-of-service.md` +- 현재 버전 설정: `src/main/resources/application.yaml`의 `app.agreements.terms-of-service.version` +``` + +```markdown +# 개인정보 처리방침 + +원문은 백엔드 약관 리소스로 이동했습니다. + +- 리소스: `src/main/resources/agreements/privacy-policy.md` +- 현재 버전 설정: `src/main/resources/application.yaml`의 `app.agreements.privacy-policy.version` +``` + +- [ ] **Step 2: Add agreement configuration** + +In `src/main/resources/application.yaml`, add this block under the existing top-level configuration: + +```yaml +app: + agreements: + terms-of-service: + version: "1.0" + resource: "classpath:agreements/terms-of-service.md" + privacy-policy: + version: "1.0" + resource: "classpath:agreements/privacy-policy.md" +``` + +If `application.yaml` already has an `app:` block, merge the `agreements:` subtree into it instead of creating a second `app:` key. + +- [ ] **Step 3: Create properties binding** + +Create `src/main/java/com/howaboutus/backend/agreements/config/AgreementProperties.java`: + +```java +package com.howaboutus.backend.agreements.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.core.io.Resource; + +@ConfigurationProperties(prefix = "app.agreements") +public record AgreementProperties( + AgreementDocument termsOfService, + AgreementDocument privacyPolicy +) { + + public record AgreementDocument( + String version, + Resource resource + ) { + } +} +``` + +- [ ] **Step 4: Enable properties** + +Modify `src/main/java/com/howaboutus/backend/common/config/SecurityConfig.java` annotation from: + +```java +@EnableConfigurationProperties(CorsProperties.class) +``` + +to: + +```java +@EnableConfigurationProperties({CorsProperties.class, AgreementProperties.class}) +``` + +and add: + +```java +import com.howaboutus.backend.agreements.config.AgreementProperties; +``` + +- [ ] **Step 5: Compile** + +Run: + +```bash +./gradlew compileJava +``` + +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 6: Commit** + +```bash +git add src/main/resources/agreements src/main/resources/application.yaml \ + docs/policy/terms-of-service.md docs/policy/privacy-policy.md \ + src/main/java/com/howaboutus/backend/agreements/config/AgreementProperties.java \ + src/main/java/com/howaboutus/backend/common/config/SecurityConfig.java +git commit -m "feat: 약관 원문 리소스와 현재 버전 설정 추가" +``` + +--- + +### Task 2: Agreement Current API + +**Files:** +- Create: `src/main/java/com/howaboutus/backend/agreements/service/dto/AgreementDocumentResult.java` +- Create: `src/main/java/com/howaboutus/backend/agreements/service/dto/AgreementVersions.java` +- Create: `src/main/java/com/howaboutus/backend/agreements/service/AgreementService.java` +- Create: `src/main/java/com/howaboutus/backend/agreements/controller/dto/AgreementDocumentResponse.java` +- Create: `src/main/java/com/howaboutus/backend/agreements/controller/dto/AgreementCurrentResponse.java` +- Create: `src/main/java/com/howaboutus/backend/agreements/controller/AgreementController.java` +- Create: `src/test/java/com/howaboutus/backend/agreements/service/AgreementServiceTest.java` +- Create: `src/test/java/com/howaboutus/backend/agreements/controller/AgreementControllerTest.java` +- Modify: `src/main/java/com/howaboutus/backend/common/config/SecurityConfig.java` +- Modify: `src/main/java/com/howaboutus/backend/common/error/ErrorCode.java` + +- [ ] **Step 1: Add failing AgreementService tests** + +Create `src/test/java/com/howaboutus/backend/agreements/service/AgreementServiceTest.java`: + +```java +package com.howaboutus.backend.agreements.service; + +import static org.assertj.core.api.Assertions.*; + +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ByteArrayResource; + +import com.howaboutus.backend.agreements.config.AgreementProperties; +import com.howaboutus.backend.common.error.CustomException; +import com.howaboutus.backend.common.error.ErrorCode; + +class AgreementServiceTest { + + @Test + @DisplayName("설정된 현재 이용약관과 개인정보 처리방침을 Markdown으로 반환한다") + void returnsCurrentAgreements() { + AgreementService service = new AgreementService(properties( + "1.0", "# 이용약관", + "1.0", "# 개인정보 처리방침" + )); + + var result = service.getCurrentAgreements(); + + assertThat(result).hasSize(2); + assertThat(result.get(0).type()).isEqualTo("TERMS_OF_SERVICE"); + assertThat(result.get(0).title()).isEqualTo("이용약관"); + assertThat(result.get(0).version()).isEqualTo("1.0"); + assertThat(result.get(0).contentFormat()).isEqualTo("MARKDOWN"); + assertThat(result.get(0).content()).isEqualTo("# 이용약관"); + assertThat(result.get(1).type()).isEqualTo("PRIVACY_POLICY"); + assertThat(result.get(1).title()).isEqualTo("개인정보 처리방침"); + assertThat(result.get(1).version()).isEqualTo("1.0"); + } + + @Test + @DisplayName("약관 버전 설정이 비어 있으면 AGREEMENT_CONFIGURATION_INVALID 예외") + void throwsForBlankVersion() { + AgreementService service = new AgreementService(properties( + "", "# 이용약관", + "1.0", "# 개인정보 처리방침" + )); + + assertThatThrownBy(service::getCurrentAgreements) + .isInstanceOf(CustomException.class) + .satisfies(e -> assertThat(((CustomException)e).getErrorCode()) + .isEqualTo(ErrorCode.AGREEMENT_CONFIGURATION_INVALID)); + } + + private AgreementProperties properties(String tosVersion, String tosContent, + String privacyVersion, String privacyContent) { + return new AgreementProperties( + new AgreementProperties.AgreementDocument( + tosVersion, new ByteArrayResource(tosContent.getBytes(StandardCharsets.UTF_8))), + new AgreementProperties.AgreementDocument( + privacyVersion, new ByteArrayResource(privacyContent.getBytes(StandardCharsets.UTF_8))) + ); + } +} +``` + +- [ ] **Step 2: Run service test to verify failure** + +Run: + +```bash +./gradlew test --tests com.howaboutus.backend.agreements.service.AgreementServiceTest +``` + +Expected: FAIL because `AgreementService` and DTOs do not exist. + +- [ ] **Step 3: Add agreement errors** + +In `src/main/java/com/howaboutus/backend/common/error/ErrorCode.java`, add under `// 400 BAD REQUEST`: + +```java +AGREEMENTS_NOT_ACCEPTED(HttpStatus.BAD_REQUEST, "필수 약관에 동의해야 합니다"), +``` + +Add under `// 401 UNAUTHORIZED`: + +```java +AGREEMENTS_REACCEPTANCE_REQUIRED(HttpStatus.UNAUTHORIZED, "변경된 약관에 다시 동의해야 합니다"), +``` + +Add under `// 503 SERVICE UNAVAILABLE`: + +```java +AGREEMENT_CONFIGURATION_INVALID(HttpStatus.SERVICE_UNAVAILABLE, "약관 설정이 올바르지 않습니다"), +``` + +- [ ] **Step 4: Add service DTOs** + +Create `src/main/java/com/howaboutus/backend/agreements/service/dto/AgreementDocumentResult.java`: + +```java +package com.howaboutus.backend.agreements.service.dto; + +public record AgreementDocumentResult( + String type, + String title, + String version, + String contentFormat, + String content +) { +} +``` + +Create `src/main/java/com/howaboutus/backend/agreements/service/dto/AgreementVersions.java`: + +```java +package com.howaboutus.backend.agreements.service.dto; + +public record AgreementVersions( + String tosVersion, + String privacyVersion +) { +} +``` + +- [ ] **Step 5: Add AgreementService** + +Create `src/main/java/com/howaboutus/backend/agreements/service/AgreementService.java`: + +```java +package com.howaboutus.backend.agreements.service; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.List; + +import org.springframework.stereotype.Service; + +import com.howaboutus.backend.agreements.config.AgreementProperties; +import com.howaboutus.backend.agreements.service.dto.AgreementDocumentResult; +import com.howaboutus.backend.agreements.service.dto.AgreementVersions; +import com.howaboutus.backend.common.error.CustomException; +import com.howaboutus.backend.common.error.ErrorCode; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class AgreementService { + + private static final String CONTENT_FORMAT_MARKDOWN = "MARKDOWN"; + + private final AgreementProperties properties; + + public List getCurrentAgreements() { + return List.of( + document("TERMS_OF_SERVICE", "이용약관", properties.termsOfService()), + document("PRIVACY_POLICY", "개인정보 처리방침", properties.privacyPolicy()) + ); + } + + public AgreementVersions currentVersions() { + return new AgreementVersions( + requireVersion(properties.termsOfService()), + requireVersion(properties.privacyPolicy()) + ); + } + + public void validateAccepted(Boolean accepted) { + if (!Boolean.TRUE.equals(accepted)) { + throw new CustomException(ErrorCode.AGREEMENTS_NOT_ACCEPTED); + } + } + + private AgreementDocumentResult document(String type, String title, + AgreementProperties.AgreementDocument document) { + return new AgreementDocumentResult( + type, + title, + requireVersion(document), + CONTENT_FORMAT_MARKDOWN, + readContent(document) + ); + } + + private String requireVersion(AgreementProperties.AgreementDocument document) { + if (document == null || document.version() == null || document.version().isBlank()) { + throw new CustomException(ErrorCode.AGREEMENT_CONFIGURATION_INVALID); + } + return document.version(); + } + + private String readContent(AgreementProperties.AgreementDocument document) { + if (document == null || document.resource() == null) { + throw new CustomException(ErrorCode.AGREEMENT_CONFIGURATION_INVALID); + } + try { + return document.resource().getContentAsString(StandardCharsets.UTF_8); + } catch (IOException e) { + throw new CustomException(ErrorCode.AGREEMENT_CONFIGURATION_INVALID); + } + } +} +``` + +- [ ] **Step 6: Run service test to verify pass** + +Run: + +```bash +./gradlew test --tests com.howaboutus.backend.agreements.service.AgreementServiceTest +``` + +Expected: PASS. + +- [ ] **Step 7: Add failing controller test** + +Create `src/test/java/com/howaboutus/backend/agreements/controller/AgreementControllerTest.java`: + +```java +package com.howaboutus.backend.agreements.controller; + +import static org.mockito.BDDMockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import com.howaboutus.backend.agreements.service.AgreementService; +import com.howaboutus.backend.agreements.service.dto.AgreementDocumentResult; +import com.howaboutus.backend.auth.filter.JwtAuthenticationFilter; +import com.howaboutus.backend.auth.service.JwtProvider; +import com.howaboutus.backend.common.config.SecurityConfig; +import com.howaboutus.backend.common.error.GlobalExceptionHandler; +import com.howaboutus.backend.common.security.JwtAuthenticationEntryPoint; + +@WebMvcTest(AgreementController.class) +@Import({SecurityConfig.class, JwtAuthenticationFilter.class, JwtAuthenticationEntryPoint.class, + GlobalExceptionHandler.class}) +class AgreementControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private AgreementService agreementService; + + @MockitoBean + private JwtProvider jwtProvider; + + @Test + @DisplayName("비인증 상태에서 현재 약관을 조회한다") + void returnsCurrentAgreementsWithoutAuthentication() throws Exception { + given(agreementService.getCurrentAgreements()).willReturn(List.of( + new AgreementDocumentResult("TERMS_OF_SERVICE", "이용약관", "1.0", "MARKDOWN", "# 이용약관"), + new AgreementDocumentResult("PRIVACY_POLICY", "개인정보 처리방침", "1.0", "MARKDOWN", "# 개인정보") + )); + + mockMvc.perform(get("/api/agreements/current")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.items[0].type").value("TERMS_OF_SERVICE")) + .andExpect(jsonPath("$.items[0].version").value("1.0")) + .andExpect(jsonPath("$.items[0].contentFormat").value("MARKDOWN")) + .andExpect(jsonPath("$.items[0].content").value("# 이용약관")) + .andExpect(jsonPath("$.items[1].type").value("PRIVACY_POLICY")); + } +} +``` + +- [ ] **Step 8: Run controller test to verify failure** + +Run: + +```bash +./gradlew test --tests com.howaboutus.backend.agreements.controller.AgreementControllerTest +``` + +Expected: FAIL because controller and response DTOs do not exist. + +- [ ] **Step 9: Add controller DTOs and controller** + +Create `src/main/java/com/howaboutus/backend/agreements/controller/dto/AgreementDocumentResponse.java`: + +```java +package com.howaboutus.backend.agreements.controller.dto; + +import com.howaboutus.backend.agreements.service.dto.AgreementDocumentResult; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record AgreementDocumentResponse( + @Schema(description = "약관 문서 타입", example = "TERMS_OF_SERVICE") + String type, + @Schema(description = "약관 제목", example = "이용약관") + String title, + @Schema(description = "현재 약관 버전", example = "1.0") + String version, + @Schema(description = "본문 형식", example = "MARKDOWN") + String contentFormat, + @Schema(description = "약관 원문 Markdown") + String content +) { + + public static AgreementDocumentResponse from(AgreementDocumentResult result) { + return new AgreementDocumentResponse( + result.type(), + result.title(), + result.version(), + result.contentFormat(), + result.content() + ); + } +} +``` + +Create `src/main/java/com/howaboutus/backend/agreements/controller/dto/AgreementCurrentResponse.java`: + +```java +package com.howaboutus.backend.agreements.controller.dto; + +import java.util.List; + +import com.howaboutus.backend.agreements.service.dto.AgreementDocumentResult; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record AgreementCurrentResponse( + @Schema(description = "현재 필수 약관 목록") + List items +) { + + public static AgreementCurrentResponse from(List results) { + return new AgreementCurrentResponse( + results.stream() + .map(AgreementDocumentResponse::from) + .toList() + ); + } +} +``` + +Create `src/main/java/com/howaboutus/backend/agreements/controller/AgreementController.java`: + +```java +package com.howaboutus.backend.agreements.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.howaboutus.backend.agreements.controller.dto.AgreementCurrentResponse; +import com.howaboutus.backend.agreements.service.AgreementService; +import com.howaboutus.backend.common.error.ApiErrorCodes; +import com.howaboutus.backend.common.error.ErrorCode; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@Tag(name = "Agreements", description = "약관 API") +@RestController +@RequestMapping("/api/agreements") +@RequiredArgsConstructor +public class AgreementController { + + private final AgreementService agreementService; + + @Operation( + summary = "현재 약관 조회", + description = "가입과 재동의 화면에 표시할 현재 이용약관과 개인정보 처리방침 원문을 조회합니다." + ) + @ApiResponse(responseCode = "200", description = "조회 성공") + @ApiErrorCodes({ErrorCode.AGREEMENT_CONFIGURATION_INVALID}) + @GetMapping("/current") + public ResponseEntity getCurrentAgreements() { + return ResponseEntity.ok(AgreementCurrentResponse.from(agreementService.getCurrentAgreements())); + } +} +``` + +- [ ] **Step 10: Permit current agreements endpoint** + +In `src/main/java/com/howaboutus/backend/common/config/SecurityConfig.java`, add: + +```java +"/api/agreements/current", +``` + +to the existing `.requestMatchers(...).permitAll()` list. + +- [ ] **Step 11: Run controller test to verify pass** + +Run: + +```bash +./gradlew test --tests com.howaboutus.backend.agreements.controller.AgreementControllerTest +``` + +Expected: PASS. + +- [ ] **Step 12: Commit** + +```bash +git add src/main/java/com/howaboutus/backend/agreements \ + src/test/java/com/howaboutus/backend/agreements \ + src/main/java/com/howaboutus/backend/common/config/SecurityConfig.java \ + src/main/java/com/howaboutus/backend/common/error/ErrorCode.java +git commit -m "feat: 현재 약관 조회 API 추가" +``` + +--- + +### Task 3: User Agreement State + +**Files:** +- Modify: `src/main/resources/db/migration/V1__init.sql` +- Modify: `src/main/java/com/howaboutus/backend/user/entity/User.java` +- Modify: `src/main/java/com/howaboutus/backend/user/service/UserService.java` +- Modify: `src/test/java/com/howaboutus/backend/user/service/UserServiceTest.java` +- Create: `src/main/java/com/howaboutus/backend/user/controller/dto/AcceptAgreementsRequest.java` +- Create: `src/main/java/com/howaboutus/backend/user/controller/UserAgreementController.java` +- Create: `src/test/java/com/howaboutus/backend/user/controller/UserAgreementControllerTest.java` + +- [ ] **Step 1: Add failing UserService tests** + +Append to `src/test/java/com/howaboutus/backend/user/service/UserServiceTest.java`: + +```java +@Test +@DisplayName("신규 구글 유저 생성 시 현재 약관 버전과 동의 시각을 저장한다") +void getOrCreateGoogleUserCreatesUserWithAgreements() { + Instant acceptedAt = Instant.parse("2026-06-08T10:00:00Z"); + AgreementVersions versions = new AgreementVersions("1.0", "1.0"); + given(userRepository.findByProviderAndProviderId("GOOGLE", "provider-1")) + .willReturn(Optional.empty()); + given(userRepository.save(any(User.class))).willAnswer(invocation -> invocation.getArgument(0)); + + User result = userService.getOrCreateGoogleUser( + "provider-1", "test@gmail.com", "닉네임", "https://img.url/photo.jpg", versions, acceptedAt); + + assertThat(result.getTosVersion()).isEqualTo("1.0"); + assertThat(result.getTosAcceptedAt()).isEqualTo(acceptedAt); + assertThat(result.getPrivacyVersion()).isEqualTo("1.0"); + assertThat(result.getPrivacyAcceptedAt()).isEqualTo(acceptedAt); +} + +@Test +@DisplayName("현재 사용자 약관 동의를 서버 버전과 서버 시간으로 갱신한다") +void acceptCurrentAgreementsUpdatesUser() { + User user = User.ofGoogle( + "provider-1", "test@gmail.com", "닉네임", null, + new AgreementVersions("1.0", "1.0"), Instant.parse("2026-06-01T10:00:00Z")); + Instant acceptedAt = Instant.parse("2026-06-08T10:00:00Z"); + given(userRepository.findById(1L)).willReturn(Optional.of(user)); + + userService.acceptCurrentAgreements(1L, new AgreementVersions("1.1", "1.0"), acceptedAt); + + assertThat(user.getTosVersion()).isEqualTo("1.1"); + assertThat(user.getTosAcceptedAt()).isEqualTo(acceptedAt); + assertThat(user.getPrivacyVersion()).isEqualTo("1.0"); + assertThat(user.getPrivacyAcceptedAt()).isEqualTo(acceptedAt); +} +``` + +Add imports: + +```java +import java.time.Instant; + +import com.howaboutus.backend.agreements.service.dto.AgreementVersions; +``` + +- [ ] **Step 2: Run UserService test to verify failure** + +Run: + +```bash +./gradlew test --tests com.howaboutus.backend.user.service.UserServiceTest +``` + +Expected: FAIL because new methods and constructors do not exist. + +- [ ] **Step 3: Update users schema** + +In `src/main/resources/db/migration/V1__init.sql`, update the `users` table: + +```sql + provider_id VARCHAR(255) NOT NULL, + tos_version VARCHAR(30) NOT NULL, + tos_accepted_at TIMESTAMP WITH TIME ZONE NOT NULL, + privacy_version VARCHAR(30) NOT NULL, + privacy_accepted_at TIMESTAMP WITH TIME ZONE NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL, +``` + +Keep the provider unique constraint unchanged. + +- [ ] **Step 4: Update User entity** + +Modify `src/main/java/com/howaboutus/backend/user/entity/User.java`: + +```java +import java.time.Instant; + +import com.howaboutus.backend.agreements.service.dto.AgreementVersions; +``` + +Add fields: + +```java +@Column(nullable = false, length = 30) +private String tosVersion; + +@Column(nullable = false) +private Instant tosAcceptedAt; + +@Column(nullable = false, length = 30) +private String privacyVersion; + +@Column(nullable = false) +private Instant privacyAcceptedAt; +``` + +Replace the private constructor with: + +```java +private User(String providerId, String email, String nickname, String profileImageUrl, String provider, + AgreementVersions agreementVersions, Instant acceptedAt) { + this.providerId = providerId; + this.email = email; + this.nickname = nickname; + this.profileImageUrl = profileImageUrl; + this.provider = provider; + acceptAgreements(agreementVersions, acceptedAt); +} +``` + +Replace `ofGoogle` with: + +```java +public static User ofGoogle(String providerId, String email, String nickname, String profileImageUrl, + AgreementVersions agreementVersions, Instant acceptedAt) { + return new User(providerId, email, nickname, profileImageUrl, "GOOGLE", agreementVersions, acceptedAt); +} +``` + +Add: + +```java +public void acceptAgreements(AgreementVersions agreementVersions, Instant acceptedAt) { + this.tosVersion = agreementVersions.tosVersion(); + this.tosAcceptedAt = acceptedAt; + this.privacyVersion = agreementVersions.privacyVersion(); + this.privacyAcceptedAt = acceptedAt; +} +``` + +Temporarily keep a test convenience factory for existing tests: + +```java +public static User ofGoogle(String providerId, String email, String nickname, String profileImageUrl) { + return ofGoogle( + providerId, + email, + nickname, + profileImageUrl, + new AgreementVersions("1.0", "1.0"), + Instant.EPOCH + ); +} +``` + +- [ ] **Step 5: Update UserService** + +In `src/main/java/com/howaboutus/backend/user/service/UserService.java`, add imports: + +```java +import java.time.Instant; + +import com.howaboutus.backend.agreements.service.dto.AgreementVersions; +``` + +Add overload: + +```java +@Transactional +public User getOrCreateGoogleUser(String providerId, String email, String nickname, String profileImageUrl, + AgreementVersions agreementVersions, Instant acceptedAt) { + return userRepository.findByProviderAndProviderId("GOOGLE", providerId) + .orElseGet(() -> { + try { + return userRepository.save( + User.ofGoogle( + providerId, + email, + nickname, + profileImageUrl, + agreementVersions, + acceptedAt + ) + ); + } catch (DataIntegrityViolationException e) { + return userRepository.findByProviderAndProviderId("GOOGLE", providerId) + .orElseThrow(() -> e); + } + }); +} +``` + +Keep the existing `getOrCreateGoogleUser(String, String, String, String)` temporarily for older tests and update it to call the overload: + +```java +@Transactional +public User getOrCreateGoogleUser(String providerId, String email, String nickname, String profileImageUrl) { + return getOrCreateGoogleUser( + providerId, + email, + nickname, + profileImageUrl, + new AgreementVersions("1.0", "1.0"), + Instant.EPOCH + ); +} +``` + +Add: + +```java +@Transactional +public void acceptCurrentAgreements(Long userId, AgreementVersions agreementVersions, Instant acceptedAt) { + User user = getUser(userId); + user.acceptAgreements(agreementVersions, acceptedAt); +} +``` + +- [ ] **Step 6: Run UserService test to verify pass** + +Run: + +```bash +./gradlew test --tests com.howaboutus.backend.user.service.UserServiceTest +``` + +Expected: PASS. + +- [ ] **Step 7: Add failing user agreement controller test** + +Create `src/test/java/com/howaboutus/backend/user/controller/UserAgreementControllerTest.java`: + +```java +package com.howaboutus.backend.user.controller; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import com.howaboutus.backend.agreements.service.AgreementService; +import com.howaboutus.backend.agreements.service.dto.AgreementVersions; +import com.howaboutus.backend.auth.filter.JwtAuthenticationFilter; +import com.howaboutus.backend.auth.service.JwtProvider; +import com.howaboutus.backend.common.config.SecurityConfig; +import com.howaboutus.backend.common.error.GlobalExceptionHandler; +import com.howaboutus.backend.common.security.JwtAuthenticationEntryPoint; +import com.howaboutus.backend.user.service.UserService; + +import jakarta.servlet.http.Cookie; + +@WebMvcTest(UserAgreementController.class) +@Import({SecurityConfig.class, JwtAuthenticationFilter.class, JwtAuthenticationEntryPoint.class, + GlobalExceptionHandler.class}) +class UserAgreementControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private UserService userService; + + @MockitoBean + private AgreementService agreementService; + + @MockitoBean + private JwtProvider jwtProvider; + + @MockitoBean + private Clock clock; + + @Test + @DisplayName("인증된 사용자가 현재 약관에 재동의한다") + void acceptsCurrentAgreementsForAuthenticatedUser() throws Exception { + given(jwtProvider.extractUserId("valid-jwt")).willReturn(1L); + given(agreementService.currentVersions()).willReturn(new AgreementVersions("1.0", "1.0")); + given(clock.instant()).willReturn(Instant.parse("2026-06-08T10:00:00Z")); + given(clock.getZone()).willReturn(ZoneOffset.UTC); + + mockMvc.perform(post("/api/users/me/agreements") + .cookie(new Cookie("access_token", "valid-jwt")) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"agreementsAccepted": true} + """)) + .andExpect(status().isNoContent()); + + verify(userService).acceptCurrentAgreements(eq(1L), any(), any()); + } + + @Test + @DisplayName("인증 없이 현재 약관 재동의 요청 시 401을 반환한다") + void returns401WithoutAuthentication() throws Exception { + mockMvc.perform(post("/api/users/me/agreements") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"agreementsAccepted": true} + """)) + .andExpect(status().isUnauthorized()); + } +} +``` + +- [ ] **Step 8: Run user agreement controller test to verify failure** + +Run: + +```bash +./gradlew test --tests com.howaboutus.backend.user.controller.UserAgreementControllerTest +``` + +Expected: FAIL because request DTO and controller endpoint do not exist. + +- [ ] **Step 9: Add request DTO** + +Create `src/main/java/com/howaboutus/backend/user/controller/dto/AcceptAgreementsRequest.java`: + +```java +package com.howaboutus.backend.user.controller.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record AcceptAgreementsRequest( + @Schema(description = "현재 필수 약관 전체 동의 여부", example = "true") + Boolean agreementsAccepted +) { +} +``` + +- [ ] **Step 10: Add UserAgreementController** + +Create `src/main/java/com/howaboutus/backend/user/controller/UserAgreementController.java`: + +```java +package com.howaboutus.backend.user.controller; + +import java.time.Clock; +import java.time.Instant; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.howaboutus.backend.agreements.service.AgreementService; +import com.howaboutus.backend.common.error.ApiErrorCodes; +import com.howaboutus.backend.common.error.ErrorCode; +import com.howaboutus.backend.user.controller.dto.AcceptAgreementsRequest; +import com.howaboutus.backend.user.service.UserService; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@Tag(name = "Users", description = "사용자 API") +@RestController +@RequestMapping("/api/users/me/agreements") +@RequiredArgsConstructor +public class UserAgreementController { + + private final UserService userService; + private final AgreementService agreementService; + private final Clock clock; + +@Operation( + summary = "현재 약관 재동의", + description = "현재 인증된 사용자가 서버 기준 현재 이용약관과 개인정보 처리방침에 재동의합니다." +) +@ApiResponse(responseCode = "204", description = "재동의 성공", content = @Content) +@ApiErrorCodes({ + ErrorCode.INVALID_TOKEN, + ErrorCode.ACCESS_TOKEN_EXPIRED, + ErrorCode.USER_NOT_FOUND, + ErrorCode.AGREEMENTS_NOT_ACCEPTED +}) +@PostMapping +public ResponseEntity acceptCurrentAgreements(@AuthenticationPrincipal Long userId, + @RequestBody AcceptAgreementsRequest request) { + agreementService.validateAccepted(request.agreementsAccepted()); + userService.acceptCurrentAgreements(userId, agreementService.currentVersions(), Instant.now(clock)); + return ResponseEntity.noContent().build(); +} + +} +``` + +- [ ] **Step 11: Run user agreement controller test to verify pass** + +Run: + +```bash +./gradlew test --tests com.howaboutus.backend.user.controller.UserAgreementControllerTest +``` + +Expected: PASS. + +- [ ] **Step 12: Commit** + +```bash +git add src/main/resources/db/migration/V1__init.sql \ + src/main/java/com/howaboutus/backend/user \ + src/test/java/com/howaboutus/backend/user +git commit -m "feat: 사용자 약관 동의 상태 저장 추가" +``` + +--- + +### Task 4: Auth Agreement Enforcement + +**Files:** +- Modify: `src/main/java/com/howaboutus/backend/auth/controller/dto/GoogleLoginRequest.java` +- Modify: `src/main/java/com/howaboutus/backend/auth/controller/AuthController.java` +- Modify: `src/main/java/com/howaboutus/backend/auth/service/AuthService.java` +- Modify: `src/test/java/com/howaboutus/backend/auth/service/AuthServiceTest.java` +- Modify: `src/test/java/com/howaboutus/backend/auth/controller/AuthControllerTest.java` + +- [ ] **Step 1: Add failing AuthService tests** + +Modify `src/test/java/com/howaboutus/backend/auth/service/AuthServiceTest.java`. + +Add mocks: + +```java +@Mock +private AgreementService agreementService; + +@Mock +private Clock clock; +``` + +Add imports: + +```java +import java.time.Clock; +import java.time.Instant; + +import com.howaboutus.backend.agreements.service.AgreementService; +import com.howaboutus.backend.agreements.service.dto.AgreementVersions; +import com.howaboutus.backend.common.error.CustomException; +import com.howaboutus.backend.common.error.ErrorCode; +``` + +Update existing `authService.googleLogin("auth-code")` calls to: + +```java +authService.googleLogin("auth-code", true) +``` + +Update stubs for new user creation: + +```java +AgreementVersions versions = new AgreementVersions("1.0", "1.0"); +Instant now = Instant.parse("2026-06-08T10:00:00Z"); +given(agreementService.currentVersions()).willReturn(versions); +given(clock.instant()).willReturn(now); +given(userService.getOrCreateGoogleUser("google-123", "test@gmail.com", "테스트", null, versions, now)) + .willReturn(mockUser); +``` + +Add tests: + +```java +@Test +@DisplayName("신규 사용자가 약관에 동의하지 않으면 가입과 토큰 발급을 거부한다") +void rejectsNewUserWithoutAgreementAcceptance() { + GoogleUserInfo userInfo = new GoogleUserInfo("google-123", "test@gmail.com", "테스트", null); + given(googleOAuthClient.login("auth-code")).willReturn(userInfo); + given(userService.findGoogleUser("google-123")).willReturn(Optional.empty()); + + assertThatThrownBy(() -> authService.googleLogin("auth-code", false)) + .isInstanceOf(CustomException.class) + .satisfies(e -> assertThat(((CustomException)e).getErrorCode()) + .isEqualTo(ErrorCode.AGREEMENTS_NOT_ACCEPTED)); +} + +@Test +@DisplayName("기존 사용자의 약관 버전이 오래됐고 동의하지 않으면 재동의를 요구한다") +void rejectsExistingUserWithStaleAgreementsWithoutAcceptance() { + GoogleUserInfo userInfo = new GoogleUserInfo("google-123", "test@gmail.com", "테스트", null); + User existingUser = User.ofGoogle("google-123", "test@gmail.com", "테스트", null); + given(googleOAuthClient.login("auth-code")).willReturn(userInfo); + given(userService.findGoogleUser("google-123")).willReturn(Optional.of(existingUser)); + given(agreementService.needsReacceptance(existingUser)).willReturn(true); + + assertThatThrownBy(() -> authService.googleLogin("auth-code", false)) + .isInstanceOf(CustomException.class) + .satisfies(e -> assertThat(((CustomException)e).getErrorCode()) + .isEqualTo(ErrorCode.AGREEMENTS_REACCEPTANCE_REQUIRED)); +} + +@Test +@DisplayName("기존 사용자의 약관 버전이 오래됐고 동의하면 갱신 후 토큰을 발급한다") +void updatesStaleAgreementsAndReturnsTokens() { + GoogleUserInfo userInfo = new GoogleUserInfo("google-123", "test@gmail.com", "테스트", null); + User existingUser = User.ofGoogle("google-123", "test@gmail.com", "테스트", null); + AgreementVersions versions = new AgreementVersions("1.1", "1.0"); + Instant now = Instant.parse("2026-06-08T10:00:00Z"); + given(googleOAuthClient.login("auth-code")).willReturn(userInfo); + given(userService.findGoogleUser("google-123")).willReturn(Optional.of(existingUser)); + given(agreementService.needsReacceptance(existingUser)).willReturn(true); + given(agreementService.currentVersions()).willReturn(versions); + given(clock.instant()).willReturn(now); + given(jwtProvider.generateAccessToken(any())).willReturn("jwt-token"); + given(refreshTokenService.create(any())).willReturn("1:refresh-uuid"); + + LoginResult result = authService.googleLogin("auth-code", true); + + verify(userService).acceptCurrentAgreements(existingUser.getId(), versions, now); + assertThat(result.accessToken()).isEqualTo("jwt-token"); + assertThat(result.refreshToken()).isEqualTo("1:refresh-uuid"); +} +``` + +Add: + +```java +import java.util.Optional; +``` + +- [ ] **Step 2: Run AuthService test to verify failure** + +Run: + +```bash +./gradlew test --tests com.howaboutus.backend.auth.service.AuthServiceTest +``` + +Expected: FAIL because `AuthService.googleLogin(String, Boolean)` and `UserService.findGoogleUser` do not exist. + +- [ ] **Step 3: Add UserService lookup method** + +In `src/main/java/com/howaboutus/backend/user/service/UserService.java`, add: + +```java +import java.util.Optional; +``` + +Add method: + +```java +public Optional findGoogleUser(String providerId) { + return userRepository.findByProviderAndProviderId("GOOGLE", providerId); +} +``` + +- [ ] **Step 4: Add agreement reacceptance comparison** + +In `src/main/java/com/howaboutus/backend/agreements/service/AgreementService.java`, add import: + +```java +import com.howaboutus.backend.user.entity.User; +``` + +Add method: + +```java +public boolean needsReacceptance(User user) { + AgreementVersions versions = currentVersions(); + return !versions.tosVersion().equals(user.getTosVersion()) + || !versions.privacyVersion().equals(user.getPrivacyVersion()); +} +``` + +- [ ] **Step 5: Update AuthService** + +Modify `src/main/java/com/howaboutus/backend/auth/service/AuthService.java`. + +Add fields: + +```java +private final AgreementService agreementService; +private final Clock clock; +``` + +Add imports: + +```java +import java.time.Clock; +import java.time.Instant; + +import com.howaboutus.backend.agreements.service.AgreementService; +import com.howaboutus.backend.agreements.service.dto.AgreementVersions; +import com.howaboutus.backend.common.error.CustomException; +import com.howaboutus.backend.common.error.ErrorCode; +``` + +Replace `googleLogin(String authorizationCode)` with: + +```java +@Loggable +@Transactional +public LoginResult googleLogin(String authorizationCode, Boolean agreementsAccepted) { + GoogleUserInfo userInfo = googleOAuthClient.login(authorizationCode); + + User user = userService.findGoogleUser(userInfo.providerId()) + .map(existingUser -> handleExistingUser(existingUser, agreementsAccepted)) + .orElseGet(() -> createNewUser(userInfo, agreementsAccepted)); + + String accessToken = jwtProvider.generateAccessToken(user.getId()); + String refreshToken = refreshTokenService.create(user.getId()); + + return new LoginResult(accessToken, refreshToken, user.getId()); +} + +private User handleExistingUser(User user, Boolean agreementsAccepted) { + if (!agreementService.needsReacceptance(user)) { + return user; + } + if (!Boolean.TRUE.equals(agreementsAccepted)) { + throw new CustomException(ErrorCode.AGREEMENTS_REACCEPTANCE_REQUIRED); + } + userService.acceptCurrentAgreements(user.getId(), agreementService.currentVersions(), Instant.now(clock)); + return user; +} + +private User createNewUser(GoogleUserInfo userInfo, Boolean agreementsAccepted) { + agreementService.validateAccepted(agreementsAccepted); + AgreementVersions versions = agreementService.currentVersions(); + return userService.getOrCreateGoogleUser( + userInfo.providerId(), + userInfo.email(), + userInfo.nickname(), + userInfo.profileImageUrl(), + versions, + Instant.now(clock) + ); +} +``` + +Do not keep the old `googleLogin(String)` method after controller tests are updated. + +- [ ] **Step 6: Run AuthService test** + +Run: + +```bash +./gradlew test --tests com.howaboutus.backend.auth.service.AuthServiceTest +``` + +Expected: PASS after updating any stale stubs to the new flow. + +- [ ] **Step 7: Update GoogleLoginRequest** + +Modify `src/main/java/com/howaboutus/backend/auth/controller/dto/GoogleLoginRequest.java`: + +```java +package com.howaboutus.backend.auth.controller.dto; + +import com.howaboutus.backend.common.logging.MaskField; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record GoogleLoginRequest( + @MaskField + @Schema(description = "Google OAuth2 인가 코드") + String code, + @Schema(description = "현재 필수 약관 전체 동의 여부", example = "true") + Boolean agreementsAccepted +) { +} +``` + +- [ ] **Step 8: Update AuthController** + +In `src/main/java/com/howaboutus/backend/auth/controller/AuthController.java`, change: + +```java +LoginResult result = authService.googleLogin(request.code()); +``` + +to: + +```java +LoginResult result = authService.googleLogin(request.code(), request.agreementsAccepted()); +``` + +Update the Google login `@Operation(description = ...)` to mention that new signups and stale existing users must send `agreementsAccepted=true`. + +Add `@ApiErrorCodes` entries: + +```java +@ApiErrorCodes({ + ErrorCode.GOOGLE_AUTH_FAILED, + ErrorCode.AGREEMENTS_NOT_ACCEPTED, + ErrorCode.AGREEMENTS_REACCEPTANCE_REQUIRED +}) +``` + +- [ ] **Step 9: Update AuthControllerTest** + +In `src/test/java/com/howaboutus/backend/auth/controller/AuthControllerTest.java`, update login JSON: + +```json +{"code": "valid-code", "agreementsAccepted": true} +``` + +and stubbing: + +```java +given(authService.googleLogin("valid-code", true)) +``` + +For the bad-code test: + +```json +{"code": "bad-code", "agreementsAccepted": true} +``` + +and: + +```java +given(authService.googleLogin("bad-code", true)) +``` + +- [ ] **Step 10: Run auth controller test** + +Run: + +```bash +./gradlew test --tests com.howaboutus.backend.auth.controller.AuthControllerTest +``` + +Expected: PASS. + +- [ ] **Step 11: Commit** + +```bash +git add src/main/java/com/howaboutus/backend/auth \ + src/test/java/com/howaboutus/backend/auth \ + src/main/java/com/howaboutus/backend/user/service/UserService.java +git commit -m "feat: 로그인 약관 동의 검증 추가" +``` + +--- + +### Task 5: Docs And API Spec Sync + +**Files:** +- Modify: `docs/ai/features.md` +- Modify: `docs/ai/erd.md` +- Verify: Java Swagger annotations already changed in Tasks 2-4 + +- [ ] **Step 1: Update features.md auth/user section** + +In `docs/ai/features.md`, under the auth/user area around Google OAuth login and profile rows, add or update rows to include: + +```markdown +| `[x]` | 현재 약관 조회 | `GET /api/agreements/current`로 현재 이용약관과 개인정보 처리방침 원문 Markdown, 버전, 문서 타입을 비인증 상태에서 조회한다. 약관 원문은 백엔드 리소스 파일에 있고 현재 버전은 `application.yaml`에서 관리한다 | - | +| `[x]` | 가입 약관 동의 기록 | `POST /auth/google/login`에서 프론트는 `agreementsAccepted`만 전송한다. 신규 사용자는 값이 `true`일 때만 생성되며, 백엔드 현재 약관 버전과 서버 시간을 `users`에 저장한다 | users | +| `[x]` | 약관 재동의 | 기존 사용자의 저장 약관 버전이 현재 서버 버전과 다르면 로그인 시 재동의가 필요하다. 로그인 요청에서 `agreementsAccepted=true`이면 서버 현재 버전으로 갱신 후 토큰을 발급하고, 이미 로그인된 사용자는 `POST /api/users/me/agreements`로 현재 약관에 재동의한다 | users | +``` + +- [ ] **Step 2: Update erd.md users table** + +In `docs/ai/erd.md`, update `users` columns with: + +```markdown +| tos_version | VARCHAR(30) | NOT NULL | 동의한 이용약관 버전 | +| tos_accepted_at | TIMESTAMP WITH TIME ZONE | NOT NULL | 이용약관 동의 또는 재동의 시각 | +| privacy_version | VARCHAR(30) | NOT NULL | 동의한 개인정보 처리방침 버전 | +| privacy_accepted_at | TIMESTAMP WITH TIME ZONE | NOT NULL | 개인정보 처리방침 동의 또는 재동의 시각 | +``` + +Add a short note below the table: + +```markdown +약관 원문은 DB에 저장하지 않고 백엔드 리소스 파일로 관리한다. 현재 버전은 `application.yaml`의 `app.agreements` 설정을 기준으로 하며, 프론트엔드는 버전 문자열을 전송하지 않는다. +``` + +- [ ] **Step 3: Run Markdown conflict check** + +Run: + +```bash +rg -n '`docs/ai/[^`]*\.md`|`[A-Z][A-Z_]*\.md`' -g '*.md' +rg -n "Doc Update Rules|갱신 규칙|불일치|Source Of Truth|문서 경로|Project Docs" AGENTS.md docs/ai/README.md docs/ai/api-spec-guidelines.md docs/ai/plugin-guidelines.md +``` + +Expected: + +- Referenced paths exist. +- `docs/ai/README.md` still delegates update rules to `AGENTS.md`. +- No contradiction between new agreement feature rows and `erd.md` user columns. + +- [ ] **Step 4: Commit** + +```bash +git add docs/ai/features.md docs/ai/erd.md +git commit -m "docs: 약관 동의 기능과 ERD 반영" +``` + +--- + +### Task 6: Final Verification + +**Files:** +- Verify all changed code and docs + +- [ ] **Step 1: Run focused tests** + +Run: + +```bash +./gradlew test --tests com.howaboutus.backend.agreements.service.AgreementServiceTest \ + --tests com.howaboutus.backend.agreements.controller.AgreementControllerTest \ + --tests com.howaboutus.backend.user.service.UserServiceTest \ + --tests com.howaboutus.backend.user.controller.UserAgreementControllerTest \ + --tests com.howaboutus.backend.auth.service.AuthServiceTest \ + --tests com.howaboutus.backend.auth.controller.AuthControllerTest +``` + +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 2: Run full tests** + +Run: + +```bash +./gradlew test +``` + +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 3: Run checkstyle** + +Run: + +```bash +./gradlew checkstyleMain checkstyleTest +``` + +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 4: Review git status** + +Run: + +```bash +git status --short +``` + +Expected: no unstaged implementation changes. If verification generated reports only under `build/`, do not commit them. + +- [ ] **Step 5: Summarize implementation** + +Prepare a concise summary covering: + +```text +- GET /api/agreements/current added and public +- Markdown agreement resources added +- users agreement version/timestamp fields added +- Google login now requires/handles agreementsAccepted +- POST /api/users/me/agreements added for authenticated reacceptance +- docs/ai/features.md and docs/ai/erd.md updated +- Tests and checkstyle commands run +``` diff --git a/docs/superpowers/specs/2026-06-08-backend-agreements-design.md b/docs/superpowers/specs/2026-06-08-backend-agreements-design.md new file mode 100644 index 00000000..6cb5ecfa --- /dev/null +++ b/docs/superpowers/specs/2026-06-08-backend-agreements-design.md @@ -0,0 +1,212 @@ +# Backend Agreements Design + +## Context + +현재 약관 원문은 `docs/policy/terms-of-service.md`와 `docs/policy/privacy-policy.md`에 작성되어 있다. 빠른 런칭을 위해 약관 버전과 동의 기록만 관리하려는 논의가 있었으나, 프론트엔드가 보낸 버전 문자열을 신뢰하면 동의 증적이 오염될 수 있다. + +따라서 백엔드가 약관 원문과 현재 버전의 기준이 되고, 프론트엔드는 백엔드가 내려준 원문을 표시한 뒤 사용자의 동의 여부만 전송한다. + +## Goals + +- 백엔드가 현재 이용약관과 개인정보 처리방침 원문을 제공한다. +- 백엔드가 현재 약관 버전을 설정으로 관리한다. +- 프론트엔드는 약관 버전을 전송하지 않는다. +- 신규 가입 시 서버 기준 현재 버전과 서버 시간을 `users`에 저장한다. +- 기존 사용자가 현재 약관 버전과 다른 버전에 동의한 상태라면 로그인 토큰 발급 전에 재동의를 요구한다. +- 인증된 기존 사용자는 별도 API로 현재 약관에 재동의할 수 있다. + +## Non-Goals + +- 약관 변경 이력 테이블을 만들지 않는다. +- 과거 약관 원문을 DB에 저장하지 않는다. +- 운영정책과 저작권 정책 원문은 이번 범위에서 백엔드 리소스로 이동하지 않는다. +- 약관 고지 발송, 공지 예약, 30일 고지 기간 자동 계산은 이번 범위에 포함하지 않는다. +- 개인정보 처리 목적 변경 등 별도 명시 동의가 필요한 정책 판단을 자동화하지 않는다. + +## Source Files + +이번 구현에서 백엔드 리소스로 옮길 원문은 두 개다. + +```text +docs/policy/terms-of-service.md +docs/policy/privacy-policy.md +``` + +이 두 파일의 내용을 다음 리소스 파일로 이동한다. + +```text +src/main/resources/agreements/terms-of-service.md +src/main/resources/agreements/privacy-policy.md +``` + +`terms-of-service.md` 내부의 `privacy-policy.md`, `operation-policy.md`, `copyright-policy.md` 링크는 API 응답에서 그대로 노출될 수 있으므로 구현 시 링크 의미를 확인한다. 이번 범위에서 운영정책과 저작권 정책은 백엔드가 제공하는 필수 동의 문서가 아니다. + +## Configuration + +현재 약관 버전과 원문 리소스 경로는 `application.yaml`에서 관리한다. + +```yaml +app: + agreements: + terms-of-service: + version: "1.0" + resource: "classpath:agreements/terms-of-service.md" + privacy-policy: + version: "1.0" + resource: "classpath:agreements/privacy-policy.md" +``` + +설정이 비어 있거나 리소스 파일을 읽을 수 없으면 서버 설정 오류로 처리한다. 백엔드는 클라이언트가 보낸 버전 문자열을 저장하지 않는다. + +## Data Model + +데이터베이스는 초기화 가능하므로 `users` 테이블에 약관별 동의 컬럼을 `NOT NULL`로 추가한다. + +```text +tos_version VARCHAR(30) NOT NULL +tos_accepted_at TIMESTAMP WITH TIME ZONE NOT NULL +privacy_version VARCHAR(30) NOT NULL +privacy_accepted_at TIMESTAMP WITH TIME ZONE NOT NULL +``` + +`User` 엔티티에도 같은 의미의 필드를 추가한다. + +- `tosVersion` +- `tosAcceptedAt` +- `privacyVersion` +- `privacyAcceptedAt` + +신규 사용자는 생성 시점에 이 네 값을 반드시 가진다. 기존 사용자는 저장된 버전이 설정의 현재 버전과 다르면 재동의가 필요하다. + +## API Design + +### GET /api/agreements/current + +현재 필수 약관 원문과 버전을 조회한다. 가입 화면에서 사용해야 하므로 인증 없이 호출할 수 있어야 한다. + +응답 예시: + +```json +{ + "items": [ + { + "type": "TERMS_OF_SERVICE", + "title": "이용약관", + "version": "1.0", + "contentFormat": "MARKDOWN", + "content": "# 이용약관\n..." + }, + { + "type": "PRIVACY_POLICY", + "title": "개인정보 처리방침", + "version": "1.0", + "contentFormat": "MARKDOWN", + "content": "# 개인정보 처리방침\n..." + } + ] +} +``` + +### POST /auth/google/login + +기존 Google 로그인 요청에 `agreementsAccepted`를 추가한다. + +요청 예시: + +```json +{ + "code": "google-authorization-code", + "agreementsAccepted": true +} +``` + +신규 사용자 처리: + +- `agreementsAccepted`가 `true`가 아니면 가입을 거부한다. +- Google 인증 성공 후 `users` 생성 시 설정의 현재 버전과 서버 시간을 저장한다. +- 저장된 동의 버전은 클라이언트 요청값이 아니라 서버 설정값이다. + +기존 사용자 처리: + +- 저장된 약관 버전이 설정의 현재 버전과 같으면 기존처럼 토큰을 발급한다. +- 저장된 약관 버전이 현재 버전과 다르고 `agreementsAccepted`가 `true`가 아니면 토큰을 발급하지 않고 재동의 필요 에러를 반환한다. +- 저장된 약관 버전이 현재 버전과 다르고 `agreementsAccepted`가 `true`이면 현재 서버 버전과 서버 시간으로 동의 컬럼을 갱신한 뒤 토큰을 발급한다. + +### POST /api/users/me/agreements + +인증된 기존 사용자가 현재 약관에 재동의한다. 이미 로그인된 사용자가 서비스 이용 중 약관 변경 안내를 보고 재동의하는 흐름에 사용한다. 로그아웃 상태의 기존 사용자는 `POST /auth/google/login`에서 `agreementsAccepted=true`를 보내 재동의와 로그인을 함께 처리한다. + +요청 예시: + +```json +{ + "agreementsAccepted": true +} +``` + +동작: + +- `agreementsAccepted`가 `true`가 아니면 거부한다. +- 현재 서버 설정의 이용약관 버전과 개인정보 처리방침 버전으로 `users`의 동의 컬럼을 갱신한다. +- 갱신 시각은 서버 시간을 사용한다. + +## Errors + +새 에러 코드를 추가한다. + +```text +AGREEMENTS_NOT_ACCEPTED +AGREEMENTS_REACCEPTANCE_REQUIRED +``` + +- `AGREEMENTS_NOT_ACCEPTED`: 신규 가입 또는 재동의 API에서 동의 여부가 `true`가 아닐 때 사용한다. +- `AGREEMENTS_REACCEPTANCE_REQUIRED`: 기존 사용자가 현재 서버 약관 버전에 동의하지 않았고 로그인 요청에도 동의 여부가 포함되지 않아 토큰을 발급할 수 없을 때 사용한다. + +약관 리소스 파일을 읽을 수 없거나 설정이 잘못된 경우는 클라이언트 동의 문제가 아니라 서버 설정 문제로 분리한다. + +## Components + +- `agreements` 도메인 패키지를 추가한다. +- `AgreementProperties`는 현재 버전과 리소스 경로를 바인딩한다. +- `AgreementService`는 현재 약관 목록 조회, 현재 버전 조회, 사용자 동의 상태 비교를 담당한다. +- `AgreementController`는 `GET /api/agreements/current`를 제공한다. +- `UserService`는 신규 사용자 생성과 기존 사용자 재동의 갱신 시 약관 버전을 저장한다. +- `AuthService`는 로그인 시 신규 가입 동의 여부와 기존 사용자 재동의 필요 여부를 검사한다. + +## Security + +`GET /api/agreements/current`는 공개 엔드포인트로 허용한다. `POST /api/users/me/agreements`는 인증된 사용자만 호출할 수 있다. `POST /auth/google/login`은 기존처럼 공개 엔드포인트지만, 신규 사용자 생성에는 `agreementsAccepted=true`가 필요하다. + +## Documentation Updates + +- `docs/ai/features.md`: 인증/사용자 기능에 약관 조회, 가입 동의 기록, 재동의 필요 흐름을 반영한다. +- `docs/ai/erd.md`: `users` 테이블의 약관 동의 컬럼을 반영한다. +- `docs/ai/api-spec-guidelines.md`: 변경하지 않는다. +- API 명세 변경이 있으므로 Swagger 어노테이션과 요청/응답 DTO 설명을 실제 동작과 맞춘다. + +## Testing + +- `AgreementServiceTest` + - 설정된 현재 약관 두 개를 반환한다. + - 리소스 파일 읽기 실패 또는 설정 누락을 서버 설정 오류로 처리한다. +- `AgreementControllerTest` + - 비인증 상태에서 현재 약관 조회가 가능하다. +- `AuthServiceTest` + - 신규 사용자가 동의하지 않으면 생성과 토큰 발급을 거부한다. + - 신규 사용자가 동의하면 현재 서버 버전과 서버 시간을 저장한다. + - 기존 사용자의 저장 버전이 현재 버전이면 로그인한다. + - 기존 사용자의 저장 버전이 오래됐고 동의 여부가 없으면 토큰 발급을 거부한다. + - 기존 사용자의 저장 버전이 오래됐고 `agreementsAccepted=true`이면 현재 서버 버전으로 갱신하고 로그인한다. +- `UserServiceTest` + - 인증된 기존 사용자의 재동의 API가 현재 서버 버전과 서버 시간으로 동의 컬럼을 갱신한다. +- 컨트롤러 테스트 + - `POST /api/users/me/agreements`는 인증이 필요하다. + - `agreementsAccepted=false` 또는 누락 시 거부한다. + +최소 검증은 관련 테스트와 `./gradlew compileJava`다. 구현에서 Java DTO, 컨트롤러, 엔티티, 마이그레이션이 바뀌므로 PR 전에는 `./gradlew test`와 `./gradlew checkstyleMain checkstyleTest`도 수행한다. + +## Implementation Decisions + +- 약관 Markdown의 상대 링크는 원문 그대로 응답한다. 프론트는 현재 필수 동의 문서인 이용약관과 개인정보 처리방침을 백엔드 응답으로 표시하고, 운영정책과 저작권 정책 링크는 별도 화면 또는 외부 문서 연결 방식으로 처리한다. +- 로그아웃 상태의 기존 사용자가 재동의해야 하는 경우에는 `POST /auth/google/login`에서 `agreementsAccepted=true`를 받아 재동의 갱신과 토큰 발급을 한 번에 처리한다. +- 이미 로그인된 사용자의 재동의는 `POST /api/users/me/agreements`에서 처리한다. diff --git a/src/main/java/com/howaboutus/backend/agreements/config/AgreementProperties.java b/src/main/java/com/howaboutus/backend/agreements/config/AgreementProperties.java new file mode 100644 index 00000000..8a125f6c --- /dev/null +++ b/src/main/java/com/howaboutus/backend/agreements/config/AgreementProperties.java @@ -0,0 +1,17 @@ +package com.howaboutus.backend.agreements.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.core.io.Resource; + +@ConfigurationProperties(prefix = "app.agreements") +public record AgreementProperties( + AgreementDocument termsOfService, + AgreementDocument privacyPolicy +) { + + public record AgreementDocument( + String version, + Resource resource + ) { + } +} diff --git a/src/main/java/com/howaboutus/backend/agreements/controller/AgreementController.java b/src/main/java/com/howaboutus/backend/agreements/controller/AgreementController.java new file mode 100644 index 00000000..45d7f4b9 --- /dev/null +++ b/src/main/java/com/howaboutus/backend/agreements/controller/AgreementController.java @@ -0,0 +1,36 @@ +package com.howaboutus.backend.agreements.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.howaboutus.backend.agreements.controller.dto.AgreementCurrentResponse; +import com.howaboutus.backend.agreements.service.AgreementService; +import com.howaboutus.backend.common.error.ApiErrorCodes; +import com.howaboutus.backend.common.error.ErrorCode; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@Tag(name = "Agreements", description = "약관 API") +@RestController +@RequestMapping("/api/agreements") +@RequiredArgsConstructor +public class AgreementController { + + private final AgreementService agreementService; + + @Operation( + summary = "현재 약관 조회", + description = "가입과 재동의 화면에 표시할 현재 이용약관과 개인정보 처리방침 원문을 조회합니다." + ) + @ApiResponse(responseCode = "200", description = "조회 성공") + @ApiErrorCodes({ErrorCode.AGREEMENT_CONFIGURATION_INVALID}) + @GetMapping("/current") + public ResponseEntity getCurrentAgreements() { + return ResponseEntity.ok(AgreementCurrentResponse.from(agreementService.getCurrentAgreements())); + } +} diff --git a/src/main/java/com/howaboutus/backend/agreements/controller/dto/AgreementCurrentResponse.java b/src/main/java/com/howaboutus/backend/agreements/controller/dto/AgreementCurrentResponse.java new file mode 100644 index 00000000..9d1d4ae3 --- /dev/null +++ b/src/main/java/com/howaboutus/backend/agreements/controller/dto/AgreementCurrentResponse.java @@ -0,0 +1,21 @@ +package com.howaboutus.backend.agreements.controller.dto; + +import java.util.List; + +import com.howaboutus.backend.agreements.service.dto.AgreementDocumentResult; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record AgreementCurrentResponse( + @Schema(description = "현재 필수 약관 목록") + List items +) { + + public static AgreementCurrentResponse from(List results) { + return new AgreementCurrentResponse( + results.stream() + .map(AgreementDocumentResponse::from) + .toList() + ); + } +} diff --git a/src/main/java/com/howaboutus/backend/agreements/controller/dto/AgreementDocumentResponse.java b/src/main/java/com/howaboutus/backend/agreements/controller/dto/AgreementDocumentResponse.java new file mode 100644 index 00000000..6c9f2ac6 --- /dev/null +++ b/src/main/java/com/howaboutus/backend/agreements/controller/dto/AgreementDocumentResponse.java @@ -0,0 +1,29 @@ +package com.howaboutus.backend.agreements.controller.dto; + +import com.howaboutus.backend.agreements.service.dto.AgreementDocumentResult; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record AgreementDocumentResponse( + @Schema(description = "약관 문서 타입", example = "TERMS_OF_SERVICE") + String type, + @Schema(description = "약관 제목", example = "이용약관") + String title, + @Schema(description = "현재 약관 버전", example = "1.0") + String version, + @Schema(description = "본문 형식", example = "MARKDOWN") + String contentFormat, + @Schema(description = "약관 원문 Markdown") + String content +) { + + public static AgreementDocumentResponse from(AgreementDocumentResult result) { + return new AgreementDocumentResponse( + result.type(), + result.title(), + result.version(), + result.contentFormat(), + result.content() + ); + } +} diff --git a/src/main/java/com/howaboutus/backend/agreements/service/AgreementService.java b/src/main/java/com/howaboutus/backend/agreements/service/AgreementService.java new file mode 100644 index 00000000..54c46b7f --- /dev/null +++ b/src/main/java/com/howaboutus/backend/agreements/service/AgreementService.java @@ -0,0 +1,80 @@ +package com.howaboutus.backend.agreements.service; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.List; + +import org.springframework.stereotype.Service; + +import com.howaboutus.backend.agreements.config.AgreementProperties; +import com.howaboutus.backend.agreements.service.dto.AgreementDocumentResult; +import com.howaboutus.backend.agreements.service.dto.AgreementVersions; +import com.howaboutus.backend.common.error.CustomException; +import com.howaboutus.backend.common.error.ErrorCode; +import com.howaboutus.backend.user.entity.User; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class AgreementService { + + private static final String CONTENT_FORMAT_MARKDOWN = "MARKDOWN"; + + private final AgreementProperties properties; + + public List getCurrentAgreements() { + return List.of( + document("TERMS_OF_SERVICE", "이용약관", properties.termsOfService()), + document("PRIVACY_POLICY", "개인정보 처리방침", properties.privacyPolicy()) + ); + } + + public AgreementVersions currentVersions() { + return new AgreementVersions( + requireVersion(properties.termsOfService()), + requireVersion(properties.privacyPolicy()) + ); + } + + public void validateAccepted(Boolean accepted) { + if (!Boolean.TRUE.equals(accepted)) { + throw new CustomException(ErrorCode.AGREEMENTS_NOT_ACCEPTED); + } + } + + public boolean needsReacceptance(User user) { + AgreementVersions versions = currentVersions(); + return !versions.tosVersion().equals(user.getTosVersion()) + || !versions.privacyVersion().equals(user.getPrivacyVersion()); + } + + private AgreementDocumentResult document(String type, String title, + AgreementProperties.AgreementDocument document) { + return new AgreementDocumentResult( + type, + title, + requireVersion(document), + CONTENT_FORMAT_MARKDOWN, + readContent(document) + ); + } + + private String requireVersion(AgreementProperties.AgreementDocument document) { + if (document == null || document.version() == null || document.version().isBlank()) { + throw new CustomException(ErrorCode.AGREEMENT_CONFIGURATION_INVALID); + } + return document.version(); + } + + private String readContent(AgreementProperties.AgreementDocument document) { + if (document == null || document.resource() == null) { + throw new CustomException(ErrorCode.AGREEMENT_CONFIGURATION_INVALID); + } + try { + return document.resource().getContentAsString(StandardCharsets.UTF_8); + } catch (IOException e) { + throw new CustomException(ErrorCode.AGREEMENT_CONFIGURATION_INVALID); + } + } +} diff --git a/src/main/java/com/howaboutus/backend/agreements/service/dto/AgreementDocumentResult.java b/src/main/java/com/howaboutus/backend/agreements/service/dto/AgreementDocumentResult.java new file mode 100644 index 00000000..d8f23624 --- /dev/null +++ b/src/main/java/com/howaboutus/backend/agreements/service/dto/AgreementDocumentResult.java @@ -0,0 +1,10 @@ +package com.howaboutus.backend.agreements.service.dto; + +public record AgreementDocumentResult( + String type, + String title, + String version, + String contentFormat, + String content +) { +} diff --git a/src/main/java/com/howaboutus/backend/agreements/service/dto/AgreementVersions.java b/src/main/java/com/howaboutus/backend/agreements/service/dto/AgreementVersions.java new file mode 100644 index 00000000..39c4b43d --- /dev/null +++ b/src/main/java/com/howaboutus/backend/agreements/service/dto/AgreementVersions.java @@ -0,0 +1,7 @@ +package com.howaboutus.backend.agreements.service.dto; + +public record AgreementVersions( + String tosVersion, + String privacyVersion +) { +} diff --git a/src/main/java/com/howaboutus/backend/auth/controller/AuthController.java b/src/main/java/com/howaboutus/backend/auth/controller/AuthController.java index 3931b907..81e249ef 100644 --- a/src/main/java/com/howaboutus/backend/auth/controller/AuthController.java +++ b/src/main/java/com/howaboutus/backend/auth/controller/AuthController.java @@ -45,14 +45,19 @@ public class AuthController { @Operation( summary = "Google 로그인", - description = "Google OAuth2 인가 코드를 사용하여 로그인을 진행하고, 쿠키에 액세스 토큰 및 리프레시 토큰을 심어 반환합니다." + description = "Google OAuth2 인가 코드를 사용하여 로그인을 진행하고, 쿠키에 액세스 토큰 및 리프레시 토큰을 심어 반환합니다. " + + "신규 가입자와 변경된 약관에 다시 동의해야 하는 기존 사용자는 agreementsAccepted=true를 보내야 합니다." ) @ApiResponse(responseCode = "200", description = "로그인 성공") - @ApiErrorCodes({ErrorCode.GOOGLE_AUTH_FAILED}) + @ApiErrorCodes({ + ErrorCode.GOOGLE_AUTH_FAILED, + ErrorCode.AGREEMENTS_NOT_ACCEPTED, + ErrorCode.AGREEMENTS_REACCEPTANCE_REQUIRED + }) @Loggable @PostMapping("/google/login") public ResponseEntity googleLogin(@RequestBody GoogleLoginRequest request) { - LoginResult result = authService.googleLogin(request.code()); + LoginResult result = authService.googleLogin(request.code(), request.agreementsAccepted()); return ResponseEntity.ok() .header(HttpHeaders.SET_COOKIE, buildAccessTokenCookie(result.accessToken()).toString()) .header(HttpHeaders.SET_COOKIE, buildRefreshTokenCookie(result.refreshToken()).toString()) diff --git a/src/main/java/com/howaboutus/backend/auth/controller/dto/GoogleLoginRequest.java b/src/main/java/com/howaboutus/backend/auth/controller/dto/GoogleLoginRequest.java index eb4bf90d..fbc094cc 100644 --- a/src/main/java/com/howaboutus/backend/auth/controller/dto/GoogleLoginRequest.java +++ b/src/main/java/com/howaboutus/backend/auth/controller/dto/GoogleLoginRequest.java @@ -2,7 +2,13 @@ import com.howaboutus.backend.common.logging.MaskField; +import io.swagger.v3.oas.annotations.media.Schema; + public record GoogleLoginRequest( - @MaskField String code + @MaskField + @Schema(description = "Google OAuth2 인가 코드") + String code, + @Schema(description = "현재 필수 약관 전체 동의 여부", example = "true") + Boolean agreementsAccepted ) { } diff --git a/src/main/java/com/howaboutus/backend/auth/service/AuthService.java b/src/main/java/com/howaboutus/backend/auth/service/AuthService.java index 5ff6a33a..09d7e372 100644 --- a/src/main/java/com/howaboutus/backend/auth/service/AuthService.java +++ b/src/main/java/com/howaboutus/backend/auth/service/AuthService.java @@ -1,8 +1,13 @@ package com.howaboutus.backend.auth.service; +import java.time.Clock; +import java.time.Instant; + import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.howaboutus.backend.agreements.service.AgreementService; +import com.howaboutus.backend.agreements.service.dto.AgreementVersions; import com.howaboutus.backend.auth.service.dto.GoogleUserInfo; import com.howaboutus.backend.auth.service.dto.LoginResult; import com.howaboutus.backend.auth.service.dto.RotateResult; @@ -23,18 +28,17 @@ public class AuthService { private final UserService userService; private final JwtProvider jwtProvider; private final RefreshTokenService refreshTokenService; + private final AgreementService agreementService; + private final Clock clock; @Loggable @Transactional - public LoginResult googleLogin(String authorizationCode) { + public LoginResult googleLogin(String authorizationCode, Boolean agreementsAccepted) { GoogleUserInfo userInfo = googleOAuthClient.login(authorizationCode); - User user = userService.getOrCreateGoogleUser( - userInfo.providerId(), - userInfo.email(), - userInfo.nickname(), - userInfo.profileImageUrl() - ); + User user = userService.findGoogleUser(userInfo.providerId()) + .map(existingUser -> handleExistingUser(existingUser, agreementsAccepted)) + .orElseGet(() -> createNewUser(userInfo, agreementsAccepted)); String accessToken = jwtProvider.generateAccessToken(user.getId()); String refreshToken = refreshTokenService.create(user.getId()); @@ -42,6 +46,30 @@ public LoginResult googleLogin(String authorizationCode) { return new LoginResult(accessToken, refreshToken, user.getId()); } + private User handleExistingUser(User user, Boolean agreementsAccepted) { + if (!agreementService.needsReacceptance(user)) { + return user; + } + if (!Boolean.TRUE.equals(agreementsAccepted)) { + throw new CustomException(ErrorCode.AGREEMENTS_REACCEPTANCE_REQUIRED); + } + userService.acceptCurrentAgreements(user.getId(), agreementService.currentVersions(), Instant.now(clock)); + return user; + } + + private User createNewUser(GoogleUserInfo userInfo, Boolean agreementsAccepted) { + agreementService.validateAccepted(agreementsAccepted); + AgreementVersions versions = agreementService.currentVersions(); + return userService.getOrCreateGoogleUser( + userInfo.providerId(), + userInfo.email(), + userInfo.nickname(), + userInfo.profileImageUrl(), + versions, + Instant.now(clock) + ); + } + @Loggable public LoginResult refresh(String refreshToken) { RotateResult rotated = refreshTokenService.rotate(refreshToken); diff --git a/src/main/java/com/howaboutus/backend/common/config/SecurityConfig.java b/src/main/java/com/howaboutus/backend/common/config/SecurityConfig.java index 5e6124da..264f1759 100644 --- a/src/main/java/com/howaboutus/backend/common/config/SecurityConfig.java +++ b/src/main/java/com/howaboutus/backend/common/config/SecurityConfig.java @@ -16,6 +16,7 @@ import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import com.howaboutus.backend.agreements.config.AgreementProperties; import com.howaboutus.backend.auth.filter.JwtAuthenticationFilter; import com.howaboutus.backend.common.config.properties.CorsProperties; import com.howaboutus.backend.common.ratelimit.HttpRateLimitFilter; @@ -25,7 +26,7 @@ @Configuration @RequiredArgsConstructor -@EnableConfigurationProperties(CorsProperties.class) +@EnableConfigurationProperties({CorsProperties.class, AgreementProperties.class}) public class SecurityConfig { private final CorsProperties corsProperties; @@ -50,6 +51,7 @@ SecurityFilterChain securityFilterChain(HttpSecurity http) { "/actuator/health", "/actuator/prometheus", "/actuator/caches", + "/api/agreements/current", "/auth/google/login", "/auth/refresh", "/auth/logout", diff --git a/src/main/java/com/howaboutus/backend/common/error/ErrorCode.java b/src/main/java/com/howaboutus/backend/common/error/ErrorCode.java index 63158fa6..364644f3 100644 --- a/src/main/java/com/howaboutus/backend/common/error/ErrorCode.java +++ b/src/main/java/com/howaboutus/backend/common/error/ErrorCode.java @@ -21,6 +21,7 @@ public enum ErrorCode { MESSAGE_CONTENT_TOO_LONG(HttpStatus.BAD_REQUEST, "메시지는 1000자 이하여야 합니다"), MESSAGE_PLACE_ID_BLANK(HttpStatus.BAD_REQUEST, "공유할 장소 ID는 공백일 수 없습니다"), AI_REQUEST_ID_BLANK(HttpStatus.BAD_REQUEST, "AI 요청 ID는 공백일 수 없습니다"), + AGREEMENTS_NOT_ACCEPTED(HttpStatus.BAD_REQUEST, "필수 약관에 동의해야 합니다"), // 401 UNAUTHORIZED GOOGLE_AUTH_FAILED(HttpStatus.UNAUTHORIZED, "Google 인증에 실패했습니다"), @@ -28,6 +29,7 @@ public enum ErrorCode { ACCESS_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "액세스 토큰이 만료되었습니다"), REFRESH_TOKEN_NOT_FOUND(HttpStatus.UNAUTHORIZED, "리프레시 토큰이 존재하지 않습니다"), REFRESH_TOKEN_REUSE_DETECTED(HttpStatus.UNAUTHORIZED, "토큰 재사용이 감지되었습니다"), + AGREEMENTS_REACCEPTANCE_REQUIRED(HttpStatus.UNAUTHORIZED, "변경된 약관에 다시 동의해야 합니다"), // 400 BAD REQUEST SCHEDULE_DATE_MISMATCH(HttpStatus.BAD_REQUEST, "여행 날짜와 일차 정보가 일치하지 않습니다"), @@ -71,6 +73,7 @@ public enum ErrorCode { // 503 SERVICE UNAVAILABLE ROUTE_TEMPORARILY_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "이동 경로를 조회 중입니다. 잠시 후 다시 시도해 주세요"), + AGREEMENT_CONFIGURATION_INVALID(HttpStatus.SERVICE_UNAVAILABLE, "약관 설정이 올바르지 않습니다"), // 422 UNPROCESSABLE ENTITY WITHDRAWAL_REQUIRES_HOST_DELEGATION( diff --git a/src/main/java/com/howaboutus/backend/user/controller/UserAgreementController.java b/src/main/java/com/howaboutus/backend/user/controller/UserAgreementController.java new file mode 100644 index 00000000..905bb659 --- /dev/null +++ b/src/main/java/com/howaboutus/backend/user/controller/UserAgreementController.java @@ -0,0 +1,53 @@ +package com.howaboutus.backend.user.controller; + +import java.time.Clock; +import java.time.Instant; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.howaboutus.backend.agreements.service.AgreementService; +import com.howaboutus.backend.common.error.ApiErrorCodes; +import com.howaboutus.backend.common.error.ErrorCode; +import com.howaboutus.backend.user.controller.dto.AcceptAgreementsRequest; +import com.howaboutus.backend.user.service.UserService; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@Tag(name = "Users", description = "사용자 API") +@RestController +@RequestMapping("/api/users/me/agreements") +@RequiredArgsConstructor +public class UserAgreementController { + + private final UserService userService; + private final AgreementService agreementService; + private final Clock clock; + + @Operation( + summary = "현재 약관 재동의", + description = "현재 인증된 사용자가 서버 기준 현재 이용약관과 개인정보 처리방침에 재동의합니다." + ) + @ApiResponse(responseCode = "204", description = "재동의 성공", content = @Content) + @ApiErrorCodes({ + ErrorCode.INVALID_TOKEN, + ErrorCode.ACCESS_TOKEN_EXPIRED, + ErrorCode.USER_NOT_FOUND, + ErrorCode.AGREEMENTS_NOT_ACCEPTED + }) + @PostMapping + public ResponseEntity acceptCurrentAgreements(@AuthenticationPrincipal Long userId, + @RequestBody AcceptAgreementsRequest request) { + agreementService.validateAccepted(request.agreementsAccepted()); + userService.acceptCurrentAgreements(userId, agreementService.currentVersions(), Instant.now(clock)); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/howaboutus/backend/user/controller/dto/AcceptAgreementsRequest.java b/src/main/java/com/howaboutus/backend/user/controller/dto/AcceptAgreementsRequest.java new file mode 100644 index 00000000..b038965a --- /dev/null +++ b/src/main/java/com/howaboutus/backend/user/controller/dto/AcceptAgreementsRequest.java @@ -0,0 +1,9 @@ +package com.howaboutus.backend.user.controller.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record AcceptAgreementsRequest( + @Schema(description = "현재 필수 약관 전체 동의 여부", example = "true") + Boolean agreementsAccepted +) { +} diff --git a/src/main/java/com/howaboutus/backend/user/entity/User.java b/src/main/java/com/howaboutus/backend/user/entity/User.java index e2c5d88b..16527949 100644 --- a/src/main/java/com/howaboutus/backend/user/entity/User.java +++ b/src/main/java/com/howaboutus/backend/user/entity/User.java @@ -4,6 +4,7 @@ import org.hibernate.annotations.SQLRestriction; +import com.howaboutus.backend.agreements.service.dto.AgreementVersions; import com.howaboutus.backend.common.entity.BaseTimeEntity; import jakarta.persistence.Column; @@ -42,19 +43,52 @@ public class User extends BaseTimeEntity { @Column private String providerId; + @Column(nullable = false, length = 30) + private String tosVersion; + + @Column(nullable = false) + private Instant tosAcceptedAt; + + @Column(nullable = false, length = 30) + private String privacyVersion; + + @Column(nullable = false) + private Instant privacyAcceptedAt; + @Column(name = "deleted_at") private Instant deletedAt; - private User(String providerId, String email, String nickname, String profileImageUrl, String provider) { + private User(String providerId, String email, String nickname, String profileImageUrl, String provider, + AgreementVersions agreementVersions, Instant acceptedAt) { this.providerId = providerId; this.email = email; this.nickname = nickname; this.profileImageUrl = profileImageUrl; this.provider = provider; + acceptAgreements(agreementVersions, acceptedAt); + } + + public static User ofGoogle(String providerId, String email, String nickname, String profileImageUrl, + AgreementVersions agreementVersions, Instant acceptedAt) { + return new User(providerId, email, nickname, profileImageUrl, "GOOGLE", agreementVersions, acceptedAt); } public static User ofGoogle(String providerId, String email, String nickname, String profileImageUrl) { - return new User(providerId, email, nickname, profileImageUrl, "GOOGLE"); + return ofGoogle( + providerId, + email, + nickname, + profileImageUrl, + new AgreementVersions("1.0", "1.0"), + Instant.EPOCH + ); + } + + public void acceptAgreements(AgreementVersions agreementVersions, Instant acceptedAt) { + this.tosVersion = agreementVersions.tosVersion(); + this.tosAcceptedAt = acceptedAt; + this.privacyVersion = agreementVersions.privacyVersion(); + this.privacyAcceptedAt = acceptedAt; } public boolean isWithdrawn() { diff --git a/src/main/java/com/howaboutus/backend/user/service/UserService.java b/src/main/java/com/howaboutus/backend/user/service/UserService.java index 426a90fd..582a06c3 100644 --- a/src/main/java/com/howaboutus/backend/user/service/UserService.java +++ b/src/main/java/com/howaboutus/backend/user/service/UserService.java @@ -1,12 +1,15 @@ package com.howaboutus.backend.user.service; +import java.time.Instant; import java.util.Collection; import java.util.List; +import java.util.Optional; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.howaboutus.backend.agreements.service.dto.AgreementVersions; import com.howaboutus.backend.common.error.CustomException; import com.howaboutus.backend.common.error.ErrorCode; import com.howaboutus.backend.user.entity.User; @@ -37,8 +40,13 @@ public List getUsersByIds(Collection userIds) { return userRepository.findAllById(userIds); } + public Optional findGoogleUser(String providerId) { + return userRepository.findByProviderAndProviderId("GOOGLE", providerId); + } + @Transactional - public User getOrCreateGoogleUser(String providerId, String email, String nickname, String profileImageUrl) { + public User getOrCreateGoogleUser(String providerId, String email, String nickname, String profileImageUrl, + AgreementVersions agreementVersions, Instant acceptedAt) { return userRepository.findByProviderAndProviderId("GOOGLE", providerId) .orElseGet(() -> { try { @@ -47,7 +55,9 @@ public User getOrCreateGoogleUser(String providerId, String email, String nickna providerId, email, nickname, - profileImageUrl + profileImageUrl, + agreementVersions, + acceptedAt ) ); } catch (DataIntegrityViolationException e) { @@ -56,4 +66,22 @@ public User getOrCreateGoogleUser(String providerId, String email, String nickna } }); } + + @Transactional + public User getOrCreateGoogleUser(String providerId, String email, String nickname, String profileImageUrl) { + return getOrCreateGoogleUser( + providerId, + email, + nickname, + profileImageUrl, + new AgreementVersions("1.0", "1.0"), + Instant.EPOCH + ); + } + + @Transactional + public void acceptCurrentAgreements(Long userId, AgreementVersions agreementVersions, Instant acceptedAt) { + User user = getUser(userId); + user.acceptAgreements(agreementVersions, acceptedAt); + } } diff --git a/src/main/resources/agreements/privacy-policy.md b/src/main/resources/agreements/privacy-policy.md new file mode 100644 index 00000000..d53f0637 --- /dev/null +++ b/src/main/resources/agreements/privacy-policy.md @@ -0,0 +1,211 @@ +# 개인정보 처리방침 + +우때(이하 "서비스")는 이용자의 개인정보를 소중히 여기며, 「개인정보 보호법」 등 관련 법령을 준수합니다. 이 처리방침은 서비스가 어떤 개인정보를 어떤 목적으로 처리하는지, 이용자가 어떤 권리를 행사할 수 있는지 안내합니다. + +**시행일: 2026년 6월 7일** + +--- + +## 1. 개인정보 수집 항목 및 이용 목적 + +서비스는 이용 목적에 필요한 최소한의 개인정보를 수집·이용합니다. + +### 회원 가입 및 계정 관리 + +서비스는 Google 소셜 로그인을 통해 가입과 로그인을 제공합니다. + +| 법적 근거 | 수집 항목 | 이용 목적 | 보유 기간 | +|-----------|-----------|-----------|-----------| +| 개인정보 보호법 제15조 제1항 제4호(계약 이행) | 이메일 주소, 이름, 프로필 이미지 URL, Google 계정 고유 식별자 | 계정 식별, 중복 가입 방지, 로그인, 서비스 내 프로필 표시, 공지 전달 | 회원 탈퇴 시까지 | + +### 서비스 이용 과정에서 이용자가 입력하는 정보 + +| 법적 근거 | 수집 항목 | 이용 목적 | 보유 기간 | +|-----------|-----------|-----------|-----------| +| 개인정보 보호법 제15조 제1항 제4호(계약 이행) | 여행 방 제목, 여행지, 여행 날짜, 초대·참여 정보 | 여행 방 생성·관리, 멤버 협업 기능 제공 | 방 삭제 시까지. 단, 회원 탈퇴 시 탈퇴자의 참여 정보는 삭제되며, 탈퇴 처리와 함께 방이 삭제되는 경우 방 내 데이터도 함께 삭제 | +| 개인정보 보호법 제15조 제1항 제4호(계약 이행) | 채팅 메시지, AI 요청 메시지, 장소 공유 메시지 | 실시간 채팅, 메시지 조회, AI 여행 계획 보조 | 방 삭제 시까지. 단, 탈퇴 처리와 함께 방이 삭제되는 경우 함께 삭제 | +| 개인정보 보호법 제15조 제1항 제4호(계약 이행) | 여행 일정, 일정 메모, 북마크 장소, 장소 메모 | 여행 계획 작성·수정·공유 | 방 삭제 시까지. 단, 탈퇴 처리와 함께 방이 삭제되는 경우 함께 삭제 | +| 개인정보 보호법 제15조 제1항 제4호(계약 이행) | 문의·신고 내용, 이메일 주소, 처리에 필요한 추가 정보 | 문의 응대, 신고 처리, 분쟁 대응 | 처리 완료 후 3년 또는 관련 법령상 보관 기간 | + +### 서비스 이용 과정에서 자동으로 수집되는 정보 + +| 법적 근거 | 수집 항목 | 이용 목적 | 보유 기간 | +|-----------|-----------|-----------|-----------| +| 개인정보 보호법 제15조 제1항 제4호(계약 이행) | IP 주소, 접속 일시, User-Agent, 요청 경로, 기기 및 브라우저 정보 | 보안, 부정 이용 탐지, 장애 대응, 접속 기록 관리 | 접속 로그 3개월 | +| 개인정보 보호법 제15조 제1항 제4호(계약 이행) | `access_token`, `refresh_token` 쿠키 | 로그인 인증, 자동 로그인 유지, WebSocket 인증 | Access Token: 운영 설정에 따른 만료 시간(현재 운영 기준 30분) / Refresh Token: 14일 | +| 개인정보 보호법 제15조 제1항 제4호(계약 이행) | 서비스 이용 기록, 요청 횟수, Rate Limit 처리 기록 | 서비스 안정성 확보, 도배·남용 방지 | 목적 달성 시 또는 관련 캐시 만료 시까지 | +| 개인정보 보호법 제15조 제1항 제1호(동의) 또는 제15조 제1항 제4호(계약 이행) | Google Analytics 쿠키, 페이지뷰, 이벤트, 기기·브라우저 정보 | 서비스 이용 통계 분석, 서비스 개선 | Google Analytics 보관 설정 및 쿠키 보존 기간에 따름 | + +### 장소·경로 검색 시 처리되는 정보 + +서비스는 Google Places/Routes API를 이용해 장소 검색, 장소 상세 정보, 사진, 이동 경로 정보를 제공합니다. + +| 처리 항목 | 이용 목적 | 보관 여부 | +|-----------|-----------|-----------| +| 검색어, Google 장소 ID, 좌표, 이동수단, 출발·도착 장소 ID | 장소 검색, 장소 상세 조회, 이동 경로 조회 | 서비스 DB에 위치 이력으로 저장하지 않음 | + +--- + +## 2. 이용자의 동의 없는 이용 및 제공 + +서비스는 원칙적으로 이용자의 동의 없이 개인정보를 목적 외로 이용하거나 제3자에게 제공하지 않습니다. 다만 「개인정보 보호법」 등 관련 법령에 따라 다음의 경우에는 동의 없이 이용하거나 제공할 수 있습니다. + +1. 법률에 특별한 규정이 있거나 법령상 의무를 준수하기 위해 불가피한 경우 +2. 이용자와 체결한 서비스 이용계약을 이행하거나 계약 체결 과정에서 이용자의 요청에 따른 조치를 이행하기 위해 필요한 경우 +3. 이용자 또는 제3자의 생명, 신체, 재산의 급박한 이익을 위해 필요하다고 인정되는 경우 +4. 서비스의 정당한 이익을 달성하기 위해 필요한 경우로서 이용자의 권리를 부당하게 침해하지 않는 경우 +5. 수집 목적과 합리적으로 관련된 범위에서 이용자의 이익을 부당하게 침해하지 않고 추가 이용 또는 제공이 가능한 경우 +6. 수사기관, 법원, 감독기관 등이 적법한 절차에 따라 요청하는 경우 + +--- + +## 3. 개인정보의 보유기간 및 파기 + +서비스는 개인정보의 처리 목적이 달성되거나 보유 기간이 경과하면 지체 없이 해당 개인정보를 파기합니다. 다만 법령에 따라 보관이 필요한 정보는 해당 기간 동안 별도로 분리하여 보관한 뒤 파기합니다. + +| 항목 | 보유 기간 | +|------|-----------| +| 회원 정보(이메일, 이름, 프로필 이미지 URL, Google 계정 고유 식별자) | 회원 탈퇴 시까지 | +| 여행 방 제목, 여행지, 여행 날짜, 초대·참여 정보 | 방 삭제 시까지. 단, 회원 탈퇴 시 탈퇴자의 참여 정보는 삭제되며, 탈퇴 처리와 함께 방이 삭제되는 경우 방 내 데이터도 함께 삭제 | +| 채팅 메시지, AI 요청 메시지, 장소 공유 메시지 | 방 삭제 시까지. 단, 탈퇴 처리와 함께 방이 삭제되는 경우 함께 삭제 | +| 일정, 메모, 북마크, 장소 카드 등 여행 방 데이터 | 방 삭제 시까지. 단, 탈퇴 처리와 함께 방이 삭제되는 경우 함께 삭제 | +| 문의·신고 및 분쟁 처리 기록 | 처리 완료 후 3년 | +| 접속 로그 | 3개월(통신비밀보호법 시행령상 인터넷 로그기록 보관 기준) | +| Access Token 쿠키 | 운영 설정에 따른 만료 시간(현재 운영 기준 30분) | +| Refresh Token 쿠키 및 Redis 세션 정보 | 14일 | +| Rate Limit 및 일시 캐시 정보 | 각 캐시·제한 정책의 만료 시까지 | + +파기 방법은 다음과 같습니다. + +1. 전자적 파일: 복구 또는 재생이 어렵도록 삭제 +2. 종이 문서: 분쇄 또는 소각 + +## 4. 만 14세 미만 아동의 개인정보 + +서비스는 만 14세 미만 아동의 가입을 허용하지 않습니다. 만 14세 미만 이용자의 가입 또는 개인정보 처리가 확인되는 경우 해당 계정과 개인정보를 지체 없이 삭제합니다. + +서비스는 현재 법정대리인 동의 절차를 운영하지 않습니다. + +--- + +## 5. 개인정보의 제3자 제공 + +서비스는 이용자의 개인정보를 원칙적으로 제3자에게 제공하지 않습니다. 다만 이용자가 사전에 동의한 경우 또는 관련 법령에 근거가 있는 경우에는 예외적으로 제공할 수 있습니다. + +--- + +## 6. 개인정보 처리의 위탁 및 국외 이전 + +서비스는 원활한 운영을 위해 다음과 같이 개인정보 처리 업무를 외부 사업자에게 위탁하거나 국외로 이전할 수 있습니다. + +| 수탁자 | 국가 | 연락처 | 위탁 업무 | 이전 또는 처리 항목 | 이전 방법 | 보유·이용 기간 | +|--------|------|--------|-----------|---------------------|-----------|----------------| +| Amazon Web Services | 서비스 운영 리전 | [AWS Privacy Notice](https://aws.amazon.com/privacy/) | 서버 인프라 운영, 데이터 보관 | 서비스 내 전체 데이터 | 서비스 이용 시 네트워크를 통한 자동 전송 및 저장 | 서비스 운영 기간 또는 위탁계약 종료 시까지 | +| Google LLC | 미국 | [Google Privacy Policy](https://policies.google.com/privacy) | Google 소셜 로그인, 장소·경로 API, 서비스 이용 통계 분석(Google Analytics) | Google 계정 정보, 검색어, 장소 ID, 좌표, IP 주소, 쿠키, 서비스 이용 기록 | 서비스 이용 시 네트워크를 통한 자동 전송(API 호출 등) | Google 정책 및 서비스 설정에 따름 | +| OpenAI, L.L.C. | 미국 | [OpenAI Privacy Policy](https://openai.com/policies/privacy-policy) | AI 응답 생성, 대화 요약 생성 | 채팅 메시지, AI 요청 메시지, 여행 일정·북마크 요약, 장소 정보 요약 | AI 기능 이용 시 네트워크를 통한 자동 전송(API 호출 등) | 처리 목적 달성 시까지 또는 위탁계약 종료 시까지 | + +AI 기능 관련 안내: 이용자가 AI 어시스턴트에게 메시지를 전송하면 해당 메시지와 여행 방의 일정, 북마크, 채팅 요약 정보가 AI 응답 생성을 위해 처리될 수 있습니다. 같은 여행 방 내 다른 멤버의 메시지와 장소 정보가 요약 정보에 포함될 수 있으므로, 민감한 개인정보는 AI 어시스턴트에게 입력하지 않는 것이 좋습니다. + +국외 이전을 원하지 않는 이용자는 외부 서비스가 필요한 기능, 특히 AI 기능, 장소·경로 검색, Google Analytics가 포함된 서비스 이용을 제한하거나 서비스 이용을 중단할 수 있습니다. + +--- + +## 7. 이용자의 권리와 행사 방법 + +이용자는 언제든지 다음 권리를 행사할 수 있습니다. + +1. 개인정보 열람 요구 +2. 오류 등이 있는 경우 정정 요구 +3. 삭제 요구 +4. 처리정지 요구 +5. 동의 철회 + +권리 행사는 team.uttae@gmail.com으로 요청할 수 있습니다. 서비스는 본인 확인 후 지체 없이 처리합니다. + +다음의 경우에는 법령에 따라 권리 행사가 제한될 수 있습니다. + +1. 법령상 의무 준수를 위해 보관이 필요한 경우 +2. 다른 사람의 생명, 신체, 재산 또는 권리를 침해할 우려가 있는 경우 +3. 개인정보를 처리하지 않으면 이용자와의 서비스 이용계약을 이행하기 어려운 경우 +4. 다른 법령에서 해당 개인정보의 수집 또는 보관을 요구하는 경우 + +이용자는 본인의 계정 정보와 인증 수단을 안전하게 관리해야 하며, 타인의 개인정보를 무단으로 수집·공개·훼손해서는 안 됩니다. + +--- + +## 8. 개인정보 보호책임자 및 권익침해 구제방법 + +서비스는 개인정보 처리와 관련한 문의, 불만, 피해 구제를 위해 아래 연락처를 운영합니다. + +| 항목 | 내용 | +|------|------| +| 개인정보 보호책임자 | 박주영 | +| 이메일 | team.uttae@gmail.com | + +개인정보와 관련된 불만 또는 피해 구제는 아래 기관에도 신청할 수 있습니다. + +| 기관 | 연락처 | +|------|--------| +| 개인정보 침해신고센터 | [privacy.kisa.or.kr](https://privacy.kisa.or.kr) / 국번없이 118 | +| 개인정보 분쟁조정위원회 | [www.kopico.go.kr](https://www.kopico.go.kr) / 1833-6972 | +| 경찰청 사이버범죄 신고시스템 | [ecrm.police.go.kr](https://ecrm.police.go.kr) / 국번없이 182 | + +--- + +## 9. 개인정보의 안전성 확보 조치 + +서비스는 개인정보의 안전성 확보를 위해 다음 조치를 시행합니다. + +1. 관리적 조치: 개인정보 접근 권한 최소화, 운영자 접근 관리, 내부 점검 +2. 기술적 조치: HTTPS 통신, 인증 토큰 HttpOnly 쿠키 적용, 비밀번호 없는 Google OAuth 로그인, 접근 권한 관리, 민감 로그 마스킹, Rate Limit을 통한 남용 방지 +3. 저장·접근 보호: PostgreSQL, MongoDB, Redis 접근 권한 분리, 운영 환경 비밀값 분리 관리 +4. 로그 보호: 토큰, 인증 코드 등 민감 정보가 로그에 남지 않도록 마스킹 + +--- + +## 10. 쿠키의 설치·운영 및 거부 + +쿠키는 웹사이트가 이용자의 브라우저에 저장하는 소량의 정보입니다. 서비스는 로그인 인증과 이용 통계 분석을 위해 쿠키를 사용할 수 있습니다. + +| 쿠키명 | 목적 | 보존 기간 | +|--------|------|-----------| +| `access_token` | 로그인 인증, API 및 WebSocket 인증 | 운영 설정에 따른 만료 시간(현재 운영 기준 30분) | +| `refresh_token` | 자동 로그인 유지, Access Token 재발급 | 14일 | +| `_ga`, `_ga_*` | Google Analytics 이용자 구분 및 통계 분석 | 최대 2년 | +| `_gid` | Google Analytics 세션 구분 | 24시간 | + +이용자는 브라우저 설정에서 쿠키 저장을 거부할 수 있습니다. 다만 `access_token` 및 `refresh_token` 쿠키를 거부하면 로그인이 필요한 기능을 이용할 수 없습니다. + +Google Analytics 수집만 선택적으로 차단하려면 [Google Analytics 수집 거부 플러그인](https://tools.google.com/dlpage/gaoptout)을 사용할 수 있습니다. + +--- + +## 11. 맞춤형 광고 및 행태정보 + +서비스는 현재 자체 맞춤형 광고를 제공하지 않습니다. + +다만 Google Analytics 등 분석 도구를 통해 페이지뷰, 이벤트, 기기·브라우저 정보, 쿠키 기반 식별자 등 행태정보가 수집될 수 있으며, 이는 서비스 이용 통계 분석과 서비스 개선 목적으로 사용됩니다. + +향후 맞춤형 광고 또는 제3자 광고 도구를 도입하는 경우, 수집 항목, 이용 목적, 보유 기간, 거부 방법을 이 처리방침 또는 별도 안내를 통해 고지하고 필요한 경우 동의를 받겠습니다. + +--- + +## 12. 개인위치정보 및 연계정보 처리 + +서비스는 현재 「위치정보의 보호 및 이용 등에 관한 법률」상 개인위치정보를 지속적으로 수집·저장하거나 위치 이력을 관리하지 않습니다. + +서비스는 현재 본인확인기관을 통한 연계정보(Connecting Information, CI)를 수집·저장하지 않습니다. + +향후 개인위치정보 또는 CI를 처리하는 기능을 도입하는 경우, 관련 법령에 따라 별도 동의 절차와 처리방침을 마련하겠습니다. + +--- + +## 13. 처리방침의 변경 + +서비스는 법령, 서비스 기능, 개인정보 처리 방식의 변경에 따라 이 처리방침을 개정할 수 있습니다. + +처리방침을 변경하는 경우 시행 7일 전 서비스 공지 또는 정책 문서 갱신을 통해 안내합니다. 이용자의 권리에 중대한 영향을 미치는 변경은 30일 전에 고지합니다. + +| 버전 | 시행일 | +|------|--------| +| 1.0 | 2026년 6월 7일 | diff --git a/src/main/resources/agreements/terms-of-service.md b/src/main/resources/agreements/terms-of-service.md new file mode 100644 index 00000000..b45d128a --- /dev/null +++ b/src/main/resources/agreements/terms-of-service.md @@ -0,0 +1,177 @@ +# 이용약관 + +**시행일: 2026년 6월 8일** + +--- + +## 제1조 (목적) + +이 약관은 우때(이하 "서비스")가 제공하는 여행 계획 협업 서비스의 이용 조건과 절차, 이용자와 운영자 간의 권리·의무 및 책임 사항을 정함을 목적으로 합니다. + +서비스의 개인정보 처리에 관한 사항은 별도의 [개인정보 처리방침](https://uttae.com/policies/privacy)에 따릅니다. 서비스 이용 제한, 신고, 이의제기 등 세부 운영 기준은 [운영정책](https://uttae.com/policies/operation)에 따르며, 저작권 침해 신고와 게시중단 절차는 [저작권 정책](https://uttae.com/policies/copyright)에 따릅니다. 이용자는 이 약관과 관련 정책을 확인하고 동의한 뒤 서비스를 이용해야 합니다. + +--- + +## 제2조 (정의) + +이 약관에서 사용하는 용어의 정의는 다음과 같습니다. + +1. **서비스**: "우때" 또는 "실시간 협업 여행 플래너"라는 이름으로 제공되는 여행 계획 협업 웹 서비스 및 이에 부속하는 기능 일체 +2. **이용자**: Google 계정으로 로그인하거나, 이 약관에 따라 서비스를 이용하는 자 +3. **여행 방**: 이용자가 생성하거나 초대받아 참여하는 여행 계획 협업 공간으로, 서비스 화면에서 "여행 방", "새 여행 계획", "방" 등으로 표시될 수 있습니다. +4. **방장(HOST)**: 여행 방을 생성하거나 권한을 위임받은 이용자로, 서비스 화면에서 "방장" 또는 "HOST" 배지로 표시될 수 있습니다. 방장이 아닌 참여자는 일반 멤버입니다. +5. **AI 어시스턴트**: 여행 방 채팅에서 "WOORI", "@ai" 등으로 호출되며 여행 계획 수립을 보조하는 인공지능 기능 +6. **북마크**: 이용자가 여행 방에서 저장한 장소 정보로, 일정 작성과 여행 계획 협업에 활용됩니다. +7. **콘텐츠**: 이용자가 서비스 내에서 작성·등록·공유하는 채팅 메시지, 일정, 일차, 메모, 체류 시간, 장소 카드, 북마크, 프로필 정보 등 일체 +8. **외부 서비스**: Google, OpenAI 등 서비스 제공을 위해 연동되는 제3자 서비스 + +--- + +## 제3조 (약관의 효력 및 변경) + +1. 이 약관은 서비스 가입 시 동의함으로써 효력이 발생합니다. +2. 운영자는 약관을 변경할 경우 시행 7일 전 이용자가 제공한 이메일 또는 서비스 내 공지사항을 통해 안내합니다. 이용자의 권리에 중대한 영향을 미치는 변경은 30일 전에 이메일 또는 서비스 내 공지사항을 통해 고지합니다. +3. 변경된 약관의 시행일 이후에도 서비스를 계속 이용하면 변경 약관에 동의한 것으로 봅니다. 변경 약관에 동의하지 않는 이용자는 서비스 이용을 중단하고 탈퇴할 수 있습니다. + +--- + +## 제4조 (서비스의 제공) + +서비스는 다음 기능을 제공합니다. + +1. 여행 방 생성·관리, 초대 코드를 통한 멤버 초대 +2. 여행 일정 및 장소 협업 편집 (일정, 북마크, 지도 연동) +3. 여행 방 내 실시간 채팅 +4. AI 어시스턴트를 통한 여행 계획 수립 보조 +5. Google 장소 검색 및 이동 경로 정보 조회 + +--- + +## 제5조 (회원 가입) + +1. 서비스는 Google 계정을 통한 소셜 로그인으로만 가입할 수 있습니다. +2. 만 14세 미만은 서비스에 가입할 수 없습니다. +3. 타인의 Google 계정을 도용하거나 허위 정보로 가입하는 행위는 금지됩니다. +4. 이용자는 가입 및 서비스 이용 과정에서 정확하고 최신의 정보를 제공해야 합니다. + +--- + +## 제6조 (계정 관리) + +1. 이용자는 본인의 계정과 로그인 수단을 안전하게 관리할 책임이 있습니다. +2. 이용자는 본인 계정에서 발생하는 활동에 대해 책임을 집니다. 다만, 운영자의 고의 또는 중대한 과실로 발생한 경우는 제외합니다. +3. 계정의 무단 사용, 보안 침해, 제3자의 접근이 의심되는 경우 이용자는 지체 없이 team.uttae@gmail.com으로 알려야 합니다. +4. 이용자는 타인을 사칭하거나, 타인의 권리를 침해하거나, 불쾌감·혐오감을 유발할 수 있는 이름·프로필 정보를 사용할 수 없습니다. + +--- + +## 제7조 (회원 탈퇴 및 계정 삭제) + +1. 이용자는 언제든지 서비스 내 탈퇴 기능이 제공되는 경우 해당 기능을 통해 탈퇴할 수 있으며, 기능 제공 전에는 team.uttae@gmail.com을 통해 탈퇴를 요청할 수 있습니다. +2. 탈퇴 처리 시 회원의 계정 식별 정보는 삭제 또는 익명화되고 로그인 인증 정보는 무효화됩니다. 다만 여행 방 내 채팅, 일정, 북마크, 메모 등 공동 협업 데이터는 방 삭제 전까지 유지될 수 있으며, 법령에 따라 보관이 필요한 정보는 해당 기간 동안 보관 후 삭제합니다. +3. 탈퇴하는 이용자가 방장인 여행 방에 다른 멤버가 없는 경우, 해당 여행 방과 방 내 데이터는 함께 삭제됩니다. +4. 탈퇴하는 이용자가 방장인 여행 방에 다른 멤버가 있는 경우, 탈퇴 요청은 거절될 수 있습니다. 이 경우 방장은 다른 멤버에게 방장 권한을 위임한 뒤 다시 탈퇴를 요청해야 합니다. + +--- + +## 제8조 (이용자의 의무 및 금지 행위) + +이용자는 서비스를 이용함에 있어 다음 행위를 하여서는 안 됩니다. + +1. 타인의 명예를 훼손하거나 개인정보를 무단으로 수집·유포하는 행위 +2. 음란물, 폭력, 혐오 표현, 차별적 언행, 괴롭힘, 위협 등 불법·유해한 콘텐츠를 게시하는 행위 +3. 허위 장소 정보, 허위 후기, 오해를 유발하는 일정·메모 등 다른 이용자의 여행 계획을 방해할 수 있는 정보를 고의로 게시하는 행위 +4. 미성년자를 대상으로 위해를 가하거나 부적절한 콘텐츠에 노출시키는 행위 +5. 채팅 도배, 광고성 메시지, 스팸, 피싱, 악성 링크 전송 행위 +6. 자동화 프로그램(봇), 스크래퍼, 크롤러 등을 사용하여 운영자의 허가 없이 서비스 또는 콘텐츠에 접근·수집·복제하는 행위 +7. 서비스의 서버, 네트워크, 보안 기능을 방해하거나 과도한 부하를 유발하는 행위 +8. 바이러스, 악성 코드, 비정상 요청 등 서비스 또는 다른 이용자의 기기에 해를 줄 수 있는 자료를 전송하는 행위 +9. 타인의 계정으로 로그인하거나 초대 코드를 무단으로 수집·배포하는 행위 +10. 서비스를 역분석하거나, 비공개 API를 무단 호출하거나, 운영자가 제공하지 않는 방식으로 서비스에 접근하는 행위 +11. 운영자 또는 제3자의 지식재산권, 초상권, 개인정보, 기타 권리를 침해하는 행위 +12. 관련 법령을 위반하거나 서비스의 정상적인 운영을 방해하는 일체의 행위 + +--- + +## 제9조 (유해 콘텐츠 신고 및 조치) + +1. 이용자는 불법·유해 콘텐츠, 권리 침해 콘텐츠, 서비스 운영을 방해하는 콘텐츠를 발견한 경우 team.uttae@gmail.com으로 신고할 수 있습니다. +2. 운영자는 신고된 콘텐츠 또는 운영 과정에서 발견한 콘텐츠가 이 약관 또는 관련 법령을 위반한다고 판단하는 경우 해당 콘텐츠를 숨김·삭제하거나 작성자의 서비스 이용을 제한할 수 있습니다. +3. 저작권 등 권리 침해 신고, 게시중단, 재게시 요청에 관한 세부 절차는 [저작권 정책](https://uttae.com/policies/copyright)에 따릅니다. +4. 운영자는 위반 여부 확인을 위해 필요한 범위에서 콘텐츠를 검토할 수 있으나, 모든 콘텐츠를 사전에 검토할 의무를 부담하지 않습니다. + +--- + +## 제10조 (서비스 이용 제한) + +1. 운영자는 이용자가 제8조 또는 제9조를 위반한 경우 사전 통보 없이 서비스 이용을 제한하거나 계정을 삭제할 수 있습니다. +2. 운영자는 위반 행위의 내용, 반복 여부, 피해 규모, 긴급성을 고려하여 게시물 삭제, 채팅 제한, 여행 방 접근 제한, 계정 정지 또는 계정 삭제 조치를 할 수 있습니다. 세부 기준은 [운영정책](https://uttae.com/policies/operation)에 따릅니다. +3. 이용 제한 처분에 이의가 있는 경우 team.uttae@gmail.com으로 이의를 신청할 수 있으며, 운영자는 7일 이내에 처리 결과를 안내합니다. + +--- + +## 제11조 (콘텐츠에 대한 권리) + +1. 이용자가 서비스 내에서 작성·등록한 콘텐츠의 저작권은 해당 이용자에게 있습니다. +2. 이용자는 서비스 운영, 저장, 백업, 공유, 실시간 동기화, AI 어시스턴트 응답 생성 등 기능 제공에 필요한 범위에서 운영자가 콘텐츠를 이용할 수 있도록 비독점적·무상의 사용권을 부여합니다. +3. 이용자는 본인이 작성·등록한 콘텐츠에 대해 필요한 권리를 보유하고 있으며, 해당 콘텐츠가 제3자의 권리 또는 관련 법령을 침해하지 않음을 보증합니다. +4. 이용자가 작성·등록한 콘텐츠가 제3자의 권리를 침해한다는 신고 또는 이의제기가 있는 경우, 운영자는 관련 법령과 [저작권 정책](https://uttae.com/policies/copyright)에 따라 게시중단, 삭제, 접근 제한, 서비스 이용 제한 등 필요한 조치를 할 수 있습니다. +5. 운영자는 이용자의 콘텐츠를 광고·마케팅 목적으로 사용하지 않습니다. +6. 운영자가 제공하는 서비스 화면, 기능, 로고, 디자인, 데이터베이스, 프로그램 등 서비스 자체의 권리는 운영자 또는 정당한 권리자에게 귀속됩니다. + +--- + +## 제12조 (AI 어시스턴트 이용 안내) + +1. AI 어시스턴트의 응답은 참고 목적으로만 제공되며, 정확성·완전성을 보장하지 않습니다. +2. AI 어시스턴트가 추천하는 장소, 일정, 경로 정보는 실제와 다를 수 있으므로, 중요한 사항은 반드시 공식 정보를 직접 확인하시기 바랍니다. +3. AI 어시스턴트에게 메시지를 전송하면 채팅 내용이 외부 AI 서비스(OpenAI)로 전달됩니다. 민감한 개인정보는 AI 어시스턴트에게 입력하지 않도록 주의하시기 바랍니다. +4. 이용자는 AI 어시스턴트의 응답을 여행 예약, 결제, 안전, 법률, 의료 등 중요한 의사결정의 유일한 근거로 사용해서는 안 됩니다. + +--- + +## 제13조 (외부 서비스 및 외부 정보에 관한 고지) + +1. 서비스 내 장소 정보(영업시간, 평점, 주소, 사진 등)는 Google 등 외부 서비스가 제공하는 데이터로, 운영자가 정확성을 보장하지 않습니다. 방문 전 해당 장소에 직접 확인하시기 바랍니다. +2. 서비스의 장소 검색, 지도, 장소 사진, 이동 경로 등 Google Maps 기능 및 콘텐츠를 이용하는 경우, 이용자에게 Google Maps/Google Earth 추가 서비스 약관([https://maps.google.com/help/terms_maps/](https://maps.google.com/help/terms_maps/)) 및 Google 개인정보처리방침([https://policies.google.com/privacy](https://policies.google.com/privacy))이 적용됩니다. +3. 서비스는 외부 웹사이트 또는 외부 서비스로 연결되는 링크를 포함할 수 있습니다. 운영자는 외부 웹사이트 또는 외부 서비스의 내용, 정책, 이용 가능성, 안전성을 보증하지 않습니다. +4. 외부 서비스 이용에는 해당 외부 서비스의 약관과 정책이 적용될 수 있습니다. + +--- + +## 제14조 (오류 제보 및 피드백) + +1. 이용자는 서비스 오류, 개선 제안, 아이디어, 불만 사항 등을 team.uttae@gmail.com으로 전달할 수 있습니다. +2. 이용자가 피드백을 제공하는 경우, 운영자는 별도의 보상 없이 서비스 개선, 문제 해결, 기능 개발 목적으로 해당 피드백을 이용할 수 있습니다. +3. 피드백에 개인정보나 제3자의 비밀 정보가 포함되지 않도록 주의해야 합니다. + +--- + +## 제15조 (서비스의 변경 및 중단) + +1. 운영자는 서비스 내용을 변경하거나 서비스를 중단할 수 있습니다. +2. 서비스를 전부 중단하는 경우 30일 전 이메일 또는 서비스 내 공지사항을 통해 안내합니다. 다만, 장애 대응, 보안 사고, 외부 서비스 장애, 긴급 점검 등 부득이한 경우 사후에 안내할 수 있습니다. +3. 서비스는 현재 무료로 제공되며, 운영자는 서비스 변경 또는 중단으로 인한 손해에 대해 관련 법령상 책임이 인정되는 경우를 제외하고 별도의 보상 의무를 지지 않습니다. + +--- + +## 제16조 (보증의 부인 및 책임의 제한) + +1. 서비스는 현재 상태와 제공 가능한 범위에서 제공됩니다. 운영자는 서비스가 항상 중단 없이 제공되거나, 모든 오류가 수정되거나, 모든 정보가 정확하다고 보증하지 않습니다. +2. 운영자는 천재지변, 서비스 장애, 네트워크 문제, 외부 서비스 장애, 이용자의 귀책 사유 등 운영자의 합리적 통제 범위를 벗어난 사유로 인한 서비스 중단 또는 손해에 대해 책임을 지지 않습니다. +3. 운영자는 이용자 간 또는 이용자와 제3자 간의 분쟁에 개입할 의무를 부담하지 않으며, 관련 법령상 책임이 인정되는 경우를 제외하고 이에 대한 책임을 지지 않습니다. +4. 운영자는 무료로 제공되는 서비스의 이용과 관련하여 고의 또는 중대한 과실이 없는 한 손해배상 책임을 지지 않습니다. + +--- + +## 제17조 (준거법 및 관할) + +1. 이 약관은 대한민국 법률에 따라 해석·적용됩니다. +2. 서비스 이용과 관련한 분쟁은 운영자와 이용자가 성실히 협의하여 해결합니다. +3. 협의로 해결되지 않는 경우 「민사소송법」에 따른 관할 법원을 전속 관할로 합니다. + +--- + +## 부칙 + +이 약관은 2026년 6월 8일부터 시행합니다. diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 42beb852..7a519b6a 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -94,6 +94,13 @@ management: include: health,prometheus,caches app: + agreements: + terms-of-service: + version: "1.0" + resource: "classpath:agreements/terms-of-service.md" + privacy-policy: + version: "1.0" + resource: "classpath:agreements/privacy-policy.md" executor: ai: concurrency-limit: 4 diff --git a/src/main/resources/db/migration/V1.9__add_user_agreements.sql b/src/main/resources/db/migration/V1.9__add_user_agreements.sql new file mode 100644 index 00000000..792b0a48 --- /dev/null +++ b/src/main/resources/db/migration/V1.9__add_user_agreements.sql @@ -0,0 +1,15 @@ +-- =========================================== +-- V1.9: users 약관 동의 버전 기록 +-- =========================================== + +ALTER TABLE users + ADD COLUMN tos_version VARCHAR(30) NOT NULL DEFAULT '1.0', + ADD COLUMN tos_accepted_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT TIMESTAMP WITH TIME ZONE '1970-01-01 00:00:00+00', + ADD COLUMN privacy_version VARCHAR(30) NOT NULL DEFAULT '1.0', + ADD COLUMN privacy_accepted_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT TIMESTAMP WITH TIME ZONE '1970-01-01 00:00:00+00'; + +ALTER TABLE users + ALTER COLUMN tos_version DROP DEFAULT, + ALTER COLUMN tos_accepted_at DROP DEFAULT, + ALTER COLUMN privacy_version DROP DEFAULT, + ALTER COLUMN privacy_accepted_at DROP DEFAULT; diff --git a/src/test/java/com/howaboutus/backend/agreements/controller/AgreementControllerTest.java b/src/test/java/com/howaboutus/backend/agreements/controller/AgreementControllerTest.java new file mode 100644 index 00000000..17c91441 --- /dev/null +++ b/src/test/java/com/howaboutus/backend/agreements/controller/AgreementControllerTest.java @@ -0,0 +1,55 @@ +package com.howaboutus.backend.agreements.controller; + +import static org.mockito.BDDMockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import com.howaboutus.backend.agreements.service.AgreementService; +import com.howaboutus.backend.agreements.service.dto.AgreementDocumentResult; +import com.howaboutus.backend.auth.filter.JwtAuthenticationFilter; +import com.howaboutus.backend.auth.service.JwtProvider; +import com.howaboutus.backend.common.config.SecurityConfig; +import com.howaboutus.backend.common.error.GlobalExceptionHandler; +import com.howaboutus.backend.common.security.JwtAuthenticationEntryPoint; + +@WebMvcTest(AgreementController.class) +@Import({SecurityConfig.class, JwtAuthenticationFilter.class, JwtAuthenticationEntryPoint.class, + GlobalExceptionHandler.class}) +class AgreementControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private AgreementService agreementService; + + @MockitoBean + private JwtProvider jwtProvider; + + @Test + @DisplayName("비인증 상태에서 현재 약관을 조회한다") + void returnsCurrentAgreementsWithoutAuthentication() throws Exception { + given(agreementService.getCurrentAgreements()).willReturn(List.of( + new AgreementDocumentResult("TERMS_OF_SERVICE", "이용약관", "1.0", "MARKDOWN", "# 이용약관"), + new AgreementDocumentResult("PRIVACY_POLICY", "개인정보 처리방침", "1.0", "MARKDOWN", "# 개인정보") + )); + + mockMvc.perform(get("/api/agreements/current")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.items[0].type").value("TERMS_OF_SERVICE")) + .andExpect(jsonPath("$.items[0].version").value("1.0")) + .andExpect(jsonPath("$.items[0].contentFormat").value("MARKDOWN")) + .andExpect(jsonPath("$.items[0].content").value("# 이용약관")) + .andExpect(jsonPath("$.items[1].type").value("PRIVACY_POLICY")); + } +} diff --git a/src/test/java/com/howaboutus/backend/agreements/service/AgreementServiceTest.java b/src/test/java/com/howaboutus/backend/agreements/service/AgreementServiceTest.java new file mode 100644 index 00000000..16a479f5 --- /dev/null +++ b/src/test/java/com/howaboutus/backend/agreements/service/AgreementServiceTest.java @@ -0,0 +1,125 @@ +package com.howaboutus.backend.agreements.service; + +import static org.assertj.core.api.Assertions.*; + +import java.io.IOException; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; + +import com.howaboutus.backend.agreements.config.AgreementProperties; +import com.howaboutus.backend.common.error.CustomException; +import com.howaboutus.backend.common.error.ErrorCode; + +class AgreementServiceTest { + + @Test + @DisplayName("설정된 현재 이용약관과 개인정보 처리방침을 Markdown으로 반환한다") + void returnsCurrentAgreements() { + AgreementService service = new AgreementService(properties( + "1.0", "# 이용약관", + "1.0", "# 개인정보 처리방침" + )); + + var result = service.getCurrentAgreements(); + + assertThat(result).hasSize(2); + assertThat(result.get(0).type()).isEqualTo("TERMS_OF_SERVICE"); + assertThat(result.get(0).title()).isEqualTo("이용약관"); + assertThat(result.get(0).version()).isEqualTo("1.0"); + assertThat(result.get(0).contentFormat()).isEqualTo("MARKDOWN"); + assertThat(result.get(0).content()).isEqualTo("# 이용약관"); + assertThat(result.get(1).type()).isEqualTo("PRIVACY_POLICY"); + assertThat(result.get(1).title()).isEqualTo("개인정보 처리방침"); + assertThat(result.get(1).version()).isEqualTo("1.0"); + } + + @Test + @DisplayName("현재 이용약관 원문은 프론트 공개 정책 URL을 사용한다") + void termsOfServiceUsesPublicPolicyUrls() { + AgreementService service = new AgreementService(new AgreementProperties( + new AgreementProperties.AgreementDocument( + "1.0", new ClassPathResource("agreements/terms-of-service.md")), + new AgreementProperties.AgreementDocument( + "1.0", new ClassPathResource("agreements/privacy-policy.md")) + )); + + var result = service.getCurrentAgreements(); + + assertThat(result.get(0).content()) + .doesNotContain("(privacy-policy.md)") + .doesNotContain("(operation-policy.md)") + .doesNotContain("(copyright-policy.md)") + .contains("(https://uttae.com/policies/privacy)") + .contains("(https://uttae.com/policies/operation)") + .contains("(https://uttae.com/policies/copyright)"); + } + + @Test + @DisplayName("약관 버전 설정이 비어 있으면 AGREEMENT_CONFIGURATION_INVALID 예외") + void throwsForBlankVersion() { + AgreementService service = new AgreementService(properties( + "", "# 이용약관", + "1.0", "# 개인정보 처리방침" + )); + + assertThatThrownBy(service::getCurrentAgreements) + .isInstanceOf(CustomException.class) + .satisfies(e -> assertThat(((CustomException)e).getErrorCode()) + .isEqualTo(ErrorCode.AGREEMENT_CONFIGURATION_INVALID)); + } + + @Test + @DisplayName("약관 리소스 설정이 없으면 AGREEMENT_CONFIGURATION_INVALID 예외") + void throwsForMissingResource() { + AgreementService service = new AgreementService(new AgreementProperties( + new AgreementProperties.AgreementDocument("1.0", null), + new AgreementProperties.AgreementDocument( + "1.0", new ByteArrayResource("# 개인정보 처리방침".getBytes(StandardCharsets.UTF_8))) + )); + + assertThatThrownBy(service::getCurrentAgreements) + .isInstanceOf(CustomException.class) + .satisfies(e -> assertThat(((CustomException)e).getErrorCode()) + .isEqualTo(ErrorCode.AGREEMENT_CONFIGURATION_INVALID)); + } + + @Test + @DisplayName("약관 리소스 읽기에 실패하면 AGREEMENT_CONFIGURATION_INVALID 예외") + void throwsForUnreadableResource() { + AgreementService service = new AgreementService(new AgreementProperties( + new AgreementProperties.AgreementDocument("1.0", unreadableResource()), + new AgreementProperties.AgreementDocument( + "1.0", new ByteArrayResource("# 개인정보 처리방침".getBytes(StandardCharsets.UTF_8))) + )); + + assertThatThrownBy(service::getCurrentAgreements) + .isInstanceOf(CustomException.class) + .satisfies(e -> assertThat(((CustomException)e).getErrorCode()) + .isEqualTo(ErrorCode.AGREEMENT_CONFIGURATION_INVALID)); + } + + private AgreementProperties properties(String tosVersion, String tosContent, + String privacyVersion, String privacyContent) { + return new AgreementProperties( + new AgreementProperties.AgreementDocument( + tosVersion, new ByteArrayResource(tosContent.getBytes(StandardCharsets.UTF_8))), + new AgreementProperties.AgreementDocument( + privacyVersion, new ByteArrayResource(privacyContent.getBytes(StandardCharsets.UTF_8))) + ); + } + + private Resource unreadableResource() { + return new ByteArrayResource("".getBytes(StandardCharsets.UTF_8)) { + @Override + public String getContentAsString(Charset charset) throws IOException { + throw new IOException("read failed"); + } + }; + } +} diff --git a/src/test/java/com/howaboutus/backend/auth/AuthIntegrationTest.java b/src/test/java/com/howaboutus/backend/auth/AuthIntegrationTest.java index 1908cd7b..4f8282af 100644 --- a/src/test/java/com/howaboutus/backend/auth/AuthIntegrationTest.java +++ b/src/test/java/com/howaboutus/backend/auth/AuthIntegrationTest.java @@ -38,7 +38,7 @@ void loginIssuesTokenCookies() throws Exception { mockMvc.perform(post("/auth/google/login") .contentType(MediaType.APPLICATION_JSON) .content(""" - {"code": "auth-code-login"} + {"code": "auth-code-login", "agreementsAccepted": true} """)) .andExpect(status().isOk()) .andExpect(cookie().exists("access_token")) @@ -56,7 +56,7 @@ void refreshRotatesTokenAndRejectsOld() throws Exception { MvcResult loginResult = mockMvc.perform(post("/auth/google/login") .contentType(MediaType.APPLICATION_JSON) .content(""" - {"code": "auth-code-rotate"} + {"code": "auth-code-rotate", "agreementsAccepted": true} """)) .andExpect(status().isOk()) .andReturn(); @@ -89,7 +89,7 @@ void logoutInvalidatesRefreshToken() throws Exception { MvcResult loginResult = mockMvc.perform(post("/auth/google/login") .contentType(MediaType.APPLICATION_JSON) .content(""" - {"code": "auth-code-logout"} + {"code": "auth-code-logout", "agreementsAccepted": true} """)) .andExpect(status().isOk()) .andReturn(); diff --git a/src/test/java/com/howaboutus/backend/auth/controller/AuthControllerTest.java b/src/test/java/com/howaboutus/backend/auth/controller/AuthControllerTest.java index 27525b78..5a406723 100644 --- a/src/test/java/com/howaboutus/backend/auth/controller/AuthControllerTest.java +++ b/src/test/java/com/howaboutus/backend/auth/controller/AuthControllerTest.java @@ -55,7 +55,7 @@ class AuthControllerTest { @Test @DisplayName("Google 로그인 성공 시 access_token과 refresh_token 쿠키를 반환한다") void returnsAccessAndRefreshTokenCookiesOnLogin() throws Exception { - given(authService.googleLogin("valid-code")) + given(authService.googleLogin("valid-code", true)) .willReturn(new LoginResult("jwt-token", "1:refresh-uuid", 1L)); given(jwtProperties.accessTokenExpiration()).willReturn(1800000L); given(refreshTokenProperties.expiration()).willReturn(1209600000L); @@ -63,7 +63,7 @@ void returnsAccessAndRefreshTokenCookiesOnLogin() throws Exception { mockMvc.perform(post("/auth/google/login") .contentType(MediaType.APPLICATION_JSON) .content(""" - {"code": "valid-code"} + {"code": "valid-code", "agreementsAccepted": true} """)) .andExpect(status().isOk()) .andExpect(cookie().exists("access_token")) @@ -77,13 +77,13 @@ void returnsAccessAndRefreshTokenCookiesOnLogin() throws Exception { @Test @DisplayName("Google 인증 실패 시 401을 반환한다") void returns401WhenGoogleAuthFails() throws Exception { - given(authService.googleLogin("bad-code")) + given(authService.googleLogin("bad-code", true)) .willThrow(new CustomException(ErrorCode.GOOGLE_AUTH_FAILED)); mockMvc.perform(post("/auth/google/login") .contentType(MediaType.APPLICATION_JSON) .content(""" - {"code": "bad-code"} + {"code": "bad-code", "agreementsAccepted": true} """)) .andExpect(status().isUnauthorized()); } diff --git a/src/test/java/com/howaboutus/backend/auth/service/AuthServiceTest.java b/src/test/java/com/howaboutus/backend/auth/service/AuthServiceTest.java index 77610292..da9b3d2e 100644 --- a/src/test/java/com/howaboutus/backend/auth/service/AuthServiceTest.java +++ b/src/test/java/com/howaboutus/backend/auth/service/AuthServiceTest.java @@ -5,6 +5,10 @@ import static org.mockito.BDDMockito.*; import static org.mockito.Mockito.*; +import java.time.Clock; +import java.time.Instant; +import java.util.Optional; + import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -12,6 +16,8 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import com.howaboutus.backend.agreements.service.AgreementService; +import com.howaboutus.backend.agreements.service.dto.AgreementVersions; import com.howaboutus.backend.auth.service.dto.GoogleUserInfo; import com.howaboutus.backend.auth.service.dto.LoginResult; import com.howaboutus.backend.auth.service.dto.RotateResult; @@ -39,22 +45,49 @@ class AuthServiceTest { @Mock private RefreshTokenService refreshTokenService; + @Mock + private AgreementService agreementService; + + @Mock + private Clock clock; + @Test @DisplayName("신규 사용자 로그인 시 회원가입 후 Access Token과 Refresh Token을 발급한다") void registersNewUserAndReturnsTokens() { GoogleUserInfo userInfo = new GoogleUserInfo("google-123", "test@gmail.com", "테스트", null); User mockUser = User.ofGoogle("google-123", "test@gmail.com", "테스트", null); + AgreementVersions versions = new AgreementVersions("1.0", "1.0"); + Instant now = Instant.parse("2026-06-08T10:00:00Z"); given(googleOAuthClient.login("auth-code")).willReturn(userInfo); - given(userService.getOrCreateGoogleUser("google-123", "test@gmail.com", "테스트", null)).willReturn(mockUser); + given(userService.findGoogleUser("google-123")).willReturn(Optional.empty()); + given(agreementService.currentVersions()).willReturn(versions); + given(clock.instant()).willReturn(now); + given(userService.getOrCreateGoogleUser("google-123", "test@gmail.com", "테스트", null, versions, now)) + .willReturn(mockUser); given(jwtProvider.generateAccessToken(any())).willReturn("jwt-token"); given(refreshTokenService.create(any())).willReturn("1:refresh-uuid"); - LoginResult result = authService.googleLogin("auth-code"); + LoginResult result = authService.googleLogin("auth-code", true); assertThat(result.accessToken()).isEqualTo("jwt-token"); assertThat(result.refreshToken()).isEqualTo("1:refresh-uuid"); } + @Test + @DisplayName("신규 사용자가 약관에 동의하지 않으면 가입과 토큰 발급을 거부한다") + void rejectsNewUserWithoutAgreementAcceptance() { + GoogleUserInfo userInfo = new GoogleUserInfo("google-123", "test@gmail.com", "테스트", null); + given(googleOAuthClient.login("auth-code")).willReturn(userInfo); + given(userService.findGoogleUser("google-123")).willReturn(Optional.empty()); + willThrow(new CustomException(ErrorCode.AGREEMENTS_NOT_ACCEPTED)) + .given(agreementService).validateAccepted(false); + + assertThatThrownBy(() -> authService.googleLogin("auth-code", false)) + .isInstanceOf(CustomException.class) + .satisfies(e -> assertThat(((CustomException)e).getErrorCode()) + .isEqualTo(ErrorCode.AGREEMENTS_NOT_ACCEPTED)); + } + @Test @DisplayName("기존 사용자 로그인 시 조회 후 Access Token과 Refresh Token을 발급한다") void returnsTokensForExistingUser() { @@ -62,13 +95,49 @@ void returnsTokensForExistingUser() { User existingUser = User.ofGoogle("google-123", "test@gmail.com", "테스트", null); given(googleOAuthClient.login("auth-code")).willReturn(userInfo); - given(userService.getOrCreateGoogleUser("google-123", "test@gmail.com", "테스트", null)) - .willReturn(existingUser); + given(userService.findGoogleUser("google-123")).willReturn(Optional.of(existingUser)); + given(jwtProvider.generateAccessToken(any())).willReturn("jwt-token"); + given(refreshTokenService.create(any())).willReturn("1:refresh-uuid"); + + LoginResult result = authService.googleLogin("auth-code", true); + + assertThat(result.accessToken()).isEqualTo("jwt-token"); + assertThat(result.refreshToken()).isEqualTo("1:refresh-uuid"); + } + + @Test + @DisplayName("기존 사용자의 약관 버전이 오래됐고 동의하지 않으면 재동의를 요구한다") + void rejectsExistingUserWithStaleAgreementsWithoutAcceptance() { + GoogleUserInfo userInfo = new GoogleUserInfo("google-123", "test@gmail.com", "테스트", null); + User existingUser = User.ofGoogle("google-123", "test@gmail.com", "테스트", null); + given(googleOAuthClient.login("auth-code")).willReturn(userInfo); + given(userService.findGoogleUser("google-123")).willReturn(Optional.of(existingUser)); + given(agreementService.needsReacceptance(existingUser)).willReturn(true); + + assertThatThrownBy(() -> authService.googleLogin("auth-code", false)) + .isInstanceOf(CustomException.class) + .satisfies(e -> assertThat(((CustomException)e).getErrorCode()) + .isEqualTo(ErrorCode.AGREEMENTS_REACCEPTANCE_REQUIRED)); + } + + @Test + @DisplayName("기존 사용자의 약관 버전이 오래됐고 동의하면 갱신 후 토큰을 발급한다") + void updatesStaleAgreementsAndReturnsTokens() { + GoogleUserInfo userInfo = new GoogleUserInfo("google-123", "test@gmail.com", "테스트", null); + User existingUser = User.ofGoogle("google-123", "test@gmail.com", "테스트", null); + AgreementVersions versions = new AgreementVersions("1.1", "1.0"); + Instant now = Instant.parse("2026-06-08T10:00:00Z"); + given(googleOAuthClient.login("auth-code")).willReturn(userInfo); + given(userService.findGoogleUser("google-123")).willReturn(Optional.of(existingUser)); + given(agreementService.needsReacceptance(existingUser)).willReturn(true); + given(agreementService.currentVersions()).willReturn(versions); + given(clock.instant()).willReturn(now); given(jwtProvider.generateAccessToken(any())).willReturn("jwt-token"); given(refreshTokenService.create(any())).willReturn("1:refresh-uuid"); - LoginResult result = authService.googleLogin("auth-code"); + LoginResult result = authService.googleLogin("auth-code", true); + verify(userService).acceptCurrentAgreements(existingUser.getId(), versions, now); assertThat(result.accessToken()).isEqualTo("jwt-token"); assertThat(result.refreshToken()).isEqualTo("1:refresh-uuid"); } diff --git a/src/test/java/com/howaboutus/backend/user/controller/UserAgreementControllerTest.java b/src/test/java/com/howaboutus/backend/user/controller/UserAgreementControllerTest.java new file mode 100644 index 00000000..4d96351b --- /dev/null +++ b/src/test/java/com/howaboutus/backend/user/controller/UserAgreementControllerTest.java @@ -0,0 +1,120 @@ +package com.howaboutus.backend.user.controller; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import com.howaboutus.backend.agreements.service.AgreementService; +import com.howaboutus.backend.agreements.service.dto.AgreementVersions; +import com.howaboutus.backend.auth.filter.JwtAuthenticationFilter; +import com.howaboutus.backend.auth.service.JwtProvider; +import com.howaboutus.backend.common.config.SecurityConfig; +import com.howaboutus.backend.common.error.CustomException; +import com.howaboutus.backend.common.error.ErrorCode; +import com.howaboutus.backend.common.error.GlobalExceptionHandler; +import com.howaboutus.backend.common.security.JwtAuthenticationEntryPoint; +import com.howaboutus.backend.user.service.UserService; + +import jakarta.servlet.http.Cookie; + +@WebMvcTest(UserAgreementController.class) +@Import({SecurityConfig.class, JwtAuthenticationFilter.class, JwtAuthenticationEntryPoint.class, + GlobalExceptionHandler.class}) +class UserAgreementControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private UserService userService; + + @MockitoBean + private AgreementService agreementService; + + @MockitoBean + private JwtProvider jwtProvider; + + @MockitoBean + private Clock clock; + + @Test + @DisplayName("인증된 사용자가 현재 약관에 재동의한다") + void acceptsCurrentAgreementsForAuthenticatedUser() throws Exception { + given(jwtProvider.extractUserId("valid-jwt")).willReturn(1L); + given(agreementService.currentVersions()).willReturn(new AgreementVersions("1.0", "1.0")); + given(clock.instant()).willReturn(Instant.parse("2026-06-08T10:00:00Z")); + given(clock.getZone()).willReturn(ZoneOffset.UTC); + + mockMvc.perform(post("/api/users/me/agreements") + .cookie(new Cookie("access_token", "valid-jwt")) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"agreementsAccepted": true} + """)) + .andExpect(status().isNoContent()); + + verify(userService).acceptCurrentAgreements(eq(1L), any(), any()); + } + + @Test + @DisplayName("재동의 요청에서 agreementsAccepted=false이면 400을 반환한다") + void returns400WhenAgreementsNotAccepted() throws Exception { + given(jwtProvider.extractUserId("valid-jwt")).willReturn(1L); + willThrow(new CustomException(ErrorCode.AGREEMENTS_NOT_ACCEPTED)) + .given(agreementService).validateAccepted(false); + + mockMvc.perform(post("/api/users/me/agreements") + .cookie(new Cookie("access_token", "valid-jwt")) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"agreementsAccepted": false} + """)) + .andExpect(status().isBadRequest()); + + verify(userService, never()).acceptCurrentAgreements(any(), any(), any()); + } + + @Test + @DisplayName("재동의 요청에서 agreementsAccepted가 누락되면 400을 반환한다") + void returns400WhenAgreementsAcceptedMissing() throws Exception { + given(jwtProvider.extractUserId("valid-jwt")).willReturn(1L); + willThrow(new CustomException(ErrorCode.AGREEMENTS_NOT_ACCEPTED)) + .given(agreementService).validateAccepted(null); + + mockMvc.perform(post("/api/users/me/agreements") + .cookie(new Cookie("access_token", "valid-jwt")) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {} + """)) + .andExpect(status().isBadRequest()); + + verify(userService, never()).acceptCurrentAgreements(any(), any(), any()); + } + + @Test + @DisplayName("인증 없이 현재 약관 재동의 요청 시 401을 반환한다") + void returns401WithoutAuthentication() throws Exception { + mockMvc.perform(post("/api/users/me/agreements") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"agreementsAccepted": true} + """)) + .andExpect(status().isUnauthorized()); + } +} diff --git a/src/test/java/com/howaboutus/backend/user/service/UserServiceTest.java b/src/test/java/com/howaboutus/backend/user/service/UserServiceTest.java index db2f1c00..a0703f95 100644 --- a/src/test/java/com/howaboutus/backend/user/service/UserServiceTest.java +++ b/src/test/java/com/howaboutus/backend/user/service/UserServiceTest.java @@ -4,6 +4,7 @@ import static org.mockito.ArgumentMatchers.*; import static org.mockito.BDDMockito.*; +import java.time.Instant; import java.util.List; import java.util.Optional; @@ -14,6 +15,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import com.howaboutus.backend.agreements.service.dto.AgreementVersions; import com.howaboutus.backend.common.error.CustomException; import com.howaboutus.backend.common.error.ErrorCode; import com.howaboutus.backend.user.entity.User; @@ -115,4 +117,39 @@ void getOrCreateGoogleUserCreatesNewUser() { assertThat(result).isSameAs(user); } + + @Test + @DisplayName("신규 구글 유저 생성 시 현재 약관 버전과 동의 시각을 저장한다") + void getOrCreateGoogleUserCreatesUserWithAgreements() { + Instant acceptedAt = Instant.parse("2026-06-08T10:00:00Z"); + AgreementVersions versions = new AgreementVersions("1.0", "1.0"); + given(userRepository.findByProviderAndProviderId("GOOGLE", "provider-1")) + .willReturn(Optional.empty()); + given(userRepository.save(any(User.class))).willAnswer(invocation -> invocation.getArgument(0)); + + User result = userService.getOrCreateGoogleUser( + "provider-1", "test@gmail.com", "닉네임", "https://img.url/photo.jpg", versions, acceptedAt); + + assertThat(result.getTosVersion()).isEqualTo("1.0"); + assertThat(result.getTosAcceptedAt()).isEqualTo(acceptedAt); + assertThat(result.getPrivacyVersion()).isEqualTo("1.0"); + assertThat(result.getPrivacyAcceptedAt()).isEqualTo(acceptedAt); + } + + @Test + @DisplayName("현재 사용자 약관 동의를 서버 버전과 서버 시간으로 갱신한다") + void acceptCurrentAgreementsUpdatesUser() { + User user = User.ofGoogle( + "provider-1", "test@gmail.com", "닉네임", null, + new AgreementVersions("1.0", "1.0"), Instant.parse("2026-06-01T10:00:00Z")); + Instant acceptedAt = Instant.parse("2026-06-08T10:00:00Z"); + given(userRepository.findById(1L)).willReturn(Optional.of(user)); + + userService.acceptCurrentAgreements(1L, new AgreementVersions("1.1", "1.0"), acceptedAt); + + assertThat(user.getTosVersion()).isEqualTo("1.1"); + assertThat(user.getTosAcceptedAt()).isEqualTo(acceptedAt); + assertThat(user.getPrivacyVersion()).isEqualTo("1.0"); + assertThat(user.getPrivacyAcceptedAt()).isEqualTo(acceptedAt); + } } From beef4a23b5448b48f32fa842940311c7acbc4031 Mon Sep 17 00:00:00 2001 From: PARK JU YEONG <96644508+parkjuyeong0312@users.noreply.github.com> Date: Mon, 8 Jun 2026 15:50:05 +0900 Subject: [PATCH 6/9] =?UTF-8?q?chore:=20create-issue=20=EC=8A=A4=ED=82=AC?= =?UTF-8?q?=EC=9D=98=20repo=20=EA=B2=BD=EB=A1=9C=EB=A5=BC=20uttae=20?= =?UTF-8?q?=EC=A1=B0=EC=A7=81=EC=9C=BC=EB=A1=9C=20=EA=B0=B1=EC=8B=A0=20(#1?= =?UTF-8?q?36)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/skills/create-issue/SKILL.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/.claude/skills/create-issue/SKILL.md b/.claude/skills/create-issue/SKILL.md index eb8b4e24..025ce5a0 100644 --- a/.claude/skills/create-issue/SKILL.md +++ b/.claude/skills/create-issue/SKILL.md @@ -13,9 +13,9 @@ GitHub 이슈를 대상 repo에 생성한다. **반드시 사용자 승인을 | 구분 | repo | |------|------| -| frontend | `how-about-us/frontend-server` | -| backend | `how-about-us/backend-server` | -| ai | `how-about-us/ai-server` | +| frontend | `uttae/frontend-server` | +| backend | `uttae/backend-server` | +| ai | `uttae/ai-server` | ## Workflow @@ -79,7 +79,7 @@ GitHub 이슈를 대상 repo에 생성한다. **반드시 사용자 승인을 ``` 이슈 초안입니다. 확인해주세요. -- Repo: how-about-us/xxx +- Repo: uttae/xxx - 라벨: enhancement, bug (해당하는 라벨 나열) - 제목: ... - 본문: @@ -116,4 +116,3 @@ EOF - 사용자가 "이슈 올려"라고 하면 바로 생성 → **초안 먼저** - repo를 추측해서 잘못된 곳에 생성 → **모호하면 물어보기** - 템플릿 없이 자유 형식으로 작성 → **항상 템플릿 사용** - From 8db5a9b820f72b863c51d48be1464d7b7dbb40b9 Mon Sep 17 00:00:00 2001 From: PARK JU YEONG <96644508+parkjuyeong0312@users.noreply.github.com> Date: Mon, 8 Jun 2026 21:21:51 +0900 Subject: [PATCH 7/9] =?UTF-8?q?refactor:=20SYSTEM=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=80=20=ED=8E=98=EC=9D=B4=EB=A1=9C=EB=93=9C=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EA=B3=84=EC=95=BD=20=EC=A0=95=EB=A6=AC=20(#138)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: SYSTEM 메시지 MongoDB 페이로드 정리 spec 추가 SYSTEM 메시지에서 content·metadata.nickname·metadata.profileImageUrl을 BSON·JSON 키 자체로 남기지 않고, 표시 책임을 클라이언트로 일원화하는 refactor 설계안을 docs/superpowers/specs/에 작성한다. * docs: SYSTEM 메시지 MongoDB 페이로드 정리 plan 추가 * docs: SYSTEM 메시지 페이로드 정리 ADR 추가 및 기존 ADR 교체 표기 * refactor: SYSTEM 메시지 페이로드에서 표시 데이터 제거 및 식별자만 저장 * refactor: SYSTEM 메시지 응답에서 content 키 제외 및 @Schema 설명 갱신 * test: SYSTEM 메시지 BSON 페이로드 키 부재 통합 테스트 가드 * docs: SYSTEM 메시지 페이로드 정리 결정 features.md/spec 반영 * docs: SYSTEM 메시지 MongoDB 문서 계약 정합성 보정 * refactor: members topic SYSTEM payload content 키 제외 * test: SYSTEM 메시지 content 키 부재 검증 보강 * test: 시스템 메시지 페이로드 테스트 보강 --- ...-1636-system-message-metadata-rendering.md | 2 +- ...608-1553-system-message-payload-cleanup.md | 62 + docs/ai/erd.md | 4 +- docs/ai/features.md | 2 +- .../plans/2026-06-08-mongo-message-payload.md | 1019 +++++++++++++++++ ...2026-06-08-mongo-message-payload-design.md | 159 +++ .../controller/dto/MessageResponse.java | 22 +- .../messages/document/ChatMessage.java | 4 +- .../HostDelegatedMessageListener.java | 4 +- .../MemberApprovedMessageListener.java | 7 +- .../listener/MemberKickedMessageListener.java | 4 +- .../listener/MemberLeftMessageListener.java | 4 +- .../messages/service/MessageService.java | 33 +- .../service/SystemMessageService.java | 76 +- .../realtime/service/dto/MessagePayload.java | 22 +- .../service/dto/RoomMemberPayload.java | 18 +- .../ai/service/AiSummaryServiceTest.java | 2 +- .../controller/MessageControllerTest.java | 46 +- .../SystemMessagePayloadIntegrationTest.java | 135 +++ .../HostDelegatedMessageListenerTest.java | 3 +- .../MemberApprovedMessageListenerTest.java | 2 +- .../MemberKickedMessageListenerTest.java | 6 +- .../MemberLeftMessageListenerTest.java | 6 +- .../messages/service/MessageServiceTest.java | 23 +- .../service/SystemMessageServiceTest.java | 111 +- .../service/RoomMemberBroadcasterTest.java | 30 +- .../service/RoomMessageBroadcasterTest.java | 22 +- 27 files changed, 1632 insertions(+), 196 deletions(-) create mode 100644 docs/ai/decisions/20260608-1553-system-message-payload-cleanup.md create mode 100644 docs/superpowers/plans/2026-06-08-mongo-message-payload.md create mode 100644 docs/superpowers/specs/2026-06-08-mongo-message-payload-design.md create mode 100644 src/test/java/com/howaboutus/backend/messages/document/SystemMessagePayloadIntegrationTest.java diff --git a/docs/ai/decisions/20260607-1636-system-message-metadata-rendering.md b/docs/ai/decisions/20260607-1636-system-message-metadata-rendering.md index 69a34bc6..61d7e307 100644 --- a/docs/ai/decisions/20260607-1636-system-message-metadata-rendering.md +++ b/docs/ai/decisions/20260607-1636-system-message-metadata-rendering.md @@ -1,6 +1,6 @@ # SYSTEM 메시지는 metadata 기반 클라이언트 렌더링으로 통일 -- **상태**: 결정 +- **상태**: 교체됨 (→ [20260608-1553-system-message-payload-cleanup.md](20260608-1553-system-message-payload-cleanup.md)) - **날짜**: 2026-06-07 - **관련**: [20260607-user-withdrawal-soft-delete.md](20260607-user-withdrawal-soft-delete.md) diff --git a/docs/ai/decisions/20260608-1553-system-message-payload-cleanup.md b/docs/ai/decisions/20260608-1553-system-message-payload-cleanup.md new file mode 100644 index 00000000..f500010c --- /dev/null +++ b/docs/ai/decisions/20260608-1553-system-message-payload-cleanup.md @@ -0,0 +1,62 @@ +# SYSTEM 메시지 페이로드에서 표시 데이터 제거 + +- **상태**: 결정 +- **날짜**: 2026-06-08 +- **교체**: [20260607-1636-system-message-metadata-rendering.md](20260607-1636-system-message-metadata-rendering.md) + +## Status + +결정 + +## Context + +`20260607-1636-system-message-metadata-rendering.md`는 "백엔드 변경 없음, 클라이언트가 metadata 기반으로 재렌더링"이라는 보수적 결정을 내렸다. 그 결정에서 `content`와 `metadata.nickname`/`profileImageUrl`/`previousHostNickname`/`newHostNickname`은 legacy 클라이언트와 디버깅용 fallback으로 의도적으로 남겨졌다. + +이후 두 가지 전제가 추가로 확정되었다. + +1. MongoDB `messages` 컬렉션이 초기화 상태라 마이그레이션 비용이 없다. +2. 프론트엔드가 `messageType` + `metadata.eventType` + `userId` lookup 흐름으로 이미 구현되어 있다. + +전제 (1)로 fallback content 보존 동기가 사라졌고, 전제 (2)로 표시 책임을 클라이언트로 일원화할 수 있다. 굳은 표시 데이터를 저장하면 회원 탈퇴·닉네임 변경 후에도 stale 표시가 남는 본 문제의 근본 원인이 그대로 유지된다. + +## Decision + +SYSTEM 메시지의 MongoDB 문서·STOMP 페이로드·REST 응답에서 다음 키를 **저장하지 않고 응답에도 포함하지 않는다**. + +- `content` +- `metadata.nickname` +- `metadata.profileImageUrl` +- `metadata.previousHostNickname` +- `metadata.newHostNickname` + +대신 SYSTEM 메시지의 metadata는 다음 식별자만 포함한다. + +- `MEMBER_JOINED`/`MEMBER_LEFT`/`MEMBER_KICKED`: `{ eventType, userId }` +- `HOST_DELEGATED`: `{ eventType, previousHostUserId, newHostUserId }` + +클라이언트는 `messageType === "SYSTEM"`인 경우 metadata의 `eventType`과 userId 식별자를 기준으로 방 멤버 맵에서 닉네임/프로필을 lookup해 표시 문구를 조립한다. lookup 실패 시 `(알 수 없음)`로 표시한다. + +CHAT/AI_REQUEST/AI_RESPONSE/PLACE_SHARE 메시지의 저장 및 전송 구조는 변경하지 않는다. + +## Consequences + +**장점** +- 회원 탈퇴·닉네임 변경 후에도 SYSTEM 메시지가 stale 표시 데이터로 남지 않는다. 일반 채팅과 SYSTEM 메시지의 표시 일관성이 보장된다. +- MongoDB 문서·payload 크기 감소. +- 표시 문구 조립 책임이 클라이언트로 일원화되어 백엔드는 식별자만 책임진다. + +**단점/제약** +- legacy 클라이언트(아직 metadata 기반 분기를 구현하지 못한 버전)에서는 SYSTEM 메시지가 빈 줄로 보일 수 있다. 본 결정 배포는 프론트엔드 분기 구현 배포 이후로 합의한다. +- BSON 직렬화에서 null 필드 skip은 Spring Data MongoDB 기본 동작이지만, 향후 `MongoMappingContext` 설정이 변경되면 깨질 수 있다. 통합 테스트 가드로 회귀를 막는다. + +**후속 문서/코드 갱신** +- `docs/ai/features.md` 시스템 메시지 항목 +- `docs/superpowers/plans/2026-06-08-mongo-message-payload.md` (본 plan 진행) +- `MessagePayload`/`MessageResponse` `@Schema` 설명 + +## Related Docs + +- `docs/superpowers/specs/2026-06-08-mongo-message-payload-design.md` +- `docs/ai/features.md` +- `docs/ai/decisions/20260607-1636-system-message-metadata-rendering.md` (교체) +- `docs/ai/decisions/20260607-user-withdrawal-soft-delete.md` diff --git a/docs/ai/erd.md b/docs/ai/erd.md index 69a1eafe..fc1382f9 100644 --- a/docs/ai/erd.md +++ b/docs/ai/erd.md @@ -88,7 +88,7 @@ CHECK `ck_room_members_left_role` (status='ACTIVE' OR role <> 'PENDING') ## 4. messages (MongoDB 채팅 메시지 컬렉션) -실시간 채팅 메시지. 메시지별 metadata 구조가 달라질 수 있어 MongoDB 컬렉션으로 저장한다. 일반 채팅 요청은 클라이언트 metadata를 받지 않고 `metadata={}`로 저장하며, AI_REQUEST / AI_RESPONSE / PLACE_SHARE / SYSTEM 같은 타입별 확장 데이터는 서버가 정형 요청 또는 내부 이벤트에서 구성한다. AI_REQUEST는 사용자가 AI에게 보낸 질문이며 `metadata.aiStatus=QUEUED|PROCESSING|CANCELED`, `cancelable`, `canceledBy`(취소 시)를 담는다. AI_RESPONSE는 `senderId=NULL`인 AI 답변이다. PLACE_SHARE metadata는 장소 카드 표시용 스냅샷(`googlePlaceId`, `name`, `formattedAddress`, `latitude`, `longitude`, `rating`, `photoName`)을 담고, SYSTEM metadata는 이벤트 식별자와 대상 사용자 정보를 담는다. MongoDB `_id` 문자열을 클라이언트에 `id`로 노출하고 읽음 위치 복귀 cursor로 사용하며, 방별 `sequence`는 브로드캐스트 누락 감지와 복구 cursor로 사용한다. +실시간 채팅 메시지. 메시지별 metadata 구조가 달라질 수 있어 MongoDB 컬렉션으로 저장한다. 일반 채팅 요청은 클라이언트 metadata를 받지 않고 `metadata={}`로 저장하며, AI_REQUEST / AI_RESPONSE / PLACE_SHARE / SYSTEM 같은 타입별 확장 데이터는 서버가 정형 요청 또는 내부 이벤트에서 구성한다. AI_REQUEST는 사용자가 AI에게 보낸 질문이며 `metadata.aiStatus=QUEUED|PROCESSING|CANCELED`, `cancelable`, `canceledBy`(취소 시)를 담는다. AI_RESPONSE는 `senderId=NULL`인 AI 답변이다. PLACE_SHARE metadata는 장소 카드 표시용 스냅샷(`googlePlaceId`, `name`, `formattedAddress`, `latitude`, `longitude`, `rating`, `photoName`)을 담고, SYSTEM metadata는 이벤트 식별자와 대상 사용자 ID만 담는다. MongoDB `_id` 문자열을 클라이언트에 `id`로 노출하고 읽음 위치 복귀 cursor로 사용하며, 방별 `sequence`는 브로드캐스트 누락 감지와 복구 cursor로 사용한다. | 필드 | 타입 | 제약조건 | 설명 | |------|------|----------|------| @@ -97,7 +97,7 @@ CHECK `ck_room_members_left_role` (status='ACTIVE' OR role <> 'PENDING') | sequence | LONG | NOT NULL, UNIQUE(roomId, sequence) | 방 안에서 append-only로 증가하는 메시지 순번 | | senderId | BIGINT | NULLABLE | PostgreSQL users.id 논리 참조, NULL = 시스템/AI 메시지 | | messageType | STRING | NOT NULL, DEFAULT 'CHAT' | CHAT / AI_REQUEST / AI_RESPONSE / PLACE_SHARE / SYSTEM | -| content | STRING | NOT NULL | 메시지 내용 | +| content | STRING | NULLABLE | 메시지 내용. CHAT / AI_REQUEST / AI_RESPONSE / PLACE_SHARE에서 사용하며 SYSTEM 메시지는 content 키를 저장하지 않음 | | metadata | DOCUMENT | NOT NULL, DEFAULT `{}` | 장소 공유, AI 응답 등 메시지 타입별 확장 데이터 | | createdAt | DATE | NOT NULL | 생성일시 | diff --git a/docs/ai/features.md b/docs/ai/features.md index 95679066..89d8eb76 100644 --- a/docs/ai/features.md +++ b/docs/ai/features.md @@ -171,7 +171,7 @@ Caddy는 `caddy-ratelimit` 기반 IP 제한으로 엣지에서 넓고 거친 폭 | `[x]` | 재접속 시 미수신 메시지 동기화 | 클라이언트는 실시간 수신 중 마지막 수신 message `sequence` 이후 메시지를 `afterSequence`로 조회해 브로드캐스트 누락을 복구한다. `afterSequence`는 저장된 읽음 위치 복귀가 아니라 sequence gap 복구용이다 | MongoDB messages | | `[x]` | 읽음 위치 조회 | `GET /rooms/{roomId}/messages/read-status`로 현재 사용자의 `lastReadMessageId`를 조회한다. 채팅 초기 진입/재접속 시 이 값을 `afterId`에 전달해 저장된 읽음 위치 이후 메시지를 조회한다. 값이 없으면 cursor 없이 최근 메시지를 조회한다 | room_members | | `[x]` | 장소 카드 메시지 전송 | 클라이언트가 `/app/rooms/{roomId}/messages/place`로 장소 스냅샷을 전송하면 MongoDB `messages` 컬렉션에 `messageType=PLACE_SHARE`로 저장 후 `/topic/rooms/{roomId}/messages`로 브로드캐스트 | MongoDB messages | -| `[x]` | 시스템 메시지 | 입장 승인 같은 멤버십 변경 이벤트를 `messageType=SYSTEM`, `senderId=NULL` 메시지로 저장 후 `/topic/rooms/{roomId}/messages`와 `/topic/rooms/{roomId}/members` 양쪽으로 브로드캐스트. WebSocket 접속/해제 presence 이벤트는 채팅 히스토리에 저장하지 않음 | MongoDB messages | +| `[x]` | 시스템 메시지 | 입장 승인 같은 멤버십 변경 이벤트를 `messageType=SYSTEM`, `senderId=NULL` 메시지로 저장 후 `/topic/rooms/{roomId}/messages`와 `/topic/rooms/{roomId}/members` 양쪽으로 브로드캐스트. MongoDB 문서와 STOMP/REST 응답 모두에서 `content` 키는 포함되지 않으며, metadata는 식별자만 가진다. `MEMBER_JOINED`/`MEMBER_LEFT`/`MEMBER_KICKED`는 `{ eventType, userId }`, `HOST_DELEGATED`는 `{ eventType, previousHostUserId, newHostUserId }`. 표시 문구는 클라이언트가 `eventType`과 userId를 멤버 맵에서 lookup해 조립한다. WebSocket 접속/해제 presence 이벤트는 채팅 히스토리에 저장하지 않음 | MongoDB messages | | `[x]` | 읽음 처리 (WS) | 클라이언트가 `/app/rooms/{roomId}/messages/read`로 `lastReadMessageId`를 전송하면 `room_members.last_read_message_id`를 업데이트. 후퇴 방지(새 ID > 기존 ID만 허용). 실패 시 로그만 남기고 무시. 읽음 cursor는 현재 `_id` 기준을 유지한다 | room_members | | `[x]` | 안읽은 메시지 카운트 조회 | `GET /rooms/{roomId}/messages/unread-count`로 `lastReadMessageId` 이후 SYSTEM 제외 메시지 수 반환. 페이지 로딩/재접속 시 호출하여 배지 카운트 초기화, 이후 실시간 메시지는 프론트 로컬 증가. unread count는 현재 `_id` 기준을 유지한다 | room_members, MongoDB messages | | `[x]` | 채팅 도배 방지 (Flood Protection) | Redis 기반 Bucket4j로 사용자별 `5/sec + 60/min` token bucket 제한을 `/chat`, `/place` 전송에 적용한다. 초과 시 `CHAT_RATE_LIMIT_EXCEEDED` 에러와 다음 토큰 충전까지의 시간(`retryAfterMs`)을 `/user/queue/errors`로 전달한다. Redis/Bucket4j backend 장애 시에는 fail-open으로 전송을 허용한다. 서버 사이드 cooldown과 점진 증가 정책은 사용하지 않는다 | Redis | diff --git a/docs/superpowers/plans/2026-06-08-mongo-message-payload.md b/docs/superpowers/plans/2026-06-08-mongo-message-payload.md new file mode 100644 index 00000000..4d01431b --- /dev/null +++ b/docs/superpowers/plans/2026-06-08-mongo-message-payload.md @@ -0,0 +1,1019 @@ +# SYSTEM 메시지 MongoDB 페이로드 정리 플랜 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** SYSTEM 메시지(`MEMBER_JOINED`/`MEMBER_LEFT`/`MEMBER_KICKED`/`HOST_DELEGATED`)의 MongoDB 문서·STOMP 페이로드·REST 응답에서 굳은 표시 데이터(`content`, `metadata.nickname`, `metadata.profileImageUrl`, `metadata.previousHostNickname`, `metadata.newHostNickname`)를 키 자체로 남기지 않는다. 표시 문구 조립 책임을 클라이언트로 일원화한다. + +**Architecture:** `ChatMessage.system()` 팩토리에서 `content` 인자를 제거해 SYSTEM 문서의 `content` 필드가 항상 null이 되도록 한다. Spring Data MongoDB의 `MappingMongoConverter`는 null 필드를 BSON에 쓰지 않으므로 저장 문서에 `content` 키가 생기지 않는다. `SystemMessageService` 4개 메서드와 `MessageService` 위임, 4개 리스너 호출 사이트에서 nickname/profileImageUrl/previousHostNickname/newHostNickname 인자를 모두 제거한다. `MessagePayload`/`MessageResponse`의 `content` 필드에 `@JsonInclude(NON_NULL)`을 붙여 SYSTEM 응답 JSON에서 `content` 키가 사라지게 한다. CHAT/AI_REQUEST/AI_RESPONSE/PLACE_SHARE 메시지의 저장·전송 구조는 건드리지 않는다. + +**Tech Stack:** Spring Boot 4.0.5, Java 21, Spring Data MongoDB, MongoDB 8, JUnit 5 + Mockito + AssertJ, Testcontainers(MongoDB) 통합 테스트. + +**참조 스펙:** `docs/superpowers/specs/2026-06-08-mongo-message-payload-design.md` + +**전제(스펙):** +1. MongoDB `messages` 컬렉션이 초기화 상태라 마이그레이션 작업 자체가 없다. +2. 프론트엔드는 `messageType` + `metadata.eventType`로 분기하고 방 진입 시 받은 멤버 데이터로 lookup하는 흐름이 이미 구현되어 있다(또는 본 PR 배포 전까지 합의된다). + +--- + +## File Structure + +**신규 파일** +- `docs/ai/decisions/20260608--system-message-payload-cleanup.md` — 본 결정 ADR +- `src/test/java/com/howaboutus/backend/messages/document/SystemMessagePayloadIntegrationTest.java` — Mongo raw `Document` 키 부재 가드 + +**수정 파일** +- `src/main/java/com/howaboutus/backend/messages/document/ChatMessage.java` — `system()` 팩토리에서 `content` 인자 제거 +- `src/main/java/com/howaboutus/backend/messages/service/SystemMessageService.java` — 4개 메서드 시그니처 축소, `MessageContentValidator` 의존성 제거, content 텍스트 조립 제거 +- `src/main/java/com/howaboutus/backend/messages/service/MessageService.java` — 4개 위임 메서드 시그니처 축소 +- `src/main/java/com/howaboutus/backend/messages/listener/MemberApprovedMessageListener.java` — 호출 인자 축소 +- `src/main/java/com/howaboutus/backend/messages/listener/MemberLeftMessageListener.java` — 호출 인자 축소 (`RoomPresenceChangedEvent` 발행은 유지) +- `src/main/java/com/howaboutus/backend/messages/listener/MemberKickedMessageListener.java` — 호출 인자 축소 (`RoomPresenceChangedEvent`/`UserRoomActionBroadcaster` 발행은 유지) +- `src/main/java/com/howaboutus/backend/messages/listener/HostDelegatedMessageListener.java` — 호출 인자 축소 +- `src/main/java/com/howaboutus/backend/realtime/service/dto/MessagePayload.java` — `content`에 `@JsonInclude(NON_NULL)` + `@Schema` 갱신 +- `src/main/java/com/howaboutus/backend/messages/controller/dto/MessageResponse.java` — `content`에 `@JsonInclude(NON_NULL)` + `@Schema` 갱신 +- `src/test/java/com/howaboutus/backend/messages/service/SystemMessageServiceTest.java` — 4개 케이스로 확장, 새 metadata 키 집합 검증 +- `src/test/java/com/howaboutus/backend/messages/service/MessageServiceTest.java` — SYSTEM 위임 케이스 시그니처 축소 및 assertion 수정 +- `src/test/java/com/howaboutus/backend/messages/listener/MemberApprovedMessageListenerTest.java` — verify 시그니처 축소 +- `src/test/java/com/howaboutus/backend/messages/listener/MemberLeftMessageListenerTest.java` — verify 시그니처 축소 +- `src/test/java/com/howaboutus/backend/messages/listener/MemberKickedMessageListenerTest.java` — verify 시그니처 축소 +- `src/test/java/com/howaboutus/backend/messages/listener/HostDelegatedMessageListenerTest.java` — verify 시그니처 축소 +- `docs/ai/decisions/20260607-1636-system-message-metadata-rendering.md` — 상태를 `결정` → `교체됨 (→ 20260608--system-message-payload-cleanup.md)` +- `docs/ai/features.md` — 171행 시스템 메시지 항목 설명 갱신 +- `docs/superpowers/specs/2026-06-08-mongo-message-payload-design.md` — 마지막에 본 plan과 ADR 경로 링크 + 상태를 `구현 중`/`구현 완료`로 갱신 + +--- + +## 작업 순서 원칙 + +- 각 Task 끝에 conventional commit(`feat:`/`refactor:`/`test:`/`docs:`/`chore:`) 한 개를 만든다. +- 커밋 직전에는 **반드시** `./gradlew checkstyleMain checkstyleTest`를 실행해 경고 0개를 확인한다. 경고가 있으면 빌드가 실패한다. +- 커밋 메시지에 `Co-Authored-By:` 트레일러를 **절대** 추가하지 않는다 (사용자 선호). +- 단위 테스트는 `@ExtendWith(MockitoExtension.class) + @Mock + @InjectMocks` 우선, 필요한 경우에만 `Mockito.mock(...)` 직접 생성. +- Task 2는 SystemMessageService/MessageService/리스너 4종을 한 번에 바꾼다. 시그니처가 호출 사이트와 양방향으로 의존하므로 분리하면 중간 상태가 컴파일되지 않는다. +- TDD 순서: 테스트 먼저 수정해 fail → 프로덕션 코드 수정 → 동일 테스트 pass → 커밋. +- 본 plan은 컬렉션 초기화 상태를 전제로 한다. 마이그레이션 작업은 없다. + +--- + +## Task 1: ADR 추가 + 기존 ADR 상태 갱신 + +**Files:** +- Create: `docs/ai/decisions/20260608--system-message-payload-cleanup.md` (`` 자리는 커밋 직전 시각 4자리, 예: `1541`) +- Modify: `docs/ai/decisions/20260607-1636-system-message-metadata-rendering.md` (상단 `상태` 줄 1줄) + +- [ ] **Step 1: 신규 ADR 파일 생성** + +`docs/ai/decisions/20260608--system-message-payload-cleanup.md` (현재 시각의 HHMM, 예: 1541): + +```markdown +# SYSTEM 메시지 페이로드에서 표시 데이터 제거 + +- **상태**: 결정 +- **날짜**: 2026-06-08 +- **교체**: [20260607-1636-system-message-metadata-rendering.md](20260607-1636-system-message-metadata-rendering.md) + +## Status + +결정 + +## Context + +`20260607-1636-system-message-metadata-rendering.md`는 "백엔드 변경 없음, 클라이언트가 metadata 기반으로 재렌더링"이라는 보수적 결정을 내렸다. 그 결정에서 `content`와 `metadata.nickname`/`profileImageUrl`/`previousHostNickname`/`newHostNickname`은 legacy 클라이언트와 디버깅용 fallback으로 의도적으로 남겨졌다. + +이후 두 가지 전제가 추가로 확정되었다. + +1. MongoDB `messages` 컬렉션이 초기화 상태라 마이그레이션 비용이 없다. +2. 프론트엔드가 `messageType` + `metadata.eventType` + `userId` lookup 흐름으로 이미 구현되어 있다. + +전제 (1)로 fallback content 보존 동기가 사라졌고, 전제 (2)로 표시 책임을 클라이언트로 일원화할 수 있다. 굳은 표시 데이터를 저장하면 회원 탈퇴·닉네임 변경 후에도 stale 표시가 남는 본 문제의 근본 원인이 그대로 유지된다. + +## Decision + +SYSTEM 메시지의 MongoDB 문서·STOMP 페이로드·REST 응답에서 다음 키를 **저장하지 않고 응답에도 포함하지 않는다**. + +- `content` +- `metadata.nickname` +- `metadata.profileImageUrl` +- `metadata.previousHostNickname` +- `metadata.newHostNickname` + +대신 SYSTEM 메시지의 metadata는 다음 식별자만 포함한다. + +- `MEMBER_JOINED`/`MEMBER_LEFT`/`MEMBER_KICKED`: `{ eventType, userId }` +- `HOST_DELEGATED`: `{ eventType, previousHostUserId, newHostUserId }` + +클라이언트는 `messageType === "SYSTEM"`인 경우 metadata의 `eventType`과 userId 식별자를 기준으로 방 멤버 맵에서 닉네임/프로필을 lookup해 표시 문구를 조립한다. lookup 실패 시 `(알 수 없음)`로 표시한다. + +CHAT/AI_REQUEST/AI_RESPONSE/PLACE_SHARE 메시지의 저장 및 전송 구조는 변경하지 않는다. + +## Consequences + +**장점** +- 회원 탈퇴·닉네임 변경 후에도 SYSTEM 메시지가 stale 표시 데이터로 남지 않는다. 일반 채팅과 SYSTEM 메시지의 표시 일관성이 보장된다. +- MongoDB 문서·payload 크기 감소. +- 표시 문구 조립 책임이 클라이언트로 일원화되어 백엔드는 식별자만 책임진다. + +**단점/제약** +- legacy 클라이언트(아직 metadata 기반 분기를 구현하지 못한 버전)에서는 SYSTEM 메시지가 빈 줄로 보일 수 있다. 본 결정 배포는 프론트엔드 분기 구현 배포 이후로 합의한다. +- BSON 직렬화에서 null 필드 skip은 Spring Data MongoDB 기본 동작이지만, 향후 `MongoMappingContext` 설정이 변경되면 깨질 수 있다. 통합 테스트 가드로 회귀를 막는다. + +**후속 문서/코드 갱신** +- `docs/ai/features.md` 시스템 메시지 항목 +- `docs/superpowers/plans/2026-06-08-mongo-message-payload.md` (본 plan 진행) +- `MessagePayload`/`MessageResponse` `@Schema` 설명 + +## Related Docs + +- `docs/superpowers/specs/2026-06-08-mongo-message-payload-design.md` +- `docs/ai/features.md` +- `docs/ai/decisions/20260607-1636-system-message-metadata-rendering.md` (교체) +- `docs/ai/decisions/20260607-user-withdrawal-soft-delete.md` +``` + +- [ ] **Step 2: 기존 ADR 상태 줄 갱신** + +`docs/ai/decisions/20260607-1636-system-message-metadata-rendering.md` 상단의 `- **상태**: 결정` 줄을 다음으로 바꾼다 (HHMM은 Step 1에서 정한 값과 일치). + +```markdown +- **상태**: 교체됨 (→ [20260608--system-message-payload-cleanup.md](20260608--system-message-payload-cleanup.md)) +``` + +본문은 이력 보존을 위해 그대로 둔다. + +- [ ] **Step 3: 변경 사항 확인** + +Run: `git diff docs/ai/decisions/` +Expected: 신규 파일 1개 + 기존 파일 상단 1줄 수정. + +- [ ] **Step 4: 커밋** + +```bash +git add docs/ai/decisions/20260608-*-system-message-payload-cleanup.md \ + docs/ai/decisions/20260607-1636-system-message-metadata-rendering.md +git commit -m "docs: SYSTEM 메시지 페이로드 정리 ADR 추가 및 기존 ADR 교체 표기" +``` + +--- + +## Task 2: SystemMessageService + ChatMessage + MessageService + 4 리스너 시그니처 축소 + +도메인 모델·서비스·위임·리스너 5개 파일과 5개 테스트 파일을 한 번에 바꾼다. 호출 사이트와 정의가 양방향으로 의존하므로 분리하면 중간 상태가 컴파일되지 않는다. TDD: 테스트를 먼저 깬 뒤 프로덕션 코드를 바꾼다. + +**Files:** +- Modify: `src/main/java/com/howaboutus/backend/messages/document/ChatMessage.java:71-73` +- Modify: `src/main/java/com/howaboutus/backend/messages/service/SystemMessageService.java` (전체) +- Modify: `src/main/java/com/howaboutus/backend/messages/service/MessageService.java:53-86` +- Modify: `src/main/java/com/howaboutus/backend/messages/listener/MemberApprovedMessageListener.java:19-26` +- Modify: `src/main/java/com/howaboutus/backend/messages/listener/MemberLeftMessageListener.java:28-38` +- Modify: `src/main/java/com/howaboutus/backend/messages/listener/MemberKickedMessageListener.java:43-61` +- Modify: `src/main/java/com/howaboutus/backend/messages/listener/HostDelegatedMessageListener.java:20-28` +- Modify: `src/test/java/com/howaboutus/backend/messages/service/SystemMessageServiceTest.java` +- Modify: `src/test/java/com/howaboutus/backend/messages/service/MessageServiceTest.java:343-373` +- Modify: `src/test/java/com/howaboutus/backend/messages/listener/MemberApprovedMessageListenerTest.java:30-34` +- Modify: `src/test/java/com/howaboutus/backend/messages/listener/MemberLeftMessageListenerTest.java` +- Modify: `src/test/java/com/howaboutus/backend/messages/listener/MemberKickedMessageListenerTest.java` +- Modify: `src/test/java/com/howaboutus/backend/messages/listener/HostDelegatedMessageListenerTest.java:32-42` + +- [ ] **Step 1: `SystemMessageServiceTest`를 4개 케이스로 확장하고 새 metadata 키만 단언하도록 갱신** + +`src/test/java/com/howaboutus/backend/messages/service/SystemMessageServiceTest.java` 전체를 다음으로 교체: + +```java +package com.howaboutus.backend.messages.service; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; +import static org.mockito.Mockito.*; + +import java.util.Optional; +import java.util.UUID; + +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.context.ApplicationEventPublisher; +import org.springframework.test.util.ReflectionTestUtils; + +import com.howaboutus.backend.messages.document.ChatMessage; +import com.howaboutus.backend.messages.document.MessageType; +import com.howaboutus.backend.messages.repository.ChatMessageRepository; +import com.howaboutus.backend.messages.service.dto.MessageResult; +import com.howaboutus.backend.realtime.event.MessageSentEvent; + +@ExtendWith(MockitoExtension.class) +class SystemMessageServiceTest { + + @Mock + private ChatMessageRepository chatMessageRepository; + + @Mock + private ApplicationEventPublisher eventPublisher; + + private SystemMessageService systemMessageService; + + @BeforeEach + void setUp() { + MessagePublisher messagePublisher = new MessagePublisher(eventPublisher); + MessageSequenceAllocator messageSequenceAllocator = new MessageSequenceAllocator(chatMessageRepository); + lenient().when(chatMessageRepository.findFirstByRoomIdOrderBySequenceDescIdDesc(any(UUID.class))) + .thenReturn(Optional.empty()); + systemMessageService = new SystemMessageService(messageSequenceAllocator, messagePublisher); + } + + @Test + @DisplayName("MEMBER_JOINED 시스템 메시지는 content 없이 eventType/userId만 저장한다") + void sendMemberJoinedSystemMessageStoresIdentifiersOnly() { + UUID roomId = UUID.randomUUID(); + givenSaveReturnsWithId("6628f5f4c49a9f7b3772c333"); + + MessageResult result = systemMessageService.sendMemberJoinedSystemMessage(roomId, 7L); + + assertThat(result.senderId()).isNull(); + assertThat(result.messageType()).isEqualTo(MessageType.SYSTEM); + assertThat(result.content()).isNull(); + assertThat(result.sequence()).isEqualTo(1L); + assertThat(result.metadata()) + .containsOnly( + entry("eventType", "MEMBER_JOINED"), + entry("userId", 7L) + ); + + verifySavedSystemMessageHasNullContent(); + verify(eventPublisher).publishEvent(MessageSentEvent.from(result)); + } + + @Test + @DisplayName("MEMBER_LEFT 시스템 메시지는 content 없이 eventType/userId만 저장한다") + void sendMemberLeftSystemMessageStoresIdentifiersOnly() { + UUID roomId = UUID.randomUUID(); + givenSaveReturnsWithId("6628f5f4c49a9f7b3772c334"); + + MessageResult result = systemMessageService.sendMemberLeftSystemMessage(roomId, 7L); + + assertThat(result.content()).isNull(); + assertThat(result.metadata()) + .containsOnly( + entry("eventType", "MEMBER_LEFT"), + entry("userId", 7L) + ); + + verifySavedSystemMessageHasNullContent(); + } + + @Test + @DisplayName("MEMBER_KICKED 시스템 메시지는 content 없이 eventType/userId만 저장한다") + void sendMemberKickedSystemMessageStoresIdentifiersOnly() { + UUID roomId = UUID.randomUUID(); + givenSaveReturnsWithId("6628f5f4c49a9f7b3772c335"); + + MessageResult result = systemMessageService.sendMemberKickedSystemMessage(roomId, 7L); + + assertThat(result.content()).isNull(); + assertThat(result.metadata()) + .containsOnly( + entry("eventType", "MEMBER_KICKED"), + entry("userId", 7L) + ); + + verifySavedSystemMessageHasNullContent(); + } + + @Test + @DisplayName("HOST_DELEGATED 시스템 메시지는 content 없이 이전/새 호스트 userId만 저장한다") + void sendHostDelegatedSystemMessageStoresIdentifiersOnly() { + UUID roomId = UUID.randomUUID(); + givenSaveReturnsWithId("6628f5f4c49a9f7b3772c336"); + + MessageResult result = systemMessageService.sendHostDelegatedSystemMessage(roomId, 1L, 2L); + + assertThat(result.content()).isNull(); + assertThat(result.metadata()) + .containsOnly( + entry("eventType", "HOST_DELEGATED"), + entry("previousHostUserId", 1L), + entry("newHostUserId", 2L) + ); + + verifySavedSystemMessageHasNullContent(); + } + + private void givenSaveReturnsWithId(String id) { + given(chatMessageRepository.save(any(ChatMessage.class))).willAnswer(invocation -> { + ChatMessage message = invocation.getArgument(0); + ReflectionTestUtils.setField(message, "id", id); + return message; + }); + } + + private void verifySavedSystemMessageHasNullContent() { + ArgumentCaptor captor = ArgumentCaptor.forClass(ChatMessage.class); + verify(chatMessageRepository).save(captor.capture()); + ChatMessage saved = captor.getValue(); + assertThat(saved.getMessageType()).isEqualTo(MessageType.SYSTEM); + assertThat(saved.getContent()).isNull(); + assertThat(saved.getSenderId()).isNull(); + } +} +``` + +위에서 사용한 `entry(...)`는 `org.assertj.core.api.Assertions.entry`다. import에 이미 wildcard로 포함되어 있다. + +- [ ] **Step 2: `MessageServiceTest`의 SYSTEM 위임 케이스 갱신** + +`src/test/java/com/howaboutus/backend/messages/service/MessageServiceTest.java`에서 `setUp()`의 `SystemMessageService` 생성자 호출과 `sendMemberJoinedSystemMessageStoresSystemMessage` 케이스를 갱신한다. + +`setUp()` 내 (76-80행) `SystemMessageService` 생성 부분을 다음으로 교체: + +```java + SystemMessageService systemMessageService = new SystemMessageService( + messageSequenceAllocator, + messagePublisher + ); +``` + +이어서 `sendMemberJoinedSystemMessageStoresSystemMessage` 케이스(현재 343-373행)를 다음으로 교체: + +```java + @Test + @DisplayName("멤버 입장 시스템 메시지는 senderId/content 없이 MongoDB에 저장할 수 있다") + void sendMemberJoinedSystemMessageStoresSystemMessage() { + UUID roomId = UUID.randomUUID(); + + given(chatMessageRepository.save(any(ChatMessage.class))).willAnswer(invocation -> { + ChatMessage message = invocation.getArgument(0); + ReflectionTestUtils.setField(message, "id", "6628f5f4c49a9f7b3772c333"); + return message; + }); + + MessageResult result = messageService.sendMemberJoinedSystemMessage(roomId, 7L); + + assertThat(result.senderId()).isNull(); + assertThat(result.messageType()).isEqualTo(MessageType.SYSTEM); + assertThat(result.content()).isNull(); + assertThat(result.metadata()) + .containsOnly( + entry("eventType", "MEMBER_JOINED"), + entry("userId", 7L) + ); + + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(ChatMessage.class); + verify(chatMessageRepository).save(messageCaptor.capture()); + assertThat(messageCaptor.getValue().getMessageType()).isEqualTo(MessageType.SYSTEM); + assertThat(messageCaptor.getValue().getContent()).isNull(); + verify(eventPublisher).publishEvent(MessageSentEvent.from(result)); + } +``` + +필요한 import가 누락되어 있으면 `import static org.assertj.core.api.Assertions.*;`로 wildcard import이 이미 있으므로 별도 추가는 없다(`entry`가 그 안에 포함됨). + +- [ ] **Step 3: 4개 리스너 테스트의 verify 시그니처 축소** + +`MemberApprovedMessageListenerTest.java`의 `handleSendsSystemMessage` 본문(30-34행): + +```java + UUID roomId = UUID.randomUUID(); + MemberApprovedEvent event = new MemberApprovedEvent(roomId, 3L, "대기자", "https://example.com/p.png"); + + listener.handle(event); + + verify(messageService).sendMemberJoinedSystemMessage(roomId, 3L); +``` + +`MemberLeftMessageListenerTest.java`의 두 테스트에서 마지막 `then(messageService)` 단언을 다음으로 교체: + +```java + then(messageService).should().sendMemberLeftSystemMessage(ROOM_ID, 2L); +``` + +`MemberKickedMessageListenerTest.java`의 `handleRemovesPresenceAndBroadcastsAndSendsSystemMessage`/`handleSendsMessageEvenWhenRedisFails`의 `then(messageService)` 단언을 다음으로 교체: + +```java + then(messageService).should().sendMemberKickedSystemMessage(ROOM_ID, 2L); +``` + +`HostDelegatedMessageListenerTest.java`의 `handleSendsSystemMessage` 본문(32-42행)을 다음으로 교체: + +```java + @Test + @DisplayName("이벤트 처리 - 시스템 메시지 전송") + void handleSendsSystemMessage() { + HostDelegatedEvent event = new HostDelegatedEvent( + ROOM_ID, 1L, "호스트", 2L, "타겟"); + + listener.handle(event); + + then(messageService).should().sendHostDelegatedSystemMessage(ROOM_ID, 1L, 2L); + } +``` + +리스너 테스트는 `HostDelegatedEvent`/`MemberLeftEvent`/`MemberKickedEvent`/`MemberApprovedEvent` 이벤트 자체는 변경하지 않으므로(spec 비목표) nickname/profileImageUrl/previousHostNickname/newHostNickname 인자는 그대로 둔다. 테스트가 검증하는 것은 "리스너가 messageService에 nickname/profileImageUrl를 전달하지 않는다"는 것이다. + +- [ ] **Step 4: 테스트를 돌려 모두 빨갛게 떠지는지 확인** + +Run: `./gradlew test --tests 'com.howaboutus.backend.messages.service.SystemMessageServiceTest' --tests 'com.howaboutus.backend.messages.service.MessageServiceTest' --tests 'com.howaboutus.backend.messages.listener.*MessageListenerTest' -q` +Expected: 컴파일 오류(`SystemMessageService(...)` 생성자/`sendXxxSystemMessage(...)` 시그니처 mismatch). 이게 정상이다. 테스트가 새 시그니처를 요구한다. + +- [ ] **Step 5: `ChatMessage.system()`에서 content 인자 제거** + +`src/main/java/com/howaboutus/backend/messages/document/ChatMessage.java`의 `system` 팩토리(71-73행)를 다음으로 교체: + +```java + public static ChatMessage system(UUID roomId, Map metadata) { + return new ChatMessage(roomId, null, MessageType.SYSTEM, null, metadata, Instant.now()); + } +``` + +다른 팩토리(`chat`, `aiRequest`, `aiResponse`, `placeShare`)와 인스턴스 필드는 건드리지 않는다. `content` 필드 자체는 다른 MessageType이 본질 데이터로 사용한다. + +- [ ] **Step 6: `SystemMessageService` 전체 재작성** + +`src/main/java/com/howaboutus/backend/messages/service/SystemMessageService.java` 전체를 다음으로 교체: + +```java +package com.howaboutus.backend.messages.service; + +import java.util.Map; +import java.util.UUID; + +import org.springframework.stereotype.Service; + +import com.howaboutus.backend.common.logging.Loggable; +import com.howaboutus.backend.messages.document.ChatMessage; +import com.howaboutus.backend.messages.service.dto.MessageResult; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class SystemMessageService { + + private final MessageSequenceAllocator messageSequenceAllocator; + private final MessagePublisher messagePublisher; + + @Loggable + public MessageResult sendMemberJoinedSystemMessage(UUID roomId, long joinedUserId) { + Map metadata = Map.of( + "eventType", "MEMBER_JOINED", + "userId", joinedUserId + ); + return saveSystemMessage(roomId, metadata); + } + + @Loggable + public MessageResult sendMemberKickedSystemMessage(UUID roomId, long kickedUserId) { + Map metadata = Map.of( + "eventType", "MEMBER_KICKED", + "userId", kickedUserId + ); + return saveSystemMessage(roomId, metadata); + } + + @Loggable + public MessageResult sendMemberLeftSystemMessage(UUID roomId, long leftUserId) { + Map metadata = Map.of( + "eventType", "MEMBER_LEFT", + "userId", leftUserId + ); + return saveSystemMessage(roomId, metadata); + } + + @Loggable + public MessageResult sendHostDelegatedSystemMessage(UUID roomId, + long previousHostUserId, + long newHostUserId) { + Map metadata = Map.of( + "eventType", "HOST_DELEGATED", + "previousHostUserId", previousHostUserId, + "newHostUserId", newHostUserId + ); + return saveSystemMessage(roomId, metadata); + } + + private MessageResult saveSystemMessage(UUID roomId, Map metadata) { + ChatMessage message = ChatMessage.system(roomId, metadata); + ChatMessage savedMessage = messageSequenceAllocator.saveWithNextSequence(message); + MessageResult result = MessageResult.from(savedMessage); + messagePublisher.publishMessageSent(result); + return result; + } +} +``` + +`MessageContentValidator` 의존성은 SystemMessageService에서 더 이상 필요 없으므로 제거한다. `MessageContentValidator` 클래스 자체는 다른 서비스(`MessageCommandService`, `AiMessageService`, `MessageQueryService`)가 여전히 사용하므로 삭제하지 않는다. + +`MessageMetadata` 유틸리티(`nonNull` / `entries`)는 SYSTEM 경로에서 더 이상 호출되지 않지만, 다른 메시지 경로에서 사용되는지 확인 후 사용처가 없으면 삭제한다. 사용처가 있으면 그대로 둔다. + +Run: `grep -rn "MessageMetadata\." src/main/java` +사용처가 SystemMessageService 외부에 없으면 `src/main/java/com/howaboutus/backend/messages/service/MessageMetadata.java`를 삭제한다. + +- [ ] **Step 7: `MessageService`의 4개 위임 메서드 축소** + +`src/main/java/com/howaboutus/backend/messages/service/MessageService.java`의 53-86행을 다음으로 교체: + +```java + public MessageResult sendMemberJoinedSystemMessage(UUID roomId, long joinedUserId) { + return systemMessageService.sendMemberJoinedSystemMessage(roomId, joinedUserId); + } + + public void sendMemberKickedSystemMessage(UUID roomId, long kickedUserId) { + systemMessageService.sendMemberKickedSystemMessage(roomId, kickedUserId); + } + + public void sendMemberLeftSystemMessage(UUID roomId, long leftUserId) { + systemMessageService.sendMemberLeftSystemMessage(roomId, leftUserId); + } + + public void sendHostDelegatedSystemMessage(UUID roomId, + long previousHostUserId, + long newHostUserId) { + systemMessageService.sendHostDelegatedSystemMessage(roomId, previousHostUserId, newHostUserId); + } +``` + +`sendMemberKickedSystemMessage`/`sendMemberLeftSystemMessage`/`sendHostDelegatedSystemMessage`는 기존과 같이 반환값 없이 `void`로 유지한다. + +- [ ] **Step 8: `MemberApprovedMessageListener`의 호출 인자 축소** + +`src/main/java/com/howaboutus/backend/messages/listener/MemberApprovedMessageListener.java`의 19-26행 `handle` 본문을 다음으로 교체: + +```java + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) + public void handle(MemberApprovedEvent event) { + messageService.sendMemberJoinedSystemMessage(event.roomId(), event.joinedUserId()); + } +``` + +`MemberApprovedEvent`의 nickname/profileImageUrl 필드는 그대로 둔다(이벤트 변경은 spec 비목표). 단지 이 리스너가 그 값을 무시할 뿐이다. + +- [ ] **Step 9: `MemberLeftMessageListener`의 호출 인자 축소** + +`src/main/java/com/howaboutus/backend/messages/listener/MemberLeftMessageListener.java`의 28-38행 `handle` 본문에서 `messageService.sendMemberLeftSystemMessage(...)` 호출만 다음으로 교체. `RoomPresenceChangedEvent` 발행은 그대로 둔다. + +```java + messageService.sendMemberLeftSystemMessage(event.roomId(), event.leftUserId()); +``` + +전체 `handle` 메서드: + +```java + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) + public void handle(MemberLeftEvent event) { + removePresenceSafe(event.roomId(), event.leftUserId()); + eventPublisher.publishEvent(new RoomPresenceChangedEvent( + event.roomId(), event.leftUserId(), + RoomPresenceEventType.USER_DISCONNECTED, + event.nickname(), event.profileImageUrl())); + messageService.sendMemberLeftSystemMessage(event.roomId(), event.leftUserId()); + } +``` + +- [ ] **Step 10: `MemberKickedMessageListener`의 호출 인자 축소** + +`src/main/java/com/howaboutus/backend/messages/listener/MemberKickedMessageListener.java`의 53-55행 `messageService.sendMemberKickedSystemMessage(...)` 호출만 다음으로 교체. `RoomPresenceChangedEvent`/`UserRoomActionBroadcaster.sendToUser` 호출은 그대로 둔다. + +```java + messageService.sendMemberKickedSystemMessage(event.roomId(), event.kickedUserId()); +``` + +- [ ] **Step 11: `HostDelegatedMessageListener`의 호출 인자 축소** + +`src/main/java/com/howaboutus/backend/messages/listener/HostDelegatedMessageListener.java`의 20-28행 `handle` 본문을 다음으로 교체: + +```java + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) + public void handle(HostDelegatedEvent event) { + messageService.sendHostDelegatedSystemMessage( + event.roomId(), + event.previousHostUserId(), + event.newHostUserId()); + } +``` + +- [ ] **Step 12: 단위 테스트 전부 통과 확인** + +Run: `./gradlew test --tests 'com.howaboutus.backend.messages.service.SystemMessageServiceTest' --tests 'com.howaboutus.backend.messages.service.MessageServiceTest' --tests 'com.howaboutus.backend.messages.listener.*MessageListenerTest' -q` +Expected: PASS. + +- [ ] **Step 13: 전체 빌드 + 체크스타일** + +Run: `./gradlew checkstyleMain checkstyleTest` +Expected: 0 warnings. + +Run: `./gradlew compileJava compileTestJava -q` +Expected: SUCCESS. SystemMessageService/MessageService/리스너의 새 시그니처가 다른 호출 사이트(테스트 외)와 충돌하지 않아야 한다. + +- [ ] **Step 14: 커밋** + +```bash +git add src/main/java/com/howaboutus/backend/messages/document/ChatMessage.java \ + src/main/java/com/howaboutus/backend/messages/service/SystemMessageService.java \ + src/main/java/com/howaboutus/backend/messages/service/MessageService.java \ + src/main/java/com/howaboutus/backend/messages/listener/MemberApprovedMessageListener.java \ + src/main/java/com/howaboutus/backend/messages/listener/MemberLeftMessageListener.java \ + src/main/java/com/howaboutus/backend/messages/listener/MemberKickedMessageListener.java \ + src/main/java/com/howaboutus/backend/messages/listener/HostDelegatedMessageListener.java \ + src/test/java/com/howaboutus/backend/messages/service/SystemMessageServiceTest.java \ + src/test/java/com/howaboutus/backend/messages/service/MessageServiceTest.java \ + src/test/java/com/howaboutus/backend/messages/listener/MemberApprovedMessageListenerTest.java \ + src/test/java/com/howaboutus/backend/messages/listener/MemberLeftMessageListenerTest.java \ + src/test/java/com/howaboutus/backend/messages/listener/MemberKickedMessageListenerTest.java \ + src/test/java/com/howaboutus/backend/messages/listener/HostDelegatedMessageListenerTest.java +# MessageMetadata.java 삭제했으면 함께 stage +[ -f src/main/java/com/howaboutus/backend/messages/service/MessageMetadata.java ] || git add -u src/main/java/com/howaboutus/backend/messages/service/MessageMetadata.java +git commit -m "refactor: SYSTEM 메시지 페이로드에서 표시 데이터 제거 및 식별자만 저장" +``` + +--- + +## Task 3: API DTO에서 SYSTEM content 키 응답 제외 + +`MessagePayload`(STOMP)와 `MessageResponse`(REST)의 `content` 필드에 `@JsonInclude(NON_NULL)`을 붙여 SYSTEM 메시지에서 `content`가 null일 때 JSON 응답에서 키 자체가 빠지게 한다. `@Schema` 설명을 spec과 일치하도록 갱신한다. + +**Files:** +- Modify: `src/main/java/com/howaboutus/backend/realtime/service/dto/MessagePayload.java` +- Modify: `src/main/java/com/howaboutus/backend/messages/controller/dto/MessageResponse.java` + +- [ ] **Step 1: `MessagePayload`에 `@JsonInclude(NON_NULL)` 및 `@Schema` 갱신** + +`src/main/java/com/howaboutus/backend/realtime/service/dto/MessagePayload.java` 전체를 다음으로 교체: + +```java +package com.howaboutus.backend.realtime.service.dto; + +import java.time.Instant; +import java.util.Map; +import java.util.UUID; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.howaboutus.backend.messages.document.MessageType; +import com.howaboutus.backend.realtime.event.MessageSentEvent; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record MessagePayload( + String id, + Long sequence, + String clientMessageId, + UUID roomId, + Long senderId, + MessageType messageType, + @JsonInclude(JsonInclude.Include.NON_NULL) + @Schema(description = """ + 사람이 읽을 수 있는 메시지 본문. + + CHAT/AI_REQUEST/AI_RESPONSE/PLACE_SHARE에서만 제공됩니다. + SYSTEM 메시지에서는 응답에 포함되지 않습니다(키 자체가 빠집니다).""") + String content, + @Schema(description = """ + 상위 messageType에 따라 구조가 달라지는 확장 데이터입니다. + + - CHAT: 빈 객체 {} + - AI_REQUEST: { clientMessageId: string, aiStatus: QUEUED|PROCESSING|CANCELED, + cancelable: boolean, canceledBy?: number } + - AI_RESPONSE: { requestMessageId: string, intent: string, placeRecommendation?: object, + conversationSummary?: object } + placeRecommendation: { title, subtitle?, places: [{ place_id, name, address?, + lat?, lng?, primary_type?, reason, google_maps_uri? }] } + - PLACE_SHARE: { googlePlaceId: string, name: string, formattedAddress?: string, + latitude?: number, longitude?: number, rating?: number, photoName?: string } + - SYSTEM MEMBER_JOINED/MEMBER_LEFT/MEMBER_KICKED: { eventType, userId } + - SYSTEM HOST_DELEGATED: { eventType, previousHostUserId, newHostUserId } + + SYSTEM 메시지의 표시 문구는 metadata.eventType과 userId + (또는 previousHostUserId/newHostUserId)로 클라이언트가 멤버 맵에서 lookup해 조립합니다. + 닉네임/프로필 이미지는 응답에 포함되지 않습니다.""") + Map metadata, + Instant createdAt +) { + public static MessagePayload from(MessageSentEvent event) { + return new MessagePayload( + event.id(), + event.sequence(), + event.clientMessageId(), + event.roomId(), + event.senderId(), + event.messageType(), + event.content(), + event.metadata(), + event.createdAt() + ); + } +} +``` + +- [ ] **Step 2: `MessageResponse`에 `@JsonInclude(NON_NULL)` 및 `@Schema` 갱신** + +`src/main/java/com/howaboutus/backend/messages/controller/dto/MessageResponse.java` 전체를 다음으로 교체: + +```java +package com.howaboutus.backend.messages.controller.dto; + +import java.time.Instant; +import java.util.Map; +import java.util.UUID; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.howaboutus.backend.messages.document.MessageType; +import com.howaboutus.backend.messages.service.dto.MessageResult; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record MessageResponse( + String id, + Long sequence, + UUID roomId, + Long senderId, + MessageType messageType, + @JsonInclude(JsonInclude.Include.NON_NULL) + @Schema(description = """ + 사람이 읽을 수 있는 메시지 본문. + + CHAT/AI_REQUEST/AI_RESPONSE/PLACE_SHARE에서만 제공됩니다. + SYSTEM 메시지에서는 응답에 포함되지 않습니다(키 자체가 빠집니다).""") + String content, + @Schema(description = """ + 상위 messageType에 따라 구조가 달라지는 확장 데이터입니다. + + - CHAT: 빈 객체 {} + - AI_REQUEST: { clientMessageId: string, aiStatus: QUEUED|PROCESSING|CANCELED, + cancelable: boolean, canceledBy?: number } + - AI_RESPONSE: { requestMessageId: string, intent: string, placeRecommendation?: object, + conversationSummary?: object } + placeRecommendation: { title, subtitle?, places: [{ place_id, name, address?, + lat?, lng?, primary_type?, rating?, user_rating_count?, reason, google_maps_uri? }] } + - PLACE_SHARE: { googlePlaceId: string, name: string, formattedAddress?: string, + latitude?: number, longitude?: number, rating?: number, photoName?: string } + - SYSTEM MEMBER_JOINED/MEMBER_LEFT/MEMBER_KICKED: { eventType, userId } + - SYSTEM HOST_DELEGATED: { eventType, previousHostUserId, newHostUserId } + + SYSTEM 메시지의 표시 문구는 metadata.eventType과 userId + (또는 previousHostUserId/newHostUserId)로 클라이언트가 멤버 맵에서 lookup해 조립합니다. + 닉네임/프로필 이미지는 응답에 포함되지 않습니다.""") + Map metadata, + Instant createdAt +) { + public static MessageResponse from(MessageResult result) { + return new MessageResponse( + result.id(), + result.sequence(), + result.roomId(), + result.senderId(), + result.messageType(), + result.content(), + result.metadata(), + result.createdAt() + ); + } +} +``` + +- [ ] **Step 3: 컴파일 + 체크스타일** + +Run: `./gradlew compileJava checkstyleMain -q` +Expected: SUCCESS, 0 warnings. + +- [ ] **Step 4: 커밋** + +```bash +git add src/main/java/com/howaboutus/backend/realtime/service/dto/MessagePayload.java \ + src/main/java/com/howaboutus/backend/messages/controller/dto/MessageResponse.java +git commit -m "refactor: SYSTEM 메시지 응답에서 content 키 제외 및 @Schema 설명 갱신" +``` + +--- + +## Task 4: MongoDB raw BSON 키 부재 통합 테스트 가드 + +mock 기반 단위 테스트로는 Spring Data MongoDB의 BSON 직렬화 동작을 검증할 수 없다. `BaseIntegrationTest`(Testcontainers MongoDB)를 상속한 신규 통합 테스트로 SYSTEM 메시지를 저장한 뒤 `mongoTemplate.getCollection("messages").find(...)`로 raw `Document`를 가져와 `content` 키 부재와 metadata 키 집합을 직접 검증한다. Spring Data MongoDB null skip 동작 회귀 방지가 목적이다. + +**Files:** +- Create: `src/test/java/com/howaboutus/backend/messages/document/SystemMessagePayloadIntegrationTest.java` + +- [ ] **Step 1: 통합 테스트 파일 생성** + +`src/test/java/com/howaboutus/backend/messages/document/SystemMessagePayloadIntegrationTest.java`: + +```java +package com.howaboutus.backend.messages.document; + +import static org.assertj.core.api.Assertions.*; + +import java.util.UUID; + +import org.bson.Document; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Query; + +import com.howaboutus.backend.messages.service.SystemMessageService; +import com.howaboutus.backend.support.BaseIntegrationTest; + +class SystemMessagePayloadIntegrationTest extends BaseIntegrationTest { + + @Autowired + private SystemMessageService systemMessageService; + + @Autowired + private MongoTemplate mongoTemplate; + + @AfterEach + void cleanUp() { + mongoTemplate.getCollection("messages").deleteMany(new Document()); + } + + @Test + @DisplayName("MEMBER_JOINED 시스템 메시지의 BSON 문서에는 content 키가 없고 metadata는 eventType/userId만 포함한다") + void memberJoinedSystemMessageBsonHasNoContentKey() { + UUID roomId = UUID.randomUUID(); + + systemMessageService.sendMemberJoinedSystemMessage(roomId, 7L); + + Document raw = mongoTemplate.getCollection("messages").find().first(); + assertThat(raw).isNotNull(); + assertThat(raw.containsKey("content")).isFalse(); + + Document metadata = raw.get("metadata", Document.class); + assertThat(metadata).isNotNull(); + assertThat(metadata.keySet()).containsExactlyInAnyOrder("eventType", "userId"); + assertThat(metadata.getString("eventType")).isEqualTo("MEMBER_JOINED"); + assertThat(metadata.getLong("userId")).isEqualTo(7L); + } + + @Test + @DisplayName("MEMBER_LEFT 시스템 메시지의 BSON 문서에는 content 키가 없고 metadata는 eventType/userId만 포함한다") + void memberLeftSystemMessageBsonHasNoContentKey() { + UUID roomId = UUID.randomUUID(); + + systemMessageService.sendMemberLeftSystemMessage(roomId, 7L); + + Document raw = mongoTemplate.getCollection("messages").find().first(); + assertThat(raw).isNotNull(); + assertThat(raw.containsKey("content")).isFalse(); + + Document metadata = raw.get("metadata", Document.class); + assertThat(metadata.keySet()).containsExactlyInAnyOrder("eventType", "userId"); + assertThat(metadata.getString("eventType")).isEqualTo("MEMBER_LEFT"); + } + + @Test + @DisplayName("MEMBER_KICKED 시스템 메시지의 BSON 문서에는 content 키가 없고 metadata는 eventType/userId만 포함한다") + void memberKickedSystemMessageBsonHasNoContentKey() { + UUID roomId = UUID.randomUUID(); + + systemMessageService.sendMemberKickedSystemMessage(roomId, 7L); + + Document raw = mongoTemplate.getCollection("messages").find().first(); + assertThat(raw).isNotNull(); + assertThat(raw.containsKey("content")).isFalse(); + + Document metadata = raw.get("metadata", Document.class); + assertThat(metadata.keySet()).containsExactlyInAnyOrder("eventType", "userId"); + assertThat(metadata.getString("eventType")).isEqualTo("MEMBER_KICKED"); + } + + @Test + @DisplayName("HOST_DELEGATED 시스템 메시지의 BSON 문서에는 content 키가 없고 metadata는 식별자 3개만 포함한다") + void hostDelegatedSystemMessageBsonHasNoContentKey() { + UUID roomId = UUID.randomUUID(); + + systemMessageService.sendHostDelegatedSystemMessage(roomId, 1L, 2L); + + Document raw = mongoTemplate.getCollection("messages").find().first(); + assertThat(raw).isNotNull(); + assertThat(raw.containsKey("content")).isFalse(); + + Document metadata = raw.get("metadata", Document.class); + assertThat(metadata.keySet()).containsExactlyInAnyOrder( + "eventType", "previousHostUserId", "newHostUserId"); + assertThat(metadata.getString("eventType")).isEqualTo("HOST_DELEGATED"); + assertThat(metadata.getLong("previousHostUserId")).isEqualTo(1L); + assertThat(metadata.getLong("newHostUserId")).isEqualTo(2L); + } + + @Test + @DisplayName("기준선: CHAT 메시지의 BSON 문서에는 content 키가 보존된다 (회귀 가드)") + void chatMessageBsonStillHasContentKey() { + UUID roomId = UUID.randomUUID(); + ChatMessage chat = ChatMessage.chat(roomId, 42L, "안녕"); + mongoTemplate.save(chat); + + Document raw = mongoTemplate.getCollection("messages") + .find(Query.query(org.springframework.data.mongodb.core.query.Criteria + .where("messageType").is("CHAT")).getQueryObject()) + .first(); + assertThat(raw).isNotNull(); + assertThat(raw.containsKey("content")).isTrue(); + assertThat(raw.getString("content")).isEqualTo("안녕"); + } +} +``` + +마지막 케이스(CHAT 회귀 가드)는 향후 누군가 모든 메시지에서 content를 빼버리는 회귀를 막기 위한 기준선이다. + +- [ ] **Step 2: 통합 테스트 실행** + +Run: `./gradlew test --tests 'com.howaboutus.backend.messages.document.SystemMessagePayloadIntegrationTest' -q` +Expected: PASS (Task 2의 코드 변경이 이미 들어가 있어야 한다). + +Testcontainers MongoDB 컨테이너 부팅에 수십 초가 걸린다. CI 환경에서 Docker가 활성화되어 있는지 확인한다. + +- [ ] **Step 3: 체크스타일** + +Run: `./gradlew checkstyleTest -q` +Expected: 0 warnings. + +- [ ] **Step 4: 커밋** + +```bash +git add src/test/java/com/howaboutus/backend/messages/document/SystemMessagePayloadIntegrationTest.java +git commit -m "test: SYSTEM 메시지 BSON 페이로드 키 부재 통합 테스트 가드" +``` + +--- + +## Task 5: `docs/ai/features.md` 갱신 + +171행의 시스템 메시지 항목을 본 결정을 반영하도록 갱신한다. + +**Files:** +- Modify: `docs/ai/features.md:171` + +- [ ] **Step 1: 시스템 메시지 항목 갱신** + +`docs/ai/features.md`의 171행 시스템 메시지 row를 다음으로 교체: + +```markdown +| `[x]` | 시스템 메시지 | 입장 승인 같은 멤버십 변경 이벤트를 `messageType=SYSTEM`, `senderId=NULL` 메시지로 저장 후 `/topic/rooms/{roomId}/messages`와 `/topic/rooms/{roomId}/members` 양쪽으로 브로드캐스트. MongoDB 문서와 STOMP/REST 응답 모두에서 `content` 키는 포함되지 않으며, metadata는 식별자만 가진다. `MEMBER_JOINED`/`MEMBER_LEFT`/`MEMBER_KICKED`는 `{ eventType, userId }`, `HOST_DELEGATED`는 `{ eventType, previousHostUserId, newHostUserId }`. 표시 문구는 클라이언트가 `eventType`과 userId를 멤버 맵에서 lookup해 조립한다. WebSocket 접속/해제 presence 이벤트는 채팅 히스토리에 저장하지 않음 | MongoDB messages | +``` + +- [ ] **Step 2: spec 상태/링크 갱신** + +`docs/superpowers/specs/2026-06-08-mongo-message-payload-design.md` 상단의 상태를 `초안` → `구현 완료`로 변경하고, "문서/ADR" 섹션의 신규 ADR 경로 placeholder를 Task 1에서 실제로 만든 파일명으로 갱신한다(예: `20260608-1541-system-message-payload-cleanup.md`). + +- [ ] **Step 3: 커밋** + +```bash +git add docs/ai/features.md \ + docs/superpowers/specs/2026-06-08-mongo-message-payload-design.md +git commit -m "docs: SYSTEM 메시지 페이로드 정리 결정 features.md/spec 반영" +``` + +--- + +## Task 6: 최종 빌드/체크스타일 일제 검증 + +PR 올리기 전 마지막 검증. + +- [ ] **Step 1: 체크스타일** + +Run: `./gradlew checkstyleMain checkstyleTest` +Expected: 0 warnings, BUILD SUCCESSFUL. + +- [ ] **Step 2: 전체 단위 + 통합 테스트** + +Run: `./gradlew test -q` +Expected: BUILD SUCCESSFUL. SystemMessagePayloadIntegrationTest 포함 4 케이스가 통합 테스트 슬라이스에서 통과해야 한다. + +- [ ] **Step 3: 빌드** + +Run: `./gradlew build -q` +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 4: 커밋 로그 점검** + +Run: `git log --oneline origin/main..HEAD` +Expected: Task 1~5의 5개 conventional commit. `Co-Authored-By` 트레일러가 어디에도 없어야 한다. + +- [ ] **Step 5: 프론트엔드 합의 확인 (배포 전 게이트)** + +본 변경은 클라이언트 fallback content를 제거한다. 프론트가 `messageType === "SYSTEM"` + `metadata.eventType` 분기를 이미 배포한 상태인지 확인하고, 그렇지 않다면 본 PR 머지 전 프론트 배포 순서를 합의한다. 합의 결과는 PR 본문의 "배포 노트" 또는 "테스트 메모"에 적는다. + +--- + +## 위험 / 가드 + +- **Spring Data MongoDB null skip 회귀**: BSON 키 부재 검증 통합 테스트(Task 4)로 가드. 향후 `MongoMappingContext` 설정이 바뀌면 이 테스트가 즉시 빨갛게 뜬다. +- **프론트 미배포 상태에서 배포**: SYSTEM 메시지가 빈 줄로 보일 수 있다. Task 6 Step 5의 합의 게이트로 차단. +- **`MessageContentValidator` 사용처 변경**: SystemMessageService에서만 빠진다. 다른 서비스(`MessageCommandService`, `AiMessageService`, `MessageQueryService`)는 그대로. 사용처 검증은 Task 2 Step 6에서 `grep`으로 확인. +- **`MessageMetadata` 유틸리티**: SystemMessageService 외 사용처 없으면 Task 2 Step 6에서 삭제. 사용처 있으면 그대로 둔다. +- **presence 토픽 비변경**: nickname/profileImageUrl은 `/topic/rooms/{id}/presence`에서 그대로 흘러간다. 본 작업 비목표. +- **이벤트 객체 비변경**: `MemberJoinedEvent`/`MemberLeftEvent`/`MemberKickedEvent`/`HostDelegatedEvent`의 nickname/profileImageUrl/previousHostNickname/newHostNickname 필드는 그대로 둔다. 본 작업 비목표. + +--- + +## 비목표 + +- 기존 MongoDB 문서 마이그레이션 (컬렉션 초기화 상태 전제). +- presence 토픽(`/topic/rooms/{id}/presence`)의 nickname/profileImageUrl 정리. +- 도메인 이벤트 4종 자체의 필드 정리. +- 다국어 처리(클라이언트 책임). +- CHAT/AI_REQUEST/AI_RESPONSE/PLACE_SHARE의 저장/전송 구조 변경. diff --git a/docs/superpowers/specs/2026-06-08-mongo-message-payload-design.md b/docs/superpowers/specs/2026-06-08-mongo-message-payload-design.md new file mode 100644 index 00000000..dba0761f --- /dev/null +++ b/docs/superpowers/specs/2026-06-08-mongo-message-payload-design.md @@ -0,0 +1,159 @@ +# SYSTEM 메시지 MongoDB 저장 페이로드 정리 + +- **상태**: 구현 완료 +- **날짜**: 2026-06-08 +- **관련 ADR**: + - 교체 대상: `docs/ai/decisions/20260607-1636-system-message-metadata-rendering.md` + - 신규 ADR: `docs/ai/decisions/20260608-1553-system-message-payload-cleanup.md` +- **워크트리/브랜치**: `.worktrees/refactor-mongo-message-payload` / `refactor/mongo-message-payload` + +## 배경 + +MongoDB `messages` 컬렉션의 SYSTEM 메시지(`MEMBER_JOINED`, `MEMBER_LEFT`, `MEMBER_KICKED`, `HOST_DELEGATED`)는 사람이 읽을 수 있는 `content`("박주영님이 방을 나갔습니다")와 metadata의 `nickname`/`profileImageUrl`/`previousHostNickname`/`newHostNickname`을 함께 저장하고 있다. 이 값은 저장 시점 스냅샷이라 회원 탈퇴·닉네임 변경 후에는 굳은 표시 데이터로 남는다. + +기존 ADR(`20260607-1636`)은 "백엔드 데이터 변경 없음, 클라이언트가 metadata 기반으로 재렌더링"이라는 보수적 결정을 내렸다. 그 결정에서는 content를 legacy 클라이언트와 디버깅용 fallback으로 의도적으로 유지했다. + +이번 작업의 전제는 두 가지다. + +1. MongoDB `messages` 컬렉션이 초기화 상태라 마이그레이션 비용이 없다. +2. 프론트엔드가 `messageType` + `metadata.eventType`으로 분기하고 방 진입 시 받은 멤버 데이터로 lookup하는 흐름으로 이미 구현되어 있다 (또는 그 방향으로 합의된다). + +전제 (1) 덕분에 fallback content를 굳이 보존할 필요가 없고, 전제 (2) 덕분에 표시 책임을 클라이언트로 일원화할 수 있다. + +## 목표 + +- SYSTEM 메시지의 MongoDB 문서·STOMP 페이로드·REST 응답에서 굳은 표시 데이터(`content`, `metadata.nickname`, `metadata.profileImageUrl`, `metadata.previousHostNickname`, `metadata.newHostNickname`)를 **키 자체로 남기지 않는다**. +- 표시 문구 조립 책임을 클라이언트로 일원화한다. 백엔드는 식별자(`eventType` + 관련 `userId`)만 보낸다. +- CHAT/AI_REQUEST/AI_RESPONSE/PLACE_SHARE 메시지의 저장/전송 구조는 변경하지 않는다. + +## 비목표 + +- 기존 MongoDB 문서 마이그레이션 (컬렉션 초기화 상태로 가정). +- presence 토픽(`RoomPresenceChangedEvent` → `/topic/rooms/{id}/presence`)의 nickname/profileImageUrl 사용 변경. +- 도메인 이벤트(`MemberJoinedEvent` 외 3종) 자체의 필드 정리. +- 다국어 처리. + +## 변경 사항 + +### 1. 도메인 모델 (`ChatMessage`) + +`ChatMessage.system()` 팩토리 시그니처에서 `content` 인자를 제거한다. + +```java +// before +public static ChatMessage system(UUID roomId, String content, Map metadata) { + return new ChatMessage(roomId, null, MessageType.SYSTEM, content, metadata, Instant.now()); +} + +// after +public static ChatMessage system(UUID roomId, Map metadata) { + return new ChatMessage(roomId, null, MessageType.SYSTEM, null, metadata, Instant.now()); +} +``` + +다른 팩토리(`chat`, `aiRequest`, `aiResponse`, `placeShare`)와 인스턴스 필드는 변경하지 않는다. `content` 필드는 다른 MessageType이 본질 데이터로 사용한다. + +Spring Data MongoDB의 `MappingMongoConverter`는 엔티티의 null 필드를 BSON에 쓰지 않으므로, 저장된 SYSTEM 문서에는 `content` 키가 생기지 않는다. + +### 2. `SystemMessageService` + +4개 메서드의 시그니처에서 nickname·profileImageUrl·previousHostNickname·newHostNickname 인자를 제거한다. metadata 맵에도 해당 키를 넣지 않는다. `MessageContentValidator.normalizeContent`로 닉네임을 정규화하던 로직도 제거한다. + +```java +public MessageResult sendMemberJoinedSystemMessage(UUID roomId, long joinedUserId) { + Map metadata = Map.of( + "eventType", "MEMBER_JOINED", + "userId", joinedUserId + ); + return saveSystemMessage(roomId, metadata); +} + +public MessageResult sendMemberLeftSystemMessage(UUID roomId, long leftUserId) { ... "MEMBER_LEFT" ... } +public MessageResult sendMemberKickedSystemMessage(UUID roomId, long kickedUserId) { ... "MEMBER_KICKED" ... } + +public MessageResult sendHostDelegatedSystemMessage(UUID roomId, + long previousHostUserId, + long newHostUserId) { + Map metadata = Map.of( + "eventType", "HOST_DELEGATED", + "previousHostUserId", previousHostUserId, + "newHostUserId", newHostUserId + ); + return saveSystemMessage(roomId, metadata); +} + +private MessageResult saveSystemMessage(UUID roomId, Map metadata) { + ChatMessage message = ChatMessage.system(roomId, metadata); + ChatMessage savedMessage = messageSequenceAllocator.saveWithNextSequence(message); + MessageResult result = MessageResult.from(savedMessage); + messagePublisher.publishMessageSent(result); + return result; +} +``` + +`MessageContentValidator` 의존성은 SystemMessageService에서 더 이상 필요 없으면 주입에서 제거한다(다른 메서드가 쓰면 유지). + +### 3. `MessageService` 위임 메서드 + +4개 메서드의 시그니처를 SystemMessageService와 동일하게 줄인다. + +### 4. 리스너 4종 + +- `MemberApprovedMessageListener`, `MemberLeftMessageListener`, `MemberKickedMessageListener`, `HostDelegatedMessageListener`에서 `messageService.sendXxxSystemMessage(...)` 호출 시 nickname/profileImageUrl/previousHostNickname/newHostNickname 인자를 제거한다. +- 같은 리스너에서 발행하는 `RoomPresenceChangedEvent`는 그대로 둔다(별도 토픽). +- 이벤트 클래스(`MemberJoinedEvent`/`MemberLeftEvent`/`MemberKickedEvent`/`HostDelegatedEvent`)는 변경하지 않는다. + +### 5. API DTO + +`MessagePayload.content`, `MessageResponse.content` 필드에 `@JsonInclude(JsonInclude.Include.NON_NULL)`를 적용한다. SYSTEM 메시지에서 `content`가 null이면 JSON 응답에 키가 포함되지 않는다. + +`@Schema` 설명을 다음과 같이 갱신한다. + +- `content`: "사람이 읽을 수 있는 메시지 본문. CHAT/AI_REQUEST/AI_RESPONSE/PLACE_SHARE에서만 제공됩니다. SYSTEM 메시지에서는 응답에 포함되지 않습니다." +- `metadata` 중 SYSTEM 항목: + - `SYSTEM MEMBER_JOINED/MEMBER_LEFT/MEMBER_KICKED`: `{ eventType, userId }` + - `SYSTEM HOST_DELEGATED`: `{ eventType, previousHostUserId, newHostUserId }` + - 보강 설명: "SYSTEM 메시지의 표시 문구는 metadata.eventType과 userId(또는 previousHostUserId/newHostUserId)로 클라이언트가 멤버 맵에서 lookup해 조립합니다. 닉네임/프로필 이미지는 응답에 포함되지 않습니다." + +### 6. 데이터 흐름 (MEMBER_LEFT 예시) + +1. RoomService → `MemberLeftEvent(roomId, leftUserId, nickname, profileImageUrl)` 발행. +2. `MemberLeftMessageListener`: + - presence 토픽 갱신을 위해 `RoomPresenceChangedEvent`는 기존 인자 그대로 발행. + - 시스템 메시지 호출: `messageService.sendMemberLeftSystemMessage(roomId, leftUserId)` (인자 2개). +3. `SystemMessageService` → metadata `{eventType: "MEMBER_LEFT", userId}`로 저장. content는 null. +4. MongoDB 문서: `{_id, roomId, sequence, senderId:null, messageType:"SYSTEM", metadata:{eventType, userId}, createdAt}` (`content` 키 없음). +5. STOMP/REST 응답 JSON: `content` 키 없음, metadata에 nickname/profileImageUrl 없음. +6. 클라이언트: `messageType === "SYSTEM"` && `metadata.eventType === "MEMBER_LEFT"` → 멤버 맵에서 `userId` lookup해 "{닉}님이 방을 나갔습니다" 조립. lookup 실패 시 `(알 수 없음)`. + +## 테스트 + +| 파일 | 변경 | +|---|---| +| `SystemMessageServiceTest` | 4개 케이스 호출 인자를 축소. 검증: `ChatMessage.content == null`, metadata 키는 `eventType`/`userId`(HOST는 previous/new HostUserId)만, nickname/profileImageUrl 키 없음. | +| `MessageServiceTest`의 SYSTEM 위임 케이스 | 동일하게 인자 축소 및 검증. | +| 4개 리스너 테스트 | `sendXxxSystemMessage` mock verify를 축소된 시그니처로 갱신. `RoomPresenceChangedEvent` 발행 검증은 그대로 유지. | +| (추가) MongoDB 저장 가드 테스트 | mock 기반 `SystemMessageServiceTest`로는 BSON 직렬화를 검증할 수 없으므로 `@DataMongoTest`(또는 기존 `messages` 패키지의 통합 테스트 슬라이스)에서 SYSTEM 메시지를 저장한 뒤 `mongoTemplate.getCollection("messages").find(...)`로 raw `Document`를 받아 `content` 키 부재와 metadata 키 집합을 직접 검증한다. Spring Data MongoDB null skip 동작 회귀 방지. | + +## 문서/ADR + +- **신규 ADR**: `docs/ai/decisions/20260608-1553-system-message-payload-cleanup.md` + - 상태: 결정 + - 배경: 기존 ADR의 "백엔드 변경 없음" 결정과 컬렉션 초기화 기회. + - 결정: SYSTEM 메시지는 metadata 식별자만 저장/전송, content는 저장도 응답도 하지 않는다. + - 영향: API 스키마 갱신, 마이그레이션 없음, 클라이언트는 기존 metadata 렌더링 계약 그대로 사용. +- **기존 ADR 갱신**: `20260607-1636-system-message-metadata-rendering.md` 상단 `상태`를 `결정` → `교체됨 (→ 20260608-...-system-message-payload-cleanup.md)`으로 변경. 본문은 이력 보존을 위해 그대로 둔다. +- **`docs/ai/features.md`**: SYSTEM 메시지 페이로드 설명을 본 spec과 일치하도록 갱신. +- **plan**: 본 spec 승인 후 writing-plans 스킬로 `docs/superpowers/plans/2026-06-08-mongo-message-payload.md` 작성. + +## 위험 / 가드 + +- **Spring Data MongoDB 동작 회귀**: null 필드가 BSON에 포함되지 않는 기본 동작은 보장이지만, 향후 `MongoMappingContext` 설정이 바뀌면 깨질 수 있다. BSON 키 부재를 직접 검증하는 테스트로 가드한다. +- **프론트 합의**: 프론트가 metadata.eventType + userId 기반 분기를 이미 구현했는지 별도 확인 필요. content fallback이 사라지므로 미구현 상태에서 배포하면 SYSTEM 메시지가 빈 줄로 보일 수 있다. 본 작업과 프론트 작업의 배포 순서를 합의한다. +- **presence 토픽 비변경**: nickname/profileImageUrl은 presence 토픽에서 그대로 흘러간다. 그쪽까지 정리하려면 별도 작업. + +## 비목표 재확인 + +- CHAT/AI/PLACE_SHARE 메시지 구조 변경 — 다음 기회. +- 다국어/번역 — 클라이언트 책임. +- 기존 SYSTEM 문서 마이그레이션 — 컬렉션 초기화로 비목표. diff --git a/src/main/java/com/howaboutus/backend/messages/controller/dto/MessageResponse.java b/src/main/java/com/howaboutus/backend/messages/controller/dto/MessageResponse.java index 0038fff3..993b48f5 100644 --- a/src/main/java/com/howaboutus/backend/messages/controller/dto/MessageResponse.java +++ b/src/main/java/com/howaboutus/backend/messages/controller/dto/MessageResponse.java @@ -4,6 +4,7 @@ import java.util.Map; import java.util.UUID; +import com.fasterxml.jackson.annotation.JsonInclude; import com.howaboutus.backend.messages.document.MessageType; import com.howaboutus.backend.messages.service.dto.MessageResult; @@ -15,13 +16,12 @@ public record MessageResponse( UUID roomId, Long senderId, MessageType messageType, + @JsonInclude(JsonInclude.Include.NON_NULL) @Schema(description = """ 사람이 읽을 수 있는 메시지 본문. - SYSTEM 메시지의 경우 저장 시점의 닉네임이 그대로 박혀 있는 fallback 문장입니다. - 탈퇴·닉네임 변경 이후에도 갱신되지 않으므로, 클라이언트는 SYSTEM 메시지에 한해 - metadata.eventType + userId를 기준으로 현재 멤버 정보를 lookup하여 본문을 재구성하는 것을 - 권장합니다. 멤버 목록에 없는 userId(탈퇴/방 나감)는 "(알 수 없음)"으로 표시합니다.""") + CHAT/AI_REQUEST/AI_RESPONSE/PLACE_SHARE에서만 제공됩니다. + SYSTEM 메시지에서는 응답에 포함되지 않습니다(키 자체가 빠집니다).""") String content, @Schema(description = """ 상위 messageType에 따라 구조가 달라지는 확장 데이터입니다. @@ -35,16 +35,12 @@ public record MessageResponse( lat?, lng?, primary_type?, rating?, user_rating_count?, reason, google_maps_uri? }] } - PLACE_SHARE: { googlePlaceId: string, name: string, formattedAddress?: string, latitude?: number, longitude?: number, rating?: number, photoName?: string } - - SYSTEM MEMBER_JOINED/MEMBER_LEFT/MEMBER_KICKED: { eventType, userId, nickname, - profileImageUrl? } - - SYSTEM HOST_DELEGATED: { eventType, previousHostUserId, previousHostNickname, - newHostUserId, newHostNickname } + - SYSTEM MEMBER_JOINED/MEMBER_LEFT/MEMBER_KICKED: { eventType, userId } + - SYSTEM HOST_DELEGATED: { eventType, previousHostUserId, newHostUserId } - SYSTEM 메시지의 metadata는 클라이언트 재렌더링 계약입니다. content는 저장 시점의 - 닉네임이 박힌 fallback이고, 화면 표시 문구는 metadata의 userId를 현재 방 멤버 정보로 - lookup하여 조립합니다. - - nullable 표기된 optional 필드는 서버에서 null로 보내지 않고 metadata에서 생략됩니다.""") + SYSTEM 메시지의 표시 문구는 metadata.eventType과 userId + (또는 previousHostUserId/newHostUserId)로 클라이언트가 멤버 맵에서 lookup해 조립합니다. + 닉네임/프로필 이미지는 응답에 포함되지 않습니다.""") Map metadata, Instant createdAt ) { diff --git a/src/main/java/com/howaboutus/backend/messages/document/ChatMessage.java b/src/main/java/com/howaboutus/backend/messages/document/ChatMessage.java index c1d4aacc..cda6a97e 100644 --- a/src/main/java/com/howaboutus/backend/messages/document/ChatMessage.java +++ b/src/main/java/com/howaboutus/backend/messages/document/ChatMessage.java @@ -68,8 +68,8 @@ public static ChatMessage placeShare(UUID roomId, Long senderId, String content, return new ChatMessage(roomId, senderId, MessageType.PLACE_SHARE, content, metadata, Instant.now()); } - public static ChatMessage system(UUID roomId, String content, Map metadata) { - return new ChatMessage(roomId, null, MessageType.SYSTEM, content, metadata, Instant.now()); + public static ChatMessage system(UUID roomId, Map metadata) { + return new ChatMessage(roomId, null, MessageType.SYSTEM, null, metadata, Instant.now()); } public void assignSequence(long sequence) { diff --git a/src/main/java/com/howaboutus/backend/messages/listener/HostDelegatedMessageListener.java b/src/main/java/com/howaboutus/backend/messages/listener/HostDelegatedMessageListener.java index b739be1c..3536f8e0 100644 --- a/src/main/java/com/howaboutus/backend/messages/listener/HostDelegatedMessageListener.java +++ b/src/main/java/com/howaboutus/backend/messages/listener/HostDelegatedMessageListener.java @@ -22,8 +22,6 @@ public void handle(HostDelegatedEvent event) { messageService.sendHostDelegatedSystemMessage( event.roomId(), event.previousHostUserId(), - event.previousHostNickname(), - event.newHostUserId(), - event.newHostNickname()); + event.newHostUserId()); } } diff --git a/src/main/java/com/howaboutus/backend/messages/listener/MemberApprovedMessageListener.java b/src/main/java/com/howaboutus/backend/messages/listener/MemberApprovedMessageListener.java index e93ba55e..59d7b2e8 100644 --- a/src/main/java/com/howaboutus/backend/messages/listener/MemberApprovedMessageListener.java +++ b/src/main/java/com/howaboutus/backend/messages/listener/MemberApprovedMessageListener.java @@ -17,11 +17,6 @@ public class MemberApprovedMessageListener { @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) public void handle(MemberApprovedEvent event) { - messageService.sendMemberJoinedSystemMessage( - event.roomId(), - event.joinedUserId(), - event.nickname(), - event.profileImageUrl() - ); + messageService.sendMemberJoinedSystemMessage(event.roomId(), event.joinedUserId()); } } diff --git a/src/main/java/com/howaboutus/backend/messages/listener/MemberKickedMessageListener.java b/src/main/java/com/howaboutus/backend/messages/listener/MemberKickedMessageListener.java index 5db5af5e..6fa27a79 100644 --- a/src/main/java/com/howaboutus/backend/messages/listener/MemberKickedMessageListener.java +++ b/src/main/java/com/howaboutus/backend/messages/listener/MemberKickedMessageListener.java @@ -50,9 +50,7 @@ public void handle(MemberKickedEvent event) { RoomPresenceEventType.USER_DISCONNECTED, event.nickname(), event.profileImageUrl())); //3. 추방 시스템 메시지 전송 - messageService.sendMemberKickedSystemMessage( - event.roomId(), event.kickedUserId(), - event.nickname(), event.profileImageUrl()); + messageService.sendMemberKickedSystemMessage(event.roomId(), event.kickedUserId()); //4. 추방 당사자에게 개인 알림 전송 userRoomActionBroadcaster.sendToUser( event.kickedUserId(), diff --git a/src/main/java/com/howaboutus/backend/messages/listener/MemberLeftMessageListener.java b/src/main/java/com/howaboutus/backend/messages/listener/MemberLeftMessageListener.java index 6bcff40f..188e8115 100644 --- a/src/main/java/com/howaboutus/backend/messages/listener/MemberLeftMessageListener.java +++ b/src/main/java/com/howaboutus/backend/messages/listener/MemberLeftMessageListener.java @@ -32,9 +32,7 @@ public void handle(MemberLeftEvent event) { event.roomId(), event.leftUserId(), RoomPresenceEventType.USER_DISCONNECTED, event.nickname(), event.profileImageUrl())); - messageService.sendMemberLeftSystemMessage( - event.roomId(), event.leftUserId(), - event.nickname(), event.profileImageUrl()); + messageService.sendMemberLeftSystemMessage(event.roomId(), event.leftUserId()); } private void removePresenceSafe(UUID roomId, long userId) { diff --git a/src/main/java/com/howaboutus/backend/messages/service/MessageService.java b/src/main/java/com/howaboutus/backend/messages/service/MessageService.java index f5e3d889..05e48bce 100644 --- a/src/main/java/com/howaboutus/backend/messages/service/MessageService.java +++ b/src/main/java/com/howaboutus/backend/messages/service/MessageService.java @@ -50,39 +50,22 @@ public MessageResult sendAiResponse(UUID roomId, SendAiResponseCommand command) return messageCommandService.sendAiResponse(roomId, command); } - public MessageResult sendMemberJoinedSystemMessage(UUID roomId, - long joinedUserId, - String nickname, - String profileImageUrl) { - return systemMessageService.sendMemberJoinedSystemMessage(roomId, joinedUserId, nickname, profileImageUrl); + public MessageResult sendMemberJoinedSystemMessage(UUID roomId, long joinedUserId) { + return systemMessageService.sendMemberJoinedSystemMessage(roomId, joinedUserId); } - public void sendMemberKickedSystemMessage(UUID roomId, - long kickedUserId, - String nickname, - String profileImageUrl) { - systemMessageService.sendMemberKickedSystemMessage(roomId, kickedUserId, nickname, profileImageUrl); + public void sendMemberKickedSystemMessage(UUID roomId, long kickedUserId) { + systemMessageService.sendMemberKickedSystemMessage(roomId, kickedUserId); } - public void sendMemberLeftSystemMessage(UUID roomId, - long leftUserId, - String nickname, - String profileImageUrl) { - systemMessageService.sendMemberLeftSystemMessage(roomId, leftUserId, nickname, profileImageUrl); + public void sendMemberLeftSystemMessage(UUID roomId, long leftUserId) { + systemMessageService.sendMemberLeftSystemMessage(roomId, leftUserId); } public void sendHostDelegatedSystemMessage(UUID roomId, long previousHostUserId, - String previousHostNickname, - long newHostUserId, - String newHostNickname) { - systemMessageService.sendHostDelegatedSystemMessage( - roomId, - previousHostUserId, - previousHostNickname, - newHostUserId, - newHostNickname - ); + long newHostUserId) { + systemMessageService.sendHostDelegatedSystemMessage(roomId, previousHostUserId, newHostUserId); } public List getMessagesAfter(UUID roomId, String afterId, long userId, int size) { diff --git a/src/main/java/com/howaboutus/backend/messages/service/SystemMessageService.java b/src/main/java/com/howaboutus/backend/messages/service/SystemMessageService.java index acf78620..7be0acd3 100644 --- a/src/main/java/com/howaboutus/backend/messages/service/SystemMessageService.java +++ b/src/main/java/com/howaboutus/backend/messages/service/SystemMessageService.java @@ -17,80 +17,48 @@ public class SystemMessageService { private final MessageSequenceAllocator messageSequenceAllocator; private final MessagePublisher messagePublisher; - private final MessageContentValidator messageContentValidator; @Loggable - public MessageResult sendMemberJoinedSystemMessage(UUID roomId, - long joinedUserId, - String nickname, - String profileImageUrl) { - String normalizedNickname = messageContentValidator.normalizeContent(nickname); - Map metadata = MessageMetadata.nonNull(MessageMetadata.entries( + public MessageResult sendMemberJoinedSystemMessage(UUID roomId, long joinedUserId) { + Map metadata = Map.of( "eventType", "MEMBER_JOINED", - "userId", joinedUserId, - "nickname", normalizedNickname, - "profileImageUrl", profileImageUrl - )); - - return saveSystemMessage(roomId, normalizedNickname + "님이 방에 참여했습니다", metadata); + "userId", joinedUserId + ); + return saveSystemMessage(roomId, metadata); } @Loggable - public MessageResult sendMemberKickedSystemMessage(UUID roomId, - long kickedUserId, - String nickname, - String profileImageUrl) { - String normalizedNickname = messageContentValidator.normalizeContent(nickname); - Map metadata = MessageMetadata.nonNull(MessageMetadata.entries( + public MessageResult sendMemberKickedSystemMessage(UUID roomId, long kickedUserId) { + Map metadata = Map.of( "eventType", "MEMBER_KICKED", - "userId", kickedUserId, - "nickname", normalizedNickname, - "profileImageUrl", profileImageUrl - )); - - return saveSystemMessage(roomId, normalizedNickname + "님이 방에서 내보내졌습니다", metadata); + "userId", kickedUserId + ); + return saveSystemMessage(roomId, metadata); } @Loggable - public MessageResult sendMemberLeftSystemMessage(UUID roomId, - long leftUserId, - String nickname, - String profileImageUrl) { - String normalizedNickname = messageContentValidator.normalizeContent(nickname); - Map metadata = MessageMetadata.nonNull(MessageMetadata.entries( + public MessageResult sendMemberLeftSystemMessage(UUID roomId, long leftUserId) { + Map metadata = Map.of( "eventType", "MEMBER_LEFT", - "userId", leftUserId, - "nickname", normalizedNickname, - "profileImageUrl", profileImageUrl - )); - - return saveSystemMessage(roomId, normalizedNickname + "님이 방을 나갔습니다", metadata); + "userId", leftUserId + ); + return saveSystemMessage(roomId, metadata); } @Loggable public MessageResult sendHostDelegatedSystemMessage(UUID roomId, long previousHostUserId, - String previousHostNickname, - long newHostUserId, - String newHostNickname) { - String normalizedPrevNickname = messageContentValidator.normalizeContent(previousHostNickname); - String normalizedNewNickname = messageContentValidator.normalizeContent(newHostNickname); - Map metadata = MessageMetadata.nonNull(MessageMetadata.entries( + long newHostUserId) { + Map metadata = Map.of( "eventType", "HOST_DELEGATED", "previousHostUserId", previousHostUserId, - "previousHostNickname", normalizedPrevNickname, - "newHostUserId", newHostUserId, - "newHostNickname", normalizedNewNickname - )); - - return saveSystemMessage( - roomId, - normalizedPrevNickname + "님이 " + normalizedNewNickname + "님에게 방장을 위임했습니다", - metadata); + "newHostUserId", newHostUserId + ); + return saveSystemMessage(roomId, metadata); } - private MessageResult saveSystemMessage(UUID roomId, String content, Map metadata) { - ChatMessage message = ChatMessage.system(roomId, content, metadata); + private MessageResult saveSystemMessage(UUID roomId, Map metadata) { + ChatMessage message = ChatMessage.system(roomId, metadata); ChatMessage savedMessage = messageSequenceAllocator.saveWithNextSequence(message); MessageResult result = MessageResult.from(savedMessage); messagePublisher.publishMessageSent(result); diff --git a/src/main/java/com/howaboutus/backend/realtime/service/dto/MessagePayload.java b/src/main/java/com/howaboutus/backend/realtime/service/dto/MessagePayload.java index 782dc266..ba85b266 100644 --- a/src/main/java/com/howaboutus/backend/realtime/service/dto/MessagePayload.java +++ b/src/main/java/com/howaboutus/backend/realtime/service/dto/MessagePayload.java @@ -4,6 +4,7 @@ import java.util.Map; import java.util.UUID; +import com.fasterxml.jackson.annotation.JsonInclude; import com.howaboutus.backend.messages.document.MessageType; import com.howaboutus.backend.realtime.event.MessageSentEvent; @@ -16,13 +17,12 @@ public record MessagePayload( UUID roomId, Long senderId, MessageType messageType, + @JsonInclude(JsonInclude.Include.NON_NULL) @Schema(description = """ 사람이 읽을 수 있는 메시지 본문. - SYSTEM 메시지의 경우 저장 시점의 닉네임이 그대로 박혀 있는 fallback 문장입니다. - 탈퇴·닉네임 변경 이후에도 갱신되지 않으므로, 클라이언트는 SYSTEM 메시지에 한해 - metadata.eventType + userId를 기준으로 현재 멤버 정보를 lookup하여 본문을 재구성하는 것을 - 권장합니다. 멤버 목록에 없는 userId(탈퇴/방 나감)는 "(알 수 없음)"으로 표시합니다.""") + CHAT/AI_REQUEST/AI_RESPONSE/PLACE_SHARE에서만 제공됩니다. + SYSTEM 메시지에서는 응답에 포함되지 않습니다(키 자체가 빠집니다).""") String content, @Schema(description = """ 상위 messageType에 따라 구조가 달라지는 확장 데이터입니다. @@ -36,16 +36,12 @@ public record MessagePayload( lat?, lng?, primary_type?, reason, google_maps_uri? }] } - PLACE_SHARE: { googlePlaceId: string, name: string, formattedAddress?: string, latitude?: number, longitude?: number, rating?: number, photoName?: string } - - SYSTEM MEMBER_JOINED/MEMBER_LEFT/MEMBER_KICKED: { eventType, userId, nickname, - profileImageUrl? } - - SYSTEM HOST_DELEGATED: { eventType, previousHostUserId, previousHostNickname, - newHostUserId, newHostNickname } + - SYSTEM MEMBER_JOINED/MEMBER_LEFT/MEMBER_KICKED: { eventType, userId } + - SYSTEM HOST_DELEGATED: { eventType, previousHostUserId, newHostUserId } - SYSTEM 메시지의 metadata는 클라이언트 재렌더링 계약입니다. content는 저장 시점의 - 닉네임이 박힌 fallback이고, 화면 표시 문구는 metadata의 userId를 현재 방 멤버 정보로 - lookup하여 조립합니다. - - nullable 표기된 optional 필드는 서버에서 null로 보내지 않고 metadata에서 생략됩니다.""") + SYSTEM 메시지의 표시 문구는 metadata.eventType과 userId + (또는 previousHostUserId/newHostUserId)로 클라이언트가 멤버 맵에서 lookup해 조립합니다. + 닉네임/프로필 이미지는 응답에 포함되지 않습니다.""") Map metadata, Instant createdAt ) { diff --git a/src/main/java/com/howaboutus/backend/realtime/service/dto/RoomMemberPayload.java b/src/main/java/com/howaboutus/backend/realtime/service/dto/RoomMemberPayload.java index 4e52b925..f99471a2 100644 --- a/src/main/java/com/howaboutus/backend/realtime/service/dto/RoomMemberPayload.java +++ b/src/main/java/com/howaboutus/backend/realtime/service/dto/RoomMemberPayload.java @@ -4,6 +4,7 @@ import java.util.Map; import java.util.UUID; +import com.fasterxml.jackson.annotation.JsonInclude; import com.howaboutus.backend.realtime.event.MessageSentEvent; import io.swagger.v3.oas.annotations.media.Schema; @@ -14,13 +15,22 @@ public record RoomMemberPayload( String id, UUID roomId, RoomMemberEventType type, + @JsonInclude(JsonInclude.Include.NON_NULL) + @Schema(description = """ + 사람이 읽을 수 있는 메시지 본문. + + SYSTEM 멤버 이벤트에서는 응답에 포함되지 않습니다(키 자체가 빠집니다).""") String content, @Schema(description = """ 이벤트 타입별 메타데이터: - - MEMBER_JOINED: { eventType, userId, nickname, profileImageUrl } - - MEMBER_LEFT: { eventType, userId, nickname, profileImageUrl } - - MEMBER_KICKED: { eventType, userId, nickname, profileImageUrl } - - HOST_DELEGATED: { eventType, previousHostUserId, previousHostNickname, newHostUserId, newHostNickname }""") + - MEMBER_JOINED: { eventType, userId } + - MEMBER_LEFT: { eventType, userId } + - MEMBER_KICKED: { eventType, userId } + - HOST_DELEGATED: { eventType, previousHostUserId, newHostUserId } + + 표시 문구는 metadata.eventType과 userId + (또는 previousHostUserId/newHostUserId)로 클라이언트가 멤버 맵에서 lookup해 조립합니다. + 닉네임/프로필 이미지는 응답에 포함되지 않습니다.""") Map metadata, Instant createdAt ) { diff --git a/src/test/java/com/howaboutus/backend/ai/service/AiSummaryServiceTest.java b/src/test/java/com/howaboutus/backend/ai/service/AiSummaryServiceTest.java index aaacd0fb..f126e012 100644 --- a/src/test/java/com/howaboutus/backend/ai/service/AiSummaryServiceTest.java +++ b/src/test/java/com/howaboutus/backend/ai/service/AiSummaryServiceTest.java @@ -261,7 +261,7 @@ private ChatMessage message(UUID roomId, String id, Long senderId, MessageType m case AI_REQUEST -> ChatMessage.aiRequest(roomId, senderId, content, Map.of()); case AI_RESPONSE -> ChatMessage.aiResponse(roomId, content, Map.of()); case PLACE_SHARE -> ChatMessage.placeShare(roomId, senderId, content, Map.of()); - case SYSTEM -> ChatMessage.system(roomId, content, Map.of()); + case SYSTEM -> ChatMessage.system(roomId, Map.of()); case CHAT -> ChatMessage.chat(roomId, senderId, content); }; ReflectionTestUtils.setField(message, "id", id); diff --git a/src/test/java/com/howaboutus/backend/messages/controller/MessageControllerTest.java b/src/test/java/com/howaboutus/backend/messages/controller/MessageControllerTest.java index faa7d5d1..606ac5b7 100644 --- a/src/test/java/com/howaboutus/backend/messages/controller/MessageControllerTest.java +++ b/src/test/java/com/howaboutus/backend/messages/controller/MessageControllerTest.java @@ -38,6 +38,7 @@ class MessageControllerTest { private static final UUID ROOM_ID = UUID.fromString("11111111-1111-1111-1111-111111111111"); + private static final Instant FIXED_CREATED_AT = Instant.parse("2020-01-01T00:00:00Z"); private static final Long USER_ID = 1L; private static final String VALID_TOKEN = "valid-jwt"; @Autowired @@ -98,7 +99,7 @@ void getMessagesReturns400WhenBeforeIdAndAfterSequenceProvided() throws Exceptio void getMessagesRoutesToAfterSequenceWhenAfterSequenceProvided() throws Exception { MessageResult msg = new MessageResult( "msg-004", 124L, null, ROOM_ID, USER_ID, - MessageType.CHAT, "누락 복구", Map.of(), Instant.now()); + MessageType.CHAT, "누락 복구", Map.of(), FIXED_CREATED_AT); given(messageService.getMessagesAfterSequence(eq(ROOM_ID), eq(123L), eq(USER_ID), eq(50))) .willReturn(List.of(msg)); @@ -112,12 +113,47 @@ void getMessagesRoutesToAfterSequenceWhenAfterSequenceProvided() throws Exceptio verify(messageService).getMessagesAfterSequence(ROOM_ID, 123L, USER_ID, 50); } + @Test + @DisplayName("SYSTEM 메시지 조회 응답은 content 키를 포함하지 않고 식별자 metadata만 반환한다") + void getMessagesOmitsContentForSystemMessage() throws Exception { + MessageResult msg = new MessageResult( + "msg-system-001", + 125L, + null, + ROOM_ID, + null, + MessageType.SYSTEM, + null, + Map.of( + "eventType", "MEMBER_JOINED", + "userId", 3L + ), + FIXED_CREATED_AT + ); + given(messageService.getMessagesAfter(eq(ROOM_ID), eq("abc123"), eq(USER_ID), eq(50))) + .willReturn(List.of(msg)); + + mockMvc.perform(get("/rooms/{roomId}/messages", ROOM_ID) + .cookie(new Cookie("access_token", VALID_TOKEN)) + .param("afterId", "abc123")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value("msg-system-001")) + .andExpect(jsonPath("$[0].messageType").value("SYSTEM")) + .andExpect(jsonPath("$[0].content").doesNotExist()) + .andExpect(jsonPath("$[0].metadata.eventType").value("MEMBER_JOINED")) + .andExpect(jsonPath("$[0].metadata.userId").value(3)) + .andExpect(jsonPath("$[0].metadata.nickname").doesNotExist()) + .andExpect(jsonPath("$[0].metadata.profileImageUrl").doesNotExist()); + + verify(messageService).getMessagesAfter(ROOM_ID, "abc123", USER_ID, 50); + } + @Test @DisplayName("빈 문자열 afterSequence와 유효한 afterId는 afterId 라우팅으로 처리한다") void getMessagesIgnoresBlankAfterSequenceWhenAfterIdProvided() throws Exception { MessageResult msg = new MessageResult( "msg-005", 12L, null, ROOM_ID, USER_ID, - MessageType.CHAT, "기존 cursor", Map.of(), Instant.now()); + MessageType.CHAT, "기존 cursor", Map.of(), FIXED_CREATED_AT); given(messageService.getMessagesAfter(eq(ROOM_ID), eq("abc123"), eq(USER_ID), eq(50))) .willReturn(List.of(msg)); @@ -172,7 +208,7 @@ void getMessagesReturns400WhenAfterSequenceIsNegative() throws Exception { void getMessagesRoutesToBeforeWhenBeforeIdProvided() throws Exception { MessageResult msg = new MessageResult( "msg-001", 10L, null, ROOM_ID, USER_ID, - MessageType.CHAT, "안녕", Map.of(), Instant.now()); + MessageType.CHAT, "안녕", Map.of(), FIXED_CREATED_AT); given(messageService.getMessagesBefore(eq(ROOM_ID), eq("def456"), eq(USER_ID), eq(50))) .willReturn(List.of(msg)); @@ -191,7 +227,7 @@ void getMessagesRoutesToBeforeWhenBeforeIdProvided() throws Exception { void getMessagesRoutesToAfterWhenAfterIdProvided() throws Exception { MessageResult msg = new MessageResult( "msg-002", 11L, null, ROOM_ID, USER_ID, - MessageType.CHAT, "반가워", Map.of(), Instant.now()); + MessageType.CHAT, "반가워", Map.of(), FIXED_CREATED_AT); given(messageService.getMessagesAfter(eq(ROOM_ID), eq("abc123"), eq(USER_ID), eq(50))) .willReturn(List.of(msg)); @@ -210,7 +246,7 @@ void getMessagesRoutesToAfterWhenAfterIdProvided() throws Exception { void getMessagesIgnoresBlankAfterIdWhenBeforeIdProvided() throws Exception { MessageResult msg = new MessageResult( "msg-003", 9L, null, ROOM_ID, USER_ID, - MessageType.CHAT, "과거 메시지", Map.of(), Instant.now()); + MessageType.CHAT, "과거 메시지", Map.of(), FIXED_CREATED_AT); given(messageService.getMessagesBefore(eq(ROOM_ID), eq("def456"), eq(USER_ID), eq(50))) .willReturn(List.of(msg)); diff --git a/src/test/java/com/howaboutus/backend/messages/document/SystemMessagePayloadIntegrationTest.java b/src/test/java/com/howaboutus/backend/messages/document/SystemMessagePayloadIntegrationTest.java new file mode 100644 index 00000000..9f39ad50 --- /dev/null +++ b/src/test/java/com/howaboutus/backend/messages/document/SystemMessagePayloadIntegrationTest.java @@ -0,0 +1,135 @@ +package com.howaboutus.backend.messages.document; + +import static org.assertj.core.api.Assertions.*; + +import java.util.UUID; + +import org.bson.Document; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; + +import com.howaboutus.backend.messages.service.SystemMessageService; +import com.howaboutus.backend.support.BaseIntegrationTest; + +class SystemMessagePayloadIntegrationTest extends BaseIntegrationTest { + + @Autowired + private SystemMessageService systemMessageService; + + @Autowired + private MongoTemplate mongoTemplate; + + @BeforeEach + void setUp() { + cleanMessages(); + } + + @AfterEach + void cleanUp() { + cleanMessages(); + } + + @Test + @DisplayName("MEMBER_JOINED 시스템 메시지의 BSON 문서에는 content 키가 없고 metadata는 eventType/userId만 포함한다") + void memberJoinedSystemMessageBsonHasNoContentKey() { + UUID roomId = UUID.randomUUID(); + + systemMessageService.sendMemberJoinedSystemMessage(roomId, 7L); + + Document raw = findSystemMessage(roomId); + assertThat(raw).isNotNull(); + assertThat(raw.containsKey("content")).isFalse(); + + Document metadata = raw.get("metadata", Document.class); + assertThat(metadata).isNotNull(); + assertThat(metadata.keySet()).containsExactlyInAnyOrder("eventType", "userId"); + assertThat(metadata.getString("eventType")).isEqualTo("MEMBER_JOINED"); + assertThat(metadata.getLong("userId")).isEqualTo(7L); + } + + @Test + @DisplayName("MEMBER_LEFT 시스템 메시지의 BSON 문서에는 content 키가 없고 metadata는 eventType/userId만 포함한다") + void memberLeftSystemMessageBsonHasNoContentKey() { + UUID roomId = UUID.randomUUID(); + + systemMessageService.sendMemberLeftSystemMessage(roomId, 7L); + + Document raw = findSystemMessage(roomId); + assertThat(raw).isNotNull(); + assertThat(raw.containsKey("content")).isFalse(); + + Document metadata = raw.get("metadata", Document.class); + assertThat(metadata.keySet()).containsExactlyInAnyOrder("eventType", "userId"); + assertThat(metadata.getString("eventType")).isEqualTo("MEMBER_LEFT"); + assertThat(metadata.getLong("userId")).isEqualTo(7L); + } + + @Test + @DisplayName("MEMBER_KICKED 시스템 메시지의 BSON 문서에는 content 키가 없고 metadata는 eventType/userId만 포함한다") + void memberKickedSystemMessageBsonHasNoContentKey() { + UUID roomId = UUID.randomUUID(); + + systemMessageService.sendMemberKickedSystemMessage(roomId, 7L); + + Document raw = findSystemMessage(roomId); + assertThat(raw).isNotNull(); + assertThat(raw.containsKey("content")).isFalse(); + + Document metadata = raw.get("metadata", Document.class); + assertThat(metadata.keySet()).containsExactlyInAnyOrder("eventType", "userId"); + assertThat(metadata.getString("eventType")).isEqualTo("MEMBER_KICKED"); + assertThat(metadata.getLong("userId")).isEqualTo(7L); + } + + @Test + @DisplayName("HOST_DELEGATED 시스템 메시지의 BSON 문서에는 content 키가 없고 metadata는 식별자 3개만 포함한다") + void hostDelegatedSystemMessageBsonHasNoContentKey() { + UUID roomId = UUID.randomUUID(); + + systemMessageService.sendHostDelegatedSystemMessage(roomId, 1L, 2L); + + Document raw = findSystemMessage(roomId); + assertThat(raw).isNotNull(); + assertThat(raw.containsKey("content")).isFalse(); + + Document metadata = raw.get("metadata", Document.class); + assertThat(metadata.keySet()).containsExactlyInAnyOrder( + "eventType", "previousHostUserId", "newHostUserId"); + assertThat(metadata.getString("eventType")).isEqualTo("HOST_DELEGATED"); + assertThat(metadata.getLong("previousHostUserId")).isEqualTo(1L); + assertThat(metadata.getLong("newHostUserId")).isEqualTo(2L); + } + + @Test + @DisplayName("기준선: CHAT 메시지의 BSON 문서에는 content 키가 보존된다 (회귀 가드)") + void chatMessageBsonStillHasContentKey() { + UUID roomId = UUID.randomUUID(); + ChatMessage chat = ChatMessage.chat(roomId, 42L, "안녕"); + mongoTemplate.save(chat); + + Document raw = mongoTemplate.getCollection("messages") + .find(Query.query(Criteria.where("roomId").is(roomId) + .and("messageType").is("CHAT")).getQueryObject()) + .first(); + assertThat(raw).isNotNull(); + assertThat(raw.containsKey("content")).isTrue(); + assertThat(raw.getString("content")).isEqualTo("안녕"); + } + + private Document findSystemMessage(UUID roomId) { + return mongoTemplate.getCollection("messages") + .find(Query.query(Criteria.where("roomId").is(roomId) + .and("messageType").is("SYSTEM")).getQueryObject()) + .first(); + } + + private void cleanMessages() { + mongoTemplate.getCollection("messages").deleteMany(new Document()); + } +} diff --git a/src/test/java/com/howaboutus/backend/messages/listener/HostDelegatedMessageListenerTest.java b/src/test/java/com/howaboutus/backend/messages/listener/HostDelegatedMessageListenerTest.java index c070b913..fcca1af1 100644 --- a/src/test/java/com/howaboutus/backend/messages/listener/HostDelegatedMessageListenerTest.java +++ b/src/test/java/com/howaboutus/backend/messages/listener/HostDelegatedMessageListenerTest.java @@ -37,7 +37,6 @@ void handleSendsSystemMessage() { listener.handle(event); - then(messageService).should().sendHostDelegatedSystemMessage( - ROOM_ID, 1L, "호스트", 2L, "타겟"); + then(messageService).should().sendHostDelegatedSystemMessage(ROOM_ID, 1L, 2L); } } diff --git a/src/test/java/com/howaboutus/backend/messages/listener/MemberApprovedMessageListenerTest.java b/src/test/java/com/howaboutus/backend/messages/listener/MemberApprovedMessageListenerTest.java index a6baf661..3d157f11 100644 --- a/src/test/java/com/howaboutus/backend/messages/listener/MemberApprovedMessageListenerTest.java +++ b/src/test/java/com/howaboutus/backend/messages/listener/MemberApprovedMessageListenerTest.java @@ -31,6 +31,6 @@ void handleSendsSystemMessage() { listener.handle(event); - verify(messageService).sendMemberJoinedSystemMessage(roomId, 3L, "대기자", "https://example.com/p.png"); + verify(messageService).sendMemberJoinedSystemMessage(roomId, 3L); } } diff --git a/src/test/java/com/howaboutus/backend/messages/listener/MemberKickedMessageListenerTest.java b/src/test/java/com/howaboutus/backend/messages/listener/MemberKickedMessageListenerTest.java index a2c7155c..9b5fb917 100644 --- a/src/test/java/com/howaboutus/backend/messages/listener/MemberKickedMessageListenerTest.java +++ b/src/test/java/com/howaboutus/backend/messages/listener/MemberKickedMessageListenerTest.java @@ -63,8 +63,7 @@ void handleRemovesPresenceAndBroadcastsAndSendsSystemMessage() { assertThat(published.userId()).isEqualTo(2L); assertThat(published.type()).isEqualTo(RoomPresenceEventType.USER_DISCONNECTED); - then(messageService).should().sendMemberKickedSystemMessage( - ROOM_ID, 2L, "타겟", "https://img/target.jpg"); + then(messageService).should().sendMemberKickedSystemMessage(ROOM_ID, 2L); then(userRoomActionBroadcaster).should().sendToUser( 2L, @@ -97,8 +96,7 @@ void handleSendsMessageEvenWhenRedisFails() { listener.handle(event); - then(messageService).should().sendMemberKickedSystemMessage( - ROOM_ID, 2L, "타겟", "https://img/target.jpg"); + then(messageService).should().sendMemberKickedSystemMessage(ROOM_ID, 2L); then(userRoomActionBroadcaster).should().sendToUser( 2L, diff --git a/src/test/java/com/howaboutus/backend/messages/listener/MemberLeftMessageListenerTest.java b/src/test/java/com/howaboutus/backend/messages/listener/MemberLeftMessageListenerTest.java index c5877e4f..7ad751ce 100644 --- a/src/test/java/com/howaboutus/backend/messages/listener/MemberLeftMessageListenerTest.java +++ b/src/test/java/com/howaboutus/backend/messages/listener/MemberLeftMessageListenerTest.java @@ -56,8 +56,7 @@ void handleRemovesPresenceAndBroadcastsAndSendsSystemMessage() { assertThat(published.userId()).isEqualTo(2L); assertThat(published.type()).isEqualTo(RoomPresenceEventType.USER_DISCONNECTED); - then(messageService).should().sendMemberLeftSystemMessage( - ROOM_ID, 2L, "멤버", "https://img/member.jpg"); + then(messageService).should().sendMemberLeftSystemMessage(ROOM_ID, 2L); } @Test @@ -70,7 +69,6 @@ void handleSendsMessageEvenWhenRedisFails() { listener.handle(event); - then(messageService).should().sendMemberLeftSystemMessage( - ROOM_ID, 2L, "멤버", "https://img/member.jpg"); + then(messageService).should().sendMemberLeftSystemMessage(ROOM_ID, 2L); } } diff --git a/src/test/java/com/howaboutus/backend/messages/service/MessageServiceTest.java b/src/test/java/com/howaboutus/backend/messages/service/MessageServiceTest.java index 05e49768..7f576e0c 100644 --- a/src/test/java/com/howaboutus/backend/messages/service/MessageServiceTest.java +++ b/src/test/java/com/howaboutus/backend/messages/service/MessageServiceTest.java @@ -75,8 +75,7 @@ void setUp() { ); SystemMessageService systemMessageService = new SystemMessageService( messageSequenceAllocator, - messagePublisher, - messageContentValidator + messagePublisher ); MessageQueryService messageQueryService = new MessageQueryService( chatMessageRepository, @@ -340,7 +339,7 @@ void sendAiResponseStoresContentLongerThanUserMessageLimit() { } @Test - @DisplayName("멤버 입장 시스템 메시지는 senderId 없이 MongoDB에 저장할 수 있다") + @DisplayName("멤버 입장 시스템 메시지는 senderId/content 없이 MongoDB에 저장할 수 있다") void sendMemberJoinedSystemMessageStoresSystemMessage() { UUID roomId = UUID.randomUUID(); @@ -350,25 +349,21 @@ void sendMemberJoinedSystemMessageStoresSystemMessage() { return message; }); - MessageResult result = messageService.sendMemberJoinedSystemMessage( - roomId, - 7L, - "대기자", - "https://example.com/profile.png" - ); + MessageResult result = messageService.sendMemberJoinedSystemMessage(roomId, 7L); assertThat(result.senderId()).isNull(); assertThat(result.messageType()).isEqualTo(MessageType.SYSTEM); - assertThat(result.content()).isEqualTo("대기자님이 방에 참여했습니다"); + assertThat(result.content()).isNull(); assertThat(result.metadata()) - .containsEntry("eventType", "MEMBER_JOINED") - .containsEntry("userId", 7L) - .containsEntry("nickname", "대기자") - .containsEntry("profileImageUrl", "https://example.com/profile.png"); + .containsOnly( + entry("eventType", "MEMBER_JOINED"), + entry("userId", 7L) + ); ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(ChatMessage.class); verify(chatMessageRepository).save(messageCaptor.capture()); assertThat(messageCaptor.getValue().getMessageType()).isEqualTo(MessageType.SYSTEM); + assertThat(messageCaptor.getValue().getContent()).isNull(); verify(eventPublisher).publishEvent(MessageSentEvent.from(result)); } diff --git a/src/test/java/com/howaboutus/backend/messages/service/SystemMessageServiceTest.java b/src/test/java/com/howaboutus/backend/messages/service/SystemMessageServiceTest.java index 675dd3b7..d3d2ec7b 100644 --- a/src/test/java/com/howaboutus/backend/messages/service/SystemMessageServiceTest.java +++ b/src/test/java/com/howaboutus/backend/messages/service/SystemMessageServiceTest.java @@ -38,46 +38,103 @@ class SystemMessageServiceTest { @BeforeEach void setUp() { MessagePublisher messagePublisher = new MessagePublisher(eventPublisher); - MessageContentValidator messageContentValidator = new MessageContentValidator(); MessageSequenceAllocator messageSequenceAllocator = new MessageSequenceAllocator(chatMessageRepository); lenient().when(chatMessageRepository.findFirstByRoomIdOrderBySequenceDescIdDesc(any(UUID.class))) .thenReturn(Optional.empty()); - systemMessageService = new SystemMessageService( - messageSequenceAllocator, - messagePublisher, - messageContentValidator); + systemMessageService = new SystemMessageService(messageSequenceAllocator, messagePublisher); } @Test - @DisplayName("멤버 입장 시스템 메시지는 전용 서비스에서 생성하고 브로드캐스트한다") - void sendMemberJoinedSystemMessageStoresSystemMessage() { + @DisplayName("MEMBER_JOINED 시스템 메시지는 content 없이 eventType/userId만 저장한다") + void sendMemberJoinedSystemMessageStoresIdentifiersOnly() { UUID roomId = UUID.randomUUID(); - given(chatMessageRepository.save(any(ChatMessage.class))).willAnswer(invocation -> { - ChatMessage message = invocation.getArgument(0); - ReflectionTestUtils.setField(message, "id", "6628f5f4c49a9f7b3772c333"); - return message; - }); + givenSaveReturnsWithId("6628f5f4c49a9f7b3772c333"); - MessageResult result = systemMessageService.sendMemberJoinedSystemMessage( - roomId, - 7L, - " 대기자 ", - "https://example.com/profile.png" - ); + MessageResult result = systemMessageService.sendMemberJoinedSystemMessage(roomId, 7L); assertThat(result.senderId()).isNull(); assertThat(result.messageType()).isEqualTo(MessageType.SYSTEM); - assertThat(result.content()).isEqualTo("대기자님이 방에 참여했습니다"); + assertThat(result.content()).isNull(); assertThat(result.sequence()).isEqualTo(1L); assertThat(result.metadata()) - .containsEntry("eventType", "MEMBER_JOINED") - .containsEntry("userId", 7L) - .containsEntry("nickname", "대기자") - .containsEntry("profileImageUrl", "https://example.com/profile.png"); - - ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(ChatMessage.class); - verify(chatMessageRepository).save(messageCaptor.capture()); - assertThat(messageCaptor.getValue().getMessageType()).isEqualTo(MessageType.SYSTEM); + .containsOnly( + entry("eventType", "MEMBER_JOINED"), + entry("userId", 7L) + ); + + verifySavedSystemMessageHasNullContent(); verify(eventPublisher).publishEvent(MessageSentEvent.from(result)); } + + @Test + @DisplayName("MEMBER_LEFT 시스템 메시지는 content 없이 eventType/userId만 저장한다") + void sendMemberLeftSystemMessageStoresIdentifiersOnly() { + UUID roomId = UUID.randomUUID(); + givenSaveReturnsWithId("6628f5f4c49a9f7b3772c334"); + + MessageResult result = systemMessageService.sendMemberLeftSystemMessage(roomId, 7L); + + assertThat(result.content()).isNull(); + assertThat(result.metadata()) + .containsOnly( + entry("eventType", "MEMBER_LEFT"), + entry("userId", 7L) + ); + + verifySavedSystemMessageHasNullContent(); + } + + @Test + @DisplayName("MEMBER_KICKED 시스템 메시지는 content 없이 eventType/userId만 저장한다") + void sendMemberKickedSystemMessageStoresIdentifiersOnly() { + UUID roomId = UUID.randomUUID(); + givenSaveReturnsWithId("6628f5f4c49a9f7b3772c335"); + + MessageResult result = systemMessageService.sendMemberKickedSystemMessage(roomId, 7L); + + assertThat(result.content()).isNull(); + assertThat(result.metadata()) + .containsOnly( + entry("eventType", "MEMBER_KICKED"), + entry("userId", 7L) + ); + + verifySavedSystemMessageHasNullContent(); + } + + @Test + @DisplayName("HOST_DELEGATED 시스템 메시지는 content 없이 이전/새 호스트 userId만 저장한다") + void sendHostDelegatedSystemMessageStoresIdentifiersOnly() { + UUID roomId = UUID.randomUUID(); + givenSaveReturnsWithId("6628f5f4c49a9f7b3772c336"); + + MessageResult result = systemMessageService.sendHostDelegatedSystemMessage(roomId, 1L, 2L); + + assertThat(result.content()).isNull(); + assertThat(result.metadata()) + .containsOnly( + entry("eventType", "HOST_DELEGATED"), + entry("previousHostUserId", 1L), + entry("newHostUserId", 2L) + ); + + verifySavedSystemMessageHasNullContent(); + } + + private void givenSaveReturnsWithId(String id) { + given(chatMessageRepository.save(any(ChatMessage.class))).willAnswer(invocation -> { + ChatMessage message = invocation.getArgument(0); + ReflectionTestUtils.setField(message, "id", id); + return message; + }); + } + + private void verifySavedSystemMessageHasNullContent() { + ArgumentCaptor captor = ArgumentCaptor.forClass(ChatMessage.class); + verify(chatMessageRepository).save(captor.capture()); + ChatMessage saved = captor.getValue(); + assertThat(saved.getMessageType()).isEqualTo(MessageType.SYSTEM); + assertThat(saved.getContent()).isNull(); + assertThat(saved.getSenderId()).isNull(); + } } diff --git a/src/test/java/com/howaboutus/backend/realtime/service/RoomMemberBroadcasterTest.java b/src/test/java/com/howaboutus/backend/realtime/service/RoomMemberBroadcasterTest.java index f88e4ef7..84968461 100644 --- a/src/test/java/com/howaboutus/backend/realtime/service/RoomMemberBroadcasterTest.java +++ b/src/test/java/com/howaboutus/backend/realtime/service/RoomMemberBroadcasterTest.java @@ -17,6 +17,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.messaging.simp.SimpMessagingTemplate; +import com.fasterxml.jackson.databind.ObjectMapper; import com.howaboutus.backend.messages.document.MessageType; import com.howaboutus.backend.realtime.event.MessageSentEvent; import com.howaboutus.backend.realtime.service.dto.RoomMemberEventType; @@ -25,6 +26,8 @@ @ExtendWith(MockitoExtension.class) class RoomMemberBroadcasterTest { + private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules(); + @Mock private SimpMessagingTemplate messagingTemplate; @@ -37,7 +40,10 @@ void broadcastsSystemMessageToMembersTopic() { UUID roomId = UUID.randomUUID(); String id = "6628f5f4c49a9f7b3772c222"; Instant createdAt = Instant.parse("2026-05-04T00:00:00Z"); - Map metadata = Map.of("eventType", "MEMBER_JOINED"); + Map metadata = Map.of( + "eventType", "MEMBER_JOINED", + "userId", 3L + ); MessageSentEvent event = new MessageSentEvent( id, @@ -46,7 +52,7 @@ void broadcastsSystemMessageToMembersTopic() { roomId, null, MessageType.SYSTEM, - "홍길동 님이 입장했습니다.", + null, metadata, createdAt ); @@ -62,12 +68,30 @@ void broadcastsSystemMessageToMembersTopic() { id, roomId, RoomMemberEventType.MEMBER_JOINED, - "홍길동 님이 입장했습니다.", + null, metadata, createdAt )); } + @Test + @DisplayName("members topic SYSTEM payload는 content 키를 직렬화하지 않는다") + void systemPayloadJsonOmitsNullContent() throws Exception { + UUID roomId = UUID.randomUUID(); + RoomMemberPayload payload = new RoomMemberPayload( + "6628f5f4c49a9f7b3772c222", + roomId, + RoomMemberEventType.MEMBER_JOINED, + null, + Map.of("eventType", "MEMBER_JOINED", "userId", 3L), + Instant.parse("2026-05-04T00:00:00Z") + ); + + String json = objectMapper.writeValueAsString(payload); + + assertThat(json).doesNotContain("\"content\""); + } + @Test @DisplayName("SYSTEM이 아닌 메시지는 무시한다") void ignoresNonSystemMessage() { diff --git a/src/test/java/com/howaboutus/backend/realtime/service/RoomMessageBroadcasterTest.java b/src/test/java/com/howaboutus/backend/realtime/service/RoomMessageBroadcasterTest.java index ff1853cc..cf86e6ac 100644 --- a/src/test/java/com/howaboutus/backend/realtime/service/RoomMessageBroadcasterTest.java +++ b/src/test/java/com/howaboutus/backend/realtime/service/RoomMessageBroadcasterTest.java @@ -17,6 +17,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.messaging.simp.SimpMessagingTemplate; +import com.fasterxml.jackson.databind.ObjectMapper; import com.howaboutus.backend.common.error.ErrorCode; import com.howaboutus.backend.messages.document.MessageType; import com.howaboutus.backend.realtime.event.MessageRateLimitedEvent; @@ -29,6 +30,8 @@ @ExtendWith(MockitoExtension.class) class RoomMessageBroadcasterTest { + private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules(); + @Mock private SimpMessagingTemplate messagingTemplate; @@ -98,8 +101,12 @@ void sendsRateLimitedEventToUserQueue() { @Test @DisplayName("SYSTEM 메시지도 messages topic으로 브로드캐스트한다") - void broadcastsSystemMessage() { + void broadcastsSystemMessage() throws Exception { UUID roomId = UUID.randomUUID(); + Map metadata = Map.of( + "eventType", "MEMBER_JOINED", + "userId", 3L + ); MessageSentEvent event = new MessageSentEvent( "mongo-id-1", 2L, @@ -107,16 +114,21 @@ void broadcastsSystemMessage() { roomId, null, MessageType.SYSTEM, - "테스트님이 방에 참여했습니다", - Map.of("eventType", "MEMBER_JOINED"), + null, + metadata, Instant.parse("2026-05-04T00:00:00Z") ); broadcaster.handleMessageSent(event); + ArgumentCaptor payloadCaptor = ArgumentCaptor.forClass(MessagePayload.class); verify(messagingTemplate).convertAndSend( - "/topic/rooms/" + roomId + "/messages", - MessagePayload.from(event) + eq("/topic/rooms/" + roomId + "/messages"), + payloadCaptor.capture() ); + MessagePayload payload = payloadCaptor.getValue(); + assertThat(payload.content()).isNull(); + assertThat(payload.metadata()).isEqualTo(metadata); + assertThat(objectMapper.writeValueAsString(payload)).doesNotContain("\"content\""); } } From 9c1a8e7f83b49df616e0c0a00e67cd88a7258681 Mon Sep 17 00:00:00 2001 From: Minhyung Kim <127458006+minbros@users.noreply.github.com> Date: Mon, 8 Jun 2026 22:54:13 +0900 Subject: [PATCH 8/9] =?UTF-8?q?chore:=20=EC=9A=B4=EC=98=81=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=20PostgreSQL=20=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20=EC=99=B8=EB=B6=80=20DB=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=20=EC=84=A4=EC=A0=95=20=EB=B3=B4=EC=99=84=20?= =?UTF-8?q?(#139)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 운영 DB 서버에서 PostgreSQL 제거 및 exporter 외부 연동 재설정 * chore: 운영 데이터 저장소 설정 강화 * chore: Caddy 요청 본문 크기 제한 추가 * docs: PR 기준 브랜치 전략 명시 * chore: 로컬 PostgreSQL Docker 헬스체크 타이밍 이슈 수정 - pg_isready 명령어에 -h localhost -p 5432를 추가하여 TCP 5432 포트 바인딩 완료 여부까지 검증하도록 개선 - 설계 사양서(spec) 및 구현 계획서(plan) 추가 * feat: MongoDB 메시지 인덱스 마이그레이션 추가 * docs: MongoDB 마이그레이션 관리 규칙 추가 * docs: PostGIS 제거 설계 문서 추가 * docs: PostGIS 제거 구현 계획서 추가 * chore: build.gradle에서 hibernate-spatial 의존성 제거 * chore: 로컬 및 테스트용 PostgreSQL 이미지를 postgres:17로 교체 * docs: 문서 내 PostGIS 및 Spatial 기술 스택 언급 제거 * chore: compose.db.dev.yaml에서 불필요한 platform: linux/amd64 설정 제거 * chore: PostgreSQL 및 MongoDB 버전을 17.10 및 8.0.21로 상세 고정 --- .../skills/review-code-against-docs/SKILL.md | 28 +++- .env.db.example | 7 +- .github/workflows/deploy-compose.yml | 1 + .github/workflows/deploy-db.yml | 7 +- AGENTS.md | 11 +- CONTRIBUTING.md | 5 +- README.md | 4 +- build.gradle | 3 +- compose.db.dev.yaml | 7 +- compose.db.prod.yaml | 59 +++---- docs/ai/erd.md | 6 +- ...026-06-08-postgresql-docker-healthcheck.md | 89 +++++++++++ .../plans/2026-06-08-remove-postgis.md | 151 ++++++++++++++++++ ...08-postgresql-docker-healthcheck-design.md | 52 ++++++ .../specs/2026-06-08-remove-postgis-design.md | 92 +++++++++++ infra/caddy/Caddyfile | 4 + infra/monitoring/README.md | 15 +- scripts/mongo/backfill-message-sequence.js | 112 ------------- .../common/config/MongoMigrationConfig.java | 33 ++++ .../mongo/CreateMessageIndexesChangeUnit.java | 73 +++++++++ .../messages/document/ChatMessage.java | 1 + src/main/resources/application-prod.yaml | 21 ++- .../common/config/ProdDatabaseConfigTest.java | 92 +++++++++++ .../MongoMessageIndexMigrationTest.java | 52 ++++++ .../backend/support/BaseIntegrationTest.java | 5 +- 25 files changed, 752 insertions(+), 178 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-08-postgresql-docker-healthcheck.md create mode 100644 docs/superpowers/plans/2026-06-08-remove-postgis.md create mode 100644 docs/superpowers/specs/2026-06-08-postgresql-docker-healthcheck-design.md create mode 100644 docs/superpowers/specs/2026-06-08-remove-postgis-design.md delete mode 100644 scripts/mongo/backfill-message-sequence.js create mode 100644 src/main/java/com/howaboutus/backend/common/config/MongoMigrationConfig.java create mode 100644 src/main/java/com/howaboutus/backend/common/migration/mongo/CreateMessageIndexesChangeUnit.java create mode 100644 src/test/java/com/howaboutus/backend/common/config/ProdDatabaseConfigTest.java create mode 100644 src/test/java/com/howaboutus/backend/common/migration/MongoMessageIndexMigrationTest.java diff --git a/.claude/skills/review-code-against-docs/SKILL.md b/.claude/skills/review-code-against-docs/SKILL.md index df69498c..46a93282 100644 --- a/.claude/skills/review-code-against-docs/SKILL.md +++ b/.claude/skills/review-code-against-docs/SKILL.md @@ -11,12 +11,36 @@ description: Use when the user asks to create, open, prepare, or publish a PR/pu ## Checklist -### Step 0. diff 수집 +### Step 0. 기준 브랜치 결정 및 diff 수집 + +먼저 `CONTRIBUTING.md`의 Branch Strategy를 확인하고 PR 기준 브랜치를 결정한다. + +기준 브랜치 우선순위: + +1. 사용자가 PR 대상 브랜치를 명시했으면 그 값을 사용한다. +2. 이미 열린 PR이 있으면 `gh pr view --json baseRefName`의 `baseRefName`을 사용한다. +3. 현재 브랜치가 `feature/*`이면 `dev`를 사용한다. +4. 현재 브랜치가 `hotfix/*`이면 `main`을 사용한다. +5. 현재 브랜치가 `dev`이면 `main`을 사용한다. +6. 현재 브랜치가 `main`이면 `dev`를 사용한다. 이 경우는 hotfix 반영 후 `main` → `dev` 백머지 PR이다. +7. 판단할 수 없으면 사용자에게 PR 대상 브랜치를 확인한다. + +브랜치 전략: + +| 현재 브랜치 | PR 대상 | +|------|------| +| `feature/*` | `dev` | +| `hotfix/*` | `main` | +| `dev` | `main` | +| `main` | `dev` 백머지 | ```bash -git diff main --name-only +git fetch origin --quiet +git diff --name-only origin/...HEAD ``` +보고서에는 기준 브랜치, 현재 브랜치, diff 범위를 먼저 적는다. + 변경된 `.java` 파일 목록을 추출한다. 변경 파일이 없으면 "변경 없음"으로 종료한다. 변경된 파일을 두 그룹으로 분류한다: diff --git a/.env.db.example b/.env.db.example index 7f9bdfb7..c151ac01 100644 --- a/.env.db.example +++ b/.env.db.example @@ -1,4 +1,5 @@ -# PostgreSQL +# PostgreSQL (AWS Lightsail Managed DB) +DB_HOST=change-this-lightsail-db-endpoint DB_NAME=uttae DB_USER=prod DB_PASSWORD=change-this-db-password @@ -8,6 +9,10 @@ MONGO_USER=prod MONGO_PASSWORD=change-this-mongo-password MONGO_DB=uttae +# Redis +REDIS_PASSWORD=change-this-redis-password +REDIS_MAXMEMORY=384mb + # Private network bindings DB_PRIVATE_IP=10.0.0.13 diff --git a/.github/workflows/deploy-compose.yml b/.github/workflows/deploy-compose.yml index ecd0d687..2ed18cdc 100644 --- a/.github/workflows/deploy-compose.yml +++ b/.github/workflows/deploy-compose.yml @@ -108,6 +108,7 @@ jobs: grep '^APP_IMAGE=' "$DEPLOY_PATH/.env.prod" grep '^API_PRIVATE_IP=' "$DEPLOY_PATH/.env.prod" grep '^LOKI_PUSH_URL=' "$DEPLOY_PATH/.env.prod" + grep '^REDIS_PASSWORD=.' "$DEPLOY_PATH/.env.prod" docker compose --env-file "$DEPLOY_PATH/.env.prod" -f "$DEPLOY_PATH/compose.app.prod.yaml" config >/dev/null - name: Detect runtime config changes diff --git a/.github/workflows/deploy-db.yml b/.github/workflows/deploy-db.yml index 5298df82..2cc6a5f6 100644 --- a/.github/workflows/deploy-db.yml +++ b/.github/workflows/deploy-db.yml @@ -54,14 +54,15 @@ jobs: grep '^POSTGRES_EXPORTER_PORT=' .env.db grep '^REDIS_EXPORTER_PORT=' .env.db grep '^MONGODB_EXPORTER_PORT=' .env.db + grep '^REDIS_PASSWORD=.' .env.db docker compose --env-file .env.db -f compose.db.prod.yaml config >/dev/null - name: Deploy DB services and exporters run: | cd "$DEPLOY_PATH" - docker compose --env-file .env.db -f compose.db.prod.yaml pull postgres redis mongodb postgres-exporter redis-exporter mongodb-exporter node-exporter - docker compose --env-file .env.db -f compose.db.prod.yaml up -d --wait --wait-timeout "$DB_WAIT_TIMEOUT_SECONDS" postgres redis mongodb postgres-exporter redis-exporter mongodb-exporter node-exporter + docker compose --env-file .env.db -f compose.db.prod.yaml pull postgres-exporter redis mongodb redis-exporter mongodb-exporter node-exporter + docker compose --env-file .env.db -f compose.db.prod.yaml up -d --wait --wait-timeout "$DB_WAIT_TIMEOUT_SECONDS" postgres-exporter redis mongodb redis-exporter mongodb-exporter node-exporter docker compose --env-file .env.db -f compose.db.prod.yaml ps - name: Print DB logs on deploy failure @@ -69,7 +70,7 @@ jobs: run: | cd "$DEPLOY_PATH" docker compose --env-file .env.db -f compose.db.prod.yaml ps - docker compose --env-file .env.db -f compose.db.prod.yaml logs postgres redis mongodb postgres-exporter redis-exporter mongodb-exporter node-exporter --tail=200 + docker compose --env-file .env.db -f compose.db.prod.yaml logs postgres-exporter redis mongodb redis-exporter mongodb-exporter node-exporter --tail=200 - name: Prune unused Docker images if: always() diff --git a/AGENTS.md b/AGENTS.md index 8ec82e81..2becf389 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,12 +18,12 @@ ## Tech Stack - **Framework**: Spring Boot 4.0.5, Java 21 -- **Database**: PostgreSQL 17 + PostGIS 3.5, MongoDB 8 +- **Database**: PostgreSQL 17.10, MongoDB 8.0.21 - **Cache**: Redis 8 - **Auth**: Spring Security - **Realtime**: WebSocket + STOMP - **Build**: Gradle -- **기타**: Lombok, Spring Data JPA, Spring Data MongoDB, `hibernate-spatial` +- **기타**: Lombok, Spring Data JPA, Spring Data MongoDB ## Commands @@ -78,9 +78,10 @@ src/main/resources/ ## Gotchas -- PostgreSQL 이미지는 `postgis/postgis:17-3.5`를 사용한다. -- MongoDB 이미지는 `mongo:8`을 사용하며 채팅 메시지 저장소로 사용한다. -- 공간 데이터 엔티티에는 `hibernate-spatial` 타입을 사용한다. +- PostgreSQL 이미지는 `postgres:17.10`을 사용한다. +- MongoDB 이미지는 `mongo:8.0.21`을 사용하며 채팅 메시지 저장소로 사용한다. +- PostgreSQL 스키마 변경은 Flyway SQL 마이그레이션으로 관리한다. +- MongoDB 컬렉션/인덱스 변경은 Mongock ChangeUnit으로 관리한다. 변경 클래스는 `common/migration/mongo/` 아래에 두고, Spring Data 자동 인덱스 생성에 의존하지 않는다. - dev 환경의 PostgreSQL 포트는 `5433`이다. - `spring.jpa.open-in-view=false`가 설정되어 있다. - WebSocket + STOMP 사용 시 Spring Security 설정에서 WebSocket 엔드포인트를 별도 허용해야 한다. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8c591d4c..bf57f969 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,6 +20,8 @@ feature/ → dev → main - `main`에 직접 push하지 않는다. - `feature/*` 브랜치는 `dev`에서 분기하고 `dev`로 머지한다. - 브랜치는 작업 단위로 짧게 유지한다. +- `feature/*` 브랜치의 PR 대상은 `dev`다. +- `dev` 브랜치의 릴리스 PR 대상은 `main`이다. ### Hotfix 흐름 @@ -28,8 +30,9 @@ hotfix/ → main → dev (백머지) ``` - `hotfix/*` 브랜치는 `main`에서 직접 분기한다. +- `hotfix/*` 브랜치의 PR 대상은 `main`이다. - 수정 완료 후 `main`에 머지한다. -- 머지 후 동일 변경사항을 반드시 `dev`에도 백머지하여 이후 릴리스에서 누락되지 않도록 한다. +- 머지 후 `main`에서 `dev`로 백머지 PR을 올려 동일 변경사항이 이후 릴리스에서 누락되지 않도록 한다. ## Commit Convention diff --git a/README.md b/README.md index eb8bada2..01cb7008 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ - **Language**: Java 21 - **Framework**: Spring Boot 4.0.5 -- **Database**: PostgreSQL 17 + PostGIS 3.5, MongoDB 8 +- **Database**: PostgreSQL 17.10, MongoDB 8.0.21 - **Cache**: Redis 8 - **Auth**: Spring Security + JWT - **Realtime**: WebSocket + STOMP @@ -29,7 +29,7 @@ src/main/java/com/howaboutus/backend/ ├── auth/ ← 인증/인가 ├── bookmarks/ ← 북마크 ├── messages/ ← 채팅 메시지 (MongoDB) -├── places/ ← 장소 (PostGIS 공간 데이터) +├── places/ ← 장소 (Google Places API 연동) ├── realtime/ ← WebSocket 실시간 통신 ├── rooms/ ← 채팅방 ├── schedules/ ← 일정 diff --git a/build.gradle b/build.gradle index 33504728..20740ff5 100644 --- a/build.gradle +++ b/build.gradle @@ -34,10 +34,11 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-data-mongodb' implementation 'org.springframework.boot:spring-boot-starter-data-redis' - implementation 'org.hibernate.orm:hibernate-spatial' runtimeOnly 'org.postgresql:postgresql' implementation 'org.springframework.boot:spring-boot-starter-flyway' runtimeOnly 'org.flywaydb:flyway-database-postgresql' + implementation 'io.mongock:mongock-standalone:5.5.1' + implementation 'io.mongock:mongodb-sync-v4-driver:5.5.1' implementation 'org.springframework.boot:spring-boot-starter-security' diff --git a/compose.db.dev.yaml b/compose.db.dev.yaml index 6ec01249..ebed58b3 100644 --- a/compose.db.dev.yaml +++ b/compose.db.dev.yaml @@ -3,8 +3,7 @@ name: uttae services: postgres: - image: 'postgis/postgis:17-3.5' - platform: linux/amd64 + image: 'postgres:17.10' env_file: - ./.env.dev environment: @@ -12,7 +11,7 @@ services: POSTGRES_USER: ${DB_USER} POSTGRES_PASSWORD: ${DB_PASSWORD} healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"] + test: ["CMD-SHELL", "pg_isready -h localhost -p 5432 -U $${DB_USER} -d $${DB_NAME}"] interval: 10s timeout: 5s retries: 10 @@ -35,7 +34,7 @@ services: - redis-data:/data mongodb: - image: 'mongo:8' + image: 'mongo:8.0.21' env_file: - ./.env.dev environment: diff --git a/compose.db.prod.yaml b/compose.db.prod.yaml index 308e5b61..e57951ca 100644 --- a/compose.db.prod.yaml +++ b/compose.db.prod.yaml @@ -2,40 +2,26 @@ name: uttae-data services: - postgres: - image: 'postgis/postgis:17-3.5' - env_file: - - .env.db - environment: - POSTGRES_DB: ${DB_NAME} - POSTGRES_USER: ${DB_USER} - POSTGRES_PASSWORD: ${DB_PASSWORD} - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"] - interval: 10s - timeout: 5s - retries: 10 - ports: - - '${DB_PRIVATE_IP}:5432:5432' - volumes: - - postgres-data:/var/lib/postgresql/data - restart: unless-stopped - deploy: - resources: - limits: - cpus: '1.0' - memory: 2g - logging: - driver: "json-file" - options: - max-size: "10m" - max-file: "3" - redis: image: 'redis:8.6.2' - command: ["redis-server", "--notify-keyspace-events", "Ex"] + env_file: + - .env.db + command: + - redis-server + - --notify-keyspace-events + - Ex + - --save + - "" + - --appendonly + - "no" + - --maxmemory + - ${REDIS_MAXMEMORY:-384mb} + - --maxmemory-policy + - noeviction + - --requirepass + - ${REDIS_PASSWORD} healthcheck: - test: ["CMD", "redis-cli", "ping"] + test: ["CMD-SHELL", "redis-cli --no-auth-warning -a \"$${REDIS_PASSWORD}\" ping"] interval: 10s timeout: 5s retries: 10 @@ -56,7 +42,7 @@ services: max-file: "3" mongodb: - image: 'mongo:8' + image: 'mongo:8.0.21' env_file: - .env.db environment: @@ -87,14 +73,11 @@ services: postgres-exporter: image: prometheuscommunity/postgres-exporter:v0.19.1 environment: - DATA_SOURCE_URI: postgres:5432/${DB_NAME}?sslmode=disable + DATA_SOURCE_URI: ${DB_HOST}:5432/${DB_NAME}?sslmode=require DATA_SOURCE_USER: ${DB_USER} DATA_SOURCE_PASS: ${DB_PASSWORD} ports: - '${DB_PRIVATE_IP}:${POSTGRES_EXPORTER_PORT}:9187' - depends_on: - postgres: - condition: service_healthy restart: unless-stopped logging: driver: "json-file" @@ -104,8 +87,11 @@ services: redis-exporter: image: oliver006/redis_exporter:v1.83.0 + env_file: + - .env.db environment: REDIS_ADDR: redis://redis:6379 + REDIS_PASSWORD: ${REDIS_PASSWORD} ports: - '${DB_PRIVATE_IP}:${REDIS_EXPORTER_PORT}:9121' depends_on: @@ -156,6 +142,5 @@ services: max-file: "3" volumes: - postgres-data: redis-data: mongodb-data: diff --git a/docs/ai/erd.md b/docs/ai/erd.md index fc1382f9..ade58635 100644 --- a/docs/ai/erd.md +++ b/docs/ai/erd.md @@ -2,7 +2,7 @@ > **초안 문서입니다.** 구현 과정에서 컬럼 및 관계가 변경될 수 있습니다. -- **기술 스택:** PostgreSQL + PostGIS, MongoDB (채팅 메시지), Redis (세션/토큰) +- **기술 스택:** PostgreSQL, MongoDB (채팅 메시지), Redis (세션/토큰) - **ID 전략:** roomId → UUID (초대 URL 보안), MongoDB message `_id` → 읽음 위치 복귀/히스토리 cursor, PostgreSQL 엔티티 → BIGINT auto-increment --- @@ -101,9 +101,9 @@ CHECK `ck_room_members_left_role` (status='ACTIVE' OR role <> 'PENDING') | metadata | DOCUMENT | NOT NULL, DEFAULT `{}` | 장소 공유, AI 응답 등 메시지 타입별 확장 데이터 | | createdAt | DATE | NOT NULL | 생성일시 | -**인덱스:** `{ roomId: 1, sequence: 1 }` unique — 방별 sequence 중복 방지 및 `afterSequence` 조회용, `{ roomId: 1, sequence: -1, _id: -1 }` — 최근 메시지 조회용, `{ roomId: 1, createdAt: -1, _id: -1 }` — 기존 최근 조회/운영 호환용, `{ roomId: 1, _id: 1 }` — `afterId` 호환 조회용 +**인덱스:** `uidx_messages_room_sequence` `{ roomId: 1, sequence: 1 }` unique — 방별 sequence 중복 방지 및 `afterSequence` 조회용, `idx_messages_room_sequence_recent` `{ roomId: 1, sequence: -1, _id: -1 }` — 최근 메시지 조회용, `idx_messages_room_recent` `{ roomId: 1, createdAt: -1, _id: -1 }` — 기존 최근 조회/운영 호환용, `idx_messages_room_id` `{ roomId: 1, _id: 1 }` — `afterId` 호환 조회용 -> 운영 MongoDB 기존 메시지는 서버 배포 전 `scripts/mongo/backfill-message-sequence.js`로 room별 `_id` 오름차순 기준 `sequence: 1, 2, 3...`을 채운 뒤 missing/null 및 중복 검증을 통과해야 한다. `(roomId, sequence)` unique index는 backfill 검증 완료 후 스크립트에서 생성한다. +> 초기 빈 MongoDB 배포에서는 Mongock 마이그레이션이 `messages` 컬렉션과 위 인덱스를 생성한다. 운영 MongoDB 기존 메시지가 있는 환경은 서버 배포 전 room별 `_id` 오름차순 기준 `sequence: 1, 2, 3...` backfill과 missing/null 및 중복 검증을 별도 절차로 완료해야 한다. `(roomId, sequence)` unique index는 backfill 검증 완료 후 적용한다. > > 채팅 초기 진입/재접속에서 저장된 읽음 위치로 복귀할 때는 `room_members.last_read_message_id`를 `afterId`로 보낸다. 실시간 수신 중 브로드캐스트 누락이 감지되면 마지막 수신 message `sequence`를 `afterSequence`로 보내 복구한다. diff --git a/docs/superpowers/plans/2026-06-08-postgresql-docker-healthcheck.md b/docs/superpowers/plans/2026-06-08-postgresql-docker-healthcheck.md new file mode 100644 index 00000000..e02dbfa6 --- /dev/null +++ b/docs/superpowers/plans/2026-06-08-postgresql-docker-healthcheck.md @@ -0,0 +1,89 @@ +# PostgreSQL Docker Healthcheck Timing Issue Resolution Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Resolve the PostgreSQL Docker healthcheck timing issue by verifying TCP availability instead of Unix socket availability, preventing early health status. + +**Architecture:** Update the `postgres` healthcheck script in `compose.db.dev.yaml` to include `-h localhost -p 5432`. This ensures the health check only passes when the container accepts TCP connections on port 5432, preventing dependent containers from starting too early. + +**Tech Stack:** Docker Compose, PostgreSQL 17 + +--- + +### Task 1: Update PostgreSQL Healthcheck configuration + +**Files:** +- Modify: `compose.db.dev.yaml` + +- [ ] **Step 1: Modify PostgreSQL healthcheck test command** + +Modify `compose.db.dev.yaml` to update the `test` property under `postgres.healthcheck`. + +```yaml + healthcheck: + test: ["CMD-SHELL", "pg_isready -h localhost -p 5432 -U $${DB_USER} -d $${DB_NAME}"] + interval: 10s + timeout: 5s + retries: 10 +``` + +### Task 2: Verification + +**Files:** +- Config: `compose.db.dev.yaml` +- Config: `compose.app.dev.yaml` + +- [ ] **Step 1: Validate compose file syntax** + +Run docker-compose config check to verify there are no syntax errors. + +Run: +```bash +docker compose -f compose.db.dev.yaml config > /dev/null +``` +Expected: command exits with `0` status and no validation errors. + +- [ ] **Step 2: Restart the database container and check health status** + +Stop any running containers and start `postgres` service to verify its health status. + +Run: +```bash +docker compose -f compose.db.dev.yaml down +docker compose -f compose.db.dev.yaml up -d postgres +``` +Wait for 15-20 seconds for the healthcheck intervals to trigger. +Run: +```bash +docker compose -f compose.db.dev.yaml ps +``` +Expected: The status of the `postgres` container transitions to `healthy`. + +- [ ] **Step 3: Start the application and check connection** + +Start the application container which depends on the postgres container being healthy, and check if it starts successfully. + +Run: +```bash +docker compose -f compose.db.dev.yaml -f compose.app.dev.yaml up -d --build +``` +Wait for 15-20 seconds. +Verify if the `api-server` container is running and healthy: +```bash +docker compose -f compose.db.dev.yaml -f compose.app.dev.yaml ps +``` +Expected: `api-server` container status is `healthy` (or running) without connection failure errors in the logs. +Check logs for database connection: +```bash +docker compose -f compose.db.dev.yaml -f compose.app.dev.yaml logs api-server | grep -E "Connection|Database|Flyway" +``` +Expected: Flyway migration runs and database connection is established successfully. + +- [ ] **Step 4: Shut down containers** + +Clean up the containers. +Run: +```bash +docker compose -f compose.db.dev.yaml -f compose.app.dev.yaml down +``` +Expected: All containers stopped and removed. diff --git a/docs/superpowers/plans/2026-06-08-remove-postgis.md b/docs/superpowers/plans/2026-06-08-remove-postgis.md new file mode 100644 index 00000000..6a2f3abd --- /dev/null +++ b/docs/superpowers/plans/2026-06-08-remove-postgis.md @@ -0,0 +1,151 @@ +# Remove PostGIS Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Clean up the project from unused PostGIS docker image, hibernate-spatial dependency, and all documentation references to them. + +**Architecture:** Remove `hibernate-spatial` from dependencies, switch PostgreSQL Docker images (in docker-compose dev config and Testcontainers integration test base class) to official `postgres:17`, and update docs to match the cleaned-up tech stack. + +**Tech Stack:** Gradle, Spring Boot, Docker Compose, PostgreSQL 17, Testcontainers + +--- + +### Task 1: Remove Build Dependency + +**Files:** +- Modify: `build.gradle` + +- [ ] **Step 1: Remove `hibernate-spatial` dependency** + + In [build.gradle](file:///home/minbros/projects/java/how-about-us-backend/build.gradle), find and remove the following line (around line 37): + ```groovy + implementation 'org.hibernate.orm:hibernate-spatial' + ``` + +- [ ] **Step 2: Run build dry-run and convention check** + + Run Gradle to ensure the build compiles successfully without the dependency. + Run: `./gradlew compileJava checkstyleMain` + Expected: BUILD SUCCESSFUL + +- [ ] **Step 3: Commit** + + Run: + ```bash + git add build.gradle + git commit -m "chore: build.gradle에서 hibernate-spatial 의존성 제거" + ``` + +--- + +### Task 2: Replace Docker Database Images + +**Files:** +- Modify: `compose.db.dev.yaml` +- Modify: `src/test/java/com/howaboutus/backend/support/BaseIntegrationTest.java` + +- [ ] **Step 1: Modify `compose.db.dev.yaml` PostgreSQL image** + + In [compose.db.dev.yaml](file:///home/minbros/projects/java/how-about-us-backend/compose.db.dev.yaml), modify the container image from `postgis/postgis:17-3.5` to `postgres:17` under the `postgres` service (around line 6): + ```yaml + postgres: + image: 'postgres:17' + ``` + +- [ ] **Step 2: Modify `BaseIntegrationTest.java` Testcontainers image** + + In [BaseIntegrationTest.java](file:///home/minbros/projects/java/how-about-us-backend/src/test/java/com/howaboutus/backend/support/BaseIntegrationTest.java), modify the PostgreSQL Testcontainer Docker image (around line 23): + ```java + DockerImageName.parse("postgres:17") + ``` + +- [ ] **Step 3: Run target integration tests to verify database connectivity** + + Verify that the tests spin up and execute successfully using the standard `postgres:17` image. + Run: `./gradlew test --tests "*PlaceControllerTest"` + Expected: BUILD SUCCESSFUL + +- [ ] **Step 4: Commit** + + Run: + ```bash + git add compose.db.dev.yaml src/test/java/com/howaboutus/backend/support/BaseIntegrationTest.java + git commit -m "chore: 로컬 및 테스트용 PostgreSQL 이미지를 postgres:17로 교체" + ``` + +--- + +### Task 3: Clean up Documentation References + +**Files:** +- Modify: `AGENTS.md` +- Modify: `README.md` +- Modify: `docs/ai/erd.md` + +- [ ] **Step 1: Remove references in `AGENTS.md`** + + In [AGENTS.md](file:///home/minbros/projects/java/how-about-us-backend/AGENTS.md): + - Change line 21: + ```markdown + - **Database**: PostgreSQL 17, MongoDB 8 + ``` + - Modify line 26 under "기타" section: + Remove `, hibernate-spatial` from the list. + - Modify line 81: + ```markdown + - PostgreSQL 이미지는 `postgres:17`을 사용한다. + ``` + - Remove line 85: + Delete `- 공간 데이터 엔티티에는 hibernate-spatial 타입을 사용한다.` completely. + +- [ ] **Step 2: Remove references in `README.md`** + + In [README.md](file:///home/minbros/projects/java/how-about-us-backend/README.md): + - Change line 16: + ```markdown + - **Database**: PostgreSQL 17, MongoDB 8 + ``` + - Change line 32: + ```markdown + ├── places/ ← 장소 (Google Places API 연동) + ``` + +- [ ] **Step 3: Remove references in `docs/ai/erd.md`** + + In [docs/ai/erd.md](file:///home/minbros/projects/java/how-about-us-backend/docs/ai/erd.md): + - Change line 5: + ```markdown + - **기술 스택:** PostgreSQL, MongoDB (채팅 메시지), Redis (세션/토큰) + ``` + +- [ ] **Step 4: Verify markdown changes via Git diff** + + Ensure that all spatial and PostGIS references are removed. + Run: `git diff AGENTS.md README.md docs/ai/erd.md` + Expected: View output containing only expected modifications to database image names, spatial lists, and comments. + +- [ ] **Step 5: Commit** + + Run: + ```bash + git add AGENTS.md README.md docs/ai/erd.md + git commit -m "docs: 문서 내 PostGIS 및 Spatial 기술 스택 언급 제거" + ``` + +--- + +### Task 4: Full Build and Integration Verification + +**Files:** None + +- [ ] **Step 1: Re-launch local DB container with the new postgres:17 image** + + Ensure local docker container gets updated to the standard postgres image. + Run: `docker compose -f compose.db.dev.yaml down -v && docker compose --env-file .env.dev -f compose.db.dev.yaml up -d` + Expected: Services (postgres, redis, mongodb) start successfully. + +- [ ] **Step 2: Run full build and test execution** + + Run a complete clean build of the application. + Run: `./gradlew clean build checkstyleMain checkstyleTest` + Expected: BUILD SUCCESSFUL (All tests pass and checkstyle has 0 warnings) diff --git a/docs/superpowers/specs/2026-06-08-postgresql-docker-healthcheck-design.md b/docs/superpowers/specs/2026-06-08-postgresql-docker-healthcheck-design.md new file mode 100644 index 00000000..5952fd6c --- /dev/null +++ b/docs/superpowers/specs/2026-06-08-postgresql-docker-healthcheck-design.md @@ -0,0 +1,52 @@ +# Design Spec: PostgreSQL Docker Healthcheck Timing Issue Resolution + +- **Date**: 2026-06-08 +- **Topic**: PostgreSQL Docker Healthcheck Timing Issue +- **Status**: Draft (Under Review) + +## 1. Background & Problem Statement + +In the local development environment, when bootstrapping the application using `docker compose`, the Spring Boot application (`api-server`) occasionally fails to connect to the PostgreSQL database during its startup phase. This results in connection failure errors, such as `java.io.EOFException` during the SSL handshake. + +### Root Cause +- **Early Health State**: The PostgreSQL container health check was configured as `pg_isready -U ${DB_USER} -d ${DB_NAME}`. +- **Unix Socket vs TCP**: Without specifying the host (`-h`) and port (`-p`), `pg_isready` defaults to checking connection availability via the local Unix domain socket. +- **Timing Mismatch**: The PostgreSQL database engine reports itself as healthy as soon as it accepts Unix socket connections. However, the external TCP port (`5432` mapped to host `5433`) is not yet fully initialized and ready to accept connections. +- **Premature Startup**: Since `api-server` depends on `postgres` being healthy (`condition: service_healthy`), it attempts to connect to `postgres:5432` via TCP immediately upon the container transitioning to the healthy state, resulting in connection failure. + +## 2. Proposed Changes + +We will modify the health check command in the local Docker Compose configuration to explicitly verify TCP connectivity on port 5432. + +### Target File +- [compose.db.dev.yaml](file:///home/minbros/projects/java/how-about-us-backend/compose.db.dev.yaml) + +### Modifications +Modify the `healthcheck.test` field under the `postgres` service: + +```diff + healthcheck: +- test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"] ++ test: ["CMD-SHELL", "pg_isready -h localhost -p 5432 -U $${DB_USER} -d $${DB_NAME}"] + interval: 10s + timeout: 5s + retries: 10 +``` + +> [!NOTE] +> We use `$${DB_USER}` and `$${DB_NAME}` (double dollar sign) to escape the variables so they are evaluated inside the container shell, ensuring that the container's environment variables are correctly resolved. + +## 3. Impact & Risk Analysis + +- **Backward Compatibility**: No impact. This only changes the local development container configuration. +- **Resource Usage**: No impact. `pg_isready` is a lightweight utility. +- **Dependency**: The change ensures that any container depending on the `postgres` service (e.g., `api-server` in `compose.app.dev.yaml`) will only start when PostgreSQL is truly ready to receive TCP connections. + +## 5. Verification Plan + +1. **Verify Syntax**: Check YAML syntax using Docker Compose parser. +2. **Local Test Execution**: + - Run the DB dev compose: `docker compose -f compose.db.dev.yaml up -d` + - Monitor the health status: `docker compose -f compose.db.dev.yaml ps` and ensure the postgres container transitions to `healthy`. + - Run the app dev compose: `docker compose -f compose.db.dev.yaml -f compose.app.dev.yaml up -d --build` + - Check if `api-server` starts successfully without any database connection issues. diff --git a/docs/superpowers/specs/2026-06-08-remove-postgis-design.md b/docs/superpowers/specs/2026-06-08-remove-postgis-design.md new file mode 100644 index 00000000..a08dc926 --- /dev/null +++ b/docs/superpowers/specs/2026-06-08-remove-postgis-design.md @@ -0,0 +1,92 @@ +# Design Doc: Remove PostGIS and Spatial Dependencies + +- **Date:** 2026-06-08 +- **Status:** Approved +- **Author:** Antigravity + +--- + +## 1. Background & Context + +Currently, the project lists `PostgreSQL 17 + PostGIS 3.5` as part of its tech stack. However, the database schema (managed by Flyway migrations) and JPA Entities do not store spatial coordinate types (like `Point` or `Geometry`) nor do they query database-level spatial coordinates. Coordinates are stored either as floats in MongoDB messages or processed dynamically via Google Places API integrations. + +Therefore, PostGIS and spatial library dependencies represent unnecessary overhead and should be removed. + +--- + +## 2. Goals & Objectives + +- Remove spatial library dependencies (`hibernate-spatial`) from the project build configuration. +- Change the database Docker image from `postgis/postgis:17-3.5` to official `postgres:17` in the local development environment. +- Update Testcontainers database image in integration tests. +- Clean up all mentions of PostGIS and spatial coordinates in system documentation. + +--- + +## 3. Detailed Changes + +### A. Build Configurations +#### `build.gradle` +- Remove the dependency: + ```groovy + implementation 'org.hibernate.orm:hibernate-spatial' + ``` + +### B. Infrastructure Configs +#### `compose.db.dev.yaml` +- Update the PostgreSQL container image: + ```yaml + services: + postgres: + image: 'postgres:17' + ``` + +### C. Test Support Configs +#### `src/test/java/com/howaboutus/backend/support/BaseIntegrationTest.java` +- Update the Docker image for Testcontainers PostgreSQL: + ```java + DockerImageName.parse("postgres:17") + ``` + +### D. Documentation Updates +#### `AGENTS.md` +- Update the Database entry in **Tech Stack**: + ```markdown + - **Database**: PostgreSQL 17, MongoDB 8 + ``` +- Remove references to `hibernate-spatial` in **Tech Stack** -> **기타**: + - Remove `, hibernate-spatial` from the list. +- Update **Gotchas**: + - Change `postgis/postgis:17-3.5` to `postgres:17`. + - Remove `- 공간 데이터 엔티티에는 hibernate-spatial 타입을 사용한다.` rule. + +#### `README.md` +- Update Database entry in **Database** Section: + ```markdown + - **Database**: PostgreSQL 17, MongoDB 8 + ``` +- Update package map description for `places`: + ```markdown + ├── places/ ← 장소 (Google Places API 연동) + ``` + +#### `docs/ai/erd.md` +- Update the Tech Stack section: + ```markdown + - **기술 스택:** PostgreSQL, MongoDB (채팅 메시지), Redis (세션/토큰) + ``` + +--- + +## 4. Verification & Testing Plan + +1. **Checkstyle verification**: + ```bash + ./gradlew checkstyleMain checkstyleTest + ``` +2. **Build and Test execution**: + ```bash + ./gradlew clean build + ``` + - Ensure the compile phase passes without spatial library imports. + - Verify integration tests start successfully using `postgres:17` via Testcontainers and all test cases pass. diff --git a/infra/caddy/Caddyfile b/infra/caddy/Caddyfile index 854aa3d2..a5f882d8 100644 --- a/infra/caddy/Caddyfile +++ b/infra/caddy/Caddyfile @@ -68,5 +68,9 @@ respond @429 `{"code":"RATE_LIMIT_EXCEEDED","message":"요청이 너무 많습니다. 잠시 후 다시 시도해 주세요"}` 429 } + request_body { + max_size 1MB + } + reverse_proxy app:8080 } diff --git a/infra/monitoring/README.md b/infra/monitoring/README.md index 0fbf3a79..b6a6fcf6 100644 --- a/infra/monitoring/README.md +++ b/infra/monitoring/README.md @@ -55,6 +55,7 @@ API 서버의 운영 compose에는 Alloy와 node-exporter가 필수 구성으로 ```bash API_PRIVATE_IP=10.0.0.11 LOKI_PUSH_URL=http://10.0.0.10:3100/loki/api/v1/push +REDIS_PASSWORD=change-this-redis-password ``` 2. API 서버에서 실행한다. @@ -67,7 +68,7 @@ docker compose --env-file .env.prod -f compose.app.prod.yaml up -d ## DB Server -DB 서버의 운영 compose에는 PostgreSQL, Redis, MongoDB와 함께 postgres-exporter, redis-exporter, mongodb-exporter, node-exporter를 포함한다. +DB 서버의 운영 compose에는 Redis, MongoDB와 함께 redis-exporter, mongodb-exporter, node-exporter를 포함한다. 1. DB 서버의 `.env.db`에 아래 값을 추가한다. @@ -76,6 +77,8 @@ DB_PRIVATE_IP=10.0.0.13 POSTGRES_EXPORTER_PORT=9187 REDIS_EXPORTER_PORT=9121 MONGODB_EXPORTER_PORT=9216 +REDIS_PASSWORD=change-this-redis-password +REDIS_MAXMEMORY=384mb ``` 2. DB 서버에서 실행한다. @@ -84,7 +87,7 @@ MONGODB_EXPORTER_PORT=9216 docker compose --env-file .env.db -f compose.db.prod.yaml up -d ``` -`compose.db.prod.yaml`은 PostgreSQL `5432`, Redis `6379`, MongoDB `27017`, exporter 포트, node-exporter `9100`을 모두 `${DB_PRIVATE_IP}`에만 바인딩한다. 운영 방화벽에서도 해당 포트는 public inbound로 열지 않고, API 서버와 모니터링 서버의 private 접근만 허용한다. +`compose.db.prod.yaml`은 Redis `6379`, MongoDB `27017`, exporter 포트, node-exporter `9100`을 모두 `${DB_PRIVATE_IP}`에만 바인딩한다. Redis는 `REDIS_PASSWORD`로 인증하고, 비영속 보조 상태에 맞춰 RDB/AOF를 끄며 `REDIS_MAXMEMORY`와 `noeviction` 정책으로 메모리 상한을 명시한다. 운영 방화벽에서도 해당 포트는 public inbound로 열지 않고, API 서버와 모니터링 서버의 private 접근만 허용한다. GitHub Actions로 배포할 때는 DB 서버에 `self-hosted`, `hbu-db` 라벨을 가진 runner를 설치하고 `production-db` environment에 `ENV_DB` secret을 등록한다. `ENV_DB`에는 DB 서버용 `.env.db` 파일 내용을 그대로 넣는다. `.github/workflows/deploy-db.yml`은 DB 서버의 `/opt/how-about-us-data`에 compose 파일과 env를 준비한 뒤 설정을 검증하고 DB 서비스와 exporter를 함께 배포한다. @@ -157,6 +160,14 @@ DB exporter target 렌더링 결과 예시: "component": "postgres", "exporter": "postgres-exporter" } + }, + { + "targets": ["10.0.0.13:9121"], + "labels": { + "host": "db-1", + "component": "redis", + "exporter": "redis-exporter" + } } ] ``` diff --git a/scripts/mongo/backfill-message-sequence.js b/scripts/mongo/backfill-message-sequence.js deleted file mode 100644 index a823a222..00000000 --- a/scripts/mongo/backfill-message-sequence.js +++ /dev/null @@ -1,112 +0,0 @@ -/* - * Backfill room-local message sequence values. - * - * Usage: - * mongosh "$MONGO_URI" scripts/mongo/backfill-message-sequence.js - * - * Operational preconditions: - * - Back up production MongoDB first. - * - Stop message writes or enter maintenance mode while this script runs. - * - Deploy the server only after this script completes successfully. - */ - -const collection = db.messages; -const indexName = "uq_messages_room_sequence"; -const batchSize = 1000; - -function assertNoMissingSequence() { - const missingCount = collection.countDocuments({ - $or: [{ sequence: { $exists: false } }, { sequence: null }], - }); - if (missingCount !== 0) { - throw new Error(`messages still missing sequence: ${missingCount}`); - } -} - -function assertNoDuplicateSequence() { - const duplicates = collection - .aggregate([ - { - $group: { - _id: { roomId: "$roomId", sequence: "$sequence" }, - count: { $sum: 1 }, - }, - }, - { - $match: { - "_id.sequence": { $ne: null }, - count: { $gt: 1 }, - }, - }, - { $limit: 10 }, - ]) - .toArray(); - - if (duplicates.length > 0) { - throw new Error(`duplicate roomId+sequence values found: ${JSON.stringify(duplicates)}`); - } -} - -function flush(operations) { - if (operations.length === 0) { - return; - } - collection.bulkWrite(operations, { ordered: true }); - operations.length = 0; -} - -const roomIds = collection.distinct("roomId"); -print(`Backfilling message sequence for ${roomIds.length} rooms`); - -for (const roomId of roomIds) { - let expectedSequence = 1; - let existingCount = 0; - let backfilledCount = 0; - const operations = []; - const cursor = collection.find({ roomId }).sort({ _id: 1 }); - - while (cursor.hasNext()) { - const message = cursor.next(); - if (message.sequence !== undefined && message.sequence !== null) { - const existing = Number(message.sequence); - if (existing !== expectedSequence) { - throw new Error( - `existing sequence mismatch for roomId=${roomId}, _id=${message._id}, ` + - `existing=${existing}, expected=${expectedSequence}` - ); - } - existingCount += 1; - } else { - operations.push({ - updateOne: { - filter: { _id: message._id }, - update: { $set: { sequence: NumberLong(String(expectedSequence)) } }, - }, - }); - backfilledCount += 1; - } - - expectedSequence += 1; - if (operations.length >= batchSize) { - flush(operations); - } - } - - flush(operations); - print( - `roomId=${roomId}: total=${expectedSequence - 1}, ` + - `backfilled=${backfilledCount}, alreadyAssigned=${existingCount}` - ); -} - -assertNoMissingSequence(); -assertNoDuplicateSequence(); - -collection.createIndex( - { roomId: 1, sequence: 1 }, - { unique: true, name: indexName } -); - -assertNoMissingSequence(); -assertNoDuplicateSequence(); -print(`Backfill complete. Unique index ensured: ${indexName}`); diff --git a/src/main/java/com/howaboutus/backend/common/config/MongoMigrationConfig.java b/src/main/java/com/howaboutus/backend/common/config/MongoMigrationConfig.java new file mode 100644 index 00000000..e54a94f0 --- /dev/null +++ b/src/main/java/com/howaboutus/backend/common/config/MongoMigrationConfig.java @@ -0,0 +1,33 @@ +package com.howaboutus.backend.common.config; + +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.mongodb.MongoDatabaseFactory; +import org.springframework.data.mongodb.core.MongoTemplate; + +import com.mongodb.client.MongoClient; + +import io.mongock.driver.mongodb.sync.v4.driver.MongoSync4Driver; +import io.mongock.runner.standalone.MongockStandalone; + +@Configuration +public class MongoMigrationConfig { + + private static final String MIGRATION_PACKAGE = "com.howaboutus.backend.common.migration.mongo"; + + @Bean + ApplicationRunner mongoMigrationRunner( + MongoClient mongoClient, + MongoDatabaseFactory mongoDatabaseFactory, + MongoTemplate mongoTemplate + ) { + return args -> MongockStandalone.builder() + .setDriver(MongoSync4Driver.withDefaultLock(mongoClient, mongoDatabaseFactory.getMongoDatabase().getName())) + .addMigrationScanPackage(MIGRATION_PACKAGE) + .addDependency(MongoTemplate.class, mongoTemplate) + .setTransactional(false) + .buildRunner() + .execute(); + } +} diff --git a/src/main/java/com/howaboutus/backend/common/migration/mongo/CreateMessageIndexesChangeUnit.java b/src/main/java/com/howaboutus/backend/common/migration/mongo/CreateMessageIndexesChangeUnit.java new file mode 100644 index 00000000..743d3bb5 --- /dev/null +++ b/src/main/java/com/howaboutus/backend/common/migration/mongo/CreateMessageIndexesChangeUnit.java @@ -0,0 +1,73 @@ +package com.howaboutus.backend.common.migration.mongo; + +import org.springframework.data.domain.Sort.Direction; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.index.Index; +import org.springframework.data.mongodb.core.index.IndexOperations; + +import com.howaboutus.backend.messages.document.ChatMessage; + +import io.mongock.api.annotations.ChangeUnit; +import io.mongock.api.annotations.Execution; +import io.mongock.api.annotations.RollbackExecution; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@ChangeUnit(id = "create-message-indexes", order = "001", author = "minbros") +public class CreateMessageIndexesChangeUnit { + + private static final String MESSAGES_COLLECTION = "messages"; + private static final String ROOM_SEQUENCE_UNIQUE_INDEX = "uidx_messages_room_sequence"; + private static final String ROOM_SEQUENCE_RECENT_INDEX = "idx_messages_room_sequence_recent"; + private static final String ROOM_RECENT_INDEX = "idx_messages_room_recent"; + private static final String ROOM_ID_INDEX = "idx_messages_room_id"; + + private final MongoTemplate mongoTemplate; + + @Execution + public void createIndexes() { + mongoTemplate.indexOps(ChatMessage.class) + .createIndex(new Index() + .on("roomId", Direction.ASC) + .on("sequence", Direction.ASC) + .unique() + .named(ROOM_SEQUENCE_UNIQUE_INDEX)); + mongoTemplate.indexOps(ChatMessage.class) + .createIndex(new Index() + .on("roomId", Direction.ASC) + .on("sequence", Direction.DESC) + .on("_id", Direction.DESC) + .named(ROOM_SEQUENCE_RECENT_INDEX)); + mongoTemplate.indexOps(ChatMessage.class) + .createIndex(new Index() + .on("roomId", Direction.ASC) + .on("createdAt", Direction.DESC) + .on("_id", Direction.DESC) + .named(ROOM_RECENT_INDEX)); + mongoTemplate.indexOps(ChatMessage.class) + .createIndex(new Index() + .on("roomId", Direction.ASC) + .on("_id", Direction.ASC) + .named(ROOM_ID_INDEX)); + } + + @RollbackExecution + public void rollback() { + if (!mongoTemplate.collectionExists(MESSAGES_COLLECTION)) { + return; + } + IndexOperations indexOperations = mongoTemplate.indexOps(ChatMessage.class); + dropIndexIfExists(indexOperations, ROOM_SEQUENCE_UNIQUE_INDEX); + dropIndexIfExists(indexOperations, ROOM_SEQUENCE_RECENT_INDEX); + dropIndexIfExists(indexOperations, ROOM_RECENT_INDEX); + dropIndexIfExists(indexOperations, ROOM_ID_INDEX); + } + + private void dropIndexIfExists(IndexOperations indexOperations, String indexName) { + boolean exists = indexOperations.getIndexInfo().stream() + .anyMatch(indexInfo -> indexName.equals(indexInfo.getName())); + if (exists) { + indexOperations.dropIndex(indexName); + } + } +} diff --git a/src/main/java/com/howaboutus/backend/messages/document/ChatMessage.java b/src/main/java/com/howaboutus/backend/messages/document/ChatMessage.java index cda6a97e..bab62d1e 100644 --- a/src/main/java/com/howaboutus/backend/messages/document/ChatMessage.java +++ b/src/main/java/com/howaboutus/backend/messages/document/ChatMessage.java @@ -16,6 +16,7 @@ @Getter @Document(collection = "messages") @CompoundIndexes({ + @CompoundIndex(name = "uidx_messages_room_sequence", def = "{'roomId': 1, 'sequence': 1}", unique = true), @CompoundIndex(name = "idx_messages_room_recent", def = "{'roomId': 1, 'createdAt': -1, '_id': -1}"), @CompoundIndex(name = "idx_messages_room_sequence_recent", def = "{'roomId': 1, 'sequence': -1, '_id': -1}"), @CompoundIndex(name = "idx_messages_room_id", def = "{'roomId': 1, '_id': 1}") diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml index ae548111..4541c627 100644 --- a/src/main/resources/application-prod.yaml +++ b/src/main/resources/application-prod.yaml @@ -1,17 +1,28 @@ #file: noinspection SpellCheckingInspection spring: datasource: - url: jdbc:postgresql://${DB_HOST}:5432/${DB_NAME} + url: jdbc:postgresql://${DB_HOST}:5432/${DB_NAME}?connectTimeout=3&socketTimeout=30&tcpKeepAlive=true&sslmode=require username: ${DB_USER} password: ${DB_PASSWORD} + hikari: + pool-name: UttaePostgresPool + maximum-pool-size: ${DB_POOL_MAX_SIZE:10} + minimum-idle: ${DB_POOL_MIN_IDLE:2} + connection-timeout: 3000 + validation-timeout: 1000 + idle-timeout: 600000 + max-lifetime: 1500000 + keepalive-time: 300000 data: redis: host: ${REDIS_HOST:${DB_HOST}} port: 6379 + database: ${REDIS_DATABASE:0} + password: ${REDIS_PASSWORD:} mongodb: - uri: mongodb://${MONGO_USER}:${MONGO_PASSWORD}@${MONGO_HOST:${DB_HOST}}:27017/${MONGO_DB}?authSource=admin + uri: mongodb://${MONGO_USER}:${MONGO_PASSWORD}@${MONGO_HOST:${DB_HOST}}:27017/${MONGO_DB}?authSource=admin&connectTimeoutMS=3000&serverSelectionTimeoutMS=3000&socketTimeoutMS=30000&maxPoolSize=${MONGO_MAX_POOL_SIZE:20}&minPoolSize=${MONGO_MIN_POOL_SIZE:2} docker: compose: @@ -52,3 +63,9 @@ cookie: management: server: port: 8081 + +app: + rate-limit: + redis: + database: ${spring.data.redis.database} + password: ${spring.data.redis.password:} diff --git a/src/test/java/com/howaboutus/backend/common/config/ProdDatabaseConfigTest.java b/src/test/java/com/howaboutus/backend/common/config/ProdDatabaseConfigTest.java new file mode 100644 index 00000000..a9d063da --- /dev/null +++ b/src/test/java/com/howaboutus/backend/common/config/ProdDatabaseConfigTest.java @@ -0,0 +1,92 @@ +package com.howaboutus.backend.common.config; + +import static org.assertj.core.api.Assertions.*; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.config.YamlPropertiesFactoryBean; +import org.springframework.core.io.ClassPathResource; + +class ProdDatabaseConfigTest { + + @Test + @DisplayName("prod 환경에서는 PostgreSQL 커넥션 풀과 JDBC 타임아웃을 명시한다") + void configuresPostgresPoolAndTimeoutsInProd() { + Map properties = loadProdProperties(); + + assertThat(properties) + .containsEntry( + "spring.datasource.url", + "jdbc:postgresql://${DB_HOST}:5432/${DB_NAME}" + + "?connectTimeout=3&socketTimeout=30&tcpKeepAlive=true&sslmode=require" + ) + .containsEntry("spring.datasource.hikari.pool-name", "UttaePostgresPool") + .containsEntry("spring.datasource.hikari.maximum-pool-size", "${DB_POOL_MAX_SIZE:10}") + .containsEntry("spring.datasource.hikari.minimum-idle", "${DB_POOL_MIN_IDLE:2}") + .containsEntry("spring.datasource.hikari.connection-timeout", "3000") + .containsEntry("spring.datasource.hikari.validation-timeout", "1000") + .containsEntry("spring.datasource.hikari.idle-timeout", "600000") + .containsEntry("spring.datasource.hikari.max-lifetime", "1500000") + .containsEntry("spring.datasource.hikari.keepalive-time", "300000"); + } + + @Test + @DisplayName("prod 환경에서는 MongoDB 드라이버 타임아웃과 풀 크기를 URI에 명시한다") + void configuresMongoTimeoutsInProd() { + Map properties = loadProdProperties(); + + assertThat(properties.get("spring.mongodb.uri")) + .contains("authSource=admin") + .contains("connectTimeoutMS=3000") + .contains("serverSelectionTimeoutMS=3000") + .contains("socketTimeoutMS=30000") + .contains("maxPoolSize=${MONGO_MAX_POOL_SIZE:20}") + .contains("minPoolSize=${MONGO_MIN_POOL_SIZE:2}"); + } + + @Test + @DisplayName("prod 환경에서는 Redis DB 번호를 앱과 rate limit 클라이언트가 공유한다") + void configuresRedisDatabaseInProd() { + Map properties = loadProdProperties(); + + assertThat(properties) + .containsEntry("spring.data.redis.database", "${REDIS_DATABASE:0}") + .containsEntry("app.rate-limit.redis.database", "${spring.data.redis.database}"); + } + + @Test + @DisplayName("prod Redis는 비영속 보조 상태에 맞춰 메모리 제한과 noeviction 정책을 명시한다") + void configuresRedisRuntimePolicyInProdCompose() throws IOException { + String compose = Files.readString(Path.of("compose.db.prod.yaml")); + + assertThat(compose) + .contains("--notify-keyspace-events") + .contains("Ex") + .contains("--save") + .contains("--appendonly") + .contains("\"no\"") + .contains("--maxmemory") + .contains("${REDIS_MAXMEMORY:-384mb}") + .contains("--maxmemory-policy") + .contains("noeviction"); + } + + private Map loadProdProperties() { + YamlPropertiesFactoryBean yaml = new YamlPropertiesFactoryBean(); + yaml.setResources(new ClassPathResource("application-prod.yaml")); + yaml.afterPropertiesSet(); + + Map properties = new HashMap<>(); + for (Map.Entry entry : Objects.requireNonNull(yaml.getObject()).entrySet()) { + properties.put(entry.getKey().toString(), entry.getValue().toString()); + } + return properties; + } +} diff --git a/src/test/java/com/howaboutus/backend/common/migration/MongoMessageIndexMigrationTest.java b/src/test/java/com/howaboutus/backend/common/migration/MongoMessageIndexMigrationTest.java new file mode 100644 index 00000000..3062b1b4 --- /dev/null +++ b/src/test/java/com/howaboutus/backend/common/migration/MongoMessageIndexMigrationTest.java @@ -0,0 +1,52 @@ +package com.howaboutus.backend.common.migration; + +import static org.assertj.core.api.Assertions.*; + +import java.util.ArrayList; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.bson.Document; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.mongodb.core.MongoTemplate; + +import com.howaboutus.backend.support.BaseIntegrationTest; + +class MongoMessageIndexMigrationTest extends BaseIntegrationTest { + + @Autowired + private MongoTemplate mongoTemplate; + + @Test + @DisplayName("Mongock는 빈 MongoDB에 messages 컬렉션과 메시지 조회/순번 인덱스를 생성한다") + void mongockCreatesMessageIndexesOnEmptyMongo() { + assertThat(mongoTemplate.collectionExists("messages")).isTrue(); + + Map indexesByName = mongoTemplate.getCollection("messages") + .listIndexes() + .into(new ArrayList<>()) + .stream() + .collect(Collectors.toMap(index -> index.getString("name"), Function.identity())); + + assertIndex(indexesByName, "uidx_messages_room_sequence", + new Document("roomId", 1).append("sequence", 1), true); + assertIndex(indexesByName, "idx_messages_room_sequence_recent", + new Document("roomId", 1).append("sequence", -1).append("_id", -1), false); + assertIndex(indexesByName, "idx_messages_room_recent", + new Document("roomId", 1).append("createdAt", -1).append("_id", -1), false); + assertIndex(indexesByName, "idx_messages_room_id", + new Document("roomId", 1).append("_id", 1), false); + } + + private void assertIndex(Map indexesByName, String name, Document key, boolean unique) { + assertThat(indexesByName) + .containsKey(name); + assertThat(indexesByName.get(name).get("key", Document.class)) + .isEqualTo(key); + assertThat(indexesByName.get(name).getBoolean("unique", false)) + .isEqualTo(unique); + } +} diff --git a/src/test/java/com/howaboutus/backend/support/BaseIntegrationTest.java b/src/test/java/com/howaboutus/backend/support/BaseIntegrationTest.java index 67f2fcab..a6a7969c 100644 --- a/src/test/java/com/howaboutus/backend/support/BaseIntegrationTest.java +++ b/src/test/java/com/howaboutus/backend/support/BaseIntegrationTest.java @@ -20,8 +20,7 @@ public abstract class BaseIntegrationTest { static { POSTGRES = new PostgreSQLContainer( - DockerImageName.parse("postgis/postgis:17-3.5") - .asCompatibleSubstituteFor("postgres")) + DockerImageName.parse("postgres:17.10")) .withDatabaseName("howaboutus_test") .withUsername("test") .withPassword("test"); @@ -32,7 +31,7 @@ public abstract class BaseIntegrationTest { .withCommand("redis-server", "--notify-keyspace-events", "Ex") .waitingFor(Wait.forLogMessage(".*Ready to accept connections.*\\n", 1)); - MONGO = new MongoDBContainer(DockerImageName.parse("mongo:8")); + MONGO = new MongoDBContainer(DockerImageName.parse("mongo:8.0.21")); POSTGRES.start(); REDIS.start(); From 23572b492d3d8e7b2e654aec1d9164c5f59833d8 Mon Sep 17 00:00:00 2001 From: PARK JU YEONG <96644508+parkjuyeong0312@users.noreply.github.com> Date: Tue, 9 Jun 2026 00:39:16 +0900 Subject: [PATCH 9/9] =?UTF-8?q?feat:=20=EC=95=BD=EA=B4=80=20=EC=9B=90?= =?UTF-8?q?=EB=AC=B8=20=EB=B2=84=EC=A0=84=20=ED=8C=8C=EC=9D=BC=20=EB=B3=B4?= =?UTF-8?q?=EC=A1=B4=20(#141)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: 약관 버전 파일 설계 추가 * docs: 약관 버전 파일 구현 계획 추가 * feat: 약관 원문을 버전별 파일로 보존 * fix: 약관 현재 버전 리소스 검증 보강 * docs: 약관 버전 파일 관리 방식 반영 * docs: 정책 시행일과 운영 제한 문구 정리 * chore : 설계 문서 삭제, 서비스상에서 필요하지않음 * fix: 약관 버전 검증 정규식 경고 제거 --- docs/ai/erd.md | 2 +- docs/ai/features.md | 2 +- docs/legal/policy-preparation.md | 154 ----- docs/policy/copyright-policy.md | 4 +- docs/policy/operation-policy.md | 10 +- docs/policy/operator-info.md | 16 - docs/policy/privacy-policy.md | 4 +- docs/policy/terms-of-service.md | 4 +- .../2026-06-08-agreement-versioned-files.md | 547 ++++++++++++++++++ ...-06-08-agreement-versioned-files-design.md | 161 ++++++ .../config/AgreementProperties.java | 4 +- .../agreements/service/AgreementService.java | 69 ++- .../1.0.md} | 4 +- .../1.0.md} | 8 +- src/main/resources/application.yaml | 6 +- .../service/AgreementServiceTest.java | 103 ++-- 16 files changed, 823 insertions(+), 275 deletions(-) delete mode 100644 docs/legal/policy-preparation.md delete mode 100644 docs/policy/operator-info.md create mode 100644 docs/superpowers/plans/2026-06-08-agreement-versioned-files.md create mode 100644 docs/superpowers/specs/2026-06-08-agreement-versioned-files-design.md rename src/main/resources/agreements/{privacy-policy.md => privacy-policy/1.0.md} (99%) rename src/main/resources/agreements/{terms-of-service.md => terms-of-service/1.0.md} (95%) diff --git a/docs/ai/erd.md b/docs/ai/erd.md index ade58635..0e5f2b24 100644 --- a/docs/ai/erd.md +++ b/docs/ai/erd.md @@ -36,7 +36,7 @@ Google OAuth 기반 사용자 정보 > 활성 회원은 email/nickname/provider/provider_id가 NOT NULL이며 이메일과 (provider, provider_id) 조합이 unique. 탈퇴 회원은 모두 NULL 가능하며 unique 검사 대상에서 제외되어 동일 OAuth 계정으로 재가입할 수 있다. -약관 원문은 DB에 저장하지 않고 백엔드 리소스 파일로 관리한다. 현재 버전은 `application.yaml`의 `app.agreements` 설정을 기준으로 하며, 프론트엔드는 버전 문자열을 전송하지 않는다. +약관 원문은 DB에 저장하지 않고 백엔드의 문서 타입별 버전 리소스 파일로 보존한다. 현재 버전은 `application.yaml`의 `app.agreements.*.current-version` 설정을 기준으로 하며, 프론트엔드는 버전 문자열을 전송하지 않는다. --- diff --git a/docs/ai/features.md b/docs/ai/features.md index 89d8eb76..b95c4518 100644 --- a/docs/ai/features.md +++ b/docs/ai/features.md @@ -51,7 +51,7 @@ Caddy는 `caddy-ratelimit` 기반 IP 제한으로 엣지에서 넓고 거친 폭 | 상태 | 기능 | 설명 | ERD 연관 | |------|------|------|----------| | `[x]` | 구글 OAuth 로그인 | Google 계정으로 소셜 로그인 | users | -| `[x]` | 현재 약관 조회 | `GET /api/agreements/current`로 현재 이용약관과 개인정보 처리방침 원문 Markdown, 버전, 문서 타입을 비인증 상태에서 조회한다. 약관 원문은 백엔드 리소스 파일에 있고 현재 버전은 `application.yaml`에서 관리한다 | - | +| `[x]` | 현재 약관 조회 | `GET /api/agreements/current`로 현재 이용약관과 개인정보 처리방침 원문 Markdown, 버전, 문서 타입을 비인증 상태에서 조회한다. 약관 원문은 백엔드의 문서 타입별 버전 리소스 파일에 보존하고, 현재 버전은 `application.yaml`의 `app.agreements.*.current-version`에서 관리한다 | - | | `[x]` | 가입 약관 동의 기록 | `POST /auth/google/login`에서 프론트는 `agreementsAccepted`만 전송한다. 신규 사용자는 값이 `true`일 때만 생성되며, 백엔드 현재 약관 버전과 서버 시간을 `users`에 저장한다 | users | | `[x]` | 약관 재동의 | 기존 사용자의 저장 약관 버전이 현재 서버 버전과 다르면 로그인 시 재동의가 필요하다. 로그인 요청에서 `agreementsAccepted=true`이면 서버 현재 버전으로 갱신 후 토큰을 발급하고, 이미 로그인된 사용자는 `POST /api/users/me/agreements`로 현재 약관에 재동의한다 | users | | `[x]` | 토큰 재발급 (Refresh) | Refresh Token Rotation: UUID 기반 HTTP-only 쿠키(path=/auth/refresh), Redis `refresh:token:{uuid}`→userId(TTL 14일) / `refresh:user:{userId}`→Set\. Replay Detection 으로 탈취 시 전체 무효화 | Redis | diff --git a/docs/legal/policy-preparation.md b/docs/legal/policy-preparation.md deleted file mode 100644 index 99c7a724..00000000 --- a/docs/legal/policy-preparation.md +++ /dev/null @@ -1,154 +0,0 @@ -# 약관·개인정보 처리 준비 체크리스트 - -> 배포 전에 작성·고지해야 하는 약관 및 개인정보 관련 정책을 정리한다. -> 현재 단계: `초안`. 실제 약관 본문은 별도 문서로 분리해 작성한다. - -## 문서 목적 - -- 배포 전 법적으로 갖춰야 할 약관/정책 항목을 식별한다. -- 우리 서비스(여행 협업 + AI 어시스턴트)에서 어떤 조항이 핵심인지 정리한다. -- 약관에 적은 절차를 실제로 이행하려면 코드/기능 상 무엇을 추가해야 하는지 추적한다. - -## 서비스 특성 요약 - -- **수집 정보**: Google OAuth 프로필(이메일, 이름, 프로필 이미지), 채팅 메시지, 여행 일정/북마크, 접속 로그, 쿠키 기반 인증 토큰 -- **외부 전송**: Google Places/Routes API, 자체 AI 서버 -- **저장소**: PostgreSQL, MongoDB(채팅), Redis(세션·캐시) -- **회원 기능**: 로그인/로그아웃은 구현됨. **회원 탈퇴, 신고, 운영자 제재 절차는 미구현** (`docs/ai/features.md` 기준) - ---- - -## 1. 법적으로 반드시 필요한 문서 - -| 문서 | 근거 법령 | 비고 | -|------|-----------|------| -| 개인정보 처리방침 | 개인정보보호법 §30 | 홈페이지 첫 화면에서 쉽게 접근 가능해야 함 | -| 이용약관 | 약관규제법, 전기통신사업법 | 가입 전 동의 절차 필요 | -| 운영정책 | 이용약관 하위 정책 | 채팅·게시물·AI·초대코드·이용제한·신고/이의제기 세부 기준 | -| 운영자 정보 표시 | 정보통신망법 §10 | 상호, 대표자, 주소, 사업자번호, 연락처, 개인정보 보호책임자 | -| 저작권 정책 | 저작권법 §102, §103 | 이용자 콘텐츠의 권리 침해 신고, 게시중단, 재게시 요청 절차 안내 | - -> 위치정보법은 "단말기 위치"를 수집하지 않고 사용자가 검색한 장소 좌표만 다루면 일반적으로 적용 범위 밖이다. 추후 "내 위치 기반 추천" 기능 도입 시 **위치기반서비스 이용약관**과 방통위 신고가 추가로 필요하다. - ---- - -## 2. 개인정보 처리방침에 들어가야 할 항목 - -> 개인정보보호법 시행령 §31 기준. 각 항목을 우리 서비스 맥락으로 채워야 한다. - -### 2-1. 수집 항목 / 수집 목적 - -- **필수 수집**: Google OAuth `sub`(고유 ID), 이메일, 이름, 프로필 이미지 URL -- **자동 수집**: IP, User-Agent, 접속 일시, 쿠키(`access_token`, `refresh_token`), Redis presence 정보 -- **사용자 입력**: 방 제목·여행지·날짜, 채팅 메시지, 북마크/일정 메모 - -### 2-2. 보유·이용 기간 - -- 회원 정보: 탈퇴 시까지. 탈퇴 시 계정 식별 정보는 삭제 또는 익명화하고 인증 정보는 무효화 -- 여행 방 단위 데이터(방 제목, 채팅, 일정, 북마크, 메모): 방 삭제 시까지. 단, 탈퇴 처리와 함께 방이 삭제되는 경우 함께 삭제 -- **법령상 의무 보관 (검토 필요)**: - - 통신비밀보호법: 접속 로그 3개월 - - 전자상거래법: 소비자 불만/분쟁 처리 기록 3년 — 결제 기능이 없으면 적용 범위가 좁음 - -### 2-3. 제3자 제공 · 국외 이전 · 처리위탁 - -> 외부 호출이 많아 이 항목이 가장 큰 리스크다. 누가 어떤 데이터를 받는지 명확히 적어야 한다. - -- **제3자 제공**: 사전 동의 또는 법령상 근거가 있는 예외를 제외하고 제공하지 않는 것으로 정리 -- **Google (미국)**: OAuth 인증, Places/Routes API, Google Analytics → 처리 위탁 및 국외 이전 고지 필요 -- **OpenAI (미국)**: AI 응답 생성, 대화 요약 생성 → 처리 위탁 및 국외 이전 고지 필요 -- **클라우드 인프라(AWS Lightsail 등)**: 처리 위탁 및 국외 이전 고지 필요 -- 채팅 메시지에 포함된 **다른 사용자의 메시지/장소 스냅샷**이 AI 서버로 함께 전송되는 흐름을 명확히 적어야 한다. - -### 2-4. 정보주체 권리 - -- 열람, 정정, 삭제, 처리정지, 동의 철회권 안내 -- 권리 행사 방법 (이메일 또는 서비스 내 메뉴 등) - -### 2-5. 자동 수집 장치 (쿠키 등) - -- HTTP-only 쿠키(`access_token`, `refresh_token`) 사용 사실 -- 쿠키 거부 방법과 거부 시 영향 (로그인 불가) - -### 2-6. 개인정보 보호책임자 - -- 이름, 직책, 연락처 (이메일 가능) - -### 2-7. 만 14세 미만 처리 - -- 만 14세 미만 가입을 불허할지, 법정대리인 동의를 받을지 정책 결정 — `미결` -- 통상 스타트업은 "만 14세 미만 가입 불가" 정책으로 단순화한다. - -### 2-8. 변경 절차 - -- 처리방침 변경 시 사전 고지 기간과 통지 방법 - ---- - -## 3. 이용약관 핵심 조항 - -| 조항 | 우리 서비스에서 다뤄야 할 내용 | -|------|--------------------------------| -| 서비스 내용 | "여행 계획 협업 + AI 어시스턴트 보조" 명시 | -| 회원 가입/자격 | Google 계정 보유자, 만 14세 이상 | -| 계정·탈퇴 절차 | **현재 미구현** — 탈퇴 API/UI 마련 필요 | -| 이용제한·정지 | 사유, 기간, 사전·사후 통지 절차, 이의제기 절차 | -| 금지 행위 | 도배, 음란/혐오/명예훼손, 타인 사칭, 크롤링, 부정 이용 | -| 운영정책 | 여행 방, 채팅, AI 어시스턴트, 초대코드, 신고 및 이용 제한 세부 기준 | -| 게시물 정책 | 채팅·북마크·일정 메모의 권리 귀속(사용자 보유), 서비스 운영 목적 사용 동의 범위 | -| 저작권 신고/게시중단 | 권리자 신고, 작성자 통지, 재게시 요청, 반복 침해자 제한 | -| AI 응답 면책 | AI 추천은 참고용이며 정확성·안전성 보장하지 않음 | -| 외부 데이터 면책 | Google 장소 정보(영업시간·평점 등)는 Google 제공 자료이며 실제와 다를 수 있음 | -| 서비스 변경/중단 | 사전 공지 기간, 무료 서비스 한정 면책 | -| 손해배상/면책 | 무료 서비스 특성 반영 | -| 분쟁 해결 | 준거법(대한민국), 관할 법원, 사전 협의 절차 | - ---- - -## 4. 코드/기능 측에서 추가로 준비할 작업 - -> 약관 본문만 작성해도 실제 절차를 이행할 수 없으면 의미가 없다. 다음 항목이 `docs/ai/features.md`에 없거나 부족하다. - -| # | 항목 | 비고 | -|---|------|------| -| 1 | 회원 탈퇴 기능 | 현재 worktree의 인증 섹션에는 없음. `feature/user-withdrawal` 기준 정책은 계정 식별 정보 익명화, 참여 정보 삭제, 방 단위 협업 데이터는 방 삭제 시까지 유지 | -| 2 | 계정 정지/차단 운영자 도구 | 방장 추방은 있으나 서비스 차원의 제재 수단 없음 | -| 3 | 신고 기능 | 다른 사용자/메시지 신고 API | -| 4 | 저작권 게시중단 처리 절차 | 이메일 접수로 시작 가능하나 운영자 검토/작성자 통지/재게시 요청 기록 필요 | -| 5 | 약관 동의 이력 저장 | 가입 시 어떤 버전에 동의했는지, 변경 시 재동의 처리 | -| 6 | 개인정보 파기 절차 | 탈퇴 후 보관 기간, 자동 파기 배치 | -| 7 | 로그 보관 기간 정책 | 접속 로그는 통신비밀보호법 시행령 기준 3개월로 정리. 채팅 로그는 방 삭제 시까지 보관하는 기준과 구현 일치 필요 | -| 8 | 데이터 열람/내보내기 (선택) | 정보주체의 열람권 대응. 이메일 송부로도 대체 가능 | - ---- - -## 5. 진행 순서 (제안) - -1. **정책 결정** (코드 작업보다 먼저) - - 탈퇴 시 계정 식별 정보 익명화와 방 단위 협업 데이터 보관 정책 반영 - - 만 14세 미만 정책 (가입 차단으로 단순화 권장) - - 신고 처리 SLA - - 저작권 게시중단 및 재게시 요청 처리 방식 - - 채팅 보관 기간과 방 삭제 시 처리 방식 -2. **운영자 정보 확정** - - 사업자등록 여부, 대표자, 주소, 연락처 - - 개인정보 보호책임자 지정 -3. **회원 탈퇴/신고 기능 구현** - - 약관에 적을 절차가 실제로 동작하도록 코드 추가 -4. **약관·처리방침·운영정책·저작권 정책 초안 작성** - - 한국인터넷진흥원(KISA) 표준 처리방침 양식 활용 가능 -5. **가입 동의 UI / 약관 버전 관리 구현** -6. **법률 검토** - - 가능하면 변호사 검토. 특히 AI 데이터 처리, 국외 이전 부분이 중요하다. - ---- - -## 미결 사항 - -| # | 항목 | 비고 | -|---|------|------| -| 1 | 탈퇴 시 채팅 메시지 처리 | `feature/user-withdrawal` 기준: 방 단위 협업 데이터로 방 삭제 시까지 유지. 탈퇴자의 계정 식별 정보와 참여 정보는 삭제 또는 익명화 | -| 2 | 만 14세 미만 정책 | 가입 차단 vs 법정대리인 동의 | -| 3 | 채팅 메시지 AI 전송 고지 방식 | 처리 위탁 및 국외 이전 고지로 정리. 실제 가입/AI 호출 UI 고지 방식 결정 필요 | -| 4 | 회원 정보 탈퇴 후 보관 기간 | 즉시 파기 vs N일 유예 | -| 5 | 사업자등록 여부 | 등록 시점, 상호, 주소 | diff --git a/docs/policy/copyright-policy.md b/docs/policy/copyright-policy.md index 606c810e..87ec3cdb 100644 --- a/docs/policy/copyright-policy.md +++ b/docs/policy/copyright-policy.md @@ -1,6 +1,6 @@ # 저작권 정책 -**시행일: 2026년 6월 6일** +**시행일: 2026년 6월 9일** --- @@ -108,4 +108,4 @@ Google 등 외부 서비스가 제공하는 장소 정보, 사진, 지도, 경 | 버전 | 시행일 | |------|--------| -| 1.0 | 2026년 6월 6일 | +| 1.0 | 2026년 6월 9일 | diff --git a/docs/policy/operation-policy.md b/docs/policy/operation-policy.md index a2483575..ca63e91b 100644 --- a/docs/policy/operation-policy.md +++ b/docs/policy/operation-policy.md @@ -1,6 +1,6 @@ # 운영정책 -**시행일: 2026년 6월 6일** +**시행일: 2026년 6월 9일** --- @@ -71,10 +71,10 @@ 이용 제한은 위반 행위의 내용, 반복 여부, 피해 규모, 고의성, 긴급성, 소명 여부를 고려하여 다음 범위에서 적용됩니다. -1. 콘텐츠 제한: 게시물 숨김, 삭제, 검색·노출 제한 +1. 콘텐츠 제한: 게시물 삭제 요청, 접근 제한 등 운영 가능한 범위의 조치 2. 기능 제한: 채팅, AI 요청, 방 생성, 초대, 장소·경로 검색 등 일부 기능의 일시 제한 -3. 접근 제한: 특정 여행 방 접근 제한 또는 멤버 제외 -4. 계정 제한: 경고, 일시 정지, 영구 정지, 계정 삭제 +3. 접근 제한: 특정 여행 방 접근 제한, 멤버 제외 요청 등 운영 가능한 범위의 조치 +4. 계정 제한: 경고, 일시 정지, 영구 정지, 계정 삭제 요청·처리 등 운영 가능한 범위의 조치 5. 기술적 제한: 비정상 요청 차단, Rate Limit 적용, 보안상 필요한 접속 제한 위반이 중대하거나 긴급한 피해 방지가 필요한 경우 운영자는 사전 통지 없이 즉시 제한 조치를 할 수 있습니다. @@ -115,4 +115,4 @@ ## 부칙 -이 정책은 2026년 6월 6일부터 시행합니다. +이 정책은 2026년 6월 9일부터 시행합니다. diff --git a/docs/policy/operator-info.md b/docs/policy/operator-info.md deleted file mode 100644 index e910cfca..00000000 --- a/docs/policy/operator-info.md +++ /dev/null @@ -1,16 +0,0 @@ -# 운영자 정보 - -정보통신망 이용촉진 및 정보보호 등에 관한 법률 제10조에 따라 다음과 같이 운영자 정보를 공개합니다. - ---- - -| 항목 | 내용 | -|------|------| -| 서비스명 | 우때 | -| 운영자 | 박주영 (개인 운영) | -| 이메일 | team.uttae@gmail.com | -| 호스팅 사업자 | Amazon Web Services (AWS) | - ---- - -> 사업자 미등록 개인 운영으로, 사업자등록번호 및 사업장 주소는 별도 공개하지 않습니다. 운영자에 대한 문의는 위 이메일로 연락주시기 바랍니다. diff --git a/docs/policy/privacy-policy.md b/docs/policy/privacy-policy.md index 0b60b45a..61857782 100644 --- a/docs/policy/privacy-policy.md +++ b/docs/policy/privacy-policy.md @@ -2,5 +2,5 @@ 원문은 백엔드 약관 리소스로 이동했습니다. -- 리소스: `src/main/resources/agreements/privacy-policy.md` -- 현재 버전 설정: `src/main/resources/application.yaml`의 `app.agreements.privacy-policy.version` +- 현재 리소스: `src/main/resources/agreements/privacy-policy/1.0.md` +- 현재 버전 설정: `src/main/resources/application.yaml`의 `app.agreements.privacy-policy.current-version` diff --git a/docs/policy/terms-of-service.md b/docs/policy/terms-of-service.md index 166bcde7..bbc36afe 100644 --- a/docs/policy/terms-of-service.md +++ b/docs/policy/terms-of-service.md @@ -2,5 +2,5 @@ 원문은 백엔드 약관 리소스로 이동했습니다. -- 리소스: `src/main/resources/agreements/terms-of-service.md` -- 현재 버전 설정: `src/main/resources/application.yaml`의 `app.agreements.terms-of-service.version` +- 현재 리소스: `src/main/resources/agreements/terms-of-service/1.0.md` +- 현재 버전 설정: `src/main/resources/application.yaml`의 `app.agreements.terms-of-service.current-version` diff --git a/docs/superpowers/plans/2026-06-08-agreement-versioned-files.md b/docs/superpowers/plans/2026-06-08-agreement-versioned-files.md new file mode 100644 index 00000000..d1bbc20d --- /dev/null +++ b/docs/superpowers/plans/2026-06-08-agreement-versioned-files.md @@ -0,0 +1,547 @@ +# Agreement Versioned Files Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Preserve historical agreement Markdown files by version while keeping `GET /api/agreements/current` as the only current-agreement lookup API. + +**Architecture:** Agreement content remains in classpath resources. `application.yaml` stores only each agreement document's `current-version`; `AgreementService` validates that version, derives the resource path, reads the matching Markdown file, and returns the existing response shape. Historical versions stay in `src/main/resources/agreements//.md`. + +**Tech Stack:** Spring Boot 4.0.5, Java 21, Gradle, JUnit 5, AssertJ, Spring `ClassPathResource`, Markdown documentation. + +--- + +## File Structure + +Create and modify these files: + +- Modify: `src/main/java/com/howaboutus/backend/agreements/config/AgreementProperties.java` + - Keep the `app.agreements` binding. + - Replace per-document `version` and `resource` fields with `currentVersion`. +- Modify: `src/main/java/com/howaboutus/backend/agreements/service/AgreementService.java` + - Validate `currentVersion`. + - Derive `classpath:agreements/terms-of-service/{version}.md` and `classpath:agreements/privacy-policy/{version}.md`. + - Keep `getCurrentAgreements()`, `currentVersions()`, `validateAccepted(Boolean)`, and `needsReacceptance(User)` behavior. +- Modify: `src/test/java/com/howaboutus/backend/agreements/service/AgreementServiceTest.java` + - Update tests for `currentVersion`. + - Add invalid version format and missing derived resource coverage. +- Modify: `src/main/resources/application.yaml` + - Change `version` to `current-version`. + - Remove `resource`. +- Move: `src/main/resources/agreements/terms-of-service.md` to `src/main/resources/agreements/terms-of-service/1.0.md` +- Move: `src/main/resources/agreements/privacy-policy.md` to `src/main/resources/agreements/privacy-policy/1.0.md` +- Modify: `docs/ai/features.md` + - Update current agreement lookup description. +- Modify: `docs/ai/erd.md` + - Update agreement original-text storage note. +- Modify: `docs/policy/terms-of-service.md` + - Update backend resource pointer and setting pointer. +- Modify: `docs/policy/privacy-policy.md` + - Update backend resource pointer and setting pointer. + +Do not change these files unless compilation forces an import cleanup: + +- `src/main/java/com/howaboutus/backend/agreements/controller/AgreementController.java` +- `src/main/java/com/howaboutus/backend/agreements/controller/dto/AgreementCurrentResponse.java` +- `src/main/java/com/howaboutus/backend/agreements/controller/dto/AgreementDocumentResponse.java` +- `src/main/java/com/howaboutus/backend/agreements/service/dto/AgreementDocumentResult.java` +- `src/main/java/com/howaboutus/backend/agreements/service/dto/AgreementVersions.java` + +--- + +### Task 1: Service Tests For Version-Derived Resources + +**Files:** +- Modify: `src/test/java/com/howaboutus/backend/agreements/service/AgreementServiceTest.java` + +- [ ] **Step 1: Replace the service test file with version-derived resource tests** + +Replace `src/test/java/com/howaboutus/backend/agreements/service/AgreementServiceTest.java` with: + +```java +package com.howaboutus.backend.agreements.service; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.howaboutus.backend.agreements.config.AgreementProperties; +import com.howaboutus.backend.common.error.CustomException; +import com.howaboutus.backend.common.error.ErrorCode; + +class AgreementServiceTest { + + @Test + @DisplayName("설정된 현재 버전의 이용약관과 개인정보 처리방침을 Markdown으로 반환한다") + void returnsCurrentAgreements() { + AgreementService service = new AgreementService(properties("1.0", "1.0")); + + var result = service.getCurrentAgreements(); + + assertThat(result).hasSize(2); + assertThat(result.get(0).type()).isEqualTo("TERMS_OF_SERVICE"); + assertThat(result.get(0).title()).isEqualTo("이용약관"); + assertThat(result.get(0).version()).isEqualTo("1.0"); + assertThat(result.get(0).contentFormat()).isEqualTo("MARKDOWN"); + assertThat(result.get(0).content()).startsWith("# 이용약관"); + assertThat(result.get(1).type()).isEqualTo("PRIVACY_POLICY"); + assertThat(result.get(1).title()).isEqualTo("개인정보 처리방침"); + assertThat(result.get(1).version()).isEqualTo("1.0"); + assertThat(result.get(1).content()).startsWith("# 개인정보 처리방침"); + } + + @Test + @DisplayName("현재 이용약관 원문은 프론트 공개 정책 URL을 사용한다") + void termsOfServiceUsesPublicPolicyUrls() { + AgreementService service = new AgreementService(properties("1.0", "1.0")); + + var result = service.getCurrentAgreements(); + + assertThat(result.get(0).content()) + .doesNotContain("(privacy-policy.md)") + .doesNotContain("(operation-policy.md)") + .doesNotContain("(copyright-policy.md)") + .contains("(https://uttae.com/policies/privacy)") + .contains("(https://uttae.com/policies/operation)") + .contains("(https://uttae.com/policies/copyright)"); + } + + @Test + @DisplayName("현재 약관 버전 설정이 비어 있으면 AGREEMENT_CONFIGURATION_INVALID 예외") + void throwsForBlankVersion() { + AgreementService service = new AgreementService(properties("", "1.0")); + + assertAgreementConfigurationInvalid(service); + } + + @Test + @DisplayName("현재 약관 버전 형식이 올바르지 않으면 AGREEMENT_CONFIGURATION_INVALID 예외") + void throwsForInvalidVersionFormat() { + AgreementService service = new AgreementService(properties("../1.0", "1.0")); + + assertAgreementConfigurationInvalid(service); + } + + @Test + @DisplayName("현재 약관 버전에 해당하는 리소스가 없으면 AGREEMENT_CONFIGURATION_INVALID 예외") + void throwsForMissingVersionResource() { + AgreementService service = new AgreementService(properties("9.9", "1.0")); + + assertAgreementConfigurationInvalid(service); + } + + private AgreementProperties properties(String tosVersion, String privacyVersion) { + return new AgreementProperties( + new AgreementProperties.AgreementDocument(tosVersion), + new AgreementProperties.AgreementDocument(privacyVersion) + ); + } + + private void assertAgreementConfigurationInvalid(AgreementService service) { + assertThatThrownBy(service::getCurrentAgreements) + .isInstanceOf(CustomException.class) + .satisfies(e -> assertThat(((CustomException)e).getErrorCode()) + .isEqualTo(ErrorCode.AGREEMENT_CONFIGURATION_INVALID)); + } +} +``` + +- [ ] **Step 2: Run the focused service test to verify it fails** + +Run: + +```bash +./gradlew test --tests com.howaboutus.backend.agreements.service.AgreementServiceTest +``` + +Expected: compilation fails because `AgreementProperties.AgreementDocument` still requires `(String version, Resource resource)` and the service still reads configured `resource`. + +- [ ] **Step 3: Keep the failing test uncommitted** + +```bash +git status --short +``` + +Expected: `src/test/java/com/howaboutus/backend/agreements/service/AgreementServiceTest.java` is modified. Do not commit this red state. + +--- + +### Task 2: Configuration And Service Implementation + +**Files:** +- Modify: `src/main/java/com/howaboutus/backend/agreements/config/AgreementProperties.java` +- Modify: `src/main/java/com/howaboutus/backend/agreements/service/AgreementService.java` +- Modify: `src/main/resources/application.yaml` + +- [ ] **Step 1: Update `AgreementProperties`** + +Replace `src/main/java/com/howaboutus/backend/agreements/config/AgreementProperties.java` with: + +```java +package com.howaboutus.backend.agreements.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "app.agreements") +public record AgreementProperties( + AgreementDocument termsOfService, + AgreementDocument privacyPolicy +) { + + public record AgreementDocument( + String currentVersion + ) { + } +} +``` + +- [ ] **Step 2: Update `AgreementService`** + +Replace `src/main/java/com/howaboutus/backend/agreements/service/AgreementService.java` with: + +```java +package com.howaboutus.backend.agreements.service; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.regex.Pattern; + +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Service; + +import com.howaboutus.backend.agreements.config.AgreementProperties; +import com.howaboutus.backend.agreements.service.dto.AgreementDocumentResult; +import com.howaboutus.backend.agreements.service.dto.AgreementVersions; +import com.howaboutus.backend.common.error.CustomException; +import com.howaboutus.backend.common.error.ErrorCode; +import com.howaboutus.backend.user.entity.User; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class AgreementService { + + private static final String CONTENT_FORMAT_MARKDOWN = "MARKDOWN"; + private static final Pattern VERSION_PATTERN = Pattern.compile("^[0-9]+(\\.[0-9]+)*$"); + + private final AgreementProperties properties; + + public List getCurrentAgreements() { + return List.of( + document("TERMS_OF_SERVICE", "이용약관", "agreements/terms-of-service", + properties.termsOfService()), + document("PRIVACY_POLICY", "개인정보 처리방침", "agreements/privacy-policy", + properties.privacyPolicy()) + ); + } + + public AgreementVersions currentVersions() { + return new AgreementVersions( + requireCurrentVersion(properties.termsOfService()), + requireCurrentVersion(properties.privacyPolicy()) + ); + } + + public void validateAccepted(Boolean accepted) { + if (!Boolean.TRUE.equals(accepted)) { + throw new CustomException(ErrorCode.AGREEMENTS_NOT_ACCEPTED); + } + } + + public boolean needsReacceptance(User user) { + AgreementVersions versions = currentVersions(); + return !versions.tosVersion().equals(user.getTosVersion()) + || !versions.privacyVersion().equals(user.getPrivacyVersion()); + } + + private AgreementDocumentResult document(String type, String title, String directory, + AgreementProperties.AgreementDocument document) { + String version = requireCurrentVersion(document); + return new AgreementDocumentResult( + type, + title, + version, + CONTENT_FORMAT_MARKDOWN, + readContent(directory, version) + ); + } + + private String requireCurrentVersion(AgreementProperties.AgreementDocument document) { + if (document == null || document.currentVersion() == null + || document.currentVersion().isBlank() + || !VERSION_PATTERN.matcher(document.currentVersion()).matches()) { + throw new CustomException(ErrorCode.AGREEMENT_CONFIGURATION_INVALID); + } + return document.currentVersion(); + } + + private String readContent(String directory, String version) { + ClassPathResource resource = new ClassPathResource(directory + "/" + version + ".md"); + try { + if (!resource.exists()) { + throw new CustomException(ErrorCode.AGREEMENT_CONFIGURATION_INVALID); + } + return resource.getContentAsString(StandardCharsets.UTF_8); + } catch (IOException e) { + throw new CustomException(ErrorCode.AGREEMENT_CONFIGURATION_INVALID); + } + } +} +``` + +- [ ] **Step 3: Update agreement configuration in `application.yaml`** + +In `src/main/resources/application.yaml`, replace: + +```yaml + agreements: + terms-of-service: + version: "1.0" + resource: "classpath:agreements/terms-of-service.md" + privacy-policy: + version: "1.0" + resource: "classpath:agreements/privacy-policy.md" +``` + +with: + +```yaml + agreements: + terms-of-service: + current-version: "1.0" + privacy-policy: + current-version: "1.0" +``` + +- [ ] **Step 4: Run the focused service test before moving resources** + +Run: + +```bash +./gradlew test --tests com.howaboutus.backend.agreements.service.AgreementServiceTest +``` + +Expected: test compiles but fails at runtime because `agreements/terms-of-service/1.0.md` and `agreements/privacy-policy/1.0.md` do not exist yet. + +- [ ] **Step 5: Keep service implementation uncommitted until resources move** + +```bash +git status --short +``` + +Expected: test, service, properties, and `application.yaml` changes are present. Do not commit until Task 3 makes the focused tests pass. + +--- + +### Task 3: Versioned Agreement Resource Files + +**Files:** +- Move: `src/main/resources/agreements/terms-of-service.md` to `src/main/resources/agreements/terms-of-service/1.0.md` +- Move: `src/main/resources/agreements/privacy-policy.md` to `src/main/resources/agreements/privacy-policy/1.0.md` +- Test: `src/test/java/com/howaboutus/backend/agreements/service/AgreementServiceTest.java` +- Test: `src/test/java/com/howaboutus/backend/agreements/controller/AgreementControllerTest.java` + +- [ ] **Step 1: Move agreement Markdown files with Git** + +Run: + +```bash +mkdir -p src/main/resources/agreements/terms-of-service src/main/resources/agreements/privacy-policy +git mv src/main/resources/agreements/terms-of-service.md \ + src/main/resources/agreements/terms-of-service/1.0.md +git mv src/main/resources/agreements/privacy-policy.md \ + src/main/resources/agreements/privacy-policy/1.0.md +``` + +- [ ] **Step 2: Run focused agreement tests** + +Run: + +```bash +./gradlew test --tests com.howaboutus.backend.agreements.service.AgreementServiceTest \ + --tests com.howaboutus.backend.agreements.controller.AgreementControllerTest +``` + +Expected: both test classes pass. `AgreementControllerTest` should not require changes because the API response shape is unchanged. + +- [ ] **Step 3: Run compile verification** + +Run: + +```bash +./gradlew compileJava +``` + +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 4: Commit tests, implementation, and resource move together** + +```bash +git add src/test/java/com/howaboutus/backend/agreements/service/AgreementServiceTest.java \ + src/main/java/com/howaboutus/backend/agreements/config/AgreementProperties.java \ + src/main/java/com/howaboutus/backend/agreements/service/AgreementService.java \ + src/main/resources/application.yaml \ + src/main/resources/agreements +git commit -m "feat: 약관 원문을 버전별 파일로 보존" +``` + +--- + +### Task 4: Domain And Policy Documentation + +**Files:** +- Modify: `docs/ai/features.md` +- Modify: `docs/ai/erd.md` +- Modify: `docs/policy/terms-of-service.md` +- Modify: `docs/policy/privacy-policy.md` + +- [ ] **Step 1: Update `docs/ai/features.md` current agreement row** + +In `docs/ai/features.md`, replace the current agreement lookup row: + +```markdown +| `[x]` | 현재 약관 조회 | `GET /api/agreements/current`로 현재 이용약관과 개인정보 처리방침 원문 Markdown, 버전, 문서 타입을 비인증 상태에서 조회한다. 약관 원문은 백엔드 리소스 파일에 있고 현재 버전은 `application.yaml`에서 관리한다 | - | +``` + +with: + +```markdown +| `[x]` | 현재 약관 조회 | `GET /api/agreements/current`로 현재 이용약관과 개인정보 처리방침 원문 Markdown, 버전, 문서 타입을 비인증 상태에서 조회한다. 약관 원문은 백엔드의 문서 타입별 버전 리소스 파일에 보존하고, 현재 버전은 `application.yaml`의 `app.agreements.*.current-version`에서 관리한다 | - | +``` + +- [ ] **Step 2: Update `docs/ai/erd.md` agreement storage note** + +In `docs/ai/erd.md`, replace: + +```markdown +약관 원문은 DB에 저장하지 않고 백엔드 리소스 파일로 관리한다. 현재 버전은 `application.yaml`의 `app.agreements` 설정을 기준으로 하며, 프론트엔드는 버전 문자열을 전송하지 않는다. +``` + +with: + +```markdown +약관 원문은 DB에 저장하지 않고 백엔드의 문서 타입별 버전 리소스 파일로 보존한다. 현재 버전은 `application.yaml`의 `app.agreements.*.current-version` 설정을 기준으로 하며, 프론트엔드는 버전 문자열을 전송하지 않는다. +``` + +- [ ] **Step 3: Update `docs/policy/terms-of-service.md` pointer** + +Replace the file with: + +```markdown +# 이용약관 + +원문은 백엔드 약관 리소스로 이동했습니다. + +- 현재 리소스: `src/main/resources/agreements/terms-of-service/1.0.md` +- 현재 버전 설정: `src/main/resources/application.yaml`의 `app.agreements.terms-of-service.current-version` +``` + +- [ ] **Step 4: Update `docs/policy/privacy-policy.md` pointer** + +Replace the file with: + +```markdown +# 개인정보 처리방침 + +원문은 백엔드 약관 리소스로 이동했습니다. + +- 현재 리소스: `src/main/resources/agreements/privacy-policy/1.0.md` +- 현재 버전 설정: `src/main/resources/application.yaml`의 `app.agreements.privacy-policy.current-version` +``` + +- [ ] **Step 5: Run Markdown conflict checks** + +Run: + +```bash +rg -n '`docs/ai/[^`]*\.md`|`[A-Z][A-Z_]*\.md`' -g '*.md' +rg -n 'agreements/terms-of-service\.md|agreements/privacy-policy\.md|app\.agreements\..*\.version' AGENTS.md CONTRIBUTING.md docs src/main/resources src/test +``` + +Expected: + +- All referenced `docs/ai/*.md`, `AGENTS.md`, and `CONTRIBUTING.md` paths exist. +- No active documentation still says the current backend agreement resource is `src/main/resources/agreements/terms-of-service.md` or `src/main/resources/agreements/privacy-policy.md`. +- No active documentation still points to `app.agreements.terms-of-service.version` or `app.agreements.privacy-policy.version`. +- Historical spec and plan files may mention the old fields as prior context; do not rewrite historical design records unless they present themselves as current source of truth. + +- [ ] **Step 6: Commit documentation updates** + +```bash +git add docs/ai/features.md docs/ai/erd.md docs/policy/terms-of-service.md docs/policy/privacy-policy.md +git commit -m "docs: 약관 버전 파일 관리 방식 반영" +``` + +--- + +### Task 5: Final Verification + +**Files:** +- Verify all changed Java, resource, and Markdown files. + +- [ ] **Step 1: Run focused tests** + +Run: + +```bash +./gradlew test --tests com.howaboutus.backend.agreements.service.AgreementServiceTest \ + --tests com.howaboutus.backend.agreements.controller.AgreementControllerTest +``` + +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 2: Run Java compilation** + +Run: + +```bash +./gradlew compileJava +``` + +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 3: Run project checkstyle** + +Run: + +```bash +./gradlew checkstyleMain checkstyleTest +``` + +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 4: Inspect final diff against the feature branch base** + +Run: + +```bash +git status --short +git log --oneline -5 +``` + +Expected: + +- `git status --short` prints no unstaged or uncommitted changes. +- Recent commits include: + - `feat: 약관 원문을 버전별 파일로 보존` + - `docs: 약관 버전 파일 관리 방식 반영` + +- [ ] **Step 5: Completion note** + +Report: + +```text +Implemented agreement versioned files. + +Verification: +- AgreementServiceTest and AgreementControllerTest passed. +- compileJava passed. +- checkstyleMain/checkstyleTest passed. + +Notes: +- /api/agreements/current response shape is unchanged. +- Historical agreement originals are preserved under versioned resource paths. +- Historical version lookup API remains out of scope. +``` diff --git a/docs/superpowers/specs/2026-06-08-agreement-versioned-files-design.md b/docs/superpowers/specs/2026-06-08-agreement-versioned-files-design.md new file mode 100644 index 00000000..1f483953 --- /dev/null +++ b/docs/superpowers/specs/2026-06-08-agreement-versioned-files-design.md @@ -0,0 +1,161 @@ +# Agreement Versioned Files Design + +## Context + +현재 약관 기능은 `GET /api/agreements/current`가 백엔드 리소스 Markdown과 `application.yaml`의 버전 설정을 읽어 현재 이용약관과 개인정보 처리방침을 반환한다. 사용자는 `users.tos_version`, `users.privacy_version`에 동의한 버전을 저장한다. + +문제는 원문 파일이 문서 타입별로 하나씩만 있어서 새 약관을 반영할 때 기존 파일을 덮어쓰게 된다는 점이다. 그러면 사용자가 과거에 동의한 버전 문자열은 DB에 남지만, 그 버전의 실제 원문은 백엔드 리소스에서 사라진다. + +## Goals + +- 약관 원문을 문서 타입별 버전 파일로 보존한다. +- `GET /api/agreements/current`는 계속 현재 버전 원문만 반환한다. +- API 응답 구조와 프론트엔드 호출 방식은 변경하지 않는다. +- 과거 원문은 백엔드 리소스에 남기되, 이번 범위에서는 과거 버전 조회 API를 추가하지 않는다. +- 현재 버전 설정과 실제 파일이 맞지 않으면 서버 설정 오류로 빠르게 드러나게 한다. + +## Non-Goals + +- 약관 원문을 DB에 저장하지 않는다. +- 약관 변경 이력 테이블을 만들지 않는다. +- `type + version`으로 과거 원문을 조회하는 공개 API를 추가하지 않는다. +- 로그인 사용자의 동의 버전 원문 조회 API를 추가하지 않는다. +- 고지 발송, 예약 발행, 시행일 자동 계산, 변경 사유 관리 기능은 포함하지 않는다. +- 운영정책, 저작권 정책은 이번 버전 파일 구조 변경 대상에 포함하지 않는다. + +## Resource Layout + +약관 리소스는 문서 타입별 디렉토리와 버전 파일로 관리한다. + +```text +src/main/resources/agreements/ +├── terms-of-service/ +│ └── 1.0.md +└── privacy-policy/ + └── 1.0.md +``` + +새 약관을 발행할 때는 기존 파일을 수정하지 않고 새 버전 파일을 추가한다. + +```text +src/main/resources/agreements/ +├── terms-of-service/ +│ ├── 1.0.md +│ └── 1.1.md +└── privacy-policy/ + ├── 1.0.md + └── 1.1.md +``` + +버전 문자열은 파일명으로 안전하게 사용할 수 있도록 숫자와 점으로만 구성한다. 허용 패턴은 `^[0-9]+(\\.[0-9]+)*$`이며, 예시는 `1.0`, `1.1`, `2.0.1`이다. + +## Configuration + +`application.yaml`은 현재 버전만 지정한다. + +```yaml +app: + agreements: + terms-of-service: + current-version: "1.0" + privacy-policy: + current-version: "1.0" +``` + +서비스는 문서 타입별 고정 경로와 `current-version`으로 리소스 위치를 계산한다. + +```text +classpath:agreements/terms-of-service/{currentVersion}.md +classpath:agreements/privacy-policy/{currentVersion}.md +``` + +현재 설정의 `resource` 필드는 제거한다. 리소스 경로를 설정에서 받지 않으면 `version=1.1`인데 `resource=1.0.md`를 가리키는 불일치를 만들 수 없다. + +## API Behavior + +`GET /api/agreements/current`는 기존과 같은 응답 구조를 유지한다. + +```json +{ + "items": [ + { + "type": "TERMS_OF_SERVICE", + "title": "이용약관", + "version": "1.0", + "contentFormat": "MARKDOWN", + "content": "# 이용약관\n..." + }, + { + "type": "PRIVACY_POLICY", + "title": "개인정보 처리방침", + "version": "1.0", + "contentFormat": "MARKDOWN", + "content": "# 개인정보 처리방침\n..." + } + ] +} +``` + +`version`에는 각 문서의 `current-version` 값이 들어간다. `content`는 해당 버전 파일의 Markdown 원문이다. + +## Error Handling + +기존 `AGREEMENT_CONFIGURATION_INVALID`를 계속 사용한다. + +다음 경우는 서버 설정 오류로 처리한다. + +- `current-version`이 비어 있다. +- `current-version`이 허용된 버전 문자열 패턴과 맞지 않는다. +- 계산된 리소스 파일이 존재하지 않는다. +- 리소스 파일을 읽을 수 없다. + +이 오류는 클라이언트 입력 문제가 아니라 배포된 설정과 리소스의 불일치이므로 503 응답을 유지한다. + +## Components + +- `AgreementProperties` + - `AgreementDocument.version`을 `currentVersion`으로 바꾼다. + - `Resource resource` 설정은 제거한다. +- `AgreementService` + - 문서 타입별 title, API type, 리소스 디렉토리를 내부 매핑으로 둔다. + - 현재 버전 값을 검증한 뒤 `ClassPathResource`로 `{directory}/{version}.md`를 읽는다. + - `currentVersions()`, `needsReacceptance(User)`의 외부 동작은 유지한다. +- `AgreementController`와 응답 DTO + - API 계약을 바꾸지 않는다. + +## Manual Release Flow + +새 약관 버전을 발행할 때 운영자는 다음 순서로 변경한다. + +1. 기존 버전 파일은 수정하지 않는다. +2. 새 버전 파일을 추가한다. +3. `application.yaml`의 `current-version`을 새 버전으로 변경한다. +4. 테스트에서 현재 버전에 해당하는 파일이 읽히는지 확인한다. + +이 방식은 의도적으로 수동 발행이다. 지금 단계에서는 manifest나 배포 예약 기능 없이 파일 보존과 현재 버전 전환만 보장한다. + +## Documentation Updates + +구현 시 다음 문서를 함께 갱신한다. + +- `docs/ai/features.md`: 현재 약관 조회 설명을 버전별 리소스 파일과 `current-version` 설정 기준으로 수정한다. +- `docs/ai/erd.md`: 약관 원문 보존 방식 설명을 버전별 리소스 파일 기준으로 수정한다. +- `docs/policy/terms-of-service.md`, `docs/policy/privacy-policy.md`: 백엔드 리소스 경로 안내가 단일 파일을 가리키고 있으면 새 버전 경로로 수정한다. +- 기존 `docs/superpowers/specs/2026-06-08-backend-agreements-design.md`는 최초 약관 기능 설계 기록으로 남긴다. 후속 구현은 이 문서를 기준으로 단일 리소스 구조를 대체한다. + +## Testing + +- `AgreementServiceTest` + - 현재 버전 설정이 `1.0`이면 `agreements/{type}/1.0.md`를 읽는다. + - 현재 버전이 비어 있으면 `AGREEMENT_CONFIGURATION_INVALID`를 던진다. + - 현재 버전이 허용된 버전 문자열 패턴과 맞지 않으면 `AGREEMENT_CONFIGURATION_INVALID`를 던진다. + - 현재 버전에 해당하는 파일이 없으면 `AGREEMENT_CONFIGURATION_INVALID`를 던진다. + - 실제 약관 파일의 공개 정책 URL 검증은 새 경로 기준으로 유지한다. +- `AgreementControllerTest` + - 응답 구조가 바뀌지 않았음을 기존 테스트로 유지한다. + +최소 검증은 `./gradlew test --tests com.howaboutus.backend.agreements.service.AgreementServiceTest --tests com.howaboutus.backend.agreements.controller.AgreementControllerTest`와 `./gradlew compileJava`다. 커밋 전에는 프로젝트 규칙에 따라 `./gradlew checkstyleMain checkstyleTest`도 실행한다. + +## Follow-Up Scope + +- 과거 버전 원문 조회 API는 이번 범위에서 제외한다. 실제 서비스에서 사용자가 동의한 원문 표시나 감사 대응 화면이 필요해지면 인증된 사용자용 조회 API를 별도 설계한다. diff --git a/src/main/java/com/howaboutus/backend/agreements/config/AgreementProperties.java b/src/main/java/com/howaboutus/backend/agreements/config/AgreementProperties.java index 8a125f6c..4a366c9c 100644 --- a/src/main/java/com/howaboutus/backend/agreements/config/AgreementProperties.java +++ b/src/main/java/com/howaboutus/backend/agreements/config/AgreementProperties.java @@ -1,7 +1,6 @@ package com.howaboutus.backend.agreements.config; import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.core.io.Resource; @ConfigurationProperties(prefix = "app.agreements") public record AgreementProperties( @@ -10,8 +9,7 @@ public record AgreementProperties( ) { public record AgreementDocument( - String version, - Resource resource + String currentVersion ) { } } diff --git a/src/main/java/com/howaboutus/backend/agreements/service/AgreementService.java b/src/main/java/com/howaboutus/backend/agreements/service/AgreementService.java index 54c46b7f..c6c62da4 100644 --- a/src/main/java/com/howaboutus/backend/agreements/service/AgreementService.java +++ b/src/main/java/com/howaboutus/backend/agreements/service/AgreementService.java @@ -4,6 +4,7 @@ import java.nio.charset.StandardCharsets; import java.util.List; +import org.springframework.core.io.ClassPathResource; import org.springframework.stereotype.Service; import com.howaboutus.backend.agreements.config.AgreementProperties; @@ -20,20 +21,24 @@ public class AgreementService { private static final String CONTENT_FORMAT_MARKDOWN = "MARKDOWN"; + private static final String TERMS_OF_SERVICE_RESOURCE_DIR = "agreements/terms-of-service"; + private static final String PRIVACY_POLICY_RESOURCE_DIR = "agreements/privacy-policy"; private final AgreementProperties properties; public List getCurrentAgreements() { return List.of( - document("TERMS_OF_SERVICE", "이용약관", properties.termsOfService()), - document("PRIVACY_POLICY", "개인정보 처리방침", properties.privacyPolicy()) + document("TERMS_OF_SERVICE", "이용약관", properties.termsOfService(), + TERMS_OF_SERVICE_RESOURCE_DIR), + document("PRIVACY_POLICY", "개인정보 처리방침", properties.privacyPolicy(), + PRIVACY_POLICY_RESOURCE_DIR) ); } public AgreementVersions currentVersions() { return new AgreementVersions( - requireVersion(properties.termsOfService()), - requireVersion(properties.privacyPolicy()) + requireVersionWithResource(properties.termsOfService(), TERMS_OF_SERVICE_RESOURCE_DIR), + requireVersionWithResource(properties.privacyPolicy(), PRIVACY_POLICY_RESOURCE_DIR) ); } @@ -50,31 +55,67 @@ public boolean needsReacceptance(User user) { } private AgreementDocumentResult document(String type, String title, - AgreementProperties.AgreementDocument document) { + AgreementProperties.AgreementDocument document, String resourceDirectory) { + String version = requireVersion(document); return new AgreementDocumentResult( type, title, - requireVersion(document), + version, CONTENT_FORMAT_MARKDOWN, - readContent(document) + readContent(resourceDirectory, version) ); } private String requireVersion(AgreementProperties.AgreementDocument document) { - if (document == null || document.version() == null || document.version().isBlank()) { + if (document == null || document.currentVersion() == null + || document.currentVersion().isBlank()) { throw new CustomException(ErrorCode.AGREEMENT_CONFIGURATION_INVALID); } - return document.version(); - } - - private String readContent(AgreementProperties.AgreementDocument document) { - if (document == null || document.resource() == null) { + String version = document.currentVersion(); + if (!isValidVersion(version)) { throw new CustomException(ErrorCode.AGREEMENT_CONFIGURATION_INVALID); } + return version; + } + + private String requireVersionWithResource(AgreementProperties.AgreementDocument document, + String resourceDirectory) { + String version = requireVersion(document); + requireExistingResource(resourceDirectory, version); + return version; + } + + private String readContent(String resourceDirectory, String version) { + ClassPathResource resource = requireExistingResource(resourceDirectory, version); try { - return document.resource().getContentAsString(StandardCharsets.UTF_8); + return resource.getContentAsString(StandardCharsets.UTF_8); } catch (IOException e) { throw new CustomException(ErrorCode.AGREEMENT_CONFIGURATION_INVALID); } } + + private ClassPathResource requireExistingResource(String resourceDirectory, String version) { + ClassPathResource resource = new ClassPathResource(resourceDirectory + "/" + version + ".md"); + if (!resource.exists()) { + throw new CustomException(ErrorCode.AGREEMENT_CONFIGURATION_INVALID); + } + return resource; + } + + private boolean isValidVersion(String version) { + boolean requiresDigit = true; + for (int i = 0; i < version.length(); i++) { + char current = version.charAt(i); + if (Character.isDigit(current)) { + requiresDigit = false; + continue; + } + if (current == '.' && !requiresDigit) { + requiresDigit = true; + continue; + } + return false; + } + return !requiresDigit; + } } diff --git a/src/main/resources/agreements/privacy-policy.md b/src/main/resources/agreements/privacy-policy/1.0.md similarity index 99% rename from src/main/resources/agreements/privacy-policy.md rename to src/main/resources/agreements/privacy-policy/1.0.md index d53f0637..bb2e3bb8 100644 --- a/src/main/resources/agreements/privacy-policy.md +++ b/src/main/resources/agreements/privacy-policy/1.0.md @@ -2,7 +2,7 @@ 우때(이하 "서비스")는 이용자의 개인정보를 소중히 여기며, 「개인정보 보호법」 등 관련 법령을 준수합니다. 이 처리방침은 서비스가 어떤 개인정보를 어떤 목적으로 처리하는지, 이용자가 어떤 권리를 행사할 수 있는지 안내합니다. -**시행일: 2026년 6월 7일** +**시행일: 2026년 6월 9일** --- @@ -208,4 +208,4 @@ Google Analytics 수집만 선택적으로 차단하려면 [Google Analytics 수 | 버전 | 시행일 | |------|--------| -| 1.0 | 2026년 6월 7일 | +| 1.0 | 2026년 6월 9일 | diff --git a/src/main/resources/agreements/terms-of-service.md b/src/main/resources/agreements/terms-of-service/1.0.md similarity index 95% rename from src/main/resources/agreements/terms-of-service.md rename to src/main/resources/agreements/terms-of-service/1.0.md index b45d128a..f1277dc4 100644 --- a/src/main/resources/agreements/terms-of-service.md +++ b/src/main/resources/agreements/terms-of-service/1.0.md @@ -1,6 +1,6 @@ # 이용약관 -**시행일: 2026년 6월 8일** +**시행일: 2026년 6월 9일** --- @@ -104,8 +104,8 @@ ## 제10조 (서비스 이용 제한) -1. 운영자는 이용자가 제8조 또는 제9조를 위반한 경우 사전 통보 없이 서비스 이용을 제한하거나 계정을 삭제할 수 있습니다. -2. 운영자는 위반 행위의 내용, 반복 여부, 피해 규모, 긴급성을 고려하여 게시물 삭제, 채팅 제한, 여행 방 접근 제한, 계정 정지 또는 계정 삭제 조치를 할 수 있습니다. 세부 기준은 [운영정책](https://uttae.com/policies/operation)에 따릅니다. +1. 운영자는 이용자가 제8조 또는 제9조를 위반한 경우 사전 통보 없이 서비스가 제공하는 기능 또는 운영 가능한 절차의 범위에서 서비스 이용을 제한하거나 계정 삭제를 요청·처리할 수 있습니다. +2. 운영자는 위반 행위의 내용, 반복 여부, 피해 규모, 긴급성을 고려하여 게시물 삭제 요청, 채팅 이용 제한, 여행 방 접근 제한, 계정 정지 또는 계정 삭제 등 필요한 조치를 운영 가능한 범위에서 할 수 있습니다. 세부 기준은 [운영정책](https://uttae.com/policies/operation)에 따릅니다. 3. 이용 제한 처분에 이의가 있는 경우 team.uttae@gmail.com으로 이의를 신청할 수 있으며, 운영자는 7일 이내에 처리 결과를 안내합니다. --- @@ -174,4 +174,4 @@ ## 부칙 -이 약관은 2026년 6월 8일부터 시행합니다. +이 약관은 2026년 6월 9일부터 시행합니다. diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 7a519b6a..85039680 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -96,11 +96,9 @@ management: app: agreements: terms-of-service: - version: "1.0" - resource: "classpath:agreements/terms-of-service.md" + current-version: "1.0" privacy-policy: - version: "1.0" - resource: "classpath:agreements/privacy-policy.md" + current-version: "1.0" executor: ai: concurrency-limit: 4 diff --git a/src/test/java/com/howaboutus/backend/agreements/service/AgreementServiceTest.java b/src/test/java/com/howaboutus/backend/agreements/service/AgreementServiceTest.java index 16a479f5..1df15ab1 100644 --- a/src/test/java/com/howaboutus/backend/agreements/service/AgreementServiceTest.java +++ b/src/test/java/com/howaboutus/backend/agreements/service/AgreementServiceTest.java @@ -2,15 +2,8 @@ import static org.assertj.core.api.Assertions.*; -import java.io.IOException; -import java.nio.charset.Charset; -import java.nio.charset.StandardCharsets; - import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.springframework.core.io.ByteArrayResource; -import org.springframework.core.io.ClassPathResource; -import org.springframework.core.io.Resource; import com.howaboutus.backend.agreements.config.AgreementProperties; import com.howaboutus.backend.common.error.CustomException; @@ -19,12 +12,9 @@ class AgreementServiceTest { @Test - @DisplayName("설정된 현재 이용약관과 개인정보 처리방침을 Markdown으로 반환한다") + @DisplayName("설정된 현재 버전의 이용약관과 개인정보 처리방침을 Markdown으로 반환한다") void returnsCurrentAgreements() { - AgreementService service = new AgreementService(properties( - "1.0", "# 이용약관", - "1.0", "# 개인정보 처리방침" - )); + AgreementService service = new AgreementService(properties("1.0", "1.0")); var result = service.getCurrentAgreements(); @@ -33,21 +23,17 @@ void returnsCurrentAgreements() { assertThat(result.get(0).title()).isEqualTo("이용약관"); assertThat(result.get(0).version()).isEqualTo("1.0"); assertThat(result.get(0).contentFormat()).isEqualTo("MARKDOWN"); - assertThat(result.get(0).content()).isEqualTo("# 이용약관"); + assertThat(result.get(0).content()).startsWith("# 이용약관"); assertThat(result.get(1).type()).isEqualTo("PRIVACY_POLICY"); assertThat(result.get(1).title()).isEqualTo("개인정보 처리방침"); assertThat(result.get(1).version()).isEqualTo("1.0"); + assertThat(result.get(1).content()).startsWith("# 개인정보 처리방침"); } @Test @DisplayName("현재 이용약관 원문은 프론트 공개 정책 URL을 사용한다") void termsOfServiceUsesPublicPolicyUrls() { - AgreementService service = new AgreementService(new AgreementProperties( - new AgreementProperties.AgreementDocument( - "1.0", new ClassPathResource("agreements/terms-of-service.md")), - new AgreementProperties.AgreementDocument( - "1.0", new ClassPathResource("agreements/privacy-policy.md")) - )); + AgreementService service = new AgreementService(properties("1.0", "1.0")); var result = service.getCurrentAgreements(); @@ -61,65 +47,52 @@ void termsOfServiceUsesPublicPolicyUrls() { } @Test - @DisplayName("약관 버전 설정이 비어 있으면 AGREEMENT_CONFIGURATION_INVALID 예외") + @DisplayName("현재 약관 버전 설정이 비어 있으면 AGREEMENT_CONFIGURATION_INVALID 예외") void throwsForBlankVersion() { - AgreementService service = new AgreementService(properties( - "", "# 이용약관", - "1.0", "# 개인정보 처리방침" - )); + AgreementService service = new AgreementService(properties("", "1.0")); - assertThatThrownBy(service::getCurrentAgreements) - .isInstanceOf(CustomException.class) - .satisfies(e -> assertThat(((CustomException)e).getErrorCode()) - .isEqualTo(ErrorCode.AGREEMENT_CONFIGURATION_INVALID)); + assertAgreementConfigurationInvalid(service); } @Test - @DisplayName("약관 리소스 설정이 없으면 AGREEMENT_CONFIGURATION_INVALID 예외") - void throwsForMissingResource() { - AgreementService service = new AgreementService(new AgreementProperties( - new AgreementProperties.AgreementDocument("1.0", null), - new AgreementProperties.AgreementDocument( - "1.0", new ByteArrayResource("# 개인정보 처리방침".getBytes(StandardCharsets.UTF_8))) - )); - - assertThatThrownBy(service::getCurrentAgreements) - .isInstanceOf(CustomException.class) - .satisfies(e -> assertThat(((CustomException)e).getErrorCode()) - .isEqualTo(ErrorCode.AGREEMENT_CONFIGURATION_INVALID)); + @DisplayName("현재 약관 버전 형식이 올바르지 않으면 AGREEMENT_CONFIGURATION_INVALID 예외") + void throwsForInvalidVersionFormat() { + AgreementService service = new AgreementService(properties("../1.0", "1.0")); + + assertAgreementConfigurationInvalid(service); } @Test - @DisplayName("약관 리소스 읽기에 실패하면 AGREEMENT_CONFIGURATION_INVALID 예외") - void throwsForUnreadableResource() { - AgreementService service = new AgreementService(new AgreementProperties( - new AgreementProperties.AgreementDocument("1.0", unreadableResource()), - new AgreementProperties.AgreementDocument( - "1.0", new ByteArrayResource("# 개인정보 처리방침".getBytes(StandardCharsets.UTF_8))) - )); - - assertThatThrownBy(service::getCurrentAgreements) - .isInstanceOf(CustomException.class) - .satisfies(e -> assertThat(((CustomException)e).getErrorCode()) - .isEqualTo(ErrorCode.AGREEMENT_CONFIGURATION_INVALID)); + @DisplayName("현재 약관 버전에 해당하는 리소스가 없으면 AGREEMENT_CONFIGURATION_INVALID 예외") + void throwsForMissingVersionResource() { + AgreementService service = new AgreementService(properties("9.9", "1.0")); + + assertAgreementConfigurationInvalid(service); + } + + @Test + @DisplayName("현재 약관 버전 조회 시 리소스가 없으면 AGREEMENT_CONFIGURATION_INVALID 예외") + void currentVersionsThrowsForMissingVersionResource() { + AgreementService service = new AgreementService(properties("9.9", "1.0")); + + assertAgreementConfigurationInvalid(service::currentVersions); } - private AgreementProperties properties(String tosVersion, String tosContent, - String privacyVersion, String privacyContent) { + private AgreementProperties properties(String tosVersion, String privacyVersion) { return new AgreementProperties( - new AgreementProperties.AgreementDocument( - tosVersion, new ByteArrayResource(tosContent.getBytes(StandardCharsets.UTF_8))), - new AgreementProperties.AgreementDocument( - privacyVersion, new ByteArrayResource(privacyContent.getBytes(StandardCharsets.UTF_8))) + new AgreementProperties.AgreementDocument(tosVersion), + new AgreementProperties.AgreementDocument(privacyVersion) ); } - private Resource unreadableResource() { - return new ByteArrayResource("".getBytes(StandardCharsets.UTF_8)) { - @Override - public String getContentAsString(Charset charset) throws IOException { - throw new IOException("read failed"); - } - }; + private void assertAgreementConfigurationInvalid(AgreementService service) { + assertAgreementConfigurationInvalid(service::getCurrentAgreements); + } + + private void assertAgreementConfigurationInvalid(Runnable action) { + assertThatThrownBy(action::run) + .isInstanceOf(CustomException.class) + .satisfies(e -> assertThat(((CustomException)e).getErrorCode()) + .isEqualTo(ErrorCode.AGREEMENT_CONFIGURATION_INVALID)); } }