diff --git a/.docs/design/03-class-diagram.md b/.docs/design/03-class-diagram.md index 46b76008e..293fa11fa 100644 --- a/.docs/design/03-class-diagram.md +++ b/.docs/design/03-class-diagram.md @@ -162,6 +162,102 @@ classDiagram User "1" --> "*" UserCoupon : 보유 쿠폰 Coupon "1" --> "*" UserCoupon : 발급됨 UserCoupon ..> CouponType : 사용 + Order "1" --> "0..1" Payment : 결제 정보 + + class Payment { + <> + +String transactionKey + +String orderId + +String userId + +BigDecimal amount + +PaymentStatus status + +String failureReason + +String cardType + +String cardNo + +updateStatus(status, reason) 상태 변경 + +isPending() 대기 확인 + +isSuccess() 성공 확인 + +isFailed() 실패 확인 + 결제 정보 + } + + class PaymentStatus { + <> + PENDING 대기 + SUCCESS 성공 + FAILED 실패 + } + + class EventOutbox { + <> + +String aggregateType + +String aggregateId + +String eventType + +String payload + +OutboxStatus status + +Integer retryCount + +String errorMessage + +markAsPublished() 발행 완료 + +markAsFailed(message) 발행 실패 + +canRetry() 재시도 가능 여부 + 이벤트 아웃박스 + } + + class OutboxStatus { + <> + PENDING 대기 + PUBLISHED 발행 완료 + FAILED 실패 + } + + class WeeklyProductRank { + <> + +Long id + +Long productId + +String yearWeek + +Integer rankPosition + +Double totalScore + +Integer likeCount + +Integer viewCount + +Integer orderCount + +BigDecimal salesAmount + 주간 랭킹 + } + + class MonthlyProductRank { + <> + +Long id + +Long productId + +String yearMonth + +Integer rankPosition + +Double totalScore + +Integer likeCount + +Integer viewCount + +Integer orderCount + +BigDecimal salesAmount + 월간 랭킹 + } + + class ProductMetrics { + <> + +Long productId + +Integer likeCount + +Integer viewCount + +Integer orderCount + +BigDecimal salesAmount + +Integer version + +incrementLikeCount() 좋아요 증가 + +decrementLikeCount() 좋아요 감소 + +incrementViewCount() 조회 증가 + +incrementOrderCount(quantity, amount) 주문 증가 + 실시간 메트릭 + } + + Payment ..> PaymentStatus : 사용 + EventOutbox ..> OutboxStatus : 사용 + Product "1" --> "*" WeeklyProductRank : 주간 랭킹 + Product "1" --> "*" MonthlyProductRank : 월간 랭킹 + Product "1" --> "1" ProductMetrics : 실시간 집계 ``` ## 📦 도메인별 상세 설계 @@ -854,3 +950,462 @@ Optional findByIdWithLock(@Param("id") Long id); - 기존: 상품 → 포인트 → 주문 - 변경: **쿠폰** → 상품 → **쿠폰 할인** → 포인트 → 주문 + +--- + +## 8. 랭킹 도메인 (Round 10 추가) + +### 8.1 WeeklyProductRank (주간 상품 랭킹) + +```mermaid +classDiagram + class WeeklyProductRank { + <> + -Long id + -Long productId + -String yearWeek + -Integer rankPosition + -Double totalScore + -Integer likeCount + -Integer viewCount + -Integer orderCount + -BigDecimal salesAmount + } +``` + +**책임**: "주간 상품 랭킹 데이터 저장" + +| 속성 | 설명 | 예시 | +|---|---|---| +| id | 랭킹 고유 번호 | 1 | +| productId | 상품 ID | 123 | +| yearWeek | ISO Week 형식 | "2025-W05" | +| rankPosition | 순위 (1~100) | 1 | +| totalScore | 총점 | 125.5 | +| likeCount | 좋아요 수 | 100 | +| viewCount | 조회 수 | 1000 | +| orderCount | 주문 수 | 50 | +| salesAmount | 판매 금액 | 5000000.00 | + +**비즈니스 규칙**: +``` +✓ Spring Batch로 주 1회 집계 (매주 월요일 01:00) +✓ TOP 100만 저장 +✓ 점수 계산: (view_count × 0.1) + (like_count × 0.2) + (order_count × 0.6 × log10(sales_amount + 1)) +✓ Read-Only 데이터 (조회 전용) +``` + +**특징**: +- Materialized View 패턴 적용 +- 조회 성능 최적화를 위한 사전 집계 데이터 +- Batch Job을 통해 주기적으로 갱신 + +--- + +### 8.2 MonthlyProductRank (월간 상품 랭킹) + +```mermaid +classDiagram + class MonthlyProductRank { + <> + -Long id + -Long productId + -String yearMonth + -Integer rankPosition + -Double totalScore + -Integer likeCount + -Integer viewCount + -Integer orderCount + -BigDecimal salesAmount + } +``` + +**책임**: "월간 상품 랭킹 데이터 저장" + +| 속성 | 설명 | 예시 | +|---|---|---| +| id | 랭킹 고유 번호 | 1 | +| productId | 상품 ID | 123 | +| yearMonth | 년월 형식 | "2025-01" | +| rankPosition | 순위 (1~100) | 1 | +| totalScore | 총점 | 850.3 | +| likeCount | 좋아요 수 | 400 | +| viewCount | 조회 수 | 5000 | +| orderCount | 주문 수 | 200 | +| salesAmount | 판매 금액 | 20000000.00 | + +**비즈니스 규칙**: +``` +✓ Spring Batch로 월 1회 집계 (매월 1일 02:00) +✓ TOP 100만 저장 +✓ 점수 계산: 주간 랭킹과 동일 +✓ Read-Only 데이터 (조회 전용) +``` + +--- + +### 8.3 Ranking API 설계 + +**RankingFacade의 역할**: +``` +RankingFacade: +"랭킹 데이터를 Product 정보와 함께 제공" + +1. Daily Ranking (기존) + → product_metrics 테이블 조회 + → Product 정보와 결합 + +2. Weekly Ranking (NEW) + → mv_product_rank_weekly 테이블 조회 + → Product 정보와 결합 + +3. Monthly Ranking (NEW) + → mv_product_rank_monthly 테이블 조회 + → Product 정보와 결합 +``` + +**왜 Facade를 사용하나?** +- 랭킹 데이터만으로는 불충분 (상품명, 브랜드명 필요) +- RankingService는 랭킹 조회만 담당 +- ProductService는 상품 정보 조회만 담당 +- RankingFacade가 둘을 결합 + +**조회 성능 최적화**: +``` +[나쁜 예] +매번 product_metrics 전체 집계 +→ 느림 (SUM, GROUP BY) + +[좋은 예 - Materialized View] +미리 계산된 랭킹 조회 +→ 빠름 (단순 SELECT) +``` + +--- + +## 10. 결제 도메인 (Round 6 추가) + +### 10.1 Payment (결제) + +```mermaid +classDiagram + class Payment { + <> + -Long id + -String transactionKey + -String orderId + -String userId + -BigDecimal amount + -PaymentStatus status + -String failureReason + -String cardType + -String cardNo + +updateStatus(status, failureReason) void + +isPending() boolean + +isSuccess() boolean + +isFailed() boolean + } + + class PaymentStatus { + <> + PENDING + SUCCESS + FAILED + } + + Payment ..> PaymentStatus : 사용 +``` + +**책임**: "결제 상태 관리 및 결제 정보 보관" + +| 속성 | 설명 | 예시 | +|---|---|---| +| id | 결제 고유 번호 | 1 | +| transactionKey | PG사 거래 키 (unique) | "TXN20250130123456" | +| orderId | 주문 ID | "123" | +| userId | 사용자 ID | "user123" | +| amount | 결제 금액 | 50000.00 | +| status | 결제 상태 | PENDING | +| failureReason | 실패 사유 | "카드 한도 초과" | +| cardType | 카드 타입 | "CREDIT" | +| cardNo | 카드 번호 (마스킹) | "1234-****-****-5678" | + +**비즈니스 규칙**: +``` +✓ transactionKey는 중복 불가 (PG사 거래 고유 식별자) +✓ 결제 상태는 PENDING → SUCCESS 또는 FAILED로만 변경 가능 +✓ SUCCESS 또는 FAILED는 최종 상태 (더 이상 변경 불가) +✓ 결제 실패 시 failureReason 필수 +``` + +**주요 메서드**: +- `updateStatus(status, failureReason)`: 결제 상태 업데이트 + - PENDING에서만 SUCCESS 또는 FAILED로 변경 가능 + - FAILED로 변경 시 failureReason 필수 +- `isPending()`, `isSuccess()`, `isFailed()`: 상태 확인 편의 메서드 + +**결제 프로세스**: +``` +1. 주문 생성 → Payment 생성 (status=PENDING) +2. PG사 연동 → 결제 요청 +3. PG사 콜백: + - 성공 → updateStatus(SUCCESS, null) + - 실패 → updateStatus(FAILED, "실패 사유") +4. PaymentSuccessEvent 또는 PaymentFailedEvent 발행 +``` + +--- + +### 10.2 PaymentStatus (결제 상태) + +| 상태 | 의미 | 다음 가능 상태 | +|---|---|---| +| PENDING | 결제 요청됨, PG사 응답 대기 중 | SUCCESS, FAILED | +| SUCCESS | 결제 성공 | 없음 (최종 상태) | +| FAILED | 결제 실패 | 없음 (최종 상태) | + +--- + +## 11. 이벤트 도메인 (Round 8 추가) + +### 11.1 EventOutbox (이벤트 아웃박스) + +```mermaid +classDiagram + class EventOutbox { + <> + -Long id + -String aggregateType + -String aggregateId + -String eventType + -String payload + -OutboxStatus status + -Integer retryCount + -String errorMessage + +markAsPublished() void + +markAsFailed(message) void + +canRetry() boolean + } + + class OutboxStatus { + <> + PENDING + PUBLISHED + FAILED + } + + EventOutbox ..> OutboxStatus : 사용 +``` + +**책임**: "Transactional Outbox 패턴 구현을 통한 이벤트 발행 보장" + +| 속성 | 설명 | 예시 | +|---|---|---| +| id | 아웃박스 고유 번호 | 1 | +| aggregateType | 애그리거트 타입 | "ORDER", "PAYMENT", "LIKE" | +| aggregateId | 애그리거트 ID | "123" | +| eventType | 이벤트 타입 | "OrderCreatedEvent" | +| payload | 이벤트 페이로드 (JSON) | "{\"orderId\":123,\"userId\":\"user1\"}" | +| status | 발행 상태 | PENDING | +| retryCount | 재시도 횟수 | 0 | +| errorMessage | 에러 메시지 | "Kafka broker not available" | + +**비즈니스 규칙**: +``` +✓ 비즈니스 트랜잭션과 동일한 트랜잭션에서 이벤트 저장 +✓ 최대 재시도 횟수는 3회 +✓ 3회 실패 시 status = FAILED (수동 처리 필요) +✓ PUBLISHED 상태는 최종 상태 +``` + +**Transactional Outbox 패턴**: +``` +[문제] +주문 생성 후 이벤트 발행 시 네트워크 장애로 실패하면? +→ 주문은 생성되었지만 이벤트는 발행 안 됨 +→ 데이터 불일치 + +[해결] +1. 주문 생성과 동시에 EventOutbox에 이벤트 저장 (같은 트랜잭션) +2. 별도 스케줄러가 PENDING 이벤트를 Kafka로 발행 +3. 발행 성공 → PUBLISHED +4. 발행 실패 → 재시도 (최대 3회) +5. 3회 실패 → FAILED (수동 처리) + +[장점] +✓ At-least-once 전송 보장 (이벤트 손실 방지) +✓ 트랜잭션 일관성 (주문 생성 실패 → 이벤트도 저장 안 됨) +``` + +**주요 메서드**: +- `markAsPublished()`: 발행 성공 처리 +- `markAsFailed(errorMessage)`: 발행 실패 처리 및 재시도 횟수 증가 +- `canRetry()`: 재시도 가능 여부 확인 (retryCount < 3) + +--- + +### 11.2 OutboxStatus (아웃박스 상태) + +| 상태 | 의미 | 다음 가능 상태 | +|---|---|---| +| PENDING | 발행 대기 중 | PUBLISHED, FAILED | +| PUBLISHED | 발행 완료 | 없음 (최종 상태) | +| FAILED | 발행 실패 (3회 초과) | 없음 (수동 처리 필요) | + +--- + +## 12. 메트릭 도메인 (Round 9 추가) + +### 12.1 ProductMetrics (상품 메트릭) + +```mermaid +classDiagram + class ProductMetrics { + <> + -Long productId + -Integer likeCount + -Integer viewCount + -Integer orderCount + -BigDecimal salesAmount + -Integer version + +incrementLikeCount() void + +decrementLikeCount() void + +incrementViewCount() void + +incrementOrderCount(quantity, amount) void + } +``` + +**책임**: "상품별 실시간 메트릭 집계" + +| 속성 | 설명 | 예시 | +|---|---|---| +| productId | 상품 ID (PK) | 123 | +| likeCount | 좋아요 수 | 100 | +| viewCount | 조회 수 | 1000 | +| orderCount | 주문 수 | 50 | +| salesAmount | 판매 금액 | 5000000.00 | +| version | 낙관적 락 버전 | 0 | + +**비즈니스 규칙**: +``` +✓ Kafka 이벤트를 통해 실시간 집계 +✓ 이벤트 타입별 처리: + - LikeCreatedEvent → incrementLikeCount() + - LikeDeletedEvent → decrementLikeCount() + - ProductViewedEvent → incrementViewCount() + - OrderCreatedEvent → incrementOrderCount() +✓ 낙관적 락을 통한 동시성 제어 +``` + +**이벤트 기반 집계**: +``` +[commerce-streamer 역할] +1. Kafka Consumer가 이벤트 수신 +2. ProductMetrics 조회 (없으면 생성) +3. 이벤트 타입별 메트릭 증가 +4. DB 저장 + +[예시] +ProductViewedEvent 수신 → productMetrics.incrementViewCount() +LikeCreatedEvent 수신 → productMetrics.incrementLikeCount() +OrderCreatedEvent 수신 → productMetrics.incrementOrderCount(quantity, amount) +``` + +**주요 메서드**: +- `incrementLikeCount()`: 좋아요 수 +1 +- `decrementLikeCount()`: 좋아요 수 -1 +- `incrementViewCount()`: 조회 수 +1 +- `incrementOrderCount(quantity, amount)`: 주문 수 +quantity, 판매 금액 +amount + +--- + +## 13. Spring Batch 아키텍처 (Round 10 추가) + +### 9.1 Batch Job 구성 + +```mermaid +classDiagram + class WeeklyRankingJobConfig { + <> + +weeklyRankingJob() Job + +weeklyRankingStep() Step + +weeklyMetricsReader() ItemReader + +weeklyRankingProcessor() ItemProcessor + +weeklyRankingWriter() ItemWriter + } + + class ProductMetricsAggregateReader { + <> + -EntityManager entityManager + -String period + -String periodType + -int topN + +read() ProductRankingAggregation + -fetchAggregatedData() List + } + + class RankingScoreProcessor { + <> + -String periodType + -String period + +process(item) WeeklyProductRank + -calculateScore(agg) double + } + + class WeeklyRankingWriter { + <> + -WeeklyRankJpaRepository repository + +write(chunk) void + } + + WeeklyRankingJobConfig --> ProductMetricsAggregateReader + WeeklyRankingJobConfig --> RankingScoreProcessor + WeeklyRankingJobConfig --> WeeklyRankingWriter +``` + +**Chunk-Oriented Processing**: +``` +Step 실행: +1. Reader: product_metrics에서 100개 읽기 +2. Processor: 점수 계산 및 순위 매기기 +3. Writer: mv_product_rank_weekly에 저장 +4. Transaction Commit +5. 다음 Chunk로 반복 +``` + +**책임 분리**: +- **Reader**: 데이터 읽기 (product_metrics 집계) +- **Processor**: 비즈니스 로직 (점수 계산) +- **Writer**: 데이터 쓰기 (Materialized View 저장) + +--- + +## 요약 + +### Round 10에서 추가된 도메인 + +1. **WeeklyProductRank**: 주간 상품 랭킹 (Materialized View) +2. **MonthlyProductRank**: 월간 상품 랭킹 (Materialized View) +3. **Spring Batch 컴포넌트**: Reader, Processor, Writer + +### 설계 패턴 + +- **Materialized View Pattern**: 조회 성능 최적화를 위한 사전 집계 +- **Chunk-Oriented Processing**: Batch 처리의 효율성과 트랜잭션 관리 +- **Facade Pattern**: 랭킹과 상품 정보를 결합하여 제공 + +### 배치 처리 흐름 + +``` +매주 월요일 01:00 (주간 랭킹): +1. product_metrics 집계 +2. 점수 계산 및 순위 매기기 +3. TOP 100 선정 +4. mv_product_rank_weekly 저장 + +매월 1일 02:00 (월간 랭킹): +1. product_metrics 집계 +2. 점수 계산 및 순위 매기기 +3. TOP 100 선정 +4. mv_product_rank_monthly 저장 +``` diff --git a/.docs/design/04-erd.md b/.docs/design/04-erd.md index 0aadcf02f..88df38f64 100644 --- a/.docs/design/04-erd.md +++ b/.docs/design/04-erd.md @@ -15,6 +15,15 @@ erDiagram orders ||--|{ order_items : "주문 항목" coupons ||--o{ user_coupons : "쿠폰 발급" users ||--o{ user_coupons : "보유 쿠폰" + products ||--o{ product_metrics : "메트릭 집계" + products ||--o{ mv_product_rank_weekly : "주간 랭킹" + products ||--o{ mv_product_rank_monthly : "월간 랭킹" + orders ||--o| payments : "결제 정보" + event_outbox }o--|| orders : "주문 이벤트" + event_outbox }o--|| payments : "결제 이벤트" + event_outbox }o--|| likes : "좋아요 이벤트" + event_inbox }o--|| catalog_events : "카탈로그 이벤트" + event_inbox }o--|| order_events : "주문 이벤트" users { bigint id PK @@ -119,6 +128,94 @@ erDiagram timestamp updated_at timestamp deleted_at } + + product_metrics { + bigint product_id PK + int like_count + int view_count + int order_count + decimal(15_2) sales_amount + int version + timestamp created_at + timestamp updated_at + } + + mv_product_rank_weekly { + bigint id PK + bigint product_id + varchar(10) year_week + int rank_position + double total_score + int like_count + int view_count + int order_count + decimal(15_2) sales_amount + timestamp created_at + timestamp updated_at + } + + mv_product_rank_monthly { + bigint id PK + bigint product_id + varchar(7) year_month + int rank_position + double total_score + int like_count + int view_count + int order_count + decimal(15_2) sales_amount + timestamp created_at + timestamp updated_at + } + + payments { + bigint id PK + varchar(100) transaction_key UK + varchar(20) order_id + varchar(10) user_id + decimal(19_2) amount + varchar(20) status + varchar(500) failure_reason + varchar(50) card_type + varchar(50) card_no + timestamp created_at + timestamp updated_at + } + + event_outbox { + bigint id PK + varchar(50) aggregate_type + varchar(100) aggregate_id + varchar(100) event_type + text payload + varchar(20) status + int retry_count + text error_message + timestamp created_at + timestamp updated_at + } + + event_inbox { + bigint id PK + varchar(100) event_id UK + varchar(50) aggregate_type + varchar(100) aggregate_id + varchar(100) event_type + text payload + timestamp processed_at + timestamp created_at + } + + dead_letter_queue { + bigint id PK + varchar(50) topic + int partition_number + bigint offset_value + varchar(100) event_type + text payload + text error_message + timestamp created_at + } ``` ## 📦 테이블별 상세 설계 @@ -457,6 +554,100 @@ INDEX idx_user_id_is_used (user_id, is_used, deleted_at) --- +## 11. product_metrics (상품 메트릭 집계) + +**설명**: 실시간 상품 이벤트 집계를 저장하는 테이블 (Round 9 추가) + +| 컬럼명 | 타입 | 제약조건 | 설명 | +|---|---|---|---| +| product_id | bigint | PK | 상품 ID | +| like_count | int | NOT NULL, DEFAULT 0 | 좋아요 수 | +| view_count | int | NOT NULL, DEFAULT 0 | 조회 수 | +| order_count | int | NOT NULL, DEFAULT 0 | 주문 수 | +| sales_amount | decimal(15,2) | NOT NULL, DEFAULT 0.00 | 판매 금액 | +| version | int | NOT NULL, DEFAULT 0 | 낙관적 락 버전 | +| created_at | timestamp | NOT NULL | 생성 시간 | +| updated_at | timestamp | NOT NULL | 수정 시간 | + +**비즈니스 규칙**: +- Kafka 이벤트를 통해 실시간으로 집계 +- `version` 필드를 통한 낙관적 락으로 동시성 제어 +- 일별 데이터 집계 + +**인덱스**: +```sql +INDEX idx_like_count (like_count DESC) +INDEX idx_view_count (view_count DESC) +INDEX idx_order_count (order_count DESC) +INDEX idx_updated_at (updated_at) +``` + +--- + +## 12. mv_product_rank_weekly (주간 랭킹) + +**설명**: 주간 상품 랭킹 Materialized View (Round 10 추가) + +| 컬럼명 | 타입 | 제약조건 | 설명 | +|---|---|---|---| +| id | bigint | PK, AUTO_INCREMENT | 랭킹 고유 번호 | +| product_id | bigint | NOT NULL | 상품 ID | +| year_week | varchar(10) | NOT NULL | ISO Week 형식 (YYYY-Wnn) | +| rank_position | int | NOT NULL | 순위 (1~100) | +| total_score | double | NOT NULL | 총점 | +| like_count | int | NOT NULL | 좋아요 수 | +| view_count | int | NOT NULL | 조회 수 | +| order_count | int | NOT NULL | 주문 수 | +| sales_amount | decimal(15,2) | NOT NULL | 판매 금액 | +| created_at | timestamp | NOT NULL | 생성 시간 | +| updated_at | timestamp | NOT NULL | 수정 시간 | + +**비즈니스 규칙**: +- Spring Batch로 주 1회 집계 (매주 월요일 01:00) +- TOP 100만 저장 +- 점수 계산: `(view_count × 0.1) + (like_count × 0.2) + (order_count × 0.6 × log10(sales_amount + 1))` + +**인덱스**: +```sql +UNIQUE KEY uk_product_week (product_id, year_week) +INDEX idx_year_week_rank (year_week, rank_position) +INDEX idx_year_week_score (year_week, total_score DESC) +``` + +--- + +## 13. mv_product_rank_monthly (월간 랭킹) + +**설명**: 월간 상품 랭킹 Materialized View (Round 10 추가) + +| 컬럼명 | 타입 | 제약조건 | 설명 | +|---|---|---|---| +| id | bigint | PK, AUTO_INCREMENT | 랭킹 고유 번호 | +| product_id | bigint | NOT NULL | 상품 ID | +| year_month | varchar(7) | NOT NULL | 년월 형식 (YYYY-MM) | +| rank_position | int | NOT NULL | 순위 (1~100) | +| total_score | double | NOT NULL | 총점 | +| like_count | int | NOT NULL | 좋아요 수 | +| view_count | int | NOT NULL | 조회 수 | +| order_count | int | NOT NULL | 주문 수 | +| sales_amount | decimal(15,2) | NOT NULL | 판매 금액 | +| created_at | timestamp | NOT NULL | 생성 시간 | +| updated_at | timestamp | NOT NULL | 수정 시간 | + +**비즈니스 규칙**: +- Spring Batch로 월 1회 집계 (매월 1일 02:00) +- TOP 100만 저장 +- 점수 계산: 주간 랭킹과 동일 + +**인덱스**: +```sql +UNIQUE KEY uk_product_month (product_id, year_month) +INDEX idx_year_month_rank (year_month, rank_position) +INDEX idx_year_month_score (year_month, total_score DESC) +``` + +--- + ## 업데이트된 order_items 테이블 (스냅샷 패턴) **변경 사항**: `product_id`를 FK에서 일반 컬럼으로 변경, 스냅샷 필드 추가 @@ -479,6 +670,166 @@ INDEX idx_user_id_is_used (user_id, is_used, deleted_at) --- +## 14. payments (결제) + +**설명**: 결제 정보 및 상태 관리 + +| 컬럼명 | 타입 | 제약조건 | 설명 | +|---|---|---|---| +| id | bigint | PK, AUTO_INCREMENT | 결제 고유 번호 | +| transaction_key | varchar(100) | UNIQUE, NOT NULL | PG사 거래 키 (중복 방지) | +| order_id | varchar(20) | NOT NULL | 주문 ID | +| user_id | varchar(10) | NOT NULL | 사용자 ID | +| amount | decimal(19,2) | NOT NULL | 결제 금액 | +| status | varchar(20) | NOT NULL | 결제 상태 (PENDING, SUCCESS, FAILED) | +| failure_reason | varchar(500) | NULL | 실패 사유 (FAILED 시 필수) | +| card_type | varchar(50) | NULL | 카드 타입 (CREDIT, DEBIT) | +| card_no | varchar(50) | NULL | 카드 번호 (마스킹 처리) | +| created_at | timestamp | NOT NULL | 생성 시간 | +| updated_at | timestamp | NOT NULL | 수정 시간 | + +**비즈니스 규칙**: +- `transaction_key`는 PG사에서 제공하는 고유 거래 식별자 +- 결제 상태는 PENDING → SUCCESS 또는 FAILED로만 변경 가능 +- 결제 실패 시 `failure_reason` 필수 +- `card_no`는 마스킹 처리 (예: 1234-****-****-5678) + +**인덱스**: +```sql +UNIQUE INDEX uk_transaction_key (transaction_key) +INDEX idx_order_id (order_id) +INDEX idx_user_id (user_id) +INDEX idx_status (status) +``` + +--- + +## 15. event_outbox (이벤트 아웃박스) + +**설명**: Transactional Outbox 패턴 구현 (Round 8 추가) + +| 컬럼명 | 타입 | 제약조건 | 설명 | +|---|---|---|---| +| id | bigint | PK, AUTO_INCREMENT | 아웃박스 고유 번호 | +| aggregate_type | varchar(50) | NOT NULL | 애그리거트 타입 (ORDER, PAYMENT, LIKE) | +| aggregate_id | varchar(100) | NOT NULL | 애그리거트 ID | +| event_type | varchar(100) | NOT NULL | 이벤트 타입 (OrderCreatedEvent 등) | +| payload | text | NOT NULL | 이벤트 페이로드 (JSON) | +| status | varchar(20) | NOT NULL, DEFAULT 'PENDING' | 발행 상태 (PENDING, PUBLISHED, FAILED) | +| retry_count | int | NOT NULL, DEFAULT 0 | 재시도 횟수 | +| error_message | text | NULL | 에러 메시지 (실패 시) | +| created_at | timestamp | NOT NULL | 생성 시간 | +| updated_at | timestamp | NOT NULL | 수정 시간 | + +**비즈니스 규칙**: +- 비즈니스 트랜잭션과 동일한 트랜잭션에서 이벤트 저장 +- 최대 재시도 횟수는 3회 +- 3회 실패 시 status = FAILED (수동 처리 필요) +- OutboxEventPublisher 스케줄러가 주기적으로 PENDING 이벤트 발행 + +**인덱스**: +```sql +INDEX idx_status_created (status, created_at) +INDEX idx_aggregate (aggregate_type, aggregate_id) +``` + +**Transactional Outbox 패턴**: +``` +[목적] +이벤트 발행 실패 시에도 이벤트 손실 방지 + +[흐름] +1. 주문 생성 + EventOutbox 저장 (같은 트랜잭션) +2. 트랜잭션 커밋 +3. OutboxEventPublisher가 PENDING 이벤트 조회 +4. Kafka로 발행 +5. 성공 → PUBLISHED / 실패 → 재시도 +``` + +--- + +## 16. event_inbox (이벤트 인박스) + +**설명**: Event Inbox 패턴 구현 - 멱등성 보장 (Round 9 추가) + +| 컬럼명 | 타입 | 제약조건 | 설명 | +|---|---|---|---| +| id | bigint | PK, AUTO_INCREMENT | 인박스 고유 번호 | +| event_id | varchar(100) | UNIQUE, NOT NULL | 이벤트 고유 ID (중복 방지) | +| aggregate_type | varchar(50) | NOT NULL | 애그리거트 타입 | +| aggregate_id | varchar(100) | NOT NULL | 애그리거트 ID | +| event_type | varchar(100) | NOT NULL | 이벤트 타입 | +| payload | text | NOT NULL | 이벤트 페이로드 (JSON) | +| processed_at | timestamp | NOT NULL | 처리 시간 | +| created_at | timestamp | NOT NULL | 생성 시간 (수신 시간) | + +**비즈니스 규칙**: +- Kafka 이벤트 수신 시 중복 처리 방지 +- `event_id`가 이미 존재하면 이벤트 스킵 (멱등성 보장) +- 처리 성공 시에만 저장 + +**인덱스**: +```sql +UNIQUE INDEX uk_event_id (event_id) +INDEX idx_aggregate (aggregate_type, aggregate_id) +INDEX idx_processed_at (processed_at) +``` + +**Event Inbox 패턴**: +``` +[목적] +중복 이벤트 처리 방지 (Exactly-once 보장) + +[흐름] +1. Kafka 메시지 수신 +2. event_id 추출 +3. event_inbox 테이블 조회 +4. 중복이면 → 스킵 +5. 중복 아니면 → 비즈니스 로직 실행 + event_inbox 저장 +``` + +--- + +## 17. dead_letter_queue (실패 메시지 저장) + +**설명**: 처리 실패한 Kafka 메시지 저장 (Round 9 추가) + +| 컬럼명 | 타입 | 제약조건 | 설명 | +|---|---|---|---| +| id | bigint | PK, AUTO_INCREMENT | DLQ 고유 번호 | +| topic | varchar(50) | NOT NULL | Kafka 토픽명 | +| partition_number | int | NOT NULL | 파티션 번호 | +| offset_value | bigint | NOT NULL | 오프셋 값 | +| event_type | varchar(100) | NULL | 이벤트 타입 | +| payload | text | NOT NULL | 메시지 페이로드 | +| error_message | text | NOT NULL | 에러 메시지 | +| created_at | timestamp | NOT NULL | 생성 시간 (실패 시간) | + +**비즈니스 규칙**: +- Kafka Consumer에서 처리 실패한 메시지 저장 +- 수동으로 재처리 가능 +- 에러 분석 및 디버깅 용도 + +**인덱스**: +```sql +INDEX idx_topic_created (topic, created_at DESC) +INDEX idx_event_type (event_type) +``` + +**DLQ 패턴**: +``` +[목적] +실패한 메시지를 별도 저장소에 보관하여 재처리 가능 + +[흐름] +1. Kafka 메시지 처리 중 예외 발생 +2. dead_letter_queue에 저장 +3. 로그로 알림 +4. 수동으로 원인 파악 후 재처리 +``` + +--- + ## 동시성 제어 전략 요약 ### Version 필드 (@Version) diff --git a/README.md b/README.md index 04950f29d..f86e4dd8a 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ docker-compose -f ./docker/monitoring-compose.yml up Root ├── apps ( spring-applications ) │ ├── 📦 commerce-api +│ ├── 📦 commerce-batch │ └── 📦 commerce-streamer ├── modules ( reusable-configurations ) │ ├── 📦 jpa 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 4749bd948..498e4851c 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,6 +3,10 @@ import com.loopers.application.product.ProductInfo; import com.loopers.application.product.ProductService; import com.loopers.application.ranking.RankingService.RankingItem; +import com.loopers.domain.rank.MonthlyProductRank; +import com.loopers.domain.rank.WeeklyProductRank; +import com.loopers.infrastructure.rank.MonthlyRankJpaRepository; +import com.loopers.infrastructure.rank.WeeklyRankJpaRepository; import java.math.BigDecimal; import java.util.ArrayList; import java.util.List; @@ -10,10 +14,14 @@ import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; /** - * 랭킹 + 상품 정보 조합 Facade + * Facade for combining ranking data with product information. + * + *

