Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
));
}
Comment thread
minor7295 marked this conversation as resolved.
Outdated

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>
Comment thread
minor7295 marked this conversation as resolved.
* <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