Skip to content

Commit 9c26225

Browse files
committed
feat(ranking): 판매량 및 총 판매 금액 집계 로직 개선
- 판매량 증가 메서드에 총 판매 금액 매개변수 추가 - 월간 랭킹 조회 API에 페이징 처리 적용 - 관련된 리포지토리 및 서비스 메서드 수정
1 parent c02335e commit 9c26225

18 files changed

Lines changed: 106 additions & 123 deletions

File tree

apps/commerce-api/src/main/java/com/loopers/application/ranking/MonthlyRankingService.java

Lines changed: 3 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -37,37 +37,12 @@ public Page<MonthlyRankEntity> getMonthlyRanking(String yearMonth, Pageable page
3737
yearMonth, pageable.getPageNumber(), pageable.getPageSize());
3838

3939
// 1. 전체 랭킹 조회 (순위 순으로 정렬됨)
40-
List<MonthlyRankEntity> allRankings = monthlyRankRepository.findByYearMonth(yearMonth);
40+
Page<MonthlyRankEntity> pagedRankings = monthlyRankRepository.findByYearMonth(yearMonth, pageable);
4141

42-
if (allRankings.isEmpty()) {
43-
log.debug("월간 랭킹 데이터 없음: yearMonth={}", yearMonth);
44-
return Page.empty(pageable);
45-
}
46-
47-
// 2. 페이징 처리
48-
int start = (int) pageable.getOffset();
49-
int end = Math.min(start + pageable.getPageSize(), allRankings.size());
50-
51-
if (start >= allRankings.size()) {
52-
return Page.empty(pageable);
53-
}
54-
55-
List<MonthlyRankEntity> pagedRankings = allRankings.subList(start, end);
5642

5743
log.debug("월간 랭킹 조회 완료: yearMonth={}, 전체={}, 페이지={}",
58-
yearMonth, allRankings.size(), pagedRankings.size());
59-
60-
return new PageImpl<>(pagedRankings, pageable, allRankings.size());
61-
}
44+
yearMonth, pagedRankings.getTotalPages(), pagedRankings.getNumber());
6245

63-
/**
64-
* 특정 월의 전체 랭킹 개수를 조회합니다.
65-
*
66-
* @param yearMonth 조회할 월
67-
* @return 랭킹 개수
68-
*/
69-
public long getMonthlyRankingCount(String yearMonth) {
70-
List<MonthlyRankEntity> rankings = monthlyRankRepository.findByYearMonth(yearMonth);
71-
return rankings.size();
46+
return pagedRankings;
7247
}
7348
}

apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankJpaRepository.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import java.util.List;
44

5+
import org.springframework.data.domain.Page;
56
import org.springframework.data.domain.Pageable;
67
import org.springframework.data.jpa.repository.JpaRepository;
78
import org.springframework.data.jpa.repository.Modifying;
@@ -26,7 +27,7 @@ public interface MonthlyRankJpaRepository extends JpaRepository<MonthlyRankEntit
2627
* 특정 월의 랭킹을 순위 순으로 페이지네이션하여 조회합니다.
2728
*/
2829
@Query("SELECT m FROM MonthlyRankEntity m WHERE m.id.yearMonth = :yearMonth ORDER BY m.rankPosition ASC")
29-
List<MonthlyRankEntity> findByIdYearMonthOrderByRankPosition(@Param("yearMonth") String yearMonth, Pageable pageable);
30+
Page<MonthlyRankEntity> findByIdYearMonthOrderByRankPosition(@Param("yearMonth") String yearMonth, Pageable pageable);
3031

