From 4e7ad55fc6fe96f2bc8756b0a106acad043db840 Mon Sep 17 00:00:00 2001 From: Whale0928 Date: Tue, 27 Oct 2026 17:32:19 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=9D=B8=EA=B8=B0=EB=8F=84=20=EC=A0=95?= =?UTF-8?q?=EA=B7=9C=ED=99=94=20=EA=B8=B0=EC=A4=80=EA=B3=BC=20=EB=8F=99?= =?UTF-8?q?=EC=A0=90=20=EC=A0=95=EB=A0=AC=20=EB=B3=B4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 주간 조회수 분모가 50이라 한 주에 50회만 넘으면 관심도 점수가 만점으로 묶이고, 동점은 id 오름차순으로 풀려서 조회수가 더 많은 주류보다 id가 작은 주류가 앞에 섰다. 10월 18일 하루 급증한 242번이 두 주 연속 1위로 노출된 원인이다. - 운영 분포(2026-10-27)를 기준으로 단위별 분모를 재설정한다 - 인기 목록 정렬에 원시 조회수 내림차순을 두 번째 기준으로 넣는다 - 테스트 팩토리에 원시 조회수를 지정하는 Snapshot 생성 오버로드를 추가한다 --- .../src/main/resources/application.yml | 25 +++++----- .../CustomPopularQueryRepositoryImpl.java | 31 +++++++++--- ...laritySnapshotConsumerIntegrationTest.java | 47 +++++++++++++++++++ .../alcohols/fixture/AlcoholTestFactory.java | 21 ++++++++- 4 files changed, 105 insertions(+), 19 deletions(-) diff --git a/bottlenote-batch/src/main/resources/application.yml b/bottlenote-batch/src/main/resources/application.yml index 416695d8e..ab3f7e11d 100644 --- a/bottlenote-batch/src/main/resources/application.yml +++ b/bottlenote-batch/src/main/resources/application.yml @@ -82,23 +82,24 @@ popularity: rating: 0.35 pick: 0.20 engagement: 0.40 - # 단위별 각 축을 0~1로 누르는 분모. 실제 분포를 확인하기 전까지 같은 임시값을 쓴다. + # 단위별 각 축을 0~1로 누르는 분모. 2026-10-27 운영 분포(주간 조회 최대 146, 별점 19, 찜 5, 참여 94)를 기준으로 + # 최댓값이 0.5~0.8에 오도록 잡았다. 조회는 기간 합산이라 단위별로 다르고, 나머지 세 축은 누적 상태라 단위와 무관하다. reference: hour: interest: 50 - rating: 100 - pick: 200 - engagement: 300 + rating: 25 + pick: 10 + engagement: 120 week: - interest: 50 - rating: 100 - pick: 200 - engagement: 300 + interest: 200 + rating: 25 + pick: 10 + engagement: 120 month: - interest: 50 - rating: 100 - pick: 200 - engagement: 300 + interest: 600 + rating: 25 + pick: 10 + engagement: 120 batch: visitor-telemetry: diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomPopularQueryRepositoryImpl.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomPopularQueryRepositoryImpl.java index fedbc0fe3..1e73f92e6 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomPopularQueryRepositoryImpl.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomPopularQueryRepositoryImpl.java @@ -35,19 +35,28 @@ public List getPopularOfWeeks(Long userId, Pageable pageable) { userId, pageable.getPageSize(), BucketGranularity.WEEK, - alcoholPopularitySnapshot.popularityScore); + alcoholPopularitySnapshot.popularityScore, + alcoholPopularitySnapshot.interestValue); } @Override public List getPopularByInterestWeekly(Long userId, int limit) { return getPopularBySnapshot( - userId, limit, BucketGranularity.WEEK, alcoholPopularitySnapshot.interestScore); + userId, + limit, + BucketGranularity.WEEK, + alcoholPopularitySnapshot.interestScore, + alcoholPopularitySnapshot.interestValue); } @Override public List getPopularByInterestMonthly(Long userId, int limit) { return getPopularBySnapshot( - userId, limit, BucketGranularity.MONTH, alcoholPopularitySnapshot.interestScore); + userId, + limit, + BucketGranularity.MONTH, + alcoholPopularitySnapshot.interestScore, + alcoholPopularitySnapshot.interestValue); } @Override @@ -87,8 +96,17 @@ public List getSpringItems( .fetch(); } + /** + * 정규화 점수는 분모에 닿으면 만점으로 묶이므로 동점이 생긴다. 동점은 원시값 내림차순으로 풀고, 그래도 같으면 id로 고정한다. + * + *

