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() {