From 1ffb5b533f14bb6ce634e145b8c7f4562da9b265 Mon Sep 17 00:00:00 2001
From: minor7295
Date: Fri, 26 Dec 2025 02:25:13 +0900
Subject: [PATCH 1/9] =?UTF-8?q?test:=20=EB=9E=AD=ED=82=B9=20=EC=A1=B0?=
=?UTF-8?q?=ED=9A=8C=20=EC=8B=A4=ED=8C=A8=ED=95=A0=20=EB=95=8C=EC=9D=98=20?=
=?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=BD=94=EB=93=9C=20=EC=B6=94?=
=?UTF-8?q?=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../ranking/RankingServiceTest.java | 155 ++++++++++++++++++
1 file changed, 155 insertions(+)
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..e428d8fa1 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
@@ -397,4 +397,159 @@ void canHandleMultipleProductsFromSameBrand() {
// 브랜드는 한 번만 조회됨 (중복 제거)
verify(brandService).getBrands(List.of(brandId));
}
+
+ @DisplayName("Redis 장애 시 전날 랭킹으로 Fallback한다.")
+ @Test
+ void fallbackToYesterdayRanking_whenRedisFails() {
+ // arrange
+ LocalDate date = LocalDate.of(2024, 12, 15);
+ LocalDate yesterday = date.minusDays(1);
+ int page = 0;
+ int size = 20;
+ String todayKey = "ranking:all:20241215";
+ String yesterdayKey = "ranking:all:20241214";
+
+ Long productId = 1L;
+ Long brandId = 10L;
+
+ List yesterdayEntries = 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(todayKey);
+ when(keyGenerator.generateDailyKey(yesterday)).thenReturn(yesterdayKey);
+
+ // 오늘 랭킹 조회 시 예외 발생
+ when(zSetTemplate.getTopRankings(todayKey, 0L, 19L))
+ .thenThrow(new org.springframework.dao.DataAccessException("Redis connection failed") {});
+
+ // 전날 랭킹 조회 성공
+ when(zSetTemplate.getTopRankings(yesterdayKey, 0L, 19L)).thenReturn(yesterdayEntries);
+ when(zSetTemplate.getSize(yesterdayKey)).thenReturn(1L);
+ 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.items().get(0).productDetail().getId()).isEqualTo(productId);
+ verify(zSetTemplate).getTopRankings(todayKey, 0L, 19L);
+ verify(zSetTemplate).getTopRankings(yesterdayKey, 0L, 19L);
+ }
+
+ @DisplayName("Redis 장애 시 전날 랭킹도 없으면 기본 랭킹(좋아요순)으로 Fallback한다.")
+ @Test
+ void fallbackToDefaultRanking_whenRedisAndYesterdayRankingFail() {
+ // arrange
+ LocalDate date = LocalDate.of(2024, 12, 15);
+ LocalDate yesterday = date.minusDays(1);
+ int page = 0;
+ int size = 20;
+ String todayKey = "ranking:all:20241215";
+ String yesterdayKey = "ranking:all:20241214";
+
+ 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(keyGenerator.generateDailyKey(yesterday)).thenReturn(yesterdayKey);
+
+ // 오늘 랭킹 조회 시 예외 발생
+ when(zSetTemplate.getTopRankings(todayKey, 0L, 19L))
+ .thenThrow(new org.springframework.dao.DataAccessException("Redis connection failed") {});
+
+ // 전날 랭킹 조회도 예외 발생
+ when(zSetTemplate.getTopRankings(yesterdayKey, 0L, 19L))
+ .thenThrow(new org.springframework.dao.DataAccessException("Redis connection failed") {});
+
+ // 기본 랭킹(좋아요순) 조회
+ 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(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));
+ }
}
From a07d41ce41daf83044e019d488081abf048f7575 Mon Sep 17 00:00:00 2001
From: minor7295
Date: Fri, 26 Dec 2025 02:26:25 +0900
Subject: [PATCH 2/9] =?UTF-8?q?feat:=20=EB=9E=AD=ED=82=B9=20=EC=A1=B0?=
=?UTF-8?q?=ED=9A=8C=20=EC=8B=A4=ED=8C=A8=EC=8B=9C=20=EC=A0=84=EB=82=A0=20?=
=?UTF-8?q?=ED=98=B9=EC=9D=80=20=EC=A2=8B=EC=95=84=EC=9A=94=20=EC=88=9C=20?=
=?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=EB=A1=9C=20=EC=9D=91=EB=8B=B5?=
=?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=B4=EC=99=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../application/ranking/RankingService.java | 140 +++++++++++++++++-
.../com/loopers/zset/RedisZSetTemplate.java | 44 ++----
2 files changed, 154 insertions(+), 30 deletions(-)
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..cda45a594 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;
@@ -51,6 +50,13 @@ public class RankingService {
*
* ZSET에서 상위 N개를 조회하고, 상품 정보를 Aggregation하여 반환합니다.
*
+ *
+ * Graceful Degradation:
+ *
+ * - Redis 장애 시 전날 랭킹으로 Fallback
+ * - 전날 랭킹도 없으면 기본 랭킹(좋아요순) 제공
+ *
+ *
*
* @param date 날짜 (yyyyMMdd 형식의 문자열 또는 LocalDate)
* @param page 페이지 번호 (0부터 시작)
@@ -59,6 +65,37 @@ 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 시도
+ try {
+ LocalDate yesterday = date.minusDays(1);
+ return getRankingsFromRedis(yesterday, page, size);
+ } catch (DataAccessException fallbackException) {
+ log.warn("전날 랭킹 조회도 실패, 기본 랭킹(좋아요순)으로 Fallback: date={}, error={}",
+ date, fallbackException.getMessage());
+ // 기본 랭킹(좋아요순) 제공
+ return getDefaultRankings(page, size);
+ }
+ } catch (Exception e) {
+ log.error("랭킹 조회 중 예상치 못한 오류 발생, 기본 랭킹으로 Fallback: date={}", date, e);
+ return getDefaultRankings(page, size);
+ }
+ }
+
+ /**
+ * Redis에서 랭킹을 조회합니다.
+ *
+ * @param date 날짜
+ * @param page 페이지 번호
+ * @param size 페이지당 항목 수
+ * @return 랭킹 조회 결과
+ * @throws DataAccessException Redis 접근 실패 시
+ */
+ private 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 +169,80 @@ public RankingsResponse getRankings(LocalDate date, int page, int size) {
return new RankingsResponse(rankingItems, page, size, hasNext);
}
+ /**
+ * 기본 랭킹(좋아요순)을 제공합니다.
+ *
+ * Redis 장애 시 Fallback으로 사용됩니다.
+ *
+ *
+ * @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 +250,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/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;
}
/**
From 4fac3ff3b17f612d637f50a7e8927f2791e248d0 Mon Sep 17 00:00:00 2001
From: minor7295
Date: Fri, 26 Dec 2025 12:35:37 +0900
Subject: [PATCH 3/9] =?UTF-8?q?feat:=20=EB=9E=AD=ED=82=B9=20fallback=20?=
=?UTF-8?q?=EC=A0=84=EB=9E=B5=20=EA=B5=AC=ED=98=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../application/ranking/RankingService.java | 41 ++++---
.../ranking/RankingSnapshotCache.java | 102 ++++++++++++++++++
.../ranking/RankingSnapshotService.java | 101 +++++++++++++++++
.../scheduler/RankingSnapshotScheduler.java | 71 ++++++++++++
4 files changed, 301 insertions(+), 14 deletions(-)
create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingSnapshotCache.java
create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingSnapshotService.java
create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/scheduler/RankingSnapshotScheduler.java
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 cda45a594..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
@@ -17,6 +17,7 @@
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
+import java.util.Optional;
import java.util.stream.Collectors;
/**
@@ -44,6 +45,7 @@ public class RankingService {
private final RankingKeyGenerator keyGenerator;
private final ProductService productService;
private final BrandService brandService;
+ private final RankingSnapshotService rankingSnapshotService;
/**
* 랭킹을 조회합니다 (페이징).
@@ -53,8 +55,8 @@ public class RankingService {
*
* Graceful Degradation:
*
- * - Redis 장애 시 전날 랭킹으로 Fallback
- * - 전날 랭킹도 없으면 기본 랭킹(좋아요순) 제공
+ * - Redis 장애 시 스냅샷으로 Fallback
+ * - 스냅샷도 없으면 기본 랭킹(좋아요순) 제공 (단순 조회, 계산 아님)
*
*
*
@@ -68,18 +70,25 @@ public RankingsResponse getRankings(LocalDate date, int page, int size) {
try {
return getRankingsFromRedis(date, page, size);
} catch (DataAccessException e) {
- log.warn("Redis 랭킹 조회 실패, 전날 랭킹으로 Fallback: date={}, error={}",
+ log.warn("Redis 랭킹 조회 실패, 스냅샷으로 Fallback: date={}, error={}",
date, e.getMessage());
- // 전날 랭킹으로 Fallback 시도
- try {
- LocalDate yesterday = date.minusDays(1);
- return getRankingsFromRedis(yesterday, page, size);
- } catch (DataAccessException fallbackException) {
- log.warn("전날 랭킹 조회도 실패, 기본 랭킹(좋아요순)으로 Fallback: date={}, error={}",
- date, fallbackException.getMessage());
- // 기본 랭킹(좋아요순) 제공
- return getDefaultRankings(page, size);
+ // 스냅샷으로 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);
@@ -88,6 +97,9 @@ public RankingsResponse getRankings(LocalDate date, int page, int size) {
/**
* Redis에서 랭킹을 조회합니다.
+ *
+ * 스케줄러에서 스냅샷 저장 시 호출하기 위해 public으로 제공합니다.
+ *
*
* @param date 날짜
* @param page 페이지 번호
@@ -95,7 +107,7 @@ public RankingsResponse getRankings(LocalDate date, int page, int size) {
* @return 랭킹 조회 결과
* @throws DataAccessException Redis 접근 실패 시
*/
- private RankingsResponse getRankingsFromRedis(LocalDate date, int page, int size) {
+ public RankingsResponse getRankingsFromRedis(LocalDate date, int page, int size) {
String key = keyGenerator.generateDailyKey(date);
long start = (long) page * size;
long end = start + size - 1;
@@ -172,7 +184,8 @@ private RankingsResponse getRankingsFromRedis(LocalDate date, int page, int size
/**
* 기본 랭킹(좋아요순)을 제공합니다.
*
- * Redis 장애 시 Fallback으로 사용됩니다.
+ * 최종 Fallback으로 사용됩니다. 랭킹을 새로 계산하는 것이 아니라
+ * 이미 집계된 좋아요 수를 단순 조회하는 것이므로 DB 부하가 크지 않습니다.
*
*
* @param page 페이지 번호 (0부터 시작)
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingSnapshotCache.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingSnapshotCache.java
new file mode 100644
index 000000000..aca65b6d0
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingSnapshotCache.java
@@ -0,0 +1,102 @@
+package com.loopers.application.ranking;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDate;
+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();
+ 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/application/ranking/RankingSnapshotService.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingSnapshotService.java
new file mode 100644
index 000000000..cf9f1440a
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingSnapshotService.java
@@ -0,0 +1,101 @@
+package com.loopers.application.ranking;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDate;
+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();
+ 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..5fbbf7da0
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/scheduler/RankingSnapshotScheduler.java
@@ -0,0 +1,71 @@
+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;
+
+/**
+ * 랭킹 스냅샷 저장 스케줄러.
+ *
+ * 주기적으로 랭킹 결과를 스냅샷으로 저장하여, 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();
+ 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);
+ // 스냅샷 저장 실패는 다음 스케줄에서 재시도
+ }
+ }
+}
+
From d11121d9412199f6b4222b077ade0fac884f74b8 Mon Sep 17 00:00:00 2001
From: minor7295
Date: Fri, 26 Dec 2025 12:36:24 +0900
Subject: [PATCH 4/9] =?UTF-8?q?test:=20=EB=9E=AD=ED=82=B9=20fallback=20?=
=?UTF-8?q?=EC=A0=84=EB=9E=B5=EC=97=90=20=EB=A7=9E=EC=B6=B0=20=ED=85=8C?=
=?UTF-8?q?=EC=8A=A4=ED=8A=B8=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../ranking/RankingServiceTest.java | 99 +++++++++++++++----
1 file changed, 79 insertions(+), 20 deletions(-)
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 e428d8fa1..473e57588 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,8 +4,10 @@
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.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -39,9 +41,18 @@ class RankingServiceTest {
@Mock
private BrandService brandService;
+ @Mock
+ private RankingSnapshotService rankingSnapshotService;
+
@InjectMocks
private RankingService rankingService;
+ @BeforeEach
+ void setUp() {
+ // 기본적으로 스냅샷은 없음 (Redis가 정상 동작하는 경우)
+ when(rankingSnapshotService.getSnapshot(any())).thenReturn(java.util.Optional.empty());
+ }
+
/**
* Product에 ID를 설정합니다 (리플렉션 사용).
*/
@@ -398,24 +409,66 @@ void canHandleMultipleProductsFromSameBrand() {
verify(brandService).getBrands(List.of(brandId));
}
- @DisplayName("Redis 장애 시 전날 랭킹으로 Fallback한다.")
+ @DisplayName("Redis 장애 시 스냅샷으로 Fallback한다.")
@Test
- void fallbackToYesterdayRanking_whenRedisFails() {
+ void fallbackToSnapshot_whenRedisFails() {
// arrange
LocalDate date = LocalDate.of(2024, 12, 15);
- LocalDate yesterday = date.minusDays(1);
int page = 0;
int size = 20;
String todayKey = "ranking:all:20241215";
- String yesterdayKey = "ranking:all:20241214";
Long productId = 1L;
Long brandId = 10L;
- List yesterdayEntries = 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);
+
+ 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("브랜드");
@@ -423,18 +476,23 @@ void fallbackToYesterdayRanking_whenRedisFails() {
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(keyGenerator.generateDailyKey(yesterday)).thenReturn(yesterdayKey);
// 오늘 랭킹 조회 시 예외 발생
when(zSetTemplate.getTopRankings(todayKey, 0L, 19L))
.thenThrow(new org.springframework.dao.DataAccessException("Redis connection failed") {});
- // 전날 랭킹 조회 성공
- when(zSetTemplate.getTopRankings(yesterdayKey, 0L, 19L)).thenReturn(yesterdayEntries);
- when(zSetTemplate.getSize(yesterdayKey)).thenReturn(1L);
- when(productService.getProducts(List.of(productId))).thenReturn(List.of(product));
- when(brandService.getBrands(List.of(brandId))).thenReturn(List.of(brand));
+ // 오늘 스냅샷 없음, 전날 스냅샷 있음
+ 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);
@@ -443,19 +501,19 @@ void fallbackToYesterdayRanking_whenRedisFails() {
assertThat(result.items()).hasSize(1);
assertThat(result.items().get(0).productDetail().getId()).isEqualTo(productId);
verify(zSetTemplate).getTopRankings(todayKey, 0L, 19L);
- verify(zSetTemplate).getTopRankings(yesterdayKey, 0L, 19L);
+ verify(rankingSnapshotService).getSnapshot(date);
+ verify(rankingSnapshotService).getSnapshot(yesterday);
}
- @DisplayName("Redis 장애 시 전날 랭킹도 없으면 기본 랭킹(좋아요순)으로 Fallback한다.")
+ @DisplayName("Redis 장애 시 스냅샷도 없으면 기본 랭킹(좋아요순)으로 Fallback한다.")
@Test
- void fallbackToDefaultRanking_whenRedisAndYesterdayRankingFail() {
+ 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";
- String yesterdayKey = "ranking:all:20241214";
Long productId = 1L;
Long brandId = 10L;
@@ -468,15 +526,14 @@ void fallbackToDefaultRanking_whenRedisAndYesterdayRankingFail() {
setId(brand, brandId);
when(keyGenerator.generateDailyKey(date)).thenReturn(todayKey);
- when(keyGenerator.generateDailyKey(yesterday)).thenReturn(yesterdayKey);
// 오늘 랭킹 조회 시 예외 발생
when(zSetTemplate.getTopRankings(todayKey, 0L, 19L))
.thenThrow(new org.springframework.dao.DataAccessException("Redis connection failed") {});
- // 전날 랭킹 조회도 예외 발생
- when(zSetTemplate.getTopRankings(yesterdayKey, 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));
@@ -490,6 +547,8 @@ void fallbackToDefaultRanking_whenRedisAndYesterdayRankingFail() {
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);
}
From 336c56085cdc98730e1976599aa04fd26d083b95 Mon Sep 17 00:00:00 2001
From: minor7295
Date: Fri, 26 Dec 2025 13:17:52 +0900
Subject: [PATCH 5/9] =?UTF-8?q?refactor:=20=EC=9D=BC=EC=9E=90=20=EB=8B=A8?=
=?UTF-8?q?=EC=9C=84=20carry=20over=20=EB=8F=84=EC=9E=85=EC=97=90=20?=
=?UTF-8?q?=EB=94=B0=EB=9D=BC=20unionstore=20=EC=A0=9C=EA=B1=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../loopers/CommerceStreamerApplication.java | 2 +
.../ranking/RankingKeyGenerator.java | 17 -----
.../application/ranking/RankingService.java | 33 ---------
.../scheduler/RankingCarryOverScheduler.java | 70 +++++++++++++++++++
.../ranking/RankingServiceTest.java | 46 ------------
5 files changed, 72 insertions(+), 96 deletions(-)
create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/RankingCarryOverScheduler.java
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java b/apps/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java
index ea4b4d15a..eb986acdd 100644
--- a/apps/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java
+++ b/apps/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java
@@ -4,11 +4,13 @@
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
+import org.springframework.scheduling.annotation.EnableScheduling;
import java.util.TimeZone;
@ConfigurationPropertiesScan
@SpringBootApplication
+@EnableScheduling
public class CommerceStreamerApplication {
@PostConstruct
public void started() {
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/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..601838418
--- /dev/null
+++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/RankingCarryOverScheduler.java
@@ -0,0 +1,70 @@
+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;
+
+/**
+ * 랭킹 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();
+ 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/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() {
From 92bab9dbe229dfea3e7364526c1e1556c44ab31a Mon Sep 17 00:00:00 2001
From: minor7295
Date: Fri, 26 Dec 2025 13:33:28 +0900
Subject: [PATCH 6/9] =?UTF-8?q?chore:=20=ED=81=B4=EB=9E=98=EC=8A=A4?=
=?UTF-8?q?=EB=AA=85=EA=B3=BC=20=EB=8F=99=EC=9D=BC=ED=95=98=EA=B2=8C=20?=
=?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=9D=B4=EB=A6=84=20=EB=B3=80=EA=B2=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../ranking/RankingSnapshotCache.java | 102 ------------------
.../ranking/RankingSnapshotService.java | 1 +
2 files changed, 1 insertion(+), 102 deletions(-)
delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingSnapshotCache.java
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingSnapshotCache.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingSnapshotCache.java
deleted file mode 100644
index aca65b6d0..000000000
--- a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingSnapshotCache.java
+++ /dev/null
@@ -1,102 +0,0 @@
-package com.loopers.application.ranking;
-
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.stereotype.Service;
-
-import java.time.LocalDate;
-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();
- 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/application/ranking/RankingSnapshotService.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingSnapshotService.java
index cf9f1440a..aca65b6d0 100644
--- 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
@@ -99,3 +99,4 @@ private void cleanupOldSnapshots() {
});
}
}
+
From 8cb26238a54d1a0190ede10af926bd0853349661 Mon Sep 17 00:00:00 2001
From: minor7295
Date: Fri, 26 Dec 2025 13:36:05 +0900
Subject: [PATCH 7/9] =?UTF-8?q?refactor:=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=EC=97=90?=
=?UTF-8?q?=EC=84=9C=20=EB=A9=B1=EB=93=B1=EC=84=B1=20=EC=B2=B4=ED=81=AC=20?=
=?UTF-8?q?=EB=A1=9C=EC=A7=81,=20=EC=97=90=EB=9F=AC=20=EC=B2=98=EB=A6=AC?=
=?UTF-8?q?=20=EB=A1=9C=EC=A7=81,=20=EB=B0=B0=EC=B9=98=20=EC=BB=A4?=
=?UTF-8?q?=EB=B0=8B=20=EB=A1=9C=EC=A7=81=20=EB=B0=98=EB=B3=B5=20=EC=A0=9C?=
=?UTF-8?q?=EA=B1=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../interfaces/consumer/RankingConsumer.java | 241 ++++++++----------
1 file changed, 104 insertions(+), 137 deletions(-)
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");
+ });
}
/**
From df97fdce1127b1ca40f8e7a779541d4a12a99e87 Mon Sep 17 00:00:00 2001
From: minor7295
Date: Fri, 26 Dec 2025 13:38:32 +0900
Subject: [PATCH 8/9] =?UTF-8?q?refactor:=20=EB=B6=88=ED=95=84=EC=9A=94?=
=?UTF-8?q?=ED=95=9C=20stubbing=20=EC=A0=9C=EA=B1=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../loopers/application/ranking/RankingServiceTest.java | 7 -------
1 file changed, 7 deletions(-)
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 473e57588..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
@@ -7,7 +7,6 @@
import com.loopers.domain.product.ProductDetail;
import com.loopers.zset.RedisZSetTemplate;
import com.loopers.zset.ZSetEntry;
-import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -47,12 +46,6 @@ class RankingServiceTest {
@InjectMocks
private RankingService rankingService;
- @BeforeEach
- void setUp() {
- // 기본적으로 스냅샷은 없음 (Redis가 정상 동작하는 경우)
- when(rankingSnapshotService.getSnapshot(any())).thenReturn(java.util.Optional.empty());
- }
-
/**
* Product에 ID를 설정합니다 (리플렉션 사용).
*/
From 50a3f73a60a7f736aaa596f1ff5e2d3e6b4769c7 Mon Sep 17 00:00:00 2001
From: minor7295
Date: Fri, 26 Dec 2025 13:42:47 +0900
Subject: [PATCH 9/9] =?UTF-8?q?chore:=20=EC=8B=9C=EA=B0=84=EB=8C=80=20?=
=?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../application/ranking/RankingSnapshotService.java | 3 ++-
.../scheduler/RankingSnapshotScheduler.java | 3 ++-
.../interfaces/api/ranking/RankingV1Controller.java | 8 ++++----
.../loopers/application/ranking/RankingEventHandler.java | 9 +++++----
.../scheduler/RankingCarryOverScheduler.java | 3 ++-
5 files changed, 15 insertions(+), 11 deletions(-)
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
index aca65b6d0..c9bd2efab 100644
--- 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
@@ -4,6 +4,7 @@
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;
@@ -81,7 +82,7 @@ private void cleanupOldSnapshots() {
}
// 가장 오래된 스냅샷 제거
- LocalDate today = LocalDate.now();
+ LocalDate today = LocalDate.now(ZoneId.of("UTC"));
LocalDate oldestDate = today.minusDays(MAX_SNAPSHOTS);
snapshotCache.entrySet().removeIf(entry -> {
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
index 5fbbf7da0..3adefd9be 100644
--- 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
@@ -8,6 +8,7 @@
import org.springframework.stereotype.Component;
import java.time.LocalDate;
+import java.time.ZoneId;
/**
* 랭킹 스냅샷 저장 스케줄러.
@@ -50,7 +51,7 @@ public class RankingSnapshotScheduler {
*/
@Scheduled(fixedRate = 3600000) // 1시간마다 (3600000ms = 1시간)
public void saveRankingSnapshot() {
- LocalDate today = LocalDate.now();
+ LocalDate today = LocalDate.now(ZoneId.of("UTC"));
try {
// 상위 100개 랭킹을 스냅샷으로 저장 (대부분의 사용자가 상위 100개 이내만 조회)
// Redis가 정상일 때만 스냅샷 저장 (예외 발생 시 스킵)
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-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/infrastructure/scheduler/RankingCarryOverScheduler.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/RankingCarryOverScheduler.java
index 601838418..c23a29d4c 100644
--- 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
@@ -7,6 +7,7 @@
import org.springframework.stereotype.Component;
import java.time.LocalDate;
+import java.time.ZoneId;
/**
* 랭킹 Score Carry-Over 스케줄러.
@@ -49,7 +50,7 @@ public class RankingCarryOverScheduler {
*/
@Scheduled(cron = "0 0 0 * * ?") // 매일 자정 (00:00:00)
public void carryOverScore() {
- LocalDate today = LocalDate.now();
+ LocalDate today = LocalDate.now(ZoneId.of("UTC"));
LocalDate yesterday = today.minusDays(1);
try {