Skip to content

Commit 438c77d

Browse files
authored
[volume-10] Collect, Stack, Zip (#233)
* Feature/batch (#40) * feat: batch 처리 모듇 분리 * feat: batch 모듈에 ProductMetrics 도메인 추가 * feat: ProudctMetrics의 Repository 추가 * test: Product Metrics 배치 작업에 대한 테스트 코드 추가 * feat: ProductMetrics 배치 작업 구현 * test: Product Rank에 대한 테스트 코드 추가 * feat: Product Rank 도메인 구현 * feat: Product Rank Repository 추가 * test: Product Rank 배치에 대한 테스트 코드 추가 * feat: Product Rank 배치 작업 추가 * feat: 일간, 주간, 월간 랭킹을 제공하는 api 추가 * refractor: 랭킹 집계 로직을 여러 step으로 분리함 * chore: db 초기화 로직에서 발생하는 오류 수정 * test: 랭킹 집계의 각 step에 대한 테스트 코드 추가 * cdoerabbit 피드백 반영 * 트랜젝션 어노테이션 추가 * 랭킹 대상 항목이 100개 미만일 때의 배치 에외 처리 * @StepScope를 적용하여 Step 실행마다 새 인스턴스를 생성 * 랭크 계산 후 싱글톤 인스턴스 내의 필드 초기화하여 데이터 오염 및 메모리 누수 문제 방지 * 배치 실행 파라미터에서 발생할 수 있는 null pointer exeception 수정 * n+1 쿼리 개선 --------- Co-authored-by: 이건영 <>
1 parent f8db897 commit 438c77d

42 files changed

Lines changed: 4453 additions & 11 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/commerce-api/build.gradle.kts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,6 @@ dependencies {
2424
implementation("io.github.resilience4j:resilience4j-bulkhead") // Bulkheads 패턴 구현
2525
implementation("org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j")
2626

27-
// batch
28-
implementation("org.springframework.boot:spring-boot-starter-batch")
29-
3027
// querydsl
3128
annotationProcessor("com.querydsl:querydsl-apt::jakarta")
3229
annotationProcessor("jakarta.persistence:jakarta.persistence-api")

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

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,49 @@ public class RankingService {
4646
private final ProductService productService;
4747
private final BrandService brandService;
4848
private final RankingSnapshotService rankingSnapshotService;
49+
private final com.loopers.domain.rank.ProductRankRepository productRankRepository;
4950

5051
/**
5152
* 랭킹을 조회합니다 (페이징).
5253
* <p>
54+
* 기간별(일간/주간/월간) 랭킹을 조회합니다.
55+
* </p>
56+
* <p>
57+
* <b>기간별 조회 방식:</b>
58+
* <ul>
59+
* <li>DAILY: Redis ZSET에서 조회 (기존 방식)</li>
60+
* <li>WEEKLY: Materialized View에서 조회</li>
61+
* <li>MONTHLY: Materialized View에서 조회</li>
62+
* </ul>
63+
* </p>
64+
* <p>
65+
* <b>Graceful Degradation (DAILY만 적용):</b>
66+
* <ul>
67+
* <li>Redis 장애 시 스냅샷으로 Fallback</li>
68+
* <li>스냅샷도 없으면 기본 랭킹(좋아요순) 제공 (단순 조회, 계산 아님)</li>
69+
* </ul>
70+
* </p>
71+
*
72+
* @param date 날짜 (yyyyMMdd 형식의 문자열 또는 LocalDate)
73+
* @param periodType 기간 타입 (DAILY, WEEKLY, MONTHLY)
74+
* @param page 페이지 번호 (0부터 시작)
75+
* @param size 페이지당 항목 수
76+
* @return 랭킹 조회 결과
77+
*/
78+
@Transactional(readOnly = true)
79+
public RankingsResponse getRankings(LocalDate date, PeriodType periodType, int page, int size) {
80+
if (periodType == PeriodType.DAILY) {
81+
// 일간 랭킹: 기존 Redis 방식
82+
return getRankings(date, page, size);
83+
} else {
84+
// 주간/월간 랭킹: Materialized View에서 조회
85+
return getRankingsFromMaterializedView(date, periodType, page, size);
86+
}
87+
}
88+
89+
/**
90+
* 랭킹을 조회합니다 (페이징) - 일간 랭킹 전용.
91+
* <p>
5392
* ZSET에서 상위 N개를 조회하고, 상품 정보를 Aggregation하여 반환합니다.
5493
* </p>
5594
* <p>
@@ -304,6 +343,151 @@ private Long getProductRankFromRedis(Long productId, LocalDate date) {
304343
return rank + 1;
305344
}
306345

346+
/**
347+
* Materialized View에서 주간/월간 랭킹을 조회합니다.
348+
* <p>
349+
* Materialized View에 저장된 TOP 100 랭킹을 조회하고, 상품 정보를 Aggregation하여 반환합니다.
350+
* </p>
351+
*
352+
* @param date 기준 날짜
353+
* @param periodType 기간 타입 (WEEKLY 또는 MONTHLY)
354+
* @param page 페이지 번호 (0부터 시작)
355+
* @param size 페이지당 항목 수
356+
* @return 랭킹 조회 결과
357+
*/
358+
private RankingsResponse getRankingsFromMaterializedView(
359+
LocalDate date,
360+
PeriodType periodType,
361+
int page,
362+
int size
363+
) {
364+
// 기간 시작일 계산
365+
LocalDate periodStartDate;
366+
if (periodType == PeriodType.WEEKLY) {
367+
// 주간: 해당 주의 월요일
368+
periodStartDate = date.with(java.time.DayOfWeek.MONDAY);
369+
} else {
370+
// 월간: 해당 월의 1일
371+
periodStartDate = date.with(java.time.temporal.TemporalAdjusters.firstDayOfMonth());
372+
}
373+
374+
// Materialized View에서 랭킹 조회
375+
com.loopers.domain.rank.ProductRank.PeriodType rankPeriodType =
376+
periodType == PeriodType.WEEKLY
377+
? com.loopers.domain.rank.ProductRank.PeriodType.WEEKLY
378+
: com.loopers.domain.rank.ProductRank.PeriodType.MONTHLY;
379+
380+
List<com.loopers.domain.rank.ProductRank> ranks = productRankRepository.findByPeriod(
381+
rankPeriodType, periodStartDate, 100
382+
);
383+
384+
if (ranks.isEmpty()) {
385+
return RankingsResponse.empty(page, size);
386+
}
387+
388+
// 페이징 처리
389+
long start = (long) page * size;
390+
long end = Math.min(start + size, ranks.size());
391+
392+
if (start >= ranks.size()) {
393+
return RankingsResponse.empty(page, size);
394+
}
395+
396+
List<com.loopers.domain.rank.ProductRank> pagedRanks = ranks.subList((int) start, (int) end);
397+
398+
// 상품 ID 추출
399+
List<Long> productIds = pagedRanks.stream()
400+
.map(com.loopers.domain.rank.ProductRank::getProductId)
401+
.toList();
402+
403+
// 상품 정보 배치 조회
404+
List<Product> products = productService.getProducts(productIds);
405+
406+
// 상품 ID → Product Map 생성
407+
Map<Long, Product> productMap = products.stream()
408+
.collect(Collectors.toMap(Product::getId, product -> product));
409+
410+
// 브랜드 ID 수집
411+
List<Long> brandIds = products.stream()
412+
.map(Product::getBrandId)
413+
.distinct()
414+
.toList();
415+
416+
// 브랜드 배치 조회
417+
Map<Long, Brand> brandMap = brandService.getBrands(brandIds).stream()
418+
.collect(Collectors.toMap(Brand::getId, brand -> brand));
419+
420+
// 랭킹 항목 생성 (순위 재계산: 누락된 항목 제외 후 연속 순위 부여)
421+
List<RankingItem> rankingItems = new ArrayList<>();
422+
long currentRank = start + 1; // 1-based 순위 (페이지 시작 순위)
423+
424+
for (com.loopers.domain.rank.ProductRank rank : pagedRanks) {
425+
Long productId = rank.getProductId();
426+
Product product = productMap.get(productId);
427+
428+
if (product == null) {
429+
log.warn("랭킹에 포함된 상품을 찾을 수 없습니다: productId={}", productId);
430+
continue;
431+
}
432+
433+
Brand brand = brandMap.get(product.getBrandId());
434+
if (brand == null) {
435+
log.warn("상품의 브랜드를 찾을 수 없습니다: productId={}, brandId={}",
436+
productId, product.getBrandId());
437+
continue;
438+
}
439+
440+
ProductDetail productDetail = ProductDetail.from(
441+
product,
442+
brand.getName(),
443+
rank.getLikeCount()
444+
);
445+
446+
// 종합 점수 계산 (Materialized View에는 저장되지 않으므로 계산)
447+
double score = calculateScore(rank.getLikeCount(), rank.getSalesCount(), rank.getViewCount());
448+
449+
rankingItems.add(new RankingItem(
450+
currentRank++, // 연속 순위 부여
451+
score,
452+
productDetail
453+
));
454+
}
455+
456+
boolean hasNext = end < ranks.size();
457+
return new RankingsResponse(rankingItems, page, size, hasNext);
458+
}
459+
460+
/**
461+
* 종합 점수를 계산합니다.
462+
* <p>
463+
* 가중치:
464+
* <ul>
465+
* <li>좋아요: 0.3</li>
466+
* <li>판매량: 0.5</li>
467+
* <li>조회수: 0.2</li>
468+
* </ul>
469+
* </p>
470+
*
471+
* @param likeCount 좋아요 수
472+
* @param salesCount 판매량
473+
* @param viewCount 조회 수
474+
* @return 종합 점수
475+
*/
476+
private double calculateScore(Long likeCount, Long salesCount, Long viewCount) {
477+
return (likeCount != null ? likeCount : 0L) * 0.3
478+
+ (salesCount != null ? salesCount : 0L) * 0.5
479+
+ (viewCount != null ? viewCount : 0L) * 0.2;
480+
}
481+
482+
/**
483+
* 기간 타입 열거형.
484+
*/
485+
public enum PeriodType {
486+
DAILY, // 일간
487+
WEEKLY, // 주간
488+
MONTHLY // 월간
489+
}
490+
307491
/**
308492
* 랭킹 조회 결과.
309493
*
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
package com.loopers.domain.rank;
2+
3+
import jakarta.persistence.*;
4+
import lombok.AccessLevel;
5+
import lombok.Getter;
6+
import lombok.NoArgsConstructor;
7+
8+
import java.time.LocalDate;
9+
import java.time.LocalDateTime;
10+
11+
/**
12+
* 상품 랭킹 Materialized View 엔티티.
13+
* <p>
14+
* 주간/월간 TOP 100 랭킹을 저장하는 조회 전용 테이블입니다.
15+
* </p>
16+
* <p>
17+
* <b>Materialized View 설계:</b>
18+
* <ul>
19+
* <li>테이블: `mv_product_rank` (단일 테이블)</li>
20+
* <li>주간 랭킹: period_type = WEEKLY</li>
21+
* <li>월간 랭킹: period_type = MONTHLY</li>
22+
* <li>TOP 100만 저장하여 조회 성능 최적화</li>
23+
* </ul>
24+
* </p>
25+
* <p>
26+
* <b>인덱스 전략:</b>
27+
* <ul>
28+
* <li>복합 인덱스: (period_type, period_start_date, rank) - 기간별 랭킹 조회 최적화</li>
29+
* <li>복합 인덱스: (period_type, period_start_date, product_id) - 특정 상품 랭킹 조회 최적화</li>
30+
* </ul>
31+
* </p>
32+
*
33+
* @author Loopers
34+
* @version 1.0
35+
*/
36+
@Entity
37+
@Table(
38+
name = "mv_product_rank",
39+
indexes = {
40+
@Index(name = "idx_period_type_start_date_rank", columnList = "period_type, period_start_date, rank"),
41+
@Index(name = "idx_period_type_start_date_product_id", columnList = "period_type, period_start_date, product_id")
42+
}
43+
)
44+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
45+
@Getter
46+
public class ProductRank {
47+
48+
@Id
49+
@GeneratedValue(strategy = GenerationType.IDENTITY)
50+
@Column(name = "id")
51+
private Long id;
52+
53+
/**
54+
* 기간 타입 (WEEKLY: 주간, MONTHLY: 월간)
55+
*/
56+
@Enumerated(EnumType.STRING)
57+
@Column(name = "period_type", nullable = false, length = 20)
58+
private PeriodType periodType;
59+
60+
/**
61+
* 기간 시작일
62+
* <ul>
63+
* <li>주간: 해당 주의 월요일 (ISO 8601 기준)</li>
64+
* <li>월간: 해당 월의 1일</li>
65+
* </ul>
66+
*/
67+
@Column(name = "period_start_date", nullable = false)
68+
private LocalDate periodStartDate;
69+
70+
/**
71+
* 상품 ID
72+
*/
73+
@Column(name = "product_id", nullable = false)
74+
private Long productId;
75+
76+
/**
77+
* 랭킹 (1-100)
78+
*/
79+
@Column(name = "rank", nullable = false)
80+
private Integer rank;
81+
82+
/**
83+
* 좋아요 수
84+
*/
85+
@Column(name = "like_count", nullable = false)
86+
private Long likeCount;
87+
88+
/**
89+
* 판매량
90+
*/
91+
@Column(name = "sales_count", nullable = false)
92+
private Long salesCount;
93+
94+
/**
95+
* 조회 수
96+
*/
97+
@Column(name = "view_count", nullable = false)
98+
private Long viewCount;
99+
100+
/**
101+
* 생성 시각
102+
*/
103+
@Column(name = "created_at", nullable = false, updatable = false)
104+
private LocalDateTime createdAt;
105+
106+
/**
107+
* 수정 시각
108+
*/
109+
@Column(name = "updated_at", nullable = false)
110+
private LocalDateTime updatedAt;
111+
112+
/**
113+
* 기간 타입 열거형.
114+
*/
115+
public enum PeriodType {
116+
WEEKLY, // 주간
117+
MONTHLY // 월간
118+
}
119+
}
120+
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package com.loopers.domain.rank;
2+
3+
import java.time.LocalDate;
4+
import java.util.List;
5+
import java.util.Optional;
6+
7+
/**
8+
* ProductRank 도메인 Repository 인터페이스.
9+
* <p>
10+
* Materialized View에 저장된 상품 랭킹 데이터를 조회합니다.
11+
* </p>
12+
*/
13+
public interface ProductRankRepository {
14+
15+
/**
16+
* 특정 기간의 랭킹 데이터를 조회합니다.
17+
*
18+
* @param periodType 기간 타입
19+
* @param periodStartDate 기간 시작일
20+
* @param limit 조회할 랭킹 수 (기본: 100)
21+
* @return 랭킹 리스트 (rank 오름차순)
22+
*/
23+
List<ProductRank> findByPeriod(ProductRank.PeriodType periodType, LocalDate periodStartDate, int limit);
24+
25+
/**
26+
* 특정 기간의 특정 상품 랭킹을 조회합니다.
27+
*
28+
* @param periodType 기간 타입
29+
* @param periodStartDate 기간 시작일
30+
* @param productId 상품 ID
31+
* @return 랭킹 정보 (없으면 Optional.empty())
32+
*/
33+
Optional<ProductRank> findByPeriodAndProductId(
34+
ProductRank.PeriodType periodType,
35+
LocalDate periodStartDate,
36+
Long productId
37+
);
38+
}
39+

0 commit comments

Comments
 (0)