From a1415fb2b9d95e6dc5c962e64245334d89b0642d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Mon, 13 Apr 2026 12:06:21 +0900 Subject: [PATCH 01/21] =?UTF-8?q?docs=20:=20md=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/10-batch-ranking.md | 183 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 docs/10-batch-ranking.md diff --git a/docs/10-batch-ranking.md b/docs/10-batch-ranking.md new file mode 100644 index 000000000..037ffb64e --- /dev/null +++ b/docs/10-batch-ranking.md @@ -0,0 +1,183 @@ +# 주간/월간 랭킹 시스템 (Spring Batch) + +## 1. 왜 Batch인가 — 실시간 처리의 한계 + +### 문제 + +일간 랭킹은 Redis ZSET에 이벤트가 발생할 때마다 점수를 실시간으로 누적하는 방식으로 구현되어 있다. + +그런데 **주간/월간 랭킹**을 같은 방식으로 처리하면 어떻게 될까? + +- 7일치, 30일치 Redis ZSET 키를 `ZUNIONSTORE`로 합산 → 키가 많아질수록 연산 비용 증가 +- Redis TTL(현재 2일)을 늘리면 메모리 사용량 급증 +- 매 요청마다 합산 연산을 하면 응답 지연 발생 + +주간/월간 랭킹은 **정확성이 중요하고, 수 초 단위의 실시간성은 필요하지 않다.** 하루에 한 번 집계해도 충분한 데이터다. 이런 경우 Spring Batch로 주기적으로 집계해두는 것이 적합하다. + +| 항목 | 실시간 (Redis ZSET) | 배치 (Spring Batch) | +|---|---|---| +| 반영 속도 | 즉시 | 주기적 (1일 1회) | +| 집계 비용 | 이벤트마다 분산 처리 | 한 번에 대량 처리 | +| 적합한 단위 | 일간 | 주간, 월간 | +| 인프라 부담 | 메모리(Redis) | DB I/O | + +--- + +## 2. product_metrics 구조 변경 + +### 문제 + +기존 `product_metrics` 테이블은 날짜 없이 상품별 **전체 누적값**만 저장한다. + +``` +product_metrics +id | product_id | like_count | view_count | sales_count +-- | ---------- | ---------- | ---------- | ----------- +1 | 101 | 150 | 800 | 30 +``` + +배치가 "이번 주 랭킹"을 계산하려면 이번 주에 발생한 이벤트만 집계해야 한다. 그런데 이 테이블로는 불가능하다. + +``` +like_count = 150 +→ 이 중에서 이번 주에 발생한 게 몇 건인지 알 수 없음 +``` + +### 결정: metrics_date 컬럼 추가 + +별도 테이블(`product_metrics_daily`)을 만드는 방법도 있지만, 과제 대상 테이블이 `product_metrics`로 명시되어 있고 변경 범위를 최소화하는 방향으로 **기존 테이블에 날짜 컬럼만 추가**한다. + +``` +product_metrics (변경 후) +id | product_id | metrics_date | like_count | view_count | sales_count +-- | ---------- | ------------ | ---------- | ---------- | ----------- +1 | 101 | 2026-04-07 | 10 | 80 | 3 +2 | 101 | 2026-04-08 | 25 | 120 | 7 +3 | 101 | 2026-04-13 | 30 | 95 | 5 +``` + +- `product_id + metrics_date` 조합이 UNIQUE (기존 `product_id` 단독 UNIQUE 제거) +- 하루에 상품당 레코드 1개 +- 값은 **그날 하루 발생한 증분** + +이렇게 하면 주간 집계가 단순한 날짜 범위 SUM으로 해결된다. + +```sql +SELECT product_id, SUM(like_count + view_count + sales_count) AS score +FROM product_metrics +WHERE metrics_date BETWEEN '2026-04-07' AND '2026-04-13' +GROUP BY product_id +ORDER BY score DESC +LIMIT 100; +``` + +--- + +## 3. Materialized View 설계 + +### 왜 MV인가 + +주간/월간 집계 쿼리를 매 API 요청마다 실행하면 DB 부하가 크다. 배치로 미리 계산한 결과를 별도 테이블에 저장해두고 API는 이 테이블만 읽는다. MySQL은 MV 기능이 없으므로 **별도 테이블 + 배치 적재** 방식으로 구현한다. + +### 테이블 구조 + +```sql +-- 주간 TOP 100 +CREATE TABLE mv_product_rank_weekly ( + product_id BIGINT PRIMARY KEY, + score DOUBLE NOT NULL, + year_week VARCHAR(8) NOT NULL, -- e.g. 20260414 (해당 주 월요일) + rank INT NOT NULL, + updated_at DATETIME NOT NULL +); + +-- 월간 TOP 100 +CREATE TABLE mv_product_rank_monthly ( + product_id BIGINT PRIMARY KEY, + score DOUBLE NOT NULL, + year_month VARCHAR(6) NOT NULL, -- e.g. 202604 + rank INT NOT NULL, + updated_at DATETIME NOT NULL +); +``` + +--- + +## 4. Spring Batch Job 설계 + +### Chunk-Oriented Processing + +`product_metrics`에서 날짜 범위로 읽어 MV 테이블에 적재한다. + +``` +ItemReader → product_metrics에서 해당 기간 데이터 읽기 (chunk 단위) +ItemProcessor → score 계산 (가중치 합산), 순위 부여 +ItemWriter → mv_product_rank_weekly / mv_product_rank_monthly upsert +``` + +### Job 파라미터 + +- `targetDate` : 집계 기준 날짜 (e.g. `20260413`) +- `period` : `weekly` 또는 `monthly` + +``` +targetDate=20260413, period=weekly +→ 2026-04-07 ~ 2026-04-13 기간 집계 +``` + +### 점수 공식 + +일간 랭킹과 동일한 가중치를 사용한다. + +``` +score = 0.1 * view_count + 0.2 * like_count + 0.7 * log1p(total_quantity) +``` + +일간/주간/월간 모두 동일한 기준으로 점수를 산정해야 랭킹 간 일관성이 유지된다. +이를 위해 `product_metrics`에 `total_quantity`(수량 합산)를 추가로 저장한다. + +--- + +## 5. Ranking API 확장 + +기존 API에 `period` 파라미터를 추가한다. + +``` +GET /api/v1/rankings?date=20260413&period=daily&size=20&page=1 → Redis ZSET +GET /api/v1/rankings?date=20260413&period=weekly&size=20&page=1 → mv_product_rank_weekly +GET /api/v1/rankings?date=20260413&period=monthly&size=20&page=1 → mv_product_rank_monthly +``` + +- `period` 기본값은 `daily` (기존 동작 유지) +- `date` 파라미터는 세 가지 모드 모두 기준 날짜로 사용 + +--- + +## 6. 구현 체크리스트 + +### Phase 1. product_metrics 구조 변경 + +- [ ] `metrics_date` 컬럼 추가 (DDL) +- [ ] UNIQUE 제약 변경: `product_id` → `(product_id, metrics_date)` +- [ ] `ProductMetricsEntity` — `metricsDate` 필드 추가 +- [ ] `ProductMetricsJpaRepository` — increment 쿼리에 `metrics_date` 조건 추가 +- [ ] `ProductMetricsProcessor` — `occurredAt.toLocalDate()`를 날짜 기준으로 upsert + +### Phase 2. MV 테이블 생성 + +- [ ] `mv_product_rank_weekly` DDL 작성 +- [ ] `mv_product_rank_monthly` DDL 작성 + +### Phase 3. Spring Batch Job + +- [ ] `RankingWeeklyJobConfig` — Chunk-Oriented Job 구현 +- [ ] `RankingMonthlyJobConfig` — Chunk-Oriented Job 구현 +- [ ] `ProductMetricsItemReader` — 날짜 범위 기반 페이징 Reader +- [ ] `RankingItemProcessor` — score 계산 및 순위 부여 +- [ ] `MvRankingItemWriter` — MV 테이블 upsert Writer + +### Phase 4. Ranking API 확장 + +- [ ] `RankingV1Controller` — `period` 파라미터 추가 +- [ ] `RankingFacade` — period별 분기 처리 +- [ ] `RankingRepository` — 주간/월간 MV 조회 메서드 추가 From 698ce7ea9b2fd2097e8e68a547e2d7288a042bcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Mon, 13 Apr 2026 14:49:31 +0900 Subject: [PATCH 02/21] =?UTF-8?q?refactor=20:=20product=5Fmetrics=20?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=EC=97=90=20=EC=A7=91=EA=B3=84=20?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=EB=82=A0=EC=A7=9C=20+=20=EC=A3=BC=EB=AC=B8?= =?UTF-8?q?=20=EC=88=98=EB=9F=89=20=ED=95=A9=EC=82=B0=20=ED=95=84=EB=93=9C?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/metrics/ProductMetrics.java | 24 ++++++++++++++---- .../metrics/ProductMetricsEntity.java | 25 +++++++++++++++++-- 2 files changed, 42 insertions(+), 7 deletions(-) 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 374c65575..9ef48bd27 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 @@ -1,27 +1,33 @@ package com.loopers.domain.metrics; +import java.time.LocalDate; + public class ProductMetrics { private final Long id; private final Long productId; + private final LocalDate metricsDate; private final int likeCount; private final int viewCount; private final int salesCount; + private final int totalQuantity; - private ProductMetrics(Long id, Long productId, int likeCount, int viewCount, int salesCount) { + private ProductMetrics(Long id, Long productId, LocalDate metricsDate, int likeCount, int viewCount, int salesCount, int totalQuantity) { this.id = id; this.productId = productId; + this.metricsDate = metricsDate; this.likeCount = likeCount; this.viewCount = viewCount; this.salesCount = salesCount; + this.totalQuantity = totalQuantity; } - public static ProductMetrics create(Long productId) { - return new ProductMetrics(null, productId, 0, 0, 0); + public static ProductMetrics create(Long productId, LocalDate metricsDate) { + return new ProductMetrics(null, productId, metricsDate, 0, 0, 0, 0); } - public static ProductMetrics restore(Long id, Long productId, int likeCount, int viewCount, int salesCount) { - return new ProductMetrics(id, productId, likeCount, viewCount, salesCount); + public static ProductMetrics restore(Long id, Long productId, LocalDate metricsDate, int likeCount, int viewCount, int salesCount, int totalQuantity) { + return new ProductMetrics(id, productId, metricsDate, likeCount, viewCount, salesCount, totalQuantity); } public Long getId() { @@ -32,6 +38,10 @@ public Long getProductId() { return productId; } + public LocalDate getMetricsDate() { + return metricsDate; + } + public int getLikeCount() { return likeCount; } @@ -43,4 +53,8 @@ public int getViewCount() { public int getSalesCount() { return salesCount; } + + public int getTotalQuantity() { + return totalQuantity; + } } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsEntity.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsEntity.java index 5e7ee92cc..67ee3fac1 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsEntity.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsEntity.java @@ -6,6 +6,7 @@ import jakarta.persistence.Entity; import jakarta.persistence.Table; import jakarta.persistence.UniqueConstraint; +import java.time.LocalDate; import lombok.NoArgsConstructor; import org.hibernate.annotations.Comment; @@ -13,7 +14,7 @@ @Table( name = "product_metrics", uniqueConstraints = { - @UniqueConstraint(name = "uk_product_metrics_product_id", columnNames = "product_id") + @UniqueConstraint(name = "uk_product_metrics_product_id_date", columnNames = {"product_id", "metrics_date"}) } ) @NoArgsConstructor @@ -23,6 +24,10 @@ public class ProductMetricsEntity extends BaseEntity { @Column(name = "product_id", nullable = false, updatable = false) private Long productId; + @Comment("집계 기준 날짜") + @Column(name = "metrics_date", nullable = false, updatable = false) + private LocalDate metricsDate; + @Comment("좋아요 수 집계") @Column(name = "like_count", nullable = false) private int likeCount; @@ -35,20 +40,28 @@ public class ProductMetricsEntity extends BaseEntity { @Column(name = "sales_count", nullable = false) private int salesCount; + @Comment("주문 수량 합산") + @Column(name = "total_quantity", nullable = false) + private int totalQuantity; + public ProductMetricsEntity(ProductMetrics metrics) { this.productId = metrics.getProductId(); + this.metricsDate = metrics.getMetricsDate(); this.likeCount = metrics.getLikeCount(); this.viewCount = metrics.getViewCount(); this.salesCount = metrics.getSalesCount(); + this.totalQuantity = metrics.getTotalQuantity(); } public static ProductMetrics toDomain(ProductMetricsEntity entity) { return ProductMetrics.restore( entity.getId(), entity.productId, + entity.metricsDate, entity.likeCount, entity.viewCount, - entity.salesCount + entity.salesCount, + entity.totalQuantity ); } @@ -56,6 +69,10 @@ public Long getProductId() { return productId; } + public LocalDate getMetricsDate() { + return metricsDate; + } + public int getLikeCount() { return likeCount; } @@ -67,4 +84,8 @@ public int getViewCount() { public int getSalesCount() { return salesCount; } + + public int getTotalQuantity() { + return totalQuantity; + } } From d15406d0584a2df8c522c1d05b96148a425267b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Mon, 13 Apr 2026 15:00:52 +0900 Subject: [PATCH 03/21] =?UTF-8?q?refactor=20:=20=EC=BF=BC=EB=A6=AC=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20(metricsDate=20=ED=8C=8C=EB=9D=BC=EB=AF=B8?= =?UTF-8?q?=ED=84=B0=20=EC=B6=94=EA=B0=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../metrics/ProductMetricsRepository.java | 13 ++++++---- .../metrics/ProductMetricsJpaRepository.java | 23 +++++++++------- .../metrics/ProductMetricsRepositoryImpl.java | 26 ++++++++++++------- 3 files changed, 38 insertions(+), 24 deletions(-) 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 8b5abed6c..fe3721612 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 @@ -1,18 +1,21 @@ package com.loopers.domain.metrics; +import java.time.LocalDate; import java.util.Optional; public interface ProductMetricsRepository { - Optional findByProductId(Long productId); + Optional findByProductIdAndMetricsDate(Long productId, LocalDate metricsDate); ProductMetrics save(ProductMetrics metrics); - void incrementLikeCount(Long productId); + void incrementLikeCount(Long productId, LocalDate metricsDate); - void decrementLikeCount(Long productId); + void decrementLikeCount(Long productId, LocalDate metricsDate); - void incrementViewCount(Long productId); + void incrementViewCount(Long productId, LocalDate metricsDate); - void incrementSalesCount(Long productId); + void incrementSalesCount(Long productId, LocalDate metricsDate); + + void incrementTotalQuantity(Long productId, LocalDate metricsDate, int quantity); } 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 3c58d339b..6537c3e54 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 @@ -1,5 +1,6 @@ package com.loopers.infrastructure.metrics; +import java.time.LocalDate; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; @@ -8,21 +9,25 @@ public interface ProductMetricsJpaRepository extends JpaRepository { - Optional findByProductId(Long productId); + Optional findByProductIdAndMetricsDate(Long productId, LocalDate metricsDate); @Modifying - @Query("UPDATE ProductMetricsEntity m SET m.likeCount = m.likeCount + 1 WHERE m.productId = :productId") - void incrementLikeCount(@Param("productId") Long productId); + @Query("UPDATE ProductMetricsEntity m SET m.likeCount = m.likeCount + 1 WHERE m.productId = :productId AND m.metricsDate = :metricsDate") + void incrementLikeCount(@Param("productId") Long productId, @Param("metricsDate") LocalDate metricsDate); @Modifying - @Query("UPDATE ProductMetricsEntity m SET m.likeCount = m.likeCount - 1 WHERE m.productId = :productId AND m.likeCount > 0") - void decrementLikeCount(@Param("productId") Long productId); + @Query("UPDATE ProductMetricsEntity m SET m.likeCount = m.likeCount - 1 WHERE m.productId = :productId AND m.metricsDate = :metricsDate AND m.likeCount > 0") + void decrementLikeCount(@Param("productId") Long productId, @Param("metricsDate") LocalDate metricsDate); @Modifying - @Query("UPDATE ProductMetricsEntity m SET m.viewCount = m.viewCount + 1 WHERE m.productId = :productId") - void incrementViewCount(@Param("productId") Long productId); + @Query("UPDATE ProductMetricsEntity m SET m.viewCount = m.viewCount + 1 WHERE m.productId = :productId AND m.metricsDate = :metricsDate") + void incrementViewCount(@Param("productId") Long productId, @Param("metricsDate") LocalDate metricsDate); @Modifying - @Query("UPDATE ProductMetricsEntity m SET m.salesCount = m.salesCount + 1 WHERE m.productId = :productId") - void incrementSalesCount(@Param("productId") Long productId); + @Query("UPDATE ProductMetricsEntity m SET m.salesCount = m.salesCount + 1 WHERE m.productId = :productId AND m.metricsDate = :metricsDate") + void incrementSalesCount(@Param("productId") Long productId, @Param("metricsDate") LocalDate metricsDate); + + @Modifying + @Query("UPDATE ProductMetricsEntity m SET m.totalQuantity = m.totalQuantity + :quantity WHERE m.productId = :productId AND m.metricsDate = :metricsDate") + void incrementTotalQuantity(@Param("productId") Long productId, @Param("metricsDate") LocalDate metricsDate, @Param("quantity") int quantity); } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java index 415ca6935..57d4282de 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java @@ -2,6 +2,7 @@ import com.loopers.domain.metrics.ProductMetrics; import com.loopers.domain.metrics.ProductMetricsRepository; +import java.time.LocalDate; import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -13,8 +14,8 @@ public class ProductMetricsRepositoryImpl implements ProductMetricsRepository { private final ProductMetricsJpaRepository jpaRepository; @Override - public Optional findByProductId(Long productId) { - return jpaRepository.findByProductId(productId) + public Optional findByProductIdAndMetricsDate(Long productId, LocalDate metricsDate) { + return jpaRepository.findByProductIdAndMetricsDate(productId, metricsDate) .map(ProductMetricsEntity::toDomain); } @@ -24,22 +25,27 @@ public ProductMetrics save(ProductMetrics metrics) { } @Override - public void incrementLikeCount(Long productId) { - jpaRepository.incrementLikeCount(productId); + public void incrementLikeCount(Long productId, LocalDate metricsDate) { + jpaRepository.incrementLikeCount(productId, metricsDate); } @Override - public void decrementLikeCount(Long productId) { - jpaRepository.decrementLikeCount(productId); + public void decrementLikeCount(Long productId, LocalDate metricsDate) { + jpaRepository.decrementLikeCount(productId, metricsDate); } @Override - public void incrementViewCount(Long productId) { - jpaRepository.incrementViewCount(productId); + public void incrementViewCount(Long productId, LocalDate metricsDate) { + jpaRepository.incrementViewCount(productId, metricsDate); } @Override - public void incrementSalesCount(Long productId) { - jpaRepository.incrementSalesCount(productId); + public void incrementSalesCount(Long productId, LocalDate metricsDate) { + jpaRepository.incrementSalesCount(productId, metricsDate); + } + + @Override + public void incrementTotalQuantity(Long productId, LocalDate metricsDate, int quantity) { + jpaRepository.incrementTotalQuantity(productId, metricsDate, quantity); } } From c0860a50e14c13b9ebb73b5129c37e2cad2bdbe9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Mon, 13 Apr 2026 15:11:12 +0900 Subject: [PATCH 04/21] =?UTF-8?q?feat=20:=20ProductMetrics=20=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=20=EB=8F=99=EC=8B=9C=EC=84=B1=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=EB=A5=BC=20=EC=98=88=EB=B0=A9=EC=9D=84=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20=EC=9B=90=EC=9E=90=EC=A0=81=20=EC=BF=BC=EB=A6=AC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/metrics/ProductMetricsRepository.java | 2 ++ .../metrics/ProductMetricsJpaRepository.java | 10 ++++++++++ .../metrics/ProductMetricsRepositoryImpl.java | 5 +++++ 3 files changed, 17 insertions(+) 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 fe3721612..61d289f9e 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 @@ -9,6 +9,8 @@ public interface ProductMetricsRepository { ProductMetrics save(ProductMetrics metrics); + void upsertIfAbsent(Long productId, LocalDate metricsDate); + void incrementLikeCount(Long productId, LocalDate metricsDate); void decrementLikeCount(Long productId, LocalDate metricsDate); 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 6537c3e54..b4fb6730a 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 @@ -6,11 +6,21 @@ import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import org.springframework.transaction.annotation.Transactional; public interface ProductMetricsJpaRepository extends JpaRepository { Optional findByProductIdAndMetricsDate(Long productId, LocalDate metricsDate); + @Transactional + @Modifying + @Query(value = """ + INSERT INTO product_metrics (product_id, metrics_date, like_count, view_count, sales_count, total_quantity, created_at, updated_at) + VALUES (:productId, :metricsDate, 0, 0, 0, 0, NOW(), NOW()) + ON DUPLICATE KEY UPDATE product_id = product_id + """, nativeQuery = true) + void upsertIfAbsent(@Param("productId") Long productId, @Param("metricsDate") LocalDate metricsDate); + @Modifying @Query("UPDATE ProductMetricsEntity m SET m.likeCount = m.likeCount + 1 WHERE m.productId = :productId AND m.metricsDate = :metricsDate") void incrementLikeCount(@Param("productId") Long productId, @Param("metricsDate") LocalDate metricsDate); diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java index 57d4282de..f95ac3e08 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java @@ -24,6 +24,11 @@ public ProductMetrics save(ProductMetrics metrics) { return ProductMetricsEntity.toDomain(jpaRepository.save(new ProductMetricsEntity(metrics))); } + @Override + public void upsertIfAbsent(Long productId, LocalDate metricsDate) { + jpaRepository.upsertIfAbsent(productId, metricsDate); + } + @Override public void incrementLikeCount(Long productId, LocalDate metricsDate) { jpaRepository.incrementLikeCount(productId, metricsDate); From 1110a225545f10586c76d4a8ae3d60a860c86f2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Mon, 13 Apr 2026 15:23:37 +0900 Subject: [PATCH 05/21] =?UTF-8?q?refactor=20:=20=EC=A3=BC=EB=AC=B8=20?= =?UTF-8?q?=EC=88=98=EB=9F=89=20=EB=8D=94=ED=95=98=EB=8A=94=20=EC=BF=BC?= =?UTF-8?q?=EB=A6=AC,=20=EC=A3=BC=EB=AC=B8=20=EC=88=98=EB=9F=89=20?= =?UTF-8?q?=ED=95=A9=EC=82=B0=20=ED=95=98=EB=8A=94=20=EC=BF=BC=EB=A6=AC?= =?UTF-8?q?=EB=A5=BC=201=EA=B0=9C=EC=9D=98=20=EC=BF=BC=EB=A6=AC=EB=A1=9C?= =?UTF-8?q?=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/domain/metrics/ProductMetricsRepository.java | 4 +--- .../metrics/ProductMetricsRepositoryImpl.java | 9 ++------- 2 files changed, 3 insertions(+), 10 deletions(-) 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 61d289f9e..88a45027c 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 @@ -17,7 +17,5 @@ public interface ProductMetricsRepository { void incrementViewCount(Long productId, LocalDate metricsDate); - void incrementSalesCount(Long productId, LocalDate metricsDate); - - void incrementTotalQuantity(Long productId, LocalDate metricsDate, int quantity); + void incrementSalesAndQuantity(Long productId, LocalDate metricsDate, int quantity); } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java index f95ac3e08..b2d049fca 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java @@ -45,12 +45,7 @@ public void incrementViewCount(Long productId, LocalDate metricsDate) { } @Override - public void incrementSalesCount(Long productId, LocalDate metricsDate) { - jpaRepository.incrementSalesCount(productId, metricsDate); - } - - @Override - public void incrementTotalQuantity(Long productId, LocalDate metricsDate, int quantity) { - jpaRepository.incrementTotalQuantity(productId, metricsDate, quantity); + public void incrementSalesAndQuantity(Long productId, LocalDate metricsDate, int quantity) { + jpaRepository.incrementSalesAndQuantity(productId, metricsDate, quantity); } } From 1c31308d96393dc19db4529f1a2453053571bf58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Mon, 13 Apr 2026 16:01:38 +0900 Subject: [PATCH 06/21] =?UTF-8?q?refactor=20:=20ProductMetricsProcessor?= =?UTF-8?q?=EB=A5=BC=20=EB=82=A0=EC=A7=9C=20=EA=B8=B0=EB=B0=98=20upsert=20?= =?UTF-8?q?=EB=B0=A9=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 일# 다음 라인에 작성하세요 --- .../metrics/ProductMetricsProcessor.java | 26 +++++++++---------- .../metrics/ProductMetricsJpaRepository.java | 8 ++---- 2 files changed, 15 insertions(+), 19 deletions(-) diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/ProductMetricsProcessor.java b/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/ProductMetricsProcessor.java index ea64322ea..f1c645b03 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/ProductMetricsProcessor.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/ProductMetricsProcessor.java @@ -2,9 +2,9 @@ import com.loopers.domain.event.EventHandled; import com.loopers.domain.event.EventHandledRepository; -import com.loopers.domain.metrics.ProductMetrics; import com.loopers.domain.metrics.ProductMetricsRepository; import com.loopers.domain.ranking.RankingRepository; +import java.time.LocalDate; import java.time.ZonedDateTime; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -35,24 +35,25 @@ public void process(String eventId, String eventType, Long productId, Integer qu return; } - ensureMetricsExists(productId); + LocalDate metricsDate = occurredAt.toLocalDate(); + ensureMetricsExists(productId, metricsDate); switch (eventType) { case "PRODUCT_VIEWED" -> { - productMetricsRepository.incrementViewCount(productId); - rankingRepository.incrementScore(productId, 0.1, occurredAt.toLocalDate()); + productMetricsRepository.incrementViewCount(productId, metricsDate); + rankingRepository.incrementScore(productId, 0.1, metricsDate); } case "LIKED" -> { - productMetricsRepository.incrementLikeCount(productId); - rankingRepository.incrementScore(productId, 0.2, occurredAt.toLocalDate()); + productMetricsRepository.incrementLikeCount(productId, metricsDate); + rankingRepository.incrementScore(productId, 0.2, metricsDate); } case "UNLIKED" -> { - productMetricsRepository.decrementLikeCount(productId); - rankingRepository.incrementScore(productId, -0.2, occurredAt.toLocalDate()); + productMetricsRepository.decrementLikeCount(productId, metricsDate); + rankingRepository.incrementScore(productId, -0.2, metricsDate); } case "ORDER_CONFIRMED" -> { - productMetricsRepository.incrementSalesCount(productId); - rankingRepository.incrementScore(productId, 0.7 * Math.log1p(quantity), occurredAt.toLocalDate()); + productMetricsRepository.incrementSalesAndQuantity(productId, metricsDate, quantity); + rankingRepository.incrementScore(productId, 0.7 * Math.log1p(quantity), metricsDate); } default -> log.warn("알 수 없는 이벤트 타입. eventType={}", eventType); } @@ -60,8 +61,7 @@ public void process(String eventId, String eventType, Long productId, Integer qu eventHandledRepository.save(EventHandled.create(eventId, eventType, entityId, occurredAt)); } - private void ensureMetricsExists(Long productId) { - productMetricsRepository.findByProductId(productId) - .orElseGet(() -> productMetricsRepository.save(ProductMetrics.create(productId))); + private void ensureMetricsExists(Long productId, LocalDate metricsDate) { + productMetricsRepository.upsertIfAbsent(productId, metricsDate); } } 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 b4fb6730a..c557f908f 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 @@ -34,10 +34,6 @@ INSERT INTO product_metrics (product_id, metrics_date, like_count, view_count, s void incrementViewCount(@Param("productId") Long productId, @Param("metricsDate") LocalDate metricsDate); @Modifying - @Query("UPDATE ProductMetricsEntity m SET m.salesCount = m.salesCount + 1 WHERE m.productId = :productId AND m.metricsDate = :metricsDate") - void incrementSalesCount(@Param("productId") Long productId, @Param("metricsDate") LocalDate metricsDate); - - @Modifying - @Query("UPDATE ProductMetricsEntity m SET m.totalQuantity = m.totalQuantity + :quantity WHERE m.productId = :productId AND m.metricsDate = :metricsDate") - void incrementTotalQuantity(@Param("productId") Long productId, @Param("metricsDate") LocalDate metricsDate, @Param("quantity") int quantity); + @Query("UPDATE ProductMetricsEntity m SET m.salesCount = m.salesCount + 1, m.totalQuantity = m.totalQuantity + :quantity WHERE m.productId = :productId AND m.metricsDate = :metricsDate") + void incrementSalesAndQuantity(@Param("productId") Long productId, @Param("metricsDate") LocalDate metricsDate, @Param("quantity") int quantity); } From 3e1b790af50142dbe82333c3af1809e83453cead Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Mon, 13 Apr 2026 16:05:15 +0900 Subject: [PATCH 07/21] =?UTF-8?q?docs=20:=20md=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/10-batch-ranking.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/10-batch-ranking.md b/docs/10-batch-ranking.md index 037ffb64e..3e9d084c1 100644 --- a/docs/10-batch-ranking.md +++ b/docs/10-batch-ranking.md @@ -158,10 +158,11 @@ GET /api/v1/rankings?date=20260413&period=monthly&size=20&page=1 → mv_product ### Phase 1. product_metrics 구조 변경 - [ ] `metrics_date` 컬럼 추가 (DDL) +- [ ] `total_quantity` 컬럼 추가 (DDL) — 주문 수량 합산, 일간과 동일한 점수 기준 유지 - [ ] UNIQUE 제약 변경: `product_id` → `(product_id, metrics_date)` -- [ ] `ProductMetricsEntity` — `metricsDate` 필드 추가 -- [ ] `ProductMetricsJpaRepository` — increment 쿼리에 `metrics_date` 조건 추가 -- [ ] `ProductMetricsProcessor` — `occurredAt.toLocalDate()`를 날짜 기준으로 upsert +- [ ] `ProductMetricsEntity` — `metricsDate`, `totalQuantity` 필드 추가 +- [ ] `ProductMetricsJpaRepository` — increment 쿼리에 `metrics_date` 조건 추가, `incrementTotalQuantity` 추가 +- [ ] `ProductMetricsProcessor` — `occurredAt.toLocalDate()`를 날짜 기준으로 upsert, ORDER_CONFIRMED 시 quantity 누적 ### Phase 2. MV 테이블 생성 From fe12b12444d8f213f61963ca7878514f93531562 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Mon, 13 Apr 2026 16:57:40 +0900 Subject: [PATCH 08/21] =?UTF-8?q?docs=20:=20md=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/10-batch-ranking.md | 42 ++++++++++++++++++++++++++++++++++------ 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/docs/10-batch-ranking.md b/docs/10-batch-ranking.md index 3e9d084c1..ee628c718 100644 --- a/docs/10-batch-ranking.md +++ b/docs/10-batch-ranking.md @@ -153,16 +153,46 @@ GET /api/v1/rankings?date=20260413&period=monthly&size=20&page=1 → mv_product --- +## 7. 설계 시 고민한 이슈들 + +### 배치 실패 시 랭킹 공백 +배치가 실패하면 MV 테이블이 갱신되지 않아 이전 데이터가 그대로 노출된다. +Spring Batch는 동일 파라미터로 재실행 시 실패 지점부터 이어서 실행하는 기능을 제공한다. +자동 재시도를 구성할 경우 최대 재시도 횟수 + Exponential Backoff + 실패 알림을 세트로 설계해야 한다. + +### 배치 실행 타이밍 +매일 새벽(예: 새벽 1시)에 실행하는 게 자연스럽다. 하루치 `product_metrics`가 모두 쌓인 이후에 집계해야 정확하기 때문이다. +단, 배치 실행 이후 자정까지 들어온 이벤트는 다음 배치까지 반영되지 않는 지연이 발생한다. + +### 주간/월간 기준 정의 +집계 기간을 어떻게 정의하느냐에 따라 결과가 달라진다. + +| 방식 | 설명 | 적합한 경우 | +|---|---|---| +| 최근 N일 | 오늘 기준 7일/30일 전 ~ 오늘, 매일 바뀜 | 트렌드 중심 서비스 | +| 고정 기간 | 월요일~일요일, 1일~말일 고정, 기간 시작일에 한 번에 바뀜 | 커머스 큐레이션, 마케팅 연동 | + +커머스 서비스 특성상 고정 기간 방식이 더 자연스러울 수 있으나, 비즈니스 요구사항에 따라 결정해야 한다. + +### 동점 처리 +두 상품의 점수가 동일할 때 순위 결정 기준이 명확하지 않으면 배치를 돌릴 때마다 순위가 달라질 수 있다. +Redis ZSET은 동점 시 사전순으로 처리하지만, DB 집계는 기준이 없으면 비결정적이다. + +### 어뷰징 방지 +배치는 기간 내 데이터를 단순 합산하는 구조라, 배치 실행 직전 단기간에 대량 이벤트를 발생시켜 랭킹을 인위적으로 올리는 어뷰징에 취약하다. + +--- + ## 6. 구현 체크리스트 ### Phase 1. product_metrics 구조 변경 -- [ ] `metrics_date` 컬럼 추가 (DDL) -- [ ] `total_quantity` 컬럼 추가 (DDL) — 주문 수량 합산, 일간과 동일한 점수 기준 유지 -- [ ] UNIQUE 제약 변경: `product_id` → `(product_id, metrics_date)` -- [ ] `ProductMetricsEntity` — `metricsDate`, `totalQuantity` 필드 추가 -- [ ] `ProductMetricsJpaRepository` — increment 쿼리에 `metrics_date` 조건 추가, `incrementTotalQuantity` 추가 -- [ ] `ProductMetricsProcessor` — `occurredAt.toLocalDate()`를 날짜 기준으로 upsert, ORDER_CONFIRMED 시 quantity 누적 +- [x] `metrics_date` 컬럼 추가 (DDL) +- [x] `total_quantity` 컬럼 추가 (DDL) — 주문 수량 합산, 일간과 동일한 점수 기준 유지 +- [x] UNIQUE 제약 변경: `product_id` → `(product_id, metrics_date)` +- [x] `ProductMetricsEntity` — `metricsDate`, `totalQuantity` 필드 추가 +- [x] `ProductMetricsJpaRepository` — increment 쿼리에 `metrics_date` 조건 추가, `upsertIfAbsent` 추가, `incrementSalesAndQuantity`로 통합 +- [x] `ProductMetricsProcessor` — `occurredAt.toLocalDate()`를 날짜 기준으로 upsert, ORDER_CONFIRMED 시 `incrementSalesAndQuantity` 호출 ### Phase 2. MV 테이블 생성 From 50172d7a772db56ef7a5cc9cc432b97f896021c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Wed, 15 Apr 2026 14:30:55 +0900 Subject: [PATCH 09/21] =?UTF-8?q?feat=20:=20=EC=A3=BC=EA=B0=84/=EC=9B=94?= =?UTF-8?q?=EA=B0=84=20=EB=9E=AD=ED=82=B9=20Materialized=20View=20?= =?UTF-8?q?=EC=84=A4=EA=B3=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ranking/MvProductRankMonthlyEntity.java | 81 +++++++++++++++++++ .../ranking/MvProductRankWeeklyEntity.java | 81 +++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyEntity.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyEntity.java diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyEntity.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyEntity.java new file mode 100644 index 000000000..ec6f3a5ee --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyEntity.java @@ -0,0 +1,81 @@ +package com.loopers.infrastructure.ranking; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Comment; + +@Entity +@Table(name = "mv_product_rank_monthly") +@NoArgsConstructor +public class MvProductRankMonthlyEntity { + + @Id + @Comment("상품 id (ref)") + @Column(name = "product_id", nullable = false, updatable = false) + private Long productId; + + @Comment("집계 점수") + @Column(name = "score", nullable = false) + private double score; + + @Comment("집계 기준 월 (e.g. 202604)") + @Column(name = "year_month", nullable = false, length = 6) + private String yearMonth; + + @Comment("랭킹 순위") + @Column(name = "product_rank", nullable = false) + private int productRank; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + public MvProductRankMonthlyEntity(Long productId, double score, String yearMonth, int productRank) { + this.productId = productId; + this.score = score; + this.yearMonth = yearMonth; + this.productRank = productRank; + } + + public void update(double score, String yearMonth, int productRank) { + this.score = score; + this.yearMonth = yearMonth; + this.productRank = productRank; + } + + @PrePersist + private void prePersist() { + LocalDateTime now = LocalDateTime.now(); + this.createdAt = now; + this.updatedAt = now; + } + + @PreUpdate + private void preUpdate() { + this.updatedAt = LocalDateTime.now(); + } + + public Long getProductId() { + return productId; + } + + public double getScore() { + return score; + } + + public String getYearMonth() { + return yearMonth; + } + + public int getProductRank() { + return productRank; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyEntity.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyEntity.java new file mode 100644 index 000000000..4cf28929c --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyEntity.java @@ -0,0 +1,81 @@ +package com.loopers.infrastructure.ranking; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Comment; + +@Entity +@Table(name = "mv_product_rank_weekly") +@NoArgsConstructor +public class MvProductRankWeeklyEntity { + + @Id + @Comment("상품 id (ref)") + @Column(name = "product_id", nullable = false, updatable = false) + private Long productId; + + @Comment("집계 점수") + @Column(name = "score", nullable = false) + private double score; + + @Comment("집계 기준 주 (해당 주 월요일, e.g. 20260414)") + @Column(name = "year_week", nullable = false, length = 8) + private String yearWeek; + + @Comment("랭킹 순위") + @Column(name = "product_rank", nullable = false) + private int productRank; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + public MvProductRankWeeklyEntity(Long productId, double score, String yearWeek, int productRank) { + this.productId = productId; + this.score = score; + this.yearWeek = yearWeek; + this.productRank = productRank; + } + + public void update(double score, String yearWeek, int productRank) { + this.score = score; + this.yearWeek = yearWeek; + this.productRank = productRank; + } + + @PrePersist + private void prePersist() { + LocalDateTime now = LocalDateTime.now(); + this.createdAt = now; + this.updatedAt = now; + } + + @PreUpdate + private void preUpdate() { + this.updatedAt = LocalDateTime.now(); + } + + public Long getProductId() { + return productId; + } + + public double getScore() { + return score; + } + + public String getYearWeek() { + return yearWeek; + } + + public int getProductRank() { + return productRank; + } +} From 3433e33923d8712484a0917e68f1e543c31b0296 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Wed, 15 Apr 2026 14:31:09 +0900 Subject: [PATCH 10/21] =?UTF-8?q?docs=20:=20md=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/10-batch-ranking.md | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/docs/10-batch-ranking.md b/docs/10-batch-ranking.md index ee628c718..69354730a 100644 --- a/docs/10-batch-ranking.md +++ b/docs/10-batch-ranking.md @@ -181,6 +181,27 @@ Redis ZSET은 동점 시 사전순으로 처리하지만, DB 집계는 기준이 ### 어뷰징 방지 배치는 기간 내 데이터를 단순 합산하는 구조라, 배치 실행 직전 단기간에 대량 이벤트를 발생시켜 랭킹을 인위적으로 올리는 어뷰징에 취약하다. +### 월간 집계를 주간 MV에서 하지 않는 이유 + +월간 배치가 `mv_product_rank_weekly`를 입력으로 쓰는 방식(체인 구조)을 고려했으나 채택하지 않았다. + +**이유 1 — score 합산 불가** +현재 점수 공식에 `log1p`가 포함되어 있어 주간 score를 단순 합산하면 월간 score와 달라진다. +``` +log1p(3) + log1p(4) ≠ log1p(7) +→ 주간 score 합산 ≠ 월간 score +``` +원본 count를 함께 저장하면 해결되지만, MV 테이블의 역할(랭킹 조회)과 책임이 섞인다. + +**이유 2 — TOP 100 절단 문제** +`mv_product_rank_weekly`는 TOP 100만 저장한다. 주간 TOP 100에 한 번도 들지 못한 상품은 월간 집계에서 누락된다. + +**이유 3 — 스케줄러 의존성** +월간 배치가 주간 배치 완료 후에만 실행될 수 있어 순서 의존성이 생긴다. 주간 배치 장애 시 월간 배치도 영향을 받는다. + +**결론** +`product_metrics`(일별 원본)에서 weekly, monthly가 각자 독립적으로 집계하는 구조(fan-out)를 채택했다. 단일 원천에서 파생되므로 재집계가 단순하고 스케줄러 간 의존성이 없다. + --- ## 6. 구현 체크리스트 @@ -196,8 +217,8 @@ Redis ZSET은 동점 시 사전순으로 처리하지만, DB 집계는 기준이 ### Phase 2. MV 테이블 생성 -- [ ] `mv_product_rank_weekly` DDL 작성 -- [ ] `mv_product_rank_monthly` DDL 작성 +- [x] `MvProductRankWeeklyEntity` JPA Entity 생성 (DDL 대체) +- [x] `MvProductRankMonthlyEntity` JPA Entity 생성 (DDL 대체) ### Phase 3. Spring Batch Job From d073d2bb7f12db18244d34e543904d37110562c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Wed, 15 Apr 2026 15:22:46 +0900 Subject: [PATCH 11/21] =?UTF-8?q?feat=20:=20infrastructure=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=96=B4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../metrics/ProductMetricsAggregatedDto.java | 32 +++++++++++++++++++ .../MvProductRankMonthlyJpaRepository.java | 11 +++++++ .../MvProductRankWeeklyJpaRepository.java | 11 +++++++ 3 files changed, 54 insertions(+) create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsAggregatedDto.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsAggregatedDto.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsAggregatedDto.java new file mode 100644 index 000000000..e9de7c8ce --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsAggregatedDto.java @@ -0,0 +1,32 @@ +package com.loopers.infrastructure.metrics; + +public class ProductMetricsAggregatedDto { + + private final Long productId; + private final long totalViewCount; + private final long totalLikeCount; + private final long totalQuantity; + + public ProductMetricsAggregatedDto(Long productId, long totalViewCount, long totalLikeCount, long totalQuantity) { + this.productId = productId; + this.totalViewCount = totalViewCount; + this.totalLikeCount = totalLikeCount; + this.totalQuantity = totalQuantity; + } + + public Long getProductId() { + return productId; + } + + public long getTotalViewCount() { + return totalViewCount; + } + + public long getTotalLikeCount() { + return totalLikeCount; + } + + public long getTotalQuantity() { + return totalQuantity; + } +} 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 000000000..34792dd2b --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.ranking; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MvProductRankMonthlyJpaRepository extends JpaRepository { + + List findAllByYearMonthOrderByScoreDesc(String yearMonth); + + void deleteAllByYearMonthNot(String yearMonth); +} 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 000000000..2ae53ef74 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.ranking; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MvProductRankWeeklyJpaRepository extends JpaRepository { + + List findAllByYearWeekOrderByScoreDesc(String yearWeek); + + void deleteAllByYearWeekNot(String yearWeek); +} From 192e2361eb0810d8dac2f7b9c3f18b0246e47e6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Wed, 15 Apr 2026 15:26:00 +0900 Subject: [PATCH 12/21] =?UTF-8?q?feat=20:=20=EC=A3=BC=EA=B0=84=20batch=20j?= =?UTF-8?q?ob=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../job/ranking/RankingWeeklyJobConfig.java | 168 ++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingWeeklyJobConfig.java diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingWeeklyJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingWeeklyJobConfig.java new file mode 100644 index 000000000..e61af4fe7 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingWeeklyJobConfig.java @@ -0,0 +1,168 @@ +package com.loopers.batch.job.ranking; + +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; +import com.loopers.infrastructure.metrics.ProductMetricsAggregatedDto; +import com.loopers.infrastructure.ranking.MvProductRankWeeklyEntity; +import com.loopers.infrastructure.ranking.MvProductRankWeeklyJpaRepository; +import java.sql.Date; +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Map; +import javax.sql.DataSource; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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.core.step.tasklet.Tasklet; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.ItemWriter; +import org.springframework.batch.item.database.JdbcPagingItemReader; +import org.springframework.batch.item.database.Order; +import org.springframework.batch.item.database.builder.JdbcPagingItemReaderBuilder; +import org.springframework.batch.repeat.RepeatStatus; +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; + +@Slf4j +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = RankingWeeklyJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Configuration +public class RankingWeeklyJobConfig { + + public static final String JOB_NAME = "rankingWeeklyJob"; + private static final int CHUNK_SIZE = 100; + private static final int TOP_RANK_LIMIT = 100; + + private final JobRepository jobRepository; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final PlatformTransactionManager transactionManager; + private final DataSource dataSource; + private final MvProductRankWeeklyJpaRepository weeklyRepository; + + @Bean(JOB_NAME) + public Job rankingWeeklyJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .start(weeklyScoreCalculationStep()) + .next(weeklyRankAssignStep()) + .listener(jobListener) + .build(); + } + + @Bean("weeklyScoreCalculationStep") + public Step weeklyScoreCalculationStep() { + return new StepBuilder("weeklyScoreCalculationStep", jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(weeklyProductMetricsReader(null)) + .processor(weeklyRankingProcessor(null)) + .writer(weeklyMvWriter(null)) + .listener(stepMonitorListener) + .build(); + } + + @Bean("weeklyRankAssignStep") + public Step weeklyRankAssignStep() { + return new StepBuilder("weeklyRankAssignStep", jobRepository) + .tasklet(weeklyRankAssignTasklet(null), transactionManager) + .listener(stepMonitorListener) + .build(); + } + + @StepScope + @Bean("weeklyProductMetricsReader") + public JdbcPagingItemReader weeklyProductMetricsReader( + @Value("#{jobParameters['targetDate']}") String targetDate) { + LocalDate target = LocalDate.parse(targetDate, DateTimeFormatter.BASIC_ISO_DATE); + LocalDate startDate = target.with(DayOfWeek.MONDAY); + LocalDate endDate = target.with(DayOfWeek.SUNDAY); + + log.info("주간 Reader 기간: {} ~ {}", startDate, endDate); + + return new JdbcPagingItemReaderBuilder() + .name("weeklyProductMetricsReader") + .dataSource(dataSource) + .selectClause("product_id, SUM(view_count) AS total_view_count, SUM(like_count) AS total_like_count, SUM(total_quantity) AS total_quantity") + .fromClause("product_metrics") + .whereClause("metrics_date BETWEEN :startDate AND :endDate") + .groupClause("product_id") + .sortKeys(Map.of("product_id", Order.ASCENDING)) + .parameterValues(Map.of("startDate", Date.valueOf(startDate), "endDate", Date.valueOf(endDate))) + .rowMapper((rs, rowNum) -> new ProductMetricsAggregatedDto( + rs.getLong("product_id"), + rs.getLong("total_view_count"), + rs.getLong("total_like_count"), + rs.getLong("total_quantity") + )) + .pageSize(CHUNK_SIZE) + .build(); + } + + @StepScope + @Bean("weeklyRankingProcessor") + public ItemProcessor weeklyRankingProcessor( + @Value("#{jobParameters['targetDate']}") String targetDate) { + LocalDate target = LocalDate.parse(targetDate, DateTimeFormatter.BASIC_ISO_DATE); + String yearWeek = target.with(DayOfWeek.MONDAY).format(DateTimeFormatter.BASIC_ISO_DATE); + + return dto -> { + double score = 0.1 * dto.getTotalViewCount() + + 0.2 * dto.getTotalLikeCount() + + 0.7 * Math.log1p(dto.getTotalQuantity()); + return new MvProductRankWeeklyEntity(dto.getProductId(), score, yearWeek, 0); + }; + } + + @StepScope + @Bean("weeklyMvWriter") + public ItemWriter weeklyMvWriter( + @Value("#{jobParameters['targetDate']}") String targetDate) { + LocalDate target = LocalDate.parse(targetDate, DateTimeFormatter.BASIC_ISO_DATE); + String yearWeek = target.with(DayOfWeek.MONDAY).format(DateTimeFormatter.BASIC_ISO_DATE); + + return chunk -> { + for (MvProductRankWeeklyEntity item : chunk.getItems()) { + weeklyRepository.findById(item.getProductId()) + .ifPresentOrElse( + existing -> existing.update(item.getScore(), yearWeek, 0), + () -> weeklyRepository.save(item) + ); + } + }; + } + + @StepScope + @Bean("weeklyRankAssignTasklet") + public Tasklet weeklyRankAssignTasklet( + @Value("#{jobParameters['targetDate']}") String targetDate) { + LocalDate target = LocalDate.parse(targetDate, DateTimeFormatter.BASIC_ISO_DATE); + String yearWeek = target.with(DayOfWeek.MONDAY).format(DateTimeFormatter.BASIC_ISO_DATE); + + return (contribution, chunkContext) -> { + weeklyRepository.deleteAllByYearWeekNot(yearWeek); + + List ranked = weeklyRepository.findAllByYearWeekOrderByScoreDesc(yearWeek); + + for (int i = 0; i < Math.min(ranked.size(), TOP_RANK_LIMIT); i++) { + ranked.get(i).update(ranked.get(i).getScore(), yearWeek, i + 1); + } + + if (ranked.size() > TOP_RANK_LIMIT) { + weeklyRepository.deleteAll(ranked.subList(TOP_RANK_LIMIT, ranked.size())); + log.info("주간 랭킹 {}위 이후 {}건 삭제", TOP_RANK_LIMIT, ranked.size() - TOP_RANK_LIMIT); + } + + log.info("주간 랭킹 확정: {}건 (yearWeek={})", Math.min(ranked.size(), TOP_RANK_LIMIT), yearWeek); + return RepeatStatus.FINISHED; + }; + } +} From a7f9a3bd9183226fbe24b72ee52405331a7ea17f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Wed, 15 Apr 2026 15:26:07 +0900 Subject: [PATCH 13/21] =?UTF-8?q?feat=20:=20=EC=9B=94=EA=B0=84=20batch=20j?= =?UTF-8?q?ob=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../job/ranking/RankingMonthlyJobConfig.java | 168 ++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingMonthlyJobConfig.java diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingMonthlyJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingMonthlyJobConfig.java new file mode 100644 index 000000000..804a00649 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingMonthlyJobConfig.java @@ -0,0 +1,168 @@ +package com.loopers.batch.job.ranking; + +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; +import com.loopers.infrastructure.metrics.ProductMetricsAggregatedDto; +import com.loopers.infrastructure.ranking.MvProductRankMonthlyEntity; +import com.loopers.infrastructure.ranking.MvProductRankMonthlyJpaRepository; +import java.sql.Date; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Map; +import javax.sql.DataSource; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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.core.step.tasklet.Tasklet; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.ItemWriter; +import org.springframework.batch.item.database.JdbcPagingItemReader; +import org.springframework.batch.item.database.Order; +import org.springframework.batch.item.database.builder.JdbcPagingItemReaderBuilder; +import org.springframework.batch.repeat.RepeatStatus; +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; + +@Slf4j +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = RankingMonthlyJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Configuration +public class RankingMonthlyJobConfig { + + public static final String JOB_NAME = "rankingMonthlyJob"; + private static final int CHUNK_SIZE = 100; + private static final int TOP_RANK_LIMIT = 100; + private static final DateTimeFormatter YEAR_MONTH_FORMAT = DateTimeFormatter.ofPattern("yyyyMM"); + + private final JobRepository jobRepository; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final PlatformTransactionManager transactionManager; + private final DataSource dataSource; + private final MvProductRankMonthlyJpaRepository monthlyRepository; + + @Bean(JOB_NAME) + public Job rankingMonthlyJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .start(monthlyScoreCalculationStep()) + .next(monthlyRankAssignStep()) + .listener(jobListener) + .build(); + } + + @Bean("monthlyScoreCalculationStep") + public Step monthlyScoreCalculationStep() { + return new StepBuilder("monthlyScoreCalculationStep", jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(monthlyProductMetricsReader(null)) + .processor(monthlyRankingProcessor(null)) + .writer(monthlyMvWriter(null)) + .listener(stepMonitorListener) + .build(); + } + + @Bean("monthlyRankAssignStep") + public Step monthlyRankAssignStep() { + return new StepBuilder("monthlyRankAssignStep", jobRepository) + .tasklet(monthlyRankAssignTasklet(null), transactionManager) + .listener(stepMonitorListener) + .build(); + } + + @StepScope + @Bean("monthlyProductMetricsReader") + public JdbcPagingItemReader monthlyProductMetricsReader( + @Value("#{jobParameters['targetDate']}") String targetDate) { + LocalDate target = LocalDate.parse(targetDate, DateTimeFormatter.BASIC_ISO_DATE); + LocalDate startDate = target.withDayOfMonth(1); + LocalDate endDate = target.withDayOfMonth(target.lengthOfMonth()); + + log.info("월간 Reader 기간: {} ~ {}", startDate, endDate); + + return new JdbcPagingItemReaderBuilder() + .name("monthlyProductMetricsReader") + .dataSource(dataSource) + .selectClause("product_id, SUM(view_count) AS total_view_count, SUM(like_count) AS total_like_count, SUM(total_quantity) AS total_quantity") + .fromClause("product_metrics") + .whereClause("metrics_date BETWEEN :startDate AND :endDate") + .groupClause("product_id") + .sortKeys(Map.of("product_id", Order.ASCENDING)) + .parameterValues(Map.of("startDate", Date.valueOf(startDate), "endDate", Date.valueOf(endDate))) + .rowMapper((rs, rowNum) -> new ProductMetricsAggregatedDto( + rs.getLong("product_id"), + rs.getLong("total_view_count"), + rs.getLong("total_like_count"), + rs.getLong("total_quantity") + )) + .pageSize(CHUNK_SIZE) + .build(); + } + + @StepScope + @Bean("monthlyRankingProcessor") + public ItemProcessor monthlyRankingProcessor( + @Value("#{jobParameters['targetDate']}") String targetDate) { + LocalDate target = LocalDate.parse(targetDate, DateTimeFormatter.BASIC_ISO_DATE); + String yearMonth = target.format(YEAR_MONTH_FORMAT); + + return dto -> { + double score = 0.1 * dto.getTotalViewCount() + + 0.2 * dto.getTotalLikeCount() + + 0.7 * Math.log1p(dto.getTotalQuantity()); + return new MvProductRankMonthlyEntity(dto.getProductId(), score, yearMonth, 0); + }; + } + + @StepScope + @Bean("monthlyMvWriter") + public ItemWriter monthlyMvWriter( + @Value("#{jobParameters['targetDate']}") String targetDate) { + LocalDate target = LocalDate.parse(targetDate, DateTimeFormatter.BASIC_ISO_DATE); + String yearMonth = target.format(YEAR_MONTH_FORMAT); + + return chunk -> { + for (MvProductRankMonthlyEntity item : chunk.getItems()) { + monthlyRepository.findById(item.getProductId()) + .ifPresentOrElse( + existing -> existing.update(item.getScore(), yearMonth, 0), + () -> monthlyRepository.save(item) + ); + } + }; + } + + @StepScope + @Bean("monthlyRankAssignTasklet") + public Tasklet monthlyRankAssignTasklet( + @Value("#{jobParameters['targetDate']}") String targetDate) { + LocalDate target = LocalDate.parse(targetDate, DateTimeFormatter.BASIC_ISO_DATE); + String yearMonth = target.format(YEAR_MONTH_FORMAT); + + return (contribution, chunkContext) -> { + monthlyRepository.deleteAllByYearMonthNot(yearMonth); + + List ranked = monthlyRepository.findAllByYearMonthOrderByScoreDesc(yearMonth); + + for (int i = 0; i < Math.min(ranked.size(), TOP_RANK_LIMIT); i++) { + ranked.get(i).update(ranked.get(i).getScore(), yearMonth, i + 1); + } + + if (ranked.size() > TOP_RANK_LIMIT) { + monthlyRepository.deleteAll(ranked.subList(TOP_RANK_LIMIT, ranked.size())); + log.info("월간 랭킹 {}위 이후 {}건 삭제", TOP_RANK_LIMIT, ranked.size() - TOP_RANK_LIMIT); + } + + log.info("월간 랭킹 확정: {}건 (yearMonth={})", Math.min(ranked.size(), TOP_RANK_LIMIT), yearMonth); + return RepeatStatus.FINISHED; + }; + } +} From c9e6053ed68661ae0e824091186eda60fc84ea11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Wed, 15 Apr 2026 15:49:59 +0900 Subject: [PATCH 14/21] =?UTF-8?q?refactor=20:=20=EC=A3=BC=EA=B0=84=20reade?= =?UTF-8?q?r=20/=20processor=20/=20writer=EB=A5=BC=20=EA=B0=81=EA=B0=81?= =?UTF-8?q?=EC=9D=98=20=ED=81=B4=EB=9E=98=EC=8A=A4=EB=A1=9C=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../job/ranking/RankingWeeklyJobConfig.java | 130 ++---------------- .../step/ProductMetricsItemReader.java | 71 ++++++++++ .../job/ranking/step/RankedProductDto.java | 20 +++ .../ranking/step/RankingItemProcessor.java | 19 +++ .../step/WeeklyMvRankingItemWriter.java | 42 ++++++ .../ranking/step/WeeklyRankAssignTasklet.java | 57 ++++++++ 6 files changed, 223 insertions(+), 116 deletions(-) create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/ProductMetricsItemReader.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/RankedProductDto.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/RankingItemProcessor.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyMvRankingItemWriter.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyRankAssignTasklet.java diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingWeeklyJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingWeeklyJobConfig.java index e61af4fe7..4d233189e 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingWeeklyJobConfig.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingWeeklyJobConfig.java @@ -2,38 +2,23 @@ import com.loopers.batch.listener.JobListener; import com.loopers.batch.listener.StepMonitorListener; +import com.loopers.batch.job.ranking.step.ProductMetricsItemReader; +import com.loopers.batch.job.ranking.step.RankedProductDto; +import com.loopers.batch.job.ranking.step.RankingItemProcessor; +import com.loopers.batch.job.ranking.step.WeeklyMvRankingItemWriter; +import com.loopers.batch.job.ranking.step.WeeklyRankAssignTasklet; import com.loopers.infrastructure.metrics.ProductMetricsAggregatedDto; -import com.loopers.infrastructure.ranking.MvProductRankWeeklyEntity; -import com.loopers.infrastructure.ranking.MvProductRankWeeklyJpaRepository; -import java.sql.Date; -import java.time.DayOfWeek; -import java.time.LocalDate; -import java.time.format.DateTimeFormatter; -import java.util.List; -import java.util.Map; -import javax.sql.DataSource; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; 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.core.step.tasklet.Tasklet; -import org.springframework.batch.item.ItemProcessor; -import org.springframework.batch.item.ItemWriter; -import org.springframework.batch.item.database.JdbcPagingItemReader; -import org.springframework.batch.item.database.Order; -import org.springframework.batch.item.database.builder.JdbcPagingItemReaderBuilder; -import org.springframework.batch.repeat.RepeatStatus; -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; -@Slf4j @ConditionalOnProperty(name = "spring.batch.job.name", havingValue = RankingWeeklyJobConfig.JOB_NAME) @RequiredArgsConstructor @Configuration @@ -41,14 +26,15 @@ public class RankingWeeklyJobConfig { public static final String JOB_NAME = "rankingWeeklyJob"; private static final int CHUNK_SIZE = 100; - private static final int TOP_RANK_LIMIT = 100; private final JobRepository jobRepository; private final JobListener jobListener; private final StepMonitorListener stepMonitorListener; private final PlatformTransactionManager transactionManager; - private final DataSource dataSource; - private final MvProductRankWeeklyJpaRepository weeklyRepository; + private final ProductMetricsItemReader productMetricsItemReader; + private final RankingItemProcessor rankingItemProcessor; + private final WeeklyMvRankingItemWriter weeklyMvRankingItemWriter; + private final WeeklyRankAssignTasklet weeklyRankAssignTasklet; @Bean(JOB_NAME) public Job rankingWeeklyJob() { @@ -62,10 +48,10 @@ public Job rankingWeeklyJob() { @Bean("weeklyScoreCalculationStep") public Step weeklyScoreCalculationStep() { return new StepBuilder("weeklyScoreCalculationStep", jobRepository) - .chunk(CHUNK_SIZE, transactionManager) - .reader(weeklyProductMetricsReader(null)) - .processor(weeklyRankingProcessor(null)) - .writer(weeklyMvWriter(null)) + .chunk(CHUNK_SIZE, transactionManager) + .reader(productMetricsItemReader.reader(null, null, null)) + .processor(rankingItemProcessor) + .writer(weeklyMvRankingItemWriter) .listener(stepMonitorListener) .build(); } @@ -73,96 +59,8 @@ public Step weeklyScoreCalculationStep() { @Bean("weeklyRankAssignStep") public Step weeklyRankAssignStep() { return new StepBuilder("weeklyRankAssignStep", jobRepository) - .tasklet(weeklyRankAssignTasklet(null), transactionManager) + .tasklet(weeklyRankAssignTasklet, transactionManager) .listener(stepMonitorListener) .build(); } - - @StepScope - @Bean("weeklyProductMetricsReader") - public JdbcPagingItemReader weeklyProductMetricsReader( - @Value("#{jobParameters['targetDate']}") String targetDate) { - LocalDate target = LocalDate.parse(targetDate, DateTimeFormatter.BASIC_ISO_DATE); - LocalDate startDate = target.with(DayOfWeek.MONDAY); - LocalDate endDate = target.with(DayOfWeek.SUNDAY); - - log.info("주간 Reader 기간: {} ~ {}", startDate, endDate); - - return new JdbcPagingItemReaderBuilder() - .name("weeklyProductMetricsReader") - .dataSource(dataSource) - .selectClause("product_id, SUM(view_count) AS total_view_count, SUM(like_count) AS total_like_count, SUM(total_quantity) AS total_quantity") - .fromClause("product_metrics") - .whereClause("metrics_date BETWEEN :startDate AND :endDate") - .groupClause("product_id") - .sortKeys(Map.of("product_id", Order.ASCENDING)) - .parameterValues(Map.of("startDate", Date.valueOf(startDate), "endDate", Date.valueOf(endDate))) - .rowMapper((rs, rowNum) -> new ProductMetricsAggregatedDto( - rs.getLong("product_id"), - rs.getLong("total_view_count"), - rs.getLong("total_like_count"), - rs.getLong("total_quantity") - )) - .pageSize(CHUNK_SIZE) - .build(); - } - - @StepScope - @Bean("weeklyRankingProcessor") - public ItemProcessor weeklyRankingProcessor( - @Value("#{jobParameters['targetDate']}") String targetDate) { - LocalDate target = LocalDate.parse(targetDate, DateTimeFormatter.BASIC_ISO_DATE); - String yearWeek = target.with(DayOfWeek.MONDAY).format(DateTimeFormatter.BASIC_ISO_DATE); - - return dto -> { - double score = 0.1 * dto.getTotalViewCount() - + 0.2 * dto.getTotalLikeCount() - + 0.7 * Math.log1p(dto.getTotalQuantity()); - return new MvProductRankWeeklyEntity(dto.getProductId(), score, yearWeek, 0); - }; - } - - @StepScope - @Bean("weeklyMvWriter") - public ItemWriter weeklyMvWriter( - @Value("#{jobParameters['targetDate']}") String targetDate) { - LocalDate target = LocalDate.parse(targetDate, DateTimeFormatter.BASIC_ISO_DATE); - String yearWeek = target.with(DayOfWeek.MONDAY).format(DateTimeFormatter.BASIC_ISO_DATE); - - return chunk -> { - for (MvProductRankWeeklyEntity item : chunk.getItems()) { - weeklyRepository.findById(item.getProductId()) - .ifPresentOrElse( - existing -> existing.update(item.getScore(), yearWeek, 0), - () -> weeklyRepository.save(item) - ); - } - }; - } - - @StepScope - @Bean("weeklyRankAssignTasklet") - public Tasklet weeklyRankAssignTasklet( - @Value("#{jobParameters['targetDate']}") String targetDate) { - LocalDate target = LocalDate.parse(targetDate, DateTimeFormatter.BASIC_ISO_DATE); - String yearWeek = target.with(DayOfWeek.MONDAY).format(DateTimeFormatter.BASIC_ISO_DATE); - - return (contribution, chunkContext) -> { - weeklyRepository.deleteAllByYearWeekNot(yearWeek); - - List ranked = weeklyRepository.findAllByYearWeekOrderByScoreDesc(yearWeek); - - for (int i = 0; i < Math.min(ranked.size(), TOP_RANK_LIMIT); i++) { - ranked.get(i).update(ranked.get(i).getScore(), yearWeek, i + 1); - } - - if (ranked.size() > TOP_RANK_LIMIT) { - weeklyRepository.deleteAll(ranked.subList(TOP_RANK_LIMIT, ranked.size())); - log.info("주간 랭킹 {}위 이후 {}건 삭제", TOP_RANK_LIMIT, ranked.size() - TOP_RANK_LIMIT); - } - - log.info("주간 랭킹 확정: {}건 (yearWeek={})", Math.min(ranked.size(), TOP_RANK_LIMIT), yearWeek); - return RepeatStatus.FINISHED; - }; - } } diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/ProductMetricsItemReader.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/ProductMetricsItemReader.java new file mode 100644 index 000000000..89ca655ae --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/ProductMetricsItemReader.java @@ -0,0 +1,71 @@ +package com.loopers.batch.job.ranking.step; + +import com.loopers.infrastructure.metrics.ProductMetricsAggregatedDto; +import java.sql.Date; +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.Map; +import javax.sql.DataSource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.item.database.JdbcPagingItemReader; +import org.springframework.batch.item.database.Order; +import org.springframework.batch.item.database.builder.JdbcPagingItemReaderBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Slf4j +@Configuration +public class ProductMetricsItemReader { + + private static final int PAGE_SIZE = 100; + + @StepScope + @Bean("productMetricsItemReader") + public JdbcPagingItemReader reader( + DataSource dataSource, + @Value("#{jobParameters['targetDate']}") String targetDate, + @Value("#{jobParameters['period']}") String period) { + LocalDate target = LocalDate.parse(targetDate, DateTimeFormatter.BASIC_ISO_DATE); + LocalDate startDate = resolveStartDate(target, period); + LocalDate endDate = resolveEndDate(target, period); + + log.info("ProductMetricsItemReader 기간: {} ~ {} (period={})", startDate, endDate, period); + + return new JdbcPagingItemReaderBuilder() + .name("productMetricsItemReader") + .dataSource(dataSource) + .selectClause("product_id, SUM(view_count) AS total_view_count, SUM(like_count) AS total_like_count, SUM(total_quantity) AS total_quantity") + .fromClause("product_metrics") + .whereClause("metrics_date BETWEEN :startDate AND :endDate") + .groupClause("product_id") + .sortKeys(Map.of("product_id", Order.ASCENDING)) + .parameterValues(Map.of("startDate", Date.valueOf(startDate), "endDate", Date.valueOf(endDate))) + .rowMapper((rs, rowNum) -> new ProductMetricsAggregatedDto( + rs.getLong("product_id"), + rs.getLong("total_view_count"), + rs.getLong("total_like_count"), + rs.getLong("total_quantity") + )) + .pageSize(PAGE_SIZE) + .build(); + } + + private LocalDate resolveStartDate(LocalDate target, String period) { + return switch (period) { + case "weekly" -> target.with(DayOfWeek.MONDAY); + case "monthly" -> target.withDayOfMonth(1); + default -> throw new IllegalArgumentException("지원하지 않는 period: " + period); + }; + } + + private LocalDate resolveEndDate(LocalDate target, String period) { + return switch (period) { + case "weekly" -> target.with(DayOfWeek.SUNDAY); + case "monthly" -> target.withDayOfMonth(target.lengthOfMonth()); + default -> throw new IllegalArgumentException("지원하지 않는 period: " + period); + }; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/RankedProductDto.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/RankedProductDto.java new file mode 100644 index 000000000..81b1e67ad --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/RankedProductDto.java @@ -0,0 +1,20 @@ +package com.loopers.batch.job.ranking.step; + +public class RankedProductDto { + + private final Long productId; + private final double score; + + public RankedProductDto(Long productId, double score) { + this.productId = productId; + this.score = score; + } + + public Long getProductId() { + return productId; + } + + public double getScore() { + return score; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/RankingItemProcessor.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/RankingItemProcessor.java new file mode 100644 index 000000000..121946de8 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/RankingItemProcessor.java @@ -0,0 +1,19 @@ +package com.loopers.batch.job.ranking.step; + +import com.loopers.infrastructure.metrics.ProductMetricsAggregatedDto; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.stereotype.Component; + +@StepScope +@Component +public class RankingItemProcessor implements ItemProcessor { + + @Override + public RankedProductDto process(ProductMetricsAggregatedDto dto) { + double score = 0.1 * dto.getTotalViewCount() + + 0.2 * dto.getTotalLikeCount() + + 0.7 * Math.log1p(dto.getTotalQuantity()); + return new RankedProductDto(dto.getProductId(), score); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyMvRankingItemWriter.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyMvRankingItemWriter.java new file mode 100644 index 000000000..cc5783b26 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyMvRankingItemWriter.java @@ -0,0 +1,42 @@ +package com.loopers.batch.job.ranking.step; + +import com.loopers.batch.job.ranking.RankingWeeklyJobConfig; +import com.loopers.infrastructure.ranking.MvProductRankWeeklyEntity; +import com.loopers.infrastructure.ranking.MvProductRankWeeklyJpaRepository; +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.item.Chunk; +import org.springframework.batch.item.ItemWriter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +@StepScope +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = RankingWeeklyJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Component +public class WeeklyMvRankingItemWriter implements ItemWriter { + + private final MvProductRankWeeklyJpaRepository weeklyRepository; + + @Value("#{jobParameters['targetDate']}") + private String targetDate; + + @Override + public void write(Chunk chunk) { + String yearWeek = LocalDate.parse(targetDate, DateTimeFormatter.BASIC_ISO_DATE) + .with(DayOfWeek.MONDAY) + .format(DateTimeFormatter.BASIC_ISO_DATE); + + for (RankedProductDto item : chunk.getItems()) { + weeklyRepository.findById(item.getProductId()) + .ifPresentOrElse( + existing -> existing.update(item.getScore(), yearWeek, 0), + () -> weeklyRepository.save(new MvProductRankWeeklyEntity(item.getProductId(), item.getScore(), yearWeek, 0)) + ); + } + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyRankAssignTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyRankAssignTasklet.java new file mode 100644 index 000000000..15df01750 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyRankAssignTasklet.java @@ -0,0 +1,57 @@ +package com.loopers.batch.job.ranking.step; + +import com.loopers.batch.job.ranking.RankingWeeklyJobConfig; +import com.loopers.infrastructure.ranking.MvProductRankWeeklyEntity; +import com.loopers.infrastructure.ranking.MvProductRankWeeklyJpaRepository; +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +@Slf4j +@StepScope +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = RankingWeeklyJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Component +public class WeeklyRankAssignTasklet implements Tasklet { + + private static final int TOP_RANK_LIMIT = 100; + + private final MvProductRankWeeklyJpaRepository weeklyRepository; + + @Value("#{jobParameters['targetDate']}") + private String targetDate; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + String yearWeek = LocalDate.parse(targetDate, DateTimeFormatter.BASIC_ISO_DATE) + .with(DayOfWeek.MONDAY) + .format(DateTimeFormatter.BASIC_ISO_DATE); + + weeklyRepository.deleteAllByYearWeekNot(yearWeek); + + List ranked = weeklyRepository.findAllByYearWeekOrderByScoreDesc(yearWeek); + + for (int i = 0; i < Math.min(ranked.size(), TOP_RANK_LIMIT); i++) { + ranked.get(i).update(ranked.get(i).getScore(), yearWeek, i + 1); + } + + if (ranked.size() > TOP_RANK_LIMIT) { + weeklyRepository.deleteAll(ranked.subList(TOP_RANK_LIMIT, ranked.size())); + log.info("주간 랭킹 {}위 이후 {}건 삭제", TOP_RANK_LIMIT, ranked.size() - TOP_RANK_LIMIT); + } + + log.info("주간 랭킹 확정: {}건 (yearWeek={})", Math.min(ranked.size(), TOP_RANK_LIMIT), yearWeek); + return RepeatStatus.FINISHED; + } +} From fa3c0bb2a21a0e7b322b650d76685bbd9f23278a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Wed, 15 Apr 2026 15:50:11 +0900 Subject: [PATCH 15/21] =?UTF-8?q?refactor=20:=20=EC=9B=94=EA=B0=84=20reade?= =?UTF-8?q?r=20/=20processor=20/=20writer=EB=A5=BC=20=EA=B0=81=EA=B0=81?= =?UTF-8?q?=EC=9D=98=20=ED=81=B4=EB=9E=98=EC=8A=A4=EB=A1=9C=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../job/ranking/RankingMonthlyJobConfig.java | 130 ++---------------- .../step/MonthlyMvRankingItemWriter.java | 40 ++++++ .../step/MonthlyRankAssignTasklet.java | 55 ++++++++ 3 files changed, 110 insertions(+), 115 deletions(-) create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyMvRankingItemWriter.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankAssignTasklet.java diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingMonthlyJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingMonthlyJobConfig.java index 804a00649..68dfdd423 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingMonthlyJobConfig.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingMonthlyJobConfig.java @@ -2,53 +2,41 @@ import com.loopers.batch.listener.JobListener; import com.loopers.batch.listener.StepMonitorListener; +import com.loopers.batch.job.ranking.step.MonthlyMvRankingItemWriter; +import com.loopers.batch.job.ranking.step.MonthlyRankAssignTasklet; +import com.loopers.batch.job.ranking.step.ProductMetricsItemReader; +import com.loopers.batch.job.ranking.step.RankedProductDto; +import com.loopers.batch.job.ranking.step.RankingItemProcessor; import com.loopers.infrastructure.metrics.ProductMetricsAggregatedDto; -import com.loopers.infrastructure.ranking.MvProductRankMonthlyEntity; -import com.loopers.infrastructure.ranking.MvProductRankMonthlyJpaRepository; -import java.sql.Date; -import java.time.LocalDate; import java.time.format.DateTimeFormatter; -import java.util.List; -import java.util.Map; -import javax.sql.DataSource; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; 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.core.step.tasklet.Tasklet; -import org.springframework.batch.item.ItemProcessor; -import org.springframework.batch.item.ItemWriter; -import org.springframework.batch.item.database.JdbcPagingItemReader; -import org.springframework.batch.item.database.Order; -import org.springframework.batch.item.database.builder.JdbcPagingItemReaderBuilder; -import org.springframework.batch.repeat.RepeatStatus; -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; -@Slf4j @ConditionalOnProperty(name = "spring.batch.job.name", havingValue = RankingMonthlyJobConfig.JOB_NAME) @RequiredArgsConstructor @Configuration public class RankingMonthlyJobConfig { public static final String JOB_NAME = "rankingMonthlyJob"; + public static final DateTimeFormatter YEAR_MONTH_FORMAT = DateTimeFormatter.ofPattern("yyyyMM"); private static final int CHUNK_SIZE = 100; - private static final int TOP_RANK_LIMIT = 100; - private static final DateTimeFormatter YEAR_MONTH_FORMAT = DateTimeFormatter.ofPattern("yyyyMM"); private final JobRepository jobRepository; private final JobListener jobListener; private final StepMonitorListener stepMonitorListener; private final PlatformTransactionManager transactionManager; - private final DataSource dataSource; - private final MvProductRankMonthlyJpaRepository monthlyRepository; + private final ProductMetricsItemReader productMetricsItemReader; + private final RankingItemProcessor rankingItemProcessor; + private final MonthlyMvRankingItemWriter monthlyMvRankingItemWriter; + private final MonthlyRankAssignTasklet monthlyRankAssignTasklet; @Bean(JOB_NAME) public Job rankingMonthlyJob() { @@ -62,10 +50,10 @@ public Job rankingMonthlyJob() { @Bean("monthlyScoreCalculationStep") public Step monthlyScoreCalculationStep() { return new StepBuilder("monthlyScoreCalculationStep", jobRepository) - .chunk(CHUNK_SIZE, transactionManager) - .reader(monthlyProductMetricsReader(null)) - .processor(monthlyRankingProcessor(null)) - .writer(monthlyMvWriter(null)) + .chunk(CHUNK_SIZE, transactionManager) + .reader(productMetricsItemReader.reader(null, null, null)) + .processor(rankingItemProcessor) + .writer(monthlyMvRankingItemWriter) .listener(stepMonitorListener) .build(); } @@ -73,96 +61,8 @@ public Step monthlyScoreCalculationStep() { @Bean("monthlyRankAssignStep") public Step monthlyRankAssignStep() { return new StepBuilder("monthlyRankAssignStep", jobRepository) - .tasklet(monthlyRankAssignTasklet(null), transactionManager) + .tasklet(monthlyRankAssignTasklet, transactionManager) .listener(stepMonitorListener) .build(); } - - @StepScope - @Bean("monthlyProductMetricsReader") - public JdbcPagingItemReader monthlyProductMetricsReader( - @Value("#{jobParameters['targetDate']}") String targetDate) { - LocalDate target = LocalDate.parse(targetDate, DateTimeFormatter.BASIC_ISO_DATE); - LocalDate startDate = target.withDayOfMonth(1); - LocalDate endDate = target.withDayOfMonth(target.lengthOfMonth()); - - log.info("월간 Reader 기간: {} ~ {}", startDate, endDate); - - return new JdbcPagingItemReaderBuilder() - .name("monthlyProductMetricsReader") - .dataSource(dataSource) - .selectClause("product_id, SUM(view_count) AS total_view_count, SUM(like_count) AS total_like_count, SUM(total_quantity) AS total_quantity") - .fromClause("product_metrics") - .whereClause("metrics_date BETWEEN :startDate AND :endDate") - .groupClause("product_id") - .sortKeys(Map.of("product_id", Order.ASCENDING)) - .parameterValues(Map.of("startDate", Date.valueOf(startDate), "endDate", Date.valueOf(endDate))) - .rowMapper((rs, rowNum) -> new ProductMetricsAggregatedDto( - rs.getLong("product_id"), - rs.getLong("total_view_count"), - rs.getLong("total_like_count"), - rs.getLong("total_quantity") - )) - .pageSize(CHUNK_SIZE) - .build(); - } - - @StepScope - @Bean("monthlyRankingProcessor") - public ItemProcessor monthlyRankingProcessor( - @Value("#{jobParameters['targetDate']}") String targetDate) { - LocalDate target = LocalDate.parse(targetDate, DateTimeFormatter.BASIC_ISO_DATE); - String yearMonth = target.format(YEAR_MONTH_FORMAT); - - return dto -> { - double score = 0.1 * dto.getTotalViewCount() - + 0.2 * dto.getTotalLikeCount() - + 0.7 * Math.log1p(dto.getTotalQuantity()); - return new MvProductRankMonthlyEntity(dto.getProductId(), score, yearMonth, 0); - }; - } - - @StepScope - @Bean("monthlyMvWriter") - public ItemWriter monthlyMvWriter( - @Value("#{jobParameters['targetDate']}") String targetDate) { - LocalDate target = LocalDate.parse(targetDate, DateTimeFormatter.BASIC_ISO_DATE); - String yearMonth = target.format(YEAR_MONTH_FORMAT); - - return chunk -> { - for (MvProductRankMonthlyEntity item : chunk.getItems()) { - monthlyRepository.findById(item.getProductId()) - .ifPresentOrElse( - existing -> existing.update(item.getScore(), yearMonth, 0), - () -> monthlyRepository.save(item) - ); - } - }; - } - - @StepScope - @Bean("monthlyRankAssignTasklet") - public Tasklet monthlyRankAssignTasklet( - @Value("#{jobParameters['targetDate']}") String targetDate) { - LocalDate target = LocalDate.parse(targetDate, DateTimeFormatter.BASIC_ISO_DATE); - String yearMonth = target.format(YEAR_MONTH_FORMAT); - - return (contribution, chunkContext) -> { - monthlyRepository.deleteAllByYearMonthNot(yearMonth); - - List ranked = monthlyRepository.findAllByYearMonthOrderByScoreDesc(yearMonth); - - for (int i = 0; i < Math.min(ranked.size(), TOP_RANK_LIMIT); i++) { - ranked.get(i).update(ranked.get(i).getScore(), yearMonth, i + 1); - } - - if (ranked.size() > TOP_RANK_LIMIT) { - monthlyRepository.deleteAll(ranked.subList(TOP_RANK_LIMIT, ranked.size())); - log.info("월간 랭킹 {}위 이후 {}건 삭제", TOP_RANK_LIMIT, ranked.size() - TOP_RANK_LIMIT); - } - - log.info("월간 랭킹 확정: {}건 (yearMonth={})", Math.min(ranked.size(), TOP_RANK_LIMIT), yearMonth); - return RepeatStatus.FINISHED; - }; - } } diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyMvRankingItemWriter.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyMvRankingItemWriter.java new file mode 100644 index 000000000..003fce3f4 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyMvRankingItemWriter.java @@ -0,0 +1,40 @@ +package com.loopers.batch.job.ranking.step; + +import com.loopers.batch.job.ranking.RankingMonthlyJobConfig; +import com.loopers.infrastructure.ranking.MvProductRankMonthlyEntity; +import com.loopers.infrastructure.ranking.MvProductRankMonthlyJpaRepository; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.item.Chunk; +import org.springframework.batch.item.ItemWriter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +@StepScope +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = RankingMonthlyJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Component +public class MonthlyMvRankingItemWriter implements ItemWriter { + + private final MvProductRankMonthlyJpaRepository monthlyRepository; + + @Value("#{jobParameters['targetDate']}") + private String targetDate; + + @Override + public void write(Chunk chunk) { + String yearMonth = LocalDate.parse(targetDate, DateTimeFormatter.BASIC_ISO_DATE) + .format(RankingMonthlyJobConfig.YEAR_MONTH_FORMAT); + + for (RankedProductDto item : chunk.getItems()) { + monthlyRepository.findById(item.getProductId()) + .ifPresentOrElse( + existing -> existing.update(item.getScore(), yearMonth, 0), + () -> monthlyRepository.save(new MvProductRankMonthlyEntity(item.getProductId(), item.getScore(), yearMonth, 0)) + ); + } + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankAssignTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankAssignTasklet.java new file mode 100644 index 000000000..744a57c98 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankAssignTasklet.java @@ -0,0 +1,55 @@ +package com.loopers.batch.job.ranking.step; + +import com.loopers.batch.job.ranking.RankingMonthlyJobConfig; +import com.loopers.infrastructure.ranking.MvProductRankMonthlyEntity; +import com.loopers.infrastructure.ranking.MvProductRankMonthlyJpaRepository; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +@Slf4j +@StepScope +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = RankingMonthlyJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Component +public class MonthlyRankAssignTasklet implements Tasklet { + + private static final int TOP_RANK_LIMIT = 100; + + private final MvProductRankMonthlyJpaRepository monthlyRepository; + + @Value("#{jobParameters['targetDate']}") + private String targetDate; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + String yearMonth = LocalDate.parse(targetDate, DateTimeFormatter.BASIC_ISO_DATE) + .format(RankingMonthlyJobConfig.YEAR_MONTH_FORMAT); + + monthlyRepository.deleteAllByYearMonthNot(yearMonth); + + List ranked = monthlyRepository.findAllByYearMonthOrderByScoreDesc(yearMonth); + + for (int i = 0; i < Math.min(ranked.size(), TOP_RANK_LIMIT); i++) { + ranked.get(i).update(ranked.get(i).getScore(), yearMonth, i + 1); + } + + if (ranked.size() > TOP_RANK_LIMIT) { + monthlyRepository.deleteAll(ranked.subList(TOP_RANK_LIMIT, ranked.size())); + log.info("월간 랭킹 {}위 이후 {}건 삭제", TOP_RANK_LIMIT, ranked.size() - TOP_RANK_LIMIT); + } + + log.info("월간 랭킹 확정: {}건 (yearMonth={})", Math.min(ranked.size(), TOP_RANK_LIMIT), yearMonth); + return RepeatStatus.FINISHED; + } +} From 35d4c17fa1d238c296c9f02feae6c54b82bd4314 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Wed, 15 Apr 2026 15:55:07 +0900 Subject: [PATCH 16/21] =?UTF-8?q?docs=20:=20md=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/10-batch-ranking.md | 33 ++++++++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/docs/10-batch-ranking.md b/docs/10-batch-ranking.md index 69354730a..a7045eb35 100644 --- a/docs/10-batch-ranking.md +++ b/docs/10-batch-ranking.md @@ -202,6 +202,22 @@ log1p(3) + log1p(4) ≠ log1p(7) **결론** `product_metrics`(일별 원본)에서 weekly, monthly가 각자 독립적으로 집계하는 구조(fan-out)를 채택했다. 단일 원천에서 파생되므로 재집계가 단순하고 스케줄러 간 의존성이 없다. +### product_metrics Reader에 JPA 대신 JDBC를 사용한 이유 + +`commerce-batch`는 `commerce-streamer`를 의존하지 않는다. `ProductMetricsEntity`는 `commerce-streamer` 모듈에 정의되어 있어 batch 모듈의 클래스패스에 존재하지 않는다. + +JPA(JPQL)는 Entity 클래스를 직접 참조하기 때문에 클래스가 없으면 컴파일 에러가 발생한다. +``` +FROM ProductMetricsEntity e -- ProductMetricsEntity.class가 있어야 함 +``` + +JDBC는 테이블 이름을 문자열로 참조하므로 Java 클래스 없이 DB 연결만으로 읽기가 가능하다. +```sql +FROM product_metrics -- 테이블명 문자열, 클래스 불필요 +``` + +batch 모듈에 `ProductMetricsEntity`를 중복 정의하는 방법도 있으나, 같은 Entity를 두 모듈에서 관리하면 스키마 변경 시 동기화 누락 위험이 있다. JDBC를 사용하면 이 문제를 피할 수 있다. + --- ## 6. 구현 체크리스트 @@ -222,11 +238,18 @@ log1p(3) + log1p(4) ≠ log1p(7) ### Phase 3. Spring Batch Job -- [ ] `RankingWeeklyJobConfig` — Chunk-Oriented Job 구현 -- [ ] `RankingMonthlyJobConfig` — Chunk-Oriented Job 구현 -- [ ] `ProductMetricsItemReader` — 날짜 범위 기반 페이징 Reader -- [ ] `RankingItemProcessor` — score 계산 및 순위 부여 -- [ ] `MvRankingItemWriter` — MV 테이블 upsert Writer +- [x] `RankingWeeklyJobConfig` — Chunk-Oriented Job 구현 (Step1: 점수 계산, Step2: 순위 확정) +- [x] `RankingMonthlyJobConfig` — Chunk-Oriented Job 구현 (Step1: 점수 계산, Step2: 순위 확정) +- [x] `ProductMetricsItemReader` — 날짜 범위 기반 페이징 Reader (period 파라미터로 weekly/monthly 날짜 범위 계산) +- [x] `RankingItemProcessor` — score 계산 (`score = 0.1*view + 0.2*like + 0.7*log1p(qty)`), 출력: `RankedProductDto` +- [x] `WeeklyMvRankingItemWriter` — weekly MV 테이블 upsert Writer +- [x] `MonthlyMvRankingItemWriter` — monthly MV 테이블 upsert Writer +- [x] `WeeklyRankAssignTasklet` — weekly 순위 부여 및 TOP 100 정리 (원래 계획의 `MvRankingItemWriter`에서 분리) +- [x] `MonthlyRankAssignTasklet` — monthly 순위 부여 및 TOP 100 정리 (원래 계획의 `MvRankingItemWriter`에서 분리) +- [x] `RankedProductDto` — Processor 출력 DTO (productId, score) +- [x] `ProductMetricsAggregatedDto` — Reader 출력 DTO (productId, 집계된 view/like/quantity) +- [x] `MvProductRankWeeklyJpaRepository` — weekly MV 조회/저장 Repository +- [x] `MvProductRankMonthlyJpaRepository` — monthly MV 조회/저장 Repository ### Phase 4. Ranking API 확장 From 42d201a2fb44400b3dcea1f31b5c3e5212bbc277 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Thu, 16 Apr 2026 09:39:51 +0900 Subject: [PATCH 17/21] =?UTF-8?q?fix:=20JobConfig=EC=97=90=EC=84=9C=20@Ste?= =?UTF-8?q?pScope=20Reader=20=EB=B9=88=20=EC=A7=81=EC=A0=91=20=EC=A3=BC?= =?UTF-8?q?=EC=9E=85=EC=9C=BC=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/batch/job/ranking/RankingMonthlyJobConfig.java | 6 +++--- .../loopers/batch/job/ranking/RankingWeeklyJobConfig.java | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingMonthlyJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingMonthlyJobConfig.java index 68dfdd423..a1eeac712 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingMonthlyJobConfig.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingMonthlyJobConfig.java @@ -4,7 +4,6 @@ import com.loopers.batch.listener.StepMonitorListener; import com.loopers.batch.job.ranking.step.MonthlyMvRankingItemWriter; import com.loopers.batch.job.ranking.step.MonthlyRankAssignTasklet; -import com.loopers.batch.job.ranking.step.ProductMetricsItemReader; import com.loopers.batch.job.ranking.step.RankedProductDto; import com.loopers.batch.job.ranking.step.RankingItemProcessor; import com.loopers.infrastructure.metrics.ProductMetricsAggregatedDto; @@ -15,6 +14,7 @@ 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.database.JdbcPagingItemReader; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -33,7 +33,7 @@ public class RankingMonthlyJobConfig { private final JobListener jobListener; private final StepMonitorListener stepMonitorListener; private final PlatformTransactionManager transactionManager; - private final ProductMetricsItemReader productMetricsItemReader; + private final JdbcPagingItemReader productMetricsItemReader; private final RankingItemProcessor rankingItemProcessor; private final MonthlyMvRankingItemWriter monthlyMvRankingItemWriter; private final MonthlyRankAssignTasklet monthlyRankAssignTasklet; @@ -51,7 +51,7 @@ public Job rankingMonthlyJob() { public Step monthlyScoreCalculationStep() { return new StepBuilder("monthlyScoreCalculationStep", jobRepository) .chunk(CHUNK_SIZE, transactionManager) - .reader(productMetricsItemReader.reader(null, null, null)) + .reader(productMetricsItemReader) .processor(rankingItemProcessor) .writer(monthlyMvRankingItemWriter) .listener(stepMonitorListener) diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingWeeklyJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingWeeklyJobConfig.java index 4d233189e..b95d4e6d8 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingWeeklyJobConfig.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingWeeklyJobConfig.java @@ -2,7 +2,6 @@ import com.loopers.batch.listener.JobListener; import com.loopers.batch.listener.StepMonitorListener; -import com.loopers.batch.job.ranking.step.ProductMetricsItemReader; import com.loopers.batch.job.ranking.step.RankedProductDto; import com.loopers.batch.job.ranking.step.RankingItemProcessor; import com.loopers.batch.job.ranking.step.WeeklyMvRankingItemWriter; @@ -14,6 +13,7 @@ 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.database.JdbcPagingItemReader; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -31,7 +31,7 @@ public class RankingWeeklyJobConfig { private final JobListener jobListener; private final StepMonitorListener stepMonitorListener; private final PlatformTransactionManager transactionManager; - private final ProductMetricsItemReader productMetricsItemReader; + private final JdbcPagingItemReader productMetricsItemReader; private final RankingItemProcessor rankingItemProcessor; private final WeeklyMvRankingItemWriter weeklyMvRankingItemWriter; private final WeeklyRankAssignTasklet weeklyRankAssignTasklet; @@ -49,7 +49,7 @@ public Job rankingWeeklyJob() { public Step weeklyScoreCalculationStep() { return new StepBuilder("weeklyScoreCalculationStep", jobRepository) .chunk(CHUNK_SIZE, transactionManager) - .reader(productMetricsItemReader.reader(null, null, null)) + .reader(productMetricsItemReader) .processor(rankingItemProcessor) .writer(weeklyMvRankingItemWriter) .listener(stepMonitorListener) From 2f311e50b18e991e6d36859ed3ae736a0c68640f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Thu, 16 Apr 2026 11:35:21 +0900 Subject: [PATCH 18/21] =?UTF-8?q?feat=20:=20=EC=A3=BC=EA=B0=84/=EC=9B=94?= =?UTF-8?q?=EA=B0=84=20=EB=9E=AD=ED=82=B9=20=EC=9D=BD=EA=B8=B0=EC=9A=A9=20?= =?UTF-8?q?mv=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ranking/MvProductRankMonthlyEntity.java | 81 +++++++++++++++++++ .../MvProductRankMonthlyJpaRepository.java | 10 +++ .../ranking/MvProductRankWeeklyEntity.java | 81 +++++++++++++++++++ .../MvProductRankWeeklyJpaRepository.java | 10 +++ 4 files changed, 182 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyEntity.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyEntity.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyEntity.java new file mode 100644 index 000000000..ec6f3a5ee --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyEntity.java @@ -0,0 +1,81 @@ +package com.loopers.infrastructure.ranking; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Comment; + +@Entity +@Table(name = "mv_product_rank_monthly") +@NoArgsConstructor +public class MvProductRankMonthlyEntity { + + @Id + @Comment("상품 id (ref)") + @Column(name = "product_id", nullable = false, updatable = false) + private Long productId; + + @Comment("집계 점수") + @Column(name = "score", nullable = false) + private double score; + + @Comment("집계 기준 월 (e.g. 202604)") + @Column(name = "year_month", nullable = false, length = 6) + private String yearMonth; + + @Comment("랭킹 순위") + @Column(name = "product_rank", nullable = false) + private int productRank; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + public MvProductRankMonthlyEntity(Long productId, double score, String yearMonth, int productRank) { + this.productId = productId; + this.score = score; + this.yearMonth = yearMonth; + this.productRank = productRank; + } + + public void update(double score, String yearMonth, int productRank) { + this.score = score; + this.yearMonth = yearMonth; + this.productRank = productRank; + } + + @PrePersist + private void prePersist() { + LocalDateTime now = LocalDateTime.now(); + this.createdAt = now; + this.updatedAt = now; + } + + @PreUpdate + private void preUpdate() { + this.updatedAt = LocalDateTime.now(); + } + + public Long getProductId() { + return productId; + } + + public double getScore() { + return score; + } + + public String getYearMonth() { + return yearMonth; + } + + public int getProductRank() { + return productRank; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java new file mode 100644 index 000000000..ae840d667 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java @@ -0,0 +1,10 @@ +package com.loopers.infrastructure.ranking; + +import java.util.List; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MvProductRankMonthlyJpaRepository extends JpaRepository { + + List findAllByYearMonthOrderByProductRankAsc(String yearMonth, Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyEntity.java new file mode 100644 index 000000000..4cf28929c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyEntity.java @@ -0,0 +1,81 @@ +package com.loopers.infrastructure.ranking; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Comment; + +@Entity +@Table(name = "mv_product_rank_weekly") +@NoArgsConstructor +public class MvProductRankWeeklyEntity { + + @Id + @Comment("상품 id (ref)") + @Column(name = "product_id", nullable = false, updatable = false) + private Long productId; + + @Comment("집계 점수") + @Column(name = "score", nullable = false) + private double score; + + @Comment("집계 기준 주 (해당 주 월요일, e.g. 20260414)") + @Column(name = "year_week", nullable = false, length = 8) + private String yearWeek; + + @Comment("랭킹 순위") + @Column(name = "product_rank", nullable = false) + private int productRank; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + public MvProductRankWeeklyEntity(Long productId, double score, String yearWeek, int productRank) { + this.productId = productId; + this.score = score; + this.yearWeek = yearWeek; + this.productRank = productRank; + } + + public void update(double score, String yearWeek, int productRank) { + this.score = score; + this.yearWeek = yearWeek; + this.productRank = productRank; + } + + @PrePersist + private void prePersist() { + LocalDateTime now = LocalDateTime.now(); + this.createdAt = now; + this.updatedAt = now; + } + + @PreUpdate + private void preUpdate() { + this.updatedAt = LocalDateTime.now(); + } + + public Long getProductId() { + return productId; + } + + public double getScore() { + return score; + } + + public String getYearWeek() { + return yearWeek; + } + + public int getProductRank() { + return productRank; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java new file mode 100644 index 000000000..557588ff8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java @@ -0,0 +1,10 @@ +package com.loopers.infrastructure.ranking; + +import java.util.List; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MvProductRankWeeklyJpaRepository extends JpaRepository { + + List findAllByYearWeekOrderByProductRankAsc(String yearWeek, Pageable pageable); +} From fc9047b31a9a7c0633bb3cb59888812dcf3c148f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Thu, 16 Apr 2026 12:01:36 +0900 Subject: [PATCH 19/21] =?UTF-8?q?feat=20:=20RankingRepositoryImpl=EC=97=90?= =?UTF-8?q?=20=EC=A3=BC=EA=B0=84/=EC=9B=94=EA=B0=84=20=EB=9E=AD=ED=82=B9?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20=EA=B5=AC=ED=98=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/ranking/RankingRepository.java | 2 ++ .../ranking/RankingRepositoryImpl.java | 28 ++++++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingRepository.java index e378e0e7e..177841246 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingRepository.java @@ -5,6 +5,8 @@ public interface RankingRepository { List getTopN(LocalDate date, int size, int page); + List getWeeklyTopN(LocalDate date, int size, int page); + List getMonthlyTopN(LocalDate date, int size, int page); Long getRank(Long productId, LocalDate date); void carryOver(LocalDate from, LocalDate to, double ratio); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRepositoryImpl.java index 4e36be511..f3b39f67b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRepositoryImpl.java @@ -3,6 +3,7 @@ import com.loopers.config.redis.RedisConfig; import com.loopers.domain.ranking.RankedProduct; import com.loopers.domain.ranking.RankingRepository; +import java.time.DayOfWeek; import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.util.Collections; @@ -10,6 +11,7 @@ import java.util.Set; import java.util.concurrent.TimeUnit; import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.data.domain.PageRequest; import org.springframework.data.redis.connection.zset.Aggregate; import org.springframework.data.redis.connection.zset.Weights; import org.springframework.data.redis.core.RedisTemplate; @@ -25,13 +27,19 @@ public class RankingRepositoryImpl implements RankingRepository { private final RedisTemplate redisTemplate; private final RedisTemplate masterRedisTemplate; + private final MvProductRankWeeklyJpaRepository weeklyRepository; + private final MvProductRankMonthlyJpaRepository monthlyRepository; public RankingRepositoryImpl( RedisTemplate redisTemplate, - @Qualifier(RedisConfig.REDIS_TEMPLATE_MASTER) RedisTemplate masterRedisTemplate + @Qualifier(RedisConfig.REDIS_TEMPLATE_MASTER) RedisTemplate masterRedisTemplate, + MvProductRankWeeklyJpaRepository weeklyRepository, + MvProductRankMonthlyJpaRepository monthlyRepository ) { this.redisTemplate = redisTemplate; this.masterRedisTemplate = masterRedisTemplate; + this.weeklyRepository = weeklyRepository; + this.monthlyRepository = monthlyRepository; } @Override @@ -48,6 +56,24 @@ public List getTopN(LocalDate date, int size, int page) { .toList(); } + @Override + public List getWeeklyTopN(LocalDate date, int size, int page) { + String yearWeek = date.with(DayOfWeek.MONDAY).format(DateTimeFormatter.BASIC_ISO_DATE); + return weeklyRepository.findAllByYearWeekOrderByProductRankAsc(yearWeek, PageRequest.of(page, size)) + .stream() + .map(e -> new RankedProduct(e.getProductId(), e.getScore())) + .toList(); + } + + @Override + public List getMonthlyTopN(LocalDate date, int size, int page) { + String yearMonth = date.format(DateTimeFormatter.ofPattern("yyyyMM")); + return monthlyRepository.findAllByYearMonthOrderByProductRankAsc(yearMonth, PageRequest.of(page, size)) + .stream() + .map(e -> new RankedProduct(e.getProductId(), e.getScore())) + .toList(); + } + @Override public Long getRank(Long productId, LocalDate date) { String key = KEY_PREFIX + date.format(DATE_FORMATTER); From ebaca994e325ce0972b850b78af9d8e9b4b2831d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Thu, 16 Apr 2026 12:02:07 +0900 Subject: [PATCH 20/21] =?UTF-8?q?feat=20:=20period=20=ED=8C=8C=EB=9D=BC?= =?UTF-8?q?=EB=AF=B8=ED=84=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/application/ranking/RankingFacade.java | 8 ++++++-- .../interfaces/api/ranking/RankingV1Controller.java | 3 ++- 2 files changed, 8 insertions(+), 3 deletions(-) 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 06453509d..903cf1e0c 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 @@ -23,8 +23,12 @@ public class RankingFacade { private final ProductService productService; private final BrandService brandService; - public List getRankings(LocalDate date, int size, int page) { - List rankedProducts = rankingRepository.getTopN(date, size, page); + public List getRankings(LocalDate date, String period, int size, int page) { + List rankedProducts = switch (period) { + case "weekly" -> rankingRepository.getWeeklyTopN(date, size, page); + case "monthly" -> rankingRepository.getMonthlyTopN(date, size, page); + default -> rankingRepository.getTopN(date, size, page); + }; if (rankedProducts.isEmpty()) { return List.of(); } 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 a7e9ee451..14b355f12 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 @@ -24,11 +24,12 @@ public class RankingV1Controller { @GetMapping public ApiResponse> getRankings( @RequestParam String date, + @RequestParam(defaultValue = "daily") String period, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size ) { LocalDate localDate = LocalDate.parse(date, DATE_FORMATTER); - List rankings = rankingFacade.getRankings(localDate, size, page); + List rankings = rankingFacade.getRankings(localDate, period, size, page); List response = rankings.stream() .map(RankingV1Dto.RankingResponse::from) .toList(); From da56e8b6814b4f8b6228308e91cca468c8ee97fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Thu, 16 Apr 2026 16:02:51 +0900 Subject: [PATCH 21/21] =?UTF-8?q?docs=20:=20md=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/10-batch-ranking.md | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/docs/10-batch-ranking.md b/docs/10-batch-ranking.md index a7045eb35..2685ec225 100644 --- a/docs/10-batch-ranking.md +++ b/docs/10-batch-ranking.md @@ -202,6 +202,26 @@ log1p(3) + log1p(4) ≠ log1p(7) **결론** `product_metrics`(일별 원본)에서 weekly, monthly가 각자 독립적으로 집계하는 구조(fan-out)를 채택했다. 단일 원천에서 파생되므로 재집계가 단순하고 스케줄러 간 의존성이 없다. +### MV Entity를 modules/jpa 대신 각 모듈에 별도 정의한 이유 + +`commerce-batch`와 `commerce-api`가 같은 MV 테이블을 사용하므로, Entity를 공유 모듈에 두는 방안을 고려했다. + +**modules/jpa에 넣지 않은 이유** +`modules/jpa`는 JPA 설정(DataSource, QueryDSL)과 공통 기반(BaseEntity)만 담당하는 모듈이다. 비즈니스 도메인 Entity를 넣으면 설정 모듈과 도메인 모듈의 책임이 섞인다. + +**결론** +`commerce-batch`(쓰기용)와 `commerce-api`(읽기용) 각각에 Entity를 별도 정의했다. 같은 테이블을 가리키지만 사용 목적이 다르고, 두 모듈이 서로 의존하지 않는 구조를 유지하기 위해서다. 스키마 변경 시 두 곳을 모두 수정해야 한다는 단점이 있다. + +### RankingRepository 인터페이스에 period 파라미터를 통합하지 않은 이유 + +`getTopN(LocalDate date, String period, int size, int page)` 형태로 통합하는 방안을 고려했다. + +**통합하지 않은 이유** +기존 `RankingRepositoryImpl`은 Redis 기반으로 daily만 처리한다. `period` 파라미터를 받으면 Redis 구현체가 weekly/monthly 케이스를 처리해야 하는데, Redis는 daily 전용이라 구현체 내에서 분기가 어색해진다. + +**결론** +`getTopN`(daily), `getWeeklyTopN`, `getMonthlyTopN`으로 메서드를 분리했다. 각 메서드가 명확한 저장소(Redis / MV 테이블)와 1:1 대응되어 구현체의 책임이 명확해진다. + ### product_metrics Reader에 JPA 대신 JDBC를 사용한 이유 `commerce-batch`는 `commerce-streamer`를 의존하지 않는다. `ProductMetricsEntity`는 `commerce-streamer` 모듈에 정의되어 있어 batch 모듈의 클래스패스에 존재하지 않는다. @@ -253,6 +273,6 @@ batch 모듈에 `ProductMetricsEntity`를 중복 정의하는 방법도 있으 ### Phase 4. Ranking API 확장 -- [ ] `RankingV1Controller` — `period` 파라미터 추가 -- [ ] `RankingFacade` — period별 분기 처리 -- [ ] `RankingRepository` — 주간/월간 MV 조회 메서드 추가 +- [x] `RankingV1Controller` — `period` 파라미터 추가 +- [x] `RankingFacade` — period별 분기 처리 +- [x] `RankingRepository` — 주간/월간 MV 조회 메서드 추가