diff --git a/README.md b/README.md index 04950f29d..609d4e742 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,499 @@ -# Loopers Template (Spring + Java) -Loopers 에서 제공하는 스프링 자바 템플릿 프로젝트입니다. +# E-Commerce Platform with Event-Driven Architecture + +이벤트 기반 아키텍처를 활용한 프로덕션급 이커머스 플랫폼입니다. 트랜잭션 처리와 실시간 분석을 분리하여 일관성과 확장성을 동시에 확보했습니다. + +## 📋 목차 + +- [프로젝트 개요](#프로젝트-개요) +- [주요 기능](#주요-기능) +- [아키텍처](#아키텍처) +- [핵심 설계 결정](#핵심-설계-결정) +- [기술 스택](#기술-스택) +- [시작하기](#시작하기) +- [프로젝트 구조](#프로젝트-구조) + +--- + +## 프로젝트 개요 + +### 시스템 구성 + +``` +┌─────────────────┐ ┌──────────────────────┐ +│ commerce-api │────────>│ commerce-collector │ +│ (트랜잭션 처리) │ Kafka │ (메트릭 수집/집계) │ +└─────────────────┘ └──────────────────────┘ + │ │ + │ │ + ▼ ▼ + MySQL (OLTP) MySQL (OLAP) + Redis Cache Spring Batch +``` + +### 주요 특징 + +- ✅ **높은 안정성**: Circuit Breaker + Retry 패턴으로 외부 시스템 장애 격리 +- ✅ **확장 가능**: CQRS 패턴으로 읽기/쓰기 독립적 스케일링 +- ✅ **데이터 정합성**: 멱등성 보장 및 보상 트랜잭션으로 일관성 유지 +- ✅ **고성능**: Redis 캐싱, 배치 처리, 인덱스 최적화 + +--- + +## 주요 기능 + +### commerce-api (트랜잭션 서비스) + +- **주문 관리**: 주문 생성, 조회, 취소 +- **결제 처리**: + - 포인트 결제 (동기) + - 카드 결제 (비동기, PG 연동) + - 결제 실패 시 자동 보상 트랜잭션 +- **재고 관리**: 실시간 재고 차감 및 복구 +- **쿠폰 시스템**: 할인 정책 적용 (금액/비율 할인) +- **사용자 포인트**: 적립 및 차감 + +### commerce-collector (분석 서비스) + +- **실시간 메트릭 수집**: Kafka 이벤트 소비 및 배치 처리 +- **다층 집계**: + - 일별 집계 (자정 실행) + - 주간 집계 (Spring Batch) + - 월간 집계 (Spring Batch) +- **랭킹 API**: 주간/월간 인기 상품 랭킹 제공 +- **이벤트 멱등성**: EventHandled 테이블로 중복 처리 방지 + +--- + +## 아키텍처 + +### 계층형 아키텍처 (DDD) + +``` +┌─────────────────────────────────────────┐ +│ Presentation Layer (interfaces/api) │ ← Controllers, DTOs +├─────────────────────────────────────────┤ +│ Application Layer (application) │ ← Facades, Orchestration +├─────────────────────────────────────────┤ +│ Domain Layer (domain) │ ← Entities, Services, VOs +├─────────────────────────────────────────┤ +│ Infrastructure Layer (infrastructure) │ ← JPA, Feign, Kafka +└─────────────────────────────────────────┘ + +의존성 방향: Presentation → Application → Domain ← Infrastructure +``` + +**핵심 원칙**: +- 각 계층은 하위 계층만 의존 +- Domain은 외부 의존성 없음 (순수 비즈니스 로직) +- Infrastructure가 Domain 인터페이스 구현 (Dependency Inversion) + +### 도메인 주도 설계 (DDD) + +**Aggregate 경계**: +- Order Aggregate: Order + Payment (동일 트랜잭션 보장) +- Product Aggregate: Product + Stock +- User Aggregate: User + Point + +**Value Objects**: +- `Money`: 금액 계산 로직 캡슐화, 불변성 보장 +- `Stock`: 재고 차감/복구 로직, 음수 방지 + +**Repository 패턴**: +```java +// Domain Layer +public interface PaymentRepository { + Payment save(Payment payment); + Optional findById(String paymentId); +} + +// Infrastructure Layer +@Repository +public class PaymentRepositoryImpl implements PaymentRepository { + private final PaymentJpaRepository jpaRepository; + // JPA 구현체에 위임 +} +``` + +--- + +## 핵심 설계 결정 + +### 1. 트랜잭션 분리 전략 + +**문제**: PG 호출 실패 시 결제 기록이 사라져 복구 불가능 + +**해결**: `@Transactional(propagation = REQUIRES_NEW)` 사용 + +```java +@Transactional(propagation = Propagation.REQUIRES_NEW) +public Payment processCardPayment(Order order) { + Payment payment = Payment.createPending(order); + paymentRepository.save(payment); // 독립 트랜잭션으로 확실히 저장 + + try { + pgClient.requestPayment(payment); + } catch (Exception e) { + // PG 실패해도 Payment는 이미 저장됨 → 스케줄러가 재처리 + } +} +``` + +**트레이드오프**: +- ✅ 결제 기록 누락 방지, 자동 복구 가능 +- ⚠️ 트랜잭션 관리 복잡도 증가, 즉시 실패 불가 + +### 2. Circuit Breaker + Retry 패턴 + +**문제**: PG 서비스 장애가 전체 시스템에 전파 + +**해결**: Resilience4j 적용 + +```yaml +resilience4j: + circuitbreaker: + configs: + default: + failureRateThreshold: 50 # 50% 실패율에서 열림 + slidingWindowSize: 10 # 최근 10회 기준 + retry: + configs: + default: + maxAttempts: 3 # 3회 재시도 + waitDuration: 1s # 지수 백오프 +``` + +**트레이드오프**: +- ✅ 장애 전파 차단, 스레드 풀 보호, 자동 복구 +- ⚠️ Circuit 열린 동안 모든 요청 실패, 사용자 경험 저하 가능 + +### 3. CQRS-lite: 읽기/쓰기 분리 + +**문제**: 실시간 메트릭 조회가 트랜잭션 쓰기와 경합 + +**해결**: Kafka 기반 이벤트 분리 -## Getting Started -현재 프로젝트 안정성 및 유지보수성 등을 위해 아래와 같은 장치를 운용하고 있습니다. 이에 아래 명령어를 통해 프로젝트의 기반을 설치해주세요. -### Environment -`local` 프로필로 동작할 수 있도록, 필요 인프라를 `docker-compose` 로 제공합니다. -```shell -docker-compose -f ./docker/infra-compose.yml up ``` -### Monitoring -`local` 환경에서 모니터링을 할 수 있도록, `docker-compose` 를 통해 `prometheus` 와 `grafana` 를 제공합니다. +commerce-api commerce-collector + │ │ + ├─ 주문 생성 │ + ├─ OrderCreated 발행 ──────> │ + │ ├─ 배치 소비 (100개씩) + │ ├─ 메트릭 업데이트 + │ └─ EventHandled 기록 +``` + +**배치 처리로 N+1 방지**: +```java +// 1. 한 번의 쿼리로 처리된 이벤트 조회 +Set handledEventIds = + eventHandledRepository.findAlreadyHandled(eventIds); -애플리케이션 실행 이후, **http://localhost:3000** 로 접속해, admin/admin 계정으로 로그인하여 확인하실 수 있습니다. -```shell -docker-compose -f ./docker/monitoring-compose.yml up +// 2. 미처리 이벤트만 필터링 +List unprocessed = events.stream() + .filter(e -> !handledEventIds.contains(e.getId())) + .collect(toList()); ``` -## About Multi-Module Project -본 프로젝트는 멀티 모듈 프로젝트로 구성되어 있습니다. 각 모듈의 위계 및 역할을 분명히 하고, 아래와 같은 규칙을 적용합니다. +**트레이드오프**: +- ✅ 트랜잭션 성능 영향 없음, 독립적 스케일링 +- ⚠️ 데이터 지연 (수 초), Kafka 운영 복잡도 증가 + +### 4. Spring Batch 다층 집계 + +**문제**: 주간/월간 랭킹 조회 시 일별 데이터 풀스캔 -- apps : 각 모듈은 실행가능한 **SpringBootApplication** 을 의미합니다. -- modules : 특정 구현이나 도메인에 의존적이지 않고, reusable 한 configuration 을 원칙으로 합니다. -- supports : logging, monitoring 과 같이 부가적인 기능을 지원하는 add-on 모듈입니다. +**해결**: 사전 집계 테이블 구축 + +``` +ProductMetrics (실시간) + ↓ 이벤트마다 업데이트 +ProductMetricsDaily (자정 집계) + ↓ 주간 배치 (매주 월요일) +ProductMetricsWeekly + ↓ 월간 배치 (매월 1일) +ProductMetricsMonthly +``` + +**Spring Batch 구성**: +```java +@Bean +public Step aggregateWeeklyMetricsStep() { + return stepBuilder + .chunk(100) // 100개씩 처리 + .reader(dailyMetricsReader()) + .processor(weeklyAggregationProcessor()) + .writer(weeklyMetricsWriter()) + .build(); +} +``` + +**성능 개선**: +- 주간 랭킹: O(n*7) → O(1) +- 월간 랭킹: 약 30배 쿼리 감소 + +**트레이드오프**: +- ✅ 조회 성능 밀리초 단위, DB 부하 최소화 +- ⚠️ 저장소 비용 3배, 실시간성 부족 + +### 5. 멱등성 보장 + +**문제**: Kafka 중복 메시지로 이중 카운팅 + +**해결**: EventHandled 테이블 + 원자적 처리 + +```java +@Transactional +public void handleEvents(List events) { + // 1. 중복 체크 + Set handledIds = findAlreadyHandled(events); + List unprocessed = filterUnprocessed(events, handledIds); + + // 2. 메트릭 업데이트 + updateMetrics(unprocessed); + + // 3. 처리 완료 기록 (동일 트랜잭션) + markAsHandled(unprocessed); +} +``` + +**트레이드오프**: +- ✅ Exactly-once 처리, 중복 방지, 감사 로그 +- ⚠️ 저장소 증가, 체크 쿼리 오버헤드 + +### 6. 보상 트랜잭션 + +**문제**: 결제 실패 시 재고/포인트/쿠폰 원복 필요 + +**해결**: 멱등성 보장 보상 로직 + +```java +public void handleFailedPayment(String orderId) { + Order order = orderRepository.findById(orderId); + + // 멱등성: 이미 취소됐으면 스킵 + if (order.getStatus() == CANCELED) return; + + // 보상 실행 + order.cancelOrder(); // 주문 취소 + inventoryService.restoreStock(); // 재고 복구 + pointService.refund(); // 포인트 환불 + couponService.restore(); // 쿠폰 복구 +} +``` + +**트레이드오프**: +- ✅ 데이터 일관성 유지, 안전한 재시도 +- ⚠️ 로직 복잡도 증가, 테스트 시나리오 복잡 + +--- + +## 기술 스택 + +### Backend +- **Java 21**: Virtual Threads, Record 등 최신 기능 활용 +- **Spring Boot 3.x**: 성숙한 생태계, Observability 기본 지원 +- **Spring Data JPA**: Repository 패턴, Query DSL +- **Spring Batch**: 대용량 배치 처리, 재시작 기능 + +### Infrastructure +- **MySQL 8.0**: 트랜잭션 데이터베이스 +- **Redis 7**: L2 캐시, 세션 저장소 +- **Kafka**: 이벤트 스트리밍, CQRS 구현 +- **Docker Compose**: 로컬 개발 환경 + +### Resilience & Monitoring +- **Resilience4j**: Circuit Breaker, Retry, Fallback +- **Spring Boot Actuator**: Health Check, Metrics +- **Prometheus**: 메트릭 수집 +- **Grafana**: 모니터링 대시보드 + +### Testing +- **JUnit 5**: 테스트 프레임워크 +- **Mockito**: Mock 객체 생성 +- **TestContainers**: 실제 DB/Kafka 환경 통합 테스트 +- **Embedded Kafka**: Kafka Consumer/Producer 테스트 + +--- + +## 시작하기 + +### 사전 요구사항 + +- Java 21+ +- Docker & Docker Compose +- Gradle 8.x + +### 1. 인프라 실행 + +```bash +# MySQL, Redis, Kafka 실행 +docker-compose -f ./docker/infra-compose.yml up -d + +# 모니터링 스택 실행 (선택) +docker-compose -f ./docker/monitoring-compose.yml up -d +``` + +### 2. 애플리케이션 실행 + +```bash +# commerce-api 실행 (포트 8080) +./gradlew :apps:commerce-api:bootRun + +# commerce-collector 실행 (포트 8081) +./gradlew :apps:commerce-collector:bootRun + +# PG 시뮬레이터 실행 (포트 8082) +./gradlew :apps:pg-simulator:bootRun +``` + +### 3. 모니터링 + +- **Grafana**: http://localhost:3000 (admin/admin) +- **Actuator**: http://localhost:8080/actuator +- **Swagger UI**: http://localhost:8080/swagger-ui.html + +### 테스트 실행 + +```bash +# 전체 테스트 +./gradlew test + +# 특정 모듈 테스트 +./gradlew :apps:commerce-api:test + +# 단일 테스트 클래스 +./gradlew :apps:commerce-api:test --tests "PaymentFacadeTest" + +# 커버리지 리포트 생성 +./gradlew test jacocoTestReport +``` + +--- + +## 프로젝트 구조 + +### Multi-Module 구성 ``` Root -├── apps ( spring-applications ) -│ ├── 📦 commerce-api -│ └── 📦 commerce-streamer -├── modules ( reusable-configurations ) -│ ├── 📦 jpa -│ ├── 📦 redis -│ └── 📦 kafka -└── supports ( add-ons ) - ├── 📦 jackson - ├── 📦 monitoring - └── 📦 logging +├── apps/ # 실행 가능한 Spring Boot 애플리케이션 +│ ├── commerce-api # 메인 API 서버 +│ ├── commerce-collector # 메트릭 수집 서버 +│ └── pg-simulator # PG 시뮬레이터 +├── modules/ # 재사용 가능한 설정 모듈 +│ ├── jpa # JPA 설정 +│ ├── redis # Redis 설정 +│ └── kafka # Kafka 설정 +└── supports/ # 부가 기능 모듈 + ├── jackson # JSON 직렬화 + ├── logging # 로깅 설정 + └── monitoring # 메트릭 설정 +``` + +**중요**: `apps/*` 모듈만 실행 가능한 JAR 생성, 나머지는 라이브러리 + +### commerce-api 상세 구조 + +``` +src/main/java/com/loopers/ +├── interfaces/api/ # Presentation Layer +│ ├── order/ +│ ├── payment/ +│ └── product/ +├── application/ # Application Layer +│ ├── order/ # OrderFacade, OrderInfo +│ ├── payment/ # PaymentFacade, PaymentInfo +│ └── product/ # ProductFacade, ProductInfo +├── domain/ # Domain Layer +│ ├── order/ # Order, OrderRepository +│ ├── payment/ # Payment, PaymentRepository +│ └── product/ # Product, ProductRepository +├── infrastructure/ # Infrastructure Layer +│ ├── order/ # OrderJpaRepository, OrderRepositoryImpl +│ ├── payment/ # PaymentJpaRepository, PgClient +│ └── product/ # ProductJpaRepository +├── config/ # 설정 클래스 +└── support/error/ # 예외 처리 +``` + +--- + +## 성능 최적화 + +### 1. 인덱스 전략 + +```sql +-- 일별 메트릭 조회 최적화 +CREATE INDEX idx_product_metrics_daily_date_product +ON product_metrics_daily(date, product_id); + +-- 주문 조회 최적화 +CREATE INDEX idx_order_user_created +ON orders(user_id, created_at DESC); + +-- 이벤트 중복 체크 최적화 +CREATE UNIQUE INDEX idx_event_handled_event_id +ON event_handled(event_id); +``` + +### 2. 배치 처리 + +- 이벤트 소비: 100개씩 배치 처리로 10배 처리량 향상 +- Spring Batch: 청크 크기 100으로 메모리 효율성 확보 + +### 3. 캐시 전략 + +```java +@Cacheable(value = "product", key = "#productId") +public ProductInfo getProduct(Long productId) { + // Redis 캐시 히트 시 DB 조회 생략 +} + +@CacheEvict(value = "product", key = "#productId") +public void updateProduct(Long productId, ProductInfo info) { + // 업데이트 시 캐시 무효화 +} +``` + +### 4. 커넥션 풀 튜닝 + +```yaml +spring: + datasource: + hikari: + maximum-pool-size: 20 # 동시 요청 처리량 기반 + minimum-idle: 10 + connection-timeout: 3000 + idle-timeout: 600000 ``` + +--- + +## 배운 점 + +### 트랜잭션 경계 설계의 중요성 +처음엔 전체 결제 흐름에 단일 트랜잭션을 사용했으나, PG 실패 시 결제 기록까지 롤백되어 복구가 불가능했습니다. `REQUIRES_NEW`를 통한 트랜잭션 분리로 안정성을 크게 향상시켰습니다. + +### 최종 일관성 구현의 복잡성 +CQRS 패턴은 개념적으로 간단해 보이지만, 실제 구현 시 이벤트 처리 실패, 재처리, 중복 처리 등 수많은 예외 상황을 고려해야 했습니다. EventHandled 테이블과 DLQ가 필수 인프라임을 깨달았습니다. + +### 멱등성은 선택이 아닌 필수 +초기 테스트에서 중복 Kafka 메시지로 인한 이중 환불 문제를 경험했습니다. 이후 모든 외부 호출, 이벤트 컨슈머, 보상 트랜잭션에 멱등성 설계를 필수로 적용했습니다. + +### 메트릭과 모니터링의 차이 +Spring Boot Actuator 메트릭(기술 지표)만으로는 부족하고, 비즈니스 메트릭(주문 수, 결제 성공률 등)이 별도로 필요함을 깨달았습니다. 이것이 ProductMetrics 도메인 엔티티 설계의 계기가 되었습니다. + +--- + +## 향후 개선 계획 + +1. **Event Sourcing**: 현재 방식은 결제 상태 히스토리를 잃어버림 +2. **분산 추적**: Spring Cloud Sleuth → OpenTelemetry로 전환 +3. **Rate Limiting**: API 레벨 스로틀링 추가 +4. **캐시 워밍**: Redis 콜드 스타트 문제 해결 + +--- + +## 라이선스 + +이 프로젝트는 학습 목적으로 작성되었습니다. 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 da43fb576..932bf1fb6 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 @@ -3,9 +3,7 @@ import com.loopers.application.product.ProductInfo; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductService; -import com.loopers.domain.ranking.Ranking; -import com.loopers.domain.ranking.RankingService; -import com.loopers.domain.ranking.RankingType; +import com.loopers.domain.ranking.*; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -20,14 +18,27 @@ public class RankingFacade { private final RankingService rankingService; + private final PeriodRankingService periodRankingService; private final ProductService productService; /** * TOP N 랭킹 조회 (상품 정보 포함) */ @Transactional(readOnly = true) - public List getTopRanking(RankingType rankingType, LocalDate date, int limit) { - List entries = rankingService.getTopRanking(rankingType, date, limit); + public List getTopRanking( + RankingType rankingType, + PeriodType periodType, + LocalDate date, + int limit + ) { + // period가 null이면 DAILY로 처리 + PeriodType period = periodType != null ? periodType : PeriodType.DAILY; + + List entries = switch (period) { + case DAILY -> rankingService.getTopRanking(rankingType, LocalDate.now(), limit); + case WEEKLY -> periodRankingService.getTopWeeklyRanking(rankingType, date, limit); + case MONTHLY -> periodRankingService.getTopMonthlyRanking(rankingType, date, limit); + }; if (entries.isEmpty()) { return List.of(); @@ -40,9 +51,21 @@ public List getTopRanking(RankingType rankingType, LocalDate date, * 페이지네이션 랭킹 조회 */ @Transactional(readOnly = true) - public List getRankingWithPaging(RankingType rankingType, LocalDate date, - int page, int size) { - List entries = rankingService.getRankingWithPaging(rankingType, date, page, size); + public List getRankingWithPaging( + RankingType rankingType, + PeriodType periodType, + LocalDate date, + int page, + int size + ) { + + PeriodType period = periodType != null ? periodType : PeriodType.DAILY; + + List entries = switch (period) { + case DAILY -> rankingService.getRankingWithPaging(rankingType, LocalDate.now(), page, size); + case WEEKLY -> periodRankingService.getWeeklyRankingWithPaging(rankingType, date, page, size); + case MONTHLY -> periodRankingService.getMonthlyRankingWithPaging(rankingType, date, page, size); + }; if (entries.isEmpty()) { return List.of(); @@ -55,7 +78,11 @@ public List getRankingWithPaging(RankingType rankingType, LocalDate * 특정 상품의 특정 랭킹 조회 */ @Transactional(readOnly = true) - public RankingInfo getProductRanking(RankingType rankingType, LocalDate date, Long productId) { + public RankingInfo getProductRanking( + RankingType rankingType, + LocalDate date, + Long productId + ) { Ranking entry = rankingService.getProductRanking(rankingType, date, productId); if (entry == null) { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsMonthly.java b/apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsMonthly.java new file mode 100644 index 000000000..22a84fd55 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsMonthly.java @@ -0,0 +1,66 @@ +package com.loopers.domain.metrics; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Immutable; + +import java.time.LocalDate; +import java.time.ZonedDateTime; + +/** + * 월간 상품 집계 Materialized View (읽기 전용) + * commerce-collector에서 생성한 집계 데이터 조회용 + */ +@Entity +@Table(name = "mv_product_metrics_monthly") +@Getter +@NoArgsConstructor +@Immutable // 읽기 전용 +public class ProductMetricsMonthly { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "year", nullable = false) + private Integer year; + + @Column(name = "month", nullable = false) + private Integer month; + + @Column(name = "period_start_date", nullable = false) + private LocalDate periodStartDate; + + @Column(name = "period_end_date", nullable = false) + private LocalDate periodEndDate; + + @Column(name = "total_like_count", nullable = false) + private Long totalLikeCount; + + @Column(name = "total_view_count", nullable = false) + private Long totalViewCount; + + @Column(name = "total_order_count", nullable = false) + private Long totalOrderCount; + + @Column(name = "aggregated_at") + private ZonedDateTime aggregatedAt; + + @Column(name = "created_at") + private ZonedDateTime createdAt; + + @Column(name = "updated_at") + private ZonedDateTime updatedAt; + + /** + * 종합 점수 계산 (가중치 적용) + * Score = (like * 0.2) + (view * 0.1) + (order * 0.6) + */ + public double calculateCompositeScore() { + return (totalLikeCount * 0.2) + (totalViewCount * 0.1) + (totalOrderCount * 0.6); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsMonthlyRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsMonthlyRepository.java new file mode 100644 index 000000000..991d103ce --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsMonthlyRepository.java @@ -0,0 +1,36 @@ +package com.loopers.domain.metrics; + +import java.util.List; +import java.util.Optional; + +public interface ProductMetricsMonthlyRepository { + /** + * 특정 년도/월의 랭킹 조회 (좋아요 기준 정렬) + */ + List findByYearAndMonthOrderByLikeCountDesc(int year, int month, int limit); + + /** + * 특정 년도/월의 랭킹 조회 (조회수 기준 정렬) + */ + List findByYearAndMonthOrderByViewCountDesc(int year, int month, int limit); + + /** + * 특정 년도/월의 랭킹 조회 (주문수 기준 정렬) + */ + List findByYearAndMonthOrderByOrderCountDesc(int year, int month, int limit); + + /** + * 특정 년도/월의 랭킹 조회 (Score 기준 정렬) + */ + List findByYearAndMonthOrderByCompositeScoreDesc(int year, int month, int limit); + + /** + * 특정 상품의 월간 랭킹 조회 + */ + Optional findByYearAndMonthAndProductId(int year, int month, Long productId); + + /** + * 특정 년도/월의 전체 상품 수 + */ + long countByYearAndMonth(int year, int month); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsWeekly.java b/apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsWeekly.java new file mode 100644 index 000000000..61cf9c087 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsWeekly.java @@ -0,0 +1,66 @@ +package com.loopers.domain.metrics; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Immutable; + +import java.time.LocalDate; +import java.time.ZonedDateTime; + +/** + * 주간 상품 집계 Materialized View (읽기 전용) + * commerce-collector에서 생성한 집계 데이터 조회용 + */ +@Entity +@Table(name = "mv_product_metrics_weekly") +@Getter +@NoArgsConstructor +@Immutable // 읽기 전용 +public class ProductMetricsWeekly { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "year", nullable = false) + private Integer year; + + @Column(name = "week", nullable = false) + private Integer week; + + @Column(name = "period_start_date", nullable = false) + private LocalDate periodStartDate; + + @Column(name = "period_end_date", nullable = false) + private LocalDate periodEndDate; + + @Column(name = "total_like_count", nullable = false) + private Long totalLikeCount; + + @Column(name = "total_view_count", nullable = false) + private Long totalViewCount; + + @Column(name = "total_order_count", nullable = false) + private Long totalOrderCount; + + @Column(name = "aggregated_at") + private ZonedDateTime aggregatedAt; + + @Column(name = "created_at") + private ZonedDateTime createdAt; + + @Column(name = "updated_at") + private ZonedDateTime updatedAt; + + /** + * 종합 점수 계산 (가중치 적용) + * Score = (like * 0.2) + (view * 0.1) + (order * 0.6) + */ + public double calculateCompositeScore() { + return (totalLikeCount * 0.2) + (totalViewCount * 0.1) + (totalOrderCount * 0.6); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsWeeklyRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsWeeklyRepository.java new file mode 100644 index 000000000..4380bb5a3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsWeeklyRepository.java @@ -0,0 +1,36 @@ +package com.loopers.domain.metrics; + +import java.util.List; +import java.util.Optional; + +public interface ProductMetricsWeeklyRepository { + /** + * 특정 년도/주차의 랭킹 조회 (좋아요 기준 정렬) + */ + List findByYearAndWeekOrderByLikeCountDesc(int year, int week, int limit); + + /** + * 특정 년도/주차의 랭킹 조회 (조회수 기준 정렬) + */ + List findByYearAndWeekOrderByViewCountDesc(int year, int week, int limit); + + /** + * 특정 년도/주차의 랭킹 조회 (주문수 기준 정렬) + */ + List findByYearAndWeekOrderByOrderCountDesc(int year, int week, int limit); + + /** + * 특정 년도/주차의 랭킹 조회 (score 기준 정렬) + */ + List findByYearAndWeekOrderByCompositeScoreDesc(int year, int week, int limit); + + /** + * 특정 상품의 주간 랭킹 조회 + */ + Optional findByYearAndWeekAndProductId(int year, int week, Long productId); + + /** + * 특정 년도/주차의 전체 상품 수 + */ + long countByYearAndWeek(int year, int week); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/PeriodRankingService.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/PeriodRankingService.java new file mode 100644 index 000000000..4cfff452e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/PeriodRankingService.java @@ -0,0 +1,140 @@ +package com.loopers.domain.ranking; + +import com.loopers.domain.metrics.ProductMetricsMonthly; +import com.loopers.domain.metrics.ProductMetricsMonthlyRepository; +import com.loopers.domain.metrics.ProductMetricsWeekly; +import com.loopers.domain.metrics.ProductMetricsWeeklyRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.temporal.IsoFields; +import java.util.ArrayList; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class PeriodRankingService { + + private final ProductMetricsWeeklyRepository weeklyRepository; + private final ProductMetricsMonthlyRepository monthlyRepository; + + /** + * 주간 TOP N 랭킹 조회 + */ + public List getTopWeeklyRanking(RankingType type, LocalDate date, int limit) { + LocalDate targetDate = date != null ? date : LocalDate.now(); + int year = targetDate.getYear(); + int week = targetDate.get(IsoFields.WEEK_OF_WEEK_BASED_YEAR); + + List weeklyMetrics = switch (type) { + case LIKE -> weeklyRepository.findByYearAndWeekOrderByLikeCountDesc(year, week, limit); + case VIEW -> weeklyRepository.findByYearAndWeekOrderByViewCountDesc(year, week, limit); + case ORDER -> weeklyRepository.findByYearAndWeekOrderByOrderCountDesc(year, week, limit); + case ALL -> weeklyRepository.findByYearAndWeekOrderByCompositeScoreDesc(year, week, limit); + }; + + return convertWeeklyToRanking(weeklyMetrics, type); + } + + /** + * 월간 TOP N 랭킹 조회 + */ + public List getTopMonthlyRanking(RankingType type, LocalDate date, int limit) { + LocalDate targetDate = date != null ? date : LocalDate.now(); + int year = targetDate.getYear(); + int month = targetDate.getMonthValue(); + + List monthlyMetrics = switch (type) { + case LIKE -> monthlyRepository.findByYearAndMonthOrderByLikeCountDesc(year, month, limit); + case VIEW -> monthlyRepository.findByYearAndMonthOrderByViewCountDesc(year, month, limit); + case ORDER -> monthlyRepository.findByYearAndMonthOrderByOrderCountDesc(year, month, limit); + case ALL -> monthlyRepository.findByYearAndMonthOrderByCompositeScoreDesc(year, month, limit); + }; + + return convertMonthlyToRanking(monthlyMetrics, type); + } + + /** + * 주간 페이징 랭킹 조회 + */ + public List getWeeklyRankingWithPaging(RankingType type, LocalDate date, int page, int size) { + return getTopWeeklyRanking(type, date, (page + 1) * size) + .stream() + .skip((long) page * size) + .limit(size) + .toList(); + } + + /** + * 월간 페이징 랭킹 조회 + */ + public List getMonthlyRankingWithPaging(RankingType type, LocalDate date, int page, int size) { + return getTopMonthlyRanking(type, date, (page + 1) * size) + .stream() + .skip((long) page * size) + .limit(size) + .toList(); + } + + + private List convertWeeklyToRanking( + List metrics, + RankingType type + ) { + int rank = 1; + List rankings = new ArrayList<>(); + + for (ProductMetricsWeekly metric : metrics) { + double score = calculateScore(type, + metric.getTotalLikeCount(), + metric.getTotalViewCount(), + metric.getTotalOrderCount()); + + rankings.add(Ranking.of( + rank++, + metric.getProductId(), + score, + metric.getTotalLikeCount(), + metric.getTotalViewCount(), + metric.getTotalOrderCount() + )); + } + + return rankings; + } + + private List convertMonthlyToRanking(List metrics, RankingType type) { + int rank = 1; + List rankings = new ArrayList<>(); + + for (ProductMetricsMonthly metric : metrics) { + double score = calculateScore( + type, + metric.getTotalLikeCount(), + metric.getTotalViewCount(), + metric.getTotalOrderCount() + ); + + rankings.add(Ranking.of( + rank++, + metric.getProductId(), + score, + metric.getTotalLikeCount(), + metric.getTotalViewCount(), + metric.getTotalOrderCount() + )); + } + + return rankings; + } + + private double calculateScore(RankingType type, long likeCount, long viewCount, long orderCount) { + return switch (type) { + case LIKE -> likeCount; + case VIEW -> viewCount; + case ORDER -> orderCount; + case ALL -> (likeCount * 0.2) + (viewCount * 0.1) + (orderCount * 0.6); + }; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/PeriodType.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/PeriodType.java new file mode 100644 index 000000000..dcadfd9d0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/PeriodType.java @@ -0,0 +1,15 @@ +package com.loopers.domain.ranking; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum PeriodType { + DAILY("일간", "오늘 또는 특정 날짜"), + WEEKLY("주간", "이번 주 또는 특정 주차"), + MONTHLY("월간", "이번 달 또는 특정 월"); + + private final String name; + private final String description; +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/Ranking.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/Ranking.java index 12d47487b..f9968b648 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/Ranking.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/Ranking.java @@ -8,11 +8,42 @@ public class Ranking { private final int rank; private final Long productId; private final Double score; + private final Long totalLikeCount; + private final Long totalViewCount; + private final Long totalOrderCount; @Builder - public Ranking(int rank, Long productId, Double score) { + public Ranking( + int rank, + Long productId, + Double score, + Long totalLikeCount, + Long totalViewCount, + Long totalOrderCount + ) { this.rank = rank; this.productId = productId; this.score = score; + this.totalLikeCount = totalLikeCount; + this.totalViewCount = totalViewCount; + this.totalOrderCount = totalOrderCount; + } + + public static Ranking of( + int i, + Long productId, + double score, + Long totalLikeCount, + Long totalViewCount, + Long totalOrderCount + ) { + return new Ranking( + i, + productId, + score, + totalLikeCount, + totalViewCount, + totalOrderCount + ); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsMonthlyJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsMonthlyJpaRepository.java new file mode 100644 index 000000000..04bf2383b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsMonthlyJpaRepository.java @@ -0,0 +1,53 @@ +package com.loopers.infrastructure.metrics; + +import com.loopers.domain.metrics.ProductMetricsMonthly; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface ProductMetricsMonthlyJpaRepository extends JpaRepository { + + /** + * 특정 년도/월의 랭킹 조회 (좋아요 기준 정렬) + */ + List findByYearAndMonthOrderByTotalLikeCountDesc(int year, int month, Pageable pageable); + + /** + * 특정 년도/월의 랭킹 조회 (조회수 기준 정렬) + */ + List findByYearAndMonthOrderByTotalViewCountDesc(int year, int month, Pageable pageable); + + /** + * 특정 년도/월의 랭킹 조회 (주문수 기준 정렬) + */ + List findByYearAndMonthOrderByTotalOrderCountDesc(int year, int month, Pageable pageable); + + /** + * 특정 년도/월의 랭킹 조회 (종합 점수 기준 정렬) + * 종합 점수 = (like * 0.2) + (view * 0.1) + (order * 0.6) + */ + @Query(""" + SELECT m FROM ProductMetricsMonthly m + WHERE m.year = :year AND m.month = :month + ORDER BY (m.totalLikeCount * 0.2 + m.totalViewCount * 0.1 + m.totalOrderCount * 0.6) DESC + """) + List findByYearAndMonthOrderByCompositeScoreDesc( + @Param("year") int year, + @Param("month") int month, + Pageable pageable + ); + + /** + * 특정 상품의 월간 랭킹 조회 + */ + Optional findByYearAndMonthAndProductId(int year, int month, Long productId); + + /** + * 특정 년도/월의 전체 상품 수 + */ + long countByYearAndMonth(int year, int month); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsMonthlyRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsMonthlyRepositoryImpl.java new file mode 100644 index 000000000..897f7b079 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsMonthlyRepositoryImpl.java @@ -0,0 +1,67 @@ +package com.loopers.infrastructure.metrics; + +import com.loopers.domain.metrics.ProductMetricsMonthly; +import com.loopers.domain.metrics.ProductMetricsMonthlyRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; + +/** + * 월간 상품 집계 Repository 구현 (읽기 전용) + * commerce-collector에서 생성한 집계 데이터 조회 + */ +@Component +@RequiredArgsConstructor +public class ProductMetricsMonthlyRepositoryImpl implements ProductMetricsMonthlyRepository { + + private final ProductMetricsMonthlyJpaRepository monthlyJpaRepository; + + @Override + public List findByYearAndMonthOrderByLikeCountDesc(int year, int month, int limit) { + return monthlyJpaRepository.findByYearAndMonthOrderByTotalLikeCountDesc( + year, + month, + PageRequest.of(0, limit) + ); + } + + @Override + public List findByYearAndMonthOrderByViewCountDesc(int year, int month, int limit) { + return monthlyJpaRepository.findByYearAndMonthOrderByTotalViewCountDesc( + year, + month, + PageRequest.of(0, limit) + ); + } + + @Override + public List findByYearAndMonthOrderByOrderCountDesc(int year, int month, int limit) { + return monthlyJpaRepository.findByYearAndMonthOrderByTotalOrderCountDesc( + year, + month, + PageRequest.of(0, limit) + ); + } + + @Override + public List findByYearAndMonthOrderByCompositeScoreDesc(int year, int month, int limit) { + return monthlyJpaRepository.findByYearAndMonthOrderByCompositeScoreDesc( + year, + month, + PageRequest.of(0, limit) + ); + } + + @Override + public Optional findByYearAndMonthAndProductId(int year, int month, Long productId) { + return monthlyJpaRepository.findByYearAndMonthAndProductId(year, month, productId); + } + + @Override + public long countByYearAndMonth(int year, int month) { + return monthlyJpaRepository.countByYearAndMonth(year, month); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsWeeklyJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsWeeklyJpaRepository.java new file mode 100644 index 000000000..6fd60b011 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsWeeklyJpaRepository.java @@ -0,0 +1,53 @@ +package com.loopers.infrastructure.metrics; + +import com.loopers.domain.metrics.ProductMetricsWeekly; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface ProductMetricsWeeklyJpaRepository extends JpaRepository { + + /** + * 특정 년도/주차의 랭킹 조회 (좋아요 기준 정렬) + */ + List findByYearAndWeekOrderByTotalLikeCountDesc(int year, int week, Pageable pageable); + + /** + * 특정 년도/주차의 랭킹 조회 (조회수 기준 정렬) + */ + List findByYearAndWeekOrderByTotalViewCountDesc(int year, int week, Pageable pageable); + + /** + * 특정 년도/주차의 랭킹 조회 (주문수 기준 정렬) + */ + List findByYearAndWeekOrderByTotalOrderCountDesc(int year, int week, Pageable pageable); + + /** + * 특정 년도/주차의 랭킹 조회 (종합 점수 기준 정렬) + * 종합 점수 = (like * 0.2) + (view * 0.1) + (order * 0.6) + */ + @Query(""" + SELECT w FROM ProductMetricsWeekly w + WHERE w.year = :year AND w.week = :week + ORDER BY (w.totalLikeCount * 0.2 + w.totalViewCount * 0.1 + w.totalOrderCount * 0.6) DESC + """) + List findByYearAndWeekOrderByCompositeScoreDesc( + @Param("year") int year, + @Param("week") int week, + Pageable pageable + ); + + /** + * 특정 상품의 주간 랭킹 조회 + */ + Optional findByYearAndWeekAndProductId(int year, int week, Long productId); + + /** + * 특정 년도/주차의 전체 상품 수 + */ + long countByYearAndWeek(int year, int week); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsWeeklyRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsWeeklyRepositoryImpl.java new file mode 100644 index 000000000..c24c0febd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsWeeklyRepositoryImpl.java @@ -0,0 +1,67 @@ +package com.loopers.infrastructure.metrics; + +import com.loopers.domain.metrics.ProductMetricsWeekly; +import com.loopers.domain.metrics.ProductMetricsWeeklyRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; + +/** + * 주간 상품 집계 Repository 구현 (읽기 전용) + * commerce-collector에서 생성한 집계 데이터 조회 + */ +@Component +@RequiredArgsConstructor +public class ProductMetricsWeeklyRepositoryImpl implements ProductMetricsWeeklyRepository { + + private final ProductMetricsWeeklyJpaRepository weeklyJpaRepository; + + @Override + public List findByYearAndWeekOrderByLikeCountDesc(int year, int week, int limit) { + return weeklyJpaRepository.findByYearAndWeekOrderByTotalLikeCountDesc( + year, + week, + PageRequest.of(0, limit) + ); + } + + @Override + public List findByYearAndWeekOrderByViewCountDesc(int year, int week, int limit) { + return weeklyJpaRepository.findByYearAndWeekOrderByTotalViewCountDesc( + year, + week, + PageRequest.of(0, limit) + ); + } + + @Override + public List findByYearAndWeekOrderByOrderCountDesc(int year, int week, int limit) { + return weeklyJpaRepository.findByYearAndWeekOrderByTotalOrderCountDesc( + year, + week, + PageRequest.of(0, limit) + ); + } + + @Override + public List findByYearAndWeekOrderByCompositeScoreDesc(int year, int week, int limit) { + return weeklyJpaRepository.findByYearAndWeekOrderByCompositeScoreDesc( + year, + week, + PageRequest.of(0, limit) + ); + } + + @Override + public Optional findByYearAndWeekAndProductId(int year, int week, Long productId) { + return weeklyJpaRepository.findByYearAndWeekAndProductId(year, week, productId); + } + + @Override + public long countByYearAndWeek(int year, int week) { + return weeklyJpaRepository.countByYearAndWeek(year, week); + } +} 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 f392dc9d7..dfd00b85a 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 @@ -28,6 +28,7 @@ public ApiResponse getRankingWithPaging( List rankings = rankingFacade.getRankingWithPaging( request.type(), + request.periodType(), date, request.page(), request.size() @@ -54,6 +55,7 @@ public ApiResponse getTopRanking( List rankings = rankingFacade.getTopRanking( request.type(), + request.periodType(), date, request.limit() ); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java index 848a979c5..33c845008 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java @@ -2,6 +2,7 @@ import com.loopers.application.product.ProductInfo; import com.loopers.application.ranking.RankingInfo; +import com.loopers.domain.ranking.PeriodType; import com.loopers.domain.ranking.RankingType; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Max; @@ -16,6 +17,9 @@ public record GetTopRankingRequest( @Schema(description = "랭킹 타입", example = "ALL", allowableValues = {"LIKE", "VIEW", "ORDER", "ALL"}) RankingType type, + @Schema(description = "랭킹 조회 타입", example = "DAILY", allowableValues = {"DAILY", "WEEKLY", "MONTHLY"}) + PeriodType periodType, + @Schema(description = "조회 날짜 (yyyyMMdd)", example = "20251225") String date, @@ -35,6 +39,9 @@ public record GetRankingWithPagingRequest( @Schema(description = "랭킹 타입", example = "ALL") RankingType type, + @Schema(description = "랭킹 조회 타입", example = "DAILY", allowableValues = {"DAILY", "WEEKLY", "MONTHLY"}) + PeriodType periodType, + @Schema(description = "조회 날짜 (yyyyMMdd)", example = "20251225") String date, diff --git a/apps/commerce-collector/build.gradle.kts b/apps/commerce-collector/build.gradle.kts index 95e1d4967..a93b93a5e 100644 --- a/apps/commerce-collector/build.gradle.kts +++ b/apps/commerce-collector/build.gradle.kts @@ -11,6 +11,7 @@ dependencies { // web implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-actuator") + implementation("org.springframework.boot:spring-boot-starter-batch") implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${project.properties["springDocOpenApiVersion"]}") // resilience4j @@ -30,4 +31,7 @@ dependencies { // test-fixtures testImplementation(testFixtures(project(":modules:jpa"))) testImplementation(testFixtures(project(":modules:redis"))) + + // spring-batch-test + testImplementation("org.springframework.batch:spring-batch-test") } diff --git a/apps/commerce-collector/src/main/java/com/loopers/application/ranking/RankingScheduler.java b/apps/commerce-collector/src/main/java/com/loopers/application/ranking/RankingScheduler.java index adb801c6e..4418426db 100644 --- a/apps/commerce-collector/src/main/java/com/loopers/application/ranking/RankingScheduler.java +++ b/apps/commerce-collector/src/main/java/com/loopers/application/ranking/RankingScheduler.java @@ -132,13 +132,13 @@ private Map calculateCompositeScores(Map likeDeltas /** * 오래된 일자별 데이터 정리 (매일 새벽 4시) - * - 10일 이전 데이터 삭제 + * - 30일 이전 데이터 삭제 */ @Scheduled(cron = "0 0 4 * * *") @Transactional public void cleanupOldDailyMetrics() { try { - LocalDate cutoffDate = LocalDate.now().minusDays(10); + LocalDate cutoffDate = LocalDate.now().minusDays(30); int deleted = productMetricsDailyRepository.deleteByMetricDateBefore(cutoffDate); log.info("오래된 일자별 데이터 정리 완료 - {} 건 삭제", deleted); } catch (Exception e) { diff --git a/apps/commerce-collector/src/main/java/com/loopers/batch/listener/ChunkListener.java b/apps/commerce-collector/src/main/java/com/loopers/batch/listener/ChunkListener.java new file mode 100644 index 000000000..6401bb777 --- /dev/null +++ b/apps/commerce-collector/src/main/java/com/loopers/batch/listener/ChunkListener.java @@ -0,0 +1,21 @@ +package com.loopers.batch.listener; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.annotation.AfterChunk; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.stereotype.Component; + +@Slf4j +@RequiredArgsConstructor +@Component +public class ChunkListener { + + @AfterChunk + void afterChunk(ChunkContext chunkContext) { + log.info("청크 종료: readCount: {}, writeCount: {}", + chunkContext.getStepContext().getStepExecution().getReadCount(), + chunkContext.getStepContext().getStepExecution().getWriteCount() + ); + } +} diff --git a/apps/commerce-collector/src/main/java/com/loopers/batch/listener/JobListener.java b/apps/commerce-collector/src/main/java/com/loopers/batch/listener/JobListener.java new file mode 100644 index 000000000..4276fba54 --- /dev/null +++ b/apps/commerce-collector/src/main/java/com/loopers/batch/listener/JobListener.java @@ -0,0 +1,53 @@ +package com.loopers.batch.listener; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.annotation.AfterJob; +import org.springframework.batch.core.annotation.BeforeJob; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; + +@Slf4j +@RequiredArgsConstructor +@Component +public class JobListener { + + @BeforeJob + void beforeJob(JobExecution jobExecution) { + log.info("Job '{}' 시작", jobExecution.getJobInstance().getJobName()); + jobExecution.getExecutionContext().putLong("startTime", System.currentTimeMillis()); + } + + @AfterJob + void afterJob(JobExecution jobExecution) { + var startTime = jobExecution.getExecutionContext().getLong("startTime"); + var endTime = System.currentTimeMillis(); + + var startDateTime = Instant.ofEpochMilli(startTime) + .atZone(ZoneId.systemDefault()) + .toLocalDateTime(); + var endDateTime = Instant.ofEpochMilli(endTime) + .atZone(ZoneId.systemDefault()) + .toLocalDateTime(); + + var totalTime = endTime - startTime; + var duration = Duration.ofMillis(totalTime); + var hours = duration.toHours(); + var minutes = duration.toMinutes() % 60; + var seconds = duration.getSeconds() % 60; + + var message = String.format( + """ + *Start Time:* %s + *End Time:* %s + *Total Time:* %d시간 %d분 %d초 + """, startDateTime, endDateTime, hours, minutes, seconds + ).trim(); + + log.info(message); + } +} diff --git a/apps/commerce-collector/src/main/java/com/loopers/batch/listener/StepMonitorListener.java b/apps/commerce-collector/src/main/java/com/loopers/batch/listener/StepMonitorListener.java new file mode 100644 index 000000000..2231a646b --- /dev/null +++ b/apps/commerce-collector/src/main/java/com/loopers/batch/listener/StepMonitorListener.java @@ -0,0 +1,45 @@ +package com.loopers.batch.listener; + +import jakarta.annotation.Nonnull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.ExitStatus; +import org.springframework.batch.core.StepExecution; +import org.springframework.batch.core.StepExecutionListener; +import org.springframework.stereotype.Component; + +import java.util.Objects; +import java.util.stream.Collectors; + +@Slf4j +@RequiredArgsConstructor +@Component +public class StepMonitorListener implements StepExecutionListener { + + @Override + public void beforeStep(@Nonnull StepExecution stepExecution) { + log.info("Step '{}' 시작", stepExecution.getStepName()); + } + + @Override + public ExitStatus afterStep(@Nonnull StepExecution stepExecution) { + if (!stepExecution.getFailureExceptions().isEmpty()) { + var jobName = stepExecution.getJobExecution().getJobInstance().getJobName(); + var exceptions = stepExecution.getFailureExceptions().stream() + .map(Throwable::getMessage) + .filter(Objects::nonNull) + .collect(Collectors.joining("\n")); + log.error( + """ + [에러 발생] + jobName: {} + exceptions: + {} + """.trim(), jobName, exceptions + ); + // error 발생 시 slack 등 다른 채널로 모니터 전송 + return ExitStatus.FAILED; + } + return ExitStatus.COMPLETED; + } +} diff --git a/apps/commerce-collector/src/main/java/com/loopers/batch/metrics/MonthlyMetricsProcessor.java b/apps/commerce-collector/src/main/java/com/loopers/batch/metrics/MonthlyMetricsProcessor.java new file mode 100644 index 000000000..06a108f8d --- /dev/null +++ b/apps/commerce-collector/src/main/java/com/loopers/batch/metrics/MonthlyMetricsProcessor.java @@ -0,0 +1,31 @@ +package com.loopers.batch.metrics; + +import com.loopers.domain.metrics.ProductMetricsMonthly; +import com.loopers.domain.metrics.dto.MonthlyAggregationDto; +import org.springframework.batch.item.ItemProcessor; + +/** + * 월간 집계 DTO를 ProductMetricsMonthly 엔티티로 변환하는 Processor + */ +public class MonthlyMetricsProcessor implements ItemProcessor { + @Override + public ProductMetricsMonthly process(MonthlyAggregationDto dto) { + // DTO를 도메인 엔티티로 변환 + ProductMetricsMonthly metrics = ProductMetricsMonthly.create( + dto.getProductId(), + dto.getYear(), + dto.getMonth(), + dto.getPeriodStartDate(), + dto.getPeriodEndDate() + ); + + // 집계 메트릭 업데이트 + metrics.updateMetrics( + dto.getTotalLikeCount(), + dto.getTotalViewCount(), + dto.getTotalOrderCount() + ); + + return metrics; + } +} diff --git a/apps/commerce-collector/src/main/java/com/loopers/batch/metrics/MonthlyMetricsWriter.java b/apps/commerce-collector/src/main/java/com/loopers/batch/metrics/MonthlyMetricsWriter.java new file mode 100644 index 000000000..8670f0520 --- /dev/null +++ b/apps/commerce-collector/src/main/java/com/loopers/batch/metrics/MonthlyMetricsWriter.java @@ -0,0 +1,30 @@ +package com.loopers.batch.metrics; + +import com.loopers.domain.metrics.ProductMetricsMonthly; +import com.loopers.domain.metrics.ProductMetricsMonthlyRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.Chunk; +import org.springframework.batch.item.ItemWriter; + +import java.util.List; + +/** + * 월간 집계 결과를 MV 테이블에 저장하는 Writer + */ +@Slf4j +@RequiredArgsConstructor +public class MonthlyMetricsWriter implements ItemWriter { + + private final ProductMetricsMonthlyRepository monthlyRepository; + + @Override + public void write(Chunk chunk) { + List items = chunk.getItems(); + + // Bulk Insert/Update (UPSERT) + monthlyRepository.saveAll((List) items); + + log.info("월간 집계 저장 완료: {} 건", items.size()); + } +} diff --git a/apps/commerce-collector/src/main/java/com/loopers/batch/metrics/WeeklyMetricsProcessor.java b/apps/commerce-collector/src/main/java/com/loopers/batch/metrics/WeeklyMetricsProcessor.java new file mode 100644 index 000000000..d8a80092e --- /dev/null +++ b/apps/commerce-collector/src/main/java/com/loopers/batch/metrics/WeeklyMetricsProcessor.java @@ -0,0 +1,31 @@ +package com.loopers.batch.metrics; + +import com.loopers.domain.metrics.ProductMetricsWeekly; +import com.loopers.domain.metrics.dto.WeeklyAggregationDto; +import org.springframework.batch.item.ItemProcessor; + +/** + * 주간 집계 DTO를 ProductMetricsWeekly 엔티티로 변환하는 Processor + */ +public class WeeklyMetricsProcessor implements ItemProcessor { + @Override + public ProductMetricsWeekly process(WeeklyAggregationDto dto) { + // DTO를 도메인 엔티티로 변환 + ProductMetricsWeekly metrics = ProductMetricsWeekly.create( + dto.getProductId(), + dto.getYear(), + dto.getWeek(), + dto.getPeriodStartDate(), + dto.getPeriodEndDate() + ); + + // 집계 메트릭 업데이트 + metrics.updateMetrics( + dto.getTotalLikeCount(), + dto.getTotalViewCount(), + dto.getTotalOrderCount() + ); + + return metrics; + } +} diff --git a/apps/commerce-collector/src/main/java/com/loopers/batch/metrics/WeeklyMetricsWriter.java b/apps/commerce-collector/src/main/java/com/loopers/batch/metrics/WeeklyMetricsWriter.java new file mode 100644 index 000000000..9789b03f4 --- /dev/null +++ b/apps/commerce-collector/src/main/java/com/loopers/batch/metrics/WeeklyMetricsWriter.java @@ -0,0 +1,25 @@ +package com.loopers.batch.metrics; + +import com.loopers.domain.metrics.ProductMetricsWeekly; +import com.loopers.domain.metrics.ProductMetricsWeeklyRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.Chunk; +import org.springframework.batch.item.ItemWriter; + +import java.util.List; + +@Slf4j +@RequiredArgsConstructor +public class WeeklyMetricsWriter implements ItemWriter { + private final ProductMetricsWeeklyRepository weeklyRepository; + + @Override + public void write(Chunk chunk) { + List items = (List) chunk.getItems(); + + weeklyRepository.saveAll(items); + + log.info("주간 집계 저장 완료: {} 건", items.size()); + } +} diff --git a/apps/commerce-collector/src/main/java/com/loopers/batch/metrics/job/ProductMetricsMonthlyJobConfig.java b/apps/commerce-collector/src/main/java/com/loopers/batch/metrics/job/ProductMetricsMonthlyJobConfig.java new file mode 100644 index 000000000..1b3efecd8 --- /dev/null +++ b/apps/commerce-collector/src/main/java/com/loopers/batch/metrics/job/ProductMetricsMonthlyJobConfig.java @@ -0,0 +1,100 @@ +package com.loopers.batch.metrics.job; + +import com.loopers.batch.listener.ChunkListener; +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; +import com.loopers.batch.metrics.MonthlyMetricsProcessor; +import com.loopers.batch.metrics.MonthlyMetricsWriter; +import com.loopers.domain.metrics.ProductMetricsMonthly; +import com.loopers.domain.metrics.ProductMetricsMonthlyRepository; +import com.loopers.domain.metrics.dto.MonthlyAggregationDto; +import com.loopers.infrastructure.metrics.ProductMetricsDailyJpaRepository; +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.data.RepositoryItemReader; +import org.springframework.batch.item.data.builder.RepositoryItemReaderBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.domain.Sort; +import org.springframework.transaction.PlatformTransactionManager; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; + +@Configuration +@RequiredArgsConstructor +public class ProductMetricsMonthlyJobConfig { + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final ProductMetricsDailyJpaRepository dailyJpaRepository; + private final ProductMetricsMonthlyRepository monthlyRepository; + + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final ChunkListener chunkListener; + + @Bean + public Job productMetricsMonthlyJob() { + return new JobBuilder("productMetricsMonthlyJob", jobRepository) + .start(aggregateMonthlyMetricsStep()) + .listener(jobListener) // Job Listener + .build(); + } + + @Bean + public Step aggregateMonthlyMetricsStep() { + return new StepBuilder("aggregateMonthlyMetricsStep", jobRepository) + .chunk(100, transactionManager) + .reader(monthlyMetricsReader(null, null)) + .processor(monthlyMetricsProcessor()) + .writer(monthlyMetricsWriter()) + .listener(stepMonitorListener) // Step Listener + .listener(chunkListener) // Chunk Listener + .build(); + } + + /** + * RepositoryItemReader를 사용하여 DB에서 페이징 집계 수행 + */ + @Bean + @StepScope + public RepositoryItemReader monthlyMetricsReader( + @Value("#{jobParameters['year']}") Integer year, + @Value("#{jobParameters['month']}") Integer month + ) { + // 월간 시작일/종료일 계산 + LocalDate startDate = LocalDate.of(year, month, 1); + LocalDate endDate = startDate.plusMonths(1).minusDays(1); // 말일 + + return new RepositoryItemReaderBuilder() + .name("monthlyMetricsReader") + .repository(dailyJpaRepository) + .methodName("findMonthlyAggregation") + .arguments(List.of(year, month, startDate, endDate)) + .pageSize(100) // Chunk Size와 일치 + .sorts(Map.of("productId", Sort.Direction.ASC)) + .build(); + } + + @Bean + @StepScope + public ItemProcessor monthlyMetricsProcessor() { + return new MonthlyMetricsProcessor(); + } + + @Bean + @StepScope + public ItemWriter monthlyMetricsWriter() { + return new MonthlyMetricsWriter(monthlyRepository); + } +} diff --git a/apps/commerce-collector/src/main/java/com/loopers/batch/metrics/job/ProductMetricsWeeklyJobConfig.java b/apps/commerce-collector/src/main/java/com/loopers/batch/metrics/job/ProductMetricsWeeklyJobConfig.java new file mode 100644 index 000000000..bb747453d --- /dev/null +++ b/apps/commerce-collector/src/main/java/com/loopers/batch/metrics/job/ProductMetricsWeeklyJobConfig.java @@ -0,0 +1,104 @@ +package com.loopers.batch.metrics.job; + +import com.loopers.batch.listener.ChunkListener; +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; +import com.loopers.batch.metrics.WeeklyMetricsProcessor; +import com.loopers.batch.metrics.WeeklyMetricsWriter; +import com.loopers.domain.metrics.ProductMetricsWeekly; +import com.loopers.domain.metrics.ProductMetricsWeeklyRepository; +import com.loopers.domain.metrics.dto.WeeklyAggregationDto; +import com.loopers.infrastructure.metrics.ProductMetricsDailyJpaRepository; +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.data.RepositoryItemReader; +import org.springframework.batch.item.data.builder.RepositoryItemReaderBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.domain.Sort; +import org.springframework.transaction.PlatformTransactionManager; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.temporal.IsoFields; +import java.util.List; +import java.util.Map; + +@Configuration +@RequiredArgsConstructor +public class ProductMetricsWeeklyJobConfig { + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final ProductMetricsDailyJpaRepository dailyJpaRepository; + private final ProductMetricsWeeklyRepository weeklyRepository; + + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final ChunkListener chunkListener; + + @Bean + public Job productMetricsWeeklyJob() { + return new JobBuilder("productMetricsWeeklyJob", jobRepository) + .start(aggregateWeeklyMetricsStep()) + .listener(jobListener) // Job Listener + .build(); + } + + @Bean + public Step aggregateWeeklyMetricsStep() { + return new StepBuilder("aggregateWeeklyMetricsStep", jobRepository) + .chunk(100, transactionManager) + .reader(weeklyMetricsReader(null, null)) + .processor(weeklyMetricsProcessor()) + .writer(weeklyMetricsWriter()) + .listener(stepMonitorListener) // Step Listener + .listener(chunkListener) // Chunk Listener + .build(); + } + + /** + * RepositoryItemReader를 사용하여 DB에서 페이징 집계 수행 + */ + @Bean + @StepScope + public RepositoryItemReader weeklyMetricsReader( + @Value("#{jobParameters['year']}") Integer year, + @Value("#{jobParameters['week']}") Integer week + ) { + // 주간 시작일/종료일 계산 + LocalDate startDate = LocalDate.of(year, 1, 1) + .with(IsoFields.WEEK_OF_WEEK_BASED_YEAR, week) + .with(DayOfWeek.MONDAY); + LocalDate endDate = startDate.plusDays(6); + + return new RepositoryItemReaderBuilder() + .name("weeklyMetricsReader") + .repository(dailyJpaRepository) + .methodName("findWeeklyAggregation") + .arguments(List.of(year, week, startDate, endDate)) + .pageSize(100) // Chunk Size와 일치 + .sorts(Map.of("productId", Sort.Direction.ASC)) + .build(); + } + + @Bean + @StepScope + public ItemProcessor weeklyMetricsProcessor() { + return new WeeklyMetricsProcessor(); + } + + @Bean + @StepScope + public ItemWriter weeklyMetricsWriter() { + return new WeeklyMetricsWriter(weeklyRepository); + } +} diff --git a/apps/commerce-collector/src/main/java/com/loopers/domain/metrics/ProductMetricsDailyRepository.java b/apps/commerce-collector/src/main/java/com/loopers/domain/metrics/ProductMetricsDailyRepository.java index 3202511f9..f16e095e4 100644 --- a/apps/commerce-collector/src/main/java/com/loopers/domain/metrics/ProductMetricsDailyRepository.java +++ b/apps/commerce-collector/src/main/java/com/loopers/domain/metrics/ProductMetricsDailyRepository.java @@ -1,6 +1,10 @@ package com.loopers.domain.metrics; import com.loopers.application.order.OrderMetrics; +import com.loopers.domain.metrics.dto.MonthlyAggregationDto; +import com.loopers.domain.metrics.dto.WeeklyAggregationDto; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import java.time.LocalDate; import java.util.List; @@ -20,4 +24,44 @@ public interface ProductMetricsDailyRepository { // 오래된 데이터 삭제 int deleteByMetricDateBefore(LocalDate cutoffDate); + + List findAllByMetricDateBetween(LocalDate startDate, LocalDate endDate); + + /** + * 주간 집계 데이터 조회 (페이징) + * Spring Batch Job에서 사용 + * + * @param year 집계 연도 + * @param week 집계 주차 + * @param startDate 집계 시작일 (월요일) + * @param endDate 집계 종료일 (일요일) + * @param pageable 페이징 정보 + * @return 상품별 주간 집계 결과 + */ + Page findWeeklyAggregation( + Integer year, + Integer week, + LocalDate startDate, + LocalDate endDate, + Pageable pageable + ); + + /** + * 월간 집계 데이터 조회 (페이징) + * Spring Batch Job에서 사용 + * + * @param year 집계 연도 + * @param month 집계 월 + * @param startDate 집계 시작일 (1일) + * @param endDate 집계 종료일 (말일) + * @param pageable 페이징 정보 + * @return 상품별 월간 집계 결과 + */ + Page findMonthlyAggregation( + Integer year, + Integer month, + LocalDate startDate, + LocalDate endDate, + Pageable pageable + ); } diff --git a/apps/commerce-collector/src/main/java/com/loopers/domain/metrics/ProductMetricsMonthly.java b/apps/commerce-collector/src/main/java/com/loopers/domain/metrics/ProductMetricsMonthly.java new file mode 100644 index 000000000..fadc512e5 --- /dev/null +++ b/apps/commerce-collector/src/main/java/com/loopers/domain/metrics/ProductMetricsMonthly.java @@ -0,0 +1,111 @@ +package com.loopers.domain.metrics; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.ZonedDateTime; + +/** + * 월간 상품 집계 Materialized View + * Spring Batch를 통해 ProductMetricsDaily 데이터를 월 단위로 집계 + */ +@Entity +@Table( + name = "mv_product_metrics_monthly", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_product_year_month", + columnNames = {"product_id", "year", "month"} + ) + }, + indexes = { + @Index(name = "idx_year_month", columnList = "year, month"), + @Index(name = "idx_product_id", columnList = "product_id") + } +) +@Getter +@NoArgsConstructor +public class ProductMetricsMonthly extends BaseEntity { + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "year", nullable = false) + private Integer year; + + @Column(name = "month", nullable = false) + private Integer month; + + /** + * 집계 기간 시작일 (해당 월의 1일) + */ + @Column(name = "period_start_date", nullable = false) + private LocalDate periodStartDate; + + /** + * 집계 기간 종료일 (해당 월의 말일) + */ + @Column(name = "period_end_date", nullable = false) + private LocalDate periodEndDate; + + @Column(name = "total_like_count", nullable = false) + private Long totalLikeCount = 0L; + + @Column(name = "total_view_count", nullable = false) + private Long totalViewCount = 0L; + + @Column(name = "total_order_count", nullable = false) + private Long totalOrderCount = 0L; + + /** + * 마지막 집계 시각 + */ + @Column(name = "aggregated_at") + private ZonedDateTime aggregatedAt; + + /** + * 월간 집계 생성 + */ + public static ProductMetricsMonthly create( + Long productId, + Integer year, + Integer month, + LocalDate periodStartDate, + LocalDate periodEndDate + ) { + ProductMetricsMonthly metrics = new ProductMetricsMonthly(); + metrics.productId = productId; + metrics.year = year; + metrics.month = month; + metrics.periodStartDate = periodStartDate; + metrics.periodEndDate = periodEndDate; + return metrics; + } + + /** + * 집계 메트릭 업데이트 + */ + public void updateMetrics( + Long likeCount, + Long viewCount, + Long orderCount + ) { + this.totalLikeCount = likeCount; + this.totalViewCount = viewCount; + this.totalOrderCount = orderCount; + this.aggregatedAt = ZonedDateTime.now(); + } + + /** + * 집계 데이터 초기화 (재집계 시) + */ + public void reset() { + this.totalLikeCount = 0L; + this.totalViewCount = 0L; + this.totalOrderCount = 0L; + this.aggregatedAt = null; + } +} diff --git a/apps/commerce-collector/src/main/java/com/loopers/domain/metrics/ProductMetricsMonthlyRepository.java b/apps/commerce-collector/src/main/java/com/loopers/domain/metrics/ProductMetricsMonthlyRepository.java new file mode 100644 index 000000000..bc86bcd1f --- /dev/null +++ b/apps/commerce-collector/src/main/java/com/loopers/domain/metrics/ProductMetricsMonthlyRepository.java @@ -0,0 +1,10 @@ +package com.loopers.domain.metrics; + +import java.util.List; + +public interface ProductMetricsMonthlyRepository { + ProductMetricsMonthly save(ProductMetricsMonthly metrics); + void saveAll(List metricsList); + int deleteByYearAndMonthBefore(Integer year, Integer month); + List findAll(); +} diff --git a/apps/commerce-collector/src/main/java/com/loopers/domain/metrics/ProductMetricsWeekly.java b/apps/commerce-collector/src/main/java/com/loopers/domain/metrics/ProductMetricsWeekly.java new file mode 100644 index 000000000..0daaea175 --- /dev/null +++ b/apps/commerce-collector/src/main/java/com/loopers/domain/metrics/ProductMetricsWeekly.java @@ -0,0 +1,111 @@ +package com.loopers.domain.metrics; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.ZonedDateTime; + +/** + * 주간 상품 집계 Materialized View + * Spring Batch를 통해 ProductMetricsDaily 데이터를 주 단위로 집계 + */ +@Entity +@Table( + name = "mv_product_metrics_weekly", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_product_year_week", + columnNames = {"product_id", "year", "week"} + ) + }, + indexes = { + @Index(name = "idx_year_week", columnList = "year, week"), + @Index(name = "idx_product_id", columnList = "product_id") + } +) +@Getter +@NoArgsConstructor +public class ProductMetricsWeekly extends BaseEntity { + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "year", nullable = false) + private Integer year; + + @Column(name = "week", nullable = false) + private Integer week; + + /** + * 집계 기간 시작일 (해당 주의 월요일) + */ + @Column(name = "period_start_date", nullable = false) + private LocalDate periodStartDate; + + /** + * 집계 기간 종료일 (해당 주의 일요일) + */ + @Column(name = "period_end_date", nullable = false) + private LocalDate periodEndDate; + + @Column(name = "total_like_count", nullable = false) + private Long totalLikeCount = 0L; + + @Column(name = "total_view_count", nullable = false) + private Long totalViewCount = 0L; + + @Column(name = "total_order_count", nullable = false) + private Long totalOrderCount = 0L; + + /** + * 마지막 집계 시각 + */ + @Column(name = "aggregated_at") + private ZonedDateTime aggregatedAt; + + /** + * 주간 집계 생성 + */ + public static ProductMetricsWeekly create( + Long productId, + Integer year, + Integer week, + LocalDate periodStartDate, + LocalDate periodEndDate + ) { + ProductMetricsWeekly metrics = new ProductMetricsWeekly(); + metrics.productId = productId; + metrics.year = year; + metrics.week = week; + metrics.periodStartDate = periodStartDate; + metrics.periodEndDate = periodEndDate; + return metrics; + } + + /** + * 집계 메트릭 업데이트 + */ + public void updateMetrics( + Long likeCount, + Long viewCount, + Long orderCount + ) { + this.totalLikeCount = likeCount; + this.totalViewCount = viewCount; + this.totalOrderCount = orderCount; + this.aggregatedAt = ZonedDateTime.now(); + } + + /** + * 집계 데이터 초기화 (재집계 시) + */ + public void reset() { + this.totalLikeCount = 0L; + this.totalViewCount = 0L; + this.totalOrderCount = 0L; + this.aggregatedAt = null; + } +} diff --git a/apps/commerce-collector/src/main/java/com/loopers/domain/metrics/ProductMetricsWeeklyRepository.java b/apps/commerce-collector/src/main/java/com/loopers/domain/metrics/ProductMetricsWeeklyRepository.java new file mode 100644 index 000000000..2d94d0171 --- /dev/null +++ b/apps/commerce-collector/src/main/java/com/loopers/domain/metrics/ProductMetricsWeeklyRepository.java @@ -0,0 +1,11 @@ +package com.loopers.domain.metrics; + +import java.util.List; + +public interface ProductMetricsWeeklyRepository { + + ProductMetricsWeekly save(ProductMetricsWeekly metrics); + void saveAll(List metricsList); + int deleteByYearAndWeekBefore(Integer year, Integer week); + List findAll(); +} diff --git a/apps/commerce-collector/src/main/java/com/loopers/domain/metrics/dto/MonthlyAggregationDto.java b/apps/commerce-collector/src/main/java/com/loopers/domain/metrics/dto/MonthlyAggregationDto.java new file mode 100644 index 000000000..cef0f8066 --- /dev/null +++ b/apps/commerce-collector/src/main/java/com/loopers/domain/metrics/dto/MonthlyAggregationDto.java @@ -0,0 +1,26 @@ +package com.loopers.domain.metrics.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +/** + * 월간 집계 데이터 DTO + * Spring Batch ItemReader에서 Repository 조회 결과로 사용됨 + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class MonthlyAggregationDto { + private Long productId; + private Integer year; + private Integer month; + private LocalDate periodStartDate; + private LocalDate periodEndDate; + private Long totalLikeCount; + private Long totalViewCount; + private Long totalOrderCount; + private Long totalOrderQuantity; +} \ No newline at end of file diff --git a/apps/commerce-collector/src/main/java/com/loopers/domain/metrics/dto/WeeklyAggregationDto.java b/apps/commerce-collector/src/main/java/com/loopers/domain/metrics/dto/WeeklyAggregationDto.java new file mode 100644 index 000000000..7a0bba457 --- /dev/null +++ b/apps/commerce-collector/src/main/java/com/loopers/domain/metrics/dto/WeeklyAggregationDto.java @@ -0,0 +1,26 @@ +package com.loopers.domain.metrics.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +/** + * 주간 집계 데이터 DTO + * Spring Batch ItemReader에서 Repository 조회 결과로 사용됨 + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class WeeklyAggregationDto { + private Long productId; + private Integer year; + private Integer week; + private LocalDate periodStartDate; + private LocalDate periodEndDate; + private Long totalLikeCount; + private Long totalViewCount; + private Long totalOrderCount; + private Long totalOrderQuantity; +} \ No newline at end of file diff --git a/apps/commerce-collector/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsDailyJpaRepository.java b/apps/commerce-collector/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsDailyJpaRepository.java index ae9f86240..3f2a89acc 100644 --- a/apps/commerce-collector/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsDailyJpaRepository.java +++ b/apps/commerce-collector/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsDailyJpaRepository.java @@ -1,6 +1,10 @@ package com.loopers.infrastructure.metrics; import com.loopers.domain.metrics.ProductMetricsDaily; +import com.loopers.domain.metrics.dto.MonthlyAggregationDto; +import com.loopers.domain.metrics.dto.WeeklyAggregationDto; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -18,4 +22,62 @@ public interface ProductMetricsDailyJpaRepository extends JpaRepository findAllByMetricDateBetween(LocalDate startDate, LocalDate endDate); + + /** + * 주간 집계 쿼리 (DB에서 GROUP BY 수행) + */ + @Query(""" + SELECT new com.loopers.domain.metrics.dto.WeeklyAggregationDto( + p.productId, + :year, + :week, + :startDate, + :endDate, + SUM(p.likeDelta), + SUM(p.viewDelta), + SUM(p.orderDelta), + 0L + ) + FROM ProductMetricsDaily p + WHERE p.metricDate BETWEEN :startDate AND :endDate + GROUP BY p.productId + ORDER BY p.productId + """) + Page findWeeklyAggregation( + @Param("year") Integer year, + @Param("week") Integer week, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate, + Pageable pageable + ); + + /** + * 월간 집계 쿼리 (DB에서 GROUP BY 수행) + */ + @Query(""" + SELECT new com.loopers.domain.metrics.dto.MonthlyAggregationDto( + p.productId, + :year, + :month, + :startDate, + :endDate, + SUM(p.likeDelta), + SUM(p.viewDelta), + SUM(p.orderDelta), + 0L + ) + FROM ProductMetricsDaily p + WHERE p.metricDate BETWEEN :startDate AND :endDate + GROUP BY p.productId + ORDER BY p.productId + """) + Page findMonthlyAggregation( + @Param("year") Integer year, + @Param("month") Integer month, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate, + Pageable pageable + ); } diff --git a/apps/commerce-collector/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsDailyRepositoryImpl.java b/apps/commerce-collector/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsDailyRepositoryImpl.java index 3c775779b..bab2c7657 100644 --- a/apps/commerce-collector/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsDailyRepositoryImpl.java +++ b/apps/commerce-collector/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsDailyRepositoryImpl.java @@ -154,4 +154,36 @@ public int getBatchSize() { public int deleteByMetricDateBefore(LocalDate cutoffDate) { return productMetricsDailyJpaRepository.deleteByMetricDateBefore(cutoffDate); } + + @Override + public List findAllByMetricDateBetween(LocalDate startDate, LocalDate endDate) { + return productMetricsDailyJpaRepository + .findAllByMetricDateBetween(startDate, endDate); + } + + @Override + public org.springframework.data.domain.Page findWeeklyAggregation( + Integer year, + Integer week, + LocalDate startDate, + LocalDate endDate, + org.springframework.data.domain.Pageable pageable + ) { + return productMetricsDailyJpaRepository.findWeeklyAggregation( + year, week, startDate, endDate, pageable + ); + } + + @Override + public org.springframework.data.domain.Page findMonthlyAggregation( + Integer year, + Integer month, + LocalDate startDate, + LocalDate endDate, + org.springframework.data.domain.Pageable pageable + ) { + return productMetricsDailyJpaRepository.findMonthlyAggregation( + year, month, startDate, endDate, pageable + ); + } } diff --git a/apps/commerce-collector/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsMonthlyJpaRepository.java b/apps/commerce-collector/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsMonthlyJpaRepository.java new file mode 100644 index 000000000..dca3ce7ab --- /dev/null +++ b/apps/commerce-collector/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsMonthlyJpaRepository.java @@ -0,0 +1,22 @@ +package com.loopers.infrastructure.metrics; + +import com.loopers.domain.metrics.ProductMetricsMonthly; +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; + +public interface ProductMetricsMonthlyJpaRepository extends JpaRepository { + + @Modifying + @Query(""" + DELETE FROM ProductMetricsMonthly m + WHERE (m.year < :year) + OR (m.year = :year AND m.month < :month) + """) + int deleteByYearAndMonthBefore( + @Param("year") Integer year, + @Param("month") Integer month + ); + +} diff --git a/apps/commerce-collector/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsMonthlyRepositoryImpl.java b/apps/commerce-collector/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsMonthlyRepositoryImpl.java new file mode 100644 index 000000000..4fe79c655 --- /dev/null +++ b/apps/commerce-collector/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsMonthlyRepositoryImpl.java @@ -0,0 +1,86 @@ +package com.loopers.infrastructure.metrics; + +import com.loopers.domain.metrics.ProductMetricsMonthly; +import com.loopers.domain.metrics.ProductMetricsMonthlyRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.BatchPreparedStatementSetter; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import java.sql.Date; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ProductMetricsMonthlyRepositoryImpl implements ProductMetricsMonthlyRepository { + + private final ProductMetricsMonthlyJpaRepository monthlyJpaRepository; + private final JdbcTemplate jdbcTemplate; + + @Override + public ProductMetricsMonthly save(ProductMetricsMonthly metrics) { + return monthlyJpaRepository.save(metrics); + } + + @Override + public void saveAll(List metricsList) { + if (metricsList.isEmpty()) { + return; + } + + // UPSERT를 위한 Bulk Insert with ON DUPLICATE KEY UPDATE + String sql = """ + INSERT INTO mv_product_metrics_monthly + (product_id, year, month, period_start_date, period_end_date, + total_like_count, total_view_count, total_order_count, + aggregated_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW()) + ON DUPLICATE KEY UPDATE + total_like_count = VALUES(total_like_count), + total_view_count = VALUES(total_view_count), + total_order_count = VALUES(total_order_count), + aggregated_at = VALUES(aggregated_at), + updated_at = NOW() + """; + + jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() { + @Override + public void setValues(PreparedStatement ps, int i) throws SQLException { + ProductMetricsMonthly metrics = metricsList.get(i); + ps.setLong(1, metrics.getProductId()); + ps.setInt(2, metrics.getYear()); + ps.setInt(3, metrics.getMonth()); + ps.setDate(4, Date.valueOf(metrics.getPeriodStartDate())); + ps.setDate(5, Date.valueOf(metrics.getPeriodEndDate())); + ps.setLong(6, metrics.getTotalLikeCount()); + ps.setLong(7, metrics.getTotalViewCount()); + ps.setLong(8, metrics.getTotalOrderCount()); + ps.setTimestamp(9, metrics.getAggregatedAt() != null + ? Timestamp.from(metrics.getAggregatedAt().toInstant()) + : new Timestamp(System.currentTimeMillis())); + } + + @Override + public int getBatchSize() { + return metricsList.size(); + } + }); + + log.info("월간 집계 Bulk UPSERT 완료: {} 건", metricsList.size()); + } + + @Override + public int deleteByYearAndMonthBefore(Integer year, Integer month) { + return monthlyJpaRepository.deleteByYearAndMonthBefore(year, month); + } + + @Override + public List findAll() { + return monthlyJpaRepository.findAll(); + } +} diff --git a/apps/commerce-collector/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsWeeklyJpaRepository.java b/apps/commerce-collector/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsWeeklyJpaRepository.java new file mode 100644 index 000000000..24d6a0e82 --- /dev/null +++ b/apps/commerce-collector/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsWeeklyJpaRepository.java @@ -0,0 +1,22 @@ +package com.loopers.infrastructure.metrics; + +import com.loopers.domain.metrics.ProductMetricsWeekly; +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; + +public interface ProductMetricsWeeklyJpaRepository extends JpaRepository { + + @Modifying + @Query(""" + DELETE FROM ProductMetricsWeekly m + WHERE (m.year < :year) + OR (m.year = :year AND m.week < :week) + """) + int deleteByYearAndWeekBefore( + @Param("year") Integer year, + @Param("week") Integer week + ); + +} diff --git a/apps/commerce-collector/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsWeeklyRepositoryImpl.java b/apps/commerce-collector/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsWeeklyRepositoryImpl.java new file mode 100644 index 000000000..20d813bff --- /dev/null +++ b/apps/commerce-collector/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsWeeklyRepositoryImpl.java @@ -0,0 +1,86 @@ +package com.loopers.infrastructure.metrics; + +import com.loopers.domain.metrics.ProductMetricsWeekly; +import com.loopers.domain.metrics.ProductMetricsWeeklyRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.BatchPreparedStatementSetter; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import java.sql.Date; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ProductMetricsWeeklyRepositoryImpl implements ProductMetricsWeeklyRepository { + + private final ProductMetricsWeeklyJpaRepository weeklyJpaRepository; + private final JdbcTemplate jdbcTemplate; + + @Override + public ProductMetricsWeekly save(ProductMetricsWeekly metrics) { + return weeklyJpaRepository.save(metrics); + } + + @Override + public void saveAll(List metricsList) { + if (metricsList.isEmpty()) { + return; + } + + // UPSERT를 위한 Bulk Insert with ON DUPLICATE KEY UPDATE + String sql = """ + INSERT INTO mv_product_metrics_weekly + (product_id, year, week, period_start_date, period_end_date, + total_like_count, total_view_count, total_order_count, + aggregated_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW()) + ON DUPLICATE KEY UPDATE + total_like_count = VALUES(total_like_count), + total_view_count = VALUES(total_view_count), + total_order_count = VALUES(total_order_count), + aggregated_at = VALUES(aggregated_at), + updated_at = NOW() + """; + + jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() { + @Override + public void setValues(PreparedStatement ps, int i) throws SQLException { + ProductMetricsWeekly metrics = metricsList.get(i); + ps.setLong(1, metrics.getProductId()); + ps.setInt(2, metrics.getYear()); + ps.setInt(3, metrics.getWeek()); + ps.setDate(4, Date.valueOf(metrics.getPeriodStartDate())); + ps.setDate(5, Date.valueOf(metrics.getPeriodEndDate())); + ps.setLong(6, metrics.getTotalLikeCount()); + ps.setLong(7, metrics.getTotalViewCount()); + ps.setLong(8, metrics.getTotalOrderCount()); + ps.setTimestamp(9, metrics.getAggregatedAt() != null + ? Timestamp.from(metrics.getAggregatedAt().toInstant()) + : new Timestamp(System.currentTimeMillis())); + } + + @Override + public int getBatchSize() { + return metricsList.size(); + } + }); + + log.info("주간 집계 Bulk UPSERT 완료: {} 건", metricsList.size()); + } + + @Override + public int deleteByYearAndWeekBefore(Integer year, Integer week) { + return weeklyJpaRepository.deleteByYearAndWeekBefore(year, week); + } + + @Override + public List findAll() { + return weeklyJpaRepository.findAll(); + } +} diff --git a/apps/commerce-collector/src/test/java/com/loopers/batch/metrics/job/ProductMetricsMonthlyJobTest.java b/apps/commerce-collector/src/test/java/com/loopers/batch/metrics/job/ProductMetricsMonthlyJobTest.java new file mode 100644 index 000000000..2ffc44278 --- /dev/null +++ b/apps/commerce-collector/src/test/java/com/loopers/batch/metrics/job/ProductMetricsMonthlyJobTest.java @@ -0,0 +1,273 @@ +package com.loopers.batch.metrics.job; + +import com.loopers.domain.metrics.ProductMetricsDaily; +import com.loopers.domain.metrics.ProductMetricsDailyRepository; +import com.loopers.domain.metrics.ProductMetricsMonthly; +import com.loopers.domain.metrics.ProductMetricsMonthlyRepository; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.*; +import org.springframework.batch.test.JobLauncherTestUtils; +import org.springframework.batch.test.JobRepositoryTestUtils; +import org.springframework.batch.test.context.SpringBatchTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@SpringBatchTest +@TestPropertySource(properties = { + "spring.batch.job.enabled=false", + "spring.batch.jdbc.initialize-schema=always" +}) +class ProductMetricsMonthlyJobTest { + + @Autowired + private JobLauncherTestUtils jobLauncherTestUtils; + + @Autowired + private JobRepositoryTestUtils jobRepositoryTestUtils; + + @Autowired + private ProductMetricsDailyRepository dailyRepository; + + @Autowired + private ProductMetricsMonthlyRepository monthlyRepository; + + @Autowired + private Job productMetricsMonthlyJob; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @BeforeEach + void setUp() { + jobLauncherTestUtils.setJob(productMetricsMonthlyJob); + jobRepositoryTestUtils.removeJobExecutions(); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Test + @DisplayName("월간 집계 배치가 성공적으로 실행되고 Monthly 데이터가 생성된다") + void productMetricsMonthlyJob_Success() throws Exception { + // given: 2025년 12월 (2025-12-01 ~ 2025-12-31) Daily 데이터 생성 + int year = 2025; + int month = 12; + LocalDate startDate = LocalDate.of(2025, 12, 1); + LocalDate endDate = LocalDate.of(2025, 12, 31); + + // 상품 3개에 대해 31일치 Daily 데이터 생성 + List dailyMetrics = new ArrayList<>(); + for (long productId = 1L; productId <= 3L; productId++) { + for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) { + ProductMetricsDaily daily = ProductMetricsDaily.create(productId, date); + daily.addLikeDelta(10); // 일일 좋아요 10개 + daily.addViewDelta(100); // 일일 조회 100개 + daily.addOrderDelta(5); // 일일 주문 5개 + dailyMetrics.add(daily); + } + } + dailyRepository.saveAll(dailyMetrics); + + // when: Job 실행 + JobParameters jobParameters = new JobParametersBuilder() + .addLong("timestamp", System.currentTimeMillis()) + .addString("year", String.valueOf(year)) + .addString("month", String.valueOf(month)) + .toJobParameters(); + + JobExecution jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // then: Job 성공 확인 + assertThat(jobExecution.getStatus()).isEqualTo(BatchStatus.COMPLETED); + assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo("COMPLETED"); + + // Step 실행 결과 확인 + StepExecution stepExecution = jobExecution.getStepExecutions().iterator().next(); + assertThat(stepExecution.getStepName()).isEqualTo("aggregateMonthlyMetricsStep"); + assertThat(stepExecution.getReadCount()).isEqualTo(3); // 3개 상품 + assertThat(stepExecution.getWriteCount()).isEqualTo(3); + assertThat(stepExecution.getStatus()).isEqualTo(BatchStatus.COMPLETED); + + // 실제 DB 저장 결과 확인 + List monthlyMetrics = monthlyRepository.findAll(); + assertThat(monthlyMetrics).hasSize(3); + + // 첫 번째 상품의 월간 집계 검증 + ProductMetricsMonthly firstProduct = monthlyMetrics.stream() + .filter(m -> m.getProductId().equals(1L)) + .findFirst() + .orElseThrow(); + + assertThat(firstProduct.getYear()).isEqualTo(year); + assertThat(firstProduct.getMonth()).isEqualTo(month); + assertThat(firstProduct.getPeriodStartDate()).isEqualTo(startDate); + assertThat(firstProduct.getPeriodEndDate()).isEqualTo(endDate); + assertThat(firstProduct.getTotalLikeCount()).isEqualTo(310L); // 10 * 31일 + assertThat(firstProduct.getTotalViewCount()).isEqualTo(3100L); // 100 * 31일 + assertThat(firstProduct.getTotalOrderCount()).isEqualTo(155L); // 5 * 31일 + assertThat(firstProduct.getAggregatedAt()).isNotNull(); + } + + @Test + @DisplayName("Daily 데이터가 없으면 월간 집계가 생성되지 않는다") + void productMetricsMonthlyJob_NoData() throws Exception { + // given: Daily 데이터 없음 + int year = 2025; + int month = 12; + + // when: Job 실행 + JobParameters jobParameters = new JobParametersBuilder() + .addLong("timestamp", System.currentTimeMillis()) + .addString("year", String.valueOf(year)) + .addString("month", String.valueOf(month)) + .toJobParameters(); + + JobExecution jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // then: Job은 성공하지만 처리 데이터 없음 + assertThat(jobExecution.getStatus()).isEqualTo(BatchStatus.COMPLETED); + + StepExecution stepExecution = jobExecution.getStepExecutions().iterator().next(); + assertThat(stepExecution.getReadCount()).isEqualTo(0); + assertThat(stepExecution.getWriteCount()).isEqualTo(0); + + // Monthly 데이터 생성되지 않음 + List monthlyMetrics = monthlyRepository.findAll(); + assertThat(monthlyMetrics).isEmpty(); + } + + @Test + @DisplayName("동일한 월간 집계를 다시 실행하면 UPSERT로 업데이트된다") + void productMetricsMonthlyJob_Upsert() throws Exception { + // given: 2025년 12월 Daily 데이터 생성 + int year = 2025; + int month = 12; + LocalDate startDate = LocalDate.of(2025, 12, 1); + + ProductMetricsDaily daily = ProductMetricsDaily.create(1L, startDate); + daily.addLikeDelta(10); + daily.addViewDelta(100); + daily.addOrderDelta(5); + dailyRepository.save(daily); + + JobParameters jobParameters = new JobParametersBuilder() + .addLong("timestamp", System.currentTimeMillis()) + .addString("year", String.valueOf(year)) + .addString("month", String.valueOf(month)) + .toJobParameters(); + + // when: 첫 번째 실행 + JobExecution firstExecution = jobLauncherTestUtils.launchJob(jobParameters); + assertThat(firstExecution.getStatus()).isEqualTo(BatchStatus.COMPLETED); + + List firstResult = monthlyRepository.findAll(); + assertThat(firstResult).hasSize(1); + assertThat(firstResult.get(0).getTotalLikeCount()).isEqualTo(10L); + + // Daily 데이터 변경 (증가) + ProductMetricsDaily updatedDaily = dailyRepository + .findByProductIdAndMetricDate(1L, startDate) + .orElseThrow(); + updatedDaily.addLikeDelta(20); // 추가로 20 증가 + dailyRepository.save(updatedDaily); + + // when: 동일한 월로 두 번째 실행 (새로운 timestamp로) + JobParameters secondJobParameters = new JobParametersBuilder() + .addLong("timestamp", System.currentTimeMillis() + 1000) + .addString("year", String.valueOf(year)) + .addString("month", String.valueOf(month)) + .toJobParameters(); + + JobExecution secondExecution = jobLauncherTestUtils.launchJob(secondJobParameters); + + // then: Job 성공 + assertThat(secondExecution.getStatus()).isEqualTo(BatchStatus.COMPLETED); + + // Monthly 데이터는 여전히 1개 (UPSERT) + List secondResult = monthlyRepository.findAll(); + assertThat(secondResult).hasSize(1); + + // 값이 업데이트됨 (10 + 20 = 30) + assertThat(secondResult.get(0).getTotalLikeCount()).isEqualTo(30L); + } + + @Test + @DisplayName("특정 Step만 실행할 수 있다") + void aggregateMonthlyMetricsStep_Success() { + // given: 테스트 데이터 + ProductMetricsDaily daily = ProductMetricsDaily.create(1L, LocalDate.of(2025, 12, 1)); + daily.addLikeDelta(50); + dailyRepository.save(daily); + + // JobParameters 생성 (StepScope 빈에 필요) + JobParameters jobParameters = new JobParametersBuilder() + .addLong("timestamp", System.currentTimeMillis()) + .addString("year", "2025") + .addString("month", "12") + .toJobParameters(); + + // when: Step만 실행 + JobExecution jobExecution = jobLauncherTestUtils.launchStep("aggregateMonthlyMetricsStep", jobParameters); + + // then: Step 성공 + assertThat(jobExecution.getStatus()).isEqualTo(BatchStatus.COMPLETED); + + StepExecution stepExecution = jobExecution.getStepExecutions().iterator().next(); + assertThat(stepExecution.getStepName()).isEqualTo("aggregateMonthlyMetricsStep"); + } + + @Test + @DisplayName("2월(28일)과 12월(31일)의 일수 차이를 정확히 처리한다") + void productMetricsMonthlyJob_DifferentMonthDays() throws Exception { + // given: 2025년 2월 (평년, 28일) + int year = 2025; + int month = 2; + LocalDate startDate = LocalDate.of(2025, 2, 1); + LocalDate endDate = LocalDate.of(2025, 2, 28); // 2025년은 평년 + + // 상품 1개에 대해 2월 전체 Daily 데이터 생성 + List dailyMetrics = new ArrayList<>(); + for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) { + ProductMetricsDaily daily = ProductMetricsDaily.create(1L, date); + daily.addLikeDelta(10); + dailyMetrics.add(daily); + } + dailyRepository.saveAll(dailyMetrics); + + // when: Job 실행 + JobParameters jobParameters = new JobParametersBuilder() + .addLong("timestamp", System.currentTimeMillis()) + .addString("year", String.valueOf(year)) + .addString("month", String.valueOf(month)) + .toJobParameters(); + + JobExecution jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // then: Job 성공 + assertThat(jobExecution.getStatus()).isEqualTo(BatchStatus.COMPLETED); + + List monthlyMetrics = monthlyRepository.findAll(); + assertThat(monthlyMetrics).hasSize(1); + + ProductMetricsMonthly result = monthlyMetrics.get(0); + assertThat(result.getYear()).isEqualTo(year); + assertThat(result.getMonth()).isEqualTo(month); + assertThat(result.getPeriodStartDate()).isEqualTo(startDate); + assertThat(result.getPeriodEndDate()).isEqualTo(endDate); + assertThat(result.getTotalLikeCount()).isEqualTo(280L); // 10 * 28일 (평년) + } +} diff --git a/apps/commerce-collector/src/test/java/com/loopers/batch/metrics/job/ProductMetricsWeeklyJobTest.java b/apps/commerce-collector/src/test/java/com/loopers/batch/metrics/job/ProductMetricsWeeklyJobTest.java new file mode 100644 index 000000000..260f683f7 --- /dev/null +++ b/apps/commerce-collector/src/test/java/com/loopers/batch/metrics/job/ProductMetricsWeeklyJobTest.java @@ -0,0 +1,232 @@ +package com.loopers.batch.metrics.job; + +import com.loopers.domain.metrics.ProductMetricsDaily; +import com.loopers.domain.metrics.ProductMetricsDailyRepository; +import com.loopers.domain.metrics.ProductMetricsWeekly; +import com.loopers.domain.metrics.ProductMetricsWeeklyRepository; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.*; +import org.springframework.batch.test.JobLauncherTestUtils; +import org.springframework.batch.test.JobRepositoryTestUtils; +import org.springframework.batch.test.context.SpringBatchTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@SpringBatchTest +@TestPropertySource(properties = { + "spring.batch.job.enabled=false", + "spring.batch.jdbc.initialize-schema=always" +}) +class ProductMetricsWeeklyJobTest { + + @Autowired + private JobLauncherTestUtils jobLauncherTestUtils; + + @Autowired + private JobRepositoryTestUtils jobRepositoryTestUtils; + + @Autowired + private ProductMetricsDailyRepository dailyRepository; + + @Autowired + private ProductMetricsWeeklyRepository weeklyRepository; + + @Autowired + private Job productMetricsWeeklyJob; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @BeforeEach + void setUp() { + jobLauncherTestUtils.setJob(productMetricsWeeklyJob); + jobRepositoryTestUtils.removeJobExecutions(); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Test + @DisplayName("주간 집계 배치가 성공적으로 실행되고 Weekly 데이터가 생성된다") + void productMetricsWeeklyJob_Success() throws Exception { + // given: 2025년 12월 1주차 (2025-12-01 ~ 2025-12-07) Daily 데이터 생성 + int year = 2025; + int week = 49; + LocalDate startDate = LocalDate.of(2025, 12, 1); // 월요일 + LocalDate endDate = LocalDate.of(2025, 12, 7); // 일요일 + + // 상품 3개에 대해 7일치 Daily 데이터 생성 + List dailyMetrics = new ArrayList<>(); + for (long productId = 1L; productId <= 3L; productId++) { + for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) { + ProductMetricsDaily daily = ProductMetricsDaily.create(productId, date); + daily.addLikeDelta(10); // 일일 좋아요 10개 + daily.addViewDelta(100); // 일일 조회 100개 + daily.addOrderDelta(5); // 일일 주문 5개 + dailyMetrics.add(daily); + } + } + dailyRepository.saveAll(dailyMetrics); + + // when: Job 실행 + JobParameters jobParameters = new JobParametersBuilder() + .addLong("timestamp", System.currentTimeMillis()) + .addString("year", String.valueOf(year)) + .addString("week", String.valueOf(week)) + .toJobParameters(); + + JobExecution jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // then: Job 성공 확인 + assertThat(jobExecution.getStatus()).isEqualTo(BatchStatus.COMPLETED); + assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo("COMPLETED"); + + // Step 실행 결과 확인 + StepExecution stepExecution = jobExecution.getStepExecutions().iterator().next(); + assertThat(stepExecution.getStepName()).isEqualTo("aggregateWeeklyMetricsStep"); + assertThat(stepExecution.getReadCount()).isEqualTo(3); // 3개 상품 + assertThat(stepExecution.getWriteCount()).isEqualTo(3); + assertThat(stepExecution.getStatus()).isEqualTo(BatchStatus.COMPLETED); + + // 실제 DB 저장 결과 확인 + List weeklyMetrics = weeklyRepository.findAll(); + assertThat(weeklyMetrics).hasSize(3); + + // 첫 번째 상품의 주간 집계 검증 + ProductMetricsWeekly firstProduct = weeklyMetrics.stream() + .filter(m -> m.getProductId().equals(1L)) + .findFirst() + .orElseThrow(); + + assertThat(firstProduct.getYear()).isEqualTo(year); + assertThat(firstProduct.getWeek()).isEqualTo(week); + assertThat(firstProduct.getPeriodStartDate()).isEqualTo(startDate); + assertThat(firstProduct.getPeriodEndDate()).isEqualTo(endDate); + assertThat(firstProduct.getTotalLikeCount()).isEqualTo(70L); // 10 * 7일 + assertThat(firstProduct.getTotalViewCount()).isEqualTo(700L); // 100 * 7일 + assertThat(firstProduct.getTotalOrderCount()).isEqualTo(35L); // 5 * 7일 + assertThat(firstProduct.getAggregatedAt()).isNotNull(); + } + + @Test + @DisplayName("Daily 데이터가 없으면 주간 집계가 생성되지 않는다") + void productMetricsWeeklyJob_NoData() throws Exception { + // given: Daily 데이터 없음 + int year = 2025; + int week = 49; + + // when: Job 실행 + JobParameters jobParameters = new JobParametersBuilder() + .addLong("timestamp", System.currentTimeMillis()) + .addString("year", String.valueOf(year)) + .addString("week", String.valueOf(week)) + .toJobParameters(); + + JobExecution jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // then: Job은 성공하지만 처리 데이터 없음 + assertThat(jobExecution.getStatus()).isEqualTo(BatchStatus.COMPLETED); + + StepExecution stepExecution = jobExecution.getStepExecutions().iterator().next(); + assertThat(stepExecution.getReadCount()).isEqualTo(0); + assertThat(stepExecution.getWriteCount()).isEqualTo(0); + + // Weekly 데이터 생성되지 않음 + List weeklyMetrics = weeklyRepository.findAll(); + assertThat(weeklyMetrics).isEmpty(); + } + + @Test + @DisplayName("동일한 주간 집계를 다시 실행하면 UPSERT로 업데이트된다") + void productMetricsWeeklyJob_Upsert() throws Exception { + // given: 2025년 12월 1주차 Daily 데이터 생성 + int year = 2025; + int week = 49; + LocalDate startDate = LocalDate.of(2025, 12, 1); + + ProductMetricsDaily daily = ProductMetricsDaily.create(1L, startDate); + daily.addLikeDelta(10); + daily.addViewDelta(100); + daily.addOrderDelta(5); + dailyRepository.save(daily); + + JobParameters jobParameters = new JobParametersBuilder() + .addLong("timestamp", System.currentTimeMillis()) + .addString("year", String.valueOf(year)) + .addString("week", String.valueOf(week)) + .toJobParameters(); + + // when: 첫 번째 실행 + JobExecution firstExecution = jobLauncherTestUtils.launchJob(jobParameters); + assertThat(firstExecution.getStatus()).isEqualTo(BatchStatus.COMPLETED); + + List firstResult = weeklyRepository.findAll(); + assertThat(firstResult).hasSize(1); + assertThat(firstResult.get(0).getTotalLikeCount()).isEqualTo(10L); + + // Daily 데이터 변경 (증가) + ProductMetricsDaily updatedDaily = dailyRepository + .findByProductIdAndMetricDate(1L, startDate) + .orElseThrow(); + updatedDaily.addLikeDelta(20); // 추가로 20 증가 + dailyRepository.save(updatedDaily); + + // when: 동일한 주차로 두 번째 실행 (새로운 timestamp로) + JobParameters secondJobParameters = new JobParametersBuilder() + .addLong("timestamp", System.currentTimeMillis() + 1000) + .addString("year", String.valueOf(year)) + .addString("week", String.valueOf(week)) + .toJobParameters(); + + JobExecution secondExecution = jobLauncherTestUtils.launchJob(secondJobParameters); + + // then: Job 성공 + assertThat(secondExecution.getStatus()).isEqualTo(BatchStatus.COMPLETED); + + // Weekly 데이터는 여전히 1개 (UPSERT) + List secondResult = weeklyRepository.findAll(); + assertThat(secondResult).hasSize(1); + + // 값이 업데이트됨 (10 + 20 = 30) + assertThat(secondResult.get(0).getTotalLikeCount()).isEqualTo(30L); + } + + @Test + @DisplayName("특정 Step만 실행할 수 있다") + void aggregateWeeklyMetricsStep_Success() { + // given: 테스트 데이터 + ProductMetricsDaily daily = ProductMetricsDaily.create(1L, LocalDate.of(2025, 12, 1)); + daily.addLikeDelta(50); + dailyRepository.save(daily); + + // JobParameters 생성 (StepScope 빈에 필요) + JobParameters jobParameters = new JobParametersBuilder() + .addLong("timestamp", System.currentTimeMillis()) + .addString("year", "2025") + .addString("week", "49") + .toJobParameters(); + + // when: Step만 실행 + JobExecution jobExecution = jobLauncherTestUtils.launchStep("aggregateWeeklyMetricsStep", jobParameters); + + // then: Step 성공 + assertThat(jobExecution.getStatus()).isEqualTo(BatchStatus.COMPLETED); + + StepExecution stepExecution = jobExecution.getStepExecutions().iterator().next(); + assertThat(stepExecution.getStepName()).isEqualTo("aggregateWeeklyMetricsStep"); + } +} \ No newline at end of file