This service provides unified access to daily, weekly, and monthly rankings + * by merging ranking data with detailed product information. */ @Slf4j @Service @@ -22,19 +30,104 @@ public class RankingFacade { private final RankingService rankingService; private final ProductService productService; + private final WeeklyRankJpaRepository weeklyRankJpaRepository; + private final MonthlyRankJpaRepository monthlyRankJpaRepository; /** - * 일간 랭킹 페이지 조회 (상품 정보 포함) + * Retrieves daily rankings with product information. + * + * @param date the target date in yyyyMMdd format + * @param page the page number (1-based) + * @param size the page size + * @return list of rankings with product details */ public List getDailyRanking(String date, int page, int size) { - // 1. Redis ZSET에서 랭킹 조회 List rankingItems = rankingService.getDailyRanking(date, page, size); if (rankingItems.isEmpty()) { return List.of(); } - // 2. Product 정보 조회 (Batch) + return combineWithProductInfo(rankingItems); + } + + /** + * Retrieves today's rankings. + * + * @param page the page number (1-based) + * @param size the page size + * @return list of rankings with product details + */ + public List getTodayRanking(int page, int size) { + String today = java.time.LocalDate.now().format(java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd")); + return getDailyRanking(today, page, size); + } + + /** + * Retrieves weekly rankings with product information. + * + * @param yearWeek the target week in ISO format (e.g., "2025-W01") + * @param page the page number (1-based) + * @param size the page size + * @return list of rankings with product details + */ + public List getWeeklyRanking(String yearWeek, int page, int size) { + PageRequest pageRequest = PageRequest.of(page - 1, size); + List weeklyRanks = weeklyRankJpaRepository.findByYearWeekOrderByRankPositionAsc( + yearWeek, pageRequest + ); + + if (weeklyRanks.isEmpty()) { + return List.of(); + } + + return combineWithProductInfo( + weeklyRanks.stream() + .map(rank -> new RankingItem( + rank.getRankPosition(), + rank.getProductId(), + rank.getTotalScore() + )) + .toList() + ); + } + + /** + * Retrieves monthly rankings with product information. + * + * @param yearMonth the target month (e.g., "2025-01") + * @param page the page number (1-based) + * @param size the page size + * @return list of rankings with product details + */ + public List getMonthlyRanking(String yearMonth, int page, int size) { + PageRequest pageRequest = PageRequest.of(page - 1, size); + List monthlyRanks = monthlyRankJpaRepository.findByYearMonthOrderByRankPositionAsc( + yearMonth, pageRequest + ); + + if (monthlyRanks.isEmpty()) { + return List.of(); + } + + return combineWithProductInfo( + monthlyRanks.stream() + .map(rank -> new RankingItem( + rank.getRankPosition(), + rank.getProductId(), + rank.getTotalScore() + )) + .toList() + ); + } + + /** + * Combines ranking items with product information. + * + * @param rankingItems the list of ranking items + * @return list of rankings with product details + */ + private List combineWithProductInfo(List rankingItems) { List productIds = rankingItems.stream() .map(RankingItem::getProductId) .toList(); @@ -42,13 +135,12 @@ public List getDailyRanking(String date, int page, int size) Map productMap = productService.findByIds(productIds).stream() .collect(Collectors.toMap(ProductInfo::id, p -> p)); - // 3. 랭킹 + 상품 정보 조합 List results = new ArrayList<>(); for (RankingItem item : rankingItems) { ProductInfo product = productMap.get(item.getProductId()); if (product == null) { - log.warn("랭킹에 있지만 상품 정보 없음 - productId: {}", item.getProductId()); + log.warn("Product not found for ranking: productId={}", item.getProductId()); continue; } @@ -68,26 +160,18 @@ public List getDailyRanking(String date, int page, int size) } /** - * 오늘 랭킹 페이지 조회 - */ - public List getTodayRanking(int page, int size) { - String today = java.time.LocalDate.now().format(java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd")); - return getDailyRanking(today, page, size); - } - - /** - * 랭킹 + 상품 정보 DTO + * DTO for ranking combined with product information. */ @lombok.Getter @lombok.Builder public static class RankingProductInfo { - private int rank; // 순위 - private Double score; // 점수 - private Long productId; // 상품 ID - private String productName; // 상품명 - private String brandName; // 브랜드명 - private BigDecimal price; // 가격 - private Integer stock; // 재고 - private Long likeCount; // 좋아요 수 + private int rank; + private Double score; + private Long productId; + private String productName; + private String brandName; + private BigDecimal price; + private Integer stock; + private Long likeCount; } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/rank/MonthlyProductRank.java b/apps/commerce-api/src/main/java/com/loopers/domain/rank/MonthlyProductRank.java new file mode 100644 index 000000000..2b6893c1a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/rank/MonthlyProductRank.java @@ -0,0 +1,59 @@ +package com.loopers.domain.rank; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * Monthly product ranking entity for materialized view. + * + *

This table stores pre-aggregated monthly ranking data for performance optimization. + */ +@Entity +@Table(name = "mv_product_rank_monthly") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MonthlyProductRank { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "`year_month`", nullable = false, length = 7) + private String yearMonth; + + @Column(name = "rank_position", nullable = false) + private Integer rankPosition; + + @Column(name = "total_score", nullable = false) + private Double totalScore; + + @Column(name = "like_count", nullable = false) + private Integer likeCount; + + @Column(name = "view_count", nullable = false) + private Integer viewCount; + + @Column(name = "order_count", nullable = false) + private Integer orderCount; + + @Column(name = "sales_amount", nullable = false, precision = 15, scale = 2) + private BigDecimal salesAmount; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/rank/WeeklyProductRank.java b/apps/commerce-api/src/main/java/com/loopers/domain/rank/WeeklyProductRank.java new file mode 100644 index 000000000..093c87a39 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/rank/WeeklyProductRank.java @@ -0,0 +1,59 @@ +package com.loopers.domain.rank; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * Weekly product ranking entity for materialized view. + * + *

This table stores pre-aggregated weekly ranking data for performance optimization. + */ +@Entity +@Table(name = "mv_product_rank_weekly") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class WeeklyProductRank { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "year_week", nullable = false, length = 10) + private String yearWeek; + + @Column(name = "rank_position", nullable = false) + private Integer rankPosition; + + @Column(name = "total_score", nullable = false) + private Double totalScore; + + @Column(name = "like_count", nullable = false) + private Integer likeCount; + + @Column(name = "view_count", nullable = false) + private Integer viewCount; + + @Column(name = "order_count", nullable = false) + private Integer orderCount; + + @Column(name = "sales_amount", nullable = false, precision = 15, scale = 2) + private BigDecimal salesAmount; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/MonthlyRankJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/MonthlyRankJpaRepository.java new file mode 100644 index 000000000..c732b7876 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/MonthlyRankJpaRepository.java @@ -0,0 +1,21 @@ +package com.loopers.infrastructure.rank; + +import com.loopers.domain.rank.MonthlyProductRank; +import java.util.List; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +/** + * JPA Repository for MonthlyProductRank entity. + */ +public interface MonthlyRankJpaRepository extends JpaRepository { + + /** + * Finds monthly rankings for a specific month with pagination. + * + * @param yearMonth the year-month (e.g., "2025-01") + * @param pageable pagination parameters + * @return list of monthly rankings + */ + List findByYearMonthOrderByRankPositionAsc(String yearMonth, Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/WeeklyRankJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/WeeklyRankJpaRepository.java new file mode 100644 index 000000000..1fda668ae --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/WeeklyRankJpaRepository.java @@ -0,0 +1,21 @@ +package com.loopers.infrastructure.rank; + +import com.loopers.domain.rank.WeeklyProductRank; +import java.util.List; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +/** + * JPA Repository for WeeklyProductRank entity. + */ +public interface WeeklyRankJpaRepository extends JpaRepository { + + /** + * Finds weekly rankings for a specific week with pagination. + * + * @param yearWeek the year-week in ISO format (e.g., "2025-W01") + * @param pageable pagination parameters + * @return list of weekly rankings + */ + List findByYearWeekOrderByRankPositionAsc(String yearWeek, Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingApi.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingApi.java index 3958a85e9..0798a18f7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingApi.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingApi.java @@ -16,9 +16,11 @@ import org.springframework.web.bind.annotation.RestController; /** - * 랭킹 API + * API controller for product rankings. + * + *

Provides endpoints for querying daily, weekly, and monthly product rankings. */ -@Tag(name = "Ranking", description = "상품 랭킹 API") +@Tag(name = "Ranking", description = "Product Ranking API") @RestController @RequestMapping("/api/v1/rankings") @RequiredArgsConstructor @@ -29,28 +31,59 @@ public class RankingApi { private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); @Operation( - summary = "일간 랭킹 조회", - description = "특정 날짜의 상품 랭킹을 페이지 단위로 조회합니다." + summary = "Query product rankings", + description = "Retrieves product rankings for a specific period (daily, weekly, or monthly) with pagination support." ) @GetMapping public ResponseEntity getRankings( - @Parameter(description = "조회 날짜 (yyyyMMdd), 미입력 시 오늘", example = "20250123") + @Parameter(description = "Period type: DAILY, WEEKLY, MONTHLY", example = "DAILY") + @RequestParam(required = false, defaultValue = "DAILY") String periodType, + + @Parameter(description = "Target date (yyyyMMdd) for daily rankings", example = "20250130") @RequestParam(required = false) String date, - @Parameter(description = "페이지 번호 (1부터 시작)", example = "1") + @Parameter(description = "Target week (YYYY-Wnn) for weekly rankings", example = "2025-W05") + @RequestParam(required = false) String yearWeek, + + @Parameter(description = "Target month (YYYY-MM) for monthly rankings", example = "2025-01") + @RequestParam(required = false) String yearMonth, + + @Parameter(description = "Page number (1-based)", example = "1") @RequestParam(defaultValue = "1") int page, - @Parameter(description = "페이지 크기", example = "20") + @Parameter(description = "Page size", example = "20") @RequestParam(defaultValue = "20") int size ) { - // 날짜 검증 - String targetDate = validateAndGetDate(date); + String period; + List rankings; - // 랭킹 조회 - List rankings = rankingFacade.getDailyRanking(targetDate, page, size); + switch (periodType.toUpperCase()) { + case "WEEKLY": + if (yearWeek == null || yearWeek.isBlank()) { + throw new IllegalArgumentException("yearWeek parameter is required for WEEKLY period type"); + } + period = yearWeek; + rankings = rankingFacade.getWeeklyRanking(yearWeek, page, size); + break; + + case "MONTHLY": + if (yearMonth == null || yearMonth.isBlank()) { + throw new IllegalArgumentException("yearMonth parameter is required for MONTHLY period type"); + } + period = yearMonth; + rankings = rankingFacade.getMonthlyRanking(yearMonth, page, size); + break; + + case "DAILY": + default: + String targetDate = validateAndGetDate(date); + period = targetDate; + rankings = rankingFacade.getDailyRanking(targetDate, page, size); + break; + } return ResponseEntity.ok(new RankingResponse( - targetDate, + period, page, size, rankings @@ -58,7 +91,11 @@ public ResponseEntity getRankings( } /** - * 날짜 검증 및 변환 + * Validates and normalizes the date parameter. + * + * @param date the date string in yyyyMMdd format (nullable) + * @return validated date string, defaults to today if null + * @throws IllegalArgumentException if date format is invalid */ private String validateAndGetDate(String date) { if (date == null || date.isBlank()) { @@ -69,7 +106,7 @@ private String validateAndGetDate(String date) { LocalDate.parse(date, DATE_FORMATTER); return date; } catch (Exception e) { - throw new IllegalArgumentException("날짜 형식이 올바르지 않습니다. (yyyyMMdd)"); + throw new IllegalArgumentException("Invalid date format. Expected: yyyyMMdd"); } } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingResponse.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingResponse.java index 641eca8d1..0961126ce 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingResponse.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingResponse.java @@ -4,18 +4,18 @@ import io.swagger.v3.oas.annotations.media.Schema; import java.util.List; -@Schema(description = "랭킹 조회 응답") +@Schema(description = "Ranking query response") public record RankingResponse( - @Schema(description = "조회 날짜", example = "20250123") - String date, + @Schema(description = "Query period (date, week, or month)", example = "20250130") + String period, - @Schema(description = "페이지 번호", example = "1") + @Schema(description = "Page number", example = "1") int page, - @Schema(description = "페이지 크기", example = "20") + @Schema(description = "Page size", example = "20") int size, - @Schema(description = "랭킹 목록") + @Schema(description = "List of rankings with product details") List rankings ) { } diff --git a/apps/commerce-batch/build.gradle.kts b/apps/commerce-batch/build.gradle.kts new file mode 100644 index 000000000..b22b6477c --- /dev/null +++ b/apps/commerce-batch/build.gradle.kts @@ -0,0 +1,21 @@ +dependencies { + // add-ons + implementation(project(":modules:jpa")) + implementation(project(":modules:redis")) + implementation(project(":supports:jackson")) + implementation(project(":supports:logging")) + implementation(project(":supports:monitoring")) + + // batch + implementation("org.springframework.boot:spring-boot-starter-batch") + testImplementation("org.springframework.batch:spring-batch-test") + + // querydsl + annotationProcessor("com.querydsl:querydsl-apt::jakarta") + annotationProcessor("jakarta.persistence:jakarta.persistence-api") + annotationProcessor("jakarta.annotation:jakarta.annotation-api") + + // test-fixtures + testImplementation(testFixtures(project(":modules:jpa"))) + testImplementation(testFixtures(project(":modules:redis"))) +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java b/apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java new file mode 100644 index 000000000..f52527dee --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java @@ -0,0 +1,23 @@ +package com.loopers; + +import jakarta.annotation.PostConstruct; +import java.util.TimeZone; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; + +@ConfigurationPropertiesScan +@SpringBootApplication +public class CommerceBatchApplication { + + @PostConstruct + public void started() { + // set timezone + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")); + } + + public static void main(String[] args) { + int exitCode = SpringApplication.exit(SpringApplication.run(CommerceBatchApplication.class, args)); + System.exit(exitCode); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/config/BatchConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/config/BatchConfig.java new file mode 100644 index 000000000..737123495 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/config/BatchConfig.java @@ -0,0 +1,13 @@ +package com.loopers.batch.config; + +import org.springframework.context.annotation.Configuration; + +/** + * Spring Batch 기본 설정 클래스. + * + *

Spring Boot 3.x는 Spring Batch를 자동 설정하므로 @EnableBatchProcessing은 필요하지 않습니다. + *

이 클래스는 필요한 경우 공통 배치 설정 빈을 정의하는 용도로 사용할 수 있습니다. + */ +@Configuration +public class BatchConfig { +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/config/MonthlyRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/config/MonthlyRankingJobConfig.java new file mode 100644 index 000000000..83f10880d --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/config/MonthlyRankingJobConfig.java @@ -0,0 +1,139 @@ +package com.loopers.batch.config; + +import com.loopers.batch.processor.RankingScoreProcessor; +import com.loopers.batch.reader.ProductMetricsAggregateReader; +import com.loopers.batch.writer.MonthlyRankingWriter; +import com.loopers.domain.dto.ProductRankingAggregation; +import com.loopers.domain.rank.MonthlyProductRank; +import com.loopers.domain.rank.MonthlyRankRepository; +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.JobScope; +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.ItemReader; +import org.springframework.batch.item.ItemWriter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +/** + * 월간 랭킹 집계 배치 작업 설정 클래스. + * + *

이 작업은 상품 지표 데이터를 월간 단위로 집계하여 + * 상위 N개 랭킹을 Materialized View 테이블에 저장합니다. + * + *

작업 실행 예시: + *

+ * java -jar commerce-batch.jar \
+ *   --job.name=monthlyRankingJob \
+ *   yearMonth=2025-01
+ * 
+ * + *

Chunk 지향 처리 흐름: + *

    + *
  1. Reader: product_metrics를 월간 단위로 집계
  2. + *
  3. Processor: 랭킹 점수 계산
  4. + *
  5. Writer: mv_product_rank_monthly에 저장
  6. + *
+ */ +@Slf4j +@Configuration +@RequiredArgsConstructor +public class MonthlyRankingJobConfig { + + private static final int CHUNK_SIZE = 100; + private static final int TOP_N = 100; + + private final EntityManager entityManager; + private final MonthlyRankRepository monthlyRankRepository; + + /** + * 월간 랭킹 작업을 정의합니다. + * + * @param jobRepository Spring Batch 작업 저장소 + * @param monthlyRankingStep 실행할 Step + * @return 설정된 Job 인스턴스 + */ + @Bean + public Job monthlyRankingJob( + JobRepository jobRepository, + Step monthlyRankingStep + ) { + return new JobBuilder("monthlyRankingJob", jobRepository) + .start(monthlyRankingStep) + .build(); + } + + /** + * Chunk 지향 처리를 사용하는 월간 랭킹 Step을 정의합니다. + * + * @param jobRepository Spring Batch 작업 저장소 + * @param transactionManager 트랜잭션 관리자 + * @param yearMonth 대상 월 (작업 파라미터에서 주입) + * @return 설정된 Step 인스턴스 + */ + @Bean + @JobScope + public Step monthlyRankingStep( + JobRepository jobRepository, + PlatformTransactionManager transactionManager, + @Value("#{jobParameters['yearMonth']}") String yearMonth + ) { + log.info("Initializing monthly ranking step: yearMonth={}", yearMonth); + + return new StepBuilder("monthlyRankingStep", jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(monthlyMetricsReader(yearMonth)) + .processor(monthlyRankingProcessor(yearMonth)) + .writer(monthlyRankingWriter()) + .build(); + } + + /** + * 월간 지표 집계를 위한 ItemReader를 생성합니다. + * + * @param yearMonth 대상 월 + * @return 설정된 ItemReader + */ + @Bean + @StepScope + public ItemReader monthlyMetricsReader( + @Value("#{jobParameters['yearMonth']}") String yearMonth + ) { + return new ProductMetricsAggregateReader(entityManager, yearMonth, "MONTHLY", TOP_N); + } + + /** + * 랭킹 점수 계산을 위한 ItemProcessor를 생성합니다. + * + * @param yearMonth 대상 월 + * @return 설정된 ItemProcessor + */ + @Bean + @StepScope + public ItemProcessor monthlyRankingProcessor( + @Value("#{jobParameters['yearMonth']}") String yearMonth + ) { + RankingScoreProcessor processor = new RankingScoreProcessor("MONTHLY", yearMonth); + return item -> (MonthlyProductRank) processor.process(item); + } + + /** + * 월간 랭킹 저장을 위한 ItemWriter를 생성합니다. + * + * @return 설정된 ItemWriter + */ + @Bean + @StepScope + public ItemWriter monthlyRankingWriter() { + return new MonthlyRankingWriter(monthlyRankRepository); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/config/WeeklyRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/config/WeeklyRankingJobConfig.java new file mode 100644 index 000000000..9ff740328 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/config/WeeklyRankingJobConfig.java @@ -0,0 +1,139 @@ +package com.loopers.batch.config; + +import com.loopers.batch.processor.RankingScoreProcessor; +import com.loopers.batch.reader.ProductMetricsAggregateReader; +import com.loopers.batch.writer.WeeklyRankingWriter; +import com.loopers.domain.dto.ProductRankingAggregation; +import com.loopers.domain.rank.WeeklyProductRank; +import com.loopers.domain.rank.WeeklyRankRepository; +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.JobScope; +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.ItemReader; +import org.springframework.batch.item.ItemWriter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +/** + * 주간 랭킹 집계 배치 작업 설정 클래스. + * + *

이 작업은 상품 지표 데이터를 주간 단위로 집계하여 + * 상위 N개 랭킹을 Materialized View 테이블에 저장합니다. + * + *

작업 실행 예시: + *

+ * java -jar commerce-batch.jar \
+ *   --job.name=weeklyRankingJob \
+ *   yearWeek=2025-W01
+ * 
+ * + *

Chunk 지향 처리 흐름: + *

    + *
  1. Reader: product_metrics를 주간 단위로 집계
  2. + *
  3. Processor: 랭킹 점수 계산
  4. + *
  5. Writer: mv_product_rank_weekly에 저장
  6. + *
+ */ +@Slf4j +@Configuration +@RequiredArgsConstructor +public class WeeklyRankingJobConfig { + + private static final int CHUNK_SIZE = 100; + private static final int TOP_N = 100; + + private final EntityManager entityManager; + private final WeeklyRankRepository weeklyRankRepository; + + /** + * 주간 랭킹 작업을 정의합니다. + * + * @param jobRepository Spring Batch 작업 저장소 + * @param weeklyRankingStep 실행할 Step + * @return 설정된 Job 인스턴스 + */ + @Bean + public Job weeklyRankingJob( + JobRepository jobRepository, + Step weeklyRankingStep + ) { + return new JobBuilder("weeklyRankingJob", jobRepository) + .start(weeklyRankingStep) + .build(); + } + + /** + * Chunk 지향 처리를 사용하는 주간 랭킹 Step을 정의합니다. + * + * @param jobRepository Spring Batch 작업 저장소 + * @param transactionManager 트랜잭션 관리자 + * @param yearWeek ISO 형식의 대상 주차 (작업 파라미터에서 주입) + * @return 설정된 Step 인스턴스 + */ + @Bean + @JobScope + public Step weeklyRankingStep( + JobRepository jobRepository, + PlatformTransactionManager transactionManager, + @Value("#{jobParameters['yearWeek']}") String yearWeek + ) { + log.info("Initializing weekly ranking step: yearWeek={}", yearWeek); + + return new StepBuilder("weeklyRankingStep", jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(weeklyMetricsReader(yearWeek)) + .processor(weeklyRankingProcessor(yearWeek)) + .writer(weeklyRankingWriter()) + .build(); + } + + /** + * 주간 지표 집계를 위한 ItemReader를 생성합니다. + * + * @param yearWeek 대상 주차 + * @return 설정된 ItemReader + */ + @Bean + @StepScope + public ItemReader weeklyMetricsReader( + @Value("#{jobParameters['yearWeek']}") String yearWeek + ) { + return new ProductMetricsAggregateReader(entityManager, yearWeek, "WEEKLY", TOP_N); + } + + /** + * 랭킹 점수 계산을 위한 ItemProcessor를 생성합니다. + * + * @param yearWeek 대상 주차 + * @return 설정된 ItemProcessor + */ + @Bean + @StepScope + public ItemProcessor weeklyRankingProcessor( + @Value("#{jobParameters['yearWeek']}") String yearWeek + ) { + RankingScoreProcessor processor = new RankingScoreProcessor("WEEKLY", yearWeek); + return item -> (WeeklyProductRank) processor.process(item); + } + + /** + * 주간 랭킹 저장을 위한 ItemWriter를 생성합니다. + * + * @return 설정된 ItemWriter + */ + @Bean + @StepScope + public ItemWriter weeklyRankingWriter() { + return new WeeklyRankingWriter(weeklyRankRepository); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java new file mode 100644 index 000000000..7c486483f --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java @@ -0,0 +1,48 @@ +package com.loopers.batch.job.demo; + +import com.loopers.batch.job.demo.step.DemoTasklet; +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.JobScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.support.transaction.ResourcelessTransactionManager; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = DemoJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Configuration +public class DemoJobConfig { + public static final String JOB_NAME = "demoJob"; + private static final String STEP_DEMO_SIMPLE_TASK_NAME = "demoSimpleTask"; + + private final JobRepository jobRepository; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final DemoTasklet demoTasklet; + + @Bean(JOB_NAME) + public Job demoJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(categorySyncStep()) + .listener(jobListener) + .build(); + } + + @JobScope + @Bean(STEP_DEMO_SIMPLE_TASK_NAME) + public Step categorySyncStep() { + return new StepBuilder(STEP_DEMO_SIMPLE_TASK_NAME, jobRepository) + .tasklet(demoTasklet, new ResourcelessTransactionManager()) + .listener(stepMonitorListener) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java new file mode 100644 index 000000000..800fe5a03 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java @@ -0,0 +1,32 @@ +package com.loopers.batch.job.demo.step; + +import com.loopers.batch.job.demo.DemoJobConfig; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +@StepScope +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = DemoJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Component +public class DemoTasklet implements Tasklet { + @Value("#{jobParameters['requestDate']}") + private String requestDate; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception { + if (requestDate == null) { + throw new RuntimeException("requestDate is null"); + } + System.out.println("Demo Tasklet 실행 (실행 일자 : " + requestDate + ")"); + Thread.sleep(1000); + System.out.println("Demo Tasklet 작업 완료"); + return RepeatStatus.FINISHED; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java new file mode 100644 index 000000000..10b09b8fc --- /dev/null +++ b/apps/commerce-batch/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: ${chunkContext.stepContext.stepExecution.readCount}, " + + "writeCount: ${chunkContext.stepContext.stepExecution.writeCount}" + ); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java new file mode 100644 index 000000000..fa5720884 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java @@ -0,0 +1,52 @@ +package com.loopers.batch.listener; + +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +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; + +@Slf4j +@RequiredArgsConstructor +@Component +public class JobListener { + + @BeforeJob + void beforeJob(JobExecution jobExecution) { + log.info("Job '${jobExecution.jobInstance.jobName}' 시작"); + 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-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java new file mode 100644 index 000000000..fd22e6baa --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java @@ -0,0 +1,44 @@ +package com.loopers.batch.listener; + +import jakarta.annotation.Nonnull; +import java.util.Objects; +import java.util.stream.Collectors; +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; + +@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.info( + """ + [에러 발생] + jobName: {} + exceptions: + {} + """.trim(), jobName, exceptions + ); + // error 발생 시 slack 등 다른 채널로 모니터 전송 + return ExitStatus.FAILED; + } + return ExitStatus.COMPLETED; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/processor/RankingScoreProcessor.java b/apps/commerce-batch/src/main/java/com/loopers/batch/processor/RankingScoreProcessor.java new file mode 100644 index 000000000..d4d6ed375 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/processor/RankingScoreProcessor.java @@ -0,0 +1,98 @@ +package com.loopers.batch.processor; + +import com.loopers.domain.dto.ProductRankingAggregation; +import com.loopers.domain.rank.MonthlyProductRank; +import com.loopers.domain.rank.WeeklyProductRank; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.ItemProcessor; + +/** + * 집계된 지표를 랭킹 엔티티로 변환하는 ItemProcessor. + * + *

이 Processor는 ProductRankingAggregation DTO를 기간 타입에 따라 + * WeeklyProductRank 또는 MonthlyProductRank 엔티티로 변환합니다. + * 랭킹 점수는 가중치가 적용된 지표를 사용하여 계산됩니다. + * + *

점수 계산 공식: + *

+ * 점수 = (조회수 * 조회_가중치) +
+ *       (좋아요수 * 좋아요_가중치) +
+ *       (주문수 * 주문_가중치 * log10(판매금액 + 1))
+ * 
+ * + * 가중치: + *
    + *
  • 조회_가중치 = 0.1
  • + *
  • 좋아요_가중치 = 0.2
  • + *
  • 주문_가중치 = 0.6
  • + *
+ */ +@Slf4j +public class RankingScoreProcessor implements ItemProcessor { + + private static final double WEIGHT_VIEW = 0.1; + private static final double WEIGHT_LIKE = 0.2; + private static final double WEIGHT_ORDER = 0.6; + + private final String periodType; + private final String period; + + /** + * RankingScoreProcessor 생성자. + * + * @param periodType 기간 타입 ("WEEKLY" 또는 "MONTHLY") + * @param period 기간 문자열 (예: "2025-W01" 또는 "2025-01") + */ + public RankingScoreProcessor(String periodType, String period) { + this.periodType = periodType; + this.period = period; + } + + @Override + public Object process(ProductRankingAggregation item) { + double score = calculateScore(item); + + log.debug("Processing ranking: productId={}, rank={}, score={}", + item.getProductId(), item.getRankPosition(), score); + + if ("WEEKLY".equals(periodType)) { + return WeeklyProductRank.builder() + .productId(item.getProductId()) + .yearWeek(period) + .rankPosition(item.getRankPosition()) + .totalScore(score) + .likeCount(item.getLikeCount()) + .viewCount(item.getViewCount()) + .orderCount(item.getOrderCount()) + .salesAmount(item.getSalesAmount()) + .build(); + } else { + return MonthlyProductRank.builder() + .productId(item.getProductId()) + .yearMonth(period) + .rankPosition(item.getRankPosition()) + .totalScore(score) + .likeCount(item.getLikeCount()) + .viewCount(item.getViewCount()) + .orderCount(item.getOrderCount()) + .salesAmount(item.getSalesAmount()) + .build(); + } + } + + /** + * 가중치가 적용된 지표를 기반으로 랭킹 점수를 계산합니다. + * + *

판매 금액에 대해 로그 정규화를 사용하여 + * 극단적인 값이 점수를 지배하는 것을 방지합니다. + * + * @param agg 집계된 지표 + * @return 계산된 점수 + */ + private double calculateScore(ProductRankingAggregation agg) { + double salesAmountValue = agg.getSalesAmount() != null ? agg.getSalesAmount().doubleValue() : 0.0; + return (agg.getViewCount() * WEIGHT_VIEW) + + (agg.getLikeCount() * WEIGHT_LIKE) + + (agg.getOrderCount() * WEIGHT_ORDER * Math.log10(salesAmountValue + 1)); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/reader/ProductMetricsAggregateReader.java b/apps/commerce-batch/src/main/java/com/loopers/batch/reader/ProductMetricsAggregateReader.java new file mode 100644 index 000000000..e9ebb689a --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/reader/ProductMetricsAggregateReader.java @@ -0,0 +1,180 @@ +package com.loopers.batch.reader; + +import com.loopers.domain.dto.ProductRankingAggregation; +import jakarta.persistence.EntityManager; +import jakarta.persistence.Query; +import java.math.BigDecimal; +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.temporal.TemporalAdjusters; +import java.util.Iterator; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.ItemReader; + +/** + * 특정 기간 동안의 상품 지표를 집계하는 ItemReader. + * + *

이 Reader는 product_metrics 데이터를 가져와서 상품 ID별로 집계하며, + * 특정 기간(주간 또는 월간) 동안의 데이터를 처리합니다. 집계된 결과에는 + * 가중치가 적용된 지표를 기반으로 계산된 랭킹 점수가 포함됩니다. + * + *

점수 계산 공식: + *

+ * 점수 = (조회수 * 0.1) +
+ *       (좋아요수 * 0.2) +
+ *       (주문수 * 0.6 * log10(판매금액 + 1))
+ * 
+ */ +@Slf4j +public class ProductMetricsAggregateReader implements ItemReader { + + private final EntityManager entityManager; + private final String period; + private final String periodType; + private final int topN; + private Iterator resultIterator; + + /** + * ProductMetricsAggregateReader 생성자. + * + * @param entityManager JPA 엔티티 매니저 + * @param period 기간 문자열 (예: 주간 "2025-W01", 월간 "2025-01") + * @param periodType 기간 타입 ("WEEKLY" 또는 "MONTHLY") + * @param topN 가져올 최대 상위 랭킹 개수 + */ + public ProductMetricsAggregateReader( + EntityManager entityManager, + String period, + String periodType, + int topN + ) { + this.entityManager = entityManager; + this.period = period; + this.periodType = periodType; + this.topN = topN; + } + + @Override + public ProductRankingAggregation read() { + if (resultIterator == null) { + resultIterator = fetchAggregatedData().iterator(); + log.info("Aggregated data fetched: period={}, type={}, count={}", + period, periodType, resultIterator.hasNext() ? "available" : "empty"); + } + + return resultIterator.hasNext() ? resultIterator.next() : null; + } + + /** + * 설정된 기간 동안의 상품 지표를 가져와 집계합니다. + * + * @return 계산된 점수가 포함된 집계 랭킹 데이터 목록 + */ + private List fetchAggregatedData() { + DateRange dateRange = calculateDateRange(period, periodType); + + String sql = """ + SELECT + product_id, + SUM(like_count) as total_likes, + SUM(view_count) as total_views, + SUM(order_count) as total_orders, + SUM(sales_amount) as total_sales, + ( + SUM(view_count) * 0.1 + + SUM(like_count) * 0.2 + + SUM(order_count) * 0.6 * LOG10(SUM(sales_amount) + 1) + ) as total_score + FROM product_metrics + WHERE created_at >= :startDate + AND created_at < :endDate + GROUP BY product_id + ORDER BY total_score DESC + LIMIT :topN + """; + + Query query = entityManager.createNativeQuery(sql) + .setParameter("startDate", dateRange.start()) + .setParameter("endDate", dateRange.end()) + .setParameter("topN", topN); + + @SuppressWarnings("unchecked") + List results = query.getResultList(); + + return IntStream.range(0, results.size()) + .mapToObj(i -> { + Object[] row = results.get(i); + return new ProductRankingAggregation( + ((Number) row[0]).longValue(), + ((Number) row[1]).intValue(), + ((Number) row[2]).intValue(), + ((Number) row[3]).intValue(), + (BigDecimal) row[4], + i + 1 + ); + }) + .collect(Collectors.toList()); + } + + /** + * 주어진 기간의 날짜 범위를 계산합니다. + * + * @param period 기간 문자열 + * @param type 기간 타입 + * @return 시작일과 종료일이 포함된 날짜 범위 + */ + private DateRange calculateDateRange(String period, String type) { + if ("WEEKLY".equals(type)) { + return calculateWeeklyDateRange(period); + } else { + return calculateMonthlyDateRange(period); + } + } + + /** + * ISO 주차 형식에서 주간 날짜 범위를 계산합니다. + * + * @param yearWeek "YYYY-Wnn" 형식의 연-주차 + * @return 주간 날짜 범위 + */ + private DateRange calculateWeeklyDateRange(String yearWeek) { + int year = Integer.parseInt(yearWeek.substring(0, 4)); + int week = Integer.parseInt(yearWeek.substring(6)); + + LocalDate firstDayOfYear = LocalDate.of(year, 1, 1); + LocalDate firstMonday = firstDayOfYear.with(TemporalAdjusters.firstInMonth(DayOfWeek.MONDAY)); + + if (firstMonday.isAfter(firstDayOfYear)) { + firstMonday = firstMonday.minusWeeks(1); + } + + LocalDate startOfWeek = firstMonday.plusWeeks(week - 1); + LocalDate endOfWeek = startOfWeek.plusWeeks(1); + + return new DateRange(startOfWeek, endOfWeek); + } + + /** + * 월간 날짜 범위를 계산합니다. + * + * @param yearMonth "YYYY-MM" 형식의 연-월 + * @return 월간 날짜 범위 + */ + private DateRange calculateMonthlyDateRange(String yearMonth) { + LocalDate startOfMonth = LocalDate.parse(yearMonth + "-01"); + LocalDate endOfMonth = startOfMonth.plusMonths(1); + + return new DateRange(startOfMonth, endOfMonth); + } + + /** + * 시작일과 종료일을 포함하는 날짜 범위를 나타냅니다. + * + * @param start 시작일 (포함) + * @param end 종료일 (미포함) + */ + private record DateRange(LocalDate start, LocalDate end) {} +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/writer/MonthlyRankingWriter.java b/apps/commerce-batch/src/main/java/com/loopers/batch/writer/MonthlyRankingWriter.java new file mode 100644 index 000000000..32a17da5d --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/writer/MonthlyRankingWriter.java @@ -0,0 +1,51 @@ +package com.loopers.batch.writer; + +import com.loopers.domain.rank.MonthlyProductRank; +import com.loopers.domain.rank.MonthlyRankRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.Chunk; +import org.springframework.batch.item.ItemWriter; +import org.springframework.transaction.annotation.Transactional; + +/** + * 월간 랭킹 데이터를 저장하는 ItemWriter. + * + *

이 Writer는 MonthlyProductRank 엔티티를 데이터베이스에 저장합니다. + * 데이터 일관성을 보장하기 위해 삭제 후 삽입 전략을 사용하며, + * 새로운 랭킹을 삽입하기 전에 해당 기간의 기존 데이터를 제거합니다. + */ +@Slf4j +@RequiredArgsConstructor +public class MonthlyRankingWriter implements ItemWriter { + + private final MonthlyRankRepository repository; + + /** + * 월간 랭킹 데이터 청크를 데이터베이스에 저장합니다. + * + *

구현 전략: + *

    + *
  1. 대상 월의 기존 랭킹 모두 삭제
  2. + *
  3. 새로운 집계 랭킹 삽입
  4. + *
+ * + * @param chunk 저장할 랭킹 청크 + */ + @Override + @Transactional + public void write(Chunk chunk) { + if (chunk.isEmpty()) { + log.warn("Empty chunk received, skipping write operation"); + return; + } + + String yearMonth = chunk.getItems().get(0).getYearMonth(); + log.info("Writing monthly rankings: yearMonth={}, count={}", yearMonth, chunk.size()); + + repository.deleteByYearMonth(yearMonth); + repository.saveAll(chunk.getItems()); + + log.info("Successfully saved {} monthly rankings for month {}", chunk.size(), yearMonth); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/writer/WeeklyRankingWriter.java b/apps/commerce-batch/src/main/java/com/loopers/batch/writer/WeeklyRankingWriter.java new file mode 100644 index 000000000..4b4875795 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/writer/WeeklyRankingWriter.java @@ -0,0 +1,51 @@ +package com.loopers.batch.writer; + +import com.loopers.domain.rank.WeeklyProductRank; +import com.loopers.domain.rank.WeeklyRankRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.Chunk; +import org.springframework.batch.item.ItemWriter; +import org.springframework.transaction.annotation.Transactional; + +/** + * 주간 랭킹 데이터를 저장하는 ItemWriter. + * + *

이 Writer는 WeeklyProductRank 엔티티를 데이터베이스에 저장합니다. + * 데이터 일관성을 보장하기 위해 삭제 후 삽입 전략을 사용하며, + * 새로운 랭킹을 삽입하기 전에 해당 기간의 기존 데이터를 제거합니다. + */ +@Slf4j +@RequiredArgsConstructor +public class WeeklyRankingWriter implements ItemWriter { + + private final WeeklyRankRepository repository; + + /** + * 주간 랭킹 데이터 청크를 데이터베이스에 저장합니다. + * + *

구현 전략: + *

    + *
  1. 대상 주차의 기존 랭킹 모두 삭제
  2. + *
  3. 새로운 집계 랭킹 삽입
  4. + *
+ * + * @param chunk 저장할 랭킹 청크 + */ + @Override + @Transactional + public void write(Chunk chunk) { + if (chunk.isEmpty()) { + log.warn("Empty chunk received, skipping write operation"); + return; + } + + String yearWeek = chunk.getItems().get(0).getYearWeek(); + log.info("Writing weekly rankings: yearWeek={}, count={}", yearWeek, chunk.size()); + + repository.deleteByYearWeek(yearWeek); + repository.saveAll(chunk.getItems()); + + log.info("Successfully saved {} weekly rankings for week {}", chunk.size(), yearWeek); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/dto/ProductRankingAggregation.java b/apps/commerce-batch/src/main/java/com/loopers/domain/dto/ProductRankingAggregation.java new file mode 100644 index 000000000..e9bd3d8ea --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/dto/ProductRankingAggregation.java @@ -0,0 +1,46 @@ +package com.loopers.domain.dto; + +import java.math.BigDecimal; +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 상품 랭킹 집계 결과를 담는 Data Transfer Object. + * + *

이 DTO는 데이터베이스 쿼리에서 집계된 지표 데이터를 배치 프로세서로 전달하여 + * 추가 계산 및 랭킹 할당을 수행합니다. + */ +@Getter +@AllArgsConstructor +public class ProductRankingAggregation { + + /** + * 상품 ID + */ + private Long productId; + + /** + * 기간 내 총 좋아요 수 + */ + private Integer likeCount; + + /** + * 기간 내 총 조회 수 + */ + private Integer viewCount; + + /** + * 기간 내 총 주문 수 + */ + private Integer orderCount; + + /** + * 기간 내 총 판매 금액 + */ + private BigDecimal salesAmount; + + /** + * 계산된 랭킹 순위 (1부터 시작) + */ + private Integer rankPosition; +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MonthlyProductRank.java b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MonthlyProductRank.java new file mode 100644 index 000000000..c91dcb73a --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MonthlyProductRank.java @@ -0,0 +1,107 @@ +package com.loopers.domain.rank; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * Materialized View를 위한 월간 상품 랭킹 엔티티. + * + *

이 테이블은 성능 최적화를 위해 사전 집계된 월간 랭킹 데이터를 저장합니다. + * 집계는 Spring Batch Job에 의해 수행되며 빠른 조회를 위해 여기에 저장됩니다. + * + * @see com.loopers.batch.config.MonthlyRankingJobConfig + */ +@Entity +@Table( + name = "mv_product_rank_monthly", + uniqueConstraints = @UniqueConstraint( + name = "uk_product_month", + columnNames = {"product_id", "year_month"} + ), + indexes = { + @Index(name = "idx_year_month_rank", columnList = "year_month, rank_position"), + @Index(name = "idx_year_month_score", columnList = "year_month, total_score DESC") + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MonthlyProductRank { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "product_id", nullable = false) + private Long productId; + + /** + * 년-월 형식: YYYY-MM (예: "2025-01") + */ + @Column(name = "`year_month`", nullable = false, length = 7) + private String yearMonth; + + @Column(name = "rank_position", nullable = false) + private Integer rankPosition; + + @Column(name = "total_score", nullable = false) + private Double totalScore; + + @Column(name = "like_count", nullable = false) + private Integer likeCount; + + @Column(name = "view_count", nullable = false) + private Integer viewCount; + + @Column(name = "order_count", nullable = false) + private Integer orderCount; + + @Column(name = "sales_amount", nullable = false, precision = 15, scale = 2) + private BigDecimal salesAmount; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @Builder + public MonthlyProductRank( + Long productId, + String yearMonth, + Integer rankPosition, + Double totalScore, + Integer likeCount, + Integer viewCount, + Integer orderCount, + BigDecimal salesAmount + ) { + this.productId = productId; + this.yearMonth = yearMonth; + this.rankPosition = rankPosition; + this.totalScore = totalScore; + this.likeCount = likeCount; + this.viewCount = viewCount; + this.orderCount = orderCount; + this.salesAmount = salesAmount; + this.createdAt = LocalDateTime.now(); + this.updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + this.updatedAt = LocalDateTime.now(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MonthlyRankRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MonthlyRankRepository.java new file mode 100644 index 000000000..f3da336f2 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MonthlyRankRepository.java @@ -0,0 +1,34 @@ +package com.loopers.domain.rank; + +import java.util.List; +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; + +/** + * MonthlyProductRank 엔티티를 위한 Repository 인터페이스. + * + *

월간 상품 랭킹 데이터에 대한 데이터 접근 메서드를 제공합니다. + */ +public interface MonthlyRankRepository extends JpaRepository { + + /** + * 특정 월의 모든 랭킹을 순위 순서대로 조회합니다. + * + * @param yearMonth 년-월 (예: "2025-01") + * @return 순위순으로 정렬된 월간 랭킹 목록 + */ + List findByYearMonthOrderByRankPositionAsc(String yearMonth); + + /** + * 특정 월의 모든 랭킹을 삭제합니다. + * + *

데이터 일관성을 보장하기 위해 새로운 집계 데이터를 삽입하기 전에 사용됩니다. + * + * @param yearMonth 삭제할 년-월 + */ + @Modifying + @Query("DELETE FROM MonthlyProductRank m WHERE m.yearMonth = :yearMonth") + void deleteByYearMonth(@Param("yearMonth") String yearMonth); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/WeeklyProductRank.java b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/WeeklyProductRank.java new file mode 100644 index 000000000..ab622e7ec --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/WeeklyProductRank.java @@ -0,0 +1,107 @@ +package com.loopers.domain.rank; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * Materialized View를 위한 주간 상품 랭킹 엔티티. + * + *

이 테이블은 성능 최적화를 위해 사전 집계된 주간 랭킹 데이터를 저장합니다. + * 집계는 Spring Batch Job에 의해 수행되며 빠른 조회를 위해 여기에 저장됩니다. + * + * @see com.loopers.batch.config.WeeklyRankingJobConfig + */ +@Entity +@Table( + name = "mv_product_rank_weekly", + uniqueConstraints = @UniqueConstraint( + name = "uk_product_week", + columnNames = {"product_id", "year_week"} + ), + indexes = { + @Index(name = "idx_year_week_rank", columnList = "year_week, rank_position"), + @Index(name = "idx_year_week_score", columnList = "year_week, total_score DESC") + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class WeeklyProductRank { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "product_id", nullable = false) + private Long productId; + + /** + * ISO 주차 형식: YYYY-Wnn (예: "2025-W01") + */ + @Column(name = "year_week", nullable = false, length = 10) + private String yearWeek; + + @Column(name = "rank_position", nullable = false) + private Integer rankPosition; + + @Column(name = "total_score", nullable = false) + private Double totalScore; + + @Column(name = "like_count", nullable = false) + private Integer likeCount; + + @Column(name = "view_count", nullable = false) + private Integer viewCount; + + @Column(name = "order_count", nullable = false) + private Integer orderCount; + + @Column(name = "sales_amount", nullable = false, precision = 15, scale = 2) + private BigDecimal salesAmount; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @Builder + public WeeklyProductRank( + Long productId, + String yearWeek, + Integer rankPosition, + Double totalScore, + Integer likeCount, + Integer viewCount, + Integer orderCount, + BigDecimal salesAmount + ) { + this.productId = productId; + this.yearWeek = yearWeek; + this.rankPosition = rankPosition; + this.totalScore = totalScore; + this.likeCount = likeCount; + this.viewCount = viewCount; + this.orderCount = orderCount; + this.salesAmount = salesAmount; + this.createdAt = LocalDateTime.now(); + this.updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + this.updatedAt = LocalDateTime.now(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/WeeklyRankRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/WeeklyRankRepository.java new file mode 100644 index 000000000..23b7a9101 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/WeeklyRankRepository.java @@ -0,0 +1,34 @@ +package com.loopers.domain.rank; + +import java.util.List; +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; + +/** + * WeeklyProductRank 엔티티를 위한 Repository 인터페이스. + * + *

주간 상품 랭킹 데이터에 대한 데이터 접근 메서드를 제공합니다. + */ +public interface WeeklyRankRepository extends JpaRepository { + + /** + * 특정 주차의 모든 랭킹을 순위 순서대로 조회합니다. + * + * @param yearWeek ISO 형식의 년-주차 (예: "2025-W01") + * @return 순위순으로 정렬된 주간 랭킹 목록 + */ + List findByYearWeekOrderByRankPositionAsc(String yearWeek); + + /** + * 특정 주차의 모든 랭킹을 삭제합니다. + * + *

데이터 일관성을 보장하기 위해 새로운 집계 데이터를 삽입하기 전에 사용됩니다. + * + * @param yearWeek 삭제할 년-주차 + */ + @Modifying + @Query("DELETE FROM WeeklyProductRank w WHERE w.yearWeek = :yearWeek") + void deleteByYearWeek(@Param("yearWeek") String yearWeek); +} diff --git a/apps/commerce-batch/src/main/resources/application.yml b/apps/commerce-batch/src/main/resources/application.yml new file mode 100644 index 000000000..9aa0d760a --- /dev/null +++ b/apps/commerce-batch/src/main/resources/application.yml @@ -0,0 +1,54 @@ +spring: + main: + web-application-type: none + application: + name: commerce-batch + profiles: + active: local + config: + import: + - jpa.yml + - redis.yml + - logging.yml + - monitoring.yml + batch: + job: + name: ${job.name:NONE} + jdbc: + initialize-schema: never + +management: + health: + defaults: + enabled: false + +--- +spring: + config: + activate: + on-profile: local, test + batch: + jdbc: + initialize-schema: always + +--- +spring: + config: + activate: + on-profile: dev + +--- +spring: + config: + activate: + on-profile: qa + +--- +spring: + config: + activate: + on-profile: prd + +springdoc: + api-docs: + enabled: false \ No newline at end of file diff --git a/apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java b/apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java new file mode 100644 index 000000000..71a907186 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java @@ -0,0 +1,12 @@ +package com.loopers; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; + +@SpringBootTest +@TestPropertySource(properties = "spring.batch.job.enabled=false") +public class CommerceBatchApplicationTest { + @Test + void contextLoads() {} +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java new file mode 100644 index 000000000..314068f0d --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java @@ -0,0 +1,74 @@ +package com.loopers.job.demo; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.loopers.batch.job.demo.DemoJobConfig; +import java.time.LocalDate; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.ExitStatus; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.test.JobLauncherTestUtils; +import org.springframework.batch.test.context.SpringBatchTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; + +@SpringBootTest +@SpringBatchTest +@TestPropertySource(properties = "spring.batch.job.name=" + DemoJobConfig.JOB_NAME) +class DemoJobE2ETest { + + // IDE 정적 분석 상 [SpringBatchTest] 의 주입보다 [SpringBootTest] 의 주입이 우선되어, 해당 컴포넌트는 없으므로 오류처럼 보일 수 있음. + // [SpringBatchTest] 자체가 Scope 기반으로 주입하기 때문에 정상 동작함. + @Autowired + private JobLauncherTestUtils jobLauncherTestUtils; + + @Autowired + @Qualifier(DemoJobConfig.JOB_NAME) + private Job job; + + @BeforeEach + void beforeEach() { + + } + + @DisplayName("jobParameter 중 requestDate 인자가 주어지지 않았을 때, demoJob 배치는 실패한다.") + @Test + void shouldNotSaveCategories_whenApiError() throws Exception { + // arrange + jobLauncherTestUtils.setJob(job); + + // act + var jobExecution = jobLauncherTestUtils.launchJob(); + + // assert + assertAll( + () -> assertThat(jobExecution).isNotNull(), + () -> assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.FAILED.getExitCode()) + ); + } + + @DisplayName("demoJob 배치가 정상적으로 실행된다.") + @Test + void success() throws Exception { + // arrange + jobLauncherTestUtils.setJob(job); + + // act + var jobParameters = new JobParametersBuilder() + .addLocalDate("requestDate", LocalDate.now()) + .toJobParameters(); + var jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // assert + assertAll( + () -> assertThat(jobExecution).isNotNull(), + () -> assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()) + ); + } +} diff --git a/docker/init-db.sql b/docker/init-db.sql index 0c8efe2e1..513b1b846 100644 --- a/docker/init-db.sql +++ b/docker/init-db.sql @@ -58,3 +58,42 @@ CREATE TABLE IF NOT EXISTS dead_letter_queue ( INDEX idx_topic (original_topic) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='처리 실패한 메시지 저장'; + +-- Round 10: Materialized View for Weekly/Monthly Rankings +-- Weekly Product Ranking +CREATE TABLE IF NOT EXISTS mv_product_rank_weekly ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + product_id BIGINT NOT NULL COMMENT '상품 ID', + year_week VARCHAR(10) NOT NULL COMMENT 'YYYY-Wnn (ISO Week)', + rank_position INT NOT NULL COMMENT '순위 (1-100)', + total_score DOUBLE NOT NULL COMMENT '총 점수', + like_count INT NOT NULL DEFAULT 0 COMMENT '주간 좋아요 수', + view_count INT NOT NULL DEFAULT 0 COMMENT '주간 조회 수', + order_count INT NOT NULL DEFAULT 0 COMMENT '주간 주문 수', + sales_amount DECIMAL(15,2) NOT NULL DEFAULT 0.00 COMMENT '주간 판매 금액', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_product_week (product_id, year_week), + INDEX idx_year_week_rank (year_week, rank_position), + INDEX idx_year_week_score (year_week, total_score DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci +COMMENT='주간 상품 랭킹 집계'; + +-- Monthly Product Ranking +CREATE TABLE IF NOT EXISTS mv_product_rank_monthly ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + product_id BIGINT NOT NULL COMMENT '상품 ID', + year_month VARCHAR(7) NOT NULL COMMENT 'YYYY-MM', + rank_position INT NOT NULL COMMENT '순위 (1-100)', + total_score DOUBLE NOT NULL COMMENT '총 점수', + like_count INT NOT NULL DEFAULT 0 COMMENT '월간 좋아요 수', + view_count INT NOT NULL DEFAULT 0 COMMENT '월간 조회 수', + order_count INT NOT NULL DEFAULT 0 COMMENT '월간 주문 수', + sales_amount DECIMAL(15,2) NOT NULL DEFAULT 0.00 COMMENT '월간 판매 금액', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_product_month (product_id, year_month), + INDEX idx_year_month_rank (year_month, rank_position), + INDEX idx_year_month_score (year_month, total_score DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci +COMMENT='월간 상품 랭킹 집계'; diff --git a/modules/jpa/src/main/java/com/loopers/config/jpa/JpaConfig.java b/modules/jpa/src/main/java/com/loopers/config/jpa/JpaConfig.java index 7fad5872b..56917985a 100644 --- a/modules/jpa/src/main/java/com/loopers/config/jpa/JpaConfig.java +++ b/modules/jpa/src/main/java/com/loopers/config/jpa/JpaConfig.java @@ -8,6 +8,6 @@ @Configuration @EnableTransactionManagement @EntityScan({"com.loopers"}) -@EnableJpaRepositories({"com.loopers.infrastructure"}) +@EnableJpaRepositories({"com.loopers.infrastructure", "com.loopers.domain"}) public class JpaConfig { } diff --git a/settings.gradle.kts b/settings.gradle.kts index d26f95453..7c31b65aa 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -3,6 +3,7 @@ rootProject.name = "loopers-java-spring-template" include( ":apps:commerce-api", ":apps:commerce-streamer", + ":apps:commerce-batch", ":modules:jpa", ":modules:redis", ":modules:kafka",