diff --git a/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogFacade.java
index c8eed8f67..4ce66ca53 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogFacade.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogFacade.java
@@ -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;
@@ -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;
@@ -34,6 +36,7 @@ public class CatalogFacade {
private final ProductService productService;
private final ProductCacheService productCacheService;
private final ProductEventPublisher productEventPublisher;
+ private final RankingService rankingService;
/**
* 상품 목록을 조회합니다.
@@ -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();
@@ -108,10 +111,11 @@ public ProductInfoList getProducts(Long brandId, String sort, int page, int size
*
* Redis 캐시를 먼저 확인하고, 캐시에 없으면 DB에서 조회한 후 캐시에 저장합니다.
* 상품 조회 시 ProductViewed 이벤트를 발행하여 메트릭 집계에 사용합니다.
+ * 랭킹 정보도 함께 조회하여 반환합니다.
*
*
* @param productId 상품 ID
- * @return 상품 정보와 좋아요 수
+ * @return 상품 정보와 좋아요 수, 랭킹 순위
* @throws CoreException 상품을 찾을 수 없는 경우
*/
@Transactional(readOnly = true)
@@ -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에서 조회
@@ -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);
}
}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/catalog/ProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/catalog/ProductInfo.java
index 6a22a5f21..ec634bc0a 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/catalog/ProductInfo.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/catalog/ProductInfo.java
@@ -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);
+ }
}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheService.java
index f2e6b5bfe..32c4f915b 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheService.java
@@ -269,7 +269,7 @@ public ProductInfo applyLikeCountDelta(ProductInfo productInfo) {
updatedLikesCount
);
- return new ProductInfo(updatedDetail);
+ return ProductInfo.withoutRank(updatedDetail);
}
}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingKeyGenerator.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingKeyGenerator.java
new file mode 100644
index 000000000..f87a52422
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingKeyGenerator.java
@@ -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;
+
+/**
+ * 랭킹 키 생성 유틸리티.
+ *
+ * Redis ZSET 랭킹 키를 생성합니다.
+ *
+ *
+ * @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");
+
+ /**
+ * 일간 랭킹 키를 생성합니다.
+ *
+ * 예: ranking:all:20241215
+ *
+ *
+ * @param date 날짜
+ * @return 일간 랭킹 키
+ */
+ public String generateDailyKey(LocalDate date) {
+ String dateStr = date.format(DATE_FORMATTER);
+ return DAILY_KEY_PREFIX + dateStr;
+ }
+
+ /**
+ * 시간 단위 랭킹 키를 생성합니다.
+ *
+ * 예: ranking:hourly:2024121514
+ *
+ *
+ * @param dateTime 날짜 및 시간
+ * @return 시간 단위 랭킹 키
+ */
+ public String generateHourlyKey(LocalDateTime dateTime) {
+ String dateTimeStr = dateTime.format(DATE_TIME_FORMATTER);
+ return HOURLY_KEY_PREFIX + dateTimeStr;
+ }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java
new file mode 100644
index 000000000..df6305b83
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java
@@ -0,0 +1,342 @@
+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.zset.ZSetEntry;
+import com.loopers.zset.RedisZSetTemplate;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.dao.DataAccessException;
+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.Optional;
+import java.util.stream.Collectors;
+
+/**
+ * 랭킹 조회 서비스.
+ *
+ * Redis ZSET에서 랭킹을 조회하고 상품 정보를 Aggregation하여 제공합니다.
+ *
+ *
+ * 설계 원칙:
+ *
+ * - Application 유즈케이스: Ranking은 도메인이 아닌 파생 View로 취급
+ * - 상품 정보 Aggregation: 상품 ID만이 아닌 상품 정보 포함
+ * - 배치 조회: N+1 쿼리 문제 방지
+ *
+ *
+ *
+ * @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;
+ private final RankingSnapshotService rankingSnapshotService;
+
+ /**
+ * 랭킹을 조회합니다 (페이징).
+ *
+ * ZSET에서 상위 N개를 조회하고, 상품 정보를 Aggregation하여 반환합니다.
+ *
+ *
+ * Graceful Degradation:
+ *
+ * - Redis 장애 시 스냅샷으로 Fallback
+ * - 스냅샷도 없으면 기본 랭킹(좋아요순) 제공 (단순 조회, 계산 아님)
+ *
+ *
+ *
+ * @param date 날짜 (yyyyMMdd 형식의 문자열 또는 LocalDate)
+ * @param page 페이지 번호 (0부터 시작)
+ * @param size 페이지당 항목 수
+ * @return 랭킹 조회 결과
+ */
+ @Transactional(readOnly = true)
+ public RankingsResponse getRankings(LocalDate date, int page, int size) {
+ try {
+ return getRankingsFromRedis(date, page, size);
+ } catch (DataAccessException e) {
+ log.warn("Redis 랭킹 조회 실패, 스냅샷으로 Fallback: date={}, error={}",
+ date, e.getMessage());
+ // 스냅샷으로 Fallback 시도
+ Optional snapshot = rankingSnapshotService.getSnapshot(date);
+ if (snapshot.isPresent()) {
+ log.info("스냅샷으로 랭킹 제공: date={}, itemCount={}", date, snapshot.get().items().size());
+ return snapshot.get();
+ }
+
+ // 전날 스냅샷 시도
+ Optional yesterdaySnapshot = rankingSnapshotService.getSnapshot(date.minusDays(1));
+ if (yesterdaySnapshot.isPresent()) {
+ log.info("전날 스냅샷으로 랭킹 제공: date={}, itemCount={}", date, yesterdaySnapshot.get().items().size());
+ return yesterdaySnapshot.get();
+ }
+
+ // 최종 Fallback: 기본 랭킹 (단순 조회, 계산 아님)
+ log.warn("스냅샷도 없음, 기본 랭킹(좋아요순)으로 Fallback: date={}", date);
+ return getDefaultRankings(page, size);
+ } catch (Exception e) {
+ log.error("랭킹 조회 중 예상치 못한 오류 발생, 기본 랭킹으로 Fallback: date={}", date, e);
+ return getDefaultRankings(page, size);
+ }
+ }
+
+ /**
+ * Redis에서 랭킹을 조회합니다.
+ *
+ * 스케줄러에서 스냅샷 저장 시 호출하기 위해 public으로 제공합니다.
+ *
+ *
+ * @param date 날짜
+ * @param page 페이지 번호
+ * @param size 페이지당 항목 수
+ * @return 랭킹 조회 결과
+ * @throws DataAccessException Redis 접근 실패 시
+ */
+ public RankingsResponse getRankingsFromRedis(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 entries = zSetTemplate.getTopRankings(key, start, end);
+
+ if (entries.isEmpty()) {
+ return RankingsResponse.empty(page, size);
+ }
+
+ // 상품 ID 추출
+ List productIds = entries.stream()
+ .map(entry -> Long.parseLong(entry.member()))
+ .toList();
+
+ // 상품 정보 배치 조회
+ List products = productService.getProducts(productIds);
+
+ // 상품 ID → Product Map 생성
+ Map productMap = products.stream()
+ .collect(Collectors.toMap(Product::getId, product -> product));
+
+ // 브랜드 ID 수집
+ List brandIds = products.stream()
+ .map(Product::getBrandId)
+ .distinct()
+ .toList();
+
+ // 브랜드 배치 조회
+ Map brandMap = brandService.getBrands(brandIds).stream()
+ .collect(Collectors.toMap(Brand::getId, brand -> brand));
+
+ // 랭킹 항목 생성 (순위, 점수, 상품 정보 포함)
+ List 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);
+ }
+
+ /**
+ * 기본 랭킹(좋아요순)을 제공합니다.
+ *
+ * 최종 Fallback으로 사용됩니다. 랭킹을 새로 계산하는 것이 아니라
+ * 이미 집계된 좋아요 수를 단순 조회하는 것이므로 DB 부하가 크지 않습니다.
+ *
+ *
+ * @param page 페이지 번호 (0부터 시작)
+ * @param size 페이지당 항목 수
+ * @return 랭킹 조회 결과
+ */
+ private RankingsResponse getDefaultRankings(int page, int size) {
+ // 좋아요순으로 상품 조회
+ List products = productService.findAll(null, "likes_desc", page, size);
+ long totalCount = productService.countAll(null);
+
+ if (products.isEmpty()) {
+ return RankingsResponse.empty(page, size);
+ }
+
+ // 브랜드 ID 수집
+ List brandIds = products.stream()
+ .map(Product::getBrandId)
+ .distinct()
+ .toList();
+
+ // 브랜드 배치 조회
+ Map brandMap = brandService.getBrands(brandIds).stream()
+ .collect(Collectors.toMap(Brand::getId, brand -> brand));
+
+ // 랭킹 항목 생성 (좋아요 수를 점수로 사용)
+ List rankingItems = new ArrayList<>();
+ long start = (long) page * size;
+ for (int i = 0; i < products.size(); i++) {
+ Product product = products.get(i);
+ Long rank = start + i + 1; // 1-based 순위
+
+ Brand brand = brandMap.get(product.getBrandId());
+ if (brand == null) {
+ log.warn("상품의 브랜드를 찾을 수 없습니다: productId={}, brandId={}",
+ product.getId(), product.getBrandId());
+ continue;
+ }
+
+ ProductDetail productDetail = ProductDetail.from(
+ product,
+ brand.getName(),
+ product.getLikeCount()
+ );
+
+ // 좋아요 수를 점수로 사용
+ double score = product.getLikeCount() != null ? product.getLikeCount().doubleValue() : 0.0;
+ rankingItems.add(new RankingItem(
+ rank,
+ score,
+ productDetail
+ ));
+ }
+
+ boolean hasNext = (start + size) < totalCount;
+ return new RankingsResponse(rankingItems, page, size, hasNext);
+ }
+
+ /**
+ * 특정 상품의 순위를 조회합니다.
+ *
+ * 상품이 랭킹에 없으면 null을 반환합니다.
+ *
+ *
+ * Graceful Degradation:
+ *
+ * - Redis 장애 시 전날 랭킹으로 Fallback
+ * - 전날 랭킹도 없으면 null 반환 (기본 랭킹에서는 순위 계산 불가)
+ *
+ *
+ *
+ * @param productId 상품 ID
+ * @param date 날짜
+ * @return 순위 (1부터 시작, 없으면 null)
+ */
+ @Transactional(readOnly = true)
+ public Long getProductRank(Long productId, LocalDate date) {
+ try {
+ return getProductRankFromRedis(productId, date);
+ } catch (DataAccessException e) {
+ log.warn("Redis 상품 순위 조회 실패, 전날 랭킹으로 Fallback: productId={}, date={}, error={}",
+ productId, date, e.getMessage());
+ // 전날 랭킹으로 Fallback 시도
+ try {
+ LocalDate yesterday = date.minusDays(1);
+ return getProductRankFromRedis(productId, yesterday);
+ } catch (DataAccessException fallbackException) {
+ log.warn("전날 랭킹 조회도 실패: productId={}, date={}, error={}",
+ productId, date, fallbackException.getMessage());
+ // 기본 랭킹에서는 순위 계산이 어려우므로 null 반환
+ return null;
+ }
+ } catch (Exception e) {
+ log.error("상품 순위 조회 중 예상치 못한 오류 발생: productId={}, date={}", productId, date, e);
+ return null;
+ }
+ }
+
+ /**
+ * Redis에서 상품 순위를 조회합니다.
+ *
+ * @param productId 상품 ID
+ * @param date 날짜
+ * @return 순위 (1부터 시작, 없으면 null)
+ * @throws DataAccessException Redis 접근 실패 시
+ */
+ private Long getProductRankFromRedis(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 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
+ ) {
+ }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingSnapshotService.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingSnapshotService.java
new file mode 100644
index 000000000..c9bd2efab
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingSnapshotService.java
@@ -0,0 +1,103 @@
+package com.loopers.application.ranking;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * 랭킹 스냅샷 서비스.
+ *
+ * Redis 장애 시 Fallback으로 사용하기 위한 랭킹 데이터 스냅샷을 인메모리에 저장합니다.
+ *
+ *
+ * 설계 원칙:
+ *
+ * - 인메모리 캐시: 구현이 간단하고 성능이 우수함
+ * - 메모리 관리: 최근 7일치만 보관하여 메모리 사용량 제한
+ * - 스냅샷 기반 Fallback: DB 실시간 재계산 대신 스냅샷 서빙으로 DB 부하 방지
+ *
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Slf4j
+@Service
+public class RankingSnapshotService {
+
+ private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd");
+ private static final int MAX_SNAPSHOTS = 7; // 최근 7일치만 보관
+
+ private final Map snapshotCache = new ConcurrentHashMap<>();
+
+ /**
+ * 랭킹 스냅샷을 저장합니다.
+ *
+ * @param date 날짜
+ * @param rankings 랭킹 조회 결과
+ */
+ public void saveSnapshot(LocalDate date, RankingService.RankingsResponse rankings) {
+ String key = date.format(DATE_FORMATTER);
+ snapshotCache.put(key, rankings);
+ log.debug("랭킹 스냅샷 저장: date={}, key={}, itemCount={}", date, key, rankings.items().size());
+
+ // 오래된 스냅샷 정리 (메모리 관리)
+ cleanupOldSnapshots();
+ }
+
+ /**
+ * 랭킹 스냅샷을 조회합니다.
+ *
+ * @param date 날짜
+ * @return 랭킹 조회 결과 (없으면 empty)
+ */
+ public Optional getSnapshot(LocalDate date) {
+ String key = date.format(DATE_FORMATTER);
+ RankingService.RankingsResponse snapshot = snapshotCache.get(key);
+
+ if (snapshot != null) {
+ log.debug("랭킹 스냅샷 조회 성공: date={}, key={}, itemCount={}", date, key, snapshot.items().size());
+ return Optional.of(snapshot);
+ }
+
+ log.debug("랭킹 스냅샷 없음: date={}, key={}", date, key);
+ return Optional.empty();
+ }
+
+ /**
+ * 오래된 스냅샷을 정리합니다.
+ *
+ * 최근 7일치만 보관하여 메모리 사용량을 제한합니다.
+ *
+ */
+ private void cleanupOldSnapshots() {
+ if (snapshotCache.size() <= MAX_SNAPSHOTS) {
+ return;
+ }
+
+ // 가장 오래된 스냅샷 제거
+ LocalDate today = LocalDate.now(ZoneId.of("UTC"));
+ LocalDate oldestDate = today.minusDays(MAX_SNAPSHOTS);
+
+ snapshotCache.entrySet().removeIf(entry -> {
+ try {
+ LocalDate entryDate = LocalDate.parse(entry.getKey(), DATE_FORMATTER);
+ boolean shouldRemove = entryDate.isBefore(oldestDate);
+ if (shouldRemove) {
+ log.debug("오래된 스냅샷 제거: key={}", entry.getKey());
+ }
+ return shouldRemove;
+ } catch (Exception e) {
+ log.warn("스냅샷 키 파싱 실패, 제거: key={}", entry.getKey(), e);
+ return true; // 파싱 실패한 키는 제거
+ }
+ });
+ }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/scheduler/RankingSnapshotScheduler.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/scheduler/RankingSnapshotScheduler.java
new file mode 100644
index 000000000..3adefd9be
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/scheduler/RankingSnapshotScheduler.java
@@ -0,0 +1,72 @@
+package com.loopers.infrastructure.scheduler;
+
+import com.loopers.application.ranking.RankingService;
+import com.loopers.application.ranking.RankingSnapshotService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDate;
+import java.time.ZoneId;
+
+/**
+ * 랭킹 스냅샷 저장 스케줄러.
+ *
+ * 주기적으로 랭킹 결과를 스냅샷으로 저장하여, Redis 장애 시 Fallback으로 사용할 수 있도록 합니다.
+ *
+ *
+ * 설계 원칙:
+ *
+ * - 스냅샷 기반 Fallback: DB 실시간 재계산 대신 스냅샷 서빙으로 DB 부하 방지
+ * - 주기적 저장: 1시간마다 최신 랭킹을 스냅샷으로 저장
+ * - 에러 처리: 스냅샷 저장 실패 시에도 다음 스케줄에서 재시도
+ *
+ *
+ *
+ * 주기 선택 근거:
+ *
+ * - 비용 대비 효과: 1시간 주기가 리소스 사용량이 1/12로 감소하면서도 사용자 체감 차이는 거의 없음
+ * - 랭킹의 성격: 비즈니스 결정이 아닌 조회용 파생 데이터이므로 1시간 전 데이터도 충분히 유용함
+ * - 운영 관점: 스케줄러 실행 빈도가 낮아 모니터링 부담 감소
+ *
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class RankingSnapshotScheduler {
+
+ private final RankingService rankingService;
+ private final RankingSnapshotService rankingSnapshotService;
+
+ /**
+ * 랭킹 스냅샷을 저장합니다.
+ *
+ * 1시간마다 실행되어 오늘의 랭킹을 스냅샷으로 저장합니다.
+ *
+ */
+ @Scheduled(fixedRate = 3600000) // 1시간마다 (3600000ms = 1시간)
+ public void saveRankingSnapshot() {
+ LocalDate today = LocalDate.now(ZoneId.of("UTC"));
+ try {
+ // 상위 100개 랭킹을 스냅샷으로 저장 (대부분의 사용자가 상위 100개 이내만 조회)
+ // Redis가 정상일 때만 스냅샷 저장 (예외 발생 시 스킵)
+ RankingService.RankingsResponse rankings = rankingService.getRankingsFromRedis(today, 0, 100);
+
+ rankingSnapshotService.saveSnapshot(today, rankings);
+
+ log.debug("랭킹 스냅샷 저장 완료: date={}, itemCount={}", today, rankings.items().size());
+ } catch (org.springframework.dao.DataAccessException e) {
+ log.warn("Redis 장애로 인한 랭킹 스냅샷 저장 실패: date={}, error={}", today, e.getMessage());
+ // Redis 장애 시 스냅샷 저장 스킵 (다음 스케줄에서 재시도)
+ } catch (Exception e) {
+ log.warn("랭킹 스냅샷 저장 실패: date={}", today, e);
+ // 스냅샷 저장 실패는 다음 스케줄에서 재시도
+ }
+ }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/ProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/ProductV1Dto.java
index 3661d9c9e..7df592db6 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/ProductV1Dto.java
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/ProductV1Dto.java
@@ -21,6 +21,7 @@ public class ProductV1Dto {
* @param stock 상품 재고
* @param brandId 브랜드 ID
* @param likesCount 좋아요 수
+ * @param rank 랭킹 순위 (1부터 시작, 랭킹에 없으면 null)
*/
public record ProductResponse(
Long productId,
@@ -28,7 +29,8 @@ public record ProductResponse(
Integer price,
Integer stock,
Long brandId,
- Long likesCount
+ Long likesCount,
+ Long rank
) {
/**
* ProductInfo로부터 ProductResponse를 생성합니다.
@@ -44,7 +46,8 @@ public static ProductResponse from(ProductInfo productInfo) {
detail.getPrice(),
detail.getStock(),
detail.getBrandId(),
- detail.getLikesCount()
+ detail.getLikesCount(),
+ productInfo.rank()
);
}
}
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java
new file mode 100644
index 000000000..ecbae6157
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java
@@ -0,0 +1,89 @@
+package com.loopers.interfaces.api.ranking;
+
+import com.loopers.application.ranking.RankingService;
+import com.loopers.interfaces.api.ApiResponse;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+
+/**
+ * 랭킹 조회 API v1 컨트롤러.
+ *
+ * 랭킹 조회 유즈케이스를 처리합니다.
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@RequiredArgsConstructor
+@RestController
+@RequestMapping("/api/v1/rankings")
+public class RankingV1Controller {
+
+ private final RankingService rankingService;
+ private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd");
+
+ /**
+ * 랭킹을 조회합니다.
+ *
+ * 날짜별 랭킹을 페이징하여 조회합니다.
+ *
+ *
+ * @param date 날짜 (yyyyMMdd 형식, 기본값: 오늘 날짜)
+ * @param page 페이지 번호 (기본값: 0)
+ * @param size 페이지당 항목 수 (기본값: 20)
+ * @return 랭킹 목록을 담은 API 응답
+ */
+ @GetMapping
+ public ApiResponse getRankings(
+ @RequestParam(required = false) String date,
+ @RequestParam(required = false, defaultValue = "0") int page,
+ @RequestParam(required = false, defaultValue = "20") int size
+ ) {
+ // 날짜 파라미터 검증 및 기본값 처리
+ LocalDate targetDate = parseDate(date);
+
+ // 페이징 검증
+ if (page < 0) {
+ page = 0;
+ }
+ if (size < 1) {
+ size = 20;
+ }
+ if (size > 100) {
+ size = 100; // 최대 100개로 제한
+ }
+
+ RankingService.RankingsResponse result = rankingService.getRankings(targetDate, page, size);
+ return ApiResponse.success(RankingV1Dto.RankingsResponse.from(result));
+ }
+
+ /**
+ * 날짜 문자열을 LocalDate로 파싱합니다.
+ *
+ * 날짜가 없거나 파싱 실패 시 오늘 날짜를 반환합니다.
+ *
+ *
+ * @param dateStr 날짜 문자열 (yyyyMMdd 형식)
+ * @return 파싱된 날짜 (실패 시 오늘 날짜)
+ */
+ private LocalDate parseDate(String dateStr) {
+ if (dateStr == null || dateStr.isBlank()) {
+ return LocalDate.now(ZoneId.of("UTC"));
+ }
+
+ try {
+ return LocalDate.parse(dateStr, DATE_FORMATTER);
+ } catch (DateTimeParseException e) {
+ // 파싱 실패 시 오늘 날짜 반환 (UTC 기준)
+ return LocalDate.now(ZoneId.of("UTC"));
+ }
+ }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java
new file mode 100644
index 000000000..45ac64ab0
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java
@@ -0,0 +1,94 @@
+package com.loopers.interfaces.api.ranking;
+
+import com.loopers.application.ranking.RankingService;
+import com.loopers.domain.product.ProductDetail;
+
+import java.util.List;
+
+/**
+ * 랭킹 조회 API v1의 데이터 전송 객체(DTO) 컨테이너.
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+public class RankingV1Dto {
+ /**
+ * 랭킹 항목 응답 데이터.
+ *
+ * @param rank 순위 (1부터 시작)
+ * @param score 점수
+ * @param productId 상품 ID
+ * @param name 상품 이름
+ * @param price 상품 가격
+ * @param stock 상품 재고
+ * @param brandId 브랜드 ID
+ * @param brandName 브랜드 이름
+ * @param likesCount 좋아요 수
+ */
+ public record RankingItemResponse(
+ Long rank,
+ Double score,
+ Long productId,
+ String name,
+ Integer price,
+ Integer stock,
+ Long brandId,
+ String brandName,
+ Long likesCount
+ ) {
+ /**
+ * RankingService.RankingItem으로부터 RankingItemResponse를 생성합니다.
+ *
+ * @param item 랭킹 항목
+ * @return 생성된 응답 객체
+ */
+ public static RankingItemResponse from(RankingService.RankingItem item) {
+ ProductDetail detail = item.productDetail();
+ return new RankingItemResponse(
+ item.rank(),
+ item.score(),
+ detail.getId(),
+ detail.getName(),
+ detail.getPrice(),
+ detail.getStock(),
+ detail.getBrandId(),
+ detail.getBrandName(),
+ detail.getLikesCount()
+ );
+ }
+ }
+
+ /**
+ * 랭킹 목록 응답 데이터.
+ *
+ * @param items 랭킹 항목 목록
+ * @param page 현재 페이지 번호
+ * @param size 페이지당 항목 수
+ * @param hasNext 다음 페이지 존재 여부
+ */
+ public record RankingsResponse(
+ List items,
+ int page,
+ int size,
+ boolean hasNext
+ ) {
+ /**
+ * RankingService.RankingsResponse로부터 RankingsResponse를 생성합니다.
+ *
+ * @param response 랭킹 조회 결과
+ * @return 생성된 응답 객체
+ */
+ public static RankingsResponse from(RankingService.RankingsResponse response) {
+ List items = response.items().stream()
+ .map(RankingItemResponse::from)
+ .toList();
+
+ return new RankingsResponse(
+ items,
+ response.page(),
+ response.size(),
+ response.hasNext()
+ );
+ }
+ }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/application/catalog/CatalogFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/catalog/CatalogFacadeTest.java
new file mode 100644
index 000000000..bb78c71b6
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/application/catalog/CatalogFacadeTest.java
@@ -0,0 +1,230 @@
+package com.loopers.application.catalog;
+
+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;
+import com.loopers.domain.product.ProductEvent;
+import com.loopers.domain.product.ProductEventPublisher;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.lang.reflect.Field;
+import java.time.LocalDate;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.*;
+
+/**
+ * CatalogFacade 테스트.
+ *
+ * 상품 조회 시 랭킹 정보가 포함되는지 검증합니다.
+ * 캐시 히트/미스의 세부 로직은 ProductCacheService 테스트에서 검증합니다.
+ *
+ */
+@ExtendWith(MockitoExtension.class)
+@DisplayName("CatalogFacade 상품 조회 랭킹 정보 포함 테스트")
+class CatalogFacadeTest {
+
+ @Mock
+ private BrandService brandService;
+
+ @Mock
+ private ProductService productService;
+
+ @Mock
+ private ProductCacheService productCacheService;
+
+ @Mock
+ private ProductEventPublisher productEventPublisher;
+
+ @Mock
+ private RankingService rankingService;
+
+ @InjectMocks
+ private CatalogFacade catalogFacade;
+
+ private static final Long PRODUCT_ID = 1L;
+ private static final Long BRAND_ID = 10L;
+ private static final String BRAND_NAME = "테스트 브랜드";
+ private static final String PRODUCT_NAME = "테스트 상품";
+ private static final Integer PRODUCT_PRICE = 10000;
+ private static final Integer PRODUCT_STOCK = 10;
+ private static final Long LIKES_COUNT = 5L;
+
+ /**
+ * Product에 ID를 설정합니다 (리플렉션 사용).
+ */
+ private void setId(Product product, Long id) {
+ try {
+ Field idField = product.getClass().getSuperclass().getDeclaredField("id");
+ idField.setAccessible(true);
+ idField.set(product, id);
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to set Product ID", e);
+ }
+ }
+
+ /**
+ * Brand에 ID를 설정합니다 (리플렉션 사용).
+ */
+ private void setId(Brand brand, Long id) {
+ try {
+ Field idField = brand.getClass().getSuperclass().getDeclaredField("id");
+ idField.setAccessible(true);
+ idField.set(brand, id);
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to set Brand ID", e);
+ }
+ }
+
+ @Test
+ @DisplayName("캐시 히트 시 랭킹 정보가 포함된다")
+ void getProduct_withCacheHit_includesRanking() {
+ // arrange
+ ProductDetail cachedProductDetail = ProductDetail.of(
+ PRODUCT_ID,
+ PRODUCT_NAME,
+ PRODUCT_PRICE,
+ PRODUCT_STOCK,
+ BRAND_ID,
+ BRAND_NAME,
+ LIKES_COUNT
+ );
+ ProductInfo cachedProductInfo = ProductInfo.withoutRank(cachedProductDetail);
+ Long expectedRank = 3L;
+
+ when(productCacheService.getCachedProduct(PRODUCT_ID))
+ .thenReturn(cachedProductInfo);
+ when(rankingService.getProductRank(eq(PRODUCT_ID), any(LocalDate.class)))
+ .thenReturn(expectedRank);
+
+ // act
+ ProductInfo result = catalogFacade.getProduct(PRODUCT_ID);
+
+ // assert
+ assertThat(result.rank()).isEqualTo(expectedRank);
+ verify(rankingService).getProductRank(eq(PRODUCT_ID), any(LocalDate.class));
+ verify(productEventPublisher).publish(any(ProductEvent.ProductViewed.class));
+ verify(productService, never()).getProduct(any());
+ }
+
+ @Test
+ @DisplayName("캐시 히트 시 랭킹에 없는 상품은 null을 반환한다")
+ void getProduct_withCacheHit_noRanking_returnsNull() {
+ // arrange
+ ProductDetail cachedProductDetail = ProductDetail.of(
+ PRODUCT_ID,
+ PRODUCT_NAME,
+ PRODUCT_PRICE,
+ PRODUCT_STOCK,
+ BRAND_ID,
+ BRAND_NAME,
+ LIKES_COUNT
+ );
+ ProductInfo cachedProductInfo = ProductInfo.withoutRank(cachedProductDetail);
+
+ when(productCacheService.getCachedProduct(PRODUCT_ID))
+ .thenReturn(cachedProductInfo);
+ when(rankingService.getProductRank(eq(PRODUCT_ID), any(LocalDate.class)))
+ .thenReturn(null);
+
+ // act
+ ProductInfo result = catalogFacade.getProduct(PRODUCT_ID);
+
+ // assert
+ assertThat(result.rank()).isNull();
+ verify(rankingService).getProductRank(eq(PRODUCT_ID), any(LocalDate.class));
+ }
+
+ @Test
+ @DisplayName("캐시 미스 시 랭킹 정보가 포함된다")
+ void getProduct_withCacheMiss_includesRanking() {
+ // arrange
+ Product product = Product.of(PRODUCT_NAME, PRODUCT_PRICE, PRODUCT_STOCK, BRAND_ID);
+ setId(product, PRODUCT_ID);
+
+ // Product.likeCount 설정 (리플렉션 사용)
+ try {
+ Field likeCountField = Product.class.getDeclaredField("likeCount");
+ likeCountField.setAccessible(true);
+ likeCountField.set(product, LIKES_COUNT);
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to set Product likeCount", e);
+ }
+
+ Brand brand = Brand.of(BRAND_NAME);
+ setId(brand, BRAND_ID);
+
+ Long expectedRank = 5L;
+
+ when(productCacheService.getCachedProduct(PRODUCT_ID))
+ .thenReturn(null);
+ when(productService.getProduct(PRODUCT_ID))
+ .thenReturn(product);
+ when(brandService.getBrand(BRAND_ID))
+ .thenReturn(brand);
+ when(rankingService.getProductRank(eq(PRODUCT_ID), any(LocalDate.class)))
+ .thenReturn(expectedRank);
+ when(productCacheService.applyLikeCountDelta(any(ProductInfo.class)))
+ .thenAnswer(invocation -> invocation.getArgument(0));
+
+ // act
+ ProductInfo result = catalogFacade.getProduct(PRODUCT_ID);
+
+ // assert
+ assertThat(result.rank()).isEqualTo(expectedRank);
+ verify(rankingService).getProductRank(eq(PRODUCT_ID), any(LocalDate.class));
+ verify(productEventPublisher).publish(any(ProductEvent.ProductViewed.class));
+ verify(productService).getProduct(PRODUCT_ID);
+ verify(productCacheService).cacheProduct(eq(PRODUCT_ID), any(ProductInfo.class));
+ }
+
+ @Test
+ @DisplayName("캐시 미스 시 랭킹에 없는 상품은 null을 반환한다")
+ void getProduct_withCacheMiss_noRanking_returnsNull() {
+ // arrange
+ Product product = Product.of(PRODUCT_NAME, PRODUCT_PRICE, PRODUCT_STOCK, BRAND_ID);
+ setId(product, PRODUCT_ID);
+
+ // Product.likeCount 설정 (리플렉션 사용)
+ try {
+ Field likeCountField = Product.class.getDeclaredField("likeCount");
+ likeCountField.setAccessible(true);
+ likeCountField.set(product, LIKES_COUNT);
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to set Product likeCount", e);
+ }
+
+ Brand brand = Brand.of(BRAND_NAME);
+ setId(brand, BRAND_ID);
+
+ when(productCacheService.getCachedProduct(PRODUCT_ID))
+ .thenReturn(null);
+ when(productService.getProduct(PRODUCT_ID))
+ .thenReturn(product);
+ when(brandService.getBrand(BRAND_ID))
+ .thenReturn(brand);
+ when(rankingService.getProductRank(eq(PRODUCT_ID), any(LocalDate.class)))
+ .thenReturn(null);
+ when(productCacheService.applyLikeCountDelta(any(ProductInfo.class)))
+ .thenAnswer(invocation -> invocation.getArgument(0));
+
+ // act
+ ProductInfo result = catalogFacade.getProduct(PRODUCT_ID);
+
+ // assert
+ assertThat(result.rank()).isNull();
+ verify(rankingService).getProductRank(eq(PRODUCT_ID), any(LocalDate.class));
+ }
+}
+
diff --git a/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingServiceTest.java
new file mode 100644
index 000000000..5bd82f939
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingServiceTest.java
@@ -0,0 +1,607 @@
+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.zset.RedisZSetTemplate;
+import com.loopers.zset.ZSetEntry;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.lang.reflect.Field;
+import java.time.LocalDate;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * RankingService 테스트.
+ */
+@ExtendWith(MockitoExtension.class)
+class RankingServiceTest {
+
+ @Mock
+ private RedisZSetTemplate zSetTemplate;
+
+ @Mock
+ private RankingKeyGenerator keyGenerator;
+
+ @Mock
+ private ProductService productService;
+
+ @Mock
+ private BrandService brandService;
+
+ @Mock
+ private RankingSnapshotService rankingSnapshotService;
+
+ @InjectMocks
+ private RankingService rankingService;
+
+ /**
+ * Product에 ID를 설정합니다 (리플렉션 사용).
+ */
+ private void setId(Product product, Long id) {
+ try {
+ Field idField = product.getClass().getSuperclass().getDeclaredField("id");
+ idField.setAccessible(true);
+ idField.set(product, id);
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to set Product ID", e);
+ }
+ }
+
+ /**
+ * Brand에 ID를 설정합니다 (리플렉션 사용).
+ */
+ private void setId(Brand brand, Long id) {
+ try {
+ Field idField = brand.getClass().getSuperclass().getDeclaredField("id");
+ idField.setAccessible(true);
+ idField.set(brand, id);
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to set Brand ID", e);
+ }
+ }
+
+ @DisplayName("랭킹을 조회할 수 있다.")
+ @Test
+ void canGetRankings() {
+ // arrange
+ LocalDate date = LocalDate.of(2024, 12, 15);
+ int page = 0;
+ int size = 20;
+ String key = "ranking:all:20241215";
+
+ Long productId1 = 1L;
+ Long productId2 = 2L;
+ Long brandId1 = 10L;
+ Long brandId2 = 20L;
+
+ List entries = List.of(
+ new ZSetEntry(String.valueOf(productId1), 100.5),
+ new ZSetEntry(String.valueOf(productId2), 90.3)
+ );
+
+ Product product1 = Product.of("상품1", 10000, 10, brandId1);
+ Product product2 = Product.of("상품2", 20000, 5, brandId2);
+ Brand brand1 = Brand.of("브랜드1");
+ Brand brand2 = Brand.of("브랜드2");
+
+ // ID 설정
+ setId(product1, productId1);
+ setId(product2, productId2);
+ setId(brand1, brandId1);
+ setId(brand2, brandId2);
+ when(keyGenerator.generateDailyKey(date)).thenReturn(key);
+ when(zSetTemplate.getTopRankings(key, 0L, 19L)).thenReturn(entries);
+ when(zSetTemplate.getSize(key)).thenReturn(50L);
+ when(productService.getProducts(List.of(productId1, productId2)))
+ .thenReturn(List.of(product1, product2));
+ when(brandService.getBrands(List.of(brandId1, brandId2)))
+ .thenReturn(List.of(brand1, brand2));
+
+ // act
+ RankingService.RankingsResponse result = rankingService.getRankings(date, page, size);
+
+ // assert
+ assertThat(result.items()).hasSize(2);
+ assertThat(result.page()).isEqualTo(page);
+ assertThat(result.size()).isEqualTo(size);
+ assertThat(result.hasNext()).isTrue();
+
+ RankingService.RankingItem item1 = result.items().get(0);
+ assertThat(item1.rank()).isEqualTo(1L);
+ assertThat(item1.score()).isEqualTo(100.5);
+ assertThat(item1.productDetail().getId()).isEqualTo(productId1);
+ assertThat(item1.productDetail().getName()).isEqualTo("상품1");
+
+ RankingService.RankingItem item2 = result.items().get(1);
+ assertThat(item2.rank()).isEqualTo(2L);
+ assertThat(item2.score()).isEqualTo(90.3);
+ assertThat(item2.productDetail().getId()).isEqualTo(productId2);
+ assertThat(item2.productDetail().getName()).isEqualTo("상품2");
+ }
+
+ @DisplayName("빈 랭킹을 조회할 수 있다.")
+ @Test
+ void canGetEmptyRankings() {
+ // arrange
+ LocalDate date = LocalDate.of(2024, 12, 15);
+ int page = 0;
+ int size = 20;
+ String key = "ranking:all:20241215";
+
+ when(keyGenerator.generateDailyKey(date)).thenReturn(key);
+ when(zSetTemplate.getTopRankings(key, 0L, 19L)).thenReturn(List.of());
+
+ // act
+ RankingService.RankingsResponse result = rankingService.getRankings(date, page, size);
+
+ // assert
+ assertThat(result.items()).isEmpty();
+ assertThat(result.page()).isEqualTo(page);
+ assertThat(result.size()).isEqualTo(size);
+ assertThat(result.hasNext()).isFalse();
+ verify(zSetTemplate, never()).getSize(anyString());
+ }
+
+ @DisplayName("페이징이 정상적으로 동작한다.")
+ @Test
+ void canGetRankingsWithPaging() {
+ // arrange
+ LocalDate date = LocalDate.of(2024, 12, 15);
+ int page = 2;
+ int size = 10;
+ String key = "ranking:all:20241215";
+
+ Long productId = 1L;
+ Long brandId = 10L;
+
+ List entries = List.of(
+ new ZSetEntry(String.valueOf(productId), 100.0)
+ );
+
+ Product product = Product.of("상품", 10000, 10, brandId);
+ Brand brand = Brand.of("브랜드");
+
+ // ID 설정
+ setId(product, productId);
+ setId(brand, brandId);
+
+ when(keyGenerator.generateDailyKey(date)).thenReturn(key);
+ when(zSetTemplate.getTopRankings(key, 20L, 29L)).thenReturn(entries);
+ when(zSetTemplate.getSize(key)).thenReturn(31L); // 31 > 20 + 10이므로 다음 페이지 있음
+ when(productService.getProducts(List.of(productId))).thenReturn(List.of(product));
+ when(brandService.getBrands(List.of(brandId))).thenReturn(List.of(brand));
+
+ // act
+ RankingService.RankingsResponse result = rankingService.getRankings(date, page, size);
+
+ // assert
+ assertThat(result.items()).hasSize(1);
+ assertThat(result.page()).isEqualTo(page);
+ assertThat(result.size()).isEqualTo(size);
+ assertThat(result.hasNext()).isTrue(); // 31 > 20 + 10
+
+ RankingService.RankingItem item = result.items().get(0);
+ assertThat(item.rank()).isEqualTo(21L); // start(20) + i(0) + 1
+ }
+
+ @DisplayName("랭킹에 포함된 상품이 DB에 없으면 스킵한다.")
+ @Test
+ void skipsProduct_whenProductNotFound() {
+ // arrange
+ LocalDate date = LocalDate.of(2024, 12, 15);
+ int page = 0;
+ int size = 20;
+ String key = "ranking:all:20241215";
+
+ Long productId1 = 1L;
+ Long productId2 = 999L; // 존재하지 않는 상품
+
+ List entries = List.of(
+ new ZSetEntry(String.valueOf(productId1), 100.0),
+ new ZSetEntry(String.valueOf(productId2), 90.0)
+ );
+
+ Product product1 = Product.of("상품1", 10000, 10, 10L);
+ Brand brand1 = Brand.of("브랜드1");
+
+ // ID 설정
+ setId(product1, productId1);
+ setId(brand1, 10L);
+
+ when(keyGenerator.generateDailyKey(date)).thenReturn(key);
+ when(zSetTemplate.getTopRankings(key, 0L, 19L)).thenReturn(entries);
+ when(zSetTemplate.getSize(key)).thenReturn(2L);
+ when(productService.getProducts(List.of(productId1, productId2)))
+ .thenReturn(List.of(product1)); // productId2는 없음
+ when(brandService.getBrands(List.of(10L))).thenReturn(List.of(brand1));
+
+ // act
+ RankingService.RankingsResponse result = rankingService.getRankings(date, page, size);
+
+ // assert
+ assertThat(result.items()).hasSize(1); // productId2는 스킵됨
+ assertThat(result.items().get(0).productDetail().getId()).isEqualTo(productId1);
+ }
+
+ @DisplayName("상품의 브랜드가 없으면 스킵한다.")
+ @Test
+ void skipsProduct_whenBrandNotFound() {
+ // arrange
+ LocalDate date = LocalDate.of(2024, 12, 15);
+ int page = 0;
+ int size = 20;
+ String key = "ranking:all:20241215";
+
+ Long productId1 = 1L;
+ Long productId2 = 2L;
+ Long brandId1 = 10L;
+ Long brandId2 = 999L; // 존재하지 않는 브랜드
+
+ List entries = List.of(
+ new ZSetEntry(String.valueOf(productId1), 100.0),
+ new ZSetEntry(String.valueOf(productId2), 90.0)
+ );
+
+ Product product1 = Product.of("상품1", 10000, 10, brandId1);
+ Product product2 = Product.of("상품2", 20000, 5, brandId2);
+ Brand brand1 = Brand.of("브랜드1");
+
+ // ID 설정
+ setId(product1, productId1);
+ setId(product2, productId2);
+ setId(brand1, brandId1);
+
+ when(keyGenerator.generateDailyKey(date)).thenReturn(key);
+ when(zSetTemplate.getTopRankings(key, 0L, 19L)).thenReturn(entries);
+ when(zSetTemplate.getSize(key)).thenReturn(2L);
+ when(productService.getProducts(List.of(productId1, productId2)))
+ .thenReturn(List.of(product1, product2));
+ when(brandService.getBrands(List.of(brandId1, brandId2)))
+ .thenReturn(List.of(brand1)); // brandId2는 없음
+
+ // act
+ RankingService.RankingsResponse result = rankingService.getRankings(date, page, size);
+
+ // assert
+ assertThat(result.items()).hasSize(1); // productId2는 브랜드가 없어서 스킵됨
+ assertThat(result.items().get(0).productDetail().getId()).isEqualTo(productId1);
+ }
+
+ @DisplayName("다음 페이지가 없을 때 hasNext가 false이다.")
+ @Test
+ void hasNextIsFalse_whenNoMorePages() {
+ // arrange
+ LocalDate date = LocalDate.of(2024, 12, 15);
+ int page = 0;
+ int size = 20;
+ String key = "ranking:all:20241215";
+
+ Long productId = 1L;
+ Long brandId = 10L;
+
+ List entries = List.of(
+ new ZSetEntry(String.valueOf(productId), 100.0)
+ );
+
+ Product product = Product.of("상품", 10000, 10, brandId);
+ Brand brand = Brand.of("브랜드");
+
+ // ID 설정
+ setId(product, productId);
+ setId(brand, brandId);
+
+ when(keyGenerator.generateDailyKey(date)).thenReturn(key);
+ when(zSetTemplate.getTopRankings(key, 0L, 19L)).thenReturn(entries);
+ when(zSetTemplate.getSize(key)).thenReturn(1L); // 전체 크기가 1이므로 다음 페이지 없음
+ when(productService.getProducts(List.of(productId))).thenReturn(List.of(product));
+ when(brandService.getBrands(List.of(brandId))).thenReturn(List.of(brand));
+
+ // act
+ RankingService.RankingsResponse result = rankingService.getRankings(date, page, size);
+
+ // assert
+ assertThat(result.hasNext()).isFalse(); // 1 <= 0 + 20
+ }
+
+ @DisplayName("특정 상품의 순위를 조회할 수 있다.")
+ @Test
+ void canGetProductRank() {
+ // arrange
+ Long productId = 1L;
+ LocalDate date = LocalDate.of(2024, 12, 15);
+ String key = "ranking:all:20241215";
+ Long rank = 5L; // 0-based
+
+ when(keyGenerator.generateDailyKey(date)).thenReturn(key);
+ when(zSetTemplate.getRank(key, String.valueOf(productId))).thenReturn(rank);
+
+ // act
+ Long result = rankingService.getProductRank(productId, date);
+
+ // assert
+ assertThat(result).isEqualTo(6L); // 1-based (5 + 1)
+ verify(keyGenerator).generateDailyKey(date);
+ verify(zSetTemplate).getRank(key, String.valueOf(productId));
+ }
+
+ @DisplayName("랭킹에 없는 상품의 순위는 null이다.")
+ @Test
+ void returnsNull_whenProductNotInRanking() {
+ // arrange
+ Long productId = 999L;
+ LocalDate date = LocalDate.of(2024, 12, 15);
+ String key = "ranking:all:20241215";
+
+ when(keyGenerator.generateDailyKey(date)).thenReturn(key);
+ when(zSetTemplate.getRank(key, String.valueOf(productId))).thenReturn(null);
+
+ // act
+ Long result = rankingService.getProductRank(productId, date);
+
+ // assert
+ assertThat(result).isNull();
+ verify(keyGenerator).generateDailyKey(date);
+ verify(zSetTemplate).getRank(key, String.valueOf(productId));
+ }
+
+ @DisplayName("같은 브랜드의 여러 상품이 랭킹에 포함될 수 있다.")
+ @Test
+ void canHandleMultipleProductsFromSameBrand() {
+ // arrange
+ LocalDate date = LocalDate.of(2024, 12, 15);
+ int page = 0;
+ int size = 20;
+ String key = "ranking:all:20241215";
+
+ Long productId1 = 1L;
+ Long productId2 = 2L;
+ Long brandId = 10L; // 같은 브랜드
+
+ List entries = List.of(
+ new ZSetEntry(String.valueOf(productId1), 100.0),
+ new ZSetEntry(String.valueOf(productId2), 90.0)
+ );
+
+ Product product1 = Product.of("상품1", 10000, 10, brandId);
+ Product product2 = Product.of("상품2", 20000, 5, brandId);
+ Brand brand = Brand.of("브랜드");
+
+ // ID 설정
+ setId(product1, productId1);
+ setId(product2, productId2);
+ setId(brand, brandId);
+
+ when(keyGenerator.generateDailyKey(date)).thenReturn(key);
+ when(zSetTemplate.getTopRankings(key, 0L, 19L)).thenReturn(entries);
+ when(zSetTemplate.getSize(key)).thenReturn(2L);
+ when(productService.getProducts(List.of(productId1, productId2)))
+ .thenReturn(List.of(product1, product2));
+ when(brandService.getBrands(List.of(brandId))) // 중복 제거되어 한 번만 조회
+ .thenReturn(List.of(brand));
+
+ // act
+ RankingService.RankingsResponse result = rankingService.getRankings(date, page, size);
+
+ // assert
+ assertThat(result.items()).hasSize(2);
+ assertThat(result.items().get(0).productDetail().getBrandId()).isEqualTo(brandId);
+ assertThat(result.items().get(1).productDetail().getBrandId()).isEqualTo(brandId);
+ // 브랜드는 한 번만 조회됨 (중복 제거)
+ verify(brandService).getBrands(List.of(brandId));
+ }
+
+ @DisplayName("Redis 장애 시 스냅샷으로 Fallback한다.")
+ @Test
+ void fallbackToSnapshot_whenRedisFails() {
+ // arrange
+ LocalDate date = LocalDate.of(2024, 12, 15);
+ int page = 0;
+ int size = 20;
+ String todayKey = "ranking:all:20241215";
+
+ Long productId = 1L;
+ Long brandId = 10L;
+
+ Product product = Product.of("상품", 10000, 10, brandId);
+ Brand brand = Brand.of("브랜드");
+
+ // ID 설정
+ setId(product, productId);
+ setId(brand, brandId);
+
+ RankingService.RankingItem rankingItem = new RankingService.RankingItem(
+ 1L, 100.0,
+ ProductDetail.from(product, brand.getName(), product.getLikeCount())
+ );
+ RankingService.RankingsResponse snapshot = new RankingService.RankingsResponse(
+ List.of(rankingItem), page, size, false
+ );
+
+ when(keyGenerator.generateDailyKey(date)).thenReturn(todayKey);
+
+ // 오늘 랭킹 조회 시 예외 발생
+ when(zSetTemplate.getTopRankings(todayKey, 0L, 19L))
+ .thenThrow(new org.springframework.dao.DataAccessException("Redis connection failed") {});
+
+ // 스냅샷 조회 성공
+ when(rankingSnapshotService.getSnapshot(date)).thenReturn(java.util.Optional.of(snapshot));
+
+ // act
+ RankingService.RankingsResponse result = rankingService.getRankings(date, page, size);
+
+ // assert
+ assertThat(result.items()).hasSize(1);
+ assertThat(result.items().get(0).productDetail().getId()).isEqualTo(productId);
+ verify(zSetTemplate).getTopRankings(todayKey, 0L, 19L);
+ verify(rankingSnapshotService).getSnapshot(date);
+ verify(rankingSnapshotService, never()).getSnapshot(date.minusDays(1));
+ }
+
+ @DisplayName("Redis 장애 시 스냅샷이 없으면 전날 스냅샷으로 Fallback한다.")
+ @Test
+ void fallbackToYesterdaySnapshot_whenSnapshotNotAvailable() {
+ // arrange
+ LocalDate date = LocalDate.of(2024, 12, 15);
+ LocalDate yesterday = date.minusDays(1);
+ int page = 0;
+ int size = 20;
+ String todayKey = "ranking:all:20241215";
+
+ Long productId = 1L;
+ Long brandId = 10L;
+
+ Product product = Product.of("상품", 10000, 10, brandId);
+ Brand brand = Brand.of("브랜드");
+
+ // ID 설정
+ setId(product, productId);
+ setId(brand, brandId);
+
+ RankingService.RankingItem rankingItem = new RankingService.RankingItem(
+ 1L, 100.0,
+ ProductDetail.from(product, brand.getName(), product.getLikeCount())
+ );
+ RankingService.RankingsResponse yesterdaySnapshot = new RankingService.RankingsResponse(
+ List.of(rankingItem), page, size, false
+ );
+
+ when(keyGenerator.generateDailyKey(date)).thenReturn(todayKey);
+
+ // 오늘 랭킹 조회 시 예외 발생
+ when(zSetTemplate.getTopRankings(todayKey, 0L, 19L))
+ .thenThrow(new org.springframework.dao.DataAccessException("Redis connection failed") {});
+
+ // 오늘 스냅샷 없음, 전날 스냅샷 있음
+ when(rankingSnapshotService.getSnapshot(date)).thenReturn(java.util.Optional.empty());
+ when(rankingSnapshotService.getSnapshot(yesterday)).thenReturn(java.util.Optional.of(yesterdaySnapshot));
+
+ // act
+ RankingService.RankingsResponse result = rankingService.getRankings(date, page, size);
+
+ // assert
+ assertThat(result.items()).hasSize(1);
+ assertThat(result.items().get(0).productDetail().getId()).isEqualTo(productId);
+ verify(zSetTemplate).getTopRankings(todayKey, 0L, 19L);
+ verify(rankingSnapshotService).getSnapshot(date);
+ verify(rankingSnapshotService).getSnapshot(yesterday);
+ }
+
+ @DisplayName("Redis 장애 시 스냅샷도 없으면 기본 랭킹(좋아요순)으로 Fallback한다.")
+ @Test
+ void fallbackToDefaultRanking_whenSnapshotNotAvailable() {
+ // arrange
+ LocalDate date = LocalDate.of(2024, 12, 15);
+ LocalDate yesterday = date.minusDays(1);
+ int page = 0;
+ int size = 20;
+ String todayKey = "ranking:all:20241215";
+
+ Long productId = 1L;
+ Long brandId = 10L;
+
+ Product product = Product.of("상품", 10000, 10, brandId);
+ Brand brand = Brand.of("브랜드");
+
+ // ID 설정
+ setId(product, productId);
+ setId(brand, brandId);
+
+ when(keyGenerator.generateDailyKey(date)).thenReturn(todayKey);
+
+ // 오늘 랭킹 조회 시 예외 발생
+ when(zSetTemplate.getTopRankings(todayKey, 0L, 19L))
+ .thenThrow(new org.springframework.dao.DataAccessException("Redis connection failed") {});
+
+ // 스냅샷도 없음
+ when(rankingSnapshotService.getSnapshot(date)).thenReturn(java.util.Optional.empty());
+ when(rankingSnapshotService.getSnapshot(yesterday)).thenReturn(java.util.Optional.empty());
+
+ // 기본 랭킹(좋아요순) 조회
+ when(productService.findAll(null, "likes_desc", page, size)).thenReturn(List.of(product));
+ when(productService.countAll(null)).thenReturn(1L);
+ when(brandService.getBrands(List.of(brandId))).thenReturn(List.of(brand));
+
+ // act
+ RankingService.RankingsResponse result = rankingService.getRankings(date, page, size);
+
+ // assert
+ assertThat(result.items()).hasSize(1);
+ assertThat(result.items().get(0).productDetail().getId()).isEqualTo(productId);
+ assertThat(result.items().get(0).score()).isEqualTo(product.getLikeCount().doubleValue());
+ verify(rankingSnapshotService).getSnapshot(date);
+ verify(rankingSnapshotService).getSnapshot(yesterday);
+ verify(productService).findAll(null, "likes_desc", page, size);
+ }
+
+ @DisplayName("Redis 장애 시 상품 순위 조회도 전날 랭킹으로 Fallback한다.")
+ @Test
+ void fallbackToYesterdayRanking_whenGetProductRankFails() {
+ // arrange
+ Long productId = 1L;
+ LocalDate date = LocalDate.of(2024, 12, 15);
+ LocalDate yesterday = date.minusDays(1);
+ String todayKey = "ranking:all:20241215";
+ String yesterdayKey = "ranking:all:20241214";
+ Long rank = 5L; // 0-based
+
+ when(keyGenerator.generateDailyKey(date)).thenReturn(todayKey);
+ when(keyGenerator.generateDailyKey(yesterday)).thenReturn(yesterdayKey);
+
+ // 오늘 랭킹 조회 시 예외 발생
+ when(zSetTemplate.getRank(todayKey, String.valueOf(productId)))
+ .thenThrow(new org.springframework.dao.DataAccessException("Redis connection failed") {});
+
+ // 전날 랭킹 조회 성공
+ when(zSetTemplate.getRank(yesterdayKey, String.valueOf(productId))).thenReturn(rank);
+
+ // act
+ Long result = rankingService.getProductRank(productId, date);
+
+ // assert
+ assertThat(result).isEqualTo(6L); // 1-based (5 + 1)
+ verify(zSetTemplate).getRank(todayKey, String.valueOf(productId));
+ verify(zSetTemplate).getRank(yesterdayKey, String.valueOf(productId));
+ }
+
+ @DisplayName("Redis 장애 시 상품 순위 조회도 전날 랭킹이 없으면 null을 반환한다.")
+ @Test
+ void returnsNull_whenRedisAndYesterdayRankingFail() {
+ // arrange
+ Long productId = 1L;
+ LocalDate date = LocalDate.of(2024, 12, 15);
+ LocalDate yesterday = date.minusDays(1);
+ String todayKey = "ranking:all:20241215";
+ String yesterdayKey = "ranking:all:20241214";
+
+ when(keyGenerator.generateDailyKey(date)).thenReturn(todayKey);
+ when(keyGenerator.generateDailyKey(yesterday)).thenReturn(yesterdayKey);
+
+ // 오늘 랭킹 조회 시 예외 발생
+ when(zSetTemplate.getRank(todayKey, String.valueOf(productId)))
+ .thenThrow(new org.springframework.dao.DataAccessException("Redis connection failed") {});
+
+ // 전날 랭킹 조회도 예외 발생
+ when(zSetTemplate.getRank(yesterdayKey, String.valueOf(productId)))
+ .thenThrow(new org.springframework.dao.DataAccessException("Redis connection failed") {});
+
+ // act
+ Long result = rankingService.getProductRank(productId, date);
+
+ // assert
+ assertThat(result).isNull();
+ verify(zSetTemplate).getRank(todayKey, String.valueOf(productId));
+ verify(zSetTemplate).getRank(yesterdayKey, String.valueOf(productId));
+ }
+}
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java b/apps/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java
index ea4b4d15a..eb986acdd 100644
--- a/apps/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java
+++ b/apps/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java
@@ -4,11 +4,13 @@
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
+import org.springframework.scheduling.annotation.EnableScheduling;
import java.util.TimeZone;
@ConfigurationPropertiesScan
@SpringBootApplication
+@EnableScheduling
public class CommerceStreamerApplication {
@PostConstruct
public void started() {
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingEventHandler.java b/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingEventHandler.java
new file mode 100644
index 000000000..49c078580
--- /dev/null
+++ b/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingEventHandler.java
@@ -0,0 +1,118 @@
+package com.loopers.application.ranking;
+
+import com.loopers.domain.event.LikeEvent;
+import com.loopers.domain.event.OrderEvent;
+import com.loopers.domain.event.ProductEvent;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDate;
+import java.time.ZoneId;
+
+/**
+ * 랭킹 이벤트 핸들러.
+ *
+ * 좋아요 추가/취소, 주문 생성, 상품 조회 이벤트를 받아 랭킹 점수를 집계하는 애플리케이션 로직을 처리합니다.
+ *
+ *
+ * DDD/EDA 관점:
+ *
+ * - 책임 분리: RankingService는 랭킹 점수 계산/적재, RankingEventHandler는 이벤트 처리 로직
+ * - 이벤트 핸들러: 이벤트를 받아서 처리하는 역할을 명확히 나타냄
+ * - 도메인 경계 준수: 랭킹은 파생 View로 취급하며, 도메인 이벤트를 구독하여 집계
+ *
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class RankingEventHandler {
+
+ private final RankingService rankingService;
+
+ /**
+ * 좋아요 추가 이벤트를 처리하여 랭킹 점수를 추가합니다.
+ *
+ * @param event 좋아요 추가 이벤트
+ */
+ public void handleLikeAdded(LikeEvent.LikeAdded event) {
+ log.debug("좋아요 추가 이벤트 처리: productId={}, userId={}",
+ event.productId(), event.userId());
+
+ LocalDate date = LocalDate.now(ZoneId.of("UTC"));
+ rankingService.addLikeScore(event.productId(), date, true);
+
+ log.debug("좋아요 점수 추가 완료: productId={}", event.productId());
+ }
+
+ /**
+ * 좋아요 취소 이벤트를 처리하여 랭킹 점수를 차감합니다.
+ *
+ * @param event 좋아요 취소 이벤트
+ */
+ public void handleLikeRemoved(LikeEvent.LikeRemoved event) {
+ log.debug("좋아요 취소 이벤트 처리: productId={}, userId={}",
+ event.productId(), event.userId());
+
+ LocalDate date = LocalDate.now(ZoneId.of("UTC"));
+ rankingService.addLikeScore(event.productId(), date, false);
+
+ log.debug("좋아요 점수 차감 완료: productId={}", event.productId());
+ }
+
+ /**
+ * 주문 생성 이벤트를 처리하여 랭킹 점수를 추가합니다.
+ *
+ * 주문 금액 계산:
+ *
+ * - OrderEvent.OrderCreated에는 개별 상품 가격 정보가 없음
+ * - subtotal을 totalQuantity로 나눠서 평균 단가를 구하고, 각 아이템의 quantity를 곱함
+ * - 향후 개선: 주문 이벤트에 개별 상품 가격 정보 추가
+ *
+ *
+ *
+ * @param event 주문 생성 이벤트
+ */
+ public void handleOrderCreated(OrderEvent.OrderCreated event) {
+ log.debug("주문 생성 이벤트 처리: orderId={}", event.orderId());
+
+ LocalDate date = LocalDate.now(ZoneId.of("UTC"));
+
+ // 주문 아이템별로 점수 집계
+ // 주의: OrderEvent.OrderCreated에는 개별 상품 가격 정보가 없으므로
+ // subtotal을 totalQuantity로 나눠서 평균 단가를 구하고, 각 아이템의 quantity를 곱함
+ int totalQuantity = event.orderItems().stream()
+ .mapToInt(OrderEvent.OrderCreated.OrderItemInfo::quantity)
+ .sum();
+
+ if (totalQuantity > 0 && event.subtotal() != null) {
+ double averagePrice = (double) event.subtotal() / totalQuantity;
+
+ for (OrderEvent.OrderCreated.OrderItemInfo item : event.orderItems()) {
+ double orderAmount = averagePrice * item.quantity();
+ rankingService.addOrderScore(item.productId(), date, orderAmount);
+ }
+ }
+
+ log.debug("주문 점수 추가 완료: orderId={}", event.orderId());
+ }
+
+ /**
+ * 상품 조회 이벤트를 처리하여 랭킹 점수를 추가합니다.
+ *
+ * @param event 상품 조회 이벤트
+ */
+ public void handleProductViewed(ProductEvent.ProductViewed event) {
+ log.debug("상품 조회 이벤트 처리: productId={}", event.productId());
+
+ LocalDate date = LocalDate.now(ZoneId.of("UTC"));
+ rankingService.addViewScore(event.productId(), date);
+
+ log.debug("조회 점수 추가 완료: productId={}", event.productId());
+ }
+}
+
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingKeyGenerator.java b/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingKeyGenerator.java
new file mode 100644
index 000000000..583b8b7d7
--- /dev/null
+++ b/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingKeyGenerator.java
@@ -0,0 +1,35 @@
+package com.loopers.application.ranking;
+
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+
+/**
+ * 랭킹 키 생성 유틸리티.
+ *
+ * Redis ZSET 랭킹 키를 생성합니다.
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Component
+public class RankingKeyGenerator {
+ private static final String DAILY_KEY_PREFIX = "ranking:all:";
+ private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd");
+
+ /**
+ * 일간 랭킹 키를 생성합니다.
+ *
+ * 예: ranking:all:20241215
+ *
+ *
+ * @param date 날짜
+ * @return 일간 랭킹 키
+ */
+ public String generateDailyKey(LocalDate date) {
+ String dateStr = date.format(DATE_FORMATTER);
+ return DAILY_KEY_PREFIX + dateStr;
+ }
+}
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingService.java b/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingService.java
new file mode 100644
index 000000000..f88096f5e
--- /dev/null
+++ b/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingService.java
@@ -0,0 +1,165 @@
+package com.loopers.application.ranking;
+
+import com.loopers.zset.RedisZSetTemplate;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.time.Duration;
+import java.time.LocalDate;
+import java.util.Map;
+
+/**
+ * 랭킹 점수 계산 및 ZSET 적재 서비스.
+ *
+ * Kafka Consumer에서 이벤트를 수취하여 Redis ZSET에 랭킹 점수를 적재합니다.
+ *
+ *
+ * 설계 원칙:
+ *
+ * - Application 유즈케이스: Ranking은 도메인이 아닌 파생 View로 취급
+ * - Eventually Consistent: 일시적인 지연/중복 허용
+ * - CQRS Read Model: Write Side(도메인) → Kafka → Read Side(Application) → Redis ZSET
+ * - 단순성: ZSetTemplate을 직접 사용하여 불필요한 추상화 제거
+ *
+ *
+ *
+ * 점수 계산 공식:
+ *
+ * - 조회: Weight = 0.1, Score = 1
+ * - 좋아요: Weight = 0.2, Score = 1
+ * - 주문: Weight = 0.6, Score = price * amount (정규화: log(1 + amount))
+ *
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class RankingService {
+ private static final double VIEW_WEIGHT = 0.1;
+ private static final double LIKE_WEIGHT = 0.2;
+ private static final double ORDER_WEIGHT = 0.6;
+ private static final Duration TTL = Duration.ofDays(2);
+
+ private final RedisZSetTemplate zSetTemplate;
+ private final RankingKeyGenerator keyGenerator;
+
+ /**
+ * 조회 이벤트 점수를 ZSET에 추가합니다.
+ *
+ * @param productId 상품 ID
+ * @param date 날짜
+ */
+ public void addViewScore(Long productId, LocalDate date) {
+ String key = keyGenerator.generateDailyKey(date);
+ double score = VIEW_WEIGHT;
+ incrementScore(key, productId, score);
+ log.debug("조회 점수 추가: productId={}, date={}, score={}", productId, date, score);
+ }
+
+ /**
+ * 좋아요 이벤트 점수를 ZSET에 추가/차감합니다.
+ *
+ * @param productId 상품 ID
+ * @param date 날짜
+ * @param isAdded 좋아요 추가 여부 (true: 추가, false: 취소)
+ */
+ public void addLikeScore(Long productId, LocalDate date, boolean isAdded) {
+ String key = keyGenerator.generateDailyKey(date);
+ double score = isAdded ? LIKE_WEIGHT : -LIKE_WEIGHT;
+ incrementScore(key, productId, score);
+ log.debug("좋아요 점수 {}: productId={}, date={}, score={}",
+ isAdded ? "추가" : "차감", productId, date, score);
+ }
+
+ /**
+ * 주문 이벤트 점수를 ZSET에 추가합니다.
+ *
+ * 주문 금액을 기반으로 점수를 계산합니다.
+ * 정규화를 위해 log(1 + orderAmount)를 사용합니다.
+ *
+ *
+ * @param productId 상품 ID
+ * @param date 날짜
+ * @param orderAmount 주문 금액 (price * quantity)
+ */
+ public void addOrderScore(Long productId, LocalDate date, double orderAmount) {
+ String key = keyGenerator.generateDailyKey(date);
+ // 정규화: log(1 + orderAmount) 사용하여 큰 금액 차이를 완화
+ double score = Math.log1p(orderAmount) * ORDER_WEIGHT;
+ incrementScore(key, productId, score);
+ log.debug("주문 점수 추가: productId={}, date={}, orderAmount={}, score={}",
+ productId, date, orderAmount, score);
+ }
+
+ /**
+ * 배치로 점수를 적재합니다.
+ *
+ * 같은 배치 내에서 같은 상품의 여러 이벤트를 메모리에서 집계한 후 한 번에 적재합니다.
+ *
+ *
+ * @param scoreMap 상품 ID별 점수 맵
+ * @param date 날짜
+ */
+ public void addScoresBatch(Map scoreMap, LocalDate date) {
+ if (scoreMap.isEmpty()) {
+ return;
+ }
+
+ String key = keyGenerator.generateDailyKey(date);
+ for (Map.Entry entry : scoreMap.entrySet()) {
+ zSetTemplate.incrementScore(key, String.valueOf(entry.getKey()), entry.getValue());
+ }
+
+ // TTL 설정 (최초 1회만)
+ zSetTemplate.setTtlIfNotExists(key, TTL);
+
+ log.debug("배치 점수 적재 완료: date={}, count={}", date, scoreMap.size());
+ }
+
+ /**
+ * Score Carry-Over: 오늘의 랭킹을 내일 랭킹에 일부 반영합니다.
+ *
+ * 콜드 스타트 문제를 완화하기 위해 오늘의 랭킹을 가중치를 적용하여 내일 랭킹에 반영합니다.
+ * 예: 오늘 랭킹의 10%를 내일 랭킹에 반영
+ *
+ *
+ * @param today 오늘 날짜
+ * @param tomorrow 내일 날짜
+ * @param carryOverWeight Carry-Over 가중치 (예: 0.1 = 10%)
+ * @return 반영된 멤버 수
+ */
+ public Long carryOverScore(LocalDate today, LocalDate tomorrow, double carryOverWeight) {
+ String todayKey = keyGenerator.generateDailyKey(today);
+ String tomorrowKey = keyGenerator.generateDailyKey(tomorrow);
+
+ // 오늘 랭킹을 가중치를 적용하여 내일 랭킹에 합산
+ Long result = zSetTemplate.unionStoreWithWeight(tomorrowKey, todayKey, carryOverWeight);
+
+ // TTL 설정
+ zSetTemplate.setTtlIfNotExists(tomorrowKey, TTL);
+
+ log.info("Score Carry-Over 완료: today={}, tomorrow={}, weight={}, memberCount={}",
+ today, tomorrow, carryOverWeight, result);
+ return result;
+ }
+
+ /**
+ * ZSET에 점수를 증가시킵니다.
+ *
+ * 점수 계산 후 ZSetTemplate을 통해 Redis에 적재합니다.
+ *
+ *
+ * @param key ZSET 키
+ * @param productId 상품 ID
+ * @param score 증가시킬 점수
+ */
+ private void incrementScore(String key, Long productId, double score) {
+ zSetTemplate.incrementScore(key, String.valueOf(productId), score);
+ // TTL 설정 (최초 1회만)
+ zSetTemplate.setTtlIfNotExists(key, TTL);
+ }
+}
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/RankingCarryOverScheduler.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/RankingCarryOverScheduler.java
new file mode 100644
index 000000000..c23a29d4c
--- /dev/null
+++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/RankingCarryOverScheduler.java
@@ -0,0 +1,71 @@
+package com.loopers.infrastructure.scheduler;
+
+import com.loopers.application.ranking.RankingService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDate;
+import java.time.ZoneId;
+
+/**
+ * 랭킹 Score Carry-Over 스케줄러.
+ *
+ * 매일 자정에 전날 랭킹을 오늘 랭킹에 일부 반영하여 콜드 스타트 문제를 완화합니다.
+ *
+ *
+ * 설계 원칙:
+ *
+ * - 콜드 스타트 완화: 매일 자정에 랭킹이 0점에서 시작하는 문제를 완화
+ * - 가중치 적용: 전날 랭킹의 일부(예: 10%)만 반영하여 신선도 유지
+ * - 에러 처리: Carry-Over 실패 시에도 다음 스케줄에서 재시도
+ *
+ *
+ *
+ * 실행 시점:
+ *
+ * - 매일 자정(00:00:00)에 실행
+ * - 전날(어제) 랭킹을 오늘 랭킹에 반영
+ *
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class RankingCarryOverScheduler {
+
+ private static final double DEFAULT_CARRY_OVER_WEIGHT = 0.1; // 10%
+
+ private final RankingService rankingService;
+
+ /**
+ * 전날 랭킹을 오늘 랭킹에 일부 반영합니다.
+ *
+ * 매일 자정에 실행되어 어제 랭킹의 일부를 오늘 랭킹에 반영합니다.
+ *
+ */
+ @Scheduled(cron = "0 0 0 * * ?") // 매일 자정 (00:00:00)
+ public void carryOverScore() {
+ LocalDate today = LocalDate.now(ZoneId.of("UTC"));
+ LocalDate yesterday = today.minusDays(1);
+
+ try {
+ Long memberCount = rankingService.carryOverScore(yesterday, today, DEFAULT_CARRY_OVER_WEIGHT);
+
+ log.info("랭킹 Score Carry-Over 완료: yesterday={}, today={}, weight={}, memberCount={}",
+ yesterday, today, DEFAULT_CARRY_OVER_WEIGHT, memberCount);
+ } catch (org.springframework.dao.DataAccessException e) {
+ log.warn("Redis 장애로 인한 랭킹 Score Carry-Over 실패: yesterday={}, today={}, error={}",
+ yesterday, today, e.getMessage());
+ // Redis 장애 시 Carry-Over 스킵 (다음 스케줄에서 재시도)
+ } catch (Exception e) {
+ log.warn("랭킹 Score Carry-Over 실패: yesterday={}, today={}", yesterday, today, e);
+ // Carry-Over 실패는 다음 스케줄에서 재시도
+ }
+ }
+}
+
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/RankingConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/RankingConsumer.java
new file mode 100644
index 000000000..8c19d687d
--- /dev/null
+++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/RankingConsumer.java
@@ -0,0 +1,389 @@
+package com.loopers.interfaces.consumer;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.loopers.application.eventhandled.EventHandledService;
+import com.loopers.confg.kafka.KafkaConfig;
+import com.loopers.domain.event.LikeEvent;
+import com.loopers.domain.event.OrderEvent;
+import com.loopers.domain.event.ProductEvent;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.kafka.clients.consumer.ConsumerRecord;
+import org.apache.kafka.common.header.Header;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.kafka.annotation.KafkaListener;
+import org.springframework.kafka.support.Acknowledgment;
+import org.springframework.stereotype.Component;
+
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+
+/**
+ * 랭킹 집계 Kafka Consumer.
+ *
+ * Kafka에서 이벤트를 수취하여 Spring ApplicationEvent로 발행합니다.
+ * 조회, 좋아요, 주문 이벤트를 기반으로 실시간 랭킹을 구축합니다.
+ *
+ *
+ * 처리 이벤트:
+ *
+ * - like-events: LikeAdded, LikeRemoved (좋아요 점수 집계)
+ * - order-events: OrderCreated (주문 점수 집계)
+ * - product-events: ProductViewed (조회 점수 집계)
+ *
+ *
+ *
+ * Manual Ack:
+ *
+ * - 이벤트 처리 성공 후 수동으로 커밋하여 At Most Once 보장
+ * - 에러 발생 시 커밋하지 않아 재처리 가능
+ *
+ *
+ *
+ * 설계 원칙:
+ *
+ * - 관심사 분리: Consumer는 Kafka 메시지 수신/파싱만 담당, 비즈니스 로직은 EventHandler에서 처리
+ * - 이벤트 핸들러 패턴: Kafka Event → Spring ApplicationEvent → RankingEventListener → RankingEventHandler
+ * - Eventually Consistent: 일시적인 지연/중복 허용
+ * - CQRS Read Model: Write Side(도메인) → Kafka → Read Side(Application) → Redis ZSET
+ *
+ *
+ *
+ * @author Loopers
+ * @version 2.0
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class RankingConsumer {
+
+ private final ApplicationEventPublisher applicationEventPublisher;
+ private final EventHandledService eventHandledService;
+ private final ObjectMapper objectMapper;
+
+ private static final String EVENT_ID_HEADER = "eventId";
+ private static final String EVENT_TYPE_HEADER = "eventType";
+ private static final String VERSION_HEADER = "version";
+
+ /**
+ * 개별 레코드 처리 로직을 정의하는 함수형 인터페이스.
+ */
+ @FunctionalInterface
+ private interface RecordProcessor {
+ /**
+ * 개별 레코드를 처리합니다.
+ *
+ * @param record Kafka 메시지 레코드
+ * @param eventId 이벤트 ID
+ * @return 처리된 이벤트 타입과 토픽 이름을 담은 EventProcessResult
+ * @throws Exception 처리 중 발생한 예외
+ */
+ EventProcessResult process(ConsumerRecord record, String eventId) throws Exception;
+ }
+
+ /**
+ * 이벤트 처리 결과를 담는 레코드.
+ */
+ private record EventProcessResult(String eventType, String topicName) {
+ }
+
+ /**
+ * 공통 배치 처리 로직을 실행합니다.
+ *
+ * 멱등성 체크, 에러 처리, 배치 커밋 등의 공통 로직을 처리합니다.
+ *
+ *
+ * @param records Kafka 메시지 레코드 목록
+ * @param acknowledgment 수동 커밋을 위한 Acknowledgment
+ * @param topicName 토픽 이름 (로깅 및 이벤트 기록용)
+ * @param processor 개별 레코드 처리 로직
+ */
+ private void processBatch(
+ List> records,
+ Acknowledgment acknowledgment,
+ String topicName,
+ RecordProcessor processor
+ ) {
+ try {
+ for (ConsumerRecord record : records) {
+ try {
+ String eventId = extractEventId(record);
+ if (eventId == null) {
+ log.warn("eventId가 없는 메시지는 건너뜁니다: offset={}, partition={}",
+ record.offset(), record.partition());
+ continue;
+ }
+
+ // 멱등성 체크: 이미 처리된 이벤트는 스킵
+ if (eventHandledService.isAlreadyHandled(eventId)) {
+ log.debug("이미 처리된 이벤트 스킵: eventId={}", eventId);
+ continue;
+ }
+
+ // 개별 레코드 처리
+ EventProcessResult result = processor.process(record, eventId);
+
+ // 이벤트 처리 기록 저장
+ eventHandledService.markAsHandled(eventId, result.eventType(), result.topicName());
+ } catch (org.springframework.dao.DataIntegrityViolationException e) {
+ // UNIQUE 제약조건 위반 = 동시성 상황에서 이미 처리됨 (정상)
+ log.debug("동시성 상황에서 이미 처리된 이벤트: offset={}, partition={}",
+ record.offset(), record.partition());
+ } catch (Exception e) {
+ log.error("이벤트 처리 실패: topic={}, offset={}, partition={}",
+ topicName, record.offset(), record.partition(), e);
+ // 개별 이벤트 처리 실패는 로그만 기록하고 계속 진행
+ }
+ }
+
+ // 모든 이벤트 처리 완료 후 수동 커밋
+ acknowledgment.acknowledge();
+ log.debug("이벤트 처리 완료: topic={}, count={}", topicName, records.size());
+ } catch (Exception e) {
+ log.error("배치 처리 실패: topic={}, count={}", topicName, records.size(), e);
+ // 에러 발생 시 커밋하지 않음 (재처리 가능)
+ throw e;
+ }
+ }
+
+ /**
+ * like-events 토픽을 구독하여 좋아요 점수를 집계합니다.
+ *
+ * 멱등성 처리:
+ *
+ * - Kafka 메시지 헤더에서 `eventId`를 추출
+ * - 이미 처리된 이벤트는 스킵하여 중복 처리 방지
+ * - 처리 후 `event_handled` 테이블에 기록
+ *
+ *
+ *
+ * @param records Kafka 메시지 레코드 목록
+ * @param acknowledgment 수동 커밋을 위한 Acknowledgment
+ */
+ @KafkaListener(
+ topics = "like-events",
+ containerFactory = KafkaConfig.BATCH_LISTENER
+ )
+ public void consumeLikeEvents(
+ List> records,
+ Acknowledgment acknowledgment
+ ) {
+ processBatch(records, acknowledgment, "like-events", (record, eventId) -> {
+ Object value = record.value();
+ String eventType;
+
+ // Spring Kafka가 자동으로 역직렬화한 경우
+ if (value instanceof LikeEvent.LikeAdded) {
+ LikeEvent.LikeAdded event = (LikeEvent.LikeAdded) value;
+ applicationEventPublisher.publishEvent(event);
+ eventType = "LikeAdded";
+ } else if (value instanceof LikeEvent.LikeRemoved) {
+ LikeEvent.LikeRemoved event = (LikeEvent.LikeRemoved) value;
+ applicationEventPublisher.publishEvent(event);
+ eventType = "LikeRemoved";
+ } else {
+ // JSON 문자열인 경우 이벤트 타입 헤더로 구분
+ String eventTypeHeader = extractEventType(record);
+ if ("LikeRemoved".equals(eventTypeHeader)) {
+ LikeEvent.LikeRemoved event = parseLikeRemovedEvent(value);
+ applicationEventPublisher.publishEvent(event);
+ eventType = "LikeRemoved";
+ } else {
+ // 기본값은 LikeAdded
+ LikeEvent.LikeAdded event = parseLikeEvent(value);
+ applicationEventPublisher.publishEvent(event);
+ eventType = "LikeAdded";
+ }
+ }
+
+ return new EventProcessResult(eventType, "like-events");
+ });
+ }
+
+ /**
+ * order-events 토픽을 구독하여 주문 점수를 집계합니다.
+ *
+ * 멱등성 처리:
+ *
+ * - Kafka 메시지 헤더에서 `eventId`를 추출
+ * - 이미 처리된 이벤트는 스킵하여 중복 처리 방지
+ * - 처리 후 `event_handled` 테이블에 기록
+ *
+ *
+ *
+ * @param records Kafka 메시지 레코드 목록
+ * @param acknowledgment 수동 커밋을 위한 Acknowledgment
+ */
+ @KafkaListener(
+ topics = "order-events",
+ containerFactory = KafkaConfig.BATCH_LISTENER
+ )
+ public void consumeOrderEvents(
+ List> records,
+ Acknowledgment acknowledgment
+ ) {
+ processBatch(records, acknowledgment, "order-events", (record, eventId) -> {
+ Object value = record.value();
+ OrderEvent.OrderCreated event = parseOrderCreatedEvent(value);
+
+ // Spring ApplicationEvent 발행 (애플리케이션 내부 이벤트)
+ applicationEventPublisher.publishEvent(event);
+
+ return new EventProcessResult("OrderCreated", "order-events");
+ });
+ }
+
+ /**
+ * product-events 토픽을 구독하여 조회 점수를 집계합니다.
+ *
+ * 멱등성 처리:
+ *
+ * - Kafka 메시지 헤더에서 `eventId`를 추출
+ * - 이미 처리된 이벤트는 스킵하여 중복 처리 방지
+ * - 처리 후 `event_handled` 테이블에 기록
+ *
+ *
+ *
+ * @param records Kafka 메시지 레코드 목록
+ * @param acknowledgment 수동 커밋을 위한 Acknowledgment
+ */
+ @KafkaListener(
+ topics = "product-events",
+ containerFactory = KafkaConfig.BATCH_LISTENER
+ )
+ public void consumeProductEvents(
+ List> records,
+ Acknowledgment acknowledgment
+ ) {
+ processBatch(records, acknowledgment, "product-events", (record, eventId) -> {
+ Object value = record.value();
+ ProductEvent.ProductViewed event = parseProductViewedEvent(value);
+
+ // Spring ApplicationEvent 발행 (애플리케이션 내부 이벤트)
+ applicationEventPublisher.publishEvent(event);
+
+ return new EventProcessResult("ProductViewed", "product-events");
+ });
+ }
+
+ /**
+ * Kafka 메시지 값을 LikeAdded 이벤트로 파싱합니다.
+ *
+ * @param value Kafka 메시지 값
+ * @return 파싱된 LikeAdded 이벤트
+ */
+ private LikeEvent.LikeAdded parseLikeEvent(Object value) {
+ try {
+ // JSON 문자열인 경우 파싱
+ String json = value instanceof String ? (String) value : objectMapper.writeValueAsString(value);
+ return objectMapper.readValue(json, LikeEvent.LikeAdded.class);
+ } catch (Exception e) {
+ throw new RuntimeException("LikeAdded 이벤트 파싱 실패", e);
+ }
+ }
+
+ /**
+ * Kafka 메시지 값을 LikeRemoved 이벤트로 파싱합니다.
+ *
+ * @param value Kafka 메시지 값
+ * @return 파싱된 LikeRemoved 이벤트
+ */
+ private LikeEvent.LikeRemoved parseLikeRemovedEvent(Object value) {
+ try {
+ // JSON 문자열인 경우 파싱
+ String json = value instanceof String ? (String) value : objectMapper.writeValueAsString(value);
+ return objectMapper.readValue(json, LikeEvent.LikeRemoved.class);
+ } catch (Exception e) {
+ throw new RuntimeException("LikeRemoved 이벤트 파싱 실패", e);
+ }
+ }
+
+ /**
+ * Kafka 메시지 값을 OrderCreated 이벤트로 파싱합니다.
+ *
+ * @param value Kafka 메시지 값
+ * @return 파싱된 OrderCreated 이벤트
+ */
+ private OrderEvent.OrderCreated parseOrderCreatedEvent(Object value) {
+ try {
+ if (value instanceof OrderEvent.OrderCreated) {
+ return (OrderEvent.OrderCreated) value;
+ }
+
+ // JSON 문자열인 경우 파싱
+ String json = value instanceof String ? (String) value : objectMapper.writeValueAsString(value);
+ return objectMapper.readValue(json, OrderEvent.OrderCreated.class);
+ } catch (Exception e) {
+ throw new RuntimeException("OrderCreated 이벤트 파싱 실패", e);
+ }
+ }
+
+ /**
+ * Kafka 메시지 값을 ProductViewed 이벤트로 파싱합니다.
+ *
+ * @param value Kafka 메시지 값
+ * @return 파싱된 ProductViewed 이벤트
+ */
+ private ProductEvent.ProductViewed parseProductViewedEvent(Object value) {
+ try {
+ if (value instanceof ProductEvent.ProductViewed) {
+ return (ProductEvent.ProductViewed) value;
+ }
+
+ // JSON 문자열인 경우 파싱
+ String json = value instanceof String ? (String) value : objectMapper.writeValueAsString(value);
+ return objectMapper.readValue(json, ProductEvent.ProductViewed.class);
+ } catch (Exception e) {
+ throw new RuntimeException("ProductViewed 이벤트 파싱 실패", e);
+ }
+ }
+
+ /**
+ * Kafka 메시지 헤더에서 eventId를 추출합니다.
+ *
+ * @param record Kafka 메시지 레코드
+ * @return eventId (없으면 null)
+ */
+ private String extractEventId(ConsumerRecord record) {
+ Header header = record.headers().lastHeader(EVENT_ID_HEADER);
+ if (header != null && header.value() != null) {
+ return new String(header.value(), StandardCharsets.UTF_8);
+ }
+ return null;
+ }
+
+ /**
+ * Kafka 메시지 헤더에서 eventType을 추출합니다.
+ *
+ * @param record Kafka 메시지 레코드
+ * @return eventType (없으면 null)
+ */
+ private String extractEventType(ConsumerRecord record) {
+ Header header = record.headers().lastHeader(EVENT_TYPE_HEADER);
+ if (header != null && header.value() != null) {
+ return new String(header.value(), StandardCharsets.UTF_8);
+ }
+ return null;
+ }
+
+ /**
+ * Kafka 메시지 헤더에서 version을 추출합니다.
+ *
+ * @param record Kafka 메시지 레코드
+ * @return version (없으면 null)
+ */
+ private Long extractVersion(ConsumerRecord record) {
+ Header header = record.headers().lastHeader(VERSION_HEADER);
+ if (header != null && header.value() != null) {
+ try {
+ String versionStr = new String(header.value(), StandardCharsets.UTF_8);
+ return Long.parseLong(versionStr);
+ } catch (NumberFormatException e) {
+ log.warn("버전 헤더 파싱 실패: offset={}, partition={}",
+ record.offset(), record.partition());
+ return null;
+ }
+ }
+ return null;
+ }
+}
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/event/ranking/RankingEventListener.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/event/ranking/RankingEventListener.java
new file mode 100644
index 000000000..b72cc4a48
--- /dev/null
+++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/event/ranking/RankingEventListener.java
@@ -0,0 +1,121 @@
+package com.loopers.interfaces.event.ranking;
+
+import com.loopers.application.ranking.RankingEventHandler;
+import com.loopers.domain.event.LikeEvent;
+import com.loopers.domain.event.OrderEvent;
+import com.loopers.domain.event.ProductEvent;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.event.EventListener;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Component;
+
+/**
+ * 랭킹 이벤트 리스너.
+ *
+ * 좋아요 추가/취소, 주문 생성, 상품 조회 이벤트를 받아서 랭킹 점수를 집계하는 인터페이스 레이어의 어댑터입니다.
+ *
+ *
+ * 레이어 역할:
+ *
+ * - 인터페이스 레이어: 외부 이벤트(도메인 이벤트)를 받아서 애플리케이션 핸들러를 호출하는 어댑터
+ * - 비즈니스 로직 없음: 단순히 이벤트를 받아서 애플리케이션 핸들러를 호출하는 역할만 수행
+ *
+ *
+ *
+ * EDA 원칙:
+ *
+ * - 비동기 처리: @Async로 집계 처리를 비동기로 실행하여 Kafka Consumer의 성능에 영향 없음
+ * - 이벤트 기반: 좋아요, 주문, 조회 이벤트를 구독하여 랭킹 점수 집계
+ *
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class RankingEventListener {
+
+ private final RankingEventHandler rankingEventHandler;
+
+ /**
+ * 좋아요 추가 이벤트를 처리합니다.
+ *
+ * 비동기로 실행되어 랭킹 점수를 집계합니다.
+ *
+ *
+ * @param event 좋아요 추가 이벤트
+ */
+ @Async
+ @EventListener
+ public void handleLikeAdded(LikeEvent.LikeAdded event) {
+ try {
+ rankingEventHandler.handleLikeAdded(event);
+ } catch (Exception e) {
+ log.error("좋아요 추가 이벤트 처리 중 오류 발생: productId={}, userId={}",
+ event.productId(), event.userId(), e);
+ // 이벤트 처리 실패는 다른 리스너에 영향을 주지 않도록 예외를 삼킴
+ }
+ }
+
+ /**
+ * 좋아요 취소 이벤트를 처리합니다.
+ *
+ * 비동기로 실행되어 랭킹 점수를 차감합니다.
+ *
+ *
+ * @param event 좋아요 취소 이벤트
+ */
+ @Async
+ @EventListener
+ public void handleLikeRemoved(LikeEvent.LikeRemoved event) {
+ try {
+ rankingEventHandler.handleLikeRemoved(event);
+ } catch (Exception e) {
+ log.error("좋아요 취소 이벤트 처리 중 오류 발생: productId={}, userId={}",
+ event.productId(), event.userId(), e);
+ // 이벤트 처리 실패는 다른 리스너에 영향을 주지 않도록 예외를 삼킴
+ }
+ }
+
+ /**
+ * 주문 생성 이벤트를 처리합니다.
+ *
+ * 비동기로 실행되어 랭킹 점수를 집계합니다.
+ *
+ *
+ * @param event 주문 생성 이벤트
+ */
+ @Async
+ @EventListener
+ public void handleOrderCreated(OrderEvent.OrderCreated event) {
+ try {
+ rankingEventHandler.handleOrderCreated(event);
+ } catch (Exception e) {
+ log.error("주문 생성 이벤트 처리 중 오류 발생: orderId={}", event.orderId(), e);
+ // 이벤트 처리 실패는 다른 리스너에 영향을 주지 않도록 예외를 삼킴
+ }
+ }
+
+ /**
+ * 상품 조회 이벤트를 처리합니다.
+ *
+ * 비동기로 실행되어 랭킹 점수를 집계합니다.
+ *
+ *
+ * @param event 상품 조회 이벤트
+ */
+ @Async
+ @EventListener
+ public void handleProductViewed(ProductEvent.ProductViewed event) {
+ try {
+ rankingEventHandler.handleProductViewed(event);
+ } catch (Exception e) {
+ log.error("상품 조회 이벤트 처리 중 오류 발생: productId={}", event.productId(), e);
+ // 이벤트 처리 실패는 다른 리스너에 영향을 주지 않도록 예외를 삼킴
+ }
+ }
+}
+
diff --git a/apps/commerce-streamer/src/test/java/com/loopers/application/ranking/RankingEventHandlerTest.java b/apps/commerce-streamer/src/test/java/com/loopers/application/ranking/RankingEventHandlerTest.java
new file mode 100644
index 000000000..a32182b98
--- /dev/null
+++ b/apps/commerce-streamer/src/test/java/com/loopers/application/ranking/RankingEventHandlerTest.java
@@ -0,0 +1,158 @@
+package com.loopers.application.ranking;
+
+import com.loopers.domain.event.LikeEvent;
+import com.loopers.domain.event.OrderEvent;
+import com.loopers.domain.event.ProductEvent;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.List;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.*;
+
+/**
+ * RankingEventHandler 테스트.
+ */
+@ExtendWith(MockitoExtension.class)
+class RankingEventHandlerTest {
+
+ @Mock
+ private RankingService rankingService;
+
+ @InjectMocks
+ private RankingEventHandler rankingEventHandler;
+
+ @DisplayName("좋아요 추가 이벤트를 처리할 수 있다.")
+ @Test
+ void canHandleLikeAdded() {
+ // arrange
+ Long productId = 1L;
+ Long userId = 100L;
+ LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now());
+
+ // act
+ rankingEventHandler.handleLikeAdded(event);
+
+ // assert
+ verify(rankingService).addLikeScore(eq(productId), any(LocalDate.class), eq(true));
+ }
+
+ @DisplayName("좋아요 취소 이벤트를 처리할 수 있다.")
+ @Test
+ void canHandleLikeRemoved() {
+ // arrange
+ Long productId = 1L;
+ Long userId = 100L;
+ LikeEvent.LikeRemoved event = new LikeEvent.LikeRemoved(userId, productId, LocalDateTime.now());
+
+ // act
+ rankingEventHandler.handleLikeRemoved(event);
+
+ // assert
+ verify(rankingService).addLikeScore(eq(productId), any(LocalDate.class), eq(false));
+ }
+
+ @DisplayName("주문 생성 이벤트를 처리할 수 있다.")
+ @Test
+ void canHandleOrderCreated() {
+ // arrange
+ Long orderId = 1L;
+ Long userId = 100L;
+ OrderEvent.OrderCreated.OrderItemInfo item1 =
+ new OrderEvent.OrderCreated.OrderItemInfo(1L, 2);
+ OrderEvent.OrderCreated.OrderItemInfo item2 =
+ new OrderEvent.OrderCreated.OrderItemInfo(2L, 3);
+ OrderEvent.OrderCreated event = new OrderEvent.OrderCreated(
+ orderId,
+ userId,
+ null, // couponCode
+ 10000, // subtotal
+ null, // usedPointAmount
+ List.of(item1, item2),
+ LocalDateTime.now()
+ );
+
+ // act
+ rankingEventHandler.handleOrderCreated(event);
+
+ // assert
+ // totalQuantity = 2 + 3 = 5
+ // averagePrice = 10000 / 5 = 2000
+ // item1: 2000 * 2 = 4000
+ // item2: 2000 * 3 = 6000
+ verify(rankingService).addOrderScore(eq(1L), any(LocalDate.class), eq(4000.0));
+ verify(rankingService).addOrderScore(eq(2L), any(LocalDate.class), eq(6000.0));
+ }
+
+ @DisplayName("주문 아이템이 없으면 점수를 추가하지 않는다.")
+ @Test
+ void doesNothing_whenOrderItemsIsEmpty() {
+ // arrange
+ Long orderId = 1L;
+ Long userId = 100L;
+ OrderEvent.OrderCreated event = new OrderEvent.OrderCreated(
+ orderId,
+ userId,
+ null, // couponCode
+ 10000, // subtotal
+ null, // usedPointAmount
+ List.of(),
+ LocalDateTime.now()
+ );
+
+ // act
+ rankingEventHandler.handleOrderCreated(event);
+
+ // assert
+ verify(rankingService, never()).addOrderScore(any(), any(), anyDouble());
+ }
+
+ @DisplayName("주문 subtotal이 null이면 점수를 추가하지 않는다.")
+ @Test
+ void doesNothing_whenSubtotalIsNull() {
+ // arrange
+ Long orderId = 1L;
+ Long userId = 100L;
+ OrderEvent.OrderCreated.OrderItemInfo item =
+ new OrderEvent.OrderCreated.OrderItemInfo(1L, 2);
+ OrderEvent.OrderCreated event = new OrderEvent.OrderCreated(
+ orderId,
+ userId,
+ null, // couponCode
+ null, // subtotal
+ null, // usedPointAmount
+ List.of(item),
+ LocalDateTime.now()
+ );
+
+ // act
+ rankingEventHandler.handleOrderCreated(event);
+
+ // assert
+ verify(rankingService, never()).addOrderScore(any(), any(), anyDouble());
+ }
+
+ @DisplayName("상품 조회 이벤트를 처리할 수 있다.")
+ @Test
+ void canHandleProductViewed() {
+ // arrange
+ Long productId = 1L;
+ Long userId = 100L;
+ ProductEvent.ProductViewed event = new ProductEvent.ProductViewed(productId, userId, LocalDateTime.now());
+
+ // act
+ rankingEventHandler.handleProductViewed(event);
+
+ // assert
+ verify(rankingService).addViewScore(eq(productId), any(LocalDate.class));
+ }
+}
+
diff --git a/apps/commerce-streamer/src/test/java/com/loopers/application/ranking/RankingServiceTest.java b/apps/commerce-streamer/src/test/java/com/loopers/application/ranking/RankingServiceTest.java
new file mode 100644
index 000000000..ed3e67e23
--- /dev/null
+++ b/apps/commerce-streamer/src/test/java/com/loopers/application/ranking/RankingServiceTest.java
@@ -0,0 +1,296 @@
+package com.loopers.application.ranking;
+
+import com.loopers.zset.RedisZSetTemplate;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.time.Duration;
+import java.time.LocalDate;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.*;
+
+/**
+ * RankingService 테스트.
+ */
+@ExtendWith(MockitoExtension.class)
+class RankingServiceTest {
+
+ @Mock
+ private RedisZSetTemplate zSetTemplate;
+
+ @Mock
+ private RankingKeyGenerator keyGenerator;
+
+ @InjectMocks
+ private RankingService rankingService;
+
+ @DisplayName("조회 점수를 ZSET에 추가할 수 있다.")
+ @Test
+ void canAddViewScore() {
+ // arrange
+ Long productId = 1L;
+ LocalDate date = LocalDate.of(2024, 12, 15);
+ String expectedKey = "ranking:all:20241215";
+ double expectedScore = 0.1; // VIEW_WEIGHT
+
+ when(keyGenerator.generateDailyKey(date)).thenReturn(expectedKey);
+
+ // act
+ rankingService.addViewScore(productId, date);
+
+ // assert
+ verify(keyGenerator).generateDailyKey(date);
+ verify(zSetTemplate).incrementScore(eq(expectedKey), eq(String.valueOf(productId)), eq(expectedScore));
+ verify(zSetTemplate).setTtlIfNotExists(eq(expectedKey), eq(Duration.ofDays(2)));
+ }
+
+ @DisplayName("좋아요 추가 시 점수를 ZSET에 추가할 수 있다.")
+ @Test
+ void canAddLikeScore_whenAdded() {
+ // arrange
+ Long productId = 1L;
+ LocalDate date = LocalDate.of(2024, 12, 15);
+ String expectedKey = "ranking:all:20241215";
+ double expectedScore = 0.2; // LIKE_WEIGHT
+ boolean isAdded = true;
+
+ when(keyGenerator.generateDailyKey(date)).thenReturn(expectedKey);
+
+ // act
+ rankingService.addLikeScore(productId, date, isAdded);
+
+ // assert
+ verify(keyGenerator).generateDailyKey(date);
+ verify(zSetTemplate).incrementScore(eq(expectedKey), eq(String.valueOf(productId)), eq(expectedScore));
+ verify(zSetTemplate).setTtlIfNotExists(eq(expectedKey), eq(Duration.ofDays(2)));
+ }
+
+ @DisplayName("좋아요 취소 시 점수를 ZSET에서 차감할 수 있다.")
+ @Test
+ void canSubtractLikeScore_whenRemoved() {
+ // arrange
+ Long productId = 1L;
+ LocalDate date = LocalDate.of(2024, 12, 15);
+ String expectedKey = "ranking:all:20241215";
+ double expectedScore = -0.2; // -LIKE_WEIGHT
+ boolean isAdded = false;
+
+ when(keyGenerator.generateDailyKey(date)).thenReturn(expectedKey);
+
+ // act
+ rankingService.addLikeScore(productId, date, isAdded);
+
+ // assert
+ verify(keyGenerator).generateDailyKey(date);
+ verify(zSetTemplate).incrementScore(eq(expectedKey), eq(String.valueOf(productId)), eq(expectedScore));
+ verify(zSetTemplate).setTtlIfNotExists(eq(expectedKey), eq(Duration.ofDays(2)));
+ }
+
+ @DisplayName("주문 점수를 ZSET에 추가할 수 있다.")
+ @Test
+ void canAddOrderScore() {
+ // arrange
+ Long productId = 1L;
+ LocalDate date = LocalDate.of(2024, 12, 15);
+ String expectedKey = "ranking:all:20241215";
+ double orderAmount = 10000.0;
+ // 정규화: log(1 + orderAmount) * ORDER_WEIGHT
+ // log(1 + 10000) ≈ 9.2103, 9.2103 * 0.6 ≈ 5.526
+ double expectedScore = Math.log1p(orderAmount) * 0.6; // ORDER_WEIGHT = 0.6
+
+ when(keyGenerator.generateDailyKey(date)).thenReturn(expectedKey);
+
+ // act
+ rankingService.addOrderScore(productId, date, orderAmount);
+
+ // assert
+ verify(keyGenerator).generateDailyKey(date);
+ verify(zSetTemplate).incrementScore(eq(expectedKey), eq(String.valueOf(productId)), eq(expectedScore));
+ verify(zSetTemplate).setTtlIfNotExists(eq(expectedKey), eq(Duration.ofDays(2)));
+ }
+
+ @DisplayName("주문 금액이 0일 때도 정상적으로 처리된다.")
+ @Test
+ void canAddOrderScore_whenOrderAmountIsZero() {
+ // arrange
+ Long productId = 1L;
+ LocalDate date = LocalDate.of(2024, 12, 15);
+ String expectedKey = "ranking:all:20241215";
+ double orderAmount = 0.0;
+ double expectedScore = Math.log1p(orderAmount) * 0.6; // log(1) * 0.6 = 0
+
+ when(keyGenerator.generateDailyKey(date)).thenReturn(expectedKey);
+
+ // act
+ rankingService.addOrderScore(productId, date, orderAmount);
+
+ // assert
+ verify(keyGenerator).generateDailyKey(date);
+ verify(zSetTemplate).incrementScore(eq(expectedKey), eq(String.valueOf(productId)), eq(expectedScore));
+ verify(zSetTemplate).setTtlIfNotExists(eq(expectedKey), eq(Duration.ofDays(2)));
+ }
+
+ @DisplayName("배치로 여러 상품의 점수를 한 번에 적재할 수 있다.")
+ @Test
+ void canAddScoresBatch() {
+ // arrange
+ LocalDate date = LocalDate.of(2024, 12, 15);
+ String expectedKey = "ranking:all:20241215";
+
+ Map scoreMap = new HashMap<>();
+ scoreMap.put(1L, 10.5);
+ scoreMap.put(2L, 20.3);
+ scoreMap.put(3L, 15.7);
+
+ when(keyGenerator.generateDailyKey(date)).thenReturn(expectedKey);
+
+ // act
+ rankingService.addScoresBatch(scoreMap, date);
+
+ // assert
+ verify(keyGenerator).generateDailyKey(date);
+
+ // 각 상품에 대해 incrementScore 호출 확인
+ verify(zSetTemplate).incrementScore(eq(expectedKey), eq("1"), eq(10.5));
+ verify(zSetTemplate).incrementScore(eq(expectedKey), eq("2"), eq(20.3));
+ verify(zSetTemplate).incrementScore(eq(expectedKey), eq("3"), eq(15.7));
+
+ // TTL 설정은 한 번만 호출
+ verify(zSetTemplate, times(1)).setTtlIfNotExists(eq(expectedKey), eq(Duration.ofDays(2)));
+ }
+
+ @DisplayName("빈 맵을 배치로 적재할 때는 아무 작업도 수행하지 않는다.")
+ @Test
+ void doesNothing_whenBatchIsEmpty() {
+ // arrange
+ LocalDate date = LocalDate.of(2024, 12, 15);
+ Map emptyScoreMap = new HashMap<>();
+
+ // act
+ rankingService.addScoresBatch(emptyScoreMap, date);
+
+ // assert
+ verify(keyGenerator, never()).generateDailyKey(any());
+ verify(zSetTemplate, never()).incrementScore(anyString(), anyString(), anyDouble());
+ verify(zSetTemplate, never()).setTtlIfNotExists(anyString(), any(Duration.class));
+ }
+
+ @DisplayName("여러 날짜에 대해 독립적으로 점수를 추가할 수 있다.")
+ @Test
+ void canAddScoresForDifferentDates() {
+ // arrange
+ Long productId = 1L;
+ LocalDate date1 = LocalDate.of(2024, 12, 15);
+ LocalDate date2 = LocalDate.of(2024, 12, 16);
+ String key1 = "ranking:all:20241215";
+ String key2 = "ranking:all:20241216";
+
+ when(keyGenerator.generateDailyKey(date1)).thenReturn(key1);
+ when(keyGenerator.generateDailyKey(date2)).thenReturn(key2);
+
+ // act
+ rankingService.addViewScore(productId, date1);
+ rankingService.addViewScore(productId, date2);
+
+ // assert
+ verify(keyGenerator).generateDailyKey(date1);
+ verify(keyGenerator).generateDailyKey(date2);
+ verify(zSetTemplate).incrementScore(eq(key1), eq(String.valueOf(productId)), eq(0.1));
+ verify(zSetTemplate).incrementScore(eq(key2), eq(String.valueOf(productId)), eq(0.1));
+ verify(zSetTemplate).setTtlIfNotExists(eq(key1), eq(Duration.ofDays(2)));
+ verify(zSetTemplate).setTtlIfNotExists(eq(key2), eq(Duration.ofDays(2)));
+ }
+
+ @DisplayName("같은 상품에 여러 이벤트를 추가하면 점수가 누적된다.")
+ @Test
+ void accumulatesScoresForSameProduct() {
+ // arrange
+ Long productId = 1L;
+ LocalDate date = LocalDate.of(2024, 12, 15);
+ String expectedKey = "ranking:all:20241215";
+
+ when(keyGenerator.generateDailyKey(date)).thenReturn(expectedKey);
+
+ // act
+ rankingService.addViewScore(productId, date); // +0.1
+ rankingService.addLikeScore(productId, date, true); // +0.2
+ rankingService.addOrderScore(productId, date, 1000.0); // +log(1001) * 0.6
+
+ // assert
+ verify(keyGenerator, times(3)).generateDailyKey(date);
+
+ // 각 이벤트별로 incrementScore 호출 확인
+ verify(zSetTemplate).incrementScore(eq(expectedKey), eq(String.valueOf(productId)), eq(0.1));
+ verify(zSetTemplate).incrementScore(eq(expectedKey), eq(String.valueOf(productId)), eq(0.2));
+
+ ArgumentCaptor scoreCaptor = ArgumentCaptor.forClass(Double.class);
+ verify(zSetTemplate, times(3)).incrementScore(eq(expectedKey), eq(String.valueOf(productId)), scoreCaptor.capture());
+
+ // 주문 점수 계산 확인
+ double orderScore = scoreCaptor.getAllValues().get(2);
+ double expectedOrderScore = Math.log1p(1000.0) * 0.6;
+ assertThat(orderScore).isCloseTo(expectedOrderScore, org.assertj.core.data.Offset.offset(0.001));
+
+ // TTL 설정은 각 호출마다 수행됨 (incrementScore 내부에서 호출)
+ verify(zSetTemplate, times(3)).setTtlIfNotExists(eq(expectedKey), eq(Duration.ofDays(2)));
+ }
+
+ @DisplayName("Score Carry-Over로 오늘 랭킹을 내일 랭킹에 반영할 수 있다.")
+ @Test
+ void canCarryOverScore() {
+ // arrange
+ LocalDate today = LocalDate.of(2024, 12, 15);
+ LocalDate tomorrow = LocalDate.of(2024, 12, 16);
+ String todayKey = "ranking:all:20241215";
+ String tomorrowKey = "ranking:all:20241216";
+ double carryOverWeight = 0.1; // 10%
+
+ when(keyGenerator.generateDailyKey(today)).thenReturn(todayKey);
+ when(keyGenerator.generateDailyKey(tomorrow)).thenReturn(tomorrowKey);
+ when(zSetTemplate.unionStoreWithWeight(eq(tomorrowKey), eq(todayKey), eq(carryOverWeight)))
+ .thenReturn(50L);
+
+ // act
+ Long result = rankingService.carryOverScore(today, tomorrow, carryOverWeight);
+
+ // assert
+ assertThat(result).isEqualTo(50L);
+ verify(keyGenerator).generateDailyKey(today);
+ verify(keyGenerator).generateDailyKey(tomorrow);
+ verify(zSetTemplate).unionStoreWithWeight(eq(tomorrowKey), eq(todayKey), eq(carryOverWeight));
+ verify(zSetTemplate).setTtlIfNotExists(eq(tomorrowKey), eq(Duration.ofDays(2)));
+ }
+
+ @DisplayName("Score Carry-Over 가중치가 0일 때도 정상적으로 처리된다.")
+ @Test
+ void canCarryOverScore_withZeroWeight() {
+ // arrange
+ LocalDate today = LocalDate.of(2024, 12, 15);
+ LocalDate tomorrow = LocalDate.of(2024, 12, 16);
+ String todayKey = "ranking:all:20241215";
+ String tomorrowKey = "ranking:all:20241216";
+ double carryOverWeight = 0.0;
+
+ when(keyGenerator.generateDailyKey(today)).thenReturn(todayKey);
+ when(keyGenerator.generateDailyKey(tomorrow)).thenReturn(tomorrowKey);
+ when(zSetTemplate.unionStoreWithWeight(eq(tomorrowKey), eq(todayKey), eq(carryOverWeight)))
+ .thenReturn(0L);
+
+ // act
+ Long result = rankingService.carryOverScore(today, tomorrow, carryOverWeight);
+
+ // assert
+ assertThat(result).isEqualTo(0L);
+ verify(zSetTemplate).unionStoreWithWeight(eq(tomorrowKey), eq(todayKey), eq(carryOverWeight));
+ }
+}
diff --git a/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/RankingConsumerTest.java b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/RankingConsumerTest.java
new file mode 100644
index 000000000..67485df03
--- /dev/null
+++ b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/RankingConsumerTest.java
@@ -0,0 +1,450 @@
+package com.loopers.interfaces.consumer;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.loopers.application.eventhandled.EventHandledService;
+import com.loopers.domain.event.LikeEvent;
+import com.loopers.domain.event.OrderEvent;
+import com.loopers.domain.event.ProductEvent;
+import org.apache.kafka.clients.consumer.ConsumerRecord;
+import org.apache.kafka.common.header.Headers;
+import org.apache.kafka.common.header.internals.RecordHeader;
+import org.apache.kafka.common.header.internals.RecordHeaders;
+import org.apache.kafka.common.record.TimestampType;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.dao.DataIntegrityViolationException;
+import org.springframework.kafka.support.Acknowledgment;
+
+import java.nio.charset.StandardCharsets;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Optional;
+
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * RankingConsumer 테스트.
+ */
+@ExtendWith(MockitoExtension.class)
+class RankingConsumerTest {
+
+ @Mock
+ private ApplicationEventPublisher applicationEventPublisher;
+
+ @Mock
+ private EventHandledService eventHandledService;
+
+ @Mock
+ private ObjectMapper objectMapper;
+
+ @Mock
+ private Acknowledgment acknowledgment;
+
+ @InjectMocks
+ private RankingConsumer rankingConsumer;
+
+ @DisplayName("LikeAdded 이벤트를 처리할 수 있다.")
+ @Test
+ void canConsumeLikeAddedEvent() {
+ // arrange
+ String eventId = "test-event-id";
+ Long productId = 1L;
+ Long userId = 100L;
+ LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now());
+
+ Headers headers = new RecordHeaders();
+ headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8)));
+
+ ConsumerRecord record = new ConsumerRecord<>(
+ "like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty()
+ );
+ List> records = List.of(record);
+
+ when(eventHandledService.isAlreadyHandled(eventId)).thenReturn(false);
+
+ // act
+ rankingConsumer.consumeLikeEvents(records, acknowledgment);
+
+ // assert
+ verify(eventHandledService).isAlreadyHandled(eventId);
+ verify(applicationEventPublisher).publishEvent(any(LikeEvent.LikeAdded.class));
+ verify(eventHandledService).markAsHandled(eventId, "LikeAdded", "like-events");
+ verify(acknowledgment).acknowledge();
+ }
+
+ @DisplayName("LikeRemoved 이벤트를 처리할 수 있다.")
+ @Test
+ void canConsumeLikeRemovedEvent() {
+ // arrange
+ String eventId = "test-event-id-2";
+ Long productId = 1L;
+ Long userId = 100L;
+ LikeEvent.LikeRemoved event = new LikeEvent.LikeRemoved(userId, productId, LocalDateTime.now());
+
+ Headers headers = new RecordHeaders();
+ headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8)));
+ headers.add(new RecordHeader("eventType", "LikeRemoved".getBytes(StandardCharsets.UTF_8)));
+
+ ConsumerRecord record = new ConsumerRecord<>(
+ "like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty()
+ );
+ List> records = List.of(record);
+
+ when(eventHandledService.isAlreadyHandled(eventId)).thenReturn(false);
+
+ // act
+ rankingConsumer.consumeLikeEvents(records, acknowledgment);
+
+ // assert
+ verify(eventHandledService).isAlreadyHandled(eventId);
+ verify(applicationEventPublisher).publishEvent(any(LikeEvent.LikeRemoved.class));
+ verify(eventHandledService).markAsHandled(eventId, "LikeRemoved", "like-events");
+ verify(acknowledgment).acknowledge();
+ }
+
+ @DisplayName("OrderCreated 이벤트를 처리할 수 있다.")
+ @Test
+ void canConsumeOrderCreatedEvent() {
+ // arrange
+ String eventId = "test-event-id-3";
+ Long orderId = 1L;
+ Long userId = 100L;
+ Long productId1 = 1L;
+ Long productId2 = 2L;
+
+ List orderItems = List.of(
+ new OrderEvent.OrderCreated.OrderItemInfo(productId1, 3),
+ new OrderEvent.OrderCreated.OrderItemInfo(productId2, 2)
+ );
+
+ OrderEvent.OrderCreated event = new OrderEvent.OrderCreated(
+ orderId, userId, null, 10000, 0L, orderItems, LocalDateTime.now()
+ );
+
+ Headers headers = new RecordHeaders();
+ headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8)));
+
+ ConsumerRecord record = new ConsumerRecord<>(
+ "order-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty()
+ );
+ List> records = List.of(record);
+
+ when(eventHandledService.isAlreadyHandled(eventId)).thenReturn(false);
+
+ // act
+ rankingConsumer.consumeOrderEvents(records, acknowledgment);
+
+ // assert
+ verify(eventHandledService).isAlreadyHandled(eventId);
+ verify(applicationEventPublisher).publishEvent(any(OrderEvent.OrderCreated.class));
+ verify(eventHandledService).markAsHandled(eventId, "OrderCreated", "order-events");
+ verify(acknowledgment).acknowledge();
+ }
+
+ @DisplayName("ProductViewed 이벤트를 처리할 수 있다.")
+ @Test
+ void canConsumeProductViewedEvent() {
+ // arrange
+ String eventId = "test-event-id-4";
+ Long productId = 1L;
+ Long userId = 100L;
+ ProductEvent.ProductViewed event = new ProductEvent.ProductViewed(productId, userId, LocalDateTime.now());
+
+ Headers headers = new RecordHeaders();
+ headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8)));
+
+ ConsumerRecord record = new ConsumerRecord<>(
+ "product-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty()
+ );
+ List> records = List.of(record);
+
+ when(eventHandledService.isAlreadyHandled(eventId)).thenReturn(false);
+
+ // act
+ rankingConsumer.consumeProductEvents(records, acknowledgment);
+
+ // assert
+ verify(eventHandledService).isAlreadyHandled(eventId);
+ verify(applicationEventPublisher).publishEvent(any(ProductEvent.ProductViewed.class));
+ verify(eventHandledService).markAsHandled(eventId, "ProductViewed", "product-events");
+ verify(acknowledgment).acknowledge();
+ }
+
+ @DisplayName("배치로 여러 이벤트를 처리할 수 있다.")
+ @Test
+ void canConsumeMultipleEvents() {
+ // arrange
+ String eventId1 = "test-event-id-5";
+ String eventId2 = "test-event-id-6";
+ Long productId = 1L;
+ Long userId = 100L;
+
+ LikeEvent.LikeAdded event1 = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now());
+ ProductEvent.ProductViewed event2 = new ProductEvent.ProductViewed(productId, userId, LocalDateTime.now());
+
+ Headers headers1 = new RecordHeaders();
+ headers1.add(new RecordHeader("eventId", eventId1.getBytes(StandardCharsets.UTF_8)));
+ Headers headers2 = new RecordHeaders();
+ headers2.add(new RecordHeader("eventId", eventId2.getBytes(StandardCharsets.UTF_8)));
+
+ List> records = List.of(
+ new ConsumerRecord<>("like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event1, headers1, Optional.empty()),
+ new ConsumerRecord<>("product-events", 0, 1L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event2, headers2, Optional.empty())
+ );
+
+ when(eventHandledService.isAlreadyHandled(eventId1)).thenReturn(false);
+ when(eventHandledService.isAlreadyHandled(eventId2)).thenReturn(false);
+
+ // act
+ rankingConsumer.consumeLikeEvents(List.of(records.get(0)), acknowledgment);
+ rankingConsumer.consumeProductEvents(List.of(records.get(1)), acknowledgment);
+
+ // assert
+ verify(eventHandledService).isAlreadyHandled(eventId1);
+ verify(eventHandledService).isAlreadyHandled(eventId2);
+ verify(applicationEventPublisher).publishEvent(any(LikeEvent.LikeAdded.class));
+ verify(applicationEventPublisher).publishEvent(any(ProductEvent.ProductViewed.class));
+ verify(eventHandledService).markAsHandled(eventId1, "LikeAdded", "like-events");
+ verify(eventHandledService).markAsHandled(eventId2, "ProductViewed", "product-events");
+ verify(acknowledgment, times(2)).acknowledge();
+ }
+
+ @DisplayName("이미 처리된 이벤트는 스킵한다.")
+ @Test
+ void skipsAlreadyHandledEvent() {
+ // arrange
+ String eventId = "test-event-id";
+ Long productId = 1L;
+ Long userId = 100L;
+ LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now());
+
+ Headers headers = new RecordHeaders();
+ headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8)));
+
+ ConsumerRecord record = new ConsumerRecord<>(
+ "like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty()
+ );
+ List> records = List.of(record);
+
+ when(eventHandledService.isAlreadyHandled(eventId)).thenReturn(true);
+
+ // act
+ rankingConsumer.consumeLikeEvents(records, acknowledgment);
+
+ // assert
+ verify(eventHandledService).isAlreadyHandled(eventId);
+ verify(applicationEventPublisher, never()).publishEvent(any());
+ verify(eventHandledService, never()).markAsHandled(any(), any(), any());
+ verify(acknowledgment).acknowledge();
+ }
+
+ @DisplayName("eventId가 없는 메시지는 건너뛴다.")
+ @Test
+ void skipsEventWithoutEventId() {
+ // arrange
+ Long productId = 1L;
+ Long userId = 100L;
+ LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now());
+
+ ConsumerRecord record = new ConsumerRecord<>(
+ "like-events", 0, 0L, "key", event
+ );
+ List> records = List.of(record);
+
+ // act
+ rankingConsumer.consumeLikeEvents(records, acknowledgment);
+
+ // assert
+ verify(eventHandledService, never()).isAlreadyHandled(any());
+ verify(applicationEventPublisher, never()).publishEvent(any());
+ verify(acknowledgment).acknowledge();
+ }
+
+ @DisplayName("개별 이벤트 처리 실패 시에도 배치 처리를 계속한다.")
+ @Test
+ void continuesProcessing_whenIndividualEventFails() {
+ // arrange
+ String eventId1 = "test-event-id-7";
+ String eventId2 = "test-event-id-8";
+ Long productId = 1L;
+ Long userId = 100L;
+
+ LikeEvent.LikeAdded validEvent = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now());
+ Object invalidEvent = "invalid-event";
+
+ Headers headers1 = new RecordHeaders();
+ headers1.add(new RecordHeader("eventId", eventId1.getBytes(StandardCharsets.UTF_8)));
+ Headers headers2 = new RecordHeaders();
+ headers2.add(new RecordHeader("eventId", eventId2.getBytes(StandardCharsets.UTF_8)));
+
+ when(eventHandledService.isAlreadyHandled(eventId1)).thenReturn(false);
+ when(eventHandledService.isAlreadyHandled(eventId2)).thenReturn(false);
+
+ List> records = List.of(
+ new ConsumerRecord<>("like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", invalidEvent, headers1, Optional.empty()),
+ new ConsumerRecord<>("like-events", 0, 1L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", validEvent, headers2, Optional.empty())
+ );
+
+ // act
+ rankingConsumer.consumeLikeEvents(records, acknowledgment);
+
+ // assert
+ verify(eventHandledService).isAlreadyHandled(eventId1);
+ verify(eventHandledService).isAlreadyHandled(eventId2);
+ // 첫 번째 이벤트는 파싱 실패로 publishEvent가 호출되지 않음
+ // 두 번째 이벤트는 정상적으로 publishEvent가 호출됨
+ verify(applicationEventPublisher, times(1)).publishEvent(any(LikeEvent.LikeAdded.class));
+ verify(acknowledgment).acknowledge();
+ }
+
+ @DisplayName("동시성 상황에서 DataIntegrityViolationException이 발생하면 정상 처리로 간주한다.")
+ @Test
+ void handlesDataIntegrityViolationException() {
+ // arrange
+ String eventId = "test-event-id";
+ Long productId = 1L;
+ Long userId = 100L;
+ LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now());
+
+ Headers headers = new RecordHeaders();
+ headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8)));
+
+ ConsumerRecord record = new ConsumerRecord<>(
+ "like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty()
+ );
+ List> records = List.of(record);
+
+ when(eventHandledService.isAlreadyHandled(eventId)).thenReturn(false);
+ doThrow(new DataIntegrityViolationException("UNIQUE constraint violation"))
+ .when(eventHandledService).markAsHandled(eventId, "LikeAdded", "like-events");
+
+ // act
+ rankingConsumer.consumeLikeEvents(records, acknowledgment);
+
+ // assert
+ verify(eventHandledService).isAlreadyHandled(eventId);
+ verify(applicationEventPublisher).publishEvent(any(LikeEvent.LikeAdded.class));
+ verify(eventHandledService).markAsHandled(eventId, "LikeAdded", "like-events");
+ verify(acknowledgment).acknowledge();
+ }
+
+ @DisplayName("주문 이벤트에서 totalQuantity가 0이면 점수를 추가하지 않는다.")
+ @Test
+ void doesNotAddScore_whenTotalQuantityIsZero() {
+ // arrange
+ String eventId = "test-event-id-9";
+ Long orderId = 1L;
+ Long userId = 100L;
+
+ List orderItems = List.of(
+ new OrderEvent.OrderCreated.OrderItemInfo(1L, 0)
+ );
+
+ OrderEvent.OrderCreated event = new OrderEvent.OrderCreated(
+ orderId, userId, null, 0, 0L, orderItems, LocalDateTime.now()
+ );
+
+ Headers headers = new RecordHeaders();
+ headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8)));
+
+ ConsumerRecord record = new ConsumerRecord<>(
+ "order-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty()
+ );
+ List> records = List.of(record);
+
+ when(eventHandledService.isAlreadyHandled(eventId)).thenReturn(false);
+
+ // act
+ rankingConsumer.consumeOrderEvents(records, acknowledgment);
+
+ // assert
+ verify(eventHandledService).isAlreadyHandled(eventId);
+ verify(applicationEventPublisher).publishEvent(any(OrderEvent.OrderCreated.class));
+ verify(eventHandledService).markAsHandled(eventId, "OrderCreated", "order-events");
+ verify(acknowledgment).acknowledge();
+ }
+
+ @DisplayName("주문 이벤트에서 subtotal이 null이면 점수를 추가하지 않는다.")
+ @Test
+ void doesNotAddScore_whenSubtotalIsNull() {
+ // arrange
+ String eventId = "test-event-id-10";
+ Long orderId = 1L;
+ Long userId = 100L;
+
+ List orderItems = List.of(
+ new OrderEvent.OrderCreated.OrderItemInfo(1L, 3)
+ );
+
+ OrderEvent.OrderCreated event = new OrderEvent.OrderCreated(
+ orderId, userId, null, null, 0L, orderItems, LocalDateTime.now()
+ );
+
+ Headers headers = new RecordHeaders();
+ headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8)));
+
+ ConsumerRecord record = new ConsumerRecord<>(
+ "order-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty()
+ );
+ List> records = List.of(record);
+
+ when(eventHandledService.isAlreadyHandled(eventId)).thenReturn(false);
+
+ // act
+ rankingConsumer.consumeOrderEvents(records, acknowledgment);
+
+ // assert
+ verify(eventHandledService).isAlreadyHandled(eventId);
+ verify(applicationEventPublisher).publishEvent(any(OrderEvent.OrderCreated.class));
+ verify(eventHandledService).markAsHandled(eventId, "OrderCreated", "order-events");
+ verify(acknowledgment).acknowledge();
+ }
+
+ @DisplayName("중복 메시지 재전송 시 한 번만 처리되어 멱등성이 보장된다.")
+ @Test
+ void handlesDuplicateMessagesIdempotently() {
+ // arrange
+ String eventId = "duplicate-event-id";
+ Long productId = 1L;
+ Long userId = 100L;
+ LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now());
+
+ Headers headers = new RecordHeaders();
+ headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8)));
+
+ // 동일한 eventId를 가진 메시지 3개 생성
+ List> records = List.of(
+ new ConsumerRecord<>("like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty()),
+ new ConsumerRecord<>("like-events", 0, 1L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty()),
+ new ConsumerRecord<>("like-events", 0, 2L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty())
+ );
+
+ // 첫 번째 메시지는 처리되지 않았으므로 false, 나머지는 이미 처리되었으므로 true
+ when(eventHandledService.isAlreadyHandled(eventId))
+ .thenReturn(false) // 첫 번째: 처리됨
+ .thenReturn(true) // 두 번째: 이미 처리됨 (스킵)
+ .thenReturn(true); // 세 번째: 이미 처리됨 (스킵)
+
+ // act
+ rankingConsumer.consumeLikeEvents(records, acknowledgment);
+
+ // assert
+ // isAlreadyHandled는 3번 호출됨 (각 메시지마다)
+ verify(eventHandledService, times(3)).isAlreadyHandled(eventId);
+
+ // publishEvent는 한 번만 호출되어야 함 (첫 번째 메시지만 처리)
+ verify(applicationEventPublisher, times(1)).publishEvent(any(LikeEvent.LikeAdded.class));
+
+ // markAsHandled는 한 번만 호출되어야 함 (첫 번째 메시지만 처리)
+ verify(eventHandledService, times(1)).markAsHandled(eventId, "LikeAdded", "like-events");
+
+ // acknowledgment는 한 번만 호출되어야 함 (배치 처리 완료)
+ verify(acknowledgment, times(1)).acknowledge();
+ }
+}
diff --git a/modules/redis/src/main/java/com/loopers/zset/RedisZSetTemplate.java b/modules/redis/src/main/java/com/loopers/zset/RedisZSetTemplate.java
new file mode 100644
index 000000000..0b81e46a7
--- /dev/null
+++ b/modules/redis/src/main/java/com/loopers/zset/RedisZSetTemplate.java
@@ -0,0 +1,215 @@
+package com.loopers.zset;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.core.ZSetOperations;
+import org.springframework.stereotype.Component;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Redis ZSET 템플릿.
+ *
+ * Redis Sorted Set (ZSET) 조작 기능을 제공합니다.
+ * ZSET은 Redis 전용 데이터 구조이므로 인터페이스 분리 없이 클래스로 직접 제공합니다.
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class RedisZSetTemplate {
+
+ private final RedisTemplate redisTemplate;
+
+ /**
+ * ZSET에 점수를 증가시킵니다.
+ *
+ * ZINCRBY는 원자적 연산이므로 동시성 문제가 없습니다.
+ *
+ *
+ * @param key ZSET 키
+ * @param member 멤버 (예: 상품 ID)
+ * @param score 증가시킬 점수
+ */
+ public void incrementScore(String key, String member, double score) {
+ try {
+ redisTemplate.opsForZSet().incrementScore(key, member, score);
+ } catch (Exception e) {
+ log.warn("ZSET 점수 증가 실패: key={}, member={}, score={}", key, member, score, e);
+ // Redis 연결 실패 시 로그만 기록하고 계속 진행
+ }
+ }
+
+ /**
+ * ZSET의 TTL을 설정합니다.
+ *
+ * 이미 TTL이 설정되어 있으면 설정하지 않습니다.
+ *
+ *
+ * @param key ZSET 키
+ * @param ttl TTL (Duration)
+ */
+ public void setTtlIfNotExists(String key, Duration ttl) {
+ try {
+ Long currentTtl = redisTemplate.getExpire(key);
+ if (currentTtl == null || currentTtl == -1) {
+ // TTL이 없거나 -1(만료 시간 없음)인 경우에만 설정
+ redisTemplate.expire(key, ttl);
+ }
+ } catch (Exception e) {
+ log.warn("ZSET TTL 설정 실패: key={}", key, e);
+ }
+ }
+
+ /**
+ * 특정 멤버의 순위를 조회합니다.
+ *
+ * 점수가 높은 순서대로 정렬된 순위를 반환합니다 (0부터 시작).
+ * 멤버가 없으면 null을 반환합니다.
+ *
+ *
+ * @param key ZSET 키
+ * @param member 멤버
+ * @return 순위 (0부터 시작, 없으면 null)
+ * @throws org.springframework.dao.DataAccessException Redis 접근 실패 시
+ */
+ public Long getRank(String key, String member) {
+ return redisTemplate.opsForZSet().reverseRank(key, member);
+ }
+
+ /**
+ * ZSET에서 상위 N개 멤버를 조회합니다.
+ *
+ * 점수가 높은 순서대로 정렬된 멤버와 점수를 반환합니다.
+ *
+ *
+ * @param key ZSET 키
+ * @param start 시작 인덱스 (0부터 시작)
+ * @param end 종료 인덱스 (포함)
+ * @return 멤버와 점수 쌍의 리스트
+ * @throws org.springframework.dao.DataAccessException Redis 접근 실패 시
+ */
+ public List getTopRankings(String key, long start, long end) {
+ Set> tuples = redisTemplate.opsForZSet()
+ .reverseRangeWithScores(key, start, end);
+
+ if (tuples == null) {
+ return List.of();
+ }
+
+ List entries = new ArrayList<>();
+ for (ZSetOperations.TypedTuple tuple : tuples) {
+ entries.add(new ZSetEntry(tuple.getValue(), tuple.getScore()));
+ }
+ return entries;
+ }
+
+ /**
+ * ZSET의 크기를 조회합니다.
+ *
+ * ZSET에 포함된 멤버의 총 개수를 반환합니다.
+ *
+ *
+ * @param key ZSET 키
+ * @return ZSET 크기 (없으면 0)
+ * @throws org.springframework.dao.DataAccessException Redis 접근 실패 시
+ */
+ public Long getSize(String key) {
+ Long size = redisTemplate.opsForZSet().size(key);
+ return size != null ? size : 0L;
+ }
+
+ /**
+ * 여러 ZSET을 합쳐서 새로운 ZSET을 생성합니다.
+ *
+ * ZUNIONSTORE 명령어를 사용하여 여러 소스 ZSET의 점수를 합산합니다.
+ * 같은 멤버가 여러 ZSET에 있으면 점수가 합산됩니다.
+ *
+ *
+ * 사용 사례:
+ *
+ * - 시간 단위 랭킹을 일간 랭킹으로 집계
+ * - Score Carry-Over: 오늘 랭킹을 내일 랭킹에 일부 반영
+ *
+ *
+ *
+ * @param destination 목적지 ZSET 키
+ * @param sourceKeys 소스 ZSET 키 목록
+ * @return 합쳐진 ZSET의 멤버 수
+ */
+ public Long unionStore(String destination, List sourceKeys) {
+ try {
+ if (sourceKeys.isEmpty()) {
+ log.warn("소스 키가 비어있습니다: destination={}", destination);
+ return 0L;
+ }
+
+ Long result = redisTemplate.opsForZSet().unionAndStore(
+ sourceKeys.get(0),
+ sourceKeys.subList(1, sourceKeys.size()),
+ destination
+ );
+ return result != null ? result : 0L;
+ } catch (Exception e) {
+ log.warn("ZSET 합치기 실패: destination={}, sourceKeys={}", destination, sourceKeys, e);
+ return 0L;
+ }
+ }
+
+ /**
+ * 단일 ZSET을 가중치를 적용하여 목적지 ZSET에 합산합니다.
+ *
+ * 소스 ZSET의 점수에 가중치를 곱한 후 목적지 ZSET에 합산합니다.
+ * 목적지 ZSET이 이미 존재하면 기존 점수에 합산됩니다.
+ *
+ *
+ * 사용 사례:
+ *
+ * - Score Carry-Over: 오늘 랭킹을 0.1 배율로 내일 랭킹에 반영
+ *
+ *
+ *
+ * @param destination 목적지 ZSET 키
+ * @param sourceKey 소스 ZSET 키
+ * @param weight 가중치 (예: 0.1 = 10%)
+ * @return 합쳐진 ZSET의 멤버 수
+ */
+ public Long unionStoreWithWeight(String destination, String sourceKey, double weight) {
+ try {
+ // ZUNIONSTORE를 사용하여 가중치 적용
+ // destination과 sourceKey를 합치되, sourceKey에만 가중치 적용
+ // 이를 위해 임시 키를 사용하거나 직접 구현
+
+ // 방법: sourceKey의 모든 멤버를 읽어서 가중치를 적용한 후 destination에 추가
+ Set> sourceMembers = redisTemplate.opsForZSet()
+ .rangeWithScores(sourceKey, 0, -1);
+
+ if (sourceMembers == null || sourceMembers.isEmpty()) {
+ return 0L;
+ }
+
+ // 가중치를 적용하여 destination에 추가
+ for (ZSetOperations.TypedTuple tuple : sourceMembers) {
+ String member = tuple.getValue();
+ Double originalScore = tuple.getScore();
+ if (member != null && originalScore != null) {
+ double weightedScore = originalScore * weight;
+ redisTemplate.opsForZSet().incrementScore(destination, member, weightedScore);
+ }
+ }
+
+ return (long) sourceMembers.size();
+ } catch (Exception e) {
+ log.warn("ZSET 가중치 합치기 실패: destination={}, sourceKey={}, weight={}",
+ destination, sourceKey, weight, e);
+ return 0L;
+ }
+ }
+}
diff --git a/modules/redis/src/main/java/com/loopers/zset/ZSetEntry.java b/modules/redis/src/main/java/com/loopers/zset/ZSetEntry.java
new file mode 100644
index 000000000..0c9642503
--- /dev/null
+++ b/modules/redis/src/main/java/com/loopers/zset/ZSetEntry.java
@@ -0,0 +1,12 @@
+package com.loopers.zset;
+
+/**
+ * ZSET 엔트리 (멤버와 점수 쌍).
+ *
+ * @param member 멤버
+ * @param score 점수
+ * @author Loopers
+ * @version 1.0
+ */
+public record ZSetEntry(String member, Double score) {
+}