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() ); } }