종합 인기도의 동점은 기간마다 새로 쌓이는 조회수로 푼다. 나머지 축은 누적 상태라 주차가 바뀌어도 순서가 거의 바뀌지 않기 때문이다. + */ private List getPopularBySnapshot( - Long userId, int limit, BucketGranularity granularity, NumberPath snapshotScore) { + Long userId, + int limit, + BucketGranularity granularity, + NumberPath snapshotScore, + NumberPath tieBreakValue) { if (limit <= 0) { return List.of(); } @@ -114,8 +132,9 @@ private List getPopularBySnapshot( alcohol.korCategory, alcohol.engCategory, alcohol.imageUrl, - snapshotScore) - .orderBy(snapshotScore.desc(), alcohol.id.asc()) + snapshotScore, + tieBreakValue) + .orderBy(snapshotScore.desc(), tieBreakValue.desc(), alcohol.id.asc()) .limit(limit) .fetch(); } diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/integration/PopularitySnapshotConsumerIntegrationTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/integration/PopularitySnapshotConsumerIntegrationTest.java index d4d3a486e..173f542d8 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/integration/PopularitySnapshotConsumerIntegrationTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/integration/PopularitySnapshotConsumerIntegrationTest.java @@ -85,6 +85,53 @@ void springRecommendation_countsRatingsWithoutTagJoinDuplication() { assertThat(result).singleElement().extracting(PopularItem::ratingCount).isEqualTo(3L); } + @Test + @DisplayName("주간 관심도 순위는 정규화 점수가 같을 때 원시 조회수가 많은 주류를 먼저 반환한다") + void weeklyInterest_breaksTiesByRawViewCountBeforeId() { + Alcohol lowerIdFewerViews = alcoholTestFactory.persistAlcohol(); + Alcohol higherIdMoreViews = alcoholTestFactory.persistAlcohol(); + LocalDateTime bucket = BucketGranularity.WEEK.startAt(LocalDateTime.now()); + alcoholTestFactory.persistPopularitySnapshot( + lowerIdFewerViews.getId(), + BucketGranularity.WEEK, + bucket, + 54L, + BigDecimal.ONE, + BigDecimal.ZERO); + alcoholTestFactory.persistPopularitySnapshot( + higherIdMoreViews.getId(), + BucketGranularity.WEEK, + bucket, + 105L, + BigDecimal.ONE, + BigDecimal.ZERO); + + List result = popularQueryRepository.getPopularByInterestWeekly(-1L, 2); + + assertThat(result) + .extracting(PopularItem::alcoholId) + .containsExactly(higherIdMoreViews.getId(), lowerIdFewerViews.getId()); + } + + @Test + @DisplayName("주간 인기 순위는 종합 점수가 같을 때 원시 조회수가 많은 주류를 먼저 반환한다") + void weeklyPopular_breaksTiesByRawViewCountBeforeId() { + Alcohol lowerIdFewerViews = alcoholTestFactory.persistAlcohol(); + Alcohol higherIdMoreViews = alcoholTestFactory.persistAlcohol(); + LocalDateTime bucket = BucketGranularity.WEEK.startAt(LocalDateTime.now()); + BigDecimal sameScore = new BigDecimal("0.05"); + alcoholTestFactory.persistPopularitySnapshot( + lowerIdFewerViews.getId(), BucketGranularity.WEEK, bucket, 54L, BigDecimal.ONE, sameScore); + alcoholTestFactory.persistPopularitySnapshot( + higherIdMoreViews.getId(), BucketGranularity.WEEK, bucket, 105L, BigDecimal.ONE, sameScore); + + List result = popularQueryRepository.getPopularOfWeeks(-1L, Pageable.ofSize(2)); + + assertThat(result) + .extracting(PopularItem::alcoholId) + .containsExactly(higherIdMoreViews.getId(), lowerIdFewerViews.getId()); + } + @Test @DisplayName("마이보틀 isHot5는 최신 WEEK Snapshot 상위 5개만 true로 반환한다") void myBottle_marksOnlyTopFiveWeeklySnapshotsAsHot() throws Exception { diff --git a/bottlenote-test-support/src/main/java/app/bottlenote/alcohols/fixture/AlcoholTestFactory.java b/bottlenote-test-support/src/main/java/app/bottlenote/alcohols/fixture/AlcoholTestFactory.java index fc65236ac..0da38cab1 100644 --- a/bottlenote-test-support/src/main/java/app/bottlenote/alcohols/fixture/AlcoholTestFactory.java +++ b/bottlenote-test-support/src/main/java/app/bottlenote/alcohols/fixture/AlcoholTestFactory.java @@ -505,13 +505,32 @@ public AlcoholPopularitySnapshot persistPopularitySnapshot( @NotNull LocalDateTime bucketAt, @NotNull BigDecimal interestScore, @NotNull BigDecimal popularityScore) { + return persistPopularitySnapshot( + alcoholId, + granularity, + bucketAt, + interestScore.movePointRight(4).longValue(), + interestScore, + popularityScore); + } + + /** 정규화 점수가 같아도 원시 조회수가 다른 경우를 만들 때 쓴다. */ + @Transactional + @NotNull + public AlcoholPopularitySnapshot persistPopularitySnapshot( + @NotNull Long alcoholId, + @NotNull BucketGranularity granularity, + @NotNull LocalDateTime bucketAt, + long interestValue, + @NotNull BigDecimal interestScore, + @NotNull BigDecimal popularityScore) { AlcoholPopularitySnapshot snapshot = AlcoholPopularitySnapshot.builder() .alcoholId(alcoholId) .bucketGranularity(granularity) .bucketAt(bucketAt) .observedAt(bucketAt.plusHours(1)) - .interestValue(interestScore.movePointRight(4).longValue()) + .interestValue(interestValue) .interestSourceBucketAt(bucketAt) .interestScore(interestScore) .ratingValue(0L)