Skip to content

Commit d4ceccc

Browse files
authored
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에 대한 테스트 코드 추가
1 parent f8db897 commit d4ceccc

42 files changed

Lines changed: 4342 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: 182 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,149 @@ 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+
for (com.loopers.domain.rank.ProductRank rank : pagedRanks) {
423+
Long productId = rank.getProductId();
424+
Product product = productMap.get(productId);
425+
426+
if (product == null) {
427+
log.warn("랭킹에 포함된 상품을 찾을 수 없습니다: productId={}", productId);
428+
continue;
429+
}
430+
431+
Brand brand = brandMap.get(product.getBrandId());
432+
if (brand == null) {
433+
log.warn("상품의 브랜드를 찾을 수 없습니다: productId={}, brandId={}",
434+
productId, product.getBrandId());
435+
continue;
436+
}
437+
438+
ProductDetail productDetail = ProductDetail.from(
439+
product,
440+
brand.getName(),
441+
rank.getLikeCount()
442+
);
443+
444+
// 종합 점수 계산 (Materialized View에는 저장되지 않으므로 계산)
445+
double score = calculateScore(rank.getLikeCount(), rank.getSalesCount(), rank.getViewCount());
446+
447+
rankingItems.add(new RankingItem(
448+
rank.getRank().longValue(),
449+
score,
450+
productDetail
451+
));
452+
}
453+
454+
boolean hasNext = end < ranks.size();
455+
return new RankingsResponse(rankingItems, page, size, hasNext);
456+
}
457+
458+
/**
459+
* 종합 점수를 계산합니다.
460+
* <p>
461+
* 가중치:
462+
* <ul>
463+
* <li>좋아요: 0.3</li>
464+
* <li>판매량: 0.5</li>
465+
* <li>조회수: 0.2</li>
466+
* </ul>
467+
* </p>
468+
*
469+
* @param likeCount 좋아요 수
470+
* @param salesCount 판매량
471+
* @param viewCount 조회 수
472+
* @return 종합 점수
473+
*/
474+
private double calculateScore(Long likeCount, Long salesCount, Long viewCount) {
475+
return (likeCount != null ? likeCount : 0L) * 0.3
476+
+ (salesCount != null ? salesCount : 0L) * 0.5
477+
+ (viewCount != null ? viewCount : 0L) * 0.2;
478+
}
479+
480+
/**
481+
* 기간 타입 열거형.
482+
*/
483+
public enum PeriodType {
484+
DAILY, // 일간
485+
WEEKLY, // 주간
486+
MONTHLY // 월간
487+
}
488+
307489
/**
308490
* 랭킹 조회 결과.
309491
*
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
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_weekly` (period_type = WEEKLY)</li>
20+
* <li>월간 랭킹: `mv_product_rank_monthly` (period_type = MONTHLY)</li>
21+
* <li>TOP 100만 저장하여 조회 성능 최적화</li>
22+
* </ul>
23+
* </p>
24+
* <p>
25+
* <b>인덱스 전략:</b>
26+
* <ul>
27+
* <li>복합 인덱스: (period_type, period_start_date, rank) - 기간별 랭킹 조회 최적화</li>
28+
* <li>복합 인덱스: (period_type, period_start_date, product_id) - 특정 상품 랭킹 조회 최적화</li>
29+
* </ul>
30+
* </p>
31+
*
32+
* @author Loopers
33+
* @version 1.0
34+
*/
35+
@Entity
36+
@Table(
37+
name = "mv_product_rank",
38+
indexes = {
39+
@Index(name = "idx_period_type_start_date_rank", columnList = "period_type, period_start_date, rank"),
40+
@Index(name = "idx_period_type_start_date_product_id", columnList = "period_type, period_start_date, product_id")
41+
}
42+
)
43+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
44+
@Getter
45+
public class ProductRank {
46+
47+
@Id
48+
@GeneratedValue(strategy = GenerationType.IDENTITY)
49+
@Column(name = "id")
50+
private Long id;
51+
52+
/**
53+
* 기간 타입 (WEEKLY: 주간, MONTHLY: 월간)
54+
*/
55+
@Enumerated(EnumType.STRING)
56+
@Column(name = "period_type", nullable = false, length = 20)
57+
private PeriodType periodType;
58+
59+
/**
60+
* 기간 시작일
61+
* <ul>
62+
* <li>주간: 해당 주의 월요일 (ISO 8601 기준)</li>
63+
* <li>월간: 해당 월의 1일</li>
64+
* </ul>
65+
*/
66+
@Column(name = "period_start_date", nullable = false)
67+
private LocalDate periodStartDate;
68+
69+
/**
70+
* 상품 ID
71+
*/
72+
@Column(name = "product_id", nullable = false)
73+
private Long productId;
74+
75+
/**
76+
* 랭킹 (1-100)
77+
*/
78+
@Column(name = "rank", nullable = false)
79+
private Integer rank;
80+
81+
/**
82+
* 좋아요 수
83+
*/
84+
@Column(name = "like_count", nullable = false)
85+
private Long likeCount;
86+
87+
/**
88+
* 판매량
89+
*/
90+
@Column(name = "sales_count", nullable = false)
91+
private Long salesCount;
92+
93+
/**
94+
* 조회 수
95+
*/
96+
@Column(name = "view_count", nullable = false)
97+
private Long viewCount;
98+
99+
/**
100+
* 생성 시각
101+
*/
102+
@Column(name = "created_at", nullable = false, updatable = false)
103+
private LocalDateTime createdAt;
104+
105+
/**
106+
* 수정 시각
107+
*/
108+
@Column(name = "updated_at", nullable = false)
109+
private LocalDateTime updatedAt;
110+
111+
/**
112+
* 기간 타입 열거형.
113+
*/
114+
public enum PeriodType {
115+
WEEKLY, // 주간
116+
MONTHLY // 월간
117+
}
118+
}
119+
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)