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: + *

+ *

* * @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; } /**