Skip to content

Commit eaff8f0

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

12 files changed

Lines changed: 69 additions & 45 deletions

File tree

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: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,6 @@ Page<ProductMetrics> findByUpdatedAtBetween(
8080
*
8181
* @return PagingAndSortingRepository를 구현한 JPA Repository
8282
*/
83-
@SuppressWarnings("rawtypes")
8483
org.springframework.data.repository.PagingAndSortingRepository<ProductMetrics, Long> getJpaRepository();
8584
}
8685

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/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

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,10 @@ public ItemReader<ProductMetrics> productRankReader(
181181
@Value("#{jobParameters['periodType']}") String periodType,
182182
@Value("#{jobParameters['targetDate']}") String targetDate
183183
) {
184+
if (periodType == null || periodType.isEmpty()) {
185+
throw new IllegalArgumentException("periodType 파라미터는 필수입니다. (WEEKLY 또는 MONTHLY)");
186+
}
187+
184188
LocalDate date = parseDate(targetDate);
185189
ProductRank.PeriodType period = ProductRank.PeriodType.valueOf(periodType.toUpperCase());
186190

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

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111

1212
import java.util.List;
1313
import java.util.Map;
14+
import java.util.Set;
15+
import java.util.function.Function;
1416
import java.util.stream.Collectors;
1517

1618
/**
@@ -80,25 +82,31 @@ public void write(Chunk<? extends ProductMetrics> chunk) throws Exception {
8082
)
8183
));
8284

85+
// Chunk 내 모든 productId를 한 번에 조회
86+
Set<Long> productIds = chunkAggregatedMap.keySet();
87+
Map<Long, ProductRankScore> existingScores = productRankScoreRepository
88+
.findAllByProductIdIn(productIds)
89+
.stream()
90+
.collect(Collectors.toMap(ProductRankScore::getProductId, Function.identity()));
91+
8392
// 기존 데이터와 누적하여 ProductRankScore 생성
8493
List<ProductRankScore> scores = chunkAggregatedMap.entrySet().stream()
8594
.map(entry -> {
8695
Long productId = entry.getKey();
8796
AggregatedMetrics chunkAggregated = entry.getValue();
8897

89-
// 기존 데이터 조회
90-
java.util.Optional<ProductRankScore> existing = productRankScoreRepository.findByProductId(productId);
98+
// 기존 데이터 조회 (일괄 조회 결과에서)
99+
ProductRankScore existing = existingScores.get(productId);
91100

92101
// 기존 데이터와 누적
93102
Long totalLikeCount = chunkAggregated.getLikeCount();
94103
Long totalSalesCount = chunkAggregated.getSalesCount();
95104
Long totalViewCount = chunkAggregated.getViewCount();
96105

97-
if (existing.isPresent()) {
98-
ProductRankScore existingScore = existing.get();
99-
totalLikeCount += existingScore.getLikeCount();
100-
totalSalesCount += existingScore.getSalesCount();
101-
totalViewCount += existingScore.getViewCount();
106+
if (existing != null) {
107+
totalLikeCount += existing.getLikeCount();
108+
totalSalesCount += existing.getSalesCount();
109+
totalViewCount += existing.getViewCount();
102110
}
103111

104112
// 점수 계산 (가중치: 좋아요 0.3, 판매량 0.5, 조회수 0.2)

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,6 @@ public Page<ProductMetrics> findByUpdatedAtBetween(
6565
* {@inheritDoc}
6666
*/
6767
@Override
68-
@SuppressWarnings("rawtypes")
6968
public org.springframework.data.repository.PagingAndSortingRepository<ProductMetrics, Long> getJpaRepository() {
7069
return productMetricsJpaRepository;
7170
}

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
import java.util.List;
1212
import java.util.Optional;
13+
import java.util.Set;
1314

1415
/**
1516
* ProductRankScore Repository 구현체.
@@ -70,6 +71,18 @@ public Optional<ProductRankScore> findByProductId(Long productId) {
7071
}
7172
}
7273

74+
@Override
75+
public List<ProductRankScore> findAllByProductIdIn(Set<Long> productIds) {
76+
if (productIds == null || productIds.isEmpty()) {
77+
return List.of();
78+
}
79+
80+
String jpql = "SELECT prs FROM ProductRankScore prs WHERE prs.productId IN :productIds";
81+
return entityManager.createQuery(jpql, ProductRankScore.class)
82+
.setParameter("productIds", productIds)
83+
.getResultList();
84+
}
85+
7386
@Override
7487
public List<ProductRankScore> findAllOrderByScoreDesc(int limit) {
7588
String jpql = "SELECT prs FROM ProductRankScore prs ORDER BY prs.score DESC";

0 commit comments

Comments
 (0)