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