From b8d28d6d71b9742935746c964d2faa2d546890b1 Mon Sep 17 00:00:00 2001 From: Whale0928 Date: Sat, 5 Sep 2026 12:24:41 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=9D=B8=EA=B8=B0=EC=88=9C=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=EC=97=90=EC=84=9C=20=EC=8A=A4=EB=83=85=EC=83=B7=20?= =?UTF-8?q?=EC=97=86=EB=8A=94=20=EC=A3=BC=EB=A5=98=20=EB=88=84=EB=9D=BD=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/dsl/ExploreStandardCriteria.java | 2 + .../CustomAlcoholQueryRepositoryImpl.java | 26 +- .../alcohols/service/AlcoholQueryService.java | 9 +- .../docs/AlcoholExploreApiDocs.java | 3 + ...coholExploreControllerIntegrationTest.java | 305 +++++++++++++++++- ...4\354\210\230 \354\235\274\354\271\230.md" | 45 +++ 6 files changed, 369 insertions(+), 21 deletions(-) create mode 100644 "plan/2026.09.05 \352\262\200\354\203\211 \352\262\260\352\263\274 \352\260\234\354\210\230 \354\235\274\354\271\230.md" diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/dsl/ExploreStandardCriteria.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/dsl/ExploreStandardCriteria.java index b74e2dd90..69628c741 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/dsl/ExploreStandardCriteria.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/dsl/ExploreStandardCriteria.java @@ -30,6 +30,8 @@ public record ExploreStandardCriteria( Integer size, LocalDateTime popularityBucketAt) { + public static final String NO_POPULARITY_BUCKET = "NONE"; + public static ExploreStandardCriteria of( ExploreStandardRequest request, Long userId, long seed, LocalDateTime popularityBucketAt) { return new ExploreStandardCriteria( diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomAlcoholQueryRepositoryImpl.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomAlcoholQueryRepositoryImpl.java index 041e8fac9..27d75a19b 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomAlcoholQueryRepositoryImpl.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomAlcoholQueryRepositoryImpl.java @@ -30,6 +30,7 @@ import com.querydsl.core.types.OrderSpecifier; import com.querydsl.core.types.Projections; import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.Expressions; import com.querydsl.core.types.dsl.NumberExpression; import com.querydsl.jpa.impl.JPAQueryFactory; import java.math.BigDecimal; @@ -354,7 +355,12 @@ public KeysetPageResponse> getStandardExplore( Map extra = switch (criteria.sortType()) { case RANDOM -> Map.of("seed", String.valueOf(criteria.seed())); - case POPULAR -> Map.of("bucketAt", criteria.popularityBucketAt().toString()); + case POPULAR -> + Map.of( + "bucketAt", + criteria.popularityBucketAt() == null + ? ExploreStandardCriteria.NO_POPULARITY_BUCKET + : criteria.popularityBucketAt().toString()); default -> Map.of(); }; return cursorCodec.encode(context, keys, extra); @@ -466,11 +472,13 @@ private List fetchCandidateIds(ExploreStandardCriteria criteria, private List fetchPopularityCandidates( ExploreStandardCriteria criteria, CursorClaims claims, int fetchSize) { - if (criteria.popularityBucketAt() == null) { - return List.of(); - } - - NumberExpression score = alcoholPopularitySnapshot.popularityScore; + NumberExpression score = + alcoholPopularitySnapshot.popularityScore.coalesce(BigDecimal.ZERO); + // 첫 페이지에서 스냅샷이 없었다면 이후에도 점수 조인을 차단한다. + BooleanExpression bucketCondition = + criteria.popularityBucketAt() == null + ? Expressions.FALSE + : alcoholPopularitySnapshot.bucketAt.eq(criteria.popularityBucketAt()); var query = queryFactory .select(alcohol.id, score) @@ -479,7 +487,7 @@ private List fetchPopularityCandidates( .on(alcohol.region.id.eq(region.id)) .join(distillery) .on(alcohol.distillery.id.eq(distillery.id)) - .join(alcoholPopularitySnapshot) + .leftJoin(alcoholPopularitySnapshot) .on( alcoholPopularitySnapshot .alcoholId @@ -487,7 +495,7 @@ private List fetchPopularityCandidates( .and( alcoholPopularitySnapshot.bucketGranularity.eq( app.bottlenote.alcohols.constant.BucketGranularity.HOUR)) - .and(alcoholPopularitySnapshot.bucketAt.eq(criteria.popularityBucketAt()))); + .and(bucketCondition)); if (criteria.hasRatingRange()) { query = query.leftJoin(rating).on(rating.id.alcoholId.eq(alcohol.id)); } @@ -504,7 +512,7 @@ private List fetchPopularityCandidates( supporter.eqCurationId(criteria.curationId()), supporter.isNotDeleted(), popularitySeek(claims, criteria.sortOrder(), score)) - .groupBy(alcohol.id, score) + .groupBy(alcohol.id, alcoholPopularitySnapshot.popularityScore) .having(ratingInRange(criteria.ratingFrom(), criteria.ratingTo())) .orderBy(scoreOrder, alcohol.id.asc()) .limit(fetchSize) diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AlcoholQueryService.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AlcoholQueryService.java index d29574a41..9b0efac09 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AlcoholQueryService.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AlcoholQueryService.java @@ -127,10 +127,13 @@ private LocalDateTime resolvePopularityBucketAt(ExploreStandardRequest request, return null; } if (request.cursor() != null) { - return CursorKeys.requireExtraTime( + var claims = cursorCodec.verify( - request.cursor(), ExploreStandardCriteria.of(request, userId, 0L, null).context()), - "bucketAt"); + request.cursor(), ExploreStandardCriteria.of(request, userId, 0L, null).context()); + if (ExploreStandardCriteria.NO_POPULARITY_BUCKET.equals(claims.extra().get("bucketAt"))) { + return null; + } + return CursorKeys.requireExtraTime(claims, "bucketAt"); } return alcoholPopularitySnapshotRepository .findLatestBucketAt(BucketGranularity.HOUR) diff --git a/bottlenote-product-api/src/main/java/app/bottlenote/alcohols/controller/docs/AlcoholExploreApiDocs.java b/bottlenote-product-api/src/main/java/app/bottlenote/alcohols/controller/docs/AlcoholExploreApiDocs.java index f060bb797..2c5f78fee 100644 --- a/bottlenote-product-api/src/main/java/app/bottlenote/alcohols/controller/docs/AlcoholExploreApiDocs.java +++ b/bottlenote-product-api/src/main/java/app/bottlenote/alcohols/controller/docs/AlcoholExploreApiDocs.java @@ -30,6 +30,9 @@ private AlcoholExploreApiDocs() {} description = """ 인기순, 별점순, 리뷰순, 찜순, 무작위 중 원하는 기준으로 위스키를 둘러봅니다. + 동일한 검색 조건에서는 정렬 방식과 관계없이 검색 대상이 같습니다. + 인기 점수가 없는 위스키도 포함하며, 정렬할 때는 0점으로 취급합니다. + 인기순의 점수 기준은 첫 페이지에서 고정되며, 점수가 같으면 ID 오름차순으로 정렬합니다. ratingFrom/ratingTo는 목록에 표시되는 0.5 단위 반올림 집계 평점의 포함 하한/상한입니다. 한쪽 경계만 보내면 이상/이하로 조회하며, 둘 다 생략하면 별점 조건을 적용하지 않습니다. diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/integration/AlcoholExploreControllerIntegrationTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/integration/AlcoholExploreControllerIntegrationTest.java index 99b4be986..6cd3f5cae 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/integration/AlcoholExploreControllerIntegrationTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/integration/AlcoholExploreControllerIntegrationTest.java @@ -12,10 +12,21 @@ import app.bottlenote.alcohols.domain.AlcoholQueryRepository; import app.bottlenote.alcohols.domain.Distillery; import app.bottlenote.alcohols.domain.Region; +import app.bottlenote.alcohols.dto.dsl.ExploreStandardCriteria; +import app.bottlenote.alcohols.dto.request.ExploreStandardRequest; import app.bottlenote.alcohols.fixture.AlcoholTestFactory; +import app.bottlenote.global.pagination.HmacCursorCodec; +import app.bottlenote.global.service.cursor.SortOrder; +import app.bottlenote.rating.fixture.RatingTestFactory; +import app.bottlenote.user.domain.User; +import app.bottlenote.user.fixture.UserTestFactory; import java.math.BigDecimal; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashSet; import java.util.List; +import java.util.Map; +import java.util.Set; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Tag; @@ -40,6 +51,9 @@ class AlcoholExploreControllerIntegrationTest extends IntegrationTestSupport { @Autowired private AlcoholTestFactory alcoholTestFactory; @Autowired private AlcoholQueryRepository alcoholQueryRepository; + @Autowired private RatingTestFactory ratingTestFactory; + @Autowired private UserTestFactory userTestFactory; + @Autowired private HmacCursorCodec cursorCodec; private MvcTestResult exchangeGet( java.util.function.Consumer< @@ -404,13 +418,13 @@ void keyword_cursor_continues_without_duplicates() throws Exception { } // ============================================================================================= - // RANDOM seed + // 정렬 커서 안정성 // ============================================================================================= - /** RANDOM 정렬은 CRC32(seed,id) keyset이며, seed는 다음 커서 extra에만 이어진다. */ + /** 정렬별 커서가 최초 조회 기준과 동점 순서를 유지하는지 검증한다. */ @Nested - @DisplayName("RANDOM seed") - class RandomSeed { + @DisplayName("정렬 커서 안정성") + class SortCursorStability { @Test @DisplayName("nextCursor로 이어 요청하면 첫 페이지와 중복되지 않는다") @@ -472,17 +486,212 @@ void non_random_sort_is_stable() throws Exception { } @Test - @DisplayName("HOUR Snapshot이 없으면 POPULAR 목록은 비어 있다") - void popular_without_snapshot_returns_empty_page() throws Exception { - alcoholTestFactory.persistAlcohols(3); + @DisplayName("HOUR Snapshot이 없어도 POPULAR 목록은 전체 주류를 반환한다") + void popular_without_snapshot_returns_all_alcohols() throws Exception { + List alcohols = alcoholTestFactory.persistAlcohols(3); exchangeGet(b -> b.param("sortType", "POPULAR").param("sortOrder", "DESC").param("size", "3")) .assertThat() .hasStatusOk() .bodyJson() - .extractingPath("$.data.items") + .extractingPath("$.data.items[*].alcoholId") .asArray() - .isEmpty(); + .containsExactly( + alcohols.get(0).getId().intValue(), + alcohols.get(1).getId().intValue(), + alcohols.get(2).getId().intValue()); + } + + @Test + @DisplayName("31개에만 Snapshot이 있어도 POPULAR과 RANDOM은 34개 ID를 누락과 중복 없이 반환한다") + void partial_snapshot_keeps_same_ids_across_sorts_and_page_sizes() throws Exception { + String keyword = "개수일치"; + List alcohols = persistNamedAlcohols(keyword, 34); + LocalDateTime bucket = BucketGranularity.HOUR.startAt(LocalDateTime.now()).minusHours(1); + for (int index = 0; index < 31; index++) { + alcoholTestFactory.persistPopularitySnapshot( + alcohols.get(index).getId(), + BucketGranularity.HOUR, + bucket, + BigDecimal.ZERO, + BigDecimal.valueOf(index + 1L, 2)); + } + Set expectedIds = new HashSet<>(alcoholIds(alcohols)); + + for (int size : List.of(10, 20)) { + List popularIds = + fetchAllIds(keyword, SearchSortType.POPULAR, SortOrder.DESC, size); + List randomIds = + fetchAllIds(keyword, SearchSortType.RANDOM, SortOrder.DESC, size); + + assertCompleteIdSet(popularIds, expectedIds); + assertCompleteIdSet(randomIds, expectedIds); + assertThat(new HashSet<>(popularIds)).isEqualTo(new HashSet<>(randomIds)); + } + } + + @ParameterizedTest(name = "sortOrder={0}") + @EnumSource(SortOrder.class) + @DisplayName("POPULAR은 실제 0점과 Snapshot이 없는 주류를 ID 오름차순 동점으로 페이징한다") + void popular_zero_score_tie_uses_id_ascending(SortOrder sortOrder) throws Exception { + String keyword = "0점동점"; + Alcohol actualZero = + alcoholTestFactory.persistAlcoholWithName(keyword + " Snapshot", "Zero Snapshot"); + Alcohol withoutSnapshot = + alcoholTestFactory.persistAlcoholWithName(keyword + " Missing", "Zero Missing"); + Alcohol positive = + alcoholTestFactory.persistAlcoholWithName(keyword + " Positive", "Positive"); + LocalDateTime bucket = BucketGranularity.HOUR.startAt(LocalDateTime.now()).minusHours(1); + alcoholTestFactory.persistPopularitySnapshot( + actualZero.getId(), + BucketGranularity.HOUR, + bucket, + BigDecimal.ZERO, + BigDecimal.ZERO); + alcoholTestFactory.persistPopularitySnapshot( + positive.getId(), + BucketGranularity.HOUR, + bucket, + BigDecimal.ZERO, + BigDecimal.ONE); + + List ids = fetchAllIds(keyword, SearchSortType.POPULAR, sortOrder, 1); + + List zeroTieIds = + List.of(actualZero.getId().intValue(), withoutSnapshot.getId().intValue()); + if (sortOrder == SortOrder.ASC) { + assertThat(ids).containsExactlyElementsOf( + List.of(zeroTieIds.get(0), zeroTieIds.get(1), positive.getId().intValue())); + } else { + assertThat(ids).containsExactlyElementsOf( + List.of(positive.getId().intValue(), zeroTieIds.get(0), zeroTieIds.get(1))); + } + } + + @Test + @DisplayName("Snapshot 부재 커서는 페이지 중간에 첫 Snapshot이 생겨도 0점 기준을 유지한다") + void no_snapshot_cursor_keeps_zero_score_baseline() throws Exception { + String keyword = "무스냅샷커서"; + List alcohols = persistNamedAlcohols(keyword, 5); + MvcTestResult first = + exchangeGet( + b -> + b.param("keyword", keyword) + .param("sortType", "POPULAR") + .param("sortOrder", "DESC") + .param("size", "2")); + String firstCursor = nextCursor(first); + assertThat(cursorCodec.verify(firstCursor, popularContext(keyword, SortOrder.DESC, 2)).extra()) + .containsEntry("bucketAt", ExploreStandardCriteria.NO_POPULARITY_BUCKET); + + LocalDateTime bucket = BucketGranularity.HOUR.startAt(LocalDateTime.now()); + for (int index = 0; index < alcohols.size(); index++) { + alcoholTestFactory.persistPopularitySnapshot( + alcohols.get(index).getId(), + BucketGranularity.HOUR, + bucket, + BigDecimal.ZERO, + BigDecimal.valueOf(alcohols.size() - index)); + } + + MvcTestResult second = + exchangeGet( + b -> + b.param("keyword", keyword) + .param("sortType", "POPULAR") + .param("sortOrder", "DESC") + .param("cursor", firstCursor) + .param("size", "2")); + String secondCursor = nextCursor(second); + MvcTestResult third = + exchangeGet( + b -> + b.param("keyword", keyword) + .param("sortType", "POPULAR") + .param("sortOrder", "DESC") + .param("cursor", secondCursor) + .param("size", "2")); + + List ids = new ArrayList<>(); + ids.addAll(alcoholIds(first)); + ids.addAll(alcoholIds(second)); + ids.addAll(alcoholIds(third)); + assertThat(ids).containsExactlyElementsOf(alcoholIds(alcohols)); + third.assertThat().bodyJson().extractingPath("$.meta.pagination.hasNext").isEqualTo(false); + } + + @Test + @DisplayName("부분 Snapshot 상태에서도 POPULAR은 평점 범위와 삭제 필터를 유지한다") + void popular_partial_snapshot_keeps_rating_and_deleted_filters() throws Exception { + String keyword = "부분스냅샷필터"; + User ratingUser = userTestFactory.persistUser(); + Alcohol withSnapshot = + alcoholTestFactory.persistAlcoholWithName(keyword + " Included Snapshot", "Included A"); + Alcohol withoutSnapshot = + alcoholTestFactory.persistAlcoholWithName(keyword + " Included Missing", "Included B"); + Alcohol belowRange = + alcoholTestFactory.persistAlcoholWithName(keyword + " Below", "Below"); + Alcohol deleted = + alcoholTestFactory.persistAlcoholWithName(keyword + " Deleted", "Deleted"); + ratingTestFactory.persistRating(ratingUser, withSnapshot, 4); + ratingTestFactory.persistRating(ratingUser, withoutSnapshot, 4); + ratingTestFactory.persistRating(ratingUser, belowRange, 2); + ratingTestFactory.persistRating(ratingUser, deleted, 5); + LocalDateTime bucket = BucketGranularity.HOUR.startAt(LocalDateTime.now()).minusHours(1); + alcoholTestFactory.persistPopularitySnapshot( + withSnapshot.getId(), + BucketGranularity.HOUR, + bucket, + BigDecimal.ZERO, + BigDecimal.ONE); + alcoholTestFactory.persistPopularitySnapshot( + belowRange.getId(), + BucketGranularity.HOUR, + bucket, + BigDecimal.ZERO, + BigDecimal.TEN); + deleted.delete(); + alcoholQueryRepository.save(deleted); + + exchangeGet( + b -> + b.param("keyword", keyword) + .param("sortType", "POPULAR") + .param("sortOrder", "DESC") + .param("ratingFrom", "4.0") + .param("ratingTo", "5.0") + .param("size", "10")) + .assertThat() + .hasStatusOk() + .bodyJson() + .extractingPath("$.data.items[*].alcoholId") + .asArray() + .containsExactly(withSnapshot.getId().intValue(), withoutSnapshot.getId().intValue()) + .doesNotContain(belowRange.getId().intValue(), deleted.getId().intValue()); + } + + @ParameterizedTest(name = "bucketAt={0}") + @ValueSource(strings = {"MISSING", "", " ", "not-a-date"}) + @DisplayName("서명된 POPULAR 커서의 bucketAt이 누락되거나 형식이 틀리면 400을 반환한다") + void popular_cursor_rejects_invalid_bucket_at(String bucketAt) { + getToken(); + String context = popularContext(null, SortOrder.DESC, 1); + Map extra = + "MISSING".equals(bucketAt) ? Map.of() : Map.of("bucketAt", bucketAt); + String cursor = + cursorCodec.encode(context, Map.of("id", "1", "sort", "0"), extra); + + exchangeGet( + b -> + b.param("sortType", "POPULAR") + .param("sortOrder", "DESC") + .param("cursor", cursor) + .param("size", "1")) + .assertThat() + .hasStatus(HttpStatus.BAD_REQUEST) + .bodyJson() + .extractingPath("$.errors[0].code") + .isEqualTo("INVALID_CURSOR"); } @Test @@ -593,4 +802,82 @@ void popular_asc_cursor_keeps_tie_break_order() throws Exception { .containsExactly(alcohols.get(2).getId().intValue(), alcohols.get(3).getId().intValue()); } } + + private List persistNamedAlcohols(String keyword, int count) { + List alcohols = new ArrayList<>(); + for (int index = 0; index < count; index++) { + alcohols.add( + alcoholTestFactory.persistAlcoholWithName( + keyword + " " + index, "Explore Regression " + index)); + } + return alcohols; + } + + private List fetchAllIds( + String keyword, SearchSortType sortType, SortOrder sortOrder, int size) throws Exception { + List ids = new ArrayList<>(); + Set seenCursors = new HashSet<>(); + String cursor = null; + int pageCount = 0; + boolean hasNext; + do { + assertThat(++pageCount).as("페이지 순회가 종료되어야 한다").isLessThanOrEqualTo(100); + String currentCursor = cursor; + MvcTestResult page = + exchangeGet( + b -> { + b.param("keyword", keyword) + .param("sortType", sortType.name()) + .param("sortOrder", sortOrder.name()) + .param("size", String.valueOf(size)); + if (currentCursor != null) { + b.param("cursor", currentCursor); + } + }); + page.assertThat().hasStatusOk(); + ids.addAll(alcoholIds(page)); + hasNext = readJsonPath(page, "$.meta.pagination.hasNext"); + cursor = hasNext ? nextCursor(page) : null; + if (hasNext) { + assertThat(cursor).isNotBlank(); + assertThat(seenCursors.add(cursor)).as("다음 커서가 반복되지 않아야 한다").isTrue(); + } + } while (hasNext); + return ids; + } + + private String popularContext(String keyword, SortOrder sortOrder, int size) { + ExploreStandardRequest request = + ExploreStandardRequest.builder() + .keyword(keyword) + .sortType(SearchSortType.POPULAR) + .sortOrder(sortOrder) + .size(size) + .build(); + return ExploreStandardCriteria.of(request, getTokenUserId(), 0L, null).context(); + } + + private void assertCompleteIdSet(List actualIds, Set expectedIds) { + assertThat(actualIds).hasSize(expectedIds.size()); + assertThat(new HashSet<>(actualIds)) + .hasSize(actualIds.size()) + .containsExactlyInAnyOrderElementsOf(expectedIds); + } + + private List alcoholIds(List alcohols) { + return alcohols.stream().map(alcohol -> alcohol.getId().intValue()).toList(); + } + + private List alcoholIds(MvcTestResult result) throws Exception { + return readJsonPath(result, "$.data.items[*].alcoholId"); + } + + private String nextCursor(MvcTestResult result) throws Exception { + return readJsonPath(result, "$.meta.pagination.nextCursor"); + } + + private T readJsonPath(MvcTestResult result, String path) throws Exception { + return com.jayway.jsonpath.JsonPath.read( + result.getMvcResult().getResponse().getContentAsString(), path); + } } diff --git "a/plan/2026.09.05 \352\262\200\354\203\211 \352\262\260\352\263\274 \352\260\234\354\210\230 \354\235\274\354\271\230.md" "b/plan/2026.09.05 \352\262\200\354\203\211 \352\262\260\352\263\274 \352\260\234\354\210\230 \354\235\274\354\271\230.md" new file mode 100644 index 000000000..e5e6844f8 --- /dev/null +++ "b/plan/2026.09.05 \352\262\200\354\203\211 \352\262\260\352\263\274 \352\260\234\354\210\230 \354\235\274\354\271\230.md" @@ -0,0 +1,45 @@ +# 검색 결과 개수 일치 + +## Overview +- 관련 이슈: bottle-note/workspace#438 +- 운영 API에서 벤로막 검색의 POPULAR 31개와 RANDOM 34개를 재현했다. 인기순에만 있는 최신 HOUR 스냅샷 INNER JOIN이 검색 대상을 제한한다. +- 같은 데이터와 필터에서는 정렬 방식과 페이지 크기에 관계없이 전체 주류 ID 집합을 동일하게 유지한다. + +## Assumptions +- 누락된 인기 점수는 조회 정렬에서만 0으로 취급한다. DB나 배치에 점수를 만들어 넣지 않는다. +- 스냅샷이 전혀 없을 때도 검색한다. 최초 페이지의 스냅샷 부재를 서명된 커서에 명시하고 다음 페이지에서도 유지한다. +- 기존 날짜형 bucketAt 커서는 계속 해석한다. 필터, 삭제 제외, 평점 범위, 동점 ID 오름차순 규칙을 유지한다. +- 프론트엔드와 요청 파라미터 수정은 제외한다. 사용자가 FE에 별도로 전달했다. + +## Success Criteria +- 부분 스냅샷과 전체 스냅샷 부재에서 POPULAR와 RANDOM의 검색 ID 집합이 일치한다. +- ASC/DESC와 0점 동점에서 커서 중복 및 누락이 없고, 조회 중 첫 스냅샷이 생겨도 기존 페이지의 기준을 유지한다. +- 페이지 크기 10과 20을 포함하는 회귀 테스트를 GitHub Actions에서 실행한다. + +## Impact Scope +- mono: 인기순 후보 조회, 정렬 점수와 seek, 커서 스냅샷 기준 복원. +- product-api: 검색 설명과 통합 테스트. +- 배치, 스키마, 프론트엔드, 운영 데이터 변경 없음. + +## Execution Mode +- mode: delegated +- scope: plan, implement, test, commit, push, pr, verify +- authorization: 사용자가 대화의 백엔드 수정 계획을 승인하고 즉시 진행을 요청했다. 커밋·푸시·PR 생성 후 GitHub Actions로 검증하도록 명시했다. +- verification-order: 로컬 빌드·테스트·verify는 실행하지 않는다. 코드 검토 후 commit → push → PR → GitHub Actions 확인 순서로 진행한다. +- stop-conditions: 가정 붕괴, verify 3회 내 해결 실패, 승인 범위 밖의 되돌리기 어려운 행동. + +## Tasks +### Task 1: 인기순 검색 대상과 커서 기준을 일치시킨다 +- Acceptance: 스냅샷이 없는 주류를 0점으로 포함하고 ASC/DESC seek와 커서에 같은 기준을 사용한다. +- Acceptance: 필터와 기존 커서를 유지하며 스냅샷 부재를 별도로 인코딩한다. +- Acceptance: API 문서 및 통합 회귀 테스트에 새 검색 기준을 반영한다. +- Verification: PR GitHub Actions의 unit-tests, rule-tests, integration-tests, product-ci-final-build와 테스트 리포트를 확인한다. +- Files: CustomAlcoholQueryRepositoryImpl.java, AlcoholQueryService.java, ExploreStandardCriteria.java, AlcoholExploreApiDocs.java, AlcoholExploreControllerIntegrationTest.java +- Depends: 없음 +- Size: M +- Status: [x] implemented; GitHub Actions 검증 대기 + +## Progress Log +- 2026.09.05: 사용자 승인 범위를 백엔드로 한정하고 GitHub Actions 검증 순서를 기록했다. Orca 작업자는 통합 테스트 파일만 수정하며 주 작업자는 조회·커서·문서를 수정한다. +- 2026.09.05: 인기순 후보 조회를 LEFT JOIN으로 변경하고 누락 점수에 COALESCE 0을 적용했다. 스냅샷 부재는 서명된 커서의 bucketAt=NONE으로 기록하여 첫 스냅샷 생성 전후에도 기존 기준을 유지한다. 잘못된 날짜와 누락된 bucketAt은 기존 INVALID_CURSOR 처리를 유지한다. +- 2026.09.05: 34개 중 31개 스냅샷 재현, 크기 10/20 전체 목록 비교, 0점 ASC/DESC 동점, 전체 스냅샷 부재와 첫 스냅샷 생성, 평점·삭제 필터, 잘못된 커서 테스트를 작성했다. 코드 검토만 수행하며 빌드·테스트 결과는 PR의 GitHub Actions 기록에 남긴다.