From 449e4aea91219f367d5e4ebc6533459ae6f70ce3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=84=80=E1=85=A5=E1=86=AB=E1=84=8B?=
=?UTF-8?q?=E1=85=A7=E1=86=BC?= <>
Date: Sun, 21 Dec 2025 21:31:04 +0900
Subject: [PATCH 01/10] =?UTF-8?q?feat:=20zset=20=EB=AA=A8=EB=93=88=20?=
=?UTF-8?q?=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
zset
---
.../com/loopers/zset/RedisZSetTemplate.java | 140 ++++++++++++++++++
.../main/java/com/loopers/zset/ZSetEntry.java | 12 ++
2 files changed, 152 insertions(+)
create mode 100644 modules/redis/src/main/java/com/loopers/zset/RedisZSetTemplate.java
create mode 100644 modules/redis/src/main/java/com/loopers/zset/ZSetEntry.java
diff --git a/modules/redis/src/main/java/com/loopers/zset/RedisZSetTemplate.java b/modules/redis/src/main/java/com/loopers/zset/RedisZSetTemplate.java
new file mode 100644
index 000000000..2d762a879
--- /dev/null
+++ b/modules/redis/src/main/java/com/loopers/zset/RedisZSetTemplate.java
@@ -0,0 +1,140 @@
+package com.loopers.zset;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.core.ZSetOperations;
+import org.springframework.stereotype.Component;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Redis ZSET 템플릿.
+ *
+ * Redis Sorted Set (ZSET) 조작 기능을 제공합니다.
+ * ZSET은 Redis 전용 데이터 구조이므로 인터페이스 분리 없이 클래스로 직접 제공합니다.
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class RedisZSetTemplate {
+
+ private final RedisTemplate redisTemplate;
+
+ /**
+ * ZSET에 점수를 증가시킵니다.
+ *
+ * ZINCRBY는 원자적 연산이므로 동시성 문제가 없습니다.
+ *
+ *
+ * @param key ZSET 키
+ * @param member 멤버 (예: 상품 ID)
+ * @param score 증가시킬 점수
+ */
+ public void incrementScore(String key, String member, double score) {
+ try {
+ redisTemplate.opsForZSet().incrementScore(key, member, score);
+ } catch (Exception e) {
+ log.warn("ZSET 점수 증가 실패: key={}, member={}, score={}", key, member, score, e);
+ // Redis 연결 실패 시 로그만 기록하고 계속 진행
+ }
+ }
+
+ /**
+ * ZSET의 TTL을 설정합니다.
+ *
+ * 이미 TTL이 설정되어 있으면 설정하지 않습니다.
+ *
+ *
+ * @param key ZSET 키
+ * @param ttl TTL (Duration)
+ */
+ public void setTtlIfNotExists(String key, Duration ttl) {
+ try {
+ Long currentTtl = redisTemplate.getExpire(key);
+ if (currentTtl == null || currentTtl == -1) {
+ // TTL이 없거나 -1(만료 시간 없음)인 경우에만 설정
+ redisTemplate.expire(key, ttl);
+ }
+ } catch (Exception e) {
+ log.warn("ZSET TTL 설정 실패: key={}", key, e);
+ }
+ }
+
+ /**
+ * 특정 멤버의 순위를 조회합니다.
+ *
+ * 점수가 높은 순서대로 정렬된 순위를 반환합니다 (0부터 시작).
+ * 멤버가 없으면 null을 반환합니다.
+ *
+ *
+ * @param key ZSET 키
+ * @param member 멤버
+ * @return 순위 (0부터 시작, 없으면 null)
+ */
+ public Long getRank(String key, String member) {
+ try {
+ return redisTemplate.opsForZSet().reverseRank(key, member);
+ } catch (Exception e) {
+ log.warn("ZSET 순위 조회 실패: key={}, member={}", key, member, e);
+ return null;
+ }
+ }
+
+ /**
+ * ZSET에서 상위 N개 멤버를 조회합니다.
+ *
+ * 점수가 높은 순서대로 정렬된 멤버와 점수를 반환합니다.
+ *
+ *
+ * @param key ZSET 키
+ * @param start 시작 인덱스 (0부터 시작)
+ * @param end 종료 인덱스 (포함)
+ * @return 멤버와 점수 쌍의 리스트
+ */
+ public List getTopRankings(String key, long start, long end) {
+ try {
+ Set> tuples = redisTemplate.opsForZSet()
+ .reverseRangeWithScores(key, start, end);
+
+ if (tuples == null) {
+ return List.of();
+ }
+
+ List entries = new ArrayList<>();
+ for (ZSetOperations.TypedTuple tuple : tuples) {
+ entries.add(new ZSetEntry(tuple.getValue(), tuple.getScore()));
+ }
+ return entries;
+ } catch (Exception e) {
+ log.warn("ZSET 상위 랭킹 조회 실패: key={}, start={}, end={}", key, start, end, e);
+ return List.of();
+ }
+ }
+
+ /**
+ * ZSET의 크기를 조회합니다.
+ *
+ * ZSET에 포함된 멤버의 총 개수를 반환합니다.
+ *
+ *
+ * @param key ZSET 키
+ * @return ZSET 크기 (없으면 0)
+ */
+ public Long getSize(String key) {
+ try {
+ Long size = redisTemplate.opsForZSet().size(key);
+ return size != null ? size : 0L;
+ } catch (Exception e) {
+ log.warn("ZSET 크기 조회 실패: key={}", key, e);
+ return 0L;
+ }
+ }
+}
diff --git a/modules/redis/src/main/java/com/loopers/zset/ZSetEntry.java b/modules/redis/src/main/java/com/loopers/zset/ZSetEntry.java
new file mode 100644
index 000000000..0c9642503
--- /dev/null
+++ b/modules/redis/src/main/java/com/loopers/zset/ZSetEntry.java
@@ -0,0 +1,12 @@
+package com.loopers.zset;
+
+/**
+ * ZSET 엔트리 (멤버와 점수 쌍).
+ *
+ * @param member 멤버
+ * @param score 점수
+ * @author Loopers
+ * @version 1.0
+ */
+public record ZSetEntry(String member, Double score) {
+}
From 65036ac751be84980758d0a0b96b4947ed7f1efb Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=84=80=E1=85=A5=E1=86=AB=E1=84=8B?=
=?UTF-8?q?=E1=85=A7=E1=86=BC?= <>
Date: Sun, 21 Dec 2025 22:04:19 +0900
Subject: [PATCH 02/10] =?UTF-8?q?test:=20=EB=9E=AD=ED=82=B9=20=EA=B3=84?=
=?UTF-8?q?=EC=82=B0=EC=97=90=20=EB=8C=80=ED=95=9C=20=ED=85=8C=EC=8A=A4?=
=?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../ranking/RankingServiceTest.java | 247 ++++++++++++++++++
1 file changed, 247 insertions(+)
create mode 100644 apps/commerce-streamer/src/test/java/com/loopers/application/ranking/RankingServiceTest.java
diff --git a/apps/commerce-streamer/src/test/java/com/loopers/application/ranking/RankingServiceTest.java b/apps/commerce-streamer/src/test/java/com/loopers/application/ranking/RankingServiceTest.java
new file mode 100644
index 000000000..cad3228aa
--- /dev/null
+++ b/apps/commerce-streamer/src/test/java/com/loopers/application/ranking/RankingServiceTest.java
@@ -0,0 +1,247 @@
+package com.loopers.application.ranking;
+
+import com.loopers.zset.RedisZSetTemplate;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.time.Duration;
+import java.time.LocalDate;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.*;
+
+/**
+ * RankingService 테스트.
+ */
+@ExtendWith(MockitoExtension.class)
+class RankingServiceTest {
+
+ @Mock
+ private RedisZSetTemplate zSetTemplate;
+
+ @Mock
+ private RankingKeyGenerator keyGenerator;
+
+ @InjectMocks
+ private RankingService rankingService;
+
+ @DisplayName("조회 점수를 ZSET에 추가할 수 있다.")
+ @Test
+ void canAddViewScore() {
+ // arrange
+ Long productId = 1L;
+ LocalDate date = LocalDate.of(2024, 12, 15);
+ String expectedKey = "ranking:all:20241215";
+ double expectedScore = 0.1; // VIEW_WEIGHT
+
+ when(keyGenerator.generateDailyKey(date)).thenReturn(expectedKey);
+
+ // act
+ rankingService.addViewScore(productId, date);
+
+ // assert
+ verify(keyGenerator).generateDailyKey(date);
+ verify(zSetTemplate).incrementScore(eq(expectedKey), eq(String.valueOf(productId)), eq(expectedScore));
+ verify(zSetTemplate).setTtlIfNotExists(eq(expectedKey), eq(Duration.ofDays(2)));
+ }
+
+ @DisplayName("좋아요 추가 시 점수를 ZSET에 추가할 수 있다.")
+ @Test
+ void canAddLikeScore_whenAdded() {
+ // arrange
+ Long productId = 1L;
+ LocalDate date = LocalDate.of(2024, 12, 15);
+ String expectedKey = "ranking:all:20241215";
+ double expectedScore = 0.2; // LIKE_WEIGHT
+ boolean isAdded = true;
+
+ when(keyGenerator.generateDailyKey(date)).thenReturn(expectedKey);
+
+ // act
+ rankingService.addLikeScore(productId, date, isAdded);
+
+ // assert
+ verify(keyGenerator).generateDailyKey(date);
+ verify(zSetTemplate).incrementScore(eq(expectedKey), eq(String.valueOf(productId)), eq(expectedScore));
+ verify(zSetTemplate).setTtlIfNotExists(eq(expectedKey), eq(Duration.ofDays(2)));
+ }
+
+ @DisplayName("좋아요 취소 시 점수를 ZSET에서 차감할 수 있다.")
+ @Test
+ void canSubtractLikeScore_whenRemoved() {
+ // arrange
+ Long productId = 1L;
+ LocalDate date = LocalDate.of(2024, 12, 15);
+ String expectedKey = "ranking:all:20241215";
+ double expectedScore = -0.2; // -LIKE_WEIGHT
+ boolean isAdded = false;
+
+ when(keyGenerator.generateDailyKey(date)).thenReturn(expectedKey);
+
+ // act
+ rankingService.addLikeScore(productId, date, isAdded);
+
+ // assert
+ verify(keyGenerator).generateDailyKey(date);
+ verify(zSetTemplate).incrementScore(eq(expectedKey), eq(String.valueOf(productId)), eq(expectedScore));
+ verify(zSetTemplate).setTtlIfNotExists(eq(expectedKey), eq(Duration.ofDays(2)));
+ }
+
+ @DisplayName("주문 점수를 ZSET에 추가할 수 있다.")
+ @Test
+ void canAddOrderScore() {
+ // arrange
+ Long productId = 1L;
+ LocalDate date = LocalDate.of(2024, 12, 15);
+ String expectedKey = "ranking:all:20241215";
+ double orderAmount = 10000.0;
+ // 정규화: log(1 + orderAmount) * ORDER_WEIGHT
+ // log(1 + 10000) ≈ 9.2103, 9.2103 * 0.6 ≈ 5.526
+ double expectedScore = Math.log1p(orderAmount) * 0.6; // ORDER_WEIGHT = 0.6
+
+ when(keyGenerator.generateDailyKey(date)).thenReturn(expectedKey);
+
+ // act
+ rankingService.addOrderScore(productId, date, orderAmount);
+
+ // assert
+ verify(keyGenerator).generateDailyKey(date);
+ verify(zSetTemplate).incrementScore(eq(expectedKey), eq(String.valueOf(productId)), eq(expectedScore));
+ verify(zSetTemplate).setTtlIfNotExists(eq(expectedKey), eq(Duration.ofDays(2)));
+ }
+
+ @DisplayName("주문 금액이 0일 때도 정상적으로 처리된다.")
+ @Test
+ void canAddOrderScore_whenOrderAmountIsZero() {
+ // arrange
+ Long productId = 1L;
+ LocalDate date = LocalDate.of(2024, 12, 15);
+ String expectedKey = "ranking:all:20241215";
+ double orderAmount = 0.0;
+ double expectedScore = Math.log1p(orderAmount) * 0.6; // log(1) * 0.6 = 0
+
+ when(keyGenerator.generateDailyKey(date)).thenReturn(expectedKey);
+
+ // act
+ rankingService.addOrderScore(productId, date, orderAmount);
+
+ // assert
+ verify(keyGenerator).generateDailyKey(date);
+ verify(zSetTemplate).incrementScore(eq(expectedKey), eq(String.valueOf(productId)), eq(expectedScore));
+ verify(zSetTemplate).setTtlIfNotExists(eq(expectedKey), eq(Duration.ofDays(2)));
+ }
+
+ @DisplayName("배치로 여러 상품의 점수를 한 번에 적재할 수 있다.")
+ @Test
+ void canAddScoresBatch() {
+ // arrange
+ LocalDate date = LocalDate.of(2024, 12, 15);
+ String expectedKey = "ranking:all:20241215";
+
+ Map scoreMap = new HashMap<>();
+ scoreMap.put(1L, 10.5);
+ scoreMap.put(2L, 20.3);
+ scoreMap.put(3L, 15.7);
+
+ when(keyGenerator.generateDailyKey(date)).thenReturn(expectedKey);
+
+ // act
+ rankingService.addScoresBatch(scoreMap, date);
+
+ // assert
+ verify(keyGenerator).generateDailyKey(date);
+
+ // 각 상품에 대해 incrementScore 호출 확인
+ verify(zSetTemplate).incrementScore(eq(expectedKey), eq("1"), eq(10.5));
+ verify(zSetTemplate).incrementScore(eq(expectedKey), eq("2"), eq(20.3));
+ verify(zSetTemplate).incrementScore(eq(expectedKey), eq("3"), eq(15.7));
+
+ // TTL 설정은 한 번만 호출
+ verify(zSetTemplate, times(1)).setTtlIfNotExists(eq(expectedKey), eq(Duration.ofDays(2)));
+ }
+
+ @DisplayName("빈 맵을 배치로 적재할 때는 아무 작업도 수행하지 않는다.")
+ @Test
+ void doesNothing_whenBatchIsEmpty() {
+ // arrange
+ LocalDate date = LocalDate.of(2024, 12, 15);
+ Map emptyScoreMap = new HashMap<>();
+
+ // act
+ rankingService.addScoresBatch(emptyScoreMap, date);
+
+ // assert
+ verify(keyGenerator, never()).generateDailyKey(any());
+ verify(zSetTemplate, never()).incrementScore(anyString(), anyString(), anyDouble());
+ verify(zSetTemplate, never()).setTtlIfNotExists(anyString(), any(Duration.class));
+ }
+
+ @DisplayName("여러 날짜에 대해 독립적으로 점수를 추가할 수 있다.")
+ @Test
+ void canAddScoresForDifferentDates() {
+ // arrange
+ Long productId = 1L;
+ LocalDate date1 = LocalDate.of(2024, 12, 15);
+ LocalDate date2 = LocalDate.of(2024, 12, 16);
+ String key1 = "ranking:all:20241215";
+ String key2 = "ranking:all:20241216";
+
+ when(keyGenerator.generateDailyKey(date1)).thenReturn(key1);
+ when(keyGenerator.generateDailyKey(date2)).thenReturn(key2);
+
+ // act
+ rankingService.addViewScore(productId, date1);
+ rankingService.addViewScore(productId, date2);
+
+ // assert
+ verify(keyGenerator).generateDailyKey(date1);
+ verify(keyGenerator).generateDailyKey(date2);
+ verify(zSetTemplate).incrementScore(eq(key1), eq(String.valueOf(productId)), eq(0.1));
+ verify(zSetTemplate).incrementScore(eq(key2), eq(String.valueOf(productId)), eq(0.1));
+ verify(zSetTemplate).setTtlIfNotExists(eq(key1), eq(Duration.ofDays(2)));
+ verify(zSetTemplate).setTtlIfNotExists(eq(key2), eq(Duration.ofDays(2)));
+ }
+
+ @DisplayName("같은 상품에 여러 이벤트를 추가하면 점수가 누적된다.")
+ @Test
+ void accumulatesScoresForSameProduct() {
+ // arrange
+ Long productId = 1L;
+ LocalDate date = LocalDate.of(2024, 12, 15);
+ String expectedKey = "ranking:all:20241215";
+
+ when(keyGenerator.generateDailyKey(date)).thenReturn(expectedKey);
+
+ // act
+ rankingService.addViewScore(productId, date); // +0.1
+ rankingService.addLikeScore(productId, date, true); // +0.2
+ rankingService.addOrderScore(productId, date, 1000.0); // +log(1001) * 0.6
+
+ // assert
+ verify(keyGenerator, times(3)).generateDailyKey(date);
+
+ // 각 이벤트별로 incrementScore 호출 확인
+ verify(zSetTemplate).incrementScore(eq(expectedKey), eq(String.valueOf(productId)), eq(0.1));
+ verify(zSetTemplate).incrementScore(eq(expectedKey), eq(String.valueOf(productId)), eq(0.2));
+
+ ArgumentCaptor scoreCaptor = ArgumentCaptor.forClass(Double.class);
+ verify(zSetTemplate, times(3)).incrementScore(eq(expectedKey), eq(String.valueOf(productId)), scoreCaptor.capture());
+
+ // 주문 점수 계산 확인
+ double orderScore = scoreCaptor.getAllValues().get(2);
+ double expectedOrderScore = Math.log1p(1000.0) * 0.6;
+ assertThat(orderScore).isCloseTo(expectedOrderScore, org.assertj.core.data.Offset.offset(0.001));
+
+ // TTL 설정은 각 호출마다 수행됨 (incrementScore 내부에서 호출)
+ verify(zSetTemplate, times(3)).setTtlIfNotExists(eq(expectedKey), eq(Duration.ofDays(2)));
+ }
+}
From 7e1af76889b4850cc7ce6802267342bceee2cf52 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=84=80=E1=85=A5=E1=86=AB=E1=84=8B?=
=?UTF-8?q?=E1=85=A7=E1=86=BC?= <>
Date: Sun, 21 Dec 2025 22:04:59 +0900
Subject: [PATCH 03/10] =?UTF-8?q?feat:=20=EB=9E=AD=ED=82=B9=20=EA=B3=84?=
=?UTF-8?q?=EC=82=B0=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EA=B5=AC=ED=98=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../ranking/RankingKeyGenerator.java | 52 +++++++
.../application/ranking/RankingService.java | 138 ++++++++++++++++++
2 files changed, 190 insertions(+)
create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingKeyGenerator.java
create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingService.java
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingKeyGenerator.java b/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingKeyGenerator.java
new file mode 100644
index 000000000..f87a52422
--- /dev/null
+++ b/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingKeyGenerator.java
@@ -0,0 +1,52 @@
+package com.loopers.application.ranking;
+
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+
+/**
+ * 랭킹 키 생성 유틸리티.
+ *
+ * Redis ZSET 랭킹 키를 생성합니다.
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Component
+public class RankingKeyGenerator {
+ private static final String DAILY_KEY_PREFIX = "ranking:all:";
+ private static final String HOURLY_KEY_PREFIX = "ranking:hourly:";
+ private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd");
+ private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHH");
+
+ /**
+ * 일간 랭킹 키를 생성합니다.
+ *
+ * 예: ranking:all:20241215
+ *
+ *
+ * @param date 날짜
+ * @return 일간 랭킹 키
+ */
+ public String generateDailyKey(LocalDate date) {
+ String dateStr = date.format(DATE_FORMATTER);
+ return DAILY_KEY_PREFIX + dateStr;
+ }
+
+ /**
+ * 시간 단위 랭킹 키를 생성합니다.
+ *
+ * 예: ranking:hourly:2024121514
+ *
+ *
+ * @param dateTime 날짜 및 시간
+ * @return 시간 단위 랭킹 키
+ */
+ public String generateHourlyKey(LocalDateTime dateTime) {
+ String dateTimeStr = dateTime.format(DATE_TIME_FORMATTER);
+ return HOURLY_KEY_PREFIX + dateTimeStr;
+ }
+}
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingService.java b/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingService.java
new file mode 100644
index 000000000..4e6e2293b
--- /dev/null
+++ b/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingService.java
@@ -0,0 +1,138 @@
+package com.loopers.application.ranking;
+
+import com.loopers.zset.RedisZSetTemplate;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.time.Duration;
+import java.time.LocalDate;
+import java.util.Map;
+
+/**
+ * 랭킹 점수 계산 및 ZSET 적재 서비스.
+ *
+ * Kafka Consumer에서 이벤트를 수취하여 Redis ZSET에 랭킹 점수를 적재합니다.
+ *
+ *
+ * 설계 원칙:
+ *
+ * - Application 유즈케이스: Ranking은 도메인이 아닌 파생 View로 취급
+ * - Eventually Consistent: 일시적인 지연/중복 허용
+ * - CQRS Read Model: Write Side(도메인) → Kafka → Read Side(Application) → Redis ZSET
+ * - 단순성: ZSetTemplate을 직접 사용하여 불필요한 추상화 제거
+ *
+ *
+ *
+ * 점수 계산 공식:
+ *
+ * - 조회: Weight = 0.1, Score = 1
+ * - 좋아요: Weight = 0.2, Score = 1
+ * - 주문: Weight = 0.6, Score = price * amount (정규화: log(1 + amount))
+ *
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class RankingService {
+ private static final double VIEW_WEIGHT = 0.1;
+ private static final double LIKE_WEIGHT = 0.2;
+ private static final double ORDER_WEIGHT = 0.6;
+ private static final Duration TTL = Duration.ofDays(2);
+
+ private final RedisZSetTemplate zSetTemplate;
+ private final RankingKeyGenerator keyGenerator;
+
+ /**
+ * 조회 이벤트 점수를 ZSET에 추가합니다.
+ *
+ * @param productId 상품 ID
+ * @param date 날짜
+ */
+ public void addViewScore(Long productId, LocalDate date) {
+ String key = keyGenerator.generateDailyKey(date);
+ double score = VIEW_WEIGHT;
+ incrementScore(key, productId, score);
+ log.debug("조회 점수 추가: productId={}, date={}, score={}", productId, date, score);
+ }
+
+ /**
+ * 좋아요 이벤트 점수를 ZSET에 추가/차감합니다.
+ *
+ * @param productId 상품 ID
+ * @param date 날짜
+ * @param isAdded 좋아요 추가 여부 (true: 추가, false: 취소)
+ */
+ public void addLikeScore(Long productId, LocalDate date, boolean isAdded) {
+ String key = keyGenerator.generateDailyKey(date);
+ double score = isAdded ? LIKE_WEIGHT : -LIKE_WEIGHT;
+ incrementScore(key, productId, score);
+ log.debug("좋아요 점수 {}: productId={}, date={}, score={}",
+ isAdded ? "추가" : "차감", productId, date, score);
+ }
+
+ /**
+ * 주문 이벤트 점수를 ZSET에 추가합니다.
+ *
+ * 주문 금액을 기반으로 점수를 계산합니다.
+ * 정규화를 위해 log(1 + orderAmount)를 사용합니다.
+ *
+ *
+ * @param productId 상품 ID
+ * @param date 날짜
+ * @param orderAmount 주문 금액 (price * quantity)
+ */
+ public void addOrderScore(Long productId, LocalDate date, double orderAmount) {
+ String key = keyGenerator.generateDailyKey(date);
+ // 정규화: log(1 + orderAmount) 사용하여 큰 금액 차이를 완화
+ double score = Math.log1p(orderAmount) * ORDER_WEIGHT;
+ incrementScore(key, productId, score);
+ log.debug("주문 점수 추가: productId={}, date={}, orderAmount={}, score={}",
+ productId, date, orderAmount, score);
+ }
+
+ /**
+ * 배치로 점수를 적재합니다.
+ *
+ * 같은 배치 내에서 같은 상품의 여러 이벤트를 메모리에서 집계한 후 한 번에 적재합니다.
+ *
+ *
+ * @param scoreMap 상품 ID별 점수 맵
+ * @param date 날짜
+ */
+ public void addScoresBatch(Map scoreMap, LocalDate date) {
+ if (scoreMap.isEmpty()) {
+ return;
+ }
+
+ String key = keyGenerator.generateDailyKey(date);
+ for (Map.Entry entry : scoreMap.entrySet()) {
+ zSetTemplate.incrementScore(key, String.valueOf(entry.getKey()), entry.getValue());
+ }
+
+ // TTL 설정 (최초 1회만)
+ zSetTemplate.setTtlIfNotExists(key, TTL);
+
+ log.debug("배치 점수 적재 완료: date={}, count={}", date, scoreMap.size());
+ }
+
+ /**
+ * ZSET에 점수를 증가시킵니다.
+ *
+ * 점수 계산 후 ZSetTemplate을 통해 Redis에 적재합니다.
+ *
+ *
+ * @param key ZSET 키
+ * @param productId 상품 ID
+ * @param score 증가시킬 점수
+ */
+ private void incrementScore(String key, Long productId, double score) {
+ zSetTemplate.incrementScore(key, String.valueOf(productId), score);
+ // TTL 설정 (최초 1회만)
+ zSetTemplate.setTtlIfNotExists(key, TTL);
+ }
+}
From 064fd8c7ae01ce0cae948a9057769470954d0af9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=84=80=E1=85=A5=E1=86=AB=E1=84=8B?=
=?UTF-8?q?=E1=85=A7=E1=86=BC?= <>
Date: Sun, 21 Dec 2025 22:08:33 +0900
Subject: [PATCH 04/10] =?UTF-8?q?test:=20=EB=9E=AD=ED=82=B9=20=EC=9D=B4?=
=?UTF-8?q?=EB=B2=A4=ED=8A=B8=20=EC=BB=A8=EC=8A=88=EB=A8=B8=20=ED=85=8C?=
=?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../consumer/RankingConsumerTest.java | 456 ++++++++++++++++++
1 file changed, 456 insertions(+)
create mode 100644 apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/RankingConsumerTest.java
diff --git a/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/RankingConsumerTest.java b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/RankingConsumerTest.java
new file mode 100644
index 000000000..99e91a877
--- /dev/null
+++ b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/RankingConsumerTest.java
@@ -0,0 +1,456 @@
+package com.loopers.interfaces.consumer;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.loopers.application.eventhandled.EventHandledService;
+import com.loopers.application.ranking.RankingService;
+import com.loopers.domain.event.LikeEvent;
+import com.loopers.domain.event.OrderEvent;
+import com.loopers.domain.event.ProductEvent;
+import org.apache.kafka.clients.consumer.ConsumerRecord;
+import org.apache.kafka.common.header.Headers;
+import org.apache.kafka.common.header.internals.RecordHeader;
+import org.apache.kafka.common.header.internals.RecordHeaders;
+import org.apache.kafka.common.record.TimestampType;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.dao.DataIntegrityViolationException;
+import org.springframework.kafka.support.Acknowledgment;
+
+import java.nio.charset.StandardCharsets;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Optional;
+
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * RankingConsumer 테스트.
+ */
+@ExtendWith(MockitoExtension.class)
+class RankingConsumerTest {
+
+ @Mock
+ private RankingService rankingService;
+
+ @Mock
+ private EventHandledService eventHandledService;
+
+ @Mock
+ private ObjectMapper objectMapper;
+
+ @Mock
+ private Acknowledgment acknowledgment;
+
+ @InjectMocks
+ private RankingConsumer rankingConsumer;
+
+ @DisplayName("LikeAdded 이벤트를 처리할 수 있다.")
+ @Test
+ void canConsumeLikeAddedEvent() {
+ // arrange
+ String eventId = "test-event-id";
+ Long productId = 1L;
+ Long userId = 100L;
+ LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now());
+
+ Headers headers = new RecordHeaders();
+ headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8)));
+
+ ConsumerRecord record = new ConsumerRecord<>(
+ "like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty()
+ );
+ List> records = List.of(record);
+
+ when(eventHandledService.isAlreadyHandled(eventId)).thenReturn(false);
+
+ // act
+ rankingConsumer.consumeLikeEvents(records, acknowledgment);
+
+ // assert
+ verify(eventHandledService).isAlreadyHandled(eventId);
+ verify(rankingService).addLikeScore(eq(productId), any(), eq(true));
+ verify(eventHandledService).markAsHandled(eventId, "LikeAdded", "like-events");
+ verify(acknowledgment).acknowledge();
+ }
+
+ @DisplayName("LikeRemoved 이벤트를 처리할 수 있다.")
+ @Test
+ void canConsumeLikeRemovedEvent() {
+ // arrange
+ String eventId = "test-event-id-2";
+ Long productId = 1L;
+ Long userId = 100L;
+ LikeEvent.LikeRemoved event = new LikeEvent.LikeRemoved(userId, productId, LocalDateTime.now());
+
+ Headers headers = new RecordHeaders();
+ headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8)));
+ headers.add(new RecordHeader("eventType", "LikeRemoved".getBytes(StandardCharsets.UTF_8)));
+
+ ConsumerRecord record = new ConsumerRecord<>(
+ "like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty()
+ );
+ List> records = List.of(record);
+
+ when(eventHandledService.isAlreadyHandled(eventId)).thenReturn(false);
+
+ // act
+ rankingConsumer.consumeLikeEvents(records, acknowledgment);
+
+ // assert
+ verify(eventHandledService).isAlreadyHandled(eventId);
+ verify(rankingService).addLikeScore(eq(productId), any(), eq(false));
+ verify(eventHandledService).markAsHandled(eventId, "LikeRemoved", "like-events");
+ verify(acknowledgment).acknowledge();
+ }
+
+ @DisplayName("OrderCreated 이벤트를 처리할 수 있다.")
+ @Test
+ void canConsumeOrderCreatedEvent() {
+ // arrange
+ String eventId = "test-event-id-3";
+ Long orderId = 1L;
+ Long userId = 100L;
+ Long productId1 = 1L;
+ Long productId2 = 2L;
+
+ List orderItems = List.of(
+ new OrderEvent.OrderCreated.OrderItemInfo(productId1, 3),
+ new OrderEvent.OrderCreated.OrderItemInfo(productId2, 2)
+ );
+
+ OrderEvent.OrderCreated event = new OrderEvent.OrderCreated(
+ orderId, userId, null, 10000, 0L, orderItems, LocalDateTime.now()
+ );
+
+ Headers headers = new RecordHeaders();
+ headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8)));
+
+ ConsumerRecord record = new ConsumerRecord<>(
+ "order-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty()
+ );
+ List> records = List.of(record);
+
+ when(eventHandledService.isAlreadyHandled(eventId)).thenReturn(false);
+
+ // act
+ rankingConsumer.consumeOrderEvents(records, acknowledgment);
+
+ // assert
+ verify(eventHandledService).isAlreadyHandled(eventId);
+
+ // 평균 단가 계산: 10000 / (3 + 2) = 2000
+ // productId1: 2000 * 3 = 6000
+ // productId2: 2000 * 2 = 4000
+ verify(rankingService).addOrderScore(eq(productId1), any(), eq(6000.0));
+ verify(rankingService).addOrderScore(eq(productId2), any(), eq(4000.0));
+
+ verify(eventHandledService).markAsHandled(eventId, "OrderCreated", "order-events");
+ verify(acknowledgment).acknowledge();
+ }
+
+ @DisplayName("ProductViewed 이벤트를 처리할 수 있다.")
+ @Test
+ void canConsumeProductViewedEvent() {
+ // arrange
+ String eventId = "test-event-id-4";
+ Long productId = 1L;
+ Long userId = 100L;
+ ProductEvent.ProductViewed event = new ProductEvent.ProductViewed(productId, userId, LocalDateTime.now());
+
+ Headers headers = new RecordHeaders();
+ headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8)));
+
+ ConsumerRecord record = new ConsumerRecord<>(
+ "product-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty()
+ );
+ List> records = List.of(record);
+
+ when(eventHandledService.isAlreadyHandled(eventId)).thenReturn(false);
+
+ // act
+ rankingConsumer.consumeProductEvents(records, acknowledgment);
+
+ // assert
+ verify(eventHandledService).isAlreadyHandled(eventId);
+ verify(rankingService).addViewScore(eq(productId), any());
+ verify(eventHandledService).markAsHandled(eventId, "ProductViewed", "product-events");
+ verify(acknowledgment).acknowledge();
+ }
+
+ @DisplayName("배치로 여러 이벤트를 처리할 수 있다.")
+ @Test
+ void canConsumeMultipleEvents() {
+ // arrange
+ String eventId1 = "test-event-id-5";
+ String eventId2 = "test-event-id-6";
+ Long productId = 1L;
+ Long userId = 100L;
+
+ LikeEvent.LikeAdded event1 = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now());
+ ProductEvent.ProductViewed event2 = new ProductEvent.ProductViewed(productId, userId, LocalDateTime.now());
+
+ Headers headers1 = new RecordHeaders();
+ headers1.add(new RecordHeader("eventId", eventId1.getBytes(StandardCharsets.UTF_8)));
+ Headers headers2 = new RecordHeaders();
+ headers2.add(new RecordHeader("eventId", eventId2.getBytes(StandardCharsets.UTF_8)));
+
+ List> records = List.of(
+ new ConsumerRecord<>("like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event1, headers1, Optional.empty()),
+ new ConsumerRecord<>("product-events", 0, 1L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event2, headers2, Optional.empty())
+ );
+
+ when(eventHandledService.isAlreadyHandled(eventId1)).thenReturn(false);
+ when(eventHandledService.isAlreadyHandled(eventId2)).thenReturn(false);
+
+ // act
+ rankingConsumer.consumeLikeEvents(List.of(records.get(0)), acknowledgment);
+ rankingConsumer.consumeProductEvents(List.of(records.get(1)), acknowledgment);
+
+ // assert
+ verify(eventHandledService).isAlreadyHandled(eventId1);
+ verify(eventHandledService).isAlreadyHandled(eventId2);
+ verify(rankingService).addLikeScore(eq(productId), any(), eq(true));
+ verify(rankingService).addViewScore(eq(productId), any());
+ verify(eventHandledService).markAsHandled(eventId1, "LikeAdded", "like-events");
+ verify(eventHandledService).markAsHandled(eventId2, "ProductViewed", "product-events");
+ verify(acknowledgment, times(2)).acknowledge();
+ }
+
+ @DisplayName("이미 처리된 이벤트는 스킵한다.")
+ @Test
+ void skipsAlreadyHandledEvent() {
+ // arrange
+ String eventId = "test-event-id";
+ Long productId = 1L;
+ Long userId = 100L;
+ LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now());
+
+ Headers headers = new RecordHeaders();
+ headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8)));
+
+ ConsumerRecord record = new ConsumerRecord<>(
+ "like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty()
+ );
+ List> records = List.of(record);
+
+ when(eventHandledService.isAlreadyHandled(eventId)).thenReturn(true);
+
+ // act
+ rankingConsumer.consumeLikeEvents(records, acknowledgment);
+
+ // assert
+ verify(eventHandledService).isAlreadyHandled(eventId);
+ verify(rankingService, never()).addLikeScore(any(), any(), anyBoolean());
+ verify(eventHandledService, never()).markAsHandled(any(), any(), any());
+ verify(acknowledgment).acknowledge();
+ }
+
+ @DisplayName("eventId가 없는 메시지는 건너뛴다.")
+ @Test
+ void skipsEventWithoutEventId() {
+ // arrange
+ Long productId = 1L;
+ Long userId = 100L;
+ LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now());
+
+ ConsumerRecord record = new ConsumerRecord<>(
+ "like-events", 0, 0L, "key", event
+ );
+ List> records = List.of(record);
+
+ // act
+ rankingConsumer.consumeLikeEvents(records, acknowledgment);
+
+ // assert
+ verify(eventHandledService, never()).isAlreadyHandled(any());
+ verify(rankingService, never()).addLikeScore(any(), any(), anyBoolean());
+ verify(acknowledgment).acknowledge();
+ }
+
+ @DisplayName("개별 이벤트 처리 실패 시에도 배치 처리를 계속한다.")
+ @Test
+ void continuesProcessing_whenIndividualEventFails() {
+ // arrange
+ String eventId1 = "test-event-id-7";
+ String eventId2 = "test-event-id-8";
+ Long productId = 1L;
+ Long userId = 100L;
+
+ LikeEvent.LikeAdded validEvent = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now());
+ Object invalidEvent = "invalid-event";
+
+ Headers headers1 = new RecordHeaders();
+ headers1.add(new RecordHeader("eventId", eventId1.getBytes(StandardCharsets.UTF_8)));
+ Headers headers2 = new RecordHeaders();
+ headers2.add(new RecordHeader("eventId", eventId2.getBytes(StandardCharsets.UTF_8)));
+
+ when(eventHandledService.isAlreadyHandled(eventId1)).thenReturn(false);
+ when(eventHandledService.isAlreadyHandled(eventId2)).thenReturn(false);
+ doThrow(new RuntimeException("처리 실패"))
+ .when(rankingService).addLikeScore(any(), any(), anyBoolean());
+
+ List> records = List.of(
+ new ConsumerRecord<>("like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", invalidEvent, headers1, Optional.empty()),
+ new ConsumerRecord<>("like-events", 0, 1L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", validEvent, headers2, Optional.empty())
+ );
+
+ // act
+ rankingConsumer.consumeLikeEvents(records, acknowledgment);
+
+ // assert
+ verify(eventHandledService).isAlreadyHandled(eventId1);
+ verify(eventHandledService).isAlreadyHandled(eventId2);
+ verify(rankingService, atLeastOnce()).addLikeScore(any(), any(), anyBoolean());
+ verify(acknowledgment).acknowledge();
+ }
+
+ @DisplayName("동시성 상황에서 DataIntegrityViolationException이 발생하면 정상 처리로 간주한다.")
+ @Test
+ void handlesDataIntegrityViolationException() {
+ // arrange
+ String eventId = "test-event-id";
+ Long productId = 1L;
+ Long userId = 100L;
+ LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now());
+
+ Headers headers = new RecordHeaders();
+ headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8)));
+
+ ConsumerRecord record = new ConsumerRecord<>(
+ "like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty()
+ );
+ List> records = List.of(record);
+
+ when(eventHandledService.isAlreadyHandled(eventId)).thenReturn(false);
+ doThrow(new DataIntegrityViolationException("UNIQUE constraint violation"))
+ .when(eventHandledService).markAsHandled(eventId, "LikeAdded", "like-events");
+
+ // act
+ rankingConsumer.consumeLikeEvents(records, acknowledgment);
+
+ // assert
+ verify(eventHandledService).isAlreadyHandled(eventId);
+ verify(rankingService).addLikeScore(eq(productId), any(), eq(true));
+ verify(eventHandledService).markAsHandled(eventId, "LikeAdded", "like-events");
+ verify(acknowledgment).acknowledge();
+ }
+
+ @DisplayName("주문 이벤트에서 totalQuantity가 0이면 점수를 추가하지 않는다.")
+ @Test
+ void doesNotAddScore_whenTotalQuantityIsZero() {
+ // arrange
+ String eventId = "test-event-id-9";
+ Long orderId = 1L;
+ Long userId = 100L;
+
+ List orderItems = List.of(
+ new OrderEvent.OrderCreated.OrderItemInfo(1L, 0)
+ );
+
+ OrderEvent.OrderCreated event = new OrderEvent.OrderCreated(
+ orderId, userId, null, 0, 0L, orderItems, LocalDateTime.now()
+ );
+
+ Headers headers = new RecordHeaders();
+ headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8)));
+
+ ConsumerRecord record = new ConsumerRecord<>(
+ "order-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty()
+ );
+ List> records = List.of(record);
+
+ when(eventHandledService.isAlreadyHandled(eventId)).thenReturn(false);
+
+ // act
+ rankingConsumer.consumeOrderEvents(records, acknowledgment);
+
+ // assert
+ verify(eventHandledService).isAlreadyHandled(eventId);
+ verify(rankingService, never()).addOrderScore(any(), any(), anyDouble());
+ verify(eventHandledService).markAsHandled(eventId, "OrderCreated", "order-events");
+ verify(acknowledgment).acknowledge();
+ }
+
+ @DisplayName("주문 이벤트에서 subtotal이 null이면 점수를 추가하지 않는다.")
+ @Test
+ void doesNotAddScore_whenSubtotalIsNull() {
+ // arrange
+ String eventId = "test-event-id-10";
+ Long orderId = 1L;
+ Long userId = 100L;
+
+ List orderItems = List.of(
+ new OrderEvent.OrderCreated.OrderItemInfo(1L, 3)
+ );
+
+ OrderEvent.OrderCreated event = new OrderEvent.OrderCreated(
+ orderId, userId, null, null, 0L, orderItems, LocalDateTime.now()
+ );
+
+ Headers headers = new RecordHeaders();
+ headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8)));
+
+ ConsumerRecord record = new ConsumerRecord<>(
+ "order-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty()
+ );
+ List> records = List.of(record);
+
+ when(eventHandledService.isAlreadyHandled(eventId)).thenReturn(false);
+
+ // act
+ rankingConsumer.consumeOrderEvents(records, acknowledgment);
+
+ // assert
+ verify(eventHandledService).isAlreadyHandled(eventId);
+ verify(rankingService, never()).addOrderScore(any(), any(), anyDouble());
+ verify(eventHandledService).markAsHandled(eventId, "OrderCreated", "order-events");
+ verify(acknowledgment).acknowledge();
+ }
+
+ @DisplayName("중복 메시지 재전송 시 한 번만 처리되어 멱등성이 보장된다.")
+ @Test
+ void handlesDuplicateMessagesIdempotently() {
+ // arrange
+ String eventId = "duplicate-event-id";
+ Long productId = 1L;
+ Long userId = 100L;
+ LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now());
+
+ Headers headers = new RecordHeaders();
+ headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8)));
+
+ // 동일한 eventId를 가진 메시지 3개 생성
+ List> records = List.of(
+ new ConsumerRecord<>("like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty()),
+ new ConsumerRecord<>("like-events", 0, 1L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty()),
+ new ConsumerRecord<>("like-events", 0, 2L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty())
+ );
+
+ // 첫 번째 메시지는 처리되지 않았으므로 false, 나머지는 이미 처리되었으므로 true
+ when(eventHandledService.isAlreadyHandled(eventId))
+ .thenReturn(false) // 첫 번째: 처리됨
+ .thenReturn(true) // 두 번째: 이미 처리됨 (스킵)
+ .thenReturn(true); // 세 번째: 이미 처리됨 (스킵)
+
+ // act
+ rankingConsumer.consumeLikeEvents(records, acknowledgment);
+
+ // assert
+ // isAlreadyHandled는 3번 호출됨 (각 메시지마다)
+ verify(eventHandledService, times(3)).isAlreadyHandled(eventId);
+
+ // addLikeScore는 한 번만 호출되어야 함 (첫 번째 메시지만 처리)
+ verify(rankingService, times(1)).addLikeScore(eq(productId), any(), eq(true));
+
+ // markAsHandled는 한 번만 호출되어야 함 (첫 번째 메시지만 처리)
+ verify(eventHandledService, times(1)).markAsHandled(eventId, "LikeAdded", "like-events");
+
+ // acknowledgment는 한 번만 호출되어야 함 (배치 처리 완료)
+ verify(acknowledgment, times(1)).acknowledge();
+ }
+}
From 1f6de22b6153539ef59d5f682d7e1211bf6c1b3c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=84=80=E1=85=A5=E1=86=AB=E1=84=8B?=
=?UTF-8?q?=E1=85=A7=E1=86=BC?= <>
Date: Sun, 21 Dec 2025 22:09:04 +0900
Subject: [PATCH 05/10] =?UTF-8?q?feat:=20=EB=9E=AD=ED=82=B9=20=EC=BB=A8?=
=?UTF-8?q?=EC=8A=88=EB=A8=B8=20=EA=B5=AC=ED=98=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../interfaces/consumer/RankingConsumer.java | 434 ++++++++++++++++++
1 file changed, 434 insertions(+)
create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/RankingConsumer.java
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/RankingConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/RankingConsumer.java
new file mode 100644
index 000000000..7b79bc95d
--- /dev/null
+++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/RankingConsumer.java
@@ -0,0 +1,434 @@
+package com.loopers.interfaces.consumer;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.loopers.application.eventhandled.EventHandledService;
+import com.loopers.application.ranking.RankingService;
+import com.loopers.confg.kafka.KafkaConfig;
+import com.loopers.domain.event.LikeEvent;
+import com.loopers.domain.event.OrderEvent;
+import com.loopers.domain.event.ProductEvent;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.kafka.clients.consumer.ConsumerRecord;
+import org.apache.kafka.common.header.Header;
+import org.springframework.kafka.annotation.KafkaListener;
+import org.springframework.kafka.support.Acknowledgment;
+import org.springframework.stereotype.Component;
+
+import java.nio.charset.StandardCharsets;
+import java.time.LocalDate;
+import java.util.List;
+
+/**
+ * 랭킹 집계 Kafka Consumer.
+ *
+ * Kafka에서 이벤트를 수취하여 Redis ZSET에 랭킹 점수를 적재합니다.
+ * 조회, 좋아요, 주문 이벤트를 기반으로 실시간 랭킹을 구축합니다.
+ *
+ *
+ * 처리 이벤트:
+ *
+ * - like-events: LikeAdded, LikeRemoved (좋아요 점수 집계)
+ * - order-events: OrderCreated (주문 점수 집계)
+ * - product-events: ProductViewed (조회 점수 집계)
+ *
+ *
+ *
+ * Manual Ack:
+ *
+ * - 이벤트 처리 성공 후 수동으로 커밋하여 At Most Once 보장
+ * - 에러 발생 시 커밋하지 않아 재처리 가능
+ *
+ *
+ *
+ * 설계 원칙:
+ *
+ * - Eventually Consistent: 일시적인 지연/중복 허용
+ * - CQRS Read Model: Write Side(도메인) → Kafka → Read Side(Application) → Redis ZSET
+ *
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class RankingConsumer {
+
+ private final RankingService rankingService;
+ private final EventHandledService eventHandledService;
+ private final ObjectMapper objectMapper;
+
+ private static final String EVENT_ID_HEADER = "eventId";
+ private static final String EVENT_TYPE_HEADER = "eventType";
+ private static final String VERSION_HEADER = "version";
+
+ /**
+ * like-events 토픽을 구독하여 좋아요 점수를 집계합니다.
+ *
+ * 멱등성 처리:
+ *
+ * - Kafka 메시지 헤더에서 `eventId`를 추출
+ * - 이미 처리된 이벤트는 스킵하여 중복 처리 방지
+ * - 처리 후 `event_handled` 테이블에 기록
+ *
+ *
+ *
+ * @param records Kafka 메시지 레코드 목록
+ * @param acknowledgment 수동 커밋을 위한 Acknowledgment
+ */
+ @KafkaListener(
+ topics = "like-events",
+ containerFactory = KafkaConfig.BATCH_LISTENER
+ )
+ public void consumeLikeEvents(
+ List> records,
+ Acknowledgment acknowledgment
+ ) {
+ try {
+ for (ConsumerRecord record : records) {
+ try {
+ String eventId = extractEventId(record);
+ if (eventId == null) {
+ log.warn("eventId가 없는 메시지는 건너뜁니다: offset={}, partition={}",
+ record.offset(), record.partition());
+ continue;
+ }
+
+ // 멱등성 체크: 이미 처리된 이벤트는 스킵
+ if (eventHandledService.isAlreadyHandled(eventId)) {
+ log.debug("이미 처리된 이벤트 스킵: eventId={}", eventId);
+ continue;
+ }
+
+ Object value = record.value();
+ String eventType;
+ LocalDate date = LocalDate.now();
+
+ // Spring Kafka가 자동으로 역직렬화한 경우
+ if (value instanceof LikeEvent.LikeAdded) {
+ LikeEvent.LikeAdded event = (LikeEvent.LikeAdded) value;
+ rankingService.addLikeScore(event.productId(), date, true);
+ eventType = "LikeAdded";
+ } else if (value instanceof LikeEvent.LikeRemoved) {
+ LikeEvent.LikeRemoved event = (LikeEvent.LikeRemoved) value;
+ rankingService.addLikeScore(event.productId(), date, false);
+ eventType = "LikeRemoved";
+ } else {
+ // JSON 문자열인 경우 이벤트 타입 헤더로 구분
+ String eventTypeHeader = extractEventType(record);
+ if ("LikeRemoved".equals(eventTypeHeader)) {
+ LikeEvent.LikeRemoved event = parseLikeRemovedEvent(value);
+ rankingService.addLikeScore(event.productId(), date, false);
+ eventType = "LikeRemoved";
+ } else {
+ // 기본값은 LikeAdded
+ LikeEvent.LikeAdded event = parseLikeEvent(value);
+ rankingService.addLikeScore(event.productId(), date, true);
+ eventType = "LikeAdded";
+ }
+ }
+
+ // 이벤트 처리 기록 저장
+ eventHandledService.markAsHandled(eventId, eventType, "like-events");
+ } catch (org.springframework.dao.DataIntegrityViolationException e) {
+ // UNIQUE 제약조건 위반 = 동시성 상황에서 이미 처리됨 (정상)
+ log.debug("동시성 상황에서 이미 처리된 이벤트: offset={}, partition={}",
+ record.offset(), record.partition());
+ } catch (Exception e) {
+ log.error("좋아요 이벤트 처리 실패: offset={}, partition={}",
+ record.offset(), record.partition(), e);
+ // 개별 이벤트 처리 실패는 로그만 기록하고 계속 진행
+ }
+ }
+
+ // 모든 이벤트 처리 완료 후 수동 커밋
+ acknowledgment.acknowledge();
+ log.debug("좋아요 이벤트 처리 완료: count={}", records.size());
+ } catch (Exception e) {
+ log.error("좋아요 이벤트 배치 처리 실패: count={}", records.size(), e);
+ // 에러 발생 시 커밋하지 않음 (재처리 가능)
+ throw e;
+ }
+ }
+
+ /**
+ * order-events 토픽을 구독하여 주문 점수를 집계합니다.
+ *
+ * 멱등성 처리:
+ *
+ * - Kafka 메시지 헤더에서 `eventId`를 추출
+ * - 이미 처리된 이벤트는 스킵하여 중복 처리 방지
+ * - 처리 후 `event_handled` 테이블에 기록
+ *
+ *
+ *
+ * 주문 금액 계산:
+ *
+ * - OrderEvent.OrderCreated에는 개별 상품 가격 정보가 없음
+ * - subtotal을 totalQuantity로 나눠서 평균 단가를 구하고, 각 아이템의 quantity를 곱함
+ * - 향후 개선: 주문 이벤트에 개별 상품 가격 정보 추가
+ *
+ *
+ *
+ * @param records Kafka 메시지 레코드 목록
+ * @param acknowledgment 수동 커밋을 위한 Acknowledgment
+ */
+ @KafkaListener(
+ topics = "order-events",
+ containerFactory = KafkaConfig.BATCH_LISTENER
+ )
+ public void consumeOrderEvents(
+ List> records,
+ Acknowledgment acknowledgment
+ ) {
+ try {
+ for (ConsumerRecord record : records) {
+ try {
+ String eventId = extractEventId(record);
+ if (eventId == null) {
+ log.warn("eventId가 없는 메시지는 건너뜁니다: offset={}, partition={}",
+ record.offset(), record.partition());
+ continue;
+ }
+
+ // 멱등성 체크: 이미 처리된 이벤트는 스킵
+ if (eventHandledService.isAlreadyHandled(eventId)) {
+ log.debug("이미 처리된 이벤트 스킵: eventId={}", eventId);
+ continue;
+ }
+
+ Object value = record.value();
+ OrderEvent.OrderCreated event = parseOrderCreatedEvent(value);
+
+ LocalDate date = LocalDate.now();
+
+ // 주문 아이템별로 점수 집계
+ // 주의: OrderEvent.OrderCreated에는 개별 상품 가격 정보가 없으므로
+ // subtotal을 totalQuantity로 나눠서 평균 단가를 구하고, 각 아이템의 quantity를 곱함
+ int totalQuantity = event.orderItems().stream()
+ .mapToInt(OrderEvent.OrderCreated.OrderItemInfo::quantity)
+ .sum();
+
+ if (totalQuantity > 0 && event.subtotal() != null) {
+ double averagePrice = (double) event.subtotal() / totalQuantity;
+
+ for (OrderEvent.OrderCreated.OrderItemInfo item : event.orderItems()) {
+ double orderAmount = averagePrice * item.quantity();
+ rankingService.addOrderScore(item.productId(), date, orderAmount);
+ }
+ }
+
+ // 이벤트 처리 기록 저장
+ eventHandledService.markAsHandled(eventId, "OrderCreated", "order-events");
+ } catch (org.springframework.dao.DataIntegrityViolationException e) {
+ // UNIQUE 제약조건 위반 = 동시성 상황에서 이미 처리됨 (정상)
+ log.debug("동시성 상황에서 이미 처리된 이벤트: offset={}, partition={}",
+ record.offset(), record.partition());
+ } catch (Exception e) {
+ log.error("주문 이벤트 처리 실패: offset={}, partition={}",
+ record.offset(), record.partition(), e);
+ // 개별 이벤트 처리 실패는 로그만 기록하고 계속 진행
+ }
+ }
+
+ // 모든 이벤트 처리 완료 후 수동 커밋
+ acknowledgment.acknowledge();
+ log.debug("주문 이벤트 처리 완료: count={}", records.size());
+ } catch (Exception e) {
+ log.error("주문 이벤트 배치 처리 실패: count={}", records.size(), e);
+ // 에러 발생 시 커밋하지 않음 (재처리 가능)
+ throw e;
+ }
+ }
+
+ /**
+ * product-events 토픽을 구독하여 조회 점수를 집계합니다.
+ *
+ * 멱등성 처리:
+ *
+ * - Kafka 메시지 헤더에서 `eventId`를 추출
+ * - 이미 처리된 이벤트는 스킵하여 중복 처리 방지
+ * - 처리 후 `event_handled` 테이블에 기록
+ *
+ *
+ *
+ * @param records Kafka 메시지 레코드 목록
+ * @param acknowledgment 수동 커밋을 위한 Acknowledgment
+ */
+ @KafkaListener(
+ topics = "product-events",
+ containerFactory = KafkaConfig.BATCH_LISTENER
+ )
+ public void consumeProductEvents(
+ List> records,
+ Acknowledgment acknowledgment
+ ) {
+ try {
+ for (ConsumerRecord record : records) {
+ try {
+ String eventId = extractEventId(record);
+ if (eventId == null) {
+ log.warn("eventId가 없는 메시지는 건너뜁니다: offset={}, partition={}",
+ record.offset(), record.partition());
+ continue;
+ }
+
+ // 멱등성 체크: 이미 처리된 이벤트는 스킵
+ if (eventHandledService.isAlreadyHandled(eventId)) {
+ log.debug("이미 처리된 이벤트 스킵: eventId={}", eventId);
+ continue;
+ }
+
+ Object value = record.value();
+ ProductEvent.ProductViewed event = parseProductViewedEvent(value);
+
+ LocalDate date = LocalDate.now();
+
+ rankingService.addViewScore(event.productId(), date);
+
+ // 이벤트 처리 기록 저장
+ eventHandledService.markAsHandled(eventId, "ProductViewed", "product-events");
+ } catch (org.springframework.dao.DataIntegrityViolationException e) {
+ // UNIQUE 제약조건 위반 = 동시성 상황에서 이미 처리됨 (정상)
+ log.debug("동시성 상황에서 이미 처리된 이벤트: offset={}, partition={}",
+ record.offset(), record.partition());
+ } catch (Exception e) {
+ log.error("상품 조회 이벤트 처리 실패: offset={}, partition={}",
+ record.offset(), record.partition(), e);
+ // 개별 이벤트 처리 실패는 로그만 기록하고 계속 진행
+ }
+ }
+
+ // 모든 이벤트 처리 완료 후 수동 커밋
+ acknowledgment.acknowledge();
+ log.debug("상품 조회 이벤트 처리 완료: count={}", records.size());
+ } catch (Exception e) {
+ log.error("상품 조회 이벤트 배치 처리 실패: count={}", records.size(), e);
+ // 에러 발생 시 커밋하지 않음 (재처리 가능)
+ throw e;
+ }
+ }
+
+ /**
+ * Kafka 메시지 값을 LikeAdded 이벤트로 파싱합니다.
+ *
+ * @param value Kafka 메시지 값
+ * @return 파싱된 LikeAdded 이벤트
+ */
+ private LikeEvent.LikeAdded parseLikeEvent(Object value) {
+ try {
+ // JSON 문자열인 경우 파싱
+ String json = value instanceof String ? (String) value : objectMapper.writeValueAsString(value);
+ return objectMapper.readValue(json, LikeEvent.LikeAdded.class);
+ } catch (Exception e) {
+ throw new RuntimeException("LikeAdded 이벤트 파싱 실패", e);
+ }
+ }
+
+ /**
+ * Kafka 메시지 값을 LikeRemoved 이벤트로 파싱합니다.
+ *
+ * @param value Kafka 메시지 값
+ * @return 파싱된 LikeRemoved 이벤트
+ */
+ private LikeEvent.LikeRemoved parseLikeRemovedEvent(Object value) {
+ try {
+ // JSON 문자열인 경우 파싱
+ String json = value instanceof String ? (String) value : objectMapper.writeValueAsString(value);
+ return objectMapper.readValue(json, LikeEvent.LikeRemoved.class);
+ } catch (Exception e) {
+ throw new RuntimeException("LikeRemoved 이벤트 파싱 실패", e);
+ }
+ }
+
+ /**
+ * Kafka 메시지 값을 OrderCreated 이벤트로 파싱합니다.
+ *
+ * @param value Kafka 메시지 값
+ * @return 파싱된 OrderCreated 이벤트
+ */
+ private OrderEvent.OrderCreated parseOrderCreatedEvent(Object value) {
+ try {
+ if (value instanceof OrderEvent.OrderCreated) {
+ return (OrderEvent.OrderCreated) value;
+ }
+
+ // JSON 문자열인 경우 파싱
+ String json = value instanceof String ? (String) value : objectMapper.writeValueAsString(value);
+ return objectMapper.readValue(json, OrderEvent.OrderCreated.class);
+ } catch (Exception e) {
+ throw new RuntimeException("OrderCreated 이벤트 파싱 실패", e);
+ }
+ }
+
+ /**
+ * Kafka 메시지 값을 ProductViewed 이벤트로 파싱합니다.
+ *
+ * @param value Kafka 메시지 값
+ * @return 파싱된 ProductViewed 이벤트
+ */
+ private ProductEvent.ProductViewed parseProductViewedEvent(Object value) {
+ try {
+ if (value instanceof ProductEvent.ProductViewed) {
+ return (ProductEvent.ProductViewed) value;
+ }
+
+ // JSON 문자열인 경우 파싱
+ String json = value instanceof String ? (String) value : objectMapper.writeValueAsString(value);
+ return objectMapper.readValue(json, ProductEvent.ProductViewed.class);
+ } catch (Exception e) {
+ throw new RuntimeException("ProductViewed 이벤트 파싱 실패", e);
+ }
+ }
+
+ /**
+ * Kafka 메시지 헤더에서 eventId를 추출합니다.
+ *
+ * @param record Kafka 메시지 레코드
+ * @return eventId (없으면 null)
+ */
+ private String extractEventId(ConsumerRecord record) {
+ Header header = record.headers().lastHeader(EVENT_ID_HEADER);
+ if (header != null && header.value() != null) {
+ return new String(header.value(), StandardCharsets.UTF_8);
+ }
+ return null;
+ }
+
+ /**
+ * Kafka 메시지 헤더에서 eventType을 추출합니다.
+ *
+ * @param record Kafka 메시지 레코드
+ * @return eventType (없으면 null)
+ */
+ private String extractEventType(ConsumerRecord record) {
+ Header header = record.headers().lastHeader(EVENT_TYPE_HEADER);
+ if (header != null && header.value() != null) {
+ return new String(header.value(), StandardCharsets.UTF_8);
+ }
+ return null;
+ }
+
+ /**
+ * Kafka 메시지 헤더에서 version을 추출합니다.
+ *
+ * @param record Kafka 메시지 레코드
+ * @return version (없으면 null)
+ */
+ private Long extractVersion(ConsumerRecord record) {
+ Header header = record.headers().lastHeader(VERSION_HEADER);
+ if (header != null && header.value() != null) {
+ try {
+ String versionStr = new String(header.value(), StandardCharsets.UTF_8);
+ return Long.parseLong(versionStr);
+ } catch (NumberFormatException e) {
+ log.warn("버전 헤더 파싱 실패: offset={}, partition={}",
+ record.offset(), record.partition());
+ return null;
+ }
+ }
+ return null;
+ }
+}
From 76a007abfd0146f12089d172be9c9268ec5c39ec Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=84=80=E1=85=A5=E1=86=AB=E1=84=8B?=
=?UTF-8?q?=E1=85=A7=E1=86=BC?= <>
Date: Sun, 21 Dec 2025 22:16:38 +0900
Subject: [PATCH 06/10] =?UTF-8?q?test:=20=EB=9E=AD=ED=82=B9=20=EC=A1=B0?=
=?UTF-8?q?=ED=9A=8C=20=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?=
=?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../ranking/RankingServiceTest.java | 400 ++++++++++++++++++
1 file changed, 400 insertions(+)
create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingServiceTest.java
diff --git a/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingServiceTest.java
new file mode 100644
index 000000000..222cd9f9b
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingServiceTest.java
@@ -0,0 +1,400 @@
+package com.loopers.application.ranking;
+
+import com.loopers.application.brand.BrandService;
+import com.loopers.application.product.ProductService;
+import com.loopers.domain.brand.Brand;
+import com.loopers.domain.product.Product;
+import com.loopers.zset.RedisZSetTemplate;
+import com.loopers.zset.ZSetEntry;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.lang.reflect.Field;
+import java.time.LocalDate;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * RankingService 테스트.
+ */
+@ExtendWith(MockitoExtension.class)
+class RankingServiceTest {
+
+ @Mock
+ private RedisZSetTemplate zSetTemplate;
+
+ @Mock
+ private RankingKeyGenerator keyGenerator;
+
+ @Mock
+ private ProductService productService;
+
+ @Mock
+ private BrandService brandService;
+
+ @InjectMocks
+ private RankingService rankingService;
+
+ /**
+ * Product에 ID를 설정합니다 (리플렉션 사용).
+ */
+ private void setId(Product product, Long id) {
+ try {
+ Field idField = product.getClass().getSuperclass().getDeclaredField("id");
+ idField.setAccessible(true);
+ idField.set(product, id);
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to set Product ID", e);
+ }
+ }
+
+ /**
+ * Brand에 ID를 설정합니다 (리플렉션 사용).
+ */
+ private void setId(Brand brand, Long id) {
+ try {
+ Field idField = brand.getClass().getSuperclass().getDeclaredField("id");
+ idField.setAccessible(true);
+ idField.set(brand, id);
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to set Brand ID", e);
+ }
+ }
+
+ @DisplayName("랭킹을 조회할 수 있다.")
+ @Test
+ void canGetRankings() {
+ // arrange
+ LocalDate date = LocalDate.of(2024, 12, 15);
+ int page = 0;
+ int size = 20;
+ String key = "ranking:all:20241215";
+
+ Long productId1 = 1L;
+ Long productId2 = 2L;
+ Long brandId1 = 10L;
+ Long brandId2 = 20L;
+
+ List entries = List.of(
+ new ZSetEntry(String.valueOf(productId1), 100.5),
+ new ZSetEntry(String.valueOf(productId2), 90.3)
+ );
+
+ Product product1 = Product.of("상품1", 10000, 10, brandId1);
+ Product product2 = Product.of("상품2", 20000, 5, brandId2);
+ Brand brand1 = Brand.of("브랜드1");
+ Brand brand2 = Brand.of("브랜드2");
+
+ // ID 설정
+ setId(product1, productId1);
+ setId(product2, productId2);
+ setId(brand1, brandId1);
+ setId(brand2, brandId2);
+ when(keyGenerator.generateDailyKey(date)).thenReturn(key);
+ when(zSetTemplate.getTopRankings(key, 0L, 19L)).thenReturn(entries);
+ when(zSetTemplate.getSize(key)).thenReturn(50L);
+ when(productService.getProducts(List.of(productId1, productId2)))
+ .thenReturn(List.of(product1, product2));
+ when(brandService.getBrands(List.of(brandId1, brandId2)))
+ .thenReturn(List.of(brand1, brand2));
+
+ // act
+ RankingService.RankingsResponse result = rankingService.getRankings(date, page, size);
+
+ // assert
+ assertThat(result.items()).hasSize(2);
+ assertThat(result.page()).isEqualTo(page);
+ assertThat(result.size()).isEqualTo(size);
+ assertThat(result.hasNext()).isTrue();
+
+ RankingService.RankingItem item1 = result.items().get(0);
+ assertThat(item1.rank()).isEqualTo(1L);
+ assertThat(item1.score()).isEqualTo(100.5);
+ assertThat(item1.productDetail().getId()).isEqualTo(productId1);
+ assertThat(item1.productDetail().getName()).isEqualTo("상품1");
+
+ RankingService.RankingItem item2 = result.items().get(1);
+ assertThat(item2.rank()).isEqualTo(2L);
+ assertThat(item2.score()).isEqualTo(90.3);
+ assertThat(item2.productDetail().getId()).isEqualTo(productId2);
+ assertThat(item2.productDetail().getName()).isEqualTo("상품2");
+ }
+
+ @DisplayName("빈 랭킹을 조회할 수 있다.")
+ @Test
+ void canGetEmptyRankings() {
+ // arrange
+ LocalDate date = LocalDate.of(2024, 12, 15);
+ int page = 0;
+ int size = 20;
+ String key = "ranking:all:20241215";
+
+ when(keyGenerator.generateDailyKey(date)).thenReturn(key);
+ when(zSetTemplate.getTopRankings(key, 0L, 19L)).thenReturn(List.of());
+
+ // act
+ RankingService.RankingsResponse result = rankingService.getRankings(date, page, size);
+
+ // assert
+ assertThat(result.items()).isEmpty();
+ assertThat(result.page()).isEqualTo(page);
+ assertThat(result.size()).isEqualTo(size);
+ assertThat(result.hasNext()).isFalse();
+ verify(zSetTemplate, never()).getSize(anyString());
+ }
+
+ @DisplayName("페이징이 정상적으로 동작한다.")
+ @Test
+ void canGetRankingsWithPaging() {
+ // arrange
+ LocalDate date = LocalDate.of(2024, 12, 15);
+ int page = 2;
+ int size = 10;
+ String key = "ranking:all:20241215";
+
+ Long productId = 1L;
+ Long brandId = 10L;
+
+ List entries = List.of(
+ new ZSetEntry(String.valueOf(productId), 100.0)
+ );
+
+ Product product = Product.of("상품", 10000, 10, brandId);
+ Brand brand = Brand.of("브랜드");
+
+ // ID 설정
+ setId(product, productId);
+ setId(brand, brandId);
+
+ when(keyGenerator.generateDailyKey(date)).thenReturn(key);
+ when(zSetTemplate.getTopRankings(key, 20L, 29L)).thenReturn(entries);
+ when(zSetTemplate.getSize(key)).thenReturn(31L); // 31 > 20 + 10이므로 다음 페이지 있음
+ when(productService.getProducts(List.of(productId))).thenReturn(List.of(product));
+ when(brandService.getBrands(List.of(brandId))).thenReturn(List.of(brand));
+
+ // act
+ RankingService.RankingsResponse result = rankingService.getRankings(date, page, size);
+
+ // assert
+ assertThat(result.items()).hasSize(1);
+ assertThat(result.page()).isEqualTo(page);
+ assertThat(result.size()).isEqualTo(size);
+ assertThat(result.hasNext()).isTrue(); // 31 > 20 + 10
+
+ RankingService.RankingItem item = result.items().get(0);
+ assertThat(item.rank()).isEqualTo(21L); // start(20) + i(0) + 1
+ }
+
+ @DisplayName("랭킹에 포함된 상품이 DB에 없으면 스킵한다.")
+ @Test
+ void skipsProduct_whenProductNotFound() {
+ // arrange
+ LocalDate date = LocalDate.of(2024, 12, 15);
+ int page = 0;
+ int size = 20;
+ String key = "ranking:all:20241215";
+
+ Long productId1 = 1L;
+ Long productId2 = 999L; // 존재하지 않는 상품
+
+ List entries = List.of(
+ new ZSetEntry(String.valueOf(productId1), 100.0),
+ new ZSetEntry(String.valueOf(productId2), 90.0)
+ );
+
+ Product product1 = Product.of("상품1", 10000, 10, 10L);
+ Brand brand1 = Brand.of("브랜드1");
+
+ // ID 설정
+ setId(product1, productId1);
+ setId(brand1, 10L);
+
+ when(keyGenerator.generateDailyKey(date)).thenReturn(key);
+ when(zSetTemplate.getTopRankings(key, 0L, 19L)).thenReturn(entries);
+ when(zSetTemplate.getSize(key)).thenReturn(2L);
+ when(productService.getProducts(List.of(productId1, productId2)))
+ .thenReturn(List.of(product1)); // productId2는 없음
+ when(brandService.getBrands(List.of(10L))).thenReturn(List.of(brand1));
+
+ // act
+ RankingService.RankingsResponse result = rankingService.getRankings(date, page, size);
+
+ // assert
+ assertThat(result.items()).hasSize(1); // productId2는 스킵됨
+ assertThat(result.items().get(0).productDetail().getId()).isEqualTo(productId1);
+ }
+
+ @DisplayName("상품의 브랜드가 없으면 스킵한다.")
+ @Test
+ void skipsProduct_whenBrandNotFound() {
+ // arrange
+ LocalDate date = LocalDate.of(2024, 12, 15);
+ int page = 0;
+ int size = 20;
+ String key = "ranking:all:20241215";
+
+ Long productId1 = 1L;
+ Long productId2 = 2L;
+ Long brandId1 = 10L;
+ Long brandId2 = 999L; // 존재하지 않는 브랜드
+
+ List entries = List.of(
+ new ZSetEntry(String.valueOf(productId1), 100.0),
+ new ZSetEntry(String.valueOf(productId2), 90.0)
+ );
+
+ Product product1 = Product.of("상품1", 10000, 10, brandId1);
+ Product product2 = Product.of("상품2", 20000, 5, brandId2);
+ Brand brand1 = Brand.of("브랜드1");
+
+ // ID 설정
+ setId(product1, productId1);
+ setId(product2, productId2);
+ setId(brand1, brandId1);
+
+ when(keyGenerator.generateDailyKey(date)).thenReturn(key);
+ when(zSetTemplate.getTopRankings(key, 0L, 19L)).thenReturn(entries);
+ when(zSetTemplate.getSize(key)).thenReturn(2L);
+ when(productService.getProducts(List.of(productId1, productId2)))
+ .thenReturn(List.of(product1, product2));
+ when(brandService.getBrands(List.of(brandId1, brandId2)))
+ .thenReturn(List.of(brand1)); // brandId2는 없음
+
+ // act
+ RankingService.RankingsResponse result = rankingService.getRankings(date, page, size);
+
+ // assert
+ assertThat(result.items()).hasSize(1); // productId2는 브랜드가 없어서 스킵됨
+ assertThat(result.items().get(0).productDetail().getId()).isEqualTo(productId1);
+ }
+
+ @DisplayName("다음 페이지가 없을 때 hasNext가 false이다.")
+ @Test
+ void hasNextIsFalse_whenNoMorePages() {
+ // arrange
+ LocalDate date = LocalDate.of(2024, 12, 15);
+ int page = 0;
+ int size = 20;
+ String key = "ranking:all:20241215";
+
+ Long productId = 1L;
+ Long brandId = 10L;
+
+ List entries = List.of(
+ new ZSetEntry(String.valueOf(productId), 100.0)
+ );
+
+ Product product = Product.of("상품", 10000, 10, brandId);
+ Brand brand = Brand.of("브랜드");
+
+ // ID 설정
+ setId(product, productId);
+ setId(brand, brandId);
+
+ when(keyGenerator.generateDailyKey(date)).thenReturn(key);
+ when(zSetTemplate.getTopRankings(key, 0L, 19L)).thenReturn(entries);
+ when(zSetTemplate.getSize(key)).thenReturn(1L); // 전체 크기가 1이므로 다음 페이지 없음
+ when(productService.getProducts(List.of(productId))).thenReturn(List.of(product));
+ when(brandService.getBrands(List.of(brandId))).thenReturn(List.of(brand));
+
+ // act
+ RankingService.RankingsResponse result = rankingService.getRankings(date, page, size);
+
+ // assert
+ assertThat(result.hasNext()).isFalse(); // 1 <= 0 + 20
+ }
+
+ @DisplayName("특정 상품의 순위를 조회할 수 있다.")
+ @Test
+ void canGetProductRank() {
+ // arrange
+ Long productId = 1L;
+ LocalDate date = LocalDate.of(2024, 12, 15);
+ String key = "ranking:all:20241215";
+ Long rank = 5L; // 0-based
+
+ when(keyGenerator.generateDailyKey(date)).thenReturn(key);
+ when(zSetTemplate.getRank(key, String.valueOf(productId))).thenReturn(rank);
+
+ // act
+ Long result = rankingService.getProductRank(productId, date);
+
+ // assert
+ assertThat(result).isEqualTo(6L); // 1-based (5 + 1)
+ verify(keyGenerator).generateDailyKey(date);
+ verify(zSetTemplate).getRank(key, String.valueOf(productId));
+ }
+
+ @DisplayName("랭킹에 없는 상품의 순위는 null이다.")
+ @Test
+ void returnsNull_whenProductNotInRanking() {
+ // arrange
+ Long productId = 999L;
+ LocalDate date = LocalDate.of(2024, 12, 15);
+ String key = "ranking:all:20241215";
+
+ when(keyGenerator.generateDailyKey(date)).thenReturn(key);
+ when(zSetTemplate.getRank(key, String.valueOf(productId))).thenReturn(null);
+
+ // act
+ Long result = rankingService.getProductRank(productId, date);
+
+ // assert
+ assertThat(result).isNull();
+ verify(keyGenerator).generateDailyKey(date);
+ verify(zSetTemplate).getRank(key, String.valueOf(productId));
+ }
+
+ @DisplayName("같은 브랜드의 여러 상품이 랭킹에 포함될 수 있다.")
+ @Test
+ void canHandleMultipleProductsFromSameBrand() {
+ // arrange
+ LocalDate date = LocalDate.of(2024, 12, 15);
+ int page = 0;
+ int size = 20;
+ String key = "ranking:all:20241215";
+
+ Long productId1 = 1L;
+ Long productId2 = 2L;
+ Long brandId = 10L; // 같은 브랜드
+
+ List entries = List.of(
+ new ZSetEntry(String.valueOf(productId1), 100.0),
+ new ZSetEntry(String.valueOf(productId2), 90.0)
+ );
+
+ Product product1 = Product.of("상품1", 10000, 10, brandId);
+ Product product2 = Product.of("상품2", 20000, 5, brandId);
+ Brand brand = Brand.of("브랜드");
+
+ // ID 설정
+ setId(product1, productId1);
+ setId(product2, productId2);
+ setId(brand, brandId);
+
+ when(keyGenerator.generateDailyKey(date)).thenReturn(key);
+ when(zSetTemplate.getTopRankings(key, 0L, 19L)).thenReturn(entries);
+ when(zSetTemplate.getSize(key)).thenReturn(2L);
+ when(productService.getProducts(List.of(productId1, productId2)))
+ .thenReturn(List.of(product1, product2));
+ when(brandService.getBrands(List.of(brandId))) // 중복 제거되어 한 번만 조회
+ .thenReturn(List.of(brand));
+
+ // act
+ RankingService.RankingsResponse result = rankingService.getRankings(date, page, size);
+
+ // assert
+ assertThat(result.items()).hasSize(2);
+ assertThat(result.items().get(0).productDetail().getBrandId()).isEqualTo(brandId);
+ assertThat(result.items().get(1).productDetail().getBrandId()).isEqualTo(brandId);
+ // 브랜드는 한 번만 조회됨 (중복 제거)
+ verify(brandService).getBrands(List.of(brandId));
+ }
+}
From b03e321a9d517c92d8db8a467457ed4a3ecddc12 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=84=80=E1=85=A5=E1=86=AB=E1=84=8B?=
=?UTF-8?q?=E1=85=A7=E1=86=BC?= <>
Date: Sun, 21 Dec 2025 22:17:14 +0900
Subject: [PATCH 07/10] =?UTF-8?q?feat:=20=EB=9E=AD=ED=82=B9=20=EC=A1=B0?=
=?UTF-8?q?=ED=9A=8C=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EB=A1=9C=EC=A7=81=20?=
=?UTF-8?q?=EA=B5=AC=ED=98=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../ranking/RankingKeyGenerator.java | 52 +++++
.../application/ranking/RankingService.java | 193 ++++++++++++++++++
2 files changed, 245 insertions(+)
create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingKeyGenerator.java
create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingKeyGenerator.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingKeyGenerator.java
new file mode 100644
index 000000000..f87a52422
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingKeyGenerator.java
@@ -0,0 +1,52 @@
+package com.loopers.application.ranking;
+
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+
+/**
+ * 랭킹 키 생성 유틸리티.
+ *
+ * Redis ZSET 랭킹 키를 생성합니다.
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Component
+public class RankingKeyGenerator {
+ private static final String DAILY_KEY_PREFIX = "ranking:all:";
+ private static final String HOURLY_KEY_PREFIX = "ranking:hourly:";
+ private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd");
+ private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHH");
+
+ /**
+ * 일간 랭킹 키를 생성합니다.
+ *
+ * 예: ranking:all:20241215
+ *
+ *
+ * @param date 날짜
+ * @return 일간 랭킹 키
+ */
+ public String generateDailyKey(LocalDate date) {
+ String dateStr = date.format(DATE_FORMATTER);
+ return DAILY_KEY_PREFIX + dateStr;
+ }
+
+ /**
+ * 시간 단위 랭킹 키를 생성합니다.
+ *
+ * 예: ranking:hourly:2024121514
+ *
+ *
+ * @param dateTime 날짜 및 시간
+ * @return 시간 단위 랭킹 키
+ */
+ public String generateHourlyKey(LocalDateTime dateTime) {
+ String dateTimeStr = dateTime.format(DATE_TIME_FORMATTER);
+ return HOURLY_KEY_PREFIX + dateTimeStr;
+ }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java
new file mode 100644
index 000000000..327383145
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java
@@ -0,0 +1,193 @@
+package com.loopers.application.ranking;
+
+import com.loopers.application.brand.BrandService;
+import com.loopers.application.product.ProductService;
+import com.loopers.domain.brand.Brand;
+import com.loopers.domain.product.Product;
+import com.loopers.domain.product.ProductDetail;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import com.loopers.zset.ZSetEntry;
+import com.loopers.zset.RedisZSetTemplate;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * 랭킹 조회 서비스.
+ *
+ * Redis ZSET에서 랭킹을 조회하고 상품 정보를 Aggregation하여 제공합니다.
+ *
+ *
+ * 설계 원칙:
+ *
+ * - Application 유즈케이스: Ranking은 도메인이 아닌 파생 View로 취급
+ * - 상품 정보 Aggregation: 상품 ID만이 아닌 상품 정보 포함
+ * - 배치 조회: N+1 쿼리 문제 방지
+ *
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class RankingService {
+ private final RedisZSetTemplate zSetTemplate;
+ private final RankingKeyGenerator keyGenerator;
+ private final ProductService productService;
+ private final BrandService brandService;
+
+ /**
+ * 랭킹을 조회합니다 (페이징).
+ *
+ * ZSET에서 상위 N개를 조회하고, 상품 정보를 Aggregation하여 반환합니다.
+ *
+ *
+ * @param date 날짜 (yyyyMMdd 형식의 문자열 또는 LocalDate)
+ * @param page 페이지 번호 (0부터 시작)
+ * @param size 페이지당 항목 수
+ * @return 랭킹 조회 결과
+ */
+ @Transactional(readOnly = true)
+ public RankingsResponse getRankings(LocalDate date, int page, int size) {
+ String key = keyGenerator.generateDailyKey(date);
+ long start = (long) page * size;
+ long end = start + size - 1;
+
+ // ZSET에서 Top N 조회
+ List entries = zSetTemplate.getTopRankings(key, start, end);
+
+ if (entries.isEmpty()) {
+ return RankingsResponse.empty(page, size);
+ }
+
+ // 상품 ID 추출
+ List productIds = entries.stream()
+ .map(entry -> Long.parseLong(entry.member()))
+ .toList();
+
+ // 상품 정보 배치 조회
+ List products = productService.getProducts(productIds);
+
+ // 상품 ID → Product Map 생성
+ Map productMap = products.stream()
+ .collect(Collectors.toMap(Product::getId, product -> product));
+
+ // 브랜드 ID 수집
+ List brandIds = products.stream()
+ .map(Product::getBrandId)
+ .distinct()
+ .toList();
+
+ // 브랜드 배치 조회
+ Map brandMap = brandService.getBrands(brandIds).stream()
+ .collect(Collectors.toMap(Brand::getId, brand -> brand));
+
+ // 랭킹 항목 생성 (순위, 점수, 상품 정보 포함)
+ List rankingItems = new ArrayList<>();
+ for (int i = 0; i < entries.size(); i++) {
+ ZSetEntry entry = entries.get(i);
+ Long productId = Long.parseLong(entry.member());
+ Long rank = start + i + 1; // 1-based 순위
+
+ Product product = productMap.get(productId);
+ if (product == null) {
+ log.warn("랭킹에 포함된 상품을 찾을 수 없습니다: productId={}", productId);
+ continue;
+ }
+
+ Brand brand = brandMap.get(product.getBrandId());
+ if (brand == null) {
+ log.warn("상품의 브랜드를 찾을 수 없습니다: productId={}, brandId={}",
+ productId, product.getBrandId());
+ continue;
+ }
+
+ ProductDetail productDetail = ProductDetail.from(
+ product,
+ brand.getName(),
+ product.getLikeCount()
+ );
+
+ rankingItems.add(new RankingItem(
+ rank,
+ entry.score(),
+ productDetail
+ ));
+ }
+
+ // 전체 랭킹 개수 조회 (ZSET 크기)
+ Long totalSize = zSetTemplate.getSize(key);
+ boolean hasNext = (start + size) < totalSize;
+
+ return new RankingsResponse(rankingItems, page, size, hasNext);
+ }
+
+ /**
+ * 특정 상품의 순위를 조회합니다.
+ *
+ * 상품이 랭킹에 없으면 null을 반환합니다.
+ *
+ *
+ * @param productId 상품 ID
+ * @param date 날짜
+ * @return 순위 (1부터 시작, 없으면 null)
+ */
+ @Transactional(readOnly = true)
+ public Long getProductRank(Long productId, LocalDate date) {
+ String key = keyGenerator.generateDailyKey(date);
+ Long rank = zSetTemplate.getRank(key, String.valueOf(productId));
+
+ if (rank == null) {
+ return null;
+ }
+
+ // 0-based → 1-based 변환
+ return rank + 1;
+ }
+
+ /**
+ * 랭킹 조회 결과.
+ *
+ * @param items 랭킹 항목 목록
+ * @param page 현재 페이지 번호
+ * @param size 페이지당 항목 수
+ * @param hasNext 다음 페이지 존재 여부
+ */
+ public record RankingsResponse(
+ List items,
+ int page,
+ int size,
+ boolean hasNext
+ ) {
+ /**
+ * 빈 랭킹 조회 결과를 생성합니다.
+ */
+ public static RankingsResponse empty(int page, int size) {
+ return new RankingsResponse(List.of(), page, size, false);
+ }
+ }
+
+ /**
+ * 랭킹 항목 (순위, 점수, 상품 정보).
+ *
+ * @param rank 순위 (1부터 시작)
+ * @param score 점수
+ * @param productDetail 상품 상세 정보
+ */
+ public record RankingItem(
+ Long rank,
+ Double score,
+ ProductDetail productDetail
+ ) {
+ }
+}
From 2203dcd301d3c76657abfde02223dcac5c88b29d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=84=80=E1=85=A5=E1=86=AB=E1=84=8B?=
=?UTF-8?q?=E1=85=A7=E1=86=BC?= <>
Date: Sun, 21 Dec 2025 22:19:53 +0900
Subject: [PATCH 08/10] =?UTF-8?q?feat:=20=EB=9E=AD=ED=82=B9=20=EC=A1=B0?=
=?UTF-8?q?=ED=9A=8C=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=20?=
=?UTF-8?q?=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../api/ranking/RankingV1Controller.java | 89 ++++++++++++++++++
.../interfaces/api/ranking/RankingV1Dto.java | 94 +++++++++++++++++++
2 files changed, 183 insertions(+)
create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java
create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java
new file mode 100644
index 000000000..0abf28ef1
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java
@@ -0,0 +1,89 @@
+package com.loopers.interfaces.api.ranking;
+
+import com.loopers.application.ranking.RankingService;
+import com.loopers.interfaces.api.ApiResponse;
+import lombok.RequiredArgsConstructor;
+import org.springframework.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;
+
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+
+/**
+ * 랭킹 조회 API v1 컨트롤러.
+ *
+ * 랭킹 조회 유즈케이스를 처리합니다.
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@RequiredArgsConstructor
+@RestController
+@RequestMapping("/api/v1/rankings")
+public class RankingV1Controller {
+
+ private final RankingService rankingService;
+ private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd");
+
+ /**
+ * 랭킹을 조회합니다.
+ *
+ * 날짜별 랭킹을 페이징하여 조회합니다.
+ *
+ *
+ * @param date 날짜 (yyyyMMdd 형식, 기본값: 오늘 날짜)
+ * @param page 페이지 번호 (기본값: 0)
+ * @param size 페이지당 항목 수 (기본값: 20)
+ * @return 랭킹 목록을 담은 API 응답
+ */
+ @GetMapping
+ public ApiResponse getRankings(
+ @RequestParam(required = false) String date,
+ @RequestParam(required = false, defaultValue = "0") int page,
+ @RequestParam(required = false, defaultValue = "20") int size
+ ) {
+ // 날짜 파라미터 검증 및 기본값 처리
+ LocalDate targetDate = parseDate(date);
+
+ // 페이징 검증
+ if (page < 0) {
+ page = 0;
+ }
+ if (size < 1) {
+ size = 20;
+ }
+ if (size > 100) {
+ size = 100; // 최대 100개로 제한
+ }
+
+ RankingService.RankingsResponse result = rankingService.getRankings(targetDate, page, size);
+ return ApiResponse.success(RankingV1Dto.RankingsResponse.from(result));
+ }
+
+ /**
+ * 날짜 문자열을 LocalDate로 파싱합니다.
+ *
+ * 날짜가 없거나 파싱 실패 시 오늘 날짜를 반환합니다.
+ *
+ *
+ * @param dateStr 날짜 문자열 (yyyyMMdd 형식)
+ * @return 파싱된 날짜 (실패 시 오늘 날짜)
+ */
+ private LocalDate parseDate(String dateStr) {
+ if (dateStr == null || dateStr.isBlank()) {
+ return LocalDate.now();
+ }
+
+ try {
+ return LocalDate.parse(dateStr, DATE_FORMATTER);
+ } catch (DateTimeParseException e) {
+ // 파싱 실패 시 오늘 날짜 반환
+ return LocalDate.now();
+ }
+ }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java
new file mode 100644
index 000000000..45ac64ab0
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java
@@ -0,0 +1,94 @@
+package com.loopers.interfaces.api.ranking;
+
+import com.loopers.application.ranking.RankingService;
+import com.loopers.domain.product.ProductDetail;
+
+import java.util.List;
+
+/**
+ * 랭킹 조회 API v1의 데이터 전송 객체(DTO) 컨테이너.
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+public class RankingV1Dto {
+ /**
+ * 랭킹 항목 응답 데이터.
+ *
+ * @param rank 순위 (1부터 시작)
+ * @param score 점수
+ * @param productId 상품 ID
+ * @param name 상품 이름
+ * @param price 상품 가격
+ * @param stock 상품 재고
+ * @param brandId 브랜드 ID
+ * @param brandName 브랜드 이름
+ * @param likesCount 좋아요 수
+ */
+ public record RankingItemResponse(
+ Long rank,
+ Double score,
+ Long productId,
+ String name,
+ Integer price,
+ Integer stock,
+ Long brandId,
+ String brandName,
+ Long likesCount
+ ) {
+ /**
+ * RankingService.RankingItem으로부터 RankingItemResponse를 생성합니다.
+ *
+ * @param item 랭킹 항목
+ * @return 생성된 응답 객체
+ */
+ public static RankingItemResponse from(RankingService.RankingItem item) {
+ ProductDetail detail = item.productDetail();
+ return new RankingItemResponse(
+ item.rank(),
+ item.score(),
+ detail.getId(),
+ detail.getName(),
+ detail.getPrice(),
+ detail.getStock(),
+ detail.getBrandId(),
+ detail.getBrandName(),
+ detail.getLikesCount()
+ );
+ }
+ }
+
+ /**
+ * 랭킹 목록 응답 데이터.
+ *
+ * @param items 랭킹 항목 목록
+ * @param page 현재 페이지 번호
+ * @param size 페이지당 항목 수
+ * @param hasNext 다음 페이지 존재 여부
+ */
+ public record RankingsResponse(
+ List items,
+ int page,
+ int size,
+ boolean hasNext
+ ) {
+ /**
+ * RankingService.RankingsResponse로부터 RankingsResponse를 생성합니다.
+ *
+ * @param response 랭킹 조회 결과
+ * @return 생성된 응답 객체
+ */
+ public static RankingsResponse from(RankingService.RankingsResponse response) {
+ List items = response.items().stream()
+ .map(RankingItemResponse::from)
+ .toList();
+
+ return new RankingsResponse(
+ items,
+ response.page(),
+ response.size(),
+ response.hasNext()
+ );
+ }
+ }
+}
From f65de924875f8954736739d18097586fbf2505ca Mon Sep 17 00:00:00 2001
From: minor7295
Date: Wed, 24 Dec 2025 01:58:05 +0900
Subject: [PATCH 09/10] =?UTF-8?q?test:=20=EB=9E=AD=ED=82=B9=20=EC=A0=95?=
=?UTF-8?q?=EB=B3=B4=20=ED=8F=AC=ED=95=A8=ED=95=98=EC=97=AC=20=EC=83=81?=
=?UTF-8?q?=ED=92=88=20=EC=A1=B0=ED=9A=8C=ED=95=98=EB=8A=94=20=ED=85=8C?=
=?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../catalog/CatalogFacadeTest.java | 230 ++++++++++++++++++
1 file changed, 230 insertions(+)
create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/catalog/CatalogFacadeTest.java
diff --git a/apps/commerce-api/src/test/java/com/loopers/application/catalog/CatalogFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/catalog/CatalogFacadeTest.java
new file mode 100644
index 000000000..bb78c71b6
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/application/catalog/CatalogFacadeTest.java
@@ -0,0 +1,230 @@
+package com.loopers.application.catalog;
+
+import com.loopers.application.brand.BrandService;
+import com.loopers.application.product.ProductCacheService;
+import com.loopers.application.product.ProductService;
+import com.loopers.application.ranking.RankingService;
+import com.loopers.domain.brand.Brand;
+import com.loopers.domain.product.Product;
+import com.loopers.domain.product.ProductDetail;
+import com.loopers.domain.product.ProductEvent;
+import com.loopers.domain.product.ProductEventPublisher;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.lang.reflect.Field;
+import java.time.LocalDate;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.*;
+
+/**
+ * CatalogFacade 테스트.
+ *
+ * 상품 조회 시 랭킹 정보가 포함되는지 검증합니다.
+ * 캐시 히트/미스의 세부 로직은 ProductCacheService 테스트에서 검증합니다.
+ *
+ */
+@ExtendWith(MockitoExtension.class)
+@DisplayName("CatalogFacade 상품 조회 랭킹 정보 포함 테스트")
+class CatalogFacadeTest {
+
+ @Mock
+ private BrandService brandService;
+
+ @Mock
+ private ProductService productService;
+
+ @Mock
+ private ProductCacheService productCacheService;
+
+ @Mock
+ private ProductEventPublisher productEventPublisher;
+
+ @Mock
+ private RankingService rankingService;
+
+ @InjectMocks
+ private CatalogFacade catalogFacade;
+
+ private static final Long PRODUCT_ID = 1L;
+ private static final Long BRAND_ID = 10L;
+ private static final String BRAND_NAME = "테스트 브랜드";
+ private static final String PRODUCT_NAME = "테스트 상품";
+ private static final Integer PRODUCT_PRICE = 10000;
+ private static final Integer PRODUCT_STOCK = 10;
+ private static final Long LIKES_COUNT = 5L;
+
+ /**
+ * Product에 ID를 설정합니다 (리플렉션 사용).
+ */
+ private void setId(Product product, Long id) {
+ try {
+ Field idField = product.getClass().getSuperclass().getDeclaredField("id");
+ idField.setAccessible(true);
+ idField.set(product, id);
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to set Product ID", e);
+ }
+ }
+
+ /**
+ * Brand에 ID를 설정합니다 (리플렉션 사용).
+ */
+ private void setId(Brand brand, Long id) {
+ try {
+ Field idField = brand.getClass().getSuperclass().getDeclaredField("id");
+ idField.setAccessible(true);
+ idField.set(brand, id);
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to set Brand ID", e);
+ }
+ }
+
+ @Test
+ @DisplayName("캐시 히트 시 랭킹 정보가 포함된다")
+ void getProduct_withCacheHit_includesRanking() {
+ // arrange
+ ProductDetail cachedProductDetail = ProductDetail.of(
+ PRODUCT_ID,
+ PRODUCT_NAME,
+ PRODUCT_PRICE,
+ PRODUCT_STOCK,
+ BRAND_ID,
+ BRAND_NAME,
+ LIKES_COUNT
+ );
+ ProductInfo cachedProductInfo = ProductInfo.withoutRank(cachedProductDetail);
+ Long expectedRank = 3L;
+
+ when(productCacheService.getCachedProduct(PRODUCT_ID))
+ .thenReturn(cachedProductInfo);
+ when(rankingService.getProductRank(eq(PRODUCT_ID), any(LocalDate.class)))
+ .thenReturn(expectedRank);
+
+ // act
+ ProductInfo result = catalogFacade.getProduct(PRODUCT_ID);
+
+ // assert
+ assertThat(result.rank()).isEqualTo(expectedRank);
+ verify(rankingService).getProductRank(eq(PRODUCT_ID), any(LocalDate.class));
+ verify(productEventPublisher).publish(any(ProductEvent.ProductViewed.class));
+ verify(productService, never()).getProduct(any());
+ }
+
+ @Test
+ @DisplayName("캐시 히트 시 랭킹에 없는 상품은 null을 반환한다")
+ void getProduct_withCacheHit_noRanking_returnsNull() {
+ // arrange
+ ProductDetail cachedProductDetail = ProductDetail.of(
+ PRODUCT_ID,
+ PRODUCT_NAME,
+ PRODUCT_PRICE,
+ PRODUCT_STOCK,
+ BRAND_ID,
+ BRAND_NAME,
+ LIKES_COUNT
+ );
+ ProductInfo cachedProductInfo = ProductInfo.withoutRank(cachedProductDetail);
+
+ when(productCacheService.getCachedProduct(PRODUCT_ID))
+ .thenReturn(cachedProductInfo);
+ when(rankingService.getProductRank(eq(PRODUCT_ID), any(LocalDate.class)))
+ .thenReturn(null);
+
+ // act
+ ProductInfo result = catalogFacade.getProduct(PRODUCT_ID);
+
+ // assert
+ assertThat(result.rank()).isNull();
+ verify(rankingService).getProductRank(eq(PRODUCT_ID), any(LocalDate.class));
+ }
+
+ @Test
+ @DisplayName("캐시 미스 시 랭킹 정보가 포함된다")
+ void getProduct_withCacheMiss_includesRanking() {
+ // arrange
+ Product product = Product.of(PRODUCT_NAME, PRODUCT_PRICE, PRODUCT_STOCK, BRAND_ID);
+ setId(product, PRODUCT_ID);
+
+ // Product.likeCount 설정 (리플렉션 사용)
+ try {
+ Field likeCountField = Product.class.getDeclaredField("likeCount");
+ likeCountField.setAccessible(true);
+ likeCountField.set(product, LIKES_COUNT);
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to set Product likeCount", e);
+ }
+
+ Brand brand = Brand.of(BRAND_NAME);
+ setId(brand, BRAND_ID);
+
+ Long expectedRank = 5L;
+
+ when(productCacheService.getCachedProduct(PRODUCT_ID))
+ .thenReturn(null);
+ when(productService.getProduct(PRODUCT_ID))
+ .thenReturn(product);
+ when(brandService.getBrand(BRAND_ID))
+ .thenReturn(brand);
+ when(rankingService.getProductRank(eq(PRODUCT_ID), any(LocalDate.class)))
+ .thenReturn(expectedRank);
+ when(productCacheService.applyLikeCountDelta(any(ProductInfo.class)))
+ .thenAnswer(invocation -> invocation.getArgument(0));
+
+ // act
+ ProductInfo result = catalogFacade.getProduct(PRODUCT_ID);
+
+ // assert
+ assertThat(result.rank()).isEqualTo(expectedRank);
+ verify(rankingService).getProductRank(eq(PRODUCT_ID), any(LocalDate.class));
+ verify(productEventPublisher).publish(any(ProductEvent.ProductViewed.class));
+ verify(productService).getProduct(PRODUCT_ID);
+ verify(productCacheService).cacheProduct(eq(PRODUCT_ID), any(ProductInfo.class));
+ }
+
+ @Test
+ @DisplayName("캐시 미스 시 랭킹에 없는 상품은 null을 반환한다")
+ void getProduct_withCacheMiss_noRanking_returnsNull() {
+ // arrange
+ Product product = Product.of(PRODUCT_NAME, PRODUCT_PRICE, PRODUCT_STOCK, BRAND_ID);
+ setId(product, PRODUCT_ID);
+
+ // Product.likeCount 설정 (리플렉션 사용)
+ try {
+ Field likeCountField = Product.class.getDeclaredField("likeCount");
+ likeCountField.setAccessible(true);
+ likeCountField.set(product, LIKES_COUNT);
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to set Product likeCount", e);
+ }
+
+ Brand brand = Brand.of(BRAND_NAME);
+ setId(brand, BRAND_ID);
+
+ when(productCacheService.getCachedProduct(PRODUCT_ID))
+ .thenReturn(null);
+ when(productService.getProduct(PRODUCT_ID))
+ .thenReturn(product);
+ when(brandService.getBrand(BRAND_ID))
+ .thenReturn(brand);
+ when(rankingService.getProductRank(eq(PRODUCT_ID), any(LocalDate.class)))
+ .thenReturn(null);
+ when(productCacheService.applyLikeCountDelta(any(ProductInfo.class)))
+ .thenAnswer(invocation -> invocation.getArgument(0));
+
+ // act
+ ProductInfo result = catalogFacade.getProduct(PRODUCT_ID);
+
+ // assert
+ assertThat(result.rank()).isNull();
+ verify(rankingService).getProductRank(eq(PRODUCT_ID), any(LocalDate.class));
+ }
+}
+
From 8ea1eff8f9482071f7891c1cdf9954b51596e389 Mon Sep 17 00:00:00 2001
From: minor7295
Date: Wed, 24 Dec 2025 01:58:43 +0900
Subject: [PATCH 10/10] =?UTF-8?q?feat:=20=EB=9E=AD=ED=82=B9=20=ED=8F=AC?=
=?UTF-8?q?=ED=95=A8=ED=95=98=EC=97=AC=20=EC=83=81=ED=92=88=20=EC=A0=95?=
=?UTF-8?q?=EB=B3=B4=20=EC=A1=B0=ED=9A=8C=ED=95=98=EB=8F=84=EB=A1=9D=20api?=
=?UTF-8?q?=20=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../application/catalog/CatalogFacade.java | 25 +++++++++++++------
.../application/catalog/ProductInfo.java | 23 ++++++++++++++++-
.../product/ProductCacheService.java | 2 +-
.../interfaces/api/catalog/ProductV1Dto.java | 7 ++++--
4 files changed, 46 insertions(+), 11 deletions(-)
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogFacade.java
index c8eed8f67..4ce66ca53 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogFacade.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogFacade.java
@@ -3,6 +3,7 @@
import com.loopers.application.brand.BrandService;
import com.loopers.application.product.ProductCacheService;
import com.loopers.application.product.ProductService;
+import com.loopers.application.ranking.RankingService;
import com.loopers.domain.brand.Brand;
import com.loopers.domain.product.Product;
import com.loopers.domain.product.ProductDetail;
@@ -14,6 +15,7 @@
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
+import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@@ -34,6 +36,7 @@ public class CatalogFacade {
private final ProductService productService;
private final ProductCacheService productCacheService;
private final ProductEventPublisher productEventPublisher;
+ private final RankingService rankingService;
/**
* 상품 목록을 조회합니다.
@@ -90,7 +93,7 @@ public ProductInfoList getProducts(Long brandId, String sort, int page, int size
}
// ✅ Product.likeCount 필드 사용 (비동기 집계된 값)
ProductDetail productDetail = ProductDetail.from(product, brand.getName(), product.getLikeCount());
- return new ProductInfo(productDetail);
+ return ProductInfo.withoutRank(productDetail);
})
.toList();
@@ -108,10 +111,11 @@ public ProductInfoList getProducts(Long brandId, String sort, int page, int size
*
* Redis 캐시를 먼저 확인하고, 캐시에 없으면 DB에서 조회한 후 캐시에 저장합니다.
* 상품 조회 시 ProductViewed 이벤트를 발행하여 메트릭 집계에 사용합니다.
+ * 랭킹 정보도 함께 조회하여 반환합니다.
*
*
* @param productId 상품 ID
- * @return 상품 정보와 좋아요 수
+ * @return 상품 정보와 좋아요 수, 랭킹 순위
* @throws CoreException 상품을 찾을 수 없는 경우
*/
@Transactional(readOnly = true)
@@ -121,7 +125,11 @@ public ProductInfo getProduct(Long productId) {
if (cachedResult != null) {
// 캐시 히트 시에도 조회 수 집계를 위해 이벤트 발행
productEventPublisher.publish(ProductEvent.ProductViewed.from(productId));
- return cachedResult;
+
+ // 랭킹 정보 조회 (캐시된 결과에 랭킹 정보 추가)
+ LocalDate today = LocalDate.now();
+ Long rank = rankingService.getProductRank(productId, today);
+ return ProductInfo.withRank(cachedResult.productDetail(), rank);
}
// 캐시에 없으면 DB에서 조회
@@ -136,16 +144,19 @@ public ProductInfo getProduct(Long productId) {
// ProductDetail 생성 (Aggregate 경계 준수: Brand 엔티티 대신 brandName만 전달)
ProductDetail productDetail = ProductDetail.from(product, brand.getName(), likesCount);
- ProductInfo result = new ProductInfo(productDetail);
+ // 랭킹 정보 조회
+ LocalDate today = LocalDate.now();
+ Long rank = rankingService.getProductRank(productId, today);
- // 캐시에 저장
- productCacheService.cacheProduct(productId, result);
+ // 캐시에 저장 (랭킹 정보는 제외하고 저장 - 랭킹은 실시간으로 조회)
+ productCacheService.cacheProduct(productId, ProductInfo.withoutRank(productDetail));
// ✅ 상품 조회 이벤트 발행 (메트릭 집계용)
productEventPublisher.publish(ProductEvent.ProductViewed.from(productId));
// 로컬 캐시의 좋아요 수 델타 적용 (DB 조회 결과에도 델타 반영)
- return productCacheService.applyLikeCountDelta(result);
+ ProductInfo deltaApplied = productCacheService.applyLikeCountDelta(ProductInfo.withoutRank(productDetail));
+ return ProductInfo.withRank(deltaApplied.productDetail(), rank);
}
}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/catalog/ProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/catalog/ProductInfo.java
index 6a22a5f21..ec634bc0a 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/catalog/ProductInfo.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/catalog/ProductInfo.java
@@ -6,7 +6,28 @@
* 상품 상세 정보를 담는 레코드.
*
* @param productDetail 상품 상세 정보 (Product + Brand + 좋아요 수)
+ * @param rank 랭킹 순위 (1부터 시작, 랭킹에 없으면 null)
*/
-public record ProductInfo(ProductDetail productDetail) {
+public record ProductInfo(ProductDetail productDetail, Long rank) {
+ /**
+ * 랭킹 정보 없이 ProductInfo를 생성합니다.
+ *
+ * @param productDetail 상품 상세 정보
+ * @return ProductInfo (rank는 null)
+ */
+ public static ProductInfo withoutRank(ProductDetail productDetail) {
+ return new ProductInfo(productDetail, null);
+ }
+
+ /**
+ * 랭킹 정보와 함께 ProductInfo를 생성합니다.
+ *
+ * @param productDetail 상품 상세 정보
+ * @param rank 랭킹 순위 (1부터 시작, 랭킹에 없으면 null)
+ * @return ProductInfo
+ */
+ public static ProductInfo withRank(ProductDetail productDetail, Long rank) {
+ return new ProductInfo(productDetail, rank);
+ }
}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheService.java
index f2e6b5bfe..32c4f915b 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheService.java
@@ -269,7 +269,7 @@ public ProductInfo applyLikeCountDelta(ProductInfo productInfo) {
updatedLikesCount
);
- return new ProductInfo(updatedDetail);
+ return ProductInfo.withoutRank(updatedDetail);
}
}
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/ProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/ProductV1Dto.java
index 3661d9c9e..7df592db6 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/ProductV1Dto.java
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/ProductV1Dto.java
@@ -21,6 +21,7 @@ public class ProductV1Dto {
* @param stock 상품 재고
* @param brandId 브랜드 ID
* @param likesCount 좋아요 수
+ * @param rank 랭킹 순위 (1부터 시작, 랭킹에 없으면 null)
*/
public record ProductResponse(
Long productId,
@@ -28,7 +29,8 @@ public record ProductResponse(
Integer price,
Integer stock,
Long brandId,
- Long likesCount
+ Long likesCount,
+ Long rank
) {
/**
* ProductInfo로부터 ProductResponse를 생성합니다.
@@ -44,7 +46,8 @@ public static ProductResponse from(ProductInfo productInfo) {
detail.getPrice(),
detail.getStock(),
detail.getBrandId(),
- detail.getLikesCount()
+ detail.getLikesCount(),
+ productInfo.rank()
);
}
}