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 93e2712d9..39802aeb9 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 @@ -7,12 +7,15 @@ import com.loopers.domain.product.ProductSortType; import com.loopers.infrastructure.cache.ProductCacheService; import com.loopers.infrastructure.cache.ProductDetailCache; +import com.loopers.infrastructure.event.ViewEventPublisher; +import com.loopers.infrastructure.ranking.RankingRedisService; import com.loopers.interfaces.api.product.ProductDto; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDate; import java.util.Map; import java.util.Optional; import java.util.Set; @@ -26,6 +29,8 @@ public class ProductFacade { private final ProductDomainService productDomainService; private final BrandDomainService brandDomainService; private final ProductCacheService productCacheService; + private final ViewEventPublisher viewEventPublisher; + private final RankingRedisService rankingRedisService; /** * 상품 목록 조회 (Cache-Aside 패턴) @@ -77,11 +82,18 @@ public ProductDto.ProductDetailResponse getProduct(Long productId) { // 1. 캐시 조회 시도 Optional cachedDetail = productCacheService.getProductDetail(productId); + // 2. 조회 이벤트 발행 (캐시 히트 여부와 무관하게) + viewEventPublisher.publish(productId); + + // 3. 순위 조회 + Long rank = rankingRedisService.getRankingPosition(LocalDate.now(), productId); + if (cachedDetail.isPresent()) { // Cache Hit: 캐시된 데이터 직접 반환 return ProductDto.ProductDetailResponse.from( productId, - cachedDetail.get() + cachedDetail.get(), + rank ); } @@ -94,6 +106,6 @@ public ProductDto.ProductDetailResponse getProduct(Long productId) { productCacheService.setProductDetail(productId, cache); // Response 반환 - return ProductDto.ProductDetailResponse.from(productId, cache); + return ProductDto.ProductDetailResponse.from(productId, cache, rank); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java new file mode 100644 index 000000000..ba37b5a1a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java @@ -0,0 +1,70 @@ +package com.loopers.application.ranking; + +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.infrastructure.ranking.RankingEntry; +import com.loopers.infrastructure.ranking.RankingRedisService; +import com.loopers.interfaces.api.ranking.RankingDto; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Component +@RequiredArgsConstructor +public class RankingFacade { + + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + + private final RankingRedisService rankingRedisService; + private final ProductRepository productRepository; + + public RankingDto.RankingListResponse getRankings(String dateStr, int page, int size) { + LocalDate date = parseDate(dateStr); + int offset = page * size; + + List entries = rankingRedisService.getTopProducts(date, offset, size); + + if (entries.isEmpty()) { + return new RankingDto.RankingListResponse(List.of(), page, size, 0); + } + + long totalCount = rankingRedisService.getTotalCount(date); + + List productIds = entries.stream() + .map(RankingEntry::productId) + .toList(); + + Map productMap = productRepository.findAllByIdIn(productIds).stream() + .collect(Collectors.toMap(Product::getId, p -> p)); + + List rankings = new ArrayList<>(); + int rank = offset + 1; + for (RankingEntry entry : entries) { + Product product = productMap.get(entry.productId()); + if (product != null) { + rankings.add(new RankingDto.RankingResponse( + rank++, + product.getId(), + product.getName(), + product.getPrice(), + entry.score() + )); + } + } + + return new RankingDto.RankingListResponse(rankings, page, size, totalCount); + } + + private LocalDate parseDate(String dateStr) { + if (dateStr == null || dateStr.isBlank()) { + return LocalDate.now(); + } + return LocalDate.parse(dateStr, DATE_FORMATTER); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/event/ProductLikedEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/event/ProductLikedEvent.java index 317268407..5006f7736 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/event/ProductLikedEvent.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/event/ProductLikedEvent.java @@ -4,10 +4,13 @@ public class ProductLikedEvent { - private final Long productId; - private final Long userId; - private final boolean liked; - private final LocalDateTime occurredAt; + private Long productId; + private Long userId; + private boolean liked; + private LocalDateTime occurredAt; + + protected ProductLikedEvent() { + } private ProductLikedEvent(Long productId, Long userId, boolean liked) { this.productId = productId; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/event/OrderCompletedEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/event/OrderCompletedEvent.java index ac3656066..25d5b8aea 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/event/OrderCompletedEvent.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/event/OrderCompletedEvent.java @@ -3,15 +3,20 @@ import com.loopers.domain.order.Order; import java.time.LocalDateTime; +import java.util.List; public class OrderCompletedEvent { - private final Long orderId; - private final String userId; - private final long totalAmount; - private final long discountAmount; - private final long paymentAmount; - private final LocalDateTime occurredAt; + private Long orderId; + private String userId; + private long totalAmount; + private long discountAmount; + private long paymentAmount; + private List items; + private LocalDateTime occurredAt; + + protected OrderCompletedEvent() { + } private OrderCompletedEvent(Order order) { this.orderId = order.getId(); @@ -19,6 +24,9 @@ private OrderCompletedEvent(Order order) { this.totalAmount = order.getTotalAmount(); this.discountAmount = order.getDiscountAmount(); this.paymentAmount = order.getPaymentAmount(); + this.items = order.getOrderItems().stream() + .map(item -> new OrderItemInfo(item.getProductId(), item.getQuantity())) + .toList(); this.occurredAt = LocalDateTime.now(); } @@ -46,7 +54,32 @@ public long getPaymentAmount() { return paymentAmount; } + public List getItems() { + return items; + } + public LocalDateTime getOccurredAt() { return occurredAt; } + + public static class OrderItemInfo { + private Long productId; + private Long quantity; + + protected OrderItemInfo() { + } + + public OrderItemInfo(Long productId, Long quantity) { + this.productId = productId; + this.quantity = quantity; + } + + public Long getProductId() { + return productId; + } + + public Long getQuantity() { + return quantity; + } + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductViewLog.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductViewLog.java new file mode 100644 index 000000000..88b2039ab --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductViewLog.java @@ -0,0 +1,57 @@ +package com.loopers.domain.product; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "product_view_logs", indexes = { + @Index(name = "idx_product_id_created_at", columnList = "product_id, created_at") +}) +public class ProductViewLog { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + protected ProductViewLog() { + } + + private ProductViewLog(Long productId) { + this.productId = productId; + } + + public static ProductViewLog create(Long productId) { + return new ProductViewLog(productId); + } + + @PrePersist + private void prePersist() { + this.createdAt = LocalDateTime.now(); + } + + public Long getId() { + return id; + } + + public Long getProductId() { + return productId; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductViewLogRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductViewLogRepository.java new file mode 100644 index 000000000..c9f5467a9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductViewLogRepository.java @@ -0,0 +1,8 @@ +package com.loopers.domain.product; + +import java.util.List; + +public interface ProductViewLogRepository { + + List saveAll(List logs); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/event/ProductViewedEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/event/ProductViewedEvent.java new file mode 100644 index 000000000..f0178b6db --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/event/ProductViewedEvent.java @@ -0,0 +1,29 @@ +package com.loopers.domain.product.event; + +import java.time.LocalDateTime; + +public class ProductViewedEvent { + + private Long productId; + private LocalDateTime occurredAt; + + protected ProductViewedEvent() { + } + + private ProductViewedEvent(Long productId) { + this.productId = productId; + this.occurredAt = LocalDateTime.now(); + } + + public static ProductViewedEvent of(Long productId) { + return new ProductViewedEvent(productId); + } + + public Long getProductId() { + return productId; + } + + public LocalDateTime getOccurredAt() { + return occurredAt; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/event/StockDepletedEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/event/StockDepletedEvent.java index e959c69d3..cc8d2d439 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/event/StockDepletedEvent.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/event/StockDepletedEvent.java @@ -4,8 +4,11 @@ public class StockDepletedEvent { - private final Long productId; - private final LocalDateTime occurredAt; + private Long productId; + private LocalDateTime occurredAt; + + protected StockDepletedEvent() { + } private StockDepletedEvent(Long productId) { this.productId = productId; diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/consumer/CatalogEventConsumer.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/consumer/CatalogEventConsumer.java deleted file mode 100644 index 4e252ffbe..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/consumer/CatalogEventConsumer.java +++ /dev/null @@ -1,108 +0,0 @@ -package com.loopers.infrastructure.consumer; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.loopers.infrastructure.cache.ProductCacheService; -import com.loopers.infrastructure.idempotent.EventHandled; -import com.loopers.infrastructure.idempotent.EventHandledRepository; -import com.loopers.infrastructure.metrics.ProductMetrics; -import com.loopers.infrastructure.metrics.ProductMetricsRepository; -import com.loopers.infrastructure.outbox.OutboxRelay; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.apache.kafka.clients.consumer.ConsumerRecord; -import org.apache.kafka.common.header.Header; -import org.springframework.kafka.annotation.KafkaListener; -import org.springframework.kafka.support.Acknowledgment; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -import java.nio.charset.StandardCharsets; -import java.time.LocalDateTime; - -@Slf4j -@Component -@RequiredArgsConstructor -public class CatalogEventConsumer { - - private final EventHandledRepository eventHandledRepository; - private final ProductMetricsRepository productMetricsRepository; - private final ProductCacheService productCacheService; - private final ObjectMapper objectMapper; - - @KafkaListener(topics = "catalog-events", groupId = "catalog-consumer") - @Transactional - public void consume(ConsumerRecord record, Acknowledgment acknowledgment) { - String eventId = extractEventId(record); - if (eventId == null) { - log.warn("outbox-id 헤더가 없는 메시지 수신: {}", record); - acknowledgment.acknowledge(); - return; - } - - if (eventHandledRepository.existsByEventId(eventId)) { - log.info("이미 처리된 이벤트 무시: eventId={}", eventId); - acknowledgment.acknowledge(); - return; - } - - try { - String eventType = extractEventType(record); - processEvent(eventType, record.value()); - eventHandledRepository.save(EventHandled.create(eventId)); - acknowledgment.acknowledge(); - log.info("이벤트 처리 완료: eventId={}, eventType={}", eventId, eventType); - } catch (Exception e) { - log.error("이벤트 처리 실패, 재처리 예정: eventId={}", eventId, e); - } - } - - private String extractEventId(ConsumerRecord record) { - Header header = record.headers().lastHeader(OutboxRelay.HEADER_OUTBOX_ID); - if (header == null) { - return null; - } - return new String(header.value(), StandardCharsets.UTF_8); - } - - private String extractEventType(ConsumerRecord record) { - Header header = record.headers().lastHeader(OutboxRelay.HEADER_EVENT_TYPE); - if (header == null) { - return "Unknown"; - } - return new String(header.value(), StandardCharsets.UTF_8); - } - - private void processEvent(String eventType, String payload) { - try { - JsonNode node = objectMapper.readTree(payload); - Long productId = node.get("productId").asLong(); - - switch (eventType) { - case "ProductLikedEvent" -> processProductLikedEvent(node, productId); - case "StockDepletedEvent" -> processStockDepletedEvent(productId); - default -> log.warn("알 수 없는 이벤트 타입: {}", eventType); - } - } catch (Exception e) { - throw new RuntimeException("이벤트 파싱 실패", e); - } - } - - private void processProductLikedEvent(JsonNode node, Long productId) { - boolean liked = node.get("liked").asBoolean(); - LocalDateTime occurredAt = LocalDateTime.parse(node.get("occurredAt").asText()); - - ProductMetrics metrics = productMetricsRepository.findByProductId(productId) - .orElseGet(() -> ProductMetrics.create(productId)); - - if (metrics.updateLikeIfNewer(liked, occurredAt)) { - productMetricsRepository.save(metrics); - } - } - - private void processStockDepletedEvent(Long productId) { - productCacheService.deleteProductDetail(productId); - productCacheService.invalidateProductListCaches(); - log.info("재고 소진으로 캐시 무효화: productId={}", productId); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/consumer/OrderCompletedConsumer.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/consumer/OrderCompletedConsumer.java new file mode 100644 index 000000000..2556abb0f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/consumer/OrderCompletedConsumer.java @@ -0,0 +1,56 @@ +package com.loopers.infrastructure.consumer; + +import com.loopers.domain.order.event.OrderCompletedEvent; +import com.loopers.infrastructure.idempotent.EventHandled; +import com.loopers.infrastructure.idempotent.EventHandledRepository; +import com.loopers.infrastructure.ranking.RankingRedisService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.messaging.handler.annotation.Header; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Component +@RequiredArgsConstructor +public class OrderCompletedConsumer { + + private final EventHandledRepository eventHandledRepository; + private final RankingRedisService rankingRedisService; + + @KafkaListener(topics = "order-events", groupId = "order-completed-consumer") + @Transactional + public void consume( + @Payload OrderCompletedEvent event, + @Header("outbox-id") String eventId, + Acknowledgment ack + ) { + if (eventHandledRepository.existsByEventId(eventId)) { + log.info("이미 처리된 이벤트 무시: eventId={}", eventId); + ack.acknowledge(); + return; + } + + try { + processOrderCompletedEvent(event); + eventHandledRepository.save(EventHandled.create(eventId)); + ack.acknowledge(); + log.info("주문 완료 이벤트 처리 완료: eventId={}, orderId={}", eventId, event.getOrderId()); + } catch (Exception e) { + log.error("주문 완료 이벤트 처리 실패, 재처리 예정: eventId={}", eventId, e); + } + } + + private void processOrderCompletedEvent(OrderCompletedEvent event) { + for (OrderCompletedEvent.OrderItemInfo item : event.getItems()) { + rankingRedisService.incrementScoreForOrder( + event.getOccurredAt().toLocalDate(), + item.getProductId(), + item.getQuantity() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/consumer/ProductLikedConsumer.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/consumer/ProductLikedConsumer.java new file mode 100644 index 000000000..0bbc0eac1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/consumer/ProductLikedConsumer.java @@ -0,0 +1,62 @@ +package com.loopers.infrastructure.consumer; + +import com.loopers.domain.like.event.ProductLikedEvent; +import com.loopers.infrastructure.idempotent.EventHandled; +import com.loopers.infrastructure.idempotent.EventHandledRepository; +import com.loopers.infrastructure.metrics.ProductMetrics; +import com.loopers.infrastructure.metrics.ProductMetricsRepository; +import com.loopers.infrastructure.ranking.RankingRedisService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.messaging.handler.annotation.Header; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ProductLikedConsumer { + + private final EventHandledRepository eventHandledRepository; + private final ProductMetricsRepository productMetricsRepository; + private final RankingRedisService rankingRedisService; + + @KafkaListener(topics = "product-liked", groupId = "product-liked-consumer") + @Transactional + public void consume( + @Payload ProductLikedEvent event, + @Header("outbox-id") String eventId, + Acknowledgment ack + ) { + if (eventHandledRepository.existsByEventId(eventId)) { + log.info("이미 처리된 이벤트 무시: eventId={}", eventId); + ack.acknowledge(); + return; + } + + try { + processProductLikedEvent(event); + eventHandledRepository.save(EventHandled.create(eventId)); + ack.acknowledge(); + log.info("좋아요 이벤트 처리 완료: eventId={}, productId={}", eventId, event.getProductId()); + } catch (Exception e) { + log.error("좋아요 이벤트 처리 실패, 재처리 예정: eventId={}", eventId, e); + } + } + + private void processProductLikedEvent(ProductLikedEvent event) { + ProductMetrics metrics = productMetricsRepository.findByProductId(event.getProductId()) + .orElseGet(() -> ProductMetrics.create(event.getProductId())); + + if (metrics.updateLikeIfNewer(event.isLiked(), event.getOccurredAt())) { + productMetricsRepository.save(metrics); + } + + if (event.isLiked()) { + rankingRedisService.incrementScoreForLike(event.getOccurredAt().toLocalDate(), event.getProductId()); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/consumer/StockDepletedConsumer.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/consumer/StockDepletedConsumer.java new file mode 100644 index 000000000..39c977509 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/consumer/StockDepletedConsumer.java @@ -0,0 +1,52 @@ +package com.loopers.infrastructure.consumer; + +import com.loopers.domain.product.event.StockDepletedEvent; +import com.loopers.infrastructure.cache.ProductCacheService; +import com.loopers.infrastructure.idempotent.EventHandled; +import com.loopers.infrastructure.idempotent.EventHandledRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.messaging.handler.annotation.Header; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Component +@RequiredArgsConstructor +public class StockDepletedConsumer { + + private final EventHandledRepository eventHandledRepository; + private final ProductCacheService productCacheService; + + @KafkaListener(topics = "stock-depleted", groupId = "stock-depleted-consumer") + @Transactional + public void consume( + @Payload StockDepletedEvent event, + @Header("outbox-id") String eventId, + Acknowledgment ack + ) { + if (eventHandledRepository.existsByEventId(eventId)) { + log.info("이미 처리된 이벤트 무시: eventId={}", eventId); + ack.acknowledge(); + return; + } + + try { + processStockDepletedEvent(event); + eventHandledRepository.save(EventHandled.create(eventId)); + ack.acknowledge(); + log.info("재고 소진 이벤트 처리 완료: eventId={}, productId={}", eventId, event.getProductId()); + } catch (Exception e) { + log.error("재고 소진 이벤트 처리 실패, 재처리 예정: eventId={}", eventId, e); + } + } + + private void processStockDepletedEvent(StockDepletedEvent event) { + productCacheService.deleteProductDetail(event.getProductId()); + productCacheService.invalidateProductListCaches(); + log.info("재고 소진으로 캐시 무효화: productId={}", event.getProductId()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/consumer/ViewLogConsumer.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/consumer/ViewLogConsumer.java new file mode 100644 index 000000000..9f3663bd2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/consumer/ViewLogConsumer.java @@ -0,0 +1,66 @@ +package com.loopers.infrastructure.consumer; + +import com.loopers.confg.kafka.KafkaConfig; +import com.loopers.domain.product.ProductViewLog; +import com.loopers.domain.product.ProductViewLogRepository; +import com.loopers.domain.product.event.ProductViewedEvent; +import com.loopers.infrastructure.ranking.RankingRedisService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ViewLogConsumer { + + private final ProductViewLogRepository viewLogRepository; + private final RankingRedisService rankingRedisService; + + @KafkaListener( + topics = "product-viewed", + groupId = "view-log-consumer", + containerFactory = KafkaConfig.BATCH_LISTENER + ) + public void consumeBatch(List events, Acknowledgment ack) { + if (events.isEmpty()) { + ack.acknowledge(); + return; + } + + try { + // 1. MySQL 저장 (원본 보존) + List logs = events.stream() + .map(event -> ProductViewLog.create(event.getProductId())) + .toList(); + viewLogRepository.saveAll(logs); + + // 2. Redis 업데이트 (상품별로 그룹핑해서 한 번에) + LocalDate today = LocalDate.now(); + Map viewCountByProduct = events.stream() + .collect(Collectors.groupingBy( + ProductViewedEvent::getProductId, + Collectors.counting() + )); + + for (Map.Entry entry : viewCountByProduct.entrySet()) { + Long productId = entry.getKey(); + int count = entry.getValue().intValue(); + rankingRedisService.incrementScoreForView(today, productId, count); + } + + ack.acknowledge(); + log.info("조회 로그 배치 처리 완료: {}건, 상품 {}종", events.size(), viewCountByProduct.size()); + } catch (Exception e) { + log.error("조회 로그 배치 처리 실패: {}건", events.size(), e); + // ack 안 하면 재처리됨 + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/ViewEventPublisher.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/ViewEventPublisher.java new file mode 100644 index 000000000..cc3c19a0a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/ViewEventPublisher.java @@ -0,0 +1,27 @@ +package com.loopers.infrastructure.event; + +import com.loopers.domain.product.event.ProductViewedEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ViewEventPublisher { + + private static final String TOPIC = "product-viewed"; + + private final KafkaTemplate kafkaTemplate; + + public void publish(Long productId) { + try { + ProductViewedEvent event = ProductViewedEvent.of(productId); + kafkaTemplate.send(TOPIC, String.valueOf(productId), event); + log.debug("조회 이벤트 발행: productId={}", productId); + } catch (Exception e) { + log.warn("조회 이벤트 발행 실패: productId={}, error={}", productId, e.getMessage()); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventHandler.java index 59cc31d7a..4d74485a2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventHandler.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventHandler.java @@ -23,7 +23,7 @@ public void handleProductLikedEvent(ProductLikedEvent event) { "PRODUCT", event.getProductId().toString(), "ProductLikedEvent", - "catalog-events", + "product-liked", toJson(event) ); outboxRepository.save(outbox); @@ -47,7 +47,7 @@ public void handleStockDepletedEvent(StockDepletedEvent event) { "PRODUCT", event.getProductId().toString(), "StockDepletedEvent", - "catalog-events", + "stock-depleted", toJson(event) ); outboxRepository.save(outbox); diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductViewLogJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductViewLogJpaRepository.java new file mode 100644 index 000000000..5f52be5a2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductViewLogJpaRepository.java @@ -0,0 +1,7 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.ProductViewLog; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProductViewLogJpaRepository extends JpaRepository { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductViewLogRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductViewLogRepositoryImpl.java new file mode 100644 index 000000000..e6dd7f1af --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductViewLogRepositoryImpl.java @@ -0,0 +1,20 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.ProductViewLog; +import com.loopers.domain.product.ProductViewLogRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; + +@RequiredArgsConstructor +@Component +public class ProductViewLogRepositoryImpl implements ProductViewLogRepository { + + private final ProductViewLogJpaRepository productViewLogJpaRepository; + + @Override + public List saveAll(List logs) { + return productViewLogJpaRepository.saveAll(logs); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingEntry.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingEntry.java new file mode 100644 index 000000000..e5f85f6e3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingEntry.java @@ -0,0 +1,7 @@ +package com.loopers.infrastructure.ranking; + +public record RankingEntry( + Long productId, + Double score +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRedisService.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRedisService.java new file mode 100644 index 000000000..e8cf90c85 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRedisService.java @@ -0,0 +1,94 @@ +package com.loopers.infrastructure.ranking; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +@Service +@RequiredArgsConstructor +public class RankingRedisService { + + private static final String KEY_PREFIX = "ranking:all:"; + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + private static final long TTL_SECONDS = 172800; // 2일 + + private static final String ZINCRBY_WITH_EXPIRE_SCRIPT = """ + redis.call('ZINCRBY', KEYS[1], ARGV[1], ARGV[2]) + redis.call('EXPIRE', KEYS[1], ARGV[3]) + return 1 + """; + + private final RedisTemplate redisTemplate; + + private static final double VIEW_SCORE = 0.1; + private static final double LIKE_SCORE = 0.2; + private static final double ORDER_SCORE = 0.6; + + public List getTopProducts(LocalDate date, int offset, int limit) { + String key = generateKey(date); + + Set> tuples = redisTemplate.opsForZSet() + .reverseRangeWithScores(key, offset, offset + limit - 1); + + List entries = new ArrayList<>(); + if (tuples != null) { + for (ZSetOperations.TypedTuple tuple : tuples) { + Long productId = Long.parseLong(tuple.getValue()); + Double score = tuple.getScore(); + entries.add(new RankingEntry(productId, score)); + } + } + return entries; + } + + public void incrementScoreForView(LocalDate date, Long productId) { + incrementScoreForView(date, productId, 1); + } + + public void incrementScoreForView(LocalDate date, Long productId, int count) { + String key = generateKey(date); + executeIncrementWithExpire(key, String.valueOf(productId), VIEW_SCORE * count); + } + + public void incrementScoreForLike(LocalDate date, Long productId) { + String key = generateKey(date); + executeIncrementWithExpire(key, String.valueOf(productId), LIKE_SCORE); + } + + public void incrementScoreForOrder(LocalDate date, Long productId, Long quantity) { + String key = generateKey(date); + executeIncrementWithExpire(key, String.valueOf(productId), ORDER_SCORE * quantity); + } + + public Long getRankingPosition(LocalDate date, Long productId) { + String key = generateKey(date); + Long rank = redisTemplate.opsForZSet().reverseRank(key, String.valueOf(productId)); + if (rank == null) { + return null; + } + return rank + 1; + } + + public long getTotalCount(LocalDate date) { + String key = generateKey(date); + Long count = redisTemplate.opsForZSet().zCard(key); + return count != null ? count : 0; + } + + private void executeIncrementWithExpire(String key, String member, double score) { + DefaultRedisScript script = new DefaultRedisScript<>(ZINCRBY_WITH_EXPIRE_SCRIPT, Long.class); + redisTemplate.execute(script, List.of(key), String.valueOf(score), member, String.valueOf(TTL_SECONDS)); + } + + private String generateKey(LocalDate date) { + return KEY_PREFIX + date.format(DATE_FORMATTER); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductDto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductDto.java index 6df65a993..e3b9a0b23 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductDto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductDto.java @@ -53,9 +53,10 @@ public record ProductDetailResponse( Long stock, Long totalLikes, BrandSummary brand, - Boolean isLiked + Boolean isLiked, + Long rank ) { - public static ProductDetailResponse from(Product product, Brand brand, Boolean isLiked) { + public static ProductDetailResponse from(Product product, Brand brand, Boolean isLiked, Long rank) { return new ProductDetailResponse( product.getId(), product.getName(), @@ -64,12 +65,12 @@ public static ProductDetailResponse from(Product product, Brand brand, Boolean i product.getStock(), product.getTotalLikes(), BrandSummary.from(brand), - isLiked + isLiked, + rank ); } - // 캐시에서 복원 (id 제외) - public static ProductDetailResponse from(Long productId, ProductDetailCache cache) { + public static ProductDetailResponse from(Long productId, ProductDetailCache cache, Long rank) { return new ProductDetailResponse( productId, cache.getName(), @@ -78,7 +79,8 @@ public static ProductDetailResponse from(Long productId, ProductDetailCache cach cache.getStock(), cache.getTotalLikes(), cache.getBrand(), - null + null, + rank ); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingController.java new file mode 100644 index 000000000..3199805d5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingController.java @@ -0,0 +1,27 @@ +package com.loopers.interfaces.api.ranking; + +import com.loopers.application.ranking.RankingFacade; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/rankings") +public class RankingController { + + private final RankingFacade rankingFacade; + + @GetMapping + public ApiResponse getRankings( + @RequestParam(required = false) String date, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + RankingDto.RankingListResponse response = rankingFacade.getRankings(date, page, size); + return ApiResponse.success(response); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingDto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingDto.java new file mode 100644 index 000000000..1053af016 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingDto.java @@ -0,0 +1,23 @@ +package com.loopers.interfaces.api.ranking; + +import java.util.List; + +public class RankingDto { + + public record RankingListResponse( + List content, + int page, + int size, + long totalElements + ) { + } + + public record RankingResponse( + int rank, + Long productId, + String productName, + Long price, + Double score + ) { + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeRankTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeRankTest.java new file mode 100644 index 000000000..262d2df44 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeRankTest.java @@ -0,0 +1,104 @@ +package com.loopers.application.product; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.ranking.RankingRedisService; +import com.loopers.interfaces.api.product.ProductDto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.redis.core.RedisTemplate; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +class ProductFacadeRankTest { + + @Autowired + private ProductFacade productFacade; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private BrandJpaRepository brandJpaRepository; + + @Autowired + private RankingRedisService rankingRedisService; + + @Autowired + private RedisTemplate redisTemplate; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + private static final LocalDate TODAY = LocalDate.now(); + private static final String TODAY_KEY = "ranking:all:" + TODAY.format(DateTimeFormatter.ofPattern("yyyyMMdd")); + + private Brand brand; + private Product product1; + private Product product2; + + @BeforeEach + void setUp() { + redisTemplate.delete(TODAY_KEY); + + brand = brandJpaRepository.save(Brand.create("테스트브랜드")); + product1 = productRepository.save(Product.create("상품1", "설명1", 10000L, 100L, brand.getId())); + product2 = productRepository.save(Product.create("상품2", "설명2", 20000L, 100L, brand.getId())); + } + + @AfterEach + void tearDown() { + redisTemplate.delete(TODAY_KEY); + databaseCleanUp.truncateAllTables(); + } + + @Test + @DisplayName("랭킹에 있는 상품 조회 시 순위가 반환된다") + void getProductRankTest1() { + // arrange - 상품2가 1위, 상품1이 2위 + rankingRedisService.incrementScoreForOrder(TODAY, product2.getId(), 2L); // 1.2점 + rankingRedisService.incrementScoreForView(TODAY, product1.getId()); // 0.1점 + + // act + ProductDto.ProductDetailResponse response = productFacade.getProduct(product1.getId()); + + // assert + assertThat(response.rank()).isEqualTo(2L); + } + + @Test + @DisplayName("랭킹 1위 상품 조회 시 rank=1이 반환된다") + void getProductRankTest2() { + // arrange - 상품1이 1위 + rankingRedisService.incrementScoreForOrder(TODAY, product1.getId(), 3L); // 1.8점 + + // act + ProductDto.ProductDetailResponse response = productFacade.getProduct(product1.getId()); + + // assert + assertThat(response.rank()).isEqualTo(1L); + } + + @Test + @DisplayName("랭킹에 없는 상품 조회 시 rank가 null이다") + void getProductRankTest3() { + // arrange - 랭킹에 아무것도 없음 + + // act + ProductDto.ProductDetailResponse response = productFacade.getProduct(product1.getId()); + + // assert + assertThat(response.rank()).isNull(); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.java new file mode 100644 index 000000000..39007f48a --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.java @@ -0,0 +1,131 @@ +package com.loopers.application.ranking; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.ranking.RankingRedisService; +import com.loopers.interfaces.api.ranking.RankingDto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.redis.core.RedisTemplate; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +class RankingFacadeTest { + + @Autowired + private RankingFacade rankingFacade; + + @Autowired + private RankingRedisService rankingRedisService; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private BrandJpaRepository brandJpaRepository; + + @Autowired + private RedisTemplate redisTemplate; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + private static final LocalDate TODAY = LocalDate.now(); + private static final String TODAY_KEY = "ranking:all:" + TODAY.format(DateTimeFormatter.ofPattern("yyyyMMdd")); + + private Brand brand; + private Product product1; + private Product product2; + private Product product3; + + @BeforeEach + void setUp() { + redisTemplate.delete(TODAY_KEY); + + brand = brandJpaRepository.save(Brand.create("테스트브랜드")); + product1 = productRepository.save(Product.create("상품1", "설명1", 10000L, 100L, brand.getId())); + product2 = productRepository.save(Product.create("상품2", "설명2", 20000L, 100L, brand.getId())); + product3 = productRepository.save(Product.create("상품3", "설명3", 30000L, 100L, brand.getId())); + } + + @AfterEach + void tearDown() { + redisTemplate.delete(TODAY_KEY); + databaseCleanUp.truncateAllTables(); + } + + @Test + @DisplayName("랭킹 조회 시 점수 높은 순으로 상품 정보와 함께 반환한다") + void getRankingsTest1() { + // arrange + rankingRedisService.incrementScoreForView(TODAY, product1.getId()); // 0.1점 + rankingRedisService.incrementScoreForLike(TODAY, product2.getId()); // 0.2점 + rankingRedisService.incrementScoreForOrder(TODAY, product3.getId(), 1L); // 0.6점 + + // act + RankingDto.RankingListResponse response = rankingFacade.getRankings(null, 0, 20); + + // assert + assertThat(response.content()).hasSize(3); + assertThat(response.content().get(0).productId()).isEqualTo(product3.getId()); // 1위: 0.6점 + assertThat(response.content().get(0).rank()).isEqualTo(1); + assertThat(response.content().get(0).productName()).isEqualTo("상품3"); + assertThat(response.content().get(1).productId()).isEqualTo(product2.getId()); // 2위: 0.2점 + assertThat(response.content().get(2).productId()).isEqualTo(product1.getId()); // 3위: 0.1점 + } + + @Test + @DisplayName("랭킹 조회 시 전체 개수를 반환한다") + void getRankingsTest2() { + // arrange + rankingRedisService.incrementScoreForView(TODAY, product1.getId()); + rankingRedisService.incrementScoreForView(TODAY, product2.getId()); + rankingRedisService.incrementScoreForView(TODAY, product3.getId()); + + // act + RankingDto.RankingListResponse response = rankingFacade.getRankings(null, 0, 2); + + // assert + assertThat(response.content()).hasSize(2); // 페이지 사이즈만큼 + assertThat(response.totalElements()).isEqualTo(3); // 전체 개수 + } + + @Test + @DisplayName("랭킹이 비어있으면 빈 목록을 반환한다") + void getRankingsTest3() { + // act + RankingDto.RankingListResponse response = rankingFacade.getRankings(null, 0, 20); + + // assert + assertThat(response.content()).isEmpty(); + assertThat(response.totalElements()).isEqualTo(0); + } + + @Test + @DisplayName("페이지네이션이 정상 동작한다") + void getRankingsTest4() { + // arrange + rankingRedisService.incrementScoreForOrder(TODAY, product1.getId(), 3L); // 1.8점 - 1위 + rankingRedisService.incrementScoreForOrder(TODAY, product2.getId(), 2L); // 1.2점 - 2위 + rankingRedisService.incrementScoreForOrder(TODAY, product3.getId(), 1L); // 0.6점 - 3위 + + // act - 2페이지 (size=2, page=1) + RankingDto.RankingListResponse response = rankingFacade.getRankings(null, 1, 2); + + // assert + assertThat(response.content()).hasSize(1); // 3번째 상품만 + assertThat(response.content().get(0).productId()).isEqualTo(product3.getId()); + assertThat(response.content().get(0).rank()).isEqualTo(3); // 3위 + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/consumer/CatalogEventConsumerTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/consumer/CatalogEventConsumerTest.java deleted file mode 100644 index afe96e74d..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/infrastructure/consumer/CatalogEventConsumerTest.java +++ /dev/null @@ -1,168 +0,0 @@ -package com.loopers.infrastructure.consumer; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.loopers.infrastructure.cache.ProductCacheService; -import com.loopers.infrastructure.idempotent.EventHandledRepository; -import com.loopers.infrastructure.metrics.ProductMetrics; -import com.loopers.infrastructure.metrics.ProductMetricsRepository; -import org.apache.kafka.clients.consumer.ConsumerRecord; -import org.apache.kafka.common.header.internals.RecordHeaders; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.kafka.support.Acknowledgment; - -import java.nio.charset.StandardCharsets; -import java.util.Optional; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.argThat; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -class CatalogEventConsumerTest { - - @Mock - private EventHandledRepository eventHandledRepository; - - @Mock - private ProductMetricsRepository productMetricsRepository; - - @Mock - private ProductCacheService productCacheService; - - @Mock - private Acknowledgment acknowledgment; - - private CatalogEventConsumer consumer; - private ObjectMapper objectMapper; - - @BeforeEach - void setUp() { - objectMapper = new ObjectMapper(); - consumer = new CatalogEventConsumer(eventHandledRepository, productMetricsRepository, productCacheService, objectMapper); - } - - @Test - @DisplayName("좋아요 이벤트 수신 시 ProductMetrics의 likeCount를 증가시킨다") - void consumeTest1() { - String payload = "{\"productId\":1,\"userId\":100,\"liked\":true,\"occurredAt\":\"2024-01-01T10:00:00\"}"; - ConsumerRecord record = createRecord("1", payload, "100"); - when(eventHandledRepository.existsByEventId("100")).thenReturn(false); - when(productMetricsRepository.findByProductId(1L)).thenReturn(Optional.empty()); - - consumer.consume(record, acknowledgment); - - verify(eventHandledRepository).save(argThat(e -> e.getEventId().equals("100"))); - verify(productMetricsRepository).save(argThat(m -> m.getProductId().equals(1L) && m.getLikeCount() == 1L)); - verify(acknowledgment).acknowledge(); - } - - @Test - @DisplayName("좋아요 취소 이벤트 수신 시 ProductMetrics의 likeCount를 감소시킨다") - void consumeTest2() { - String payload = "{\"productId\":1,\"userId\":100,\"liked\":false,\"occurredAt\":\"2024-01-01T12:00:00\"}"; - ConsumerRecord record = createRecord("1", payload, "101"); - ProductMetrics existingMetrics = ProductMetrics.create(1L); - existingMetrics.updateLikeIfNewer(true, java.time.LocalDateTime.of(2024, 1, 1, 10, 0)); - existingMetrics.updateLikeIfNewer(true, java.time.LocalDateTime.of(2024, 1, 1, 11, 0)); - - when(eventHandledRepository.existsByEventId("101")).thenReturn(false); - when(productMetricsRepository.findByProductId(1L)).thenReturn(Optional.of(existingMetrics)); - - consumer.consume(record, acknowledgment); - - verify(productMetricsRepository).save(argThat(m -> m.getLikeCount() == 1L)); - verify(acknowledgment).acknowledge(); - } - - @Test - @DisplayName("이미 처리된 이벤트는 무시하고 ack만 한다") - void consumeTest3() { - String payload = "{\"productId\":1,\"userId\":100,\"liked\":true}"; - ConsumerRecord record = createRecord("1", payload, "100"); - when(eventHandledRepository.existsByEventId("100")).thenReturn(true); - - consumer.consume(record, acknowledgment); - - verify(productMetricsRepository, never()).save(any()); - verify(eventHandledRepository, never()).save(any()); - verify(acknowledgment).acknowledge(); - } - - @Test - @DisplayName("이전 이벤트보다 오래된 이벤트는 무시한다") - void consumeTest4() { - String newPayload = "{\"productId\":1,\"userId\":100,\"liked\":true,\"occurredAt\":\"2024-01-01T11:00:00\"}"; - String oldPayload = "{\"productId\":1,\"userId\":100,\"liked\":true,\"occurredAt\":\"2024-01-01T10:00:00\"}"; - ConsumerRecord newRecord = createRecord("1", newPayload, "100"); - ConsumerRecord oldRecord = createRecord("1", oldPayload, "101"); - - ProductMetrics metrics = ProductMetrics.create(1L); - metrics.updateLikeIfNewer(true, java.time.LocalDateTime.of(2024, 1, 1, 11, 0)); - - when(eventHandledRepository.existsByEventId("100")).thenReturn(false); - when(eventHandledRepository.existsByEventId("101")).thenReturn(false); - when(productMetricsRepository.findByProductId(1L)) - .thenReturn(Optional.empty()) - .thenReturn(Optional.of(metrics)); - - consumer.consume(newRecord, acknowledgment); - consumer.consume(oldRecord, acknowledgment); - - verify(productMetricsRepository, times(1)).save(any()); - } - - @Test - @DisplayName("재고 소진 이벤트 수신 시 상품 캐시를 무효화한다") - void consumeTest5() { - String payload = "{\"productId\":1,\"occurredAt\":\"2024-01-01T10:00:00\"}"; - ConsumerRecord record = createRecord("1", payload, "200", "StockDepletedEvent"); - when(eventHandledRepository.existsByEventId("200")).thenReturn(false); - - consumer.consume(record, acknowledgment); - - verify(productCacheService).deleteProductDetail(1L); - verify(productCacheService).invalidateProductListCaches(); - verify(eventHandledRepository).save(argThat(e -> e.getEventId().equals("200"))); - verify(acknowledgment).acknowledge(); - } - - @Test - @DisplayName("동일 메시지 재전송 시 최종 결과는 한 번만 반영된다") - void consumeTest6() { - String payload = "{\"productId\":1,\"userId\":100,\"liked\":true,\"occurredAt\":\"2024-01-01T10:00:00\"}"; - ConsumerRecord firstRecord = createRecord("1:100", payload, "300"); - ConsumerRecord duplicateRecord = createRecord("1:100", payload, "300"); - - when(eventHandledRepository.existsByEventId("300")) - .thenReturn(false) - .thenReturn(true); - when(productMetricsRepository.findByProductId(1L)).thenReturn(Optional.empty()); - - consumer.consume(firstRecord, acknowledgment); - consumer.consume(duplicateRecord, acknowledgment); - - verify(eventHandledRepository, times(1)).save(any()); - verify(productMetricsRepository, times(1)).save(argThat(m -> - m.getProductId().equals(1L) && m.getLikeCount() == 1L)); - verify(acknowledgment, times(2)).acknowledge(); - } - - private ConsumerRecord createRecord(String key, String value, String outboxId) { - return createRecord(key, value, outboxId, "ProductLikedEvent"); - } - - private ConsumerRecord createRecord(String key, String value, String outboxId, String eventType) { - RecordHeaders headers = new RecordHeaders(); - headers.add("outbox-id", outboxId.getBytes(StandardCharsets.UTF_8)); - headers.add("event-type", eventType.getBytes(StandardCharsets.UTF_8)); - return new ConsumerRecord<>("catalog-events", 0, 0, 0L, null, 0, 0, key, value, headers, Optional.empty()); - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/consumer/OrderCompletedConsumerTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/consumer/OrderCompletedConsumerTest.java new file mode 100644 index 000000000..faebcc12d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/consumer/OrderCompletedConsumerTest.java @@ -0,0 +1,82 @@ +package com.loopers.infrastructure.consumer; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderItem; +import com.loopers.domain.order.event.OrderCompletedEvent; +import com.loopers.infrastructure.idempotent.EventHandledRepository; +import com.loopers.infrastructure.ranking.RankingRedisService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDate; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class OrderCompletedConsumerTest { + + @Mock + private EventHandledRepository eventHandledRepository; + + @Mock + private RankingRedisService rankingRedisService; + + @Mock + private Acknowledgment acknowledgment; + + private OrderCompletedConsumer consumer; + + @BeforeEach + void setUp() { + consumer = new OrderCompletedConsumer(eventHandledRepository, rankingRedisService); + } + + @Test + @DisplayName("주문 완료 이벤트 수신 시 주문한 상품들의 랭킹 점수를 올린다") + void consumeTest1() { + OrderItem item1 = OrderItem.create(1L, "상품1", 2L, 10000); + OrderItem item2 = OrderItem.create(2L, "상품2", 3L, 20000); + Order order = Order.create("user1", List.of(item1, item2), 80000); + ReflectionTestUtils.setField(order, "id", 100L); + + OrderCompletedEvent event = OrderCompletedEvent.from(order); + when(eventHandledRepository.existsByEventId("300")).thenReturn(false); + + consumer.consume(event, "300", acknowledgment); + + verify(rankingRedisService).incrementScoreForOrder(any(LocalDate.class), eq(1L), eq(2L)); + verify(rankingRedisService).incrementScoreForOrder(any(LocalDate.class), eq(2L), eq(3L)); + verify(eventHandledRepository).save(argThat(e -> e.getEventId().equals("300"))); + verify(acknowledgment).acknowledge(); + } + + @Test + @DisplayName("이미 처리된 이벤트는 무시하고 ack만 한다") + void consumeTest2() { + OrderItem item = OrderItem.create(1L, "상품1", 1L, 10000); + Order order = Order.create("user1", List.of(item), 10000); + ReflectionTestUtils.setField(order, "id", 100L); + + OrderCompletedEvent event = OrderCompletedEvent.from(order); + when(eventHandledRepository.existsByEventId("300")).thenReturn(true); + + consumer.consume(event, "300", acknowledgment); + + verify(rankingRedisService, never()).incrementScoreForOrder(any(), any(), any(Long.class)); + verify(eventHandledRepository, never()).save(any()); + verify(acknowledgment).acknowledge(); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/consumer/ProductLikedConsumerTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/consumer/ProductLikedConsumerTest.java new file mode 100644 index 000000000..5c65803f6 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/consumer/ProductLikedConsumerTest.java @@ -0,0 +1,92 @@ +package com.loopers.infrastructure.consumer; + +import com.loopers.domain.like.event.ProductLikedEvent; +import com.loopers.infrastructure.idempotent.EventHandledRepository; +import com.loopers.infrastructure.metrics.ProductMetrics; +import com.loopers.infrastructure.metrics.ProductMetricsRepository; +import com.loopers.infrastructure.ranking.RankingRedisService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.kafka.support.Acknowledgment; + +import java.time.LocalDate; +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ProductLikedConsumerTest { + + @Mock + private EventHandledRepository eventHandledRepository; + + @Mock + private ProductMetricsRepository productMetricsRepository; + + @Mock + private RankingRedisService rankingRedisService; + + @Mock + private Acknowledgment acknowledgment; + + private ProductLikedConsumer consumer; + + @BeforeEach + void setUp() { + consumer = new ProductLikedConsumer(eventHandledRepository, productMetricsRepository, rankingRedisService); + } + + @Test + @DisplayName("좋아요 이벤트 수신 시 ProductMetrics의 likeCount를 증가시키고 랭킹 점수를 올린다") + void consumeTest1() { + ProductLikedEvent event = ProductLikedEvent.liked(1L, 100L); + when(eventHandledRepository.existsByEventId("100")).thenReturn(false); + when(productMetricsRepository.findByProductId(1L)).thenReturn(Optional.empty()); + + consumer.consume(event, "100", acknowledgment); + + verify(eventHandledRepository).save(argThat(e -> e.getEventId().equals("100"))); + verify(productMetricsRepository).save(argThat(m -> m.getProductId().equals(1L) && m.getLikeCount() == 1L)); + verify(rankingRedisService).incrementScoreForLike(any(LocalDate.class), argThat(id -> id.equals(1L))); + verify(acknowledgment).acknowledge(); + } + + @Test + @DisplayName("좋아요 취소 이벤트 수신 시 ProductMetrics의 likeCount를 감소시키지만 랭킹 점수는 변경하지 않는다") + void consumeTest2() { + ProductLikedEvent event = ProductLikedEvent.unliked(1L, 100L); + ProductMetrics existingMetrics = ProductMetrics.create(1L); + existingMetrics.updateLikeIfNewer(true, java.time.LocalDateTime.of(2024, 1, 1, 10, 0)); + existingMetrics.updateLikeIfNewer(true, java.time.LocalDateTime.of(2024, 1, 1, 11, 0)); + + when(eventHandledRepository.existsByEventId("101")).thenReturn(false); + when(productMetricsRepository.findByProductId(1L)).thenReturn(Optional.of(existingMetrics)); + + consumer.consume(event, "101", acknowledgment); + + verify(productMetricsRepository).save(argThat(m -> m.getLikeCount() == 1L)); + verify(rankingRedisService, never()).incrementScoreForLike(any(), any()); + verify(acknowledgment).acknowledge(); + } + + @Test + @DisplayName("이미 처리된 이벤트는 무시하고 ack만 한다") + void consumeTest3() { + ProductLikedEvent event = ProductLikedEvent.liked(1L, 100L); + when(eventHandledRepository.existsByEventId("100")).thenReturn(true); + + consumer.consume(event, "100", acknowledgment); + + verify(productMetricsRepository, never()).save(any()); + verify(eventHandledRepository, never()).save(any()); + verify(acknowledgment).acknowledge(); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/consumer/StockDepletedConsumerTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/consumer/StockDepletedConsumerTest.java new file mode 100644 index 000000000..043027eea --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/consumer/StockDepletedConsumerTest.java @@ -0,0 +1,65 @@ +package com.loopers.infrastructure.consumer; + +import com.loopers.domain.product.event.StockDepletedEvent; +import com.loopers.infrastructure.cache.ProductCacheService; +import com.loopers.infrastructure.idempotent.EventHandledRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.kafka.support.Acknowledgment; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class StockDepletedConsumerTest { + + @Mock + private EventHandledRepository eventHandledRepository; + + @Mock + private ProductCacheService productCacheService; + + @Mock + private Acknowledgment acknowledgment; + + private StockDepletedConsumer consumer; + + @BeforeEach + void setUp() { + consumer = new StockDepletedConsumer(eventHandledRepository, productCacheService); + } + + @Test + @DisplayName("재고 소진 이벤트 수신 시 상품 캐시를 무효화한다") + void consumeTest1() { + StockDepletedEvent event = StockDepletedEvent.of(1L); + when(eventHandledRepository.existsByEventId("200")).thenReturn(false); + + consumer.consume(event, "200", acknowledgment); + + verify(productCacheService).deleteProductDetail(1L); + verify(productCacheService).invalidateProductListCaches(); + verify(eventHandledRepository).save(argThat(e -> e.getEventId().equals("200"))); + verify(acknowledgment).acknowledge(); + } + + @Test + @DisplayName("이미 처리된 이벤트는 무시하고 ack만 한다") + void consumeTest2() { + StockDepletedEvent event = StockDepletedEvent.of(1L); + when(eventHandledRepository.existsByEventId("200")).thenReturn(true); + + consumer.consume(event, "200", acknowledgment); + + verify(productCacheService, never()).deleteProductDetail(any()); + verify(eventHandledRepository, never()).save(any()); + verify(acknowledgment).acknowledge(); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventHandlerTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventHandlerTest.java index 3e0526e21..af31b1167 100644 --- a/apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventHandlerTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventHandlerTest.java @@ -48,7 +48,7 @@ void handleProductLikedEventTest1() { assertThat(saved.getAggregateType()).isEqualTo("PRODUCT"); assertThat(saved.getAggregateId()).isEqualTo("1"); assertThat(saved.getEventType()).isEqualTo("ProductLikedEvent"); - assertThat(saved.getTopic()).isEqualTo("catalog-events"); + assertThat(saved.getTopic()).isEqualTo("product-liked"); assertThat(saved.getStatus()).isEqualTo(OutboxStatus.PENDING); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/ranking/RankingRedisServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/ranking/RankingRedisServiceTest.java new file mode 100644 index 000000000..83a0cfe9a --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/ranking/RankingRedisServiceTest.java @@ -0,0 +1,165 @@ +package com.loopers.infrastructure.ranking; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.redis.core.RedisTemplate; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +@SpringBootTest +class RankingRedisServiceTest { + + @Autowired + private RedisTemplate redisTemplate; + + @Autowired + private RankingRedisService rankingRedisService; + + private static final LocalDate TODAY = LocalDate.now(); + private static final String KEY = "ranking:all:" + TODAY.format(DateTimeFormatter.ofPattern("yyyyMMdd")); + + @BeforeEach + void setUp() { + redisTemplate.delete(KEY); + } + + @Test + @DisplayName("상위 N개 상품을 점수 높은 순으로 조회한다") + void getTopProducts() { + // arrange + redisTemplate.opsForZSet().add(KEY, "1", 3.0); // 상품1: 3점 + redisTemplate.opsForZSet().add(KEY, "2", 5.0); // 상품2: 5점 + redisTemplate.opsForZSet().add(KEY, "3", 1.0); // 상품3: 1점 + + // act + List result = rankingRedisService.getTopProducts(TODAY, 0, 3); + + // assert + // 예상 순서: 상품2(5점) > 상품1(3점) > 상품3(1점) + assertThat(result).hasSize(3); + assertThat(result.get(0).productId()).isEqualTo(2L); + assertThat(result.get(0).score()).isEqualTo(5.0); + assertThat(result.get(1).productId()).isEqualTo(1L); + assertThat(result.get(2).productId()).isEqualTo(3L); + } + + @Test + @DisplayName("상품 조회 시 0.1점을 증가시킨다") + void incrementScoreForView() { + // arrange - 아무 데이터 없는 상태 + + // act + rankingRedisService.incrementScoreForView(TODAY, 1L); + + // assert + Double score = redisTemplate.opsForZSet().score(KEY, "1"); + assertThat(score).isEqualTo(0.1); + } + + @Test + @DisplayName("상품 조회 시 기존 점수에 0.1점을 누적한다") + void incrementScoreForView_accumulates() { + // arrange - 이미 3.0점 있는 상태 + redisTemplate.opsForZSet().add(KEY, "1", 3.0); + + // act + rankingRedisService.incrementScoreForView(TODAY, 1L); + + // assert + Double score = redisTemplate.opsForZSet().score(KEY, "1"); + assertThat(score).isEqualTo(3.1); + } + + @Test + @DisplayName("좋아요 시 0.2점을 증가시킨다") + void incrementScoreForLike() { + // act + rankingRedisService.incrementScoreForLike(TODAY, 1L); + + // assert + Double score = redisTemplate.opsForZSet().score(KEY, "1"); + assertThat(score).isEqualTo(0.2); + } + + @Test + @DisplayName("주문 시 수량 * 0.6점을 증가시킨다") + void incrementScoreForOrder() { + // act - 3개 주문 + rankingRedisService.incrementScoreForOrder(TODAY, 1L, 3L); + + // assert - 0.6 * 3 = 1.8 + Double score = redisTemplate.opsForZSet().score(KEY, "1"); + assertThat(score).isCloseTo(1.8, within(0.0001)); // 부동소수점 오차 허용 + } + + @Test + @DisplayName("점수 증가 시 TTL이 2일로 설정된다") + void incrementScore_setsTTL() { + // act + rankingRedisService.incrementScoreForView(TODAY, 1L); + + // assert - TTL이 설정되어 있어야 함 (2일 = 172800초, 약간의 오차 허용) + Long ttl = redisTemplate.getExpire(KEY); + assertThat(ttl).isGreaterThan(172800 - 60); // 최소 2일 - 1분 + assertThat(ttl).isLessThanOrEqualTo(172800); // 최대 2일 + } + + @Test + @DisplayName("특정 상품의 순위를 조회한다 (1-based)") + void getRankingPosition() { + // arrange + redisTemplate.opsForZSet().add(KEY, "1", 3.0); // 상품1: 3점 -> 2위 + redisTemplate.opsForZSet().add(KEY, "2", 5.0); // 상품2: 5점 -> 1위 + redisTemplate.opsForZSet().add(KEY, "3", 1.0); // 상품3: 1점 -> 3위 + + // act & assert + assertThat(rankingRedisService.getRankingPosition(TODAY, 2L)).isEqualTo(1L); + assertThat(rankingRedisService.getRankingPosition(TODAY, 1L)).isEqualTo(2L); + assertThat(rankingRedisService.getRankingPosition(TODAY, 3L)).isEqualTo(3L); + } + + @Test + @DisplayName("랭킹에 없는 상품은 null을 반환한다") + void getRankingPosition_notFound() { + // arrange - 상품 999는 랭킹에 없음 + + // act + Long position = rankingRedisService.getRankingPosition(TODAY, 999L); + + // assert + assertThat(position).isNull(); + } + + @Test + @DisplayName("랭킹에 등록된 전체 상품 수를 조회한다") + void getTotalCount() { + // arrange + redisTemplate.opsForZSet().add(KEY, "1", 3.0); + redisTemplate.opsForZSet().add(KEY, "2", 5.0); + redisTemplate.opsForZSet().add(KEY, "3", 1.0); + + // act + long count = rankingRedisService.getTotalCount(TODAY); + + // assert + assertThat(count).isEqualTo(3); + } + + @Test + @DisplayName("랭킹이 비어있으면 0을 반환한다") + void getTotalCount_empty() { + // act + long count = rankingRedisService.getTotalCount(TODAY); + + // assert + assertThat(count).isEqualTo(0); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingApiE2ETest.java new file mode 100644 index 000000000..7ece0ed80 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingApiE2ETest.java @@ -0,0 +1,96 @@ +package com.loopers.interfaces.api.ranking; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.product.Product; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class RankingApiE2ETest { + + private static final String ENDPOINT = "/api/v1/rankings"; + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @Autowired + private RedisTemplate redisTemplate; + + @Autowired + private BrandJpaRepository brandJpaRepository; + + @Autowired + private ProductJpaRepository productJpaRepository; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + // Redis ZSET 정리 + String today = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); + redisTemplate.delete("ranking:all:" + today); + } + + @Test + @Disabled("Controller 구현 후 활성화") + @DisplayName("랭킹 조회 시 점수가 높은 순으로 상품 목록을 반환한다") + void rankingTest1() { + // arrange + Brand brand = brandJpaRepository.save(Brand.create("브랜드A")); + + Product productA = productJpaRepository.save( + Product.create("상품A", "설명", 10000, 100L, brand.getId())); + Product productB = productJpaRepository.save( + Product.create("상품B", "설명", 20000, 100L, brand.getId())); + Product productC = productJpaRepository.save( + Product.create("상품C", "설명", 30000, 100L, brand.getId())); + + String today = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); + String key = "ranking:all:" + today; + + // Redis ZSET에 점수 저장 + redisTemplate.opsForZSet().add(key, productA.getId().toString(), 3.0); + redisTemplate.opsForZSet().add(key, productB.getId().toString(), 5.0); + redisTemplate.opsForZSet().add(key, productC.getId().toString(), 1.0); + + // act + ParameterizedTypeReference> type = + new ParameterizedTypeReference<>() {}; + + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT, HttpMethod.GET, null, type); + + // assert + // 예상 순서: 상품B(5점) > 상품A(3점) > 상품C(1점) + assertAll( + () -> assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data().content()).hasSize(3), + () -> assertThat(response.getBody().data().content().get(0).productId()).isEqualTo(productB.getId()), + () -> assertThat(response.getBody().data().content().get(0).rank()).isEqualTo(1), + () -> assertThat(response.getBody().data().content().get(1).productId()).isEqualTo(productA.getId()), + () -> assertThat(response.getBody().data().content().get(2).productId()).isEqualTo(productC.getId()) + ); + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 1198395d8..6a9debdf7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -53,10 +53,11 @@ services: image: confluentinc/cp-kafka:7.5.0 container_name: loopers-kafka ports: - - "19092:9092" + - "19092:19092" environment: KAFKA_BROKER_ID: 1 KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 + KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:19092 KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:19092 KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 depends_on: diff --git a/docs/week9/01-requirements.md b/docs/week9/01-requirements.md new file mode 100644 index 000000000..2f99803d1 --- /dev/null +++ b/docs/week9/01-requirements.md @@ -0,0 +1,169 @@ +# 9주차 - 실시간 랭킹 시스템 + +## 1. 개요 + +### 1.1 목표 +Redis ZSET을 이용한 실시간 랭킹 시스템 구축 + +### 1.2 배경 +- 8주차에 Kafka Consumer로 `product_metrics` 테이블에 집계 정보 적재 +- 이번 주차: 이벤트 기반으로 Redis ZSET에 랭킹 점수 반영 +- API로 "오늘의 인기상품" 제공 + +--- + +## 2. 기능 요구사항 + +### 2.1 랭킹 조회 API + +**유저 스토리** +- 사용자는 오늘의 인기 상품 목록을 조회할 수 있다 +- 사용자는 특정 날짜의 랭킹을 조회할 수 있다 +- 사용자는 페이지네이션으로 랭킹을 탐색할 수 있다 + +**기능 흐름** +- `GET /api/v1/rankings?date=yyyyMMdd&size=20&page=0` +- 요청 파라미터 + - date (Optional): 조회할 날짜 (기본값: 오늘) + - size: 페이지당 상품 수 (기본값: 20) + - page: 페이지 번호 (기본값: 0) +- 응답: 상품 ID뿐만 아니라 상품 정보가 Aggregation 되어 제공 + +**제약사항** +- 날짜 형식은 yyyyMMdd (예: 20241222) +- 랭킹 데이터가 없는 날짜는 빈 목록 반환 +- 최대 2일 전까지 조회 가능 (TTL 제한) + +--- + +### 2.2 상품 상세에 순위 추가 + +**유저 스토리** +- 사용자는 상품 상세 조회 시 해당 상품의 오늘 순위를 확인할 수 있다 +- 순위에 없는 상품은 null로 표시된다 + +**기능 흐름** +- 기존 `GET /api/v1/products/{productId}` 응답에 `rank` 필드 추가 +- 응답 예시: +```json +{ + "id": 1, + "name": "상품명", + "rank": 5 +} +``` + +**제약사항** +- 순위는 오늘 날짜 기준으로 조회 +- 랭킹에 없는 상품은 `rank: null` 반환 +- 순위는 1부터 시작 (0-based 아님) + +--- + +### 2.3 이벤트 -> ZSET 적재 + +**유저 스토리** +- 사용자의 행동(조회, 좋아요, 주문)이 실시간으로 랭킹에 반영된다 + +**이벤트별 처리** + +| 이벤트 | 설명 | 토픽 | +|--------|------|------| +| ProductViewedEvent | 상품 조회 | catalog-events | +| ProductLikedEvent | 상품 좋아요 | catalog-events | +| OrderCompletedEvent | 주문 완료 | order-events | + +**가중치 (Weight)** + +| 이벤트 | Weight | Score | 계산식 | +|--------|--------|-------|--------| +| View (조회) | 0.1 | 1 | 0.1 * 1 = 0.1 | +| Like (좋아요) | 0.2 | 1 | 0.2 * 1 = 0.2 | +| Order (주문) | 0.6 | quantity | 0.6 * quantity | + +**제약사항** +- 이벤트 중복 처리 방지 (멱등성 보장) +- 이벤트 발생 시점의 날짜로 ZSET에 적재 +- 좋아요 취소(liked=false)는 점수에 반영하지 않음 + +--- + +## 3. 8주차 피드백 반영 + +| 피드백 | 적용 방안 | +|--------|----------| +| JSONConverter 사용 | 새로 만드는 Consumer(RankingOrderConsumer)에 @Payload 방식 적용 | +| Zero-Trust 관점 | Consumer에서 멱등성 처리 유지 (eventId 기반 중복 체크) | + +--- + +## 4. 기술 스펙 + +### 4.1 Redis ZSET + +| 항목 | 값 | 비고 | +|------|-----|------| +| KEY 형식 | `ranking:all:{yyyyMMdd}` | 일별 분리 | +| Member | productId (String) | ZSET member | +| Score | 가중치 합산 점수 | double | +| TTL | 2일 (172,800초) | 메모리 관리 | + +### 4.2 Consumer 구성 + +| Consumer | 토픽 | Group ID | 처리 이벤트 | +|----------|------|----------|------------| +| CatalogEventConsumer | catalog-events | catalog-consumer | View, Like, StockDepleted | +| RankingOrderConsumer | order-events | ranking-consumer | OrderCompleted | + +### 4.3 신규 이벤트 + +**ProductViewedEvent** +```java +public class ProductViewedEvent { + private Long productId; + private LocalDateTime occurredAt; +} +``` + +**OrderCompletedEvent 수정** +```java +public class OrderCompletedEvent { + private Long orderId; + private String userId; + private long totalAmount; + private long discountAmount; + private long paymentAmount; + private List items; // 추가 + private LocalDateTime occurredAt; +} + +public record OrderItemInfo( + Long productId, + int quantity, + long price +) {} +``` + +--- + +## 5. 체크리스트 + +### 5.1 Ranking Consumer +- [ ] 랭킹 ZSET의 TTL, 키 전략을 적절하게 구성하였다 +- [ ] 날짜별로 적재할 키를 계산하는 기능을 만들었다 +- [ ] 이벤트가 발생한 후, ZSET에 점수가 적절하게 반영된다 + +### 5.2 Ranking API +- [ ] 랭킹 Page 조회 시 정상적으로 랭킹 정보가 반환된다 +- [ ] 랭킹 Page 조회 시 단순히 상품 ID가 아닌 상품정보가 Aggregation 되어 제공된다 +- [ ] 상품 상세 조회 시 해당 상품의 순위가 함께 반환된다 (순위에 없다면 null) + +--- + +## 6. 선택 사항 (Nice-to-Have) + +| 기능 | 설명 | +|------|------| +| 초실시간 랭킹 | 시간 단위 랭킹 (ranking:all:2024122114) | +| 콜드 스타트 해결 | 23:50에 Score Carry-Over로 다음날 랭킹판 미리 생성 | +| 실시간 Weight 조절 | 가중치를 동적으로 변경할 수 있는 구조 | diff --git a/docs/week9/02-sequence-diagrams.md b/docs/week9/02-sequence-diagrams.md new file mode 100644 index 000000000..2b85a09dc --- /dev/null +++ b/docs/week9/02-sequence-diagrams.md @@ -0,0 +1,128 @@ +# 시퀀스 다이어그램 + +## 1. 상품 상세 조회 (순위 + View 이벤트) + +~~~mermaid +sequenceDiagram + participant C as Client + participant API as ProductAPI + participant DB as ProductDB + participant Redis as Redis ZSET + participant Kafka as Kafka + participant Consumer as CatalogEventConsumer + + C->>+API: GET /api/v1/products/{id} + API->>+DB: findById(productId) + DB-->>-API: Product + + API->>+Redis: ZREVRANK ranking:all:{today} {productId} + alt 랭킹에 존재 + Redis-->>API: rank (0-based) + API->>API: rank + 1 (1-based 변환) + else 랭킹에 없음 + Redis-->>API: null + end + Redis-->>-API: rank + + API->>Kafka: ProductViewedEvent 발행 + API-->>-C: 200 OK { ..., rank: 5 } + + Kafka->>+Consumer: consume + Consumer->>Consumer: 멱등성 체크 + Consumer->>+Redis: ZINCRBY (weight: 0.1) + Redis-->>-Consumer: OK + Consumer-->>-Kafka: ack +~~~ + +--- + +## 2. Like 이벤트 흐름 + +~~~mermaid +sequenceDiagram + participant C as Client + participant API as LikeAPI + participant Kafka as Kafka + participant Consumer as CatalogEventConsumer + participant Redis as Redis ZSET + + C->>+API: POST /api/v1/like/products/{id} + API->>API: 좋아요 등록 (멱등) + API->>Kafka: ProductLikedEvent 발행 + API-->>-C: 200 OK + + Kafka->>+Consumer: consume + Consumer->>Consumer: 멱등성 체크 + alt liked == true + Consumer->>+Redis: ZINCRBY (weight: 0.2) + Redis-->>-Consumer: OK + else liked == false + Note over Consumer: ZSET 반영 안 함 + end + Consumer-->>-Kafka: ack +~~~ + +--- + +## 3. Order 이벤트 흐름 + +~~~mermaid +sequenceDiagram + participant API as OrderAPI + participant Kafka as Kafka + participant Consumer as RankingOrderConsumer + participant Redis as Redis ZSET + + API->>API: order.confirm() + API->>Kafka: OrderCompletedEvent 발행 + Note right of Kafka: items 포함 (productId, quantity) + + Kafka->>+Consumer: consume (@Payload) + Consumer->>Consumer: 멱등성 체크 + loop 각 OrderItem + Consumer->>+Redis: ZINCRBY (weight: 0.6 * quantity) + Redis-->>-Consumer: OK + end + Consumer-->>-Kafka: ack +~~~ + +--- + +## 4. 랭킹 조회 API + +~~~mermaid +sequenceDiagram + participant C as Client + participant API as RankingAPI + participant Redis as Redis ZSET + participant DB as ProductDB + + C->>+API: GET /api/v1/rankings?date=20251222&page=0&size=20 + API->>+Redis: ZREVRANGE ranking:all:{date} + Redis-->>-API: List + API->>+DB: 상품 정보 조회 (productIds) + DB-->>-API: List + API->>API: 랭킹 + 상품정보 조합 + API-->>-C: 200 OK (RankingResponse) +~~~ + +--- + +## 5. ZSET 키 전략 + +~~~mermaid +sequenceDiagram + participant Service as RankingRedisService + participant Redis as Redis ZSET + + Note over Service,Redis: KEY: ranking:all:{yyyyMMdd} + + Service->>+Redis: ZINCRBY ranking:all:20251222 {score} {productId} + Redis-->>-Service: newScore + + opt 최초 적재 시 + Service->>+Redis: EXPIRE ranking:all:20251222 172800 + Note over Redis: TTL: 2일 (172,800초) + Redis-->>-Service: OK + end +~~~