3132
/**
3233
* 특정 월의 모든 랭킹을 삭제합니다.

apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankRepositoryImpl.java

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import java.util.List;
44

5+
import org.springframework.data.domain.Page;
56
import org.springframework.data.domain.PageRequest;
67
import org.springframework.data.domain.Pageable;
78
import org.springframework.stereotype.Repository;
@@ -31,13 +32,7 @@ public List<MonthlyRankEntity> saveAll(List<MonthlyRankEntity> entities) {
3132
}
3233

3334
@Override
34-
public List<MonthlyRankEntity> findByYearMonth(String yearMonth) {
35-
return jpaRepository.findByIdYearMonthOrderByRankPosition(yearMonth);
36-
}
37-
38-
@Override
39-
public List<MonthlyRankEntity> findByYearMonthWithPagination(String yearMonth, int page, int size) {
40-
Pageable pageable = PageRequest.of(page, size);
35+
public Page<MonthlyRankEntity> findByYearMonth(String yearMonth, Pageable pageable) {
4136
return jpaRepository.findByIdYearMonthOrderByRankPosition(yearMonth, pageable);
4237
}
4338

apps/commerce-api/src/test/java/com/loopers/fixtures/UserTestFixture.java

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.loopers.fixtures;
22

3+
import java.math.BigDecimal;
34
import java.time.LocalDate;
45

56
import org.assertj.core.api.Assertions;
@@ -165,20 +166,20 @@ public static class InvalidGender {
165166
* 사용자의 포인트가 0인지 검증하는 헬퍼 메서드
166167
*/
167168
public static void assertUserPointIsZero(UserEntity user) {
168-
Assertions.assertThat(user.getPointAmount()).isEqualByComparingTo(java.math.BigDecimal.ZERO.setScale(2));
169+
Assertions.assertThat(user.getPointAmount()).isEqualByComparingTo(BigDecimal.ZERO.setScale(2));
169170
}
170171

