Skip to content

Commit 37a09a9

Browse files
author
이건영
committed
cdoerabbit 피드백 반영
* 트랜젝션 어노테이션 추가 * 랭킹 대상 항목이 100개 미만일 때의 배치 에외 처리 * @StepScope를 적용하여 Step 실행마다 새 인스턴스를 생성 * 랭크 계산 후 싱글톤 인스턴스 내의 필드 초기화하여 데이터 오염 및 메모리 누수 문제 방지 * 배치 실행 파라미터에서 발생할 수 있는 null pointer exeception 수정 * n+1 쿼리 개선
1 parent d4ceccc commit 37a09a9

18 files changed

Lines changed: 198 additions & 87 deletions

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -417,8 +417,10 @@ private RankingsResponse getRankingsFromMaterializedView(
417417
Map<Long, Brand> brandMap = brandService.getBrands(brandIds).stream()
418418
.collect(Collectors.toMap(Brand::getId, brand -> brand));
419419

420-
// 랭킹 항목 생성
420+
// 랭킹 항목 생성 (순위 재계산: 누락된 항목 제외 후 연속 순위 부여)
421421
List<RankingItem> rankingItems = new ArrayList<>();
422+
long currentRank = start + 1; // 1-based 순위 (페이지 시작 순위)
423+
422424
for (com.loopers.domain.rank.ProductRank rank : pagedRanks) {
423425
Long productId = rank.getProductId();
424426
Product product = productMap.get(productId);
@@ -445,7 +447,7 @@ private RankingsResponse getRankingsFromMaterializedView(
445447
double score = calculateScore(rank.getLikeCount(), rank.getSalesCount(), rank.getViewCount());
446448

447449
rankingItems.add(new RankingItem(
448-
rank.getRank().longValue(),
450+
currentRank++, // 연속 순위 부여
449451
score,
450452
productDetail
451453
));

apps/commerce-api/src/main/java/com/loopers/domain/rank/ProductRank.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,9 @@
1616
* <p>
1717
* <b>Materialized View 설계:</b>
1818
* <ul>
19-
* <li>주간 랭킹: `mv_product_rank_weekly` (period_type = WEEKLY)</li>
20-
* <li>월간 랭킹: `mv_product_rank_monthly` (period_type = MONTHLY)</li>
19+
* <li>테이블: `mv_product_rank` (단일 테이블)</li>
20+
* <li>주간 랭킹: period_type = WEEKLY</li>
21+
* <li>월간 랭킹: period_type = MONTHLY</li>
2122
* <li>TOP 100만 저장하여 조회 성능 최적화</li>
2223
* </ul>
2324
* </p>

apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/ProductRankRepositoryImpl.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import jakarta.persistence.PersistenceContext;
77
import lombok.extern.slf4j.Slf4j;
88
import org.springframework.stereotype.Repository;
9+
import org.springframework.transaction.annotation.Transactional;
910

1011
import java.time.LocalDate;
1112
import java.util.List;
@@ -19,6 +20,7 @@
1920
*/
2021
@Slf4j
2122
@Repository
23+
@Transactional(readOnly = true)
2224
public class ProductRankRepositoryImpl implements ProductRankRepository {
2325

2426
@PersistenceContext

apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,18 @@ public interface ProductMetricsRepository {
5454
* Spring Batch의 JpaPagingItemReader에서 사용됩니다.
5555
* updated_at 필드를 기준으로 해당 날짜의 데이터만 조회합니다.
5656
* </p>
57+
* <p>
58+
* <b>주의:</b> 쿼리는 {@code updatedAt >= :startDateTime AND updatedAt < :endDateTime} 조건을 사용하므로,
59+
* endDateTime은 exclusive end입니다. 예를 들어, 2024-12-15의 데이터를 조회하려면:
60+
* <ul>
61+
* <li>startDateTime: 2024-12-15 00:00:00</li>
62+
* <li>endDateTime: 2024-12-16 00:00:00 (다음 날 00:00:00)</li>
63+
* </ul>
64+
* 또는 {@code date.atTime(LocalTime.MAX)}를 사용할 수도 있습니다.
65+
* </p>
5766
*
58-
* @param startDateTime 조회 시작 시각 (해당 날짜의 00:00:00)
59-
* @param endDateTime 조회 종료 시각 (해당 날짜의 23:59:59.999999999)
67+
* @param startDateTime 조회 시작 시각 (해당 날짜의 00:00:00, inclusive)
68+
* @param endDateTime 조회 종료 시각 (다음 날 00:00:00 또는 해당 날짜의 23:59:59.999999999, exclusive)
6069
* @param pageable 페이징 정보
6170
* @return 조회된 메트릭 페이지
6271
*/
@@ -80,7 +89,6 @@ Page<ProductMetrics> findByUpdatedAtBetween(
8089
*
8190
* @return PagingAndSortingRepository를 구현한 JPA Repository
8291
*/
83-
@SuppressWarnings("rawtypes")
8492
org.springframework.data.repository.PagingAndSortingRepository<ProductMetrics, Long> getJpaRepository();
8593
}
8694

apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRank.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,9 @@
1616
* <p>
1717
* <b>Materialized View 설계:</b>
1818
* <ul>
19-
* <li>주간 랭킹: `mv_product_rank_weekly` (period_type = WEEKLY)</li>
20-
* <li>월간 랭킹: `mv_product_rank_monthly` (period_type = MONTHLY)</li>
19+
* <li>테이블: `mv_product_rank` (단일 테이블)</li>
20+
* <li>주간 랭킹: period_type = WEEKLY</li>
21+
* <li>월간 랭킹: period_type = MONTHLY</li>
2122
* <li>TOP 100만 저장하여 조회 성능 최적화</li>
2223
* </ul>
2324
* </p>

apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankScoreRepository.java

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

33
import java.util.List;
44
import java.util.Optional;
5+
import java.util.Set;
56

67
/**
78
* ProductRankScore 도메인 Repository 인터페이스.
@@ -39,6 +40,17 @@ public interface ProductRankScoreRepository {
3940
*/
4041
Optional<ProductRankScore> findByProductId(Long productId);
4142

43+
/**
44+
* 여러 product_id로 ProductRankScore를 일괄 조회합니다.
45+
* <p>
46+
* N+1 쿼리 문제를 방지하기 위해 사용합니다.
47+
* </p>
48+
*
49+
* @param productIds 상품 ID 집합
50+
* @return ProductRankScore 리스트
51+
*/
52+
List<ProductRankScore> findAllByProductIdIn(Set<Long> productIds);
53+
4254
/**
4355
* 모든 ProductRankScore를 점수 내림차순으로 조회합니다.
4456
* <p>

apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReader.java

Lines changed: 59 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -50,18 +50,33 @@ public class ProductRankAggregationReader {
5050
* @return RepositoryItemReader 인스턴스
5151
*/
5252
public RepositoryItemReader<ProductMetrics> createWeeklyReader(LocalDate targetDate) {
53+
DateRange weekRange = calculateWeeklyRange(targetDate);
54+
55+
log.info("ProductRank 주간 Reader 초기화: targetDate={}, weekStart={}, weekEnd={}",
56+
targetDate, weekRange.startDate(), weekRange.endDate());
57+
58+
return createReader(weekRange.startDateTime(), weekRange.endDateTime(), "weeklyReader");
59+
}
60+
61+
/**
62+
* 주간 범위를 계산합니다.
63+
* <p>
64+
* 테스트 가능성을 위해 별도 메서드로 분리했습니다.
65+
* </p>
66+
*
67+
* @param targetDate 기준 날짜 (해당 주의 어느 날짜든 가능)
68+
* @return 주간 범위 (시작일, 종료일)
69+
*/
70+
DateRange calculateWeeklyRange(LocalDate targetDate) {
5371
// 주간 시작일 계산 (월요일)
5472
LocalDate weekStart = targetDate.with(java.time.DayOfWeek.MONDAY);
5573
LocalDateTime startDateTime = weekStart.atStartOfDay();
5674

5775
// 주간 종료일 계산 (다음 주 월요일 00:00:00)
5876
LocalDate weekEnd = weekStart.plusWeeks(1);
5977
LocalDateTime endDateTime = weekEnd.atStartOfDay();
60-
61-
log.info("ProductRank 주간 Reader 초기화: targetDate={}, weekStart={}, weekEnd={}",
62-
targetDate, weekStart, weekEnd);
63-
64-
return createReader(startDateTime, endDateTime, "weeklyReader");
78+
79+
return new DateRange(weekStart, weekEnd, startDateTime, endDateTime);
6580
}
6681

6782
/**
@@ -74,18 +89,33 @@ public RepositoryItemReader<ProductMetrics> createWeeklyReader(LocalDate targetD
7489
* @return RepositoryItemReader 인스턴스
7590
*/
7691
public RepositoryItemReader<ProductMetrics> createMonthlyReader(LocalDate targetDate) {
92+
DateRange monthRange = calculateMonthlyRange(targetDate);
93+
94+
log.info("ProductRank 월간 Reader 초기화: targetDate={}, monthStart={}, monthEnd={}",
95+
targetDate, monthRange.startDate(), monthRange.endDate());
96+
97+
return createReader(monthRange.startDateTime(), monthRange.endDateTime(), "monthlyReader");
98+
}
99+
100+
/**
101+
* 월간 범위를 계산합니다.
102+
* <p>
103+
* 테스트 가능성을 위해 별도 메서드로 분리했습니다.
104+
* </p>
105+
*
106+
* @param targetDate 기준 날짜 (해당 월의 어느 날짜든 가능)
107+
* @return 월간 범위 (시작일, 종료일)
108+
*/
109+
DateRange calculateMonthlyRange(LocalDate targetDate) {
77110
// 월간 시작일 계산 (1일)
78111
LocalDate monthStart = targetDate.with(TemporalAdjusters.firstDayOfMonth());
79112
LocalDateTime startDateTime = monthStart.atStartOfDay();
80113

81114
// 월간 종료일 계산 (다음 달 1일 00:00:00)
82115
LocalDate monthEnd = targetDate.with(TemporalAdjusters.firstDayOfNextMonth());
83116
LocalDateTime endDateTime = monthEnd.atStartOfDay();
84-
85-
log.info("ProductRank 월간 Reader 초기화: targetDate={}, monthStart={}, monthEnd={}",
86-
targetDate, monthStart, monthEnd);
87-
88-
return createReader(startDateTime, endDateTime, "monthlyReader");
117+
118+
return new DateRange(monthStart, monthEnd, startDateTime, endDateTime);
89119
}
90120

91121
/**
@@ -119,5 +149,24 @@ private RepositoryItemReader<ProductMetrics> createReader(
119149
.sorts(sorts)
120150
.build();
121151
}
152+
153+
/**
154+
* 날짜 범위를 담는 레코드.
155+
* <p>
156+
* 테스트 가능성을 위해 내부 클래스로 정의했습니다.
157+
* </p>
158+
*
159+
* @param startDate 시작일
160+
* @param endDate 종료일 (exclusive)
161+
* @param startDateTime 시작 시각
162+
* @param endDateTime 종료 시각 (exclusive)
163+
*/
164+
record DateRange(
165+
LocalDate startDate,
166+
LocalDate endDate,
167+
LocalDateTime startDateTime,
168+
LocalDateTime endDateTime
169+
) {
170+
}
122171
}
123172

apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationProcessor.java

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import com.loopers.domain.rank.ProductRankScore;
55
import lombok.RequiredArgsConstructor;
66
import lombok.extern.slf4j.Slf4j;
7+
import org.springframework.batch.core.configuration.annotation.StepScope;
78
import org.springframework.batch.item.ItemProcessor;
89
import org.springframework.stereotype.Component;
910

@@ -29,11 +30,12 @@
2930
*/
3031
@Slf4j
3132
@Component
33+
@StepScope
3234
@RequiredArgsConstructor
3335
public class ProductRankCalculationProcessor implements ItemProcessor<ProductRankScore, ProductRank> {
3436

3537
private final ProductRankAggregationProcessor productRankAggregationProcessor;
36-
private final ThreadLocal<Integer> currentRank = ThreadLocal.withInitial(() -> 0);
38+
private int currentRank = 0;
3739
private static final int TOP_RANK_LIMIT = 100;
3840

3941
/**
@@ -48,8 +50,7 @@ public class ProductRankCalculationProcessor implements ItemProcessor<ProductRan
4850
*/
4951
@Override
5052
public ProductRank process(ProductRankScore score) throws Exception {
51-
int rank = currentRank.get() + 1;
52-
currentRank.set(rank);
53+
int rank = ++currentRank;
5354

5455
// TOP 100에 포함되지 않으면 null 반환 (필터링)
5556
if (rank > TOP_RANK_LIMIT) {
@@ -76,11 +77,6 @@ public ProductRank process(ProductRankScore score) throws Exception {
7677
score.getViewCount()
7778
);
7879

79-
// Step 완료 후 ThreadLocal 정리 (마지막 항목 처리 시)
80-
if (rank == TOP_RANK_LIMIT) {
81-
currentRank.remove();
82-
}
83-
8480
return productRank;
8581
}
8682
}

apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationReader.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import com.loopers.domain.rank.ProductRankScoreRepository;
55
import lombok.RequiredArgsConstructor;
66
import lombok.extern.slf4j.Slf4j;
7+
import org.springframework.batch.core.configuration.annotation.StepScope;
78
import org.springframework.batch.item.ItemReader;
89
import org.springframework.batch.item.NonTransientResourceException;
910
import org.springframework.batch.item.ParseException;
@@ -33,6 +34,7 @@
3334
*/
3435
@Slf4j
3536
@Component
37+
@StepScope
3638
@RequiredArgsConstructor
3739
public class ProductRankCalculationReader implements ItemReader<ProductRankScore> {
3840

apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationWriter.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import com.loopers.domain.rank.ProductRankRepository;
55
import lombok.RequiredArgsConstructor;
66
import lombok.extern.slf4j.Slf4j;
7+
import org.springframework.batch.core.configuration.annotation.StepScope;
78
import org.springframework.batch.item.Chunk;
89
import org.springframework.batch.item.ItemWriter;
910
import org.springframework.stereotype.Component;
@@ -32,6 +33,7 @@
3233
*/
3334
@Slf4j
3435
@Component
36+
@StepScope
3537
@RequiredArgsConstructor
3638
public class ProductRankCalculationWriter implements ItemWriter<ProductRank> {
3739

0 commit comments

Comments
 (0)