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하여 제공합니다. + *

+ *

+ * 설계 원칙: + *

+ *

+ * + * @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: + *

+ *

+ * + * @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) { +}