Skip to content

Commit 16bb063

Browse files
committed
feat(ranking): 상품 메트릭 집계 로직 개선
- 집계 쿼리 결과를 Object[]에서 ProductMetricsAggregation으로 변경 - 랭킹 집계 로직에서 DTO 변환 및 점수 계산 방식 개선 - rankPosition 타입을 long에서 int로 변경하여 일관성 강화
1 parent 20b19fc commit 16bb063

15 files changed

Lines changed: 93 additions & 108 deletions

File tree

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

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import java.math.BigDecimal;
44

55
import com.loopers.batch.job.ranking.support.ScoreCalculator;
6+
import com.loopers.domain.metrics.ProductMetricsAggregation;
67

78
import lombok.Getter;
89

@@ -38,30 +39,31 @@ private RankingAggregation(Long productId, long viewCount, long likeCount,
3839
/**
3940
* DB 집계 결과로부터 RankingAggregation을 생성합니다.
4041
*
41-
* @param row DB 집계 쿼리 결과 (Object[] 형태)
42+
* @param metrics 상품 메트릭 집계 결과 DTO
4243
* @param calculator 점수 계산기
4344
* @return 생성된 RankingAggregation 객체
44-
* @throws IllegalArgumentException row가 null이거나 형식이 잘못된 경우
45+
* @throws IllegalArgumentException metrics가 null인 경우
4546
*/
46-
public static RankingAggregation from(Object[] row, ScoreCalculator calculator) {
47-
if (row == null || row.length < 6) {
48-
throw new IllegalArgumentException("집계 결과 배열이 null이거나 길이가 부족합니다.");
47+
public static RankingAggregation from(ProductMetricsAggregation metrics, ScoreCalculator calculator) {
48+
if (metrics == null) {
49+
throw new IllegalArgumentException("집계 결과(metrics)가 null입니다.");
4950
}
5051

51-
try {
52-
Long productId = (Long) row[0];
53-
long viewCount = ((Number) row[1]).longValue();
54-
long likeCount = ((Number) row[2]).longValue();
55-
long salesCount = ((Number) row[3]).longValue();
56-
long orderCount = ((Number) row[4]).longValue();
57-
BigDecimal totalSalesAmount = (BigDecimal) row[5];
52+
long totalScore = calculator.calculate(
53+
metrics.viewCount(),
54+
metrics.likeCount(),
55+
metrics.totalSalesAmount()
56+
);
5857

59-
long totalScore = calculator.calculate(viewCount, likeCount, totalSalesAmount);
60-
61-
return new RankingAggregation(productId, viewCount, likeCount, salesCount, orderCount, totalSalesAmount, totalScore);
62-
} catch (ClassCastException | NullPointerException e) {
63-
throw new IllegalArgumentException("집계 결과 데이터 형식이 올바르지 않습니다.", e);
64-
}
58+
return new RankingAggregation(
59+
metrics.productId(),
60+
metrics.viewCount(),
61+
metrics.likeCount(),
62+
metrics.salesCount(),
63+
metrics.orderCount(),
64+
metrics.totalSalesAmount(),
65+
totalScore
66+
);
6567
}
6668

6769
/**

apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/reader/AbstractMetricsReader.java

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

99
import com.loopers.batch.job.ranking.dto.RankingAggregation;
1010
import com.loopers.batch.job.ranking.support.RankingAggregator;
11+
import com.loopers.domain.metrics.ProductMetricsAggregation;
1112
import com.loopers.domain.metrics.ProductMetricsRepository;
1213

1314
import lombok.extern.slf4j.Slf4j;
@@ -54,7 +55,7 @@ private void initializeIterator() {
5455
log.info("집계 기간: {} ~ {}", startDate, endDate);
5556

5657
// 2. DB에서 집계 쿼리 실행
57-
List<Object[]> aggregationResults = productMetricsRepository.aggregateByDateRange(startDate, endDate);
58+
List<ProductMetricsAggregation> aggregationResults = productMetricsRepository.aggregateByDateRange(startDate, endDate);
5859
log.info("집계 대상 상품 수: {}", aggregationResults.size());
5960

6061
// 3. 랭킹 처리 (정렬 + TOP 100 + 순위 부여)

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import java.util.Comparator;
44
import java.util.List;
55

6+
import com.loopers.domain.metrics.ProductMetricsAggregation;
67
import org.springframework.stereotype.Component;
78

89
import com.loopers.batch.job.ranking.dto.RankingAggregation;
@@ -29,14 +30,14 @@ public class RankingAggregator {
2930
* @param aggregationResults DB 집계 쿼리 결과 목록
3031
* @return TOP 100 랭킹 목록 (순위 부여 완료)
3132
*/
32-
public List<RankingAggregation> processRankings(List<Object[]> aggregationResults) {
33+
public List<RankingAggregation> processRankings(List<ProductMetricsAggregation> aggregationResults) {
3334
if (aggregationResults == null || aggregationResults.isEmpty()) {
3435
return List.of();
3536
}
3637

3738
// 1. DTO 변환 + 점수 계산
3839
List<RankingAggregation> aggregations = aggregationResults.stream()
39-
.map(row -> RankingAggregation.from(row, scoreCalculator))
40+
.map(metrics -> RankingAggregation.from(metrics, scoreCalculator))
4041
.toList();
4142

4243
// 2. 점수 기준 내림차순 정렬 + TOP 100 필터링

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

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import java.time.LocalDate;
44
import java.util.List;
55

6+
import com.loopers.domain.metrics.ProductMetricsAggregation;
67
import org.springframework.data.jpa.repository.JpaRepository;
78
import org.springframework.data.jpa.repository.Query;
89
import org.springframework.data.repository.query.Param;
@@ -22,20 +23,21 @@ public interface ProductMetricsJpaRepository extends JpaRepository<ProductMetric
2223
*
2324
* @param startDate 시작 날짜 (포함)
2425
* @param endDate 종료 날짜 (포함)
25-
* @return 집계 결과 [productId, viewCount, likeCount, salesCount, orderCount, totalSalesAmount]
26+
* @return 집계 결과 목록
2627
*/
2728
@Query("""
28-
SELECT m.id.productId,
29+
SELECT new com.loopers.domain.metrics.ProductMetricsAggregation(
30+
m.id.productId,
2931
SUM(m.viewCount),
3032
SUM(m.likeCount),
3133
SUM(m.salesCount),
3234
SUM(m.orderCount),
33-
SUM(m.totalSalesAmount)
35+
SUM(m.totalSalesAmount))
3436
FROM ProductMetricsEntity m
3537
WHERE m.id.metricDate BETWEEN :startDate AND :endDate
3638
GROUP BY m.id.productId
3739
""")
38-
List<Object[]> aggregateByDateRange(
40+
List<ProductMetricsAggregation> aggregateByDateRange(
3941
@Param("startDate") LocalDate startDate,
4042
@Param("endDate") LocalDate endDate);
4143

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import java.util.List;
55
import java.util.Optional;
66

7+
import com.loopers.domain.metrics.ProductMetricsAggregation;
78
import org.springframework.stereotype.Repository;
89

910
import com.loopers.domain.metrics.ProductMetricsEntity;
@@ -44,7 +45,7 @@ public List<ProductMetricsEntity> findByMetricDateBetween(LocalDate startDate, L
4445
}
4546

4647
@Override
47-
public List<Object[]> aggregateByDateRange(LocalDate startDate, LocalDate endDate) {
48+
public List<ProductMetricsAggregation> aggregateByDateRange(LocalDate startDate, LocalDate endDate) {
4849
return jpaRepository.aggregateByDateRange(startDate, endDate);
4950
}
5051
}

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

Lines changed: 28 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import org.junit.jupiter.api.Test;
77

88
import com.loopers.batch.job.ranking.support.ScoreCalculator;
9+
import com.loopers.domain.metrics.ProductMetricsAggregation;
910

1011
@DisplayName("RankingAggregation 단위 테스트")
1112
class RankingAggregationUnitTest {
@@ -20,10 +21,12 @@ class 집계_결과로부터_생성 {
2021
@DisplayName("유효한 집계 결과로부터 객체를 생성한다")
2122
void should_create_from_valid_aggregation_result() {
2223
// given
23-
Object[] row = {1L, 100L, 50L, 10L, 5L, java.math.BigDecimal.valueOf(1000)}; // productId, view, like, sales, order, amount
24+
ProductMetricsAggregation metrics = new ProductMetricsAggregation(
25+
1L, 100L, 50L, 10L, 5L, java.math.BigDecimal.valueOf(1000)
26+
);
2427

2528
// when
26-
RankingAggregation aggregation = RankingAggregation.from(row, calculator);
29+
RankingAggregation aggregation = RankingAggregation.from(metrics, calculator);
2730

2831
// then
2932
Assertions.assertThat(aggregation.getProductId()).isEqualTo(1L);
@@ -38,52 +41,12 @@ void should_create_from_valid_aggregation_result() {
3841
}
3942

4043
@Test
41-
@DisplayName("null 배열에 대해 예외가 발생한다")
42-
void should_throw_exception_when_row_is_null() {
44+
@DisplayName("null 메트릭에 대해 예외가 발생한다")
45+
void should_throw_exception_when_metrics_is_null() {
4346
// given & when & then
4447
Assertions.assertThatThrownBy(() -> RankingAggregation.from(null, calculator))
4548
.isInstanceOf(IllegalArgumentException.class)
46-
.hasMessageContaining("집계 결과 배열이 null이거나 길이가 부족합니다");
47-
}
48-
49-
@Test
50-
@DisplayName("길이가 부족한 배열에 대해 예외가 발생한다")
51-
void should_throw_exception_when_row_length_is_insufficient() {
52-
// given
53-
Object[] shortRow = {1L, 100L, 50L}; // 길이 3 (6 미만)
54-
55-
// when & then
56-
Assertions.assertThatThrownBy(() -> RankingAggregation.from(shortRow, calculator))
57-
.isInstanceOf(IllegalArgumentException.class)
58-
.hasMessageContaining("집계 결과 배열이 null이거나 길이가 부족합니다");
59-
}
60-
61-
@Test
62-
@DisplayName("잘못된 데이터 타입에 대해 예외가 발생한다")
63-
void should_throw_exception_when_data_type_is_invalid() {
64-
// given
65-
Object[] invalidRow = {"invalid", 100L, 50L, 10L, 5L, java.math.BigDecimal.valueOf(1000)}; // productId가 String
66-
67-
// when & then
68-
Assertions.assertThatThrownBy(() -> RankingAggregation.from(invalidRow, calculator))
69-
.isInstanceOf(IllegalArgumentException.class)
70-
.hasMessageContaining("집계 결과 데이터 형식이 올바르지 않습니다");
71-
}
72-
73-
@Test
74-
@DisplayName("Number 타입의 다양한 형태를 처리한다")
75-
void should_handle_various_number_types() {
76-
// given - Integer, Long, BigDecimal 등 다양한 Number 타입
77-
Object[] row = {1L, 100, 50L, 10, 5L, java.math.BigDecimal.valueOf(1000)};
78-
79-
// when
80-
RankingAggregation aggregation = RankingAggregation.from(row, calculator);
81-
82-
// then
83-
Assertions.assertThat(aggregation.getViewCount()).isEqualTo(100L);
84-
Assertions.assertThat(aggregation.getLikeCount()).isEqualTo(50L);
85-
Assertions.assertThat(aggregation.getSalesCount()).isEqualTo(10L);
86-
Assertions.assertThat(aggregation.getOrderCount()).isEqualTo(5L);
49+
.hasMessageContaining("집계 결과(metrics)가 null입니다.");
8750
}
8851
}
8952

@@ -95,8 +58,10 @@ class 순위_부여 {
9558
@DisplayName("유효한 순위를 부여한다")
9659
void should_assign_valid_rank() {
9760
// given
98-
Object[] row = {1L, 100L, 50L, 10L, 5L, java.math.BigDecimal.valueOf(1000)};
99-
RankingAggregation aggregation = RankingAggregation.from(row, calculator);
61+
ProductMetricsAggregation metrics = new ProductMetricsAggregation(
62+
1L, 100L, 50L, 10L, 5L, java.math.BigDecimal.valueOf(1000)
63+
);
64+
RankingAggregation aggregation = RankingAggregation.from(metrics, calculator);
10065

10166
// when
10267
aggregation.assignRank(1);
@@ -109,8 +74,10 @@ void should_assign_valid_rank() {
10974
@DisplayName("100위까지 순위를 부여할 수 있다")
11075
void should_assign_rank_up_to_100() {
11176
// given
112-
Object[] row = {1L, 100L, 50L, 10L, 5L, java.math.BigDecimal.valueOf(1000)};
113-
RankingAggregation aggregation = RankingAggregation.from(row, calculator);
77+
ProductMetricsAggregation metrics = new ProductMetricsAggregation(
78+
1L, 100L, 50L, 10L, 5L, java.math.BigDecimal.valueOf(1000)
79+
);
80+
RankingAggregation aggregation = RankingAggregation.from(metrics, calculator);
11481

11582
// when
11683
aggregation.assignRank(100);
@@ -123,8 +90,10 @@ void should_assign_rank_up_to_100() {
12390
@DisplayName("0 이하의 순위에 대해 예외가 발생한다")
12491
void should_throw_exception_when_rank_is_zero_or_negative() {
12592
// given
126-
Object[] row = {1L, 100L, 50L, 10L, 5L, java.math.BigDecimal.valueOf(1000)};
127-
RankingAggregation aggregation = RankingAggregation.from(row, calculator);
93+
ProductMetricsAggregation metrics = new ProductMetricsAggregation(
94+
1L, 100L, 50L, 10L, 5L, java.math.BigDecimal.valueOf(1000)
95+
);
96+
RankingAggregation aggregation = RankingAggregation.from(metrics, calculator);
12897

12998
// when & then
13099
Assertions.assertThatThrownBy(() -> aggregation.assignRank(0))
@@ -140,8 +109,10 @@ void should_throw_exception_when_rank_is_zero_or_negative() {
140109
@DisplayName("100을 초과하는 순위에 대해 예외가 발생한다")
141110
void should_throw_exception_when_rank_exceeds_100() {
142111
// given
143-
Object[] row = {1L, 100L, 50L, 10L, 5L, java.math.BigDecimal.valueOf(1000)};
144-
RankingAggregation aggregation = RankingAggregation.from(row, calculator);
112+
ProductMetricsAggregation metrics = new ProductMetricsAggregation(
113+
1L, 100L, 50L, 10L, 5L, java.math.BigDecimal.valueOf(1000)
114+
);
115+
RankingAggregation aggregation = RankingAggregation.from(metrics, calculator);
145116

146117
// when & then
147118
Assertions.assertThatThrownBy(() -> aggregation.assignRank(101))
@@ -158,8 +129,10 @@ class 문자열_표현 {
158129
@DisplayName("toString이 올바른 형식을 반환한다")
159130
void should_return_correct_string_format() {
160131
// given
161-
Object[] row = {1L, 100L, 50L, 10L, 5L, java.math.BigDecimal.valueOf(1000)};
162-
RankingAggregation aggregation = RankingAggregation.from(row, calculator);
132+
ProductMetricsAggregation metrics = new ProductMetricsAggregation(
133+
1L, 100L, 50L, 10L, 5L, java.math.BigDecimal.valueOf(1000)
134+
);
135+
RankingAggregation aggregation = RankingAggregation.from(metrics, calculator);
163136
aggregation.assignRank(1);
164137

165138
// when

apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/support/RankingAggregatorUnitTest.java

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import java.util.ArrayList;
55
import java.util.List;
66

7+
import com.loopers.domain.metrics.ProductMetricsAggregation;
78
import org.assertj.core.api.Assertions;
89
import org.junit.jupiter.api.DisplayName;
910
import org.junit.jupiter.api.Nested;
@@ -25,10 +26,10 @@ class 랭킹_처리 {
2526
@DisplayName("집계 결과를 점수 기준으로 정렬하고 순위를 부여한다")
2627
void should_sort_by_score_and_assign_ranks() {
2728
// given
28-
List<Object[]> results = List.of(
29-
new Object[]{1L, 100L, 10L, 5L, 2L , new BigDecimal(0)},
30-
new Object[]{2L, 200L, 20L, 10L, 4L, new BigDecimal(0)},
31-
new Object[]{3L, 50L, 5L, 2L, 1L, new BigDecimal(0)}
29+
List<ProductMetricsAggregation> results = List.of(
30+
new ProductMetricsAggregation(1L, 100L, 10L, 5L, 2L , new BigDecimal(0)),
31+
new ProductMetricsAggregation(2L, 200L, 20L, 10L, 4L, new BigDecimal(0)),
32+
new ProductMetricsAggregation(3L, 50L, 5L, 2L, 1L, new BigDecimal(0))
3233
);
3334

3435
// when
@@ -40,25 +41,25 @@ void should_sort_by_score_and_assign_ranks() {
4041
// 점수 기준 내림차순 정렬 확인
4142
Assertions.assertThat(rankings.get(0).getProductId()).isEqualTo(2L); // 1위
4243
Assertions.assertThat(rankings.get(0).getRankPosition()).isEqualTo(1);
43-
Assertions.assertThat(rankings.get(0).getTotalScore()).isEqualTo(240L);
44-
44+
Assertions.assertThat(rankings.get(0).getTotalScore()).isEqualTo(240L); // (200*0.1 + 20*0.2 + log(1)*0.6) * 10 = (20 + 4 + 0) * 10 = 240
45+
4546
Assertions.assertThat(rankings.get(1).getProductId()).isEqualTo(1L); // 2위
4647
Assertions.assertThat(rankings.get(1).getRankPosition()).isEqualTo(2);
47-
Assertions.assertThat(rankings.get(1).getTotalScore()).isEqualTo(120L);
48+
Assertions.assertThat(rankings.get(1).getTotalScore()).isEqualTo(120L); // (100*0.1 + 10*0.2) * 10 = 120
4849

4950
Assertions.assertThat(rankings.get(2).getProductId()).isEqualTo(3L); // 3위
5051
Assertions.assertThat(rankings.get(2).getRankPosition()).isEqualTo(3);
51-
Assertions.assertThat(rankings.get(2).getTotalScore()).isEqualTo(60L);
52+
Assertions.assertThat(rankings.get(2).getTotalScore()).isEqualTo(60L); // (50*0.1 + 5*0.2) * 10 = 60
5253
}
5354

5455
@Test
5556
@DisplayName("TOP 100을 초과하는 결과는 필터링된다")
5657
void should_filter_results_beyond_top_100() {
5758
// given - 150개의 결과 생성
58-
List<Object[]> results = new ArrayList<>();
59+
List<ProductMetricsAggregation> results = new ArrayList<>();
5960
for (int i = 1; i <= 150; i++) {
6061
// 점수가 높은 순서대로 생성 (i가 클수록 점수 높음)
61-
results.add(new Object[]{(long) i, (long) i * 10, (long) i, (long) i, (long) i, new BigDecimal(i)});
62+
results.add(new ProductMetricsAggregation((long) i, (long) i * 10, (long) i, (long) i, (long) i, new BigDecimal(i)));
6263
}
6364

6465
// when
@@ -74,7 +75,7 @@ void should_filter_results_beyond_top_100() {
7475
@DisplayName("빈 결과에 대해 빈 목록을 반환한다")
7576
void should_return_empty_list_for_empty_results() {
7677
// given
77-
List<Object[]> emptyResults = List.of();
78+
List<ProductMetricsAggregation> emptyResults = List.of();
7879

7980
// when
8081
List<RankingAggregation> rankings = aggregator.processRankings(emptyResults);
@@ -97,10 +98,10 @@ void should_return_empty_list_for_null_results() {
9798
@DisplayName("동일한 점수의 상품들은 순서가 유지된다")
9899
void should_maintain_order_for_same_scores() {
99100
// given - 동일한 점수를 가진 상품들
100-
List<Object[]> results = List.of(
101-
new Object[]{1L, 100L, 0L, 0L, 0L, new BigDecimal(0)}, // score = 100
102-
new Object[]{2L, 100L, 0L, 0L, 0L, new BigDecimal(0)}, // score = 100
103-
new Object[]{3L, 100L, 0L, 0L, 0L, new BigDecimal(0)} // score = 100
101+
List<ProductMetricsAggregation> results = List.of(
102+
new ProductMetricsAggregation(1L, 100L, 0L, 0L, 0L, new BigDecimal(0)), // score = 100
103+
new ProductMetricsAggregation(2L, 100L, 0L, 0L, 0L, new BigDecimal(0)), // score = 100
104+
new ProductMetricsAggregation(3L, 100L, 0L, 0L, 0L, new BigDecimal(0)) // score = 100
104105
);
105106

106107
// when

0 commit comments

Comments
 (0)