From 2f797fb6d329ad94f04a2a320c695490adbb87d7 Mon Sep 17 00:00:00 2001 From: Seoyeon Lee <68765200+sylee6529@users.noreply.github.com> Date: Fri, 26 Dec 2025 13:01:50 +0900 Subject: [PATCH 01/17] =?UTF-8?q?feat:=20=EB=9E=AD=ED=82=B9=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RankingConfig: 이벤트별 가중치 설정 (view: 0.1, like: 0.2, order: 0.6) - TTL 설정 (2일) --- .../com/loopers/config/RankingConfig.java | 24 +++++++++++++++++++ .../src/main/resources/application.yml | 7 ++++++ 2 files changed, 31 insertions(+) create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/config/RankingConfig.java diff --git a/apps/commerce-streamer/src/main/java/com/loopers/config/RankingConfig.java b/apps/commerce-streamer/src/main/java/com/loopers/config/RankingConfig.java new file mode 100644 index 000000000..180e89113 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/config/RankingConfig.java @@ -0,0 +1,24 @@ +package com.loopers.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Getter +@Setter +@Configuration +@ConfigurationProperties(prefix = "ranking") +public class RankingConfig { + + private Weight weight = new Weight(); + private int ttlDays = 2; + + @Getter + @Setter + public static class Weight { + private double view = 0.1; + private double like = 0.2; + private double order = 0.6; + } +} diff --git a/apps/commerce-streamer/src/main/resources/application.yml b/apps/commerce-streamer/src/main/resources/application.yml index 0651bc2bd..a031868da 100644 --- a/apps/commerce-streamer/src/main/resources/application.yml +++ b/apps/commerce-streamer/src/main/resources/application.yml @@ -29,6 +29,13 @@ demo-kafka: test: topic-name: demo.internal.topic-v1 +ranking: + weight: + view: 0.1 + like: 0.2 + order: 0.6 + ttl-days: 2 + --- spring: config: From d31356038d6300a5725a1fc2984da3100e9ab69a Mon Sep 17 00:00:00 2001 From: Seoyeon Lee <68765200+sylee6529@users.noreply.github.com> Date: Fri, 26 Dec 2025 13:02:13 +0900 Subject: [PATCH 02/17] =?UTF-8?q?feat:=20=EB=9E=AD=ED=82=B9=20ZSET=20?= =?UTF-8?q?=EC=BA=90=EC=8B=9C=20=EA=B5=AC=ED=98=84=20(commerce-streamer)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProductRankingCache: ZINCRBY로 점수 적재 - CacheKeyGenerator: 일간 랭킹 키 생성 (ranking:all:v1:{date}) - TTL race condition 방지 (AtomicReference) - 주문 점수 log 정규화 - Redis Pipeline 배치 처리 지원 --- .../cache/CacheKeyGenerator.java | 19 ++ .../cache/ProductRankingCache.java | 201 ++++++++++++++++++ 2 files changed, 220 insertions(+) create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/cache/CacheKeyGenerator.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/cache/ProductRankingCache.java diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/cache/CacheKeyGenerator.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/cache/CacheKeyGenerator.java new file mode 100644 index 000000000..0b9887daf --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/cache/CacheKeyGenerator.java @@ -0,0 +1,19 @@ +package com.loopers.infrastructure.cache; + +/** + * Redis 캐시 키 생성기 (commerce-streamer) + * commerce-api의 CacheKeyGenerator와 동일한 키 형식을 사용 + */ +public class CacheKeyGenerator { + + private static final String VERSION = "v1"; + + /** + * Daily ranking ZSET key + * 형식: ranking:all:v1:{yyyyMMdd} + * 예: ranking:all:v1:20251224 + */ + public static String dailyRankingKey(String date) { + return String.format("ranking:all:%s:%s", VERSION, date); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/cache/ProductRankingCache.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/cache/ProductRankingCache.java new file mode 100644 index 000000000..bc96bfbaf --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/cache/ProductRankingCache.java @@ -0,0 +1,201 @@ +package com.loopers.infrastructure.cache; + +import com.loopers.config.RankingConfig; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +import org.springframework.dao.DataAccessException; +import org.springframework.data.redis.core.RedisOperations; +import org.springframework.data.redis.core.SessionCallback; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +/** + * 상품 랭킹 ZSET 캐시 (적재용 - commerce-streamer) + * Key: ranking:all:v1:{yyyyMMdd} + * Member: productId + * Score: 가중치 적용된 점수 + */ +@Slf4j +@Component +public class ProductRankingCache { + + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + private static final ZoneId ZONE_ID = ZoneId.of("Asia/Seoul"); + + private final RedisTemplate cacheRedisTemplate; + private final RankingConfig rankingConfig; + + /** + * TTL이 설정된 키를 추적 (Race Condition 방지) + * 날짜가 바뀌면 자동으로 새 키에 대해 TTL 설정 + */ + private final AtomicReference ttlInitializedKey = new AtomicReference<>(); + + public ProductRankingCache(RedisTemplate cacheRedisTemplate, RankingConfig rankingConfig) { + this.cacheRedisTemplate = cacheRedisTemplate; + this.rankingConfig = rankingConfig; + } + + /** + * 조회 이벤트 점수 추가 + */ + public void addViewScore(Long productId) { + double score = rankingConfig.getWeight().getView(); + incrementScore(productId, score); + log.debug("[Ranking] View score added - productId: {}, score: {}", productId, score); + } + + /** + * 좋아요 이벤트 점수 추가 + */ + public void addLikeScore(Long productId) { + double score = rankingConfig.getWeight().getLike(); + incrementScore(productId, score); + log.debug("[Ranking] Like score added - productId: {}, score: {}", productId, score); + } + + /** + * 좋아요 취소 이벤트 점수 감소 + * - 좋아요 추가와 동일한 가중치만큼 감소 + * - 점수가 0 이하가 되어도 ZSET에서 자동 제거되지 않음 (일관성 유지) + */ + public void subtractLikeScore(Long productId) { + double score = rankingConfig.getWeight().getLike(); + decrementScore(productId, score); + log.debug("[Ranking] Like score subtracted - productId: {}, score: -{}", productId, score); + } + + /** + * 주문 이벤트 점수 추가 + * - log 정규화를 적용하여 고가 상품의 과도한 점수 편중 방지 + * - Math.log1p(x) = ln(1 + x)로 안전하게 계산 + * + * @param productId 상품 ID + * @param price 상품 단가 + * @param quantity 주문 수량 + */ + public void addOrderScore(Long productId, long price, int quantity) { + double weight = rankingConfig.getWeight().getOrder(); + long orderAmount = price * quantity; + // log 정규화: 가격 스케일 차이를 완화하여 공정한 랭킹 반영 + // 예: 100,000원 → log1p(100000) ≈ 11.5, 1,000원 → log1p(1000) ≈ 6.9 + double score = weight * Math.log1p(orderAmount); + incrementScore(productId, score); + log.debug("[Ranking] Order score added - productId: {}, price: {}, qty: {}, amount: {}, score: {}", + productId, price, quantity, orderAmount, score); + } + + /** + * 주문 아이템 배치 점수 추가 (Pipeline 사용) + * - 여러 아이템을 한 번의 Redis 요청으로 처리하여 네트워크 오버헤드 감소 + * + * @param orderItems 주문 아이템 목록 (productId -> OrderItemScore) + */ + public void addOrderScoresBatch(List orderItems) { + if (orderItems == null || orderItems.isEmpty()) { + return; + } + + try { + String key = getTodayKey(); + double weight = rankingConfig.getWeight().getOrder(); + + // Pipeline으로 여러 ZINCRBY를 한 번에 실행 + cacheRedisTemplate.executePipelined(new SessionCallback<>() { + @Override + @SuppressWarnings("unchecked") + public Object execute(RedisOperations operations) throws DataAccessException { + for (OrderItemScore item : orderItems) { + long orderAmount = item.price() * item.quantity(); + double score = weight * Math.log1p(orderAmount); + operations.opsForZSet().incrementScore(key, item.productId().toString(), score); + } + return null; + } + }); + + // TTL 설정 (CAS로 원자적 처리) + ensureTtl(key); + + log.debug("[Ranking] Batch order scores added - {} items", orderItems.size()); + } catch (Exception e) { + log.warn("[Ranking] Failed to add batch order scores - error: {}", e.getMessage()); + // Fallback: 개별 처리 + for (OrderItemScore item : orderItems) { + addOrderScore(item.productId(), item.price(), item.quantity()); + } + } + } + + /** + * 주문 아이템 점수 정보 + */ + public record OrderItemScore(Long productId, long price, int quantity) {} + + /** + * ZSET 점수 증가 (ZINCRBY) + */ + private void incrementScore(Long productId, double score) { + try { + String key = getTodayKey(); + cacheRedisTemplate.opsForZSet().incrementScore(key, productId.toString(), score); + ensureTtl(key); + } catch (Exception e) { + log.warn("[Ranking] Failed to increment score - productId: {}, error: {}", + productId, e.getMessage()); + } + } + + /** + * ZSET 점수 감소 (ZINCRBY with negative value) + */ + private void decrementScore(Long productId, double score) { + try { + String key = getTodayKey(); + // ZINCRBY는 음수 값으로 감소 가능 + cacheRedisTemplate.opsForZSet().incrementScore(key, productId.toString(), -score); + ensureTtl(key); + } catch (Exception e) { + log.warn("[Ranking] Failed to decrement score - productId: {}, error: {}", + productId, e.getMessage()); + } + } + + /** + * TTL 설정 (CAS로 원자적 처리) + * AtomicReference를 사용하여 날짜별로 한 번만 설정 (Race Condition 방지) + */ + private void ensureTtl(String key) { + if (!key.equals(ttlInitializedKey.get())) { + if (ttlInitializedKey.compareAndSet(ttlInitializedKey.get(), key)) { + cacheRedisTemplate.expire(key, rankingConfig.getTtlDays(), TimeUnit.DAYS); + log.info("[Ranking] TTL set for key: {} ({} days)", key, rankingConfig.getTtlDays()); + } + } + } + + /** + * 오늘 날짜 키 생성 (Asia/Seoul 기준) + * CacheKeyGenerator를 사용하여 commerce-api와 동일한 키 형식 보장 + */ + private String getTodayKey() { + String date = LocalDate.now(ZONE_ID).format(DATE_FORMATTER); + return CacheKeyGenerator.dailyRankingKey(date); + } + + /** + * 특정 날짜 키 생성 + */ + public static String getKeyForDate(String date) { + return CacheKeyGenerator.dailyRankingKey(date); + } +} From 6ab67c3ab854f52e0aedb84ff7fa56e189f937c4 Mon Sep 17 00:00:00 2001 From: Seoyeon Lee <68765200+sylee6529@users.noreply.github.com> Date: Fri, 26 Dec 2025 13:02:34 +0900 Subject: [PATCH 03/17] =?UTF-8?q?feat:=20=EB=A9=94=ED=8A=B8=EB=A6=AD=20?= =?UTF-8?q?=EC=A7=91=EA=B3=84=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EB=9E=AD?= =?UTF-8?q?=ED=82=B9=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 조회/좋아요/주문 이벤트 시 랭킹 점수 적재 - 좋아요 취소 시 점수 감소 반영 - 주문 이벤트 Pipeline 배치 처리 --- .../metrics/MetricsAggregationService.java | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsAggregationService.java b/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsAggregationService.java index cea286f32..59427a31f 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsAggregationService.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsAggregationService.java @@ -6,6 +6,8 @@ import com.loopers.application.event.product.ProductViewedEvent; import com.loopers.domain.event.EventHandled; import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.infrastructure.cache.ProductRankingCache; +import com.loopers.infrastructure.cache.ProductRankingCache.OrderItemScore; import com.loopers.infrastructure.persistence.EventHandledRepository; import com.loopers.infrastructure.persistence.ProductMetricsRepository; import lombok.RequiredArgsConstructor; @@ -13,6 +15,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.ArrayList; + /** * 메트릭 집계 서비스 * - Kafka 이벤트를 받아서 ProductMetrics 테이블 업데이트 @@ -26,6 +30,7 @@ public class MetricsAggregationService { private final ProductMetricsRepository metricsRepository; private final EventHandledRepository eventHandledRepository; + private final ProductRankingCache productRankingCache; /** * 상품 좋아요 이벤트 처리 @@ -44,6 +49,9 @@ public void handleProductLiked(String eventId, ProductLikedEvent payload) { metrics.incrementLikeCount(); metricsRepository.save(metrics); + // 랭킹 ZSET 점수 추가 + productRankingCache.addLikeScore(payload.productId()); + // 처리 완료 기록 eventHandledRepository.save( EventHandled.create(eventId, "PRODUCT_LIKED", String.valueOf(payload.productId())) @@ -55,6 +63,7 @@ public void handleProductLiked(String eventId, ProductLikedEvent payload) { /** * 상품 좋아요 취소 이벤트 처리 + * - 랭킹 점수도 함께 감소 (일관성 유지) */ public void handleProductUnliked(String eventId, ProductUnlikedEvent payload) { // 멱등성 체크 @@ -70,6 +79,9 @@ public void handleProductUnliked(String eventId, ProductUnlikedEvent payload) { metrics.decrementLikeCount(); metricsRepository.save(metrics); + // 랭킹 ZSET 점수 감소 (좋아요 취소 반영) + productRankingCache.subtractLikeScore(payload.productId()); + // 처리 완료 기록 eventHandledRepository.save( EventHandled.create(eventId, "PRODUCT_UNLIKED", String.valueOf(payload.productId())) @@ -81,6 +93,7 @@ public void handleProductUnliked(String eventId, ProductUnlikedEvent payload) { /** * 주문 완료 이벤트 처리 (판매량 집계) + * - Redis Pipeline을 사용하여 여러 아이템의 랭킹 점수를 한 번에 업데이트 */ public void handleOrderCompleted(String eventId, OrderCompletedEvent payload) { // 멱등성 체크 @@ -89,6 +102,9 @@ public void handleOrderCompleted(String eventId, OrderCompletedEvent payload) { return; } + // 랭킹 점수 배치 처리용 목록 + var orderItemScores = new ArrayList(); + // 각 주문 아이템별로 ProductMetrics 업데이트 for (var item : payload.items()) { ProductMetrics metrics = metricsRepository.findById(item.productId()) @@ -99,10 +115,20 @@ public void handleOrderCompleted(String eventId, OrderCompletedEvent payload) { metrics.addSales(item.quantity(), totalAmount); metricsRepository.save(metrics); + // 랭킹 점수 배치 목록에 추가 + orderItemScores.add(new OrderItemScore( + item.productId(), + item.price().longValue(), + item.quantity() + )); + log.debug("[Metrics] Sales updated - productId: {}, quantity: {}, amount: {}", item.productId(), item.quantity(), totalAmount); } + // 랭킹 ZSET 점수 배치 추가 (Pipeline 사용) + productRankingCache.addOrderScoresBatch(orderItemScores); + // 처리 완료 기록 eventHandledRepository.save( EventHandled.create(eventId, "ORDER_COMPLETED", payload.orderNo()) @@ -129,6 +155,9 @@ public void handleProductViewed(String eventId, ProductViewedEvent payload) { metrics.incrementViewCount(); metricsRepository.save(metrics); + // 랭킹 ZSET 점수 추가 + productRankingCache.addViewScore(payload.productId()); + // 처리 완료 기록 eventHandledRepository.save( EventHandled.create(eventId, "PRODUCT_VIEWED", String.valueOf(payload.productId())) From d4e6aef790fdf2ca8158ba47fc2f425748285c32 Mon Sep 17 00:00:00 2001 From: Seoyeon Lee <68765200+sylee6529@users.noreply.github.com> Date: Fri, 26 Dec 2025 13:03:00 +0900 Subject: [PATCH 04/17] =?UTF-8?q?feat:=20=EB=9E=AD=ED=82=B9=20=EC=BA=90?= =?UTF-8?q?=EC=8B=9C=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(commerce-api)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProductRankingCache: ZSET 조회 (순위, 점수, 페이지네이션) - CacheKeyGenerator: dailyRankingKey 추가 --- .../cache/CacheKeyGenerator.java | 6 + .../cache/ProductRankingCache.java | 155 ++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/ProductRankingCache.java diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/CacheKeyGenerator.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/CacheKeyGenerator.java index 232cde1ce..d558d1682 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/CacheKeyGenerator.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/CacheKeyGenerator.java @@ -50,4 +50,10 @@ public static String productLikeCountKey(Long productId) { public static String productLikeCountKeyPattern() { return String.format("product:like:count:%s:*", VERSION); } + + // Daily ranking ZSET key + public static String dailyRankingKey(String date) { + return String.format("ranking:all:%s:%s", VERSION, date); + // 예: "ranking:all:v1:20251223" + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/ProductRankingCache.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/ProductRankingCache.java new file mode 100644 index 000000000..5a03be02e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/ProductRankingCache.java @@ -0,0 +1,155 @@ +package com.loopers.infrastructure.cache; + +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.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.Collections; +import java.util.List; +import java.util.Set; + +/** + * 상품 랭킹 ZSET 캐시 (조회용 - commerce-api) + * Key: ranking:all:v1:{yyyyMMdd} + * Member: productId + * Score: 가중치 적용된 점수 + */ +@Slf4j +@RequiredArgsConstructor +@Component +public class ProductRankingCache { + + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + private static final ZoneId ZONE_ID = ZoneId.of("Asia/Seoul"); + + private final RedisTemplate cacheRedisTemplate; + + /** + * 상품의 오늘 날짜 기준 순위 조회 + * @param productId 상품 ID + * @return 순위 (1-based), 순위권 밖이면 null + */ + public Integer getRank(Long productId) { + return getRank(productId, getTodayDate()); + } + + /** + * 상품의 특정 날짜 순위 조회 + * @param productId 상품 ID + * @param date 날짜 (yyyyMMdd 형식) + * @return 순위 (1-based), 순위권 밖이면 null + */ + public Integer getRank(Long productId, String date) { + try { + String key = CacheKeyGenerator.dailyRankingKey(date); + Long rank = cacheRedisTemplate.opsForZSet().reverseRank(key, productId.toString()); + + if (rank == null) { + log.debug("[Ranking] Product not in ranking - productId: {}, date: {}", productId, date); + return null; + } + + // 0-based → 1-based 변환 + int ranking = rank.intValue() + 1; + log.debug("[Ranking] Rank retrieved - productId: {}, date: {}, rank: {}", productId, date, ranking); + return ranking; + } catch (Exception e) { + log.warn("[Ranking] Failed to get rank - productId: {}, date: {}, error: {}", + productId, date, e.getMessage()); + return null; + } + } + + /** + * 상품의 점수 조회 + * @param productId 상품 ID + * @return 점수, 없으면 null + */ + public Double getScore(Long productId) { + return getScore(productId, getTodayDate()); + } + + /** + * 상품의 특정 날짜 점수 조회 + */ + public Double getScore(Long productId, String date) { + try { + String key = CacheKeyGenerator.dailyRankingKey(date); + return cacheRedisTemplate.opsForZSet().score(key, productId.toString()); + } catch (Exception e) { + log.warn("[Ranking] Failed to get score - productId: {}, date: {}, error: {}", + productId, date, e.getMessage()); + return null; + } + } + + /** + * 오늘 날짜 문자열 반환 (Asia/Seoul 기준) + */ + public String getTodayDate() { + return LocalDate.now(ZONE_ID).format(DATE_FORMATTER); + } + + /** + * 랭킹 상위 목록 조회 (페이지네이션) + * @param date 날짜 (yyyyMMdd 형식) + * @param page 페이지 번호 (0-based) + * @param size 페이지 크기 + * @return 상품 ID와 점수 목록 (순위 높은 순) + */ + public List getTopRankings(String date, int page, int size) { + try { + String key = CacheKeyGenerator.dailyRankingKey(date); + long start = (long) page * size; + long end = start + size - 1; + + Set> results = + cacheRedisTemplate.opsForZSet().reverseRangeWithScores(key, start, end); + + if (results == null || results.isEmpty()) { + log.debug("[Ranking] No rankings found - date: {}, page: {}, size: {}", date, page, size); + return Collections.emptyList(); + } + + int rank = page * size + 1; // 시작 순위 (1-based) + List entries = new java.util.ArrayList<>(); + + for (ZSetOperations.TypedTuple tuple : results) { + Long productId = Long.parseLong(tuple.getValue().toString()); + Double score = tuple.getScore(); + entries.add(new RankingEntry(rank++, productId, score)); + } + + log.debug("[Ranking] Retrieved {} rankings - date: {}, page: {}", entries.size(), date, page); + return entries; + } catch (Exception e) { + log.warn("[Ranking] Failed to get top rankings - date: {}, page: {}, error: {}", + date, page, e.getMessage()); + return Collections.emptyList(); + } + } + + /** + * 전체 랭킹 수 조회 + */ + public long getTotalCount(String date) { + try { + String key = CacheKeyGenerator.dailyRankingKey(date); + Long count = cacheRedisTemplate.opsForZSet().zCard(key); + return count != null ? count : 0; + } catch (Exception e) { + log.warn("[Ranking] Failed to get total count - date: {}, error: {}", date, e.getMessage()); + return 0; + } + } + + /** + * 랭킹 엔트리 (순위, 상품ID, 점수) + */ + public record RankingEntry(int rank, Long productId, Double score) {} +} From bac530436e6b04323b8be9116584e6dd6e962e45 Mon Sep 17 00:00:00 2001 From: Seoyeon Lee <68765200+sylee6529@users.noreply.github.com> Date: Fri, 26 Dec 2025 13:03:19 +0900 Subject: [PATCH 05/17] =?UTF-8?q?feat:=20=EB=9E=AD=ED=82=B9=20Facade=20?= =?UTF-8?q?=EB=B0=8F=20Info=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RankingFacade: 랭킹 조회 비즈니스 로직 - RankingInfo: 랭킹 페이지/아이템 정보 DTO - 상품/브랜드 정보 Aggregation (N+1 방지) --- .../application/ranking/RankingFacade.java | 98 +++++++++++++++++++ .../application/ranking/RankingInfo.java | 40 ++++++++ 2 files changed, 138 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingInfo.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java new file mode 100644 index 000000000..6f55ac69a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java @@ -0,0 +1,98 @@ +package com.loopers.application.ranking; + +import com.loopers.application.ranking.RankingInfo.RankingItemInfo; +import com.loopers.application.ranking.RankingInfo.RankingPageInfo; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.repository.BrandRepository; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.repository.ProductRepository; +import com.loopers.infrastructure.cache.ProductRankingCache; +import com.loopers.infrastructure.cache.ProductRankingCache.RankingEntry; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Slf4j +@RequiredArgsConstructor +@Component +@Transactional(readOnly = true) +public class RankingFacade { + + private final ProductRankingCache productRankingCache; + private final ProductRepository productRepository; + private final BrandRepository brandRepository; + + /** + * 랭킹 페이지 조회 + * @param date 날짜 (yyyyMMdd), null이면 오늘 + * @param page 페이지 번호 (0-based) + * @param size 페이지 크기 + * @return 랭킹 페이지 정보 + */ + public RankingPageInfo getRankings(String date, int page, int size) { + // 날짜 기본값: 오늘 + String targetDate = date != null ? date : productRankingCache.getTodayDate(); + + // 1. ZSET에서 랭킹 조회 + List rankingEntries = productRankingCache.getTopRankings(targetDate, page, size); + + if (rankingEntries.isEmpty()) { + return RankingPageInfo.of(Collections.emptyList(), targetDate, page, size, 0); + } + + // 2. 상품 ID 목록 추출 + List productIds = rankingEntries.stream() + .map(RankingEntry::productId) + .toList(); + + // 3. 상품 정보 조회 + List products = productRepository.findByIdIn(productIds); + Map productMap = products.stream() + .collect(Collectors.toMap(Product::getId, Function.identity())); + + // 4. 브랜드 정보 조회 (N+1 방지) + List brandIds = products.stream() + .map(Product::getBrandId) + .distinct() + .toList(); + List brands = brandRepository.findByIdIn(brandIds); + Map brandMap = brands.stream() + .collect(Collectors.toMap(Brand::getId, Function.identity())); + + // 5. 응답 생성 + List rankings = rankingEntries.stream() + .map(entry -> { + Product product = productMap.get(entry.productId()); + if (product == null) { + log.warn("[Ranking] Product not found - productId: {}", entry.productId()); + return null; + } + Brand brand = brandMap.get(product.getBrandId()); + String brandName = brand != null ? brand.getName() : "Unknown"; + + return new RankingItemInfo( + entry.rank(), + product.getId(), + product.getName(), + brandName, + product.getPrice(), + product.getLikeCount(), + entry.score() + ); + }) + .filter(item -> item != null) + .toList(); + + // 6. 전체 개수 조회 + long totalCount = productRankingCache.getTotalCount(targetDate); + + return RankingPageInfo.of(rankings, targetDate, page, size, totalCount); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingInfo.java new file mode 100644 index 000000000..1a77aff81 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingInfo.java @@ -0,0 +1,40 @@ +package com.loopers.application.ranking; + +import com.loopers.domain.common.vo.Money; + +import java.util.List; + +public class RankingInfo { + + public record RankingPageInfo( + List rankings, + String date, + int page, + int size, + long totalCount, + int totalPages, + boolean hasNext + ) { + public static RankingPageInfo of( + List rankings, + String date, + int page, + int size, + long totalCount + ) { + int totalPages = size > 0 ? (int) Math.ceil((double) totalCount / size) : 0; + boolean hasNext = page < totalPages - 1; + return new RankingPageInfo(rankings, date, page, size, totalCount, totalPages, hasNext); + } + } + + public record RankingItemInfo( + int rank, + Long productId, + String productName, + String brandName, + Money price, + int likeCount, + Double score + ) {} +} From b3b3270281e159dfc305692b2ac456da6f3c39f3 Mon Sep 17 00:00:00 2001 From: Seoyeon Lee <68765200+sylee6529@users.noreply.github.com> Date: Fri, 26 Dec 2025 13:03:41 +0900 Subject: [PATCH 06/17] =?UTF-8?q?feat:=20=EB=9E=AD=ED=82=B9=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20API=20=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /api/v1/rankings?date=&page=&size= - page 파라미터 1-based (API) → 0-based (내부) 변환 --- .../api/ranking/RankingV1Controller.java | 35 ++++++++++++ .../interfaces/api/ranking/RankingV1Dto.java | 57 +++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java 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..fc6cbcee3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java @@ -0,0 +1,35 @@ +package com.loopers.interfaces.api.ranking; + +import com.loopers.application.ranking.RankingFacade; +import com.loopers.application.ranking.RankingInfo.RankingPageInfo; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.ranking.RankingV1Dto.RankingPageResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1") +public class RankingV1Controller { + + private final RankingFacade rankingFacade; + + /** + * 랭킹 페이지 조회 + * GET /api/v1/rankings?date=yyyyMMdd&size=20&page=1 + * + * @param page 페이지 번호 (1-based, 기본값 1) + */ + @GetMapping("/rankings") + public ApiResponse getRankings( + @RequestParam(value = "date", required = false) String date, + @RequestParam(value = "page", defaultValue = "1") int page, + @RequestParam(value = "size", defaultValue = "20") int size + ) { + // API는 1-based, 내부는 0-based로 변환 + int zeroBasedPage = Math.max(0, page - 1); + RankingPageInfo info = rankingFacade.getRankings(date, zeroBasedPage, size); + RankingPageResponse response = RankingPageResponse.from(info); + return ApiResponse.success(response); + } +} 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..50ac99c92 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java @@ -0,0 +1,57 @@ +package com.loopers.interfaces.api.ranking; + +import com.loopers.application.ranking.RankingInfo.RankingItemInfo; +import com.loopers.application.ranking.RankingInfo.RankingPageInfo; + +import java.math.BigDecimal; +import java.util.List; + +public class RankingV1Dto { + + public record RankingPageResponse( + List rankings, + String date, + int page, // 1-based page number for API response + int size, + long totalCount, + int totalPages, + boolean hasNext + ) { + public static RankingPageResponse from(RankingPageInfo info) { + List rankings = info.rankings().stream() + .map(RankingItemResponse::from) + .toList(); + return new RankingPageResponse( + rankings, + info.date(), + info.page() + 1, // 0-based → 1-based 변환 + info.size(), + info.totalCount(), + info.totalPages(), + info.hasNext() + ); + } + } + + public record RankingItemResponse( + int rank, + Long productId, + String productName, + String brandName, + BigDecimal price, + int likeCount, + Double score + ) { + public static RankingItemResponse from(RankingItemInfo info) { + return new RankingItemResponse( + info.rank(), + info.productId(), + info.productName(), + info.brandName(), + info.price().getAmount(), + info.likeCount(), + info.score() + ); + } + } +} From 8626727a4254c55260d36860d7472378aa1d77ee Mon Sep 17 00:00:00 2001 From: Seoyeon Lee <68765200+sylee6529@users.noreply.github.com> Date: Fri, 26 Dec 2025 13:04:02 +0900 Subject: [PATCH 07/17] =?UTF-8?q?feat:=20=EC=83=81=ED=92=88=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=EC=97=90=20=EB=9E=AD=ED=82=B9=20=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProductDetailInfo: ranking, brandId 필드 추가 - ProductReadService: brandId 설정 - ProductV1Dto: ranking 응답 필드 추가 --- .../com/loopers/application/product/ProductDetailInfo.java | 5 +++++ .../loopers/domain/product/service/ProductReadService.java | 2 ++ .../com/loopers/interfaces/api/product/ProductV1Dto.java | 6 ++++-- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java index d52f2e49b..1e40a171c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java @@ -14,16 +14,19 @@ public class ProductDetailInfo { private final Long id; private final String name; private final String description; + private final Long brandId; private final String brandName; private final String brandDescription; private final Money price; private final Stock stock; private final int likeCount; private final boolean isLikedByMember; + private final Integer ranking; // 순위 (1-based), 순위권 밖이면 null public Long getId() { return id; } public String getName() { return name; } public String getDescription() { return description; } + public Long getBrandId() { return brandId; } public String getBrandName() { return brandName; } public String getBrandDescription() { return brandDescription; } public Money getPrice() { return price; } @@ -33,6 +36,8 @@ public class ProductDetailInfo { @JsonProperty("likedByMember") public boolean isLikedByMember() { return isLikedByMember; } + public Integer getRanking() { return ranking; } + @JsonPOJOBuilder(withPrefix = "") public static class ProductDetailInfoBuilder { } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/service/ProductReadService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/service/ProductReadService.java index d2223f46d..3d89adcf0 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/service/ProductReadService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/service/ProductReadService.java @@ -51,12 +51,14 @@ public ProductDetailInfo getProductDetail(Long productId, Long memberIdOrNull) { .id(product.getId()) .name(product.getName()) .description(product.getDescription()) + .brandId(product.getBrandId()) .brandName(brand.getName()) .brandDescription(brand.getDescription()) .price(product.getPrice()) .stock(product.getStock()) .likeCount(product.getLikeCount()) .isLikedByMember(isLikedByMember) + .ranking(null) // 캐시 저장용, Facade에서 실시간 조회로 교체 .build(); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java index 1d31dfccf..124fd8062 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java @@ -39,7 +39,8 @@ public record ProductDetailResponse( BigDecimal price, int stockQuantity, int likeCount, - boolean isLikedByMember + boolean isLikedByMember, + Integer ranking // 순위 (1-based), 순위권 밖이면 null ) { public static ProductDetailResponse from(ProductDetailInfo info) { return new ProductDetailResponse( @@ -51,7 +52,8 @@ public static ProductDetailResponse from(ProductDetailInfo info) { info.getPrice().getAmount(), info.getStock().getQuantity(), info.getLikeCount(), - info.isLikedByMember() + info.isLikedByMember(), + info.getRanking() ); } } From bd05e1145ccecf68a8fe906bb9a4d16e094ddd8b Mon Sep 17 00:00:00 2001 From: Seoyeon Lee <68765200+sylee6529@users.noreply.github.com> Date: Fri, 26 Dec 2025 13:04:22 +0900 Subject: [PATCH 08/17] =?UTF-8?q?refactor:=20=EC=83=81=ED=92=88=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20=EC=B5=9C=EC=A0=81?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - brandId 캐시 사용으로 불필요한 DB 조회 제거 - 실시간 랭킹 조회 연동 --- .../application/product/ProductFacade.java | 25 +++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java index b18658643..d9267962b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -2,13 +2,12 @@ import com.loopers.application.event.product.ProductViewedEvent; import com.loopers.domain.like.service.LikeReadService; -import com.loopers.domain.product.Product; -import com.loopers.domain.product.repository.ProductRepository; import com.loopers.domain.product.service.ProductReadService; import com.loopers.domain.product.command.ProductSearchFilter; import com.loopers.domain.product.enums.ProductSortCondition; import com.loopers.infrastructure.cache.ProductDetailCache; import com.loopers.infrastructure.cache.ProductListCache; +import com.loopers.infrastructure.cache.ProductRankingCache; import lombok.RequiredArgsConstructor; import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Page; @@ -29,7 +28,7 @@ public class ProductFacade { private final LikeReadService likeReadService; private final ProductDetailCache productDetailCache; private final ProductListCache productListCache; - private final ProductRepository productRepository; + private final ProductRankingCache productRankingCache; private final ApplicationEventPublisher eventPublisher; @Transactional(readOnly = true) @@ -100,33 +99,33 @@ public ProductDetailInfo getProductDetail(Long productId, Long memberIdOrNull) { return result; }); - // 2. Product 엔티티 조회 (brandId 획득용) - Product product = productRepository.findById(productId) - .orElseThrow(() -> new com.loopers.support.error.CoreException( - com.loopers.support.error.ErrorType.NOT_FOUND, - "상품을 찾을 수 없습니다.")); - - // 3. isLikedByMember 동적 계산 + // 2. isLikedByMember 동적 계산 boolean isLiked = memberIdOrNull != null && likeReadService.isLikedBy(memberIdOrNull, productId); - // 4. isLikedByMember 필드만 교체해서 반환 + // 3. 순위 조회 (실시간) + Integer ranking = productRankingCache.getRank(productId); + + // 4. 동적 필드(isLikedByMember, ranking)를 교체해서 반환 ProductDetailInfo result = ProductDetailInfo.builder() .id(cachedInfo.getId()) .name(cachedInfo.getName()) .description(cachedInfo.getDescription()) + .brandId(cachedInfo.getBrandId()) .brandName(cachedInfo.getBrandName()) .brandDescription(cachedInfo.getBrandDescription()) .price(cachedInfo.getPrice()) .stock(cachedInfo.getStock()) .likeCount(cachedInfo.getLikeCount()) - .isLikedByMember(isLiked) // ⭐ 동적 계산 + .isLikedByMember(isLiked) + .ranking(ranking) .build(); // 5. ProductViewedEvent 발행 (조회수 집계) + // brandId는 캐시된 정보에서 가져옴 (불필요한 DB 조회 제거) eventPublisher.publishEvent(new ProductViewedEvent( memberIdOrNull, // 비로그인 사용자는 null productId, - product.getBrandId(), + cachedInfo.getBrandId(), LocalDateTime.now() )); From 376903ff610215eab5afc587f7045c2a55a31eb6 Mon Sep 17 00:00:00 2001 From: Seoyeon Lee <68765200+sylee6529@users.noreply.github.com> Date: Fri, 26 Dec 2025 16:34:54 +0900 Subject: [PATCH 09/17] =?UTF-8?q?test:=20=EB=9E=AD=ED=82=B9=20=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=ED=85=9C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RankingFacadeTest, ProductRankingCacheTest, RankingV1ApiE2ETest 추가 - MetricsAggregationServiceRankingTest, ProductRankingCacheTest 추가 - MetricsAggregationServiceIdempotencyTest에 MockBean 추가 --- .../ranking/RankingFacadeTest.java | 177 ++++++++++++ .../cache/ProductRankingCacheTest.java | 169 +++++++++++ .../api/ranking/RankingV1ApiE2ETest.java | 263 ++++++++++++++++++ ...ricsAggregationServiceIdempotencyTest.java | 5 + .../MetricsAggregationServiceRankingTest.java | 164 +++++++++++ .../cache/ProductRankingCacheTest.java | 158 +++++++++++ 6 files changed, 936 insertions(+) create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/infrastructure/cache/ProductRankingCacheTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ApiE2ETest.java create mode 100644 apps/commerce-streamer/src/test/java/com/loopers/application/metrics/MetricsAggregationServiceRankingTest.java create mode 100644 apps/commerce-streamer/src/test/java/com/loopers/infrastructure/cache/ProductRankingCacheTest.java diff --git a/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.java new file mode 100644 index 000000000..68476d3ff --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.java @@ -0,0 +1,177 @@ +package com.loopers.application.ranking; + +import com.loopers.application.ranking.RankingInfo.RankingItemInfo; +import com.loopers.application.ranking.RankingInfo.RankingPageInfo; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.repository.BrandRepository; +import com.loopers.domain.common.vo.Money; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.repository.ProductRepository; +import com.loopers.infrastructure.cache.ProductRankingCache; +import com.loopers.infrastructure.cache.ProductRankingCache.RankingEntry; +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.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class RankingFacadeTest { + + @Mock + private ProductRankingCache productRankingCache; + + @Mock + private ProductRepository productRepository; + + @Mock + private BrandRepository brandRepository; + + @InjectMocks + private RankingFacade rankingFacade; + + private static final String TODAY_DATE = "20251226"; + + @Test + @DisplayName("getRankings - 상품 정보가 Aggregation 된다") + void getRankings_aggregatesProductInfo() { + // given + List rankingEntries = List.of( + new RankingEntry(1, 100L, 50.0), + new RankingEntry(2, 200L, 40.0) + ); + + Product product1 = createProduct(100L, "상품A", 1L, 10000); + Product product2 = createProduct(200L, "상품B", 2L, 20000); + Brand brand1 = createBrand(1L, "브랜드A"); + Brand brand2 = createBrand(2L, "브랜드B"); + + when(productRankingCache.getTodayDate()).thenReturn(TODAY_DATE); + when(productRankingCache.getTopRankings(TODAY_DATE, 0, 10)).thenReturn(rankingEntries); + when(productRankingCache.getTotalCount(TODAY_DATE)).thenReturn(2L); + when(productRepository.findByIdIn(List.of(100L, 200L))).thenReturn(List.of(product1, product2)); + when(brandRepository.findByIdIn(List.of(1L, 2L))).thenReturn(List.of(brand1, brand2)); + + // when + RankingPageInfo result = rankingFacade.getRankings(null, 0, 10); + + // then + assertThat(result.rankings()).hasSize(2); + + RankingItemInfo first = result.rankings().get(0); + assertThat(first.rank()).isEqualTo(1); + assertThat(first.productId()).isEqualTo(100L); + assertThat(first.productName()).isEqualTo("상품A"); + assertThat(first.brandName()).isEqualTo("브랜드A"); + assertThat(first.score()).isEqualTo(50.0); + } + + @Test + @DisplayName("getRankings - 존재하지 않는 상품은 필터링된다") + void getRankings_filtersNonExistentProducts() { + // given + List rankingEntries = List.of( + new RankingEntry(1, 100L, 50.0), + new RankingEntry(2, 999L, 40.0) // 존재하지 않는 상품 + ); + + Product product1 = createProduct(100L, "상품A", 1L, 10000); + Brand brand1 = createBrand(1L, "브랜드A"); + + when(productRankingCache.getTodayDate()).thenReturn(TODAY_DATE); + when(productRankingCache.getTopRankings(TODAY_DATE, 0, 10)).thenReturn(rankingEntries); + when(productRankingCache.getTotalCount(TODAY_DATE)).thenReturn(2L); + when(productRepository.findByIdIn(anyList())).thenReturn(List.of(product1)); + when(brandRepository.findByIdIn(anyList())).thenReturn(List.of(brand1)); + + // when + RankingPageInfo result = rankingFacade.getRankings(null, 0, 10); + + // then + assertThat(result.rankings()).hasSize(1); + assertThat(result.rankings().get(0).productId()).isEqualTo(100L); + } + + @Test + @DisplayName("getRankings - 날짜 미지정 시 오늘 날짜 사용") + void getRankings_usesTodayDateWhenNotSpecified() { + // given + when(productRankingCache.getTodayDate()).thenReturn(TODAY_DATE); + when(productRankingCache.getTopRankings(TODAY_DATE, 0, 10)).thenReturn(Collections.emptyList()); + + // when + RankingPageInfo result = rankingFacade.getRankings(null, 0, 10); + + // then + assertThat(result.date()).isEqualTo(TODAY_DATE); + verify(productRankingCache).getTopRankings(eq(TODAY_DATE), anyInt(), anyInt()); + } + + @Test + @DisplayName("getRankings - 페이지 정보가 정확하다") + void getRankings_pageInfoIsCorrect() { + // given - 첫 페이지에 10개 상품이 있고, 전체 25개 + List rankingEntries = List.of( + new RankingEntry(1, 100L, 50.0) + ); + Product product = createProduct(100L, "상품A", 1L, 10000); + Brand brand = createBrand(1L, "브랜드A"); + + when(productRankingCache.getTodayDate()).thenReturn(TODAY_DATE); + when(productRankingCache.getTopRankings(TODAY_DATE, 0, 10)).thenReturn(rankingEntries); + when(productRankingCache.getTotalCount(TODAY_DATE)).thenReturn(25L); + when(productRepository.findByIdIn(anyList())).thenReturn(List.of(product)); + when(brandRepository.findByIdIn(anyList())).thenReturn(List.of(brand)); + + // when + RankingPageInfo result = rankingFacade.getRankings(null, 0, 10); + + // then + assertThat(result.page()).isEqualTo(0); + assertThat(result.size()).isEqualTo(10); + assertThat(result.totalCount()).isEqualTo(25); + assertThat(result.totalPages()).isEqualTo(3); // ceil(25/10) + assertThat(result.hasNext()).isTrue(); + } + + @Test + @DisplayName("getRankings - 빈 결과 시 빈 리스트 반환") + void getRankings_returnsEmptyListWhenNoResults() { + // given + when(productRankingCache.getTodayDate()).thenReturn(TODAY_DATE); + when(productRankingCache.getTopRankings(TODAY_DATE, 0, 10)).thenReturn(Collections.emptyList()); + + // when + RankingPageInfo result = rankingFacade.getRankings(null, 0, 10); + + // then + assertThat(result.rankings()).isEmpty(); + assertThat(result.totalCount()).isEqualTo(0); + assertThat(result.hasNext()).isFalse(); + } + + private Product createProduct(Long id, String name, Long brandId, int price) { + Product product = mock(Product.class); + when(product.getId()).thenReturn(id); + when(product.getName()).thenReturn(name); + when(product.getBrandId()).thenReturn(brandId); + when(product.getPrice()).thenReturn(Money.of(price)); + when(product.getLikeCount()).thenReturn(10); + return product; + } + + private Brand createBrand(Long id, String name) { + Brand brand = mock(Brand.class); + when(brand.getId()).thenReturn(id); + when(brand.getName()).thenReturn(name); + return brand; + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/cache/ProductRankingCacheTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/cache/ProductRankingCacheTest.java new file mode 100644 index 000000000..739e32f3e --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/cache/ProductRankingCacheTest.java @@ -0,0 +1,169 @@ +package com.loopers.infrastructure.cache; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; + +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class ProductRankingCacheTest { + + @Mock + private RedisTemplate redisTemplate; + + @Mock + private ZSetOperations zSetOperations; + + private ProductRankingCache productRankingCache; + + private static final Long PRODUCT_ID = 100L; + private static final String TODAY_DATE = "20251226"; + + @BeforeEach + void setUp() { + productRankingCache = new ProductRankingCache(redisTemplate); + when(redisTemplate.opsForZSet()).thenReturn(zSetOperations); + } + + @Test + @DisplayName("getRank - 순위가 1-based로 반환된다") + void getRank_returnsOneBasedRank() { + // given + when(zSetOperations.reverseRank(anyString(), eq(PRODUCT_ID.toString()))) + .thenReturn(0L); // 0-based rank + + // when + Integer rank = productRankingCache.getRank(PRODUCT_ID, TODAY_DATE); + + // then + assertThat(rank).isEqualTo(1); // 1-based + } + + @Test + @DisplayName("getRank - 순위권 밖이면 null 반환") + void getRank_returnsNullWhenNotRanked() { + // given + when(zSetOperations.reverseRank(anyString(), eq(PRODUCT_ID.toString()))) + .thenReturn(null); + + // when + Integer rank = productRankingCache.getRank(PRODUCT_ID, TODAY_DATE); + + // then + assertThat(rank).isNull(); + } + + @Test + @DisplayName("getScore - 점수가 정상 반환된다") + void getScore_returnsScore() { + // given + when(zSetOperations.score(anyString(), eq(PRODUCT_ID.toString()))) + .thenReturn(15.5); + + // when + Double score = productRankingCache.getScore(PRODUCT_ID, TODAY_DATE); + + // then + assertThat(score).isEqualTo(15.5); + } + + @Test + @DisplayName("getTopRankings - 페이지네이션이 동작한다") + void getTopRankings_paginationWorks() { + // given + int page = 1; + int size = 10; + long expectedStart = 10L; // page * size + long expectedEnd = 19L; // start + size - 1 + + Set> mockResults = new LinkedHashSet<>(); + mockResults.add(createTuple("101", 50.0)); + mockResults.add(createTuple("102", 45.0)); + + when(zSetOperations.reverseRangeWithScores(anyString(), eq(expectedStart), eq(expectedEnd))) + .thenReturn(mockResults); + + // when + List rankings = + productRankingCache.getTopRankings(TODAY_DATE, page, size); + + // then + assertThat(rankings).hasSize(2); + assertThat(rankings.get(0).rank()).isEqualTo(11); // page * size + 1 + assertThat(rankings.get(0).productId()).isEqualTo(101L); + assertThat(rankings.get(0).score()).isEqualTo(50.0); + } + + @Test + @DisplayName("getTopRankings - 빈 결과 시 빈 리스트 반환") + void getTopRankings_returnsEmptyListWhenNoResults() { + // given + when(zSetOperations.reverseRangeWithScores(anyString(), anyLong(), anyLong())) + .thenReturn(Collections.emptySet()); + + // when + List rankings = + productRankingCache.getTopRankings(TODAY_DATE, 0, 10); + + // then + assertThat(rankings).isEmpty(); + } + + @Test + @DisplayName("getTotalCount - 전체 개수를 반환한다") + void getTotalCount_returnsTotalCount() { + // given + when(zSetOperations.zCard(anyString())).thenReturn(150L); + + // when + long count = productRankingCache.getTotalCount(TODAY_DATE); + + // then + assertThat(count).isEqualTo(150); + } + + @Test + @DisplayName("getTotalCount - null일 경우 0 반환") + void getTotalCount_returnsZeroWhenNull() { + // given + when(zSetOperations.zCard(anyString())).thenReturn(null); + + // when + long count = productRankingCache.getTotalCount(TODAY_DATE); + + // then + assertThat(count).isEqualTo(0); + } + + private ZSetOperations.TypedTuple createTuple(String value, Double score) { + return new ZSetOperations.TypedTuple<>() { + @Override + public Object getValue() { + return value; + } + + @Override + public Double getScore() { + return score; + } + + @Override + public int compareTo(ZSetOperations.TypedTuple o) { + return Double.compare(score, o.getScore()); + } + }; + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ApiE2ETest.java new file mode 100644 index 000000000..e7efeb2e7 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ApiE2ETest.java @@ -0,0 +1,263 @@ +package com.loopers.interfaces.api.ranking; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.repository.BrandRepository; +import com.loopers.domain.common.vo.Money; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.repository.ProductRepository; +import com.loopers.domain.product.vo.Stock; +import com.loopers.infrastructure.cache.CacheKeyGenerator; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.testcontainers.RedisTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.context.annotation.Import; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Import(RedisTestContainersConfig.class) +@DisplayName("랭킹 API E2E 테스트") +class RankingV1ApiE2ETest { + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private BrandRepository brandRepository; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private RedisTemplate redisTemplate; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + private String todayDate; + private Brand testBrand; + + @BeforeEach + void setUp() { + todayDate = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); + + // Redis 초기화 + redisTemplate.getConnectionFactory().getConnection().flushDb(); + + // 테스트 브랜드 생성 + testBrand = brandRepository.save(new Brand("TestBrand", "Test Brand Description")); + } + + @AfterEach + void tearDown() { + redisTemplate.getConnectionFactory().getConnection().flushDb(); + databaseCleanUp.truncateAllTables(); + } + + private Product createProduct(String name, int price) { + Product product = new Product( + testBrand.getId(), + name, + "Description", + Money.of(BigDecimal.valueOf(price)), + Stock.of(100) + ); + return productRepository.save(product); + } + + private void addToRanking(Long productId, double score) { + String key = CacheKeyGenerator.dailyRankingKey(todayDate); + redisTemplate.opsForZSet().add(key, productId.toString(), score); + } + + @DisplayName("랭킹 조회 (GET /api/v1/rankings)") + @Nested + class GetRankings { + + @DisplayName("랭킹 데이터가 있을 때 정상 응답을 반환한다") + @Test + void shouldReturnRankings_whenDataExists() { + // given + Product product1 = createProduct("인기상품1", 10000); + Product product2 = createProduct("인기상품2", 20000); + Product product3 = createProduct("인기상품3", 30000); + + addToRanking(product1.getId(), 100.0); + addToRanking(product2.getId(), 80.0); + addToRanking(product3.getId(), 60.0); + + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange("/api/v1/rankings", HttpMethod.GET, null, responseType); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data()).isNotNull(), + () -> assertThat(response.getBody().data().rankings()).hasSize(3), + () -> assertThat(response.getBody().data().date()).isEqualTo(todayDate), + // 1위는 점수가 가장 높은 product1 + () -> assertThat(response.getBody().data().rankings().get(0).rank()).isEqualTo(1), + () -> assertThat(response.getBody().data().rankings().get(0).productId()).isEqualTo(product1.getId()), + () -> assertThat(response.getBody().data().rankings().get(0).productName()).isEqualTo("인기상품1"), + () -> assertThat(response.getBody().data().rankings().get(0).brandName()).isEqualTo("TestBrand"), + () -> assertThat(response.getBody().data().rankings().get(0).score()).isEqualTo(100.0), + // 2위 + () -> assertThat(response.getBody().data().rankings().get(1).rank()).isEqualTo(2), + () -> assertThat(response.getBody().data().rankings().get(1).productId()).isEqualTo(product2.getId()), + // 3위 + () -> assertThat(response.getBody().data().rankings().get(2).rank()).isEqualTo(3), + () -> assertThat(response.getBody().data().rankings().get(2).productId()).isEqualTo(product3.getId()) + ); + } + + @DisplayName("page 파라미터가 1-based로 동작한다") + @Test + void shouldWork_with1BasedPageParameter() { + // given - 25개 상품 생성 및 랭킹 추가 + for (int i = 1; i <= 25; i++) { + Product product = createProduct("상품" + i, 10000 * i); + addToRanking(product.getId(), 100.0 - i); // 상품1이 1위 + } + + // when - page=1 (첫 페이지) + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange("/api/v1/rankings?page=1&size=10", HttpMethod.GET, null, responseType); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().rankings()).hasSize(10), + () -> assertThat(response.getBody().data().page()).isEqualTo(1), + () -> assertThat(response.getBody().data().totalCount()).isEqualTo(25), + () -> assertThat(response.getBody().data().totalPages()).isEqualTo(3), + () -> assertThat(response.getBody().data().hasNext()).isTrue(), + // 첫 페이지는 1위~10위 + () -> assertThat(response.getBody().data().rankings().get(0).rank()).isEqualTo(1), + () -> assertThat(response.getBody().data().rankings().get(9).rank()).isEqualTo(10) + ); + + // when - page=2 (두 번째 페이지) + ResponseEntity> response2 = + testRestTemplate.exchange("/api/v1/rankings?page=2&size=10", HttpMethod.GET, null, responseType); + + // then + assertAll( + () -> assertTrue(response2.getStatusCode().is2xxSuccessful()), + () -> assertThat(response2.getBody().data().rankings()).hasSize(10), + () -> assertThat(response2.getBody().data().page()).isEqualTo(2), + // 두 번째 페이지는 11위~20위 + () -> assertThat(response2.getBody().data().rankings().get(0).rank()).isEqualTo(11), + () -> assertThat(response2.getBody().data().rankings().get(9).rank()).isEqualTo(20) + ); + + // when - page=3 (마지막 페이지) + ResponseEntity> response3 = + testRestTemplate.exchange("/api/v1/rankings?page=3&size=10", HttpMethod.GET, null, responseType); + + // then + assertAll( + () -> assertTrue(response3.getStatusCode().is2xxSuccessful()), + () -> assertThat(response3.getBody().data().rankings()).hasSize(5), // 21~25위 + () -> assertThat(response3.getBody().data().page()).isEqualTo(3), + () -> assertThat(response3.getBody().data().hasNext()).isFalse(), + () -> assertThat(response3.getBody().data().rankings().get(0).rank()).isEqualTo(21), + () -> assertThat(response3.getBody().data().rankings().get(4).rank()).isEqualTo(25) + ); + } + + @DisplayName("랭킹 데이터가 없을 때 빈 배열을 반환한다") + @Test + void shouldReturnEmptyList_whenNoRankingData() { + // given - 랭킹 데이터 없음 + + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange("/api/v1/rankings", HttpMethod.GET, null, responseType); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data()).isNotNull(), + () -> assertThat(response.getBody().data().rankings()).isEmpty(), + () -> assertThat(response.getBody().data().totalCount()).isEqualTo(0), + () -> assertThat(response.getBody().data().hasNext()).isFalse() + ); + } + + @DisplayName("date 파라미터로 특정 날짜의 랭킹을 조회할 수 있다") + @Test + void shouldReturnRankings_forSpecificDate() { + // given - 어제 날짜의 랭킹 데이터 생성 + String yesterday = LocalDate.now().minusDays(1).format(DateTimeFormatter.ofPattern("yyyyMMdd")); + Product product = createProduct("어제상품", 10000); + + String yesterdayKey = CacheKeyGenerator.dailyRankingKey(yesterday); + redisTemplate.opsForZSet().add(yesterdayKey, product.getId().toString(), 50.0); + + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange("/api/v1/rankings?date=" + yesterday, HttpMethod.GET, null, responseType); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().date()).isEqualTo(yesterday), + () -> assertThat(response.getBody().data().rankings()).hasSize(1), + () -> assertThat(response.getBody().data().rankings().get(0).productName()).isEqualTo("어제상품") + ); + } + + @DisplayName("존재하지 않는 상품은 랭킹에서 필터링된다") + @Test + void shouldFilterNonExistentProducts() { + // given + Product existingProduct = createProduct("존재하는상품", 10000); + Long nonExistentProductId = 99999L; + + addToRanking(existingProduct.getId(), 100.0); + addToRanking(nonExistentProductId, 80.0); // DB에 없는 상품 + + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange("/api/v1/rankings", HttpMethod.GET, null, responseType); + + // then - 존재하는 상품만 반환됨 + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().rankings()).hasSize(1), + () -> assertThat(response.getBody().data().rankings().get(0).productId()).isEqualTo(existingProduct.getId()) + ); + } + } +} diff --git a/apps/commerce-streamer/src/test/java/com/loopers/application/metrics/MetricsAggregationServiceIdempotencyTest.java b/apps/commerce-streamer/src/test/java/com/loopers/application/metrics/MetricsAggregationServiceIdempotencyTest.java index a9f922461..8362c9589 100644 --- a/apps/commerce-streamer/src/test/java/com/loopers/application/metrics/MetricsAggregationServiceIdempotencyTest.java +++ b/apps/commerce-streamer/src/test/java/com/loopers/application/metrics/MetricsAggregationServiceIdempotencyTest.java @@ -6,6 +6,7 @@ import com.loopers.application.event.product.ProductViewedEvent; import com.loopers.config.TestConfig; import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.infrastructure.cache.ProductRankingCache; import com.loopers.infrastructure.persistence.EventHandledRepository; import com.loopers.infrastructure.persistence.ProductMetricsRepository; import org.junit.jupiter.api.BeforeEach; @@ -13,6 +14,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.context.annotation.Import; import org.springframework.test.context.ActiveProfiles; @@ -41,6 +43,9 @@ class MetricsAggregationServiceIdempotencyTest { @Autowired private EventHandledRepository eventHandledRepository; + @MockBean + private ProductRankingCache productRankingCache; + private static final Long PRODUCT_ID = 100L; private static final Long MEMBER_ID = 1L; private static final Long BRAND_ID = 10L; diff --git a/apps/commerce-streamer/src/test/java/com/loopers/application/metrics/MetricsAggregationServiceRankingTest.java b/apps/commerce-streamer/src/test/java/com/loopers/application/metrics/MetricsAggregationServiceRankingTest.java new file mode 100644 index 000000000..743decc78 --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/application/metrics/MetricsAggregationServiceRankingTest.java @@ -0,0 +1,164 @@ +package com.loopers.application.metrics; + +import com.loopers.application.event.like.ProductLikedEvent; +import com.loopers.application.event.like.ProductUnlikedEvent; +import com.loopers.application.event.order.OrderCompletedEvent; +import com.loopers.application.event.product.ProductViewedEvent; +import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.infrastructure.cache.ProductRankingCache; +import com.loopers.infrastructure.cache.ProductRankingCache.OrderItemScore; +import com.loopers.infrastructure.persistence.EventHandledRepository; +import com.loopers.infrastructure.persistence.ProductMetricsRepository; +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.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * 메트릭 집계 서비스 - 랭킹 적재 테스트 + */ +@ExtendWith(MockitoExtension.class) +class MetricsAggregationServiceRankingTest { + + @Mock + private ProductMetricsRepository metricsRepository; + + @Mock + private EventHandledRepository eventHandledRepository; + + @Mock + private ProductRankingCache productRankingCache; + + @InjectMocks + private MetricsAggregationService aggregationService; + + private static final Long PRODUCT_ID = 100L; + private static final Long MEMBER_ID = 1L; + private static final Long BRAND_ID = 10L; + + private void setupNonDuplicateEvent() { + when(eventHandledRepository.existsById(anyString())).thenReturn(false); + when(metricsRepository.findById(PRODUCT_ID)) + .thenReturn(Optional.of(ProductMetrics.create(PRODUCT_ID))); + } + + @Test + @DisplayName("handleProductLiked - 랭킹 점수가 적재된다") + void handleProductLiked_addsRankingScore() { + // given + setupNonDuplicateEvent(); + String eventId = "event-liked-001"; + ProductLikedEvent event = new ProductLikedEvent( + MEMBER_ID, PRODUCT_ID, BRAND_ID, LocalDateTime.now() + ); + + // when + aggregationService.handleProductLiked(eventId, event); + + // then + verify(productRankingCache).addLikeScore(PRODUCT_ID); + } + + @Test + @DisplayName("handleProductUnliked - 랭킹 점수가 감소한다") + void handleProductUnliked_subtractsRankingScore() { + // given + setupNonDuplicateEvent(); + String eventId = "event-unliked-001"; + ProductUnlikedEvent event = new ProductUnlikedEvent( + MEMBER_ID, PRODUCT_ID, BRAND_ID, LocalDateTime.now() + ); + + // when + aggregationService.handleProductUnliked(eventId, event); + + // then + verify(productRankingCache).subtractLikeScore(PRODUCT_ID); + } + + @Test + @DisplayName("handleProductViewed - 랭킹 점수가 적재된다") + void handleProductViewed_addsRankingScore() { + // given + setupNonDuplicateEvent(); + String eventId = "event-viewed-001"; + ProductViewedEvent event = new ProductViewedEvent( + MEMBER_ID, PRODUCT_ID, BRAND_ID, LocalDateTime.now() + ); + + // when + aggregationService.handleProductViewed(eventId, event); + + // then + verify(productRankingCache).addViewScore(PRODUCT_ID); + } + + @Test + @DisplayName("handleOrderCompleted - 배치로 랭킹 점수가 적재된다") + void handleOrderCompleted_addsBatchRankingScore() { + // given + when(eventHandledRepository.existsById(anyString())).thenReturn(false); + String eventId = "event-order-001"; + Long productId1 = 100L; + Long productId2 = 200L; + + when(metricsRepository.findById(productId1)) + .thenReturn(Optional.of(ProductMetrics.create(productId1))); + when(metricsRepository.findById(productId2)) + .thenReturn(Optional.of(ProductMetrics.create(productId2))); + + OrderCompletedEvent event = new OrderCompletedEvent( + "ORDER-001", + MEMBER_ID, + BigDecimal.valueOf(75000), + List.of( + new OrderCompletedEvent.OrderItemInfo(productId1, 2, BigDecimal.valueOf(25000)), + new OrderCompletedEvent.OrderItemInfo(productId2, 1, BigDecimal.valueOf(25000)) + ), + LocalDateTime.now() + ); + + // when + aggregationService.handleOrderCompleted(eventId, event); + + // then + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(productRankingCache).addOrderScoresBatch(captor.capture()); + + List capturedItems = captor.getValue(); + assertThat(capturedItems).hasSize(2); + assertThat(capturedItems.get(0).productId()).isEqualTo(productId1); + assertThat(capturedItems.get(0).price()).isEqualTo(25000L); + assertThat(capturedItems.get(0).quantity()).isEqualTo(2); + } + + @Test + @DisplayName("중복 이벤트는 랭킹 점수를 적재하지 않는다") + void duplicateEvent_doesNotAddRankingScore() { + // given + String eventId = "event-liked-duplicate"; + when(eventHandledRepository.existsById(eventId)).thenReturn(true); + + ProductLikedEvent event = new ProductLikedEvent( + MEMBER_ID, PRODUCT_ID, BRAND_ID, LocalDateTime.now() + ); + + // when + aggregationService.handleProductLiked(eventId, event); + + // then + verify(productRankingCache, never()).addLikeScore(anyLong()); + } +} diff --git a/apps/commerce-streamer/src/test/java/com/loopers/infrastructure/cache/ProductRankingCacheTest.java b/apps/commerce-streamer/src/test/java/com/loopers/infrastructure/cache/ProductRankingCacheTest.java new file mode 100644 index 000000000..83820f368 --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/infrastructure/cache/ProductRankingCacheTest.java @@ -0,0 +1,158 @@ +package com.loopers.infrastructure.cache; + +import com.loopers.config.RankingConfig; +import org.junit.jupiter.api.BeforeEach; +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.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.SessionCallback; +import org.springframework.data.redis.core.ZSetOperations; + +import java.util.List; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class ProductRankingCacheTest { + + @Mock + private RedisTemplate redisTemplate; + + @Mock + private ZSetOperations zSetOperations; + + private RankingConfig rankingConfig; + private ProductRankingCache productRankingCache; + + private static final Long PRODUCT_ID = 100L; + + @BeforeEach + void setUp() { + rankingConfig = new RankingConfig(); + rankingConfig.setTtlDays(2); + RankingConfig.Weight weight = new RankingConfig.Weight(); + weight.setView(0.1); + weight.setLike(0.2); + weight.setOrder(0.6); + rankingConfig.setWeight(weight); + + productRankingCache = new ProductRankingCache(redisTemplate, rankingConfig); + } + + private void setupZSetOperations() { + when(redisTemplate.opsForZSet()).thenReturn(zSetOperations); + } + + @Test + @DisplayName("addViewScore - 조회 점수가 가중치 0.1로 증가한다") + void addViewScore_incrementsScoreWithWeight() { + // given + setupZSetOperations(); + + // when + productRankingCache.addViewScore(PRODUCT_ID); + + // then + verify(zSetOperations).incrementScore(anyString(), eq(PRODUCT_ID.toString()), eq(0.1)); + } + + @Test + @DisplayName("addLikeScore - 좋아요 점수가 가중치 0.2로 증가한다") + void addLikeScore_incrementsScoreWithWeight() { + // given + setupZSetOperations(); + + // when + productRankingCache.addLikeScore(PRODUCT_ID); + + // then + verify(zSetOperations).incrementScore(anyString(), eq(PRODUCT_ID.toString()), eq(0.2)); + } + + @Test + @DisplayName("subtractLikeScore - 좋아요 취소 시 점수가 감소한다") + void subtractLikeScore_decrementsScore() { + // given + setupZSetOperations(); + + // when + productRankingCache.subtractLikeScore(PRODUCT_ID); + + // then + verify(zSetOperations).incrementScore(anyString(), eq(PRODUCT_ID.toString()), eq(-0.2)); + } + + @Test + @DisplayName("addOrderScore - 주문 점수에 log 정규화가 적용된다") + void addOrderScore_appliesLogNormalization() { + // given + setupZSetOperations(); + long price = 100000L; + int quantity = 2; + double expectedScore = 0.6 * Math.log1p(price * quantity); + + // when + productRankingCache.addOrderScore(PRODUCT_ID, price, quantity); + + // then + ArgumentCaptor scoreCaptor = ArgumentCaptor.forClass(Double.class); + verify(zSetOperations).incrementScore(anyString(), eq(PRODUCT_ID.toString()), scoreCaptor.capture()); + + assertThat(scoreCaptor.getValue()).isCloseTo(expectedScore, within(0.0001)); + } + + @Test + @DisplayName("addOrderScoresBatch - 여러 주문 아이템이 일괄 처리된다") + void addOrderScoresBatch_processesBatch() { + // given + List orderItems = List.of( + new ProductRankingCache.OrderItemScore(1L, 10000L, 1), + new ProductRankingCache.OrderItemScore(2L, 20000L, 2), + new ProductRankingCache.OrderItemScore(3L, 30000L, 3) + ); + + when(redisTemplate.executePipelined(any(SessionCallback.class))).thenReturn(List.of()); + + // when + productRankingCache.addOrderScoresBatch(orderItems); + + // then + verify(redisTemplate).executePipelined(any(SessionCallback.class)); + } + + @Test + @DisplayName("addOrderScoresBatch - 빈 리스트는 처리하지 않는다") + void addOrderScoresBatch_skipsEmptyList() { + // when + productRankingCache.addOrderScoresBatch(List.of()); + + // then + verify(redisTemplate, never()).executePipelined(any(SessionCallback.class)); + } + + @Test + @DisplayName("TTL이 첫 호출 시에만 설정된다") + void ttl_setOnlyOnFirstCall() { + // given + setupZSetOperations(); + + // when - 같은 날짜에 여러 번 호출 + productRankingCache.addViewScore(PRODUCT_ID); + productRankingCache.addLikeScore(PRODUCT_ID); + productRankingCache.addViewScore(PRODUCT_ID); + + // then - expire는 한 번만 호출됨 + verify(redisTemplate, times(1)).expire(anyString(), eq(2L), eq(TimeUnit.DAYS)); + } + + private static org.assertj.core.data.Offset within(double offset) { + return org.assertj.core.data.Offset.offset(offset); + } +} From 57fe5a707b987dafe71db2933bff289e85d5f4f3 Mon Sep 17 00:00:00 2001 From: Seoyeon Lee <68765200+sylee6529@users.noreply.github.com> Date: Fri, 26 Dec 2025 16:45:32 +0900 Subject: [PATCH 10/17] =?UTF-8?q?fix:=20TTL=20CAS=20=EB=A1=9C=EC=A7=81=20T?= =?UTF-8?q?OCTOU=20=EC=9D=B4=EC=8A=88=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../infrastructure/cache/ProductRankingCache.java | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/cache/ProductRankingCache.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/cache/ProductRankingCache.java index bc96bfbaf..300b74693 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/cache/ProductRankingCache.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/cache/ProductRankingCache.java @@ -171,15 +171,14 @@ private void decrementScore(Long productId, double score) { } /** - * TTL 설정 (CAS로 원자적 처리) - * AtomicReference를 사용하여 날짜별로 한 번만 설정 (Race Condition 방지) + * TTL 설정 (원자적 처리) + * getAndSet으로 키 변경 시에만 TTL 설정 (날짜가 바뀌는 경우) */ private void ensureTtl(String key) { - if (!key.equals(ttlInitializedKey.get())) { - if (ttlInitializedKey.compareAndSet(ttlInitializedKey.get(), key)) { - cacheRedisTemplate.expire(key, rankingConfig.getTtlDays(), TimeUnit.DAYS); - log.info("[Ranking] TTL set for key: {} ({} days)", key, rankingConfig.getTtlDays()); - } + String oldKey = ttlInitializedKey.getAndSet(key); + if (!key.equals(oldKey)) { + cacheRedisTemplate.expire(key, rankingConfig.getTtlDays(), TimeUnit.DAYS); + log.info("[Ranking] TTL set for key: {} ({} days)", key, rankingConfig.getTtlDays()); } } From d5117fbe71f9715da22701c8273b37a76692ed47 Mon Sep 17 00:00:00 2001 From: Seoyeon Lee <68765200+sylee6529@users.noreply.github.com> Date: Fri, 26 Dec 2025 16:52:26 +0900 Subject: [PATCH 11/17] =?UTF-8?q?feat:=20=EB=9E=AD=ED=82=B9=20API=20date?= =?UTF-8?q?=20=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20=EC=9C=A0=ED=9A=A8?= =?UTF-8?q?=EC=84=B1=20=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/ranking/RankingFacade.java | 28 +++++++++++++++++-- .../ranking/RankingFacadeTest.java | 25 +++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java index 6f55ac69a..7937545a5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java @@ -13,6 +13,9 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; import java.util.Collections; import java.util.List; import java.util.Map; @@ -25,6 +28,8 @@ @Transactional(readOnly = true) public class RankingFacade { + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + private final ProductRankingCache productRankingCache; private final ProductRepository productRepository; private final BrandRepository brandRepository; @@ -37,8 +42,8 @@ public class RankingFacade { * @return 랭킹 페이지 정보 */ public RankingPageInfo getRankings(String date, int page, int size) { - // 날짜 기본값: 오늘 - String targetDate = date != null ? date : productRankingCache.getTodayDate(); + // 날짜 검증 및 기본값 처리 + String targetDate = validateAndNormalizeDate(date); // 1. ZSET에서 랭킹 조회 List rankingEntries = productRankingCache.getTopRankings(targetDate, page, size); @@ -95,4 +100,23 @@ public RankingPageInfo getRankings(String date, int page, int size) { return RankingPageInfo.of(rankings, targetDate, page, size, totalCount); } + + /** + * 날짜 파라미터 검증 및 정규화 + * @param date 날짜 문자열 (yyyyMMdd 형식) 또는 null + * @return 검증된 날짜 문자열 (null이면 오늘 날짜) + * @throws IllegalArgumentException 유효하지 않은 날짜 형식 + */ + private String validateAndNormalizeDate(String date) { + if (date == null || date.isBlank()) { + return productRankingCache.getTodayDate(); + } + + try { + LocalDate.parse(date, DATE_FORMATTER); + return date; + } catch (DateTimeParseException e) { + throw new IllegalArgumentException("Invalid date format. Expected yyyyMMdd, got: " + date); + } + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.java index 68476d3ff..b96303e10 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.java @@ -20,6 +20,7 @@ import java.util.List; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; @@ -174,4 +175,28 @@ private Brand createBrand(Long id, String name) { when(brand.getName()).thenReturn(name); return brand; } + + @Test + @DisplayName("getRankings - 잘못된 날짜 형식은 예외 발생") + void getRankings_throwsExceptionForInvalidDateFormat() { + // given + String invalidDate = "2025-12-26"; // yyyy-MM-dd 형식 (잘못됨) + + // when & then + assertThatThrownBy(() -> rankingFacade.getRankings(invalidDate, 0, 10)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Invalid date format"); + } + + @Test + @DisplayName("getRankings - 존재하지 않는 날짜는 예외 발생") + void getRankings_throwsExceptionForNonExistentDate() { + // given + String invalidDate = "20251332"; // 13월 32일 (존재하지 않는 날짜) + + // when & then + assertThatThrownBy(() -> rankingFacade.getRankings(invalidDate, 0, 10)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Invalid date format"); + } } From c9c03b24e6f39b5ebef7bdc0927aff099ac147a0 Mon Sep 17 00:00:00 2001 From: Seoyeon Lee <68765200+sylee6529@users.noreply.github.com> Date: Fri, 26 Dec 2025 16:56:58 +0900 Subject: [PATCH 12/17] =?UTF-8?q?feat:=20=EB=9E=AD=ED=82=B9=20API=20page/s?= =?UTF-8?q?ize=20=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20=EC=9C=A0=ED=9A=A8?= =?UTF-8?q?=EC=84=B1=20=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../interfaces/api/ranking/RankingV1Controller.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 index fc6cbcee3..1e9b98610 100644 --- 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 @@ -4,9 +4,13 @@ import com.loopers.application.ranking.RankingInfo.RankingPageInfo; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.ranking.RankingV1Dto.RankingPageResponse; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; import lombok.RequiredArgsConstructor; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; +@Validated @RequiredArgsConstructor @RestController @RequestMapping("/api/v1") @@ -23,8 +27,8 @@ public class RankingV1Controller { @GetMapping("/rankings") public ApiResponse getRankings( @RequestParam(value = "date", required = false) String date, - @RequestParam(value = "page", defaultValue = "1") int page, - @RequestParam(value = "size", defaultValue = "20") int size + @RequestParam(value = "page", defaultValue = "1") @Min(1) int page, + @RequestParam(value = "size", defaultValue = "20") @Min(1) @Max(100) int size ) { // API는 1-based, 내부는 0-based로 변환 int zeroBasedPage = Math.max(0, page - 1); From 31047f5a56ca8ae08147e410f37b3fe46cd7bc22 Mon Sep 17 00:00:00 2001 From: Seoyeon Lee <68765200+sylee6529@users.noreply.github.com> Date: Fri, 26 Dec 2025 17:39:26 +0900 Subject: [PATCH 13/17] =?UTF-8?q?feat:=20IllegalArgumentException,=20Const?= =?UTF-8?q?raintViolationException=20=ED=95=B8=EB=93=A4=EB=9F=AC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 잘못된 날짜 형식 등 IllegalArgumentException 400 처리 - @Min/@Max 검증 실패 시 ConstraintViolationException 400 처리 --- .../interfaces/api/ApiControllerAdvice.java | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java index 377a3d28c..52b01c5bb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java @@ -18,6 +18,7 @@ import org.springframework.web.server.ServerWebInputException; import org.springframework.web.servlet.resource.NoResourceFoundException; +import jakarta.validation.ConstraintViolationException; import java.util.Arrays; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -119,6 +120,26 @@ public ResponseEntity> handleBadRequest(ServerWebInputException e } } + @ExceptionHandler + public ResponseEntity> handleBadRequest(IllegalArgumentException e) { + log.warn("IllegalArgumentException : {}", e.getMessage()); + return failureResponse(ErrorType.BAD_REQUEST, e.getMessage()); + } + + @ExceptionHandler + public ResponseEntity> handleBadRequest(ConstraintViolationException e) { + String message = e.getConstraintViolations().stream() + .map(v -> { + String path = v.getPropertyPath().toString(); + // "getRankings.page" -> "page" + String field = path.contains(".") ? path.substring(path.lastIndexOf('.') + 1) : path; + return String.format("'%s': %s", field, v.getMessage()); + }) + .collect(Collectors.joining(", ")); + log.warn("ConstraintViolationException : {}", message); + return failureResponse(ErrorType.BAD_REQUEST, message); + } + @ExceptionHandler public ResponseEntity> handleNotFound(NoResourceFoundException e) { return failureResponse(ErrorType.NOT_FOUND, null); From 836b8090f860febfe21aa47eac770d06148666c3 Mon Sep 17 00:00:00 2001 From: Seoyeon Lee <68765200+sylee6529@users.noreply.github.com> Date: Fri, 26 Dec 2025 17:39:35 +0900 Subject: [PATCH 14/17] =?UTF-8?q?fix:=20=EA=B8=B0=EC=A1=B4=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=8B=A4=ED=8C=A8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LikeServiceIntegrationTest: LikeFacade를 통해 멱등성 보장 - DlqPublisherTest: RedisTestContainersConfig 추가 - commerce-streamer CacheConfig 추가 (RedisTemplate) --- .../like/LikeServiceIntegrationTest.java | 8 +++- .../infrastructure/cache/CacheConfig.java | 37 +++++++++++++++++++ .../kafka/DlqPublisherTest.java | 3 ++ 3 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/cache/CacheConfig.java diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java index c72057eb2..87692e6f1 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java @@ -1,5 +1,6 @@ package com.loopers.domain.like; +import com.loopers.application.like.LikeFacade; import com.loopers.domain.common.vo.Money; import com.loopers.domain.like.repository.LikeRepository; import com.loopers.domain.like.service.LikeService; @@ -23,6 +24,9 @@ class LikeServiceIntegrationTest { @Autowired private LikeService likeService; + @Autowired + private LikeFacade likeFacade; + @Autowired private LikeRepository likeRepository; @@ -76,10 +80,10 @@ void duplicateLike() { Member member = memberRepository.save(createMember("user1", "u1@mail.com")); Product product = productRepository.save(createProduct(1L, "상품A", 1000L, 10)); - likeService.like(member.getId(), product.getId()); + likeFacade.likeProduct(member.getId(), product.getId()); // when - likeService.like(member.getId(), product.getId()); // 중복 호출 + likeFacade.likeProduct(member.getId(), product.getId()); // 중복 호출 - Facade에서 멱등성 보장 // then long likeCount = likeRepository.countByProductId(product.getId()); diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/cache/CacheConfig.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/cache/CacheConfig.java new file mode 100644 index 000000000..5eb7f1558 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/cache/CacheConfig.java @@ -0,0 +1,37 @@ +package com.loopers.infrastructure.cache; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class CacheConfig { + + @Bean + public RedisTemplate cacheRedisTemplate(LettuceConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + + // Key serializer + template.setKeySerializer(new StringRedisSerializer()); + template.setHashKeySerializer(new StringRedisSerializer()); + + // Value serializer - JSON + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer(objectMapper); + template.setValueSerializer(jsonSerializer); + template.setHashValueSerializer(jsonSerializer); + + template.afterPropertiesSet(); + return template; + } +} diff --git a/apps/commerce-streamer/src/test/java/com/loopers/infrastructure/kafka/DlqPublisherTest.java b/apps/commerce-streamer/src/test/java/com/loopers/infrastructure/kafka/DlqPublisherTest.java index 3988a519d..21611d144 100644 --- a/apps/commerce-streamer/src/test/java/com/loopers/infrastructure/kafka/DlqPublisherTest.java +++ b/apps/commerce-streamer/src/test/java/com/loopers/infrastructure/kafka/DlqPublisherTest.java @@ -3,11 +3,13 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.loopers.domain.dlq.DlqMessage; import com.loopers.infrastructure.persistence.DlqMessageRepository; +import com.loopers.testcontainers.RedisTestContainersConfig; import org.apache.kafka.clients.consumer.ConsumerRecord; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; import org.springframework.test.context.ActiveProfiles; import org.springframework.transaction.annotation.Transactional; @@ -18,6 +20,7 @@ */ @SpringBootTest @ActiveProfiles("test") +@Import(RedisTestContainersConfig.class) @Transactional class DlqPublisherTest { From 9c3b0e08ea0c3193a1e679869da63ec4cf320218 Mon Sep 17 00:00:00 2001 From: Seoyeon Lee <68765200+sylee6529@users.noreply.github.com> Date: Fri, 26 Dec 2025 17:39:42 +0900 Subject: [PATCH 15/17] =?UTF-8?q?test:=20=EC=83=81=ED=92=88=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20=EC=8B=9C=20=EB=9E=AD=ED=82=B9?= =?UTF-8?q?=20=EC=A0=95=EB=B3=B4=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProductFacadeTest: 랭킹 포함/미포함 단위 테스트 - ProductV1ApiE2ETest: 랭킹 정보 E2E 테스트 --- .../product/ProductFacadeTest.java | 211 ++++++++++++++++++ .../api/product/ProductV1ApiE2ETest.java | 158 ++++++++++++- 2 files changed, 368 insertions(+), 1 deletion(-) create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java new file mode 100644 index 000000000..7c9ad32fd --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java @@ -0,0 +1,211 @@ +package com.loopers.application.product; + +import com.loopers.application.event.product.ProductViewedEvent; +import com.loopers.domain.common.vo.Money; +import com.loopers.domain.like.service.LikeReadService; +import com.loopers.domain.product.service.ProductReadService; +import com.loopers.domain.product.vo.Stock; +import com.loopers.infrastructure.cache.ProductDetailCache; +import com.loopers.infrastructure.cache.ProductListCache; +import com.loopers.infrastructure.cache.ProductRankingCache; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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 org.springframework.context.ApplicationEventPublisher; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class ProductFacadeTest { + + @Mock + private ProductReadService productReadService; + + @Mock + private LikeReadService likeReadService; + + @Mock + private ProductDetailCache productDetailCache; + + @Mock + private ProductListCache productListCache; + + @Mock + private ProductRankingCache productRankingCache; + + @Mock + private ApplicationEventPublisher eventPublisher; + + @InjectMocks + private ProductFacade productFacade; + + private static final Long PRODUCT_ID = 100L; + private static final Long MEMBER_ID = 1L; + private static final Long BRAND_ID = 10L; + + private ProductDetailInfo createCachedProductDetailInfo() { + return ProductDetailInfo.builder() + .id(PRODUCT_ID) + .name("테스트 상품") + .description("테스트 설명") + .brandId(BRAND_ID) + .brandName("테스트 브랜드") + .brandDescription("브랜드 설명") + .price(Money.of(10000)) + .stock(Stock.of(100)) + .likeCount(50) + .isLikedByMember(false) + .ranking(null) + .build(); + } + + @DisplayName("상품 상세 조회 (getProductDetail)") + @Nested + class GetProductDetail { + + @DisplayName("랭킹에 존재하는 상품이면 순위가 포함되어 반환된다") + @Test + void shouldIncludeRanking_whenProductIsRanked() { + // given + ProductDetailInfo cachedInfo = createCachedProductDetailInfo(); + when(productDetailCache.get(PRODUCT_ID)).thenReturn(Optional.of(cachedInfo)); + when(likeReadService.isLikedBy(MEMBER_ID, PRODUCT_ID)).thenReturn(false); + when(productRankingCache.getRank(PRODUCT_ID)).thenReturn(5); // 5위 + + // when + ProductDetailInfo result = productFacade.getProductDetail(PRODUCT_ID, MEMBER_ID); + + // then + assertThat(result.getRanking()).isEqualTo(5); + verify(productRankingCache).getRank(PRODUCT_ID); + } + + @DisplayName("랭킹에 존재하지 않는 상품이면 순위가 null로 반환된다") + @Test + void shouldReturnNullRanking_whenProductIsNotRanked() { + // given + ProductDetailInfo cachedInfo = createCachedProductDetailInfo(); + when(productDetailCache.get(PRODUCT_ID)).thenReturn(Optional.of(cachedInfo)); + when(likeReadService.isLikedBy(MEMBER_ID, PRODUCT_ID)).thenReturn(false); + when(productRankingCache.getRank(PRODUCT_ID)).thenReturn(null); // 순위권 밖 + + // when + ProductDetailInfo result = productFacade.getProductDetail(PRODUCT_ID, MEMBER_ID); + + // then + assertThat(result.getRanking()).isNull(); + verify(productRankingCache).getRank(PRODUCT_ID); + } + + @DisplayName("랭킹 1위 상품이면 순위가 1로 반환된다") + @Test + void shouldReturnRanking1_whenProductIsFirstPlace() { + // given + ProductDetailInfo cachedInfo = createCachedProductDetailInfo(); + when(productDetailCache.get(PRODUCT_ID)).thenReturn(Optional.of(cachedInfo)); + when(likeReadService.isLikedBy(MEMBER_ID, PRODUCT_ID)).thenReturn(true); + when(productRankingCache.getRank(PRODUCT_ID)).thenReturn(1); // 1위 + + // when + ProductDetailInfo result = productFacade.getProductDetail(PRODUCT_ID, MEMBER_ID); + + // then + assertThat(result.getRanking()).isEqualTo(1); + assertThat(result.isLikedByMember()).isTrue(); + } + + @DisplayName("비로그인 사용자도 랭킹 정보가 포함되어 반환된다") + @Test + void shouldIncludeRanking_whenUserNotLoggedIn() { + // given + ProductDetailInfo cachedInfo = createCachedProductDetailInfo(); + when(productDetailCache.get(PRODUCT_ID)).thenReturn(Optional.of(cachedInfo)); + when(productRankingCache.getRank(PRODUCT_ID)).thenReturn(10); // 10위 + + // when + ProductDetailInfo result = productFacade.getProductDetail(PRODUCT_ID, null); + + // then + assertThat(result.getRanking()).isEqualTo(10); + assertThat(result.isLikedByMember()).isFalse(); + verify(likeReadService, never()).isLikedBy(anyLong(), anyLong()); + } + + @DisplayName("캐시 miss 시 DB 조회 후에도 랭킹 정보가 포함된다") + @Test + void shouldIncludeRanking_whenCacheMiss() { + // given + ProductDetailInfo dbInfo = createCachedProductDetailInfo(); + when(productDetailCache.get(PRODUCT_ID)).thenReturn(Optional.empty()); // 캐시 miss + when(productReadService.getProductDetail(PRODUCT_ID, null)).thenReturn(dbInfo); + when(likeReadService.isLikedBy(MEMBER_ID, PRODUCT_ID)).thenReturn(false); + when(productRankingCache.getRank(PRODUCT_ID)).thenReturn(3); + + // when + ProductDetailInfo result = productFacade.getProductDetail(PRODUCT_ID, MEMBER_ID); + + // then + assertThat(result.getRanking()).isEqualTo(3); + verify(productDetailCache).set(PRODUCT_ID, dbInfo); // 캐시 저장 확인 + verify(productRankingCache).getRank(PRODUCT_ID); + } + + @DisplayName("상품 조회 이벤트가 발행된다") + @Test + void shouldPublishProductViewedEvent() { + // given + ProductDetailInfo cachedInfo = createCachedProductDetailInfo(); + when(productDetailCache.get(PRODUCT_ID)).thenReturn(Optional.of(cachedInfo)); + when(likeReadService.isLikedBy(MEMBER_ID, PRODUCT_ID)).thenReturn(false); + when(productRankingCache.getRank(PRODUCT_ID)).thenReturn(5); + + // when + productFacade.getProductDetail(PRODUCT_ID, MEMBER_ID); + + // then + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(ProductViewedEvent.class); + verify(eventPublisher).publishEvent(eventCaptor.capture()); + + ProductViewedEvent capturedEvent = eventCaptor.getValue(); + assertThat(capturedEvent.productId()).isEqualTo(PRODUCT_ID); + assertThat(capturedEvent.memberId()).isEqualTo(MEMBER_ID); + assertThat(capturedEvent.brandId()).isEqualTo(BRAND_ID); + } + + @DisplayName("다른 필드들이 올바르게 유지된다") + @Test + void shouldPreserveOtherFields() { + // given + ProductDetailInfo cachedInfo = createCachedProductDetailInfo(); + when(productDetailCache.get(PRODUCT_ID)).thenReturn(Optional.of(cachedInfo)); + when(likeReadService.isLikedBy(MEMBER_ID, PRODUCT_ID)).thenReturn(true); + when(productRankingCache.getRank(PRODUCT_ID)).thenReturn(7); + + // when + ProductDetailInfo result = productFacade.getProductDetail(PRODUCT_ID, MEMBER_ID); + + // then + assertThat(result.getId()).isEqualTo(PRODUCT_ID); + assertThat(result.getName()).isEqualTo("테스트 상품"); + assertThat(result.getDescription()).isEqualTo("테스트 설명"); + assertThat(result.getBrandId()).isEqualTo(BRAND_ID); + assertThat(result.getBrandName()).isEqualTo("테스트 브랜드"); + assertThat(result.getBrandDescription()).isEqualTo("브랜드 설명"); + assertThat(result.getPrice().getAmount().intValue()).isEqualTo(10000); + assertThat(result.getStock().getQuantity()).isEqualTo(100); + assertThat(result.getLikeCount()).isEqualTo(50); + assertThat(result.isLikedByMember()).isTrue(); + assertThat(result.getRanking()).isEqualTo(7); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ApiE2ETest.java index c17107ac0..8479682f9 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ApiE2ETest.java @@ -9,23 +9,31 @@ import com.loopers.domain.product.Product; import com.loopers.domain.product.vo.Stock; import com.loopers.domain.product.repository.ProductRepository; +import com.loopers.infrastructure.cache.CacheKeyGenerator; import com.loopers.interfaces.api.ApiResponse; +import com.loopers.testcontainers.RedisTestContainersConfig; import com.loopers.utils.DatabaseCleanUp; import com.loopers.utils.RestPage; import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.context.annotation.Import; import org.springframework.core.ParameterizedTypeReference; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + import java.math.BigDecimal; import static org.assertj.core.api.Assertions.assertThat; @@ -33,6 +41,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Import(RedisTestContainersConfig.class) class ProductV1ApiE2ETest { private final TestRestTemplate testRestTemplate; @@ -40,6 +49,9 @@ class ProductV1ApiE2ETest { private final BrandRepository brandRepository; private final ProductRepository productRepository; private final DatabaseCleanUp databaseCleanUp; + private final RedisTemplate redisTemplate; + + private String todayDate; @Autowired public ProductV1ApiE2ETest( @@ -47,20 +59,34 @@ public ProductV1ApiE2ETest( MemberFacade memberFacade, BrandRepository brandRepository, ProductRepository productRepository, - DatabaseCleanUp databaseCleanUp + DatabaseCleanUp databaseCleanUp, + RedisTemplate redisTemplate ) { this.testRestTemplate = testRestTemplate; this.memberFacade = memberFacade; this.brandRepository = brandRepository; this.productRepository = productRepository; this.databaseCleanUp = databaseCleanUp; + this.redisTemplate = redisTemplate; + } + + @BeforeEach + void setUp() { + todayDate = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); + redisTemplate.getConnectionFactory().getConnection().flushDb(); } @AfterEach void tearDown() { + redisTemplate.getConnectionFactory().getConnection().flushDb(); databaseCleanUp.truncateAllTables(); } + private void addToRanking(Long productId, double score) { + String key = CacheKeyGenerator.dailyRankingKey(todayDate); + redisTemplate.opsForZSet().add(key, productId.toString(), score); + } + private record TestContext(Long memberId, Long productId) {} private TestContext setupProductAndMember() { @@ -287,6 +313,136 @@ void shouldReturn404_whenProductNotFound() { () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) ); } + + @DisplayName("랭킹에 존재하는 상품 조회 시 순위가 포함되어 반환된다") + @Test + void shouldIncludeRanking_whenProductIsRanked() { + // given + TestContext context = setupProductAndMember(); + Long productId = context.productId(); + + // 랭킹에 상품 추가 (1위) + addToRanking(productId, 100.0); + + HttpHeaders headers = headersOf(context.memberId()); + + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange("/api/v1/products/" + productId, HttpMethod.GET, + new HttpEntity<>(null, headers), responseType); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data()).isNotNull(), + () -> assertThat(response.getBody().data().id()).isEqualTo(productId), + () -> assertThat(response.getBody().data().ranking()).isEqualTo(1) + ); + } + + @DisplayName("여러 상품이 랭킹에 있을 때 올바른 순위가 반환된다") + @Test + void shouldReturnCorrectRanking_whenMultipleProductsRanked() { + // given + Long memberId = memberFacade.registerMember("test123", "test@example.com", "password", "1990-01-01", Gender.MALE).id(); + Brand brand = brandRepository.save(new Brand("TestBrand", "Test Brand Description")); + + Product product1 = productRepository.save(new Product( + brand.getId(), "상품1", "설명1", + Money.of(BigDecimal.valueOf(10000)), Stock.of(100))); + Product product2 = productRepository.save(new Product( + brand.getId(), "상품2", "설명2", + Money.of(BigDecimal.valueOf(20000)), Stock.of(100))); + Product product3 = productRepository.save(new Product( + brand.getId(), "상품3", "설명3", + Money.of(BigDecimal.valueOf(30000)), Stock.of(100))); + + // 랭킹 추가 (점수가 높을수록 순위가 높음) + addToRanking(product1.getId(), 100.0); // 1위 + addToRanking(product2.getId(), 80.0); // 2위 + addToRanking(product3.getId(), 60.0); // 3위 + + HttpHeaders headers = headersOf(memberId); + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + + // when & then - 1위 상품 조회 + ResponseEntity> response1 = + testRestTemplate.exchange("/api/v1/products/" + product1.getId(), HttpMethod.GET, + new HttpEntity<>(null, headers), responseType); + assertThat(response1.getBody().data().ranking()).isEqualTo(1); + + // when & then - 2위 상품 조회 + ResponseEntity> response2 = + testRestTemplate.exchange("/api/v1/products/" + product2.getId(), HttpMethod.GET, + new HttpEntity<>(null, headers), responseType); + assertThat(response2.getBody().data().ranking()).isEqualTo(2); + + // when & then - 3위 상품 조회 + ResponseEntity> response3 = + testRestTemplate.exchange("/api/v1/products/" + product3.getId(), HttpMethod.GET, + new HttpEntity<>(null, headers), responseType); + assertThat(response3.getBody().data().ranking()).isEqualTo(3); + } + + @DisplayName("랭킹에 존재하지 않는 상품 조회 시 ranking이 null로 반환된다") + @Test + void shouldReturnNullRanking_whenProductNotRanked() { + // given + TestContext context = setupProductAndMember(); + Long productId = context.productId(); + + // 랭킹에 추가하지 않음 + + HttpHeaders headers = headersOf(context.memberId()); + + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange("/api/v1/products/" + productId, HttpMethod.GET, + new HttpEntity<>(null, headers), responseType); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data()).isNotNull(), + () -> assertThat(response.getBody().data().id()).isEqualTo(productId), + () -> assertThat(response.getBody().data().ranking()).isNull() + ); + } + + @DisplayName("비로그인 사용자도 상품 조회 시 랭킹 정보가 포함된다") + @Test + void shouldIncludeRanking_whenUserNotLoggedIn() { + // given + Brand brand = brandRepository.save(new Brand("TestBrand", "Test Brand Description")); + Product product = productRepository.save(new Product( + brand.getId(), "테스트 상품", "설명", + Money.of(BigDecimal.valueOf(10000)), Stock.of(100))); + + addToRanking(product.getId(), 50.0); + + // when - X-USER-ID 헤더 없이 요청 + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange("/api/v1/products/" + product.getId(), HttpMethod.GET, + new HttpEntity<>(null, null), responseType); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data()).isNotNull(), + () -> assertThat(response.getBody().data().ranking()).isEqualTo(1), + () -> assertThat(response.getBody().data().isLikedByMember()).isFalse() + ); + } } @DisplayName("인기 상품 TOP100 조회 (GET /api/v1/products/popular)") From ee508d04abd7a9d2c16a88e53aa23a18308f3a20 Mon Sep 17 00:00:00 2001 From: Seoyeon Lee <68765200+sylee6529@users.noreply.github.com> Date: Fri, 26 Dec 2025 17:57:09 +0900 Subject: [PATCH 16/17] =?UTF-8?q?refactor:=20Info=20=ED=81=B4=EB=9E=98?= =?UTF-8?q?=EC=8A=A4=20@Getter=20=EC=95=A0=EB=84=88=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../product/ProductDetailInfo.java | 25 +++---------------- .../product/ProductSummaryInfo.java | 19 +++----------- 2 files changed, 6 insertions(+), 38 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java index 1e40a171c..67cb7015c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java @@ -1,14 +1,13 @@ package com.loopers.application.product; import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; -import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; import com.loopers.domain.common.vo.Money; import com.loopers.domain.product.vo.Stock; import lombok.Builder; +import lombok.Getter; +@Getter @Builder -@JsonDeserialize(builder = ProductDetailInfo.ProductDetailInfoBuilder.class) public class ProductDetailInfo { private final Long id; @@ -20,25 +19,7 @@ public class ProductDetailInfo { private final Money price; private final Stock stock; private final int likeCount; + @JsonProperty("likedByMember") private final boolean isLikedByMember; private final Integer ranking; // 순위 (1-based), 순위권 밖이면 null - - public Long getId() { return id; } - public String getName() { return name; } - public String getDescription() { return description; } - public Long getBrandId() { return brandId; } - public String getBrandName() { return brandName; } - public String getBrandDescription() { return brandDescription; } - public Money getPrice() { return price; } - public Stock getStock() { return stock; } - public int getLikeCount() { return likeCount; } - - @JsonProperty("likedByMember") - public boolean isLikedByMember() { return isLikedByMember; } - - public Integer getRanking() { return ranking; } - - @JsonPOJOBuilder(withPrefix = "") - public static class ProductDetailInfoBuilder { - } } \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductSummaryInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductSummaryInfo.java index a7d279eee..909c04371 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductSummaryInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductSummaryInfo.java @@ -1,13 +1,12 @@ package com.loopers.application.product; import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; -import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; import com.loopers.domain.common.vo.Money; import lombok.Builder; +import lombok.Getter; +@Getter @Builder -@JsonDeserialize(builder = ProductSummaryInfo.ProductSummaryInfoBuilder.class) public class ProductSummaryInfo { private final Long id; @@ -15,18 +14,6 @@ public class ProductSummaryInfo { private final String brandName; private final Money price; private final int likeCount; - private final boolean isLikedByMember; - - public Long getId() { return id; } - public String getName() { return name; } - public String getBrandName() { return brandName; } - public Money getPrice() { return price; } - public int getLikeCount() { return likeCount; } - @JsonProperty("likedByMember") - public boolean isLikedByMember() { return isLikedByMember; } - - @JsonPOJOBuilder(withPrefix = "") - public static class ProductSummaryInfoBuilder { - } + private final boolean isLikedByMember; } \ No newline at end of file From e89e5c40f8395a962e4b143b0a997d0ff1a62a4e Mon Sep 17 00:00:00 2001 From: Seoyeon Lee <68765200+sylee6529@users.noreply.github.com> Date: Fri, 26 Dec 2025 18:29:51 +0900 Subject: [PATCH 17/17] =?UTF-8?q?refactor:=20=EB=9E=AD=ED=82=B9=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EB=B6=88=ED=95=84=EC=9A=94=ED=95=9C=20?= =?UTF-8?q?=EC=A3=BC=EC=84=9D=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/ranking/RankingFacade.java | 15 +----- .../cache/ProductRankingCache.java | 38 ++------------ .../cache/ProductRankingCache.java | 51 ++----------------- 3 files changed, 10 insertions(+), 94 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java index 7937545a5..c8dfc8226 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java @@ -34,13 +34,7 @@ public class RankingFacade { private final ProductRepository productRepository; private final BrandRepository brandRepository; - /** - * 랭킹 페이지 조회 - * @param date 날짜 (yyyyMMdd), null이면 오늘 - * @param page 페이지 번호 (0-based) - * @param size 페이지 크기 - * @return 랭킹 페이지 정보 - */ + /** @param page 0-based */ public RankingPageInfo getRankings(String date, int page, int size) { // 날짜 검증 및 기본값 처리 String targetDate = validateAndNormalizeDate(date); @@ -101,12 +95,7 @@ public RankingPageInfo getRankings(String date, int page, int size) { return RankingPageInfo.of(rankings, targetDate, page, size, totalCount); } - /** - * 날짜 파라미터 검증 및 정규화 - * @param date 날짜 문자열 (yyyyMMdd 형식) 또는 null - * @return 검증된 날짜 문자열 (null이면 오늘 날짜) - * @throws IllegalArgumentException 유효하지 않은 날짜 형식 - */ + /** @throws IllegalArgumentException 유효하지 않은 날짜 형식 */ private String validateAndNormalizeDate(String date) { if (date == null || date.isBlank()) { return productRankingCache.getTodayDate(); diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/ProductRankingCache.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/ProductRankingCache.java index 5a03be02e..963131d99 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/ProductRankingCache.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/ProductRankingCache.java @@ -29,21 +29,12 @@ public class ProductRankingCache { private final RedisTemplate cacheRedisTemplate; - /** - * 상품의 오늘 날짜 기준 순위 조회 - * @param productId 상품 ID - * @return 순위 (1-based), 순위권 밖이면 null - */ + /** @return 순위 (1-based), 순위권 밖이면 null */ public Integer getRank(Long productId) { return getRank(productId, getTodayDate()); } - /** - * 상품의 특정 날짜 순위 조회 - * @param productId 상품 ID - * @param date 날짜 (yyyyMMdd 형식) - * @return 순위 (1-based), 순위권 밖이면 null - */ + /** @return 순위 (1-based), 순위권 밖이면 null */ public Integer getRank(Long productId, String date) { try { String key = CacheKeyGenerator.dailyRankingKey(date); @@ -65,18 +56,10 @@ public Integer getRank(Long productId, String date) { } } - /** - * 상품의 점수 조회 - * @param productId 상품 ID - * @return 점수, 없으면 null - */ public Double getScore(Long productId) { return getScore(productId, getTodayDate()); } - /** - * 상품의 특정 날짜 점수 조회 - */ public Double getScore(Long productId, String date) { try { String key = CacheKeyGenerator.dailyRankingKey(date); @@ -88,20 +71,11 @@ public Double getScore(Long productId, String date) { } } - /** - * 오늘 날짜 문자열 반환 (Asia/Seoul 기준) - */ public String getTodayDate() { return LocalDate.now(ZONE_ID).format(DATE_FORMATTER); } - /** - * 랭킹 상위 목록 조회 (페이지네이션) - * @param date 날짜 (yyyyMMdd 형식) - * @param page 페이지 번호 (0-based) - * @param size 페이지 크기 - * @return 상품 ID와 점수 목록 (순위 높은 순) - */ + /** @param page 0-based */ public List getTopRankings(String date, int page, int size) { try { String key = CacheKeyGenerator.dailyRankingKey(date); @@ -134,9 +108,6 @@ public List getTopRankings(String date, int page, int size) { } } - /** - * 전체 랭킹 수 조회 - */ public long getTotalCount(String date) { try { String key = CacheKeyGenerator.dailyRankingKey(date); @@ -148,8 +119,5 @@ public long getTotalCount(String date) { } } - /** - * 랭킹 엔트리 (순위, 상품ID, 점수) - */ public record RankingEntry(int rank, Long productId, Double score) {} } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/cache/ProductRankingCache.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/cache/ProductRankingCache.java index 300b74693..5a6a36f2a 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/cache/ProductRankingCache.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/cache/ProductRankingCache.java @@ -45,44 +45,26 @@ public ProductRankingCache(RedisTemplate cacheRedisTemplate, Ran this.rankingConfig = rankingConfig; } - /** - * 조회 이벤트 점수 추가 - */ public void addViewScore(Long productId) { double score = rankingConfig.getWeight().getView(); incrementScore(productId, score); log.debug("[Ranking] View score added - productId: {}, score: {}", productId, score); } - /** - * 좋아요 이벤트 점수 추가 - */ public void addLikeScore(Long productId) { double score = rankingConfig.getWeight().getLike(); incrementScore(productId, score); log.debug("[Ranking] Like score added - productId: {}, score: {}", productId, score); } - /** - * 좋아요 취소 이벤트 점수 감소 - * - 좋아요 추가와 동일한 가중치만큼 감소 - * - 점수가 0 이하가 되어도 ZSET에서 자동 제거되지 않음 (일관성 유지) - */ + /** 점수가 0 이하가 되어도 ZSET에서 자동 제거되지 않음 (일관성 유지) */ public void subtractLikeScore(Long productId) { double score = rankingConfig.getWeight().getLike(); decrementScore(productId, score); log.debug("[Ranking] Like score subtracted - productId: {}, score: -{}", productId, score); } - /** - * 주문 이벤트 점수 추가 - * - log 정규화를 적용하여 고가 상품의 과도한 점수 편중 방지 - * - Math.log1p(x) = ln(1 + x)로 안전하게 계산 - * - * @param productId 상품 ID - * @param price 상품 단가 - * @param quantity 주문 수량 - */ + /** log 정규화로 고가 상품의 과도한 점수 편중 방지: log1p(price * qty) */ public void addOrderScore(Long productId, long price, int quantity) { double weight = rankingConfig.getWeight().getOrder(); long orderAmount = price * quantity; @@ -94,12 +76,7 @@ public void addOrderScore(Long productId, long price, int quantity) { productId, price, quantity, orderAmount, score); } - /** - * 주문 아이템 배치 점수 추가 (Pipeline 사용) - * - 여러 아이템을 한 번의 Redis 요청으로 처리하여 네트워크 오버헤드 감소 - * - * @param orderItems 주문 아이템 목록 (productId -> OrderItemScore) - */ + /** Pipeline으로 여러 아이템을 한 번의 Redis 요청으로 처리 */ public void addOrderScoresBatch(List orderItems) { if (orderItems == null || orderItems.isEmpty()) { return; @@ -136,14 +113,8 @@ public Object execute(RedisOperations operations) throws DataAccessException { } } - /** - * 주문 아이템 점수 정보 - */ public record OrderItemScore(Long productId, long price, int quantity) {} - /** - * ZSET 점수 증가 (ZINCRBY) - */ private void incrementScore(Long productId, double score) { try { String key = getTodayKey(); @@ -155,9 +126,6 @@ private void incrementScore(Long productId, double score) { } } - /** - * ZSET 점수 감소 (ZINCRBY with negative value) - */ private void decrementScore(Long productId, double score) { try { String key = getTodayKey(); @@ -170,10 +138,7 @@ private void decrementScore(Long productId, double score) { } } - /** - * TTL 설정 (원자적 처리) - * getAndSet으로 키 변경 시에만 TTL 설정 (날짜가 바뀌는 경우) - */ + /** getAndSet으로 키 변경 시에만 TTL 설정 (날짜가 바뀌는 시점) */ private void ensureTtl(String key) { String oldKey = ttlInitializedKey.getAndSet(key); if (!key.equals(oldKey)) { @@ -182,18 +147,12 @@ private void ensureTtl(String key) { } } - /** - * 오늘 날짜 키 생성 (Asia/Seoul 기준) - * CacheKeyGenerator를 사용하여 commerce-api와 동일한 키 형식 보장 - */ + /** Asia/Seoul 기준, commerce-api와 동일한 키 형식 */ private String getTodayKey() { String date = LocalDate.now(ZONE_ID).format(DATE_FORMATTER); return CacheKeyGenerator.dailyRankingKey(date); } - /** - * 특정 날짜 키 생성 - */ public static String getKeyForDate(String date) { return CacheKeyGenerator.dailyRankingKey(date); }