From 5770039d4e8426df776483d60e57cf2f49b20024 Mon Sep 17 00:00:00 2001 From: sevin98 Date: Fri, 14 Aug 2026 01:56:54 +0900 Subject: [PATCH 1/9] =?UTF-8?q?feat:=20=ED=95=84=ED=84=B0=EC=B9=A9?= =?UTF-8?q?=EC=97=90=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=EB=B3=84=20?= =?UTF-8?q?=EC=A7=80=EC=97=AD=20=EC=88=98=EB=A5=BC=20=ED=95=A8=EA=BB=98=20?= =?UTF-8?q?=EB=82=B4=EB=A6=B0=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 칩 목록이 라벨만 줘서 앱이 개수를 전부 1 로 채우고 있었다("있다/없다" 만 판별). 칩에 개수를 보여주거나 빈 칩을 가리려면 실제 수가 필요하다 - regionCount = 그 칩으로 좁혔을 때 나오는 지역 수. 목록 필터와 같은 판정 (RegionContent.has)을 쓴다. 판정이 둘로 갈리면 "12곳" 칩을 눌렀는데 9곳이 나오는, 예외도 로그도 없이 조용히 틀리는 어긋남이 생긴다 - 매 요청 세지 않는다. 세는 대상이 89곳 고정이고 그 콘텐츠는 주 1회 배치가 갈아끼운다. 기동 직후 1회 + 콘텐츠 적재 직후 무효화 + 1시간 안전망으로 둔다. TTL 만 두면 새 배포의 첫 적재 뒤 최대 한 시간 동안 "전부 0" 인 칩이 나간다 - 캐시 키 공간은 하나다 — 지역·사용자별로 갈리지 않아 상한을 설계할 키가 없다 - 칩을 노출하는 두 자리(GET /categories, 홈의 filters)가 같은 값을 쓰게 했다. 한쪽만 고치면 앱이 화면마다 다른 규칙을 갖는다 - 필터칩과 지역 카드의 분류 태그를 다른 타입으로 갈랐다(CategoryTagResponse). 한 타입이면 지역 카드마다 전체 지역 수가 따라붙어 그 지역의 수로 읽힌다. JSON 은 그대로 {key, label} 이다 --- .../core/trip/controller/CategoryApi.java | 13 +- .../trip/controller/CategoryController.java | 7 +- .../trip/controller/dto/CategoryResponse.java | 17 ++- .../controller/dto/CategoryTagResponse.java | 23 ++++ .../trip/controller/dto/HomeResponse.java | 8 +- .../dto/RegionRecommendResponse.java | 6 +- .../core/trip/domain/CategoryCounts.java | 47 ++++++++ .../core/trip/domain/RegionContent.java | 10 ++ .../offway/core/trip/service/HomeService.java | 4 +- .../service/RegionCategoryCountProvider.java | 114 ++++++++++++++++++ .../service/RegionContentRefreshService.java | 4 + .../core/trip/service/dto/HomeResult.java | 4 +- 12 files changed, 240 insertions(+), 17 deletions(-) create mode 100644 src/main/java/com/offway/core/trip/controller/dto/CategoryTagResponse.java create mode 100644 src/main/java/com/offway/core/trip/domain/CategoryCounts.java create mode 100644 src/main/java/com/offway/core/trip/service/RegionCategoryCountProvider.java 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/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/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..48bd2c53 --- /dev/null +++ b/src/main/java/com/offway/core/trip/service/RegionCategoryCountProvider.java @@ -0,0 +1,114 @@ +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 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시간"; + + private final RegionMaster regionMaster; + private final RegionContentProvider regionContentProvider; + + /** + * 마지막으로 센 결과. {@code null} 이 "아직 세지 않았다" 다 — 결과가 전부 0 인 것과 구별해야 다음 조회가 다시 센다. + */ + private volatile Snapshot snapshot; + + /** 센 결과와 센 시각(단조 시계). 벽시계로 재면 시스템 시각 보정에 간격이 늘거나 즉시 만료된다. */ + private record Snapshot(CategoryCounts counts, long countedNanos) { + + private boolean isStale() { + return System.nanoTime() - countedNanos >= RECOUNT_INTERVAL.toNanos(); + } + } + + /** + * 기동이 끝난 뒤 한 번 센다 — 첫 요청이 계산을 떠안지 않게. 마이그레이션(Flyway)이 시드를 넣은 뒤여야 해서 + * {@code @PostConstruct} 가 아니라 이 이벤트를 쓴다({@link RegionMaster} 와 같은 이유). + */ + @EventListener(ApplicationReadyEvent.class) + public void warm() { + recount(); + } + + /** 지금 칩별 지역 수. 오래됐거나 아직 안 셌으면 그 자리에서 센다(89행 한 번). */ + public CategoryCounts counts() { + Snapshot current = snapshot; + if (current != null && !current.isStale()) { + return current.counts(); + } + return recount(); + } + + /** + * 다음 조회가 다시 세게 한다 — 지역 콘텐츠 적재가 끝나면 부른다. + * + *

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

