Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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,151 @@ 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<>();
long currentRank = start + 1; // 1-based ์ˆœ์œ„ (ํŽ˜์ด์ง€ ์‹œ์ž‘ ์ˆœ์œ„)

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(
currentRank++, // ์—ฐ์† ์ˆœ์œ„ ๋ถ€์—ฌ
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,120 @@
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` (๋‹จ์ผ ํ…Œ์ด๋ธ”)</li>
* <li>์ฃผ๊ฐ„ ๋žญํ‚น: period_type = WEEKLY</li>
* <li>์›”๊ฐ„ ๋žญํ‚น: 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