171172
/**
172173
* 사용자의 포인트 금액 검증 헬퍼 메서드
173174
*/
174-
public static void assertUserPointAmount(UserEntity user, java.math.BigDecimal expectedAmount) {
175+
public static void assertUserPointAmount(UserEntity user, BigDecimal expectedAmount) {
175176
Assertions.assertThat(user.getPointAmount()).isEqualByComparingTo(expectedAmount);
176177
}
177178

178179
/**
179180
* 포인트 충전 실패 검증 헬퍼 메서드
180181
*/
181-
public static void assertChargePointFails(UserEntity user, java.math.BigDecimal amount, String expectedMessage) {
182+
public static void assertChargePointFails(UserEntity user, BigDecimal amount, String expectedMessage) {
182183
Assertions.assertThatThrownBy(() -> user.chargePoint(amount))
183184
.isInstanceOf(IllegalArgumentException.class)
184185
.hasMessage(expectedMessage);

apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/dto/RankingAggregation.java

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package com.loopers.batch.job.ranking.dto;
22

3+
import java.math.BigDecimal;
4+
35
import com.loopers.batch.job.ranking.support.ScoreCalculator;
46

57
import lombok.Getter;
@@ -17,16 +19,18 @@ public class RankingAggregation {
1719
private final long likeCount;
1820
private final long salesCount;
1921
private final long orderCount;
22+
private final BigDecimal totalSalesAmount;
2023
private final long totalScore;
2124
private int rankPosition; // 가변 필드 (순위 부여용)
2225

2326
private RankingAggregation(Long productId, long viewCount, long likeCount,
24-
long salesCount, long orderCount, long totalScore) {
27+
long salesCount, long orderCount, BigDecimal totalSalesAmount, long totalScore) {
2528
this.productId = productId;
2629
this.viewCount = viewCount;
2730
this.likeCount = likeCount;
2831
this.salesCount = salesCount;
2932
this.orderCount = orderCount;
33+
this.totalSalesAmount = totalSalesAmount;
3034
this.totalScore = totalScore;
3135
this.rankPosition = 0; // 초기값
3236
}
@@ -40,7 +44,7 @@ private RankingAggregation(Long productId, long viewCount, long likeCount,
4044
* @throws IllegalArgumentException row가 null이거나 형식이 잘못된 경우
4145
*/
4246
public static RankingAggregation from(Object[] row, ScoreCalculator calculator) {
43-
if (row == null || row.length < 5) {
47+
if (row == null || row.length < 4) {
4448
throw new IllegalArgumentException("집계 결과 배열이 null이거나 길이가 부족합니다.");
4549
}
4650

@@ -50,10 +54,11 @@ public static RankingAggregation from(Object[] row, ScoreCalculator calculator)
5054
long likeCount = ((Number) row[2]).longValue();
5155
long salesCount = ((Number) row[3]).longValue();
5256
long orderCount = ((Number) row[4]).longValue();
57+
BigDecimal totalSalesAmount = (BigDecimal) row[5];
5358

54-
long totalScore = calculator.calculate(viewCount, likeCount, salesCount, orderCount);
59+
long totalScore = calculator.calculate(viewCount, likeCount, totalSalesAmount);
5560

56-
return new RankingAggregation(productId, viewCount, likeCount, salesCount, orderCount, totalScore);
61+
return new RankingAggregation(productId, viewCount, likeCount, salesCount, orderCount, totalSalesAmount, totalScore);
5762
} catch (ClassCastException | NullPointerException e) {
5863
throw new IllegalArgumentException("집계 결과 데이터 형식이 올바르지 않습니다.", e);
5964
}

apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/support/ScoreCalculator.java

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package com.loopers.batch.job.ranking.support;
22

3+
import java.math.BigDecimal;
4+
35
import org.springframework.stereotype.Component;
46

57
/**
@@ -18,22 +20,20 @@ public class ScoreCalculator {
1820
/**
1921
* 메트릭 데이터를 기반으로 랭킹 점수를 계산합니다.
2022
*
21-
* @param viewCount 조회수
22-
* @param likeCount 좋아요수
23-
* @param salesCount 판매수량
24-
* @param orderCount 주문수
23+
* @param viewCount 조회수
24+
* @param likeCount 좋아요수
25+
* @param totalSalesAmount 총 판매 금액
2526
* @return 계산된 총 점수
2627
*/
27-
public long calculate(long viewCount, long likeCount, long salesCount, long orderCount) {
28+
public long calculate(long viewCount, long likeCount, BigDecimal totalSalesAmount) {
2829
// 1. 조회와 좋아요는 단순 수량 기반 가중치 적용
2930
double viewScore = viewCount * VIEW_WEIGHT;
3031
double likeScore = likeCount * LIKE_WEIGHT;
3132

3233
// 2. 판매량(Sales)은 CachePayloads.forPaymentSuccess와 동일하게 로그 정규화 적용
33-
// 배치에서는 이미 집계된 salesCount(수량)를 기반으로 하므로,
34-
// 만약 금액 기반 정규화가 필요하다면 매개변수로 총액을 받아야 하지만,
35-
// 수량 기반으로 로그 정규화를 적용한다면 아래와 같이 작성합니다.
36-
double normalizedSalesScore = Math.log1p(salesCount) * SALES_WEIGHT;
34+
// RankingScore.forPaymentSuccess: normalizedScore = Math.log(totalPrice.doubleValue() + 1);
35+
double amount = totalSalesAmount != null ? totalSalesAmount.doubleValue() : 0.0;
36+
double normalizedSalesScore = Math.log(amount + 1) * SALES_WEIGHT;
3737

3838
// 3. 최종 점수 계산 (소수점 처리를 위해 적절한 스케일 곱산 후 long 변환)
3939
// Redis ZSET의 score가 double임을 감안하여 정밀도를 유지합니다.

apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ public interface ProductMetricsJpaRepository extends JpaRepository<ProductMetric
2929
SUM(m.viewCount),
3030
SUM(m.likeCount),
3131
SUM(m.salesCount),
32-
SUM(m.orderCount)
32+
SUM(m.orderCount),
33+
SUM(m.totalSalesAmount)
3334
FROM ProductMetricsEntity m
3435
WHERE m.id.metricDate BETWEEN :startDate AND :endDate
3536
GROUP BY m.id.productId

apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankJpaRepository.java

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import java.util.List;
44

5+
import org.springframework.data.domain.Page;
56
import org.springframework.data.domain.Pageable;
67
import org.springframework.data.jpa.repository.JpaRepository;
78
import org.springframework.data.jpa.repository.Modifying;
@@ -16,17 +17,11 @@
1617
*/
1718
public interface MonthlyRankJpaRepository extends JpaRepository<MonthlyRankEntity, MonthlyRankId> {
1819

19-
/**
20-
* 특정 월의 랭킹을 순위 순으로 조회합니다.
21-
*/
22-
@Query("SELECT m FROM MonthlyRankEntity m WHERE m.id.yearMonth = :yearMonth ORDER BY m.rankPosition ASC")
23-
List<MonthlyRankEntity> findByIdYearMonthOrderByRankPosition(@Param("yearMonth") String yearMonth);
24-
2520
/**
2621
* 특정 월의 랭킹을 순위 순으로 페이지네이션하여 조회합니다.
2722
*/
2823
@Query("SELECT m FROM MonthlyRankEntity m WHERE m.id.yearMonth = :yearMonth ORDER BY m.rankPosition ASC")
29-
List<MonthlyRankEntity> findByIdYearMonthOrderByRankPosition(@Param("yearMonth") String yearMonth, Pageable pageable);
24+
Page<MonthlyRankEntity> findByIdYearMonthOrderByRankPosition(@Param("yearMonth") String yearMonth, Pageable pageable);
3025

3126
/**
3227
* 특정 월의 모든 랭킹을 삭제합니다.

apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankRepositoryImpl.java

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import java.util.List;
44

5+
import org.springframework.data.domain.Page;
56
import org.springframework.data.domain.PageRequest;
67
import org.springframework.data.domain.Pageable;
78
import org.springframework.stereotype.Repository;
@@ -31,13 +32,7 @@ public List<MonthlyRankEntity> saveAll(List<MonthlyRankEntity> entities) {
3132
}
3233

3334
@Override
34-
public List<MonthlyRankEntity> findByYearMonth(String yearMonth) {
35-
return jpaRepository.findByIdYearMonthOrderByRankPosition(yearMonth);
36-
}
37-
38-
@Override
39-
public List<MonthlyRankEntity> findByYearMonthWithPagination(String yearMonth, int page, int size) {
40-
Pageable pageable = PageRequest.of(page, size);
35+
public Page<MonthlyRankEntity> findByYearMonth(String yearMonth, Pageable pageable) {
4136
return jpaRepository.findByIdYearMonthOrderByRankPosition(yearMonth, pageable);
4237
}
4338

apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/dto/RankingAggregationUnitTest.java

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ class 집계_결과로부터_생성 {
2020
@DisplayName("유효한 집계 결과로부터 객체를 생성한다")
2121
void should_create_from_valid_aggregation_result() {
2222
// given
23-
Object[] row = {1L, 100L, 50L, 10L, 5L}; // productId, view, like, sales, order
23+
Object[] row = {1L, 100L, 50L, 10L, 5L, java.math.BigDecimal.valueOf(1000)}; // productId, view, like, sales, order, amount
2424

2525
// when
2626
RankingAggregation aggregation = RankingAggregation.from(row, calculator);
@@ -31,8 +31,9 @@ void should_create_from_valid_aggregation_result() {
3131
Assertions.assertThat(aggregation.getLikeCount()).isEqualTo(50L);
3232
Assertions.assertThat(aggregation.getSalesCount()).isEqualTo(10L);
3333
Assertions.assertThat(aggregation.getOrderCount()).isEqualTo(5L);
34-
// score = 100*1 + 50*3 + 10*5 + 5*2 = 310
35-
Assertions.assertThat(aggregation.getTotalScore()).isEqualTo(214L);
34+
Assertions.assertThat(aggregation.getTotalSalesAmount()).isEqualByComparingTo(java.math.BigDecimal.valueOf(1000));
35+
// score = (100*0.1 + 50*0.2 + log(1001)*0.6) * 10 = (10+10+4.145) * 10 = 241
36+
Assertions.assertThat(aggregation.getTotalScore()).isEqualTo(241L);
3637
Assertions.assertThat(aggregation.getRankPosition()).isEqualTo(0); // 초기값
3738
}
3839

@@ -49,7 +50,7 @@ void should_throw_exception_when_row_is_null() {
4950
@DisplayName("길이가 부족한 배열에 대해 예외가 발생한다")
5051
void should_throw_exception_when_row_length_is_insufficient() {
5152
// given
52-
Object[] shortRow = {1L, 100L, 50L}; // 길이 3 (5 미만)
53+
Object[] shortRow = {1L, 100L, 50L}; // 길이 3 (6 미만)
5354

5455
// when & then
5556
Assertions.assertThatThrownBy(() -> RankingAggregation.from(shortRow, calculator))
@@ -61,7 +62,7 @@ void should_throw_exception_when_row_length_is_insufficient() {
6162
@DisplayName("잘못된 데이터 타입에 대해 예외가 발생한다")
6263
void should_throw_exception_when_data_type_is_invalid() {
6364
// given
64-
Object[] invalidRow = {"invalid", 100L, 50L, 10L, 5L}; // productId가 String
65+
Object[] invalidRow = {"invalid", 100L, 50L, 10L, 5L, java.math.BigDecimal.valueOf(1000)}; // productId가 String
6566

6667
// when & then
6768
Assertions.assertThatThrownBy(() -> RankingAggregation.from(invalidRow, calculator))
@@ -73,7 +74,7 @@ void should_throw_exception_when_data_type_is_invalid() {
7374
@DisplayName("Number 타입의 다양한 형태를 처리한다")
7475
void should_handle_various_number_types() {
7576
// given - Integer, Long, BigDecimal 등 다양한 Number 타입
76-
Object[] row = {1L, 100, 50L, 10, 5L};
77+
Object[] row = {1L, 100, 50L, 10, 5L, java.math.BigDecimal.valueOf(1000)};
7778

7879
// when
7980
RankingAggregation aggregation = RankingAggregation.from(row, calculator);
@@ -94,7 +95,7 @@ class 순위_부여 {
9495
@DisplayName("유효한 순위를 부여한다")
9596
void should_assign_valid_rank() {
9697
// given
97-
Object[] row = {1L, 100L, 50L, 10L, 5L};
98+
Object[] row = {1L, 100L, 50L, 10L, 5L, java.math.BigDecimal.valueOf(1000)};
9899
RankingAggregation aggregation = RankingAggregation.from(row, calculator);
99100

100101
// when
@@ -108,7 +109,7 @@ void should_assign_valid_rank() {
108109
@DisplayName("100위까지 순위를 부여할 수 있다")
109110
void should_assign_rank_up_to_100() {
110111
// given
111-
Object[] row = {1L, 100L, 50L, 10L, 5L};
112+
Object[] row = {1L, 100L, 50L, 10L, 5L, java.math.BigDecimal.valueOf(1000)};
112113
RankingAggregation aggregation = RankingAggregation.from(row, calculator);
113114

114115
// when
@@ -122,7 +123,7 @@ void should_assign_rank_up_to_100() {
122123
@DisplayName("0 이하의 순위에 대해 예외가 발생한다")
123124
void should_throw_exception_when_rank_is_zero_or_negative() {
124125
// given
125-
Object[] row = {1L, 100L, 50L, 10L, 5L};
126+
Object[] row = {1L, 100L, 50L, 10L, 5L, java.math.BigDecimal.valueOf(1000)};
126127
RankingAggregation aggregation = RankingAggregation.from(row, calculator);
127128

128129
// when & then
@@ -139,7 +140,7 @@ void should_throw_exception_when_rank_is_zero_or_negative() {
139140
@DisplayName("100을 초과하는 순위에 대해 예외가 발생한다")
140141
void should_throw_exception_when_rank_exceeds_100() {
141142
// given
142-
Object[] row = {1L, 100L, 50L, 10L, 5L};
143+
Object[] row = {1L, 100L, 50L, 10L, 5L, java.math.BigDecimal.valueOf(1000)};
143144
RankingAggregation aggregation = RankingAggregation.from(row, calculator);
144145

145146
// when & then
@@ -157,7 +158,7 @@ class 문자열_표현 {
157158
@DisplayName("toString이 올바른 형식을 반환한다")
158159
void should_return_correct_string_format() {
159160
// given
160-
Object[] row = {1L, 100L, 50L, 10L, 5L};
161+
Object[] row = {1L, 100L, 50L, 10L, 5L, java.math.BigDecimal.valueOf(1000)};
161162
RankingAggregation aggregation = RankingAggregation.from(row, calculator);
162163
aggregation.assignRank(1);
163164

@@ -166,7 +167,7 @@ void should_return_correct_string_format() {
166167

167168
// then
168169
Assertions.assertThat(result).contains("productId=1");
169-
Assertions.assertThat(result).contains("score=214");
170+
Assertions.assertThat(result).contains("score=241");
170171
Assertions.assertThat(result).contains("rank=1");
171172
}
172173
}

0 commit comments

Comments
 (0)