From 5928966eaa1746e7977518e73ac156f7e09e12e9 Mon Sep 17 00:00:00 2001 From: minbros Date: Fri, 5 Jun 2026 17:30:36 +0900 Subject: [PATCH 01/11] =?UTF-8?q?feat:=20=EB=B0=A9=20=EC=B4=88=EA=B8=B0=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20N+1=20=EC=99=84=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/ai/features.md | 4 +- .../controller/BookmarkController.java | 116 ++++--- .../repository/BookmarkRepository.java | 3 + .../bookmarks/service/BookmarkService.java | 86 +++--- .../controller/ScheduleController.java | 115 ++++--- .../controller/dto/ScheduleResponse.java | 43 ++- .../repository/ScheduleItemRepository.java | 9 +- .../repository/ScheduleRepository.java | 23 +- .../schedules/service/ScheduleService.java | 106 ++++--- .../service/dto/ScheduleWithItemsResult.java | 9 + .../bookmarks/BookmarkIntegrationTest.java | 7 + .../controller/BookmarkControllerTest.java | 15 + .../service/BookmarkServiceTest.java | 195 ++++++------ .../schedules/ScheduleIntegrationTest.java | 257 ++++++++-------- .../controller/ScheduleControllerTest.java | 274 +++++++++-------- .../service/ScheduleServiceTest.java | 284 ++++++++++-------- 16 files changed, 869 insertions(+), 677 deletions(-) create mode 100644 src/main/java/com/howaboutus/backend/schedules/service/dto/ScheduleWithItemsResult.java diff --git a/docs/ai/features.md b/docs/ai/features.md index c9b30321..2d5f4bf5 100644 --- a/docs/ai/features.md +++ b/docs/ai/features.md @@ -108,7 +108,7 @@ Caddy는 `caddy-ratelimit` 기반 IP 제한으로 엣지에서 넓고 거친 폭 | 상태 | 기능 | 설명 | ERD 연관 | |------|------|------|----------| | `[x]` | 보관함에 장소 추가 | 검색된 장소를 방의 후보지로 등록. 생성 시 방 소속 `categoryIds`(배열)가 필수이며 한 장소를 여러 카테고리에 동시에 추가할 수 있음 | bookmarks, bookmark_categories | -| `[x]` | 보관함 목록 조회 | 방의 후보지 목록 조회, `categoryId` 쿼리 파라미터로 카테고리별 필터링 지원 | bookmarks | +| `[x]` | 보관함 목록 조회 | 방의 후보지 목록 조회. `categoryId` 쿼리 파라미터를 전달하면 카테고리별 필터링, 생략하면 방 전체 보관함 항목을 한 번에 조회 | bookmarks | | `[x]` | 보관함 항목 삭제 | 후보지에서 제거 | bookmarks | | `[x]` | 보관함 카테고리 목록 조회 | 방에서 사용 가능한 북마크 카테고리 목록 조회, 색상 코드 포함 | bookmark_categories | | `[x]` | 보관함 카테고리 생성 | 방별 사용자 정의 카테고리 생성, 색상 코드 필수 | bookmark_categories | @@ -128,7 +128,7 @@ Caddy는 `caddy-ratelimit` 기반 IP 제한으로 엣지에서 넓고 거친 폭 | 상태 | 기능 | 설명 | ERD 연관 | |------|------|------|----------| | `[x]` | 일정 생성 | N일차 + 날짜 등록. 방 하나에 최대 30개까지 생성 가능하며, 생성 후 방의 여행 기간(`rooms.start_date/end_date`)은 남은 일정 날짜의 최소/최대값으로 동기화 | schedules, rooms | -| `[x]` | 일정 목록 조회 | 방의 전체 일자별 일정 조회 | schedules | +| `[x]` | 일정 목록 조회 | 방의 전체 일자별 일정 조회. `includeItems=true`이면 각 일정의 장소 항목 목록을 함께 반환해 일정 항목 N+1 조회를 피함 | schedules, schedule_items | | `[x]` | 일정 삭제 | 특정 일자 삭제 (하위 items 포함). 삭제 후 방의 여행 기간은 남은 일정 날짜의 최소/최대값으로 동기화하며, 남은 일정이 없으면 `NULL`로 비움 | schedules, rooms | ### 6-2. 일정 항목 (Schedule Items — 장소 단위) 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..07424285 100644 --- a/src/main/java/com/howaboutus/backend/bookmarks/controller/BookmarkController.java +++ b/src/main/java/com/howaboutus/backend/bookmarks/controller/BookmarkController.java @@ -1,21 +1,5 @@ package com.howaboutus.backend.bookmarks.controller; -import java.util.List; -import java.util.UUID; - -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PatchMapping; -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.RequestParam; -import org.springframework.web.bind.annotation.RestController; - import com.howaboutus.backend.bookmarks.controller.dto.BookmarkResponse; import com.howaboutus.backend.bookmarks.controller.dto.CreateBookmarkRequest; import com.howaboutus.backend.bookmarks.controller.dto.UpdateBookmarkCategoryRequest; @@ -23,14 +7,27 @@ import com.howaboutus.backend.common.error.ApiErrorCodes; import com.howaboutus.backend.common.error.ErrorCode; import com.howaboutus.backend.common.logging.Loggable; - import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; +import java.util.List; +import java.util.UUID; import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PatchMapping; +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; @Tag(name = "Bookmarks", description = "보관함 API") @RestController @@ -41,84 +38,79 @@ 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, - ErrorCode.ROOM_NOT_FOUND, ErrorCode.BOOKMARK_CATEGORY_NOT_FOUND, ErrorCode.BOOKMARK_CATEGORY_EMPTY, - ErrorCode.BOOKMARK_ALREADY_EXISTS}) + @ApiErrorCodes({ErrorCode.INVALID_TOKEN, ErrorCode.ACCESS_TOKEN_EXPIRED, ErrorCode.NOT_ROOM_MEMBER, ErrorCode.ROOM_NOT_FOUND, ErrorCode.BOOKMARK_CATEGORY_NOT_FOUND, ErrorCode.BOOKMARK_CATEGORY_EMPTY, ErrorCode.BOOKMARK_ALREADY_EXISTS}) @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}) + @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, - ErrorCode.ROOM_NOT_FOUND, ErrorCode.BOOKMARK_NOT_FOUND, ErrorCode.BOOKMARK_CATEGORY_NOT_FOUND}) + @ApiErrorCodes({ErrorCode.INVALID_TOKEN, ErrorCode.ACCESS_TOKEN_EXPIRED, ErrorCode.NOT_ROOM_MEMBER, ErrorCode.ROOM_NOT_FOUND, ErrorCode.BOOKMARK_NOT_FOUND, ErrorCode.BOOKMARK_CATEGORY_NOT_FOUND}) @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, - ErrorCode.ROOM_NOT_FOUND, ErrorCode.BOOKMARK_NOT_FOUND}) + @ApiErrorCodes({ErrorCode.INVALID_TOKEN, ErrorCode.ACCESS_TOKEN_EXPIRED, ErrorCode.NOT_ROOM_MEMBER, ErrorCode.ROOM_NOT_FOUND, ErrorCode.BOOKMARK_NOT_FOUND}) @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..85390eef 100644 --- a/src/main/java/com/howaboutus/backend/bookmarks/service/BookmarkService.java +++ b/src/main/java/com/howaboutus/backend/bookmarks/service/BookmarkService.java @@ -1,22 +1,11 @@ package com.howaboutus.backend.bookmarks.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.dao.DataIntegrityViolationException; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - import com.howaboutus.backend.bookmarks.entity.Bookmark; -import com.howaboutus.backend.bookmarks.entity.BookmarkCategory; -import com.howaboutus.backend.bookmarks.repository.BookmarkCategoryRepository; import com.howaboutus.backend.bookmarks.repository.BookmarkRepository; import com.howaboutus.backend.bookmarks.service.dto.BookmarkCreateCommand; import com.howaboutus.backend.bookmarks.service.dto.BookmarkResult; +import com.howaboutus.backend.bookmarks.entity.BookmarkCategory; +import com.howaboutus.backend.bookmarks.repository.BookmarkCategoryRepository; import com.howaboutus.backend.common.error.CustomException; import com.howaboutus.backend.common.error.ErrorCode; import com.howaboutus.backend.common.logging.Loggable; @@ -25,7 +14,17 @@ import com.howaboutus.backend.rooms.entity.Room; import com.howaboutus.backend.rooms.service.RoomAccessService; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; + import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor @@ -46,38 +45,38 @@ 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 +84,15 @@ 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 +100,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 +111,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/schedules/controller/ScheduleController.java b/src/main/java/com/howaboutus/backend/schedules/controller/ScheduleController.java index ecf2b86d..ccf1f7dd 100644 --- a/src/main/java/com/howaboutus/backend/schedules/controller/ScheduleController.java +++ b/src/main/java/com/howaboutus/backend/schedules/controller/ScheduleController.java @@ -1,35 +1,34 @@ package com.howaboutus.backend.schedules.controller; +import com.howaboutus.backend.schedules.controller.dto.CreateBatchScheduleRequest; +import com.howaboutus.backend.schedules.controller.dto.CreateScheduleRequest; +import com.howaboutus.backend.schedules.controller.dto.ScheduleResponse; +import com.howaboutus.backend.schedules.service.ScheduleService; +import com.howaboutus.backend.schedules.SchedulePolicy; +import com.howaboutus.backend.common.error.ApiErrorCodes; +import com.howaboutus.backend.common.error.ErrorCode; +import com.howaboutus.backend.common.logging.Loggable; +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.media.Content; +import io.swagger.v3.oas.annotations.tags.Tag; import java.util.List; import java.util.UUID; - +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.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.RequestParam; 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.SchedulePolicy; -import com.howaboutus.backend.schedules.controller.dto.CreateBatchScheduleRequest; -import com.howaboutus.backend.schedules.controller.dto.CreateScheduleRequest; -import com.howaboutus.backend.schedules.controller.dto.ScheduleResponse; -import com.howaboutus.backend.schedules.service.ScheduleService; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -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 lombok.RequiredArgsConstructor; @Tag(name = "Schedules", description = "일정 API") @RestController @@ -40,82 +39,82 @@ public class ScheduleController { private final ScheduleService scheduleService; @Operation( - summary = "일정 생성", - description = "방에 일정을 생성합니다." + summary = "일정 생성", + description = "방에 일정을 생성합니다." ) @ApiResponse(responseCode = "201", description = "생성 성공") - @ApiErrorCodes({ErrorCode.SCHEDULE_DATE_MISMATCH, ErrorCode.SCHEDULE_LIMIT_EXCEEDED, ErrorCode.INVALID_TOKEN, - ErrorCode.ACCESS_TOKEN_EXPIRED, ErrorCode.NOT_ROOM_MEMBER, ErrorCode.ROOM_NOT_FOUND, - ErrorCode.SCHEDULE_ALREADY_EXISTS}) + @ApiErrorCodes({ErrorCode.SCHEDULE_DATE_MISMATCH, ErrorCode.SCHEDULE_LIMIT_EXCEEDED, ErrorCode.INVALID_TOKEN, ErrorCode.ACCESS_TOKEN_EXPIRED, ErrorCode.NOT_ROOM_MEMBER, ErrorCode.ROOM_NOT_FOUND, ErrorCode.SCHEDULE_ALREADY_EXISTS}) @Loggable @PostMapping @SuppressWarnings("JvmTaintAnalysis") public ResponseEntity create( - @AuthenticationPrincipal Long userId, - @Parameter(description = "방 ID", example = "123e4567-e89b-12d3-a456-426614174000") - @PathVariable UUID roomId, - @RequestBody @Valid CreateScheduleRequest request + @AuthenticationPrincipal Long userId, + @Parameter(description = "방 ID", example = "123e4567-e89b-12d3-a456-426614174000") + @PathVariable UUID roomId, + @RequestBody @Valid CreateScheduleRequest request ) { return ResponseEntity.status(HttpStatus.CREATED) - .body(ScheduleResponse.from(scheduleService.create(roomId, request.toCommand(), userId))); + .body(ScheduleResponse.from(scheduleService.create(roomId, request.toCommand(), userId))); } @Operation( - summary = "일정 배치 생성", - description = "방에 여러 일정을 한 번에 생성합니다. 방 하나에 최대 " + SchedulePolicy.MAX_SCHEDULES_PER_ROOM - + "개까지 생성 가능하며, 생성 후 방의 여행 기간(start_date/end_date)이 남은 일정 날짜 기준으로 자동 동기화됩니다." + summary = "일정 배치 생성", + description = "방에 여러 일정을 한 번에 생성합니다. 방 하나에 최대 " + SchedulePolicy.MAX_SCHEDULES_PER_ROOM + "개까지 생성 가능하며, 생성 후 방의 여행 기간(start_date/end_date)이 남은 일정 날짜 기준으로 자동 동기화됩니다." ) @ApiResponse(responseCode = "201", description = "생성 성공") - @ApiErrorCodes({ErrorCode.SCHEDULE_DATE_MISMATCH, ErrorCode.SCHEDULE_LIMIT_EXCEEDED, ErrorCode.INVALID_TOKEN, - ErrorCode.ACCESS_TOKEN_EXPIRED, ErrorCode.NOT_ROOM_MEMBER, ErrorCode.ROOM_NOT_FOUND, - ErrorCode.SCHEDULE_ALREADY_EXISTS}) + @ApiErrorCodes({ErrorCode.SCHEDULE_DATE_MISMATCH, ErrorCode.SCHEDULE_LIMIT_EXCEEDED, ErrorCode.INVALID_TOKEN, ErrorCode.ACCESS_TOKEN_EXPIRED, ErrorCode.NOT_ROOM_MEMBER, ErrorCode.ROOM_NOT_FOUND, ErrorCode.SCHEDULE_ALREADY_EXISTS}) @Loggable @PostMapping("/batch") public ResponseEntity> createBatch( - @AuthenticationPrincipal Long userId, - @Parameter(description = "방 ID", example = "123e4567-e89b-12d3-a456-426614174000") - @PathVariable UUID roomId, - @RequestBody @Valid CreateBatchScheduleRequest request + @AuthenticationPrincipal Long userId, + @Parameter(description = "방 ID", example = "123e4567-e89b-12d3-a456-426614174000") + @PathVariable UUID roomId, + @RequestBody @Valid CreateBatchScheduleRequest request ) { return ResponseEntity.status(HttpStatus.CREATED) - .body(scheduleService.createBatch(roomId, request.toCommand(), userId).stream() - .map(ScheduleResponse::from) - .toList()); + .body(scheduleService.createBatch(roomId, request.toCommand(), userId).stream() + .map(ScheduleResponse::from) + .toList()); } @Operation( - summary = "일정 목록 조회", - description = "방의 일정 목록을 조회합니다." + summary = "일정 목록 조회", + description = "방의 일정 목록을 조회합니다. includeItems=true이면 각 일정의 장소 항목 목록을 함께 반환합니다." ) @ApiResponse(responseCode = "200", description = "조회 성공") - @ApiErrorCodes({ErrorCode.INVALID_TOKEN, ErrorCode.ACCESS_TOKEN_EXPIRED, ErrorCode.NOT_ROOM_MEMBER, - ErrorCode.ROOM_NOT_FOUND}) + @ApiErrorCodes({ErrorCode.INVALID_TOKEN, ErrorCode.ACCESS_TOKEN_EXPIRED, ErrorCode.NOT_ROOM_MEMBER, ErrorCode.ROOM_NOT_FOUND}) @GetMapping public List getSchedules( - @AuthenticationPrincipal Long userId, - @Parameter(description = "방 ID", example = "123e4567-e89b-12d3-a456-426614174000") - @PathVariable UUID roomId + @AuthenticationPrincipal Long userId, + @Parameter(description = "방 ID", example = "123e4567-e89b-12d3-a456-426614174000") + @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(); + .map(ScheduleResponse::from) + .toList(); } @Operation( - summary = "일정 삭제", - description = "방의 일정을 삭제합니다." + summary = "일정 삭제", + description = "방의 일정을 삭제합니다." ) @ApiResponse(responseCode = "204", description = "삭제 성공", content = @Content) - @ApiErrorCodes({ErrorCode.INVALID_TOKEN, ErrorCode.ACCESS_TOKEN_EXPIRED, ErrorCode.NOT_ROOM_MEMBER, - ErrorCode.ROOM_NOT_FOUND, ErrorCode.SCHEDULE_NOT_FOUND}) + @ApiErrorCodes({ErrorCode.INVALID_TOKEN, ErrorCode.ACCESS_TOKEN_EXPIRED, ErrorCode.NOT_ROOM_MEMBER, ErrorCode.ROOM_NOT_FOUND, ErrorCode.SCHEDULE_NOT_FOUND}) @Loggable @DeleteMapping("/{scheduleId}") 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 scheduleId + @AuthenticationPrincipal Long userId, + @Parameter(description = "방 ID", example = "123e4567-e89b-12d3-a456-426614174000") + @PathVariable UUID roomId, + @Parameter(description = "일정 ID", example = "1") + @PathVariable Long scheduleId ) { scheduleService.delete(roomId, scheduleId, userId); return ResponseEntity.noContent().build(); 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..cb6cf9e2 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 @@ -1,26 +1,45 @@ package com.howaboutus.backend.schedules.controller.dto; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.howaboutus.backend.schedules.service.dto.ScheduleResult; +import com.howaboutus.backend.schedules.service.dto.ScheduleWithItemsResult; import java.time.Instant; import java.time.LocalDate; +import java.util.List; import java.util.UUID; -import com.howaboutus.backend.schedules.service.dto.ScheduleResult; - +@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..3e6027d8 100644 --- a/src/main/java/com/howaboutus/backend/schedules/repository/ScheduleItemRepository.java +++ b/src/main/java/com/howaboutus/backend/schedules/repository/ScheduleItemRepository.java @@ -1,13 +1,12 @@ package com.howaboutus.backend.schedules.repository; +import com.howaboutus.backend.schedules.entity.ScheduleItem; import java.util.List; 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; - public interface ScheduleItemRepository extends JpaRepository { Optional findTopBySchedule_IdOrderByOrderIndexDesc(Long scheduleId); @@ -16,8 +15,8 @@ public interface ScheduleItemRepository extends JpaRepository findAllBySchedule_IdOrderByOrderIndexAscIdAsc(Long scheduleId); - List findAllBySchedule_Room_IdOrderBySchedule_DayNumberAscSchedule_IdAscOrderIndexAscIdAsc( - UUID roomId); + @EntityGraph(attributePaths = {"schedule", "schedule.room"}) + List findAllBySchedule_Room_IdOrderBySchedule_DayNumberAscSchedule_IdAscOrderIndexAscIdAsc(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 2849bbd6..692e4c8c 100644 --- a/src/main/java/com/howaboutus/backend/schedules/repository/ScheduleRepository.java +++ b/src/main/java/com/howaboutus/backend/schedules/repository/ScheduleRepository.java @@ -1,17 +1,16 @@ package com.howaboutus.backend.schedules.repository; +import com.howaboutus.backend.schedules.entity.Schedule; import java.time.LocalDate; import java.util.List; import java.util.Optional; import java.util.UUID; - +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -import com.howaboutus.backend.schedules.entity.Schedule; - public interface ScheduleRepository extends JpaRepository { boolean existsByRoom_IdAndDayNumber(UUID roomId, int dayNumber); @@ -22,22 +21,24 @@ public interface ScheduleRepository extends JpaRepository { boolean existsByIdAndRoom_Id(Long scheduleId, UUID roomId); + @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) @Query(""" - update Schedule schedule - set schedule.version = schedule.version + 1 - where schedule.id = :scheduleId - and schedule.room.id = :roomId - and schedule.version = :version - """) + update Schedule schedule + set schedule.version = schedule.version + 1 + where schedule.id = :scheduleId + and schedule.room.id = :roomId + and schedule.version = :version + """) int incrementVersionIfCurrent(@Param("scheduleId") Long scheduleId, - @Param("roomId") UUID roomId, - @Param("version") Long version); + @Param("roomId") UUID roomId, + @Param("version") Long version); } 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 efb3c47b..2fb0ba35 100644 --- a/src/main/java/com/howaboutus/backend/schedules/service/ScheduleService.java +++ b/src/main/java/com/howaboutus/backend/schedules/service/ScheduleService.java @@ -1,19 +1,5 @@ package com.howaboutus.backend.schedules.service; -import java.time.LocalDate; -import java.util.Comparator; -import java.util.HashSet; -import java.util.List; -import java.util.Set; -import java.util.UUID; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.dao.DataIntegrityViolationException; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - import com.howaboutus.backend.common.error.CustomException; import com.howaboutus.backend.common.error.ErrorCode; import com.howaboutus.backend.common.logging.Loggable; @@ -21,14 +7,30 @@ import com.howaboutus.backend.realtime.service.dto.RoomScheduleEventType; import com.howaboutus.backend.rooms.entity.Room; import com.howaboutus.backend.rooms.service.RoomAccessService; -import com.howaboutus.backend.schedules.SchedulePolicy; 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.ScheduleResult; - +import com.howaboutus.backend.schedules.service.dto.ScheduleWithItemsResult; +import java.time.LocalDate; +import java.util.Comparator; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; +import java.util.stream.Stream; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.dao.DataIntegrityViolationException; +import com.howaboutus.backend.schedules.SchedulePolicy; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor @@ -37,6 +39,7 @@ public class ScheduleService { private final RoomAccessService roomAccessService; private final ScheduleRepository scheduleRepository; + private final ScheduleItemRepository scheduleItemRepository; private final ScheduleItemService scheduleItemService; private final ApplicationEventPublisher eventPublisher; @@ -48,7 +51,7 @@ public ScheduleResult create(UUID roomId, ScheduleCreateCommand command, Long us validateScheduleDate(room, command.dayNumber(), command.date()); validateSingleScheduleLimit(roomId); if (scheduleRepository.existsByRoom_IdAndDayNumber(roomId, command.dayNumber()) - || scheduleRepository.existsByRoom_IdAndDate(roomId, command.date())) { + || scheduleRepository.existsByRoom_IdAndDate(roomId, command.date())) { throw new CustomException(ErrorCode.SCHEDULE_ALREADY_EXISTS); } @@ -95,16 +98,16 @@ public List createBatch(UUID roomId, ScheduleBatchCreateCommand } List schedules = commands.stream() - .map(cmd -> Schedule.create(room, cmd.dayNumber(), cmd.date())) - .toList(); + .map(cmd -> Schedule.create(room, cmd.dayNumber(), cmd.date())) + .toList(); try { List saved = scheduleRepository.saveAllAndFlush(schedules); syncRoomDateRangeFromMemory(room, existing, saved); List scheduleIds = saved.stream().map(Schedule::getId).toList(); eventPublisher.publishEvent(new RoomScheduleChangedEvent( - roomId, userId, RoomScheduleEventType.SCHEDULES_BATCH_CREATED, - null, null, List.of(), scheduleIds + roomId, userId, RoomScheduleEventType.SCHEDULES_BATCH_CREATED, + null, null, List.of(), scheduleIds )); return saved.stream().map(ScheduleResult::from).toList(); @@ -116,9 +119,33 @@ public List createBatch(UUID roomId, ScheduleBatchCreateCommand public List getSchedules(UUID roomId, Long userId) { roomAccessService.requireExistingActiveMember(roomId, userId); return scheduleRepository.findAllByRoom_IdOrderByDayNumberAsc(roomId) - .stream() - .map(ScheduleResult::from) - .toList(); + .stream() + .map(ScheduleResult::from) + .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 @@ -127,7 +154,7 @@ public void delete(UUID roomId, Long scheduleId, Long userId) { Room room = roomAccessService.getRoom(roomId); roomAccessService.requireExistingActiveMember(roomId, userId); Schedule schedule = scheduleRepository.findByIdAndRoom_Id(scheduleId, roomId) - .orElseThrow(() -> new CustomException(ErrorCode.SCHEDULE_NOT_FOUND)); + .orElseThrow(() -> new CustomException(ErrorCode.SCHEDULE_NOT_FOUND)); scheduleItemService.deleteAllByScheduleId(scheduleId); scheduleRepository.delete(schedule); syncRoomDateRange(room, roomId); @@ -165,32 +192,31 @@ private void validateBatchScheduleLimit(int existingCount, int requestedCount) { private void syncRoomDateRangeFromMemory(Room room, List existing, List saved) { LocalDate startDate = Stream.concat(existing.stream(), saved.stream()) - .map(Schedule::getDate) - .min(Comparator.naturalOrder()) - .orElse(null); + .map(Schedule::getDate) + .min(Comparator.naturalOrder()) + .orElse(null); LocalDate endDate = Stream.concat(existing.stream(), saved.stream()) - .map(Schedule::getDate) - .max(Comparator.naturalOrder()) - .orElse(null); + .map(Schedule::getDate) + .max(Comparator.naturalOrder()) + .orElse(null); room.syncTravelDates(startDate, endDate); } private void syncRoomDateRange(Room room, UUID roomId) { List schedules = scheduleRepository.findAllByRoom_IdOrderByDayNumberAsc(roomId); LocalDate startDate = schedules.stream() - .map(Schedule::getDate) - .min(Comparator.naturalOrder()) - .orElse(null); + .map(Schedule::getDate) + .min(Comparator.naturalOrder()) + .orElse(null); LocalDate endDate = schedules.stream() - .map(Schedule::getDate) - .max(Comparator.naturalOrder()) - .orElse(null); + .map(Schedule::getDate) + .max(Comparator.naturalOrder()) + .orElse(null); room.syncTravelDates(startDate, endDate); } private void publishChanged(UUID roomId, Long actorUserId, RoomScheduleEventType type, Long scheduleId, - Long itemId) { - eventPublisher.publishEvent( - new RoomScheduleChangedEvent(roomId, actorUserId, type, scheduleId, itemId, List.of(), List.of())); + Long itemId) { + eventPublisher.publishEvent(new RoomScheduleChangedEvent(roomId, actorUserId, type, scheduleId, itemId, List.of(), List.of())); } } 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/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..551ab315 100644 --- a/src/test/java/com/howaboutus/backend/bookmarks/service/BookmarkServiceTest.java +++ b/src/test/java/com/howaboutus/backend/bookmarks/service/BookmarkServiceTest.java @@ -1,15 +1,29 @@ 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.verify; +import static org.mockito.Mockito.times; +import com.howaboutus.backend.bookmarks.entity.Bookmark; +import com.howaboutus.backend.bookmarks.entity.BookmarkCategory; +import com.howaboutus.backend.bookmarks.repository.BookmarkCategoryRepository; +import com.howaboutus.backend.bookmarks.repository.BookmarkRepository; +import com.howaboutus.backend.bookmarks.service.dto.BookmarkCreateCommand; +import com.howaboutus.backend.bookmarks.service.dto.BookmarkResult; +import com.howaboutus.backend.common.error.CustomException; +import com.howaboutus.backend.common.error.ErrorCode; +import com.howaboutus.backend.realtime.event.RoomBookmarkChangedEvent; +import com.howaboutus.backend.realtime.service.dto.RoomBookmarkEventType; +import com.howaboutus.backend.rooms.entity.Room; +import com.howaboutus.backend.rooms.service.RoomAccessService; import java.time.Instant; import java.util.List; import java.util.Optional; import java.util.UUID; - import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -17,23 +31,10 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.context.ApplicationEventPublisher; import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.test.util.ReflectionTestUtils; -import com.howaboutus.backend.bookmarks.entity.Bookmark; -import com.howaboutus.backend.bookmarks.entity.BookmarkCategory; -import com.howaboutus.backend.bookmarks.repository.BookmarkCategoryRepository; -import com.howaboutus.backend.bookmarks.repository.BookmarkRepository; -import com.howaboutus.backend.bookmarks.service.dto.BookmarkCreateCommand; -import com.howaboutus.backend.bookmarks.service.dto.BookmarkResult; -import com.howaboutus.backend.common.error.CustomException; -import com.howaboutus.backend.common.error.ErrorCode; -import com.howaboutus.backend.realtime.event.RoomBookmarkChangedEvent; -import com.howaboutus.backend.realtime.service.dto.RoomBookmarkEventType; -import com.howaboutus.backend.rooms.entity.Room; -import com.howaboutus.backend.rooms.service.RoomAccessService; - @ExtendWith(MockitoExtension.class) class BookmarkServiceTest { @@ -54,7 +55,7 @@ class BookmarkServiceTest { @BeforeEach void setUp() { bookmarkService = new BookmarkService(roomAccessService, bookmarkRepository, bookmarkCategoryRepository, - eventPublisher); + eventPublisher); } @Test @@ -73,13 +74,12 @@ 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 +90,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 +119,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 +161,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 +186,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 +202,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 +217,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 +239,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 +255,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 +277,7 @@ 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 +286,34 @@ 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 +341,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 +366,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 +380,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 +404,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 +426,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/schedules/ScheduleIntegrationTest.java b/src/test/java/com/howaboutus/backend/schedules/ScheduleIntegrationTest.java index e65feaec..c248c439 100644 --- a/src/test/java/com/howaboutus/backend/schedules/ScheduleIntegrationTest.java +++ b/src/test/java/com/howaboutus/backend/schedules/ScheduleIntegrationTest.java @@ -1,24 +1,12 @@ package com.howaboutus.backend.schedules; -import static org.assertj.core.api.Assertions.*; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - -import java.time.LocalDate; -import java.time.LocalTime; -import java.util.Optional; -import java.util.UUID; - -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.mockito.BDDMockito; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; -import org.springframework.http.MediaType; -import org.springframework.test.context.bean.override.mockito.MockitoBean; -import org.springframework.test.web.servlet.MockMvc; +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.howaboutus.backend.auth.service.JwtProvider; import com.howaboutus.backend.rooms.entity.Room; @@ -34,8 +22,21 @@ import com.howaboutus.backend.user.entity.User; import com.howaboutus.backend.user.repository.UserRepository; import com.jayway.jsonpath.JsonPath; - import jakarta.servlet.http.Cookie; +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.BDDMockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; @AutoConfigureMockMvc @SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection") @@ -83,59 +84,59 @@ void tearDown() { @DisplayName("일정 생성 조회 삭제가 HTTP 흐름으로 동작한다") void scheduleFlowWorksEndToEnd() throws Exception { Room room = roomRepository.save(Room.create( - "서울 여행", - "서울", - LocalDate.of(2026, 5, 1), - LocalDate.of(2026, 5, 3), - "SEOUL-SCH-1", - 1L + "서울 여행", + "서울", + LocalDate.of(2026, 5, 1), + LocalDate.of(2026, 5, 3), + "SEOUL-SCH-1", + 1L )); authorizeRequestUserAsMember(room); String createResponse = mockMvc.perform(post("/rooms/{roomId}/schedules", room.getId()) - .cookie(new Cookie("access_token", VALID_TOKEN)) - .contentType(MediaType.APPLICATION_JSON) - .content(""" - { - "dayNumber": 1, - "date": "2026-05-01" - } - """)) - .andExpect(status().isCreated()) - .andExpect(jsonPath("$.scheduleId").isNumber()) - .andExpect(jsonPath("$.roomId").value(room.getId().toString())) - .andExpect(jsonPath("$.dayNumber").value(1)) - .andExpect(jsonPath("$.date").value("2026-05-01")) - .andExpect(jsonPath("$.createdAt").isString()) - .andReturn() - .getResponse() - .getContentAsString(); - - Long scheduleId = ((Number)JsonPath.read(createResponse, "$.scheduleId")).longValue(); + .cookie(new Cookie("access_token", VALID_TOKEN)) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "dayNumber": 1, + "date": "2026-05-01" + } + """)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.scheduleId").isNumber()) + .andExpect(jsonPath("$.roomId").value(room.getId().toString())) + .andExpect(jsonPath("$.dayNumber").value(1)) + .andExpect(jsonPath("$.date").value("2026-05-01")) + .andExpect(jsonPath("$.createdAt").isString()) + .andReturn() + .getResponse() + .getContentAsString(); + + Long scheduleId = ((Number) JsonPath.read(createResponse, "$.scheduleId")).longValue(); String createdAt = JsonPath.read(createResponse, "$.createdAt"); Optional savedSchedule = scheduleRepository.findAllByRoom_IdOrderByDayNumberAsc(room.getId()) - .stream() - .filter(schedule -> schedule.getId().equals(scheduleId)) - .findFirst(); + .stream() + .filter(schedule -> schedule.getId().equals(scheduleId)) + .findFirst(); assertThat(savedSchedule).isPresent(); String persistedCreatedAt = savedSchedule.orElseThrow().getCreatedAt().toString(); assertThat(createdAt).isEqualTo(persistedCreatedAt); mockMvc.perform(get("/rooms/{roomId}/schedules", room.getId()) - .cookie(new Cookie("access_token", VALID_TOKEN))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.length()").value(1)) - .andExpect(jsonPath("$[0].scheduleId").value(scheduleId)) - .andExpect(jsonPath("$[0].roomId").value(room.getId().toString())) - .andExpect(jsonPath("$[0].dayNumber").value(1)) - .andExpect(jsonPath("$[0].date").value("2026-05-01")) - .andExpect(jsonPath("$[0].createdAt").value(persistedCreatedAt)); + .cookie(new Cookie("access_token", VALID_TOKEN))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(1)) + .andExpect(jsonPath("$[0].scheduleId").value(scheduleId)) + .andExpect(jsonPath("$[0].roomId").value(room.getId().toString())) + .andExpect(jsonPath("$[0].dayNumber").value(1)) + .andExpect(jsonPath("$[0].date").value("2026-05-01")) + .andExpect(jsonPath("$[0].createdAt").value(persistedCreatedAt)); mockMvc.perform(delete("/rooms/{roomId}/schedules/{scheduleId}", room.getId(), scheduleId) - .cookie(new Cookie("access_token", VALID_TOKEN))) - .andExpect(status().isNoContent()); + .cookie(new Cookie("access_token", VALID_TOKEN))) + .andExpect(status().isNoContent()); assertThat(scheduleRepository.findAll()).isEmpty(); } @@ -144,54 +145,62 @@ void scheduleFlowWorksEndToEnd() throws Exception { @DisplayName("일정 항목 생성 후 목록 조회, 시간 수정, 삭제 시 순서 재정렬이 동작한다") void scheduleItemFlowWorksEndToEnd() throws Exception { Room room = roomRepository.save(Room.create( - "도쿄 여행", - "도쿄", - LocalDate.of(2026, 5, 1), - LocalDate.of(2026, 5, 3), - "TOKYO-ITEM-INTEGRATION", - 1L + "도쿄 여행", + "도쿄", + LocalDate.of(2026, 5, 1), + LocalDate.of(2026, 5, 3), + "TOKYO-ITEM-INTEGRATION", + 1L )); authorizeRequestUserAsMember(room); Schedule schedule = scheduleRepository.saveAndFlush(Schedule.create(room, 1, LocalDate.of(2026, 5, 1))); mockMvc.perform(post("/rooms/{roomId}/schedules/{scheduleId}/items", room.getId(), schedule.getId()) - .cookie(new Cookie("access_token", VALID_TOKEN)) - .contentType(MediaType.APPLICATION_JSON) - .content(""" - { - "googlePlaceId": "place-1", - "startTime": "09:00", - "durationMinutes": 90 - } - """)) - .andExpect(status().isCreated()) - .andExpect(jsonPath("$.orderIndex").value(0)) - .andExpect(jsonPath("$.memo").doesNotExist()); + .cookie(new Cookie("access_token", VALID_TOKEN)) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "googlePlaceId": "place-1", + "startTime": "09:00", + "durationMinutes": 90 + } + """)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.orderIndex").value(0)) + .andExpect(jsonPath("$.memo").doesNotExist()); mockMvc.perform(post("/rooms/{roomId}/schedules/{scheduleId}/items", room.getId(), schedule.getId()) - .cookie(new Cookie("access_token", VALID_TOKEN)) - .contentType(MediaType.APPLICATION_JSON) - .content(""" - {"googlePlaceId": "place-2"} - """)) - .andExpect(status().isCreated()) - .andExpect(jsonPath("$.orderIndex").value(1)); + .cookie(new Cookie("access_token", VALID_TOKEN)) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"googlePlaceId": "place-2"} + """)) + .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(); + ScheduleItem firstItem = scheduleItemRepository.findAllBySchedule_IdOrderByOrderIndexAsc(schedule.getId()).getFirst(); mockMvc.perform(patch("/rooms/{roomId}/schedules/{scheduleId}/items/{itemId}", room.getId(), schedule.getId(), - firstItem.getId()) - .cookie(new Cookie("access_token", VALID_TOKEN)) - .contentType(MediaType.APPLICATION_JSON) - .content(""" - {"durationMinutes": 120, "memo": "예약 확인 후 입장"} - """)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.startTime").value("09:00")) - .andExpect(jsonPath("$.durationMinutes").value(120)) - .andExpect(jsonPath("$.memo").value("예약 확인 후 입장")); + firstItem.getId()) + .cookie(new Cookie("access_token", VALID_TOKEN)) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"durationMinutes": 120, "memo": "예약 확인 후 입장"} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.startTime").value("09:00")) + .andExpect(jsonPath("$.durationMinutes").value(120)) + .andExpect(jsonPath("$.memo").value("예약 확인 후 입장")); ScheduleItem updatedFirstItem = scheduleItemRepository.findById(firstItem.getId()).orElseThrow(); assertThat(updatedFirstItem.getStartTime()).isEqualTo(LocalTime.of(9, 0)); @@ -199,28 +208,28 @@ void scheduleItemFlowWorksEndToEnd() throws Exception { assertThat(updatedFirstItem.getMemo()).isEqualTo("예약 확인 후 입장"); mockMvc.perform(patch("/rooms/{roomId}/schedules/{scheduleId}/items/{itemId}", room.getId(), schedule.getId(), - firstItem.getId()) - .cookie(new Cookie("access_token", VALID_TOKEN)) - .contentType(MediaType.APPLICATION_JSON) - .content(""" - {"memo": ""} - """)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.memo").doesNotExist()); + firstItem.getId()) + .cookie(new Cookie("access_token", VALID_TOKEN)) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"memo": ""} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.memo").doesNotExist()); ScheduleItem memoClearedItem = scheduleItemRepository.findById(firstItem.getId()).orElseThrow(); assertThat(memoClearedItem.getMemo()).isNull(); mockMvc.perform(delete("/rooms/{roomId}/schedules/{scheduleId}/items/{itemId}", room.getId(), schedule.getId(), - firstItem.getId()) - .cookie(new Cookie("access_token", VALID_TOKEN))) - .andExpect(status().isNoContent()); + firstItem.getId()) + .cookie(new Cookie("access_token", VALID_TOKEN))) + .andExpect(status().isNoContent()); mockMvc.perform(get("/rooms/{roomId}/schedules/{scheduleId}/items", room.getId(), schedule.getId()) - .cookie(new Cookie("access_token", VALID_TOKEN))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$[0].orderIndex").value(0)) - .andExpect(jsonPath("$[0].googlePlaceId").value("place-2")); + .cookie(new Cookie("access_token", VALID_TOKEN))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].orderIndex").value(0)) + .andExpect(jsonPath("$[0].googlePlaceId").value("place-2")); } @Test @@ -229,24 +238,24 @@ void scheduleEndpointsRejectMissingRoom() throws Exception { UUID roomId = UUID.randomUUID(); mockMvc.perform(post("/rooms/{roomId}/schedules", roomId) - .cookie(new Cookie("access_token", VALID_TOKEN)) - .contentType(MediaType.APPLICATION_JSON) - .content(""" - { - "dayNumber": 1, - "date": "2026-06-01" - } - """)) - .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.code").value("ROOM_NOT_FOUND")); + .cookie(new Cookie("access_token", VALID_TOKEN)) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "dayNumber": 1, + "date": "2026-06-01" + } + """)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("ROOM_NOT_FOUND")); } private void authorizeRequestUserAsMember(Room room) { User user = userRepository.save(User.ofGoogle( - "schedule-" + room.getId(), - "schedule-" + room.getId() + "@test.com", - "일정테스터", - null + "schedule-" + room.getId(), + "schedule-" + room.getId() + "@test.com", + "일정테스터", + null )); BDDMockito.given(jwtProvider.extractUserId(VALID_TOKEN)).willReturn(user.getId()); roomMemberRepository.saveAndFlush(RoomMember.create(room, user, RoomRole.MEMBER)); 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 c2be41ff..bfa2a784 100644 --- a/src/test/java/com/howaboutus/backend/schedules/controller/ScheduleControllerTest.java +++ b/src/test/java/com/howaboutus/backend/schedules/controller/ScheduleControllerTest.java @@ -1,32 +1,27 @@ 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 static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import com.howaboutus.backend.auth.filter.JwtAuthenticationFilter; +import com.howaboutus.backend.auth.service.JwtProvider; 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; import java.util.stream.IntStream; -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; @@ -34,30 +29,31 @@ 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.ScheduleResult; - +import com.howaboutus.backend.schedules.service.dto.ScheduleWithItemsResult; import jakarta.servlet.http.Cookie; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.BeforeEach; +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; @WebMvcTest(ScheduleController.class) -@Import({SecurityConfig.class, JwtAuthenticationFilter.class, JwtAuthenticationEntryPoint.class, - GlobalExceptionHandler.class}) +@Import({SecurityConfig.class, JwtAuthenticationFilter.class, JwtAuthenticationEntryPoint.class, GlobalExceptionHandler.class}) class ScheduleControllerTest { - 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; - private static final ScheduleResult SCHEDULE_RESULT = new ScheduleResult( - SCHEDULE_ID, - ROOM_ID, - 2, - LocalDate.of(2025, 1, 2), - Instant.parse("2025-01-01T00:00:00Z") - ); @Autowired private MockMvc mockMvc; + @MockitoBean private JwtProvider jwtProvider; + @MockitoBean private ScheduleService scheduleService; @@ -70,14 +66,14 @@ void setUp() { @DisplayName("dayNumber가 없으면 400을 반환하고 서비스는 호출하지 않는다") void returnsBadRequestWhenDayNumberIsMissing() throws Exception { mockMvc.perform(post("/rooms/{roomId}/schedules", ROOM_ID) - .cookie(new Cookie("access_token", VALID_TOKEN)) - .contentType(MediaType.APPLICATION_JSON) - .content(""" - {"date": "2025-01-02"} - """)) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.code").value("BAD_REQUEST")) - .andExpect(jsonPath("$.message").value("dayNumber는 필수입니다")); + .cookie(new Cookie("access_token", VALID_TOKEN)) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"date": "2025-01-02"} + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("BAD_REQUEST")) + .andExpect(jsonPath("$.message").value("dayNumber는 필수입니다")); verifyNoInteractions(scheduleService); } @@ -86,14 +82,14 @@ void returnsBadRequestWhenDayNumberIsMissing() throws Exception { @DisplayName("dayNumber가 1보다 작으면 400을 반환하고 서비스는 호출하지 않는다") void returnsBadRequestWhenDayNumberIsLessThanOne() throws Exception { mockMvc.perform(post("/rooms/{roomId}/schedules", ROOM_ID) - .cookie(new Cookie("access_token", VALID_TOKEN)) - .contentType(MediaType.APPLICATION_JSON) - .content(""" - {"dayNumber": 0, "date": "2025-01-02"} - """)) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.code").value("BAD_REQUEST")) - .andExpect(jsonPath("$.message").value("dayNumber는 1 이상이어야 합니다")); + .cookie(new Cookie("access_token", VALID_TOKEN)) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"dayNumber": 0, "date": "2025-01-02"} + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("BAD_REQUEST")) + .andExpect(jsonPath("$.message").value("dayNumber는 1 이상이어야 합니다")); verifyNoInteractions(scheduleService); } @@ -102,14 +98,14 @@ void returnsBadRequestWhenDayNumberIsLessThanOne() throws Exception { @DisplayName("date가 없으면 400을 반환하고 서비스는 호출하지 않는다") void returnsBadRequestWhenDateIsMissing() throws Exception { mockMvc.perform(post("/rooms/{roomId}/schedules", ROOM_ID) - .cookie(new Cookie("access_token", VALID_TOKEN)) - .contentType(MediaType.APPLICATION_JSON) - .content(""" - {"dayNumber": 2} - """)) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.code").value("BAD_REQUEST")) - .andExpect(jsonPath("$.message").value("date는 필수입니다")); + .cookie(new Cookie("access_token", VALID_TOKEN)) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"dayNumber": 2} + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("BAD_REQUEST")) + .andExpect(jsonPath("$.message").value("date는 필수입니다")); verifyNoInteractions(scheduleService); } @@ -118,14 +114,14 @@ void returnsBadRequestWhenDateIsMissing() throws Exception { @DisplayName("date 형식이 잘못되면 400을 반환하고 서비스는 호출하지 않는다") void returnsBadRequestWhenDateFormatIsInvalid() throws Exception { mockMvc.perform(post("/rooms/{roomId}/schedules", ROOM_ID) - .cookie(new Cookie("access_token", VALID_TOKEN)) - .contentType(MediaType.APPLICATION_JSON) - .content(""" - {"dayNumber": 2, "date": "2025-13-40"} - """)) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.code").value("BAD_REQUEST")) - .andExpect(jsonPath("$.message").value("요청 본문 형식이 올바르지 않습니다")); + .cookie(new Cookie("access_token", VALID_TOKEN)) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"dayNumber": 2, "date": "2025-13-40"} + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("BAD_REQUEST")) + .andExpect(jsonPath("$.message").value("요청 본문 형식이 올바르지 않습니다")); verifyNoInteractions(scheduleService); } @@ -133,21 +129,20 @@ void returnsBadRequestWhenDateFormatIsInvalid() throws Exception { @Test @DisplayName("일정 생성 성공 시 201을 반환한다") void createsScheduleSuccessfully() throws Exception { - given(scheduleService.create(eq(ROOM_ID), any(ScheduleCreateCommand.class), eq(USER_ID))).willReturn( - SCHEDULE_RESULT); + given(scheduleService.create(eq(ROOM_ID), any(ScheduleCreateCommand.class), eq(USER_ID))).willReturn(SCHEDULE_RESULT); mockMvc.perform(post("/rooms/{roomId}/schedules", ROOM_ID) - .cookie(new Cookie("access_token", VALID_TOKEN)) - .contentType(MediaType.APPLICATION_JSON) - .content(""" - {"dayNumber": 2, "date": "2025-01-02"} - """)) - .andExpect(status().isCreated()) - .andExpect(jsonPath("$.scheduleId").value(SCHEDULE_RESULT.scheduleId())) - .andExpect(jsonPath("$.roomId").value(ROOM_ID.toString())) - .andExpect(jsonPath("$.dayNumber").value(SCHEDULE_RESULT.dayNumber())) - .andExpect(jsonPath("$.date").value(SCHEDULE_RESULT.date().toString())) - .andExpect(jsonPath("$.createdAt").value(SCHEDULE_RESULT.createdAt().toString())); + .cookie(new Cookie("access_token", VALID_TOKEN)) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"dayNumber": 2, "date": "2025-01-02"} + """)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.scheduleId").value(SCHEDULE_RESULT.scheduleId())) + .andExpect(jsonPath("$.roomId").value(ROOM_ID.toString())) + .andExpect(jsonPath("$.dayNumber").value(SCHEDULE_RESULT.dayNumber())) + .andExpect(jsonPath("$.date").value(SCHEDULE_RESULT.date().toString())) + .andExpect(jsonPath("$.createdAt").value(SCHEDULE_RESULT.createdAt().toString())); ArgumentCaptor captor = ArgumentCaptor.forClass(ScheduleCreateCommand.class); then(scheduleService).should().create(eq(ROOM_ID), captor.capture(), eq(USER_ID)); @@ -161,49 +156,79 @@ void returnsScheduleListSuccessfully() throws Exception { given(scheduleService.getSchedules(ROOM_ID, USER_ID)).willReturn(List.of(SCHEDULE_RESULT)); mockMvc.perform(get("/rooms/{roomId}/schedules", ROOM_ID) - .cookie(new Cookie("access_token", VALID_TOKEN))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$[0].scheduleId").value(SCHEDULE_RESULT.scheduleId())) - .andExpect(jsonPath("$[0].roomId").value(ROOM_ID.toString())) - .andExpect(jsonPath("$[0].dayNumber").value(SCHEDULE_RESULT.dayNumber())) - .andExpect(jsonPath("$[0].date").value(SCHEDULE_RESULT.date().toString())) - .andExpect(jsonPath("$[0].createdAt").value(SCHEDULE_RESULT.createdAt().toString())); + .cookie(new Cookie("access_token", VALID_TOKEN))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].scheduleId").value(SCHEDULE_RESULT.scheduleId())) + .andExpect(jsonPath("$[0].roomId").value(ROOM_ID.toString())) + .andExpect(jsonPath("$[0].dayNumber").value(SCHEDULE_RESULT.dayNumber())) + .andExpect(jsonPath("$[0].date").value(SCHEDULE_RESULT.date().toString())) + .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 { given(scheduleService.createBatch(eq(ROOM_ID), any(ScheduleBatchCreateCommand.class), eq(USER_ID))) - .willReturn(List.of(SCHEDULE_RESULT)); + .willReturn(List.of(SCHEDULE_RESULT)); mockMvc.perform(post("/rooms/{roomId}/schedules/batch", ROOM_ID) - .cookie(new Cookie("access_token", VALID_TOKEN)) - .contentType(MediaType.APPLICATION_JSON) - .content(""" - {"schedules": [{"dayNumber": 2, "date": "2025-01-02"}]} - """)) - .andExpect(status().isCreated()) - .andExpect(jsonPath("$[0].scheduleId").value(SCHEDULE_RESULT.scheduleId())) - .andExpect(jsonPath("$[0].roomId").value(ROOM_ID.toString())); + .cookie(new Cookie("access_token", VALID_TOKEN)) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"schedules": [{"dayNumber": 2, "date": "2025-01-02"}]} + """)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$[0].scheduleId").value(SCHEDULE_RESULT.scheduleId())) + .andExpect(jsonPath("$[0].roomId").value(ROOM_ID.toString())); } @Test @DisplayName("배치 요청에 30개를 보내면 성공한다") void createBatchWith30SchedulesSucceeds() throws Exception { given(scheduleService.createBatch(eq(ROOM_ID), any(ScheduleBatchCreateCommand.class), eq(USER_ID))) - .willReturn(List.of(SCHEDULE_RESULT)); + .willReturn(List.of(SCHEDULE_RESULT)); LocalDate baseDate = LocalDate.of(2025, 1, 1); String schedulesJson = IntStream.range(0, 30) - .mapToObj(i -> """ - {"dayNumber": %d, "date": "%s"}""".formatted(i + 1, baseDate.plusDays(i))) - .collect(Collectors.joining(",", "[", "]")); + .mapToObj(i -> """ + {"dayNumber": %d, "date": "%s"}""".formatted(i + 1, baseDate.plusDays(i))) + .collect(Collectors.joining(",", "[", "]")); mockMvc.perform(post("/rooms/{roomId}/schedules/batch", ROOM_ID) - .cookie(new Cookie("access_token", VALID_TOKEN)) - .contentType(MediaType.APPLICATION_JSON) - .content("{\"schedules\": " + schedulesJson + "}")) - .andExpect(status().isCreated()); + .cookie(new Cookie("access_token", VALID_TOKEN)) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"schedules\": " + schedulesJson + "}")) + .andExpect(status().isCreated()); } @Test @@ -211,17 +236,16 @@ void createBatchWith30SchedulesSucceeds() throws Exception { void createBatchWith31SchedulesReturnsBadRequest() throws Exception { LocalDate baseDate = LocalDate.of(2025, 1, 1); String schedulesJson = IntStream.range(0, 31) - .mapToObj(i -> """ - {"dayNumber": %d, "date": "%s"}""".formatted(i + 1, baseDate.plusDays(i))) - .collect(Collectors.joining(",", "[", "]")); + .mapToObj(i -> """ + {"dayNumber": %d, "date": "%s"}""".formatted(i + 1, baseDate.plusDays(i))) + .collect(Collectors.joining(",", "[", "]")); mockMvc.perform(post("/rooms/{roomId}/schedules/batch", ROOM_ID) - .cookie(new Cookie("access_token", VALID_TOKEN)) - .contentType(MediaType.APPLICATION_JSON) - .content("{\"schedules\": " + schedulesJson + "}")) - .andExpect(status().isBadRequest()) - .andExpect( - jsonPath("$.message").value("한 번에 최대 " + SchedulePolicy.MAX_SCHEDULES_PER_ROOM + "개의 일정만 생성할 수 있습니다")); + .cookie(new Cookie("access_token", VALID_TOKEN)) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"schedules\": " + schedulesJson + "}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("한 번에 최대 " + SchedulePolicy.MAX_SCHEDULES_PER_ROOM + "개의 일정만 생성할 수 있습니다")); verifyNoInteractions(scheduleService); } @@ -230,12 +254,12 @@ void createBatchWith31SchedulesReturnsBadRequest() throws Exception { @DisplayName("배치 요청에 schedules가 비어있으면 400을 반환한다") void createBatchReturnsBadRequestWhenEmpty() throws Exception { mockMvc.perform(post("/rooms/{roomId}/schedules/batch", ROOM_ID) - .cookie(new Cookie("access_token", VALID_TOKEN)) - .contentType(MediaType.APPLICATION_JSON) - .content(""" - {"schedules": []} - """)) - .andExpect(status().isBadRequest()); + .cookie(new Cookie("access_token", VALID_TOKEN)) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"schedules": []} + """)) + .andExpect(status().isBadRequest()); verifyNoInteractions(scheduleService); } @@ -244,9 +268,21 @@ void createBatchReturnsBadRequestWhenEmpty() throws Exception { @DisplayName("일정 삭제 성공 시 204를 반환한다") void deletesScheduleSuccessfully() throws Exception { mockMvc.perform(delete("/rooms/{roomId}/schedules/{scheduleId}", ROOM_ID, SCHEDULE_ID) - .cookie(new Cookie("access_token", VALID_TOKEN))) - .andExpect(status().isNoContent()); + .cookie(new Cookie("access_token", VALID_TOKEN))) + .andExpect(status().isNoContent()); then(scheduleService).should().delete(ROOM_ID, SCHEDULE_ID, USER_ID); } + + 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; + private static final ScheduleResult SCHEDULE_RESULT = new ScheduleResult( + SCHEDULE_ID, + ROOM_ID, + 2, + LocalDate.of(2025, 1, 2), + Instant.parse("2025-01-01T00:00:00Z") + ); } 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 8a9ead7d..52862251 100644 --- a/src/test/java/com/howaboutus/backend/schedules/service/ScheduleServiceTest.java +++ b/src/test/java/com/howaboutus/backend/schedules/service/ScheduleServiceTest.java @@ -1,10 +1,30 @@ package com.howaboutus.backend.schedules.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.doThrow; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import com.howaboutus.backend.common.error.CustomException; +import com.howaboutus.backend.common.error.ErrorCode; +import com.howaboutus.backend.realtime.event.RoomScheduleChangedEvent; +import com.howaboutus.backend.realtime.service.dto.RoomScheduleEventType; +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.SchedulePolicy; +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.ScheduleResult; +import com.howaboutus.backend.schedules.service.dto.ScheduleWithItemsResult; import java.time.Instant; import java.time.LocalDate; import java.util.List; @@ -12,7 +32,6 @@ import java.util.UUID; import java.util.stream.IntStream; import java.util.stream.Stream; - import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -23,29 +42,19 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.context.ApplicationEventPublisher; import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.test.util.ReflectionTestUtils; -import com.howaboutus.backend.common.error.CustomException; -import com.howaboutus.backend.common.error.ErrorCode; -import com.howaboutus.backend.realtime.event.RoomScheduleChangedEvent; -import com.howaboutus.backend.realtime.service.dto.RoomScheduleEventType; -import com.howaboutus.backend.rooms.entity.Room; -import com.howaboutus.backend.rooms.service.RoomAccessService; -import com.howaboutus.backend.schedules.SchedulePolicy; -import com.howaboutus.backend.schedules.entity.Schedule; -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.ScheduleResult; - @ExtendWith(MockitoExtension.class) class ScheduleServiceTest { @Mock private ScheduleRepository scheduleRepository; + @Mock + private ScheduleItemRepository scheduleItemRepository; + @Mock private ScheduleItemService scheduleItemService; @@ -57,21 +66,10 @@ class ScheduleServiceTest { private ScheduleService scheduleService; - private static Stream invalidScheduleCreateCommands() { - return Stream.of( - Arguments.of("일차가 1보다 작으면 일정 생성 시 SCHEDULE_DATE_MISMATCH 예외를 던진다", - new ScheduleCreateCommand(0, LocalDate.of(2026, 4, 20))), - Arguments.of("여행 기간 밖의 날짜로 일정 생성 시 SCHEDULE_DATE_MISMATCH 예외를 던진다", - new ScheduleCreateCommand(1, LocalDate.of(2026, 4, 24))), - Arguments.of("일차와 날짜 조합이 맞지 않으면 일정 생성 시 SCHEDULE_DATE_MISMATCH 예외를 던진다", - new ScheduleCreateCommand(2, LocalDate.of(2026, 4, 22))) - ); - } - @BeforeEach void setUp() { - scheduleService = new ScheduleService(roomAccessService, scheduleRepository, scheduleItemService, - eventPublisher); + scheduleService = new ScheduleService(roomAccessService, scheduleRepository, scheduleItemRepository, scheduleItemService, + eventPublisher); } @Test @@ -109,13 +107,13 @@ void createReturnsSavedSchedule() { assertThat(scheduleCaptor.getValue().getDayNumber()).isEqualTo(2); assertThat(scheduleCaptor.getValue().getDate()).isEqualTo(LocalDate.of(2026, 4, 21)); verify(eventPublisher).publishEvent(new RoomScheduleChangedEvent( - roomId, - 1L, - RoomScheduleEventType.SCHEDULE_CREATED, - 10L, - null, - List.of(), - List.of() + roomId, + 1L, + RoomScheduleEventType.SCHEDULE_CREATED, + 10L, + null, + List.of(), + List.of() )); } @@ -154,9 +152,9 @@ void createThrowsWhenRoomUnavailable() { given(roomAccessService.getRoomForUpdate(roomId)).willThrow(new CustomException(ErrorCode.ROOM_NOT_FOUND)); assertThatThrownBy(() -> scheduleService.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 @@ -173,7 +171,7 @@ void createPropagatesAuthorizationFailureBeforeScheduleRepositoryCalls() { doThrow(authorizationFailure).when(roomAccessService).requireExistingActiveMember(roomId, 1L); assertThatThrownBy(() -> scheduleService.create(roomId, command, 1L)) - .isSameAs(authorizationFailure); + .isSameAs(authorizationFailure); verifyNoInteractions(scheduleRepository); } @@ -190,9 +188,9 @@ void createThrowsWhenScheduleDateInvalid(String ignoredScenario, ScheduleCreateC given(roomAccessService.getRoomForUpdate(roomId)).willReturn(room); assertThatThrownBy(() -> scheduleService.create(roomId, command, 1L)) - .isInstanceOf(CustomException.class) - .extracting("errorCode") - .isEqualTo(ErrorCode.SCHEDULE_DATE_MISMATCH); + .isInstanceOf(CustomException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.SCHEDULE_DATE_MISMATCH); } @Test @@ -205,12 +203,12 @@ void createThrowsWhenRoomAlreadyHasThirtySchedules() { ReflectionTestUtils.setField(room, "id", roomId); given(roomAccessService.getRoomForUpdate(roomId)).willReturn(room); - given(scheduleRepository.countByRoom_Id(roomId)).willReturn((long)SchedulePolicy.MAX_SCHEDULES_PER_ROOM); + given(scheduleRepository.countByRoom_Id(roomId)).willReturn((long) SchedulePolicy.MAX_SCHEDULES_PER_ROOM); assertThatThrownBy(() -> scheduleService.create(roomId, command, 1L)) - .isInstanceOf(CustomException.class) - .extracting("errorCode") - .isEqualTo(ErrorCode.SCHEDULE_LIMIT_EXCEEDED); + .isInstanceOf(CustomException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.SCHEDULE_LIMIT_EXCEEDED); verify(scheduleRepository, never()).existsByRoom_IdAndDayNumber(roomId, 31); verify(scheduleRepository, never()).existsByRoom_IdAndDate(roomId, LocalDate.of(2026, 5, 20)); @@ -231,9 +229,9 @@ void createThrowsWhenSameDayNumberExists() { given(scheduleRepository.existsByRoom_IdAndDayNumber(roomId, 1)).willReturn(true); assertThatThrownBy(() -> scheduleService.create(roomId, command, 1L)) - .isInstanceOf(CustomException.class) - .extracting("errorCode") - .isEqualTo(ErrorCode.SCHEDULE_ALREADY_EXISTS); + .isInstanceOf(CustomException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.SCHEDULE_ALREADY_EXISTS); } @Test @@ -250,9 +248,9 @@ void createThrowsWhenSameDateExists() { given(scheduleRepository.existsByRoom_IdAndDate(roomId, LocalDate.of(2026, 4, 20))).willReturn(true); assertThatThrownBy(() -> scheduleService.create(roomId, command, 1L)) - .isInstanceOf(CustomException.class) - .extracting("errorCode") - .isEqualTo(ErrorCode.SCHEDULE_ALREADY_EXISTS); + .isInstanceOf(CustomException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.SCHEDULE_ALREADY_EXISTS); } @Test @@ -268,12 +266,12 @@ void createTranslatesDatabaseDuplicateOnSave() { given(scheduleRepository.existsByRoom_IdAndDayNumber(roomId, 1)).willReturn(false); given(scheduleRepository.existsByRoom_IdAndDate(roomId, LocalDate.of(2026, 4, 20))).willReturn(false); given(scheduleRepository.saveAndFlush(any(Schedule.class))) - .willThrow(new DataIntegrityViolationException("duplicate")); + .willThrow(new DataIntegrityViolationException("duplicate")); assertThatThrownBy(() -> scheduleService.create(roomId, command, 1L)) - .isInstanceOf(CustomException.class) - .extracting("errorCode") - .isEqualTo(ErrorCode.SCHEDULE_ALREADY_EXISTS); + .isInstanceOf(CustomException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.SCHEDULE_ALREADY_EXISTS); } @Test @@ -300,18 +298,55 @@ void getSchedulesReturnsOrderedResults() { order.verify(scheduleRepository).findAllByRoom_IdOrderByDayNumberAsc(roomId); } + @Test + @DisplayName("아이템 포함 일정 목록은 스케줄과 방 전체 아이템을 각각 한 번만 조회해 묶어 반환한다") + void getSchedulesWithItemsFetchesItemsInBulk() { + UUID roomId = UUID.randomUUID(); + Room room = Room.create("도쿄 여행", "도쿄", LocalDate.of(2026, 4, 20), LocalDate.of(2026, 4, 21), "INVITE", 1L); + Schedule first = Schedule.create(room, 1, LocalDate.of(2026, 4, 20)); + Schedule second = Schedule.create(room, 2, LocalDate.of(2026, 4, 21)); + ScheduleItem firstItem = ScheduleItem.create(first, "place-1", null, null, null, 0); + ScheduleItem secondItem = ScheduleItem.create(first, "place-2", null, null, null, 1); + ScheduleItem thirdItem = ScheduleItem.create(second, "place-3", null, null, null, 0); + + ReflectionTestUtils.setField(room, "id", roomId); + ReflectionTestUtils.setField(first, "id", 10L); + ReflectionTestUtils.setField(second, "id", 20L); + ReflectionTestUtils.setField(firstItem, "id", 101L); + ReflectionTestUtils.setField(secondItem, "id", 102L); + ReflectionTestUtils.setField(thirdItem, "id", 201L); + + 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, thirdItem)); + + 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(101L, 102L); + assertThat(results.get(1).schedule().scheduleId()).isEqualTo(20L); + assertThat(results.get(1).items()).extracting("itemId").containsExactly(201L); + 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); + verify(scheduleItemService, never()).getItems(any(), any(), any()); + } + @Test @DisplayName("방이 없으면 일정 목록 조회 시 ROOM_NOT_FOUND 예외를 던진다") void getSchedulesThrowsWhenRoomMissing() { UUID roomId = UUID.randomUUID(); given(roomAccessService.requireExistingActiveMember(roomId, 1L)) - .willThrow(new CustomException(ErrorCode.ROOM_NOT_FOUND)); + .willThrow(new CustomException(ErrorCode.ROOM_NOT_FOUND)); assertThatThrownBy(() -> scheduleService.getSchedules(roomId, 1L)) - .isInstanceOf(CustomException.class) - .extracting("errorCode") - .isEqualTo(ErrorCode.ROOM_NOT_FOUND); + .isInstanceOf(CustomException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.ROOM_NOT_FOUND); } @Test @@ -326,7 +361,7 @@ void getSchedulesPropagatesAuthorizationFailureBeforeScheduleRepositoryCalls() { doThrow(authorizationFailure).when(roomAccessService).requireExistingActiveMember(roomId, 1L); assertThatThrownBy(() -> scheduleService.getSchedules(roomId, 1L)) - .isSameAs(authorizationFailure); + .isSameAs(authorizationFailure); verify(scheduleRepository, never()).findAllByRoom_IdOrderByDayNumberAsc(roomId); } @@ -336,13 +371,13 @@ void getSchedulesPropagatesAuthorizationFailureBeforeScheduleRepositoryCalls() { void deleteThrowsWhenScheduleOutsideRoom() { UUID roomId = UUID.randomUUID(); given(roomAccessService.getRoom(roomId)) - .willReturn(Room.create("도쿄 여행", "도쿄", LocalDate.of(2026, 4, 20), LocalDate.of(2026, 4, 23), "INVITE", 1L)); + .willReturn(Room.create("도쿄 여행", "도쿄", LocalDate.of(2026, 4, 20), LocalDate.of(2026, 4, 23), "INVITE", 1L)); given(scheduleRepository.findByIdAndRoom_Id(10L, roomId)).willReturn(Optional.empty()); assertThatThrownBy(() -> scheduleService.delete(roomId, 10L, 1L)) - .isInstanceOf(CustomException.class) - .extracting("errorCode") - .isEqualTo(ErrorCode.SCHEDULE_NOT_FOUND); + .isInstanceOf(CustomException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.SCHEDULE_NOT_FOUND); } @Test @@ -353,9 +388,9 @@ void deleteThrowsWhenRoomMissing() { given(roomAccessService.getRoom(roomId)).willThrow(new CustomException(ErrorCode.ROOM_NOT_FOUND)); assertThatThrownBy(() -> scheduleService.delete(roomId, 10L, 1L)) - .isInstanceOf(CustomException.class) - .extracting("errorCode") - .isEqualTo(ErrorCode.ROOM_NOT_FOUND); + .isInstanceOf(CustomException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.ROOM_NOT_FOUND); } @Test @@ -379,13 +414,13 @@ void deleteRemovesSchedule() { order.verify(scheduleItemService).deleteAllByScheduleId(10L); order.verify(scheduleRepository).delete(schedule); verify(eventPublisher).publishEvent(new RoomScheduleChangedEvent( - roomId, - 1L, - RoomScheduleEventType.SCHEDULE_DELETED, - 10L, - null, - List.of(), - List.of() + roomId, + 1L, + RoomScheduleEventType.SCHEDULE_DELETED, + 10L, + null, + List.of(), + List.of() )); } @@ -446,7 +481,7 @@ void deletePropagatesAuthorizationFailureBeforeScheduleRepositoryCalls() { doThrow(authorizationFailure).when(roomAccessService).requireExistingActiveMember(roomId, 1L); assertThatThrownBy(() -> scheduleService.delete(roomId, 10L, 1L)) - .isSameAs(authorizationFailure); + .isSameAs(authorizationFailure); verify(scheduleRepository, never()).findByIdAndRoom_Id(10L, roomId); verifyNoInteractions(scheduleItemService); @@ -467,8 +502,8 @@ void createBatchReturnsSavedSchedules() { ReflectionTestUtils.setField(schedule2, "createdAt", Instant.parse("2026-04-17T00:00:00Z")); ScheduleBatchCreateCommand batchCommand = new ScheduleBatchCreateCommand(List.of( - new ScheduleCreateCommand(1, LocalDate.of(2026, 4, 20)), - new ScheduleCreateCommand(2, LocalDate.of(2026, 4, 21)) + new ScheduleCreateCommand(1, LocalDate.of(2026, 4, 20)), + new ScheduleCreateCommand(2, LocalDate.of(2026, 4, 21)) )); given(roomAccessService.getRoomForUpdate(roomId)).willReturn(room); @@ -483,13 +518,13 @@ void createBatchReturnsSavedSchedules() { assertThat(room.getStartDate()).isEqualTo(LocalDate.of(2026, 4, 20)); assertThat(room.getEndDate()).isEqualTo(LocalDate.of(2026, 4, 21)); verify(eventPublisher).publishEvent(new RoomScheduleChangedEvent( - roomId, - 1L, - RoomScheduleEventType.SCHEDULES_BATCH_CREATED, - null, - null, - List.of(), - List.of(10L, 11L) + roomId, + 1L, + RoomScheduleEventType.SCHEDULES_BATCH_CREATED, + null, + null, + List.of(), + List.of(10L, 11L) )); } @@ -502,16 +537,16 @@ void createBatchThrowsWhenDuplicateDayNumberInList() { ReflectionTestUtils.setField(room, "id", roomId); ScheduleBatchCreateCommand batchCommand = new ScheduleBatchCreateCommand(List.of( - new ScheduleCreateCommand(1, LocalDate.of(2026, 4, 20)), - new ScheduleCreateCommand(1, LocalDate.of(2026, 4, 21)) + new ScheduleCreateCommand(1, LocalDate.of(2026, 4, 20)), + new ScheduleCreateCommand(1, LocalDate.of(2026, 4, 21)) )); given(roomAccessService.getRoomForUpdate(roomId)).willReturn(room); assertThatThrownBy(() -> scheduleService.createBatch(roomId, batchCommand, 1L)) - .isInstanceOf(CustomException.class) - .extracting("errorCode") - .isEqualTo(ErrorCode.SCHEDULE_ALREADY_EXISTS); + .isInstanceOf(CustomException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.SCHEDULE_ALREADY_EXISTS); } @Test @@ -523,16 +558,16 @@ void createBatchThrowsWhenDuplicateDateInList() { ReflectionTestUtils.setField(room, "id", roomId); ScheduleBatchCreateCommand batchCommand = new ScheduleBatchCreateCommand(List.of( - new ScheduleCreateCommand(1, LocalDate.of(2026, 4, 20)), - new ScheduleCreateCommand(2, LocalDate.of(2026, 4, 20)) + new ScheduleCreateCommand(1, LocalDate.of(2026, 4, 20)), + new ScheduleCreateCommand(2, LocalDate.of(2026, 4, 20)) )); given(roomAccessService.getRoomForUpdate(roomId)).willReturn(room); assertThatThrownBy(() -> scheduleService.createBatch(roomId, batchCommand, 1L)) - .isInstanceOf(CustomException.class) - .extracting("errorCode") - .isEqualTo(ErrorCode.SCHEDULE_ALREADY_EXISTS); + .isInstanceOf(CustomException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.SCHEDULE_ALREADY_EXISTS); } @Test @@ -546,16 +581,16 @@ void createBatchThrowsWhenExistingDayNumberConflicts() { ReflectionTestUtils.setField(existing, "id", 10L); ScheduleBatchCreateCommand batchCommand = new ScheduleBatchCreateCommand(List.of( - new ScheduleCreateCommand(1, LocalDate.of(2026, 4, 20)) + new ScheduleCreateCommand(1, LocalDate.of(2026, 4, 20)) )); given(roomAccessService.getRoomForUpdate(roomId)).willReturn(room); given(scheduleRepository.findAllByRoom_IdOrderByDayNumberAsc(roomId)).willReturn(List.of(existing)); assertThatThrownBy(() -> scheduleService.createBatch(roomId, batchCommand, 1L)) - .isInstanceOf(CustomException.class) - .extracting("errorCode") - .isEqualTo(ErrorCode.SCHEDULE_ALREADY_EXISTS); + .isInstanceOf(CustomException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.SCHEDULE_ALREADY_EXISTS); } @Test @@ -565,23 +600,23 @@ void createBatchThrowsWhenFinalScheduleCountExceedsThirty() { Room room = Room.create("도쿄 여행", "도쿄", LocalDate.of(2026, 4, 20), LocalDate.of(2026, 5, 19), "INVITE", 1L); int limit = SchedulePolicy.MAX_SCHEDULES_PER_ROOM; List existingSchedules = IntStream.rangeClosed(1, limit - 1) - .mapToObj(day -> Schedule.create(room, day, LocalDate.of(2026, 4, 19).plusDays(day))) - .toList(); + .mapToObj(day -> Schedule.create(room, day, LocalDate.of(2026, 4, 19).plusDays(day))) + .toList(); ReflectionTestUtils.setField(room, "id", roomId); ScheduleBatchCreateCommand batchCommand = new ScheduleBatchCreateCommand(List.of( - new ScheduleCreateCommand(limit, LocalDate.of(2026, 4, 19).plusDays(limit)), - new ScheduleCreateCommand(limit + 1, LocalDate.of(2026, 4, 19).plusDays(limit + 1)) + new ScheduleCreateCommand(limit, LocalDate.of(2026, 4, 19).plusDays(limit)), + new ScheduleCreateCommand(limit + 1, LocalDate.of(2026, 4, 19).plusDays(limit + 1)) )); given(roomAccessService.getRoomForUpdate(roomId)).willReturn(room); given(scheduleRepository.findAllByRoom_IdOrderByDayNumberAsc(roomId)).willReturn(existingSchedules); assertThatThrownBy(() -> scheduleService.createBatch(roomId, batchCommand, 1L)) - .isInstanceOf(CustomException.class) - .extracting("errorCode") - .isEqualTo(ErrorCode.SCHEDULE_LIMIT_EXCEEDED); + .isInstanceOf(CustomException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.SCHEDULE_LIMIT_EXCEEDED); verify(scheduleRepository, never()).saveAllAndFlush(any()); verifyNoInteractions(eventPublisher); @@ -596,18 +631,18 @@ void createBatchTranslatesDatabaseDuplicateOnSave() { ReflectionTestUtils.setField(room, "id", roomId); ScheduleBatchCreateCommand batchCommand = new ScheduleBatchCreateCommand(List.of( - new ScheduleCreateCommand(1, LocalDate.of(2026, 4, 20)) + new ScheduleCreateCommand(1, LocalDate.of(2026, 4, 20)) )); given(roomAccessService.getRoomForUpdate(roomId)).willReturn(room); given(scheduleRepository.findAllByRoom_IdOrderByDayNumberAsc(roomId)).willReturn(List.of()); given(scheduleRepository.saveAllAndFlush(any())) - .willThrow(new DataIntegrityViolationException("duplicate")); + .willThrow(new DataIntegrityViolationException("duplicate")); assertThatThrownBy(() -> scheduleService.createBatch(roomId, batchCommand, 1L)) - .isInstanceOf(CustomException.class) - .extracting("errorCode") - .isEqualTo(ErrorCode.SCHEDULE_ALREADY_EXISTS); + .isInstanceOf(CustomException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.SCHEDULE_ALREADY_EXISTS); } @Test @@ -619,14 +654,25 @@ void createBatchThrowsWhenDateMismatch() { ReflectionTestUtils.setField(room, "id", roomId); ScheduleBatchCreateCommand batchCommand = new ScheduleBatchCreateCommand(List.of( - new ScheduleCreateCommand(2, LocalDate.of(2026, 4, 22)) + new ScheduleCreateCommand(2, LocalDate.of(2026, 4, 22)) )); given(roomAccessService.getRoomForUpdate(roomId)).willReturn(room); assertThatThrownBy(() -> scheduleService.createBatch(roomId, batchCommand, 1L)) - .isInstanceOf(CustomException.class) - .extracting("errorCode") - .isEqualTo(ErrorCode.SCHEDULE_DATE_MISMATCH); + .isInstanceOf(CustomException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.SCHEDULE_DATE_MISMATCH); + } + + private static Stream invalidScheduleCreateCommands() { + return Stream.of( + Arguments.of("일차가 1보다 작으면 일정 생성 시 SCHEDULE_DATE_MISMATCH 예외를 던진다", + new ScheduleCreateCommand(0, LocalDate.of(2026, 4, 20))), + Arguments.of("여행 기간 밖의 날짜로 일정 생성 시 SCHEDULE_DATE_MISMATCH 예외를 던진다", + new ScheduleCreateCommand(1, LocalDate.of(2026, 4, 24))), + Arguments.of("일차와 날짜 조합이 맞지 않으면 일정 생성 시 SCHEDULE_DATE_MISMATCH 예외를 던진다", + new ScheduleCreateCommand(2, LocalDate.of(2026, 4, 22))) + ); } } From 96a47d96071aa10a66ee92d184750840d94ae36a Mon Sep 17 00:00:00 2001 From: minbros Date: Fri, 5 Jun 2026 17:40:25 +0900 Subject: [PATCH 02/11] =?UTF-8?q?refactor:=20=EC=A1=B0=ED=9A=8C=20API=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20=EC=BD=94=EB=93=9C=20=EC=BB=A8=EB=B2=A4?= =?UTF-8?q?=EC=85=98=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/BookmarkController.java | 51 ++++++++++------- .../bookmarks/service/BookmarkService.java | 35 +++++++----- .../controller/ScheduleController.java | 51 ++++++++++------- .../controller/dto/ScheduleResponse.java | 7 ++- .../repository/ScheduleItemRepository.java | 10 ++-- .../repository/ScheduleRepository.java | 6 +- .../schedules/service/ScheduleService.java | 37 +++++++------ .../service/BookmarkServiceTest.java | 42 ++++++++------ .../schedules/ScheduleIntegrationTest.java | 35 ++++++------ .../controller/ScheduleControllerTest.java | 38 +++++++------ .../service/ScheduleServiceTest.java | 55 ++++++++++++------- 11 files changed, 218 insertions(+), 149 deletions(-) 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 07424285..5ec9e2e9 100644 --- a/src/main/java/com/howaboutus/backend/bookmarks/controller/BookmarkController.java +++ b/src/main/java/com/howaboutus/backend/bookmarks/controller/BookmarkController.java @@ -1,34 +1,37 @@ package com.howaboutus.backend.bookmarks.controller; -import com.howaboutus.backend.bookmarks.controller.dto.BookmarkResponse; -import com.howaboutus.backend.bookmarks.controller.dto.CreateBookmarkRequest; -import com.howaboutus.backend.bookmarks.controller.dto.UpdateBookmarkCategoryRequest; -import com.howaboutus.backend.bookmarks.service.BookmarkService; -import com.howaboutus.backend.common.error.ApiErrorCodes; -import com.howaboutus.backend.common.error.ErrorCode; -import com.howaboutus.backend.common.logging.Loggable; -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.media.Content; -import io.swagger.v3.oas.annotations.tags.Tag; import java.util.List; import java.util.UUID; -import lombok.RequiredArgsConstructor; -import org.springframework.security.core.annotation.AuthenticationPrincipal; + import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import jakarta.validation.Valid; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PatchMapping; +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.RequestParam; import org.springframework.web.bind.annotation.RestController; +import com.howaboutus.backend.bookmarks.controller.dto.BookmarkResponse; +import com.howaboutus.backend.bookmarks.controller.dto.CreateBookmarkRequest; +import com.howaboutus.backend.bookmarks.controller.dto.UpdateBookmarkCategoryRequest; +import com.howaboutus.backend.bookmarks.service.BookmarkService; +import com.howaboutus.backend.common.error.ApiErrorCodes; +import com.howaboutus.backend.common.error.ErrorCode; +import com.howaboutus.backend.common.logging.Loggable; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +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 lombok.RequiredArgsConstructor; + @Tag(name = "Bookmarks", description = "보관함 API") @RestController @RequiredArgsConstructor @@ -42,7 +45,9 @@ public class BookmarkController { description = "방에 후보 장소를 보관함 항목으로 추가합니다." ) @ApiResponse(responseCode = "201", description = "생성 성공") - @ApiErrorCodes({ErrorCode.INVALID_TOKEN, ErrorCode.ACCESS_TOKEN_EXPIRED, ErrorCode.NOT_ROOM_MEMBER, ErrorCode.ROOM_NOT_FOUND, ErrorCode.BOOKMARK_CATEGORY_NOT_FOUND, ErrorCode.BOOKMARK_CATEGORY_EMPTY, ErrorCode.BOOKMARK_ALREADY_EXISTS}) + @ApiErrorCodes({ErrorCode.INVALID_TOKEN, ErrorCode.ACCESS_TOKEN_EXPIRED, ErrorCode.NOT_ROOM_MEMBER, + ErrorCode.ROOM_NOT_FOUND, ErrorCode.BOOKMARK_CATEGORY_NOT_FOUND, ErrorCode.BOOKMARK_CATEGORY_EMPTY, + ErrorCode.BOOKMARK_ALREADY_EXISTS}) @Loggable @PostMapping public ResponseEntity> create( @@ -59,10 +64,12 @@ public ResponseEntity> create( @Operation( summary = "보관함 목록 조회", - description = "방의 보관함 항목 목록을 조회합니다. categoryId를 전달하면 해당 카테고리만, 생략하면 방 전체 보관함 항목을 반환합니다." + description = "방의 보관함 항목 목록을 조회합니다. categoryId를 전달하면 해당 카테고리만, " + + "생략하면 방 전체 보관함 항목을 반환합니다." ) @ApiResponse(responseCode = "200", description = "조회 성공") - @ApiErrorCodes({ErrorCode.INVALID_TOKEN, ErrorCode.ACCESS_TOKEN_EXPIRED, ErrorCode.NOT_ROOM_MEMBER, ErrorCode.ROOM_NOT_FOUND}) + @ApiErrorCodes({ErrorCode.INVALID_TOKEN, ErrorCode.ACCESS_TOKEN_EXPIRED, ErrorCode.NOT_ROOM_MEMBER, + ErrorCode.ROOM_NOT_FOUND}) @GetMapping public List getBookmarks( @AuthenticationPrincipal Long userId, @@ -81,7 +88,8 @@ public List getBookmarks( description = "보관함 항목의 카테고리를 현재 방 소속 카테고리로 변경합니다." ) @ApiResponse(responseCode = "200", description = "변경 성공") - @ApiErrorCodes({ErrorCode.INVALID_TOKEN, ErrorCode.ACCESS_TOKEN_EXPIRED, ErrorCode.NOT_ROOM_MEMBER, ErrorCode.ROOM_NOT_FOUND, ErrorCode.BOOKMARK_NOT_FOUND, ErrorCode.BOOKMARK_CATEGORY_NOT_FOUND}) + @ApiErrorCodes({ErrorCode.INVALID_TOKEN, ErrorCode.ACCESS_TOKEN_EXPIRED, ErrorCode.NOT_ROOM_MEMBER, + ErrorCode.ROOM_NOT_FOUND, ErrorCode.BOOKMARK_NOT_FOUND, ErrorCode.BOOKMARK_CATEGORY_NOT_FOUND}) @Loggable @PatchMapping("/{bookmarkId}/category") public BookmarkResponse updateCategory( @@ -102,7 +110,8 @@ public BookmarkResponse updateCategory( description = "방의 보관함 항목을 삭제합니다." ) @ApiResponse(responseCode = "204", description = "삭제 성공", content = @Content) - @ApiErrorCodes({ErrorCode.INVALID_TOKEN, ErrorCode.ACCESS_TOKEN_EXPIRED, ErrorCode.NOT_ROOM_MEMBER, ErrorCode.ROOM_NOT_FOUND, ErrorCode.BOOKMARK_NOT_FOUND}) + @ApiErrorCodes({ErrorCode.INVALID_TOKEN, ErrorCode.ACCESS_TOKEN_EXPIRED, ErrorCode.NOT_ROOM_MEMBER, + ErrorCode.ROOM_NOT_FOUND, ErrorCode.BOOKMARK_NOT_FOUND}) @Loggable @DeleteMapping("/{bookmarkId}") public ResponseEntity delete( 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 85390eef..5eb901e7 100644 --- a/src/main/java/com/howaboutus/backend/bookmarks/service/BookmarkService.java +++ b/src/main/java/com/howaboutus/backend/bookmarks/service/BookmarkService.java @@ -1,11 +1,22 @@ package com.howaboutus.backend.bookmarks.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.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + import com.howaboutus.backend.bookmarks.entity.Bookmark; +import com.howaboutus.backend.bookmarks.entity.BookmarkCategory; +import com.howaboutus.backend.bookmarks.repository.BookmarkCategoryRepository; import com.howaboutus.backend.bookmarks.repository.BookmarkRepository; import com.howaboutus.backend.bookmarks.service.dto.BookmarkCreateCommand; import com.howaboutus.backend.bookmarks.service.dto.BookmarkResult; -import com.howaboutus.backend.bookmarks.entity.BookmarkCategory; -import com.howaboutus.backend.bookmarks.repository.BookmarkCategoryRepository; import com.howaboutus.backend.common.error.CustomException; import com.howaboutus.backend.common.error.ErrorCode; import com.howaboutus.backend.common.logging.Loggable; @@ -14,17 +25,7 @@ import com.howaboutus.backend.rooms.entity.Room; import com.howaboutus.backend.rooms.service.RoomAccessService; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.UUID; -import java.util.stream.Collectors; - import lombok.RequiredArgsConstructor; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.dao.DataIntegrityViolationException; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor @@ -69,7 +70,12 @@ public List create(UUID roomId, BookmarkCreateCommand command, L } try { List bookmarks = uniqueCategoryIds.stream() - .map(categoryId -> Bookmark.create(room, command.googlePlaceId(), categoriesById.get(categoryId), null)) + .map(categoryId -> Bookmark.create( + room, + command.googlePlaceId(), + categoriesById.get(categoryId), + null + )) .toList(); List results = bookmarkRepository.saveAllAndFlush(bookmarks).stream() .map(BookmarkResult::from) @@ -87,7 +93,8 @@ public List create(UUID roomId, BookmarkCreateCommand command, L public List getBookmarks(UUID roomId, Long categoryId, Long userId) { roomAccessService.requireExistingActiveMember(roomId, userId); List bookmarks = categoryId == null - ? bookmarkRepository.findAllByRoom_IdOrderByCategory_CreatedAtAscCategory_IdAscCreatedAtDescIdDesc(roomId) + ? bookmarkRepository + .findAllByRoom_IdOrderByCategory_CreatedAtAscCategory_IdAscCreatedAtDescIdDesc(roomId) : bookmarkRepository.findAllByRoom_IdAndCategory_IdOrderByCreatedAtDesc(roomId, categoryId); return bookmarks .stream() 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 ccf1f7dd..b418c88e 100644 --- a/src/main/java/com/howaboutus/backend/schedules/controller/ScheduleController.java +++ b/src/main/java/com/howaboutus/backend/schedules/controller/ScheduleController.java @@ -1,24 +1,11 @@ package com.howaboutus.backend.schedules.controller; -import com.howaboutus.backend.schedules.controller.dto.CreateBatchScheduleRequest; -import com.howaboutus.backend.schedules.controller.dto.CreateScheduleRequest; -import com.howaboutus.backend.schedules.controller.dto.ScheduleResponse; -import com.howaboutus.backend.schedules.service.ScheduleService; -import com.howaboutus.backend.schedules.SchedulePolicy; -import com.howaboutus.backend.common.error.ApiErrorCodes; -import com.howaboutus.backend.common.error.ErrorCode; -import com.howaboutus.backend.common.logging.Loggable; -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.media.Content; -import io.swagger.v3.oas.annotations.tags.Tag; import java.util.List; import java.util.UUID; -import lombok.RequiredArgsConstructor; -import org.springframework.security.core.annotation.AuthenticationPrincipal; + import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -28,7 +15,22 @@ import org.springframework.web.bind.annotation.RequestParam; 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.SchedulePolicy; +import com.howaboutus.backend.schedules.controller.dto.CreateBatchScheduleRequest; +import com.howaboutus.backend.schedules.controller.dto.CreateScheduleRequest; +import com.howaboutus.backend.schedules.controller.dto.ScheduleResponse; +import com.howaboutus.backend.schedules.service.ScheduleService; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +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 lombok.RequiredArgsConstructor; @Tag(name = "Schedules", description = "일정 API") @RestController @@ -43,7 +45,9 @@ public class ScheduleController { description = "방에 일정을 생성합니다." ) @ApiResponse(responseCode = "201", description = "생성 성공") - @ApiErrorCodes({ErrorCode.SCHEDULE_DATE_MISMATCH, ErrorCode.SCHEDULE_LIMIT_EXCEEDED, ErrorCode.INVALID_TOKEN, ErrorCode.ACCESS_TOKEN_EXPIRED, ErrorCode.NOT_ROOM_MEMBER, ErrorCode.ROOM_NOT_FOUND, ErrorCode.SCHEDULE_ALREADY_EXISTS}) + @ApiErrorCodes({ErrorCode.SCHEDULE_DATE_MISMATCH, ErrorCode.SCHEDULE_LIMIT_EXCEEDED, ErrorCode.INVALID_TOKEN, + ErrorCode.ACCESS_TOKEN_EXPIRED, ErrorCode.NOT_ROOM_MEMBER, ErrorCode.ROOM_NOT_FOUND, + ErrorCode.SCHEDULE_ALREADY_EXISTS}) @Loggable @PostMapping @SuppressWarnings("JvmTaintAnalysis") @@ -59,10 +63,15 @@ public ResponseEntity create( @Operation( summary = "일정 배치 생성", - description = "방에 여러 일정을 한 번에 생성합니다. 방 하나에 최대 " + SchedulePolicy.MAX_SCHEDULES_PER_ROOM + "개까지 생성 가능하며, 생성 후 방의 여행 기간(start_date/end_date)이 남은 일정 날짜 기준으로 자동 동기화됩니다." + description = "방에 여러 일정을 한 번에 생성합니다. 방 하나에 최대 " + + SchedulePolicy.MAX_SCHEDULES_PER_ROOM + + "개까지 생성 가능하며, 생성 후 방의 여행 기간(start_date/end_date)이 " + + "남은 일정 날짜 기준으로 자동 동기화됩니다." ) @ApiResponse(responseCode = "201", description = "생성 성공") - @ApiErrorCodes({ErrorCode.SCHEDULE_DATE_MISMATCH, ErrorCode.SCHEDULE_LIMIT_EXCEEDED, ErrorCode.INVALID_TOKEN, ErrorCode.ACCESS_TOKEN_EXPIRED, ErrorCode.NOT_ROOM_MEMBER, ErrorCode.ROOM_NOT_FOUND, ErrorCode.SCHEDULE_ALREADY_EXISTS}) + @ApiErrorCodes({ErrorCode.SCHEDULE_DATE_MISMATCH, ErrorCode.SCHEDULE_LIMIT_EXCEEDED, ErrorCode.INVALID_TOKEN, + ErrorCode.ACCESS_TOKEN_EXPIRED, ErrorCode.NOT_ROOM_MEMBER, ErrorCode.ROOM_NOT_FOUND, + ErrorCode.SCHEDULE_ALREADY_EXISTS}) @Loggable @PostMapping("/batch") public ResponseEntity> createBatch( @@ -82,7 +91,8 @@ public ResponseEntity> createBatch( description = "방의 일정 목록을 조회합니다. includeItems=true이면 각 일정의 장소 항목 목록을 함께 반환합니다." ) @ApiResponse(responseCode = "200", description = "조회 성공") - @ApiErrorCodes({ErrorCode.INVALID_TOKEN, ErrorCode.ACCESS_TOKEN_EXPIRED, ErrorCode.NOT_ROOM_MEMBER, ErrorCode.ROOM_NOT_FOUND}) + @ApiErrorCodes({ErrorCode.INVALID_TOKEN, ErrorCode.ACCESS_TOKEN_EXPIRED, ErrorCode.NOT_ROOM_MEMBER, + ErrorCode.ROOM_NOT_FOUND}) @GetMapping public List getSchedules( @AuthenticationPrincipal Long userId, @@ -106,7 +116,8 @@ public List getSchedules( description = "방의 일정을 삭제합니다." ) @ApiResponse(responseCode = "204", description = "삭제 성공", content = @Content) - @ApiErrorCodes({ErrorCode.INVALID_TOKEN, ErrorCode.ACCESS_TOKEN_EXPIRED, ErrorCode.NOT_ROOM_MEMBER, ErrorCode.ROOM_NOT_FOUND, ErrorCode.SCHEDULE_NOT_FOUND}) + @ApiErrorCodes({ErrorCode.INVALID_TOKEN, ErrorCode.ACCESS_TOKEN_EXPIRED, ErrorCode.NOT_ROOM_MEMBER, + ErrorCode.ROOM_NOT_FOUND, ErrorCode.SCHEDULE_NOT_FOUND}) @Loggable @DeleteMapping("/{scheduleId}") public ResponseEntity delete( 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 cb6cf9e2..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 @@ -1,13 +1,14 @@ package com.howaboutus.backend.schedules.controller.dto; -import com.fasterxml.jackson.annotation.JsonInclude; -import com.howaboutus.backend.schedules.service.dto.ScheduleResult; -import com.howaboutus.backend.schedules.service.dto.ScheduleWithItemsResult; 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, 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 3e6027d8..a48a0176 100644 --- a/src/main/java/com/howaboutus/backend/schedules/repository/ScheduleItemRepository.java +++ b/src/main/java/com/howaboutus/backend/schedules/repository/ScheduleItemRepository.java @@ -1,22 +1,24 @@ package com.howaboutus.backend.schedules.repository; -import com.howaboutus.backend.schedules.entity.ScheduleItem; import java.util.List; 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; + public interface ScheduleItemRepository extends JpaRepository { Optional findTopBySchedule_IdOrderByOrderIndexDesc(Long scheduleId); List findAllBySchedule_IdOrderByOrderIndexAsc(Long scheduleId); - List findAllBySchedule_IdOrderByOrderIndexAscIdAsc(Long scheduleId); - @EntityGraph(attributePaths = {"schedule", "schedule.room"}) - List findAllBySchedule_Room_IdOrderBySchedule_DayNumberAscSchedule_IdAscOrderIndexAscIdAsc(UUID roomId); + List findAllBySchedule_Room_IdOrderBySchedule_DayNumberAscSchedule_IdAscOrderIndexAscIdAsc( + 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 692e4c8c..df280bec 100644 --- a/src/main/java/com/howaboutus/backend/schedules/repository/ScheduleRepository.java +++ b/src/main/java/com/howaboutus/backend/schedules/repository/ScheduleRepository.java @@ -1,16 +1,18 @@ package com.howaboutus.backend.schedules.repository; -import com.howaboutus.backend.schedules.entity.Schedule; import java.time.LocalDate; import java.util.List; import java.util.Optional; import java.util.UUID; + import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import com.howaboutus.backend.schedules.entity.Schedule; + public interface ScheduleRepository extends JpaRepository { boolean existsByRoom_IdAndDayNumber(UUID roomId, int dayNumber); @@ -29,7 +31,7 @@ public interface ScheduleRepository extends JpaRepository { Optional findByIdAndRoom_Id(Long scheduleId, UUID roomId); - @Modifying(flushAutomatically = true, clearAutomatically = false) + @Modifying(flushAutomatically = true) @Query(""" update Schedule schedule set schedule.version = schedule.version + 1 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 2fb0ba35..c39d5e5d 100644 --- a/src/main/java/com/howaboutus/backend/schedules/service/ScheduleService.java +++ b/src/main/java/com/howaboutus/backend/schedules/service/ScheduleService.java @@ -1,5 +1,21 @@ package com.howaboutus.backend.schedules.service; +import java.time.LocalDate; +import java.util.Comparator; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + import com.howaboutus.backend.common.error.CustomException; import com.howaboutus.backend.common.error.ErrorCode; import com.howaboutus.backend.common.logging.Loggable; @@ -7,6 +23,7 @@ import com.howaboutus.backend.realtime.service.dto.RoomScheduleEventType; import com.howaboutus.backend.rooms.entity.Room; import com.howaboutus.backend.rooms.service.RoomAccessService; +import com.howaboutus.backend.schedules.SchedulePolicy; import com.howaboutus.backend.schedules.entity.Schedule; import com.howaboutus.backend.schedules.repository.ScheduleItemRepository; import com.howaboutus.backend.schedules.repository.ScheduleRepository; @@ -15,22 +32,8 @@ import com.howaboutus.backend.schedules.service.dto.ScheduleItemResult; import com.howaboutus.backend.schedules.service.dto.ScheduleResult; import com.howaboutus.backend.schedules.service.dto.ScheduleWithItemsResult; -import java.time.LocalDate; -import java.util.Comparator; -import java.util.HashSet; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.UUID; -import java.util.stream.Collectors; -import java.util.stream.Stream; + import lombok.RequiredArgsConstructor; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.dao.DataIntegrityViolationException; -import com.howaboutus.backend.schedules.SchedulePolicy; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor @@ -217,6 +220,8 @@ private void syncRoomDateRange(Room room, UUID roomId) { private void publishChanged(UUID roomId, Long actorUserId, RoomScheduleEventType type, Long scheduleId, Long itemId) { - eventPublisher.publishEvent(new RoomScheduleChangedEvent(roomId, actorUserId, type, scheduleId, itemId, List.of(), List.of())); + eventPublisher.publishEvent( + new RoomScheduleChangedEvent(roomId, actorUserId, type, scheduleId, itemId, List.of(), List.of()) + ); } } 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 551ab315..16b6b121 100644 --- a/src/test/java/com/howaboutus/backend/bookmarks/service/BookmarkServiceTest.java +++ b/src/test/java/com/howaboutus/backend/bookmarks/service/BookmarkServiceTest.java @@ -5,25 +5,14 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; -import com.howaboutus.backend.bookmarks.entity.Bookmark; -import com.howaboutus.backend.bookmarks.entity.BookmarkCategory; -import com.howaboutus.backend.bookmarks.repository.BookmarkCategoryRepository; -import com.howaboutus.backend.bookmarks.repository.BookmarkRepository; -import com.howaboutus.backend.bookmarks.service.dto.BookmarkCreateCommand; -import com.howaboutus.backend.bookmarks.service.dto.BookmarkResult; -import com.howaboutus.backend.common.error.CustomException; -import com.howaboutus.backend.common.error.ErrorCode; -import com.howaboutus.backend.realtime.event.RoomBookmarkChangedEvent; -import com.howaboutus.backend.realtime.service.dto.RoomBookmarkEventType; -import com.howaboutus.backend.rooms.entity.Room; -import com.howaboutus.backend.rooms.service.RoomAccessService; import java.time.Instant; import java.util.List; import java.util.Optional; import java.util.UUID; + import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -31,10 +20,23 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.dao.DataIntegrityViolationException; import org.springframework.context.ApplicationEventPublisher; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.test.util.ReflectionTestUtils; +import com.howaboutus.backend.bookmarks.entity.Bookmark; +import com.howaboutus.backend.bookmarks.entity.BookmarkCategory; +import com.howaboutus.backend.bookmarks.repository.BookmarkCategoryRepository; +import com.howaboutus.backend.bookmarks.repository.BookmarkRepository; +import com.howaboutus.backend.bookmarks.service.dto.BookmarkCreateCommand; +import com.howaboutus.backend.bookmarks.service.dto.BookmarkResult; +import com.howaboutus.backend.common.error.CustomException; +import com.howaboutus.backend.common.error.ErrorCode; +import com.howaboutus.backend.realtime.event.RoomBookmarkChangedEvent; +import com.howaboutus.backend.realtime.service.dto.RoomBookmarkEventType; +import com.howaboutus.backend.rooms.entity.Room; +import com.howaboutus.backend.rooms.service.RoomAccessService; + @ExtendWith(MockitoExtension.class) class BookmarkServiceTest { @@ -79,7 +81,11 @@ void createReturnsSavedBookmarkWithCategory() { .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)); @@ -277,7 +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); @@ -310,7 +317,8 @@ void getBookmarksWithoutCategoryReturnsAllBookmarksInSingleRepositoryCall() { assertThat(results).extracting("bookmarkId").containsExactly(10L, 11L); assertThat(results).extracting("categoryId").containsExactly(20L, 21L); - verify(bookmarkRepository).findAllByRoom_IdOrderByCategory_CreatedAtAscCategory_IdAscCreatedAtDescIdDesc(roomId); + verify(bookmarkRepository) + .findAllByRoom_IdOrderByCategory_CreatedAtAscCategory_IdAscCreatedAtDescIdDesc(roomId); verify(bookmarkRepository, never()).findAllByRoom_IdAndCategory_IdOrderByCreatedAtDesc(any(), any()); } diff --git a/src/test/java/com/howaboutus/backend/schedules/ScheduleIntegrationTest.java b/src/test/java/com/howaboutus/backend/schedules/ScheduleIntegrationTest.java index c248c439..f303501c 100644 --- a/src/test/java/com/howaboutus/backend/schedules/ScheduleIntegrationTest.java +++ b/src/test/java/com/howaboutus/backend/schedules/ScheduleIntegrationTest.java @@ -8,25 +8,11 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import com.howaboutus.backend.auth.service.JwtProvider; -import com.howaboutus.backend.rooms.entity.Room; -import com.howaboutus.backend.rooms.entity.RoomMember; -import com.howaboutus.backend.rooms.entity.RoomRole; -import com.howaboutus.backend.rooms.repository.RoomMemberRepository; -import com.howaboutus.backend.rooms.repository.RoomRepository; -import com.howaboutus.backend.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.support.BaseIntegrationTest; -import com.howaboutus.backend.user.entity.User; -import com.howaboutus.backend.user.repository.UserRepository; -import com.jayway.jsonpath.JsonPath; -import jakarta.servlet.http.Cookie; import java.time.LocalDate; import java.time.LocalTime; import java.util.Optional; import java.util.UUID; + import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -38,6 +24,22 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; +import com.howaboutus.backend.auth.service.JwtProvider; +import com.howaboutus.backend.rooms.entity.Room; +import com.howaboutus.backend.rooms.entity.RoomMember; +import com.howaboutus.backend.rooms.entity.RoomRole; +import com.howaboutus.backend.rooms.repository.RoomMemberRepository; +import com.howaboutus.backend.rooms.repository.RoomRepository; +import com.howaboutus.backend.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.support.BaseIntegrationTest; +import com.howaboutus.backend.user.entity.User; +import com.howaboutus.backend.user.repository.UserRepository; +import com.jayway.jsonpath.JsonPath; + +import jakarta.servlet.http.Cookie; @AutoConfigureMockMvc @SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection") class ScheduleIntegrationTest extends BaseIntegrationTest { @@ -188,7 +190,8 @@ void scheduleItemFlowWorksEndToEnd() throws Exception { .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(); + ScheduleItem firstItem = scheduleItemRepository.findAllBySchedule_IdOrderByOrderIndexAsc(schedule.getId()) + .getFirst(); mockMvc.perform(patch("/rooms/{roomId}/schedules/{scheduleId}/items/{itemId}", room.getId(), schedule.getId(), firstItem.getId()) 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 bfa2a784..714f1ed5 100644 --- a/src/test/java/com/howaboutus/backend/schedules/controller/ScheduleControllerTest.java +++ b/src/test/java/com/howaboutus/backend/schedules/controller/ScheduleControllerTest.java @@ -3,17 +3,15 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.verifyNoInteractions; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import com.howaboutus.backend.auth.filter.JwtAuthenticationFilter; -import com.howaboutus.backend.auth.service.JwtProvider; import java.time.Instant; import java.time.LocalDate; import java.time.LocalTime; @@ -22,6 +20,19 @@ import java.util.stream.Collectors; import java.util.stream.IntStream; +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; @@ -32,20 +43,12 @@ import com.howaboutus.backend.schedules.service.dto.ScheduleItemResult; import com.howaboutus.backend.schedules.service.dto.ScheduleResult; import com.howaboutus.backend.schedules.service.dto.ScheduleWithItemsResult; + import jakarta.servlet.http.Cookie; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.BeforeEach; -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; @WebMvcTest(ScheduleController.class) -@Import({SecurityConfig.class, JwtAuthenticationFilter.class, JwtAuthenticationEntryPoint.class, GlobalExceptionHandler.class}) +@Import({SecurityConfig.class, JwtAuthenticationFilter.class, JwtAuthenticationEntryPoint.class, + GlobalExceptionHandler.class}) class ScheduleControllerTest { @Autowired @@ -129,7 +132,8 @@ void returnsBadRequestWhenDateFormatIsInvalid() throws Exception { @Test @DisplayName("일정 생성 성공 시 201을 반환한다") void createsScheduleSuccessfully() throws Exception { - given(scheduleService.create(eq(ROOM_ID), any(ScheduleCreateCommand.class), eq(USER_ID))).willReturn(SCHEDULE_RESULT); + given(scheduleService.create(eq(ROOM_ID), any(ScheduleCreateCommand.class), eq(USER_ID))) + .willReturn(SCHEDULE_RESULT); mockMvc.perform(post("/rooms/{roomId}/schedules", ROOM_ID) .cookie(new Cookie("access_token", VALID_TOKEN)) @@ -245,7 +249,9 @@ void createBatchWith31SchedulesReturnsBadRequest() throws Exception { .contentType(MediaType.APPLICATION_JSON) .content("{\"schedules\": " + schedulesJson + "}")) .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.message").value("한 번에 최대 " + SchedulePolicy.MAX_SCHEDULES_PER_ROOM + "개의 일정만 생성할 수 있습니다")); + .andExpect(jsonPath("$.message").value( + "한 번에 최대 " + SchedulePolicy.MAX_SCHEDULES_PER_ROOM + "개의 일정만 생성할 수 있습니다" + )); verifyNoInteractions(scheduleService); } 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 52862251..23cc41dd 100644 --- a/src/test/java/com/howaboutus/backend/schedules/service/ScheduleServiceTest.java +++ b/src/test/java/com/howaboutus/backend/schedules/service/ScheduleServiceTest.java @@ -10,21 +10,6 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; -import com.howaboutus.backend.common.error.CustomException; -import com.howaboutus.backend.common.error.ErrorCode; -import com.howaboutus.backend.realtime.event.RoomScheduleChangedEvent; -import com.howaboutus.backend.realtime.service.dto.RoomScheduleEventType; -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.SchedulePolicy; -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.ScheduleResult; -import com.howaboutus.backend.schedules.service.dto.ScheduleWithItemsResult; import java.time.Instant; import java.time.LocalDate; import java.util.List; @@ -32,6 +17,7 @@ import java.util.UUID; import java.util.stream.IntStream; import java.util.stream.Stream; + import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -42,10 +28,26 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.dao.DataIntegrityViolationException; import org.springframework.context.ApplicationEventPublisher; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.test.util.ReflectionTestUtils; +import com.howaboutus.backend.common.error.CustomException; +import com.howaboutus.backend.common.error.ErrorCode; +import com.howaboutus.backend.realtime.event.RoomScheduleChangedEvent; +import com.howaboutus.backend.realtime.service.dto.RoomScheduleEventType; +import com.howaboutus.backend.rooms.entity.Room; +import com.howaboutus.backend.rooms.service.RoomAccessService; +import com.howaboutus.backend.schedules.SchedulePolicy; +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.ScheduleResult; +import com.howaboutus.backend.schedules.service.dto.ScheduleWithItemsResult; + @ExtendWith(MockitoExtension.class) class ScheduleServiceTest { @@ -68,7 +70,11 @@ class ScheduleServiceTest { @BeforeEach void setUp() { - scheduleService = new ScheduleService(roomAccessService, scheduleRepository, scheduleItemRepository, scheduleItemService, + scheduleService = new ScheduleService( + roomAccessService, + scheduleRepository, + scheduleItemRepository, + scheduleItemService, eventPublisher); } @@ -318,7 +324,8 @@ void getSchedulesWithItemsFetchesItemsInBulk() { 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)) + given(scheduleItemRepository + .findAllBySchedule_Room_IdOrderBySchedule_DayNumberAscSchedule_IdAscOrderIndexAscIdAsc(roomId)) .willReturn(List.of(firstItem, secondItem, thirdItem)); List results = scheduleService.getSchedulesWithItems(roomId, 1L); @@ -331,7 +338,8 @@ void getSchedulesWithItemsFetchesItemsInBulk() { 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); + order.verify(scheduleItemRepository) + .findAllBySchedule_Room_IdOrderBySchedule_DayNumberAscSchedule_IdAscOrderIndexAscIdAsc(roomId); verify(scheduleItemService, never()).getItems(any(), any(), any()); } @@ -371,7 +379,14 @@ void getSchedulesPropagatesAuthorizationFailureBeforeScheduleRepositoryCalls() { void deleteThrowsWhenScheduleOutsideRoom() { UUID roomId = UUID.randomUUID(); given(roomAccessService.getRoom(roomId)) - .willReturn(Room.create("도쿄 여행", "도쿄", LocalDate.of(2026, 4, 20), LocalDate.of(2026, 4, 23), "INVITE", 1L)); + .willReturn(Room.create( + "도쿄 여행", + "도쿄", + LocalDate.of(2026, 4, 20), + LocalDate.of(2026, 4, 23), + "INVITE", + 1L + )); given(scheduleRepository.findByIdAndRoom_Id(10L, roomId)).willReturn(Optional.empty()); assertThatThrownBy(() -> scheduleService.delete(roomId, 10L, 1L)) From 5de260ca5beaaef2b23eb08812365c847b08254d Mon Sep 17 00:00:00 2001 From: minbros Date: Sun, 7 Jun 2026 15:04:21 +0900 Subject: [PATCH 03/11] =?UTF-8?q?feat:=20=EC=9E=A5=EC=86=8C=20=EB=B2=8C?= =?UTF-8?q?=ED=81=AC=20=EC=A1=B0=ED=9A=8C=EC=99=80=20=EC=82=AC=EC=A7=84=20?= =?UTF-8?q?URL=20=EC=BA=90=EC=8B=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/ai/erd.md | 4 +- docs/ai/features.md | 8 +- .../common/cache/RedisBulkCacheAccessor.java | 78 ++++++++++++ .../backend/common/config/CachePolicy.java | 2 +- .../HttpRateLimitPolicyResolver.java | 17 ++- .../places/controller/PlaceController.java | 54 ++++++++- .../dto/PlacePhotoBatchRequest.java | 20 ++++ .../dto/PlacePhotoBatchResponse.java | 20 ++++ .../controller/dto/PlacePhotoUrlResponse.java | 12 ++ .../dto/PlacePreviewBatchRequest.java | 19 +++ .../dto/PlacePreviewBatchResponse.java | 20 ++++ .../places/service/PlacePhotoService.java | 94 ++++++++++++++- .../places/service/PlacePreviewService.java | 78 ++++++++++++ .../service/dto/PlacePhotoUrlResult.java | 7 ++ .../HttpRateLimitPolicyResolverTest.java | 14 +++ .../controller/PlaceControllerTest.java | 112 ++++++++++++++++++ .../service/PlaceDetailCachingTest.java | 44 ++++++- .../places/service/PlacePhotoCachingTest.java | 49 +++++++- .../places/service/PlacePhotoServiceTest.java | 13 +- 19 files changed, 643 insertions(+), 22 deletions(-) create mode 100644 src/main/java/com/howaboutus/backend/common/cache/RedisBulkCacheAccessor.java create mode 100644 src/main/java/com/howaboutus/backend/places/controller/dto/PlacePhotoBatchRequest.java create mode 100644 src/main/java/com/howaboutus/backend/places/controller/dto/PlacePhotoBatchResponse.java create mode 100644 src/main/java/com/howaboutus/backend/places/controller/dto/PlacePhotoUrlResponse.java create mode 100644 src/main/java/com/howaboutus/backend/places/controller/dto/PlacePreviewBatchRequest.java create mode 100644 src/main/java/com/howaboutus/backend/places/controller/dto/PlacePreviewBatchResponse.java create mode 100644 src/main/java/com/howaboutus/backend/places/service/dto/PlacePhotoUrlResult.java diff --git a/docs/ai/erd.md b/docs/ai/erd.md index 0b760767..3b89bd94 100644 --- a/docs/ai/erd.md +++ b/docs/ai/erd.md @@ -220,7 +220,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)을 확인했음을 대기 요청에 전달하는 짧은 조정 신호 | @@ -242,7 +242,7 @@ 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 호출을 줄인다. 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 2d5f4bf5..34b30d5d 100644 --- a/docs/ai/features.md +++ b/docs/ai/features.md @@ -36,7 +36,7 @@ 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 상세/미리보기/사진 조회 및 벌크 조회 | `30/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` | @@ -89,14 +89,14 @@ Caddy는 `caddy-ratelimit` 기반 IP 제한으로 엣지에서 넓고 거친 폭 ## 4. 장소 (Places) -> `places` 테이블 없이 `google_place_id`를 직접 사용한다. 검색은 캐시하지 않고, 장소 미리보기 본문은 Redis에 24시간(1일) TTL, 장소 상세 조회 본문은 Redis에 6시간 TTL로 캐시한다. 사진 리소스명(`photoName`/`photoNames`)은 응답에는 포함하지만 캐시에는 저장하지 않는다. +> `places` 테이블 없이 `google_place_id`를 직접 사용한다. 검색은 캐시하지 않고, 장소 미리보기 본문은 Redis에 24시간(1일) TTL, 장소 상세 조회 본문은 Redis에 6시간 TTL로 캐시한다. 사진 URL은 `photoName`과 기본 크기(400x400) 기준으로 Redis에 24시간(1일) TTL 캐시한다. 사진 리소스명(`photoName`/`photoNames`)은 응답에는 포함하지만 캐시에는 저장하지 않는다. | 상태 | 기능 | 설명 | ERD 연관 | |------|------|------|----------| | `[x]` | 장소 검색 | Google Places API (New)로 장소명, 주소, 장소 유형 표시명(`primaryTypeDisplayName`), 평점(`rating`), 평점 수(`userRatingCount`), 영업 여부, 사진 리소스명 등을 검색. `pageSize`(1~20, 기본값 10)와 `pageToken`으로 다음 페이지를 요청하고, 응답은 `items`와 Google `nextPageToken`을 포함한다 | - | -| `[x]` | 장소 미리보기 조회 | 북마크/일정 카드 표시용으로 장소명, 주소, 장소 유형(`primaryType`), 장소 유형 표시명(`primaryTypeDisplayName`), 좌표, 대표 사진 리소스명(`photoName`)을 응답한다. 캐시 miss 시 `photos.name`을 포함한 미리보기 요청 1회로 응답하고, Redis에는 `photoName`을 제거한 본문만 24시간(1일) TTL로 저장한다. 캐시 hit 시에는 `photos.name` 전용 Google Place Details 요청으로 대표 사진 리소스명을 다시 조회해 합친다 | Redis | +| `[x]` | 장소 미리보기 조회 | 북마크/일정 카드 표시용으로 장소명, 주소, 장소 유형(`primaryType`), 장소 유형 표시명(`primaryTypeDisplayName`), 좌표, 대표 사진 리소스명(`photoName`)을 응답한다. 단건은 `GET /places/{googlePlaceId}/preview`, 벌크는 `POST /places/previews/batch`로 조회한다. 벌크 조회는 최대 100개 `googlePlaceIds`를 받아 중복 ID를 제거하고 Redis bulk read 후 캐시 miss 항목만 Google Places API로 병렬 조회한다. 캐시 miss 시 `photos.name`을 포함한 미리보기 요청 1회로 응답하고, Redis에는 `photoName`을 제거한 본문만 24시간(1일) TTL로 저장한다. 캐시 hit 시에는 `photos.name` 전용 Google Place Details 요청으로 대표 사진 리소스명을 다시 조회해 합친다 | Redis | | `[x]` | 장소 상세 조회 | 장소명, 주소, 장소 유형 표시명(`primaryTypeDisplayName`), 평점(`rating`), 평점 수(`userRatingCount`), 전화번호, 웹사이트, 요일별 영업시간(`weekdayDescriptions`)을 포함한 전체 영업시간(`regularOpeningHours`), 리뷰 목록(`reviews`) 등을 응답한다. 캐시 miss 시 `photos.name`을 포함한 상세 요청 1회로 응답하고, Redis에는 `photoNames`를 제거한 본문만 6시간 TTL로 저장한다. 캐시 hit 시에는 `photos.name` 전용 Google Place Details 요청으로 사진 목록을 다시 조회해 합친다 | Redis | -| `[x]` | 장소 사진 URL 조회 | `photoName`을 받아 Google Photo Media API를 호출, `photoUrl` 반환. `photoUrl`은 짧은 수명 URL이므로 Redis에 10분 TTL 캐시 | Redis | +| `[x]` | 장소 사진 URL 조회 | `photoName`을 받아 Google Photo Media API를 호출, `photoUrl` 반환. 단건은 `GET /places/photos`, 벌크는 `POST /places/photos/batch`로 조회한다. 벌크 조회는 최대 100개 `photoNames`를 받아 중복 `photoName`을 제거하고 Redis bulk read 후 캐시 miss 항목만 Google Photo Media API로 병렬 조회한다. `photoUrl`은 짧은 수명 URL이지만 우선 Redis에 24시간(1일) TTL 캐시하며, cache key는 `photoName`과 기본 크기(400x400)를 포함한다 | Redis | | `[x]` | 장소 사진 이름 목록 조회 | googlePlaceId를 기반으로 장소의 사진 이름(photoName) 목록을 조회한다. 결과는 maxPhotoCount 개수만큼 반환 | - | --- 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..d5a3098b --- /dev/null +++ b/src/main/java/com/howaboutus/backend/common/cache/RedisBulkCacheAccessor.java @@ -0,0 +1,78 @@ +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 lombok.RequiredArgsConstructor; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.jsontype.BasicPolymorphicTypeValidator; + +@Component +@RequiredArgsConstructor +public class RedisBulkCacheAccessor { + + private final RedisConnectionFactory connectionFactory; + private final ObjectMapper objectMapper; + + 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<>(); + GenericJacksonJsonRedisSerializer serializer = serializer(); + 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); + } + + private GenericJacksonJsonRedisSerializer serializer() { + return GenericJacksonJsonRedisSerializer.builder(objectMapper::rebuild) + .enableDefaultTyping( + BasicPolymorphicTypeValidator.builder() + .allowIfSubType("com.howaboutus.backend") + .allowIfBaseType(Map.class) + .allowIfBaseType(Collection.class) + .build() + ) + .build(); + } +} 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/ratelimit/HttpRateLimitPolicyResolver.java b/src/main/java/com/howaboutus/backend/common/ratelimit/HttpRateLimitPolicyResolver.java index 2c5bbd50..451d075b 100644 --- a/src/main/java/com/howaboutus/backend/common/ratelimit/HttpRateLimitPolicyResolver.java +++ b/src/main/java/com/howaboutus/backend/common/ratelimit/HttpRateLimitPolicyResolver.java @@ -27,6 +27,10 @@ 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 List PLACE_BATCH_READ_PATHS = List.of( + "/places/previews/batch", + "/places/photos/batch" + ); private final RateLimitProperties rateLimitProperties; @@ -68,18 +72,23 @@ 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()) { + if (isPlaceReadRequest(method, path)) { plans.add(plan("http:places-read", "user", String.valueOf(userId.get()), "places-read")); } } + private boolean isPlaceReadRequest(String method, String path) { + return ("GET".equals(method) && ("/places/photos".equals(path) || PLACE_READ_PATTERN.matcher(path).matches())) + || ("POST".equals(method) && PLACE_BATCH_READ_PATHS.contains(path)); + } + private void addRoutePolicies(String method, String path, Optional userId, List plans) { if (!"GET".equals(method)) { return; @@ -103,7 +112,7 @@ 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)) { return; } if ("POST".equals(method) || "PATCH".equals(method) || "DELETE".equals(method)) { 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..48aceabf 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,12 @@ 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.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 +33,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 +130,63 @@ public PlacePreviewResponse getPreview( return PlacePreviewResponse.from(placePreviewService.getPreview(googlePlaceId)); } + @Operation( + summary = "장소 미리보기 벌크 조회", + description = "googlePlaceId 목록을 기반으로 카드 미리보기에 필요한 최소 장소 정보를 한 번에 조회합니다. " + + "서버는 중복 ID를 제거하고 캐시 miss 항목만 Google Places API로 조회합니다." + ) + @ApiResponse(responseCode = "200", description = "조회 성공") + @ApiErrorCodes({ErrorCode.INVALID_TOKEN, ErrorCode.ACCESS_TOKEN_EXPIRED, ErrorCode.EXTERNAL_API_ERROR}) + @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(new PlacePhotoResponse(placePhotoService.getPhotoUrl(photoName))); + } + return ResponseEntity.noContent().build(); + } + + @Operation( + summary = "장소 사진 URL 벌크 조회", + description = "photoName 목록을 기반으로 Google 장소 사진 URL을 한 번에 조회합니다. " + + "서버는 중복 photoName을 제거하고 기본 크기 기준 캐시 miss 항목만 Google Photo Media API로 조회합니다. " + + "사진 기능이 비활성화된 경우 204를 반환합니다." + ) + @ApiResponse(responseCode = "200", description = "조회 성공") + @ApiResponse(responseCode = "204", description = "사진 기능 비활성화 상태 (No Content)", content = @Content) + @ApiErrorCodes({ErrorCode.INVALID_TOKEN, ErrorCode.ACCESS_TOKEN_EXPIRED, ErrorCode.EXTERNAL_API_ERROR}) + @PostMapping("/places/photos/batch") + public ResponseEntity getPhotoUrls( + @Valid + @RequestBody + PlacePhotoBatchRequest request) { + if (photoEnabled) { + return ResponseEntity.ok(PlacePhotoBatchResponse.from(placePhotoService.getPhotoUrls( + request.photoNames() + ))); } - return null; + return ResponseEntity.noContent().build(); } @Operation( 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..ca640263 --- /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.PlacePhotoUrlResult; + +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(PlacePhotoUrlResponse::from) + .toList() + ); + } +} diff --git a/src/main/java/com/howaboutus/backend/places/controller/dto/PlacePhotoUrlResponse.java b/src/main/java/com/howaboutus/backend/places/controller/dto/PlacePhotoUrlResponse.java new file mode 100644 index 00000000..43a4ac88 --- /dev/null +++ b/src/main/java/com/howaboutus/backend/places/controller/dto/PlacePhotoUrlResponse.java @@ -0,0 +1,12 @@ +package com.howaboutus.backend.places.controller.dto; + +import com.howaboutus.backend.places.service.dto.PlacePhotoUrlResult; + +public record PlacePhotoUrlResponse( + String photoName, + String photoUrl +) { + public static PlacePhotoUrlResponse from(PlacePhotoUrlResult result) { + return new PlacePhotoUrlResponse(result.photoName(), result.photoUrl()); + } +} 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..1c27d6d1 --- /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.PlacePreviewResult; + +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(PlacePreviewResponse::from) + .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..3881f24f 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.CompletionException; +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.places.service.dto.PlacePhotoUrlResult; import lombok.RequiredArgsConstructor; @@ -12,10 +20,90 @@ @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 taskExecutor; - @Cacheable(cacheNames = CachePolicy.Keys.PLACE_PHOTO_URI) public String getPhotoUrl(String photoName) { - return googlePlacePhotoClient.getPhotoUri(photoName); + return getPhotoUrls(List.of(photoName)).getFirst().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 -> new PlacePhotoUrlResult( + 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), + taskExecutor + ) + )); + + Map result = new LinkedHashMap<>(); + futures.forEach((photoName, future) -> { + String photoUrl = join(future); + redisBulkCacheAccessor.put( + CachePolicy.Keys.PLACE_PHOTO_URI, + cacheKey(photoName), + photoUrl, + CachePolicy.PLACE_PHOTO_URI.getDuration() + ); + result.put(photoName, photoUrl); + }); + return result; + } + + private String resolvePhotoUrl( + String photoName, + Map cacheKeysByPhotoName, + Map cachedByCacheKey, + Map freshByPhotoName) { + String cached = cachedByCacheKey.get(cacheKeysByPhotoName.get(photoName)); + if (cached != null) { + return cached; + } + return freshByPhotoName.get(photoName); + } + + private String cacheKey(String photoName) { + return photoName + DEFAULT_PHOTO_SIZE_CACHE_SUFFIX; + } + + private String join(CompletableFuture future) { + try { + return future.join(); + } catch (CompletionException exception) { + if (exception.getCause() instanceof RuntimeException runtimeException) { + throw runtimeException; + } + throw exception; + } } } 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..4bc716ef 100644 --- a/src/main/java/com/howaboutus/backend/places/service/PlacePreviewService.java +++ b/src/main/java/com/howaboutus/backend/places/service/PlacePreviewService.java @@ -1,11 +1,18 @@ 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.CompletionException; +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.places.service.dto.PlacePreviewResult; @@ -19,6 +26,8 @@ public class PlacePreviewService { private final GooglePlaceDetailClient googlePlaceDetailClient; private final PlacePhotoNameService placePhotoNameService; private final CacheManager cacheManager; + private final RedisBulkCacheAccessor redisBulkCacheAccessor; + private final Executor taskExecutor; public PlacePreviewResult getPreview(String googlePlaceId) { Cache cache = Objects.requireNonNull(cacheManager.getCache(CachePolicy.Keys.PLACE_PREVIEW)); @@ -31,4 +40,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( + () -> preview.withPhotoName(placePhotoNameService.getFirstPhotoName(googlePlaceId)), + taskExecutor + ) + )); + 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 fresh; + }, taskExecutor) + )); + return joinPreviewFutures(futures); + } + + private Map joinPreviewFutures( + Map> futures) { + Map result = new LinkedHashMap<>(); + futures.forEach((googlePlaceId, future) -> result.put(googlePlaceId, join(future))); + return result; + } + + private PlacePreviewResult join(CompletableFuture future) { + try { + return future.join(); + } catch (CompletionException exception) { + if (exception.getCause() instanceof RuntimeException runtimeException) { + throw runtimeException; + } + throw exception; + } + } } diff --git a/src/main/java/com/howaboutus/backend/places/service/dto/PlacePhotoUrlResult.java b/src/main/java/com/howaboutus/backend/places/service/dto/PlacePhotoUrlResult.java new file mode 100644 index 00000000..8637b71f --- /dev/null +++ b/src/main/java/com/howaboutus/backend/places/service/dto/PlacePhotoUrlResult.java @@ -0,0 +1,7 @@ +package com.howaboutus.backend.places.service.dto; + +public record PlacePhotoUrlResult( + String photoName, + String photoUrl +) { +} 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..415b9c49 100644 --- a/src/test/java/com/howaboutus/backend/common/ratelimit/HttpRateLimitPolicyResolverTest.java +++ b/src/test/java/com/howaboutus/backend/common/ratelimit/HttpRateLimitPolicyResolverTest.java @@ -81,6 +81,20 @@ void resolvesPlaceReadPolicies() { .containsExactly("rate-limit:http:places-read:user:42"); } + @Test + @DisplayName("장소 미리보기와 사진 벌크 POST는 조회 제한만 적용하고 쓰기 제한은 적용하지 않는다") + void resolvesPlaceBatchReadPoliciesWithoutWritePolicy() { + authenticate(42L); + + assertThat(resolver.resolve(request("POST", "/places/previews/batch"))) + .extracting(RateLimitPlan::key) + .containsExactly("rate-limit:http:places-read:user:42"); + + assertThat(resolver.resolve(request("POST", "/places/photos/batch"))) + .extracting(RateLimitPlan::key) + .containsExactly("rate-limit:http:places-read:user:42"); + } + @Test @DisplayName("일반 GET은 Spring 기능별 rate limit을 적용하지 않는다") void doesNotResolveSpringPolicyForOrdinaryGet() { 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..cb04e15c 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,7 @@ 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.PlacePhotoUrlResult; 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 +54,9 @@ class PlaceControllerTest { @Autowired private MockMvc mockMvc; + @Autowired + private PlaceController placeController; + @MockitoBean private JwtProvider jwtProvider; @@ -83,6 +89,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 +450,63 @@ 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( + new PlacePreviewResult( + "ChIJ123", + "Cafe Layered", + "서울 종로구 ...", + "cafe", + "카페", + new PlacePreviewResult.Location(37.57, 126.98), + "places/ChIJ123/photos/a" + ), + 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].photoName").value("places/ChIJ123/photos/a")) + .andExpect(jsonPath("$.previews[1].googlePlaceId").value("ChIJ456")) + .andExpect(jsonPath("$.previews[1].photoName").value(Matchers.nullValue())); + + then(placePreviewService).should().getPreviews(List.of("ChIJ123", "ChIJ456")); + } + + @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 +535,54 @@ 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( + new PlacePhotoUrlResult( + "places/ChIJ123/photos/a", + "https://lh3.googleusercontent.com/a.jpg" + ), + new PlacePhotoUrlResult( + "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].photoUrl").value("https://lh3.googleusercontent.com/a.jpg")) + .andExpect(jsonPath("$.photos[1].photoName").value("places/ChIJ456/photos/b")) + .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 조회는 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 { 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..02af072b 100644 --- a/src/test/java/com/howaboutus/backend/places/service/PlaceDetailCachingTest.java +++ b/src/test/java/com/howaboutus/backend/places/service/PlaceDetailCachingTest.java @@ -94,6 +94,38 @@ 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(PlacePreviewResult::googlePlaceId) + .containsExactly("ChIJ123", "ChIJ123", "ChIJ456"); + assertThat(first) + .extracting(PlacePreviewResult::photoName) + .containsExactly("places/ChIJ123/photos/a", "places/ChIJ123/photos/a", "places/ChIJ456/photos/b"); + assertThat(second) + .extracting(PlacePreviewResult::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("구버전 미리보기 캐시에 유형 필드가 없어도 null로 역직렬화하고 사진명만 갱신한다") void readsLegacyCachedPreviewBodyWithoutPrimaryTypeFields() { @@ -137,13 +169,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..065ea97f 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,23 @@ 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.integration.google.GooglePlacePhotoClient; +import com.howaboutus.backend.places.service.dto.PlacePhotoUrlResult; import com.howaboutus.backend.support.BaseIntegrationTest; class PlacePhotoCachingTest extends BaseIntegrationTest { @@ -28,6 +34,9 @@ class PlacePhotoCachingTest extends BaseIntegrationTest { @Autowired private CacheManager cacheManager; + @Autowired + private RedisConnectionFactory redisConnectionFactory; + @BeforeEach void setUp() { reset(googlePlacePhotoClient); @@ -35,7 +44,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 +54,44 @@ 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).containsExactly( + new PlacePhotoUrlResult("places/ChIJ123/photos/a", "https://lh3.googleusercontent.com/a.jpg"), + new PlacePhotoUrlResult("places/ChIJ123/photos/a", "https://lh3.googleusercontent.com/a.jpg"), + new PlacePhotoUrlResult("places/ChIJ456/photos/b", "https://lh3.googleusercontent.com/b.jpg") + ); + assertThat(second).containsExactly( + new PlacePhotoUrlResult("places/ChIJ456/photos/b", "https://lh3.googleusercontent.com/b.jpg"), + new PlacePhotoUrlResult("places/ChIJ123/photos/a", "https://lh3.googleusercontent.com/a.jpg") + ); + then(googlePlacePhotoClient).should(times(1)).getPhotoUri("places/ChIJ123/photos/a"); + then(googlePlacePhotoClient).should(times(1)).getPhotoUri("places/ChIJ456/photos/b"); + } + + 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..c20ab9c0 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,30 @@ import static org.mockito.BDDMockito.*; import static org.mockito.Mockito.*; +import java.util.concurrent.Executor; + import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import com.howaboutus.backend.common.cache.RedisBulkCacheAccessor; import com.howaboutus.backend.common.integration.google.GooglePlacePhotoClient; class PlacePhotoServiceTest { private final GooglePlacePhotoClient googlePlacePhotoClient = mock(GooglePlacePhotoClient.class); - private final PlacePhotoService placePhotoService = new PlacePhotoService(googlePlacePhotoClient); + private final RedisBulkCacheAccessor redisBulkCacheAccessor = mock(RedisBulkCacheAccessor.class); + private final Executor taskExecutor = Runnable::run; + private final PlacePhotoService placePhotoService = new PlacePhotoService( + googlePlacePhotoClient, + redisBulkCacheAccessor, + 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"); From 060ae5e0bdfcd5baf59bb65b92ecc34da2eb363a Mon Sep 17 00:00:00 2001 From: minbros Date: Sun, 7 Jun 2026 16:13:38 +0900 Subject: [PATCH 04/11] =?UTF-8?q?feat:=20=EA=B2=BD=EB=A1=9C=20=EB=B2=8C?= =?UTF-8?q?=ED=81=AC=20=EC=A1=B0=ED=9A=8C=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/ai/erd.md | 4 +- docs/ai/features.md | 6 +- .../HttpRateLimitPolicyResolver.java | 21 ++- .../controller/ScheduleRouteController.java | 55 ++++++++ .../controller/dto/BatchRouteItemRequest.java | 30 ++++ .../dto/BatchRouteItemResponse.java | 49 +++++++ .../controller/dto/BatchRouteRequest.java | 25 ++++ .../controller/dto/BatchRouteResponse.java | 19 +++ .../service/ScheduleItemService.java | 47 +++++++ .../service/dto/RouteBatchItemCommand.java | 7 + .../service/dto/RouteBatchItemResult.java | 37 +++++ .../HttpRateLimitPolicyResolverTest.java | 13 ++ .../ScheduleRouteControllerTest.java | 129 ++++++++++++++++++ .../service/ScheduleItemServiceTest.java | 72 ++++++++++ 14 files changed, 504 insertions(+), 10 deletions(-) create mode 100644 src/main/java/com/howaboutus/backend/schedules/controller/ScheduleRouteController.java create mode 100644 src/main/java/com/howaboutus/backend/schedules/controller/dto/BatchRouteItemRequest.java create mode 100644 src/main/java/com/howaboutus/backend/schedules/controller/dto/BatchRouteItemResponse.java create mode 100644 src/main/java/com/howaboutus/backend/schedules/controller/dto/BatchRouteRequest.java create mode 100644 src/main/java/com/howaboutus/backend/schedules/controller/dto/BatchRouteResponse.java create mode 100644 src/main/java/com/howaboutus/backend/schedules/service/dto/RouteBatchItemCommand.java create mode 100644 src/main/java/com/howaboutus/backend/schedules/service/dto/RouteBatchItemResult.java create mode 100644 src/test/java/com/howaboutus/backend/schedules/controller/ScheduleRouteControllerTest.java diff --git a/docs/ai/erd.md b/docs/ai/erd.md index 3b89bd94..210442dc 100644 --- a/docs/ai/erd.md +++ b/docs/ai/erd.md @@ -190,7 +190,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). Google Routes가 경로를 찾지 못한 구간도 단건 조회에서는 이동 정보를 반환하지 않습니다(204). 벌크 조회는 요청 자체가 유효하면 200을 반환하고, 마지막 항목·경로 없음·일시 실패 같은 구간별 결과는 각 route의 `status`/`errorCode`로 반환합니다. --- @@ -244,7 +244,7 @@ AI_RESPONSE metadata는 `requestMessageId`, `intent`, `placeRecommendation`, `co 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로 저장한다. 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 34b30d5d..01b0f70e 100644 --- a/docs/ai/features.md +++ b/docs/ai/features.md @@ -37,7 +37,7 @@ Caddy는 `caddy-ratelimit` 기반 IP 제한으로 엣지에서 넓고 거친 폭 | `POST /auth/refresh` | `10/min/refresh_token_hash` | | Places 검색 `GET /places/search` | `10/min/user` | | Places 상세/미리보기/사진 조회 및 벌크 조회 | `30/min/user` | -| Routes 조회 `GET /rooms/{roomId}/schedules/{scheduleId}/items/{itemId}/route` | `20/min/user` + `60/min/room` | +| Routes 조회 `GET /rooms/{roomId}/schedules/{scheduleId}/items/{itemId}/route`, `POST /rooms/{roomId}/schedules/{scheduleId}/routes/batch` | `20/min/user` + `60/min/room` | | `POST /rooms/join` | `10/min/user` | | 인증된 상태 변경 HTTP API | `60/min/user` | @@ -133,7 +133,7 @@ Caddy는 `caddy-ratelimit` 기반 IP 제한으로 엣지에서 넓고 거친 폭 ### 6-2. 일정 항목 (Schedule Items — 장소 단위) -> **이동 정보 흐름:** 순서 변경(HTTP) → DB 반영 → 클라이언트가 영향받는 구간을 `/route`로 조회 → 서버가 Google Routes API 프록시 (Redis 10분 캐시 + Redis single-flight lock). Google Maps Platform 정책상 결과(distance, duration)를 DB에 영구 저장하지 않음. 이동 수단은 서버 DB에 저장하지 않고 클라이언트가 로컬에서 관리하며, 필요 시 `/route?travelMode=` 파라미터로 전달함. 동일 경로 조회가 동시에 몰리면 Redis `SET NX EX` 기반 락 획득 요청 1개만 Google Routes API를 호출하고, 나머지는 route 캐시 또는 짧은 no-route 신호를 기다림. 마지막 장소이거나 Google Routes가 경로를 찾지 못한 구간은 204를 반환함. +> **이동 정보 흐름:** 순서 변경(HTTP) → DB 반영 → 클라이언트가 영향받는 구간을 `/route` 또는 `/routes/batch`로 조회 → 서버가 Google Routes API 프록시 (Redis 10분 캐시 + Redis single-flight lock). Google Maps Platform 정책상 결과(distance, duration)를 DB에 영구 저장하지 않음. 이동 수단은 서버 DB에 저장하지 않고 클라이언트가 로컬에서 관리하며, 단건 조회는 `/route?travelMode=` 파라미터, 벌크 조회는 요청 body의 항목별 `travelMode`로 전달함. 동일 경로 조회가 동시에 몰리면 Redis `SET NX EX` 기반 락 획득 요청 1개만 Google Routes API를 호출하고, 나머지는 route 캐시 또는 짧은 no-route 신호를 기다림. 단건 조회에서 마지막 장소이거나 Google Routes가 경로를 찾지 못한 구간은 204를 반환함. 벌크 조회는 요청 자체가 유효하면 200을 반환하고, 마지막 항목·경로 없음·일시 실패 같은 구간별 결과는 각 route의 `status`/`errorCode`로 반환함. | 상태 | 기능 | 설명 | ERD 연관 | |------|------|------|----------| @@ -143,7 +143,7 @@ Caddy는 `caddy-ratelimit` 기반 IP 제한으로 엣지에서 넓고 거친 폭 | `[x]` | 일정 순서 변경 (D&D) | order_index 재정렬, 변경된 전체 목록 반환 | schedule_items | | `[x]` | 일정 항목 메모 수정 | 방문 메모 수정/삭제 지원 (상세 정책은 [schedule_items.md](/docs/ai/schedule_items.md) 하위 메모 기능 문서 참조) | schedule_items | | `[x]` | 시간 설정 | start_time, duration_minutes 설정. duration_minutes는 NULL 허용, 값이 있으면 0~1000분 | schedule_items | -| `[x]` | 이동 정보 조회 | 현재→다음 장소 구간의 distance_meters, duration_seconds, travelMode 반환. 이동 수단은 요청 파라미터로만 받고 서버 DB에는 저장하지 않음. 마지막 항목이거나 Google Routes가 경로를 찾지 못하면 204. 결과는 Redis 10분 캐시, DB 저장 없음. 동시 cache miss는 Redis `SET NX EX` 기반 single-flight lock으로 대표 요청 1개만 Google Routes API를 호출 | schedule_items, Redis | +| `[x]` | 이동 정보 조회 | 단건은 현재→다음 장소 구간의 distance_meters, duration_seconds, travelMode 반환. 이동 수단은 요청 파라미터로 받고 서버 DB에는 저장하지 않음. 마지막 항목이거나 Google Routes가 경로를 찾지 못하면 204. 벌크는 `POST /rooms/{roomId}/schedules/{scheduleId}/routes/batch`에서 `{itemId, travelMode}` 목록을 받아 현재 일정 순서 기준 item→다음 item 구간을 요청 순서대로 반환하며, 구간별 부분 실패는 `status`/`errorCode`로 표현. 결과는 Redis 10분 캐시, DB 저장 없음. 동시 cache miss는 Redis `SET NX EX` 기반 single-flight lock으로 대표 요청 1개만 Google Routes API를 호출 | schedule_items, Redis | | `[-]` | 이동 수단 변경 동기화 | 서버 저장 및 실시간 브로드캐스트 대상에서 제외. 클라이언트가 로컬 스토리지 등으로 사용자별 이동 수단을 관리 | - | --- 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 451d075b..26c6af0b 100644 --- a/src/main/java/com/howaboutus/backend/common/ratelimit/HttpRateLimitPolicyResolver.java +++ b/src/main/java/com/howaboutus/backend/common/ratelimit/HttpRateLimitPolicyResolver.java @@ -26,6 +26,9 @@ public class HttpRateLimitPolicyResolver { private static final Pattern ROUTE_PATTERN = Pattern.compile("^/rooms/([^/]+)/schedules/[^/]+/items/[^/]+/route$"); + private static final Pattern ROUTE_BATCH_PATTERN = + Pattern.compile("^/rooms/([^/]+)/schedules/[^/]+/routes/batch$"); + private static final Pattern NO_ROUTE_PATTERN = Pattern.compile("$^"); private static final Pattern PLACE_READ_PATTERN = Pattern.compile("^/places/[^/]+(/preview|/photo-names)?$"); private static final List PLACE_BATCH_READ_PATHS = List.of( "/places/previews/batch", @@ -90,10 +93,7 @@ private boolean isPlaceReadRequest(String method, String path) { } private void addRoutePolicies(String method, String path, Optional userId, List plans) { - if (!"GET".equals(method)) { - return; - } - Matcher matcher = ROUTE_PATTERN.matcher(path); + Matcher matcher = routeMatcher(method, path); if (!matcher.matches()) { return; } @@ -101,6 +101,16 @@ private void addRoutePolicies(String method, String path, Optional userId, plans.add(plan("http:route", "room", matcher.group(1), "route-room")); } + private Matcher routeMatcher(String method, String path) { + if ("GET".equals(method)) { + return ROUTE_PATTERN.matcher(path); + } + if ("POST".equals(method)) { + return ROUTE_BATCH_PATTERN.matcher(path); + } + return NO_ROUTE_PATTERN.matcher(path); + } + private void addJoinPolicies(String method, String path, Optional userId, @@ -112,7 +122,8 @@ private void addJoinPolicies(String method, } private void addWritePolicy(String method, String path, Optional userId, List plans) { - if (userId.isEmpty() || path.startsWith("/auth/") || PLACE_BATCH_READ_PATHS.contains(path)) { + if (userId.isEmpty() || path.startsWith("/auth/") || PLACE_BATCH_READ_PATHS.contains(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/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..9a2477e8 --- /dev/null +++ b/src/main/java/com/howaboutus/backend/schedules/controller/dto/BatchRouteItemResponse.java @@ -0,0 +1,49 @@ +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", "NO_ROUTE", "LAST_ITEM", "SCHEDULE_ITEM_NOT_FOUND", + "ROUTE_TEMPORARILY_UNAVAILABLE", "EXTERNAL_API_ERROR"}) + 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/service/ScheduleItemService.java b/src/main/java/com/howaboutus/backend/schedules/service/ScheduleItemService.java index 8a4ad398..3a533c03 100644 --- a/src/main/java/com/howaboutus/backend/schedules/service/ScheduleItemService.java +++ b/src/main/java/com/howaboutus/backend/schedules/service/ScheduleItemService.java @@ -22,6 +22,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.ScheduleItemResult; @@ -182,6 +184,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/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..e283812d --- /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, errorCode, + null, null, errorCode); + } +} 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 415b9c49..6ad2563d 100644 --- a/src/test/java/com/howaboutus/backend/common/ratelimit/HttpRateLimitPolicyResolverTest.java +++ b/src/test/java/com/howaboutus/backend/common/ratelimit/HttpRateLimitPolicyResolverTest.java @@ -95,6 +95,19 @@ void resolvesPlaceBatchReadPoliciesWithoutWritePolicy() { .containsExactly("rate-limit:http:places-read: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:user:42", + "rate-limit:http:route:room:room-1" + ); + } + @Test @DisplayName("일반 GET은 Spring 기능별 rate limit을 적용하지 않는다") void doesNotResolveSpringPolicyForOrdinaryGet() { 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..0ee2c09e --- /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("NO_ROUTE")) + .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 8753a1b1..e31d06df 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.ScheduleItemResult; @@ -650,6 +652,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", "NO_ROUTE", 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, "LAST_ITEM", "LAST_ITEM"), + tuple(999L, "SCHEDULE_ITEM_NOT_FOUND", "SCHEDULE_ITEM_NOT_FOUND") + ); + verifyNoInteractions(routeService); + } + @Test @DisplayName("방이 없으면 일정 항목 생성 시 ROOM_NOT_FOUND 예외를 던진다") void createThrowsWhenRoomMissing() { From 23b91e2d6153f7d7603199a727fa07092bba8efa Mon Sep 17 00:00:00 2001 From: minbros Date: Sun, 7 Jun 2026 16:34:23 +0900 Subject: [PATCH 05/11] =?UTF-8?q?feat:=20=EC=9E=A5=EC=86=8C=20=EB=B2=8C?= =?UTF-8?q?=ED=81=AC=20=EC=A1=B0=ED=9A=8C=20=EC=8B=A4=ED=8C=A8=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/ai/features.md | 4 +- .../places/controller/PlaceController.java | 8 +- .../dto/PlacePhotoBatchItemResponse.java | 32 ++++++++ .../dto/PlacePhotoBatchResponse.java | 10 +-- .../controller/dto/PlacePhotoUrlResponse.java | 12 --- .../dto/PlacePreviewBatchItemResponse.java | 53 ++++++++++++ .../dto/PlacePreviewBatchResponse.java | 10 +-- .../places/service/PlacePhotoService.java | 74 +++++++++++------ .../places/service/PlacePreviewService.java | 42 ++++++---- .../dto/PlacePhotoBatchItemResult.java | 17 ++++ .../service/dto/PlacePhotoUrlResult.java | 7 -- .../dto/PlacePreviewBatchItemResult.java | 42 ++++++++++ .../controller/PlaceControllerTest.java | 81 +++++++++++++++++-- .../service/PlaceDetailCachingTest.java | 33 ++++++-- .../places/service/PlacePhotoCachingTest.java | 48 ++++++++--- 15 files changed, 378 insertions(+), 95 deletions(-) create mode 100644 src/main/java/com/howaboutus/backend/places/controller/dto/PlacePhotoBatchItemResponse.java delete mode 100644 src/main/java/com/howaboutus/backend/places/controller/dto/PlacePhotoUrlResponse.java create mode 100644 src/main/java/com/howaboutus/backend/places/controller/dto/PlacePreviewBatchItemResponse.java create mode 100644 src/main/java/com/howaboutus/backend/places/service/dto/PlacePhotoBatchItemResult.java delete mode 100644 src/main/java/com/howaboutus/backend/places/service/dto/PlacePhotoUrlResult.java create mode 100644 src/main/java/com/howaboutus/backend/places/service/dto/PlacePreviewBatchItemResult.java diff --git a/docs/ai/features.md b/docs/ai/features.md index 01b0f70e..c18d18c9 100644 --- a/docs/ai/features.md +++ b/docs/ai/features.md @@ -94,9 +94,9 @@ Caddy는 `caddy-ratelimit` 기반 IP 제한으로 엣지에서 넓고 거친 폭 | 상태 | 기능 | 설명 | ERD 연관 | |------|------|------|----------| | `[x]` | 장소 검색 | Google Places API (New)로 장소명, 주소, 장소 유형 표시명(`primaryTypeDisplayName`), 평점(`rating`), 평점 수(`userRatingCount`), 영업 여부, 사진 리소스명 등을 검색. `pageSize`(1~20, 기본값 10)와 `pageToken`으로 다음 페이지를 요청하고, 응답은 `items`와 Google `nextPageToken`을 포함한다 | - | -| `[x]` | 장소 미리보기 조회 | 북마크/일정 카드 표시용으로 장소명, 주소, 장소 유형(`primaryType`), 장소 유형 표시명(`primaryTypeDisplayName`), 좌표, 대표 사진 리소스명(`photoName`)을 응답한다. 단건은 `GET /places/{googlePlaceId}/preview`, 벌크는 `POST /places/previews/batch`로 조회한다. 벌크 조회는 최대 100개 `googlePlaceIds`를 받아 중복 ID를 제거하고 Redis bulk read 후 캐시 miss 항목만 Google Places API로 병렬 조회한다. 캐시 miss 시 `photos.name`을 포함한 미리보기 요청 1회로 응답하고, Redis에는 `photoName`을 제거한 본문만 24시간(1일) TTL로 저장한다. 캐시 hit 시에는 `photos.name` 전용 Google Place Details 요청으로 대표 사진 리소스명을 다시 조회해 합친다 | Redis | +| `[x]` | 장소 미리보기 조회 | 북마크/일정 카드 표시용으로 장소명, 주소, 장소 유형(`primaryType`), 장소 유형 표시명(`primaryTypeDisplayName`), 좌표, 대표 사진 리소스명(`photoName`)을 응답한다. 단건은 `GET /places/{googlePlaceId}/preview`, 벌크는 `POST /places/previews/batch`로 조회한다. 벌크 조회는 최대 100개 `googlePlaceIds`를 받아 중복 ID를 제거하고 Redis bulk read 후 캐시 miss 항목만 Google Places API로 병렬 조회하며, 일부 항목의 외부 API 실패는 `status`/`errorCode`로 반환한다. 캐시 miss 시 `photos.name`을 포함한 미리보기 요청 1회로 응답하고, Redis에는 `photoName`을 제거한 본문만 24시간(1일) TTL로 저장한다. 캐시 hit 시에는 `photos.name` 전용 Google Place Details 요청으로 대표 사진 리소스명을 다시 조회해 합친다 | Redis | | `[x]` | 장소 상세 조회 | 장소명, 주소, 장소 유형 표시명(`primaryTypeDisplayName`), 평점(`rating`), 평점 수(`userRatingCount`), 전화번호, 웹사이트, 요일별 영업시간(`weekdayDescriptions`)을 포함한 전체 영업시간(`regularOpeningHours`), 리뷰 목록(`reviews`) 등을 응답한다. 캐시 miss 시 `photos.name`을 포함한 상세 요청 1회로 응답하고, Redis에는 `photoNames`를 제거한 본문만 6시간 TTL로 저장한다. 캐시 hit 시에는 `photos.name` 전용 Google Place Details 요청으로 사진 목록을 다시 조회해 합친다 | Redis | -| `[x]` | 장소 사진 URL 조회 | `photoName`을 받아 Google Photo Media API를 호출, `photoUrl` 반환. 단건은 `GET /places/photos`, 벌크는 `POST /places/photos/batch`로 조회한다. 벌크 조회는 최대 100개 `photoNames`를 받아 중복 `photoName`을 제거하고 Redis bulk read 후 캐시 miss 항목만 Google Photo Media API로 병렬 조회한다. `photoUrl`은 짧은 수명 URL이지만 우선 Redis에 24시간(1일) TTL 캐시하며, cache key는 `photoName`과 기본 크기(400x400)를 포함한다 | Redis | +| `[x]` | 장소 사진 URL 조회 | `photoName`을 받아 Google Photo Media API를 호출, `photoUrl` 반환. 단건은 `GET /places/photos`, 벌크는 `POST /places/photos/batch`로 조회한다. 벌크 조회는 최대 100개 `photoNames`를 받아 중복 `photoName`을 제거하고 Redis bulk read 후 캐시 miss 항목만 Google Photo Media API로 병렬 조회하며, 일부 항목의 외부 API 실패는 `status`/`errorCode`로 반환한다. `photoUrl`은 짧은 수명 URL이지만 우선 Redis에 24시간(1일) TTL 캐시하며, cache key는 `photoName`과 기본 크기(400x400)를 포함한다 | Redis | | `[x]` | 장소 사진 이름 목록 조회 | googlePlaceId를 기반으로 장소의 사진 이름(photoName) 목록을 조회한다. 결과는 maxPhotoCount 개수만큼 반환 | - | --- 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 48aceabf..7bb5365b 100644 --- a/src/main/java/com/howaboutus/backend/places/controller/PlaceController.java +++ b/src/main/java/com/howaboutus/backend/places/controller/PlaceController.java @@ -133,10 +133,11 @@ public PlacePreviewResponse getPreview( @Operation( summary = "장소 미리보기 벌크 조회", description = "googlePlaceId 목록을 기반으로 카드 미리보기에 필요한 최소 장소 정보를 한 번에 조회합니다. " - + "서버는 중복 ID를 제거하고 캐시 miss 항목만 Google Places API로 조회합니다." + + "서버는 중복 ID를 제거하고 캐시 miss 항목만 Google Places API로 조회합니다. " + + "일부 항목의 외부 API 실패는 응답 body의 항목별 status/errorCode로 반환합니다." ) @ApiResponse(responseCode = "200", description = "조회 성공") - @ApiErrorCodes({ErrorCode.INVALID_TOKEN, ErrorCode.ACCESS_TOKEN_EXPIRED, ErrorCode.EXTERNAL_API_ERROR}) + @ApiErrorCodes({ErrorCode.INVALID_TOKEN, ErrorCode.ACCESS_TOKEN_EXPIRED}) @Loggable @PostMapping("/places/previews/batch") public PlacePreviewBatchResponse getPreviews( @@ -171,11 +172,12 @@ public ResponseEntity getPhotoUrl( 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, ErrorCode.EXTERNAL_API_ERROR}) + @ApiErrorCodes({ErrorCode.INVALID_TOKEN, ErrorCode.ACCESS_TOKEN_EXPIRED}) @PostMapping("/places/photos/batch") public ResponseEntity getPhotoUrls( @Valid 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..d2889736 --- /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", "EXTERNAL_API_ERROR"}) + 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/PlacePhotoBatchResponse.java b/src/main/java/com/howaboutus/backend/places/controller/dto/PlacePhotoBatchResponse.java index ca640263..f858bc4c 100644 --- a/src/main/java/com/howaboutus/backend/places/controller/dto/PlacePhotoBatchResponse.java +++ b/src/main/java/com/howaboutus/backend/places/controller/dto/PlacePhotoBatchResponse.java @@ -2,18 +2,18 @@ import java.util.List; -import com.howaboutus.backend.places.service.dto.PlacePhotoUrlResult; +import com.howaboutus.backend.places.service.dto.PlacePhotoBatchItemResult; import io.swagger.v3.oas.annotations.media.Schema; public record PlacePhotoBatchResponse( - @Schema(description = "요청 순서대로 정렬된 사진 URL 목록") - List photos + @Schema(description = "요청 순서대로 정렬된 사진 URL 항목별 결과") + List photos ) { - public static PlacePhotoBatchResponse from(List results) { + public static PlacePhotoBatchResponse from(List results) { return new PlacePhotoBatchResponse( results.stream() - .map(PlacePhotoUrlResponse::from) + .map(PlacePhotoBatchItemResponse::from) .toList() ); } diff --git a/src/main/java/com/howaboutus/backend/places/controller/dto/PlacePhotoUrlResponse.java b/src/main/java/com/howaboutus/backend/places/controller/dto/PlacePhotoUrlResponse.java deleted file mode 100644 index 43a4ac88..00000000 --- a/src/main/java/com/howaboutus/backend/places/controller/dto/PlacePhotoUrlResponse.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.howaboutus.backend.places.controller.dto; - -import com.howaboutus.backend.places.service.dto.PlacePhotoUrlResult; - -public record PlacePhotoUrlResponse( - String photoName, - String photoUrl -) { - public static PlacePhotoUrlResponse from(PlacePhotoUrlResult result) { - return new PlacePhotoUrlResponse(result.photoName(), result.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..13452735 --- /dev/null +++ b/src/main/java/com/howaboutus/backend/places/controller/dto/PlacePreviewBatchItemResponse.java @@ -0,0 +1,53 @@ +package com.howaboutus.backend.places.controller.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.howaboutus.backend.places.service.dto.PlacePreviewBatchItemResult; +import com.howaboutus.backend.places.service.dto.PlacePreviewResult; + +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", "EXTERNAL_API_ERROR"}) + 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) + PlacePreviewResult.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 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() + ); + } +} 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 index 1c27d6d1..55b0aae4 100644 --- a/src/main/java/com/howaboutus/backend/places/controller/dto/PlacePreviewBatchResponse.java +++ b/src/main/java/com/howaboutus/backend/places/controller/dto/PlacePreviewBatchResponse.java @@ -2,18 +2,18 @@ import java.util.List; -import com.howaboutus.backend.places.service.dto.PlacePreviewResult; +import com.howaboutus.backend.places.service.dto.PlacePreviewBatchItemResult; import io.swagger.v3.oas.annotations.media.Schema; public record PlacePreviewBatchResponse( - @Schema(description = "요청 순서대로 정렬된 장소 미리보기 목록") - List previews + @Schema(description = "요청 순서대로 정렬된 장소 미리보기 항목별 결과") + List previews ) { - public static PlacePreviewBatchResponse from(List results) { + public static PlacePreviewBatchResponse from(List results) { return new PlacePreviewBatchResponse( results.stream() - .map(PlacePreviewResponse::from) + .map(PlacePreviewBatchItemResponse::from) .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 3881f24f..5f1b6b3c 100644 --- a/src/main/java/com/howaboutus/backend/places/service/PlacePhotoService.java +++ b/src/main/java/com/howaboutus/backend/places/service/PlacePhotoService.java @@ -11,8 +11,10 @@ import com.howaboutus.backend.common.cache.RedisBulkCacheAccessor; import com.howaboutus.backend.common.config.CachePolicy; +import com.howaboutus.backend.common.error.CustomException; +import com.howaboutus.backend.common.error.ErrorCode; import com.howaboutus.backend.common.integration.google.GooglePlacePhotoClient; -import com.howaboutus.backend.places.service.dto.PlacePhotoUrlResult; +import com.howaboutus.backend.places.service.dto.PlacePhotoBatchItemResult; import lombok.RequiredArgsConstructor; @@ -27,10 +29,23 @@ public class PlacePhotoService { private final Executor taskExecutor; public String getPhotoUrl(String photoName) { - return getPhotoUrls(List.of(photoName)).getFirst().photoUrl(); + 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) { + public List getPhotoUrls(List photoNames) { List distinctPhotoNames = photoNames.stream().distinct().toList(); Map cacheKeysByPhotoName = new LinkedHashMap<>(); distinctPhotoNames.forEach(photoName -> cacheKeysByPhotoName.put( @@ -46,17 +61,14 @@ public List getPhotoUrls(List photoNames) { List missingPhotoNames = distinctPhotoNames.stream() .filter(photoName -> !cachedByCacheKey.containsKey(cacheKeysByPhotoName.get(photoName))) .toList(); - Map freshByPhotoName = fetchMissingPhotoUrls(missingPhotoNames); + Map freshByPhotoName = fetchMissingPhotoUrls(missingPhotoNames); return photoNames.stream() - .map(photoName -> new PlacePhotoUrlResult( - photoName, - resolvePhotoUrl(photoName, cacheKeysByPhotoName, cachedByCacheKey, freshByPhotoName) - )) + .map(photoName -> resolvePhotoUrl(photoName, cacheKeysByPhotoName, cachedByCacheKey, freshByPhotoName)) .toList(); } - private Map fetchMissingPhotoUrls(List photoNames) { + private Map fetchMissingPhotoUrls(List photoNames) { Map> futures = new LinkedHashMap<>(); photoNames.forEach(photoName -> futures.put( photoName, @@ -66,28 +78,25 @@ private Map fetchMissingPhotoUrls(List photoNames) { ) )); - Map result = new LinkedHashMap<>(); + Map result = new LinkedHashMap<>(); futures.forEach((photoName, future) -> { - String photoUrl = join(future); - redisBulkCacheAccessor.put( - CachePolicy.Keys.PLACE_PHOTO_URI, - cacheKey(photoName), - photoUrl, - CachePolicy.PLACE_PHOTO_URI.getDuration() - ); - result.put(photoName, photoUrl); + PlacePhotoBatchItemResult itemResult = join(photoName, future); + if ("OK".equals(itemResult.status())) { + cachePhotoUrl(photoName, itemResult.photoUrl()); + } + result.put(photoName, itemResult); }); return result; } - private String resolvePhotoUrl( + private PlacePhotoBatchItemResult resolvePhotoUrl( String photoName, Map cacheKeysByPhotoName, Map cachedByCacheKey, - Map freshByPhotoName) { + Map freshByPhotoName) { String cached = cachedByCacheKey.get(cacheKeysByPhotoName.get(photoName)); if (cached != null) { - return cached; + return PlacePhotoBatchItemResult.ok(photoName, cached); } return freshByPhotoName.get(photoName); } @@ -96,14 +105,31 @@ private String cacheKey(String photoName) { return photoName + DEFAULT_PHOTO_SIZE_CACHE_SUFFIX; } - private String join(CompletableFuture future) { + private PlacePhotoBatchItemResult join(String photoName, CompletableFuture future) { try { - return future.join(); + return PlacePhotoBatchItemResult.ok(photoName, future.join()); } catch (CompletionException exception) { if (exception.getCause() instanceof RuntimeException runtimeException) { - throw runtimeException; + return handleBatchFailure(photoName, runtimeException); } throw exception; } } + + private PlacePhotoBatchItemResult handleBatchFailure(String photoName, RuntimeException exception) { + if (exception instanceof CustomException customException + && customException.getErrorCode() == ErrorCode.EXTERNAL_API_ERROR) { + return PlacePhotoBatchItemResult.failure(photoName, customException.getErrorCode().name()); + } + throw exception; + } + + 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 4bc716ef..001e5044 100644 --- a/src/main/java/com/howaboutus/backend/places/service/PlacePreviewService.java +++ b/src/main/java/com/howaboutus/backend/places/service/PlacePreviewService.java @@ -14,7 +14,10 @@ import com.howaboutus.backend.common.cache.RedisBulkCacheAccessor; import com.howaboutus.backend.common.config.CachePolicy; +import com.howaboutus.backend.common.error.CustomException; +import com.howaboutus.backend.common.error.ErrorCode; import com.howaboutus.backend.common.integration.google.GooglePlaceDetailClient; +import com.howaboutus.backend.places.service.dto.PlacePreviewBatchItemResult; import com.howaboutus.backend.places.service.dto.PlacePreviewResult; import lombok.RequiredArgsConstructor; @@ -41,7 +44,7 @@ public PlacePreviewResult getPreview(String googlePlaceId) { return fresh; } - public List getPreviews(List googlePlaceIds) { + public List getPreviews(List googlePlaceIds) { List distinctPlaceIds = googlePlaceIds.stream().distinct().toList(); Map cached = redisBulkCacheAccessor.multiGet( CachePolicy.Keys.PLACE_PREVIEW, @@ -52,7 +55,7 @@ public List getPreviews(List googlePlaceIds) { .filter(googlePlaceId -> !cached.containsKey(googlePlaceId)) .toList(); - Map resolved = new LinkedHashMap<>(); + Map resolved = new LinkedHashMap<>(); resolved.putAll(fetchCachedWithFreshPhotoNames(cached)); resolved.putAll(fetchMissingPreviews(missingPlaceIds)); @@ -61,21 +64,23 @@ public List getPreviews(List googlePlaceIds) { .toList(); } - private Map fetchCachedWithFreshPhotoNames( + private Map fetchCachedWithFreshPhotoNames( Map cached) { - Map> futures = new LinkedHashMap<>(); + Map> futures = new LinkedHashMap<>(); cached.forEach((googlePlaceId, preview) -> futures.put( googlePlaceId, CompletableFuture.supplyAsync( - () -> preview.withPhotoName(placePhotoNameService.getFirstPhotoName(googlePlaceId)), + () -> PlacePreviewBatchItemResult.ok( + preview.withPhotoName(placePhotoNameService.getFirstPhotoName(googlePlaceId)) + ), taskExecutor ) )); return joinPreviewFutures(futures); } - private Map fetchMissingPreviews(List googlePlaceIds) { - Map> futures = new LinkedHashMap<>(); + private Map fetchMissingPreviews(List googlePlaceIds) { + Map> futures = new LinkedHashMap<>(); googlePlaceIds.forEach(googlePlaceId -> futures.put( googlePlaceId, CompletableFuture.supplyAsync(() -> { @@ -86,27 +91,36 @@ private Map fetchMissingPreviews(List google fresh.withoutPhotoName(), CachePolicy.PLACE_PREVIEW.getDuration() ); - return fresh; + return PlacePreviewBatchItemResult.ok(fresh); }, taskExecutor) )); return joinPreviewFutures(futures); } - private Map joinPreviewFutures( - Map> futures) { - Map result = new LinkedHashMap<>(); - futures.forEach((googlePlaceId, future) -> result.put(googlePlaceId, join(future))); + private Map joinPreviewFutures( + Map> futures) { + Map result = new LinkedHashMap<>(); + futures.forEach((googlePlaceId, future) -> result.put(googlePlaceId, join(googlePlaceId, future))); return result; } - private PlacePreviewResult join(CompletableFuture future) { + private PlacePreviewBatchItemResult join(String googlePlaceId, + CompletableFuture future) { try { return future.join(); } catch (CompletionException exception) { if (exception.getCause() instanceof RuntimeException runtimeException) { - throw runtimeException; + return handleBatchFailure(googlePlaceId, runtimeException); } throw exception; } } + + private PlacePreviewBatchItemResult handleBatchFailure(String googlePlaceId, RuntimeException exception) { + if (exception instanceof CustomException customException + && customException.getErrorCode() == ErrorCode.EXTERNAL_API_ERROR) { + return PlacePreviewBatchItemResult.failure(googlePlaceId, customException.getErrorCode().name()); + } + throw exception; + } } 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..ef9da9ba --- /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, errorCode, null, errorCode); + } +} diff --git a/src/main/java/com/howaboutus/backend/places/service/dto/PlacePhotoUrlResult.java b/src/main/java/com/howaboutus/backend/places/service/dto/PlacePhotoUrlResult.java deleted file mode 100644 index 8637b71f..00000000 --- a/src/main/java/com/howaboutus/backend/places/service/dto/PlacePhotoUrlResult.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.howaboutus.backend.places.service.dto; - -public record PlacePhotoUrlResult( - String photoName, - String photoUrl -) { -} 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..e59be8fd --- /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, + errorCode, + null, + null, + null, + null, + null, + null, + errorCode + ); + } +} 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 cb04e15c..6d2e3946 100644 --- a/src/test/java/com/howaboutus/backend/places/controller/PlaceControllerTest.java +++ b/src/test/java/com/howaboutus/backend/places/controller/PlaceControllerTest.java @@ -32,7 +32,8 @@ 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.PlacePhotoUrlResult; +import com.howaboutus.backend.places.service.dto.PlacePhotoBatchItemResult; +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; @@ -455,7 +456,7 @@ void returnsPlacePreviewWhenGooglePlaceIdIsValid() throws Exception { void returnsPlacePreviewsForBatchRequest() throws Exception { given(placePreviewService.getPreviews(List.of("ChIJ123", "ChIJ456"))) .willReturn(List.of( - new PlacePreviewResult( + PlacePreviewBatchItemResult.ok(new PlacePreviewResult( "ChIJ123", "Cafe Layered", "서울 종로구 ...", @@ -463,8 +464,8 @@ void returnsPlacePreviewsForBatchRequest() throws Exception { "카페", new PlacePreviewResult.Location(37.57, 126.98), "places/ChIJ123/photos/a" - ), - new PlacePreviewResult( + )), + PlacePreviewBatchItemResult.ok(new PlacePreviewResult( "ChIJ456", "Museum", "서울 중구 ...", @@ -472,7 +473,7 @@ void returnsPlacePreviewsForBatchRequest() throws Exception { "박물관", new PlacePreviewResult.Location(37.56, 126.97), null - ) + )) )); mockMvc.perform(post("/places/previews/batch") @@ -484,13 +485,49 @@ void returnsPlacePreviewsForBatchRequest() throws Exception { .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].photoName").value(Matchers.nullValue())); + .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("EXTERNAL_API_ERROR")) + .andExpect(jsonPath("$.previews[1].errorCode").value("EXTERNAL_API_ERROR")) + .andExpect(jsonPath("$.previews[1].name").doesNotExist()); + } + @Test @DisplayName("미리보기 벌크 요청의 googlePlaceIds가 비어 있으면 400을 반환한다") void returnsBadRequestWhenBatchPreviewIdsAreEmpty() throws Exception { @@ -540,11 +577,11 @@ void returnsPhotoUrlForValidName() throws Exception { void returnsPhotoUrlsForBatchRequest() throws Exception { given(placePhotoService.getPhotoUrls(List.of("places/ChIJ123/photos/a", "places/ChIJ456/photos/b"))) .willReturn(List.of( - new PlacePhotoUrlResult( + PlacePhotoBatchItemResult.ok( "places/ChIJ123/photos/a", "https://lh3.googleusercontent.com/a.jpg" ), - new PlacePhotoUrlResult( + PlacePhotoBatchItemResult.ok( "places/ChIJ456/photos/b", "https://lh3.googleusercontent.com/b.jpg" ) @@ -559,14 +596,42 @@ void returnsPhotoUrlsForBatchRequest() throws Exception { .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("EXTERNAL_API_ERROR")) + .andExpect(jsonPath("$.photos[1].errorCode").value("EXTERNAL_API_ERROR")) + .andExpect(jsonPath("$.photos[1].photoUrl").doesNotExist()); + } + @Test @DisplayName("사진 기능이 비활성화된 경우 벌크 사진 URL 조회는 204를 반환한다") void returnsNoContentForBatchPhotoRequestWhenPhotoDisabled() 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 02af072b..1eeb88a1 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,7 @@ 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.PlacePreviewBatchItemResult; import com.howaboutus.backend.places.service.dto.PlacePreviewResult; import com.howaboutus.backend.support.BaseIntegrationTest; @@ -106,17 +108,19 @@ void getsPreviewsInBulkWithDeduplicationAndCacheReuse() { 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")); + List first = placePreviewService.getPreviews( + List.of("ChIJ123", "ChIJ123", "ChIJ456") + ); + List second = placePreviewService.getPreviews(List.of("ChIJ456", "ChIJ123")); assertThat(first) - .extracting(PlacePreviewResult::googlePlaceId) + .extracting(PlacePreviewBatchItemResult::googlePlaceId) .containsExactly("ChIJ123", "ChIJ123", "ChIJ456"); assertThat(first) - .extracting(PlacePreviewResult::photoName) + .extracting(PlacePreviewBatchItemResult::photoName) .containsExactly("places/ChIJ123/photos/a", "places/ChIJ123/photos/a", "places/ChIJ456/photos/b"); assertThat(second) - .extracting(PlacePreviewResult::photoName) + .extracting(PlacePreviewBatchItemResult::photoName) .containsExactly("places/ChIJ456/photos/d", "places/ChIJ123/photos/c"); assertThat(cachedPreviewBody("ChIJ123").photoName()).isNull(); assertThat(cachedPreviewBody("ChIJ456").photoName()).isNull(); @@ -126,6 +130,25 @@ void getsPreviewsInBulkWithDeduplicationAndCacheReuse() { 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", "EXTERNAL_API_ERROR", null, "EXTERNAL_API_ERROR") + ); + } + @Test @DisplayName("구버전 미리보기 캐시에 유형 필드가 없어도 null로 역직렬화하고 사진명만 갱신한다") void readsLegacyCachedPreviewBodyWithoutPrimaryTypeFields() { 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 065ea97f..27440b30 100644 --- a/src/test/java/com/howaboutus/backend/places/service/PlacePhotoCachingTest.java +++ b/src/test/java/com/howaboutus/backend/places/service/PlacePhotoCachingTest.java @@ -19,8 +19,9 @@ 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.PlacePhotoUrlResult; +import com.howaboutus.backend.places.service.dto.PlacePhotoBatchItemResult; import com.howaboutus.backend.support.BaseIntegrationTest; class PlacePhotoCachingTest extends BaseIntegrationTest { @@ -74,19 +75,46 @@ void getsPhotoUrlsInBulkWithDeduplicationAndCacheReuse() { List.of("places/ChIJ456/photos/b", "places/ChIJ123/photos/a") ); - assertThat(first).containsExactly( - new PlacePhotoUrlResult("places/ChIJ123/photos/a", "https://lh3.googleusercontent.com/a.jpg"), - new PlacePhotoUrlResult("places/ChIJ123/photos/a", "https://lh3.googleusercontent.com/a.jpg"), - new PlacePhotoUrlResult("places/ChIJ456/photos/b", "https://lh3.googleusercontent.com/b.jpg") - ); - assertThat(second).containsExactly( - new PlacePhotoUrlResult("places/ChIJ456/photos/b", "https://lh3.googleusercontent.com/b.jpg"), - new PlacePhotoUrlResult("places/ChIJ123/photos/a", "https://lh3.googleusercontent.com/a.jpg") - ); + 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", "EXTERNAL_API_ERROR", null, "EXTERNAL_API_ERROR") + ); + } + private long photoCacheTtl(String key) { try (RedisConnection connection = redisConnectionFactory.getConnection()) { return connection.keyCommands().ttl( From 9081b128cfd411bfa2880d8695dd9631910012df Mon Sep 17 00:00:00 2001 From: minbros Date: Sun, 7 Jun 2026 16:49:45 +0900 Subject: [PATCH 06/11] =?UTF-8?q?feat:=20=EC=9E=A5=EC=86=8C=20=EB=8C=80?= =?UTF-8?q?=ED=91=9C=20=EC=82=AC=EC=A7=84=20=EC=9D=B4=EB=A6=84=20=EB=B2=8C?= =?UTF-8?q?=ED=81=AC=20=EC=A1=B0=ED=9A=8C=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /places/photo-names/batch API 신규 구현 - PlacePhotoNameService에 대표 사진 이름 목록 병렬 벌크 조회 로직 추가 (taskExecutor 활용, 중복 ID 제거 및 요청 순서 유지, 부분 실패 대응) - HttpRateLimitPolicyResolver에 벌크 조회 경로를 rate limit 대상으로 추가 - 관련 컨트롤러 테스트, 서비스/통합 테스트 작성 및 docs/ai/features.md 명세 업데이트 --- docs/ai/features.md | 4 +- .../HttpRateLimitPolicyResolver.java | 1 + .../places/controller/PlaceController.java | 18 +++++ .../dto/PlacePhotoNameBatchItemResponse.java | 33 +++++++++ .../dto/PlacePhotoNameBatchRequest.java | 20 ++++++ .../dto/PlacePhotoNameBatchResponse.java | 21 ++++++ .../places/service/PlacePhotoNameService.java | 50 +++++++++++++ .../dto/PlacePhotoNameBatchItemResult.java | 17 +++++ .../HttpRateLimitPolicyResolverTest.java | 6 +- .../controller/PlaceControllerTest.java | 70 +++++++++++++++++++ .../service/PlaceDetailCachingTest.java | 49 +++++++++++++ 11 files changed, 286 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/howaboutus/backend/places/controller/dto/PlacePhotoNameBatchItemResponse.java create mode 100644 src/main/java/com/howaboutus/backend/places/controller/dto/PlacePhotoNameBatchRequest.java create mode 100644 src/main/java/com/howaboutus/backend/places/controller/dto/PlacePhotoNameBatchResponse.java create mode 100644 src/main/java/com/howaboutus/backend/places/service/dto/PlacePhotoNameBatchItemResult.java diff --git a/docs/ai/features.md b/docs/ai/features.md index c18d18c9..d7bbdad4 100644 --- a/docs/ai/features.md +++ b/docs/ai/features.md @@ -36,7 +36,7 @@ Caddy는 `caddy-ratelimit` 기반 IP 제한으로 엣지에서 넓고 거친 폭 |------|------| | `POST /auth/refresh` | `10/min/refresh_token_hash` | | Places 검색 `GET /places/search` | `10/min/user` | -| Places 상세/미리보기/사진 조회 및 벌크 조회 | `30/min/user` | +| Places 상세/미리보기/사진 이름/사진 URL 조회 및 벌크 조회 | `30/min/user` | | Routes 조회 `GET /rooms/{roomId}/schedules/{scheduleId}/items/{itemId}/route`, `POST /rooms/{roomId}/schedules/{scheduleId}/routes/batch` | `20/min/user` + `60/min/room` | | `POST /rooms/join` | `10/min/user` | | 인증된 상태 변경 HTTP API | `60/min/user` | @@ -97,7 +97,7 @@ Caddy는 `caddy-ratelimit` 기반 IP 제한으로 엣지에서 넓고 거친 폭 | `[x]` | 장소 미리보기 조회 | 북마크/일정 카드 표시용으로 장소명, 주소, 장소 유형(`primaryType`), 장소 유형 표시명(`primaryTypeDisplayName`), 좌표, 대표 사진 리소스명(`photoName`)을 응답한다. 단건은 `GET /places/{googlePlaceId}/preview`, 벌크는 `POST /places/previews/batch`로 조회한다. 벌크 조회는 최대 100개 `googlePlaceIds`를 받아 중복 ID를 제거하고 Redis bulk read 후 캐시 miss 항목만 Google Places API로 병렬 조회하며, 일부 항목의 외부 API 실패는 `status`/`errorCode`로 반환한다. 캐시 miss 시 `photos.name`을 포함한 미리보기 요청 1회로 응답하고, Redis에는 `photoName`을 제거한 본문만 24시간(1일) TTL로 저장한다. 캐시 hit 시에는 `photos.name` 전용 Google Place Details 요청으로 대표 사진 리소스명을 다시 조회해 합친다 | Redis | | `[x]` | 장소 상세 조회 | 장소명, 주소, 장소 유형 표시명(`primaryTypeDisplayName`), 평점(`rating`), 평점 수(`userRatingCount`), 전화번호, 웹사이트, 요일별 영업시간(`weekdayDescriptions`)을 포함한 전체 영업시간(`regularOpeningHours`), 리뷰 목록(`reviews`) 등을 응답한다. 캐시 miss 시 `photos.name`을 포함한 상세 요청 1회로 응답하고, Redis에는 `photoNames`를 제거한 본문만 6시간 TTL로 저장한다. 캐시 hit 시에는 `photos.name` 전용 Google Place Details 요청으로 사진 목록을 다시 조회해 합친다 | Redis | | `[x]` | 장소 사진 URL 조회 | `photoName`을 받아 Google Photo Media API를 호출, `photoUrl` 반환. 단건은 `GET /places/photos`, 벌크는 `POST /places/photos/batch`로 조회한다. 벌크 조회는 최대 100개 `photoNames`를 받아 중복 `photoName`을 제거하고 Redis bulk read 후 캐시 miss 항목만 Google Photo Media API로 병렬 조회하며, 일부 항목의 외부 API 실패는 `status`/`errorCode`로 반환한다. `photoUrl`은 짧은 수명 URL이지만 우선 Redis에 24시간(1일) TTL 캐시하며, cache key는 `photoName`과 기본 크기(400x400)를 포함한다 | Redis | -| `[x]` | 장소 사진 이름 목록 조회 | googlePlaceId를 기반으로 장소의 사진 이름(photoName) 목록을 조회한다. 결과는 maxPhotoCount 개수만큼 반환 | - | +| `[x]` | 장소 사진 이름 목록 조회 | googlePlaceId를 기반으로 장소의 사진 이름(photoName) 목록을 조회한다. 단건은 `GET /places/{googlePlaceId}/photo-names`로 전체 사진 이름 목록을 maxPhotoCount 개수만큼 반환하고, 벌크는 `POST /places/photo-names/batch`로 최대 100개 `googlePlaceIds`의 대표 `photoName` 1개를 요청 순서대로 반환한다. 벌크 조회는 중복 ID를 제거하고 Google Place Details API의 사진 이름 필드만 병렬 조회하며, 일부 항목의 외부 API 실패는 `status`/`errorCode`로 반환한다 | - | --- 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 26c6af0b..16090c74 100644 --- a/src/main/java/com/howaboutus/backend/common/ratelimit/HttpRateLimitPolicyResolver.java +++ b/src/main/java/com/howaboutus/backend/common/ratelimit/HttpRateLimitPolicyResolver.java @@ -32,6 +32,7 @@ public class HttpRateLimitPolicyResolver { private static final Pattern PLACE_READ_PATTERN = Pattern.compile("^/places/[^/]+(/preview|/photo-names)?$"); private static final List PLACE_BATCH_READ_PATHS = List.of( "/places/previews/batch", + "/places/photo-names/batch", "/places/photos/batch" ); 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 7bb5365b..27e56746 100644 --- a/src/main/java/com/howaboutus/backend/places/controller/PlaceController.java +++ b/src/main/java/com/howaboutus/backend/places/controller/PlaceController.java @@ -16,6 +16,8 @@ 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; @@ -191,6 +193,22 @@ public ResponseEntity getPhotoUrls( 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( summary = "장소 사진 이름 목록 조회", description = "googlePlaceId를 기반으로 장소의 사진 이름(photoName) 목록을 조회합니다. 이 API는 로그인 유저별 요청 속도 제한(Rate Limit)을 가집니다." 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/service/PlacePhotoNameService.java b/src/main/java/com/howaboutus/backend/places/service/PlacePhotoNameService.java index 0bbaf0f6..40b73c3d 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,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.CompletionException; +import java.util.concurrent.Executor; import org.springframework.stereotype.Service; import com.howaboutus.backend.common.config.properties.GooglePlacesProperties; +import com.howaboutus.backend.common.error.CustomException; +import com.howaboutus.backend.common.error.ErrorCode; import com.howaboutus.backend.common.integration.google.GooglePlaceDetailClient; import com.howaboutus.backend.common.integration.google.dto.GooglePlacePhoto; +import com.howaboutus.backend.places.service.dto.PlacePhotoNameBatchItemResult; import lombok.RequiredArgsConstructor; @@ -16,6 +25,7 @@ public class PlacePhotoNameService { private final GooglePlaceDetailClient googlePlaceDetailClient; private final GooglePlacesProperties googlePlacesProperties; + private final Executor taskExecutor; public List getPhotoNames(String googlePlaceId) { List photos = googlePlaceDetailClient.getPhotoNames(googlePlaceId).photos(); @@ -25,6 +35,7 @@ public List getPhotoNames(String googlePlaceId) { return photos.stream() .limit(googlePlacesProperties.maxPhotoCount()) .map(GooglePlacePhoto::name) + .filter(Objects::nonNull) .toList(); } @@ -33,4 +44,43 @@ 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)), + taskExecutor + ) + )); + + Map resolved = new LinkedHashMap<>(); + futures.forEach((googlePlaceId, future) -> resolved.put(googlePlaceId, join(googlePlaceId, future))); + + return googlePlaceIds.stream() + .map(resolved::get) + .toList(); + } + + private PlacePhotoNameBatchItemResult join(String googlePlaceId, + CompletableFuture future) { + try { + return future.join(); + } catch (CompletionException exception) { + if (exception.getCause() instanceof RuntimeException runtimeException) { + return handleBatchFailure(googlePlaceId, runtimeException); + } + throw exception; + } + } + + private PlacePhotoNameBatchItemResult handleBatchFailure(String googlePlaceId, RuntimeException exception) { + if (exception instanceof CustomException customException + && customException.getErrorCode() == ErrorCode.EXTERNAL_API_ERROR) { + return PlacePhotoNameBatchItemResult.failure(googlePlaceId, customException.getErrorCode().name()); + } + throw exception; + } } 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/test/java/com/howaboutus/backend/common/ratelimit/HttpRateLimitPolicyResolverTest.java b/src/test/java/com/howaboutus/backend/common/ratelimit/HttpRateLimitPolicyResolverTest.java index 6ad2563d..ee8ae107 100644 --- a/src/test/java/com/howaboutus/backend/common/ratelimit/HttpRateLimitPolicyResolverTest.java +++ b/src/test/java/com/howaboutus/backend/common/ratelimit/HttpRateLimitPolicyResolverTest.java @@ -82,7 +82,7 @@ void resolvesPlaceReadPolicies() { } @Test - @DisplayName("장소 미리보기와 사진 벌크 POST는 조회 제한만 적용하고 쓰기 제한은 적용하지 않는다") + @DisplayName("장소 미리보기와 사진 이름, 사진 URL 벌크 POST는 조회 제한만 적용하고 쓰기 제한은 적용하지 않는다") void resolvesPlaceBatchReadPoliciesWithoutWritePolicy() { authenticate(42L); @@ -90,6 +90,10 @@ void resolvesPlaceBatchReadPoliciesWithoutWritePolicy() { .extracting(RateLimitPlan::key) .containsExactly("rate-limit:http:places-read:user:42"); + assertThat(resolver.resolve(request("POST", "/places/photo-names/batch"))) + .extracting(RateLimitPlan::key) + .containsExactly("rate-limit:http:places-read:user:42"); + assertThat(resolver.resolve(request("POST", "/places/photos/batch"))) .extracting(RateLimitPlan::key) .containsExactly("rate-limit:http:places-read:user:42"); 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 6d2e3946..074848c0 100644 --- a/src/test/java/com/howaboutus/backend/places/controller/PlaceControllerTest.java +++ b/src/test/java/com/howaboutus/backend/places/controller/PlaceControllerTest.java @@ -33,6 +33,7 @@ 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; @@ -716,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 1eeb88a1..08364774 100644 --- a/src/test/java/com/howaboutus/backend/places/service/PlaceDetailCachingTest.java +++ b/src/test/java/com/howaboutus/backend/places/service/PlaceDetailCachingTest.java @@ -26,6 +26,7 @@ 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; @@ -38,6 +39,9 @@ class PlaceDetailCachingTest extends BaseIntegrationTest { @Autowired private PlacePreviewService placePreviewService; + @Autowired + private PlacePhotoNameService placePhotoNameService; + @MockitoBean private GooglePlaceDetailClient googlePlaceDetailClient; @@ -149,6 +153,51 @@ void getsPreviewsInBulkWithPartialFailures() { ); } + @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() { From 13367676cf30ddf1d589f288ed09dc211e5d8ab3 Mon Sep 17 00:00:00 2001 From: minbros Date: Sun, 7 Jun 2026 17:01:32 +0900 Subject: [PATCH 07/11] =?UTF-8?q?feat:=20=EB=B0=A9=20=EC=B4=88=EA=B8=B0?= =?UTF-8?q?=ED=99=94=20=EC=86=8D=EB=8F=84=20=EA=B0=9C=EC=84=A0=EC=9D=84=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20Rate=20Limit=20=EC=A0=95=EC=B1=85=20?= =?UTF-8?q?=EB=8B=A8=EA=B1=B4/=EB=B2=8C=ED=81=AC=20=EB=B6=84=EB=A6=AC=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B2=BD=EB=A1=9C=20=EB=A7=A4=ED=95=91=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-06-07-rate-limit-redesign.md | 238 ++++++++++++++++++ .../properties/RateLimitProperties.java | 5 +- .../HttpRateLimitPolicyResolver.java | 42 ++-- src/main/resources/application.yaml | 15 +- .../HttpRateLimitPolicyResolverTest.java | 54 ++-- .../service/MessageRateLimiterTest.java | 23 +- 6 files changed, 320 insertions(+), 57 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-07-rate-limit-redesign.md 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/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..dcb3df00 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", + "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 16090c74..6e443694 100644 --- a/src/main/java/com/howaboutus/backend/common/ratelimit/HttpRateLimitPolicyResolver.java +++ b/src/main/java/com/howaboutus/backend/common/ratelimit/HttpRateLimitPolicyResolver.java @@ -28,8 +28,7 @@ public class HttpRateLimitPolicyResolver { Pattern.compile("^/rooms/([^/]+)/schedules/[^/]+/items/[^/]+/route$"); private static final Pattern ROUTE_BATCH_PATTERN = Pattern.compile("^/rooms/([^/]+)/schedules/[^/]+/routes/batch$"); - private static final Pattern NO_ROUTE_PATTERN = Pattern.compile("$^"); - private static final Pattern PLACE_READ_PATTERN = Pattern.compile("^/places/[^/]+(/preview|/photo-names)?$"); + 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", @@ -83,33 +82,30 @@ private void addPlacesPolicies(String method, String path, Optional userId plans.add(plan("http:places-search", "user", String.valueOf(userId.get()), "places-search")); return; } - if (isPlaceReadRequest(method, path)) { - plans.add(plan("http:places-read", "user", String.valueOf(userId.get()), "places-read")); + if ("GET".equals(method) + && ("/places/photos".equals(path) || 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 boolean isPlaceReadRequest(String method, String path) { - return ("GET".equals(method) && ("/places/photos".equals(path) || PLACE_READ_PATTERN.matcher(path).matches())) - || ("POST".equals(method) && PLACE_BATCH_READ_PATHS.contains(path)); - } - private void addRoutePolicies(String method, String path, Optional userId, List plans) { - Matcher matcher = routeMatcher(method, path); - if (!matcher.matches()) { - return; - } - 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 Matcher routeMatcher(String method, String path) { if ("GET".equals(method)) { - return ROUTE_PATTERN.matcher(path); - } - if ("POST".equals(method)) { - return ROUTE_BATCH_PATTERN.matcher(path); + 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")); + } } - return NO_ROUTE_PATTERN.matcher(path); } private void addJoinPolicies(String method, diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 1f121022..14ab0f60 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -112,15 +112,24 @@ 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 + 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/common/ratelimit/HttpRateLimitPolicyResolverTest.java b/src/test/java/com/howaboutus/backend/common/ratelimit/HttpRateLimitPolicyResolverTest.java index ee8ae107..de9e8e5b 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,21 @@ 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("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 +66,22 @@ 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-read-single:user:42"); } @Test @@ -88,15 +91,15 @@ void resolvesPlaceBatchReadPoliciesWithoutWritePolicy() { assertThat(resolver.resolve(request("POST", "/places/previews/batch"))) .extracting(RateLimitPlan::key) - .containsExactly("rate-limit:http:places-read:user:42"); + .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:user:42"); + .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:user:42"); + .containsExactly("rate-limit:http:places-read-bulk:user:42"); } @Test @@ -107,8 +110,21 @@ void resolvesRouteBatchPoliciesWithoutWritePolicy() { assertThat(resolver.resolve(request("POST", "/rooms/room-1/schedules/10/routes/batch"))) .extracting(RateLimitPlan::key) .containsExactly( - "rate-limit:http:route:user:42", - "rate-limit:http:route:room:room-1" + "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" ); } 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..43c18859 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,21 @@ @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("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; From f2c9a9be792389c80cd7c2fac21ad60f9500ae69 Mon Sep 17 00:00:00 2001 From: minbros Date: Sun, 7 Jun 2026 17:08:53 +0900 Subject: [PATCH 08/11] =?UTF-8?q?feat:=20=EC=82=AC=EC=A7=84=20URI=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API=EC=97=90=20=EB=8C=80=ED=95=9C=20Rate?= =?UTF-8?q?=20Limit=20=EB=B3=84=EB=8F=84=20=EB=B6=84=EB=A6=AC=20=EB=B0=8F?= =?UTF-8?q?=20=EC=9E=84=EA=B3=84=EC=B9=98=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-06-07-rate-limit-photo-split.md | 143 ++++++++++++++++++ .../properties/RateLimitProperties.java | 4 +- .../HttpRateLimitPolicyResolver.java | 13 +- src/main/resources/application.yaml | 6 + .../HttpRateLimitPolicyResolverTest.java | 6 +- .../service/MessageRateLimiterTest.java | 2 + 6 files changed, 168 insertions(+), 6 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-07-rate-limit-photo-split.md 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/src/main/java/com/howaboutus/backend/common/config/properties/RateLimitProperties.java b/src/main/java/com/howaboutus/backend/common/config/properties/RateLimitProperties.java index dcb3df00..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 @@ -19,8 +19,8 @@ public record RateLimitProperties( ) { private static final List REQUIRED_POLICIES = List.of( "auth-refresh-session", "places-search", "places-read-single", "places-read-bulk", - "route-single-user", "route-single-room", "route-batch-user", "route-batch-room", - "join-user", "write-user", "chat" + "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 6e443694..f5f804ed 100644 --- a/src/main/java/com/howaboutus/backend/common/ratelimit/HttpRateLimitPolicyResolver.java +++ b/src/main/java/com/howaboutus/backend/common/ratelimit/HttpRateLimitPolicyResolver.java @@ -82,8 +82,17 @@ private void addPlacesPolicies(String method, String path, Optional userId plans.add(plan("http:places-search", "user", String.valueOf(userId.get()), "places-search")); return; } - if ("GET".equals(method) - && ("/places/photos".equals(path) || PLACE_READ_SINGLE_PATTERN.matcher(path).matches())) { + 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")); diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 14ab0f60..765a5c5d 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -118,6 +118,12 @@ app: 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 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 de9e8e5b..826fd372 100644 --- a/src/test/java/com/howaboutus/backend/common/ratelimit/HttpRateLimitPolicyResolverTest.java +++ b/src/test/java/com/howaboutus/backend/common/ratelimit/HttpRateLimitPolicyResolverTest.java @@ -23,6 +23,8 @@ class HttpRateLimitPolicyResolverTest { 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)), @@ -81,7 +83,7 @@ void resolvesPlaceReadPolicies() { // 사진 파일 GET assertThat(resolver.resolve(request("GET", "/places/photos"))) .extracting(RateLimitPlan::key) - .containsExactly("rate-limit:http:places-read-single:user:42"); + .containsExactly("rate-limit:http:places-photo-single:user:42"); } @Test @@ -99,7 +101,7 @@ void resolvesPlaceBatchReadPoliciesWithoutWritePolicy() { assertThat(resolver.resolve(request("POST", "/places/photos/batch"))) .extracting(RateLimitPlan::key) - .containsExactly("rate-limit:http:places-read-bulk:user:42"); + .containsExactly("rate-limit:http:places-photo-bulk:user:42"); } @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 43c18859..54862f7c 100644 --- a/src/test/java/com/howaboutus/backend/messages/service/MessageRateLimiterTest.java +++ b/src/test/java/com/howaboutus/backend/messages/service/MessageRateLimiterTest.java @@ -30,6 +30,8 @@ class MessageRateLimiterTest { 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)), From 0ea07401d3530eee57949dd580ac16be82fd808d Mon Sep 17 00:00:00 2001 From: minbros Date: Sun, 7 Jun 2026 22:59:45 +0900 Subject: [PATCH 09/11] =?UTF-8?q?refactor:=20=EB=B9=84=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=20=EC=8B=A4=ED=96=89=EA=B8=B0=20=EB=B6=84=EB=A6=AC=20=EB=B0=8F?= =?UTF-8?q?=20=EA=B0=80=EC=83=81=20=EC=8A=A4=EB=A0=88=EB=93=9C=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ai/listener/AiSummaryTriggerListener.java | 3 +- .../ai/service/AiRequestQueueWorker.java | 3 +- .../backend/common/config/AsyncConfig.java | 41 +++++++++++++---- .../properties/AsyncExecutorProperties.java | 37 +++++++++++++++ .../places/service/PlacePhotoNameService.java | 14 ++++-- .../places/service/PlacePhotoService.java | 14 ++++-- .../places/service/PlacePreviewService.java | 18 ++++++-- src/main/resources/application.yaml | 6 +++ .../common/config/AsyncConfigTest.java | 46 +++++++++++++++++++ 9 files changed, 161 insertions(+), 21 deletions(-) create mode 100644 src/main/java/com/howaboutus/backend/common/config/properties/AsyncExecutorProperties.java create mode 100644 src/test/java/com/howaboutus/backend/common/config/AsyncConfigTest.java 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/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/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/places/service/PlacePhotoNameService.java b/src/main/java/com/howaboutus/backend/places/service/PlacePhotoNameService.java index 40b73c3d..82cac3a9 100644 --- a/src/main/java/com/howaboutus/backend/places/service/PlacePhotoNameService.java +++ b/src/main/java/com/howaboutus/backend/places/service/PlacePhotoNameService.java @@ -8,8 +8,10 @@ import java.util.concurrent.CompletionException; import java.util.concurrent.Executor; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Service; +import com.howaboutus.backend.common.config.AsyncConfig; import com.howaboutus.backend.common.config.properties.GooglePlacesProperties; import com.howaboutus.backend.common.error.CustomException; import com.howaboutus.backend.common.error.ErrorCode; @@ -17,16 +19,22 @@ import com.howaboutus.backend.common.integration.google.dto.GooglePlacePhoto; import com.howaboutus.backend.places.service.dto.PlacePhotoNameBatchItemResult; -import lombok.RequiredArgsConstructor; - @Service -@RequiredArgsConstructor public class PlacePhotoNameService { private final GooglePlaceDetailClient googlePlaceDetailClient; private final GooglePlacesProperties googlePlacesProperties; private final Executor taskExecutor; + public PlacePhotoNameService( + GooglePlaceDetailClient googlePlaceDetailClient, + GooglePlacesProperties googlePlacesProperties, + @Qualifier(AsyncConfig.GOOGLE_API_EXECUTOR) Executor taskExecutor) { + this.googlePlaceDetailClient = googlePlaceDetailClient; + this.googlePlacesProperties = googlePlacesProperties; + this.taskExecutor = taskExecutor; + } + public List getPhotoNames(String googlePlaceId) { List photos = googlePlaceDetailClient.getPhotoNames(googlePlaceId).photos(); if (photos == null) { 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 5f1b6b3c..1230e034 100644 --- a/src/main/java/com/howaboutus/backend/places/service/PlacePhotoService.java +++ b/src/main/java/com/howaboutus/backend/places/service/PlacePhotoService.java @@ -7,19 +7,18 @@ import java.util.concurrent.CompletionException; import java.util.concurrent.Executor; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Service; import com.howaboutus.backend.common.cache.RedisBulkCacheAccessor; +import com.howaboutus.backend.common.config.AsyncConfig; import com.howaboutus.backend.common.config.CachePolicy; import com.howaboutus.backend.common.error.CustomException; import com.howaboutus.backend.common.error.ErrorCode; import com.howaboutus.backend.common.integration.google.GooglePlacePhotoClient; import com.howaboutus.backend.places.service.dto.PlacePhotoBatchItemResult; -import lombok.RequiredArgsConstructor; - @Service -@RequiredArgsConstructor public class PlacePhotoService { private static final String DEFAULT_PHOTO_SIZE_CACHE_SUFFIX = ":w400:h400"; @@ -28,6 +27,15 @@ public class PlacePhotoService { private final RedisBulkCacheAccessor redisBulkCacheAccessor; private final Executor taskExecutor; + public PlacePhotoService( + GooglePlacePhotoClient googlePlacePhotoClient, + RedisBulkCacheAccessor redisBulkCacheAccessor, + @Qualifier(AsyncConfig.GOOGLE_API_EXECUTOR) Executor taskExecutor) { + this.googlePlacePhotoClient = googlePlacePhotoClient; + this.redisBulkCacheAccessor = redisBulkCacheAccessor; + this.taskExecutor = taskExecutor; + } + public String getPhotoUrl(String photoName) { String cacheKey = cacheKey(photoName); Map cachedByCacheKey = redisBulkCacheAccessor.multiGet( 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 001e5044..0c184a3d 100644 --- a/src/main/java/com/howaboutus/backend/places/service/PlacePreviewService.java +++ b/src/main/java/com/howaboutus/backend/places/service/PlacePreviewService.java @@ -8,11 +8,13 @@ import java.util.concurrent.CompletionException; import java.util.concurrent.Executor; +import org.springframework.beans.factory.annotation.Qualifier; 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.AsyncConfig; import com.howaboutus.backend.common.config.CachePolicy; import com.howaboutus.backend.common.error.CustomException; import com.howaboutus.backend.common.error.ErrorCode; @@ -20,10 +22,7 @@ import com.howaboutus.backend.places.service.dto.PlacePreviewBatchItemResult; import com.howaboutus.backend.places.service.dto.PlacePreviewResult; -import lombok.RequiredArgsConstructor; - @Service -@RequiredArgsConstructor public class PlacePreviewService { private final GooglePlaceDetailClient googlePlaceDetailClient; @@ -32,6 +31,19 @@ public class PlacePreviewService { private final RedisBulkCacheAccessor redisBulkCacheAccessor; private final Executor taskExecutor; + public PlacePreviewService( + GooglePlaceDetailClient googlePlaceDetailClient, + PlacePhotoNameService placePhotoNameService, + CacheManager cacheManager, + RedisBulkCacheAccessor redisBulkCacheAccessor, + @Qualifier(AsyncConfig.GOOGLE_API_EXECUTOR) Executor taskExecutor) { + this.googlePlaceDetailClient = googlePlaceDetailClient; + this.placePhotoNameService = placePhotoNameService; + this.cacheManager = cacheManager; + this.redisBulkCacheAccessor = redisBulkCacheAccessor; + this.taskExecutor = taskExecutor; + } + public PlacePreviewResult getPreview(String googlePlaceId) { Cache cache = Objects.requireNonNull(cacheManager.getCache(CachePolicy.Keys.PLACE_PREVIEW)); PlacePreviewResult cached = cache.get(googlePlaceId, PlacePreviewResult.class); diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 765a5c5d..f5bba2fb 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -99,6 +99,12 @@ management: include: health,prometheus,caches app: + executor: + ai: + concurrency-limit: 4 + google: + concurrency-limit: 8 + rate-limit: redis: host: ${spring.data.redis.host} 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(); + } +} From 3cad360eed72b5daecbed0f6c94460f80b3ff229 Mon Sep 17 00:00:00 2001 From: minbros Date: Sun, 7 Jun 2026 23:59:51 +0900 Subject: [PATCH 10/11] =?UTF-8?q?refactor:=20PR=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EC=BD=94=EB=A9=98=ED=8A=B8=20=EB=B0=98=EC=98=81=20=EB=B0=8F=20?= =?UTF-8?q?Google=20API=20Executor=20Lombok=20=EC=A3=BC=EC=9E=85=20?= =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RedisBulkCacheAccessor Jackson Serializer 캐싱 최적화 - DTO status/errorCode 스펙 정비 및 결합도 완화 - PlacePhotoService 등 Executor Lombok RequiredArgsConstructor 주입 적용 - Rate Limit 정책 예외 조건 보강 및 테스트 코드 최신화 --- docs/ai/erd.md | 2 +- docs/ai/features.md | 5 +- .../plans/2026-06-07-pr-review-refactoring.md | 448 ++++++++++++++++++ .../common/cache/RedisBulkCacheAccessor.java | 32 +- .../HttpRateLimitPolicyResolver.java | 5 +- .../places/controller/PlaceController.java | 2 +- .../dto/PlacePhotoBatchItemResponse.java | 2 +- .../controller/dto/PlacePhotoResponse.java | 3 + .../dto/PlacePreviewBatchItemResponse.java | 18 +- .../places/service/PlacePhotoNameService.java | 17 +- .../places/service/PlacePhotoService.java | 18 +- .../places/service/PlacePreviewService.java | 35 +- .../dto/PlacePhotoBatchItemResult.java | 2 +- .../dto/PlacePreviewBatchItemResult.java | 2 +- .../dto/BatchRouteItemResponse.java | 3 +- .../service/dto/RouteBatchItemResult.java | 2 +- .../controller/PlaceControllerTest.java | 4 +- .../service/PlaceDetailCachingTest.java | 23 +- .../places/service/PlacePhotoCachingTest.java | 2 +- .../places/service/PlacePhotoServiceTest.java | 28 +- .../ScheduleRouteControllerTest.java | 2 +- .../service/ScheduleItemServiceTest.java | 6 +- 22 files changed, 568 insertions(+), 93 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-07-pr-review-refactoring.md diff --git a/docs/ai/erd.md b/docs/ai/erd.md index 3942b9af..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` 요청 파라미터로, 벌크 경로 조회는 요청 body의 항목별 `travelMode`로 이동 수단을 전달합니다. 마지막 장소는 다음 장소가 없으므로 단건 조회에서는 이동 정보를 반환하지 않습니다(204). Google Routes가 경로를 찾지 못한 구간도 단건 조회에서는 이동 정보를 반환하지 않습니다(204). 벌크 조회는 요청 자체가 유효하면 200을 반환하고, 마지막 항목·경로 없음·일시 실패 같은 구간별 결과는 각 route의 `status`/`errorCode`로 반환합니다. +> **이동 정보 흐름:** `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`로 반환합니다. --- diff --git a/docs/ai/features.md b/docs/ai/features.md index a084f1f5..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` | 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/src/main/java/com/howaboutus/backend/common/cache/RedisBulkCacheAccessor.java b/src/main/java/com/howaboutus/backend/common/cache/RedisBulkCacheAccessor.java index d5a3098b..160278ef 100644 --- a/src/main/java/com/howaboutus/backend/common/cache/RedisBulkCacheAccessor.java +++ b/src/main/java/com/howaboutus/backend/common/cache/RedisBulkCacheAccessor.java @@ -14,16 +14,27 @@ import org.springframework.data.redis.serializer.GenericJacksonJsonRedisSerializer; import org.springframework.stereotype.Component; -import lombok.RequiredArgsConstructor; import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.jsontype.BasicPolymorphicTypeValidator; @Component -@RequiredArgsConstructor public class RedisBulkCacheAccessor { private final RedisConnectionFactory connectionFactory; - private final ObjectMapper objectMapper; + 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(); @@ -38,7 +49,6 @@ public Map multiGet(String cacheName, Collection keys, Cl try (RedisConnection connection = connectionFactory.getConnection()) { List values = connection.stringCommands().mGet(redisKeys); Map result = new LinkedHashMap<>(); - GenericJacksonJsonRedisSerializer serializer = serializer(); for (int index = 0; index < distinctKeys.size(); index++) { byte[] value = values.get(index); if (value != null) { @@ -53,7 +63,7 @@ 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), + serializer.serialize(value), Expiration.from(ttl), RedisStringCommands.SetOption.upsert() ); @@ -63,16 +73,4 @@ public void put(String cacheName, String key, Object value, Duration ttl) { private byte[] redisKey(String cacheName, String key) { return (cacheName + "::" + key).getBytes(StandardCharsets.UTF_8); } - - private GenericJacksonJsonRedisSerializer serializer() { - return GenericJacksonJsonRedisSerializer.builder(objectMapper::rebuild) - .enableDefaultTyping( - BasicPolymorphicTypeValidator.builder() - .allowIfSubType("com.howaboutus.backend") - .allowIfBaseType(Map.class) - .allowIfBaseType(Collection.class) - .build() - ) - .build(); - } } 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 f5f804ed..f5ba19e1 100644 --- a/src/main/java/com/howaboutus/backend/common/ratelimit/HttpRateLimitPolicyResolver.java +++ b/src/main/java/com/howaboutus/backend/common/ratelimit/HttpRateLimitPolicyResolver.java @@ -31,8 +31,8 @@ public class HttpRateLimitPolicyResolver { 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", - "/places/photos/batch" + "/places/photo-names/batch" + // Note: /places/photos/batch is handled separately in addPlacesPolicies ); private final RateLimitProperties rateLimitProperties; @@ -129,6 +129,7 @@ private void addJoinPolicies(String method, private void addWritePolicy(String method, String path, Optional userId, List plans) { if (userId.isEmpty() || path.startsWith("/auth/") || PLACE_BATCH_READ_PATHS.contains(path) + || "/places/photos/batch".equals(path) || ROUTE_BATCH_PATTERN.matcher(path).matches()) { return; } 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 27e56746..5a54aee0 100644 --- a/src/main/java/com/howaboutus/backend/places/controller/PlaceController.java +++ b/src/main/java/com/howaboutus/backend/places/controller/PlaceController.java @@ -165,7 +165,7 @@ public ResponseEntity getPhotoUrl( @Pattern(regexp = "^places/[^/]+/photos/[^/]+$", message = "유효하지 않은 photoName 형식입니다") String photoName) { if (photoEnabled) { - return ResponseEntity.ok(new PlacePhotoResponse(placePhotoService.getPhotoUrl(photoName))); + return ResponseEntity.ok(PlacePhotoResponse.from(placePhotoService.getPhotoUrl(photoName))); } return ResponseEntity.noContent().build(); } 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 index d2889736..ad6220fd 100644 --- a/src/main/java/com/howaboutus/backend/places/controller/dto/PlacePhotoBatchItemResponse.java +++ b/src/main/java/com/howaboutus/backend/places/controller/dto/PlacePhotoBatchItemResponse.java @@ -10,7 +10,7 @@ public record PlacePhotoBatchItemResponse( @Schema(description = "요청한 Google 장소 사진 리소스 이름", example = "places/ChIJ123/photos/a") String photoName, - @Schema(description = "항목별 처리 상태", example = "OK", allowableValues = {"OK", "EXTERNAL_API_ERROR"}) + @Schema(description = "항목별 처리 상태", example = "OK", allowableValues = {"OK", "FAILED"}) String status, @Schema(description = "사진 URL. status가 OK일 때만 포함됩니다.", example = "https://lh3.googleusercontent.com/a.jpg", 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 index 13452735..955f84a3 100644 --- a/src/main/java/com/howaboutus/backend/places/controller/dto/PlacePreviewBatchItemResponse.java +++ b/src/main/java/com/howaboutus/backend/places/controller/dto/PlacePreviewBatchItemResponse.java @@ -2,7 +2,6 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.howaboutus.backend.places.service.dto.PlacePreviewBatchItemResult; -import com.howaboutus.backend.places.service.dto.PlacePreviewResult; import io.swagger.v3.oas.annotations.media.Schema; @@ -11,7 +10,7 @@ public record PlacePreviewBatchItemResponse( @Schema(description = "요청한 Google Place ID", example = "ChIJ123") String googlePlaceId, - @Schema(description = "항목별 처리 상태", example = "OK", allowableValues = {"OK", "EXTERNAL_API_ERROR"}) + @Schema(description = "항목별 처리 상태", example = "OK", allowableValues = {"OK", "FAILED"}) String status, @Schema(description = "장소명. status가 OK일 때만 포함됩니다.", example = "Cafe Layered", nullable = true) @@ -27,7 +26,7 @@ public record PlacePreviewBatchItemResponse( String primaryTypeDisplayName, @Schema(description = "장소 좌표. status가 OK일 때만 포함됩니다.", nullable = true) - PlacePreviewResult.Location location, + Location location, @Schema(description = "대표 사진 리소스명. 없거나 조회 실패 시 null입니다.", example = "places/ChIJ123/photos/a", nullable = true) @@ -37,7 +36,18 @@ public record PlacePreviewBatchItemResponse( 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(), @@ -45,7 +55,7 @@ public static PlacePreviewBatchItemResponse from(PlacePreviewBatchItemResult res result.formattedAddress(), result.primaryType(), result.primaryTypeDisplayName(), - result.location(), + location, result.photoName(), result.errorCode() ); 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 82cac3a9..3b7cdaa2 100644 --- a/src/main/java/com/howaboutus/backend/places/service/PlacePhotoNameService.java +++ b/src/main/java/com/howaboutus/backend/places/service/PlacePhotoNameService.java @@ -8,7 +8,6 @@ import java.util.concurrent.CompletionException; import java.util.concurrent.Executor; -import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Service; import com.howaboutus.backend.common.config.AsyncConfig; @@ -19,21 +18,15 @@ import com.howaboutus.backend.common.integration.google.dto.GooglePlacePhoto; import com.howaboutus.backend.places.service.dto.PlacePhotoNameBatchItemResult; +import lombok.RequiredArgsConstructor; + @Service +@RequiredArgsConstructor public class PlacePhotoNameService { private final GooglePlaceDetailClient googlePlaceDetailClient; private final GooglePlacesProperties googlePlacesProperties; - private final Executor taskExecutor; - - public PlacePhotoNameService( - GooglePlaceDetailClient googlePlaceDetailClient, - GooglePlacesProperties googlePlacesProperties, - @Qualifier(AsyncConfig.GOOGLE_API_EXECUTOR) Executor taskExecutor) { - this.googlePlaceDetailClient = googlePlaceDetailClient; - this.googlePlacesProperties = googlePlacesProperties; - this.taskExecutor = taskExecutor; - } + private final Executor googleApiExecutor; public List getPhotoNames(String googlePlaceId) { List photos = googlePlaceDetailClient.getPhotoNames(googlePlaceId).photos(); @@ -60,7 +53,7 @@ public List getFirstPhotoNames(List googl googlePlaceId, CompletableFuture.supplyAsync( () -> PlacePhotoNameBatchItemResult.ok(googlePlaceId, getFirstPhotoName(googlePlaceId)), - taskExecutor + googleApiExecutor ) )); 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 1230e034..e17b24cc 100644 --- a/src/main/java/com/howaboutus/backend/places/service/PlacePhotoService.java +++ b/src/main/java/com/howaboutus/backend/places/service/PlacePhotoService.java @@ -7,34 +7,26 @@ import java.util.concurrent.CompletionException; import java.util.concurrent.Executor; -import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Service; import com.howaboutus.backend.common.cache.RedisBulkCacheAccessor; -import com.howaboutus.backend.common.config.AsyncConfig; import com.howaboutus.backend.common.config.CachePolicy; import com.howaboutus.backend.common.error.CustomException; import com.howaboutus.backend.common.error.ErrorCode; import com.howaboutus.backend.common.integration.google.GooglePlacePhotoClient; import com.howaboutus.backend.places.service.dto.PlacePhotoBatchItemResult; +import lombok.RequiredArgsConstructor; + @Service +@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 taskExecutor; - - public PlacePhotoService( - GooglePlacePhotoClient googlePlacePhotoClient, - RedisBulkCacheAccessor redisBulkCacheAccessor, - @Qualifier(AsyncConfig.GOOGLE_API_EXECUTOR) Executor taskExecutor) { - this.googlePlacePhotoClient = googlePlacePhotoClient; - this.redisBulkCacheAccessor = redisBulkCacheAccessor; - this.taskExecutor = taskExecutor; - } + private final Executor googleApiExecutor; public String getPhotoUrl(String photoName) { String cacheKey = cacheKey(photoName); @@ -82,7 +74,7 @@ private Map fetchMissingPhotoUrls(List googlePlacePhotoClient.getPhotoUri(photoName), - taskExecutor + googleApiExecutor ) )); 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 0c184a3d..3851668b 100644 --- a/src/main/java/com/howaboutus/backend/places/service/PlacePreviewService.java +++ b/src/main/java/com/howaboutus/backend/places/service/PlacePreviewService.java @@ -8,13 +8,11 @@ import java.util.concurrent.CompletionException; import java.util.concurrent.Executor; -import org.springframework.beans.factory.annotation.Qualifier; 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.AsyncConfig; import com.howaboutus.backend.common.config.CachePolicy; import com.howaboutus.backend.common.error.CustomException; import com.howaboutus.backend.common.error.ErrorCode; @@ -22,27 +20,17 @@ import com.howaboutus.backend.places.service.dto.PlacePreviewBatchItemResult; import com.howaboutus.backend.places.service.dto.PlacePreviewResult; +import lombok.RequiredArgsConstructor; + @Service +@RequiredArgsConstructor public class PlacePreviewService { private final GooglePlaceDetailClient googlePlaceDetailClient; private final PlacePhotoNameService placePhotoNameService; private final CacheManager cacheManager; private final RedisBulkCacheAccessor redisBulkCacheAccessor; - private final Executor taskExecutor; - - public PlacePreviewService( - GooglePlaceDetailClient googlePlaceDetailClient, - PlacePhotoNameService placePhotoNameService, - CacheManager cacheManager, - RedisBulkCacheAccessor redisBulkCacheAccessor, - @Qualifier(AsyncConfig.GOOGLE_API_EXECUTOR) Executor taskExecutor) { - this.googlePlaceDetailClient = googlePlaceDetailClient; - this.placePhotoNameService = placePhotoNameService; - this.cacheManager = cacheManager; - this.redisBulkCacheAccessor = redisBulkCacheAccessor; - this.taskExecutor = taskExecutor; - } + private final Executor googleApiExecutor; public PlacePreviewResult getPreview(String googlePlaceId) { Cache cache = Objects.requireNonNull(cacheManager.getCache(CachePolicy.Keys.PLACE_PREVIEW)); @@ -82,10 +70,15 @@ private Map fetchCachedWithFreshPhotoNames( cached.forEach((googlePlaceId, preview) -> futures.put( googlePlaceId, CompletableFuture.supplyAsync( - () -> PlacePreviewBatchItemResult.ok( - preview.withPhotoName(placePhotoNameService.getFirstPhotoName(googlePlaceId)) - ), - taskExecutor + () -> { + try { + String photoName = placePhotoNameService.getFirstPhotoName(googlePlaceId); + return PlacePreviewBatchItemResult.ok(preview.withPhotoName(photoName)); + } catch (RuntimeException exception) { + return PlacePreviewBatchItemResult.failure(googlePlaceId, "EXTERNAL_API_ERROR"); + } + }, + googleApiExecutor ) )); return joinPreviewFutures(futures); @@ -104,7 +97,7 @@ private Map fetchMissingPreviews(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() { 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 27440b30..aecd985d 100644 --- a/src/test/java/com/howaboutus/backend/places/service/PlacePhotoCachingTest.java +++ b/src/test/java/com/howaboutus/backend/places/service/PlacePhotoCachingTest.java @@ -111,7 +111,7 @@ void getsPhotoUrlsInBulkWithPartialFailures() { PlacePhotoBatchItemResult::photoUrl, PlacePhotoBatchItemResult::errorCode) .containsExactly( tuple("places/ChIJ123/photos/a", "OK", "https://lh3.googleusercontent.com/a.jpg", null), - tuple("places/ChIJ456/photos/b", "EXTERNAL_API_ERROR", null, "EXTERNAL_API_ERROR") + tuple("places/ChIJ456/photos/b", "FAILED", null, "EXTERNAL_API_ERROR") ); } 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 c20ab9c0..4082cb9a 100644 --- a/src/test/java/com/howaboutus/backend/places/service/PlacePhotoServiceTest.java +++ b/src/test/java/com/howaboutus/backend/places/service/PlacePhotoServiceTest.java @@ -6,22 +6,36 @@ 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 RedisBulkCacheAccessor redisBulkCacheAccessor = mock(RedisBulkCacheAccessor.class); + @Mock + private GooglePlacePhotoClient googlePlacePhotoClient; + + @Mock + private RedisBulkCacheAccessor redisBulkCacheAccessor; + + @InjectMocks + private PlacePhotoService placePhotoService; + private final Executor taskExecutor = Runnable::run; - private final PlacePhotoService placePhotoService = new PlacePhotoService( - googlePlacePhotoClient, - redisBulkCacheAccessor, - taskExecutor - ); + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(placePhotoService, "googleApiExecutor", taskExecutor); + } @Test @DisplayName("photoName을 클라이언트에 위임해 photoUrl을 반환한다") diff --git a/src/test/java/com/howaboutus/backend/schedules/controller/ScheduleRouteControllerTest.java b/src/test/java/com/howaboutus/backend/schedules/controller/ScheduleRouteControllerTest.java index 0ee2c09e..9002bddf 100644 --- a/src/test/java/com/howaboutus/backend/schedules/controller/ScheduleRouteControllerTest.java +++ b/src/test/java/com/howaboutus/backend/schedules/controller/ScheduleRouteControllerTest.java @@ -86,7 +86,7 @@ void returnsBatchRoutes() throws Exception { .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("NO_ROUTE")) + .andExpect(jsonPath("$.routes[1].status").value("FAILED")) .andExpect(jsonPath("$.routes[1].errorCode").value("NO_ROUTE")); @SuppressWarnings("unchecked") 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 8f9b0185..0e2ebbce 100644 --- a/src/test/java/com/howaboutus/backend/schedules/service/ScheduleItemServiceTest.java +++ b/src/test/java/com/howaboutus/backend/schedules/service/ScheduleItemServiceTest.java @@ -927,7 +927,7 @@ void getRoutesForItemsUsesTravelModePerItem() { RouteBatchItemResult::durationSeconds, RouteBatchItemResult::errorCode) .containsExactly( tuple(10L, 10L, 11L, "WALKING", "OK", 500, 300, null), - tuple(11L, 11L, 12L, "TRANSIT", "NO_ROUTE", null, null, "NO_ROUTE") + tuple(11L, 11L, 12L, "TRANSIT", "FAILED", null, null, "NO_ROUTE") ); } @@ -956,8 +956,8 @@ void getRoutesForItemsReturnsPartialStatuses() { assertThat(results) .extracting(RouteBatchItemResult::itemId, RouteBatchItemResult::status, RouteBatchItemResult::errorCode) .containsExactly( - tuple(10L, "LAST_ITEM", "LAST_ITEM"), - tuple(999L, "SCHEDULE_ITEM_NOT_FOUND", "SCHEDULE_ITEM_NOT_FOUND") + tuple(10L, "FAILED", "LAST_ITEM"), + tuple(999L, "FAILED", "SCHEDULE_ITEM_NOT_FOUND") ); verifyNoInteractions(routeService); } From 026ffba32aa370367b68d5d68c4df20955dcd100 Mon Sep 17 00:00:00 2001 From: minbros Date: Mon, 8 Jun 2026 00:30:05 +0900 Subject: [PATCH 11/11] =?UTF-8?q?refactor:=20=EB=B9=84=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=20=EB=B0=B0=EC=B9=98=20=EC=A1=B0=ED=9A=8C=20=EC=98=88=EC=99=B8?= =?UTF-8?q?=20=EC=B2=98=EB=A6=AC=20=EA=B3=B5=ED=86=B5=ED=99=94=20=EB=B0=8F?= =?UTF-8?q?=20AsyncHelper=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 비동기 CompletableFuture join 시 외부 API 에러(EXTERNAL_API_ERROR) 복구 로직을 AsyncHelper로 공통화 - PlacePreviewService, PlacePhotoNameService, PlacePhotoService의 중복 예외 처리 및 join 헬퍼 메서드 제거 - Lombok 어노테이션(@NoArgsConstructor)을 활용한 AsyncHelper 인스턴스화 방지 처리 --- .../backend/common/utils/AsyncHelper.java | 47 +++++++++++++++++++ .../places/service/PlacePhotoNameService.java | 36 +++++--------- .../places/service/PlacePhotoService.java | 31 ++++-------- .../places/service/PlacePreviewService.java | 46 ++++++------------ 4 files changed, 80 insertions(+), 80 deletions(-) create mode 100644 src/main/java/com/howaboutus/backend/common/utils/AsyncHelper.java 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/service/PlacePhotoNameService.java b/src/main/java/com/howaboutus/backend/places/service/PlacePhotoNameService.java index 3b7cdaa2..c03aaf74 100644 --- a/src/main/java/com/howaboutus/backend/places/service/PlacePhotoNameService.java +++ b/src/main/java/com/howaboutus/backend/places/service/PlacePhotoNameService.java @@ -5,17 +5,14 @@ import java.util.Map; import java.util.Objects; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionException; import java.util.concurrent.Executor; import org.springframework.stereotype.Service; -import com.howaboutus.backend.common.config.AsyncConfig; import com.howaboutus.backend.common.config.properties.GooglePlacesProperties; -import com.howaboutus.backend.common.error.CustomException; -import com.howaboutus.backend.common.error.ErrorCode; 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; @@ -58,30 +55,19 @@ public List getFirstPhotoNames(List googl )); Map resolved = new LinkedHashMap<>(); - futures.forEach((googlePlaceId, future) -> resolved.put(googlePlaceId, join(googlePlaceId, future))); + futures.forEach((googlePlaceId, future) -> resolved.put( + googlePlaceId, + AsyncHelper.joinOrHandleExternalApiError( + future, + customException -> PlacePhotoNameBatchItemResult.failure( + googlePlaceId, + customException.getErrorCode().name() + ) + ) + )); return googlePlaceIds.stream() .map(resolved::get) .toList(); } - - private PlacePhotoNameBatchItemResult join(String googlePlaceId, - CompletableFuture future) { - try { - return future.join(); - } catch (CompletionException exception) { - if (exception.getCause() instanceof RuntimeException runtimeException) { - return handleBatchFailure(googlePlaceId, runtimeException); - } - throw exception; - } - } - - private PlacePhotoNameBatchItemResult handleBatchFailure(String googlePlaceId, RuntimeException exception) { - if (exception instanceof CustomException customException - && customException.getErrorCode() == ErrorCode.EXTERNAL_API_ERROR) { - return PlacePhotoNameBatchItemResult.failure(googlePlaceId, customException.getErrorCode().name()); - } - throw exception; - } } 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 e17b24cc..d696aa3f 100644 --- a/src/main/java/com/howaboutus/backend/places/service/PlacePhotoService.java +++ b/src/main/java/com/howaboutus/backend/places/service/PlacePhotoService.java @@ -4,16 +4,14 @@ import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionException; 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.error.CustomException; -import com.howaboutus.backend.common.error.ErrorCode; 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; @@ -80,7 +78,13 @@ private Map fetchMissingPhotoUrls(List result = new LinkedHashMap<>(); futures.forEach((photoName, future) -> { - PlacePhotoBatchItemResult itemResult = join(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()); } @@ -105,25 +109,6 @@ private String cacheKey(String photoName) { return photoName + DEFAULT_PHOTO_SIZE_CACHE_SUFFIX; } - private PlacePhotoBatchItemResult join(String photoName, CompletableFuture future) { - try { - return PlacePhotoBatchItemResult.ok(photoName, future.join()); - } catch (CompletionException exception) { - if (exception.getCause() instanceof RuntimeException runtimeException) { - return handleBatchFailure(photoName, runtimeException); - } - throw exception; - } - } - - private PlacePhotoBatchItemResult handleBatchFailure(String photoName, RuntimeException exception) { - if (exception instanceof CustomException customException - && customException.getErrorCode() == ErrorCode.EXTERNAL_API_ERROR) { - return PlacePhotoBatchItemResult.failure(photoName, customException.getErrorCode().name()); - } - throw exception; - } - private void cachePhotoUrl(String photoName, String photoUrl) { redisBulkCacheAccessor.put( CachePolicy.Keys.PLACE_PHOTO_URI, 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 3851668b..e0d215c1 100644 --- a/src/main/java/com/howaboutus/backend/places/service/PlacePreviewService.java +++ b/src/main/java/com/howaboutus/backend/places/service/PlacePreviewService.java @@ -5,7 +5,6 @@ import java.util.Map; import java.util.Objects; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionException; import java.util.concurrent.Executor; import org.springframework.cache.Cache; @@ -14,9 +13,8 @@ import com.howaboutus.backend.common.cache.RedisBulkCacheAccessor; import com.howaboutus.backend.common.config.CachePolicy; -import com.howaboutus.backend.common.error.CustomException; -import com.howaboutus.backend.common.error.ErrorCode; 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; @@ -70,14 +68,9 @@ private Map fetchCachedWithFreshPhotoNames( cached.forEach((googlePlaceId, preview) -> futures.put( googlePlaceId, CompletableFuture.supplyAsync( - () -> { - try { - String photoName = placePhotoNameService.getFirstPhotoName(googlePlaceId); - return PlacePreviewBatchItemResult.ok(preview.withPhotoName(photoName)); - } catch (RuntimeException exception) { - return PlacePreviewBatchItemResult.failure(googlePlaceId, "EXTERNAL_API_ERROR"); - } - }, + () -> PlacePreviewBatchItemResult.ok( + preview.withPhotoName(placePhotoNameService.getFirstPhotoName(googlePlaceId)) + ), googleApiExecutor ) )); @@ -105,27 +98,16 @@ private Map fetchMissingPreviews(List joinPreviewFutures( Map> futures) { Map result = new LinkedHashMap<>(); - futures.forEach((googlePlaceId, future) -> result.put(googlePlaceId, join(googlePlaceId, future))); + futures.forEach((googlePlaceId, future) -> result.put( + googlePlaceId, + AsyncHelper.joinOrHandleExternalApiError( + future, + customException -> PlacePreviewBatchItemResult.failure( + googlePlaceId, + customException.getErrorCode().name() + ) + ) + )); return result; } - - private PlacePreviewBatchItemResult join(String googlePlaceId, - CompletableFuture future) { - try { - return future.join(); - } catch (CompletionException exception) { - if (exception.getCause() instanceof RuntimeException runtimeException) { - return handleBatchFailure(googlePlaceId, runtimeException); - } - throw exception; - } - } - - private PlacePreviewBatchItemResult handleBatchFailure(String googlePlaceId, RuntimeException exception) { - if (exception instanceof CustomException customException - && customException.getErrorCode() == ErrorCode.EXTERNAL_API_ERROR) { - return PlacePreviewBatchItemResult.failure(googlePlaceId, customException.getErrorCode().name()); - } - throw exception; - } }