Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
215b61c
feat: batch 처리 λͺ¨λ“‡ 뢄리
minor7295 Dec 29, 2025
63769a1
feat: batch λͺ¨λ“ˆμ— ProductMetrics 도메인 μΆ”κ°€
minor7295 Dec 29, 2025
c0fc73a
feat: ProudctMetrics의 Repository μΆ”κ°€
minor7295 Dec 29, 2025
8c75257
test: Product Metrics 배치 μž‘μ—…μ— λŒ€ν•œ ν…ŒμŠ€νŠΈ μ½”λ“œ μΆ”κ°€
minor7295 Dec 29, 2025
9ee7c5b
feat: ProductMetrics 배치 μž‘μ—… κ΅¬ν˜„
minor7295 Dec 29, 2025
6ff36ed
test: Product Rank에 λŒ€ν•œ ν…ŒμŠ€νŠΈ μ½”λ“œ μΆ”κ°€
minor7295 Dec 29, 2025
f2b01ae
feat: Product Rank 도메인 κ΅¬ν˜„
minor7295 Dec 29, 2025
6e0110b
feat: Product Rank Repository μΆ”κ°€
minor7295 Dec 29, 2025
db8f80c
test: Product Rank λ°°μΉ˜μ— λŒ€ν•œ ν…ŒμŠ€νŠΈ μ½”λ“œ μΆ”κ°€
minor7295 Dec 29, 2025
43dca99
feat: Product Rank 배치 μž‘μ—… μΆ”κ°€
minor7295 Dec 29, 2025
e70dadf
feat: 일간, μ£Όκ°„, μ›”κ°„ λž­ν‚Ήμ„ μ œκ³΅ν•˜λŠ” api μΆ”κ°€
minor7295 Dec 29, 2025
7fd8a80
refractor: λž­ν‚Ή 집계 λ‘œμ§μ„ μ—¬λŸ¬ step으둜 뢄리함
minor7295 Jan 1, 2026
7e91c91
chore: db μ΄ˆκΈ°ν™” λ‘œμ§μ—μ„œ λ°œμƒν•˜λŠ” 였λ₯˜ μˆ˜μ •
minor7295 Jan 1, 2026
6c6d341
test: λž­ν‚Ή μ§‘κ³„μ˜ 각 step에 λŒ€ν•œ ν…ŒμŠ€νŠΈ μ½”λ“œ μΆ”κ°€
minor7295 Jan 1, 2026
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
3 changes: 0 additions & 3 deletions apps/commerce-api/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,6 @@ dependencies {
implementation("io.github.resilience4j:resilience4j-bulkhead") // Bulkheads νŒ¨ν„΄ κ΅¬ν˜„
implementation("org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j")

// batch
implementation("org.springframework.boot:spring-boot-starter-batch")

// querydsl
annotationProcessor("com.querydsl:querydsl-apt::jakarta")
annotationProcessor("jakarta.persistence:jakarta.persistence-api")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,49 @@ public class RankingService {
private final ProductService productService;
private final BrandService brandService;
private final RankingSnapshotService rankingSnapshotService;
private final com.loopers.domain.rank.ProductRankRepository productRankRepository;

/**
* λž­ν‚Ήμ„ μ‘°νšŒν•©λ‹ˆλ‹€ (νŽ˜μ΄μ§•).
* <p>
* 기간별(일간/μ£Όκ°„/μ›”κ°„) λž­ν‚Ήμ„ μ‘°νšŒν•©λ‹ˆλ‹€.
* </p>
* <p>
* <b>기간별 쑰회 방식:</b>
* <ul>
* <li>DAILY: Redis ZSETμ—μ„œ 쑰회 (κΈ°μ‘΄ 방식)</li>
* <li>WEEKLY: Materialized Viewμ—μ„œ 쑰회</li>
* <li>MONTHLY: Materialized Viewμ—μ„œ 쑰회</li>
* </ul>
* </p>
* <p>
* <b>Graceful Degradation (DAILY만 적용):</b>
* <ul>
* <li>Redis μž₯μ•  μ‹œ μŠ€λƒ…μƒ·μœΌλ‘œ Fallback</li>
* <li>μŠ€λƒ…μƒ·λ„ μ—†μœΌλ©΄ κΈ°λ³Έ λž­ν‚Ή(μ’‹μ•„μš”μˆœ) 제곡 (λ‹¨μˆœ 쑰회, 계산 μ•„λ‹˜)</li>
* </ul>
* </p>
*
* @param date λ‚ μ§œ (yyyyMMdd ν˜•μ‹μ˜ λ¬Έμžμ—΄ λ˜λŠ” LocalDate)
* @param periodType κΈ°κ°„ νƒ€μž… (DAILY, WEEKLY, MONTHLY)
* @param page νŽ˜μ΄μ§€ 번호 (0λΆ€ν„° μ‹œμž‘)
* @param size νŽ˜μ΄μ§€λ‹Ή ν•­λͺ© 수
* @return λž­ν‚Ή 쑰회 κ²°κ³Ό
*/
@Transactional(readOnly = true)
public RankingsResponse getRankings(LocalDate date, PeriodType periodType, int page, int size) {
if (periodType == PeriodType.DAILY) {
// 일간 λž­ν‚Ή: κΈ°μ‘΄ Redis 방식
return getRankings(date, page, size);
} else {
// μ£Όκ°„/μ›”κ°„ λž­ν‚Ή: Materialized Viewμ—μ„œ 쑰회
return getRankingsFromMaterializedView(date, periodType, page, size);
}
}

/**
* λž­ν‚Ήμ„ μ‘°νšŒν•©λ‹ˆλ‹€ (νŽ˜μ΄μ§•) - 일간 λž­ν‚Ή μ „μš©.
* <p>
* ZSETμ—μ„œ μƒμœ„ N개λ₯Ό μ‘°νšŒν•˜κ³ , μƒν’ˆ 정보λ₯Ό Aggregationν•˜μ—¬ λ°˜ν™˜ν•©λ‹ˆλ‹€.
* </p>
* <p>
Expand Down Expand Up @@ -304,6 +343,149 @@ private Long getProductRankFromRedis(Long productId, LocalDate date) {
return rank + 1;
}

/**
* Materialized Viewμ—μ„œ μ£Όκ°„/μ›”κ°„ λž­ν‚Ήμ„ μ‘°νšŒν•©λ‹ˆλ‹€.
* <p>
* Materialized View에 μ €μž₯된 TOP 100 λž­ν‚Ήμ„ μ‘°νšŒν•˜κ³ , μƒν’ˆ 정보λ₯Ό Aggregationν•˜μ—¬ λ°˜ν™˜ν•©λ‹ˆλ‹€.
* </p>
*
* @param date κΈ°μ€€ λ‚ μ§œ
* @param periodType κΈ°κ°„ νƒ€μž… (WEEKLY λ˜λŠ” MONTHLY)
* @param page νŽ˜μ΄μ§€ 번호 (0λΆ€ν„° μ‹œμž‘)
* @param size νŽ˜μ΄μ§€λ‹Ή ν•­λͺ© 수
* @return λž­ν‚Ή 쑰회 κ²°κ³Ό
*/
private RankingsResponse getRankingsFromMaterializedView(
LocalDate date,
PeriodType periodType,
int page,
int size
) {
// κΈ°κ°„ μ‹œμž‘μΌ 계산
LocalDate periodStartDate;
if (periodType == PeriodType.WEEKLY) {
// μ£Όκ°„: ν•΄λ‹Ή 주의 μ›”μš”μΌ
periodStartDate = date.with(java.time.DayOfWeek.MONDAY);
} else {
// μ›”κ°„: ν•΄λ‹Ή μ›”μ˜ 1일
periodStartDate = date.with(java.time.temporal.TemporalAdjusters.firstDayOfMonth());
}

// Materialized Viewμ—μ„œ λž­ν‚Ή 쑰회
com.loopers.domain.rank.ProductRank.PeriodType rankPeriodType =
periodType == PeriodType.WEEKLY
? com.loopers.domain.rank.ProductRank.PeriodType.WEEKLY
: com.loopers.domain.rank.ProductRank.PeriodType.MONTHLY;

List<com.loopers.domain.rank.ProductRank> ranks = productRankRepository.findByPeriod(
rankPeriodType, periodStartDate, 100
);

if (ranks.isEmpty()) {
return RankingsResponse.empty(page, size);
}

// νŽ˜μ΄μ§• 처리
long start = (long) page * size;
long end = Math.min(start + size, ranks.size());

if (start >= ranks.size()) {
return RankingsResponse.empty(page, size);
}

List<com.loopers.domain.rank.ProductRank> pagedRanks = ranks.subList((int) start, (int) end);

// μƒν’ˆ ID μΆ”μΆœ
List<Long> productIds = pagedRanks.stream()
.map(com.loopers.domain.rank.ProductRank::getProductId)
.toList();

// μƒν’ˆ 정보 배치 쑰회
List<Product> products = productService.getProducts(productIds);

// μƒν’ˆ ID β†’ Product Map 생성
Map<Long, Product> productMap = products.stream()
.collect(Collectors.toMap(Product::getId, product -> product));

// λΈŒλžœλ“œ ID μˆ˜μ§‘
List<Long> brandIds = products.stream()
.map(Product::getBrandId)
.distinct()
.toList();

// λΈŒλžœλ“œ 배치 쑰회
Map<Long, Brand> brandMap = brandService.getBrands(brandIds).stream()
.collect(Collectors.toMap(Brand::getId, brand -> brand));

// λž­ν‚Ή ν•­λͺ© 생성
List<RankingItem> rankingItems = new ArrayList<>();
for (com.loopers.domain.rank.ProductRank rank : pagedRanks) {
Long productId = rank.getProductId();
Product product = productMap.get(productId);

if (product == null) {
log.warn("λž­ν‚Ήμ— ν¬ν•¨λœ μƒν’ˆμ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€: productId={}", productId);
continue;
}

Brand brand = brandMap.get(product.getBrandId());
if (brand == null) {
log.warn("μƒν’ˆμ˜ λΈŒλžœλ“œλ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€: productId={}, brandId={}",
productId, product.getBrandId());
continue;
}

ProductDetail productDetail = ProductDetail.from(
product,
brand.getName(),
rank.getLikeCount()
);

// μ’…ν•© 점수 계산 (Materialized Viewμ—λŠ” μ €μž₯λ˜μ§€ μ•ŠμœΌλ―€λ‘œ 계산)
double score = calculateScore(rank.getLikeCount(), rank.getSalesCount(), rank.getViewCount());

rankingItems.add(new RankingItem(
rank.getRank().longValue(),
score,
productDetail
));
}

boolean hasNext = end < ranks.size();
return new RankingsResponse(rankingItems, page, size, hasNext);
}

/**
* μ’…ν•© 점수λ₯Ό κ³„μ‚°ν•©λ‹ˆλ‹€.
* <p>
* κ°€μ€‘μΉ˜:
* <ul>
* <li>μ’‹μ•„μš”: 0.3</li>
* <li>νŒλ§€λŸ‰: 0.5</li>
* <li>쑰회수: 0.2</li>
* </ul>
* </p>
*
* @param likeCount μ’‹μ•„μš” 수
* @param salesCount νŒλ§€λŸ‰
* @param viewCount 쑰회 수
* @return μ’…ν•© 점수
*/
private double calculateScore(Long likeCount, Long salesCount, Long viewCount) {
return (likeCount != null ? likeCount : 0L) * 0.3
+ (salesCount != null ? salesCount : 0L) * 0.5
+ (viewCount != null ? viewCount : 0L) * 0.2;
}

/**
* κΈ°κ°„ νƒ€μž… μ—΄κ±°ν˜•.
*/
public enum PeriodType {
DAILY, // 일간
WEEKLY, // μ£Όκ°„
MONTHLY // μ›”κ°„
}

/**
* λž­ν‚Ή 쑰회 κ²°κ³Ό.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package com.loopers.domain.rank;

import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.time.LocalDate;
import java.time.LocalDateTime;

/**
* μƒν’ˆ λž­ν‚Ή Materialized View μ—”ν‹°ν‹°.
* <p>
* μ£Όκ°„/μ›”κ°„ TOP 100 λž­ν‚Ήμ„ μ €μž₯ν•˜λŠ” 쑰회 μ „μš© ν…Œμ΄λΈ”μž…λ‹ˆλ‹€.
* </p>
* <p>
* <b>Materialized View 섀계:</b>
* <ul>
* <li>μ£Όκ°„ λž­ν‚Ή: `mv_product_rank_weekly` (period_type = WEEKLY)</li>
* <li>μ›”κ°„ λž­ν‚Ή: `mv_product_rank_monthly` (period_type = MONTHLY)</li>
* <li>TOP 100만 μ €μž₯ν•˜μ—¬ 쑰회 μ„±λŠ₯ μ΅œμ ν™”</li>
* </ul>
* </p>
* <p>
* <b>인덱슀 μ „λž΅:</b>
* <ul>
* <li>볡합 인덱슀: (period_type, period_start_date, rank) - 기간별 λž­ν‚Ή 쑰회 μ΅œμ ν™”</li>
* <li>볡합 인덱슀: (period_type, period_start_date, product_id) - νŠΉμ • μƒν’ˆ λž­ν‚Ή 쑰회 μ΅œμ ν™”</li>
* </ul>
* </p>
*
* @author Loopers
* @version 1.0
*/
@Entity
@Table(
name = "mv_product_rank",
indexes = {
@Index(name = "idx_period_type_start_date_rank", columnList = "period_type, period_start_date, rank"),
@Index(name = "idx_period_type_start_date_product_id", columnList = "period_type, period_start_date, product_id")
}
)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class ProductRank {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
private Long id;

/**
* κΈ°κ°„ νƒ€μž… (WEEKLY: μ£Όκ°„, MONTHLY: μ›”κ°„)
*/
@Enumerated(EnumType.STRING)
@Column(name = "period_type", nullable = false, length = 20)
private PeriodType periodType;

/**
* κΈ°κ°„ μ‹œμž‘μΌ
* <ul>
* <li>μ£Όκ°„: ν•΄λ‹Ή 주의 μ›”μš”μΌ (ISO 8601 κΈ°μ€€)</li>
* <li>μ›”κ°„: ν•΄λ‹Ή μ›”μ˜ 1일</li>
* </ul>
*/
@Column(name = "period_start_date", nullable = false)
private LocalDate periodStartDate;

/**
* μƒν’ˆ ID
*/
@Column(name = "product_id", nullable = false)
private Long productId;

/**
* λž­ν‚Ή (1-100)
*/
@Column(name = "rank", nullable = false)
private Integer rank;

/**
* μ’‹μ•„μš” 수
*/
@Column(name = "like_count", nullable = false)
private Long likeCount;

/**
* νŒλ§€λŸ‰
*/
@Column(name = "sales_count", nullable = false)
private Long salesCount;

/**
* 쑰회 수
*/
@Column(name = "view_count", nullable = false)
private Long viewCount;

/**
* 생성 μ‹œκ°
*/
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;

/**
* μˆ˜μ • μ‹œκ°
*/
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;

/**
* κΈ°κ°„ νƒ€μž… μ—΄κ±°ν˜•.
*/
public enum PeriodType {
WEEKLY, // μ£Όκ°„
MONTHLY // μ›”κ°„
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.loopers.domain.rank;

import java.time.LocalDate;
import java.util.List;
import java.util.Optional;

/**
* ProductRank 도메인 Repository μΈν„°νŽ˜μ΄μŠ€.
* <p>
* Materialized View에 μ €μž₯된 μƒν’ˆ λž­ν‚Ή 데이터λ₯Ό μ‘°νšŒν•©λ‹ˆλ‹€.
* </p>
*/
public interface ProductRankRepository {

/**
* νŠΉμ • κΈ°κ°„μ˜ λž­ν‚Ή 데이터λ₯Ό μ‘°νšŒν•©λ‹ˆλ‹€.
*
* @param periodType κΈ°κ°„ νƒ€μž…
* @param periodStartDate κΈ°κ°„ μ‹œμž‘μΌ
* @param limit μ‘°νšŒν•  λž­ν‚Ή 수 (κΈ°λ³Έ: 100)
* @return λž­ν‚Ή 리슀트 (rank μ˜€λ¦„μ°¨μˆœ)
*/
List<ProductRank> findByPeriod(ProductRank.PeriodType periodType, LocalDate periodStartDate, int limit);

/**
* νŠΉμ • κΈ°κ°„μ˜ νŠΉμ • μƒν’ˆ λž­ν‚Ήμ„ μ‘°νšŒν•©λ‹ˆλ‹€.
*
* @param periodType κΈ°κ°„ νƒ€μž…
* @param periodStartDate κΈ°κ°„ μ‹œμž‘μΌ
* @param productId μƒν’ˆ ID
* @return λž­ν‚Ή 정보 (μ—†μœΌλ©΄ Optional.empty())
*/
Optional<ProductRank> findByPeriodAndProductId(
ProductRank.PeriodType periodType,
LocalDate periodStartDate,
Long productId
);
}

Loading