From 7218ff9383e0f20cc2623200946c4ad108706296 Mon Sep 17 00:00:00 2001 From: hyujikoh Date: Fri, 2 Jan 2026 17:50:04 +0900 Subject: [PATCH 1/6] =?UTF-8?q?refactor(ranking):=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=ED=8F=AC=EB=A7=B7=ED=8C=85=20=EB=B0=8F=20=EB=B6=88=ED=95=84?= =?UTF-8?q?=EC=9A=94=ED=95=9C=20=EC=9E=84=ED=8F=AC=ED=8A=B8=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 코드 가독성을 높이기 위해 불필요한 임포트 문을 제거 - 주석 및 공백 정리로 코드 일관성 향상 --- .../job/ranking/support/ScoreCalculator.java | 30 ++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/support/ScoreCalculator.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/support/ScoreCalculator.java index 50a76762e..6d9ffe447 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/support/ScoreCalculator.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/support/ScoreCalculator.java @@ -10,11 +10,10 @@ @Component public class ScoreCalculator { - // 가중치 상수 (Redis ZSET과 동일하게 유지) - private static final int VIEW_WEIGHT = 1; - private static final int LIKE_WEIGHT = 3; - private static final int SALES_WEIGHT = 5; - private static final int ORDER_WEIGHT = 2; + // CachePayloads의 EventType 가중치와 일치하도록 조정 (비율 유지) + private static final double VIEW_WEIGHT = 0.1; + private static final double LIKE_WEIGHT = 0.2; + private static final double SALES_WEIGHT = 0.6; // 주문(결제성공) 가중치 /** * 메트릭 데이터를 기반으로 랭킹 점수를 계산합니다. @@ -26,17 +25,26 @@ public class ScoreCalculator { * @return 계산된 총 점수 */ public long calculate(long viewCount, long likeCount, long salesCount, long orderCount) { - return viewCount * VIEW_WEIGHT - + likeCount * LIKE_WEIGHT - + salesCount * SALES_WEIGHT - + orderCount * ORDER_WEIGHT; + // 1. 조회와 좋아요는 단순 수량 기반 가중치 적용 + double viewScore = viewCount * VIEW_WEIGHT; + double likeScore = likeCount * LIKE_WEIGHT; + + // 2. 판매량(Sales)은 CachePayloads.forPaymentSuccess와 동일하게 로그 정규화 적용 + // 배치에서는 이미 집계된 salesCount(수량)를 기반으로 하므로, + // 만약 금액 기반 정규화가 필요하다면 매개변수로 총액을 받아야 하지만, + // 수량 기반으로 로그 정규화를 적용한다면 아래와 같이 작성합니다. + double normalizedSalesScore = Math.log1p(salesCount) * SALES_WEIGHT; + + // 3. 최종 점수 계산 (소수점 처리를 위해 적절한 스케일 곱산 후 long 변환) + // Redis ZSET의 score가 double임을 감안하여 정밀도를 유지합니다. + return (long) ((viewScore + likeScore + normalizedSalesScore) * 1000); } /** * 가중치 정보를 반환합니다. (테스트 및 디버깅용) */ public String getWeightInfo() { - return String.format("VIEW=%d, LIKE=%d, SALES=%d, ORDER=%d", - VIEW_WEIGHT, LIKE_WEIGHT, SALES_WEIGHT, ORDER_WEIGHT); + return String.format("VIEW=%f, LIKE=%f, SALES=%f,", + VIEW_WEIGHT, LIKE_WEIGHT, SALES_WEIGHT); } } From 58639157a0d0a1f2cf01b22eb2895fc0e70978c6 Mon Sep 17 00:00:00 2001 From: hyujikoh Date: Fri, 2 Jan 2026 22:26:12 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat(ranking):=20=EC=A3=BC=EA=B0=84=20?= =?UTF-8?q?=EB=9E=AD=ED=82=B9=20=EC=A1=B0=ED=9A=8C=20API=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=95=20=EC=B2=98=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 주간 랭킹 조회 시 페이징 기능을 추가하여 성능 개선 - 관련 메서드 및 리포지토리 수정 --- .../ranking/WeeklyRankingService.java | 19 +++---------------- .../ranking/WeeklyRankJpaRepository.java | 3 ++- .../ranking/WeeklyRankRepositoryImpl.java | 11 +++++------ .../api/ranking/RankingApiE2ETest.java | 4 ++-- .../ranking/WeeklyRankJpaRepository.java | 3 ++- .../ranking/WeeklyRankRepositoryImpl.java | 10 +++++----- .../domain/ranking/WeeklyRankRepository.java | 10 +++++----- 7 files changed, 24 insertions(+), 36 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/WeeklyRankingService.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/WeeklyRankingService.java index 345e8c6a4..27c2c24d1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/ranking/WeeklyRankingService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/WeeklyRankingService.java @@ -37,27 +37,14 @@ public Page getWeeklyRanking(String yearWeek, Pageable pageabl yearWeek, pageable.getPageNumber(), pageable.getPageSize()); // 1. 전체 랭킹 조회 (순위 순으로 정렬됨) - List allRankings = weeklyRankRepository.findByYearWeek(yearWeek); + Page pagedRankings = weeklyRankRepository.findByYearWeek(yearWeek , pageable); - if (allRankings.isEmpty()) { + if (pagedRankings.isEmpty()) { log.debug("주간 랭킹 데이터 없음: yearWeek={}", yearWeek); return Page.empty(pageable); } - // 2. 페이징 처리 - int start = (int) pageable.getOffset(); - int end = Math.min(start + pageable.getPageSize(), allRankings.size()); - - if (start >= allRankings.size()) { - return Page.empty(pageable); - } - - List pagedRankings = allRankings.subList(start, end); - - log.debug("주간 랭킹 조회 완료: yearWeek={}, 전체={}, 페이지={}", - yearWeek, allRankings.size(), pagedRankings.size()); - - return new PageImpl<>(pagedRankings, pageable, allRankings.size()); + return pagedRankings; } /** diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankJpaRepository.java index 3df5fbaa2..be16adc99 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankJpaRepository.java @@ -2,6 +2,7 @@ import java.util.List; +import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; @@ -26,7 +27,7 @@ public interface WeeklyRankJpaRepository extends JpaRepository findByIdYearWeekOrderByRankPosition(@Param("yearWeek") String yearWeek, Pageable pageable); + Page findByIdYearWeekOrderByRankPosition(@Param("yearWeek") String yearWeek, Pageable pageable); /** * 특정 주차의 모든 랭킹을 삭제합니다. diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankRepositoryImpl.java index 3301cf85a..9b3d2d221 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankRepositoryImpl.java @@ -2,6 +2,7 @@ import java.util.List; +import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; @@ -34,15 +35,13 @@ public List saveAll(List entities) { public List findByYearWeek(String yearWeek) { return jpaRepository.findByIdYearWeekOrderByRankPosition(yearWeek); } - @Override - public List findByYearWeekWithPagination(String yearWeek, int page, int size) { - Pageable pageable = PageRequest.of(page, size); - return jpaRepository.findByIdYearWeekOrderByRankPosition(yearWeek, pageable); + public long deleteByYearWeek(String yearWeek) { + return jpaRepository.deleteByIdYearWeek(yearWeek); } @Override - public long deleteByYearWeek(String yearWeek) { - return jpaRepository.deleteByIdYearWeek(yearWeek); + public Page findByYearWeek(String yearWeek, Pageable pageable) { + return jpaRepository.findByIdYearWeekOrderByRankPosition(yearWeek, pageable); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingApiE2ETest.java index 2a052f036..718cbb74f 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingApiE2ETest.java @@ -150,8 +150,8 @@ void should_return_products_in_ranking_order() { () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), () -> assertThat(Objects.requireNonNull(response.getBody()).data().content()).hasSize(3), () -> assertThat(Objects.requireNonNull(response.getBody()).data().content().get(0).productId()).isEqualTo(product1), - () -> assertThat(Objects.requireNonNull(response.getBody()).data().content().get(2).productId()).isEqualTo(product2), - () -> assertThat(Objects.requireNonNull(response.getBody()).data().content().get(1).productId()).isEqualTo(product3) + () -> assertThat(Objects.requireNonNull(response.getBody()).data().content().get(1).productId()).isEqualTo(product3), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().content().get(2).productId()).isEqualTo(product2) ); } diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankJpaRepository.java index aab645ed1..3dceb4bf7 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankJpaRepository.java +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankJpaRepository.java @@ -2,6 +2,7 @@ import java.util.List; +import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; @@ -26,7 +27,7 @@ public interface WeeklyRankJpaRepository extends JpaRepository findByIdYearWeekOrderByRankPosition(@Param("yearWeek") String yearWeek, Pageable pageable); + Page findByIdYearWeekOrderByRankPosition(@Param("yearWeek") String yearWeek, Pageable pageable); /** * 특정 주차의 모든 랭킹을 삭제합니다. diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankRepositoryImpl.java index b938e2c1f..5626d881e 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankRepositoryImpl.java +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankRepositoryImpl.java @@ -2,6 +2,7 @@ import java.util.List; +import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; @@ -36,13 +37,12 @@ public List findByYearWeek(String yearWeek) { } @Override - public List findByYearWeekWithPagination(String yearWeek, int page, int size) { - Pageable pageable = PageRequest.of(page, size); - return jpaRepository.findByIdYearWeekOrderByRankPosition(yearWeek, pageable); + public long deleteByYearWeek(String yearWeek) { + return jpaRepository.deleteByIdYearWeek(yearWeek); } @Override - public long deleteByYearWeek(String yearWeek) { - return jpaRepository.deleteByIdYearWeek(yearWeek); + public Page findByYearWeek(String yearWeek, Pageable pageable) { + return jpaRepository.findByIdYearWeekOrderByRankPosition(yearWeek, pageable); } } diff --git a/modules/jpa/src/main/java/com/loopers/domain/ranking/WeeklyRankRepository.java b/modules/jpa/src/main/java/com/loopers/domain/ranking/WeeklyRankRepository.java index 07d5a04b0..cc5ab6b07 100644 --- a/modules/jpa/src/main/java/com/loopers/domain/ranking/WeeklyRankRepository.java +++ b/modules/jpa/src/main/java/com/loopers/domain/ranking/WeeklyRankRepository.java @@ -2,6 +2,9 @@ import java.util.List; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + /** * 주간 랭킹 Repository 인터페이스 */ @@ -22,11 +25,6 @@ public interface WeeklyRankRepository { */ List findByYearWeek(String yearWeek); - /** - * 특정 주차의 랭킹을 페이지네이션하여 조회합니다. - */ - List findByYearWeekWithPagination(String yearWeek, int page, int size); - /** * 특정 주차의 모든 랭킹을 삭제합니다. (멱등성 보장용) * @@ -34,4 +32,6 @@ public interface WeeklyRankRepository { * @return 삭제된 레코드 수 */ long deleteByYearWeek(String yearWeek); + + Page findByYearWeek(String yearWeek, Pageable pageable); } From 6aee58a189026d38919a652f62c5c5a955427bbf Mon Sep 17 00:00:00 2001 From: hyujikoh Date: Fri, 2 Jan 2026 22:27:15 +0900 Subject: [PATCH 3/6] =?UTF-8?q?refactor(ranking):=20=EC=A3=BC=EC=B0=A8=20?= =?UTF-8?q?=EA=B3=84=EC=82=B0=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ISO 주차 기준으로 변경하여 일관성 있는 주차 계산 보장 --- .../loopers/interfaces/api/ranking/RankingV1Controller.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java index d1d841b99..37c0ba8dd 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java @@ -77,9 +77,9 @@ private String processYearWeekParameter(String yearWeek, LocalDate date) { // date가 있으면 해당 날짜의 주차, 없으면 현재 주차 LocalDate targetDate = date != null ? date : LocalDate.now(); - WeekFields weekFields = WeekFields.of(Locale.getDefault()); + WeekFields weekFields = WeekFields.ISO; int year = targetDate.getYear(); - int week = targetDate.get(weekFields.weekOfYear()); + int week = targetDate.get(weekFields.weekOfWeekBasedYear()); return String.format("%d-W%02d", year, week); } From 2be7a2f12d3c80e557ce8ecface967bc5bdf32d2 Mon Sep 17 00:00:00 2001 From: hyujikoh Date: Fri, 2 Jan 2026 22:32:07 +0900 Subject: [PATCH 4/6] =?UTF-8?q?feat(ranking):=20=EB=A9=94=ED=8A=B8?= =?UTF-8?q?=EB=A6=AD=20=EC=A7=91=EA=B3=84=20=EA=B3=B5=ED=86=B5=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=B6=94=EC=83=81=20=ED=81=B4=EB=9E=98=EC=8A=A4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 메트릭 Reader 공통 추상 클래스인 AbstractMetricsReader 구현 - 월간 및 주간 메트릭 Reader에서 상속하여 코드 중복 제거 - 랭킹 집계 로직을 통합하여 유지보수성 향상 --- .../ranking/reader/AbstractMetricsReader.java | 83 +++++++++++++++++++ .../ranking/reader/MonthlyMetricsReader.java | 59 ++++--------- .../ranking/reader/WeeklyMetricsReader.java | 59 ++++--------- 3 files changed, 119 insertions(+), 82 deletions(-) create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/reader/AbstractMetricsReader.java diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/reader/AbstractMetricsReader.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/reader/AbstractMetricsReader.java new file mode 100644 index 000000000..f0da39d5f --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/reader/AbstractMetricsReader.java @@ -0,0 +1,83 @@ +package com.loopers.batch.job.ranking.reader; + +import java.time.LocalDate; +import java.util.Iterator; +import java.util.List; + +import org.springframework.batch.item.ItemReader; + +import com.loopers.batch.job.ranking.dto.RankingAggregation; +import com.loopers.batch.job.ranking.support.RankingAggregator; +import com.loopers.domain.metrics.ProductMetricsRepository; + +import lombok.extern.slf4j.Slf4j; + +/** + * 메트릭 Reader 공통 추상 클래스 + * - 특정 기간의 데이터를 집계하고 랭킹을 생성하는 공통 로직을 포함 + */ +@Slf4j +public abstract class AbstractMetricsReader implements ItemReader { + + protected final ProductMetricsRepository productMetricsRepository; + protected final RankingAggregator rankingAggregator; + + private Iterator iterator; + + protected AbstractMetricsReader(ProductMetricsRepository productMetricsRepository, RankingAggregator rankingAggregator) { + this.productMetricsRepository = productMetricsRepository; + this.rankingAggregator = rankingAggregator; + } + + @Override + public RankingAggregation read() throws Exception { + if (iterator == null) { + initializeIterator(); + } + + return iterator.hasNext() ? iterator.next() : null; + } + + private void initializeIterator() { + String logIdentifier = getLogIdentifier(); + log.info("{} 랭킹 집계 시작: parameter={}", logIdentifier, getParameterValue()); + + try { + // 1. 기간 파싱 (추상 메서드 호출) + LocalDate[] dateRange = parseDateRange(); + LocalDate startDate = dateRange[0]; + LocalDate endDate = dateRange[1]; + + log.info("집계 기간: {} ~ {}", startDate, endDate); + + // 2. DB에서 집계 쿼리 실행 + List aggregationResults = productMetricsRepository.aggregateByDateRange(startDate, endDate); + log.info("집계 대상 상품 수: {}", aggregationResults.size()); + + // 3. 랭킹 처리 (정렬 + TOP 100 + 순위 부여) + List rankings = rankingAggregator.processRankings(aggregationResults); + log.info("생성된 랭킹 수: {}", rankings.size()); + + iterator = rankings.iterator(); + + } catch (Exception e) { + log.error("{} 랭킹 집계 중 오류 발생: parameter={}", logIdentifier, getParameterValue(), e); + throw new RuntimeException(logIdentifier + " 랭킹 집계 실패", e); + } + } + + /** + * 기간에 해당하는 LocalDate 범위를 반환합니다. + */ + protected abstract LocalDate[] parseDateRange(); + + /** + * 로그 식별자를 반환합니다. (예: "월간", "주간") + */ + protected abstract String getLogIdentifier(); + + /** + * 현재 사용 중인 파라미터 값을 반환합니다. + */ + protected abstract String getParameterValue(); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/reader/MonthlyMetricsReader.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/reader/MonthlyMetricsReader.java index ebba661d5..4486ffc1b 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/reader/MonthlyMetricsReader.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/reader/MonthlyMetricsReader.java @@ -1,20 +1,15 @@ package com.loopers.batch.job.ranking.reader; import java.time.LocalDate; -import java.util.Iterator; -import java.util.List; import org.springframework.batch.core.configuration.annotation.StepScope; -import org.springframework.batch.item.ItemReader; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; -import com.loopers.batch.job.ranking.dto.RankingAggregation; import com.loopers.batch.job.ranking.support.DateRangeParser; import com.loopers.batch.job.ranking.support.RankingAggregator; import com.loopers.domain.metrics.ProductMetricsRepository; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; /** @@ -25,51 +20,33 @@ @Slf4j @StepScope @Component -@RequiredArgsConstructor -public class MonthlyMetricsReader implements ItemReader { +public class MonthlyMetricsReader extends AbstractMetricsReader { - private final ProductMetricsRepository productMetricsRepository; private final DateRangeParser dateRangeParser; - private final RankingAggregator rankingAggregator; - - private Iterator iterator; @Value("#{jobParameters['yearMonth']}") private String yearMonth; // e.g., "2024-12" - @Override - public RankingAggregation read() throws Exception { - if (iterator == null) { - initializeIterator(); - } - - return iterator.hasNext() ? iterator.next() : null; + public MonthlyMetricsReader( + ProductMetricsRepository productMetricsRepository, + RankingAggregator rankingAggregator, + DateRangeParser dateRangeParser) { + super(productMetricsRepository, rankingAggregator); + this.dateRangeParser = dateRangeParser; } - private void initializeIterator() { - log.info("월간 랭킹 집계 시작: yearMonth={}", yearMonth); - - try { - // 1. 월 → 날짜 범위 변환 - LocalDate[] dateRange = dateRangeParser.parseYearMonth(yearMonth); - LocalDate startDate = dateRange[0]; - LocalDate endDate = dateRange[1]; - - log.info("집계 기간: {} ~ {}", startDate, endDate); - - // 2. DB에서 집계 쿼리 실행 - List aggregationResults = productMetricsRepository.aggregateByDateRange(startDate, endDate); - log.info("집계 대상 상품 수: {}", aggregationResults.size()); - - // 3. 랭킹 처리 (정렬 + TOP 100 + 순위 부여) - List rankings = rankingAggregator.processRankings(aggregationResults); - log.info("생성된 랭킹 수: {}", rankings.size()); + @Override + protected LocalDate[] parseDateRange() { + return dateRangeParser.parseYearMonth(yearMonth); + } - iterator = rankings.iterator(); + @Override + protected String getLogIdentifier() { + return "월간"; + } - } catch (Exception e) { - log.error("월간 랭킹 집계 중 오류 발생: yearMonth={}", yearMonth, e); - throw new RuntimeException("월간 랭킹 집계 실패", e); - } + @Override + protected String getParameterValue() { + return yearMonth; } } diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/reader/WeeklyMetricsReader.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/reader/WeeklyMetricsReader.java index 7394f3e31..f712214ee 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/reader/WeeklyMetricsReader.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/reader/WeeklyMetricsReader.java @@ -1,20 +1,15 @@ package com.loopers.batch.job.ranking.reader; import java.time.LocalDate; -import java.util.Iterator; -import java.util.List; import org.springframework.batch.core.configuration.annotation.StepScope; -import org.springframework.batch.item.ItemReader; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; -import com.loopers.batch.job.ranking.dto.RankingAggregation; import com.loopers.batch.job.ranking.support.DateRangeParser; import com.loopers.batch.job.ranking.support.RankingAggregator; import com.loopers.domain.metrics.ProductMetricsRepository; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; /** @@ -25,51 +20,33 @@ @Slf4j @StepScope @Component -@RequiredArgsConstructor -public class WeeklyMetricsReader implements ItemReader { +public class WeeklyMetricsReader extends AbstractMetricsReader { - private final ProductMetricsRepository productMetricsRepository; private final DateRangeParser dateRangeParser; - private final RankingAggregator rankingAggregator; - - private Iterator iterator; @Value("#{jobParameters['yearWeek']}") private String yearWeek; // e.g., "2024-W52" - @Override - public RankingAggregation read() throws Exception { - if (iterator == null) { - initializeIterator(); - } - - return iterator.hasNext() ? iterator.next() : null; + public WeeklyMetricsReader( + ProductMetricsRepository productMetricsRepository, + RankingAggregator rankingAggregator, + DateRangeParser dateRangeParser) { + super(productMetricsRepository, rankingAggregator); + this.dateRangeParser = dateRangeParser; } - private void initializeIterator() { - log.info("주간 랭킹 집계 시작: yearWeek={}", yearWeek); - - try { - // 1. 주차 → 날짜 범위 변환 - LocalDate[] dateRange = dateRangeParser.parseYearWeek(yearWeek); - LocalDate startDate = dateRange[0]; - LocalDate endDate = dateRange[1]; - - log.info("집계 기간: {} ~ {}", startDate, endDate); - - // 2. DB에서 집계 쿼리 실행 - List aggregationResults = productMetricsRepository.aggregateByDateRange(startDate, endDate); - log.info("집계 대상 상품 수: {}", aggregationResults.size()); - - // 3. 랭킹 처리 (정렬 + TOP 100 + 순위 부여) - List rankings = rankingAggregator.processRankings(aggregationResults); - log.info("생성된 랭킹 수: {}", rankings.size()); + @Override + protected LocalDate[] parseDateRange() { + return dateRangeParser.parseYearWeek(yearWeek); + } - iterator = rankings.iterator(); + @Override + protected String getLogIdentifier() { + return "주간"; + } - } catch (Exception e) { - log.error("주간 랭킹 집계 중 오류 발생: yearWeek={}", yearWeek, e); - throw new RuntimeException("주간 랭킹 집계 실패", e); - } + @Override + protected String getParameterValue() { + return yearWeek; } } From c02335e91335ea1cd7f528fd22f83649cb26f514 Mon Sep 17 00:00:00 2001 From: hyujikoh Date: Fri, 2 Jan 2026 22:50:36 +0900 Subject: [PATCH 5/6] =?UTF-8?q?test(ranking):=20=EC=A0=90=EC=88=98=20?= =?UTF-8?q?=EA=B3=84=EC=82=B0=20=EB=A1=9C=EC=A7=81=20=EB=B0=8F=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 점수 계산 방식 변경에 따른 테스트 값 업데이트 - 기존 테스트에서 사용된 점수 값 수정 --- .../job/ranking/support/ScoreCalculator.java | 2 +- .../dto/RankingAggregationUnitTest.java | 4 +-- .../support/RankingAggregatorUnitTest.java | 12 ++++----- .../support/ScoreCalculatorUnitTest.java | 26 +++++-------------- 4 files changed, 15 insertions(+), 29 deletions(-) diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/support/ScoreCalculator.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/support/ScoreCalculator.java index 6d9ffe447..980635a41 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/support/ScoreCalculator.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/support/ScoreCalculator.java @@ -37,7 +37,7 @@ public long calculate(long viewCount, long likeCount, long salesCount, long orde // 3. 최종 점수 계산 (소수점 처리를 위해 적절한 스케일 곱산 후 long 변환) // Redis ZSET의 score가 double임을 감안하여 정밀도를 유지합니다. - return (long) ((viewScore + likeScore + normalizedSalesScore) * 1000); + return (long) ((viewScore + likeScore + normalizedSalesScore) * 10); } /** diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/dto/RankingAggregationUnitTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/dto/RankingAggregationUnitTest.java index 63cfa3ebb..242373de9 100644 --- a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/dto/RankingAggregationUnitTest.java +++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/dto/RankingAggregationUnitTest.java @@ -32,7 +32,7 @@ void should_create_from_valid_aggregation_result() { Assertions.assertThat(aggregation.getSalesCount()).isEqualTo(10L); Assertions.assertThat(aggregation.getOrderCount()).isEqualTo(5L); // score = 100*1 + 50*3 + 10*5 + 5*2 = 310 - Assertions.assertThat(aggregation.getTotalScore()).isEqualTo(310L); + Assertions.assertThat(aggregation.getTotalScore()).isEqualTo(214L); Assertions.assertThat(aggregation.getRankPosition()).isEqualTo(0); // 초기값 } @@ -166,7 +166,7 @@ void should_return_correct_string_format() { // then Assertions.assertThat(result).contains("productId=1"); - Assertions.assertThat(result).contains("score=310"); + Assertions.assertThat(result).contains("score=214"); Assertions.assertThat(result).contains("rank=1"); } } diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/support/RankingAggregatorUnitTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/support/RankingAggregatorUnitTest.java index 0d980dd50..dbca864ed 100644 --- a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/support/RankingAggregatorUnitTest.java +++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/support/RankingAggregatorUnitTest.java @@ -25,9 +25,9 @@ class 랭킹_처리 { void should_sort_by_score_and_assign_ranks() { // given List results = List.of( - new Object[]{1L, 100L, 10L, 5L, 2L}, // score = 100 + 30 + 25 + 4 = 159 - new Object[]{2L, 200L, 20L, 10L, 4L}, // score = 200 + 60 + 50 + 8 = 318 - new Object[]{3L, 50L, 5L, 2L, 1L} // score = 50 + 15 + 10 + 2 = 77 + new Object[]{1L, 100L, 10L, 5L, 2L}, + new Object[]{2L, 200L, 20L, 10L, 4L}, + new Object[]{3L, 50L, 5L, 2L, 1L} ); // when @@ -39,15 +39,15 @@ void should_sort_by_score_and_assign_ranks() { // 점수 기준 내림차순 정렬 확인 Assertions.assertThat(rankings.get(0).getProductId()).isEqualTo(2L); // 1위 Assertions.assertThat(rankings.get(0).getRankPosition()).isEqualTo(1); - Assertions.assertThat(rankings.get(0).getTotalScore()).isEqualTo(318L); + Assertions.assertThat(rankings.get(0).getTotalScore()).isEqualTo(254L); Assertions.assertThat(rankings.get(1).getProductId()).isEqualTo(1L); // 2위 Assertions.assertThat(rankings.get(1).getRankPosition()).isEqualTo(2); - Assertions.assertThat(rankings.get(1).getTotalScore()).isEqualTo(159L); + Assertions.assertThat(rankings.get(1).getTotalScore()).isEqualTo(130L); Assertions.assertThat(rankings.get(2).getProductId()).isEqualTo(3L); // 3위 Assertions.assertThat(rankings.get(2).getRankPosition()).isEqualTo(3); - Assertions.assertThat(rankings.get(2).getTotalScore()).isEqualTo(77L); + Assertions.assertThat(rankings.get(2).getTotalScore()).isEqualTo(66L); } @Test diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/support/ScoreCalculatorUnitTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/support/ScoreCalculatorUnitTest.java index 9612e6278..ed89385af 100644 --- a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/support/ScoreCalculatorUnitTest.java +++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/support/ScoreCalculatorUnitTest.java @@ -24,8 +24,7 @@ void should_calculate_score_with_correct_weights() { long score = calculator.calculate(viewCount, likeCount, salesCount, orderCount); // then - // score = 100*1 + 50*3 + 10*5 + 5*2 = 100 + 150 + 50 + 10 = 310 - Assertions.assertThat(score).isEqualTo(310L); + Assertions.assertThat(score).isEqualTo(214L); } @Test @@ -50,18 +49,6 @@ void should_have_highest_weight_for_sales_count() { // when & then Assertions.assertThat(singleSale).isGreaterThan(singleView); Assertions.assertThat(singleSale).isGreaterThan(singleLike); - Assertions.assertThat(singleSale).isGreaterThan(singleOrder); - } - - @Test - @DisplayName("좋아요가 조회수보다 높은 가중치를 가진다") - void should_have_higher_weight_for_like_than_view() { - // given - long singleLike = calculator.calculate(0, 1, 0, 0); - long singleView = calculator.calculate(1, 0, 0, 0); - - // when & then - Assertions.assertThat(singleLike).isGreaterThan(singleView); } @Test @@ -77,7 +64,7 @@ void should_calculate_correctly_with_large_numbers() { long score = calculator.calculate(viewCount, likeCount, salesCount, orderCount); // then - long expected = 1_000_000L * 1 + 500_000L * 3 + 100_000L * 5 + 50_000L * 2; + long expected = (long) (((1_000_000L * 0.1) + (500_000L * 0.2) + Math.log1p(100_000L) *0.6 ) * 10); Assertions.assertThat(score).isEqualTo(expected); } } @@ -93,10 +80,9 @@ void should_return_weight_info_in_correct_format() { String weightInfo = calculator.getWeightInfo(); // then - Assertions.assertThat(weightInfo).contains("VIEW=1"); - Assertions.assertThat(weightInfo).contains("LIKE=3"); - Assertions.assertThat(weightInfo).contains("SALES=5"); - Assertions.assertThat(weightInfo).contains("ORDER=2"); + Assertions.assertThat(weightInfo).contains("VIEW=0.1"); + Assertions.assertThat(weightInfo).contains("LIKE=0.2"); + Assertions.assertThat(weightInfo).contains("SALES=0.6"); } } -} \ No newline at end of file +} From 9c262257bfcc0f8a918c2e71be14ff0ab5ce9a52 Mon Sep 17 00:00:00 2001 From: hyujikoh Date: Sat, 3 Jan 2026 00:11:36 +0900 Subject: [PATCH 6/6] =?UTF-8?q?feat(ranking):=20=ED=8C=90=EB=A7=A4?= =?UTF-8?q?=EB=9F=89=20=EB=B0=8F=20=EC=B4=9D=20=ED=8C=90=EB=A7=A4=20?= =?UTF-8?q?=EA=B8=88=EC=95=A1=20=EC=A7=91=EA=B3=84=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 판매량 증가 메서드에 총 판매 금액 매개변수 추가 - 월간 랭킹 조회 API에 페이징 처리 적용 - 관련된 리포지토리 및 서비스 메서드 수정 --- .../ranking/MonthlyRankingService.java | 31 ++-------------- .../ranking/MonthlyRankJpaRepository.java | 3 +- .../ranking/MonthlyRankRepositoryImpl.java | 9 ++--- .../com/loopers/fixtures/UserTestFixture.java | 7 ++-- .../job/ranking/dto/RankingAggregation.java | 13 ++++--- .../job/ranking/support/ScoreCalculator.java | 18 +++++----- .../metrics/ProductMetricsJpaRepository.java | 3 +- .../ranking/MonthlyRankJpaRepository.java | 9 ++--- .../ranking/MonthlyRankRepositoryImpl.java | 9 ++--- .../dto/RankingAggregationUnitTest.java | 25 ++++++------- .../support/RankingAggregatorUnitTest.java | 21 +++++------ .../support/ScoreCalculatorUnitTest.java | 36 +++++++++++-------- .../event/EventProcessingFacade.java | 2 +- .../application/metrics/MetricsService.java | 6 ++-- .../domain/metrics/ProductMetricsService.java | 13 +++---- .../metrics/ProductMetricsJpaRepository.java | 3 +- .../domain/metrics/ProductMetricsEntity.java | 12 +++++-- .../domain/ranking/MonthlyRankRepository.java | 9 +++-- 18 files changed, 106 insertions(+), 123 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/MonthlyRankingService.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/MonthlyRankingService.java index d1600b0cb..02f968fc3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/ranking/MonthlyRankingService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/MonthlyRankingService.java @@ -37,37 +37,12 @@ public Page getMonthlyRanking(String yearMonth, Pageable page yearMonth, pageable.getPageNumber(), pageable.getPageSize()); // 1. 전체 랭킹 조회 (순위 순으로 정렬됨) - List allRankings = monthlyRankRepository.findByYearMonth(yearMonth); + Page pagedRankings = monthlyRankRepository.findByYearMonth(yearMonth, pageable); - if (allRankings.isEmpty()) { - log.debug("월간 랭킹 데이터 없음: yearMonth={}", yearMonth); - return Page.empty(pageable); - } - - // 2. 페이징 처리 - int start = (int) pageable.getOffset(); - int end = Math.min(start + pageable.getPageSize(), allRankings.size()); - - if (start >= allRankings.size()) { - return Page.empty(pageable); - } - - List pagedRankings = allRankings.subList(start, end); log.debug("월간 랭킹 조회 완료: yearMonth={}, 전체={}, 페이지={}", - yearMonth, allRankings.size(), pagedRankings.size()); - - return new PageImpl<>(pagedRankings, pageable, allRankings.size()); - } + yearMonth, pagedRankings.getTotalPages(), pagedRankings.getNumber()); - /** - * 특정 월의 전체 랭킹 개수를 조회합니다. - * - * @param yearMonth 조회할 월 - * @return 랭킹 개수 - */ - public long getMonthlyRankingCount(String yearMonth) { - List rankings = monthlyRankRepository.findByYearMonth(yearMonth); - return rankings.size(); + return pagedRankings; } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankJpaRepository.java index 45a50042b..d45fcd5eb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankJpaRepository.java @@ -2,6 +2,7 @@ import java.util.List; +import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; @@ -26,7 +27,7 @@ public interface MonthlyRankJpaRepository extends JpaRepository findByIdYearMonthOrderByRankPosition(@Param("yearMonth") String yearMonth, Pageable pageable); + Page findByIdYearMonthOrderByRankPosition(@Param("yearMonth") String yearMonth, Pageable pageable); /** * 특정 월의 모든 랭킹을 삭제합니다. diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankRepositoryImpl.java index ec28bbb6c..cbee3ed47 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankRepositoryImpl.java @@ -2,6 +2,7 @@ import java.util.List; +import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; @@ -31,13 +32,7 @@ public List saveAll(List entities) { } @Override - public List findByYearMonth(String yearMonth) { - return jpaRepository.findByIdYearMonthOrderByRankPosition(yearMonth); - } - - @Override - public List findByYearMonthWithPagination(String yearMonth, int page, int size) { - Pageable pageable = PageRequest.of(page, size); + public Page findByYearMonth(String yearMonth, Pageable pageable) { return jpaRepository.findByIdYearMonthOrderByRankPosition(yearMonth, pageable); } diff --git a/apps/commerce-api/src/test/java/com/loopers/fixtures/UserTestFixture.java b/apps/commerce-api/src/test/java/com/loopers/fixtures/UserTestFixture.java index 49ee37912..30b41ac8a 100644 --- a/apps/commerce-api/src/test/java/com/loopers/fixtures/UserTestFixture.java +++ b/apps/commerce-api/src/test/java/com/loopers/fixtures/UserTestFixture.java @@ -1,5 +1,6 @@ package com.loopers.fixtures; +import java.math.BigDecimal; import java.time.LocalDate; import org.assertj.core.api.Assertions; @@ -165,20 +166,20 @@ public static class InvalidGender { * 사용자의 포인트가 0인지 검증하는 헬퍼 메서드 */ public static void assertUserPointIsZero(UserEntity user) { - Assertions.assertThat(user.getPointAmount()).isEqualByComparingTo(java.math.BigDecimal.ZERO.setScale(2)); + Assertions.assertThat(user.getPointAmount()).isEqualByComparingTo(BigDecimal.ZERO.setScale(2)); } /** * 사용자의 포인트 금액 검증 헬퍼 메서드 */ - public static void assertUserPointAmount(UserEntity user, java.math.BigDecimal expectedAmount) { + public static void assertUserPointAmount(UserEntity user, BigDecimal expectedAmount) { Assertions.assertThat(user.getPointAmount()).isEqualByComparingTo(expectedAmount); } /** * 포인트 충전 실패 검증 헬퍼 메서드 */ - public static void assertChargePointFails(UserEntity user, java.math.BigDecimal amount, String expectedMessage) { + public static void assertChargePointFails(UserEntity user, BigDecimal amount, String expectedMessage) { Assertions.assertThatThrownBy(() -> user.chargePoint(amount)) .isInstanceOf(IllegalArgumentException.class) .hasMessage(expectedMessage); diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/dto/RankingAggregation.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/dto/RankingAggregation.java index f8493e72d..b5b61a2a6 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/dto/RankingAggregation.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/dto/RankingAggregation.java @@ -1,5 +1,7 @@ package com.loopers.batch.job.ranking.dto; +import java.math.BigDecimal; + import com.loopers.batch.job.ranking.support.ScoreCalculator; import lombok.Getter; @@ -17,16 +19,18 @@ public class RankingAggregation { private final long likeCount; private final long salesCount; private final long orderCount; + private final BigDecimal totalSalesAmount; private final long totalScore; private int rankPosition; // 가변 필드 (순위 부여용) private RankingAggregation(Long productId, long viewCount, long likeCount, - long salesCount, long orderCount, long totalScore) { + long salesCount, long orderCount, BigDecimal totalSalesAmount, long totalScore) { this.productId = productId; this.viewCount = viewCount; this.likeCount = likeCount; this.salesCount = salesCount; this.orderCount = orderCount; + this.totalSalesAmount = totalSalesAmount; this.totalScore = totalScore; this.rankPosition = 0; // 초기값 } @@ -40,7 +44,7 @@ private RankingAggregation(Long productId, long viewCount, long likeCount, * @throws IllegalArgumentException row가 null이거나 형식이 잘못된 경우 */ public static RankingAggregation from(Object[] row, ScoreCalculator calculator) { - if (row == null || row.length < 5) { + if (row == null || row.length < 4) { throw new IllegalArgumentException("집계 결과 배열이 null이거나 길이가 부족합니다."); } @@ -50,10 +54,11 @@ public static RankingAggregation from(Object[] row, ScoreCalculator calculator) long likeCount = ((Number) row[2]).longValue(); long salesCount = ((Number) row[3]).longValue(); long orderCount = ((Number) row[4]).longValue(); + BigDecimal totalSalesAmount = (BigDecimal) row[5]; - long totalScore = calculator.calculate(viewCount, likeCount, salesCount, orderCount); + long totalScore = calculator.calculate(viewCount, likeCount, totalSalesAmount); - return new RankingAggregation(productId, viewCount, likeCount, salesCount, orderCount, totalScore); + return new RankingAggregation(productId, viewCount, likeCount, salesCount, orderCount, totalSalesAmount, totalScore); } catch (ClassCastException | NullPointerException e) { throw new IllegalArgumentException("집계 결과 데이터 형식이 올바르지 않습니다.", e); } diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/support/ScoreCalculator.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/support/ScoreCalculator.java index 980635a41..39950e248 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/support/ScoreCalculator.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/support/ScoreCalculator.java @@ -1,5 +1,7 @@ package com.loopers.batch.job.ranking.support; +import java.math.BigDecimal; + import org.springframework.stereotype.Component; /** @@ -18,22 +20,20 @@ public class ScoreCalculator { /** * 메트릭 데이터를 기반으로 랭킹 점수를 계산합니다. * - * @param viewCount 조회수 - * @param likeCount 좋아요수 - * @param salesCount 판매수량 - * @param orderCount 주문수 + * @param viewCount 조회수 + * @param likeCount 좋아요수 + * @param totalSalesAmount 총 판매 금액 * @return 계산된 총 점수 */ - public long calculate(long viewCount, long likeCount, long salesCount, long orderCount) { + public long calculate(long viewCount, long likeCount, BigDecimal totalSalesAmount) { // 1. 조회와 좋아요는 단순 수량 기반 가중치 적용 double viewScore = viewCount * VIEW_WEIGHT; double likeScore = likeCount * LIKE_WEIGHT; // 2. 판매량(Sales)은 CachePayloads.forPaymentSuccess와 동일하게 로그 정규화 적용 - // 배치에서는 이미 집계된 salesCount(수량)를 기반으로 하므로, - // 만약 금액 기반 정규화가 필요하다면 매개변수로 총액을 받아야 하지만, - // 수량 기반으로 로그 정규화를 적용한다면 아래와 같이 작성합니다. - double normalizedSalesScore = Math.log1p(salesCount) * SALES_WEIGHT; + // RankingScore.forPaymentSuccess: normalizedScore = Math.log(totalPrice.doubleValue() + 1); + double amount = totalSalesAmount != null ? totalSalesAmount.doubleValue() : 0.0; + double normalizedSalesScore = Math.log(amount + 1) * SALES_WEIGHT; // 3. 최종 점수 계산 (소수점 처리를 위해 적절한 스케일 곱산 후 long 변환) // Redis ZSET의 score가 double임을 감안하여 정밀도를 유지합니다. diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java index cc8474c32..d49e324a6 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java @@ -29,7 +29,8 @@ public interface ProductMetricsJpaRepository extends JpaRepository { - /** - * 특정 월의 랭킹을 순위 순으로 조회합니다. - */ - @Query("SELECT m FROM MonthlyRankEntity m WHERE m.id.yearMonth = :yearMonth ORDER BY m.rankPosition ASC") - List findByIdYearMonthOrderByRankPosition(@Param("yearMonth") String yearMonth); - /** * 특정 월의 랭킹을 순위 순으로 페이지네이션하여 조회합니다. */ @Query("SELECT m FROM MonthlyRankEntity m WHERE m.id.yearMonth = :yearMonth ORDER BY m.rankPosition ASC") - List findByIdYearMonthOrderByRankPosition(@Param("yearMonth") String yearMonth, Pageable pageable); + Page findByIdYearMonthOrderByRankPosition(@Param("yearMonth") String yearMonth, Pageable pageable); /** * 특정 월의 모든 랭킹을 삭제합니다. diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankRepositoryImpl.java index a2b005c18..478e195fc 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankRepositoryImpl.java +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankRepositoryImpl.java @@ -2,6 +2,7 @@ import java.util.List; +import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; @@ -31,13 +32,7 @@ public List saveAll(List entities) { } @Override - public List findByYearMonth(String yearMonth) { - return jpaRepository.findByIdYearMonthOrderByRankPosition(yearMonth); - } - - @Override - public List findByYearMonthWithPagination(String yearMonth, int page, int size) { - Pageable pageable = PageRequest.of(page, size); + public Page findByYearMonth(String yearMonth, Pageable pageable) { return jpaRepository.findByIdYearMonthOrderByRankPosition(yearMonth, pageable); } diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/dto/RankingAggregationUnitTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/dto/RankingAggregationUnitTest.java index 242373de9..9f6270804 100644 --- a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/dto/RankingAggregationUnitTest.java +++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/dto/RankingAggregationUnitTest.java @@ -20,7 +20,7 @@ class 집계_결과로부터_생성 { @DisplayName("유효한 집계 결과로부터 객체를 생성한다") void should_create_from_valid_aggregation_result() { // given - Object[] row = {1L, 100L, 50L, 10L, 5L}; // productId, view, like, sales, order + Object[] row = {1L, 100L, 50L, 10L, 5L, java.math.BigDecimal.valueOf(1000)}; // productId, view, like, sales, order, amount // when RankingAggregation aggregation = RankingAggregation.from(row, calculator); @@ -31,8 +31,9 @@ void should_create_from_valid_aggregation_result() { Assertions.assertThat(aggregation.getLikeCount()).isEqualTo(50L); Assertions.assertThat(aggregation.getSalesCount()).isEqualTo(10L); Assertions.assertThat(aggregation.getOrderCount()).isEqualTo(5L); - // score = 100*1 + 50*3 + 10*5 + 5*2 = 310 - Assertions.assertThat(aggregation.getTotalScore()).isEqualTo(214L); + Assertions.assertThat(aggregation.getTotalSalesAmount()).isEqualByComparingTo(java.math.BigDecimal.valueOf(1000)); + // score = (100*0.1 + 50*0.2 + log(1001)*0.6) * 10 = (10+10+4.145) * 10 = 241 + Assertions.assertThat(aggregation.getTotalScore()).isEqualTo(241L); Assertions.assertThat(aggregation.getRankPosition()).isEqualTo(0); // 초기값 } @@ -49,7 +50,7 @@ void should_throw_exception_when_row_is_null() { @DisplayName("길이가 부족한 배열에 대해 예외가 발생한다") void should_throw_exception_when_row_length_is_insufficient() { // given - Object[] shortRow = {1L, 100L, 50L}; // 길이 3 (5 미만) + Object[] shortRow = {1L, 100L, 50L}; // 길이 3 (6 미만) // when & then Assertions.assertThatThrownBy(() -> RankingAggregation.from(shortRow, calculator)) @@ -61,7 +62,7 @@ void should_throw_exception_when_row_length_is_insufficient() { @DisplayName("잘못된 데이터 타입에 대해 예외가 발생한다") void should_throw_exception_when_data_type_is_invalid() { // given - Object[] invalidRow = {"invalid", 100L, 50L, 10L, 5L}; // productId가 String + Object[] invalidRow = {"invalid", 100L, 50L, 10L, 5L, java.math.BigDecimal.valueOf(1000)}; // productId가 String // when & then Assertions.assertThatThrownBy(() -> RankingAggregation.from(invalidRow, calculator)) @@ -73,7 +74,7 @@ void should_throw_exception_when_data_type_is_invalid() { @DisplayName("Number 타입의 다양한 형태를 처리한다") void should_handle_various_number_types() { // given - Integer, Long, BigDecimal 등 다양한 Number 타입 - Object[] row = {1L, 100, 50L, 10, 5L}; + Object[] row = {1L, 100, 50L, 10, 5L, java.math.BigDecimal.valueOf(1000)}; // when RankingAggregation aggregation = RankingAggregation.from(row, calculator); @@ -94,7 +95,7 @@ class 순위_부여 { @DisplayName("유효한 순위를 부여한다") void should_assign_valid_rank() { // given - Object[] row = {1L, 100L, 50L, 10L, 5L}; + Object[] row = {1L, 100L, 50L, 10L, 5L, java.math.BigDecimal.valueOf(1000)}; RankingAggregation aggregation = RankingAggregation.from(row, calculator); // when @@ -108,7 +109,7 @@ void should_assign_valid_rank() { @DisplayName("100위까지 순위를 부여할 수 있다") void should_assign_rank_up_to_100() { // given - Object[] row = {1L, 100L, 50L, 10L, 5L}; + Object[] row = {1L, 100L, 50L, 10L, 5L, java.math.BigDecimal.valueOf(1000)}; RankingAggregation aggregation = RankingAggregation.from(row, calculator); // when @@ -122,7 +123,7 @@ void should_assign_rank_up_to_100() { @DisplayName("0 이하의 순위에 대해 예외가 발생한다") void should_throw_exception_when_rank_is_zero_or_negative() { // given - Object[] row = {1L, 100L, 50L, 10L, 5L}; + Object[] row = {1L, 100L, 50L, 10L, 5L, java.math.BigDecimal.valueOf(1000)}; RankingAggregation aggregation = RankingAggregation.from(row, calculator); // when & then @@ -139,7 +140,7 @@ void should_throw_exception_when_rank_is_zero_or_negative() { @DisplayName("100을 초과하는 순위에 대해 예외가 발생한다") void should_throw_exception_when_rank_exceeds_100() { // given - Object[] row = {1L, 100L, 50L, 10L, 5L}; + Object[] row = {1L, 100L, 50L, 10L, 5L, java.math.BigDecimal.valueOf(1000)}; RankingAggregation aggregation = RankingAggregation.from(row, calculator); // when & then @@ -157,7 +158,7 @@ class 문자열_표현 { @DisplayName("toString이 올바른 형식을 반환한다") void should_return_correct_string_format() { // given - Object[] row = {1L, 100L, 50L, 10L, 5L}; + Object[] row = {1L, 100L, 50L, 10L, 5L, java.math.BigDecimal.valueOf(1000)}; RankingAggregation aggregation = RankingAggregation.from(row, calculator); aggregation.assignRank(1); @@ -166,7 +167,7 @@ void should_return_correct_string_format() { // then Assertions.assertThat(result).contains("productId=1"); - Assertions.assertThat(result).contains("score=214"); + Assertions.assertThat(result).contains("score=241"); Assertions.assertThat(result).contains("rank=1"); } } diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/support/RankingAggregatorUnitTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/support/RankingAggregatorUnitTest.java index dbca864ed..818b6970a 100644 --- a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/support/RankingAggregatorUnitTest.java +++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/support/RankingAggregatorUnitTest.java @@ -1,5 +1,6 @@ package com.loopers.batch.job.ranking.support; +import java.math.BigDecimal; import java.util.ArrayList; import java.util.List; @@ -25,9 +26,9 @@ class 랭킹_처리 { void should_sort_by_score_and_assign_ranks() { // given List results = List.of( - new Object[]{1L, 100L, 10L, 5L, 2L}, - new Object[]{2L, 200L, 20L, 10L, 4L}, - new Object[]{3L, 50L, 5L, 2L, 1L} + new Object[]{1L, 100L, 10L, 5L, 2L , new BigDecimal(0)}, + new Object[]{2L, 200L, 20L, 10L, 4L, new BigDecimal(0)}, + new Object[]{3L, 50L, 5L, 2L, 1L, new BigDecimal(0)} ); // when @@ -39,15 +40,15 @@ void should_sort_by_score_and_assign_ranks() { // 점수 기준 내림차순 정렬 확인 Assertions.assertThat(rankings.get(0).getProductId()).isEqualTo(2L); // 1위 Assertions.assertThat(rankings.get(0).getRankPosition()).isEqualTo(1); - Assertions.assertThat(rankings.get(0).getTotalScore()).isEqualTo(254L); + Assertions.assertThat(rankings.get(0).getTotalScore()).isEqualTo(240L); Assertions.assertThat(rankings.get(1).getProductId()).isEqualTo(1L); // 2위 Assertions.assertThat(rankings.get(1).getRankPosition()).isEqualTo(2); - Assertions.assertThat(rankings.get(1).getTotalScore()).isEqualTo(130L); + Assertions.assertThat(rankings.get(1).getTotalScore()).isEqualTo(120L); Assertions.assertThat(rankings.get(2).getProductId()).isEqualTo(3L); // 3위 Assertions.assertThat(rankings.get(2).getRankPosition()).isEqualTo(3); - Assertions.assertThat(rankings.get(2).getTotalScore()).isEqualTo(66L); + Assertions.assertThat(rankings.get(2).getTotalScore()).isEqualTo(60L); } @Test @@ -57,7 +58,7 @@ void should_filter_results_beyond_top_100() { List results = new ArrayList<>(); for (int i = 1; i <= 150; i++) { // 점수가 높은 순서대로 생성 (i가 클수록 점수 높음) - results.add(new Object[]{(long) i, (long) i * 10, (long) i, (long) i, (long) i}); + results.add(new Object[]{(long) i, (long) i * 10, (long) i, (long) i, (long) i, new BigDecimal(i)}); } // when @@ -97,9 +98,9 @@ void should_return_empty_list_for_null_results() { void should_maintain_order_for_same_scores() { // given - 동일한 점수를 가진 상품들 List results = List.of( - new Object[]{1L, 100L, 0L, 0L, 0L}, // score = 100 - new Object[]{2L, 100L, 0L, 0L, 0L}, // score = 100 - new Object[]{3L, 100L, 0L, 0L, 0L} // score = 100 + new Object[]{1L, 100L, 0L, 0L, 0L, new BigDecimal(0)}, // score = 100 + new Object[]{2L, 100L, 0L, 0L, 0L, new BigDecimal(0)}, // score = 100 + new Object[]{3L, 100L, 0L, 0L, 0L, new BigDecimal(0)} // score = 100 ); // when diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/support/ScoreCalculatorUnitTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/support/ScoreCalculatorUnitTest.java index ed89385af..7d8b4e36b 100644 --- a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/support/ScoreCalculatorUnitTest.java +++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/support/ScoreCalculatorUnitTest.java @@ -19,36 +19,41 @@ class 점수_계산 { void should_calculate_score_with_correct_weights() { // given long viewCount = 100, likeCount = 50, salesCount = 10, orderCount = 5; + java.math.BigDecimal totalSalesAmount = java.math.BigDecimal.valueOf(1000); // when - long score = calculator.calculate(viewCount, likeCount, salesCount, orderCount); + long score = calculator.calculate(viewCount, likeCount, totalSalesAmount); // then - Assertions.assertThat(score).isEqualTo(214L); + // viewScore = 100 * 0.1 = 10.0 + // likeScore = 50 * 0.2 = 10.0 + // salesScore = log(1000 + 1) * 0.6 = 6.908 * 0.6 = 4.145 + // total = (10.0 + 10.0 + 4.145) * 10 = 24.145 * 10 = 241 + Assertions.assertThat(score).isEqualTo(241L); } @Test @DisplayName("모든 메트릭이 0인 경우 점수는 0이다") void should_return_zero_when_all_metrics_are_zero() { // given & when - long score = calculator.calculate(0, 0, 0, 0); + long score = calculator.calculate(0, 0, java.math.BigDecimal.ZERO); // then Assertions.assertThat(score).isEqualTo(0L); } @Test - @DisplayName("판매수량이 가장 높은 가중치를 가진다") - void should_have_highest_weight_for_sales_count() { + @DisplayName("판매금액이 가장 높은 가중치를 가진다") + void should_have_highest_weight_for_sales_amount() { // given - long singleSale = calculator.calculate(0, 0, 1, 0); - long singleView = calculator.calculate(1, 0, 0, 0); - long singleLike = calculator.calculate(0, 1, 0, 0); - long singleOrder = calculator.calculate(0, 0, 0, 1); - - // when & then - Assertions.assertThat(singleSale).isGreaterThan(singleView); - Assertions.assertThat(singleSale).isGreaterThan(singleLike); + long viewScore = calculator.calculate(100, 0, java.math.BigDecimal.ZERO); // 100 * 0.1 * 10 = 100 + long amountScore = calculator.calculate(0, 0, java.math.BigDecimal.valueOf(1000000)); // log(1000001) * 0.6 * 10 = 13.8 * 6 = 82 + + // 로그 정규화로 인해 금액이 매우 커야 다른 지표를 압도함 + long largeAmountScore = calculator.calculate(0, 0, java.math.BigDecimal.valueOf(1000000000)); + + Assertions.assertThat(viewScore).isEqualTo(100L); + Assertions.assertThat(largeAmountScore).isGreaterThan(viewScore); } @Test @@ -59,12 +64,13 @@ void should_calculate_correctly_with_large_numbers() { long likeCount = 500_000L; long salesCount = 100_000L; long orderCount = 50_000L; + java.math.BigDecimal totalSalesAmount = java.math.BigDecimal.valueOf(100_000_000L); // when - long score = calculator.calculate(viewCount, likeCount, salesCount, orderCount); + long score = calculator.calculate(viewCount, likeCount, totalSalesAmount); // then - long expected = (long) (((1_000_000L * 0.1) + (500_000L * 0.2) + Math.log1p(100_000L) *0.6 ) * 10); + long expected = (long) (((1_000_000L * 0.1) + (500_000L * 0.2) + Math.log(100_000_000L + 1) * 0.6) * 10); Assertions.assertThat(score).isEqualTo(expected); } } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/event/EventProcessingFacade.java b/apps/commerce-streamer/src/main/java/com/loopers/application/event/EventProcessingFacade.java index 9a2c71eaa..5450a2870 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/application/event/EventProcessingFacade.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/event/EventProcessingFacade.java @@ -218,7 +218,7 @@ private OrderEventResult processPaymentSuccess(DomainEventEnvelope envelope) { return OrderEventResult.notProcessed(); } - metricsService.addSales(payload.productId(), payload.quantity(), envelope.occurredAtEpochMillis()); + metricsService.addSales(payload.productId(), payload.quantity(), payload.totalPrice(), envelope.occurredAtEpochMillis()); log.debug("Processed PAYMENT_SUCCESS - orderId: {}, productId: {}, quantity: {}, totalPrice: {}", payload.orderId(), payload.productId(), payload.quantity(), payload.totalPrice()); diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsService.java b/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsService.java index acf3487d1..8b3933131 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsService.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsService.java @@ -121,15 +121,15 @@ public void applyLikeDelta(Long productId, int delta, long occurredAtEpochMillis /** * 판매량 증가 */ - public void addSales(Long productId, int quantity, long occurredAtEpochMillis) { + public void addSales(Long productId, int quantity, java.math.BigDecimal totalAmount, long occurredAtEpochMillis) { executeWithLock(productId, () -> { ZonedDateTime eventTime = convertToZonedDateTime(occurredAtEpochMillis); - boolean updated = productMetricsService.addSales(productId, quantity, eventTime); + boolean updated = productMetricsService.addSales(productId, quantity, totalAmount, eventTime); if (updated) { // 캐시 무효화 (판매량 변경 - 인기 상품 순위 영향) productCacheService.onSalesCountChanged(productId); - log.debug("판매량 업데이트 성공: productId={}, quantity={}", productId, quantity); + log.debug("판매량 업데이트 성공: productId={}, quantity={}, totalAmount={}", productId, quantity, totalAmount); } }); } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java index f9510d857..94bf2478c 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java @@ -80,13 +80,14 @@ public boolean applyLikeDelta(Long productId, int delta, ZonedDateTime eventTime /** * 판매량 증가 * - * @param productId 상품 ID - * @param quantity 판매 수량 - * @param eventTime 이벤트 발생 시간 + * @param productId 상품 ID + * @param quantity 판매 수량 + * @param totalAmount 총 판매 금액 + * @param eventTime 이벤트 발생 시간 * @return true: 증가됨, false: 증가 안 됨 (잘못된 수량) */ @Transactional - public boolean addSales(Long productId, int quantity, ZonedDateTime eventTime) { + public boolean addSales(Long productId, int quantity, java.math.BigDecimal totalAmount, ZonedDateTime eventTime) { if (quantity <= 0) { log.debug("잘못된 판매량 무시: productId={}, quantity={}", productId, quantity); return false; @@ -94,9 +95,9 @@ public boolean addSales(Long productId, int quantity, ZonedDateTime eventTime) { LocalDate metricDate = eventTime.toLocalDate(); ProductMetricsEntity metrics = getOrCreateMetrics(productId, metricDate); - metrics.addSales(quantity, eventTime); + metrics.addSales(quantity, totalAmount, eventTime); productMetricsRepository.save(metrics); - log.debug("판매량 증가 완료: productId={}, quantity={}, date={}", productId, quantity, metricDate); + log.debug("판매량 증가 완료: productId={}, quantity={}, totalAmount={}, date={}", productId, quantity, totalAmount, metricDate); return true; } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java index d0aa2dbd4..cbdc2bea6 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java @@ -49,7 +49,8 @@ List findByMetricDateBetween( SUM(m.viewCount), SUM(m.likeCount), SUM(m.salesCount), - SUM(m.orderCount) + SUM(m.orderCount), + SUM(m.totalSalesAmount) FROM ProductMetricsEntity m WHERE m.id.metricDate BETWEEN :startDate AND :endDate GROUP BY m.id.productId diff --git a/modules/jpa/src/main/java/com/loopers/domain/metrics/ProductMetricsEntity.java b/modules/jpa/src/main/java/com/loopers/domain/metrics/ProductMetricsEntity.java index cba44c95a..7b3b27828 100644 --- a/modules/jpa/src/main/java/com/loopers/domain/metrics/ProductMetricsEntity.java +++ b/modules/jpa/src/main/java/com/loopers/domain/metrics/ProductMetricsEntity.java @@ -43,6 +43,9 @@ public class ProductMetricsEntity { @Column(name = "order_count", nullable = false) private long orderCount = 0L; + @Column(name = "total_sales_amount", nullable = false) + private java.math.BigDecimal totalSalesAmount = java.math.BigDecimal.ZERO; + @Column(name = "last_event_at") private ZonedDateTime lastEventAt; @@ -50,6 +53,7 @@ private ProductMetricsEntity(Long productId, LocalDate metricDate) { Objects.requireNonNull(productId, "상품 ID는 필수입니다."); Objects.requireNonNull(metricDate, "메트릭 날짜는 필수입니다."); this.id = ProductMetricsId.of(productId, metricDate); + this.totalSalesAmount = java.math.BigDecimal.ZERO; } /** @@ -108,15 +112,17 @@ public void applyLikeDelta(int delta, ZonedDateTime eventTime) { /** * 판매량 증가 * - * @param quantity 판매 수량 - * @param eventTime 이벤트 발생 시간 + * @param quantity 판매 수량 + * @param totalAmount 총 판매 금액 + * @param eventTime 이벤트 발생 시간 */ - public void addSales(int quantity, ZonedDateTime eventTime) { + public void addSales(int quantity, java.math.BigDecimal totalAmount, ZonedDateTime eventTime) { if (quantity <= 0) { return; } this.salesCount += quantity; this.orderCount += 1; + this.totalSalesAmount = this.totalSalesAmount.add(totalAmount != null ? totalAmount : java.math.BigDecimal.ZERO); this.lastEventAt = eventTime; } } diff --git a/modules/jpa/src/main/java/com/loopers/domain/ranking/MonthlyRankRepository.java b/modules/jpa/src/main/java/com/loopers/domain/ranking/MonthlyRankRepository.java index f531e9dc0..127f823a6 100644 --- a/modules/jpa/src/main/java/com/loopers/domain/ranking/MonthlyRankRepository.java +++ b/modules/jpa/src/main/java/com/loopers/domain/ranking/MonthlyRankRepository.java @@ -2,6 +2,9 @@ import java.util.List; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + /** * 월간 랭킹 Repository 인터페이스 */ @@ -20,12 +23,8 @@ public interface MonthlyRankRepository { /** * 특정 월의 랭킹을 조회합니다. */ - List findByYearMonth(String yearMonth); + Page findByYearMonth(String yearMonth, Pageable pageable); - /** - * 특정 월의 랭킹을 페이지네이션하여 조회합니다. - */ - List findByYearMonthWithPagination(String yearMonth, int page, int size); /** * 특정 월의 모든 랭킹을 삭제합니다. (멱등성 보장용)