diff --git a/docs/specs/api-spec.md b/docs/specs/api-spec.md index a661b527..1109ea91 100644 --- a/docs/specs/api-spec.md +++ b/docs/specs/api-spec.md @@ -19,6 +19,7 @@ | 7 | `GET` | `/categories` | 필터칩 | 카테고리 목록 | | 8 | `GET` | `/pois/{id}` | 장소 상세 | 장소 정보 | | 9 | `POST/GET` | `/courses` | 내 코스 | 저장·조회 | +| 10 | `GET` | `/regions` | 지역 목록(더보기) | 89곳 페이지 조회·카테고리 필터 | --- @@ -115,9 +116,9 @@ { "user": { "name": "게스트", "remainingLeaveDays": 13 }, "filters": [ - { "key": "ALL", "label": "전체" }, { "key": "SIGHT", "label": "관광지" }, - { "key": "STAY", "label": "숙박" }, { "key": "EXPERIENCE", "label": "체험" }, - { "key": "FOOD", "label": "맛집" } + { "key": "ALL", "label": "전체", "regionCount": 89 }, { "key": "SIGHT", "label": "관광지", "regionCount": 61 }, + { "key": "STAY", "label": "숙박", "regionCount": 34 }, { "key": "EXPERIENCE", "label": "체험", "regionCount": 12 }, + { "key": "FOOD", "label": "맛집", "regionCount": 47 } ], "recommendedRegions": [ { @@ -237,13 +238,16 @@ > 🎯 필터칩 목록 · **기능 F6** · 서버 내부에서 `SIGHT`→lclsSystm(NA+HS+VE+LS+EV) 등 매핑 +- `regionCount` 는 **적재된 지역 콘텐츠로 그때그때 세는 값**이라 아래 숫자는 예시다. `ALL`(89)만 고시로 고정이고 나머지는 적재 상태에 따라 달라진다. +- `regionCount` = **그 칩으로 좁혔을 때 나오는 지역 수**(#266). `GET /regions?category={key}` 의 `pageResponse.totalElements` 와 같은 값이고, `ALL` 은 전체 지역 수다. 화면이 개수를 지어내거나("전부 1건") 빈 칩을 그리지 않게 하려는 것. + **응답 `data`** ```json { "categories": [ - { "key": "ALL", "label": "전체" }, { "key": "SIGHT", "label": "관광지" }, - { "key": "STAY", "label": "숙박" }, { "key": "EXPERIENCE", "label": "체험" }, - { "key": "FOOD", "label": "맛집" } + { "key": "ALL", "label": "전체", "regionCount": 89 }, { "key": "SIGHT", "label": "관광지", "regionCount": 61 }, + { "key": "STAY", "label": "숙박", "regionCount": 34 }, { "key": "EXPERIENCE", "label": "체험", "regionCount": 12 }, + { "key": "FOOD", "label": "맛집", "regionCount": 47 } ] } ``` @@ -282,12 +286,46 @@ --- +### 🔟 지역 목록(더보기) · `GET /api/v1/regions` + +> 🎯 "이번달 추천 여행지 더보기" · **기능 F3·F6** · **데이터** region89 · 관광빅데이터 · 지역 콘텐츠 · 관광사진 · **구현** #266 + +**쿼리** `?category=SIGHT&page=0&size=20` + +- 홈은 랭킹 상위 **6곳**만 준다. 이 엔드포인트가 89곳 전부를 페이지로 끊어 준다. +- 정렬은 **방문자 랭킹 내림차순 하나뿐**이라 `sort` 파라미터가 없다. 도달시간 순은 출발지 좌표가 있어야 정의되고, 그건 `POST /regions/recommendations` 가 소유한다. +- `page`(기본 0) · `size`(기본 20, 최대 100). **잘못된 값은 거절하지 않고 자른다** — 음수 page 는 0, 상한 초과 size 는 100. +- 페이지 메타는 `data` 가 아니라 **공통 래퍼의 `pageResponse`** 에 실린다. +- **외부 API 호출이 없다.** 재료가 전부 적재된 값이라 관광 API 한도가 소진돼도 목록은 나간다. + +**응답** + +```json +{ + "status": 200, "code": "OK", "detail": "요청이 정상 처리되었습니다.", + "data": { "regions": [ + { + "regionId": 51, "name": "정선군 · 강원특별자치도", + "crowdLevel": "LOW", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/83/1234583_image2_1.jpg", + "contentCount": 128, + "categories": [ { "key": "SIGHT", "label": "관광지" } ], + "neighborIncluded": false + } + ] }, + "pageResponse": { "page": 0, "size": 20, "totalElements": 89, "totalPages": 5 } +} +``` + +--- + ## 🖥️ 화면 ↔ API 매핑 | 화면 | 엔드포인트 | 기능 | | --- | --- | --- | | 연차 입력 | `POST /leave/available-time` | F1 | | 홈 | `GET /home` · `GET /categories` | F3·F6 | +| 홈 → 추천 여행지 더보기 | `GET /regions` · `GET /categories` | F3·F6 | | 샌드위치 | `GET /leave/sandwich` | F2 | | 추천 플로우 → 후보지역 | `POST /regions/recommend` | F3 | | 코스 확정 | `POST /courses/generate` | F4·F5 | diff --git a/src/main/java/com/offway/core/trip/controller/CategoryApi.java b/src/main/java/com/offway/core/trip/controller/CategoryApi.java index b42fcf2a..5e6d366d 100644 --- a/src/main/java/com/offway/core/trip/controller/CategoryApi.java +++ b/src/main/java/com/offway/core/trip/controller/CategoryApi.java @@ -10,7 +10,16 @@ @Tag(name = "카테고리", description = "여행지 필터칩(무드/유형)") public interface CategoryApi { - @Operation(summary = "필터칩 카테고리 목록", description = "결과 필터/재정렬에 쓰는 카테고리 칩을 노출 순서대로 반환한다.") - @ApiResponse(responseCode = "200", description = "조회 성공") + @Operation( + summary = "필터칩 카테고리 목록", + description = """ + 결과 필터/재정렬에 쓰는 카테고리 칩을 노출 순서대로 반환한다. + + 칩마다 **그 칩으로 좁혔을 때 나오는 인구감소지역 수**(`regionCount`)를 함께 준다 — + `GET /api/v1/regions?category={key}` 의 `pageResponse.totalElements` 와 같은 값이다. + `ALL` 은 전체 지역 수다. 개수는 적재된 지역 콘텐츠에서 세며 **외부 API 를 부르지 않는다**. + """) + @ApiResponse(responseCode = "200", description = "조회 성공 (콘텐츠 적재 전이면 ALL 을 제외한 개수가 0)") + @ApiResponse(responseCode = "401", description = "인증 필요") ApiResponseBody categories(); } diff --git a/src/main/java/com/offway/core/trip/controller/CategoryController.java b/src/main/java/com/offway/core/trip/controller/CategoryController.java index 031abdee..f0650f2c 100644 --- a/src/main/java/com/offway/core/trip/controller/CategoryController.java +++ b/src/main/java/com/offway/core/trip/controller/CategoryController.java @@ -2,17 +2,22 @@ import com.offway.core.common.response.ApiResponseBody; import com.offway.core.trip.controller.dto.CategoryResponse; +import com.offway.core.trip.service.RegionCategoryCountProvider; +import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/api/v1/categories") +@RequiredArgsConstructor public class CategoryController implements CategoryApi { + private final RegionCategoryCountProvider regionCategoryCountProvider; + @Override @GetMapping public ApiResponseBody categories() { - return ApiResponseBody.ok(CategoryResponse.of()); + return ApiResponseBody.ok(CategoryResponse.of(regionCategoryCountProvider.counts())); } } diff --git a/src/main/java/com/offway/core/trip/controller/RegionListApi.java b/src/main/java/com/offway/core/trip/controller/RegionListApi.java new file mode 100644 index 00000000..7814e000 --- /dev/null +++ b/src/main/java/com/offway/core/trip/controller/RegionListApi.java @@ -0,0 +1,42 @@ +package com.offway.core.trip.controller; + +import com.offway.core.common.response.ApiResponseBody; +import com.offway.core.trip.controller.dto.RegionListResponse; +import com.offway.core.trip.domain.Category; +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; + +/** 지역 목록 API 문서 계약(#266). 매핑은 구현체({@link RegionListController})가 소유한다. */ +@Tag(name = "지역 목록", description = "인구감소지역 89곳 페이지 조회") +public interface RegionListApi { + + @Operation( + summary = "인구감소지역 목록", + description = """ + 인구감소지역 89곳을 **방문자 랭킹 내림차순**으로 페이지에 담아 준다. 홈이 주는 상위 6곳 + 너머를 보는 "더보기" 화면이 쓴다. 카드 재료(한산도·볼거리 수·대표 이미지·카테고리)는 + 홈 카드와 같다. + + **외부 API 를 부르지 않는다.** 방문자 집계·지역 콘텐츠·관광사진이 모두 적재된 값이라, + 관광 API 한도가 소진되거나 포털이 점검 중이어도 목록은 그대로 나간다. 아직 콘텐츠가 + 적재되지 않은 지역은 목록에서 빠지지 않고 볼거리 0·이미지 없음으로 나간다. + + **정렬 파라미터는 없다.** 정렬이 하나뿐이기 때문이다. 도달시간 순은 출발지 좌표가 있어야 + 정의되는데 이 엔드포인트는 그것을 받지 않는다 — 그쪽은 `POST /api/v1/regions/recommendations` + 가 소유한다. + + 페이지 정보(`page`·`size`·`totalElements`·`totalPages`)는 응답 본문이 아니라 공통 래퍼의 + `pageResponse` 에 실린다. + """) + @ApiResponse(responseCode = "200", description = "조회 성공 (해당 카테고리에 지역이 없으면 빈 목록)") + @ApiResponse(responseCode = "400", description = "category 가 정의되지 않은 값 (ALL·SIGHT·STAY·EXPERIENCE·FOOD 외)") + @ApiResponse(responseCode = "401", description = "인증 필요") + ApiResponseBody regions( + @Parameter(description = "필터칩으로 좁히기. 생략하거나 ALL 이면 전체. 칩별 지역 수는 GET /api/v1/categories 가 준다") + Category category, + @Parameter(description = "0부터 시작하는 페이지 번호. 기본 0. 음수는 0 으로 자른다") Integer page, + @Parameter(description = "페이지 크기. 기본 20, 최대 100. 범위를 벗어나면 잘라 준다(거절하지 않는다)") + Integer size); +} diff --git a/src/main/java/com/offway/core/trip/controller/RegionListController.java b/src/main/java/com/offway/core/trip/controller/RegionListController.java new file mode 100644 index 00000000..4bd4eee5 --- /dev/null +++ b/src/main/java/com/offway/core/trip/controller/RegionListController.java @@ -0,0 +1,31 @@ +package com.offway.core.trip.controller; + +import com.offway.core.common.response.ApiResponseBody; +import com.offway.core.common.response.PageResponse; +import com.offway.core.trip.controller.dto.RegionListResponse; +import com.offway.core.trip.domain.Category; +import com.offway.core.trip.service.RegionListService; +import com.offway.core.trip.service.dto.RegionList; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/regions") +@RequiredArgsConstructor +public class RegionListController implements RegionListApi { + + private final RegionListService regionListService; + + @Override + @GetMapping + public ApiResponseBody regions( + @RequestParam(required = false) Category category, + @RequestParam(required = false) Integer page, + @RequestParam(required = false) Integer size) { + RegionList regions = regionListService.list(category, page, size); + return ApiResponseBody.ok(RegionListResponse.from(regions), PageResponse.of(regions)); + } +} diff --git a/src/main/java/com/offway/core/trip/controller/dto/CategoryResponse.java b/src/main/java/com/offway/core/trip/controller/dto/CategoryResponse.java index a8f867f6..a4d1821b 100644 --- a/src/main/java/com/offway/core/trip/controller/dto/CategoryResponse.java +++ b/src/main/java/com/offway/core/trip/controller/dto/CategoryResponse.java @@ -1,6 +1,7 @@ package com.offway.core.trip.controller.dto; import com.offway.core.trip.domain.Category; +import com.offway.core.trip.domain.CategoryCounts; import io.swagger.v3.oas.annotations.media.Schema; import java.util.Arrays; import java.util.List; @@ -8,25 +9,31 @@ /** * 필터칩 카테고리 목록 응답 — API 계약. * + *

칩마다 그 칩으로 좁혔을 때 나오는 지역 수를 함께 낸다(#266). 없으면 화면이 개수를 지어내거나("전부 1건") 빈 칩을 그대로 + * 그린다. + * * @param categories 노출 순서대로의 카테고리 칩 */ public record CategoryResponse(List categories) { /** 도메인 {@link Category} 전부를 선언 순서대로 노출한다(ALL 이 맨 앞). */ - public static CategoryResponse of() { - return new CategoryResponse(Arrays.stream(Category.values()).map(Item::from).toList()); + public static CategoryResponse of(CategoryCounts counts) { + return new CategoryResponse( + Arrays.stream(Category.values()).map(category -> Item.from(category, counts)).toList()); } /** * @param key enum 식별자 (ALL·SIGHT·STAY·EXPERIENCE·FOOD) * @param label 한글 라벨 + * @param regionCount 이 칩으로 좁혔을 때 나오는 인구감소지역 수. {@code ALL} 은 전체 지역 수다 */ public record Item( @Schema(example = "SIGHT") String key, - @Schema(example = "관광지") String label) { + @Schema(example = "관광지") String label, + @Schema(description = "이 칩으로 좁혔을 때 나오는 지역 수 (ALL 은 전체)", example = "61") int regionCount) { - static Item from(Category category) { - return new Item(category.name(), category.label()); + static Item from(Category category, CategoryCounts counts) { + return new Item(category.name(), category.label(), counts.of(category)); } } } diff --git a/src/main/java/com/offway/core/trip/controller/dto/CategoryTagResponse.java b/src/main/java/com/offway/core/trip/controller/dto/CategoryTagResponse.java new file mode 100644 index 00000000..3305b9ff --- /dev/null +++ b/src/main/java/com/offway/core/trip/controller/dto/CategoryTagResponse.java @@ -0,0 +1,23 @@ +package com.offway.core.trip.controller.dto; + +import com.offway.core.trip.domain.Category; +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * 지역 카드에 붙는 볼거리 분류 태그 — "이 지역에 이런 것이 있다". + * + *

필터칩({@link CategoryResponse.Item})과 다른 타입이다. 둘 다 {@code key}·{@code label} 을 갖지만 답하는 질문이 다르다 — + * 필터칩은 "이 칩으로 좁히면 몇 곳인가"({@code regionCount})까지 답하고, 태그는 그 지역 카드의 표시일 뿐이라 개수라는 개념이 없다. + * 한 타입으로 묶으면 지역 카드마다 전체 지역 수가 따라붙어 읽는 쪽이 그것을 그 지역의 수로 오해한다. + * + * @param key enum 식별자 (SIGHT·STAY·EXPERIENCE·FOOD) + * @param label 한글 라벨 + */ +public record CategoryTagResponse( + @Schema(example = "SIGHT") String key, + @Schema(example = "관광지") String label) { + + public static CategoryTagResponse from(Category category) { + return new CategoryTagResponse(category.name(), category.label()); + } +} diff --git a/src/main/java/com/offway/core/trip/controller/dto/HomeResponse.java b/src/main/java/com/offway/core/trip/controller/dto/HomeResponse.java index 6be28478..c7cfe3d7 100644 --- a/src/main/java/com/offway/core/trip/controller/dto/HomeResponse.java +++ b/src/main/java/com/offway/core/trip/controller/dto/HomeResponse.java @@ -16,7 +16,7 @@ public record HomeResponse(User user, List filters, List< public static HomeResponse from(HomeResult result) { return new HomeResponse( new User(GUEST_NAME, result.remainingLeaveDays()), - CategoryResponse.of().categories(), + CategoryResponse.of(result.categoryCounts()).categories(), result.regions().stream().map(RegionCard::from).toList()); } @@ -34,7 +34,7 @@ public record User( * @param name 지역명 (시군구 · 시도) * @param crowdLevel 한산도 뱃지 * @param imageUrl 대표 이미지 URL (없으면 null) - * @param categories 볼거리 카테고리 칩 + * @param categories 볼거리 카테고리 태그 (필터칩과 달리 개수가 없다 — {@link CategoryTagResponse}) * @param benefit 대표 혜택 (없으면 null) */ public record RegionCard( @@ -45,7 +45,7 @@ public record RegionCard( example = "http://tong.visitkorea.or.kr/cms/resource/83/1234583_image2_1.jpg", nullable = true) String imageUrl, - List categories, + List categories, @Schema(description = "대표 혜택 (없으면 null)", nullable = true) Benefit benefit) { static RegionCard from(HomeResult.RegionCard card) { @@ -54,7 +54,7 @@ static RegionCard from(HomeResult.RegionCard card) { card.sigungu() + " · " + card.sido(), card.crowdLevel(), card.imageUrl(), - card.categories().stream().map(CategoryResponse.Item::from).toList(), + card.categories().stream().map(CategoryTagResponse::from).toList(), card.benefit() == null ? null : Benefit.from(card.benefit())); } } diff --git a/src/main/java/com/offway/core/trip/controller/dto/RegionListResponse.java b/src/main/java/com/offway/core/trip/controller/dto/RegionListResponse.java new file mode 100644 index 00000000..a29bf9b3 --- /dev/null +++ b/src/main/java/com/offway/core/trip/controller/dto/RegionListResponse.java @@ -0,0 +1,61 @@ +package com.offway.core.trip.controller.dto; + +import com.offway.core.common.logging.LogSummaries; +import com.offway.core.common.logging.LogSummary; +import com.offway.core.trip.domain.CrowdLevel; +import com.offway.core.trip.service.dto.RegionList; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +/** + * 지역 목록 응답 — API 계약. 방문자 랭킹 내림차순. + * + *

페이지 메타({@code page}·{@code size}·{@code totalElements}·{@code totalPages})는 여기가 아니라 공통 래퍼의 + * {@code pageResponse} 로 나간다(api-convention). 목록 API 가 전부 같은 자리에서 페이지 정보를 주게 하려는 것이다. + * + * @param regions 이 페이지의 지역 + */ +public record RegionListResponse(List regions) implements LogSummary { + + public static RegionListResponse from(RegionList regions) { + return new RegionListResponse(regions.regions().stream().map(Item::from).toList()); + } + + @Override + public String logSummary() { + return LogSummaries.count("지역", regions); + } + + /** + * @param regionId 지역 ID + * @param name 지역명 (시군구 · 시도) + * @param crowdLevel 한산도 뱃지 + * @param imageUrl 대표 이미지 URL (없으면 null) + * @param contentCount 볼거리 수 (인접 50km 병합 시 합산) + * @param categories 볼거리 카테고리 태그 + * @param neighborIncluded 볼거리 부족으로 인접 50km 지역이 포함됐는지 + */ + public record Item( + long regionId, + @Schema(example = "완도군 · 전라남도") String name, + CrowdLevel crowdLevel, + @Schema( + example = "http://tong.visitkorea.or.kr/cms/resource/83/1234583_image2_1.jpg", + nullable = true) + String imageUrl, + @Schema(example = "38") int contentCount, + List categories, + @Schema(example = "false") boolean neighborIncluded) { + + static Item from(RegionList.Item region) { + return new Item( + region.regionId(), + region.sigungu() + " · " + region.sido(), + region.crowdLevel(), + region.imageUrl(), + region.contentCount(), + region.categories().stream().map(CategoryTagResponse::from).toList(), + region.neighborIncluded()); + } + } +} diff --git a/src/main/java/com/offway/core/trip/controller/dto/RegionRecommendResponse.java b/src/main/java/com/offway/core/trip/controller/dto/RegionRecommendResponse.java index 1b071df6..b40cc2d4 100644 --- a/src/main/java/com/offway/core/trip/controller/dto/RegionRecommendResponse.java +++ b/src/main/java/com/offway/core/trip/controller/dto/RegionRecommendResponse.java @@ -30,7 +30,7 @@ public String logSummary() { * @param crowdLevel 한산도 뱃지 * @param imageUrl 대표 이미지 URL (없으면 null) * @param contentCount 볼거리 수 (인접 50km 병합 시 합산) - * @param categories 볼거리 카테고리 칩 + * @param categories 볼거리 카테고리 태그 (필터칩과 달리 개수가 없다 — {@link CategoryTagResponse}) * @param neighborIncluded 볼거리 부족으로 인접 50km 지역이 포함됐는지 * @param benefits 적용 혜택 뱃지 */ @@ -44,7 +44,7 @@ public record Item( nullable = true) String imageUrl, @Schema(example = "38") int contentCount, - List categories, + List categories, @Schema(example = "false") boolean neighborIncluded, @Schema(description = """ 지역 한 줄 소개(#140). 그 지역에 실제로 있는 대표 볼거리 이름으로 만든다. @@ -62,7 +62,7 @@ static Item from(RecommendedRegion region) { region.crowdLevel(), region.imageUrl(), region.contentCount(), - region.categories().stream().map(CategoryResponse.Item::from).toList(), + region.categories().stream().map(CategoryTagResponse::from).toList(), region.neighborIncluded(), region.intro(), region.benefits().stream().map(Benefit::from).toList()); diff --git a/src/main/java/com/offway/core/trip/domain/CategoryCounts.java b/src/main/java/com/offway/core/trip/domain/CategoryCounts.java new file mode 100644 index 00000000..b8298b95 --- /dev/null +++ b/src/main/java/com/offway/core/trip/domain/CategoryCounts.java @@ -0,0 +1,47 @@ +package com.offway.core.trip.domain; + +import java.util.Collection; +import java.util.EnumMap; +import java.util.Map; +import java.util.Objects; + +/** + * 필터칩별 지역 수 — "이 칩으로 좁히면 몇 곳이 나오는가"(#266). + * + *

왜 필요한가. 칩 목록은 라벨만 주고 개수를 주지 않아, 앱이 개수를 전부 1 로 채워 "있다/없다" 만 판별하고 있었다. 칩에 개수를 + * 보여주거나 빈 칩을 가리려면 실제 수가 있어야 한다. + * + *

세는 규칙은 하나다. {@link RegionContent#has} 를 그대로 쓴다 — 목록 필터가 쓰는 판정과 같은 것이어야 개수와 결과가 + * 어긋나지 않는다. + * + * @param byCategory 칩 → 그 칩으로 좁혔을 때 나오는 지역 수 + */ +public record CategoryCounts(Map byCategory) { + + /** 아직 세지 않은 상태 — 모든 칩이 0 이다. */ + public static final CategoryCounts EMPTY = new CategoryCounts(Map.of()); + + public CategoryCounts { + byCategory = Map.copyOf(Objects.requireNonNull(byCategory, "칩별 지역 수는 필수입니다")); + } + + /** + * 지역별 콘텐츠에서 칩 개수를 센다. 입력에서 도출되는 값이라 빌더가 아니라 팩토리다(조립이면 빌더, 계산이면 팩토리). + * + * @param regionContents 지역 전부의 콘텐츠. 아직 콘텐츠가 없는 지역도 {@link RegionContent#EMPTY} 로 들어와야 + * {@code ALL} 이 전체 지역 수가 된다 — 빠뜨리면 "전체" 칩이 목록보다 작아진다 + */ + public static CategoryCounts of(Collection regionContents) { + Objects.requireNonNull(regionContents, "지역 콘텐츠 목록은 필수입니다"); + Map counts = new EnumMap<>(Category.class); + for (Category category : Category.values()) { + counts.put(category, (int) regionContents.stream().filter(content -> content.has(category)).count()); + } + return new CategoryCounts(counts); + } + + /** 이 칩으로 좁혔을 때 나오는 지역 수. 세지 않았으면 0. */ + public int of(Category category) { + return byCategory.getOrDefault(category, 0); + } +} diff --git a/src/main/java/com/offway/core/trip/domain/RegionContent.java b/src/main/java/com/offway/core/trip/domain/RegionContent.java index fdacaeb2..64f043d9 100644 --- a/src/main/java/com/offway/core/trip/domain/RegionContent.java +++ b/src/main/java/com/offway/core/trip/domain/RegionContent.java @@ -31,6 +31,16 @@ public boolean isSufficient() { return contentCount >= SUFFICIENCY_MIN; } + /** + * 이 지역이 그 필터칩에 걸리는가. {@code ALL} 은 필터가 아니라 전체 표지라 항상 참이다({@link Category#includes} 와 같은 규칙). + * + *

목록 필터와 칩 개수가 이 한 메서드를 공유한다({@link CategoryCounts}). 판정이 둘로 갈리면 "12곳" 이라고 적힌 칩을 눌렀는데 + * 9곳이 나온다 — 예외도 로그도 없이 조용히 틀리는 종류의 어긋남이다. + */ + public boolean has(Category category) { + return category == Category.ALL || categories.contains(category); + } + /** * 인접 지역 콘텐츠를 이 지역에 병합한다 — 볼거리 수는 합산, categories 는 합집합(순서 유지), 대표 이미지는 이쪽이 없을 때만 인접 것으로 * 폴백. 병합이 실제로 콘텐츠를 더했으면 {@code neighborIncluded} 를 세운다. diff --git a/src/main/java/com/offway/core/trip/service/HomeService.java b/src/main/java/com/offway/core/trip/service/HomeService.java index c489e7e3..9a243980 100644 --- a/src/main/java/com/offway/core/trip/service/HomeService.java +++ b/src/main/java/com/offway/core/trip/service/HomeService.java @@ -33,6 +33,7 @@ public class HomeService { private final RegionRankingService regionRankingService; private final RegionContentProvider regionContentProvider; private final RegionHeroPhotoProvider regionHeroPhotoProvider; + private final RegionCategoryCountProvider regionCategoryCountProvider; private final PolicyService policyService; private final MyLeaveService myLeaveService; @@ -73,7 +74,8 @@ public HomeResult home(String guestId) { .map(score -> toCard( regionById.get(score.regionId()), score, contents, heroPhotos, policiesByRegion)) .toList(); - return new HomeResult(remainingLeaveDays, cards); + // 필터칩 개수는 미리 세어 둔 값을 읽기만 한다(#266) — 요청마다 89곳을 다시 세지 않는다. + return new HomeResult(remainingLeaveDays, cards, regionCategoryCountProvider.counts()); } private HomeResult.RegionCard toCard( diff --git a/src/main/java/com/offway/core/trip/service/RegionCategoryCountProvider.java b/src/main/java/com/offway/core/trip/service/RegionCategoryCountProvider.java new file mode 100644 index 00000000..f2550bce --- /dev/null +++ b/src/main/java/com/offway/core/trip/service/RegionCategoryCountProvider.java @@ -0,0 +1,166 @@ +package com.offway.core.trip.service; + +import com.offway.core.region.domain.Region; +import com.offway.core.region.service.RegionMaster; +import com.offway.core.trip.domain.CategoryCounts; +import com.offway.core.trip.domain.RegionContent; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +/** + * 필터칩별 지역 수를 세어 들고 있는다(#266) — 칩을 노출하는 두 자리({@code GET /api/v1/categories}, 홈의 {@code filters})가 + * 같은 값을 쓴다. + * + *

매 요청 세지 않는다. 세는 입력이 느리게 변한다 — 인구감소지역은 고시로 정해진 89곳이고, 그 콘텐츠는 + * {@link RegionContentRefreshService} 가 주 1회 갈아끼운다. 요청마다 89행을 읽어 다시 세면 안 바뀌는 답을 반복해서 만드는 셈이다. + * + *

키 공간은 하나다. 캐시하는 값이 지역·사용자별로 갈리지 않고 서비스 전체에 하나뿐이라(칩 {@link + * com.offway.core.trip.domain.Category} 5개의 개수 묶음), 상한·LRU 를 설계할 키가 애초에 없다. + * + *

갱신은 두 갈래다. + * + *

    + *
  • 적재가 끝나면 바로 버린다({@link #invalidate()}). 원본이 바뀐 그 순간을 아는 유일한 자리라, 여기서 버리면 새 배포의 + * 첫 적재가 곧바로 칩에 반영된다. TTL 만 두면 그 사이 사용자가 "전부 0" 인 칩을 본다. + *
  • {@value #RECOUNT_INTERVAL_TEXT} 마다 다시 센다 — 위 경로를 타지 않은 변경(다른 프로세스의 적재, 시드 마이그레이션 + * 직후 등)까지 덮는 안전망이다. 재계산은 89행 한 번이라 하루 24번을 써도 무시할 만하다. + *
+ */ +@Slf4j +@Component +@RequiredArgsConstructor +public class RegionCategoryCountProvider { + + /** 안전망 재계산 간격 — 위 클래스 주석의 근거. */ + private static final Duration RECOUNT_INTERVAL = Duration.ofHours(1); + + private static final String RECOUNT_INTERVAL_TEXT = "1시간"; + + /** + * 지역이 비어 warn 을 남기는 최소 간격. + * + *

다시 세는 것을 억제하려는 것이 아니다 — {@link RegionMaster} 가 이미 빈 상태의 재적재를 자기 간격으로 막는다. + * 여기서 막는 것은 로그다. 조회마다 같은 warn 을 남기면 시드가 늦어진 동안 로그가 그 한 줄로 덮인다. + */ + private static final Duration EMPTY_WARN_INTERVAL = Duration.ofMinutes(5); + + private final RegionMaster regionMaster; + private final RegionContentProvider regionContentProvider; + + /** + * 마지막으로 센 결과. {@code null} 이 "아직 세지 않았다" 다 — 결과가 전부 0 인 것과 구별해야 다음 조회가 다시 센다. + */ + private volatile Snapshot snapshot; + + /** + * 원본이 바뀐 횟수 — 세는 동안 바뀌었는지를 가른다. + * + *

재계산은 콘텐츠를 읽고 나서 결과를 세운다. 그 사이에 적재가 끝나 {@link #invalidate()} 가 스냅샷을 버리면, + * 진행 중이던 재계산이 옛 입력으로 만든 결과를 새 시각으로 다시 세운다 — 버린 것이 되살아나 새 콘텐츠가 최대 + * {@value #RECOUNT_INTERVAL_TEXT} 동안 칩에 안 나온다. 적재 직후 버리는 것이 이 클래스의 설계인데 그 자리가 무력해진다. + * + *

세대를 스냅샷 자신이 들고 다닌다. 세울 때 대조하고 끝내면 대조와 대입 사이에 또 틈이 생긴다(TOCTOU) — + * 좁아질 뿐 닫히지 않는다. 값에 세대를 붙여 두면 늦게 세워진 옛 결과도 읽는 쪽이 알아보고 무시하므로 + * 락 없이 닫힌다. 임계구역을 만들지 않아 조회 스레드가 적재 스레드를 기다릴 일도 없다. + */ + private final AtomicLong generation = new AtomicLong(); + + /** 지역이 비어 마지막으로 warn 을 남긴 시각(단조 시계). 창 진입은 CAS 로 한 스레드만 통과한다. */ + private final AtomicLong lastEmptyWarnNanos = new AtomicLong(Long.MIN_VALUE); + + /** + * 센 결과와 센 시각(단조 시계), 그리고 어느 세대의 입력으로 셌는지. + * + *

시각은 벽시계로 재지 않는다 — 시스템 시각 보정에 간격이 늘거나 즉시 만료된다. + */ + private record Snapshot(CategoryCounts counts, long countedNanos, long generation) { + + private boolean isStale() { + return System.nanoTime() - countedNanos >= RECOUNT_INTERVAL.toNanos(); + } + + /** 이 결과를 만든 입력이 아직 최신인가. */ + private boolean isCurrent(long latestGeneration) { + return generation == latestGeneration; + } + } + + /** + * 기동이 끝난 뒤 한 번 센다 — 첫 요청이 계산을 떠안지 않게. 마이그레이션(Flyway)이 시드를 넣은 뒤여야 해서 + * {@code @PostConstruct} 가 아니라 이 이벤트를 쓴다({@link RegionMaster} 와 같은 이유). + */ + @EventListener(ApplicationReadyEvent.class) + public void warm() { + recount(); + } + + /** 지금 칩별 지역 수. 오래됐거나 아직 안 셌으면 그 자리에서 센다(89행 한 번). */ + public CategoryCounts counts() { + Snapshot current = snapshot; + if (current != null && current.isCurrent(generation.get()) && !current.isStale()) { + return current.counts(); + } + return recount(); + } + + /** + * 다음 조회가 다시 세게 한다 — 지역 콘텐츠 적재가 끝나면 부른다. + * + *

지금 세지 않고 버리기만 한다. 적재는 배치 스레드에서 끝나는데 거기서 다시 세면 아무도 안 볼 수도 있는 값을 만드는 것이고, + * 어차피 다음 조회가 센다. + */ + public void invalidate() { + generation.incrementAndGet(); + snapshot = null; + } + + /** + * 89곳 전부의 콘텐츠로 다시 센다. + * + *

이중 계산을 막지 않는다({@link RegionMaster} 와 같은 판단) — 두 스레드가 동시에 들어와도 같은 입력에서 같은 결과를 만들어 + * 같은 값으로 덮을 뿐이고, 락을 걸면 89행 조회 하나 때문에 요청 스레드가 서로를 기다린다. + */ + private CategoryCounts recount() { + long startedAt = generation.get(); + List all = regionMaster.all(); + if (all.isEmpty()) { + // 시드가 아직 없는 상태(초기 부팅)다. 스냅샷을 세우지 않아 다음 조회가 다시 시도한다 — + // 여기서 "전부 0" 을 굳히면 시드가 들어온 뒤에도 한 시간 동안 빈 칩이 나간다. + warnEmptyRegions(); + return CategoryCounts.EMPTY; + } + List regionIds = all.stream().map(Region::getId).toList(); + Map stored = regionContentProvider.storedForAll(regionIds); + // 콘텐츠가 아직 없는 지역도 빈 콘텐츠로 세운다 — 그래야 ALL 이 목록의 전체 건수와 같아진다. + List contents = regionIds.stream() + .map(regionId -> stored.getOrDefault(regionId, RegionContent.EMPTY)) + .toList(); + CategoryCounts counted = CategoryCounts.of(contents); + // 세운 뒤 적재가 끝나도 안전하다 — 이 스냅샷은 startedAt 세대를 달고 있어 읽는 쪽이 알아보고 무시한다. + snapshot = new Snapshot(counted, System.nanoTime(), startedAt); + log.debug("필터칩 개수 집계 지역={} 콘텐츠={} 결과={}", all.size(), stored.size(), counted.byCategory()); + return counted; + } + + /** 같은 warn 이 조회마다 쌓이지 않게 창당 한 줄로 누른다. 창이 지나면 다시 남겨 장애 지속도 보이게 한다. */ + private void warnEmptyRegions() { + long now = System.nanoTime(); + long last = lastEmptyWarnNanos.get(); + if (last != Long.MIN_VALUE && now - last < EMPTY_WARN_INTERVAL.toNanos()) { + return; + } + // 읽고 쓰는 사이에 다른 스레드가 먼저 통과했으면 양보한다 — 창이 열린 순간 몰린 요청이 + // 저마다 같은 warn 을 남기면 억제하려던 것이 그대로 일어난다. + if (!lastEmptyWarnNanos.compareAndSet(last, now)) { + return; + } + log.warn("지역이 없어 필터칩 개수를 세지 못했습니다 — 다음 조회에서 다시 셉니다"); + } +} diff --git a/src/main/java/com/offway/core/trip/service/RegionContentRefreshService.java b/src/main/java/com/offway/core/trip/service/RegionContentRefreshService.java index cb907444..63973eee 100644 --- a/src/main/java/com/offway/core/trip/service/RegionContentRefreshService.java +++ b/src/main/java/com/offway/core/trip/service/RegionContentRefreshService.java @@ -57,6 +57,7 @@ public class RegionContentRefreshService { private static final ZoneId SERVICE_ZONE = ZoneId.of("Asia/Seoul"); private final RegionContentProvider regionContentProvider; + private final RegionCategoryCountProvider regionCategoryCountProvider; private final RegionContentRepository regionContentRepository; private final RegionRepository regionRepository; private final BatchRunRepository batchRunRepository; @@ -135,6 +136,9 @@ public void refresh() { // 새 값도 이전 값도 없으면 그 지역은 저장하지 않는다 — 호출자가 빈 콘텐츠로 취급한다. } regionContentRepository.replaceAll(rows); + // 필터칩 개수는 이 콘텐츠에서 세는 값이라, 바뀐 그 순간에 버려야 한다(#266). 여기서 안 버리면 + // 다음 재계산 주기까지 칩이 옛 개수를 들고 있고, 새 배포의 첫 적재 뒤에는 "전부 0" 인 칩이 나간다. + regionCategoryCountProvider.invalidate(); int missing = regions.size() - rows.size(); if (missing > 0 || kept > 0 || fetched.degraded() > 0) { diff --git a/src/main/java/com/offway/core/trip/service/RegionListService.java b/src/main/java/com/offway/core/trip/service/RegionListService.java new file mode 100644 index 00000000..0fdeb445 --- /dev/null +++ b/src/main/java/com/offway/core/trip/service/RegionListService.java @@ -0,0 +1,113 @@ +package com.offway.core.trip.service; + +import com.offway.core.common.response.Paging; +import com.offway.core.region.domain.Region; +import com.offway.core.region.service.RegionMaster; +import com.offway.core.trip.domain.Category; +import com.offway.core.trip.domain.RegionContent; +import com.offway.core.trip.domain.RegionScore; +import com.offway.core.trip.service.dto.RegionList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; + +/** + * 지역 목록 조회(#266) — "이번달 추천 여행지 더보기" 가 쓰는 길이다. + * + *

왜 생겼나. 목록 전용 API 가 없어 그 화면이 홈({@code GET /api/v1/home}) 응답을 재사용했는데, 홈은 랭킹 상위 6곳만 준다. + * 89곳을 페이지로 끊어 주고 카테고리로 좁힐 수 있어야 한다. + * + *

외부를 부르지 않는다. 이 목록의 재료는 전부 이미 우리 DB 에 있다 — 한산도는 방문자 집계({@link RegionRankingService}), + * 볼거리 수·카테고리·이미지는 적재된 지역 콘텐츠({@link RegionContentProvider#storedForAll}), 대표 사진은 관광사진 갤러리 + * ({@link RegionHeroPhotoProvider}). 페이지마다 외부를 부르면 더보기 몇 번으로 TourAPI 일일 한도가 마른다. + * + *

트랜잭션으로 감싸지 않는다. {@link RegionRankingService} 는 집계가 통째로 비어 있을 때(새 환경 첫 요청) 한 번 외부를 + * 부르는 경로를 갖고 있다. 여기를 {@code @Transactional} 로 묶으면 그 호출이 트랜잭션 안에 들어가 read-timeout 동안 DB 커넥션을 + * 잡는다(영속성 규약). 협력자들이 각자 짧은 트랜잭션을 갖는다 — {@link HomeService} 와 같은 판단이다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class RegionListService { + + private final RegionMaster regionMaster; + private final RegionRankingService regionRankingService; + private final RegionContentProvider regionContentProvider; + private final RegionHeroPhotoProvider regionHeroPhotoProvider; + + /** + * 인구감소지역을 방문자 랭킹 순으로 한 페이지 준다. + * + *

정렬은 하나뿐이라 파라미터로 받지 않는다. 홈·추천이 쓰는 그 랭킹(베이지안 보정 방문자수)이다. 도달시간 순은 출발지 + * 좌표가 있어야 정의되는데 이 엔드포인트는 그것을 받지 않는다 — 그쪽은 {@code POST /api/v1/regions/recommendations} 가 이미 + * 소유한다. 근거 없는 정렬 키를 열어 두는 것이 제일 나쁘다. + * + * @param category 필터칩. null 또는 {@link Category#ALL} 이면 전체 + * @param page 0부터. 해석(기본값·상한·자르기)은 {@link Paging} 이 단독으로 소유한다 + * @param size 페이지 크기. 위와 같다 + */ + public RegionList list(Category category, Integer page, Integer size) { + PageRequest pageRequest = Paging.of(page, size); + List all = regionMaster.all(); + if (all.isEmpty()) { + return RegionList.from(new PageImpl<>(List.of(), pageRequest, 0)); + } + + List allIds = all.stream().map(Region::getId).toList(); + // 89행 한 번. 필터와 카드 재료가 같은 값이라 페이지 것만 읽어 봐야 필터를 걸 수 없다. + Map contents = regionContentProvider.storedForAll(allIds); + Map regionById = new HashMap<>(); + all.forEach(region -> regionById.put(region.getId(), region)); + + Category filter = category == null ? Category.ALL : category; + // 정렬·필터·페이지 자르기를 메모리에서 한다. 대상이 고시로 정해진 89곳 고정이고, 랭킹 점수가 DB 컬럼이 + // 아니라 전체 표본으로 계산되는 값이라 SQL 로 내릴 수 있는 정렬이 아니다. + List matched = regionRankingService.rankByVisitors(all).stream() + .filter(score -> contentOf(contents, score.regionId()).has(filter)) + .toList(); + + List pageScores = slice(matched, pageRequest); + List pageIds = pageScores.stream().map(RegionScore::regionId).toList(); + // 대표 사진은 이 페이지 것만 고른다 — 89곳 전부를 고르면 안 보여줄 카드까지 계산한다. + Map heroPhotos = regionHeroPhotoProvider.heroPhotoUrls(pageIds, null); + + List items = pageScores.stream() + .map(score -> toItem(regionById.get(score.regionId()), score, contents, heroPhotos)) + .toList(); + log.debug("지역 목록 category={} 전체={} 필터후={} 페이지={}건", filter, all.size(), matched.size(), items.size()); + return RegionList.from(new PageImpl<>(items, pageRequest, matched.size())); + } + + private static RegionList.Item toItem( + Region region, RegionScore score, Map contents, Map heroPhotos) { + return RegionList.Item.of( + region.getId(), + region.getSido(), + region.getSigungu(), + score.crowdLevel(), + contentOf(contents, region.getId()), + heroPhotos.get(region.getId())); + } + + /** 아직 적재되지 않은 지역은 빈 콘텐츠다 — 목록에서 빠지지 않고 볼거리 0 으로 나간다. */ + private static RegionContent contentOf(Map contents, long regionId) { + return contents.getOrDefault(regionId, RegionContent.EMPTY); + } + + /** + * 페이지 범위만큼 잘라 낸다. + * + *

범위를 벗어난 페이지는 빈 목록이다 — 거절하지 않는다({@link Paging} 과 같은 판단). 마지막 페이지 다음을 요청하는 것은 + * 무한 스크롤에서 정상적으로 일어나는 일이라 400 으로 끊을 이유가 없다. + */ + private static List slice(List all, PageRequest pageRequest) { + int from = (int) Math.min(pageRequest.getOffset(), all.size()); + int to = Math.min(from + pageRequest.getPageSize(), all.size()); + return all.subList(from, to); + } +} diff --git a/src/main/java/com/offway/core/trip/service/RegionRankingService.java b/src/main/java/com/offway/core/trip/service/RegionRankingService.java index 1d4aa2a9..d0b963bd 100644 --- a/src/main/java/com/offway/core/trip/service/RegionRankingService.java +++ b/src/main/java/com/offway/core/trip/service/RegionRankingService.java @@ -80,10 +80,32 @@ public class RegionRankingService { private final TourDataLabClient tourDataLabClient; private final RegionVisitorAggregateRepository aggregateRepository; + /** + * 성과 없이 끝난 최초 적재를 다시 시도하기까지의 간격. + * + *

왜 필요한가. 집계가 비어 있는 동안은 {@link #stored()} 가 요청마다 적재를 다시 시도한다. + * 이 집계를 채우는 경로가 요청 경로 하나뿐(스케줄러·워머 없음)이라 그 자체는 의도한 것이지만, 외부가 죽어 + * 있거나 한도가 말라 결과가 계속 비면 재시도가 트래픽에 비례해 늘어난다. 실측(2026-08-14): 집계가 빈 + * 상태에서 목록 5회에 관광빅데이터 호출 15건(요청당 3건 = {@value #MAX_MONTHS_BACK}개월 역행)이 나갔다. + * "더보기" 는 한 세션에 여러 페이지를 넘기는 화면이라 이 곱셈이 그대로 한도로 간다. + * + *

그래서 실패에는 간격을 둔다(빈 응답도 실패와 같이 취급 — 값이 없다는 점에서 결과가 같다). + * 5분이면 재시도가 트래픽과 무관하게 인스턴스당 시간당 12회로 묶이고, 외부가 회복하면 5분 안에 스스로 + * 채워진다. 성공하면 집계가 차서 이 경로 자체를 더 타지 않으므로 정상 상태의 비용은 그대로 0 이다. + */ + private static final Duration BOOTSTRAP_RETRY_COOLDOWN = Duration.ofMinutes(5); + /** 최초 적재가 진행 중인가 — 동시 요청이 같은 60초 집계를 겹쳐 돌리지 않게. */ private final java.util.concurrent.atomic.AtomicBoolean bootstrapping = new java.util.concurrent.atomic.AtomicBoolean(); + /** + * 마지막으로 최초 적재가 성과 없이 끝난 시각(단조 시계). {@code null} 이면 아직 실패한 적이 없다. + * + *

벽시계로 재지 않는다 — 시스템 시각 보정에 간격이 늘거나 즉시 만료된다. + */ + private volatile Long lastFailedBootstrapNanos; + /** * 지금 들고 있는 집계가 어느 달 것인지 — 실패했을 때 "얼마나 낡았나" 를 로그가 답하게 한다. * @@ -94,13 +116,22 @@ private String storedMonth() { return aggregateRepository.latestBaseMonth().map(YearMonth::toString).orElse("없음"); } - /** 저장된 집계를 비운다 — 운영상 강제 갱신, 그리고 공유 컨텍스트 통합 테스트의 격리용. */ + /** + * 저장된 집계를 비운다 — 운영상 강제 갱신, 그리고 공유 컨텍스트 통합 테스트의 격리용. + * + *

재시도 대기도 함께 푼다. 비워 놓고 대기가 남아 있으면 "강제 갱신" 이 다음 조회에서 아무 일도 하지 않는다. + */ public void evictCache() { aggregateRepository.replaceAll(List.of()); + lastFailedBootstrapNanos = null; } /** - * 관광빅데이터를 다시 받아 영속한다 — 워머가 부른다(#193). + * 관광빅데이터를 다시 받아 영속한다. + * + *

부르는 곳은 요청 경로의 최초 적재({@link #stored()})뿐이다. 이 집계에는 스케줄러도 워머도 없다 — + * 한 번 채워지면 월 단위로만 바뀌는 값이라 그대로 두는 것이 의도다. 그래서 실패가 이어질 때의 재시도 간격을 + * {@link #BOOTSTRAP_RETRY_COOLDOWN} 이 소유한다. * *

비어 있으면 저장하지 않는다. 빈 집계로 덮으면 전 지역 방문자가 0이 돼 랭킹이 무의미해지는데, * 그건 이전 값을 그대로 두는 것보다 나쁘다 — 미발행·장애가 지나가면 이전 달 값으로도 순위는 선다. @@ -155,8 +186,9 @@ public boolean hasLatest() { /** * 저장된 집계를 랭킹이 쓰는 모양으로 되돌린다. 평상시엔 DB 만 읽는다. * - *

비어 있을 때만 한 번 받아 온다 — 새 환경에 처음 배포해 워머가 아직 안 돈 구간이다. 그마저 하지 - * 않으면 전 지역 방문자가 0이 돼 랭킹이 무의미해진다. + *

비어 있을 때만 받아 온다 — 새 환경에 처음 배포해 아직 아무도 적재하지 않은 구간이다. 이 집계를 + * 채우는 경로가 여기뿐이라, 그마저 하지 않으면 전 지역 방문자가 0이 돼 랭킹이 무의미해진다. 다만 결과가 계속 + * 비면 재시도가 트래픽에 비례하므로 {@link #BOOTSTRAP_RETRY_COOLDOWN} 만큼 간격을 둔다. * *

동시 요청은 기다리지 않는다. 이 적재는 호출 하나가 아니라 {@link #AGGREGATE_DEADLINE} 짜리 * 집계라, 기다려 봐야 대부분 상한에 걸려 결국 같은 결과에 지연만 얹힌다. 첫 요청 하나만 채우고 나머지는 @@ -164,25 +196,47 @@ public boolean hasLatest() { * *

적재가 실패해도 랭킹은 나간다. {@link #refresh()} 는 {@link TourApiException} 만 삼키므로 * 영속화 충돌·락 타임아웃 같은 {@link RuntimeException} 은 여기까지 올라온다. 이 경로는 요청 스레드라 - * (워머와 달리) 위에 안전망이 없어 그대로 두면 500 이 된다 — 가중치일 뿐인 값 때문에 홈·추천이 죽는 것은 - * 이 클래스의 설계 의도와 정반대다. 빈 가중치로도 순위는 선다. + * 위에 안전망이 없어 그대로 두면 500 이 된다 — 가중치일 뿐인 값 때문에 홈·추천이 죽는 것은 이 클래스의 설계 + * 의도와 정반대다. 빈 가중치로도 순위는 선다. */ private Map stored() { Map byCode = read(); - if (byCode.isEmpty() && bootstrapping.compareAndSet(false, true)) { - try { - refresh(); - } catch (RuntimeException e) { - log.warn("방문자 집계 최초 적재 실패 — 빈 가중치로 진행합니다", e); - return byCode; - } finally { - bootstrapping.set(false); + if (!byCode.isEmpty()) { + return byCode; + } + if (inRetryCooldown()) { + // 왜 가중치 없이 나가는지 남긴다 — 폴백이 정상처럼 보이면 집계가 비어 있는 것을 아무도 모른다. + log.debug("방문자 집계가 비었지만 최초 적재 재시도 대기 중입니다 — 빈 가중치로 진행합니다"); + return byCode; + } + if (!bootstrapping.compareAndSet(false, true)) { + return byCode; + } + try { + if (refresh()) { + return read(); } - return read(); + // 실패·빈 결과. refresh() 가 사유를 warn 으로 남겼으므로 여기서는 재시도만 늦춘다. + markBootstrapFailed(); + } catch (RuntimeException e) { + markBootstrapFailed(); + log.warn("방문자 집계 최초 적재 실패 — 빈 가중치로 진행합니다", e); + } finally { + bootstrapping.set(false); } return byCode; } + /** 최초 적재가 성과 없이 끝났음을 기록해 {@link #BOOTSTRAP_RETRY_COOLDOWN} 동안 재시도를 막는다. */ + private void markBootstrapFailed() { + lastFailedBootstrapNanos = System.nanoTime(); + } + + private boolean inRetryCooldown() { + Long failedAt = lastFailedBootstrapNanos; + return failedAt != null && System.nanoTime() - failedAt < BOOTSTRAP_RETRY_COOLDOWN.toNanos(); + } + private Map read() { Map byCode = new HashMap<>(); for (RegionVisitorAggregate row : aggregateRepository.findAll()) { diff --git a/src/main/java/com/offway/core/trip/service/dto/HomeResult.java b/src/main/java/com/offway/core/trip/service/dto/HomeResult.java index 83b7cd19..ebd36ebc 100644 --- a/src/main/java/com/offway/core/trip/service/dto/HomeResult.java +++ b/src/main/java/com/offway/core/trip/service/dto/HomeResult.java @@ -2,6 +2,7 @@ import com.offway.core.policy.domain.PolicyType; import com.offway.core.trip.domain.Category; +import com.offway.core.trip.domain.CategoryCounts; import com.offway.core.trip.domain.CrowdLevel; import com.offway.core.trip.domain.RegionContent; import java.util.List; @@ -13,8 +14,9 @@ * * @param remainingLeaveDays 남은 연차 (저장값. 설정한 적 없으면 null — 0 과 구분한다). 반차가 0.5 라 실수다 * @param regions 추천 지역 카드 (랭킹 top-N) + * @param categoryCounts 필터칩별 지역 수(#266). 홈이 그리는 칩이 개수를 지어내지 않게 함께 내린다 */ -public record HomeResult(Double remainingLeaveDays, List regions) { +public record HomeResult(Double remainingLeaveDays, List regions, CategoryCounts categoryCounts) { /** * @param regionId 지역 ID diff --git a/src/main/java/com/offway/core/trip/service/dto/RegionList.java b/src/main/java/com/offway/core/trip/service/dto/RegionList.java new file mode 100644 index 00000000..e52a3b68 --- /dev/null +++ b/src/main/java/com/offway/core/trip/service/dto/RegionList.java @@ -0,0 +1,79 @@ +package com.offway.core.trip.service.dto; + +import com.offway.core.common.response.PageResponse; +import com.offway.core.trip.domain.Category; +import com.offway.core.trip.domain.CrowdLevel; +import com.offway.core.trip.domain.RegionContent; +import java.util.List; +import org.springframework.data.domain.Page; + +/** + * 지역 목록 조회 결과(#266) — 한 페이지의 지역과 페이지 정보. + * + * @param regions 이 페이지의 지역들 (방문자 랭킹 내림차순) + * @param page 0부터 시작하는 페이지 번호 + * @param size 페이지 크기 + * @param totalElements 필터를 적용한 전체 건수 + * @param totalPages 전체 페이지 수 + */ +public record RegionList(List regions, int page, int size, long totalElements, int totalPages) + implements PageResponse.Paged { + + public RegionList { + regions = List.copyOf(regions); + } + + public static RegionList from(Page page) { + return new RegionList( + page.getContent(), page.getNumber(), page.getSize(), page.getTotalElements(), page.getTotalPages()); + } + + /** + * 목록에 실리는 지역 한 곳. 홈 카드와 같은 재료를 쓴다 — 같은 화면의 "더보기" 라 카드 모양이 달라질 이유가 없다. + * + * @param regionId 지역 ID + * @param sido 시도 + * @param sigungu 시군구 + * @param crowdLevel 한산도 뱃지 + * @param imageUrl 대표 이미지 URL (없으면 null) + * @param contentCount 볼거리 수 (인접 50km 병합 시 합산) + * @param categories 볼거리 카테고리 + * @param neighborIncluded 볼거리 부족으로 인접 50km 지역이 포함됐는지 — {@code contentCount} 가 무엇의 합인지 설명한다 + */ + public record Item( + long regionId, + String sido, + String sigungu, + CrowdLevel crowdLevel, + String imageUrl, + int contentCount, + List categories, + boolean neighborIncluded) { + + /** + * 랭킹·콘텐츠·대표 사진을 한 항목으로 조립한다. + * + *

대표 사진은 갤러리에서 고른 것이 먼저고, 못 골랐으면 콘텐츠 표본의 이미지로 내려간다(#196) — + * {@link HomeResult.RegionCard#of} 와 같은 사다리다. + * + * @param heroPhotoUrl 갤러리에서 고른 대표 사진. 못 골랐으면 null + */ + public static Item of( + long regionId, + String sido, + String sigungu, + CrowdLevel crowdLevel, + RegionContent content, + String heroPhotoUrl) { + return new Item( + regionId, + sido, + sigungu, + crowdLevel, + heroPhotoUrl != null ? heroPhotoUrl : content.imageUrl(), + content.contentCount(), + content.categories(), + content.neighborIncluded()); + } + } +} diff --git a/src/test/java/com/offway/core/trip/controller/CategoryIntegrationTest.java b/src/test/java/com/offway/core/trip/controller/CategoryIntegrationTest.java index f3ab4945..d4ac34a9 100644 --- a/src/test/java/com/offway/core/trip/controller/CategoryIntegrationTest.java +++ b/src/test/java/com/offway/core/trip/controller/CategoryIntegrationTest.java @@ -16,6 +16,9 @@ @WithMockUser class CategoryIntegrationTest { + /** 시드된 인구감소지역 수(행안부 고시) — {@code ALL} 칩의 개수와 같다. */ + private static final int SEEDED_REGIONS = 89; + @Autowired private MockMvc mockMvc; @@ -36,4 +39,19 @@ class CategoryIntegrationTest { .andExpect(jsonPath("$.data.categories[4].key").value("FOOD")) .andExpect(jsonPath("$.data.categories[4].label").value("맛집")); } + + /** + * 칩마다 지역 수를 함께 준다(#266) — 없으면 화면이 개수를 지어낸다("전부 1건"). + * + *

{@code ALL} 만 단언한다. 나머지 칩은 적재된 지역 콘텐츠에 따라 달라지는 값이라 여기서 잠그면 콘텐츠와 함께 흔들린다 — + * 필터 결과와 개수가 일치하는지는 콘텐츠를 직접 적재하는 {@code RegionListIntegrationTest} 가 소유한다. + */ + @Test + void 칩마다_지역_수를_함께_준다() throws Exception { + mockMvc.perform(get("/api/v1/categories")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.categories[0].key").value("ALL")) + .andExpect(jsonPath("$.data.categories[0].regionCount").value(SEEDED_REGIONS)) + .andExpect(jsonPath("$.data.categories[1].regionCount").exists()); + } } diff --git a/src/test/java/com/offway/core/trip/controller/HomeIntegrationTest.java b/src/test/java/com/offway/core/trip/controller/HomeIntegrationTest.java index 4b5814d8..c81c99e2 100644 --- a/src/test/java/com/offway/core/trip/controller/HomeIntegrationTest.java +++ b/src/test/java/com/offway/core/trip/controller/HomeIntegrationTest.java @@ -103,6 +103,11 @@ private static TourPoiResult content() { .andExpect(jsonPath("$.data.user.remainingLeaveDays").value(13.0)) .andExpect(jsonPath("$.data.filters.length()").value(5)) .andExpect(jsonPath("$.data.filters[0].key").value("ALL")) + // 칩 개수를 함께 내린다(#266) — 앱이 "전부 1건" 으로 채우던 자리다. 89곳 전부에 관광지(NA) + // 콘텐츠를 적재했으므로 ALL·SIGHT 가 둘 다 89 다. + .andExpect(jsonPath("$.data.filters[0].regionCount").value(89)) + .andExpect(jsonPath("$.data.filters[1].key").value("SIGHT")) + .andExpect(jsonPath("$.data.filters[1].regionCount").value(89)) .andExpect(jsonPath("$.data.recommendedRegions.length()").value(6)) .andExpect(jsonPath("$.data.recommendedRegions[0].name").exists()) .andExpect(jsonPath("$.data.recommendedRegions[0].crowdLevel").value("LOW")) diff --git a/src/test/java/com/offway/core/trip/controller/RegionListIntegrationTest.java b/src/test/java/com/offway/core/trip/controller/RegionListIntegrationTest.java new file mode 100644 index 00000000..20a16662 --- /dev/null +++ b/src/test/java/com/offway/core/trip/controller/RegionListIntegrationTest.java @@ -0,0 +1,294 @@ +package com.offway.core.trip.controller; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.offway.core.trip.domain.VisitorType; +import com.offway.core.trip.infrastructure.datalab.StubTourDataLabClient; +import com.offway.core.trip.infrastructure.datalab.TourDataLabClient; +import com.offway.core.trip.infrastructure.datalab.dto.RegionVisitor; +import com.offway.core.trip.infrastructure.datalab.dto.TourVisitorResult; +import com.offway.core.trip.infrastructure.tour.StubTourApiClient; +import com.offway.core.trip.infrastructure.tour.TourApiClient; +import com.offway.core.trip.infrastructure.tour.dto.TourPoi; +import com.offway.core.trip.infrastructure.tour.dto.TourPoiResult; +import com.offway.core.trip.service.RegionContentProvider; +import com.offway.core.trip.service.RegionContentRefreshService; +import com.offway.core.trip.service.RegionRankingService; +import java.time.LocalDate; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; + +/** + * 지역 목록 API 계약(#266) — "더보기" 가 홈 상위 6곳 너머를 볼 수 있는지, 페이지 경계가 잠겨 있는지. + * + *

대상은 마이그레이션이 시드한 인구감소지역 89곳 전부다. + */ +@SpringBootTest +@AutoConfigureMockMvc +@WithMockUser +class RegionListIntegrationTest { + + private static final String URL = "/api/v1/regions"; + + /** 시드된 인구감소지역 수(행안부 고시). 기본 페이지 20 기준 5페이지, 마지막 페이지 9건. */ + private static final int SEEDED_REGIONS = 89; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private StubTourDataLabClient dataLabClient; + + @Autowired + private StubTourApiClient tourApiClient; + + @Autowired + private RegionRankingService regionRankingService; + + @Autowired + private RegionContentProvider regionContentProvider; + + @Autowired + private RegionContentRefreshService regionContentRefreshService; + + // 랭킹·콘텐츠 캐시는 공유 싱글톤 — 각 테스트가 자기 stub 시나리오를 타도록 비운다(DB 롤백에 준하는 격리). + @BeforeEach + void evictCaches() { + regionRankingService.evictCache(); + regionContentProvider.evictCache(); + } + + @TestConfiguration + static class StubConfig { + + @Bean + @Primary + TourDataLabClient stubTourDataLabClient() { + return new StubTourDataLabClient(); + } + + @Bean + @Primary + TourApiClient stubTourApiClient() { + return new StubTourApiClient(); + } + } + + /** 볼거리가 충분한 지역 콘텐츠 — 대표 이미지·categories(NA → 관광지). 89곳이 전부 같은 값을 받는다. */ + private static TourPoiResult content() { + TourPoi poi = new TourPoi("126508", 12, "NA", "가사동백숲해변", "전남 완도군", 34.36, 126.92, "http://img/1.jpg", null); + return new TourPoiResult(List.of(poi), 38); + } + + /** 방문자 집계가 실제로 채워지는 응답 — 이게 있어야 랭킹이 최초 적재 경로를 타지 않는다(정상 상태). */ + private static TourVisitorResult visitors() { + RegionVisitor visitor = new RegionVisitor( + "51770", "정선군", LocalDate.now().minusMonths(1).withDayOfMonth(1), VisitorType.DOMESTIC, 1000.0); + return new TourVisitorResult(List.of(visitor), 1); + } + + /** 콘텐츠를 stub 으로 적재한다 — 요청 경로는 저장된 값만 읽는다(#193). */ + private void loadContent() { + dataLabClient.respond(TourVisitorResult::empty); + tourApiClient.respond(RegionListIntegrationTest::content); + regionContentRefreshService.refresh(); + } + + @Test + void 첫_페이지를_페이지_메타와_함께_내린다() throws Exception { + loadContent(); + + mockMvc.perform(get(URL)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value(200)) + .andExpect(jsonPath("$.code").value("OK")) + .andExpect(jsonPath("$.detail").value("요청이 정상 처리되었습니다.")) + .andExpect(jsonPath("$.data.regions.length()").value(20)) + .andExpect(jsonPath("$.data.regions[0].regionId").exists()) + .andExpect(jsonPath("$.data.regions[0].name").isNotEmpty()) + .andExpect(jsonPath("$.data.regions[0].crowdLevel").value("LOW")) + .andExpect(jsonPath("$.data.regions[0].imageUrl").value("http://img/1.jpg")) + .andExpect(jsonPath("$.data.regions[0].contentCount").value(38)) + .andExpect(jsonPath("$.data.regions[0].categories[0].key").value("SIGHT")) + .andExpect(jsonPath("$.data.regions[0].categories[0].label").value("관광지")) + // 페이지 메타는 data 안이 아니라 공통 래퍼의 pageResponse 로 나간다(api-convention). + .andExpect(jsonPath("$.data.page").doesNotExist()) + .andExpect(jsonPath("$.pageResponse.page").value(0)) + .andExpect(jsonPath("$.pageResponse.size").value(20)) + .andExpect(jsonPath("$.pageResponse.totalElements").value(SEEDED_REGIONS)) + .andExpect(jsonPath("$.pageResponse.totalPages").value(5)); + } + + /** 이 API 가 생긴 이유 그대로 — 더보기가 홈의 6곳이 아니라 새 지역을 줘야 한다. */ + @Test + void 다음_페이지는_앞_페이지와_다른_지역을_준다() throws Exception { + loadContent(); + + String page0 = mockMvc.perform(get(URL).param("page", "0")) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + String page1 = mockMvc.perform(get(URL).param("page", "1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.pageResponse.page").value(1)) + .andExpect(jsonPath("$.data.regions.length()").value(20)) + .andReturn() + .getResponse() + .getContentAsString(); + + assertNotEquals(page0, page1); + } + + @Test + void 마지막_페이지는_남은_만큼만_준다() throws Exception { + loadContent(); + + mockMvc.perform(get(URL).param("page", "4")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.regions.length()").value(SEEDED_REGIONS - 80)) + .andExpect(jsonPath("$.pageResponse.page").value(4)) + .andExpect(jsonPath("$.pageResponse.totalPages").value(5)); + } + + /** 무한 스크롤은 마지막 다음 페이지를 자연스럽게 한 번 더 요청한다 — 오류가 아니라 빈 목록이다. */ + @Test + void 범위를_벗어난_페이지는_빈_목록이다() throws Exception { + loadContent(); + + mockMvc.perform(get(URL).param("page", "99")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("OK")) + .andExpect(jsonPath("$.data.regions").isEmpty()) + .andExpect(jsonPath("$.pageResponse.totalElements").value(SEEDED_REGIONS)); + } + + /** 잘못된 값은 거절하지 않고 자른다 — 400 으로 끊으면 화면이 통째로 빈다(Paging 규약). */ + @Test + void 음수_페이지는_첫_페이지로_자른다() throws Exception { + loadContent(); + + mockMvc.perform(get(URL).param("page", "-5")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.pageResponse.page").value(0)) + .andExpect(jsonPath("$.data.regions.length()").value(20)); + } + + /** 상한이 없으면 size=9999 한 번으로 페이지네이션이 없던 때와 같아진다. */ + @Test + void 페이지_크기_상한을_넘겨도_상한까지만_준다() throws Exception { + loadContent(); + + mockMvc.perform(get(URL).param("size", "9999")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.pageResponse.size").value(100)) + .andExpect(jsonPath("$.data.regions.length()").value(SEEDED_REGIONS)); + } + + @Test + void 카테고리로_좁혀_조회한다() throws Exception { + loadContent(); + + // 적재된 콘텐츠가 전부 관광지(NA)라 SIGHT 는 89곳, 숙박은 0곳이다. + mockMvc.perform(get(URL).param("category", "SIGHT").param("size", "5")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.regions.length()").value(5)) + .andExpect(jsonPath("$.pageResponse.totalElements").value(SEEDED_REGIONS)); + + mockMvc.perform(get(URL).param("category", "STAY")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.regions").isEmpty()) + .andExpect(jsonPath("$.pageResponse.totalElements").value(0)); + } + + /** 필터 결과와 칩 개수가 같은 판정을 쓰는지 — 어긋나면 "89곳" 칩을 눌렀는데 다른 수가 나온다. */ + @Test + void 칩_개수는_그_카테고리로_좁힌_전체_건수와_같다() throws Exception { + loadContent(); + + mockMvc.perform(get("/api/v1/categories")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.categories[?(@.key == 'ALL')].regionCount").value(SEEDED_REGIONS)) + .andExpect(jsonPath("$.data.categories[?(@.key == 'SIGHT')].regionCount").value(SEEDED_REGIONS)) + .andExpect(jsonPath("$.data.categories[?(@.key == 'STAY')].regionCount").value(0)); + } + + /** + * 이 API 의 비용을 잠근다 — 89곳을 페이지로 끊어 주는데 페이지마다 외부를 부르면 더보기 몇 번으로 TourAPI 일일 한도가 마른다. + * + *

두 외부를 다 센다. 지역 콘텐츠(TourAPI)와 방문자 집계(관광빅데이터)가 각각 다른 경로라, 하나만 세면 나머지가 + * 조용히 새어 나간다. 실제로 관광빅데이터는 집계가 비어 있는 동안 요청마다 최초 적재를 시도하므로, 여기서는 집계를 채워 + * 정상 상태를 만든 뒤 0 을 단언한다. 집계가 빈 degrade 상태는 아래 테스트가 따로 잠근다. + */ + @Test + void 목록_조회는_외부_API_를_한_번도_부르지_않는다() throws Exception { + loadContent(); + dataLabClient.respond(RegionListIntegrationTest::visitors); + regionRankingService.refresh(); + + AtomicInteger dataLabCalls = new AtomicInteger(); + dataLabClient.respond(() -> { + dataLabCalls.incrementAndGet(); + return TourVisitorResult.empty(); + }); + tourApiClient.resetAreaCallCount(); + + mockMvc.perform(get(URL)).andExpect(status().isOk()); + mockMvc.perform(get(URL).param("page", "1")).andExpect(status().isOk()); + mockMvc.perform(get(URL).param("category", "SIGHT")).andExpect(status().isOk()); + mockMvc.perform(get("/api/v1/categories")).andExpect(status().isOk()); + + assertEquals(0, tourApiClient.areaCallCount(), "지역 콘텐츠(TourAPI) 호출"); + assertEquals(0, dataLabCalls.get(), "방문자 집계(관광빅데이터) 호출"); + } + + /** + * 집계가 비어 외부가 계속 빈 결과를 줘도 재시도가 요청 수에 비례하면 안 된다. + * + *

이 집계를 채우는 경로가 요청 경로뿐이라 첫 시도 자체는 의도된 것이다. 문제는 그 다음이다 — 실패를 기억하지 않으면 + * 페이지를 넘길 때마다 다시 시도해 "더보기" 한 세션이 외부 호출 수십 건이 된다. 페이지네이션 API 라 요청 수가 곱해지는 + * 자리라서 상한이 필요하다. + */ + @Test + void 집계가_비어도_최초_적재를_요청마다_되풀이하지_않는다() throws Exception { + loadContent(); + AtomicInteger dataLabCalls = new AtomicInteger(); + dataLabClient.respond(() -> { + dataLabCalls.incrementAndGet(); + return TourVisitorResult.empty(); + }); + + mockMvc.perform(get(URL).param("page", "0")).andExpect(status().isOk()); + int afterFirstRequest = dataLabCalls.get(); + + for (int page = 1; page < 5; page++) { + mockMvc.perform(get(URL).param("page", String.valueOf(page))).andExpect(status().isOk()); + } + + assertTrue(afterFirstRequest > 0, "첫 요청은 최초 적재를 시도한다"); + assertEquals(afterFirstRequest, dataLabCalls.get(), "이후 페이지는 적재를 다시 시도하지 않는다"); + } + + @Test + void 없는_카테고리를_보내면_400이다() throws Exception { + mockMvc.perform(get(URL).param("category", "NOPE")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status").value(400)) + .andExpect(jsonPath("$.data").doesNotExist()); + } +} diff --git a/src/test/java/com/offway/core/trip/domain/CategoryCountsTest.java b/src/test/java/com/offway/core/trip/domain/CategoryCountsTest.java new file mode 100644 index 00000000..0a5fcf2b --- /dev/null +++ b/src/test/java/com/offway/core/trip/domain/CategoryCountsTest.java @@ -0,0 +1,61 @@ +package com.offway.core.trip.domain; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +class CategoryCountsTest { + + @Test + void ALL은_콘텐츠가_없는_지역까지_포함한_전체_지역_수다() { + List contents = List.of( + new RegionContent(10, "http://a.jpg", List.of(Category.SIGHT), false), + new RegionContent(3, null, List.of(Category.FOOD), false), + RegionContent.EMPTY); + + assertEquals(3, CategoryCounts.of(contents).of(Category.ALL)); + } + + @ParameterizedTest + @CsvSource({"ALL,3", "SIGHT,2", "FOOD,1", "STAY,0", "EXPERIENCE,0"}) + void 칩마다_그_칩으로_좁혔을_때_나오는_지역_수를_센다(Category category, int expected) { + List contents = List.of( + new RegionContent(10, null, List.of(Category.SIGHT), false), + new RegionContent(12, null, List.of(Category.SIGHT, Category.FOOD), false), + RegionContent.EMPTY); + + assertEquals(expected, CategoryCounts.of(contents).of(category)); + } + + /** 한 지역이 여러 칩을 갖고 있어도 칩마다 지역 하나로 센다 — 세는 단위는 볼거리가 아니라 지역이다. */ + @Test + void 여러_칩을_가진_지역은_칩마다_한_번씩만_센다() { + List contents = List.of( + new RegionContent(30, null, List.of(Category.SIGHT, Category.FOOD, Category.STAY), false)); + + CategoryCounts counts = CategoryCounts.of(contents); + + assertEquals(1, counts.of(Category.SIGHT)); + assertEquals(1, counts.of(Category.FOOD)); + assertEquals(1, counts.of(Category.STAY)); + assertEquals(1, counts.of(Category.ALL)); + } + + @Test + void 지역이_하나도_없으면_전부_0이다() { + CategoryCounts counts = CategoryCounts.of(List.of()); + + assertEquals(0, counts.of(Category.ALL)); + assertEquals(0, counts.of(Category.SIGHT)); + } + + /** 아직 세지 않은 상태를 "전부 0" 으로 읽어도 예외가 나지 않아야 한다 — 부팅 직후 조회가 여기 닿는다. */ + @Test + void 세지_않은_상태는_모든_칩이_0이다() { + assertEquals(0, CategoryCounts.EMPTY.of(Category.ALL)); + assertEquals(0, CategoryCounts.EMPTY.of(Category.SIGHT)); + } +} diff --git a/src/test/java/com/offway/core/trip/domain/RegionContentTest.java b/src/test/java/com/offway/core/trip/domain/RegionContentTest.java index f4ed5539..94a3b3fa 100644 --- a/src/test/java/com/offway/core/trip/domain/RegionContentTest.java +++ b/src/test/java/com/offway/core/trip/domain/RegionContentTest.java @@ -48,6 +48,23 @@ class RegionContentTest { assertEquals("http://a.jpg", hasImage.expandedWith(neighbor).imageUrl()); } + @Test + void 가진_카테고리의_칩에만_걸린다() { + RegionContent content = new RegionContent(10, null, List.of(Category.SIGHT, Category.FOOD), false); + + assertTrue(content.has(Category.SIGHT)); + assertTrue(content.has(Category.FOOD)); + assertFalse(content.has(Category.STAY)); + assertFalse(content.has(Category.EXPERIENCE)); + } + + /** {@code ALL} 은 필터가 아니라 전체 표지라, 볼거리가 하나도 없는 지역도 목록에서 빠지지 않는다. */ + @Test + void ALL은_콘텐츠가_비어도_항상_걸린다() { + assertTrue(RegionContent.EMPTY.has(Category.ALL)); + assertFalse(RegionContent.EMPTY.has(Category.SIGHT)); + } + @Test void 인접이_비어있으면_병합하지_않고_확장표시도_세우지_않는다() { RegionContent sparse = new RegionContent(3, "http://a.jpg", List.of(Category.SIGHT), false);