From 1dcbe111770a9602d8c83fcbd5dfb6215135e00e Mon Sep 17 00:00:00 2001 From: Kim yeonsu Date: Tue, 23 Dec 2025 18:55:24 +0900 Subject: [PATCH 01/10] =?UTF-8?q?feat:=20=EC=9D=BC=EA=B0=84=20=EB=9E=AD?= =?UTF-8?q?=ED=82=B9=20=EC=A1=B0=ED=9A=8C=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/ranking/RankingV1ApiSpec.java | 25 +++++++++++++++ .../api/ranking/RankingV1Controller.java | 31 +++++++++++++++++++ .../interfaces/api/ranking/RankingV1Dto.java | 25 +++++++++++++++ 3 files changed, 81 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java new file mode 100644 index 000000000..00022020d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java @@ -0,0 +1,25 @@ +package com.loopers.interfaces.api.ranking; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.time.LocalDate; + +@Tag(name = "Ranking V1 API", description = "Ranking V1 API 입니다.") +public interface RankingV1ApiSpec { + + @Operation( + summary = "상품 랭킹 조회", + description = "상품의 일간 랭킹 정보를 조회합니다." + ) + ApiResponse getDailyRanking( + @Parameter(name = "date", description = "조회할 날짜 (yyyyMMdd 형식)", required = true) + LocalDate date, + @Parameter(name = "page", description = "페이지 번호 (기본값: 1)") + Integer page, + @Parameter(name = "size", description = "페이지당 상품 수 (기본값: 20)") + Integer size + ); + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java new file mode 100644 index 000000000..20bbc0281 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java @@ -0,0 +1,31 @@ +package com.loopers.interfaces.api.ranking; + +import com.loopers.interfaces.api.ApiResponse; +import java.time.LocalDate; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +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; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/rankings") +public class RankingV1Controller implements RankingV1ApiSpec { + + @GetMapping + @Override + public ApiResponse getDailyRanking( + @RequestParam @DateTimeFormat(pattern = "yyyyMMdd") LocalDate date, + @RequestParam(required = false, defaultValue = "1") Integer page, + @RequestParam(required = false, defaultValue = "20") Integer size + ) { + RankingV1Dto.DailyRankingListResponse response = + new RankingV1Dto.DailyRankingListResponse(List.of()); + + return ApiResponse.success(response); + } + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java new file mode 100644 index 000000000..aee841e45 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java @@ -0,0 +1,25 @@ +package com.loopers.interfaces.api.ranking; + +import java.util.List; + +public class RankingV1Dto { + + public record DailyRankingListResponse( + List rankings + ) { + public static DailyRankingListResponse from(List items) { + return new DailyRankingListResponse(items); + } + } + + public record DailyRankingItem( + Long productId, + String productName, + String brandName, + Integer price, + Integer likeCount, + Integer rank + ) { + } + +} From 806feda31049af0466ebc79e60228509866405cf Mon Sep 17 00:00:00 2001 From: Kim yeonsu Date: Tue, 23 Dec 2025 19:21:57 +0900 Subject: [PATCH 02/10] =?UTF-8?q?feat:=20Outbox=20OrderCreated=20=EC=9D=B4?= =?UTF-8?q?=EB=B2=A4=ED=8A=B8=EC=97=90=20=EC=A3=BC=EB=AC=B8=20=ED=95=AD?= =?UTF-8?q?=EB=AA=A9=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 --- .../com/loopers/application/order/OrderEvent.java | 15 +++++++++++++-- .../listener/KafkaOutboxEventListener.java | 12 +++++++++++- .../com/loopers/application/kafka/KafkaEvent.java | 4 +++- 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderEvent.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderEvent.java index fd5031ac7..3e2664067 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderEvent.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderEvent.java @@ -23,16 +23,27 @@ public record OrderCreated( Long orderId, Long userId, Integer originalTotalPrice, - Integer discountPrice + Integer discountPrice, + List orderItems ) { public static OrderCreated from(Order order, String loginId) { + List orderItemInfos = order.getOrderItems().stream() + .map(item -> new OrderItemInfo( + item.getProductId(), + item.getProductName(), + item.getPrice(), + item.getQuantity() + )) + .toList(); + return new OrderCreated( loginId, order.getOrderKey(), order.getId(), order.getUserId(), order.getOriginalTotalPrice(), - order.getDiscountPrice() + order.getDiscountPrice(), + orderItemInfos ); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/listener/KafkaOutboxEventListener.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/listener/KafkaOutboxEventListener.java index e05d1a524..913bb4008 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/listener/KafkaOutboxEventListener.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/listener/KafkaOutboxEventListener.java @@ -22,12 +22,22 @@ public class KafkaOutboxEventListener { @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) public void handleOrderCreated(OrderEvent.OrderCreated event) { + List orderItemInfos = event.orderItems().stream() + .map(item -> new KafkaEvent.OrderEvent.OrderItemInfo( + item.productId(), + item.productName(), + item.price(), + item.quantity() + )) + .toList(); + KafkaEvent.OrderEvent.OrderCreated kafkaEvent = KafkaEvent.OrderEvent.OrderCreated.from( event.orderKey(), event.userId(), event.orderId(), event.originalTotalPrice(), - event.discountPrice() + event.discountPrice(), + orderItemInfos ); outboxService.saveOutbox("order-created-events", event.orderKey(), kafkaEvent); } diff --git a/modules/kafka/src/main/java/com/loopers/application/kafka/KafkaEvent.java b/modules/kafka/src/main/java/com/loopers/application/kafka/KafkaEvent.java index 3e349faa9..c32a461fc 100644 --- a/modules/kafka/src/main/java/com/loopers/application/kafka/KafkaEvent.java +++ b/modules/kafka/src/main/java/com/loopers/application/kafka/KafkaEvent.java @@ -29,9 +29,10 @@ record OrderCreated( Long orderId, Integer totalPrice, Integer discountPrice, + List orderItems, ZonedDateTime timestamp ) implements OrderEvent { - public static OrderCreated from(String orderKey, Long userId, Long orderId, Integer totalPrice, Integer discountPrice) { + public static OrderCreated from(String orderKey, Long userId, Long orderId, Integer totalPrice, Integer discountPrice, List orderItems) { return new OrderCreated( KafkaEvent.generateEventId("order-created", orderKey), orderKey, @@ -39,6 +40,7 @@ public static OrderCreated from(String orderKey, Long userId, Long orderId, Inte orderId, totalPrice, discountPrice, + orderItems, ZonedDateTime.now() ); } From b5511924fcb5a6f85fd7918a322eb26df004bec6 Mon Sep 17 00:00:00 2001 From: Kim yeonsu Date: Wed, 24 Dec 2025 19:09:03 +0900 Subject: [PATCH 03/10] =?UTF-8?q?feat:=20=EC=A2=8B=EC=95=84=EC=9A=94=20?= =?UTF-8?q?=EC=88=98,=20=EC=A1=B0=ED=9A=8C=20=EC=88=98,=20=EC=A3=BC?= =?UTF-8?q?=EB=AC=B8=20=EC=83=9D=EC=84=B1=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BB=A8=EC=8A=88=EB=A8=B8=EC=97=90=EC=84=9C=20=EB=9E=AD?= =?UTF-8?q?=ED=82=B9=20=EC=A7=91=EA=B3=84=20=EB=A1=9C=EC=A7=81=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 --- .../domain/ranking/RankingService.java | 76 +++++++++++++++++++ .../loopers/domain/ranking/RankingWeight.java | 20 +++++ .../interfaces/consumer/MetricsConsumer.java | 10 +++ 3 files changed, 106 insertions(+) create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingService.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingWeight.java 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..f2da833d5 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingService.java @@ -0,0 +1,76 @@ +package com.loopers.domain.ranking; + +import java.time.Duration; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +@Slf4j +@Service +public class RankingService { + + private static final String RANKING_KEY_PREFIX = "ranking:all:"; + private static final int TTL_SECONDS = 2 * 24 * 60 * 60; + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + + private final RedisTemplate redisTemplateMaster; + + public RankingService(@Qualifier("redisTemplateMaster") RedisTemplate redisTemplateMaster) { + this.redisTemplateMaster = redisTemplateMaster; + } + + public void incrementScore(Long productId, RankingWeight weight) { + LocalDate date = LocalDate.now(); + + String key = getRankingKey(date); + String member = String.valueOf(productId); + + ZSetOperations zSetOps = redisTemplateMaster.opsForZSet(); + Double newScore = zSetOps.incrementScore(key, member, weight.getWeight()); + + redisTemplateMaster.expire(key, Duration.ofSeconds(TTL_SECONDS)); + + log.debug("랭킹 점수 증가: productId={}, weight={}, date={}, newScore={}", + productId, weight.getDescription(), date, newScore); + } + + public void carryOverScore(LocalDate fromDate, LocalDate toDate) { + String fromKey = getRankingKey(fromDate); + String toKey = getRankingKey(toDate); + + ZSetOperations zSetOps = redisTemplateMaster.opsForZSet(); + + var entries = zSetOps.reverseRangeWithScores(fromKey, 0, -1); + + if (entries == null || entries.isEmpty()) { + log.info("carry-over할 데이터가 없음: fromDate={}", fromDate); + return; + } + + for (ZSetOperations.TypedTuple entry : entries) { + String productId = entry.getValue(); + Double score = entry.getScore(); + + if (productId == null || score == null || score <= 0) { + continue; + } + + double carryOverScore = score * RankingWeight.CARRY_OVER.getWeight(); + zSetOps.incrementScore(toKey, productId, carryOverScore); + } + + redisTemplateMaster.expire(toKey, java.time.Duration.ofSeconds(TTL_SECONDS)); + + log.info("랭킹 점수 carry-over 완료: fromDate={}, toDate={}", fromDate, toDate); + } + + private String getRankingKey(LocalDate date) { + return RANKING_KEY_PREFIX + date.format(DATE_FORMATTER); + } +} + diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingWeight.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingWeight.java new file mode 100644 index 000000000..9a4d11916 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingWeight.java @@ -0,0 +1,20 @@ +package com.loopers.domain.ranking; + +import lombok.Getter; + +@Getter +public enum RankingWeight { + VIEW(0.1, "조회수"), + LIKE(0.2, "좋아요"), + ORDER_CREATED(0.7, "주문 생성"), + CARRY_OVER(0.1, "Carry-Over"); + + private final double weight; + private final String description; + + RankingWeight(double weight, String description) { + this.weight = weight; + this.description = description; + } +} + diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsConsumer.java index 9f9cbdef0..6ff8546d4 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsConsumer.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsConsumer.java @@ -5,6 +5,8 @@ import com.loopers.domain.cache.ProductCacheService; import com.loopers.domain.eventhandled.EventHandledService; import com.loopers.domain.metrics.ProductMetricsService; +import com.loopers.domain.ranking.RankingService; +import com.loopers.domain.ranking.RankingWeight; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.kafka.clients.consumer.ConsumerRecord; @@ -23,6 +25,7 @@ public class MetricsConsumer { private final EventHandledService eventHandledService; private final ProductMetricsService productMetricsService; private final ProductCacheService productCacheService; + private final RankingService rankingService; @KafkaListener( topics = {"product-liked-events"}, @@ -42,6 +45,7 @@ public void handleProductLikedEvents( } productMetricsService.incrementLikeCount(event.productId()); + rankingService.incrementScore(event.productId(), RankingWeight.LIKE); eventHandledService.markAsHandled( event.eventId(), "ProductLiked", @@ -100,6 +104,7 @@ public void handleProductViewedEvents( } productMetricsService.incrementViewCount(event.productId()); + rankingService.incrementScore(event.productId(), RankingWeight.VIEW); eventHandledService.markAsHandled( event.eventId(), "ProductViewed", @@ -129,6 +134,11 @@ public void handleOrderCreatedEvents( } log.info("주문 생성 이벤트 수신: orderId={}, orderKey={}", event.orderId(), event.orderKey()); + + for (KafkaEvent.OrderEvent.OrderItemInfo orderItem : event.orderItems()) { + rankingService.incrementScore(orderItem.productId(), RankingWeight.ORDER_CREATED); + } + eventHandledService.markAsHandled( event.eventId(), "OrderCreated", From e52fd0f607b5e19d39fe2416330169d95a4ddafe Mon Sep 17 00:00:00 2001 From: Kim yeonsu Date: Wed, 24 Dec 2025 19:11:58 +0900 Subject: [PATCH 04/10] =?UTF-8?q?feat:=20=EC=BD=9C=EB=93=9C=EC=8A=A4?= =?UTF-8?q?=ED=83=80=ED=8A=B8=20=EC=99=84=ED=99=94=EB=A5=BC=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/CommerceStreamerApplication.java | 2 ++ .../RankingScoreCarryOverScheduler.java | 30 +++++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/interfaces/scheduler/RankingScoreCarryOverScheduler.java diff --git a/apps/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java b/apps/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java index ea4b4d15a..eb986acdd 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java @@ -4,11 +4,13 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.scheduling.annotation.EnableScheduling; import java.util.TimeZone; @ConfigurationPropertiesScan @SpringBootApplication +@EnableScheduling public class CommerceStreamerApplication { @PostConstruct public void started() { diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/scheduler/RankingScoreCarryOverScheduler.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/scheduler/RankingScoreCarryOverScheduler.java new file mode 100644 index 000000000..ada86a94e --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/scheduler/RankingScoreCarryOverScheduler.java @@ -0,0 +1,30 @@ +package com.loopers.interfaces.scheduler; + +import com.loopers.domain.ranking.RankingService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; + +@RequiredArgsConstructor +@Component +@Slf4j +public class RankingScoreCarryOverScheduler { + + private final RankingService rankingService; + + @Scheduled(cron = "0 50 23 * * *", zone = "Asia/Seoul") + public void carryOverRankingScore() { + try { + LocalDate today = LocalDate.now(); + LocalDate targetDate = today.plusDays(1); + + rankingService.carryOverScore(today, targetDate); + } catch (Exception e) { + log.error("랭킹 점수 carry-over 중 오류 발생", e); + } + } +} + From ae39cab67287c7098f6979fd8e10ad0d55df49d3 Mon Sep 17 00:00:00 2001 From: Kim yeonsu Date: Wed, 24 Dec 2025 20:06:52 +0900 Subject: [PATCH 05/10] =?UTF-8?q?refactor:=20carry=20over=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20ZUNIONSTORE=20=EB=AA=85=EB=A0=B9=EC=96=B4=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=ED=95=98=EB=8F=84=EB=A1=9D=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 --- .../domain/ranking/RankingService.java | 26 +++++-------------- 1 file changed, 7 insertions(+), 19 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 f2da833d5..59999d1a3 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,8 +1,11 @@ package com.loopers.domain.ranking; import java.time.Duration; +import java.util.Collections; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.data.redis.connection.zset.Aggregate; +import org.springframework.data.redis.connection.zset.Weights; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.ZSetOperations; import org.springframework.stereotype.Service; @@ -45,26 +48,11 @@ public void carryOverScore(LocalDate fromDate, LocalDate toDate) { ZSetOperations zSetOps = redisTemplateMaster.opsForZSet(); - var entries = zSetOps.reverseRangeWithScores(fromKey, 0, -1); + double weight = RankingWeight.CARRY_OVER.getWeight(); + zSetOps.unionAndStore(fromKey, Collections.emptyList(), toKey, + Aggregate.SUM, Weights.of(weight)); - if (entries == null || entries.isEmpty()) { - log.info("carry-over할 데이터가 없음: fromDate={}", fromDate); - return; - } - - for (ZSetOperations.TypedTuple entry : entries) { - String productId = entry.getValue(); - Double score = entry.getScore(); - - if (productId == null || score == null || score <= 0) { - continue; - } - - double carryOverScore = score * RankingWeight.CARRY_OVER.getWeight(); - zSetOps.incrementScore(toKey, productId, carryOverScore); - } - - redisTemplateMaster.expire(toKey, java.time.Duration.ofSeconds(TTL_SECONDS)); + redisTemplateMaster.expire(toKey, Duration.ofSeconds(TTL_SECONDS)); log.info("랭킹 점수 carry-over 완료: fromDate={}, toDate={}", fromDate, toDate); } From f845467442277df8f4031f03244f8d95d791cea6 Mon Sep 17 00:00:00 2001 From: Kim yeonsu Date: Fri, 26 Dec 2025 00:10:18 +0900 Subject: [PATCH 06/10] =?UTF-8?q?feat:=20=EB=9E=AD=ED=82=B9=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=A1=B0=ED=9A=8C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/ranking/RankingCommand.java | 14 ++++ .../application/ranking/RankingFacade.java | 76 ++++++++++++++++++ .../application/ranking/RankingInfo.java | 18 +++++ .../domain/product/ProductRepository.java | 2 + .../domain/product/ProductService.java | 5 ++ .../com/loopers/domain/ranking/Ranking.java | 8 ++ .../loopers/domain/ranking/RankingItem.java | 8 ++ .../domain/ranking/RankingService.java | 37 +++++++++ .../cache/RankingCacheService.java | 79 +++++++++++++++++++ .../product/ProductJpaRepository.java | 3 + .../product/ProductRepositoryImpl.java | 8 ++ .../api/ranking/RankingV1Controller.java | 16 +++- .../interfaces/api/ranking/RankingV1Dto.java | 15 +++- 13 files changed, 285 insertions(+), 4 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingCommand.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/ranking/Ranking.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingItem.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/RankingCacheService.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingCommand.java new file mode 100644 index 000000000..8cb938c04 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingCommand.java @@ -0,0 +1,14 @@ +package com.loopers.application.ranking; + +import java.time.LocalDate; + +public class RankingCommand { + + public record GetDailyRankingCommand( + LocalDate date, + int page, + int size + ) { + } + +} 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..3c7235fc6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java @@ -0,0 +1,76 @@ +package com.loopers.application.ranking; + +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.ranking.Ranking; +import com.loopers.domain.ranking.RankingService; +import java.util.Objects; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@Component +@Slf4j +public class RankingFacade { + + private final RankingService rankingService; + private final ProductService productService; + private final BrandService brandService; + + public RankingInfo getDailyRanking(RankingCommand.GetDailyRankingCommand command) { + List rankings = rankingService.getRanking( + command.date(), + command.page(), + command.size() + ); + + if (rankings.isEmpty()) { + return new RankingInfo(List.of()); + } + + List productIds = rankings.stream() + .map(Ranking::productId) + .collect(Collectors.toList()); + + List products = productService.findProductsByIds(productIds); + Map productMap = products.stream() + .collect(Collectors.toMap(Product::getId, product -> product)); + + List brandIds = products.stream() + .map(Product::getBrandId) + .distinct() + .collect(Collectors.toList()); + Map brandNameMap = brandService.findBrandNamesByIds(brandIds); + + List rankingItemInfos = rankings.stream() + .map(ranking -> { + Product product = productMap.get(ranking.productId()); + if (product == null) { + log.warn("랭킹에 있는 상품이 DB에 존재하지 않음: productId={}", ranking.productId()); + return null; + } + + String brandName = brandNameMap.getOrDefault(product.getBrandId(), "알 수 없음"); + + return new RankingInfo.RankingItemInfo( + ranking.productId(), + product.getName(), + brandName, + product.getPrice().getPrice(), + product.getLikeCount().getCount(), + ranking.rank(), + ranking.score() + ); + }) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + + return new RankingInfo(rankingItemInfos); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingInfo.java new file mode 100644 index 000000000..5eb1eea31 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingInfo.java @@ -0,0 +1,18 @@ +package com.loopers.application.ranking; + +import java.util.List; + +public record RankingInfo( + List items +) { + public record RankingItemInfo( + Long productId, + String productName, + String brandName, + Integer price, + Integer likeCount, + Long rank, + Double score + ) { + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java index 717f09916..5330d09c6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -15,4 +15,6 @@ public interface ProductRepository { List findProductsByLikesDesc(Long brandId, int page, int size); + List findProductsByIds(List productIds); + } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java index 36ecc3b9f..438125be0 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -53,6 +53,11 @@ public List findProductsByLikesDesc(Long brandId, int page, int size) { return productRepository.findProductsByLikesDesc(brandId, page, size); } + @Transactional(readOnly = true) + public List findProductsByIds(List productIds) { + return productRepository.findProductsByIds(productIds); + } + @Transactional public Product increaseLikeCount(Long productId) { Product product = productRepository.findProductById(productId) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/Ranking.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/Ranking.java new file mode 100644 index 000000000..b881f25da --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/Ranking.java @@ -0,0 +1,8 @@ +package com.loopers.domain.ranking; + +public record Ranking( + Long productId, + Long rank, + Double score +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingItem.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingItem.java new file mode 100644 index 000000000..1c78c7af5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingItem.java @@ -0,0 +1,8 @@ +package com.loopers.domain.ranking; + +public record RankingItem( + Long productId, + Double score +) { +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java new file mode 100644 index 000000000..a90fe17c5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java @@ -0,0 +1,37 @@ +package com.loopers.domain.ranking; + +import com.loopers.infrastructure.cache.RankingCacheService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.IntStream; + +@RequiredArgsConstructor +@Service +public class RankingService { + + private final RankingCacheService rankingCacheService; + + public List getRanking(LocalDate date, int page, int size) { + long start = (long) (page - 1) * size; + long end = start + size - 1; + + List rankingItems = rankingCacheService.getRankingRange(date, start, end); + + if (rankingItems.isEmpty()) { + return new ArrayList<>(); + } + + return IntStream.range(0, rankingItems.size()) + .mapToObj(i -> { + RankingItem item = rankingItems.get(i); + long rank = start + 1 + i; + return new Ranking(item.productId(), rank, item.score()); + }) + .toList(); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/RankingCacheService.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/RankingCacheService.java new file mode 100644 index 000000000..44258db11 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/RankingCacheService.java @@ -0,0 +1,79 @@ +package com.loopers.infrastructure.cache; + +import com.loopers.domain.ranking.RankingItem; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; +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; + +@Slf4j +@Service +public class RankingCacheService { + + private static final String RANKING_KEY_PREFIX = "ranking:all:"; + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + + private final RedisTemplate redisTemplateMaster; + + public RankingCacheService(@Qualifier("redisTemplateMaster") RedisTemplate redisTemplateMaster) { + this.redisTemplateMaster = redisTemplateMaster; + } + + public List getRankingRange(LocalDate date, long start, long end) { + String key = getRankingKey(date); + + ZSetOperations zSetOps = redisTemplateMaster.opsForZSet(); + + Set> entries = zSetOps.reverseRangeWithScores(key, start, end); + + if (entries == null || entries.isEmpty()) { + log.debug("랭킹 데이터가 없음: key={}, start={}, end={}", key, start, end); + return new ArrayList<>(); + } + + List rankingItems = new ArrayList<>(); + for (ZSetOperations.TypedTuple entry : entries) { + String productIdStr = entry.getValue(); + Double score = entry.getScore(); + + if (productIdStr == null || score == null) { + continue; + } + + try { + Long productId = Long.parseLong(productIdStr); + rankingItems.add(new RankingItem(productId, score)); + } catch (NumberFormatException e) { + log.warn("랭킹에서 잘못된 productId 형식: {}", productIdStr); + } + } + + log.debug("랭킹 데이터 조회 완료: key={}, start={}, end={}, count={}", key, start, end, rankingItems.size()); + return rankingItems; + } + + public Long getProductRank(LocalDate date, String productId) { + String key = getRankingKey(date); + ZSetOperations zSetOps = redisTemplateMaster.opsForZSet(); + + Long rank = zSetOps.reverseRank(key, productId); + + if (rank == null) { + log.debug("해당 상품이 랭킹에 존재하지 않음: key={}, productId={}", key, productId); + return null; + } + + return rank + 1; + } + + private String getRankingKey(LocalDate date) { + return RANKING_KEY_PREFIX + date.format(DATE_FORMATTER); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java index 672981960..527a9fbfa 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -24,5 +24,8 @@ public interface ProductJpaRepository extends JpaRepository { @Query("SELECT p FROM Product p WHERE p.isDeleted = false AND (:brandId IS NULL OR p.brandId = :brandId) ORDER BY p.likeCount.count DESC") List findProductsByLikesDesc(@Param("brandId") Long brandId, Pageable pageable); + + @Query("SELECT p FROM Product p WHERE p.isDeleted = false AND p.id IN :productIds") + List findProductsByIds(@Param("productIds") List productIds); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java index db0abab0d..10ad37e44 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -38,4 +38,12 @@ public List findProductsByPriceAsc(Long brandId, int page, int size) { public List findProductsByLikesDesc(Long brandId, int page, int size) { return productJpaRepository.findProductsByLikesDesc(brandId, PageRequest.of(page, size)); } + + @Override + public List findProductsByIds(List productIds) { + if (productIds == null || productIds.isEmpty()) { + return List.of(); + } + return productJpaRepository.findProductsByIds(productIds); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java index 20bbc0281..9150013ce 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java @@ -1,8 +1,10 @@ package com.loopers.interfaces.api.ranking; +import com.loopers.application.ranking.RankingCommand; +import com.loopers.application.ranking.RankingFacade; +import com.loopers.application.ranking.RankingInfo; import com.loopers.interfaces.api.ApiResponse; import java.time.LocalDate; -import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.web.bind.annotation.GetMapping; @@ -15,6 +17,8 @@ @RequestMapping("/api/v1/rankings") public class RankingV1Controller implements RankingV1ApiSpec { + private final RankingFacade rankingFacade; + @GetMapping @Override public ApiResponse getDailyRanking( @@ -22,8 +26,14 @@ public ApiResponse getDailyRanking( @RequestParam(required = false, defaultValue = "1") Integer page, @RequestParam(required = false, defaultValue = "20") Integer size ) { - RankingV1Dto.DailyRankingListResponse response = - new RankingV1Dto.DailyRankingListResponse(List.of()); + RankingCommand.GetDailyRankingCommand command = new RankingCommand.GetDailyRankingCommand( + date, + page, + size + ); + + RankingInfo rankingInfo = rankingFacade.getDailyRanking(command); + RankingV1Dto.DailyRankingListResponse response = RankingV1Dto.DailyRankingListResponse.from(rankingInfo); return ApiResponse.success(response); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java index aee841e45..5d0bd8503 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java @@ -1,13 +1,26 @@ package com.loopers.interfaces.api.ranking; +import com.loopers.application.ranking.RankingInfo; import java.util.List; +import java.util.stream.Collectors; public class RankingV1Dto { public record DailyRankingListResponse( List rankings ) { - public static DailyRankingListResponse from(List items) { + public static DailyRankingListResponse from(RankingInfo rankingInfo) { + List items = rankingInfo.items().stream() + .map(item -> new DailyRankingItem( + item.productId(), + item.productName(), + item.brandName(), + item.price(), + item.likeCount(), + item.rank().intValue() + )) + .collect(Collectors.toList()); + return new DailyRankingListResponse(items); } } From 9829964c25ae833b4b6b751852c208212c47cdfd Mon Sep 17 00:00:00 2001 From: Kim yeonsu Date: Fri, 26 Dec 2025 00:21:18 +0900 Subject: [PATCH 07/10] =?UTF-8?q?feat:=20=EC=83=81=ED=92=88=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20=EC=8B=9C=20=EC=83=81=ED=92=88?= =?UTF-8?q?=20=EC=88=9C=EC=9C=84=20=EB=B0=98=ED=99=98=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/product/ProductFacade.java | 23 ++++++++++++++++++- .../application/product/ProductInfo.java | 20 ++++++++++++++-- .../cache/RankingCacheService.java | 5 ++-- .../interfaces/api/product/ProductV1Dto.java | 6 +++-- 4 files changed, 47 insertions(+), 7 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java index a1240898a..36320ca7f 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 @@ -4,8 +4,10 @@ import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductService; import com.loopers.infrastructure.cache.ProductCacheService; +import com.loopers.infrastructure.cache.RankingCacheService; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; +import java.time.LocalDate; import java.util.List; import java.util.Map; import java.util.Set; @@ -20,6 +22,7 @@ public class ProductFacade { private final ProductService productService; private final BrandService brandService; private final ProductCacheService productCacheService; + private final RankingCacheService rankingCacheService; public List getProducts(ProductCommand.GetProductsCommand command) { return productCacheService.getProductList( @@ -57,12 +60,30 @@ public List getProducts(ProductCommand.GetProductsCommand command) } public ProductInfo getProduct(Long productId) { - return productCacheService.getProduct(productId, () -> { + ProductInfo productInfo = productCacheService.getProduct(productId, () -> { Product product = productService.findProductById(productId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); String brandName = brandService.findBrandNameById(product.getBrandId()); return ProductInfo.from(product, brandName); }); + + Long rank = rankingCacheService.getProductRank(LocalDate.now(), productId); + + if (rank != null) { + return new ProductInfo( + productInfo.id(), + productInfo.name(), + productInfo.brandId(), + productInfo.brandName(), + productInfo.price(), + productInfo.likeCount(), + productInfo.stock(), + productInfo.createdAt(), + rank + ); + } + + return productInfo; } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java index 4acf7121f..06faac7de 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java @@ -11,7 +11,8 @@ public record ProductInfo( Integer price, Integer likeCount, Integer stock, - ZonedDateTime createdAt + ZonedDateTime createdAt, + Long rank ) { public static ProductInfo from(Product product, String brandName) { return new ProductInfo( @@ -22,7 +23,22 @@ public static ProductInfo from(Product product, String brandName) { product.getPrice().getPrice(), product.getLikeCount().getCount(), product.getStock().getQuantity(), - product.getCreatedAt() + product.getCreatedAt(), + null + ); + } + + public static ProductInfo from(Product product, String brandName, Long rank) { + return new ProductInfo( + product.getId(), + product.getName(), + product.getBrandId(), + brandName, + product.getPrice().getPrice(), + product.getLikeCount().getCount(), + product.getStock().getQuantity(), + product.getCreatedAt(), + rank ); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/RankingCacheService.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/RankingCacheService.java index 44258db11..27999ea0d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/RankingCacheService.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/RankingCacheService.java @@ -59,11 +59,12 @@ public List getRankingRange(LocalDate date, long start, long end) { return rankingItems; } - public Long getProductRank(LocalDate date, String productId) { + public Long getProductRank(LocalDate date, Long productId) { String key = getRankingKey(date); ZSetOperations zSetOps = redisTemplateMaster.opsForZSet(); - Long rank = zSetOps.reverseRank(key, productId); + String productIdStr = String.valueOf(productId); + Long rank = zSetOps.reverseRank(key, productIdStr); if (rank == null) { log.debug("해당 상품이 랭킹에 존재하지 않음: key={}, productId={}", key, productId); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java index 2e50ef808..365302550 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java @@ -47,7 +47,8 @@ public record ProductResponse( String brandName, Integer price, Integer likeCount, - Integer stock + Integer stock, + Long rank ) { public static ProductResponse from(ProductInfo info) { return new ProductResponse( @@ -57,7 +58,8 @@ public static ProductResponse from(ProductInfo info) { info.brandName(), info.price(), info.likeCount(), - info.stock() + info.stock(), + info.rank() ); } } From a16b2a2709feedab572524c8db366f2f9eed3d75 Mon Sep 17 00:00:00 2001 From: Kim yeonsu Date: Fri, 26 Dec 2025 00:36:29 +0900 Subject: [PATCH 08/10] =?UTF-8?q?test:=20=EC=83=81=ED=92=88=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=8B=9C=20=EB=9E=AD=ED=82=B9=20=EB=B0=98=ED=99=98?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../product/ProductFacadeIntegrationTest.java | 45 ++++++++++++++++++- .../ProductCacheServiceIntegrationTest.java | 3 +- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeIntegrationTest.java index 0abe9be6a..00ea1325d 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeIntegrationTest.java @@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; @@ -19,10 +20,12 @@ import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductService; import com.loopers.domain.product.Stock; +import com.loopers.infrastructure.cache.RankingCacheService; import com.loopers.support.IntegrationTest; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import com.loopers.utils.RedisCleanUp; +import java.time.LocalDate; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -50,6 +53,9 @@ class ProductFacadeIntegrationTest extends IntegrationTest { @MockitoSpyBean private BrandService brandService; + @MockitoSpyBean + private RankingCacheService rankingCacheService; + @BeforeEach void setUp() { redisCleanUp.truncateAll(); @@ -290,6 +296,41 @@ void returnsProductInfo_whenProductExists() { doReturn(Optional.of(product)).when(productService).findProductById(productId); doReturn(brandName).when(brandService).findBrandNameById(1L); + doReturn(null).when(rankingCacheService).getProductRank(any(LocalDate.class), eq(productId)); + + // act + ProductInfo result = productFacade.getProduct(productId); + + // assert + assertAll( + () -> assertThat(result.id()).isEqualTo(1L), + () -> assertThat(result.name()).isEqualTo("상품1"), + () -> assertThat(result.brandId()).isEqualTo(1L), + () -> assertThat(result.brandName()).isEqualTo("브랜드1"), + () -> assertThat(result.price()).isEqualTo(10000), + () -> assertThat(result.likeCount()).isEqualTo(10), + () -> assertThat(result.stock()).isEqualTo(100), + () -> assertThat(result.rank()).isNull() + ); + + // verify + verify(productService, times(1)).findProductById(productId); + verify(brandService, times(1)).findBrandNameById(1L); + verify(rankingCacheService, times(1)).getProductRank(any(LocalDate.class), eq(productId)); + } + + @DisplayName("상품이 존재하고 랭킹에 등록되어 있으면 랭킹 순위가 포함되어 반환된다.") + @Test + void returnsProductInfoWithRank_whenProductExistsAndHasRank() { + // arrange + Long productId = 1L; + Product product = createProduct(1L, "상품1", 1L, 10000, 10, 100); + String brandName = "브랜드1"; + Long rank = 5L; + + doReturn(Optional.of(product)).when(productService).findProductById(productId); + doReturn(brandName).when(brandService).findBrandNameById(1L); + doReturn(rank).when(rankingCacheService).getProductRank(any(LocalDate.class), eq(productId)); // act ProductInfo result = productFacade.getProduct(productId); @@ -302,12 +343,14 @@ void returnsProductInfo_whenProductExists() { () -> assertThat(result.brandName()).isEqualTo("브랜드1"), () -> assertThat(result.price()).isEqualTo(10000), () -> assertThat(result.likeCount()).isEqualTo(10), - () -> assertThat(result.stock()).isEqualTo(100) + () -> assertThat(result.stock()).isEqualTo(100), + () -> assertThat(result.rank()).isEqualTo(5L) ); // verify verify(productService, times(1)).findProductById(productId); verify(brandService, times(1)).findBrandNameById(1L); + verify(rankingCacheService, times(1)).getProductRank(any(LocalDate.class), eq(productId)); } @DisplayName("상품이 존재하지 않으면 NOT_FOUND 예외가 발생한다.") diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/cache/ProductCacheServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/cache/ProductCacheServiceIntegrationTest.java index 63b9ec513..6d7551365 100644 --- a/apps/commerce-api/src/test/java/com/loopers/infrastructure/cache/ProductCacheServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/cache/ProductCacheServiceIntegrationTest.java @@ -336,7 +336,8 @@ private ProductInfo createProductInfo( price, likeCount, stock, - ZonedDateTime.now() + ZonedDateTime.now(), + null ); } } From 35d23f4f0d5ff99f5f7ae2b31da7f71e29bed72b Mon Sep 17 00:00:00 2001 From: Kim yeonsu Date: Fri, 26 Dec 2025 01:34:06 +0900 Subject: [PATCH 09/10] =?UTF-8?q?test:=20=EB=9E=AD=ED=82=B9=20API=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/loopers/domain/brand/Brand.java | 16 ++ .../ranking/RankingFacadeIntegrationTest.java | 214 ++++++++++++++++ .../RankingServiceIntegrationTest.java | 159 ++++++++++++ .../RankingCacheServiceIntegrationTest.java | 166 +++++++++++++ .../api/ranking/RankingV1ApiE2ETest.java | 232 ++++++++++++++++++ 5 files changed, 787 insertions(+) create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/ranking/RankingServiceIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/infrastructure/cache/RankingCacheServiceIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ApiE2ETest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java index 670d6d1dd..11f588eea 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java @@ -4,6 +4,7 @@ import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Table; +import lombok.Builder; import lombok.Getter; @Entity @@ -17,4 +18,19 @@ public class Brand extends BaseEntity { @Column(nullable = false) private String description; + public Brand() { + } + + @Builder + public Brand(String name, String description) { + this.name = name; + this.description = description; + } + + public static Brand createBrand(String name, String description) { + return Brand.builder() + .name(name) + .description(description) + .build(); + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeIntegrationTest.java new file mode 100644 index 000000000..b43c730fb --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeIntegrationTest.java @@ -0,0 +1,214 @@ +package com.loopers.application.ranking; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.product.LikeCount; +import com.loopers.domain.product.Price; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.Stock; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.support.IntegrationTest; +import com.loopers.utils.DatabaseCleanUp; +import com.loopers.utils.RedisCleanUp; +import org.junit.jupiter.api.AfterEach; +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.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +class RankingFacadeIntegrationTest extends IntegrationTest { + + @Autowired + private RankingFacade rankingFacade; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private BrandJpaRepository brandJpaRepository; + + @Autowired + @Qualifier("redisTemplateMaster") + private RedisTemplate redisTemplateMaster; + + @Autowired + private RedisCleanUp redisCleanUp; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + private static final String RANKING_KEY_PREFIX = "ranking:all:"; + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + + @AfterEach + void tearDown() { + redisCleanUp.truncateAll(); + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("일별 랭킹 조회") + @Nested + class GetDailyRanking { + + @DisplayName("랭킹 데이터가 있고 상품 정보가 있으면 정상적으로 조회된다.") + @Test + void returnsRankingInfo_whenDataExists() { + // arrange + Brand brand1 = createBrand("브랜드1"); + Brand brand2 = createBrand("브랜드2"); + brand1 = brandJpaRepository.save(brand1); + brand2 = brandJpaRepository.save(brand2); + + Product product1 = createProduct("상품1", brand1.getId(), 10000, 10, 100); + Product product2 = createProduct("상품2", brand2.getId(), 20000, 20, 200); + productRepository.saveProduct(product1); + productRepository.saveProduct(product2); + + LocalDate date = LocalDate.now(); + String key = getRankingKey(date); + ZSetOperations zSetOps = redisTemplateMaster.opsForZSet(); + zSetOps.add(key, String.valueOf(product1.getId()), 100.0); + zSetOps.add(key, String.valueOf(product2.getId()), 50.0); + + RankingCommand.GetDailyRankingCommand command = new RankingCommand.GetDailyRankingCommand( + date, 1, 20 + ); + + // act + RankingInfo result = rankingFacade.getDailyRanking(command); + + // assert + assertThat(result.items()).hasSize(2); + RankingInfo.RankingItemInfo item1 = result.items().get(0); + assertThat(item1.productId()).isEqualTo(product1.getId()); + assertThat(item1.productName()).isEqualTo("상품1"); + assertThat(item1.brandName()).isEqualTo("브랜드1"); + assertThat(item1.price()).isEqualTo(10000); + assertThat(item1.likeCount()).isEqualTo(10); + assertThat(item1.rank()).isEqualTo(1L); + assertThat(item1.score()).isEqualTo(100.0); + + RankingInfo.RankingItemInfo item2 = result.items().get(1); + assertThat(item2.productId()).isEqualTo(product2.getId()); + assertThat(item2.productName()).isEqualTo("상품2"); + assertThat(item2.brandName()).isEqualTo("브랜드2"); + assertThat(item2.price()).isEqualTo(20000); + assertThat(item2.likeCount()).isEqualTo(20); + assertThat(item2.rank()).isEqualTo(2L); + assertThat(item2.score()).isEqualTo(50.0); + } + + @DisplayName("랭킹에 있지만 DB에 없는 상품은 제외된다.") + @Test + void excludesProducts_notInDatabase() { + // arrange + Brand brand = createBrand("브랜드1"); + brand = brandJpaRepository.save(brand); + + Product product = createProduct("상품1", brand.getId(), 10000, 10, 100); + productRepository.saveProduct(product); + + LocalDate date = LocalDate.now(); + String key = getRankingKey(date); + ZSetOperations zSetOps = redisTemplateMaster.opsForZSet(); + zSetOps.add(key, String.valueOf(product.getId()), 100.0); + zSetOps.add(key, "999", 50.0); // DB에 없는 상품 + + RankingCommand.GetDailyRankingCommand command = new RankingCommand.GetDailyRankingCommand( + date, 1, 20 + ); + + // act + RankingInfo result = rankingFacade.getDailyRanking(command); + + // assert + assertThat(result.items()).hasSize(1); + assertThat(result.items().get(0).productId()).isEqualTo(product.getId()); + } + + @DisplayName("랭킹 데이터가 없으면 빈 리스트를 반환한다.") + @Test + void returnsEmptyList_whenNoRankingData() { + // arrange + RankingCommand.GetDailyRankingCommand command = new RankingCommand.GetDailyRankingCommand( + LocalDate.now(), 1, 20 + ); + + // act + RankingInfo result = rankingFacade.getDailyRanking(command); + + // assert + assertThat(result.items()).isEmpty(); + } + + @DisplayName("페이징이 정상적으로 동작한다.") + @Test + void returnsPaginatedResults() { + // arrange + Brand brand = createBrand("브랜드1"); + brand = brandJpaRepository.save(brand); + + Product product1 = createProduct("상품1", brand.getId(), 10000, 10, 100); + Product product2 = createProduct("상품2", brand.getId(), 20000, 20, 200); + Product product3 = createProduct("상품3", brand.getId(), 30000, 30, 300); + productRepository.saveProduct(product1); + productRepository.saveProduct(product2); + productRepository.saveProduct(product3); + + LocalDate date = LocalDate.now(); + String key = getRankingKey(date); + ZSetOperations zSetOps = redisTemplateMaster.opsForZSet(); + zSetOps.add(key, String.valueOf(product1.getId()), 100.0); + zSetOps.add(key, String.valueOf(product2.getId()), 50.0); + zSetOps.add(key, String.valueOf(product3.getId()), 30.0); + + // act - 첫 페이지 + RankingInfo page1 = rankingFacade.getDailyRanking( + new RankingCommand.GetDailyRankingCommand(date, 1, 2) + ); + + // assert + assertThat(page1.items()).hasSize(2); + assertThat(page1.items().get(0).productId()).isEqualTo(product1.getId()); + assertThat(page1.items().get(1).productId()).isEqualTo(product2.getId()); + + // act - 두 번째 페이지 + RankingInfo page2 = rankingFacade.getDailyRanking( + new RankingCommand.GetDailyRankingCommand(date, 2, 2) + ); + + // assert + assertThat(page2.items()).hasSize(1); + assertThat(page2.items().get(0).productId()).isEqualTo(product3.getId()); + } + } + + private Brand createBrand(String name) { + return Brand.createBrand(name, name + " 설명"); + } + + private Product createProduct(String name, Long brandId, Integer price, Integer likeCount, Integer stock) { + return Product.createProduct( + name, + brandId, + Price.createPrice(price), + LikeCount.createLikeCount(likeCount), + Stock.createStock(stock) + ); + } + + private String getRankingKey(LocalDate date) { + return RANKING_KEY_PREFIX + date.format(DATE_FORMATTER); + } +} + diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/ranking/RankingServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/ranking/RankingServiceIntegrationTest.java new file mode 100644 index 000000000..b3623777b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/ranking/RankingServiceIntegrationTest.java @@ -0,0 +1,159 @@ +package com.loopers.domain.ranking; + +import com.loopers.support.IntegrationTest; +import com.loopers.utils.RedisCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +class RankingServiceIntegrationTest extends IntegrationTest { + + @Autowired + private RankingService rankingService; + + @Autowired + @Qualifier("redisTemplateMaster") + private RedisTemplate redisTemplateMaster; + + @Autowired + private RedisCleanUp redisCleanUp; + + private static final String RANKING_KEY_PREFIX = "ranking:all:"; + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + + @BeforeEach + void setUp() { + redisCleanUp.truncateAll(); + } + + @AfterEach + void tearDown() { + redisCleanUp.truncateAll(); + } + + @DisplayName("랭킹 조회") + @Nested + class GetRanking { + + @DisplayName("랭킹 데이터가 있으면 정상적으로 조회된다.") + @Test + void returnsRankings_whenDataExists() { + // arrange + LocalDate date = LocalDate.now(); + String key = getRankingKey(date); + ZSetOperations zSetOps = redisTemplateMaster.opsForZSet(); + + zSetOps.add(key, "1", 100.0); + zSetOps.add(key, "2", 50.0); + zSetOps.add(key, "3", 30.0); + + // act + List result = rankingService.getRanking(date, 1, 20); + + // assert + assertThat(result).hasSize(3); + assertThat(result.get(0).productId()).isEqualTo(1L); + assertThat(result.get(0).rank()).isEqualTo(1L); + assertThat(result.get(0).score()).isEqualTo(100.0); + assertThat(result.get(1).productId()).isEqualTo(2L); + assertThat(result.get(1).rank()).isEqualTo(2L); + assertThat(result.get(1).score()).isEqualTo(50.0); + assertThat(result.get(2).productId()).isEqualTo(3L); + assertThat(result.get(2).rank()).isEqualTo(3L); + assertThat(result.get(2).score()).isEqualTo(30.0); + } + + @DisplayName("페이징이 정상적으로 동작한다.") + @Test + void returnsPaginatedResults() { + // arrange + LocalDate date = LocalDate.now(); + String key = getRankingKey(date); + ZSetOperations zSetOps = redisTemplateMaster.opsForZSet(); + + zSetOps.add(key, "1", 100.0); + zSetOps.add(key, "2", 90.0); + zSetOps.add(key, "3", 80.0); + zSetOps.add(key, "4", 70.0); + zSetOps.add(key, "5", 60.0); + + // act - 첫 페이지 + List page1 = rankingService.getRanking(date, 1, 2); + + // assert + assertThat(page1).hasSize(2); + assertThat(page1.get(0).productId()).isEqualTo(1L); + assertThat(page1.get(0).rank()).isEqualTo(1L); + assertThat(page1.get(1).productId()).isEqualTo(2L); + assertThat(page1.get(1).rank()).isEqualTo(2L); + + // act - 두 번째 페이지 + List page2 = rankingService.getRanking(date, 2, 2); + + // assert + assertThat(page2).hasSize(2); + assertThat(page2.get(0).productId()).isEqualTo(3L); + assertThat(page2.get(0).rank()).isEqualTo(3L); + assertThat(page2.get(1).productId()).isEqualTo(4L); + assertThat(page2.get(1).rank()).isEqualTo(4L); + + // act - 세 번째 페이지 + List page3 = rankingService.getRanking(date, 3, 2); + + // assert + assertThat(page3).hasSize(1); + assertThat(page3.get(0).productId()).isEqualTo(5L); + assertThat(page3.get(0).rank()).isEqualTo(5L); + } + + @DisplayName("랭킹 데이터가 없으면 빈 리스트를 반환한다.") + @Test + void returnsEmptyList_whenNoDataExists() { + // arrange + LocalDate date = LocalDate.now(); + + // act + List result = rankingService.getRanking(date, 1, 20); + + // assert + assertThat(result).isEmpty(); + } + + @DisplayName("순위는 1부터 시작한다.") + @Test + void startsRankFromOne() { + // arrange + LocalDate date = LocalDate.now(); + String key = getRankingKey(date); + ZSetOperations zSetOps = redisTemplateMaster.opsForZSet(); + + zSetOps.add(key, "1", 100.0); + + // act + List result = rankingService.getRanking(date, 1, 20); + + // assert + assertThat(result).hasSize(1); + assertThat(result.get(0).rank()).isEqualTo(1L); + } + } + + private String getRankingKey(LocalDate date) { + return RANKING_KEY_PREFIX + date.format(DATE_FORMATTER); + } +} + diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/cache/RankingCacheServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/cache/RankingCacheServiceIntegrationTest.java new file mode 100644 index 000000000..6c255cbf2 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/cache/RankingCacheServiceIntegrationTest.java @@ -0,0 +1,166 @@ +package com.loopers.infrastructure.cache; + +import com.loopers.domain.ranking.RankingItem; +import com.loopers.support.IntegrationTest; +import com.loopers.utils.RedisCleanUp; +import org.junit.jupiter.api.AfterEach; +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.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +class RankingCacheServiceIntegrationTest extends IntegrationTest { + + @Autowired + private RankingCacheService rankingCacheService; + + @Autowired + @Qualifier("redisTemplateMaster") + private RedisTemplate redisTemplateMaster; + + @Autowired + private RedisCleanUp redisCleanUp; + + private static final String RANKING_KEY_PREFIX = "ranking:all:"; + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + + @AfterEach + void tearDown() { + redisCleanUp.truncateAll(); + } + + @DisplayName("랭킹 범위 조회") + @Nested + class GetRankingRange { + + @DisplayName("랭킹 데이터가 있으면 정상적으로 조회된다.") + @Test + void returnsRankingItems_whenDataExists() { + // arrange + LocalDate date = LocalDate.now(); + String key = getRankingKey(date); + ZSetOperations zSetOps = redisTemplateMaster.opsForZSet(); + + zSetOps.add(key, "1", 100.0); + zSetOps.add(key, "2", 50.0); + zSetOps.add(key, "3", 30.0); + + // act + List result = rankingCacheService.getRankingRange(date, 0, 2); + + // assert + assertThat(result).hasSize(3); + assertThat(result.get(0).productId()).isEqualTo(1L); + assertThat(result.get(0).score()).isEqualTo(100.0); + assertThat(result.get(1).productId()).isEqualTo(2L); + assertThat(result.get(1).score()).isEqualTo(50.0); + assertThat(result.get(2).productId()).isEqualTo(3L); + assertThat(result.get(2).score()).isEqualTo(30.0); + } + + @DisplayName("페이징이 정상적으로 동작한다.") + @Test + void returnsPaginatedResults() { + // arrange + LocalDate date = LocalDate.now(); + String key = getRankingKey(date); + ZSetOperations zSetOps = redisTemplateMaster.opsForZSet(); + + zSetOps.add(key, "1", 100.0); + zSetOps.add(key, "2", 90.0); + zSetOps.add(key, "3", 80.0); + zSetOps.add(key, "4", 70.0); + zSetOps.add(key, "5", 60.0); + + // act - 첫 페이지 (0-1) + List page1 = rankingCacheService.getRankingRange(date, 0, 1); + + // assert + assertThat(page1).hasSize(2); + assertThat(page1.get(0).productId()).isEqualTo(1L); + assertThat(page1.get(1).productId()).isEqualTo(2L); + + // act - 두 번째 페이지 (2-3) + List page2 = rankingCacheService.getRankingRange(date, 2, 3); + + // assert + assertThat(page2).hasSize(2); + assertThat(page2.get(0).productId()).isEqualTo(3L); + assertThat(page2.get(1).productId()).isEqualTo(4L); + } + + @DisplayName("랭킹 데이터가 없으면 빈 리스트를 반환한다.") + @Test + void returnsEmptyList_whenNoDataExists() { + // arrange + LocalDate date = LocalDate.now(); + + // act + List result = rankingCacheService.getRankingRange(date, 0, 10); + + // assert + assertThat(result).isEmpty(); + } + } + + @DisplayName("상품 랭킹 조회") + @Nested + class GetProductRank { + + @DisplayName("랭킹에 있는 상품의 순위를 반환한다.") + @Test + void returnsRank_whenProductExistsInRanking() { + // arrange + LocalDate date = LocalDate.now(); + String key = getRankingKey(date); + ZSetOperations zSetOps = redisTemplateMaster.opsForZSet(); + + zSetOps.add(key, "1", 100.0); + zSetOps.add(key, "2", 50.0); + zSetOps.add(key, "3", 30.0); + + // act + Long rank1 = rankingCacheService.getProductRank(date, 1L); + Long rank2 = rankingCacheService.getProductRank(date, 2L); + Long rank3 = rankingCacheService.getProductRank(date, 3L); + + // assert + assertThat(rank1).isEqualTo(1L); // 1등 + assertThat(rank2).isEqualTo(2L); // 2등 + assertThat(rank3).isEqualTo(3L); // 3등 + } + + @DisplayName("랭킹에 없는 상품은 null을 반환한다.") + @Test + void returnsNull_whenProductNotInRanking() { + // arrange + LocalDate date = LocalDate.now(); + String key = getRankingKey(date); + ZSetOperations zSetOps = redisTemplateMaster.opsForZSet(); + + zSetOps.add(key, "1", 100.0); + + // act + Long rank = rankingCacheService.getProductRank(date, 999L); + + // assert + assertThat(rank).isNull(); + } + } + + private String getRankingKey(LocalDate date) { + return RANKING_KEY_PREFIX + date.format(DATE_FORMATTER); + } +} + diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ApiE2ETest.java new file mode 100644 index 000000000..61685253b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ApiE2ETest.java @@ -0,0 +1,232 @@ +package com.loopers.interfaces.api.ranking; + +import com.loopers.domain.brand.Brand; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.domain.product.LikeCount; +import com.loopers.domain.product.Price; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.Stock; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.support.IntegrationTest; +import com.loopers.utils.DatabaseCleanUp; +import com.loopers.utils.RedisCleanUp; +import org.junit.jupiter.api.AfterEach; +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.beans.factory.annotation.Qualifier; +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.data.redis.core.ZSetOperations; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class RankingV1ApiE2ETest extends IntegrationTest { + + private static final String ENDPOINT_RANKING = "/api/v1/rankings"; + private static final String RANKING_KEY_PREFIX = "ranking:all:"; + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + + private final TestRestTemplate testRestTemplate; + private final BrandJpaRepository brandJpaRepository; + private final ProductRepository productRepository; + private final DatabaseCleanUp databaseCleanUp; + private final RedisCleanUp redisCleanUp; + + @Autowired + @Qualifier("redisTemplateMaster") + private RedisTemplate redisTemplateMaster; + + @Autowired + public RankingV1ApiE2ETest( + TestRestTemplate testRestTemplate, + BrandJpaRepository brandJpaRepository, + ProductRepository productRepository, + DatabaseCleanUp databaseCleanUp, + RedisCleanUp redisCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.brandJpaRepository = brandJpaRepository; + this.productRepository = productRepository; + this.databaseCleanUp = databaseCleanUp; + this.redisCleanUp = redisCleanUp; + } + + @AfterEach + void tearDown() { + redisCleanUp.truncateAll(); + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("GET /api/v1/rankings - 일별 랭킹 조회") + @Nested + class GetDailyRanking { + + @DisplayName("랭킹 데이터가 있으면 정상적으로 반환된다.") + @Test + void returnsRanking_whenDataExists() { + // arrange + Brand brand1 = createBrand("브랜드1"); + Brand brand2 = createBrand("브랜드2"); + brand1 = brandJpaRepository.save(brand1); + brand2 = brandJpaRepository.save(brand2); + + Product product1 = createProduct("상품1", brand1.getId(), 10000, 10, 100); + Product product2 = createProduct("상품2", brand2.getId(), 20000, 20, 200); + productRepository.saveProduct(product1); + productRepository.saveProduct(product2); + + LocalDate date = LocalDate.now(); + String key = getRankingKey(date); + ZSetOperations zSetOps = redisTemplateMaster.opsForZSet(); + zSetOps.add(key, String.valueOf(product1.getId()), 100.0); + zSetOps.add(key, String.valueOf(product2.getId()), 50.0); + + String url = ENDPOINT_RANKING + "?date=" + date.format(DATE_FORMATTER) + "&page=1&size=20"; + + // act + ResponseEntity> response = testRestTemplate.exchange( + url, + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS); + + RankingV1Dto.DailyRankingListResponse rankingResponse = response.getBody().data(); + assertThat(rankingResponse.rankings()).hasSize(2); + + RankingV1Dto.DailyRankingItem item1 = rankingResponse.rankings().get(0); + assertAll( + () -> assertThat(item1.productId()).isEqualTo(product1.getId()), + () -> assertThat(item1.productName()).isEqualTo("상품1"), + () -> assertThat(item1.brandName()).isEqualTo("브랜드1"), + () -> assertThat(item1.price()).isEqualTo(10000), + () -> assertThat(item1.likeCount()).isEqualTo(10), + () -> assertThat(item1.rank()).isEqualTo(1) + ); + + RankingV1Dto.DailyRankingItem item2 = rankingResponse.rankings().get(1); + assertAll( + () -> assertThat(item2.productId()).isEqualTo(product2.getId()), + () -> assertThat(item2.productName()).isEqualTo("상품2"), + () -> assertThat(item2.brandName()).isEqualTo("브랜드2"), + () -> assertThat(item2.price()).isEqualTo(20000), + () -> assertThat(item2.likeCount()).isEqualTo(20), + () -> assertThat(item2.rank()).isEqualTo(2) + ); + } + + @DisplayName("랭킹 데이터가 없으면 빈 리스트를 반환한다.") + @Test + void returnsEmptyList_whenNoDataExists() { + // arrange + LocalDate date = LocalDate.now(); + String url = ENDPOINT_RANKING + "?date=" + date.format(DATE_FORMATTER) + "&page=1&size=20"; + + // act + ResponseEntity> response = testRestTemplate.exchange( + url, + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS); + + RankingV1Dto.DailyRankingListResponse rankingResponse = response.getBody().data(); + assertThat(rankingResponse.rankings()).isEmpty(); + } + + @DisplayName("페이징이 정상적으로 동작한다.") + @Test + void returnsPaginatedResults() { + // arrange + Brand brand = createBrand("브랜드1"); + brand = brandJpaRepository.save(brand); + + Product product1 = createProduct("상품1", brand.getId(), 10000, 10, 100); + Product product2 = createProduct("상품2", brand.getId(), 20000, 20, 200); + Product product3 = createProduct("상품3", brand.getId(), 30000, 30, 300); + productRepository.saveProduct(product1); + productRepository.saveProduct(product2); + productRepository.saveProduct(product3); + + LocalDate date = LocalDate.now(); + String key = getRankingKey(date); + ZSetOperations zSetOps = redisTemplateMaster.opsForZSet(); + zSetOps.add(key, String.valueOf(product1.getId()), 100.0); + zSetOps.add(key, String.valueOf(product2.getId()), 50.0); + zSetOps.add(key, String.valueOf(product3.getId()), 30.0); + + // act - 첫 페이지 + String url1 = ENDPOINT_RANKING + "?date=" + date.format(DATE_FORMATTER) + "&page=1&size=2"; + ResponseEntity> response1 = testRestTemplate.exchange( + url1, + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response1.getStatusCode()).isEqualTo(HttpStatus.OK); + RankingV1Dto.DailyRankingListResponse page1 = response1.getBody().data(); + assertThat(page1.rankings()).hasSize(2); + assertThat(page1.rankings().get(0).productId()).isEqualTo(product1.getId()); + assertThat(page1.rankings().get(1).productId()).isEqualTo(product2.getId()); + + // act - 두 번째 페이지 + String url2 = ENDPOINT_RANKING + "?date=" + date.format(DATE_FORMATTER) + "&page=2&size=2"; + ResponseEntity> response2 = testRestTemplate.exchange( + url2, + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response2.getStatusCode()).isEqualTo(HttpStatus.OK); + RankingV1Dto.DailyRankingListResponse page2 = response2.getBody().data(); + assertThat(page2.rankings()).hasSize(1); + assertThat(page2.rankings().get(0).productId()).isEqualTo(product3.getId()); + } + } + + private Brand createBrand(String name) { + return Brand.createBrand(name, name + " 설명"); + } + + private Product createProduct(String name, Long brandId, Integer price, Integer likeCount, Integer stock) { + return Product.createProduct( + name, + brandId, + Price.createPrice(price), + LikeCount.createLikeCount(likeCount), + Stock.createStock(stock) + ); + } + + private String getRankingKey(LocalDate date) { + return RANKING_KEY_PREFIX + date.format(DATE_FORMATTER); + } +} + From f834a6bbe9582b2fd389cdfb33b2358c7648facc Mon Sep 17 00:00:00 2001 From: Kim yeonsu Date: Fri, 26 Dec 2025 01:34:23 +0900 Subject: [PATCH 10/10] =?UTF-8?q?test:=20=EB=9E=AD=ED=82=B9=20=EC=BB=A8?= =?UTF-8?q?=EC=8A=88=EB=A8=B8=20=EA=B4=80=EB=A0=A8=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RankingServiceIntegrationTest.java | 223 ++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingServiceIntegrationTest.java diff --git a/apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingServiceIntegrationTest.java b/apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingServiceIntegrationTest.java new file mode 100644 index 000000000..b44bf6a09 --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingServiceIntegrationTest.java @@ -0,0 +1,223 @@ +package com.loopers.domain.ranking; + +import com.loopers.utils.RedisCleanUp; +import org.junit.jupiter.api.AfterEach; +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.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +@SpringBootTest +class RankingServiceIntegrationTest { + + @Autowired + private RankingService rankingService; + + @Autowired + @Qualifier("redisTemplateMaster") + private RedisTemplate redisTemplateMaster; + + @Autowired + private RedisCleanUp redisCleanUp; + + private static final String RANKING_KEY_PREFIX = "ranking:all:"; + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + + @AfterEach + void tearDown() { + redisCleanUp.truncateAll(); + } + + @DisplayName("랭킹 점수 증가") + @Nested + class IncrementScore { + + @DisplayName("좋아요 이벤트 발생 시 점수가 적절하게 증가한다.") + @Test + void incrementsScore_whenLikeEventOccurs() { + // arrange + Long productId = 1L; + LocalDate date = LocalDate.now(); + String key = getRankingKey(date); + + // act + rankingService.incrementScore(productId, RankingWeight.LIKE); + + // assert + ZSetOperations zSetOps = redisTemplateMaster.opsForZSet(); + Double score = zSetOps.score(key, String.valueOf(productId)); + assertThat(score).isNotNull(); + assertThat(score).isCloseTo(RankingWeight.LIKE.getWeight(), within(0.001)); + + // TTL 확인 + Long ttl = redisTemplateMaster.getExpire(key); + assertThat(ttl).isNotNull(); + assertThat(ttl).isGreaterThan(0); + assertThat(ttl).isLessThanOrEqualTo(2 * 24 * 60 * 60); // 2일 + } + + @DisplayName("조회수 이벤트 발생 시 점수가 적절하게 증가한다.") + @Test + void incrementsScore_whenViewEventOccurs() { + // arrange + Long productId = 2L; + LocalDate date = LocalDate.now(); + String key = getRankingKey(date); + + // act + rankingService.incrementScore(productId, RankingWeight.VIEW); + + // assert + ZSetOperations zSetOps = redisTemplateMaster.opsForZSet(); + Double score = zSetOps.score(key, String.valueOf(productId)); + assertThat(score).isNotNull(); + assertThat(score).isCloseTo(RankingWeight.VIEW.getWeight(), within(0.001)); + } + + @DisplayName("주문 생성 이벤트 발생 시 점수가 적절하게 증가한다.") + @Test + void incrementsScore_whenOrderCreatedEventOccurs() { + // arrange + Long productId = 3L; + LocalDate date = LocalDate.now(); + String key = getRankingKey(date); + + // act + rankingService.incrementScore(productId, RankingWeight.ORDER_CREATED); + + // assert + ZSetOperations zSetOps = redisTemplateMaster.opsForZSet(); + Double score = zSetOps.score(key, String.valueOf(productId)); + assertThat(score).isNotNull(); + assertThat(score).isCloseTo(RankingWeight.ORDER_CREATED.getWeight(), within(0.001)); + } + + @DisplayName("여러 번 점수를 증가시키면 누적된다.") + @Test + void accumulatesScore_whenMultipleIncrements() { + // arrange + Long productId = 4L; + LocalDate date = LocalDate.now(); + String key = getRankingKey(date); + + // act + rankingService.incrementScore(productId, RankingWeight.LIKE); + rankingService.incrementScore(productId, RankingWeight.VIEW); + rankingService.incrementScore(productId, RankingWeight.ORDER_CREATED); + + // assert + ZSetOperations zSetOps = redisTemplateMaster.opsForZSet(); + Double score = zSetOps.score(key, String.valueOf(productId)); + assertThat(score).isNotNull(); + double expectedScore = RankingWeight.LIKE.getWeight() + + RankingWeight.VIEW.getWeight() + + RankingWeight.ORDER_CREATED.getWeight(); + assertThat(score).isCloseTo(expectedScore, within(0.001)); + } + + @DisplayName("날짜별로 다른 키를 사용한다.") + @Test + void usesDifferentKeys_forDifferentDates() { + // arrange + Long productId = 5L; + LocalDate today = LocalDate.now(); + LocalDate tomorrow = today.plusDays(1); + + // act + rankingService.incrementScore(productId, RankingWeight.LIKE); + + // assert + String todayKey = getRankingKey(today); + String tomorrowKey = getRankingKey(tomorrow); + + ZSetOperations zSetOps = redisTemplateMaster.opsForZSet(); + Double todayScore = zSetOps.score(todayKey, String.valueOf(productId)); + Double tomorrowScore = zSetOps.score(tomorrowKey, String.valueOf(productId)); + + assertThat(todayScore).isNotNull(); + assertThat(tomorrowScore).isNull(); // 내일 키에는 데이터가 없음 + } + } + + @DisplayName("랭킹 점수 Carry-Over") + @Nested + class CarryOverScore { + + @DisplayName("전날 점수를 다음 날로 carry-over한다.") + @Test + void carriesOverScore_fromPreviousDayToNextDay() { + // arrange + Long productId1 = 10L; + Long productId2 = 20L; + LocalDate yesterday = LocalDate.now().minusDays(1); + LocalDate today = LocalDate.now(); + + String yesterdayKey = getRankingKey(yesterday); + String todayKey = getRankingKey(today); + + // 전날 데이터 설정 + ZSetOperations zSetOps = redisTemplateMaster.opsForZSet(); + zSetOps.add(yesterdayKey, String.valueOf(productId1), 100.0); + zSetOps.add(yesterdayKey, String.valueOf(productId2), 50.0); + + // act + rankingService.carryOverScore(yesterday, today); + + // assert + Double todayScore1 = zSetOps.score(todayKey, String.valueOf(productId1)); + Double todayScore2 = zSetOps.score(todayKey, String.valueOf(productId2)); + + assertThat(todayScore1).isNotNull(); + assertThat(todayScore2).isNotNull(); + + // carry-over weight (0.1)가 적용된 점수 + assertThat(todayScore1).isCloseTo(100.0 * RankingWeight.CARRY_OVER.getWeight(), within(0.001)); + assertThat(todayScore2).isCloseTo(50.0 * RankingWeight.CARRY_OVER.getWeight(), within(0.001)); + + // TTL 확인 + Long ttl = redisTemplateMaster.getExpire(todayKey); + assertThat(ttl).isNotNull(); + assertThat(ttl).isGreaterThan(0); + } + + @DisplayName("carry-over 후에도 원본 데이터는 유지된다.") + @Test + void preservesOriginalData_afterCarryOver() { + // arrange + Long productId = 30L; + LocalDate yesterday = LocalDate.now().minusDays(1); + LocalDate today = LocalDate.now(); + + String yesterdayKey = getRankingKey(yesterday); + String todayKey = getRankingKey(today); + + ZSetOperations zSetOps = redisTemplateMaster.opsForZSet(); + zSetOps.add(yesterdayKey, String.valueOf(productId), 200.0); + + // act + rankingService.carryOverScore(yesterday, today); + + // assert + Double yesterdayScore = zSetOps.score(yesterdayKey, String.valueOf(productId)); + Double todayScore = zSetOps.score(todayKey, String.valueOf(productId)); + + assertThat(yesterdayScore).isCloseTo(200.0, within(0.001)); + assertThat(todayScore).isCloseTo(200.0 * RankingWeight.CARRY_OVER.getWeight(), within(0.001)); + } + } + + private String getRankingKey(LocalDate date) { + return RANKING_KEY_PREFIX + date.format(DATE_FORMATTER); + } +} +