이중 계산을 막지 않는다({@link RegionMaster} 와 같은 판단) — 두 스레드가 동시에 들어와도 같은 입력에서 같은 결과를 만들어 + * 같은 값으로 덮을 뿐이고, 락을 걸면 89행 조회 하나 때문에 요청 스레드가 서로를 기다린다. + */ + private CategoryCounts recount() { + List all = regionMaster.all(); + if (all.isEmpty()) { + // 시드가 아직 없는 상태(초기 부팅)다. 스냅샷을 세우지 않아 다음 조회가 다시 시도한다 — + // 여기서 "전부 0" 을 굳히면 시드가 들어온 뒤에도 한 시간 동안 빈 칩이 나간다. + log.warn("지역이 없어 필터칩 개수를 세지 못했습니다 — 다음 조회에서 다시 셉니다"); + 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); + snapshot = new Snapshot(counted, System.nanoTime()); + log.debug("필터칩 개수 집계 지역={} 콘텐츠={} 결과={}", all.size(), stored.size(), counted.byCategory()); + return counted; + } +} 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/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 From 880433f4d9d7d8e7a07d9c131fe96b7172be8d35 Mon Sep 17 00:00:00 2001 From: sevin98 Date: Fri, 14 Aug 2026 01:57:11 +0900 Subject: [PATCH 2/9] =?UTF-8?q?feat:=20=EC=9D=B8=EA=B5=AC=EA=B0=90?= =?UTF-8?q?=EC=86=8C=EC=A7=80=EC=97=AD=20=EB=AA=A9=EB=A1=9D=20API=20(GET?= =?UTF-8?q?=20/api/v1/regions)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "더보기" 화면이 홈 응답을 재사용하고 있어 89곳 중 랭킹 상위 6곳만 보였다. 눌러도 새 데이터가 오지 않았고 페이징·카테고리 조회도 불가능했다 - 재료는 전부 이미 DB 에 있다 — 한산도는 방문자 집계, 볼거리 수·카테고리·이미지는 적재된 지역 콘텐츠, 대표 사진은 관광사진 갤러리. 목록 한 번의 외부 호출은 0 건이고 DB 조회 4회로 끝난다. 페이지마다 외부를 부르는 설계면 더보기 몇 번으로 TourAPI 일일 한도가 마른다 - 대표 사진은 이 페이지에 실릴 지역 것만 고른다. 89곳 전부를 고르면 안 보여줄 카드까지 계산한다 - 정렬·필터·페이지 자르기는 메모리에서 한다. 대상이 89곳 고정이고, 랭킹 점수가 DB 컬럼이 아니라 전체 표본으로 계산되는 값이라 SQL 로 내릴 정렬이 아니다 - sort 파라미터를 두지 않았다. popular 는 홈·추천이 쓰는 그 랭킹이라 기본값이 되고, reach 는 출발지 좌표가 있어야 정의되는데 이 엔드포인트는 그걸 받지 않는다 — 그쪽은 POST /regions/recommendations 가 이미 소유한다. 값 하나만 받는 정렬 키는 있는 척하는 정렬이라 아예 열지 않았다(FE 가 sort=popular 를 보내도 무시된다) - 트랜잭션으로 감싸지 않는다. 랭킹이 집계가 비었을 때 한 번 외부를 부르는 경로를 갖고 있어, 묶으면 그 호출이 read-timeout 동안 DB 커넥션을 잡는다(HomeService 와 같은 판단) - 페이지 메타는 data 안이 아니라 공통 래퍼의 pageResponse 로 나간다. 프론트가 적어 준 위치와 다르지만 모든 목록 API 가 같은 자리를 쓰게 하는 쪽을 택했다 --- .../core/trip/controller/RegionListApi.java | 42 +++++++ .../trip/controller/RegionListController.java | 31 +++++ .../controller/dto/RegionListResponse.java | 61 ++++++++++ .../core/trip/service/RegionListService.java | 113 ++++++++++++++++++ .../core/trip/service/dto/RegionList.java | 79 ++++++++++++ 5 files changed, 326 insertions(+) create mode 100644 src/main/java/com/offway/core/trip/controller/RegionListApi.java create mode 100644 src/main/java/com/offway/core/trip/controller/RegionListController.java create mode 100644 src/main/java/com/offway/core/trip/controller/dto/RegionListResponse.java create mode 100644 src/main/java/com/offway/core/trip/service/RegionListService.java create mode 100644 src/main/java/com/offway/core/trip/service/dto/RegionList.java 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/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/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/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()); + } + } +} From c3a0a279cb6c5289d7563d75d9cee90b9fc75f45 Mon Sep 17 00:00:00 2001 From: sevin98 Date: Fri, 14 Aug 2026 01:57:22 +0900 Subject: [PATCH 3/9] =?UTF-8?q?test:=20=EC=A7=80=EC=97=AD=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B2=BD=EA=B3=84?= =?UTF-8?q?=C2=B7=EC=99=B8=EB=B6=80=20=ED=98=B8=EC=B6=9C=200=EA=B1=B4?= =?UTF-8?q?=C2=B7=EC=B9=A9=20=EA=B0=9C=EC=88=98=20=EC=9D=BC=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 페이지 경계를 전부 잠갔다: 첫 페이지·마지막 페이지(9건)·범위 밖 페이지(빈 목록) ·음수 page(0으로 자름)·상한 초과 size(100으로 자름) - 더보기가 실제로 새 데이터를 주는지 단언한다(0페이지와 1페이지 응답이 다른지). 이 작업이 고치려는 증상 그 자체라 회귀하면 여기서 걸린다 - 목록 3회 + 칩 1회를 부른 뒤 지역 콘텐츠 외부 호출 횟수가 0 인지 단언한다. 이 API 의 비용을 코드가 아니라 테스트가 잠그게 한 것. 관광빅데이터 최초 적재는 집계가 통째로 빌 때만 도는 별도 경로라 세지 않는다 - 칩 개수와 그 카테고리로 좁힌 pageResponse.totalElements 가 같은지 단언한다 - CategoryIntegrationTest 는 ALL 만 단언한다. 나머지 칩은 적재된 콘텐츠에 따라 달라지는 값이라 여기서 잠그면 콘텐츠와 함께 흔들린다 --- .../controller/CategoryIntegrationTest.java | 18 ++ .../trip/controller/HomeIntegrationTest.java | 5 + .../controller/RegionListIntegrationTest.java | 245 ++++++++++++++++++ .../core/trip/domain/CategoryCountsTest.java | 61 +++++ .../core/trip/domain/RegionContentTest.java | 17 ++ 5 files changed, 346 insertions(+) create mode 100644 src/test/java/com/offway/core/trip/controller/RegionListIntegrationTest.java create mode 100644 src/test/java/com/offway/core/trip/domain/CategoryCountsTest.java 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..d2bd8bf3 --- /dev/null +++ b/src/test/java/com/offway/core/trip/controller/RegionListIntegrationTest.java @@ -0,0 +1,245 @@ +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.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.infrastructure.datalab.StubTourDataLabClient; +import com.offway.core.trip.infrastructure.datalab.TourDataLabClient; +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.util.List; +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); + } + + /** 콘텐츠를 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 일일 한도가 마른다. + * + *

세는 대상은 지역 콘텐츠 조회다(지역당 자기 + 인접 최대 3곳이라 팬아웃이 가장 큰 호출). 관광빅데이터는 방문자 집계가 + * 통째로 비어 있을 때만 한 번 시도하는 별도 경로라 여기서 세지 않는다 — 홈·추천과 공유하는 기존 동작이다. + */ + @Test + void 목록_조회는_지역_콘텐츠_외부_호출을_한_번도_하지_않는다() throws Exception { + loadContent(); + 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()); + } + + @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); From 49a379a7aa457a91e5a0601f637bca25fa7c02f5 Mon Sep 17 00:00:00 2001 From: sevin98 Date: Fri, 14 Aug 2026 01:57:28 +0900 Subject: [PATCH 4/9] =?UTF-8?q?docs:=20api-spec=20=EC=97=90=20=EC=A7=80?= =?UTF-8?q?=EC=97=AD=20=EB=AA=A9=EB=A1=9D=20API=C2=B7=ED=95=84=ED=84=B0?= =?UTF-8?q?=EC=B9=A9=20regionCount=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - living doc 이라 엔드포인트가 늘면 함께 갱신한다 - 지역 목록은 sort 파라미터가 없는 이유(도달시간 순은 출발지 좌표가 필요)와 페이지 메타가 공통 래퍼에 실린다는 점을 함께 적었다 — 프론트가 적어 준 계약과 다른 자리라 문서에서 먼저 드러나야 한다 --- docs/specs/api-spec.md | 49 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 43 insertions(+), 6 deletions(-) diff --git a/docs/specs/api-spec.md b/docs/specs/api-spec.md index a661b527..949355e9 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,15 @@ > 🎯 필터칩 목록 · **기능 F6** · 서버 내부에서 `SIGHT`→lclsSystm(NA+HS+VE+LS+EV) 등 매핑 +- `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 +285,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 | From 59480ed1219e34686dc2823809a98e451e9f17af Mon Sep 17 00:00:00 2001 From: sevin98 Date: Fri, 14 Aug 2026 02:13:46 +0900 Subject: [PATCH 5/9] =?UTF-8?q?fix:=20=EB=B0=A9=EB=AC=B8=EC=9E=90=20?= =?UTF-8?q?=EC=A7=91=EA=B3=84=EA=B0=80=20=EB=B9=84=EB=A9=B4=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=9A=94=EC=B2=AD=EB=A7=88=EB=8B=A4=20=EA=B4=80?= =?UTF-8?q?=EA=B4=91=EB=B9=85=EB=8D=B0=EC=9D=B4=ED=84=B0=EB=A5=BC=20?= =?UTF-8?q?=EB=8B=A4=EC=8B=9C=20=EB=B6=80=EB=A5=B4=EB=8D=98=20=EA=B2=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 집계가 비어 있는 동안 `stored()` 가 요청마다 최초 적재를 다시 시도했다. `bootstrapping` 플래그는 동시 요청만 막고 finally 에서 바로 풀려, 순차로 들어오는 요청은 전부 새로 시도한다. 결과가 계속 비면(외부 장애·한도 소진· 미발행) 재시도가 트래픽에 비례해 늘어난다. 실측(2026-08-14, Testcontainers MySQL): 집계가 빈 상태에서 목록 5회에 관광빅데이터 15건(요청당 3건 = MAX_MONTHS_BACK 3개월 역행)이 나갔다. "더보기" 는 한 세션에 여러 페이지를 넘기는 화면이라 이 곱셈이 그대로 한도로 간다. 성과 없이 끝난 적재를 기억해 5분간 재시도를 막는다. 재시도가 트래픽과 무관하게 인스턴스당 시간당 12회로 묶이고, 외부가 회복하면 5분 안에 스스로 채워진다. 성공하면 집계가 차서 이 경로를 더 타지 않으므로 정상 상태 비용은 그대로 0 이다. `evictCache()` 는 대기도 함께 푼다 — 비워 놓고 대기가 남으면 강제 갱신이 아무 일도 하지 않는다. `refresh()` 의 "워머가 부른다" 주석도 함께 고친다. 이 집계에는 스케줄러도 워머도 없고, 부르는 곳은 요청 경로의 최초 적재 하나뿐이다. --- .../trip/service/RegionRankingService.java | 84 +++++++++++++++---- 1 file changed, 69 insertions(+), 15 deletions(-) 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()) { From be5d7e65ac91fe736144719f75c33f73445e9a1f Mon Sep 17 00:00:00 2001 From: sevin98 Date: Fri, 14 Aug 2026 02:13:56 +0900 Subject: [PATCH 6/9] =?UTF-8?q?test:=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=EC=9D=98=20=EC=99=B8=EB=B6=80=20=ED=98=B8=EC=B6=9C=20?= =?UTF-8?q?0=EA=B1=B4=EC=9D=84=20=EB=91=90=20=EC=99=B8=EB=B6=80=20?= =?UTF-8?q?=EB=AA=A8=EB=91=90=EB=A1=9C=20=EC=9E=A0=EA=B7=BC=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "목록 한 번 = 외부 호출 0건" 을 잠근다던 단언이 TourAPI(areaCallCount)만 셌다. 정작 그 테스트는 방문자 집계를 비운 채 돌아서, 매 요청 관광빅데이터를 3건씩 부르는 동안 0 을 단언하고 지나갔다 — 가장 비싼 쪽을 세지 않은 셈이다. 집계를 채워 정상 상태를 만든 뒤 두 외부 모두 0 을 단언한다. 그리고 집계가 빈 degrade 상태에서 페이지를 5번 넘겨도 최초 적재를 되풀이하지 않는지를 따로 잠근다(직전 커밋의 재시도 간격). 이 테스트는 그 수정 없이는 실패한다 — 되돌려 확인했다. --- .../controller/RegionListIntegrationTest.java | 57 +++++++++++++++++-- 1 file changed, 53 insertions(+), 4 deletions(-) diff --git a/src/test/java/com/offway/core/trip/controller/RegionListIntegrationTest.java b/src/test/java/com/offway/core/trip/controller/RegionListIntegrationTest.java index d2bd8bf3..20a16662 100644 --- a/src/test/java/com/offway/core/trip/controller/RegionListIntegrationTest.java +++ b/src/test/java/com/offway/core/trip/controller/RegionListIntegrationTest.java @@ -2,12 +2,15 @@ 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; @@ -16,7 +19,9 @@ 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; @@ -90,6 +95,13 @@ private static TourPoiResult content() { 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); @@ -219,12 +231,21 @@ private void loadContent() { /** * 이 API 의 비용을 잠근다 — 89곳을 페이지로 끊어 주는데 페이지마다 외부를 부르면 더보기 몇 번으로 TourAPI 일일 한도가 마른다. * - *

세는 대상은 지역 콘텐츠 조회다(지역당 자기 + 인접 최대 3곳이라 팬아웃이 가장 큰 호출). 관광빅데이터는 방문자 집계가 - * 통째로 비어 있을 때만 한 번 시도하는 별도 경로라 여기서 세지 않는다 — 홈·추천과 공유하는 기존 동작이다. + *

두 외부를 다 센다. 지역 콘텐츠(TourAPI)와 방문자 집계(관광빅데이터)가 각각 다른 경로라, 하나만 세면 나머지가 + * 조용히 새어 나간다. 실제로 관광빅데이터는 집계가 비어 있는 동안 요청마다 최초 적재를 시도하므로, 여기서는 집계를 채워 + * 정상 상태를 만든 뒤 0 을 단언한다. 집계가 빈 degrade 상태는 아래 테스트가 따로 잠근다. */ @Test - void 목록_조회는_지역_콘텐츠_외부_호출을_한_번도_하지_않는다() throws Exception { + 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()); @@ -232,7 +253,35 @@ private void loadContent() { mockMvc.perform(get(URL).param("category", "SIGHT")).andExpect(status().isOk()); mockMvc.perform(get("/api/v1/categories")).andExpect(status().isOk()); - assertEquals(0, tourApiClient.areaCallCount()); + 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 From 32b0d6e8e88f8f1565ffc668a9e6999d729b3129 Mon Sep 17 00:00:00 2001 From: sevin98 Date: Fri, 14 Aug 2026 21:10:32 +0900 Subject: [PATCH 7/9] =?UTF-8?q?fix:=20=EC=A7=91=EA=B3=84=20=EC=A4=91=20?= =?UTF-8?q?=EC=BD=98=ED=85=90=EC=B8=A0=EA=B0=80=20=EA=B0=B1=EC=8B=A0?= =?UTF-8?q?=EB=90=98=EB=A9=B4=20=EC=98=9B=20=EA=B2=B0=EA=B3=BC=EB=A5=BC=20?= =?UTF-8?q?=EC=84=B8=EC=9A=B0=EC=A7=80=20=EC=95=8A=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 재계산은 콘텐츠를 읽고 나서 결과를 세운다. 그 사이 적재가 끝나 invalidate() 가 스냅샷을 버리면, 진행 중이던 재계산이 옛 입력으로 만든 결과를 새 시각으로 다시 세운다 — 버린 것이 되살아나 새 콘텐츠가 최대 1시간 동안 칩에 안 나온다 - 적재 직후 버리는 것이 이 클래스의 설계인데 그 자리가 무력해지는 경로였다 - 세대 번호를 읽기 전에 적어 두고 세울 때 대조한다. 달라졌으면 세우지 않고 다음 조회가 새 입력으로 다시 센다 - 지역이 빈 상태의 warn 을 창당 한 줄로 눌렀다. 다시 세는 것을 억제하는 것이 아니다 — RegionMaster 가 이미 빈 상태의 재적재를 자기 간격으로 막고 있어, 남는 것은 조회마다 쌓이는 로그뿐이었다 --- .../service/RegionCategoryCountProvider.java | 44 ++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/offway/core/trip/service/RegionCategoryCountProvider.java b/src/main/java/com/offway/core/trip/service/RegionCategoryCountProvider.java index 48bd2c53..8df691f8 100644 --- a/src/main/java/com/offway/core/trip/service/RegionCategoryCountProvider.java +++ b/src/main/java/com/offway/core/trip/service/RegionCategoryCountProvider.java @@ -7,6 +7,7 @@ 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; @@ -42,6 +43,14 @@ public class RegionCategoryCountProvider { 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; @@ -50,6 +59,20 @@ public class RegionCategoryCountProvider { */ private volatile Snapshot snapshot; + /** + * 원본이 바뀐 횟수 — 세는 동안 바뀌었는지를 가른다. + * + *

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

읽기 전에 세대를 적어 두고 세울 때 대조한다. 달라졌으면 세우지 않는다 — 다음 조회가 새 입력으로 다시 센다. + */ + private final AtomicLong generation = new AtomicLong(); + + /** 지역이 비어 마지막으로 warn 을 남긴 시각(단조 시계). */ + private volatile long lastEmptyWarnNanos = Long.MIN_VALUE; + /** 센 결과와 센 시각(단조 시계). 벽시계로 재면 시스템 시각 보정에 간격이 늘거나 즉시 만료된다. */ private record Snapshot(CategoryCounts counts, long countedNanos) { @@ -83,6 +106,7 @@ public CategoryCounts counts() { * 어차피 다음 조회가 센다. */ public void invalidate() { + generation.incrementAndGet(); snapshot = null; } @@ -93,11 +117,12 @@ public void invalidate() { * 같은 값으로 덮을 뿐이고, 락을 걸면 89행 조회 하나 때문에 요청 스레드가 서로를 기다린다. */ private CategoryCounts recount() { + long startedAt = generation.get(); List all = regionMaster.all(); if (all.isEmpty()) { // 시드가 아직 없는 상태(초기 부팅)다. 스냅샷을 세우지 않아 다음 조회가 다시 시도한다 — // 여기서 "전부 0" 을 굳히면 시드가 들어온 뒤에도 한 시간 동안 빈 칩이 나간다. - log.warn("지역이 없어 필터칩 개수를 세지 못했습니다 — 다음 조회에서 다시 셉니다"); + warnEmptyRegions(); return CategoryCounts.EMPTY; } List regionIds = all.stream().map(Region::getId).toList(); @@ -107,8 +132,25 @@ private CategoryCounts recount() { .map(regionId -> stored.getOrDefault(regionId, RegionContent.EMPTY)) .toList(); CategoryCounts counted = CategoryCounts.of(contents); + if (generation.get() != startedAt) { + // 세는 동안 적재가 끝났다. 이 결과는 옛 입력에서 나왔으므로 세우지 않는다 — 세우면 방금 버린 것이 + // 되살아나 새 콘텐츠가 안전망 간격만큼 늦게 반영된다. 다음 조회가 새 입력으로 다시 센다. + log.debug("집계 중 지역 콘텐츠가 갱신돼 결과를 버립니다 — 다음 조회가 다시 셉니다"); + return counted; + } snapshot = new Snapshot(counted, System.nanoTime()); log.debug("필터칩 개수 집계 지역={} 콘텐츠={} 결과={}", all.size(), stored.size(), counted.byCategory()); return counted; } + + /** 같은 warn 이 조회마다 쌓이지 않게 창당 한 줄로 누른다. 창이 지나면 다시 남겨 장애 지속도 보이게 한다. */ + private void warnEmptyRegions() { + long now = System.nanoTime(); + long last = lastEmptyWarnNanos; + if (last != Long.MIN_VALUE && now - last < EMPTY_WARN_INTERVAL.toNanos()) { + return; + } + lastEmptyWarnNanos = now; + log.warn("지역이 없어 필터칩 개수를 세지 못했습니다 — 다음 조회에서 다시 셉니다"); + } } From 34e6bf53ee1210848c2bc62435d550c844aaec2d Mon Sep 17 00:00:00 2001 From: sevin98 Date: Fri, 14 Aug 2026 21:10:32 +0900 Subject: [PATCH 8/9] =?UTF-8?q?docs:=20=ED=95=84=ED=84=B0=EC=B9=A9=20regio?= =?UTF-8?q?nCount=20=EA=B0=80=20=EC=98=88=EC=8B=9C=EC=9E=84=EC=9D=84=20?= =?UTF-8?q?=EB=B0=9D=ED=9E=8C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 적재된 지역 콘텐츠로 그때그때 세는 값이라 문서의 숫자는 고정 응답이 아니다. ALL(89)만 고시로 고정이고 나머지는 적재 상태에 따라 달라진다 --- docs/specs/api-spec.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/specs/api-spec.md b/docs/specs/api-spec.md index 949355e9..1109ea91 100644 --- a/docs/specs/api-spec.md +++ b/docs/specs/api-spec.md @@ -238,6 +238,7 @@ > 🎯 필터칩 목록 · **기능 F6** · 서버 내부에서 `SIGHT`→lclsSystm(NA+HS+VE+LS+EV) 등 매핑 +- `regionCount` 는 **적재된 지역 콘텐츠로 그때그때 세는 값**이라 아래 숫자는 예시다. `ALL`(89)만 고시로 고정이고 나머지는 적재 상태에 따라 달라진다. - `regionCount` = **그 칩으로 좁혔을 때 나오는 지역 수**(#266). `GET /regions?category={key}` 의 `pageResponse.totalElements` 와 같은 값이고, `ALL` 은 전체 지역 수다. 화면이 개수를 지어내거나("전부 1건") 빈 칩을 그리지 않게 하려는 것. **응답 `data`** From eadadff0871daf3e2759555ee6d82a497f16fb9a Mon Sep 17 00:00:00 2001 From: sevin98 Date: Fri, 14 Aug 2026 21:23:48 +0900 Subject: [PATCH 9/9] =?UTF-8?q?fix:=20=EC=84=B8=EB=8C=80=20=EB=8C=80?= =?UTF-8?q?=EC=A1=B0=EC=99=80=20=EC=8A=A4=EB=83=85=EC=83=B7=20=EB=8C=80?= =?UTF-8?q?=EC=9E=85=20=EC=82=AC=EC=9D=B4=EC=9D=98=20=ED=8B=88=EC=9D=84=20?= =?UTF-8?q?=EB=8B=AB=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 앞선 수정은 세우기 직전에 세대를 대조했는데, 대조와 대입 사이에 invalidate() 가 끼면 옛 결과가 그대로 세워진다. 창이 좁아졌을 뿐 닫히지 않았다 - 락으로 임계구역을 만드는 대신 스냅샷이 자기 세대를 들고 다니게 했다. 늦게 세워진 옛 결과도 읽는 쪽이 알아보고 무시하므로 락 없이 닫힌다 - 조회 스레드가 적재 스레드를 기다리지 않고, "이중 계산을 막지 않는다" 는 기존 판단도 그대로 산다 - 빈 지역 warn 의 창 진입을 CAS 로 바꿨다. 읽고 쓰는 사이에 다른 스레드가 통과하면 같은 warn 이 여러 줄 남아, 억제하려던 것이 그대로 일어난다 --- .../service/RegionCategoryCountProvider.java | 40 ++++++++++++------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/src/main/java/com/offway/core/trip/service/RegionCategoryCountProvider.java b/src/main/java/com/offway/core/trip/service/RegionCategoryCountProvider.java index 8df691f8..f2550bce 100644 --- a/src/main/java/com/offway/core/trip/service/RegionCategoryCountProvider.java +++ b/src/main/java/com/offway/core/trip/service/RegionCategoryCountProvider.java @@ -66,19 +66,30 @@ public class RegionCategoryCountProvider { * 진행 중이던 재계산이 옛 입력으로 만든 결과를 새 시각으로 다시 세운다 — 버린 것이 되살아나 새 콘텐츠가 최대 * {@value #RECOUNT_INTERVAL_TEXT} 동안 칩에 안 나온다. 적재 직후 버리는 것이 이 클래스의 설계인데 그 자리가 무력해진다. * - *

읽기 전에 세대를 적어 두고 세울 때 대조한다. 달라졌으면 세우지 않는다 — 다음 조회가 새 입력으로 다시 센다. + *

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

시각은 벽시계로 재지 않는다 — 시스템 시각 보정에 간격이 늘거나 즉시 만료된다. + */ + 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; + } } /** @@ -93,7 +104,7 @@ public void warm() { /** 지금 칩별 지역 수. 오래됐거나 아직 안 셌으면 그 자리에서 센다(89행 한 번). */ public CategoryCounts counts() { Snapshot current = snapshot; - if (current != null && !current.isStale()) { + if (current != null && current.isCurrent(generation.get()) && !current.isStale()) { return current.counts(); } return recount(); @@ -132,13 +143,8 @@ private CategoryCounts recount() { .map(regionId -> stored.getOrDefault(regionId, RegionContent.EMPTY)) .toList(); CategoryCounts counted = CategoryCounts.of(contents); - if (generation.get() != startedAt) { - // 세는 동안 적재가 끝났다. 이 결과는 옛 입력에서 나왔으므로 세우지 않는다 — 세우면 방금 버린 것이 - // 되살아나 새 콘텐츠가 안전망 간격만큼 늦게 반영된다. 다음 조회가 새 입력으로 다시 센다. - log.debug("집계 중 지역 콘텐츠가 갱신돼 결과를 버립니다 — 다음 조회가 다시 셉니다"); - return counted; - } - snapshot = new Snapshot(counted, System.nanoTime()); + // 세운 뒤 적재가 끝나도 안전하다 — 이 스냅샷은 startedAt 세대를 달고 있어 읽는 쪽이 알아보고 무시한다. + snapshot = new Snapshot(counted, System.nanoTime(), startedAt); log.debug("필터칩 개수 집계 지역={} 콘텐츠={} 결과={}", all.size(), stored.size(), counted.byCategory()); return counted; } @@ -146,11 +152,15 @@ private CategoryCounts recount() { /** 같은 warn 이 조회마다 쌓이지 않게 창당 한 줄로 누른다. 창이 지나면 다시 남겨 장애 지속도 보이게 한다. */ private void warnEmptyRegions() { long now = System.nanoTime(); - long last = lastEmptyWarnNanos; + long last = lastEmptyWarnNanos.get(); if (last != Long.MIN_VALUE && now - last < EMPTY_WARN_INTERVAL.toNanos()) { return; } - lastEmptyWarnNanos = now; + // 읽고 쓰는 사이에 다른 스레드가 먼저 통과했으면 양보한다 — 창이 열린 순간 몰린 요청이 + // 저마다 같은 warn 을 남기면 억제하려던 것이 그대로 일어난다. + if (!lastEmptyWarnNanos.compareAndSet(last, now)) { + return; + } log.warn("지역이 없어 필터칩 개수를 세지 못했습니다 — 다음 조회에서 다시 셉니다"); } }