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
index 327383145..df6305b83 100644
--- 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
@@ -5,12 +5,11 @@
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.dao.DataAccessException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -18,6 +17,7 @@
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
+import java.util.Optional;
import java.util.stream.Collectors;
/**
@@ -45,12 +45,20 @@ public class RankingService {
private final RankingKeyGenerator keyGenerator;
private final ProductService productService;
private final BrandService brandService;
+ private final RankingSnapshotService rankingSnapshotService;
/**
* 랭킹을 조회합니다 (페이징).
*
* ZSET에서 상위 N개를 조회하고, 상품 정보를 Aggregation하여 반환합니다.
*
+ *
+ * Graceful Degradation:
+ *
+ * - Redis 장애 시 스냅샷으로 Fallback
+ * - 스냅샷도 없으면 기본 랭킹(좋아요순) 제공 (단순 조회, 계산 아님)
+ *
+ *
*
* @param date 날짜 (yyyyMMdd 형식의 문자열 또는 LocalDate)
* @param page 페이지 번호 (0부터 시작)
@@ -59,6 +67,47 @@ public class RankingService {
*/
@Transactional(readOnly = true)
public RankingsResponse getRankings(LocalDate date, int page, int size) {
+ try {
+ return getRankingsFromRedis(date, page, size);
+ } catch (DataAccessException e) {
+ log.warn("Redis 랭킹 조회 실패, 스냅샷으로 Fallback: date={}, error={}",
+ date, e.getMessage());
+ // 스냅샷으로 Fallback 시도
+ Optional snapshot = rankingSnapshotService.getSnapshot(date);
+ if (snapshot.isPresent()) {
+ log.info("스냅샷으로 랭킹 제공: date={}, itemCount={}", date, snapshot.get().items().size());
+ return snapshot.get();
+ }
+
+ // 전날 스냅샷 시도
+ Optional yesterdaySnapshot = rankingSnapshotService.getSnapshot(date.minusDays(1));
+ if (yesterdaySnapshot.isPresent()) {
+ log.info("전날 스냅샷으로 랭킹 제공: date={}, itemCount={}", date, yesterdaySnapshot.get().items().size());
+ return yesterdaySnapshot.get();
+ }
+
+ // 최종 Fallback: 기본 랭킹 (단순 조회, 계산 아님)
+ log.warn("스냅샷도 없음, 기본 랭킹(좋아요순)으로 Fallback: date={}", date);
+ return getDefaultRankings(page, size);
+ } catch (Exception e) {
+ log.error("랭킹 조회 중 예상치 못한 오류 발생, 기본 랭킹으로 Fallback: date={}", date, e);
+ return getDefaultRankings(page, size);
+ }
+ }
+
+ /**
+ * Redis에서 랭킹을 조회합니다.
+ *
+ * 스케줄러에서 스냅샷 저장 시 호출하기 위해 public으로 제공합니다.
+ *
+ *
+ * @param date 날짜
+ * @param page 페이지 번호
+ * @param size 페이지당 항목 수
+ * @return 랭킹 조회 결과
+ * @throws DataAccessException Redis 접근 실패 시
+ */
+ public RankingsResponse getRankingsFromRedis(LocalDate date, int page, int size) {
String key = keyGenerator.generateDailyKey(date);
long start = (long) page * size;
long end = start + size - 1;
@@ -132,11 +181,81 @@ public RankingsResponse getRankings(LocalDate date, int page, int size) {
return new RankingsResponse(rankingItems, page, size, hasNext);
}
+ /**
+ * 기본 랭킹(좋아요순)을 제공합니다.
+ *
+ * 최종 Fallback으로 사용됩니다. 랭킹을 새로 계산하는 것이 아니라
+ * 이미 집계된 좋아요 수를 단순 조회하는 것이므로 DB 부하가 크지 않습니다.
+ *
+ *
+ * @param page 페이지 번호 (0부터 시작)
+ * @param size 페이지당 항목 수
+ * @return 랭킹 조회 결과
+ */
+ private RankingsResponse getDefaultRankings(int page, int size) {
+ // 좋아요순으로 상품 조회
+ List products = productService.findAll(null, "likes_desc", page, size);
+ long totalCount = productService.countAll(null);
+
+ if (products.isEmpty()) {
+ return RankingsResponse.empty(page, size);
+ }
+
+ // 브랜드 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<>();
+ long start = (long) page * size;
+ for (int i = 0; i < products.size(); i++) {
+ Product product = products.get(i);
+ Long rank = start + i + 1; // 1-based 순위
+
+ Brand brand = brandMap.get(product.getBrandId());
+ if (brand == null) {
+ log.warn("상품의 브랜드를 찾을 수 없습니다: productId={}, brandId={}",
+ product.getId(), product.getBrandId());
+ continue;
+ }
+
+ ProductDetail productDetail = ProductDetail.from(
+ product,
+ brand.getName(),
+ product.getLikeCount()
+ );
+
+ // 좋아요 수를 점수로 사용
+ double score = product.getLikeCount() != null ? product.getLikeCount().doubleValue() : 0.0;
+ rankingItems.add(new RankingItem(
+ rank,
+ score,
+ productDetail
+ ));
+ }
+
+ boolean hasNext = (start + size) < totalCount;
+ return new RankingsResponse(rankingItems, page, size, hasNext);
+ }
+
/**
* 특정 상품의 순위를 조회합니다.
*
* 상품이 랭킹에 없으면 null을 반환합니다.
*
+ *
+ * Graceful Degradation:
+ *
+ * - Redis 장애 시 전날 랭킹으로 Fallback
+ * - 전날 랭킹도 없으면 null 반환 (기본 랭킹에서는 순위 계산 불가)
+ *
+ *
*
* @param productId 상품 ID
* @param date 날짜
@@ -144,6 +263,36 @@ public RankingsResponse getRankings(LocalDate date, int page, int size) {
*/
@Transactional(readOnly = true)
public Long getProductRank(Long productId, LocalDate date) {
+ try {
+ return getProductRankFromRedis(productId, date);
+ } catch (DataAccessException e) {
+ log.warn("Redis 상품 순위 조회 실패, 전날 랭킹으로 Fallback: productId={}, date={}, error={}",
+ productId, date, e.getMessage());
+ // 전날 랭킹으로 Fallback 시도
+ try {
+ LocalDate yesterday = date.minusDays(1);
+ return getProductRankFromRedis(productId, yesterday);
+ } catch (DataAccessException fallbackException) {
+ log.warn("전날 랭킹 조회도 실패: productId={}, date={}, error={}",
+ productId, date, fallbackException.getMessage());
+ // 기본 랭킹에서는 순위 계산이 어려우므로 null 반환
+ return null;
+ }
+ } catch (Exception e) {
+ log.error("상품 순위 조회 중 예상치 못한 오류 발생: productId={}, date={}", productId, date, e);
+ return null;
+ }
+ }
+
+ /**
+ * Redis에서 상품 순위를 조회합니다.
+ *
+ * @param productId 상품 ID
+ * @param date 날짜
+ * @return 순위 (1부터 시작, 없으면 null)
+ * @throws DataAccessException Redis 접근 실패 시
+ */
+ private Long getProductRankFromRedis(Long productId, LocalDate date) {
String key = keyGenerator.generateDailyKey(date);
Long rank = zSetTemplate.getRank(key, String.valueOf(productId));
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingSnapshotService.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingSnapshotService.java
new file mode 100644
index 000000000..c9bd2efab
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingSnapshotService.java
@@ -0,0 +1,103 @@
+package com.loopers.application.ranking;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * 랭킹 스냅샷 서비스.
+ *
+ * Redis 장애 시 Fallback으로 사용하기 위한 랭킹 데이터 스냅샷을 인메모리에 저장합니다.
+ *
+ *
+ * 설계 원칙:
+ *
+ * - 인메모리 캐시: 구현이 간단하고 성능이 우수함
+ * - 메모리 관리: 최근 7일치만 보관하여 메모리 사용량 제한
+ * - 스냅샷 기반 Fallback: DB 실시간 재계산 대신 스냅샷 서빙으로 DB 부하 방지
+ *
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Slf4j
+@Service
+public class RankingSnapshotService {
+
+ private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd");
+ private static final int MAX_SNAPSHOTS = 7; // 최근 7일치만 보관
+
+ private final Map snapshotCache = new ConcurrentHashMap<>();
+
+ /**
+ * 랭킹 스냅샷을 저장합니다.
+ *
+ * @param date 날짜
+ * @param rankings 랭킹 조회 결과
+ */
+ public void saveSnapshot(LocalDate date, RankingService.RankingsResponse rankings) {
+ String key = date.format(DATE_FORMATTER);
+ snapshotCache.put(key, rankings);
+ log.debug("랭킹 스냅샷 저장: date={}, key={}, itemCount={}", date, key, rankings.items().size());
+
+ // 오래된 스냅샷 정리 (메모리 관리)
+ cleanupOldSnapshots();
+ }
+
+ /**
+ * 랭킹 스냅샷을 조회합니다.
+ *
+ * @param date 날짜
+ * @return 랭킹 조회 결과 (없으면 empty)
+ */
+ public Optional getSnapshot(LocalDate date) {
+ String key = date.format(DATE_FORMATTER);
+ RankingService.RankingsResponse snapshot = snapshotCache.get(key);
+
+ if (snapshot != null) {
+ log.debug("랭킹 스냅샷 조회 성공: date={}, key={}, itemCount={}", date, key, snapshot.items().size());
+ return Optional.of(snapshot);
+ }
+
+ log.debug("랭킹 스냅샷 없음: date={}, key={}", date, key);
+ return Optional.empty();
+ }
+
+ /**
+ * 오래된 스냅샷을 정리합니다.
+ *
+ * 최근 7일치만 보관하여 메모리 사용량을 제한합니다.
+ *
+ */
+ private void cleanupOldSnapshots() {
+ if (snapshotCache.size() <= MAX_SNAPSHOTS) {
+ return;
+ }
+
+ // 가장 오래된 스냅샷 제거
+ LocalDate today = LocalDate.now(ZoneId.of("UTC"));
+ LocalDate oldestDate = today.minusDays(MAX_SNAPSHOTS);
+
+ snapshotCache.entrySet().removeIf(entry -> {
+ try {
+ LocalDate entryDate = LocalDate.parse(entry.getKey(), DATE_FORMATTER);
+ boolean shouldRemove = entryDate.isBefore(oldestDate);
+ if (shouldRemove) {
+ log.debug("오래된 스냅샷 제거: key={}", entry.getKey());
+ }
+ return shouldRemove;
+ } catch (Exception e) {
+ log.warn("스냅샷 키 파싱 실패, 제거: key={}", entry.getKey(), e);
+ return true; // 파싱 실패한 키는 제거
+ }
+ });
+ }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/scheduler/RankingSnapshotScheduler.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/scheduler/RankingSnapshotScheduler.java
new file mode 100644
index 000000000..3adefd9be
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/scheduler/RankingSnapshotScheduler.java
@@ -0,0 +1,72 @@
+package com.loopers.infrastructure.scheduler;
+
+import com.loopers.application.ranking.RankingService;
+import com.loopers.application.ranking.RankingSnapshotService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDate;
+import java.time.ZoneId;
+
+/**
+ * 랭킹 스냅샷 저장 스케줄러.
+ *
+ * 주기적으로 랭킹 결과를 스냅샷으로 저장하여, Redis 장애 시 Fallback으로 사용할 수 있도록 합니다.
+ *
+ *
+ * 설계 원칙:
+ *
+ * - 스냅샷 기반 Fallback: DB 실시간 재계산 대신 스냅샷 서빙으로 DB 부하 방지
+ * - 주기적 저장: 1시간마다 최신 랭킹을 스냅샷으로 저장
+ * - 에러 처리: 스냅샷 저장 실패 시에도 다음 스케줄에서 재시도
+ *
+ *
+ *
+ * 주기 선택 근거:
+ *
+ * - 비용 대비 효과: 1시간 주기가 리소스 사용량이 1/12로 감소하면서도 사용자 체감 차이는 거의 없음
+ * - 랭킹의 성격: 비즈니스 결정이 아닌 조회용 파생 데이터이므로 1시간 전 데이터도 충분히 유용함
+ * - 운영 관점: 스케줄러 실행 빈도가 낮아 모니터링 부담 감소
+ *
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class RankingSnapshotScheduler {
+
+ private final RankingService rankingService;
+ private final RankingSnapshotService rankingSnapshotService;
+
+ /**
+ * 랭킹 스냅샷을 저장합니다.
+ *
+ * 1시간마다 실행되어 오늘의 랭킹을 스냅샷으로 저장합니다.
+ *
+ */
+ @Scheduled(fixedRate = 3600000) // 1시간마다 (3600000ms = 1시간)
+ public void saveRankingSnapshot() {
+ LocalDate today = LocalDate.now(ZoneId.of("UTC"));
+ try {
+ // 상위 100개 랭킹을 스냅샷으로 저장 (대부분의 사용자가 상위 100개 이내만 조회)
+ // Redis가 정상일 때만 스냅샷 저장 (예외 발생 시 스킵)
+ RankingService.RankingsResponse rankings = rankingService.getRankingsFromRedis(today, 0, 100);
+
+ rankingSnapshotService.saveSnapshot(today, rankings);
+
+ log.debug("랭킹 스냅샷 저장 완료: date={}, itemCount={}", today, rankings.items().size());
+ } catch (org.springframework.dao.DataAccessException e) {
+ log.warn("Redis 장애로 인한 랭킹 스냅샷 저장 실패: date={}, error={}", today, e.getMessage());
+ // Redis 장애 시 스냅샷 저장 스킵 (다음 스케줄에서 재시도)
+ } catch (Exception e) {
+ log.warn("랭킹 스냅샷 저장 실패: date={}", today, e);
+ // 스냅샷 저장 실패는 다음 스케줄에서 재시도
+ }
+ }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java
index 0abf28ef1..ecbae6157 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java
@@ -3,13 +3,13 @@
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.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
@@ -76,14 +76,14 @@ public ApiResponse getRankings(
*/
private LocalDate parseDate(String dateStr) {
if (dateStr == null || dateStr.isBlank()) {
- return LocalDate.now();
+ return LocalDate.now(ZoneId.of("UTC"));
}
try {
return LocalDate.parse(dateStr, DATE_FORMATTER);
} catch (DateTimeParseException e) {
- // 파싱 실패 시 오늘 날짜 반환
- return LocalDate.now();
+ // 파싱 실패 시 오늘 날짜 반환 (UTC 기준)
+ return LocalDate.now(ZoneId.of("UTC"));
}
}
}
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
index 222cd9f9b..5bd82f939 100644
--- 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
@@ -4,6 +4,7 @@
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.zset.RedisZSetTemplate;
import com.loopers.zset.ZSetEntry;
import org.junit.jupiter.api.DisplayName;
@@ -39,6 +40,9 @@ class RankingServiceTest {
@Mock
private BrandService brandService;
+ @Mock
+ private RankingSnapshotService rankingSnapshotService;
+
@InjectMocks
private RankingService rankingService;
@@ -397,4 +401,207 @@ void canHandleMultipleProductsFromSameBrand() {
// 브랜드는 한 번만 조회됨 (중복 제거)
verify(brandService).getBrands(List.of(brandId));
}
+
+ @DisplayName("Redis 장애 시 스냅샷으로 Fallback한다.")
+ @Test
+ void fallbackToSnapshot_whenRedisFails() {
+ // arrange
+ LocalDate date = LocalDate.of(2024, 12, 15);
+ int page = 0;
+ int size = 20;
+ String todayKey = "ranking:all:20241215";
+
+ Long productId = 1L;
+ Long brandId = 10L;
+
+ Product product = Product.of("상품", 10000, 10, brandId);
+ Brand brand = Brand.of("브랜드");
+
+ // ID 설정
+ setId(product, productId);
+ setId(brand, brandId);
+
+ RankingService.RankingItem rankingItem = new RankingService.RankingItem(
+ 1L, 100.0,
+ ProductDetail.from(product, brand.getName(), product.getLikeCount())
+ );
+ RankingService.RankingsResponse snapshot = new RankingService.RankingsResponse(
+ List.of(rankingItem), page, size, false
+ );
+
+ when(keyGenerator.generateDailyKey(date)).thenReturn(todayKey);
+
+ // 오늘 랭킹 조회 시 예외 발생
+ when(zSetTemplate.getTopRankings(todayKey, 0L, 19L))
+ .thenThrow(new org.springframework.dao.DataAccessException("Redis connection failed") {});
+
+ // 스냅샷 조회 성공
+ when(rankingSnapshotService.getSnapshot(date)).thenReturn(java.util.Optional.of(snapshot));
+
+ // act
+ RankingService.RankingsResponse result = rankingService.getRankings(date, page, size);
+
+ // assert
+ assertThat(result.items()).hasSize(1);
+ assertThat(result.items().get(0).productDetail().getId()).isEqualTo(productId);
+ verify(zSetTemplate).getTopRankings(todayKey, 0L, 19L);
+ verify(rankingSnapshotService).getSnapshot(date);
+ verify(rankingSnapshotService, never()).getSnapshot(date.minusDays(1));
+ }
+
+ @DisplayName("Redis 장애 시 스냅샷이 없으면 전날 스냅샷으로 Fallback한다.")
+ @Test
+ void fallbackToYesterdaySnapshot_whenSnapshotNotAvailable() {
+ // arrange
+ LocalDate date = LocalDate.of(2024, 12, 15);
+ LocalDate yesterday = date.minusDays(1);
+ int page = 0;
+ int size = 20;
+ String todayKey = "ranking:all:20241215";
+
+ Long productId = 1L;
+ Long brandId = 10L;
+
+ Product product = Product.of("상품", 10000, 10, brandId);
+ Brand brand = Brand.of("브랜드");
+
+ // ID 설정
+ setId(product, productId);
+ setId(brand, brandId);
+
+ RankingService.RankingItem rankingItem = new RankingService.RankingItem(
+ 1L, 100.0,
+ ProductDetail.from(product, brand.getName(), product.getLikeCount())
+ );
+ RankingService.RankingsResponse yesterdaySnapshot = new RankingService.RankingsResponse(
+ List.of(rankingItem), page, size, false
+ );
+
+ when(keyGenerator.generateDailyKey(date)).thenReturn(todayKey);
+
+ // 오늘 랭킹 조회 시 예외 발생
+ when(zSetTemplate.getTopRankings(todayKey, 0L, 19L))
+ .thenThrow(new org.springframework.dao.DataAccessException("Redis connection failed") {});
+
+ // 오늘 스냅샷 없음, 전날 스냅샷 있음
+ when(rankingSnapshotService.getSnapshot(date)).thenReturn(java.util.Optional.empty());
+ when(rankingSnapshotService.getSnapshot(yesterday)).thenReturn(java.util.Optional.of(yesterdaySnapshot));
+
+ // act
+ RankingService.RankingsResponse result = rankingService.getRankings(date, page, size);
+
+ // assert
+ assertThat(result.items()).hasSize(1);
+ assertThat(result.items().get(0).productDetail().getId()).isEqualTo(productId);
+ verify(zSetTemplate).getTopRankings(todayKey, 0L, 19L);
+ verify(rankingSnapshotService).getSnapshot(date);
+ verify(rankingSnapshotService).getSnapshot(yesterday);
+ }
+
+ @DisplayName("Redis 장애 시 스냅샷도 없으면 기본 랭킹(좋아요순)으로 Fallback한다.")
+ @Test
+ void fallbackToDefaultRanking_whenSnapshotNotAvailable() {
+ // arrange
+ LocalDate date = LocalDate.of(2024, 12, 15);
+ LocalDate yesterday = date.minusDays(1);
+ int page = 0;
+ int size = 20;
+ String todayKey = "ranking:all:20241215";
+
+ Long productId = 1L;
+ Long brandId = 10L;
+
+ Product product = Product.of("상품", 10000, 10, brandId);
+ Brand brand = Brand.of("브랜드");
+
+ // ID 설정
+ setId(product, productId);
+ setId(brand, brandId);
+
+ when(keyGenerator.generateDailyKey(date)).thenReturn(todayKey);
+
+ // 오늘 랭킹 조회 시 예외 발생
+ when(zSetTemplate.getTopRankings(todayKey, 0L, 19L))
+ .thenThrow(new org.springframework.dao.DataAccessException("Redis connection failed") {});
+
+ // 스냅샷도 없음
+ when(rankingSnapshotService.getSnapshot(date)).thenReturn(java.util.Optional.empty());
+ when(rankingSnapshotService.getSnapshot(yesterday)).thenReturn(java.util.Optional.empty());
+
+ // 기본 랭킹(좋아요순) 조회
+ when(productService.findAll(null, "likes_desc", page, size)).thenReturn(List.of(product));
+ when(productService.countAll(null)).thenReturn(1L);
+ 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.items().get(0).productDetail().getId()).isEqualTo(productId);
+ assertThat(result.items().get(0).score()).isEqualTo(product.getLikeCount().doubleValue());
+ verify(rankingSnapshotService).getSnapshot(date);
+ verify(rankingSnapshotService).getSnapshot(yesterday);
+ verify(productService).findAll(null, "likes_desc", page, size);
+ }
+
+ @DisplayName("Redis 장애 시 상품 순위 조회도 전날 랭킹으로 Fallback한다.")
+ @Test
+ void fallbackToYesterdayRanking_whenGetProductRankFails() {
+ // arrange
+ Long productId = 1L;
+ LocalDate date = LocalDate.of(2024, 12, 15);
+ LocalDate yesterday = date.minusDays(1);
+ String todayKey = "ranking:all:20241215";
+ String yesterdayKey = "ranking:all:20241214";
+ Long rank = 5L; // 0-based
+
+ when(keyGenerator.generateDailyKey(date)).thenReturn(todayKey);
+ when(keyGenerator.generateDailyKey(yesterday)).thenReturn(yesterdayKey);
+
+ // 오늘 랭킹 조회 시 예외 발생
+ when(zSetTemplate.getRank(todayKey, String.valueOf(productId)))
+ .thenThrow(new org.springframework.dao.DataAccessException("Redis connection failed") {});
+
+ // 전날 랭킹 조회 성공
+ when(zSetTemplate.getRank(yesterdayKey, String.valueOf(productId))).thenReturn(rank);
+
+ // act
+ Long result = rankingService.getProductRank(productId, date);
+
+ // assert
+ assertThat(result).isEqualTo(6L); // 1-based (5 + 1)
+ verify(zSetTemplate).getRank(todayKey, String.valueOf(productId));
+ verify(zSetTemplate).getRank(yesterdayKey, String.valueOf(productId));
+ }
+
+ @DisplayName("Redis 장애 시 상품 순위 조회도 전날 랭킹이 없으면 null을 반환한다.")
+ @Test
+ void returnsNull_whenRedisAndYesterdayRankingFail() {
+ // arrange
+ Long productId = 1L;
+ LocalDate date = LocalDate.of(2024, 12, 15);
+ LocalDate yesterday = date.minusDays(1);
+ String todayKey = "ranking:all:20241215";
+ String yesterdayKey = "ranking:all:20241214";
+
+ when(keyGenerator.generateDailyKey(date)).thenReturn(todayKey);
+ when(keyGenerator.generateDailyKey(yesterday)).thenReturn(yesterdayKey);
+
+ // 오늘 랭킹 조회 시 예외 발생
+ when(zSetTemplate.getRank(todayKey, String.valueOf(productId)))
+ .thenThrow(new org.springframework.dao.DataAccessException("Redis connection failed") {});
+
+ // 전날 랭킹 조회도 예외 발생
+ when(zSetTemplate.getRank(yesterdayKey, String.valueOf(productId)))
+ .thenThrow(new org.springframework.dao.DataAccessException("Redis connection failed") {});
+
+ // act
+ Long result = rankingService.getProductRank(productId, date);
+
+ // assert
+ assertThat(result).isNull();
+ verify(zSetTemplate).getRank(todayKey, String.valueOf(productId));
+ verify(zSetTemplate).getRank(yesterdayKey, String.valueOf(productId));
+ }
}
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java b/apps/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java
index ea4b4d15a..eb986acdd 100644
--- a/apps/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java
+++ b/apps/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java
@@ -4,11 +4,13 @@
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
+import org.springframework.scheduling.annotation.EnableScheduling;
import java.util.TimeZone;
@ConfigurationPropertiesScan
@SpringBootApplication
+@EnableScheduling
public class CommerceStreamerApplication {
@PostConstruct
public void started() {
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingEventHandler.java b/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingEventHandler.java
index 016670ca7..49c078580 100644
--- a/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingEventHandler.java
+++ b/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingEventHandler.java
@@ -8,6 +8,7 @@
import org.springframework.stereotype.Component;
import java.time.LocalDate;
+import java.time.ZoneId;
/**
* 랭킹 이벤트 핸들러.
@@ -42,7 +43,7 @@ public void handleLikeAdded(LikeEvent.LikeAdded event) {
log.debug("좋아요 추가 이벤트 처리: productId={}, userId={}",
event.productId(), event.userId());
- LocalDate date = LocalDate.now();
+ LocalDate date = LocalDate.now(ZoneId.of("UTC"));
rankingService.addLikeScore(event.productId(), date, true);
log.debug("좋아요 점수 추가 완료: productId={}", event.productId());
@@ -57,7 +58,7 @@ public void handleLikeRemoved(LikeEvent.LikeRemoved event) {
log.debug("좋아요 취소 이벤트 처리: productId={}, userId={}",
event.productId(), event.userId());
- LocalDate date = LocalDate.now();
+ LocalDate date = LocalDate.now(ZoneId.of("UTC"));
rankingService.addLikeScore(event.productId(), date, false);
log.debug("좋아요 점수 차감 완료: productId={}", event.productId());
@@ -79,7 +80,7 @@ public void handleLikeRemoved(LikeEvent.LikeRemoved event) {
public void handleOrderCreated(OrderEvent.OrderCreated event) {
log.debug("주문 생성 이벤트 처리: orderId={}", event.orderId());
- LocalDate date = LocalDate.now();
+ LocalDate date = LocalDate.now(ZoneId.of("UTC"));
// 주문 아이템별로 점수 집계
// 주의: OrderEvent.OrderCreated에는 개별 상품 가격 정보가 없으므로
@@ -108,7 +109,7 @@ public void handleOrderCreated(OrderEvent.OrderCreated event) {
public void handleProductViewed(ProductEvent.ProductViewed event) {
log.debug("상품 조회 이벤트 처리: productId={}", event.productId());
- LocalDate date = LocalDate.now();
+ LocalDate date = LocalDate.now(ZoneId.of("UTC"));
rankingService.addViewScore(event.productId(), date);
log.debug("조회 점수 추가 완료: productId={}", event.productId());
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
index f87a52422..583b8b7d7 100644
--- 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
@@ -3,7 +3,6 @@
import org.springframework.stereotype.Component;
import java.time.LocalDate;
-import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
/**
@@ -18,9 +17,7 @@
@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");
/**
* 일간 랭킹 키를 생성합니다.
@@ -35,18 +32,4 @@ 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
index dfdf5d57e..f88096f5e 100644
--- 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
@@ -7,9 +7,6 @@
import java.time.Duration;
import java.time.LocalDate;
-import java.time.LocalDateTime;
-import java.util.ArrayList;
-import java.util.List;
import java.util.Map;
/**
@@ -123,36 +120,6 @@ public void addScoresBatch(Map scoreMap, LocalDate date) {
log.debug("배치 점수 적재 완료: date={}, count={}", date, scoreMap.size());
}
- /**
- * 시간 단위 랭킹을 일간 랭킹으로 집계합니다.
- *
- * 하루의 모든 시간 단위 랭킹을 ZUNIONSTORE로 합쳐서 일간 랭킹을 생성합니다.
- *
- *
- * @param date 날짜
- * @return 집계된 멤버 수
- */
- public Long aggregateHourlyToDaily(LocalDate date) {
- String dailyKey = keyGenerator.generateDailyKey(date);
- List hourlyKeys = new ArrayList<>();
-
- // 해당 날짜의 모든 시간 단위 키 생성 (00시 ~ 23시)
- for (int hour = 0; hour < 24; hour++) {
- LocalDateTime dateTime = date.atTime(hour, 0);
- String hourlyKey = keyGenerator.generateHourlyKey(dateTime);
- hourlyKeys.add(hourlyKey);
- }
-
- // ZUNIONSTORE로 모든 시간 단위 랭킹을 일간 랭킹으로 집계
- Long result = zSetTemplate.unionStore(dailyKey, hourlyKeys);
-
- // TTL 설정
- zSetTemplate.setTtlIfNotExists(dailyKey, TTL);
-
- log.info("시간 단위 랭킹을 일간 랭킹으로 집계 완료: date={}, memberCount={}", date, result);
- return result;
- }
-
/**
* Score Carry-Over: 오늘의 랭킹을 내일 랭킹에 일부 반영합니다.
*
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/RankingCarryOverScheduler.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/RankingCarryOverScheduler.java
new file mode 100644
index 000000000..c23a29d4c
--- /dev/null
+++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/RankingCarryOverScheduler.java
@@ -0,0 +1,71 @@
+package com.loopers.infrastructure.scheduler;
+
+import com.loopers.application.ranking.RankingService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDate;
+import java.time.ZoneId;
+
+/**
+ * 랭킹 Score Carry-Over 스케줄러.
+ *
+ * 매일 자정에 전날 랭킹을 오늘 랭킹에 일부 반영하여 콜드 스타트 문제를 완화합니다.
+ *
+ *
+ * 설계 원칙:
+ *
+ * - 콜드 스타트 완화: 매일 자정에 랭킹이 0점에서 시작하는 문제를 완화
+ * - 가중치 적용: 전날 랭킹의 일부(예: 10%)만 반영하여 신선도 유지
+ * - 에러 처리: Carry-Over 실패 시에도 다음 스케줄에서 재시도
+ *
+ *
+ *
+ * 실행 시점:
+ *
+ * - 매일 자정(00:00:00)에 실행
+ * - 전날(어제) 랭킹을 오늘 랭킹에 반영
+ *
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class RankingCarryOverScheduler {
+
+ private static final double DEFAULT_CARRY_OVER_WEIGHT = 0.1; // 10%
+
+ private final RankingService rankingService;
+
+ /**
+ * 전날 랭킹을 오늘 랭킹에 일부 반영합니다.
+ *
+ * 매일 자정에 실행되어 어제 랭킹의 일부를 오늘 랭킹에 반영합니다.
+ *
+ */
+ @Scheduled(cron = "0 0 0 * * ?") // 매일 자정 (00:00:00)
+ public void carryOverScore() {
+ LocalDate today = LocalDate.now(ZoneId.of("UTC"));
+ LocalDate yesterday = today.minusDays(1);
+
+ try {
+ Long memberCount = rankingService.carryOverScore(yesterday, today, DEFAULT_CARRY_OVER_WEIGHT);
+
+ log.info("랭킹 Score Carry-Over 완료: yesterday={}, today={}, weight={}, memberCount={}",
+ yesterday, today, DEFAULT_CARRY_OVER_WEIGHT, memberCount);
+ } catch (org.springframework.dao.DataAccessException e) {
+ log.warn("Redis 장애로 인한 랭킹 Score Carry-Over 실패: yesterday={}, today={}, error={}",
+ yesterday, today, e.getMessage());
+ // Redis 장애 시 Carry-Over 스킵 (다음 스케줄에서 재시도)
+ } catch (Exception e) {
+ log.warn("랭킹 Score Carry-Over 실패: yesterday={}, today={}", yesterday, today, e);
+ // Carry-Over 실패는 다음 스케줄에서 재시도
+ }
+ }
+}
+
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
index cfe6fd9b7..8c19d687d 100644
--- 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
@@ -66,26 +66,43 @@ public class RankingConsumer {
private static final String VERSION_HEADER = "version";
/**
- * like-events 토픽을 구독하여 좋아요 점수를 집계합니다.
+ * 개별 레코드 처리 로직을 정의하는 함수형 인터페이스.
+ */
+ @FunctionalInterface
+ private interface RecordProcessor {
+ /**
+ * 개별 레코드를 처리합니다.
+ *
+ * @param record Kafka 메시지 레코드
+ * @param eventId 이벤트 ID
+ * @return 처리된 이벤트 타입과 토픽 이름을 담은 EventProcessResult
+ * @throws Exception 처리 중 발생한 예외
+ */
+ EventProcessResult process(ConsumerRecord record, String eventId) throws Exception;
+ }
+
+ /**
+ * 이벤트 처리 결과를 담는 레코드.
+ */
+ private record EventProcessResult(String eventType, String topicName) {
+ }
+
+ /**
+ * 공통 배치 처리 로직을 실행합니다.
*
- * 멱등성 처리:
- *
- * - Kafka 메시지 헤더에서 `eventId`를 추출
- * - 이미 처리된 이벤트는 스킵하여 중복 처리 방지
- * - 처리 후 `event_handled` 테이블에 기록
- *
+ * 멱등성 체크, 에러 처리, 배치 커밋 등의 공통 로직을 처리합니다.
*
*
* @param records Kafka 메시지 레코드 목록
* @param acknowledgment 수동 커밋을 위한 Acknowledgment
+ * @param topicName 토픽 이름 (로깅 및 이벤트 기록용)
+ * @param processor 개별 레코드 처리 로직
*/
- @KafkaListener(
- topics = "like-events",
- containerFactory = KafkaConfig.BATCH_LISTENER
- )
- public void consumeLikeEvents(
+ private void processBatch(
List> records,
- Acknowledgment acknowledgment
+ Acknowledgment acknowledgment,
+ String topicName,
+ RecordProcessor processor
) {
try {
for (ConsumerRecord record : records) {
@@ -103,62 +120,34 @@ public void consumeLikeEvents(
continue;
}
- Object value = record.value();
- String eventType;
+ // 개별 레코드 처리
+ EventProcessResult result = processor.process(record, eventId);
- // Spring Kafka가 자동으로 역직렬화한 경우
- if (value instanceof LikeEvent.LikeAdded) {
- LikeEvent.LikeAdded event = (LikeEvent.LikeAdded) value;
- // Spring ApplicationEvent 발행 (애플리케이션 내부 이벤트)
- applicationEventPublisher.publishEvent(event);
- eventType = "LikeAdded";
- } else if (value instanceof LikeEvent.LikeRemoved) {
- LikeEvent.LikeRemoved event = (LikeEvent.LikeRemoved) value;
- // Spring ApplicationEvent 발행 (애플리케이션 내부 이벤트)
- applicationEventPublisher.publishEvent(event);
- eventType = "LikeRemoved";
- } else {
- // JSON 문자열인 경우 이벤트 타입 헤더로 구분
- String eventTypeHeader = extractEventType(record);
- if ("LikeRemoved".equals(eventTypeHeader)) {
- LikeEvent.LikeRemoved event = parseLikeRemovedEvent(value);
- // Spring ApplicationEvent 발행 (애플리케이션 내부 이벤트)
- applicationEventPublisher.publishEvent(event);
- eventType = "LikeRemoved";
- } else {
- // 기본값은 LikeAdded
- LikeEvent.LikeAdded event = parseLikeEvent(value);
- // Spring ApplicationEvent 발행 (애플리케이션 내부 이벤트)
- applicationEventPublisher.publishEvent(event);
- eventType = "LikeAdded";
- }
- }
-
// 이벤트 처리 기록 저장
- eventHandledService.markAsHandled(eventId, eventType, "like-events");
+ eventHandledService.markAsHandled(eventId, result.eventType(), result.topicName());
} 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);
+ log.error("이벤트 처리 실패: topic={}, offset={}, partition={}",
+ topicName, record.offset(), record.partition(), e);
// 개별 이벤트 처리 실패는 로그만 기록하고 계속 진행
}
}
// 모든 이벤트 처리 완료 후 수동 커밋
acknowledgment.acknowledge();
- log.debug("좋아요 이벤트 처리 완료: count={}", records.size());
+ log.debug("이벤트 처리 완료: topic={}, count={}", topicName, records.size());
} catch (Exception e) {
- log.error("좋아요 이벤트 배치 처리 실패: count={}", records.size(), e);
+ log.error("배치 처리 실패: topic={}, count={}", topicName, records.size(), e);
// 에러 발생 시 커밋하지 않음 (재처리 가능)
throw e;
}
}
/**
- * order-events 토픽을 구독하여 주문 점수를 집계합니다.
+ * like-events 토픽을 구독하여 좋아요 점수를 집계합니다.
*
* 멱등성 처리:
*
@@ -167,12 +156,58 @@ public void consumeLikeEvents(
* - 처리 후 `event_handled` 테이블에 기록
*
*
+ *
+ * @param records Kafka 메시지 레코드 목록
+ * @param acknowledgment 수동 커밋을 위한 Acknowledgment
+ */
+ @KafkaListener(
+ topics = "like-events",
+ containerFactory = KafkaConfig.BATCH_LISTENER
+ )
+ public void consumeLikeEvents(
+ List> records,
+ Acknowledgment acknowledgment
+ ) {
+ processBatch(records, acknowledgment, "like-events", (record, eventId) -> {
+ Object value = record.value();
+ String eventType;
+
+ // Spring Kafka가 자동으로 역직렬화한 경우
+ if (value instanceof LikeEvent.LikeAdded) {
+ LikeEvent.LikeAdded event = (LikeEvent.LikeAdded) value;
+ applicationEventPublisher.publishEvent(event);
+ eventType = "LikeAdded";
+ } else if (value instanceof LikeEvent.LikeRemoved) {
+ LikeEvent.LikeRemoved event = (LikeEvent.LikeRemoved) value;
+ applicationEventPublisher.publishEvent(event);
+ eventType = "LikeRemoved";
+ } else {
+ // JSON 문자열인 경우 이벤트 타입 헤더로 구분
+ String eventTypeHeader = extractEventType(record);
+ if ("LikeRemoved".equals(eventTypeHeader)) {
+ LikeEvent.LikeRemoved event = parseLikeRemovedEvent(value);
+ applicationEventPublisher.publishEvent(event);
+ eventType = "LikeRemoved";
+ } else {
+ // 기본값은 LikeAdded
+ LikeEvent.LikeAdded event = parseLikeEvent(value);
+ applicationEventPublisher.publishEvent(event);
+ eventType = "LikeAdded";
+ }
+ }
+
+ return new EventProcessResult(eventType, "like-events");
+ });
+ }
+
+ /**
+ * order-events 토픽을 구독하여 주문 점수를 집계합니다.
*
- * 주문 금액 계산:
+ * 멱등성 처리:
*
- * - OrderEvent.OrderCreated에는 개별 상품 가격 정보가 없음
- * - subtotal을 totalQuantity로 나눠서 평균 단가를 구하고, 각 아이템의 quantity를 곱함
- * - 향후 개선: 주문 이벤트에 개별 상품 가격 정보 추가
+ * - Kafka 메시지 헤더에서 `eventId`를 추출
+ * - 이미 처리된 이벤트는 스킵하여 중복 처리 방지
+ * - 처리 후 `event_handled` 테이블에 기록
*
*
*
@@ -187,49 +222,15 @@ 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);
-
- // Spring ApplicationEvent 발행 (애플리케이션 내부 이벤트)
- applicationEventPublisher.publishEvent(event);
-
- // 이벤트 처리 기록 저장
- 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);
- // 개별 이벤트 처리 실패는 로그만 기록하고 계속 진행
- }
- }
+ processBatch(records, acknowledgment, "order-events", (record, eventId) -> {
+ Object value = record.value();
+ OrderEvent.OrderCreated event = parseOrderCreatedEvent(value);
- // 모든 이벤트 처리 완료 후 수동 커밋
- acknowledgment.acknowledge();
- log.debug("주문 이벤트 처리 완료: count={}", records.size());
- } catch (Exception e) {
- log.error("주문 이벤트 배치 처리 실패: count={}", records.size(), e);
- // 에러 발생 시 커밋하지 않음 (재처리 가능)
- throw e;
- }
+ // Spring ApplicationEvent 발행 (애플리케이션 내부 이벤트)
+ applicationEventPublisher.publishEvent(event);
+
+ return new EventProcessResult("OrderCreated", "order-events");
+ });
}
/**
@@ -254,49 +255,15 @@ 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);
-
- // Spring ApplicationEvent 발행 (애플리케이션 내부 이벤트)
- applicationEventPublisher.publishEvent(event);
-
- // 이벤트 처리 기록 저장
- 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);
- // 개별 이벤트 처리 실패는 로그만 기록하고 계속 진행
- }
- }
+ processBatch(records, acknowledgment, "product-events", (record, eventId) -> {
+ Object value = record.value();
+ ProductEvent.ProductViewed event = parseProductViewedEvent(value);
- // 모든 이벤트 처리 완료 후 수동 커밋
- acknowledgment.acknowledge();
- log.debug("상품 조회 이벤트 처리 완료: count={}", records.size());
- } catch (Exception e) {
- log.error("상품 조회 이벤트 배치 처리 실패: count={}", records.size(), e);
- // 에러 발생 시 커밋하지 않음 (재처리 가능)
- throw e;
- }
+ // Spring ApplicationEvent 발행 (애플리케이션 내부 이벤트)
+ applicationEventPublisher.publishEvent(event);
+
+ return new EventProcessResult("ProductViewed", "product-events");
+ });
}
/**
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
index 3851331be..ed3e67e23 100644
--- 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
@@ -11,10 +11,7 @@
import java.time.Duration;
import java.time.LocalDate;
-import java.time.LocalDateTime;
-import java.util.ArrayList;
import java.util.HashMap;
-import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
@@ -248,49 +245,6 @@ void accumulatesScoresForSameProduct() {
verify(zSetTemplate, times(3)).setTtlIfNotExists(eq(expectedKey), eq(Duration.ofDays(2)));
}
- @DisplayName("시간 단위 랭킹을 일간 랭킹으로 집계할 수 있다.")
- @Test
- void canAggregateHourlyToDaily() {
- // arrange
- LocalDate date = LocalDate.of(2024, 12, 15);
- String dailyKey = "ranking:all:20241215";
- List expectedHourlyKeys = new ArrayList<>();
-
- // 00시 ~ 23시 키 생성
- for (int hour = 0; hour < 24; hour++) {
- LocalDateTime dateTime = date.atTime(hour, 0);
- String hourlyKey = "ranking:hourly:" + dateTime.format(java.time.format.DateTimeFormatter.ofPattern("yyyyMMddHH"));
- expectedHourlyKeys.add(hourlyKey);
- when(keyGenerator.generateHourlyKey(dateTime)).thenReturn(hourlyKey);
- }
-
- when(keyGenerator.generateDailyKey(date)).thenReturn(dailyKey);
- when(zSetTemplate.unionStore(eq(dailyKey), any(List.class))).thenReturn(100L);
-
- // act
- Long result = rankingService.aggregateHourlyToDaily(date);
-
- // assert
- assertThat(result).isEqualTo(100L);
- verify(keyGenerator).generateDailyKey(date);
-
- // 24개의 시간 단위 키 생성 확인
- for (int hour = 0; hour < 24; hour++) {
- LocalDateTime dateTime = date.atTime(hour, 0);
- verify(keyGenerator).generateHourlyKey(dateTime);
- }
-
- // ZUNIONSTORE 호출 확인
- ArgumentCaptor> keysCaptor = ArgumentCaptor.forClass(List.class);
- verify(zSetTemplate).unionStore(eq(dailyKey), keysCaptor.capture());
- List capturedKeys = keysCaptor.getValue();
- assertThat(capturedKeys).hasSize(24);
- assertThat(capturedKeys).containsAll(expectedHourlyKeys);
-
- // TTL 설정 확인
- verify(zSetTemplate).setTtlIfNotExists(eq(dailyKey), eq(Duration.ofDays(2)));
- }
-
@DisplayName("Score Carry-Over로 오늘 랭킹을 내일 랭킹에 반영할 수 있다.")
@Test
void canCarryOverScore() {
diff --git a/modules/redis/src/main/java/com/loopers/zset/RedisZSetTemplate.java b/modules/redis/src/main/java/com/loopers/zset/RedisZSetTemplate.java
index 1b31f4eac..0b81e46a7 100644
--- a/modules/redis/src/main/java/com/loopers/zset/RedisZSetTemplate.java
+++ b/modules/redis/src/main/java/com/loopers/zset/RedisZSetTemplate.java
@@ -78,14 +78,10 @@ public void setTtlIfNotExists(String key, Duration ttl) {
* @param key ZSET 키
* @param member 멤버
* @return 순위 (0부터 시작, 없으면 null)
+ * @throws org.springframework.dao.DataAccessException Redis 접근 실패 시
*/
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;
- }
+ return redisTemplate.opsForZSet().reverseRank(key, member);
}
/**
@@ -98,25 +94,21 @@ public Long getRank(String key, String member) {
* @param start 시작 인덱스 (0부터 시작)
* @param end 종료 인덱스 (포함)
* @return 멤버와 점수 쌍의 리스트
+ * @throws org.springframework.dao.DataAccessException Redis 접근 실패 시
*/
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);
+ 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;
}
/**
@@ -127,15 +119,11 @@ public List getTopRankings(String key, long start, long end) {
*
* @param key ZSET 키
* @return ZSET 크기 (없으면 0)
+ * @throws org.springframework.dao.DataAccessException Redis 접근 실패 시
*/
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;
- }
+ Long size = redisTemplate.opsForZSet().size(key);
+ return size != null ? size : 0L;
}
/**