diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java index 9e923d79a8..ce94df7600 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java @@ -7,6 +7,9 @@ import com.loopers.domain.product.ProductRepository; import com.loopers.domain.ranking.RankingEntry; import com.loopers.domain.ranking.RankingRepository; +import com.loopers.domain.ranking.mv.MvProductRankMonthly; +import com.loopers.domain.ranking.mv.MvProductRankWeekly; +import com.loopers.domain.ranking.mv.MvRankingRepository; import com.loopers.infrastructure.ranking.RankingCacheStore; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -32,14 +35,19 @@ public class RankingFacade { private final ProductRepository productRepository; private final BrandRepository brandRepository; private final RankingCacheStore rankingCacheStore; + private final MvRankingRepository mvRankingRepository; @Transactional(readOnly = true) - public List getRankings(String date, int page, int size) { - return rankingCacheStore.getRankings(date, page, size) + public List getRankings(String period, String date, int page, int size) { + return rankingCacheStore.getRankings(period, date, page, size) .orElseGet(() -> { - List result = loadRankings(date, page, size); + List result = switch (period) { + case "weekly" -> loadMvRankings(date, page, size, true); + case "monthly" -> loadMvRankings(date, page, size, false); + default -> loadRankings(date, page, size); + }; if (!result.isEmpty()) { - rankingCacheStore.putRankings(date, page, size, result); + rankingCacheStore.putRankings(period, date, page, size, result); } return result; }); @@ -86,6 +94,52 @@ private List loadRankings(String date, int page, int return result; } + private List loadMvRankings(String date, int page, int size, boolean weekly) { + LocalDate aggregatedAt = LocalDate.parse(date, DATE_FORMAT); + + List productIds; + List ranks; + List scores; + + if (weekly) { + var mvRanks = mvRankingRepository.findWeeklyRankings(aggregatedAt, page, size); + productIds = mvRanks.stream().map(MvProductRankWeekly::getProductId).toList(); + ranks = mvRanks.stream().map(MvProductRankWeekly::getRank).toList(); + scores = mvRanks.stream().map(MvProductRankWeekly::getScore).toList(); + } else { + var mvRanks = mvRankingRepository.findMonthlyRankings(aggregatedAt, page, size); + productIds = mvRanks.stream().map(MvProductRankMonthly::getProductId).toList(); + ranks = mvRanks.stream().map(MvProductRankMonthly::getRank).toList(); + scores = mvRanks.stream().map(MvProductRankMonthly::getScore).toList(); + } + + if (productIds.isEmpty()) { + return List.of(); + } + + Map productMap = productRepository.findAllByIds(Set.copyOf(productIds)).stream() + .collect(Collectors.toMap(Product::getId, p -> p)); + + Set brandIds = productMap.values().stream() + .map(Product::getBrandId) + .collect(Collectors.toSet()); + + Map brandNameMap = brandRepository.findAllByIds(brandIds).stream() + .collect(Collectors.toMap(Brand::getId, Brand::getName)); + + List result = new ArrayList<>(); + for (int i = 0; i < productIds.size(); i++) { + Product product = productMap.get(productIds.get(i)); + if (product == null) { + continue; + } + String brandName = brandNameMap.getOrDefault(product.getBrandId(), ""); + ProductDto.ProductInfo productInfo = ProductDto.ProductInfo.of(product, brandName); + result.add(RankingDto.RankingItemInfo.of(ranks.get(i), scores.get(i), productInfo)); + } + return result; + } + public RankingDto.ProductRankInfo getProductRank(Long productId, String date) { String key = RANKING_KEY_PREFIX + date; Long rank = rankingRepository.getRank(key, productId); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MvProductRankMonthly.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MvProductRankMonthly.java new file mode 100644 index 0000000000..a94a7baaab --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MvProductRankMonthly.java @@ -0,0 +1,40 @@ +package com.loopers.domain.ranking.mv; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Entity +@Table(name = "mv_product_rank_monthly") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MvProductRankMonthly { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "score", nullable = false) + private double score; + + @Column(name = "ranking", nullable = false) + private int rank; + + @Column(name = "view_count", nullable = false) + private long viewCount; + + @Column(name = "like_count", nullable = false) + private long likeCount; + + @Column(name = "sales_count", nullable = false) + private long salesCount; + + @Column(name = "aggregated_at", nullable = false) + private LocalDate aggregatedAt; +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MvProductRankWeekly.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MvProductRankWeekly.java new file mode 100644 index 0000000000..3a50577ebc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MvProductRankWeekly.java @@ -0,0 +1,40 @@ +package com.loopers.domain.ranking.mv; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Entity +@Table(name = "mv_product_rank_weekly") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MvProductRankWeekly { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "score", nullable = false) + private double score; + + @Column(name = "ranking", nullable = false) + private int rank; + + @Column(name = "view_count", nullable = false) + private long viewCount; + + @Column(name = "like_count", nullable = false) + private long likeCount; + + @Column(name = "sales_count", nullable = false) + private long salesCount; + + @Column(name = "aggregated_at", nullable = false) + private LocalDate aggregatedAt; +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MvRankingRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MvRankingRepository.java new file mode 100644 index 0000000000..2ad4cb85ca --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MvRankingRepository.java @@ -0,0 +1,11 @@ +package com.loopers.domain.ranking.mv; + +import java.time.LocalDate; +import java.util.List; + +public interface MvRankingRepository { + + List findWeeklyRankings(LocalDate aggregatedAt, int page, int size); + + List findMonthlyRankings(LocalDate aggregatedAt, int page, int size); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingCacheStore.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingCacheStore.java index c325c6e6b0..7c048c2c46 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingCacheStore.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingCacheStore.java @@ -18,32 +18,37 @@ public class RankingCacheStore { private static final String RANKING_CACHE_KEY_PREFIX = "ranking:cache:"; - private static final Duration RANKING_CACHE_TTL = Duration.ofMinutes(5); + private static final Duration DAILY_CACHE_TTL = Duration.ofMinutes(5); + private static final Duration WEEKLY_MONTHLY_CACHE_TTL = Duration.ofHours(1); private final RedisTemplate redisTemplate; private final ObjectMapper objectMapper; - public Optional> getRankings(String date, int page, int size) { + public Optional> getRankings(String period, String date, int page, int size) { try { - String json = redisTemplate.opsForValue().get(cacheKey(date, page, size)); + String json = redisTemplate.opsForValue().get(cacheKey(period, date, page, size)); if (json == null) return Optional.empty(); return Optional.of(objectMapper.readValue(json, new TypeReference<>() {})); } catch (Exception e) { - log.warn("랭킹 캐시 조회 실패 - date:{} page:{}", date, page, e); + log.warn("랭킹 캐시 조회 실패 - period:{} date:{} page:{}", period, date, page, e); return Optional.empty(); } } - public void putRankings(String date, int page, int size, List rankings) { + public void putRankings(String period, String date, int page, int size, List rankings) { try { String json = objectMapper.writeValueAsString(rankings); - redisTemplate.opsForValue().set(cacheKey(date, page, size), json, RANKING_CACHE_TTL); + redisTemplate.opsForValue().set(cacheKey(period, date, page, size), json, ttlFor(period)); } catch (Exception e) { - log.warn("랭킹 캐시 저장 실패 - date:{} page:{}", date, page, e); + log.warn("랭킹 캐시 저장 실패 - period:{} date:{} page:{}", period, date, page, e); } } - private String cacheKey(String date, int page, int size) { - return RANKING_CACHE_KEY_PREFIX + "date=" + date + ":page=" + page + ":size=" + size; + private Duration ttlFor(String period) { + return "daily".equals(period) ? DAILY_CACHE_TTL : WEEKLY_MONTHLY_CACHE_TTL; + } + + private String cacheKey(String period, String date, int page, int size) { + return RANKING_CACHE_KEY_PREFIX + "period=" + period + ":date=" + date + ":page=" + page + ":size=" + size; } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankMonthlyJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankMonthlyJpaRepository.java new file mode 100644 index 0000000000..3592f55cd2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankMonthlyJpaRepository.java @@ -0,0 +1,13 @@ +package com.loopers.infrastructure.ranking.mv; + +import com.loopers.domain.ranking.mv.MvProductRankMonthly; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDate; +import java.util.List; + +public interface MvProductRankMonthlyJpaRepository extends JpaRepository { + + List findByAggregatedAtOrderByRankAsc(LocalDate aggregatedAt, Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankWeeklyJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankWeeklyJpaRepository.java new file mode 100644 index 0000000000..2c384b437d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankWeeklyJpaRepository.java @@ -0,0 +1,13 @@ +package com.loopers.infrastructure.ranking.mv; + +import com.loopers.domain.ranking.mv.MvProductRankWeekly; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDate; +import java.util.List; + +public interface MvProductRankWeeklyJpaRepository extends JpaRepository { + + List findByAggregatedAtOrderByRankAsc(LocalDate aggregatedAt, Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MvRankingRepositoryAdapter.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MvRankingRepositoryAdapter.java new file mode 100644 index 0000000000..86cfe1d0a4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MvRankingRepositoryAdapter.java @@ -0,0 +1,29 @@ +package com.loopers.infrastructure.ranking.mv; + +import com.loopers.domain.ranking.mv.MvProductRankMonthly; +import com.loopers.domain.ranking.mv.MvProductRankWeekly; +import com.loopers.domain.ranking.mv.MvRankingRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class MvRankingRepositoryAdapter implements MvRankingRepository { + + private final MvProductRankWeeklyJpaRepository weeklyJpaRepository; + private final MvProductRankMonthlyJpaRepository monthlyJpaRepository; + + @Override + public List findWeeklyRankings(LocalDate aggregatedAt, int page, int size) { + return weeklyJpaRepository.findByAggregatedAtOrderByRankAsc(aggregatedAt, PageRequest.of(page, size)); + } + + @Override + public List findMonthlyRankings(LocalDate aggregatedAt, int page, int size) { + return monthlyJpaRepository.findByAggregatedAtOrderByRankAsc(aggregatedAt, PageRequest.of(page, size)); + } +} 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 e1d8547481..41bd6c659c 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 @@ -23,12 +23,13 @@ public class RankingV1Controller { @GetMapping("/api/v1/rankings") public ApiResponse getRankings( + @RequestParam(defaultValue = "daily") String period, @RequestParam(required = false) String date, @RequestParam(defaultValue = "20") int size, @RequestParam(defaultValue = "0") int page ) { String resolvedDate = (date != null) ? date : todayDate(); - List rankings = rankingFacade.getRankings(resolvedDate, page, size); + List rankings = rankingFacade.getRankings(period, resolvedDate, page, size); return ApiResponse.success(RankingV1Dto.RankingPageResponse.of(rankings, page, size)); } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.java index 8924a27520..918facd9da 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.java @@ -8,6 +8,7 @@ import com.loopers.domain.product.ProductRepository; import com.loopers.domain.ranking.RankingEntry; import com.loopers.domain.ranking.RankingRepository; +import com.loopers.domain.ranking.mv.MvRankingRepository; import com.loopers.infrastructure.ranking.RankingCacheStore; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -30,6 +31,7 @@ class RankingFacadeTest { private ProductRepository productRepository; private BrandRepository brandRepository; private RankingCacheStore rankingCacheStore; + private MvRankingRepository mvRankingRepository; private RankingFacade rankingFacade; private final String todayKey = "ranking:all:" + @@ -41,9 +43,10 @@ void setUp() { productRepository = mock(ProductRepository.class); brandRepository = mock(BrandRepository.class); rankingCacheStore = mock(RankingCacheStore.class); - rankingFacade = new RankingFacade(rankingRepository, productRepository, brandRepository, rankingCacheStore); + mvRankingRepository = mock(MvRankingRepository.class); + rankingFacade = new RankingFacade(rankingRepository, productRepository, brandRepository, rankingCacheStore, mvRankingRepository); - when(rankingCacheStore.getRankings(anyString(), anyInt(), anyInt())).thenReturn(Optional.empty()); + when(rankingCacheStore.getRankings(anyString(), anyString(), anyInt(), anyInt())).thenReturn(Optional.empty()); } @DisplayName("랭킹 페이지 조회 시 상품 정보가 포함된 랭킹 목록을 반환한다") @@ -65,7 +68,7 @@ void getRankings_returnsRankingWithProductInfo() { setId(brand, 1L); when(brandRepository.findAllByIds(anyCollection())).thenReturn(List.of(brand)); - List result = rankingFacade.getRankings(date, 0, 20); + List result = rankingFacade.getRankings("daily", date, 0, 20); assertThat(result).hasSize(2); assertThat(result.get(0).rank()).isEqualTo(1); @@ -80,7 +83,7 @@ void getRankings_returnsRankingWithProductInfo() { void getRankings_emptyZset_returnsEmptyList() { when(rankingRepository.getTopRankings(anyString(), anyLong(), anyLong())).thenReturn(List.of()); - List result = rankingFacade.getRankings("20260405", 0, 20); + List result = rankingFacade.getRankings("daily", "20260405", 0, 20); assertThat(result).isEmpty(); } @@ -117,7 +120,7 @@ void getRankings_page1_calculatesCorrectStart() { String key = "ranking:all:" + date; when(rankingRepository.getTopRankings(eq(key), eq(20L), anyLong())).thenReturn(List.of()); - rankingFacade.getRankings(date, 1, 20); + rankingFacade.getRankings("daily", date, 1, 20); verify(rankingRepository).getTopRankings(eq(key), eq(20L), anyLong()); } @@ -128,9 +131,9 @@ void getRankings_cacheHit_skipsZsetAndDb() { List cached = List.of( new RankingDto.RankingItemInfo(1, 5.0, 10L, "상품A", "브랜드", BigDecimal.valueOf(10000)) ); - when(rankingCacheStore.getRankings("20260405", 0, 20)).thenReturn(Optional.of(cached)); + when(rankingCacheStore.getRankings("daily", "20260405", 0, 20)).thenReturn(Optional.of(cached)); - List result = rankingFacade.getRankings("20260405", 0, 20); + List result = rankingFacade.getRankings("daily", "20260405", 0, 20); assertThat(result).hasSize(1); assertThat(result.get(0).productName()).isEqualTo("상품A"); @@ -153,9 +156,9 @@ void getRankings_cacheMiss_savesToCache() { setId(brand, 1L); when(brandRepository.findAllByIds(anyCollection())).thenReturn(List.of(brand)); - rankingFacade.getRankings(date, 0, 20); + rankingFacade.getRankings("daily", date, 0, 20); - verify(rankingCacheStore).putRankings(eq(date), eq(0), eq(20), anyList()); + verify(rankingCacheStore).putRankings(eq("daily"), eq(date), eq(0), eq(20), anyList()); } private void setId(Object entity, Long id) { diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingE2ETest.java index 9f1d0d4e8b..0c7bbed577 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingE2ETest.java @@ -17,6 +17,7 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.test.web.servlet.MockMvc; import java.math.BigDecimal; @@ -54,6 +55,9 @@ class RankingE2ETest { @Autowired private RedisCleanUp redisCleanUp; + @Autowired + private JdbcTemplate jdbcTemplate; + private static final String TODAY = LocalDate.now(ZoneId.of("Asia/Seoul")) .format(DateTimeFormatter.ofPattern("yyyyMMdd")); private static final String RANKING_KEY = "ranking:all:" + TODAY; @@ -223,4 +227,129 @@ void weightVerification_orderBeatsLikes() throws Exception { .andExpect(jsonPath("$.data.content[1].productId").value(productId1)) .andExpect(jsonPath("$.data.content[1].score").value(0.6)); } + + @DisplayName("GET /api/v1/rankings?period=daily: 기존 일간 랭킹과 동일하게 동작한다") + @Test + void getRankings_dailyPeriod() throws Exception { + mockMvc.perform(get("/api/v1/rankings") + .param("period", "daily") + .param("date", TODAY)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.content.length()").value(3)); + } + + @DisplayName("GET /api/v1/rankings?period=weekly: MV 테이블에서 주간 랭킹을 조회한다") + @Test + void getRankings_weeklyPeriod() throws Exception { + // MV 테이블에 직접 데이터 적재 (배치가 적재한 것으로 시뮬레이션, saturation 기반 점수) + jdbcTemplate.update( + "INSERT INTO mv_product_rank_weekly (product_id, score, ranking, view_count, like_count, sales_count, aggregated_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + productId2, 0.1462, 1, 200, 30, 5, LocalDate.parse(TODAY, DateTimeFormatter.ofPattern("yyyyMMdd")) + ); + jdbcTemplate.update( + "INSERT INTO mv_product_rank_weekly (product_id, score, ranking, view_count, like_count, sales_count, aggregated_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + productId1, 0.1803, 2, 100, 50, 10, LocalDate.parse(TODAY, DateTimeFormatter.ofPattern("yyyyMMdd")) + ); + + mockMvc.perform(get("/api/v1/rankings") + .param("period", "weekly") + .param("date", TODAY)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.content.length()").value(2)) + .andExpect(jsonPath("$.data.content[0].rank").value(1)) + .andExpect(jsonPath("$.data.content[0].productId").value(productId2)) + .andExpect(jsonPath("$.data.content[0].score").value(0.1462)) + .andExpect(jsonPath("$.data.content[0].productName").value("조던1")) + .andExpect(jsonPath("$.data.content[0].brandName").value("NIKE")) + .andExpect(jsonPath("$.data.content[1].rank").value(2)) + .andExpect(jsonPath("$.data.content[1].productId").value(productId1)); + } + + @DisplayName("GET /api/v1/rankings?period=monthly: MV 테이블에서 월간 랭킹을 조회한다") + @Test + void getRankings_monthlyPeriod() throws Exception { + jdbcTemplate.update( + "INSERT INTO mv_product_rank_monthly (product_id, score, ranking, view_count, like_count, sales_count, aggregated_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + productId3, 0.2917, 1, 300, 100, 20, LocalDate.parse(TODAY, DateTimeFormatter.ofPattern("yyyyMMdd")) + ); + + mockMvc.perform(get("/api/v1/rankings") + .param("period", "monthly") + .param("date", TODAY)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.content.length()").value(1)) + .andExpect(jsonPath("$.data.content[0].rank").value(1)) + .andExpect(jsonPath("$.data.content[0].productId").value(productId3)) + .andExpect(jsonPath("$.data.content[0].score").value(0.2917)); + } + + @DisplayName("GET /api/v1/rankings: period 미입력 시 daily로 동작한다") + @Test + void getRankings_noPeriod_defaultsToDaily() throws Exception { + mockMvc.perform(get("/api/v1/rankings") + .param("date", TODAY)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.content.length()").value(3)); + } + + @DisplayName("주간 랭킹은 캐시에서 조회되며, DB 데이터가 변경되어도 캐시된 결과를 반환한다") + @Test + void getRankings_weekly_usesCache() throws Exception { + jdbcTemplate.update( + "INSERT INTO mv_product_rank_weekly (product_id, score, ranking, view_count, like_count, sales_count, aggregated_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + productId1, 10.0, 1, 100, 50, 10, LocalDate.parse(TODAY, DateTimeFormatter.ofPattern("yyyyMMdd")) + ); + + // 첫 번째 조회 → DB에서 읽고 캐시에 저장 + mockMvc.perform(get("/api/v1/rankings") + .param("period", "weekly") + .param("date", TODAY)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.content.length()").value(1)) + .andExpect(jsonPath("$.data.content[0].productId").value(productId1)); + + // MV 테이블을 다른 데이터로 교체 (캐시 미적용 시 이 결과가 보여야 함) + jdbcTemplate.update("DELETE FROM mv_product_rank_weekly"); + jdbcTemplate.update( + "INSERT INTO mv_product_rank_weekly (product_id, score, ranking, view_count, like_count, sales_count, aggregated_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + productId2, 20.0, 1, 200, 30, 5, LocalDate.parse(TODAY, DateTimeFormatter.ofPattern("yyyyMMdd")) + ); + + // 두 번째 조회 → 캐시에서 반환되므로 여전히 productId1이 나와야 함 + mockMvc.perform(get("/api/v1/rankings") + .param("period", "weekly") + .param("date", TODAY)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.content.length()").value(1)) + .andExpect(jsonPath("$.data.content[0].productId").value(productId1)); + } + + @DisplayName("월간 랭킹도 캐시에서 조회된다") + @Test + void getRankings_monthly_usesCache() throws Exception { + jdbcTemplate.update( + "INSERT INTO mv_product_rank_monthly (product_id, score, ranking, view_count, like_count, sales_count, aggregated_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + productId3, 50.0, 1, 300, 100, 20, LocalDate.parse(TODAY, DateTimeFormatter.ofPattern("yyyyMMdd")) + ); + + mockMvc.perform(get("/api/v1/rankings") + .param("period", "monthly") + .param("date", TODAY)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.content[0].productId").value(productId3)); + + jdbcTemplate.update("DELETE FROM mv_product_rank_monthly"); + + // 캐시 적용 시 DB가 비어도 이전 결과 반환 + mockMvc.perform(get("/api/v1/rankings") + .param("period", "monthly") + .param("date", TODAY)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.content.length()").value(1)) + .andExpect(jsonPath("$.data.content[0].productId").value(productId3)); + } } diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingAggregationJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingAggregationJobConfig.java new file mode 100644 index 0000000000..628997fec7 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingAggregationJobConfig.java @@ -0,0 +1,197 @@ +package com.loopers.batch.job.ranking; + +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; +import com.loopers.domain.ranking.MvProductRankMonthly; +import com.loopers.domain.ranking.MvProductRankWeekly; +import com.loopers.domain.ranking.ProductMetricsAggregation; +import com.loopers.infrastructure.ranking.MvProductRankMonthlyJpaRepository; +import com.loopers.infrastructure.ranking.MvProductRankWeeklyJpaRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.ItemWriter; +import org.springframework.batch.item.database.JdbcCursorItemReader; +import org.springframework.batch.item.database.builder.JdbcCursorItemReaderBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +import javax.sql.DataSource; +import java.time.LocalDate; +import java.util.concurrent.atomic.AtomicInteger; + +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = RankingAggregationJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Configuration +public class RankingAggregationJobConfig { + + public static final String JOB_NAME = "rankingAggregationJob"; + private static final int CHUNK_SIZE = 100; + private static final int TOP_N = 100; + + // saturation 상수: ProductMetricsAggregation.SATURATION_K(=100)와 일치해야 한다. + // TODO: 실제 운영 데이터로 튜닝 필요. 현재는 임의값. + private static final double SATURATION_K = 100.0; + + private static final String AGGREGATION_SQL = """ + SELECT pm.product_id, + SUM(pm.view_count) AS view_count, + SUM(pm.like_count) AS like_count, + SUM(pm.sales_count) AS sales_count + FROM product_metrics pm + INNER JOIN products p ON pm.product_id = p.id AND p.deleted_at IS NULL + WHERE pm.metric_date BETWEEN ? AND ? + GROUP BY pm.product_id + ORDER BY ( + GREATEST(SUM(pm.view_count), 0) / (GREATEST(SUM(pm.view_count), 0) + %1$s) * 0.1 + + GREATEST(SUM(pm.like_count), 0) / (GREATEST(SUM(pm.like_count), 0) + %1$s) * 0.2 + + GREATEST(SUM(pm.sales_count), 0) / (GREATEST(SUM(pm.sales_count), 0) + %1$s) * 0.7 + ) DESC + LIMIT %2$s + """.formatted(SATURATION_K, TOP_N); + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final MvProductRankWeeklyJpaRepository weeklyRepository; + private final MvProductRankMonthlyJpaRepository monthlyRepository; + private final DataSource dataSource; + + @Bean(JOB_NAME) + public Job rankingAggregationJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .start(weeklyRankingStep()) + .next(monthlyRankingStep()) + .listener(jobListener) + .build(); + } + + // === Weekly === + + @Bean + public Step weeklyRankingStep() { + return new StepBuilder("weeklyRankingStep", jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(weeklyMetricsReader(null)) + .processor(weeklyProcessor(null)) + .writer(weeklyWriter(null)) + .listener(stepMonitorListener) + .build(); + } + + @Bean + @StepScope + public JdbcCursorItemReader weeklyMetricsReader( + @Value("#{jobParameters['baseDate']}") LocalDate baseDate) { + + if (baseDate == null) { + throw new IllegalArgumentException("baseDate 파라미터가 필요합니다"); + } + + LocalDate fromDate = baseDate.minusDays(6); + return new JdbcCursorItemReaderBuilder() + .name("weeklyMetricsReader") + .dataSource(dataSource) + .sql(AGGREGATION_SQL) + .preparedStatementSetter(ps -> { + ps.setObject(1, fromDate); + ps.setObject(2, baseDate); + }) + .rowMapper((rs, rowNum) -> new ProductMetricsAggregation( + rs.getLong("product_id"), + rs.getLong("view_count"), + rs.getLong("like_count"), + rs.getLong("sales_count") + )) + .build(); + } + + @Bean + @StepScope + public ItemProcessor weeklyProcessor( + @Value("#{jobParameters['baseDate']}") LocalDate baseDate) { + + AtomicInteger rankCounter = new AtomicInteger(1); + return item -> MvProductRankWeekly.of(item, rankCounter.getAndIncrement(), baseDate); + } + + @Bean + @StepScope + public ItemWriter weeklyWriter( + @Value("#{jobParameters['baseDate']}") LocalDate baseDate) { + + return chunk -> { + weeklyRepository.deleteByAggregatedAt(baseDate); + weeklyRepository.saveAll(chunk.getItems()); + }; + } + + // === Monthly === + + @Bean + public Step monthlyRankingStep() { + return new StepBuilder("monthlyRankingStep", jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(monthlyMetricsReader(null)) + .processor(monthlyProcessor(null)) + .writer(monthlyWriter(null)) + .listener(stepMonitorListener) + .build(); + } + + @Bean + @StepScope + public JdbcCursorItemReader monthlyMetricsReader( + @Value("#{jobParameters['baseDate']}") LocalDate baseDate) { + + if (baseDate == null) { + throw new IllegalArgumentException("baseDate 파라미터가 필요합니다"); + } + + LocalDate fromDate = baseDate.minusDays(29); + return new JdbcCursorItemReaderBuilder() + .name("monthlyMetricsReader") + .dataSource(dataSource) + .sql(AGGREGATION_SQL) + .preparedStatementSetter(ps -> { + ps.setObject(1, fromDate); + ps.setObject(2, baseDate); + }) + .rowMapper((rs, rowNum) -> new ProductMetricsAggregation( + rs.getLong("product_id"), + rs.getLong("view_count"), + rs.getLong("like_count"), + rs.getLong("sales_count") + )) + .build(); + } + + @Bean + @StepScope + public ItemProcessor monthlyProcessor( + @Value("#{jobParameters['baseDate']}") LocalDate baseDate) { + + AtomicInteger rankCounter = new AtomicInteger(1); + return item -> MvProductRankMonthly.of(item, rankCounter.getAndIncrement(), baseDate); + } + + @Bean + @StepScope + public ItemWriter monthlyWriter( + @Value("#{jobParameters['baseDate']}") LocalDate baseDate) { + + return chunk -> { + monthlyRepository.deleteByAggregatedAt(baseDate); + monthlyRepository.saveAll(chunk.getItems()); + }; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetrics.java b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetrics.java new file mode 100644 index 0000000000..045f869f6c --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetrics.java @@ -0,0 +1,42 @@ +package com.loopers.domain.metrics; + +import jakarta.persistence.*; +import lombok.Getter; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; + +@Entity +@Table(name = "product_metrics") +@Getter +public class ProductMetrics { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "metric_date", nullable = false) + private LocalDate metricDate; + + @Column(name = "view_count", nullable = false) + private long viewCount; + + @Column(name = "like_count", nullable = false) + private long likeCount; + + @Column(name = "sales_count", nullable = false) + private long salesCount; + + @Column(name = "latest_price", precision = 19, scale = 2) + private BigDecimal latestPrice; + + @Column(name = "price_updated_at") + private Instant priceUpdatedAt; + + protected ProductMetrics() { + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-batch/src/main/java/com/loopers/domain/product/Product.java new file mode 100644 index 0000000000..3504ef0f14 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/product/Product.java @@ -0,0 +1,45 @@ +package com.loopers.domain.product; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import lombok.Getter; + +import java.math.BigDecimal; + +/** + * 배치 모듈용 Product 읽기 전용 엔티티. + * 배치 집계 SQL에서 deleted_at 필터링을 위해 테이블 스키마가 필요하다. + */ +@Entity +@Table(name = "products") +@Getter +public class Product extends BaseEntity { + + @Column(name = "brand_id", nullable = false) + private Long brandId; + + @Column(name = "name", nullable = false, length = 200) + private String name; + + @Column(name = "description") + private String description; + + @Column(name = "price", nullable = false, precision = 19, scale = 2) + private BigDecimal price; + + @Column(name = "stock", nullable = false) + private int stock; + + @Column(name = "like_count", nullable = false) + private int likeCount; + + @Version + @Column(name = "version", nullable = false) + private Long version; + + protected Product() { + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRank.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRank.java new file mode 100644 index 0000000000..78c267b672 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRank.java @@ -0,0 +1,51 @@ +package com.loopers.domain.ranking; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@MappedSuperclass +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public abstract class MvProductRank { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "score", nullable = false) + private double score; + + @Column(name = "ranking", nullable = false) + private int rank; + + @Column(name = "view_count", nullable = false) + private long viewCount; + + @Column(name = "like_count", nullable = false) + private long likeCount; + + @Column(name = "sales_count", nullable = false) + private long salesCount; + + @Column(name = "aggregated_at", nullable = false) + private LocalDate aggregatedAt; + + protected MvProductRank(Long productId, double score, int rank, + long viewCount, long likeCount, long salesCount, + LocalDate aggregatedAt) { + this.productId = productId; + this.score = score; + this.rank = rank; + this.viewCount = viewCount; + this.likeCount = likeCount; + this.salesCount = salesCount; + this.aggregatedAt = aggregatedAt; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankMonthly.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankMonthly.java new file mode 100644 index 0000000000..f8f378719a --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankMonthly.java @@ -0,0 +1,35 @@ +package com.loopers.domain.ranking; + +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Entity +@Table(name = "mv_product_rank_monthly", uniqueConstraints = { + @UniqueConstraint(name = "uk_product_aggregated", columnNames = {"product_id", "aggregated_at"}) +}) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MvProductRankMonthly extends MvProductRank { + + public MvProductRankMonthly(Long productId, double score, int rank, + long viewCount, long likeCount, long salesCount, + LocalDate aggregatedAt) { + super(productId, score, rank, viewCount, likeCount, salesCount, aggregatedAt); + } + + public static MvProductRankMonthly of(ProductMetricsAggregation aggregation, int rank, LocalDate aggregatedAt) { + return new MvProductRankMonthly( + aggregation.getProductId(), + aggregation.calculateScore(), + rank, + aggregation.getViewCount(), + aggregation.getLikeCount(), + aggregation.getSalesCount(), + aggregatedAt + ); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankWeekly.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankWeekly.java new file mode 100644 index 0000000000..3ddd9cf84c --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankWeekly.java @@ -0,0 +1,35 @@ +package com.loopers.domain.ranking; + +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Entity +@Table(name = "mv_product_rank_weekly", uniqueConstraints = { + @UniqueConstraint(name = "uk_product_aggregated", columnNames = {"product_id", "aggregated_at"}) +}) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MvProductRankWeekly extends MvProductRank { + + public MvProductRankWeekly(Long productId, double score, int rank, + long viewCount, long likeCount, long salesCount, + LocalDate aggregatedAt) { + super(productId, score, rank, viewCount, likeCount, salesCount, aggregatedAt); + } + + public static MvProductRankWeekly of(ProductMetricsAggregation aggregation, int rank, LocalDate aggregatedAt) { + return new MvProductRankWeekly( + aggregation.getProductId(), + aggregation.calculateScore(), + rank, + aggregation.getViewCount(), + aggregation.getLikeCount(), + aggregation.getSalesCount(), + aggregatedAt + ); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductMetricsAggregation.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductMetricsAggregation.java new file mode 100644 index 0000000000..8beb40838c --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductMetricsAggregation.java @@ -0,0 +1,44 @@ +package com.loopers.domain.ranking; + +import lombok.Getter; + +@Getter +public class ProductMetricsAggregation { + + private static final double WEIGHT_VIEW = 0.1; + private static final double WEIGHT_LIKE = 0.2; + private static final double WEIGHT_SALES = 0.7; + + // Saturation 상수 (x/(x+k) 수식의 k). + // TODO: 실제 운영 데이터 분포(중앙값, 상위 TOP N의 평균)를 바탕으로 지표별로 튜닝 필요. + // 현재는 첫 도입 단계라 임의값 100으로 통일. + private static final double SATURATION_K = 100.0; + + private final Long productId; + private final long viewCount; + private final long likeCount; + private final long salesCount; + + public ProductMetricsAggregation(Long productId, long viewCount, long likeCount, long salesCount) { + this.productId = productId; + this.viewCount = viewCount; + this.likeCount = likeCount; + this.salesCount = salesCount; + } + + public double calculateScore() { + return saturate(viewCount) * WEIGHT_VIEW + + saturate(likeCount) * WEIGHT_LIKE + + saturate(salesCount) * WEIGHT_SALES; + } + + /** + * Saturation 함수 x/(x+k). + * 큰 값일수록 1에 수렴하여 이상치가 점수를 지배하지 못하도록 한다. + * 음수는 0으로 간주한다. + */ + private double saturate(long count) { + if (count <= 0) return 0.0; + return (double) count / (count + SATURATION_K); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java new file mode 100644 index 0000000000..2e00bc9ad9 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java @@ -0,0 +1,19 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.MvProductRankMonthly; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDate; +import java.util.List; + +public interface MvProductRankMonthlyJpaRepository extends JpaRepository { + + List findByAggregatedAtOrderByRankAsc(LocalDate aggregatedAt); + + @Modifying + @Query("DELETE FROM MvProductRankMonthly m WHERE m.aggregatedAt = :aggregatedAt") + void deleteByAggregatedAt(@Param("aggregatedAt") LocalDate aggregatedAt); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java new file mode 100644 index 0000000000..b2e619aade --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java @@ -0,0 +1,19 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.MvProductRankWeekly; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDate; +import java.util.List; + +public interface MvProductRankWeeklyJpaRepository extends JpaRepository { + + List findByAggregatedAtOrderByRankAsc(LocalDate aggregatedAt); + + @Modifying + @Query("DELETE FROM MvProductRankWeekly m WHERE m.aggregatedAt = :aggregatedAt") + void deleteByAggregatedAt(@Param("aggregatedAt") LocalDate aggregatedAt); +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/domain/ranking/ProductMetricsAggregationTest.java b/apps/commerce-batch/src/test/java/com/loopers/domain/ranking/ProductMetricsAggregationTest.java new file mode 100644 index 0000000000..7e3770b543 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/domain/ranking/ProductMetricsAggregationTest.java @@ -0,0 +1,63 @@ +package com.loopers.domain.ranking; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +class ProductMetricsAggregationTest { + + @DisplayName("saturation 기반 점수를 계산한다: (x/(x+k)) × 가중치의 합") + @Test + void calculateScore_saturation() { + var aggregation = new ProductMetricsAggregation(1L, 100, 50, 10); + + // k=100 가정: + // view : 100/(100+100) × 0.1 = 0.05 + // like : 50/(50+100) × 0.2 ≈ 0.0667 + // sales: 10/(10+100) × 0.7 ≈ 0.0636 + // 합: ≈ 0.1803 + assertThat(aggregation.calculateScore()).isCloseTo(0.180303, within(0.0001)); + } + + @DisplayName("모든 카운트가 0이면 점수도 0이다") + @Test + void calculateScore_allZero() { + var aggregation = new ProductMetricsAggregation(1L, 0, 0, 0); + + assertThat(aggregation.calculateScore()).isEqualTo(0.0); + } + + @DisplayName("값이 매우 커져도 점수는 가중치 합(0.1+0.2+0.7=1.0)을 넘지 않는다 (포화 특성)") + @Test + void calculateScore_saturates() { + var aggregation = new ProductMetricsAggregation(1L, 1_000_000, 1_000_000, 1_000_000); + + // 각 항이 거의 1에 수렴 → 총합은 거의 1.0 + assertThat(aggregation.calculateScore()).isLessThan(1.0).isGreaterThan(0.99); + } + + @DisplayName("큰 값 차이가 점수에서는 크게 벌어지지 않는다 (롱테일 변별력)") + @Test + void calculateScore_longTailDiscrimination() { + // 선형이라면 10배 차이지만 saturation에서는 거의 같아야 함 + var popular = new ProductMetricsAggregation(1L, 10_000, 0, 0); + var veryPopular = new ProductMetricsAggregation(2L, 100_000, 0, 0); + + double popularScore = popular.calculateScore(); + double veryPopularScore = veryPopular.calculateScore(); + + // 두 점수 모두 가중치(0.1)에 근접하지만 아주 미세하게 다름 + assertThat(veryPopularScore - popularScore).isLessThan(0.001); + } + + @DisplayName("음수 카운트는 0으로 간주하여 점수에 영향을 주지 않는다") + @Test + void calculateScore_negativeCountTreatedAsZero() { + var aggregation = new ProductMetricsAggregation(1L, 0, -5, 0); + + // 음수는 0으로 처리 → 점수 0 + assertThat(aggregation.calculateScore()).isEqualTo(0.0); + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingAggregationJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingAggregationJobE2ETest.java new file mode 100644 index 0000000000..b281696f3a --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingAggregationJobE2ETest.java @@ -0,0 +1,209 @@ +package com.loopers.job.ranking; + +import com.loopers.batch.job.ranking.RankingAggregationJobConfig; +import com.loopers.infrastructure.ranking.MvProductRankWeeklyJpaRepository; +import com.loopers.infrastructure.ranking.MvProductRankMonthlyJpaRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.ExitStatus; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.test.JobLauncherTestUtils; +import org.springframework.batch.test.context.SpringBatchTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; + +import java.time.LocalDate; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +@SpringBootTest +@SpringBatchTest +@TestPropertySource(properties = "spring.batch.job.name=" + RankingAggregationJobConfig.JOB_NAME) +class RankingAggregationJobE2ETest { + + @Autowired + private JobLauncherTestUtils jobLauncherTestUtils; + + @Autowired + @Qualifier(RankingAggregationJobConfig.JOB_NAME) + private Job job; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private MvProductRankWeeklyJpaRepository weeklyRepository; + + @Autowired + private MvProductRankMonthlyJpaRepository monthlyRepository; + + private static final LocalDate BASE_DATE = LocalDate.of(2026, 4, 13); + private static final AtomicLong RUN_ID = new AtomicLong(1); + + @BeforeEach + void setUp() { + jdbcTemplate.execute("DELETE FROM mv_product_rank_weekly"); + jdbcTemplate.execute("DELETE FROM mv_product_rank_monthly"); + jdbcTemplate.execute("DELETE FROM product_metrics"); + jdbcTemplate.execute("DELETE FROM products"); + + // 기본 상품 데이터 (활성 상태) + for (long id = 1; id <= 4; id++) { + insertProduct(id, false); + } + } + + @DisplayName("baseDate 파라미터 없이 실행하면 Job이 실패한다") + @Test + void failsWithoutBaseDate() throws Exception { + jobLauncherTestUtils.setJob(job); + + var jobExecution = jobLauncherTestUtils.launchJob(); + + assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.FAILED.getExitCode()); + } + + @DisplayName("product_metrics 데이터를 읽어 주간/월간 MV 테이블에 TOP 100을 적재한다") + @Test + void aggregatesWeeklyAndMonthlyRankings() throws Exception { + jobLauncherTestUtils.setJob(job); + + // 최근 7일간 데이터 적재 (saturation k=100 기준 점수) + insertMetrics(1L, BASE_DATE, 100, 50, 10); // score ≈ 0.1803 + insertMetrics(2L, BASE_DATE, 200, 30, 5); // score ≈ 0.1462 + insertMetrics(3L, BASE_DATE.minusDays(3), 50, 20, 3);// score ≈ 0.0870 + // 8일 전 데이터 - 주간에는 미포함, 월간에는 포함 + insertMetrics(4L, BASE_DATE.minusDays(8), 300, 100, 20); // score ≈ 0.2917 + + var jobParameters = new JobParametersBuilder() + .addLocalDate("baseDate", BASE_DATE) + .addLong("run.id", RUN_ID.getAndIncrement()) + .toJobParameters(); + var jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.COMPLETED.getExitCode()); + + // 주간: 상품 1, 2, 3만 포함 (4는 8일 전이라 제외) + // saturation 적용 시 like/sales 비율이 좋은 상품1이 상품2보다 앞선다. + var weeklyRanks = weeklyRepository.findByAggregatedAtOrderByRankAsc(BASE_DATE); + assertThat(weeklyRanks).hasSize(3); + assertThat(weeklyRanks.get(0).getProductId()).isEqualTo(1L); // 0.1803 → 1위 + assertThat(weeklyRanks.get(1).getProductId()).isEqualTo(2L); // 0.1462 → 2위 + assertThat(weeklyRanks.get(2).getProductId()).isEqualTo(3L); // 0.0870 → 3위 + + // 월간: 모든 상품 포함 + var monthlyRanks = monthlyRepository.findByAggregatedAtOrderByRankAsc(BASE_DATE); + assertThat(monthlyRanks).hasSize(4); + assertThat(monthlyRanks.get(0).getProductId()).isEqualTo(4L); // 0.2917 → 1위 + } + + @DisplayName("같은 상품이 여러 날짜에 걸쳐 있으면 합산된다") + @Test + void aggregatesMultipleDaysForSameProduct() throws Exception { + jobLauncherTestUtils.setJob(job); + + // 상품 1: 3일에 걸쳐 분산 + insertMetrics(1L, BASE_DATE, 50, 10, 5); + insertMetrics(1L, BASE_DATE.minusDays(1), 30, 20, 3); + insertMetrics(1L, BASE_DATE.minusDays(2), 20, 10, 2); + // 합계: view=100, like=40, sales=10 + // saturation 점수 (k=100): + // (100/200)*0.1 + (40/140)*0.2 + (10/110)*0.7 ≈ 0.1708 + + var jobParameters = new JobParametersBuilder() + .addLocalDate("baseDate", BASE_DATE) + .addLong("run.id", RUN_ID.getAndIncrement()) + .toJobParameters(); + jobLauncherTestUtils.launchJob(jobParameters); + + var weeklyRanks = weeklyRepository.findByAggregatedAtOrderByRankAsc(BASE_DATE); + assertThat(weeklyRanks).hasSize(1); + assertThat(weeklyRanks.get(0).getScore()).isCloseTo(0.1708, within(0.0001)); + assertThat(weeklyRanks.get(0).getViewCount()).isEqualTo(100); + assertThat(weeklyRanks.get(0).getLikeCount()).isEqualTo(40); + assertThat(weeklyRanks.get(0).getSalesCount()).isEqualTo(10); + } + + @DisplayName("재실행 시 기존 데이터를 교체한다 (멱등성)") + @Test + void idempotentReExecution() throws Exception { + jobLauncherTestUtils.setJob(job); + + insertMetrics(1L, BASE_DATE, 100, 50, 10); + + var jobParameters1 = new JobParametersBuilder() + .addLocalDate("baseDate", BASE_DATE) + .addLong("run.id", RUN_ID.getAndIncrement()) + .toJobParameters(); + jobLauncherTestUtils.launchJob(jobParameters1); + + assertThat(weeklyRepository.findByAggregatedAtOrderByRankAsc(BASE_DATE)).hasSize(1); + + // 데이터 변경 후 재실행 + jdbcTemplate.execute("DELETE FROM product_metrics"); + insertMetrics(1L, BASE_DATE, 200, 100, 20); + insertMetrics(2L, BASE_DATE, 50, 10, 5); + + var jobParameters2 = new JobParametersBuilder() + .addLocalDate("baseDate", BASE_DATE) + .addLong("run.id", RUN_ID.getAndIncrement()) + .toJobParameters(); + jobLauncherTestUtils.launchJob(jobParameters2); + + var weeklyRanks = weeklyRepository.findByAggregatedAtOrderByRankAsc(BASE_DATE); + assertThat(weeklyRanks).hasSize(2); + } + + @DisplayName("soft delete된 상품은 랭킹 집계에서 제외된다") + @Test + void excludesSoftDeletedProducts() throws Exception { + jobLauncherTestUtils.setJob(job); + + // 상품 3을 삭제 상태로 변경 + jdbcTemplate.update("UPDATE products SET deleted_at = '2026-04-01 00:00:00' WHERE id = 3"); + + insertMetrics(1L, BASE_DATE, 100, 50, 10); // saturation score ≈ 0.1803 + insertMetrics(2L, BASE_DATE, 200, 30, 5); // saturation score ≈ 0.1462 + insertMetrics(3L, BASE_DATE, 500, 200, 50); // 최고점이지만 삭제됨 + + var jobParameters = new JobParametersBuilder() + .addLocalDate("baseDate", BASE_DATE) + .addLong("run.id", RUN_ID.getAndIncrement()) + .toJobParameters(); + jobLauncherTestUtils.launchJob(jobParameters); + + var weeklyRanks = weeklyRepository.findByAggregatedAtOrderByRankAsc(BASE_DATE); + assertThat(weeklyRanks).hasSize(2); + assertThat(weeklyRanks.get(0).getProductId()).isEqualTo(1L); // 0.1803 → 1위 + assertThat(weeklyRanks.get(1).getProductId()).isEqualTo(2L); // 0.1462 → 2위 + // 삭제된 상품 3은 제외 + } + + private void insertProduct(Long productId, boolean deleted) { + jdbcTemplate.update( + "INSERT INTO products (id, brand_id, name, price, stock, like_count, version, created_at, updated_at, deleted_at) " + + "VALUES (?, 1, ?, 10000, 100, 0, 0, NOW(), NOW(), ?)", + productId, "상품" + productId, deleted ? java.sql.Timestamp.valueOf("2026-04-01 00:00:00") : null + ); + } + + private void insertMetrics(Long productId, LocalDate metricDate, + long viewCount, long likeCount, long salesCount) { + jdbcTemplate.update( + "INSERT INTO product_metrics (product_id, metric_date, view_count, like_count, sales_count) " + + "VALUES (?, ?, ?, ?, ?) " + + "ON DUPLICATE KEY UPDATE view_count = view_count + ?, like_count = like_count + ?, sales_count = sales_count + ?", + productId, metricDate, viewCount, likeCount, salesCount, + viewCount, likeCount, salesCount + ); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsEventService.java b/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsEventService.java index d63e4269ae..e5fc587768 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsEventService.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsEventService.java @@ -11,6 +11,8 @@ import java.math.BigDecimal; import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; @Slf4j @Service @@ -31,13 +33,15 @@ public void process(String topic, String eventId, String eventType, Long product eventHandledRepository.save(EventHandled.create(topic, eventId)); + LocalDate today = LocalDate.now(ZoneId.of("Asia/Seoul")); + switch (eventType) { - case "PRODUCT_VIEWED" -> productMetricsRepository.upsertViewCount(productId, 1); - case "PRODUCT_LIKED" -> productMetricsRepository.upsertLikeCount(productId, 1); - case "PRODUCT_UNLIKED" -> productMetricsRepository.upsertLikeCount(productId, -1); - case "ORDER_PLACED" -> productMetricsRepository.upsertSalesCount(productId, quantity); - case "ORDER_CANCELLED" -> productMetricsRepository.upsertSalesCount(productId, -quantity); - case "PRODUCT_PRICE_CHANGED" -> productMetricsRepository.upsertPrice(productId, price, occurredAt); + case "PRODUCT_VIEWED" -> productMetricsRepository.upsertViewCount(productId, today, 1); + case "PRODUCT_LIKED" -> productMetricsRepository.upsertLikeCount(productId, today, 1); + case "PRODUCT_UNLIKED" -> productMetricsRepository.upsertLikeCount(productId, today, -1); + case "ORDER_PLACED" -> productMetricsRepository.upsertSalesCount(productId, today, quantity); + case "ORDER_CANCELLED" -> productMetricsRepository.upsertSalesCount(productId, today, -quantity); + case "PRODUCT_PRICE_CHANGED" -> productMetricsRepository.upsertPrice(productId, today, price, occurredAt); default -> log.warn("알 수 없는 이벤트 타입 - eventType: {}", eventType); } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java index 3342800c98..7dc203bcaf 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java @@ -5,9 +5,12 @@ import java.math.BigDecimal; import java.time.Instant; +import java.time.LocalDate; @Entity -@Table(name = "product_metrics") +@Table(name = "product_metrics", uniqueConstraints = { + @UniqueConstraint(name = "uk_product_date", columnNames = {"product_id", "metric_date"}) +}) @Getter public class ProductMetrics { @@ -15,9 +18,12 @@ public class ProductMetrics { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @Column(name = "product_id", nullable = false, unique = true) + @Column(name = "product_id", nullable = false) private Long productId; + @Column(name = "metric_date", nullable = false) + private LocalDate metricDate; + @Column(name = "view_count", nullable = false) private long viewCount; @@ -36,9 +42,10 @@ public class ProductMetrics { protected ProductMetrics() { } - public static ProductMetrics create(Long productId) { + public static ProductMetrics create(Long productId, LocalDate metricDate) { ProductMetrics metrics = new ProductMetrics(); metrics.productId = productId; + metrics.metricDate = metricDate; metrics.viewCount = 0; metrics.likeCount = 0; metrics.salesCount = 0; diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java index 9156c96c54..8075fcc9cb 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java @@ -2,14 +2,15 @@ import java.math.BigDecimal; import java.time.Instant; +import java.time.LocalDate; public interface ProductMetricsRepository { - void upsertViewCount(Long productId, int delta); + void upsertViewCount(Long productId, LocalDate metricDate, int delta); - void upsertLikeCount(Long productId, int delta); + void upsertLikeCount(Long productId, LocalDate metricDate, int delta); - void upsertSalesCount(Long productId, int delta); + void upsertSalesCount(Long productId, LocalDate metricDate, int delta); - void upsertPrice(Long productId, BigDecimal price, Instant occurredAt); + void upsertPrice(Long productId, LocalDate metricDate, BigDecimal price, Instant occurredAt); } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java index eed4e3f4ab..0438767a3e 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java @@ -8,34 +8,38 @@ import java.math.BigDecimal; import java.time.Instant; +import java.time.LocalDate; public interface ProductMetricsJpaRepository extends JpaRepository { @Modifying - @Query(value = "INSERT INTO product_metrics (product_id, view_count, like_count, sales_count) " + - "VALUES (:productId, :delta, 0, 0) " + + @Query(value = "INSERT INTO product_metrics (product_id, metric_date, view_count, like_count, sales_count) " + + "VALUES (:productId, :metricDate, :delta, 0, 0) " + "ON DUPLICATE KEY UPDATE view_count = view_count + :delta", nativeQuery = true) - void upsertViewCount(@Param("productId") Long productId, @Param("delta") int delta); + void upsertViewCount(@Param("productId") Long productId, @Param("metricDate") LocalDate metricDate, + @Param("delta") int delta); @Modifying - @Query(value = "INSERT INTO product_metrics (product_id, view_count, like_count, sales_count) " + - "VALUES (:productId, 0, :delta, 0) " + + @Query(value = "INSERT INTO product_metrics (product_id, metric_date, view_count, like_count, sales_count) " + + "VALUES (:productId, :metricDate, 0, :delta, 0) " + "ON DUPLICATE KEY UPDATE like_count = like_count + :delta", nativeQuery = true) - void upsertLikeCount(@Param("productId") Long productId, @Param("delta") int delta); + void upsertLikeCount(@Param("productId") Long productId, @Param("metricDate") LocalDate metricDate, + @Param("delta") int delta); @Modifying - @Query(value = "INSERT INTO product_metrics (product_id, view_count, like_count, sales_count) " + - "VALUES (:productId, 0, 0, :delta) " + + @Query(value = "INSERT INTO product_metrics (product_id, metric_date, view_count, like_count, sales_count) " + + "VALUES (:productId, :metricDate, 0, 0, :delta) " + "ON DUPLICATE KEY UPDATE sales_count = sales_count + :delta", nativeQuery = true) - void upsertSalesCount(@Param("productId") Long productId, @Param("delta") int delta); + void upsertSalesCount(@Param("productId") Long productId, @Param("metricDate") LocalDate metricDate, + @Param("delta") int delta); @Modifying - @Query(value = "INSERT INTO product_metrics (product_id, view_count, like_count, sales_count, latest_price, price_updated_at) " + - "VALUES (:productId, 0, 0, 0, :price, :occurredAt) " + + @Query(value = "INSERT INTO product_metrics (product_id, metric_date, view_count, like_count, sales_count, latest_price, price_updated_at) " + + "VALUES (:productId, :metricDate, 0, 0, 0, :price, :occurredAt) " + "ON DUPLICATE KEY UPDATE " + "latest_price = IF(:occurredAt > COALESCE(price_updated_at, '1970-01-01'), :price, latest_price), " + "price_updated_at = IF(:occurredAt > COALESCE(price_updated_at, '1970-01-01'), :occurredAt, price_updated_at)", nativeQuery = true) - void upsertPrice(@Param("productId") Long productId, @Param("price") BigDecimal price, - @Param("occurredAt") Instant occurredAt); + void upsertPrice(@Param("productId") Long productId, @Param("metricDate") LocalDate metricDate, + @Param("price") BigDecimal price, @Param("occurredAt") Instant occurredAt); } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryAdapter.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryAdapter.java index 7f3ab4942e..60782fffd1 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryAdapter.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryAdapter.java @@ -6,6 +6,7 @@ import java.math.BigDecimal; import java.time.Instant; +import java.time.LocalDate; @Repository @RequiredArgsConstructor @@ -14,22 +15,22 @@ public class ProductMetricsRepositoryAdapter implements ProductMetricsRepository private final ProductMetricsJpaRepository jpaRepository; @Override - public void upsertViewCount(Long productId, int delta) { - jpaRepository.upsertViewCount(productId, delta); + public void upsertViewCount(Long productId, LocalDate metricDate, int delta) { + jpaRepository.upsertViewCount(productId, metricDate, delta); } @Override - public void upsertLikeCount(Long productId, int delta) { - jpaRepository.upsertLikeCount(productId, delta); + public void upsertLikeCount(Long productId, LocalDate metricDate, int delta) { + jpaRepository.upsertLikeCount(productId, metricDate, delta); } @Override - public void upsertSalesCount(Long productId, int delta) { - jpaRepository.upsertSalesCount(productId, delta); + public void upsertSalesCount(Long productId, LocalDate metricDate, int delta) { + jpaRepository.upsertSalesCount(productId, metricDate, delta); } @Override - public void upsertPrice(Long productId, BigDecimal price, Instant occurredAt) { - jpaRepository.upsertPrice(productId, price, occurredAt); + public void upsertPrice(Long productId, LocalDate metricDate, BigDecimal price, Instant occurredAt) { + jpaRepository.upsertPrice(productId, metricDate, price, occurredAt); } } diff --git a/apps/commerce-streamer/src/test/java/com/loopers/application/metrics/MetricsEventServiceTest.java b/apps/commerce-streamer/src/test/java/com/loopers/application/metrics/MetricsEventServiceTest.java index e5a56f5fc2..7653aaeafa 100644 --- a/apps/commerce-streamer/src/test/java/com/loopers/application/metrics/MetricsEventServiceTest.java +++ b/apps/commerce-streamer/src/test/java/com/loopers/application/metrics/MetricsEventServiceTest.java @@ -10,6 +10,7 @@ import java.math.BigDecimal; import java.time.Instant; +import java.time.LocalDate; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; @@ -39,7 +40,7 @@ void processViewedEvent() { metricsEventService.process("catalog.events.topic-v1", "event-1", "PRODUCT_VIEWED", 10L, 0, null, EVENT_TIME); verify(eventHandledRepository).save(any(EventHandled.class)); - verify(productMetricsRepository).upsertViewCount(10L, 1); + verify(productMetricsRepository).upsertViewCount(eq(10L), any(LocalDate.class), eq(1)); verify(rankingScoreService).updateScore("PRODUCT_VIEWED", 10L, 0); } @@ -51,7 +52,7 @@ void processLikedEvent() { metricsEventService.process("catalog.events.topic-v1", "event-2", "PRODUCT_LIKED", 10L, 0, null, EVENT_TIME); verify(eventHandledRepository).save(any(EventHandled.class)); - verify(productMetricsRepository).upsertLikeCount(10L, 1); + verify(productMetricsRepository).upsertLikeCount(eq(10L), any(LocalDate.class), eq(1)); } @DisplayName("PRODUCT_UNLIKED 이벤트를 처리하면 likeCount를 1 감소시킨다") @@ -62,7 +63,7 @@ void processUnlikedEvent() { metricsEventService.process("catalog.events.topic-v1", "event-3", "PRODUCT_UNLIKED", 10L, 0, null, EVENT_TIME); verify(eventHandledRepository).save(any(EventHandled.class)); - verify(productMetricsRepository).upsertLikeCount(10L, -1); + verify(productMetricsRepository).upsertLikeCount(eq(10L), any(LocalDate.class), eq(-1)); } @DisplayName("ORDER_PLACED 이벤트를 처리하면 salesCount를 quantity만큼 증가시키고 랭킹 점수를 갱신한다") @@ -73,7 +74,7 @@ void processOrderPlacedEvent() { metricsEventService.process("catalog.events.topic-v1", "event-4", "ORDER_PLACED", 10L, 3, null, EVENT_TIME); verify(eventHandledRepository).save(any(EventHandled.class)); - verify(productMetricsRepository).upsertSalesCount(10L, 3); + verify(productMetricsRepository).upsertSalesCount(eq(10L), any(LocalDate.class), eq(3)); verify(rankingScoreService).updateScore("ORDER_PLACED", 10L, 3); } @@ -85,7 +86,7 @@ void processOrderCancelledEvent() { metricsEventService.process("catalog.events.topic-v1", "event-5", "ORDER_CANCELLED", 10L, 2, null, EVENT_TIME); verify(eventHandledRepository).save(any(EventHandled.class)); - verify(productMetricsRepository).upsertSalesCount(10L, -2); + verify(productMetricsRepository).upsertSalesCount(eq(10L), any(LocalDate.class), eq(-2)); } @DisplayName("PRODUCT_PRICE_CHANGED 이벤트를 처리하면 occurredAt 기반으로 가격을 upsert한다") @@ -97,7 +98,7 @@ void processPriceChangedEvent() { metricsEventService.process("catalog.events.topic-v1", "event-6", "PRODUCT_PRICE_CHANGED", 10L, 0, newPrice, EVENT_TIME); verify(eventHandledRepository).save(any(EventHandled.class)); - verify(productMetricsRepository).upsertPrice(10L, newPrice, EVENT_TIME); + verify(productMetricsRepository).upsertPrice(eq(10L), any(LocalDate.class), eq(newPrice), eq(EVENT_TIME)); } @DisplayName("이미 처리된 eventId는 중복 처리하지 않는다") @@ -108,7 +109,7 @@ void processDuplicateEvent_skips() { metricsEventService.process("catalog.events.topic-v1", "event-1", "PRODUCT_VIEWED", 10L, 0, null, EVENT_TIME); verify(eventHandledRepository, never()).save(any()); - verify(productMetricsRepository, never()).upsertViewCount(anyLong(), anyInt()); + verify(productMetricsRepository, never()).upsertViewCount(anyLong(), any(LocalDate.class), anyInt()); verify(rankingScoreService, never()).updateScore(anyString(), anyLong(), anyInt()); } } diff --git a/apps/commerce-streamer/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java b/apps/commerce-streamer/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java index fad3d38a4f..7a2f547357 100644 --- a/apps/commerce-streamer/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java +++ b/apps/commerce-streamer/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java @@ -3,16 +3,20 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import java.time.LocalDate; + import static org.assertj.core.api.Assertions.assertThat; class ProductMetricsTest { - @DisplayName("create: productId로 초기 상태의 ProductMetrics를 생성한다") + @DisplayName("create: productId와 metricDate로 초기 상태의 ProductMetrics를 생성한다") @Test void create() { - ProductMetrics metrics = ProductMetrics.create(10L); + LocalDate date = LocalDate.of(2026, 4, 12); + ProductMetrics metrics = ProductMetrics.create(10L, date); assertThat(metrics.getProductId()).isEqualTo(10L); + assertThat(metrics.getMetricDate()).isEqualTo(date); assertThat(metrics.getViewCount()).isZero(); assertThat(metrics.getLikeCount()).isZero(); assertThat(metrics.getSalesCount()).isZero(); diff --git a/docs/design/02-sequence-diagrams.md b/docs/design/02-sequence-diagrams.md index dad02d8424..cab666efce 100644 --- a/docs/design/02-sequence-diagrams.md +++ b/docs/design/02-sequence-diagrams.md @@ -598,3 +598,124 @@ sequenceDiagram - LDAP 인증 후 전체 주문 페이징 조회 17. `GET /api-admin/v1/orders/{orderId}` - LDAP 인증 후 주문 상세 조회 + +--- + +## 11. 주간/월간 랭킹 배치 Job (Round 10) + +### 목적 +Spring Batch가 `product_metrics`(일별 스냅샷)를 읽어 주간/월간 MV 테이블에 적재하는 흐름을 검증. +Chunk-Oriented Processing의 Reader → Processor → Writer 경계와 트랜잭션 단위를 확인한다. + +### 시퀀스 다이어그램 + +```mermaid +sequenceDiagram + participant Scheduler as 스케줄러 (매일 새벽) + participant Job as RankingAggregationJob + participant WeeklyStep as WeeklyRankingStep + participant MonthlyStep as MonthlyRankingStep + participant Reader as ItemReader + participant Processor as ItemProcessor + participant Writer as ItemWriter + participant DB as Database + + Scheduler->>Job: Job 실행 (파라미터: baseDate) + + Note over Job,DB: === Weekly Ranking Step === + Job->>WeeklyStep: Step 시작 + + Note over WeeklyStep,DB: Chunk 단위 반복 + loop 청크 반복 (chunkSize 단위) + WeeklyStep->>Reader: read() + Reader->>DB: SELECT product_id,
SUM(view_count), SUM(like_count), SUM(sales_count)
FROM product_metrics
WHERE metric_date BETWEEN baseDate-6 AND baseDate
GROUP BY product_id + DB-->>Reader: List + Reader-->>WeeklyStep: 청크 데이터 + + WeeklyStep->>Processor: process() + Processor->>Processor: score = view×0.1 + like×0.2 + sales×0.7
점수순 정렬 → rank 부여
TOP 100 필터 + + WeeklyStep->>Writer: write() + Writer->>DB: DELETE FROM mv_product_rank_weekly
WHERE aggregated_at = baseDate + Writer->>DB: INSERT INTO mv_product_rank_weekly
(product_id, score, rank, ..., aggregated_at) + end + + Note over Job,DB: === Monthly Ranking Step === + Job->>MonthlyStep: Step 시작 + + Note over MonthlyStep,DB: Chunk 단위 반복 + loop 청크 반복 (chunkSize 단위) + MonthlyStep->>Reader: read() + Reader->>DB: SELECT ... FROM product_metrics
WHERE metric_date BETWEEN baseDate-29 AND baseDate
GROUP BY product_id + DB-->>Reader: List + Reader-->>MonthlyStep: 청크 데이터 + + MonthlyStep->>Processor: process() + Processor->>Processor: 점수 계산 → 정렬 → rank 부여 → TOP 100 + + MonthlyStep->>Writer: write() + Writer->>DB: DELETE + INSERT mv_product_rank_monthly + end + + Job-->>Scheduler: Job 완료 +``` + +### 핵심 포인트 +1. **파라미터 기반 실행**: `baseDate`를 Job 파라미터로 받아 멱등성 보장 (같은 날짜 재실행 시 동일 결과) +2. **Step 분리**: Weekly, Monthly를 별도 Step으로 분리하여 독립적 실패/재시도 가능 +3. **DELETE + INSERT 전략**: 기존 데이터를 삭제 후 재적재하여 멱등성 보장 +4. **가중치 일관성**: Redis 일간 랭킹과 동일한 가중치 공식 사용 (view×0.1 + like×0.2 + sales×0.7) +5. **TOP 100 제한**: MV 테이블에는 상위 100개만 적재하여 테이블 크기 제한 + +### 잠재 리스크 +- **배치 실패 시**: 이전 배치 결과가 삭제된 상태에서 INSERT 실패하면 데이터 유실 → DELETE와 INSERT를 같은 트랜잭션으로 묶어야 함 +- **상품 삭제**: 삭제된 상품이 MV에 포함될 수 있음 → Processor에서 삭제 상품 필터링 필요 + +--- + +## 12. 랭킹 API 확장 (Round 10) + +### 목적 +기존 일간 랭킹 API에 주간/월간 조회를 추가할 때, `period` 파라미터에 따라 데이터 소스가 분기되는 흐름을 검증. + +### 시퀀스 다이어그램 + +```mermaid +sequenceDiagram + actor User as 사용자 + participant Controller as RankingController + participant Facade as RankingFacade + participant Redis as Redis ZSET + participant MvRepo as MvRankingRepository + participant DB as Database + + User->>Controller: GET /api/v1/rankings
?period=weekly&date=20260412&size=20&page=0 + Controller->>Facade: getRankings(period, date, size, page) + + alt period = daily + Facade->>Redis: ZREVRANGE ranking:all:20260412 + Redis-->>Facade: List + Facade->>Facade: 상품 정보 조회 + 삭제 상품 필터링 + 캐싱 + else period = weekly + Facade->>MvRepo: findByAggregatedAt(date, pageable) + MvRepo->>DB: SELECT * FROM mv_product_rank_weekly
WHERE aggregated_at = ?
ORDER BY rank
LIMIT ? OFFSET ? + DB-->>MvRepo: List + MvRepo-->>Facade: 페이지 결과 + Facade->>Facade: 상품 정보 조회 (상품명, 브랜드명) + else period = monthly + Facade->>MvRepo: findByAggregatedAt(date, pageable) + MvRepo->>DB: SELECT * FROM mv_product_rank_monthly
WHERE aggregated_at = ?
ORDER BY rank
LIMIT ? OFFSET ? + DB-->>MvRepo: List + MvRepo-->>Facade: 페이지 결과 + Facade->>Facade: 상품 정보 조회 (상품명, 브랜드명) + end + + Facade-->>Controller: RankingPageResponse + Controller-->>User: 200 OK
{content: [...], page, size} +``` + +### 핵심 포인트 +1. **데이터 소스 분기**: daily는 Redis, weekly/monthly는 DB(MV 테이블) +2. **기존 로직 보존**: daily 경로는 Round 9 로직 그대로 유지 +3. **date 파라미터 의미 변화**: daily는 ZSET 키 날짜, weekly/monthly는 MV의 aggregated_at +4. **페이징**: MV 테이블에 rank가 미리 계산되어 있어 ORDER BY rank로 효율적 조회 diff --git a/docs/design/03-class-diagram.md b/docs/design/03-class-diagram.md index 6fe4d518c1..60aa1cbe31 100644 --- a/docs/design/03-class-diagram.md +++ b/docs/design/03-class-diagram.md @@ -522,7 +522,108 @@ classDiagram --- -## 6. 구현 체크리스트 +## 6. 랭킹 집계 배치 모듈 (Round 10, commerce-batch) + +### 목적 +commerce-batch 모듈의 클래스 구조와 의존 방향을 검증. +Spring Batch의 Job → Step → Reader/Processor/Writer 구조가 레이어드 아키텍처와 어떻게 결합되는지 확인한다. + +### 다이어그램 + +```mermaid +classDiagram + %% Batch Job/Step 설정 + class RankingAggregationJobConfig { + -JobRepository jobRepository + -PlatformTransactionManager txManager + +Job rankingAggregationJob() + +Step weeklyRankingStep() + +Step monthlyRankingStep() + } + + %% Reader/Processor/Writer + class ProductMetricsReader { + <> + -ProductMetricsRepository repository + +ProductMetricsAggregation read() + } + + class RankingProcessor { + <> + +MvProductRank process(ProductMetricsAggregation) + } + + class MvRankingWriter { + <> + -MvProductRankRepository repository + +void write(Chunk~MvProductRank~) + } + + %% 도메인 + class ProductMetricsAggregation { + +Long productId + +long viewCount + +long likeCount + +long salesCount + +double calculateScore() + } + + class MvProductRank { + +Long id + +Long productId + +double score + +int rank + +long viewCount + +long likeCount + +long salesCount + +LocalDate aggregatedAt + } + + %% Repository + class ProductMetricsRepository { + <> + +List~ProductMetricsAggregation~ aggregateByDateRange(LocalDate from, LocalDate to) + } + + class MvProductRankRepository { + <> + +void deleteByAggregatedAt(LocalDate aggregatedAt) + +void saveAll(List~MvProductRank~) + } + + %% 의존 관계 + RankingAggregationJobConfig --> ProductMetricsReader + RankingAggregationJobConfig --> RankingProcessor + RankingAggregationJobConfig --> MvRankingWriter + + ProductMetricsReader --> ProductMetricsRepository + MvRankingWriter --> MvProductRankRepository + + ProductMetricsReader ..> ProductMetricsAggregation : produces + RankingProcessor ..> ProductMetricsAggregation : input + RankingProcessor ..> MvProductRank : output + MvRankingWriter ..> MvProductRank : writes + + note for RankingProcessor "가중치: view×0.1 + like×0.2 + sales×0.7\n점수순 정렬 후 rank 부여\nTOP 100 필터링" + note for RankingAggregationJobConfig "파라미터: baseDate\nWeekly: baseDate-6 ~ baseDate\nMonthly: baseDate-29 ~ baseDate" +``` + +### 핵심 포인트 + +1. **Job 구성**: 하나의 Job에 Weekly Step + Monthly Step 순차 실행 +2. **Reader**: `product_metrics` 테이블에서 기간별 GROUP BY 집계 쿼리 +3. **Processor**: 가중치 점수 계산 + 순위 부여 + TOP 100 필터링 +4. **Writer**: 기존 데이터 DELETE 후 INSERT (멱등성) +5. **의존 방향**: JobConfig → Reader/Processor/Writer → Repository (단방향) + +### 잠재 리스크 +- **Processor에서 정렬**: 전체 상품을 메모리에 올려 정렬해야 함 → 상품 수가 극단적으로 많으면 OOM 가능 + - 대안: Reader 단계에서 SQL ORDER BY + LIMIT으로 TOP 100만 읽기 +- **Weekly/Monthly Step 공통화**: Reader/Writer 로직이 거의 동일 → 파라미터(기간)만 다르게 주입하여 재사용 가능 + +--- + +## 7. 구현 체크리스트 ### 도메인 모델 - [ ] Product 엔티티에 `@Version` 추가 diff --git a/docs/design/04-erd.md b/docs/design/04-erd.md index a368ac81f9..6f04ac5d46 100644 --- a/docs/design/04-erd.md +++ b/docs/design/04-erd.md @@ -25,6 +25,9 @@ erDiagram products ||--o{ likes : "receives" products ||--o{ order_items : "referenced by" + products ||--o{ product_metrics : "measured by" + products ||--o{ mv_product_rank_weekly : "ranked in" + products ||--o{ mv_product_rank_monthly : "ranked in" orders ||--|{ order_items : "contains" @@ -85,6 +88,39 @@ erDiagram int quantity "NOT NULL, CHECK > 0" decimal(19,2) subtotal "NOT NULL, CHECK >= 0" } + + product_metrics { + bigint id PK "AUTO_INCREMENT" + bigint product_id "NOT NULL" + date metric_date "NOT NULL" + bigint view_count "NOT NULL, DEFAULT 0" + bigint like_count "NOT NULL, DEFAULT 0" + bigint sales_count "NOT NULL, DEFAULT 0" + decimal(19,2) latest_price "NULL" + datetime price_updated_at "NULL" + } + + mv_product_rank_weekly { + bigint id PK "AUTO_INCREMENT" + bigint product_id "NOT NULL" + double score "NOT NULL" + int rank "NOT NULL" + bigint view_count "NOT NULL" + bigint like_count "NOT NULL" + bigint sales_count "NOT NULL" + date aggregated_at "NOT NULL, 배치 실행 기준일" + } + + mv_product_rank_monthly { + bigint id PK "AUTO_INCREMENT" + bigint product_id "NOT NULL" + double score "NOT NULL" + int rank "NOT NULL" + bigint view_count "NOT NULL" + bigint like_count "NOT NULL" + bigint sales_count "NOT NULL" + date aggregated_at "NOT NULL, 배치 실행 기준일" + } ``` ### 핵심 포인트 @@ -663,6 +699,169 @@ VALUES (1, 'Galaxy S25', 1200000, 100); --- +## 8. 랭킹/집계 테이블 (Round 9~10) + +### 8.1 product_metrics (일별 상품 메트릭) + +이 테이블은 Kafka 이벤트 소비 시 commerce-streamer가 적재하며, commerce-batch가 주간/월간 집계의 원본으로 읽는다. + +```sql +CREATE TABLE product_metrics ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + product_id BIGINT NOT NULL, + metric_date DATE NOT NULL COMMENT '메트릭 기준 날짜', + view_count BIGINT NOT NULL DEFAULT 0, + like_count BIGINT NOT NULL DEFAULT 0, + sales_count BIGINT NOT NULL DEFAULT 0, + latest_price DECIMAL(19, 2) NULL, + price_updated_at DATETIME NULL, + + UNIQUE KEY uk_product_date (product_id, metric_date), + INDEX idx_metric_date (metric_date) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +``` + +**컬럼 설명**: +- `product_id`: 상품 참조 (FK 미적용, streamer에서 적재) +- `metric_date`: 일별 스냅샷 기준 날짜 +- `view_count`, `like_count`, `sales_count`: 해당 날짜의 순증분 (음수 가능) +- `latest_price`: 해당 날짜의 최종 가격 +- `price_updated_at`: 가격 갱신 시각 + +**제약조건**: +- `UNIQUE(product_id, metric_date)`: 상품당 하루에 1행, UPSERT로 갱신 + +**배치 읽기 패턴**: +```sql +-- 주간 집계: 최근 7일 +SELECT product_id, SUM(view_count), SUM(like_count), SUM(sales_count) +FROM product_metrics +WHERE metric_date BETWEEN DATE_SUB(:baseDate, INTERVAL 6 DAY) AND :baseDate +GROUP BY product_id + +-- 월간 집계: 최근 30일 +SELECT product_id, SUM(view_count), SUM(like_count), SUM(sales_count) +FROM product_metrics +WHERE metric_date BETWEEN DATE_SUB(:baseDate, INTERVAL 29 DAY) AND :baseDate +GROUP BY product_id +``` + +--- + +### 8.2 mv_product_rank_weekly (주간 랭킹 MV) + +배치가 매일 갱신하는 조회 전용 Materialized View. 배치 실행 기준일로부터 최근 7일 집계. + +```sql +CREATE TABLE mv_product_rank_weekly ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + product_id BIGINT NOT NULL, + score DOUBLE NOT NULL COMMENT '가중치 합산 점수', + rank INT NOT NULL COMMENT '점수 기준 순위', + view_count BIGINT NOT NULL DEFAULT 0, + like_count BIGINT NOT NULL DEFAULT 0, + sales_count BIGINT NOT NULL DEFAULT 0, + aggregated_at DATE NOT NULL COMMENT '배치 실행 기준일', + + UNIQUE KEY uk_product_aggregated (product_id, aggregated_at), + INDEX idx_aggregated_rank (aggregated_at, rank) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +``` + +--- + +### 8.3 mv_product_rank_monthly (월간 랭킹 MV) + +구조는 weekly와 동일. 최근 30일 집계. + +```sql +CREATE TABLE mv_product_rank_monthly ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + product_id BIGINT NOT NULL, + score DOUBLE NOT NULL COMMENT '가중치 합산 점수', + rank INT NOT NULL COMMENT '점수 기준 순위', + view_count BIGINT NOT NULL DEFAULT 0, + like_count BIGINT NOT NULL DEFAULT 0, + sales_count BIGINT NOT NULL DEFAULT 0, + aggregated_at DATE NOT NULL COMMENT '배치 실행 기준일', + + UNIQUE KEY uk_product_aggregated (product_id, aggregated_at), + INDEX idx_aggregated_rank (aggregated_at, rank) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +``` + +**점수 계산 공식** (일간 Redis ZSET과 동일): +``` +score = view_count × 0.1 + like_count × 0.2 + sales_count × 0.7 +``` + +**설계 의도**: +- MySQL에 MV 기능이 없으므로 **별도 테이블 + 배치 적재** 방식 +- `aggregated_at`으로 배치 실행 기준일 기록, 이전 데이터와 구분 +- `rank`를 미리 계산해 적재하여 API 조회 시 정렬 비용 제거 +- TOP 100만 적재하여 테이블 크기 제한 + +**인덱스 전략**: +- `uk_product_aggregated`: 멱등 적재 보장 (동일 기준일 재실행 시 UPSERT) +- `idx_aggregated_rank`: API 조회 시 `WHERE aggregated_at = ? ORDER BY rank` 최적화 + +--- + +### 8.4 event_handled (이벤트 처리 기록) + +Kafka 이벤트 멱등성 보장을 위한 테이블 (commerce-streamer). + +```sql +CREATE TABLE event_handled ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + topic VARCHAR(255) NOT NULL, + event_id VARCHAR(255) NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + + UNIQUE KEY uk_topic_event (topic, event_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +``` + +--- + +### 8.5 랭킹 시스템 관계도 + +```mermaid +erDiagram + products ||--o{ product_metrics : "일별 메트릭 적재" + product_metrics ||--o{ mv_product_rank_weekly : "배치 집계 (7일)" + product_metrics ||--o{ mv_product_rank_monthly : "배치 집계 (30일)" + + product_metrics { + bigint product_id "UK (product_id, metric_date)" + date metric_date "일별 기준" + bigint view_count "순증분" + bigint like_count "순증분" + bigint sales_count "순증분" + } + + mv_product_rank_weekly { + bigint product_id "UK (product_id, aggregated_at)" + double score "가중치 합산" + int rank "순위" + date aggregated_at "배치 기준일" + } + + mv_product_rank_monthly { + bigint product_id "UK (product_id, aggregated_at)" + double score "가중치 합산" + int rank "순위" + date aggregated_at "배치 기준일" + } +``` + +**핵심 포인트**: +1. `product_metrics`는 streamer가 적재, batch가 읽기 전용으로 사용 +2. MV 테이블은 batch가 적재, API가 읽기 전용으로 사용 +3. 일간 랭킹은 Redis ZSET(`ranking:all:yyyyMMdd`)에서 직접 조회 (MV 미사용) + +--- + ## 9. 백업 및 복구 전략 ### 9.1 백업 우선순위