From 1458c5f4b71d21bfd7084ed1cd7913c520aae76a Mon Sep 17 00:00:00 2001 From: hyujikoh Date: Tue, 23 Dec 2025 08:49:13 +0900 Subject: [PATCH 01/18] =?UTF-8?q?feat(metrics):=20Kafka=20=EC=9D=B4?= =?UTF-8?q?=EB=B2=A4=ED=8A=B8=20=EB=B9=84=EB=8F=99=EA=B8=B0=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Kafka 이벤트 처리 시 비동기 방식으로 변경하여 성능 개선 - ExecutorService를 사용하여 이벤트 처리 스레드 풀 구성 - 종료 시 ExecutorService 정리 로직 추가 --- .../consumer/MetricsKafkaConsumer.java | 50 ++++++++++++------- 1 file changed, 33 insertions(+), 17 deletions(-) diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.java index d5d62dbe7..5bb0d314b 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.java @@ -1,6 +1,9 @@ package com.loopers.interfaces.consumer; import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import org.apache.kafka.clients.consumer.ConsumerRecord; import org.springframework.kafka.annotation.KafkaListener; @@ -16,6 +19,7 @@ import com.loopers.infrastructure.event.payloads.ProductViewPayloadV1; import com.loopers.infrastructure.event.payloads.StockDepletedPayloadV1; +import jakarta.annotation.PreDestroy; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -32,6 +36,7 @@ public class MetricsKafkaConsumer { private final MetricsService metricsService; private final EventDeserializer eventDeserializer; + private final ExecutorService executorService = Executors.newFixedThreadPool(100); @KafkaListener( topics = {"catalog-events"}, @@ -43,15 +48,18 @@ public void onCatalogEvents( log.debug("Processing {} catalog events", records.size()); - for (ConsumerRecord record : records) { - try { - processCatalogEvent(record); - } catch (Exception e) { - log.error("Failed to process catalog event: {}", record.value(), e); - // 개별 메시지 실패는 로그만 남기고 계속 진행 - // 전체 배치를 실패시키지 않음 - } - } + // 언제든지 변경 될 가능성 존재 + List> futures = records.stream() + .map(record -> CompletableFuture.runAsync(() -> { + try { + processCatalogEvent(record); + } catch (Exception e) { + log.error("Failed to process catalog event: {}", record.value(), e); + } + }, executorService)) + .toList(); + + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); ack.acknowledge(); log.debug("Acknowledged {} catalog events", records.size()); @@ -68,19 +76,27 @@ public void onOrderEvents( log.debug("Processing {} order events", records.size()); - for (ConsumerRecord record : records) { - try { - processOrderEvent(record); - } catch (Exception e) { - log.error("Failed to process order event: {}", record.value(), e); - // 개별 메시지 실패는 로그만 남기고 계속 진행 - } - } + List> futures = records.stream() + .map(record -> CompletableFuture.runAsync(() -> { + try { + processOrderEvent(record); + } catch (Exception e) { + log.error("Failed to process order event: {}", record.value(), e); + } + }, executorService)) + .toList(); + + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); ack.acknowledge(); log.debug("Acknowledged {} order events", records.size()); } + @PreDestroy + public void shutdown() { + executorService.shutdown(); + } + private void processCatalogEvent(ConsumerRecord record) { final DomainEventEnvelope envelope = eventDeserializer.deserializeEnvelope(record.value()); if (envelope == null || envelope.eventId() == null) { From 5de393921756ae24367829f0dbf1ffec2b9a4a90 Mon Sep 17 00:00:00 2001 From: hyujikoh Date: Tue, 23 Dec 2025 20:44:35 +0900 Subject: [PATCH 02/18] =?UTF-8?q?feat(ranking):=20=EB=9E=AD=ED=82=B9=20?= =?UTF-8?q?=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B8=B0=EB=8A=A5=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 - Redis ZSET을 이용한 랭킹 점수 업데이트 및 조회 기능 구현 - 일간 랭킹 키 생성 및 날짜 추출 메서드 추가 - 랭킹 아이템 및 점수 데이터 구조 정의 --- .../com/loopers/cache/CacheKeyGenerator.java | 37 ++++ .../loopers/cache/RankingRedisService.java | 183 ++++++++++++++++++ .../com/loopers/cache/dto/CachePayloads.java | 42 ++++ 3 files changed, 262 insertions(+) create mode 100644 modules/redis/src/main/java/com/loopers/cache/RankingRedisService.java create mode 100644 modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java diff --git a/modules/redis/src/main/java/com/loopers/cache/CacheKeyGenerator.java b/modules/redis/src/main/java/com/loopers/cache/CacheKeyGenerator.java index 5b5e9423f..4269024ed 100644 --- a/modules/redis/src/main/java/com/loopers/cache/CacheKeyGenerator.java +++ b/modules/redis/src/main/java/com/loopers/cache/CacheKeyGenerator.java @@ -1,5 +1,7 @@ package com.loopers.cache; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; import java.util.StringJoiner; import org.springframework.data.domain.Pageable; @@ -28,6 +30,10 @@ public class CacheKeyGenerator { public static final String METRICS_PREFIX = "metrics"; public static final String POPULAR_PREFIX = "popular"; public static final String LIST_PREFIX = "list"; + public static final String RANKING_PREFIX = "ranking"; + public static final String ALL_PREFIX = "all"; + + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); /** * 상품 상세 캐시 키: product:detail:{productId} @@ -127,4 +133,35 @@ private String generateSortString(Sort sort) { return sortJoiner.toString(); } + + /** + * 일간 랭킹 키 생성: ranking:all:20251223 + */ + public String generateDailyRankingKey(LocalDate date) { + return new StringJoiner(DELIMITER) + .add(RANKING_PREFIX) + .add(ALL_PREFIX) + .add(date.format(DATE_FORMATTER)) + .toString(); + } + + /** + * 현재 날짜 기준 랭킹 키 생성 + */ + public String generateTodayRankingKey() { + return generateDailyRankingKey(LocalDate.now()); + } + + /** + * 키에서 날짜 추출 + */ + public LocalDate extractDateFromKey(String key) { + String expectedPrefix = RANKING_PREFIX + DELIMITER + ALL_PREFIX + DELIMITER; + if (!key.startsWith(expectedPrefix)) { + throw new IllegalArgumentException("Invalid ranking key format: " + key); + } + + String dateStr = key.substring(expectedPrefix.length()); + return LocalDate.parse(dateStr, DATE_FORMATTER); + } } diff --git a/modules/redis/src/main/java/com/loopers/cache/RankingRedisService.java b/modules/redis/src/main/java/com/loopers/cache/RankingRedisService.java new file mode 100644 index 000000000..7de1d3fe7 --- /dev/null +++ b/modules/redis/src/main/java/com/loopers/cache/RankingRedisService.java @@ -0,0 +1,183 @@ +package com.loopers.cache; + +import java.time.Duration; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.springframework.data.redis.core.RedisCallback; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; +import org.springframework.stereotype.Service; + + +import com.loopers.cache.dto.CachePayloads; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Redis ZSET을 이용한 랭킹 시스템 서비스 + * + * @author hyunjikoh + * @since 2025.12.23 + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class RankingRedisService { + + private final RedisTemplate redisTemplate; + private final CacheKeyGenerator cacheKeyGenerator; + + private static final Duration RANKING_TTL = Duration.ofDays(2); // 2일 TTL + + /** + * 배치로 랭킹 점수 업데이트 + * + * @param scores 랭킹 점수 리스트 + * @param targetDate 대상 날짜 + */ + public void updateRankingScoresBatch(List scores, LocalDate targetDate) { + if (scores.isEmpty()) { + return; + } + + String rankingKey = cacheKeyGenerator.generateDailyRankingKey(targetDate); + ZSetOperations zSetOps = redisTemplate.opsForZSet(); + + try { + // 상품별로 점수 집계 + Map productScores = scores.stream() + .collect(Collectors.groupingBy( + CachePayloads.RankingScore::productId, + Collectors.summingDouble(CachePayloads.RankingScore::score) + )); + + // Redis Pipeline을 사용한 배치 업데이트 + redisTemplate.executePipelined((RedisCallback) connection -> { + productScores.forEach((productId, totalScore) -> { + zSetOps.incrementScore(rankingKey, productId.toString(), totalScore); + }); + return null; + }); + + // TTL 설정 + redisTemplate.expire(rankingKey, RANKING_TTL); + + log.debug("랭킹 점수 배치 업데이트 완료: key={}, products={}", + rankingKey, productScores.size()); + + } catch (Exception e) { + log.error("랭킹 점수 배치 업데이트 실패: key={}", rankingKey, e); + throw new RuntimeException("랭킹 업데이트 실패", e); + } + } + + /** + * 랭킹 조회 (페이징) + * + * @param date 날짜 + * @param page 페이지 (1부터 시작) + * @param size 페이지 크기 + * @return 랭킹 리스트 (상위부터) + */ + public List getRanking(LocalDate date, int page, int size) { + String rankingKey = cacheKeyGenerator.generateDailyRankingKey(date); + ZSetOperations zSetOps = redisTemplate.opsForZSet(); + + try { + // 페이징 계산 (Redis는 0부터 시작) + long start = (long) (page - 1) * size; + long end = start + size - 1; + + // 점수 높은 순으로 조회 (ZREVRANGE) + Set> rankingData = + zSetOps.reverseRangeWithScores(rankingKey, start, end); + + if (rankingData == null || rankingData.isEmpty()) { + log.debug("랭킹 데이터 없음: key={}, page={}, size={}", rankingKey, page, size); + return List.of(); + } + + // 순위 계산 (페이징 고려) + List result = new java.util.ArrayList<>(); + long currentRank = start + 1; // 1부터 시작하는 순위 + + for (ZSetOperations.TypedTuple tuple : rankingData) { + try { + Long productId = Long.parseLong(tuple.getValue()); + Double score = tuple.getScore(); + result.add(new RankingItem(currentRank, productId, score)); + currentRank++; + } catch (NumberFormatException e) { + log.warn("잘못된 상품 ID 형식: {}", tuple.getValue(), e); + } + } + + log.debug("랭킹 조회 완료: key={}, page={}, size={}, results={}", + rankingKey, page, size, result.size()); + + return result; + + } catch (Exception e) { + log.error("랭킹 조회 실패: key={}, page={}, size={}", rankingKey, page, size, e); + return List.of(); + } + } + + /** + * 특정 상품의 랭킹 조회 + * + * @param date 날짜 + * @param productId 상품 ID + * @return 랭킹 정보 (없으면 null) + */ + public RankingItem getProductRanking(LocalDate date, Long productId) { + String rankingKey = cacheKeyGenerator.generateDailyRankingKey(date); + ZSetOperations zSetOps = redisTemplate.opsForZSet(); + + try { + // 점수 조회 + Double score = zSetOps.score(rankingKey, productId.toString()); + if (score == null) { + log.debug("상품 랭킹 없음: key={}, productId={}", rankingKey, productId); + return null; + } + + // 순위 조회 (높은 점수부터 순위 매김) + Long rank = zSetOps.reverseRank(rankingKey, productId.toString()); + if (rank == null) { + log.warn("점수는 있지만 순위 조회 실패: key={}, productId={}", rankingKey, productId); + return null; + } + + RankingItem result = new RankingItem(rank + 1, productId, score); // Redis rank는 0부터 시작 + log.debug("상품 랭킹 조회 완료: {}", result); + + return result; + + } catch (Exception e) { + log.error("상품 랭킹 조회 실패: key={}, productId={}", rankingKey, productId, e); + return null; + } + } + + /** + * 랭킹 전체 개수 조회 + */ + public long getRankingCount(LocalDate date) { + String rankingKey = cacheKeyGenerator.generateDailyRankingKey(date); + Long count = redisTemplate.opsForZSet().zCard(rankingKey); + return count != null ? count : 0L; + } + + /** + * 랭킹 데이터 존재 여부 확인 + */ + public boolean hasRankingData(LocalDate date) { + return getRankingCount(date) > 0; + } +} diff --git a/modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java b/modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java new file mode 100644 index 000000000..937c47339 --- /dev/null +++ b/modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java @@ -0,0 +1,42 @@ +package com.loopers.cache.dto; + +/** + * + * @author hyunjikoh + * @since 2025. 12. 23. + */ +public class CachePayloads { + /** + * 랭킹 아이템 + */ + public record RankingItem( + long rank, + Long productId, + Double score + ) {} + + public record RankingScore( + Long productId, + EventType eventType, + double score, + long occurredAtEpochMillis + ) { + + public enum EventType { + PRODUCT_VIEW(0.1), // 조회: Weight = 0.1, Score = 1 + LIKE_ACTION(0.2), // 좋아요: Weight = 0.2, Score = 1 + PAYMENT_SUCCESS(0.6); // 주문: Weight = 0.6, Score = Math.log((단가 * 수량) + 1) + + private final double weight; + + EventType(double weight) { + this.weight = weight; + } + + public double getWeight() { + return weight; + } + } + + } +} From f3e0f94da1618fa849417f83b7720be17d951af0 Mon Sep 17 00:00:00 2001 From: hyujikoh Date: Wed, 24 Dec 2025 00:46:11 +0900 Subject: [PATCH 03/18] =?UTF-8?q?feat(ranking):=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B2=98=EB=A6=AC=20=EB=B0=8F=20=EB=9E=AD=ED=82=B9?= =?UTF-8?q?=20=EC=A0=90=EC=88=98=20=EC=83=9D=EC=84=B1=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 이벤트 처리 파사드 구현 - 다양한 이벤트에 대한 랭킹 점수 생성 메서드 추가 - Kafka 컨슈머에서 이벤트 처리 로직 개선 - 랭킹 점수 배치 업데이트 기능 추가 --- .../event/EventProcessingFacade.java | 260 ++++++++++++++++++ .../domain/ranking/RankingService.java | 184 +++++++++++++ .../consumer/MetricsKafkaConsumer.java | 217 ++++----------- .../loopers/cache/RankingRedisService.java | 14 +- .../com/loopers/cache/dto/CachePayloads.java | 31 +++ 5 files changed, 540 insertions(+), 166 deletions(-) create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/application/event/EventProcessingFacade.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingService.java diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/event/EventProcessingFacade.java b/apps/commerce-streamer/src/main/java/com/loopers/application/event/EventProcessingFacade.java new file mode 100644 index 000000000..0953efeb4 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/event/EventProcessingFacade.java @@ -0,0 +1,260 @@ +package com.loopers.application.event; + +import java.time.LocalDate; +import java.util.List; + +import org.springframework.stereotype.Service; + +import com.loopers.cache.dto.CachePayloads.RankingScore; +import com.loopers.domain.metrics.MetricsService; +import com.loopers.domain.ranking.RankingService; +import com.loopers.infrastructure.event.DomainEventEnvelope; +import com.loopers.infrastructure.event.EventDeserializer; +import com.loopers.infrastructure.event.payloads.LikeActionPayloadV1; +import com.loopers.infrastructure.event.payloads.PaymentSuccessPayloadV1; +import com.loopers.infrastructure.event.payloads.ProductViewPayloadV1; +import com.loopers.infrastructure.event.payloads.StockDepletedPayloadV1; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * 이벤트 처리 파사드 + *

+ * 여러 도메인 서비스(메트릭, 랭킹 등)를 조합하여 이벤트를 처리하는 응용 계층 서비스 + * + * @author hyunjikoh + * @since 2025.12.23 + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class EventProcessingFacade { + + private final MetricsService metricsService; + private final RankingService rankingService; + private final EventDeserializer eventDeserializer; + + /** + * 카탈로그 이벤트 처리 결과 + */ + public record CatalogEventResult( + boolean processed, + RankingScore rankingScore + ) { + public static CatalogEventResult notProcessed() { + return new CatalogEventResult(false, null); + } + + public static CatalogEventResult processed(RankingScore rankingScore) { + return new CatalogEventResult(true, rankingScore); + } + } + + /** + * 주문 이벤트 처리 결과 + */ + public record OrderEventResult( + boolean processed, + RankingScore rankingScore + ) { + public static OrderEventResult notProcessed() { + return new OrderEventResult(false, null); + } + + public static OrderEventResult processed(RankingScore rankingScore) { + return new OrderEventResult(true, rankingScore); + } + } + + /** + * 카탈로그 이벤트 처리 (조회, 좋아요, 재고 소진) + * + * @param eventValue 이벤트 원본 데이터 + * @return 처리 결과 (랭킹 점수 포함) + */ + public CatalogEventResult processCatalogEvent(Object eventValue) { + final DomainEventEnvelope envelope = eventDeserializer.deserializeEnvelope(eventValue); + if (envelope == null || envelope.eventId() == null) { + log.warn("Invalid event envelope: {}", eventValue); + return CatalogEventResult.notProcessed(); + } + + // 과거 이벤트 필터링 (1시간 이상 된 이벤트는 무시) + if (isOldEvent(envelope.occurredAtEpochMillis())) { + log.debug("Ignoring old event: eventId={}, occurredAt={}", + envelope.eventId(), envelope.occurredAtEpochMillis()); + metricsService.tryMarkHandled(envelope.eventId()); + return CatalogEventResult.notProcessed(); + } + + // 멱등성 체크 - 이미 처리된 이벤트는 무시 + final boolean isFirstTime = metricsService.tryMarkHandled(envelope.eventId()); + if (!isFirstTime) { + log.debug("Event already processed: {}", envelope.eventId()); + return CatalogEventResult.notProcessed(); + } + + // 이벤트 타입별 처리 + return switch (envelope.eventType()) { + case "PRODUCT_VIEW" -> processProductView(envelope); + case "LIKE_ACTION" -> processLikeAction(envelope); + case "STOCK_DEPLETED" -> processStockDepleted(envelope); + default -> { + log.debug("Unhandled catalog event type: {}", envelope.eventType()); + yield CatalogEventResult.notProcessed(); + } + }; + } + + /** + * 주문 이벤트 처리 (결제 성공) + * + * @param eventValue 이벤트 원본 데이터 + * @return 처리 결과 (랭킹 점수 포함) + */ + public OrderEventResult processOrderEvent(Object eventValue) { + final DomainEventEnvelope envelope = eventDeserializer.deserializeEnvelope(eventValue); + if (envelope == null || envelope.eventId() == null) { + log.warn("Invalid event envelope: {}", eventValue); + return OrderEventResult.notProcessed(); + } + + // 과거 이벤트 필터링 + if (isOldEvent(envelope.occurredAtEpochMillis())) { + log.debug("Ignoring old event: eventId={}, occurredAt={}", + envelope.eventId(), envelope.occurredAtEpochMillis()); + metricsService.tryMarkHandled(envelope.eventId()); + return OrderEventResult.notProcessed(); + } + + // 멱등성 체크 + final boolean isFirstTime = metricsService.tryMarkHandled(envelope.eventId()); + if (!isFirstTime) { + log.debug("Event already processed: {}", envelope.eventId()); + return OrderEventResult.notProcessed(); + } + + // PAYMENT_SUCCESS 이벤트만 처리 + if ("PAYMENT_SUCCESS".equals(envelope.eventType())) { + return processPaymentSuccess(envelope); + } else { + log.debug("Unhandled order event type: {}", envelope.eventType()); + return OrderEventResult.notProcessed(); + } + } + + /** + * 랭킹 점수 배치 업데이트 + * + * @param rankingScores 랭킹 점수 리스트 + * @param targetDate 대상 날짜 + */ + public void updateRankingScores(List rankingScores, LocalDate targetDate) { + if (rankingScores == null || rankingScores.isEmpty()) { + return; + } + + try { + rankingService.updateRankingScoresBatch(rankingScores, targetDate); + log.debug("랭킹 점수 배치 업데이트 완료: {} scores", rankingScores.size()); + } catch (Exception e) { + log.error("랭킹 점수 배치 업데이트 실패: date={}, scores={}", targetDate, rankingScores.size(), e); + } + } + + // ========== Private Methods ========== + + private CatalogEventResult processProductView(DomainEventEnvelope envelope) { + final ProductViewPayloadV1 payload = eventDeserializer.deserializeProductView(envelope.payloadJson()); + if (payload == null || payload.productId() == null) { + log.warn("Invalid ProductView payload: {}", envelope.payloadJson()); + return CatalogEventResult.notProcessed(); + } + + // 메트릭 처리 + metricsService.incrementView(payload.productId(), envelope.occurredAtEpochMillis()); + log.debug("Processed PRODUCT_VIEW for productId: {}", payload.productId()); + + // 랭킹 점수 생성 + RankingScore rankingScore = rankingService.generateRankingScore(envelope); + return CatalogEventResult.processed(rankingScore); + } + + private CatalogEventResult processLikeAction(DomainEventEnvelope envelope) { + final LikeActionPayloadV1 payload = eventDeserializer.deserializeLikeAction(envelope.payloadJson()); + if (payload == null || payload.productId() == null || payload.action() == null) { + log.warn("Invalid LikeAction payload: {}", envelope.payloadJson()); + return CatalogEventResult.notProcessed(); + } + + // 메트릭 처리 + final int delta = "LIKE".equals(payload.action()) ? 1 : -1; + metricsService.applyLikeDelta(payload.productId(), delta, envelope.occurredAtEpochMillis()); + log.debug("Processed LIKE_ACTION for productId: {}, action: {}", payload.productId(), payload.action()); + + // 랭킹 점수 생성 (좋아요만 반영) + RankingScore rankingScore = rankingService.generateRankingScore(envelope); + return CatalogEventResult.processed(rankingScore); + } + + private CatalogEventResult processStockDepleted(DomainEventEnvelope envelope) { + final StockDepletedPayloadV1 payload = eventDeserializer.deserializeStockDepleted(envelope.payloadJson()); + if (payload == null || payload.productId() == null) { + log.warn("Invalid StockDepleted payload: {}", envelope.payloadJson()); + return CatalogEventResult.notProcessed(); + } + + // 재고 소진 이벤트 처리 + metricsService.handleStockDepleted( + payload.productId(), + payload.brandId(), + payload.remainingStock(), + envelope.occurredAtEpochMillis() + ); + + log.info("Processed STOCK_DEPLETED - productId: {}, brandId: {}, productName: {}, remainingStock: {}", + payload.productId(), payload.brandId(), payload.productName(), payload.remainingStock()); + + // 재고 소진은 랭킹에 영향 없음 + return CatalogEventResult.notProcessed(); + } + + private OrderEventResult processPaymentSuccess(DomainEventEnvelope envelope) { + final PaymentSuccessPayloadV1 payload = eventDeserializer.deserializePaymentSuccess(envelope.payloadJson()); + if (payload == null) { + log.warn("Invalid PaymentSuccess payload: {}", envelope.payloadJson()); + return OrderEventResult.notProcessed(); + } + + // 상품별 개별 이벤트 처리 + if (payload.productId() != null && payload.quantity() != null && payload.quantity() > 0) { + // 메트릭 처리 + metricsService.addSales(payload.productId(), payload.quantity(), envelope.occurredAtEpochMillis()); + + log.debug( + "Processed PAYMENT_SUCCESS - orderId: {}, orderNumber: {}, userId: {}, productId: {}, quantity: {}, unitPrice: {}, totalPrice: {}", + payload.orderId(), payload.orderNumber(), payload.userId(), + payload.productId(), payload.quantity(), payload.unitPrice(), payload.totalPrice()); + + // 랭킹 점수 생성 + RankingScore rankingScore = rankingService.generateRankingScore(envelope); + return OrderEventResult.processed(rankingScore); + } else { + log.warn("Invalid PaymentSuccess payload - missing required fields: productId={}, quantity={}", + payload.productId(), payload.quantity()); + return OrderEventResult.notProcessed(); + } + } + + /** + * 과거 이벤트인지 확인 (1시간 이상 된 이벤트는 과거 이벤트로 간주) + */ + private boolean isOldEvent(long occurredAtEpochMillis) { + long currentTime = System.currentTimeMillis(); + long eventAge = currentTime - occurredAtEpochMillis; + long oneHourInMillis = 60 * 60 * 1000; // 1시간 + + return eventAge > oneHourInMillis; + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingService.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingService.java new file mode 100644 index 000000000..af0e549cf --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingService.java @@ -0,0 +1,184 @@ +package com.loopers.domain.ranking; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +import org.springframework.stereotype.Service; + +import com.loopers.cache.RankingRedisService; +import com.loopers.cache.dto.CachePayloads.RankingItem; +import com.loopers.cache.dto.CachePayloads.RankingScore; +import com.loopers.infrastructure.event.DomainEventEnvelope; +import com.loopers.infrastructure.event.EventDeserializer; +import com.loopers.infrastructure.event.payloads.LikeActionPayloadV1; +import com.loopers.infrastructure.event.payloads.PaymentSuccessPayloadV1; +import com.loopers.infrastructure.event.payloads.ProductViewPayloadV1; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * 랭킹 도메인 서비스 + *

+ * 랭킹 점수 생성, 배치 업데이트, 조회 등의 비즈니스 로직을 담당 + * + * @author hyunjikoh + * @since 2025.12.23 + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class RankingService { + + private final RankingRedisService rankingRedisService; + private final EventDeserializer eventDeserializer; + + /** + * 이벤트로부터 랭킹 점수 생성 + * + * @param envelope 도메인 이벤트 엔벨로프 + * @return 랭킹 점수 (생성되지 않으면 null) + */ + public RankingScore generateRankingScore(DomainEventEnvelope envelope) { + if (envelope == null || envelope.eventType() == null) { + return null; + } + + return switch (envelope.eventType()) { + case "PRODUCT_VIEW" -> generateProductViewScore(envelope); + case "LIKE_ACTION" -> generateLikeActionScore(envelope); + case "PAYMENT_SUCCESS" -> generatePaymentSuccessScore(envelope); + default -> { + log.debug("랭킹 점수 생성 불가 - 지원하지 않는 이벤트 타입: {}", envelope.eventType()); + yield null; + } + }; + } + + /** + * 배치로 랭킹 점수 업데이트 + * + * @param rankingScores 랭킹 점수 리스트 + * @param targetDate 대상 날짜 (null이면 오늘) + */ + public void updateRankingScoresBatch(List rankingScores, LocalDate targetDate) { + if (rankingScores == null || rankingScores.isEmpty()) { + log.debug("업데이트할 랭킹 점수가 없음"); + return; + } + + LocalDate date = targetDate != null ? targetDate : LocalDate.now(); + + try { + rankingRedisService.updateRankingScoresBatch(rankingScores, date); + log.debug("랭킹 점수 배치 업데이트 완료: {} scores, date: {}", rankingScores.size(), date); + } catch (Exception e) { + log.error("랭킹 점수 배치 업데이트 실패: date={}, scores={}", date, rankingScores.size(), e); + throw new RankingUpdateException("랭킹 점수 업데이트 실패", e); + } + } + + /** + * 랭킹 조회 (페이징) + * + * @param date 날짜 (null이면 오늘) + * @param page 페이지 (1부터 시작) + * @param size 페이지 크기 + * @return 랭킹 리스트 + */ + public List getRanking(LocalDate date, int page, int size) { + if (page < 1 || size < 1) { + throw new IllegalArgumentException("페이지와 크기는 1 이상이어야 합니다"); + } + + LocalDate targetDate = date != null ? date : LocalDate.now(); + + try { + return rankingRedisService.getRanking(targetDate, page, size); + } catch (Exception e) { + log.error("랭킹 조회 실패: date={}, page={}, size={}", targetDate, page, size, e); + return new ArrayList<>(); + } + } + + /** + * 특정 상품의 랭킹 조회 + * + * @param productId 상품 ID + * @param date 날짜 (null이면 오늘) + * @return 랭킹 정보 (없으면 null) + */ + public RankingItem getProductRanking(Long productId, LocalDate date) { + if (productId == null) { + return null; + } + + LocalDate targetDate = date != null ? date : LocalDate.now(); + + try { + return rankingRedisService.getProductRanking(targetDate, productId); + } catch (Exception e) { + log.error("상품 랭킹 조회 실패: productId={}, date={}", productId, targetDate, e); + return null; + } + } + + /** + * 랭킹 데이터 존재 여부 확인 + */ + public boolean hasRankingData(LocalDate date) { + LocalDate targetDate = date != null ? date : LocalDate.now(); + return rankingRedisService.hasRankingData(targetDate); + } + + // ========== Private Methods ========== + + private RankingScore generateProductViewScore(DomainEventEnvelope envelope) { + ProductViewPayloadV1 payload = eventDeserializer.deserializeProductView(envelope.payloadJson()); + if (payload == null || payload.productId() == null) { + log.warn("상품 조회 이벤트 페이로드 오류: {}", envelope.payloadJson()); + return null; + } + + return RankingScore.forProductView(payload.productId(), envelope.occurredAtEpochMillis()); + } + + private RankingScore generateLikeActionScore(DomainEventEnvelope envelope) { + LikeActionPayloadV1 payload = eventDeserializer.deserializeLikeAction(envelope.payloadJson()); + if (payload == null || payload.productId() == null || payload.action() == null) { + log.warn("좋아요 이벤트 페이로드 오류: {}", envelope.payloadJson()); + return null; + } + + // 좋아요만 점수에 반영, 좋아요 취소는 반영하지 않음 + if ("LIKE".equals(payload.action())) { + return RankingScore.forLikeAction(payload.productId(), envelope.occurredAtEpochMillis()); + } + + return null; + } + + private RankingScore generatePaymentSuccessScore(DomainEventEnvelope envelope) { + PaymentSuccessPayloadV1 payload = eventDeserializer.deserializePaymentSuccess(envelope.payloadJson()); + if (payload == null || payload.productId() == null || payload.totalPrice() == null) { + log.warn("결제 성공 이벤트 페이로드 오류: {}", envelope.payloadJson()); + return null; + } + + return RankingScore.forPaymentSuccess( + payload.productId(), + payload.totalPrice(), + envelope.occurredAtEpochMillis() + ); + } + + /** + * 랭킹 업데이트 예외 + */ + public static class RankingUpdateException extends RuntimeException { + public RankingUpdateException(String message, Throwable cause) { + super(message, cause); + } + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.java index 5bb0d314b..f1b1def13 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.java @@ -1,30 +1,28 @@ package com.loopers.interfaces.consumer; +import java.time.LocalDate; +import java.util.ArrayList; import java.util.List; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; +import java.util.Objects; +import java.util.concurrent.*; import org.apache.kafka.clients.consumer.ConsumerRecord; import org.springframework.kafka.annotation.KafkaListener; import org.springframework.kafka.support.Acknowledgment; import org.springframework.stereotype.Component; +import com.loopers.application.event.EventProcessingFacade; +import com.loopers.cache.dto.CachePayloads.RankingScore; import com.loopers.confg.kafka.KafkaConfig; -import com.loopers.domain.metrics.MetricsService; -import com.loopers.infrastructure.event.DomainEventEnvelope; -import com.loopers.infrastructure.event.EventDeserializer; -import com.loopers.infrastructure.event.payloads.LikeActionPayloadV1; -import com.loopers.infrastructure.event.payloads.PaymentSuccessPayloadV1; -import com.loopers.infrastructure.event.payloads.ProductViewPayloadV1; -import com.loopers.infrastructure.event.payloads.StockDepletedPayloadV1; import jakarta.annotation.PreDestroy; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; /** - * 메트릭스 Kafka 컨슈머 - 멱등성과 최신성을 보장하는 안전한 이벤트 처리 + * 메트릭스 Kafka 컨슈머 + *

+ * Kafka 메시지를 수신하고 EventProcessingFacade에 위임하는 인터페이스 계층 * * @author hyunjikoh * @since 2025. 12. 16. @@ -34,9 +32,14 @@ @Slf4j public class MetricsKafkaConsumer { - private final MetricsService metricsService; - private final EventDeserializer eventDeserializer; - private final ExecutorService executorService = Executors.newFixedThreadPool(100); + private final EventProcessingFacade eventProcessingFacade; + // 리소스 제한이 있는 커스텀 스레드 풀 설정 + private final ExecutorService executorService = new ThreadPoolExecutor( + 20, 100, // Core, Max 스레드 + 60L, TimeUnit.SECONDS, + new LinkedBlockingQueue<>(1000), // 큐 크기 제한으로 OOM 방지 + new ThreadPoolExecutor.CallerRunsPolicy() // 큐가 꽉 차면 호출한 스레드(Kafka 리스너)가 직접 처리 + ); @KafkaListener( topics = {"catalog-events"}, @@ -48,21 +51,30 @@ public void onCatalogEvents( log.debug("Processing {} catalog events", records.size()); - // 언제든지 변경 될 가능성 존재 - List> futures = records.stream() - .map(record -> CompletableFuture.runAsync(() -> { + // 락 프리(Lock-free) 컬렉션 사용 + final List> futures = records.stream() + .map(record -> CompletableFuture.supplyAsync(() -> { try { - processCatalogEvent(record); + var result = eventProcessingFacade.processCatalogEvent(record.value()); + return (result.processed()) ? result.rankingScore() : null; } catch (Exception e) { - log.error("Failed to process catalog event: {}", record.value(), e); + log.error("Failed to process catalog event", e); + return null; } }, executorService)) .toList(); - CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); + // 모든 작업 완료 대기 및 결과 수집 + List rankingScores = futures.stream() + .map(CompletableFuture::join) + .filter(Objects::nonNull) + .toList(); + + if (!rankingScores.isEmpty()) { + eventProcessingFacade.updateRankingScores(rankingScores, LocalDate.now()); + } ack.acknowledge(); - log.debug("Acknowledged {} catalog events", records.size()); } @KafkaListener( @@ -74,19 +86,27 @@ public void onOrderEvents( final Acknowledgment ack ) { - log.debug("Processing {} order events", records.size()); - - List> futures = records.stream() - .map(record -> CompletableFuture.runAsync(() -> { + final List> futures = records.stream() + .map(record -> CompletableFuture.supplyAsync(() -> { try { - processOrderEvent(record); + var result = eventProcessingFacade.processOrderEvent(record.value()); + return (result.processed()) ? result.rankingScore() : null; } catch (Exception e) { - log.error("Failed to process order event: {}", record.value(), e); + log.error("Failed to process catalog event", e); + return null; } }, executorService)) .toList(); - CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); + // 모든 작업 완료 대기 및 결과 수집 + List rankingScores = futures.stream() + .map(CompletableFuture::join) + .filter(Objects::nonNull) + .toList(); + + if (!rankingScores.isEmpty()) { + eventProcessingFacade.updateRankingScores(rankingScores, LocalDate.now()); + } ack.acknowledge(); log.debug("Acknowledged {} order events", records.size()); @@ -94,138 +114,17 @@ public void onOrderEvents( @PreDestroy public void shutdown() { + log.info("Shutting down MetricsKafkaConsumer executor..."); executorService.shutdown(); - } - - private void processCatalogEvent(ConsumerRecord record) { - final DomainEventEnvelope envelope = eventDeserializer.deserializeEnvelope(record.value()); - if (envelope == null || envelope.eventId() == null) { - log.warn("Invalid event envelope: {}", record.value()); - return; + try { + // 작업 완료를 위해 최대 10초 대기 + if (!executorService.awaitTermination(10, TimeUnit.SECONDS)) { + log.warn("Executor did not terminate in time, forcing shutdown"); + executorService.shutdownNow(); + } + } catch (InterruptedException e) { + executorService.shutdownNow(); + Thread.currentThread().interrupt(); } - - // 과거 이벤트 필터링 (1시간 이상 된 이벤트는 무시) - if (isOldEvent(envelope.occurredAtEpochMillis())) { - log.debug("Ignoring old event: eventId={}, occurredAt={}", - envelope.eventId(), envelope.occurredAtEpochMillis()); - // 멱등성 테이블에는 기록하되 비즈니스 로직은 처리하지 않음 - metricsService.tryMarkHandled(envelope.eventId()); - return; - } - - // 멱등성 체크 - 이미 처리된 이벤트는 무시 - final boolean isFirstTime = metricsService.tryMarkHandled(envelope.eventId()); - if (!isFirstTime) { - log.debug("Event already processed: {}", envelope.eventId()); - return; - } - - // 이벤트 타입별 처리 - switch (envelope.eventType()) { - case "PRODUCT_VIEW" -> handleProductView(envelope); - case "LIKE_ACTION" -> handleLikeAction(envelope); - case "STOCK_DEPLETED" -> handleStockDepleted(envelope); - default -> log.debug("Unhandled catalog event type: {}", envelope.eventType()); - } - } - - private void processOrderEvent(ConsumerRecord record) { - final DomainEventEnvelope envelope = eventDeserializer.deserializeEnvelope(record.value()); - if (envelope == null || envelope.eventId() == null) { - log.warn("Invalid event envelope: {}", record.value()); - return; - } - - // 과거 이벤트 필터링 - if (isOldEvent(envelope.occurredAtEpochMillis())) { - log.debug("Ignoring old event: eventId={}, occurredAt={}", - envelope.eventId(), envelope.occurredAtEpochMillis()); - metricsService.tryMarkHandled(envelope.eventId()); - return; - } - - // 멱등성 체크 - final boolean isFirstTime = metricsService.tryMarkHandled(envelope.eventId()); - if (!isFirstTime) { - log.debug("Event already processed: {}", envelope.eventId()); - return; - } - - // PAYMENT_SUCCESS 이벤트만 처리 - if ("PAYMENT_SUCCESS".equals(envelope.eventType())) { - handlePaymentSuccess(envelope); - } else { - log.debug("Unhandled order event type: {}", envelope.eventType()); - } - } - - private void handleProductView(DomainEventEnvelope envelope) { - final ProductViewPayloadV1 payload = eventDeserializer.deserializeProductView(envelope.payloadJson()); - if (payload == null || payload.productId() == null) { - log.warn("Invalid ProductView payload: {}", envelope.payloadJson()); - return; - } - - metricsService.incrementView(payload.productId(), envelope.occurredAtEpochMillis()); - log.debug("Processed PRODUCT_VIEW for productId: {}", payload.productId()); - } - - private void handleLikeAction(DomainEventEnvelope envelope) { - final LikeActionPayloadV1 payload = eventDeserializer.deserializeLikeAction(envelope.payloadJson()); - if (payload == null || payload.productId() == null || payload.action() == null) { - log.warn("Invalid LikeAction payload: {}", envelope.payloadJson()); - return; - } - - final int delta = "LIKE".equals(payload.action()) ? 1 : -1; - metricsService.applyLikeDelta(payload.productId(), delta, envelope.occurredAtEpochMillis()); - log.debug("Processed LIKE_ACTION for productId: {}, action: {}", payload.productId(), payload.action()); - } - - private void handlePaymentSuccess(DomainEventEnvelope envelope) { - final PaymentSuccessPayloadV1 payload = eventDeserializer.deserializePaymentSuccess(envelope.payloadJson()); - if (payload == null) { - log.warn("Invalid PaymentSuccess payload: {}", envelope.payloadJson()); - return; - } - - // 새로운 구조: 상품별 개별 이벤트 처리 - if (payload.productId() != null && payload.quantity() != null && payload.quantity() > 0) { - metricsService.addSales(payload.productId(), payload.quantity(), envelope.occurredAtEpochMillis()); - - log.debug( - "Processed PAYMENT_SUCCESS - orderId: {}, orderNumber: {}, userId: {}, productId: {}, quantity: {}, unitPrice: {}, totalPrice: {}", - payload.orderId(), payload.orderNumber(), payload.userId(), - payload.productId(), payload.quantity(), payload.unitPrice(), payload.totalPrice()); - } else { - log.warn("Invalid PaymentSuccess payload - missing required fields: productId={}, quantity={}", - payload.productId(), payload.quantity()); - } - } - - private void handleStockDepleted(DomainEventEnvelope envelope) { - final StockDepletedPayloadV1 payload = eventDeserializer.deserializeStockDepleted(envelope.payloadJson()); - if (payload == null || payload.productId() == null) { - log.warn("Invalid StockDepleted payload: {}", envelope.payloadJson()); - return; - } - - // 재고 소진 이벤트 처리 - remainingStock 정보 전달 - metricsService.handleStockDepleted(payload.productId(), payload.brandId(), payload.remainingStock(), - envelope.occurredAtEpochMillis()); - - log.info("Processed STOCK_DEPLETED - productId: {}, brandId: {}, productName: {}, remainingStock: {}", - payload.productId(), payload.brandId(), payload.productName(), payload.remainingStock()); - } - - /** - * 과거 이벤트인지 확인 (1시간 이상 된 이벤트는 과거 이벤트로 간주) - */ - private boolean isOldEvent(long occurredAtEpochMillis) { - long currentTime = System.currentTimeMillis(); - long eventAge = currentTime - occurredAtEpochMillis; - long oneHourInMillis = 60 * 60 * 1000; // 1시간 - - return eventAge > oneHourInMillis; } } diff --git a/modules/redis/src/main/java/com/loopers/cache/RankingRedisService.java b/modules/redis/src/main/java/com/loopers/cache/RankingRedisService.java index 7de1d3fe7..40c62f8ca 100644 --- a/modules/redis/src/main/java/com/loopers/cache/RankingRedisService.java +++ b/modules/redis/src/main/java/com/loopers/cache/RankingRedisService.java @@ -49,11 +49,11 @@ public void updateRankingScoresBatch(List scores, Lo ZSetOperations zSetOps = redisTemplate.opsForZSet(); try { - // 상품별로 점수 집계 + // 상품별로 점수 집계 (가중치 적용) Map productScores = scores.stream() .collect(Collectors.groupingBy( CachePayloads.RankingScore::productId, - Collectors.summingDouble(CachePayloads.RankingScore::score) + Collectors.summingDouble(CachePayloads.RankingScore::getWeightedScore) )); // Redis Pipeline을 사용한 배치 업데이트 @@ -84,7 +84,7 @@ public void updateRankingScoresBatch(List scores, Lo * @param size 페이지 크기 * @return 랭킹 리스트 (상위부터) */ - public List getRanking(LocalDate date, int page, int size) { + public List getRanking(LocalDate date, int page, int size) { String rankingKey = cacheKeyGenerator.generateDailyRankingKey(date); ZSetOperations zSetOps = redisTemplate.opsForZSet(); @@ -103,14 +103,14 @@ public List getRanking(LocalDate date, int page, int size) { } // 순위 계산 (페이징 고려) - List result = new java.util.ArrayList<>(); + List result = new java.util.ArrayList<>(); long currentRank = start + 1; // 1부터 시작하는 순위 for (ZSetOperations.TypedTuple tuple : rankingData) { try { Long productId = Long.parseLong(tuple.getValue()); Double score = tuple.getScore(); - result.add(new RankingItem(currentRank, productId, score)); + result.add(new CachePayloads.RankingItem(currentRank, productId, score)); currentRank++; } catch (NumberFormatException e) { log.warn("잘못된 상품 ID 형식: {}", tuple.getValue(), e); @@ -135,7 +135,7 @@ public List getRanking(LocalDate date, int page, int size) { * @param productId 상품 ID * @return 랭킹 정보 (없으면 null) */ - public RankingItem getProductRanking(LocalDate date, Long productId) { + public CachePayloads.RankingItem getProductRanking(LocalDate date, Long productId) { String rankingKey = cacheKeyGenerator.generateDailyRankingKey(date); ZSetOperations zSetOps = redisTemplate.opsForZSet(); @@ -154,7 +154,7 @@ public RankingItem getProductRanking(LocalDate date, Long productId) { return null; } - RankingItem result = new RankingItem(rank + 1, productId, score); // Redis rank는 0부터 시작 + CachePayloads.RankingItem result = new CachePayloads.RankingItem(rank + 1, productId, score); // Redis rank는 0부터 시작 log.debug("상품 랭킹 조회 완료: {}", result); return result; diff --git a/modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java b/modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java index 937c47339..ee6e46cf6 100644 --- a/modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java +++ b/modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java @@ -37,6 +37,37 @@ public double getWeight() { return weight; } } + + /** + * 조회 이벤트 점수 생성 + */ + public static RankingScore forProductView(Long productId, long occurredAt) { + return new RankingScore(productId, EventType.PRODUCT_VIEW, 1.0, occurredAt); + } + + /** + * 좋아요 이벤트 점수 생성 + */ + public static RankingScore forLikeAction(Long productId, long occurredAt) { + return new RankingScore(productId, EventType.LIKE_ACTION, 1.0, occurredAt); + } + + /** + * 주문 이벤트 점수 생성 (가격 * 수량 기반, 로그 정규화) + */ + public static RankingScore forPaymentSuccess(Long productId, java.math.BigDecimal totalPrice, long occurredAt) { + // 로그 정규화 적용하여 극값 방지 + // Math.log(x + 1)을 사용하여 0원일 때도 안전하게 처리 + double normalizedScore = Math.log(totalPrice.doubleValue() + 1); + return new RankingScore(productId, EventType.PAYMENT_SUCCESS, normalizedScore, occurredAt); + } + + /** + * 최종 가중 점수 계산 + */ + public double getWeightedScore() { + return eventType.getWeight() * score; + } } } From d23d0e84ded05a0699305799c0bbc08a5db4e165 Mon Sep 17 00:00:00 2001 From: hyujikoh Date: Thu, 25 Dec 2025 09:12:25 +0900 Subject: [PATCH 04/18] =?UTF-8?q?feat(error):=20=EC=BB=A4=EC=8A=A4?= =?UTF-8?q?=ED=85=80=20=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC=20=EB=B0=8F?= =?UTF-8?q?=20=EC=97=90=EB=9F=AC=20=ED=83=80=EC=9E=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CoreException 클래스를 추가하여 사용자 정의 예외 처리 구현 - 다양한 에러 타입을 정의하는 ErrorType 열거형 추가 - 각 에러 타입에 대한 HTTP 상태 코드 및 메시지 설정 --- .../loopers/support/error/CoreException.java | 19 +++++++ .../com/loopers/support/error/ErrorType.java | 52 +++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/support/error/CoreException.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/support/error/ErrorType.java diff --git a/apps/commerce-streamer/src/main/java/com/loopers/support/error/CoreException.java b/apps/commerce-streamer/src/main/java/com/loopers/support/error/CoreException.java new file mode 100644 index 000000000..0cc190b6b --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/support/error/CoreException.java @@ -0,0 +1,19 @@ +package com.loopers.support.error; + +import lombok.Getter; + +@Getter +public class CoreException extends RuntimeException { + private final ErrorType errorType; + private final String customMessage; + + public CoreException(ErrorType errorType) { + this(errorType, null); + } + + public CoreException(ErrorType errorType, String customMessage) { + super(customMessage != null ? customMessage : errorType.getMessage()); + this.errorType = errorType; + this.customMessage = customMessage; + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/support/error/ErrorType.java b/apps/commerce-streamer/src/main/java/com/loopers/support/error/ErrorType.java new file mode 100644 index 000000000..bb1d3ebd8 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/support/error/ErrorType.java @@ -0,0 +1,52 @@ +package com.loopers.support.error; + +import org.springframework.http.HttpStatus; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ErrorType { + /** + * 범용 에러 + */ + INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(), "일시적인 오류가 발생했습니다."), + BAD_REQUEST(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "잘못된 요청입니다."), + NOT_FOUND(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.getReasonPhrase(), "존재하지 않는 요청입니다."), + CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "이미 존재하는 리소스입니다."), + + // 사용자 관련 오류 + NOT_FOUND_USER(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.getReasonPhrase(), "존재하지 않는 사용자 입니다."), + + // 브랜드 관련 오류 + NOT_FOUND_BRAND(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.getReasonPhrase(), "존재하지 않는 브랜드입니다."), + DUPLICATE_BRAND(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "이미 존재하는 브랜드 이름입니다."), + + // 상품 관련 오류 + NOT_FOUND_PRODUCT(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.getReasonPhrase(), "존재하지 않는 상품입니다."), + INSUFFICIENT_STOCK(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "재고가 부족합니다."), + + //좋아요 관련 오류 + ALREADY_LIKED_PRODUCT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "이미 좋아요한 상품입니다."), + NOT_EXIST_LIKED(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "좋아요하지 않은 상품입니다."), + + // 주문 관련 오류 + NOT_FOUND_ORDER(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.getReasonPhrase(), "존재하지 않는 주문입니다."), + INVALID_ORDER_STATUS(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "유효하지 않은 주문 상태입니다."), + EMPTY_ORDER_ITEMS(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "주문 항목은 최소 1개 이상이어야 합니다."), + + // 결제 관련 오류 + PG_API_FAIL(HttpStatus.BAD_GATEWAY, HttpStatus.BAD_GATEWAY.getReasonPhrase(), "PG 결제 요청이 실패했습니다."), + INVALID_PG_RESPONSE(HttpStatus.BAD_GATEWAY, HttpStatus.BAD_GATEWAY.getReasonPhrase(), "PG 응답이 올바르지 않습니다."), + INVALID_PAYMENT_STATUS(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "알 수 없는 결제 상태입니다."), + + // 랭킹 관련 오류 + RANKING_UPDATE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(), "랭킹 점수 업데이트에 실패했습니다"), + + ; + + private final HttpStatus status; + private final String code; + private final String message; +} From 676c87049eb52617a774be74b9e278198ed919b7 Mon Sep 17 00:00:00 2001 From: hyujikoh Date: Thu, 25 Dec 2025 09:12:48 +0900 Subject: [PATCH 05/18] =?UTF-8?q?feat(product):=20=EC=83=81=ED=92=88=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=EC=97=90=20=EB=9E=AD?= =?UTF-8?q?=ED=82=B9=20=EC=A0=95=EB=B3=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 상품 상세 응답에 랭킹 정보 포함 - 랭킹 정보가 없을 경우 null 처리 - 랭킹 상품 목록 조회 API 추가 --- .../api/product/ProductV1ApiSpec.java | 17 +++++++ .../api/product/ProductV1Controller.java | 21 +++++++-- .../interfaces/api/product/ProductV1Dtos.java | 46 ++++++++++++++++++- .../interfaces/api/ProductV1ApiE2ETest.java | 4 +- 4 files changed, 80 insertions(+), 8 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java index 1684fcd0a..ec03d0cc9 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java @@ -5,6 +5,8 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; +import java.time.LocalDate; + import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableDefault; import org.springframework.web.bind.annotation.PathVariable; @@ -29,6 +31,21 @@ ApiResponse> getProducts( @PageableDefault(size = 20) Pageable pageable, @RequestParam(required = false) Long brandId, @RequestParam(required = false) String productName + ); + + + @Operation( + summary = "랭킹 상품 목록 조회", + description = "일자 기준 랭킹 상품 목록을 페이징하여 조회합니다. date 파라미터가 없으면 오늘 날짜 기준으로 조회합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청") + }) + ApiResponse> getRankingProducts( + @PageableDefault(size = 20) Pageable pageable, + @Parameter(description = "조회 날짜 (yyyyMMdd 형식, 선택)", example = "20251223") + @RequestParam(required = false) LocalDate date ); @Operation( diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java index 106381f81..85bac4c0c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java @@ -1,5 +1,7 @@ package com.loopers.interfaces.api.product; +import java.time.LocalDate; + import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableDefault; @@ -8,6 +10,7 @@ import com.loopers.application.product.ProductDetailInfo; import com.loopers.application.product.ProductFacade; import com.loopers.application.product.ProductInfo; +import com.loopers.cache.dto.CachePayloads.RankingItem; import com.loopers.domain.product.dto.ProductSearchFilter; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.common.PageResponse; @@ -21,7 +24,6 @@ public class ProductV1Controller implements ProductV1ApiSpec { private final ProductFacade productFacade; - @GetMapping(Uris.Product.GET_LIST) @Override public ApiResponse> getProducts( @@ -35,6 +37,17 @@ public ApiResponse> getProducts( return ApiResponse.success(PageResponse.from(responsePage)); } + @GetMapping(Uris.Ranking.GET_RANKING) + @Override + public ApiResponse> getRankingProducts( + @PageableDefault(size = 20) Pageable pageable, + @RequestParam(required = false) LocalDate date + ) { + Page products = productFacade.getRankingProducts(pageable, date); + Page responsePage = products.map(ProductV1Dtos.ProductListResponse::from); + return ApiResponse.success(PageResponse.from(responsePage)); + } + @GetMapping(Uris.Product.GET_DETAIL) @Override public ApiResponse getProductDetail( @@ -42,8 +55,8 @@ public ApiResponse getProductDetail( @RequestHeader(value = "X-USER-ID", required = false) String username ) { ProductDetailInfo productDetail = productFacade.getProductDetail(productId, username); - ProductV1Dtos.ProductDetailResponse response = ProductV1Dtos.ProductDetailResponse.from(productDetail); - return ApiResponse.success(response); + + // 3. 응답 생성 + return ApiResponse.success(ProductV1Dtos.ProductDetailResponse.from(productDetail)); } } - diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dtos.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dtos.java index 3a9893c7d..46598bd14 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dtos.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dtos.java @@ -72,9 +72,20 @@ public record ProductDetailResponse( BrandDetailResponse brand, @Schema(description = "사용자의 좋아요 여부", example = "true") - Boolean isLiked + Boolean isLiked, + + @Schema(description = "랭킹 정보 (랭킹에 없으면 null)") + RankingResponse ranking ) { public static ProductDetailResponse from(ProductDetailInfo productDetailInfo) { + RankingResponse rankingResponse = null; + if (productDetailInfo.ranking() != null) { + rankingResponse = new RankingResponse( + productDetailInfo.ranking().rank(), + productDetailInfo.ranking().score() + ); + } + return new ProductDetailResponse( productDetailInfo.id(), productDetailInfo.name(), @@ -89,7 +100,28 @@ public static ProductDetailResponse from(ProductDetailInfo productDetailInfo) { productDetailInfo.brand().id(), productDetailInfo.brand().name() ), - productDetailInfo.isLiked() + productDetailInfo.isLiked(), + rankingResponse + ); + } + + public static ProductDetailResponse fromWithRanking(ProductDetailInfo productDetailInfo, RankingResponse ranking) { + return new ProductDetailResponse( + productDetailInfo.id(), + productDetailInfo.name(), + productDetailInfo.description(), + productDetailInfo.likeCount(), + productDetailInfo.stockQuantity(), + new PriceResponse( + productDetailInfo.price().originPrice(), + productDetailInfo.price().discountPrice() + ), + new BrandDetailResponse( + productDetailInfo.brand().id(), + productDetailInfo.brand().name() + ), + productDetailInfo.isLiked(), + ranking ); } } @@ -123,6 +155,16 @@ public record BrandDetailResponse( String brandName ) { } + + @Schema(description = "랭킹 정보") + public record RankingResponse( + @Schema(description = "순위", example = "1") + Long rank, + + @Schema(description = "랭킹 점수", example = "123.45") + Double score + ) { + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java index 705957481..c44c1358e 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java @@ -204,7 +204,7 @@ void get_products_with_default_page_size_success() { void get_products_returns_empty_list_when_no_products() { // given - 모든 상품 삭제 testProductIds.forEach(productId -> { - productFacade.deletedProduct(productId); + productFacade.deleteProduct(productId); }); productMVService.syncMaterializedView(); @@ -340,7 +340,7 @@ void get_product_detail_fail_when_product_deleted() { // given Long productId = testProductIds.get(0); ProductEntity product = productService.getActiveProductDetail(productId); - productFacade.deletedProduct(product.getId()); + productFacade.deleteProduct(product.getId()); // when From 61bf7f8bc8bdcafd2660bef999f62929860618ff Mon Sep 17 00:00:00 2001 From: hyujikoh Date: Thu, 25 Dec 2025 09:12:53 +0900 Subject: [PATCH 06/18] =?UTF-8?q?feat(metrics):=20Kafka=20=EC=9D=B4?= =?UTF-8?q?=EB=B2=A4=ED=8A=B8=20=EC=B2=98=EB=A6=AC=20=EC=8B=9C=20=EB=9E=AD?= =?UTF-8?q?=ED=82=B9=20=EC=A0=90=EC=88=98=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/interfaces/consumer/MetricsKafkaConsumer.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.java index f1b1def13..4397a4ddc 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.java @@ -71,7 +71,7 @@ public void onCatalogEvents( .toList(); if (!rankingScores.isEmpty()) { - eventProcessingFacade.updateRankingScores(rankingScores, LocalDate.now()); + eventProcessingFacade.updateRankingScores(rankingScores, null); } ack.acknowledge(); @@ -105,7 +105,7 @@ public void onOrderEvents( .toList(); if (!rankingScores.isEmpty()) { - eventProcessingFacade.updateRankingScores(rankingScores, LocalDate.now()); + eventProcessingFacade.updateRankingScores(rankingScores, null); } ack.acknowledge(); From f0f0a11fbedacea7def4a771028f8a2d1b77cf1b Mon Sep 17 00:00:00 2001 From: hyujikoh Date: Thu, 25 Dec 2025 09:13:02 +0900 Subject: [PATCH 07/18] =?UTF-8?q?feat(product):=20=EC=83=81=ED=92=88=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EC=A0=95=EB=B3=B4=EC=97=90=20=EB=9E=AD?= =?UTF-8?q?=ED=82=B9=20=EC=A0=95=EB=B3=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 상품 상세 조회 시 랭킹 정보를 포함하도록 수정 - 랭킹 정보가 없는 경우를 처리하는 로직 추가 - 랭킹 점수 업데이트 기능 개선 --- .../product/ProductDetailInfo.java | 35 ++++++++- .../application/product/ProductFacade.java | 73 ++++++++++++++++--- .../domain/product/ProductMVService.java | 10 +++ .../main/java/com/loopers/support/Uris.java | 11 +++ .../product/ProductIntegrationTest.java | 2 +- .../domain/ranking/RankingService.java | 29 +++----- .../loopers/cache/RankingRedisService.java | 60 ++++++++++++++- .../com/loopers/cache/dto/CachePayloads.java | 26 +++++-- 8 files changed, 206 insertions(+), 40 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 1b7012684..065177e88 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,6 +1,7 @@ package com.loopers.application.product; import com.loopers.application.brand.BrandInfo; +import com.loopers.cache.dto.CachePayloads.RankingItem; import com.loopers.domain.brand.BrandEntity; import com.loopers.domain.product.ProductEntity; import com.loopers.domain.product.ProductMaterializedViewEntity; @@ -19,13 +20,18 @@ public record ProductDetailInfo( Integer stockQuantity, ProductPriceInfo price, BrandInfo brand, - Boolean isLiked // 사용자 좋아요 여부 + Boolean isLiked, // 사용자 좋아요 여부 + RankingItem ranking // 상품 랭킹 정보 (nullable) ) { /** * MV 엔티티와 좋아요 여부로 생성 (권장) */ public static ProductDetailInfo from(ProductMaterializedViewEntity mv, Boolean isLiked) { + return from(mv, isLiked, null); + } + + public static ProductDetailInfo from(ProductMaterializedViewEntity mv, Boolean isLiked, RankingItem ranking) { if (mv == null) { throw new IllegalArgumentException("MV 엔티티는 필수입니다."); } @@ -44,7 +50,8 @@ public static ProductDetailInfo from(ProductMaterializedViewEntity mv, Boolean i mv.getBrandId(), mv.getBrandName() ), - isLiked + isLiked, + ranking ); } @@ -52,6 +59,10 @@ public static ProductDetailInfo from(ProductMaterializedViewEntity mv, Boolean i * ProductEntity + BrandEntity + 좋아요수로 생성 (MV 사용 권장) */ public static ProductDetailInfo of(ProductEntity product, BrandEntity brand, Long likeCount, Boolean isLiked) { + return of(product, brand, likeCount, isLiked, null); + } + + public static ProductDetailInfo of(ProductEntity product, BrandEntity brand, Long likeCount, Boolean isLiked, RankingItem ranking) { if (product == null) { throw new IllegalArgumentException("상품 정보는 필수입니다."); } @@ -74,7 +85,8 @@ public static ProductDetailInfo of(ProductEntity product, BrandEntity brand, Lon brand.getId(), brand.getName() ), - isLiked + isLiked, + ranking ); } @@ -87,7 +99,22 @@ public static ProductDetailInfo fromWithSyncLike(ProductDetailInfo productDetail productDetailInfo.stockQuantity(), productDetailInfo.price(), productDetailInfo.brand(), - isLiked + isLiked, + productDetailInfo.ranking() + ); + } + + public static ProductDetailInfo fromWithRanking(ProductDetailInfo productDetailInfo, RankingItem ranking) { + return new ProductDetailInfo( + productDetailInfo.id(), + productDetailInfo.name(), + productDetailInfo.description(), + productDetailInfo.likeCount(), + productDetailInfo.stockQuantity(), + productDetailInfo.price(), + productDetailInfo.brand(), + productDetailInfo.isLiked(), + ranking ); } } 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 50c3ad916..64a8e1bc0 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 @@ -1,13 +1,21 @@ package com.loopers.application.product; +import java.time.LocalDate; +import java.util.List; +import java.util.Objects; import java.util.Optional; import java.util.Set; +import java.util.stream.Collectors; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; import com.loopers.cache.CacheStrategy; +import com.loopers.cache.RankingRedisService; +import com.loopers.cache.dto.CachePayloads.RankingItem; import com.loopers.domain.brand.BrandEntity; import com.loopers.domain.brand.BrandService; import com.loopers.domain.like.LikeService; @@ -37,6 +45,7 @@ public class ProductFacade { private final UserService userService; private final BrandService brandService; private final UserBehaviorTracker behaviorTracker; + private final RankingRedisService rankingRedisService; /** * 도메인 서비스에서 MV 엔티티를 조회하고, Facade에서 DTO로 변환합니다. @@ -61,7 +70,7 @@ public Page getProducts(ProductSearchFilter productSearchFilter) { * 도메인 서비스에서 엔티티를 조회하고, Facade에서 DTO로 변환합니다. * * @param productId 상품 ID - * @param username 사용자명 (nullable) + * @param username 사용자 ID (nullable) * @return 상품 상세 정보 */ @Transactional(readOnly = true) @@ -96,18 +105,64 @@ public ProductDetailInfo getProductDetail(Long productId, String username) { productCacheService.cacheProductDetail(productId, result); } - // 5. 유저 행동 추적 (상품 조회) - 캐시 히트/미스와 관계없이 항상 이벤트 발행 + // 5. 랭킹 정보 결합 (오늘 날짜 기준 실시간 순위 조회) + final RankingItem ranking = rankingRedisService.getProductRanking(LocalDate.now(), productId); + result = ProductDetailInfo.fromWithRanking(result, ranking); + + // 6. 유저 행동 추적 (이벤트 발행) if (userId != null) { - behaviorTracker.trackProductView( - userId, - productId, - null // searchKeyword는 Controller에서 받아야 함 - ); + behaviorTracker.trackProductView(userId, productId, null); } return result; } + /** + * 랭킹 상품 목록 조회 + * + * @param pageable 페이징 정보 + * @param date 조회 날짜 (null이면 오늘) + * @return 랭킹 상품 목록 + */ + @Transactional(readOnly = true) + public Page getRankingProducts(Pageable pageable, LocalDate date) { + LocalDate targetDate = date != null ? date : LocalDate.now(); + + // 1. 랭킹 조회 (Redis-specific pagination logic is now encapsulated in rankingRedisService) + List rankings = rankingRedisService.getRanking( + targetDate, + pageable.getPageNumber() + 1, + pageable.getPageSize() + ); + + if (rankings.isEmpty()) { + log.debug("랭킹 데이터 없음: date={}", targetDate); + return Page.empty(pageable); + } + + // 2. 상품 ID 목록 추출 + List productIds = rankings.stream() + .map(RankingItem::productId) + .collect(Collectors.toList()); + + // 3. 상품 정보 조회 (MV 사용) + List products = mvService.getByIds(productIds); + + // 4. 랭킹 순서대로 정렬 + List sortedProducts = productIds.stream() + .map(productId -> products.stream() + .filter(p -> p.getProductId().equals(productId)) + .findFirst() + .map(ProductInfo::from) + .orElse(null)) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + + // 5. Page 객체 생성 + long totalCount = rankingRedisService.getRankingCount(targetDate); + return new PageImpl<>(sortedProducts, pageable, totalCount); + } + /** * 상품을 삭제합니다. *

@@ -116,7 +171,7 @@ public ProductDetailInfo getProductDetail(Long productId, String username) { * @param productId 상품 ID */ @Transactional - public void deletedProduct(Long productId) { + public void deleteProduct(Long productId) { // 1. 상품 삭제 ProductEntity product = productService.getActiveProductDetail(productId); product.delete(); @@ -137,7 +192,7 @@ public void deletedProduct(Long productId) { * @param brandId 브랜드 ID */ @Transactional - public void deletedBrand(Long brandId) { + public void deleteBrand(Long brandId) { // 1. 브랜드 삭제 BrandEntity brand = brandService.getBrandById(brandId); brand.delete(); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductMVService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductMVService.java index b669db185..9bc661921 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductMVService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductMVService.java @@ -54,6 +54,16 @@ public ProductMaterializedViewEntity getById(Long productId) { )); } + /** + * 여러 상품 ID로 MV 목록을 조회합니다. + * + * @param productIds 상품 ID 목록 + * @return 상품 MV 목록 + */ + public List getByIds(List productIds) { + return mvRepository.findByIdIn(productIds); + } + /** * 브랜드별 상품 MV를 페이징 조회합니다. * diff --git a/apps/commerce-api/src/main/java/com/loopers/support/Uris.java b/apps/commerce-api/src/main/java/com/loopers/support/Uris.java index 15bfe6ca3..598e71128 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/Uris.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/Uris.java @@ -79,6 +79,17 @@ private Product() { public static final String GET_DETAIL = BASE + "/{productId}"; } + /** + * Ranking API 엔드포인트 + */ + public static class Ranking { + private Ranking() { + } + + public static final String BASE = API_V1 + "/rankings"; + public static final String GET_RANKING = BASE; + } + /** * Like API 엔드포인트 */ diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductIntegrationTest.java index eccbad169..2b2b30b7d 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductIntegrationTest.java @@ -272,7 +272,7 @@ void throw_exception_when_product_has_non_existent_brand() { ProductEntity product = ProductTestFixture.createAndSave(productRepository, mvRepository, brand); // 브랜드 삭제 (소프트 삭제) - productFacade.deletedBrand(brand.getId()); + productFacade.deleteBrand(brand.getId()); productMVService.syncMaterializedView(); diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingService.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingService.java index af0e549cf..a2b266156 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingService.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingService.java @@ -1,5 +1,6 @@ package com.loopers.domain.ranking; +import static com.loopers.support.error.ErrorType.RANKING_UPDATE_FAILED; import java.time.LocalDate; import java.util.ArrayList; import java.util.List; @@ -14,6 +15,7 @@ import com.loopers.infrastructure.event.payloads.LikeActionPayloadV1; import com.loopers.infrastructure.event.payloads.PaymentSuccessPayloadV1; import com.loopers.infrastructure.event.payloads.ProductViewPayloadV1; +import com.loopers.support.error.CoreException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -30,7 +32,7 @@ @RequiredArgsConstructor @Slf4j public class RankingService { - + private final RankingRedisService rankingRedisService; private final EventDeserializer eventDeserializer; @@ -60,7 +62,7 @@ public RankingScore generateRankingScore(DomainEventEnvelope envelope) { * 배치로 랭킹 점수 업데이트 * * @param rankingScores 랭킹 점수 리스트 - * @param targetDate 대상 날짜 (null이면 오늘) + * @param targetDate 대상 날짜 (null이면 각 점수의 발생 날짜 기준) */ public void updateRankingScoresBatch(List rankingScores, LocalDate targetDate) { if (rankingScores == null || rankingScores.isEmpty()) { @@ -68,14 +70,16 @@ public void updateRankingScoresBatch(List rankingScores, LocalDate return; } - LocalDate date = targetDate != null ? targetDate : LocalDate.now(); - try { - rankingRedisService.updateRankingScoresBatch(rankingScores, date); - log.debug("랭킹 점수 배치 업데이트 완료: {} scores, date: {}", rankingScores.size(), date); + if (targetDate == null) { + rankingRedisService.updateRankingScoresBatch(rankingScores); + } else { + rankingRedisService.updateRankingScoresBatch(rankingScores, targetDate); + } + log.debug("랭킹 점수 배치 업데이트 완료: {} scores, targetDate: {}", rankingScores.size(), targetDate); } catch (Exception e) { - log.error("랭킹 점수 배치 업데이트 실패: date={}, scores={}", date, rankingScores.size(), e); - throw new RankingUpdateException("랭킹 점수 업데이트 실패", e); + log.error("랭킹 점수 배치 업데이트 실패: targetDate={}, scores={}", targetDate, rankingScores.size(), e); + throw new CoreException(RANKING_UPDATE_FAILED); } } @@ -172,13 +176,4 @@ private RankingScore generatePaymentSuccessScore(DomainEventEnvelope envelope) { envelope.occurredAtEpochMillis() ); } - - /** - * 랭킹 업데이트 예외 - */ - public static class RankingUpdateException extends RuntimeException { - public RankingUpdateException(String message, Throwable cause) { - super(message, cause); - } - } } diff --git a/modules/redis/src/main/java/com/loopers/cache/RankingRedisService.java b/modules/redis/src/main/java/com/loopers/cache/RankingRedisService.java index 40c62f8ca..77cba1c06 100644 --- a/modules/redis/src/main/java/com/loopers/cache/RankingRedisService.java +++ b/modules/redis/src/main/java/com/loopers/cache/RankingRedisService.java @@ -35,7 +35,55 @@ public class RankingRedisService { private static final Duration RANKING_TTL = Duration.ofDays(2); // 2일 TTL /** - * 배치로 랭킹 점수 업데이트 + * 배치로 랭킹 점수 업데이트 (여러 날짜의 점수 포함 가능) + * + * @param scores 랭킹 점수 리스트 + */ + public void updateRankingScoresBatch(List scores) { + if (scores.isEmpty()) { + return; + } + + try { + // 날짜별로 그룹화 + Map> scoresByDate = scores.stream() + .collect(Collectors.groupingBy(CachePayloads.RankingScore::getEventDate)); + + for (Map.Entry> entry : scoresByDate.entrySet()) { + LocalDate date = entry.getKey(); + List dateScores = entry.getValue(); + String rankingKey = cacheKeyGenerator.generateDailyRankingKey(date); + + // 해당 날짜의 상품별 점수 집계 + Map productScores = dateScores.stream() + .collect(Collectors.groupingBy( + CachePayloads.RankingScore::productId, + Collectors.summingDouble(CachePayloads.RankingScore::getWeightedScore) + )); + + // Redis Pipeline 사용 + redisTemplate.executePipelined((RedisCallback) connection -> { + productScores.forEach((productId, totalScore) -> { + redisTemplate.opsForZSet().incrementScore(rankingKey, productId.toString(), totalScore); + }); + return null; + }); + + // TTL 설정 + redisTemplate.expire(rankingKey, RANKING_TTL); + + log.debug("랭킹 점수 배치 업데이트 완료: key={}, products={}", + rankingKey, productScores.size()); + } + + } catch (Exception e) { + log.error("랭킹 점수 배치 업데이트 실패", e); + throw new RuntimeException("랭킹 업데이트 실패", e); + } + } + + /** + * 특정 날짜에 대해 배치로 랭킹 점수 업데이트 (하위 호환성 유지) * * @param scores 랭킹 점수 리스트 * @param targetDate 대상 날짜 @@ -45,6 +93,11 @@ public void updateRankingScoresBatch(List scores, Lo return; } + if (targetDate == null) { + updateRankingScoresBatch(scores); + return; + } + String rankingKey = cacheKeyGenerator.generateDailyRankingKey(targetDate); ZSetOperations zSetOps = redisTemplate.opsForZSet(); @@ -85,12 +138,15 @@ public void updateRankingScoresBatch(List scores, Lo * @return 랭킹 리스트 (상위부터) */ public List getRanking(LocalDate date, int page, int size) { + // 1-based index (API)를 0-based index (Redis)로 변환하는 로직을 서비스 내부로 캡슐화 + int pageForRedis = Math.max(1, page); + String rankingKey = cacheKeyGenerator.generateDailyRankingKey(date); ZSetOperations zSetOps = redisTemplate.opsForZSet(); try { // 페이징 계산 (Redis는 0부터 시작) - long start = (long) (page - 1) * size; + long start = (long) (pageForRedis - 1) * size; long end = start + size - 1; // 점수 높은 순으로 조회 (ZREVRANGE) diff --git a/modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java b/modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java index ee6e46cf6..05269c1f6 100644 --- a/modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java +++ b/modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java @@ -1,5 +1,7 @@ package com.loopers.cache.dto; +import lombok.Getter; + /** * * @author hyunjikoh @@ -22,38 +24,48 @@ public record RankingScore( long occurredAtEpochMillis ) { + @Getter public enum EventType { PRODUCT_VIEW(0.1), // 조회: Weight = 0.1, Score = 1 LIKE_ACTION(0.2), // 좋아요: Weight = 0.2, Score = 1 - PAYMENT_SUCCESS(0.6); // 주문: Weight = 0.6, Score = Math.log((단가 * 수량) + 1) + PAYMENT_SUCCESS(0.6); // 주문: Weight = 0.6, Score = price * amount (정규화 시에는 log 적용도 가능) - private final double weight; + private double weight; EventType(double weight) { this.weight = weight; } - public double getWeight() { - return weight; + public void setWeight(double weight) { + this.weight = weight; } } /** - * 조회 이벤트 점수 생성 + * 발생 시각으로부터 날짜 추출 + */ + public java.time.LocalDate getEventDate() { + return java.time.Instant.ofEpochMilli(occurredAtEpochMillis) + .atZone(java.time.ZoneId.systemDefault()) + .toLocalDate(); + } + + /** + * 조회 이벤트 생성 생성 */ public static RankingScore forProductView(Long productId, long occurredAt) { return new RankingScore(productId, EventType.PRODUCT_VIEW, 1.0, occurredAt); } /** - * 좋아요 이벤트 점수 생성 + * 좋아요 이벤트 생성 생성 */ public static RankingScore forLikeAction(Long productId, long occurredAt) { return new RankingScore(productId, EventType.LIKE_ACTION, 1.0, occurredAt); } /** - * 주문 이벤트 점수 생성 (가격 * 수량 기반, 로그 정규화) + * 주문 이벤트 생성 메소드 (가격 * 수량 기반, 로그 정규화) */ public static RankingScore forPaymentSuccess(Long productId, java.math.BigDecimal totalPrice, long occurredAt) { // 로그 정규화 적용하여 극값 방지 From 5fa205110e948314517041a8d78ef48434f62cf9 Mon Sep 17 00:00:00 2001 From: hyujikoh Date: Thu, 25 Dec 2025 09:15:55 +0900 Subject: [PATCH 08/18] =?UTF-8?q?feat(metrics):=20=EB=A9=94=ED=8A=B8?= =?UTF-8?q?=EB=A6=AD=EC=8A=A4=20Kafka=20=EC=BB=A8=EC=8A=88=EB=A8=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC=20=EB=B0=8F=20=EC=A3=BC?= =?UTF-8?q?=EC=84=9D=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/ranking/RankingService.java | 56 +++++++++---------- .../consumer/MetricsKafkaConsumer.java | 5 +- .../com/loopers/support/error/ErrorType.java | 3 +- 3 files changed, 32 insertions(+), 32 deletions(-) diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingService.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingService.java index a2b266156..36b5d4958 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingService.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingService.java @@ -35,10 +35,10 @@ public class RankingService { private final RankingRedisService rankingRedisService; private final EventDeserializer eventDeserializer; - + /** * 이벤트로부터 랭킹 점수 생성 - * + * * @param envelope 도메인 이벤트 엔벨로프 * @return 랭킹 점수 (생성되지 않으면 null) */ @@ -46,7 +46,7 @@ public RankingScore generateRankingScore(DomainEventEnvelope envelope) { if (envelope == null || envelope.eventType() == null) { return null; } - + return switch (envelope.eventType()) { case "PRODUCT_VIEW" -> generateProductViewScore(envelope); case "LIKE_ACTION" -> generateLikeActionScore(envelope); @@ -57,19 +57,19 @@ public RankingScore generateRankingScore(DomainEventEnvelope envelope) { } }; } - + /** * 배치로 랭킹 점수 업데이트 - * + * * @param rankingScores 랭킹 점수 리스트 - * @param targetDate 대상 날짜 (null이면 각 점수의 발생 날짜 기준) + * @param targetDate 대상 날짜 (null이면 각 점수의 발생 날짜 기준) */ public void updateRankingScoresBatch(List rankingScores, LocalDate targetDate) { if (rankingScores == null || rankingScores.isEmpty()) { log.debug("업데이트할 랭킹 점수가 없음"); return; } - + try { if (targetDate == null) { rankingRedisService.updateRankingScoresBatch(rankingScores); @@ -82,10 +82,10 @@ public void updateRankingScoresBatch(List rankingScores, LocalDate throw new CoreException(RANKING_UPDATE_FAILED); } } - + /** * 랭킹 조회 (페이징) - * + * * @param date 날짜 (null이면 오늘) * @param page 페이지 (1부터 시작) * @param size 페이지 크기 @@ -95,9 +95,9 @@ public List getRanking(LocalDate date, int page, int size) { if (page < 1 || size < 1) { throw new IllegalArgumentException("페이지와 크기는 1 이상이어야 합니다"); } - + LocalDate targetDate = date != null ? date : LocalDate.now(); - + try { return rankingRedisService.getRanking(targetDate, page, size); } catch (Exception e) { @@ -105,21 +105,21 @@ public List getRanking(LocalDate date, int page, int size) { return new ArrayList<>(); } } - + /** * 특정 상품의 랭킹 조회 - * + * * @param productId 상품 ID - * @param date 날짜 (null이면 오늘) + * @param date 날짜 (null이면 오늘) * @return 랭킹 정보 (없으면 null) */ public RankingItem getProductRanking(Long productId, LocalDate date) { if (productId == null) { return null; } - + LocalDate targetDate = date != null ? date : LocalDate.now(); - + try { return rankingRedisService.getProductRanking(targetDate, productId); } catch (Exception e) { @@ -127,7 +127,7 @@ public RankingItem getProductRanking(Long productId, LocalDate date) { return null; } } - + /** * 랭킹 데이터 존재 여부 확인 */ @@ -135,45 +135,45 @@ public boolean hasRankingData(LocalDate date) { LocalDate targetDate = date != null ? date : LocalDate.now(); return rankingRedisService.hasRankingData(targetDate); } - + // ========== Private Methods ========== - + private RankingScore generateProductViewScore(DomainEventEnvelope envelope) { ProductViewPayloadV1 payload = eventDeserializer.deserializeProductView(envelope.payloadJson()); if (payload == null || payload.productId() == null) { log.warn("상품 조회 이벤트 페이로드 오류: {}", envelope.payloadJson()); return null; } - + return RankingScore.forProductView(payload.productId(), envelope.occurredAtEpochMillis()); } - + private RankingScore generateLikeActionScore(DomainEventEnvelope envelope) { LikeActionPayloadV1 payload = eventDeserializer.deserializeLikeAction(envelope.payloadJson()); if (payload == null || payload.productId() == null || payload.action() == null) { log.warn("좋아요 이벤트 페이로드 오류: {}", envelope.payloadJson()); return null; } - + // 좋아요만 점수에 반영, 좋아요 취소는 반영하지 않음 if ("LIKE".equals(payload.action())) { return RankingScore.forLikeAction(payload.productId(), envelope.occurredAtEpochMillis()); } - + return null; } - + private RankingScore generatePaymentSuccessScore(DomainEventEnvelope envelope) { PaymentSuccessPayloadV1 payload = eventDeserializer.deserializePaymentSuccess(envelope.payloadJson()); if (payload == null || payload.productId() == null || payload.totalPrice() == null) { log.warn("결제 성공 이벤트 페이로드 오류: {}", envelope.payloadJson()); return null; } - + return RankingScore.forPaymentSuccess( - payload.productId(), - payload.totalPrice(), - envelope.occurredAtEpochMillis() + payload.productId(), + payload.totalPrice(), + envelope.occurredAtEpochMillis() ); } } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.java index 4397a4ddc..2df588dcf 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.java @@ -1,7 +1,5 @@ package com.loopers.interfaces.consumer; -import java.time.LocalDate; -import java.util.ArrayList; import java.util.List; import java.util.Objects; import java.util.concurrent.*; @@ -15,10 +13,11 @@ import com.loopers.cache.dto.CachePayloads.RankingScore; import com.loopers.confg.kafka.KafkaConfig; -import jakarta.annotation.PreDestroy; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import jakarta.annotation.PreDestroy; + /** * 메트릭스 Kafka 컨슈머 *

diff --git a/apps/commerce-streamer/src/main/java/com/loopers/support/error/ErrorType.java b/apps/commerce-streamer/src/main/java/com/loopers/support/error/ErrorType.java index bb1d3ebd8..3dc0271ce 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/support/error/ErrorType.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/support/error/ErrorType.java @@ -42,7 +42,8 @@ public enum ErrorType { INVALID_PAYMENT_STATUS(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "알 수 없는 결제 상태입니다."), // 랭킹 관련 오류 - RANKING_UPDATE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(), "랭킹 점수 업데이트에 실패했습니다"), + RANKING_UPDATE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(), + "랭킹 점수 업데이트에 실패했습니다"), ; From c1d6476c48de43745cd0f9a44d516f0878532f93 Mon Sep 17 00:00:00 2001 From: hyujikoh Date: Fri, 26 Dec 2025 10:18:26 +0900 Subject: [PATCH 09/18] =?UTF-8?q?feat(product):=20=EB=9E=AD=ED=82=B9=20?= =?UTF-8?q?=EC=83=81=ED=92=88=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 콜드 스타트 문제 해결을 위해 오늘 랭킹이 비어있을 경우 어제 랭킹을 반환하도록 수정 - 랭킹 Carry-Over 스케줄러 추가로 매일 점수 일부를 다음 날로 이월 --- .../application/product/ProductFacade.java | 28 +++++-- .../scheduler/RankingCarryOverScheduler.java | 74 +++++++++++++++++++ .../loopers/cache/RankingRedisService.java | 52 +++++++++++++ 3 files changed, 149 insertions(+), 5 deletions(-) create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/RankingCarryOverScheduler.java 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 64a8e1bc0..1d912a891 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 @@ -119,6 +119,8 @@ public ProductDetailInfo getProductDetail(Long productId, String username) { /** * 랭킹 상품 목록 조회 + *

+ * 콜드 스타트 Fallback: 오늘 랭킹이 비어있으면 어제 랭킹 반환 * * @param pageable 페이징 정보 * @param date 조회 날짜 (null이면 오늘) @@ -128,27 +130,43 @@ public ProductDetailInfo getProductDetail(Long productId, String username) { public Page getRankingProducts(Pageable pageable, LocalDate date) { LocalDate targetDate = date != null ? date : LocalDate.now(); - // 1. 랭킹 조회 (Redis-specific pagination logic is now encapsulated in rankingRedisService) + // 1. 랭킹 조회 List rankings = rankingRedisService.getRanking( targetDate, pageable.getPageNumber() + 1, pageable.getPageSize() ); + // 2. 콜드 스타트 Fallback: 오늘 랭킹이 비어있으면 어제 랭킹 조회 + if (rankings.isEmpty() && date == null) { + LocalDate yesterday = targetDate.minusDays(1); + log.info("콜드 스타트 Fallback: 오늘({}) 랭킹 없음, 어제({}) 랭킹 조회", targetDate, yesterday); + + rankings = rankingRedisService.getRanking( + yesterday, + pageable.getPageNumber() + 1, + pageable.getPageSize() + ); + + if (!rankings.isEmpty()) { + targetDate = yesterday; // totalCount 계산을 위해 날짜 변경 + } + } + if (rankings.isEmpty()) { log.debug("랭킹 데이터 없음: date={}", targetDate); return Page.empty(pageable); } - // 2. 상품 ID 목록 추출 + // 3. 상품 ID 목록 추출 List productIds = rankings.stream() .map(RankingItem::productId) .collect(Collectors.toList()); - // 3. 상품 정보 조회 (MV 사용) + // 4. 상품 정보 조회 (MV 사용) List products = mvService.getByIds(productIds); - // 4. 랭킹 순서대로 정렬 + // 5. 랭킹 순서대로 정렬 List sortedProducts = productIds.stream() .map(productId -> products.stream() .filter(p -> p.getProductId().equals(productId)) @@ -158,7 +176,7 @@ public Page getRankingProducts(Pageable pageable, LocalDate date) { .filter(Objects::nonNull) .collect(Collectors.toList()); - // 5. Page 객체 생성 + // 6. Page 객체 생성 long totalCount = rankingRedisService.getRankingCount(targetDate); return new PageImpl<>(sortedProducts, pageable, totalCount); } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/RankingCarryOverScheduler.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/RankingCarryOverScheduler.java new file mode 100644 index 000000000..7dd52809c --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/RankingCarryOverScheduler.java @@ -0,0 +1,74 @@ +package com.loopers.infrastructure.scheduler; + +import java.time.LocalDate; + +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import com.loopers.cache.RankingRedisService; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * 랭킹 Score Carry-Over 스케줄러 + *

+ * 콜드 스타트 문제 해결을 위해 매일 23:50에 전날 점수의 일부를 다음 날로 이월 + * + * @author hyunjikoh + * @since 2025.12.25 + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class RankingCarryOverScheduler { + + private final RankingRedisService rankingRedisService; + + /** + * Carry-Over 가중치 (10%) + * - 너무 높으면: 어제 인기 상품이 계속 상위 유지 (신선도 ↓) + * - 너무 낮으면: 콜드 스타트 해결 효과 미미 + */ + private static final double CARRY_OVER_WEIGHT = 0.1; + + /** + * 매일 23:50에 실행 + *

+ * 오늘 점수의 10%를 내일 키에 미리 복사하여 자정 콜드 스타트 방지 + */ + @Scheduled(cron = "0 50 23 * * *") + public void carryOverDailyRanking() { + LocalDate today = LocalDate.now(); + LocalDate tomorrow = today.plusDays(1); + + log.info("랭킹 Carry-Over 스케줄러 시작: {} → {} (weight={})", + today, tomorrow, CARRY_OVER_WEIGHT); + + try { + long carryOverCount = rankingRedisService.carryOverScores(today, tomorrow, CARRY_OVER_WEIGHT); + + if (carryOverCount > 0) { + log.info("랭킹 Carry-Over 완료: {}개 상품 이월됨", carryOverCount); + } else { + log.warn("랭킹 Carry-Over: 이월할 데이터 없음 (오늘 랭킹이 비어있음)"); + } + + } catch (Exception e) { + log.error("랭킹 Carry-Over 실패", e); + // 스케줄러 실패 시에도 서비스는 계속 동작 (Fallback으로 대응) + } + } + + /** + * 수동 Carry-Over 실행 (테스트/운영용) + * + * @param sourceDate 원본 날짜 + * @param targetDate 대상 날짜 + * @return 이월된 상품 수 + */ + public long manualCarryOver(LocalDate sourceDate, LocalDate targetDate) { + log.info("수동 Carry-Over 실행: {} → {}", sourceDate, targetDate); + return rankingRedisService.carryOverScores(sourceDate, targetDate, CARRY_OVER_WEIGHT); + } +} diff --git a/modules/redis/src/main/java/com/loopers/cache/RankingRedisService.java b/modules/redis/src/main/java/com/loopers/cache/RankingRedisService.java index 77cba1c06..bbfaab689 100644 --- a/modules/redis/src/main/java/com/loopers/cache/RankingRedisService.java +++ b/modules/redis/src/main/java/com/loopers/cache/RankingRedisService.java @@ -236,4 +236,56 @@ public long getRankingCount(LocalDate date) { public boolean hasRankingData(LocalDate date) { return getRankingCount(date) > 0; } + + /** + * Score Carry-Over: 전날 점수의 일부를 다음 날로 이월 + *

+ * 전날 점수에 가중치를 곱해 다음 날 키에 복사 + * + * @param sourceDate 원본 날짜 (전날) + * @param targetDate 대상 날짜 (다음 날) + * @param carryOverWeight 이월 가중치 (0.0 ~ 1.0, 권장: 0.1) + * @return 이월된 상품 수 + */ + public long carryOverScores(LocalDate sourceDate, LocalDate targetDate, double carryOverWeight) { + String sourceKey = cacheKeyGenerator.generateDailyRankingKey(sourceDate); + String targetKey = cacheKeyGenerator.generateDailyRankingKey(targetDate); + + try { + // 원본 키에서 모든 데이터 조회 + Set> sourceData = + redisTemplate.opsForZSet().rangeWithScores(sourceKey, 0, -1); + + if (sourceData == null || sourceData.isEmpty()) { + log.info("Carry-Over 스킵: 원본 랭킹 데이터 없음 - sourceKey={}", sourceKey); + return 0; + } + + // 가중치를 적용하여 대상 키에 추가 (기존 점수에 합산) + ZSetOperations zSetOps = redisTemplate.opsForZSet(); + + for (ZSetOperations.TypedTuple tuple : sourceData) { + String member = tuple.getValue(); + Double score = tuple.getScore(); + + if (member != null && score != null) { + double weightedScore = score * carryOverWeight; + zSetOps.incrementScore(targetKey, member, weightedScore); + } + } + + // TTL 설정 + redisTemplate.expire(targetKey, RANKING_TTL); + + long resultCount = sourceData.size(); + log.info("Score Carry-Over 완료: {} → {} (weight={}, count={})", + sourceKey, targetKey, carryOverWeight, resultCount); + + return resultCount; + + } catch (Exception e) { + log.error("Score Carry-Over 실패: {} → {}", sourceKey, targetKey, e); + throw new RuntimeException("Score Carry-Over 실패", e); + } + } } From 334c14ab1c0b676d607770eb8929ffe08a5860b9 Mon Sep 17 00:00:00 2001 From: hyujikoh Date: Fri, 26 Dec 2025 11:42:31 +0900 Subject: [PATCH 10/18] =?UTF-8?q?feat(metrics):=20=EB=A9=94=ED=8A=B8?= =?UTF-8?q?=EB=A6=AD=EC=8A=A4=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DatabaseCleanUp 및 RedisCleanUp 유틸리티 추가로 테스트 데이터 정리 방식 개선 - EventProcessingFacade를 통한 이벤트 처리 로직 테스트로 신뢰성 향상 - 여러 이벤트의 랭킹 점수를 배치로 업데이트하는 테스트 추가 --- ...MetricsEventProcessingIntegrationTest.java | 14 +- .../consumer/MetricsKafkaConsumerTest.java | 164 +++++++++--------- 2 files changed, 93 insertions(+), 85 deletions(-) diff --git a/apps/commerce-streamer/src/test/java/com/loopers/integration/MetricsEventProcessingIntegrationTest.java b/apps/commerce-streamer/src/test/java/com/loopers/integration/MetricsEventProcessingIntegrationTest.java index a13c5bd6a..a9ffa3b12 100644 --- a/apps/commerce-streamer/src/test/java/com/loopers/integration/MetricsEventProcessingIntegrationTest.java +++ b/apps/commerce-streamer/src/test/java/com/loopers/integration/MetricsEventProcessingIntegrationTest.java @@ -20,6 +20,8 @@ import com.loopers.infrastructure.event.DomainEventEnvelope; import com.loopers.infrastructure.event.payloads.PaymentSuccessPayloadV1; import com.loopers.infrastructure.event.payloads.ProductViewPayloadV1; +import com.loopers.utils.DatabaseCleanUp; +import com.loopers.utils.RedisCleanUp; /** * 메트릭스 이벤트 처리 통합 테스트 @@ -43,12 +45,16 @@ class MetricsEventProcessingIntegrationTest { @Autowired private ObjectMapper objectMapper; + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @Autowired + private RedisCleanUp redisCleanUp; + @BeforeEach - @Transactional void setUp() { - // 테스트 데이터 정리 - productMetricsRepository.deleteAll(); - eventRepository.deleteAll(); + databaseCleanUp.truncateAllTables(); + redisCleanUp.truncateAll(); } @Test diff --git a/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/MetricsKafkaConsumerTest.java b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/MetricsKafkaConsumerTest.java index 99dfd0b9e..0082c7102 100644 --- a/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/MetricsKafkaConsumerTest.java +++ b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/MetricsKafkaConsumerTest.java @@ -2,10 +2,10 @@ import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; + import java.util.List; import org.apache.kafka.clients.consumer.ConsumerRecord; -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; @@ -14,15 +14,16 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.kafka.support.Acknowledgment; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.loopers.domain.metrics.MetricsService; +import com.loopers.application.event.EventProcessingFacade; +import com.loopers.application.event.EventProcessingFacade.CatalogEventResult; +import com.loopers.application.event.EventProcessingFacade.OrderEventResult; +import com.loopers.cache.dto.CachePayloads.RankingScore; import com.loopers.infrastructure.event.DomainEventEnvelope; -import com.loopers.infrastructure.event.EventDeserializer; -import com.loopers.infrastructure.event.payloads.PaymentSuccessPayloadV1; -import com.loopers.infrastructure.event.payloads.ProductViewPayloadV1; /** * MetricsKafkaConsumer 멱등성 및 신뢰성 테스트 + *

+ * Consumer는 EventProcessingFacade에 위임만 하므로, Facade mock을 통해 테스트 * * @author hyunjikoh * @since 2025. 12. 18. @@ -31,10 +32,7 @@ class MetricsKafkaConsumerTest { @Mock - private MetricsService metricsService; - - @Mock - private EventDeserializer eventDeserializer; + private EventProcessingFacade eventProcessingFacade; @Mock private Acknowledgment acknowledgment; @@ -42,104 +40,85 @@ class MetricsKafkaConsumerTest { @InjectMocks private MetricsKafkaConsumer consumer; - private ObjectMapper objectMapper; - - @BeforeEach - void setUp() { - objectMapper = new ObjectMapper(); - } - @Test - @DisplayName("중복된 이벤트 ID는 한 번만 처리되어야 한다") - void shouldProcessEventOnlyOnce() { + @DisplayName("카탈로그 이벤트가 Facade를 통해 처리되어야 한다") + void shouldProcessCatalogEventThroughFacade() { // Given - String eventId = "test-event-123"; DomainEventEnvelope envelope = new DomainEventEnvelope( - eventId, + "test-event-123", "PRODUCT_VIEW", "v1", System.currentTimeMillis(), "{\"productId\":1,\"userId\":100}" ); - ProductViewPayloadV1 payload = new ProductViewPayloadV1(1L, 100L); - + RankingScore rankingScore = RankingScore.forProductView(1L, System.currentTimeMillis()); ConsumerRecord record = new ConsumerRecord<>("catalog-events", 0, 0, null, envelope); - // 첫 번째 호출에서는 true (처음 처리), 두 번째 호출에서는 false (이미 처리됨) - when(metricsService.tryMarkHandled(eventId)) - .thenReturn(true) - .thenReturn(false); - - when(eventDeserializer.deserializeEnvelope(envelope)) - .thenReturn(envelope); - - when(eventDeserializer.deserializeProductView(envelope.payloadJson())) - .thenReturn(payload); + when(eventProcessingFacade.processCatalogEvent(envelope)) + .thenReturn(CatalogEventResult.processed(rankingScore)); - // When - 같은 이벤트를 두 번 처리 - consumer.onCatalogEvents(List.of(record, record), acknowledgment); + // When + consumer.onCatalogEvents(List.of(record), acknowledgment); - // Then - 비즈니스 로직은 한 번만 호출되어야 함 - verify(metricsService, times(2)).tryMarkHandled(eventId); - verify(metricsService, times(1)).incrementView(eq(1L), anyLong()); + // Then + verify(eventProcessingFacade, times(1)).processCatalogEvent(envelope); + verify(eventProcessingFacade, times(1)).updateRankingScores(anyList(), isNull()); verify(acknowledgment, times(1)).acknowledge(); } @Test - @DisplayName("잘못된 이벤트 봉투는 무시되어야 한다") - void shouldIgnoreInvalidEventEnvelope() { + @DisplayName("처리되지 않은 이벤트는 랭킹 업데이트에 포함되지 않아야 한다") + void shouldNotIncludeUnprocessedEventsInRankingUpdate() { // Given - ConsumerRecord record = new ConsumerRecord<>("catalog-events", 0, 0, null, "invalid-json"); + DomainEventEnvelope envelope = new DomainEventEnvelope( + "test-event-456", + "UNKNOWN_EVENT", + "v1", + System.currentTimeMillis(), + "{}" + ); + + ConsumerRecord record = new ConsumerRecord<>("catalog-events", 0, 0, null, envelope); - when(eventDeserializer.deserializeEnvelope("invalid-json")) - .thenReturn(null); + when(eventProcessingFacade.processCatalogEvent(envelope)) + .thenReturn(CatalogEventResult.notProcessed()); // When consumer.onCatalogEvents(List.of(record), acknowledgment); // Then - verify(metricsService, never()).tryMarkHandled(anyString()); - verify(metricsService, never()).incrementView(anyLong(), anyLong()); - verify(acknowledgment, times(1)).acknowledge(); // 배치는 여전히 ack 되어야 함 + verify(eventProcessingFacade, times(1)).processCatalogEvent(envelope); + verify(eventProcessingFacade, never()).updateRankingScores(anyList(), any()); + verify(acknowledgment, times(1)).acknowledge(); } @Test - @DisplayName("PAYMENT_SUCCESS 이벤트가 상품별로 개별 처리되어야 한다") - void shouldProcessPaymentSuccessEvent() { + @DisplayName("PAYMENT_SUCCESS 이벤트가 Facade를 통해 처리되어야 한다") + void shouldProcessPaymentSuccessEventThroughFacade() { // Given - String eventId = "payment-event-456"; DomainEventEnvelope envelope = new DomainEventEnvelope( - eventId, + "payment-event-789", "PAYMENT_SUCCESS", "v1", System.currentTimeMillis(), - "{\"orderId\":12345,\"orderNumber\":67890,\"userId\":100,\"productId\":1,\"quantity\":2,\"unitPrice\":1000,\"totalPrice\":2000}" + "{\"orderId\":12345,\"productId\":1,\"quantity\":2,\"totalPrice\":2000}" ); - // 새로운 PaymentSuccessPayloadV1 구조 (상품별 개별 이벤트) - PaymentSuccessPayloadV1 payload = new PaymentSuccessPayloadV1( - 12345L, // orderId - 67890L, // orderNumber - 100L, // userId - 1L, // productId - 2, // quantity - java.math.BigDecimal.valueOf(1000), // unitPrice - java.math.BigDecimal.valueOf(2000) // totalPrice + RankingScore rankingScore = RankingScore.forPaymentSuccess( + 1L, java.math.BigDecimal.valueOf(2000), System.currentTimeMillis() ); - ConsumerRecord record = new ConsumerRecord<>("order-events", 0, 0, null, envelope); - when(metricsService.tryMarkHandled(eventId)).thenReturn(true); - when(eventDeserializer.deserializeEnvelope(envelope)).thenReturn(envelope); - when(eventDeserializer.deserializePaymentSuccess(envelope.payloadJson())).thenReturn(payload); + when(eventProcessingFacade.processOrderEvent(envelope)) + .thenReturn(OrderEventResult.processed(rankingScore)); // When consumer.onOrderEvents(List.of(record), acknowledgment); // Then - verify(metricsService, times(1)).tryMarkHandled(eventId); - verify(metricsService, times(1)).addSales(eq(1L), eq(2), anyLong()); + verify(eventProcessingFacade, times(1)).processOrderEvent(envelope); + verify(eventProcessingFacade, times(1)).updateRankingScores(anyList(), isNull()); verify(acknowledgment, times(1)).acknowledge(); } @@ -147,11 +126,8 @@ void shouldProcessPaymentSuccessEvent() { @DisplayName("개별 메시지 처리 실패가 전체 배치를 실패시키지 않아야 한다") void shouldContinueProcessingWhenIndividualMessageFails() { // Given - String validEventId = "valid-event"; - String invalidEventId = "invalid-event"; - DomainEventEnvelope validEnvelope = new DomainEventEnvelope( - validEventId, + "valid-event", "PRODUCT_VIEW", "v1", System.currentTimeMillis(), @@ -159,32 +135,58 @@ void shouldContinueProcessingWhenIndividualMessageFails() { ); DomainEventEnvelope invalidEnvelope = new DomainEventEnvelope( - invalidEventId, + "invalid-event", "PRODUCT_VIEW", "v1", System.currentTimeMillis(), "invalid-payload" ); - ProductViewPayloadV1 validPayload = new ProductViewPayloadV1(1L, 100L); + RankingScore rankingScore = RankingScore.forProductView(1L, System.currentTimeMillis()); ConsumerRecord validRecord = new ConsumerRecord<>("catalog-events", 0, 0, null, validEnvelope); ConsumerRecord invalidRecord = new ConsumerRecord<>("catalog-events", 0, 1, null, invalidEnvelope); - when(metricsService.tryMarkHandled(validEventId)).thenReturn(true); - when(metricsService.tryMarkHandled(invalidEventId)).thenReturn(true); - - when(eventDeserializer.deserializeEnvelope(validEnvelope)).thenReturn(validEnvelope); - when(eventDeserializer.deserializeEnvelope(invalidEnvelope)).thenReturn(invalidEnvelope); - - when(eventDeserializer.deserializeProductView(validEnvelope.payloadJson())).thenReturn(validPayload); - when(eventDeserializer.deserializeProductView(invalidEnvelope.payloadJson())).thenReturn(null); // 파싱 실패 + when(eventProcessingFacade.processCatalogEvent(validEnvelope)) + .thenReturn(CatalogEventResult.processed(rankingScore)); + when(eventProcessingFacade.processCatalogEvent(invalidEnvelope)) + .thenThrow(new RuntimeException("Processing failed")); // When consumer.onCatalogEvents(List.of(validRecord, invalidRecord), acknowledgment); // Then - 유효한 메시지는 처리되고, 전체 배치는 ack 되어야 함 - verify(metricsService, times(1)).incrementView(eq(1L), anyLong()); + verify(eventProcessingFacade, times(2)).processCatalogEvent(any()); + verify(acknowledgment, times(1)).acknowledge(); + } + + @Test + @DisplayName("여러 이벤트의 랭킹 점수가 배치로 업데이트되어야 한다") + void shouldBatchUpdateRankingScores() { + // Given + DomainEventEnvelope envelope1 = new DomainEventEnvelope( + "event-1", "PRODUCT_VIEW", "v1", System.currentTimeMillis(), "{\"productId\":1}" + ); + DomainEventEnvelope envelope2 = new DomainEventEnvelope( + "event-2", "LIKE_ACTION", "v1", System.currentTimeMillis(), "{\"productId\":2,\"action\":\"LIKE\"}" + ); + + RankingScore score1 = RankingScore.forProductView(1L, System.currentTimeMillis()); + RankingScore score2 = RankingScore.forLikeAction(2L, System.currentTimeMillis()); + + ConsumerRecord record1 = new ConsumerRecord<>("catalog-events", 0, 0, null, envelope1); + ConsumerRecord record2 = new ConsumerRecord<>("catalog-events", 0, 1, null, envelope2); + + when(eventProcessingFacade.processCatalogEvent(envelope1)) + .thenReturn(CatalogEventResult.processed(score1)); + when(eventProcessingFacade.processCatalogEvent(envelope2)) + .thenReturn(CatalogEventResult.processed(score2)); + + // When + consumer.onCatalogEvents(List.of(record1, record2), acknowledgment); + + // Then - 2개의 랭킹 점수가 배치로 업데이트되어야 함 + verify(eventProcessingFacade, times(1)).updateRankingScores(argThat(list -> list.size() == 2), isNull()); verify(acknowledgment, times(1)).acknowledge(); } } From 67dfc10ea6e3559e3f3cb1fe9283a08365855cbe Mon Sep 17 00:00:00 2001 From: hyujikoh Date: Fri, 26 Dec 2025 11:42:49 +0900 Subject: [PATCH 11/18] =?UTF-8?q?refactor(metrics):=20=EB=A9=94=ED=8A=B8?= =?UTF-8?q?=EB=A6=AD=EC=8A=A4=20=EA=B4=80=EB=A0=A8=20=EB=A6=AC=ED=8F=AC?= =?UTF-8?q?=EC=A7=80=ED=86=A0=EB=A6=AC=20=EC=BD=94=EB=93=9C=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EventRepository와 ProductMetricsRepository에서 deleteAll 메서드 제거 - MetricsService를 MetricsApplicationService로 변경 - ProductMetricsRepository에 메트릭 저장 및 조회 메서드 주석 추가 --- .../event/EventProcessingFacade.java | 4 +- .../loopers/domain/event/EventRepository.java | 2 - .../domain/metrics/MetricsService.java | 216 ------------------ .../metrics/ProductMetricsRepository.java | 13 +- .../domain/metrics/ProductMetricsService.java | 112 --------- .../metrics/repository/MetricsRepository.java | 32 --- .../event/EventRepositoryImpl.java | 4 - .../metrics/MetricsRepositoryImpl.java | 136 ----------- .../metrics/ProductMetricsRepositoryImpl.java | 5 - .../MetricsLockCleanupScheduler.java | 4 +- 10 files changed, 15 insertions(+), 513 deletions(-) delete mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/MetricsService.java delete mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java delete mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/repository/MetricsRepository.java delete mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/MetricsRepositoryImpl.java diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/event/EventProcessingFacade.java b/apps/commerce-streamer/src/main/java/com/loopers/application/event/EventProcessingFacade.java index 0953efeb4..4805f4f5f 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/application/event/EventProcessingFacade.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/event/EventProcessingFacade.java @@ -5,8 +5,8 @@ import org.springframework.stereotype.Service; +import com.loopers.application.metrics.MetricsApplicationService; import com.loopers.cache.dto.CachePayloads.RankingScore; -import com.loopers.domain.metrics.MetricsService; import com.loopers.domain.ranking.RankingService; import com.loopers.infrastructure.event.DomainEventEnvelope; import com.loopers.infrastructure.event.EventDeserializer; @@ -31,7 +31,7 @@ @Slf4j public class EventProcessingFacade { - private final MetricsService metricsService; + private final MetricsApplicationService metricsService; private final RankingService rankingService; private final EventDeserializer eventDeserializer; diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventRepository.java index 0d8dfb30a..a3e0e546b 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventRepository.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventRepository.java @@ -8,7 +8,5 @@ public interface EventRepository { EventEntity save(EventEntity eventEntity); - void deleteAll(); - boolean existsById(String eventId); } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/MetricsService.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/MetricsService.java deleted file mode 100644 index 0171318e5..000000000 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/MetricsService.java +++ /dev/null @@ -1,216 +0,0 @@ -package com.loopers.domain.metrics; - -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.locks.ReentrantLock; - -import org.springframework.stereotype.Component; - -import com.loopers.domain.event.EventRepository; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -/** - * ConcurrentHashMap 기반 동시성 안전한 메트릭 서비스 - *

- * 상품별 메모리 락을 사용하여 동일한 상품에 대한 동시 업데이트를 제어합니다. - * Redis 분산락 대신 메모리 기반 락을 사용하여 성능을 대폭 향상시킵니다. - * - * @author hyunjikoh - * @since 2025. 12. 19. - */ -@Component -@RequiredArgsConstructor -@Slf4j -public class MetricsService { - private final EventRepository eventHandledRepository; - private final ProductMetricsService metricsTransactionService; - - // 상품별 메모리 락 관리 - private final ConcurrentHashMap productLocks = new ConcurrentHashMap<>(); - - // 락 획득 설정 (빠른 처리를 위해 짧게 설정) - private static final long LOCK_WAIT_TIME_MS = 100; // 100ms 대기 - private static final int LOCK_CLEANUP_THRESHOLD = 10000; // 락 정리 임계값 - - // 메모리 기반 멱등성 체크 (성능 최적화) - private final ConcurrentHashMap processedEvents = new ConcurrentHashMap<>(); - private static final int PROCESSED_EVENTS_CLEANUP_THRESHOLD = 50000; // 처리된 이벤트 정리 임계값 - - /** - * 멱등성 체크 - 메모리 기반으로 성능 최적화 - * 예외 기반이 아닌 조회 기반으로 중복 체크를 수행하여 성능을 향상시킵니다. - */ - public boolean tryMarkHandled(String eventId) { - // 1. 메모리 캐시 먼저 확인 (빠른 경로) - if (processedEvents.containsKey(eventId)) { - log.debug("이미 처리된 이벤트 (메모리 캐시): {}", eventId); - return false; - } - - // 2. DB에서 확인 (느린 경로) - if (eventHandledRepository.existsById(eventId)) { - // DB에 있으면 메모리 캐시에도 추가 - processedEvents.put(eventId, true); - log.debug("이미 처리된 이벤트 (DB 확인): {}", eventId); - return false; - } - - // 3. 새로운 이벤트 - 트랜잭션 서비스를 통해 안전하게 저장 - boolean saved = metricsTransactionService.saveEventHandled(eventId); - if (saved) { - processedEvents.put(eventId, true); - return true; - } else { - // 동시성으로 인해 다른 스레드가 먼저 저장한 경우 - processedEvents.put(eventId, true); - log.debug("동시성으로 인한 중복 이벤트: {}", eventId); - return false; - } - } - - /** - * 조회수 증가 (메모리 락 적용) - */ - public void incrementView(Long productId, long occurredAtEpochMillis) { - ReentrantLock lock = getProductLock(productId); - - try { - if (lock.tryLock(LOCK_WAIT_TIME_MS, TimeUnit.MILLISECONDS)) { - try { - metricsTransactionService.incrementViewWithTransaction(productId, occurredAtEpochMillis); - log.debug("조회수 업데이트 성공: productId={}", productId); - } finally { - lock.unlock(); - } - } else { - log.warn("조회수 업데이트 스킵 - 락 획득 실패: productId={}", productId); - // 락 획득 실패 시 이벤트 스킵 (성능 우선) - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - log.warn("조회수 업데이트 중단 - 스레드 인터럽트: productId={}", productId); - } - } - - - /** - * 좋아요 수 변경 (메모리 락 적용) - */ - public void applyLikeDelta(final Long productId, final int delta, long occurredAtEpochMillis) { - ReentrantLock lock = getProductLock(productId); - - try { - if (lock.tryLock(LOCK_WAIT_TIME_MS, TimeUnit.MILLISECONDS)) { - try { - metricsTransactionService.applyLikeDeltaWithTransaction(productId, delta, occurredAtEpochMillis); - log.debug("좋아요 수 업데이트 성공: productId={}, delta={}", productId, delta); - } finally { - lock.unlock(); - } - } else { - log.warn("좋아요 수 업데이트 스킵 - 락 획득 실패: productId={}, delta={}", productId, delta); - // 락 획득 실패 시 이벤트 스킵 (성능 우선) - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - log.warn("좋아요 수 업데이트 중단 - 스레드 인터럽트: productId={}, delta={}", productId, delta); - } - } - - - /** - * 판매량 증가 (메모리 락 적용) - */ - public void addSales(final Long productId, final int quantity, long occurredAtEpochMillis) { - ReentrantLock lock = getProductLock(productId); - - try { - if (lock.tryLock(LOCK_WAIT_TIME_MS, TimeUnit.MILLISECONDS)) { - try { - metricsTransactionService.addSalesWithTransaction(productId, quantity, occurredAtEpochMillis); - log.debug("판매량 업데이트 성공: productId={}, quantity={}", productId, quantity); - } finally { - lock.unlock(); - } - } else { - log.warn("판매량 업데이트 스킵 - 락 획득 실패: productId={}, quantity={}", productId, quantity); - - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - log.warn("판매량 업데이트 중단 - 스레드 인터럽트: productId={}, quantity={}", productId, quantity); - } - } - - /** - * 재고 소진 이벤트 처리 (캐시 갱신 중심) - */ - public void handleStockDepleted(Long productId, Long brandId, Integer remainingStock, long occurredAtEpochMillis) { - // 재고 소진은 메트릭 업데이트보다는 캐시 갱신이 주 목적 - // 락 없이 바로 트랜잭션 서비스 호출 - metricsTransactionService.handleStockDepletedWithTransaction(productId, brandId, remainingStock, occurredAtEpochMillis); - log.info("재고 소진 이벤트 처리 완료: productId={}, brandId={}, remainingStock={}", productId, brandId, remainingStock); - } - - /** - * 상품별 락 획득 (없으면 생성) - */ - private ReentrantLock getProductLock(Long productId) { - return productLocks.computeIfAbsent(productId, k -> new ReentrantLock()); - } - - /** - * 락 상태 모니터링 및 정리 (메모리 누수 방지) - */ - public void cleanupUnusedLocks() { - if (productLocks.size() > LOCK_CLEANUP_THRESHOLD) { - log.info("락 정리 시작 - 현재 락 수: {}", productLocks.size()); - - // 사용하지 않는 락 제거 (락이 걸려있지 않은 것들) - productLocks.entrySet().removeIf(entry -> { - ReentrantLock lock = entry.getValue(); - return !lock.isLocked() && !lock.hasQueuedThreads(); - }); - - log.info("락 정리 완료 - 정리 후 락 수: {}", productLocks.size()); - } - } - - /** - * 처리된 이벤트 캐시 정리 (메모리 누수 방지) - */ - public void cleanupProcessedEvents() { - if (processedEvents.size() > PROCESSED_EVENTS_CLEANUP_THRESHOLD) { - log.info("처리된 이벤트 캐시 정리 시작 - 현재 캐시 수: {}", processedEvents.size()); - - // 오래된 이벤트 캐시 절반 정도 제거 (LRU 방식은 아니지만 메모리 절약) - int targetSize = PROCESSED_EVENTS_CLEANUP_THRESHOLD / 2; - int currentSize = processedEvents.size(); - int toRemove = currentSize - targetSize; - - processedEvents.entrySet().stream() - .limit(toRemove) - .map(Map.Entry::getKey) - .forEach(processedEvents::remove); - - log.info("처리된 이벤트 캐시 정리 완료 - 정리 후 캐시 수: {}", processedEvents.size()); - } - } - - /** - * 락 상태 정보 조회 (모니터링용) - */ - public void logLockStatus() { - int totalLocks = productLocks.size(); - long lockedCount = productLocks.values().stream() - .mapToLong(lock -> lock.isLocked() ? 1 : 0) - .sum(); - - if (totalLocks > 0) { - log.debug("메트릭 락 상태 - 총 락: {}, 사용 중: {}", totalLocks, lockedCount); - } - } -} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java index ae4bfb12d..c9ec7e09d 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java @@ -3,14 +3,23 @@ import java.util.Optional; /** + * 상품 메트릭 Repository 인터페이스 + *

+ * Domain 계층의 순수한 Repository 인터페이스입니다. + * Infrastructure 계층에서 JPA로 구현됩니다. * * @author hyunjikoh * @since 2025. 12. 16. */ public interface ProductMetricsRepository { + + /** + * 메트릭 저장 + */ ProductMetricsEntity save(ProductMetricsEntity metrics); + /** + * 상품 ID로 메트릭 조회 + */ Optional findById(Long productId); - - void deleteAll(); } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java deleted file mode 100644 index 759c3fc43..000000000 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java +++ /dev/null @@ -1,112 +0,0 @@ -package com.loopers.domain.metrics; - -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import com.loopers.domain.event.EventRepository; -import com.loopers.domain.metrics.repository.MetricsRepository; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -/** - * 메트릭 트랜잭션 처리 서비스 - *

- * Spring AOP Self-Invocation 문제를 해결하기 위해 분리된 트랜잭션 서비스입니다. - * MetricsService에서 @Transactional 메서드를 호출할 때 발생하는 문제를 방지합니다. - * - * @author hyunjikoh - * @since 2025. 12. 19. - */ -@Service -@RequiredArgsConstructor -@Slf4j -public class ProductMetricsService { - - private final MetricsRepository metricsRepository; - private final EventRepository eventHandledRepository; - - /** - * 조회수 증가 (트랜잭션 적용) - */ - @Transactional - public void incrementViewWithTransaction(Long productId, long occurredAtEpochMillis) { - try { - metricsRepository.incrementView(productId, occurredAtEpochMillis); - log.debug("조회수 증가 완료: productId={}", productId); - } catch (Exception e) { - log.error("조회수 증가 실패: productId={}", productId, e); - throw e; - } - } - - /** - * 좋아요 수 변경 (트랜잭션 적용) - */ - @Transactional - public void applyLikeDeltaWithTransaction(Long productId, int delta, long occurredAtEpochMillis) { - try { - metricsRepository.applyLikeDelta(productId, delta, occurredAtEpochMillis); - log.debug("좋아요 수 변경 완료: productId={}, delta={}", productId, delta); - } catch (Exception e) { - log.error("좋아요 수 변경 실패: productId={}, delta={}", productId, delta, e); - throw e; - } - } - - /** - * 판매량 증가 (트랜잭션 적용) - */ - @Transactional - public void addSalesWithTransaction(Long productId, int quantity, long occurredAtEpochMillis) { - try { - metricsRepository.addSales(productId, quantity, occurredAtEpochMillis); - log.debug("판매량 증가 완료: productId={}, quantity={}", productId, quantity); - } catch (Exception e) { - log.error("판매량 증가 실패: productId={}, quantity={}", productId, quantity, e); - throw e; - } - - } - - - /** - * 재고 소진 이벤트 처리 (트랜잭션 적용) - * 주로 캐시 갱신을 담당합니다. - */ - @Transactional - public void handleStockDepletedWithTransaction(Long productId, Long brandId, Integer remainingStock, - long occurredAtEpochMillis) { - try { - // 재고 소진 시 캐시 갱신 처리 - metricsRepository.handleStockDepleted(productId, brandId, remainingStock, occurredAtEpochMillis); - log.debug("재고 소진 처리 완료: productId={}, brandId={}, remainingStock={}", productId, brandId, remainingStock); - } catch (Exception e) { - log.error("재고 소진 처리 실패: productId={}, brandId={}, remainingStock={}", productId, brandId, remainingStock, e); - throw e; - } - } - - /** - * 이벤트 처리 완료 마킹 (트랜잭션 적용) - * 예외 기반이 아닌 조회 기반으로 중복 체크를 수행합니다. - */ - @Transactional - public boolean saveEventHandled(String eventId) { - try { - // 트랜잭션 내에서 다시 한번 확인 (동시성 안전) - if (eventHandledRepository.existsById(eventId)) { - log.debug("트랜잭션 내 중복 확인: {}", eventId); - return false; - } - - eventHandledRepository.save(com.loopers.domain.event.EventEntity.create(eventId)); - log.debug("이벤트 처리 완료 저장: {}", eventId); - return true; - } catch (Exception e) { - // 동시성으로 인한 중복 저장 시도 (Unique 제약 조건 위반 등) - log.debug("동시성으로 인한 이벤트 저장 실패: {}", eventId, e); - return false; - } - } -} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/repository/MetricsRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/repository/MetricsRepository.java deleted file mode 100644 index 7837bddaf..000000000 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/repository/MetricsRepository.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.loopers.domain.metrics.repository; - -/** - * 메트릭 업데이트를 위한 Repository 인터페이스 - *

- * 동시성 안전한 메트릭 업데이트 작업을 담당합니다. - * - * @author hyunjikoh - * @since 2025. 12. 19. - */ -public interface MetricsRepository { - - /** - * 조회수 증가 - */ - void incrementView(Long productId, long occurredAtEpochMillis); - - /** - * 좋아요 수 변경 (증가/감소) - */ - void applyLikeDelta(Long productId, int delta, long occurredAtEpochMillis); - - /** - * 판매량 증가 - */ - void addSales(Long productId, int quantity, long occurredAtEpochMillis); - - /** - * 재고 소진 이벤트 처리 (캐시 갱신 중심) - */ - void handleStockDepleted(Long productId, Long brandId, Integer remainingStock, long occurredAtEpochMillis); -} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventRepositoryImpl.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventRepositoryImpl.java index a9d47cf1c..cc7bbb6d7 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventRepositoryImpl.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventRepositoryImpl.java @@ -22,10 +22,6 @@ public EventEntity save(EventEntity eventEntity) { return eventJpaRepository.save(eventEntity); } - @Override - public void deleteAll() { - eventJpaRepository.deleteAll(); - } @Override public boolean existsById(String eventId) { diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/MetricsRepositoryImpl.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/MetricsRepositoryImpl.java deleted file mode 100644 index 7296bc600..000000000 --- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/MetricsRepositoryImpl.java +++ /dev/null @@ -1,136 +0,0 @@ -package com.loopers.infrastructure.metrics; - -import java.time.Instant; -import java.time.ZoneId; -import java.time.ZonedDateTime; -import java.util.Optional; - -import org.springframework.stereotype.Repository; - -import com.loopers.domain.metrics.ProductMetricsEntity; -import com.loopers.domain.metrics.ProductMetricsRepository; -import com.loopers.domain.metrics.repository.MetricsRepository; -import com.loopers.infrastructure.cache.ProductCacheService; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -/** - * 메트릭 Repository 구현체 - *

- * ProductMetricsRepository를 사용하여 실제 메트릭 업데이트를 수행합니다. - * 존재하지 않는 상품의 경우 새로 생성하여 처리합니다. - * - * @author hyunjikoh - * @since 2025. 12. 19. - */ -@Repository -@RequiredArgsConstructor -@Slf4j -public class MetricsRepositoryImpl implements MetricsRepository { - - private final ProductMetricsRepository productMetricsRepository; - private final ProductCacheService productCacheService; - - @Override - public void incrementView(Long productId, long occurredAtEpochMillis) { - ZonedDateTime eventTime = convertToZonedDateTime(occurredAtEpochMillis); - - Optional existingMetrics = productMetricsRepository.findById(productId); - - long newViewCount; - if (existingMetrics.isPresent()) { - ProductMetricsEntity metrics = existingMetrics.get(); - metrics.incrementView(eventTime); - productMetricsRepository.save(metrics); - newViewCount = metrics.getViewCount(); - } else { - // 새로운 상품 메트릭 생성 - ProductMetricsEntity newMetrics = ProductMetricsEntity.create(productId); - newMetrics.incrementView(eventTime); - productMetricsRepository.save(newMetrics); - newViewCount = newMetrics.getViewCount(); - } - - - log.debug("조회수 증가 완료: productId={}, eventTime={}", productId, eventTime); - } - - @Override - public void applyLikeDelta(Long productId, int delta, long occurredAtEpochMillis) { - ZonedDateTime eventTime = convertToZonedDateTime(occurredAtEpochMillis); - - Optional existingMetrics = productMetricsRepository.findById(productId); - - if (existingMetrics.isPresent()) { - ProductMetricsEntity metrics = existingMetrics.get(); - metrics.applyLikeDelta(delta, eventTime); - productMetricsRepository.save(metrics); - } else { - // 새로운 상품 메트릭 생성 (좋아요가 음수가 되지 않도록 처리) - if (delta > 0) { - ProductMetricsEntity newMetrics = ProductMetricsEntity.create(productId); - newMetrics.applyLikeDelta(delta, eventTime); - productMetricsRepository.save(newMetrics); - } else { - log.debug("새로운 상품에 대한 좋아요 감소 무시: productId={}, delta={}", productId, delta); - return; // 캐시 무효화 불필요 - } - } - - // 좋아요는 캐시 무효화하지 않음 (실시간 반영 불필요) - - log.debug("좋아요 수 변경 완료: productId={}, delta={}, eventTime={}", productId, delta, eventTime); - } - - @Override - public void addSales(Long productId, int quantity, long occurredAtEpochMillis) { - if (quantity <= 0) { - log.debug("잘못된 판매량 무시: productId={}, quantity={}", productId, quantity); - return; - } - - ZonedDateTime eventTime = convertToZonedDateTime(occurredAtEpochMillis); - - Optional existingMetrics = productMetricsRepository.findById(productId); - - if (existingMetrics.isPresent()) { - ProductMetricsEntity metrics = existingMetrics.get(); - metrics.addSales(quantity, eventTime); - productMetricsRepository.save(metrics); - } else { - // 새로운 상품 메트릭 생성 - ProductMetricsEntity newMetrics = ProductMetricsEntity.create(productId); - newMetrics.addSales(quantity, eventTime); - productMetricsRepository.save(newMetrics); - } - - // 캐시 무효화 (판매량 변경 - 인기 상품 순위 영향) - productCacheService.onSalesCountChanged(productId); - - log.debug("판매량 증가 완료: productId={}, quantity={}, eventTime={}", productId, quantity, eventTime); - } - - @Override - public void handleStockDepleted(Long productId, Long brandId, Integer remainingStock, long occurredAtEpochMillis) { - // 재고 소진 이벤트 처리 - // 메트릭 자체는 업데이트하지 않고 캐시만 처리 - - // 상품 상세 캐시의 재고 정보만 갱신 (빠른 응답을 위해) - int stockToUpdate = (remainingStock != null) ? remainingStock : 0; - productCacheService.updateProductStock(productId, stockToUpdate); - - log.info("재고 소진 상세 캐시 갱신 완료: productId={}, brandId={}, remainingStock={}", - productId, brandId, stockToUpdate); - } - - /** - * Epoch 밀리초를 ZonedDateTime으로 변환 - */ - private ZonedDateTime convertToZonedDateTime(long epochMillis) { - return ZonedDateTime.ofInstant( - Instant.ofEpochMilli(epochMillis), - ZoneId.systemDefault() - ); - } -} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java index 4894e575e..633220ac4 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java @@ -28,9 +28,4 @@ public ProductMetricsEntity save(ProductMetricsEntity metrics) { public Optional findById(Long productId) { return productMetricsJpaRepository.findById(productId); } - - @Override - public void deleteAll() { - productMetricsJpaRepository.deleteAll(); - } } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/MetricsLockCleanupScheduler.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/MetricsLockCleanupScheduler.java index 4fea5785b..2be23f0bb 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/MetricsLockCleanupScheduler.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/MetricsLockCleanupScheduler.java @@ -3,7 +3,7 @@ import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; -import com.loopers.domain.metrics.MetricsService; +import com.loopers.application.metrics.MetricsApplicationService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -21,7 +21,7 @@ @Slf4j public class MetricsLockCleanupScheduler { - private final MetricsService metricsService; + private final MetricsApplicationService metricsService; /** * 사용하지 않는 락 정리 (5분마다) From 08f81a3ff94c9d9b5e6edf05d0d93d3c8afbaab2 Mon Sep 17 00:00:00 2001 From: hyujikoh Date: Fri, 26 Dec 2025 12:15:53 +0900 Subject: [PATCH 12/18] =?UTF-8?q?feat(event):=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B2=98=EB=A6=AC=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=EB=B0=8F=20DTO=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 이벤트 처리 완료를 위한 Domain Service 구현 - 이벤트 처리 결과 DTO 추가 - Kafka Consumer와의 통합을 위한 메트릭스 서비스 리팩토링 --- .../product/ProductFacadeRankingTest.java | 287 +++++++++++++++++ .../event/EventProcessingFacade.java | 124 +++---- .../event/dto/EventProcessingResult.java | 44 +++ .../application/metrics/MetricsService.java | 207 ++++++++++++ .../domain/event/EventHandledService.java | 56 ++++ .../domain/metrics/ProductMetricsService.java | 93 ++++++ .../MetricsLockCleanupScheduler.java | 4 +- .../domain/ranking/RankingServiceTest.java | 296 +++++++++++++++++ .../integration/RankingIntegrationTest.java | 303 ++++++++++++++++++ .../consumer/MetricsKafkaConsumerTest.java | 4 +- 10 files changed, 1342 insertions(+), 76 deletions(-) create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeRankingTest.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/application/event/dto/EventProcessingResult.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsService.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledService.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java create mode 100644 apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingServiceTest.java create mode 100644 apps/commerce-streamer/src/test/java/com/loopers/integration/RankingIntegrationTest.java diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeRankingTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeRankingTest.java new file mode 100644 index 000000000..b64e4c8e0 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeRankingTest.java @@ -0,0 +1,287 @@ +package com.loopers.application.product; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; +import static org.mockito.Answers.RETURNS_DEEP_STUBS; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +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.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import com.loopers.cache.RankingRedisService; +import com.loopers.cache.dto.CachePayloads.RankingItem; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.like.LikeService; +import com.loopers.domain.product.*; +import com.loopers.domain.tracking.UserBehaviorTracker; +import com.loopers.domain.user.UserService; + +/** + * ProductFacade 랭킹 관련 기능 단위 테스트 + * + * @author hyunjikoh + * @since 2025.12.26 + */ +@MockitoSettings(strictness = Strictness.LENIENT) +@ExtendWith(MockitoExtension.class) +class ProductFacadeRankingTest { + + @Mock + private ProductService productService; + + @Mock + private ProductMVService mvService; + + @Mock + private ProductCacheService productCacheService; + + @Mock + private LikeService likeService; + + @Mock + private UserService userService; + + @Mock + private BrandService brandService; + + @Mock + private UserBehaviorTracker behaviorTracker; + + @Mock + private RankingRedisService rankingRedisService; + + @InjectMocks + private ProductFacade productFacade; + + private ProductMaterializedViewEntity createMockMVEntity(Long productId, String name) { + ProductMaterializedViewEntity mv = mock(ProductMaterializedViewEntity.class, RETURNS_DEEP_STUBS); + when(mv.getProductId()).thenReturn(productId); + when(mv.getName()).thenReturn(name); + when(mv.getDescription()).thenReturn("Description for " + name); + when(mv.getLikeCount()).thenReturn(10L); + when(mv.getStockQuantity()).thenReturn(100); + when(mv.getBrandId()).thenReturn(1L); + when(mv.getBrandName()).thenReturn("Test Brand"); + when(mv.getCreatedAt()).thenReturn(java.time.ZonedDateTime.now()); + + when(mv.getPrice().getOriginPrice()).thenReturn(BigDecimal.valueOf(10000)); + when(mv.getPrice().getDiscountPrice()).thenReturn(BigDecimal.valueOf(9000)); + + return mv; + } + + @Nested + @DisplayName("랭킹 상품 목록 조회 테스트") + class GetRankingProductsTest { + + @Test + @DisplayName("랭킹 순서대로 상품 목록을 조회해야 한다") + void shouldReturnProductsInRankingOrder() { + // Given + LocalDate today = LocalDate.now(); + Pageable pageable = PageRequest.of(0, 20); + + List rankings = List.of( + new RankingItem(1, 101L, 100.0), + new RankingItem(2, 102L, 90.0), + new RankingItem(3, 103L, 80.0) + ); + + List mvEntities = List.of( + createMockMVEntity(101L, "Product 101"), + createMockMVEntity(102L, "Product 102"), + createMockMVEntity(103L, "Product 103") + ); + + when(rankingRedisService.getRanking(today, 1, 20)).thenReturn(rankings); + when(mvService.getByIds(List.of(101L, 102L, 103L))).thenReturn(mvEntities); + when(rankingRedisService.getRankingCount(today)).thenReturn(3L); + + // When + Page result = productFacade.getRankingProducts(pageable, today); + + // Then + assertThat(result.getContent()).hasSize(3); + assertThat(result.getContent().get(0).id()).isEqualTo(101L); // 1위 + assertThat(result.getContent().get(1).id()).isEqualTo(102L); // 2위 + assertThat(result.getContent().get(2).id()).isEqualTo(103L); // 3위 + assertThat(result.getTotalElements()).isEqualTo(3); + } + + @Test + @DisplayName("랭킹 데이터가 없으면 빈 페이지를 반환해야 한다") + void shouldReturnEmptyPageWhenNoRankingData() { + // Given + LocalDate today = LocalDate.now(); + Pageable pageable = PageRequest.of(0, 20); + + when(rankingRedisService.getRanking(today, 1, 20)).thenReturn(List.of()); + + // When + Page result = productFacade.getRankingProducts(pageable, today); + + // Then + assertThat(result.getContent()).isEmpty(); + assertThat(result.getTotalElements()).isEqualTo(0); + } + + @Test + @DisplayName("날짜가 null이면 오늘 날짜를 사용해야 한다") + void shouldUseTodayWhenDateIsNull() { + // Given + Pageable pageable = PageRequest.of(0, 20); + LocalDate today = LocalDate.now(); + + when(rankingRedisService.getRanking(eq(today), anyInt(), anyInt())).thenReturn(List.of()); + + // When + productFacade.getRankingProducts(pageable, null); + + // Then + verify(rankingRedisService).getRanking(eq(today), anyInt(), anyInt()); + } + } + + @Nested + @DisplayName("콜드 스타트 Fallback 테스트") + class ColdStartFallbackTest { + + @Test + @DisplayName("오늘 랭킹이 비어있으면 어제 랭킹을 조회해야 한다") + void shouldFallbackToYesterdayWhenTodayIsEmpty() { + // Given + LocalDate today = LocalDate.now(); + LocalDate yesterday = today.minusDays(1); + Pageable pageable = PageRequest.of(0, 20); + + List yesterdayRankings = List.of( + new RankingItem(1, 201L, 50.0), + new RankingItem(2, 202L, 40.0) + ); + + List mvEntities = List.of( + createMockMVEntity(201L, "Product 201"), + createMockMVEntity(202L, "Product 202") + ); + + // 오늘 랭킹은 비어있음 + when(rankingRedisService.getRanking(today, 1, 20)).thenReturn(List.of()); + // 어제 랭킹은 있음 + when(rankingRedisService.getRanking(yesterday, 1, 20)).thenReturn(yesterdayRankings); + when(mvService.getByIds(List.of(201L, 202L))).thenReturn(mvEntities); + when(rankingRedisService.getRankingCount(yesterday)).thenReturn(2L); + + // When - date를 null로 전달 (오늘 날짜 사용) + Page result = productFacade.getRankingProducts(pageable, null); + + // Then - 어제 랭킹이 반환되어야 함 + assertThat(result.getContent()).hasSize(2); + assertThat(result.getContent().get(0).id()).isEqualTo(201L); + assertThat(result.getContent().get(1).id()).isEqualTo(202L); + } + + @Test + @DisplayName("명시적 날짜 지정 시 Fallback하지 않아야 한다") + void shouldNotFallbackWhenDateIsExplicitlySpecified() { + // Given + LocalDate specificDate = LocalDate.now().minusDays(5); + Pageable pageable = PageRequest.of(0, 20); + + when(rankingRedisService.getRanking(specificDate, 1, 20)).thenReturn(List.of()); + + // When - 명시적으로 날짜 지정 + Page result = productFacade.getRankingProducts(pageable, specificDate); + + // Then - Fallback 없이 빈 결과 반환 + assertThat(result.getContent()).isEmpty(); + // 어제 랭킹 조회 안 함 + verify(rankingRedisService, never()).getRanking(eq(specificDate.minusDays(1)), anyInt(), anyInt()); + } + + @Test + @DisplayName("오늘과 어제 모두 비어있으면 빈 페이지를 반환해야 한다") + void shouldReturnEmptyWhenBothTodayAndYesterdayAreEmpty() { + // Given + LocalDate today = LocalDate.now(); + LocalDate yesterday = today.minusDays(1); + Pageable pageable = PageRequest.of(0, 20); + + when(rankingRedisService.getRanking(today, 1, 20)).thenReturn(List.of()); + when(rankingRedisService.getRanking(yesterday, 1, 20)).thenReturn(List.of()); + + // When + Page result = productFacade.getRankingProducts(pageable, null); + + // Then + assertThat(result.getContent()).isEmpty(); + } + } + + @Nested + @DisplayName("상품 상세 조회 시 랭킹 정보 포함 테스트") + class ProductDetailWithRankingTest { + + @Test + @DisplayName("상품 상세 조회 시 랭킹 정보가 포함되어야 한다") + void shouldIncludeRankingInProductDetail() { + // Given + Long productId = 301L; + LocalDate today = LocalDate.now(); + + ProductMaterializedViewEntity mvEntity = createMockMVEntity(productId, "Ranked Product"); + RankingItem ranking = new RankingItem(5, productId, 75.0); + + when(productCacheService.getProductDetailFromCache(productId)).thenReturn(Optional.empty()); + when(mvService.getById(productId)).thenReturn(mvEntity); + when(rankingRedisService.getProductRanking(today, productId)).thenReturn(ranking); + + // When + ProductDetailInfo result = productFacade.getProductDetail(productId, null); + + // Then + assertThat(result).isNotNull(); + assertThat(result.id()).isEqualTo(productId); + assertThat(result.ranking()).isNotNull(); + assertThat(result.ranking().rank()).isEqualTo(5); + assertThat(result.ranking().score()).isEqualTo(75.0); + } + + @Test + @DisplayName("랭킹에 없는 상품은 ranking이 null이어야 한다") + void shouldHaveNullRankingForUnrankedProduct() { + // Given + Long productId = 302L; + LocalDate today = LocalDate.now(); + + ProductMaterializedViewEntity mvEntity = createMockMVEntity(productId, "Unranked Product"); + + when(productCacheService.getProductDetailFromCache(productId)).thenReturn(Optional.empty()); + when(mvService.getById(productId)).thenReturn(mvEntity); + when(rankingRedisService.getProductRanking(today, productId)).thenReturn(null); + + // When + ProductDetailInfo result = productFacade.getProductDetail(productId, null); + + // Then + assertThat(result).isNotNull(); + assertThat(result.id()).isEqualTo(productId); + assertThat(result.ranking()).isNull(); + } + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/event/EventProcessingFacade.java b/apps/commerce-streamer/src/main/java/com/loopers/application/event/EventProcessingFacade.java index 4805f4f5f..501d5a8c9 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/application/event/EventProcessingFacade.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/event/EventProcessingFacade.java @@ -5,7 +5,9 @@ import org.springframework.stereotype.Service; -import com.loopers.application.metrics.MetricsApplicationService; +import com.loopers.application.event.dto.EventProcessingResult.CatalogEventResult; +import com.loopers.application.event.dto.EventProcessingResult.OrderEventResult; +import com.loopers.application.metrics.MetricsService; import com.loopers.cache.dto.CachePayloads.RankingScore; import com.loopers.domain.ranking.RankingService; import com.loopers.infrastructure.event.DomainEventEnvelope; @@ -19,9 +21,22 @@ import lombok.extern.slf4j.Slf4j; /** - * 이벤트 처리 파사드 + * 이벤트 처리 Application Facade *

- * 여러 도메인 서비스(메트릭, 랭킹 등)를 조합하여 이벤트를 처리하는 응용 계층 서비스 + * Kafka Consumer로부터 받은 이벤트를 처리하는 Application 계층 Facade입니다. + * 여러 도메인 서비스를 조합하여 이벤트를 처리합니다. + *

+ * 책임: + * - 이벤트 역직렬화 및 유효성 검증 + * - 과거 이벤트 필터링 + * - 멱등성 체크 위임 + * - 메트릭 처리 위임 + * - 랭킹 점수 생성 위임 + *

+ * 의존관계: + * - MetricsFacade (Application) - 메트릭 처리 + * - RankingService (Domain) - 랭킹 점수 생성 + * - EventDeserializer (Infrastructure) - 이벤트 역직렬화 * * @author hyunjikoh * @since 2025.12.23 @@ -31,41 +46,17 @@ @Slf4j public class EventProcessingFacade { - private final MetricsApplicationService metricsService; + // Application Layer 의존성 + private final MetricsService metricsService; + + // Domain Layer 의존성 private final RankingService rankingService; + + // Infrastructure Layer 의존성 private final EventDeserializer eventDeserializer; - /** - * 카탈로그 이벤트 처리 결과 - */ - public record CatalogEventResult( - boolean processed, - RankingScore rankingScore - ) { - public static CatalogEventResult notProcessed() { - return new CatalogEventResult(false, null); - } - - public static CatalogEventResult processed(RankingScore rankingScore) { - return new CatalogEventResult(true, rankingScore); - } - } - - /** - * 주문 이벤트 처리 결과 - */ - public record OrderEventResult( - boolean processed, - RankingScore rankingScore - ) { - public static OrderEventResult notProcessed() { - return new OrderEventResult(false, null); - } - - public static OrderEventResult processed(RankingScore rankingScore) { - return new OrderEventResult(true, rankingScore); - } - } + // 설정값 + private static final long OLD_EVENT_THRESHOLD_MS = 60 * 60 * 1000; // 1시간 /** * 카탈로그 이벤트 처리 (조회, 좋아요, 재고 소진) @@ -75,12 +66,13 @@ public static OrderEventResult processed(RankingScore rankingScore) { */ public CatalogEventResult processCatalogEvent(Object eventValue) { final DomainEventEnvelope envelope = eventDeserializer.deserializeEnvelope(eventValue); - if (envelope == null || envelope.eventId() == null) { + + if (!isValidEnvelope(envelope)) { log.warn("Invalid event envelope: {}", eventValue); return CatalogEventResult.notProcessed(); } - // 과거 이벤트 필터링 (1시간 이상 된 이벤트는 무시) + // 과거 이벤트 필터링 if (isOldEvent(envelope.occurredAtEpochMillis())) { log.debug("Ignoring old event: eventId={}, occurredAt={}", envelope.eventId(), envelope.occurredAtEpochMillis()); @@ -88,9 +80,8 @@ public CatalogEventResult processCatalogEvent(Object eventValue) { return CatalogEventResult.notProcessed(); } - // 멱등성 체크 - 이미 처리된 이벤트는 무시 - final boolean isFirstTime = metricsService.tryMarkHandled(envelope.eventId()); - if (!isFirstTime) { + // 멱등성 체크 + if (!metricsService.tryMarkHandled(envelope.eventId())) { log.debug("Event already processed: {}", envelope.eventId()); return CatalogEventResult.notProcessed(); } @@ -115,7 +106,8 @@ public CatalogEventResult processCatalogEvent(Object eventValue) { */ public OrderEventResult processOrderEvent(Object eventValue) { final DomainEventEnvelope envelope = eventDeserializer.deserializeEnvelope(eventValue); - if (envelope == null || envelope.eventId() == null) { + + if (!isValidEnvelope(envelope)) { log.warn("Invalid event envelope: {}", eventValue); return OrderEventResult.notProcessed(); } @@ -129,8 +121,7 @@ public OrderEventResult processOrderEvent(Object eventValue) { } // 멱등성 체크 - final boolean isFirstTime = metricsService.tryMarkHandled(envelope.eventId()); - if (!isFirstTime) { + if (!metricsService.tryMarkHandled(envelope.eventId())) { log.debug("Event already processed: {}", envelope.eventId()); return OrderEventResult.notProcessed(); } @@ -163,7 +154,7 @@ public void updateRankingScores(List rankingScores, LocalDate targ } } - // ========== Private Methods ========== + // ========== Private Methods - 이벤트 타입별 처리 ========== private CatalogEventResult processProductView(DomainEventEnvelope envelope) { final ProductViewPayloadV1 payload = eventDeserializer.deserializeProductView(envelope.payloadJson()); @@ -172,11 +163,9 @@ private CatalogEventResult processProductView(DomainEventEnvelope envelope) { return CatalogEventResult.notProcessed(); } - // 메트릭 처리 metricsService.incrementView(payload.productId(), envelope.occurredAtEpochMillis()); log.debug("Processed PRODUCT_VIEW for productId: {}", payload.productId()); - // 랭킹 점수 생성 RankingScore rankingScore = rankingService.generateRankingScore(envelope); return CatalogEventResult.processed(rankingScore); } @@ -188,12 +177,10 @@ private CatalogEventResult processLikeAction(DomainEventEnvelope envelope) { return CatalogEventResult.notProcessed(); } - // 메트릭 처리 final int delta = "LIKE".equals(payload.action()) ? 1 : -1; metricsService.applyLikeDelta(payload.productId(), delta, envelope.occurredAtEpochMillis()); log.debug("Processed LIKE_ACTION for productId: {}, action: {}", payload.productId(), payload.action()); - // 랭킹 점수 생성 (좋아요만 반영) RankingScore rankingScore = rankingService.generateRankingScore(envelope); return CatalogEventResult.processed(rankingScore); } @@ -205,7 +192,6 @@ private CatalogEventResult processStockDepleted(DomainEventEnvelope envelope) { return CatalogEventResult.notProcessed(); } - // 재고 소진 이벤트 처리 metricsService.handleStockDepleted( payload.productId(), payload.brandId(), @@ -216,7 +202,6 @@ private CatalogEventResult processStockDepleted(DomainEventEnvelope envelope) { log.info("Processed STOCK_DEPLETED - productId: {}, brandId: {}, productName: {}, remainingStock: {}", payload.productId(), payload.brandId(), payload.productName(), payload.remainingStock()); - // 재고 소진은 랭킹에 영향 없음 return CatalogEventResult.notProcessed(); } @@ -227,34 +212,29 @@ private OrderEventResult processPaymentSuccess(DomainEventEnvelope envelope) { return OrderEventResult.notProcessed(); } - // 상품별 개별 이벤트 처리 - if (payload.productId() != null && payload.quantity() != null && payload.quantity() > 0) { - // 메트릭 처리 - metricsService.addSales(payload.productId(), payload.quantity(), envelope.occurredAtEpochMillis()); - - log.debug( - "Processed PAYMENT_SUCCESS - orderId: {}, orderNumber: {}, userId: {}, productId: {}, quantity: {}, unitPrice: {}, totalPrice: {}", - payload.orderId(), payload.orderNumber(), payload.userId(), - payload.productId(), payload.quantity(), payload.unitPrice(), payload.totalPrice()); - - // 랭킹 점수 생성 - RankingScore rankingScore = rankingService.generateRankingScore(envelope); - return OrderEventResult.processed(rankingScore); - } else { + if (payload.productId() == null || payload.quantity() == null || payload.quantity() <= 0) { log.warn("Invalid PaymentSuccess payload - missing required fields: productId={}, quantity={}", payload.productId(), payload.quantity()); return OrderEventResult.notProcessed(); } + + metricsService.addSales(payload.productId(), payload.quantity(), envelope.occurredAtEpochMillis()); + + log.debug("Processed PAYMENT_SUCCESS - orderId: {}, productId: {}, quantity: {}, totalPrice: {}", + payload.orderId(), payload.productId(), payload.quantity(), payload.totalPrice()); + + RankingScore rankingScore = rankingService.generateRankingScore(envelope); + return OrderEventResult.processed(rankingScore); } - /** - * 과거 이벤트인지 확인 (1시간 이상 된 이벤트는 과거 이벤트로 간주) - */ - private boolean isOldEvent(long occurredAtEpochMillis) { - long currentTime = System.currentTimeMillis(); - long eventAge = currentTime - occurredAtEpochMillis; - long oneHourInMillis = 60 * 60 * 1000; // 1시간 + // ========== Private Methods - 유틸리티 ========== - return eventAge > oneHourInMillis; + private boolean isValidEnvelope(DomainEventEnvelope envelope) { + return envelope != null && envelope.eventId() != null; + } + + private boolean isOldEvent(long occurredAtEpochMillis) { + long eventAge = System.currentTimeMillis() - occurredAtEpochMillis; + return eventAge > OLD_EVENT_THRESHOLD_MS; } } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/event/dto/EventProcessingResult.java b/apps/commerce-streamer/src/main/java/com/loopers/application/event/dto/EventProcessingResult.java new file mode 100644 index 000000000..869ac58e2 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/event/dto/EventProcessingResult.java @@ -0,0 +1,44 @@ +package com.loopers.application.event.dto; + +import com.loopers.cache.dto.CachePayloads.RankingScore; + +/** + * 이벤트 처리 결과 DTO + * + * @author hyunjikoh + * @since 2025.12.26 + */ +public class EventProcessingResult { + + /** + * 카탈로그 이벤트 처리 결과 + */ + public record CatalogEventResult( + boolean processed, + RankingScore rankingScore + ) { + public static CatalogEventResult notProcessed() { + return new CatalogEventResult(false, null); + } + + public static CatalogEventResult processed(RankingScore rankingScore) { + return new CatalogEventResult(true, rankingScore); + } + } + + /** + * 주문 이벤트 처리 결과 + */ + public record OrderEventResult( + boolean processed, + RankingScore rankingScore + ) { + public static OrderEventResult notProcessed() { + return new OrderEventResult(false, null); + } + + public static OrderEventResult processed(RankingScore rankingScore) { + return new OrderEventResult(true, rankingScore); + } + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsService.java b/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsService.java new file mode 100644 index 000000000..ccae167bd --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsService.java @@ -0,0 +1,207 @@ +package com.loopers.application.metrics; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.ReentrantLock; + +import org.springframework.stereotype.Service; + +import com.loopers.domain.event.EventHandledService; +import com.loopers.domain.metrics.ProductMetricsService; +import com.loopers.infrastructure.cache.ProductCacheService; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * 메트릭 Application Service + *

+ * 메트릭 관련 유스케이스를 조합하는 Application 계층 서비스입니다. + * EventProcessingFacade에서 호출되어 메트릭 처리를 담당합니다. + *

+ * 책임: + * - 멱등성 체크 (메모리 캐시 + Domain Service 위임) + * - 동시성 제어 (상품별 메모리 락) + * - 메트릭 업데이트 조정 (Domain Service 위임) + * - 캐시 무효화 조정 (Infrastructure 위임) + *

+ * 의존관계: + * - ProductMetricsService (Domain) - 메트릭 비즈니스 로직 + * - EventHandledService (Domain) - 멱등성 처리 + * - ProductCacheService (Infrastructure) - 캐시 처리 + * + * @author hyunjikoh + * @since 2025. 12. 26. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class MetricsService { + + // Domain Layer 의존성 + private final ProductMetricsService productMetricsService; + private final EventHandledService eventHandledService; + + // Infrastructure Layer 의존성 + private final ProductCacheService productCacheService; + + // 상품별 메모리 락 관리 + private final ConcurrentHashMap productLocks = new ConcurrentHashMap<>(); + + // 설정값 + private static final long LOCK_WAIT_TIME_MS = 100; + private static final int LOCK_CLEANUP_THRESHOLD = 10000; + + // 메모리 기반 멱등성 캐시 (빠른 경로) + private final ConcurrentHashMap processedEventsCache = new ConcurrentHashMap<>(); + private static final int PROCESSED_EVENTS_CLEANUP_THRESHOLD = 50000; + + // ========== 멱등성 체크 ========== + + /** + * 이벤트 처리 여부 확인 및 마킹 + * + * @param eventId 이벤트 ID + * @return true: 처음 처리, false: 이미 처리됨 + */ + public boolean tryMarkHandled(String eventId) { + // 1. 메모리 캐시 먼저 확인 (빠른 경로) + if (processedEventsCache.containsKey(eventId)) { + log.debug("이미 처리된 이벤트 (메모리 캐시): {}", eventId); + return false; + } + + // 2. Domain Service를 통해 DB 확인 + if (eventHandledService.isAlreadyHandled(eventId)) { + processedEventsCache.put(eventId, true); + log.debug("이미 처리된 이벤트 (DB 확인): {}", eventId); + return false; + } + + // 3. Domain Service를 통해 새로운 이벤트 저장 + boolean saved = eventHandledService.markAsHandled(eventId); + if (saved) { + processedEventsCache.put(eventId, true); + return true; + } else { + processedEventsCache.put(eventId, true); + log.debug("동시성으로 인한 중복 이벤트: {}", eventId); + return false; + } + } + + // ========== 메트릭 업데이트 ========== + + /** + * 조회수 증가 + */ + public void incrementView(Long productId, long occurredAtEpochMillis) { + executeWithLock(productId, () -> { + ZonedDateTime eventTime = convertToZonedDateTime(occurredAtEpochMillis); + productMetricsService.incrementView(productId, eventTime); + log.debug("조회수 업데이트 성공: productId={}", productId); + }); + } + + /** + * 좋아요 수 변경 + */ + public void applyLikeDelta(Long productId, int delta, long occurredAtEpochMillis) { + executeWithLock(productId, () -> { + ZonedDateTime eventTime = convertToZonedDateTime(occurredAtEpochMillis); + productMetricsService.applyLikeDelta(productId, delta, eventTime); + log.debug("좋아요 수 업데이트 성공: productId={}, delta={}", productId, delta); + }); + } + + /** + * 판매량 증가 + */ + public void addSales(Long productId, int quantity, long occurredAtEpochMillis) { + executeWithLock(productId, () -> { + ZonedDateTime eventTime = convertToZonedDateTime(occurredAtEpochMillis); + boolean updated = productMetricsService.addSales(productId, quantity, eventTime); + + if (updated) { + // 캐시 무효화 (판매량 변경 - 인기 상품 순위 영향) + productCacheService.onSalesCountChanged(productId); + log.debug("판매량 업데이트 성공: productId={}, quantity={}", productId, quantity); + } + }); + } + + /** + * 재고 소진 이벤트 처리 + */ + public void handleStockDepleted(Long productId, Long brandId, Integer remainingStock, long occurredAtEpochMillis) { + int stockToUpdate = (remainingStock != null) ? remainingStock : 0; + productCacheService.updateProductStock(productId, stockToUpdate); + log.info("재고 소진 캐시 갱신 완료: productId={}, brandId={}, remainingStock={}", + productId, brandId, stockToUpdate); + } + + // ========== Helper Methods ========== + + private void executeWithLock(Long productId, Runnable action) { + ReentrantLock lock = productLocks.computeIfAbsent(productId, k -> new ReentrantLock()); + + try { + if (lock.tryLock(LOCK_WAIT_TIME_MS, TimeUnit.MILLISECONDS)) { + try { + action.run(); + } finally { + lock.unlock(); + } + } else { + log.warn("메트릭 업데이트 스킵 - 락 획득 실패: productId={}", productId); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.warn("메트릭 업데이트 중단 - 스레드 인터럽트: productId={}", productId); + } + } + + private ZonedDateTime convertToZonedDateTime(long epochMillis) { + return ZonedDateTime.ofInstant(Instant.ofEpochMilli(epochMillis), ZoneId.systemDefault()); + } + + // ========== 모니터링 및 정리 ========== + + public void cleanupUnusedLocks() { + if (productLocks.size() > LOCK_CLEANUP_THRESHOLD) { + log.info("락 정리 시작 - 현재 락 수: {}", productLocks.size()); + productLocks.entrySet().removeIf(entry -> { + ReentrantLock lock = entry.getValue(); + return !lock.isLocked() && !lock.hasQueuedThreads(); + }); + log.info("락 정리 완료 - 정리 후 락 수: {}", productLocks.size()); + } + } + + public void cleanupProcessedEvents() { + if (processedEventsCache.size() > PROCESSED_EVENTS_CLEANUP_THRESHOLD) { + log.info("처리된 이벤트 캐시 정리 시작 - 현재 캐시 수: {}", processedEventsCache.size()); + int targetSize = PROCESSED_EVENTS_CLEANUP_THRESHOLD / 2; + int toRemove = processedEventsCache.size() - targetSize; + processedEventsCache.entrySet().stream() + .limit(toRemove) + .map(Map.Entry::getKey) + .forEach(processedEventsCache::remove); + log.info("처리된 이벤트 캐시 정리 완료 - 정리 후 캐시 수: {}", processedEventsCache.size()); + } + } + + public void logLockStatus() { + int totalLocks = productLocks.size(); + long lockedCount = productLocks.values().stream() + .filter(ReentrantLock::isLocked) + .count(); + if (totalLocks > 0) { + log.debug("메트릭 락 상태 - 총 락: {}, 사용 중: {}", totalLocks, lockedCount); + } + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledService.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledService.java new file mode 100644 index 000000000..3f53ab6d2 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledService.java @@ -0,0 +1,56 @@ +package com.loopers.domain.event; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * 이벤트 처리 완료 Domain Service + *

+ * 이벤트 멱등성 처리를 담당하는 Domain 계층 서비스입니다. + * 이벤트 ID 기반으로 중복 처리를 방지합니다. + * + * @author hyunjikoh + * @since 2025. 12. 26. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class EventHandledService { + + private final EventRepository eventRepository; + + /** + * 이벤트가 이미 처리되었는지 확인 + */ + @Transactional(readOnly = true) + public boolean isAlreadyHandled(String eventId) { + return eventRepository.existsById(eventId); + } + + /** + * 이벤트 처리 완료 마킹 + * + * @return true: 저장 성공 (처음 처리), false: 저장 실패 (이미 처리됨 또는 동시성 충돌) + */ + @Transactional + public boolean markAsHandled(String eventId) { + try { + // 트랜잭션 내에서 다시 한번 확인 (동시성 안전) + if (eventRepository.existsById(eventId)) { + log.debug("트랜잭션 내 중복 확인: {}", eventId); + return false; + } + + eventRepository.save(EventEntity.create(eventId)); + log.debug("이벤트 처리 완료 저장: {}", eventId); + return true; + } catch (Exception e) { + // 동시성으로 인한 중복 저장 시도 (Unique 제약 조건 위반 등) + log.debug("동시성으로 인한 이벤트 저장 실패: {}", eventId, e); + return false; + } + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java new file mode 100644 index 000000000..befdcf1d1 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java @@ -0,0 +1,93 @@ +package com.loopers.domain.metrics; + +import java.time.ZonedDateTime; +import java.util.Optional; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * 상품 메트릭 Domain Service + *

+ * 상품 메트릭 관련 비즈니스 로직을 담당하는 Domain 계층 서비스입니다. + * 트랜잭션 경계를 관리하고 Repository를 통해 데이터를 조작합니다. + * + * @author hyunjikoh + * @since 2025. 12. 26. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class ProductMetricsService { + + private final ProductMetricsRepository productMetricsRepository; + + /** + * 조회수 증가 + */ + @Transactional + public void incrementView(Long productId, ZonedDateTime eventTime) { + ProductMetricsEntity metrics = getOrCreateMetrics(productId); + metrics.incrementView(eventTime); + productMetricsRepository.save(metrics); + log.debug("조회수 증가 완료: productId={}", productId); + } + + /** + * 좋아요 수 변경 + * + * @return true: 변경됨, false: 변경 안 됨 (새 상품에 대한 좋아요 감소) + */ + @Transactional + public boolean applyLikeDelta(Long productId, int delta, ZonedDateTime eventTime) { + Optional existing = productMetricsRepository.findById(productId); + + if (existing.isPresent()) { + ProductMetricsEntity metrics = existing.get(); + metrics.applyLikeDelta(delta, eventTime); + productMetricsRepository.save(metrics); + log.debug("좋아요 수 변경 완료: productId={}, delta={}", productId, delta); + return true; + } else if (delta > 0) { + // 새로운 상품에 대한 좋아요 추가만 허용 + ProductMetricsEntity newMetrics = ProductMetricsEntity.create(productId); + newMetrics.applyLikeDelta(delta, eventTime); + productMetricsRepository.save(newMetrics); + log.debug("새 상품 좋아요 추가 완료: productId={}, delta={}", productId, delta); + return true; + } else { + log.debug("새로운 상품에 대한 좋아요 감소 무시: productId={}, delta={}", productId, delta); + return false; + } + } + + /** + * 판매량 증가 + * + * @return true: 증가됨, false: 증가 안 됨 (잘못된 수량) + */ + @Transactional + public boolean addSales(Long productId, int quantity, ZonedDateTime eventTime) { + if (quantity <= 0) { + log.debug("잘못된 판매량 무시: productId={}, quantity={}", productId, quantity); + return false; + } + + ProductMetricsEntity metrics = getOrCreateMetrics(productId); + metrics.addSales(quantity, eventTime); + productMetricsRepository.save(metrics); + log.debug("판매량 증가 완료: productId={}, quantity={}", productId, quantity); + return true; + } + + /** + * 상품 메트릭 조회 또는 생성 + */ + private ProductMetricsEntity getOrCreateMetrics(Long productId) { + return productMetricsRepository.findById(productId) + .orElseGet(() -> ProductMetricsEntity.create(productId)); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/MetricsLockCleanupScheduler.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/MetricsLockCleanupScheduler.java index 2be23f0bb..f6eeb8476 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/MetricsLockCleanupScheduler.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/MetricsLockCleanupScheduler.java @@ -3,7 +3,7 @@ import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; -import com.loopers.application.metrics.MetricsApplicationService; +import com.loopers.application.metrics.MetricsService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -21,7 +21,7 @@ @Slf4j public class MetricsLockCleanupScheduler { - private final MetricsApplicationService metricsService; + private final MetricsService metricsService; /** * 사용하지 않는 락 정리 (5분마다) diff --git a/apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingServiceTest.java b/apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingServiceTest.java new file mode 100644 index 000000000..1ba974843 --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingServiceTest.java @@ -0,0 +1,296 @@ +package com.loopers.domain.ranking; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +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.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.cache.RankingRedisService; +import com.loopers.cache.dto.CachePayloads.RankingItem; +import com.loopers.cache.dto.CachePayloads.RankingScore; +import com.loopers.infrastructure.event.DomainEventEnvelope; +import com.loopers.infrastructure.event.EventDeserializer; +import com.loopers.infrastructure.event.payloads.LikeActionPayloadV1; +import com.loopers.infrastructure.event.payloads.PaymentSuccessPayloadV1; +import com.loopers.infrastructure.event.payloads.ProductViewPayloadV1; + +/** + * 랭킹 서비스 단위 테스트 + * + * @author hyunjikoh + * @since 2025.12.26 + */ +@ExtendWith(MockitoExtension.class) +class RankingServiceTest { + + @Mock + private RankingRedisService rankingRedisService; + + @Mock + private EventDeserializer eventDeserializer; + + @InjectMocks + private RankingService rankingService; + + private ObjectMapper objectMapper = new ObjectMapper(); + + @Nested + @DisplayName("랭킹 점수 생성 테스트") + class GenerateRankingScoreTest { + + @Test + @DisplayName("PRODUCT_VIEW 이벤트에서 랭킹 점수를 생성해야 한다") + void shouldGenerateScoreForProductView() throws Exception { + // Given + Long productId = 1L; + long occurredAt = System.currentTimeMillis(); + + ProductViewPayloadV1 payload = new ProductViewPayloadV1(productId, 100L); + String payloadJson = objectMapper.writeValueAsString(payload); + + DomainEventEnvelope envelope = new DomainEventEnvelope( + "event-1", "PRODUCT_VIEW", "v1", occurredAt, payloadJson + ); + + when(eventDeserializer.deserializeProductView(payloadJson)).thenReturn(payload); + + // When + RankingScore score = rankingService.generateRankingScore(envelope); + + // Then + assertThat(score).isNotNull(); + assertThat(score.productId()).isEqualTo(productId); + assertThat(score.eventType()).isEqualTo(RankingScore.EventType.PRODUCT_VIEW); + assertThat(score.score()).isEqualTo(1.0); + assertThat(score.getWeightedScore()).isEqualTo(0.1); // 0.1 * 1.0 + } + + @Test + @DisplayName("LIKE_ACTION 이벤트(좋아요)에서 랭킹 점수를 생성해야 한다") + void shouldGenerateScoreForLikeAction() throws Exception { + // Given + Long productId = 2L; + long occurredAt = System.currentTimeMillis(); + + LikeActionPayloadV1 payload = new LikeActionPayloadV1(productId, 100L, "LIKE"); + String payloadJson = objectMapper.writeValueAsString(payload); + + DomainEventEnvelope envelope = new DomainEventEnvelope( + "event-2", "LIKE_ACTION", "v1", occurredAt, payloadJson + ); + + when(eventDeserializer.deserializeLikeAction(payloadJson)).thenReturn(payload); + + // When + RankingScore score = rankingService.generateRankingScore(envelope); + + // Then + assertThat(score).isNotNull(); + assertThat(score.productId()).isEqualTo(productId); + assertThat(score.eventType()).isEqualTo(RankingScore.EventType.LIKE_ACTION); + assertThat(score.getWeightedScore()).isEqualTo(0.2); // 0.2 * 1.0 + } + + @Test + @DisplayName("LIKE_ACTION 이벤트(좋아요 취소)는 랭킹 점수를 생성하지 않아야 한다") + void shouldNotGenerateScoreForUnlike() throws Exception { + // Given + Long productId = 2L; + long occurredAt = System.currentTimeMillis(); + + LikeActionPayloadV1 payload = new LikeActionPayloadV1(productId, 100L, "UNLIKE"); + String payloadJson = objectMapper.writeValueAsString(payload); + + DomainEventEnvelope envelope = new DomainEventEnvelope( + "event-3", "LIKE_ACTION", "v1", occurredAt, payloadJson + ); + + when(eventDeserializer.deserializeLikeAction(payloadJson)).thenReturn(payload); + + // When + RankingScore score = rankingService.generateRankingScore(envelope); + + // Then + assertThat(score).isNull(); + } + + @Test + @DisplayName("PAYMENT_SUCCESS 이벤트에서 로그 정규화된 랭킹 점수를 생성해야 한다") + void shouldGenerateLogNormalizedScoreForPaymentSuccess() throws Exception { + // Given + Long productId = 3L; + long occurredAt = System.currentTimeMillis(); + BigDecimal totalPrice = BigDecimal.valueOf(10000); + + PaymentSuccessPayloadV1 payload = new PaymentSuccessPayloadV1( + 1L, 1L, 100L, productId, 2, BigDecimal.valueOf(5000), totalPrice + ); + String payloadJson = objectMapper.writeValueAsString(payload); + + DomainEventEnvelope envelope = new DomainEventEnvelope( + "event-4", "PAYMENT_SUCCESS", "v1", occurredAt, payloadJson + ); + + when(eventDeserializer.deserializePaymentSuccess(payloadJson)).thenReturn(payload); + + // When + RankingScore score = rankingService.generateRankingScore(envelope); + + // Then + assertThat(score).isNotNull(); + assertThat(score.productId()).isEqualTo(productId); + assertThat(score.eventType()).isEqualTo(RankingScore.EventType.PAYMENT_SUCCESS); + + // 로그 정규화 확인: log(10000 + 1) ≈ 9.21 + double expectedScore = Math.log(10001); + assertThat(score.score()).isCloseTo(expectedScore, org.assertj.core.data.Offset.offset(0.01)); + + // 가중치 적용: 0.6 * log(10001) ≈ 5.53 + assertThat(score.getWeightedScore()).isCloseTo(0.6 * expectedScore, org.assertj.core.data.Offset.offset(0.01)); + } + + @Test + @DisplayName("지원하지 않는 이벤트 타입은 null을 반환해야 한다") + void shouldReturnNullForUnsupportedEventType() { + // Given + DomainEventEnvelope envelope = new DomainEventEnvelope( + "event-5", "UNKNOWN_EVENT", "v1", System.currentTimeMillis(), "{}" + ); + + // When + RankingScore score = rankingService.generateRankingScore(envelope); + + // Then + assertThat(score).isNull(); + } + } + + @Nested + @DisplayName("랭킹 점수 배치 업데이트 테스트") + class UpdateRankingScoresBatchTest { + + @Test + @DisplayName("랭킹 점수 리스트를 배치로 업데이트해야 한다") + void shouldUpdateRankingScoresInBatch() { + // Given + LocalDate today = LocalDate.now(); + List scores = List.of( + RankingScore.forProductView(1L, System.currentTimeMillis()), + RankingScore.forLikeAction(2L, System.currentTimeMillis()), + RankingScore.forPaymentSuccess(3L, BigDecimal.valueOf(5000), System.currentTimeMillis()) + ); + + // When + rankingService.updateRankingScoresBatch(scores, today); + + // Then + verify(rankingRedisService).updateRankingScoresBatch(scores, today); + } + + @Test + @DisplayName("빈 리스트는 업데이트하지 않아야 한다") + void shouldNotUpdateEmptyList() { + // Given + List emptyScores = List.of(); + + // When + rankingService.updateRankingScoresBatch(emptyScores, LocalDate.now()); + + // Then + verify(rankingRedisService, never()).updateRankingScoresBatch(any(), any()); + } + + @Test + @DisplayName("날짜가 null이면 각 점수의 발생 날짜 기준으로 업데이트해야 한다") + void shouldUseEventDateWhenTargetDateIsNull() { + // Given + List scores = List.of( + RankingScore.forProductView(1L, System.currentTimeMillis()) + ); + + // When + rankingService.updateRankingScoresBatch(scores, null); + + // Then - targetDate가 null이면 날짜 파라미터 없이 호출 + verify(rankingRedisService).updateRankingScoresBatch(scores); + } + } + + @Nested + @DisplayName("랭킹 조회 테스트") + class GetRankingTest { + + @Test + @DisplayName("페이징된 랭킹을 조회해야 한다") + void shouldGetPaginatedRanking() { + // Given + LocalDate today = LocalDate.now(); + List expectedRankings = List.of( + new RankingItem(1, 101L, 100.0), + new RankingItem(2, 102L, 90.0), + new RankingItem(3, 103L, 80.0) + ); + + when(rankingRedisService.getRanking(today, 1, 20)).thenReturn(expectedRankings); + + // When + List result = rankingService.getRanking(today, 1, 20); + + // Then + assertThat(result).hasSize(3); + assertThat(result.get(0).rank()).isEqualTo(1); + assertThat(result.get(0).productId()).isEqualTo(101L); + } + + @Test + @DisplayName("특정 상품의 랭킹을 조회해야 한다") + void shouldGetProductRanking() { + // Given + LocalDate today = LocalDate.now(); + Long productId = 101L; + RankingItem expectedRanking = new RankingItem(5, productId, 75.0); + + when(rankingRedisService.getProductRanking(today, productId)).thenReturn(expectedRanking); + + // When + RankingItem result = rankingService.getProductRanking(productId, today); + + // Then + assertThat(result).isNotNull(); + assertThat(result.rank()).isEqualTo(5); + assertThat(result.productId()).isEqualTo(productId); + assertThat(result.score()).isEqualTo(75.0); + } + + @Test + @DisplayName("랭킹에 없는 상품은 null을 반환해야 한다") + void shouldReturnNullForUnrankedProduct() { + // Given + LocalDate today = LocalDate.now(); + Long productId = 999L; + + when(rankingRedisService.getProductRanking(today, productId)).thenReturn(null); + + // When + RankingItem result = rankingService.getProductRanking(productId, today); + + // Then + assertThat(result).isNull(); + } + } +} diff --git a/apps/commerce-streamer/src/test/java/com/loopers/integration/RankingIntegrationTest.java b/apps/commerce-streamer/src/test/java/com/loopers/integration/RankingIntegrationTest.java new file mode 100644 index 000000000..a3bd9084c --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/integration/RankingIntegrationTest.java @@ -0,0 +1,303 @@ +package com.loopers.integration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.data.Offset.offset; +import static org.awaitility.Awaitility.await; + +import java.math.BigDecimal; +import java.time.Duration; +import java.time.LocalDate; +import java.util.List; + +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.data.redis.core.RedisTemplate; +import org.springframework.kafka.core.KafkaTemplate; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.cache.CacheKeyGenerator; +import com.loopers.cache.RankingRedisService; +import com.loopers.cache.dto.CachePayloads.RankingItem; +import com.loopers.config.redis.RedisConfig; +import com.loopers.infrastructure.event.DomainEventEnvelope; +import com.loopers.infrastructure.event.payloads.LikeActionPayloadV1; +import com.loopers.infrastructure.event.payloads.PaymentSuccessPayloadV1; +import com.loopers.infrastructure.event.payloads.ProductViewPayloadV1; +import com.loopers.utils.RedisCleanUp; + +/** + * 랭킹 시스템 통합 테스트 + *

+ * Kafka 이벤트 → Redis ZSET 적재 → 랭킹 조회 E2E 테스트 + * + * @author hyunjikoh + * @since 2025.12.26 + */ +@SpringBootTest +class RankingIntegrationTest { + + @Autowired + private KafkaTemplate kafkaTemplate; + + @Autowired + private RankingRedisService rankingRedisService; + + @Autowired + private RedisTemplate redisTemplate; + + @Autowired + private RedisCleanUp redisCleanUp; + + @Autowired + private CacheKeyGenerator cacheKeyGenerator; + + @Autowired + private ObjectMapper objectMapper; + + private LocalDate today; + private String todayRankingKey; + + @BeforeEach + void setUp() { + today = LocalDate.now(); + todayRankingKey = cacheKeyGenerator.generateDailyRankingKey(today); + + redisCleanUp.truncateAll(); + } + + @Nested + @DisplayName("Kafka 이벤트 → Redis ZSET 적재 테스트") + class KafkaToRedisTest { + + @Test + @DisplayName("PRODUCT_VIEW 이벤트가 랭킹 점수로 적재되어야 한다") + void shouldStoreProductViewAsRankingScore() throws Exception { + // Given + Long productId = 1001L; + String eventId = "ranking-view-test-" + System.currentTimeMillis(); + + ProductViewPayloadV1 payload = new ProductViewPayloadV1(productId, 100L); + String payloadJson = objectMapper.writeValueAsString(payload); + + DomainEventEnvelope envelope = new DomainEventEnvelope( + eventId, "PRODUCT_VIEW", "v1", System.currentTimeMillis(), payloadJson + ); + + // When + kafkaTemplate.send("catalog-events", envelope); + + // Then - 랭킹 점수가 적재되어야 함 (Weight 0.1 * Score 1 = 0.1) + await().atMost(Duration.ofSeconds(10)) + .untilAsserted(() -> { + RankingItem ranking = rankingRedisService.getProductRanking(today, productId); + assertThat(ranking).isNotNull(); + assertThat(ranking.productId()).isEqualTo(productId); + assertThat(ranking.score()).isCloseTo(0.1, offset(0.01)); + }); + } + + @Test + @DisplayName("LIKE_ACTION 이벤트가 랭킹 점수로 적재되어야 한다") + void shouldStoreLikeActionAsRankingScore() throws Exception { + // Given + Long productId = 1002L; + String eventId = "ranking-like-test-" + System.currentTimeMillis(); + + LikeActionPayloadV1 payload = new LikeActionPayloadV1(productId, 100L, "LIKE"); + String payloadJson = objectMapper.writeValueAsString(payload); + + DomainEventEnvelope envelope = new DomainEventEnvelope( + eventId, "LIKE_ACTION", "v1", System.currentTimeMillis(), payloadJson + ); + + // When + kafkaTemplate.send("catalog-events", envelope); + + // Then - 랭킹 점수가 적재되어야 함 (Weight 0.2 * Score 1 = 0.2) + await().atMost(Duration.ofSeconds(10)) + .untilAsserted(() -> { + RankingItem ranking = rankingRedisService.getProductRanking(today, productId); + assertThat(ranking).isNotNull(); + assertThat(ranking.score()).isCloseTo(0.2, offset(0.01)); + }); + } + + @Test + @DisplayName("PAYMENT_SUCCESS 이벤트가 로그 정규화된 랭킹 점수로 적재되어야 한다") + void shouldStorePaymentSuccessAsLogNormalizedScore() throws Exception { + // Given + Long productId = 1003L; + String eventId = "ranking-payment-test-" + System.currentTimeMillis(); + BigDecimal totalPrice = BigDecimal.valueOf(10000); + + PaymentSuccessPayloadV1 payload = new PaymentSuccessPayloadV1( + 1L, 1L, 100L, productId, 2, BigDecimal.valueOf(5000), totalPrice + ); + String payloadJson = objectMapper.writeValueAsString(payload); + + DomainEventEnvelope envelope = new DomainEventEnvelope( + eventId, "PAYMENT_SUCCESS", "v1", System.currentTimeMillis(), payloadJson + ); + + // When + kafkaTemplate.send("order-events", envelope); + + // Then - 로그 정규화된 점수가 적재되어야 함 + // Weight 0.6 * log(10001) ≈ 5.53 + double expectedScore = 0.6 * Math.log(10001); + + await().atMost(Duration.ofSeconds(10)) + .untilAsserted(() -> { + RankingItem ranking = rankingRedisService.getProductRanking(today, productId); + assertThat(ranking).isNotNull(); + assertThat(ranking.score()).isCloseTo(expectedScore, offset(0.1)); + }); + } + + @Test + @DisplayName("여러 이벤트가 동일 상품에 대해 점수가 누적되어야 한다") + void shouldAccumulateScoresForSameProduct() throws Exception { + // Given + Long productId = 1004L; + long baseTime = System.currentTimeMillis(); + + // 조회 3회 + 좋아요 2회 = 0.1*3 + 0.2*2 = 0.7 + for (int i = 0; i < 3; i++) { + ProductViewPayloadV1 viewPayload = new ProductViewPayloadV1(productId, 100L); + DomainEventEnvelope viewEnvelope = new DomainEventEnvelope( + "view-" + productId + "-" + i + "-" + baseTime, + "PRODUCT_VIEW", "v1", baseTime + i, + objectMapper.writeValueAsString(viewPayload) + ); + kafkaTemplate.send("catalog-events", viewEnvelope); + } + + for (int i = 0; i < 2; i++) { + LikeActionPayloadV1 likePayload = new LikeActionPayloadV1(productId, (long)(100 + i), "LIKE"); + DomainEventEnvelope likeEnvelope = new DomainEventEnvelope( + "like-" + productId + "-" + i + "-" + baseTime, + "LIKE_ACTION", "v1", baseTime + 10 + i, + objectMapper.writeValueAsString(likePayload) + ); + kafkaTemplate.send("catalog-events", likeEnvelope); + } + + // Then - 점수가 누적되어야 함 + double expectedScore = 0.1 * 3 + 0.2 * 2; // 0.7 + + await().atMost(Duration.ofSeconds(15)) + .untilAsserted(() -> { + RankingItem ranking = rankingRedisService.getProductRanking(today, productId); + assertThat(ranking).isNotNull(); + assertThat(ranking.score()).isCloseTo(expectedScore, offset(0.1)); + }); + } + } + + @Nested + @DisplayName("랭킹 조회 테스트") + class RankingQueryTest { + + @Test + @DisplayName("랭킹 순서대로 조회되어야 한다") + void shouldReturnRankingsInOrder() throws Exception { + // Given - 점수가 다른 3개 상품 등록 + Long product1 = 2001L; // 높은 점수 + Long product2 = 2002L; // 중간 점수 + Long product3 = 2003L; // 낮은 점수 + + // product1: 결제 (높은 점수) + PaymentSuccessPayloadV1 paymentPayload = new PaymentSuccessPayloadV1( + 1L, 1L, 100L, product1, 1, BigDecimal.valueOf(50000), BigDecimal.valueOf(50000) + ); + kafkaTemplate.send("order-events", new DomainEventEnvelope( + "order-" + product1 + "-" + System.currentTimeMillis(), + "PAYMENT_SUCCESS", "v1", System.currentTimeMillis(), + objectMapper.writeValueAsString(paymentPayload) + )); + + // product2: 좋아요 (중간 점수) + LikeActionPayloadV1 likePayload = new LikeActionPayloadV1(product2, 100L, "LIKE"); + kafkaTemplate.send("catalog-events", new DomainEventEnvelope( + "like-" + product2 + "-" + System.currentTimeMillis(), + "LIKE_ACTION", "v1", System.currentTimeMillis(), + objectMapper.writeValueAsString(likePayload) + )); + + // product3: 조회 (낮은 점수) + ProductViewPayloadV1 viewPayload = new ProductViewPayloadV1(product3, 100L); + kafkaTemplate.send("catalog-events", new DomainEventEnvelope( + "view-" + product3 + "-" + System.currentTimeMillis(), + "PRODUCT_VIEW", "v1", System.currentTimeMillis(), + objectMapper.writeValueAsString(viewPayload) + )); + + // Then - 점수 높은 순으로 정렬되어야 함 + await().atMost(Duration.ofSeconds(15)) + .untilAsserted(() -> { + List rankings = rankingRedisService.getRanking(today, 1, 10); + assertThat(rankings).hasSizeGreaterThanOrEqualTo(3); + + // 첫 번째가 가장 높은 점수 (결제) + assertThat(rankings.get(0).productId()).isEqualTo(product1); + // 두 번째가 중간 점수 (좋아요) + assertThat(rankings.get(1).productId()).isEqualTo(product2); + // 세 번째가 낮은 점수 (조회) + assertThat(rankings.get(2).productId()).isEqualTo(product3); + }); + } + } + + @Nested + @DisplayName("Score Carry-Over 테스트") + class CarryOverTest { + + @Test + @DisplayName("전날 점수의 일부가 다음 날로 이월되어야 한다") + void shouldCarryOverScoresToNextDay() { + // Given - 오늘 랭킹 데이터 직접 추가 + Long productId = 3001L; + double originalScore = 100.0; + + redisTemplate.opsForZSet().add(todayRankingKey, productId.toString(), originalScore); + + LocalDate tomorrow = today.plusDays(1); + String tomorrowKey = cacheKeyGenerator.generateDailyRankingKey(tomorrow); + redisTemplate.delete(tomorrowKey); // 내일 키 정리 + + // When - Carry-Over 실행 (10%) + long carryOverCount = rankingRedisService.carryOverScores(today, tomorrow, 0.1); + + // Then + assertThat(carryOverCount).isEqualTo(1); + + Double tomorrowScore = redisTemplate.opsForZSet().score(tomorrowKey, productId.toString()); + assertThat(tomorrowScore).isNotNull(); + assertThat(tomorrowScore).isCloseTo(10.0, offset(0.01)); // 100 * 0.1 + + // Cleanup + redisTemplate.delete(tomorrowKey); + } + + @Test + @DisplayName("원본 데이터가 없으면 Carry-Over를 스킵해야 한다") + void shouldSkipCarryOverWhenNoSourceData() { + // Given + LocalDate emptyDate = today.minusDays(10); + LocalDate targetDate = emptyDate.plusDays(1); + + String emptyKey = cacheKeyGenerator.generateDailyRankingKey(emptyDate); + redisTemplate.delete(emptyKey); // 확실히 비어있게 + + // When + long carryOverCount = rankingRedisService.carryOverScores(emptyDate, targetDate, 0.1); + + // Then + assertThat(carryOverCount).isEqualTo(0); + } + } +} diff --git a/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/MetricsKafkaConsumerTest.java b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/MetricsKafkaConsumerTest.java index 0082c7102..ece4fb075 100644 --- a/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/MetricsKafkaConsumerTest.java +++ b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/MetricsKafkaConsumerTest.java @@ -15,8 +15,8 @@ import org.springframework.kafka.support.Acknowledgment; import com.loopers.application.event.EventProcessingFacade; -import com.loopers.application.event.EventProcessingFacade.CatalogEventResult; -import com.loopers.application.event.EventProcessingFacade.OrderEventResult; +import com.loopers.application.event.dto.EventProcessingResult.CatalogEventResult; +import com.loopers.application.event.dto.EventProcessingResult.OrderEventResult; import com.loopers.cache.dto.CachePayloads.RankingScore; import com.loopers.infrastructure.event.DomainEventEnvelope; From ef00e31c1ca012b8ab66dda84f21aa277d865b64 Mon Sep 17 00:00:00 2001 From: hyujikoh Date: Fri, 26 Dec 2025 12:23:41 +0900 Subject: [PATCH 13/18] =?UTF-8?q?refactor(metrics):=20MetricsService=20?= =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=9D=B4=EB=A6=84=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EB=B0=8F=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MetricsService를 MetricsApplicationService로 이름 변경 - EventProcessingFacade 및 MetricsLockCleanupScheduler에서 의존성 수정 --- .../event/EventProcessingFacade.java | 20 +++++++++---------- ...ce.java => MetricsApplicationService.java} | 2 +- .../MetricsLockCleanupScheduler.java | 12 +++++------ 3 files changed, 17 insertions(+), 17 deletions(-) rename apps/commerce-streamer/src/main/java/com/loopers/application/metrics/{MetricsService.java => MetricsApplicationService.java} (99%) diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/event/EventProcessingFacade.java b/apps/commerce-streamer/src/main/java/com/loopers/application/event/EventProcessingFacade.java index 501d5a8c9..f6324b9c1 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/application/event/EventProcessingFacade.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/event/EventProcessingFacade.java @@ -7,7 +7,7 @@ import com.loopers.application.event.dto.EventProcessingResult.CatalogEventResult; import com.loopers.application.event.dto.EventProcessingResult.OrderEventResult; -import com.loopers.application.metrics.MetricsService; +import com.loopers.application.metrics.MetricsApplicationService; import com.loopers.cache.dto.CachePayloads.RankingScore; import com.loopers.domain.ranking.RankingService; import com.loopers.infrastructure.event.DomainEventEnvelope; @@ -47,7 +47,7 @@ public class EventProcessingFacade { // Application Layer 의존성 - private final MetricsService metricsService; + private final MetricsApplicationService metricsApplicationService; // Domain Layer 의존성 private final RankingService rankingService; @@ -76,12 +76,12 @@ public CatalogEventResult processCatalogEvent(Object eventValue) { if (isOldEvent(envelope.occurredAtEpochMillis())) { log.debug("Ignoring old event: eventId={}, occurredAt={}", envelope.eventId(), envelope.occurredAtEpochMillis()); - metricsService.tryMarkHandled(envelope.eventId()); + metricsApplicationService.tryMarkHandled(envelope.eventId()); return CatalogEventResult.notProcessed(); } // 멱등성 체크 - if (!metricsService.tryMarkHandled(envelope.eventId())) { + if (!metricsApplicationService.tryMarkHandled(envelope.eventId())) { log.debug("Event already processed: {}", envelope.eventId()); return CatalogEventResult.notProcessed(); } @@ -116,12 +116,12 @@ public OrderEventResult processOrderEvent(Object eventValue) { if (isOldEvent(envelope.occurredAtEpochMillis())) { log.debug("Ignoring old event: eventId={}, occurredAt={}", envelope.eventId(), envelope.occurredAtEpochMillis()); - metricsService.tryMarkHandled(envelope.eventId()); + metricsApplicationService.tryMarkHandled(envelope.eventId()); return OrderEventResult.notProcessed(); } // 멱등성 체크 - if (!metricsService.tryMarkHandled(envelope.eventId())) { + if (!metricsApplicationService.tryMarkHandled(envelope.eventId())) { log.debug("Event already processed: {}", envelope.eventId()); return OrderEventResult.notProcessed(); } @@ -163,7 +163,7 @@ private CatalogEventResult processProductView(DomainEventEnvelope envelope) { return CatalogEventResult.notProcessed(); } - metricsService.incrementView(payload.productId(), envelope.occurredAtEpochMillis()); + metricsApplicationService.incrementView(payload.productId(), envelope.occurredAtEpochMillis()); log.debug("Processed PRODUCT_VIEW for productId: {}", payload.productId()); RankingScore rankingScore = rankingService.generateRankingScore(envelope); @@ -178,7 +178,7 @@ private CatalogEventResult processLikeAction(DomainEventEnvelope envelope) { } final int delta = "LIKE".equals(payload.action()) ? 1 : -1; - metricsService.applyLikeDelta(payload.productId(), delta, envelope.occurredAtEpochMillis()); + metricsApplicationService.applyLikeDelta(payload.productId(), delta, envelope.occurredAtEpochMillis()); log.debug("Processed LIKE_ACTION for productId: {}, action: {}", payload.productId(), payload.action()); RankingScore rankingScore = rankingService.generateRankingScore(envelope); @@ -192,7 +192,7 @@ private CatalogEventResult processStockDepleted(DomainEventEnvelope envelope) { return CatalogEventResult.notProcessed(); } - metricsService.handleStockDepleted( + metricsApplicationService.handleStockDepleted( payload.productId(), payload.brandId(), payload.remainingStock(), @@ -218,7 +218,7 @@ private OrderEventResult processPaymentSuccess(DomainEventEnvelope envelope) { return OrderEventResult.notProcessed(); } - metricsService.addSales(payload.productId(), payload.quantity(), envelope.occurredAtEpochMillis()); + metricsApplicationService.addSales(payload.productId(), payload.quantity(), envelope.occurredAtEpochMillis()); log.debug("Processed PAYMENT_SUCCESS - orderId: {}, productId: {}, quantity: {}, totalPrice: {}", payload.orderId(), payload.productId(), payload.quantity(), payload.totalPrice()); diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsService.java b/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsApplicationService.java similarity index 99% rename from apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsService.java rename to apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsApplicationService.java index ccae167bd..753f43d83 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsService.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsApplicationService.java @@ -40,7 +40,7 @@ @Service @RequiredArgsConstructor @Slf4j -public class MetricsService { +public class MetricsApplicationService { // Domain Layer 의존성 private final ProductMetricsService productMetricsService; diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/MetricsLockCleanupScheduler.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/MetricsLockCleanupScheduler.java index f6eeb8476..c007266bf 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/MetricsLockCleanupScheduler.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/MetricsLockCleanupScheduler.java @@ -3,7 +3,7 @@ import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; -import com.loopers.application.metrics.MetricsService; +import com.loopers.application.metrics.MetricsApplicationService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -21,7 +21,7 @@ @Slf4j public class MetricsLockCleanupScheduler { - private final MetricsService metricsService; + private final MetricsApplicationService metricsApplicationService; /** * 사용하지 않는 락 정리 (5분마다) @@ -29,7 +29,7 @@ public class MetricsLockCleanupScheduler { @Scheduled(fixedRate = 5 * 60 * 1000) // 5분 public void cleanupUnusedLocks() { try { - metricsService.cleanupUnusedLocks(); + metricsApplicationService.cleanupUnusedLocks(); } catch (Exception e) { log.error("락 정리 중 오류 발생", e); } @@ -41,7 +41,7 @@ public void cleanupUnusedLocks() { @Scheduled(fixedRate = 10 * 60 * 1000) // 10분 public void cleanupProcessedEvents() { try { - metricsService.cleanupProcessedEvents(); + metricsApplicationService.cleanupProcessedEvents(); } catch (Exception e) { log.error("처리된 이벤트 캐시 정리 중 오류 발생", e); } @@ -53,9 +53,9 @@ public void cleanupProcessedEvents() { @Scheduled(fixedRate = 60 * 1000) // 1분 public void monitorLockStatus() { try { - metricsService.logLockStatus(); + metricsApplicationService.logLockStatus(); } catch (Exception e) { log.error("락 상태 모니터링 중 오류 발생", e); } } -} \ No newline at end of file +} From 1f885876309c5f3f7ff04e297e068cf2a8b2d6e3 Mon Sep 17 00:00:00 2001 From: hyujikoh Date: Fri, 26 Dec 2025 12:46:21 +0900 Subject: [PATCH 14/18] =?UTF-8?q?feat(test):=20=EB=9E=AD=ED=82=B9=20API=20?= =?UTF-8?q?E2E=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 랭킹 목록 조회 API에 대한 E2E 테스트 구현 - 다양한 랭킹 데이터 시나리오 검증 - 상품 상세 조회 시 랭킹 정보 포함 여부 테스트 --- .../interfaces/api/RankingV1ApiE2ETest.java | 463 ++++++++++++++++++ 1 file changed, 463 insertions(+) create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingV1ApiE2ETest.java diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingV1ApiE2ETest.java new file mode 100644 index 000000000..c77c811f9 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingV1ApiE2ETest.java @@ -0,0 +1,463 @@ +package com.loopers.interfaces.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import org.junit.jupiter.api.*; +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.core.ParameterizedTypeReference; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.http.*; + +import com.loopers.cache.CacheKeyGenerator; +import com.loopers.cache.RankingRedisService; +import com.loopers.cache.dto.CachePayloads.RankingScore; +import com.loopers.cache.dto.CachePayloads.RankingScore.EventType; +import com.loopers.domain.brand.BrandEntity; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.product.*; +import com.loopers.fixtures.BrandTestFixture; +import com.loopers.fixtures.ProductTestFixture; +import com.loopers.interfaces.api.common.PageResponse; +import com.loopers.interfaces.api.product.ProductV1Dtos; +import com.loopers.support.Uris; +import com.loopers.utils.DatabaseCleanUp; +import com.loopers.utils.RedisCleanUp; + +/** + * 랭킹 API E2E 테스트 + *

+ * 실제 데이터 생성 → Redis ZSET 적재 → API 조회 전체 프로세스 검증 + * + * @author hyunjikoh + * @since 2025.12.26 + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@DisplayName("Ranking API E2E 테스트") +class RankingV1ApiE2ETest { + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @Autowired + private RedisCleanUp redisCleanUp; + + @Autowired + private ProductService productService; + + @Autowired + private BrandService brandService; + + @Autowired + private ProductMVService productMVService; + + @Autowired + private RankingRedisService rankingRedisService; + + @Autowired + private RedisTemplate redisTemplate; + + @Autowired + private CacheKeyGenerator cacheKeyGenerator; + + private Long testBrandId; + private final List testProductIds = new ArrayList<>(); + private LocalDate today; + + @BeforeEach + void setUp() { + databaseCleanUp.truncateAllTables(); + redisCleanUp.truncateAll(); + testProductIds.clear(); + testBrandId = null; + + today = LocalDate.now(); + + // 테스트용 브랜드 생성 + BrandEntity brand = brandService.registerBrand( + BrandTestFixture.createRequest("랭킹테스트브랜드", "랭킹 E2E 테스트용 브랜드") + ); + testBrandId = brand.getId(); + + // 테스트용 상품 5개 생성 + for (int i = 1; i <= 5; i++) { + ProductDomainCreateRequest productRequest = ProductTestFixture.createRequest( + testBrandId, + "랭킹테스트상품" + i, + "랭킹 E2E 테스트용 상품 " + i, + new BigDecimal(String.valueOf(10000 * i)), + new BigDecimal(String.valueOf(8000 * i)), + 100 + ); + ProductEntity product = productService.registerProduct(productRequest); + testProductIds.add(product.getId()); + } + + // MV 동기화 + productMVService.syncMaterializedView(); + } + + @Nested + @DisplayName("랭킹 목록 조회 API") + class GetRankingProductsTest { + + @Test + @DisplayName("랭킹 데이터가 있으면 점수 순으로 상품 목록을 반환한다") + void should_return_products_in_ranking_order() { + // Given - Redis에 랭킹 데이터 직접 적재 + Long product1 = testProductIds.get(0); // 1위 (높은 점수) + Long product2 = testProductIds.get(1); // 3위 + Long product3 = testProductIds.get(2); // 2위 + + // 점수 적재 (높은 순: product1 > product2 > product3) + List scores = List.of( + new RankingScore(product1, EventType.PAYMENT_SUCCESS, 100.0, System.currentTimeMillis()), + new RankingScore(product2, EventType.LIKE_ACTION, 10.0, System.currentTimeMillis()), + new RankingScore(product3, EventType.PRODUCT_VIEW, 50.0, System.currentTimeMillis()) + ); + rankingRedisService.updateRankingScoresBatch(scores, today); + + // When + ParameterizedTypeReference>> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity>> response = + testRestTemplate.exchange( + Uris.Ranking.GET_RANKING + "?page=0&size=10", + HttpMethod.GET, null, responseType + ); + + // Then + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().content()).hasSize(3), + () -> assertThat(response.getBody().data().content().get(0).productId()).isEqualTo(product1), + () -> assertThat(response.getBody().data().content().get(2).productId()).isEqualTo(product2), + () -> assertThat(response.getBody().data().content().get(1).productId()).isEqualTo(product3) + ); + } + + @Test + @DisplayName("랭킹 데이터가 없으면 빈 목록을 반환한다") + void should_return_empty_when_no_ranking_data() { + // Given - 랭킹 데이터 없음 + + // When + ParameterizedTypeReference>> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity>> response = + testRestTemplate.exchange( + Uris.Ranking.GET_RANKING, + HttpMethod.GET, null, responseType + ); + + // Then + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().content()).isEmpty(), + () -> assertThat(response.getBody().data().totalElements()).isEqualTo(0) + ); + } + + @Test + @DisplayName("페이징이 정상적으로 동작한다") + void should_paginate_ranking_results() { + // Given - 5개 상품 모두 랭킹에 등록 + List scores = new ArrayList<>(); + for (int i = 0; i < 5; i++) { + scores.add(new RankingScore( + testProductIds.get(i), + EventType.PRODUCT_VIEW, + (5 - i) * 10.0, // 점수: 50, 40, 30, 20, 10 + System.currentTimeMillis() + )); + } + rankingRedisService.updateRankingScoresBatch(scores, today); + + // When - 페이지 크기 2로 조회 + ParameterizedTypeReference>> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity>> response = + testRestTemplate.exchange( + Uris.Ranking.GET_RANKING + "?page=0&size=2", + HttpMethod.GET, null, responseType + ); + + // Then + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().content()).hasSize(2), + () -> assertThat(response.getBody().data().totalElements()).isEqualTo(5), + () -> assertThat(response.getBody().data().totalPages()).isEqualTo(3), + () -> assertThat(response.getBody().data().first()).isTrue(), + () -> assertThat(response.getBody().data().last()).isFalse() + ); + } + + @Test + @DisplayName("특정 날짜의 랭킹을 조회할 수 있다") + void should_return_ranking_for_specific_date() { + // Given - 어제 날짜에 랭킹 데이터 적재 + LocalDate yesterday = today.minusDays(1); + Long product1 = testProductIds.get(0); + + List scores = List.of( + new RankingScore(product1, EventType.PRODUCT_VIEW, 100.0, System.currentTimeMillis()) + ); + rankingRedisService.updateRankingScoresBatch(scores, yesterday); + + // When - 어제 날짜로 조회 + ParameterizedTypeReference>> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity>> response = + testRestTemplate.exchange( + Uris.Ranking.GET_RANKING + "?date=" + yesterday, + HttpMethod.GET, null, responseType + ); + + // Then + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().content()).hasSize(1), + () -> assertThat(response.getBody().data().content().get(0).productId()).isEqualTo(product1) + ); + } + } + + @Nested + @DisplayName("콜드 스타트 Fallback 테스트") + class ColdStartFallbackTest { + + @Test + @DisplayName("오늘 랭킹이 없으면 어제 랭킹을 반환한다") + void should_fallback_to_yesterday_when_today_is_empty() { + // Given - 어제 랭킹만 있음 + LocalDate yesterday = today.minusDays(1); + Long product1 = testProductIds.get(0); + + List scores = List.of( + new RankingScore(product1, EventType.PRODUCT_VIEW, 50.0, System.currentTimeMillis()) + ); + rankingRedisService.updateRankingScoresBatch(scores, yesterday); + + // When - 날짜 미지정 (오늘 기준) + ParameterizedTypeReference>> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity>> response = + testRestTemplate.exchange( + Uris.Ranking.GET_RANKING, + HttpMethod.GET, null, responseType + ); + + // Then - 어제 랭킹이 반환됨 + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().content()).hasSize(1), + () -> assertThat(response.getBody().data().content().get(0).productId()).isEqualTo(product1) + ); + } + + @Test + @DisplayName("명시적 날짜 지정 시 Fallback하지 않는다") + void should_not_fallback_when_date_is_explicitly_specified() { + // Given - 어제 랭킹만 있음 + LocalDate yesterday = today.minusDays(1); + Long product1 = testProductIds.get(0); + + List scores = List.of( + new RankingScore(product1, EventType.PRODUCT_VIEW, 50.0, System.currentTimeMillis()) + ); + rankingRedisService.updateRankingScoresBatch(scores, yesterday); + + // When - 오늘 날짜 명시적 지정 + ParameterizedTypeReference>> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity>> response = + testRestTemplate.exchange( + Uris.Ranking.GET_RANKING + "?date=" + today, + HttpMethod.GET, null, responseType + ); + + // Then - 빈 결과 (Fallback 안 함) + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().content()).isEmpty() + ); + } + } + + @Nested + @DisplayName("상품 상세 조회 시 랭킹 정보 포함 테스트") + class ProductDetailWithRankingTest { + + @Test + @DisplayName("랭킹에 있는 상품은 랭킹 정보가 포함된다") + void should_include_ranking_info_for_ranked_product() { + // Given - 상품을 랭킹에 등록 + Long productId = testProductIds.get(0); + double score = 123.45; + + List scores = List.of( + new RankingScore(productId, EventType.PAYMENT_SUCCESS, score, System.currentTimeMillis()) + ); + rankingRedisService.updateRankingScoresBatch(scores, today); + + // When + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange( + Uris.Product.GET_DETAIL, + HttpMethod.GET, null, responseType, productId + ); + + // Then + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().productId()).isEqualTo(productId), + () -> assertThat(response.getBody().data().ranking()).isNotNull(), + () -> assertThat(response.getBody().data().ranking().rank()).isEqualTo(1L), + () -> assertThat(response.getBody().data().ranking().score()).isGreaterThan(0) + ); + } + + @Test + @DisplayName("랭킹에 없는 상품은 랭킹 정보가 null이다") + void should_have_null_ranking_for_unranked_product() { + // Given - 랭킹 데이터 없음 + Long productId = testProductIds.get(0); + + // When + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange( + Uris.Product.GET_DETAIL, + HttpMethod.GET, null, responseType, productId + ); + + // Then + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().productId()).isEqualTo(productId), + () -> assertThat(response.getBody().data().ranking()).isNull() + ); + } + + @Test + @DisplayName("여러 상품 중 특정 상품의 순위가 정확히 반환된다") + void should_return_correct_rank_among_multiple_products() { + // Given - 3개 상품 랭킹 등록 (product2가 2위) + Long product1 = testProductIds.get(0); + Long product2 = testProductIds.get(1); + Long product3 = testProductIds.get(2); + + List scores = List.of( + new RankingScore(product1, EventType.PAYMENT_SUCCESS, 100.0, System.currentTimeMillis()), + new RankingScore(product2, EventType.LIKE_ACTION, 50.0, System.currentTimeMillis()), + new RankingScore(product3, EventType.PRODUCT_VIEW, 10.0, System.currentTimeMillis()) + ); + rankingRedisService.updateRankingScoresBatch(scores, today); + + // When - product2 상세 조회 + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange( + Uris.Product.GET_DETAIL, + HttpMethod.GET, null, responseType, product2 + ); + + // Then - 2위로 반환 + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().productId()).isEqualTo(product2), + () -> assertThat(response.getBody().data().ranking()).isNotNull(), + () -> assertThat(response.getBody().data().ranking().rank()).isEqualTo(2L) + ); + } + } + + @Nested + @DisplayName("점수 누적 테스트") + class ScoreAccumulationTest { + + @Test + @DisplayName("동일 상품에 여러 이벤트 점수가 누적된다") + void should_accumulate_scores_for_same_product() { + // Given - 동일 상품에 여러 점수 적재 + Long productId = testProductIds.get(0); + + // 첫 번째 점수 적재 (PRODUCT_VIEW: weight 0.1, score 10.0 → 1.0) + rankingRedisService.updateRankingScoresBatch( + List.of(new RankingScore(productId, EventType.PRODUCT_VIEW, 10.0, System.currentTimeMillis())), + today + ); + + // 두 번째 점수 적재 (LIKE_ACTION: weight 0.2, score 20.0 → 4.0) + // 누적 점수: 1.0 + 4.0 = 5.0 + rankingRedisService.updateRankingScoresBatch( + List.of(new RankingScore(productId, EventType.LIKE_ACTION, 20.0, System.currentTimeMillis())), + today + ); + + // When + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange( + Uris.Product.GET_DETAIL, + HttpMethod.GET, null, responseType, productId + ); + + // Then - 점수가 누적됨 (weight 적용: 10*0.1 + 20*0.2 = 5.0) + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().ranking()).isNotNull(), + () -> assertThat(response.getBody().data().ranking().score()).isGreaterThanOrEqualTo(5.0) + ); + } + } + + @Nested + @DisplayName("Score Carry-Over 테스트") + class CarryOverTest { + + @Test + @DisplayName("Carry-Over 후 다음 날 랭킹에 점수가 이월된다") + void should_carry_over_scores_to_next_day() { + // Given - 오늘 랭킹 데이터 직접 Redis에 적재 (weight 적용된 점수) + Long productId = testProductIds.get(0); + double weightedScore = 60.0; // PAYMENT_SUCCESS weight 0.6 * score 100 = 60 + + String todayKey = cacheKeyGenerator.generateDailyRankingKey(today); + redisTemplate.opsForZSet().add(todayKey, productId.toString(), weightedScore); + + LocalDate tomorrow = today.plusDays(1); + String tomorrowKey = cacheKeyGenerator.generateDailyRankingKey(tomorrow); + redisTemplate.delete(tomorrowKey); // 내일 키 정리 + + // When - Carry-Over 실행 (10%) + rankingRedisService.carryOverScores(today, tomorrow, 0.1); + + // Then - 내일 키에 10% 점수가 이월됨 + Double tomorrowScore = redisTemplate.opsForZSet().score(tomorrowKey, productId.toString()); + + assertThat(tomorrowScore).isNotNull(); + assertThat(tomorrowScore).isCloseTo(weightedScore * 0.1, org.assertj.core.data.Offset.offset(0.01)); + + // Cleanup + redisTemplate.delete(tomorrowKey); + } + } +} From 80a2ba1d233487bff28bd7b35ff9d030292c38d2 Mon Sep 17 00:00:00 2001 From: hyujikoh Date: Fri, 26 Dec 2025 12:50:00 +0900 Subject: [PATCH 15/18] =?UTF-8?q?refactor(test):=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=EC=97=90=EC=84=9C=20null=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 응답 본문에서 null 체크를 추가하여 안정성 향상 - 불필요한 변수 선언 제거로 코드 간결화 --- .../interfaces/api/RankingV1ApiE2ETest.java | 35 +++++++++---------- ...MetricsEventProcessingIntegrationTest.java | 1 - 2 files changed, 17 insertions(+), 19 deletions(-) diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingV1ApiE2ETest.java index c77c811f9..bb3a128e6 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingV1ApiE2ETest.java @@ -71,7 +71,6 @@ class RankingV1ApiE2ETest { @Autowired private CacheKeyGenerator cacheKeyGenerator; - private Long testBrandId; private final List testProductIds = new ArrayList<>(); private LocalDate today; @@ -80,7 +79,7 @@ void setUp() { databaseCleanUp.truncateAllTables(); redisCleanUp.truncateAll(); testProductIds.clear(); - testBrandId = null; + Long testBrandId = null; today = LocalDate.now(); @@ -141,9 +140,9 @@ void should_return_products_in_ranking_order() { assertAll( () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), () -> assertThat(Objects.requireNonNull(response.getBody()).data().content()).hasSize(3), - () -> assertThat(response.getBody().data().content().get(0).productId()).isEqualTo(product1), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().content().get(0).productId()).isEqualTo(product1), () -> assertThat(response.getBody().data().content().get(2).productId()).isEqualTo(product2), - () -> assertThat(response.getBody().data().content().get(1).productId()).isEqualTo(product3) + () -> assertThat(Objects.requireNonNull(response.getBody()).data().content().get(1).productId()).isEqualTo(product3) ); } @@ -165,7 +164,7 @@ void should_return_empty_when_no_ranking_data() { assertAll( () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), () -> assertThat(Objects.requireNonNull(response.getBody()).data().content()).isEmpty(), - () -> assertThat(response.getBody().data().totalElements()).isEqualTo(0) + () -> assertThat(Objects.requireNonNull(response.getBody()).data().totalElements()).isEqualTo(0) ); } @@ -197,10 +196,10 @@ void should_paginate_ranking_results() { assertAll( () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), () -> assertThat(Objects.requireNonNull(response.getBody()).data().content()).hasSize(2), - () -> assertThat(response.getBody().data().totalElements()).isEqualTo(5), - () -> assertThat(response.getBody().data().totalPages()).isEqualTo(3), - () -> assertThat(response.getBody().data().first()).isTrue(), - () -> assertThat(response.getBody().data().last()).isFalse() + () -> assertThat(Objects.requireNonNull(response.getBody()).data().totalElements()).isEqualTo(5), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().totalPages()).isEqualTo(3), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().first()).isTrue(), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().last()).isFalse() ); } @@ -229,7 +228,7 @@ void should_return_ranking_for_specific_date() { assertAll( () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), () -> assertThat(Objects.requireNonNull(response.getBody()).data().content()).hasSize(1), - () -> assertThat(response.getBody().data().content().get(0).productId()).isEqualTo(product1) + () -> assertThat(Objects.requireNonNull(response.getBody()).data().content().get(0).productId()).isEqualTo(product1) ); } } @@ -263,7 +262,7 @@ void should_fallback_to_yesterday_when_today_is_empty() { assertAll( () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), () -> assertThat(Objects.requireNonNull(response.getBody()).data().content()).hasSize(1), - () -> assertThat(response.getBody().data().content().get(0).productId()).isEqualTo(product1) + () -> assertThat(Objects.requireNonNull(response.getBody()).data().content().get(0).productId()).isEqualTo(product1) ); } @@ -325,9 +324,9 @@ void should_include_ranking_info_for_ranked_product() { assertAll( () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), () -> assertThat(Objects.requireNonNull(response.getBody()).data().productId()).isEqualTo(productId), - () -> assertThat(response.getBody().data().ranking()).isNotNull(), - () -> assertThat(response.getBody().data().ranking().rank()).isEqualTo(1L), - () -> assertThat(response.getBody().data().ranking().score()).isGreaterThan(0) + () -> assertThat(Objects.requireNonNull(response.getBody()).data().ranking()).isNotNull(), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().ranking().rank()).isEqualTo(1L), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().ranking().score()).isGreaterThan(0) ); } @@ -350,7 +349,7 @@ void should_have_null_ranking_for_unranked_product() { assertAll( () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), () -> assertThat(Objects.requireNonNull(response.getBody()).data().productId()).isEqualTo(productId), - () -> assertThat(response.getBody().data().ranking()).isNull() + () -> assertThat(Objects.requireNonNull(response.getBody()).data().ranking()).isNull() ); } @@ -382,8 +381,8 @@ void should_return_correct_rank_among_multiple_products() { assertAll( () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), () -> assertThat(Objects.requireNonNull(response.getBody()).data().productId()).isEqualTo(product2), - () -> assertThat(response.getBody().data().ranking()).isNotNull(), - () -> assertThat(response.getBody().data().ranking().rank()).isEqualTo(2L) + () -> assertThat(Objects.requireNonNull(response.getBody()).data().ranking()).isNotNull(), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().ranking().rank()).isEqualTo(2L) ); } } @@ -424,7 +423,7 @@ void should_accumulate_scores_for_same_product() { assertAll( () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), () -> assertThat(Objects.requireNonNull(response.getBody()).data().ranking()).isNotNull(), - () -> assertThat(response.getBody().data().ranking().score()).isGreaterThanOrEqualTo(5.0) + () -> assertThat(Objects.requireNonNull(response.getBody()).data().ranking().score()).isGreaterThanOrEqualTo(5.0) ); } } diff --git a/apps/commerce-streamer/src/test/java/com/loopers/integration/MetricsEventProcessingIntegrationTest.java b/apps/commerce-streamer/src/test/java/com/loopers/integration/MetricsEventProcessingIntegrationTest.java index 847c60e72..679ce3c79 100644 --- a/apps/commerce-streamer/src/test/java/com/loopers/integration/MetricsEventProcessingIntegrationTest.java +++ b/apps/commerce-streamer/src/test/java/com/loopers/integration/MetricsEventProcessingIntegrationTest.java @@ -11,7 +11,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.kafka.core.KafkaTemplate; -import org.springframework.transaction.annotation.Transactional; import com.fasterxml.jackson.databind.ObjectMapper; import com.loopers.domain.event.EventRepository; From a93dad65349644320fc00da2b1a42c71344a87d5 Mon Sep 17 00:00:00 2001 From: hyujikoh Date: Fri, 26 Dec 2025 12:55:04 +0900 Subject: [PATCH 16/18] =?UTF-8?q?refactor(metrics):=20MetricsApplicationSe?= =?UTF-8?q?rvice=EB=A5=BC=20MetricsService=EB=A1=9C=20=EC=9D=B4=EB=A6=84?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MetricsApplicationService 클래스를 MetricsService로 리팩토링 - 관련된 모든 의존성 및 호출 부분 수정 --- .../event/EventProcessingFacade.java | 20 +++++++++---------- ...cationService.java => MetricsService.java} | 2 +- .../MetricsLockCleanupScheduler.java | 10 +++++----- 3 files changed, 16 insertions(+), 16 deletions(-) rename apps/commerce-streamer/src/main/java/com/loopers/application/metrics/{MetricsApplicationService.java => MetricsService.java} (99%) diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/event/EventProcessingFacade.java b/apps/commerce-streamer/src/main/java/com/loopers/application/event/EventProcessingFacade.java index f6324b9c1..501d5a8c9 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/application/event/EventProcessingFacade.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/event/EventProcessingFacade.java @@ -7,7 +7,7 @@ import com.loopers.application.event.dto.EventProcessingResult.CatalogEventResult; import com.loopers.application.event.dto.EventProcessingResult.OrderEventResult; -import com.loopers.application.metrics.MetricsApplicationService; +import com.loopers.application.metrics.MetricsService; import com.loopers.cache.dto.CachePayloads.RankingScore; import com.loopers.domain.ranking.RankingService; import com.loopers.infrastructure.event.DomainEventEnvelope; @@ -47,7 +47,7 @@ public class EventProcessingFacade { // Application Layer 의존성 - private final MetricsApplicationService metricsApplicationService; + private final MetricsService metricsService; // Domain Layer 의존성 private final RankingService rankingService; @@ -76,12 +76,12 @@ public CatalogEventResult processCatalogEvent(Object eventValue) { if (isOldEvent(envelope.occurredAtEpochMillis())) { log.debug("Ignoring old event: eventId={}, occurredAt={}", envelope.eventId(), envelope.occurredAtEpochMillis()); - metricsApplicationService.tryMarkHandled(envelope.eventId()); + metricsService.tryMarkHandled(envelope.eventId()); return CatalogEventResult.notProcessed(); } // 멱등성 체크 - if (!metricsApplicationService.tryMarkHandled(envelope.eventId())) { + if (!metricsService.tryMarkHandled(envelope.eventId())) { log.debug("Event already processed: {}", envelope.eventId()); return CatalogEventResult.notProcessed(); } @@ -116,12 +116,12 @@ public OrderEventResult processOrderEvent(Object eventValue) { if (isOldEvent(envelope.occurredAtEpochMillis())) { log.debug("Ignoring old event: eventId={}, occurredAt={}", envelope.eventId(), envelope.occurredAtEpochMillis()); - metricsApplicationService.tryMarkHandled(envelope.eventId()); + metricsService.tryMarkHandled(envelope.eventId()); return OrderEventResult.notProcessed(); } // 멱등성 체크 - if (!metricsApplicationService.tryMarkHandled(envelope.eventId())) { + if (!metricsService.tryMarkHandled(envelope.eventId())) { log.debug("Event already processed: {}", envelope.eventId()); return OrderEventResult.notProcessed(); } @@ -163,7 +163,7 @@ private CatalogEventResult processProductView(DomainEventEnvelope envelope) { return CatalogEventResult.notProcessed(); } - metricsApplicationService.incrementView(payload.productId(), envelope.occurredAtEpochMillis()); + metricsService.incrementView(payload.productId(), envelope.occurredAtEpochMillis()); log.debug("Processed PRODUCT_VIEW for productId: {}", payload.productId()); RankingScore rankingScore = rankingService.generateRankingScore(envelope); @@ -178,7 +178,7 @@ private CatalogEventResult processLikeAction(DomainEventEnvelope envelope) { } final int delta = "LIKE".equals(payload.action()) ? 1 : -1; - metricsApplicationService.applyLikeDelta(payload.productId(), delta, envelope.occurredAtEpochMillis()); + metricsService.applyLikeDelta(payload.productId(), delta, envelope.occurredAtEpochMillis()); log.debug("Processed LIKE_ACTION for productId: {}, action: {}", payload.productId(), payload.action()); RankingScore rankingScore = rankingService.generateRankingScore(envelope); @@ -192,7 +192,7 @@ private CatalogEventResult processStockDepleted(DomainEventEnvelope envelope) { return CatalogEventResult.notProcessed(); } - metricsApplicationService.handleStockDepleted( + metricsService.handleStockDepleted( payload.productId(), payload.brandId(), payload.remainingStock(), @@ -218,7 +218,7 @@ private OrderEventResult processPaymentSuccess(DomainEventEnvelope envelope) { return OrderEventResult.notProcessed(); } - metricsApplicationService.addSales(payload.productId(), payload.quantity(), envelope.occurredAtEpochMillis()); + metricsService.addSales(payload.productId(), payload.quantity(), envelope.occurredAtEpochMillis()); log.debug("Processed PAYMENT_SUCCESS - orderId: {}, productId: {}, quantity: {}, totalPrice: {}", payload.orderId(), payload.productId(), payload.quantity(), payload.totalPrice()); diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsApplicationService.java b/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsService.java similarity index 99% rename from apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsApplicationService.java rename to apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsService.java index 753f43d83..ccae167bd 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsApplicationService.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsService.java @@ -40,7 +40,7 @@ @Service @RequiredArgsConstructor @Slf4j -public class MetricsApplicationService { +public class MetricsService { // Domain Layer 의존성 private final ProductMetricsService productMetricsService; diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/MetricsLockCleanupScheduler.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/MetricsLockCleanupScheduler.java index c007266bf..9d4fa5a1e 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/MetricsLockCleanupScheduler.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/MetricsLockCleanupScheduler.java @@ -3,7 +3,7 @@ import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; -import com.loopers.application.metrics.MetricsApplicationService; +import com.loopers.application.metrics.MetricsService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -21,7 +21,7 @@ @Slf4j public class MetricsLockCleanupScheduler { - private final MetricsApplicationService metricsApplicationService; + private final MetricsService metricsService; /** * 사용하지 않는 락 정리 (5분마다) @@ -29,7 +29,7 @@ public class MetricsLockCleanupScheduler { @Scheduled(fixedRate = 5 * 60 * 1000) // 5분 public void cleanupUnusedLocks() { try { - metricsApplicationService.cleanupUnusedLocks(); + metricsService.cleanupUnusedLocks(); } catch (Exception e) { log.error("락 정리 중 오류 발생", e); } @@ -41,7 +41,7 @@ public void cleanupUnusedLocks() { @Scheduled(fixedRate = 10 * 60 * 1000) // 10분 public void cleanupProcessedEvents() { try { - metricsApplicationService.cleanupProcessedEvents(); + metricsService.cleanupProcessedEvents(); } catch (Exception e) { log.error("처리된 이벤트 캐시 정리 중 오류 발생", e); } @@ -53,7 +53,7 @@ public void cleanupProcessedEvents() { @Scheduled(fixedRate = 60 * 1000) // 1분 public void monitorLockStatus() { try { - metricsApplicationService.logLockStatus(); + metricsService.logLockStatus(); } catch (Exception e) { log.error("락 상태 모니터링 중 오류 발생", e); } From 0502cb5f512d0ecb688f4429984278c16d5860a2 Mon Sep 17 00:00:00 2001 From: hyujikoh Date: Fri, 26 Dec 2025 15:22:33 +0900 Subject: [PATCH 17/18] =?UTF-8?q?refactor(cache):=20=EC=BA=90=EC=8B=9C=20?= =?UTF-8?q?=ED=82=A4=20=EB=B0=8F=20=ED=8E=98=EC=9D=B4=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EC=BD=94=EB=93=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 일간 랭킹 키 생성 시 날짜 형식 변경 - EventType의 weight 필드를 final로 변경하여 불변성 강화 - 로그 메시지에서 잘못된 이벤트 타입 수정 - API 문서에서 날짜 형식 설명 업데이트 --- .../loopers/interfaces/api/product/ProductV1ApiSpec.java | 2 +- .../java/com/loopers/CommerceStreamerApplication.java | 2 ++ .../loopers/interfaces/consumer/MetricsKafkaConsumer.java | 2 +- .../interfaces/consumer/MetricsKafkaConsumerTest.java | 6 ++++-- .../main/java/com/loopers/cache/CacheKeyGenerator.java | 2 +- .../main/java/com/loopers/cache/dto/CachePayloads.java | 8 ++------ 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java index ec03d0cc9..daed7d359 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java @@ -44,7 +44,7 @@ ApiResponse> getProducts( }) ApiResponse> getRankingProducts( @PageableDefault(size = 20) Pageable pageable, - @Parameter(description = "조회 날짜 (yyyyMMdd 형식, 선택)", example = "20251223") + @Parameter(description = "조회 날짜 (yyyy-MM-dd 형식, 선택)", example = "2025-12-23") @RequestParam(required = false) LocalDate date ); diff --git a/apps/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java b/apps/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java index 4ef7cfc68..6dd5005a1 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java @@ -5,11 +5,13 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.scheduling.annotation.EnableScheduling; import jakarta.annotation.PostConstruct; @ConfigurationPropertiesScan @SpringBootApplication +@EnableScheduling public class CommerceStreamerApplication { @PostConstruct public void started() { diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.java index 2df588dcf..18338fc7b 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.java @@ -91,7 +91,7 @@ public void onOrderEvents( var result = eventProcessingFacade.processOrderEvent(record.value()); return (result.processed()) ? result.rankingScore() : null; } catch (Exception e) { - log.error("Failed to process catalog event", e); + log.error("Failed to process order event", e); return null; } }, executorService)) diff --git a/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/MetricsKafkaConsumerTest.java b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/MetricsKafkaConsumerTest.java index ece4fb075..a3273bc4c 100644 --- a/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/MetricsKafkaConsumerTest.java +++ b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/MetricsKafkaConsumerTest.java @@ -2,7 +2,6 @@ import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; - import java.util.List; import org.apache.kafka.clients.consumer.ConsumerRecord; @@ -156,7 +155,10 @@ void shouldContinueProcessingWhenIndividualMessageFails() { consumer.onCatalogEvents(List.of(validRecord, invalidRecord), acknowledgment); // Then - 유효한 메시지는 처리되고, 전체 배치는 ack 되어야 함 - verify(eventProcessingFacade, times(2)).processCatalogEvent(any()); + verify(eventProcessingFacade, times(1)).updateRankingScores( + argThat(list -> list.size() == 1), + isNull() + ); verify(acknowledgment, times(1)).acknowledge(); } diff --git a/modules/redis/src/main/java/com/loopers/cache/CacheKeyGenerator.java b/modules/redis/src/main/java/com/loopers/cache/CacheKeyGenerator.java index 4269024ed..18652d9c9 100644 --- a/modules/redis/src/main/java/com/loopers/cache/CacheKeyGenerator.java +++ b/modules/redis/src/main/java/com/loopers/cache/CacheKeyGenerator.java @@ -135,7 +135,7 @@ private String generateSortString(Sort sort) { } /** - * 일간 랭킹 키 생성: ranking:all:20251223 + * 일간 랭킹 키 생성: ranking:all:2025-12-23 */ public String generateDailyRankingKey(LocalDate date) { return new StringJoiner(DELIMITER) diff --git a/modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java b/modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java index 05269c1f6..63e700101 100644 --- a/modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java +++ b/modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java @@ -30,15 +30,11 @@ public enum EventType { LIKE_ACTION(0.2), // 좋아요: Weight = 0.2, Score = 1 PAYMENT_SUCCESS(0.6); // 주문: Weight = 0.6, Score = price * amount (정규화 시에는 log 적용도 가능) - private double weight; + private final double weight; EventType(double weight) { this.weight = weight; } - - public void setWeight(double weight) { - this.weight = weight; - } } /** @@ -58,7 +54,7 @@ public static RankingScore forProductView(Long productId, long occurredAt) { } /** - * 좋아요 이벤트 생성 생성 + * 좋아요 이벤트 생성 */ public static RankingScore forLikeAction(Long productId, long occurredAt) { return new RankingScore(productId, EventType.LIKE_ACTION, 1.0, occurredAt); From a04f37e5bd9dd3871a73574b6ccfb3ed45194429 Mon Sep 17 00:00:00 2001 From: hyujikoh Date: Tue, 30 Dec 2025 21:35:50 +0900 Subject: [PATCH 18/18] =?UTF-8?q?refactor(cache):=20=EC=BA=90=EC=8B=9C=20?= =?UTF-8?q?=ED=82=A4=20=ED=98=95=EC=8B=9D=20=EB=B0=8F=20=EC=98=A4=EB=A5=98?= =?UTF-8?q?=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 일간 랭킹 키 생성 시 날짜 형식을 변경하여 일관성 향상 - 포인트 충전 관련 오류 메시지를 보다 명확하게 수정 - BigDecimal 사용 시 불필요한 패키지 경로 제거 --- .../com/loopers/application/point/PointFacade.java | 4 +++- .../java/com/loopers/support/error/ErrorType.java | 2 +- .../com/loopers/util/ProductDataGeneratorRunner.java | 3 ++- .../java/com/loopers/domain/user/UserUnitTest.java | 10 ++++++---- .../java/com/loopers/support/error/ErrorType.java | 2 +- .../generated/com/loopers/domain/QBaseEntity.java | 11 +++++++---- .../java/com/loopers/cache/CacheKeyGenerator.java | 2 +- .../java/com/loopers/cache/dto/CachePayloads.java | 8 ++++++-- 8 files changed, 27 insertions(+), 15 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java index c2ea5b3ca..a2677499c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java @@ -1,5 +1,7 @@ package com.loopers.application.point; +import java.math.BigDecimal; + import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -36,7 +38,7 @@ public PointV1Dtos.PointInfo getPointInfo(String username) { @Transactional public PointV1Dtos.PointChargeResponse chargePoint(String username, PointV1Dtos.PointChargeRequest request) { - java.math.BigDecimal totalAmount = pointService.charge(username, request.amount()); + BigDecimal totalAmount = pointService.charge(username, request.amount()); return new PointV1Dtos.PointChargeResponse(username, totalAmount); } diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java index fc52962d4..545f195a6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java @@ -29,7 +29,7 @@ public enum ErrorType { //좋아요 관련 오류 ALREADY_LIKED_PRODUCT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "이미 좋아요한 상품입니다."), - NOT_EXIST_LIKED(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "좋아요하지 않은 상품입니다."), + NOT_EXIST_LIKED(HttpStatus.BAD_REQUEST, HttpStatus.NOT_FOUND.getReasonPhrase(), "좋아요하지 않은 상품입니다."), // 주문 관련 오류 NOT_FOUND_ORDER(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.getReasonPhrase(), "존재하지 않는 주문입니다."), diff --git a/apps/commerce-api/src/main/java/com/loopers/util/ProductDataGeneratorRunner.java b/apps/commerce-api/src/main/java/com/loopers/util/ProductDataGeneratorRunner.java index 7f4abb45a..fc1772312 100644 --- a/apps/commerce-api/src/main/java/com/loopers/util/ProductDataGeneratorRunner.java +++ b/apps/commerce-api/src/main/java/com/loopers/util/ProductDataGeneratorRunner.java @@ -2,6 +2,7 @@ import net.datafaker.Faker; import java.math.BigDecimal; +import java.math.RoundingMode; import java.util.*; import org.springframework.boot.CommandLineRunner; @@ -219,7 +220,7 @@ private ProductEntity createRandomProduct(Long brandId) { BigDecimal originPrice = generateRandomPrice(10000, 500000); BigDecimal discountPrice = random.nextDouble() > 0.5 ? originPrice.multiply(BigDecimal.valueOf(random.nextDouble() * 0.8 + 0.1)) - .setScale(0, java.math.RoundingMode.HALF_UP) + .setScale(0, RoundingMode.HALF_UP) : null; int stockQuantity = random.nextInt(1000) + 1; diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserUnitTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserUnitTest.java index 8a9c07b9e..247db83fb 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserUnitTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserUnitTest.java @@ -1,5 +1,7 @@ package com.loopers.domain.user; +import java.math.BigDecimal; + import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -130,13 +132,13 @@ void can_charge_point() { // given UserDomainCreateRequest userRegisterRequest = UserTestFixture.createDefaultUserDomainRequest(); UserEntity userEntity = UserEntity.createUserEntity(userRegisterRequest); - java.math.BigDecimal chargeAmount = new java.math.BigDecimal("1000"); + BigDecimal chargeAmount = new BigDecimal("1000"); // when userEntity.chargePoint(chargeAmount); // then - UserTestFixture.assertUserPointAmount(userEntity, new java.math.BigDecimal("1000.00")); + UserTestFixture.assertUserPointAmount(userEntity, new BigDecimal("1000.00")); } @Test @@ -147,8 +149,8 @@ void charge_fails_with_zero_or_negative_amount() { UserEntity userEntity = UserEntity.createUserEntity(userRegisterRequest); // when & then - UserTestFixture.assertChargePointFails(userEntity, java.math.BigDecimal.ZERO, "충전 금액은 0보다 커야 합니다."); - UserTestFixture.assertChargePointFails(userEntity, new java.math.BigDecimal("-100"), "충전 금액은 0보다 커야 합니다."); + UserTestFixture.assertChargePointFails(userEntity, BigDecimal.ZERO, "충전 금액은 0보다 커야 합니다."); + UserTestFixture.assertChargePointFails(userEntity, new BigDecimal("-100"), "충전 금액은 0보다 커야 합니다."); } } } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/support/error/ErrorType.java b/apps/commerce-streamer/src/main/java/com/loopers/support/error/ErrorType.java index 3dc0271ce..828f968fd 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/support/error/ErrorType.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/support/error/ErrorType.java @@ -29,7 +29,7 @@ public enum ErrorType { //좋아요 관련 오류 ALREADY_LIKED_PRODUCT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "이미 좋아요한 상품입니다."), - NOT_EXIST_LIKED(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "좋아요하지 않은 상품입니다."), + NOT_EXIST_LIKED(HttpStatus.BAD_REQUEST, HttpStatus.NOT_FOUND.getReasonPhrase(), "좋아요하지 않은 상품입니다."), // 주문 관련 오류 NOT_FOUND_ORDER(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.getReasonPhrase(), "존재하지 않는 주문입니다."), diff --git a/modules/jpa/src/main/generated/com/loopers/domain/QBaseEntity.java b/modules/jpa/src/main/generated/com/loopers/domain/QBaseEntity.java index b5df4dc32..438fb1e07 100644 --- a/modules/jpa/src/main/generated/com/loopers/domain/QBaseEntity.java +++ b/modules/jpa/src/main/generated/com/loopers/domain/QBaseEntity.java @@ -13,7 +13,7 @@ * QBaseEntity is a Querydsl query type for BaseEntity */ @Generated("com.querydsl.codegen.DefaultSupertypeSerializer") -public class QBaseEntity extends EntityPathBase { +public class QBaseEntity extends EntityPathBase> { private static final long serialVersionUID = 1030422725L; @@ -27,16 +27,19 @@ public class QBaseEntity extends EntityPathBase { public final DateTimePath updatedAt = createDateTime("updatedAt", java.time.ZonedDateTime.class); + @SuppressWarnings({"all", "rawtypes", "unchecked"}) public QBaseEntity(String variable) { - super(BaseEntity.class, forVariable(variable)); + super((Class) BaseEntity.class, forVariable(variable)); } + @SuppressWarnings({"all", "rawtypes", "unchecked"}) public QBaseEntity(Path path) { - super(path.getType(), path.getMetadata()); + super((Class) path.getType(), path.getMetadata()); } + @SuppressWarnings({"all", "rawtypes", "unchecked"}) public QBaseEntity(PathMetadata metadata) { - super(BaseEntity.class, metadata); + super((Class) BaseEntity.class, metadata); } } diff --git a/modules/redis/src/main/java/com/loopers/cache/CacheKeyGenerator.java b/modules/redis/src/main/java/com/loopers/cache/CacheKeyGenerator.java index 18652d9c9..4269024ed 100644 --- a/modules/redis/src/main/java/com/loopers/cache/CacheKeyGenerator.java +++ b/modules/redis/src/main/java/com/loopers/cache/CacheKeyGenerator.java @@ -135,7 +135,7 @@ private String generateSortString(Sort sort) { } /** - * 일간 랭킹 키 생성: ranking:all:2025-12-23 + * 일간 랭킹 키 생성: ranking:all:20251223 */ public String generateDailyRankingKey(LocalDate date) { return new StringJoiner(DELIMITER) diff --git a/modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java b/modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java index 63e700101..48735fa19 100644 --- a/modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java +++ b/modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java @@ -1,5 +1,8 @@ package com.loopers.cache.dto; +import java.math.BigDecimal; +import java.util.Objects; + import lombok.Getter; /** @@ -47,7 +50,7 @@ public java.time.LocalDate getEventDate() { } /** - * 조회 이벤트 생성 생성 + * 조회 이벤트 생성 */ public static RankingScore forProductView(Long productId, long occurredAt) { return new RankingScore(productId, EventType.PRODUCT_VIEW, 1.0, occurredAt); @@ -63,7 +66,8 @@ public static RankingScore forLikeAction(Long productId, long occurredAt) { /** * 주문 이벤트 생성 메소드 (가격 * 수량 기반, 로그 정규화) */ - public static RankingScore forPaymentSuccess(Long productId, java.math.BigDecimal totalPrice, long occurredAt) { + public static RankingScore forPaymentSuccess(Long productId, BigDecimal totalPrice, long occurredAt) { + Objects.requireNonNull(totalPrice); // 로그 정규화 적용하여 극값 방지 // Math.log(x + 1)을 사용하여 0원일 때도 안전하게 처리 double normalizedScore = Math.log(totalPrice.doubleValue() + 1);