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
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.loopers.application.brand.BrandService;
import com.loopers.application.product.ProductCacheService;
import com.loopers.application.product.ProductService;
import com.loopers.application.ranking.RankingService;
import com.loopers.domain.brand.Brand;
import com.loopers.domain.product.Product;
import com.loopers.domain.product.ProductDetail;
Expand All @@ -14,6 +15,7 @@
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
Expand All @@ -34,6 +36,7 @@ public class CatalogFacade {
private final ProductService productService;
private final ProductCacheService productCacheService;
private final ProductEventPublisher productEventPublisher;
private final RankingService rankingService;

/**
* ์ƒํ’ˆ ๋ชฉ๋ก์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.
Expand Down Expand Up @@ -90,7 +93,7 @@ public ProductInfoList getProducts(Long brandId, String sort, int page, int size
}
// โœ… Product.likeCount ํ•„๋“œ ์‚ฌ์šฉ (๋น„๋™๊ธฐ ์ง‘๊ณ„๋œ ๊ฐ’)
ProductDetail productDetail = ProductDetail.from(product, brand.getName(), product.getLikeCount());
return new ProductInfo(productDetail);
return ProductInfo.withoutRank(productDetail);
})
.toList();

Expand All @@ -108,10 +111,11 @@ public ProductInfoList getProducts(Long brandId, String sort, int page, int size
* <p>
* Redis ์บ์‹œ๋ฅผ ๋จผ์ € ํ™•์ธํ•˜๊ณ , ์บ์‹œ์— ์—†์œผ๋ฉด DB์—์„œ ์กฐํšŒํ•œ ํ›„ ์บ์‹œ์— ์ €์žฅํ•ฉ๋‹ˆ๋‹ค.
* ์ƒํ’ˆ ์กฐํšŒ ์‹œ ProductViewed ์ด๋ฒคํŠธ๋ฅผ ๋ฐœํ–‰ํ•˜์—ฌ ๋ฉ”ํŠธ๋ฆญ ์ง‘๊ณ„์— ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.
* ๋žญํ‚น ์ •๋ณด๋„ ํ•จ๊ป˜ ์กฐํšŒํ•˜์—ฌ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค.
* </p>
*
* @param productId ์ƒํ’ˆ ID
* @return ์ƒํ’ˆ ์ •๋ณด์™€ ์ข‹์•„์š” ์ˆ˜
* @return ์ƒํ’ˆ ์ •๋ณด์™€ ์ข‹์•„์š” ์ˆ˜, ๋žญํ‚น ์ˆœ์œ„
* @throws CoreException ์ƒํ’ˆ์„ ์ฐพ์„ ์ˆ˜ ์—†๋Š” ๊ฒฝ์šฐ
*/
@Transactional(readOnly = true)
Expand All @@ -121,7 +125,11 @@ public ProductInfo getProduct(Long productId) {
if (cachedResult != null) {
// ์บ์‹œ ํžˆํŠธ ์‹œ์—๋„ ์กฐํšŒ ์ˆ˜ ์ง‘๊ณ„๋ฅผ ์œ„ํ•ด ์ด๋ฒคํŠธ ๋ฐœํ–‰
productEventPublisher.publish(ProductEvent.ProductViewed.from(productId));
return cachedResult;

// ๋žญํ‚น ์ •๋ณด ์กฐํšŒ (์บ์‹œ๋œ ๊ฒฐ๊ณผ์— ๋žญํ‚น ์ •๋ณด ์ถ”๊ฐ€)
LocalDate today = LocalDate.now();
Long rank = rankingService.getProductRank(productId, today);
return ProductInfo.withRank(cachedResult.productDetail(), rank);
}

// ์บ์‹œ์— ์—†์œผ๋ฉด DB์—์„œ ์กฐํšŒ
Expand All @@ -136,16 +144,19 @@ public ProductInfo getProduct(Long productId) {
// ProductDetail ์ƒ์„ฑ (Aggregate ๊ฒฝ๊ณ„ ์ค€์ˆ˜: Brand ์—”ํ‹ฐํ‹ฐ ๋Œ€์‹  brandName๋งŒ ์ „๋‹ฌ)
ProductDetail productDetail = ProductDetail.from(product, brand.getName(), likesCount);

ProductInfo result = new ProductInfo(productDetail);
// ๋žญํ‚น ์ •๋ณด ์กฐํšŒ
LocalDate today = LocalDate.now();
Long rank = rankingService.getProductRank(productId, today);

// ์บ์‹œ์— ์ €์žฅ
productCacheService.cacheProduct(productId, result);
// ์บ์‹œ์— ์ €์žฅ (๋žญํ‚น ์ •๋ณด๋Š” ์ œ์™ธํ•˜๊ณ  ์ €์žฅ - ๋žญํ‚น์€ ์‹ค์‹œ๊ฐ„์œผ๋กœ ์กฐํšŒ)
productCacheService.cacheProduct(productId, ProductInfo.withoutRank(productDetail));

// โœ… ์ƒํ’ˆ ์กฐํšŒ ์ด๋ฒคํŠธ ๋ฐœํ–‰ (๋ฉ”ํŠธ๋ฆญ ์ง‘๊ณ„์šฉ)
productEventPublisher.publish(ProductEvent.ProductViewed.from(productId));

// ๋กœ์ปฌ ์บ์‹œ์˜ ์ข‹์•„์š” ์ˆ˜ ๋ธํƒ€ ์ ์šฉ (DB ์กฐํšŒ ๊ฒฐ๊ณผ์—๋„ ๋ธํƒ€ ๋ฐ˜์˜)
return productCacheService.applyLikeCountDelta(result);
ProductInfo deltaApplied = productCacheService.applyLikeCountDelta(ProductInfo.withoutRank(productDetail));
return ProductInfo.withRank(deltaApplied.productDetail(), rank);
}

}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,28 @@
* ์ƒํ’ˆ ์ƒ์„ธ ์ •๋ณด๋ฅผ ๋‹ด๋Š” ๋ ˆ์ฝ”๋“œ.
*
* @param productDetail ์ƒํ’ˆ ์ƒ์„ธ ์ •๋ณด (Product + Brand + ์ข‹์•„์š” ์ˆ˜)
* @param rank ๋žญํ‚น ์ˆœ์œ„ (1๋ถ€ํ„ฐ ์‹œ์ž‘, ๋žญํ‚น์— ์—†์œผ๋ฉด null)
*/
public record ProductInfo(ProductDetail productDetail) {
public record ProductInfo(ProductDetail productDetail, Long rank) {
/**
* ๋žญํ‚น ์ •๋ณด ์—†์ด ProductInfo๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.
*
* @param productDetail ์ƒํ’ˆ ์ƒ์„ธ ์ •๋ณด
* @return ProductInfo (rank๋Š” null)
*/
public static ProductInfo withoutRank(ProductDetail productDetail) {
return new ProductInfo(productDetail, null);
}

/**
* ๋žญํ‚น ์ •๋ณด์™€ ํ•จ๊ป˜ ProductInfo๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.
*
* @param productDetail ์ƒํ’ˆ ์ƒ์„ธ ์ •๋ณด
* @param rank ๋žญํ‚น ์ˆœ์œ„ (1๋ถ€ํ„ฐ ์‹œ์ž‘, ๋žญํ‚น์— ์—†์œผ๋ฉด null)
* @return ProductInfo
*/
public static ProductInfo withRank(ProductDetail productDetail, Long rank) {
return new ProductInfo(productDetail, rank);
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ public ProductInfo applyLikeCountDelta(ProductInfo productInfo) {
updatedLikesCount
);

return new ProductInfo(updatedDetail);
return ProductInfo.withoutRank(updatedDetail);
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.loopers.application.ranking;

import org.springframework.stereotype.Component;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

/**
* ๋žญํ‚น ํ‚ค ์ƒ์„ฑ ์œ ํ‹ธ๋ฆฌํ‹ฐ.
* <p>
* Redis ZSET ๋žญํ‚น ํ‚ค๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.
* </p>
*
* @author Loopers
* @version 1.0
*/
@Component
public class RankingKeyGenerator {
private static final String DAILY_KEY_PREFIX = "ranking:all:";
private static final String HOURLY_KEY_PREFIX = "ranking:hourly:";
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd");
private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHH");

/**
* ์ผ๊ฐ„ ๋žญํ‚น ํ‚ค๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.
* <p>
* ์˜ˆ: ranking:all:20241215
* </p>
*
* @param date ๋‚ ์งœ
* @return ์ผ๊ฐ„ ๋žญํ‚น ํ‚ค
*/
public String generateDailyKey(LocalDate date) {
String dateStr = date.format(DATE_FORMATTER);
return DAILY_KEY_PREFIX + dateStr;
}

/**
* ์‹œ๊ฐ„ ๋‹จ์œ„ ๋žญํ‚น ํ‚ค๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.
* <p>
* ์˜ˆ: ranking:hourly:2024121514
* </p>
*
* @param dateTime ๋‚ ์งœ ๋ฐ ์‹œ๊ฐ„
* @return ์‹œ๊ฐ„ ๋‹จ์œ„ ๋žญํ‚น ํ‚ค
*/
public String generateHourlyKey(LocalDateTime dateTime) {
String dateTimeStr = dateTime.format(DATE_TIME_FORMATTER);
return HOURLY_KEY_PREFIX + dateTimeStr;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
package com.loopers.application.ranking;

import com.loopers.application.brand.BrandService;
import com.loopers.application.product.ProductService;
import com.loopers.domain.brand.Brand;
import com.loopers.domain.product.Product;
import com.loopers.domain.product.ProductDetail;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import com.loopers.zset.ZSetEntry;
import com.loopers.zset.RedisZSetTemplate;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

/**
* ๋žญํ‚น ์กฐํšŒ ์„œ๋น„์Šค.
* <p>
* Redis ZSET์—์„œ ๋žญํ‚น์„ ์กฐํšŒํ•˜๊ณ  ์ƒํ’ˆ ์ •๋ณด๋ฅผ Aggregationํ•˜์—ฌ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค.
* </p>
* <p>
* <b>์„ค๊ณ„ ์›์น™:</b>
* <ul>
* <li>Application ์œ ์ฆˆ์ผ€์ด์Šค: Ranking์€ ๋„๋ฉ”์ธ์ด ์•„๋‹Œ ํŒŒ์ƒ View๋กœ ์ทจ๊ธ‰</li>
* <li>์ƒํ’ˆ ์ •๋ณด Aggregation: ์ƒํ’ˆ ID๋งŒ์ด ์•„๋‹Œ ์ƒํ’ˆ ์ •๋ณด ํฌํ•จ</li>
* <li>๋ฐฐ์น˜ ์กฐํšŒ: N+1 ์ฟผ๋ฆฌ ๋ฌธ์ œ ๋ฐฉ์ง€</li>
* </ul>
* </p>
*
* @author Loopers
* @version 1.0
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class RankingService {
private final RedisZSetTemplate zSetTemplate;
private final RankingKeyGenerator keyGenerator;
private final ProductService productService;
private final BrandService brandService;

/**
* ๋žญํ‚น์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค (ํŽ˜์ด์ง•).
* <p>
* ZSET์—์„œ ์ƒ์œ„ N๊ฐœ๋ฅผ ์กฐํšŒํ•˜๊ณ , ์ƒํ’ˆ ์ •๋ณด๋ฅผ Aggregationํ•˜์—ฌ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค.
* </p>
*
* @param date ๋‚ ์งœ (yyyyMMdd ํ˜•์‹์˜ ๋ฌธ์ž์—ด ๋˜๋Š” LocalDate)
* @param page ํŽ˜์ด์ง€ ๋ฒˆํ˜ธ (0๋ถ€ํ„ฐ ์‹œ์ž‘)
* @param size ํŽ˜์ด์ง€๋‹น ํ•ญ๋ชฉ ์ˆ˜
* @return ๋žญํ‚น ์กฐํšŒ ๊ฒฐ๊ณผ
*/
@Transactional(readOnly = true)
public RankingsResponse getRankings(LocalDate date, int page, int size) {
String key = keyGenerator.generateDailyKey(date);
long start = (long) page * size;
long end = start + size - 1;

// ZSET์—์„œ Top N ์กฐํšŒ
List<ZSetEntry> entries = zSetTemplate.getTopRankings(key, start, end);

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

// ์ƒํ’ˆ ID ์ถ”์ถœ
List<Long> productIds = entries.stream()
.map(entry -> Long.parseLong(entry.member()))
.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 (int i = 0; i < entries.size(); i++) {
ZSetEntry entry = entries.get(i);
Long productId = Long.parseLong(entry.member());
Long rank = start + i + 1; // 1-based ์ˆœ์œ„

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(),
product.getLikeCount()
);

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

// ์ „์ฒด ๋žญํ‚น ๊ฐœ์ˆ˜ ์กฐํšŒ (ZSET ํฌ๊ธฐ)
Long totalSize = zSetTemplate.getSize(key);
boolean hasNext = (start + size) < totalSize;

return new RankingsResponse(rankingItems, page, size, hasNext);
}

/**
* ํŠน์ • ์ƒํ’ˆ์˜ ์ˆœ์œ„๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.
* <p>
* ์ƒํ’ˆ์ด ๋žญํ‚น์— ์—†์œผ๋ฉด null์„ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค.
* </p>
*
* @param productId ์ƒํ’ˆ ID
* @param date ๋‚ ์งœ
* @return ์ˆœ์œ„ (1๋ถ€ํ„ฐ ์‹œ์ž‘, ์—†์œผ๋ฉด null)
*/
@Transactional(readOnly = true)
public Long getProductRank(Long productId, LocalDate date) {
String key = keyGenerator.generateDailyKey(date);
Long rank = zSetTemplate.getRank(key, String.valueOf(productId));

if (rank == null) {
return null;
}

// 0-based โ†’ 1-based ๋ณ€ํ™˜
return rank + 1;
}

/**
* ๋žญํ‚น ์กฐํšŒ ๊ฒฐ๊ณผ.
*
* @param items ๋žญํ‚น ํ•ญ๋ชฉ ๋ชฉ๋ก
* @param page ํ˜„์žฌ ํŽ˜์ด์ง€ ๋ฒˆํ˜ธ
* @param size ํŽ˜์ด์ง€๋‹น ํ•ญ๋ชฉ ์ˆ˜
* @param hasNext ๋‹ค์Œ ํŽ˜์ด์ง€ ์กด์žฌ ์—ฌ๋ถ€
*/
public record RankingsResponse(
List<RankingItem> items,
int page,
int size,
boolean hasNext
) {
/**
* ๋นˆ ๋žญํ‚น ์กฐํšŒ ๊ฒฐ๊ณผ๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.
*/
public static RankingsResponse empty(int page, int size) {
return new RankingsResponse(List.of(), page, size, false);
}
}

/**
* ๋žญํ‚น ํ•ญ๋ชฉ (์ˆœ์œ„, ์ ์ˆ˜, ์ƒํ’ˆ ์ •๋ณด).
*
* @param rank ์ˆœ์œ„ (1๋ถ€ํ„ฐ ์‹œ์ž‘)
* @param score ์ ์ˆ˜
* @param productDetail ์ƒํ’ˆ ์ƒ์„ธ ์ •๋ณด
*/
public record RankingItem(
Long rank,
Double score,
ProductDetail productDetail
) {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,16 @@ public class ProductV1Dto {
* @param stock ์ƒํ’ˆ ์žฌ๊ณ 
* @param brandId ๋ธŒ๋žœ๋“œ ID
* @param likesCount ์ข‹์•„์š” ์ˆ˜
* @param rank ๋žญํ‚น ์ˆœ์œ„ (1๋ถ€ํ„ฐ ์‹œ์ž‘, ๋žญํ‚น์— ์—†์œผ๋ฉด null)
*/
public record ProductResponse(
Long productId,
String name,
Integer price,
Integer stock,
Long brandId,
Long likesCount
Long likesCount,
Long rank
) {
/**
* ProductInfo๋กœ๋ถ€ํ„ฐ ProductResponse๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.
Expand All @@ -44,7 +46,8 @@ public static ProductResponse from(ProductInfo productInfo) {
detail.getPrice(),
detail.getStock(),
detail.getBrandId(),
detail.getLikesCount()
detail.getLikesCount(),
productInfo.rank()
);
}
}
Expand Down
Loading