Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import java.math.BigDecimal;

import com.loopers.batch.job.ranking.support.ScoreCalculator;
import com.loopers.domain.metrics.ProductMetricsAggregation;

import lombok.Getter;

Expand Down Expand Up @@ -38,30 +39,31 @@ private RankingAggregation(Long productId, long viewCount, long likeCount,
/**
* DB 집계 κ²°κ³Όλ‘œλΆ€ν„° RankingAggregation을 μƒμ„±ν•©λ‹ˆλ‹€.
*
* @param row DB 집계 쿼리 κ²°κ³Ό (Object[] ν˜•νƒœ)
* @param metrics μƒν’ˆ λ©”νŠΈλ¦­ 집계 κ²°κ³Ό DTO
* @param calculator 점수 계산기
* @return μƒμ„±λœ RankingAggregation 객체
* @throws IllegalArgumentException rowκ°€ nullμ΄κ±°λ‚˜ ν˜•μ‹μ΄ 잘λͺ»λœ 경우
* @throws IllegalArgumentException metricsκ°€ null인 경우
*/
public static RankingAggregation from(Object[] row, ScoreCalculator calculator) {
if (row == null || row.length < 4) {
throw new IllegalArgumentException("집계 κ²°κ³Ό 배열이 nullμ΄κ±°λ‚˜ 길이가 λΆ€μ‘±ν•©λ‹ˆλ‹€.");
public static RankingAggregation from(ProductMetricsAggregation metrics, ScoreCalculator calculator) {
if (metrics == null) {
throw new IllegalArgumentException("집계 κ²°κ³Ό(metrics)κ°€ nullμž…λ‹ˆλ‹€.");
}

try {
Long productId = (Long) row[0];
long viewCount = ((Number) row[1]).longValue();
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(
metrics.viewCount(),
metrics.likeCount(),
metrics.totalSalesAmount()
);

long totalScore = calculator.calculate(viewCount, likeCount, totalSalesAmount);

return new RankingAggregation(productId, viewCount, likeCount, salesCount, orderCount, totalSalesAmount, totalScore);
} catch (ClassCastException | NullPointerException e) {
throw new IllegalArgumentException("집계 κ²°κ³Ό 데이터 ν˜•μ‹μ΄ μ˜¬λ°”λ₯΄μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.", e);
}
return new RankingAggregation(
metrics.productId(),
metrics.viewCount(),
metrics.likeCount(),
metrics.salesCount(),
metrics.orderCount(),
metrics.totalSalesAmount(),
totalScore
);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import com.loopers.batch.job.ranking.dto.RankingAggregation;
import com.loopers.batch.job.ranking.support.RankingAggregator;
import com.loopers.domain.metrics.ProductMetricsAggregation;
import com.loopers.domain.metrics.ProductMetricsRepository;

import lombok.extern.slf4j.Slf4j;
Expand Down Expand Up @@ -45,13 +46,16 @@ private void initializeIterator() {
try {
// 1. κΈ°κ°„ νŒŒμ‹± (좔상 λ©”μ„œλ“œ 호좜)
LocalDate[] dateRange = parseDateRange();
if (dateRange == null || dateRange.length != 2) {
throw new IllegalStateException("parseDateRange()λŠ” μ •ν™•νžˆ 2개의 λ‚ μ§œλ₯Ό λ°˜ν™˜ν•΄μ•Ό ν•©λ‹ˆλ‹€.");
}
LocalDate startDate = dateRange[0];
LocalDate endDate = dateRange[1];

log.info("집계 κΈ°κ°„: {} ~ {}", startDate, endDate);

// 2. DBμ—μ„œ 집계 쿼리 μ‹€ν–‰
List<Object[]> aggregationResults = productMetricsRepository.aggregateByDateRange(startDate, endDate);
List<ProductMetricsAggregation> aggregationResults = productMetricsRepository.aggregateByDateRange(startDate, endDate);
log.info("집계 λŒ€μƒ μƒν’ˆ 수: {}", aggregationResults.size());

// 3. λž­ν‚Ή 처리 (μ •λ ¬ + TOP 100 + μˆœμœ„ λΆ€μ—¬)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import java.util.Comparator;
import java.util.List;

import com.loopers.domain.metrics.ProductMetricsAggregation;
import org.springframework.stereotype.Component;

import com.loopers.batch.job.ranking.dto.RankingAggregation;
Expand All @@ -29,14 +30,14 @@ public class RankingAggregator {
* @param aggregationResults DB 집계 쿼리 κ²°κ³Ό λͺ©λ‘
* @return TOP 100 λž­ν‚Ή λͺ©λ‘ (μˆœμœ„ λΆ€μ—¬ μ™„λ£Œ)
*/
public List<RankingAggregation> processRankings(List<Object[]> aggregationResults) {
public List<RankingAggregation> processRankings(List<ProductMetricsAggregation> aggregationResults) {
if (aggregationResults == null || aggregationResults.isEmpty()) {
return List.of();
}

// 1. DTO λ³€ν™˜ + 점수 계산
List<RankingAggregation> aggregations = aggregationResults.stream()
.map(row -> RankingAggregation.from(row, scoreCalculator))
.map(metrics -> RankingAggregation.from(metrics, scoreCalculator))
.toList();

// 2. 점수 κΈ°μ€€ λ‚΄λ¦Όμ°¨μˆœ μ •λ ¬ + TOP 100 필터링
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import java.time.LocalDate;
import java.util.List;

import com.loopers.domain.metrics.ProductMetricsAggregation;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
Expand All @@ -22,20 +23,21 @@ public interface ProductMetricsJpaRepository extends JpaRepository<ProductMetric
*
* @param startDate μ‹œμž‘ λ‚ μ§œ (포함)
* @param endDate μ’…λ£Œ λ‚ μ§œ (포함)
* @return 집계 κ²°κ³Ό [productId, viewCount, likeCount, salesCount, orderCount]
* @return 집계 κ²°κ³Ό λͺ©λ‘
*/
@Query("""
SELECT m.id.productId,
SELECT new com.loopers.domain.metrics.ProductMetricsAggregation(
m.id.productId,
SUM(m.viewCount),
SUM(m.likeCount),
SUM(m.salesCount),
SUM(m.orderCount),
SUM(m.totalSalesAmount)
SUM(m.totalSalesAmount))
FROM ProductMetricsEntity m
WHERE m.id.metricDate BETWEEN :startDate AND :endDate
GROUP BY m.id.productId
""")
List<Object[]> aggregateByDateRange(
List<ProductMetricsAggregation> aggregateByDateRange(
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import java.util.List;
import java.util.Optional;

import com.loopers.domain.metrics.ProductMetricsAggregation;
import org.springframework.stereotype.Repository;

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

@Override
public List<Object[]> aggregateByDateRange(LocalDate startDate, LocalDate endDate) {
public List<ProductMetricsAggregation> aggregateByDateRange(LocalDate startDate, LocalDate endDate) {
return jpaRepository.aggregateByDateRange(startDate, endDate);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import org.junit.jupiter.api.Test;

import com.loopers.batch.job.ranking.support.ScoreCalculator;
import com.loopers.domain.metrics.ProductMetricsAggregation;

@DisplayName("RankingAggregation λ‹¨μœ„ ν…ŒμŠ€νŠΈ")
class RankingAggregationUnitTest {
Expand All @@ -20,10 +21,12 @@ class 집계_κ²°κ³Όλ‘œλΆ€ν„°_생성 {
@DisplayName("μœ νš¨ν•œ 집계 κ²°κ³Όλ‘œλΆ€ν„° 객체λ₯Ό μƒμ„±ν•œλ‹€")
void should_create_from_valid_aggregation_result() {
// given
Object[] row = {1L, 100L, 50L, 10L, 5L, java.math.BigDecimal.valueOf(1000)}; // productId, view, like, sales, order, amount
ProductMetricsAggregation metrics = new ProductMetricsAggregation(
1L, 100L, 50L, 10L, 5L, java.math.BigDecimal.valueOf(1000)
);

// when
RankingAggregation aggregation = RankingAggregation.from(row, calculator);
RankingAggregation aggregation = RankingAggregation.from(metrics, calculator);

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

@Test
@DisplayName("null 배열에 λŒ€ν•΄ μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€")
void should_throw_exception_when_row_is_null() {
@DisplayName("null λ©”νŠΈλ¦­μ— λŒ€ν•΄ μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€")
void should_throw_exception_when_metrics_is_null() {
// given & when & then
Assertions.assertThatThrownBy(() -> RankingAggregation.from(null, calculator))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("집계 κ²°κ³Ό 배열이 nullμ΄κ±°λ‚˜ 길이가 λΆ€μ‘±ν•©λ‹ˆλ‹€");
}

@Test
@DisplayName("길이가 λΆ€μ‘±ν•œ 배열에 λŒ€ν•΄ μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€")
void should_throw_exception_when_row_length_is_insufficient() {
// given
Object[] shortRow = {1L, 100L, 50L}; // 길이 3 (6 미만)

// when & then
Assertions.assertThatThrownBy(() -> RankingAggregation.from(shortRow, calculator))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("집계 κ²°κ³Ό 배열이 nullμ΄κ±°λ‚˜ 길이가 λΆ€μ‘±ν•©λ‹ˆλ‹€");
}

@Test
@DisplayName("잘λͺ»λœ 데이터 νƒ€μž…μ— λŒ€ν•΄ μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€")
void should_throw_exception_when_data_type_is_invalid() {
// given
Object[] invalidRow = {"invalid", 100L, 50L, 10L, 5L, java.math.BigDecimal.valueOf(1000)}; // productIdκ°€ String

// when & then
Assertions.assertThatThrownBy(() -> RankingAggregation.from(invalidRow, calculator))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("집계 κ²°κ³Ό 데이터 ν˜•μ‹μ΄ μ˜¬λ°”λ₯΄μ§€ μ•ŠμŠ΅λ‹ˆλ‹€");
}

@Test
@DisplayName("Number νƒ€μž…μ˜ λ‹€μ–‘ν•œ ν˜•νƒœλ₯Ό μ²˜λ¦¬ν•œλ‹€")
void should_handle_various_number_types() {
// given - Integer, Long, BigDecimal λ“± λ‹€μ–‘ν•œ Number νƒ€μž…
Object[] row = {1L, 100, 50L, 10, 5L, java.math.BigDecimal.valueOf(1000)};

// when
RankingAggregation aggregation = RankingAggregation.from(row, calculator);

// then
Assertions.assertThat(aggregation.getViewCount()).isEqualTo(100L);
Assertions.assertThat(aggregation.getLikeCount()).isEqualTo(50L);
Assertions.assertThat(aggregation.getSalesCount()).isEqualTo(10L);
Assertions.assertThat(aggregation.getOrderCount()).isEqualTo(5L);
.hasMessageContaining("집계 κ²°κ³Ό(metrics)κ°€ nullμž…λ‹ˆλ‹€.");
}
}

Expand All @@ -95,8 +58,10 @@ class μˆœμœ„_λΆ€μ—¬ {
@DisplayName("μœ νš¨ν•œ μˆœμœ„λ₯Ό λΆ€μ—¬ν•œλ‹€")
void should_assign_valid_rank() {
// given
Object[] row = {1L, 100L, 50L, 10L, 5L, java.math.BigDecimal.valueOf(1000)};
RankingAggregation aggregation = RankingAggregation.from(row, calculator);
ProductMetricsAggregation metrics = new ProductMetricsAggregation(
1L, 100L, 50L, 10L, 5L, java.math.BigDecimal.valueOf(1000)
);
RankingAggregation aggregation = RankingAggregation.from(metrics, calculator);

// when
aggregation.assignRank(1);
Expand All @@ -109,8 +74,10 @@ void should_assign_valid_rank() {
@DisplayName("100μœ„κΉŒμ§€ μˆœμœ„λ₯Ό λΆ€μ—¬ν•  수 μžˆλ‹€")
void should_assign_rank_up_to_100() {
// given
Object[] row = {1L, 100L, 50L, 10L, 5L, java.math.BigDecimal.valueOf(1000)};
RankingAggregation aggregation = RankingAggregation.from(row, calculator);
ProductMetricsAggregation metrics = new ProductMetricsAggregation(
1L, 100L, 50L, 10L, 5L, java.math.BigDecimal.valueOf(1000)
);
RankingAggregation aggregation = RankingAggregation.from(metrics, calculator);

// when
aggregation.assignRank(100);
Expand All @@ -123,8 +90,10 @@ 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, java.math.BigDecimal.valueOf(1000)};
RankingAggregation aggregation = RankingAggregation.from(row, calculator);
ProductMetricsAggregation metrics = new ProductMetricsAggregation(
1L, 100L, 50L, 10L, 5L, java.math.BigDecimal.valueOf(1000)
);
RankingAggregation aggregation = RankingAggregation.from(metrics, calculator);

// when & then
Assertions.assertThatThrownBy(() -> aggregation.assignRank(0))
Expand All @@ -140,8 +109,10 @@ 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, java.math.BigDecimal.valueOf(1000)};
RankingAggregation aggregation = RankingAggregation.from(row, calculator);
ProductMetricsAggregation metrics = new ProductMetricsAggregation(
1L, 100L, 50L, 10L, 5L, java.math.BigDecimal.valueOf(1000)
);
RankingAggregation aggregation = RankingAggregation.from(metrics, calculator);

// when & then
Assertions.assertThatThrownBy(() -> aggregation.assignRank(101))
Expand All @@ -158,8 +129,10 @@ class λ¬Έμžμ—΄_ν‘œν˜„ {
@DisplayName("toString이 μ˜¬λ°”λ₯Έ ν˜•μ‹μ„ λ°˜ν™˜ν•œλ‹€")
void should_return_correct_string_format() {
// given
Object[] row = {1L, 100L, 50L, 10L, 5L, java.math.BigDecimal.valueOf(1000)};
RankingAggregation aggregation = RankingAggregation.from(row, calculator);
ProductMetricsAggregation metrics = new ProductMetricsAggregation(
1L, 100L, 50L, 10L, 5L, java.math.BigDecimal.valueOf(1000)
);
RankingAggregation aggregation = RankingAggregation.from(metrics, calculator);
aggregation.assignRank(1);

// when
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import java.util.ArrayList;
import java.util.List;

import com.loopers.domain.metrics.ProductMetricsAggregation;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
Expand All @@ -25,10 +26,10 @@ class λž­ν‚Ή_처리 {
@DisplayName("집계 κ²°κ³Όλ₯Ό 점수 κΈ°μ€€μœΌλ‘œ μ •λ ¬ν•˜κ³  μˆœμœ„λ₯Ό λΆ€μ—¬ν•œλ‹€")
void should_sort_by_score_and_assign_ranks() {
// given
List<Object[]> results = List.of(
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)}
List<ProductMetricsAggregation> results = List.of(
new ProductMetricsAggregation(1L, 100L, 10L, 5L, 2L , new BigDecimal(0)),
new ProductMetricsAggregation(2L, 200L, 20L, 10L, 4L, new BigDecimal(0)),
new ProductMetricsAggregation(3L, 50L, 5L, 2L, 1L, new BigDecimal(0))
);

// when
Expand All @@ -40,25 +41,25 @@ 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(240L);

Assertions.assertThat(rankings.get(0).getTotalScore()).isEqualTo(240L); // (200*0.1 + 20*0.2 + log(1)*0.6) * 10 = (20 + 4 + 0) * 10 = 240
Assertions.assertThat(rankings.get(1).getProductId()).isEqualTo(1L); // 2μœ„
Assertions.assertThat(rankings.get(1).getRankPosition()).isEqualTo(2);
Assertions.assertThat(rankings.get(1).getTotalScore()).isEqualTo(120L);
Assertions.assertThat(rankings.get(1).getTotalScore()).isEqualTo(120L); // (100*0.1 + 10*0.2) * 10 = 120

Assertions.assertThat(rankings.get(2).getProductId()).isEqualTo(3L); // 3μœ„
Assertions.assertThat(rankings.get(2).getRankPosition()).isEqualTo(3);
Assertions.assertThat(rankings.get(2).getTotalScore()).isEqualTo(60L);
Assertions.assertThat(rankings.get(2).getTotalScore()).isEqualTo(60L); // (50*0.1 + 5*0.2) * 10 = 60
}

@Test
@DisplayName("TOP 100을 μ΄ˆκ³Όν•˜λŠ” κ²°κ³ΌλŠ” ν•„ν„°λ§λœλ‹€")
void should_filter_results_beyond_top_100() {
// given - 150개의 κ²°κ³Ό 생성
List<Object[]> results = new ArrayList<>();
List<ProductMetricsAggregation> 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, new BigDecimal(i)});
results.add(new ProductMetricsAggregation((long) i, (long) i * 10, (long) i, (long) i, (long) i, new BigDecimal(i)));
}

// when
Expand All @@ -74,7 +75,7 @@ void should_filter_results_beyond_top_100() {
@DisplayName("빈 결과에 λŒ€ν•΄ 빈 λͺ©λ‘μ„ λ°˜ν™˜ν•œλ‹€")
void should_return_empty_list_for_empty_results() {
// given
List<Object[]> emptyResults = List.of();
List<ProductMetricsAggregation> emptyResults = List.of();

// when
List<RankingAggregation> rankings = aggregator.processRankings(emptyResults);
Expand All @@ -97,10 +98,10 @@ void should_return_empty_list_for_null_results() {
@DisplayName("λ™μΌν•œ 점수의 μƒν’ˆλ“€μ€ μˆœμ„œκ°€ μœ μ§€λœλ‹€")
void should_maintain_order_for_same_scores() {
// given - λ™μΌν•œ 점수λ₯Ό κ°€μ§„ μƒν’ˆλ“€
List<Object[]> results = List.of(
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
List<ProductMetricsAggregation> results = List.of(
new ProductMetricsAggregation(1L, 100L, 0L, 0L, 0L, new BigDecimal(0)), // score = 100
new ProductMetricsAggregation(2L, 100L, 0L, 0L, 0L, new BigDecimal(0)), // score = 100
new ProductMetricsAggregation(3L, 100L, 0L, 0L, 0L, new BigDecimal(0)) // score = 100
);

// when
Expand Down
Loading