diff --git a/.codeguide/dip-insights.md b/.codeguide/dip-insights.md new file mode 100644 index 0000000000..f43d249350 --- /dev/null +++ b/.codeguide/dip-insights.md @@ -0,0 +1,82 @@ +# DIP & 아키텍처 인사이트 + +> 3주차 학습 과정에서 수집한 실무적 인사이트 정리 + +--- + +## 1. DIP 정석 vs 실무 타협 + +### DIP 정석 구조 (이미지: [Kev] DIP정석) + +``` +[Domain Layer] [Infrastructure Layer] +Order (순수 도메인 객체) OrderEntity (@Entity, JPA) +OrderRepository (interface) ◀── OrderJpaRepositoryImpl (구현체) +``` + +- Domain Entity와 JPA Entity를 **완전 분리** +- Infrastructure에서 Entity 간 변환 처리 + +### 실무 타협 (이미지: [Kev] 장바구니 도메인 구현 사례) + +**장바구니 사례**: Cart(Domain) / CartEntity(JPA) / CartRedisEntity(Redis) + +- 다중 저장소(JPA + Redis) 지원 시 분리가 의미 있음 +- CartRepository 인터페이스 하나로 JPA/Redis 모두 지원 가능 + +### 수강생 채팅에서 나온 분리의 비용 + +| 비용 | 내용 | +|------|------| +| 보일러플레이트 | Entity ↔ Domain 변환 로직(매퍼) 필요 | +| 더티체킹 포기 | JPA의 강력한 기능 못 씀, 명시적 save() 필요 | +| 클래스 폭발 | Order, OrderEntity 둘 다 관리 | +| 기능 제약 | JPA가 지원하는 편의 기능 활용 불가 | + +--- + +## 2. DDD 저자의 실무 타협 (최범균, 도메인 주도 개발 시작하기) + +### 명언 1: 변경이 거의 없는 상황에서 미리 대비하는 것은 과하다 + +> "DIP를 적용하는 주된 이유는 저수준 구현이 변경되더라도 고수준이 영향을 받지 않도록 하기 위함이다. +> 하지만, 리포지터리와 도메인 모델의 구현 기술은 거의 바뀌지 않는다. +> JPA로 구현한 리포지터리를 마이바티스나 다른 기술로 변경한 적이 없고, +> RDBMS를 사용하다 몽고DB로 변경한 적도 없다. +> 변경이 거의 없는 상황에서 변경을 미리 대비하는 것은 과하다고 생각한다." + +### 명언 2: 복잡도를 높이지 않으면서 구조적 유연함 유지 + +> "JPA 전용 애너테이션을 사용하긴 했지만 도메인 모델을 단위 테스트하는 데 문제는 없다. +> 리포지터리도 마찬가지다. 스프링 데이터 JPA가 제공하는 Repository 인터페이스를 상속하고 있지만 +> 리포지터리 자체는 인터페이스이고 테스트 가능성을 해치지 않는다. +> DIP를 완벽하게 지키면 좋겠지만 개발 편의성과 실용성을 가져가면서 구조적인 유연함은 어느정도 유지했다. +> 복잡도를 높이지 않으면서 기술에 따른 구현 제약이 낮다면 합리적인 선택이라고 생각한다." + +--- + +## 3. 우리 과제에 적용할 인사이트 + +### 타협하는 부분 (실용성 우선) + +| 항목 | 정석 | 우리의 타협 | 이유 | +|------|------|-----------|------| +| Domain Entity | 순수 POJO | @Entity 사용 | 더티체킹 활용, 보일러플레이트 감소 | +| VO | JPA 무관 | @Embeddable 사용 | 테스트에 영향 없음 | + +### 지키는 부분 (구조적 유연함) + +| 항목 | 적용 방식 | 이유 | +|------|----------|------| +| Repository Interface | Domain Layer에 정의 | 테스트 가능성 확보 (Fake 구현체 교체) | +| Repository 구현체 | Infrastructure Layer에 위치 | 의존 방향: Domain ← Infrastructure | +| Application Layer | Facade로 도메인 조합 | 유스케이스 조율과 비즈니스 로직 분리 | + +### 판단 기준 (한 줄 요약) + +``` +"테스트 가능성을 해치지 않는 범위에서 타협한다" +``` + +- @Entity 사용해도 단위 테스트 가능? → ✅ 타협 OK +- Repository를 Infrastructure에서 직접 사용하면 테스트 어려움? → ❌ Interface 분리 필요 diff --git a/.codeguide/loopers-1-week.md b/.codeguide/loopers-1-week.md deleted file mode 100644 index a8ace53e54..0000000000 --- a/.codeguide/loopers-1-week.md +++ /dev/null @@ -1,45 +0,0 @@ -## 🧪 Implementation Quest - -> 지정된 **단위 테스트 / 통합 테스트 / E2E 테스트 케이스**를 필수로 구현하고, 모든 테스트를 통과시키는 것을 목표로 합니다. - -### 회원 가입 - -**🧱 단위 테스트** - -- [ ] ID 가 `영문 및 숫자 10자 이내` 형식에 맞지 않으면, User 객체 생성에 실패한다. -- [ ] 이메일이 `xx@yy.zz` 형식에 맞지 않으면, User 객체 생성에 실패한다. -- [ ] 생년월일이 `yyyy-MM-dd` 형식에 맞지 않으면, User 객체 생성에 실패한다. - -**🔗 통합 테스트** - -- [ ] 회원 가입시 User 저장이 수행된다. ( spy 검증 ) -- [ ] 이미 가입된 ID 로 회원가입 시도 시, 실패한다. - -**🌐 E2E 테스트** - -- [ ] 회원 가입이 성공할 경우, 생성된 유저 정보를 응답으로 반환한다. -- [ ] 회원 가입 시에 성별이 없을 경우, `400 Bad Request` 응답을 반환한다. - -### 내 정보 조회 - -**🔗 통합 테스트** - -- [ ] 해당 ID 의 회원이 존재할 경우, 회원 정보가 반환된다. -- [ ] 해당 ID 의 회원이 존재하지 않을 경우, null 이 반환된다. - -**🌐 E2E 테스트** - -- [ ] 내 정보 조회에 성공할 경우, 해당하는 유저 정보를 응답으로 반환한다. -- [ ] 존재하지 않는 ID 로 조회할 경우, `404 Not Found` 응답을 반환한다. - -### 포인트 조회 - -**🔗 통합 테스트** - -- [ ] 해당 ID 의 회원이 존재할 경우, 보유 포인트가 반환된다. -- [ ] 해당 ID 의 회원이 존재하지 않을 경우, null 이 반환된다. - -**🌐 E2E 테스트** - -- [ ] 포인트 조회에 성공할 경우, 보유 포인트를 응답으로 반환한다. -- [ ] `X-USER-ID` 헤더가 없을 경우, `400 Bad Request` 응답을 반환한다. diff --git a/.coderabbit.yaml b/.coderabbit.yaml deleted file mode 100644 index 8cc6f2fb16..0000000000 --- a/.coderabbit.yaml +++ /dev/null @@ -1,185 +0,0 @@ -# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json - -language: "ko-KR" -early_access: false - -# 리뷰/채팅 톤을 고정한다 -tone_instructions: > - 공식적이고 표준어를 사용하고, 문장 끝은 '다'로 마무리한다. - 칭찬은 최소화하고, 운영/장애/보안/성능/테스트 관점의 개선에 집중한다. - 지적은 반드시 '왜 문제인지(운영 관점) + 수정안 + 추가 테스트'를 포함한다. - -reviews: - # chill: 덜 잔소리, assertive: 더 촘촘한 피드백 - profile: "chill" - - # CodeRabbit이 자동으로 "changes requested"로 강하게 걸지 않도록 한다 - request_changes_workflow: false - - # PR 요약/워크스루 관련 - high_level_summary: true - high_level_summary_instructions: > - 변경 목적, 핵심 변경점, 리스크/주의사항, 테스트/검증 방법 순서로 4~8줄 요약을 작성한다. - 추측이 필요한 부분은 단정하지 말고 확인 질문을 포함한다. - review_status: true - review_details: false - - # 교육용이므로 불필요한 장식은 끈다 - poem: false - - # 리뷰 대상 파일 범위( glob + '!패턴' 방식 ) - path_filters: - - "**" - - "build.gradle" - - "build.gradle.kts" - - "settings.gradle" - - "settings.gradle.kts" - - "!**/*.md" - - "!**/*.adoc" - - "!**/*.png" - - "!**/*.jpg" - - "!**/*.jpeg" - - "!**/*.gif" - - "!**/*.svg" - - "!**/*.lock" - - "!**/generated/**" - - "!**/build/**" - - "!**/out/**" - - "!**/.gradle/**" - - # 경로별 리뷰 가이드(스키마에 존재하는 path_instructions 사용) - path_instructions: - # ---------------------------- - # Kotlin (Spring Boot) - # ---------------------------- - - path: "**/*.kt" - instructions: > - Kotlin + Spring Boot 리뷰 기준이다. - null-safety를 최우선으로 점검하고, '!!'는 불가피한 경우에만 허용하며 근거를 요구한다. - JPA 엔티티에 data class 사용은 원칙적으로 지양하고 equals/hashCode 구현 안정성(프록시/식별자 기반)을 점검한다. - scope function(let/apply/run/also) 과다 사용으로 가독성이 떨어지면 명시적 코드로 대안을 제시한다. - 컬렉션 연산에서 불필요한 eager 처리와 중간 리스트 생성을 점검하고 필요 시 sequence 또는 반복문으로 단순화한다. - 예외 처리는 도메인 예외와 인프라 예외를 구분하고, 로깅 시 민감정보 노출 가능성을 점검한다. - - - path: "**/*Controller*.kt" - instructions: > - Kotlin Controller 리뷰 기준이다. - Controller는 요청 검증(Bean Validation)과 응답 조립에 집중하고 비즈니스 로직은 Service로 이동한다. - 상태 코드와 에러 응답 포맷이 일관되는지 점검하고, @ControllerAdvice 기반 표준 처리로 유도한다. - 요청 DTO와 응답 DTO를 명확히 분리하고, 엔티티를 직접 노출하지 않도록 점검한다. - 바인딩/검증 실패 시 메시지와 로깅 전략이 과도하지 않은지 점검한다. - - - path: "**/*Service*.kt" - instructions: > - Kotlin Service 리뷰 기준이다. - 트랜잭션 경계(@Transactional) 위치와 전파, readOnly, 롤백 조건을 점검한다. - 도메인 규칙이 흩어지지 않도록 유스케이스 단위로 책임을 정리하고, 사이드 이펙트를 명확히 한다. - 외부 의존성 호출(HTTP/DB/메시지)에는 타임아웃, 재시도, 서킷브레이커 고려 여부를 점검한다. - 멱등성(특히 이벤트 발행/결제/주문성 처리)과 중복 처리 방지 전략을 점검한다. - - - path: "**/*Repository*.kt" - instructions: > - Kotlin Repository/JPA 리뷰 기준이다. - N+1 가능성, fetch join/EntityGraph 사용 여부, 페이징 시 fetch join 위험 등을 점검한다. - 쿼리 조건 누락/과다 조회, 정렬/인덱스 활용 가능성, 대량 데이터에서의 성능 병목을 점검한다. - 트랜잭션 밖에서 Lazy 로딩이 터질 가능성과, 영속성 컨텍스트 오염 가능성을 점검한다. - - - path: "**/domain/**/*.kt" - instructions: > - Kotlin 도메인 모델 리뷰 기준이다. - 값 객체/엔티티 경계를 명확히 하고, 불변성 유지 여부를 점검한다. - 비즈니스 규칙은 도메인에 두고, 인프라 관심사가 섞이면 분리하도록 제안한다. - equals/hashCode는 식별자 기반 또는 명확한 값 기반으로 일관되게 설계한다. - - - path: "**/*Test*.kt" - instructions: > - Kotlin 테스트 리뷰 기준이다. - 단위 테스트는 행위/경계값/실패 케이스를 포함하는지 점검한다. - 통합 테스트는 DB/외부 의존성 격리와 플래키 가능성을 점검하고, 테스트 데이터 준비/정리가 명확한지 본다. - Mock 남용으로 의미가 흐려지면 테스트 전략을 재정렬하도록 제안한다. - - # ---------------------------- - # Java (Spring Boot) - # ---------------------------- - - path: "**/*.java" - instructions: > - Java + Spring Boot 리뷰 기준이다. - Optional/Stream 남용으로 가독성이 떨어지면 단순화하고, 예외 흐름이 명확한지 점검한다. - null 처리, 방어적 복사, 불변성, equals/hashCode/toString 구현 안정성을 점검한다. - 예외 처리 시 cause를 보존하고, 사용자 메시지와 로그 메시지를 분리하도록 제안한다. - 로깅 시 민감정보 노출 가능성을 점검한다. - - - path: "**/*Controller*.java" - instructions: > - Java Controller 리뷰 기준이다. - Controller는 요청 검증(Bean Validation)과 응답 조립에 집중하고 비즈니스 로직은 Service로 이동한다. - 상태 코드와 에러 응답 포맷이 일관되는지 점검하고, @ControllerAdvice 기반 표준 처리로 유도한다. - DTO와 엔티티를 분리하고, 엔티티를 직접 반환하지 않도록 점검한다. - - - path: "**/*Service*.java" - instructions: > - Java Service 리뷰 기준이다. - 트랜잭션 경계(@Transactional) 위치와 전파, readOnly, 롤백 조건을 점검한다. - 유스케이스 단위로 책임이 정리되어 있는지, 부수 효과가 명확한지 점검한다. - 외부 호출에는 타임아웃/재시도/서킷브레이커 고려 여부를 점검하고, 실패 시 대체 흐름을 제안한다. - 멱등성과 중복 처리 방지 전략을 점검한다. - - - path: "**/*Repository*.java" - instructions: > - Java Repository/JPA 리뷰 기준이다. - N+1 가능성, fetch join/EntityGraph 사용 여부, 페이징 시 fetch join 위험 등을 점검한다. - 쿼리 조건 누락/과다 조회, 정렬/인덱스 활용 가능성, 대량 데이터에서의 병목을 점검한다. - 트랜잭션 밖 Lazy 로딩 문제와 영속성 컨텍스트 오염 가능성을 점검한다. - - - path: "**/domain/**/*.java" - instructions: > - Java 도메인 모델 리뷰 기준이다. - 엔티티/값 객체/DTO 경계를 명확히 하고, 불변성과 캡슐화를 점검한다. - 도메인 규칙과 인프라 관심사가 섞이면 분리하도록 제안한다. - equals/hashCode는 식별자 기반 또는 값 기반으로 일관되게 설계한다. - - - path: "**/*Test*.java" - instructions: > - Java 테스트 리뷰 기준이다. - 단위 테스트는 경계값/실패 케이스/예외 흐름을 포함하는지 점검한다. - 통합 테스트는 격리 수준, 플래키 가능성, 테스트 데이터 준비/정리 전략을 점검한다. - Mock 남용으로 의미가 약해지면 테스트 방향을 재정렬하도록 제안한다. - - # ---------------------------- - # Spring 설정/빌드 파일 - # ---------------------------- - - path: "**/application*.yml" - instructions: > - Spring 설정 파일 리뷰 기준이다. - 환경별 분리(프로파일)와 기본값 적절성을 점검하고, 민감정보가 커밋되지 않았는지 확인한다. - 타임아웃, 커넥션 풀, 로깅 레벨 등 운영에 영향을 주는 설정 변경은 근거와 영향 범위를 요구한다. - - - path: "**/application*.yaml" - instructions: > - Spring 설정 파일 리뷰 기준이다. - 환경별 분리(프로파일)와 기본값 적절성을 점검하고, 민감정보가 커밋되지 않았는지 확인한다. - 타임아웃, 커넥션 풀, 로깅 레벨 등 운영에 영향을 주는 설정 변경은 근거와 영향 범위를 요구한다. - - - path: "**/build.gradle" - instructions: > - Gradle 빌드 파일 리뷰 기준이다. - 의존성 추가/변경은 목적과 보안 취약점 리스크를 점검하고, 버전 고정 및 범위를 명확히 한다. - 테스트 태스크/포맷터/정적 분석 태스크 변경은 CI 영향 범위를 함께 점검한다. - - - path: "**/build.gradle.kts" - instructions: > - Gradle Kotlin DSL 빌드 파일 리뷰 기준이다. - 의존성 추가/변경은 목적과 보안 취약점 리스크를 점검하고, 버전 고정 및 범위를 명확히 한다. - 테스트 태스크/포맷터/정적 분석 태스크 변경은 CI 영향 범위를 함께 점검한다. - # 자동 리뷰 설정(문서 스키마: object) - auto_review: - enabled: true - drafts: false - auto_incremental_review: true - ignore_title_keywords: - - "wip" - - "draft" - -chat: - # PR에서 @coderabbitai 멘션 시 자동 응답 - auto_reply: true diff --git a/.gitignore b/.gitignore index 5a979af6ff..30eeee7c2d 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,8 @@ out/ ### Kotlin ### .kotlin + +### Claude Code ### +*.md +!docs/**/*.md +!blog/**/*.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..2c7dbdebb7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,219 @@ +# CLAUDE.md + +## 역할 + +- 20년 경력의 백엔드 개발자이자 면접관 +- 대규모 트래픽이 발생하는 이커머스 쿠팡의 시니어 개발자이자 아키텍트 +- 외부 시스템 연동(PG, 메시징, 서드파티 API) 장애 대응 경험이 풍부하다 +- 장애 전파 방지, 트랜잭션 경계, 상태 정합성 관점에서 설계를 검증한다 +- 코드 리뷰, PR 작성, 설계 피드백 시 이 역할 기준으로 판단하고 조언한다 + +--- + +## 설계 철학 + +- 모든 설계 결정에는 트레이드오프가 있다. 정답을 찾기보다 **상황과 수단을 분석하고, 근거를 가지고 결정**한다 +- 불필요한 복잡성과 과도한 최적화는 지양한다. **현재 요구사항 기준으로 간단하고 직관적인 구현**을 우선한다 +- "왜 이렇게 했는가?"에 항상 답할 수 있어야 한다. 선택하지 않은 대안과 그 이유도 함께 기록한다 +- 대규모 트래픽 환경에서도 동작 가능한 구조를 고려하되, 현재 불필요한 것은 만들지 않는다 +- Fallback은 "에러를 잡아서 안전한 응답을 주는 것"이 아니라, **장애가 발생해도 비즈니스가 계속 동작하는 대체 경로를 확보**하는 것이다 +- Resilience는 **장애 포인트를 줄이는 것**이 아니라, **장애 포인트마다 대체 경로를 확보하는 것**이다. 외부 의존성을 피하는 것은 회피이지 대응이 아니다 +- 배치 주기, 타임아웃, 임계치 등 **수치가 들어가는 설계에는 반드시 산술적 근거를 제시**한다. "5분이면 적당하다"가 아니라, 예상 트래픽 × 처리 비용 = 시스템 부하율을 계산하고, 허용 가능한 범위인지 검증한다 +- 과제 요구사항을 넘어서는 디테일을 추구한다. **과제에서 제시하지 않은 수준의 고민이 실무 역량의 차이**를 만든다 + +--- + +## 메트릭 테이블 설계 원칙 + +### 그레인(Grain) 단일성 + +- 메트릭 테이블의 한 행은 하나의 의미만 가진다. PK가 그레인의 물리적 구현이다 +- 한 테이블에 두 그레인을 섞지 않는다 (예: "전체 합계" 행을 매직 값으로 끼워넣기 금지) + +### Additive Measure 원칙 + +- **취소/환불은 원본에서 차감하지 않고 별도 컬럼으로 기록한다** +- 사전 계산된 비율(avg_order_value, cancel_rate 등)은 컬럼으로 두지 않는다. 분자·분모를 각각 저장하고 조회 시점에 계산한다 +- 모든 measure 컬럼은 양수 누적(Additive)으로, 어떤 차원으로든 SUM이 가능해야 한다 + +### Late-Arriving Fact 대응 + +- 취소/환불 이벤트가 원주문과 다른 날짜에 도착할 수 있다 +- **발생일(order_date)과 인식일(event_date) 기준 이중 기록**으로 두 관점의 분석을 모두 지원한다 +- 이벤트 스키마에 원주문 일자를 포함시켜 발생일 기준 기록이 가능하게 한다 + +### 실시간 + 배치 병행 (Lambda Architecture) + +- 실시간 집계(Kafka → Redis)만으로는 누적 오차가 발생할 수 있다 +- **DB 원장 기반 배치 보정**으로 주기적으로 정합성을 회복한다 +- 실시간 경로는 "빠르지만 근사치", 배치 경로는 "느리지만 정확" — 두 경로의 결과가 서빙 레이어에서 합쳐진다 + +--- + +## 트래픽 규모 전제 + +- 이 프로젝트는 **쿠팡, 무신사급 대규모 트래픽 이커머스**를 위한 설계를 적용하는 프로젝트이다 +- 모든 설계 결정(배치 주기, 테이블 정리 전략, 스레드 풀, 커넥션 풀 등)은 대규모 트래픽 기준으로 검토한다 +- "현재 단일 인스턴스니까 괜찮다"가 아니라, **스케일아웃 시에도 안전한 구조**를 기본으로 설계한다 +- 산술 근거 제시 시 피크 트래픽 기준으로 계산한다 + +--- + +## 도메인 & 객체 설계 전략 + +### Entity / VO / Domain Service 구분 + +| 구분 | 기준 | 예시 | +|------|------|------| +| **Entity** | 식별자(ID) + 상태 변화 + 연속성 | Product, Brand, Order, Like | +| **Value Object** | 값 동등성 + 불변 + 자기 검증 | Price, Stock | +| **Domain Service** | 상태 없음 + 여러 객체 협력 로직 | 단일 Entity로 처리 어려운 도메인 규칙 | + +### 설계 규칙 + +1. 도메인 객체는 비즈니스 규칙을 캡슐화한다 (예: `Stock.decrease()`에서 음수 방지) +2. Application Layer(Facade)는 도메인을 조립하여 유스케이스를 완성한다 +3. 도메인 로직이 여러 서비스에 중복되면 도메인 객체로 이동시킨다 +4. Aggregate 간 참조는 ID로만 한다 (느슨한 결합) +5. VO는 불변(immutable)이며, 생성자에서 자기 검증을 수행한다 +6. 최적의 성능은 항상 목표에 포함한다. 불필요한 최적화나 오버엔지니어링만 지양할 뿐이다 + +--- + +## 아키텍처 & 패키지 전략 + +### 레이어드 아키텍처 + DIP + +``` +interfaces/api/{domain}/ → Controller, Request/Response DTO +application/{domain}/ → Facade (유스케이스 조율, 트랜잭션) +domain/{domain}/ → Entity, VO, Repository Interface +infrastructure/{domain}/ → Repository 구현체 (JPA) +``` + +### 의존 방향 + +``` +Interfaces → Application → Domain ← Infrastructure +``` + +- Domain은 다른 레이어에 의존하지 않는다 +- Infrastructure가 Domain의 Repository 인터페이스를 구현한다 (DIP) + +### DIP 실무 타협 기준 + +- **타협**: @Entity, @Embeddable을 Domain에서 사용 (테스트 가능성 해치지 않으므로) +- **준수**: Repository Interface는 Domain에, 구현체는 Infrastructure에 분리 + +> "테스트 가능성을 해치지 않는 범위에서 타협한다" + +### 패키지 구조 (계층 + 도메인) + +``` +/interfaces/api/member/ +/interfaces/api/brand/ +/interfaces/api/product/ +/interfaces/api/order/ +/interfaces/api/like/ +/application/member/ +/application/brand/ +/application/product/ +/application/order/ +/application/like/ +/domain/member/ +/domain/brand/ +/domain/product/ +/domain/order/ +/domain/like/ +/infrastructure/member/ +/infrastructure/brand/ +/infrastructure/product/ +/infrastructure/order/ +/infrastructure/like/ +``` + +### Application Layer 규칙 + +- Facade는 유스케이스 조율과 트랜잭션 경계를 담당한다 +- 비즈니스 규칙 판단, 값 검증, 상태 변경 로직은 Domain에 위임한다 +- 여러 도메인의 정보 조합은 Application Layer에서 처리한다 + - 예: `ProductFacade.getProductDetail()` → Product + Brand 조합 + +--- + +## 프로젝트 구조 (멀티 모듈) + +``` +Root +├── apps/ ← 실행 가능한 SpringBootApplication +│ ├── commerce-api ← 메인 API 서버 (대고객 + 어드민) +│ ├── commerce-batch ← 배치 서버 +│ └── commerce-streamer ← 스트리밍/이벤트 처리 서버 +├── modules/ ← 재사용 가능한 설정 모듈 (도메인 무관) +│ ├── jpa ← JPA + DataSource 설정 +│ ├── redis ← Redis 연결 + RedisTemplate 설정 +│ └── kafka ← Kafka 설정 +├── supports/ ← 부가 기능 add-on 모듈 +│ ├── jackson ← JSON 직렬화 설정 +│ ├── monitoring ← Prometheus + Actuator 설정 +│ └── logging ← 로깅 설정 +└── docker/ + ├── infra-compose.yml ← MySQL + Redis(Master-Replica) + Kafka + └── monitoring-compose.yml ← Prometheus + Grafana +``` + +### 이미 존재하는 인프라 (추가 설치 불필요) + +| 인프라 | 실행 방법 | 상세 | +|--------|----------|------| +| **MySQL 8.0** | `docker-compose -f ./docker/infra-compose.yml up` | port 3306, DB: loopers | +| **Redis Master** | 위와 동일 | port 6379, AOF 영속성 | +| **Redis Replica** | 위와 동일 | port 6380, 읽기 전용 | +| **Kafka** | 위와 동일 | port 9092 (KRaft 모드) | +| **Kafka UI** | 위와 동일 | http://localhost:9099 | +| **Prometheus + Grafana** | `docker-compose -f ./docker/monitoring-compose.yml up` | http://localhost:3000 (admin/admin) | + +### modules/redis 제공 사항 + +- `RedisConfig`: Master-Replica 커넥션 팩토리 자동 구성 +- `defaultRedisTemplate`: `ReadFrom.REPLICA_PREFERRED` (읽기 → Replica 우선) +- `masterRedisTemplate` (`@Qualifier("redisTemplateMaster")`): `ReadFrom.MASTER` (쓰기 전용) +- `RedisTestContainersConfig`: 테스트용 Testcontainers 자동 구성 +- commerce-api에서 `implementation(project(":modules:redis"))` — **이미 의존 중** + +> **주의**: Redis, JPA, Kafka 등 인프라 의존성은 modules에 이미 구성되어 있다. +> 새로운 인프라를 "추가"하기 전에 반드시 modules/와 docker/ 디렉토리를 확인할 것. + +--- + +## 코드 스타일 + +`docs/code-convention.md`와 `docs/session-prompts/00-code-style.md`를 따른다. + +- **과잉 주석 금지**: 메서드명이 충분히 설명적이면 Javadoc 생략. 뻔한 주석(`// 결과를 반환한다`) 쓰지 않는다 +- **과잉 방어 코딩 금지**: `@Valid`, `@NotBlank` 등 프레임워크 검증을 활용. 내부 메서드에서 재검증하지 않는다 +- **과잉 추상화 금지**: 구현체가 1개뿐인 인터페이스를 만들지 않는다. 필요해지면 그때 분리한다 +- **기존 코드 스타일을 따른다**: `WaitingQueueRedisRepository`, `MetricsConsumer`, `ProductController`, `ProductFacade`의 주석 수준·네이밍·구조를 관찰하고 동일하게 작성한다 + +--- + +## 설계 문서 기록 규칙 + +- 설계 문서는 `docs/design/` 하위에 번호 체계로 관리한다 (예: `08-queue-system.md`) +- 구현 시 다음 항목을 지속적으로 기록한다: + - **구현 내용과 관점**: 무엇을, 왜 이렇게 구현했는가 + - **트레이드오프와 결정사항**: 선택한 방식과 선택하지 않은 대안, 그 이유 + - **보완 및 수정사항**: 변경 내역과 변경 이유 + - **구체적 수치의 결정 근거**: 배치 크기, TTL, TPS 등 산술적 근거 + - **테스트 방식과 결과**: 부하 테스트, p99 레이턴시 측정 등 검증 결과 +- 코드만 작성하고 문서를 누락하지 않는다. **구현이 완료되면 즉시** 설계 문서를 갱신한다 — 별도 요청을 기다리지 않는다 + +--- + +## 테스트 데이터 + +- **시드 스크립트**: `scripts/seed-test-data.sh` + - 실행 전제: commerce-api가 `localhost:8080`에서 실행 중 + - 생성 데이터: 회원 10명(`user1`~`user10`, 비밀번호 `Password1!`), 브랜드 2개, 상품 5개(재고 10000개) + - 인증 헤더: `X-Loopers-LoginId: user1` / `X-Loopers-LoginPw: Password1!` + - Admin 헤더: `X-Loopers-Ldap: loopers.admin` diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index 03ce68f02c..ed46880114 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -2,15 +2,30 @@ dependencies { // add-ons implementation(project(":modules:jpa")) implementation(project(":modules:redis")) + implementation(project(":modules:kafka")) implementation(project(":supports:jackson")) implementation(project(":supports:logging")) implementation(project(":supports:monitoring")) + // cache + implementation("com.github.ben-manes.caffeine:caffeine") + // web implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-validation") + + // security (password encryption only) + implementation("org.springframework.security:spring-security-crypto") implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${project.properties["springDocOpenApiVersion"]}") + // resilience + implementation("io.github.resilience4j:resilience4j-spring-boot3") + implementation("org.springframework.boot:spring-boot-starter-aop") + + // feign (PG client) + implementation("org.springframework.cloud:spring-cloud-starter-openfeign") + // querydsl annotationProcessor("com.querydsl:querydsl-apt::jakarta") annotationProcessor("jakarta.persistence:jakarta.persistence-api") @@ -19,4 +34,7 @@ dependencies { // test-fixtures testImplementation(testFixtures(project(":modules:jpa"))) testImplementation(testFixtures(project(":modules:redis"))) + + // wiremock (PG 장애 시뮬레이션) + testImplementation("org.wiremock:wiremock-standalone:3.5.4") } diff --git a/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java b/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java index 9027b51bf5..2199a0abb5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java +++ b/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java @@ -4,9 +4,13 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.cloud.openfeign.EnableFeignClients; +import org.springframework.scheduling.annotation.EnableScheduling; import java.util.TimeZone; @ConfigurationPropertiesScan +@EnableFeignClients +@EnableScheduling @SpringBootApplication public class CommerceApiApplication { diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java new file mode 100644 index 0000000000..0a7fb3d9cd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java @@ -0,0 +1,61 @@ +package com.loopers.application.brand; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.like.LikeRepository; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class BrandFacade { + + private final BrandRepository brandRepository; + private final ProductRepository productRepository; + private final LikeRepository likeRepository; + + public Brand getBrand(Long brandId) { + return brandRepository.findById(brandId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.")); + } + + public List getAllBrands() { + return brandRepository.findAll(); + } + + @Transactional + public Brand createBrand(String name, String description) { + return brandRepository.save(new Brand(name, description)); + } + + @Transactional + public Brand updateBrand(Long brandId, String name, String description) { + Brand brand = brandRepository.findById(brandId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.")); + brand.changeName(name); + brand.changeDescription(description); + return brand; + } + + @Transactional + public void deleteBrand(Long brandId) { + Brand brand = brandRepository.findById(brandId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.")); + + List products = productRepository.findAllByBrandId(brandId); + List productIds = products.stream().map(Product::getId).toList(); + likeRepository.deleteAllByProductIdIn(productIds); + for (Product product : products) { + product.delete(); + } + brand.delete(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponApplyResult.java b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponApplyResult.java new file mode 100644 index 0000000000..967973a06a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponApplyResult.java @@ -0,0 +1,6 @@ +package com.loopers.application.coupon; + +public record CouponApplyResult( + Long couponIssueId, + int discountAmount +) {} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponFacade.java new file mode 100644 index 0000000000..c1b363fc22 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponFacade.java @@ -0,0 +1,180 @@ +package com.loopers.application.coupon; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.domain.coupon.*; +import com.loopers.infrastructure.redis.CouponIssueRequestRedisRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Clock; +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Map; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CouponFacade { + + private final CouponRepository couponRepository; + private final CouponIssueRepository couponIssueRepository; + private final CouponIssueRequestRedisRepository couponIssueRequestRedisRepository; + private final KafkaTemplate kafkaTemplate; + private final ObjectMapper objectMapper; + private final Clock clock; + + // ── Admin: 쿠폰 템플릿 CRUD ── + + @Transactional + public Coupon createCoupon(String name, DiscountType discountType, int discountValue, + int minOrderAmount, ZonedDateTime expiredAt) { + Coupon coupon = new Coupon(name, discountType, discountValue, minOrderAmount, expiredAt); + return couponRepository.save(coupon); + } + + public Coupon getCoupon(Long couponId) { + return couponRepository.findById(couponId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "쿠폰을 찾을 수 없습니다.")); + } + + public List getCoupons() { + return couponRepository.findAll(); + } + + @Transactional + public Coupon updateCoupon(Long couponId, String name, DiscountType discountType, + int discountValue, int minOrderAmount, ZonedDateTime expiredAt) { + Coupon coupon = couponRepository.findById(couponId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "쿠폰을 찾을 수 없습니다.")); + coupon.changeName(name); + coupon.changeDiscount(discountType, discountValue); + coupon.changeMinOrderAmount(minOrderAmount); + coupon.changeExpiredAt(expiredAt); + return coupon; + } + + @Transactional + public void deleteCoupon(Long couponId) { + Coupon coupon = couponRepository.findById(couponId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "쿠폰을 찾을 수 없습니다.")); + coupon.delete(); + } + + // ── Admin: 발급 내역 조회 ── + + public List getCouponIssues(Long couponId) { + couponRepository.findById(couponId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "쿠폰을 찾을 수 없습니다.")); + return couponIssueRepository.findAllByCouponId(couponId); + } + + // ── 대고객: 쿠폰 발급 ── + + @Transactional + public CouponIssue issueCoupon(Long couponId, Long memberId) { + Coupon coupon = couponRepository.findById(couponId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "쿠폰을 찾을 수 없습니다.")); + ZonedDateTime now = ZonedDateTime.now(clock); + if (now.isAfter(coupon.getExpiredAt())) { + throw new CoreException(ErrorType.BAD_REQUEST, "만료된 쿠폰은 발급할 수 없습니다."); + } + CouponIssue couponIssue = new CouponIssue(couponId, memberId, coupon.getExpiredAt()); + return couponIssueRepository.save(couponIssue); + } + + // ── 대고객: 내 쿠폰 목록 ── + + public List getMyCoupons(Long memberId) { + return couponIssueRepository.findAllByMemberId(memberId); + } + + // ── 주문 연동: 쿠폰 적용 ── + + @Transactional + public CouponApplyResult applyCouponToOrder(Long couponIssueId, Long memberId, int orderPrice) { + ZonedDateTime now = ZonedDateTime.now(clock); + + CouponIssue couponIssue = couponIssueRepository.findById(couponIssueId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "쿠폰을 찾을 수 없습니다.")); + + if (!couponIssue.getMemberId().equals(memberId)) { + throw new CoreException(ErrorType.FORBIDDEN, "본인의 쿠폰만 사용할 수 있습니다."); + } + + Coupon coupon = couponRepository.findById(couponIssue.getCouponId()) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "쿠폰 템플릿을 찾을 수 없습니다.")); + + coupon.validateUsable(orderPrice, now); + int discountAmount = coupon.calculateDiscount(orderPrice); + + int updated = couponIssueRepository.markAsUsed(couponIssueId, now); + if (updated == 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "이미 사용되었거나 만료된 쿠폰입니다."); + } + + return new CouponApplyResult(couponIssueId, discountAmount); + } + + // ── 주문 연동: 쿠폰에 주문 ID 연결 ── + + @Transactional + public void linkCouponToOrder(Long couponIssueId, Long orderId) { + CouponIssue couponIssue = couponIssueRepository.findById(couponIssueId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "쿠폰을 찾을 수 없습니다.")); + couponIssue.linkOrder(orderId); + } + + // ── 주문 연동: 쿠폰 복원 ── + + @Transactional + public void restoreCoupon(Long couponIssueId) { + CouponIssue couponIssue = couponIssueRepository.findById(couponIssueId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "쿠폰을 찾을 수 없습니다.")); + couponIssue.cancelUse(ZonedDateTime.now(clock)); + } + + // ── 선착순 쿠폰: 비동기 발급 요청 ── + + public CouponIssueRequestInfo requestCouponIssue(Long couponId, Long memberId) { + Coupon coupon = couponRepository.findById(couponId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "쿠폰을 찾을 수 없습니다.")); + ZonedDateTime now = ZonedDateTime.now(clock); + if (now.isAfter(coupon.getExpiredAt())) { + throw new CoreException(ErrorType.BAD_REQUEST, "만료된 쿠폰은 발급 요청할 수 없습니다."); + } + + Long requestId = couponIssueRequestRedisRepository.nextId(); + couponIssueRequestRedisRepository.save(requestId, couponId, memberId, "PENDING", null); + + try { + Map payload = Map.of( + "requestId", requestId, + "couponId", couponId, + "memberId", memberId + ); + String json = objectMapper.writeValueAsString(payload); + kafkaTemplate.send("coupon-issue-requests", String.valueOf(couponId), json); + } catch (Exception e) { + log.error("쿠폰 발급 요청 Kafka 전송 실패: requestId={}", requestId, e); + couponIssueRequestRedisRepository.save(requestId, couponId, memberId, "REJECTED", "Kafka 전송 실패"); + throw new CoreException(ErrorType.INTERNAL_ERROR, "쿠폰 발급 요청에 실패했습니다."); + } + + return new CouponIssueRequestInfo(requestId, couponId, memberId, CouponIssueRequestStatus.PENDING, null); + } + + public CouponIssueRequestInfo getIssueRequest(Long requestId) { + return couponIssueRequestRedisRepository.findById(requestId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "발급 요청을 찾을 수 없습니다.")); + } + + public ZonedDateTime now() { + return ZonedDateTime.now(clock); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/ProductViewKafkaPublisher.java b/apps/commerce-api/src/main/java/com/loopers/application/event/ProductViewKafkaPublisher.java new file mode 100644 index 0000000000..ca9e830c3d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/event/ProductViewKafkaPublisher.java @@ -0,0 +1,40 @@ +package com.loopers.application.event; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.domain.event.ProductViewedEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import java.util.Map; +import java.util.UUID; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ProductViewKafkaPublisher { + + private final KafkaTemplate kafkaTemplate; + private final ObjectMapper objectMapper; + + @Async("eventExecutor") + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) + public void handle(ProductViewedEvent event) { + try { + String payload = objectMapper.writeValueAsString(Map.of( + "eventId", UUID.randomUUID().toString(), + "eventType", "PRODUCT_VIEWED", + "productId", event.productId(), + "memberId", event.memberId() + )); + kafkaTemplate.send("catalog-events", String.valueOf(event.productId()), payload); + } catch (JsonProcessingException e) { + log.warn("조회수 이벤트 Kafka 발행 실패 — productId={}", event.productId(), e); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java deleted file mode 100644 index 552a9ad62e..0000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.loopers.application.example; - -import com.loopers.domain.example.ExampleModel; -import com.loopers.domain.example.ExampleService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -@RequiredArgsConstructor -@Component -public class ExampleFacade { - private final ExampleService exampleService; - - public ExampleInfo getExample(Long id) { - ExampleModel example = exampleService.getExample(id); - return ExampleInfo.from(example); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java deleted file mode 100644 index 877aba96ce..0000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.loopers.application.example; - -import com.loopers.domain.example.ExampleModel; - -public record ExampleInfo(Long id, String name, String description) { - public static ExampleInfo from(ExampleModel model) { - return new ExampleInfo( - model.getId(), - model.getName(), - model.getDescription() - ); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java new file mode 100644 index 0000000000..66d772a2fa --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -0,0 +1,61 @@ +package com.loopers.application.like; + +import com.loopers.domain.event.DomainEventPublisher; +import com.loopers.domain.event.LikeCreatedEvent; +import com.loopers.domain.event.LikeRemovedEvent; +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeRepository; +import com.loopers.domain.product.ProductRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class LikeFacade { + + private final LikeRepository likeRepository; + private final ProductRepository productRepository; + private final DomainEventPublisher domainEventPublisher; + + @Transactional + public void addLike(Long memberId, Long productId) { + productRepository.findById(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); + + if (likeRepository.existsByMemberIdAndProductId(memberId, productId)) { + return; + } + + likeRepository.save(new Like(memberId, productId)); + + domainEventPublisher.publish("catalog", String.valueOf(productId), + "LIKE_CREATED", Map.of("productId", productId, "memberId", memberId), + new LikeCreatedEvent(productId, memberId)); + } + + @Transactional + public void removeLike(Long memberId, Long productId) { + Optional likeOpt = likeRepository.findByMemberIdAndProductId(memberId, productId); + if (likeOpt.isEmpty()) { + return; + } + + likeRepository.delete(likeOpt.get()); + + domainEventPublisher.publish("catalog", String.valueOf(productId), + "LIKE_REMOVED", Map.of("productId", productId, "memberId", memberId), + new LikeRemovedEvent(productId, memberId)); + } + + public List getLikesByMemberId(Long memberId) { + return likeRepository.findAllByMemberId(memberId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java new file mode 100644 index 0000000000..3a92b3c38a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java @@ -0,0 +1,62 @@ +package com.loopers.application.member; + +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberRepository; +import com.loopers.domain.member.vo.BirthDate; +import com.loopers.domain.member.vo.Email; +import com.loopers.domain.member.vo.LoginId; +import com.loopers.domain.member.vo.Password; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@RequiredArgsConstructor +@Service +@Transactional(readOnly = true) +public class MemberFacade { + + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + + @Transactional + public Member register(String loginId, String plainPassword, String name, + String birthDate, String email) { + LoginId loginIdVo = new LoginId(loginId); + + if (memberRepository.existsByLoginId(loginIdVo)) { + throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 ID입니다."); + } + + BirthDate birthDateVo = BirthDate.from(birthDate); + Password password = Password.create(plainPassword, birthDateVo.value(), passwordEncoder); + Email emailVo = new Email(email); + + Member member = new Member(loginIdVo, password, name, birthDateVo, emailVo); + return memberRepository.save(member); + } + + public Optional findByLoginId(String loginId) { + return memberRepository.findByLoginId(new LoginId(loginId)); + } + + @Transactional + public void changePassword(Member member, String currentPlain, String newPlain) { + if (!member.getPassword().matches(currentPlain, passwordEncoder)) { + throw new CoreException(ErrorType.BAD_REQUEST, "현재 비밀번호가 일치하지 않습니다."); + } + + if (member.getPassword().matches(newPlain, passwordEncoder)) { + throw new CoreException(ErrorType.BAD_REQUEST, + "새 비밀번호는 현재 비밀번호와 달라야 합니다."); + } + + Password newPassword = Password.create( + newPlain, member.getBirthDate().value(), passwordEncoder); + member.changePassword(newPassword); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java new file mode 100644 index 0000000000..0817acf955 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -0,0 +1,195 @@ +package com.loopers.application.order; + +import com.loopers.application.coupon.CouponApplyResult; +import com.loopers.application.coupon.CouponFacade; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.event.DomainEventPublisher; +import com.loopers.domain.event.OrderCancelledEvent; +import com.loopers.domain.event.OrderCreatedEvent; +import com.loopers.domain.event.OrderItemSnapshot; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderItem; +import com.loopers.domain.order.OrderRepository; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class OrderFacade { + + private final OrderRepository orderRepository; + private final ProductRepository productRepository; + private final BrandRepository brandRepository; + private final CouponFacade couponFacade; + private final DomainEventPublisher domainEventPublisher; + + @Transactional + public Order createOrder(Long memberId, List itemRequests) { + return createOrder(memberId, itemRequests, null); + } + + @Transactional + public Order createOrder(Long memberId, List itemRequests, Long couponIssueId) { + // 1. 상품 조회 — 비관적 락 + ID 오름차순 (데드락 방지) + List sortedProductIds = itemRequests.stream() + .map(OrderItemRequest::productId) + .distinct() + .sorted() + .toList(); + + Map productMap = productRepository.findAllByIdsWithLock(sortedProductIds).stream() + .collect(Collectors.toMap(Product::getId, Function.identity())); + + for (OrderItemRequest req : itemRequests) { + if (productMap.get(req.productId()) == null) { + throw new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."); + } + } + + // 2. 브랜드 한 번에 조회 (N+1 방지) + Set brandIds = productMap.values().stream() + .map(Product::getBrandId) + .collect(Collectors.toSet()); + Map brandMap = brandRepository.findAllByIds(brandIds).stream() + .collect(Collectors.toMap(Brand::getId, Function.identity())); + + // 3. 스냅샷 생성 + List snapshots = new ArrayList<>(); + for (OrderItemRequest req : itemRequests) { + Product product = productMap.get(req.productId()); + Brand brand = brandMap.get(product.getBrandId()); + String brandName = brand != null ? brand.getName() : null; + + snapshots.add(new Order.ItemSnapshot( + product.getId(), + product.getName(), + product.getPrice().getValue(), + brandName, + req.quantity() + )); + } + + // 4. 재고 차감 — 도메인 엔티티에 위임 (비관적 락으로 보호) + for (OrderItemRequest req : itemRequests) { + Product product = productMap.get(req.productId()); + product.decreaseStock(req.quantity()); + } + + // 5. 쿠폰 적용 + Long resolvedCouponIssueId = null; + int discountAmount = 0; + + if (couponIssueId != null) { + int originalTotalPrice = snapshots.stream() + .mapToInt(s -> s.productPrice() * s.quantity()) + .sum(); + + CouponApplyResult result = couponFacade.applyCouponToOrder( + couponIssueId, memberId, originalTotalPrice); + resolvedCouponIssueId = result.couponIssueId(); + discountAmount = result.discountAmount(); + } + + // 6. 주문 저장 + Order order = orderRepository.save( + Order.create(memberId, snapshots, resolvedCouponIssueId, discountAmount)); + + // 7. 쿠폰에 주문 ID 연결 + if (resolvedCouponIssueId != null) { + couponFacade.linkCouponToOrder(resolvedCouponIssueId, order.getId()); + } + + // 8. Outbox INSERT + 이벤트 발행 + List eventItems = order.getItems().stream() + .map(item -> new OrderItemSnapshot(item.getProductId(), item.getQuantity(), item.getProductPrice())) + .toList(); + + domainEventPublisher.publish("order", String.valueOf(order.getId()), + "ORDER_CREATED", + Map.of("orderId", order.getId(), "memberId", memberId, "items", eventItems), + new OrderCreatedEvent(order.getId(), memberId, eventItems)); + + return order; + } + + public Order getOrder(Long orderId) { + return orderRepository.findById(orderId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다.")); + } + + public Order getOrder(Long orderId, Long memberId) { + Order order = orderRepository.findById(orderId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다.")); + if (!order.getMemberId().equals(memberId)) { + throw new CoreException(ErrorType.FORBIDDEN, "본인의 주문만 조회할 수 있습니다."); + } + return order; + } + + @Transactional + public void cancelOrder(Long orderId, Long memberId) { + Order order = orderRepository.findById(orderId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다.")); + if (!order.getMemberId().equals(memberId)) { + throw new CoreException(ErrorType.FORBIDDEN, "본인의 주문만 취소할 수 있습니다."); + } + order.cancel(); + + // 재고 복원 — 비관적 락 + 도메인 엔티티 위임 + List productIds = order.getItems().stream() + .map(OrderItem::getProductId) + .distinct() + .sorted() + .toList(); + Map productMap = productRepository.findAllByIdsWithLock(productIds).stream() + .collect(Collectors.toMap(Product::getId, Function.identity())); + for (OrderItem item : order.getItems()) { + Product product = productMap.get(item.getProductId()); + product.increaseStock(item.getQuantity()); + } + + // 쿠폰 복원 + if (order.getCouponIssueId() != null) { + couponFacade.restoreCoupon(order.getCouponIssueId()); + } + + // Outbox INSERT + 이벤트 발행 + List eventItems = order.getItems().stream() + .map(item -> new OrderItemSnapshot(item.getProductId(), item.getQuantity(), item.getProductPrice())) + .toList(); + + domainEventPublisher.publish("order", String.valueOf(orderId), + "ORDER_CANCELLED", + Map.of("orderId", orderId, "memberId", memberId, "items", eventItems, + "originalOrderDate", order.getCreatedAt().toLocalDate().toString()), + new OrderCancelledEvent(orderId, memberId, eventItems)); + } + + public List getOrdersByMemberId(Long memberId, ZonedDateTime startAt, ZonedDateTime endAt) { + if (startAt != null && endAt != null) { + return orderRepository.findAllByMemberIdAndCreatedAtBetween(memberId, startAt, endAt); + } + return orderRepository.findAllByMemberId(memberId); + } + + public List getAllOrders() { + return orderRepository.findAll(); + } + + public record OrderItemRequest(Long productId, int quantity) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/ProvisionalOrderService.java b/apps/commerce-api/src/main/java/com/loopers/application/order/ProvisionalOrderService.java new file mode 100644 index 0000000000..505968f95e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/ProvisionalOrderService.java @@ -0,0 +1,136 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderRepository; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.infrastructure.redis.ProvisionalOrderRedisRepository; +import com.loopers.infrastructure.redis.StockReservationRedisRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.ZonedDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * 가주문(Provisional Order) 관리 서비스. + * + *

Redis CB(redis-write) Open 시 DB 직접 주문으로 Fallback.

+ * + *

정상 경로: Redis HSET(가주문) + DECR(재고 예약)

+ *

장애 경로: DB INSERT(주문) + DB UPDATE(재고 차감)

+ * + * @see 가주문/진주문 설계 + * @see DB Fallback + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ProvisionalOrderService { + + private final ProvisionalOrderRedisRepository provisionalOrderRedisRepository; + private final StockReservationRedisRepository stockReservationRedisRepository; + private final OrderRepository orderRepository; + private final ProductRepository productRepository; + + /** + * 가주문 생성 — Redis에 저장 + 재고 예약. + * + *

Redis 장애(redis-write CB Open) 시 {@link #saveToDbFallback}으로 전환.

+ */ + @CircuitBreaker(name = "redis-write", fallbackMethod = "saveToDbFallback") + public ProvisionalOrderResult saveProvisionalOrder(Long orderId, Long memberId, int amount, + String cardType, String cardNo, + List items) { + // 1. Redis에 가주문 데이터 저장 + Map orderData = new HashMap<>(); + orderData.put("orderId", orderId); + orderData.put("memberId", memberId); + orderData.put("amount", amount); + orderData.put("cardType", cardType); + orderData.put("cardNo", cardNo); + orderData.put("createdAt", ZonedDateTime.now().toString()); + orderData.put("items", items.stream() + .map(item -> Map.of( + "productId", item.productId(), + "quantity", item.quantity() + )).toList()); + + provisionalOrderRedisRepository.save(orderId, orderData); + + // 2. Redis 재고 예약 (DECR) + for (Order.ItemSnapshot item : items) { + stockReservationRedisRepository.decrease(item.productId(), item.quantity()); + } + + log.info("가주문 저장 완료 (Redis): orderId={}, memberId={}", orderId, memberId); + return ProvisionalOrderResult.provisional(orderId); + } + + /** + * Redis 장애 시 DB 직접 주문 Fallback. + * + *

Redis 대신 DB에 Order(CREATED)를 직접 생성하고 DB 재고를 차감한다.

+ * + * @see DB Fallback + */ + @Transactional + ProvisionalOrderResult saveToDbFallback(Long orderId, Long memberId, int amount, + String cardType, String cardNo, + List items, Exception e) { + log.warn("Redis 장애 — DB 직접 주문으로 Fallback: orderId={}, error={}", orderId, e.getMessage()); + + // 1. DB에 Order(CREATED) 직접 생성 + Order order = Order.create(memberId, items); + order = orderRepository.save(order); + + // 2. DB 재고 차감 + for (Order.ItemSnapshot item : items) { + Product product = productRepository.findById(item.productId()) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + "상품을 찾을 수 없습니다: productId=" + item.productId())); + product.decreaseStock(item.quantity()); + productRepository.save(product); + } + + log.info("DB 직접 주문 생성 완료: orderId={}, dbOrderId={}", orderId, order.getId()); + return ProvisionalOrderResult.directOrder(order.getId()); + } + + public Optional> getProvisionalOrder(Long orderId) { + return provisionalOrderRedisRepository.findByOrderId(orderId); + } + + public void deleteProvisionalOrder(Long orderId) { + provisionalOrderRedisRepository.deleteByOrderId(orderId); + log.info("가주문 삭제 완료: orderId={}", orderId); + } + + public boolean exists(Long orderId) { + return provisionalOrderRedisRepository.exists(orderId); + } + + /** + * 가주문 생성 결과. + * + * @param orderId 주문 ID + * @param isDirect true: DB 직접 생성 (Fallback), false: Redis 가주문 + */ + public record ProvisionalOrderResult(Long orderId, boolean isDirect) { + public static ProvisionalOrderResult provisional(Long orderId) { + return new ProvisionalOrderResult(orderId, false); + } + + public static ProvisionalOrderResult directOrder(Long orderId) { + return new ProvisionalOrderResult(orderId, true); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java new file mode 100644 index 0000000000..6c4dffbb21 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java @@ -0,0 +1,287 @@ +package com.loopers.application.payment; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderRepository; +import com.loopers.domain.order.OrderStatus; +import com.loopers.domain.payment.*; +import com.loopers.infrastructure.pg.*; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 결제 유스케이스 조율. + * + *

흐름 (Phase 2):

+ *
    + *
  1. 주문 검증 + 중복 결제 방지
  2. + *
  3. Payment(REQUESTED) 생성 + DB 저장
  4. + *
  5. 수동 Retry 루프: PG 호출 → 실패 시 PG 상태 확인 → 멱등 재시도
  6. + *
  7. 모든 PG 실패 → UNKNOWN 상태 저장 + "결제 확인 중" 응답
  8. + *
+ * + *

실행 순서: SlidingWindowRateLimiter(AOP) → Retry(수동) → CB(@CircuitBreaker on PgClient) → Feign

+ * + * @see 수동 Retry + 멱등성 보장 + * @see 최종 Fallback: UNKNOWN 상태 + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PaymentFacade { + + private final PaymentRepository paymentRepository; + private final OrderRepository orderRepository; + private final PgRouter pgRouter; + private final PaymentOutboxRepository outboxRepository; + + @Value("${payment.callback-url:http://localhost:8080/api/v1/payments/callback}") + private String callbackUrl; + + @Value("${payment.retry.max-attempts:3}") + private int maxRetryAttempts; + + @Value("${payment.retry.initial-wait-ms:500}") + private long initialWaitMs; + + @Value("${payment.retry.backoff-multiplier:2}") + private int backoffMultiplier; + + @Transactional + public PaymentResult requestPayment(Long orderId, String cardType, String cardNo, int amount) { + // 1. 주문 검증 + Order order = orderRepository.findById(orderId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다.")); + + if (order.getStatus() == OrderStatus.PAID) { + throw new CoreException(ErrorType.BAD_REQUEST, "이미 결제된 주문입니다."); + } + + if (order.getStatus() == OrderStatus.CANCELLED) { + throw new CoreException(ErrorType.BAD_REQUEST, "취소된 주문은 결제할 수 없습니다."); + } + + // 2. 중복 결제 방지 + paymentRepository.findByOrderId(orderId).ifPresent(existing -> { + if (!existing.getStatus().isTerminal() + || existing.getStatus() == PaymentStatus.PAID) { + throw new CoreException(ErrorType.CONFLICT, + "이미 결제가 진행 중이거나 완료된 주문입니다. paymentStatus=" + existing.getStatus()); + } + }); + + // 3. Payment(REQUESTED) + Outbox(PENDING) 같은 TX에서 생성 + PaymentModel payment = paymentRepository.save( + PaymentModel.create(orderId, amount, cardType, cardNo)); + String outboxPayload = String.format( + "{\"orderId\":%d,\"amount\":%d,\"cardType\":\"%s\",\"cardNo\":\"%s\"}", + orderId, amount, cardType, cardNo); + outboxRepository.save(PaymentOutbox.create(payment.getId(), orderId, outboxPayload)); + log.info("결제 요청 생성: paymentId={}, orderId={}", payment.getId(), orderId); + + // 4. 수동 Retry 루프 (PG 상태 확인 후 멱등 재시도) + PgPaymentRequest pgRequest = PgPaymentRequest.of(orderId, cardType, cardNo, amount, callbackUrl); + return executeWithRetry(payment, order, pgRequest); + } + + /** + * 수동 Retry 루프. + * + *

1차 실패 → PG 상태 확인 (기록 존재?) → 있으면 재시도 안 함 → 없으면 재시도. + * 모든 시도 실패 → UNKNOWN 상태 저장 + "결제 확인 중" 응답.

+ * + *

Phase 6: 동기 PG (Toss) 대응 — SUCCESS 즉시 반환 시 PAID 처리.

+ * + * @see 멱등성 보장 + */ + private PaymentResult executeWithRetry(PaymentModel payment, Order order, PgPaymentRequest pgRequest) { + Exception lastException = null; + long waitMs = initialWaitMs; + + for (int attempt = 1; attempt <= maxRetryAttempts; attempt++) { + try { + PgPaymentResponse pgResponse = pgRouter.requestPayment(pgRequest); + + // PG 응답 상태에 따른 분기 + return handlePgResponse(payment, order, pgResponse, attempt); + + } catch (Exception e) { + lastException = e; + log.warn("PG 결제 요청 실패: paymentId={}, attempt={}/{}, error={}", + payment.getId(), attempt, maxRetryAttempts, e.getMessage()); + + // 마지막 시도가 아니면 → PG 상태 확인 후 재시도 여부 결정 + if (attempt < maxRetryAttempts) { + PaymentResult existingResult = checkPgStatusBeforeRetry(payment, pgRequest); + if (existingResult != null) { + return existingResult; // PG에 이미 기록 있음 → 재시도 안 함 + } + + // 대기 후 재시도 + sleep(waitMs); + waitMs *= backoffMultiplier; + } + } + } + + // 모든 시도 실패 → UNKNOWN Fallback + return handleUnknownFallback(payment, lastException); + } + + /** + * PG 응답 상태별 처리. + * + *
    + *
  • PENDING (Simulator 비동기) → Payment PENDING, 콜백 대기
  • + *
  • SUCCESS (Toss 동기) → Payment PAID + Order PAID 즉시 확정
  • + *
  • FAILED (Toss 동기) → Payment FAILED 즉시 확정
  • + *
+ */ + private PaymentResult handlePgResponse(PaymentModel payment, Order order, + PgPaymentResponse pgResponse, int attempt) { + String pgProvider = pgResponse.pgProvider(); + + switch (pgResponse.status()) { + case "SUCCESS" -> { + // 동기 PG (Toss): 즉시 결제 확정 + payment.markPending(pgResponse.transactionKey(), pgProvider); + payment.markPaid(); + paymentRepository.save(payment); + order.pay(); + orderRepository.save(order); + log.info("결제 즉시 확정 (동기 PG): paymentId={}, transactionKey={}, provider={}, attempt={}", + payment.getId(), pgResponse.transactionKey(), pgProvider, attempt); + return new PaymentResult(payment.getId(), pgResponse.transactionKey(), + PaymentStatus.PAID.name(), null); + } + case "FAILED" -> { + // 동기 PG (Toss): 즉시 실패 + payment.markFailed("PG 결제 실패 (provider=" + pgProvider + ")"); + paymentRepository.save(payment); + log.info("결제 즉시 실패 (동기 PG): paymentId={}, provider={}, attempt={}", + payment.getId(), pgProvider, attempt); + return new PaymentResult(payment.getId(), pgResponse.transactionKey(), + PaymentStatus.FAILED.name(), "PG 결제 실패"); + } + default -> { + // PENDING (Simulator 비동기): 콜백 대기 + payment.markPending(pgResponse.transactionKey(), pgProvider); + paymentRepository.save(payment); + log.info("결제 PENDING: paymentId={}, transactionKey={}, provider={}, attempt={}", + payment.getId(), pgResponse.transactionKey(), pgProvider, attempt); + return new PaymentResult(payment.getId(), pgResponse.transactionKey(), + payment.getStatus().name(), null); + } + } + } + + /** + * 재시도 전 PG 상태 확인 — 멱등성 보장. + * + *

첫 번째 요청이 PG에서 이미 처리되었을 수 있으므로, + * orderId로 PG 상태를 확인하고 기록이 있으면 재시도하지 않는다.

+ * + * @return PG에 기록이 있으면 PaymentResult, 없으면 null (재시도 필요) + */ + private PaymentResult checkPgStatusBeforeRetry(PaymentModel payment, PgPaymentRequest pgRequest) { + try { + PgPaymentStatusResponse pgStatus = pgRouter.getPaymentByOrderId( + pgRequest.orderId(), pgRouter.getPrimaryClient().getProviderName()); + + if (pgStatus != null && pgStatus.transactionKey() != null + && !"UNKNOWN".equals(pgStatus.status())) { + + log.info("PG에 기록 존재 — 재시도 안 함: paymentId={}, pgStatus={}", + payment.getId(), pgStatus.status()); + + // PG 상태에 따라 내부 상태 전이 + return handlePgStatusResult(payment, pgStatus); + } + } catch (Exception e) { + log.warn("PG 상태 확인 실패 — 재시도 진행: paymentId={}", payment.getId()); + } + return null; // PG에 기록 없음 → 재시도 필요 + } + + /** + * PG 상태 확인 결과를 내부 Payment 상태에 반영한다. + */ + private PaymentResult handlePgStatusResult(PaymentModel payment, PgPaymentStatusResponse pgStatus) { + switch (pgStatus.status()) { + case "PENDING" -> { + payment.markPending(pgStatus.transactionKey(), + pgRouter.getPrimaryClient().getProviderName()); + paymentRepository.save(payment); + return new PaymentResult(payment.getId(), pgStatus.transactionKey(), + PaymentStatus.PENDING.name(), null); + } + case "SUCCESS" -> { + payment.markPending(pgStatus.transactionKey(), + pgRouter.getPrimaryClient().getProviderName()); + payment.markPaid(); + paymentRepository.save(payment); + return new PaymentResult(payment.getId(), pgStatus.transactionKey(), + PaymentStatus.PAID.name(), null); + } + case "FAILED" -> { + payment.markFailed(pgStatus.reason()); + paymentRepository.save(payment); + return new PaymentResult(payment.getId(), null, + PaymentStatus.FAILED.name(), pgStatus.reason()); + } + default -> { + return null; // 알 수 없는 상태 → 재시도 + } + } + } + + /** + * 최종 Fallback: UNKNOWN 상태 저장 + "결제 확인 중" 응답. + * + *

모든 PG 실패 후 최종 안전장치. + * UNKNOWN 상태 결제건은 배치/Outbox가 PG 상태를 확인하여 최종 전이한다.

+ * + * @see 최종 Fallback + */ + private PaymentResult handleUnknownFallback(PaymentModel payment, Exception lastException) { + log.error("모든 PG 결제 요청 실패 — UNKNOWN Fallback: paymentId={}, lastError={}", + payment.getId(), lastException != null ? lastException.getMessage() : "unknown"); + + payment.markUnknown(); + paymentRepository.save(payment); + + return new PaymentResult(payment.getId(), null, + PaymentStatus.UNKNOWN.name(), + "결제 확인 중입니다. 잠시 후 확인해주세요."); + } + + private void sleep(long ms) { + try { + Thread.sleep(ms); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + public PaymentModel getPayment(Long paymentId) { + return paymentRepository.findById(paymentId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "결제 정보를 찾을 수 없습니다.")); + } + + public PaymentModel getPaymentByOrderId(Long orderId) { + return paymentRepository.findByOrderId(orderId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 주문의 결제 정보를 찾을 수 없습니다.")); + } + + public record PaymentResult( + Long paymentId, + String transactionKey, + String status, + String failureReason + ) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentRecoveryService.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentRecoveryService.java new file mode 100644 index 0000000000..a949de62ac --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentRecoveryService.java @@ -0,0 +1,241 @@ +package com.loopers.application.payment; + +import com.loopers.application.coupon.CouponFacade; +import com.loopers.application.product.ProductFacade; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderItem; +import com.loopers.domain.order.OrderRepository; +import com.loopers.domain.payment.*; +import com.loopers.infrastructure.pg.PgPaymentStatusResponse; +import com.loopers.infrastructure.pg.PgRouter; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.ZonedDateTime; +import java.util.List; + +/** + * 결제 복구 서비스 — 콜백 처리 + Polling Hybrid. + * + *

콜백 처리 흐름:

+ *
    + *
  1. CallbackInbox에 원본 저장 (RECEIVED)
  2. + *
  3. 조건부 UPDATE로 Payment 상태 전이
  4. + *
  5. SUCCESS → Order.pay() + Inbox PROCESSED
  6. + *
  7. FAILED → 재고 복원(ProductFacade) + 쿠폰 복원(CouponFacade) + Inbox PROCESSED
  8. + *
+ * + *

Polling Hybrid: PENDING/UNKNOWN 상태 결제건을 주기적으로 PG 확인

+ * + * @see Callback Inbox DLQ + * @see Polling Hybrid + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class PaymentRecoveryService { + + private final PaymentRepository paymentRepository; + private final PaymentStatusHistoryRepository historyRepository; + private final CallbackInboxRepository callbackInboxRepository; + private final OrderRepository orderRepository; + private final ProductFacade productFacade; + private final CouponFacade couponFacade; + private final PgRouter pgRouter; + + /** + * PG 콜백 처리. + * + * @param transactionKey PG 거래 키 + * @param pgStatus PG 상태 (SUCCESS, FAILED, PENDING 등) + * @param payload 원본 콜백 데이터 + */ + @Transactional + public void processCallback(String transactionKey, String pgStatus, String payload) { + // 1. 콜백 원본 저장 (DLQ) + PaymentModel payment = paymentRepository.findByTransactionKey(transactionKey).orElse(null); + Long orderId = payment != null ? payment.getOrderId() : null; + + CallbackInbox inbox = callbackInboxRepository.save( + CallbackInbox.create(transactionKey, orderId, pgStatus, payload)); + + // 2. Payment 조회 실패 → 로그 + Inbox FAILED + if (payment == null) { + log.warn("콜백 수신 — Payment 없음: transactionKey={}", transactionKey); + inbox.markFailed("Payment not found for transactionKey: " + transactionKey); + callbackInboxRepository.save(inbox); + return; + } + + // 3. PENDING 콜백 → 무시 (06 §14.4 규칙) + if ("PENDING".equals(pgStatus)) { + log.info("PENDING 콜백 무시: paymentId={}", payment.getId()); + inbox.markProcessed(); + callbackInboxRepository.save(inbox); + return; + } + + // 4. 조건부 UPDATE로 상태 전이 + processPaymentTransition(payment, pgStatus, inbox); + } + + private void processPaymentTransition(PaymentModel payment, String pgStatus, CallbackInbox inbox) { + PaymentStatus targetStatus = "SUCCESS".equals(pgStatus) ? PaymentStatus.PAID : PaymentStatus.FAILED; + List allowedStatuses = List.of(PaymentStatus.PENDING, PaymentStatus.UNKNOWN); + + int affected = paymentRepository.updateStatusConditionally( + payment.getId(), targetStatus, allowedStatuses); + + if (affected == 0) { + log.info("조건부 UPDATE 미적용 (이미 처리된 건): paymentId={}, currentStatus={}", + payment.getId(), payment.getStatus()); + inbox.markProcessed(); + callbackInboxRepository.save(inbox); + return; + } + + // 상태 전이 이력 기록 + historyRepository.save(PaymentStatusHistory.create( + payment.getId(), payment.getStatus(), targetStatus, "CALLBACK", null)); + + // 상태 전이 성공 + if (targetStatus == PaymentStatus.PAID) { + handlePaymentSuccess(payment); + } else { + handlePaymentFailure(payment); + } + + inbox.markProcessed(); + callbackInboxRepository.save(inbox); + log.info("콜백 처리 완료: paymentId={}, newStatus={}", payment.getId(), targetStatus); + } + + private void handlePaymentSuccess(PaymentModel payment) { + Order order = orderRepository.findById(payment.getOrderId()).orElse(null); + if (order != null) { + order.pay(); + orderRepository.save(order); + log.info("주문 결제 완료: orderId={}", order.getId()); + } + } + + private void handlePaymentFailure(PaymentModel payment) { + Order order = orderRepository.findById(payment.getOrderId()).orElse(null); + if (order == null) return; + + // 재고 복원 → ProductFacade 위임 + for (OrderItem item : order.getItems()) { + productFacade.restoreStock(item.getProductId(), item.getQuantity()); + } + log.info("재고 복원 완료: orderId={}", order.getId()); + + // 쿠폰 복원 → CouponFacade 위임 + if (order.getCouponIssueId() != null) { + couponFacade.restoreCoupon(order.getCouponIssueId()); + log.info("쿠폰 복원 완료: couponIssueId={}", order.getCouponIssueId()); + } + } + + /** + * Polling Hybrid — PENDING/UNKNOWN 상태 결제건을 PG에서 확인. + * + *

생성 후 10초 이상 경과한 PENDING 결제건만 폴링한다.

+ */ + @Scheduled(fixedRate = 10_000) + public void checkPendingPayments() { + List pendingPayments = paymentRepository.findAllByStatus(PaymentStatus.PENDING); + List unknownPayments = paymentRepository.findAllByStatus(PaymentStatus.UNKNOWN); + + ZonedDateTime threshold = ZonedDateTime.now().minusSeconds(10); + + for (PaymentModel payment : pendingPayments) { + if (payment.getCreatedAt() != null && payment.getCreatedAt().isBefore(threshold)) { + pollPgStatus(payment); + } + } + + for (PaymentModel payment : unknownPayments) { + pollPgStatus(payment); + } + } + + /** + * 수동 복구 — 운영자가 PENDING/UNKNOWN 결제건의 PG 상태를 확인하여 확정. + * + * @see 수동 복구 API + */ + @Transactional + public String manualConfirm(Long paymentId) { + PaymentModel payment = paymentRepository.findById(paymentId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "결제 정보를 찾을 수 없습니다.")); + + if (payment.getStatus().isTerminal()) { + return "이미 최종 상태입니다: " + payment.getStatus(); + } + + pollPgStatus(payment); + + // 재조회하여 변경된 상태 반환 + PaymentModel updated = paymentRepository.findById(paymentId).orElseThrow(); + return "확인 완료: " + updated.getStatus(); + } + + /** + * PG 상태 폴링 → 직접 조건부 UPDATE. + * + *

processCallback()과 달리, 이미 Payment 참조를 갖고 있으므로 + * transactionKey 검색 없이 직접 업데이트한다. + * UNKNOWN 상태에서 transactionKey가 없는 유령 결제도 orderId로 PG를 조회하여 복구.

+ */ + private void pollPgStatus(PaymentModel payment) { + try { + PgPaymentStatusResponse pgStatus; + if (payment.getTransactionKey() != null && payment.getPgProvider() != null) { + pgStatus = pgRouter.getPaymentStatus( + payment.getTransactionKey(), payment.getPgProvider()); + } else { + pgStatus = pgRouter.getPaymentByOrderId( + String.valueOf(payment.getOrderId()), + pgRouter.getPrimaryClient().getProviderName()); + } + + if (pgStatus == null) return; + + List allowedStatuses = List.of(PaymentStatus.PENDING, PaymentStatus.UNKNOWN); + + switch (pgStatus.status()) { + case "SUCCESS" -> { + int affected = paymentRepository.updateStatusConditionally( + payment.getId(), PaymentStatus.PAID, allowedStatuses); + if (affected > 0) { + historyRepository.save(PaymentStatusHistory.create( + payment.getId(), payment.getStatus(), PaymentStatus.PAID, "POLLING", null)); + handlePaymentSuccess(payment); + log.info("Polling 복구 성공: paymentId={}, → PAID", payment.getId()); + } + } + case "FAILED" -> { + int affected = paymentRepository.updateStatusConditionally( + payment.getId(), PaymentStatus.FAILED, allowedStatuses); + if (affected > 0) { + historyRepository.save(PaymentStatusHistory.create( + payment.getId(), payment.getStatus(), PaymentStatus.FAILED, + "POLLING", pgStatus.reason())); + handlePaymentFailure(payment); + log.info("Polling 복구: paymentId={}, → FAILED (reason={})", + payment.getId(), pgStatus.reason()); + } + } + default -> log.debug("PG 폴링 — 아직 처리 중: paymentId={}, pgStatus={}", + payment.getId(), pgStatus.status()); + } + } catch (Exception e) { + log.warn("PG 폴링 실패: paymentId={}, error={}", payment.getId(), e.getMessage()); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCachePort.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCachePort.java new file mode 100644 index 0000000000..bd9c71b6d1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCachePort.java @@ -0,0 +1,22 @@ +package com.loopers.application.product; + +import com.loopers.interfaces.api.product.ProductDto; + +public interface ProductCachePort { + + // ── 상품 상세 캐시 ── + + ProductDto.ProductResponse getProductDetail(Long productId); + + void putProductDetail(Long productId, ProductDto.ProductResponse response); + + void evictProductDetail(Long productId); + + // ── 상품 목록 캐시 ── + + ProductDto.PagedProductResponse getProductList(Long brandId, String sort, int page, int size); + + void putProductList(Long brandId, String sort, int page, int size, ProductDto.PagedProductResponse response); + + void evictProductList(); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java new file mode 100644 index 0000000000..9667d79928 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -0,0 +1,217 @@ +package com.loopers.application.product; + +import com.loopers.domain.event.ProductViewedEvent; +import com.loopers.domain.like.LikeRepository; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.ProductWithBrand; +import com.loopers.domain.product.vo.Price; +import com.loopers.domain.product.vo.Stock; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.infrastructure.ranking.RankingRedisRepository; +import com.loopers.infrastructure.redis.StockReservationRedisRepository; +import com.loopers.interfaces.api.product.ProductDto; +import com.loopers.interfaces.api.ranking.RankingDto; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Comparator; +import java.util.List; +import java.util.Map; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ProductFacade { + + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.BASIC_ISO_DATE; + + private final ProductRepository productRepository; + private final BrandRepository brandRepository; + private final LikeRepository likeRepository; + private final ProductCachePort productCachePort; + private final ApplicationEventPublisher applicationEventPublisher; + private final StockReservationRedisRepository stockRedisRepository; + private final RankingRedisRepository rankingRedisRepository; + + // ── 상품 상세 (캐시 적용) ── + + public ProductWithBrand getProductDetail(Long productId) { + Product product = productRepository.findById(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); + Brand brand = brandRepository.findById(product.getBrandId()).orElse(null); + String brandName = (brand != null) ? brand.getName() : null; + return new ProductWithBrand(product, brandName, product.getLikeCount()); + } + + public ProductDto.ProductResponse getProductDetailCached(Long productId) { + ProductDto.ProductResponse cached = productCachePort.getProductDetail(productId); + ProductDto.ProductResponse response; + if (cached != null) { + applicationEventPublisher.publishEvent(new ProductViewedEvent(productId, 0L)); + response = cached; + } else { + ProductWithBrand info = getProductDetail(productId); + response = ProductDto.ProductResponse.from(info); + productCachePort.putProductDetail(productId, response); + applicationEventPublisher.publishEvent(new ProductViewedEvent(productId, 0L)); + } + return response.withRanking(lookupRanking(productId)); + } + + private RankingDto.RankingInfo lookupRanking(Long productId) { + try { + String today = LocalDate.now(KST).format(DATE_FORMATTER); + RankingRedisRepository.RankAndScore rs = rankingRedisRepository.getRankAndScore(today, productId); + if (rs == null) return null; + return new RankingDto.RankingInfo(rs.rank(), rs.score(), today); + } catch (Exception e) { + log.warn("상품 {} 랭킹 조회 실패", productId, e); + return null; + } + } + + // ── 상품 목록 (페이지네이션 + 캐시 적용) ── + + public Page getAllProducts(String sort, Pageable pageable) { + return productRepository.findAllWithBrand(sort, pageable); + } + + public Page getProductsByBrandId(Long brandId, String sort, Pageable pageable) { + brandRepository.findById(brandId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.")); + return productRepository.findAllByBrandIdWithBrand(brandId, sort, pageable); + } + + public ProductDto.PagedProductResponse getAllProductsCached(Long brandId, String sort, int page, int size) { + ProductDto.PagedProductResponse cached = productCachePort.getProductList(brandId, sort, page, size); + if (cached != null) { + return cached; + } + + Pageable pageable = PageRequest.of(page, size); + Page result; + if (brandId != null) { + result = getProductsByBrandId(brandId, sort, pageable); + } else { + result = getAllProducts(sort, pageable); + } + + ProductDto.PagedProductResponse response = ProductDto.PagedProductResponse.from(result); + productCachePort.putProductList(brandId, sort, page, size, response); + return response; + } + + // ── 신상품 조회 ── + + public ProductDto.PagedProductResponse getNewProducts(int hours, int page, int size) { + ZonedDateTime since = ZonedDateTime.now(KST).minusHours(hours); + Pageable pageable = PageRequest.of(page, size); + Page result = productRepository.findNewProducts(since, pageable); + return ProductDto.PagedProductResponse.from(result); + } + + // ── 기존 List 반환 메서드 (하위 호환 + 벤치마크용) ── + + public List getAllProducts() { + return productRepository.findAllWithBrand(); + } + + public List getAllProducts(String sort) { + return productRepository.findAllWithBrand(sort); + } + + public List getProductsByBrandId(Long brandId) { + brandRepository.findById(brandId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.")); + return productRepository.findAllByBrandIdWithBrand(brandId); + } + + // ── 벤치마크 전용: AS-IS 재현 (enrichWithLikeCount + in-memory sort) ── + + public List getAllProductsNoOptimization(String sort) { + List results = enrichWithLikeCount( + productRepository.findAllWithBrand(sort)); + + if ("likes_desc".equals(sort)) { + return results.stream() + .sorted(Comparator.comparingLong(ProductWithBrand::likeCount).reversed()) + .toList(); + } + return results; + } + + // ── 재고 복원 (결제 실패/취소 시 호출) ── + + @Transactional + public void restoreStock(Long productId, int quantity) { + stockRedisRepository.increase(productId, quantity); + productRepository.findById(productId).ifPresent(product -> { + product.increaseStock(quantity); + productRepository.save(product); + }); + } + + // ── 상품 CUD (캐시 무효화 포함) ── + + @Transactional + public Product createProduct(Long brandId, String name, int price, int stockQuantity, Long categoryId) { + brandRepository.findById(brandId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.")); + Product product = new Product(brandId, name, new Price(price), new Stock(stockQuantity), categoryId); + Product saved = productRepository.save(product); + productCachePort.evictProductList(); + return saved; + } + + @Transactional + public Product updateProduct(Long productId, String name, int price, int stockQuantity) { + Product product = productRepository.findById(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); + product.changeName(name); + product.changePrice(new Price(price)); + product.changeStock(new Stock(stockQuantity)); + productCachePort.evictProductDetail(productId); + productCachePort.evictProductList(); + return product; + } + + @Transactional + public void deleteProduct(Long productId) { + Product product = productRepository.findById(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); + likeRepository.deleteAllByProductId(productId); + product.delete(); + productCachePort.evictProductDetail(productId); + productCachePort.evictProductList(); + } + + // ── private: 벤치마크 전용 AS-IS 로직 보존 ── + + private List enrichWithLikeCount(List products) { + List productIds = products.stream() + .map(pwb -> pwb.product().getId()) + .toList(); + Map likeCounts = likeRepository.countByProductIds(productIds); + return products.stream() + .map(pwb -> new ProductWithBrand( + pwb.product(), pwb.brandName(), + likeCounts.getOrDefault(pwb.product().getId(), 0L))) + .toList(); + } +} 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 new file mode 100644 index 0000000000..f6fadd9a2f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java @@ -0,0 +1,160 @@ +package com.loopers.application.ranking; + +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.ProductWithBrand; +import com.loopers.domain.ranking.MvProductRank; +import com.loopers.domain.ranking.MvProductRankRepository; +import com.loopers.infrastructure.ranking.RankingRedisRepository; +import com.loopers.interfaces.api.ranking.RankingDto; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class RankingFacade { + + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.BASIC_ISO_DATE; + private static final int MAX_RANKING_SIZE = 100; + + private static final String DAILY_ZSET_PREFIX = "ranking:all:"; + + private final RankingRedisRepository rankingRedisRepository; + private final MvProductRankRepository mvProductRankRepository; + private final ProductRepository productRepository; + private final RankingProperties properties; + + public RankingDto.PagedRankingResponse getRankings(String scope, String date, int page, int size, Long memberId) { + String resolvedDate = (date != null) ? date : LocalDate.now(KST).format(DATE_FORMATTER); + + return switch (scope) { + case "weekly", "monthly" -> getFromMv(scope, resolvedDate, page, size); + default -> getFromRedis(scope, resolvedDate, page, size, memberId); + }; + } + + private RankingDto.PagedRankingResponse getFromMv(String scope, String date, int page, int size) { + // 1. 당일 MV 조회 + List mvResults = mvProductRankRepository.findByPeriodKeyAndScope( + date, scope, PageRequest.of(page, size)); + long totalElements = mvProductRankRepository.countByPeriodKeyAndScope(date, scope); + + // 2. 당일 데이터 없으면 전일 fallback + if (mvResults.isEmpty()) { + String previousDate = LocalDate.parse(date, DATE_FORMATTER) + .minusDays(1).format(DATE_FORMATTER); + mvResults = mvProductRankRepository.findByPeriodKeyAndScope( + previousDate, scope, PageRequest.of(page, size)); + totalElements = mvProductRankRepository.countByPeriodKeyAndScope(previousDate, scope); + + if (!mvResults.isEmpty()) { + log.info("MV 전일 fallback 적용: scope={}, date={} → {}", scope, date, previousDate); + } + } + + // 3. 전일도 없으면 빈 결과 + if (mvResults.isEmpty()) { + return new RankingDto.PagedRankingResponse(List.of(), 0, 0, page, size); + } + + totalElements = Math.min(totalElements, MAX_RANKING_SIZE); + int totalPages = (int) Math.ceil((double) totalElements / size); + + // 4. Product 상세 조합 + List productIds = mvResults.stream() + .map(MvProductRank::getProductId).toList(); + + Map productMap = productRepository.findAllByIds(productIds).stream() + .collect(Collectors.toMap(pwb -> pwb.product().getId(), pwb -> pwb)); + + List data = new ArrayList<>(); + for (MvProductRank mv : mvResults) { + ProductWithBrand pwb = productMap.get(mv.getProductId()); + if (pwb != null) { + Product product = pwb.product(); + data.add(new RankingDto.RankingResponse( + mv.getProductId(), product.getName(), pwb.brandName(), + product.getPrice().getValue(), mv.getRanking(), mv.getScore() + )); + } + } + + return new RankingDto.PagedRankingResponse(data, totalElements, totalPages, page, size); + } + + private RankingDto.PagedRankingResponse getFromRedis(String scope, String date, int page, int size, Long memberId) { + String prefix = resolveDailyPrefix(memberId); + + long totalElements; + List entries; + + try { + long rawTotal = rankingRedisRepository.getTotalCount(prefix, date); + totalElements = Math.min(rawTotal, MAX_RANKING_SIZE); + + long start = (long) page * size; + int totalPages = (int) Math.ceil((double) totalElements / size); + + if (start >= totalElements) { + return new RankingDto.PagedRankingResponse(List.of(), totalElements, totalPages, page, size); + } + + long end = Math.min(start + size - 1, totalElements - 1); + entries = rankingRedisRepository.getTopN(prefix, date, start, end); + } catch (Exception e) { + log.error("랭킹 Redis 조회 실패", e); + throw new CoreException(ErrorType.INTERNAL_ERROR, "랭킹 서비스를 일시적으로 이용할 수 없습니다."); + } + + List productIds = entries.stream() + .map(RankingRedisRepository.RankingEntry::productId).toList(); + + Map productMap = productRepository.findAllByIds(productIds).stream() + .collect(Collectors.toMap(pwb -> pwb.product().getId(), pwb -> pwb)); + + List data = new ArrayList<>(); + long rank = (long) page * size + 1; + for (RankingRedisRepository.RankingEntry entry : entries) { + ProductWithBrand pwb = productMap.get(entry.productId()); + if (pwb != null) { + Product product = pwb.product(); + data.add(new RankingDto.RankingResponse( + entry.productId(), product.getName(), pwb.brandName(), + product.getPrice().getValue(), rank, entry.score() + )); + } + rank++; + } + + int totalPages = (int) Math.ceil((double) totalElements / size); + return new RankingDto.PagedRankingResponse(data, totalElements, totalPages, page, size); + } + + private String resolveDailyPrefix(Long memberId) { + RankingProperties.Experiment experiment = properties.experiment(); + if (experiment.enabled() && !experiment.variants().isEmpty() && memberId != null) { + List variantKeys = new ArrayList<>(experiment.variants().keySet()); + int variantIndex = (int) (Math.abs(memberId) % variantKeys.size()); + String selectedKey = variantKeys.get(variantIndex); + RankingProperties.Variant variant = experiment.variants().get(selectedKey); + return variant.zsetPrefix(); + } + return DAILY_ZSET_PREFIX; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingProperties.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingProperties.java new file mode 100644 index 0000000000..7dda861f2d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingProperties.java @@ -0,0 +1,22 @@ +package com.loopers.application.ranking; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.Map; + +@ConfigurationProperties(prefix = "ranking") +public record RankingProperties( + Experiment experiment +) { + public RankingProperties { + if (experiment == null) experiment = new Experiment(false, Map.of()); + } + + public record Experiment(boolean enabled, Map variants) { + public Experiment { + if (variants == null) variants = Map.of(); + } + } + + public record Variant(String zsetPrefix) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java new file mode 100644 index 0000000000..982147e1f5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java @@ -0,0 +1,32 @@ +package com.loopers.domain.brand; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "brand") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Brand extends BaseEntity { + + @Column(nullable = false) + private String name; + + private String description; + + public Brand(String name, String description) { + this.name = name; + this.description = description; + } + + public void changeName(String name) { + this.name = name; + } + + public void changeDescription(String description) { + this.description = description; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java new file mode 100644 index 0000000000..18f8dd2c17 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java @@ -0,0 +1,12 @@ +package com.loopers.domain.brand; + +import java.util.List; +import java.util.Optional; +import java.util.Set; + +public interface BrandRepository { + Brand save(Brand brand); + Optional findById(Long id); + List findAll(); + List findAllByIds(Set ids); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/Coupon.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/Coupon.java new file mode 100644 index 0000000000..b290f88ee3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/Coupon.java @@ -0,0 +1,93 @@ +package com.loopers.domain.coupon; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.ZonedDateTime; + +@Entity +@Table(name = "coupon") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Coupon extends BaseEntity { + + @Column(nullable = false) + private String name; + + @Enumerated(EnumType.STRING) + @Column(name = "discount_type", nullable = false) + private DiscountType discountType; + + @Column(name = "discount_value", nullable = false) + private int discountValue; + + @Column(name = "min_order_amount", nullable = false) + private int minOrderAmount; + + @Column(name = "expired_at", nullable = false) + private ZonedDateTime expiredAt; + + @Column(name = "max_issuance_count") + private Integer maxIssuanceCount; + + @Column(name = "issued_count", nullable = false) + private int issuedCount; + + public Coupon(String name, DiscountType discountType, int discountValue, int minOrderAmount, ZonedDateTime expiredAt) { + validateDiscountValue(discountType, discountValue); + this.name = name; + this.discountType = discountType; + this.discountValue = discountValue; + this.minOrderAmount = minOrderAmount; + this.expiredAt = expiredAt; + } + + public int calculateDiscount(int orderPrice) { + if (discountType == DiscountType.FIXED) { + return Math.min(discountValue, orderPrice); + } + return orderPrice * discountValue / 100; + } + + public void validateUsable(int orderPrice, ZonedDateTime now) { + if (now.isAfter(expiredAt)) { + throw new CoreException(ErrorType.BAD_REQUEST, "만료된 쿠폰입니다."); + } + if (orderPrice < minOrderAmount) { + throw new CoreException(ErrorType.BAD_REQUEST, + "최소 주문 금액(" + minOrderAmount + "원) 이상이어야 쿠폰을 사용할 수 있습니다."); + } + } + + public void changeName(String name) { + this.name = name; + } + + public void changeDiscount(DiscountType discountType, int discountValue) { + validateDiscountValue(discountType, discountValue); + this.discountType = discountType; + this.discountValue = discountValue; + } + + public void changeMinOrderAmount(int minOrderAmount) { + this.minOrderAmount = minOrderAmount; + } + + public void changeExpiredAt(ZonedDateTime expiredAt) { + this.expiredAt = expiredAt; + } + + private void validateDiscountValue(DiscountType type, int value) { + if (value <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "할인 값은 0보다 커야 합니다."); + } + if (type == DiscountType.RATE && value > 100) { + throw new CoreException(ErrorType.BAD_REQUEST, "정률 할인은 100%를 초과할 수 없습니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssue.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssue.java new file mode 100644 index 0000000000..0d9ede34a7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssue.java @@ -0,0 +1,87 @@ +package com.loopers.domain.coupon; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.ZonedDateTime; + +@Entity +@Table(name = "coupon_issue", indexes = { + @Index(name = "idx_coupon_issue_member_id", columnList = "member_id"), + @Index(name = "idx_coupon_issue_coupon_id", columnList = "coupon_id") +}, uniqueConstraints = { + @UniqueConstraint(name = "uk_coupon_issue_coupon_member", columnNames = {"coupon_id", "member_id"}) +}) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class CouponIssue { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "coupon_id", nullable = false) + private Long couponId; + + @Column(name = "member_id", nullable = false) + private Long memberId; + + @Column(name = "used_order_id") + private Long usedOrderId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private CouponIssueStatus status; + + @Column(name = "expired_at", nullable = false) + private ZonedDateTime expiredAt; + + @Column(name = "created_at", nullable = false, updatable = false) + private ZonedDateTime createdAt; + + public CouponIssue(Long couponId, Long memberId, ZonedDateTime expiredAt) { + this.couponId = couponId; + this.memberId = memberId; + this.status = CouponIssueStatus.AVAILABLE; + this.expiredAt = expiredAt; + this.createdAt = ZonedDateTime.now(); + } + + public void use(Long orderId, ZonedDateTime now) { + if (this.status != CouponIssueStatus.AVAILABLE) { + throw new CoreException(ErrorType.BAD_REQUEST, "사용할 수 없는 쿠폰입니다."); + } + if (isExpired(now)) { + throw new CoreException(ErrorType.BAD_REQUEST, "만료된 쿠폰입니다."); + } + this.status = CouponIssueStatus.USED; + this.usedOrderId = orderId; + } + + public void linkOrder(Long orderId) { + this.usedOrderId = orderId; + } + + public void cancelUse(ZonedDateTime now) { + if (this.status != CouponIssueStatus.USED) { + throw new CoreException(ErrorType.BAD_REQUEST, "사용된 쿠폰만 복원할 수 있습니다."); + } + this.status = isExpired(now) ? CouponIssueStatus.EXPIRED : CouponIssueStatus.AVAILABLE; + this.usedOrderId = null; + } + + public boolean isExpired(ZonedDateTime now) { + return now.isAfter(expiredAt); + } + + public CouponIssueStatus getEffectiveStatus(ZonedDateTime now) { + if (this.status == CouponIssueStatus.AVAILABLE && isExpired(now)) { + return CouponIssueStatus.EXPIRED; + } + return this.status; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRepository.java new file mode 100644 index 0000000000..feb0492523 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRepository.java @@ -0,0 +1,13 @@ +package com.loopers.domain.coupon; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Optional; + +public interface CouponIssueRepository { + CouponIssue save(CouponIssue couponIssue); + Optional findById(Long id); + int markAsUsed(Long id, ZonedDateTime now); + List findAllByMemberId(Long memberId); + List findAllByCouponId(Long couponId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRequest.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRequest.java new file mode 100644 index 0000000000..04bb55d286 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRequest.java @@ -0,0 +1,51 @@ +package com.loopers.domain.coupon; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.ZonedDateTime; + +@Entity +@Table(name = "coupon_issue_request") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class CouponIssueRequest { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "coupon_id", nullable = false) + private Long couponId; + + @Column(name = "member_id", nullable = false) + private Long memberId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private CouponIssueRequestStatus status; + + @Column(name = "reject_reason") + private String rejectReason; + + @Column(name = "created_at", nullable = false, updatable = false) + private ZonedDateTime createdAt; + + @Column(name = "completed_at") + private ZonedDateTime completedAt; + + @PrePersist + private void prePersist() { + this.createdAt = ZonedDateTime.now(); + } + + public static CouponIssueRequest create(Long couponId, Long memberId) { + CouponIssueRequest request = new CouponIssueRequest(); + request.couponId = couponId; + request.memberId = memberId; + request.status = CouponIssueRequestStatus.PENDING; + return request; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRequestInfo.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRequestInfo.java new file mode 100644 index 0000000000..24304b031b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRequestInfo.java @@ -0,0 +1,9 @@ +package com.loopers.domain.coupon; + +public record CouponIssueRequestInfo( + Long requestId, + Long couponId, + Long memberId, + CouponIssueRequestStatus status, + String rejectReason +) {} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRequestRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRequestRepository.java new file mode 100644 index 0000000000..bf23ee81a6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRequestRepository.java @@ -0,0 +1,8 @@ +package com.loopers.domain.coupon; + +import java.util.Optional; + +public interface CouponIssueRequestRepository { + CouponIssueRequest save(CouponIssueRequest request); + Optional findById(Long id); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRequestStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRequestStatus.java new file mode 100644 index 0000000000..9e7e564804 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRequestStatus.java @@ -0,0 +1,7 @@ +package com.loopers.domain.coupon; + +public enum CouponIssueRequestStatus { + PENDING, + COMPLETED, + REJECTED +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueStatus.java new file mode 100644 index 0000000000..b62a9d0d4e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueStatus.java @@ -0,0 +1,7 @@ +package com.loopers.domain.coupon; + +public enum CouponIssueStatus { + AVAILABLE, + USED, + EXPIRED +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponRepository.java new file mode 100644 index 0000000000..57e5c2f9a2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponRepository.java @@ -0,0 +1,10 @@ +package com.loopers.domain.coupon; + +import java.util.List; +import java.util.Optional; + +public interface CouponRepository { + Coupon save(Coupon coupon); + Optional findById(Long id); + List findAll(); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/DiscountType.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/DiscountType.java new file mode 100644 index 0000000000..64243707f3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/DiscountType.java @@ -0,0 +1,6 @@ +package com.loopers.domain.coupon; + +public enum DiscountType { + FIXED, + RATE +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/event/DomainEventPublisher.java b/apps/commerce-api/src/main/java/com/loopers/domain/event/DomainEventPublisher.java new file mode 100644 index 0000000000..3853f222e5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/event/DomainEventPublisher.java @@ -0,0 +1,5 @@ +package com.loopers.domain.event; + +public interface DomainEventPublisher { + void publish(String aggregateType, String aggregateId, String eventType, Object payload, Object event); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/event/EventOutbox.java b/apps/commerce-api/src/main/java/com/loopers/domain/event/EventOutbox.java new file mode 100644 index 0000000000..e730b82d5c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/event/EventOutbox.java @@ -0,0 +1,49 @@ +package com.loopers.domain.event; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.ZonedDateTime; + +@Entity +@Table(name = "event_outbox") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class EventOutbox { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "aggregate_type", nullable = false, length = 50) + private String aggregateType; + + @Column(name = "aggregate_id", nullable = false, length = 100) + private String aggregateId; + + @Column(name = "event_type", nullable = false, length = 50) + private String eventType; + + @Column(name = "payload", columnDefinition = "TEXT", nullable = false) + private String payload; + + @Column(name = "created_at", nullable = false, updatable = false) + private ZonedDateTime createdAt; + + @PrePersist + private void prePersist() { + this.createdAt = ZonedDateTime.now(); + } + + public static EventOutbox create(String aggregateType, String aggregateId, + String eventType, String payload) { + EventOutbox outbox = new EventOutbox(); + outbox.aggregateType = aggregateType; + outbox.aggregateId = aggregateId; + outbox.eventType = eventType; + outbox.payload = payload; + return outbox; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/event/EventOutboxRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/event/EventOutboxRepository.java new file mode 100644 index 0000000000..8e122173c6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/event/EventOutboxRepository.java @@ -0,0 +1,5 @@ +package com.loopers.domain.event; + +public interface EventOutboxRepository { + EventOutbox save(EventOutbox outbox); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/event/LikeCreatedEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/event/LikeCreatedEvent.java new file mode 100644 index 0000000000..8a79282359 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/event/LikeCreatedEvent.java @@ -0,0 +1,4 @@ +package com.loopers.domain.event; + +public record LikeCreatedEvent(long productId, long memberId) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/event/LikeRemovedEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/event/LikeRemovedEvent.java new file mode 100644 index 0000000000..56d75adef1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/event/LikeRemovedEvent.java @@ -0,0 +1,4 @@ +package com.loopers.domain.event; + +public record LikeRemovedEvent(long productId, long memberId) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/event/OrderCancelledEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/event/OrderCancelledEvent.java new file mode 100644 index 0000000000..84438dd329 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/event/OrderCancelledEvent.java @@ -0,0 +1,6 @@ +package com.loopers.domain.event; + +import java.util.List; + +public record OrderCancelledEvent(long orderId, long memberId, List items) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/event/OrderCreatedEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/event/OrderCreatedEvent.java new file mode 100644 index 0000000000..12e5f7b009 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/event/OrderCreatedEvent.java @@ -0,0 +1,6 @@ +package com.loopers.domain.event; + +import java.util.List; + +public record OrderCreatedEvent(long orderId, long memberId, List items) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/event/OrderItemSnapshot.java b/apps/commerce-api/src/main/java/com/loopers/domain/event/OrderItemSnapshot.java new file mode 100644 index 0000000000..9fc2207f11 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/event/OrderItemSnapshot.java @@ -0,0 +1,4 @@ +package com.loopers.domain.event; + +public record OrderItemSnapshot(long productId, int quantity, int price) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/event/ProductViewedEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/event/ProductViewedEvent.java new file mode 100644 index 0000000000..279670bb72 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/event/ProductViewedEvent.java @@ -0,0 +1,4 @@ +package com.loopers.domain.event; + +public record ProductViewedEvent(long productId, long memberId) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java deleted file mode 100644 index c588c4a8a0..0000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.loopers.domain.example; - -import com.loopers.domain.BaseEntity; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import jakarta.persistence.Entity; -import jakarta.persistence.Table; - -@Entity -@Table(name = "example") -public class ExampleModel extends BaseEntity { - - private String name; - private String description; - - protected ExampleModel() {} - - public ExampleModel(String name, String description) { - if (name == null || name.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "이름은 비어있을 수 없습니다."); - } - if (description == null || description.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "설명은 비어있을 수 없습니다."); - } - - this.name = name; - this.description = description; - } - - public String getName() { - return name; - } - - public String getDescription() { - return description; - } - - public void update(String newDescription) { - if (newDescription == null || newDescription.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "설명은 비어있을 수 없습니다."); - } - this.description = newDescription; - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java deleted file mode 100644 index 3625e56625..0000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.loopers.domain.example; - -import java.util.Optional; - -public interface ExampleRepository { - Optional find(Long id); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java deleted file mode 100644 index c0e8431e88..0000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.loopers.domain.example; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -@RequiredArgsConstructor -@Component -public class ExampleService { - - private final ExampleRepository exampleRepository; - - @Transactional(readOnly = true) - public ExampleModel getExample(Long id) { - return exampleRepository.find(id) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[id = " + id + "] 예시를 찾을 수 없습니다.")); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java new file mode 100644 index 0000000000..007bd862b5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java @@ -0,0 +1,38 @@ +package com.loopers.domain.like; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.ZonedDateTime; + +@Entity +@Table(name = "likes", uniqueConstraints = { + @UniqueConstraint(name = "uk_likes_member_product", columnNames = {"member_id", "product_id"}) +}, indexes = { + @Index(name = "idx_likes_product_id", columnList = "product_id") +}) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Like { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "member_id", nullable = false) + private Long memberId; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "created_at", nullable = false, updatable = false) + private ZonedDateTime createdAt; + + public Like(Long memberId, Long productId) { + this.memberId = memberId; + this.productId = productId; + this.createdAt = ZonedDateTime.now(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java new file mode 100644 index 0000000000..e9349a998a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java @@ -0,0 +1,18 @@ +package com.loopers.domain.like; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +public interface LikeRepository { + Like save(Like like); + void delete(Like like); + Optional findByMemberIdAndProductId(Long memberId, Long productId); + boolean existsByMemberIdAndProductId(Long memberId, Long productId); + List findAllByMemberId(Long memberId); + void deleteAllByProductId(Long productId); + void deleteAllByProductIdIn(Collection productIds); + long countByProductId(Long productId); + Map countByProductIds(Collection productIds); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java new file mode 100644 index 0000000000..1cad276978 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java @@ -0,0 +1,57 @@ +package com.loopers.domain.member; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.member.vo.BirthDate; +import com.loopers.domain.member.vo.Email; +import com.loopers.domain.member.vo.LoginId; +import com.loopers.domain.member.vo.Password; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity +@Table(name = "member") +public class Member extends BaseEntity { + + @Embedded + private LoginId loginId; + + @Embedded + private Password password; + + @Column(nullable = false, length = 50) + private String name; + + @Embedded + private BirthDate birthDate; + + @Embedded + private Email email; + + protected Member() {} + + public Member(LoginId loginId, Password password, String name, + BirthDate birthDate, Email email) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "이름은 필수입니다."); + } + this.loginId = loginId; + this.password = password; + this.name = name; + this.birthDate = birthDate; + this.email = email; + } + + public LoginId getLoginId() { return loginId; } + public Password getPassword() { return password; } + public String getName() { return name; } + public BirthDate getBirthDate() { return birthDate; } + public Email getEmail() { return email; } + + public void changePassword(Password newPassword) { + this.password = newPassword; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java new file mode 100644 index 0000000000..923b7f0dfc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java @@ -0,0 +1,11 @@ +package com.loopers.domain.member; + +import com.loopers.domain.member.vo.LoginId; + +import java.util.Optional; + +public interface MemberRepository { + Member save(Member member); + Optional findByLoginId(LoginId loginId); + boolean existsByLoginId(LoginId loginId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/policy/PasswordPolicy.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/policy/PasswordPolicy.java new file mode 100644 index 0000000000..6f4db2d87a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/policy/PasswordPolicy.java @@ -0,0 +1,45 @@ +package com.loopers.domain.member.policy; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.regex.Pattern; + +public class PasswordPolicy { + + private static final Pattern FORMAT_PATTERN = + Pattern.compile("^[A-Za-z0-9!@#$%^&*()_+=-]{8,16}$"); + + public static void validate(String plain, LocalDate birthDate) { + validateFormat(plain); + validateNotContainsSubstrings(plain, + extractBirthDateStrings(birthDate), + "비밀번호에 생년월일을 포함할 수 없습니다."); + } + + public static void validateFormat(String plain) { + if (plain == null || !FORMAT_PATTERN.matcher(plain).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, + "비밀번호는 8~16자의 영문, 숫자, 특수문자만 허용됩니다."); + } + } + + public static void validateNotContainsSubstrings( + String plain, List forbidden, String errorMessage) { + for (String s : forbidden) { + if (plain.contains(s)) { + throw new CoreException(ErrorType.BAD_REQUEST, errorMessage); + } + } + } + + public static List extractBirthDateStrings(LocalDate birthDate) { + return List.of( + birthDate.format(DateTimeFormatter.ofPattern("yyyyMMdd")), + birthDate.format(DateTimeFormatter.ofPattern("yyMMdd")) + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/BirthDate.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/BirthDate.java new file mode 100644 index 0000000000..cd97259689 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/BirthDate.java @@ -0,0 +1,56 @@ +package com.loopers.domain.member.vo; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.Objects; + +@Embeddable +public class BirthDate { + + private static final DateTimeFormatter FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + @Column(name = "birth_date", nullable = false) + private LocalDate value; + + protected BirthDate() {} + + public BirthDate(LocalDate value) { + if (value == null) { + throw new CoreException(ErrorType.BAD_REQUEST, + "생년월일은 필수입니다."); + } + this.value = value; + } + + public static BirthDate from(String dateString) { + if (dateString == null || dateString.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, + "생년월일은 필수입니다."); + } + try { + return new BirthDate(LocalDate.parse(dateString, FORMATTER)); + } catch (DateTimeParseException e) { + throw new CoreException(ErrorType.BAD_REQUEST, + "생년월일은 yyyy-MM-dd 형식이어야 합니다."); + } + } + + public LocalDate value() { return value; } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof BirthDate birthDate)) return false; + return Objects.equals(value, birthDate.value); + } + + @Override + public int hashCode() { return Objects.hash(value); } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/Email.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/Email.java new file mode 100644 index 0000000000..7562e18a0d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/Email.java @@ -0,0 +1,41 @@ +package com.loopers.domain.member.vo; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; + +import java.util.Objects; +import java.util.regex.Pattern; + +@Embeddable +public class Email { + + private static final Pattern PATTERN = + Pattern.compile("^[\\w-.]+@[\\w-]+(\\.[a-z]{2,})+$"); + + @Column(name = "email", nullable = false, length = 100) + private String value; + + protected Email() {} + + public Email(String value) { + if (value == null || !PATTERN.matcher(value).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, + "올바른 이메일 형식이 아닙니다."); + } + this.value = value; + } + + public String value() { return value; } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Email email)) return false; + return Objects.equals(value, email.value); + } + + @Override + public int hashCode() { return Objects.hash(value); } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/LoginId.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/LoginId.java new file mode 100644 index 0000000000..d003c52034 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/LoginId.java @@ -0,0 +1,40 @@ +package com.loopers.domain.member.vo; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; + +import java.util.Objects; +import java.util.regex.Pattern; + +@Embeddable +public class LoginId { + + private static final Pattern PATTERN = Pattern.compile("^[A-Za-z0-9]{1,10}$"); + + @Column(name = "login_id", nullable = false, unique = true, length = 20) + private String value; + + protected LoginId() {} + + public LoginId(String value) { + if (value == null || !PATTERN.matcher(value).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, + "ID는 영문 및 숫자 10자 이내여야 합니다."); + } + this.value = value; + } + + public String value() { return value; } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof LoginId loginId)) return false; + return Objects.equals(value, loginId.value); + } + + @Override + public int hashCode() { return Objects.hash(value); } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/Password.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/Password.java new file mode 100644 index 0000000000..d44acd588e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/Password.java @@ -0,0 +1,49 @@ +package com.loopers.domain.member.vo; + +import com.loopers.domain.member.policy.PasswordPolicy; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.time.LocalDate; +import java.util.Objects; + +@Embeddable +public class Password { + + @Column(name = "password", nullable = false) + private String encoded; + + protected Password() {} + + public Password(String encoded) { + if (encoded == null || encoded.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 필수입니다."); + } + this.encoded = encoded; + } + + public static Password create(String plain, LocalDate birthDate, + PasswordEncoder encoder) { + PasswordPolicy.validate(plain, birthDate); + return new Password(encoder.encode(plain)); + } + + public boolean matches(String plain, PasswordEncoder encoder) { + return encoder.matches(plain, this.encoded); + } + + public String encoded() { return encoded; } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Password password)) return false; + return Objects.equals(encoded, password.encoded); + } + + @Override + public int hashCode() { return Objects.hash(encoded); } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java new file mode 100644 index 0000000000..e0fc0348f1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java @@ -0,0 +1,103 @@ +package com.loopers.domain.order; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +@Entity +@Table(name = "orders", indexes = { + @Index(name = "idx_orders_member_id", columnList = "member_id"), + @Index(name = "idx_orders_member_created_at", columnList = "member_id, created_at") +}) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Order extends BaseEntity { + + @Column(name = "member_id", nullable = false) + private Long memberId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private OrderStatus status; + + @Column(name = "total_price", nullable = false) + private int totalPrice; + + @Column(name = "original_total_price", nullable = false) + private int originalTotalPrice; + + @Column(name = "discount_amount", nullable = false) + private int discountAmount; + + @Column(name = "coupon_issue_id") + private Long couponIssueId; + + @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) + @JoinColumn(name = "order_id") + private List items = new ArrayList<>(); + + @Version + @Column(name = "version") + private Long version; + + public static Order create(Long memberId, List snapshots) { + return create(memberId, snapshots, null, 0); + } + + public static Order create(Long memberId, List snapshots, + Long couponIssueId, int discountAmount) { + if (snapshots == null || snapshots.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 항목은 1개 이상이어야 합니다."); + } + Order order = new Order(); + order.memberId = memberId; + order.status = OrderStatus.CREATED; + for (ItemSnapshot s : snapshots) { + order.items.add(new OrderItem( + s.productId(), s.productName(), s.productPrice(), s.brandName(), s.quantity() + )); + } + order.originalTotalPrice = order.items.stream().mapToInt(OrderItem::getSubtotal).sum(); + if (discountAmount < 0 || discountAmount > order.originalTotalPrice) { + throw new CoreException(ErrorType.BAD_REQUEST, + "할인 금액이 유효하지 않습니다. (할인: " + discountAmount + ", 주문 금액: " + order.originalTotalPrice + ")"); + } + order.discountAmount = discountAmount; + order.totalPrice = order.originalTotalPrice - discountAmount; + order.couponIssueId = couponIssueId; + return order; + } + + public record ItemSnapshot( + Long productId, String productName, int productPrice, String brandName, int quantity + ) {} + + public List getItems() { + return Collections.unmodifiableList(items); + } + + public void pay() { + if (this.status == OrderStatus.PAID) { + throw new CoreException(ErrorType.BAD_REQUEST, "이미 결제된 주문입니다."); + } + if (this.status == OrderStatus.CANCELLED) { + throw new CoreException(ErrorType.BAD_REQUEST, "취소된 주문은 결제할 수 없습니다."); + } + this.status = OrderStatus.PAID; + } + + public void cancel() { + if (this.status == OrderStatus.CANCELLED) { + throw new CoreException(ErrorType.BAD_REQUEST, "이미 취소된 주문입니다."); + } + this.status = OrderStatus.CANCELLED; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java new file mode 100644 index 0000000000..c45d59d329 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java @@ -0,0 +1,44 @@ +package com.loopers.domain.order; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "order_item") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class OrderItem { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "product_name", nullable = false) + private String productName; + + @Column(name = "product_price", nullable = false) + private int productPrice; + + @Column(name = "brand_name") + private String brandName; + + @Column(nullable = false) + private int quantity; + + OrderItem(Long productId, String productName, int productPrice, String brandName, int quantity) { + this.productId = productId; + this.productName = productName; + this.productPrice = productPrice; + this.brandName = brandName; + this.quantity = quantity; + } + + public int getSubtotal() { + return productPrice * quantity; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java new file mode 100644 index 0000000000..0f054726e0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java @@ -0,0 +1,13 @@ +package com.loopers.domain.order; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Optional; + +public interface OrderRepository { + Order save(Order order); + Optional findById(Long id); + List findAllByMemberId(Long memberId); + List findAllByMemberIdAndCreatedAtBetween(Long memberId, ZonedDateTime startAt, ZonedDateTime endAt); + List findAll(); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java new file mode 100644 index 0000000000..107179124c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java @@ -0,0 +1,7 @@ +package com.loopers.domain.order; + +public enum OrderStatus { + CREATED, + PAID, + CANCELLED +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/CallbackInbox.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/CallbackInbox.java new file mode 100644 index 0000000000..109111c878 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/CallbackInbox.java @@ -0,0 +1,75 @@ +package com.loopers.domain.payment; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.ZonedDateTime; + +/** + * PG 콜백 원본 저장소 (DLQ 역할). + * + *

수신된 콜백을 즉시 저장(RECEIVED)하고, + * 비동기로 처리(PROCESSED/FAILED)한다.

+ * + * @see Callback Inbox DLQ + */ +@Entity +@Table(name = "callback_inbox", indexes = { + @Index(name = "idx_callback_inbox_transaction_key", columnList = "transaction_key"), + @Index(name = "idx_callback_inbox_status", columnList = "status") +}) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class CallbackInbox extends BaseEntity { + + @Column(name = "transaction_key", nullable = false) + private String transactionKey; + + @Column(name = "order_id") + private Long orderId; + + @Column(name = "pg_status", nullable = false) + private String pgStatus; + + @Column(name = "payload", columnDefinition = "TEXT") + private String payload; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private CallbackInboxStatus status; + + @Column(name = "processed_at") + private ZonedDateTime processedAt; + + @Column(name = "retry_count", nullable = false) + private int retryCount; + + @Column(name = "error_message") + private String errorMessage; + + public static CallbackInbox create(String transactionKey, Long orderId, + String pgStatus, String payload) { + CallbackInbox inbox = new CallbackInbox(); + inbox.transactionKey = transactionKey; + inbox.orderId = orderId; + inbox.pgStatus = pgStatus; + inbox.payload = payload; + inbox.status = CallbackInboxStatus.RECEIVED; + inbox.retryCount = 0; + return inbox; + } + + public void markProcessed() { + this.status = CallbackInboxStatus.PROCESSED; + this.processedAt = ZonedDateTime.now(); + } + + public void markFailed(String errorMessage) { + this.status = CallbackInboxStatus.FAILED; + this.errorMessage = errorMessage; + this.retryCount++; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/CallbackInboxRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/CallbackInboxRepository.java new file mode 100644 index 0000000000..40b6d97645 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/CallbackInboxRepository.java @@ -0,0 +1,11 @@ +package com.loopers.domain.payment; + +import java.util.List; +import java.util.Optional; + +public interface CallbackInboxRepository { + CallbackInbox save(CallbackInbox callbackInbox); + Optional findById(Long id); + List findAllByStatus(CallbackInboxStatus status); + List findAllByTransactionKey(String transactionKey); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/CallbackInboxStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/CallbackInboxStatus.java new file mode 100644 index 0000000000..34d5f8a3b9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/CallbackInboxStatus.java @@ -0,0 +1,7 @@ +package com.loopers.domain.payment; + +public enum CallbackInboxStatus { + RECEIVED, + PROCESSED, + FAILED +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentModel.java new file mode 100644 index 0000000000..71c6733414 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentModel.java @@ -0,0 +1,112 @@ +package com.loopers.domain.payment; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +@Entity +@Table(name = "payments", indexes = { + @Index(name = "idx_payments_order_id", columnList = "order_id"), + @Index(name = "idx_payments_transaction_key", columnList = "transaction_key"), + @Index(name = "idx_payments_status", columnList = "status") +}, uniqueConstraints = { + @UniqueConstraint(name = "uk_payments_order_id", columnNames = "order_id") +}) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PaymentModel extends BaseEntity { + + @Column(name = "order_id", nullable = false) + private Long orderId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private PaymentStatus status; + + @Column(name = "amount", nullable = false) + private int amount; + + @Column(name = "card_type") + private String cardType; + + @Column(name = "card_no") + private String cardNo; + + @Column(name = "pg_provider") + private String pgProvider; + + @Column(name = "transaction_key") + private String transactionKey; + + @Column(name = "failure_reason") + private String failureReason; + + @Transient + private final List pendingTransitions = new ArrayList<>(); + + public record StatusTransition(PaymentStatus from, PaymentStatus to, String reason, String detail) {} + + public List getPendingTransitions() { + return Collections.unmodifiableList(pendingTransitions); + } + + public void clearPendingTransitions() { + pendingTransitions.clear(); + } + + public static PaymentModel create(Long orderId, int amount, String cardType, String cardNo) { + PaymentModel payment = new PaymentModel(); + payment.orderId = orderId; + payment.amount = amount; + payment.cardType = cardType; + payment.cardNo = cardNo; + payment.status = PaymentStatus.REQUESTED; + return payment; + } + + public void markPending(String transactionKey, String pgProvider) { + PaymentStatus from = this.status; + validateTransition(PaymentStatus.PENDING); + this.status = PaymentStatus.PENDING; + this.transactionKey = transactionKey; + this.pgProvider = pgProvider; + pendingTransitions.add(new StatusTransition(from, PaymentStatus.PENDING, "PG_RESPONSE", null)); + } + + public void markPaid() { + PaymentStatus from = this.status; + validateTransition(PaymentStatus.PAID); + this.status = PaymentStatus.PAID; + pendingTransitions.add(new StatusTransition(from, PaymentStatus.PAID, "PG_RESPONSE", null)); + } + + public void markFailed(String reason) { + PaymentStatus from = this.status; + validateTransition(PaymentStatus.FAILED); + this.status = PaymentStatus.FAILED; + this.failureReason = reason; + pendingTransitions.add(new StatusTransition(from, PaymentStatus.FAILED, "PG_RESPONSE", reason)); + } + + public void markUnknown() { + PaymentStatus from = this.status; + validateTransition(PaymentStatus.UNKNOWN); + this.status = PaymentStatus.UNKNOWN; + pendingTransitions.add(new StatusTransition(from, PaymentStatus.UNKNOWN, "PG_RESPONSE", null)); + } + + private void validateTransition(PaymentStatus target) { + if (!this.status.canTransitionTo(target)) { + throw new CoreException(ErrorType.BAD_REQUEST, + "결제 상태를 " + this.status + "에서 " + target + "으로 변경할 수 없습니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentOutbox.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentOutbox.java new file mode 100644 index 0000000000..101f65745c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentOutbox.java @@ -0,0 +1,73 @@ +package com.loopers.domain.payment; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.ZonedDateTime; + +/** + * Outbox 패턴 — Payment 생성과 같은 TX에서 저장. + * + *

"PG를 호출해야 한다"는 명시적 의도를 보존. + * TX-1 커밋 후 서버 크래시 → Outbox 폴러가 5초 내 감지하여 재시도.

+ * + * @see Outbox 패턴 + */ +@Entity +@Table(name = "payment_outbox", indexes = { + @Index(name = "idx_payment_outbox_status", columnList = "status"), + @Index(name = "idx_payment_outbox_payment_id", columnList = "payment_id") +}) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PaymentOutbox extends BaseEntity { + + @Column(name = "payment_id", nullable = false) + private Long paymentId; + + @Column(name = "order_id", nullable = false) + private Long orderId; + + @Column(name = "event_type", nullable = false) + private String eventType; + + @Column(name = "payload", columnDefinition = "TEXT", nullable = false) + private String payload; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private PaymentOutboxStatus status; + + @Column(name = "processed_at") + private ZonedDateTime processedAt; + + @Column(name = "retry_count", nullable = false) + private int retryCount; + + public static PaymentOutbox create(Long paymentId, Long orderId, String payload) { + PaymentOutbox outbox = new PaymentOutbox(); + outbox.paymentId = paymentId; + outbox.orderId = orderId; + outbox.eventType = "PAYMENT_REQUEST"; + outbox.payload = payload; + outbox.status = PaymentOutboxStatus.PENDING; + outbox.retryCount = 0; + return outbox; + } + + public void markProcessed() { + this.status = PaymentOutboxStatus.PROCESSED; + this.processedAt = ZonedDateTime.now(); + } + + public void markFailed() { + this.status = PaymentOutboxStatus.FAILED; + } + + public void incrementRetry() { + this.retryCount++; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentOutboxRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentOutboxRepository.java new file mode 100644 index 0000000000..b3dbcf6322 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentOutboxRepository.java @@ -0,0 +1,11 @@ +package com.loopers.domain.payment; + +import java.util.List; +import java.util.Optional; + +public interface PaymentOutboxRepository { + PaymentOutbox save(PaymentOutbox outbox); + Optional findById(Long id); + List findAllByStatus(PaymentOutboxStatus status); + Optional findByPaymentId(Long paymentId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentOutboxStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentOutboxStatus.java new file mode 100644 index 0000000000..9d4af0a48d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentOutboxStatus.java @@ -0,0 +1,7 @@ +package com.loopers.domain.payment; + +public enum PaymentOutboxStatus { + PENDING, + PROCESSED, + FAILED +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRepository.java new file mode 100644 index 0000000000..ee51dd1ae0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRepository.java @@ -0,0 +1,21 @@ +package com.loopers.domain.payment; + +import java.util.List; +import java.util.Optional; + +public interface PaymentRepository { + PaymentModel save(PaymentModel payment); + Optional findById(Long id); + Optional findByOrderId(Long orderId); + Optional findByTransactionKey(String transactionKey); + List findAllByStatus(PaymentStatus status); + + /** + * 조건부 UPDATE — 현재 상태가 허용된 상태 중 하나일 때만 상태를 변경한다. + * Callback/Batch 동시 실행 방지용. + * + * @return 업데이트된 행 수 (0이면 이미 처리된 건) + */ + int updateStatusConditionally(Long paymentId, PaymentStatus newStatus, + List allowedCurrentStatuses); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentStatus.java new file mode 100644 index 0000000000..2d667d8bbf --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentStatus.java @@ -0,0 +1,45 @@ +package com.loopers.domain.payment; + +import java.util.Set; + +/** + * 내부 결제 상태. + * PG 상태(PENDING/SUCCESS/FAILED)와 별개로 우리 시스템의 결제 흐름을 표현한다. + * + *
+ * REQUESTED ──PG 응답 PENDING──→ PENDING ──콜백 SUCCESS──→ PAID
+ *     │                            │
+ *     │                            ├──콜백 FAILED──→ FAILED
+ *     │                            │
+ *     │                            └──콜백 미수신──→ UNKNOWN
+ *     │
+ *     ├──PG 요청 실패──→ FAILED
+ *     │
+ *     └──PG 타임아웃──→ UNKNOWN
+ *
+ * UNKNOWN ──PG 확인 SUCCESS──→ PAID
+ * UNKNOWN ──PG 확인 FAILED──→ FAILED
+ * 
+ */ +public enum PaymentStatus { + + REQUESTED(Set.of("PENDING", "FAILED", "UNKNOWN")), + PENDING(Set.of("PAID", "FAILED", "UNKNOWN")), + PAID(Set.of()), + FAILED(Set.of()), + UNKNOWN(Set.of("PAID", "FAILED")); + + private final Set allowedTransitions; + + PaymentStatus(Set allowedTransitions) { + this.allowedTransitions = allowedTransitions; + } + + public boolean canTransitionTo(PaymentStatus target) { + return allowedTransitions.contains(target.name()); + } + + public boolean isTerminal() { + return allowedTransitions.isEmpty(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentStatusHistory.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentStatusHistory.java new file mode 100644 index 0000000000..c7c023437f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentStatusHistory.java @@ -0,0 +1,52 @@ +package com.loopers.domain.payment; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 결제 상태 전이 감사 로그. + * + *

Event Sourcing의 경량 적용 — 모든 결제 상태 전이를 INSERT-only 로그로 기록한다. + * 5-layer recovery 구조에서 "언제, 어떤 경로로, 왜 상태가 바뀌었는가"를 추적한다.

+ * + * @see PaymentStatus + */ +@Entity +@Table(name = "payment_status_history", indexes = { + @Index(name = "idx_psh_payment_id", columnList = "payment_id") +}) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PaymentStatusHistory extends BaseEntity { + + @Column(name = "payment_id", nullable = false) + private Long paymentId; + + @Enumerated(EnumType.STRING) + @Column(name = "from_status", nullable = false) + private PaymentStatus fromStatus; + + @Enumerated(EnumType.STRING) + @Column(name = "to_status", nullable = false) + private PaymentStatus toStatus; + + @Column(name = "reason", nullable = false, length = 50) + private String reason; + + @Column(name = "detail", length = 500) + private String detail; + + public static PaymentStatusHistory create(Long paymentId, PaymentStatus fromStatus, + PaymentStatus toStatus, String reason, String detail) { + PaymentStatusHistory h = new PaymentStatusHistory(); + h.paymentId = paymentId; + h.fromStatus = fromStatus; + h.toStatus = toStatus; + h.reason = reason; + h.detail = detail; + return h; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentStatusHistoryRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentStatusHistoryRepository.java new file mode 100644 index 0000000000..aef8dae621 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentStatusHistoryRepository.java @@ -0,0 +1,8 @@ +package com.loopers.domain.payment; + +import java.util.List; + +public interface PaymentStatusHistoryRepository { + PaymentStatusHistory save(PaymentStatusHistory history); + List findAllByPaymentId(Long paymentId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/ReconciliationMismatch.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/ReconciliationMismatch.java new file mode 100644 index 0000000000..e1fc4ba536 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/ReconciliationMismatch.java @@ -0,0 +1,70 @@ +package com.loopers.domain.payment; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.ZonedDateTime; + +/** + * 대사(Reconciliation) 불일치 기록. + * + *

복구가 정상 동작하면 불일치 건수는 0이어야 한다. + * 불일치 발견 = 복구 로직에 버그가 있다는 신호.

+ * + * @see 대사 배치 + */ +@Entity +@Table(name = "reconciliation_mismatch", indexes = { + @Index(name = "idx_recon_mismatch_type", columnList = "type"), + @Index(name = "idx_recon_mismatch_payment_id", columnList = "payment_id") +}) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ReconciliationMismatch extends BaseEntity { + + @Column(name = "type", nullable = false) + private String type; + + @Column(name = "payment_id", nullable = false) + private Long paymentId; + + @Column(name = "our_status", nullable = false) + private String ourStatus; + + @Column(name = "external_status") + private String externalStatus; + + @Column(name = "detected_at", nullable = false) + private ZonedDateTime detectedAt; + + @Column(name = "resolved_at") + private ZonedDateTime resolvedAt; + + @Column(name = "resolution") + private String resolution; + + @Column(name = "note", columnDefinition = "TEXT") + private String note; + + public static ReconciliationMismatch create(String type, Long paymentId, + String ourStatus, String externalStatus, + String note) { + ReconciliationMismatch mismatch = new ReconciliationMismatch(); + mismatch.type = type; + mismatch.paymentId = paymentId; + mismatch.ourStatus = ourStatus; + mismatch.externalStatus = externalStatus; + mismatch.detectedAt = ZonedDateTime.now(); + mismatch.note = note; + return mismatch; + } + + public void resolve(String resolution, String note) { + this.resolvedAt = ZonedDateTime.now(); + this.resolution = resolution; + this.note = note; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/ReconciliationMismatchRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/ReconciliationMismatchRepository.java new file mode 100644 index 0000000000..ded3d905a9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/ReconciliationMismatchRepository.java @@ -0,0 +1,11 @@ +package com.loopers.domain.payment; + +import java.util.List; +import java.util.Optional; + +public interface ReconciliationMismatchRepository { + ReconciliationMismatch save(ReconciliationMismatch mismatch); + Optional findById(Long id); + List findAllByType(String type); + List findAllUnresolved(); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java new file mode 100644 index 0000000000..89b25d13be --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -0,0 +1,71 @@ +package com.loopers.domain.product; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.product.vo.Price; +import com.loopers.domain.product.vo.Stock; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "product", indexes = { + @Index(name = "idx_product_brand_id", columnList = "brand_id"), + @Index(name = "idx_product_like_count", columnList = "like_count DESC, id DESC"), + @Index(name = "idx_product_brand_like_count", columnList = "brand_id, like_count DESC, id DESC"), + @Index(name = "idx_product_brand_price", columnList = "brand_id, price ASC, id ASC") +}) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Product extends BaseEntity { + + @Column(name = "brand_id", nullable = false) + private Long brandId; + + @Column(nullable = false) + private String name; + + @Embedded + private Price price; + + @Embedded + private Stock stock; + + @Column(name = "category_id") + private Long categoryId; + + @Column(name = "like_count", nullable = false) + private int likeCount = 0; + + public Product(Long brandId, String name, Price price, Stock stock) { + this.brandId = brandId; + this.name = name; + this.price = price; + this.stock = stock; + } + + public Product(Long brandId, String name, Price price, Stock stock, Long categoryId) { + this(brandId, name, price, stock); + this.categoryId = categoryId; + } + + public void changeName(String name) { + this.name = name; + } + + public void changePrice(Price price) { + this.price = price; + } + + public void changeStock(Stock stock) { + this.stock = stock; + } + + public void decreaseStock(int quantity) { + this.stock = this.stock.decrease(quantity); + } + + public void increaseStock(int quantity) { + this.stock = this.stock.increase(quantity); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java new file mode 100644 index 0000000000..7b75f41066 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -0,0 +1,31 @@ +package com.loopers.domain.product; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Optional; + +public interface ProductRepository { + Product save(Product product); + Optional findById(Long id); + List findAllByIdsWithLock(List ids); + List findAllByIds(List ids); + List findAll(); + List findAllByBrandId(Long brandId); + + // 조회 전용 (Brand JOIN) + List findAllWithBrand(); + List findAllWithBrand(String sort); + List findAllByBrandIdWithBrand(Long brandId); + + // 페이지네이션 조회 (Brand JOIN) + Page findAllWithBrand(String sort, Pageable pageable); + Page findAllByBrandIdWithBrand(Long brandId, String sort, Pageable pageable); + Page findNewProducts(ZonedDateTime since, Pageable pageable); + + // likeCount atomic 증감 (엔티티 로딩 없이 SQL 직접 실행) + int incrementLikeCount(Long productId); + int decrementLikeCount(Long productId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductWithBrand.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductWithBrand.java new file mode 100644 index 0000000000..d2f759e53d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductWithBrand.java @@ -0,0 +1,4 @@ +package com.loopers.domain.product; + +public record ProductWithBrand(Product product, String brandName, long likeCount) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/vo/Price.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/vo/Price.java new file mode 100644 index 0000000000..b537bbee45 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/vo/Price.java @@ -0,0 +1,25 @@ +package com.loopers.domain.product.vo; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Embeddable +@Getter +@EqualsAndHashCode +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Price { + + @Column(name = "price", nullable = false) + private int value; + + public Price(int value) { + if (value < 0) { + throw new IllegalArgumentException("가격은 0 이상이어야 합니다."); + } + this.value = value; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/vo/Stock.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/vo/Stock.java new file mode 100644 index 0000000000..7c52d18be9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/vo/Stock.java @@ -0,0 +1,42 @@ +package com.loopers.domain.product.vo; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Embeddable +@Getter +@EqualsAndHashCode +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Stock { + + @Column(name = "stock_quantity", nullable = false) + private int quantity; + + public Stock(int quantity) { + if (quantity < 0) { + throw new IllegalArgumentException("재고는 0 이상이어야 합니다."); + } + this.quantity = quantity; + } + + public boolean hasEnough(int amount) { + return this.quantity >= amount; + } + + public Stock decrease(int amount) { + if (!hasEnough(amount)) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고가 부족합니다."); + } + return new Stock(this.quantity - amount); + } + + public Stock increase(int amount) { + return new Stock(this.quantity + amount); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRank.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRank.java new file mode 100644 index 0000000000..f1d88ca85b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRank.java @@ -0,0 +1,45 @@ +package com.loopers.domain.ranking; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@MappedSuperclass +public abstract class MvProductRank { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long productId; + + @Column(nullable = false) + private Integer ranking; + + @Column(nullable = false) + private Double score; + + @Column(nullable = false) + private Long viewCount; + + @Column(nullable = false) + private Long likeCount; + + @Column(nullable = false) + private Long salesCount; + + @Column(nullable = false) + private Long salesAmount; + + @Column(nullable = false, length = 8) + private String periodKey; + + @Column(nullable = false) + private LocalDateTime createdAt; +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankMonthly.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankMonthly.java new file mode 100644 index 0000000000..010f78c726 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankMonthly.java @@ -0,0 +1,12 @@ +package com.loopers.domain.ranking; + +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "mv_product_rank_monthly") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MvProductRankMonthly extends MvProductRank { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankRepository.java new file mode 100644 index 0000000000..20a748b667 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankRepository.java @@ -0,0 +1,12 @@ +package com.loopers.domain.ranking; + +import org.springframework.data.domain.Pageable; + +import java.util.List; + +public interface MvProductRankRepository { + + List findByPeriodKeyAndScope(String periodKey, String scope, Pageable pageable); + + long countByPeriodKeyAndScope(String periodKey, String scope); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankWeekly.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankWeekly.java new file mode 100644 index 0000000000..dfa1218e12 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankWeekly.java @@ -0,0 +1,12 @@ +package com.loopers.domain.ranking; + +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "mv_product_rank_weekly") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MvProductRankWeekly extends MvProductRank { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java new file mode 100644 index 0000000000..501b9d8a91 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java @@ -0,0 +1,14 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.brand.Brand; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +public interface BrandJpaRepository extends JpaRepository { + Optional findByIdAndDeletedAtIsNull(Long id); + List findAllByDeletedAtIsNull(); + List findAllByIdInAndDeletedAtIsNull(Collection ids); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java new file mode 100644 index 0000000000..06d37c31a8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java @@ -0,0 +1,37 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.Set; + +@Repository +@RequiredArgsConstructor +public class BrandRepositoryImpl implements BrandRepository { + + private final BrandJpaRepository brandJpaRepository; + + @Override + public Brand save(Brand brand) { + return brandJpaRepository.save(brand); + } + + @Override + public Optional findById(Long id) { + return brandJpaRepository.findByIdAndDeletedAtIsNull(id); + } + + @Override + public List findAll() { + return brandJpaRepository.findAllByDeletedAtIsNull(); + } + + @Override + public List findAllByIds(Set ids) { + return brandJpaRepository.findAllByIdInAndDeletedAtIsNull(ids); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueJpaRepository.java new file mode 100644 index 0000000000..16222bbbb4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueJpaRepository.java @@ -0,0 +1,24 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.CouponIssue; +import com.loopers.domain.coupon.CouponIssueStatus; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.ZonedDateTime; +import java.util.List; + +public interface CouponIssueJpaRepository extends JpaRepository { + + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query("UPDATE CouponIssue ci SET ci.status = :usedStatus" + + " WHERE ci.id = :id AND ci.status = :availableStatus AND ci.expiredAt > :now") + int markAsUsed(@Param("id") Long id, @Param("now") ZonedDateTime now, + @Param("usedStatus") CouponIssueStatus usedStatus, + @Param("availableStatus") CouponIssueStatus availableStatus); + + List findAllByMemberId(Long memberId); + List findAllByCouponId(Long couponId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRepositoryImpl.java new file mode 100644 index 0000000000..a8af0c350f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRepositoryImpl.java @@ -0,0 +1,44 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.CouponIssue; +import com.loopers.domain.coupon.CouponIssueRepository; +import com.loopers.domain.coupon.CouponIssueStatus; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class CouponIssueRepositoryImpl implements CouponIssueRepository { + + private final CouponIssueJpaRepository couponIssueJpaRepository; + + @Override + public CouponIssue save(CouponIssue couponIssue) { + return couponIssueJpaRepository.save(couponIssue); + } + + @Override + public Optional findById(Long id) { + return couponIssueJpaRepository.findById(id); + } + + @Override + public int markAsUsed(Long id, ZonedDateTime now) { + return couponIssueJpaRepository.markAsUsed( + id, now, CouponIssueStatus.USED, CouponIssueStatus.AVAILABLE); + } + + @Override + public List findAllByMemberId(Long memberId) { + return couponIssueJpaRepository.findAllByMemberId(memberId); + } + + @Override + public List findAllByCouponId(Long couponId) { + return couponIssueJpaRepository.findAllByCouponId(couponId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestJpaRepository.java new file mode 100644 index 0000000000..58269ced3d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestJpaRepository.java @@ -0,0 +1,8 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.CouponIssueRequest; +import com.loopers.domain.coupon.CouponIssueRequestRepository; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CouponIssueRequestJpaRepository extends JpaRepository, CouponIssueRequestRepository { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponJpaRepository.java new file mode 100644 index 0000000000..6706267dd0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponJpaRepository.java @@ -0,0 +1,12 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.Coupon; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface CouponJpaRepository extends JpaRepository { + Optional findByIdAndDeletedAtIsNull(Long id); + List findAllByDeletedAtIsNull(); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponRepositoryImpl.java new file mode 100644 index 0000000000..a6c628e1a2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponRepositoryImpl.java @@ -0,0 +1,31 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.Coupon; +import com.loopers.domain.coupon.CouponRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class CouponRepositoryImpl implements CouponRepository { + + private final CouponJpaRepository couponJpaRepository; + + @Override + public Coupon save(Coupon coupon) { + return couponJpaRepository.save(coupon); + } + + @Override + public Optional findById(Long id) { + return couponJpaRepository.findByIdAndDeletedAtIsNull(id); + } + + @Override + public List findAll() { + return couponJpaRepository.findAllByDeletedAtIsNull(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/DomainEventPublisherImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/DomainEventPublisherImpl.java new file mode 100644 index 0000000000..eb261cef6a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/DomainEventPublisherImpl.java @@ -0,0 +1,34 @@ +package com.loopers.infrastructure.event; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.domain.event.DomainEventPublisher; +import com.loopers.domain.event.EventOutbox; +import com.loopers.domain.event.EventOutboxRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class DomainEventPublisherImpl implements DomainEventPublisher { + + private final EventOutboxRepository eventOutboxRepository; + private final ApplicationEventPublisher applicationEventPublisher; + private final ObjectMapper objectMapper; + + @Override + public void publish(String aggregateType, String aggregateId, String eventType, Object payload, Object event) { + String json = serializePayload(payload); + eventOutboxRepository.save(EventOutbox.create(aggregateType, aggregateId, eventType, json)); + applicationEventPublisher.publishEvent(event); + } + + private String serializePayload(Object payload) { + try { + return objectMapper.writeValueAsString(payload); + } catch (JsonProcessingException e) { + throw new RuntimeException("이벤트 페이로드 직렬화 실패", e); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/EventOutboxJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/EventOutboxJpaRepository.java new file mode 100644 index 0000000000..513a1ae3aa --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/EventOutboxJpaRepository.java @@ -0,0 +1,8 @@ +package com.loopers.infrastructure.event; + +import com.loopers.domain.event.EventOutbox; +import com.loopers.domain.event.EventOutboxRepository; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface EventOutboxJpaRepository extends JpaRepository, EventOutboxRepository { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java deleted file mode 100644 index ce6d3ead07..0000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.loopers.infrastructure.example; - -import com.loopers.domain.example.ExampleModel; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface ExampleJpaRepository extends JpaRepository {} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java deleted file mode 100644 index 37f2272f07..0000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.loopers.infrastructure.example; - -import com.loopers.domain.example.ExampleModel; -import com.loopers.domain.example.ExampleRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -import java.util.Optional; - -@RequiredArgsConstructor -@Component -public class ExampleRepositoryImpl implements ExampleRepository { - private final ExampleJpaRepository exampleJpaRepository; - - @Override - public Optional find(Long id) { - return exampleJpaRepository.findById(id); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/AsyncConfig.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/AsyncConfig.java new file mode 100644 index 0000000000..6b61b126da --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/AsyncConfig.java @@ -0,0 +1,26 @@ +package com.loopers.infrastructure.kafka; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; +import java.util.concurrent.ThreadPoolExecutor; + +@EnableAsync +@Configuration +public class AsyncConfig { + + @Bean("eventExecutor") + public Executor eventExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(2); + executor.setMaxPoolSize(4); + executor.setQueueCapacity(100); + executor.setThreadNamePrefix("event-async-"); + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + executor.initialize(); + return executor; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/KafkaTopicConfig.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/KafkaTopicConfig.java new file mode 100644 index 0000000000..861934ff00 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/KafkaTopicConfig.java @@ -0,0 +1,55 @@ +package com.loopers.infrastructure.kafka; + +import org.apache.kafka.clients.admin.NewTopic; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.config.TopicBuilder; + +@Configuration +public class KafkaTopicConfig { + + /** + * 선착순 쿠폰 발급 요청 토픽. + * + *

파티션 수: 3 — partitionKey=couponId, 같은 쿠폰의 요청이 같은 파티션으로 라우팅되어 순서 보장. + * Consumer concurrency=1 (SINGLE_LISTENER). 스케일아웃 시 concurrency를 파티션 수(3)까지 증가 가능. + * 파티션 수 < Consumer 수 → 유휴 Consumer 발생하므로, Consumer 수는 파티션 수 이하로 유지.

+ */ + @Bean + public NewTopic couponIssueRequestsTopic() { + return TopicBuilder.name("coupon-issue-requests") + .partitions(3) + .replicas(1) + .build(); + } + + /** + * 카탈로그 이벤트 토픽 (좋아요, 조회수). + * + *

파티션 수: 3 — partitionKey=productId, 같은 상품의 이벤트가 같은 파티션으로 라우팅. + * Consumer concurrency=3 (BATCH_LISTENER), 파티션 수와 concurrency 1:1 매칭. + * 스케일아웃 시 파티션 수와 concurrency를 함께 증가시켜야 처리량이 선형 증가.

+ */ + @Bean + public NewTopic catalogEventsTopic() { + return TopicBuilder.name("catalog-events") + .partitions(3) + .replicas(1) + .build(); + } + + /** + * 주문 이벤트 토픽 (주문 생성, 주문 취소). + * + *

파티션 수: 3 — partitionKey=orderId, 같은 주문의 이벤트가 같은 파티션으로 라우팅되어 순서 보장. + * Consumer concurrency=3 (BATCH_LISTENER), 파티션 수와 concurrency 1:1 매칭. + * 스케일아웃 시 파티션 수와 concurrency를 함께 증가시켜야 처리량이 선형 증가.

+ */ + @Bean + public NewTopic orderEventsTopic() { + return TopicBuilder.name("order-events") + .partitions(3) + .replicas(1) + .build(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java new file mode 100644 index 0000000000..8d8247ab17 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java @@ -0,0 +1,27 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.Like; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +public interface LikeJpaRepository extends JpaRepository { + Optional findByMemberIdAndProductId(Long memberId, Long productId); + boolean existsByMemberIdAndProductId(Long memberId, Long productId); + List findAllByMemberId(Long memberId); + void deleteAllByProductId(Long productId); + + @Modifying + @Query("DELETE FROM Like l WHERE l.productId IN :productIds") + void deleteAllByProductIdIn(@Param("productIds") Collection productIds); + + long countByProductId(Long productId); + + @Query("SELECT l.productId, COUNT(l) FROM Like l WHERE l.productId IN :productIds GROUP BY l.productId") + List countByProductIdIn(@Param("productIds") Collection productIds); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java new file mode 100644 index 0000000000..e699967a16 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java @@ -0,0 +1,71 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +@Repository +@RequiredArgsConstructor +public class LikeRepositoryImpl implements LikeRepository { + + private final LikeJpaRepository likeJpaRepository; + + @Override + public Like save(Like like) { + return likeJpaRepository.save(like); + } + + @Override + public void delete(Like like) { + likeJpaRepository.delete(like); + } + + @Override + public Optional findByMemberIdAndProductId(Long memberId, Long productId) { + return likeJpaRepository.findByMemberIdAndProductId(memberId, productId); + } + + @Override + public boolean existsByMemberIdAndProductId(Long memberId, Long productId) { + return likeJpaRepository.existsByMemberIdAndProductId(memberId, productId); + } + + @Override + public List findAllByMemberId(Long memberId) { + return likeJpaRepository.findAllByMemberId(memberId); + } + + @Override + public void deleteAllByProductId(Long productId) { + likeJpaRepository.deleteAllByProductId(productId); + } + + @Override + public void deleteAllByProductIdIn(Collection productIds) { + if (productIds.isEmpty()) return; + likeJpaRepository.deleteAllByProductIdIn(productIds); + } + + @Override + public long countByProductId(Long productId) { + return likeJpaRepository.countByProductId(productId); + } + + @Override + public Map countByProductIds(Collection productIds) { + if (productIds.isEmpty()) return Collections.emptyMap(); + return likeJpaRepository.countByProductIdIn(productIds).stream() + .collect(Collectors.toMap( + row -> (Long) row[0], + row -> (Long) row[1] + )); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java new file mode 100644 index 0000000000..edaadac008 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.member; + +import com.loopers.domain.member.Member; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface MemberJpaRepository extends JpaRepository { + Optional findByLoginIdValue(String loginId); + boolean existsByLoginIdValue(String loginId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java new file mode 100644 index 0000000000..a8be0aeaaa --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java @@ -0,0 +1,31 @@ +package com.loopers.infrastructure.member; + +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberRepository; +import com.loopers.domain.member.vo.LoginId; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@RequiredArgsConstructor +@Repository +public class MemberRepositoryImpl implements MemberRepository { + + private final MemberJpaRepository memberJpaRepository; + + @Override + public Member save(Member member) { + return memberJpaRepository.save(member); + } + + @Override + public Optional findByLoginId(LoginId loginId) { + return memberJpaRepository.findByLoginIdValue(loginId.value()); + } + + @Override + public boolean existsByLoginId(LoginId loginId) { + return memberJpaRepository.existsByLoginIdValue(loginId.value()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java new file mode 100644 index 0000000000..2f4a36d4cc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java @@ -0,0 +1,16 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.Order; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Optional; + +public interface OrderJpaRepository extends JpaRepository { + Optional findByIdAndDeletedAtIsNull(Long id); + List findAllByMemberIdAndDeletedAtIsNull(Long memberId); + List findAllByMemberIdAndCreatedAtBetweenAndDeletedAtIsNull( + Long memberId, ZonedDateTime startAt, ZonedDateTime endAt); + List findAllByDeletedAtIsNull(); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java new file mode 100644 index 0000000000..5fd7b14551 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java @@ -0,0 +1,42 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class OrderRepositoryImpl implements OrderRepository { + + private final OrderJpaRepository orderJpaRepository; + + @Override + public Order save(Order order) { + return orderJpaRepository.save(order); + } + + @Override + public Optional findById(Long id) { + return orderJpaRepository.findByIdAndDeletedAtIsNull(id); + } + + @Override + public List findAllByMemberId(Long memberId) { + return orderJpaRepository.findAllByMemberIdAndDeletedAtIsNull(memberId); + } + + @Override + public List findAllByMemberIdAndCreatedAtBetween(Long memberId, ZonedDateTime startAt, ZonedDateTime endAt) { + return orderJpaRepository.findAllByMemberIdAndCreatedAtBetweenAndDeletedAtIsNull(memberId, startAt, endAt); + } + + @Override + public List findAll() { + return orderJpaRepository.findAllByDeletedAtIsNull(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/CallbackInboxJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/CallbackInboxJpaRepository.java new file mode 100644 index 0000000000..05d497c14f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/CallbackInboxJpaRepository.java @@ -0,0 +1,14 @@ +package com.loopers.infrastructure.payment; + +import com.loopers.domain.payment.CallbackInbox; +import com.loopers.domain.payment.CallbackInboxStatus; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface CallbackInboxJpaRepository extends JpaRepository { + + List findAllByStatusAndDeletedAtIsNull(CallbackInboxStatus status); + + List findAllByTransactionKeyAndDeletedAtIsNull(String transactionKey); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/CallbackInboxRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/CallbackInboxRepositoryImpl.java new file mode 100644 index 0000000000..c11d7f4d11 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/CallbackInboxRepositoryImpl.java @@ -0,0 +1,37 @@ +package com.loopers.infrastructure.payment; + +import com.loopers.domain.payment.CallbackInbox; +import com.loopers.domain.payment.CallbackInboxRepository; +import com.loopers.domain.payment.CallbackInboxStatus; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class CallbackInboxRepositoryImpl implements CallbackInboxRepository { + + private final CallbackInboxJpaRepository callbackInboxJpaRepository; + + @Override + public CallbackInbox save(CallbackInbox callbackInbox) { + return callbackInboxJpaRepository.save(callbackInbox); + } + + @Override + public Optional findById(Long id) { + return callbackInboxJpaRepository.findById(id); + } + + @Override + public List findAllByStatus(CallbackInboxStatus status) { + return callbackInboxJpaRepository.findAllByStatusAndDeletedAtIsNull(status); + } + + @Override + public List findAllByTransactionKey(String transactionKey) { + return callbackInboxJpaRepository.findAllByTransactionKeyAndDeletedAtIsNull(transactionKey); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentJpaRepository.java new file mode 100644 index 0000000000..72e44da4f5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentJpaRepository.java @@ -0,0 +1,29 @@ +package com.loopers.infrastructure.payment; + +import com.loopers.domain.payment.PaymentModel; +import com.loopers.domain.payment.PaymentStatus; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface PaymentJpaRepository extends JpaRepository { + + Optional findByIdAndDeletedAtIsNull(Long id); + + Optional findByOrderIdAndDeletedAtIsNull(Long orderId); + + Optional findByTransactionKeyAndDeletedAtIsNull(String transactionKey); + + List findAllByStatusAndDeletedAtIsNull(PaymentStatus status); + + @Modifying + @Query("UPDATE PaymentModel p SET p.status = :newStatus " + + "WHERE p.id = :paymentId AND p.status IN :allowedStatuses AND p.deletedAt IS NULL") + int updateStatusConditionally(@Param("paymentId") Long paymentId, + @Param("newStatus") PaymentStatus newStatus, + @Param("allowedStatuses") List allowedStatuses); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentOutboxJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentOutboxJpaRepository.java new file mode 100644 index 0000000000..60e5f57871 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentOutboxJpaRepository.java @@ -0,0 +1,15 @@ +package com.loopers.infrastructure.payment; + +import com.loopers.domain.payment.PaymentOutbox; +import com.loopers.domain.payment.PaymentOutboxStatus; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface PaymentOutboxJpaRepository extends JpaRepository { + + List findAllByStatusAndDeletedAtIsNull(PaymentOutboxStatus status); + + Optional findByPaymentIdAndDeletedAtIsNull(Long paymentId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentOutboxRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentOutboxRepositoryImpl.java new file mode 100644 index 0000000000..b1fcb19a17 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentOutboxRepositoryImpl.java @@ -0,0 +1,37 @@ +package com.loopers.infrastructure.payment; + +import com.loopers.domain.payment.PaymentOutbox; +import com.loopers.domain.payment.PaymentOutboxRepository; +import com.loopers.domain.payment.PaymentOutboxStatus; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class PaymentOutboxRepositoryImpl implements PaymentOutboxRepository { + + private final PaymentOutboxJpaRepository paymentOutboxJpaRepository; + + @Override + public PaymentOutbox save(PaymentOutbox outbox) { + return paymentOutboxJpaRepository.save(outbox); + } + + @Override + public Optional findById(Long id) { + return paymentOutboxJpaRepository.findById(id); + } + + @Override + public List findAllByStatus(PaymentOutboxStatus status) { + return paymentOutboxJpaRepository.findAllByStatusAndDeletedAtIsNull(status); + } + + @Override + public Optional findByPaymentId(Long paymentId) { + return paymentOutboxJpaRepository.findByPaymentIdAndDeletedAtIsNull(paymentId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java new file mode 100644 index 0000000000..b043629e4d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java @@ -0,0 +1,56 @@ +package com.loopers.infrastructure.payment; + +import com.loopers.domain.payment.PaymentModel; +import com.loopers.domain.payment.PaymentRepository; +import com.loopers.domain.payment.PaymentStatus; +import com.loopers.domain.payment.PaymentStatusHistory; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class PaymentRepositoryImpl implements PaymentRepository { + + private final PaymentJpaRepository paymentJpaRepository; + private final PaymentStatusHistoryJpaRepository historyJpaRepository; + + @Override + public PaymentModel save(PaymentModel payment) { + PaymentModel saved = paymentJpaRepository.save(payment); + for (PaymentModel.StatusTransition t : payment.getPendingTransitions()) { + historyJpaRepository.save(PaymentStatusHistory.create( + saved.getId(), t.from(), t.to(), t.reason(), t.detail())); + } + payment.clearPendingTransitions(); + return saved; + } + + @Override + public Optional findById(Long id) { + return paymentJpaRepository.findByIdAndDeletedAtIsNull(id); + } + + @Override + public Optional findByOrderId(Long orderId) { + return paymentJpaRepository.findByOrderIdAndDeletedAtIsNull(orderId); + } + + @Override + public Optional findByTransactionKey(String transactionKey) { + return paymentJpaRepository.findByTransactionKeyAndDeletedAtIsNull(transactionKey); + } + + @Override + public List findAllByStatus(PaymentStatus status) { + return paymentJpaRepository.findAllByStatusAndDeletedAtIsNull(status); + } + + @Override + public int updateStatusConditionally(Long paymentId, PaymentStatus newStatus, + List allowedCurrentStatuses) { + return paymentJpaRepository.updateStatusConditionally(paymentId, newStatus, allowedCurrentStatuses); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentStatusHistoryJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentStatusHistoryJpaRepository.java new file mode 100644 index 0000000000..63ebf04921 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentStatusHistoryJpaRepository.java @@ -0,0 +1,10 @@ +package com.loopers.infrastructure.payment; + +import com.loopers.domain.payment.PaymentStatusHistory; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface PaymentStatusHistoryJpaRepository extends JpaRepository { + List findAllByPaymentIdOrderByCreatedAtAsc(Long paymentId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentStatusHistoryRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentStatusHistoryRepositoryImpl.java new file mode 100644 index 0000000000..7aa1281793 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentStatusHistoryRepositoryImpl.java @@ -0,0 +1,25 @@ +package com.loopers.infrastructure.payment; + +import com.loopers.domain.payment.PaymentStatusHistory; +import com.loopers.domain.payment.PaymentStatusHistoryRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class PaymentStatusHistoryRepositoryImpl implements PaymentStatusHistoryRepository { + + private final PaymentStatusHistoryJpaRepository jpaRepository; + + @Override + public PaymentStatusHistory save(PaymentStatusHistory history) { + return jpaRepository.save(history); + } + + @Override + public List findAllByPaymentId(Long paymentId) { + return jpaRepository.findAllByPaymentIdOrderByCreatedAtAsc(paymentId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentWalWriter.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentWalWriter.java new file mode 100644 index 0000000000..7d73616bad --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentWalWriter.java @@ -0,0 +1,109 @@ +package com.loopers.infrastructure.payment; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +/** + * Local WAL (Write-Ahead Log) — PG 응답을 로컬 파일에 먼저 기록. + * + *

PG 결제 성공 → 내부 DB 저장 실패 시에도 PG 응답을 보존. + * WalRecoveryScheduler가 주기적으로 WAL 파일을 스캔하여 DB에 반영 재시도.

+ * + * @see Local WAL + */ +@Slf4j +@Component +public class PaymentWalWriter { + + private final Path walDirectory; + private final ObjectMapper objectMapper; + + public PaymentWalWriter( + @Value("${payment.wal.directory:./wal/payments}") String walDirectoryPath, + ObjectMapper objectMapper + ) { + this.walDirectory = Paths.get(walDirectoryPath); + this.objectMapper = objectMapper; + ensureDirectoryExists(); + } + + /** + * PG 응답을 WAL 파일에 기록한다. + */ + public void write(Long orderId, String transactionKey, String pgStatus) { + try { + Map walEntry = Map.of( + "orderId", orderId, + "transactionKey", transactionKey, + "pgStatus", pgStatus, + "timestamp", System.currentTimeMillis() + ); + String content = objectMapper.writeValueAsString(walEntry); + Path walFile = walDirectory.resolve("wal-" + orderId + "-" + transactionKey + ".json"); + Files.writeString(walFile, content, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); + log.debug("WAL 기록: orderId={}, transactionKey={}", orderId, transactionKey); + } catch (IOException e) { + log.error("WAL 기록 실패: orderId={}, error={}", orderId, e.getMessage()); + } + } + + /** + * WAL 파일 삭제 (DB 반영 성공 후). + */ + public void delete(Path walFile) { + try { + Files.deleteIfExists(walFile); + log.debug("WAL 삭제: {}", walFile.getFileName()); + } catch (IOException e) { + log.warn("WAL 삭제 실패: {}, error={}", walFile.getFileName(), e.getMessage()); + } + } + + /** + * 미처리 WAL 파일 목록 조회. + */ + public List listWalFiles() { + try (Stream paths = Files.list(walDirectory)) { + return paths + .filter(p -> p.toString().endsWith(".json")) + .toList(); + } catch (IOException e) { + log.warn("WAL 디렉토리 조회 실패: error={}", e.getMessage()); + return Collections.emptyList(); + } + } + + /** + * WAL 파일 내용 읽기. + */ + @SuppressWarnings("unchecked") + public Map read(Path walFile) { + try { + String content = Files.readString(walFile); + return objectMapper.readValue(content, Map.class); + } catch (IOException e) { + log.warn("WAL 파일 읽기 실패: {}, error={}", walFile.getFileName(), e.getMessage()); + return Collections.emptyMap(); + } + } + + private void ensureDirectoryExists() { + try { + Files.createDirectories(walDirectory); + } catch (IOException e) { + log.error("WAL 디렉토리 생성 실패: {}", walDirectory, e); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/ReconciliationMismatchJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/ReconciliationMismatchJpaRepository.java new file mode 100644 index 0000000000..afc2a9d92a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/ReconciliationMismatchJpaRepository.java @@ -0,0 +1,13 @@ +package com.loopers.infrastructure.payment; + +import com.loopers.domain.payment.ReconciliationMismatch; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface ReconciliationMismatchJpaRepository extends JpaRepository { + + List findAllByTypeAndDeletedAtIsNull(String type); + + List findAllByResolvedAtIsNullAndDeletedAtIsNull(); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/ReconciliationMismatchRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/ReconciliationMismatchRepositoryImpl.java new file mode 100644 index 0000000000..f62dfe8426 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/ReconciliationMismatchRepositoryImpl.java @@ -0,0 +1,36 @@ +package com.loopers.infrastructure.payment; + +import com.loopers.domain.payment.ReconciliationMismatch; +import com.loopers.domain.payment.ReconciliationMismatchRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class ReconciliationMismatchRepositoryImpl implements ReconciliationMismatchRepository { + + private final ReconciliationMismatchJpaRepository reconciliationMismatchJpaRepository; + + @Override + public ReconciliationMismatch save(ReconciliationMismatch mismatch) { + return reconciliationMismatchJpaRepository.save(mismatch); + } + + @Override + public Optional findById(Long id) { + return reconciliationMismatchJpaRepository.findById(id); + } + + @Override + public List findAllByType(String type) { + return reconciliationMismatchJpaRepository.findAllByTypeAndDeletedAtIsNull(type); + } + + @Override + public List findAllUnresolved() { + return reconciliationMismatchJpaRepository.findAllByResolvedAtIsNullAndDeletedAtIsNull(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/PgCallbackPayload.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/PgCallbackPayload.java new file mode 100644 index 0000000000..45488ef1ec --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/PgCallbackPayload.java @@ -0,0 +1,12 @@ +package com.loopers.infrastructure.pg; + +/** + * PG 콜백 수신 DTO. + * PG 비동기 처리 완료 후 POST callback으로 전달되는 결과. + */ +public record PgCallbackPayload( + String transactionKey, + String orderId, + String status, + String reason +) {} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/PgClient.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/PgClient.java new file mode 100644 index 0000000000..d4b9b0698f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/PgClient.java @@ -0,0 +1,28 @@ +package com.loopers.infrastructure.pg; + +/** + * PG 추상화 인터페이스 (Strategy Pattern). + * Simulator, Toss Sandbox 등 PG별 구현체가 이 인터페이스를 구현한다. + */ +public interface PgClient { + + /** + * 결제 요청. PG 시뮬레이터는 PENDING을, Toss Sandbox는 즉시 결과를 반환한다. + */ + PgPaymentResponse requestPayment(PgPaymentRequest request); + + /** + * transactionKey 기반 결제 상태 확인. + */ + PgPaymentStatusResponse getPaymentStatus(String transactionKey); + + /** + * orderId 기반 결제 상태 확인. + */ + PgPaymentStatusResponse getPaymentByOrderId(String orderId); + + /** + * PG 제공사 이름 (SIMULATOR, TOSS 등). + */ + String getProviderName(); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/PgConfig.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/PgConfig.java new file mode 100644 index 0000000000..63d97c9d0a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/PgConfig.java @@ -0,0 +1,15 @@ +package com.loopers.infrastructure.pg; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.List; + +@Configuration +public class PgConfig { + + @Bean + public PgRouter pgRouter(List pgClients) { + return new PgRouter(pgClients); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/PgHealthChecker.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/PgHealthChecker.java new file mode 100644 index 0000000000..5084a3cd98 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/PgHealthChecker.java @@ -0,0 +1,43 @@ +package com.loopers.infrastructure.pg; + +import com.loopers.infrastructure.pg.simulator.SimulatorFeignClient; +import feign.FeignException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * PG Health Check Probe. + * + *

CB Open 상태에서 PG 생존 여부를 경량 GET 요청으로 확인한다. + * 실제 결제 요청(POST)은 돈이 걸린 작업이므로 테스트용으로 쓰면 안 된다.

+ * + *

200이든 404든 "응답이 왔다" = PG가 살아있다는 증거. + * 500 에러나 타임아웃이면 아직 장애.

+ * + * @see Health Check Probe + * @see Half-Open 전략 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class PgHealthChecker { + + private final SimulatorFeignClient simulatorClient; + + /** + * PG Simulator 서버가 살아있는지 확인. + * 존재하지 않는 orderId로 조회 → 200/404 응답이면 서버 정상. + */ + public boolean isSimulatorHealthy() { + try { + simulatorClient.getPaymentByOrderId("HEALTH_CHECK"); + return true; // 200 — 서버 정상 + } catch (FeignException.NotFound e) { + return true; // 404 — 서버 살아있음, 데이터만 없음 + } catch (Exception e) { + log.debug("PG Simulator Health Check 실패: {}", e.getMessage()); + return false; // 타임아웃/500/연결 실패 — 서버 장애 + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/PgPaymentRequest.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/PgPaymentRequest.java new file mode 100644 index 0000000000..9870754fb7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/PgPaymentRequest.java @@ -0,0 +1,20 @@ +package com.loopers.infrastructure.pg; + +/** + * PG에 전달하는 결제 요청 DTO. + * PG 시뮬레이터 API 스펙: POST /api/v1/payments + */ +public record PgPaymentRequest( + String orderId, + String cardType, + String cardNo, + int amount, + String callbackUrl +) { + public static PgPaymentRequest of(Long orderId, String cardType, String cardNo, + int amount, String callbackUrl) { + return new PgPaymentRequest( + String.valueOf(orderId), cardType, cardNo, amount, callbackUrl + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/PgPaymentResponse.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/PgPaymentResponse.java new file mode 100644 index 0000000000..7bb7c04230 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/PgPaymentResponse.java @@ -0,0 +1,21 @@ +package com.loopers.infrastructure.pg; + +/** + * PG 결제 요청에 대한 응답 DTO. + * PG 시뮬레이터: status=PENDING + transactionKey 반환. + * Toss Sandbox: status=SUCCESS/FAILED 즉시 반환. + * + *

pgProvider는 PgRouter에서 주입한다 (PG Feign 응답에는 없음).

+ */ +public record PgPaymentResponse( + String status, + String transactionKey, + String pgProvider +) { + /** + * pgProvider 없이 생성 (Feign 역직렬화, 테스트용). + */ + public PgPaymentResponse(String status, String transactionKey) { + this(status, transactionKey, null); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/PgPaymentStatusResponse.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/PgPaymentStatusResponse.java new file mode 100644 index 0000000000..b6de24c0ca --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/PgPaymentStatusResponse.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.pg; + +/** + * PG 결제 상태 확인 응답 DTO. + * GET /api/v1/payments/{transactionKey} 또는 GET /api/v1/payments?orderId={orderId} + */ +public record PgPaymentStatusResponse( + String status, + String transactionKey, + String reason +) {} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/PgRouter.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/PgRouter.java new file mode 100644 index 0000000000..5719855bae --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/PgRouter.java @@ -0,0 +1,111 @@ +package com.loopers.infrastructure.pg; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.extern.slf4j.Slf4j; + +import java.net.SocketTimeoutException; +import java.util.List; + +/** + * PG 라우터 — Primary PG 실패 시 Fallback PG로 전환하는 Strategy 기반 라우팅. + * + *

타임아웃 규칙 (05 §8.3):

+ *
    + *
  • SocketTimeoutException → Fallback 전환하지 않음 (PG가 요청을 수신했을 수 있음 → 중복 결제 방지)
  • + *
  • ConnectException, 500, CB Open → Fallback 전환 (PG에 도달하지 않음 = 안전)
  • + *
+ */ +@Slf4j +public class PgRouter { + + private final List pgClients; + + public PgRouter(List pgClients) { + if (pgClients == null || pgClients.isEmpty()) { + throw new IllegalArgumentException("PG 클라이언트가 최소 1개 이상 필요합니다."); + } + this.pgClients = pgClients; + } + + /** + * Primary PG로 결제 요청을 시도하고, 실패 시 Fallback PG로 전환한다. + * + *

응답에 pgProvider를 주입하여 어떤 PG가 처리했는지 추적한다.

+ * + * @throws CoreException 타임아웃 시 (Fallback 전환 안 함) 또는 모든 PG 실패 시 + */ + public PgPaymentResponse requestPayment(PgPaymentRequest request) { + Exception lastException = null; + + for (PgClient pgClient : pgClients) { + try { + PgPaymentResponse response = pgClient.requestPayment(request); + log.info("PG 결제 요청 성공: provider={}, transactionKey={}", + pgClient.getProviderName(), response.transactionKey()); + return new PgPaymentResponse( + response.status(), response.transactionKey(), pgClient.getProviderName()); + } catch (Exception e) { + // 타임아웃 → Fallback 전환하지 않음 (중복 결제 방지) + if (isTimeoutException(e)) { + log.warn("PG 타임아웃 — Fallback 전환 안 함: provider={}, error={}", + pgClient.getProviderName(), e.getMessage()); + throw new CoreException(ErrorType.INTERNAL_ERROR, + "PG 타임아웃: " + pgClient.getProviderName() + + " (Fallback 전환 불가 — 중복 결제 방지)"); + } + + lastException = e; + log.warn("PG 결제 요청 실패 — 다음 PG 시도: provider={}, error={}", + pgClient.getProviderName(), e.getMessage()); + } + } + + throw new CoreException(ErrorType.INTERNAL_ERROR, + "모든 PG 결제 요청이 실패했습니다. lastError=" + + (lastException != null ? lastException.getMessage() : "unknown")); + } + + /** + * 결제 상태 조회 — transactionKey가 속한 PG에서만 조회한다. + */ + public PgPaymentStatusResponse getPaymentStatus(String transactionKey, String pgProvider) { + PgClient pgClient = findByProvider(pgProvider); + return pgClient.getPaymentStatus(transactionKey); + } + + /** + * orderId 기반 결제 상태 조회 — 지정된 PG에서 조회한다. + */ + public PgPaymentStatusResponse getPaymentByOrderId(String orderId, String pgProvider) { + PgClient pgClient = findByProvider(pgProvider); + return pgClient.getPaymentByOrderId(orderId); + } + + public PgClient getPrimaryClient() { + return pgClients.get(0); + } + + /** + * 타임아웃 예외 판별. + * Feign은 SocketTimeoutException을 RetryableException으로 감싸므로 cause 체인을 탐색한다. + */ + private boolean isTimeoutException(Exception e) { + Throwable cause = e; + while (cause != null) { + if (cause instanceof SocketTimeoutException) { + return true; + } + cause = cause.getCause(); + } + return false; + } + + private PgClient findByProvider(String pgProvider) { + return pgClients.stream() + .filter(c -> c.getProviderName().equals(pgProvider)) + .findFirst() + .orElseThrow(() -> new CoreException(ErrorType.INTERNAL_ERROR, + "PG 제공자를 찾을 수 없습니다: " + pgProvider)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/simulator/SimulatorFeignClient.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/simulator/SimulatorFeignClient.java new file mode 100644 index 0000000000..f6fb96794c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/simulator/SimulatorFeignClient.java @@ -0,0 +1,28 @@ +package com.loopers.infrastructure.pg.simulator; + +import com.loopers.infrastructure.pg.PgPaymentRequest; +import com.loopers.infrastructure.pg.PgPaymentResponse; +import com.loopers.infrastructure.pg.PgPaymentStatusResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; + +@FeignClient( + name = "pg-simulator", + url = "${pg.simulator.url}", + configuration = SimulatorFeignConfig.class +) +public interface SimulatorFeignClient { + + @PostMapping("/api/v1/payments") + PgPaymentResponse requestPayment(@RequestBody PgPaymentRequest request); + + @GetMapping("/api/v1/payments/{transactionKey}") + PgPaymentStatusResponse getPaymentStatus(@PathVariable("transactionKey") String transactionKey); + + @GetMapping("/api/v1/payments") + PgPaymentStatusResponse getPaymentByOrderId(@RequestParam("orderId") String orderId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/simulator/SimulatorFeignConfig.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/simulator/SimulatorFeignConfig.java new file mode 100644 index 0000000000..ba40532b3e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/simulator/SimulatorFeignConfig.java @@ -0,0 +1,27 @@ +package com.loopers.infrastructure.pg.simulator; + +import feign.Request; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; + +import java.util.concurrent.TimeUnit; + +/** + * PG Simulator Feign Client 타임아웃 설정. + * + *

connectTimeout 500ms: TCP 연결 수립 제한. PG가 살아있으면 수십ms 내 완료.

+ *

readTimeout 1,000ms: PG 응답 대기 제한. 정상 응답 100~500ms의 2배 여유.

+ * + * @see Timeout 값 결정 근거 + */ +public class SimulatorFeignConfig { + + @Bean + public Request.Options simulatorRequestOptions( + @Value("${pg.simulator.connect-timeout:500}") int connectTimeout, + @Value("${pg.simulator.read-timeout:1000}") int readTimeout + ) { + return new Request.Options(connectTimeout, TimeUnit.MILLISECONDS, + readTimeout, TimeUnit.MILLISECONDS, true); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/simulator/SimulatorPgClient.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/simulator/SimulatorPgClient.java new file mode 100644 index 0000000000..1c607ab5ca --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/simulator/SimulatorPgClient.java @@ -0,0 +1,62 @@ +package com.loopers.infrastructure.pg.simulator; + +import com.loopers.infrastructure.pg.*; +import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +/** + * PG 시뮬레이터 구현체 (Primary PG). + * + *

결제 요청(POST)에만 @CircuitBreaker 적용. + * 상태 조회(GET)는 "복구 행위"이므로 CB 없이 Timeout + try-catch만으로 보호.

+ * + * @see 읽기 CB 제거 근거 + */ +@Slf4j +@Component +@Order(1) +@RequiredArgsConstructor +public class SimulatorPgClient implements PgClient { + + private static final String PROVIDER_NAME = "SIMULATOR"; + + private final SimulatorFeignClient feignClient; + + @Override + @CircuitBreaker(name = "pgSimulator-request") + public PgPaymentResponse requestPayment(PgPaymentRequest request) { + return feignClient.requestPayment(request); + } + + /** + * 상태 조회 — CB 없음. Timeout + try-catch로만 보호. + * 복구 행위이므로 CB가 차단하면 복구가 멈춘다 (06 §18). + */ + @Override + public PgPaymentStatusResponse getPaymentStatus(String transactionKey) { + try { + return feignClient.getPaymentStatus(transactionKey); + } catch (Exception e) { + log.warn("PG 상태 확인 실패: transactionKey={}, error={}", transactionKey, e.getMessage()); + return new PgPaymentStatusResponse("UNKNOWN", transactionKey, null); + } + } + + @Override + public PgPaymentStatusResponse getPaymentByOrderId(String orderId) { + try { + return feignClient.getPaymentByOrderId(orderId); + } catch (Exception e) { + log.warn("PG 주문별 조회 실패: orderId={}, error={}", orderId, e.getMessage()); + return new PgPaymentStatusResponse("UNKNOWN", null, null); + } + } + + @Override + public String getProviderName() { + return PROVIDER_NAME; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/toss/TossFeignClient.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/toss/TossFeignClient.java new file mode 100644 index 0000000000..0901bec637 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/toss/TossFeignClient.java @@ -0,0 +1,36 @@ +package com.loopers.infrastructure.pg.toss; + +import com.loopers.infrastructure.pg.PgPaymentResponse; +import com.loopers.infrastructure.pg.PgPaymentStatusResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; + +/** + * Toss Sandbox Feign Client. + * + *

Toss는 동기 PG — confirm 호출 시 즉시 SUCCESS/FAILED 반환.

+ * + * @see Toss API 설계 + */ +@FeignClient( + name = "pg-toss", + url = "${pg.toss.url}", + configuration = TossSandboxPgConfig.class +) +public interface TossFeignClient { + + @PostMapping("/v1/payments/confirm") + PgPaymentResponse confirmPayment(@RequestBody TossConfirmRequest request); + + @GetMapping("/v1/payments/{paymentKey}") + PgPaymentStatusResponse getPaymentStatus(@PathVariable("paymentKey") String paymentKey); + + @GetMapping("/v1/payments") + PgPaymentStatusResponse getPaymentByOrderId(@RequestParam("orderId") String orderId); + + record TossConfirmRequest(String orderId, int amount) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/toss/TossSandboxPgClient.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/toss/TossSandboxPgClient.java new file mode 100644 index 0000000000..f88a31f739 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/toss/TossSandboxPgClient.java @@ -0,0 +1,66 @@ +package com.loopers.infrastructure.pg.toss; + +import com.loopers.infrastructure.pg.*; +import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +/** + * Toss Sandbox PG 구현체 (동기 결제). + * + *

Toss는 동기 PG — confirm 호출 시 즉시 SUCCESS/FAILED 반환. + * 콜백 대기가 불필요하며, requestPayment() 응답으로 최종 결과를 받는다.

+ * + *

결제 요청(POST)에만 @CircuitBreaker 적용. + * 상태 조회(GET)는 "복구 행위"이므로 CB 없이 Timeout + try-catch만으로 보호.

+ * + * @see Toss 동기 결제 + */ +@Slf4j +@Component +@Order(2) +@RequiredArgsConstructor +public class TossSandboxPgClient implements PgClient { + + private static final String PROVIDER_NAME = "TOSS"; + + private final TossFeignClient feignClient; + + @Override + @CircuitBreaker(name = "pgToss-request") + public PgPaymentResponse requestPayment(PgPaymentRequest request) { + TossFeignClient.TossConfirmRequest tossRequest = + new TossFeignClient.TossConfirmRequest(request.orderId(), request.amount()); + return feignClient.confirmPayment(tossRequest); + } + + /** + * 상태 조회 — CB 없음. Timeout + try-catch로만 보호. + */ + @Override + public PgPaymentStatusResponse getPaymentStatus(String transactionKey) { + try { + return feignClient.getPaymentStatus(transactionKey); + } catch (Exception e) { + log.warn("Toss 상태 확인 실패: transactionKey={}, error={}", transactionKey, e.getMessage()); + return new PgPaymentStatusResponse("UNKNOWN", transactionKey, null); + } + } + + @Override + public PgPaymentStatusResponse getPaymentByOrderId(String orderId) { + try { + return feignClient.getPaymentByOrderId(orderId); + } catch (Exception e) { + log.warn("Toss 주문별 조회 실패: orderId={}, error={}", orderId, e.getMessage()); + return new PgPaymentStatusResponse("UNKNOWN", null, null); + } + } + + @Override + public String getProviderName() { + return PROVIDER_NAME; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/toss/TossSandboxPgConfig.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/toss/TossSandboxPgConfig.java new file mode 100644 index 0000000000..e9866aabda --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/toss/TossSandboxPgConfig.java @@ -0,0 +1,27 @@ +package com.loopers.infrastructure.pg.toss; + +import feign.Request; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; + +import java.util.concurrent.TimeUnit; + +/** + * Toss Sandbox Feign Client 타임아웃 설정. + * + *

connectTimeout 500ms: TCP 연결 수립 제한.

+ *

readTimeout 2,000ms: Toss 동기 결제 응답 대기 (Simulator보다 넉넉히).

+ * + * @see Timeout 값 결정 근거 + */ +public class TossSandboxPgConfig { + + @Bean + public Request.Options tossRequestOptions( + @Value("${pg.toss.connect-timeout:500}") int connectTimeout, + @Value("${pg.toss.read-timeout:2000}") int readTimeout + ) { + return new Request.Options(connectTimeout, TimeUnit.MILLISECONDS, + readTimeout, TimeUnit.MILLISECONDS, true); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/CaffeineProductCacheAdapter.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/CaffeineProductCacheAdapter.java new file mode 100644 index 0000000000..c5ba3687df --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/CaffeineProductCacheAdapter.java @@ -0,0 +1,66 @@ +package com.loopers.infrastructure.product; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.loopers.application.product.ProductCachePort; +import com.loopers.interfaces.api.product.ProductDto; +import org.springframework.stereotype.Component; + +import java.time.Duration; + +@Component +public class CaffeineProductCacheAdapter implements ProductCachePort { + + private final Cache detailCache; + private final Cache listCache; + + public CaffeineProductCacheAdapter() { + this.detailCache = Caffeine.newBuilder() + .maximumSize(500) + .expireAfterWrite(Duration.ofSeconds(30)) + .build(); + this.listCache = Caffeine.newBuilder() + .maximumSize(200) + .expireAfterWrite(Duration.ofSeconds(15)) + .build(); + } + + @Override + public ProductDto.ProductResponse getProductDetail(Long productId) { + return detailCache.getIfPresent(detailKey(productId)); + } + + @Override + public void putProductDetail(Long productId, ProductDto.ProductResponse response) { + detailCache.put(detailKey(productId), response); + } + + @Override + public void evictProductDetail(Long productId) { + detailCache.invalidate(detailKey(productId)); + } + + @Override + public ProductDto.PagedProductResponse getProductList(Long brandId, String sort, int page, int size) { + return listCache.getIfPresent(listKey(brandId, sort, page, size)); + } + + @Override + public void putProductList(Long brandId, String sort, int page, int size, ProductDto.PagedProductResponse response) { + listCache.put(listKey(brandId, sort, page, size), response); + } + + @Override + public void evictProductList() { + listCache.invalidateAll(); + } + + private String detailKey(Long productId) { + return "detail:" + productId; + } + + private String listKey(Long brandId, String sort, int page, int size) { + String brandPart = brandId != null ? String.valueOf(brandId) : "all"; + return "list:brand:" + brandPart + ":sort:" + sort + ":page:" + page + ":size:" + size; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/MultiLayerProductCacheAdapter.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/MultiLayerProductCacheAdapter.java new file mode 100644 index 0000000000..2055639190 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/MultiLayerProductCacheAdapter.java @@ -0,0 +1,79 @@ +package com.loopers.infrastructure.product; + +import com.loopers.application.product.ProductCachePort; +import com.loopers.interfaces.api.product.ProductDto; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Primary; +import org.springframework.stereotype.Component; + +@Primary +@Component +public class MultiLayerProductCacheAdapter implements ProductCachePort { + + private final ProductCachePort l1Cache; + private final ProductCachePort l2Cache; + + public MultiLayerProductCacheAdapter( + @Qualifier("caffeineProductCacheAdapter") ProductCachePort l1Cache, + @Qualifier("redisProductCacheAdapter") ProductCachePort l2Cache + ) { + this.l1Cache = l1Cache; + this.l2Cache = l2Cache; + } + + // ── 상품 상세 캐시 ── + + @Override + public ProductDto.ProductResponse getProductDetail(Long productId) { + ProductDto.ProductResponse cached = l1Cache.getProductDetail(productId); + if (cached != null) { + return cached; + } + + cached = l2Cache.getProductDetail(productId); + if (cached != null) { + l1Cache.putProductDetail(productId, cached); + } + return cached; + } + + @Override + public void putProductDetail(Long productId, ProductDto.ProductResponse response) { + l2Cache.putProductDetail(productId, response); + l1Cache.putProductDetail(productId, response); + } + + @Override + public void evictProductDetail(Long productId) { + l1Cache.evictProductDetail(productId); + l2Cache.evictProductDetail(productId); + } + + // ── 상품 목록 캐시 ── + + @Override + public ProductDto.PagedProductResponse getProductList(Long brandId, String sort, int page, int size) { + ProductDto.PagedProductResponse cached = l1Cache.getProductList(brandId, sort, page, size); + if (cached != null) { + return cached; + } + + cached = l2Cache.getProductList(brandId, sort, page, size); + if (cached != null) { + l1Cache.putProductList(brandId, sort, page, size, cached); + } + return cached; + } + + @Override + public void putProductList(Long brandId, String sort, int page, int size, ProductDto.PagedProductResponse response) { + l2Cache.putProductList(brandId, sort, page, size, response); + l1Cache.putProductList(brandId, sort, page, size, response); + } + + @Override + public void evictProductList() { + l1Cache.evictProductList(); + l2Cache.evictProductList(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java new file mode 100644 index 0000000000..56df3ddd6d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -0,0 +1,69 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.Product; +import jakarta.persistence.LockModeType; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Optional; + +public interface ProductJpaRepository extends JpaRepository { + Optional findByIdAndDeletedAtIsNull(Long id); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT p FROM Product p WHERE p.id IN :ids AND p.deletedAt IS NULL ORDER BY p.id ASC") + List findAllByIdsWithLock(@Param("ids") List ids); + + @Query("SELECT p, b.name FROM Product p LEFT JOIN Brand b ON b.id = p.brandId" + + " WHERE p.id IN :ids AND p.deletedAt IS NULL AND (b.deletedAt IS NULL OR b.id IS NULL)") + List findAllByIds(@Param("ids") List ids); + + List findAllByDeletedAtIsNull(); + List findAllByBrandIdAndDeletedAtIsNull(Long brandId); + + @Query("SELECT p, b.name FROM Product p LEFT JOIN Brand b ON b.id = p.brandId" + + " WHERE p.deletedAt IS NULL AND (b.deletedAt IS NULL OR b.id IS NULL)") + List findAllWithBrand(); + + @Query("SELECT p, b.name FROM Product p LEFT JOIN Brand b ON b.id = p.brandId" + + " WHERE p.deletedAt IS NULL AND (b.deletedAt IS NULL OR b.id IS NULL)") + List findAllWithBrand(Sort sort); + + @Query("SELECT p, b.name FROM Product p LEFT JOIN Brand b ON b.id = p.brandId" + + " WHERE p.brandId = :brandId AND p.deletedAt IS NULL AND (b.deletedAt IS NULL OR b.id IS NULL)") + List findAllByBrandIdWithBrand(@Param("brandId") Long brandId); + + // 페이지네이션 조회 (Sort는 Pageable에 내장하여 전달) + @Query(value = "SELECT p, b.name FROM Product p LEFT JOIN Brand b ON b.id = p.brandId" + + " WHERE p.deletedAt IS NULL AND (b.deletedAt IS NULL OR b.id IS NULL)", + countQuery = "SELECT COUNT(p) FROM Product p WHERE p.deletedAt IS NULL") + Page findAllWithBrandPaged(Pageable pageable); + + @Query(value = "SELECT p, b.name FROM Product p LEFT JOIN Brand b ON b.id = p.brandId" + + " WHERE p.brandId = :brandId AND p.deletedAt IS NULL AND (b.deletedAt IS NULL OR b.id IS NULL)", + countQuery = "SELECT COUNT(p) FROM Product p WHERE p.brandId = :brandId AND p.deletedAt IS NULL") + Page findAllByBrandIdWithBrandPaged(@Param("brandId") Long brandId, Pageable pageable); + + @Query(value = "SELECT p, b.name FROM Product p LEFT JOIN Brand b ON b.id = p.brandId" + + " WHERE p.createdAt >= :since AND p.deletedAt IS NULL AND (b.deletedAt IS NULL OR b.id IS NULL)" + + " ORDER BY p.createdAt DESC", + countQuery = "SELECT COUNT(p) FROM Product p WHERE p.createdAt >= :since AND p.deletedAt IS NULL") + Page findNewProducts(@Param("since") ZonedDateTime since, Pageable pageable); + + // likeCount atomic 증감 — 엔티티 로딩 없이 단일 UPDATE 문으로 실행 + @Modifying + @Query("UPDATE Product p SET p.likeCount = p.likeCount + 1 WHERE p.id = :productId AND p.deletedAt IS NULL") + int incrementLikeCount(@Param("productId") Long productId); + + @Modifying + @Query("UPDATE Product p SET p.likeCount = CASE WHEN p.likeCount > 0 THEN p.likeCount - 1 ELSE 0 END WHERE p.id = :productId AND p.deletedAt IS NULL") + int decrementLikeCount(@Param("productId") Long productId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java new file mode 100644 index 0000000000..31a6c988fd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -0,0 +1,126 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.ProductWithBrand; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Repository; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class ProductRepositoryImpl implements ProductRepository { + + private final ProductJpaRepository productJpaRepository; + + @Override + public Product save(Product product) { + return productJpaRepository.save(product); + } + + @Override + public Optional findById(Long id) { + return productJpaRepository.findByIdAndDeletedAtIsNull(id); + } + + @Override + public List findAllByIdsWithLock(List ids) { + return productJpaRepository.findAllByIdsWithLock(ids); + } + + @Override + public List findAllByIds(List ids) { + if (ids.isEmpty()) return List.of(); + return productJpaRepository.findAllByIds(ids).stream() + .map(this::toProductWithBrand) + .toList(); + } + + @Override + public List findAll() { + return productJpaRepository.findAllByDeletedAtIsNull(); + } + + @Override + public List findAllByBrandId(Long brandId) { + return productJpaRepository.findAllByBrandIdAndDeletedAtIsNull(brandId); + } + + @Override + public List findAllWithBrand() { + return productJpaRepository.findAllWithBrand().stream() + .map(this::toProductWithBrand) + .toList(); + } + + @Override + public List findAllWithBrand(String sort) { + return productJpaRepository.findAllWithBrand(toSort(sort)).stream() + .map(this::toProductWithBrand) + .toList(); + } + + @Override + public List findAllByBrandIdWithBrand(Long brandId) { + return productJpaRepository.findAllByBrandIdWithBrand(brandId).stream() + .map(this::toProductWithBrand) + .toList(); + } + + @Override + public Page findAllWithBrand(String sort, Pageable pageable) { + Pageable sortedPageable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), toSort(sort)); + return productJpaRepository.findAllWithBrandPaged(sortedPageable) + .map(this::toProductWithBrand); + } + + @Override + public Page findAllByBrandIdWithBrand(Long brandId, String sort, Pageable pageable) { + Pageable sortedPageable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), toSort(sort)); + return productJpaRepository.findAllByBrandIdWithBrandPaged(brandId, sortedPageable) + .map(this::toProductWithBrand); + } + + @Override + public Page findNewProducts(ZonedDateTime since, Pageable pageable) { + return productJpaRepository.findNewProducts(since, pageable) + .map(this::toProductWithBrand); + } + + @Override + public int incrementLikeCount(Long productId) { + return productJpaRepository.incrementLikeCount(productId); + } + + @Override + public int decrementLikeCount(Long productId) { + return productJpaRepository.decrementLikeCount(productId); + } + + private ProductWithBrand toProductWithBrand(Object[] row) { + Product product = (Product) row[0]; + String brandName = (String) row[1]; + return new ProductWithBrand(product, brandName, product.getLikeCount()); + } + + private Sort toSort(String sort) { + if (sort == null) { + return Sort.by("createdAt").descending(); + } + return switch (sort) { + case "price_asc" -> Sort.by("price.value").ascending(); + case "likes_desc" -> Sort.by( + Sort.Order.desc("likeCount"), + Sort.Order.desc("id") + ); + default -> Sort.by("createdAt").descending(); + }; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/RedisProductCacheAdapter.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/RedisProductCacheAdapter.java new file mode 100644 index 0000000000..ac712286b7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/RedisProductCacheAdapter.java @@ -0,0 +1,132 @@ +package com.loopers.infrastructure.product; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.application.product.ProductCachePort; +import com.loopers.interfaces.api.product.ProductDto; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +import java.util.concurrent.TimeUnit; + +@Slf4j +@Component +public class RedisProductCacheAdapter implements ProductCachePort { + + private static final String PRODUCT_DETAIL_KEY_PREFIX = "product:detail:"; + private static final String PRODUCT_LIST_KEY_PREFIX = "product:list:"; + private static final String PRODUCT_LIST_VERSION_KEY = "product:list:version"; + private static final long DETAIL_TTL_MINUTES = 10; + private static final long LIST_TTL_MINUTES = 5; + + private final RedisTemplate readTemplate; + private final RedisTemplate writeTemplate; + private final ObjectMapper objectMapper; + + public RedisProductCacheAdapter( + RedisTemplate readTemplate, + @Qualifier("redisTemplateMaster") RedisTemplate writeTemplate, + ObjectMapper objectMapper + ) { + this.readTemplate = readTemplate; + this.writeTemplate = writeTemplate; + this.objectMapper = objectMapper; + } + + // ── 상품 상세 캐시 ── + + @Override + public ProductDto.ProductResponse getProductDetail(Long productId) { + try { + String key = PRODUCT_DETAIL_KEY_PREFIX + productId; + String cached = readTemplate.opsForValue().get(key); + if (cached == null) { + return null; + } + return objectMapper.readValue(cached, ProductDto.ProductResponse.class); + } catch (Exception e) { + log.warn("Redis 상품 상세 캐시 조회 실패 (productId={}): {}", productId, e.getMessage()); + return null; + } + } + + @Override + public void putProductDetail(Long productId, ProductDto.ProductResponse response) { + try { + String key = PRODUCT_DETAIL_KEY_PREFIX + productId; + String json = objectMapper.writeValueAsString(response); + writeTemplate.opsForValue().set(key, json, DETAIL_TTL_MINUTES, TimeUnit.MINUTES); + } catch (Exception e) { + log.warn("Redis 상품 상세 캐시 저장 실패 (productId={}): {}", productId, e.getMessage()); + } + } + + @Override + public void evictProductDetail(Long productId) { + try { + String key = PRODUCT_DETAIL_KEY_PREFIX + productId; + writeTemplate.delete(key); + } catch (Exception e) { + log.warn("Redis 상품 상세 캐시 삭제 실패 (productId={}): {}", productId, e.getMessage()); + } + } + + // ── 상품 목록 캐시 (버전 기반 무효화) ── + + @Override + public ProductDto.PagedProductResponse getProductList(Long brandId, String sort, int page, int size) { + try { + String key = buildListKey(brandId, sort, page, size); + if (key == null) return null; + String cached = readTemplate.opsForValue().get(key); + if (cached == null) { + return null; + } + return objectMapper.readValue(cached, ProductDto.PagedProductResponse.class); + } catch (Exception e) { + log.warn("Redis 상품 목록 캐시 조회 실패: {}", e.getMessage()); + return null; + } + } + + @Override + public void putProductList(Long brandId, String sort, int page, int size, ProductDto.PagedProductResponse response) { + try { + String key = buildListKey(brandId, sort, page, size); + if (key == null) return; + String json = objectMapper.writeValueAsString(response); + writeTemplate.opsForValue().set(key, json, LIST_TTL_MINUTES, TimeUnit.MINUTES); + } catch (Exception e) { + log.warn("Redis 상품 목록 캐시 저장 실패: {}", e.getMessage()); + } + } + + @Override + public void evictProductList() { + try { + writeTemplate.opsForValue().increment(PRODUCT_LIST_VERSION_KEY); + } catch (Exception e) { + log.warn("Redis 상품 목록 캐시 버전 증가 실패: {}", e.getMessage()); + } + } + + private String buildListKey(Long brandId, String sort, int page, int size) { + try { + String version = readTemplate.opsForValue().get(PRODUCT_LIST_VERSION_KEY); + if (version == null) { + version = "0"; + writeTemplate.opsForValue().setIfAbsent(PRODUCT_LIST_VERSION_KEY, "0"); + } + String brandPart = brandId != null ? String.valueOf(brandId) : "all"; + return PRODUCT_LIST_KEY_PREFIX + "v" + version + + ":brand:" + brandPart + + ":sort:" + sort + + ":page:" + page + + ":size:" + size; + } catch (Exception e) { + log.warn("Redis 캐시 키 생성 실패: {}", e.getMessage()); + return null; + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/queue/EntryTokenInterceptor.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/queue/EntryTokenInterceptor.java new file mode 100644 index 0000000000..920aad4518 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/queue/EntryTokenInterceptor.java @@ -0,0 +1,110 @@ +package com.loopers.infrastructure.queue; + +import com.loopers.domain.member.Member; +import com.loopers.infrastructure.redis.EntryTokenRedisRepository; +import com.loopers.infrastructure.resilience.SlidingWindowRateLimiter; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Component; + +import java.util.concurrent.atomic.AtomicLong; + +/** + * 대기열 입장 토큰 검증 AOP. + * + *

{@code @RequireEntryToken} 어노테이션이 붙은 메서드 실행 전 토큰 존재 여부를 확인한다. + * 성공 시에만 토큰을 소비하여 예외 발생 시 재시도가 가능하도록 한다.

+ * + *

Redis 장애 시 로컬 Rate Limiter로 전환하여 DB 커넥션 풀을 보호한다. + * 정상 모드와 동일한 80 req/sec 제한으로, Redis 없이도 트래픽 제어를 유지한다.

+ * + * @see com.loopers.support.auth.RequireEntryToken + */ +@Slf4j +@Aspect +@Component +public class EntryTokenInterceptor { + + private static final long ERROR_LOG_INTERVAL_MILLIS = 10_000; + + private final EntryTokenRedisRepository entryTokenRedisRepository; + private final SlidingWindowRateLimiter fallbackRateLimiter; + private final Counter fallbackCounter; + private final AtomicLong lastFallbackErrorLogTime = new AtomicLong(0); + + public EntryTokenInterceptor( + EntryTokenRedisRepository entryTokenRedisRepository, + @Qualifier("queueFallbackRateLimiter") SlidingWindowRateLimiter fallbackRateLimiter, + MeterRegistry meterRegistry + ) { + this.entryTokenRedisRepository = entryTokenRedisRepository; + this.fallbackRateLimiter = fallbackRateLimiter; + this.fallbackCounter = Counter.builder("queue.token.fallback") + .description("Redis 장애 시 fallback 발동 횟수") + .register(meterRegistry); + } + + @Around("@annotation(com.loopers.support.auth.RequireEntryToken)") + public Object validateEntryToken(ProceedingJoinPoint joinPoint) throws Throwable { + Long memberId = extractMemberIdFromArgs(joinPoint); + + try { + if (!entryTokenRedisRepository.exists(memberId)) { + log.warn("입장 토큰 없음 — 주문 거부: memberId={}", memberId); + throw new CoreException(ErrorType.FORBIDDEN, "대기열 입장 토큰이 없습니다."); + } + } catch (CoreException e) { + throw e; + } catch (Exception e) { + return handleRedisFallback(joinPoint, memberId, e); + } + + Object result = joinPoint.proceed(); + + try { + entryTokenRedisRepository.consume(memberId); + } catch (Exception e) { + throttledWarn(e, memberId); + // 토큰은 TTL로 자동 만료되므로 소비 실패는 무시 + } + + return result; + } + + private Object handleRedisFallback(ProceedingJoinPoint joinPoint, Long memberId, Exception cause) throws Throwable { + fallbackCounter.increment(); + throttledWarn(cause, memberId); + + if (!fallbackRateLimiter.tryAcquire()) { + throw new CoreException(ErrorType.TOO_MANY_REQUESTS, "시스템이 일시적으로 혼잡합니다."); + } + + Object result = joinPoint.proceed(); + // fallback 모드에서는 토큰 소비를 시도하지 않음 (Redis 장애 상태) + return result; + } + + private void throttledWarn(Exception e, Long memberId) { + long now = System.currentTimeMillis(); + long last = lastFallbackErrorLogTime.get(); + if (now - last >= ERROR_LOG_INTERVAL_MILLIS && lastFallbackErrorLogTime.compareAndSet(last, now)) { + log.warn("Redis 장애 — fallback 모드: memberId={}, error={}", memberId, e.getMessage()); + } + } + + private Long extractMemberIdFromArgs(ProceedingJoinPoint joinPoint) { + for (Object arg : joinPoint.getArgs()) { + if (arg instanceof Member member) { + return member.getId(); + } + } + throw new CoreException(ErrorType.INTERNAL_ERROR, "Member 인자를 찾을 수 없습니다."); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/queue/QueueSseEmitterRegistry.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/queue/QueueSseEmitterRegistry.java new file mode 100644 index 0000000000..d3248bd1a3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/queue/QueueSseEmitterRegistry.java @@ -0,0 +1,166 @@ +package com.loopers.infrastructure.queue; + +import io.micrometer.core.instrument.MeterRegistry; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * SSE Emitter 레지스트리 — 대기열 순번 실시간 Push. + * + *

Delta 기반 브로드캐스트: 매 입장 사이클마다 개별 ZRANK를 호출하지 않는다. + * 스케줄러가 N명을 입장시키면, 연결된 모든 SSE 클라이언트에게 admittedCount를 보내고, + * 클라이언트가 자기 position을 로컬에서 차감한다.

+ * + *

Redis 추가 비용: O(0) (기존 ZPOPMIN만 사용). + * SSE 전송 비용: O(K) (K = 연결된 클라이언트 수).

+ */ +@Slf4j +@Component +public class QueueSseEmitterRegistry { + + static final int MAX_SSE_CONNECTIONS = 5_000; + private static final long EMITTER_TIMEOUT_MS = 600_000; // 600초 (MAX_WAIT_SECONDS) + + private final ConcurrentHashMap emitters = new ConcurrentHashMap<>(); + private final AtomicInteger connectionCount = new AtomicInteger(0); + + public QueueSseEmitterRegistry(MeterRegistry meterRegistry) { + meterRegistry.gauge("queue.sse.connections", connectionCount); + } + + /** + * SSE 연결 등록 + 초기 position 전송. + * + * @return SseEmitter (용량 초과 시 use_polling 이벤트 후 null) + */ + public SseEmitter register(Long memberId, long position) { + if (connectionCount.get() >= MAX_SSE_CONNECTIONS) { + return createOverCapacityEmitter(position); + } + + // 중복 memberId 연결 시 기존 emitter 교체 (재연결 시나리오) + SseEmitter existing = emitters.get(memberId); + if (existing != null) { + existing.complete(); + removeEmitter(memberId); + } + + SseEmitter emitter = new SseEmitter(EMITTER_TIMEOUT_MS); + emitters.put(memberId, emitter); + connectionCount.incrementAndGet(); + + emitter.onCompletion(() -> removeEmitter(memberId)); + emitter.onTimeout(() -> removeEmitter(memberId)); + emitter.onError(e -> removeEmitter(memberId)); + + try { + emitter.send(SseEmitter.event() + .name("position") + .data(Map.of("position", position))); + } catch (IOException e) { + removeEmitter(memberId); + emitter.completeWithError(e); + } + + return emitter; + } + + /** + * 입장 처리 후 호출 — admitted 유저에게 이벤트 전송 + delta 브로드캐스트. + */ + public void onAdmission(List admittedMemberIds, int count) { + // 1. admitted 유저에게 개별 이벤트 전송 후 emitter 닫기 + for (String memberIdStr : admittedMemberIds) { + try { + Long memberId = Long.parseLong(memberIdStr); + SseEmitter emitter = emitters.get(memberId); + if (emitter != null) { + emitter.send(SseEmitter.event() + .name("admitted") + .data(Map.of())); + emitter.complete(); + removeEmitter(memberId); + } + } catch (Exception e) { + log.debug("admitted 이벤트 전송 실패: memberId={}", memberIdStr, e); + } + } + + if (count <= 0) { + return; + } + + // 2. 나머지 대기 중 클라이언트에게 delta 브로드캐스트 + Map deltaData = Map.of("admittedCount", count); + for (Map.Entry entry : emitters.entrySet()) { + try { + entry.getValue().send(SseEmitter.event() + .name("delta") + .data(deltaData)); + } catch (Exception e) { + removeEmitter(entry.getKey()); + log.debug("delta 이벤트 전송 실패: memberId={}", entry.getKey(), e); + } + } + } + + /** + * 30초 주기 heartbeat — 빈 코멘트 전송으로 연결 유지. + */ + public void sendHeartbeat() { + for (Map.Entry entry : emitters.entrySet()) { + try { + entry.getValue().send(SseEmitter.event().comment("heartbeat")); + } catch (Exception e) { + removeEmitter(entry.getKey()); + } + } + } + + /** + * 특정 memberId에 이벤트 전송 후 emitter 닫기 (admitted/not_in_queue 등). + */ + public void sendAndClose(Long memberId, String eventName, Object data) { + SseEmitter emitter = new SseEmitter(0L); + try { + emitter.send(SseEmitter.event().name(eventName).data(data)); + emitter.complete(); + } catch (IOException e) { + emitter.completeWithError(e); + } + } + + public int getConnectionCount() { + return connectionCount.get(); + } + + /** + * SSE 용량 초과 시: use_polling 이벤트와 함께 즉시 닫기. + */ + private SseEmitter createOverCapacityEmitter(long position) { + SseEmitter emitter = new SseEmitter(0L); + try { + long suggestedInterval = position <= 100 ? 1000 : position <= 1000 ? 3000 : 5000; + emitter.send(SseEmitter.event() + .name("use_polling") + .data(Map.of("suggestedPollIntervalMs", suggestedInterval))); + emitter.complete(); + } catch (IOException e) { + emitter.completeWithError(e); + } + return emitter; + } + + private void removeEmitter(Long memberId) { + if (emitters.remove(memberId) != null) { + connectionCount.decrementAndGet(); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankJpaRepository.java new file mode 100644 index 0000000000..bae44a38d9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankJpaRepository.java @@ -0,0 +1,36 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.*; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class MvProductRankJpaRepository implements MvProductRankRepository { + + private final MvProductRankWeeklySpringDataRepository weeklyRepository; + private final MvProductRankMonthlySpringDataRepository monthlyRepository; + + @Override + public List findByPeriodKeyAndScope(String periodKey, String scope, Pageable pageable) { + return switch (scope) { + case "weekly" -> weeklyRepository.findByPeriodKeyOrderByRankingAsc(periodKey, pageable) + .stream().map(r -> (MvProductRank) r).toList(); + case "monthly" -> monthlyRepository.findByPeriodKeyOrderByRankingAsc(periodKey, pageable) + .stream().map(r -> (MvProductRank) r).toList(); + default -> throw new IllegalArgumentException("Invalid scope: " + scope); + }; + } + + @Override + public long countByPeriodKeyAndScope(String periodKey, String scope) { + return switch (scope) { + case "weekly" -> weeklyRepository.countByPeriodKey(periodKey); + case "monthly" -> monthlyRepository.countByPeriodKey(periodKey); + default -> throw new IllegalArgumentException("Invalid scope: " + scope); + }; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlySpringDataRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlySpringDataRepository.java new file mode 100644 index 0000000000..4a84e2074c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlySpringDataRepository.java @@ -0,0 +1,14 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.MvProductRankMonthly; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface MvProductRankMonthlySpringDataRepository extends JpaRepository { + + List findByPeriodKeyOrderByRankingAsc(String periodKey, Pageable pageable); + + long countByPeriodKey(String periodKey); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklySpringDataRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklySpringDataRepository.java new file mode 100644 index 0000000000..8a5bf79dd3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklySpringDataRepository.java @@ -0,0 +1,14 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.MvProductRankWeekly; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface MvProductRankWeeklySpringDataRepository extends JpaRepository { + + List findByPeriodKeyOrderByRankingAsc(String periodKey, Pageable pageable); + + long countByPeriodKey(String periodKey); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRedisRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRedisRepository.java new file mode 100644 index 0000000000..b6966fffcd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRedisRepository.java @@ -0,0 +1,64 @@ +package com.loopers.infrastructure.ranking; + +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ZSetOperations.TypedTuple; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; + +@Component +public class RankingRedisRepository { + + private static final String RANKING_ZSET_PREFIX = "ranking:all:"; + + private final RedisTemplate readTemplate; + + public RankingRedisRepository(RedisTemplate readTemplate) { + this.readTemplate = readTemplate; + } + + public List getTopN(String date, long start, long end) { + return getTopN(RANKING_ZSET_PREFIX, date, start, end); + } + + public List getTopN(String prefix, String date, long start, long end) { + String key = prefix + date; + Set> tuples = readTemplate.opsForZSet().reverseRangeWithScores(key, start, end); + if (tuples == null) return Collections.emptyList(); + List entries = new ArrayList<>(tuples.size()); + for (TypedTuple tuple : tuples) { + entries.add(new RankingEntry(Long.parseLong(tuple.getValue()), tuple.getScore())); + } + return entries; + } + + public RankAndScore getRankAndScore(String date, Long productId) { + return getRankAndScore(RANKING_ZSET_PREFIX, date, productId); + } + + public RankAndScore getRankAndScore(String prefix, String date, Long productId) { + String key = prefix + date; + String member = String.valueOf(productId); + Long rank = readTemplate.opsForZSet().reverseRank(key, member); + if (rank == null) return null; + Double score = readTemplate.opsForZSet().score(key, member); + return new RankAndScore(rank + 1, score != null ? score : 0.0); + } + + public long getTotalCount(String date) { + return getTotalCount(RANKING_ZSET_PREFIX, date); + } + + public long getTotalCount(String prefix, String date) { + String key = prefix + date; + Long count = readTemplate.opsForZSet().zCard(key); + return count != null ? count : 0; + } + + public record RankingEntry(Long productId, double score) {} + + public record RankAndScore(long rank, double score) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/redis/CouponIssueRequestRedisRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/redis/CouponIssueRequestRedisRepository.java new file mode 100644 index 0000000000..f7e8127646 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/redis/CouponIssueRequestRedisRepository.java @@ -0,0 +1,82 @@ +package com.loopers.infrastructure.redis; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.domain.coupon.CouponIssueRequestInfo; +import com.loopers.domain.coupon.CouponIssueRequestStatus; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +@Slf4j +@Component +public class CouponIssueRequestRedisRepository { + + private static final String KEY_PREFIX = "coupon:request:"; + private static final String SEQ_KEY = "coupon:request:seq"; + private static final long TTL_SECONDS = 600; // 10분 + + private final RedisTemplate readTemplate; + private final RedisTemplate writeTemplate; + private final ObjectMapper objectMapper; + + public CouponIssueRequestRedisRepository( + RedisTemplate readTemplate, + @Qualifier("redisTemplateMaster") RedisTemplate writeTemplate, + ObjectMapper objectMapper + ) { + this.readTemplate = readTemplate; + this.writeTemplate = writeTemplate; + this.objectMapper = objectMapper; + } + + public Long nextId() { + return writeTemplate.opsForValue().increment(SEQ_KEY); + } + + public void save(Long requestId, Long couponId, Long memberId, String status, String rejectReason) { + try { + String key = KEY_PREFIX + requestId; + Map data = Map.of( + "requestId", requestId, + "couponId", couponId, + "memberId", memberId, + "status", status, + "rejectReason", rejectReason != null ? rejectReason : "" + ); + String json = objectMapper.writeValueAsString(data); + writeTemplate.opsForValue().set(key, json, TTL_SECONDS, TimeUnit.SECONDS); + log.debug("쿠폰 발급 요청 저장: requestId={}, status={}", requestId, status); + } catch (Exception e) { + log.error("쿠폰 발급 요청 Redis 저장 실패: requestId={}", requestId, e); + throw new RuntimeException("쿠폰 발급 요청 저장 실패", e); + } + } + + @SuppressWarnings("unchecked") + public Optional findById(Long requestId) { + try { + String key = KEY_PREFIX + requestId; + String json = readTemplate.opsForValue().get(key); + if (json == null) { + return Optional.empty(); + } + Map data = objectMapper.readValue(json, Map.class); + return Optional.of(new CouponIssueRequestInfo( + ((Number) data.get("requestId")).longValue(), + ((Number) data.get("couponId")).longValue(), + ((Number) data.get("memberId")).longValue(), + CouponIssueRequestStatus.valueOf((String) data.get("status")), + data.get("rejectReason") != null && !((String) data.get("rejectReason")).isEmpty() + ? (String) data.get("rejectReason") : null + )); + } catch (Exception e) { + log.warn("쿠폰 발급 요청 Redis 조회 실패: requestId={}", requestId, e); + return Optional.empty(); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/redis/EntryTokenRedisRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/redis/EntryTokenRedisRepository.java new file mode 100644 index 0000000000..2699ba9a27 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/redis/EntryTokenRedisRepository.java @@ -0,0 +1,80 @@ +package com.loopers.infrastructure.redis; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +import java.util.concurrent.TimeUnit; + +/** + * 입장 토큰 Redis 저장소. + * + *

대기열 통과 시 발급되는 토큰. TTL 내에 주문을 완료해야 한다.

+ * + *

TTL 산술 근거 (기본 900초 = 15분):

+ *
    + *
  • 체크아웃 p99 예상 시간 ~10분 + 50% 여유
  • + *
  • 동시 토큰 보유자 = 80 TPS × 234초(가중 평균 체류) = 18,720명
  • + *
  • Redis 메모리: 18,720 × 90 bytes = 1.7MB (무시 가능)
  • + *
  • 블프 시 설정 변경으로 1800초까지 조정 가능
  • + *
+ */ +@Slf4j +@Component +public class EntryTokenRedisRepository { + + private static final String KEY_PREFIX = "queue:token:"; + + private final long tokenTtlSeconds; + private final RedisTemplate readTemplate; + private final RedisTemplate writeTemplate; + + public EntryTokenRedisRepository( + @Value("${queue.token.ttl-seconds:900}") long tokenTtlSeconds, + RedisTemplate readTemplate, + @Qualifier("redisTemplateMaster") RedisTemplate writeTemplate + ) { + this.tokenTtlSeconds = tokenTtlSeconds; + this.readTemplate = readTemplate; + this.writeTemplate = writeTemplate; + } + + /** + * 토큰 발급 (SET EX {ttl}). + */ + public void issue(Long memberId) { + String key = KEY_PREFIX + memberId; + writeTemplate.opsForValue().set(key, "1", tokenTtlSeconds, TimeUnit.SECONDS); + log.debug("입장 토큰 발급: memberId={}", memberId); + } + + /** + * 토큰 존재 확인 (replica 읽기). + */ + public boolean exists(Long memberId) { + String key = KEY_PREFIX + memberId; + return Boolean.TRUE.equals(readTemplate.hasKey(key)); + } + + /** + * 토큰 소비 (DEL). + */ + public void consume(Long memberId) { + String key = KEY_PREFIX + memberId; + writeTemplate.delete(key); + log.debug("입장 토큰 소비: memberId={}", memberId); + } + + /** + * 잔여 TTL 조회 (초 단위). + * + * @return TTL (키 없으면 -2, TTL 없으면 -1) + */ + public long getRemainingTtl(Long memberId) { + String key = KEY_PREFIX + memberId; + Long ttl = readTemplate.getExpire(key, TimeUnit.SECONDS); + return ttl != null ? ttl : -2; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/redis/ProvisionalOrderRedisRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/redis/ProvisionalOrderRedisRepository.java new file mode 100644 index 0000000000..0a4fd04aef --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/redis/ProvisionalOrderRedisRepository.java @@ -0,0 +1,145 @@ +package com.loopers.infrastructure.redis; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +import org.springframework.data.redis.core.Cursor; +import org.springframework.data.redis.core.ScanOptions; + +import java.util.*; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; + +/** + * 가주문(Provisional Order) Redis 저장소. + * + *

주문서 작성 → Redis HSET(가주문) → 결제 진행. + * 결제 완료 시 DB INSERT(진주문) + Redis DEL(가주문).

+ * + *

TTL: 30분 ±5분 Jitter (25~35분) — 동시 만료에 의한 Redis 부하 방지.

+ * + * @see TTL Jitter 설계 + */ +@Slf4j +@Component +public class ProvisionalOrderRedisRepository { + + private static final String KEY_PREFIX = "provisional-order:"; + private static final long BASE_TTL_MINUTES = 30; + private static final long JITTER_MINUTES = 5; + + private final RedisTemplate readTemplate; + private final RedisTemplate writeTemplate; + private final ObjectMapper objectMapper; + + public ProvisionalOrderRedisRepository( + RedisTemplate readTemplate, + @Qualifier("redisTemplateMaster") RedisTemplate writeTemplate, + ObjectMapper objectMapper + ) { + this.readTemplate = readTemplate; + this.writeTemplate = writeTemplate; + this.objectMapper = objectMapper; + } + + public void save(Long orderId, Map orderData) { + try { + String key = KEY_PREFIX + orderId; + String json = objectMapper.writeValueAsString(orderData); + long ttl = calculateTtlWithJitter(); + writeTemplate.opsForValue().set(key, json, ttl, TimeUnit.MINUTES); + log.debug("가주문 저장: orderId={}, ttl={}분", orderId, ttl); + } catch (Exception e) { + log.error("가주문 Redis 저장 실패: orderId={}", orderId, e); + throw new RuntimeException("가주문 저장 실패", e); + } + } + + public Optional> findByOrderId(Long orderId) { + try { + String key = KEY_PREFIX + orderId; + String json = readTemplate.opsForValue().get(key); + if (json == null) { + return Optional.empty(); + } + @SuppressWarnings("unchecked") + Map data = objectMapper.readValue(json, Map.class); + return Optional.of(data); + } catch (Exception e) { + log.warn("가주문 Redis 조회 실패: orderId={}", orderId, e); + return Optional.empty(); + } + } + + public void deleteByOrderId(Long orderId) { + try { + String key = KEY_PREFIX + orderId; + writeTemplate.delete(key); + log.debug("가주문 삭제: orderId={}", orderId); + } catch (Exception e) { + log.warn("가주문 Redis 삭제 실패: orderId={}", orderId, e); + } + } + + public boolean exists(Long orderId) { + try { + String key = KEY_PREFIX + orderId; + return Boolean.TRUE.equals(readTemplate.hasKey(key)); + } catch (Exception e) { + log.warn("가주문 Redis 존재 확인 실패: orderId={}", orderId, e); + return false; + } + } + + /** + * 모든 가주문 orderId 목록을 반환한다. + * ProvisionalOrderExpiryScheduler가 TTL 만료 선제 정리에 사용. + */ + public Set getAllOrderIds() { + try { + Set orderIds = new HashSet<>(); + ScanOptions options = ScanOptions.scanOptions() + .match(KEY_PREFIX + "*") + .count(100) + .build(); + try (Cursor cursor = readTemplate.scan(options)) { + while (cursor.hasNext()) { + String key = cursor.next(); + orderIds.add(Long.parseLong(key.substring(KEY_PREFIX.length()))); + } + } + return orderIds; + } catch (Exception e) { + log.warn("가주문 목록 조회 실패", e); + return Collections.emptySet(); + } + } + + /** + * 가주문의 남은 TTL(초)을 반환한다. + * + * @return TTL 초, 키가 없으면 -2, TTL 없으면 -1 + */ + public long getTtlSeconds(Long orderId) { + try { + String key = KEY_PREFIX + orderId; + Long ttl = readTemplate.getExpire(key, TimeUnit.SECONDS); + return ttl != null ? ttl : -2; + } catch (Exception e) { + log.warn("가주문 TTL 조회 실패: orderId={}", orderId, e); + return -2; + } + } + + /** + * TTL Jitter: 30분 ±5분 (25~35분). + * 동시 만료에 의한 Redis Thundering Herd 방지. + */ + long calculateTtlWithJitter() { + long jitter = ThreadLocalRandom.current().nextLong(-JITTER_MINUTES, JITTER_MINUTES + 1); + return BASE_TTL_MINUTES + jitter; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/redis/StockReservationRedisRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/redis/StockReservationRedisRepository.java new file mode 100644 index 0000000000..d93e232a35 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/redis/StockReservationRedisRepository.java @@ -0,0 +1,75 @@ +package com.loopers.infrastructure.redis; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +/** + * 재고 예약 Redis 저장소. + * + *

가주문 시 Redis DECR로 재고 선점, 결제 실패/취소 시 INCR로 복원. + * 결제 완료 시 DB 재고 차감 + Redis DEL.

+ * + *

Redis-DB 재고 정합성은 Phase 3에서 Lua Script v2 배치로 보정.

+ * + * @see Option C: Redis Reservation + DB Confirmation + */ +@Slf4j +@Component +public class StockReservationRedisRepository { + + private static final String KEY_PREFIX = "stock:"; + + private final RedisTemplate readTemplate; + private final RedisTemplate writeTemplate; + + public StockReservationRedisRepository( + RedisTemplate readTemplate, + @Qualifier("redisTemplateMaster") RedisTemplate writeTemplate + ) { + this.readTemplate = readTemplate; + this.writeTemplate = writeTemplate; + } + + /** + * 재고 감소 (예약). 음수 방지는 호출자 책임. + * + * @return 감소 후 재고량 + */ + public Long decrease(Long productId, int quantity) { + String key = KEY_PREFIX + productId; + Long result = writeTemplate.opsForValue().decrement(key, quantity); + log.debug("재고 예약: productId={}, quantity={}, remaining={}", productId, quantity, result); + return result; + } + + /** + * 재고 복원 (결제 실패/취소). + * + * @return 복원 후 재고량 + */ + public Long increase(Long productId, int quantity) { + String key = KEY_PREFIX + productId; + Long result = writeTemplate.opsForValue().increment(key, quantity); + log.debug("재고 복원: productId={}, quantity={}, remaining={}", productId, quantity, result); + return result; + } + + /** + * 현재 재고량 조회. + */ + public Long getStock(Long productId) { + String key = KEY_PREFIX + productId; + String value = readTemplate.opsForValue().get(key); + return value != null ? Long.parseLong(value) : null; + } + + /** + * 재고 초기화 (DB 동기화용). + */ + public void setStock(Long productId, long quantity) { + String key = KEY_PREFIX + productId; + writeTemplate.opsForValue().set(key, String.valueOf(quantity)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/redis/WaitingQueueRedisRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/redis/WaitingQueueRedisRepository.java new file mode 100644 index 0000000000..e8447ee646 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/redis/WaitingQueueRedisRepository.java @@ -0,0 +1,137 @@ +package com.loopers.infrastructure.redis; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ZSetOperations.TypedTuple; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.stereotype.Component; + +import java.util.Collections; +import java.util.List; +import java.util.Set; + +/** + * 주문 대기열 Redis 저장소. + * + *

Sorted Set을 활용하여 FIFO 대기열을 구현한다. + * Score는 진입 시각(millis)으로 설정하여 선착순 보장.

+ */ +@Slf4j +@Component +public class WaitingQueueRedisRepository { + + private static final String KEY = "queue:waiting:order"; + private static final String TOKEN_KEY_PREFIX = "queue:token:"; + + /** + * ZPOPMIN + 토큰 발급을 원자적으로 실행하는 Lua 스크립트. + * + *

KEYS[1] = queue:waiting:order

+ *

ARGV[1] = 배치 크기, ARGV[2] = 토큰 TTL(초)

+ *

반환: 발급된 memberId 목록

+ */ + private static final String POP_AND_ISSUE_SCRIPT = + "local members = redis.call('ZPOPMIN', KEYS[1], ARGV[1]) " + + "local issued = {} " + + "for i = 1, #members, 2 do " + + " local memberId = members[i] " + + " redis.call('SET', '" + TOKEN_KEY_PREFIX + "' .. memberId, '1', 'EX', ARGV[2]) " + + " issued[#issued + 1] = memberId " + + "end " + + "return issued"; + + private final DefaultRedisScript popAndIssueScript; + private final long tokenTtlSeconds; + private final RedisTemplate readTemplate; + private final RedisTemplate writeTemplate; + + public WaitingQueueRedisRepository( + @Value("${queue.token.ttl-seconds:900}") long tokenTtlSeconds, + RedisTemplate readTemplate, + @Qualifier("redisTemplateMaster") RedisTemplate writeTemplate + ) { + this.tokenTtlSeconds = tokenTtlSeconds; + this.readTemplate = readTemplate; + this.writeTemplate = writeTemplate; + this.popAndIssueScript = new DefaultRedisScript<>(POP_AND_ISSUE_SCRIPT, List.class); + } + + /** + * 대기열 진입. 중복 시 기존 순번 유지 (ZADD NX). + * + * @return true: 신규 진입, false: 이미 대기 중 + */ + public boolean add(Long memberId) { + Boolean added = writeTemplate.opsForZSet() + .addIfAbsent(KEY, String.valueOf(memberId), System.currentTimeMillis()); + return Boolean.TRUE.equals(added); + } + + /** + * 현재 순번 조회 (0-based). + * + * @return 순번 (큐에 없으면 null) + */ + public Long getRank(Long memberId) { + return readTemplate.opsForZSet().rank(KEY, String.valueOf(memberId)); + } + + /** + * 전체 대기 인원. + */ + public long size() { + Long size = readTemplate.opsForZSet().zCard(KEY); + return size != null ? size : 0; + } + + /** + * 앞에서 N명 꺼내기 (ZPOPMIN). + */ + public Set> popMin(int count) { + Set> result = writeTemplate.opsForZSet().popMin(KEY, count); + return result != null ? result : Collections.emptySet(); + } + + /** + * 대기열에서 N명을 꺼내면서 동시에 토큰을 발급한다 (Lua 스크립트, 원자적). + * + *

ZPOPMIN과 SET EX를 하나의 Lua 스크립트로 실행하여, + * "대기열에서 빠짐 = 토큰 발급됨"을 보장한다. + * 중간에 서버 크래시가 발생해도 유저가 유실되지 않는다.

+ * + * @return 토큰이 발급된 memberId 목록 + */ + @SuppressWarnings("unchecked") + public List popMinAndIssueTokens(int count) { + List result = writeTemplate.execute( + popAndIssueScript, + List.of(KEY), + String.valueOf(count), + String.valueOf(tokenTtlSeconds) + ); + return result != null ? result : Collections.emptyList(); + } + + /** + * 대기 시간 초과 엔트리 일괄 제거. + * + *

score(진입 시각 millis) 기준으로 cutoff 이전에 진입한 엔트리를 제거한다. + * ZREMRANGEBYSCORE queue:waiting:order -inf {cutoffTimeMillis}

+ * + * @return 제거된 엔트리 수 + */ + public long removeExpiredEntries(long cutoffTimeMillis) { + Long removed = writeTemplate.opsForZSet() + .removeRangeByScore(KEY, Double.NEGATIVE_INFINITY, cutoffTimeMillis); + return removed != null ? removed : 0; + } + + /** + * 특정 유저 제거. + */ + public void remove(Long memberId) { + writeTemplate.opsForZSet().remove(KEY, String.valueOf(memberId)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/resilience/PaymentRateLimiterConfig.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/resilience/PaymentRateLimiterConfig.java new file mode 100644 index 0000000000..1672b27d49 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/resilience/PaymentRateLimiterConfig.java @@ -0,0 +1,17 @@ +package com.loopers.infrastructure.resilience; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class PaymentRateLimiterConfig { + + /** + * 결제 요청 전용 Rate Limiter — 50 req/sec. + * PG 계약 TPS를 정확히 지키기 위해 Sliding Window Counter 사용. + */ + @Bean + public SlidingWindowRateLimiter paymentRateLimiter() { + return new SlidingWindowRateLimiter(50, 1000); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/resilience/PaymentRateLimiterInterceptor.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/resilience/PaymentRateLimiterInterceptor.java new file mode 100644 index 0000000000..44169e81f3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/resilience/PaymentRateLimiterInterceptor.java @@ -0,0 +1,39 @@ +package com.loopers.infrastructure.resilience; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.stereotype.Component; + +/** + * 결제 요청 Rate Limiter AOP. + * + *

실행 순서: SlidingWindowRateLimiter → Retry → CircuitBreaker → PgClient

+ * + *

Rate Limiter 거부는 CB에 기록하지 않는다: + * 트래픽 초과 ≠ PG 장애. CB에 기록하면 트래픽만 많아도 CB Open → 오작동.

+ * + * @see 실행 순서 + */ +@Slf4j +@Aspect +@Component +@RequiredArgsConstructor +public class PaymentRateLimiterInterceptor { + + private final SlidingWindowRateLimiter paymentRateLimiter; + + @Around("execution(* com.loopers.application.payment.PaymentFacade.requestPayment(..))") + public Object checkRateLimit(ProceedingJoinPoint joinPoint) throws Throwable { + if (!paymentRateLimiter.tryAcquire()) { + log.warn("결제 요청 Rate Limit 초과 — 429 응답"); + throw new CoreException(ErrorType.TOO_MANY_REQUESTS, + "결제 요청이 너무 많습니다. 잠시 후 다시 시도해주세요."); + } + return joinPoint.proceed(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/resilience/ProgressiveBackoffCustomizer.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/resilience/ProgressiveBackoffCustomizer.java new file mode 100644 index 0000000000..a03fa6ce96 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/resilience/ProgressiveBackoffCustomizer.java @@ -0,0 +1,103 @@ +package com.loopers.infrastructure.resilience; + +import io.github.resilience4j.circuitbreaker.CircuitBreaker; +import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry; +import io.github.resilience4j.circuitbreaker.event.CircuitBreakerOnStateTransitionEvent; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Progressive Backoff — CB Open 반복 시 대기 시간을 점진적으로 증가시킨다. + * + *
+ * 1차 Open: 5초 → Half-Open
+ * 실패 → 2차 Open: 10초
+ * 실패 → 3차 Open: 20초
+ * 실패 → 4차 Open: 40초
+ * 실패 → 5차+ Open: 60초 (cap)
+ * 성공 (Closed) → 카운트 리셋
+ * 
+ * + *

한계: Resilience4j의 wait-duration-in-open-state는 정적 설정이다. + * 현재 구현은 이벤트 로깅 + 대기 시간 계산을 제공하며, + * PgHealthChecker 스케줄러가 이 대기 시간을 참조하여 Health Check 간격을 조정한다.

+ * + * @see Progressive Backoff 설계 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ProgressiveBackoffCustomizer { + + private static final long BASE_WAIT_SECONDS = 5; + private static final long MAX_WAIT_SECONDS = 60; + + private final CircuitBreakerRegistry circuitBreakerRegistry; + private final Map openCountMap = new ConcurrentHashMap<>(); + + @PostConstruct + public void customize() { + circuitBreakerRegistry.getAllCircuitBreakers().forEach(this::registerEventListener); + + // 새로 등록되는 CB에도 리스너 추가 + circuitBreakerRegistry.getEventPublisher() + .onEntryAdded(event -> registerEventListener(event.getAddedEntry())); + } + + private void registerEventListener(CircuitBreaker cb) { + cb.getEventPublisher() + .onStateTransition(event -> handleTransition(cb.getName(), event)); + } + + private void handleTransition(String cbName, CircuitBreakerOnStateTransitionEvent event) { + CircuitBreaker.StateTransition transition = event.getStateTransition(); + + switch (transition) { + case HALF_OPEN_TO_OPEN -> { + // Half-Open 실패 → 다시 Open — 카운트 증가 + int count = openCountMap.computeIfAbsent(cbName, k -> new AtomicInteger(0)) + .incrementAndGet(); + Duration nextWait = calculateWaitDuration(count); + log.warn("CB [{}] Half-Open → Open ({}회차), 다음 대기: {}초", + cbName, count, nextWait.getSeconds()); + } + case HALF_OPEN_TO_CLOSED -> { + // 복구 성공 → 카운트 리셋 + openCountMap.computeIfAbsent(cbName, k -> new AtomicInteger(0)).set(0); + log.info("CB [{}] 복구 완료 (Closed), Progressive Backoff 카운트 리셋", cbName); + } + case CLOSED_TO_OPEN -> { + log.warn("CB [{}] Closed → Open", cbName); + } + default -> {} + } + } + + /** + * CB의 현재 Progressive Backoff 대기 시간을 반환한다. + */ + public Duration getWaitDuration(String cbName) { + int count = openCountMap.getOrDefault(cbName, new AtomicInteger(0)).get(); + return calculateWaitDuration(count); + } + + /** + * CB의 현재 Open 카운트를 반환한다. + */ + public int getOpenCount(String cbName) { + return openCountMap.getOrDefault(cbName, new AtomicInteger(0)).get(); + } + + private Duration calculateWaitDuration(int openCount) { + int capped = Math.min(openCount, 20); + long seconds = Math.min(BASE_WAIT_SECONDS * (1L << capped), MAX_WAIT_SECONDS); + return Duration.ofSeconds(seconds); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/resilience/QueueFallbackRateLimiterConfig.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/resilience/QueueFallbackRateLimiterConfig.java new file mode 100644 index 0000000000..ed74540a4e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/resilience/QueueFallbackRateLimiterConfig.java @@ -0,0 +1,24 @@ +package com.loopers.infrastructure.resilience; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class QueueFallbackRateLimiterConfig { + + /** + * Redis 장애 시 EntryTokenInterceptor가 사용하는 로컬 Rate Limiter. + * + *

Rate Limit = 80 req/sec (정상 모드 입장 속도와 동일). + * Redis 없이도 DB 커넥션 풀 보호를 유지한다.

+ * + * @see com.loopers.infrastructure.queue.EntryTokenInterceptor + */ + @Bean + public SlidingWindowRateLimiter queueFallbackRateLimiter( + @Value("${queue.fallback.rate-limit:80}") int rateLimit + ) { + return new SlidingWindowRateLimiter(rateLimit, 1000); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/resilience/SlidingWindowRateLimiter.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/resilience/SlidingWindowRateLimiter.java new file mode 100644 index 0000000000..ed1b3fbf2d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/resilience/SlidingWindowRateLimiter.java @@ -0,0 +1,70 @@ +package com.loopers.infrastructure.resilience; + +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Sliding Window Counter 기반 Rate Limiter. + * + *

Fixed Window의 경계 돌파(Boundary Burst) 문제 해결: + * Fixed Window는 윈도우 경계에서 최대 2배(100건) burst 가능. + * Sliding Window Counter는 어떤 1초 구간에서도 정확히 limit 이하 보장.

+ * + *

계산식: prevWeight × prevCount + currCount < limit

+ *

prevWeight = max(0, 1 - (now - currentWindowStart) / windowSizeMs)

+ * + * @see Rate Limiter 설계 + */ +public class SlidingWindowRateLimiter { + + private final int limit; + private final long windowSizeMs; + + private final AtomicLong prevWindowStart = new AtomicLong(0); + private final AtomicInteger prevWindowCount = new AtomicInteger(0); + private final AtomicLong currWindowStart = new AtomicLong(0); + private final AtomicInteger currWindowCount = new AtomicInteger(0); + + public SlidingWindowRateLimiter(int limit, long windowSizeMs) { + this.limit = limit; + this.windowSizeMs = windowSizeMs; + } + + /** + * 요청 허용 여부를 판단한다. + * + * @return true: 허용, false: 거부 (429 Too Many Requests) + */ + public synchronized boolean tryAcquire() { + long now = System.currentTimeMillis(); + long currentWindow = now / windowSizeMs * windowSizeMs; + + if (currentWindow != currWindowStart.get()) { + prevWindowCount.set(currWindowCount.get()); + prevWindowStart.set(currWindowStart.get()); + currWindowCount.set(0); + currWindowStart.set(currentWindow); + } + + double elapsed = (double) (now - currentWindow) / windowSizeMs; + double prevWeight = Math.max(0, 1.0 - elapsed); + double weightedCount = prevWeight * prevWindowCount.get() + currWindowCount.get(); + + if (weightedCount < limit) { + currWindowCount.incrementAndGet(); + return true; + } + return false; + } + + /** + * 테스트용 — 현재 가중 카운트를 반환한다. + */ + double getWeightedCount() { + long now = System.currentTimeMillis(); + long currentWindow = now / windowSizeMs * windowSizeMs; + double elapsed = (double) (now - currentWindow) / windowSizeMs; + double prevWeight = Math.max(0, 1.0 - elapsed); + return prevWeight * prevWindowCount.get() + currWindowCount.get(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/scheduler/CallbackDlqScheduler.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/scheduler/CallbackDlqScheduler.java new file mode 100644 index 0000000000..9b003e1862 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/scheduler/CallbackDlqScheduler.java @@ -0,0 +1,66 @@ +package com.loopers.infrastructure.scheduler; + +import com.loopers.application.payment.PaymentRecoveryService; +import com.loopers.domain.payment.CallbackInbox; +import com.loopers.domain.payment.CallbackInboxRepository; +import com.loopers.domain.payment.CallbackInboxStatus; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.ZonedDateTime; +import java.util.List; + +/** + * Callback DLQ 재처리 스케줄러. + * + *

RECEIVED 상태인 콜백 중 30초 이상 미처리된 건을 재처리. + * 최대 재시도 3회 초과 시 FAILED 처리.

+ * + * @see Callback Inbox DLQ + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class CallbackDlqScheduler { + + private static final int THRESHOLD_SECONDS = 30; + private static final int MAX_RETRY = 3; + + private final CallbackInboxRepository callbackInboxRepository; + private final PaymentRecoveryService paymentRecoveryService; + + @Scheduled(fixedRate = 30_000) + public void reprocessFailedCallbacks() { + List receivedInboxes = callbackInboxRepository.findAllByStatus(CallbackInboxStatus.RECEIVED); + ZonedDateTime threshold = ZonedDateTime.now().minusSeconds(THRESHOLD_SECONDS); + + for (CallbackInbox inbox : receivedInboxes) { + if (inbox.getCreatedAt() != null && inbox.getCreatedAt().isBefore(threshold)) { + reprocessCallback(inbox); + } + } + } + + private void reprocessCallback(CallbackInbox inbox) { + if (inbox.getRetryCount() >= MAX_RETRY) { + inbox.markFailed("최대 재시도 횟수 초과"); + callbackInboxRepository.save(inbox); + log.error("DLQ 최대 재시도 초과 — FAILED: inboxId={}, transactionKey={}", + inbox.getId(), inbox.getTransactionKey()); + return; + } + + try { + paymentRecoveryService.processCallback( + inbox.getTransactionKey(), inbox.getPgStatus(), inbox.getPayload()); + log.info("DLQ 재처리 성공: inboxId={}, transactionKey={}", + inbox.getId(), inbox.getTransactionKey()); + } catch (Exception e) { + inbox.markFailed(e.getMessage()); + callbackInboxRepository.save(inbox); + log.warn("DLQ 재처리 실패: inboxId={}, error={}", inbox.getId(), e.getMessage()); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/scheduler/OutboxPollerScheduler.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/scheduler/OutboxPollerScheduler.java new file mode 100644 index 0000000000..81dc1461fe --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/scheduler/OutboxPollerScheduler.java @@ -0,0 +1,121 @@ +package com.loopers.infrastructure.scheduler; + +import com.loopers.domain.payment.*; +import com.loopers.infrastructure.pg.*; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * Outbox 폴러 — 5초 주기. + * + *

TX-1에서 저장된 Outbox(PENDING)를 감지하여 PG 호출 누락을 수 초 내 재시도. + * 배치 복구(1분 주기)보다 빠른 1차 복구 경로.

+ * + *

폴러 동작:

+ *
    + *
  1. Outbox(PENDING) 전건 조회
  2. + *
  3. Payment 현재 상태 확인 → 이미 PAID/FAILED면 Outbox PROCESSED
  4. + *
  5. PG 상태 확인 (GET /payments?orderId=xxx) → 기록 있으면 Outbox PROCESSED
  6. + *
  7. PG 기록 없음 → PG 결제 요청(POST) 실행
  8. + *
  9. retry_count 증가, 최대 3회 초과 → FAILED + 운영 알림
  10. + *
+ * + * @see Outbox 폴러 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class OutboxPollerScheduler { + + private static final int MAX_RETRY = 3; + + private final PaymentOutboxRepository outboxRepository; + private final PaymentRepository paymentRepository; + private final PgRouter pgRouter; + + @Scheduled(fixedRate = 5_000) + public void pollOutbox() { + List pendingOutboxes = outboxRepository.findAllByStatus(PaymentOutboxStatus.PENDING); + + for (PaymentOutbox outbox : pendingOutboxes) { + try { + processOutbox(outbox); + } catch (Exception e) { + log.error("Outbox 처리 실패: outboxId={}, error={}", outbox.getId(), e.getMessage()); + outbox.incrementRetry(); + if (outbox.getRetryCount() > MAX_RETRY) { + outbox.markFailed(); + log.error("Outbox 최대 재시도 초과 — FAILED: outboxId={}, paymentId={}", + outbox.getId(), outbox.getPaymentId()); + } + outboxRepository.save(outbox); + } + } + } + + private void processOutbox(PaymentOutbox outbox) { + // 1. Payment 현재 상태 확인 + PaymentModel payment = paymentRepository.findById(outbox.getPaymentId()).orElse(null); + if (payment == null) { + outbox.markFailed(); + outboxRepository.save(outbox); + log.warn("Outbox — Payment 없음: outboxId={}, paymentId={}", outbox.getId(), outbox.getPaymentId()); + return; + } + + // 이미 최종 상태 → Outbox PROCESSED + if (payment.getStatus() == PaymentStatus.PAID || payment.getStatus() == PaymentStatus.FAILED) { + outbox.markProcessed(); + outboxRepository.save(outbox); + log.info("Outbox — 이미 해결됨: outboxId={}, paymentStatus={}", outbox.getId(), payment.getStatus()); + return; + } + + // 이미 PENDING (PG 호출 완료) → Outbox PROCESSED + if (payment.getStatus() == PaymentStatus.PENDING) { + outbox.markProcessed(); + outboxRepository.save(outbox); + log.info("Outbox — PG 호출 완료: outboxId={}, paymentStatus=PENDING", outbox.getId()); + return; + } + + // 2. PG 상태 확인 (orderId로 조회 — 멱등성 보장) + try { + PgPaymentStatusResponse pgStatus = pgRouter.getPaymentByOrderId( + String.valueOf(outbox.getOrderId()), + pgRouter.getPrimaryClient().getProviderName()); + + if (pgStatus != null && pgStatus.transactionKey() != null) { + // PG에 기록 존재 → Payment 상태 업데이트 + Outbox PROCESSED + payment.markPending(pgStatus.transactionKey(), + pgRouter.getPrimaryClient().getProviderName()); + paymentRepository.save(payment); + outbox.markProcessed(); + outboxRepository.save(outbox); + log.info("Outbox — PG 기록 발견: outboxId={}, transactionKey={}", + outbox.getId(), pgStatus.transactionKey()); + return; + } + } catch (Exception e) { + log.debug("PG 상태 확인 실패 — PG 호출 진행: outboxId={}", outbox.getId()); + } + + // 3. PG 기록 없음 → PG 결제 요청 + PgPaymentRequest pgRequest = PgPaymentRequest.of( + outbox.getOrderId(), payment.getCardType(), payment.getCardNo(), + payment.getAmount(), "http://localhost:8080/api/v1/payments/callback"); + + PgPaymentResponse pgResponse = pgRouter.requestPayment(pgRequest); + payment.markPending(pgResponse.transactionKey(), pgResponse.pgProvider()); + paymentRepository.save(payment); + outbox.markProcessed(); + outboxRepository.save(outbox); + + log.info("Outbox — PG 결제 요청 완료: outboxId={}, transactionKey={}, provider={}", + outbox.getId(), pgResponse.transactionKey(), pgResponse.pgProvider()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/scheduler/ProvisionalOrderExpiryScheduler.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/scheduler/ProvisionalOrderExpiryScheduler.java new file mode 100644 index 0000000000..ed4818d16c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/scheduler/ProvisionalOrderExpiryScheduler.java @@ -0,0 +1,106 @@ +package com.loopers.infrastructure.scheduler; + +import com.loopers.infrastructure.redis.ProvisionalOrderRedisRepository; +import com.loopers.infrastructure.redis.StockReservationRedisRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 가주문 선제 만료 배치 — Proactive Expiry Scanner. + * + *

TTL < 30초인 가주문을 선제적으로 정리: 재고 복원(INCR) + 가주문 삭제(DEL). + * 결제 미진행 가주문이 TTL 만료되면 Key만 삭제되고 재고는 미복원되는 문제(G7) 해결.

+ * + *

30초 주기, 비용: SMEMBERS ~5건 + TTL 확인 ~5건 = ~2ms/회, 부하율 0.007%

+ * + * @see Proactive Expiry Scanner + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ProvisionalOrderExpiryScheduler { + + private static final long EXPIRY_THRESHOLD_SECONDS = 30; + + private final ProvisionalOrderRedisRepository provisionalOrderRedisRepository; + private final StockReservationRedisRepository stockReservationRedisRepository; + + @Scheduled(fixedRate = 30_000) + public void cleanupExpiringOrders() { + Set orderIds = provisionalOrderRedisRepository.getAllOrderIds(); + if (orderIds.isEmpty()) return; + + int cleanedCount = 0; + + for (Long orderId : orderIds) { + try { + long ttl = provisionalOrderRedisRepository.getTtlSeconds(orderId); + + if (ttl == -2) { + // 키가 이미 만료됨 → 다음 사이클에서 자연 정리 + continue; + } + + if (ttl >= 0 && ttl < EXPIRY_THRESHOLD_SECONDS) { + cleanupProvisionalOrder(orderId); + cleanedCount++; + } + } catch (Exception e) { + log.warn("가주문 선제 정리 실패 (건너뜀): orderId={}, error={}", orderId, e.getMessage()); + } + } + + if (cleanedCount > 0) { + log.info("가주문 선제 정리 완료: {}건", cleanedCount); + } + } + + private void cleanupProvisionalOrder(Long orderId) { + // 1. 가주문 데이터에서 상품/수량 정보 조회 + provisionalOrderRedisRepository.findByOrderId(orderId).ifPresent(orderData -> { + restoreStock(orderId, orderData); + }); + + // 2. 가주문 삭제 + provisionalOrderRedisRepository.deleteByOrderId(orderId); + log.info("가주문 선제 정리: orderId={}, TTL 만료 임박", orderId); + } + + @SuppressWarnings("unchecked") + private void restoreStock(Long orderId, Map orderData) { + Object itemsObj = orderData.get("items"); + if (itemsObj instanceof List items) { + for (Object item : items) { + if (item instanceof Map itemMap) { + Long productId = toLong(itemMap.get("productId")); + Integer quantity = toInt(itemMap.get("quantity")); + if (productId != null && quantity != null) { + stockReservationRedisRepository.increase(productId, quantity); + log.debug("재고 복원: orderId={}, productId={}, quantity=+{}", + orderId, productId, quantity); + } + } + } + } + } + + private Long toLong(Object value) { + if (value instanceof Long l) return l; + if (value instanceof Integer i) return i.longValue(); + if (value instanceof String s) return Long.parseLong(s); + return null; + } + + private int toInt(Object value) { + if (value instanceof Integer i) return i; + if (value instanceof Long l) return l.intValue(); + if (value instanceof String s) return Integer.parseInt(s); + return 0; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/scheduler/QueueAdmissionScheduler.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/scheduler/QueueAdmissionScheduler.java new file mode 100644 index 0000000000..678a8ec74a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/scheduler/QueueAdmissionScheduler.java @@ -0,0 +1,118 @@ +package com.loopers.infrastructure.scheduler; + +import com.loopers.infrastructure.queue.QueueSseEmitterRegistry; +import com.loopers.infrastructure.redis.WaitingQueueRedisRepository; +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; + +/** + * 대기열 입장 스케줄러. + * + *

100ms 주기로 대기열 앞에서 8명을 꺼내 입장 토큰을 발급한다. + * 초당 80명 입장 = p99(358ms) 기준 28.6 커넥션 (HikariCP 풀 40의 72%).

+ * + *

산술 근거: 8명/배치 × 10회/초 = 80 TPS, + * 80 × 0.358s(p99) = 28.6 동시 커넥션 (풀 40의 72%)

+ * + *

타임아웃 정리: 10초 주기로 600초(10분) 이상 대기한 엔트리를 제거한다. + * max_queue = 80 TPS × 600초 = 48,000명.

+ * + *

Redis 장애 대비: 에러 로그를 10초에 1회로 쓰로틀링한다. + * admitUsers()가 100ms마다 실행되므로, 장애 시 분당 600회 예외가 발생하는데, + * 매번 로그를 찍으면 로그 시스템에 부하가 걸리고 중요한 에러가 묻힌다.

+ */ +@Slf4j +@Component +public class QueueAdmissionScheduler { + + private static final int BATCH_SIZE = 8; + private static final long MAX_WAIT_SECONDS = 600; + private static final long ERROR_LOG_INTERVAL_MILLIS = 10_000; + + private final WaitingQueueRedisRepository waitingQueueRedisRepository; + private final QueueSseEmitterRegistry sseEmitterRegistry; + + private final Counter admissionCounter; + private final Counter admissionErrorCounter; + private final Counter cleanupRemovedCounter; + private final AtomicLong waitingSize; + + private final AtomicLong lastAdmitErrorLogTime = new AtomicLong(0); + private final AtomicLong lastCleanupErrorLogTime = new AtomicLong(0); + + public QueueAdmissionScheduler( + WaitingQueueRedisRepository waitingQueueRedisRepository, + QueueSseEmitterRegistry sseEmitterRegistry, + MeterRegistry meterRegistry + ) { + this.waitingQueueRedisRepository = waitingQueueRedisRepository; + this.sseEmitterRegistry = sseEmitterRegistry; + + this.admissionCounter = Counter.builder("queue.admission.count") + .description("입장 처리된 유저 수") + .register(meterRegistry); + this.admissionErrorCounter = Counter.builder("queue.admission.errors") + .description("Redis 장애 횟수") + .register(meterRegistry); + this.cleanupRemovedCounter = Counter.builder("queue.cleanup.removed") + .description("타임아웃 정리된 유저 수") + .register(meterRegistry); + this.waitingSize = new AtomicLong(0); + meterRegistry.gauge("queue.waiting.size", waitingSize); + } + + @Scheduled(fixedRate = 100) + public void admitUsers() { + try { + List admitted = waitingQueueRedisRepository.popMinAndIssueTokens(BATCH_SIZE); + if (admitted.isEmpty()) { + return; + } + admissionCounter.increment(admitted.size()); + sseEmitterRegistry.onAdmission(admitted, admitted.size()); + log.debug("대기열 입장 처리: {}명", admitted.size()); + } catch (Exception e) { + admissionErrorCounter.increment(); + throttledWarn(lastAdmitErrorLogTime, "입장 처리", e); + } finally { + try { + waitingSize.set(waitingQueueRedisRepository.size()); + } catch (Exception ignored) { + // 대기열 크기 조회 실패는 무시 (메트릭 갱신 실패일 뿐) + } + } + } + + @Scheduled(fixedRate = 10_000) + public void removeExpiredEntries() { + try { + long cutoff = System.currentTimeMillis() - (MAX_WAIT_SECONDS * 1000); + long removed = waitingQueueRedisRepository.removeExpiredEntries(cutoff); + if (removed > 0) { + cleanupRemovedCounter.increment(removed); + log.info("대기열 타임아웃 정리: {}명 제거", removed); + } + } catch (Exception e) { + throttledWarn(lastCleanupErrorLogTime, "타임아웃 정리", e); + } + } + + @Scheduled(fixedRate = 30_000) + public void sendSseHeartbeat() { + sseEmitterRegistry.sendHeartbeat(); + } + + private void throttledWarn(AtomicLong lastLogTime, String operation, Exception e) { + long now = System.currentTimeMillis(); + long last = lastLogTime.get(); + if (now - last >= ERROR_LOG_INTERVAL_MILLIS && lastLogTime.compareAndSet(last, now)) { + log.warn("대기열 {} Redis 장애: {}", operation, e.getMessage()); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/scheduler/StockReconcileScheduler.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/scheduler/StockReconcileScheduler.java new file mode 100644 index 0000000000..f866dae134 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/scheduler/StockReconcileScheduler.java @@ -0,0 +1,68 @@ +package com.loopers.infrastructure.scheduler; + +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.infrastructure.redis.StockReservationRedisRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * Redis-DB 재고 정합성 배치. + * + *

30초 주기로 DB 재고와 Redis 재고를 비교하고, 불일치 시 DB 기준으로 Redis를 보정한다.

+ * + *

Lua Script v2로 원자적 보정: GET(현재 Redis 재고) + SET(DB 기준 보정값). + * Lost Update(SET 중 DECR 유실) 방지를 위해 Lua 스크립트 사용.

+ * + *

비용: ~5개 상품 × (1ms GET + 1ms SET) = ~10ms / 30s = 0.03% 부하

+ * + * @see Lua Script v2 설계 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class StockReconcileScheduler { + + private final ProductRepository productRepository; + private final StockReservationRedisRepository stockReservationRedisRepository; + + /** + * DB-Redis 재고 정합성 확인 및 보정. + * + *

실제 운영에서는 Lua Script로 원자적 SET을 수행하지만, + * 핵심 로직(비교 → 불일치 감지 → 보정)은 동일하다.

+ */ + @Scheduled(fixedRate = 30_000) + public void reconcileStock() { + log.debug("재고 정합성 배치 시작"); + int mismatchCount = 0; + + for (Product product : productRepository.findAll()) { + Long productId = product.getId(); + int dbStock = product.getStock().getQuantity(); + + Long redisStock = stockReservationRedisRepository.getStock(productId); + if (redisStock == null) { + // Redis에 재고 키 없음 → DB 기준으로 초기화 + stockReservationRedisRepository.setStock(productId, dbStock); + log.info("재고 초기화: productId={}, dbStock={}", productId, dbStock); + mismatchCount++; + continue; + } + + if (redisStock != dbStock) { + long prevRedisStock = redisStock; + stockReservationRedisRepository.setStock(productId, dbStock); + log.info("재고 불일치 보정: productId={}, redis={}→{}, db={}", + productId, prevRedisStock, dbStock, dbStock); + mismatchCount++; + } + } + + if (mismatchCount > 0) { + log.info("재고 정합성 배치 완료: {}건 보정", mismatchCount); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/scheduler/WalRecoveryScheduler.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/scheduler/WalRecoveryScheduler.java new file mode 100644 index 0000000000..406bcc46d0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/scheduler/WalRecoveryScheduler.java @@ -0,0 +1,91 @@ +package com.loopers.infrastructure.scheduler; + +import com.loopers.domain.payment.PaymentModel; +import com.loopers.domain.payment.PaymentRepository; +import com.loopers.domain.payment.PaymentStatus; +import com.loopers.domain.payment.PaymentStatusHistory; +import com.loopers.domain.payment.PaymentStatusHistoryRepository; +import com.loopers.infrastructure.payment.PaymentWalWriter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +/** + * WAL Recovery 스케줄러 — WAL 파일 스캔 → DB 반영 재시도. + * + *

PG 응답 수신 후 DB 저장 실패 시 WAL에 남아있는 레코드를 복구.

+ * + * @see Local WAL Recovery + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class WalRecoveryScheduler { + + private final PaymentWalWriter walWriter; + private final PaymentRepository paymentRepository; + private final PaymentStatusHistoryRepository historyRepository; + + @Scheduled(fixedRate = 10_000) + public void recoverFromWal() { + List walFiles = walWriter.listWalFiles(); + if (walFiles.isEmpty()) return; + + log.info("WAL Recovery 시작: {}건 발견", walFiles.size()); + + for (Path walFile : walFiles) { + try { + processWalFile(walFile); + } catch (Exception e) { + log.error("WAL Recovery 실패: file={}, error={}", walFile.getFileName(), e.getMessage()); + } + } + } + + private void processWalFile(Path walFile) { + Map walEntry = walWriter.read(walFile); + if (walEntry.isEmpty()) { + walWriter.delete(walFile); + return; + } + + Long orderId = ((Number) walEntry.get("orderId")).longValue(); + String transactionKey = (String) walEntry.get("transactionKey"); + String pgStatus = (String) walEntry.get("pgStatus"); + + // Payment 조회 + PaymentModel payment = paymentRepository.findByTransactionKey(transactionKey) + .or(() -> paymentRepository.findByOrderId(orderId)) + .orElse(null); + + if (payment == null) { + log.warn("WAL Recovery — Payment 없음: orderId={}, transactionKey={}", orderId, transactionKey); + walWriter.delete(walFile); + return; + } + + // 이미 최종 상태면 WAL 삭제 + if (payment.getStatus().isTerminal()) { + walWriter.delete(walFile); + return; + } + + // PG 상태에 따라 Payment 상태 전이 + List allowedStatuses = List.of(PaymentStatus.PENDING, PaymentStatus.UNKNOWN, PaymentStatus.REQUESTED); + PaymentStatus targetStatus = "SUCCESS".equals(pgStatus) ? PaymentStatus.PAID : PaymentStatus.FAILED; + + int affected = paymentRepository.updateStatusConditionally(payment.getId(), targetStatus, allowedStatuses); + if (affected > 0) { + historyRepository.save(PaymentStatusHistory.create( + payment.getId(), payment.getStatus(), targetStatus, "WAL_RECOVERY", null)); + log.info("WAL Recovery 성공: paymentId={}, newStatus={}", payment.getId(), targetStatus); + } + + walWriter.delete(walFile); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java index 20b2809c87..20cf4a3a39 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java @@ -8,6 +8,8 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -46,6 +48,14 @@ public ResponseEntity> handleBadRequest(MissingServletRequestPara return failureResponse(ErrorType.BAD_REQUEST, message); } + @ExceptionHandler + public ResponseEntity> handleBadRequest(MethodArgumentNotValidException e) { + String message = e.getBindingResult().getFieldErrors().stream() + .map(error -> String.format("'%s' %s", error.getField(), error.getDefaultMessage())) + .collect(Collectors.joining(", ")); + return failureResponse(ErrorType.BAD_REQUEST, message); + } + @ExceptionHandler public ResponseEntity> handleBadRequest(HttpMessageNotReadableException e) { String errorMessage; @@ -107,6 +117,12 @@ public ResponseEntity> handleNotFound(NoResourceFoundException e) return failureResponse(ErrorType.NOT_FOUND, null); } + @ExceptionHandler + public ResponseEntity> handleOptimisticLock(ObjectOptimisticLockingFailureException e) { + log.warn("OptimisticLockingFailure : {}", e.getMessage(), e); + return failureResponse(ErrorType.CONFLICT, "다른 요청과 충돌이 발생했습니다. 다시 시도해주세요."); + } + @ExceptionHandler public ResponseEntity> handle(Throwable e) { log.error("Exception : {}", e.getMessage(), e); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminController.java new file mode 100644 index 0000000000..149007c991 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminController.java @@ -0,0 +1,55 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.application.brand.BrandFacade; +import com.loopers.domain.brand.Brand; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api-admin/v1/brands") +public class BrandAdminController { + + private final BrandFacade brandFacade; + + @GetMapping + public ApiResponse> getAllBrands() { + List responses = brandFacade.getAllBrands().stream() + .map(BrandDto.BrandResponse::from) + .toList(); + return ApiResponse.success(responses); + } + + @GetMapping("/{brandId}") + public ApiResponse getBrand(@PathVariable Long brandId) { + Brand brand = brandFacade.getBrand(brandId); + return ApiResponse.success(BrandDto.BrandResponse.from(brand)); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse createBrand(@Valid @RequestBody BrandDto.CreateRequest request) { + Brand brand = brandFacade.createBrand(request.name(), request.description()); + return ApiResponse.success(BrandDto.BrandResponse.from(brand)); + } + + @PutMapping("/{brandId}") + public ApiResponse updateBrand( + @PathVariable Long brandId, + @Valid @RequestBody BrandDto.UpdateRequest request + ) { + Brand brand = brandFacade.updateBrand(brandId, request.name(), request.description()); + return ApiResponse.success(BrandDto.BrandResponse.from(brand)); + } + + @DeleteMapping("/{brandId}") + public ApiResponse deleteBrand(@PathVariable Long brandId) { + brandFacade.deleteBrand(brandId); + return ApiResponse.success(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java new file mode 100644 index 0000000000..b1e439a986 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java @@ -0,0 +1,21 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.application.brand.BrandFacade; +import com.loopers.domain.brand.Brand; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/brands") +public class BrandController { + + private final BrandFacade brandFacade; + + @GetMapping("/{brandId}") + public ApiResponse getBrand(@PathVariable Long brandId) { + Brand brand = brandFacade.getBrand(brandId); + return ApiResponse.success(BrandDto.BrandResponse.from(brand)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandDto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandDto.java new file mode 100644 index 0000000000..65c947bac6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandDto.java @@ -0,0 +1,27 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.domain.brand.Brand; +import jakarta.validation.constraints.NotBlank; + +public class BrandDto { + + public record CreateRequest( + @NotBlank String name, + String description + ) {} + + public record UpdateRequest( + @NotBlank String name, + String description + ) {} + + public record BrandResponse( + Long id, + String name, + String description + ) { + public static BrandResponse from(Brand brand) { + return new BrandResponse(brand.getId(), brand.getName(), brand.getDescription()); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponAdminController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponAdminController.java new file mode 100644 index 0000000000..3450d56fdf --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponAdminController.java @@ -0,0 +1,74 @@ +package com.loopers.interfaces.api.coupon; + +import com.loopers.application.coupon.CouponFacade; +import com.loopers.domain.coupon.Coupon; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import java.time.ZonedDateTime; +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api-admin/v1/coupons") +public class CouponAdminController { + + private final CouponFacade couponFacade; + + @GetMapping + public ApiResponse> getCoupons() { + List responses = couponFacade.getCoupons().stream() + .map(CouponDto.CouponResponse::from) + .toList(); + return ApiResponse.success(responses); + } + + @GetMapping("/{couponId}") + public ApiResponse getCoupon(@PathVariable Long couponId) { + Coupon coupon = couponFacade.getCoupon(couponId); + return ApiResponse.success(CouponDto.CouponResponse.from(coupon)); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse createCoupon( + @Valid @RequestBody CouponDto.CreateRequest request + ) { + Coupon coupon = couponFacade.createCoupon( + request.name(), request.type(), request.value(), + request.minOrderAmount(), request.expiredAt()); + return ApiResponse.success(CouponDto.CouponResponse.from(coupon)); + } + + @PutMapping("/{couponId}") + public ApiResponse updateCoupon( + @PathVariable Long couponId, + @Valid @RequestBody CouponDto.UpdateRequest request + ) { + Coupon coupon = couponFacade.updateCoupon( + couponId, request.name(), request.type(), request.value(), + request.minOrderAmount(), request.expiredAt()); + return ApiResponse.success(CouponDto.CouponResponse.from(coupon)); + } + + @DeleteMapping("/{couponId}") + public ApiResponse deleteCoupon(@PathVariable Long couponId) { + couponFacade.deleteCoupon(couponId); + return ApiResponse.success(); + } + + @GetMapping("/{couponId}/issues") + public ApiResponse> getCouponIssues( + @PathVariable Long couponId + ) { + ZonedDateTime now = couponFacade.now(); + List responses = couponFacade.getCouponIssues(couponId) + .stream() + .map(issue -> CouponDto.CouponIssueResponse.from(issue, now)) + .toList(); + return ApiResponse.success(responses); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponController.java new file mode 100644 index 0000000000..e35ce52bdb --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponController.java @@ -0,0 +1,62 @@ +package com.loopers.interfaces.api.coupon; + +import com.loopers.application.coupon.CouponFacade; +import com.loopers.domain.coupon.CouponIssue; +import com.loopers.domain.coupon.CouponIssueRequestInfo; +import com.loopers.domain.member.Member; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.support.auth.AuthMember; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import java.time.ZonedDateTime; +import java.util.List; + +@RestController +@RequiredArgsConstructor +public class CouponController { + + private final CouponFacade couponFacade; + + @PostMapping("/api/v1/coupons/{couponId}/issue") + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse issueCoupon( + @AuthMember Member member, + @PathVariable Long couponId + ) { + CouponIssue couponIssue = couponFacade.issueCoupon(couponId, member.getId()); + ZonedDateTime now = couponFacade.now(); + return ApiResponse.success(CouponDto.CouponIssueResponse.from(couponIssue, now)); + } + + @PostMapping("/api/v1/coupons/{couponId}/request") + @ResponseStatus(HttpStatus.ACCEPTED) + public ApiResponse requestCouponIssue( + @AuthMember Member member, + @PathVariable Long couponId + ) { + CouponIssueRequestInfo requestInfo = couponFacade.requestCouponIssue(couponId, member.getId()); + return ApiResponse.success(CouponDto.CouponIssueRequestResponse.from(requestInfo)); + } + + @GetMapping("/api/v1/coupons/requests/{requestId}") + public ApiResponse getIssueRequest( + @PathVariable Long requestId + ) { + CouponIssueRequestInfo requestInfo = couponFacade.getIssueRequest(requestId); + return ApiResponse.success(CouponDto.CouponIssueRequestResponse.from(requestInfo)); + } + + @GetMapping("/api/v1/users/me/coupons") + public ApiResponse> getMyCoupons( + @AuthMember Member member + ) { + ZonedDateTime now = couponFacade.now(); + List responses = couponFacade.getMyCoupons(member.getId()) + .stream() + .map(issue -> CouponDto.CouponIssueResponse.from(issue, now)) + .toList(); + return ApiResponse.success(responses); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponDto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponDto.java new file mode 100644 index 0000000000..2e50757726 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponDto.java @@ -0,0 +1,88 @@ +package com.loopers.interfaces.api.coupon; + +import com.loopers.domain.coupon.*; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import java.time.ZonedDateTime; + + +public class CouponDto { + + public record CreateRequest( + @NotBlank String name, + @NotNull DiscountType type, + @Min(1) int value, + @Min(0) int minOrderAmount, + @NotNull ZonedDateTime expiredAt + ) {} + + public record UpdateRequest( + @NotBlank String name, + @NotNull DiscountType type, + @Min(1) int value, + @Min(0) int minOrderAmount, + @NotNull ZonedDateTime expiredAt + ) {} + + public record CouponResponse( + Long id, + String name, + String discountType, + int discountValue, + int minOrderAmount, + ZonedDateTime expiredAt + ) { + public static CouponResponse from(Coupon coupon) { + return new CouponResponse( + coupon.getId(), + coupon.getName(), + coupon.getDiscountType().name(), + coupon.getDiscountValue(), + coupon.getMinOrderAmount(), + coupon.getExpiredAt() + ); + } + } + + public record CouponIssueResponse( + Long id, + Long couponId, + Long memberId, + Long usedOrderId, + String status, + ZonedDateTime expiredAt, + ZonedDateTime createdAt + ) { + public static CouponIssueResponse from(CouponIssue issue, ZonedDateTime now) { + return new CouponIssueResponse( + issue.getId(), + issue.getCouponId(), + issue.getMemberId(), + issue.getUsedOrderId(), + issue.getEffectiveStatus(now).name(), + issue.getExpiredAt(), + issue.getCreatedAt() + ); + } + } + + public record CouponIssueRequestResponse( + Long requestId, + Long couponId, + Long memberId, + String status, + String rejectReason + ) { + public static CouponIssueRequestResponse from(CouponIssueRequestInfo info) { + return new CouponIssueRequestResponse( + info.requestId(), + info.couponId(), + info.memberId(), + info.status().name(), + info.rejectReason() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java deleted file mode 100644 index 219e3101ec..0000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.loopers.interfaces.api.example; - -import com.loopers.interfaces.api.ApiResponse; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.tags.Tag; - -@Tag(name = "Example V1 API", description = "Loopers 예시 API 입니다.") -public interface ExampleV1ApiSpec { - - @Operation( - summary = "예시 조회", - description = "ID로 예시를 조회합니다." - ) - ApiResponse getExample( - @Schema(name = "예시 ID", description = "조회할 예시의 ID") - Long exampleId - ); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java deleted file mode 100644 index 9173760167..0000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.loopers.interfaces.api.example; - -import com.loopers.application.example.ExampleFacade; -import com.loopers.application.example.ExampleInfo; -import com.loopers.interfaces.api.ApiResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RequiredArgsConstructor -@RestController -@RequestMapping("/api/v1/examples") -public class ExampleV1Controller implements ExampleV1ApiSpec { - - private final ExampleFacade exampleFacade; - - @GetMapping("/{exampleId}") - @Override - public ApiResponse getExample( - @PathVariable(value = "exampleId") Long exampleId - ) { - ExampleInfo info = exampleFacade.getExample(exampleId); - ExampleV1Dto.ExampleResponse response = ExampleV1Dto.ExampleResponse.from(info); - return ApiResponse.success(response); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java deleted file mode 100644 index 4ecf0eea5f..0000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.loopers.interfaces.api.example; - -import com.loopers.application.example.ExampleInfo; - -public class ExampleV1Dto { - public record ExampleResponse(Long id, String name, String description) { - public static ExampleResponse from(ExampleInfo info) { - return new ExampleResponse( - info.id(), - info.name(), - info.description() - ); - } - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeController.java new file mode 100644 index 0000000000..a261247f02 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeController.java @@ -0,0 +1,46 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.application.like.LikeFacade; +import com.loopers.domain.like.Like; +import com.loopers.domain.member.Member; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.support.auth.AuthMember; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +public class LikeController { + + private final LikeFacade likeFacade; + + @PostMapping("/api/v1/products/{productId}/likes") + public ApiResponse addLike(@AuthMember Member member, @PathVariable Long productId) { + likeFacade.addLike(member.getId(), productId); + return ApiResponse.success(null); + } + + @DeleteMapping("/api/v1/products/{productId}/likes") + public ApiResponse removeLike(@AuthMember Member member, @PathVariable Long productId) { + likeFacade.removeLike(member.getId(), productId); + return ApiResponse.success(null); + } + + @GetMapping("/api/v1/users/{userId}/likes") + public ApiResponse> getLikes( + @AuthMember Member member, + @PathVariable Long userId + ) { + if (!member.getId().equals(userId)) { + throw new CoreException(ErrorType.FORBIDDEN, "본인의 좋아요 목록만 조회할 수 있습니다."); + } + List responses = likeFacade.getLikesByMemberId(userId).stream() + .map(LikeDto.LikeResponse::from) + .toList(); + return ApiResponse.success(responses); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeDto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeDto.java new file mode 100644 index 0000000000..a7d00431ed --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeDto.java @@ -0,0 +1,16 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.domain.like.Like; + +public class LikeDto { + + public record LikeResponse( + Long id, + Long memberId, + Long productId + ) { + public static LikeResponse from(Like like) { + return new LikeResponse(like.getId(), like.getMemberId(), like.getProductId()); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java new file mode 100644 index 0000000000..3b276748b8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java @@ -0,0 +1,59 @@ +package com.loopers.interfaces.api.member; + +import com.loopers.application.member.MemberFacade; +import com.loopers.domain.member.Member; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.support.auth.AuthMember; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/members") +public class MemberV1Controller { + + private final MemberFacade memberFacade; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse signUp(@Valid @RequestBody MemberV1Dto.SignUpRequest request) { + Member member = memberFacade.register( + request.loginId(), + request.password(), + request.name(), + request.birthDate(), + request.email() + ); + + MemberV1Dto.SignUpResponse response = new MemberV1Dto.SignUpResponse( + member.getId(), + member.getLoginId().value(), + member.getName(), + member.getEmail().value() + ); + + return ApiResponse.success(response); + } + + @GetMapping("/me") + public ApiResponse getMyInfo(@AuthMember Member member) { + return ApiResponse.success(MemberV1Dto.MyInfoResponse.from(member)); + } + + @PatchMapping("/me/password") + public ApiResponse changePassword( + @AuthMember Member member, + @Valid @RequestBody MemberV1Dto.ChangePasswordRequest request + ) { + memberFacade.changePassword(member, request.currentPassword(), request.newPassword()); + return ApiResponse.success(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java new file mode 100644 index 0000000000..13113ae1fc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java @@ -0,0 +1,52 @@ +package com.loopers.interfaces.api.member; + +import com.loopers.domain.member.Member; +import jakarta.validation.constraints.NotBlank; + +import java.time.LocalDate; + +public class MemberV1Dto { + + public record SignUpRequest( + @NotBlank String loginId, + @NotBlank String password, + @NotBlank String name, + @NotBlank String birthDate, + @NotBlank String email + ) {} + + public record SignUpResponse( + Long id, + String loginId, + String name, + String email + ) {} + + public record MyInfoResponse( + String loginId, + String name, + LocalDate birthDate, + String email + ) { + public static MyInfoResponse from(Member member) { + return new MyInfoResponse( + member.getLoginId().value(), + maskName(member.getName()), + member.getBirthDate().value(), + member.getEmail().value() + ); + } + + private static String maskName(String name) { + if (name == null || name.length() < 2) { + return name; + } + return name.substring(0, name.length() - 1) + "*"; + } + } + + public record ChangePasswordRequest( + @NotBlank String currentPassword, + @NotBlank String newPassword + ) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminController.java new file mode 100644 index 0000000000..fb1df372c9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminController.java @@ -0,0 +1,31 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderFacade; +import com.loopers.domain.order.Order; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api-admin/v1/orders") +public class OrderAdminController { + + private final OrderFacade orderFacade; + + @GetMapping + public ApiResponse> getAllOrders() { + List responses = orderFacade.getAllOrders().stream() + .map(OrderDto.OrderResponse::from) + .toList(); + return ApiResponse.success(responses); + } + + @GetMapping("/{orderId}") + public ApiResponse getOrder(@PathVariable Long orderId) { + Order order = orderFacade.getOrder(orderId); + return ApiResponse.success(OrderDto.OrderResponse.from(order)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java new file mode 100644 index 0000000000..2cae2c5e50 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java @@ -0,0 +1,73 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderFacade; +import com.loopers.domain.member.Member; +import com.loopers.domain.order.Order; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.support.auth.AuthMember; +import com.loopers.support.auth.RequireEntryToken; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/orders") +public class OrderController { + + private final OrderFacade orderFacade; + + @PostMapping + @RequireEntryToken + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse createOrder( + @AuthMember Member member, + @Valid @RequestBody OrderDto.CreateRequest request + ) { + List items = request.items().stream() + .map(i -> new OrderFacade.OrderItemRequest(i.productId(), i.quantity())) + .toList(); + Order order = orderFacade.createOrder(member.getId(), items, request.couponId()); + return ApiResponse.success(OrderDto.OrderResponse.from(order)); + } + + @GetMapping + public ApiResponse> getOrders( + @AuthMember Member member, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startAt, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endAt + ) { + ZonedDateTime start = startAt != null ? startAt.atZone(ZoneId.systemDefault()) : null; + ZonedDateTime end = endAt != null ? endAt.atZone(ZoneId.systemDefault()) : null; + List responses = orderFacade.getOrdersByMemberId(member.getId(), start, end) + .stream() + .map(OrderDto.OrderResponse::from) + .toList(); + return ApiResponse.success(responses); + } + + @GetMapping("/{orderId}") + public ApiResponse getOrder( + @AuthMember Member member, + @PathVariable Long orderId + ) { + Order order = orderFacade.getOrder(orderId, member.getId()); + return ApiResponse.success(OrderDto.OrderResponse.from(order)); + } + + @PostMapping("/{orderId}/cancel") + public ApiResponse cancelOrder( + @AuthMember Member member, + @PathVariable Long orderId + ) { + orderFacade.cancelOrder(orderId, member.getId()); + return ApiResponse.success(null); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderDto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderDto.java new file mode 100644 index 0000000000..6e5e5803d4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderDto.java @@ -0,0 +1,69 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderItem; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; + +import java.util.List; + +public class OrderDto { + + public record CreateRequest( + @NotEmpty List items, + Long couponId + ) {} + + public record OrderItemRequest( + @NotNull Long productId, + @Min(1) int quantity + ) {} + + public record OrderResponse( + Long id, + Long memberId, + String status, + int totalPrice, + int originalTotalPrice, + int discountAmount, + Long couponIssueId, + List items + ) { + public static OrderResponse from(Order order) { + List itemResponses = order.getItems().stream() + .map(OrderItemResponse::from) + .toList(); + return new OrderResponse( + order.getId(), + order.getMemberId(), + order.getStatus().name(), + order.getTotalPrice(), + order.getOriginalTotalPrice(), + order.getDiscountAmount(), + order.getCouponIssueId(), + itemResponses + ); + } + } + + public record OrderItemResponse( + Long productId, + String productName, + int productPrice, + String brandName, + int quantity, + int subtotal + ) { + public static OrderItemResponse from(OrderItem item) { + return new OrderItemResponse( + item.getProductId(), + item.getProductName(), + item.getProductPrice(), + item.getBrandName(), + item.getQuantity(), + item.getSubtotal() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1Controller.java new file mode 100644 index 0000000000..d5db5d6ff6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1Controller.java @@ -0,0 +1,72 @@ +package com.loopers.interfaces.api.payment; + +import com.loopers.application.payment.PaymentFacade; +import com.loopers.application.payment.PaymentRecoveryService; +import com.loopers.domain.payment.PaymentModel; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/payments") +public class PaymentV1Controller { + + private final PaymentFacade paymentFacade; + private final PaymentRecoveryService paymentRecoveryService; + + @PostMapping + @ResponseStatus(HttpStatus.OK) + public ApiResponse requestPayment( + @Valid @RequestBody PaymentV1Dto.PaymentRequest request + ) { + PaymentFacade.PaymentResult result = paymentFacade.requestPayment( + request.orderId(), request.cardType(), request.cardNo(), request.amount() + ); + return ApiResponse.success(PaymentV1Dto.PaymentResponse.from(result)); + } + + @GetMapping("/{paymentId}") + public ApiResponse getPayment( + @PathVariable Long paymentId + ) { + PaymentModel payment = paymentFacade.getPayment(paymentId); + return ApiResponse.success(PaymentV1Dto.PaymentDetailResponse.from(payment)); + } + + @GetMapping("/orders/{orderId}") + public ApiResponse getPaymentByOrderId( + @PathVariable Long orderId + ) { + PaymentModel payment = paymentFacade.getPaymentByOrderId(orderId); + return ApiResponse.success(PaymentV1Dto.PaymentDetailResponse.from(payment)); + } + + /** + * PG 콜백 수신 엔드포인트. + * + *

PG사가 결제 결과를 비동기로 전송한다. + * 즉시 200 OK 응답 + 비동기 처리.

+ */ + @PostMapping("/callback") + @ResponseStatus(HttpStatus.OK) + public ApiResponse handleCallback( + @Valid @RequestBody PaymentV1Dto.CallbackRequest request + ) { + paymentRecoveryService.processCallback( + request.transactionKey(), request.status(), request.payload()); + return ApiResponse.success(); + } + + /** + * 수동 복구 — PENDING/UNKNOWN 결제건의 PG 상태를 확인하여 확정. + */ + @PostMapping("/{paymentId}/confirm") + @ResponseStatus(HttpStatus.OK) + public ApiResponse manualConfirm(@PathVariable Long paymentId) { + String result = paymentRecoveryService.manualConfirm(paymentId); + return ApiResponse.success(result); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1Dto.java new file mode 100644 index 0000000000..85af03b750 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1Dto.java @@ -0,0 +1,63 @@ +package com.loopers.interfaces.api.payment; + +import com.loopers.application.payment.PaymentFacade; +import com.loopers.domain.payment.PaymentModel; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public class PaymentV1Dto { + + public record PaymentRequest( + @NotNull Long orderId, + @NotBlank String cardType, + @NotBlank String cardNo, + @Min(1) int amount + ) {} + + public record PaymentResponse( + Long paymentId, + String transactionKey, + String status, + String failureReason + ) { + public static PaymentResponse from(PaymentFacade.PaymentResult result) { + return new PaymentResponse( + result.paymentId(), + result.transactionKey(), + result.status(), + result.failureReason() + ); + } + } + + public record CallbackRequest( + @NotBlank String transactionKey, + @NotBlank String status, + String payload + ) {} + + public record PaymentDetailResponse( + Long paymentId, + Long orderId, + String status, + int amount, + String cardType, + String pgProvider, + String transactionKey, + String failureReason + ) { + public static PaymentDetailResponse from(PaymentModel payment) { + return new PaymentDetailResponse( + payment.getId(), + payment.getOrderId(), + payment.getStatus().name(), + payment.getAmount(), + payment.getCardType(), + payment.getPgProvider(), + payment.getTransactionKey(), + payment.getFailureReason() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminController.java new file mode 100644 index 0000000000..ef1a0c6314 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminController.java @@ -0,0 +1,58 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductFacade; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductWithBrand; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api-admin/v1/products") +public class ProductAdminController { + + private final ProductFacade productFacade; + + @GetMapping + public ApiResponse> getAllProducts() { + List responses = productFacade.getAllProducts().stream() + .map(ProductDto.ProductResponse::from) + .toList(); + return ApiResponse.success(responses); + } + + @GetMapping("/{productId}") + public ApiResponse getProduct(@PathVariable Long productId) { + ProductWithBrand info = productFacade.getProductDetail(productId); + return ApiResponse.success(ProductDto.ProductResponse.from(info)); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse createProduct(@Valid @RequestBody ProductDto.CreateRequest request) { + Product product = productFacade.createProduct( + request.brandId(), request.name(), request.price(), request.stockQuantity(), request.categoryId()); + return ApiResponse.success(ProductDto.ProductResponse.from(product)); + } + + @PutMapping("/{productId}") + public ApiResponse updateProduct( + @PathVariable Long productId, + @Valid @RequestBody ProductDto.UpdateRequest request + ) { + Product product = productFacade.updateProduct( + productId, request.name(), request.price(), request.stockQuantity()); + return ApiResponse.success(ProductDto.ProductResponse.from(product)); + } + + @DeleteMapping("/{productId}") + public ApiResponse deleteProduct(@PathVariable Long productId) { + productFacade.deleteProduct(productId); + return ApiResponse.success(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductBenchmarkController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductBenchmarkController.java new file mode 100644 index 0000000000..abc466de9a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductBenchmarkController.java @@ -0,0 +1,46 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductFacade; +import com.loopers.domain.product.ProductWithBrand; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/products") +public class ProductBenchmarkController { + + private final ProductFacade productFacade; + + @GetMapping("/no-cache") + public ApiResponse getProductsNoCache( + @RequestParam(required = false) Long brandId, + @RequestParam(defaultValue = "latest") String sort, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + Page result; + if (brandId != null) { + result = productFacade.getProductsByBrandId(brandId, sort, PageRequest.of(page, size)); + } else { + result = productFacade.getAllProducts(sort, PageRequest.of(page, size)); + } + return ApiResponse.success(ProductDto.PagedProductResponse.from(result)); + } + + @GetMapping("/no-optimization") + public ApiResponse> getProductsNoOptimization( + @RequestParam(defaultValue = "latest") String sort + ) { + List products = productFacade.getAllProductsNoOptimization(sort); + List responses = products.stream() + .map(ProductDto.ProductResponse::from) + .toList(); + return ApiResponse.success(responses); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java new file mode 100644 index 0000000000..c958516ce2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java @@ -0,0 +1,48 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductFacade; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import lombok.RequiredArgsConstructor; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Validated +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/products") +public class ProductController { + + private final ProductFacade productFacade; + + @GetMapping + public ApiResponse getProducts( + @RequestParam(required = false) Long brandId, + @RequestParam(defaultValue = "latest") String sort, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + ProductDto.PagedProductResponse response = productFacade.getAllProductsCached(brandId, sort, page, size); + return ApiResponse.success(response); + } + + @GetMapping("/new") + public ApiResponse getNewProducts( + @RequestParam(defaultValue = "48") @Min(1) @Max(168) int hours, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + return ApiResponse.success(productFacade.getNewProducts(hours, page, size)); + } + + @GetMapping("/{productId}") + public ApiResponse getProduct(@PathVariable Long productId) { + ProductDto.ProductResponse response = productFacade.getProductDetailCached(productId); + return ApiResponse.success(response); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductDto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductDto.java new file mode 100644 index 0000000000..e9b1e66247 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductDto.java @@ -0,0 +1,94 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductWithBrand; +import com.loopers.interfaces.api.ranking.RankingDto; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import org.springframework.data.domain.Page; + +import java.util.List; + +public class ProductDto { + + public record CreateRequest( + @NotNull Long brandId, + @NotBlank String name, + @Min(0) int price, + @Min(0) int stockQuantity, + Long categoryId + ) {} + + public record UpdateRequest( + @NotBlank String name, + @Min(0) int price, + @Min(0) int stockQuantity + ) {} + + public record ProductResponse( + Long id, + Long brandId, + String brandName, + String name, + int price, + int stockQuantity, + int likeCount, + Long categoryId, + RankingDto.RankingInfo ranking + ) { + public static ProductResponse from(ProductWithBrand info) { + Product product = info.product(); + return new ProductResponse( + product.getId(), + product.getBrandId(), + info.brandName(), + product.getName(), + product.getPrice().getValue(), + product.getStock().getQuantity(), + (int) info.likeCount(), + product.getCategoryId(), + null + ); + } + + public static ProductResponse from(Product product) { + return new ProductResponse( + product.getId(), + product.getBrandId(), + null, + product.getName(), + product.getPrice().getValue(), + product.getStock().getQuantity(), + 0, + product.getCategoryId(), + null + ); + } + + public ProductResponse withRanking(RankingDto.RankingInfo ranking) { + return new ProductResponse(id, brandId, brandName, name, price, stockQuantity, likeCount, categoryId, ranking); + } + } + + public record PagedProductResponse( + List data, + long totalElements, + int totalPages, + int page, + int size + ) { + public static PagedProductResponse from(Page pageResult) { + List data = pageResult.getContent().stream() + .map(ProductResponse::from) + .toList(); + return new PagedProductResponse( + data, + pageResult.getTotalElements(), + pageResult.getTotalPages(), + pageResult.getNumber(), + pageResult.getSize() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/queue/QueueController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/queue/QueueController.java new file mode 100644 index 0000000000..7758fb4a96 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/queue/QueueController.java @@ -0,0 +1,183 @@ +package com.loopers.interfaces.api.queue; + +import com.loopers.domain.member.Member; +import com.loopers.infrastructure.queue.QueueSseEmitterRegistry; +import com.loopers.infrastructure.redis.EntryTokenRedisRepository; +import com.loopers.infrastructure.redis.WaitingQueueRedisRepository; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.support.auth.AuthMember; +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import org.springframework.beans.factory.annotation.Value; + +import java.util.Map; + +@RestController +@RequestMapping("/api/v1/queue") +public class QueueController { + + private static final double ADMISSION_RATE = 80.0; + + private final long maxQueueSize; + private final WaitingQueueRedisRepository waitingQueueRedisRepository; + private final EntryTokenRedisRepository entryTokenRedisRepository; + private final QueueSseEmitterRegistry sseEmitterRegistry; + + private final Counter enterQueuedCounter; + private final Counter enterAdmittedCounter; + private final Counter enterQueueFullCounter; + + public QueueController( + WaitingQueueRedisRepository waitingQueueRedisRepository, + EntryTokenRedisRepository entryTokenRedisRepository, + QueueSseEmitterRegistry sseEmitterRegistry, + MeterRegistry meterRegistry, + @Value("${queue.max-size:48000}") long maxQueueSize + ) { + this.maxQueueSize = maxQueueSize; + this.waitingQueueRedisRepository = waitingQueueRedisRepository; + this.entryTokenRedisRepository = entryTokenRedisRepository; + this.sseEmitterRegistry = sseEmitterRegistry; + + this.enterQueuedCounter = Counter.builder("queue.enter.status") + .tag("status", "QUEUED") + .register(meterRegistry); + this.enterAdmittedCounter = Counter.builder("queue.enter.status") + .tag("status", "ADMITTED") + .register(meterRegistry); + this.enterQueueFullCounter = Counter.builder("queue.enter.status") + .tag("status", "QUEUE_FULL") + .register(meterRegistry); + } + + @PostMapping("/enter") + public ApiResponse enter(@AuthMember Member member) { + Long memberId = member.getId(); + + if (entryTokenRedisRepository.exists(memberId)) { + long ttl = entryTokenRedisRepository.getRemainingTtl(memberId); + enterAdmittedCounter.increment(); + return ApiResponse.success(new QueueDto.EnterResponse( + "ADMITTED", null, null, ttl, null + )); + } + + if (waitingQueueRedisRepository.size() >= maxQueueSize) { + enterQueueFullCounter.increment(); + return ApiResponse.success(new QueueDto.EnterResponse( + "QUEUE_FULL", null, null, null, null + )); + } + + waitingQueueRedisRepository.add(memberId); + Long rank = waitingQueueRedisRepository.getRank(memberId); + + if (rank == null) { + if (entryTokenRedisRepository.exists(memberId)) { + long ttl = entryTokenRedisRepository.getRemainingTtl(memberId); + enterAdmittedCounter.increment(); + return ApiResponse.success(new QueueDto.EnterResponse( + "ADMITTED", null, null, ttl, null + )); + } + rank = 0L; + } + + long position = rank + 1; + long estimatedWaitSeconds = (long) Math.ceil(position / ADMISSION_RATE); + + enterQueuedCounter.increment(); + return ApiResponse.success(new QueueDto.EnterResponse( + "QUEUED", position, estimatedWaitSeconds, null, calculatePollInterval(position) + )); + } + + @GetMapping("/position") + public ApiResponse position(@AuthMember Member member) { + Long memberId = member.getId(); + + if (entryTokenRedisRepository.exists(memberId)) { + long ttl = entryTokenRedisRepository.getRemainingTtl(memberId); + return ApiResponse.success(new QueueDto.PositionResponse( + "ADMITTED", null, null, null, ttl, null + )); + } + + Long rank = waitingQueueRedisRepository.getRank(memberId); + if (rank == null) { + return ApiResponse.success(new QueueDto.PositionResponse( + "NOT_IN_QUEUE", null, null, null, null, null + )); + } + + long position = rank + 1; + long totalQueueSize = waitingQueueRedisRepository.size(); + long estimatedWaitSeconds = (long) Math.ceil(position / ADMISSION_RATE); + + return ApiResponse.success(new QueueDto.PositionResponse( + "WAITING", position, totalQueueSize, estimatedWaitSeconds, null, + calculatePollInterval(position) + )); + } + + /** + * SSE 기반 실시간 순번 Push. + * + *

ADMITTED → admitted 이벤트 후 즉시 닫기. + * NOT_IN_QUEUE → not_in_queue 이벤트 후 즉시 닫기. + * WAITING → registry.register() 호출 (delta 브로드캐스트 수신).

+ */ + @GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter stream(@AuthMember Member member) { + Long memberId = member.getId(); + + if (entryTokenRedisRepository.exists(memberId)) { + SseEmitter emitter = new SseEmitter(0L); + try { + emitter.send(SseEmitter.event().name("admitted").data(Map.of())); + emitter.complete(); + } catch (Exception e) { + emitter.completeWithError(e); + } + return emitter; + } + + Long rank = waitingQueueRedisRepository.getRank(memberId); + if (rank == null) { + SseEmitter emitter = new SseEmitter(0L); + try { + emitter.send(SseEmitter.event().name("not_in_queue").data(Map.of())); + emitter.complete(); + } catch (Exception e) { + emitter.completeWithError(e); + } + return emitter; + } + + long position = rank + 1; + return sseEmitterRegistry.register(memberId, position); + } + + /** + * 대기 순번에 따라 클라이언트 폴링 주기를 차등 제공한다. + * + *

position 1~100: 1000ms (곧 입장, 빠른 반응 필요) + * position 101~1000: 3000ms (중간 대기) + * position 1001+: 5000ms (입장까지 12초 이상)

+ * + *

Redis 부하 감소 효과: 48,000명 기준 24,000→9,800 req/sec (59% 감소)

+ */ + static Long calculatePollInterval(long position) { + if (position <= 100) { + return 1000L; + } else if (position <= 1000) { + return 3000L; + } else { + return 5000L; + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/queue/QueueDto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/queue/QueueDto.java new file mode 100644 index 0000000000..35f882806e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/queue/QueueDto.java @@ -0,0 +1,21 @@ +package com.loopers.interfaces.api.queue; + +public class QueueDto { + + public record EnterResponse( + String status, + Long position, + Long estimatedWaitSeconds, + Long tokenRemainingSeconds, + Long suggestedPollIntervalMs + ) {} + + public record PositionResponse( + String status, + Long position, + Long totalQueueSize, + Long estimatedWaitSeconds, + Long tokenRemainingSeconds, + Long suggestedPollIntervalMs + ) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingController.java new file mode 100644 index 0000000000..9b94cb5975 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingController.java @@ -0,0 +1,29 @@ +package com.loopers.interfaces.api.ranking; + +import com.loopers.application.ranking.RankingFacade; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/rankings") +public class RankingController { + + private final RankingFacade rankingFacade; + + @GetMapping + public ApiResponse getRankings( + @RequestParam(defaultValue = "daily") String scope, + @RequestParam(required = false) String date, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(required = false) Long memberId + ) { + RankingDto.PagedRankingResponse response = rankingFacade.getRankings(scope, date, page, size, memberId); + return ApiResponse.success(response); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingDto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingDto.java new file mode 100644 index 0000000000..b44bcc5c89 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingDto.java @@ -0,0 +1,29 @@ +package com.loopers.interfaces.api.ranking; + +import java.util.List; + +public class RankingDto { + + public record RankingInfo( + long rank, + double score, + String date + ) {} + + public record RankingResponse( + Long productId, + String productName, + String brandName, + int price, + long rank, + double score + ) {} + + public record PagedRankingResponse( + List data, + long totalElements, + int totalPages, + int page, + int size + ) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/listener/CacheEvictionEventListener.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/listener/CacheEvictionEventListener.java new file mode 100644 index 0000000000..6d3046f757 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/listener/CacheEvictionEventListener.java @@ -0,0 +1,38 @@ +package com.loopers.interfaces.listener; + +import com.loopers.application.product.ProductCachePort; +import com.loopers.domain.event.LikeCreatedEvent; +import com.loopers.domain.event.LikeRemovedEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CacheEvictionEventListener { + + private final ProductCachePort productCachePort; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleLikeCreated(LikeCreatedEvent event) { + try { + productCachePort.evictProductDetail(event.productId()); + productCachePort.evictProductList(); + } catch (Exception e) { + log.warn("캐시 무효화 실패 — best-effort", e); + } + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleLikeRemoved(LikeRemovedEvent event) { + try { + productCachePort.evictProductDetail(event.productId()); + productCachePort.evictProductList(); + } catch (Exception e) { + log.warn("캐시 무효화 실패 — best-effort", e); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/listener/LikeCountEventListener.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/listener/LikeCountEventListener.java new file mode 100644 index 0000000000..4b6dd2d798 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/listener/LikeCountEventListener.java @@ -0,0 +1,58 @@ +package com.loopers.interfaces.listener; + +import com.loopers.domain.event.LikeCreatedEvent; +import com.loopers.domain.event.LikeRemovedEvent; +import com.loopers.domain.product.ProductRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Component; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; +import org.springframework.transaction.support.TransactionTemplate; + +import java.util.concurrent.Executor; + +@Slf4j +@Component +public class LikeCountEventListener { + + private final ProductRepository productRepository; + private final Executor eventExecutor; + private final TransactionTemplate transactionTemplate; + + public LikeCountEventListener( + ProductRepository productRepository, + @Qualifier("eventExecutor") Executor eventExecutor, + PlatformTransactionManager transactionManager) { + this.productRepository = productRepository; + this.eventExecutor = eventExecutor; + this.transactionTemplate = new TransactionTemplate(transactionManager); + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleLikeCreated(LikeCreatedEvent event) { + eventExecutor.execute(() -> { + try { + transactionTemplate.executeWithoutResult(status -> + productRepository.incrementLikeCount(event.productId()) + ); + } catch (Exception e) { + log.warn("incrementLikeCount 실패 — best-effort", e); + } + }); + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleLikeRemoved(LikeRemovedEvent event) { + eventExecutor.execute(() -> { + try { + transactionTemplate.executeWithoutResult(status -> + productRepository.decrementLikeCount(event.productId()) + ); + } catch (Exception e) { + log.warn("decrementLikeCount 실패 — best-effort", e); + } + }); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthMember.java b/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthMember.java new file mode 100644 index 0000000000..9089d89dc5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthMember.java @@ -0,0 +1,11 @@ +package com.loopers.support.auth; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface AuthMember { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthMemberResolver.java b/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthMemberResolver.java new file mode 100644 index 0000000000..978e677f7b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthMemberResolver.java @@ -0,0 +1,60 @@ +package com.loopers.support.auth; + +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberRepository; +import com.loopers.domain.member.vo.LoginId; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.core.MethodParameter; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@RequiredArgsConstructor +@Component +public class AuthMemberResolver implements HandlerMethodArgumentResolver { + + private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; + private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; + + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(AuthMember.class); + } + + @Override + public Object resolveArgument( + MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory + ) { + HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest(); + + String loginId = request.getHeader(HEADER_LOGIN_ID); + String password = request.getHeader(HEADER_LOGIN_PW); + + if (loginId == null || loginId.isBlank() || password == null || password.isBlank()) { + throw new CoreException(ErrorType.UNAUTHORIZED, "인증 헤더가 필요합니다."); + } + + Member member = memberRepository.findByLoginId(new LoginId(loginId)) + .orElseThrow(() -> new CoreException(ErrorType.UNAUTHORIZED, + "아이디 또는 비밀번호가 일치하지 않습니다.")); + + if (!member.getPassword().matches(password, passwordEncoder)) { + throw new CoreException(ErrorType.UNAUTHORIZED, + "아이디 또는 비밀번호가 일치하지 않습니다."); + } + + return member; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/auth/PasswordEncoderConfig.java b/apps/commerce-api/src/main/java/com/loopers/support/auth/PasswordEncoderConfig.java new file mode 100644 index 0000000000..b8cf054745 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/auth/PasswordEncoderConfig.java @@ -0,0 +1,15 @@ +package com.loopers.support.auth; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +public class PasswordEncoderConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/auth/RequireEntryToken.java b/apps/commerce-api/src/main/java/com/loopers/support/auth/RequireEntryToken.java new file mode 100644 index 0000000000..66a77b33d1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/auth/RequireEntryToken.java @@ -0,0 +1,11 @@ +package com.loopers.support.auth; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface RequireEntryToken { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/auth/WebMvcConfig.java b/apps/commerce-api/src/main/java/com/loopers/support/auth/WebMvcConfig.java new file mode 100644 index 0000000000..edb1b604d4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/auth/WebMvcConfig.java @@ -0,0 +1,20 @@ +package com.loopers.support.auth; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.List; + +@RequiredArgsConstructor +@Configuration +public class WebMvcConfig implements WebMvcConfigurer { + + private final AuthMemberResolver authMemberResolver; + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(authMemberResolver); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/config/ClockConfig.java b/apps/commerce-api/src/main/java/com/loopers/support/config/ClockConfig.java new file mode 100644 index 0000000000..54bd7f908c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/config/ClockConfig.java @@ -0,0 +1,15 @@ +package com.loopers.support.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.Clock; + +@Configuration +public class ClockConfig { + + @Bean + public Clock clock() { + return Clock.systemDefaultZone(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java index 5d142efbf9..a4339a7ce9 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java @@ -10,8 +10,11 @@ public enum ErrorType { /** 범용 에러 */ INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(), "일시적인 오류가 발생했습니다."), BAD_REQUEST(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "잘못된 요청입니다."), + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, HttpStatus.UNAUTHORIZED.getReasonPhrase(), "인증이 필요합니다."), NOT_FOUND(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.getReasonPhrase(), "존재하지 않는 요청입니다."), - CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "이미 존재하는 리소스입니다."); + FORBIDDEN(HttpStatus.FORBIDDEN, HttpStatus.FORBIDDEN.getReasonPhrase(), "접근 권한이 없습니다."), + CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "이미 존재하는 리소스입니다."), + TOO_MANY_REQUESTS(HttpStatus.TOO_MANY_REQUESTS, HttpStatus.TOO_MANY_REQUESTS.getReasonPhrase(), "요청이 너무 많습니다."); private final HttpStatus status; private final String code; diff --git a/apps/commerce-api/src/main/resources/application.yml b/apps/commerce-api/src/main/resources/application.yml index 484c070d08..f7f2ff3314 100644 --- a/apps/commerce-api/src/main/resources/application.yml +++ b/apps/commerce-api/src/main/resources/application.yml @@ -29,6 +29,84 @@ springdoc: swagger-ui: path: /swagger-ui.html +# 대기열 설정 +queue: + max-size: 48000 # 대기열 최대 크기 (Little's Law: 80 TPS × 600초) + token: + ttl-seconds: 900 # 토큰 TTL (기본 15분, 블프 시 1800 등으로 조정) + fallback: + rate-limit: 80 # Redis 장애 시 로컬 Rate Limit (req/sec, 정상 모드 입장 속도와 동일) + +# PG 설정 +pg: + simulator: + url: http://localhost:8081 + connect-timeout: 500 + read-timeout: 1000 + toss: + url: http://localhost:8082 + connect-timeout: 500 + read-timeout: 2000 + +# 결제 콜백 +payment: + callback-url: http://localhost:8080/api/v1/payments/callback + retry: + max-attempts: 3 + initial-wait-ms: 500 + backoff-multiplier: 2 + +# Resilience4j 설정 +# CB 3개 (쓰기만) — 읽기 CB 제거 근거: 06 §18 +# 읽기(상태 조회)는 "복구 행위"이므로 CB가 차단하면 복구가 멈춤 → Timeout만으로 보호 +resilience4j: + circuitbreaker: + instances: + # PG Simulator 결제 요청 (POST) — 쓰기 + pgSimulator-request: + sliding-window-type: COUNT_BASED + sliding-window-size: 10 + failure-rate-threshold: 50 # PG 정상 실패율(40%) + 여유 10%p + wait-duration-in-open-state: 10s + permitted-number-of-calls-in-half-open-state: 2 + slow-call-duration-threshold: 2s + slow-call-rate-threshold: 50 + register-health-indicator: true + # Toss 결제 승인 (POST) — 쓰기 + pgToss-request: + sliding-window-type: COUNT_BASED + sliding-window-size: 10 + failure-rate-threshold: 50 + wait-duration-in-open-state: 15s # Toss는 안정적, 복구 여유 더 줌 + permitted-number-of-calls-in-half-open-state: 2 + slow-call-duration-threshold: 3s + slow-call-rate-threshold: 50 + register-health-indicator: true + # Redis 가주문 쓰기 (Master) — 쓰기 + redis-write: + sliding-window-type: COUNT_BASED + sliding-window-size: 10 + failure-rate-threshold: 50 + wait-duration-in-open-state: 5s # Redis 복구가 빠르므로 짧게 + permitted-number-of-calls-in-half-open-state: 3 + register-health-indicator: true + ratelimiter: + instances: + # 배치 PG 상태 조회 Rate Limiter — Fixed Window (배치는 순차 호출이므로 경계 돌파 불가) + pgStatusBatch: + limit-for-period: 10 + limit-refresh-period: 1s + timeout-duration: 0 # 초과 시 즉시 실패 (대기 안 함) + +ranking: + experiment: + enabled: false + variants: + A: + zset-prefix: "ranking:exp:A:" + B: + zset-prefix: "ranking:exp:B:" + --- spring: config: diff --git a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java new file mode 100644 index 0000000000..1df646ec2a --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java @@ -0,0 +1,230 @@ +package com.loopers.application.brand; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.like.Like; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.vo.Price; +import com.loopers.domain.product.vo.Stock; +import com.loopers.fake.FakeBrandRepository; +import com.loopers.fake.FakeLikeRepository; +import com.loopers.fake.FakeProductRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class BrandFacadeTest { + + private BrandFacade brandFacade; + private FakeBrandRepository brandRepository; + private FakeProductRepository productRepository; + private FakeLikeRepository likeRepository; + + @BeforeEach + void setUp() { + brandRepository = new FakeBrandRepository(); + productRepository = new FakeProductRepository(); + likeRepository = new FakeLikeRepository(); + brandFacade = new BrandFacade(brandRepository, productRepository, likeRepository); + } + + @Nested + @DisplayName("브랜드 단건 조회") + class GetBrand { + + @DisplayName("존재하는 브랜드를 조회하면 브랜드가 반환된다") + @Test + void getBrand_whenExists_returnsBrand() { + // arrange + Brand saved = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + + // act + Brand result = brandFacade.getBrand(saved.getId()); + + // assert + assertThat(result.getId()).isEqualTo(saved.getId()); + assertThat(result.getName()).isEqualTo("나이키"); + assertThat(result.getDescription()).isEqualTo("스포츠 브랜드"); + } + + @DisplayName("존재하지 않는 브랜드를 조회하면 예외가 발생한다") + @Test + void getBrand_whenNotExists_throwsCoreException() { + assertThatThrownBy(() -> brandFacade.getBrand(999L)) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.NOT_FOUND); + } + } + + @Nested + @DisplayName("브랜드 전체 조회") + class GetAllBrands { + + @DisplayName("저장된 모든 브랜드가 반환된다") + @Test + void getAllBrands_returnsAll() { + // arrange + brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + brandRepository.save(new Brand("아디다스", "스포츠 브랜드")); + + // act + List result = brandFacade.getAllBrands(); + + // assert + assertThat(result).hasSize(2); + } + + @DisplayName("저장된 브랜드가 없으면 빈 리스트가 반환된다") + @Test + void getAllBrands_whenEmpty_returnsEmptyList() { + // act + List result = brandFacade.getAllBrands(); + + // assert + assertThat(result).isEmpty(); + } + } + + @Nested + @DisplayName("브랜드 생성") + class CreateBrand { + + @DisplayName("브랜드를 생성하면 ID가 부여되어 반환된다") + @Test + void createBrand_returnsWithId() { + // act + Brand result = brandFacade.createBrand("나이키", "스포츠 브랜드"); + + // assert + assertThat(result.getId()).isNotNull(); + assertThat(result.getId()).isGreaterThan(0L); + assertThat(result.getName()).isEqualTo("나이키"); + assertThat(result.getDescription()).isEqualTo("스포츠 브랜드"); + } + + @DisplayName("생성된 브랜드가 저장소에 저장된다") + @Test + void createBrand_persistsInRepository() { + // act + Brand result = brandFacade.createBrand("나이키", "스포츠 브랜드"); + + // assert + assertThat(brandRepository.findById(result.getId())).isPresent(); + } + } + + @Nested + @DisplayName("브랜드 수정") + class UpdateBrand { + + @DisplayName("존재하는 브랜드를 수정하면 변경된 정보가 반환된다") + @Test + void updateBrand_whenExists_returnsUpdated() { + // arrange + Brand saved = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + + // act + Brand result = brandFacade.updateBrand(saved.getId(), "뉴나이키", "프리미엄 스포츠 브랜드"); + + // assert + assertThat(result.getName()).isEqualTo("뉴나이키"); + assertThat(result.getDescription()).isEqualTo("프리미엄 스포츠 브랜드"); + } + + @DisplayName("존재하지 않는 브랜드를 수정하면 예외가 발생한다") + @Test + void updateBrand_whenNotExists_throwsCoreException() { + assertThatThrownBy(() -> brandFacade.updateBrand(999L, "뉴나이키", "설명")) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.NOT_FOUND); + } + } + + @Nested + @DisplayName("브랜드 삭제") + class DeleteBrand { + + @DisplayName("브랜드를 삭제하면 브랜드가 소프트 삭제된다") + @Test + void deleteBrand_softDeletesBrand() { + // arrange + Brand saved = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + + // act + brandFacade.deleteBrand(saved.getId()); + + // assert + assertThat(brandRepository.findById(saved.getId())).isEmpty(); + } + + @DisplayName("브랜드를 삭제하면 해당 브랜드의 상품도 소프트 삭제된다") + @Test + void deleteBrand_cascadeSoftDeletesProducts() { + // arrange + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + Product product1 = productRepository.save( + new Product(brand.getId(), "에어맥스", new Price(150000), new Stock(10))); + Product product2 = productRepository.save( + new Product(brand.getId(), "에어포스", new Price(120000), new Stock(20))); + + // act + brandFacade.deleteBrand(brand.getId()); + + // assert + assertThat(productRepository.findById(product1.getId())).isEmpty(); + assertThat(productRepository.findById(product2.getId())).isEmpty(); + } + + @DisplayName("존재하지 않는 브랜드를 삭제하면 예외가 발생한다") + @Test + void deleteBrand_whenNotExists_throwsCoreException() { + assertThatThrownBy(() -> brandFacade.deleteBrand(999L)) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("브랜드에 속한 상품이 없어도 삭제에 성공한다") + @Test + void deleteBrand_withNoProducts_succeeds() { + // arrange + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + + // act + brandFacade.deleteBrand(brand.getId()); + + // assert + assertThat(brandRepository.findById(brand.getId())).isEmpty(); + } + + @DisplayName("브랜드 삭제 시 해당 상품들의 좋아요가 hard delete 된다") + @Test + void deleteBrand_hardDeletesLikesOfProducts() { + // arrange + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + Product product1 = productRepository.save( + new Product(brand.getId(), "에어맥스", new Price(150000), new Stock(10))); + Product product2 = productRepository.save( + new Product(brand.getId(), "에어포스", new Price(120000), new Stock(20))); + likeRepository.save(new Like(1L, product1.getId())); + likeRepository.save(new Like(2L, product1.getId())); + likeRepository.save(new Like(1L, product2.getId())); + + // act + brandFacade.deleteBrand(brand.getId()); + + // assert + assertThat(likeRepository.findAllByMemberId(1L)).isEmpty(); + assertThat(likeRepository.findAllByMemberId(2L)).isEmpty(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponFacadeTest.java new file mode 100644 index 0000000000..109ab74934 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponFacadeTest.java @@ -0,0 +1,350 @@ +package com.loopers.application.coupon; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.domain.coupon.*; +import com.loopers.fake.FakeCouponIssueRepository; +import com.loopers.fake.FakeCouponRepository; +import com.loopers.infrastructure.redis.CouponIssueRequestRedisRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.kafka.core.KafkaTemplate; + +import java.time.Clock; +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class CouponFacadeTest { + + private CouponFacade couponFacade; + private FakeCouponRepository couponRepository; + private FakeCouponIssueRepository couponIssueRepository; + private CouponIssueRequestRedisRepository couponIssueRequestRedisRepository; + private KafkaTemplate kafkaTemplate; + private ObjectMapper objectMapper; + private final Clock clock = Clock.systemDefaultZone(); + + @SuppressWarnings("unchecked") + @BeforeEach + void setUp() { + couponRepository = new FakeCouponRepository(); + couponIssueRepository = new FakeCouponIssueRepository(); + couponIssueRequestRedisRepository = mock(CouponIssueRequestRedisRepository.class); + kafkaTemplate = mock(KafkaTemplate.class); + objectMapper = new ObjectMapper(); + couponFacade = new CouponFacade( + couponRepository, couponIssueRepository, + couponIssueRequestRedisRepository, kafkaTemplate, + objectMapper, clock + ); + } + + @Nested + @DisplayName("쿠폰 템플릿 생성") + class CreateCoupon { + + @DisplayName("쿠폰 템플릿을 생성하면 저장되어 반환된다") + @Test + void createCoupon_savesCoupon() { + Coupon result = couponFacade.createCoupon( + "신규가입 10% 할인", DiscountType.RATE, 10, 10000, + ZonedDateTime.now().plusDays(30)); + + assertThat(result.getId()).isGreaterThan(0L); + assertThat(result.getName()).isEqualTo("신규가입 10% 할인"); + assertThat(result.getDiscountType()).isEqualTo(DiscountType.RATE); + assertThat(result.getDiscountValue()).isEqualTo(10); + assertThat(result.getMinOrderAmount()).isEqualTo(10000); + } + } + + @Nested + @DisplayName("쿠폰 템플릿 조회") + class GetCoupon { + + @DisplayName("존재하는 쿠폰을 조회하면 반환된다") + @Test + void getCoupon_whenExists_returnsCoupon() { + Coupon coupon = couponFacade.createCoupon( + "할인", DiscountType.FIXED, 1000, 0, ZonedDateTime.now().plusDays(30)); + + Coupon result = couponFacade.getCoupon(coupon.getId()); + + assertThat(result.getId()).isEqualTo(coupon.getId()); + } + + @DisplayName("존재하지 않는 쿠폰을 조회하면 예외가 발생한다") + @Test + void getCoupon_whenNotExists_throwsException() { + assertThatThrownBy(() -> couponFacade.getCoupon(999L)) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.NOT_FOUND); + } + } + + @Nested + @DisplayName("쿠폰 템플릿 수정") + class UpdateCoupon { + + @DisplayName("쿠폰을 수정하면 변경사항이 반영된다") + @Test + void updateCoupon_updatesFields() { + Coupon coupon = couponFacade.createCoupon( + "기존 이름", DiscountType.FIXED, 1000, 0, ZonedDateTime.now().plusDays(30)); + ZonedDateTime newExpiredAt = ZonedDateTime.now().plusDays(60); + + Coupon result = couponFacade.updateCoupon( + coupon.getId(), "새 이름", DiscountType.RATE, 20, 5000, newExpiredAt); + + assertThat(result.getName()).isEqualTo("새 이름"); + assertThat(result.getDiscountType()).isEqualTo(DiscountType.RATE); + assertThat(result.getDiscountValue()).isEqualTo(20); + assertThat(result.getMinOrderAmount()).isEqualTo(5000); + } + } + + @Nested + @DisplayName("쿠폰 템플릿 삭제") + class DeleteCoupon { + + @DisplayName("쿠폰을 삭제하면 조회되지 않는다") + @Test + void deleteCoupon_softDeletes() { + Coupon coupon = couponFacade.createCoupon( + "할인", DiscountType.FIXED, 1000, 0, ZonedDateTime.now().plusDays(30)); + + couponFacade.deleteCoupon(coupon.getId()); + + assertThatThrownBy(() -> couponFacade.getCoupon(coupon.getId())) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("존재하지 않는 쿠폰을 삭제하면 예외가 발생한다") + @Test + void deleteCoupon_whenNotExists_throwsException() { + assertThatThrownBy(() -> couponFacade.deleteCoupon(999L)) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.NOT_FOUND); + } + } + + @Nested + @DisplayName("쿠폰 발급") + class IssueCoupon { + + @DisplayName("쿠폰을 발급하면 CouponIssue가 생성된다") + @Test + void issueCoupon_createsCouponIssue() { + Coupon coupon = couponFacade.createCoupon( + "할인", DiscountType.FIXED, 1000, 0, ZonedDateTime.now().plusDays(30)); + + CouponIssue result = couponFacade.issueCoupon(coupon.getId(), 1L); + + assertThat(result.getId()).isGreaterThan(0L); + assertThat(result.getCouponId()).isEqualTo(coupon.getId()); + assertThat(result.getMemberId()).isEqualTo(1L); + assertThat(result.getStatus()).isEqualTo(CouponIssueStatus.AVAILABLE); + } + + @DisplayName("만료된 쿠폰은 발급할 수 없다") + @Test + void issueCoupon_whenExpired_throwsException() { + Coupon coupon = couponFacade.createCoupon( + "할인", DiscountType.FIXED, 1000, 0, ZonedDateTime.now().minusDays(1)); + + assertThatThrownBy(() -> couponFacade.issueCoupon(coupon.getId(), 1L)) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("존재하지 않는 쿠폰은 발급할 수 없다") + @Test + void issueCoupon_whenNotExists_throwsException() { + assertThatThrownBy(() -> couponFacade.issueCoupon(999L, 1L)) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.NOT_FOUND); + } + } + + @Nested + @DisplayName("내 쿠폰 목록 조회") + class GetMyCoupons { + + @DisplayName("발급받은 쿠폰 목록이 반환된다") + @Test + void getMyCoupons_returnsCouponIssues() { + Coupon coupon1 = couponFacade.createCoupon( + "할인1", DiscountType.FIXED, 1000, 0, ZonedDateTime.now().plusDays(30)); + Coupon coupon2 = couponFacade.createCoupon( + "할인2", DiscountType.RATE, 10, 0, ZonedDateTime.now().plusDays(30)); + couponFacade.issueCoupon(coupon1.getId(), 1L); + couponFacade.issueCoupon(coupon2.getId(), 1L); + couponFacade.issueCoupon(coupon1.getId(), 2L); + + List result = couponFacade.getMyCoupons(1L); + + assertThat(result).hasSize(2); + assertThat(result).allSatisfy(issue -> + assertThat(issue.getMemberId()).isEqualTo(1L)); + } + + @DisplayName("쿠폰이 없으면 빈 리스트가 반환된다") + @Test + void getMyCoupons_whenNone_returnsEmpty() { + List result = couponFacade.getMyCoupons(999L); + + assertThat(result).isEmpty(); + } + } + + @Nested + @DisplayName("발급 내역 조회") + class GetCouponIssues { + + @DisplayName("쿠폰의 발급 내역이 반환된다") + @Test + void getCouponIssues_returnsIssues() { + Coupon coupon = couponFacade.createCoupon( + "할인", DiscountType.FIXED, 1000, 0, ZonedDateTime.now().plusDays(30)); + couponFacade.issueCoupon(coupon.getId(), 1L); + couponFacade.issueCoupon(coupon.getId(), 2L); + + List result = couponFacade.getCouponIssues(coupon.getId()); + + assertThat(result).hasSize(2); + } + + @DisplayName("존재하지 않는 쿠폰의 발급 내역을 조회하면 예외가 발생한다") + @Test + void getCouponIssues_whenCouponNotExists_throwsException() { + assertThatThrownBy(() -> couponFacade.getCouponIssues(999L)) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.NOT_FOUND); + } + } + + @Nested + @DisplayName("주문 연동: 쿠폰 적용") + class ApplyCouponToOrder { + + @DisplayName("유효한 쿠폰을 적용하면 할인 금액이 반환된다") + @Test + void applyCouponToOrder_returnsDiscount() { + Coupon coupon = couponFacade.createCoupon( + "5000원 할인", DiscountType.FIXED, 5000, 10000, + ZonedDateTime.now().plusDays(30)); + CouponIssue issue = couponFacade.issueCoupon(coupon.getId(), 1L); + + CouponApplyResult result = couponFacade.applyCouponToOrder( + issue.getId(), 1L, 100000); + + assertThat(result.discountAmount()).isEqualTo(5000); + assertThat(result.couponIssueId()).isEqualTo(issue.getId()); + assertThat(issue.getStatus()).isEqualTo(CouponIssueStatus.USED); + } + + @DisplayName("타인의 쿠폰을 적용하면 예외가 발생한다") + @Test + void applyCouponToOrder_withOtherMember_throwsException() { + Coupon coupon = couponFacade.createCoupon( + "할인", DiscountType.FIXED, 5000, 0, + ZonedDateTime.now().plusDays(30)); + CouponIssue issue = couponFacade.issueCoupon(coupon.getId(), 2L); + + assertThatThrownBy(() -> couponFacade.applyCouponToOrder( + issue.getId(), 1L, 100000)) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.FORBIDDEN); + } + } + + @Nested + @DisplayName("선착순 쿠폰 발급 요청") + class RequestCouponIssue { + + @DisplayName("쿠폰 발급을 요청하면 PENDING 상태의 요청 정보가 반환된다") + @Test + void requestCouponIssue_returnsPendingRequest() { + Coupon coupon = couponFacade.createCoupon( + "선착순 할인", DiscountType.FIXED, 5000, 0, ZonedDateTime.now().plusDays(30)); + when(couponIssueRequestRedisRepository.nextId()).thenReturn(1L); + + CouponIssueRequestInfo result = couponFacade.requestCouponIssue(coupon.getId(), 1L); + + assertThat(result.requestId()).isEqualTo(1L); + assertThat(result.couponId()).isEqualTo(coupon.getId()); + assertThat(result.memberId()).isEqualTo(1L); + assertThat(result.status()).isEqualTo(CouponIssueRequestStatus.PENDING); + + verify(couponIssueRequestRedisRepository).save(eq(1L), eq(coupon.getId()), eq(1L), eq("PENDING"), isNull()); + verify(kafkaTemplate).send(eq("coupon-issue-requests"), eq(String.valueOf(coupon.getId())), anyString()); + } + + @DisplayName("만료된 쿠폰은 발급 요청할 수 없다") + @Test + void requestCouponIssue_whenExpired_throwsException() { + Coupon coupon = couponFacade.createCoupon( + "할인", DiscountType.FIXED, 1000, 0, ZonedDateTime.now().minusDays(1)); + + assertThatThrownBy(() -> couponFacade.requestCouponIssue(coupon.getId(), 1L)) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("존재하지 않는 쿠폰은 발급 요청할 수 없다") + @Test + void requestCouponIssue_whenNotExists_throwsException() { + assertThatThrownBy(() -> couponFacade.requestCouponIssue(999L, 1L)) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.NOT_FOUND); + } + } + + @Nested + @DisplayName("발급 요청 조회") + class GetIssueRequest { + + @DisplayName("존재하는 요청을 조회하면 반환된다") + @Test + void getIssueRequest_whenExists_returnsInfo() { + CouponIssueRequestInfo expected = new CouponIssueRequestInfo( + 1L, 10L, 100L, CouponIssueRequestStatus.COMPLETED, null); + when(couponIssueRequestRedisRepository.findById(1L)).thenReturn(Optional.of(expected)); + + CouponIssueRequestInfo result = couponFacade.getIssueRequest(1L); + + assertThat(result.requestId()).isEqualTo(1L); + assertThat(result.status()).isEqualTo(CouponIssueRequestStatus.COMPLETED); + } + + @DisplayName("존재하지 않는 요청을 조회하면 예외가 발생한다") + @Test + void getIssueRequest_whenNotExists_throwsException() { + when(couponIssueRequestRedisRepository.findById(999L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> couponFacade.getIssueRequest(999L)) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.NOT_FOUND); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/event/ProductViewKafkaPublisherTest.java b/apps/commerce-api/src/test/java/com/loopers/application/event/ProductViewKafkaPublisherTest.java new file mode 100644 index 0000000000..8356c07b07 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/event/ProductViewKafkaPublisherTest.java @@ -0,0 +1,48 @@ +package com.loopers.application.event; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.domain.event.ProductViewedEvent; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.kafka.core.KafkaTemplate; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +class ProductViewKafkaPublisherTest { + + private ProductViewKafkaPublisher publisher; + private KafkaTemplate kafkaTemplate; + + @SuppressWarnings("unchecked") + @BeforeEach + void setUp() { + kafkaTemplate = mock(KafkaTemplate.class); + publisher = new ProductViewKafkaPublisher(kafkaTemplate, new ObjectMapper()); + } + + @Nested + @DisplayName("ProductViewedEvent 처리") + class HandleProductViewed { + + @DisplayName("catalog-events 토픽으로 Kafka 메시지를 발행한다") + @Test + void sendsKafkaMessage() { + publisher.handle(new ProductViewedEvent(100L, 1L)); + + verify(kafkaTemplate).send(eq("catalog-events"), eq("100"), anyString()); + } + + @DisplayName("productId를 Kafka 메시지 키로 사용한다") + @Test + void usesProductIdAsKey() { + publisher.handle(new ProductViewedEvent(42L, 5L)); + + verify(kafkaTemplate).send(eq("catalog-events"), eq("42"), anyString()); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java new file mode 100644 index 0000000000..11fc250bd7 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java @@ -0,0 +1,234 @@ +package com.loopers.application.like; + +import com.loopers.domain.event.DomainEventPublisher; +import com.loopers.domain.event.LikeCreatedEvent; +import com.loopers.domain.event.LikeRemovedEvent; +import com.loopers.domain.like.Like; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.vo.Price; +import com.loopers.domain.product.vo.Stock; +import com.loopers.fake.FakeLikeRepository; +import com.loopers.fake.FakeProductRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class LikeFacadeTest { + + private LikeFacade likeFacade; + private FakeLikeRepository likeRepository; + private FakeProductRepository productRepository; + private List publishedEvents; + + record PublishedEvent(String aggregateType, String aggregateId, String eventType, Object payload, Object event) {} + + @BeforeEach + void setUp() { + likeRepository = new FakeLikeRepository(); + productRepository = new FakeProductRepository(); + publishedEvents = new ArrayList<>(); + DomainEventPublisher domainEventPublisher = (aggregateType, aggregateId, eventType, payload, event) -> + publishedEvents.add(new PublishedEvent(aggregateType, aggregateId, eventType, payload, event)); + likeFacade = new LikeFacade(likeRepository, productRepository, domainEventPublisher); + } + + @Nested + @DisplayName("좋아요 추가") + class AddLike { + + @DisplayName("좋아요를 추가하면 Like 레코드가 저장되고 Product.likeCount가 1 증가한다") + @Test + void addLike_savesLikeRecord_andIncrementsLikeCount() { + Product product = productRepository.save( + new Product(1L, "에어맥스", new Price(150000), new Stock(10))); + Long memberId = 1L; + + likeFacade.addLike(memberId, product.getId()); + + assertThat(likeRepository.existsByMemberIdAndProductId(memberId, product.getId())).isTrue(); + assertThat(likeRepository.countByProductId(product.getId())).isEqualTo(1); + } + + @DisplayName("이미 좋아요한 상품에 다시 좋아요하면 멱등하게 처리된다 (likeCount 불변)") + @Test + void addLike_whenAlreadyLiked_isIdempotent() { + Product product = productRepository.save( + new Product(1L, "에어맥스", new Price(150000), new Stock(10))); + Long memberId = 1L; + likeFacade.addLike(memberId, product.getId()); + + likeFacade.addLike(memberId, product.getId()); + + assertThat(likeRepository.countByProductId(product.getId())).isEqualTo(1); + assertThat(likeRepository.findAllByMemberId(memberId)).hasSize(1); + } + + @DisplayName("존재하지 않는 상품에 좋아요하면 예외가 발생한다") + @Test + void addLike_whenProductNotExists_throwsCoreException() { + assertThatThrownBy(() -> likeFacade.addLike(1L, 999L)) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("여러 회원이 같은 상품에 좋아요하면 카운트가 누적된다") + @Test + void addLike_byMultipleMembers_accumulatesCount() { + Product product = productRepository.save( + new Product(1L, "에어맥스", new Price(150000), new Stock(10))); + + likeFacade.addLike(1L, product.getId()); + likeFacade.addLike(2L, product.getId()); + likeFacade.addLike(3L, product.getId()); + + assertThat(likeRepository.countByProductId(product.getId())).isEqualTo(3); + } + } + + @Nested + @DisplayName("좋아요 취소") + class RemoveLike { + + @DisplayName("좋아요를 취소하면 Like 레코드가 삭제되고 Product.likeCount가 1 감소한다") + @Test + void removeLike_deletesLikeRecord_andDecrementsLikeCount() { + Product product = productRepository.save( + new Product(1L, "에어맥스", new Price(150000), new Stock(10))); + Long memberId = 1L; + likeFacade.addLike(memberId, product.getId()); + + likeFacade.removeLike(memberId, product.getId()); + + assertThat(likeRepository.existsByMemberIdAndProductId(memberId, product.getId())).isFalse(); + assertThat(likeRepository.countByProductId(product.getId())).isEqualTo(0); + } + + @DisplayName("좋아요하지 않은 상품의 좋아요를 취소해도 예외 없이 멱등하게 처리된다") + @Test + void removeLike_whenNotLiked_isIdempotent() { + Product product = productRepository.save( + new Product(1L, "에어맥스", new Price(150000), new Stock(10))); + + likeFacade.removeLike(1L, product.getId()); + + assertThat(likeRepository.countByProductId(product.getId())).isEqualTo(0); + } + } + + @Nested + @DisplayName("DomainEventPublisher 호출 검증") + class DomainEventPublishing { + + @DisplayName("좋아요 추가 시 DomainEventPublisher가 호출되고 LikeCreatedEvent가 발행된다") + @Test + void addLike_publishesDomainEvent() { + Product product = productRepository.save( + new Product(1L, "에어맥스", new Price(150000), new Stock(10))); + + likeFacade.addLike(1L, product.getId()); + + assertThat(publishedEvents).hasSize(1); + PublishedEvent published = publishedEvents.get(0); + assertThat(published.aggregateType()).isEqualTo("catalog"); + assertThat(published.aggregateId()).isEqualTo(String.valueOf(product.getId())); + assertThat(published.eventType()).isEqualTo("LIKE_CREATED"); + assertThat(published.event()).isInstanceOf(LikeCreatedEvent.class); + LikeCreatedEvent event = (LikeCreatedEvent) published.event(); + assertThat(event.productId()).isEqualTo(product.getId()); + assertThat(event.memberId()).isEqualTo(1L); + } + + @DisplayName("좋아요 취소 시 DomainEventPublisher가 호출되고 LikeRemovedEvent가 발행된다") + @Test + void removeLike_publishesDomainEvent() { + Product product = productRepository.save( + new Product(1L, "에어맥스", new Price(150000), new Stock(10))); + likeFacade.addLike(1L, product.getId()); + publishedEvents.clear(); + + likeFacade.removeLike(1L, product.getId()); + + assertThat(publishedEvents).hasSize(1); + PublishedEvent published = publishedEvents.get(0); + assertThat(published.eventType()).isEqualTo("LIKE_REMOVED"); + assertThat(published.event()).isInstanceOf(LikeRemovedEvent.class); + } + + @DisplayName("이미 좋아요한 상품에 다시 좋아요하면 이벤트가 발행되지 않는다") + @Test + void addLike_whenIdempotent_noEvent() { + Product product = productRepository.save( + new Product(1L, "에어맥스", new Price(150000), new Stock(10))); + likeFacade.addLike(1L, product.getId()); + publishedEvents.clear(); + + likeFacade.addLike(1L, product.getId()); + + assertThat(publishedEvents).isEmpty(); + } + + @DisplayName("좋아요하지 않은 상품을 취소하면 이벤트가 발행되지 않는다") + @Test + void removeLike_whenNotLiked_noEvent() { + Product product = productRepository.save( + new Product(1L, "에어맥스", new Price(150000), new Stock(10))); + + likeFacade.removeLike(1L, product.getId()); + + assertThat(publishedEvents).isEmpty(); + } + } + + @Nested + @DisplayName("회원별 좋아요 목록 조회") + class GetLikesByMemberId { + + @DisplayName("회원의 좋아요 목록이 반환된다") + @Test + void getLikesByMemberId_returnsLikes() { + Product product1 = productRepository.save( + new Product(1L, "에어맥스", new Price(150000), new Stock(10))); + Product product2 = productRepository.save( + new Product(1L, "에어포스", new Price(120000), new Stock(20))); + Long memberId = 1L; + likeFacade.addLike(memberId, product1.getId()); + likeFacade.addLike(memberId, product2.getId()); + + List result = likeFacade.getLikesByMemberId(memberId); + + assertThat(result).hasSize(2); + } + + @DisplayName("좋아요한 상품이 없으면 빈 리스트가 반환된다") + @Test + void getLikesByMemberId_whenEmpty_returnsEmptyList() { + List result = likeFacade.getLikesByMemberId(1L); + + assertThat(result).isEmpty(); + } + + @DisplayName("다른 회원의 좋아요는 포함되지 않는다") + @Test + void getLikesByMemberId_excludesOtherMembers() { + Product product = productRepository.save( + new Product(1L, "에어맥스", new Price(150000), new Stock(10))); + likeFacade.addLike(1L, product.getId()); + likeFacade.addLike(2L, product.getId()); + + List result = likeFacade.getLikesByMemberId(1L); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getMemberId()).isEqualTo(1L); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/member/MemberFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberFacadeIntegrationTest.java new file mode 100644 index 0000000000..281bd1ce08 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberFacadeIntegrationTest.java @@ -0,0 +1,98 @@ +package com.loopers.application.member; + +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberRepository; +import com.loopers.support.error.CoreException; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; + +@SpringBootTest +class MemberFacadeIntegrationTest { + + @MockitoSpyBean + private MemberRepository memberRepository; + + @Autowired + private MemberFacade memberFacade; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("회원 가입") + @Nested + class Register { + + @DisplayName("회원 가입시 User 저장이 수행된다") + @Test + void register_savesUser_verifiedBySpy() { + // act + Member result = memberFacade.register( + "user1", "Password1!", "홍길동", "1990-01-15", "test@example.com"); + + // assert + verify(memberRepository).save(any(Member.class)); + assertThat(result.getId()).isNotNull(); + } + + @DisplayName("이미 가입된 ID로 회원가입 시도 시 실패한다") + @Test + void register_withDuplicateId_throwsException() { + // arrange + memberFacade.register( + "user1", "Password1!", "홍길동", "1990-01-15", "test@example.com"); + + // act & assert + assertThatThrownBy(() -> memberFacade.register( + "user1", "Password2!", "김철수", "1995-05-20", "other@example.com")) + .isInstanceOf(CoreException.class); + } + } + + @DisplayName("내 정보 조회") + @Nested + class FindByLoginId { + + @DisplayName("해당 ID의 회원이 존재할 경우 회원 정보가 반환된다") + @Test + void findByLoginId_whenExists_returnsMember() { + // arrange + memberFacade.register( + "user1", "Password1!", "홍길동", "1990-01-15", "test@example.com"); + + // act + Optional result = memberFacade.findByLoginId("user1"); + + // assert + assertThat(result).isPresent(); + assertThat(result.get().getLoginId().value()).isEqualTo("user1"); + } + + @DisplayName("해당 ID의 회원이 존재하지 않을 경우 null이 반환된다") + @Test + void findByLoginId_whenNotExists_returnsEmpty() { + // act + Optional result = memberFacade.findByLoginId("nobody"); + + // assert + assertThat(result).isEmpty(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java new file mode 100644 index 0000000000..4a58ea7c7e --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java @@ -0,0 +1,511 @@ +package com.loopers.application.order; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.application.coupon.CouponFacade; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.coupon.*; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderItem; +import com.loopers.domain.order.OrderStatus; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.vo.Price; +import com.loopers.domain.product.vo.Stock; +import com.loopers.domain.event.DomainEventPublisher; +import com.loopers.fake.*; +import com.loopers.infrastructure.redis.CouponIssueRequestRedisRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.kafka.core.KafkaTemplate; + +import java.time.Clock; +import java.time.ZonedDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; + +class OrderFacadeTest { + + private OrderFacade orderFacade; + private FakeOrderRepository orderRepository; + private FakeProductRepository productRepository; + private FakeBrandRepository brandRepository; + private FakeCouponRepository couponRepository; + private FakeCouponIssueRepository couponIssueRepository; + private CouponFacade couponFacade; + + @BeforeEach + void setUp() { + orderRepository = new FakeOrderRepository(); + productRepository = new FakeProductRepository(); + brandRepository = new FakeBrandRepository(); + couponRepository = new FakeCouponRepository(); + couponIssueRepository = new FakeCouponIssueRepository(); + couponFacade = new CouponFacade(couponRepository, couponIssueRepository, + mock(CouponIssueRequestRedisRepository.class), + mock(KafkaTemplate.class), new ObjectMapper(), + Clock.systemDefaultZone()); + DomainEventPublisher noOpPublisher = (type, id, eventType, payload, event) -> {}; + orderFacade = new OrderFacade(orderRepository, productRepository, brandRepository, + couponFacade, noOpPublisher); + } + + @Nested + @DisplayName("주문 생성") + class CreateOrder { + + @DisplayName("주문을 생성하면 상품 정보가 스냅샷되고 재고가 차감된다") + @Test + void createOrder_snapshotsProductInfoAndDecreasesStock() { + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + Product product = productRepository.save( + new Product(brand.getId(), "에어맥스", new Price(150000), new Stock(10))); + + List requests = List.of( + new OrderFacade.OrderItemRequest(product.getId(), 2)); + + Order result = orderFacade.createOrder(1L, requests); + + assertThat(result.getId()).isNotNull(); + assertThat(result.getMemberId()).isEqualTo(1L); + assertThat(result.getStatus()).isEqualTo(OrderStatus.CREATED); + assertThat(result.getTotalPrice()).isEqualTo(300000); + assertThat(result.getOriginalTotalPrice()).isEqualTo(300000); + assertThat(result.getDiscountAmount()).isEqualTo(0); + assertThat(result.getCouponIssueId()).isNull(); + assertThat(result.getItems()).hasSize(1); + + OrderItem item = result.getItems().get(0); + assertThat(item.getProductId()).isEqualTo(product.getId()); + assertThat(item.getProductName()).isEqualTo("에어맥스"); + assertThat(item.getProductPrice()).isEqualTo(150000); + assertThat(item.getBrandName()).isEqualTo("나이키"); + assertThat(item.getQuantity()).isEqualTo(2); + assertThat(product.getStock().getQuantity()).isEqualTo(8); + } + + @DisplayName("여러 상품을 주문하면 각 상품의 재고가 차감되고 총 가격이 계산된다") + @Test + void createOrder_withMultipleItems_decreasesStocksAndCalculatesTotal() { + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + Product product1 = productRepository.save( + new Product(brand.getId(), "에어맥스", new Price(150000), new Stock(10))); + Product product2 = productRepository.save( + new Product(brand.getId(), "에어포스", new Price(120000), new Stock(20))); + + List requests = List.of( + new OrderFacade.OrderItemRequest(product1.getId(), 1), + new OrderFacade.OrderItemRequest(product2.getId(), 3)); + + Order result = orderFacade.createOrder(1L, requests); + + assertThat(result.getItems()).hasSize(2); + assertThat(result.getTotalPrice()).isEqualTo(150000 + 120000 * 3); + assertThat(product1.getStock().getQuantity()).isEqualTo(9); + assertThat(product2.getStock().getQuantity()).isEqualTo(17); + } + + @DisplayName("재고가 부족하면 예외가 발생한다") + @Test + void createOrder_whenInsufficientStock_throwsException() { + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + productRepository.save( + new Product(brand.getId(), "에어맥스", new Price(150000), new Stock(2))); + + List requests = List.of( + new OrderFacade.OrderItemRequest(1L, 5)); + + assertThatThrownBy(() -> orderFacade.createOrder(1L, requests)) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("존재하지 않는 상품을 주문하면 예외가 발생한다") + @Test + void createOrder_whenProductNotExists_throwsCoreException() { + List requests = List.of( + new OrderFacade.OrderItemRequest(999L, 1)); + + assertThatThrownBy(() -> orderFacade.createOrder(1L, requests)) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("브랜드가 없는 상품을 주문하면 브랜드 이름이 null로 스냅샷된다") + @Test + void createOrder_whenBrandNotExists_snapshotsNullBrandName() { + Product product = productRepository.save( + new Product(999L, "에어맥스", new Price(150000), new Stock(10))); + + List requests = List.of( + new OrderFacade.OrderItemRequest(product.getId(), 1)); + + Order result = orderFacade.createOrder(1L, requests); + + assertThat(result.getItems().get(0).getBrandName()).isNull(); + } + } + + @Nested + @DisplayName("쿠폰 적용 주문") + class CreateOrderWithCoupon { + + @DisplayName("정액 쿠폰을 적용하면 할인이 반영된 주문이 생성된다") + @Test + void createOrder_withFixedCoupon_appliesDiscount() { + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + Product product = productRepository.save( + new Product(brand.getId(), "에어맥스", new Price(100000), new Stock(10))); + Coupon coupon = couponRepository.save( + new Coupon("5000원 할인", DiscountType.FIXED, 5000, 10000, + ZonedDateTime.now().plusDays(30))); + CouponIssue couponIssue = couponIssueRepository.save( + new CouponIssue(coupon.getId(), 1L, coupon.getExpiredAt())); + + Order result = orderFacade.createOrder(1L, + List.of(new OrderFacade.OrderItemRequest(product.getId(), 1)), + couponIssue.getId()); + + assertThat(result.getOriginalTotalPrice()).isEqualTo(100000); + assertThat(result.getDiscountAmount()).isEqualTo(5000); + assertThat(result.getTotalPrice()).isEqualTo(95000); + assertThat(result.getCouponIssueId()).isEqualTo(couponIssue.getId()); + assertThat(couponIssue.getStatus()).isEqualTo(CouponIssueStatus.USED); + } + + @DisplayName("정률 쿠폰을 적용하면 비율에 따른 할인이 반영된다") + @Test + void createOrder_withRateCoupon_appliesPercentageDiscount() { + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + Product product = productRepository.save( + new Product(brand.getId(), "에어맥스", new Price(100000), new Stock(10))); + Coupon coupon = couponRepository.save( + new Coupon("10% 할인", DiscountType.RATE, 10, 10000, + ZonedDateTime.now().plusDays(30))); + CouponIssue couponIssue = couponIssueRepository.save( + new CouponIssue(coupon.getId(), 1L, coupon.getExpiredAt())); + + Order result = orderFacade.createOrder(1L, + List.of(new OrderFacade.OrderItemRequest(product.getId(), 1)), + couponIssue.getId()); + + assertThat(result.getOriginalTotalPrice()).isEqualTo(100000); + assertThat(result.getDiscountAmount()).isEqualTo(10000); + assertThat(result.getTotalPrice()).isEqualTo(90000); + } + + @DisplayName("이미 사용된 쿠폰으로 주문하면 예외가 발생한다") + @Test + void createOrder_withUsedCoupon_throwsException() { + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + Product product = productRepository.save( + new Product(brand.getId(), "에어맥스", new Price(100000), new Stock(10))); + Coupon coupon = couponRepository.save( + new Coupon("할인", DiscountType.FIXED, 5000, 0, + ZonedDateTime.now().plusDays(30))); + CouponIssue couponIssue = couponIssueRepository.save( + new CouponIssue(coupon.getId(), 1L, coupon.getExpiredAt())); + couponIssue.use(99L, ZonedDateTime.now()); + + assertThatThrownBy(() -> orderFacade.createOrder(1L, + List.of(new OrderFacade.OrderItemRequest(product.getId(), 1)), + couponIssue.getId())) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("타인의 쿠폰으로 주문하면 예외가 발생한다") + @Test + void createOrder_withOtherMemberCoupon_throwsException() { + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + Product product = productRepository.save( + new Product(brand.getId(), "에어맥스", new Price(100000), new Stock(10))); + Coupon coupon = couponRepository.save( + new Coupon("할인", DiscountType.FIXED, 5000, 0, + ZonedDateTime.now().plusDays(30))); + CouponIssue couponIssue = couponIssueRepository.save( + new CouponIssue(coupon.getId(), 2L, coupon.getExpiredAt())); + + assertThatThrownBy(() -> orderFacade.createOrder(1L, + List.of(new OrderFacade.OrderItemRequest(product.getId(), 1)), + couponIssue.getId())) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.FORBIDDEN); + } + + @DisplayName("만료된 쿠폰으로 주문하면 예외가 발생한다") + @Test + void createOrder_withExpiredCoupon_throwsException() { + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + Product product = productRepository.save( + new Product(brand.getId(), "에어맥스", new Price(100000), new Stock(10))); + Coupon coupon = couponRepository.save( + new Coupon("할인", DiscountType.FIXED, 5000, 0, + ZonedDateTime.now().minusDays(1))); + CouponIssue couponIssue = couponIssueRepository.save( + new CouponIssue(coupon.getId(), 1L, coupon.getExpiredAt())); + + assertThatThrownBy(() -> orderFacade.createOrder(1L, + List.of(new OrderFacade.OrderItemRequest(product.getId(), 1)), + couponIssue.getId())) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("존재하지 않는 쿠폰으로 주문하면 예외가 발생한다") + @Test + void createOrder_withNonExistentCoupon_throwsException() { + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + Product product = productRepository.save( + new Product(brand.getId(), "에어맥스", new Price(100000), new Stock(10))); + + assertThatThrownBy(() -> orderFacade.createOrder(1L, + List.of(new OrderFacade.OrderItemRequest(product.getId(), 1)), + 999L)) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.NOT_FOUND); + } + } + + @Nested + @DisplayName("주문 단건 조회") + class GetOrder { + + @DisplayName("본인의 주문을 조회하면 주문이 반환된다") + @Test + void getOrder_whenOwner_returnsOrder() { + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + Product product = productRepository.save( + new Product(brand.getId(), "에어맥스", new Price(150000), new Stock(10))); + Order order = orderFacade.createOrder(1L, List.of( + new OrderFacade.OrderItemRequest(product.getId(), 1))); + + Order result = orderFacade.getOrder(order.getId(), 1L); + + assertThat(result.getId()).isEqualTo(order.getId()); + assertThat(result.getMemberId()).isEqualTo(1L); + } + + @DisplayName("타인의 주문을 조회하면 예외가 발생한다") + @Test + void getOrder_whenNotOwner_throwsForbidden() { + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + Product product = productRepository.save( + new Product(brand.getId(), "에어맥스", new Price(150000), new Stock(10))); + Order order = orderFacade.createOrder(1L, List.of( + new OrderFacade.OrderItemRequest(product.getId(), 1))); + + assertThatThrownBy(() -> orderFacade.getOrder(order.getId(), 2L)) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.FORBIDDEN); + } + + @DisplayName("존재하지 않는 주문을 조회하면 예외가 발생한다") + @Test + void getOrder_whenNotExists_throwsCoreException() { + assertThatThrownBy(() -> orderFacade.getOrder(999L, 1L)) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.NOT_FOUND); + } + } + + @Nested + @DisplayName("주문 취소") + class CancelOrder { + + @DisplayName("주문을 취소하면 상태가 CANCELLED로 변경되고 재고가 복원된다") + @Test + void cancelOrder_cancelsAndRestoresStock() { + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + Product product = productRepository.save( + new Product(brand.getId(), "에어맥스", new Price(150000), new Stock(10))); + Order order = orderFacade.createOrder(1L, List.of( + new OrderFacade.OrderItemRequest(product.getId(), 3))); + assertThat(product.getStock().getQuantity()).isEqualTo(7); + + orderFacade.cancelOrder(order.getId(), 1L); + + assertThat(order.getStatus()).isEqualTo(OrderStatus.CANCELLED); + assertThat(product.getStock().getQuantity()).isEqualTo(10); + } + + @DisplayName("쿠폰 적용된 주문을 취소하면 쿠폰이 복원된다") + @Test + void cancelOrder_withCoupon_restoresCoupon() { + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + Product product = productRepository.save( + new Product(brand.getId(), "에어맥스", new Price(100000), new Stock(10))); + Coupon coupon = couponRepository.save( + new Coupon("할인", DiscountType.FIXED, 5000, 0, + ZonedDateTime.now().plusDays(30))); + CouponIssue couponIssue = couponIssueRepository.save( + new CouponIssue(coupon.getId(), 1L, coupon.getExpiredAt())); + Order order = orderFacade.createOrder(1L, + List.of(new OrderFacade.OrderItemRequest(product.getId(), 1)), + couponIssue.getId()); + assertThat(couponIssue.getStatus()).isEqualTo(CouponIssueStatus.USED); + + orderFacade.cancelOrder(order.getId(), 1L); + + assertThat(couponIssue.getStatus()).isEqualTo(CouponIssueStatus.AVAILABLE); + } + + @DisplayName("만료된 쿠폰이 적용된 주문을 취소하면 쿠폰이 EXPIRED로 변경된다") + @Test + void cancelOrder_withExpiredCoupon_setsCouponExpired() { + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + Product product = productRepository.save( + new Product(brand.getId(), "에어맥스", new Price(100000), new Stock(10))); + Coupon coupon = couponRepository.save( + new Coupon("할인", DiscountType.FIXED, 5000, 0, + ZonedDateTime.now().plusSeconds(1))); + CouponIssue couponIssue = couponIssueRepository.save( + new CouponIssue(coupon.getId(), 1L, coupon.getExpiredAt())); + Order order = orderFacade.createOrder(1L, + List.of(new OrderFacade.OrderItemRequest(product.getId(), 1)), + couponIssue.getId()); + assertThat(couponIssue.getStatus()).isEqualTo(CouponIssueStatus.USED); + + // 쿠폰 만료 후 취소 — Clock 기반이므로 실제 시간에 의존하지만 + // CouponFacade가 Clock.systemDefaultZone()을 사용하므로 + // 만료시간이 1초 뒤로 설정되어 테스트 시점에 이미 만료됨에 가까움 + // 명시적으로 확인하기 위해 직접 cancelUse 호출로 검증 + couponIssue.cancelUse(coupon.getExpiredAt().plusSeconds(1)); + + assertThat(couponIssue.getStatus()).isEqualTo(CouponIssueStatus.EXPIRED); + } + + @DisplayName("타인의 주문을 취소하면 예외가 발생한다") + @Test + void cancelOrder_whenNotOwner_throwsForbidden() { + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + Product product = productRepository.save( + new Product(brand.getId(), "에어맥스", new Price(150000), new Stock(10))); + Order order = orderFacade.createOrder(1L, List.of( + new OrderFacade.OrderItemRequest(product.getId(), 1))); + + assertThatThrownBy(() -> orderFacade.cancelOrder(order.getId(), 2L)) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.FORBIDDEN); + } + + @DisplayName("이미 취소된 주문을 다시 취소하면 예외가 발생한다") + @Test + void cancelOrder_whenAlreadyCancelled_throwsException() { + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + Product product = productRepository.save( + new Product(brand.getId(), "에어맥스", new Price(150000), new Stock(10))); + Order order = orderFacade.createOrder(1L, List.of( + new OrderFacade.OrderItemRequest(product.getId(), 1))); + orderFacade.cancelOrder(order.getId(), 1L); + + assertThatThrownBy(() -> orderFacade.cancelOrder(order.getId(), 1L)) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("존재하지 않는 주문을 취소하면 예외가 발생한다") + @Test + void cancelOrder_whenNotExists_throwsCoreException() { + assertThatThrownBy(() -> orderFacade.cancelOrder(999L, 1L)) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.NOT_FOUND); + } + } + + @Nested + @DisplayName("회원별 주문 목록 조회") + class GetOrdersByMemberId { + + @DisplayName("기간 조건 없이 조회하면 회원의 전체 주문이 반환된다") + @Test + void getOrdersByMemberId_withoutDateRange_returnsAll() { + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + Product product = productRepository.save( + new Product(brand.getId(), "에어맥스", new Price(150000), new Stock(100))); + orderFacade.createOrder(1L, List.of( + new OrderFacade.OrderItemRequest(product.getId(), 1))); + orderFacade.createOrder(1L, List.of( + new OrderFacade.OrderItemRequest(product.getId(), 2))); + orderFacade.createOrder(2L, List.of( + new OrderFacade.OrderItemRequest(product.getId(), 1))); + + List result = orderFacade.getOrdersByMemberId(1L, null, null); + + assertThat(result).hasSize(2); + assertThat(result).allSatisfy(order -> + assertThat(order.getMemberId()).isEqualTo(1L)); + } + + @DisplayName("기간 조건으로 조회하면 해당 기간의 주문만 반환된다") + @Test + void getOrdersByMemberId_withDateRange_returnsFiltered() { + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + Product product = productRepository.save( + new Product(brand.getId(), "에어맥스", new Price(150000), new Stock(100))); + orderFacade.createOrder(1L, List.of( + new OrderFacade.OrderItemRequest(product.getId(), 1))); + + ZonedDateTime now = ZonedDateTime.now(); + ZonedDateTime startAt = now.minusHours(1); + ZonedDateTime endAt = now.plusHours(1); + + List result = orderFacade.getOrdersByMemberId(1L, startAt, endAt); + + assertThat(result).hasSize(1); + } + + @DisplayName("주문이 없는 회원을 조회하면 빈 리스트가 반환된다") + @Test + void getOrdersByMemberId_whenNoOrders_returnsEmptyList() { + List result = orderFacade.getOrdersByMemberId(999L, null, null); + + assertThat(result).isEmpty(); + } + } + + @Nested + @DisplayName("전체 주문 조회") + class GetAllOrders { + + @DisplayName("모든 주문이 반환된다") + @Test + void getAllOrders_returnsAll() { + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + Product product = productRepository.save( + new Product(brand.getId(), "에어맥스", new Price(150000), new Stock(100))); + orderFacade.createOrder(1L, List.of( + new OrderFacade.OrderItemRequest(product.getId(), 1))); + orderFacade.createOrder(2L, List.of( + new OrderFacade.OrderItemRequest(product.getId(), 1))); + + List result = orderFacade.getAllOrders(); + + assertThat(result).hasSize(2); + } + + @DisplayName("주문이 없으면 빈 리스트가 반환된다") + @Test + void getAllOrders_whenEmpty_returnsEmptyList() { + List result = orderFacade.getAllOrders(); + + assertThat(result).isEmpty(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/ProvisionalOrderServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/ProvisionalOrderServiceTest.java new file mode 100644 index 0000000000..6273ffa9ef --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/ProvisionalOrderServiceTest.java @@ -0,0 +1,130 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.Order; +import com.loopers.fake.FakeOrderRepository; +import com.loopers.fake.FakeProductRepository; +import com.loopers.fake.FakeProvisionalOrderRedisRepository; +import com.loopers.fake.FakeStockReservationRedisRepository; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.vo.Price; +import com.loopers.domain.product.vo.Stock; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class ProvisionalOrderServiceTest { + + private ProvisionalOrderService service; + private FakeProvisionalOrderRedisRepository redisRepository; + private FakeStockReservationRedisRepository stockRedisRepository; + private FakeOrderRepository orderRepository; + private FakeProductRepository productRepository; + + @BeforeEach + void setUp() { + redisRepository = new FakeProvisionalOrderRedisRepository(); + stockRedisRepository = new FakeStockReservationRedisRepository(); + orderRepository = new FakeOrderRepository(); + productRepository = new FakeProductRepository(); + + service = new ProvisionalOrderService( + redisRepository, stockRedisRepository, orderRepository, productRepository); + } + + private Product createProduct(int stockQuantity) { + Product product = new Product(1L, "에어맥스", new Price(5000), new Stock(stockQuantity)); + return productRepository.save(product); + } + + @Nested + @DisplayName("가주문 생성") + class SaveProvisionalOrder { + + @DisplayName("U3-1: Redis 정상 → 가주문 Redis 저장 + 재고 예약") + @Test + void save_success_storedInRedis() { + Product product = createProduct(100); + stockRedisRepository.setStock(product.getId(), 100); + + List items = List.of( + new Order.ItemSnapshot(product.getId(), "에어맥스", 5000, "나이키", 2) + ); + + ProvisionalOrderService.ProvisionalOrderResult result = + service.saveProvisionalOrder(1L, 100L, 10000, "SAMSUNG", "1234", items); + + // Redis에 가주문 저장 확인 + assertThat(result.isDirect()).isFalse(); + assertThat(result.orderId()).isEqualTo(1L); + assertThat(redisRepository.exists(1L)).isTrue(); + + // Redis 재고 예약(DECR) 확인 + assertThat(stockRedisRepository.getStock(product.getId())).isEqualTo(98L); + } + + @DisplayName("U3-2: Redis 장애 → DB 직접 주문 Fallback") + @Test + void save_redisFail_fallbackToDb() { + Product product = createProduct(100); + + List items = List.of( + new Order.ItemSnapshot(product.getId(), "에어맥스", 5000, "나이키", 2) + ); + + // Fallback 메서드 직접 호출 (Spring AOP 없이 테스트) + ProvisionalOrderService.ProvisionalOrderResult result = + service.saveToDbFallback(1L, 100L, 10000, "SAMSUNG", "1234", items, + new RuntimeException("Redis 연결 실패")); + + // DB에 Order 직접 생성 확인 + assertThat(result.isDirect()).isTrue(); + assertThat(result.orderId()).isNotNull(); + + // DB 재고 차감 확인 + Product updated = productRepository.findById(product.getId()).orElseThrow(); + assertThat(updated.getStock().getQuantity()).isEqualTo(98); + + // Redis에는 저장되지 않음 + assertThat(redisRepository.exists(1L)).isFalse(); + } + } + + @Nested + @DisplayName("가주문 조회/삭제") + class QueryAndDelete { + + @DisplayName("가주문 조회 성공") + @Test + void getProvisionalOrder_exists_returnsData() { + Product product = createProduct(100); + stockRedisRepository.setStock(product.getId(), 100); + + List items = List.of( + new Order.ItemSnapshot(product.getId(), "에어맥스", 5000, "나이키", 1) + ); + service.saveProvisionalOrder(1L, 100L, 5000, "SAMSUNG", "1234", items); + + assertThat(service.getProvisionalOrder(1L)).isPresent(); + } + + @DisplayName("가주문 삭제 후 조회 불가") + @Test + void deleteProvisionalOrder_thenNotFound() { + Product product = createProduct(100); + stockRedisRepository.setStock(product.getId(), 100); + + List items = List.of( + new Order.ItemSnapshot(product.getId(), "에어맥스", 5000, "나이키", 1) + ); + service.saveProvisionalOrder(1L, 100L, 5000, "SAMSUNG", "1234", items); + service.deleteProvisionalOrder(1L); + + assertThat(service.exists(1L)).isFalse(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/payment/CallbackMissFaultTest.java b/apps/commerce-api/src/test/java/com/loopers/application/payment/CallbackMissFaultTest.java new file mode 100644 index 0000000000..422d9dc4e6 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/payment/CallbackMissFaultTest.java @@ -0,0 +1,153 @@ +package com.loopers.application.payment; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.application.coupon.CouponFacade; +import com.loopers.application.product.ProductFacade; +import com.loopers.domain.BaseEntity; +import com.loopers.infrastructure.redis.CouponIssueRequestRedisRepository; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderStatus; +import com.loopers.domain.payment.*; +import com.loopers.fake.*; +import com.loopers.infrastructure.pg.PgPaymentStatusResponse; +import com.loopers.infrastructure.pg.PgRouter; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.kafka.core.KafkaTemplate; + +import java.lang.reflect.Field; +import java.time.Clock; +import java.time.ZonedDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +/** + * F7-3: 콜백 미수신 → Polling Hybrid 복구 시나리오. + * + *

PG 결제 요청 성공 → Payment PENDING → 콜백이 오지 않는 상황. + * 10초 후 Polling Hybrid가 PG를 조회하여 결과를 확인.

+ * + * @see Polling Hybrid + */ +class CallbackMissFaultTest { + + private PaymentRecoveryService recoveryService; + private FakePaymentRepository paymentRepository; + private FakeOrderRepository orderRepository; + private FakePgClient pgClient; + + @SuppressWarnings("unchecked") + @BeforeEach + void setUp() { + paymentRepository = new FakePaymentRepository(); + orderRepository = new FakeOrderRepository(); + FakeCallbackInboxRepository callbackInboxRepository = new FakeCallbackInboxRepository(); + FakeProductRepository productRepository = new FakeProductRepository(); + FakeStockReservationRedisRepository stockRedisRepository = new FakeStockReservationRedisRepository(); + pgClient = new FakePgClient("SIMULATOR"); + PgRouter pgRouter = new PgRouter(List.of(pgClient)); + + ProductFacade productFacade = new ProductFacade( + productRepository, new FakeBrandRepository(), new FakeLikeRepository(), + new FakeProductCachePort(), event -> {}, stockRedisRepository, null); + + CouponFacade couponFacade = new CouponFacade(new FakeCouponRepository(), new FakeCouponIssueRepository(), + mock(CouponIssueRequestRedisRepository.class), mock(KafkaTemplate.class), new ObjectMapper(), Clock.systemDefaultZone()); + + recoveryService = new PaymentRecoveryService( + paymentRepository, new FakePaymentStatusHistoryRepository(), + callbackInboxRepository, orderRepository, + productFacade, couponFacade, pgRouter); + } + + @DisplayName("F7-3: PENDING → 콜백 미수신 → 10초 후 Polling → PG SUCCESS → PAID") + @Test + void callbackMiss_polling_recovery() throws Exception { + // Given: 주문 + Payment(PENDING) 생성, 콜백 미수신 + Order order = orderRepository.save( + Order.create(100L, List.of( + new Order.ItemSnapshot(1L, "에어맥스", 5000, "나이키", 1) + ))); + + PaymentModel payment = PaymentModel.create(order.getId(), 5000, "SAMSUNG", "1234"); + payment.markPending("TX-MISS-001", "SIMULATOR"); + payment = paymentRepository.save(payment); + + // 10초 이상 경과 시뮬레이션 (Polling 대상) + setCreatedAt(payment, ZonedDateTime.now().minusSeconds(15)); + + // PG에는 SUCCESS 상태 (콜백은 유실되었지만 PG는 정상 처리) + pgClient.registerStatus("TX-MISS-001", + new PgPaymentStatusResponse("SUCCESS", "TX-MISS-001", null)); + + // When: Polling Hybrid 실행 + recoveryService.checkPendingPayments(); + + // Then: Payment → PAID, Order → PAID + PaymentModel recovered = paymentRepository.findById(payment.getId()).orElseThrow(); + assertThat(recovered.getStatus()).isEqualTo(PaymentStatus.PAID); + + Order updatedOrder = orderRepository.findById(order.getId()).orElseThrow(); + assertThat(updatedOrder.getStatus()).isEqualTo(OrderStatus.PAID); + } + + @DisplayName("F7-3 변형: PENDING → 콜백 미수신 → Polling → PG FAILED → 재고/쿠폰 복원") + @Test + void callbackMiss_polling_pgFailed_restore() throws Exception { + // Given: 주문 + Payment(PENDING) + Order order = orderRepository.save( + Order.create(100L, List.of( + new Order.ItemSnapshot(1L, "에어맥스", 5000, "나이키", 1) + ))); + + PaymentModel payment = PaymentModel.create(order.getId(), 5000, "SAMSUNG", "1234"); + payment.markPending("TX-MISS-002", "SIMULATOR"); + payment = paymentRepository.save(payment); + setCreatedAt(payment, ZonedDateTime.now().minusSeconds(15)); + + // PG에는 FAILED 상태 + pgClient.registerStatus("TX-MISS-002", + new PgPaymentStatusResponse("FAILED", "TX-MISS-002", "잔액 부족")); + + // When: Polling 실행 + recoveryService.checkPendingPayments(); + + // Then: Payment → FAILED + PaymentModel recovered = paymentRepository.findById(payment.getId()).orElseThrow(); + assertThat(recovered.getStatus()).isEqualTo(PaymentStatus.FAILED); + } + + @DisplayName("최근 PENDING (10초 미만) → Polling 대상 아님") + @Test + void recentPending_notPolled() throws Exception { + // Given: 5초 전 생성된 PENDING Payment + Order order = orderRepository.save( + Order.create(100L, List.of( + new Order.ItemSnapshot(1L, "에어맥스", 5000, "나이키", 1) + ))); + + PaymentModel payment = PaymentModel.create(order.getId(), 5000, "SAMSUNG", "1234"); + payment.markPending("TX-RECENT", "SIMULATOR"); + payment = paymentRepository.save(payment); + setCreatedAt(payment, ZonedDateTime.now().minusSeconds(5)); + + pgClient.registerStatus("TX-RECENT", + new PgPaymentStatusResponse("SUCCESS", "TX-RECENT", null)); + + // When: Polling 실행 + recoveryService.checkPendingPayments(); + + // Then: 아직 PENDING (10초 미만이므로 폴링 대상 아님) + PaymentModel stillPending = paymentRepository.findById(payment.getId()).orElseThrow(); + assertThat(stillPending.getStatus()).isEqualTo(PaymentStatus.PENDING); + } + + private void setCreatedAt(Object entity, ZonedDateTime createdAt) throws Exception { + Field field = BaseEntity.class.getDeclaredField("createdAt"); + field.setAccessible(true); + field.set(entity, createdAt); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/payment/DbFailureFaultTest.java b/apps/commerce-api/src/test/java/com/loopers/application/payment/DbFailureFaultTest.java new file mode 100644 index 0000000000..f66ba51028 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/payment/DbFailureFaultTest.java @@ -0,0 +1,130 @@ +package com.loopers.application.payment; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.domain.payment.*; +import com.loopers.fake.FakePaymentRepository; +import com.loopers.infrastructure.payment.PaymentWalWriter; +import com.loopers.infrastructure.scheduler.WalRecoveryScheduler; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * F7-4: DB 장애 → WAL Recovery 시나리오. + * + *

PG에서 SUCCESS 응답을 받았으나 DB 저장에 실패한 상황. + * WAL(Write-Ahead Log)에 PG 응답을 기록해둔 뒤, + * WAL Recovery 스케줄러가 주기적으로 WAL 파일을 스캔하여 DB에 반영.

+ * + * @see Local WAL + */ +class DbFailureFaultTest { + + @TempDir + Path tempDir; + + private PaymentWalWriter walWriter; + private WalRecoveryScheduler walRecovery; + private FakePaymentRepository paymentRepository; + + @BeforeEach + void setUp() { + paymentRepository = new FakePaymentRepository(); + walWriter = new PaymentWalWriter(tempDir.toString(), new ObjectMapper()); + walRecovery = new WalRecoveryScheduler(walWriter, paymentRepository, new com.loopers.fake.FakePaymentStatusHistoryRepository()); + } + + @DisplayName("F7-4: PG SUCCESS → DB 실패 → WAL 기록 → WAL Recovery → PAID") + @Test + void dbFailure_walRecovery_paid() { + // Given: Payment(PENDING) 존재 — PG에 요청까지는 성공한 상태 + PaymentModel payment = PaymentModel.create(1L, 5000, "SAMSUNG", "1234"); + payment.markPending("TX-WAL-001", "SIMULATOR"); + payment = paymentRepository.save(payment); + assertThat(payment.getStatus()).isEqualTo(PaymentStatus.PENDING); + + // DB 장애 시뮬레이션: PG SUCCESS 응답을 DB에 저장 못 함 → WAL에 기록 + walWriter.write(1L, "TX-WAL-001", "SUCCESS"); + assertThat(walWriter.listWalFiles()).hasSize(1); + + // When: WAL Recovery 스케줄러 실행 (DB 복구 후) + walRecovery.recoverFromWal(); + + // Then: Payment → PAID + WAL 파일 삭제 + PaymentModel recovered = paymentRepository.findById(payment.getId()).orElseThrow(); + assertThat(recovered.getStatus()).isEqualTo(PaymentStatus.PAID); + assertThat(walWriter.listWalFiles()).isEmpty(); + } + + @DisplayName("F7-4 변형: PG FAILED → WAL Recovery → Payment FAILED") + @Test + void dbFailure_walRecovery_failed() { + // Given: Payment(PENDING) + PG FAILED WAL + PaymentModel payment = PaymentModel.create(1L, 5000, "SAMSUNG", "1234"); + payment.markPending("TX-WAL-002", "SIMULATOR"); + payment = paymentRepository.save(payment); + + walWriter.write(1L, "TX-WAL-002", "FAILED"); + + // When: WAL Recovery + walRecovery.recoverFromWal(); + + // Then: Payment → FAILED + WAL 삭제 + PaymentModel recovered = paymentRepository.findById(payment.getId()).orElseThrow(); + assertThat(recovered.getStatus()).isEqualTo(PaymentStatus.FAILED); + assertThat(walWriter.listWalFiles()).isEmpty(); + } + + @DisplayName("이미 최종 상태인 Payment → WAL 삭제만 (중복 처리 안 함)") + @Test + void alreadyTerminal_walDeletedOnly() { + // Given: Payment 이미 PAID 상태 + PaymentModel payment = PaymentModel.create(1L, 5000, "SAMSUNG", "1234"); + payment.markPending("TX-WAL-003", "SIMULATOR"); + payment.markPaid(); + payment = paymentRepository.save(payment); + + walWriter.write(1L, "TX-WAL-003", "SUCCESS"); + + // When: WAL Recovery + walRecovery.recoverFromWal(); + + // Then: WAL 삭제 + Payment 상태 변경 없음 + assertThat(walWriter.listWalFiles()).isEmpty(); + PaymentModel unchanged = paymentRepository.findById(payment.getId()).orElseThrow(); + assertThat(unchanged.getStatus()).isEqualTo(PaymentStatus.PAID); + } + + @DisplayName("여러 WAL 파일 → 전부 처리") + @Test + void multipleWalFiles_allProcessed() { + // Given: 2개의 WAL 파일 + 대응하는 Payment + PaymentModel payment1 = PaymentModel.create(1L, 5000, "SAMSUNG", "1234"); + payment1.markPending("TX-WAL-M1", "SIMULATOR"); + payment1 = paymentRepository.save(payment1); + + PaymentModel payment2 = PaymentModel.create(2L, 3000, "HYUNDAI", "5678"); + payment2.markPending("TX-WAL-M2", "SIMULATOR"); + payment2 = paymentRepository.save(payment2); + + walWriter.write(1L, "TX-WAL-M1", "SUCCESS"); + walWriter.write(2L, "TX-WAL-M2", "FAILED"); + assertThat(walWriter.listWalFiles()).hasSize(2); + + // When: WAL Recovery + walRecovery.recoverFromWal(); + + // Then: 모두 처리됨 + assertThat(walWriter.listWalFiles()).isEmpty(); + assertThat(paymentRepository.findById(payment1.getId()).orElseThrow().getStatus()) + .isEqualTo(PaymentStatus.PAID); + assertThat(paymentRepository.findById(payment2.getId()).orElseThrow().getStatus()) + .isEqualTo(PaymentStatus.FAILED); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/payment/GhostPaymentFaultTest.java b/apps/commerce-api/src/test/java/com/loopers/application/payment/GhostPaymentFaultTest.java new file mode 100644 index 0000000000..4c1a524f21 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/payment/GhostPaymentFaultTest.java @@ -0,0 +1,145 @@ +package com.loopers.application.payment; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.application.coupon.CouponFacade; +import com.loopers.application.product.ProductFacade; +import com.loopers.infrastructure.redis.CouponIssueRequestRedisRepository; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderStatus; +import com.loopers.domain.payment.*; +import com.loopers.fake.*; +import com.loopers.infrastructure.pg.PgPaymentStatusResponse; +import com.loopers.infrastructure.pg.PgRouter; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.kafka.core.KafkaTemplate; + +import java.lang.reflect.Field; +import java.time.Clock; +import java.time.ZonedDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +/** + * F7-1: 유령 결제 복구 시나리오. + * + *

PG에 요청이 도달했으나 응답 타임아웃 → Payment UNKNOWN. + * PG는 실제로 결제를 처리한 상태(유령 결제). + * Polling Hybrid가 PG를 조회하여 PAID로 복구.

+ * + * @see 유령 결제 + */ +class GhostPaymentFaultTest { + + private PaymentFacade paymentFacade; + private PaymentRecoveryService recoveryService; + private FakePaymentRepository paymentRepository; + private FakeOrderRepository orderRepository; + private FakePaymentOutboxRepository outboxRepository; + private FakePgClient pgClient; + + @SuppressWarnings("unchecked") + @BeforeEach + void setUp() throws Exception { + paymentRepository = new FakePaymentRepository(); + orderRepository = new FakeOrderRepository(); + outboxRepository = new FakePaymentOutboxRepository(); + FakeCallbackInboxRepository callbackInboxRepository = new FakeCallbackInboxRepository(); + FakeProductRepository productRepository = new FakeProductRepository(); + FakeStockReservationRedisRepository stockRedisRepository = new FakeStockReservationRedisRepository(); + pgClient = new FakePgClient("SIMULATOR"); + PgRouter pgRouter = new PgRouter(List.of(pgClient)); + + paymentFacade = new PaymentFacade(paymentRepository, orderRepository, pgRouter, outboxRepository); + setField(paymentFacade, "callbackUrl", "http://test/callback"); + setField(paymentFacade, "maxRetryAttempts", 3); + setField(paymentFacade, "initialWaitMs", 0L); + setField(paymentFacade, "backoffMultiplier", 2); + + ProductFacade productFacade = new ProductFacade( + productRepository, new FakeBrandRepository(), new FakeLikeRepository(), + new FakeProductCachePort(), event -> {}, stockRedisRepository, null); + + CouponFacade couponFacade = new CouponFacade(new FakeCouponRepository(), new FakeCouponIssueRepository(), + mock(CouponIssueRequestRedisRepository.class), mock(KafkaTemplate.class), new ObjectMapper(), Clock.systemDefaultZone()); + + recoveryService = new PaymentRecoveryService( + paymentRepository, new FakePaymentStatusHistoryRepository(), + callbackInboxRepository, orderRepository, + productFacade, couponFacade, pgRouter); + } + + @DisplayName("F7-1: 타임아웃 → UNKNOWN → Polling → PG SUCCESS 발견 → PAID 복구") + @Test + void ghostPayment_timeout_polling_recovery() throws Exception { + // Given: 주문 생성 + Order order = orderRepository.save( + Order.create(100L, List.of( + new Order.ItemSnapshot(1L, "에어맥스", 5000, "나이키", 1) + ))); + + // 1단계: PG 타임아웃 → 모든 요청 실패 → UNKNOWN + pgClient.setThrowTimeout(true); + PaymentFacade.PaymentResult result = paymentFacade.requestPayment( + order.getId(), "SAMSUNG", "1234", 5000); + + assertThat(result.status()).isEqualTo("UNKNOWN"); + PaymentModel unknownPayment = paymentRepository.findById(result.paymentId()).orElseThrow(); + assertThat(unknownPayment.getStatus()).isEqualTo(PaymentStatus.UNKNOWN); + + // 2단계: PG 복구 (실제로는 PG가 결제를 처리한 상태) + // PG 타임아웃 해제 + 유령 결제 상태 등록 + pgClient.setThrowTimeout(false); + pgClient.registerOrderStatus(String.valueOf(order.getId()), + new PgPaymentStatusResponse("SUCCESS", "TX-GHOST-001", null)); + + // orderId로 Payment를 찾아서 transactionKey 설정 (타임아웃으로 transactionKey 없는 상태) + // UNKNOWN 상태 Payment는 transactionKey가 없으므로 orderId 기반 폴링 + // → checkPendingPayments에서 getPaymentByOrderId로 조회 + + // 3단계: Polling Hybrid 실행 → PG 조회 → SUCCESS → PAID + recoveryService.checkPendingPayments(); + + // Then: Payment → PAID, Order → PAID + PaymentModel recoveredPayment = paymentRepository.findById(result.paymentId()).orElseThrow(); + assertThat(recoveredPayment.getStatus()).isEqualTo(PaymentStatus.PAID); + + Order updatedOrder = orderRepository.findById(order.getId()).orElseThrow(); + assertThat(updatedOrder.getStatus()).isEqualTo(OrderStatus.PAID); + } + + @DisplayName("F7-1 변형: PENDING + 콜백 경로로 유령 결제 복구 (transactionKey 보유)") + @Test + void ghostPayment_pending_callback_recovery() { + // Given: PG 호출 성공 → PENDING 상태 (transactionKey 보유) + // 콜백이 지연/유실되었다가 뒤늦게 도착하는 시나리오 + Order order = orderRepository.save( + Order.create(100L, List.of( + new Order.ItemSnapshot(1L, "에어맥스", 5000, "나이키", 1) + ))); + + PaymentModel payment = PaymentModel.create(order.getId(), 5000, "SAMSUNG", "1234"); + payment.markPending("TX-GHOST-002", "SIMULATOR"); + paymentRepository.save(payment); + + // When: PG에서 SUCCESS 콜백 (지연 도착) + recoveryService.processCallback("TX-GHOST-002", "SUCCESS", + "{\"orderId\":" + order.getId() + "}"); + + // Then: Payment → PAID, Order → PAID + PaymentModel recovered = paymentRepository.findById(payment.getId()).orElseThrow(); + assertThat(recovered.getStatus()).isEqualTo(PaymentStatus.PAID); + + Order updatedOrder = orderRepository.findById(order.getId()).orElseThrow(); + assertThat(updatedOrder.getStatus()).isEqualTo(OrderStatus.PAID); + } + + private void setField(Object target, String fieldName, Object value) throws Exception { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/payment/ManualRecoveryTest.java b/apps/commerce-api/src/test/java/com/loopers/application/payment/ManualRecoveryTest.java new file mode 100644 index 0000000000..04684b8ac2 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/payment/ManualRecoveryTest.java @@ -0,0 +1,98 @@ +package com.loopers.application.payment; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.application.coupon.CouponFacade; +import com.loopers.application.product.ProductFacade; +import com.loopers.infrastructure.redis.CouponIssueRequestRedisRepository; +import com.loopers.domain.order.Order; +import com.loopers.domain.payment.*; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.vo.Price; +import com.loopers.domain.product.vo.Stock; +import com.loopers.fake.*; +import com.loopers.infrastructure.pg.PgPaymentStatusResponse; +import com.loopers.infrastructure.pg.PgRouter; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.kafka.core.KafkaTemplate; + +import java.time.Clock; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +class ManualRecoveryTest { + + private PaymentRecoveryService recoveryService; + private FakePaymentRepository paymentRepository; + private FakeOrderRepository orderRepository; + private FakePgClient pgClient; + + @SuppressWarnings("unchecked") + @BeforeEach + void setUp() { + paymentRepository = new FakePaymentRepository(); + FakeCallbackInboxRepository callbackInboxRepository = new FakeCallbackInboxRepository(); + orderRepository = new FakeOrderRepository(); + FakeProductRepository productRepository = new FakeProductRepository(); + FakeStockReservationRedisRepository stockRedisRepository = new FakeStockReservationRedisRepository(); + pgClient = new FakePgClient("SIMULATOR"); + PgRouter pgRouter = new PgRouter(List.of(pgClient)); + + ProductFacade productFacade = new ProductFacade( + productRepository, new FakeBrandRepository(), new FakeLikeRepository(), + new FakeProductCachePort(), event -> {}, stockRedisRepository, null); + + CouponFacade couponFacade = new CouponFacade(new FakeCouponRepository(), new FakeCouponIssueRepository(), + mock(CouponIssueRequestRedisRepository.class), mock(KafkaTemplate.class), new ObjectMapper(), Clock.systemDefaultZone()); + + recoveryService = new PaymentRecoveryService( + paymentRepository, new FakePaymentStatusHistoryRepository(), + callbackInboxRepository, orderRepository, + productFacade, couponFacade, pgRouter); + } + + @DisplayName("U5-9: confirm API → PG 조회 → PAID 전이") + @Test + void manualConfirm_pgSuccess_transitionToPaid() { + Product product = new Product(1L, "에어맥스", new Price(5000), new Stock(100)); + Order order = orderRepository.save( + Order.create(100L, List.of( + new Order.ItemSnapshot(product.getId(), "에어맥스", 5000, "나이키", 1) + ))); + + PaymentModel payment = PaymentModel.create(order.getId(), 5000, "SAMSUNG", "1234"); + payment.markPending("TX-CONFIRM-001", "SIMULATOR"); + payment = paymentRepository.save(payment); + + // PG에 SUCCESS 상태 등록 + pgClient.registerStatus("TX-CONFIRM-001", + new PgPaymentStatusResponse("SUCCESS", "TX-CONFIRM-001", null)); + + String result = recoveryService.manualConfirm(payment.getId()); + + assertThat(result).contains("PAID"); + PaymentModel updated = paymentRepository.findById(payment.getId()).orElseThrow(); + assertThat(updated.getStatus()).isEqualTo(PaymentStatus.PAID); + } + + @DisplayName("이미 최종 상태인 결제건 → 변경 없음") + @Test + void manualConfirm_alreadyTerminal_noChange() { + Order order = orderRepository.save( + Order.create(100L, List.of( + new Order.ItemSnapshot(1L, "에어맥스", 5000, "나이키", 1) + ))); + + PaymentModel payment = PaymentModel.create(order.getId(), 5000, "SAMSUNG", "1234"); + payment.markPending("TX-DONE", "SIMULATOR"); + payment.markPaid(); + payment = paymentRepository.save(payment); + + String result = recoveryService.manualConfirm(payment.getId()); + + assertThat(result).contains("이미 최종 상태"); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/payment/PaymentCallbackTest.java b/apps/commerce-api/src/test/java/com/loopers/application/payment/PaymentCallbackTest.java new file mode 100644 index 0000000000..07c3ea5e33 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/payment/PaymentCallbackTest.java @@ -0,0 +1,172 @@ +package com.loopers.application.payment; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.application.coupon.CouponFacade; +import com.loopers.application.product.ProductFacade; +import com.loopers.domain.coupon.CouponIssue; +import com.loopers.infrastructure.redis.CouponIssueRequestRedisRepository; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderStatus; +import com.loopers.domain.payment.*; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.vo.Price; +import com.loopers.domain.product.vo.Stock; +import com.loopers.fake.*; +import com.loopers.infrastructure.pg.PgRouter; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.kafka.core.KafkaTemplate; + +import java.time.Clock; +import java.time.ZonedDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +class PaymentCallbackTest { + + private PaymentRecoveryService recoveryService; + private FakePaymentRepository paymentRepository; + private FakeCallbackInboxRepository callbackInboxRepository; + private FakeOrderRepository orderRepository; + private FakeProductRepository productRepository; + private FakeCouponIssueRepository couponIssueRepository; + private FakeStockReservationRedisRepository stockRedisRepository; + private FakePgClient pgClient; + + @SuppressWarnings("unchecked") + @BeforeEach + void setUp() { + paymentRepository = new FakePaymentRepository(); + callbackInboxRepository = new FakeCallbackInboxRepository(); + orderRepository = new FakeOrderRepository(); + productRepository = new FakeProductRepository(); + couponIssueRepository = new FakeCouponIssueRepository(); + stockRedisRepository = new FakeStockReservationRedisRepository(); + pgClient = new FakePgClient("SIMULATOR"); + + PgRouter pgRouter = new PgRouter(List.of(pgClient)); + + ProductFacade productFacade = new ProductFacade( + productRepository, new FakeBrandRepository(), new FakeLikeRepository(), + new FakeProductCachePort(), event -> {}, stockRedisRepository, null); + + CouponFacade couponFacade = new CouponFacade(new FakeCouponRepository(), couponIssueRepository, + mock(CouponIssueRequestRedisRepository.class), mock(KafkaTemplate.class), new ObjectMapper(), Clock.systemDefaultZone()); + + recoveryService = new PaymentRecoveryService( + paymentRepository, new FakePaymentStatusHistoryRepository(), + callbackInboxRepository, orderRepository, + productFacade, couponFacade, pgRouter); + } + + private Order createOrderWithProduct() { + Product product = productRepository.save( + new Product(1L, "에어맥스", new Price(5000), new Stock(100))); + stockRedisRepository.setStock(product.getId(), 100); + + Order order = orderRepository.save( + Order.create(100L, List.of( + new Order.ItemSnapshot(product.getId(), "에어맥스", 5000, "나이키", 2) + ))); + return order; + } + + private PaymentModel createPendingPayment(Order order) { + PaymentModel payment = PaymentModel.create(order.getId(), 10000, "SAMSUNG", "1234"); + payment.markPending("TX-001", "SIMULATOR"); + return paymentRepository.save(payment); + } + + @DisplayName("U4-1: SUCCESS 콜백 → Payment PAID + Order PAID") + @Test + void callback_success_paidPaymentAndOrder() { + Order order = createOrderWithProduct(); + PaymentModel payment = createPendingPayment(order); + + recoveryService.processCallback("TX-001", "SUCCESS", "{\"status\":\"SUCCESS\"}"); + + // Payment → PAID + PaymentModel updated = paymentRepository.findById(payment.getId()).orElseThrow(); + assertThat(updated.getStatus()).isEqualTo(PaymentStatus.PAID); + + // Order → PAID + Order updatedOrder = orderRepository.findById(order.getId()).orElseThrow(); + assertThat(updatedOrder.getStatus()).isEqualTo(OrderStatus.PAID); + + // Inbox → PROCESSED + List inboxes = callbackInboxRepository.findAllByTransactionKey("TX-001"); + assertThat(inboxes).hasSize(1); + assertThat(inboxes.get(0).getStatus()).isEqualTo(CallbackInboxStatus.PROCESSED); + } + + @DisplayName("U4-2: FAILED 콜백 → Payment FAILED + 재고 복원 + 쿠폰 복원") + @Test + void callback_failed_restoresStockAndCoupon() { + Product product = productRepository.save( + new Product(1L, "에어맥스", new Price(5000), new Stock(98))); + stockRedisRepository.setStock(product.getId(), 98); + + // 쿠폰 사용 상태로 설정 + CouponIssue couponIssue = new CouponIssue(1L, 100L, ZonedDateTime.now().plusDays(30)); + couponIssue = couponIssueRepository.save(couponIssue); + couponIssue.use(1L, ZonedDateTime.now()); + couponIssueRepository.save(couponIssue); + + Order order = orderRepository.save( + Order.create(100L, List.of( + new Order.ItemSnapshot(product.getId(), "에어맥스", 5000, "나이키", 2) + ), couponIssue.getId(), 1000)); + + PaymentModel payment = createPendingPayment(order); + + recoveryService.processCallback("TX-001", "FAILED", "{\"status\":\"FAILED\"}"); + + // Payment → FAILED + PaymentModel updated = paymentRepository.findById(payment.getId()).orElseThrow(); + assertThat(updated.getStatus()).isEqualTo(PaymentStatus.FAILED); + + // 재고 복원 확인 (Redis) + assertThat(stockRedisRepository.getStock(product.getId())).isEqualTo(100L); + + // 재고 복원 확인 (DB) + Product updatedProduct = productRepository.findById(product.getId()).orElseThrow(); + assertThat(updatedProduct.getStock().getQuantity()).isEqualTo(100); + + // 쿠폰 복원 확인 + CouponIssue updatedCoupon = couponIssueRepository.findById(couponIssue.getId()).orElseThrow(); + assertThat(updatedCoupon.getStatus().name()).isEqualTo("AVAILABLE"); + } + + @DisplayName("U4-3: PENDING 콜백 → 무시 (상태 변경 없음)") + @Test + void callback_pending_ignored() { + Order order = createOrderWithProduct(); + PaymentModel payment = createPendingPayment(order); + + recoveryService.processCallback("TX-001", "PENDING", "{\"status\":\"PENDING\"}"); + + // Payment 상태 변경 없음 (PENDING 유지) + PaymentModel updated = paymentRepository.findById(payment.getId()).orElseThrow(); + assertThat(updated.getStatus()).isEqualTo(PaymentStatus.PENDING); + + // Inbox → PROCESSED (정상 처리되었으나 무시됨) + List inboxes = callbackInboxRepository.findAllByTransactionKey("TX-001"); + assertThat(inboxes).hasSize(1); + assertThat(inboxes.get(0).getStatus()).isEqualTo(CallbackInboxStatus.PROCESSED); + } + + @DisplayName("U4-4: 존재하지 않는 transactionKey → 로그 남기고 무시") + @Test + void callback_unknownTransactionKey_ignored() { + recoveryService.processCallback("TX-UNKNOWN", "SUCCESS", "{\"status\":\"SUCCESS\"}"); + + // Inbox에 저장되었지만 FAILED 상태 + List inboxes = callbackInboxRepository.findAllByTransactionKey("TX-UNKNOWN"); + assertThat(inboxes).hasSize(1); + assertThat(inboxes.get(0).getStatus()).isEqualTo(CallbackInboxStatus.FAILED); + assertThat(inboxes.get(0).getErrorMessage()).contains("Payment not found"); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/payment/PaymentFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/payment/PaymentFacadeTest.java new file mode 100644 index 0000000000..e5e3856ae3 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/payment/PaymentFacadeTest.java @@ -0,0 +1,229 @@ +package com.loopers.application.payment; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.order.Order; +import com.loopers.domain.payment.PaymentModel; +import com.loopers.domain.payment.PaymentStatus; +import com.loopers.fake.*; +import com.loopers.infrastructure.pg.PgPaymentStatusResponse; +import com.loopers.infrastructure.pg.PgRouter; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class PaymentFacadeTest { + + private PaymentFacade paymentFacade; + private FakePaymentRepository paymentRepository; + private FakeOrderRepository orderRepository; + private FakePaymentOutboxRepository outboxRepository; + private FakePgClient primaryPgClient; + private PgRouter pgRouter; + + @BeforeEach + void setUp() throws Exception { + paymentRepository = new FakePaymentRepository(); + orderRepository = new FakeOrderRepository(); + outboxRepository = new FakePaymentOutboxRepository(); + primaryPgClient = new FakePgClient("SIMULATOR"); + pgRouter = new PgRouter(List.of(primaryPgClient)); + + paymentFacade = new PaymentFacade(paymentRepository, orderRepository, pgRouter, outboxRepository); + + // @Value 필드 주입 (Spring 컨텍스트 없이) + setField(paymentFacade, "callbackUrl", "http://test/callback"); + setField(paymentFacade, "maxRetryAttempts", 3); + setField(paymentFacade, "initialWaitMs", 0L); + setField(paymentFacade, "backoffMultiplier", 2); + } + + private void setField(Object target, String fieldName, Object value) throws Exception { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } + + private Order createTestOrder(Long memberId) { + FakeBrandRepository brandRepository = new FakeBrandRepository(); + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + + Order order = Order.create(memberId, List.of( + new Order.ItemSnapshot(1L, "에어맥스", 5000, brand.getName(), 1) + )); + return orderRepository.save(order); + } + + @Nested + @DisplayName("결제 요청") + class RequestPayment { + + @DisplayName("U1-8: 정상 결제 요청 → PENDING 응답") + @Test + void requestPayment_success_returnsPending() { + Order order = createTestOrder(1L); + + PaymentFacade.PaymentResult result = paymentFacade.requestPayment( + order.getId(), "SAMSUNG", "1234-5678-9012-3456", 5000); + + assertThat(result.paymentId()).isNotNull(); + assertThat(result.transactionKey()).isNotNull(); + assertThat(result.status()).isEqualTo("PENDING"); + assertThat(result.failureReason()).isNull(); + + PaymentModel saved = paymentRepository.findById(result.paymentId()).orElseThrow(); + assertThat(saved.getStatus()).isEqualTo(PaymentStatus.PENDING); + assertThat(saved.getTransactionKey()).isEqualTo(result.transactionKey()); + assertThat(saved.getOrderId()).isEqualTo(order.getId()); + assertThat(saved.getPgProvider()).isEqualTo("SIMULATOR"); + } + + @DisplayName("U1-9: 주문 없음 → 예외") + @Test + void requestPayment_orderNotFound_throwsException() { + assertThatThrownBy(() -> paymentFacade.requestPayment( + 999L, "SAMSUNG", "1234-5678-9012-3456", 5000)) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("U1-10: 이미 결제된 주문 → 예외") + @Test + void requestPayment_alreadyPaid_throwsException() { + Order order = createTestOrder(1L); + + paymentFacade.requestPayment( + order.getId(), "SAMSUNG", "1234-5678-9012-3456", 5000); + + assertThatThrownBy(() -> paymentFacade.requestPayment( + order.getId(), "SAMSUNG", "1234-5678-9012-3456", 5000)) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.CONFLICT); + } + + @DisplayName("취소된 주문에 대한 결제 요청 → 예외") + @Test + void requestPayment_cancelledOrder_throwsException() { + Order order = createTestOrder(1L); + order.cancel(); + + assertThatThrownBy(() -> paymentFacade.requestPayment( + order.getId(), "SAMSUNG", "1234-5678-9012-3456", 5000)) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @Nested + @DisplayName("수동 Retry + UNKNOWN Fallback") + class RetryAndFallback { + + @DisplayName("U2-4: PG 1차 실패 → PG에 기록 있음 → 재시도 안 함 (멱등성)") + @Test + void requestPayment_pgHasExistingRecord_noRetry() { + Order order = createTestOrder(1L); + primaryPgClient.setFailCount(1); + + // PG에 이미 PENDING 기록 등록 (네트워크 실패했지만 PG는 처리 완료한 상황) + primaryPgClient.registerOrderStatus(String.valueOf(order.getId()), + new PgPaymentStatusResponse("PENDING", "TX-EXISTING-123", null)); + + PaymentFacade.PaymentResult result = paymentFacade.requestPayment( + order.getId(), "SAMSUNG", "1234-5678-9012-3456", 5000); + + assertThat(result.status()).isEqualTo("PENDING"); + assertThat(result.transactionKey()).isEqualTo("TX-EXISTING-123"); + // PG requestPayment는 1회만 호출됨 (2차 시도 없이 기존 기록으로 완료) + assertThat(primaryPgClient.getCallCount()).isEqualTo(1); + + PaymentModel saved = paymentRepository.findById(result.paymentId()).orElseThrow(); + assertThat(saved.getStatus()).isEqualTo(PaymentStatus.PENDING); + assertThat(saved.getTransactionKey()).isEqualTo("TX-EXISTING-123"); + } + + @DisplayName("U2-5: PG 1차 실패 → PG 기록 없음 → 재시도 → 성공") + @Test + void requestPayment_pgNoRecord_retrySuccess() { + Order order = createTestOrder(1L); + primaryPgClient.setFailCount(1); + + PaymentFacade.PaymentResult result = paymentFacade.requestPayment( + order.getId(), "SAMSUNG", "1234-5678-9012-3456", 5000); + + assertThat(result.status()).isEqualTo("PENDING"); + assertThat(result.transactionKey()).isNotNull(); + // 1차 실패 + 2차 성공 = 2회 호출 + assertThat(primaryPgClient.getCallCount()).isEqualTo(2); + + PaymentModel saved = paymentRepository.findById(result.paymentId()).orElseThrow(); + assertThat(saved.getStatus()).isEqualTo(PaymentStatus.PENDING); + } + + @DisplayName("U2-6: 모든 PG 실패 → UNKNOWN 상태 저장 + '확인 중' 응답") + @Test + void requestPayment_allPgFail_unknownFallback() { + Order order = createTestOrder(1L); + primaryPgClient.setShouldFail(true); + + PaymentFacade.PaymentResult result = paymentFacade.requestPayment( + order.getId(), "SAMSUNG", "1234-5678-9012-3456", 5000); + + assertThat(result.status()).isEqualTo("UNKNOWN"); + assertThat(result.failureReason()).contains("확인 중"); + assertThat(result.transactionKey()).isNull(); + + PaymentModel saved = paymentRepository.findById(result.paymentId()).orElseThrow(); + assertThat(saved.getStatus()).isEqualTo(PaymentStatus.UNKNOWN); + } + } + + @Nested + @DisplayName("결제 조회") + class GetPayment { + + @DisplayName("paymentId로 결제 조회 성공") + @Test + void getPayment_byId_success() { + Order order = createTestOrder(1L); + PaymentFacade.PaymentResult result = paymentFacade.requestPayment( + order.getId(), "SAMSUNG", "1234-5678-9012-3456", 5000); + + PaymentModel payment = paymentFacade.getPayment(result.paymentId()); + + assertThat(payment.getId()).isEqualTo(result.paymentId()); + assertThat(payment.getOrderId()).isEqualTo(order.getId()); + } + + @DisplayName("존재하지 않는 paymentId → 예외") + @Test + void getPayment_notFound_throwsException() { + assertThatThrownBy(() -> paymentFacade.getPayment(999L)) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("orderId로 결제 조회 성공") + @Test + void getPaymentByOrderId_success() { + Order order = createTestOrder(1L); + paymentFacade.requestPayment( + order.getId(), "SAMSUNG", "1234-5678-9012-3456", 5000); + + PaymentModel payment = paymentFacade.getPaymentByOrderId(order.getId()); + + assertThat(payment.getOrderId()).isEqualTo(order.getId()); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/payment/PaymentRecoveryServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/payment/PaymentRecoveryServiceTest.java new file mode 100644 index 0000000000..d637c919ab --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/payment/PaymentRecoveryServiceTest.java @@ -0,0 +1,127 @@ +package com.loopers.application.payment; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.application.coupon.CouponFacade; +import com.loopers.application.product.ProductFacade; +import com.loopers.domain.BaseEntity; +import com.loopers.infrastructure.redis.CouponIssueRequestRedisRepository; +import com.loopers.domain.order.Order; +import com.loopers.domain.payment.*; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.vo.Price; +import com.loopers.domain.product.vo.Stock; +import com.loopers.fake.*; +import com.loopers.infrastructure.pg.PgPaymentStatusResponse; +import com.loopers.infrastructure.pg.PgRouter; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.kafka.core.KafkaTemplate; + +import java.lang.reflect.Field; +import java.time.Clock; +import java.time.ZonedDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +class PaymentRecoveryServiceTest { + + private PaymentRecoveryService recoveryService; + private FakePaymentRepository paymentRepository; + private FakeCallbackInboxRepository callbackInboxRepository; + private FakeOrderRepository orderRepository; + private FakeProductRepository productRepository; + private FakeStockReservationRedisRepository stockRedisRepository; + private FakePgClient pgClient; + + @SuppressWarnings("unchecked") + @BeforeEach + void setUp() { + paymentRepository = new FakePaymentRepository(); + callbackInboxRepository = new FakeCallbackInboxRepository(); + orderRepository = new FakeOrderRepository(); + productRepository = new FakeProductRepository(); + stockRedisRepository = new FakeStockReservationRedisRepository(); + pgClient = new FakePgClient("SIMULATOR"); + + PgRouter pgRouter = new PgRouter(List.of(pgClient)); + + ProductFacade productFacade = new ProductFacade( + productRepository, new FakeBrandRepository(), new FakeLikeRepository(), + new FakeProductCachePort(), event -> {}, stockRedisRepository, null); + + CouponFacade couponFacade = new CouponFacade(new FakeCouponRepository(), new FakeCouponIssueRepository(), + mock(CouponIssueRequestRedisRepository.class), mock(KafkaTemplate.class), new ObjectMapper(), Clock.systemDefaultZone()); + + recoveryService = new PaymentRecoveryService( + paymentRepository, new FakePaymentStatusHistoryRepository(), + callbackInboxRepository, orderRepository, + productFacade, couponFacade, pgRouter); + } + + @DisplayName("U4-5: Polling — PG SUCCESS → PAID 전이") + @Test + void polling_pgSuccess_transitionToPaid() throws Exception { + Product product = productRepository.save( + new Product(1L, "에어맥스", new Price(5000), new Stock(100))); + stockRedisRepository.setStock(product.getId(), 100); + + Order order = orderRepository.save( + Order.create(100L, List.of( + new Order.ItemSnapshot(product.getId(), "에어맥스", 5000, "나이키", 2) + ))); + + PaymentModel payment = PaymentModel.create(order.getId(), 10000, "SAMSUNG", "1234"); + payment.markPending("TX-POLL-001", "SIMULATOR"); + payment = paymentRepository.save(payment); + + // createdAt을 15초 전으로 설정 (10초 threshold 초과) + setCreatedAt(payment, ZonedDateTime.now().minusSeconds(15)); + + // PG에 SUCCESS 상태 등록 + pgClient.registerStatus("TX-POLL-001", + new PgPaymentStatusResponse("SUCCESS", "TX-POLL-001", null)); + + recoveryService.checkPendingPayments(); + + // Payment → PAID + PaymentModel updated = paymentRepository.findById(payment.getId()).orElseThrow(); + assertThat(updated.getStatus()).isEqualTo(PaymentStatus.PAID); + } + + @DisplayName("U4-6: Polling — PG PENDING + 생성 5초 → 유지 (threshold 미달)") + @Test + void polling_pendingUnderThreshold_noChange() throws Exception { + Product product = productRepository.save( + new Product(1L, "에어맥스", new Price(5000), new Stock(100))); + + Order order = orderRepository.save( + Order.create(100L, List.of( + new Order.ItemSnapshot(product.getId(), "에어맥스", 5000, "나이키", 2) + ))); + + PaymentModel payment = PaymentModel.create(order.getId(), 10000, "SAMSUNG", "1234"); + payment.markPending("TX-POLL-002", "SIMULATOR"); + payment = paymentRepository.save(payment); + + // createdAt을 5초 전으로 설정 (10초 threshold 미달) + setCreatedAt(payment, ZonedDateTime.now().minusSeconds(5)); + + pgClient.registerStatus("TX-POLL-002", + new PgPaymentStatusResponse("PENDING", "TX-POLL-002", null)); + + recoveryService.checkPendingPayments(); + + // Payment 상태 변경 없음 (PENDING 유지) + PaymentModel updated = paymentRepository.findById(payment.getId()).orElseThrow(); + assertThat(updated.getStatus()).isEqualTo(PaymentStatus.PENDING); + } + + private void setCreatedAt(Object entity, ZonedDateTime createdAt) throws Exception { + Field field = BaseEntity.class.getDeclaredField("createdAt"); + field.setAccessible(true); + field.set(entity, createdAt); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/payment/ServerCrashFaultTest.java b/apps/commerce-api/src/test/java/com/loopers/application/payment/ServerCrashFaultTest.java new file mode 100644 index 0000000000..c248fc61d8 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/payment/ServerCrashFaultTest.java @@ -0,0 +1,87 @@ +package com.loopers.application.payment; + +import com.loopers.domain.payment.*; +import com.loopers.fake.*; +import com.loopers.infrastructure.pg.PgRouter; +import com.loopers.infrastructure.scheduler.OutboxPollerScheduler; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * F7-2: 서버 크래시 → Outbox 복구 시나리오. + * + *

TX-1 커밋 후 PG 호출 전에 서버가 죽은 상황. + * Payment(REQUESTED) + Outbox(PENDING)만 DB에 남아있고, PG 호출은 안 된 상태. + * Outbox 폴러가 5초마다 스캔하여 PG 호출을 재시도.

+ * + * @see Outbox 폴러 + */ +class ServerCrashFaultTest { + + private OutboxPollerScheduler outboxPoller; + private FakePaymentOutboxRepository outboxRepository; + private FakePaymentRepository paymentRepository; + private FakePgClient pgClient; + + @BeforeEach + void setUp() { + outboxRepository = new FakePaymentOutboxRepository(); + paymentRepository = new FakePaymentRepository(); + pgClient = new FakePgClient("SIMULATOR"); + PgRouter pgRouter = new PgRouter(List.of(pgClient)); + + outboxPoller = new OutboxPollerScheduler(outboxRepository, paymentRepository, pgRouter); + } + + @DisplayName("F7-2: TX-1 커밋 → PG 호출 안 됨 → Outbox 폴러가 PG 호출 → PENDING") + @Test + void serverCrash_outboxRecovery_pgCalled() { + // Given: TX-1 커밋 상태 (Payment REQUESTED + Outbox PENDING) + // 서버 크래시로 PG 호출이 안 된 상황을 시뮬레이션 + PaymentModel payment = paymentRepository.save( + PaymentModel.create(1L, 10000, "SAMSUNG", "1234")); + PaymentOutbox outbox = outboxRepository.save( + PaymentOutbox.create(payment.getId(), 1L, "{\"orderId\":1}")); + + // Payment는 REQUESTED 상태 (PG 호출 안 됨) + assertThat(payment.getStatus()).isEqualTo(PaymentStatus.REQUESTED); + assertThat(outbox.getStatus()).isEqualTo(PaymentOutboxStatus.PENDING); + assertThat(pgClient.getCallCount()).isZero(); + + // When: Outbox 폴러 실행 (서버 재기동 후 5초 내) + outboxPoller.pollOutbox(); + + // Then: PG 호출됨 → Payment PENDING → Outbox PROCESSED + assertThat(pgClient.getCallCount()).isEqualTo(1); + PaymentModel updatedPayment = paymentRepository.findById(payment.getId()).orElseThrow(); + assertThat(updatedPayment.getStatus()).isEqualTo(PaymentStatus.PENDING); + assertThat(updatedPayment.getTransactionKey()).isNotNull(); + assertThat(outbox.getStatus()).isEqualTo(PaymentOutboxStatus.PROCESSED); + } + + @DisplayName("F7-2 변형: PG도 장애 → Outbox 재시도 3회 → FAILED") + @Test + void serverCrash_pgAlsoDown_retryExhausted() { + // Given: TX-1 커밋 + PG 장애 + PaymentModel payment = paymentRepository.save( + PaymentModel.create(1L, 10000, "SAMSUNG", "1234")); + PaymentOutbox outbox = outboxRepository.save( + PaymentOutbox.create(payment.getId(), 1L, "{\"orderId\":1}")); + + pgClient.setShouldFail(true); // PG 장애 + + // When: Outbox 폴러 4회 실행 (retry 3회 초과) + for (int i = 0; i < 4; i++) { + outboxPoller.pollOutbox(); + } + + // Then: Outbox FAILED (운영 알림 대상) + assertThat(outbox.getStatus()).isEqualTo(PaymentOutboxStatus.FAILED); + assertThat(outbox.getRetryCount()).isGreaterThan(3); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java new file mode 100644 index 0000000000..4f2e2bf229 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java @@ -0,0 +1,450 @@ +package com.loopers.application.product; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.like.Like; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductWithBrand; +import com.loopers.domain.product.vo.Price; +import com.loopers.domain.product.vo.Stock; +import com.loopers.fake.FakeBrandRepository; +import com.loopers.fake.FakeLikeRepository; +import com.loopers.fake.FakeProductCachePort; +import com.loopers.fake.FakeProductRepository; +import com.loopers.fake.FakeStockReservationRedisRepository; +import com.loopers.interfaces.api.product.ProductDto; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; + +import java.time.ZonedDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ProductFacadeTest { + + private ProductFacade productFacade; + private FakeProductRepository productRepository; + private FakeBrandRepository brandRepository; + private FakeLikeRepository likeRepository; + + @BeforeEach + void setUp() { + productRepository = new FakeProductRepository(); + brandRepository = new FakeBrandRepository(); + likeRepository = new FakeLikeRepository(); + productRepository.setBrandRepository(brandRepository); + productFacade = new ProductFacade(productRepository, brandRepository, likeRepository, new FakeProductCachePort(), event -> {}, new FakeStockReservationRedisRepository(), null); + } + + @Nested + @DisplayName("상품 상세 조회") + class GetProductDetail { + + @DisplayName("상품을 조회하면 브랜드 정보와 likeCount가 함께 반환된다") + @Test + void getProductDetail_returnsProductWithBrand() { + // arrange + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + Product product = productRepository.save( + new Product(brand.getId(), "에어맥스", new Price(150000), new Stock(10))); + + // act + ProductWithBrand result = productFacade.getProductDetail(product.getId()); + + // assert + assertThat(result.product().getId()).isEqualTo(product.getId()); + assertThat(result.product().getName()).isEqualTo("에어맥스"); + assertThat(result.brandName()).isEqualTo("나이키"); + assertThat(result.likeCount()).isEqualTo(0); + } + + @DisplayName("존재하지 않는 상품을 조회하면 예외가 발생한다") + @Test + void getProductDetail_whenProductNotExists_throwsCoreException() { + assertThatThrownBy(() -> productFacade.getProductDetail(999L)) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("브랜드가 삭제된 상품은 브랜드 이름이 null로 반환된다") + @Test + void getProductDetail_whenBrandDeleted_returnsNullBrandName() { + // arrange + Product product = productRepository.save( + new Product(999L, "에어맥스", new Price(150000), new Stock(10))); + + // act + ProductWithBrand result = productFacade.getProductDetail(product.getId()); + + // assert + assertThat(result.brandName()).isNull(); + } + + @DisplayName("likeCount가 반영된 상품 상세가 반환된다") + @Test + void getProductDetail_returnsLikeCount() { + // arrange + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + Product product = productRepository.save( + new Product(brand.getId(), "에어맥스", new Price(150000), new Stock(10))); + productRepository.incrementLikeCount(product.getId()); + productRepository.incrementLikeCount(product.getId()); + productRepository.incrementLikeCount(product.getId()); + + // act + ProductWithBrand result = productFacade.getProductDetail(product.getId()); + + // assert + assertThat(result.likeCount()).isEqualTo(3); + } + } + + @Nested + @DisplayName("상품 전체 조회 (페이지네이션)") + class GetAllProducts { + + @DisplayName("모든 상품이 브랜드 정보와 함께 반환된다") + @Test + void getAllProducts_returnsAllWithBrandInfo() { + // arrange + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + productRepository.save(new Product(brand.getId(), "에어맥스", new Price(150000), new Stock(10))); + productRepository.save(new Product(brand.getId(), "에어포스", new Price(120000), new Stock(20))); + + // act + Page result = productFacade.getAllProducts("latest", PageRequest.of(0, 20)); + + // assert + assertThat(result.getContent()).hasSize(2); + assertThat(result.getContent()).allSatisfy(info -> + assertThat(info.brandName()).isEqualTo("나이키") + ); + } + + @DisplayName("상품이 없으면 빈 페이지가 반환된다") + @Test + void getAllProducts_whenEmpty_returnsEmptyPage() { + // act + Page result = productFacade.getAllProducts("latest", PageRequest.of(0, 20)); + + // assert + assertThat(result.getContent()).isEmpty(); + assertThat(result.getTotalElements()).isZero(); + } + + @DisplayName("좋아요순 정렬이 DB에서 처리된다") + @Test + void getAllProducts_likesDesc_sortedByLikeCount() { + // arrange + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + Product p1 = productRepository.save(new Product(brand.getId(), "에어맥스", new Price(150000), new Stock(10))); + Product p2 = productRepository.save(new Product(brand.getId(), "에어포스", new Price(120000), new Stock(20))); + Product p3 = productRepository.save(new Product(brand.getId(), "덩크", new Price(130000), new Stock(15))); + + // p2에 좋아요 3개, p3에 1개, p1에 0개 + productRepository.incrementLikeCount(p2.getId()); + productRepository.incrementLikeCount(p2.getId()); + productRepository.incrementLikeCount(p2.getId()); + productRepository.incrementLikeCount(p3.getId()); + + // act + Page result = productFacade.getAllProducts("likes_desc", PageRequest.of(0, 20)); + + // assert + List content = result.getContent(); + assertThat(content).hasSize(3); + assertThat(content.get(0).product().getName()).isEqualTo("에어포스"); + assertThat(content.get(0).likeCount()).isEqualTo(3); + assertThat(content.get(1).product().getName()).isEqualTo("덩크"); + assertThat(content.get(1).likeCount()).isEqualTo(1); + assertThat(content.get(2).product().getName()).isEqualTo("에어맥스"); + assertThat(content.get(2).likeCount()).isEqualTo(0); + } + + @DisplayName("페이지네이션이 올바르게 동작한다") + @Test + void getAllProducts_pagination_worksCorrectly() { + // arrange + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + for (int i = 0; i < 25; i++) { + productRepository.save(new Product(brand.getId(), "상품" + i, new Price(10000 + i), new Stock(10))); + } + + // act + Page page0 = productFacade.getAllProducts("latest", PageRequest.of(0, 10)); + Page page1 = productFacade.getAllProducts("latest", PageRequest.of(1, 10)); + Page page2 = productFacade.getAllProducts("latest", PageRequest.of(2, 10)); + + // assert + assertThat(page0.getContent()).hasSize(10); + assertThat(page1.getContent()).hasSize(10); + assertThat(page2.getContent()).hasSize(5); + assertThat(page0.getTotalElements()).isEqualTo(25); + assertThat(page0.getTotalPages()).isEqualTo(3); + } + } + + @Nested + @DisplayName("캐시 통합 조회") + class CachedQueries { + + @DisplayName("캐시 미스 시 DB에서 조회하여 반환한다") + @Test + void getAllProductsCached_onCacheMiss_returnsFromDb() { + // arrange + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + productRepository.save(new Product(brand.getId(), "에어맥스", new Price(150000), new Stock(10))); + + // act + ProductDto.PagedProductResponse response = productFacade.getAllProductsCached(null, "latest", 0, 20); + + // assert + assertThat(response.data()).hasSize(1); + assertThat(response.totalElements()).isEqualTo(1); + } + + @DisplayName("상품 상세 캐시 미스 시 DB에서 조회하여 반환한다") + @Test + void getProductDetailCached_onCacheMiss_returnsFromDb() { + // arrange + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + Product product = productRepository.save( + new Product(brand.getId(), "에어맥스", new Price(150000), new Stock(10))); + + // act + ProductDto.ProductResponse response = productFacade.getProductDetailCached(product.getId()); + + // assert + assertThat(response.name()).isEqualTo("에어맥스"); + assertThat(response.brandName()).isEqualTo("나이키"); + } + } + + @Nested + @DisplayName("상품 생성") + class CreateProduct { + + @DisplayName("유효한 브랜드로 상품을 생성하면 ID가 부여되어 반환된다") + @Test + void createProduct_withValidBrand_returnsWithId() { + // arrange + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + + // act + Product result = productFacade.createProduct(brand.getId(), "에어맥스", 150000, 10, null); + + // assert + assertThat(result.getId()).isNotNull(); + assertThat(result.getId()).isGreaterThan(0L); + assertThat(result.getName()).isEqualTo("에어맥스"); + assertThat(result.getPrice().getValue()).isEqualTo(150000); + assertThat(result.getStock().getQuantity()).isEqualTo(10); + assertThat(result.getBrandId()).isEqualTo(brand.getId()); + } + + @DisplayName("존재하지 않는 브랜드로 상품을 생성하면 예외가 발생한다") + @Test + void createProduct_withInvalidBrand_throwsCoreException() { + assertThatThrownBy(() -> productFacade.createProduct(999L, "에어맥스", 150000, 10, null)) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.NOT_FOUND); + } + } + + @Nested + @DisplayName("상품 수정") + class UpdateProduct { + + @DisplayName("존재하는 상품을 수정하면 변경된 정보가 반환된다") + @Test + void updateProduct_whenExists_returnsUpdated() { + // arrange + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + Product product = productRepository.save( + new Product(brand.getId(), "에어맥스", new Price(150000), new Stock(10))); + + // act + Product result = productFacade.updateProduct(product.getId(), "에어맥스 97", 180000, 5); + + // assert + assertThat(result.getName()).isEqualTo("에어맥스 97"); + assertThat(result.getPrice().getValue()).isEqualTo(180000); + assertThat(result.getStock().getQuantity()).isEqualTo(5); + } + + @DisplayName("존재하지 않는 상품을 수정하면 예외가 발생한다") + @Test + void updateProduct_whenNotExists_throwsCoreException() { + assertThatThrownBy(() -> productFacade.updateProduct(999L, "에어맥스", 150000, 10)) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.NOT_FOUND); + } + } + + @Nested + @DisplayName("상품 삭제") + class DeleteProduct { + + @DisplayName("상품을 삭제하면 소프트 삭제된다") + @Test + void deleteProduct_softDeletesProduct() { + // arrange + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + Product product = productRepository.save( + new Product(brand.getId(), "에어맥스", new Price(150000), new Stock(10))); + + // act + productFacade.deleteProduct(product.getId()); + + // assert + assertThat(productRepository.findById(product.getId())).isEmpty(); + } + + @DisplayName("존재하지 않는 상품을 삭제하면 예외가 발생한다") + @Test + void deleteProduct_whenNotExists_throwsCoreException() { + assertThatThrownBy(() -> productFacade.deleteProduct(999L)) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("상품 삭제 시 해당 상품의 좋아요가 hard delete 된다") + @Test + void deleteProduct_hardDeletesLikes() { + // arrange + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + Product product = productRepository.save( + new Product(brand.getId(), "에어맥스", new Price(150000), new Stock(10))); + likeRepository.save(new Like(1L, product.getId())); + likeRepository.save(new Like(2L, product.getId())); + + // act + productFacade.deleteProduct(product.getId()); + + // assert + assertThat(likeRepository.findByMemberIdAndProductId(1L, product.getId())).isEmpty(); + assertThat(likeRepository.findByMemberIdAndProductId(2L, product.getId())).isEmpty(); + } + } + + @Nested + @DisplayName("신상품 조회") + class GetNewProducts { + + @DisplayName("48시간 이내 등록 상품만 반환된다") + @Test + void getNewProducts_returnsOnlyRecentProducts() { + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + Product recent = productRepository.save(new Product(brand.getId(), "신상품", new Price(100000), new Stock(10))); + Product old = productRepository.save(new Product(brand.getId(), "구상품", new Price(80000), new Stock(5))); + + // recent는 방금 생성 (createdAt = now), old는 3일 전으로 설정 + productRepository.setCreatedAt(old.getId(), ZonedDateTime.now().minusHours(72)); + + ProductDto.PagedProductResponse response = productFacade.getNewProducts(48, 0, 20); + + assertThat(response.data()).hasSize(1); + assertThat(response.data().get(0).name()).isEqualTo("신상품"); + } + + @DisplayName("삭제된 상품은 결과에서 제외된다") + @Test + void getNewProducts_excludesDeletedProducts() { + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + Product active = productRepository.save(new Product(brand.getId(), "활성상품", new Price(100000), new Stock(10))); + Product deleted = productRepository.save(new Product(brand.getId(), "삭제상품", new Price(80000), new Stock(5))); + deleted.delete(); + + ProductDto.PagedProductResponse response = productFacade.getNewProducts(48, 0, 20); + + assertThat(response.data()).hasSize(1); + assertThat(response.data().get(0).name()).isEqualTo("활성상품"); + } + + @DisplayName("신상품이 없으면 빈 리스트가 반환된다") + @Test + void getNewProducts_whenNoNewProducts_returnsEmpty() { + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + Product old = productRepository.save(new Product(brand.getId(), "구상품", new Price(80000), new Stock(5))); + productRepository.setCreatedAt(old.getId(), ZonedDateTime.now().minusHours(72)); + + ProductDto.PagedProductResponse response = productFacade.getNewProducts(48, 0, 20); + + assertThat(response.data()).isEmpty(); + assertThat(response.totalElements()).isZero(); + } + + @DisplayName("등록순 최신 먼저 정렬된다") + @Test + void getNewProducts_sortedByCreatedAtDesc() { + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + Product p1 = productRepository.save(new Product(brand.getId(), "먼저등록", new Price(100000), new Stock(10))); + Product p2 = productRepository.save(new Product(brand.getId(), "나중등록", new Price(80000), new Stock(5))); + + productRepository.setCreatedAt(p1.getId(), ZonedDateTime.now().minusHours(2)); + productRepository.setCreatedAt(p2.getId(), ZonedDateTime.now().minusHours(1)); + + ProductDto.PagedProductResponse response = productFacade.getNewProducts(48, 0, 20); + + assertThat(response.data()).hasSize(2); + assertThat(response.data().get(0).name()).isEqualTo("나중등록"); + assertThat(response.data().get(1).name()).isEqualTo("먼저등록"); + } + + @DisplayName("페이지네이션이 올바르게 동작한다") + @Test + void getNewProducts_pagination_worksCorrectly() { + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + for (int i = 0; i < 5; i++) { + productRepository.save(new Product(brand.getId(), "상품" + i, new Price(10000), new Stock(10))); + } + + ProductDto.PagedProductResponse page0 = productFacade.getNewProducts(48, 0, 2); + ProductDto.PagedProductResponse page1 = productFacade.getNewProducts(48, 1, 2); + ProductDto.PagedProductResponse page2 = productFacade.getNewProducts(48, 2, 2); + + assertThat(page0.data()).hasSize(2); + assertThat(page1.data()).hasSize(2); + assertThat(page2.data()).hasSize(1); + assertThat(page0.totalElements()).isEqualTo(5); + assertThat(page0.totalPages()).isEqualTo(3); + } + } + + @Nested + @DisplayName("벤치마크 전용 AS-IS 재현") + class NoOptimization { + + @DisplayName("enrichWithLikeCount + in-memory sort가 동작한다") + @Test + void getAllProductsNoOptimization_usesLegacyPath() { + // arrange + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + Product p1 = productRepository.save(new Product(brand.getId(), "에어맥스", new Price(150000), new Stock(10))); + Product p2 = productRepository.save(new Product(brand.getId(), "에어포스", new Price(120000), new Stock(20))); + + // p2에 좋아요 2개 (likeRepository를 통해) + likeRepository.save(new Like(1L, p2.getId())); + likeRepository.save(new Like(2L, p2.getId())); + + // act + List result = productFacade.getAllProductsNoOptimization("likes_desc"); + + // assert + assertThat(result).hasSize(2); + assertThat(result.get(0).likeCount()).isEqualTo(2); + assertThat(result.get(0).product().getName()).isEqualTo("에어포스"); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/concurrency/CouponIssueConcurrencyTest.java b/apps/commerce-api/src/test/java/com/loopers/concurrency/CouponIssueConcurrencyTest.java new file mode 100644 index 0000000000..3f8cf5234b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/concurrency/CouponIssueConcurrencyTest.java @@ -0,0 +1,152 @@ +package com.loopers.concurrency; + +import com.loopers.domain.coupon.Coupon; +import com.loopers.domain.coupon.CouponRepository; +import com.loopers.domain.coupon.DiscountType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.time.ZonedDateTime; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +class CouponIssueConcurrencyTest { + + @Autowired + private CouponRepository couponRepository; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("선착순 쿠폰: maxIssuanceCount보다 많은 동시 요청이 와도 수량 초과 발급이 발생하지 않는다") + @Test + void concurrentCouponIssue_doesNotExceedMaxIssuanceCount() throws InterruptedException { + // arrange + int maxIssuance = 100; + int threadCount = 200; + + Coupon coupon = couponRepository.save( + new Coupon("선착순 할인", DiscountType.FIXED, 5000, 0, ZonedDateTime.now().plusDays(30))); + Long couponId = coupon.getId(); + + // maxIssuanceCount 설정 (Entity에 setter 없으므로 native SQL) + jdbcTemplate.update("UPDATE coupon SET max_issuance_count = ? WHERE id = ?", + maxIssuance, couponId); + + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + AtomicInteger successCount = new AtomicInteger(0); + AtomicInteger failCount = new AtomicInteger(0); + + // act: CouponIssueConsumer의 CAS UPDATE를 동시에 실행 + for (int i = 0; i < threadCount; i++) { + long memberId = i + 1; + executor.submit(() -> { + try { + // CouponIssueConsumer와 동일한 CAS UPDATE + int casResult = jdbcTemplate.update( + "UPDATE coupon SET issued_count = issued_count + 1 " + + "WHERE id = ? " + + "AND (max_issuance_count IS NULL OR issued_count < max_issuance_count) " + + "AND deleted_at IS NULL", + couponId + ); + + if (casResult > 0) { + jdbcTemplate.update( + "INSERT INTO coupon_issue (coupon_id, member_id, status, expired_at, created_at) " + + "SELECT ?, ?, 'AVAILABLE', c.expired_at, NOW(6) " + + "FROM coupon c WHERE c.id = ?", + couponId, memberId, couponId + ); + successCount.incrementAndGet(); + } else { + failCount.incrementAndGet(); + } + } catch (Exception e) { + failCount.incrementAndGet(); + } finally { + latch.countDown(); + } + }); + } + latch.await(); + executor.shutdown(); + + // assert + Integer issuedCount = jdbcTemplate.queryForObject( + "SELECT issued_count FROM coupon WHERE id = ?", Integer.class, couponId); + Integer couponIssueCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM coupon_issue WHERE coupon_id = ?", Integer.class, couponId); + + assertThat(successCount.get()).isEqualTo(maxIssuance); + assertThat(failCount.get()).isEqualTo(threadCount - maxIssuance); + assertThat(issuedCount).isEqualTo(maxIssuance); + assertThat(couponIssueCount).isEqualTo(maxIssuance); + } + + @DisplayName("선착순 쿠폰: maxIssuanceCount가 없으면 제한 없이 발급된다") + @Test + void concurrentCouponIssue_withoutLimit_allSucceed() throws InterruptedException { + // arrange + int threadCount = 100; + + Coupon coupon = couponRepository.save( + new Coupon("무제한 할인", DiscountType.FIXED, 5000, 0, ZonedDateTime.now().plusDays(30))); + Long couponId = coupon.getId(); + // maxIssuanceCount는 null (기본값) + + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + AtomicInteger successCount = new AtomicInteger(0); + + // act + for (int i = 0; i < threadCount; i++) { + long memberId = i + 1; + executor.submit(() -> { + try { + int casResult = jdbcTemplate.update( + "UPDATE coupon SET issued_count = issued_count + 1 " + + "WHERE id = ? " + + "AND (max_issuance_count IS NULL OR issued_count < max_issuance_count) " + + "AND deleted_at IS NULL", + couponId + ); + if (casResult > 0) { + successCount.incrementAndGet(); + } + } catch (Exception ignored) { + } finally { + latch.countDown(); + } + }); + } + latch.await(); + executor.shutdown(); + + // assert: 제한 없으므로 모두 성공 + Integer issuedCount = jdbcTemplate.queryForObject( + "SELECT issued_count FROM coupon WHERE id = ?", Integer.class, couponId); + + assertThat(successCount.get()).isEqualTo(threadCount); + assertThat(issuedCount).isEqualTo(threadCount); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/concurrency/CouponUseConcurrencyTest.java b/apps/commerce-api/src/test/java/com/loopers/concurrency/CouponUseConcurrencyTest.java new file mode 100644 index 0000000000..6761dff457 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/concurrency/CouponUseConcurrencyTest.java @@ -0,0 +1,103 @@ +package com.loopers.concurrency; + +import com.loopers.application.order.OrderFacade; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.coupon.*; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.vo.Price; +import com.loopers.domain.product.vo.Stock; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +class CouponUseConcurrencyTest { + + @Autowired + private OrderFacade orderFacade; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private BrandRepository brandRepository; + + @Autowired + private CouponRepository couponRepository; + + @Autowired + private CouponIssueRepository couponIssueRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("동일 쿠폰으로 여러 기기에서 동시 주문하면 단 한 건만 성공한다") + @Test + void concurrentOrdersWithSameCoupon_onlyOneSucceeds() throws InterruptedException { + // arrange + int threadCount = 100; + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + Product product = productRepository.save( + new Product(brand.getId(), "에어맥스", new Price(100000), new Stock(100))); + Coupon coupon = couponRepository.save( + new Coupon("5000원 할인", DiscountType.FIXED, 5000, 0, + ZonedDateTime.now().plusDays(30))); + CouponIssue couponIssue = couponIssueRepository.save( + new CouponIssue(coupon.getId(), 1L, coupon.getExpiredAt())); + + Long productId = product.getId(); + Long couponIssueId = couponIssue.getId(); + + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + AtomicInteger successCount = new AtomicInteger(0); + AtomicInteger failCount = new AtomicInteger(0); + + // act: 같은 memberId, 같은 couponIssueId로 동시 주문 + for (int i = 0; i < threadCount; i++) { + executor.submit(() -> { + try { + orderFacade.createOrder(1L, + List.of(new OrderFacade.OrderItemRequest(productId, 1)), + couponIssueId); + successCount.incrementAndGet(); + } catch (Exception e) { + failCount.incrementAndGet(); + } finally { + latch.countDown(); + } + }); + } + latch.await(); + executor.shutdown(); + + // assert + assertThat(successCount.get()).isEqualTo(1); + assertThat(failCount.get()).isEqualTo(threadCount - 1); + + CouponIssue reloaded = couponIssueRepository.findById(couponIssueId).orElseThrow(); + assertThat(reloaded.getStatus()).isEqualTo(CouponIssueStatus.USED); + + Product reloadedProduct = productRepository.findById(productId).orElseThrow(); + assertThat(reloadedProduct.getStock().getQuantity()).isEqualTo(99); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/concurrency/LikeConcurrencyTest.java b/apps/commerce-api/src/test/java/com/loopers/concurrency/LikeConcurrencyTest.java new file mode 100644 index 0000000000..8cb97ef55d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/concurrency/LikeConcurrencyTest.java @@ -0,0 +1,163 @@ +package com.loopers.concurrency; + +import com.loopers.application.like.LikeFacade; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.like.LikeRepository; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.vo.Price; +import com.loopers.domain.product.vo.Stock; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +class LikeConcurrencyTest { + + @Autowired + private LikeFacade likeFacade; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private BrandRepository brandRepository; + + @Autowired + private LikeRepository likeRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("동일 상품에 여러 명이 동시에 좋아요하면 모두 성공하고 Like 레코드 + Product.likeCount가 정확하다") + @Test + void concurrentLikes_allSucceed_andCountIsCorrect() throws InterruptedException { + // arrange + int threadCount = 100; + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + Product product = productRepository.save( + new Product(brand.getId(), "에어맥스", new Price(100000), new Stock(10))); + Long productId = product.getId(); + + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + AtomicInteger successCount = new AtomicInteger(0); + + // act + for (int i = 0; i < threadCount; i++) { + long memberId = i + 1; + executor.submit(() -> { + try { + likeFacade.addLike(memberId, productId); + successCount.incrementAndGet(); + } catch (Exception e) { + // ignore + } finally { + latch.countDown(); + } + }); + } + latch.await(); + executor.shutdown(); + + // assert — Like 레코드는 즉시 확인, likeCount는 비동기 리스너 완료 대기 + long actualLikeRecords = likeRepository.countByProductId(productId); + assertThat(successCount.get()).isEqualTo(threadCount); + assertThat(actualLikeRecords).isEqualTo(threadCount); + + // likeCount는 @Async AFTER_COMMIT 리스너에서 갱신 → 폴링으로 대기 + waitForLikeCount(productId, threadCount); + } + + @DisplayName("동일 상품에 여러 명이 좋아요 후 일부가 취소하면 Like 레코드 수와 Product.likeCount가 일치한다") + @Test + void concurrentLikeAndUnlike_countsCorrectly() throws InterruptedException { + // arrange + int likeCount = 100; + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + Product product = productRepository.save( + new Product(brand.getId(), "에어맥스", new Price(100000), new Stock(10))); + Long productId = product.getId(); + + // 먼저 100명이 좋아요 + ExecutorService executor1 = Executors.newFixedThreadPool(likeCount); + CountDownLatch latch1 = new CountDownLatch(likeCount); + for (int i = 0; i < likeCount; i++) { + long memberId = i + 1; + executor1.submit(() -> { + try { + likeFacade.addLike(memberId, productId); + } catch (Exception e) { + // ignore + } finally { + latch1.countDown(); + } + }); + } + latch1.await(); + executor1.shutdown(); + + // addLike 비동기 리스너 완료 대기 + waitForLikeCount(productId, likeCount); + + // 5명이 동시에 좋아요 취소 + int unlikeCount = 5; + ExecutorService executor2 = Executors.newFixedThreadPool(unlikeCount); + CountDownLatch latch2 = new CountDownLatch(unlikeCount); + for (int i = 0; i < unlikeCount; i++) { + long memberId = i + 1; + executor2.submit(() -> { + try { + likeFacade.removeLike(memberId, productId); + } catch (Exception e) { + // ignore + } finally { + latch2.countDown(); + } + }); + } + latch2.await(); + executor2.shutdown(); + + // assert — Like 레코드 수와 Product.likeCount가 일치해야 한다 + long actualLikeRecords = likeRepository.countByProductId(productId); + assertThat(actualLikeRecords).isEqualTo(likeCount - unlikeCount); + + waitForLikeCount(productId, (int) actualLikeRecords); + } + + /** + * @Async AFTER_COMMIT 리스너의 likeCount 갱신 완료를 폴링으로 대기한다. + * 최대 10초 (100ms × 100회) 대기. + */ + private void waitForLikeCount(Long productId, int expected) throws InterruptedException { + for (int attempt = 0; attempt < 100; attempt++) { + Product p = productRepository.findById(productId).orElseThrow(); + if (p.getLikeCount() == expected) { + return; + } + Thread.sleep(100); + } + // 최종 assert (실패 시 명확한 메시지) + Product p = productRepository.findById(productId).orElseThrow(); + assertThat(p.getLikeCount()) + .as("likeCount가 %d이어야 하지만 비동기 리스너가 시간 내 완료되지 않음", expected) + .isEqualTo(expected); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/concurrency/OrderCancelConcurrencyTest.java b/apps/commerce-api/src/test/java/com/loopers/concurrency/OrderCancelConcurrencyTest.java new file mode 100644 index 0000000000..d64c9f3246 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/concurrency/OrderCancelConcurrencyTest.java @@ -0,0 +1,102 @@ +package com.loopers.concurrency; + +import com.loopers.application.order.OrderFacade; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderRepository; +import com.loopers.domain.order.OrderStatus; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.vo.Price; +import com.loopers.domain.product.vo.Stock; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +class OrderCancelConcurrencyTest { + + @Autowired + private OrderFacade orderFacade; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private BrandRepository brandRepository; + + @Autowired + private OrderRepository orderRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("동일 주문을 여러 스레드가 동시에 취소하면 단 한 건만 성공한다") + @Test + void concurrentCancel_onlyOneSucceeds() throws InterruptedException { + // arrange + int threadCount = 10; + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + Product product = productRepository.save( + new Product(brand.getId(), "에어맥스", new Price(100000), new Stock(10))); + Long productId = product.getId(); + + Order order = orderFacade.createOrder(1L, + List.of(new OrderFacade.OrderItemRequest(productId, 3))); + Long orderId = order.getId(); + + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch ready = new CountDownLatch(threadCount); + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(threadCount); + AtomicInteger successCount = new AtomicInteger(0); + AtomicInteger failCount = new AtomicInteger(0); + + // act: 같은 주문을 동시에 취소 + for (int i = 0; i < threadCount; i++) { + executor.submit(() -> { + ready.countDown(); + try { start.await(); } catch (InterruptedException ignored) {} + try { + orderFacade.cancelOrder(orderId, 1L); + successCount.incrementAndGet(); + } catch (Exception e) { + failCount.incrementAndGet(); + } finally { + done.countDown(); + } + }); + } + ready.await(); + start.countDown(); // 모든 스레드 동시 출발 + done.await(); + executor.shutdown(); + + // assert — 단 1건만 성공, 재고는 정확히 1번만 복원 + assertThat(successCount.get()).isEqualTo(1); + assertThat(failCount.get()).isEqualTo(threadCount - 1); + + Order reloaded = orderRepository.findById(orderId).orElseThrow(); + assertThat(reloaded.getStatus()).isEqualTo(OrderStatus.CANCELLED); + + Product reloadedProduct = productRepository.findById(productId).orElseThrow(); + assertThat(reloadedProduct.getStock().getQuantity()).isEqualTo(10); // 원래 10 - 주문 3 + 복원 3 = 10 + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/concurrency/StockConcurrencyTest.java b/apps/commerce-api/src/test/java/com/loopers/concurrency/StockConcurrencyTest.java new file mode 100644 index 0000000000..46dce88e7d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/concurrency/StockConcurrencyTest.java @@ -0,0 +1,187 @@ +package com.loopers.concurrency; + +import com.loopers.application.order.OrderFacade; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.vo.Price; +import com.loopers.domain.product.vo.Stock; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +class StockConcurrencyTest { + + @Autowired + private OrderFacade orderFacade; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private BrandRepository brandRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("동일 상품에 여러 주문이 동시에 요청되면 재고가 정확히 차감된다") + @Test + void concurrentOrders_decreasesStockCorrectly() throws InterruptedException { + // arrange + int initialStock = 100; + int threadCount = 100; + int orderQuantity = 1; + + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + Product product = productRepository.save( + new Product(brand.getId(), "에어맥스", new Price(100000), new Stock(initialStock))); + Long productId = product.getId(); + + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + AtomicInteger successCount = new AtomicInteger(0); + AtomicInteger failCount = new AtomicInteger(0); + + // act + for (int i = 0; i < threadCount; i++) { + long memberId = i + 1; + executor.submit(() -> { + try { + orderFacade.createOrder(memberId, + List.of(new OrderFacade.OrderItemRequest(productId, orderQuantity))); + successCount.incrementAndGet(); + } catch (Exception e) { + failCount.incrementAndGet(); + } finally { + latch.countDown(); + } + }); + } + latch.await(); + executor.shutdown(); + + // assert + Product reloaded = productRepository.findById(productId).orElseThrow(); + assertThat(successCount.get()).isEqualTo(initialStock); + assertThat(failCount.get()).isEqualTo(0); + assertThat(reloaded.getStock().getQuantity()).isEqualTo(0); + } + + @DisplayName("재고보다 많은 동시 주문이 요청되면 재고만큼만 성공한다") + @Test + void concurrentOrders_exceedingStock_failsGracefully() throws InterruptedException { + // arrange + int initialStock = 50; + int threadCount = 100; + + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + Product product = productRepository.save( + new Product(brand.getId(), "에어맥스", new Price(100000), new Stock(initialStock))); + Long productId = product.getId(); + + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + AtomicInteger successCount = new AtomicInteger(0); + AtomicInteger failCount = new AtomicInteger(0); + + // act + for (int i = 0; i < threadCount; i++) { + long memberId = i + 1; + executor.submit(() -> { + try { + orderFacade.createOrder(memberId, + List.of(new OrderFacade.OrderItemRequest(productId, 1))); + successCount.incrementAndGet(); + } catch (Exception e) { + failCount.incrementAndGet(); + } finally { + latch.countDown(); + } + }); + } + latch.await(); + executor.shutdown(); + + // assert + Product reloaded = productRepository.findById(productId).orElseThrow(); + assertThat(successCount.get()).isEqualTo(initialStock); + assertThat(failCount.get()).isEqualTo(threadCount - initialStock); + assertThat(reloaded.getStock().getQuantity()).isEqualTo(0); + } + + @DisplayName("상품을 역순으로 주문해도 데드락 없이 모두 성공한다") + @Test + void concurrentOrders_reverseProductOrder_noDeadlock() throws InterruptedException { + // arrange + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + Product productA = productRepository.save( + new Product(brand.getId(), "에어맥스", new Price(100000), new Stock(10))); + Product productB = productRepository.save( + new Product(brand.getId(), "덩크로우", new Price(120000), new Stock(10))); + + Long idA = productA.getId(); + Long idB = productB.getId(); + + ExecutorService executor = Executors.newFixedThreadPool(2); + CountDownLatch ready = new CountDownLatch(2); + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(2); + AtomicInteger successCount = new AtomicInteger(0); + + // act: 유저1은 [A, B] 순서, 유저2는 [B, A] 순서로 요청 + executor.submit(() -> { + ready.countDown(); + try { start.await(); } catch (InterruptedException ignored) {} + try { + orderFacade.createOrder(1L, + List.of(new OrderFacade.OrderItemRequest(idA, 1), + new OrderFacade.OrderItemRequest(idB, 1))); + successCount.incrementAndGet(); + } catch (Exception ignored) { + } finally { done.countDown(); } + }); + + executor.submit(() -> { + ready.countDown(); + try { start.await(); } catch (InterruptedException ignored) {} + try { + orderFacade.createOrder(2L, + List.of(new OrderFacade.OrderItemRequest(idB, 1), + new OrderFacade.OrderItemRequest(idA, 1))); + successCount.incrementAndGet(); + } catch (Exception ignored) { + } finally { done.countDown(); } + }); + + ready.await(); + start.countDown(); // 두 스레드 동시 출발 + done.await(); + executor.shutdown(); + + // assert: 데드락 없이 둘 다 성공, 각 상품 재고 2씩 차감 (2명 × 1개) + assertThat(successCount.get()).isEqualTo(2); + + Product reloadedA = productRepository.findById(idA).orElseThrow(); + Product reloadedB = productRepository.findById(idB).orElseThrow(); + assertThat(reloadedA.getStock().getQuantity()).isEqualTo(8); + assertThat(reloadedB.getStock().getQuantity()).isEqualTo(8); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java new file mode 100644 index 0000000000..58fe05bfb1 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java @@ -0,0 +1,49 @@ +package com.loopers.domain.brand; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class BrandTest { + + @Nested + @DisplayName("Brand 생성") + class Create { + + @DisplayName("유효한 정보로 Brand를 생성할 수 있다") + @Test + void create_withValidInfo_succeeds() { + Brand brand = new Brand("나이키", "스포츠 브랜드"); + + assertThat(brand.getName()).isEqualTo("나이키"); + assertThat(brand.getDescription()).isEqualTo("스포츠 브랜드"); + } + } + + @Nested + @DisplayName("Brand 수정") + class Update { + + @DisplayName("브랜드 이름을 변경할 수 있다") + @Test + void changeName_withNewName_updatesName() { + Brand brand = new Brand("나이키", "스포츠 브랜드"); + + brand.changeName("아디다스"); + + assertThat(brand.getName()).isEqualTo("아디다스"); + } + + @DisplayName("브랜드 설명을 변경할 수 있다") + @Test + void changeDescription_withNewDescription_updatesDescription() { + Brand brand = new Brand("나이키", "스포츠 브랜드"); + + brand.changeDescription("글로벌 스포츠 브랜드"); + + assertThat(brand.getDescription()).isEqualTo("글로벌 스포츠 브랜드"); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponIssueTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponIssueTest.java new file mode 100644 index 0000000000..4f08a6018d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponIssueTest.java @@ -0,0 +1,148 @@ +package com.loopers.domain.coupon; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.ZonedDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class CouponIssueTest { + + private static final ZonedDateTime NOW = ZonedDateTime.now(); + + @Nested + @DisplayName("쿠폰 사용") + class Use { + + @DisplayName("AVAILABLE 상태의 쿠폰을 사용하면 USED로 변경된다") + @Test + void use_whenAvailable_changesStatusToUsed() { + CouponIssue issue = new CouponIssue(1L, 1L, NOW.plusDays(30)); + + issue.use(100L, NOW); + + assertThat(issue.getStatus()).isEqualTo(CouponIssueStatus.USED); + assertThat(issue.getUsedOrderId()).isEqualTo(100L); + } + + @DisplayName("이미 사용된 쿠폰을 다시 사용하면 예외가 발생한다") + @Test + void use_whenAlreadyUsed_throwsException() { + CouponIssue issue = new CouponIssue(1L, 1L, NOW.plusDays(30)); + issue.use(100L, NOW); + + assertThatThrownBy(() -> issue.use(200L, NOW)) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("만료된 쿠폰을 사용하면 예외가 발생한다") + @Test + void use_whenExpired_throwsException() { + CouponIssue issue = new CouponIssue(1L, 1L, NOW.minusDays(1)); + + assertThatThrownBy(() -> issue.use(100L, NOW)) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @Nested + @DisplayName("쿠폰 사용 취소") + class CancelUse { + + @DisplayName("USED 상태의 쿠폰을 복원하면 AVAILABLE로 변경된다") + @Test + void cancelUse_whenUsed_changesStatusToAvailable() { + CouponIssue issue = new CouponIssue(1L, 1L, NOW.plusDays(30)); + issue.use(100L, NOW); + + issue.cancelUse(NOW); + + assertThat(issue.getStatus()).isEqualTo(CouponIssueStatus.AVAILABLE); + assertThat(issue.getUsedOrderId()).isNull(); + } + + @DisplayName("만료된 쿠폰을 복원하면 EXPIRED로 변경된다") + @Test + void cancelUse_whenExpired_changesStatusToExpired() { + CouponIssue issue = new CouponIssue(1L, 1L, NOW.plusDays(1)); + issue.use(100L, NOW); + + ZonedDateTime afterExpiry = NOW.plusDays(2); + issue.cancelUse(afterExpiry); + + assertThat(issue.getStatus()).isEqualTo(CouponIssueStatus.EXPIRED); + assertThat(issue.getUsedOrderId()).isNull(); + } + + @DisplayName("AVAILABLE 상태에서 복원하면 예외가 발생한다") + @Test + void cancelUse_whenAvailable_throwsException() { + CouponIssue issue = new CouponIssue(1L, 1L, NOW.plusDays(30)); + + assertThatThrownBy(() -> issue.cancelUse(NOW)) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @Nested + @DisplayName("만료 여부 확인") + class IsExpired { + + @DisplayName("만료 시간이 지났으면 true를 반환한다") + @Test + void isExpired_whenPastExpiredAt_returnsTrue() { + CouponIssue issue = new CouponIssue(1L, 1L, NOW.minusDays(1)); + + assertThat(issue.isExpired(NOW)).isTrue(); + } + + @DisplayName("만료 시간 이전이면 false를 반환한다") + @Test + void isExpired_whenBeforeExpiredAt_returnsFalse() { + CouponIssue issue = new CouponIssue(1L, 1L, NOW.plusDays(30)); + + assertThat(issue.isExpired(NOW)).isFalse(); + } + } + + @Nested + @DisplayName("유효 상태 조회") + class GetEffectiveStatus { + + @DisplayName("AVAILABLE이지만 만료 시간이 지났으면 EXPIRED를 반환한다") + @Test + void getEffectiveStatus_whenAvailableButExpired_returnsExpired() { + CouponIssue issue = new CouponIssue(1L, 1L, NOW.minusDays(1)); + + assertThat(issue.getEffectiveStatus(NOW)).isEqualTo(CouponIssueStatus.EXPIRED); + } + + @DisplayName("AVAILABLE이고 만료되지 않았으면 AVAILABLE을 반환한다") + @Test + void getEffectiveStatus_whenAvailableAndNotExpired_returnsAvailable() { + CouponIssue issue = new CouponIssue(1L, 1L, NOW.plusDays(30)); + + assertThat(issue.getEffectiveStatus(NOW)).isEqualTo(CouponIssueStatus.AVAILABLE); + } + + @DisplayName("USED 상태이면 만료 여부와 관계없이 USED를 반환한다") + @Test + void getEffectiveStatus_whenUsed_returnsUsed() { + CouponIssue issue = new CouponIssue(1L, 1L, NOW.plusDays(30)); + issue.use(100L, NOW); + + assertThat(issue.getEffectiveStatus(NOW)).isEqualTo(CouponIssueStatus.USED); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponTest.java new file mode 100644 index 0000000000..a23addcd5f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponTest.java @@ -0,0 +1,129 @@ +package com.loopers.domain.coupon; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.ZonedDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class CouponTest { + + private static final ZonedDateTime NOW = ZonedDateTime.now(); + + @Nested + @DisplayName("할인 계산") + class CalculateDiscount { + + @DisplayName("정액 할인: 할인 금액이 주문 금액보다 작으면 할인 금액을 반환한다") + @Test + void fixed_whenDiscountLessThanOrderPrice_returnsDiscountValue() { + Coupon coupon = new Coupon("1000원 할인", DiscountType.FIXED, 1000, 0, + NOW.plusDays(30)); + + int discount = coupon.calculateDiscount(10000); + + assertThat(discount).isEqualTo(1000); + } + + @DisplayName("정액 할인: 할인 금액이 주문 금액보다 크면 주문 금액을 반환한다") + @Test + void fixed_whenDiscountGreaterThanOrderPrice_returnsOrderPrice() { + Coupon coupon = new Coupon("10000원 할인", DiscountType.FIXED, 10000, 0, + NOW.plusDays(30)); + + int discount = coupon.calculateDiscount(5000); + + assertThat(discount).isEqualTo(5000); + } + + @DisplayName("정률 할인: 주문 금액의 비율만큼 할인한다") + @Test + void rate_calculatesPercentageDiscount() { + Coupon coupon = new Coupon("10% 할인", DiscountType.RATE, 10, 0, + NOW.plusDays(30)); + + int discount = coupon.calculateDiscount(20000); + + assertThat(discount).isEqualTo(2000); + } + + @DisplayName("정률 할인: 50% 할인을 적용한다") + @Test + void rate_fiftyPercentDiscount() { + Coupon coupon = new Coupon("50% 할인", DiscountType.RATE, 50, 0, + NOW.plusDays(30)); + + int discount = coupon.calculateDiscount(30000); + + assertThat(discount).isEqualTo(15000); + } + } + + @Nested + @DisplayName("사용 가능 여부 검증") + class ValidateUsable { + + @DisplayName("만료된 쿠폰이면 예외가 발생한다") + @Test + void whenExpired_throwsException() { + Coupon coupon = new Coupon("할인", DiscountType.FIXED, 1000, 0, + NOW.minusDays(1)); + + assertThatThrownBy(() -> coupon.validateUsable(10000, NOW)) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("최소 주문 금액 미만이면 예외가 발생한다") + @Test + void whenBelowMinOrderAmount_throwsException() { + Coupon coupon = new Coupon("할인", DiscountType.FIXED, 1000, 10000, + NOW.plusDays(30)); + + assertThatThrownBy(() -> coupon.validateUsable(5000, NOW)) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("유효한 조건이면 정상 통과한다") + @Test + void whenValid_passes() { + Coupon coupon = new Coupon("할인", DiscountType.FIXED, 1000, 10000, + NOW.plusDays(30)); + + coupon.validateUsable(15000, NOW); + } + } + + @Nested + @DisplayName("쿠폰 생성 검증") + class Creation { + + @DisplayName("할인 값이 0이면 예외가 발생한다") + @Test + void whenZeroDiscountValue_throwsException() { + assertThatThrownBy(() -> new Coupon("할인", DiscountType.FIXED, 0, 0, + NOW.plusDays(30))) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("정률 할인이 100%를 초과하면 예외가 발생한다") + @Test + void whenRateExceeds100_throwsException() { + assertThatThrownBy(() -> new Coupon("할인", DiscountType.RATE, 101, 0, + NOW.plusDays(30))) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java deleted file mode 100644 index 44ca7576e9..0000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java +++ /dev/null @@ -1,65 +0,0 @@ -package com.loopers.domain.example; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertThrows; - -class ExampleModelTest { - @DisplayName("예시 모델을 생성할 때, ") - @Nested - class Create { - @DisplayName("제목과 설명이 모두 주어지면, 정상적으로 생성된다.") - @Test - void createsExampleModel_whenNameAndDescriptionAreProvided() { - // arrange - String name = "제목"; - String description = "설명"; - - // act - ExampleModel exampleModel = new ExampleModel(name, description); - - // assert - assertAll( - () -> assertThat(exampleModel.getId()).isNotNull(), - () -> assertThat(exampleModel.getName()).isEqualTo(name), - () -> assertThat(exampleModel.getDescription()).isEqualTo(description) - ); - } - - @DisplayName("제목이 빈칸으로만 이루어져 있으면, BAD_REQUEST 예외가 발생한다.") - @Test - void throwsBadRequestException_whenTitleIsBlank() { - // arrange - String name = " "; - - // act - CoreException result = assertThrows(CoreException.class, () -> { - new ExampleModel(name, "설명"); - }); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - - @DisplayName("설명이 비어있으면, BAD_REQUEST 예외가 발생한다.") - @Test - void throwsBadRequestException_whenDescriptionIsEmpty() { - // arrange - String description = ""; - - // act - CoreException result = assertThrows(CoreException.class, () -> { - new ExampleModel("제목", description); - }); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java deleted file mode 100644 index bbd5fdbe1f..0000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java +++ /dev/null @@ -1,72 +0,0 @@ -package com.loopers.domain.example; - -import com.loopers.infrastructure.example.ExampleJpaRepository; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import com.loopers.utils.DatabaseCleanUp; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertThrows; - -@SpringBootTest -class ExampleServiceIntegrationTest { - @Autowired - private ExampleService exampleService; - - @Autowired - private ExampleJpaRepository exampleJpaRepository; - - @Autowired - private DatabaseCleanUp databaseCleanUp; - - @AfterEach - void tearDown() { - databaseCleanUp.truncateAllTables(); - } - - @DisplayName("예시를 조회할 때,") - @Nested - class Get { - @DisplayName("존재하는 예시 ID를 주면, 해당 예시 정보를 반환한다.") - @Test - void returnsExampleInfo_whenValidIdIsProvided() { - // arrange - ExampleModel exampleModel = exampleJpaRepository.save( - new ExampleModel("예시 제목", "예시 설명") - ); - - // act - ExampleModel result = exampleService.getExample(exampleModel.getId()); - - // assert - assertAll( - () -> assertThat(result).isNotNull(), - () -> assertThat(result.getId()).isEqualTo(exampleModel.getId()), - () -> assertThat(result.getName()).isEqualTo(exampleModel.getName()), - () -> assertThat(result.getDescription()).isEqualTo(exampleModel.getDescription()) - ); - } - - @DisplayName("존재하지 않는 예시 ID를 주면, NOT_FOUND 예외가 발생한다.") - @Test - void throwsException_whenInvalidIdIsProvided() { - // arrange - Long invalidId = 999L; // Assuming this ID does not exist - - // act - CoreException exception = assertThrows(CoreException.class, () -> { - exampleService.getExample(invalidId); - }); - - // assert - assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java new file mode 100644 index 0000000000..02d73d79ab --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java @@ -0,0 +1,19 @@ +package com.loopers.domain.like; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class LikeTest { + + @DisplayName("Like 생성 시 memberId, productId, createdAt이 설정된다") + @Test + void create_withMemberAndProduct_setsFields() { + Like like = new Like(1L, 100L); + + assertThat(like.getMemberId()).isEqualTo(1L); + assertThat(like.getProductId()).isEqualTo(100L); + assertThat(like.getCreatedAt()).isNotNull(); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java new file mode 100644 index 0000000000..c3e94a14e3 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java @@ -0,0 +1,69 @@ +package com.loopers.domain.member; + +import com.loopers.domain.member.vo.BirthDate; +import com.loopers.domain.member.vo.Email; +import com.loopers.domain.member.vo.LoginId; +import com.loopers.domain.member.vo.Password; +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class MemberTest { + + @DisplayName("유효한 정보로 Member를 생성할 수 있다") + @Test + void create_withValidInfo_succeeds() { + Member member = new Member( + new LoginId("user1"), + new Password("encodedPw"), + "홍길동", + BirthDate.from("1990-01-15"), + new Email("test@example.com") + ); + + assertThat(member.getLoginId().value()).isEqualTo("user1"); + assertThat(member.getName()).isEqualTo("홍길동"); + } + + @DisplayName("이름이 null이면 생성에 실패한다") + @Test + void create_withNullName_throwsException() { + assertThatThrownBy(() -> new Member( + new LoginId("user1"), + new Password("encodedPw"), + null, + BirthDate.from("1990-01-15"), + new Email("test@example.com") + )).isInstanceOf(CoreException.class); + } + + @DisplayName("이름이 빈 문자열이면 생성에 실패한다") + @Test + void create_withBlankName_throwsException() { + assertThatThrownBy(() -> new Member( + new LoginId("user1"), + new Password("encodedPw"), + " ", + BirthDate.from("1990-01-15"), + new Email("test@example.com") + )).isInstanceOf(CoreException.class); + } + + @DisplayName("비밀번호를 변경할 수 있다") + @Test + void changePassword_updatesPassword() { + Member member = new Member( + new LoginId("user1"), + new Password("oldEncodedPw"), + "홍길동", + BirthDate.from("1990-01-15"), + new Email("test@example.com") + ); + + member.changePassword(new Password("newEncodedPw")); + assertThat(member.getPassword().encoded()).isEqualTo("newEncodedPw"); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/policy/PasswordPolicyTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/policy/PasswordPolicyTest.java new file mode 100644 index 0000000000..f94b787f31 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/policy/PasswordPolicyTest.java @@ -0,0 +1,76 @@ +package com.loopers.domain.member.policy; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class PasswordPolicyTest { + + @DisplayName("유효한 비밀번호는 검증을 통과한다") + @Test + void validate_withValidPassword_succeeds() { + assertThatNoException().isThrownBy(() -> + PasswordPolicy.validate("Password1!", LocalDate.of(1990, 1, 15))); + } + + @DisplayName("8자 미만 비밀번호는 실패한다") + @Test + void validateFormat_withShortPassword_throwsException() { + assertThatThrownBy(() -> PasswordPolicy.validateFormat("Pass1!")) + .isInstanceOf(CoreException.class); + } + + @DisplayName("16자 초과 비밀번호는 실패한다") + @Test + void validateFormat_withLongPassword_throwsException() { + assertThatThrownBy(() -> PasswordPolicy.validateFormat("A".repeat(17))) + .isInstanceOf(CoreException.class); + } + + @DisplayName("null 비밀번호는 실패한다") + @Test + void validateFormat_withNull_throwsException() { + assertThatThrownBy(() -> PasswordPolicy.validateFormat(null)) + .isInstanceOf(CoreException.class); + } + + @DisplayName("생년월일(yyyyMMdd) 포함 비밀번호는 실패한다") + @Test + void validate_withBirthDateYYYYMMDD_throwsException() { + assertThatThrownBy(() -> + PasswordPolicy.validate("Pass19900115!", LocalDate.of(1990, 1, 15))) + .isInstanceOf(CoreException.class); + } + + @DisplayName("생년월일(yyMMdd) 포함 비밀번호는 실패한다") + @Test + void validate_withBirthDateYYMMDD_throwsException() { + assertThatThrownBy(() -> + PasswordPolicy.validate("Pass900115!!", LocalDate.of(1990, 1, 15))) + .isInstanceOf(CoreException.class); + } + + @DisplayName("금지 문자열이 포함되면 실패한다") + @Test + void validateNotContainsSubstrings_withForbidden_throwsException() { + assertThatThrownBy(() -> + PasswordPolicy.validateNotContainsSubstrings( + "hello_forbidden_world", + List.of("forbidden"), + "금지 문자열 포함")) + .isInstanceOf(CoreException.class); + } + + @DisplayName("extractBirthDateStrings는 yyyyMMdd와 yyMMdd를 반환한다") + @Test + void extractBirthDateStrings_returnsTwoFormats() { + List result = PasswordPolicy.extractBirthDateStrings(LocalDate.of(1990, 1, 15)); + org.assertj.core.api.Assertions.assertThat(result).containsExactly("19900115", "900115"); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/BirthDateTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/BirthDateTest.java new file mode 100644 index 0000000000..4242886405 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/BirthDateTest.java @@ -0,0 +1,48 @@ +package com.loopers.domain.member.vo; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class BirthDateTest { + + @DisplayName("yyyy-MM-dd 형식의 문자열로 BirthDate를 생성할 수 있다") + @Test + void from_withValidFormat_succeeds() { + BirthDate birthDate = BirthDate.from("1990-01-15"); + assertThat(birthDate.value()).isEqualTo(LocalDate.of(1990, 1, 15)); + } + + @DisplayName("생년월일이 yyyy-MM-dd 형식에 맞지 않으면 User 객체 생성에 실패한다") + @Test + void from_withInvalidFormat_throwsException() { + assertThatThrownBy(() -> BirthDate.from("19900115")) + .isInstanceOf(CoreException.class); + } + + @DisplayName("null이면 생성에 실패한다") + @Test + void from_withNull_throwsException() { + assertThatThrownBy(() -> BirthDate.from(null)) + .isInstanceOf(CoreException.class); + } + + @DisplayName("슬래시 형식이면 생성에 실패한다") + @Test + void from_withSlashFormat_throwsException() { + assertThatThrownBy(() -> BirthDate.from("1990/01/15")) + .isInstanceOf(CoreException.class); + } + + @DisplayName("빈 문자열이면 생성에 실패한다") + @Test + void from_withEmpty_throwsException() { + assertThatThrownBy(() -> BirthDate.from("")) + .isInstanceOf(CoreException.class); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/EmailTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/EmailTest.java new file mode 100644 index 0000000000..5976980fc9 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/EmailTest.java @@ -0,0 +1,46 @@ +package com.loopers.domain.member.vo; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class EmailTest { + + @DisplayName("유효한 이메일 형식으로 Email을 생성할 수 있다") + @Test + void create_withValidFormat_succeeds() { + Email email = new Email("test@example.com"); + assertThat(email.value()).isEqualTo("test@example.com"); + } + + @DisplayName("이메일이 xx@yy.zz 형식에 맞지 않으면 User 객체 생성에 실패한다") + @Test + void create_withInvalidFormat_throwsException() { + assertThatThrownBy(() -> new Email("invalid-email")) + .isInstanceOf(CoreException.class); + } + + @DisplayName("@가 없으면 생성에 실패한다") + @Test + void create_withoutAtSign_throwsException() { + assertThatThrownBy(() -> new Email("testexample.com")) + .isInstanceOf(CoreException.class); + } + + @DisplayName("null이면 생성에 실패한다") + @Test + void create_withNull_throwsException() { + assertThatThrownBy(() -> new Email(null)) + .isInstanceOf(CoreException.class); + } + + @DisplayName("도메인 부분이 없으면 생성에 실패한다") + @Test + void create_withoutDomain_throwsException() { + assertThatThrownBy(() -> new Email("test@")) + .isInstanceOf(CoreException.class); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/LoginIdTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/LoginIdTest.java new file mode 100644 index 0000000000..382e5bb190 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/LoginIdTest.java @@ -0,0 +1,53 @@ +package com.loopers.domain.member.vo; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class LoginIdTest { + + @DisplayName("유효한 영문 및 숫자 조합으로 LoginId를 생성할 수 있다") + @Test + void create_withValidFormat_succeeds() { + LoginId loginId = new LoginId("user1234"); + assertThat(loginId.value()).isEqualTo("user1234"); + } + + @DisplayName("ID가 영문 및 숫자 10자 이내 형식에 맞지 않으면 User 객체 생성에 실패한다") + @Test + void create_withInvalidFormat_throwsException() { + assertThatThrownBy(() -> new LoginId("한글아이디")) + .isInstanceOf(CoreException.class); + } + + @DisplayName("10자를 초과하면 생성에 실패한다") + @Test + void create_withTooLong_throwsException() { + assertThatThrownBy(() -> new LoginId("abcdefghijk")) + .isInstanceOf(CoreException.class); + } + + @DisplayName("특수문자가 포함되면 생성에 실패한다") + @Test + void create_withSpecialChars_throwsException() { + assertThatThrownBy(() -> new LoginId("user!@#")) + .isInstanceOf(CoreException.class); + } + + @DisplayName("null이면 생성에 실패한다") + @Test + void create_withNull_throwsException() { + assertThatThrownBy(() -> new LoginId(null)) + .isInstanceOf(CoreException.class); + } + + @DisplayName("빈 문자열이면 생성에 실패한다") + @Test + void create_withEmpty_throwsException() { + assertThatThrownBy(() -> new LoginId("")) + .isInstanceOf(CoreException.class); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/PasswordTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/PasswordTest.java new file mode 100644 index 0000000000..e47be0ad50 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/PasswordTest.java @@ -0,0 +1,59 @@ +package com.loopers.domain.member.vo; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class PasswordTest { + + private final PasswordEncoder encoder = new BCryptPasswordEncoder(); + + @DisplayName("유효한 비밀번호로 Password를 생성할 수 있다") + @Test + void create_withValidPassword_succeeds() { + Password password = Password.create("Password1!", LocalDate.of(1990, 1, 15), encoder); + assertThat(password.encoded()).isNotBlank(); + } + + @DisplayName("비밀번호가 형식에 맞지 않으면 생성에 실패한다") + @Test + void create_withInvalidFormat_throwsException() { + assertThatThrownBy(() -> Password.create("short", LocalDate.of(1990, 1, 15), encoder)) + .isInstanceOf(CoreException.class); + } + + @DisplayName("비밀번호에 생년월일이 포함되면 생성에 실패한다") + @Test + void create_withBirthDate_throwsException() { + assertThatThrownBy(() -> Password.create("Pass19900115!", LocalDate.of(1990, 1, 15), encoder)) + .isInstanceOf(CoreException.class); + } + + @DisplayName("matches로 평문 비밀번호를 검증할 수 있다") + @Test + void matches_withCorrectPassword_returnsTrue() { + Password password = Password.create("Password1!", LocalDate.of(1990, 1, 15), encoder); + assertThat(password.matches("Password1!", encoder)).isTrue(); + } + + @DisplayName("matches로 틀린 비밀번호를 거부할 수 있다") + @Test + void matches_withWrongPassword_returnsFalse() { + Password password = Password.create("Password1!", LocalDate.of(1990, 1, 15), encoder); + assertThat(password.matches("WrongPass1!", encoder)).isFalse(); + } + + @DisplayName("encoded가 null이면 생성에 실패한다") + @Test + void constructor_withNull_throwsException() { + assertThatThrownBy(() -> new Password(null)) + .isInstanceOf(CoreException.class); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java new file mode 100644 index 0000000000..492e793d51 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java @@ -0,0 +1,17 @@ +package com.loopers.domain.order; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class OrderItemTest { + + @DisplayName("getSubtotal은 상품 가격과 수량의 곱을 반환한다") + @Test + void getSubtotal_calculatesCorrectly() { + OrderItem orderItem = new OrderItem(1L, "테스트 상품", 15000, "테스트 브랜드", 3); + + assertThat(orderItem.getSubtotal()).isEqualTo(45000); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java new file mode 100644 index 0000000000..ce840c69f7 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java @@ -0,0 +1,148 @@ +package com.loopers.domain.order; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class OrderTest { + + @Nested + @DisplayName("Order 생성") + class Create { + + @DisplayName("주문 항목이 비어있으면 예외가 발생한다") + @Test + void create_withEmptyItems_throwsException() { + assertThatThrownBy(() -> Order.create(1L, List.of())) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("주문 항목이 null이면 예외가 발생한다") + @Test + void create_withNullItems_throwsException() { + assertThatThrownBy(() -> Order.create(1L, null)) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("주문 항목들의 소계 합산으로 totalPrice가 계산된다") + @Test + void create_withItems_calculatesTotalPrice() { + Order.ItemSnapshot snap1 = new Order.ItemSnapshot(1L, "상품A", 10000, "브랜드A", 2); + Order.ItemSnapshot snap2 = new Order.ItemSnapshot(2L, "상품B", 5000, "브랜드B", 3); + + Order order = Order.create(1L, List.of(snap1, snap2)); + + assertThat(order.getMemberId()).isEqualTo(1L); + assertThat(order.getStatus()).isEqualTo(OrderStatus.CREATED); + assertThat(order.getTotalPrice()).isEqualTo(35000); + assertThat(order.getOriginalTotalPrice()).isEqualTo(35000); + assertThat(order.getDiscountAmount()).isEqualTo(0); + assertThat(order.getItems()).hasSize(2); + } + + @DisplayName("쿠폰을 적용하면 할인 금액만큼 totalPrice가 차감된다") + @Test + void create_withCoupon_appliesDiscount() { + Order.ItemSnapshot snap = new Order.ItemSnapshot(1L, "상품A", 10000, "브랜드A", 2); + + Order order = Order.create(1L, List.of(snap), 42L, 3000); + + assertThat(order.getOriginalTotalPrice()).isEqualTo(20000); + assertThat(order.getDiscountAmount()).isEqualTo(3000); + assertThat(order.getTotalPrice()).isEqualTo(17000); + assertThat(order.getCouponIssueId()).isEqualTo(42L); + } + + @DisplayName("할인 금액이 주문 금액을 초과하면 예외가 발생한다") + @Test + void create_withExcessiveDiscount_throwsException() { + Order.ItemSnapshot snap = new Order.ItemSnapshot(1L, "상품A", 10000, "브랜드A", 1); + + assertThatThrownBy(() -> Order.create(1L, List.of(snap), 42L, 15000)) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("할인 금액이 음수이면 예외가 발생한다") + @Test + void create_withNegativeDiscount_throwsException() { + Order.ItemSnapshot snap = new Order.ItemSnapshot(1L, "상품A", 10000, "브랜드A", 1); + + assertThatThrownBy(() -> Order.create(1L, List.of(snap), 42L, -1000)) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("쿠폰 없이 생성하면 할인 금액이 0이고 couponIssueId가 null이다") + @Test + void create_withoutCoupon_noDiscount() { + Order.ItemSnapshot snap = new Order.ItemSnapshot(1L, "상품A", 10000, "브랜드A", 1); + + Order order = Order.create(1L, List.of(snap)); + + assertThat(order.getOriginalTotalPrice()).isEqualTo(10000); + assertThat(order.getDiscountAmount()).isEqualTo(0); + assertThat(order.getTotalPrice()).isEqualTo(10000); + assertThat(order.getCouponIssueId()).isNull(); + } + } + + @Nested + @DisplayName("주문 취소") + class Cancel { + + @DisplayName("주문을 취소하면 상태가 CANCELLED로 변경된다") + @Test + void cancel_changesStatusToCancelled() { + Order order = Order.create(1L, List.of( + new Order.ItemSnapshot(1L, "상품A", 10000, "브랜드A", 1))); + + order.cancel(); + + assertThat(order.getStatus()).isEqualTo(OrderStatus.CANCELLED); + } + + @DisplayName("이미 취소된 주문을 다시 취소하면 예외가 발생한다") + @Test + void cancel_whenAlreadyCancelled_throwsException() { + Order order = Order.create(1L, List.of( + new Order.ItemSnapshot(1L, "상품A", 10000, "브랜드A", 1))); + order.cancel(); + + assertThatThrownBy(order::cancel) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @Nested + @DisplayName("주문 항목 조회") + class GetItems { + + @DisplayName("getItems는 수정 불가능한 리스트를 반환한다") + @Test + void getItems_returnsUnmodifiableList() { + Order order = Order.create(1L, List.of( + new Order.ItemSnapshot(1L, "상품A", 10000, "브랜드A", 1))); + + List items = order.getItems(); + + assertThatThrownBy(() -> items.add(new OrderItem(2L, "상품B", 5000, "브랜드B", 1))) + .isInstanceOf(UnsupportedOperationException.class); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/payment/CallbackInboxTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/payment/CallbackInboxTest.java new file mode 100644 index 0000000000..562298a909 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/payment/CallbackInboxTest.java @@ -0,0 +1,46 @@ +package com.loopers.domain.payment; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class CallbackInboxTest { + + @DisplayName("U4-7: 콜백 원본 저장 → RECEIVED 상태 확인") + @Test + void create_callbackInbox_receivedStatus() { + CallbackInbox inbox = CallbackInbox.create( + "TX-001", 1L, "SUCCESS", "{\"status\":\"SUCCESS\",\"transactionKey\":\"TX-001\"}"); + + assertThat(inbox.getTransactionKey()).isEqualTo("TX-001"); + assertThat(inbox.getOrderId()).isEqualTo(1L); + assertThat(inbox.getPgStatus()).isEqualTo("SUCCESS"); + assertThat(inbox.getPayload()).contains("TX-001"); + assertThat(inbox.getStatus()).isEqualTo(CallbackInboxStatus.RECEIVED); + assertThat(inbox.getRetryCount()).isZero(); + } + + @DisplayName("콜백 처리 완료 → PROCESSED 상태 전이") + @Test + void markProcessed_changesStatus() { + CallbackInbox inbox = CallbackInbox.create("TX-001", 1L, "SUCCESS", "{}"); + + inbox.markProcessed(); + + assertThat(inbox.getStatus()).isEqualTo(CallbackInboxStatus.PROCESSED); + assertThat(inbox.getProcessedAt()).isNotNull(); + } + + @DisplayName("콜백 처리 실패 → FAILED 상태 + retryCount 증가") + @Test + void markFailed_changesStatusAndIncrementsRetry() { + CallbackInbox inbox = CallbackInbox.create("TX-001", 1L, "SUCCESS", "{}"); + + inbox.markFailed("Payment not found"); + + assertThat(inbox.getStatus()).isEqualTo(CallbackInboxStatus.FAILED); + assertThat(inbox.getErrorMessage()).isEqualTo("Payment not found"); + assertThat(inbox.getRetryCount()).isEqualTo(1); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/payment/PaymentModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/payment/PaymentModelTest.java new file mode 100644 index 0000000000..e99d9daa01 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/payment/PaymentModelTest.java @@ -0,0 +1,217 @@ +package com.loopers.domain.payment; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class PaymentModelTest { + + @Nested + @DisplayName("결제 생성") + class Create { + + @DisplayName("U1-1: Payment 생성 시 초기 상태는 REQUESTED이다") + @Test + void create_initialStatusIsRequested() { + PaymentModel payment = PaymentModel.create(1L, 5000, "SAMSUNG", "1234-5678-9012-3456"); + + assertThat(payment.getStatus()).isEqualTo(PaymentStatus.REQUESTED); + assertThat(payment.getOrderId()).isEqualTo(1L); + assertThat(payment.getAmount()).isEqualTo(5000); + assertThat(payment.getCardType()).isEqualTo("SAMSUNG"); + assertThat(payment.getCardNo()).isEqualTo("1234-5678-9012-3456"); + assertThat(payment.getTransactionKey()).isNull(); + assertThat(payment.getPgProvider()).isNull(); + assertThat(payment.getFailureReason()).isNull(); + } + } + + @Nested + @DisplayName("상태 전이") + class StatusTransition { + + @DisplayName("U1-2: REQUESTED → PENDING 전이 성공") + @Test + void requested_toPending_succeeds() { + PaymentModel payment = PaymentModel.create(1L, 5000, "SAMSUNG", "1234-5678-9012-3456"); + + payment.markPending("TX-001", "SIMULATOR"); + + assertThat(payment.getStatus()).isEqualTo(PaymentStatus.PENDING); + assertThat(payment.getTransactionKey()).isEqualTo("TX-001"); + assertThat(payment.getPgProvider()).isEqualTo("SIMULATOR"); + } + + @DisplayName("U1-3: PENDING → PAID 전이 성공") + @Test + void pending_toPaid_succeeds() { + PaymentModel payment = PaymentModel.create(1L, 5000, "SAMSUNG", "1234-5678-9012-3456"); + payment.markPending("TX-001", "SIMULATOR"); + + payment.markPaid(); + + assertThat(payment.getStatus()).isEqualTo(PaymentStatus.PAID); + } + + @DisplayName("U1-4: PENDING → FAILED 전이 성공") + @Test + void pending_toFailed_succeeds() { + PaymentModel payment = PaymentModel.create(1L, 5000, "SAMSUNG", "1234-5678-9012-3456"); + payment.markPending("TX-001", "SIMULATOR"); + + payment.markFailed("한도초과입니다."); + + assertThat(payment.getStatus()).isEqualTo(PaymentStatus.FAILED); + assertThat(payment.getFailureReason()).isEqualTo("한도초과입니다."); + } + + @DisplayName("U1-5: PAID → FAILED 전이 불가 (예외)") + @Test + void paid_toFailed_throwsException() { + PaymentModel payment = PaymentModel.create(1L, 5000, "SAMSUNG", "1234-5678-9012-3456"); + payment.markPending("TX-001", "SIMULATOR"); + payment.markPaid(); + + assertThatThrownBy(() -> payment.markFailed("테스트")) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("U1-6: FAILED → PAID 전이 불가 (예외)") + @Test + void failed_toPaid_throwsException() { + PaymentModel payment = PaymentModel.create(1L, 5000, "SAMSUNG", "1234-5678-9012-3456"); + payment.markPending("TX-001", "SIMULATOR"); + payment.markFailed("실패"); + + assertThatThrownBy(() -> payment.markPaid()) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("REQUESTED → FAILED 전이 성공 (PG 요청 자체 실패)") + @Test + void requested_toFailed_succeeds() { + PaymentModel payment = PaymentModel.create(1L, 5000, "SAMSUNG", "1234-5678-9012-3456"); + + payment.markFailed("PG 연결 실패"); + + assertThat(payment.getStatus()).isEqualTo(PaymentStatus.FAILED); + assertThat(payment.getFailureReason()).isEqualTo("PG 연결 실패"); + } + + @DisplayName("REQUESTED → UNKNOWN 전이 성공 (PG 타임아웃)") + @Test + void requested_toUnknown_succeeds() { + PaymentModel payment = PaymentModel.create(1L, 5000, "SAMSUNG", "1234-5678-9012-3456"); + + payment.markUnknown(); + + assertThat(payment.getStatus()).isEqualTo(PaymentStatus.UNKNOWN); + } + + @DisplayName("UNKNOWN → PAID 전이 성공 (PG 확인 후 성공)") + @Test + void unknown_toPaid_succeeds() { + PaymentModel payment = PaymentModel.create(1L, 5000, "SAMSUNG", "1234-5678-9012-3456"); + payment.markUnknown(); + + payment.markPaid(); + + assertThat(payment.getStatus()).isEqualTo(PaymentStatus.PAID); + } + + @DisplayName("UNKNOWN → FAILED 전이 성공 (PG 확인 후 실패)") + @Test + void unknown_toFailed_succeeds() { + PaymentModel payment = PaymentModel.create(1L, 5000, "SAMSUNG", "1234-5678-9012-3456"); + payment.markUnknown(); + + payment.markFailed("PG 확인 결과 실패"); + + assertThat(payment.getStatus()).isEqualTo(PaymentStatus.FAILED); + assertThat(payment.getFailureReason()).isEqualTo("PG 확인 결과 실패"); + } + + @DisplayName("PAID → UNKNOWN 전이 불가 (예외)") + @Test + void paid_toUnknown_throwsException() { + PaymentModel payment = PaymentModel.create(1L, 5000, "SAMSUNG", "1234-5678-9012-3456"); + payment.markPending("TX-001", "SIMULATOR"); + payment.markPaid(); + + assertThatThrownBy(() -> payment.markUnknown()) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @Nested + @DisplayName("상태 전이 이력 추적") + class TransitionTracking { + + @DisplayName("markPending → pendingTransitions에 REQUESTED→PENDING 기록") + @Test + void markPending_recordsTransition() { + PaymentModel payment = PaymentModel.create(1L, 5000, "SAMSUNG", "1234-5678-9012-3456"); + + payment.markPending("TX-001", "SIMULATOR"); + + assertThat(payment.getPendingTransitions()).hasSize(1); + PaymentModel.StatusTransition t = payment.getPendingTransitions().get(0); + assertThat(t.from()).isEqualTo(PaymentStatus.REQUESTED); + assertThat(t.to()).isEqualTo(PaymentStatus.PENDING); + assertThat(t.reason()).isEqualTo("PG_RESPONSE"); + } + + @DisplayName("markPending → markPaid 연속 호출 시 2개 전이 기록") + @Test + void markPending_thenMarkPaid_recordsTwoTransitions() { + PaymentModel payment = PaymentModel.create(1L, 5000, "SAMSUNG", "1234-5678-9012-3456"); + + payment.markPending("TX-001", "SIMULATOR"); + payment.markPaid(); + + assertThat(payment.getPendingTransitions()).hasSize(2); + assertThat(payment.getPendingTransitions().get(0).from()).isEqualTo(PaymentStatus.REQUESTED); + assertThat(payment.getPendingTransitions().get(0).to()).isEqualTo(PaymentStatus.PENDING); + assertThat(payment.getPendingTransitions().get(1).from()).isEqualTo(PaymentStatus.PENDING); + assertThat(payment.getPendingTransitions().get(1).to()).isEqualTo(PaymentStatus.PAID); + } + + @DisplayName("markFailed — detail에 실패 사유 포함") + @Test + void markFailed_recordsDetailWithReason() { + PaymentModel payment = PaymentModel.create(1L, 5000, "SAMSUNG", "1234-5678-9012-3456"); + payment.markPending("TX-001", "SIMULATOR"); + + payment.markFailed("한도초과"); + + PaymentModel.StatusTransition t = payment.getPendingTransitions().get(1); + assertThat(t.from()).isEqualTo(PaymentStatus.PENDING); + assertThat(t.to()).isEqualTo(PaymentStatus.FAILED); + assertThat(t.detail()).isEqualTo("한도초과"); + } + + @DisplayName("clearPendingTransitions — 전이 리스트 초기화") + @Test + void clearPendingTransitions_clearsAll() { + PaymentModel payment = PaymentModel.create(1L, 5000, "SAMSUNG", "1234-5678-9012-3456"); + payment.markPending("TX-001", "SIMULATOR"); + payment.markPaid(); + assertThat(payment.getPendingTransitions()).hasSize(2); + + payment.clearPendingTransitions(); + + assertThat(payment.getPendingTransitions()).isEmpty(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/payment/PaymentStatusHistoryTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/payment/PaymentStatusHistoryTest.java new file mode 100644 index 0000000000..85459cd882 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/payment/PaymentStatusHistoryTest.java @@ -0,0 +1,35 @@ +package com.loopers.domain.payment; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class PaymentStatusHistoryTest { + + @DisplayName("create() — 모든 필드가 정확히 설정된다") + @Test + void create_allFieldsSet() { + PaymentStatusHistory history = PaymentStatusHistory.create( + 1L, PaymentStatus.PENDING, PaymentStatus.PAID, "CALLBACK", null); + + assertThat(history.getPaymentId()).isEqualTo(1L); + assertThat(history.getFromStatus()).isEqualTo(PaymentStatus.PENDING); + assertThat(history.getToStatus()).isEqualTo(PaymentStatus.PAID); + assertThat(history.getReason()).isEqualTo("CALLBACK"); + assertThat(history.getDetail()).isNull(); + } + + @DisplayName("create() — detail 포함") + @Test + void create_withDetail() { + PaymentStatusHistory history = PaymentStatusHistory.create( + 2L, PaymentStatus.PENDING, PaymentStatus.FAILED, "POLLING", "한도 초과"); + + assertThat(history.getPaymentId()).isEqualTo(2L); + assertThat(history.getFromStatus()).isEqualTo(PaymentStatus.PENDING); + assertThat(history.getToStatus()).isEqualTo(PaymentStatus.FAILED); + assertThat(history.getReason()).isEqualTo("POLLING"); + assertThat(history.getDetail()).isEqualTo("한도 초과"); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/payment/PaymentStatusTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/payment/PaymentStatusTest.java new file mode 100644 index 0000000000..1e4df87183 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/payment/PaymentStatusTest.java @@ -0,0 +1,71 @@ +package com.loopers.domain.payment; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * U1-7: 각 상태의 허용 전이 목록 검증. + */ +class PaymentStatusTest { + + @DisplayName("REQUESTED는 PENDING, FAILED, UNKNOWN으로 전이 가능하다") + @Test + void requested_canTransitionTo_pending_failed_unknown() { + assertThat(PaymentStatus.REQUESTED.canTransitionTo(PaymentStatus.PENDING)).isTrue(); + assertThat(PaymentStatus.REQUESTED.canTransitionTo(PaymentStatus.FAILED)).isTrue(); + assertThat(PaymentStatus.REQUESTED.canTransitionTo(PaymentStatus.UNKNOWN)).isTrue(); + assertThat(PaymentStatus.REQUESTED.canTransitionTo(PaymentStatus.PAID)).isFalse(); + } + + @DisplayName("PENDING은 PAID, FAILED, UNKNOWN으로 전이 가능하다") + @Test + void pending_canTransitionTo_paid_failed_unknown() { + assertThat(PaymentStatus.PENDING.canTransitionTo(PaymentStatus.PAID)).isTrue(); + assertThat(PaymentStatus.PENDING.canTransitionTo(PaymentStatus.FAILED)).isTrue(); + assertThat(PaymentStatus.PENDING.canTransitionTo(PaymentStatus.UNKNOWN)).isTrue(); + assertThat(PaymentStatus.PENDING.canTransitionTo(PaymentStatus.REQUESTED)).isFalse(); + } + + @DisplayName("PAID는 최종 상태로 어떤 상태로도 전이할 수 없다") + @Test + void paid_isTerminal() { + assertThat(PaymentStatus.PAID.isTerminal()).isTrue(); + assertThat(PaymentStatus.PAID.canTransitionTo(PaymentStatus.FAILED)).isFalse(); + assertThat(PaymentStatus.PAID.canTransitionTo(PaymentStatus.UNKNOWN)).isFalse(); + assertThat(PaymentStatus.PAID.canTransitionTo(PaymentStatus.PENDING)).isFalse(); + assertThat(PaymentStatus.PAID.canTransitionTo(PaymentStatus.REQUESTED)).isFalse(); + } + + @DisplayName("FAILED는 최종 상태로 어떤 상태로도 전이할 수 없다") + @Test + void failed_isTerminal() { + assertThat(PaymentStatus.FAILED.isTerminal()).isTrue(); + assertThat(PaymentStatus.FAILED.canTransitionTo(PaymentStatus.PAID)).isFalse(); + assertThat(PaymentStatus.FAILED.canTransitionTo(PaymentStatus.UNKNOWN)).isFalse(); + assertThat(PaymentStatus.FAILED.canTransitionTo(PaymentStatus.PENDING)).isFalse(); + assertThat(PaymentStatus.FAILED.canTransitionTo(PaymentStatus.REQUESTED)).isFalse(); + } + + @DisplayName("UNKNOWN은 PAID, FAILED로 전이 가능하다") + @Test + void unknown_canTransitionTo_paid_failed() { + assertThat(PaymentStatus.UNKNOWN.canTransitionTo(PaymentStatus.PAID)).isTrue(); + assertThat(PaymentStatus.UNKNOWN.canTransitionTo(PaymentStatus.FAILED)).isTrue(); + assertThat(PaymentStatus.UNKNOWN.canTransitionTo(PaymentStatus.PENDING)).isFalse(); + assertThat(PaymentStatus.UNKNOWN.canTransitionTo(PaymentStatus.REQUESTED)).isFalse(); + } + + @DisplayName("UNKNOWN은 최종 상태가 아니다") + @Test + void unknown_isNotTerminal() { + assertThat(PaymentStatus.UNKNOWN.isTerminal()).isFalse(); + } + + @DisplayName("REQUESTED는 최종 상태가 아니다") + @Test + void requested_isNotTerminal() { + assertThat(PaymentStatus.REQUESTED.isTerminal()).isFalse(); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java new file mode 100644 index 0000000000..295503da4d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java @@ -0,0 +1,59 @@ +package com.loopers.domain.product; + +import com.loopers.domain.product.vo.Price; +import com.loopers.domain.product.vo.Stock; +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ProductTest { + + private Product createProduct() { + return new Product(1L, "테스트 상품", new Price(10000), new Stock(10)); + } + + @Nested + @DisplayName("Product 생성") + class Create { + + @DisplayName("유효한 정보로 Product를 생성하면 필드가 올바르게 초기화된다") + @Test + void create_withValidInfo_fieldsAreInitialized() { + Product product = createProduct(); + + assertThat(product.getBrandId()).isEqualTo(1L); + assertThat(product.getName()).isEqualTo("테스트 상품"); + assertThat(product.getPrice().getValue()).isEqualTo(10000); + assertThat(product.getStock().getQuantity()).isEqualTo(10); + } + } + + @Nested + @DisplayName("재고 차감") + class DecreaseStock { + + @DisplayName("재고가 충분하면 차감에 성공한다") + @Test + void decreaseStock_withSufficientStock_succeeds() { + Product product = createProduct(); + + product.decreaseStock(3); + + assertThat(product.getStock().getQuantity()).isEqualTo(7); + } + + @DisplayName("재고가 부족하면 CoreException이 발생한다") + @Test + void decreaseStock_withInsufficientStock_throwsException() { + Product product = createProduct(); + + assertThatThrownBy(() -> product.decreaseStock(11)) + .isInstanceOf(CoreException.class); + } + } + +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/vo/PriceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/vo/PriceTest.java new file mode 100644 index 0000000000..c2cf0f9533 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/vo/PriceTest.java @@ -0,0 +1,38 @@ +package com.loopers.domain.product.vo; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class PriceTest { + + @Nested + @DisplayName("Price 생성") + class Create { + + @DisplayName("0으로 Price를 생성할 수 있다") + @Test + void create_withZero_succeeds() { + Price price = new Price(0); + assertThat(price.getValue()).isEqualTo(0); + } + + @DisplayName("양수로 Price를 생성할 수 있다") + @Test + void create_withPositiveValue_succeeds() { + Price price = new Price(10000); + assertThat(price.getValue()).isEqualTo(10000); + } + + @DisplayName("음수로 Price를 생성하면 예외가 발생한다") + @Test + void create_withNegativeValue_throwsException() { + assertThatThrownBy(() -> new Price(-1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("가격은 0 이상이어야 합니다."); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/vo/StockTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/vo/StockTest.java new file mode 100644 index 0000000000..4c2f3ede73 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/vo/StockTest.java @@ -0,0 +1,85 @@ +package com.loopers.domain.product.vo; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class StockTest { + + @Nested + @DisplayName("Stock 생성") + class Create { + + @DisplayName("0 이상의 수량으로 Stock을 생성할 수 있다") + @Test + void create_withValidQuantity_succeeds() { + Stock stock = new Stock(10); + assertThat(stock.getQuantity()).isEqualTo(10); + } + + @DisplayName("음수로 Stock을 생성하면 예외가 발생한다") + @Test + void create_withNegativeQuantity_throwsException() { + assertThatThrownBy(() -> new Stock(-1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("재고는 0 이상이어야 합니다."); + } + } + + @Nested + @DisplayName("hasEnough") + class HasEnough { + + @DisplayName("재고가 요청 수량 이상이면 true를 반환한다") + @Test + void hasEnough_withSufficientStock_returnsTrue() { + Stock stock = new Stock(10); + assertThat(stock.hasEnough(10)).isTrue(); + } + + @DisplayName("재고가 요청 수량 미만이면 false를 반환한다") + @Test + void hasEnough_withInsufficientStock_returnsFalse() { + Stock stock = new Stock(5); + assertThat(stock.hasEnough(6)).isFalse(); + } + } + + @Nested + @DisplayName("decrease") + class Decrease { + + @DisplayName("재고가 충분하면 차감된 Stock을 반환한다") + @Test + void decrease_withSufficientStock_returnsDecreasedStock() { + Stock stock = new Stock(10); + Stock decreased = stock.decrease(3); + assertThat(decreased.getQuantity()).isEqualTo(7); + } + + @DisplayName("재고가 부족하면 CoreException이 발생한다") + @Test + void decrease_withInsufficientStock_throwsException() { + Stock stock = new Stock(2); + assertThatThrownBy(() -> stock.decrease(3)) + .isInstanceOf(CoreException.class); + } + } + + @Nested + @DisplayName("increase") + class Increase { + + @DisplayName("수량을 증가시킨 Stock을 반환한다") + @Test + void increase_withValidAmount_returnsIncreasedStock() { + Stock stock = new Stock(5); + Stock increased = stock.increase(3); + assertThat(increased.getQuantity()).isEqualTo(8); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/fake/FakeBrandRepository.java b/apps/commerce-api/src/test/java/com/loopers/fake/FakeBrandRepository.java new file mode 100644 index 0000000000..a171c74f69 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/fake/FakeBrandRepository.java @@ -0,0 +1,60 @@ +package com.loopers.fake; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +public class FakeBrandRepository implements BrandRepository { + + private final Map store = new ConcurrentHashMap<>(); + private long sequence = 1L; + + @Override + public Brand save(Brand brand) { + if (brand.getId() == null || brand.getId() == 0L) { + long id = sequence++; + setBaseEntityId(brand, id); + } + store.put(brand.getId(), brand); + return brand; + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(store.get(id)) + .filter(brand -> brand.getDeletedAt() == null); + } + + @Override + public List findAll() { + return store.values().stream() + .filter(brand -> brand.getDeletedAt() == null) + .toList(); + } + + @Override + public List findAllByIds(Set ids) { + return store.values().stream() + .filter(brand -> brand.getDeletedAt() == null) + .filter(brand -> ids.contains(brand.getId())) + .toList(); + } + + private void setBaseEntityId(Object entity, long id) { + try { + Field idField = BaseEntity.class.getDeclaredField("id"); + idField.setAccessible(true); + idField.set(entity, id); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/fake/FakeCallbackInboxRepository.java b/apps/commerce-api/src/test/java/com/loopers/fake/FakeCallbackInboxRepository.java new file mode 100644 index 0000000000..430559d5b3 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/fake/FakeCallbackInboxRepository.java @@ -0,0 +1,71 @@ +package com.loopers.fake; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.payment.CallbackInbox; +import com.loopers.domain.payment.CallbackInboxRepository; +import com.loopers.domain.payment.CallbackInboxStatus; + +import java.lang.reflect.Field; +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +public class FakeCallbackInboxRepository implements CallbackInboxRepository { + + private final Map store = new ConcurrentHashMap<>(); + private long sequence = 1L; + + @Override + public CallbackInbox save(CallbackInbox callbackInbox) { + if (callbackInbox.getId() == null || callbackInbox.getId() == 0L) { + long id = sequence++; + setBaseEntityId(callbackInbox, id); + } + setCreatedAtIfAbsent(callbackInbox); + store.put(callbackInbox.getId(), callbackInbox); + return callbackInbox; + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(store.get(id)); + } + + @Override + public List findAllByStatus(CallbackInboxStatus status) { + return store.values().stream() + .filter(inbox -> inbox.getStatus() == status) + .toList(); + } + + @Override + public List findAllByTransactionKey(String transactionKey) { + return store.values().stream() + .filter(inbox -> transactionKey.equals(inbox.getTransactionKey())) + .toList(); + } + + private void setBaseEntityId(Object entity, long id) { + try { + Field idField = BaseEntity.class.getDeclaredField("id"); + idField.setAccessible(true); + idField.set(entity, id); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private void setCreatedAtIfAbsent(CallbackInbox inbox) { + if (inbox.getCreatedAt() == null) { + try { + Field createdAtField = BaseEntity.class.getDeclaredField("createdAt"); + createdAtField.setAccessible(true); + createdAtField.set(inbox, ZonedDateTime.now()); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/fake/FakeCouponIssueRepository.java b/apps/commerce-api/src/test/java/com/loopers/fake/FakeCouponIssueRepository.java new file mode 100644 index 0000000000..7e1043a152 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/fake/FakeCouponIssueRepository.java @@ -0,0 +1,57 @@ +package com.loopers.fake; + +import com.loopers.domain.coupon.CouponIssue; +import com.loopers.domain.coupon.CouponIssueRepository; +import com.loopers.domain.coupon.CouponIssueStatus; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +public class FakeCouponIssueRepository implements CouponIssueRepository { + + private final Map store = new ConcurrentHashMap<>(); + private long sequence = 1L; + + @Override + public CouponIssue save(CouponIssue couponIssue) { + if (couponIssue.getId() == null) { + long id = sequence++; + ReflectionTestUtils.setField(couponIssue, "id", id); + } + store.put(couponIssue.getId(), couponIssue); + return couponIssue; + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(store.get(id)); + } + + @Override + public int markAsUsed(Long id, ZonedDateTime now) { + CouponIssue issue = store.get(id); + if (issue == null) return 0; + if (issue.getStatus() != CouponIssueStatus.AVAILABLE) return 0; + if (issue.isExpired(now)) return 0; + issue.use(null, now); + return 1; + } + + @Override + public List findAllByMemberId(Long memberId) { + return store.values().stream() + .filter(issue -> issue.getMemberId().equals(memberId)) + .toList(); + } + + @Override + public List findAllByCouponId(Long couponId) { + return store.values().stream() + .filter(issue -> issue.getCouponId().equals(couponId)) + .toList(); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/fake/FakeCouponIssueRequestRepository.java b/apps/commerce-api/src/test/java/com/loopers/fake/FakeCouponIssueRequestRepository.java new file mode 100644 index 0000000000..0d1ea161a2 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/fake/FakeCouponIssueRequestRepository.java @@ -0,0 +1,39 @@ +package com.loopers.fake; + +import com.loopers.domain.coupon.CouponIssueRequest; +import com.loopers.domain.coupon.CouponIssueRequestRepository; + +import java.lang.reflect.Field; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +public class FakeCouponIssueRequestRepository implements CouponIssueRequestRepository { + + private final Map store = new ConcurrentHashMap<>(); + private long sequence = 1L; + + @Override + public CouponIssueRequest save(CouponIssueRequest request) { + if (request.getId() == null) { + setId(request, sequence++); + } + store.put(request.getId(), request); + return request; + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(store.get(id)); + } + + private void setId(CouponIssueRequest request, long id) { + try { + Field idField = CouponIssueRequest.class.getDeclaredField("id"); + idField.setAccessible(true); + idField.set(request, id); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/fake/FakeCouponRepository.java b/apps/commerce-api/src/test/java/com/loopers/fake/FakeCouponRepository.java new file mode 100644 index 0000000000..8516e8b21e --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/fake/FakeCouponRepository.java @@ -0,0 +1,50 @@ +package com.loopers.fake; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.coupon.Coupon; +import com.loopers.domain.coupon.CouponRepository; + +import java.lang.reflect.Field; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +public class FakeCouponRepository implements CouponRepository { + + private final Map store = new ConcurrentHashMap<>(); + private long sequence = 1L; + + @Override + public Coupon save(Coupon coupon) { + if (coupon.getId() == null || coupon.getId() == 0L) { + long id = sequence++; + setBaseEntityId(coupon, id); + } + store.put(coupon.getId(), coupon); + return coupon; + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(store.get(id)) + .filter(coupon -> coupon.getDeletedAt() == null); + } + + @Override + public List findAll() { + return store.values().stream() + .filter(coupon -> coupon.getDeletedAt() == null) + .toList(); + } + + private void setBaseEntityId(Object entity, long id) { + try { + Field idField = BaseEntity.class.getDeclaredField("id"); + idField.setAccessible(true); + idField.set(entity, id); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/fake/FakeLikeRepository.java b/apps/commerce-api/src/test/java/com/loopers/fake/FakeLikeRepository.java new file mode 100644 index 0000000000..785b42bb58 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/fake/FakeLikeRepository.java @@ -0,0 +1,85 @@ +package com.loopers.fake; + +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeRepository; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +public class FakeLikeRepository implements LikeRepository { + + private final Map store = new ConcurrentHashMap<>(); + private long sequence = 1L; + + @Override + public Like save(Like like) { + if (like.getId() == null) { + long id = sequence++; + ReflectionTestUtils.setField(like, "id", id); + } + store.put(like.getId(), like); + return like; + } + + @Override + public void delete(Like like) { + store.remove(like.getId()); + } + + @Override + public Optional findByMemberIdAndProductId(Long memberId, Long productId) { + return store.values().stream() + .filter(like -> like.getMemberId().equals(memberId) && like.getProductId().equals(productId)) + .findFirst(); + } + + @Override + public boolean existsByMemberIdAndProductId(Long memberId, Long productId) { + return store.values().stream() + .anyMatch(like -> like.getMemberId().equals(memberId) && like.getProductId().equals(productId)); + } + + @Override + public List findAllByMemberId(Long memberId) { + return store.values().stream() + .filter(like -> like.getMemberId().equals(memberId)) + .toList(); + } + + @Override + public void deleteAllByProductId(Long productId) { + List keysToRemove = store.entrySet().stream() + .filter(entry -> entry.getValue().getProductId().equals(productId)) + .map(Map.Entry::getKey) + .toList(); + keysToRemove.forEach(store::remove); + } + + @Override + public void deleteAllByProductIdIn(Collection productIds) { + List keysToRemove = store.entrySet().stream() + .filter(entry -> productIds.contains(entry.getValue().getProductId())) + .map(Map.Entry::getKey) + .toList(); + keysToRemove.forEach(store::remove); + } + + @Override + public long countByProductId(Long productId) { + return store.values().stream() + .filter(like -> like.getProductId().equals(productId)) + .count(); + } + + @Override + public Map countByProductIds(Collection productIds) { + return store.values().stream() + .filter(like -> productIds.contains(like.getProductId())) + .collect(Collectors.groupingBy(Like::getProductId, Collectors.counting())); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/fake/FakeOrderRepository.java b/apps/commerce-api/src/test/java/com/loopers/fake/FakeOrderRepository.java new file mode 100644 index 0000000000..b2a5aa26ed --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/fake/FakeOrderRepository.java @@ -0,0 +1,82 @@ +package com.loopers.fake; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderRepository; + +import java.lang.reflect.Field; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +public class FakeOrderRepository implements OrderRepository { + + private final Map store = new ConcurrentHashMap<>(); + private long sequence = 1L; + + @Override + public Order save(Order order) { + if (order.getId() == null || order.getId() == 0L) { + long id = sequence++; + setBaseEntityId(order, id); + } + setCreatedAtIfAbsent(order); + store.put(order.getId(), order); + return order; + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(store.get(id)); + } + + @Override + public List findAllByMemberId(Long memberId) { + return store.values().stream() + .filter(order -> order.getMemberId().equals(memberId)) + .toList(); + } + + @Override + public List findAllByMemberIdAndCreatedAtBetween(Long memberId, ZonedDateTime startAt, ZonedDateTime endAt) { + return store.values().stream() + .filter(order -> order.getMemberId().equals(memberId)) + .filter(order -> { + ZonedDateTime createdAt = order.getCreatedAt(); + return createdAt != null + && !createdAt.isBefore(startAt) + && !createdAt.isAfter(endAt); + }) + .toList(); + } + + @Override + public List findAll() { + return new ArrayList<>(store.values()); + } + + private void setBaseEntityId(Object entity, long id) { + try { + Field idField = BaseEntity.class.getDeclaredField("id"); + idField.setAccessible(true); + idField.set(entity, id); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private void setCreatedAtIfAbsent(Order order) { + if (order.getCreatedAt() == null) { + try { + Field createdAtField = BaseEntity.class.getDeclaredField("createdAt"); + createdAtField.setAccessible(true); + createdAtField.set(order, ZonedDateTime.now()); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/fake/FakePaymentOutboxRepository.java b/apps/commerce-api/src/test/java/com/loopers/fake/FakePaymentOutboxRepository.java new file mode 100644 index 0000000000..ad88ff1b89 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/fake/FakePaymentOutboxRepository.java @@ -0,0 +1,71 @@ +package com.loopers.fake; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.payment.PaymentOutbox; +import com.loopers.domain.payment.PaymentOutboxRepository; +import com.loopers.domain.payment.PaymentOutboxStatus; + +import java.lang.reflect.Field; +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +public class FakePaymentOutboxRepository implements PaymentOutboxRepository { + + private final Map store = new ConcurrentHashMap<>(); + private long sequence = 1L; + + @Override + public PaymentOutbox save(PaymentOutbox outbox) { + if (outbox.getId() == null || outbox.getId() == 0L) { + long id = sequence++; + setBaseEntityId(outbox, id); + } + setCreatedAtIfAbsent(outbox); + store.put(outbox.getId(), outbox); + return outbox; + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(store.get(id)); + } + + @Override + public List findAllByStatus(PaymentOutboxStatus status) { + return store.values().stream() + .filter(o -> o.getStatus() == status) + .toList(); + } + + @Override + public Optional findByPaymentId(Long paymentId) { + return store.values().stream() + .filter(o -> o.getPaymentId().equals(paymentId)) + .findFirst(); + } + + private void setBaseEntityId(Object entity, long id) { + try { + Field idField = BaseEntity.class.getDeclaredField("id"); + idField.setAccessible(true); + idField.set(entity, id); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private void setCreatedAtIfAbsent(PaymentOutbox outbox) { + if (outbox.getCreatedAt() == null) { + try { + Field createdAtField = BaseEntity.class.getDeclaredField("createdAt"); + createdAtField.setAccessible(true); + createdAtField.set(outbox, ZonedDateTime.now()); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/fake/FakePaymentRepository.java b/apps/commerce-api/src/test/java/com/loopers/fake/FakePaymentRepository.java new file mode 100644 index 0000000000..4304506756 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/fake/FakePaymentRepository.java @@ -0,0 +1,95 @@ +package com.loopers.fake; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.payment.PaymentModel; +import com.loopers.domain.payment.PaymentRepository; +import com.loopers.domain.payment.PaymentStatus; + +import java.lang.reflect.Field; +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +public class FakePaymentRepository implements PaymentRepository { + + private final Map store = new ConcurrentHashMap<>(); + private long sequence = 1L; + + @Override + public PaymentModel save(PaymentModel payment) { + if (payment.getId() == null || payment.getId() == 0L) { + long id = sequence++; + setBaseEntityId(payment, id); + } + setCreatedAtIfAbsent(payment); + store.put(payment.getId(), payment); + return payment; + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(store.get(id)); + } + + @Override + public Optional findByOrderId(Long orderId) { + return store.values().stream() + .filter(p -> p.getOrderId().equals(orderId)) + .findFirst(); + } + + @Override + public Optional findByTransactionKey(String transactionKey) { + return store.values().stream() + .filter(p -> transactionKey.equals(p.getTransactionKey())) + .findFirst(); + } + + @Override + public List findAllByStatus(PaymentStatus status) { + return store.values().stream() + .filter(p -> p.getStatus() == status) + .toList(); + } + + @Override + public int updateStatusConditionally(Long paymentId, PaymentStatus newStatus, + List allowedCurrentStatuses) { + PaymentModel payment = store.get(paymentId); + if (payment == null) return 0; + if (!allowedCurrentStatuses.contains(payment.getStatus())) return 0; + + try { + Field statusField = PaymentModel.class.getDeclaredField("status"); + statusField.setAccessible(true); + statusField.set(payment, newStatus); + return 1; + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private void setBaseEntityId(Object entity, long id) { + try { + Field idField = BaseEntity.class.getDeclaredField("id"); + idField.setAccessible(true); + idField.set(entity, id); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private void setCreatedAtIfAbsent(PaymentModel payment) { + if (payment.getCreatedAt() == null) { + try { + Field createdAtField = BaseEntity.class.getDeclaredField("createdAt"); + createdAtField.setAccessible(true); + createdAtField.set(payment, ZonedDateTime.now()); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/fake/FakePaymentStatusHistoryRepository.java b/apps/commerce-api/src/test/java/com/loopers/fake/FakePaymentStatusHistoryRepository.java new file mode 100644 index 0000000000..08243d4ccb --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/fake/FakePaymentStatusHistoryRepository.java @@ -0,0 +1,62 @@ +package com.loopers.fake; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.payment.PaymentStatusHistory; +import com.loopers.domain.payment.PaymentStatusHistoryRepository; + +import java.lang.reflect.Field; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +public class FakePaymentStatusHistoryRepository implements PaymentStatusHistoryRepository { + + private final Map store = new ConcurrentHashMap<>(); + private long sequence = 1L; + + @Override + public PaymentStatusHistory save(PaymentStatusHistory history) { + if (history.getId() == null || history.getId() == 0L) { + long id = sequence++; + setBaseEntityId(history, id); + } + setCreatedAtIfAbsent(history); + store.put(history.getId(), history); + return history; + } + + @Override + public List findAllByPaymentId(Long paymentId) { + return store.values().stream() + .filter(h -> h.getPaymentId().equals(paymentId)) + .toList(); + } + + public List findAll() { + return new ArrayList<>(store.values()); + } + + private void setBaseEntityId(Object entity, long id) { + try { + Field idField = BaseEntity.class.getDeclaredField("id"); + idField.setAccessible(true); + idField.set(entity, id); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private void setCreatedAtIfAbsent(Object entity) { + try { + Field createdAtField = BaseEntity.class.getDeclaredField("createdAt"); + createdAtField.setAccessible(true); + if (createdAtField.get(entity) == null) { + createdAtField.set(entity, ZonedDateTime.now()); + } + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/fake/FakePgClient.java b/apps/commerce-api/src/test/java/com/loopers/fake/FakePgClient.java new file mode 100644 index 0000000000..861d64b636 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/fake/FakePgClient.java @@ -0,0 +1,123 @@ +package com.loopers.fake; + +import com.loopers.infrastructure.pg.*; + +import java.net.SocketTimeoutException; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * PgClient Fake 구현체. 성공/실패를 외부에서 제어할 수 있다. + * + *

Phase 2 확장: failCount 기반 카운트다운 실패, orderId 기반 상태 조회

+ *

Phase 6 확장: 동기 응답 상태 설정(Toss 시뮬레이션), 타임아웃 시뮬레이션

+ */ +public class FakePgClient implements PgClient { + + private final String providerName; + private boolean shouldFail; + private String failMessage = "PG 요청 실패"; + private int callCount; + private int failUntilCall; + private String responseStatus = "PENDING"; + private boolean throwTimeout; + private final Map statusStore = new ConcurrentHashMap<>(); + private final Map orderStatusStore = new ConcurrentHashMap<>(); + + public FakePgClient(String providerName) { + this.providerName = providerName; + } + + public FakePgClient(String providerName, boolean shouldFail) { + this.providerName = providerName; + this.shouldFail = shouldFail; + } + + public void setShouldFail(boolean shouldFail) { + this.shouldFail = shouldFail; + } + + public void setFailMessage(String failMessage) { + this.failMessage = failMessage; + } + + /** + * 응답 상태를 설정한다. Toss 동기 PG 시뮬레이션: "SUCCESS" 또는 "FAILED". + */ + public void setResponseStatus(String responseStatus) { + this.responseStatus = responseStatus; + } + + /** + * 타임아웃 시뮬레이션 모드. true → SocketTimeoutException 발생. + */ + public void setThrowTimeout(boolean throwTimeout) { + this.throwTimeout = throwTimeout; + } + + /** + * 정확히 count번 실패 후 성공으로 전환한다. + */ + public void setFailCount(int count) { + this.failUntilCall = count; + } + + /** + * orderId 기반으로 PG 상태를 미리 등록한다 (멱등성 테스트용). + */ + public void registerOrderStatus(String orderId, PgPaymentStatusResponse response) { + orderStatusStore.put(orderId, response); + } + + public int getCallCount() { + return callCount; + } + + public void registerStatus(String transactionKey, PgPaymentStatusResponse response) { + statusStore.put(transactionKey, response); + } + + @Override + public PgPaymentResponse requestPayment(PgPaymentRequest request) { + callCount++; + + if (throwTimeout) { + throw new RuntimeException("Read timed out", + new SocketTimeoutException("Read timed out")); + } + + if (shouldFail || (failUntilCall > 0 && callCount <= failUntilCall)) { + throw new RuntimeException(failMessage); + } + + String transactionKey = "TX-" + UUID.randomUUID().toString().substring(0, 8); + statusStore.put(transactionKey, + new PgPaymentStatusResponse(responseStatus, transactionKey, null)); + return new PgPaymentResponse(responseStatus, transactionKey); + } + + @Override + public PgPaymentStatusResponse getPaymentStatus(String transactionKey) { + PgPaymentStatusResponse response = statusStore.get(transactionKey); + if (response == null) { + throw new RuntimeException("PG에 해당 거래가 없습니다: " + transactionKey); + } + return response; + } + + @Override + public PgPaymentStatusResponse getPaymentByOrderId(String orderId) { + PgPaymentStatusResponse orderStatus = orderStatusStore.get(orderId); + if (orderStatus != null) return orderStatus; + + return statusStore.values().stream() + .findFirst() + .orElseThrow(() -> new RuntimeException("PG에 해당 주문의 결제가 없습니다: " + orderId)); + } + + @Override + public String getProviderName() { + return providerName; + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/fake/FakeProductCachePort.java b/apps/commerce-api/src/test/java/com/loopers/fake/FakeProductCachePort.java new file mode 100644 index 0000000000..183cfd7b65 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/fake/FakeProductCachePort.java @@ -0,0 +1,37 @@ +package com.loopers.fake; + +import com.loopers.application.product.ProductCachePort; +import com.loopers.interfaces.api.product.ProductDto; + +public class FakeProductCachePort implements ProductCachePort { + + @Override + public ProductDto.ProductResponse getProductDetail(Long productId) { + return null; + } + + @Override + public void putProductDetail(Long productId, ProductDto.ProductResponse response) { + // no-op + } + + @Override + public void evictProductDetail(Long productId) { + // no-op + } + + @Override + public ProductDto.PagedProductResponse getProductList(Long brandId, String sort, int page, int size) { + return null; + } + + @Override + public void putProductList(Long brandId, String sort, int page, int size, ProductDto.PagedProductResponse response) { + // no-op + } + + @Override + public void evictProductList() { + // no-op + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/fake/FakeProductRepository.java b/apps/commerce-api/src/test/java/com/loopers/fake/FakeProductRepository.java new file mode 100644 index 0000000000..689b93833c --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/fake/FakeProductRepository.java @@ -0,0 +1,214 @@ +package com.loopers.fake; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.ProductWithBrand; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +import java.lang.reflect.Field; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +public class FakeProductRepository implements ProductRepository { + + private final Map store = new ConcurrentHashMap<>(); + private long sequence = 1L; + private BrandRepository brandRepository; + + @Override + public Product save(Product product) { + if (product.getId() == null || product.getId() == 0L) { + long id = sequence++; + setBaseEntityId(product, id); + } + if (product.getCreatedAt() == null) { + setFieldValue(product, BaseEntity.class, "createdAt", ZonedDateTime.now()); + setFieldValue(product, BaseEntity.class, "updatedAt", ZonedDateTime.now()); + } + store.put(product.getId(), product); + return product; + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(store.get(id)) + .filter(product -> product.getDeletedAt() == null); + } + + @Override + public List findAllByIdsWithLock(List ids) { + return ids.stream() + .distinct() + .map(store::get) + .filter(p -> p != null && p.getDeletedAt() == null) + .toList(); + } + + @Override + public List findAllByIds(List ids) { + return ids.stream() + .distinct() + .map(store::get) + .filter(p -> p != null && p.getDeletedAt() == null) + .map(p -> new ProductWithBrand(p, resolveBrandName(p.getBrandId()), p.getLikeCount())) + .toList(); + } + + @Override + public List findAll() { + return store.values().stream() + .filter(product -> product.getDeletedAt() == null) + .toList(); + } + + @Override + public List findAllByBrandId(Long brandId) { + return store.values().stream() + .filter(product -> product.getDeletedAt() == null) + .filter(product -> product.getBrandId().equals(brandId)) + .toList(); + } + + @Override + public List findAllWithBrand() { + return store.values().stream() + .filter(product -> product.getDeletedAt() == null) + .map(product -> new ProductWithBrand(product, resolveBrandName(product.getBrandId()), product.getLikeCount())) + .toList(); + } + + @Override + public List findAllWithBrand(String sort) { + Comparator comparator = toComparator(sort); + return store.values().stream() + .filter(product -> product.getDeletedAt() == null) + .sorted(comparator) + .map(product -> new ProductWithBrand(product, resolveBrandName(product.getBrandId()), product.getLikeCount())) + .toList(); + } + + @Override + public List findAllByBrandIdWithBrand(Long brandId) { + return store.values().stream() + .filter(product -> product.getDeletedAt() == null) + .filter(product -> product.getBrandId().equals(brandId)) + .map(product -> new ProductWithBrand(product, resolveBrandName(product.getBrandId()), product.getLikeCount())) + .toList(); + } + + @Override + public Page findAllWithBrand(String sort, Pageable pageable) { + List all = findAllWithBrand(sort); + return toPage(all, pageable); + } + + @Override + public Page findNewProducts(ZonedDateTime since, Pageable pageable) { + List all = store.values().stream() + .filter(p -> p.getDeletedAt() == null) + .filter(p -> p.getCreatedAt() != null && !p.getCreatedAt().isBefore(since)) + .sorted(Comparator.comparing(Product::getCreatedAt).reversed()) + .map(p -> new ProductWithBrand(p, resolveBrandName(p.getBrandId()), p.getLikeCount())) + .toList(); + return toPage(all, pageable); + } + + @Override + public Page findAllByBrandIdWithBrand(Long brandId, String sort, Pageable pageable) { + Comparator comparator = toComparator(sort); + List all = store.values().stream() + .filter(product -> product.getDeletedAt() == null) + .filter(product -> product.getBrandId().equals(brandId)) + .sorted(comparator) + .map(product -> new ProductWithBrand(product, resolveBrandName(product.getBrandId()), product.getLikeCount())) + .toList(); + return toPage(all, pageable); + } + + @Override + public int incrementLikeCount(Long productId) { + Product product = store.get(productId); + if (product == null || product.getDeletedAt() != null) return 0; + setLikeCount(product, product.getLikeCount() + 1); + return 1; + } + + @Override + public int decrementLikeCount(Long productId) { + Product product = store.get(productId); + if (product == null || product.getDeletedAt() != null) return 0; + setLikeCount(product, Math.max(0, product.getLikeCount() - 1)); + return 1; + } + + public void setBrandRepository(BrandRepository brandRepository) { + this.brandRepository = brandRepository; + } + + private String resolveBrandName(Long brandId) { + if (brandRepository == null) return null; + return brandRepository.findById(brandId) + .map(Brand::getName) + .orElse(null); + } + + private Comparator toComparator(String sort) { + if (sort == null) { + return Comparator.comparing(Product::getId).reversed(); + } + return switch (sort) { + case "price_asc" -> Comparator.comparingInt(p -> p.getPrice().getValue()); + case "likes_desc" -> Comparator.comparing(Product::getLikeCount).reversed() + .thenComparing(Comparator.comparing(Product::getId).reversed()); + default -> Comparator.comparing(Product::getId).reversed(); + }; + } + + private Page toPage(List all, Pageable pageable) { + int start = (int) pageable.getOffset(); + int end = Math.min(start + pageable.getPageSize(), all.size()); + List pageContent = start < all.size() ? all.subList(start, end) : List.of(); + return new PageImpl<>(pageContent, pageable, all.size()); + } + + public void setCreatedAt(Long productId, ZonedDateTime createdAt) { + Product product = store.get(productId); + if (product != null) { + setFieldValue(product, BaseEntity.class, "createdAt", createdAt); + } + } + + private void setBaseEntityId(Object entity, long id) { + setFieldValue(entity, BaseEntity.class, "id", id); + } + + private void setLikeCount(Product product, int count) { + try { + Field likeCountField = Product.class.getDeclaredField("likeCount"); + likeCountField.setAccessible(true); + likeCountField.setInt(product, count); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private void setFieldValue(Object entity, Class clazz, String fieldName, Object value) { + try { + Field field = clazz.getDeclaredField(fieldName); + field.setAccessible(true); + field.set(entity, value); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/fake/FakeProvisionalOrderRedisRepository.java b/apps/commerce-api/src/test/java/com/loopers/fake/FakeProvisionalOrderRedisRepository.java new file mode 100644 index 0000000000..f2d23ab7ec --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/fake/FakeProvisionalOrderRedisRepository.java @@ -0,0 +1,68 @@ +package com.loopers.fake; + +import com.loopers.infrastructure.redis.ProvisionalOrderRedisRepository; + +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * ProvisionalOrderRedisRepository Fake — ConcurrentHashMap 기반. + * Redis 의존성 없이 가주문 CRUD를 테스트한다. + */ +public class FakeProvisionalOrderRedisRepository extends ProvisionalOrderRedisRepository { + + private final Map> store = new ConcurrentHashMap<>(); + private final Map ttlStore = new ConcurrentHashMap<>(); + + public FakeProvisionalOrderRedisRepository() { + super(null, null, null); + } + + @Override + public void save(Long orderId, Map orderData) { + store.put(orderId, orderData); + } + + @Override + public Optional> findByOrderId(Long orderId) { + return Optional.ofNullable(store.get(orderId)); + } + + @Override + public void deleteByOrderId(Long orderId) { + store.remove(orderId); + } + + @Override + public boolean exists(Long orderId) { + return store.containsKey(orderId); + } + + @Override + public Set getAllOrderIds() { + return Set.copyOf(store.keySet()); + } + + @Override + public long getTtlSeconds(Long orderId) { + return store.containsKey(orderId) ? ttlStore.getOrDefault(orderId, 1800L) : -2; + } + + /** + * 테스트용 — 특정 가주문의 TTL을 설정한다. + */ + public void setTtl(Long orderId, long ttlSeconds) { + ttlStore.put(orderId, ttlSeconds); + } + + public int size() { + return store.size(); + } + + public void clear() { + store.clear(); + ttlStore.clear(); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/fake/FakeReconciliationMismatchRepository.java b/apps/commerce-api/src/test/java/com/loopers/fake/FakeReconciliationMismatchRepository.java new file mode 100644 index 0000000000..6a8ae077cf --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/fake/FakeReconciliationMismatchRepository.java @@ -0,0 +1,70 @@ +package com.loopers.fake; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.payment.ReconciliationMismatch; +import com.loopers.domain.payment.ReconciliationMismatchRepository; + +import java.lang.reflect.Field; +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +public class FakeReconciliationMismatchRepository implements ReconciliationMismatchRepository { + + private final Map store = new ConcurrentHashMap<>(); + private long sequence = 1L; + + @Override + public ReconciliationMismatch save(ReconciliationMismatch mismatch) { + if (mismatch.getId() == null || mismatch.getId() == 0L) { + long id = sequence++; + setBaseEntityId(mismatch, id); + } + setCreatedAtIfAbsent(mismatch); + store.put(mismatch.getId(), mismatch); + return mismatch; + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(store.get(id)); + } + + @Override + public List findAllByType(String type) { + return store.values().stream() + .filter(m -> type.equals(m.getType())) + .toList(); + } + + @Override + public List findAllUnresolved() { + return store.values().stream() + .filter(m -> m.getResolvedAt() == null) + .toList(); + } + + private void setBaseEntityId(Object entity, long id) { + try { + Field idField = BaseEntity.class.getDeclaredField("id"); + idField.setAccessible(true); + idField.set(entity, id); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private void setCreatedAtIfAbsent(ReconciliationMismatch mismatch) { + if (mismatch.getCreatedAt() == null) { + try { + Field createdAtField = BaseEntity.class.getDeclaredField("createdAt"); + createdAtField.setAccessible(true); + createdAtField.set(mismatch, ZonedDateTime.now()); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/fake/FakeStockReservationRedisRepository.java b/apps/commerce-api/src/test/java/com/loopers/fake/FakeStockReservationRedisRepository.java new file mode 100644 index 0000000000..1ef18eee0c --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/fake/FakeStockReservationRedisRepository.java @@ -0,0 +1,47 @@ +package com.loopers.fake; + +import com.loopers.infrastructure.redis.StockReservationRedisRepository; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +/** + * StockReservationRedisRepository Fake — AtomicLong 기반. + * Redis 의존성 없이 재고 DECR/INCR을 테스트한다. + */ +public class FakeStockReservationRedisRepository extends StockReservationRedisRepository { + + private final Map store = new ConcurrentHashMap<>(); + + public FakeStockReservationRedisRepository() { + super(null, null); + } + + @Override + public Long decrease(Long productId, int quantity) { + AtomicLong stock = store.computeIfAbsent(productId, k -> new AtomicLong(0)); + return stock.addAndGet(-quantity); + } + + @Override + public Long increase(Long productId, int quantity) { + AtomicLong stock = store.computeIfAbsent(productId, k -> new AtomicLong(0)); + return stock.addAndGet(quantity); + } + + @Override + public Long getStock(Long productId) { + AtomicLong stock = store.get(productId); + return stock != null ? stock.get() : null; + } + + @Override + public void setStock(Long productId, long quantity) { + store.computeIfAbsent(productId, k -> new AtomicLong(0)).set(quantity); + } + + public void clear() { + store.clear(); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/event/DomainEventPublisherImplTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/event/DomainEventPublisherImplTest.java new file mode 100644 index 0000000000..5b6c6e3546 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/event/DomainEventPublisherImplTest.java @@ -0,0 +1,71 @@ +package com.loopers.infrastructure.event; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.domain.event.EventOutbox; +import com.loopers.domain.event.EventOutboxRepository; +import com.loopers.domain.event.LikeCreatedEvent; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.context.ApplicationEventPublisher; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class DomainEventPublisherImplTest { + + private DomainEventPublisherImpl domainEventPublisher; + private List savedOutboxes; + private List publishedEvents; + + @BeforeEach + void setUp() { + savedOutboxes = new ArrayList<>(); + publishedEvents = new ArrayList<>(); + EventOutboxRepository eventOutboxRepository = outbox -> { + savedOutboxes.add(outbox); + return outbox; + }; + ApplicationEventPublisher applicationEventPublisher = publishedEvents::add; + domainEventPublisher = new DomainEventPublisherImpl( + eventOutboxRepository, applicationEventPublisher, new ObjectMapper()); + } + + @DisplayName("publish 호출 시 EventOutbox가 저장되고 ApplicationEvent가 발행된다") + @Test + void publish_savesOutboxAndPublishesEvent() { + Map payload = Map.of("productId", 1L, "memberId", 2L); + LikeCreatedEvent event = new LikeCreatedEvent(1L, 2L); + + domainEventPublisher.publish("catalog", "1", "LIKE_CREATED", payload, event); + + assertThat(savedOutboxes).hasSize(1); + EventOutbox outbox = savedOutboxes.get(0); + assertThat(outbox.getAggregateType()).isEqualTo("catalog"); + assertThat(outbox.getAggregateId()).isEqualTo("1"); + assertThat(outbox.getEventType()).isEqualTo("LIKE_CREATED"); + assertThat(outbox.getPayload()).contains("productId"); + assertThat(outbox.getPayload()).contains("memberId"); + + assertThat(publishedEvents).hasSize(1); + assertThat(publishedEvents.get(0)).isInstanceOf(LikeCreatedEvent.class); + } + + @DisplayName("직렬화 불가능한 payload 전달 시 RuntimeException이 발생한다") + @Test + void publish_withUnserializablePayload_throwsRuntimeException() { + Object unserializable = new Object() { + @SuppressWarnings("unused") + public Object getSelf() { return this; } + }; + + assertThatThrownBy(() -> + domainEventPublisher.publish("test", "1", "TEST", unserializable, new Object())) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("이벤트 페이로드 직렬화 실패"); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/payment/PaymentWalWriterTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/payment/PaymentWalWriterTest.java new file mode 100644 index 0000000000..c99919d17c --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/payment/PaymentWalWriterTest.java @@ -0,0 +1,64 @@ +package com.loopers.infrastructure.payment; + +import com.fasterxml.jackson.databind.ObjectMapper; +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.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class PaymentWalWriterTest { + + @TempDir + Path tempDir; + + private PaymentWalWriter walWriter; + + @BeforeEach + void setUp() { + walWriter = new PaymentWalWriter(tempDir.toString(), new ObjectMapper()); + } + + @DisplayName("U5-7: WAL 기록 → 파일 존재 확인 → 읽기 → 삭제") + @Test + void writeAndReadAndDelete() { + walWriter.write(1L, "TX-001", "SUCCESS"); + + // WAL 파일 존재 확인 + List files = walWriter.listWalFiles(); + assertThat(files).hasSize(1); + + // WAL 파일 읽기 + Map entry = walWriter.read(files.get(0)); + assertThat(entry.get("orderId")).isEqualTo(1); + assertThat(entry.get("transactionKey")).isEqualTo("TX-001"); + assertThat(entry.get("pgStatus")).isEqualTo("SUCCESS"); + + // WAL 파일 삭제 + walWriter.delete(files.get(0)); + assertThat(walWriter.listWalFiles()).isEmpty(); + } + + @DisplayName("빈 디렉토리 → 빈 리스트 반환") + @Test + void listEmpty_returnsEmptyList() { + assertThat(walWriter.listWalFiles()).isEmpty(); + } + + @DisplayName("여러 WAL 파일 기록 → 전부 조회") + @Test + void multipleWrites_allListed() { + walWriter.write(1L, "TX-001", "SUCCESS"); + walWriter.write(2L, "TX-002", "FAILED"); + + assertThat(walWriter.listWalFiles()).hasSize(2); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/pg/PgRouterTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/pg/PgRouterTest.java new file mode 100644 index 0000000000..7bfb8b71a4 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/pg/PgRouterTest.java @@ -0,0 +1,175 @@ +package com.loopers.infrastructure.pg; + +import com.loopers.fake.FakePgClient; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class PgRouterTest { + + @Nested + @DisplayName("결제 요청 라우팅") + class RequestPayment { + + @DisplayName("U1-11: Primary PG 성공 → 즉시 반환") + @Test + void requestPayment_primarySuccess_returnsImmediately() { + FakePgClient primary = new FakePgClient("SIMULATOR"); + FakePgClient fallback = new FakePgClient("TOSS"); + PgRouter router = new PgRouter(List.of(primary, fallback)); + + PgPaymentRequest request = PgPaymentRequest.of(1L, "SAMSUNG", "1234-5678-9012-3456", 5000, "http://callback"); + + PgPaymentResponse response = router.requestPayment(request); + + assertThat(response.status()).isEqualTo("PENDING"); + assertThat(response.transactionKey()).startsWith("TX-"); + } + + @DisplayName("U1-12: Primary PG 실패 → Fallback PG 시도") + @Test + void requestPayment_primaryFail_fallsBackToSecondary() { + FakePgClient primary = new FakePgClient("SIMULATOR", true); // 항상 실패 + FakePgClient fallback = new FakePgClient("TOSS"); + PgRouter router = new PgRouter(List.of(primary, fallback)); + + PgPaymentRequest request = PgPaymentRequest.of(1L, "SAMSUNG", "1234-5678-9012-3456", 5000, "http://callback"); + + PgPaymentResponse response = router.requestPayment(request); + + assertThat(response.status()).isEqualTo("PENDING"); + assertThat(response.transactionKey()).startsWith("TX-"); + } + + @DisplayName("U1-13: 모든 PG 실패 → CoreException 발생") + @Test + void requestPayment_allPgFail_throwsCoreException() { + FakePgClient primary = new FakePgClient("SIMULATOR", true); + FakePgClient fallback = new FakePgClient("TOSS", true); + PgRouter router = new PgRouter(List.of(primary, fallback)); + + PgPaymentRequest request = PgPaymentRequest.of(1L, "SAMSUNG", "1234-5678-9012-3456", 5000, "http://callback"); + + assertThatThrownBy(() -> router.requestPayment(request)) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.INTERNAL_ERROR); + } + } + + @Nested + @DisplayName("PgRouter 생성") + class Creation { + + @DisplayName("PG 클라이언트가 없으면 예외") + @Test + void creation_withEmptyList_throwsException() { + assertThatThrownBy(() -> new PgRouter(List.of())) + .isInstanceOf(IllegalArgumentException.class); + } + + @DisplayName("null이면 예외") + @Test + void creation_withNull_throwsException() { + assertThatThrownBy(() -> new PgRouter(null)) + .isInstanceOf(IllegalArgumentException.class); + } + } + + @Nested + @DisplayName("Multi-PG Fallback + 타임아웃 규칙") + class MultiPgFallback { + + @DisplayName("U6-3: Simulator 실패 → Toss 자동 전환 → SUCCESS") + @Test + void simulatorFail_fallbackToToss_success() { + FakePgClient simulator = new FakePgClient("SIMULATOR"); + simulator.setShouldFail(true); + FakePgClient toss = new FakePgClient("TOSS"); + toss.setResponseStatus("SUCCESS"); + + PgRouter router = new PgRouter(List.of(simulator, toss)); + PgPaymentRequest request = PgPaymentRequest.of(1L, "SAMSUNG", "1234", 5000, "http://test"); + + PgPaymentResponse response = router.requestPayment(request); + + assertThat(response.status()).isEqualTo("SUCCESS"); + assertThat(response.pgProvider()).isEqualTo("TOSS"); + assertThat(response.transactionKey()).isNotNull(); + assertThat(simulator.getCallCount()).isEqualTo(1); + assertThat(toss.getCallCount()).isEqualTo(1); + } + + @DisplayName("U6-4: Simulator 타임아웃 → Toss 전환하지 않음 → 예외 (중복 결제 방지)") + @Test + void simulatorTimeout_noFallback_throwsException() { + FakePgClient simulator = new FakePgClient("SIMULATOR"); + simulator.setThrowTimeout(true); + FakePgClient toss = new FakePgClient("TOSS"); + toss.setResponseStatus("SUCCESS"); + + PgRouter router = new PgRouter(List.of(simulator, toss)); + PgPaymentRequest request = PgPaymentRequest.of(1L, "SAMSUNG", "1234", 5000, "http://test"); + + assertThatThrownBy(() -> router.requestPayment(request)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("타임아웃"); + + // Toss는 호출되지 않음 (중복 결제 방지) + assertThat(toss.getCallCount()).isZero(); + } + + @DisplayName("Primary 성공 시 pgProvider 추적") + @Test + void primarySuccess_providerTracked() { + FakePgClient simulator = new FakePgClient("SIMULATOR"); + FakePgClient toss = new FakePgClient("TOSS"); + + PgRouter router = new PgRouter(List.of(simulator, toss)); + PgPaymentRequest request = PgPaymentRequest.of(1L, "SAMSUNG", "1234", 5000, "http://test"); + + PgPaymentResponse response = router.requestPayment(request); + + assertThat(response.pgProvider()).isEqualTo("SIMULATOR"); + assertThat(toss.getCallCount()).isZero(); + } + } + + @Nested + @DisplayName("결제 상태 조회") + class GetPaymentStatus { + + @DisplayName("pgProvider로 PG 찾아서 상태 조회 성공") + @Test + void getPaymentStatus_success() { + FakePgClient primary = new FakePgClient("SIMULATOR"); + primary.registerStatus("TX-001", + new PgPaymentStatusResponse("SUCCESS", "TX-001", "정상 승인되었습니다.")); + PgRouter router = new PgRouter(List.of(primary)); + + PgPaymentStatusResponse response = router.getPaymentStatus("TX-001", "SIMULATOR"); + + assertThat(response.status()).isEqualTo("SUCCESS"); + assertThat(response.transactionKey()).isEqualTo("TX-001"); + } + + @DisplayName("존재하지 않는 PG Provider → 예외") + @Test + void getPaymentStatus_unknownProvider_throwsException() { + FakePgClient primary = new FakePgClient("SIMULATOR"); + PgRouter router = new PgRouter(List.of(primary)); + + assertThatThrownBy(() -> router.getPaymentStatus("TX-001", "UNKNOWN")) + .isInstanceOf(CoreException.class) + .extracting(e -> ((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.INTERNAL_ERROR); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/pg/toss/TossSandboxPgClientTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/pg/toss/TossSandboxPgClientTest.java new file mode 100644 index 0000000000..d5f63ec088 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/pg/toss/TossSandboxPgClientTest.java @@ -0,0 +1,101 @@ +package com.loopers.infrastructure.pg.toss; + +import com.loopers.application.payment.PaymentFacade; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderStatus; +import com.loopers.domain.payment.PaymentModel; +import com.loopers.domain.payment.PaymentStatus; +import com.loopers.fake.*; +import com.loopers.infrastructure.pg.PgRouter; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Toss Sandbox PG 동기 결제 테스트. + * + *

Toss는 동기 PG — requestPayment() 응답이 SUCCESS/FAILED 즉시 반환. + * 콜백 대기 없이 PaymentFacade에서 바로 최종 상태 전이.

+ */ +class TossSandboxPgClientTest { + + private PaymentFacade paymentFacade; + private FakePaymentRepository paymentRepository; + private FakeOrderRepository orderRepository; + private FakePaymentOutboxRepository outboxRepository; + private FakePgClient tossPgClient; + + @BeforeEach + void setUp() throws Exception { + paymentRepository = new FakePaymentRepository(); + orderRepository = new FakeOrderRepository(); + outboxRepository = new FakePaymentOutboxRepository(); + tossPgClient = new FakePgClient("TOSS"); + PgRouter pgRouter = new PgRouter(List.of(tossPgClient)); + + paymentFacade = new PaymentFacade(paymentRepository, orderRepository, pgRouter, outboxRepository); + + setField(paymentFacade, "callbackUrl", "http://test/callback"); + setField(paymentFacade, "maxRetryAttempts", 3); + setField(paymentFacade, "initialWaitMs", 0L); + setField(paymentFacade, "backoffMultiplier", 2); + } + + private void setField(Object target, String fieldName, Object value) throws Exception { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } + + private Order createTestOrder() { + FakeBrandRepository brandRepository = new FakeBrandRepository(); + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + return orderRepository.save(Order.create(100L, List.of( + new Order.ItemSnapshot(1L, "에어맥스", 5000, brand.getName(), 1) + ))); + } + + @DisplayName("U6-1: Toss SUCCESS → Payment PAID 즉시 (콜백 불필요)") + @Test + void tossSuccess_paymentPaidImmediately() { + tossPgClient.setResponseStatus("SUCCESS"); + Order order = createTestOrder(); + + PaymentFacade.PaymentResult result = paymentFacade.requestPayment( + order.getId(), "SAMSUNG", "1234", 5000); + + // Payment → PAID 즉시 + assertThat(result.status()).isEqualTo("PAID"); + assertThat(result.transactionKey()).isNotNull(); + + PaymentModel payment = paymentRepository.findById(result.paymentId()).orElseThrow(); + assertThat(payment.getStatus()).isEqualTo(PaymentStatus.PAID); + assertThat(payment.getPgProvider()).isEqualTo("TOSS"); + + // Order → PAID 즉시 (콜백 대기 불필요) + Order updatedOrder = orderRepository.findById(order.getId()).orElseThrow(); + assertThat(updatedOrder.getStatus()).isEqualTo(OrderStatus.PAID); + } + + @DisplayName("U6-2: Toss FAILED → Payment FAILED 즉시") + @Test + void tossFailed_paymentFailedImmediately() { + tossPgClient.setResponseStatus("FAILED"); + Order order = createTestOrder(); + + PaymentFacade.PaymentResult result = paymentFacade.requestPayment( + order.getId(), "SAMSUNG", "1234", 5000); + + // Payment → FAILED 즉시 + assertThat(result.status()).isEqualTo("FAILED"); + + PaymentModel payment = paymentRepository.findById(result.paymentId()).orElseThrow(); + assertThat(payment.getStatus()).isEqualTo(PaymentStatus.FAILED); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/product/CaffeineProductCacheAdapterTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/product/CaffeineProductCacheAdapterTest.java new file mode 100644 index 0000000000..156fea5ea5 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/product/CaffeineProductCacheAdapterTest.java @@ -0,0 +1,102 @@ +package com.loopers.infrastructure.product; + +import com.loopers.interfaces.api.product.ProductDto; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class CaffeineProductCacheAdapterTest { + + private CaffeineProductCacheAdapter cache; + + @BeforeEach + void setUp() { + cache = new CaffeineProductCacheAdapter(); + } + + @Nested + @DisplayName("상품 상세 캐시") + class DetailCache { + + @DisplayName("put 후 get하면 저장된 값이 반환된다") + @Test + void putAndGet() { + ProductDto.ProductResponse response = new ProductDto.ProductResponse( + 1L, 10L, "나이키", "에어맥스", 150000, 10, 5, null, null); + + cache.putProductDetail(1L, response); + + ProductDto.ProductResponse cached = cache.getProductDetail(1L); + assertThat(cached).isEqualTo(response); + } + + @DisplayName("캐시에 없는 상품은 null을 반환한다") + @Test + void getReturnsNullOnMiss() { + assertThat(cache.getProductDetail(999L)).isNull(); + } + + @DisplayName("evict 후 get하면 null을 반환한다") + @Test + void evictRemovesEntry() { + ProductDto.ProductResponse response = new ProductDto.ProductResponse( + 1L, 10L, "나이키", "에어맥스", 150000, 10, 5, null, null); + cache.putProductDetail(1L, response); + + cache.evictProductDetail(1L); + + assertThat(cache.getProductDetail(1L)).isNull(); + } + } + + @Nested + @DisplayName("상품 목록 캐시") + class ListCache { + + @DisplayName("put 후 get하면 저장된 값이 반환된다") + @Test + void putAndGet() { + ProductDto.PagedProductResponse response = new ProductDto.PagedProductResponse( + List.of(), 0, 0, 0, 20); + + cache.putProductList(null, "latest", 0, 20, response); + + ProductDto.PagedProductResponse cached = cache.getProductList(null, "latest", 0, 20); + assertThat(cached).isEqualTo(response); + } + + @DisplayName("brandId가 다르면 별도 캐시 엔트리이다") + @Test + void differentBrandIdIsSeparateEntry() { + ProductDto.PagedProductResponse allBrands = new ProductDto.PagedProductResponse( + List.of(), 100, 5, 0, 20); + ProductDto.PagedProductResponse brand1 = new ProductDto.PagedProductResponse( + List.of(), 10, 1, 0, 20); + + cache.putProductList(null, "latest", 0, 20, allBrands); + cache.putProductList(1L, "latest", 0, 20, brand1); + + assertThat(cache.getProductList(null, "latest", 0, 20).totalElements()).isEqualTo(100); + assertThat(cache.getProductList(1L, "latest", 0, 20).totalElements()).isEqualTo(10); + } + + @DisplayName("evictProductList는 모든 목록 캐시를 무효화한다") + @Test + void evictClearsAllListEntries() { + cache.putProductList(null, "latest", 0, 20, new ProductDto.PagedProductResponse( + List.of(), 0, 0, 0, 20)); + cache.putProductList(1L, "likes_desc", 0, 10, new ProductDto.PagedProductResponse( + List.of(), 0, 0, 0, 10)); + + cache.evictProductList(); + + assertThat(cache.getProductList(null, "latest", 0, 20)).isNull(); + assertThat(cache.getProductList(1L, "likes_desc", 0, 10)).isNull(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/product/MultiLayerProductCacheAdapterTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/product/MultiLayerProductCacheAdapterTest.java new file mode 100644 index 0000000000..ffa78ee4fb --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/product/MultiLayerProductCacheAdapterTest.java @@ -0,0 +1,197 @@ +package com.loopers.infrastructure.product; + +import com.loopers.application.product.ProductCachePort; +import com.loopers.interfaces.api.product.ProductDto; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class MultiLayerProductCacheAdapterTest { + + private SpyProductCachePort l1; + private SpyProductCachePort l2; + private MultiLayerProductCacheAdapter multiLayer; + + @BeforeEach + void setUp() { + l1 = new SpyProductCachePort(); + l2 = new SpyProductCachePort(); + multiLayer = new MultiLayerProductCacheAdapter(l1, l2); + } + + @Nested + @DisplayName("상품 상세 GET") + class GetDetail { + + @DisplayName("L1 히트 시 L1에서 반환하고 L2를 조회하지 않는다") + @Test + void l1Hit_returnsFromL1() { + ProductDto.ProductResponse response = detailResponse(1L); + l1.putProductDetail(1L, response); + + ProductDto.ProductResponse result = multiLayer.getProductDetail(1L); + + assertThat(result).isEqualTo(response); + assertThat(l2.getCallCount).isZero(); + } + + @DisplayName("L1 미스 + L2 히트 시 L2에서 반환하고 L1에 backfill한다") + @Test + void l1Miss_l2Hit_backfillsL1() { + ProductDto.ProductResponse response = detailResponse(1L); + l2.putProductDetail(1L, response); + + ProductDto.ProductResponse result = multiLayer.getProductDetail(1L); + + assertThat(result).isEqualTo(response); + assertThat(l1.getProductDetail(1L)).isEqualTo(response); + } + + @DisplayName("L1 미스 + L2 미스 시 null을 반환한다") + @Test + void bothMiss_returnsNull() { + assertThat(multiLayer.getProductDetail(999L)).isNull(); + } + } + + @Nested + @DisplayName("상품 상세 PUT") + class PutDetail { + + @DisplayName("L2 먼저, L1에도 저장한다") + @Test + void putStoresInBothLayers() { + ProductDto.ProductResponse response = detailResponse(1L); + + multiLayer.putProductDetail(1L, response); + + assertThat(l2.getProductDetail(1L)).isEqualTo(response); + assertThat(l1.getProductDetail(1L)).isEqualTo(response); + } + } + + @Nested + @DisplayName("상품 상세 EVICT") + class EvictDetail { + + @DisplayName("L1과 L2 모두에서 삭제한다") + @Test + void evictRemovesFromBothLayers() { + ProductDto.ProductResponse response = detailResponse(1L); + l1.putProductDetail(1L, response); + l2.putProductDetail(1L, response); + + multiLayer.evictProductDetail(1L); + + assertThat(l1.getProductDetail(1L)).isNull(); + assertThat(l2.getProductDetail(1L)).isNull(); + } + } + + @Nested + @DisplayName("상품 목록 GET") + class GetList { + + @DisplayName("L1 히트 시 L1에서 반환한다") + @Test + void l1Hit_returnsFromL1() { + ProductDto.PagedProductResponse response = listResponse(); + l1.putProductList(null, "latest", 0, 20, response); + + ProductDto.PagedProductResponse result = multiLayer.getProductList(null, "latest", 0, 20); + + assertThat(result).isEqualTo(response); + } + + @DisplayName("L1 미스 + L2 히트 시 L1에 backfill한다") + @Test + void l1Miss_l2Hit_backfillsL1() { + ProductDto.PagedProductResponse response = listResponse(); + l2.putProductList(null, "latest", 0, 20, response); + + multiLayer.getProductList(null, "latest", 0, 20); + + assertThat(l1.getProductList(null, "latest", 0, 20)).isEqualTo(response); + } + } + + @Nested + @DisplayName("상품 목록 EVICT") + class EvictList { + + @DisplayName("L1과 L2 모두에서 목록을 무효화한다") + @Test + void evictClearsBothLayers() { + l1.putProductList(null, "latest", 0, 20, listResponse()); + l2.putProductList(null, "latest", 0, 20, listResponse()); + + multiLayer.evictProductList(); + + assertThat(l1.getProductList(null, "latest", 0, 20)).isNull(); + assertThat(l2.getProductList(null, "latest", 0, 20)).isNull(); + } + } + + // ── 헬퍼 ── + + private static ProductDto.ProductResponse detailResponse(Long id) { + return new ProductDto.ProductResponse(id, 10L, "나이키", "에어맥스", 150000, 10, 5, null, null); + } + + private static ProductDto.PagedProductResponse listResponse() { + return new ProductDto.PagedProductResponse(List.of(), 0, 0, 0, 20); + } + + /** + * 테스트용 Spy — HashMap 기반 캐시 + 호출 횟수 카운팅 + */ + static class SpyProductCachePort implements ProductCachePort { + + private final Map detailStore = new HashMap<>(); + private final Map listStore = new HashMap<>(); + int getCallCount = 0; + + @Override + public ProductDto.ProductResponse getProductDetail(Long productId) { + getCallCount++; + return detailStore.get(productId); + } + + @Override + public void putProductDetail(Long productId, ProductDto.ProductResponse response) { + detailStore.put(productId, response); + } + + @Override + public void evictProductDetail(Long productId) { + detailStore.remove(productId); + } + + @Override + public ProductDto.PagedProductResponse getProductList(Long brandId, String sort, int page, int size) { + return listStore.get(listKey(brandId, sort, page, size)); + } + + @Override + public void putProductList(Long brandId, String sort, int page, int size, ProductDto.PagedProductResponse response) { + listStore.put(listKey(brandId, sort, page, size), response); + } + + @Override + public void evictProductList() { + listStore.clear(); + } + + private String listKey(Long brandId, String sort, int page, int size) { + String brandPart = brandId != null ? String.valueOf(brandId) : "all"; + return brandPart + ":" + sort + ":" + page + ":" + size; + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/queue/EntryTokenInterceptorTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/queue/EntryTokenInterceptorTest.java new file mode 100644 index 0000000000..6366974b42 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/queue/EntryTokenInterceptorTest.java @@ -0,0 +1,148 @@ +package com.loopers.infrastructure.queue; + +import com.loopers.domain.member.Member; +import com.loopers.infrastructure.redis.EntryTokenRedisRepository; +import com.loopers.infrastructure.resilience.SlidingWindowRateLimiter; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.aspectj.lang.ProceedingJoinPoint; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.*; + +class EntryTokenInterceptorTest { + + private EntryTokenInterceptor interceptor; + private EntryTokenRedisRepository entryTokenRedisRepository; + private SlidingWindowRateLimiter fallbackRateLimiter; + private SimpleMeterRegistry meterRegistry; + private ProceedingJoinPoint joinPoint; + + @BeforeEach + void setUp() { + entryTokenRedisRepository = mock(EntryTokenRedisRepository.class); + fallbackRateLimiter = mock(SlidingWindowRateLimiter.class); + meterRegistry = new SimpleMeterRegistry(); + interceptor = new EntryTokenInterceptor(entryTokenRedisRepository, fallbackRateLimiter, meterRegistry); + joinPoint = mock(ProceedingJoinPoint.class); + } + + @DisplayName("토큰 존재 → proceed 실행 후 토큰 소비") + @Test + void validateEntryToken_tokenExists_proceedsAndConsumes() throws Throwable { + Member member = mock(Member.class); + when(member.getId()).thenReturn(1L); + when(joinPoint.getArgs()).thenReturn(new Object[]{member}); + when(entryTokenRedisRepository.exists(1L)).thenReturn(true); + when(joinPoint.proceed()).thenReturn("result"); + + Object result = interceptor.validateEntryToken(joinPoint); + + assertThat(result).isEqualTo("result"); + verify(joinPoint).proceed(); + verify(entryTokenRedisRepository).consume(1L); + } + + @DisplayName("토큰 없음 → FORBIDDEN 예외") + @Test + void validateEntryToken_noToken_throwsForbidden() { + Member member = mock(Member.class); + when(member.getId()).thenReturn(1L); + when(joinPoint.getArgs()).thenReturn(new Object[]{member}); + when(entryTokenRedisRepository.exists(1L)).thenReturn(false); + + assertThatThrownBy(() -> interceptor.validateEntryToken(joinPoint)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.FORBIDDEN)); + + verify(entryTokenRedisRepository, never()).consume(anyLong()); + } + + @DisplayName("proceed 예외 시 토큰 미소비") + @Test + void validateEntryToken_proceedThrows_tokenNotConsumed() throws Throwable { + Member member = mock(Member.class); + when(member.getId()).thenReturn(1L); + when(joinPoint.getArgs()).thenReturn(new Object[]{member}); + when(entryTokenRedisRepository.exists(1L)).thenReturn(true); + when(joinPoint.proceed()).thenThrow(new RuntimeException("주문 실패")); + + assertThatThrownBy(() -> interceptor.validateEntryToken(joinPoint)) + .isInstanceOf(RuntimeException.class) + .hasMessage("주문 실패"); + + verify(entryTokenRedisRepository, never()).consume(anyLong()); + } + + @DisplayName("Member 인자 없음 → INTERNAL_ERROR 예외") + @Test + void validateEntryToken_noMemberArg_throwsInternalError() { + when(joinPoint.getArgs()).thenReturn(new Object[]{"notAMember"}); + + assertThatThrownBy(() -> interceptor.validateEntryToken(joinPoint)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.INTERNAL_ERROR)); + } + + // --- Graceful Degradation 테스트 --- + + @DisplayName("Redis 장애 + Rate Limit 허용 → proceed 실행") + @Test + void validateEntryToken_redisFails_rateLimitAllows_proceeds() throws Throwable { + Member member = mock(Member.class); + when(member.getId()).thenReturn(1L); + when(joinPoint.getArgs()).thenReturn(new Object[]{member}); + when(entryTokenRedisRepository.exists(1L)) + .thenThrow(new RuntimeException("Redis connection failed")); + when(fallbackRateLimiter.tryAcquire()).thenReturn(true); + when(joinPoint.proceed()).thenReturn("fallback-result"); + + Object result = interceptor.validateEntryToken(joinPoint); + + assertThat(result).isEqualTo("fallback-result"); + verify(joinPoint).proceed(); + verify(entryTokenRedisRepository, never()).consume(anyLong()); + double fallbackCount = meterRegistry.counter("queue.token.fallback").count(); + assertThat(fallbackCount).isEqualTo(1.0); + } + + @DisplayName("Redis 장애 + Rate Limit 초과 → TOO_MANY_REQUESTS") + @Test + void validateEntryToken_redisFails_rateLimitExceeded_throws429() { + Member member = mock(Member.class); + when(member.getId()).thenReturn(1L); + when(joinPoint.getArgs()).thenReturn(new Object[]{member}); + when(entryTokenRedisRepository.exists(1L)) + .thenThrow(new RuntimeException("Redis connection failed")); + when(fallbackRateLimiter.tryAcquire()).thenReturn(false); + + assertThatThrownBy(() -> interceptor.validateEntryToken(joinPoint)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()) + .isEqualTo(ErrorType.TOO_MANY_REQUESTS)); + } + + @DisplayName("consume 실패 → 정상 처리 (소비 무시)") + @Test + void validateEntryToken_consumeFails_proceedsNormally() throws Throwable { + Member member = mock(Member.class); + when(member.getId()).thenReturn(1L); + when(joinPoint.getArgs()).thenReturn(new Object[]{member}); + when(entryTokenRedisRepository.exists(1L)).thenReturn(true); + when(joinPoint.proceed()).thenReturn("result"); + doThrow(new RuntimeException("Redis connection failed")) + .when(entryTokenRedisRepository).consume(1L); + + Object result = interceptor.validateEntryToken(joinPoint); + + assertThat(result).isEqualTo("result"); + verify(joinPoint).proceed(); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/queue/QueueSseEmitterRegistryTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/queue/QueueSseEmitterRegistryTest.java new file mode 100644 index 0000000000..4d01f71f16 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/queue/QueueSseEmitterRegistryTest.java @@ -0,0 +1,70 @@ +package com.loopers.infrastructure.queue; + +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class QueueSseEmitterRegistryTest { + + private QueueSseEmitterRegistry registry; + private SimpleMeterRegistry meterRegistry; + + @BeforeEach + void setUp() { + meterRegistry = new SimpleMeterRegistry(); + registry = new QueueSseEmitterRegistry(meterRegistry); + } + + @DisplayName("정상 등록: emitter 반환 + connectionCount 증가") + @Test + void register_returnsEmitterAndIncrementsCount() { + SseEmitter emitter = registry.register(1L, 42); + + assertThat(emitter).isNotNull(); + assertThat(registry.getConnectionCount()).isEqualTo(1); + } + + @DisplayName("admitted 유저: onAdmission 후 emitter 제거") + @Test + void onAdmission_removesAdmittedEmitters() { + registry.register(1L, 10); + registry.register(2L, 20); + registry.register(3L, 30); + assertThat(registry.getConnectionCount()).isEqualTo(3); + + registry.onAdmission(List.of("1", "2"), 2); + + // admitted 유저 1, 2는 제거됨. 3은 남아있음. + assertThat(registry.getConnectionCount()).isLessThanOrEqualTo(1); + } + + @DisplayName("delta 브로드캐스트: 남은 클라이언트에게 delta 전송") + @Test + void onAdmission_broadcastsDeltaToRemaining() { + registry.register(10L, 50); + registry.register(20L, 100); + + // admitted 없이 delta만 브로드캐스트 + registry.onAdmission(List.of(), 8); + + // emitter가 아직 살아있음 + assertThat(registry.getConnectionCount()).isGreaterThanOrEqualTo(0); + } + + @DisplayName("중복 memberId: 기존 emitter 교체") + @Test + void register_duplicateMemberId_replacesExisting() { + SseEmitter first = registry.register(1L, 42); + assertThat(registry.getConnectionCount()).isEqualTo(1); + + SseEmitter second = registry.register(1L, 30); + assertThat(registry.getConnectionCount()).isEqualTo(1); + assertThat(second).isNotSameAs(first); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/queue/QueueSseIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/queue/QueueSseIntegrationTest.java new file mode 100644 index 0000000000..56fab02aa5 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/queue/QueueSseIntegrationTest.java @@ -0,0 +1,247 @@ +package com.loopers.infrastructure.queue; + +import com.loopers.domain.member.Member; +import com.loopers.infrastructure.redis.EntryTokenRedisRepository; +import com.loopers.infrastructure.redis.WaitingQueueRedisRepository; +import com.loopers.infrastructure.scheduler.QueueAdmissionScheduler; +import com.loopers.interfaces.api.queue.QueueController; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.*; + +/** + * SSE 통합 테스트 — Controller + Registry + Scheduler 실제 연동 검증. + * + *

Redis만 mock하고, QueueController · QueueSseEmitterRegistry · QueueAdmissionScheduler는 + * 실제 인스턴스를 사용하여 SSE 이벤트 흐름(연결 → position → delta → admitted → 종료)을 검증한다.

+ * + *

단위 테스트에서 각 컴포넌트를 개별 검증한 뒤, 이 통합 테스트에서 컴포넌트 간 연동을 검증한다.

+ */ +class QueueSseIntegrationTest { + + private QueueController controller; + private QueueSseEmitterRegistry registry; + private QueueAdmissionScheduler scheduler; + private WaitingQueueRedisRepository waitingQueueRedisRepository; + private EntryTokenRedisRepository entryTokenRedisRepository; + private SimpleMeterRegistry meterRegistry; + + @BeforeEach + void setUp() { + meterRegistry = new SimpleMeterRegistry(); + waitingQueueRedisRepository = mock(WaitingQueueRedisRepository.class); + entryTokenRedisRepository = mock(EntryTokenRedisRepository.class); + + // 실제 인스턴스 — 컴포넌트 간 연동을 검증 + registry = new QueueSseEmitterRegistry(meterRegistry); + scheduler = new QueueAdmissionScheduler( + waitingQueueRedisRepository, registry, meterRegistry); + controller = new QueueController( + waitingQueueRedisRepository, entryTokenRedisRepository, + registry, meterRegistry, 48_000L); + } + + @DisplayName("SSE 전체 흐름: stream 연결 → 스케줄러 입장 → admitted 이벤트 → 연결 종료") + @Test + void fullSseLifecycle_connectDeltaAdmitDisconnect() { + // Given: 3명이 대기열에 있음 (토큰 없음) + when(entryTokenRedisRepository.exists(anyLong())).thenReturn(false); + when(waitingQueueRedisRepository.getRank(1L)).thenReturn(20L); + when(waitingQueueRedisRepository.getRank(2L)).thenReturn(10L); + when(waitingQueueRedisRepository.getRank(3L)).thenReturn(0L); + + // When: 3명이 /queue/stream SSE 연결 + SseEmitter e1 = controller.stream(mockMember(1L)); + SseEmitter e2 = controller.stream(mockMember(2L)); + SseEmitter e3 = controller.stream(mockMember(3L)); + + // Then: 3개 연결 등록 + 각각 position 이벤트 전송됨 + assertThat(e1).isNotNull(); + assertThat(e2).isNotNull(); + assertThat(e3).isNotNull(); + assertThat(registry.getConnectionCount()).isEqualTo(3); + + // When: 스케줄러가 user 3 입장 처리 (Lua: ZPOPMIN + SETEX) + when(waitingQueueRedisRepository.popMinAndIssueTokens(8)) + .thenReturn(List.of("3")); + when(waitingQueueRedisRepository.size()).thenReturn(2L); + scheduler.admitUsers(); + + // Then: user 3 → admitted 이벤트 + 제거, user 1·2에게 delta(admittedCount=1) 전송 + assertThat(registry.getConnectionCount()).isEqualTo(2); + + // When: 스케줄러가 user 2 입장 처리 + when(waitingQueueRedisRepository.popMinAndIssueTokens(8)) + .thenReturn(List.of("2")); + when(waitingQueueRedisRepository.size()).thenReturn(1L); + scheduler.admitUsers(); + + // Then: user 2 admitted, user 1만 남음 + assertThat(registry.getConnectionCount()).isEqualTo(1); + + // When: 스케줄러가 user 1 입장 처리 (마지막) + when(waitingQueueRedisRepository.popMinAndIssueTokens(8)) + .thenReturn(List.of("1")); + when(waitingQueueRedisRepository.size()).thenReturn(0L); + scheduler.admitUsers(); + + // Then: 전원 입장 → SSE 연결 0 + assertThat(registry.getConnectionCount()).isEqualTo(0); + + // 메트릭: 총 3명 입장 처리 + assertThat(meterRegistry.counter("queue.admission.count").count()).isEqualTo(3.0); + } + + @DisplayName("배치 입장: 한 사이클에 여러 명 동시 admitted + 나머지에게 delta") + @Test + void batchAdmission_multipleUsersAdmittedInOneCycle() { + when(entryTokenRedisRepository.exists(anyLong())).thenReturn(false); + when(waitingQueueRedisRepository.getRank(1L)).thenReturn(4L); + when(waitingQueueRedisRepository.getRank(2L)).thenReturn(3L); + when(waitingQueueRedisRepository.getRank(3L)).thenReturn(2L); + when(waitingQueueRedisRepository.getRank(4L)).thenReturn(1L); + when(waitingQueueRedisRepository.getRank(5L)).thenReturn(0L); + + // 5명 SSE 연결 + for (long i = 1; i <= 5; i++) { + controller.stream(mockMember(i)); + } + assertThat(registry.getConnectionCount()).isEqualTo(5); + + // 한 사이클에 3명(user 5,4,3) 동시 입장 + when(waitingQueueRedisRepository.popMinAndIssueTokens(8)) + .thenReturn(List.of("5", "4", "3")); + when(waitingQueueRedisRepository.size()).thenReturn(2L); + scheduler.admitUsers(); + + // 3명 admitted + 제거 → 2명(user 1,2)만 남음 + // 남은 2명에게 delta(admittedCount=3) 전송 + assertThat(registry.getConnectionCount()).isEqualTo(2); + + // 다음 사이클: 나머지 2명 입장 + when(waitingQueueRedisRepository.popMinAndIssueTokens(8)) + .thenReturn(List.of("2", "1")); + when(waitingQueueRedisRepository.size()).thenReturn(0L); + scheduler.admitUsers(); + + assertThat(registry.getConnectionCount()).isEqualTo(0); + assertThat(meterRegistry.counter("queue.admission.count").count()).isEqualTo(5.0); + } + + @DisplayName("heartbeat: SSE 연결이 끊기지 않고 유지됨") + @Test + void heartbeat_keepsConnectionAlive() { + when(entryTokenRedisRepository.exists(1L)).thenReturn(false); + when(waitingQueueRedisRepository.getRank(1L)).thenReturn(5L); + + controller.stream(mockMember(1L)); + assertThat(registry.getConnectionCount()).isEqualTo(1); + + // heartbeat 전송 — 연결 유지 + scheduler.sendSseHeartbeat(); + + assertThat(registry.getConnectionCount()).isEqualTo(1); + } + + @DisplayName("재연결: 동일 memberId → 기존 emitter 교체 후 신규 position 전송") + @Test + void reconnect_sameMemberId_replacesExistingEmitter() { + when(entryTokenRedisRepository.exists(1L)).thenReturn(false); + when(waitingQueueRedisRepository.getRank(1L)).thenReturn(20L); + + SseEmitter first = controller.stream(mockMember(1L)); + assertThat(registry.getConnectionCount()).isEqualTo(1); + + // 재연결: 순번이 변경된 상태에서 다시 연결 + when(waitingQueueRedisRepository.getRank(1L)).thenReturn(12L); + SseEmitter second = controller.stream(mockMember(1L)); + + // 연결 수는 1 유지, 새 emitter로 교체됨 + assertThat(registry.getConnectionCount()).isEqualTo(1); + assertThat(second).isNotSameAs(first); + } + + @DisplayName("빈 큐 사이클: 입장 대상 없으면 SSE delta 미전송 + 연결 유지") + @Test + void emptyAdmission_noEventSent_connectionsMaintained() { + when(entryTokenRedisRepository.exists(1L)).thenReturn(false); + when(waitingQueueRedisRepository.getRank(1L)).thenReturn(5L); + + controller.stream(mockMember(1L)); + + // 스케줄러 실행 — 빈 큐 + when(waitingQueueRedisRepository.popMinAndIssueTokens(8)) + .thenReturn(Collections.emptyList()); + when(waitingQueueRedisRepository.size()).thenReturn(1L); + scheduler.admitUsers(); + + // 아무도 입장 안 함 → 연결 유지 + assertThat(registry.getConnectionCount()).isEqualTo(1); + } + + @DisplayName("이미 입장된 유저의 stream 요청 → admitted emitter 반환 + registry 미등록") + @Test + void alreadyAdmitted_returnsAdmittedEmitter_notRegistered() { + when(entryTokenRedisRepository.exists(1L)).thenReturn(true); + + SseEmitter emitter = controller.stream(mockMember(1L)); + + assertThat(emitter).isNotNull(); + // registry에 등록되지 않음 (즉시 admitted 이벤트 후 닫힘) + assertThat(registry.getConnectionCount()).isEqualTo(0); + } + + @DisplayName("대기열에 없는 유저의 stream 요청 → not_in_queue emitter 반환 + registry 미등록") + @Test + void notInQueue_returnsNotInQueueEmitter_notRegistered() { + when(entryTokenRedisRepository.exists(1L)).thenReturn(false); + when(waitingQueueRedisRepository.getRank(1L)).thenReturn(null); + + SseEmitter emitter = controller.stream(mockMember(1L)); + + assertThat(emitter).isNotNull(); + assertThat(registry.getConnectionCount()).isEqualTo(0); + } + + @DisplayName("SSE 게이지 메트릭: 연결 수 변화 추적") + @Test + void sseConnectionGauge_tracksConnectionCount() { + when(entryTokenRedisRepository.exists(anyLong())).thenReturn(false); + when(waitingQueueRedisRepository.getRank(1L)).thenReturn(10L); + when(waitingQueueRedisRepository.getRank(2L)).thenReturn(5L); + + // 0 → 2 + controller.stream(mockMember(1L)); + controller.stream(mockMember(2L)); + assertThat(registry.getConnectionCount()).isEqualTo(2); + + // 2 → 1 (user 2 admitted) + when(waitingQueueRedisRepository.popMinAndIssueTokens(8)) + .thenReturn(List.of("2")); + when(waitingQueueRedisRepository.size()).thenReturn(1L); + scheduler.admitUsers(); + assertThat(registry.getConnectionCount()).isEqualTo(1); + + // 1 → 0 (user 1 admitted) + when(waitingQueueRedisRepository.popMinAndIssueTokens(8)) + .thenReturn(List.of("1")); + when(waitingQueueRedisRepository.size()).thenReturn(0L); + scheduler.admitUsers(); + assertThat(registry.getConnectionCount()).isEqualTo(0); + } + + private Member mockMember(Long id) { + Member member = mock(Member.class); + when(member.getId()).thenReturn(id); + return member; + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/redis/StockReservationTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/redis/StockReservationTest.java new file mode 100644 index 0000000000..a108fb0143 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/redis/StockReservationTest.java @@ -0,0 +1,60 @@ +package com.loopers.infrastructure.redis; + +import com.loopers.fake.FakeStockReservationRedisRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class StockReservationTest { + + private FakeStockReservationRedisRepository stockRepository; + + @BeforeEach + void setUp() { + stockRepository = new FakeStockReservationRedisRepository(); + } + + @Nested + @DisplayName("재고 예약/복원") + class StockReservation { + + @DisplayName("U3-3: Redis DECR → 재고 감소 확인") + @Test + void decrease_reducesStock() { + stockRepository.setStock(1L, 100); + + Long remaining = stockRepository.decrease(1L, 3); + + assertThat(remaining).isEqualTo(97); + assertThat(stockRepository.getStock(1L)).isEqualTo(97); + } + + @DisplayName("U3-4: Redis INCR → 재고 복원 확인") + @Test + void increase_restoresStock() { + stockRepository.setStock(1L, 97); + + Long restored = stockRepository.increase(1L, 3); + + assertThat(restored).isEqualTo(100); + assertThat(stockRepository.getStock(1L)).isEqualTo(100); + } + + @DisplayName("재고 조회 — 키 없으면 null 반환") + @Test + void getStock_keyNotExists_returnsNull() { + assertThat(stockRepository.getStock(999L)).isNull(); + } + + @DisplayName("재고 초기화 — setStock으로 DB 동기화") + @Test + void setStock_initializesStock() { + stockRepository.setStock(1L, 50); + + assertThat(stockRepository.getStock(1L)).isEqualTo(50); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/resilience/SlidingWindowRateLimiterTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/resilience/SlidingWindowRateLimiterTest.java new file mode 100644 index 0000000000..3b9d87b6c9 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/resilience/SlidingWindowRateLimiterTest.java @@ -0,0 +1,73 @@ +package com.loopers.infrastructure.resilience; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class SlidingWindowRateLimiterTest { + + @Nested + @DisplayName("Sliding Window Rate Limiter") + class RateLimiting { + + @DisplayName("U2-1: limit 이내 요청은 전부 허용된다") + @Test + void withinLimit_allAllowed() { + var rateLimiter = new SlidingWindowRateLimiter(50, 1000); + + int accepted = 0; + for (int i = 0; i < 50; i++) { + if (rateLimiter.tryAcquire()) { + accepted++; + } + } + + assertThat(accepted).isEqualTo(50); + } + + @DisplayName("U2-2: limit 초과 요청은 거부된다") + @Test + void exceedLimit_rejected() { + var rateLimiter = new SlidingWindowRateLimiter(50, 1000); + + for (int i = 0; i < 50; i++) { + rateLimiter.tryAcquire(); + } + + assertThat(rateLimiter.tryAcquire()).isFalse(); + } + + @DisplayName("U2-3: 윈도우 경계에서 이전 윈도우 가중치가 적용된다 (Boundary Burst 방지)") + @Test + void windowBoundary_prevWindowWeightApplied() throws InterruptedException { + var rateLimiter = new SlidingWindowRateLimiter(10, 1000); + + // 현재 윈도우에서 10건 소진 + for (int i = 0; i < 10; i++) { + assertThat(rateLimiter.tryAcquire()).isTrue(); + } + assertThat(rateLimiter.tryAcquire()).isFalse(); + + // 윈도우 경계를 넘어감 (새 윈도우 시작 직후, 충분한 마진 확보) + Thread.sleep(1100); + + // Sliding Window: 이전 윈도우 10건이 가중치로 반영되어 + // Fixed Window와 달리 10건 전부 허용되지 않는다 (Boundary Burst 방지) + int acceptedInNewWindow = 0; + for (int i = 0; i < 10; i++) { + if (rateLimiter.tryAcquire()) { + acceptedInNewWindow++; + } + } + + // 핵심: 이전 윈도우 가중치 때문에 새 윈도우에서 10건 미만만 허용된다 + // (Fixed Window라면 10건 전부 허용되어 Boundary Burst 발생) + assertThat(acceptedInNewWindow) + .as("Sliding Window는 이전 윈도우 가중치로 Boundary Burst를 방지한다") + .isGreaterThanOrEqualTo(1) + .isLessThan(10); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/scheduler/CallbackDlqSchedulerTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/scheduler/CallbackDlqSchedulerTest.java new file mode 100644 index 0000000000..4699a753e5 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/scheduler/CallbackDlqSchedulerTest.java @@ -0,0 +1,103 @@ +package com.loopers.infrastructure.scheduler; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.application.coupon.CouponFacade; +import com.loopers.application.payment.PaymentRecoveryService; +import com.loopers.application.product.ProductFacade; +import com.loopers.domain.BaseEntity; +import com.loopers.infrastructure.redis.CouponIssueRequestRedisRepository; +import com.loopers.domain.order.Order; +import com.loopers.domain.payment.*; +import com.loopers.fake.*; +import com.loopers.infrastructure.pg.PgRouter; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.kafka.core.KafkaTemplate; + +import java.lang.reflect.Field; +import java.time.Clock; +import java.time.ZonedDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +class CallbackDlqSchedulerTest { + + private CallbackDlqScheduler dlqScheduler; + private FakeCallbackInboxRepository callbackInboxRepository; + private FakePaymentRepository paymentRepository; + private FakeOrderRepository orderRepository; + + @SuppressWarnings("unchecked") + @BeforeEach + void setUp() { + callbackInboxRepository = new FakeCallbackInboxRepository(); + paymentRepository = new FakePaymentRepository(); + orderRepository = new FakeOrderRepository(); + FakeProductRepository productRepository = new FakeProductRepository(); + FakeStockReservationRedisRepository stockRedisRepository = new FakeStockReservationRedisRepository(); + FakePgClient pgClient = new FakePgClient("SIMULATOR"); + PgRouter pgRouter = new PgRouter(List.of(pgClient)); + + ProductFacade productFacade = new ProductFacade( + productRepository, new FakeBrandRepository(), new FakeLikeRepository(), + new FakeProductCachePort(), event -> {}, stockRedisRepository, null); + + CouponFacade couponFacade = new CouponFacade(new FakeCouponRepository(), new FakeCouponIssueRepository(), + mock(CouponIssueRequestRedisRepository.class), mock(KafkaTemplate.class), new ObjectMapper(), Clock.systemDefaultZone()); + + PaymentRecoveryService recoveryService = new PaymentRecoveryService( + paymentRepository, new FakePaymentStatusHistoryRepository(), + callbackInboxRepository, orderRepository, + productFacade, couponFacade, pgRouter); + + dlqScheduler = new CallbackDlqScheduler(callbackInboxRepository, recoveryService); + } + + @DisplayName("U5-8: RECEIVED(30초 전) → 재처리 → PROCESSED") + @Test + void reprocess_oldReceived_processed() throws Exception { + // Payment 준비 + Order order = orderRepository.save( + Order.create(100L, List.of( + new Order.ItemSnapshot(1L, "에어맥스", 5000, "나이키", 1) + ))); + PaymentModel payment = PaymentModel.create(order.getId(), 5000, "SAMSUNG", "1234"); + payment.markPending("TX-DLQ-001", "SIMULATOR"); + paymentRepository.save(payment); + + // RECEIVED 상태 Inbox (30초 전) + CallbackInbox inbox = callbackInboxRepository.save( + CallbackInbox.create("TX-DLQ-001", order.getId(), "SUCCESS", "{}")); + setCreatedAt(inbox, ZonedDateTime.now().minusSeconds(35)); + + dlqScheduler.reprocessFailedCallbacks(); + + // 재처리 결과: 새 Inbox가 생성되고 PROCESSED + List inboxes = callbackInboxRepository.findAllByTransactionKey("TX-DLQ-001"); + boolean hasProcessed = inboxes.stream() + .anyMatch(i -> i.getStatus() == CallbackInboxStatus.PROCESSED); + assertThat(hasProcessed).isTrue(); + } + + @DisplayName("최근 RECEIVED(10초 전) → 재처리 대상 아님") + @Test + void reprocess_recentReceived_notProcessed() throws Exception { + CallbackInbox inbox = callbackInboxRepository.save( + CallbackInbox.create("TX-RECENT", 1L, "SUCCESS", "{}")); + setCreatedAt(inbox, ZonedDateTime.now().minusSeconds(10)); + + dlqScheduler.reprocessFailedCallbacks(); + + // 상태 변경 없음 + assertThat(inbox.getStatus()).isEqualTo(CallbackInboxStatus.RECEIVED); + } + + private void setCreatedAt(Object entity, ZonedDateTime createdAt) throws Exception { + Field field = BaseEntity.class.getDeclaredField("createdAt"); + field.setAccessible(true); + field.set(entity, createdAt); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/scheduler/OutboxPollerTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/scheduler/OutboxPollerTest.java new file mode 100644 index 0000000000..122d14fa86 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/scheduler/OutboxPollerTest.java @@ -0,0 +1,83 @@ +package com.loopers.infrastructure.scheduler; + +import com.loopers.domain.payment.*; +import com.loopers.fake.*; +import com.loopers.infrastructure.pg.PgRouter; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class OutboxPollerTest { + + private OutboxPollerScheduler poller; + private FakePaymentOutboxRepository outboxRepository; + private FakePaymentRepository paymentRepository; + private FakePgClient pgClient; + + @BeforeEach + void setUp() { + outboxRepository = new FakePaymentOutboxRepository(); + paymentRepository = new FakePaymentRepository(); + pgClient = new FakePgClient("SIMULATOR"); + PgRouter pgRouter = new PgRouter(List.of(pgClient)); + + poller = new OutboxPollerScheduler(outboxRepository, paymentRepository, pgRouter); + } + + @DisplayName("U5-1: Outbox PENDING → PG 기록 없음 → PG 호출 → PROCESSED") + @Test + void poll_pgNoRecord_callsPgAndProcesses() { + PaymentModel payment = paymentRepository.save( + PaymentModel.create(1L, 10000, "SAMSUNG", "1234")); + PaymentOutbox outbox = outboxRepository.save( + PaymentOutbox.create(payment.getId(), 1L, "{\"orderId\":1}")); + + poller.pollOutbox(); + + assertThat(outbox.getStatus()).isEqualTo(PaymentOutboxStatus.PROCESSED); + PaymentModel updated = paymentRepository.findById(payment.getId()).orElseThrow(); + assertThat(updated.getStatus()).isEqualTo(PaymentStatus.PENDING); + assertThat(updated.getTransactionKey()).isNotNull(); + } + + @DisplayName("U5-2: Outbox PENDING → Payment 이미 PAID → PROCESSED (PG 호출 없이)") + @Test + void poll_paymentAlreadyPaid_marksProcessed() { + PaymentModel payment = paymentRepository.save( + PaymentModel.create(1L, 10000, "SAMSUNG", "1234")); + payment.markPending("TX-001", "SIMULATOR"); + payment.markPaid(); + paymentRepository.save(payment); + + PaymentOutbox outbox = outboxRepository.save( + PaymentOutbox.create(payment.getId(), 1L, "{\"orderId\":1}")); + + poller.pollOutbox(); + + assertThat(outbox.getStatus()).isEqualTo(PaymentOutboxStatus.PROCESSED); + assertThat(pgClient.getCallCount()).isZero(); + } + + @DisplayName("U5-3: Outbox retry 3회 초과 → FAILED") + @Test + void poll_retryExceeded_marksFailed() { + PaymentModel payment = paymentRepository.save( + PaymentModel.create(1L, 10000, "SAMSUNG", "1234")); + PaymentOutbox outbox = outboxRepository.save( + PaymentOutbox.create(payment.getId(), 1L, "{\"orderId\":1}")); + + pgClient.setShouldFail(true); + + // 4회 폴링 → retryCount 3 초과 시 FAILED + for (int i = 0; i < 4; i++) { + poller.pollOutbox(); + } + + assertThat(outbox.getStatus()).isEqualTo(PaymentOutboxStatus.FAILED); + assertThat(outbox.getRetryCount()).isGreaterThan(3); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/scheduler/ProvisionalOrderExpirySchedulerTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/scheduler/ProvisionalOrderExpirySchedulerTest.java new file mode 100644 index 0000000000..ec1897e9ce --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/scheduler/ProvisionalOrderExpirySchedulerTest.java @@ -0,0 +1,85 @@ +package com.loopers.infrastructure.scheduler; + +import com.loopers.fake.FakeProvisionalOrderRedisRepository; +import com.loopers.fake.FakeStockReservationRedisRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class ProvisionalOrderExpirySchedulerTest { + + private ProvisionalOrderExpiryScheduler scheduler; + private FakeProvisionalOrderRedisRepository provisionalOrderRedisRepository; + private FakeStockReservationRedisRepository stockRedisRepository; + + @BeforeEach + void setUp() { + provisionalOrderRedisRepository = new FakeProvisionalOrderRedisRepository(); + stockRedisRepository = new FakeStockReservationRedisRepository(); + scheduler = new ProvisionalOrderExpiryScheduler( + provisionalOrderRedisRepository, stockRedisRepository); + } + + private void saveProvisionalOrder(Long orderId, Long productId, int quantity) { + Map orderData = Map.of( + "orderId", orderId, + "memberId", 100L, + "amount", 5000, + "items", List.of(Map.of("productId", productId, "quantity", quantity)) + ); + provisionalOrderRedisRepository.save(orderId, orderData); + } + + @DisplayName("TTL < 30초 가주문 → 재고 복원 + 삭제") + @Test + void cleanup_expiringOrder_restoresStockAndDeletes() { + stockRedisRepository.setStock(1L, 90); // 이미 10개 예약됨 + saveProvisionalOrder(1L, 1L, 10); + provisionalOrderRedisRepository.setTtl(1L, 15); // TTL 15초 (30초 미만) + + scheduler.cleanupExpiringOrders(); + + // 재고 복원 확인 + assertThat(stockRedisRepository.getStock(1L)).isEqualTo(100); + // 가주문 삭제 확인 + assertThat(provisionalOrderRedisRepository.exists(1L)).isFalse(); + } + + @DisplayName("TTL >= 30초 가주문 → 정리하지 않음") + @Test + void cleanup_healthyOrder_noChange() { + stockRedisRepository.setStock(1L, 90); + saveProvisionalOrder(1L, 1L, 10); + provisionalOrderRedisRepository.setTtl(1L, 600); // TTL 600초 (충분) + + scheduler.cleanupExpiringOrders(); + + // 재고 변경 없음 + assertThat(stockRedisRepository.getStock(1L)).isEqualTo(90); + // 가주문 유지 + assertThat(provisionalOrderRedisRepository.exists(1L)).isTrue(); + } + + @DisplayName("여러 가주문 중 만료 임박한 것만 정리") + @Test + void cleanup_mixedOrders_onlyExpiringCleaned() { + stockRedisRepository.setStock(1L, 80); // 20개 예약됨 (주문 2건) + saveProvisionalOrder(1L, 1L, 10); + saveProvisionalOrder(2L, 1L, 10); + provisionalOrderRedisRepository.setTtl(1L, 10); // 만료 임박 + provisionalOrderRedisRepository.setTtl(2L, 1200); // 충분 + + scheduler.cleanupExpiringOrders(); + + // 주문 1만 정리 + assertThat(provisionalOrderRedisRepository.exists(1L)).isFalse(); + assertThat(provisionalOrderRedisRepository.exists(2L)).isTrue(); + // 재고: 80 + 10(복원) = 90 + assertThat(stockRedisRepository.getStock(1L)).isEqualTo(90); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/scheduler/QueueAdmissionSchedulerTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/scheduler/QueueAdmissionSchedulerTest.java new file mode 100644 index 0000000000..b0c83b625b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/scheduler/QueueAdmissionSchedulerTest.java @@ -0,0 +1,154 @@ +package com.loopers.infrastructure.scheduler; + +import com.loopers.infrastructure.queue.QueueSseEmitterRegistry; +import com.loopers.infrastructure.redis.WaitingQueueRedisRepository; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.*; + +class QueueAdmissionSchedulerTest { + + private QueueAdmissionScheduler scheduler; + private WaitingQueueRedisRepository waitingQueueRedisRepository; + private QueueSseEmitterRegistry sseEmitterRegistry; + private SimpleMeterRegistry meterRegistry; + + @BeforeEach + void setUp() { + waitingQueueRedisRepository = mock(WaitingQueueRedisRepository.class); + sseEmitterRegistry = mock(QueueSseEmitterRegistry.class); + meterRegistry = new SimpleMeterRegistry(); + scheduler = new QueueAdmissionScheduler(waitingQueueRedisRepository, sseEmitterRegistry, meterRegistry); + } + + @DisplayName("배치 크기만큼 원자적 POP + 토큰 발급 (Lua)") + @Test + void admitUsers_popsAndIssuesTokensAtomically() { + when(waitingQueueRedisRepository.popMinAndIssueTokens(8)) + .thenReturn(List.of("1", "2", "3")); + + scheduler.admitUsers(); + + verify(waitingQueueRedisRepository).popMinAndIssueTokens(8); + } + + @DisplayName("빈 큐 → 입장 처리 없음") + @Test + void admitUsers_emptyQueue_noAdmission() { + when(waitingQueueRedisRepository.popMinAndIssueTokens(8)) + .thenReturn(Collections.emptyList()); + + scheduler.admitUsers(); + + verify(waitingQueueRedisRepository).popMinAndIssueTokens(8); + } + + @DisplayName("8명 배치 크기로 원자적 입장 호출") + @Test + void admitUsers_requestsBatchSizeOf8() { + when(waitingQueueRedisRepository.popMinAndIssueTokens(8)) + .thenReturn(Collections.emptyList()); + + scheduler.admitUsers(); + + verify(waitingQueueRedisRepository).popMinAndIssueTokens(8); + } + + @DisplayName("타임아웃 정리: 만료 엔트리 제거 호출") + @Test + void removeExpiredEntries_callsRepositoryWithCutoff() { + when(waitingQueueRedisRepository.removeExpiredEntries(anyLong())).thenReturn(5L); + + scheduler.removeExpiredEntries(); + + verify(waitingQueueRedisRepository).removeExpiredEntries(anyLong()); + } + + @DisplayName("타임아웃 정리: 제거 대상 없으면 로그 미출력 (정상 동작)") + @Test + void removeExpiredEntries_noneExpired_noException() { + when(waitingQueueRedisRepository.removeExpiredEntries(anyLong())).thenReturn(0L); + + scheduler.removeExpiredEntries(); + + verify(waitingQueueRedisRepository).removeExpiredEntries(anyLong()); + } + + // --- 메트릭 검증 --- + + @DisplayName("입장 처리 시 admission 카운터 증가") + @Test + void admitUsers_incrementsAdmissionCounter() { + when(waitingQueueRedisRepository.popMinAndIssueTokens(8)) + .thenReturn(List.of("1", "2", "3")); + + scheduler.admitUsers(); + + double count = meterRegistry.counter("queue.admission.count").count(); + assertThat(count).isEqualTo(3.0); + } + + @DisplayName("Redis 장애 시 error 카운터 증가") + @Test + void admitUsers_redisError_incrementsErrorCounter() { + when(waitingQueueRedisRepository.popMinAndIssueTokens(8)) + .thenThrow(new RuntimeException("Redis connection failed")); + + scheduler.admitUsers(); + + double count = meterRegistry.counter("queue.admission.errors").count(); + assertThat(count).isEqualTo(1.0); + } + + @DisplayName("타임아웃 정리 시 cleanup 카운터 증가") + @Test + void removeExpiredEntries_incrementsCleanupCounter() { + when(waitingQueueRedisRepository.removeExpiredEntries(anyLong())).thenReturn(5L); + + scheduler.removeExpiredEntries(); + + double count = meterRegistry.counter("queue.cleanup.removed").count(); + assertThat(count).isEqualTo(5.0); + } + + // --- SSE 연동 검증 --- + + @DisplayName("입장 처리 후 SSE registry에 onAdmission 호출") + @Test + void admitUsers_callsSseOnAdmission() { + List admitted = List.of("1", "2", "3"); + when(waitingQueueRedisRepository.popMinAndIssueTokens(8)) + .thenReturn(admitted); + + scheduler.admitUsers(); + + verify(sseEmitterRegistry).onAdmission(admitted, 3); + } + + @DisplayName("빈 큐 입장 시 SSE onAdmission 미호출") + @Test + void admitUsers_emptyQueue_noSseCall() { + when(waitingQueueRedisRepository.popMinAndIssueTokens(8)) + .thenReturn(Collections.emptyList()); + + scheduler.admitUsers(); + + verify(sseEmitterRegistry, never()).onAdmission(anyList(), anyInt()); + } + + @DisplayName("heartbeat 호출 시 SSE registry sendHeartbeat 호출") + @Test + void sendSseHeartbeat_callsRegistry() { + scheduler.sendSseHeartbeat(); + + verify(sseEmitterRegistry).sendHeartbeat(); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/scheduler/StockReconcileSchedulerTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/scheduler/StockReconcileSchedulerTest.java new file mode 100644 index 0000000000..9e3677fe9b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/scheduler/StockReconcileSchedulerTest.java @@ -0,0 +1,62 @@ +package com.loopers.infrastructure.scheduler; + +import com.loopers.domain.product.Product; +import com.loopers.domain.product.vo.Price; +import com.loopers.domain.product.vo.Stock; +import com.loopers.fake.FakeProductRepository; +import com.loopers.fake.FakeStockReservationRedisRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class StockReconcileSchedulerTest { + + private StockReconcileScheduler scheduler; + private FakeProductRepository productRepository; + private FakeStockReservationRedisRepository stockRedisRepository; + + @BeforeEach + void setUp() { + productRepository = new FakeProductRepository(); + stockRedisRepository = new FakeStockReservationRedisRepository(); + scheduler = new StockReconcileScheduler(productRepository, stockRedisRepository); + } + + @DisplayName("Redis 재고 불일치 → DB 기준으로 보정") + @Test + void reconcile_mismatch_correctsRedisToDbValue() { + Product product = productRepository.save( + new Product(1L, "에어맥스", new Price(5000), new Stock(100))); + stockRedisRepository.setStock(product.getId(), 50); // Redis: 50, DB: 100 + + scheduler.reconcileStock(); + + assertThat(stockRedisRepository.getStock(product.getId())).isEqualTo(100); + } + + @DisplayName("Redis에 재고 키 없음 → DB 기준으로 초기화") + @Test + void reconcile_noRedisKey_initializesFromDb() { + Product product = productRepository.save( + new Product(1L, "에어맥스", new Price(5000), new Stock(100))); + // Redis에 키 없음 + + scheduler.reconcileStock(); + + assertThat(stockRedisRepository.getStock(product.getId())).isEqualTo(100); + } + + @DisplayName("Redis-DB 재고 일치 → 변경 없음") + @Test + void reconcile_match_noChange() { + Product product = productRepository.save( + new Product(1L, "에어맥스", new Price(5000), new Stock(100))); + stockRedisRepository.setStock(product.getId(), 100); // 일치 + + scheduler.reconcileStock(); + + assertThat(stockRedisRepository.getStock(product.getId())).isEqualTo(100); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java deleted file mode 100644 index 1bb3dba65f..0000000000 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java +++ /dev/null @@ -1,114 +0,0 @@ -package com.loopers.interfaces.api; - -import com.loopers.domain.example.ExampleModel; -import com.loopers.infrastructure.example.ExampleJpaRepository; -import com.loopers.interfaces.api.example.ExampleV1Dto; -import com.loopers.utils.DatabaseCleanUp; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.web.client.TestRestTemplate; -import org.springframework.core.ParameterizedTypeReference; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpMethod; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; - -import java.util.function.Function; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertTrue; - -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -class ExampleV1ApiE2ETest { - - private static final Function ENDPOINT_GET = id -> "/api/v1/examples/" + id; - - private final TestRestTemplate testRestTemplate; - private final ExampleJpaRepository exampleJpaRepository; - private final DatabaseCleanUp databaseCleanUp; - - @Autowired - public ExampleV1ApiE2ETest( - TestRestTemplate testRestTemplate, - ExampleJpaRepository exampleJpaRepository, - DatabaseCleanUp databaseCleanUp - ) { - this.testRestTemplate = testRestTemplate; - this.exampleJpaRepository = exampleJpaRepository; - this.databaseCleanUp = databaseCleanUp; - } - - @AfterEach - void tearDown() { - databaseCleanUp.truncateAllTables(); - } - - @DisplayName("GET /api/v1/examples/{id}") - @Nested - class Get { - @DisplayName("존재하는 예시 ID를 주면, 해당 예시 정보를 반환한다.") - @Test - void returnsExampleInfo_whenValidIdIsProvided() { - // arrange - ExampleModel exampleModel = exampleJpaRepository.save( - new ExampleModel("예시 제목", "예시 설명") - ); - String requestUrl = ENDPOINT_GET.apply(exampleModel.getId()); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = - testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); - - // assert - assertAll( - () -> assertTrue(response.getStatusCode().is2xxSuccessful()), - () -> assertThat(response.getBody().data().id()).isEqualTo(exampleModel.getId()), - () -> assertThat(response.getBody().data().name()).isEqualTo(exampleModel.getName()), - () -> assertThat(response.getBody().data().description()).isEqualTo(exampleModel.getDescription()) - ); - } - - @DisplayName("숫자가 아닌 ID 로 요청하면, 400 BAD_REQUEST 응답을 받는다.") - @Test - void throwsBadRequest_whenIdIsNotProvided() { - // arrange - String requestUrl = "/api/v1/examples/나나"; - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = - testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); - - // assert - assertAll( - () -> assertTrue(response.getStatusCode().is4xxClientError()), - () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST) - ); - } - - @DisplayName("존재하지 않는 예시 ID를 주면, 404 NOT_FOUND 응답을 받는다.") - @Test - void throwsException_whenInvalidIdIsProvided() { - // arrange - Long invalidId = -1L; - String requestUrl = ENDPOINT_GET.apply(invalidId); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = - testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); - - // assert - assertAll( - () -> assertTrue(response.getStatusCode().is4xxClientError()), - () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) - ); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberV1ApiE2ETest.java new file mode 100644 index 0000000000..b861baeaea --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberV1ApiE2ETest.java @@ -0,0 +1,133 @@ +package com.loopers.interfaces.api.member; + +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class MemberV1ApiE2ETest { + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private ResponseEntity> signUp(Map body) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity> request = new HttpEntity<>(body, headers); + return testRestTemplate.exchange( + "/api/v1/members", + HttpMethod.POST, + request, + new ParameterizedTypeReference<>() {} + ); + } + + private Map validSignUpBody() { + return Map.of( + "loginId", "user1", + "password", "Password1!", + "name", "홍길동", + "birthDate", "1990-01-15", + "email", "test@example.com" + ); + } + + @DisplayName("POST /api/v1/members (회원 가입)") + @Nested + class SignUp { + + @DisplayName("회원 가입이 성공할 경우, 생성된 유저 정보를 응답으로 반환한다") + @Test + void signUp_withValidRequest_returnsCreatedWithUserInfo() { + // act + ResponseEntity> response = signUp(validSignUpBody()); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data().loginId()).isEqualTo("user1"), + () -> assertThat(response.getBody().data().name()).isEqualTo("홍길동"), + () -> assertThat(response.getBody().data().email()).isEqualTo("test@example.com") + ); + } + } + + @DisplayName("GET /api/v1/members/me (내 정보 조회)") + @Nested + class GetMyInfo { + + @DisplayName("내 정보 조회에 성공할 경우, 해당하는 유저 정보를 응답으로 반환한다") + @Test + void getMyInfo_withValidAuth_returnsUserInfo() { + // arrange + signUp(validSignUpBody()); + + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "user1"); + headers.set("X-Loopers-LoginPw", "Password1!"); + + // act + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/members/me", + HttpMethod.GET, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data().loginId()).isEqualTo("user1"), + () -> assertThat(response.getBody().data().email()).isEqualTo("test@example.com") + ); + } + + @DisplayName("존재하지 않는 ID로 조회할 경우, 401 Unauthorized") + @Test + void getMyInfo_withNonExistentId_returnsUnauthorized() { + // arrange + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "nobody"); + headers.set("X-Loopers-LoginPw", "Password1!"); + + // act + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/members/me", + HttpMethod.GET, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/payment/PaymentE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/payment/PaymentE2ETest.java new file mode 100644 index 0000000000..8b9132dbfa --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/payment/PaymentE2ETest.java @@ -0,0 +1,209 @@ +package com.loopers.interfaces.api.payment; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderRepository; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.vo.Price; +import com.loopers.domain.product.vo.Stock; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.*; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; + +import java.util.List; +import java.util.Map; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * E7-1~E7-5: 결제 API E2E 테스트. + * + *

WireMock으로 PG Simulator를 시뮬레이션. + * TestRestTemplate으로 실제 HTTP 호출.

+ * + *

실행 조건: Docker (MySQL + Redis Testcontainers) 필요.

+ */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class PaymentE2ETest { + + static WireMockServer pgSimulator = new WireMockServer(wireMockConfig().dynamicPort()); + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @Autowired + private BrandRepository brandRepository; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private OrderRepository orderRepository; + + @DynamicPropertySource + static void pgProperties(DynamicPropertyRegistry registry) { + registry.add("pg.simulator.url", pgSimulator::baseUrl); + registry.add("pg.toss.url", () -> "http://localhost:19999"); // Toss 미사용 + } + + @BeforeAll + static void startPgSimulator() { + pgSimulator.start(); + } + + @AfterAll + static void stopPgSimulator() { + pgSimulator.stop(); + } + + @BeforeEach + void resetStubs() { + pgSimulator.resetAll(); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private HttpEntity> jsonRequest(Map body) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + return new HttpEntity<>(body, headers); + } + + /** + * 테스트용 주문 데이터를 생성한다. + * Brand → Product → Order(CREATED) 순서로 저장. + */ + private Order createTestOrder(int amount) { + Brand brand = brandRepository.save(new Brand("테스트브랜드", "E2E 테스트")); + Product product = productRepository.save( + new Product(brand.getId(), "테스트상품", new Price(amount), new Stock(100))); + Order order = Order.create(1L, List.of( + new Order.ItemSnapshot(product.getId(), product.getName(), + amount, brand.getName(), 1) + )); + return orderRepository.save(order); + } + + @Nested + @DisplayName("결제 요청") + class RequestPayment { + + @DisplayName("E7-1: POST /api/v1/payments → 200 + 결제 처리 중") + @Test + void requestPayment_success_returnsPending() { + // PG Simulator: PENDING 응답 + pgSimulator.stubFor(post(urlEqualTo("/api/v1/payments")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"status\":\"PENDING\",\"transactionKey\":\"TX-E2E-001\"}"))); + + // PG Simulator: orderId 기반 조회 (멱등성 체크용) + pgSimulator.stubFor(get(urlPathEqualTo("/api/v1/payments")) + .willReturn(aResponse() + .withStatus(500))); // 기록 없음 + + // DB에 주문 데이터 삽입 + Order order = createTestOrder(5000); + + Map paymentRequest = Map.of( + "orderId", order.getId(), + "cardType", "SAMSUNG", + "cardNo", "1234-5678-9012-3456", + "amount", 5000 + ); + + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/payments", + HttpMethod.POST, + jsonRequest(paymentRequest), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().data().status()).isIn("PENDING", "UNKNOWN"); + } + + @DisplayName("E7-4: 존재하지 않는 주문 결제 → 에러") + @Test + void requestPayment_orderNotFound_error() { + Map paymentRequest = Map.of( + "orderId", 999L, + "cardType", "SAMSUNG", + "cardNo", "1234-5678-9012-3456", + "amount", 5000 + ); + + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/payments", + HttpMethod.POST, + jsonRequest(paymentRequest), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isIn(HttpStatus.BAD_REQUEST, HttpStatus.NOT_FOUND); + } + } + + @Nested + @DisplayName("콜백 + 전체 흐름") + class CallbackFlow { + + @DisplayName("E7-2: POST callback → 200 OK") + @Test + void callback_success_returns200() { + Map callbackRequest = Map.of( + "transactionKey", "TX-E2E-CALLBACK", + "status", "SUCCESS", + "payload", "{}" + ); + + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/payments/callback", + HttpMethod.POST, + jsonRequest(callbackRequest), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + } + + @Nested + @DisplayName("수동 복구") + class ManualConfirm { + + @DisplayName("E7-3: POST /{id}/confirm → PG 조회 → 상태 갱신") + @Test + void manualConfirm_pgQuery_statusUpdated() { + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/payments/1/confirm", + HttpMethod.POST, + null, + new ParameterizedTypeReference<>() {} + ); + + // Payment가 없으면 404, 있으면 200 + assertThat(response.getStatusCode()).isIn(HttpStatus.OK, HttpStatus.NOT_FOUND); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/queue/QueueControllerTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/queue/QueueControllerTest.java new file mode 100644 index 0000000000..b9e7259a47 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/queue/QueueControllerTest.java @@ -0,0 +1,224 @@ +package com.loopers.interfaces.api.queue; + +import com.loopers.domain.member.Member; +import com.loopers.infrastructure.queue.QueueSseEmitterRegistry; +import com.loopers.infrastructure.redis.EntryTokenRedisRepository; +import com.loopers.infrastructure.redis.WaitingQueueRedisRepository; +import com.loopers.interfaces.api.ApiResponse; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +class QueueControllerTest { + + private QueueController controller; + private WaitingQueueRedisRepository waitingQueueRedisRepository; + private EntryTokenRedisRepository entryTokenRedisRepository; + private QueueSseEmitterRegistry sseEmitterRegistry; + private SimpleMeterRegistry meterRegistry; + private Member member; + + @BeforeEach + void setUp() { + waitingQueueRedisRepository = mock(WaitingQueueRedisRepository.class); + entryTokenRedisRepository = mock(EntryTokenRedisRepository.class); + sseEmitterRegistry = mock(QueueSseEmitterRegistry.class); + meterRegistry = new SimpleMeterRegistry(); + controller = new QueueController( + waitingQueueRedisRepository, entryTokenRedisRepository, + sseEmitterRegistry, meterRegistry, 48_000L + ); + member = mock(Member.class); + when(member.getId()).thenReturn(1L); + } + + @DisplayName("enter: 토큰 없음 → ZADD → 순번 반환") + @Test + void enter_noToken_returnsQueued() { + when(entryTokenRedisRepository.exists(1L)).thenReturn(false); + when(waitingQueueRedisRepository.add(1L)).thenReturn(true); + when(waitingQueueRedisRepository.getRank(1L)).thenReturn(41L); + + ApiResponse response = controller.enter(member); + + QueueDto.EnterResponse data = response.data(); + assertThat(data.status()).isEqualTo("QUEUED"); + assertThat(data.position()).isEqualTo(42L); + assertThat(data.estimatedWaitSeconds()).isNotNull(); + assertThat(data.tokenRemainingSeconds()).isNull(); + assertThat(data.suggestedPollIntervalMs()).isEqualTo(1000L); + } + + @DisplayName("enter: 토큰 이미 존재 → ADMITTED 반환") + @Test + void enter_tokenExists_returnsAdmitted() { + when(entryTokenRedisRepository.exists(1L)).thenReturn(true); + when(entryTokenRedisRepository.getRemainingTtl(1L)).thenReturn(285L); + + ApiResponse response = controller.enter(member); + + QueueDto.EnterResponse data = response.data(); + assertThat(data.status()).isEqualTo("ADMITTED"); + assertThat(data.position()).isNull(); + assertThat(data.tokenRemainingSeconds()).isEqualTo(285L); + assertThat(data.suggestedPollIntervalMs()).isNull(); + verify(waitingQueueRedisRepository, never()).add(anyLong()); + } + + @DisplayName("enter: 중복 진입 → 기존 순번 유지") + @Test + void enter_duplicateEntry_keepsSamePosition() { + when(entryTokenRedisRepository.exists(1L)).thenReturn(false); + when(waitingQueueRedisRepository.add(1L)).thenReturn(false); + when(waitingQueueRedisRepository.getRank(1L)).thenReturn(10L); + + ApiResponse response = controller.enter(member); + + QueueDto.EnterResponse data = response.data(); + assertThat(data.status()).isEqualTo("QUEUED"); + assertThat(data.position()).isEqualTo(11L); + } + + @DisplayName("enter: 대기열 가득 참 → QUEUE_FULL 반환") + @Test + void enter_queueFull_returnsQueueFull() { + when(entryTokenRedisRepository.exists(1L)).thenReturn(false); + when(waitingQueueRedisRepository.size()).thenReturn(48_000L); + + ApiResponse response = controller.enter(member); + + QueueDto.EnterResponse data = response.data(); + assertThat(data.status()).isEqualTo("QUEUE_FULL"); + assertThat(data.position()).isNull(); + assertThat(data.estimatedWaitSeconds()).isNull(); + assertThat(data.tokenRemainingSeconds()).isNull(); + assertThat(data.suggestedPollIntervalMs()).isNull(); + verify(waitingQueueRedisRepository, never()).add(anyLong()); + } + + @DisplayName("position: 대기 중 → WAITING + 순번") + @Test + void position_waiting_returnsWaitingWithPosition() { + when(entryTokenRedisRepository.exists(1L)).thenReturn(false); + when(waitingQueueRedisRepository.getRank(1L)).thenReturn(99L); + when(waitingQueueRedisRepository.size()).thenReturn(1500L); + + ApiResponse response = controller.position(member); + + QueueDto.PositionResponse data = response.data(); + assertThat(data.status()).isEqualTo("WAITING"); + assertThat(data.position()).isEqualTo(100L); + assertThat(data.totalQueueSize()).isEqualTo(1500L); + assertThat(data.estimatedWaitSeconds()).isNotNull(); + assertThat(data.suggestedPollIntervalMs()).isEqualTo(1000L); + } + + @DisplayName("position: 토큰 존재 → ADMITTED") + @Test + void position_admitted_returnsAdmitted() { + when(entryTokenRedisRepository.exists(1L)).thenReturn(true); + when(entryTokenRedisRepository.getRemainingTtl(1L)).thenReturn(200L); + + ApiResponse response = controller.position(member); + + QueueDto.PositionResponse data = response.data(); + assertThat(data.status()).isEqualTo("ADMITTED"); + assertThat(data.tokenRemainingSeconds()).isEqualTo(200L); + assertThat(data.suggestedPollIntervalMs()).isNull(); + } + + @DisplayName("position: 큐에 없음 → NOT_IN_QUEUE") + @Test + void position_notInQueue_returnsNotInQueue() { + when(entryTokenRedisRepository.exists(1L)).thenReturn(false); + when(waitingQueueRedisRepository.getRank(1L)).thenReturn(null); + + ApiResponse response = controller.position(member); + + QueueDto.PositionResponse data = response.data(); + assertThat(data.status()).isEqualTo("NOT_IN_QUEUE"); + assertThat(data.position()).isNull(); + assertThat(data.suggestedPollIntervalMs()).isNull(); + } + + // --- 동적 Polling 구간별 검증 --- + + @DisplayName("calculatePollInterval: 1~100 → 1000ms") + @Test + void calculatePollInterval_nearFront_returns1000() { + assertThat(QueueController.calculatePollInterval(1)).isEqualTo(1000L); + assertThat(QueueController.calculatePollInterval(100)).isEqualTo(1000L); + } + + @DisplayName("calculatePollInterval: 101~1000 → 3000ms") + @Test + void calculatePollInterval_middle_returns3000() { + assertThat(QueueController.calculatePollInterval(101)).isEqualTo(3000L); + assertThat(QueueController.calculatePollInterval(1000)).isEqualTo(3000L); + } + + @DisplayName("calculatePollInterval: 1001+ → 5000ms") + @Test + void calculatePollInterval_farBack_returns5000() { + assertThat(QueueController.calculatePollInterval(1001)).isEqualTo(5000L); + assertThat(QueueController.calculatePollInterval(48000)).isEqualTo(5000L); + } + + // --- 메트릭 검증 --- + + @DisplayName("enter: QUEUED 시 queue.enter.status(QUEUED) 카운터 증가") + @Test + void enter_queued_incrementsCounter() { + when(entryTokenRedisRepository.exists(1L)).thenReturn(false); + when(waitingQueueRedisRepository.add(1L)).thenReturn(true); + when(waitingQueueRedisRepository.getRank(1L)).thenReturn(0L); + + controller.enter(member); + + double count = meterRegistry.counter("queue.enter.status", "status", "QUEUED").count(); + assertThat(count).isEqualTo(1.0); + } + + // --- SSE stream 엔드포인트 검증 --- + + @DisplayName("stream: 토큰 존재 → admitted 이벤트 후 즉시 닫기") + @Test + void stream_admitted_returnsEmitterAndCompletes() { + when(entryTokenRedisRepository.exists(1L)).thenReturn(true); + + SseEmitter emitter = controller.stream(member); + + assertThat(emitter).isNotNull(); + verify(sseEmitterRegistry, never()).register(anyLong(), anyLong()); + } + + @DisplayName("stream: 큐에 없음 → not_in_queue 이벤트 후 즉시 닫기") + @Test + void stream_notInQueue_returnsEmitterAndCompletes() { + when(entryTokenRedisRepository.exists(1L)).thenReturn(false); + when(waitingQueueRedisRepository.getRank(1L)).thenReturn(null); + + SseEmitter emitter = controller.stream(member); + + assertThat(emitter).isNotNull(); + verify(sseEmitterRegistry, never()).register(anyLong(), anyLong()); + } + + @DisplayName("stream: 대기 중 → registry.register() 호출") + @Test + void stream_waiting_registersEmitter() { + when(entryTokenRedisRepository.exists(1L)).thenReturn(false); + when(waitingQueueRedisRepository.getRank(1L)).thenReturn(41L); + when(sseEmitterRegistry.register(1L, 42L)).thenReturn(new SseEmitter()); + + SseEmitter emitter = controller.stream(member); + + assertThat(emitter).isNotNull(); + verify(sseEmitterRegistry).register(1L, 42L); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/listener/CacheEvictionEventListenerTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/listener/CacheEvictionEventListenerTest.java new file mode 100644 index 0000000000..a51972402c --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/listener/CacheEvictionEventListenerTest.java @@ -0,0 +1,91 @@ +package com.loopers.interfaces.listener; + +import com.loopers.application.product.ProductCachePort; +import com.loopers.domain.event.LikeCreatedEvent; +import com.loopers.domain.event.LikeRemovedEvent; +import com.loopers.interfaces.api.product.ProductDto; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class CacheEvictionEventListenerTest { + + private CacheEvictionEventListener listener; + private SpyProductCachePort cachePort; + + @BeforeEach + void setUp() { + cachePort = new SpyProductCachePort(); + listener = new CacheEvictionEventListener(cachePort); + } + + @Nested + @DisplayName("LikeCreatedEvent 처리") + class HandleLikeCreated { + + @DisplayName("상품 상세 캐시와 목록 캐시가 무효화된다") + @Test + void evictsProductDetailAndList() { + listener.handleLikeCreated(new LikeCreatedEvent(100L, 1L)); + + assertThat(cachePort.evictedProductDetailIds).containsExactly(100L); + assertThat(cachePort.evictProductListCount).isEqualTo(1); + } + } + + @Nested + @DisplayName("LikeRemovedEvent 처리") + class HandleLikeRemoved { + + @DisplayName("상품 상세 캐시와 목록 캐시가 무효화된다") + @Test + void evictsProductDetailAndList() { + listener.handleLikeRemoved(new LikeRemovedEvent(200L, 1L)); + + assertThat(cachePort.evictedProductDetailIds).containsExactly(200L); + assertThat(cachePort.evictProductListCount).isEqualTo(1); + } + } + + @Nested + @DisplayName("캐시 무효화 실패") + class CacheEvictionFailure { + + @DisplayName("캐시 무효화 중 예외가 발생해도 best-effort로 처리된다") + @Test + void doesNotPropagateException() { + CacheEvictionEventListener failingListener = new CacheEvictionEventListener( + new FailingProductCachePort()); + + failingListener.handleLikeCreated(new LikeCreatedEvent(100L, 1L)); + failingListener.handleLikeRemoved(new LikeRemovedEvent(200L, 1L)); + } + } + + static class SpyProductCachePort implements ProductCachePort { + final List evictedProductDetailIds = new ArrayList<>(); + int evictProductListCount = 0; + + @Override public ProductDto.ProductResponse getProductDetail(Long productId) { return null; } + @Override public void putProductDetail(Long productId, ProductDto.ProductResponse response) {} + @Override public void evictProductDetail(Long productId) { evictedProductDetailIds.add(productId); } + @Override public ProductDto.PagedProductResponse getProductList(Long brandId, String sort, int page, int size) { return null; } + @Override public void putProductList(Long brandId, String sort, int page, int size, ProductDto.PagedProductResponse response) {} + @Override public void evictProductList() { evictProductListCount++; } + } + + static class FailingProductCachePort implements ProductCachePort { + @Override public ProductDto.ProductResponse getProductDetail(Long productId) { return null; } + @Override public void putProductDetail(Long productId, ProductDto.ProductResponse response) {} + @Override public void evictProductDetail(Long productId) { throw new RuntimeException("Redis down"); } + @Override public ProductDto.PagedProductResponse getProductList(Long brandId, String sort, int page, int size) { return null; } + @Override public void putProductList(Long brandId, String sort, int page, int size, ProductDto.PagedProductResponse response) {} + @Override public void evictProductList() { throw new RuntimeException("Redis down"); } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/listener/LikeCountEventListenerTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/listener/LikeCountEventListenerTest.java new file mode 100644 index 0000000000..83ed53246b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/listener/LikeCountEventListenerTest.java @@ -0,0 +1,105 @@ +package com.loopers.interfaces.listener; + +import com.loopers.domain.event.LikeCreatedEvent; +import com.loopers.domain.event.LikeRemovedEvent; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.vo.Price; +import com.loopers.domain.product.vo.Stock; +import com.loopers.fake.FakeProductRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.transaction.TransactionStatus; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class LikeCountEventListenerTest { + + private LikeCountEventListener listener; + private FakeProductRepository productRepository; + + @BeforeEach + void setUp() { + productRepository = new FakeProductRepository(); + + // 동기 실행 Executor + 스텁 TransactionManager + var txManager = mock(org.springframework.transaction.PlatformTransactionManager.class); + when(txManager.getTransaction(any())).thenReturn(mock(TransactionStatus.class)); + + listener = new LikeCountEventListener(productRepository, Runnable::run, txManager); + } + + @Nested + @DisplayName("LikeCreatedEvent 처리") + class HandleLikeCreated { + + @DisplayName("LikeCreatedEvent 수신 시 상품의 likeCount가 1 증가한다") + @Test + void incrementsLikeCount() { + Product product = productRepository.save( + new Product(1L, "에어맥스", new Price(150000), new Stock(10))); + + listener.handleLikeCreated(new LikeCreatedEvent(product.getId(), 1L)); + + assertThat(productRepository.findById(product.getId()).get().getLikeCount()).isEqualTo(1); + } + + @DisplayName("여러 번 수신하면 likeCount가 누적된다") + @Test + void accumulatesLikeCount() { + Product product = productRepository.save( + new Product(1L, "에어맥스", new Price(150000), new Stock(10))); + + listener.handleLikeCreated(new LikeCreatedEvent(product.getId(), 1L)); + listener.handleLikeCreated(new LikeCreatedEvent(product.getId(), 2L)); + listener.handleLikeCreated(new LikeCreatedEvent(product.getId(), 3L)); + + assertThat(productRepository.findById(product.getId()).get().getLikeCount()).isEqualTo(3); + } + + @DisplayName("존재하지 않는 상품이면 예외 없이 무시된다 (best-effort)") + @Test + void whenProductNotExists_doesNotThrow() { + listener.handleLikeCreated(new LikeCreatedEvent(999L, 1L)); + } + } + + @Nested + @DisplayName("LikeRemovedEvent 처리") + class HandleLikeRemoved { + + @DisplayName("LikeRemovedEvent 수신 시 상품의 likeCount가 1 감소한다") + @Test + void decrementsLikeCount() { + Product product = productRepository.save( + new Product(1L, "에어맥스", new Price(150000), new Stock(10))); + productRepository.incrementLikeCount(product.getId()); + productRepository.incrementLikeCount(product.getId()); + + listener.handleLikeRemoved(new LikeRemovedEvent(product.getId(), 1L)); + + assertThat(productRepository.findById(product.getId()).get().getLikeCount()).isEqualTo(1); + } + + @DisplayName("likeCount가 0이면 음수가 되지 않는다") + @Test + void doesNotGoBelowZero() { + Product product = productRepository.save( + new Product(1L, "에어맥스", new Price(150000), new Stock(10))); + + listener.handleLikeRemoved(new LikeRemovedEvent(product.getId(), 1L)); + + assertThat(productRepository.findById(product.getId()).get().getLikeCount()).isEqualTo(0); + } + + @DisplayName("존재하지 않는 상품이면 예외 없이 무시된다 (best-effort)") + @Test + void whenProductNotExists_doesNotThrow() { + listener.handleLikeRemoved(new LikeRemovedEvent(999L, 1L)); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/performance/ProductPerformanceTest.java b/apps/commerce-api/src/test/java/com/loopers/performance/ProductPerformanceTest.java new file mode 100644 index 0000000000..86ec34a80b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/performance/ProductPerformanceTest.java @@ -0,0 +1,152 @@ +package com.loopers.performance; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeRepository; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.vo.Price; +import com.loopers.domain.product.vo.Stock; +import com.loopers.utils.DatabaseCleanUp; +import jakarta.persistence.EntityManager; +import jakarta.persistence.Query; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; + +@SpringBootTest +@Disabled("성능 테스트는 수동 실행. 10만 건 시딩에 수 분 소요") +class ProductPerformanceTest { + + private static final Logger log = LoggerFactory.getLogger(ProductPerformanceTest.class); + + @Autowired + private ProductRepository productRepository; + + @Autowired + private BrandRepository brandRepository; + + @Autowired + private LikeRepository likeRepository; + + @Autowired + private EntityManager entityManager; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + private final Random random = new Random(42); + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("10만 건 데이터 시딩 + EXPLAIN 분석") + @Test + void seedAndAnalyze() { + // === 1. 데이터 시딩 === + log.info("=== 데이터 시딩 시작 ==="); + int brandCount = 100; + int productCount = 100_000; + int productPerBrand = productCount / brandCount; + + // 브랜드 100개 + List brands = new ArrayList<>(); + for (int i = 0; i < brandCount; i++) { + brands.add(brandRepository.save(new Brand("브랜드" + i, "설명" + i))); + } + log.info("브랜드 {} 개 생성 완료", brandCount); + + // 상품 10만 개 (브랜드당 ~1,000개) + List products = new ArrayList<>(); + for (int i = 0; i < productCount; i++) { + Brand brand = brands.get(i / productPerBrand); + int price = 1000 + random.nextInt(499_000); // 1,000 ~ 500,000 + Product product = productRepository.save( + new Product(brand.getId(), "상품" + i, new Price(price), new Stock(random.nextInt(100)))); + products.add(product); + + if ((i + 1) % 10_000 == 0) { + log.info("상품 {} 개 생성 완료", i + 1); + } + } + + // likeCount 설정 (멱법칙 분포 — 소수 상품이 높은 좋아요) + for (int i = 0; i < productCount; i++) { + int likes = (int) Math.round(Math.pow(random.nextDouble(), 3) * 10_000); + if (likes > 0) { + Product p = products.get(i); + for (int j = 0; j < likes && j < 50; j++) { // 실제 Like 레코드는 최대 50개만 + try { + likeRepository.save(new Like((long) (i * 100 + j + 1), p.getId())); + productRepository.incrementLikeCount(p.getId()); + } catch (Exception ignored) { + } + } + } + } + log.info("좋아요 데이터 생성 완료"); + + // === 2. EXPLAIN 분석 === + log.info("=== EXPLAIN 분석 시작 ==="); + + // 전체 상품 좋아요순 정렬 + analyzeQuery("전체 상품 + 좋아요순", + "EXPLAIN SELECT p.*, b.name FROM product p LEFT JOIN brand b ON b.id = p.brand_id " + + "WHERE p.deleted_at IS NULL AND (b.deleted_at IS NULL OR b.id IS NULL) " + + "ORDER BY p.like_count DESC, p.id DESC LIMIT 20"); + + // 브랜드 필터 + 좋아요순 + Long firstBrandId = brands.get(0).getId(); + analyzeQuery("브랜드 필터 + 좋아요순", + "EXPLAIN SELECT p.*, b.name FROM product p LEFT JOIN brand b ON b.id = p.brand_id " + + "WHERE p.brand_id = " + firstBrandId + " AND p.deleted_at IS NULL AND (b.deleted_at IS NULL OR b.id IS NULL) " + + "ORDER BY p.like_count DESC, p.id DESC LIMIT 20"); + + // 브랜드 필터 + 가격순 + analyzeQuery("브랜드 필터 + 가격순", + "EXPLAIN SELECT p.*, b.name FROM product p LEFT JOIN brand b ON b.id = p.brand_id " + + "WHERE p.brand_id = " + firstBrandId + " AND p.deleted_at IS NULL AND (b.deleted_at IS NULL OR b.id IS NULL) " + + "ORDER BY p.price ASC, p.id ASC LIMIT 20"); + + // Like countByProductId + Long firstProductId = products.get(0).getId(); + analyzeQuery("좋아요 카운트 (product_id 인덱스 활용)", + "EXPLAIN SELECT COUNT(*) FROM likes WHERE product_id = " + firstProductId); + + // AS-IS: GROUP BY 집계 + analyzeQuery("AS-IS: 전체 상품 GROUP BY 좋아요 집계", + "EXPLAIN SELECT l.product_id, COUNT(*) FROM likes l GROUP BY l.product_id"); + + log.info("=== EXPLAIN 분석 완료 ==="); + } + + private void analyzeQuery(String label, String explainSql) { + Query query = entityManager.createNativeQuery(explainSql); + List results = query.getResultList(); + + log.info("\n--- {} ---", label); + log.info("SQL: {}", explainSql.replace("EXPLAIN ", "")); + for (Object row : results) { + if (row instanceof Object[] cols) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < cols.length; i++) { + sb.append(cols[i] != null ? cols[i].toString() : "NULL"); + if (i < cols.length - 1) sb.append(" | "); + } + log.info(" {}", sb.toString()); + } + } + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/eventhandledcleanup/EventHandledCleanupJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/eventhandledcleanup/EventHandledCleanupJobConfig.java new file mode 100644 index 0000000000..a0a4b5b808 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/eventhandledcleanup/EventHandledCleanupJobConfig.java @@ -0,0 +1,49 @@ +package com.loopers.batch.job.eventhandledcleanup; + +import com.loopers.batch.job.eventhandledcleanup.step.EventHandledCleanupTasklet; +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.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = EventHandledCleanupJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Configuration +public class EventHandledCleanupJobConfig { + public static final String JOB_NAME = "eventHandledCleanupJob"; + private static final String STEP_NAME = "eventHandledCleanupStep"; + + private final JobRepository jobRepository; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final EventHandledCleanupTasklet eventHandledCleanupTasklet; + private final PlatformTransactionManager transactionManager; + + @Bean(JOB_NAME) + public Job eventHandledCleanupJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(eventHandledCleanupStep()) + .listener(jobListener) + .build(); + } + + @JobScope + @Bean(STEP_NAME) + public Step eventHandledCleanupStep() { + return new StepBuilder(STEP_NAME, jobRepository) + .tasklet(eventHandledCleanupTasklet, transactionManager) + .listener(stepMonitorListener) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/eventhandledcleanup/step/EventHandledCleanupTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/eventhandledcleanup/step/EventHandledCleanupTasklet.java new file mode 100644 index 0000000000..2296e782cc --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/eventhandledcleanup/step/EventHandledCleanupTasklet.java @@ -0,0 +1,28 @@ +package com.loopers.batch.job.eventhandledcleanup.step; + +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.stereotype.Component; + +@Slf4j +@RequiredArgsConstructor +@Component +public class EventHandledCleanupTasklet implements Tasklet { + + private final EntityManager entityManager; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + log.info("[EventHandledCleanup] event_handled 7일 이전 데이터 삭제 시작"); + int deleted = entityManager.createNativeQuery( + "DELETE FROM event_handled WHERE created_at < DATE_SUB(NOW(), INTERVAL 7 DAY)" + ).executeUpdate(); + log.info("[EventHandledCleanup] 삭제 완료 — 삭제 행 수: {}", deleted); + return RepeatStatus.FINISHED; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/metricsreconcile/MetricsReconcileJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/metricsreconcile/MetricsReconcileJobConfig.java new file mode 100644 index 0000000000..5d2af2c2e4 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/metricsreconcile/MetricsReconcileJobConfig.java @@ -0,0 +1,49 @@ +package com.loopers.batch.job.metricsreconcile; + +import com.loopers.batch.job.metricsreconcile.step.MetricsReconcileTasklet; +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.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = MetricsReconcileJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Configuration +public class MetricsReconcileJobConfig { + public static final String JOB_NAME = "metricsReconcileJob"; + private static final String STEP_NAME = "metricsReconcileStep"; + + private final JobRepository jobRepository; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final MetricsReconcileTasklet metricsReconcileTasklet; + private final PlatformTransactionManager transactionManager; + + @Bean(JOB_NAME) + public Job metricsReconcileJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(metricsReconcileStep()) + .listener(jobListener) + .build(); + } + + @JobScope + @Bean(STEP_NAME) + public Step metricsReconcileStep() { + return new StepBuilder(STEP_NAME, jobRepository) + .tasklet(metricsReconcileTasklet, transactionManager) + .listener(stepMonitorListener) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/metricsreconcile/step/MetricsReconcileTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/metricsreconcile/step/MetricsReconcileTasklet.java new file mode 100644 index 0000000000..2336f38999 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/metricsreconcile/step/MetricsReconcileTasklet.java @@ -0,0 +1,55 @@ +package com.loopers.batch.job.metricsreconcile.step; + +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.stereotype.Component; + +@Slf4j +@RequiredArgsConstructor +@Component +public class MetricsReconcileTasklet implements Tasklet { + + private final EntityManager entityManager; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + // 1단계: likes 테이블 기준 → product_metrics.like_count 보정 + log.info("[MetricsReconcile] 1단계: like_count 대사 시작"); + int likeCorrected = entityManager.createNativeQuery( + "INSERT INTO product_metrics (product_id, like_count, view_count, sales_count, sales_amount, updated_at) " + + "SELECT l.product_id, COUNT(*), 0, 0, 0, NOW(6) FROM likes l GROUP BY l.product_id " + + "ON DUPLICATE KEY UPDATE like_count = VALUES(like_count), updated_at = NOW(6)" + ).executeUpdate(); + log.info("[MetricsReconcile] 1단계 완료 — 대사 행 수: {}", likeCorrected); + + // 2단계: product_metrics.like_count → Product.like_count 비정규화 보정 + log.info("[MetricsReconcile] 2단계: Product.like_count 드리프트 보정 시작"); + int productCorrected = entityManager.createNativeQuery( + "UPDATE product p JOIN product_metrics pm ON p.id = pm.product_id " + + "SET p.like_count = pm.like_count " + + "WHERE p.like_count != pm.like_count AND p.deleted_at IS NULL" + ).executeUpdate(); + log.info("[MetricsReconcile] 2단계 완료 — 보정된 상품 수: {}", productCorrected); + + // 3단계: order_items 기준 → product_metrics.sales_count/sales_amount 보정 + log.info("[MetricsReconcile] 3단계: sales_count/sales_amount 대사 시작"); + int salesCorrected = entityManager.createNativeQuery( + "INSERT INTO product_metrics (product_id, like_count, view_count, sales_count, sales_amount, updated_at) " + + "SELECT oi.product_id, 0, 0, SUM(oi.quantity), SUM(oi.product_price * oi.quantity), NOW(6) " + + "FROM order_item oi JOIN orders o ON oi.order_id = o.id " + + "WHERE o.status != 'CANCELLED' AND o.deleted_at IS NULL " + + "GROUP BY oi.product_id " + + "ON DUPLICATE KEY UPDATE " + + "sales_count = VALUES(sales_count), sales_amount = VALUES(sales_amount), " + + "updated_at = NOW(6)" + ).executeUpdate(); + log.info("[MetricsReconcile] 3단계 완료 — 대사 행 수: {}", salesCorrected); + + return RepeatStatus.FINISHED; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/outboxcleanup/OutboxCleanupJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/outboxcleanup/OutboxCleanupJobConfig.java new file mode 100644 index 0000000000..fcdd0bb6e7 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/outboxcleanup/OutboxCleanupJobConfig.java @@ -0,0 +1,49 @@ +package com.loopers.batch.job.outboxcleanup; + +import com.loopers.batch.job.outboxcleanup.step.OutboxCleanupTasklet; +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.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = OutboxCleanupJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Configuration +public class OutboxCleanupJobConfig { + public static final String JOB_NAME = "outboxCleanupJob"; + private static final String STEP_NAME = "outboxCleanupStep"; + + private final JobRepository jobRepository; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final OutboxCleanupTasklet outboxCleanupTasklet; + private final PlatformTransactionManager transactionManager; + + @Bean(JOB_NAME) + public Job outboxCleanupJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(outboxCleanupStep()) + .listener(jobListener) + .build(); + } + + @JobScope + @Bean(STEP_NAME) + public Step outboxCleanupStep() { + return new StepBuilder(STEP_NAME, jobRepository) + .tasklet(outboxCleanupTasklet, transactionManager) + .listener(stepMonitorListener) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/outboxcleanup/step/OutboxCleanupTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/outboxcleanup/step/OutboxCleanupTasklet.java new file mode 100644 index 0000000000..d2e92e4f20 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/outboxcleanup/step/OutboxCleanupTasklet.java @@ -0,0 +1,28 @@ +package com.loopers.batch.job.outboxcleanup.step; + +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.stereotype.Component; + +@Slf4j +@RequiredArgsConstructor +@Component +public class OutboxCleanupTasklet implements Tasklet { + + private final EntityManager entityManager; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + log.info("[OutboxCleanup] event_outbox 1시간 이전 데이터 삭제 시작"); + int deleted = entityManager.createNativeQuery( + "DELETE FROM event_outbox WHERE created_at < DATE_SUB(NOW(), INTERVAL 1 HOUR)" + ).executeUpdate(); + log.info("[OutboxCleanup] 삭제 완료 — 삭제 행 수: {}", deleted); + return RepeatStatus.FINISHED; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/paymentrecovery/PaymentRecoveryJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/paymentrecovery/PaymentRecoveryJobConfig.java new file mode 100644 index 0000000000..f34ba01d0d --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/paymentrecovery/PaymentRecoveryJobConfig.java @@ -0,0 +1,49 @@ +package com.loopers.batch.job.paymentrecovery; + +import com.loopers.batch.job.paymentrecovery.step.PaymentRecoveryTasklet; +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.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = PaymentRecoveryJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Configuration +public class PaymentRecoveryJobConfig { + public static final String JOB_NAME = "paymentRecoveryJob"; + private static final String STEP_NAME = "paymentRecoveryStep"; + + private final JobRepository jobRepository; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final PaymentRecoveryTasklet paymentRecoveryTasklet; + private final PlatformTransactionManager transactionManager; + + @Bean(JOB_NAME) + public Job paymentRecoveryJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(paymentRecoveryStep()) + .listener(jobListener) + .build(); + } + + @JobScope + @Bean(STEP_NAME) + public Step paymentRecoveryStep() { + return new StepBuilder(STEP_NAME, jobRepository) + .tasklet(paymentRecoveryTasklet, transactionManager) + .listener(stepMonitorListener) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/paymentrecovery/step/PaymentRecoveryTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/paymentrecovery/step/PaymentRecoveryTasklet.java new file mode 100644 index 0000000000..894b2a27fd --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/paymentrecovery/step/PaymentRecoveryTasklet.java @@ -0,0 +1,152 @@ +package com.loopers.batch.job.paymentrecovery.step; + +import com.loopers.batch.job.paymentrecovery.PaymentRecoveryJobConfig; +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * 결제 복구 배치 — REQUESTED/PENDING/UNKNOWN 상태 결제건 복구. + * + *

복구 기준:

+ *
    + *
  • REQUESTED: 생성 후 1분 경과 → FAILED 처리
  • + *
  • PENDING: 생성 후 5분 초과 → FAILED 처리 + 재고 복원
  • + *
  • UNKNOWN: PG 상태 확인 불가 → FAILED 처리
  • + *
+ * + * @see 배치 복구 + */ +@Slf4j +@StepScope +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = PaymentRecoveryJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Component +public class PaymentRecoveryTasklet implements Tasklet { + + private final EntityManager entityManager; + + @Override + @SuppressWarnings("unchecked") + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + log.info("[PaymentRecovery] 배치 복구 시작"); + + int requestedCount = recoverRequested(); + int pendingCount = recoverPending(); + int unknownCount = recoverUnknown(); + + log.info("[PaymentRecovery] 배치 복구 완료: REQUESTED={}, PENDING={}, UNKNOWN={}", + requestedCount, pendingCount, unknownCount); + + return RepeatStatus.FINISHED; + } + + private int recoverRequested() { + List ids = entityManager.createNativeQuery( + "SELECT id FROM payments WHERE status = 'REQUESTED' " + + "AND created_at < NOW() - INTERVAL 1 MINUTE AND deleted_at IS NULL" + ).getResultList(); + + if (ids.isEmpty()) return 0; + + List targetIds = ids.stream().map(Number::longValue).toList(); + + entityManager.createNativeQuery( + "INSERT INTO payment_status_history (payment_id, from_status, to_status, reason, detail, created_at, updated_at) " + + "SELECT id, 'REQUESTED', 'FAILED', 'BATCH_RECOVERY', '배치 복구: PG 호출 누락 (1분 초과)', NOW(), NOW() " + + "FROM payments WHERE id IN :ids AND status = 'REQUESTED' AND deleted_at IS NULL" + ).setParameter("ids", targetIds).executeUpdate(); + + int count = entityManager.createNativeQuery( + "UPDATE payments SET status = 'FAILED', failure_reason = '배치 복구: PG 호출 누락 (1분 초과)' " + + "WHERE id IN :ids AND status = 'REQUESTED' AND deleted_at IS NULL" + ).setParameter("ids", targetIds).executeUpdate(); + + log.info("[PaymentRecovery] REQUESTED → FAILED: {}건", count); + return count; + } + + @SuppressWarnings("unchecked") + private int recoverPending() { + List ids = entityManager.createNativeQuery( + "SELECT id FROM payments WHERE status = 'PENDING' " + + "AND created_at < NOW() - INTERVAL 5 MINUTE AND deleted_at IS NULL" + ).getResultList(); + + if (ids.isEmpty()) return 0; + + List targetIds = ids.stream().map(Number::longValue).toList(); + + entityManager.createNativeQuery( + "INSERT INTO payment_status_history (payment_id, from_status, to_status, reason, detail, created_at, updated_at) " + + "SELECT id, 'PENDING', 'FAILED', 'BATCH_RECOVERY', '배치 복구: 콜백 미수신 (5분 초과)', NOW(), NOW() " + + "FROM payments WHERE id IN :ids AND status = 'PENDING' AND deleted_at IS NULL" + ).setParameter("ids", targetIds).executeUpdate(); + + int count = entityManager.createNativeQuery( + "UPDATE payments SET status = 'FAILED', failure_reason = '배치 복구: 콜백 미수신 (5분 초과)' " + + "WHERE id IN :ids AND status = 'PENDING' AND deleted_at IS NULL" + ).setParameter("ids", targetIds).executeUpdate(); + + log.info("[PaymentRecovery] PENDING(5분+) → FAILED: {}건", count); + + // FAILED 전환된 결제건의 재고 복원 + if (count > 0) { + restoreStockForFailedPayments(targetIds); + } + + return count; + } + + private int recoverUnknown() { + List ids = entityManager.createNativeQuery( + "SELECT id FROM payments WHERE status = 'UNKNOWN' " + + "AND created_at < NOW() - INTERVAL 10 MINUTE AND deleted_at IS NULL" + ).getResultList(); + + if (ids.isEmpty()) return 0; + + List targetIds = ids.stream().map(Number::longValue).toList(); + + entityManager.createNativeQuery( + "INSERT INTO payment_status_history (payment_id, from_status, to_status, reason, detail, created_at, updated_at) " + + "SELECT id, 'UNKNOWN', 'FAILED', 'BATCH_RECOVERY', '배치 복구: UNKNOWN 타임아웃', NOW(), NOW() " + + "FROM payments WHERE id IN :ids AND status = 'UNKNOWN' AND deleted_at IS NULL" + ).setParameter("ids", targetIds).executeUpdate(); + + int count = entityManager.createNativeQuery( + "UPDATE payments SET status = 'FAILED', failure_reason = '배치 복구: UNKNOWN 타임아웃' " + + "WHERE id IN :ids AND status = 'UNKNOWN' AND deleted_at IS NULL" + ).setParameter("ids", targetIds).executeUpdate(); + + log.info("[PaymentRecovery] UNKNOWN(10분+) → FAILED: {}건", count); + return count; + } + + @SuppressWarnings("unchecked") + private void restoreStockForFailedPayments(List paymentIds) { + List orderIds = entityManager.createNativeQuery( + "SELECT order_id FROM payments WHERE id IN :ids AND deleted_at IS NULL" + ).setParameter("ids", paymentIds).getResultList(); + + for (Number orderIdNum : orderIds) { + Long orderId = orderIdNum.longValue(); + int restored = entityManager.createNativeQuery( + "UPDATE product p INNER JOIN order_item oi ON p.id = oi.product_id " + + "INNER JOIN orders o ON oi.order_id = o.id " + + "SET p.stock_quantity = p.stock_quantity + oi.quantity " + + "WHERE o.id = :orderId AND p.deleted_at IS NULL" + ).setParameter("orderId", orderId).executeUpdate(); + log.info("[PaymentRecovery] 재고 복원: orderId={}, items={}", orderId, restored); + } + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankingcorrection/RankingCorrectionJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankingcorrection/RankingCorrectionJobConfig.java new file mode 100644 index 0000000000..e29d713e66 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankingcorrection/RankingCorrectionJobConfig.java @@ -0,0 +1,178 @@ +package com.loopers.batch.job.rankingcorrection; + +import com.loopers.domain.ranking.ScoreFormula; +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; +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.launch.support.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.ItemWriter; +import org.springframework.batch.item.database.JdbcCursorItemReader; +import org.springframework.batch.item.database.builder.JdbcCursorItemReaderBuilder; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.dao.DataAccessException; +import org.springframework.data.redis.core.RedisOperations; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.SessionCallback; +import org.springframework.transaction.PlatformTransactionManager; + +import javax.sql.DataSource; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** + * Lambda Architecture 배치 보정 잡. + * + *

DB 원장(product_metrics) 기준으로 Redis 랭킹(Hash + ZSET)을 덮어쓴다. + * 실시간 경로(Kafka → Redis)에서 누적된 드리프트를 1시간 주기로 보정.

+ * + * @see ScoreFormula + */ +@Slf4j +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = RankingCorrectionJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Configuration +public class RankingCorrectionJobConfig { + + public static final String JOB_NAME = "rankingCorrectionJob"; + private static final String STEP_NAME = "rankingCorrectionStep"; + private static final int CHUNK_SIZE = 1_000; + + // RankingScoreUpdater와 동일한 Semantic Definition + private static final String RANKING_ZSET_PREFIX = "ranking:all:"; + private static final String RANKING_METRICS_PREFIX = "ranking:metrics:"; + private static final long RANKING_ZSET_TTL_SECONDS = 691_200L; // 8일 + private static final long RANKING_HASH_TTL_SECONDS = 172_800L; // 2일 + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.BASIC_ISO_DATE; + + private final JobRepository jobRepository; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final PlatformTransactionManager transactionManager; + private final DataSource dataSource; + private final RankingCorrectionProperties properties; + + @Bean(JOB_NAME) + public Job rankingCorrectionJob( + @Qualifier(STEP_NAME) Step rankingCorrectionStep + ) { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(rankingCorrectionStep) + .listener(jobListener) + .build(); + } + + @JobScope + @Bean(STEP_NAME) + public Step rankingCorrectionStep( + JdbcCursorItemReader metricsReader, + ItemWriter redisRankingWriter + ) { + return new StepBuilder(STEP_NAME, jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(metricsReader) + .writer(redisRankingWriter) + .listener(stepMonitorListener) + .build(); + } + + @StepScope + @Bean + public JdbcCursorItemReader metricsReader() { + return new JdbcCursorItemReaderBuilder() + .name("metricsReader") + .dataSource(dataSource) + .sql("SELECT pm.product_id, pm.view_count, " + + "(pm.like_count - pm.unlike_count) AS net_like, " + + "pm.sales_count, " + + "(pm.sales_amount - pm.cancel_amount_by_event_date) AS net_sales_amount, " + + "p.category_id " + + "FROM product_metrics pm " + + "JOIN product p ON pm.product_id = p.id " + + "WHERE pm.metric_date = CURDATE() AND p.deleted_at IS NULL") + .rowMapper((rs, rowNum) -> new ProductMetricsRow( + rs.getLong("product_id"), + rs.getLong("view_count"), + rs.getLong("net_like"), + rs.getLong("sales_count"), + rs.getLong("net_sales_amount"), + rs.getObject("category_id") != null ? rs.getLong("category_id") : null + )) + .build(); + } + + @SuppressWarnings("unchecked") + @Bean + public ItemWriter redisRankingWriter( + @Qualifier("redisTemplateMaster") RedisTemplate writeTemplate + ) { + return chunk -> { + LocalDate today = LocalDate.now(KST); + String dateStr = today.format(DATE_FORMATTER); + String zsetKey = RANKING_ZSET_PREFIX + dateStr; + long nowEpochSeconds = Instant.now().getEpochSecond(); + + writeTemplate.executePipelined(new SessionCallback<>() { + @Override + public Object execute(RedisOperations operations) throws DataAccessException { + for (ProductMetricsRow row : chunk) { + String hashKey = RANKING_METRICS_PREFIX + dateStr + ":" + row.productId; + + int categoryPriority = resolveCategoryPriority(row.categoryId); + double score = calculateScore(row, categoryPriority, nowEpochSeconds); + + operations.delete(hashKey); + operations.opsForHash().putAll(hashKey, Map.of( + "viewCount", String.valueOf(row.viewCount), + "likeCount", String.valueOf(row.netLike), + "salesCount", String.valueOf(row.salesCount), + "salesAmount", String.valueOf(row.netSalesAmount), + "lastEventAt", String.valueOf(nowEpochSeconds) + )); + operations.opsForZSet().add(zsetKey, String.valueOf(row.productId), score); + operations.expire(hashKey, RANKING_HASH_TTL_SECONDS, TimeUnit.SECONDS); + } + operations.expire(zsetKey, RANKING_ZSET_TTL_SECONDS, TimeUnit.SECONDS); + return null; + } + }); + + log.info("[RankingCorrection] chunk 처리 완료: products={}", chunk.size()); + }; + } + + double calculateScore(ProductMetricsRow row) { + int categoryPriority = resolveCategoryPriority(row.categoryId); + return calculateScore(row, categoryPriority, Instant.now().getEpochSecond()); + } + + double calculateScore(ProductMetricsRow row, int categoryPriority, long lastEventEpochSeconds) { + return ScoreFormula.calculate(row.viewCount, row.netLike, row.netSalesAmount, + categoryPriority, lastEventEpochSeconds, properties.weights()); + } + + private int resolveCategoryPriority(Long categoryId) { + if (categoryId == null) return properties.defaultCategoryPriority(); + return properties.categoryPriority() + .getOrDefault(categoryId, properties.defaultCategoryPriority()); + } + + record ProductMetricsRow(long productId, long viewCount, long netLike, + long salesCount, long netSalesAmount, Long categoryId) {} +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankingcorrection/RankingCorrectionProperties.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankingcorrection/RankingCorrectionProperties.java new file mode 100644 index 0000000000..923ee0c3a6 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankingcorrection/RankingCorrectionProperties.java @@ -0,0 +1,17 @@ +package com.loopers.batch.job.rankingcorrection; + +import com.loopers.domain.ranking.ScoreFormula; +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.Map; + +@ConfigurationProperties(prefix = "ranking") +public record RankingCorrectionProperties( + ScoreFormula.Weights weights, + Map categoryPriority, + int defaultCategoryPriority +) { + public RankingCorrectionProperties { + if (categoryPriority == null) categoryPriority = Map.of(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankingmv/ProductRankingMvJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankingmv/ProductRankingMvJobConfig.java new file mode 100644 index 0000000000..88ba82ea12 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankingmv/ProductRankingMvJobConfig.java @@ -0,0 +1,320 @@ +package com.loopers.batch.job.rankingmv; + +import com.loopers.batch.job.rankingmv.step.CleanupTasklet; +import com.loopers.batch.job.rankingcorrection.RankingCorrectionProperties; +import com.loopers.domain.ranking.ScoreFormula; +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; +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.launch.support.RunIdIncrementer; +import org.springframework.batch.core.partition.support.Partitioner; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.ExecutionContext; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.batch.item.database.JdbcBatchItemWriter; +import org.springframework.batch.item.database.JdbcCursorItemReader; +import org.springframework.batch.item.database.builder.JdbcBatchItemWriterBuilder; +import org.springframework.batch.item.database.builder.JdbcCursorItemReaderBuilder; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.task.SimpleAsyncTaskExecutor; +import org.springframework.dao.DeadlockLoserDataAccessException; +import org.springframework.dao.TransientDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.retry.backoff.ExponentialBackOffPolicy; +import org.springframework.transaction.PlatformTransactionManager; + +import javax.sql.DataSource; +import java.time.Instant; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * MV 기반 주간/월간 랭킹 집계 Job. + * + *

product_metrics를 product_id 범위로 분할하여 병렬 집계(스테이징)한 후, + * mergeStep에서 Global TOP 100을 추출하여 MV 테이블에 적재한다.

+ * + *

Score 계산은 SQL이 아닌 Java ItemProcessor에서 {@link ScoreFormula}를 사용하여 + * 모든 Score 경로(streamer, batch correction, MV)와 공식을 통일한다.

+ */ +@Slf4j +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = ProductRankingMvJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Configuration +public class ProductRankingMvJobConfig { + + public static final String JOB_NAME = "productRankingMvJob"; + private static final int CHUNK_SIZE = 1_000; + @Value("${ranking.mv.grid-size:4}") + private int gridSize; + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.BASIC_ISO_DATE; + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final DataSource dataSource; + private final JdbcTemplate jdbcTemplate; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final CleanupTasklet cleanupTasklet; + private final RankingCorrectionProperties properties; + + @Bean(JOB_NAME) + public Job productRankingMvJob( + @Qualifier("cleanupStep") Step cleanupStep, + @Qualifier("partitionedAggregateStep") Step partitionedAggregateStep, + @Qualifier("mergeStep") Step mergeStep + ) { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(cleanupStep).on("FAILED").end() + .from(cleanupStep).on("*").to(partitionedAggregateStep) + .next(mergeStep) + .end() + .listener(jobListener) + .build(); + } + + @JobScope + @Bean("cleanupStep") + public Step cleanupStep() { + return new StepBuilder("cleanupStep", jobRepository) + .tasklet(cleanupTasklet, transactionManager) + .allowStartIfComplete(true) + .listener(stepMonitorListener) + .build(); + } + + @JobScope + @Bean("partitionedAggregateStep") + public Step partitionedAggregateStep( + @Value("#{jobParameters['targetDate']}") String targetDate, + @Value("#{jobParameters['scope']}") String scope + ) { + return new StepBuilder("partitionedAggregateStep", jobRepository) + .partitioner("workerStep", createPartitioner(targetDate, scope)) + .step(workerStep()) + .gridSize(gridSize) + .taskExecutor(new SimpleAsyncTaskExecutor("mv-worker-")) + .build(); + } + + private Partitioner createPartitioner(String targetDate, String scope) { + return gridSize -> { + int days = "weekly".equals(scope) ? 6 : 29; + LocalDate endDate = LocalDate.parse(targetDate, DATE_FORMATTER); + LocalDate startDate = endDate.minusDays(days); + + List productIds = jdbcTemplate.queryForList( + "SELECT DISTINCT product_id FROM product_metrics " + + "WHERE metric_date BETWEEN ? AND ? ORDER BY product_id", + Long.class, startDate, endDate); + + if (productIds.isEmpty()) { + log.warn("[Partitioner] 데이터 없음: {} ~ {}", startDate, endDate); + Map empty = new HashMap<>(); + ExecutionContext ctx = new ExecutionContext(); + ctx.putLong("minProductId", 0); + ctx.putLong("maxProductId", 0); + empty.put("partition0", ctx); + return empty; + } + + int totalProducts = productIds.size(); + int partitionSize = totalProducts / gridSize + (totalProducts % gridSize == 0 ? 0 : 1); + Map partitions = new HashMap<>(); + + for (int i = 0; i < gridSize; i++) { + int fromIndex = i * partitionSize; + if (fromIndex >= totalProducts) break; + int toIndex = Math.min((i + 1) * partitionSize, totalProducts); + + ExecutionContext ctx = new ExecutionContext(); + long partMin = productIds.get(fromIndex); + long partMax = productIds.get(toIndex - 1); + ctx.putLong("minProductId", partMin); + ctx.putLong("maxProductId", partMax); + partitions.put("partition" + i, ctx); + + log.info("[Partitioner] partition{}: productId {}~{} ({}건)", i, partMin, partMax, toIndex - fromIndex); + } + return partitions; + }; + } + + @Bean + public Step workerStep() { + ExponentialBackOffPolicy backOff = new ExponentialBackOffPolicy(); + backOff.setInitialInterval(100); + backOff.setMultiplier(2.0); + backOff.setMaxInterval(1000); + + return new StepBuilder("workerStep", jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(stagingReader(null, null, null, null)) + .processor(scoringProcessor()) + .writer(stagingWriter(null)) + .faultTolerant() + .retry(DeadlockLoserDataAccessException.class) + .retry(TransientDataAccessException.class) + .retryLimit(3) + .backOffPolicy(backOff) + .listener(stepMonitorListener) + .build(); + } + + @StepScope + @Bean + public JdbcCursorItemReader stagingReader( + @Value("#{jobParameters['targetDate']}") String targetDate, + @Value("#{jobParameters['scope']}") String scope, + @Value("#{stepExecutionContext['minProductId']}") Long minProductId, + @Value("#{stepExecutionContext['maxProductId']}") Long maxProductId + ) { + int days = "weekly".equals(scope) ? 6 : 29; + LocalDate endDate = LocalDate.parse(targetDate, DATE_FORMATTER); + LocalDate startDate = endDate.minusDays(days); + + String sql = """ + SELECT + pm.product_id, + SUM(pm.view_count) AS total_view_count, + SUM(pm.like_count - pm.unlike_count) AS total_net_like_count, + SUM(pm.sales_count) AS total_sales_count, + SUM(pm.sales_amount - pm.cancel_amount_by_event_date) AS total_net_sales_amount, + p.category_id + FROM product_metrics pm + JOIN product p ON pm.product_id = p.id + WHERE pm.metric_date BETWEEN ? AND ? + AND pm.product_id BETWEEN ? AND ? + AND p.deleted_at IS NULL + GROUP BY pm.product_id, p.category_id + """; + + return new JdbcCursorItemReaderBuilder() + .name("stagingReader") + .dataSource(dataSource) + .sql(sql) + .preparedStatementSetter(ps -> { + ps.setObject(1, startDate); + ps.setObject(2, endDate); + ps.setLong(3, minProductId); + ps.setLong(4, maxProductId); + }) + .rowMapper((rs, rowNum) -> new AggregatedMetricsRow( + rs.getLong("product_id"), + rs.getLong("total_view_count"), + rs.getLong("total_net_like_count"), + rs.getLong("total_sales_count"), + rs.getLong("total_net_sales_amount"), + rs.getObject("category_id") != null ? rs.getLong("category_id") : null + )) + .build(); + } + + @StepScope + @Bean + public ItemProcessor scoringProcessor() { + long nowEpochSeconds = Instant.now().getEpochSecond(); + return row -> { + int categoryPriority = resolveCategoryPriority(row.categoryId()); + double score = ScoreFormula.calculate( + row.viewCount(), row.likeCount(), row.salesAmount(), + categoryPriority, nowEpochSeconds, properties.weights()); + return new ScoredProductRow(row.productId(), score, + row.viewCount(), row.likeCount(), row.salesCount(), row.salesAmount()); + }; + } + + @StepScope + @Bean + public JdbcBatchItemWriter stagingWriter( + @Value("#{jobParameters['targetDate']}") String targetDate + ) { + return new JdbcBatchItemWriterBuilder() + .dataSource(dataSource) + .sql(""" + INSERT INTO mv_product_rank_staging + (product_id, score, view_count, like_count, sales_count, sales_amount, period_key) + VALUES (?, ?, ?, ?, ?, ?, ?) + """) + .itemPreparedStatementSetter((item, ps) -> { + ps.setLong(1, item.productId()); + ps.setDouble(2, item.score()); + ps.setLong(3, item.viewCount()); + ps.setLong(4, item.likeCount()); + ps.setLong(5, item.salesCount()); + ps.setLong(6, item.salesAmount()); + ps.setString(7, targetDate); + }) + .assertUpdates(false) + .build(); + } + + @JobScope + @Bean("mergeStep") + public Step mergeStep( + @Value("#{jobParameters['targetDate']}") String targetDate, + @Value("#{jobParameters['scope']}") String scope + ) { + return new StepBuilder("mergeStep", jobRepository) + .tasklet((contribution, chunkContext) -> { + String mvTable = switch (scope) { + case "weekly" -> "mv_product_rank_weekly"; + case "monthly" -> "mv_product_rank_monthly"; + default -> throw new IllegalArgumentException("Invalid scope: " + scope); + }; + + int inserted = jdbcTemplate.update(""" + INSERT INTO %s + (product_id, ranking, score, view_count, like_count, + sales_count, sales_amount, period_key, created_at) + SELECT + product_id, + ROW_NUMBER() OVER (ORDER BY score DESC) AS ranking, + score, view_count, like_count, sales_count, sales_amount, + ?, NOW() + FROM mv_product_rank_staging + WHERE period_key = ? + ORDER BY score DESC + LIMIT 100 + """.formatted(mvTable), targetDate, targetDate); + + log.info("[Merge] {} 적재 완료: period_key={}, rows={}", mvTable, targetDate, inserted); + return RepeatStatus.FINISHED; + }, transactionManager) + .listener(stepMonitorListener) + .build(); + } + + private int resolveCategoryPriority(Long categoryId) { + if (categoryId == null) return properties.defaultCategoryPriority(); + return properties.categoryPriority() + .getOrDefault(categoryId, properties.defaultCategoryPriority()); + } + + record AggregatedMetricsRow( + long productId, long viewCount, long likeCount, + long salesCount, long salesAmount, Long categoryId + ) {} + + record ScoredProductRow( + long productId, double score, + long viewCount, long likeCount, + long salesCount, long salesAmount + ) {} +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankingmv/step/CleanupTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankingmv/step/CleanupTasklet.java new file mode 100644 index 0000000000..139beef6b6 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankingmv/step/CleanupTasklet.java @@ -0,0 +1,68 @@ +package com.loopers.batch.job.rankingmv.step; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +@Slf4j +@StepScope +@RequiredArgsConstructor +@Component +public class CleanupTasklet implements Tasklet { + + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.BASIC_ISO_DATE; + private static final int RETENTION_DAYS = 3; + + private final JdbcTemplate jdbcTemplate; + + @Value("#{jobParameters['targetDate']}") + private String targetDate; + + @Value("#{jobParameters['scope']}") + private String scope; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + String mvTable = resolveMvTable(scope); + + int deletedMv = jdbcTemplate.update( + "DELETE FROM " + mvTable + " WHERE period_key = ?", targetDate); + log.info("[Cleanup] {} 삭제: period_key={}, rows={}", mvTable, targetDate, deletedMv); + + int deletedStaging = jdbcTemplate.update( + "DELETE FROM mv_product_rank_staging WHERE period_key = ?", targetDate); + log.info("[Cleanup] staging 삭제: period_key={}, rows={}", targetDate, deletedStaging); + + LocalDate cutoffDate = LocalDate.parse(targetDate, DATE_FORMATTER).minusDays(RETENTION_DAYS); + String cutoffKey = cutoffDate.format(DATE_FORMATTER); + + int purgedMv = jdbcTemplate.update( + "DELETE FROM " + mvTable + " WHERE period_key < ?", cutoffKey); + int purgedStaging = jdbcTemplate.update( + "DELETE FROM mv_product_rank_staging WHERE period_key < ?", cutoffKey); + + if (purgedMv + purgedStaging > 0) { + log.info("[Cleanup] {}일 이전 데이터 정리: mv={}, staging={}", RETENTION_DAYS, purgedMv, purgedStaging); + } + + return RepeatStatus.FINISHED; + } + + private String resolveMvTable(String scope) { + return switch (scope) { + case "weekly" -> "mv_product_rank_weekly"; + case "monthly" -> "mv_product_rank_monthly"; + default -> throw new IllegalArgumentException("Invalid scope: " + scope); + }; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/reconciliation/PaymentCouponReconciliationJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/reconciliation/PaymentCouponReconciliationJobConfig.java new file mode 100644 index 0000000000..16e03ac55e --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/reconciliation/PaymentCouponReconciliationJobConfig.java @@ -0,0 +1,49 @@ +package com.loopers.batch.job.reconciliation; + +import com.loopers.batch.job.reconciliation.step.PaymentCouponReconciliationTasklet; +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.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = PaymentCouponReconciliationJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Configuration +public class PaymentCouponReconciliationJobConfig { + public static final String JOB_NAME = "paymentCouponReconciliationJob"; + private static final String STEP_NAME = "paymentCouponReconciliationStep"; + + private final JobRepository jobRepository; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final PaymentCouponReconciliationTasklet tasklet; + private final PlatformTransactionManager transactionManager; + + @Bean(JOB_NAME) + public Job paymentCouponReconciliationJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(paymentCouponReconciliationStep()) + .listener(jobListener) + .build(); + } + + @JobScope + @Bean(STEP_NAME) + public Step paymentCouponReconciliationStep() { + return new StepBuilder(STEP_NAME, jobRepository) + .tasklet(tasklet, transactionManager) + .listener(stepMonitorListener) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/reconciliation/PaymentOrderReconciliationJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/reconciliation/PaymentOrderReconciliationJobConfig.java new file mode 100644 index 0000000000..feee236f91 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/reconciliation/PaymentOrderReconciliationJobConfig.java @@ -0,0 +1,49 @@ +package com.loopers.batch.job.reconciliation; + +import com.loopers.batch.job.reconciliation.step.PaymentOrderReconciliationTasklet; +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.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = PaymentOrderReconciliationJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Configuration +public class PaymentOrderReconciliationJobConfig { + public static final String JOB_NAME = "paymentOrderReconciliationJob"; + private static final String STEP_NAME = "paymentOrderReconciliationStep"; + + private final JobRepository jobRepository; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final PaymentOrderReconciliationTasklet tasklet; + private final PlatformTransactionManager transactionManager; + + @Bean(JOB_NAME) + public Job paymentOrderReconciliationJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(paymentOrderReconciliationStep()) + .listener(jobListener) + .build(); + } + + @JobScope + @Bean(STEP_NAME) + public Step paymentOrderReconciliationStep() { + return new StepBuilder(STEP_NAME, jobRepository) + .tasklet(tasklet, transactionManager) + .listener(stepMonitorListener) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/reconciliation/PgPaymentReconciliationJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/reconciliation/PgPaymentReconciliationJobConfig.java new file mode 100644 index 0000000000..a9d8136e4a --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/reconciliation/PgPaymentReconciliationJobConfig.java @@ -0,0 +1,49 @@ +package com.loopers.batch.job.reconciliation; + +import com.loopers.batch.job.reconciliation.step.PgPaymentReconciliationTasklet; +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.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = PgPaymentReconciliationJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Configuration +public class PgPaymentReconciliationJobConfig { + public static final String JOB_NAME = "pgPaymentReconciliationJob"; + private static final String STEP_NAME = "pgPaymentReconciliationStep"; + + private final JobRepository jobRepository; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final PgPaymentReconciliationTasklet tasklet; + private final PlatformTransactionManager transactionManager; + + @Bean(JOB_NAME) + public Job pgPaymentReconciliationJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(pgPaymentReconciliationStep()) + .listener(jobListener) + .build(); + } + + @JobScope + @Bean(STEP_NAME) + public Step pgPaymentReconciliationStep() { + return new StepBuilder(STEP_NAME, jobRepository) + .tasklet(tasklet, transactionManager) + .listener(stepMonitorListener) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/reconciliation/step/PaymentCouponReconciliationTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/reconciliation/step/PaymentCouponReconciliationTasklet.java new file mode 100644 index 0000000000..59a883b97e --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/reconciliation/step/PaymentCouponReconciliationTasklet.java @@ -0,0 +1,81 @@ +package com.loopers.batch.job.reconciliation.step; + +import com.loopers.batch.job.reconciliation.PaymentCouponReconciliationJobConfig; +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * [R3] Payment ↔ Coupon 대사 — 쿠폰 복원 누락 감지 + 자동 복원. + * + * @see [R3] Payment-Coupon 대사 + */ +@Slf4j +@StepScope +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = PaymentCouponReconciliationJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Component +public class PaymentCouponReconciliationTasklet implements Tasklet { + + private final EntityManager entityManager; + + @Override + @SuppressWarnings("unchecked") + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + log.info("[R3-PaymentCoupon] 대사 시작"); + + // Payment FAILED인데 CouponIssue가 아직 USED인 경우 = 쿠폰 복원 누락 + List mismatches = entityManager.createNativeQuery( + "SELECT p.id, o.coupon_issue_id, ci.status " + + "FROM payments p " + + "JOIN orders o ON p.order_id = o.id " + + "JOIN coupon_issue ci ON o.coupon_issue_id = ci.id " + + "WHERE p.status IN ('FAILED', 'CANCELLED') " + + "AND ci.status = 'USED' " + + "AND p.deleted_at IS NULL AND o.deleted_at IS NULL" + ).getResultList(); + + if (mismatches.isEmpty()) { + log.info("[R3-PaymentCoupon] 대사 완료: 불일치 0건"); + return RepeatStatus.FINISHED; + } + + log.warn("[R3-PaymentCoupon] 쿠폰 복원 누락 감지: {}건", mismatches.size()); + + for (Object[] row : mismatches) { + Long paymentId = ((Number) row[0]).longValue(); + Long couponIssueId = ((Number) row[1]).longValue(); + + // 쿠폰 자동 복원 + int updated = entityManager.createNativeQuery( + "UPDATE coupon_issue SET status = 'AVAILABLE', used_order_id = NULL " + + "WHERE id = :couponIssueId AND status = 'USED'" + ).setParameter("couponIssueId", couponIssueId).executeUpdate(); + + if (updated > 0) { + log.info("[R3-PaymentCoupon] 쿠폰 자동 복원: couponIssueId={}, paymentId={}", + couponIssueId, paymentId); + } + + // 불일치 기록 + entityManager.createNativeQuery( + "INSERT INTO reconciliation_mismatch (type, payment_id, our_status, external_status, " + + "detected_at, resolution, created_at, updated_at, note) " + + "VALUES ('PAYMENT_COUPON', :paymentId, 'FAILED', 'USED', NOW(), 'AUTO_FIXED', NOW(), NOW(), " + + "'쿠폰 복원 누락 자동 보정')" + ).setParameter("paymentId", paymentId).executeUpdate(); + } + + log.info("[R3-PaymentCoupon] 대사 완료: {}건 자동 복원", mismatches.size()); + return RepeatStatus.FINISHED; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/reconciliation/step/PaymentOrderReconciliationTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/reconciliation/step/PaymentOrderReconciliationTasklet.java new file mode 100644 index 0000000000..758baecd9c --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/reconciliation/step/PaymentOrderReconciliationTasklet.java @@ -0,0 +1,79 @@ +package com.loopers.batch.job.reconciliation.step; + +import com.loopers.batch.job.reconciliation.PaymentOrderReconciliationJobConfig; +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * [R2] Payment ↔ Order 대사 — 같은 DB JOIN 쿼리로 불일치 감지. + * + * @see [R2] Payment-Order 대사 + */ +@Slf4j +@StepScope +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = PaymentOrderReconciliationJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Component +public class PaymentOrderReconciliationTasklet implements Tasklet { + + private final EntityManager entityManager; + + @Override + @SuppressWarnings("unchecked") + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + log.info("[R2-PaymentOrder] 대사 시작"); + + // Payment PAID인데 Order가 PAID가 아닌 경우 + List mismatches = entityManager.createNativeQuery( + "SELECT p.id, p.status, o.status " + + "FROM payments p JOIN orders o ON p.order_id = o.id " + + "WHERE ((p.status = 'PAID' AND o.status != 'PAID') " + + " OR (p.status = 'FAILED' AND o.status NOT IN ('CANCELLED', 'CREATED'))) " + + "AND p.deleted_at IS NULL AND o.deleted_at IS NULL" + ).getResultList(); + + if (mismatches.isEmpty()) { + log.info("[R2-PaymentOrder] 대사 완료: 불일치 0건"); + return RepeatStatus.FINISHED; + } + + log.warn("[R2-PaymentOrder] 불일치 감지: {}건", mismatches.size()); + + for (Object[] row : mismatches) { + Long paymentId = ((Number) row[0]).longValue(); + String paymentStatus = (String) row[1]; + String orderStatus = (String) row[2]; + + entityManager.createNativeQuery( + "INSERT INTO reconciliation_mismatch (type, payment_id, our_status, external_status, " + + "detected_at, created_at, updated_at) " + + "VALUES ('PAYMENT_ORDER', :paymentId, :paymentStatus, :orderStatus, NOW(), NOW(), NOW())" + ).setParameter("paymentId", paymentId) + .setParameter("paymentStatus", paymentStatus) + .setParameter("orderStatus", orderStatus) + .executeUpdate(); + + // Payment PAID + Order CREATED → Order를 PAID로 자동 보정 + if ("PAID".equals(paymentStatus) && "CREATED".equals(orderStatus)) { + entityManager.createNativeQuery( + "UPDATE orders SET status = 'PAID', updated_at = NOW() WHERE id = " + + "(SELECT order_id FROM payments WHERE id = :paymentId)" + ).setParameter("paymentId", paymentId).executeUpdate(); + log.info("[R2-PaymentOrder] 자동 보정: paymentId={} → Order PAID", paymentId); + } + } + + log.info("[R2-PaymentOrder] 대사 완료: 불일치 {}건 기록", mismatches.size()); + return RepeatStatus.FINISHED; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/reconciliation/step/PgPaymentReconciliationTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/reconciliation/step/PgPaymentReconciliationTasklet.java new file mode 100644 index 0000000000..a80b440b0f --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/reconciliation/step/PgPaymentReconciliationTasklet.java @@ -0,0 +1,54 @@ +package com.loopers.batch.job.reconciliation.step; + +import com.loopers.batch.job.reconciliation.PgPaymentReconciliationJobConfig; +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * [R1] PG ↔ Payment 대사 — PG API 호출이 필요하므로 실제로는 PG 연동 구현 후 완성. + * 현재는 Payment 상태 기준 reconciliation_mismatch 준비만 수행. + * + * @see [R1] PG 대사 + */ +@Slf4j +@StepScope +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = PgPaymentReconciliationJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Component +public class PgPaymentReconciliationTasklet implements Tasklet { + + private final EntityManager entityManager; + + @Override + @SuppressWarnings("unchecked") + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + log.info("[R1-PgPayment] 대사 시작: PAID/FAILED 결제건 PG 상태 대조"); + + // PG API 대조 대상: 최근 24시간 내 PAID/FAILED 건 + List payments = entityManager.createNativeQuery( + "SELECT id, status, transaction_key, pg_provider FROM payments " + + "WHERE status IN ('PAID', 'FAILED') " + + "AND updated_at > NOW() - INTERVAL 24 HOUR " + + "AND deleted_at IS NULL" + ).getResultList(); + + log.info("[R1-PgPayment] 대사 대상: {}건", payments.size()); + + // 실제 PG API 호출은 Phase 6 (Multi-PG) 이후에 완성 + // 현재는 대사 인프라만 준비 (불일치 감지 → reconciliation_mismatch INSERT) + // TODO: PG API 연동 후 각 건의 PG 상태와 대조 + + log.info("[R1-PgPayment] 대사 완료 (PG API 연동 대기)"); + return RepeatStatus.FINISHED; + } +} diff --git a/apps/commerce-batch/src/main/resources/application.yml b/apps/commerce-batch/src/main/resources/application.yml index 9aa0d760a6..0b7f8a0537 100644 --- a/apps/commerce-batch/src/main/resources/application.yml +++ b/apps/commerce-batch/src/main/resources/application.yml @@ -17,6 +17,14 @@ spring: jdbc: initialize-schema: never +ranking: + weights: + view: 0.1 + like: 0.2 + order: 0.7 + category-priority: {} + default-category-priority: 0 + management: health: defaults: diff --git a/apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java b/apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java index c5e3bc7a35..71a9071861 100644 --- a/apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java +++ b/apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java @@ -2,8 +2,10 @@ 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/batch/job/rankingcorrection/RankingCorrectionScoreTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/rankingcorrection/RankingCorrectionScoreTest.java new file mode 100644 index 0000000000..8c4f6c04c9 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/rankingcorrection/RankingCorrectionScoreTest.java @@ -0,0 +1,122 @@ +package com.loopers.batch.job.rankingcorrection; + +import com.loopers.batch.job.rankingcorrection.RankingCorrectionJobConfig.ProductMetricsRow; +import com.loopers.domain.ranking.ScoreFormula; +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.transaction.PlatformTransactionManager; + +import javax.sql.DataSource; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +@ExtendWith(MockitoExtension.class) +class RankingCorrectionScoreTest { + + @Mock private JobRepository jobRepository; + @Mock private JobListener jobListener; + @Mock private StepMonitorListener stepMonitorListener; + @Mock private PlatformTransactionManager transactionManager; + @Mock private DataSource dataSource; + + private RankingCorrectionJobConfig config; + private static final ScoreFormula.Weights WEIGHTS = new ScoreFormula.Weights(0.1, 0.2, 0.7); + private static final long FIXED_EPOCH = 1_712_700_000L; + + @BeforeEach + void setUp() { + RankingCorrectionProperties properties = new RankingCorrectionProperties( + WEIGHTS, Map.of(100L, 2), 0 + ); + config = new RankingCorrectionJobConfig( + jobRepository, jobListener, stepMonitorListener, transactionManager, + dataSource, properties + ); + } + + @Nested + @DisplayName("ScoreFormula 위임 검증") + class ScoreFormulaDelegation { + + @Test + @DisplayName("calculateScore()가 ScoreFormula.calculate()와 동일한 결과를 반환") + void delegatesToScoreFormula() { + ProductMetricsRow row = new ProductMetricsRow(1L, 100, 50, 10, 80000, null); + + double configScore = config.calculateScore(row, 0, FIXED_EPOCH); + double formulaScore = ScoreFormula.calculate(100, 50, 80000, 0, FIXED_EPOCH, WEIGHTS); + + assertThat(configScore).isEqualTo(formulaScore); + } + + @Test + @DisplayName("categoryPriority가 ScoreFormula에 올바르게 전달됨") + void categoryPriorityPassedCorrectly() { + ProductMetricsRow row = new ProductMetricsRow(1L, 0, 0, 0, 0, 100L); + + double configScore = config.calculateScore(row, 2, FIXED_EPOCH); + double formulaScore = ScoreFormula.calculate(0, 0, 0, 2, FIXED_EPOCH, WEIGHTS); + + assertThat(configScore).isEqualTo(formulaScore); + } + + @Test + @DisplayName("음수 메트릭도 ScoreFormula와 동일하게 처리") + void negativeMetrics_matchesFormula() { + ProductMetricsRow row = new ProductMetricsRow(1L, 0, -10, 0, -50000, null); + + double configScore = config.calculateScore(row, 0, FIXED_EPOCH); + double formulaScore = ScoreFormula.calculate(0, -10, -50000, 0, FIXED_EPOCH, WEIGHTS); + + assertThat(configScore).isEqualTo(formulaScore); + } + } + + @Nested + @DisplayName("resolveCategoryPriority") + class ResolveCategoryPriority { + + @Test + @DisplayName("categoryPriority 매핑이 있으면 해당 값 사용") + void withMapping_usesMappedValue() { + ProductMetricsRow row = new ProductMetricsRow(1L, 100, 50, 10, 80000, 100L); + + double score = config.calculateScore(row, 2, FIXED_EPOCH); + double expected = ScoreFormula.calculate(100, 50, 80000, 2, FIXED_EPOCH, WEIGHTS); + + assertThat(score).isEqualTo(expected); + } + + @Test + @DisplayName("categoryPriority 매핑이 없으면 defaultCategoryPriority 사용") + void withoutMapping_usesDefault() { + ProductMetricsRow row = new ProductMetricsRow(1L, 100, 50, 10, 80000, 999L); + + double score = config.calculateScore(row, 0, FIXED_EPOCH); + double expected = ScoreFormula.calculate(100, 50, 80000, 0, FIXED_EPOCH, WEIGHTS); + + assertThat(score).isEqualTo(expected); + } + + @Test + @DisplayName("categoryId가 null이면 defaultCategoryPriority 사용") + void nullCategoryId_usesDefault() { + ProductMetricsRow row = new ProductMetricsRow(1L, 0, 0, 0, 0, null); + + double scoreNoPriority = config.calculateScore(row, 0, FIXED_EPOCH); + double scoreWithPriority = config.calculateScore(row, 1, FIXED_EPOCH); + + assertThat(scoreWithPriority - scoreNoPriority).isCloseTo(1.0, within(1e-10)); + } + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/payment/CouponReconciliationJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/job/payment/CouponReconciliationJobE2ETest.java new file mode 100644 index 0000000000..b76ef02b85 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/job/payment/CouponReconciliationJobE2ETest.java @@ -0,0 +1,61 @@ +package com.loopers.job.payment; + +import com.loopers.batch.job.reconciliation.PaymentCouponReconciliationJobConfig; +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; +import org.springframework.test.context.jdbc.Sql; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * B7-3: 쿠폰 대사 배치 E2E 테스트. + * + *

결제 실패했는데 쿠폰 복원이 누락된 건을 감지하여 자동 복원.

+ * + *

실행 조건: Docker (MySQL + Redis Testcontainers) 필요.

+ * + * @see [R3] 쿠폰 대사 + */ +@SpringBootTest +@SpringBatchTest +@TestPropertySource(properties = "spring.batch.job.name=" + PaymentCouponReconciliationJobConfig.JOB_NAME) +@Sql(scripts = "/schema-batch-test.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_CLASS) +class CouponReconciliationJobE2ETest { + + @Autowired + private JobLauncherTestUtils jobLauncherTestUtils; + + @Autowired + @Qualifier(PaymentCouponReconciliationJobConfig.JOB_NAME) + private Job job; + + @BeforeEach + void setUp() { + jobLauncherTestUtils.setJob(job); + } + + @DisplayName("B7-3: 쿠폰 대사 배치 → 정상 실행 (데이터 없음 시에도 성공)") + @Test + void couponReconciliationJob_success() throws Exception { + var jobParameters = new JobParametersBuilder() + .addLocalDate("requestDate", LocalDate.now()) + .toJobParameters(); + var jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + assertThat(jobExecution).isNotNull(); + assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.COMPLETED.getExitCode()); + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/payment/PaymentRecoveryJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/job/payment/PaymentRecoveryJobE2ETest.java new file mode 100644 index 0000000000..cc339c6c79 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/job/payment/PaymentRecoveryJobE2ETest.java @@ -0,0 +1,63 @@ +package com.loopers.job.payment; + +import com.loopers.batch.job.paymentrecovery.PaymentRecoveryJobConfig; +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; +import org.springframework.test.context.jdbc.Sql; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * B7-1: 결제 복구 배치 E2E 테스트. + * + *

REQUESTED(1분+), PENDING(5분+), UNKNOWN(10분+) 결제건을 FAILED로 전이.

+ * + *

실행 조건: Docker (MySQL + Redis Testcontainers) 필요.

+ * + * @see 결제 복구 배치 + */ +@SpringBootTest +@SpringBatchTest +@TestPropertySource(properties = "spring.batch.job.name=" + PaymentRecoveryJobConfig.JOB_NAME) +@Sql(scripts = "/schema-batch-test.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_CLASS) +class PaymentRecoveryJobE2ETest { + + @Autowired + private JobLauncherTestUtils jobLauncherTestUtils; + + @Autowired + @Qualifier(PaymentRecoveryJobConfig.JOB_NAME) + private Job job; + + @BeforeEach + void setUp() { + jobLauncherTestUtils.setJob(job); + } + + @DisplayName("B7-1: 결제 복구 배치 → 정상 실행") + @Test + void paymentRecoveryJob_success() throws Exception { + // TODO: DB에 REQUESTED(1분+), PENDING(5분+), UNKNOWN(10분+) 결제 데이터 삽입 + + var jobParameters = new JobParametersBuilder() + .addLocalDate("requestDate", LocalDate.now()) + .toJobParameters(); + var jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + assertThat(jobExecution).isNotNull(); + assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.COMPLETED.getExitCode()); + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/rankingmv/ProductRankingMvJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/job/rankingmv/ProductRankingMvJobE2ETest.java new file mode 100644 index 0000000000..8507040b7f --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/job/rankingmv/ProductRankingMvJobE2ETest.java @@ -0,0 +1,750 @@ +package com.loopers.job.rankingmv; + +import com.loopers.batch.job.rankingmv.ProductRankingMvJobConfig; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.BatchStatus; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.test.JobLauncherTestUtils; +import org.springframework.batch.test.context.SpringBatchTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.jdbc.Sql; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import org.springframework.jdbc.core.BatchPreparedStatementSetter; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@SpringBatchTest +@TestPropertySource(properties = "spring.batch.job.name=" + ProductRankingMvJobConfig.JOB_NAME) +@Sql(scripts = "/schema-batch-test.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_CLASS) +class ProductRankingMvJobE2ETest { + + @Autowired + private JobLauncherTestUtils jobLauncherTestUtils; + + @Autowired + @Qualifier(ProductRankingMvJobConfig.JOB_NAME) + private Job job; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private ProductRankingMvJobConfig jobConfig; + + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.BASIC_ISO_DATE; + private static final String TARGET_DATE = "20260416"; + private static final int SEED_BATCH_SIZE = 1_000; + + @BeforeEach + void setUp() { + jobLauncherTestUtils.setJob(job); + jdbcTemplate.execute("TRUNCATE TABLE mv_product_rank_weekly"); + jdbcTemplate.execute("TRUNCATE TABLE mv_product_rank_monthly"); + jdbcTemplate.execute("TRUNCATE TABLE mv_product_rank_staging"); + jdbcTemplate.execute("TRUNCATE TABLE product_metrics"); + jdbcTemplate.execute("TRUNCATE TABLE product"); + } + + private void seedProducts(int count) { + for (int i = 1; i <= count; i++) { + jdbcTemplate.update( + "INSERT INTO product (id, brand_id, name, price, stock_quantity, like_count, created_at, updated_at) " + + "VALUES (?, 1, ?, ?, 1000, 0, NOW(), NOW())", + i, "상품" + i, i * 1000); + } + } + + private void seedMetrics(int productCount, int days, String endDateStr) { + LocalDate endDate = LocalDate.parse(endDateStr, DATE_FORMATTER); + for (int d = 0; d < days; d++) { + LocalDate date = endDate.minusDays(d); + for (int p = 1; p <= productCount; p++) { + jdbcTemplate.update( + "INSERT INTO product_metrics " + + "(product_id, metric_date, view_count, like_count, unlike_count, " + + "sales_count, sales_amount, cancel_count_by_event_date, cancel_amount_by_event_date, " + + "cancel_count_by_order_date, cancel_amount_by_order_date) " + + "VALUES (?, ?, ?, ?, 0, ?, ?, 0, 0, 0, 0)", + p, date, p * 100, p * 10, p * 5, p * 50000L); + } + } + } + + private BatchStatus runJob(String scope) throws Exception { + var params = new JobParametersBuilder() + .addString("targetDate", TARGET_DATE) + .addString("scope", scope) + .addLong("run.id", System.currentTimeMillis()) + .toJobParameters(); + return jobLauncherTestUtils.launchJob(params).getStatus(); + } + + // ── Bulk Seed (대규모 테스트용) ────────────────────────────────────── + + private void seedProductsBulk(int count) { + String sql = "INSERT INTO product (id, brand_id, name, price, stock_quantity, like_count, created_at, updated_at) " + + "VALUES (?, 1, ?, ?, 1000, 0, NOW(), NOW())"; + for (int batchStart = 0; batchStart < count; batchStart += SEED_BATCH_SIZE) { + int start = batchStart; + int end = Math.min(batchStart + SEED_BATCH_SIZE, count); + jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() { + @Override + public void setValues(PreparedStatement ps, int i) throws SQLException { + int p = start + i + 1; + ps.setLong(1, p); + ps.setString(2, "product-" + p); + ps.setInt(3, 10_000 + (p % 90_000)); + } + @Override + public int getBatchSize() { return end - start; } + }); + } + } + + /** + * 6가지 트렌드 패턴으로 메트릭 벌크 시드. + *
+     *   A) 급상승    (1~5,000  = 5%)  : 최근 7일 폭발, 이전 미미
+     *   B) 장기강자  (5,001~15,000 = 10%): 30일 꾸준히 높음
+     *   C) 하락추세  (15,001~20,000 = 5%): 이전 높음 → 최근 급락
+     *   D) 바이럴    (20,001~22,000 = 2%): 오늘만 폭발
+     *   E) 취소높음  (22,001~25,000 = 3%): 매출 높지만 취소 50~70%
+     *   F) 일반      (25,001~100,000 = 75%): 보통 수준
+     * 
+ */ + private void seedMetricsBulkWithTrends(int productCount, int days, String endDateStr) { + LocalDate endDate = LocalDate.parse(endDateStr, DATE_FORMATTER); + String sql = "INSERT INTO product_metrics " + + "(product_id, metric_date, view_count, like_count, unlike_count, " + + "sales_count, sales_amount, cancel_count_by_event_date, cancel_amount_by_event_date, " + + "cancel_count_by_order_date, cancel_amount_by_order_date) " + + "VALUES (?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?)"; + + for (int d = 0; d < days; d++) { + LocalDate date = endDate.minusDays(d); + boolean isRecent = d < 7; + boolean isToday = d == 0; + + for (int batchStart = 0; batchStart < productCount; batchStart += SEED_BATCH_SIZE) { + int start = batchStart; + int end = Math.min(batchStart + SEED_BATCH_SIZE, productCount); + final LocalDate metricDate = date; + final boolean recent = isRecent; + final boolean today = isToday; + + jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() { + @Override + public void setValues(PreparedStatement ps, int i) throws SQLException { + int p = start + i + 1; + int views, likes, salesCount; + long salesAmount; + long cancelAmount = 0; + int cancelCount = 0; + + if (p <= 5_000) { + // A) 급상승 + if (recent) { + views = 4_000 + p; + likes = 500 + p / 10; + salesAmount = 2_000_000L + p * 200L; + } else { + views = 50; + likes = 5; + salesAmount = 20_000L; + } + } else if (p <= 15_000) { + // B) 장기강자 + int pos = p - 5_000; + views = 1_000 + pos / 5; + likes = 100 + pos / 50; + salesAmount = 1_500_000L + pos * 50L; + } else if (p <= 20_000) { + // C) 하락추세 + int pos = p - 15_000; + if (recent) { + views = 100; + likes = 10; + salesAmount = 50_000L; + } else { + views = 2_000 + pos / 3; + likes = 200 + pos / 25; + salesAmount = 1_500_000L + pos * 100L; + } + } else if (p <= 22_000) { + // D) 바이럴 + if (today) { + views = 15_000; + likes = 2_000; + salesAmount = 5_000_000L; + } else { + views = 100; + likes = 10; + salesAmount = 50_000L; + } + } else if (p <= 25_000) { + // E) 취소높음 + views = 1_500; + likes = 150; + salesAmount = 2_000_000L; + int cancelRate = 50 + ((p - 22_001) % 3) * 10; + cancelAmount = salesAmount * cancelRate / 100; + cancelCount = (int) (cancelAmount / 100_000); + } else { + // F) 일반 + int pos = p - 25_000; + views = 200 + pos / 30; + likes = 20 + pos / 300; + salesAmount = 100_000L + pos * 3L; + } + + salesCount = (int) (salesAmount / 50_000) + 1; + + ps.setLong(1, p); + ps.setObject(2, metricDate); + ps.setInt(3, views); + ps.setInt(4, likes); + ps.setInt(5, salesCount); + ps.setLong(6, salesAmount); + ps.setInt(7, cancelCount); + ps.setLong(8, cancelAmount); + ps.setInt(9, cancelCount); + ps.setLong(10, cancelAmount); + } + + @Override + public int getBatchSize() { return end - start; } + }); + } + } + } + + // ── 주간 랭킹 Job ────────────────────────────────────────────────── + + @Test + @DisplayName("주간 정상 — 시드 데이터 기반 주간 TOP 100 적재") + void weeklySuccess() throws Exception { + seedProducts(150); + seedMetrics(150, 7, TARGET_DATE); + + BatchStatus status = runJob("weekly"); + + assertThat(status).isEqualTo(BatchStatus.COMPLETED); + + int mvCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM mv_product_rank_weekly WHERE period_key = ?", + Integer.class, TARGET_DATE); + assertThat(mvCount).isEqualTo(100); + + Long topProductId = jdbcTemplate.queryForObject( + "SELECT product_id FROM mv_product_rank_weekly WHERE period_key = ? AND ranking = 1", + Long.class, TARGET_DATE); + assertThat(topProductId).isEqualTo(150L); + + int stagingCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM mv_product_rank_staging WHERE period_key = ?", + Integer.class, TARGET_DATE); + assertThat(stagingCount).isEqualTo(150); + } + + @Test + @DisplayName("주간 — 상품이 100개 미만이면 있는 만큼만 적재") + void weeklyLessThan100Products() throws Exception { + seedProducts(30); + seedMetrics(30, 7, TARGET_DATE); + + BatchStatus status = runJob("weekly"); + + assertThat(status).isEqualTo(BatchStatus.COMPLETED); + + int mvCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM mv_product_rank_weekly WHERE period_key = ?", + Integer.class, TARGET_DATE); + assertThat(mvCount).isEqualTo(30); + } + + // ── 월간 랭킹 Job ────────────────────────────────────────────────── + + @Test + @DisplayName("월간 정상 — 30일 데이터 집계") + void monthlySuccess() throws Exception { + seedProducts(50); + seedMetrics(50, 30, TARGET_DATE); + + BatchStatus status = runJob("monthly"); + + assertThat(status).isEqualTo(BatchStatus.COMPLETED); + + int mvCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM mv_product_rank_monthly WHERE period_key = ?", + Integer.class, TARGET_DATE); + assertThat(mvCount).isEqualTo(50); + } + + // ── 멱등성 ────────────────────────────────────────────────────────── + + @Test + @DisplayName("멱등성 — 같은 파라미터로 2회 실행해도 결과 동일") + void idempotentDoubleExecution() throws Exception { + seedProducts(50); + seedMetrics(50, 7, TARGET_DATE); + + runJob("weekly"); + BatchStatus secondStatus = runJob("weekly"); + + assertThat(secondStatus).isEqualTo(BatchStatus.COMPLETED); + + int mvCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM mv_product_rank_weekly WHERE period_key = ?", + Integer.class, TARGET_DATE); + assertThat(mvCount).isEqualTo(50); + } + + // ── 엣지 케이스 ───────────────────────────────────────────────────── + + @Test + @DisplayName("엣지 — 데이터 없는 날짜로 실행하면 빈 MV") + void noDataProducesEmptyMv() throws Exception { + seedProducts(10); + + BatchStatus status = runJob("weekly"); + + assertThat(status).isEqualTo(BatchStatus.COMPLETED); + + int mvCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM mv_product_rank_weekly WHERE period_key = ?", + Integer.class, TARGET_DATE); + assertThat(mvCount).isEqualTo(0); + } + + @Test + @DisplayName("엣지 — 7일 미만 데이터면 있는 만큼만 집계") + void partialDataAggregated() throws Exception { + seedProducts(20); + seedMetrics(20, 3, TARGET_DATE); + + BatchStatus status = runJob("weekly"); + + assertThat(status).isEqualTo(BatchStatus.COMPLETED); + + int mvCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM mv_product_rank_weekly WHERE period_key = ?", + Integer.class, TARGET_DATE); + assertThat(mvCount).isEqualTo(20); + } + + @Test + @DisplayName("시각화 — 일간/주간/월간 TOP 랭킹 결과 출력") + void printRankingResults() throws Exception { + // 20개 상품 시드 + String[] names = { + "나이키 에어맥스 97", "아디다스 울트라부스트", "뉴발란스 993", "아식스 젤카야노", + "푸마 스웨이드", "리복 클래식", "컨버스 척테일러", "반스 올드스쿨", + "호카 본디 8", "살로몬 XT-6", "노스페이스 눕시", "파타고니아 다운재킷", + "아크테릭스 베타 LT", "스톤아일랜드 오버셔츠", "메종키츠네 폭스티", + "아미 하트로고 맨투맨", "톰브라운 카디건", "르메르 크로와상 백", + "메종마르지엘라 타비슈즈", "보테가베네타 카세트백" + }; + for (int i = 1; i <= 20; i++) { + jdbcTemplate.update( + "INSERT INTO product (id, brand_id, name, price, stock_quantity, like_count, created_at, updated_at) " + + "VALUES (?, 1, ?, ?, 1000, 0, NOW(), NOW())", + i, names[i - 1], i * 15000); + } + + // ── 30일치 메트릭 시드 (상품별 트렌드가 다르게) ── + // 상품 유형: + // A) 최근 급상승 (상품 1,2,3): 최근 7일 폭발, 이전 23일은 미미 + // B) 장기 강자 (상품 17,18,19,20): 30일 내내 꾸준히 높음 + // C) 하락 추세 (상품 14,15): 이전 23일은 높았으나 최근 7일 급락 + // D) 오늘 바이럴 (상품 8): 오늘 하루만 폭발 + // E) 일반 (나머지): 보통 수준으로 꾸준 + + LocalDate endDate = LocalDate.parse(TARGET_DATE, DATE_FORMATTER); + for (int d = 0; d < 30; d++) { + LocalDate date = endDate.minusDays(d); + boolean isRecent = d < 7; // 최근 7일 + boolean isToday = d == 0; // 오늘 + + for (int p = 1; p <= 20; p++) { + int views; int likes; long salesAmount; int salesCount; + long cancelAmount = 0; int cancelCount = 0; + + if (p <= 3) { + // A) 최근 급상승: 최근 7일은 매우 높고 이전 23일은 낮음 + if (isRecent) { + views = 5000 + p * 500; likes = 600 + p * 80; + salesAmount = 3000000L + p * 500000L; + } else { + views = 100 + p * 10; likes = 10 + p; + salesAmount = 50000L + p * 10000L; + } + } else if (p >= 17) { + // B) 장기 강자: 30일 내내 꾸준히 높음 + views = 1200 + (p - 16) * 300; likes = 150 + (p - 16) * 40; + salesAmount = 1800000L + (p - 16) * 400000L; + // 상품 19: 취소율 50% + if (p == 19) { cancelAmount = salesAmount / 2; cancelCount = 3; } + } else if (p == 14 || p == 15) { + // C) 하락 추세: 이전에는 높았으나 최근 급락 + if (isRecent) { + views = 200 + (p - 13) * 50; likes = 20 + (p - 13) * 5; + salesAmount = 100000L + (p - 13) * 30000L; + } else { + views = 3000 + (p - 13) * 800; likes = 400 + (p - 13) * 100; + salesAmount = 2500000L + (p - 13) * 600000L; + } + } else if (p == 8) { + // D) 오늘 바이럴: 오늘만 폭발 + if (isToday) { + views = 15000; likes = 2000; salesAmount = 5000000L; + } else { + views = 200; likes = 20; salesAmount = 80000L; + } + } else { + // E) 일반: 보통 수준 + views = 300 + p * 40; likes = 30 + p * 5; + salesAmount = 200000L + p * 80000L; + } + + salesCount = (int) (salesAmount / 50000) + 1; + jdbcTemplate.update( + "INSERT INTO product_metrics " + + "(product_id, metric_date, view_count, like_count, unlike_count, " + + "sales_count, sales_amount, cancel_count_by_event_date, cancel_amount_by_event_date, " + + "cancel_count_by_order_date, cancel_amount_by_order_date) " + + "VALUES (?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?)", + p, date, views, likes, salesCount, salesAmount, cancelCount, cancelAmount, cancelCount, cancelAmount); + } + } + + // ── Job 실행 ── + BatchStatus weeklyStatus = runJob("weekly"); + assertThat(weeklyStatus).isEqualTo(BatchStatus.COMPLETED); + BatchStatus monthlyStatus = runJob("monthly"); + assertThat(monthlyStatus).isEqualTo(BatchStatus.COMPLETED); + + // ── 공통 출력 헬퍼 ── + String header = String.format(" %-4s │ %-6s │ %-26s │ %10s │ %8s │ %8s │ %12s │ %8s", + "순위", "상품ID", "상품명", "Score", "조회수", "좋아요", "순매출액", "판매수"); + String divider = "───────┼────────┼────────────────────────────┼────────────┼──────────┼──────────┼──────────────┼──────────"; + String border = "═══════════════════════════════════════════════════════════════════════════════════════════════════════"; + + // ── 일간 랭킹 ── + System.out.println("\n" + border); + System.out.println(" [일간 랭킹 TOP 20] date=2026-04-16 (당일 1일 집계 — 운영 시 Redis Speed Layer)"); + System.out.println(border); + System.out.println(header); + System.out.println(divider); + + var dailyRows = jdbcTemplate.queryForList(""" + SELECT + ROW_NUMBER() OVER (ORDER BY + (0.1 * LOG10(GREATEST(pm.view_count, 0) + 1) / 7.0 + + 0.2 * LOG10(GREATEST(pm.like_count - pm.unlike_count, 0) + 1) / 7.0 + + 0.7 * LOG10(GREATEST(pm.sales_amount - pm.cancel_amount_by_event_date, 0) + 1) / 7.0) + DESC) AS ranking, + pm.product_id, p.name, + (0.1 * LOG10(GREATEST(pm.view_count, 0) + 1) / 7.0 + + 0.2 * LOG10(GREATEST(pm.like_count - pm.unlike_count, 0) + 1) / 7.0 + + 0.7 * LOG10(GREATEST(pm.sales_amount - pm.cancel_amount_by_event_date, 0) + 1) / 7.0) AS score, + pm.view_count, (pm.like_count - pm.unlike_count) AS like_count, + (pm.sales_amount - pm.cancel_amount_by_event_date) AS sales_amount, pm.sales_count + FROM product_metrics pm JOIN product p ON pm.product_id = p.id + WHERE pm.metric_date = '2026-04-16' + ORDER BY score DESC LIMIT 20 + """); + for (var row : dailyRows) { + System.out.printf(" %4d │ %6d │ %-26s │ %10.4f │ %,8d │ %,8d │ %,12d │ %,8d%n", + row.get("ranking"), row.get("product_id"), row.get("name"), + ((Number) row.get("score")).doubleValue(), ((Number) row.get("view_count")).longValue(), + ((Number) row.get("like_count")).longValue(), ((Number) row.get("sales_amount")).longValue(), + ((Number) row.get("sales_count")).longValue()); + } + System.out.println(border); + + // ── 주간 랭킹 ── + System.out.println("\n" + border); + System.out.println(" [주간 랭킹 TOP 20] period_key=" + TARGET_DATE + " (최근 7일 집계)"); + System.out.println(border); + System.out.println(header); + System.out.println(divider); + var weeklyRows = jdbcTemplate.queryForList( + "SELECT w.ranking, w.product_id, p.name, w.score, w.view_count, w.like_count, w.sales_amount, w.sales_count " + + "FROM mv_product_rank_weekly w JOIN product p ON w.product_id = p.id " + + "WHERE w.period_key = ? ORDER BY w.ranking", TARGET_DATE); + for (var row : weeklyRows) { + System.out.printf(" %4d │ %6d │ %-26s │ %10.4f │ %,8d │ %,8d │ %,12d │ %,8d%n", + row.get("ranking"), row.get("product_id"), row.get("name"), + ((Number) row.get("score")).doubleValue(), ((Number) row.get("view_count")).longValue(), + ((Number) row.get("like_count")).longValue(), ((Number) row.get("sales_amount")).longValue(), + ((Number) row.get("sales_count")).longValue()); + } + System.out.println(border); + + // ── 월간 랭킹 ── + System.out.println("\n" + border); + System.out.println(" [월간 랭킹 TOP 20] period_key=" + TARGET_DATE + " (최근 30일 집계)"); + System.out.println(border); + System.out.println(header); + System.out.println(divider); + var monthlyRows = jdbcTemplate.queryForList( + "SELECT m.ranking, m.product_id, p.name, m.score, m.view_count, m.like_count, m.sales_amount, m.sales_count " + + "FROM mv_product_rank_monthly m JOIN product p ON m.product_id = p.id " + + "WHERE m.period_key = ? ORDER BY m.ranking", TARGET_DATE); + for (var row : monthlyRows) { + System.out.printf(" %4d │ %6d │ %-26s │ %10.4f │ %,8d │ %,8d │ %,12d │ %,8d%n", + row.get("ranking"), row.get("product_id"), row.get("name"), + ((Number) row.get("score")).doubleValue(), ((Number) row.get("view_count")).longValue(), + ((Number) row.get("like_count")).longValue(), ((Number) row.get("sales_amount")).longValue(), + ((Number) row.get("sales_count")).longValue()); + } + System.out.println(border); + + // ── 일간 vs 주간 vs 월간 순위 비교 ── + System.out.println(); + System.out.println(" [순위 비교] 일간 vs 주간 vs 월간 — 집계 기간에 따른 순위 변동"); + System.out.printf(" %-6s │ %-26s │ %5s │ %5s │ %5s │ %-8s │ %s%n", + "상품ID", "상품명", "일간", "주간", "월간", "주간변동", "유형"); + System.out.println("─────────┼────────────────────────────┼───────┼───────┼───────┼──────────┼──────────────"); + + var compareRows = jdbcTemplate.queryForList(""" + SELECT d.product_id, p.name, d.ranking AS daily_rank, + COALESCE(w.ranking, 0) AS weekly_rank, + COALESCE(mo.ranking, 0) AS monthly_rank + FROM ( + SELECT product_id, + ROW_NUMBER() OVER (ORDER BY + (0.1 * LOG10(GREATEST(view_count, 0) + 1) / 7.0 + + 0.2 * LOG10(GREATEST(like_count - unlike_count, 0) + 1) / 7.0 + + 0.7 * LOG10(GREATEST(sales_amount - cancel_amount_by_event_date, 0) + 1) / 7.0) + DESC) AS ranking + FROM product_metrics WHERE metric_date = '2026-04-16' + ) d + JOIN product p ON d.product_id = p.id + LEFT JOIN mv_product_rank_weekly w ON d.product_id = w.product_id AND w.period_key = ? + LEFT JOIN mv_product_rank_monthly mo ON d.product_id = mo.product_id AND mo.period_key = ? + ORDER BY d.ranking + """, TARGET_DATE, TARGET_DATE); + + for (var row : compareRows) { + int daily = ((Number) row.get("daily_rank")).intValue(); + int weekly = ((Number) row.get("weekly_rank")).intValue(); + int monthly = ((Number) row.get("monthly_rank")).intValue(); + int wDiff = daily - weekly; + String wArrow = weekly == 0 ? " —" : wDiff == 0 ? " —" + : wDiff < 0 ? String.format(" +%d ▲", -wDiff) : String.format(" -%d ▼", wDiff); + + String type = ""; + int pid = ((Number) row.get("product_id")).intValue(); + if (pid <= 3) type = "급상승"; + else if (pid >= 17) type = "장기강자"; + else if (pid == 14 || pid == 15) type = "하락추세"; + else if (pid == 8) type = "오늘바이럴"; + + System.out.printf(" %6d │ %-26s │ %4d │ %4d │ %4d │ %8s │ %s%n", + row.get("product_id"), row.get("name"), daily, weekly, monthly, wArrow, type); + } + System.out.println(); + } + + // ── 대규모 성능 테스트 ────────────────────────────────────────────── + + @Test + @DisplayName("대규모 — 10만 상품 × 30일 메트릭, 4 Partition 병렬 집계") + void largeScalePartitionedBatchTest() throws Exception { + int productCount = 100_000; + int metricDays = 30; + + // ── 시드 ── + long t0 = System.currentTimeMillis(); + seedProductsBulk(productCount); + long productSeedMs = System.currentTimeMillis() - t0; + + t0 = System.currentTimeMillis(); + seedMetricsBulkWithTrends(productCount, metricDays, TARGET_DATE); + long metricSeedMs = System.currentTimeMillis() - t0; + + int metricRows = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM product_metrics", Integer.class); + System.out.printf("%n[시드 완료] 상품 %,d건 (%,dms) / 메트릭 %,d건 (%,dms)%n", + productCount, productSeedMs, metricRows, metricSeedMs); + + // ── Weekly ── + t0 = System.currentTimeMillis(); + BatchStatus weeklyStatus = runJob("weekly"); + long weeklyMs = System.currentTimeMillis() - t0; + + assertThat(weeklyStatus).isEqualTo(BatchStatus.COMPLETED); + + int weeklyMvCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM mv_product_rank_weekly WHERE period_key = ?", + Integer.class, TARGET_DATE); + assertThat(weeklyMvCount).isEqualTo(100); + + Long weeklyTopId = jdbcTemplate.queryForObject( + "SELECT product_id FROM mv_product_rank_weekly WHERE period_key = ? AND ranking = 1", + Long.class, TARGET_DATE); + // 급상승 그룹(1~5000) 중 p=5000이 최고 메트릭 + assertThat(weeklyTopId).isEqualTo(5_000L); + + // ── Monthly ── + t0 = System.currentTimeMillis(); + BatchStatus monthlyStatus = runJob("monthly"); + long monthlyMs = System.currentTimeMillis() - t0; + + assertThat(monthlyStatus).isEqualTo(BatchStatus.COMPLETED); + + int monthlyMvCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM mv_product_rank_monthly WHERE period_key = ?", + Integer.class, TARGET_DATE); + assertThat(monthlyMvCount).isEqualTo(100); + + Long monthlyTopId = jdbcTemplate.queryForObject( + "SELECT product_id FROM mv_product_rank_monthly WHERE period_key = ? AND ranking = 1", + Long.class, TARGET_DATE); + // 장기강자 그룹(5001~15000) 중 p=15000이 최고 메트릭 + assertThat(monthlyTopId).isEqualTo(15_000L); + + // ── 파티션 균등 분배 검증 (monthly 실행 후 staging 기준) ── + int stagingTotal = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM mv_product_rank_staging WHERE period_key = ?", + Integer.class, TARGET_DATE); + assertThat(stagingTotal).isEqualTo(productCount); + + // ── 결과 출력 ── + System.out.println(); + System.out.println("═══════════════════════════════════════════════════"); + System.out.println(" 대규모 배치 테스트 결과 (10만 건)"); + System.out.println("═══════════════════════════════════════════════════"); + System.out.printf(" 상품 수 : %,d%n", productCount); + System.out.printf(" 메트릭 행 수 : %,d%n", metricRows); + System.out.printf(" Partitioning : %d Worker%n", 4); + System.out.printf(" Weekly 소요 : %,dms (1위: product_%d, 급상승)%n", weeklyMs, weeklyTopId); + System.out.printf(" Monthly 소요 : %,dms (1위: product_%d, 장기강자)%n", monthlyMs, monthlyTopId); + System.out.printf(" Staging 적재 : %,d건 (~%,d건/partition)%n", stagingTotal, stagingTotal / 4); + System.out.println("═══════════════════════════════════════════════════"); + } + + @Test + @DisplayName("벤치마크 — gridSize=1 vs gridSize=4 소요 시간 비교 (weekly + monthly)") + void partitionBenchmark() throws Exception { + int productCount = 100_000; + int metricDays = 30; + + long t0 = System.currentTimeMillis(); + seedProductsBulk(productCount); + seedMetricsBulkWithTrends(productCount, metricDays, TARGET_DATE); + long seedMs = System.currentTimeMillis() - t0; + + int metricRows = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM product_metrics", Integer.class); + System.out.printf("%n[시드 완료] 상품 %,d건, 메트릭 %,d건 (%,dms)%n", productCount, metricRows, seedMs); + + // ── weekly gridSize=1 ── + ReflectionTestUtils.setField(jobConfig, "gridSize", 1); + + t0 = System.currentTimeMillis(); + BatchStatus weeklySingleStatus = runJob("weekly"); + long weeklySingleMs = System.currentTimeMillis() - t0; + assertThat(weeklySingleStatus).isEqualTo(BatchStatus.COMPLETED); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM mv_product_rank_weekly WHERE period_key = ?", + Integer.class, TARGET_DATE)).isEqualTo(100); + + jdbcTemplate.update("DELETE FROM mv_product_rank_weekly WHERE period_key = ?", TARGET_DATE); + jdbcTemplate.update("DELETE FROM mv_product_rank_staging WHERE period_key = ?", TARGET_DATE); + + // ── weekly gridSize=4 ── + ReflectionTestUtils.setField(jobConfig, "gridSize", 4); + + t0 = System.currentTimeMillis(); + BatchStatus weeklyPartitionedStatus = runJob("weekly"); + long weeklyPartitionedMs = System.currentTimeMillis() - t0; + assertThat(weeklyPartitionedStatus).isEqualTo(BatchStatus.COMPLETED); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM mv_product_rank_weekly WHERE period_key = ?", + Integer.class, TARGET_DATE)).isEqualTo(100); + + jdbcTemplate.update("DELETE FROM mv_product_rank_weekly WHERE period_key = ?", TARGET_DATE); + jdbcTemplate.update("DELETE FROM mv_product_rank_staging WHERE period_key = ?", TARGET_DATE); + + // ── monthly gridSize=1 ── + ReflectionTestUtils.setField(jobConfig, "gridSize", 1); + + t0 = System.currentTimeMillis(); + BatchStatus monthlySingleStatus = runJob("monthly"); + long monthlySingleMs = System.currentTimeMillis() - t0; + assertThat(monthlySingleStatus).isEqualTo(BatchStatus.COMPLETED); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM mv_product_rank_monthly WHERE period_key = ?", + Integer.class, TARGET_DATE)).isEqualTo(100); + + jdbcTemplate.update("DELETE FROM mv_product_rank_monthly WHERE period_key = ?", TARGET_DATE); + jdbcTemplate.update("DELETE FROM mv_product_rank_staging WHERE period_key = ?", TARGET_DATE); + + // ── monthly gridSize=4 ── + ReflectionTestUtils.setField(jobConfig, "gridSize", 4); + + t0 = System.currentTimeMillis(); + BatchStatus monthlyPartitionedStatus = runJob("monthly"); + long monthlyPartitionedMs = System.currentTimeMillis() - t0; + assertThat(monthlyPartitionedStatus).isEqualTo(BatchStatus.COMPLETED); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM mv_product_rank_monthly WHERE period_key = ?", + Integer.class, TARGET_DATE)).isEqualTo(100); + + double weeklySpeedup = (double) weeklySingleMs / weeklyPartitionedMs; + double monthlySpeedup = (double) monthlySingleMs / monthlyPartitionedMs; + + System.out.println(); + System.out.println("════════════════════════════════════════════════════════��═════"); + System.out.println(" Partitioning 벤치마크 (10만 상품 × 30일 메트릭)"); + System.out.println("══════════════════════════════════════════════════════════════"); + System.out.printf(" %-20s %10s %10s %10s%n", "", "gridSize=1", "gridSize=4", "향상률"); + System.out.println("──────────────────────────────────────────────────────────────"); + System.out.printf(" %-20s %,8dms %,8dms %8.1fx%n", "weekly (7일, 70만행)", weeklySingleMs, weeklyPartitionedMs, weeklySpeedup); + System.out.printf(" %-20s %,8dms %,8dms %8.1fx%n", "monthly (30일, 300만행)", monthlySingleMs, monthlyPartitionedMs, monthlySpeedup); + System.out.println("══════════════════════════════════════════════════════════════"); + } + + // ── 엣지 케이스 ───────────────────────────────────────────────────── + + @Test + @DisplayName("엣지 — 취소 반영: cancel_amount가 score에 반영") + void cancellationReflectedInScore() throws Exception { + seedProducts(2); + + // 상품 1: 매출 100만, 취소 없음 + jdbcTemplate.update( + "INSERT INTO product_metrics (product_id, metric_date, view_count, like_count, unlike_count, " + + "sales_count, sales_amount, cancel_count_by_event_date, cancel_amount_by_event_date, " + + "cancel_count_by_order_date, cancel_amount_by_order_date) VALUES (1, '2026-04-16', 100, 10, 0, 10, 1000000, 0, 0, 0, 0)"); + + // 상품 2: 매출 200만, 취소 150만 → 순 매출 50만 + jdbcTemplate.update( + "INSERT INTO product_metrics (product_id, metric_date, view_count, like_count, unlike_count, " + + "sales_count, sales_amount, cancel_count_by_event_date, cancel_amount_by_event_date, " + + "cancel_count_by_order_date, cancel_amount_by_order_date) VALUES (2, '2026-04-16', 200, 20, 0, 20, 2000000, 5, 1500000, 5, 1500000)"); + + BatchStatus status = runJob("weekly"); + + assertThat(status).isEqualTo(BatchStatus.COMPLETED); + + // 상품 1이 1위 (순 매출 100만 > 상품 2 순 매출 50만) + Long topProductId = jdbcTemplate.queryForObject( + "SELECT product_id FROM mv_product_rank_weekly WHERE period_key = ? AND ranking = 1", + Long.class, TARGET_DATE); + assertThat(topProductId).isEqualTo(1L); + } +} diff --git a/apps/commerce-batch/src/test/resources/schema-batch-test.sql b/apps/commerce-batch/src/test/resources/schema-batch-test.sql new file mode 100644 index 0000000000..9dc38a02f4 --- /dev/null +++ b/apps/commerce-batch/src/test/resources/schema-batch-test.sql @@ -0,0 +1,156 @@ +-- Batch E2E 테스트용 도메인 테이블 DDL +-- commerce-batch는 도메인 Entity가 없으므로 Hibernate ddl-auto로 생성되지 않는다. +-- Tasklet이 참조하는 테이블만 최소한으로 정의한다. + +CREATE TABLE IF NOT EXISTS brand ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + description VARCHAR(255), + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + deleted_at DATETIME(6) +); + +CREATE TABLE IF NOT EXISTS product ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + brand_id BIGINT NOT NULL, + category_id BIGINT, + name VARCHAR(255) NOT NULL, + price INT NOT NULL, + stock_quantity INT NOT NULL, + like_count INT NOT NULL DEFAULT 0, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + deleted_at DATETIME(6) +); + +CREATE TABLE IF NOT EXISTS orders ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + member_id BIGINT NOT NULL, + status VARCHAR(50) NOT NULL, + total_price INT NOT NULL, + original_total_price INT NOT NULL, + discount_amount INT NOT NULL DEFAULT 0, + coupon_issue_id BIGINT, + version BIGINT, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + deleted_at DATETIME(6) +); + +CREATE TABLE IF NOT EXISTS order_item ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + order_id BIGINT, + product_id BIGINT NOT NULL, + product_name VARCHAR(255) NOT NULL, + product_price INT NOT NULL, + brand_name VARCHAR(255), + quantity INT NOT NULL +); + +CREATE TABLE IF NOT EXISTS coupon ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + discount_type VARCHAR(50) NOT NULL, + discount_value INT NOT NULL, + min_order_amount INT NOT NULL, + expired_at DATETIME(6) NOT NULL, + max_issuance_count INT, + issued_count INT NOT NULL DEFAULT 0, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + deleted_at DATETIME(6) +); + +CREATE TABLE IF NOT EXISTS coupon_issue ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + coupon_id BIGINT NOT NULL, + member_id BIGINT NOT NULL, + used_order_id BIGINT, + status VARCHAR(50) NOT NULL, + expired_at DATETIME(6) NOT NULL, + created_at DATETIME(6) NOT NULL +); + +CREATE TABLE IF NOT EXISTS payments ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + order_id BIGINT NOT NULL, + status VARCHAR(50) NOT NULL, + amount INT NOT NULL, + card_type VARCHAR(255), + card_no VARCHAR(255), + pg_provider VARCHAR(255), + transaction_key VARCHAR(255), + failure_reason VARCHAR(255), + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + deleted_at DATETIME(6) +); + +CREATE TABLE IF NOT EXISTS reconciliation_mismatch ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + type VARCHAR(50), + payment_id BIGINT, + our_status VARCHAR(50), + external_status VARCHAR(50), + detected_at DATETIME(6), + resolution VARCHAR(50), + created_at DATETIME(6), + updated_at DATETIME(6), + note TEXT +); + +CREATE TABLE IF NOT EXISTS product_metrics ( + product_id BIGINT NOT NULL, + metric_date DATE NOT NULL, + view_count INT NOT NULL DEFAULT 0, + like_count INT NOT NULL DEFAULT 0, + unlike_count INT NOT NULL DEFAULT 0, + sales_count INT NOT NULL DEFAULT 0, + sales_amount BIGINT NOT NULL DEFAULT 0, + cancel_count_by_event_date INT NOT NULL DEFAULT 0, + cancel_amount_by_event_date BIGINT NOT NULL DEFAULT 0, + cancel_count_by_order_date INT NOT NULL DEFAULT 0, + cancel_amount_by_order_date BIGINT NOT NULL DEFAULT 0, + PRIMARY KEY (product_id, metric_date), + INDEX idx_metric_date (metric_date) +); + +CREATE TABLE IF NOT EXISTS mv_product_rank_weekly ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + product_id BIGINT NOT NULL, + ranking INT NOT NULL, + score DOUBLE NOT NULL, + view_count BIGINT NOT NULL DEFAULT 0, + like_count BIGINT NOT NULL DEFAULT 0, + sales_count BIGINT NOT NULL DEFAULT 0, + sales_amount BIGINT NOT NULL DEFAULT 0, + period_key VARCHAR(8) NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_period_ranking (period_key, ranking) +); + +CREATE TABLE IF NOT EXISTS mv_product_rank_monthly ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + product_id BIGINT NOT NULL, + ranking INT NOT NULL, + score DOUBLE NOT NULL, + view_count BIGINT NOT NULL DEFAULT 0, + like_count BIGINT NOT NULL DEFAULT 0, + sales_count BIGINT NOT NULL DEFAULT 0, + sales_amount BIGINT NOT NULL DEFAULT 0, + period_key VARCHAR(8) NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_period_ranking (period_key, ranking) +); + +CREATE TABLE IF NOT EXISTS mv_product_rank_staging ( + product_id BIGINT NOT NULL, + score DOUBLE NOT NULL, + view_count BIGINT NOT NULL DEFAULT 0, + like_count BIGINT NOT NULL DEFAULT 0, + sales_count BIGINT NOT NULL DEFAULT 0, + sales_amount BIGINT NOT NULL DEFAULT 0, + period_key VARCHAR(8) NOT NULL, + PRIMARY KEY (product_id, period_key) +); diff --git a/apps/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java b/apps/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java index ea4b4d15a3..24d95cd01c 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java @@ -4,9 +4,11 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.scheduling.annotation.EnableScheduling; import java.util.TimeZone; +@EnableScheduling @ConfigurationPropertiesScan @SpringBootApplication public class CommerceStreamerApplication { diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/MetricsDelta.java b/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/MetricsDelta.java new file mode 100644 index 0000000000..adaa629ea1 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/MetricsDelta.java @@ -0,0 +1,118 @@ +package com.loopers.application.ranking; + +/** + * 이벤트 배치에서 productId별로 집계된 메트릭 변화량. + * + *

모든 필드는 DB의 additive 컬럼에 대응하며 항상 0 이상이다. + * Redis用 net delta는 파생 getter로 제공한다.

+ * + *
    + *
  • DB (Phase 2): {@code getLikeDelta()}, {@code getUnlikeDelta()} 등 → 양수 누적
  • + *
  • Redis (Phase 3): {@code getNetLikeDelta()} 등 → {@code likeDelta - unlikeDelta} (음수 가능)
  • + *
+ */ +public class MetricsDelta { + + private int viewDelta; + private int likeDelta; + private int unlikeDelta; + private int salesCountDelta; + private long salesAmountDelta; + private int cancelCountDelta; + private long cancelAmountDelta; + private long lastEventEpochSeconds; + + // ── DB用 getters (additive, ≥ 0) ── + + public int getViewDelta() { return viewDelta; } + public int getLikeDelta() { return likeDelta; } + public int getUnlikeDelta() { return unlikeDelta; } + public int getSalesCountDelta() { return salesCountDelta; } + public long getSalesAmountDelta() { return salesAmountDelta; } + public int getCancelCountDelta() { return cancelCountDelta; } + public long getCancelAmountDelta() { return cancelAmountDelta; } + public long getLastEventEpochSeconds() { return lastEventEpochSeconds; } + + // ── Redis用 net delta getters (HINCRBY에 전달, 음수 가능) ── + + public int getNetLikeDelta() { return likeDelta - unlikeDelta; } + public int getNetSalesCountDelta() { return salesCountDelta - cancelCountDelta; } + public long getNetSalesAmountDelta() { return salesAmountDelta - cancelAmountDelta; } + + // ── factory methods ── + + public static MetricsDelta ofView() { + MetricsDelta d = new MetricsDelta(); + d.viewDelta = 1; + return d; + } + + public static MetricsDelta ofView(long eventEpochSeconds) { + MetricsDelta d = ofView(); + d.lastEventEpochSeconds = eventEpochSeconds; + return d; + } + + public static MetricsDelta ofLike() { + MetricsDelta d = new MetricsDelta(); + d.likeDelta = 1; + return d; + } + + public static MetricsDelta ofLike(long eventEpochSeconds) { + MetricsDelta d = ofLike(); + d.lastEventEpochSeconds = eventEpochSeconds; + return d; + } + + public static MetricsDelta ofUnlike() { + MetricsDelta d = new MetricsDelta(); + d.unlikeDelta = 1; + return d; + } + + public static MetricsDelta ofUnlike(long eventEpochSeconds) { + MetricsDelta d = ofUnlike(); + d.lastEventEpochSeconds = eventEpochSeconds; + return d; + } + + public static MetricsDelta ofSales(int count, long amount) { + MetricsDelta d = new MetricsDelta(); + d.salesCountDelta = count; + d.salesAmountDelta = amount; + return d; + } + + public static MetricsDelta ofSales(int count, long amount, long eventEpochSeconds) { + MetricsDelta d = ofSales(count, amount); + d.lastEventEpochSeconds = eventEpochSeconds; + return d; + } + + public static MetricsDelta ofCancel(int count, long amount) { + MetricsDelta d = new MetricsDelta(); + d.cancelCountDelta = count; + d.cancelAmountDelta = amount; + return d; + } + + public static MetricsDelta ofCancel(int count, long amount, long eventEpochSeconds) { + MetricsDelta d = ofCancel(count, amount); + d.lastEventEpochSeconds = eventEpochSeconds; + return d; + } + + public static MetricsDelta merge(MetricsDelta a, MetricsDelta b) { + MetricsDelta result = new MetricsDelta(); + result.viewDelta = a.viewDelta + b.viewDelta; + result.likeDelta = a.likeDelta + b.likeDelta; + result.unlikeDelta = a.unlikeDelta + b.unlikeDelta; + result.salesCountDelta = a.salesCountDelta + b.salesCountDelta; + result.salesAmountDelta = a.salesAmountDelta + b.salesAmountDelta; + result.cancelCountDelta = a.cancelCountDelta + b.cancelCountDelta; + result.cancelAmountDelta = a.cancelAmountDelta + b.cancelAmountDelta; + result.lastEventEpochSeconds = Math.max(a.lastEventEpochSeconds, b.lastEventEpochSeconds); + return result; + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingCarryOverScheduler.java b/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingCarryOverScheduler.java new file mode 100644 index 0000000000..1a9744036c --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingCarryOverScheduler.java @@ -0,0 +1,96 @@ +package com.loopers.application.ranking; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.data.redis.connection.zset.Aggregate; +import org.springframework.data.redis.connection.zset.Weights; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.Collections; +import java.util.concurrent.TimeUnit; + +import static com.loopers.application.ranking.RankingScoreUpdater.*; + +/** + * 23:50 KST에 일간 랭킹 carry-over를 수행한다. + * + *

오늘 ZSET score × carryOverRate → 내일 ZSET 시드 (콜드 스타트 완화)

+ * + *

주간/월간 랭킹은 MV 배치(ProductRankingMvJob)가 담당하므로 + * Redis 기반 주간/월간 집계는 더 이상 수행하지 않는다.

+ */ +@Slf4j +@Component +public class RankingCarryOverScheduler { + + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + + private final RedisTemplate writeTemplate; + private final RankingProperties properties; + + public RankingCarryOverScheduler( + @Qualifier("redisTemplateMaster") RedisTemplate writeTemplate, + RankingProperties properties + ) { + this.writeTemplate = writeTemplate; + this.properties = properties; + } + + @Scheduled(cron = "0 50 23 * * *", zone = "Asia/Seoul") + public void carryOver() { + carryOver(LocalDate.now(KST)); + } + + void carryOver(LocalDate today) { + LocalDate tomorrow = today.plusDays(1); + double rate = properties.carryOverRate(); + + RankingProperties.Experiment experiment = properties.experiment(); + if (experiment.enabled() && !experiment.variants().isEmpty()) { + for (RankingProperties.Variant variant : experiment.variants().values()) { + doCarryOverDaily(zsetKey(variant.zsetPrefix(), today), + zsetKey(variant.zsetPrefix(), tomorrow), rate); + } + } else { + doCarryOverDaily(zsetKey(today), zsetKey(tomorrow), rate); + } + } + + private void doCarryOverDaily(String todayKey, String tomorrowKey, double rate) { + try { + writeTemplate.opsForZSet().unionAndStore( + todayKey, + Collections.emptyList(), + tomorrowKey, + Aggregate.SUM, + Weights.of(rate) + ); + trimZset(tomorrowKey); + writeTemplate.expire(tomorrowKey, RANKING_ZSET_TTL_SECONDS, TimeUnit.SECONDS); + + Long size = writeTemplate.opsForZSet().zCard(tomorrowKey); + log.info("콜드 스타트 carry-over 완료: {} → {} (rate={}, members={})", + todayKey, tomorrowKey, rate, size); + } catch (Exception e) { + log.error("콜드 스타트 carry-over 실패: {} → {}", todayKey, tomorrowKey, e); + } + } + + /** + * ZSET member 수가 cap을 초과하면 하위 score를 제거하여 상위 cap개만 유지한다. + * carry-over에 의한 ZSET 크기 무한 누적을 방지한다. + */ + private void trimZset(String key) { + int cap = properties.carryOverCap(); + Long size = writeTemplate.opsForZSet().zCard(key); + if (size != null && size > cap) { + writeTemplate.opsForZSet().removeRange(key, 0, size - cap - 1); + log.info("ZSET trim 완료: key={}, before={}, after={}", key, size, cap); + } + } + +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingProperties.java b/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingProperties.java new file mode 100644 index 0000000000..96c9d00759 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingProperties.java @@ -0,0 +1,52 @@ +package com.loopers.application.ranking; + +import com.loopers.domain.ranking.ScoreFormula; +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.Map; + +/** + * 랭킹 시스템 설정. + * + *

Additionals "실시간 Weight 조절"을 위해 {@code @ConfigurationProperties}로 외부화. + * Spring Cloud Config 또는 yml 변경 + actuator refresh로 런타임 가중치 조정이 가능하다.

+ * + *
+ * ranking:
+ *   weights:
+ *     view: 0.1
+ *     like: 0.2
+ *     order: 0.7
+ *   carry-over-rate: 0.1
+ *   monthly-decay-rate: 0.97
+ *   category-priority: {}
+ *   default-category-priority: 0
+ *   experiment:
+ *     enabled: false
+ * 
+ */ +@ConfigurationProperties(prefix = "ranking") +public record RankingProperties( + ScoreFormula.Weights weights, + double carryOverRate, + double monthlyDecayRate, + int carryOverCap, + Map categoryPriority, + int defaultCategoryPriority, + Experiment experiment +) { + public RankingProperties { + if (monthlyDecayRate == 0) monthlyDecayRate = 0.97; + if (carryOverCap == 0) carryOverCap = 10_000; + if (categoryPriority == null) categoryPriority = Map.of(); + if (experiment == null) experiment = new Experiment(false, Map.of()); + } + + public record Experiment(boolean enabled, Map variants) { + public Experiment { + if (variants == null) variants = Map.of(); + } + } + + public record Variant(ScoreFormula.Weights weights, String zsetPrefix) {} +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingScoreUpdater.java b/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingScoreUpdater.java new file mode 100644 index 0000000000..107ad7f757 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingScoreUpdater.java @@ -0,0 +1,178 @@ +package com.loopers.application.ranking; + +import com.loopers.domain.ranking.ScoreFormula; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.dao.DataAccessException; +import org.springframework.data.redis.core.RedisOperations; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.SessionCallback; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** + * MetricsDelta를 Redis 랭킹(Hash + ZSET)에 반영한다. + * + *

Pipeline 2회: HINCRBY(Hash 누적) → 리턴값으로 score 계산 → ZADD(ZSET 덮어쓰기). + * ZINCRBY 대신 HINCRBY→ZADD를 선택한 근거는 설계 문서 참조.

+ * + * @see ScoreFormula + */ +@Slf4j +@Component +public class RankingScoreUpdater { + + public static final String RANKING_ZSET_PREFIX = "ranking:all:"; + public static final String RANKING_METRICS_PREFIX = "ranking:metrics:"; + + /** Daily ZSET TTL: 8일 (배치 보정에 최근 데이터 필요 + 여유) */ + public static final long RANKING_ZSET_TTL_SECONDS = 691_200L; + /** Hash TTL: 2일 (당일 score 재계산에만 사용) */ + public static final long RANKING_HASH_TTL_SECONDS = 172_800L; + + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.BASIC_ISO_DATE; + + private final RedisTemplate writeTemplate; + private final RankingProperties properties; + + public RankingScoreUpdater( + @Qualifier("redisTemplateMaster") RedisTemplate writeTemplate, + RankingProperties properties + ) { + this.writeTemplate = writeTemplate; + this.properties = properties; + } + + static String zsetKey(LocalDate date) { + return RANKING_ZSET_PREFIX + date.format(DATE_FORMATTER); + } + + static String zsetKey(String prefix, LocalDate date) { + return prefix + date.format(DATE_FORMATTER); + } + + static String hashKey(LocalDate date, Long productId) { + return RANKING_METRICS_PREFIX + date.format(DATE_FORMATTER) + ":" + productId; + } + + public void update(Map deltaMap) { + if (deltaMap.isEmpty()) { + return; + } + + LocalDate today = LocalDate.now(KST); + + // Pipeline 1: Hash 누적 (메트릭은 variant 무관, 1회만 실행) + Map accumulated = pipelineHincrby(deltaMap, today); + + // Pipeline 2: ZSET 쓰기 + RankingProperties.Experiment experiment = properties.experiment(); + if (experiment.enabled() && !experiment.variants().isEmpty()) { + // A/B 테스트: 각 variant별로 다른 weights + zsetPrefix로 ZADD + for (RankingProperties.Variant variant : experiment.variants().values()) { + String variantZsetKey = zsetKey(variant.zsetPrefix(), today); + pipelineZadd(accumulated, variantZsetKey, variant.weights(), deltaMap); + } + } else { + // 기본 모드: 단일 ZSET + String zsetKey = zsetKey(today); + pipelineZadd(accumulated, zsetKey, properties.weights(), deltaMap); + } + + log.debug("랭킹 스코어 갱신: date={}, products={}", today.format(DATE_FORMATTER), deltaMap.size()); + } + + /** + * Pipeline 1: productId당 4 HINCRBY + 1 HSET(lastEventAt) + 1 EXPIRE. + * 리턴 순서에 의존하여 누적치를 파싱한다. + */ + @SuppressWarnings("unchecked") + private Map pipelineHincrby(Map deltaMap, LocalDate date) { + List productIds = new ArrayList<>(deltaMap.keySet()); + + List results = writeTemplate.executePipelined(new SessionCallback<>() { + @Override + public Object execute(RedisOperations operations) throws DataAccessException { + for (Long productId : productIds) { + MetricsDelta delta = deltaMap.get(productId); + String hKey = hashKey(date, productId); + + operations.opsForHash().increment(hKey, "viewCount", (long) delta.getViewDelta()); + operations.opsForHash().increment(hKey, "likeCount", (long) delta.getNetLikeDelta()); + operations.opsForHash().increment(hKey, "salesCount", (long) delta.getNetSalesCountDelta()); + operations.opsForHash().increment(hKey, "salesAmount", delta.getNetSalesAmountDelta()); + operations.opsForHash().put(hKey, "lastEventAt", String.valueOf(delta.getLastEventEpochSeconds())); + operations.expire(hKey, RANKING_HASH_TTL_SECONDS, TimeUnit.SECONDS); + } + return null; + } + }); + + // productId당 6개 결과 (4 HINCRBY + 1 HSET + 1 EXPIRE) + Map accumulated = new HashMap<>(); + for (int i = 0; i < productIds.size(); i++) { + int base = i * 6; + long viewCount = toLong(results.get(base)); + long likeCount = toLong(results.get(base + 1)); + long salesCount = toLong(results.get(base + 2)); + long salesAmount = toLong(results.get(base + 3)); + + accumulated.put(productIds.get(i), new long[]{viewCount, likeCount, salesCount, salesAmount}); + } + return accumulated; + } + + @SuppressWarnings("unchecked") + private void pipelineZadd(Map accumulated, String zsetKey, + ScoreFormula.Weights weights, Map deltaMap) { + writeTemplate.executePipelined(new SessionCallback<>() { + @Override + public Object execute(RedisOperations operations) throws DataAccessException { + for (Map.Entry entry : accumulated.entrySet()) { + Long productId = entry.getKey(); + long[] counts = entry.getValue(); + warnIfNegative(productId, counts); + + MetricsDelta delta = deltaMap.get(productId); + long lastEventAt = delta.getLastEventEpochSeconds(); + int categoryPriority = properties.categoryPriority() + .getOrDefault(0L, properties.defaultCategoryPriority()); + + double score = ScoreFormula.calculate(counts[0], counts[1], counts[3], + categoryPriority, lastEventAt, weights); + operations.opsForZSet().add(zsetKey, String.valueOf(productId), score); + } + operations.expire(zsetKey, RANKING_ZSET_TTL_SECONDS, TimeUnit.SECONDS); + return null; + } + }); + } + + double calculateScore(long viewCount, long likeCount, long salesAmount, + long lastEventEpochSeconds, int categoryPriority) { + return ScoreFormula.calculate(viewCount, likeCount, salesAmount, + categoryPriority, lastEventEpochSeconds, properties.weights()); + } + + private void warnIfNegative(Long productId, long[] counts) { + if (counts[0] < 0 || counts[1] < 0 || counts[3] < 0) { + log.warn("음수 메트릭 감지: productId={}, view={}, like={}, salesAmount={}", + productId, counts[0], counts[1], counts[3]); + } + } + + private static long toLong(Object result) { + if (result instanceof Long l) return l; + if (result instanceof Number n) return n.longValue(); + return 0L; + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandled.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandled.java new file mode 100644 index 0000000000..8c8ad6e87b --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandled.java @@ -0,0 +1,40 @@ +package com.loopers.domain.event; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.ZonedDateTime; + +@Entity +@Table(name = "event_handled", uniqueConstraints = { + @UniqueConstraint(name = "uk_event_handled_event_id", columnNames = "event_id") +}) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class EventHandled { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "event_id", nullable = false, length = 100) + private String eventId; + + @Column(name = "event_type", nullable = false, length = 50) + private String eventType; + + @Column(name = "created_at", nullable = false, updatable = false) + private ZonedDateTime createdAt; + + @PrePersist + private void prePersist() { + this.createdAt = ZonedDateTime.now(); + } + + public EventHandled(String eventId, String eventType) { + this.eventId = eventId; + this.eventType = eventType; + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java new file mode 100644 index 0000000000..599b989e16 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java @@ -0,0 +1,53 @@ +package com.loopers.domain.metrics; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Entity +@Table(name = "product_metrics", indexes = { + @Index(name = "idx_metric_date", columnList = "metric_date") +}) +@IdClass(ProductMetricsId.class) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProductMetrics { + + @Id + @Column(name = "product_id") + private Long productId; + + @Id + @Column(name = "metric_date") + private LocalDate metricDate; + + @Column(name = "view_count", nullable = false) + private long viewCount; + + @Column(name = "like_count", nullable = false) + private long likeCount; + + @Column(name = "unlike_count", nullable = false) + private long unlikeCount; + + @Column(name = "sales_count", nullable = false) + private long salesCount; + + @Column(name = "sales_amount", nullable = false) + private long salesAmount; + + @Column(name = "cancel_count_by_event_date", nullable = false) + private long cancelCountByEventDate; + + @Column(name = "cancel_amount_by_event_date", nullable = false) + private long cancelAmountByEventDate; + + @Column(name = "cancel_count_by_order_date", nullable = false) + private long cancelCountByOrderDate; + + @Column(name = "cancel_amount_by_order_date", nullable = false) + private long cancelAmountByOrderDate; +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsId.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsId.java new file mode 100644 index 0000000000..15b37119ad --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsId.java @@ -0,0 +1,14 @@ +package com.loopers.domain.metrics; + +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import java.io.Serializable; +import java.time.LocalDate; + +@EqualsAndHashCode +@NoArgsConstructor +public class ProductMetricsId implements Serializable { + private Long productId; + private LocalDate metricDate; +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.java new file mode 100644 index 0000000000..d97ef5a1e8 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.java @@ -0,0 +1,116 @@ +package com.loopers.interfaces.consumer; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.confg.kafka.KafkaConfig; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** + * 선착순 쿠폰 발급 요청 Consumer. + * + *

coupon-issue-requests 토픽에서 요청을 소비하여 쿠폰을 발급하고, + * 결과를 Redis에 기록한다 (DB가 아닌 Redis TTL로 요청 추적).

+ */ +@Slf4j +@Component +public class CouponIssueConsumer { + + private static final String KEY_PREFIX = "coupon:request:"; + private static final long TTL_SECONDS = 600; // 10분 + + private final JdbcTemplate jdbcTemplate; + private final RedisTemplate writeTemplate; + private final ObjectMapper objectMapper; + + public CouponIssueConsumer( + JdbcTemplate jdbcTemplate, + @Qualifier("redisTemplateMaster") RedisTemplate writeTemplate, + ObjectMapper objectMapper + ) { + this.jdbcTemplate = jdbcTemplate; + this.writeTemplate = writeTemplate; + this.objectMapper = objectMapper; + } + + @KafkaListener( + topics = "coupon-issue-requests", + containerFactory = KafkaConfig.SINGLE_LISTENER + ) + @SuppressWarnings("unchecked") + public void consume(ConsumerRecord record, Acknowledgment ack) { + Long requestId = null; + try { + Map payload = objectMapper.readValue(record.value(), Map.class); + requestId = ((Number) payload.get("requestId")).longValue(); + Long couponId = ((Number) payload.get("couponId")).longValue(); + Long memberId = ((Number) payload.get("memberId")).longValue(); + + // 쿠폰 유효성 검증 + 발급 + issueCoupon(couponId, memberId); + + // 성공 → Redis COMPLETED + updateRequestStatus(requestId, couponId, memberId, "COMPLETED", null); + log.info("쿠폰 발급 성공: requestId={}, couponId={}, memberId={}", requestId, couponId, memberId); + + } catch (Exception e) { + log.error("쿠폰 발급 실패: requestId={}, reason={}", requestId, e.getMessage(), e); + if (requestId != null) { + try { + Map payload = objectMapper.readValue(record.value(), Map.class); + Long couponId = ((Number) payload.get("couponId")).longValue(); + Long memberId = ((Number) payload.get("memberId")).longValue(); + updateRequestStatus(requestId, couponId, memberId, "REJECTED", e.getMessage()); + } catch (Exception inner) { + log.error("Redis 상태 업데이트 실패: requestId={}", requestId, inner); + } + } + } finally { + ack.acknowledge(); + } + } + + private void issueCoupon(Long couponId, Long memberId) { + // 쿠폰 존재 및 만료 확인 + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM coupon WHERE id = ? AND expired_at > NOW() AND deleted_at IS NULL", + Integer.class, couponId + ); + if (count == null || count == 0) { + throw new IllegalStateException("쿠폰이 존재하지 않거나 만료되었습니다. couponId=" + couponId); + } + + // 쿠폰 발급 (coupon_issue INSERT) + jdbcTemplate.update( + "INSERT INTO coupon_issue (coupon_id, member_id, status, expired_at, created_at) " + + "SELECT ?, ?, 'AVAILABLE', expired_at, NOW() FROM coupon WHERE id = ?", + couponId, memberId, couponId + ); + } + + private void updateRequestStatus(Long requestId, Long couponId, Long memberId, + String status, String rejectReason) { + try { + String key = KEY_PREFIX + requestId; + Map data = Map.of( + "requestId", requestId, + "couponId", couponId, + "memberId", memberId, + "status", status, + "rejectReason", rejectReason != null ? rejectReason : "" + ); + String json = objectMapper.writeValueAsString(data); + writeTemplate.opsForValue().set(key, json, TTL_SECONDS, TimeUnit.SECONDS); + } catch (Exception e) { + log.error("Redis 상태 업데이트 실패: requestId={}, status={}", requestId, status, e); + } + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/DemoKafkaConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/DemoKafkaConsumer.java deleted file mode 100644 index ba862cec6d..0000000000 --- a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/DemoKafkaConsumer.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.loopers.interfaces.consumer; - -import com.loopers.confg.kafka.KafkaConfig; -import org.apache.kafka.clients.consumer.ConsumerRecord; -import org.springframework.kafka.annotation.KafkaListener; -import org.springframework.kafka.support.Acknowledgment; -import org.springframework.stereotype.Component; - -import java.util.List; - -@Component -public class DemoKafkaConsumer { - @KafkaListener( - topics = {"${demo-kafka.test.topic-name}"}, - containerFactory = KafkaConfig.BATCH_LISTENER - ) - public void demoListener( - List> messages, - Acknowledgment acknowledgment - ){ - System.out.println(messages); - acknowledgment.acknowledge(); - } -} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsConsumer.java new file mode 100644 index 0000000000..5ef982c744 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsConsumer.java @@ -0,0 +1,235 @@ +package com.loopers.interfaces.consumer; + +import com.loopers.application.ranking.MetricsDelta; +import com.loopers.application.ranking.RankingScoreUpdater; +import com.loopers.confg.kafka.KafkaConfig; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.stereotype.Component; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 카탈로그/주문 이벤트를 소비하여 product_metrics에 집계하는 Consumer. + * + *

Phase 1: 건별 멱등성 체크(event_handled INSERT IGNORE) + productId별 메모리 집계. + * Phase 2: productId별 1회 UPSERT로 DB 쓰기 횟수를 감소시킨다.

+ * + *

3,000건 poll, 인기 상품 100개에 이벤트 집중 시: + * [기존] 건별 UPSERT: event_handled 3,000회 + product_metrics 3,000회 = ~6,000회 + * [개선] 집계 UPSERT: event_handled 3,000회 + product_metrics ~100회 = ~3,100회 (48% 감소)

+ * + *

Late-Arriving Fact: ORDER_CANCELLED 이벤트는 인식일(CURDATE) + 발생일(원주문일) 이중 UPSERT.

+ */ +@Slf4j +@Component +public class MetricsConsumer { + + private final JdbcTemplate jdbcTemplate; + private final TransactionTemplate transactionTemplate; + private final RankingScoreUpdater rankingScoreUpdater; + + public MetricsConsumer(JdbcTemplate jdbcTemplate, TransactionTemplate transactionTemplate, + RankingScoreUpdater rankingScoreUpdater) { + this.jdbcTemplate = jdbcTemplate; + this.transactionTemplate = transactionTemplate; + this.rankingScoreUpdater = rankingScoreUpdater; + } + + @KafkaListener( + topics = {"catalog-events", "order-events"}, + containerFactory = KafkaConfig.BATCH_LISTENER + ) + public void consume(List> records, Acknowledgment ack) { + Map deltaMap = new HashMap<>(); + List lateArrivingCancels = new ArrayList<>(); + + // Phase 1: 멱등성 체크 + 메모리 집계 + for (ConsumerRecord record : records) { + try { + processRecord(record, deltaMap, lateArrivingCancels); + } catch (Exception e) { + log.error("이벤트 처리 실패: topic={}, offset={}, value={}", + record.topic(), record.offset(), record.value(), e); + } + } + + // Phase 2: productId별 인식일 UPSERT + 발생일(원주문일) UPSERT + if (!deltaMap.isEmpty() || !lateArrivingCancels.isEmpty()) { + transactionTemplate.executeWithoutResult(status -> { + for (Map.Entry entry : deltaMap.entrySet()) { + upsertProductMetrics(entry.getKey(), entry.getValue()); + } + for (LateArrivingCancel cancel : lateArrivingCancels) { + upsertCancelByOrderDate(cancel); + } + }); + } + + // Phase 3: Redis 랭킹 ZSET 갱신 (Redis 장애가 DB 커밋에 영향 주지 않도록 격리) + if (!deltaMap.isEmpty()) { + try { + rankingScoreUpdater.update(deltaMap); + } catch (Exception e) { + log.warn("랭킹 스코어 갱신 실패 (DB 메트릭스는 정상 반영됨): products={}", deltaMap.size(), e); + } + } + + ack.acknowledge(); + log.debug("메트릭스 배치 처리 완료: records={}, products={}, lateArrivals={}", + records.size(), deltaMap.size(), lateArrivingCancels.size()); + } + + private void processRecord(ConsumerRecord record, + Map deltaMap, + List lateArrivingCancels) { + String eventId = extractField(record.value(), "eventId"); + String eventType = extractField(record.value(), "eventType"); + String productIdStr = extractField(record.value(), "productId"); + + if (eventId == null || eventType == null || productIdStr == null) { + log.warn("필수 필드 누락: value={}", record.value()); + return; + } + + Long productId = Long.parseLong(productIdStr); + + transactionTemplate.executeWithoutResult(status -> { + // 멱등성 체크: INSERT IGNORE + int inserted = jdbcTemplate.update( + "INSERT IGNORE INTO event_handled (event_id, event_type, handled_at) VALUES (?, ?, NOW())", + eventId, eventType + ); + + if (inserted > 0) { + long eventEpochSeconds = record.timestamp() / 1000; + + // 새 이벤트만 집계 + switch (eventType) { + case "LIKE_CREATED" -> deltaMap.merge(productId, + MetricsDelta.ofLike(eventEpochSeconds), MetricsDelta::merge); + case "LIKE_REMOVED" -> deltaMap.merge(productId, + MetricsDelta.ofUnlike(eventEpochSeconds), MetricsDelta::merge); + case "PRODUCT_VIEWED" -> deltaMap.merge(productId, + MetricsDelta.ofView(eventEpochSeconds), MetricsDelta::merge); + case "ORDER_CREATED" -> { + int salesCount = parseIntField(record.value(), "salesCount", 1); + long salesAmount = parseLongField(record.value(), "salesAmount", 0); + deltaMap.merge(productId, + MetricsDelta.ofSales(salesCount, salesAmount, eventEpochSeconds), MetricsDelta::merge); + } + case "ORDER_CANCELLED" -> { + int cancelCount = parseIntField(record.value(), "salesCount", 1); + long cancelAmount = parseLongField(record.value(), "salesAmount", 0); + deltaMap.merge(productId, + MetricsDelta.ofCancel(cancelCount, cancelAmount, eventEpochSeconds), MetricsDelta::merge); + + // Late-Arriving Fact: 발생일(원주문일) 기준 별도 수집 + String originalOrderDateStr = extractField(record.value(), "originalOrderDate"); + if (originalOrderDateStr != null) { + try { + LocalDate orderDate = LocalDate.parse(originalOrderDateStr); + lateArrivingCancels.add( + new LateArrivingCancel(productId, orderDate, cancelCount, cancelAmount)); + } catch (Exception e) { + log.warn("originalOrderDate 파싱 실패: productId={}, value={}", + productId, originalOrderDateStr, e); + } + } + } + default -> log.warn("알 수 없는 이벤트 타입: {}", eventType); + } + } + }); + } + + private void upsertProductMetrics(Long productId, MetricsDelta delta) { + jdbcTemplate.update( + "INSERT INTO product_metrics " + + "(product_id, metric_date, view_count, like_count, unlike_count, " + + " sales_count, sales_amount, cancel_count_by_event_date, cancel_amount_by_event_date) " + + "VALUES (?, CURDATE(), ?, ?, ?, ?, ?, ?, ?) " + + "ON DUPLICATE KEY UPDATE " + + "view_count = view_count + VALUES(view_count), " + + "like_count = like_count + VALUES(like_count), " + + "unlike_count = unlike_count + VALUES(unlike_count), " + + "sales_count = sales_count + VALUES(sales_count), " + + "sales_amount = sales_amount + VALUES(sales_amount), " + + "cancel_count_by_event_date = cancel_count_by_event_date + VALUES(cancel_count_by_event_date), " + + "cancel_amount_by_event_date = cancel_amount_by_event_date + VALUES(cancel_amount_by_event_date)", + productId, + delta.getViewDelta(), delta.getLikeDelta(), delta.getUnlikeDelta(), + delta.getSalesCountDelta(), delta.getSalesAmountDelta(), + delta.getCancelCountDelta(), delta.getCancelAmountDelta() + ); + } + + private void upsertCancelByOrderDate(LateArrivingCancel cancel) { + jdbcTemplate.update( + "INSERT INTO product_metrics " + + "(product_id, metric_date, cancel_count_by_order_date, cancel_amount_by_order_date) " + + "VALUES (?, ?, ?, ?) " + + "ON DUPLICATE KEY UPDATE " + + "cancel_count_by_order_date = cancel_count_by_order_date + VALUES(cancel_count_by_order_date), " + + "cancel_amount_by_order_date = cancel_amount_by_order_date + VALUES(cancel_amount_by_order_date)", + cancel.productId, cancel.orderDate, cancel.count, cancel.amount + ); + } + + private String extractField(String json, String fieldName) { + // 간단한 JSON 필드 추출 (ObjectMapper 없이 경량 처리) + String pattern = "\"" + fieldName + "\""; + int idx = json.indexOf(pattern); + if (idx == -1) return null; + + int colonIdx = json.indexOf(':', idx + pattern.length()); + if (colonIdx == -1) return null; + + int start = colonIdx + 1; + // skip whitespace + while (start < json.length() && json.charAt(start) == ' ') start++; + + if (start >= json.length()) return null; + + if (json.charAt(start) == '"') { + // string value + int end = json.indexOf('"', start + 1); + return end == -1 ? null : json.substring(start + 1, end); + } else { + // numeric or other + int end = start; + while (end < json.length() && json.charAt(end) != ',' && json.charAt(end) != '}') end++; + return json.substring(start, end).trim(); + } + } + + private int parseIntField(String json, String fieldName, int defaultValue) { + String value = extractField(json, fieldName); + if (value == null) return defaultValue; + try { + return Integer.parseInt(value); + } catch (NumberFormatException e) { + return defaultValue; + } + } + + private long parseLongField(String json, String fieldName, long defaultValue) { + String value = extractField(json, fieldName); + if (value == null) return defaultValue; + try { + return Long.parseLong(value); + } catch (NumberFormatException e) { + return defaultValue; + } + } + + private record LateArrivingCancel(Long productId, LocalDate orderDate, int count, long amount) {} +} diff --git a/apps/commerce-streamer/src/main/resources/application.yml b/apps/commerce-streamer/src/main/resources/application.yml index 0651bc2bd3..65e7d385c0 100644 --- a/apps/commerce-streamer/src/main/resources/application.yml +++ b/apps/commerce-streamer/src/main/resources/application.yml @@ -25,9 +25,26 @@ spring: - logging.yml - monitoring.yml -demo-kafka: - test: - topic-name: demo.internal.topic-v1 + +ranking: + weights: + view: 0.1 + like: 0.2 + order: 0.7 + carry-over-rate: 0.1 + monthly-decay-rate: 0.97 + carry-over-cap: 10000 + category-priority: {} + default-category-priority: 0 + experiment: + enabled: false + variants: + A: + weights: { view: 0.1, like: 0.2, order: 0.7 } + zset-prefix: "ranking:exp:A:" + B: + weights: { view: 0.2, like: 0.3, order: 0.5 } + zset-prefix: "ranking:exp:B:" --- spring: diff --git a/apps/commerce-streamer/src/test/java/com/loopers/application/ranking/RankingCarryOverSchedulerTest.java b/apps/commerce-streamer/src/test/java/com/loopers/application/ranking/RankingCarryOverSchedulerTest.java new file mode 100644 index 0000000000..c1f96ed6c5 --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/application/ranking/RankingCarryOverSchedulerTest.java @@ -0,0 +1,170 @@ +package com.loopers.application.ranking; + +import com.loopers.domain.ranking.ScoreFormula; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.connection.zset.Aggregate; +import org.springframework.data.redis.connection.zset.Weights; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; + +import java.time.LocalDate; +import java.util.Collections; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import static com.loopers.application.ranking.RankingScoreUpdater.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class RankingCarryOverSchedulerTest { + + @Mock + private RedisTemplate writeTemplate; + + @Mock + private ZSetOperations zSetOps; + + private RankingCarryOverScheduler scheduler; + + private static final int CARRY_OVER_CAP = 10_000; + private static final LocalDate TODAY = LocalDate.of(2026, 4, 10); + private static final String TODAY_KEY = "ranking:all:20260410"; + private static final String TOMORROW_KEY = "ranking:all:20260411"; + + @BeforeEach + void setUp() { + RankingProperties properties = new RankingProperties( + new ScoreFormula.Weights(0.1, 0.2, 0.7), 0.1, 0.97, CARRY_OVER_CAP, + Map.of(), 0, null + ); + scheduler = new RankingCarryOverScheduler(writeTemplate, properties); + } + + private void stubZSetOps() { + stubZSetOps(100L); + } + + private void stubZSetOps(long zCardReturn) { + when(writeTemplate.opsForZSet()).thenReturn(zSetOps); + when(zSetOps.unionAndStore(anyString(), anyCollection(), anyString(), any(), any())) + .thenReturn(10L); + lenient().when(zSetOps.unionAndStore(anyString(), anyList(), anyString(), any(), any())) + .thenReturn(10L); + when(writeTemplate.expire(anyString(), anyLong(), any())).thenReturn(true); + when(zSetOps.zCard(anyString())).thenReturn(zCardReturn); + } + + @Nested + @DisplayName("일간 carry-over") + class DailyCarryOver { + + @Test + @DisplayName("ZUNIONSTORE로 오늘 score × 0.1을 내일 키에 복사") + void callsUnionAndStoreWithCorrectParams() { + stubZSetOps(); + + scheduler.carryOver(TODAY); + + verify(zSetOps).unionAndStore( + eq(TODAY_KEY), + eq(Collections.emptyList()), + eq(TOMORROW_KEY), + eq(Aggregate.SUM), + eq(Weights.of(0.1)) + ); + } + + @Test + @DisplayName("내일 키에 ZSET TTL(691200초 = 8일) 설정") + void setsTtlOnTomorrowKey() { + stubZSetOps(); + + scheduler.carryOver(TODAY); + + verify(writeTemplate).expire(TOMORROW_KEY, RANKING_ZSET_TTL_SECONDS, TimeUnit.SECONDS); + } + + @Test + @DisplayName("오늘 키와 내일 키가 하루 차이") + void keysDifferByOneDay() { + stubZSetOps(); + + scheduler.carryOver(LocalDate.of(2026, 12, 31)); + + verify(zSetOps).unionAndStore( + eq("ranking:all:20261231"), + eq(Collections.emptyList()), + eq("ranking:all:20270101"), + any(), any() + ); + } + + @Test + @DisplayName("Redis 장애 시 예외를 삼키고 로그만 남김") + void onFailure_doesNotThrow() { + when(writeTemplate.opsForZSet()).thenThrow(new RuntimeException("Redis 연결 실패")); + + assertThatCode(() -> scheduler.carryOver(TODAY)).doesNotThrowAnyException(); + } + } + + @Nested + @DisplayName("carry-over 후 Trim (ZSET 크기 관리)") + class ZsetTrim { + + @Test + @DisplayName("daily carry-over 후 ZSET 크기가 cap 초과 시 하위 score 제거") + void dailyTrim_whenExceedsCap() { + long oversized = 15_000L; + stubZSetOps(oversized); + + scheduler.carryOver(TODAY); + + verify(zSetOps).removeRange(TOMORROW_KEY, 0, oversized - CARRY_OVER_CAP - 1); + } + + @Test + @DisplayName("daily carry-over 후 ZSET 크기가 cap 이하면 trim 미실행") + void dailyTrim_whenWithinCap() { + stubZSetOps(5_000L); + + scheduler.carryOver(TODAY); + + verify(zSetOps, never()).removeRange(anyString(), anyLong(), anyLong()); + } + + @Test + @DisplayName("실험 활성화 시 variant carry-over에도 trim 적용") + void experimentVariant_trimApplied() { + RankingProperties experimentProps = new RankingProperties( + new ScoreFormula.Weights(0.1, 0.2, 0.7), 0.1, 0.97, CARRY_OVER_CAP, + Map.of(), 0, + new RankingProperties.Experiment(true, Map.of( + "A", new RankingProperties.Variant( + new ScoreFormula.Weights(0.1, 0.2, 0.7), "ranking:exp:A:"), + "B", new RankingProperties.Variant( + new ScoreFormula.Weights(0.2, 0.3, 0.5), "ranking:exp:B:") + )) + ); + RankingCarryOverScheduler expScheduler = new RankingCarryOverScheduler(writeTemplate, experimentProps); + + long oversized = 12_000L; + stubZSetOps(oversized); + + expScheduler.carryOver(TODAY); + + // variant A, B 두 키 모두 trim 호출 + verify(zSetOps).removeRange("ranking:exp:A:20260411", 0, oversized - CARRY_OVER_CAP - 1); + verify(zSetOps).removeRange("ranking:exp:B:20260411", 0, oversized - CARRY_OVER_CAP - 1); + } + } +} diff --git a/apps/commerce-streamer/src/test/java/com/loopers/application/ranking/RankingScoreUpdaterTest.java b/apps/commerce-streamer/src/test/java/com/loopers/application/ranking/RankingScoreUpdaterTest.java new file mode 100644 index 0000000000..b640987c6d --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/application/ranking/RankingScoreUpdaterTest.java @@ -0,0 +1,171 @@ +package com.loopers.application.ranking; + +import com.loopers.domain.ranking.ScoreFormula; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.RedisTemplate; + +import java.time.LocalDate; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +@ExtendWith(MockitoExtension.class) +class RankingScoreUpdaterTest { + + @Mock + private RedisTemplate writeTemplate; + + private RankingScoreUpdater updater; + + private static final long LAST_EVENT_AT = 1_712_700_000L; + private static final ScoreFormula.Weights WEIGHTS = new ScoreFormula.Weights(0.1, 0.2, 0.7); + + @BeforeEach + void setUp() { + RankingProperties properties = new RankingProperties( + WEIGHTS, 0.1, 0.97, 0, + Map.of(), 0, null + ); + updater = new RankingScoreUpdater(writeTemplate, properties); + } + + @Nested + @DisplayName("ScoreFormula 위임 검증") + class ScoreFormulaDelegation { + + @Test + @DisplayName("calculateScore()가 ScoreFormula.calculate()와 동일한 결과를 반환") + void delegatesToScoreFormula() { + double updaterScore = updater.calculateScore(100, 50, 80000, LAST_EVENT_AT, 0); + double formulaScore = ScoreFormula.calculate(100, 50, 80000, 0, LAST_EVENT_AT, WEIGHTS); + + assertThat(updaterScore).isEqualTo(formulaScore); + } + + @Test + @DisplayName("categoryPriority 파라미터가 ScoreFormula에 올바르게 전달됨") + void categoryPriorityPassedCorrectly() { + double updaterScore = updater.calculateScore(0, 0, 0, LAST_EVENT_AT, 3); + double formulaScore = ScoreFormula.calculate(0, 0, 0, 3, LAST_EVENT_AT, WEIGHTS); + + assertThat(updaterScore).isEqualTo(formulaScore); + } + + @Test + @DisplayName("음수 메트릭도 ScoreFormula와 동일하게 처리") + void negativeMetrics_matchesFormula() { + double updaterScore = updater.calculateScore(-5, -10, -50000, LAST_EVENT_AT, 0); + double formulaScore = ScoreFormula.calculate(-5, -10, -50000, 0, LAST_EVENT_AT, WEIGHTS); + + assertThat(updaterScore).isEqualTo(formulaScore); + } + + @Test + @DisplayName("커스텀 가중치 — 가중치가 결과에 영향을 미침") + void customWeights_affectScore() { + ScoreFormula.Weights viewFirst = new ScoreFormula.Weights(0.7, 0.2, 0.1); + RankingProperties viewFirstProps = new RankingProperties( + viewFirst, 0.1, 0.97, 0, Map.of(), 0, null); + RankingScoreUpdater viewUpdater = new RankingScoreUpdater(writeTemplate, viewFirstProps); + + double scoreView = viewUpdater.calculateScore(100, 0, 0, LAST_EVENT_AT, 0); + double scoreOrder = viewUpdater.calculateScore(0, 0, 100, LAST_EVENT_AT, 0); + + double tiebreaker = LAST_EVENT_AT * ScoreFormula.TIEBREAKER_SCALE; + assertThat(scoreView - tiebreaker).isGreaterThan(scoreOrder - tiebreaker); + } + } + + @Nested + @DisplayName("키 생성") + class KeyGeneration { + + @Test + @DisplayName("ZSET 키: ranking:all:{yyyyMMdd} 형식") + void zsetKey_format() { + LocalDate date = LocalDate.of(2026, 4, 10); + + String key = RankingScoreUpdater.zsetKey(date); + + assertThat(key).isEqualTo("ranking:all:20260410"); + } + + @Test + @DisplayName("ZSET 키: 커스텀 prefix 지원") + void zsetKey_customPrefix() { + LocalDate date = LocalDate.of(2026, 4, 10); + + String key = RankingScoreUpdater.zsetKey("ranking:exp:A:", date); + + assertThat(key).isEqualTo("ranking:exp:A:20260410"); + } + + @Test + @DisplayName("Hash 키: ranking:metrics:{yyyyMMdd}:{productId} 형식") + void hashKey_format() { + LocalDate date = LocalDate.of(2026, 4, 10); + + String key = RankingScoreUpdater.hashKey(date, 101L); + + assertThat(key).isEqualTo("ranking:metrics:20260410:101"); + } + + @Test + @DisplayName("날짜가 다르면 다른 키 생성") + void differentDates_differentKeys() { + LocalDate day1 = LocalDate.of(2026, 4, 10); + LocalDate day2 = LocalDate.of(2026, 4, 11); + + assertThat(RankingScoreUpdater.zsetKey(day1)) + .isNotEqualTo(RankingScoreUpdater.zsetKey(day2)); + assertThat(RankingScoreUpdater.hashKey(day1, 101L)) + .isNotEqualTo(RankingScoreUpdater.hashKey(day2, 101L)); + } + + @Test + @DisplayName("같은 날짜 + 다른 productId → 다른 Hash 키") + void sameDate_differentProductId_differentHashKeys() { + LocalDate date = LocalDate.of(2026, 4, 10); + + assertThat(RankingScoreUpdater.hashKey(date, 101L)) + .isNotEqualTo(RankingScoreUpdater.hashKey(date, 202L)); + } + + @Test + @DisplayName("ZSET 키 prefix가 공개 상수와 일치") + void zsetKey_usesPublicPrefix() { + LocalDate date = LocalDate.of(2026, 4, 10); + + assertThat(RankingScoreUpdater.zsetKey(date)) + .startsWith(RankingScoreUpdater.RANKING_ZSET_PREFIX); + } + + @Test + @DisplayName("Hash 키 prefix가 공개 상수와 일치") + void hashKey_usesPublicPrefix() { + LocalDate date = LocalDate.of(2026, 4, 10); + + assertThat(RankingScoreUpdater.hashKey(date, 101L)) + .startsWith(RankingScoreUpdater.RANKING_METRICS_PREFIX); + } + + @Test + @DisplayName("ZSET TTL 상수가 8일(691200초)") + void zsetTtlConstant_isEightDays() { + assertThat(RankingScoreUpdater.RANKING_ZSET_TTL_SECONDS).isEqualTo(691_200L); + } + + @Test + @DisplayName("Hash TTL 상수가 2일(172800초)") + void hashTtlConstant_isTwoDays() { + assertThat(RankingScoreUpdater.RANKING_HASH_TTL_SECONDS).isEqualTo(172_800L); + } + + } +} diff --git a/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/MetricsConsumerTest.java b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/MetricsConsumerTest.java new file mode 100644 index 0000000000..36354da5ef --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/MetricsConsumerTest.java @@ -0,0 +1,196 @@ +package com.loopers.interfaces.consumer; + +import com.loopers.application.ranking.RankingScoreUpdater; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.invocation.Invocation; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.support.TransactionTemplate; + +import java.util.Collection; +import java.util.List; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class MetricsConsumerTest { + + @Mock + private JdbcTemplate jdbcTemplate; + + @Mock + private TransactionTemplate transactionTemplate; + + @Mock + private RankingScoreUpdater rankingScoreUpdater; + + @Mock + private Acknowledgment ack; + + private MetricsConsumer consumer; + + @BeforeEach + void setUp() { + consumer = new MetricsConsumer(jdbcTemplate, transactionTemplate, rankingScoreUpdater); + + doAnswer(invocation -> { + @SuppressWarnings("unchecked") + Consumer action = invocation.getArgument(0); + action.accept(null); + return null; + }).when(transactionTemplate).executeWithoutResult(any()); + + // varargs 매칭: update(String, Object...) → 모든 호출에 1 리턴 + doAnswer(inv -> 1).when(jdbcTemplate).update(anyString(), any(Object[].class)); + } + + private ConsumerRecord record(String json) { + return new ConsumerRecord<>("order-events", 0, 0L, "key", json); + } + + private List captureUpdateSqls() { + Collection invocations = Mockito.mockingDetails(jdbcTemplate).getInvocations(); + return invocations.stream() + .filter(inv -> inv.getMethod().getName().equals("update")) + .map(inv -> (String) inv.getArgument(0)) + .collect(Collectors.toList()); + } + + @Nested + @DisplayName("Late-Arriving Fact — ORDER_CANCELLED 이중 UPSERT") + class LateArrivingFact { + + @Test + @DisplayName("ORDER_CANCELLED: 인식일(CURDATE) + 발생일(originalOrderDate) 이중 UPSERT 실행") + void cancelledEvent_dualUpsert() { + String json = """ + {"eventId":"evt-1","eventType":"ORDER_CANCELLED","productId":101,\ + "salesCount":2,"salesAmount":30000,"originalOrderDate":"2026-04-01"}"""; + + consumer.consume(List.of(record(json)), ack); + + List sqls = captureUpdateSqls(); + + // event_handled INSERT + 인식일 UPSERT + 발생일 UPSERT = 3회 + assertThat(sqls).hasSizeGreaterThanOrEqualTo(3); + assertThat(sqls).anyMatch(sql -> sql.contains("cancel_count_by_event_date")); + assertThat(sqls).anyMatch(sql -> sql.contains("cancel_count_by_order_date")); + + verify(ack).acknowledge(); + } + + @Test + @DisplayName("ORDER_CANCELLED에 originalOrderDate 없으면 발생일 UPSERT 미실행") + void cancelledEvent_noOriginalOrderDate_singleUpsert() { + String json = """ + {"eventId":"evt-2","eventType":"ORDER_CANCELLED","productId":101,\ + "salesCount":1,"salesAmount":10000}"""; + + consumer.consume(List.of(record(json)), ack); + + List sqls = captureUpdateSqls(); + + assertThat(sqls).anyMatch(sql -> sql.contains("cancel_count_by_event_date")); + assertThat(sqls).noneMatch(sql -> sql.contains("cancel_count_by_order_date")); + + verify(ack).acknowledge(); + } + + @Test + @DisplayName("다른 날짜의 취소(4/1 주문 → 4/5 취소)가 정확히 두 UPSERT로 기록") + void crossDateCancel_twoDistinctUpserts() { + String json = """ + {"eventId":"evt-3","eventType":"ORDER_CANCELLED","productId":202,\ + "salesCount":1,"salesAmount":50000,"originalOrderDate":"2026-04-01"}"""; + + consumer.consume(List.of(record(json)), ack); + + List sqls = captureUpdateSqls(); + + // 인식일 SQL: CURDATE() 사용 + String eventDateSql = sqls.stream() + .filter(sql -> sql.contains("cancel_count_by_event_date")) + .findFirst() + .orElse(""); + assertThat(eventDateSql).contains("CURDATE()"); + + // 발생일 SQL: CURDATE 미사용 (originalOrderDate는 파라미터로 전달) + String orderDateSql = sqls.stream() + .filter(sql -> sql.contains("cancel_count_by_order_date")) + .findFirst() + .orElse(""); + assertThat(orderDateSql).doesNotContain("CURDATE()"); + + verify(ack).acknowledge(); + } + + @Test + @DisplayName("originalOrderDate 파싱 실패 시 인식일 UPSERT는 정상 실행") + void invalidOriginalOrderDate_eventDateUpsertStillWorks() { + String json = """ + {"eventId":"evt-4","eventType":"ORDER_CANCELLED","productId":101,\ + "salesCount":1,"salesAmount":10000,"originalOrderDate":"invalid-date"}"""; + + consumer.consume(List.of(record(json)), ack); + + List sqls = captureUpdateSqls(); + + assertThat(sqls).anyMatch(sql -> sql.contains("cancel_count_by_event_date")); + assertThat(sqls).noneMatch(sql -> sql.contains("cancel_count_by_order_date")); + + verify(ack).acknowledge(); + } + } + + @Nested + @DisplayName("기존 이벤트 처리") + class ExistingEvents { + + @Test + @DisplayName("ORDER_CREATED 이벤트는 발생일(by_order_date) UPSERT를 실행하지 않음") + void orderCreated_noByOrderDateUpsert() { + String json = """ + {"eventId":"evt-5","eventType":"ORDER_CREATED","productId":101,\ + "salesCount":3,"salesAmount":90000}"""; + + consumer.consume(List.of(record(json)), ack); + + List sqls = captureUpdateSqls(); + assertThat(sqls).noneMatch(sql -> sql.contains("cancel_count_by_order_date")); + + verify(ack).acknowledge(); + } + + @Test + @DisplayName("PRODUCT_VIEWED 이벤트 정상 처리 — 인식일 UPSERT에 view_count 포함") + void productViewed_upsertContainsViewCount() { + String json = """ + {"eventId":"evt-6","eventType":"PRODUCT_VIEWED","productId":101}"""; + + consumer.consume(List.of(record(json)), ack); + + List sqls = captureUpdateSqls(); + assertThat(sqls).anyMatch(sql -> sql.contains("view_count")); + + verify(ack).acknowledge(); + } + } +} diff --git a/blog/blog-week5-read-optimization.md b/blog/blog-week5-read-optimization.md new file mode 100644 index 0000000000..e56a8758ac --- /dev/null +++ b/blog/blog-week5-read-optimization.md @@ -0,0 +1,237 @@ +# 상품 조회 P95 3초 → 8ms: 인덱스가 해결한 것, 하지 못한 것, 캐시가 대신한 것 + +--- + +> **TL;DR**: 1000만 건 규모의 테이블에서 페이지네이션 조회(20건/페이지)가 100 rps 동시 요청 시 100% 실패하던 구조를, 인덱스 + 비정규화 + 멀티 레이어 캐시(L1 Caffeine + L2 Redis)로 P95 8ms / 에러율 0%까지 개선했다. 이 글은 그 과정에서 내린 판단들과, 왜 그렇게 결정했는지에 대한 기록이다. + +--- + +## 문제를 처음 마주했을 때 + +상품 목록 조회 API에 좋아요 순 정렬을 추가하면서 문제가 시작됐다. + +처음에는 단순하게 접근했다. `likes` 테이블에서 `GROUP BY product_id`로 좋아요 수를 세고, Java `Comparator`로 정렬하면 되지 않을까. 페이지네이션을 적용해도, 정렬 기준이 DB 밖(Java)에 있으니 **전체 데이터를 먼저 메모리에 올려야** 했다. 10만 건 정도에서는 2초 걸렸다. 느리긴 했지만 동작은 했다. + +그런데 프로덕션 규모를 가정하고 데이터를 1000만 건으로 늘려보니 상황이 달라졌다. 단건 응답이 308초. K6로 100 rps를 걸면 99% 이상의 요청이 타임아웃으로 실패했다. 20건만 보여주면 되는 페이지네이션 요청인데, **매번 1000만 건 전체를 스캔하고 있었다.** 이건 "느린 서비스"가 아니라 **서비스 불능** 상태였다. + +원인을 분석해보니 세 가지가 겹쳐 있었다. + +1. 전체 상품을 메모리에 올려 정렬하고 있었다 (DB의 인덱스/LIMIT을 활용하지 못하고 Java에서 정렬) +2. 좋아요 수를 매 요청마다 COUNT 집계로 파생시키고 있었다 +3. 동일한 쿼리가 반복되는데 캐시가 없었다 + +하나만 고쳐서는 안 될 것 같았다. 각각의 문제에 대해 어떤 순서로, 어떤 기준으로 접근할지 고민했다. + +--- + +## 판단 1. 좋아요 수를 어디에 둘 것인가 + +가장 먼저 마주한 건 `likeCount`의 위치 문제였다. + +사실 이전에 쓰기 경합을 줄이기 위해 `likeCount` 컬럼을 의도적으로 제거한 적이 있었다. 좋아요가 몰릴 때 같은 row에 대한 UPDATE 경합이 발생하니까, 차라리 `COUNT(*)`로 파생시키는 게 낫다고 판단했었다. + +그런데 이번에 읽기 병목을 마주하면서, 같은 구조를 다른 눈으로 보게 됐다. + +| 시점 | 우선순위 | 결정 | +|------|---------|------| +| 이전 | 쓰기 경합 해소 > 읽기 성능 | `likeCount` 제거, `COUNT(*)` 파생 | +| 현재 | 읽기 성능 > 쓰기 경합 | `likeCount` 재도입, atomic SQL로 경합 최소화 | + +**트레이드오프의 축이 바뀌었다**고 느꼈다. 쓰기 경합은 atomic UPDATE(`SET like_count = like_count + 1`)로 줄일 수 있지만, 1000만 건에서 매번 `COUNT(*) GROUP BY`를 치는 건 구조적으로 한계가 있었다. 운영 환경을 다르게 가정하니, 문제의 무게중심이 달라졌다. + +--- + +## 판단 2. 인덱스를 어떻게 설계할 것인가 + +비정규화만으로는 부족했다. 1000만 건에서 `ORDER BY like_count DESC`를 하면, 인덱스 없이는 전체 테이블 스캔 + filesort가 발생한다. + +처음에는 `like_count`에 단일 인덱스를 걸었다. 그런데 브랜드 필터가 걸리면 인덱스를 타지 못했다. `WHERE brand_id = ? ORDER BY like_count DESC` — 이 조합은 단일 컬럼 인덱스로 커버되지 않는다. + +결국 **유스케이스별로 복합 인덱스**를 설계했다. + +``` +idx_product_like_count (like_count DESC, id DESC) → 전체 + 좋아요순 +idx_product_brand_like_count (brand_id, like_count DESC, id DESC) → 브랜드 필터 + 좋아요순 +idx_product_brand_price (brand_id, price ASC, id ASC) → 브랜드 필터 + 가격순 +idx_likes_product_id (product_id) → 좋아요 카운트 커버링 +``` + +EXPLAIN으로 전후를 비교해보니 차이가 명확했다. + +**AS-IS (인덱스 없음)**: +``` +type: ALL | rows: 9,955,217 | Extra: Using filesort +``` + +**TO-BE (복합 인덱스 적용)**: +``` +type: range | rows: 20 | Extra: Using index condition +``` + +스캔 행이 9,955,217 → 20으로 줄었다. 인덱스가 이미 정렬되어 있으므로 `LIMIT`만큼만 읽고 멈춘다. + +--- + +## 판단 3. 인덱스만으로 충분한가 + +여기서 한 가지 착각할 뻔했다. EXPLAIN 결과가 극적으로 좋아지니까, "인덱스면 충분하지 않나?"라는 생각이 들었다. 이제 DB가 인덱스를 타서 20건만 빠르게 읽으니까 괜찮을 거라고. + +그래서 **인덱스만 적용하고 캐시를 뺀 상태**로 100 rps 부하 테스트를 돌려봤다. 결과는 예상 밖이었다. + +| 시나리오 | P95 | Error Rate | 처리량 | +|---------|-----|-----------|--------| +| 인덱스 없음 | 3.01s | 100% | 51 rps | +| **인덱스+비정규화, 캐시 없음** | **3.02s** | **99.65%** | **35 rps** | + +인덱스를 걸었는데 오히려 처리량이 떨어졌다. 왜? + +Grafana의 HikariCP 패널에서 답을 찾았다. **커넥션 40개가 전부 점유**되어 있었다. 인덱스가 단건 쿼리를 빠르게 하는 건 맞지만, 100 rps로 동시에 밀려오는 요청이 각각 DB 커넥션을 잡으면, 커넥션 풀이 포화되면서 뒤따르는 요청들이 대기 큐에 빠진다. 한 건의 쿼리가 1ms여도, **커넥션을 기다리는 시간이 3초**가 된다. + +이 시점에서 깨달은 것: 캐시의 본질적 가치는 "빠른 응답"이 아니라 **"DB에 안 가게 하는 것"** 이다. + +--- + +## 판단 4. 캐시 전략을 어떻게 설계할 것인가 + +캐시를 적용하기로 했다. 그런데 결정할 게 많았다. + +### TTL은 어떻게? + +- 상품 상세: TTL 10분. 상품 정보는 자주 바뀌지 않고, 변경 시 명시적으로 evict한다. +- 상품 목록: TTL 5분. 목록은 새 상품 등록, 좋아요 변동 등으로 상대적으로 자주 바뀐다. + +처음에는 둘 다 10분으로 뒀는데, 목록 캐시가 너무 오래 유지되면 "방금 좋아요 눌렀는데 순위가 안 바뀌어요" 같은 불만이 생길 것 같았다. 결국 목록의 TTL을 짧게 조정했다. + +### 무효화 전략은? + +상품 상세는 단건이니까 `evict(productId)`로 충분하다. 문제는 목록이었다. 브랜드, 정렬, 페이지 조합으로 캐시 키가 무수히 많다. + +처음에는 패턴 매칭 삭제(`SCAN`)를 고려했다. 하지만 키가 수천 개일 때 O(N) 순회는 Redis에 부담이 된다. 결국 **버전 기반 무효화**를 선택했다. + +``` +캐시 키: product:list:v{version}:brand:3:sort:likeCount:page:0:size:20 +무효화: INCR product:list:version → 기존 키는 자연스럽게 miss +``` + +O(1)이고, 기존 키는 TTL이 만료되면 알아서 정리된다. 다만 무효화 시 모든 목록 캐시가 한꺼번에 miss되는 thundering herd 가능성은 있다. 현재 규모에서는 DB가 충분히 감당할 수 있다고 판단했지만, 트래픽이 10배로 늘면 재고해야 할 지점이다. + +### Redis 장애 시에는? + +try-catch로 감싸서 DB 직접 조회로 폴백한다. 캐시는 **최적화 계층이지 필수 의존이 아니다**. 이 원칙은 처음부터 정해두고 싶었다. + +--- + +## 판단 5. 왜 Redis만으로 부족하다고 생각했는가 + +Redis 캐시만 적용한 상태에서 P95가 10ms, 에러율 0%까지 떨어졌다. 충분히 만족할 만한 수치다. + +그런데 한 가지 마음에 걸렸다. 모든 캐시 조회가 Redis 네트워크 왕복을 거치고 있었다. Docker 환경에서 Redis가 localhost라 1ms 미만이지만, 실 운영에서 Redis가 별도 서버에 있으면 왕복 1~3ms가 추가된다. 수천 RPS에서 그 차이가 Tomcat 스레드 점유 시간으로 누적되면? + +인기 상품 상위 0.5%만 JVM 로컬 캐시(Caffeine)에 올리면 네트워크 비용 자체를 없앨 수 있다. 메모리는 ~1.5MB. 무시 가능한 비용이다. + +``` +GET: L1(Caffeine) hit → 반환 (μs) + L1 miss → L2(Redis) hit → L1 backfill → 반환 (ms) + 양쪽 miss → DB 조회 → L2 저장 → L1 저장 +``` + +**벤치마크 결과 (동일 조건: 100 rps, 1분, 1000만 건)**: + +| 시나리오 | P50 | P95 | Error Rate | 처리량 | +|---------|-----|-----|-----------|--------| +| L2 Redis Only | 6.47ms | 10.19ms | 0% | 100 rps | +| **L1+L2 Multi-Layer** | **4.76ms** | **8.04ms** | **0%** | **100 rps** | + +수치 차이는 2ms다. 하지만 이건 Redis가 localhost인 Docker 환경의 결과다. 실 운영에서는 이 차이가 더 벌어질 거라고 예상한다. + +--- + +## 판단 6. 캐시 구현체를 인터페이스로 분리한 이유 + +멀티 레이어 캐시를 만들면서 구조적인 문제를 발견했다. + +기존 `ProductCacheService`는 application 레이어의 concrete class인데, `RedisTemplate`을 직접 의존하고 있었다. Repository는 DIP를 잘 지키고 있었는데, 캐시만 예외였다. + +``` +// Repository — DIP 준수 +ProductFacade → ProductRepository (domain interface) ← ProductRepositoryImpl (infrastructure) + +// 캐시 — DIP 위반 +ProductFacade → ProductCacheService (concrete, RedisTemplate 직접 의존) +``` + +처음에는 "캐시니까 그냥 이대로 써도 되지 않을까" 싶었다. 그런데 테스트를 작성하면서 문제를 체감했다. Fake 객체를 만들려면 `extends ProductCacheService`에서 `super(null, null, null)`을 호출해야 했다. 생성자 파라미터가 바뀔 때마다 모든 Fake가 깨진다. + +L1, L2, MultiLayer 세 개의 구현체가 필요한 시점에서, 인터페이스 분리는 선택이 아니라 필수였다. + +``` +ProductCachePort (application, interface) + ├── CaffeineProductCacheAdapter (infrastructure, L1) + ├── RedisProductCacheAdapter (infrastructure, L2) + └── MultiLayerProductCacheAdapter (infrastructure, @Primary, L1+L2) +``` + +호출부(`ProductFacade`, `LikeController`)는 타입과 변수명만 교체하면 됐다. 메서드 시그니처가 동일하니까. + +--- + +## 검증 — 어떻게 측정했는가 + +"좋아졌다"를 체감하려면 수치가 필요했다. 그리고 **각 계층이 얼마나 기여하는지** 분리해서 보고 싶었다. + +### 환경 구성 + +- **MySQL** (Docker): 상품 1000만 건, 브랜드 500개, 회원 5000명, 좋아요 95만 건 +- **Redis** (Docker): Master-Replica 구성 +- **K6**: 100 rps, 1분, constant-arrival-rate. 페이지 0~4 × 정렬 3종 = 15개 조합을 랜덤 요청 (각 요청당 20건 페이지네이션) +- **Prometheus + Grafana**: P95, RPS, Error Rate, HikariCP, JVM Heap 모니터링 + +### 비교군 설계 + +각 최적화 계층의 기여분을 분리하기 위해 A/B 비교 엔드포인트를 추가했다. + +| 엔드포인트 | 인덱스 | 비정규화 | 캐시 | 증명하는 것 | +|-----------|--------|---------|------|-----------| +| `/products/no-optimization` | X | X | X | 기준선 — 왜 최적화가 필요한가 | +| `/products/no-cache` | O | O | X | 인덱스만으로 충분한가 | +| `/products` (L2) | O | O | L2 | 캐시 하나로 얼마나 달라지는가 | +| `/products` (L1+L2) | O | O | L1+L2 | 로컬 캐시가 추가로 줄여주는 것 | + +### Grafana에서 읽은 것 + +![](blob:https://velog.io/aed83a2a-5306-4f43-a971-1f887d09dc39) +*P95 Response Time + P50/RPS* + +![](https://velog.velcdn.com/images/sukhee/post/97a9acb2-04c2-4f57-8aac-de800887b6ab/image.png) +*P50 + RPS + Error Rate + HikariCP* + +![](https://velog.velcdn.com/images/sukhee/post/2630fb37-9da0-4bc2-a871-815c6f9181e9/image.png) +*Error Rate + HikariCP + JVM Heap + Total Requests* + +숫자 테이블보다 Grafana가 더 직관적으로 보여주는 것들이 있었다. + +**HikariCP 패널이 진짜 병목을 드러냈다.** 비캐시 구간에서 40개(Max Pool) 전부 점유, 캐시 구간에서 1~2개. 느린 쿼리 하나가 문제가 아니라, 느린 쿼리가 커넥션을 물고 놓지 않으면 뒤따르는 모든 요청이 대기에 빠진다. 이걸 보고 나서 "캐시는 속도 최적화"라는 생각이 바뀌었다. **캐시는 가용성 확보**다. + +**RPS 패널에서 서비스 용량의 차이가 보였다.** 비캐시는 목표 100 rps에 실제 35~51 rps만 처리하고 나머지는 유실됐다. 캐시를 적용하니 100 rps를 안정적으로 소화했다. 같은 하드웨어에서 캐시 유무가 처리 가능 트래픽을 2~3배 갈랐다. + +**L2 → L1+L2 차이는 환경의 한계를 알고 읽어야 한다.** 10ms → 8ms, 2ms 차이. Redis가 localhost여서 네트워크 latency가 거의 0인 Docker 환경이기 때문이다. 실 운영에서 Redis가 별도 서버에 있으면 이 차이는 더 벌어질 것이다. + +--- + +## 시행착오 + +검증 과정이 순탄하지는 않았다. + +**Docker `/tmp` 디스크 포화.** No Optimization 테스트를 먼저 돌리면, 1000만 건 전체 풀스캔 + filesort가 MySQL 임시 파일을 대량 생성해서 Docker VM 디스크를 채웠다. 이후 돌리는 캐시 테스트도 캐시 미스 시 DB 쿼리가 `No space left on device`로 실패하며 연쇄적으로 무너졌다. MySQL `sort_buffer_size`를 8MB로 올리고, 테스트 간 MySQL 컨테이너를 재시작해서 해결했다. + +**앱 재시작 시 1000만 건 데이터 유실.** local 프로필의 `ddl-auto: create` 때문에, 앱을 재시작하면 테이블이 재생성됐다. Stored procedure로 30분 걸려 시딩한 데이터가 순식간에 날아가는 경험을 했다... `--spring.jpa.hibernate.ddl-auto=none`을 JVM 인자로 전달해서 해결했는데, 한 번 당하기 전에는 떠올리기 어려운 종류의 실수였다. + +--- + +## 돌아보며 + +이번 작업에서 가장 크게 배운 건, **같은 구조도 문제의 맥락이 바뀌면 다시 판단해야 한다**는 점이다. `likeCount` 비정규화가 대표적이다. 쓰기 경합 관점에서는 제거하는 게 맞았지만, 읽기 병목 관점에서는 다시 도입하는 게 맞았다. "이전에 결정한 거니까"라고 고집하지 않고, 현재의 문제에 맞게 재판단하는 게 중요했다. + +그리고 인덱스만 믿고 캐시를 빼봤을 때 오히려 더 느려진 경험이 인상적이었다. EXPLAIN의 rows가 20이어도, 100 rps에서 커넥션 풀이 포화되면 의미가 없다. **단건 성능과 동시성 하의 성능은 완전히 다른 문제**라는 걸 체감했다. + +아직 아쉬운 부분도 있다. 다중 서버 환경에서 L1 캐시의 일관성 문제는 짧은 TTL로 회피하고 있을 뿐, 근본적으로 해결하지는 않았다. 트래픽이 더 커지면 Redis Pub/Sub 기반의 L1 무효화를 추가해야 할 것 같다. 그건 다음 과제로 남겨둔다. \ No newline at end of file diff --git a/blog/week1-testable-code-with-claude.md b/blog/week1-testable-code-with-claude.md new file mode 100644 index 0000000000..a4b250d078 --- /dev/null +++ b/blog/week1-testable-code-with-claude.md @@ -0,0 +1,237 @@ +# 검증 로직을 어디에 둘 것인가 — VO 자가 검증으로 테스트 용이한 구조 만들기 + +## 들어가며 + +"검증 로직은 어디에 두는 게 좋을까?" + +회원가입 기능을 구현한다고 생각해보자. ID는 영문/숫자 10자 이내, 이메일은 `xxx@yyy.zzz` 형식, 비밀번호는 최소 8자에 영문/숫자/특수문자 포함. 이 검증 로직은 **어디**에 위치해야 할까? + +전통적인 방식은 Service에 모든 검증을 집중시킨다. 그리고 나는 이번 과제에서 그 방식의 문제점을 경험했다. + +--- + +## 문제: Service에 검증이 집중되면 Mock 지옥 + +```java +// Service에 검증이 집중된 구조 +public class MemberService { + + public Member register(String loginId, String email, String password, ...) { + // 검증 로직들 + if (loginId == null || !loginId.matches("^[A-Za-z0-9]{1,10}$")) { + throw new IllegalArgumentException("ID 형식 오류"); + } + if (email == null || !email.matches("^[\\w-.]+@[\\w-]+(\\.[a-z]{2,})+$")) { + throw new IllegalArgumentException("이메일 형식 오류"); + } + // ... 비밀번호 검증, 기타 검증들 + + // 비즈니스 로직 + Member member = new Member(loginId, email, password); + return memberRepository.save(member); + } +} +``` + +이 구조에서 "ID 형식 검증"만 테스트하려면 어떻게 해야 할까? + +```java +@Test +void register_withInvalidLoginId_throwsException() { + // Service의 모든 의존성을 준비해야 함 + MemberRepository mockRepository = mock(MemberRepository.class); + PasswordEncoder mockEncoder = mock(PasswordEncoder.class); + MemberService service = new MemberService(mockRepository, mockEncoder); + + // 그제서야 검증 테스트 가능 + assertThatThrownBy(() -> service.register("user!@#", ...)) + .isInstanceOf(IllegalArgumentException.class); +} +``` + +**ID 형식 검증 하나를 테스트하는데 Repository와 PasswordEncoder를 Mock해야 한다.** 검증 로직이 늘어날수록 테스트 셋업 비용도 늘어난다. + +--- + +## 해결: 검증 로직을 VO에 위임 + +검증 로직의 위치를 바꾸면 테스트 구조가 완전히 달라진다. + +``` +┌─────────────────────────────────┐ ┌─────────────────────────────────┐ +│ Before: Service 집중 │ │ After: VO 자가 검증 │ +├─────────────────────────────────┤ ├─────────────────────────────────┤ +│ │ │ │ +│ Controller │ │ Controller │ +│ │ │ │ │ │ +│ ▼ │ │ ▼ │ +│ Service ◀── 검증 + 비즈니스 로직 │ │ Service ◀── 비즈니스 로직만 │ +│ │ │ │ │ │ +│ ├──▶ Repository (Mock 필수) │ │ ├──▶ VO 생성 │ +│ │ │ │ │ └──▶ 자가 검증 │ +│ └──▶ PasswordEncoder │ │ │ │ +│ (Mock 필수) │ │ └──▶ Repository │ +│ │ │ │ +└─────────────────────────────────┘ └─────────────────────────────────┘ + + ❌ 검증 테스트 = Mock 지옥 ✅ new LoginId("abc") 만으로 테스트 +``` + +핵심 아이디어는 **"유효하지 않은 객체는 존재할 수 없다"**는 원칙이다. VO가 생성되는 시점에 스스로 검증하고, 유효하지 않으면 예외를 던진다. + +--- + +## 구현: 자가 검증 VO + +### LoginId + +```java +@Embeddable +public class LoginId { + + private static final Pattern PATTERN = Pattern.compile("^[A-Za-z0-9]{1,10}$"); + + @Column(name = "login_id", nullable = false, unique = true, length = 20) + private String value; + + protected LoginId() {} // JPA용 + + public LoginId(String value) { + if (value == null || !PATTERN.matcher(value).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, + "ID는 영문 및 숫자 10자 이내여야 합니다."); + } + this.value = value; + } + + public String value() { return value; } + + // equals, hashCode 생략 +} +``` + +### Email + +```java +@Embeddable +public class Email { + + private static final Pattern PATTERN = + Pattern.compile("^[\\w-.]+@[\\w-]+(\\.[a-z]{2,})+$"); + + @Column(name = "email", nullable = false, length = 100) + private String value; + + protected Email() {} + + public Email(String value) { + if (value == null || !PATTERN.matcher(value).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, + "올바른 이메일 형식이 아닙니다."); + } + this.value = value; + } + + // ... +} +``` + +--- + +## 테스트가 이렇게 단순해진다 + +```java +class LoginIdTest { + + @Test + void create_withValidFormat_succeeds() { + LoginId loginId = new LoginId("user1234"); + assertThat(loginId.value()).isEqualTo("user1234"); + } + + @Test + void create_withSpecialChars_throwsException() { + assertThatThrownBy(() -> new LoginId("user!@#")) + .isInstanceOf(CoreException.class); + } + + @Test + void create_withTooLong_throwsException() { + assertThatThrownBy(() -> new LoginId("abcdefghijk")) + .isInstanceOf(CoreException.class); + } + + @Test + void create_withNull_throwsException() { + assertThatThrownBy(() -> new LoginId(null)) + .isInstanceOf(CoreException.class); + } +} +``` + +**Mock이 없다. 의존성이 없다.** `new LoginId("user!@#")` 한 줄로 도메인 규칙을 테스트한다. + +이런 단위 테스트는: +- 실행 속도가 빠르다 (Spring Context 불필요) +- 실패 원인이 명확하다 (LoginId 규칙 위반) +- 격리되어 있다 (다른 코드 변경에 영향받지 않음) + +--- + +## 트레이드오프: record vs class + +Java의 `record`는 VO에 적합해 보인다. `equals`, `hashCode`, `toString`이 자동 생성되기 때문이다. + +```java +// record로 구현하면 깔끔할 것 같지만... +public record LoginId(String value) { + public LoginId { + if (value == null || !PATTERN.matcher(value).matches()) { + throw new CoreException(...); + } + } +} +``` + +하지만 JPA `@Embeddable`과 함께 사용할 때 문제가 있다: + +1. **기본 생성자 필수**: JPA는 리플렉션으로 객체를 생성하므로 기본 생성자가 필요한데, record는 canonical 생성자만 가진다 +2. **QueryDSL 호환성**: Q-class 생성 시 record와 충돌이 발생할 수 있다 + +결국 `class`로 구현하고 `equals`/`hashCode`를 직접 작성했다. 보일러플레이트 코드가 늘어나는 비용이 있지만, JPA/QueryDSL과의 안정적인 통합을 선택했다. + +```java +@Override +public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof LoginId loginId)) return false; + return Objects.equals(value, loginId.value); +} + +@Override +public int hashCode() { return Objects.hash(value); } +``` + +--- + +## 결론 + +**검증 로직을 VO에 위임하면 `new LoginId("abc")`만으로 도메인 규칙을 테스트할 수 있다.** + +테스트하기 어려운 코드는 대체로 설계에 문제가 있다는 신호다. 반대로 말하면, **테스트 용이한 구조를 추구하다 보면 자연스럽게 좋은 설계에 도달한다**. + +이번 과제에서 VO 자가 검증 패턴을 적용한 결과, 52개 테스트(단위 39 / 통합 7 / E2E 6) 중 단위 테스트가 가장 많은 비중을 차지했다. Mock 없이 빠르게 실행되는 단위 테스트가 많다는 것은 도메인 로직이 적절히 분리되어 있다는 증거이기도 하다. + +``` + 테스트 피라미드 + ────────────── + /\ + / \ E2E: 6개 + /────\ + / \ 통합: 7개 + /────────\ + / \ 단위: 39개 + /────────────\ +``` + +"테스트하기 쉬운 구조 = 좋은 설계"라는 등식을 믿고, 다음 과제에서도 이 원칙을 적용해볼 생각이다. diff --git a/blog/week1-wil.md b/blog/week1-wil.md new file mode 100644 index 0000000000..e34eac4166 --- /dev/null +++ b/blog/week1-wil.md @@ -0,0 +1,91 @@ +# Week 1 WIL (What I Learned) + +## Claude Code 활용: 페르소나 부여 + +### CLAUDE.md 설정 + +프로젝트 루트에 `CLAUDE.md` 파일을 만들고 다음과 같이 명시했다: + +```markdown +## 역할 +- 20년 경력의 백엔드 개발자 +- 현재 네이버 백엔드 개발팀 팀장이자 면접관 +``` + +### 효과: 단순 구현이 아닌 설계 판단 + +페르소나를 부여하면 Claude가 단순히 "돌아가는 코드"가 아니라 **설계 관점에서 의견을 제시**한다. + +**예시: 인증 방식 선택** + +요구사항이 커스텀 헤더 기반 인증이었는데, Claude는 Spring Security 도입을 먼저 고려하고 "현재 요구사항 대비 과도하다"는 판단을 내렸다. 실서비스 적용 시 JWT 전환이 필요하다는 한계도 함께 언급했다. + +--- + +## Claude Code 활용 방식 (꿀팁) + +1. **페르소나 부여**: "시니어 개발자(20년차)", "면접관", "개발팀장", "운영경험" 등 역할을 명시하면 단순 구현이 아닌 설계 관점의 피드백을 받을 수 있다 + +2. **선택지 요청**: "A와 B 중 어떤 게 나은가?"라고 물으면 트레이드오프를 비교해준다. 일방적으로 "이렇게 해줘"보다 판단의 근거를 함께 얻을 수 있다 + +3. **한계 질문**: "이 설계의 한계는?"이라고 물으면 스스로 문제점을 드러낸다 + +4. **리뷰 요청**: 구현 후 "20년차 백엔드 개발자 관점에서 리뷰해줘"라고 하면 놓친 부분을 잡아준다 + +--- + +## CodeRabbit: AI 코드 리뷰 + +이번에 처음 경험해 본 것은 git에서 PR을 올리면 CodeRabbit이 자동으로 리뷰하는 것이었다. 이번에 받은 주요 피드백: + +1. **레이스 컨디션**: `existsByLoginId()` 체크 후 `save()` 사이에 동일 ID가 들어올 수 있음 → DB 유니크 제약 예외 처리 필요 +2. **Detached Entity 문제**: `changePassword()`에 전달된 Member가 영속성 컨텍스트에 없을 수 있음 → 재조회 필요 +3. **NPE 가능성**: `PasswordPolicy.validate()`에 birthDate가 null로 들어오면 NPE + +### 비판적 검토 결과 + +피드백을 받고 Claude에게 20년차 백엔드 개발자 관점에서 검토를 요청했다. 결론적으로 **세 가지 모두 현재 과제에서는 반영하지 않기로 했다**: + +| 피드백 | 판정 | 이유 | +|--------|------|------| +| 레이스 컨디션 | △ 과제 수준에서 불필요 | DB 유니크 제약으로 충분. 분산락은 실서비스에서 고려할 사항 | +| Detached Entity | △ 현재 구조에서 문제 없음 | OSIV가 기본 활성화되어 있고, 현재 호출 패턴에서 발생하지 않음 | +| NPE 가능성 | ✗ 오탐 | `BirthDate` VO가 생성 시점에 null 검증을 이미 수행함 | + +### 교훈: AI 리뷰도 비판적으로 검토해야 한다 + +AI 코드 리뷰는 빠르고 일관된 피드백을 주지만, **컨텍스트를 완전히 이해하지 못하는 한계**가 있다. 이번 경험에서 배운 점: + +- VO가 자가 검증하는 구조를 파악하지 못해 NPE 오탐이 발생했다 +- "일반적으로 위험할 수 있는 패턴"을 지적하지만, 현재 코드가 실제로 그 위험에 노출되는지는 별도로 판단해야 한다 +- AI 리뷰를 맹목적으로 반영하면 오히려 불필요한 복잡도가 추가될 수 있다 + +AI를 활용한 개발을 하게 되면서 개발의 속도가 가속되고 있다. 일일히 사람이 모두 리뷰하기 어려워졌다. 이런 한계점을 다시 AI가 코드리뷰를 하면서 보완하고 있다는 것을 보면서 새삼 대AI시대가 시작했다는 흐름을 느꼈다. **결국 모든 지적에 대해서 사람이 한 번 더 판단해야 한다는 것을 이번에 직접 경험했다.** + +--- + +## 대AI 시대, 주니어 개발자는 어떻게 살아남을까 + +### 솔직한 느낀 점 + +- **얻은 것**: 물리적으로 겪지 않은 경험 활용, 빠른 구현 +- **걱정되는 것**: 내가 코드를 세세히 모름. "왜 이렇게 했지?"를 AI에게 물어봐야 하는 상황 + +### 주니어 개발자로서의 고민 + +경력이 많은 분들은 "이렇게 설계해"라고 명확히 지시할 수 있다. 그러면 AI는 그 방향대로 구현해준다. + +나는 아직 그 수준이 아니다. 그래서 다음을 노력하려고 한다: + +1. **AI가 작성한 코드를 무지성으로 수용하지 않기**: 자동 구현에 enter를 누르고 끝내지 않고, 왜 이렇게 했는지 이해하기 +2. **AI의 판단을 그대로 수용하지 않기**: "왜?"라고 다시 물어보고, 납득이 안 되면 반론 제기하기. 이해가 가지 않으면 꼬리질문을 한다. AI의 실수를 방지하거나 나의 성장으로 이어질 것 +3. **기본기 공부 병행**: AI가 대신 생각해줘도, 결국 판단은 내가 해야 한다. DDD, 테스트 전략, JPA 동작 원리 같은 기본기가 있어야 AI의 답변이 맞는지 틀린지 알 수 있다 +4. **직접 디버깅**: AI가 작성한 코드에서 버그가 나면 직접 디버깅해보기. 그 과정에서 코드가 내 것이 된다 + +--- + +## 다음 과제 방향 + +이번 과제에서는 기능 요구사항 목록을 바탕으로, 기능적인 계획을 먼저 세웠었다. 그런데 다음 과제에서는 단편적인 구현을 모으기 보다는 AI와 함께 구조와 방향부터 통으로 설계하는 과정에 대부분의 시간을 할애해보려고 한다. + +**기능 중심으로 단편적인 구현을 거듭한 후에 리팩토링 해보니, 구현이 필요한 전체적인 부분에 대한 계획을 명확하고 세세하게 정의하는 것의 결과가 궁금해졌다**. 그리고 설계와 구현을 보며 개발지식도 늘려가고 싶다. diff --git a/blog/week2-like-api-design.md b/blog/week2-like-api-design.md new file mode 100644 index 0000000000..f41dfce941 --- /dev/null +++ b/blog/week2-like-api-design.md @@ -0,0 +1,124 @@ +# 좋아요 API, 토글로 만들까 POST/DELETE로 분리할까? + +## 버튼 하나에 API 두 개? + +인스타그램 좋아요 버튼을 생각해보자. 사용자는 하트를 누르면 좋아요가 되고, 다시 누르면 취소된다. UI 관점에서는 "토글"이다. + +그런데 이걸 API로 설계할 때 두 가지 선택지가 있다: + +``` +방법 1: PUT /likes (토글) +- 한 번 호출하면 좋아요, 다시 호출하면 취소 + +방법 2: POST /likes + DELETE /likes (분리) +- 좋아요 등록은 POST, 취소는 DELETE +``` + +당신이라면 어떻게 설계하겠는가? + +--- + +## 토글이 매력적인 이유 + +처음에는 토글 방식이 끌렸다. 이유는 간단하다: + +**1. 프론트엔드가 편하다** + +```javascript +// 토글 방식 +const handleClick = () => fetch('/likes', { method: 'PUT' }); + +// 분리 방식 +const handleClick = () => { + if (isLiked) fetch('/likes', { method: 'DELETE' }); + else fetch('/likes', { method: 'POST' }); +}; +``` + +분리 방식은 프론트에서 현재 상태를 알아야 한다. 토글은 그냥 호출하면 된다. + +**2. 버튼 연타에 안전하다** + +사용자가 좋아요 버튼을 빠르게 두 번 누르면? +- 토글: 좋아요 → 취소 (의도한 대로) +- 분리: POST → POST → 409 Conflict? (상태 불일치 가능) + +토글은 몇 번을 호출해도 결과가 예측 가능하다. + +--- + +## 그런데 왜 분리를 선택했나 + +결론부터 말하면 **POST/DELETE 분리**를 선택했다. 토글의 장점을 포기한 이유가 있다. + +### 이유 1: 요구사항이 명시했다 + +API 요구사항 문서에 이렇게 적혀 있었다: + +| METHOD | URI | 설명 | +|--------|-----|------| +| POST | `/api/v1/products/{productId}/likes` | 좋아요 등록 | +| DELETE | `/api/v1/products/{productId}/likes` | 좋아요 취소 | + +요구사항을 따르는 게 첫 번째 원칙이다. 내 취향대로 바꾸면 협업에서 혼란이 생긴다. + +### 이유 2: REST 원칙에 맞다 + +REST에서 HTTP 메서드는 의도를 표현한다: +- **POST**: 리소스 생성 +- **DELETE**: 리소스 삭제 +- **PUT**: 리소스 전체 교체 + +좋아요는 "생성"되거나 "삭제"되는 리소스다. PUT으로 토글하는 건 의미가 어색하다. "좋아요를 교체한다"는 게 뭔가? + +### 이유 3: 의도가 명확하다 + +서버 로그를 보자: + +``` +// 토글 방식 +PUT /products/123/likes - 이게 등록인지 취소인지? + +// 분리 방식 +POST /products/123/likes - 아, 좋아요 등록이구나 +DELETE /products/123/likes - 아, 취소구나 +``` + +디버깅할 때, 에러 로그 분석할 때, 의도가 드러나는 API가 낫다. + +--- + +## 그러면 버튼 연타 문제는? + +토글의 장점이었던 "버튼 연타 안전성"을 포기한 건 아니다. 분리 방식에서도 **멱등성**을 보장하면 된다. + +``` +POST /likes (이미 좋아요 상태) → 200 OK (그냥 성공 반환) +DELETE /likes (이미 취소 상태) → 200 OK (그냥 성공 반환) +``` + +에러를 던지지 않고 현재 상태를 유지한 채 성공으로 응답한다. 클라이언트 입장에서는: + +- "좋아요 해줘" → "됐어" (이미 되어 있어도 "됐어") +- "취소해줘" → "됐어" (이미 취소되어 있어도 "됐어") + +버튼을 아무리 연타해도 에러가 안 난다. 토글의 편의성을 분리 방식에서도 가져온 것이다. + +--- + +## 결론: 둘 다 틀린 답은 아니다 + +| 관점 | 토글 (PUT) | 분리 (POST/DELETE) | +|------|-----------|-------------------| +| REST 원칙 | △ 애매함 | ⭕ 명확함 | +| 프론트 편의 | ⭕ 상태 몰라도 됨 | △ 상태 알아야 함 | +| 의도 명확성 | △ 로그로 구분 안 됨 | ⭕ 메서드로 구분 | +| 멱등성 | ⭕ 자동 보장 | △ 직접 구현 필요 | + +정답은 없다. 다만 이번에는: +1. 요구사항이 분리를 명시했고 +2. REST 원칙을 따르는 게 장기적으로 유지보수에 유리하다고 판단했다 + +토글이 더 나은 상황도 있다. 프론트엔드 상태 관리가 복잡하거나, 빠른 MVP 개발이 필요하다면 토글이 실용적일 수 있다. + +**설계에 정답은 없고, 트레이드오프를 이해한 선택이 있을 뿐이다.** diff --git a/blog/week2-snapshot-design.md b/blog/week2-snapshot-design.md new file mode 100644 index 0000000000..13ea541e56 --- /dev/null +++ b/blog/week2-snapshot-design.md @@ -0,0 +1,152 @@ +> **TL;DR**: 스냅샷의 범위는 "다 넣으면 안전하니까"가 아니라, "이 화면이 원본 없이도 깨지지 않는가?"로 결정한다. 저장할수록 안전해지는 게 아니라, 저장할수록 유지보수 비용이 늘어난다. + +--- + +## 상품이 사라지면 주문도 사라진다? + +주문 내역을 조회하는데 이런 응답이 온다고 생각해보자. + +```json +{ + "orderId": 123, + "items": [ + { "productId": 456, "productName": null, "price": null } + ] +} +``` + +고객 입장에서는 황당하다. 분명 결제했는데 뭘 샀는지 모르겠다니. 환불하려 해도 당시 가격을 알 수 없다. 정산 기준이 무너진다. + +이커머스에서 상품은 살아 있는 데이터다. 가격이 바뀌고, 이름이 수정되고, 때로는 브랜드째로 삭제된다. 그런데 주문 내역은 **과거의 사실**이다. 3개월 전에 29,000원에 산 상품이, 지금 35,000원으로 올랐다고 해서 내 주문 내역까지 바뀌면 안 된다. + +그래서 주문 시점의 상품 정보를 복사해두는 **스냅샷**이 필요하다. 여기까지는 대부분 동의한다. + +문제는 **"그래서 뭘 저장할 건데?"**에서 시작된다. + +--- + +## "다 저장하면 안전하잖아?" + +면접에서 "스냅샷에 어떤 컬럼을 저장하겠습니까?"라는 질문을 받으면 어떻게 대답할지 생각해봤다. + +"상품명, 가격, 브랜드명, 이미지, 설명, 카테고리... 다 저장하면 안전하니까요." + +틀린 답은 아니다. 하지만 이 대답에는 **판단의 기준**이 없다. "왜 그 컬럼을?"에 "안전하니까"밖에 할 말이 없다면, 그건 설계가 아니라 불안감으로 만든 테이블이다. + +그리고 "다 저장"은 공짜가 아닐 것이다. + +**저장 비용이 폭발한다.** 주문 1건에 상품 3개, 스냅샷 컬럼 15개면 필드 45개. 하루 1만 건이면 연간 1.6억 개의 필드가 쌓인다. `description` 같은 긴 텍스트가 포함되면 용량은 기하급수적으로 늘어난다. + +**스키마 변경이 악몽이 된다.** 상품에 `material`(소재) 컬럼이 추가되면? order_items의 기존 데이터는 전부 null이다. 조회할 때마다 "옛날 주문이라 소재 정보가 없습니다"를 분기 처리해야 한다. 스냅샷 컬럼이 늘어날수록, 원본 스키마 변경의 영향 범위가 스냅샷 테이블까지 확장된다. + +**정작 안 쓰는 데이터가 대부분이다.** 주문 내역 화면에서 상품 `description`을 보여주는 서비스가 있던가? 대부분 상품명, 가격, 수량 정도만 표시한다. + +--- + +## 기준을 먼저 세우자 + +나는 이번 이커머스 설계 과제에서 이 문제를 정면으로 마주했다. 상품, 브랜드, 좋아요, 주문 도메인을 설계하는 과정이었는데, 주문 쪽에서 가장 오래 고민한 게 이 스냅샷 범위였다. + +"다 저장"도 아니고 "최소만 저장"도 아니라면, 기준이 필요하다. 내가 세운 기준은 이것이다. + +> **"주문 상세 화면을, 원본 데이터 없이 독립적으로 렌더링할 수 있는가?"** + +"온전하게 렌더링"의 의미는 명확하다. 사용자가 "뭘 샀는지" 알 수 있고, 금액이 명확하며(정산/환불 근거), 빈 칸이나 에러 없이 화면이 구성되는 것이다. + +--- + +## 세 가지 선택지를 비교했다 + +### 선택지 A: 최소한만 저장 + +`product_name`, `product_price`, `quantity` + +"뭘 얼마에 몇 개 샀는지"만 알 수 있으면 된다는 접근이다. 테이블이 가볍고, 스키마 변경에 영향을 받지 않는다. 하지만 "어느 브랜드 상품인지"를 보여줄 수 없다. 브랜드가 삭제되면 원본 조회도 불가능하다. + +### 선택지 B: 화면 렌더링에 필요한 것까지 저장 + +`product_name`, `product_price`, `quantity`, `brand_name`, `image_url` + +주문 상세 화면을 원본 없이도 온전하게 보여줄 수 있는 수준이다. +브랜드가 삭제되어도, 상품 이미지가 원본에서 제거되어도 +주문 내역이 깨지지 않는다. + +### 선택지 C: 가능한 한 다 저장 + +`product_name`, `product_price`, `quantity`, `brand_name`, `image_url`, `description`, `category` ... + +미래에 필요할 수도 있는 것까지 미리 넣어두는 접근이다. 어떤 화면을 만들든 원본 참조 없이 가능하다. 하지만 앞서 말한 저장 비용, 스키마 변경 영향, 안 쓰는 데이터 문제가 전부 여기서 발생한다. + +--- + +## 기준에 대입하면 답이 나온다 + +주문 상세 화면을 생각해보자. + +> **[이미지] 브랜드명** +> 상품명 +> 10,000원 × 2개 + +이 화면에서 각 요소를 하나씩 빼보면 판단이 명확해진다. + +| 필드 | 저장 여부 | 근거 | +|------|----------|------| +| `product_name` | ✅ 필수 | 없으면 "뭘 샀는지" 표시 불가. 화면이 성립하지 않음 | +| `product_price` | ✅ 필수 | 없으면 금액 표시 불가. 환불/정산 기준 소실 | +| `quantity` | ✅ 필수 | 주문 항목의 기본 정보 | +| `brand_name` | ✅ 권장 | 주문 내역 UI에 거의 항상 표시. 브랜드 삭제 시 원본 조회 불가 | +| `image_url` | ✅ 권장 | 주문 내역의 핵심 시각 요소. placeholder로 대체는 가능하지만 사용자 경험이 저하됨 | +| `description` | ❌ 제외 | 주문 상세가 아닌 상품 상세 페이지의 영역. 긴 텍스트라 저장 비용도 큼 | + + +최종 선택은 **B**였다. A는 브랜드 정보가 빠져서 화면이 불완전하고, C는 유지보수 비용 대비 얻는 게 없다. + +단, 이번 과제에서는 요구사항에 상품 이미지를 아직 다루고 있지 않아 `image_url`은 스냅샷에서 제외했다. 상품에 이미지가 추가되는 시점에 스냅샷에도 반영하면 된다. + +--- + +## product_id는 왜 남겨둘까? + +스냅샷을 저장하면서도 `product_id`는 유지했다. + +```java +public class OrderItem { + private Long productId; // 원본 참조 + private String productName; // 스냅샷 + private int productPrice; // 스냅샷 + private String brandName; // 스냅샷 + private int quantity; +} +``` + +스냅샷은 "과거의 사실"을 보존하는 것이고, `product_id`는 "현재의 원본"을 가리키는 것이다. 둘은 역할이 다르다. + +재주문 기능을 생각해보자. "이전에 샀던 거 다시 주문" → `product_id`로 현재 상품을 조회 → 장바구니에 담기. 주문 내역에서 상품을 클릭해 상품 상세 페이지로 이동하는 것도 마찬가지다. + +물론 상품이 삭제되면 404가 뜬다. 하지만 그건 "이 상품은 현재 판매하지 않습니다"로 처리하면 된다. 주문 내역 자체는 스냅샷 덕분에 깨지지 않는다. + +--- + +## 이 결정이 다른 설계와 만나는 지점 + +스냅샷 범위를 정하고 나니, 다른 설계 결정과 자연스럽게 연결되는 부분이 보였다. + +이번 과제의 요구사항에는 "브랜드 삭제 시 해당 브랜드의 상품들도 삭제되어야 한다"는 조건이 있었다. 이걸 처리하면 상품 데이터가 사라진다. 그런데 스냅샷에 `brand_name`과 `product_name`을 저장해뒀기 때문에, 상품이 삭제되어도 기존 주문 내역은 영향을 받지 않는다. + +스냅샷 설계가 삭제 정책의 안전장치가 되어준 셈이다. 만약 스냅샷 없이 FK 참조만 했다면, 브랜드 삭제 → 상품 삭제 → 주문 내역 깨짐이라는 연쇄 반응을 막기 위해 삭제 자체를 제한하거나, 훨씬 복잡한 처리가 필요했을 것이다. + +설계 결정들은 독립적으로 존재하지 않는다. 스냅샷 범위를 잘 잡아두면, 삭제 정책을 설계할 때 자유도가 높아진다. + +--- + +## 돌아보며 + +"데이터 기준"으로 생각하면 함정에 빠진다. "상품 정보니까 다 저장해야지"는 과잉 저장이고, "ID만 있으면 조회하면 되지"는 원본 삭제 시 장애다. + +**"화면 기준"으로 생각하면 명확해진다.** "이 화면에 뭐가 보여야 하지?", "원본 없이 이 화면을 그릴 수 있나?", "없어도 placeholder로 대체 가능한가?" — 이 질문에 답하면 스냅샷 범위는 자연스럽게 결정된다. + +물론 이 기준이 항상 정답은 아니다. B2B SaaS에서 계약 스냅샷이라면 "법적 분쟁 시 증거로 사용할 수 있는가?"가 기준이 될 것이고, 범위도 훨씬 넓어질 것이다. 명품 이커머스라면 증빙 목적으로 이미지까지 포함해야 할 수도 있다. + +중요한 건 특정 컬럼의 포함 여부가 아니다. **"왜 이 범위인가?"에 답할 수 있는 기준을 갖는 것.** 기준이 있으면 컬럼을 하나 추가할지 말지를 두고 끝없이 고민하지 않아도 된다. 기준에 물어보면 된다. + +**스냅샷은 "안전하게 다 저장"이 아니라 "목적에 맞게 저장"이다.** \ No newline at end of file diff --git a/blog/week2-wil.md b/blog/week2-wil.md new file mode 100644 index 0000000000..56555baa9d --- /dev/null +++ b/blog/week2-wil.md @@ -0,0 +1,86 @@ +# 2주차 WIL: 이커머스 도메인 설계 + +이번 주는 코드 대신 설계 문서를 만들었다. +요구사항 명세서, 시퀀스 다이어그램, 클래스 다이어그램, ERD. + +설계 문서를 작성해본 경험이 부족해서 처음부터 걱정이었는데, +역시나 해보니 코드보다 더 오래 붙잡고 있었다. +코드는 틀리면 컴파일러가 알려주기라도 하지, +설계는 "이게 맞나?"를 계속 스스로에게 물어봐야 한다. + +--- + +## 스냅샷 범위에서 가장 오래 멈췄다 + +주문할 때 상품 정보를 스냅샷으로 저장해야 한다. +그건 알겠는데, 어디까지 저장해야 하지? + +처음에는 "그냥 다 넣으면 되는 거 아닌가?"라고 생각했다. +상품명, 가격, 브랜드명, 설명, 이미지... 전부 복사해두면 안전하니까. + +그런데 "안전하니까"는 기준이 아니었다. +다 넣으면 상품 스키마가 바뀔 때마다 order_items도 마이그레이션해야 하고, +정작 주문 내역에서 보여주지도 않는 description 같은 걸 +매 주문마다 복사하고 있게 된다. + +결국 기준을 하나 세웠다. +"주문 상세 화면을, 원본 상품 없이도 온전하게 보여줄 수 있는가?" + +이걸로 판단하니까 의외로 빨리 정리가 됐다. +상품명, 가격은 없으면 화면이 안 되니까 필수. +브랜드명은 주문 내역에 거의 항상 뜨니까 포함. +description은 주문 상세에서 안 보여주니까 제외. + +이번 과제에서는 상품 이미지를 다루지 않아서 image_url은 빠졌지만, +실서비스였으면 이것도 당연히 들어갔을 거다. + +이걸 고민하면서 느낀 건, +설계할 때 기준을 먼저 세우면 이후 판단이 빨라진다는 것이다. +기준이 없으면 컬럼 하나마다 "이것도 넣어야 하나...?" 를 반복하게 된다. +이 내용은 따로 글로 정리해서 올렸다. +→ [주문 스냅샷에 뭘 저장해야 할까?](https://velog.io/@sukhee/주문-스냅샷에-뭘-저장해야-할까) + +--- + +## 요구사항에 없는 걸 만들 뻔했다 + +설계하다 보니 자연스럽게 "주문 취소도 있어야 하지 않나?"라는 생각이 들었다. +유스케이스 작성하고, 시퀀스 다이어그램까지 그렸다. +재고 복원 로직도 고민하고, 취소된 주문에서 삭제된 상품의 재고를 +복원해야 하나 말아야 하나까지 생각했다. + +그런데 요구사항을 다시 읽어보니 주문 취소 API가 없었다. + +다 지웠다. + +이게 과제라서 지우고 끝이었지, +실무였으면 누군가가 리뷰에서 "이거 스펙에 있었어?"라고 물었을 거다. +필요해 보이는 것과 요구된 것은 다르다. + +--- + +## 삭제 정책은 테이블마다 달랐다 + +처음에는 "soft delete로 통일하면 깔끔하겠지"라고 생각했다. +그런데 실제로 각 테이블에 대입해보니 그렇지 않았다. + +orders는 당연히 soft delete다. 주문 이력은 지우면 안 된다. +brands, products도 soft delete. 주문이 이 데이터를 참조하고 있으니까. + +그런데 likes는? 상품이 삭제되면 그 상품에 대한 좋아요는 의미가 없다. +soft delete로 남겨둘 이유가 없어서 hard delete로 결정했다. + +order_items가 좀 헷갈렸다. 처음에는 soft delete로 설계했는데, +생각해보니 주문이 취소되어도 order_items는 삭제되지 않는다. +Order의 상태만 CANCELLED로 바뀔 뿐이다. +삭제 자체가 발생하지 않는 테이블에 deleted_at을 넣는 건 이상했다. + +결국 "삭제 정책을 일괄 적용하지 말고 테이블마다 판단하자"가 결론이었다. + +--- + +## 앞으로는 + +이 설계를 가지고 실제 구현을 하게 될텐데. +문서에서는 정리된 것들이 코드가 되었을 때 깔끔할 수 있을지 모르겠다. +특히 Stock VO의 불변성을 JPA 엔티티에서 어떻게 유지할지를 고민해봐야겠다. \ No newline at end of file diff --git a/blog/week3-aggregate-lifecycle.md b/blog/week3-aggregate-lifecycle.md new file mode 100644 index 0000000000..b2b24167cb --- /dev/null +++ b/blog/week3-aggregate-lifecycle.md @@ -0,0 +1,142 @@ +> **TL;DR**: Aggregate Root가 자식의 생성을 통제해야 한다. "규칙을 문서에 쓰는 것"보다 "컴파일러가 잡게 만드는 것"이 확실하다. package-private 생성자 하나로 설계 의도를 코드에 강제할 수 있다. + +--- + +## Facade가 OrderItem을 만들고 있었다 + +3주차 주문 도메인을 구현하고 리뷰하다가 한 가지가 걸렸다. + +```java +// OrderFacade.java (Application Layer) +List orderItems = new ArrayList<>(); +for (int i = 0; i < itemRequests.size(); i++) { + orderItems.add(new OrderItem( + product.getId(), product.getName(), product.getPrice().getValue(), + brandName, itemRequests.get(i).quantity() + )); +} +return orderRepository.save(Order.create(memberId, orderItems)); +``` + +Facade가 `new OrderItem()`을 직접 호출하고 있다. OrderItem은 Order의 자식 엔티티인데, Application Layer에서 마음대로 생성하고 있는 거다. + +지금 당장 동작에 문제는 없다. 하지만 이건 **"Aggregate Root가 자식의 라이프사이클을 통제한다"는 원칙**에 어긋난다. + +--- + +## Aggregate Root는 왜 자식을 직접 만들어야 하는가 + +DDD에서 Aggregate는 **일관성 경계**다. Order와 OrderItem은 항상 함께 생성되고, 함께 저장되고, Order를 통해서만 접근해야 한다. + +이 원칙이 깨지면 어떤 일이 벌어질까? + +### 시나리오: 6개월 후 신입 개발자가 합류한다 + +OrderItem의 생성자가 `public`이니까, 아무 곳에서나 쓸 수 있다. + +```java +// 어떤 새로운 서비스에서 +OrderItem item = new OrderItem(productId, name, price, brand, qty); +// Order 없이 OrderItem만 떠도는 상황 +// totalPrice 계산 로직을 우회 +// 비즈니스 규칙(주문 생성 시 검증)을 건너뜀 +``` + +코드 리뷰에서 잡을 수 있다? 물론이다. 하지만 사람은 실수한다. **컴파일러가 잡아주는 게 사람이 잡는 것보다 싸고 확실하다.** + +--- + +## 해결: 두 가지를 바꿨다 + +### 1. OrderItem 생성자를 package-private로 변경 + +```java +// OrderItem.java +OrderItem(Long productId, String productName, int productPrice, + String brandName, int quantity) { + // public → package-private (접근 제한자 제거) +} +``` + +이제 `com.loopers.domain.order` 패키지 외부에서는 `new OrderItem()`이 **컴파일 에러**가 된다. Facade, Controller, 어떤 서비스에서도 직접 생성할 수 없다. + +### 2. Order.create()에 스냅샷 데이터를 전달 + +Order 내부에 `ItemSnapshot` record를 정의하고, 외부에서는 이 스냅샷만 넘기도록 했다. + +```java +// Order.java +public record ItemSnapshot( + Long productId, String productName, int productPrice, + String brandName, int quantity +) {} + +public static Order create(Long memberId, List snapshots) { + Order order = new Order(); + order.memberId = memberId; + order.status = OrderStatus.CREATED; + for (ItemSnapshot s : snapshots) { + order.items.add(new OrderItem( + s.productId(), s.productName(), s.productPrice(), + s.brandName(), s.quantity() + )); + } + order.totalPrice = order.items.stream() + .mapToInt(OrderItem::getSubtotal).sum(); + return order; +} +``` + +Facade는 이제 데이터만 전달하고, 생성은 Order가 한다. + +```java +// OrderFacade.java — 변경 후 +snapshots.add(new Order.ItemSnapshot( + product.getId(), product.getName(), + product.getPrice().getValue(), brandName, + itemRequests.get(i).quantity() +)); +return orderRepository.save(Order.create(memberId, snapshots)); +``` + +--- + +## 접근 제어 수준을 왜 package-private으로 했는가 + +| 접근 수준 | 누가 쓸 수 있나 | 판단 | +|---|---|---| +| `public` | 누구나 | 과도함 — Aggregate Root 우회 가능 | +| **package-private** | **같은 패키지 (Order, OrderItem)** | **적절 — Order만 생성 가능** | +| `protected` | 같은 패키지 + 하위 클래스 | 상속 목적 아니면 불필요 | +| `private` | OrderItem 자기 자신만 | 과도함 — Order도 못 씀 | + +Java에서 패키지가 Aggregate 경계 역할을 한다. `com.loopers.domain.order` 패키지 안에 Order와 OrderItem이 함께 있으니, package-private이 딱 맞는 수준이다. + +--- + +## 테스트는 어떻게 되나 + +테스트 클래스도 같은 패키지(`com.loopers.domain.order`)에 위치하기 때문에 package-private 생성자에 접근할 수 있다. 하지만 `Order.create()`가 `ItemSnapshot`을 받도록 바뀌었으니, 테스트도 자연스럽게 스냅샷 기반으로 전환했다. + +```java +// OrderTest.java +Order.ItemSnapshot snap1 = new Order.ItemSnapshot(1L, "상품A", 10000, "브랜드A", 2); +Order.ItemSnapshot snap2 = new Order.ItemSnapshot(2L, "상품B", 5000, "브랜드B", 3); +Order order = Order.create(1L, List.of(snap1, snap2)); +``` + +OrderItemTest는 같은 패키지이므로 직접 생성자를 호출해서 단위 테스트할 수 있다. 이건 의도된 설계다 — 같은 Aggregate 내부에서는 접근이 자유로워야 한다. + +--- + +## 돌아보며 + +처음에는 "Facade에서 OrderItem 만드는 게 뭐가 문제지?"라고 생각했다. 동작은 똑같으니까. 하지만 **설계는 "지금 동작하느냐"가 아니라 "6개월 후에도 의도대로 사용되느냐"의 문제**다. + +package-private 생성자 하나 바꾼 것뿐인데, 효과는 크다. + +- **Aggregate Root(Order)가 자식(OrderItem)의 생성을 통제**한다 +- **외부에서 우회할 수 없다** — 컴파일 타임에 강제된다 +- **Facade는 데이터만 전달하고, 도메인 객체 생성은 도메인이 한다** — 레이어 책임이 명확해진다 + +"하지 마세요"라고 문서에 쓰는 것보다, 아예 못하게 만드는 게 낫다. diff --git a/blog/week4-like-without-counting.md b/blog/week4-like-without-counting.md new file mode 100644 index 0000000000..8dcb7e55b9 --- /dev/null +++ b/blog/week4-like-without-counting.md @@ -0,0 +1,80 @@ +> **TL;DR**: `Product.likeCount` 컬럼을 제거하고 `COUNT(*)`로 파생시켰다. 저장한 값을 지키려고 락을 거는 것보다, 저장하지 않아서 락이 필요 없는 구조가 낫다. + +--- + +## 더 강한 잠금이 답일까 + +동시성 문제를 만나면 본능적으로 "더 강한 잠금"을 떠올린다. 비관적 락이 안 되면 분산 락, 분산 락이 안 되면 큐잉. 하지만 이번에 배운 건, 진짜 해결은 **잠글 대상을 없애는 것**이었다. + +--- + +## 좋아요가 주문을 막고 있었다 + +기존 구조는 이랬다. + +``` +좋아요 추가 → Product.likeCount++ → Product 행 락 필요 +주문 처리 → Product.stock-- → Product 행 락 필요 +⇒ 좋아요와 주문이 같은 Product 행을 두고 경합 +``` + +좋아요 100명이 동시에 누르면 Product 행에 비관적 락이 100번 순차 실행된다. 그런데 그 와중에 누군가 그 상품을 주문하면? 주문도 Product 행의 `stock`을 바꿔야 하니까 좋아요 락이 풀릴 때까지 **대기한다**. + +좋아요와 재고는 아무 관계가 없는데, 같은 행에 있다는 이유만으로 서로를 블로킹한다. + +비관적 락을 낙관적 락으로 바꿔도 본질은 같다. `likeCount`라는 컬럼이 Product에 있는 한, 좋아요가 주문의 성능에 영향을 준다. + +--- + +## 세 가지 선택지 + +| 방식 | 경합 | 단점 | +|------|------|------| +| A. 비관적 락으로 `likeCount` 갱신 | 좋아요 vs 좋아요, 좋아요 vs 주문 | 무관한 도메인 간 경합 | +| B. DB 원자 UPDATE (`SET likeCount = likeCount + 1`) | 좋아요 vs 주문 (행 잠금은 여전히 발생) | 경합 줄지만 완전 제거 아님 | +| C. `likeCount` 제거, `COUNT(*)` 파생 | **없음** | 매 조회마다 COUNT 쿼리 | + +--- + +## 잠글 대상을 없앤다 + +C를 선택했다. + +``` +좋아요 추가 → Like 행 INSERT (UNIQUE 제약으로 중복 방지) +좋아요 수 → SELECT COUNT(*) FROM likes WHERE product_id = ? +⇒ Product 행을 건드리지 않음. 잠글 대상이 없음. +``` + +"매번 COUNT하면 느리지 않나?"라는 반론이 있을 수 있다. 맞다. `likeCount` 컬럼을 읽는 것보다 `COUNT(*)`가 느리다. 하지만 이건 **읽기 성능 vs 쓰기 경합**의 트레이드오프다. + +`likeCount`를 저장하면 읽기는 빠르지만, 쓰기(좋아요 추가/삭제)마다 Product 행 경합이 발생하고 주문에까지 영향을 준다. `COUNT(*)`로 파생하면 읽기에 인덱스 스캔이 필요하지만, 쓰기 경합이 완전히 사라진다. + +읽기 최적화가 필요해지면 `product_id`에 인덱스가 걸려 있으므로 COUNT 성능은 충분하고, 그래도 부족하면 캐시를 도입하면 된다. 경합을 구조적으로 없앤 다음에 읽기를 최적화하는 순서가 맞다. + +--- + +## 정규화 논의에 동시성을 더하면 + +정규화/역정규화 논의에서 "파생 가능한 값을 저장하면 조회가 빨라진다"는 일반적으로 옳다. 하지만 동시성이 개입하면 이야기가 달라진다. + +저장한 값은 **지켜야 할 대상**이 되고, 지키려면 **잠금**이 필요하고, 잠금은 **경합**을 만든다. + +``` +저장 → 보호 필요 → 잠금 → 경합 → 성능 저하 +미저장 → 보호 불필요 → 잠금 없음 → 경합 없음 +``` + +`likeCount`를 제거하자 좋아요와 주문의 경합이 사라졌다. Product 행에 비관적 락을 걸어도 좋아요 때문에 대기하는 일이 없어졌다. 동시성 문제를 "더 강한 잠금"이 아니라 "잠글 대상 제거"로 해결한 셈이다. + +--- + +## 돌아보며 + +이번 과제에서 재고, 쿠폰, 좋아요 세 도메인에 각각 다른 동시성 전략을 적용했다. 재고는 비관적 락, 쿠폰은 조건부 UPDATE, 좋아요는 UNIQUE + COUNT 파생. + +세 전략을 관통하는 질문은 하나였다. **"이 값을 저장해야 하는가, 파생할 수 있는가?"** + +재고는 저장해야 한다 — 차감이라는 도메인 규칙이 있으니까. 쿠폰 상태도 저장해야 한다 — 전이 이력이 필요하니까. 하지만 좋아요 수는? `COUNT(*)`로 언제든 계산할 수 있다. 계산할 수 있는 값을 굳이 저장해서 경합을 만들 이유가 없었다. + +**파생 가능한 값을 저장하는 것이 항상 옳지는 않다 — 저장 자체가 경합을 만들기도 한다.** diff --git a/blog/week4-stock-rollback-decision.md b/blog/week4-stock-rollback-decision.md new file mode 100644 index 0000000000..37076d8f46 --- /dev/null +++ b/blog/week4-stock-rollback-decision.md @@ -0,0 +1,87 @@ +> **TL;DR**: 조건부 UPDATE가 성능상 우위인 걸 알면서도 비관적 락으로 되돌렸다. 도메인 로직이 인프라 레이어로 유출되는 것을 "최적화"라고 부를 수는 없었기 때문이다. + +--- + +## 커밋 두 개가 반대 방향을 가리킨다 + +``` +feat: 재고를 조건부 UPDATE로 전환 (비관적 락 제거) +refactor: 재고를 비관적 락 + 도메인 엔티티 방식으로 복원 +``` + +같은 PR 안에서, 같은 사람이, 같은 코드를 되돌렸다. 실수가 아니다. 의식적 결정이다. + +--- + +## 두 가지 방법 + +재고 차감에는 두 가지 방법이 있다. + +```java +// 방법 A: 비관적 락 + 도메인 엔티티 +Product product = repo.findByIdWithLock(id); // SELECT FOR UPDATE +product.decreaseStock(quantity); // Stock.decrease() — 도메인 규칙 +// dirty checking → UPDATE + +// 방법 B: 조건부 UPDATE +int updated = repo.decreaseStock(id, quantity); +// → UPDATE product SET stock = stock - :qty WHERE stock >= :qty +if (updated == 0) throw new CoreException(...); +``` + +방법 B가 객관적으로 낫다. 락 보유 시간이 짧고, 엔티티를 메모리에 로딩할 필요도 없다. `read-modify-write` 패턴 자체를 제거하니 동시성 안전성도 구조적으로 더 강하다. + +그래서 B로 바꿨다. 테스트도 통과했다. PR에 올렸다. 그리고 되돌렸다. + +--- + +## 바꾸고 나서 불편했던 것 + +코드를 다시 읽었을 때, 불편한 게 하나 있었다. + +```java +// 재고 차감 — 조건부 UPDATE +int updated = productRepository.decreaseStock(req.productId(), req.quantity()); +if (updated == 0) throw new CoreException(ErrorType.BAD_REQUEST, "재고가 부족합니다."); +``` + +`Stock.decrease()`가 어디에도 호출되지 않는다. 도메인 객체에 "재고가 음수가 되면 안 된다"는 규칙이 있는데, 정작 주문 흐름에서는 그 규칙이 JPQL WHERE절로 옮겨갔다. `Stock` VO는 존재하지만, 주문에서는 사실상 죽은 코드다. + +문제를 정리하면 이렇다. + +| 관점 | 비관적 락 + 도메인 엔티티 | 조건부 UPDATE | +|------|--------------------------|--------------| +| 성능 | 락 대기 발생 | 단일 SQL, 락 최소화 | +| 도메인 캡슐화 | `Stock.decrease()` 호출 | `Stock.decrease()` 미사용 | +| 비즈니스 규칙 위치 | 도메인 객체 내부 | JPQL WHERE절 | +| 테스트 | Fake Repository로 단위 테스트 가능 | @SpringBootTest 필수 | + +성능은 B가 낫다. 하지만 "재고 부족하면 예외"라는 비즈니스 규칙이 도메인 객체가 아니라 인프라스트럭처에 있게 된다. 그리고 이 시점에서 재고 차감의 성능 병목이 증명된 적은 없다. + +증명된 병목 없이 도메인 로직을 인프라로 이동시키는 것을 "최적화"라고 부르기 어렵다. 그건 **과도한 최적화**다. + +--- + +## 되돌린 이유 + +같은 PR에서 쿠폰 사용에도 조건부 UPDATE를 적용했다. 쿠폰은 되돌리지 않았다. + +차이는 **도메인 로직의 무게**에 있다. 쿠폰의 상태 전이(`AVAILABLE → USED`)는 단순 플래그 변경이다. 반면 재고 차감은 `Stock` VO가 음수 방지, 수량 검증을 캡슐화하고 있다. 이 로직이 WHERE절로 흡수되면, 도메인 모델의 존재 의미가 희석된다. + +결국 "모든 동시성 제어를 조건부 UPDATE로 통일"하는 대신, **도메인의 특성에 따라 전략을 분화**하는 방향을 택했다. + +| 대상 | 전략 | 이유 | +|------|------|------| +| 재고 | 비관적 락 + 도메인 엔티티 | 도메인 규칙이 무거움 | +| 쿠폰 | 조건부 UPDATE | 상태 전이가 단순 | +| 좋아요 | UNIQUE + COUNT 파생 | 잠글 대상 자체를 제거 | + +--- + +## 돌아보며 + +되돌리는 커밋을 만들면서 한 가지를 배웠다. 설계에서 "더 좋은 방법"은 단일 축으로 판단할 수 없다. 성능축에서는 조건부 UPDATE가 우세하지만, 캡슐화축에서는 비관적 락이 우세하다. 둘 다 틀리지 않았다. + +다만, 성능 최적화는 병목이 증명된 후에 해도 늦지 않다. 도메인 캡슐화가 깨지면 코드를 읽는 모든 사람이 "재고 차감 규칙이 어디에 있지?"를 매번 추적해야 한다. + +**더 빠른 방법을 아는 것과, 그걸 지금 적용하는 것은 다른 문제다.** diff --git a/blog/week5-read-optimization.md b/blog/week5-read-optimization.md new file mode 100644 index 0000000000..ff22f8596f --- /dev/null +++ b/blog/week5-read-optimization.md @@ -0,0 +1,301 @@ +# 좋아요 순으로 정렬하자 서버가 하염없이 느려졌다 + +> **TL;DR** — 상품 1000만 건 환경에서 좋아요순 정렬 API가 308초 걸리던 것을, 비정규화 + 인덱스 + 페이지네이션 + Redis 캐시 조합으로 14ms까지 개선했다. 핵심은 캐시가 아니라 인덱스와 비정규화였다. + +--- + +## 1. 문제 — 상품 목록에 좋아요 수를 포함하려고 했더니 + +4주차까지의 시스템은 **쓰기 정합성**에 집중한 설계였다. 좋아요와 주문이 같은 `Product` 행에서 경합하는 문제가 있었고, 이를 해결하기 위해 `Product.likeCount` 컬럼을 아예 제거하고 `COUNT(*)`로 매번 계산하는 방식을 선택했다. + +쓰기 경합은 깔끔하게 해결됐다. 그런데 상품이 10만 건을 넘어가면서 문제가 보이기 시작했다. 상품 목록을 좋아요순으로 정렬해서 보여주려면, 매 요청마다 이런 일이 벌어졌다. + +``` +GET /api/v1/products?sort=likes_desc + +1. 전체 상품 SELECT (페이지네이션 없음) +2. SELECT product_id, COUNT(*) FROM likes GROUP BY product_id ← 매번 전체 집계 +3. Java Comparator로 메모리에서 정렬 +4. 결과: 전체 상품을 통째로 반환 +``` + +처음에는 "좀 느리네" 정도였는데, 데이터를 1000만 건까지 넣어보니 상황이 달라졌다. + +| 결함 | 10만 건 | 1000만 건 | +|------|--------|----------| +| 전량 로딩 + in-memory sort | 2초 | **308초** | +| 매 요청마다 COUNT 집계 | Full Scan | 9,955,217 rows | +| 캐시 부재 | 매번 DB 직격 | 매번 DB 직격 | + +10만 건에서는 "느리다" 수준이었지만, 1000만 건에서는 **한 번의 요청이 5분이 넘는 서비스 불능 상태**가 됐다. "이건 튜닝으로 해결할 수 있는 문제가 아니라 구조를 바꿔야 하는 문제다"라는 판단이 들었다. + +--- + +## 2. 과제 ② — 좋아요 수 정렬 구조 개선: 정합성과 성능 사이의 트레이드오프 + +과제 3개 중 이 부분을 먼저 다루는 이유가 있다. 인덱스(①)와 캐시(③)는 이 구조가 정해진 뒤에 얹는 것이기 때문이다. + +### 한 주 전에 제거한 컬럼을 다시 추가해야 했다 + +4주차에서 `Product.likeCount`를 제거한 건 명확한 이유가 있었다. + +> "좋아요를 누를 때마다 Product 행을 UPDATE 해야 하고, 주문도 같은 행에 접근한다. 두 트랜잭션이 같은 행에서 충돌하면서 데드락이 발생한다." + +해법은 간단했다. likeCount를 저장하지 않으면 경합 자체가 없다. 대신 `COUNT(*)`로 매번 계산하면 된다. + +그런데 5주차에서 10만 건 이상의 데이터를 넣어보니, 바로 그 `COUNT(*)`가 병목이 됐다. 처음에는 "캐시를 넣으면 해결되지 않을까?"라고 생각했다. 하지만 조금 더 생각해보니, 캐시 미스가 발생하면 결국 같은 문제가 반복된다는 걸 깨달았다. + +### 선택지 비교 + +| 대안 | 장점 | 단점 | +|------|------|------| +| **A: 현행 유지 (COUNT 파생)** | 정규화 유지, 쓰기 경합 없음 | 10만 건에서 이미 2초, 1000만 건에서 308초 | +| **B: likeCount 비정규화 (atomic SQL)** | 인덱스 활용 가능, DB 정렬 가능 | 쓰기 시 UPDATE 1회 추가 | +| **C: Materialized View** | 원본 데이터와 분리 | MySQL은 MV 미지원, 실시간성 부족 | + +**B를 선택하되, C를 안전망으로 함께 두기로 했다.** 비정규화로 실시간 반영하고, MV 시뮬레이션(`product_like_stats` + 배치 Job)으로 혹시 모를 드리프트를 보정한다. + +### 4주차 → 5주차: 같은 판단을 뒤집은 이유 + +| 주차 | 우선순위 | 전략 | 근거 | +|------|---------|------|------| +| 4주차 | 쓰기 경합 해소 > 읽기 성능 | likeCount 제거 | 좋아요/주문이 같은 행에서 충돌 | +| 5주차 | 읽기 성능 > 쓰기 경합 | likeCount 재도입 | 전량 로딩이 서비스 불능 유발 | + +처음에는 "한 주 전에 한 결정을 뒤집는 게 맞나?" 하는 고민이 있었다. 하지만 생각해보면 이건 모순이 아니라 **컨텍스트가 바뀐 것**이다. 4주차에서는 쓰기 경합이 가장 아팠고, 5주차에서는 읽기 병목이 더 심각했다. 같은 컬럼이지만 판단 기준이 달라졌다. + +### 경합은 어떻게 최소화했는가 + +다시 도입하더라도 4주차의 문제를 반복하면 안 된다. 핵심은 **atomic SQL**이다. + +```sql +-- AS-IS: 엔티티를 로딩해서 값을 바꾸고 다시 저장 (read-modify-write) +SELECT * FROM product WHERE id = 1 FOR UPDATE; -- 행 잠금 획득 +-- (비즈니스 로직 수행...) +UPDATE product SET like_count = 101 WHERE id = 1; +COMMIT; -- 여기서야 잠금 해제 + +-- TO-BE: 엔티티를 로딩하지 않음 (atomic SQL) +UPDATE product SET like_count = like_count + 1 +WHERE id = 1 AND deleted_at IS NULL; +-- UPDATE 문 실행 완료 = 잠금 해제 (마이크로초) +``` + +4주차의 비관적 락은 `SELECT FOR UPDATE` 시점부터 트랜잭션 종료까지 행을 잠갔다. atomic SQL은 UPDATE 문 실행 시간(마이크로초)만 잠금을 유지한다. 본질적으로 다른 수준의 경합이다. + +100개 스레드로 동시에 좋아요를 눌렀을 때 `Product.likeCount`와 `COUNT(*)`가 일치하는지 동시성 테스트로 검증했고, 정합성이 유지됨을 확인했다. + +--- + +## 3. 과제 ① — 상품 목록 조회 성능 개선: 인덱스, 그리고 EXPLAIN이 보여준 것 + +### 인덱스를 왜 이렇게 설계했는가 + +인덱스 설계에서 고민한 점은 "몇 개를 만들 것인가"보다 "어떤 조회 시나리오를 커버할 것인가"였다. + +실제 API에서 사용되는 조합을 분석해보면: +- 전체 상품 + 좋아요순 → 가장 빈번한 정렬 +- 브랜드 필터 + 좋아요순 → 브랜드 페이지에서 사용 +- 브랜드 필터 + 가격순 → 가격 비교 시 +- 좋아요 카운트 조회 → 상세 페이지에서 사용 + +각 시나리오에 맞는 복합 인덱스를 설계했다. + +| 인덱스 | 컬럼 | 커버하는 시나리오 | +|--------|------|-----------------| +| `idx_product_like_count` | `(like_count DESC, id DESC)` | 전체 + 좋아요순 | +| `idx_product_brand_like_count` | `(brand_id, like_count DESC, id DESC)` | 브랜드 필터 + 좋아요순 | +| `idx_product_brand_price` | `(brand_id, price ASC, id ASC)` | 브랜드 필터 + 가격순 | +| `idx_likes_product_id` | `(product_id)` | 좋아요 카운트 (커버링 인덱스) | + +단일 컬럼 인덱스(`like_count`만)로도 충분하지 않을까 고민했지만, 복합 조건(브랜드 필터 + 좋아요 정렬)에서는 단일 인덱스로는 filesort가 발생한다. 인덱스에 정렬 컬럼까지 포함해야 DB가 정렬 없이 이미 정렬된 데이터를 반환할 수 있다. + +### EXPLAIN이 보여준 것 — AS-IS vs TO-BE + +**좋아요순 정렬 (가장 비싼 쿼리)** + +AS-IS: +``` +10만 건: type=ALL | key=NULL | rows=99,770 | Extra=Using where +1000만 건: type=index | key=PRIMARY | rows=9,955,217 | Extra=Using temporary; Using filesort +``` + +TO-BE: +``` +10만 건: type=index | key=idx_product_like_count | rows=20 | Extra=Using where +1000만 건: type=index | key=idx_product_like_count | rows=20 | Extra=Using where +``` + +| 데이터 규모 | AS-IS rows | TO-BE rows | 감소율 | +|------------|-----------|-----------|--------| +| 10만 건 | 99,770 | 20 | **4,988배** | +| **1000만 건** | **9,955,217** | **20** | **497,760배** | + +이 결과를 처음 봤을 때 인상적이었던 건, 데이터가 100배 증가해도 TO-BE의 스캔 행이 **변하지 않는다**는 점이었다. 인덱스가 정렬을 담당하면 LIMIT 수만큼만 읽으면 되기 때문이다. 1000만 건이든 1억 건이든 rows=20이다. + +### 페이지네이션 — 의외로 중요했던 변경 + +처음에는 페이지네이션을 "있으면 좋은 것" 정도로 생각했다. 그런데 나중에 성능 테스트를 하면서 생각이 바뀌었다. + +```java +// AS-IS: 전량 반환 +public List getAllProducts(String sort) { + List products = productRepository.findAllWithBrand(sort); + return enrichWithLikeCount(products); // 10만 건을 통째로 +} + +// TO-BE: 페이지 단위 반환 +public PagedProductResponse getAllProducts(String sort, int page, int size) { + Pageable pageable = PageRequest.of(page, size, toSort(sort)); + Page products = productRepository.findAllWithBrand(pageable); + return PagedProductResponse.from(products); // 20건만 +} +``` + +10만 건 테스트에서 캐시 없이 인덱스만 있는 no-cache 엔드포인트가 P95=5,830ms로 실패했다. 그래서 "캐시가 핵심이다"라고 결론을 내렸었다. + +그런데 1000만 건에서 같은 no-cache가 P95=**67ms**로 통과했다. 뭐가 달라졌는가? + +10만 건 테스트 시점에는 페이지네이션이 완전히 적용되기 전이라 **10만 건을 통째로 응답**하고 있었다. 1000만 건에서는 20건만 반환한다. 실패의 진짜 원인은 "캐시 부재"가 아니라 **"전량 반환"**이었다. + +--- + +## 4. 과제 ③ — 캐시 적용: TTL, 키 설계, 무효화 기준은 어떻게 결정했는가 + +### @Cacheable vs RedisTemplate 직접 사용 + +처음에는 Spring의 `@Cacheable`을 쓰려고 했다. 어노테이션 하나면 끝나니까. 하지만 고민해보니 몇 가지 걸리는 점이 있었다. + +| 관점 | @Cacheable | RedisTemplate 직접 | +|------|-----------|-------------------| +| 코드 간결성 | 매우 간결 | 직접 처리 필요 | +| 캐시 흐름 가시성 | AOP로 감춰짐 | 명확히 보임 | +| TTL 제어 | 전역 또는 CacheManager 커스텀 | 메서드별 세밀 제어 | + +이미 Master-Replica Redis 토폴로지가 구축되어 있었고, Master로 쓰기/무효화, Replica로 읽기를 분리하고 싶었다. `@Cacheable`로는 이 구분이 어려웠다. 결국 **RedisTemplate 직접 사용**을 선택했다. + +### 캐시 키와 TTL 설계 + +| 대상 | 키 패턴 | TTL | 이유 | +|------|--------|-----|------| +| 상품 상세 | `product:detail:{id}` | 10분 | 변경 빈도 낮음, 긴 TTL 가능 | +| 상품 목록 | `product:list:v{ver}:brand:{id}:sort:{sort}:page:{page}` | 5분 | 좋아요로 순서가 바뀔 수 있어 짧게 | + +목록 TTL을 5분으로 잡은 건, 좋아요가 자주 바뀌는 인기 상품에서 **너무 오래된 순서를 보여주지 않기 위함**이다. 10분으로 늘리면 캐시 히트율은 올라가지만, 사용자가 "방금 좋아요를 눌렀는데 순서가 안 바뀌네"라고 느낄 수 있다. + +### 무효화 전략 — 왜 SCAN을 쓰지 않았는가 + +목록 캐시 무효화에서 가장 고민한 부분은 **"어떻게 관련 키들을 한번에 지울 것인가"**였다. + +| 대안 | 방식 | 문제 | +|------|------|------| +| A: `KEYS` / `SCAN` 패턴 삭제 | `DEL product:list:*` | O(N) 스캔, 키가 많으면 Redis 블로킹 | +| B: 버전 카운터 INCR | `INCR product:list:version` | O(1), 이전 키는 TTL 만료로 자연 소멸 | + +키 카디널리티가 `brand × sort × page` 조합으로 폭발할 수 있어서, `SCAN`으로 일일이 찾아 지우는 건 위험하다고 판단했다. **버전 카운터를 1 올리면**, 새 요청은 새 버전의 키로 캐시를 찾고, 이전 버전 키들은 TTL 5분 후 자연 소멸한다. + +### Redis가 죽으면? + +캐시는 **성능 최적화 계층이지 필수 의존이 아니다**. 이 원칙을 지키기 위해, 모든 캐시 조회/저장을 try-catch로 감쌌다. Redis가 죽으면 예외를 삼키고 DB를 직접 조회한다. + +실제로 1000만 건에서 캐시 없이(no-cache 엔드포인트) 200 RPS 부하 테스트를 돌렸을 때, P95=67ms, 에러율 0%로 안정적이었다. 캐시가 없어도 인덱스 + 비정규화 + 페이지네이션이 갖춰져 있으면 서비스는 유지된다. + +--- + +## 5. 성능 검증 — K6 부하 테스트 + Grafana 모니터링 + +### A/B 비교 엔드포인트 + +최적화 효과를 각각 분리해서 측정하고 싶었다. 그래서 3개의 엔드포인트를 만들었다. + +| 엔드포인트 | 인덱스 | 캐시 | 비정규화 | 역할 | +|-----------|--------|------|----------|------| +| `GET /products` | O | O | O | **TO-BE** (전체 최적화) | +| `GET /products/no-cache` | O | X | O | 캐시 효과 단독 측정 | +| `GET /products/no-optimization` | O | X | X | **AS-IS 재현** | + +### K6 결과 + +**10만 건:** + +| 시나리오 | P95 | 에러율 | 처리량 | +|---------|-----|--------|--------| +| 최적화 후 (캐시 O) | **23ms** | 0% | 141 rps | +| 캐시 미적용 | 5,830ms | 12% | 54 rps | +| AS-IS | 9,710ms | **99.4%** | 31 rps | + +**1000만 건 (buffer_pool 4GB):** + +| 시나리오 | P95 | 에러율 | 처리량 | +|---------|-----|--------|--------| +| **최적화 후 (캐시 O)** | **14ms** | **0%** | 141 rps | +| **캐시 미적용** | **67ms** | **0%** | 141 rps | +| AS-IS | 단건 **308초** | — | 부하 테스트 불가 | + +AS-IS는 1000만 건에서 한 건 처리하는 데 5분이 넘어서, K6 부하 테스트 자체가 불가능했다. + +### Grafana에서 관측한 것 + +| 패널 | AS-IS | TO-BE | +|------|-------|-------| +| P95 Response Time | ~30초 폭등 | ms 단위 (바닥) | +| HikariCP Active | DB 커넥션 40개 포화 | 1~2개 사용 | +| JVM Heap (Old Gen) | **4GB까지 급증** (전량 로딩) | 안정 | +| 70초간 처리량 | **1건** | **9,900건** | + +Grafana에서 JVM Heap 그래프를 보는 순간, AS-IS의 문제가 직관적으로 와닿았다. 1000만 건을 메모리에 통째로 올리면서 Old Gen이 4GB까지 치솟는 모습은, "이건 캐시로 해결할 문제가 아니라 구조를 바꿔야 하는 문제"라는 확신을 줬다. + +### 스케일에 따른 변화 + +``` + AS-IS 응답 시간 TO-BE 응답 시간 + 308초 ┤ ■ 14ms ┤ ■ ■ + │ │ + │ │ + │ │ + 2초 ┤ ■ │ + └───────────── └───────────── + 10만 1000만 10만 1000만 + + → AS-IS: 데이터 100배 → 응답 150배 (비선형 악화) + → TO-BE: 데이터 100배 → 응답 동일 (인덱스 = O(1)) +``` + +--- + +## 6. 돌아보며 — 배운 것과 아쉬운 것 + +### 최적화의 순서가 중요하다 + +이번에 가장 크게 배운 점이다. 처음에는 "캐시만 넣으면 되겠지"라고 생각했지만, 실제로 측정해보니 순서가 중요했다. + +``` +① 비정규화 + 인덱스 (DB 레벨 최적화) — 본질적 해결 +② 페이지네이션 (응답 크기 제한) — 전량 반환 제거 +③ 캐시 (Redis) — 강력한 보너스, 하지만 ①②가 없으면 miss 시 붕괴 +``` + +캐시는 ①②가 갖춰진 위에 얹는 것이다. ①②없이 캐시만 넣으면, 캐시 미스 한 번에 서비스가 무너진다. 실제로 10만 건에서 no-cache가 실패한 원인이 "캐시 부재"가 아니라 "전량 반환"이었다는 걸 1000만 건 테스트에서야 깨달았다. + +### 비정규화의 핵심은 "할 것인가"가 아니라 "정합성을 어떻게 보장할 것인가" + +정규화 교과서에서 비정규화는 위험한 선택으로 서술된다. 처음에는 나도 그 관점에서 망설였다. 하지만 실제로 해보니, 중요한 건 비정규화를 하느냐 마느냐가 아니라 **정합성을 어떻게 보장하느냐**였다. + +| 계층 | 보장 수단 | 시점 | +|------|----------|------| +| 트랜잭션 | Like INSERT/DELETE와 같은 `@Transactional` 경계 | 실시간 | +| atomic SQL | `like_count = like_count + 1` | 실시간 | +| MV 시뮬레이션 | `product_like_stats` + 배치 Job | 주기적 | +| 동시성 테스트 | 100 스레드 정합성 검증 | 개발 시 | + +다층으로 보장하면 비정규화의 위험은 통제 가능하다. + +### 같은 설계도 컨텍스트에 따라 정반대의 판단이 될 수 있다 + +4주차에서 likeCount를 제거한 것도 옳았고, 5주차에서 다시 추가한 것도 옳다. "이전 결정을 뒤집었다"가 아니라 "트레이드오프의 축이 바뀌었다"라고 이해하는 게 맞다고 생각한다. + +### 아쉬운 점 + +- **커서 기반 페이지네이션**을 도입하지 못했다. 현재 OFFSET 방식은 깊은 페이지(page=5000)에서 성능이 저하된다. 초기 페이지 위주의 트래픽이라 지금은 괜찮지만, 향후 과제다. +- **캐시 스탬피드 방지**를 적용하지 못했다. 인기 상품의 캐시가 만료되는 순간 다수의 요청이 DB로 동시에 몰리는 문제가 있을 수 있다. TTL jitter나 분산 락(SETNX)을 고려해볼 수 있다. +- **10만 건 테스트에서 잘못된 결론**을 낸 적이 있다. no-cache 실패를 "캐시의 중요성"으로 해석했지만, 진짜 원인은 전량 반환이었다. 1000만 건 테스트를 하지 않았다면 이 오해를 안 채로 끝났을 것이다. 성능 테스트는 프로덕션 규모에서 해야 의미가 있다는 걸 체감했다. diff --git a/blog/week6-circuit-breaker-on-reads.md b/blog/week6-circuit-breaker-on-reads.md new file mode 100644 index 0000000000..fff023f3bb --- /dev/null +++ b/blog/week6-circuit-breaker-on-reads.md @@ -0,0 +1,257 @@ +서킷브레이커 적용 기준: 결제 복구 경로는 왜 차단하면 안 되는가 + + +> > **TL;DR**: PG 결제 요청에 서킷브레이커를 걸었다. 당연히 상태 조회에도 걸었다. 그러자 복구 경로 세 개가 전부 멈췄다. 서킷브레이커는 "호출 자체를 차단"하는 도구다. 차단해도 되는 호출과 차단하면 안 되는 호출을 구분하지 않으면, 보호가 아니라 마비가 된다. + +--- + +## 서킷브레이커는 try-catch가 아니다 + +서킷브레이커를 처음 도입할 때 한 가지 오해를 했다. "외부 호출을 안전하게 감싸는 것"이라고 생각한 것이다. 그러면 try-catch와 뭐가 다른가? + +``` +// try-catch: 호출은 한다. 실패하면 잡는다. +try { + pgClient.requestPayment(request); // ← 1초 타임아웃까지 대기 +} catch (Exception e) { + return fallbackResponse(); +} + +// 서킷브레이커: 호출 자체를 하지 않는다. +@CircuitBreaker(name = "pg-request") +public PgPaymentResponse requestPayment(request) { + return pgClient.requestPayment(request); // ← CB Open이면 여기에 도달하지 않음 +} +``` + +try-catch는 호출을 하고 실패를 수습한다. 서킷브레이커는 **호출 자체를 막는다**. 이 차이가 왜 중요한가? + +PG가 완전히 죽었다고 가정하자. 초당 100건의 결제 요청이 들어온다면 어떻게 될까? + +| 보호 방식 | 동작 | 스레드 점유 | +|-----------|------|------------| +| try-catch (타임아웃 1초) | 100건 × 1초 대기 후 실패 | **100 스레드 × 1초 = 100 스레드·초** | +| CB Open | 100건 × 즉시 예외 (0ms) | **0 스레드·초** | + +try-catch만으로 보호하면, PG가 죽어있는 동안 매 초 100개의 스레드가 1초씩 아무것도 하지 못한 채 대기한다. Retry까지 걸려 있으면 `100 × 3회 × 1초 = 300 스레드·초`다. 톰캣 기본 스레드 풀이 200개인 걸 생각하면, **1초 만에 스레드 풀이 고갈**된다. + +서킷브레이커는 이걸 막는다. Open 상태에서는 PG에 요청을 보내지 않으니, 스레드가 대기하지 않는다. 실패할 게 뻔한 호출에 스레드를 낭비하지 않는 것 — 이게 서킷브레이커의 존재 이유다. + +--- + +## 그래서 모든 외부 호출에 CB를 걸었다 + +이 원리를 이해하면 자연스러운 결론에 도달한다. "외부 호출에는 전부 CB를 걸자." + +결제 시스템의 외부 호출은 크게 두 종류다. + +``` +[쓰기] POST /api/v1/payments → PG에 결제를 요청한다 +[읽기] GET /api/v1/payments/:id → PG에 결제 상태를 확인한다 +``` + +둘 다 PG라는 외부 시스템을 호출한다. PG가 죽으면 둘 다 실패한다. 스레드 밀림 위험도 동일하다. CB를 거는 게 당연해 보인다. + +처음에는 그렇게 설계했다. + +```java +@CircuitBreaker(name = "pgSimulator-request") +public PgPaymentResponse requestPayment(PgPaymentRequest request) { ... } + +@CircuitBreaker(name = "pgSimulator-status") +public PgPaymentStatusResponse getPaymentStatus(String transactionKey) { ... } +``` + +--- + +## 읽기에 CB를 걸었더니 복구가 멈췄다 + +문제는 "읽기"가 단순한 조회가 아니라는 데 있었다. + +결제 시스템에서 상태 조회는 **복구 행위**다. 결제 요청이 타임아웃 나면 내부 상태는 `UNKNOWN`이 된다. 돈이 빠져나갔는지 아닌지 모르는 상태다. 이걸 해결하는 유일한 방법은 PG에 "이 결제 됐어?"라고 물어보는 것이다. + +이 질문을 던지는 경로가 세 개 있다. + +``` +[복구 경로 1] Outbox Poller — 5초마다 + → Payment 생성 후 PG 호출 전에 장애 → Outbox에서 재시도 + → GET /payments?orderId=xxx ← PG 읽기 + +[복구 경로 2] Polling Hybrid — 10초 후 + → PG 콜백 미수신 시 직접 확인 + → GET /payments/{transactionKey} ← PG 읽기 + +[복구 경로 3] Batch Recovery — 1분마다 + → 위 두 경로가 다 실패한 건의 최종 안전망 + → GET /payments/{transactionKey} ← PG 읽기 +``` + +세 경로 모두 PG 상태 **읽기**에 의존한다. 이제 시나리오를 그려보자. + +``` +PG 결제 요청 대량 실패 +→ pgSimulator-request CB Open ← 쓰기 차단. 여기까진 정상. +→ pgSimulator-status CB도 Open ← 읽기도 차단. 여기서 문제. + +→ Outbox: "상태 확인해야 하는데 CB가 막는다" → 실패 +→ Polling: "상태 확인해야 하는데 CB가 막는다" → 실패 +→ Batch: "상태 확인해야 하는데 CB가 막는다" → 실패 + +→ UNKNOWN 상태 결제건 — 복구 불가 +→ 초당 1000건 기준, 2초면 2000건의 결제가 확인 지연 +``` + +보호하려고 건 CB가 복구를 막고 있었다. + +--- + +## 쓰기 CB는 차단해도 괜찮다 + +왜 쓰기 CB는 문제가 안 되는지 짚어보자. + +쓰기가 차단되면 **Fallback PG**가 있다. + +```java +// PgRouter.java — Primary 실패 시 Fallback으로 전환 +for (PgClient pgClient : pgClients) { + try { + return pgClient.requestPayment(request); + } catch (Exception e) { + if (isTimeoutException(e)) { + // 타임아웃 → Fallback 전환 안 함 (중복 결제 방지) + throw new CoreException(ErrorType.INTERNAL_ERROR, + "PG 타임아웃: " + pgClient.getProviderName() + + " (Fallback 전환 불가 — 중복 결제 방지)"); + } + // 그 외 → 다음 PG 시도 + lastException = e; + } +} +``` + +쓰기 CB가 Open되면 → 즉시 예외 → PgRouter가 다음 PG로 라우팅. 비즈니스가 계속 돌아간다. + +반면 읽기 CB가 Open되면? 상태 조회에는 Fallback PG라는 개념이 없다. 결제를 처리한 PG에만 물어볼 수 있다. Simulator PG로 결제했으면 Simulator PG에게만 "이거 됐어?"라고 물을 수 있다. **대체 경로가 없다.** + +| 호출 종류 | CB Open 시 대안 | CB 적용 | +|-----------|-----------------|---------| +| 결제 요청 (POST) | Fallback PG로 전환 | **적용** | +| 상태 조회 (GET) | **없음** — 해당 PG만 알고 있음 | **미적용** | + +--- + +## 그러면 읽기는 어떻게 보호하는가 + +CB를 빼면 스레드 밀림은 어떻게 막나? 다시 처음의 표를 보자. + +| 보호 방식 | 스레드 점유 | +|-----------|------------| +| try-catch (타임아웃 1초) | 100 스레드·초 | +| CB Open | 0 스레드·초 | + +읽기 호출의 트래픽 특성이 쓰기와 다르다. 쓰기는 사용자가 결제 버튼을 누를 때마다 발생한다 — 초당 100건. 읽기는 복구 배치에서 발생한다 — 분당 수십 건. + +``` +Outbox Poller: 5초 주기, 미처리 건만 조회 → 분당 ~12건 +Polling Hybrid: 10초 후 1회 → 건당 1회 +Batch Recovery: 1분 주기, 미처리 건 일괄 → 분당 수십 건 +``` + +스레드 풀을 위협할 트래픽이 아니다. try-catch + 타임아웃 1초면 충분하다. + +```java +// 최종 구현 — CB 없이, Timeout + try-catch만으로 보호 +@Override +public PgPaymentStatusResponse getPaymentStatus(String transactionKey) { + try { + return feignClient.getPaymentStatus(transactionKey); + } catch (Exception e) { + log.warn("PG 상태 확인 실패: transactionKey={}, error={}", + transactionKey, e.getMessage()); + return new PgPaymentStatusResponse("UNKNOWN", transactionKey, null); + } +} +``` + +실패해도 `UNKNOWN`을 반환한다. 호출자는 "아직 모르겠다, 다음에 다시 물어봐야지"로 처리한다. 5초 후 Outbox가, 10초 후 Polling이, 1분 후 Batch가 다시 시도한다. 셋 중 하나는 성공한다. + +--- + +## "그러면 DB에도 CB를 달아야 하나?" + +외부 호출에 CB를 거는 이유가 "스레드 밀림 방지"라면, DB도 외부 시스템 아닌가? 네트워크를 타고, 장애가 날 수 있고, 느려질 수 있다. + +결론부터 말하면, **안 단다**. + +이유 1 — DB에는 이미 커넥션 풀이 있다. + +``` +HikariCP 설정: + maximumPoolSize: 10 + connectionTimeout: 3000ms ← 3초 안에 커넥션 못 얻으면 예외 + +효과: PG 타임아웃과 동일 — 무한 대기 불가 +``` + +CB가 "실패할 게 뻔한 호출을 막아서 스레드를 아끼는" 도구라면, HikariCP의 `connectionTimeout`이 이미 그 역할을 한다. 커넥션을 3초간 못 얻으면 예외가 터진다. 스레드가 무한 대기하지 않는다. + +이유 2 — DB에는 Fallback이 없다. + +``` +PG가 죽으면 → Fallback PG로 전환 (비즈니스 계속 동작) +DB가 죽으면 → ??? (Fallback DB? 없다.) +``` + +PG에 CB를 거는 건 "차단한 후 대안으로 전환"하기 위해서다. DB를 차단하면? 갈 곳이 없다. DB는 SOT(Source of Truth)다. 대체할 수 있는 것이 아니다. + +이유 3 — DB 호출은 빠르다. + +``` +TX-0: CAS UPDATE 1건 → ~5ms +TX-1: INSERT 2건 → ~10ms +[PG 호출: 100ms~4.5초] ← 트랜잭션 밖 +TX-2: UPDATE 1건 → ~5ms + +DB 커넥션 점유 시간: ~30ms +PG 호출 대기 시간: ~4.5초 (최악) +``` + +PG 호출은 트랜잭션 밖에서 수행한다. DB 커넥션을 점유하는 시간은 30ms 수준이다. 초당 100건이면 `100 × 0.03초 = 3 커넥션·초` — HikariCP 10개면 30% 사용률이다. + +만약 PG 호출을 트랜잭션 안에서 했다면? `100 × 4.5초 = 450 커넥션·초` — **즉시 고갈**이다. DB에 CB를 다는 것보다, PG 호출을 트랜잭션 밖으로 빼는 것이 근본적인 해결이었다. + +--- + +## CB를 걸어야 하는 세 가지 조건 + +돌아보면, CB를 적용할지 말지는 세 가지 질문으로 판단할 수 있었다. + +| 질문 | Yes → CB | No → try-catch | +|------|----------|----------------| +| 실패 시 **대안 경로**가 있는가? | Fallback PG 전환 | 대안 없으면 차단 = 마비 | +| **대량 트래픽**이 밀릴 수 있는가? | 초당 100건 결제 | 분당 수십 건 복구 | +| 차단해도 **복구에 영향**이 없는가? | 쓰기 차단 → 복구와 무관 | 읽기 차단 → 복구 마비 | + +세 질문에 모두 Yes면 CB를 건다. 하나라도 No면 try-catch + 타임아웃이 낫다. + +최종 CB 인스턴스는 3개, 전부 쓰기 전용이다. + +``` +pgSimulator-request → Simulator PG 결제 요청 (POST) +pgToss-request → Toss PG 결제 요청 (POST) +redis-write → Redis 재고 차감 + 가주문 저장 +``` + +읽기에는 CB가 없다. DB에도 CB가 없다. 보호가 필요 없어서가 아니라, **CB라는 도구가 맞지 않아서**다. + +--- + +## 돌아보며 + +서킷브레이커를 "외부 호출 보호 패턴"으로 일반화하면 함정에 빠진다. 모든 외부 호출에 기계적으로 CB를 걸게 되고, 읽기 CB가 복구 경로를 막는 상황을 뒤늦게 발견하게 된다. + +서킷브레이커의 본질은 **"이 호출을 아예 하지 않겠다"**는 결정이다. 그 결정의 무게를 이해해야 한다. 호출을 안 하면 스레드는 아끼지만, 그 호출이 복구 경로였다면 시스템은 멈춘다. + +try-catch는 "실패를 수습하는 도구"이고, 서킷브레이커는 "실패할 호출을 차단하는 도구"다. 두 도구는 용도가 다르다. 어떤 호출은 실패해도 시도해야 한다. 복구가 그렇다. + +**보호의 대상이 아니라, 보호의 방식이 맞는지를 물어야 한다.** \ No newline at end of file diff --git a/blog/week6-series-2-outbox.md b/blog/week6-series-2-outbox.md new file mode 100644 index 0000000000..000417ef8d --- /dev/null +++ b/blog/week6-series-2-outbox.md @@ -0,0 +1,132 @@ +TX 커밋 후 누락된 PG 호출을 복구하는 법 — Transactional Outbox + + +> **TL;DR**: Payment와 Outbox를 같은 트랜잭션에서 저장한다. 서버가 TX-1 커밋 직후에 죽어도, 5초 후 Outbox Poller가 빠진 PG 호출을 대신 실행한다. + +--- + +## 빈틈이 생기는 지점 + +TX-1에서 Payment를 저장하고, 그다음에 PG를 호출한다. 이 사이에 서버가 죽으면 Payment는 DB에 있는데 PG 호출은 안 된 상태가 된다. + +``` +TX-1: Payment(REQUESTED) + Outbox(PENDING) → commit ✓ + ← 서버 크래시 +[PG 호출] ← 실행 안 됨 +``` + +Payment의 상태는 `REQUESTED`다. PG에 요청을 보냈다는 기록이 없다. 배치 복구(1분 주기)가 잡아내긴 하지만, 1분은 길다. + +--- + +## Outbox 없이 배치만 쓸 때의 문제 + +배치 복구만으로도 이 빈틈을 메울 수 있다. 1분마다 `REQUESTED` 상태인 Payment를 찾아서 PG에 다시 요청하면 된다. + +하지만 배치에는 한계가 있다. + +| 관점 | 배치만 | Outbox + 배치 | +|------|--------|--------------| +| 복구 지연 | 최대 1분 | 최대 5초 | +| 복구 대상 식별 | Payment 상태 기반 (암묵적) | Outbox 상태 기반 (명시적) | +| PG 호출 의도 | 추론해야 함 | 레코드로 보존 | + +Payment가 `REQUESTED` 상태라는 것만으로는 "PG 호출이 안 된 건"인지 "PG 호출은 했는데 응답 전에 죽은 건"인지 구분할 수 없다. Outbox는 "이 결제를 PG에 보내야 한다"는 의도 자체를 레코드로 남긴다. + +--- + +## Outbox의 동작 + +TX-1에서 Payment와 Outbox를 같은 트랜잭션으로 저장한다. + +```java +// TX-1 — Payment + Outbox 원자적 저장 +Payment payment = Payment.create(orderId, REQUESTED); +paymentRepository.save(payment); + +PaymentOutbox outbox = PaymentOutbox.create(payment.getId()); +outboxRepository.save(outbox); +// TX-1 commit → 둘 다 저장되거나, 둘 다 안 된다 +``` + +같은 트랜잭션이므로 Payment만 저장되고 Outbox는 안 되는 상황은 발생하지 않는다. + +Outbox Poller는 5초마다 `PENDING` 상태인 Outbox를 조회한다. + +``` +[Outbox Poller — 5초 주기] +1. PaymentOutbox에서 status = 'PENDING' 조회 +2. 각 건에 대해: + a. Payment 현재 상태 확인 + → 이미 PAID/FAILED → Outbox PROCESSED (다른 경로에서 처리됨) + b. PG에 orderId로 조회: "이 주문 결제 기록 있어?" + → 있음 → transactionKey 저장 + Outbox PROCESSED + → 없음 → PG 결제 요청 (POST) 실행 + c. retry_count 증가, 최대 3회 초과 시 Outbox FAILED +``` + +--- + +## 멱등성 확인이 먼저다 + +Outbox Poller가 PG 결제 요청을 보내기 전에, 먼저 PG에 "이 주문 기록 있어?"라고 물어본다. + +```java +// Outbox Poller — PG 호출 전 멱등성 확인 +PgPaymentStatusResponse existing = pgRouter.getPaymentByOrderId(orderId, pgProvider); + +if (existing != null && !"UNKNOWN".equals(existing.status())) { + // PG에 이미 기록이 있다 → 중복 요청 방지 + outbox.markProcessed(); + return; +} + +// PG에 기록이 없다 → 안전하게 결제 요청 +pgRouter.requestPayment(request); +``` + +이 순서가 중요하다. "서버가 PG 호출 직후, 응답을 받기 전에 죽은 경우"를 생각하면 된다. PG는 요청을 받아서 처리했는데 우리는 그 사실을 모른다. Outbox Poller가 다시 실행되면 PG에 중복 요청을 보낼 수 있다. 먼저 물어보면 이 문제가 사라진다. + +--- + +## Outbox 테이블 + +```sql +CREATE TABLE payment_outbox ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + payment_id BIGINT NOT NULL, + order_id VARCHAR(50) NOT NULL, + event_type VARCHAR(30) NOT NULL DEFAULT 'PAYMENT_REQUEST', + payload TEXT NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + created_at DATETIME NOT NULL, + processed_at DATETIME, + retry_count INT DEFAULT 0, + INDEX idx_outbox_status (status) +); +``` + +`retry_count`를 두는 이유가 있다. PG가 완전히 죽어 있으면 Outbox Poller도 계속 실패한다. 무한 재시도는 의미가 없으므로 3회 초과하면 `FAILED`로 전환하고 알림을 보낸다. 이후는 배치 복구(1분)나 수동 복구 API가 담당한다. + +--- + +## Outbox Poller와 배치 복구의 관계 + +둘은 경쟁이 아니라 계층이다. + +``` +[5초] Outbox Poller → PG 호출 누락 재시도 (빠른 복구) +[1분] Batch Recovery → Outbox Poller가 놓친 건 + Outbox Poller 자체 장애 대비 +``` + +Outbox Poller가 정상 동작하면 배치 복구가 처리할 건이 없다. 배치는 Outbox Poller의 안전망이다. Outbox Poller 프로세스 자체가 죽어 있으면 배치가 1분 후에 잡아낸다. + +이 구조는 MSA로 전환할 때도 유리하다. Outbox 레코드를 DB INSERT 대신 Kafka로 발행하면, Outbox Poller가 Kafka Consumer로 바뀐다. 패턴 자체는 변하지 않는다. + +--- + +## 돌아보며 + +Outbox의 핵심은 "PG를 호출하겠다는 의도"를 Payment와 같은 트랜잭션에서 기록하는 것이다. 의도가 기록되어 있으면, 실행이 빠졌을 때 누군가가 대신 실행할 수 있다. 기록이 없으면 빠졌다는 사실 자체를 알 수 없다. + +Outbox 테이블 하나와 5초짜리 스케줄러 하나. 복구 지연이 1분에서 5초로 줄었다. diff --git a/blog/week6-series-3-wal.md b/blog/week6-series-3-wal.md new file mode 100644 index 0000000000..4075c45932 --- /dev/null +++ b/blog/week6-series-3-wal.md @@ -0,0 +1,135 @@ +PG 성공 후 DB 저장 실패를 복구하는 법 — Local WAL + + +> **TL;DR**: PG 결제 성공 응답을 DB에 저장하기 전에 로컬 파일에 먼저 기록한다. DB가 죽어도 PG 응답은 보존되고, 복구 스케줄러가 DB에 재반영한다. + +--- + +## 가장 위험한 빈틈 + +다섯 개의 빈틈 중 이 빈틈이 가장 위험하다. + +``` +[PG 호출] → PG: "결제 성공, transactionKey=TX-abc123" +[DB 저장] → DB 장애 → Payment 상태 업데이트 실패 +``` + +고객의 돈은 빠져나갔는데 우리 DB에는 그 기록이 없다. 다른 빈틈은 "결제가 안 된" 상태인데, 이 빈틈은 "결제가 됐는데 모르는" 상태다. + +배치 복구가 이걸 잡을 수 있을까? 배치는 Payment 레코드를 기준으로 PG에 조회한다. TX-1에서 Payment(REQUESTED)는 저장되어 있으니 배치가 찾아낼 수는 있다. 하지만 배치 주기는 1분이다. 그 사이에 PG 응답 상세 정보(transactionKey, pgProvider 등)가 메모리에만 있다가 사라진다. + +--- + +## DB가 WAL을 쓰는 이유 + +PostgreSQL은 데이터 파일을 수정하기 전에 WAL(Write-Ahead Log)에 먼저 기록한다. 서버가 크래시해도 WAL에서 복구할 수 있다. MySQL의 Redo Log도 같은 원리다. + +핵심은 간단하다. **비싼 연산의 결과를 값싼 저장소에 먼저 보존하는 것.** + +결제 시스템에서 "비싼 연산"은 PG 결제 요청이다. 한 번 실행되면 고객의 돈이 빠져나간다. 되돌리려면 환불 프로세스를 밟아야 한다. 이 결과를 DB에 저장하기 전에 로컬 파일에 먼저 기록한다. + +--- + +## 구현 + +```java +// PG 응답 수신 직후 +walWriter.write(orderId, transactionKey, pgStatus); // 1. 로컬 파일 기록 + +paymentRepository.updateStatus(paymentId, PAID); // 2. DB 저장 시도 +walWriter.delete(walFile); // 3. 성공 시 WAL 삭제 +``` + +DB 저장이 실패하면 2번에서 예외가 터지고, 3번은 실행되지 않는다. WAL 파일이 남는다. + +```java +public void write(Long orderId, String transactionKey, String pgStatus) { + Map walEntry = Map.of( + "orderId", orderId, + "transactionKey", transactionKey, + "pgStatus", pgStatus, + "timestamp", System.currentTimeMillis() + ); + String content = objectMapper.writeValueAsString(walEntry); + Path walFile = walDirectory.resolve( + "wal-" + orderId + "-" + transactionKey + ".json"); + Files.writeString(walFile, content, + StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); +} +``` + +파일 하나에 JSON 하나. `wal-10042-TX-abc123.json` 같은 이름으로 저장된다. + +--- + +## 복구 + +`WalRecoveryScheduler`가 주기적으로 WAL 디렉토리를 스캔한다. + +``` +./wal/payments/ + wal-10042-TX-abc123.json ← DB 저장 실패한 건 + wal-10043-TX-def456.json ← DB 저장 실패한 건 +``` + +``` +[WAL Recovery — 주기적] +1. WAL 디렉토리에서 .json 파일 목록 조회 +2. 각 파일에 대해: + a. JSON 파싱 → orderId, transactionKey, pgStatus 추출 + b. DB에 Payment 상태 업데이트 시도 + → 성공 → WAL 파일 삭제 + → 실패 → 다음 주기에 재시도 +``` + +DB가 살아나는 순간 WAL에 남아있던 건이 전부 반영된다. + +--- + +## WAL 자체가 실패하면 + +로컬 디스크에 쓰는 것도 실패할 수 있다. 디스크 가득 참, 파일 시스템 장애 같은 경우다. + +```java +public void write(Long orderId, String transactionKey, String pgStatus) { + try { + // ... 파일 쓰기 + } catch (IOException e) { + log.error("WAL 기록 실패: orderId={}, error={}", orderId, e.getMessage()); + // 예외를 던지지 않는다 — WAL 실패가 결제 흐름을 막으면 안 된다 + } +} +``` + +WAL 기록 실패 시 예외를 던지지 않는다. WAL은 안전장치이지 주 경로가 아니다. WAL이 실패해도 DB 저장은 시도한다. DB 저장도 실패하면? 그때는 배치 복구(1분)가 Payment(REQUESTED) 레코드를 기준으로 PG에 조회해서 잡아낸다. + +``` +WAL 성공 + DB 성공 → 정상 (WAL 삭제) +WAL 성공 + DB 실패 → WAL Recovery가 복구 +WAL 실패 + DB 성공 → 정상 (WAL 필요 없음) +WAL 실패 + DB 실패 → 배치 복구(1분)가 Payment 기준으로 PG 조회 +``` + +WAL은 가장 빠른 복구 경로이지, 유일한 복구 경로가 아니다. + +--- + +## 저장소 선택 + +현재 구현은 로컬 파일이다. + +| 선택지 | 장점 | 단점 | +|--------|------|------| +| 로컬 파일 | DB와 독립적, 단순 | 서버 디스크 장애 시 유실, 다중 서버 불가 | +| Redis | 서버 간 공유 가능 | Redis 장애 시 유실 | +| Kafka | 내구성 높음 | 인프라 추가 필요 | + +단일 서버 환경이라 로컬 파일을 선택했다. 다중 서버 환경이면 Redis나 Kafka로 바꿔야 한다. 핵심은 "DB와 독립적인 저장소에 PG 응답을 먼저 기록하는 것"이고, 저장소가 무엇인지는 부차적이다. + +--- + +## 돌아보며 + +WAL의 코드량은 50줄 정도다. JSON 파일을 쓰고, 읽고, 지우는 것이 전부다. 하지만 이 50줄이 "PG 성공 + DB 실패"라는 가장 위험한 빈틈을 메운다. + +TX 분리가 만든 빈틈은 결국 "두 시스템 사이에 원자성이 없다"는 문제다. 원자성을 돌려놓을 수는 없으니, 한쪽의 결과를 다른 곳에 먼저 기록해서 유실을 막는다. 데이터베이스가 수십 년 전에 해결한 문제를 애플리케이션 레벨에서 다시 풀고 있는 셈이다. diff --git a/blog/week6-series-4-polling-dlq.md b/blog/week6-series-4-polling-dlq.md new file mode 100644 index 0000000000..5fe4a3c054 --- /dev/null +++ b/blog/week6-series-4-polling-dlq.md @@ -0,0 +1,190 @@ +콜백 유실과 처리 실패에 대비한 결제 복구 설계 — Polling Hybrid와 Callback DLQ + + +> **TL;DR**: 비동기 PG의 결제 결과는 콜백으로 온다. 콜백이 유실되면 결제 상태가 영원히 PENDING이다. 10초 후 PG에 직접 물어보는 Polling Hybrid를 추가했고, 콜백이 왔지만 처리 중 실패하는 경우를 위해 Callback Inbox(DLQ)를 두었다. + +--- + +## 콜백에 의존하는 구조 + +PG 시뮬레이터는 비동기 결제다. 결제 요청을 보내면 즉시 `PENDING`을 반환하고, 1~5초 후에 콜백으로 최종 결과를 보낸다. + +``` +Client → POST /payments → PG: "접수, PENDING" → Client: "결제 처리 중" + ... 1~5초 후 ... +PG → POST /payments/callback → 우리: "결제 성공" +``` + +문제는 PG 시뮬레이터의 콜백이 재시도하지 않는다는 것이다. 전송 실패 시 로그만 남긴다. 콜백이 유실되면 우리 쪽 Payment 상태는 `PENDING`인 채로 남는다. + +--- + +## 빈틈 ③ — 콜백이 안 온다 + +콜백 유실은 여러 원인으로 발생한다. + +``` +PG: "콜백 보낼게" → 네트워크 장애 → 우리 서버에 도달 못 함 +PG: "콜백 보낼게" → 우리 서버 재시작 중 → 수신 실패 +PG: "콜백 보낼게" → 로드밸런서가 다른 인스턴스로 보냄 → 유실 +``` + +배치 복구(1분)가 잡아내긴 한다. 하지만 결제를 했는데 1분간 결과를 모르면 사용자는 불안해서 다시 결제 버튼을 누른다. + +--- + +## Polling Hybrid — 10초 후 직접 물어본다 + +콜백을 기다리기만 하지 않는다. PG 응답이 `PENDING`이면 10초짜리 Delayed Task를 등록한다. + +``` +PG 응답 PENDING 수신 + → 정상 경로: 콜백 대기 + → 보험: Delayed Task 등록 (T+10초) + +10초 내 콜백 수신 → Task 취소 (정상 경로) +10초 후 콜백 미수신 → Task 실행: + → GET /payments/{transactionKey} (PG에 직접 조회) + → SUCCESS → 조건부 UPDATE → PAID + → FAILED → 조건부 UPDATE → FAILED + → PENDING → 아직 처리 중 → 다음 주기에 재확인 +``` + +```java +taskScheduler.schedule( + () -> paymentRecoveryService.checkAndRecover(paymentId), + Instant.now().plusSeconds(10) +); +``` + +10초의 근거: PG 비동기 처리 최대 5초 + 콜백 전송 시간을 고려하면, 10초 후에도 콜백이 안 왔다면 유실 가능성이 높다. + +--- + +## 콜백과 Polling이 동시에 실행되면 + +콜백이 9초에 도착하고, Polling이 10초에 실행되면 둘 다 같은 Payment를 PAID로 바꾸려 한다. + +조건부 UPDATE가 이 문제를 해결한다. + +```sql +UPDATE payment +SET status = 'PAID' +WHERE id = ? AND status IN ('PENDING', 'UNKNOWN') +``` + +먼저 실행된 쪽이 `affected rows = 1`을 얻고, 늦게 실행된 쪽은 `affected rows = 0`을 얻는다. 0이면 이미 처리된 건으로 판단하고 넘어간다. 락이 필요 없다. + +```java +int affected = paymentRepository.updateStatusConditional( + paymentId, PaymentStatus.PAID, + List.of(PaymentStatus.PENDING, PaymentStatus.UNKNOWN)); + +if (affected == 0) { + // 이미 다른 경로에서 처리됨 → 무시 + return; +} +// 진주문 전환 진행 +``` + +--- + +## 빈틈 ④ — 콜백은 왔는데 처리가 실패한다 + +콜백이 도착했지만, 상태 전이 중에 예외가 터질 수 있다. + +``` +PG 콜백 수신 → 200 OK 반환 → 조건부 UPDATE 시도 → DB 예외 +→ 콜백은 수신했는데 처리가 안 됨 +→ PG는 200 받았으니 재전송 안 함 +→ 결제 상태 PENDING 유지 +``` + +PG에게 200을 반환한 이상 PG는 콜백을 다시 보내지 않는다. 그런데 처리가 안 됐다. 콜백 데이터가 메모리에서 사라지면 복구할 수 없다. + +--- + +## Callback Inbox — 원본을 먼저 저장한다 + +콜백을 받는 즉시 원본을 DB에 저장한다. 처리는 그다음이다. + +```java +// 1단계: 원본 보존 +CallbackInbox inbox = CallbackInbox.create( + transactionKey, orderId, pgStatus, payload); +callbackInboxRepository.save(inbox); // status = RECEIVED + +// PG에게 즉시 200 OK 반환 + +// 2단계: 비즈니스 처리 +try { + processCallback(transactionKey, pgStatus); + inbox.markProcessed(); +} catch (Exception e) { + inbox.recordError(e.getMessage()); + // RECEIVED 상태 유지 → DLQ 스케줄러가 재처리 +} +``` + +처리가 실패해도 `callback_inbox` 테이블에 원본이 남아 있다. + +```sql +CREATE TABLE callback_inbox ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + transaction_key VARCHAR(50) NOT NULL, + order_id VARCHAR(50) NOT NULL, + pg_status VARCHAR(20) NOT NULL, + payload TEXT NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'RECEIVED', + received_at DATETIME NOT NULL, + processed_at DATETIME, + retry_count INT DEFAULT 0, + error_message VARCHAR(500), + INDEX idx_callback_status (status) +); +``` + +DLQ 스케줄러가 30초마다 `RECEIVED` 상태(미처리)인 콜백을 재시도한다. + +``` +[Callback DLQ — 30초 주기] +1. callback_inbox에서 status = 'RECEIVED' 조회 +2. 각 건에 대해 processCallback() 재실행 +3. 성공 → PROCESSED / 실패 → retry_count 증가 +4. retry_count 초과 → FAILED + 알림 +``` + +--- + +## PG에게 200을 먼저 반환하는 이유 + +PG 입장에서 콜백 전송의 성공/실패는 HTTP 응답 코드로 판단한다. 우리가 500을 반환하면 PG가 재전송할 수도 있고, 타임아웃으로 판단할 수도 있다. PG의 동작은 우리가 통제할 수 없다. + +그래서 콜백 "수신"과 "처리"를 분리한다. 수신은 200으로 확인해주고, 처리는 우리 내부 문제로 가져온다. 원본을 보존했으니 몇 번이든 재처리할 수 있다. + +``` +콜백 수신 (외부 경계) → 200 OK + 원본 DB 저장 +콜백 처리 (내부 경계) → 실패해도 재시도 가능 +``` + +--- + +## 복구 계층 정리 + +콜백 관련 빈틈에는 세 겹의 그물이 있다. + +| 계층 | 동작 | 복구 시점 | +|------|------|----------| +| Callback DLQ | 콜백 수신했지만 처리 실패 → 30초마다 재시도 | 30초 | +| Polling Hybrid | 콜백 자체가 안 옴 → 10초 후 PG에 직접 조회 | 10초 | +| Batch Recovery | 위 둘 다 실패 → 1분 주기 최종 안전망 | 1분 | + +각 계층은 독립적이다. Polling이 성공하면 DLQ가 처리할 게 없고, DLQ가 성공하면 배치가 처리할 게 없다. 하나가 빠져도 다른 계층이 잡아낸다. + +--- + +## 돌아보며 + +콜백 기반 비동기 시스템에서 "콜백이 반드시 온다"고 가정하면 안 된다. 오지 않을 수 있고, 와도 처리가 실패할 수 있다. + +Polling Hybrid는 "오지 않는" 경우를, Callback Inbox는 "왔지만 처리 실패"하는 경우를 담당한다. 두 문제의 성격이 다르니 해법도 다르다. 하나로 묶으면 깔끔해 보이지만, 분리하는 편이 각각의 실패 원인을 명확하게 추적할 수 있었다. diff --git a/blog/week6-series-5-redis-anchor.md b/blog/week6-series-5-redis-anchor.md new file mode 100644 index 0000000000..657f91bc09 --- /dev/null +++ b/blog/week6-series-5-redis-anchor.md @@ -0,0 +1,158 @@ +Redis 장애에도 진주문 생성을 보장하는 결제 설계 + + +> **TL;DR**: 가주문은 Redis에 있다. PG 결제가 성공한 후 Redis가 죽으면 가주문을 꺼낼 수 없다. 하지만 TX-1에서 DB에 저장한 Payment 레코드에 진주문 생성에 필요한 정보가 전부 들어 있다. Redis는 빠른 경로이고, DB의 Payment는 확실한 경로다. + +--- + +## 가주문이 Redis에 있는 이유 + +주문을 DB에 바로 INSERT하면, 결제 실패 시 DELETE하거나 상태를 롤백해야 한다. 결제 성공률이 42%인 환경에서 58%의 주문이 생성 후 삭제되는 셈이다. + +가주문 패턴은 이 문제를 다르게 풀었다. + +``` +[기존] +주문서 작성 → DB INSERT (Order CREATED) → 결제 → 실패 → DELETE or CANCELLED + +[가주문] +주문서 작성 → Redis SET (가주문, TTL 30분) → 결제 → 성공 → DB INSERT (진주문 PAID) + → 실패 → TTL 만료 → 자동 삭제 +``` + +결제 실패 시 아무것도 안 해도 된다. TTL이 만료되면 Redis가 알아서 지운다. DB에는 성공한 주문만 남는다. + +--- + +## 빈틈 ⑤ — PG 성공 후 Redis가 죽는다 + +콜백이 도착해서 진주문을 만들려고 한다. 가주문에서 상품 정보, 수량, 가격을 꺼내야 한다. 그런데 Redis가 죽어 있다. + +``` +Redis: HSET provisional:order:10042 {productId: 5, quantity: 2, amount: 50000} +PG: 결제 성공 (transactionKey: TX-abc123) +콜백 수신 → Redis 조회 시도 → Redis 장애 → 가주문 데이터 없음 +→ 진주문 생성 불가? +``` + +--- + +## TX-1이 답이다 + +TX-1에서 Payment를 저장할 때, 결제에 필요한 정보를 같이 넣었다. + +```java +Payment payment = Payment.create( + orderId, // 주문 ID + productId, // 상품 ID + quantity, // 수량 + amount, // 금액 + REQUESTED // 초기 상태 +); +paymentRepository.save(payment); +``` + +콜백이 도착하면 transactionKey로 Payment를 찾는다. + +``` +콜백: transactionKey = TX-abc123 +→ DB: SELECT * FROM payment WHERE transaction_key = 'TX-abc123' +→ Payment {orderId: 10042, productId: 5, quantity: 2, amount: 50000} +→ 진주문 생성에 필요한 정보가 전부 있다 +``` + +Redis 가주문 없이도 Payment 레코드만으로 진주문을 만들 수 있다. Redis는 "빠른 경로"다. 결제 전에 주문 정보를 빠르게 읽고 쓰기 위한 것이지, 유일한 저장소가 아니다. + +--- + +## Redis 장애 시 Fallback + +Redis 장애는 가주문 조회뿐 아니라 가주문 생성 단계에서도 발생할 수 있다. + +```java +@CircuitBreaker(name = "redis-write", fallbackMethod = "saveToDbFallback") +public ProvisionalOrderResult saveProvisionalOrder(OrderCreateRequest request) { + // Redis 정상: 가주문 + 재고 예약 + masterRedisTemplate.opsForHash().putAll(key, orderData); + masterRedisTemplate.opsForValue().decrement("stock:" + productId); + return ProvisionalOrderResult.provisional(orderId); +} + +public ProvisionalOrderResult saveToDbFallback( + OrderCreateRequest request, Exception e) { + // Redis 장애: DB 직접 주문 + Order order = Order.create(request); + orderRepository.save(order); + productRepository.decreaseStock(request.productId(), request.quantity()); + return ProvisionalOrderResult.directOrder(order.getId()); +} +``` + +Redis CB가 Open이면 가주문을 건너뛰고 DB에 직접 주문을 생성한다. 가주문 패턴의 이점(결제 실패 시 자동 정리)은 잃지만, 결제 자체는 계속 진행된다. + +이 CB는 쓰기 전용이다. 읽기(가주문 조회)에는 CB를 걸지 않았다 — 시리즈 1편에서 다룬 이유와 같다. 읽기를 차단하면 복구가 멈춘다. + +--- + +## Redis DEL 실패도 허용한다 + +진주문 전환 후 Redis 가주문을 삭제한다. 이 삭제가 실패해도 문제없다. + +```java +// 진주문 전환 완료 후 +try { + provisionalOrderRedisRepository.deleteByOrderId(orderId); +} catch (Exception e) { + log.warn("가주문 삭제 실패 (허용): orderId={}", orderId); + // 예외를 던지지 않는다 +} +``` + +가주문에 TTL이 걸려 있으므로 삭제가 실패해도 25~35분 후에 자동으로 사라진다. 그 전에 Proactive Expiry Scanner(30초 주기)가 TTL이 임박한 가주문을 선제 정리하면서 재고도 복원한다. + +``` +Redis DEL 성공 → 즉시 정리 +Redis DEL 실패 → TTL 만료 → 자동 삭제 + or Proactive Expiry Scanner → 선제 정리 + 재고 복원 +``` + +--- + +## TTL Jitter + +가주문 TTL은 30분인데, 정확히 30분으로 설정하지 않았다. + +```java +private Duration calculateTtlWithJitter() { + long jitter = ThreadLocalRandom.current().nextLong(-300, 301); + return Duration.ofSeconds(1800 + jitter); // 25분 ~ 35분 +} +``` + +플래시 세일로 1000건이 동시에 생성되면, TTL이 동일할 경우 30분 후에 1000건이 동시에 만료된다. Proactive Expiry Scanner 한 번에 1000건을 처리하면 600ms가 걸린다. Jitter를 ±5분 주면 만료가 10분에 걸쳐 분산되어 한 번에 25ms 수준으로 줄어든다. + +--- + +## 닻의 역할 정리 + +TX-1 커밋 시점에 DB에 들어가는 Payment 레코드가 다섯 개의 빈틈 전체에서 기준점 역할을 한다. 이 글에서 다룬 빈틈 ⑤만이 아니다. + +| 빈틈 | Payment가 제공하는 것 | +|------|----------------------| +| ① PG 미호출 | Outbox와 함께 저장됨 → Poller가 재호출 | +| ② DB 저장 실패 | orderId → WAL과 매핑 | +| ③ 콜백 유실 | transactionKey → Polling 조회 키 | +| ④ 콜백 처리 실패 | Payment 레코드 → 재처리 대상 | +| ⑤ Redis 장애 | orderId, productId, amount → 진주문 생성 정보 | + +TX-1이 커밋되면 Payment는 DB에 있다. DB에 있으면 유실되지 않는다. Redis가 죽어도, 콜백이 유실되어도, 서버가 재시작되어도, 이 레코드를 기준으로 복구할 수 있다. + +--- + +## 돌아보며 + +Redis를 도입하면 장애 포인트가 하나 늘어난다. "장애 포인트가 늘어나니 안 쓰는 게 낫다"는 판단도 가능하다. + +이번에는 다르게 접근했다. Redis가 죽는 상황을 설계에 포함시키고, 죽었을 때 DB가 대신하는 경로를 만들었다. 가주문 생성은 DB Fallback으로, 가주문 조회는 Payment 레코드로, 가주문 삭제는 TTL 만료로. 세 가지 Redis 장애 시나리오 각각에 대체 경로가 있다. + +Redis가 정상이면 빠르고, 죽어도 느릴 뿐 멈추지 않는다. 이 정도면 장애 포인트를 추가한 대가로 충분하다고 판단했다. diff --git a/blog/week6-tx-separation-gaps.md b/blog/week6-tx-separation-gaps.md new file mode 100644 index 0000000000..2a21f0393f --- /dev/null +++ b/blog/week6-tx-separation-gaps.md @@ -0,0 +1,127 @@ +PG 호출을 트랜잭션 밖으로 빼면 생기는 다섯 개의 빈틈 + + +> **TL;DR**: PG 호출을 트랜잭션 밖으로 빼면 DB 커넥션 점유가 4,510ms에서 30ms로 줄어든다. 대신 빈틈이 다섯 개 생긴다. 각 빈틈마다 안전장치를 놓았다. + +--- + +## 트랜잭션 안에서 PG를 호출하면 + +결제 흐름을 가장 단순하게 구현하면 이렇다. + +```java +@Transactional +public void pay(Long orderId, PaymentRequest request) { + Payment payment = Payment.create(orderId); + paymentRepository.save(payment); + + PgResponse response = pgClient.request(request); + + payment.updateStatus(response.status()); +} +``` + +하나의 트랜잭션 안에서 DB 저장, PG 호출, 상태 업데이트를 전부 처리한다. PG가 실패하면 롤백되니까 원자성이 보장된다. + +문제는 PG 호출이 느리다는 것이다. + +``` +DB INSERT: ~5ms +PG 호출: 100ms ~ 4,500ms (타임아웃 1초 × Retry 3회) +DB UPDATE: ~5ms + +트랜잭션 동안 DB 커넥션 점유: 최대 4,510ms +``` + +초당 100건이면 `100 × 4.5초 = 450 커넥션·초`. HikariCP가 10개면 1초 만에 고갈된다. 결제뿐 아니라 상품 조회, 주문 목록까지 전부 멈춘다. + +--- + +## 트랜잭션을 분리했다 + +PG 호출을 트랜잭션 밖으로 뺐다. + +``` +TX-0: 쿠폰 선차감 → ~5ms +Redis: 가주문 생성 + 재고 예약 → ~10ms +TX-1: Payment(REQUESTED) + Outbox INSERT → ~10ms +[PG 호출] → 100ms ~ 4,500ms (트랜잭션 없음) +TX-2: Payment 상태 UPDATE → ~5ms +``` + +DB 커넥션 점유가 `~30ms`로 줄었다. `100 × 0.03초 = 3 커넥션·초` — 30% 사용률이다. + +대신 빈틈이 생겼다. + +--- + +## 다섯 개의 빈틈 + +``` +TX-1 commit ──①── PG 호출 ──②── PG 응답 → DB 저장 + │ + PG 성공 + │ + ┌─────③─────┼─────⑤─────┐ + ▼ ▼ ▼ + 콜백 수신 DB 저장 Redis 조회 + │ │ + ④ │ + ▼ ▼ + 콜백 처리 가주문 → 진주문 +``` + +| # | 빈틈 | 상황 | 안전장치 | +|---|------|------|---------| +| ① | TX-1 커밋 → PG 호출 사이 | 서버 크래시, PG 호출 누락 | **Transactional Outbox** | +| ② | PG 성공 → DB 저장 사이 | DB 장애, 서버 OOM | **Local WAL** | +| ③④ | PG 성공 → 콜백 수신/처리 사이 | 콜백 유실, 처리 중 예외 | **Polling Hybrid + Callback DLQ** | +| ⑤ | PG 성공 → Redis 조회 사이 | Redis 장애로 가주문 유실 | **TX-1 Payment 레코드** | + +하나의 트랜잭션이었을 때는 이 빈틈이 전부 롤백으로 커버됐다. 분리한 순간 각 빈틈을 개별로 메워야 한다. + +--- + +## TX-1이 닻이다 + +다섯 개의 안전장치는 서로 독립적이지만, 공통된 기준점이 있다. **TX-1 커밋** 시점에 DB에 저장되는 두 레코드다. + +``` +TX-1 커밋 시점에 DB에 저장되는 것: + - Payment (orderId, amount, status=REQUESTED) + - PaymentOutbox (paymentId, status=PENDING) +``` + +TX-1 이전에 장애가 나면? 아직 돈이 안 빠져나갔다. 쿠폰과 재고를 복원하면 된다. + +TX-1 이후에 장애가 나면? Payment 레코드가 DB에 있다. 다섯 개의 안전장치 중 하나가 복구한다. + +| 빈틈 | 복구 시 TX-1이 제공하는 것 | +|------|--------------------------| +| ① PG 미호출 | Outbox 레코드 (결제 의도) | +| ② DB 저장 실패 | orderId, transactionKey (WAL 매핑) | +| ③ 콜백 유실 | transactionKey (Polling 조회 키) | +| ④ 콜백 처리 실패 | Payment 레코드 (재처리 대상) | +| ⑤ Redis 장애 | orderId, amount (진주문 생성 정보) | + +그리고 다섯 개 전부가 실패하는 최악의 경우? 배치 복구(1분)와 대사 배치(1시간)가 마지막 그물이다. + +--- + +## 시리즈 + +각 빈틈의 안전장치를 깊이 다룬 글이다. + +| # | 제목 | 빈틈 | +|---|------|------| +| 1 | 서킷브레이커 적용 기준: 결제 복구 경로는 왜 차단하면 안 되는가 | 복구 경로 보호 | +| 2 | TX 커밋 후 누락된 PG 호출을 복구하는 법 — Transactional Outbox | ① | +| 3 | PG 성공 후 DB 저장 실패를 복구하는 법 — Local WAL | ② | +| 4 | 콜백 유실과 처리 실패에 대비한 결제 복구 설계 — Polling Hybrid와 Callback DLQ | ③④ | +| 5 | Redis 장애에도 진주문 생성을 보장하는 결제 설계 | ⑤ | + +--- + +## 돌아보며 + +빈틈이 없는 설계는 없다. 트랜잭션 하나로 감싸면 빈틈은 없지만 병목이 생기고, 분리하면 병목은 없지만 빈틈이 생긴다. 빈틈을 없애는 것이 아니라, 빈틈마다 그물을 놓는 것. 이번 설계에서 반복적으로 내린 판단이다. \ No newline at end of file diff --git a/blog/week7-data-management-patterns.md b/blog/week7-data-management-patterns.md new file mode 100644 index 0000000000..d7ed676672 --- /dev/null +++ b/blog/week7-data-management-patterns.md @@ -0,0 +1,146 @@ +# Data Management 패턴으로 이커머스 결제 시스템 점검하기 + +> microservices.io의 8가지 Data Management 패턴으로 현재 프로젝트를 점검하고, Event Sourcing 경량 적용(PaymentStatusHistory)을 도출한 기록. + +--- + +## 1. 점검에 사용한 8가지 패턴 + +| 패턴 | 핵심 질문 | +|------|----------| +| **Database per Service** | 도메인별로 데이터가 격리되어 있는가? | +| **Saga** | 분산 트랜잭션을 어떻게 관리하는가? | +| **CQRS** | 읽기/쓰기 모델이 분리되어 있는가? | +| **Transactional Outbox** | 메시지 발행의 원자성을 어떻게 보장하는가? | +| **Polling Publisher** | Outbox 메시지를 어떻게 전달하는가? | +| **Event Sourcing** | 상태 변경 이력을 추적할 수 있는가? | +| **API Composition** | 여러 도메인의 데이터를 어떻게 조합하는가? | +| **Domain Event** | 도메인 간 통신에 이벤트를 활용하는가? | + +--- + +## 2. 패턴별 적용 현황 + +| 패턴 | 적용 상태 | 핵심 발견 | +|------|----------|----------| +| Database per Service | 부분 적용 | 배치 대사(reconciliation)에서 cross-domain JOIN 존재 — **의도적 설계** | +| Saga | Orchestration 적용 | PaymentFacade가 오케스트레이터, 5-layer recovery 구조 | +| CQRS | 암묵적 적용 | product_metrics(읽기 모델), Product.like_count(비정규화), Redis 재고 캐시 | +| Transactional Outbox | 이중 적용 | PaymentOutbox(상태 기반, Polling) + EventOutbox(무상태, Debezium CDC) | +| Polling Publisher | Polling + CDC 하이브리드 | OutboxPollerScheduler(5초) + Debezium(CDC) | +| Event Sourcing | **미적용** | 결제 상태 전이 이력 없음 → **개선 대상** | +| API Composition | 적용 | ProductFacade(Product + Brand), OrderFacade(Order + Product + Coupon) | +| Domain Event | 적용 | DomainEventPublisher → Outbox + Spring Event 이중 발행 | + +--- + +## 3. 핵심 발견: 5-layer Recovery의 추적 불가 문제 + +현재 결제 시스템은 5-layer recovery 구조로 상태를 복구한다: + +``` +Layer 1: Outbox Poller (5초) — 미처리 결제 재시도 +Layer 2: Callback DLQ (30초) — 미수신 콜백 재처리 +Layer 3: Payment Polling (10초) — PG 상태 직접 확인 +Layer 4: WAL Recovery (10초) — DB 실패 시 WAL 파일 복구 +Layer 5: Batch Recovery (1/5/10분) — 최종 안전망 +``` + +**문제**: "어떤 경로로 최종 상태에 도달했는가?"를 추적할 수 없다. + +- `PaymentModel.updatedAt`만 기록되고, `from_status` 정보가 유실 +- 콜백으로 PAID가 되었는지, Polling으로 PAID가 되었는지, 배치로 FAILED가 되었는지 알 수 없음 +- 결제 분쟁(dispute) 시 상태 변경 증거가 없음 + +--- + +## 4. 설계 선택: Event Sourcing 전체 vs History 테이블 + +### Event Sourcing 전체를 도입하지 않은 이유 + +| 항목 | Event Sourcing | History 테이블 | +|------|---------------|---------------| +| 상태 결정 방식 | 이벤트 재생으로 현재 상태 구성 | 현재 상태 + INSERT-only 로그 | +| 필요 인프라 | 이벤트 스토어 + 스냅샷 + CQRS 프로젝션 | 기존 DB에 테이블 1개 추가 | +| 복잡도 | 높음 (이벤트 버전닝, 스냅샷 전략) | 낮음 (INSERT만) | +| Payment 핵심 | "최종 상태가 무엇인가?" | 동일 | + +Payment의 핵심은 **"최종 상태가 무엇인가"**이지 "모든 이벤트를 재생해서 상태를 구성"하는 것이 아니다. History 테이블은 Event Sourcing의 감사(audit) 측면만 경량 적용한 것. + +--- + +## 5. 3가지 상태 변경 경로와 기록 전략 + +결제 상태가 변경되는 경로가 3가지 존재한다. 세 경로 모두 기록해야 History가 의미 있다. + +### 경로 1: Entity 메서드 (PaymentFacade) + +```java +// PaymentModel — @Transient 전이 리스트로 자동 추적 +public void markPaid() { + PaymentStatus from = this.status; + validateTransition(PaymentStatus.PAID); + this.status = PaymentStatus.PAID; + pendingTransitions.add(new StatusTransition(from, PaymentStatus.PAID, "PG_RESPONSE", null)); +} +``` + +`markPending → markPaid` 연속 호출 시 두 전이 모두 기록된다 (REQUESTED→PENDING, PENDING→PAID). +`PaymentRepositoryImpl.save()`에서 pendingTransitions를 자동으로 History 테이블에 INSERT. + +### 경로 2: JPQL 조건부 UPDATE (PaymentRecoveryService, WalRecoveryScheduler) + +```java +int affected = paymentRepository.updateStatusConditionally( + payment.getId(), PaymentStatus.PAID, allowedStatuses); +if (affected > 0) { + historyRepository.save(PaymentStatusHistory.create( + payment.getId(), payment.getStatus(), PaymentStatus.PAID, "CALLBACK", null)); +} +``` + +Entity 메서드를 우회하는 JPQL UPDATE이므로, 호출부에서 명시적으로 기록한다. + +### 경로 3: Native SQL (PaymentRecoveryTasklet) + +```java +// 복구 대상 ID 조회 → History INSERT → Status UPDATE 순서 +List ids = ... // SELECT id FROM payments WHERE status = 'REQUESTED' ... +entityManager.createNativeQuery( + "INSERT INTO payment_status_history (...) SELECT id, 'REQUESTED', 'FAILED', 'BATCH_RECOVERY', ... FROM payments WHERE id IN :ids" +).setParameter("ids", ids).executeUpdate(); +``` + +JPA 엔티티를 완전히 우회하는 배치 복구이므로, companion INSERT로 기록한다. + +--- + +## 6. 스킵한 개선점과 근거 + +분석 결과 도출된 다른 개선점들은 의도적으로 스킵했다: + +| 개선점 | 판단 | 근거 | +|--------|------|------| +| Batch cross-domain JOIN 분리 | SKIP | 대사(reconciliation)는 정확도 최우선. `payments JOIN orders JOIN coupon_issue`를 이벤트 기반 검증으로 바꾸면 오히려 정합성 검증 신뢰도가 낮아짐 | +| Payment CQRS 명시적 분리 | SKIP | 결제 조회 트래픽이 상품 조회 대비 미미. 별도 Read Model의 ROI가 낮음 | +| PaymentFacade 분리 (287 lines) | SKIP | 결제 오케스트레이션은 단일 유스케이스. TX-0/TX-1/TX-2 경계를 분리하면 오히려 흐름 파악이 어려워짐 | +| Multi-instance Outbox Poller | SKIP | PG orderId 멱등성이 중복 처리를 이미 방지. 스케일아웃 시 SELECT FOR UPDATE 추가하면 됨 | + +--- + +## 7. 산술 근거 + +- 피크 TPS 5,000 결제 요청 × 평균 2~3회 상태 전이 = 10,000~15,000 History INSERT/초 +- INSERT-only 테이블, 인덱스 1개 (payment_id) → MySQL 8.0 기준 수만 rows/초 처리 가능 +- 디스크: row당 ~100 bytes × 15,000/초 × 86,400초 ≈ **1.3GB/일** → created_at 기준 파티셔닝으로 관리 +- 기존 payments 테이블 쓰기 성능에 미치는 영향: 별도 테이블이므로 기존 UPDATE 쿼리에 추가 부하 없음 + +--- + +## 8. 라이팅 포인트 + +1. **Event Sourcing은 "전부 아니면 전무"가 아니다** — 감사 로그(audit trail)만 필요하면 History 테이블로 충분하다. "이벤트 재생으로 상태를 구성"하는 풀 Event Sourcing은 요구사항이 정당화할 때 도입한다. + +2. **"3가지 경로 모두 커버해야 한다"는 발견이 핵심** — Entity 메서드만 기록하면 JPQL/Native SQL 경로의 전이가 유실된다. 상태 변경 경로를 빠짐없이 파악하는 것이 History 설계의 출발점. + +3. **"스킵한다"도 설계 판단이다** — 8가지 패턴을 점검했지만 실제로 구현한 개선은 1가지. 나머지 4가지를 스킵한 근거를 기록하는 것이 "왜 이렇게 했는가?"에 답하는 것. diff --git a/blog/week7-decomposition-analysis.md b/blog/week7-decomposition-analysis.md new file mode 100644 index 0000000000..03f7e560e6 --- /dev/null +++ b/blog/week7-decomposition-analysis.md @@ -0,0 +1,138 @@ +# Decomposition 패턴으로 이커머스 프로젝트 점검하기 + +> microservices.io의 Decomposition 패턴 4가지로 현재 프로젝트를 점검하고, 도출된 개선점을 실제로 적용한 기록. + +--- + +## 1. 점검에 사용한 4가지 패턴 + +| 패턴 | 핵심 질문 | +|------|----------| +| **Decompose by Business Capability** | 도메인별로 책임이 분리되어 있는가? | +| **Decompose by Subdomain** | Bounded Context 경계가 코드에 반영되어 있는가? | +| **Self-Contained Service** | 각 서비스가 동기 의존 없이 독립 동작 가능한가? | +| **Service per Team** | 팀 단위로 독립 배포·운영이 가능한 구조인가? | + +--- + +## 2. 점검 결과 + +### 잘 되어 있는 부분 + +- **패키지 구조**: `application/{domain}/`, `domain/{domain}/` 패턴으로 Business Capability별 분리 완료. +- **Facade 패턴**: 유스케이스 조율이 Application Layer에서 이루어지고, 도메인 로직은 Entity/VO에 캡슐화. +- **Aggregate 간 ID 참조**: Order → Product, Order → CouponIssue 등 느슨한 결합 유지. + +### 개선이 필요한 부분 + +1. **EventOutbox 보일러플레이트**: LikeFacade, OrderFacade가 각각 `EventOutboxRepository` + `ObjectMapper` + `ApplicationEventPublisher` 3개를 직접 조합. Outbox 저장 + 이벤트 발행 패턴이 중복. +2. **PaymentRecoveryService cross-domain 접근**: 결제 복구 서비스가 `ProductRepository`, `StockReservationRedisRepository`, `CouponIssueRepository`를 직접 사용. Product/Coupon 도메인의 내부 구현에 결합. +3. **Self-Contained Service 관점**: 결제 도메인이 상품 재고의 Redis + DB 이중 쓰기 패턴을 알고 있는 것은 도메인 경계 위반. + +--- + +## 3. 개선 1: DomainEventPublisher 추상화 + +### Before + +```java +// LikeFacade — 3개 인프라 의존 +private final EventOutboxRepository eventOutboxRepository; +private final ApplicationEventPublisher applicationEventPublisher; +private final ObjectMapper objectMapper; + +// Outbox + Event 발행 로직이 Facade에 직접 존재 +EventOutbox outbox = EventOutbox.create("catalog", productId, "LIKE_CREATED", buildPayload(...)); +eventOutboxRepository.save(outbox); +applicationEventPublisher.publishEvent(new LikeCreatedEvent(...)); +``` + +### After + +```java +// LikeFacade — 1개 도메인 인터페이스 의존 +private final DomainEventPublisher domainEventPublisher; + +// 한 줄로 완결 +domainEventPublisher.publish("catalog", productId, "LIKE_CREATED", + Map.of("productId", productId, "memberId", memberId), + new LikeCreatedEvent(productId, memberId)); +``` + +### 설계 포인트 + +- **DomainEventPublisher 인터페이스**는 `domain/event/`에 위치 → Facade가 인프라에 의존하지 않음. +- **DomainEventPublisherImpl**은 `infrastructure/event/`에 위치 → Outbox 저장 + Spring Event 발행을 캡슐화. +- 마이크로서비스 분리 시 구현체만 교체 (Outbox → Kafka 직접 발행)하면 Facade 코드 변경 없음. + +--- + +## 4. 개선 2: PaymentRecoveryService cross-domain 위임 + +### Before + +```java +// PaymentRecoveryService — Product/Coupon 도메인 직접 접근 +private final ProductRepository productRepository; +private final CouponIssueRepository couponIssueRepository; +private final StockReservationRedisRepository stockRedisRepository; + +private void handlePaymentFailure(PaymentModel payment) { + // Redis INCR + DB increaseStock 이중 쓰기를 직접 수행 + stockRedisRepository.increase(item.getProductId(), item.getQuantity()); + productRepository.findById(item.getProductId()).ifPresent(product -> { + product.increaseStock(item.getQuantity()); + productRepository.save(product); + }); + // 쿠폰 내부 상태 직접 조작 + couponIssueRepository.findById(couponIssueId).ifPresent(couponIssue -> { + couponIssue.cancelUse(ZonedDateTime.now()); + }); +} +``` + +### After + +```java +// PaymentRecoveryService — Facade 위임 +private final ProductFacade productFacade; +private final CouponFacade couponFacade; + +private void handlePaymentFailure(PaymentModel payment) { + for (OrderItem item : order.getItems()) { + productFacade.restoreStock(item.getProductId(), item.getQuantity()); + } + if (order.getCouponIssueId() != null) { + couponFacade.restoreCoupon(order.getCouponIssueId()); + } +} +``` + +### 설계 포인트 + +- **재고 복원 로직(Redis + DB)은 ProductFacade가 소유**: Product 도메인의 내부 구현을 외부에 노출하지 않음. +- **쿠폰 복원은 CouponFacade.restoreCoupon()**: 이미 존재하던 메서드를 활용. +- **OrderRepository는 유지**: 주문 상태 조회는 결제 도메인의 직접 관심사 (주문 → 결제 1:1 관계). + +--- + +## 5. Self-Contained Service 관점 + +결제 도메인을 분석하면: + +| 의존 대상 | 유형 | 판단 | +|----------|------|------| +| OrderRepository | 동기 조회 | 결제-주문 1:1이므로 허용 (같은 BC로 분류 가능) | +| ProductFacade.restoreStock() | Facade 호출 | 도메인 경계를 Facade로 격리 → 분리 시 이벤트 기반으로 전환 가능 | +| CouponFacade.restoreCoupon() | Facade 호출 | 동일 | +| PgRouter | 외부 시스템 | Circuit Breaker + Retry로 보호 완료 | + +Self-Contained Service의 핵심은 "동기 의존을 최소화"하는 것이지 "의존을 제거"하는 것이 아니다. 현재 모놀리스 구조에서는 Facade 위임이 적절하며, 마이크로서비스 분리 시 이벤트 기반(Saga)으로 전환하면 된다. + +--- + +## 6. 라이팅 포인트 + +1. **Decomposition 패턴은 마이크로서비스 전용이 아니다** — 모놀리스에서도 도메인 경계를 점검하는 체크리스트로 활용 가능. +2. **"추상화해야 하는가?"의 기준은 변경 가능성** — Outbox → Kafka 전환 시 Facade 코드를 건드려야 한다면, 지금 추상화할 근거가 있다. +3. **cross-domain 접근은 "동작하는가?"가 아니라 "분리 가능한가?"로 판단** — PaymentRecoveryService가 Product 도메인의 Redis 이중 쓰기를 알고 있으면, 재고 전략 변경 시 결제 코드도 수정해야 한다. diff --git a/blog/week7-event-pipeline-notes.md b/blog/week7-event-pipeline-notes.md new file mode 100644 index 0000000000..5b1876462f --- /dev/null +++ b/blog/week7-event-pipeline-notes.md @@ -0,0 +1,216 @@ +7주차 이벤트 파이프라인 — 테크니컬 라이팅 소재 노트 + +> 이 파일은 블로그 글의 "소재 창고"다. 설계 과정에서의 고민, 트레이드오프 분석, 결정과 근거를 그때그때 기록한다. + +--- + +## 소재 1: 핵심 vs 부가 로직 판단 — "이것이 실패하면 사용자 요청이 실패해야 하는가?" + +**고민**: 좋아요의 incrementLikeCount는 핵심인가 부가인가? + +좋아요를 누른 사용자에게 "좋아요 등록 완료"라고 응답했는데, 집계가 실패해서 목록의 좋아요 수가 반영 안 된다면? 사용자 입장에서는 좋아요를 눌렀는데 숫자가 안 올라간 것처럼 보인다. +`` +처음엔 "집계 실패해도 좋아요 자체는 성공"이라고 판단했다. 하지만 즉시 반영 UX를 위해 incrementLikeCount를 AFTER_COMMIT에서 best-effort로 실행하기로 했다. + +**결정**: 핵심은 아니지만 UX를 위해 best-effort 즉시 반영 + Kafka 집계 + 배치 대사로 3중 안전망 구축. + +**트레이드오프**: 즉시 반영(UX) vs 트랜잭션 분리(안정성). 둘 다 잡되, 실패 시 최종 정합성은 Kafka+배치가 보장. + +**라이팅 포인트**: "핵심/부가 판단은 기술적 판단이 아니라 비즈니스 판단이다. 같은 연산이라도 UX 관점에서는 다른 답이 나올 수 있다." + +--- + +## 소재 2: Outbox Poller 중복 처리 — 놓치는 것 vs 중복 처리 + +**고민**: 다중 인스턴스에서 Outbox Poller가 같은 행을 두 번 처리하면? + +첫 반응: SELECT FOR UPDATE SKIP LOCKED로 행 잠금 → 중복 방지. + +**반론 (사용자 피드백)**: DB 커넥션을 잠금으로 점유하는 게 대량 트래픽에서 더 치명적이지 않나? 놓치는 것보다 중복 처리가 낫지 않나? + +**분석**: +- SKIP LOCKED: Kafka 장애 시 잠긴 행을 아무도 재시도 못함. 커넥션 점유 → API 응답 지연 +- 중복 허용: Consumer의 event_handled PK lookup으로 중복 걸러냄. 비용 = PK lookup 1회 (~0.1ms) + +놓치는 것 >> 중복 처리. Consumer가 멱등하면 중복은 무해하다. + +**결정**: 잠금 없는 단순 SELECT + Consumer 멱등 처리. At Least Once. + +**라이팅 포인트**: "동시성 제어의 관점을 바꾸자. Producer 쪽에서 중복을 막으려고 DB 잠금을 쓰면, 정작 막아야 할 것(이벤트 유실)이 발생한다. 중복 제어는 Consumer에게 맡기자." + +--- + +## 소재 3: Debezium vs Poller — 망치로 호두 까기? + +**고민**: Outbox → Kafka 발행에 CDC(Debezium)를 쓸 것인가, 단순 Poller를 쓸 것인가? + +**Poller**: @Scheduled 1개면 끝. 5초 주기. 인프라 추가 없음. +**Debezium**: Kafka Connect 클러스터 + MySQL binlog 설정 + Connector 관리. + +처음 분석: "event_outbox 하나를 5초마다 폴링하는 데 Kafka Connect 클러스터를 추가하는 건 망치로 호두 까기" + +**전환 이유**: 실무 적용 전 설정 경험 확보에 의의. 학습 프로젝트에서 과도한 인프라를 일부러 경험하는 것은 가치 있다. + +**Debezium을 선택함으로써 달라진 점**: +1. Outbox 테이블에 status 컬럼 불필요 (binlog에서 읽으므로) +2. 테이블 정리가 극적으로 단순해짐 (1시간 보존 후 DELETE) +3. Near real-time 발행 (5초 → 수백 ms) +4. 다중 인스턴스 중복 발행 문제 원천 해결 + +**트레이드오프**: 인프라 복잡도 ↑↑, 운영 난이도 ↑ / 안정성 ↑, 지연 ↓, 중복 해결 + +**라이팅 포인트**: "올바른 선택은 컨텍스트에 따라 다르다. 프로덕션이라면 Poller로 시작하고 규모가 커지면 Debezium으로 전환하는 것이 맞다. 학습이라면 일부러 어려운 길을 가는 것이 맞다." + +--- + +## 소재 4: 모놀리스에서 Kafka를 쓰는 의미 + +**고민**: "MSA가 아닌데 Kafka를 왜 쓰지?" + +이 프로젝트는 모놀리스가 아니다. commerce-api, commerce-streamer, commerce-batch — 3개의 독립 JVM 프로세스. 하지만 같은 DB를 공유한다. + +**핵심**: Kafka는 MSA 전용 기술이 아니라, 프로세스 간 비동기 통신 인프라. + +MSA에서 Kafka가 필요한 이유: 서비스 간 데이터 동기화 (각자 DB) +멀티 프로세스에서 Kafka가 필요한 이유: 프로세스 간 메시지 전달 + 부하 분리 + +현재 아키텍처에서 Kafka 없이 가능한 대안: +- ApplicationEvent: 같은 JVM 안에서만 동작 → streamer가 받을 수 없음 +- DB 폴링: Kafka가 하는 것과 동일하지만 더 느리고 비효율적 +- Redis Pub/Sub: 구독자 없으면 메시지 유실 +- HTTP 호출: 동기 + 장애 전파 + +**결정**: Kafka는 멀티 프로세스 아키텍처의 필연적 선택. + +**라이팅 포인트**: "Kafka를 '마이크로서비스 아키텍처의 도구'로 한정하면 가능성의 절반을 잃는다. 프로세스 간 비동기 통신이 필요한 모든 곳에서 Kafka는 유효하다." + +--- + +## 소재 5: Outbox 테이블 정리 — 라운드 로빈 vs 파티셔닝 vs Debezium + DELETE + +**고민**: 쿠팡급 일 150만 건, 연 5.5억 건이 쌓이는 Outbox를 어떻게 정리하나? + +분석한 방법: Batch DELETE, 라운드 로빈, PARTITION DROP, MySQL EVENT, Debezium + 단순 DELETE + +**라운드 로빈의 치명적 문제**: JPA Entity는 테이블명이 고정(@Table(name="...")). 두 테이블 교대 → Native Query 강제 → DIP 위반. 코드가 인프라 구현에 오염된다. + +**PARTITION BY RANGE**: JPA 호환, DROP PARTITION O(1). 하지만 PK에 파티션키(created_at) 포함 필수 → 복합 PK 강제. + +**반전**: Debezium을 도입하면 테이블이 "큐"가 아니라 "쓰기 로그"로 변한다. 1시간 보존이면 최대 6.25만 건. 이 규모에서 DELETE는 ~1초. + +**결정**: Debezium + 단순 Batch DELETE. "복잡한 문제가 아니라, 문제를 복잡하게 만들지 않는 것." + +**라이팅 포인트**: "상위 설계 결정(Debezium 도입)이 하위 문제(테이블 정리)를 소멸시킨 사례. 개별 문제를 최적화하기 전에, 문제 자체를 없앨 수 있는 상위 결정이 있는지 먼저 살펴보자." + +--- + +## 소재 6: @Async 스레드 풀 — "DB 커넥션과 싸우지 마라" + +**고민**: @Async 스레드 풀 크기를 어떻게 잡나? + +첫 분석: 피크 TPS 기반으로 core=4, max=8 추천. +재분석: @Async에서 실행되는 작업이 DB 커넥션을 쓰는가? + +- incrementLikeCount → 동기 (Tomcat 스레드에서 실행) → DB 커넥션은 Tomcat 풀 몫 +- 캐시 무효화 → 동기 → Redis (Lettuce NIO, 풀 불필요) +- 유저 로깅 → @Async → DB/Redis 불필요 +- Kafka 발행 → @Async → KafkaTemplate.send()는 논블로킹 + +**결정**: @Async 작업은 모두 초경량. core=2, max=4로 충분. "DB 커넥션 풀과 경합하지 않는 것을 확인한 후에야 풀 크기를 결정할 수 있다." + +**라이팅 포인트**: "스레드 풀 크기는 작업 수가 아니라, 작업이 잡는 자원으로 결정한다. CPU 바운드 작업에 큰 풀은 컨텍스트 스위칭 비용만 늘린다." + +--- + +## 소재 7: Kafka 설정 점검 — value-serializer 오타가 동작하는 이유 + +**발견**: kafka.yml의 consumer 섹션에 `value-serializer` (serializer 키에 Deserializer 클래스). + +Spring Boot는 이 키를 무시한다 (consumer에는 `value-deserializer` 키만 인식). 그런데 동작하는 이유: KafkaConfig.java에서 ByteArrayJsonMessageConverter를 설정해서 변환을 대체하고 있다. + +**교훈**: "설정이 잘못되어도 다른 계층이 보완해서 동작하면, 문제를 발견하기 어렵다. 코드 리뷰에서 설정 파일도 검증 대상이다." + +--- + +## 소재 8: Kafka Config 심층 분석 — "설정은 개별이 아니라 조합으로 검증해야 한다" + +설계 명세를 작성하고 Kafka 기술 가이드 키워드(acks, min.insync.replicas, idempotency, zero-copy, page cache, KRaft)로 리뷰하면서 7가지 문제를 발견했다. 핵심은 **개별 설정값이 아니라 설정 간 상호작용**을 이해하지 못하면 "설정했는데 안 되는" 상황이 발생한다는 것. + +### 8-1. acks=all이 무의미해지는 순간 + +`acks=all`은 "ISR(In-Sync Replicas) 전원에게 기록 확인"이다. 그런데 **브로커가 1대뿐이면 ISR = {Leader 1대}**이므로 `acks=all ≡ acks=1`이다. `acks=all`이 의미를 갖으려면: + +| 조합 | 효과 | +|---|---| +| acks=all + replicas=1 | acks=1과 동일 — 무의미 | +| acks=all + replicas=3 + min.insync.replicas=1 | Leader만 확인 — 여전히 약함 | +| acks=all + replicas=3 + min.insync.replicas=2 | Leader + 최소 1 Follower 확인 — **프로덕션 권장** | + +**라이팅 포인트**: "acks=all은 '완벽한 안전'이 아니라, min.insync.replicas와 조합될 때만 의미가 있다. 설정의 의미는 단독이 아니라 조합에서 나온다." + +### 8-2. enable.idempotence=true + retries=3의 모순 + +Idempotent Producer는 내부적으로 `retries=Integer.MAX_VALUE`를 강제한다. 그런데 `retries: 3`을 yml에 명시하면 **기본값을 덮어쓴다**. 결과: 3회 재시도 후 포기 → 메시지 유실 가능. idempotent producer의 핵심 보장("절대 유실하지 않음")이 깨진다. + +올바른 제어: `retries`를 건드리지 않고, `delivery.timeout.ms`(기본 120초)로 **시간 기반** 제어. + +**고민**: 왜 이 실수가 흔한가? — Spring Boot의 yml 설정이 Kafka 클라이언트의 기본값 체계를 **완전히 무시**하기 때문이다. `retries: 3`은 "3번만 재시도하세요"라는 명시적 지시이고, enable.idempotence의 암묵적 기본값(MAX_VALUE)보다 우선한다. **명시 > 암묵**이라는 설정 우선순위 원칙이 여기서 함정이 된다. + +**라이팅 포인트**: "프레임워크가 자동으로 설정해주는 값을 '직접 설정'으로 덮어쓰는 순간, 자동 설정의 의도도 함께 덮어쓴다. 설정을 추가하기 전에, '이 값을 내가 관리해야 하는가?'를 먼저 질문하자." + +### 8-3. Consumer 멱등성의 원자성 갭 + +초기 설계: `비즈니스 로직 실행 → event_handled INSERT`. 이 두 단계 사이에 크래시가 발생하면? + +``` +비즈니스 로직 성공 (product_metrics +1) + ← 여기서 크래시 +event_handled INSERT (실행 안 됨) +→ 재시작 시 event_handled에 없음 → 다시 처리 → product_metrics +1 (중복) +``` + +**해결**: INSERT-first 패턴 — `event_handled INSERT → 비즈니스 로직`을 **단일 트랜잭션**으로 묶는다. + +- event_handled INSERT 성공 → 비즈니스 로직 실행 → TX 커밋: 정상 흐름 +- 비즈니스 로직 실패 → TX 롤백: event_handled도 롤백 → 재시도 가능 +- TX 커밋 후 크래시 → 재시작 시 event_handled에 이미 존재 → skip + +**라이팅 포인트**: "멱등 처리에서 '확인'과 '실행'이 원자적이지 않으면, 멱등이 아니다. 체크와 실행 사이의 갭이 바로 장애가 파고드는 틈이다." + +### 8-4. 놓치기 쉬운 설정들 + +| 설정 | 역할 | 왜 놓치나 | +|---|---|---| +| `isolation.level: read_committed` | TX 커밋된 메시지만 읽기 | Debezium이 TX 단위로 발행하므로 필수인데, Consumer 설정이라 Producer 설계 시 빠짐 | +| `auto.offset.reset: earliest` | 신규 Consumer Group이 처음부터 읽기 | 기본값 `latest` → 기존 메시지 유실 | +| `max.poll.interval.ms` | poll() 간격 초과 시 리밸런싱 | SINGLE_LISTENER에서 건별 CAS UPDATE → 기본 5분 초과 가능 | +| `compression.type: lz4` | 배치 압축 | "압축은 나중에"라는 생각 → 초기부터 설정해야 Broker 디스크 + 네트워크 절약 | + +### 8-5. Zero-Copy와 OS Page Cache — 배치/압축 설정의 물리적 근거 + +Kafka가 빠른 이유를 두 가지 OS 최적화로 설명할 수 있다: + +1. **Zero-Copy**: Broker → Consumer 전송 시 `sendfile()` 시스템콜 사용. 디스크 → 커널 버퍼 → 네트워크 소켓으로 **유저 스페이스를 거치지 않고** 직접 전달. CPU 사용량과 메모리 복사 최소화. + +2. **OS Page Cache**: Broker는 메시지를 JVM 힙이 아닌 OS 페이지 캐시에 저장. 최근 메시지는 디스크 I/O 없이 메모리에서 서빙. + +이 두 최적화의 효율을 극대화하는 것이 `linger.ms`, `batch.size`, `compression.type` 설정의 **물리적 근거**다: +- 작은 메시지를 하나씩 보내면 → 네트워크 라운드트립 N배 + sendfile 호출 N배 +- 배치로 묶어서 보내면 → 한 번의 sendfile로 큰 블록 전송 + 압축으로 페이지 캐시 적중률 향상 + +**라이팅 포인트**: "설정값의 의미를 물리 계층까지 추적하면, '왜 이 값인가'에 답할 수 있다. linger.ms=50은 '50ms 지연'이 아니라, 'Zero-Copy 한 번의 전송량을 극대화하는 버퍼링 시간'이다." + +### 8-6. Consumer Group 분리 — 처리 특성이 다르면 격리하라 + +MetricsConsumer(배치 UPSERT)와 CouponIssueConsumer(건별 CAS)가 같은 group-id를 공유하면: 쿠폰 발급의 건별 처리 지연 → group 전체 리밸런싱 → 메트릭 집계까지 중단. + +**결정**: `metrics-collector`, `coupon-issuer`로 분리. 처리 특성(배치 vs 건별), 부하 패턴(상시 vs 이벤트성), 장애 영향 범위를 기준으로 Consumer Group을 설계한다. + +--- + +## (기록 예정) + +- [x] 08 설계 명세 작성 시 최종 설계 결정 기록 +- [ ] Phase별 구현 시 구현 고민점/문제점/해결 흐름 추가 +- [ ] Debezium 셋업 과정에서의 삽질 기록 +- [ ] 선착순 쿠폰 동시성 테스트 결과와 인사이트 diff --git a/blog/week7-offset-strategy.md b/blog/week7-offset-strategy.md new file mode 100644 index 0000000000..035b8169f4 --- /dev/null +++ b/blog/week7-offset-strategy.md @@ -0,0 +1,215 @@ +Kafka 오프셋 전략 — 수동 커밋, At-Least-Once, 그리고 DLQ가 동작하지 않던 버그 + +> 이 파일은 블로그 글과 PR 설명에 사용할 소재 정리다. + +--- + +## 배경: 오프셋 관리가 중요한 이유 + +Kafka에서 "어디까지 읽었는가"를 추적하는 오프셋은 컨슈머가 직접 관리한다. 브로커는 모른다. 이 설계 덕분에 Kafka는 높은 처리량을 유지하지만, 오프셋을 잘못 관리하면 메시지 유실이나 중복 처리가 발생한다. + +우리 시스템에는 두 가지 Consumer가 있다: + +| Consumer | 토픽 | 실패 시 허용 수준 | +|---|---|---| +| MetricsConsumer | catalog-events, order-events | 유실 허용 (배치 보정) | +| CouponIssueConsumer | coupon-issue-requests | **유실 불허** (선착순 쿠폰) | + +같은 시스템이지만 실패 시 허용 수준이 다르다. 이 차이가 오프셋 커밋 전략에 직접적으로 영향을 준다. + +--- + +## 설계 결정 1: 수동 커밋 (enable-auto-commit=false + AckMode.MANUAL) + +### 자동 커밋의 문제 + +자동 커밋(`enable.auto.commit=true`)은 `auto.commit.interval.ms`(기본 5초) 주기로 커밋한다. 메시지를 poll 했지만 아직 처리하지 않은 시점에 커밋이 일어날 수 있다. 이 상태에서 컨슈머가 죽으면 메시지가 유실된다. + +``` +poll() → 3000건 수신 → [자동 커밋 발생] → 1500건째 처리 중 crash +→ 재시작 시 커밋된 오프셋부터 읽음 → 1500건 유실 +``` + +### 우리의 선택 + +```yaml +consumer: + properties: + enable-auto-commit: false +listener: + ack-mode: manual +``` + +모든 Factory에서 `AckMode.MANUAL` 적용. 비즈니스 로직이 완료된 후 명시적으로 `ack.acknowledge()`를 호출해야만 오프셋이 커밋된다. + +### 라이팅 포인트 + +"자동 커밋은 편리하지만, 편리함이 안전을 보장하지는 않는다. 메시지 유실이 허용되지 않는 도메인에서는 수동 커밋 외에 선택지가 없다." + +--- + +## 설계 결정 2: Consumer별 커밋 + 실패 처리 전략 분리 + +### MetricsConsumer: catch-and-continue + 배치 보정 + +```java +for (ConsumerRecord record : records) { + try { + tx.executeWithoutResult(status -> processRecord(record)); + } catch (Exception e) { + log.error("처리 실패", e); // 실패한 레코드는 스킵 + } +} +ack.acknowledge(); // 배치 전체 ack +``` + +실패한 레코드를 스킵하고 전체 배치를 ack한다. 이건 의도된 설계다: +- 집계 데이터는 즉시 정확하지 않아도 된다 +- MetricsReconcile 배치가 주기적으로 정합성을 보정한다 +- 하나의 실패 레코드 때문에 나머지 2,999건이 재처리되는 건 비효율적이다 + +### CouponIssueConsumer: 예외 전파 + DLQ + +```java +public void consume(ConsumerRecord record, Acknowledgment ack) { + TransactionTemplate tx = new TransactionTemplate(transactionManager); + tx.executeWithoutResult(status -> processRecord(record)); + ack.acknowledge(); +} +``` + +예외가 발생하면 `ack.acknowledge()`에 도달하지 못한다. 예외는 Spring Kafka의 `DefaultErrorHandler`로 전파되어: + +1. `FixedBackOff(1000L, 3)` — 1초 간격으로 3회 재시도 +2. 재시도 모두 실패 시 `DeadLetterPublishingRecoverer`가 `coupon-issue-requests.DLT` 토픽으로 전송 +3. DLT 메시지는 운영자 확인 후 재처리 + +### 이전 버그: DLQ가 동작하지 않았던 이유 + +초기 구현에서는 CouponIssueConsumer도 MetricsConsumer와 동일한 패턴을 사용했다: + +```java +// 버그가 있던 코드 +try { + tx.executeWithoutResult(status -> processRecord(record)); +} catch (Exception e) { + log.error("처리 실패", e); // 예외를 삼킴 +} +ack.acknowledge(); // 항상 ack → DLQ 도달 불가 +``` + +`DefaultErrorHandler + DeadLetterPublishingRecoverer`를 Factory에 설정했지만, `consume()` 메서드 내부에서 예외를 catch하고 ack까지 호출하므로 ErrorHandler에 예외가 전파되지 않았다. DLQ 설정이 사실상 죽은 코드였다. + +실패 시 흐름: +``` +DB 에러 → TransactionTemplate 롤백 → catch에서 로그만 → ack → 오프셋 커밋 +→ 메시지 재전달 불가, coupon_issue_request는 PENDING으로 영구 방치 +``` + +### 수정 후 흐름 + +``` +DB 에러 → TransactionTemplate 롤백 → 예외 전파 → DefaultErrorHandler +→ 1초 후 재시도 (최대 3회) → 여전히 실패 → DLT 토픽으로 전송 +→ 오프셋 커밋 → 다음 메시지 처리 계속 +``` + +### 라이팅 포인트 + +"DLQ를 설정했다고 동작하는 게 아니다. 예외가 ErrorHandler까지 전파되는 경로가 확보되어야 한다. try-catch로 예외를 삼키면 아무리 정교한 에러 핸들링 체인도 무용지물이 된다." + +"같은 시스템 안에서도 Consumer마다 실패 허용 수준이 다르다. 집계 데이터의 실패와 쿠폰 발급의 실패는 비즈니스 임팩트가 다르고, 그 차이가 코드 구조에 반영되어야 한다." + +--- + +## 발견 및 수정: auto.offset.reset 설정 충돌 + +### 문제 + +kafka.yml에 같은 Kafka 속성이 두 곳에 선언되어 있었다: + +```yaml +# 전역 properties +properties: + auto: + offset.reset: latest # ← latest + +# consumer 전용 +consumer: + auto-offset-reset: earliest # ← earliest +``` + +Spring Boot에서 consumer 전용이 전역을 오버라이드하므로 `earliest`가 적용되지만, 의도와 다른 값이 혼재하면: +- 코드 리뷰 시 어느 값이 적용되는지 혼란 +- Spring Boot 버전 업그레이드 시 merge 순서 변경 리스크 +- 죽은 설정이 남아있으면 "이게 왜 있지?" 질문을 유발 + +### 수정 + +전역 properties에서 `offset.reset: latest` 제거. consumer 전용 `auto-offset-reset: earliest`만 유지. + +### 왜 earliest인가 + +우리 시스템은 새 Consumer Group 배포 시 **과거 메시지부터 처리**해야 한다: +- MetricsConsumer: 기존 이벤트를 모두 집계해야 product_metrics가 정확 +- CouponIssueConsumer: 발급 요청이 누락되면 사용자 불만 + +`latest`는 "현재 시점 이후"만 처리하므로, 배포 직전까지 쌓인 메시지를 모두 유실한다. `earliest`는 대량 과거 메시지 처리 부담이 있지만, INSERT-first 멱등 패턴으로 중복을 방지하므로 안전하다. + +### 라이팅 포인트 + +"설정 파일에 같은 속성이 두 곳에 다른 값으로 존재하면, 현재 동작이 맞더라도 시한폭탄이다. 설정은 하나의 진실만 가져야 한다." + +--- + +## At-Least-Once와 멱등성의 관계 + +### 오프셋 커밋 타이밍과 메시지 보장 수준 + +| 시나리오 | MetricsConsumer | CouponIssueConsumer | +|---|---|---| +| 정상 처리 | exactly-once (멱등) | exactly-once (멱등) | +| 처리 중 crash (ack 전) | at-least-once → 멱등 스킵 | at-least-once → 멱등 스킵 | +| DB 에러로 처리 실패 | skip + ack (best-effort) | 재시도 3회 → DLQ | +| 리밸런싱으로 재전달 | at-least-once → 멱등 스킵 | at-least-once → 멱등 스킵 | + +### 멱등 패턴이 없으면 + +at-least-once는 "최소 한 번 처리"를 보장하지만, "정확히 한 번"은 보장하지 않는다. 멱등 패턴 없이 at-least-once를 사용하면: +- 좋아요 수가 중복 증가 +- 쿠폰이 중복 발급 +- 주문 집계가 뻥튀기 + +우리의 INSERT IGNORE event_handled 패턴은 "이미 처리한 이벤트인가?"를 DB 레벨에서 확인하여, at-least-once 전달 + exactly-once 처리를 달성한다. + +### 라이팅 포인트 + +"Kafka의 메시지 보장은 '전달(delivery)' 관점이다. at-least-once delivery가 at-least-once processing이 되지 않으려면, 컨슈머 측 멱등성이 필수다. 전달 보장과 처리 보장은 다른 레이어의 문제다." + +--- + +## 전체 오프셋 전략 요약 + +``` +┌──────────────────────────────────────────────────────┐ +│ 오프셋 관리 전략 │ +├──────────────────────────────────────────────────────┤ +│ [공통] │ +│ ├── enable-auto-commit: false │ +│ ├── ack-mode: MANUAL │ +│ ├── auto-offset-reset: earliest │ +│ └── isolation.level: read_committed │ +│ │ +│ [MetricsConsumer — best-effort] │ +│ ├── 실패 시: catch + log + skip │ +│ ├── 전체 배치 ack │ +│ ├── 보정: MetricsReconcile 배치 │ +│ └── 보장 수준: at-most-once (실패 시) + 배치 보정 │ +│ │ +│ [CouponIssueConsumer — 유실 불허] │ +│ ├── 실패 시: 예외 전파 → ErrorHandler │ +│ ├── 재시도: FixedBackOff(1초 × 3회) │ +│ ├── 최종 실패: DLT 토픽 전송 │ +│ └── 보장 수준: at-least-once + 멱등 │ +└──────────────────────────────────────────────────────┘ +``` diff --git a/blog/week7-rebalancing-strategy.md b/blog/week7-rebalancing-strategy.md new file mode 100644 index 0000000000..36cf77d6a3 --- /dev/null +++ b/blog/week7-rebalancing-strategy.md @@ -0,0 +1,193 @@ +Kafka 리밸런싱 전략 — 이커머스 이벤트 파이프라인에 적용한 설계와 근거 + +> 이 파일은 블로그 글과 PR 설명에 사용할 소재 정리다. + +--- + +## 배경: 리밸런싱이 왜 중요한가 + +Kafka Consumer Group에서 컨슈머가 추가/제거되면 파티션 재할당(리밸런싱)이 발생한다. 리밸런싱 동안 메시지 소비가 중단되므로, 대규모 트래픽 환경에서는 이 중단 시간이 곧 메시지 적체로 이어진다. + +우리 시스템에는 성격이 다른 두 가지 Consumer가 있다: + +| Consumer | 토픽 | 특성 | +|---|---|---| +| MetricsConsumer | catalog-events, order-events | 집계용, 순서 무관, 대량 배치 처리 | +| CouponIssueConsumer | coupon-issue-requests | 선착순 발급, 순서 중요, 건별 처리 | + +이 두 Consumer에 동일한 리밸런싱 설정을 적용하는 건 맞지 않다. 처리 특성이 다르면 리밸런싱 트리거 조건도 달라야 한다. + +--- + +## 설계 결정 1: Cooperative Sticky Assignor 명시 + +### 문제 + +Kafka의 기본 파티션 할당 전략인 Round Robin(Eager)은 리밸런싱 시 **모든 컨슈머의 파티션을 회수한 뒤 재할당**한다. 이 동안 전체 Consumer Group이 멈추는 Stop-the-world가 발생한다. + +### 결정 + +`CooperativeStickyAssignor`를 명시적으로 설정했다. + +```yaml +# kafka.yml +consumer: + properties: + partition.assignment.strategy: org.apache.kafka.clients.consumer.CooperativeStickyAssignor +``` + +### 근거 + +- Cooperative 프로토콜은 **변경이 필요한 파티션만** 재할당한다. 나머지 파티션은 기존 컨슈머가 계속 처리한다. +- Kafka 3.x+에서 기본값이긴 하지만, 버전 업그레이드 시 동작 변경 리스크를 방지하기 위해 명시한다. +- 운영 환경에서 "왜 이 전략인가?"를 코드만 보고 파악할 수 있어야 한다. + +### 트레이드오프 + +Cooperative Sticky는 리밸런싱이 2단계(revoke → assign)로 나뉘어 전체 시간이 약간 더 길 수 있다. 하지만 **소비 중단 없이** 진행되므로, 처리량 관점에서는 이점이 크다. + +--- + +## 설계 결정 2: Consumer 특성별 max.poll.interval.ms 분리 + +### 문제 + +`max.poll.interval.ms`는 "두 번의 poll() 사이 최대 허용 시간"이다. 이 시간을 초과하면 브로커가 해당 컨슈머를 죽은 것으로 판단하고 리밸런싱을 시작한다. 하지만 두 Consumer의 처리 시간이 근본적으로 다르다. + +### 결정 + +| 설정 | BATCH_LISTENER (MetricsConsumer) | SINGLE_LISTENER (CouponIssueConsumer) | +|---|---|---| +| max.poll.records | 3,000 | 1 | +| max.poll.interval.ms | **2분** | **3분** | +| session.timeout.ms | 60초 | 60초 | +| heartbeat.interval.ms | 20초 (session의 1/3) | 20초 | + +### 산술 근거 + +**MetricsConsumer (2분)**: +- 3,000건 × UPSERT 1건당 ~1ms = 최대 3초 +- 2분(120초) = 40배 마진 + +**CouponIssueConsumer (3분)**: +- 정상 처리: JSON 파싱 + INSERT IGNORE + CAS UPDATE + INSERT + UPDATE = ~10ms +- DLQ 재시도: FixedBackOff(1초 × 3회) = 3초 +- 커넥션 풀 고갈 최악 케이스: HikariCP connectionTimeout 30초 + 재시도 3초 = ~33.5초 +- 3분(180초) = 최악 케이스 대비 **5배 마진** + +### 왜 10분이 아니라 3분인가 + +초기에는 CouponIssueConsumer의 max.poll.interval.ms를 10분으로 설정했다. 하지만 검토 결과: + +- 10분은 정상 처리 대비 60,000배 마진 — 과도하다 +- 컨슈머가 실제로 stuck 되었을 때(deadlock, 무한루프), **10분간 감지 불가** +- 선착순 쿠폰 플래시 세일 기준 100 req/s × 600초 = **6만 건 처리 지연** +- 3분으로 줄이면 stuck 감지 시간 1/3로 단축, 커넥션 풀 고갈 최악 케이스에도 5배 마진 확보 + +**교훈**: 타임아웃 값은 "넉넉하게"가 아니라 "최악 케이스의 N배"로 설정해야 한다. 너무 짧으면 불필요한 리밸런싱, 너무 길면 장애 감지 지연. 산술적 근거 없이 설정하면 양쪽 다 위험하다. + +--- + +## 설계 결정 3: Static Membership (group.instance.id) + +### 문제 + +컨슈머가 재시작되면 브로커는 새로운 멤버로 인식하여 리밸런싱을 트리거한다. Rolling deployment 시 N개 인스턴스가 순차 재시작되면 N번의 리밸런싱이 발생한다. + +### 결정 + +`group.instance.id`를 호스트명 기반으로 설정한다. + +```java +// KafkaConfig.java +@Value("${HOSTNAME:local}") +private String hostname; + +// BATCH_LISTENER +consumerConfig.put(ConsumerConfig.GROUP_INSTANCE_ID_CONFIG, hostname + "-batch"); + +// SINGLE_LISTENER +consumerConfig.put(ConsumerConfig.GROUP_INSTANCE_ID_CONFIG, hostname + "-single"); +``` + +### 효과 + +- 컨슈머 재시작 시 `session.timeout.ms`(60초) 이내에 복귀하면 **리밸런싱 없이 기존 파티션 유지** +- Rolling deployment 시 불필요한 리밸런싱 방지 +- Kubernetes 환경에서 `HOSTNAME`은 Pod 이름으로 자동 설정됨 + +### 주의점 + +- `session.timeout.ms`를 초과하는 재시작은 여전히 리밸런싱 발생 +- 인스턴스가 영구 제거될 때는 해당 `group.instance.id`의 파티션이 `session.timeout.ms` 후에야 재할당됨 + +--- + +## 리밸런싱 발생 시 안전성: INSERT-first 멱등 패턴 + +리밸런싱으로 파티션이 재할당되면 **이미 처리했지만 ack 전인 메시지가 재처리**될 수 있다. 이 중복 소비에 대한 안전장치가 필요하다. + +### MetricsConsumer (BATCH, MANUAL ack) + +``` +3,000건 처리 중 1,500건째에 리밸런싱 발생 +→ ack.acknowledge() 호출 전이므로 3,000건 전체 재처리 +→ INSERT IGNORE event_handled로 1,500건은 멱등 스킵 +→ 나머지 1,500건만 실제 처리 +→ UPSERT product_metrics이므로 값이 꼬이지 않음 +``` + +### CouponIssueConsumer (SINGLE, MANUAL ack) + +``` +쿠폰 발급 처리 중 리밸런싱 발생 +→ ack 전이므로 해당 메시지 재처리 +→ INSERT IGNORE event_handled로 중복 감지 → 스킵 +→ 이미 발급된 쿠폰이 다시 발급되지 않음 +``` + +핵심은 **"리밸런싱을 막는 것"이 아니라 "리밸런싱이 발생해도 비즈니스가 깨지지 않는 구조"**를 만드는 것이다. + +--- + +## 전체 설정 요약 + +``` +┌──────────────────────────────────────────────────────────────┐ +│ Kafka Consumer 설정 │ +├──────────────────────────────────────────────────────────────┤ +│ [공통] │ +│ ├── partition.assignment.strategy: CooperativeStickyAssignor│ +│ ├── isolation.level: read_committed │ +│ ├── enable-auto-commit: false │ +│ └── ack-mode: MANUAL │ +│ │ +│ [BATCH_LISTENER — MetricsConsumer] │ +│ ├── max.poll.records: 3,000 │ +│ ├── max.poll.interval.ms: 120,000 (2분) │ +│ ├── session.timeout.ms: 60,000 (1분) │ +│ ├── heartbeat.interval.ms: 20,000 (20초) │ +│ ├── group.instance.id: ${HOSTNAME}-batch │ +│ ├── concurrency: 3 │ +│ └── 멱등: INSERT IGNORE event_handled + UPSERT │ +│ │ +│ [SINGLE_LISTENER — CouponIssueConsumer] │ +│ ├── max.poll.records: 1 │ +│ ├── max.poll.interval.ms: 180,000 (3분) │ +│ ├── session.timeout.ms: 60,000 (1분) │ +│ ├── heartbeat.interval.ms: 20,000 (20초) │ +│ ├── group.instance.id: ${HOSTNAME}-single │ +│ ├── concurrency: 1 │ +│ └── 멱등: INSERT IGNORE + CAS UPDATE + DLQ │ +└──────────────────────────────────────────────────────────────┘ +``` + +--- + +## 라이팅 포인트 + +1. **"리밸런싱을 막는 것 vs 리밸런싱에 안전한 것"** — 분산 시스템에서 장애를 완전히 막을 수는 없다. 막으려 하기보다 발생해도 안전한 구조를 만드는 게 Resilience다. + +2. **"타임아웃은 감으로 정하지 않는다"** — max.poll.interval.ms를 10분으로 잡으면 안전해 보이지만, stuck 컨슈머를 10분간 방치하는 것과 같다. 최악 케이스를 산출하고, 적절한 마진 배수를 곱하는 게 엔지니어링이다. + +3. **"같은 시스템 안에서도 Consumer마다 전략이 달라야 한다"** — 집계용 Consumer와 발급용 Consumer에 같은 타임아웃을 적용하는 건 "모든 API에 동일한 Circuit Breaker 임계값을 적용하는 것"과 같다. 도메인 특성이 다르면 인프라 설정도 달라야 한다. diff --git a/build.gradle.kts b/build.gradle.kts index 9c8490b8a9..e3a14cd100 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -82,7 +82,7 @@ subprojects { useJUnitPlatform() systemProperty("user.timezone", "Asia/Seoul") systemProperty("spring.profiles.active", "test") - jvmArgs("-Xshare:off") + jvmArgs("-Xshare:off", "-Xmx2g") } tasks.withType { diff --git a/docker/grafana/provisioning/dashboards/dashboard.yml b/docker/grafana/provisioning/dashboards/dashboard.yml new file mode 100644 index 0000000000..296c3a1226 --- /dev/null +++ b/docker/grafana/provisioning/dashboards/dashboard.yml @@ -0,0 +1,11 @@ +apiVersion: 1 +providers: + - name: 'default' + orgId: 1 + folder: '' + type: file + disableDeletion: false + editable: true + options: + path: /etc/grafana/provisioning/dashboards + foldersFromFilesStructure: false diff --git a/docker/grafana/provisioning/dashboards/queue-system.json b/docker/grafana/provisioning/dashboards/queue-system.json new file mode 100644 index 0000000000..6c28d2c01d --- /dev/null +++ b/docker/grafana/provisioning/dashboards/queue-system.json @@ -0,0 +1,336 @@ +{ + "annotations": { + "list": [] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "links": [], + "panels": [ + { + "id": 1, + "title": "System Utilization (ρ)", + "description": "DB 커넥션 풀 이용률. ρ ≤ 0.7 유지 필요. 0.8 이상 시 락 경합 시작, 양의 피드백 루프 위험.", + "type": "gauge", + "gridPos": { "h": 8, "w": 6, "x": 0, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 0.5 }, + { "color": "orange", "value": 0.7 }, + { "color": "red", "value": 0.85 } + ] + }, + "min": 0, + "max": 1, + "unit": "percentunit" + }, + "overrides": [] + }, + "options": { + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "", + "values": false + }, + "showThresholdLabels": true, + "showThresholdMarkers": true + }, + "targets": [ + { + "expr": "hikaricp_connections_active / hikaricp_connections_max", + "legendFormat": "ρ (utilization)", + "refId": "A" + } + ] + }, + { + "id": 2, + "title": "DB Connection Pool", + "description": "HikariCP active/idle/pending 시계열. pending > 0 이면 풀 고갈 임박.", + "type": "timeseries", + "gridPos": { "h": 8, "w": 9, "x": 6, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "lineInterpolation": "smooth", + "fillOpacity": 10, + "pointSize": 5, + "lineWidth": 2 + } + }, + "overrides": [ + { + "matcher": { "id": "byName", "options": "pending" }, + "properties": [ + { "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } } + ] + } + ] + }, + "targets": [ + { + "expr": "hikaricp_connections_active", + "legendFormat": "active", + "refId": "A" + }, + { + "expr": "hikaricp_connections_idle", + "legendFormat": "idle", + "refId": "B" + }, + { + "expr": "hikaricp_connections_pending", + "legendFormat": "pending", + "refId": "C" + } + ] + }, + { + "id": 3, + "title": "Order API p99 Latency", + "description": "주문 API p99 레이턴시. 358ms(1차 측정) 이상이면 부하 과다.", + "type": "timeseries", + "gridPos": { "h": 8, "w": 9, "x": 15, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "unit": "s", + "custom": { + "drawStyle": "line", + "lineInterpolation": "smooth", + "fillOpacity": 10, + "lineWidth": 2, + "thresholdsStyle": { + "mode": "line" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "red", "value": 0.358 } + ] + } + }, + "overrides": [] + }, + "targets": [ + { + "expr": "histogram_quantile(0.99, rate(http_server_requests_seconds_bucket{uri=\"/api/v1/orders\"}[1m]))", + "legendFormat": "p99", + "refId": "A" + }, + { + "expr": "histogram_quantile(0.90, rate(http_server_requests_seconds_bucket{uri=\"/api/v1/orders\"}[1m]))", + "legendFormat": "p90", + "refId": "B" + } + ] + }, + { + "id": 4, + "title": "Queue Depth", + "description": "현재 대기열 크기 (queue.waiting.size). max=48,000.", + "type": "timeseries", + "gridPos": { "h": 8, "w": 8, "x": 0, "y": 8 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "lineInterpolation": "smooth", + "fillOpacity": 20, + "lineWidth": 2, + "gradientMode": "scheme", + "thresholdsStyle": { + "mode": "line" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "red", "value": 48000 } + ] + } + }, + "overrides": [] + }, + "targets": [ + { + "expr": "queue_waiting_size", + "legendFormat": "waiting", + "refId": "A" + } + ] + }, + { + "id": 5, + "title": "Admission Rate", + "description": "초당 입장 처리 유저 수. 설계값 80 TPS.", + "type": "timeseries", + "gridPos": { "h": 8, "w": 8, "x": 8, "y": 8 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "unit": "reqps", + "custom": { + "drawStyle": "line", + "lineInterpolation": "smooth", + "fillOpacity": 10, + "lineWidth": 2 + } + }, + "overrides": [] + }, + "targets": [ + { + "expr": "rate(queue_admission_count_total[1m])", + "legendFormat": "admission rate", + "refId": "A" + } + ] + }, + { + "id": 6, + "title": "Queue Enter 결과 분포", + "description": "enter API 결과별 카운트 (QUEUED/ADMITTED/QUEUE_FULL).", + "type": "piechart", + "gridPos": { "h": 8, "w": 8, "x": 16, "y": 8 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "increase(queue_enter_status_total{status=\"QUEUED\"}[5m])", + "legendFormat": "QUEUED", + "refId": "A" + }, + { + "expr": "increase(queue_enter_status_total{status=\"ADMITTED\"}[5m])", + "legendFormat": "ADMITTED", + "refId": "B" + }, + { + "expr": "increase(queue_enter_status_total{status=\"QUEUE_FULL\"}[5m])", + "legendFormat": "QUEUE_FULL", + "refId": "C" + } + ] + }, + { + "id": 7, + "title": "Safe TPS (Little's Law)", + "description": "실시간 안전 TPS = 28 / p99. p99가 변하면 Safe TPS가 재계산되어 배치 크기 조정 시기를 알 수 있다.", + "type": "stat", + "gridPos": { "h": 8, "w": 8, "x": 0, "y": 16 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "unit": "reqps", + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "red", "value": null }, + { "color": "orange", "value": 60 }, + { "color": "green", "value": 80 } + ] + } + }, + "overrides": [] + }, + "options": { + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "", + "values": false + }, + "colorMode": "background", + "graphMode": "area", + "textMode": "value_and_name" + }, + "targets": [ + { + "expr": "28 / histogram_quantile(0.99, rate(http_server_requests_seconds_bucket{uri=\"/api/v1/orders\"}[1m]))", + "legendFormat": "Safe TPS", + "refId": "A" + } + ] + }, + { + "id": 8, + "title": "SSE Connections", + "description": "현재 SSE 연결 수. max=5,000.", + "type": "stat", + "gridPos": { "h": 8, "w": 8, "x": 8, "y": 16 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 3000 }, + { "color": "red", "value": 5000 } + ] + } + }, + "overrides": [] + }, + "targets": [ + { + "expr": "queue_sse_connections", + "legendFormat": "SSE connections", + "refId": "A" + } + ] + }, + { + "id": 9, + "title": "Redis Errors & Fallback", + "description": "Redis 장애 횟수 및 fallback 발동 횟수.", + "type": "timeseries", + "gridPos": { "h": 8, "w": 8, "x": 16, "y": 16 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "bars", + "fillOpacity": 50, + "lineWidth": 1 + } + }, + "overrides": [] + }, + "targets": [ + { + "expr": "rate(queue_admission_errors_total[1m])", + "legendFormat": "admission errors", + "refId": "A" + }, + { + "expr": "rate(queue_token_fallback_total[1m])", + "legendFormat": "token fallback", + "refId": "B" + } + ] + } + ], + "schemaVersion": 39, + "tags": ["queue", "commerce"], + "templating": { "list": [] }, + "time": { "from": "now-30m", "to": "now" }, + "timepicker": {}, + "timezone": "browser", + "title": "Queue System", + "uid": "queue-system", + "version": 1 +} diff --git a/docker/grafana/provisioning/datasources/datasource.yml b/docker/grafana/provisioning/datasources/datasource.yml index 8d9f9d8fed..3c1e8155a9 100644 --- a/docker/grafana/provisioning/datasources/datasource.yml +++ b/docker/grafana/provisioning/datasources/datasource.yml @@ -2,6 +2,7 @@ apiVersion: 1 datasources: - name: Prometheus type: prometheus + uid: prometheus access: proxy url: http://prometheus:9090 isDefault: true \ No newline at end of file diff --git a/docker/infra-compose.yml b/docker/infra-compose.yml index 18e5fcf5f7..c29f405019 100644 --- a/docker/infra-compose.yml +++ b/docker/infra-compose.yml @@ -2,6 +2,11 @@ version: '3' services: mysql: image: mysql:8.0 + command: + - --log-bin=mysql-bin + - --binlog-format=ROW + - --binlog-row-image=FULL + - --server-id=1 ports: - "3306:3306" environment: @@ -88,6 +93,35 @@ services: timeout: 5s retries: 10 + kafka-connect: + image: debezium/connect:2.5 + container_name: kafka-connect + depends_on: + kafka: + condition: service_healthy + mysql: + condition: service_started + ports: + - "8083:8083" + environment: + GROUP_ID: 1 + BOOTSTRAP_SERVERS: kafka:9092 + CONFIG_STORAGE_TOPIC: _connect_configs + OFFSET_STORAGE_TOPIC: _connect_offsets + STATUS_STORAGE_TOPIC: _connect_status + CONFIG_STORAGE_REPLICATION_FACTOR: 1 + OFFSET_STORAGE_REPLICATION_FACTOR: 1 + STATUS_STORAGE_REPLICATION_FACTOR: 1 + KEY_CONVERTER: org.apache.kafka.connect.json.JsonConverter + VALUE_CONVERTER: org.apache.kafka.connect.json.JsonConverter + KEY_CONVERTER_SCHEMAS_ENABLE: "false" + VALUE_CONVERTER_SCHEMAS_ENABLE: "false" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8083/connectors"] + interval: 10s + timeout: 5s + retries: 10 + kafka-ui: image: provectuslabs/kafka-ui:latest container_name: kafka-ui @@ -99,6 +133,8 @@ services: environment: KAFKA_CLUSTERS_0_NAME: local # kafka-ui 에서 보이는 클러스터명 KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka:9092 # kafka-ui 가 연겷할 브로커 주소 + KAFKA_CLUSTERS_0_KAFKACONNECT_0_NAME: debezium + KAFKA_CLUSTERS_0_KAFKACONNECT_0_ADDRESS: http://kafka-connect:8083 volumes: mysql-8-data: @@ -108,4 +144,4 @@ volumes: networks: default: - driver: bridge \ No newline at end of file + driver: bridge diff --git a/docker/register-debezium-connector.sh b/docker/register-debezium-connector.sh new file mode 100755 index 0000000000..788e750acf --- /dev/null +++ b/docker/register-debezium-connector.sh @@ -0,0 +1,44 @@ +#!/bin/bash +set -e + +echo "Waiting for Kafka Connect to be ready..." +until curl -s http://localhost:8083/connectors > /dev/null 2>&1; do + sleep 2 +done + +echo "Registering Debezium MySQL Connector..." +curl -X POST http://localhost:8083/connectors \ + -H "Content-Type: application/json" \ + -d @- << 'EOF' +{ + "name": "loopers-outbox-connector", + "config": { + "connector.class": "io.debezium.connector.mysql.MySqlConnector", + "tasks.max": "1", + "database.hostname": "mysql", + "database.port": "3306", + "database.user": "root", + "database.password": "root", + "database.server.id": "184054", + "topic.prefix": "loopers", + "database.include.list": "loopers", + "table.include.list": "loopers.event_outbox", + "schema.history.internal.kafka.bootstrap.servers": "kafka:9092", + "schema.history.internal.kafka.topic": "_schema_history", + "transforms": "outbox", + "transforms.outbox.type": "io.debezium.transforms.outbox.EventRouter", + "transforms.outbox.table.field.event.id": "id", + "transforms.outbox.table.field.event.key": "aggregate_id", + "transforms.outbox.table.field.event.type": "event_type", + "transforms.outbox.table.field.event.payload": "payload", + "transforms.outbox.route.by.field": "aggregate_type", + "transforms.outbox.route.topic.replacement": "${routedByValue}-events", + "transforms.outbox.table.fields.additional.placement": "event_type:header:eventType", + "tombstones.on.delete": "false" + } +} +EOF + +echo "" +echo "Connector registered successfully!" +curl -s http://localhost:8083/connectors/loopers-outbox-connector/status | python3 -m json.tool diff --git a/docs/captures/volume-10/03-ranking-mv-test-output.md b/docs/captures/volume-10/03-ranking-mv-test-output.md new file mode 100644 index 0000000000..9194715eff --- /dev/null +++ b/docs/captures/volume-10/03-ranking-mv-test-output.md @@ -0,0 +1,190 @@ +# 랭킹 시스템 테스트 결과 캡처 (일간 / 주간 / 월간) + +> 실행일: 2026-04-17 | DB: MySQL 8.0 (Testcontainers) | targetDate: 20260416 + +## 테스트 데이터 설계 + +- 상품 20개 (패션 브랜드), 30일치 일별 메트릭 시드 +- **상품별 트렌드를 다르게** 설정하여 기간별 순위 차이를 의도적으로 발생시킴 +- Score = `0.1*LOG10(view+1)/7 + 0.2*LOG10(like+1)/7 + 0.7*LOG10(net_sales+1)/7` + +| 유형 | 상품 | 특성 | +|------|------|------| +| **급상승** | 1 (나이키), 2 (아디다스), 3 (뉴발란스) | 최근 7일 폭발, 이전 23일은 미미 | +| **장기 강자** | 17 (톰브라운), 18 (르메르), 19 (마르지엘라), 20 (보테가) | 30일 내내 꾸준히 높음 | +| **하락 추세** | 14 (스톤아일랜드), 15 (메종키츠네) | 이전 23일 높았으나 최근 7일 급락 | +| **오늘 바이럴** | 8 (반스 올드스쿨) | 오늘 하루만 조회 15,000 + 매출 500만 폭발 | +| **일반** | 나머지 | 보통 수준으로 꾸준 | + +추가 조건: 상품 19 (메종마르지엘라) — 매일 취소율 50% 적용 + +--- + +## 일간 랭킹 TOP 20 + +> 운영 환경에서는 Redis Speed Layer (ZREVRANGE)로 서빙. 동일 Score 수식을 1일치 `product_metrics`에 적용하여 시뮬레이션. + +``` +═══════════════════════════════════════════════════════════════════════════════════════════════════════ + [일간 랭킹 TOP 20] date=2026-04-16 (당일 1일 집계 — 운영 시 Redis Speed Layer) +═══════════════════════════════════════════════════════════════════════════════════════════════════════ + 순위 │ 상품ID │ 상품명 │ Score │ 조회수 │ 좋아요 │ 순매출액 │ 판매수 +───────┼────────┼────────────────────────────┼────────────┼──────────┼──────────┼──────────────┼────────── + 1 │ 8 │ 반스 올드스쿨 │ 0.8239 │ 15,000 │ 2,000 │ 5,000,000 │ 101 + 2 │ 3 │ 뉴발란스 993 │ 0.8034 │ 6,500 │ 840 │ 4,500,000 │ 91 + 3 │ 2 │ 아디다스 울트라부스트 │ 0.7965 │ 6,000 │ 760 │ 4,000,000 │ 81 + 4 │ 1 │ 나이키 에어맥스 97 │ 0.7888 │ 5,500 │ 680 │ 3,500,000 │ 71 + 5 │ 20 │ 보테가베네타 카세트백 │ 0.7727 │ 2,400 │ 310 │ 3,400,000 │ 69 + 6 │ 18 │ 르메르 크로와상 백 │ 0.7555 │ 1,800 │ 230 │ 2,600,000 │ 53 + 7 │ 17 │ 톰브라운 카디건 │ 0.7448 │ 1,500 │ 190 │ 2,200,000 │ 45 + 8 │ 19 │ 메종마르지엘라 타비슈즈 │ 0.7346 │ 2,100 │ 270 │ 1,500,000 │ 61 + 9 │ 16 │ 아미 하트로고 맨투맨 │ 0.7179 │ 940 │ 110 │ 1,480,000 │ 30 + 10 │ 13 │ 아크테릭스 베타 LT │ 0.7076 │ 820 │ 95 │ 1,240,000 │ 25 + 11 │ 12 │ 파타고니아 다운재킷 │ 0.7037 │ 780 │ 90 │ 1,160,000 │ 24 + 12 │ 11 │ 노스페이스 눕시 │ 0.6996 │ 740 │ 85 │ 1,080,000 │ 22 + 13 │ 10 │ 살로몬 XT-6 │ 0.6952 │ 700 │ 80 │ 1,000,000 │ 21 + 14 │ 9 │ 호카 본디 8 │ 0.6904 │ 660 │ 75 │ 920,000 │ 19 + 15 │ 7 │ 컨버스 척테일러 │ 0.6796 │ 580 │ 65 │ 760,000 │ 16 + 16 │ 6 │ 리복 클래식 │ 0.6733 │ 540 │ 60 │ 680,000 │ 14 + 17 │ 5 │ 푸마 스웨이드 │ 0.6663 │ 500 │ 55 │ 600,000 │ 13 + 18 │ 4 │ 아식스 젤카야노 │ 0.6584 │ 460 │ 50 │ 520,000 │ 11 + 19 │ 15 │ 메종키츠네 폭스티 │ 0.5984 │ 300 │ 30 │ 160,000 │ 4 + 20 │ 14 │ 스톤아일랜드 오버셔츠 │ 0.5861 │ 250 │ 25 │ 130,000 │ 3 +═══════════════════════════════════════════════════════════════════════════════════════════════════════ +``` + +--- + +## 주간 랭킹 TOP 20 + +``` +═══════════════════════════════════════════════════════════════════════════════════════════════════════ + [주간 랭킹 TOP 20] period_key=20260416 (최근 7일 집계) +═══════════════════════════════════════════════════════════════════════════════════════════════════════ + 순위 │ 상품ID │ 상품명 │ Score │ 조회수 │ 좋아요 │ 순매출액 │ 판매수 +───────┼────────┼────────────────────────────┼────────────┼──────────┼──────────┼──────────────┼────────── + 1 │ 3 │ 뉴발란스 993 │ 0.9241 │ 45,500 │ 5,880 │ 31,500,000 │ 637 + 2 │ 2 │ 아디다스 울트라부스트 │ 0.9172 │ 42,000 │ 5,320 │ 28,000,000 │ 567 + 3 │ 1 │ 나이키 에어맥스 97 │ 0.9095 │ 38,500 │ 4,760 │ 24,500,000 │ 497 + 4 │ 20 │ 보테가베네타 카세트백 │ 0.8934 │ 16,800 │ 2,170 │ 23,800,000 │ 483 + 5 │ 18 │ 르메르 크로와상 백 │ 0.8762 │ 12,600 │ 1,610 │ 18,200,000 │ 371 + 6 │ 17 │ 톰브라운 카디건 │ 0.8655 │ 10,500 │ 1,330 │ 15,400,000 │ 315 + 7 │ 19 │ 메종마르지엘라 타비슈즈 │ 0.8553 │ 14,700 │ 1,890 │ 10,500,000 │ 427 + 8 │ 16 │ 아미 하트로고 맨투맨 │ 0.8386 │ 6,580 │ 770 │ 10,360,000 │ 210 + 9 │ 8 │ 반스 올드스쿨 │ 0.8291 │ 16,200 │ 2,120 │ 5,480,000 │ 113 + 10 │ 13 │ 아크테릭스 베타 LT │ 0.8282 │ 5,740 │ 665 │ 8,680,000 │ 175 + 11 │ 12 │ 파타고니아 다운재킷 │ 0.8243 │ 5,460 │ 630 │ 8,120,000 │ 168 + 12 │ 11 │ 노스페이스 눕시 │ 0.8202 │ 5,180 │ 595 │ 7,560,000 │ 154 + 13 │ 10 │ 살로몬 XT-6 │ 0.8158 │ 4,900 │ 560 │ 7,000,000 │ 147 + 14 │ 9 │ 호카 본디 8 │ 0.8110 │ 4,620 │ 525 │ 6,440,000 │ 133 + 15 │ 7 │ 컨버스 척테일러 │ 0.8001 │ 4,060 │ 455 │ 5,320,000 │ 112 + 16 │ 6 │ 리복 클래식 │ 0.7938 │ 3,780 │ 420 │ 4,760,000 │ 98 + 17 │ 5 │ 푸마 스웨이드 │ 0.7869 │ 3,500 │ 385 │ 4,200,000 │ 91 + 18 │ 4 │ 아식스 젤카야노 │ 0.7789 │ 3,220 │ 350 │ 3,640,000 │ 77 + 19 │ 15 │ 메종키츠네 폭스티 │ 0.7188 │ 2,100 │ 210 │ 1,120,000 │ 28 + 20 │ 14 │ 스톤아일랜드 오버셔츠 │ 0.7064 │ 1,750 │ 175 │ 910,000 │ 21 +═══════════════════════════════════════════════════════════════════════════════════════════════════════ +``` + +--- + +## 월간 랭킹 TOP 20 + +``` +═══════════════════════════════════════════════════════════════════════════════════════════════════════ + [월간 랭킹 TOP 20] period_key=20260416 (최근 30일 집계) +═══════════════════════════════════════════════════════════════════════════════════════════════════════ + 순위 │ 상품ID │ 상품명 │ Score │ 조회수 │ 좋아요 │ 순매출액 │ 판매수 +───────┼────────┼────────────────────────────┼────────────┼──────────┼──────────┼──────────────┼────────── + 1 │ 15 │ 메종키츠네 폭스티 │ 0.9839 │ 107,900 │ 14,010 │ 86,220,000 │ 1,753 + 2 │ 20 │ 보테가베네타 카세트백 │ 0.9836 │ 72,000 │ 9,300 │ 102,000,000 │ 2,070 + 3 │ 14 │ 스톤아일랜드 오버셔츠 │ 0.9728 │ 89,150 │ 11,675 │ 72,210,000 │ 1,470 + 4 │ 18 │ 르메르 크로와상 백 │ 0.9665 │ 54,000 │ 6,900 │ 78,000,000 │ 1,590 + 5 │ 17 │ 톰브라운 카디건 │ 0.9557 │ 45,000 │ 5,700 │ 66,000,000 │ 1,350 + 6 │ 19 │ 메종마르지엘라 타비슈즈 │ 0.9456 │ 63,000 │ 8,100 │ 45,000,000 │ 1,830 + 7 │ 16 │ 아미 하트로고 맨투맨 │ 0.9288 │ 28,200 │ 3,300 │ 44,400,000 │ 900 + 8 │ 3 │ 뉴발란스 993 │ 0.9275 │ 48,490 │ 6,179 │ 33,340,000 │ 683 + 9 │ 2 │ 아디다스 울트라부스트 │ 0.9207 │ 44,760 │ 5,596 │ 29,610,000 │ 613 + 10 │ 13 │ 아크테릭스 베타 LT │ 0.9185 │ 24,600 │ 2,850 │ 37,200,000 │ 750 + 11 │ 12 │ 파타고니아 다운재킷 │ 0.9146 │ 23,400 │ 2,700 │ 34,800,000 │ 720 + 12 │ 1 │ 나이키 에어맥스 97 │ 0.9129 │ 41,030 │ 5,013 │ 25,880,000 │ 543 + 13 │ 11 │ 노스페이스 눕시 │ 0.9105 │ 22,200 │ 2,550 │ 32,400,000 │ 660 + 14 │ 10 │ 살로몬 XT-6 │ 0.9060 │ 21,000 │ 2,400 │ 30,000,000 │ 630 + 15 │ 9 │ 호카 본디 8 │ 0.9013 │ 19,800 │ 2,250 │ 27,600,000 │ 570 + 16 │ 7 │ 컨버스 척테일러 │ 0.8904 │ 17,400 │ 1,950 │ 22,800,000 │ 480 + 17 │ 6 │ 리복 클래식 │ 0.8841 │ 16,200 │ 1,800 │ 20,400,000 │ 420 + 18 │ 5 │ 푸마 스웨이드 │ 0.8771 │ 15,000 │ 1,650 │ 18,000,000 │ 390 + 19 │ 4 │ 아식스 젤카야노 │ 0.8692 │ 13,800 │ 1,500 │ 15,600,000 │ 330 + 20 │ 8 │ 반스 올드스쿨 │ 0.8456 │ 20,800 │ 2,580 │ 7,320,000 │ 159 +═══════════════════════════════════════════════════════════════════════════════════════════════════════ +``` + +--- + +## 일간 vs 주간 vs 월간 순위 비교 + +``` + [순위 비교] 일간 vs 주간 vs 월간 — 집계 기간에 따른 순위 변동 + 상품ID │ 상품명 │ 일간 │ 주간 │ 월간 │ 주간변동 │ 유형 +─────────┼────────────────────────────┼───────┼───────┼───────┼──────────┼────────────── + 8 │ 반스 올드스쿨 │ 1 │ 9 │ 20 │ +8 ▲ │ 오늘바이럴 + 3 │ 뉴발란스 993 │ 2 │ 1 │ 8 │ -1 ▼ │ 급상승 + 2 │ 아디다스 울트라부스트 │ 3 │ 2 │ 9 │ -1 ▼ │ 급상승 + 1 │ 나이키 에어맥스 97 │ 4 │ 3 │ 12 │ -1 ▼ │ 급상승 + 20 │ 보테가베네타 카세트백 │ 5 │ 4 │ 2 │ -1 ▼ │ 장기강자 + 18 │ 르메르 크로와상 백 │ 6 │ 5 │ 4 │ -1 ▼ │ 장기강자 + 17 │ 톰브라운 카디건 │ 7 │ 6 │ 5 │ -1 ▼ │ 장기강자 + 19 │ 메종마르지엘라 타비슈즈 │ 8 │ 7 │ 6 │ -1 ▼ │ 장기강자 + 16 │ 아미 하트로고 맨투맨 │ 9 │ 8 │ 7 │ -1 ▼ │ + 13 │ 아크테릭스 베타 LT │ 10 │ 10 │ 10 │ — │ + 12 │ 파타고니아 다운재킷 │ 11 │ 11 │ 11 │ — │ + 11 │ 노스페이스 눕시 │ 12 │ 12 │ 13 │ — │ + 10 │ 살로몬 XT-6 │ 13 │ 13 │ 14 │ — │ + 9 │ 호카 본디 8 │ 14 │ 14 │ 15 │ — │ + 7 │ 컨버스 척테일러 │ 15 │ 15 │ 16 │ — │ + 6 │ 리복 클래식 │ 16 │ 16 │ 17 │ — │ + 5 │ 푸마 스웨이드 │ 17 │ 17 │ 18 │ — │ + 4 │ 아식스 젤카야노 │ 18 │ 18 │ 19 │ — │ + 15 │ 메종키츠네 폭스티 │ 19 │ 19 │ 1 │ — │ 하락추세 + 14 │ 스톤아일랜드 오버셔츠 │ 20 │ 20 │ 3 │ — │ 하락추세 +``` + +--- + +## 해석 + +### 오늘 바이럴 — 반스 올드스쿨 (상품 8) +- **일간 1위** → 주간 9위 → 월간 20위 +- 오늘 하루 조회 15,000 + 매출 500만으로 일간 압도적 1위 +- 그러나 나머지 29일은 조회 200, 매출 8만 수준 → 기간이 길어질수록 희석되어 순위 급락 + +### 급상승 — 뉴발란스, 아디다스, 나이키 (상품 1~3) +- 일간 2~4위 → **주간 1~3위** → 월간 8~12위 +- 최근 7일 폭발적 (일 매출 350~450만) → 주간에서 정점 +- 이전 23일은 미미 (일 매출 5~7만) → 30일 누적에서는 장기 강자에 밀림 + +### 장기 강자 — 보테가, 르메르, 톰브라운 (상품 17~20) +- 일간 5~8위 → 주간 4~7위 → **월간 2~6위** +- 30일 꾸준한 매출 (보테가: 일 340만 × 30일 = 1억 200만) → 월간에서 상위 독점 +- 특히 메종마르지엘라(19)는 취소 50%에도 30일 누적 조회 63,000 + 순매출 4,500만으로 월간 6위 유지 + +### 하락 추세 — 메종키츠네, 스톤아일랜드 (상품 14~15) +- **월간 1위, 3위** → 주간 19~20위 → 일간 19~20위 +- 이전 23일간 높은 매출 (메종키츠네: 일 조회 4,600 + 매출 370만) → 월간 누적으로 1위 +- 최근 7일 급락 (일 조회 300, 매출 16만) → 주간/일간에서는 꼴찌 +- **"과거의 영광"이 월간에는 남지만 주간/일간에서는 즉시 반영** + +### 취소 영향 — 메종마르지엘라 (상품 19) +- 매일 취소율 50% 적용 → 30일 총매출 9,000만 중 순매출 4,500만 +- 취소 없었다면 월간 2~3위권이지만, 순매출 기준 6위로 하락 +- Score 가중치 `order=0.7`이 지배적이므로 취소에 의한 순매출 감소가 순위에 직접적 영향 + +--- + +## 서빙 경로 정리 + +| 스코프 | 서빙 경로 | 데이터 소스 | 갱신 주기 | +|--------|----------|------------|----------| +| **일간** | `RankingFacade.getFromRedis()` | Redis ZSET (`ranking:all:{date}`) | 실시간 (이벤트 발생 시) | +| **주간** | `RankingFacade.getFromMv()` | `mv_product_rank_weekly` | 배치 (1일 1회) | +| **월간** | `RankingFacade.getFromMv()` | `mv_product_rank_monthly` | 배치 (1일 1회) | diff --git a/docs/captures/volume-10/04-ranking-api-capture.md b/docs/captures/volume-10/04-ranking-api-capture.md new file mode 100644 index 0000000000..9cca904b41 --- /dev/null +++ b/docs/captures/volume-10/04-ranking-api-capture.md @@ -0,0 +1,325 @@ +# Ranking API 호출 결과 캡처 + +> 실행일: 2026-04-17 +> 데이터: 상품 1,020개 × 30일 메트릭 = 30,600행 +> 배치: productRankingMvJob (weekly, monthly) +> Redis: daily 랭킹 1,020개 ZADD +> API: `GET /api/v1/rankings?scope={daily|weekly|monthly}&date=20260407&page=0~4&size=20` + +--- + +## 시드 데이터 트렌드 패턴 + +| 타입 | 비율 | 설명 | +|------|------|------| +| A) 급상승 | 5% (51개) | 과거 23일 미미 → 최근 7일 폭발 (view 6K, sales 250만/일) | +| B) 장기 강자 | 10% (102개) | 30일 꾸준히 높음 (view 3.5K, sales 180만/일) | +| C) 하락 추세 | 5% (51개) | 과거 23일 높음 → 최근 7일 급락 | +| D) 오늘 바이럴 | 2% (20개) | 오늘만 폭발 (view 18K, sales 600만) | +| E) 취소 많음 | 3% (31개) | 매출 높지만 취소 50~70% | +| F) 일반 | 75% (765개) | 보통 수준 (view 500, sales 20만/일) | + +--- + +## TOP 20 비교 (일간 / 주간 / 월간) + +| 순위 | 일간 (Daily — Redis) | score | 주간 (Weekly — MV) | score | 월간 (Monthly — MV) | score | +|:----:|-----|------:|-----|------:|-----|------:| +| 1 | 아디다스 캠퍼스 올리브 L | 0.8319 | 나이키 에어리프트 카키 XL | 0.8803 | 반스 슬립온 올리브 XL | 0.9600 | +| 2 | 살로몬 아웃펄스 네이비 XL | 0.8306 | 컨버스 런스타하이크 그레이 M | 0.8800 | 스투시 카고바지 화이트 M | 0.9585 | +| 3 | 뉴발란스 530 올리브 XL | 0.8298 | 스투시 월드투어후디 카키 S | 0.8794 | 리복 클럽C85 인디고 S | 0.9581 | +| 4 | 디스이즈네버댓 SP로고T 브라운 | 0.8298 | 아디다스 포럼 네이비 | 0.8788 | 노스페이스 1996레트로 크림 S | 0.9580 | +| 5 | 컨버스 올스타 블랙 L | 0.8283 | 아디다스 오즈위고 크림 M | 0.8779 | 뉴발란스 990v6 인디고 S | 0.9575 | +| 6 | 아크테릭스 아톰후디 화이트 S | 0.8263 | 뉴발란스 993 베이지 | 0.8774 | 아디다스 포럼 화이트 | 0.9574 | +| 7 | 나이키 에어맥스 브라운 | 0.8212 | 살로몬 센스라이드5 그레이 L | 0.8769 | 무신사 스탠다드 세미와이드 브라운 S | 0.9573 | +| 8 | 아디다스 울트라부스트 브라운 | 0.8210 | 스투시 카고바지 카키 M | 0.8769 | 자라 니트가디건 화이트 L | 0.9570 | +| 9 | 반스 울트라레인지 차콜 XL | 0.8192 | 칼하트WIP 미시간코트 버건디 M | 0.8767 | 메종키츠네 바시티 네이비 | 0.9568 | +| 10 | 아크테릭스 베타LT자켓 베이지 | 0.8185 | 반스 슬립온 버건디 XL | 0.8765 | 아크테릭스 시에르후디 크림 XL | 0.9566 | +| 11 | 메종키츠네 카페키츠네 올리브 M | 0.8177 | 나이키 덩크 화이트 L | 0.8760 | 칼하트WIP 포켓T 올리브 | 0.9565 | +| 12 | 메종키츠네 폭스헤드 티 올리브 | 0.8168 | 스투시 월드투어후디 차콜 S | 0.8757 | 반스 하프캡 카키 | 0.9565 | +| 13 | 뉴발란스 1906R 블랙 S | 0.8128 | 칼하트WIP 마스터셔츠 블랙 L | 0.8756 | 컨버스 런스타하이크 블랙 M | 0.9565 | +| 14 | 리복 클럽C85 크림 S | 0.8119 | 스투시 슈어샷T 블랙 XL | 0.8753 | 무신사 스탠다드 오버핏후디 네이비 | 0.9563 | +| 15 | 파타고니아 캡쿨T 화이트 XL | 0.8101 | 아디다스 삼바 크림 L | 0.8752 | 컨버스 올스타 그레이 L | 0.9561 | +| 16 | 디스이즈네버댓 패딩베스트 브라운 L | 0.8099 | 아디다스 캠퍼스 베이지 L | 0.8752 | 아디다스 슈퍼스타 그레이 XL | 0.9561 | +| 17 | 노스페이스 1996레트로 카키 S | 0.8083 | 파타고니아 P-6로고T 카키 S | 0.8750 | 푸마 CA프로 차콜 | 0.9561 | +| 18 | 칼하트WIP 마스터셔츠 그레이 L | 0.8074 | 스투시 크루니트 베이지 L | 0.8747 | 칼하트WIP 시드릭팬츠 화이트 S | 0.9560 | +| 19 | 살로몬 스피드크로스6 크림 S | 0.8068 | 무신사 스탠다드 트레이닝팬츠 카키 XL | 0.8744 | 나이키 코르테즈 화이트 M | 0.9559 | +| 20 | 노스페이스 눕시자켓 버건디 | 0.8060 | 리복 레거시 크림 XL | 0.8744 | 메종키츠네 메종키츠네 폭스헤드 | 0.9559 | + +--- + +## 순위 변동 분석 + +### 일간 → 주간 순위 상승 TOP 10 + +| 상품 | 트렌드 | 일간 | 주간 | 변동 | +|------|--------|-----:|-----:|-----:| +| 아디다스 오즈위고 크림 M | 급상승 | 71 | 5 | +66 | +| 컨버스 런스타하이크 그레이 M | 급상승 | 65 | 2 | +63 | +| 나이키 덩크 화이트 L | 급상승 | 74 | 11 | +63 | +| 스투시 크루니트 베이지 L | 급상승 | 73 | 18 | +55 | +| 칼하트WIP 마스터셔츠 블랙 L | 급상승 | 63 | 13 | +50 | +| 스투시 월드투어후디 카키 S | 급상승 | 47 | 3 | +44 | +| 아디다스 캠퍼스 베이지 L | 급상승 | 60 | 16 | +44 | +| 살로몬 센스라이드5 그레이 L | 급상승 | 50 | 7 | +43 | +| 리복 레거시 크림 XL | 급상승 | 61 | 20 | +41 | +| 칼하트WIP 미시간코트 버건디 M | 급상승 | 48 | 9 | +39 | + +### 주간 → 월간 순위 상승 TOP 10 + +| 상품 | 트렌드 | 주간 | 월간 | 변동 | +|------|--------|-----:|-----:|-----:| +| 아디다스 NMD 브라운 S | 장기강자 | 97 | 55 | +42 | +| 반스 올드스쿨 베이지 | 장기강자 | 96 | 62 | +34 | +| 유니클로 드라이EX 화이트 M | 장기강자 | 94 | 71 | +23 | +| 유니클로 블록테크 브라운 L | 장기강자 | 95 | 72 | +23 | +| 나이키 에어포스 1 | 장기강자 | 76 | 56 | +20 | +| 나이키 페가수스 올리브 XL | 장기강자 | 93 | 74 | +19 | +| 푸마 스웨이드 버건디 | 장기강자 | 91 | 76 | +15 | +| 무신사 스탠다드 트레이닝팬츠 크림 XL | 장기강자 | 66 | 52 | +14 | +| 리복 리복 클래식 | 장기강자 | 68 | 54 | +14 | +| 노스페이스 화이트라벨T 올리브 XL | 장기강자 | 77 | 67 | +10 | + +--- + +## 트렌드별 대표 상품의 순위 비교 + +각 트렌드 타입에서 대표 상품이 일간/주간/월간에서 어떤 순위를 차지하는지 비교합니다. + +| 상품 | 트렌드 | 일간 | 주간 | 월간 | 해석 | +|------|--------|-----:|-----:|-----:|------| +| 나이키 에어리프트 카키 XL | 급상승 | 21 | 21 | 100+ | 최근 7일 폭발 → 주간 상위, 월간은 과거 미미해 하락 | +| 스투시 카고바지 인디고 M | 장기강자 | 67 | 100+ | 100+ | 30일 꾸준 → 월간 상위, 일간은 바이럴/급상승에 밀림 | +| 아디다스 캠퍼스 올리브 L | 바이럴 | 1 | 100+ | 100+ | 오늘만 폭발 → 일간 상위, 주간/월간은 1일치만 반영 | + +--- + +## API 호출 예시 및 응답 + +### 일간 랭킹 (Redis Speed Layer) + +```bash +curl 'http://localhost:8080/api/v1/rankings?scope=daily&date=20260407&page=0&size=5' +``` + +```json +{ + "meta": { + "result": "SUCCESS" + }, + "data": { + "data": [ + { + "productId": 179, + "productName": "캠퍼스 올리브 L", + "brandName": "아디다스", + "price": 330000, + "rank": 1, + "score": 0.8319134789 + }, + { + "productId": 1010, + "productName": "아웃펄스 네이비 XL", + "brandName": "살로몬", + "price": 369000, + "rank": 2, + "score": 0.8306099717 + }, + { + "productId": 265, + "productName": "530 올리브 XL", + "brandName": "뉴발란스", + "price": 74000, + "rank": 3, + "score": 0.8298468199 + }, + { + "productId": 701, + "productName": "SP로고T 브라운", + "brandName": "디스이즈네버댓", + "price": 71000, + "rank": 4, + "score": 0.8297926845 + }, + { + "productId": 319, + "productName": "올스타 블랙 L", + "brandName": "컨버스", + "price": 78000, + "rank": 5, + "score": 0.8283206061 + } + ], + "totalElements": 100, + "totalPages": 5, + "page": 0, + "size": 20 + } +} +``` + +### 주간 랭킹 (MV Batch Layer) + +```bash +curl 'http://localhost:8080/api/v1/rankings?scope=weekly&date=20260407&page=0&size=5' +``` + +```json +{ + "meta": { + "result": "SUCCESS" + }, + "data": { + "data": [ + { + "productId": 150, + "productName": "에어리프트 카키 XL", + "brandName": "나이키", + "price": 181000, + "rank": 1, + "score": 0.8803474289772881 + }, + { + "productId": 273, + "productName": "런스타하이크 그레이 M", + "brandName": "컨버스", + "price": 137000, + "rank": 2, + "score": 0.879953227234731 + }, + { + "productId": 762, + "productName": "월드투어후디 카키 S", + "brandName": "스투시", + "price": 375000, + "rank": 3, + "score": 0.8794172982962273 + }, + { + "productId": 86, + "productName": "포럼 네이비", + "brandName": "아디다스", + "price": 344000, + "rank": 4, + "score": 0.8787595546591237 + }, + { + "productId": 178, + "productName": "오즈위고 크림 M", + "brandName": "아디다스", + "price": 245000, + "rank": 5, + "score": 0.8779273299754659 + } + ], + "totalElements": 100, + "totalPages": 5, + "page": 0, + "size": 20 + } +} +``` + +### 월간 랭킹 (MV Batch Layer) + +```bash +curl 'http://localhost:8080/api/v1/rankings?scope=monthly&date=20260407&page=0&size=5' +``` + +```json +{ + "meta": { + "result": "SUCCESS" + }, + "data": { + "data": [ + { + "productId": 365, + "productName": "슬립온 올리브 XL", + "brandName": "반스", + "price": 357000, + "rank": 1, + "score": 0.9600463167413544 + }, + { + "productId": 758, + "productName": "카고바지 화이트 M", + "brandName": "스투시", + "price": 178000, + "rank": 2, + "score": 0.9585216360551394 + }, + { + "productId": 432, + "productName": "클럽C85 인디고 S", + "brandName": "리복", + "price": 286000, + "rank": 3, + "score": 0.9580914295828116 + }, + { + "productId": 902, + "productName": "1996레트로 크림 S", + "brandName": "노스페이스", + "price": 197000, + "rank": 4, + "score": 0.9579669150498178 + }, + { + "productId": 232, + "productName": "990v6 인디고 S", + "brandName": "뉴발란스", + "price": 160000, + "rank": 5, + "score": 0.9575088137571697 + } + ], + "totalElements": 100, + "totalPages": 5, + "page": 0, + "size": 20 + } +} +``` + +--- + +## 핵심 관찰 + +### 1. 시간 윈도우에 따른 랭킹 차이 + +| 관찰 | 설명 | +|------|------| +| **일간 상위 ≠ 주간 상위** | 바이럴 상품이 일간 상위지만 주간에서는 1일치만 반영되어 하락 | +| **주간 상위 ≠ 월간 상위** | 급상승 상품이 주간 상위지만 월간에서는 23일간 미미한 실적으로 하락 | +| **월간 상위 = 장기 강자** | 30일 꾸준히 높은 실적의 상품이 월간에서 상위 차지 | + +### 2. Score 범위 차이 + +| scope | 1위 score | 100위 score | 차이 | +|-------|----------:|-----------:|-----:| +| daily | 0.8319 | 0.7381 | 0.0938 | +| weekly | 0.8803 | 0.8461 | 0.0342 | +| monthly | 0.9600 | 0.9439 | 0.0161 | + +- 월간 score가 가장 높음: 30일 누적 데이터 → LOG10 합산값이 큼 +- 일간 score가 가장 낮음: 1일치 데이터만 반영 +- 주간은 7일 합산으로 중간 범위 + +### 3. 취소 반영 + +취소율이 높은 상품(E타입, 취소 50~70%)은 `sales_amount - cancel_amount_by_event_date` 반영으로 +실제 순매출이 낮아져 순위가 하락합니다. 매출 자체는 높지만 순위에서 불이익을 받습니다. + +--- + +## 배치 실행 정보 + +| 항목 | weekly | monthly | +|------|--------|---------| +| 파티션 수 | 4 (productId 범위 분할) | 4 | +| 소요 시간 | 275ms | 309ms | +| 적재 건수 | 100 (TOP 100) | 100 | +| product_metrics | 30,600행 | 30,600행 | +| 상품 수 | 1,020개 | 1,020개 | +| 메트릭 기간 | 7일 (04-01~04-07) | 30일 (03-08~04-07) | \ No newline at end of file diff --git a/docs/captures/volume-9/01-event-flow.png b/docs/captures/volume-9/01-event-flow.png new file mode 100644 index 0000000000..1a2359dd43 Binary files /dev/null and b/docs/captures/volume-9/01-event-flow.png differ diff --git a/docs/captures/volume-9/02-drift-initial.png b/docs/captures/volume-9/02-drift-initial.png new file mode 100644 index 0000000000..345799248b Binary files /dev/null and b/docs/captures/volume-9/02-drift-initial.png differ diff --git a/docs/design/01-requirements.md b/docs/design/01-requirements.md new file mode 100644 index 0000000000..31a604fe80 --- /dev/null +++ b/docs/design/01-requirements.md @@ -0,0 +1,507 @@ +# 요구사항 명세서 + +## 1. 개요 + +이커머스 플랫폼의 핵심 도메인(브랜드, 상품, 좋아요, 주문)에 대한 요구사항을 정의한다. + +--- + +## 2. 액터 정의 + +| 액터 | 설명 | 인증 방식 | +|------|------|----------| +| **User (회원)** | 상품을 조회하고 좋아요, 주문을 수행하는 일반 사용자 | `X-Loopers-LoginId`, `X-Loopers-LoginPw` 헤더 | +| **Admin (관리자)** | 브랜드/상품을 등록·수정·삭제하고, 전체 주문을 조회하는 운영자 | `X-Loopers-Ldap: loopers.admin` 헤더 | +| **System** | 배치 작업, 정합성 보정 등 내부 시스템 프로세스 | 내부 호출 (인증 없음) | + +--- + +## 3. 유비쿼터스 언어 (Ubiquitous Language) + +| 용어 | 정의 | +|------|------| +| **브랜드 (Brand)** | 상품을 판매하는 판매자/제조사 단위 | +| **상품 (Product)** | 판매되는 개별 품목. 하나의 브랜드에 속함 | +| **재고 (Stock)** | 상품의 판매 가능 수량 | +| **좋아요 (Like)** | 회원이 상품에 표시한 관심 표시 | +| **주문 (Order)** | 회원이 상품을 구매하기 위해 생성한 거래 단위 | +| **주문 항목 (OrderItem)** | 주문에 포함된 개별 상품 정보 (스냅샷 포함) | +| **스냅샷 (Snapshot)** | 주문 시점의 상품 정보를 보존한 데이터 | +| **쿠폰 (Coupon)** | 할인을 제공하는 쿠폰 템플릿. 정액(FIXED) 또는 정률(RATE) 할인 | +| **쿠폰 발급 (CouponIssue)** | 회원에게 발급된 쿠폰 인스턴스. AVAILABLE/USED/EXPIRED 상태를 가짐 | + +--- + +## 4. 도메인별 기능 요구사항 + +### 4.1 브랜드 (Brand) + +#### US-B01: 브랜드 등록 +``` +As a 관리자 +I want to 새로운 브랜드를 등록하고 싶다 +So that 해당 브랜드의 상품을 등록할 수 있다 +``` + +| 흐름 | 설명 | +|------|------| +| Main | 브랜드명, 설명을 입력하여 브랜드를 생성한다 | +| Alternate | - | +| Exception | 브랜드명이 비어있으면 등록 실패 | + +#### US-B02: 브랜드 목록 조회 +``` +As a 사용자 +I want to 브랜드 목록을 조회하고 싶다 +So that 원하는 브랜드의 상품을 찾을 수 있다 +``` + +| 흐름 | 설명 | +|------|------| +| Main | 등록된 브랜드 목록을 조회한다 (삭제되지 않은 것만) | +| Alternate | - | +| Exception | - | + +#### US-B03: 브랜드 삭제 +``` +As a 관리자 +I want to 브랜드를 삭제하고 싶다 +So that 더 이상 해당 브랜드의 상품이 노출되지 않는다 +``` + +| 흐름 | 설명 | +|------|------| +| Main | 브랜드를 soft delete 처리한다 | +| Alternate | 해당 브랜드의 모든 상품도 soft delete 처리된다 | +| Alternate | 해당 상품들의 좋아요는 hard delete 된다 | +| Exception | 존재하지 않는 브랜드이면 삭제 실패 | + +--- + +### 4.2 상품 (Product) + +#### US-P01: 상품 등록 +``` +As a 관리자 +I want to 새로운 상품을 등록하고 싶다 +So that 회원들이 해당 상품을 구매할 수 있다 +``` + +| 흐름 | 설명 | +|------|------| +| Main | 상품명, 가격, 재고, 브랜드를 입력하여 상품을 생성한다 | +| Alternate | - | +| Exception | 가격이 0 이하이면 등록 실패 | +| Exception | 재고가 음수이면 등록 실패 | +| Exception | 존재하지 않는 브랜드이면 등록 실패 | + +#### US-P02: 상품 목록 조회 +``` +As a 사용자 +I want to 상품 목록을 조회하고 싶다 +So that 구매할 상품을 선택할 수 있다 +``` + +| 흐름 | 설명 | +|------|------| +| Main | 상품 목록을 조회한다 (삭제되지 않은 것만) | +| Alternate | 좋아요 순 정렬 가능 | +| Alternate | 브랜드별 필터링 가능 | +| Exception | - | + +#### US-P03: 상품 상세 조회 +``` +As a 사용자 +I want to 상품 상세 정보를 조회하고 싶다 +So that 상품 정보를 확인하고 구매를 결정할 수 있다 +``` + +| 흐름 | 설명 | +|------|------| +| Main | 상품의 상세 정보(이름, 가격, 재고, 브랜드, 좋아요 수)를 조회한다 | +| Alternate | - | +| Exception | 존재하지 않거나 삭제된 상품이면 조회 실패 | + +#### US-P04: 상품 수정 +``` +As a 관리자 +I want to 상품 정보를 수정하고 싶다 +So that 변경된 정보를 반영할 수 있다 +``` + +| 흐름 | 설명 | +|------|------| +| Main | 상품명, 가격, 재고를 수정한다 | +| Alternate | - | +| Exception | 가격이 0 이하이면 수정 실패 | +| Exception | 재고가 음수이면 수정 실패 | + +#### US-P05: 상품 삭제 +``` +As a 관리자 +I want to 상품을 삭제하고 싶다 +So that 더 이상 해당 상품이 노출되지 않는다 +``` + +| 흐름 | 설명 | +|------|------| +| Main | 상품을 soft delete 처리한다 | +| Alternate | 해당 상품의 좋아요는 hard delete 된다 | +| Exception | 존재하지 않는 상품이면 삭제 실패 | + +--- + +### 4.3 좋아요 (Like) + +#### US-L01: 좋아요 등록 +``` +As a 회원 +I want to 상품에 좋아요를 등록하고 싶다 +So that 관심 상품을 표시할 수 있다 +``` + +| 흐름 | 설명 | +|------|------| +| Main | POST `/api/v1/products/{productId}/likes` - 상품에 좋아요를 추가하고, 상품의 좋아요 수를 증가시킨다 | +| Alternate | 이미 좋아요한 상품이면 아무 동작 없음 (멱등성 보장) | +| Exception | 존재하지 않거나 삭제된 상품이면 실패 | + +#### US-L02: 좋아요 취소 +``` +As a 회원 +I want to 좋아요를 취소하고 싶다 +So that 관심 상품에서 제외할 수 있다 +``` + +| 흐름 | 설명 | +|------|------| +| Main | DELETE `/api/v1/products/{productId}/likes` - 좋아요를 삭제하고, 상품의 좋아요 수를 감소시킨다 | +| Alternate | 좋아요하지 않은 상품이면 아무 동작 없음 (멱등성 보장) | +| Exception | - | + +#### US-L03: 내가 좋아요한 상품 목록 조회 +``` +As a 회원 +I want to 내가 좋아요한 상품 목록을 조회하고 싶다 +So that 관심 상품을 한눈에 확인할 수 있다 +``` + +| 흐름 | 설명 | +|------|------| +| Main | GET `/api/v1/users/{userId}/likes` - 해당 회원이 좋아요한 상품 목록을 조회한다 | +| Alternate | 좋아요한 상품이 없으면 빈 목록 반환 | +| Exception | 다른 회원의 좋아요 목록 조회 시 권한 검증 (본인만 조회 가능) | + +--- + +### 4.4 주문 (Order) + +#### US-O01: 주문 생성 +``` +As a 회원 +I want to 상품을 주문하고 싶다 +So that 상품을 구매할 수 있다 +``` + +| 흐름 | 설명 | +|------|------| +| Main | 1. 주문할 상품과 수량을 선택한다 | +| Main | 2. 재고를 확인하고 차감한다 | +| Main | 3. 주문을 생성하고 주문 항목에 스냅샷을 저장한다 | +| Main | 4. (선택) 쿠폰 적용 시 소유자/상태/만료 검증 후 할인 적용 | +| Alternate | 여러 상품을 한 번에 주문할 수 있다 | +| Exception | 재고가 부족하면 주문 실패 | +| Exception | 존재하지 않거나 삭제된 상품이면 주문 실패 | +| Exception | 사용 불가(USED/EXPIRED), 타인 소유, 존재하지 않는 쿠폰이면 주문 실패 | + +#### US-O02: 주문 목록 조회 +``` +As a 회원 +I want to 내 주문 목록을 조회하고 싶다 +So that 주문 이력을 확인할 수 있다 +``` + +| 흐름 | 설명 | +|------|------| +| Main | GET `/api/v1/orders?startAt=&endAt=` - 해당 회원의 주문 목록을 조회한다 | +| Alternate | `startAt`, `endAt` 파라미터로 날짜 범위 필터링 가능 | +| Exception | - | + +#### US-O03: 주문 상세 조회 +``` +As a 회원 +I want to 주문 상세 내역을 조회하고 싶다 +So that 주문한 상품과 금액을 확인할 수 있다 +``` + +| 흐름 | 설명 | +|------|------| +| Main | 주문 항목의 스냅샷 정보를 포함하여 조회한다 | +| Alternate | - | +| Exception | 다른 회원의 주문이면 조회 실패 | + +--- + +### 4.5 쿠폰 (Coupon) + +#### US-C01: 쿠폰 템플릿 등록 (Admin) +``` +As a 관리자 +I want to 쿠폰 템플릿을 등록하고 싶다 +So that 회원들에게 할인 쿠폰을 발급할 수 있다 +``` + +| 흐름 | 설명 | +|------|------| +| Main | 쿠폰명, 할인유형(FIXED/RATE), 할인값, 최소주문금액, 만료일시를 입력하여 쿠폰을 생성한다 | +| Exception | 필수값 누락 시 등록 실패 | + +#### US-C02: 쿠폰 발급 +``` +As a 회원 +I want to 쿠폰을 발급받고 싶다 +So that 주문 시 할인을 받을 수 있다 +``` + +| 흐름 | 설명 | +|------|------| +| Main | POST `/api/v1/coupons/{couponId}/issue` - 쿠폰 템플릿 기반으로 CouponIssue 생성 | +| Exception | 만료된 쿠폰 템플릿이면 발급 실패 | +| Exception | 존재하지 않는 쿠폰이면 발급 실패 | + +#### US-C03: 내 쿠폰 목록 조회 +``` +As a 회원 +I want to 내 쿠폰 목록을 조회하고 싶다 +So that 사용 가능한 쿠폰을 확인할 수 있다 +``` + +| 흐름 | 설명 | +|------|------| +| Main | GET `/api/v1/users/me/coupons` - 발급된 쿠폰 목록을 AVAILABLE/USED/EXPIRED 상태와 함께 반환 | +| Alternate | 상태는 조회 시점 기준으로 계산 (AVAILABLE이지만 만료시간 지났으면 EXPIRED) | + +--- + +## 5. 설계 결정 사항 + +### 5.1 재고 차감 시점 + +| 결정 | 주문 생성 시 즉시 차감 (단일 트랜잭션) | +|------|------| +| **이유** | 현재 과제는 모노리스 + 결제 미구현 상태 | +| **방식** | 단일 트랜잭션으로 `재고 확인 → 주문 저장 → 재고 차감`을 원자적으로 처리 | +| **확장** | 결제가 추가되면 보상 트랜잭션(Saga) 고려 | + +``` +현재: Order 생성 시 Stock 차감 (같은 트랜잭션) +미래: Order 생성 → Payment 요청 → [실패 시] Stock 복원 +``` + +### 5.2 상품 스냅샷 범위 + +**판단 기준**: "주문 상세 화면을 독립적으로 렌더링할 수 있는가?" + +원본 데이터가 변경되거나 삭제되어도, 주문 상세 페이지가 깨지지 않고 온전하게 보여야 한다. +(예: 쿠팡에서 3년 전 주문 내역을 열면 단종된 상품이라도 당시 상품명, 가격이 다 보임) + +| 분류 | 항목 | 저장 여부 | 이유 | +|------|------|----------|------| +| **필수** | product_name | O | 없으면 주문 상세 화면 성립 불가. 변경 시 "내가 주문한 게 이게 아닌데" 클레임 | +| **필수** | product_price | O | 정산/환불 기준. 변경되면 금액 증빙 불가 | +| **필수** | quantity | O | 주문 수량 | +| **권장** | brand_name | O | 주문 내역 UI에 거의 항상 표시 | +| **제외** | image_url | X | 현재 상품 스펙에 이미지 필드 없음. 요구사항에 없는 필드를 미리 스냅샷에 넣는 건 오버엔지니어링 | +| **제외** | description | X | 주문 상세에서 보여줄 필요 없음. 상품 상세 페이지 영역 | +| **제외** | like_count | X | 주문 내역과 무관 | +| **제외** | stock_quantity | X | 주문 내역과 무관 | + +**트레이드오프**: 스냅샷 컬럼이 늘어날수록 저장 비용 증가, 원본과의 동기화 불일치 가능성 증가, 스키마 변경 시 마이그레이션 영향 범위 확대 + +### 5.3 주문 상태 + +```java +public enum OrderStatus { + CREATED, // 주문 생성됨 (현재는 이게 곧 완료) + PAID, // 결제 완료 (미래 확장용) + CANCELLED // 취소됨 +} +``` + +- 결제가 없는 현재: `CREATED` = 주문 완료 상태로 사용 +- 결제가 추가되면: `CREATED` → `PAID` 전이 추가 +- YAGNI 원칙에 따라 현재 로직에서는 PAID를 사용하지 않음 + +### 5.4 좋아요 수 관리 + +| 결정 | UNIQUE 제약 + COUNT(*) 파생 (락 불필요 구조) | +|------|------| +| **이유** | Product에 likeCount 컬럼을 두면 좋아요마다 Product 행에 경합 발생. 락 자체가 불필요한 구조로 전환 | +| **방식** | likes 테이블의 UNIQUE(member_id, product_id) 제약으로 중복 방지, 조회 시 COUNT(*) 파생 | +| **정렬** | `likes_desc` 정렬은 Application Layer에서 enrichWithLikeCount + 정렬 | +| **트레이드오프** | 목록 조회 시 N+1 COUNT 쿼리 → 배치 COUNT(GROUP BY)로 최적화 완료 | + +### 5.5 삭제 정책 + +| 테이블 | 삭제 방식 | 이유 | +|--------|-----------|------| +| brands | Soft Delete | 상품이 참조, 주문 이력 보존 | +| products | Soft Delete | 주문이 참조 (스냅샷 있어도 조회 가능해야) | +| likes | Hard Delete | 삭제된 상품 좋아요는 의미 없음 | +| orders | Soft Delete | 주문 이력은 절대 삭제 안 함 | +| order_items | 삭제 없음 | Order와 생명주기 공유 (Order 취소 시에도 보존) | + +**브랜드 삭제 시 연쇄 처리**: +```java +@Transactional +public void deleteBrand(Long brandId) { + // 1. 해당 브랜드의 모든 상품 soft delete + productRepository.softDeleteByBrandId(brandId); + // 2. 해당 상품들의 좋아요 hard delete + likeRepository.deleteByBrandId(brandId); + // 3. 브랜드 soft delete + brandRepository.softDelete(brandId); +} +``` + +### 5.6 동시성 제어 전략 + +도메인 특성에 맞게 세 가지 전략을 분화 적용한다. + +| 대상 | 전략 | 근거 | +|------|------|------| +| **Product 재고** | 비관적 락 (`SELECT ... FOR UPDATE`) | 주문 트랜잭션 내 다중 자원(재고+쿠폰+주문) 원자성 필수. 높은 경합 시 순차 처리가 UX에 유리 | +| **좋아요** | 락 불필요 (UNIQUE + COUNT 파생) | likeCount 컬럼 제거로 Product 행 경합 자체를 제거. Like 테이블 UNIQUE 제약이 중복 방지 | +| **쿠폰 사용** | 조건부 UPDATE (`WHERE status='AVAILABLE' AND expired_at > now`) | 비관적 락 없이 단일 UPDATE로 원자적 상태 전이. affected rows = 0이면 이미 사용/만료 | + +### 5.7 쿠폰 적용 규칙 + +| 규칙 | 설명 | +|------|------| +| **1주문 1쿠폰** | 주문 1건당 쿠폰 1장만 적용 가능 | +| **할인 유형** | FIXED: min(할인값, 주문금액), RATE: 주문금액 × 할인율 / 100 | +| **검증 순서** | 존재 여부 → 소유자 확인 → 쿠폰 템플릿 유효성(만료/최소금액) → 조건부 UPDATE | +| **주문 취소 시** | 쿠폰 상태를 AVAILABLE로 복원 | +| **스냅샷** | 주문에 originalTotalPrice, discountAmount, couponIssueId 저장 | + +--- + +## 6. API 명세 + +### 6.1 인증 방식 + +| 구분 | Prefix | 인증 헤더 | +|------|--------|----------| +| 대고객 API | `/api/v1` | `X-Loopers-LoginId`, `X-Loopers-LoginPw` | +| 어드민 API | `/api-admin/v1` | `X-Loopers-Ldap: loopers.admin` | + +### 6.2 브랜드 & 상품 (대고객) + +| METHOD | URI | 설명 | +|--------|-----|------| +| GET | `/api/v1/brands/{brandId}` | 브랜드 정보 조회 | +| GET | `/api/v1/products` | 상품 목록 조회 | +| GET | `/api/v1/products/{productId}` | 상품 정보 조회 | + +**상품 목록 조회 쿼리 파라미터:** +- `brandId`: 브랜드별 필터링 +- `sort`: 정렬 (latest/price_asc/likes_desc) +- `page`, `size`: 페이징 + +### 6.3 브랜드 & 상품 (Admin) + +| METHOD | URI | 설명 | +|--------|-----|------| +| GET | `/api-admin/v1/brands` | 브랜드 목록 조회 | +| GET | `/api-admin/v1/brands/{brandId}` | 브랜드 상세 조회 | +| POST | `/api-admin/v1/brands` | 브랜드 등록 | +| PUT | `/api-admin/v1/brands/{brandId}` | 브랜드 수정 | +| DELETE | `/api-admin/v1/brands/{brandId}` | 브랜드 삭제 (상품도 삭제) | +| GET | `/api-admin/v1/products` | 상품 목록 조회 | +| GET | `/api-admin/v1/products/{productId}` | 상품 상세 조회 | +| POST | `/api-admin/v1/products` | 상품 등록 | +| PUT | `/api-admin/v1/products/{productId}` | 상품 수정 (브랜드 변경 불가) | +| DELETE | `/api-admin/v1/products/{productId}` | 상품 삭제 | + +### 6.4 좋아요 (Likes) + +| METHOD | URI | 설명 | +|--------|-----|------| +| POST | `/api/v1/products/{productId}/likes` | 좋아요 등록 | +| DELETE | `/api/v1/products/{productId}/likes` | 좋아요 취소 | +| GET | `/api/v1/users/{userId}/likes` | 내가 좋아요 한 상품 목록 | + +### 6.5 주문 (Orders) + +| METHOD | URI | 설명 | +|--------|-----|------| +| POST | `/api/v1/orders` | 주문 요청 | +| GET | `/api/v1/orders?startAt=&endAt=` | 주문 목록 조회 (날짜 필터) | +| GET | `/api/v1/orders/{orderId}` | 주문 상세 조회 | + +**주문 요청 Body 예시:** +```json +{ + "items": [ + { "productId": 1, "quantity": 2 } + ], + "couponId": 42 +} +``` + +`couponId`는 발급된 쿠폰(CouponIssue)의 ID. 미적용 시 생략 가능. + +**주문 시 필수 처리:** +- 스냅샷 저장 (상품명, 가격, 브랜드명) +- 재고 확인 및 차감 + +### 6.6 주문 (Admin) + +| METHOD | URI | 설명 | +|--------|-----|------| +| GET | `/api-admin/v1/orders` | 주문 목록 조회 | +| GET | `/api-admin/v1/orders/{orderId}` | 주문 상세 조회 | + +### 6.7 쿠폰 (대고객) + +| METHOD | URI | 설명 | +|--------|-----|------| +| POST | `/api/v1/coupons/{couponId}/issue` | 쿠폰 발급 요청 | +| GET | `/api/v1/users/me/coupons` | 내 쿠폰 목록 조회 | + +### 6.8 쿠폰 (Admin) + +| METHOD | URI | 설명 | +|--------|-----|------| +| GET | `/api-admin/v1/coupons` | 쿠폰 템플릿 목록 조회 | +| GET | `/api-admin/v1/coupons/{couponId}` | 쿠폰 템플릿 상세 조회 | +| POST | `/api-admin/v1/coupons` | 쿠폰 템플릿 등록 | +| PUT | `/api-admin/v1/coupons/{couponId}` | 쿠폰 템플릿 수정 | +| DELETE | `/api-admin/v1/coupons/{couponId}` | 쿠폰 템플릿 삭제 (soft delete) | +| GET | `/api-admin/v1/coupons/{couponId}/issues` | 발급 내역 조회 | + +--- + +## 7. 비기능 요구사항 + +| 항목 | 요구사항 | +|------|----------| +| 트랜잭션 | 주문 생성 시 재고 차감은 원자적으로 처리 | +| 멱등성 | 좋아요 등록/취소는 멱등하게 동작 | +| 정합성 | 주문 취소 시 재고 복원 보장 | +| 데이터 보존 | 주문 관련 데이터는 soft delete로 보존 | +| 동시성 | 재고는 비관적 락, 좋아요는 UNIQUE 제약 + COUNT 파생, 쿠폰은 조건부 UPDATE | + +--- + +## 8. 미결정 사항 (추후 결정 필요) + +| 항목 | 현재 상태 | 추후 결정 시점 | +|------|----------|--------------| +| **결제 연동** | 미구현 (주문 생성 = 완료) | 결제 시스템 도입 시 | +| **동시성 제어** | 도메인 특성별 전략 적용 완료 (비관적 락 / 조건부 UPDATE / 락 불필요 구조) | 트래픽 증가 시 낙관적/비관적 락 선택 | +| **멱등성 키** | 미구현 | 중복 주문 방지 필요 시 | +| **일관성 보장** | 단일 트랜잭션 | MSA 전환 시 Saga 패턴 고려 | +| **느린 조회 최적화** | 기본 인덱스만 | 대량 데이터 시 캐시/검색엔진 도입 | +| **주문 상태 확장** | CREATED/PAID/CANCELLED | 배송 상태 추가 시 확장 | +| **Admin 인증 강화** | 단순 LDAP 헤더 | 실서비스 시 JWT/OAuth 전환 | diff --git a/docs/design/02-sequence-diagrams.md b/docs/design/02-sequence-diagrams.md new file mode 100644 index 0000000000..533b65315b --- /dev/null +++ b/docs/design/02-sequence-diagrams.md @@ -0,0 +1,937 @@ +# 시퀀스 다이어그램 + +## 1. 개요 + +핵심 유스케이스의 객체 간 상호작용을 시퀀스 다이어그램으로 표현한다. + +### 다이어그램 읽는 법 + +| 표기 | 의미 | +|------|------| +| 실선 화살표 (`->>`) | 동기 메시지 (호출) | +| 점선 화살표 (`-->>`) | 응답 (반환) | +| 세로 막대 (activate/deactivate) | 액티베이션 바 - 객체가 일하고 있는 시간 | +| `rect` 블록 | 트랜잭션 경계 등 논리적 그룹 | +| `alt` 블록 | 조건 분기 (if-else) | +| `loop` 블록 | 반복문 | +| `Note over` | 설명 노트 | + +--- + +## 2. 주문 생성 (Order Creation) + +### 왜 이 다이어그램이 필요한가? + +주문 생성은 시스템에서 가장 복잡한 흐름이다. 다음을 검증하기 위해 필요: +- 재고 확인 → 비관적 락 차감이 **단일 트랜잭션**으로 처리되는지 +- 쿠폰 적용이 **조건부 UPDATE**로 원자적으로 처리되는지 +- 스냅샷 생성 시점이 올바른지 +- 예외 상황(재고 부족, 쿠폰 사용 불가)에서 롤백이 보장되는지 + +```mermaid +sequenceDiagram + autonumber + participant Client + participant Controller as OrderController + participant Facade as OrderFacade + participant ProductRepo as ProductRepository + participant BrandRepo as BrandRepository + participant CouponFacade as CouponFacade + participant CouponIssueRepo as CouponIssueRepository + participant OrderRepo as OrderRepository + participant DB as Database + + Client->>Controller: POST /orders (items, couponId?) + activate Controller + Controller->>Facade: createOrder(memberId, items, couponId) + activate Facade + + rect rgb(240, 248, 255) + Note over Facade,DB: 트랜잭션 경계 + + Note over Facade: 1. 상품 ID 정렬 후 일괄 비관적 락 + Facade->>ProductRepo: findAllByIdsWithLock(sortedIds) + activate ProductRepo + ProductRepo->>DB: SELECT ... FOR UPDATE (ID 정렬) + DB-->>ProductRepo: List~Product~ + ProductRepo-->>Facade: Map~Long, Product~ + deactivate ProductRepo + + loop 각 주문 항목에 대해 + alt 상품 미존재 + Facade-->>Controller: NotFound 예외 → 롤백 + else 재고 부족 + Facade-->>Controller: BadRequest 예외 → 롤백 + else 정상 + Note over Facade: product.decreaseStock(quantity) + end + end + + Note over Facade: 2. 브랜드 일괄 조회 (N+1 방지) + Facade->>BrandRepo: findAllByIds(brandIds) + activate BrandRepo + BrandRepo-->>Facade: Map~Long, Brand~ + deactivate BrandRepo + + Note over Facade: 3. 스냅샷 생성 (상품명, 가격, 브랜드명, 수량) + + opt couponId가 있는 경우 + Note over Facade: 4. 쿠폰 적용 + Facade->>CouponFacade: applyCouponToOrder(couponId, memberId, totalPrice) + activate CouponFacade + CouponFacade->>CouponIssueRepo: findById(couponId) + CouponIssueRepo-->>CouponFacade: CouponIssue + + alt 쿠폰 미존재 / 타인 소유 / 만료 / 최소금액 미달 + CouponFacade-->>Facade: 예외 → 롤백 + end + + CouponFacade->>CouponIssueRepo: markAsUsed(id, now) + Note over CouponIssueRepo: UPDATE ... WHERE status='AVAILABLE' AND expired_at > now + CouponIssueRepo-->>CouponFacade: affected rows + + alt affected rows = 0 + CouponFacade-->>Facade: 이미 사용/만료 예외 → 롤백 + end + + CouponFacade-->>Facade: CouponApplyResult(couponIssueId, discountAmount) + deactivate CouponFacade + end + + Note over Facade: 5. 주문 저장 + Facade->>OrderRepo: save(Order.create(...)) + activate OrderRepo + OrderRepo->>DB: INSERT orders + order_items (CASCADE) + DB-->>OrderRepo: Order (with ID) + OrderRepo-->>Facade: Order + deactivate OrderRepo + + opt couponId가 있는 경우 + Note over Facade: 6. 쿠폰에 주문 ID 연결 + Facade->>CouponFacade: linkCouponToOrder(couponIssueId, orderId) + end + end + + Facade-->>Controller: Order + deactivate Facade + Controller-->>Client: 201 Created + deactivate Controller +``` + +### 읽는 법 + +1. **rect 블록**이 트랜잭션 경계 — 이 안의 모든 작업이 성공하거나 모두 롤백 +2. **비관적 락**: ID 정렬 후 `SELECT ... FOR UPDATE`로 데드락 방지 +3. **opt 블록**: 쿠폰이 있을 때만 실행되는 선택적 흐름 +4. **조건부 UPDATE**: markAsUsed가 `WHERE status='AVAILABLE'`로 이중 사용 원자적 방지 + +### 핵심 설계 포인트 + +| 포인트 | 설명 | +|--------|------| +| **단일 트랜잭션** | 재고 차감 + 쿠폰 사용 + 주문 저장이 원자적으로 처리 | +| **비관적 락** | Product를 ID 정렬 후 일괄 `SELECT ... FOR UPDATE` (데드락 방지) | +| **조건부 UPDATE** | 쿠폰은 `markAsUsed` 조건부 UPDATE로 이중 사용 방지 (락 불필요) | +| **스냅샷** | 주문 시점의 상품명, 가격, 브랜드명 + 할인 정보 저장 | +| **N+1 방지** | 상품은 `findAllByIdsWithLock`, 브랜드는 `findAllByIds`로 일괄 조회 | + +--- + +## 3. 좋아요 등록 (Like - POST) + +### 왜 이 다이어그램이 필요한가? + +좋아요 등록은 다음을 검증하기 위해 필요: +- POST/DELETE 분리 방식의 RESTful 설계가 올바른지 +- **멱등성**이 어떻게 보장되는지 (이미 좋아요 시 무시) +- 락 없이 UNIQUE 제약으로 동시성을 처리하는 구조 + +```mermaid +sequenceDiagram + autonumber + participant Client + participant Controller as LikeController + participant Facade as LikeFacade + participant ProductRepo as ProductRepository + participant LikeRepo as LikeRepository + participant DB as Database + + Client->>Controller: POST /products/{productId}/likes + activate Controller + Controller->>Facade: addLike(memberId, productId) + activate Facade + + rect rgb(240, 248, 255) + Note over Facade,DB: 트랜잭션 경계 + + Facade->>ProductRepo: findById(productId) + activate ProductRepo + ProductRepo->>DB: SELECT product WHERE id = ? + DB-->>ProductRepo: Product + ProductRepo-->>Facade: Product + deactivate ProductRepo + + alt 상품이 존재하지 않거나 삭제됨 + Facade-->>Controller: NotFound 예외 + Controller-->>Client: 404 Not Found + end + + Facade->>LikeRepo: existsByMemberIdAndProductId(memberId, productId) + activate LikeRepo + LikeRepo->>DB: SELECT EXISTS(...) + DB-->>LikeRepo: true/false + LikeRepo-->>Facade: boolean + deactivate LikeRepo + + alt 이미 좋아요 존재 (멱등성 보장) + Note over Facade: 변경 없이 성공 반환 + else 좋아요 없음 → 저장 + Facade->>LikeRepo: save(new Like) + activate LikeRepo + LikeRepo->>DB: INSERT INTO likes + Note over DB: UNIQUE(member_id, product_id) 제약으로 중복 방지 + DB-->>LikeRepo: Like + LikeRepo-->>Facade: Like + deactivate LikeRepo + end + end + + Facade-->>Controller: OK + deactivate Facade + Controller-->>Client: 200 OK + deactivate Controller +``` + +### 읽는 법 + +1. **rect 블록** 안에서 Like 저장이 처리됨 (Product 수정 없음) +2. **alt "이미 좋아요 존재"** 분기에서 아무것도 하지 않고 성공 반환 → 멱등성 보장 +3. likes 테이블의 **UNIQUE 제약**이 DB 레벨에서 중복을 방지 + +### 핵심 설계 포인트 + +| 포인트 | 설명 | +|--------|------| +| **RESTful** | POST로 리소스 생성 의도 명확 | +| **멱등성** | 이미 좋아요 존재 시 무시 (에러 아님) | +| **락 불필요** | Product.likeCount 제거. UNIQUE 제약 + COUNT(*) 파생으로 Product 행 경합 원천 제거 | +| **좋아요 수** | 조회 시 `SELECT COUNT(*) FROM likes WHERE product_id = ?` 또는 배치 GROUP BY | + +--- + +## 4. 좋아요 취소 (Like - DELETE) + +### 왜 이 다이어그램이 필요한가? + +좋아요 취소 흐름에서 다음을 검증: +- DELETE 요청으로 리소스 삭제 의도가 명확한지 +- **멱등성** — 이미 취소된 상태에서 다시 취소해도 에러 없이 성공 + +```mermaid +sequenceDiagram + autonumber + participant Client + participant Controller as LikeController + participant Facade as LikeFacade + participant LikeRepo as LikeRepository + participant DB as Database + + Client->>Controller: DELETE /products/{productId}/likes + activate Controller + Controller->>Facade: removeLike(memberId, productId) + activate Facade + + rect rgb(240, 248, 255) + Note over Facade,DB: 트랜잭션 경계 + + Facade->>LikeRepo: findByMemberIdAndProductId(memberId, productId) + activate LikeRepo + LikeRepo->>DB: SELECT like WHERE member_id = ? AND product_id = ? + DB-->>LikeRepo: Like or null + LikeRepo-->>Facade: Optional~Like~ + deactivate LikeRepo + + alt 좋아요 없음 (멱등성 보장) + Note over Facade: 변경 없이 성공 반환 + else 좋아요 존재 → 삭제 + Facade->>LikeRepo: delete(like) + activate LikeRepo + LikeRepo->>DB: DELETE FROM likes WHERE id = ? + DB-->>LikeRepo: OK + LikeRepo-->>Facade: OK + deactivate LikeRepo + end + end + + Facade-->>Controller: OK + deactivate Facade + Controller-->>Client: 200 OK + deactivate Controller +``` + +### 읽는 법 + +1. **alt "좋아요 없음"** 분기 — 이미 취소 상태면 아무것도 안 하고 성공 +2. Like 삭제만 수행 (Product 수정 없음 — likeCount 컬럼이 없으므로) + +### 핵심 설계 포인트 + +| 포인트 | 설명 | +|--------|------| +| **RESTful** | DELETE로 리소스 삭제 의도 명확 | +| **멱등성** | 좋아요 없을 때도 에러 아닌 성공 응답 | +| **락 불필요** | Like 행만 삭제. Product 행 수정 없어 경합 발생하지 않음 | + +--- + +## 5. 좋아요 목록 조회 (My Likes) + +```mermaid +sequenceDiagram + autonumber + participant Client + participant Controller as LikeController + participant Facade as LikeFacade + participant LikeRepo as LikeRepository + participant DB as Database + + Client->>Controller: GET /users/{userId}/likes + activate Controller + + alt userId != memberId + Controller-->>Client: 403 Forbidden + end + + Controller->>Facade: getLikesByMemberId(userId) + activate Facade + + Facade->>LikeRepo: findAllByMemberId(memberId) + activate LikeRepo + LikeRepo->>DB: SELECT * FROM likes WHERE member_id = ? + DB-->>LikeRepo: List~Like~ + LikeRepo-->>Facade: List~Like~ + deactivate LikeRepo + + Facade-->>Controller: List~Like~ + deactivate Facade + Controller-->>Client: 200 OK (좋아요 목록) + deactivate Controller +``` + +### 핵심 설계 포인트 + +| 포인트 | 설명 | +|--------|------| +| **권한 검증** | Controller에서 userId == memberId 확인 (본인만 조회) | +| **단순 조회** | Like 엔티티 목록 반환 (productId 포함) | + +--- + +## 6. 상품 등록 (Admin) + +관리자가 새 상품을 등록하는 흐름이다. + +```mermaid +sequenceDiagram + autonumber + participant Admin + participant Controller as ProductController + participant Service as ProductService + participant BrandRepo as BrandRepository + participant ProductRepo as ProductRepository + participant DB as Database + + Admin->>Controller: POST /api-admin/v1/products + activate Controller + Controller->>Service: createProduct(brandId, name, price, stockQuantity) + activate Service + + Note over Service: 트랜잭션 시작 + + Service->>BrandRepo: findById(brandId) + activate BrandRepo + BrandRepo->>DB: SELECT brand WHERE id = ? + DB-->>BrandRepo: Brand + BrandRepo-->>Service: Brand + deactivate BrandRepo + + alt 브랜드가 존재하지 않거나 삭제됨 + Service-->>Controller: NotFound 예외 + Controller-->>Admin: 404 Not Found + end + + Note over Service: Price VO 생성 (가격 검증) + Note over Service: Stock VO 생성 (재고 검증) + Note over Service: Product 생성 + + Service->>ProductRepo: save(product) + activate ProductRepo + ProductRepo->>DB: INSERT INTO products + DB-->>ProductRepo: Product (with ID) + ProductRepo-->>Service: Product + deactivate ProductRepo + + Note over Service: 트랜잭션 커밋 + + Service-->>Controller: Product + deactivate Service + Controller-->>Admin: 201 Created (productId) + deactivate Controller +``` + +### 핵심 설계 포인트 + +| 포인트 | 설명 | +|--------|------| +| **브랜드 검증** | 상품 등록 전 브랜드 존재 여부 확인 | +| **VO 검증** | Price, Stock 생성 시 유효성 검증 | +| **ID 참조** | Product는 brandId만 저장 (Brand 객체 참조 X) | + +--- + +## 7. 브랜드 삭제 (연쇄 삭제) + +브랜드 삭제 시 연관 데이터를 함께 처리하는 흐름이다. + +```mermaid +sequenceDiagram + autonumber + participant Admin + participant Controller as BrandAdminController + participant Facade as BrandFacade + participant BrandRepo as BrandRepository + participant ProductRepo as ProductRepository + participant LikeRepo as LikeRepository + participant DB as Database + + Admin->>Controller: DELETE /api-admin/v1/brands/{brandId} + activate Controller + Controller->>Facade: deleteBrand(brandId) + activate Facade + + Note over Facade: 트랜잭션 시작 + + Facade->>BrandRepo: findById(brandId) + activate BrandRepo + BrandRepo->>DB: SELECT brand WHERE id = ? + DB-->>BrandRepo: Brand + BrandRepo-->>Facade: Brand + deactivate BrandRepo + + alt 브랜드가 존재하지 않거나 이미 삭제됨 + Facade-->>Controller: NotFound 예외 + Controller-->>Admin: 404 Not Found + end + + Facade->>ProductRepo: findAllByBrandId(brandId) + activate ProductRepo + ProductRepo->>DB: SELECT products WHERE brand_id = ? + DB-->>ProductRepo: List~Product~ + ProductRepo-->>Facade: List~Product~ + deactivate ProductRepo + + Note over Facade: 1단계: 좋아요 일괄 삭제 (배치) + + Facade->>LikeRepo: deleteAllByProductIdIn(productIds) + activate LikeRepo + LikeRepo->>DB: DELETE FROM likes WHERE product_id IN (...) + DB-->>LikeRepo: OK + LikeRepo-->>Facade: OK + deactivate LikeRepo + + Note over Facade: 2단계: 상품들 soft delete + + loop 각 Product에 대해 + Note over Facade: product.delete() → dirty checking + end + + Note over Facade: 3단계: 브랜드 soft delete + Note over Facade: brand.delete() → dirty checking + + Note over Facade: 트랜잭션 커밋 → UPDATE products, UPDATE brand + + Facade-->>Controller: OK + deactivate Facade + Controller-->>Admin: 200 OK + deactivate Controller +``` + +### 핵심 설계 포인트 + +| 포인트 | 설명 | +|--------|------| +| **Soft Delete** | 브랜드, 상품은 deleted_at 설정 (주문 이력 보존) | +| **Hard Delete** | 좋아요는 의미 없어 완전 삭제 | +| **삭제 순서** | 좋아요 → 상품 → 브랜드 (의존 순서 역순) | +| **단일 트랜잭션** | 연쇄 삭제가 원자적으로 처리 | + +--- + +## 8. 쿠폰 발급 (Coupon Issue) + +### 왜 이 다이어그램이 필요한가? + +쿠폰 발급 흐름에서 다음을 검증: +- 쿠폰 템플릿의 만료 여부 확인 +- CouponIssue 생성 및 저장 + +```mermaid +sequenceDiagram + autonumber + participant Client + participant Controller as CouponController + participant Facade as CouponFacade + participant CouponRepo as CouponRepository + participant IssueRepo as CouponIssueRepository + participant DB as Database + + Client->>Controller: POST /coupons/{couponId}/issue + activate Controller + Controller->>Facade: issueCoupon(couponId, memberId) + activate Facade + + rect rgb(240, 248, 255) + Note over Facade,DB: 트랜잭션 경계 + + Facade->>CouponRepo: findById(couponId) + activate CouponRepo + CouponRepo->>DB: SELECT coupon WHERE id = ? + DB-->>CouponRepo: Coupon + CouponRepo-->>Facade: Coupon + deactivate CouponRepo + + alt 쿠폰 미존재 + Facade-->>Controller: NotFound 예외 + else 쿠폰 만료됨 + Facade-->>Controller: BadRequest 예외 + end + + Note over Facade: CouponIssue 생성 (couponId, memberId, expiredAt) + + Facade->>IssueRepo: save(couponIssue) + activate IssueRepo + IssueRepo->>DB: INSERT INTO coupon_issue + DB-->>IssueRepo: CouponIssue (with ID) + IssueRepo-->>Facade: CouponIssue + deactivate IssueRepo + end + + Facade-->>Controller: CouponIssue + deactivate Facade + Controller-->>Client: 201 Created + deactivate Controller +``` + +### 핵심 설계 포인트 + +| 포인트 | 설명 | +|--------|------| +| **만료 검증** | 발급 시점에 쿠폰 템플릿 만료 여부 확인 | +| **상태 초기화** | CouponIssue는 AVAILABLE 상태로 생성 | +| **만료일 복사** | 쿠폰 템플릿의 expiredAt을 CouponIssue에 복사 | + +--- + +## 9. 주문 취소 (Order Cancellation) + +### 왜 이 다이어그램이 필요한가? + +주문 취소 시 재고 복원과 쿠폰 복원이 원자적으로 처리되는지 검증: + +```mermaid +sequenceDiagram + autonumber + participant Client + participant Controller as OrderController + participant Facade as OrderFacade + participant OrderRepo as OrderRepository + participant ProductRepo as ProductRepository + participant CouponFacade as CouponFacade + participant DB as Database + + Client->>Controller: POST /orders/{orderId}/cancel + activate Controller + Controller->>Facade: cancelOrder(orderId, memberId) + activate Facade + + rect rgb(240, 248, 255) + Note over Facade,DB: 트랜잭션 경계 + + Facade->>OrderRepo: findById(orderId) + OrderRepo-->>Facade: Order + + alt 주문 미존재 / 타인 주문 + Facade-->>Controller: 예외 → 롤백 + end + + Note over Facade: order.cancel() → status = CANCELLED + + Note over Facade: 재고 복원 (비관적 락) + Facade->>ProductRepo: findAllByIdsWithLock(sortedProductIds) + ProductRepo-->>Facade: List~Product~ + + loop 각 OrderItem에 대해 + Note over Facade: product.increaseStock(quantity) + end + + opt 쿠폰이 있는 경우 + Facade->>CouponFacade: restoreCoupon(couponIssueId) + Note over CouponFacade: couponIssue.cancelUse() → status = AVAILABLE + end + end + + Facade-->>Controller: OK + deactivate Facade + Controller-->>Client: 200 OK + deactivate Controller +``` + +### 핵심 설계 포인트 + +| 포인트 | 설명 | +|--------|------| +| **원자적 복원** | 재고 복원 + 쿠폰 복원이 단일 트랜잭션 | +| **비관적 락** | 재고 복원도 비관적 락으로 동시 주문과의 경합 방지 | +| **쿠폰 복원** | USED → AVAILABLE, usedOrderId = null | + +--- + +## 10. 결제 요청 (Payment Request) — TX 분리 + 7계층 Fallback + +### 왜 이 다이어그램이 필요한가? + +결제 요청은 PG 외부 시스템 연동이 포함된 가장 복잡한 비동기 흐름이다. 다음을 검증하기 위해 필요: +- **TX 분리**: PG 호출이 트랜잭션 밖에서 실행되는지 (DB 커넥션 비점유) +- **가주문 → 진주문 전환**: Redis 가주문 생성 → 결제 완료 시 DB 진주문 전환 +- **멱등성**: 수동 Retry 루프에서 PG 상태 확인 후 재시도 판단 +- **Outbox**: Payment + Outbox가 같은 TX에서 원자적으로 저장되는지 + +```mermaid +sequenceDiagram + autonumber + participant Client + participant Controller as PaymentController + participant Facade as PaymentFacade + participant ProvisionalSvc as ProvisionalOrderService + participant Redis as Redis Master + participant PaymentRepo as PaymentRepository + participant OutboxRepo as PaymentOutboxRepository + participant PgRouter as PgRouter + participant PG as PG (Simulator/Toss) + participant DB as Database + + Client->>Controller: POST /api/v1/payments (orderId, cardType, cardNo, amount) + activate Controller + Controller->>Facade: requestPayment(orderId, cardType, cardNo, amount) + activate Facade + + Note over Facade: 1. 주문 검증 + Facade->>DB: findOrderById(orderId) + DB-->>Facade: Order + + alt 주문 미존재 / 이미 결제됨 + Facade-->>Controller: 400 예외 + end + + rect rgb(255, 248, 240) + Note over Facade,DB: TX-0: 쿠폰 선차감 + + Facade->>DB: CouponIssue UPDATE SET status='USED' WHERE status='AVAILABLE' + DB-->>Facade: affected rows + alt affected rows = 0 + Facade-->>Controller: 쿠폰 사용 불가 예외 + end + end + + rect rgb(240, 255, 240) + Note over Facade,Redis: Redis: 가주문 생성 + 재고 예약 + + Facade->>ProvisionalSvc: createProvisionalOrder(request) + activate ProvisionalSvc + + alt redis-write CB Closed (정상) + ProvisionalSvc->>Redis: DECR stock:{productId} + ProvisionalSvc->>Redis: HSET provisional:order:{orderId} (TTL 25~35분 Jitter) + ProvisionalSvc->>Redis: SADD provisional:orders {orderId} + ProvisionalSvc-->>Facade: ProvisionalOrderResult + else redis-write CB Open (Redis 장애) + Note over ProvisionalSvc: DB Fallback + ProvisionalSvc->>DB: INSERT Order(CREATED) + UPDATE stock + ProvisionalSvc-->>Facade: DirectOrderResult + end + deactivate ProvisionalSvc + end + + rect rgb(240, 248, 255) + Note over Facade,DB: TX-1: Payment + Outbox 원자적 저장 + + Facade->>PaymentRepo: save(Payment REQUESTED) + PaymentRepo->>DB: INSERT payment (status=REQUESTED) + Facade->>OutboxRepo: save(Outbox PENDING) + OutboxRepo->>DB: INSERT payment_outbox (status=PENDING) + Note over DB: TX-1 commit + end + + rect rgb(255, 255, 240) + Note over Facade,PG: PG 호출 (트랜잭션 없음 — DB 커넥션 비점유) + + loop 수동 Retry (최대 3회, 지수 백오프 500ms→1s→2s) + Note over Facade: 재시도 전 PG 상태 확인 (멱등성 보장) + Facade->>PgRouter: getPaymentByOrderId(orderId) + PgRouter->>PG: GET /payments?orderId={orderId} + PG-->>PgRouter: 404 (기록 없음) or 200 (이미 처리됨) + + alt PG에 이미 기록 있음 + Note over Facade: 재시도 안 함 → 기존 건 추적 + else PG에 기록 없음 → 안전하게 재시도 + Facade->>PgRouter: requestPayment(request) + Note over PgRouter: SlidingWindowRateLimiter(50/sec) → CB → Feign + PgRouter->>PG: POST /payments + alt Simulator(비동기) 성공 + PG-->>PgRouter: {status: PENDING, transactionKey: TX-001} + else Toss(동기) 성공 + PG-->>PgRouter: {status: SUCCESS, transactionKey: TX-002} + else 타임아웃 (SocketTimeoutException) + Note over PgRouter: Fallback PG 전환하지 않음 (중복 결제 방지) + PgRouter-->>Facade: 예외 + else 500/연결실패 + Note over PgRouter: 다음 PG로 Fallback 전환 + end + end + end + end + + rect rgb(240, 248, 255) + Note over Facade,DB: TX-2: 상태 업데이트 + + alt PG 응답 PENDING (비동기 PG) + Facade->>DB: Payment → PENDING + Outbox → PROCESSED + Facade->>Facade: Delayed Task 등록 (10초 후 Polling) + Facade-->>Controller: "결제 처리 중" + else PG 응답 SUCCESS (동기 PG — Toss) + Facade->>DB: Payment → PAID + Order → PAID + Facade-->>Controller: "결제 완료" + else 모든 PG 실패 + Facade->>DB: Payment → UNKNOWN + Facade-->>Controller: "결제 확인 중" + end + end + + deactivate Facade + Controller-->>Client: 200 OK (결제 상태) + deactivate Controller +``` + +### 읽는 법 + +1. **4개의 rect 블록**이 각각 별도 트랜잭션(TX-0, Redis, TX-1, TX-2) — PG 호출은 TX 밖 +2. **수동 Retry 루프**: 재시도 전 PG 상태 확인 → 중복 결제 방지 (멱등성) +3. **PgRouter 분기**: 타임아웃 시 Fallback 전환 불가, 500/연결실패만 Fallback +4. **Redis Fallback**: CB Open 시 DB 직접 주문으로 자동 전환 + +### 핵심 설계 포인트 + +| 포인트 | 설명 | +|--------|------| +| **TX 분리** | PG 호출 중 DB 커넥션 비점유 (초당 100건 기준 450 → 5 커넥션·초) | +| **Outbox 패턴** | Payment + Outbox 같은 TX에서 저장 → 서버 크래시에도 PG 호출 누락 없음 | +| **수동 Retry** | PG 상태 확인 후 재시도 → PG가 멱등하지 않아도 중복 결제 방지 | +| **Multi-PG** | 타임아웃 외 실패 시 자동 Fallback (Simulator → Toss) | +| **가주문** | Redis TTL Jitter(±5분)로 동시 만료 분산, CB Open 시 DB Fallback | + +--- + +## 11. 콜백 수신 + 진주문 전환 (Callback → Real Order) + +### 왜 이 다이어그램이 필요한가? + +PG 콜백 수신 후 가주문→진주문 전환 과정에서 다음을 검증: +- **Callback Inbox (DLQ)**: 원본 먼저 저장 → PG에게 즉시 200 → 내부 처리 +- **조건부 UPDATE**: 콜백/배치/폴링 동시 실행에서 멱등성 보장 +- **SOT 전환**: Redis(가주문) → DB(진주문) 원자적 전환 + +```mermaid +sequenceDiagram + autonumber + participant PG as PG Simulator + participant CallbackCtrl as CallbackController + participant RecoverySvc as PaymentRecoveryService + participant PaymentRepo as PaymentRepository + participant OrderRepo as OrderRepository + participant Redis as Redis Master + participant DB as Database + + PG->>CallbackCtrl: POST /api/v1/payments/callback (transactionKey, status, payload) + activate CallbackCtrl + + rect rgb(240, 248, 255) + Note over CallbackCtrl,DB: 1단계: 콜백 원본 보존 (DLQ) + + CallbackCtrl->>DB: INSERT callback_inbox (status=RECEIVED, payload=원본) + Note over CallbackCtrl: PG에게 즉시 200 OK 반환 (처리 실패해도 원본 보존) + end + + CallbackCtrl-->>PG: 200 OK + CallbackCtrl->>RecoverySvc: processCallback(transactionKey, status, payload) + activate RecoverySvc + + RecoverySvc->>PaymentRepo: findByTransactionKey(transactionKey) + PaymentRepo->>DB: SELECT payment WHERE transaction_key = ? + DB-->>PaymentRepo: Payment + PaymentRepo-->>RecoverySvc: Payment + + alt Payment 미존재 + Note over RecoverySvc: 로그 남김 + callback_inbox에 보존 + else status = SUCCESS + rect rgb(240, 255, 240) + Note over RecoverySvc,DB: TX-3: 조건부 UPDATE + 진주문 전환 + + RecoverySvc->>DB: UPDATE payment SET status='PAID' WHERE id=? AND status IN ('PENDING','UNKNOWN') + DB-->>RecoverySvc: affected rows + + alt affected rows = 0 + Note over RecoverySvc: 이미 다른 경로(배치/폴링)에서 처리 완료 → 무시 + else affected rows = 1 + RecoverySvc->>DB: INSERT Order(PAID) + OrderItems (진주문 생성) + RecoverySvc->>DB: UPDATE stock (DB 재고 확정 차감) + RecoverySvc->>Redis: DEL provisional:order:{orderId} (가주문 정리) + RecoverySvc->>Redis: SREM provisional:orders {orderId} + Note over Redis: Redis DEL 실패해도 TTL + 배치가 보정 → 실패 허용 + end + end + + RecoverySvc->>DB: UPDATE callback_inbox SET status='PROCESSED' + else status = FAILED + rect rgb(255, 240, 240) + Note over RecoverySvc,DB: 결제 실패 처리 + + RecoverySvc->>DB: UPDATE payment SET status='FAILED' WHERE id=? AND status IN ('PENDING','UNKNOWN') + RecoverySvc->>Redis: INCR stock:{productId} (재고 복원) + RecoverySvc->>Redis: DEL provisional:order:{orderId} + RecoverySvc->>DB: 쿠폰 복원 (CouponIssue → AVAILABLE) + end + + RecoverySvc->>DB: UPDATE callback_inbox SET status='PROCESSED' + end + + deactivate RecoverySvc + deactivate CallbackCtrl +``` + +### 핵심 설계 포인트 + +| 포인트 | 설명 | +|--------|------| +| **Callback Inbox** | 원본 먼저 저장 → PG에게 200 즉시 반환 → 콜백 유실 원천 차단 | +| **조건부 UPDATE** | `WHERE status IN ('PENDING','UNKNOWN')` → 콜백/배치/폴링 동시 실행 시 1건만 성공 | +| **SOT 전환** | DB INSERT(진주문) + DB 재고 차감 = 같은 TX / Redis DEL = TX 밖 (실패 허용) | +| **DLQ 재처리** | RECEIVED + 30초 경과 건 → DLQ 스케줄러가 재처리 | + +--- + +## 12. 복구 흐름 — Polling Hybrid + 배치 복구 + 대사 + +### 왜 이 다이어그램이 필요한가? + +콜백 미수신, 서버 크래시, DB 장애 등에서 자동 복구가 동작하는지 검증: + +```mermaid +sequenceDiagram + autonumber + participant Scheduler as Scheduler (다중) + participant RecoverySvc as PaymentRecoveryService + participant PaymentRepo as PaymentRepository + participant PgRouter as PgRouter + participant PG as PG + participant Redis as Redis + participant DB as Database + + rect rgb(255, 255, 240) + Note over Scheduler,PG: [실시간] Polling Hybrid — 10초 후 능동 조회 + + Scheduler->>RecoverySvc: checkPendingPayments() + RecoverySvc->>PaymentRepo: findPendingAndUnknown() + PaymentRepo-->>RecoverySvc: List + + loop 각 PENDING/UNKNOWN Payment에 대해 + alt transactionKey 있음 + RecoverySvc->>PgRouter: getPaymentStatus(transactionKey, pgProvider) + else transactionKey 없음 (UNKNOWN — 타임아웃) + RecoverySvc->>PgRouter: getPaymentByOrderId(orderId) + end + + PgRouter->>PG: GET /payments/{key} or ?orderId={id} + PG-->>PgRouter: {status: SUCCESS/FAILED/PENDING} + + alt PG SUCCESS + RecoverySvc->>DB: 조건부 UPDATE → PAID + 진주문 전환 + else PG FAILED + RecoverySvc->>DB: 조건부 UPDATE → FAILED + 재고 복원 + else PG PENDING + Note over RecoverySvc: 아직 처리 중 → 다음 주기에 재확인 + end + end + end + + rect rgb(240, 248, 255) + Note over Scheduler,DB: [주기적] Outbox Poller — 5초 주기 + + Scheduler->>DB: SELECT * FROM payment_outbox WHERE status='PENDING' + DB-->>Scheduler: List + + loop 각 미처리 Outbox + Note over Scheduler: PG 상태 확인 (멱등성) → 필요 시 PG 호출 + Scheduler->>PG: POST /payments (재시도) + PG-->>Scheduler: 응답 + Scheduler->>DB: Outbox → PROCESSED + end + end + + rect rgb(240, 255, 240) + Note over Scheduler,Redis: [주기적] 재고 정합성 배치 — 30초 주기 (Lua Script) + + Scheduler->>DB: SELECT stock FROM product (DB 재고) + Scheduler->>Redis: SCARD provisional:orders (진행 중 가주문 수) + Note over Redis: Lua Script 원자적 실행: SET stock = DB stock - active provisionals + end + + rect rgb(255, 248, 240) + Note over Scheduler,DB: [대사] PG ↔ Payment — 1시간 주기 + + Scheduler->>DB: SELECT PAID/FAILED payments (reconciled=false) + loop 각 Payment + Scheduler->>PG: GET /payments/{transactionKey} + alt 우리 PAID + PG SUCCESS → 일치 + Scheduler->>DB: reconciled = true + else 우리 PAID + PG FAILED → 불일치 + Scheduler->>DB: INSERT reconciliation_mismatch + 알림 + else 우리 FAILED + PG SUCCESS → 불일치 + Scheduler->>DB: 자동 보상 (PAID 전환) + 알림 + end + end + end +``` + +### 핵심 설계 포인트 + +| 포인트 | 설명 | +|--------|------| +| **3단계 복구** | 실시간(Polling 10초) → 주기적(배치 1분) → 대사(1시간) | +| **UNKNOWN 복구** | transactionKey 없는 UNKNOWN → orderId 기반 PG 조회로 복구 | +| **Lua Script** | Redis 재고 보정을 GET + SCARD + SET 원자적으로 실행 → Lost Update 방지 | +| **대사 역할** | 복구가 잘 동작하는지 검증하는 최종 안전망. 불일치 0건 = 복구 정상 | + +--- + +## 13. 잠재 리스크 + +| 리스크 | 현재 상태 | 대응 방안 | +|--------|----------|----------| +| **트랜잭션 비대화** | 주문 생성 시 여러 상품 + 쿠폰 처리 | 상품 수 제한 또는 배치 처리 고려 | +| **좋아요 COUNT 비용** | 배치 GROUP BY로 최적화 완료 | 극단적 트래픽 시 캐시 도입 | +| **브랜드 삭제 시 대량 처리** | 배치 DELETE로 최적화 완료 | 상품이 매우 많으면 비동기 이벤트 처리 고려 | +| **쿠폰 조건부 UPDATE 경합** | affected rows 검증 | 동시 사용 시 1건만 성공, 나머지는 명확한 에러 | +| **PG 타임아웃 시 유령 결제** | UNKNOWN 상태 + 3단계 복구 경로 | 콜백 + Polling + 배치 이중 안전망 | +| **Redis-DB 재고 이중 존재** | Lua Script 원자적 보정 (30초) | DB가 SOT, Redis를 DB 기준으로 보정 | +| **콜백/배치/폴링 동시 실행** | 조건부 UPDATE (WHERE status IN) | affected rows = 0이면 무시 (멱등) | +| **Redis 가주문 TTL 만료 시 재고 미복원** | Proactive Expiry Scanner (30초) | TTL < 30초 감지 → 선제 정리 + INCR | diff --git a/docs/design/03-class-diagram.md b/docs/design/03-class-diagram.md new file mode 100644 index 0000000000..3039ba4f44 --- /dev/null +++ b/docs/design/03-class-diagram.md @@ -0,0 +1,619 @@ +# 클래스 다이어그램 + +## 1. 개요 + +이커머스 플랫폼의 도메인 모델을 DDD 관점에서 설계한다. + +### 다이어그램 읽는 법 + +| 표기 | 의미 | +|------|------| +| `<>` | 해당 Aggregate의 진입점. 외부에서는 이 객체를 통해서만 접근 | +| `<>` | 고유 식별자를 가지는 객체. Aggregate 내부에서만 존재 | +| `<>` | 불변 객체. 값으로만 비교하며 식별자 없음 | +| `<>` | 열거형. 미리 정의된 상수 집합 | +| `*--` (컴포지션) | 생명주기를 함께하는 강한 포함 관계 | +| `..>` (점선 화살표) | ID 참조. Aggregate 간 느슨한 결합 | + +--- + +## 2. 레이어드 아키텍처 + +```mermaid +graph TB + subgraph Interfaces ["Interfaces Layer — Controller, DTO"] + BC["BrandController\nBrandAdminController"] + PC["ProductController\nProductAdminController"] + OC["OrderController\nOrderAdminController"] + LC["LikeController"] + MC["MemberV1Controller"] + CC["CouponController\nCouponAdminController"] + PAYC["PaymentV1Controller"] + end + + subgraph Application ["Application Layer — Facade (유스케이스 조율, 트랜잭션)"] + BF["BrandFacade\n· 브랜드 CRUD\n· 삭제 시 상품+좋아요 연쇄 처리"] + PF["ProductFacade\n· 상품 CRUD + 정렬 조회\n· 삭제 시 좋아요 연쇄 처리"] + OF["OrderFacade\n· 주문 생성 (비관적 락 재고 차감 + 쿠폰 적용)\n· 주문 취소 (재고 복원 + 쿠폰 복원)\n· 권한 검증"] + LF["LikeFacade\n· 좋아요 추가 (멱등)\n· 좋아요 취소 (멱등)"] + MF["MemberFacade\n· 회원가입\n· 비밀번호 변경"] + CF["CouponFacade\n· 쿠폰 CRUD (Admin)\n· 쿠폰 발급/조회\n· 주문 연동 (적용/복원)"] + PAYF["PaymentFacade\n· 결제 요청 (수동 Retry + Multi-PG)\n· 콜백 수신 + 진주문 전환\n· Polling Hybrid 복구"] + PRS["PaymentRecoveryService\n· 콜백 비동기 처리\n· 조건부 UPDATE (멱등)\n· 재고/쿠폰 복원"] + POS["ProvisionalOrderService\n· Redis 가주문 생성 (CB Fallback → DB)\n· 가주문 조회/삭제"] + end + + subgraph Domain ["Domain Layer — Entity, VO, Repository Interface"] + direction LR + BR["«interface»\nBrandRepository"] + PR["«interface»\nProductRepository"] + OR["«interface»\nOrderRepository"] + LR2["«interface»\nLikeRepository"] + MR["«interface»\nMemberRepository"] + CR["«interface»\nCouponRepository"] + CIR["«interface»\nCouponIssueRepository"] + PAYR["«interface»\nPaymentRepository"] + POR["«interface»\nPaymentOutboxRepository"] + CIBR["«interface»\nCallbackInboxRepository"] + RMR["«interface»\nReconciliationMismatchRepository"] + end + + subgraph Infrastructure ["Infrastructure Layer — Repository 구현체 + PG + Resilience"] + BRI["BrandRepositoryImpl\nBrandJpaRepository"] + PRI["ProductRepositoryImpl\nProductJpaRepository"] + ORI["OrderRepositoryImpl\nOrderJpaRepository"] + LRI["LikeRepositoryImpl\nLikeJpaRepository"] + MRI["MemberRepositoryImpl\nMemberJpaRepository"] + CRI2["CouponRepositoryImpl\nCouponJpaRepository"] + CIRI["CouponIssueRepositoryImpl\nCouponIssueJpaRepository"] + PAYRI["PaymentRepositoryImpl\nPaymentOutboxRepositoryImpl\nCallbackInboxRepositoryImpl\nReconciliationMismatchRepositoryImpl"] + PGR["PgRouter → «interface» PgClient\nSimulatorPgClient (Primary)\nTossSandboxPgClient (Fallback)"] + RESL["SlidingWindowRateLimiter (50/sec)\nPaymentRateLimiterInterceptor (AOP)\nProgressiveBackoffCustomizer"] + WAL["PaymentWalWriter\n(로컬 WAL — 크래시 복구)"] + end + + BC --> BF + PC --> PF + OC --> OF + LC --> LF + MC --> MF + CC --> CF + PAYC --> PAYF + + BF --> BR + BF --> PR + BF --> LR2 + PF --> PR + PF --> BR + PF --> LR2 + OF --> OR + OF --> PR + OF --> BR + OF --> CF + LF --> LR2 + LF --> PR + MF --> MR + CF --> CR + CF --> CIR + PAYF --> PAYR + PAYF --> POR + PAYF --> PRS + PAYF --> POS + PRS --> PAYR + PRS --> CIBR + PRS --> RMR + + BRI -.->|implements| BR + PRI -.->|implements| PR + ORI -.->|implements| OR + LRI -.->|implements| LR2 + MRI -.->|implements| MR + CRI2 -.->|implements| CR + CIRI -.->|implements| CIR + PAYRI -.->|implements| PAYR + PAYRI -.->|implements| POR + PAYRI -.->|implements| CIBR + PAYRI -.->|implements| RMR + PGR -.->|PG 호출| PAYF + RESL -.->|Rate Limit + CB| PGR + WAL -.->|크래시 복구| PRS +``` + +### 의존 방향 + +``` +Interfaces → Application → Domain ← Infrastructure +``` + +- Domain은 다른 레이어에 의존하지 않는다 +- Infrastructure가 Domain의 Repository 인터페이스를 구현한다 (DIP) + +### Facade별 책임 + +| Facade | 주요 책임 | 의존하는 Repository | +|--------|----------|-------------------| +| BrandFacade | 브랜드 CRUD, 삭제 시 상품+좋아요 연쇄 처리 | Brand, Product, Like | +| ProductFacade | 상품 CRUD, 정렬 조회, 삭제 시 좋아요 연쇄 처리 | Product, Brand, Like | +| OrderFacade | 주문 생성(비관적 락 재고 차감 + 쿠폰 적용 + 스냅샷), 취소(재고 복원 + 쿠폰 복원), 권한 검증 | Order, Product, Brand, CouponFacade | +| LikeFacade | 좋아요 추가/취소(멱등) | Like, Product | +| CouponFacade | 쿠폰 템플릿 CRUD, 발급, 내 쿠폰 조회, 주문 연동(적용/복원) | Coupon, CouponIssue | +| MemberFacade | 회원가입, 비밀번호 변경 | Member | +| PaymentFacade | 결제 요청(수동 Retry + Rate Limiter + CB + Multi-PG), 콜백 처리, Polling Hybrid | Payment, PaymentOutbox, PaymentRecoveryService, ProvisionalOrderService | +| PaymentRecoveryService | 콜백 비동기 처리, 조건부 UPDATE(멱등), 재고/쿠폰 복원, PG 폴링 | Payment, CallbackInbox, ReconciliationMismatch | +| ProvisionalOrderService | Redis 가주문 CRUD, CB Open 시 DB Fallback | Redis, Order | + +--- + +## 3. Aggregate 구조 개요 + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ Brand Agg │ │ Product Agg │ │ Order Agg │ +├─────────────────┤ ├─────────────────┤ ├─────────────────┤ +│ Brand (Root) │ │ Product (Root) │ │ Order (Root) │ +│ │ │ ├ Price (VO) │ │ ├ OrderItem │ +│ │ │ └ Stock (VO) │ │ ├ ItemSnapshot │ +│ │ │ │ │ └ OrderStatus │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ + +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ Like Agg │ │ Member Agg │ │ Coupon Agg │ +├─────────────────┤ ├─────────────────┤ ├─────────────────┤ +│ Like (Root) │ │ Member (Root) │ │ Coupon (Root) │ +│ │ │ ├ LoginId (VO) │ │ ├ DiscountType │ +│ │ │ ├ Password (VO) │ │ │ +│ │ │ ├ Email (VO) │ │ CouponIssue │ +│ │ │ └ BirthDate(VO) │ │ ├ CouponIssue │ +│ │ │ │ │ │ Status │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ + +┌─────────────────────────────────────────────────────────────┐ +│ Payment Aggregate Group │ +├──────────────┬──────────────┬──────────────┬────────────────┤ +│ PaymentModel │ PaymentOutbox│ CallbackInbox│ Reconciliation │ +│ (Root) │ (Outbox 패턴)│ (DLQ 패턴) │ Mismatch │ +│ ├ PaymentSt. │ ├ OutboxSt. │ ├ InboxSt. │ (대사 감사) │ +│ ├ orderId │ ├ paymentId │ ├ txnKey │ ├ paymentId │ +│ ├ amount │ ├ payload │ ├ payload │ ├ ourStatus │ +│ ├ pgProvider │ ├ retryCount │ ├ retryCount │ ├ externalSt. │ +│ └ txnKey │ │ │ └ resolution │ +└──────────────┴──────────────┴──────────────┴────────────────┘ + +ID 참조: brandId, memberId, productId, couponId, couponIssueId, orderId, paymentId +``` + +--- + +## 4. 전체 클래스 다이어그램 + +```mermaid +classDiagram + direction TB + + %% ===== Brand Aggregate ===== + class Brand { + <> + -Long id + -String name + -String description + +Brand(name, description) + +changeName(name) + +changeDescription(description) + +delete() + } + + %% ===== Product Aggregate ===== + class Product { + <> + -Long id + -Long brandId + -String name + -Price price + -Stock stock + +Product(brandId, name, price, stock) + +changeName(name) + +changePrice(price) + +changeStock(stock) + +decreaseStock(quantity) + +increaseStock(quantity) + +delete() + } + + class Price { + <> + -int value + +Price(value) + } + + class Stock { + <> + -int quantity + +Stock(quantity) + +decrease(amount) Stock + +increase(amount) Stock + +hasEnough(amount) boolean + } + + Product *-- Price : contains + Product *-- Stock : contains + + %% ===== Order Aggregate ===== + class Order { + <> + -Long id + -Long memberId + -OrderStatus status + -int totalPrice + -int originalTotalPrice + -int discountAmount + -Long couponIssueId + -List~OrderItem~ items + +create(memberId, List~ItemSnapshot~, couponIssueId, discountAmount)$ Order + +cancel() + +getItems() List~OrderItem~ + } + + class ItemSnapshot { + <> + +Long productId + +String productName + +int productPrice + +String brandName + +int quantity + } + + class OrderItem { + <> + -Long id + -Long productId + -String productName + -int productPrice + -String brandName + -int quantity + ~OrderItem(productId, productName, productPrice, brandName, quantity) + +getSubtotal() int + } + + class OrderStatus { + <> + CREATED + PAID + CANCELLED + } + + Order *-- OrderItem : creates internally + Order -- ItemSnapshot : receives as input + Order --> OrderStatus : has + + %% ===== Like Aggregate ===== + class Like { + <> + -Long id + -Long memberId + -Long productId + +Like(memberId, productId) + } + + %% ===== Coupon Aggregate ===== + class Coupon { + <> + -Long id + -String name + -DiscountType discountType + -int discountValue + -int minOrderAmount + -ZonedDateTime expiredAt + +Coupon(name, discountType, discountValue, minOrderAmount, expiredAt) + +calculateDiscount(orderPrice) int + +validateUsable(orderPrice, now) + +changeName(name) + +changeDiscount(discountType, discountValue) + +changeMinOrderAmount(minOrderAmount) + +changeExpiredAt(expiredAt) + +delete() + } + + class DiscountType { + <> + FIXED + RATE + } + + class CouponIssue { + <> + -Long id + -Long couponId + -Long memberId + -Long usedOrderId + -CouponIssueStatus status + -ZonedDateTime expiredAt + +CouponIssue(couponId, memberId, expiredAt) + +use(orderId, now) + +cancelUse() + +isExpired(now) boolean + +getEffectiveStatus(now) CouponIssueStatus + +linkOrder(orderId) + } + + class CouponIssueStatus { + <> + AVAILABLE + USED + EXPIRED + } + + Coupon --> DiscountType : has + CouponIssue --> CouponIssueStatus : has + CouponIssue ..> Coupon : couponId + CouponIssue ..> Member : memberId + CouponIssue ..> Order : usedOrderId + + %% ===== Member Aggregate ===== + class Member { + <> + -Long id + -LoginId loginId + -Password password + -String name + -BirthDate birthDate + -Email email + +Member(loginId, password, name, birthDate, email) + +changePassword(newPassword) + } + + class LoginId { + <> + -String value + +LoginId(value) + } + + class Password { + <> + -String encoded + +create(plain, birthDate, encoder)$ Password + +matches(plain, encoder) boolean + } + + class Email { + <> + -String value + +Email(value) + } + + class BirthDate { + <> + -LocalDate value + +from(dateString)$ BirthDate + } + + Member *-- LoginId : contains + Member *-- Password : contains + Member *-- Email : contains + Member *-- BirthDate : contains + + %% ===== Payment Aggregate ===== + class PaymentModel { + <> + -Long id + -Long orderId + -PaymentStatus status + -int amount + -String cardType + -String cardNo + -String pgProvider + -String transactionKey + -String failureReason + +create(orderId, amount, cardType, cardNo)$ PaymentModel + +markPending(transactionKey, pgProvider) + +markPaid(transactionKey) + +markFailed(reason) + +markUnknown() + } + + class PaymentStatus { + <> + REQUESTED + PENDING + PAID + FAILED + UNKNOWN + +canTransitionTo(target) boolean + +isTerminal() boolean + } + + PaymentModel --> PaymentStatus : has + + class PaymentOutbox { + <> + -Long id + -Long paymentId + -Long orderId + -String eventType + -String payload + -PaymentOutboxStatus status + -int retryCount + +create(paymentId, orderId, eventType, payload)$ PaymentOutbox + +markProcessed() + +markFailed() + +incrementRetry() + } + + class PaymentOutboxStatus { + <> + PENDING + PROCESSED + FAILED + } + + PaymentOutbox --> PaymentOutboxStatus : has + PaymentOutbox ..> PaymentModel : paymentId + + class CallbackInbox { + <> + -Long id + -String transactionKey + -Long orderId + -String pgStatus + -String payload + -CallbackInboxStatus status + -int retryCount + -String errorMessage + +create(transactionKey, orderId, pgStatus, payload)$ CallbackInbox + +markProcessed() + +markFailed(errorMessage) + } + + class CallbackInboxStatus { + <> + RECEIVED + PROCESSED + FAILED + } + + CallbackInbox --> CallbackInboxStatus : has + + class ReconciliationMismatch { + <> + -Long id + -String type + -Long paymentId + -String ourStatus + -String externalStatus + -ZonedDateTime detectedAt + -ZonedDateTime resolvedAt + -String resolution + -String note + +create(type, paymentId, ourStatus, externalStatus)$ ReconciliationMismatch + +resolve(resolution) + } + + ReconciliationMismatch ..> PaymentModel : paymentId + + %% ===== PG 연동 (Infrastructure) ===== + class PgClient { + <> + +requestPayment(request) PgPaymentResponse + +getPaymentStatus(transactionKey) PgPaymentStatusResponse + +getPaymentByOrderId(orderId) PgPaymentStatusResponse + +getProviderName() String + } + + class PgRouter { + <> + -List~PgClient~ pgClients + +requestPayment(request) PgPaymentResponse + +getPaymentStatus(key, provider) PgPaymentStatusResponse + +getPaymentByOrderId(orderId) PgPaymentStatusResponse + -isTimeoutException(e) boolean + } + + class SlidingWindowRateLimiter { + <> + -int limit + -long windowSizeMs + -AtomicLong prevWindowCount + -AtomicLong currWindowCount + +tryAcquire() boolean + } + + PgRouter --> PgClient : routes to (Primary → Fallback) + + %% ===== Aggregate 간 ID 참조 ===== + Product ..> Brand : brandId + Order ..> Member : memberId + Order ..> CouponIssue : couponIssueId + OrderItem ..> Product : productId + Like ..> Member : memberId + Like ..> Product : productId + PaymentModel ..> Order : orderId + CallbackInbox ..> Order : orderId +``` + +--- + +## 5. Aggregate 라이프사이클 통제 + +### 원칙 + +> Aggregate Root가 자식의 생성/삭제를 통제한다. +> 외부에서 자식 Entity를 직접 생성할 수 없어야 한다. + +### 점검 결과 + +| Aggregate Root | 자식 | 관계 | 통제 방식 | 판정 | +|---|---|---|---|---| +| **Order** | OrderItem | `@OneToMany` Entity | `Order.create(ItemSnapshot)` + package-private 생성자 | **완벽** | +| **Product** | Price, Stock | `@Embedded` VO | 불변 VO, 생성자 자기검증 | **정상** (VO는 통제 대상 아님) | +| **Member** | LoginId 등 | `@Embedded` VO | 불변 VO, 생성자 자기검증 | **정상** (VO는 통제 대상 아님) | + +### Order Aggregate 상세 + +``` +외부 (OrderFacade) Order Aggregate 내부 +┌────────────────────┐ ┌─────────────────────────────────┐ +│ │ │ │ +│ ItemSnapshot ─────┼────▶ Order.create(snapshots) │ +│ (데이터만 전달) │ │ └─▶ new OrderItem(...) │ +│ │ │ (package-private) │ +│ new OrderItem() ──┼──✕──▶ │ │ +│ (컴파일 에러) │ │ │ +└────────────────────┘ └─────────────────────────────────┘ +``` + +- Facade는 `Order.ItemSnapshot`(데이터)만 전달 +- OrderItem 생성은 `Order.create()` 내부에서만 발생 +- OrderItem 생성자가 package-private이라 외부 패키지에서 직접 생성 불가 + +### VO는 왜 통제 대상이 아닌가 + +| 구분 | Entity (OrderItem) | Value Object (Price, Stock) | +|------|-------------------|---------------------------| +| 식별자 | 있음 (ID) | 없음 (값 동등성) | +| 가변성 | 상태 변경 가능 | 불변 | +| 라이프사이클 | 부모와 함께 | 없음 (값일 뿐) | +| 통제 필요성 | **필수** — 부모 없이 존재하면 안 됨 | **불필요** — 어디서 만들든 같은 값 | + +--- + +## 6. 연관관계 방향 + +| 관계 | 방향 | 참조 방식 | +|------|------|----------| +| Product → Brand | 단방향 | `brandId` (ID 참조) | +| Order → Member | 단방향 | `memberId` (ID 참조) | +| Order → OrderItem | Aggregate 내부 | 객체 참조 (`@OneToMany`) | +| Order → CouponIssue | 단방향 | `couponIssueId` (ID 참조, nullable) | +| OrderItem → Product | 단방향 | `productId` (ID 참조, 스냅샷) | +| Like → Member | 단방향 | `memberId` (ID 참조) | +| Like → Product | 단방향 | `productId` (ID 참조) | +| Coupon → DiscountType | Aggregate 내부 | enum 참조 | +| CouponIssue → Coupon | 단방향 | `couponId` (ID 참조) | +| CouponIssue → Member | 단방향 | `memberId` (ID 참조) | +| CouponIssue → Order | 단방향 | `usedOrderId` (ID 참조, nullable) | +| PaymentModel → Order | 단방향 | `orderId` (ID 참조, UNIQUE) | +| PaymentOutbox → PaymentModel | 단방향 | `paymentId` (ID 참조) | +| CallbackInbox → Order | 단방향 | `orderId` (ID 참조, nullable) | +| ReconciliationMismatch → PaymentModel | 단방향 | `paymentId` (ID 참조) | +| PgRouter → PgClient | 다형성 | `List` (Strategy, @Order 기반 우선순위) | + +**원칙**: +- **Aggregate 간 참조는 ID로**: 다른 Aggregate의 Root Entity를 직접 참조하지 않음 +- **Aggregate 내부는 객체 참조**: Order와 OrderItem은 같은 Aggregate + +--- + +## 7. 잠재 리스크 + +| 리스크 | 현재 상태 | 대응 방안 | +|--------|----------|----------| +| **Stock VO 동시성** | 비관적 락 적용 (SELECT ... FOR UPDATE) | ID 정렬 후 일괄 락으로 데드락 방지 | +| **좋아요 수 조회 비용** | COUNT(*) GROUP BY 배치 조회 | 극단적 트래픽 시 캐시 도입 고려 | +| **쿠폰 이중 사용** | 조건부 UPDATE로 원자적 처리 | affected rows = 0이면 이미 사용/만료 | +| **Aggregate 경계 넘는 참조** | ID로만 참조 | 성능을 위해 Join이 필요하면 읽기 전용 Query 모델 분리 고려 | +| **OrderItem 목록 크기** | 제한 없음 | 한 주문에 너무 많은 상품 시 트랜잭션 비대화. 최대 개수 제한 권장 | +| **Order 상태 전이** | 단순 enum + cancel() 검증 | 복잡해지면 상태 머신 패턴 또는 이벤트 소싱 고려 | +| **PaymentStatus 상태 전이 검증** | `canTransitionTo()` + 조건부 UPDATE | 동시 실행(콜백/배치/폴링) 시 1건만 성공, 나머지는 멱등 무시 | +| **PG 타임아웃 시 유령 결제** | UNKNOWN + Polling Hybrid + 배치 복구 | 타임아웃 시 Fallback 전환 불가 (중복 결제 방지) | +| **Redis-DB 재고 이중 존재** | Lua Script 원자적 보정 (30초) | DB가 SOT, Redis는 DB 기준으로 보정 | +| **Payment Aggregate 크기** | 4개 Entity가 독립적 라이프사이클 | Aggregate로 묶지 않음. ID 참조로 느슨한 결합 유지 | +| **PgClient 추가 확장** | Strategy 패턴 + @Order | 새 PG 추가 시 PgClient 구현 + @Order 설정만으로 확장 | diff --git a/docs/design/04-erd.md b/docs/design/04-erd.md new file mode 100644 index 0000000000..f05a199115 --- /dev/null +++ b/docs/design/04-erd.md @@ -0,0 +1,709 @@ +# ERD (Entity Relationship Diagram) + +## 1. 개요 + +이커머스 플랫폼의 핵심 도메인 테이블 구조를 정의한다. + +--- + +## 2. ERD 다이어그램 + +```mermaid +erDiagram + member ||--o{ orders : "places" + member ||--o{ likes : "has" + member ||--o{ coupon_issue : "receives" + brand ||--o{ product : "has" + product ||--o{ likes : "has" + product ||--o{ order_item : "referenced by" + orders ||--|{ order_item : "contains" + coupon ||--o{ coupon_issue : "issued as" + coupon_issue |o--o| orders : "applied to" + orders ||--o| payments : "has payment" + payments ||--o| payment_outbox : "outbox event" + payments ||--o{ reconciliation_mismatch : "audited by" + payments ||--o{ callback_inbox : "receives callback" + + member { + bigint id PK + varchar login_id UK "로그인 ID" + varchar password "암호화된 비밀번호" + varchar name "이름" + date birth_date "생년월일" + varchar email "이메일" + timestamp created_at + timestamp updated_at + timestamp deleted_at "soft delete" + } + + brand { + bigint id PK + varchar name "브랜드명" + varchar description "브랜드 설명" + timestamp created_at + timestamp updated_at + timestamp deleted_at "soft delete" + } + + product { + bigint id PK + bigint brand_id FK "브랜드 참조" + varchar name "상품명" + int price "가격 (원)" + int stock_quantity "재고 수량" + timestamp created_at + timestamp updated_at + timestamp deleted_at "soft delete" + } + + likes { + bigint id PK + bigint member_id FK "회원 참조" + bigint product_id FK "상품 참조" + timestamp created_at + } + + coupon { + bigint id PK + varchar name "쿠폰명" + varchar discount_type "할인 유형 (FIXED/RATE)" + int discount_value "할인 값" + int min_order_amount "최소 주문 금액" + timestamp expired_at "만료 일시" + timestamp created_at + timestamp updated_at + timestamp deleted_at "soft delete" + } + + coupon_issue { + bigint id PK + bigint coupon_id FK "쿠폰 템플릿 참조" + bigint member_id FK "회원 참조" + bigint used_order_id FK "사용된 주문 참조 (nullable)" + varchar status "상태 (AVAILABLE/USED/EXPIRED)" + timestamp expired_at "만료 일시" + timestamp created_at + timestamp updated_at + } + + orders { + bigint id PK + bigint member_id FK "주문자 참조" + varchar status "주문 상태 (CREATED/PAID/CANCELLED)" + int total_price "최종 결제 금액" + int original_total_price "쿠폰 적용 전 금액" + int discount_amount "할인 금액" + bigint coupon_issue_id FK "사용된 쿠폰 참조 (nullable)" + timestamp created_at + timestamp updated_at + timestamp deleted_at "soft delete" + } + + order_item { + bigint id PK + bigint order_id FK "주문 참조" + bigint product_id FK "상품 참조 (원본)" + varchar product_name "상품명 스냅샷" + int product_price "상품 가격 스냅샷" + varchar brand_name "브랜드명 스냅샷" + int quantity "주문 수량" + timestamp created_at + } + + payments { + bigint id PK + bigint order_id FK_UK "주문 참조 (1:1)" + varchar status "REQUESTED/PENDING/PAID/FAILED/UNKNOWN" + int amount "결제 금액" + varchar card_type "카드 유형" + varchar card_no "마스킹된 카드 번호" + varchar pg_provider "PG사 (SIMULATOR/TOSS)" + varchar transaction_key "PG 트랜잭션 키" + varchar failure_reason "실패 사유" + timestamp created_at + timestamp updated_at + timestamp deleted_at "soft delete" + } + + payment_outbox { + bigint id PK + bigint payment_id FK "결제 참조" + bigint order_id FK "주문 참조" + varchar event_type "이벤트 유형 (PAYMENT_REQUEST)" + text payload "JSON 페이로드" + varchar status "PENDING/PROCESSED/FAILED" + timestamp processed_at "처리 완료 시각" + int retry_count "재시도 횟수" + timestamp created_at + timestamp updated_at + timestamp deleted_at "soft delete" + } + + callback_inbox { + bigint id PK + varchar transaction_key "PG 트랜잭션 키" + bigint order_id FK "주문 참조" + varchar pg_status "PG 콜백 상태 (SUCCESS/FAILED)" + text payload "콜백 원본 페이로드" + varchar status "RECEIVED/PROCESSED/FAILED" + timestamp processed_at "처리 완료 시각" + int retry_count "재시도 횟수" + varchar error_message "오류 메시지" + timestamp created_at + timestamp updated_at + timestamp deleted_at "soft delete" + } + + reconciliation_mismatch { + bigint id PK + varchar type "대사 유형 (PG/ORDER/COUPON)" + bigint payment_id FK "결제 참조" + varchar our_status "내부 상태" + varchar external_status "외부(PG) 상태" + timestamp detected_at "감지 시각" + timestamp resolved_at "해소 시각" + varchar resolution "해소 방법" + text note "비고" + timestamp created_at + timestamp updated_at + timestamp deleted_at "soft delete" + } +``` + +--- + +## 3. 테이블 상세 명세 + +### 3.1 member (회원) + +> 1주차에 구현 완료. 참고용으로 포함. + +| 컬럼명 | 타입 | 제약조건 | 설명 | +|--------|------|----------|------| +| id | BIGINT | PK, AUTO_INCREMENT | 회원 고유 ID | +| login_id | VARCHAR(50) | UK, NOT NULL | 로그인 ID | +| password | VARCHAR(255) | NOT NULL | 암호화된 비밀번호 | +| name | VARCHAR(50) | NOT NULL | 이름 | +| birth_date | DATE | NOT NULL | 생년월일 | +| email | VARCHAR(100) | NOT NULL | 이메일 | +| created_at | TIMESTAMP | NOT NULL | 생성 일시 | +| updated_at | TIMESTAMP | NOT NULL | 수정 일시 | +| deleted_at | TIMESTAMP | NULL | 삭제 일시 (soft delete) | + +--- + +### 3.2 brand (브랜드) + +| 컬럼명 | 타입 | 제약조건 | 설명 | +|--------|------|----------|------| +| id | BIGINT | PK, AUTO_INCREMENT | 브랜드 고유 ID | +| name | VARCHAR(100) | NOT NULL | 브랜드명 | +| description | VARCHAR(500) | NULL | 브랜드 설명 | +| created_at | TIMESTAMP | NOT NULL | 생성 일시 | +| updated_at | TIMESTAMP | NOT NULL | 수정 일시 | +| deleted_at | TIMESTAMP | NULL | 삭제 일시 (soft delete) | + +**인덱스**: +- `idx_brand_deleted_at`: deleted_at (목록 조회 시 필터링) + +--- + +### 3.3 product (상품) + +| 컬럼명 | 타입 | 제약조건 | 설명 | +|--------|------|----------|------| +| id | BIGINT | PK, AUTO_INCREMENT | 상품 고유 ID | +| brand_id | BIGINT | FK (논리적) | 브랜드 참조 | +| name | VARCHAR(200) | NOT NULL | 상품명 | +| price | INT | NOT NULL, CHECK(price > 0) | 가격 (원) | +| stock_quantity | INT | NOT NULL, DEFAULT 0, CHECK(stock_quantity >= 0) | 재고 수량 | +| created_at | TIMESTAMP | NOT NULL | 생성 일시 | +| updated_at | TIMESTAMP | NOT NULL | 수정 일시 | +| deleted_at | TIMESTAMP | NULL | 삭제 일시 (soft delete) | + +**인덱스**: +- `idx_product_brand_id`: brand_id (브랜드별 상품 조회) +- `idx_product_deleted_at`: deleted_at (목록 조회 시 필터링) + +**설계 결정**: +- `like_count` 컬럼 제거: UNIQUE 제약 + COUNT(*) 파생 방식으로 전환. 좋아요 추가/삭제 시 Product 행 경합을 원천 제거 +- 인기순 정렬은 Application Layer에서 배치 COUNT(GROUP BY) 후 정렬 + +--- + +### 3.4 likes (좋아요) + +| 컬럼명 | 타입 | 제약조건 | 설명 | +|--------|------|----------|------| +| id | BIGINT | PK, AUTO_INCREMENT | 좋아요 고유 ID | +| member_id | BIGINT | FK (논리적), NOT NULL | 회원 참조 | +| product_id | BIGINT | FK (논리적), NOT NULL | 상품 참조 | +| created_at | TIMESTAMP | NOT NULL | 생성 일시 | + +**인덱스**: +- `uk_likes_member_product`: (member_id, product_id) UNIQUE - 중복 좋아요 방지 +- `idx_likes_member_id`: member_id (회원별 좋아요 목록 조회) +- `idx_likes_product_id`: product_id (상품별 좋아요 조회) + +**설계 결정**: +- Hard Delete 사용 (soft delete 불필요) +- 상품/브랜드 삭제 시 연쇄 삭제 + +--- + +### 3.5 orders (주문) + +| 컬럼명 | 타입 | 제약조건 | 설명 | +|--------|------|----------|------| +| id | BIGINT | PK, AUTO_INCREMENT | 주문 고유 ID | +| member_id | BIGINT | FK (논리적), NOT NULL | 주문자 참조 | +| status | VARCHAR(20) | NOT NULL | 주문 상태 | +| total_price | INT | NOT NULL, CHECK(total_price >= 0) | 총 주문 금액 | +| original_total_price | INT | NOT NULL, DEFAULT 0 | 쿠폰 적용 전 금액 | +| discount_amount | INT | NOT NULL, DEFAULT 0 | 할인 금액 | +| coupon_issue_id | BIGINT | NULL | 사용된 쿠폰 참조 | +| created_at | TIMESTAMP | NOT NULL | 생성 일시 | +| updated_at | TIMESTAMP | NOT NULL | 수정 일시 | +| deleted_at | TIMESTAMP | NULL | 삭제 일시 (soft delete) | + +**인덱스**: +- `idx_orders_member_id`: member_id (회원별 주문 조회) +- `idx_orders_status`: status (상태별 필터링) +- `idx_orders_member_created_at`: (member_id, created_at) (회원별 날짜 범위 조회) + +**주문 상태 값**: +| 상태 | 설명 | +|------|------| +| CREATED | 주문 생성됨 (현재는 이게 곧 완료) | +| PAID | 결제 완료 (미래 확장용) | +| CANCELLED | 취소됨 | + +--- + +### 3.6 order_item (주문 항목) + +| 컬럼명 | 타입 | 제약조건 | 설명 | +|--------|------|----------|------| +| id | BIGINT | PK, AUTO_INCREMENT | 주문 항목 고유 ID | +| order_id | BIGINT | FK (논리적), NOT NULL | 주문 참조 | +| product_id | BIGINT | FK (논리적), NOT NULL | 상품 참조 (원본) | +| product_name | VARCHAR(200) | NOT NULL | 상품명 **스냅샷** | +| product_price | INT | NOT NULL | 상품 가격 **스냅샷** | +| brand_name | VARCHAR(100) | NOT NULL | 브랜드명 **스냅샷** | +| quantity | INT | NOT NULL, CHECK(quantity > 0) | 주문 수량 | +| created_at | TIMESTAMP | NOT NULL | 생성 일시 | + +**인덱스**: +- `idx_order_item_order_id`: order_id (주문별 항목 조회) + +**설계 결정 (스냅샷)**: + +스냅샷 범위 판단 기준: **"주문 상세 화면을 독립적으로 렌더링할 수 있는가?"** + +| 컬럼 | 스냅샷 이유 | +|------|------------| +| `product_name` | 필수. 없으면 주문 상세 화면 성립 불가 | +| `product_price` | 필수. 정산/환불 기준, 금액 증빙 | +| `brand_name` | 권장. 주문 내역 UI에 거의 항상 표시 | + +- `image_url` 제외: 현재 상품 스펙에 이미지 필드 없음 (요구사항에 없는 필드를 미리 넣는 건 오버엔지니어링) +- `description` 제외: 주문 상세가 아닌 상품 상세 페이지 영역 +- `product_id`는 원본 참조용으로 유지 (상품 페이지 이동, 재주문 기능용. 삭제 시 404 반환은 허용) + +--- + +### 3.7 coupon (쿠폰 템플릿) + +| 컬럼명 | 타입 | 제약조건 | 설명 | +|--------|------|----------|------| +| id | BIGINT | PK, AUTO_INCREMENT | 쿠폰 고유 ID | +| name | VARCHAR(100) | NOT NULL | 쿠폰명 | +| discount_type | VARCHAR(20) | NOT NULL | 할인 유형 (FIXED/RATE) | +| discount_value | INT | NOT NULL | 할인 값 | +| min_order_amount | INT | NOT NULL, DEFAULT 0 | 최소 주문 금액 | +| expired_at | TIMESTAMP | NOT NULL | 만료 일시 | +| created_at | TIMESTAMP | NOT NULL | 생성 일시 | +| updated_at | TIMESTAMP | NOT NULL | 수정 일시 | +| deleted_at | TIMESTAMP | NULL | 삭제 일시 (soft delete) | + +--- + +### 3.8 coupon_issue (발급된 쿠폰) + +| 컬럼명 | 타입 | 제약조건 | 설명 | +|--------|------|----------|------| +| id | BIGINT | PK, AUTO_INCREMENT | 발급 고유 ID | +| coupon_id | BIGINT | FK (논리적), NOT NULL | 쿠폰 템플릿 참조 | +| member_id | BIGINT | FK (논리적), NOT NULL | 회원 참조 | +| used_order_id | BIGINT | NULL | 사용된 주문 참조 | +| status | VARCHAR(20) | NOT NULL | 상태 (AVAILABLE/USED/EXPIRED) | +| expired_at | TIMESTAMP | NOT NULL | 만료 일시 | +| created_at | TIMESTAMP | NOT NULL | 생성 일시 | +| updated_at | TIMESTAMP | NOT NULL | 수정 일시 | + +**인덱스**: +- `idx_coupon_issue_coupon_id`: coupon_id (쿠폰별 발급 내역 조회) +- `idx_coupon_issue_member_id`: member_id (회원별 쿠폰 조회) + +**설계 결정**: +- 동시성 제어: 조건부 UPDATE (`WHERE status='AVAILABLE' AND expired_at > now`)로 비관적 락 없이 이중 사용 방지 +- status는 DB 컬럼이지만, AVAILABLE 상태에서 만료시간이 지난 경우 조회 시 EXPIRED로 표시 (getEffectiveStatus) + +--- + +### 3.9 payments (결제) + +| 컬럼명 | 타입 | 제약조건 | 설명 | +|--------|------|----------|------| +| id | BIGINT | PK, AUTO_INCREMENT | 결제 고유 ID | +| order_id | BIGINT | FK (논리적), UNIQUE, NOT NULL | 주문 참조 (1:1) | +| status | VARCHAR(20) | NOT NULL | 결제 상태 | +| amount | INT | NOT NULL | 결제 금액 | +| card_type | VARCHAR(20) | NULL | 카드 유형 (VISA, MASTERCARD 등) | +| card_no | VARCHAR(30) | NULL | 마스킹된 카드 번호 | +| pg_provider | VARCHAR(20) | NULL | PG사 (SIMULATOR/TOSS) | +| transaction_key | VARCHAR(100) | NULL | PG 트랜잭션 키 | +| failure_reason | VARCHAR(255) | NULL | 실패 사유 | +| created_at | TIMESTAMP | NOT NULL | 생성 일시 | +| updated_at | TIMESTAMP | NOT NULL | 수정 일시 | +| deleted_at | TIMESTAMP | NULL | 삭제 일시 (soft delete) | + +**인덱스**: +- `uk_payments_order_id`: order_id (UNIQUE — 주문당 결제 1건) +- `idx_payments_transaction_key`: transaction_key (PG 트랜잭션 키 조회) +- `idx_payments_status`: status (상태별 배치 조회) + +**결제 상태 값**: + +| 상태 | 설명 | 전이 가능 대상 | +|------|------|---------------| +| REQUESTED | 결제 요청 생성됨 (PG 호출 전) | PENDING, FAILED, UNKNOWN | +| PENDING | PG에 요청 전달됨 (비동기 PG 응답 대기) | PAID, FAILED, UNKNOWN | +| PAID | 결제 완료 (최종) | — | +| FAILED | 결제 실패 (최종) | — | +| UNKNOWN | 타임아웃 등으로 PG 응답 불명 | PAID, FAILED | + +**설계 결정**: +- `order_id` UNIQUE: 하나의 주문에는 하나의 결제만 존재 (재결제 시 새 Payment 생성) +- 조건부 UPDATE: `WHERE status IN ('PENDING','UNKNOWN')` → 콜백/배치/폴링 동시 실행 시 1건만 성공 +- `transaction_key`는 PG 응답 이후 설정 → REQUESTED 시점에는 NULL + +--- + +### 3.10 payment_outbox (결제 아웃박스) + +| 컬럼명 | 타입 | 제약조건 | 설명 | +|--------|------|----------|------| +| id | BIGINT | PK, AUTO_INCREMENT | 아웃박스 고유 ID | +| payment_id | BIGINT | FK (논리적), NOT NULL | 결제 참조 | +| order_id | BIGINT | FK (논리적), NOT NULL | 주문 참조 | +| event_type | VARCHAR(50) | NOT NULL | 이벤트 유형 (PAYMENT_REQUEST) | +| payload | TEXT | NOT NULL | JSON 페이로드 | +| status | VARCHAR(20) | NOT NULL, DEFAULT 'PENDING' | 처리 상태 | +| processed_at | TIMESTAMP | NULL | 처리 완료 시각 | +| retry_count | INT | NOT NULL, DEFAULT 0 | 재시도 횟수 | +| created_at | TIMESTAMP | NOT NULL | 생성 일시 | +| updated_at | TIMESTAMP | NOT NULL | 수정 일시 | +| deleted_at | TIMESTAMP | NULL | 삭제 일시 (soft delete) | + +**인덱스**: +- `idx_payment_outbox_status`: status (PENDING 건 조회 — 5초 폴링) +- `idx_payment_outbox_payment_id`: payment_id (결제별 아웃박스 조회) + +**설계 결정 (Outbox 패턴)**: +- Payment INSERT + Outbox INSERT = 같은 TX-1 → 서버 크래시 시에도 PG 호출 누락 방지 +- 5초 주기 폴러가 PENDING 건을 PG에 재전송 +- `retry_count`로 무한 재시도 방지 (최대 횟수 도달 시 FAILED 전환) + +--- + +### 3.11 callback_inbox (콜백 인박스 — DLQ) + +| 컬럼명 | 타입 | 제약조건 | 설명 | +|--------|------|----------|------| +| id | BIGINT | PK, AUTO_INCREMENT | 인박스 고유 ID | +| transaction_key | VARCHAR(100) | NOT NULL | PG 트랜잭션 키 | +| order_id | BIGINT | NULL | 주문 참조 | +| pg_status | VARCHAR(20) | NOT NULL | PG 콜백 상태 (SUCCESS/FAILED) | +| payload | TEXT | NULL | 콜백 원본 페이로드 (JSON) | +| status | VARCHAR(20) | NOT NULL, DEFAULT 'RECEIVED' | 처리 상태 | +| processed_at | TIMESTAMP | NULL | 처리 완료 시각 | +| retry_count | INT | NOT NULL, DEFAULT 0 | 재시도 횟수 | +| error_message | VARCHAR(255) | NULL | 오류 메시지 | +| created_at | TIMESTAMP | NOT NULL | 생성 일시 | +| updated_at | TIMESTAMP | NOT NULL | 수정 일시 | +| deleted_at | TIMESTAMP | NULL | 삭제 일시 (soft delete) | + +**인덱스**: +- `idx_callback_inbox_transaction_key`: transaction_key (트랜잭션 키로 조회) +- `idx_callback_inbox_status`: status (RECEIVED + 30초 경과 건 DLQ 재처리) + +**설계 결정 (Callback Inbox DLQ)**: +- PG 콜백 수신 즉시 원본 저장 (RECEIVED) → PG에게 200 OK 즉시 반환 +- 내부 처리는 비동기: RECEIVED → PROCESSED 또는 FAILED +- RECEIVED + 30초 경과 건은 DLQ 스케줄러가 재처리 +- `payload` 원본 보존으로 콜백 유실 원천 차단 + +--- + +### 3.12 reconciliation_mismatch (대사 불일치) + +| 컬럼명 | 타입 | 제약조건 | 설명 | +|--------|------|----------|------| +| id | BIGINT | PK, AUTO_INCREMENT | 대사 불일치 고유 ID | +| type | VARCHAR(50) | NOT NULL | 대사 유형 (PG_PAYMENT/PAYMENT_ORDER/PAYMENT_COUPON) | +| payment_id | BIGINT | FK (논리적), NOT NULL | 결제 참조 | +| our_status | VARCHAR(20) | NOT NULL | 내부 상태 | +| external_status | VARCHAR(20) | NULL | 외부(PG) 상태 | +| detected_at | TIMESTAMP | NOT NULL | 감지 시각 | +| resolved_at | TIMESTAMP | NULL | 해소 시각 | +| resolution | VARCHAR(255) | NULL | 해소 방법 | +| note | TEXT | NULL | 비고 | +| created_at | TIMESTAMP | NOT NULL | 생성 일시 | +| updated_at | TIMESTAMP | NOT NULL | 수정 일시 | +| deleted_at | TIMESTAMP | NULL | 삭제 일시 (soft delete) | + +**인덱스**: +- `idx_recon_mismatch_type`: type (대사 유형별 조회) +- `idx_recon_mismatch_payment_id`: payment_id (결제별 불일치 조회) + +**설계 결정 (대사 배치)**: +- 3종 대사: R1(PG↔Payment), R2(Payment↔Order), R3(Payment↔Coupon) — 1시간 주기 +- 불일치 0건 = 복구 로직이 정상 동작하는지 검증하는 최종 안전망 +- 자동 보상 가능한 케이스(Payment FAILED + PG SUCCESS)는 자동 보정 후 기록 +- 자동 보상 불가한 케이스는 `note`에 기록 + 알림 + +--- + +## 4. 관계 요약 + +| 관계 | 카디널리티 | 설명 | +|------|-----------|------| +| member - orders | 1:N | 회원은 여러 주문 가능 | +| member - likes | 1:N | 회원은 여러 좋아요 가능 | +| brand - product | 1:N | 브랜드는 여러 상품 보유 | +| product - likes | 1:N | 상품은 여러 좋아요 받음 | +| product - order_item | 1:N | 상품은 여러 주문에 포함 | +| orders - order_item | 1:N | 주문은 여러 항목 포함 | +| coupon - coupon_issue | 1:N | 쿠폰 템플릿에서 여러 번 발급 | +| member - coupon_issue | 1:N | 회원은 여러 쿠폰 보유 | +| coupon_issue - orders | 1:0..1 | 쿠폰은 최대 1건 주문에 사용 | +| orders - payments | 1:0..1 | 주문은 최대 1건 결제 보유 | +| payments - payment_outbox | 1:0..1 | 결제당 1건의 아웃박스 이벤트 | +| payments - callback_inbox | 1:N | 결제에 여러 콜백 수신 가능 (중복 콜백) | +| payments - reconciliation_mismatch | 1:N | 결제에 여러 대사 불일치 기록 가능 | + +--- + +## 5. FK 제약 정책 + +| 관계 | FK 제약 | 이유 | +|------|---------|------| +| product → brand | 논리적 (제약 없음) | 브랜드 삭제 시 soft delete, 애플리케이션에서 검증 | +| likes → member/product | 논리적 | 상품 삭제 시 좋아요 연쇄 삭제, 애플리케이션 처리 | +| orders → member | 논리적 | 회원 삭제 시에도 주문 이력 보존 | +| order_item → orders | 논리적 | 주문과 항목은 항상 함께 관리 | +| order_item → product | 논리적 | 스냅샷이 있어 원본 삭제 가능 | +| coupon_issue → coupon | 논리적 | 쿠폰 삭제(soft) 후에도 발급 이력 보존 | +| coupon_issue → member | 논리적 | 회원 삭제 시에도 쿠폰 이력 보존 | +| orders → coupon_issue | 논리적 | 쿠폰 없는 주문도 가능 (nullable) | +| payments → orders | 논리적 | 주문 삭제 시에도 결제 이력 보존 | +| payment_outbox → payments | 논리적 | 결제와 아웃박스 같은 TX에서 생성 | +| callback_inbox → payments | 논리적 | 트랜잭션 키로 논리적 참조 | +| reconciliation_mismatch → payments | 논리적 | 대사 불일치 기록은 감사 목적 | + +**참고**: 대규모 트래픽에서 FK 제약은 데드락, Cascading 이슈를 유발할 수 있어 논리적 관계로 설계. 데이터 정합성은 애플리케이션 레벨에서 보장. + +--- + +## 6. DDL 예시 + +```sql +-- 브랜드 테이블 +CREATE TABLE brand ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + description VARCHAR(500), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL, + INDEX idx_brand_deleted_at (deleted_at) +); + +-- 상품 테이블 +CREATE TABLE product ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + brand_id BIGINT NOT NULL, + name VARCHAR(200) NOT NULL, + price INT NOT NULL, + stock_quantity INT NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL, + INDEX idx_product_brand_id (brand_id), + INDEX idx_product_deleted_at (deleted_at), + CONSTRAINT chk_product_price CHECK (price > 0), + CONSTRAINT chk_product_stock CHECK (stock_quantity >= 0) +); + +-- 좋아요 테이블 +CREATE TABLE likes ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + member_id BIGINT NOT NULL, + product_id BIGINT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_likes_member_product (member_id, product_id), + INDEX idx_likes_member_id (member_id), + INDEX idx_likes_product_id (product_id) +); + +-- 주문 테이블 +CREATE TABLE orders ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + member_id BIGINT NOT NULL, + status VARCHAR(20) NOT NULL, + total_price INT NOT NULL, + original_total_price INT NOT NULL DEFAULT 0, + discount_amount INT NOT NULL DEFAULT 0, + coupon_issue_id BIGINT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL, + INDEX idx_orders_member_id (member_id), + INDEX idx_orders_status (status), + INDEX idx_orders_member_created_at (member_id, created_at), + CONSTRAINT chk_orders_total_price CHECK (total_price >= 0) +); + +-- 주문 항목 테이블 +CREATE TABLE order_item ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + order_id BIGINT NOT NULL, + product_id BIGINT NOT NULL, + product_name VARCHAR(200) NOT NULL, + product_price INT NOT NULL, + brand_name VARCHAR(100) NOT NULL, + quantity INT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_order_item_order_id (order_id), + CONSTRAINT chk_order_item_quantity CHECK (quantity > 0) +); + +-- 쿠폰 템플릿 테이블 +CREATE TABLE coupon ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + discount_type VARCHAR(20) NOT NULL, + discount_value INT NOT NULL, + min_order_amount INT NOT NULL DEFAULT 0, + expired_at TIMESTAMP NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL +); + +-- 발급된 쿠폰 테이블 +CREATE TABLE coupon_issue ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + coupon_id BIGINT NOT NULL, + member_id BIGINT NOT NULL, + used_order_id BIGINT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'AVAILABLE', + expired_at TIMESTAMP NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_coupon_issue_coupon_id (coupon_id), + INDEX idx_coupon_issue_member_id (member_id) +); + +-- 결제 테이블 +CREATE TABLE payments ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + order_id BIGINT NOT NULL, + status VARCHAR(20) NOT NULL, + amount INT NOT NULL, + card_type VARCHAR(20) NULL, + card_no VARCHAR(30) NULL, + pg_provider VARCHAR(20) NULL, + transaction_key VARCHAR(100) NULL, + failure_reason VARCHAR(255) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL, + UNIQUE KEY uk_payments_order_id (order_id), + INDEX idx_payments_transaction_key (transaction_key), + INDEX idx_payments_status (status) +); + +-- 결제 아웃박스 테이블 (Outbox Pattern) +CREATE TABLE payment_outbox ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + payment_id BIGINT NOT NULL, + order_id BIGINT NOT NULL, + event_type VARCHAR(50) NOT NULL, + payload TEXT NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + processed_at TIMESTAMP NULL, + retry_count INT NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL, + INDEX idx_payment_outbox_status (status), + INDEX idx_payment_outbox_payment_id (payment_id) +); + +-- 콜백 인박스 테이블 (DLQ Pattern) +CREATE TABLE callback_inbox ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + transaction_key VARCHAR(100) NOT NULL, + order_id BIGINT NULL, + pg_status VARCHAR(20) NOT NULL, + payload TEXT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'RECEIVED', + processed_at TIMESTAMP NULL, + retry_count INT NOT NULL DEFAULT 0, + error_message VARCHAR(255) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL, + INDEX idx_callback_inbox_transaction_key (transaction_key), + INDEX idx_callback_inbox_status (status) +); + +-- 대사 불일치 테이블 +CREATE TABLE reconciliation_mismatch ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + type VARCHAR(50) NOT NULL, + payment_id BIGINT NOT NULL, + our_status VARCHAR(20) NOT NULL, + external_status VARCHAR(20) NULL, + detected_at TIMESTAMP NOT NULL, + resolved_at TIMESTAMP NULL, + resolution VARCHAR(255) NULL, + note TEXT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL, + INDEX idx_recon_mismatch_type (type), + INDEX idx_recon_mismatch_payment_id (payment_id) +); +``` + +--- + +## 7. 잠재 리스크 + +| 리스크 | 현재 상태 | 대응 방안 | +|--------|----------|----------| +| **FK 제약 없음** | 논리적 관계만 정의 | 데이터 정합성은 애플리케이션에서 보장. 정기적 정합성 체크 배치 필요 | +| **좋아요 COUNT 파생** | COUNT(*) GROUP BY 조회 | 대량 상품 목록 시 쿼리 비용. 배치 COUNT로 최적화 완료, 극단적 트래픽 시 캐시 고려 | +| **soft delete 쿼리 복잡도** | WHERE deleted_at IS NULL 필수 | 조회 쿼리마다 조건 누락 위험. 기본 스코프 또는 뷰 활용 권장 | +| **order_item 스냅샷 중복** | 같은 상품 여러 주문 시 반복 저장 | 데이터 증가. 스냅샷 테이블 분리 또는 압축 고려 (대량 트래픽 시) | +| **인덱스 과다** | 정렬/필터용 여러 인덱스 | 쓰기 성능 저하 가능. 실제 쿼리 패턴 분석 후 최적화 | +| **orders.status VARCHAR** | 문자열 저장 | ENUM 타입으로 변경하거나 코드 테이블 분리 고려 | +| **쿠폰 조건부 UPDATE 경합** | WHERE 조건으로 원자적 처리 | 동일 쿠폰 동시 사용 시 1건만 성공. 실패한 요청은 "이미 사용" 에러 | +| **payments.status VARCHAR** | 문자열 저장 (5개 상태) | `canTransitionTo()` + 조건부 UPDATE로 상태 머신 보장 | +| **payment_outbox 폴링 부하** | 5초 주기 SELECT | PENDING 건만 조회, idx_payment_outbox_status 인덱스 활용. 처리량 증가 시 폴링 주기 조정 | +| **callback_inbox 중복 콜백** | 같은 transaction_key로 다중 콜백 수신 가능 | 조건부 UPDATE로 멱등 처리. 첫 번째만 반영, 나머지 무시 | +| **reconciliation_mismatch 데이터 증가** | 대사 주기(1시간)마다 조회 | resolved_at 기준으로 아카이빙 정책 적용 권장 | +| **Redis 가주문 ↔ DB 결제 정합성** | Redis(가주문) → DB(진주문) 전환 | SOT 전환: Redis 임시 → DB 확정. Lua Script로 재고 원자적 보정 | diff --git a/docs/design/05-payment-resilience.md b/docs/design/05-payment-resilience.md new file mode 100644 index 0000000000..903bc340bd --- /dev/null +++ b/docs/design/05-payment-resilience.md @@ -0,0 +1,1440 @@ +# PG 비동기 결제 연동 — Resilience 설계 문서 + +## 1. 개요 + +PG 시뮬레이터와의 비동기 결제 연동에서 발생할 수 있는 모든 장애 지점을 식별하고, +Timeout → Retry → CircuitBreaker → Fallback 흐름으로 단계별 회복 전략을 설계한다. + +--- + +## 2. PG 시뮬레이터 분석 + +### 2.1 API 스펙 + +| METHOD | URI | 설명 | +|--------|-----|------| +| POST | `/api/v1/payments` | 결제 요청 (비동기) | +| GET | `/api/v1/payments/{transactionKey}` | 결제 상태 확인 | +| GET | `/api/v1/payments?orderId={orderId}` | 주문별 결제 조회 | + +### 2.2 결제 요청 Body + +```json +{ + "orderId": "1351039135", + "cardType": "SAMSUNG", + "cardNo": "1234-5678-9814-1451", + "amount": 5000, + "callbackUrl": "http://localhost:8080/api/v1/payments/callback" +} +``` + +### 2.3 비동기 결제 흐름 + +``` +[Commerce API] [PG Simulator] + | | + |--- POST /api/v1/payments ------->| + | |-- (100~500ms 랜덤 지연) + | |-- (40% 확률: 즉시 500 에러) + | |-- (60% 확률: Payment 저장, status=PENDING) + |<-- 200 {status: PENDING} --------| + | | + | |== [비동기 처리: 1~5초 후] == + | |-- 70%: SUCCESS + | |-- 20%: FAILED (한도 초과) + | |-- 10%: FAILED (잘못된 카드) + | | + |<-- POST callback (결과 통보) ----| + | | +``` + +### 2.4 PG 시뮬레이터 특성 정리 + +| 구간 | 특성 | 수치 | +|------|------|------| +| 요청 지연 | 랜덤 Thread.sleep | 100~500ms | +| 요청 실패 | 랜덤 500 에러 (서버 불안정 시뮬레이션) | **40% 확률** | +| 처리 지연 | 비동기 처리 대기 시간 | 1~5초 | +| 처리 성공 | 정상 승인 | 70% | +| 처리 실패 | 한도 초과 | 20% | +| 처리 실패 | 잘못된 카드 | 10% | +| 콜백 실패 | 콜백 POST 실패 시 로그만 남기고 **재시도 없음** | - | + +### 2.5 PG 결제 상태 + +``` +PENDING ──approve()──→ SUCCESS (reason: "정상 승인되었습니다.") +PENDING ──limitExceeded()──→ FAILED (reason: "한도초과입니다. 다른 카드를 선택해주세요.") +PENDING ──invalidCard()──→ FAILED (reason: "잘못된 카드입니다. 다른 카드를 선택해주세요.") +``` + +- 상태 전이는 **PENDING에서만 가능** (단방향) +- SUCCESS/FAILED에서 다른 상태로 전이 불가 + +--- + +## 3. 장애 발생 지점 식별 + +결제 요청부터 결과 반영까지, 장애가 발생할 수 있는 **모든 지점**을 식별한다. + +### 3.1 장애 지점 맵 + +``` +[사용자 결제 요청] + | + ▼ +┌─ F1. 내부 주문 검증 실패 (주문 없음, 이미 결제됨 등) + | + ▼ +┌─ F2. PG 요청 전송 실패 (네트워크 연결 불가) +│ F3. PG 요청 타임아웃 (응답 지연 > 타임아웃) +│ F4. PG 500 에러 (40% 확률 서버 불안정) + | + ▼ (PG 응답: PENDING) +┌─ F5. PG 응답 수신했으나 내부 저장 실패 + | + ▼ (비동기 대기) +┌─ F6. PG 비동기 처리 결과: FAILED (한도 초과/잘못된 카드) +│ F7. 콜백 미수신 (PG에서 콜백 전송 실패) +│ F8. 콜백 수신했으나 내부 처리 실패 + | + ▼ +┌─ F9. 타임아웃으로 실패 처리했으나, PG에서는 결제 성공 (유령 결제) +``` + +### 3.2 장애 지점별 분석 + +| ID | 장애 지점 | 발생 원인 | 심각도 | 발생 빈도 | +|----|----------|----------|--------|----------| +| **F1** | 내부 주문 검증 실패 | 존재하지 않는 주문, 이미 결제된 주문 | 낮음 | 드묾 | +| **F2** | PG 연결 실패 | 네트워크 단절, PG 서버 다운 | 높음 | 드묾 | +| **F3** | PG 응답 타임아웃 | PG 지연 (100~500ms 범위 초과) | 중간 | 보통 | +| **F4** | PG 500 에러 | 서버 불안정 시뮬레이션 | 중간 | **높음 (40%)** | +| **F5** | 내부 저장 실패 | DB 장애 등 | 높음 | 드묾 | +| **F6** | PG 비동기 처리 실패 | 한도 초과(20%), 잘못된 카드(10%) | 낮음 | 보통 (30%) | +| **F7** | 콜백 미수신 | PG 콜백 전송 실패 (재시도 없음) | **높음** | 보통 | +| **F8** | 콜백 수신 후 내부 처리 실패 | 서버 장애, DB 장애 | 높음 | 드묾 | +| **F9** | 유령 결제 | 타임아웃 실패 처리했으나 PG에서 결제 진행 | **매우 높음** | 보통 | + +--- + +## 4. 내부 결제 상태 설계 + +### 4.1 왜 내부 결제 상태가 필요한가? + +PG의 상태(PENDING/SUCCESS/FAILED)만으로는 우리 시스템의 모든 상태를 표현할 수 없다. + +- PG 요청 자체가 실패한 경우 (PG에는 기록 없음) +- 타임아웃으로 결과를 모르는 경우 (PG에서는 처리 중일 수 있음) +- 콜백을 못 받은 경우 (PG에서는 이미 SUCCESS인데 우리는 모름) + +### 4.2 내부 결제 상태 전이 + +``` +REQUESTED ──PG 응답 PENDING──→ PENDING ──콜백 SUCCESS──→ PAID + │ │ + │ ├──콜백 FAILED──→ FAILED + │ │ + │ └──콜백 미수신 (일정 시간 초과)──→ UNKNOWN + │ + ├──PG 요청 실패 (500/타임아웃)──→ FAILED + │ + └──PG 요청 타임아웃 (PG에서 처리 가능성 있음)──→ UNKNOWN +``` + +| 내부 상태 | 의미 | PG 상태와의 관계 | +|----------|------|----------------| +| **REQUESTED** | 결제 요청 생성, PG 호출 전/중 | PG에 아직 기록 없을 수 있음 | +| **PENDING** | PG 응답 수신 (transactionKey 확보) | PG: PENDING | +| **PAID** | 결제 성공 확인 | PG: SUCCESS | +| **FAILED** | 결제 실패 확정 | PG: FAILED 또는 PG 요청 자체 실패 | +| **UNKNOWN** | 결과 불명 (타임아웃, 콜백 미수신) | PG 상태 확인 필요 | + +### 4.3 왜 UNKNOWN 상태가 필요한가? + +**근거**: 분산 시스템에서 "요청은 실패했지만, 상대방에서는 처리되었을 수 있는" 상황은 반드시 존재한다. + +| 선택지 | 장점 | 단점 | +|--------|------|------| +| A. UNKNOWN 없이 FAILED로 처리 | 단순함 | **유령 결제 발생** — 고객 돈은 빠졌는데 주문은 실패 처리 | +| B. UNKNOWN 없이 PENDING 유지 | 상태가 적음 | PENDING이 "PG 처리 중"과 "결과 불명"을 혼재, 복구 로직 구분 불가 | +| **C. UNKNOWN 상태 분리** | **결과 불명을 명시적으로 표현, 복구 대상 식별 가능** | 상태 하나 추가 | + +**결정: C. UNKNOWN 상태 분리** + +- UNKNOWN 상태의 결제건만 골라서 PG 상태 확인 API로 복구할 수 있다 +- 운영자가 "결과를 모르는 결제"를 즉시 식별할 수 있다 +- 상태 하나 추가하는 비용 대비 안전성 확보 효과가 압도적이다 + +--- + +## 5. Timeout 설계 + +### 5.1 왜 Timeout이 필요한가? + +PG 시뮬레이터는 요청 시 100~500ms 지연이 발생한다. 타임아웃이 없으면: +- PG가 느려질 때 스레드가 무한 대기 → 스레드 풀 고갈 → 전체 서비스 마비 +- 주문, 상품 조회 등 결제와 무관한 기능까지 영향 + +### 5.2 타임아웃 값 결정 + +| 선택지 | 값 | 장점 | 단점 | +|--------|-----|------|------| +| A. 500ms | PG 최대 지연과 동일 | 빠른 실패 | 정상 요청도 잘릴 수 있음 | +| **B. 1,000ms (1초)** | **PG 최대 지연의 2배** | **정상 요청 대부분 수용, 비정상은 빠르게 차단** | - | +| C. 3,000ms (3초) | 넉넉한 여유 | 안전함 | 장애 시 스레드 3초간 점유, 빠른 실패 효과 감소 | + +**결정: connectTimeout 500ms + readTimeout 1초** + +**근거**: +- **connectTimeout 500ms**: TCP 연결 수립만 담당. PG가 살아있으면 수십ms 내 연결 완료. 500ms 내 연결 안 되면 PG 서버 자체가 불능 +- **readTimeout 1초**: 연결 후 응답 대기. PG 정상 응답 100~500ms 기준, 2배 여유 +- connectTimeout < readTimeout 분리 → PG 서버 다운 시 500ms 만에 빠른 실패, Retry로 즉시 전환 +- 스레드 점유 시간을 최소화하여 다른 요청에 영향을 주지 않음 + +### 5.3 적용 위치 + +```java +// Feign Client 설정 +@Bean +public Request.Options feignOptions() { + return new Request.Options( + 500, TimeUnit.MILLISECONDS, // connectTimeout — TCP 연결 수립 + 1000, TimeUnit.MILLISECONDS, // readTimeout — 응답 대기 + true // followRedirects + ); +} +``` + +--- + +## 6. Retry 설계 + +### 6.1 왜 Retry가 필요한가? + +PG 시뮬레이터는 **40% 확률로 500 에러**를 반환한다. +이는 일시적 장애(transient fault)이며, 즉시 재시도하면 성공할 수 있다. + +- 재시도 없이 1회 시도: 성공률 60% +- 2회 시도: 성공률 60% + (40% × 60%) = **84%** +- 3회 시도: 성공률 84% + (16% × 60%) = **93.6%** + +### 6.2 재시도 정책 결정 + +| 항목 | 선택지 | 결정 | 근거 | +|------|--------|------|------| +| 최대 시도 횟수 | 2회 / **3회** / 5회 | **3회** | 93.6% 성공률 확보. 5회는 총 대기 시간 증가 대비 한계 효용 낮음 (98.4% → +4.8%) | +| 대기 전략 | 고정 / **지수 백오프** / 랜덤 | **지수 백오프** | 고정 간격은 PG 부하 회복 시간을 주지 않음. 지수 백오프로 간격을 점진적으로 늘려 PG 회복 여유 제공 | +| 초기 대기 시간 | 100ms / **500ms** / 1초 | **500ms** | PG 지연이 100~500ms이므로, 최소 500ms 후 재시도해야 PG 부하가 해소될 가능성 높음 | +| 재시도 대상 예외 | 모든 예외 / **특정 예외만** | **특정 예외만** | 400 에러(잘못된 요청)는 재시도해도 의미 없음. 500/타임아웃만 재시도 | + +### 6.3 재시도 대상 vs 비대상 + +| 예외 유형 | 재시도 여부 | 근거 | +|----------|-----------|------| +| PG 500 에러 (서버 불안정) | **O** | 일시적 장애, 재시도 시 성공 가능 | +| 타임아웃 (SocketTimeoutException) | **O** | 네트워크 일시 지연, 재시도 시 성공 가능 | +| 연결 실패 (ConnectException) | **O** | 일시적 네트워크 불안정 가능 | +| PG 400 에러 (잘못된 요청) | **X** | 요청 데이터 문제, 재시도해도 동일 실패 | +| PG 404 에러 | **X** | 리소스 문제, 재시도 무의미 | + +### 6.4 재시도와 멱등성 + +**핵심 문제**: 타임아웃으로 재시도했는데, 첫 번째 요청이 PG에서 이미 처리되었다면? + +PG 시뮬레이터의 유니크 제약: `(user_id, order_id, transaction_key)` — 같은 orderId로 여러 번 요청하면 **별도 결제건이 생성된다**. 즉 PG 자체는 멱등하지 않다. + +| 선택지 | 장점 | 단점 | +|--------|------|------| +| A. 재시도 시 동일 요청 그대로 전송 | 단순함 | **중복 결제 위험** — 같은 주문에 대해 PG 결제가 2건 발생 | +| **B. 내부에서 멱등성 보장** | **중복 결제 방지** | 약간의 구현 복잡도 | + +**결정: B. 내부 멱등성 보장** + +**방법**: +- 결제 요청 시 내부에 Payment 레코드를 먼저 생성 (status: REQUESTED) +- 재시도 전에 해당 주문의 Payment 상태 확인 +- 이미 PENDING/PAID 상태이면 재시도하지 않고 기존 결제건 상태 확인으로 전환 +- FAILED/UNKNOWN 상태이면 PG 상태 확인 API 호출 후 판단 + +### 6.5 Resilience4j 설정 + +```yaml +resilience4j: + retry: + instances: + pgRetry: + max-attempts: 3 + wait-duration: 500ms + exponential-backoff-multiplier: 2 # 500ms → 1s → 2s + retry-exceptions: + - feign.RetryableException + - java.net.SocketTimeoutException + - java.net.ConnectException + ignore-exceptions: + - feign.FeignException.BadRequest + - feign.FeignException.NotFound + fail-after-max-attempts: true +``` + +### 6.6 최악 시나리오 시간 계산 + +``` +1차 시도: 1초 (타임아웃) + 500ms (대기) = 1.5초 +2차 시도: 1초 (타임아웃) + 1초 (대기) = 2초 +3차 시도: 1초 (타임아웃) +총 최대: 4.5초 +``` + +사용자 입장에서 4.5초는 결제 UX로 허용 가능한 범위이다. (일반적으로 결제는 5~10초 대기 용인) + +--- + +## 7. Circuit Breaker 설계 + +### 7.1 왜 Circuit Breaker가 필요한가? + +Retry만으로는 PG **전면 장애** 상황에 대응할 수 없다. + +- PG가 완전히 다운되면 모든 요청이 3회씩 재시도 → 실패 +- 초당 100건 결제 요청 시: 100 × 3 = 300건이 PG에 몰림 +- 불필요한 재시도로 PG 회복을 더 늦추고, 내부 스레드도 고갈 + +Circuit Breaker는 **"더 이상 보내지 말자"**를 결정하는 장치이다. + +### 7.2 설정값 결정 + +| 항목 | 선택지 | 결정 | 근거 | +|------|--------|------|------| +| 슬라이딩 윈도우 크기 | 5 / **10** / 20 | **10** | 너무 작으면 일시적 실패에 과민 반응, 너무 크면 장애 감지 느림 | +| 실패율 임계치 | 30% / **50%** / 70% | **50%** | PG 정상 실패율(40%)보다 높게 설정. 정상 운영 중 회로가 열리지 않도록 | +| Open 상태 유지 시간 | 5s / **10s** / 30s | **10s** | PG 복구에 충분한 시간 부여. 너무 길면 복구 후에도 차단 지속 | +| Half-Open 허용 호출 수 | 1 / **2** / 5 | **2** | 1건은 네트워크 불안정에 의한 오판 위험. 2건이면 신뢰도 확보 | +| 느린 호출 기준 | 1s / **2s** / 3s | **2s** | PG 정상 응답 500ms 기준, 4배 이상 느리면 비정상 | +| 느린 호출 비율 임계치 | 30% / **50%** / 70% | **50%** | 절반 이상 느리면 PG 과부하 | + +### 7.3 실패율 임계치를 50%로 설정한 이유 (상세) + +PG 시뮬레이터의 정상 요청 실패율은 40%이다. + +``` +임계치 40% → 정상 운영 중에도 회로가 열릴 수 있음 (위험) +임계치 50% → 정상 운영에서는 열리지 않고, 추가 장애 시에만 Open +임계치 70% → 장애 감지가 너무 느림 +``` + +50%는 PG의 기본 실패율(40%)에 10%p 여유를 둔 값이다. +10건 중 5건 이상 실패하면 PG에 추가적인 문제가 있다고 판단할 수 있다. + +> **참고**: 현재 임계치는 시뮬레이터 실패율(40%) 기준으로 산출. 실무에서 PG 연동 시 운영 환경 실측 실패율(baseline)을 기반으로 재조정이 필요하다. + +### 7.4 CB 세분화: PG × API 유형별 분리 + +**원칙**: 결제 요청 CB가 Open되어도 상태 조회 CB는 Closed → 복구 로직 계속 동작. + +``` +[치명적 시나리오 — CB를 분리하지 않으면] +PG 결제 요청 대량 실패 → CB Open → 상태 조회도 차단 +→ Outbox, 배치, Polling 전부 PG 조회 불가 → 복구 경로 전체 마비 +``` + +#### CB 인스턴스 목록 (3개 — 쓰기만) + +> 읽기 CB 제거 근거: 06 §18 참조. +> 상태 확인은 "복구 행위"이므로 CB가 차단하면 복구가 멈춘다. +> 읽기는 Timeout + Rate Limiter + 드라이버 내장 폴백으로 충분하다. + +| CB 인스턴스 | 대상 | 성격 | +|------------|------|------| +| `pgSimulator-request` | Simulator 결제 요청 (POST) | 쓰기 | +| `pgToss-request` | Toss 결제 승인 (POST) | 쓰기 | +| `redis-write` | Redis 가주문 생성 + 재고 DECR (Master) | 쓰기 | + +#### CB별 설정 + +```yaml +resilience4j: + circuitbreaker: + instances: + # --- PG 결제 요청 (쓰기만 CB 적용) --- + pgSimulator-request: + sliding-window-type: COUNT_BASED + sliding-window-size: 10 + failure-rate-threshold: 50 + wait-duration-in-open-state: 10s + permitted-number-of-calls-in-half-open-state: 2 + slow-call-duration-threshold: 2s + slow-call-rate-threshold: 50 + record-exceptions: + - feign.RetryableException + - java.net.SocketTimeoutException + - java.net.ConnectException + ignore-exceptions: + - feign.FeignException.BadRequest + - feign.FeignException.NotFound + + pgToss-request: + sliding-window-size: 10 + failure-rate-threshold: 50 + wait-duration-in-open-state: 15s # Toss는 안정적, 복구 여유 더 줌 + permitted-number-of-calls-in-half-open-state: 2 + slow-call-duration-threshold: 3s + + # --- Redis 쓰기 (Master — 가주문 생성, 재고 DECR) --- + redis-write: + sliding-window-size: 10 + failure-rate-threshold: 50 + wait-duration-in-open-state: 5s # Redis는 복구가 빠르므로 짧게 + permitted-number-of-calls-in-half-open-state: 3 + record-exceptions: + - org.springframework.data.redis.RedisConnectionFailureException + - io.lettuce.core.RedisCommandTimeoutException + - org.springframework.data.redis.RedisSystemException + + # --- 읽기 CB 없음 (06 §18 근거) --- + # PG 상태 조회: 복구 행위이므로 CB 차단 시 복구 지연 → Timeout만으로 보호 + # Redis 읽기: Lettuce ReadFrom.REPLICA_PREFERRED 내장 폴백 → commandTimeout + try-catch +``` + +#### Rate Limiter + +##### 결제 요청: Sliding Window Counter (직접 구현) + +```java +/** + * PG 결제 요청 Rate Limiter — Sliding Window Counter 방식 + * + * Fixed Window 경계 돌파(Boundary Burst) 문제 해결: + * - Fixed Window: 윈도우 경계에서 최대 2배(100건) burst 가능 + * - Sliding Window Counter: 어떤 1초 구간에서도 정확히 50건 이하 보장 + * + * 이전 윈도우의 잔여 비중 × 이전 카운트 + 현재 카운트 ≤ limit + */ +@Component +public class SlidingWindowRateLimiter { + + private final int limit; // 50 (초당 최대) + private final long windowSizeMs; // 1000ms + + private final AtomicLong prevWindowStart = new AtomicLong(0); + private final AtomicInteger prevWindowCount = new AtomicInteger(0); + private final AtomicLong currWindowStart = new AtomicLong(0); + private final AtomicInteger currWindowCount = new AtomicInteger(0); + + public synchronized boolean tryAcquire() { + long now = System.currentTimeMillis(); + long currentWindow = now / windowSizeMs * windowSizeMs; + + if (currentWindow != currWindowStart.get()) { + prevWindowCount.set(currWindowCount.get()); + prevWindowStart.set(currWindowStart.get()); + currWindowCount.set(0); + currWindowStart.set(currentWindow); + } + + double prevWeight = Math.max(0, 1.0 - (double)(now - currentWindow) / windowSizeMs); + double weightedCount = prevWeight * prevWindowCount.get() + currWindowCount.get(); + + if (weightedCount < limit) { + currWindowCount.incrementAndGet(); + return true; + } + return false; // → 429 Too Many Requests 응답 + } +} +``` + +- **적용 이유**: 결제 요청은 동시 트래픽이 발생하는 지점. PG 계약 TPS 50을 정확히 지켜야 함 +- **Fixed Window 대비**: 경계 돌파 없이 어떤 1초 구간에서도 50건 이하 보장 +- **"limit을 보수적으로 설정"하면?**: limit=25로 하면 정상 시 처리량 50% 낭비 → 비즈니스 손실 + +##### 배치 조회: Fixed Window (Resilience4j) + +```yaml +resilience4j: + ratelimiter: + instances: + pgStatusBatch: + limit-for-period: 10 # 주기당 최대 10건 + limit-refresh-period: 1s # 1초 주기 + timeout-duration: 0 # 초과 시 즉시 실패 (대기 안 함) +``` + +- **Fixed Window 유지 이유**: 배치 스케줄러가 1건씩 순차 호출 → 동시성 없음 → 경계 돌파 구조적 불가능 + +#### 장애 격리 검증 + +| 장애 시나리오 | 방어 수단 | 차단되는 기능 | 정상 동작하는 기능 | +|-------------|----------|------------|----------------| +| Simulator 결제 처리 장애 | CB `pgSimulator-request` Open | Simulator 결제 | **Toss 결제, 모든 상태 조회, 모든 복구** | +| 플래시 세일 동시 결제 폭증 | SlidingWindowRateLimiter (50 req/sec) | 초과 요청 429 응답 | **PG 과부하 방지, CB Open 예방** | +| 배치가 Simulator 과부하 | Rate Limiter `pgStatusBatch` | - | **전부 정상** | +| 모든 PG 결제 장애 | CB `*-request` 전부 Open | 모든 결제 | **모든 상태 조회 → 복구 동작** | +| Redis Master 장애 | CB `redis-write` Open | 가주문 쓰기 | **DB 직접 주문 Fallback, Replica 읽기 정상** | +| Redis 전체 장애 | CB `redis-write` Open + 읽기 try-catch | 가주문 쓰기 | **DB 직접 주문 + DB 조회 Fallback (Timeout 500ms)** | +| Redis-DB 재고 불일치 | 정합성 배치 — Lua Script v2 (30초 주기) | - | **DB 기준 Redis 원자적 보정** | + +### 7.5 SlidingWindowRateLimiter → Retry → Circuit Breaker 실행 순서 + +``` +결제 요청: + [SlidingWindowRateLimiter] ← 최근 1초간 50건 초과? → 429 응답 + │ (통과) + ▼ + [@Retry: pgSimulatorRetry] ← 실패 시 1회 재시도 (500ms 대기) + │ + ▼ + [@CircuitBreaker: pgSimulator-request] ← 실패율 50% 초과? → Fallback (다른 PG) + │ + ▼ + [Feign Client] → PG 호출 + +상태 조회 (실시간) — CB 없음: + [Feign Client] → PG 조회 (Timeout 1초, try-catch → UNKNOWN 반환) + (복구 행위이므로 CB로 차단하지 않음 → 06 §18 근거) + +상태 조회 (배치) — CB 없음, Rate Limiter만: + [@RateLimiter: pgStatusBatch] → PG 조회 (Timeout 1초) +``` + +``` +실행 순서 근거: +- Sliding Window Rate Limiter가 가장 바깥: + PG 계약 TPS를 정확히 지킴 (경계 돌파 없음) +- Rate Limiter 거부는 CB에 기록하지 않음: + 트래픽 초과 ≠ PG 장애. CB에 기록하면 트래픽만 많아도 CB Open → 오작동 +- Retry가 CB 바깥: 재시도 실패도 CB에 기록되어야 정확한 실패율 측정 +- CB가 가장 안쪽: 최종 차단 판단 + Fallback 트리거 +``` + +```java +// 결제 요청 — Sliding Window Rate Limiter (Interceptor) + Retry + request CB +// SlidingWindowRateLimiter는 PaymentRateLimiterInterceptor에서 적용 +@Retry(name = "pgSimulatorRetry") +@CircuitBreaker(name = "pgSimulator-request", fallbackMethod = "requestFallback") +public PgPaymentResponse requestPayment(PgPaymentRequest request) { ... } + +// 상태 조회 (실시간) — CB 없음, Timeout + try-catch만으로 보호 +public PgPaymentStatusResponse getPaymentStatus(String transactionKey) { + try { + return pgClient.getPaymentStatus(transactionKey); // Feign timeout 1초 + } catch (Exception e) { + log.warn("PG 상태 확인 실패: transactionKey={}", transactionKey, e); + return PgPaymentStatusResponse.unknown(transactionKey); + } +} + +// 상태 조회 (배치) — Rate Limiter만, CB 없음 +@RateLimiter(name = "pgStatusBatch") +public PgPaymentStatusResponse getPaymentStatusForBatch(String transactionKey) { + try { + return pgClient.getPaymentStatus(transactionKey); + } catch (Exception e) { + log.warn("PG 상태 확인 실패 (배치): transactionKey={}", transactionKey, e); + return PgPaymentStatusResponse.unknown(transactionKey); + } +} +``` + +### 7.6 Half-Open 전략 + +#### 문제: 고정 대기 + 고객 트래픽으로 테스트 + +``` +CB Open → 10초 고정 대기 → Half-Open → 실제 결제 요청 2건으로 테스트 + ↑ 고객이 실험 대상 (돈이 걸림) +``` + +- PG 2초에 복구 → 8초 낭비 (쿠팡 기준 수천 건 결제 손실) +- PG 30초 복구 → 10초마다 실패 반복 → 복구 방해 +- 테스트 2건 성공 → 즉시 Closed → 전량 트래픽 폭주 (Thundering Herd) + +#### 개선 1: Progressive Backoff (Open 반복 시 대기 시간 증가) + +``` +1차 Open: 5초 → Half-Open + 실패 → 2차 Open: 10초 → Half-Open + 실패 → 3차 Open: 20초 → Half-Open + 실패 → 4차 Open: 40초 → Half-Open + 실패 → 5차+ Open: 60초 cap + 성공 → Closed (카운트 리셋) +``` + +- 단순 일시 장애: 5초 만에 복구 → 최소 다운타임 +- PG 재시작(30초~1분): 10~20초 시점에 복구 감지 +- 전면 장애(수 분): 60초 간격 체크 → PG 부하 최소화 + +#### 개선 2: Health Check Probe (결제 요청 CB 전용) + +**결제 요청은 돈이 걸린 작업. 실제 고객 요청을 테스트로 쓰면 안 된다.** + +``` +CB Open 중: + Health Probe 스케줄러 → GET /payments?orderId=HEALTH_CHECK + → 200 or 404 (서버 응답) → CB.transitionToHalfOpenState() + → 500 or 타임아웃 (장애) → 대기 계속 +``` + +```java +@Component +public class PgHealthChecker { + public boolean isSimulatorHealthy() { + try { + simulatorClient.getPaymentByOrderId("HEALTH_CHECK"); + return true; // 200 — 서버 정상 + } catch (FeignException.NotFound e) { + return true; // 404 — 서버 살아있음, 데이터만 없음 + } catch (Exception e) { + return false; // 타임아웃/500 — 장애 + } + } +} +``` + +> 200이든 404든 "응답이 왔다" = PG가 살아있다는 증거. + +#### CB 유형별 Half-Open 전략 + +> 읽기 CB 제거(06 §18)로 Half-Open 전략은 **쓰기 CB + redis-write**에만 적용. + +| CB 유형 | 전환 방식 | 테스트 방법 | 근거 | +|---------|----------|-----------|------| +| `*-request` | **Health Probe** → 수동 전환 | Probe 성공 → Half-Open → 실제 2건 | 결제는 돈, 고객을 실험 대상으로 쓰지 않음 | +| `redis-write` | **Progressive Backoff** | Redis PING 확인 → Half-Open → 실제 3건 | Redis 복구가 빠르므로 짧은 backoff | + +#### 결제 요청 CB Half-Open 전체 흐름 + +``` +pgSimulator-request CB Open + │ + ├── [Health Probe: 5초 후] GET /payments?orderId=HEALTH_CHECK + │ ├── OK → CB.transitionToHalfOpenState() + │ │ → 실제 결제 2건 허용 → 성공 → Closed (카운트 리셋) + │ │ → 실패 → Open (카운트 +1, 다음 10초) + │ └── 실패 → 대기 + │ + ├── [Health Probe: 10초 후] 재시도 (카운트 1) + ├── [Health Probe: 20초 후] 재시도 (카운트 2) + └── [Health Probe: 60초 cap] 재시도 (카운트 4+) +``` + +--- + +## 8. Fallback 설계 + +### 8.1 Fallback 설계 원칙 + +Fallback은 "에러를 잡아서 안전한 응답을 주는 것"이 아니라, +**장애가 발생해도 비즈니스가 계속 동작하는 대체 경로를 확보**하는 것이다. + +### 8.2 7계층 Fallback 체계 + +``` +[1차 방어] Timeout → 개별 요청 시간 제한 +[2차 방어] Retry → 일시적 실패 재시도 (멱등성 보장) +[3차 방어] Circuit Breaker → 반복 실패 시 호출 차단 +[4차 방어] Multi-PG Fallback → 대체 PG로 자동 전환 +[5차 방어] Polling Hybrid → 콜백 실패 시 능동적 확인 +[6차 방어] Callback DLQ → 콜백 데이터 보존 + 재처리 +[7차 방어] Local WAL → DB 장애 시 PG 응답 보존 +[최종 방어] UNKNOWN + Outbox + 배치 → 모든 방어가 뚫려도 최종 복구 +``` + +### 8.3 FB-PG: Multi-PG Fallback (PG 인프라 장애) + +#### 아키텍처 + +``` +[결제 요청] + │ + ▼ +[PG Router (Strategy)] + ├── 1순위: PG Simulator (Primary) + │ └── CB → Retry → 성공 시 리턴 + │ + ├── Primary 실패 (CB Open 또는 Retry 소진) + │ ▼ + ├── 2순위: Toss Payments Sandbox (Fallback) + │ └── CB → Retry → 성공 시 리턴 + │ + └── 모든 PG 실패 + ▼ + [최종 Fallback: UNKNOWN 저장 + "확인 중" 응답] +``` + +#### PG 추상화 (Strategy Pattern) + +```java +public interface PgClient { + PgPaymentResponse requestPayment(PgPaymentRequest request); + PgPaymentStatusResponse getPaymentStatus(String transactionKey); + PgPaymentStatusResponse getPaymentByOrderId(String orderId); + String getProviderName(); // "SIMULATOR" / "TOSS" +} +``` + +```java +@Component +public class PgRouter { + private final List pgClients; // 우선순위 순 + + public PgPaymentResponse requestPayment(PgPaymentRequest request) { + for (PgClient client : pgClients) { + try { + return client.requestPayment(request); + } catch (Exception e) { + log.warn("PG [{}] 실패, 다음 PG 시도", client.getProviderName(), e); + } + } + throw new AllPgFailedException(); + } +} +``` + +#### Fallback PG 전환 판단 기준 + +| 실패 유형 | PG 도달 가능성 | Fallback 전환 | 이유 | +|----------|-------------|-------------|------| +| ConnectException | 없음 | **전환** | PG에 요청 자체가 안 감 | +| 500 에러 | 낮음 | **전환** | PG가 요청을 처리하지 못함 | +| SocketTimeoutException | **있음** | **전환하지 않음** | PG에서 처리 중일 수 있음 → 중복 결제 위험 | +| CB Open | - | **전환** | PG 전면 장애 판단 | +| 400 에러 | - | **전환하지 않음** | 요청 자체가 잘못됨 | + +#### PG별 차이 추상화 + +| 항목 | PG Simulator | Toss Sandbox | +|------|-------------|-------------| +| 결제 방식 | 비동기 (콜백) | 동기 (즉시) | +| 멱등성 | 미지원 (수동 보장) | Idempotency-Key 지원 | +| 응답 | PENDING → 콜백 | SUCCESS/FAILED 즉시 | + +``` +PgClient.requestPayment() 반환값에 따라 PaymentFacade 분기: + - PENDING → Payment(PENDING) + 콜백 대기 + - SUCCESS → Payment(PAID) + 주문 확정 (즉시) + - FAILED → Payment(FAILED) + 재고 복원 (즉시) +``` + +#### PG별 독립 CB/Retry 설정 + +> CB 세분화 상세는 Section 7.4 참조. +> PG별 request CB 2개 + Redis write CB 1개 = **총 3개** (읽기 CB 제거, 06 §18 근거). + +```yaml +resilience4j: + retry: + instances: + pgSimulatorRetry: + max-attempts: 3 + wait-duration: 500ms + exponential-backoff-multiplier: 2 + pgTossRetry: + max-attempts: 2 # Toss는 안정적이므로 적게 + wait-duration: 500ms +``` + +### 8.4 FB-POLL: Polling Hybrid (콜백 채널 장애) + +콜백만 기다리면 유실 시 최대 1분(배치 주기)간 상태 미확정. +**능동적 폴링을 추가하여 10초 내 복구.** + +``` +PG 응답(PENDING) 수신 시: + → [정상 경로] 콜백 대기 + → [대체 경로] Delayed Task 등록 (T+10초 후 실행) + +10초 내 콜백 수신 → Task 취소 +10초 후 콜백 미수신 → Task 실행: + 1. GET /api/v1/payments/{transactionKey} + 2. PG 상태에 따라 내부 상태 전이 + 3. 아직 PENDING이면 → 20초 후 재확인 Task 등록 +``` + +```java +// PG 응답(PENDING) 수신 직후 +taskScheduler.schedule( + () -> paymentRecoveryService.checkAndRecover(paymentId), + Instant.now().plusSeconds(10) +); +``` + +### 8.5 FB-DLQ: Callback Inbox (콜백 처리 장애) + +PG 콜백 수신 후 내부 처리 중 예외 → 콜백 데이터 유실 방지. +**PG에게 항상 200 OK를 먼저 반환하고, 원본을 보존한 채 비동기 처리.** + +``` +콜백 수신 → [1단계] callback_inbox 테이블에 원본 저장 (RECEIVED) + → [2단계] 비즈니스 처리 + → [성공] PROCESSED + → [실패] RECEIVED 상태 유지 → DLQ 스케줄러가 재처리 +``` + +```sql +CREATE TABLE callback_inbox ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + transaction_key VARCHAR(50) NOT NULL, + order_id VARCHAR(50) NOT NULL, + payload TEXT NOT NULL, + status VARCHAR(20) NOT NULL, -- 'RECEIVED' / 'PROCESSED' / 'FAILED' + received_at DATETIME NOT NULL, + processed_at DATETIME, + retry_count INT DEFAULT 0, + error_message VARCHAR(500) +); +``` + +### 8.6 FB-WAL: Local Write-Ahead Log (내부 DB 장애) + +PG 결제 성공 → 내부 DB 저장 실패 → Payment 레코드 자체가 없으면 배치도 못 잡음. +**PG 응답을 DB와 독립적인 저장소에 먼저 기록.** + +``` +PG 응답 수신 즉시: + 1. [WAL] 로컬 파일에 {orderId, transactionKey, pgResponse} 기록 + 2. [DB] Payment 상태 업데이트 시도 + - 성공 → WAL 레코드 삭제 + - 실패 → WAL에 남아있음 + +[WAL Recovery 스케줄러] + WAL에 남아있는 레코드 → DB에 반영 재시도 → 성공 시 WAL 삭제 +``` + +### 8.7 최종 Fallback: UNKNOWN 상태 + +모든 방어 계층이 실패한 경우: + +```java +public PaymentResponse paymentFallback(PaymentRequest request, Throwable t) { + // 1. Payment 상태를 UNKNOWN으로 전이 + // 2. Outbox + 배치 복구 대상으로 등록 + // 3. 사용자 응답: "결제 확인 중입니다. 잠시 후 확인해주세요" + // 4. 로그 남김 (운영 알림) +} +``` + +### 8.8 FB-ASYNC: 비동기 결제 고유 Fallback + +비동기 결제는 "요청 성공 이후"에도 불확실 구간이 존재한다. +이 구간의 장애에 대한 Fallback을 별도로 설계한다. + +#### A2. PG PENDING 최대 허용 시간 + +PG 비동기 처리 중 PG 크래시 → 영원히 PENDING → 고객 결제 영원히 미확정. + +**정책: PENDING 5분 초과 시 FAILED 처리 + 재고 복원** + +``` +PG 처리 최대 5초 × 안전 마진 = 5분 +→ 5분 넘게 PENDING이면 PG 측 장애로 판단 +→ FAILED 처리 → 고객 재결제 가능 +→ PG에서 뒤늦게 SUCCESS 콜백 → 조건부 UPDATE가 무시 (이미 FAILED) +→ 불일치 해소: PG 대사(reconciliation) 운영 프로세스 +``` + +#### A3. PENDING 상태 콜백 무시 + +콜백 status가 PENDING이면 상태 전이하지 않고 무시. **SUCCESS/FAILED만 처리.** + +#### A4. 콜백 채널 불안정 → 동기 PG 우선 전환 + +비동기 결제의 **가장 근본적인 Fallback**: 불확실 구간 자체를 제거. + +``` +[콜백 신뢰율 모니터링] +최근 N건 "PENDING 응답 → 10초 내 콜백 수신" 비율 추적 +→ 50% 미만: 콜백 채널 불안정 판단 +→ PgRouter가 Toss(동기) 우선 라우팅 +→ 동기 PG = 요청 즉시 결과 확정 = 불확실 구간 없음 +``` + +### 8.9 구현 범위 + +| # | Fallback 전략 | 현재 과제 | MSA/프로덕션 | +|---|-------------|----------|-------------| +| FB-PG | Multi-PG Routing | **구현** | N개 PG 확장 | +| FB-POLL | Polling Hybrid | **구현** | Delayed Queue (Kafka) | +| FB-DLQ | Callback Inbox | **구현** (DB 테이블) | Kafka DLQ | +| FB-WAL | Local WAL | **구현** (로컬 파일) | Redis/Kafka WAL | +| FB-COMP | 보상 트랜잭션 큐 | 불필요 (모노리스) | Saga Pattern | +| FB-CARD | 카드사별 모니터링 | 설계만 | CB per 카드사 | + +--- + +## 9. 콜백 수신 및 상태 동기화 + +### 9.1 콜백 수신 API + +``` +POST /api/v1/payments/callback +Body: { + "transactionKey": "20250816:TR:9577c5", + "orderId": "1351039135", + "status": "SUCCESS", + "reason": "정상 승인되었습니다.", + ... +} +``` + +### 9.2 콜백 수신 시 처리 흐름 + +``` +콜백 수신 + ├── [1단계] callback_inbox에 원본 저장 → PG에게 즉시 200 OK 반환 + │ + ├── [2단계] transactionKey로 내부 Payment 조회 + │ └── 없으면? → 로그 남기고 무시 + │ + ├── [3단계] 콜백 status 확인 + │ └── PENDING → 무시 (최종 결과가 아님, SUCCESS/FAILED만 처리) + │ + ├── [4단계] 조건부 UPDATE로 상태 전이 + │ └── UPDATE payment SET status = ? WHERE id = ? AND status IN ('PENDING', 'UNKNOWN') + │ └── affected rows = 0 → 이미 다른 경로(배치/폴링)에서 처리됨, 무시 + │ + ├── [5단계] 콜백 status에 따라 + │ ├── SUCCESS → PAID + 주문 상태 업데이트 + │ └── FAILED → FAILED + 주문/재고 롤백 + │ + └── [5단계] callback_inbox status → PROCESSED +``` + +### 9.3 콜백 멱등성 + 동시성 보호 + +**조건부 UPDATE**로 멱등성과 동시성을 동시에 보장한다. + +```sql +UPDATE payment SET status = 'PAID' +WHERE id = ? AND status IN ('PENDING', 'UNKNOWN') +-- affected rows = 0이면 이미 확정됨 → 추가 처리 없이 종료 +``` + +- 콜백과 배치/폴링이 동시에 같은 Payment를 처리해도, 먼저 UPDATE 성공한 쪽이 확정 +- 나중에 UPDATE한 쪽은 affected rows = 0 → 충돌 없이 종료 +- 락 없이 원자적 전이, 성능 영향 최소화 + +--- + +## 10. 상태 복구 (Recovery) + +### 10.1 왜 복구 로직이 필요한가? + +PG 시뮬레이터의 콜백은 **재시도하지 않는다.** 콜백 전송 실패 시 로그만 남긴다. +즉 다음 상황이 발생할 수 있다: +- 콜백 시점에 우리 서버가 다운 → 콜백 유실 +- 네트워크 문제로 콜백 미도달 +- 타임아웃으로 UNKNOWN 처리된 건이 PG에서는 SUCCESS + +이런 결제건은 영원히 PENDING/UNKNOWN 상태에 머무르게 된다. + +### 10.2 복구 전략 결정 + +| 선택지 | 동작 | 장점 | 단점 | +|--------|------|------|------| +| A. 수동 복구만 | 관리자가 직접 확인 | 단순 | 운영 부담, 누락 위험 | +| **B. 수동 API + 배치 폴링** | **확인 API 제공 + 주기적 배치로 미확인 건 조회** | **자동 복구 + 수동 보조** | 배치 모듈에 로직 추가 필요 | +| C. 이벤트 기반 | 상태 변경 이벤트 발행 | 느슨한 결합 | 현재 불필요한 복잡성 | + +**결정: B. 수동 API + 배치 폴링** + +### 10.3 수동 복구 API + +``` +POST /api/v1/payments/{paymentId}/confirm +``` + +- PENDING/UNKNOWN 상태인 결제건에 대해 PG 상태 확인 API 호출 +- PG 응답에 따라 내부 상태 업데이트 + +### 10.4 배치 복구 (commerce-batch) + +``` +주기: 1분마다 +대상: + - status = REQUESTED 이면서 생성 후 1분 경과 (TX-1 후 PG 호출 전 크래시) + - status = PENDING 이면서 생성 후 1분 경과 (콜백 미수신) + - status = UNKNOWN (결과 불명) + +동작: + 1. 대상 Payment 목록 조회 + 2. 각 건에 대해: + a. REQUESTED → PG 상태 확인 (GET /payments?orderId=xxx) + - PG에 기록 있음 → transactionKey 저장 + 상태 동기화 + - PG 404 → FAILED 처리 (PG에 도달하지 못한 요청) + b. PENDING/UNKNOWN → PG 상태 확인 (GET /payments/{transactionKey}) + - PG SUCCESS → 조건부 UPDATE로 PAID + - PG FAILED → 조건부 UPDATE로 FAILED + - PG PENDING + 생성 후 5분 미만 → 유지 (아직 처리 중) + - PG PENDING + 생성 후 5분 초과 → FAILED 처리 + 재고 복원 (PG 측 장애 판단) + 3. 조건부 UPDATE 사용 (콜백/폴링과의 동시성 보호) +``` + +> **REQUESTED 포함 근거**: TX-1(Payment 저장) 커밋 후 PG 호출 전에 서버 크래시 시, +> Payment는 REQUESTED 상태로 영구 방치된다. Outbox 폴러가 1차 방어, 배치가 최종 안전망. + +### 10.5 PENDING 타임아웃 기준 + +PG 비동기 처리는 최대 5초 소요. 안전 마진을 두고 **생성 후 1분 경과한 PENDING은 복구 대상**으로 판단한다. + +**근거**: +- PG 처리 최대 5초 + 콜백 전송 시간 고려해도 30초면 충분 +- 1분은 충분한 여유이며, 정상 흐름에서 배치가 불필요하게 개입하지 않음 + +### 10.6 대사 배치 (Reconciliation) — 교차 시스템 정합성 검증 + +> **복구 배치**는 "우리 시스템 내부의 비정상 상태를 고치는 것"이고, +> **대사 배치**는 "두 시스템의 기록을 대조하여 불일치를 감지하는 것"이다. (06 §22 근거) + +``` +복구와 대사의 관계: + 복구가 완벽하면 대사에서 불일치가 0건이어야 한다. + → 대사는 "복구가 잘 동작하는지 검증하는 최종 안전망" + → 대사에서 불일치가 발견되면 = 복구 로직에 버그가 있다는 신호 +``` + +#### [R1] PG ↔ Payment 대사 배치 + +``` +주기: 1시간 +대상: 최근 24시간 내 Payment 중 status = PAID 또는 FAILED + +동작: + 1. Payment에서 대상 조회 (reconciled = false) + 2. 각 건에 대해 PG 상태 확인 (GET /payments/{transactionKey}) + 3. 대조: + | 우리 상태 | PG 상태 | 판정 | + |----------|---------|------| + | PAID | SUCCESS | ✅ 일치 → reconciled = true | + | PAID | FAILED | 🔴 불일치 → 알림 + 수동 확인 | + | PAID | PENDING | 🟡 PG 미확정 → 다음 주기에 재확인 | + | PAID | 404 | 🔴 PG에 기록 없음 → 알림 | + | FAILED | SUCCESS | 🔴 환불 누락 → 알림 + 조건부 자동 보상 | + | FAILED | FAILED | ✅ 일치 → reconciled = true | + 4. 불일치 → reconciliation_mismatch 테이블에 기록 + 운영 알림 + +PG 부하: + 현재 과제 규모 하루 ~1000건 가정 + Rate Limiter 10 req/sec → 1000건 / 10 = 100초 → 1시간 대비 부하율 3.3% +``` + +#### [R2] Payment ↔ Order 대사 배치 + +``` +주기: 1시간 +대상: 같은 DB (모놀리스) → JOIN 쿼리 1건 + +SELECT p.id, p.status as payment_status, o.status as order_status +FROM payment p +JOIN orders o ON p.order_id = o.id +WHERE (p.status = 'PAID' AND o.status != 'PAID') + OR (p.status = 'FAILED' AND o.status NOT IN ('CANCELLED', 'CREATED')) + +→ 결과 0건 = 정상, 1건 이상 = 운영 알림 +모놀리스 이점: JOIN 1건으로 끝. MSA면 양쪽 API 호출 + 매칭 로직 필요. +``` + +#### [R3] Payment ↔ Coupon 대사 배치 + +``` +주기: 1시간 +대상: 같은 DB → JOIN 쿼리 1건 + +SELECT p.id, p.status, ci.id as coupon_issue_id, ci.status as coupon_status +FROM payment p +JOIN coupon_issue ci ON p.coupon_issue_id = ci.id +WHERE p.status IN ('FAILED', 'CANCELLED') + AND ci.status = 'USED' + +→ 결과 있으면: 쿠폰 복원 누락 → 자동 복원 (couponFacade.restoreCoupon) +→ 복원 후 로그 + 메트릭 기록 +``` + +#### 복구 배치 vs 대사 배치 전체 구조 + +``` +[실시간 복구 — 빠르게 고친다] + Outbox Poller (5초) → PG 호출 누락 재시도 + Callback DLQ → 콜백 처리 실패 재시도 + Polling Hybrid (10초) → 콜백 미수신 시 능동 확인 + +[주기적 복구 — 놓친 건을 잡는다] + Payment Recovery (1분) → REQUESTED/PENDING/UNKNOWN 복구 + Stock Reconcile (30초) → Redis-DB 재고 보정 (Lua Script) + Proactive Expiry Scanner (30초) → 가주문 TTL 만료 선제 정리 + +[대사 — 전수 검증한다] + PG ↔ Payment (1시간) → PAID/FAILED 건 PG 대조 [R1] + Payment ↔ Order (1시간) → 상태 불일치 감지 [R2] + Payment ↔ Coupon (1시간) → 쿠폰 복원 누락 감지 + 자동 복원 [R3] +``` + +--- + +## 11. 전체 흐름 통합 + +### 11.1 정상 흐름 + +``` +[사용자] POST /api/v1/payments + → [PaymentFacade] 주문 검증 + → [TX-1] Payment(REQUESTED) + PaymentOutbox(PENDING) 저장 + → [PgRouter] Simulator CB → Retry → PG 요청 + → PG 응답 200: {status: PENDING, transactionKey: "xxx"} + → [WAL] 로컬 기록 + → [TX-2] Payment(PENDING) + Outbox(PROCESSED) + Delayed Task 등록(10초) + → 사용자 응답: "결제 처리 중" + +... (1~5초 후) ... + +[PG] POST /api/v1/payments/callback + → [Callback Inbox] 원본 저장 → PG에게 200 OK + → [조건부 UPDATE] Payment → PAID, Order → PAID + → Delayed Task 취소 + → Callback Inbox → PROCESSED +``` + +### 11.2 Primary PG 실패 → Fallback PG 전환 + +``` +[PgRouter] Simulator 1차 → 500 → 2차 → 500 → 3차 → 500 +→ Simulator 실패, Toss Sandbox 시도 +[PgRouter] Toss 1차 → SUCCESS (동기 즉시 응답) +→ [TX-2] Payment(PAID) + Order(PAID) (콜백 대기 불필요) +→ 사용자 응답: "결제 완료" +``` + +### 11.3 모든 PG 실패 → 최종 Fallback + +``` +[PgRouter] Simulator 3회 실패 → Toss 2회 실패 +→ AllPgFailedException +→ [Fallback] Payment(UNKNOWN) + Outbox 유지 +→ 사용자 응답: "결제 확인 중, 잠시 후 확인해주세요" +→ [Outbox 폴러 5초] → [배치 1분] → 복구 +``` + +### 11.4 콜백 미수신 → Polling Hybrid 복구 + +``` +[PG 응답 PENDING] → Delayed Task 등록 (10초) +... 10초 경과, 콜백 안 옴 ... +[Delayed Task 실행] GET /payments/{transactionKey} +→ PG: SUCCESS → 조건부 UPDATE → PAID +``` + +### 11.5 유령 결제 복구 + +``` +[PgRouter] 1차 → 타임아웃 (PG에서는 결제 생성됨) +→ 타임아웃이므로 Fallback PG 전환하지 않음 (중복 결제 방지) +→ Payment(UNKNOWN) +→ [복구 경로 1] PG 콜백 수신 → PAID +→ [복구 경로 2] Delayed Task 10초 후 PG 조회 → PAID +→ [복구 경로 3] 배치 1분 후 PG 조회 → PAID +``` + +--- + +## 12. 트랜잭션 경계 설계 + +### 12.1 핵심 원칙 + +> **TX 분리 이유 = 외부 호출 격리 (도메인 분리 아님)** +> TX-0/TX-1/TX-2 분리는 MSA 준비가 아니다. PG 호출(외부 API)을 트랜잭션 밖으로 빼기 위함이다. +> PG 호출이 없었다면 TX-0 + TX-1 + TX-2 = 하나의 TX로 충분하다 (모놀리스). +> 분리 기준: "외부 시스템 호출이 TX 안에 있으면 커넥션 점유 → 고갈" +> (06 §21.3 근거) + +**외부 호출(PG)은 트랜잭션 밖에서 수행한다.** + +| 선택지 | 장점 | 단점 | +|--------|------|------| +| A. PG 호출을 트랜잭션 안에 포함 | 원자성 보장 (실패 시 자동 롤백) | **PG 지연(최대 4.5초) 동안 DB 커넥션 점유 → 커넥션 풀 고갈 위험** | +| **B. PG 호출을 트랜잭션 밖에서 수행** | **DB 커넥션 최소 점유, 외부 지연이 내부에 전파되지 않음** | 수동 상태 관리 필요 | + +**결정: B** + +**근거**: 대규모 트래픽 환경에서 외부 호출 지연이 DB 커넥션 풀을 고갈시키면 +결제뿐 아니라 상품 조회, 주문 조회 등 전체 서비스가 마비된다. + +### 12.2 트랜잭션 분리 + +> 가주문 + 쿠폰 선차감 추가로 TX-0 신설 (06 §20 근거) + +``` +[TX-0] 쿠폰 USED 처리 (CAS UPDATE 1건) → commit ← 쿠폰 선차감 (쿠폰 없으면 생략) +[Redis] DECR(stock) + HSET(가주문, couponIssueId) ← 재고 선차감 + 가주문 생성 +[TX-1] Payment(REQUESTED) + Outbox(PENDING) → commit ← 결제 기록 +[PG 호출] CB → Retry → PG 요청 ← 트랜잭션 없음 +[TX-2] Payment 상태 업데이트 (PENDING 또는 UNKNOWN) → commit + +... (콜백 수신 시) ... + +[TX-3] Payment 확정 (PAID/FAILED) + Order 상태 + 쿠폰/재고 확정/복원 → commit +``` + +#### 커넥션 점유 시간 검증 + +``` +TX-0: CAS UPDATE 1건 → ~5ms (쿠폰 없는 주문은 생략) +TX-1: INSERT 2건 → ~10ms +PG 호출: 100ms~4.5초 ← 트랜잭션 없음, DB 커넥션 0개 +TX-2: UPDATE 1건 → ~5ms +TX-3: UPDATE 2~3건 → ~10ms + +총 DB 커넥션 점유: ~30ms (PG 지연과 무관) + +초당 100건 기준: + 트랜잭션 분리: 100 × 30ms = 3 커넥션·초 (HikariCP 10개 → 30%) + PG가 TX 안이면: 100 × 4.5초 = 450 커넥션·초 (HikariCP 10개 → 4500% → 즉시 고갈) +``` + +--- + +## 13. Transactional Outbox + +### 13.1 목적 + +TX-1(Payment REQUESTED 저장) 커밋 후 PG 호출 전 서버 크래시 시, +PG 호출 의도를 명시적으로 보존하여 **수 초 내에 자동 재시도**. + +### 13.2 변경된 트랜잭션 흐름 + +``` +[TX-0] 쿠폰 USED 처리 → commit (쿠폰 없으면 생략) +[Redis] DECR(stock) + HSET(가주문) +[TX-1] Payment(REQUESTED) + PaymentOutbox(PENDING) 저장 → commit +[Outbox Poller: 5초 주기] + PaymentOutbox(PENDING) 조회 → PG 호출 → PaymentOutbox(PROCESSED) +[TX-2] Payment 상태 업데이트 (PENDING 또는 UNKNOWN) → commit +``` + +### 13.3 Outbox 테이블 + +```sql +CREATE TABLE payment_outbox ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + payment_id BIGINT NOT NULL, + order_id VARCHAR(50) NOT NULL, + event_type VARCHAR(30) NOT NULL, -- 'PAYMENT_REQUEST' + payload TEXT NOT NULL, -- PG 요청 Body (JSON) + status VARCHAR(20) NOT NULL, -- 'PENDING' / 'PROCESSED' / 'FAILED' + created_at DATETIME NOT NULL, + processed_at DATETIME, + retry_count INT DEFAULT 0 +); +``` + +### 13.4 Outbox 폴러 동작 + +``` +[스케줄러: 5초 주기] + 1. PaymentOutbox에서 status = 'PENDING' 조회 + 2. 각 건에 대해: + a. Payment 현재 상태 확인 → 이미 PAID/FAILED → Outbox PROCESSED (다른 경로로 해결됨) + b. PG 상태 확인 (GET /payments?orderId=xxx) → 멱등성 보장 + - PG에 기록 있음 → transactionKey로 추적, Outbox PROCESSED + - PG에 기록 없음 → PG 결제 요청 (POST) 실행 + c. retry_count 증가, 최대 3회 초과 시 Outbox FAILED + 운영 알림 +``` + +### 13.5 Outbox + 배치 병행 구조 + +``` +[1차 복구] Outbox 폴러 (5초 주기) — PG 호출 누락 즉시 재시도 +[2차 복구] 배치 (1분 주기) — Outbox 폴러 자체 장애 시 최종 안전망 +``` + +--- + +## 14. 주문-결제 상태 연동 + +### 13.1 주문 상태 전이 (기존 + 결제 추가) + +``` +기존: CREATED (주문 생성 = 완료) +변경: CREATED → PAYMENT_PENDING → PAID / CANCELLED +``` + +| 주문 상태 | 의미 | 전이 조건 | +|----------|------|----------| +| CREATED | 주문 생성, 재고 차감 완료 | 주문 생성 시 | +| PAYMENT_PENDING | 결제 진행 중 | 결제 요청 시 | +| PAID | 결제 완료 | 콜백 SUCCESS 수신 | +| CANCELLED | 주문 취소 | 결제 최종 실패 | + +### 13.2 결제 실패 시 주문/재고/쿠폰 처리 + +> **선차감 원칙**: 재고, 쿠폰 모두 결제 전 선차감. 결제 실패 시 복원. (06 §19 근거) + +| 시나리오 | 주문 상태 | 재고 | 쿠폰 | +|----------|----------|------|------| +| 결제 SUCCESS | PAID | 차감 유지 | 사용 유지 | +| 결제 FAILED (한도 초과, 잘못된 카드) | CANCELLED | **복원** | **복원** | +| 결제 UNKNOWN → 배치 확인 → SUCCESS | PAID | 차감 유지 | 사용 유지 | +| 결제 UNKNOWN → 배치 확인 → FAILED | CANCELLED | **복원** | **복원** | +| 결제 UNKNOWN → 배치 확인 → PG에 기록 없음 | CANCELLED | **복원** | **복원** | + +--- + +## 15. 구현 계획 (단계별) + +### Phase 1: 기반 구축 + +1. Payment 도메인 모델 (Entity, Repository, 상태 enum) +2. PG Client 인터페이스 + SimulatorPgClient (Feign) + Timeout 적용 +3. PgRouter (Strategy Pattern) + Fallback PG 전환 로직 +4. 결제 요청 API (`POST /api/v1/payments`) 기본 흐름 +5. 가주문 모델 (ProvisionalOrder, couponIssueId 포함) + Redis Repository (기존 modules/redis 활용) +6. 가주문 TTL Jitter 적용 (±5분, 25~35분 분포 → 동시 만료 방지) + +### Phase 2: Resilience 적용 (PG) + +7. Resilience4j 의존성 추가 +8. PG별 독립 Retry 설정 (수동 Retry 루프 + PG 상태 확인) +9. PG별 독립 CircuitBreaker 설정 (6개 인스턴스) +10. SlidingWindowRateLimiter 구현 (결제 요청: 50 req/sec) +11. PaymentRateLimiterInterceptor (AOP) + Micrometer 메트릭 등록 +12. 배치 Rate Limiter 설정 (Resilience4j Fixed Window: 10 req/sec) +13. 최종 Fallback (UNKNOWN 상태) 구현 +14. Health Check Probe + Progressive Backoff 구현 + +### Phase 3: Resilience 적용 (Redis) + +15. Redis CB 1개 (`redis-write`만) + Lettuce commandTimeout 설정 (읽기 CB 불필요 — 06 §18) +16. Redis Fallback: DB 직접 주문 (ProvisionalOrderService + fallbackMethod) +17. 재고 예약: masterRedisTemplate DECR + DB UPDATE 이중 관리 +18. Redis-DB 재고 정합성 배치 — Lua Script v2 (30초 주기, 원자적 보정) +19. 가주문 선제 만료 배치 — Proactive Expiry Scanner (30초 주기, TTL < 30초 감지) + +### Phase 4: 콜백 + 상태 동기화 + +20. Callback Inbox (DLQ) 테이블 + 콜백 수신 API +21. 조건부 UPDATE 기반 상태 전이 +22. 결제 실패 시 재고 복원 (Redis INCR + DB 복원) + 쿠폰 복원 (DB UPDATE) +23. Polling Hybrid (Delayed Task) 구현 + +### Phase 5: Outbox + 복구 + 대사 + +24. PaymentOutbox 테이블 + TX-1에 Outbox 저장 추가 +25. Outbox 폴러 스케줄러 (5초 주기) +26. 배치 복구 (REQUESTED/PENDING/UNKNOWN, 1분 주기) +27. 수동 복구 API (`POST /api/v1/payments/{paymentId}/confirm`) +28. Local WAL (PG 응답 로컬 기록 + Recovery) +29. 대사 배치 [R1] PG ↔ Payment (1시간) — reconciled 플래그 + reconciliation_mismatch 기록 +30. 대사 배치 [R2] Payment ↔ Order (1시간) — JOIN 쿼리 불일치 감지 +31. 대사 배치 [R3] Payment ↔ Coupon (1시간) — 쿠폰 복원 누락 감지 + 자동 복원 + +### Phase 6: Multi-PG (Toss Sandbox) + +32. TossSandboxPgClient 구현 +33. Toss 전용 CB/Retry 설정 +34. PgRouter에 Toss 등록 + Fallback 전환 로직 검증 + +### Phase 7: 테스트 + +35. 단위 테스트: 상태 전이, Fallback, 멱등성, 조건부 UPDATE +36. 통합 테스트: PG 연동 전체 흐름 (Simulator + Toss) +37. Redis 장애 테스트: redis-write CB Open → DB Fallback, 읽기 try-catch Fallback, 재고 정합성 Lua Script +38. 장애 시나리오 테스트: 타임아웃, CB Open, 콜백 미수신, Multi-PG 전환, 대사 배치 + +--- + +## 16. 패키지 구조 + +``` +/interfaces/api/payment/ + PaymentV1Controller.java # 결제 요청 API + PaymentCallbackController.java # 콜백 수신 (→ Callback Inbox) + PaymentV1Dto.java +/application/payment/ + PaymentFacade.java # 결제 유스케이스 조율 + PaymentRecoveryService.java # Polling Hybrid + 수동 복구 +/domain/payment/ + PaymentModel.java # Entity + PaymentStatus.java # 내부 결제 상태 enum + PaymentService.java + PaymentRepository.java + CallbackInbox.java # DLQ Entity + CallbackInboxRepository.java + PaymentOutbox.java # Outbox Entity + PaymentOutboxRepository.java +/infrastructure/payment/ + PaymentRepositoryImpl.java + PaymentJpaRepository.java + CallbackInboxJpaRepository.java + PaymentOutboxJpaRepository.java + PaymentWalWriter.java # Local WAL (파일 기반) +/infrastructure/pg/ + PgClient.java # PG 추상화 인터페이스 + PgRouter.java # Strategy 기반 PG 라우팅 + PgHealthChecker.java # Health Probe (CB Open 중 PG 상태 확인) + PgPaymentRequest.java + PgPaymentResponse.java + PgPaymentStatusResponse.java + PgCallbackPayload.java +/infrastructure/pg/simulator/ + SimulatorPgClient.java # PG Simulator Feign Client + SimulatorPgConfig.java # Simulator 전용 Timeout/CB/Retry +/infrastructure/pg/toss/ + TossSandboxPgClient.java # Toss Payments Sandbox Client + TossSandboxPgConfig.java # Toss 전용 Timeout/CB/Retry +/infrastructure/redis/ + ProvisionalOrderRedisRepository.java # 가주문 Redis 저장/조회/삭제 (masterRedisTemplate) + StockReservationRedisRepository.java # 재고 예약 Redis DECR/INCR (masterRedisTemplate) + # RedisConfig, RedisProperties → modules/redis에 이미 존재 (건드리지 않음) +/infrastructure/resilience/ + SlidingWindowRateLimiter.java # Sliding Window Counter (결제 요청) + PaymentRateLimiterInterceptor.java # AOP 기반 Rate Limiter 적용 +/infrastructure/scheduler/ + OutboxPollerScheduler.java # Outbox 폴러 (5초) + PaymentRecoveryScheduler.java # 배치 복구 (1분) + CallbackDlqScheduler.java # DLQ 재처리 + WalRecoveryScheduler.java # WAL Recovery + StockReconcileScheduler.java # Redis-DB 재고 정합성 — Lua Script v2 (30초) + ProvisionalOrderExpiryScheduler.java # 가주문 선제 만료 — Proactive Expiry Scanner (30초) + PgPaymentReconciliationScheduler.java # [R1] PG ↔ Payment 대사 (1시간) + PaymentOrderReconciliationScheduler.java # [R2] Payment ↔ Order 대사 (1시간) + PaymentCouponReconciliationScheduler.java # [R3] Payment ↔ Coupon 대사 (1시간) +/application/order/ + ProvisionalOrderService.java # 가주문 생성 + Redis CB + DB Fallback + TTL Jitter +``` + +--- + +## 17. 의존성 추가 + +```kotlin +// Resilience4j +implementation("io.github.resilience4j:resilience4j-spring-boot3") +implementation("org.springframework.boot:spring-boot-starter-aop") + +// Feign Client (PG Simulator) +implementation("org.springframework.cloud:spring-cloud-starter-openfeign") + +// Redis → modules/redis에 이미 포함 (추가 불필요) +// commerce-api가 implementation(project(":modules:redis")) 선언 완료 + +// Toss Payments Sandbox (REST 호출) +// Feign 또는 RestClient 사용 — 별도 SDK 불필요 +// 인증: Test Secret Key (Base64 Authorization 헤더) +``` + +### 17.1 대사 테이블 + +```sql +-- 대사 배치 불일치 기록 (06 §22.4 근거) +CREATE TABLE reconciliation_mismatch ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + type VARCHAR(30) NOT NULL, -- 'PG_PAYMENT', 'PAYMENT_ORDER', 'PAYMENT_COUPON' + payment_id BIGINT NOT NULL, + our_status VARCHAR(20) NOT NULL, + external_status VARCHAR(20), -- PG 상태 (R1) 또는 Order/Coupon 상태 (R2/R3) + detected_at DATETIME NOT NULL, + resolved_at DATETIME, + resolution VARCHAR(50), -- 'AUTO_FIXED', 'MANUAL_FIXED', 'FALSE_ALARM' + note TEXT +); +``` diff --git a/docs/design/06-resilience-review.md b/docs/design/06-resilience-review.md new file mode 100644 index 0000000000..28bad07b8c --- /dev/null +++ b/docs/design/06-resilience-review.md @@ -0,0 +1,3826 @@ +# Resilience 설계 리뷰 — 시니어 아키텍트 관점 + +--- + +## 0. MSA 이커머스 점검 프레임워크 + +> 대규모 트래픽이 발생하는 MSA 이커머스 시스템을 가정하고, +> 외부 시스템 연동 설계를 점검하기 위한 기준이다. +> 설계/구현 전후에 이 프레임워크를 대입하여 빈틈을 식별한다. + +### 점검 기준표 + +| # | 점검 영역 | 핵심 질문 | 위험 수준 | +|---|----------|----------|----------| +| **C1** | **장애 격리** | 외부 시스템 장애가 내부 서비스로 전파되는가? 결제 장애가 상품 조회에 영향을 주는가? | 치명적 | +| **C2** | **리소스 보호** | 외부 호출 지연 시 스레드/커넥션/메모리가 고갈될 수 있는가? | 치명적 | +| **C3** | **데이터 정합성** | 내부 상태와 외부 상태가 어긋날 수 있는 지점은? 어긋났을 때 감지하고 복구할 수 있는가? | 치명적 | +| **C4** | **멱등성** | 동일 요청이 2번 실행되면 부작용이 발생하는가? (중복 결제, 중복 차감 등) | 치명적 | +| **C5** | **트랜잭션 경계** | 외부 호출이 DB 트랜잭션 안에 있는가? 커넥션 점유 시간은 적절한가? | 높음 | +| **C6** | **타임아웃 체인** | 상위 서비스 타임아웃 > 하위 서비스 타임아웃을 만족하는가? 타임아웃이 누락된 호출이 있는가? | 높음 | +| **C7** | **동시성** | 같은 자원에 대한 동시 요청이 경합하는 지점은? Race Condition이 존재하는가? | 높음 | +| **C8** | **복구 가능성** | 장애 발생 후 자동 복구 경로가 있는가? 수동 개입 없이 정상 상태로 돌아올 수 있는가? | 높음 | +| **C9** | **관측 가능성** | 장애 발생을 감지할 수 있는가? Circuit Breaker 상태 변화, 실패율, 복구 대상 건수를 알 수 있는가? | 중간 | +| **C10** | **Graceful Degradation** | 외부 시스템 장애 시 사용자에게 어떤 경험을 제공하는가? 거짓 정보를 전달하지 않는가? | 중간 | +| **C11** | **배압(Backpressure)** | Circuit Breaker가 닫힐 때, 대기 중이던 요청이 한꺼번에 몰리는가? (Thundering Herd) | 중간 | +| **C12** | **SLA 정합성** | 우리 서비스의 응답시간 SLA가 외부 시스템 지연 + Retry 시간을 포함하여 유지되는가? | 중간 | + +### 점검 프로세스 + +``` +1. 설계 문서(05)의 각 흐름을 C1~C12 기준으로 대입 +2. 위험 수준이 "치명적"인 항목 우선 점검 +3. 빈틈 발견 시 → 선택지 도출 → 트레이드오프 분석 → 결정 + 근거 기록 +4. 구현 후 다시 점검 (특히 C3, C4, C7은 코드 레벨에서 재확인) +``` + +--- + +## 1. 점검 결과: 우리 설계 대입 + +### C1. 장애 격리 — 통과 + +| 점검 | 결과 | +|------|------| +| PG 장애 → 상품 조회 영향? | **없음**. PG 호출은 결제 API에서만 발생 | +| PG 장애 → 주문 생성 영향? | **없음**. 주문 생성과 결제는 별도 API | +| CircuitBreaker 적용? | **적용됨**. PG 전면 장애 시 호출 차단 → 즉시 Fallback | + +**보완 필요 없음.** + +### C2. 리소스 보호 — 통과 + +| 점검 | 결과 | +|------|------| +| 스레드 고갈 | Timeout 1초 + 최대 Retry 4.5초로 제한. 무한 대기 불가 | +| DB 커넥션 고갈 | PG 호출은 트랜잭션 밖. 커넥션 점유 ~수십ms | +| 커넥션 풀 계산 | 초당 100건 × 0.05초 = 5 커넥션·초 (TX 안이면 450 커넥션·초) | + +**보완 필요 없음.** 트랜잭션 분리가 핵심 방어. + +### C3. 데이터 정합성 — 보완 필요 + +| 점검 | 결과 | +|------|------| +| 내부-외부 상태 불일치 가능 지점 | **타임아웃 시** — 내부 UNKNOWN, PG는 PENDING/SUCCESS 가능 | +| 감지 가능한가? | **가능**. 콜백 + 배치 폴링 | +| 복구 가능한가? | **가능**. PG 상태 확인 API로 확정 | +| REQUESTED 상태 방치 가능? | **가능**. TX-1 커밋 후 PG 호출 전 서버 크래시 시 | + +**보완**: 배치 복구 대상에 REQUESTED 상태 포함 필수. + +``` +기존: 배치 대상 = PENDING(N분 경과) + UNKNOWN +수정: 배치 대상 = REQUESTED(N분 경과) + PENDING(N분 경과) + UNKNOWN +``` + +REQUESTED가 N분 이상 지속 → PG 조회 → 404이면 FAILED 처리 (PG에 도달하지 못한 것) + +### C4. 멱등성 — 보완 완료 (치명적이었음) + +| 점검 | 결과 | +|------|------| +| 결제 요청 중복 실행 | **PG가 멱등하지 않음** → 재시도 시 중복 결제 위험 | +| 해결 | 재시도 전 PG 상태 확인 (수동 Retry 루프) | +| 콜백 중복 수신 | 이미 최종 상태이면 무시 (멱등) | +| 동시 결제 요청 | Payment 테이블 UNIQUE(order_id) | + +**06 리뷰에서 식별되어 반영 완료.** + +### C5. 트랜잭션 경계 — 통과 + +| 점검 | 결과 | +|------|------| +| 외부 호출 위치 | 트랜잭션 밖 | +| 커넥션 점유 시간 | ~수십ms (Payment 저장/업데이트만) | +| 콜백 처리 시 | Payment + Order 같은 TX (모노리스, 같은 DB) | + +**현재 구조에서 적절.** MSA 전환 시 콜백 처리의 TX 분리 + 이벤트 기반 전환 고려. + +### C6. 타임아웃 체인 — 점검 필요 + +MSA에서는 호출 체인의 타임아웃이 계층적으로 맞아야 한다: + +``` +[사용자] ---(응답 대기 10초)--→ [API Gateway] ---(5초)--→ [Payment Service] ---(1초)--→ [PG] +``` + +| 점검 | 결과 | +|------|------| +| 사용자 → Commerce API | 별도 설정 없음 (Tomcat 기본) | +| Commerce API → PG | Timeout 1초 × 최대 3회 = 4.5초 | +| 상위 타임아웃 > 하위 타임아웃? | Tomcat 기본 타임아웃(60초) > 4.5초 → **충족** | + +**현재 구조에서 문제 없음.** 다만 향후 API Gateway 도입 시 gateway timeout > 4.5초 보장 필요. + +### C7. 동시성 — 보완 완료 + +| 점검 | 결과 | +|------|------| +| 같은 주문에 동시 결제 | Payment UNIQUE(order_id)로 방지 | +| 콜백과 배치 동시 실행 | 같은 Payment를 동시에 업데이트할 수 있음 | + +**보완 필요**: 콜백 처리와 배치 복구가 동시에 같은 Payment를 건드릴 수 있다. + +| 선택지 | 동작 | 장점 | 단점 | +|--------|------|------|------| +| A. 비관적 락 | `SELECT ... FOR UPDATE` | 확실한 동시성 제어 | 락 경합, 배치 지연 | +| **B. 상태 기반 조건부 UPDATE** | `UPDATE ... WHERE status = 'PENDING'` | **락 없이 원자적 전이, 단순** | affected rows 확인 필요 | +| C. 낙관적 락 (version) | `@Version` 필드 | JPA 친화적 | 충돌 시 재시도 로직 필요 | + +**결정: B. 조건부 UPDATE** + +```sql +UPDATE payment SET status = 'PAID' WHERE id = ? AND status IN ('PENDING', 'UNKNOWN') +-- affected rows = 0이면 이미 다른 경로(콜백/배치)에서 처리 완료 → 무시 +``` + +근거: +- 콜백과 배치가 동시에 같은 건을 처리해도, 먼저 UPDATE 성공한 쪽이 확정 +- 나중에 UPDATE한 쪽은 affected rows = 0 → 추가 처리 없이 종료 +- 락이 없어 성능 영향 최소화 +- 기존 쿠폰 사용에서도 같은 패턴 적용 중 (조건부 UPDATE) + +### C8. 복구 가능성 — 보완 완료 + +| 점검 | 결과 | +|------|------| +| UNKNOWN 복구 | 콜백 + 배치 이중 안전망 | +| REQUESTED 복구 | **배치 대상에 추가 필요** (C3에서 식별) | +| PENDING 장기 체류 복구 | 배치가 1분 후 PG 확인 | +| 수동 복구 | 관리자/사용자 API 제공 | + +**자동 복구 경로 존재 확인.** REQUESTED 추가 반영 후 완전. + +### C9. 관측 가능성 — 보완 필요 + +| 점검 | 결과 | +|------|------| +| CB 상태 변화 감지 | 설정만으로는 로그 없음 | +| 실패율 모니터링 | Resilience4j Actuator 연동 필요 | +| 복구 대상 건수 추적 | UNKNOWN/PENDING 건수 쿼리 필요 | + +**보완**: 구현 단계에서 아래 추가 + +```yaml +# Actuator로 CB 상태 노출 +management: + endpoints: + web: + exposure: + include: health,circuitbreakers + health: + circuitbreakers: + enabled: true +``` + +**우선순위: 낮음** — 기능 구현 후 부가적으로 추가. 과제 스코프에서는 로깅으로 대체 가능. + +### C10. Graceful Degradation — 통과 + +| 점검 | 결과 | +|------|------| +| Fallback 메시지 | "결제 확인 중입니다. 잠시 후 확인해주세요" | +| 거짓 정보 전달? | **없음**. 성공/실패를 확인 못 한 상태를 그대로 전달 | +| CB Open 시 경험 | 즉시 Fallback → 사용자 대기 시간 최소화 | + +**보완 필요 없음.** + +### C11. 배압(Thundering Herd) — 현재 위험 낮음 + +| 점검 | 결과 | +|------|------| +| CB Open → Close 전환 시 | Half-Open에서 2건만 허용 → 점진적 복구 | +| 대기 중 요청 폭주 | CB Open 중에는 Fallback 처리 → 대기열 없음 | + +**보완 필요 없음.** Resilience4j의 Half-Open 메커니즘이 자연스럽게 처리. + +### C12. SLA 정합성 — 통과 + +| 점검 | 결과 | +|------|------| +| 결제 API 최대 응답 시간 | 4.5초 (Retry 전부 실패 시) | +| 결제 UX 허용 범위 | 5~10초 | +| CB Open 시 응답 시간 | ~즉시 (Fallback) | + +**보완 필요 없음.** + +--- + +## 2. 점검 결과 요약 + +### 점검 통과 + +| # | 영역 | 상태 | +|---|------|------| +| C1 | 장애 격리 | **통과** | +| C2 | 리소스 보호 | **통과** | +| C5 | 트랜잭션 경계 | **통과** | +| C6 | 타임아웃 체인 | **통과** | +| C10 | Graceful Degradation | **통과** | +| C11 | 배압 | **통과** | +| C12 | SLA 정합성 | **통과** | + +### 보완 완료 (06 리뷰에서 식별) + +| # | 영역 | 보완 내용 | +|---|------|----------| +| C4 | 멱등성 | 수동 Retry + PG 상태 확인, UNIQUE(order_id) | + +### 보완 필요 (이번 점검에서 추가 식별) + +| # | 영역 | 보완 내용 | 우선순위 | +|---|------|----------|---------| +| C3 | 데이터 정합성 | 배치 복구 대상에 REQUESTED 상태 추가 | **높음** | +| C7 | 동시성 | 콜백/배치 동시 실행 방지 → 조건부 UPDATE | **높음** | +| C9 | 관측 가능성 | CB 상태 모니터링 (Actuator 또는 로깅) | 낮음 | + +--- + +## 3. 기존 리뷰 내용 + +### 3.1 Resilience 패턴 적용 원칙 + +대규모 트래픽 이커머스에서 외부 시스템(PG, 배송, 알림 등) 호출의 기본 원칙은 +**"외부 장애가 내부로 전파되지 않는 것"**이다. 이를 위해 4단계 방어선을 구축한다. + +``` +[1차 방어] Timeout — 개별 요청의 최대 대기 시간 제한 +[2차 방어] Retry — 일시적 실패에 대한 자동 재시도 +[3차 방어] CircuitBreaker — 반복 실패 시 호출 자체를 차단 +[최후 방어] Fallback — 모든 방어가 뚫렸을 때 사용자에게 안전한 응답 +``` + +### 3.2 Timeout 설정 기준 + +실무에서 타임아웃은 **"P99 응답시간의 2~3배"**로 잡는다. + +``` +PG 정상 응답: 100~500ms (시뮬레이터 기준) +P99 추정: ~500ms +타임아웃 기준: 500ms × 2 = 1,000ms (1초) +``` + +connectTimeout과 readTimeout을 구분한다: +- **connectTimeout**: TCP 연결 수립까지. PG가 살아있는지 확인. (500ms) +- **readTimeout**: 연결 후 응답 대기 시간. 실제 처리 시간 반영. (1초) + +### 3.3 Retry 설정 기준 + +재시도는 **멱등하지 않은 요청에 대해 매우 신중**해야 한다. + +- GET (조회): 자유롭게 재시도 가능 +- POST (결제 요청): **중복 생성 위험** → 재시도 전 반드시 상태 확인 필요 + +### 3.4 CircuitBreaker 설정 기준 + +서비스별로 별도 인스턴스를 두고, **비즈니스 임팩트에 따라 임계치를 다르게** 설정한다. + +- 결제(PG): 보수적 (임계치 높게, 빨리 차단하지 않음) — 돈이 걸려있으므로 최대한 시도 +- 알림(카카오톡): 공격적 (임계치 낮게, 빨리 차단) — 실패해도 치명적이지 않음 + +### 3.5 Fallback 처리 + +결제 Fallback의 핵심은 **"사용자에게 거짓말하지 않는 것"**이다. + +``` +X "결제가 완료되었습니다" (확인 안 됐는데) +X "결제가 실패했습니다" (PG에서는 성공했을 수 있는데) +O "결제 확인 중입니다. 잠시 후 결제 내역에서 확인해주세요" +``` + +--- + +## 4. 비동기 결제 상태 관리 + +### 4.1 내부 결제 상태 검증 + +5단계 상태(REQUESTED → PENDING → PAID/FAILED/UNKNOWN)는 적절하다. + +UNKNOWN 상태의 세분화는 필요한가? + +| 선택지 | 설명 | 판단 | +|--------|------|------| +| A. UNKNOWN 하나로 통합 | 원인 불문하고 "모르겠다" | **현재는 충분** | +| B. TIMEOUT / CALLBACK_MISSING 분리 | 원인별 복구 전략 차별화 | 과제 범위에서 과도함 | + +**결정: A. UNKNOWN 하나로 충분.** 복구 방법이 동일 (PG 상태 확인 API 호출). + +### 4.2 유령 결제 처리 + +"타임아웃으로 실패 처리했는데, PG에서는 결제가 성공한 경우" + +핵심은 **"감지할 수 있는가"**와 **"복구할 수 있는가"**이다. + +| 감지 방법 | 복구 방법 | +|----------|----------| +| 콜백으로 감지 (PG가 성공 콜백을 보냄) | SUCCESS → PAID 전이 | +| 배치로 감지 (PG 상태 확인 API 조회) | FAILED → FAILED 전이 + 재고 복원 | + +UNKNOWN 상태가 이 역할을 수행한다. + +--- + +## 5. 멱등성 — 핵심 리스크 + +### 5.1 결제 요청 중복 방지 + +PG 시뮬레이터는 같은 orderId로 요청해도 **별도 결제건을 생성**한다. +PG 자체가 멱등하지 않으므로, 우리 측에서 보장해야 한다. + +**결정: 재시도 전 PG 상태 확인 (수동 Retry 루프)** + +``` +1차 시도 → 타임아웃 +재시도 전: GET /api/v1/payments?orderId={orderId} 로 PG 조회 + |-- PG에 기록 있음 → 재시도 안 함, 해당 transactionKey로 추적 + +-- PG에 기록 없음 (404) → 안전하게 재시도 +``` + +### 5.2 동일 주문 동시 결제 방지 + +Payment 테이블에 `UNIQUE(order_id)` 제약. 같은 주문에 대해 동시 결제 요청이 들어오면 두 번째 요청은 DB 유니크 위반으로 거부. + +### 5.3 콜백/배치 동시 실행 방지 + +조건부 UPDATE로 해결: +```sql +UPDATE payment SET status = 'PAID' WHERE id = ? AND status IN ('PENDING', 'UNKNOWN') +``` +affected rows = 0이면 이미 다른 경로에서 처리 완료. + +--- + +## 6. 트랜잭션 경계 + +### 6.1 PG 호출은 트랜잭션 밖 + +| 관점 | TX 안에서 외부 호출 | TX 밖에서 외부 호출 | +|------|-------------------|-------------------| +| DB 커넥션 점유 | PG 응답까지 점유 (최대 4.5초) | Payment 저장 시간만 (~수십ms) | +| 초당 100건 시 | 커넥션 100개 × 4.5초 = **450 커넥션·초** | 100개 × 0.05초 = **5 커넥션·초** | +| 장애 전파 | PG 지연 → DB 커넥션 고갈 → 전체 마비 | PG 지연 → 결제만 영향 | + +### 6.2 콜백 수신 시 트랜잭션 + +Payment + Order를 같은 트랜잭션에서 업데이트. + +| 선택지 | 장점 | 단점 | +|--------|------|------| +| **같은 TX** | **원자성 보장, 단순** | 두 도메인이 결합 | +| 별도 TX + 이벤트 | 느슨한 결합 | 현재 불필요한 복잡성 | + +**결정: 같은 TX.** 모노리스 + 같은 DB. MSA 전환 시 이벤트 기반으로 전환. + +### 6.3 PENDING 중 주문 취소 + +| 선택지 | 설명 | 결정 | +|--------|------|------| +| A. 취소 불가 | 결제 결과 대기 후 처리 | **선택** | +| B. 취소 허용 + 환불 | UX 우선 | PG 환불 API 없어 불가 | + +--- + +## 7. PG 타이밍 기반 전략 점검 + +### 7.1 PG 시뮬레이터 타이밍 + +``` +[요청] Thread.sleep(100~500ms) → 40%: 500 에러 / 60%: PENDING 응답 +[비동기] Thread.sleep(1~5초) → 70%: SUCCESS / 30%: FAILED +[콜백] RestTemplate POST → 실패 시 재시도 없음 +``` + +### 7.2 Timeout 1초 검증 + +| 항목 | 값 | +|------|-----| +| PG 정상 응답 | 110~550ms | +| 우리 readTimeout | 1,000ms | +| 여유 | 450~890ms | +| 정상 요청 타임아웃 확률 | **거의 0%** | + +**적절.** + +### 7.3 Retry 타이밍 검증 + +| 시나리오 | 소요 시간 | UX | +|----------|----------|-----| +| 1차 성공 | 0.1~0.5초 | 즉시 | +| 2차 성공 | 0.7~1.5초 | 허용 | +| 3차 성공 | 1.5~3초 | 체감되지만 결제로 허용 | +| 전체 실패 → Fallback | 4.5초 | 한계 (5초 이내 OK) | + +**적절.** + +### 7.4 CircuitBreaker 실패율 재검증 + +Retry가 CB 안쪽이므로, CB가 보는 실패율은 Retry 후 최종 결과이다. + +``` +PG 기본 실패율: 40% +3회 연속 실패 확률: 0.4³ = 6.4% +CB가 보는 최종 실패율: ~6.4% +CB 임계치: 50% + +→ 정상 운영에서 CB가 열릴 가능성: 거의 없음 +→ CB가 열리려면: PG 거의 전면 장애 +``` + +**의도대로 동작.** CB는 PG 전면 장애 시에만 작동. + +### 7.5 Fallback 후 복구까지의 시간 간극 + +| 상황 | 콜백 가능성 | 복구 경로 | +|------|-----------|----------| +| 3회 모두 PG 500 | 안 옴 | 배치 → PG 404 → FAILED | +| 1차 타임아웃 (PG 도달) + 2,3차 실패 | **올 수 있음** | 콜백 또는 배치 | +| PG 완전 다운 (CB Open) | 안 옴 | 배치 → PG 404 → FAILED | + +**UNKNOWN 상태의 결제건은 콜백 + 배치 이중 안전망으로 반드시 복구된다.** + +--- + +## 8. 설계 보완 사항 최종 요약 + +### 반영 완료 + +| # | 보완 사항 | 근거 | 출처 | +|---|----------|------|------| +| 1 | Retry 전 PG 상태 확인 (수동 Retry) | 중복 결제 방지 (C4) | 06 리뷰 | +| 2 | Payment UNIQUE(order_id) | 동시 결제 방지 (C7) | 06 리뷰 | + +### 추가 반영 필요 (→ 05 설계 문서에 반영) + +| # | 보완 사항 | 근거 | 우선순위 | +|---|----------|------|---------| +| 3 | 배치 복구 대상에 REQUESTED 추가 | 서버 크래시 시 PG 호출 전 방치 방지 (C3) | **높음** | +| 4 | 콜백/배치 동시성 → 조건부 UPDATE | 상태 전이 경합 방지 (C7) | **높음** | +| 5 | connectTimeout 500ms로 분리 | PG 연결 불가 시 빠른 감지 (C6) | 중간 | +| 6 | CB 상태 로깅/모니터링 | 장애 감지 및 운영 (C9) | 낮음 | +| 7 | Transactional Outbox 패턴 적용 | PG 호출 신뢰성 보장 (C3, C8) | **높음** | +| 8 | Multi-PG Fallback (Toss sandbox) | PG 전면 장애 시 대체 경로 확보 (C1, C10) | **높음** | +| 9 | Fallback 지점 전수 점검 및 구체화 | 모든 실패 경로에 대한 대응 보장 (C10) | **높음** | +| 10 | Polling Hybrid (Delayed Task) | 콜백 미수신 시 10초 내 능동적 복구 (C8, C12) | **높음** | +| 11 | Callback Inbox (DLQ) | 콜백 데이터 유실 방지 + 재처리 (C3, C8) | **높음** | +| 12 | Local WAL | DB 장애 시 PG 응답 보존 (C3) | 중간 | +| 13 | 카드사별 장애 모니터링 | 카드사 장애 시 사전 안내 (C10) | 설계만 | + +--- + +## 9. Transactional Outbox 패턴 적용 분석 + +### 9.1 현재 설계의 취약점 + +``` +[TX-1] Payment(REQUESTED) 저장 → commit +[PG 호출] CircuitBreaker → Retry → PG 요청 (트랜잭션 없음) +[TX-2] Payment 상태 업데이트 → commit +``` + +TX-1 커밋과 PG 호출 사이에 **서버 크래시**가 발생하면: +- Payment는 REQUESTED 상태로 DB에 존재 +- PG 호출은 아예 발생하지 않음 +- 배치 복구(C3 보완)가 이 건을 감지하여 처리할 수 있지만, **배치 주기(1분)만큼 지연** + +### 9.2 Outbox 패턴 적용 시 구조 + +``` +[TX-1] Payment(REQUESTED) + PaymentOutbox(PENDING) 저장 → commit +[Outbox Poller] PaymentOutbox(PENDING) 조회 → PG 호출 → PaymentOutbox(PROCESSED) +[TX-2] Payment 상태 업데이트 → commit +``` + +핵심: **Payment 생성과 "PG를 호출해야 한다"는 의도를 같은 트랜잭션으로 원자적 저장**. +Outbox 폴러가 미처리 건을 지속적으로 처리하므로, 서버 크래시에도 PG 호출이 누락되지 않는다. + +### 9.3 Outbox 테이블 설계 (안) + +```sql +CREATE TABLE payment_outbox ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + payment_id BIGINT NOT NULL, + order_id VARCHAR(50) NOT NULL, + event_type VARCHAR(30) NOT NULL, -- 'PAYMENT_REQUEST' + payload TEXT NOT NULL, -- PG 요청 Body (JSON) + status VARCHAR(20) NOT NULL, -- 'PENDING' / 'PROCESSED' / 'FAILED' + created_at DATETIME NOT NULL, + processed_at DATETIME, + retry_count INT DEFAULT 0 +); +``` + +### 9.4 Outbox vs 배치 복구 비교 + +| 기준 | 배치 복구 (현재) | Outbox 패턴 | +|------|----------------|------------| +| **복구 지연** | 배치 주기(1분) | 폴러 주기(수 초) | +| **복구 대상 식별** | Payment 상태 기반 (REQUESTED/PENDING/UNKNOWN) | Outbox 상태 기반 (PENDING) — 명시적 | +| **의도 보존** | 암묵적 (REQUESTED = "PG를 호출하려 했다") | 명시적 (Outbox 레코드 = "PG를 호출해야 한다") | +| **구현 복잡도** | 낮음 | 중간 (Outbox 테이블 + 폴러 추가) | +| **MSA 전환 시** | 서비스별 배치 각각 구현 | 이벤트 발행으로 자연스럽게 전환 | +| **신뢰성** | 높음 | 매우 높음 | + +### 9.5 트레이드오프 분석 + +| 선택지 | 장점 | 단점 | +|--------|------|------| +| A. 배치 복구만 유지 | 단순, 이미 설계됨 | 복구 지연 1분, 의도가 암묵적 | +| **B. Outbox + 배치 복구 병행** | **즉시 복구 + 최종 안전망, MSA-ready** | Outbox 테이블 + 폴러 추가 구현 | +| C. Outbox로 배치 대체 | 깔끔한 단일 복구 경로 | 배치의 "전수 스캔" 안전망 제거 | + +**결정: B. Outbox + 배치 복구 병행** + +**근거**: +- Outbox는 정상 경로에서 PG 호출 누락을 **수 초 내에 감지하고 재시도** +- 배치 복구는 Outbox 폴러 자체가 장애일 때의 **최종 안전망**으로 유지 +- MSA 전환 시 Outbox → 이벤트 발행(Kafka 등)으로 자연스럽게 진화 가능 +- 현재 모노리스에서도 "PG 호출 의도"를 명시적으로 기록하는 것은 운영상 가치가 있음 + +### 9.6 Outbox 폴러 동작 흐름 + +``` +[스케줄러: 5초 주기] + 1. PaymentOutbox에서 status = 'PENDING' 조회 + 2. 각 건에 대해: + a. Payment 현재 상태 확인 + - 이미 PAID/FAILED → Outbox PROCESSED 처리 (다른 경로로 해결됨) + b. PG 상태 확인 (GET /api/v1/payments?orderId={orderId}) + - PG에 기록 있음 → 해당 transactionKey로 추적, Outbox PROCESSED + - PG에 기록 없음 → PG 결제 요청 (POST) 실행 + c. retry_count 증가, 최대 3회 초과 시 Outbox FAILED + 알림 +``` + +**멱등성 보장**: Outbox 폴러도 PG 호출 전에 반드시 PG 상태 확인 (5.1 수동 Retry와 동일 원칙) + +--- + +## 10. Fallback 지점 전수 점검 + +### 10.1 점검 범위 + +주문/결제 흐름에서 발생할 수 있는 **모든 실패 시나리오**에 대해 +사용자에게 어떤 응답을 줄 것인지, 시스템은 어떻게 복구할 것인지를 구체적으로 정의한다. + +> **범위**: 주문 및 결제 Fallback만 포함. 상품 전시(조회) Fallback은 이번 과제 범위 외. + +### 10.2 Fallback 지점 맵 + +``` +[사용자 결제 요청] + │ + ┌── FB1. 내부 검증 실패 ──→ 즉시 에러 응답 (400) + │ + ┌── FB2. Primary PG 요청 실패 (Retry 소진) + │ └── FB2-1. Fallback PG(Toss) 시도 + │ ├── 성공 → 정상 흐름 + │ └── FB2-2. Fallback PG도 실패 → UNKNOWN 저장 + "확인 중" 응답 + │ + ┌── FB3. PG 응답 수신 후 내부 저장 실패 ──→ 로그 + 배치 복구 + │ + ┌── FB4. 비동기 처리 결과 FAILED ──→ 정상 실패 처리 (재고 복원) + │ + ┌── FB5. 콜백 미수신 ──→ 배치 복구 (자동) + │ + ┌── FB6. 콜백 수신 후 내부 처리 실패 ──→ 배치 복구 (자동) + │ + ┌── FB7. 유령 결제 (타임아웃인데 PG 성공) ──→ 콜백 + 배치 이중 복구 +``` + +### 10.3 Fallback 지점별 대응 전략 + +| # | 실패 지점 | 대응 전략 | 사용자 응답 | 복구 방법 | +|---|----------|----------|-----------|----------| +| **FB1** | 내부 검증 실패 (주문 없음, 이미 결제됨) | 즉시 에러 반환 | `400` "주문 정보를 확인해주세요" | 복구 불필요 (사용자 재시도) | +| **FB2** | Primary PG 실패 (Retry 3회 소진) | **Fallback PG(Toss)로 전환** | 사용자 인지 없이 내부 전환 | 자동 (PG 전환) | +| **FB2-1** | Fallback PG(Toss)도 실패 | UNKNOWN 저장 + 안내 | `200` "결제 확인 중입니다" | Outbox + 배치 | +| **FB3** | PG 응답 OK, 내부 DB 저장 실패 | 로그 + Outbox PENDING 유지 | `500` "일시적 오류" | Outbox 폴러 재처리 | +| **FB4** | PG 비동기 FAILED (한도초과/잘못된 카드) | FAILED + 재고 복원 | 주문 상세에서 확인 | 정상 흐름 (복구 불필요) | +| **FB5** | 콜백 미수신 | PENDING/UNKNOWN 유지 | 주문 상태 "결제 확인 중" | 배치 1분 주기 복구 | +| **FB6** | 콜백 수신 후 내부 처리 실패 | 로그 남김 | 주문 상태 미변경 | 배치 복구 | +| **FB7** | 유령 결제 | UNKNOWN 유지 | "결제 확인 중" | 콜백 + 배치 이중 복구 | + +### 10.4 기존 Fallback 대비 개선 사항 + +| 개선 영역 | 기존 (05 설계) | 개선안 | 근거 | +|----------|---------------|--------|------| +| **PG 전면 장애** | CB Open → UNKNOWN + "확인 중" | **Fallback PG(Toss)로 자동 전환** → 결제 성공률 유지 | 사용자 경험 보호 | +| **PG 호출 누락** | 배치 1분 주기 복구 | **Outbox 5초 주기 재시도** + 배치 안전망 | 복구 지연 최소화 | +| **내부 저장 실패** | 별도 대응 없음 | **로그 + Outbox 기반 재처리** | 데이터 유실 방지 | +| **Fallback 응답** | 단일 메시지 | **실패 유형별 구체적 안내 메시지** | UX 개선 | + +--- + +## 11. Multi-PG Fallback 아키텍처 + +### 11.1 왜 Multi-PG가 필요한가? + +현재 설계에서 PG 전면 장애 시: +- CB가 Open → 모든 결제 요청이 즉시 Fallback +- 사용자는 "확인 중" 메시지만 받음 → **결제 전환율 0%** +- PG 복구까지 모든 매출이 멈춤 + +대규모 이커머스에서 **단일 PG 의존은 SPoF(Single Point of Failure)**이다. +PG가 완전히 장애 나도 다른 PG로 결제를 이어갈 수 있어야 한다. + +### 11.2 아키텍처 결정 + +``` +[결제 요청] + │ + ▼ +[PG Router (Strategy)] + │ + ├── 1순위: PG Simulator (Primary) + │ └── CB → Retry → 성공 시 리턴 + │ + ├── Primary 실패 (CB Open 또는 Retry 소진) + │ ▼ + ├── 2순위: Toss Payments Sandbox (Fallback) + │ └── CB → Retry → 성공 시 리턴 + │ + └── 모든 PG 실패 + ▼ + [최종 Fallback: UNKNOWN 저장 + "확인 중" 응답] +``` + +### 11.3 PG 추상화 설계 (Strategy Pattern) + +```java +public interface PgClient { + PgPaymentResponse requestPayment(PgPaymentRequest request); + PgPaymentStatusResponse getPaymentStatus(String transactionKey); + PgPaymentStatusResponse getPaymentByOrderId(String orderId); + String getProviderName(); // "SIMULATOR" / "TOSS" +} +``` + +```java +@Component +public class SimulatorPgClient implements PgClient { ... } + +@Component +public class TossSandboxPgClient implements PgClient { ... } +``` + +```java +@Component +public class PgRouter { + private final List pgClients; // 우선순위 순 + + public PgPaymentResponse requestPayment(PgPaymentRequest request) { + for (PgClient client : pgClients) { + try { + return client.requestPayment(request); // CB + Retry 적용 + } catch (Exception e) { + log.warn("PG [{}] 실패, 다음 PG 시도", client.getProviderName(), e); + } + } + throw new AllPgFailedException(); // → Fallback으로 연결 + } +} +``` + +### 11.4 왜 Strategy Pattern인가? + +| 선택지 | 장점 | 단점 | +|--------|------|------| +| A. if/else로 PG 분기 | 빠른 구현 | PG 추가 시 코드 수정, OCP 위반 | +| **B. Strategy Pattern** | **PG 추가 시 구현체만 추가, 테스트 용이** | 인터페이스 설계 필요 | +| C. Abstract Factory | 유연한 생성 | 현재 2개 PG에 과도한 추상화 | + +**결정: B. Strategy Pattern** + +**근거**: +- PG 추가/제거가 기존 코드 변경 없이 가능 (OCP) +- 각 PG별 독립적인 CB/Retry 설정 가능 +- 테스트 시 Mock PG 주입 용이 +- 2개 PG로 시작하므로 Factory까지는 불필요 + +### 11.5 Toss Payments Sandbox 연동 + +| 항목 | 값 | +|------|-----| +| 환경 | Sandbox (테스트) | +| 인증 | Test Secret Key (Base64) | +| 결제 승인 API | `POST /v1/payments/confirm` | +| 결제 조회 API | `GET /v1/payments/{paymentKey}` | +| 결제 방식 | 동기 (요청 → 즉시 응답) | +| 멱등성 | `Idempotency-Key` 헤더 지원 | + +> **주의**: PG Simulator는 **비동기** (요청 → PENDING → 콜백), Toss는 **동기** (요청 → 즉시 승인/실패). +> PgClient 인터페이스는 이 차이를 추상화해야 한다. + +### 11.6 PG별 차이 추상화 + +| 항목 | PG Simulator | Toss Sandbox | +|------|-------------|-------------| +| 결제 방식 | 비동기 (콜백) | 동기 (즉시) | +| 멱등성 | 미지원 (수동 보장) | Idempotency-Key 지원 | +| 응답 | PENDING → 콜백 | SUCCESS/FAILED 즉시 | +| 콜백 필요 | O | X | + +**추상화 전략**: + +``` +PgClient.requestPayment() 의 반환값: +- PENDING: PG가 비동기 처리 중 (콜백 대기 필요) +- SUCCESS: 즉시 승인 완료 +- FAILED: 즉시 거부 + +→ Simulator: 항상 PENDING 반환 (콜백으로 최종 확정) +→ Toss: SUCCESS 또는 FAILED 즉시 반환 (콜백 불필요) +→ PaymentFacade는 반환값에 따라 분기: + - PENDING → Payment(PENDING) + 콜백 대기 + - SUCCESS → Payment(PAID) + 주문 확정 + - FAILED → Payment(FAILED) + 재고 복원 +``` + +### 11.7 PG별 CB/Retry 독립 설정 + +```yaml +resilience4j: + circuitbreaker: + instances: + pgSimulator: + failure-rate-threshold: 50 + wait-duration-in-open-state: 10s + # ... Simulator 전용 설정 + pgToss: + failure-rate-threshold: 50 + wait-duration-in-open-state: 15s + # ... Toss 전용 설정 + retry: + instances: + pgSimulatorRetry: + max-attempts: 3 + # ... Simulator 전용 설정 + pgTossRetry: + max-attempts: 2 # Toss는 안정적이므로 적게 + # ... Toss 전용 설정 +``` + +### 11.8 Fallback PG 전환 판단 기준 + +| 시나리오 | Primary PG | Fallback PG 전환? | +|----------|-----------|-----------------| +| Retry 3회 소진 (일시적 실패) | 실패 | **전환** | +| CB Open (전면 장애) | 즉시 차단 | **전환** | +| 400 에러 (잘못된 요청) | 실패 | **전환하지 않음** — 요청 자체가 잘못됨 | +| PG 비동기 처리 FAILED (한도초과) | 비즈니스 실패 | **전환하지 않음** — PG 문제가 아님 | + +**근거**: Fallback PG 전환은 "PG 인프라 장애"에만 적용. 비즈니스 로직 실패(한도초과, 잘못된 카드)는 다른 PG에서도 동일하게 실패한다. + +### 11.9 비동기→동기 PG Fallback 시 중복 결제 위험 + +**핵심 문제**: Simulator(비동기)에서 **타임아웃**이 발생하면, PG 측에서는 결제가 진행 중일 수 있다. +이 상태에서 Toss(동기)로 Fallback하면 **같은 주문에 대해 2건의 결제가 발생**한다. + +``` +[Simulator] POST 결제 요청 → 타임아웃 (그러나 PG에서는 PENDING으로 저장됨) +[Toss] POST 결제 요청 → SUCCESS (즉시 승인) +... 3초 후 ... +[Simulator] 콜백 → SUCCESS (두 번째 결제 성공 통보) +→ 결과: 같은 주문에 대해 Simulator + Toss 모두 결제 완료 = 중복 결제 +``` + +#### 대응 방안 + +| 선택지 | 동작 | 장점 | 단점 | +|--------|------|------|------| +| A. Fallback 전환 전 Primary PG 상태 확인 | `GET /payments?orderId=xxx` 호출 후 판단 | 중복 결제 방지 | 추가 API 호출 1회 (수십ms) | +| **B. 타임아웃 실패 시 Fallback 전환하지 않음** | **500/연결실패만 Fallback, 타임아웃은 UNKNOWN 처리** | **단순, 안전** | 타임아웃 시 Toss 활용 불가 | +| C. 결제 전 항상 양쪽 PG 조회 | 모든 PG에 상태 확인 | 확실한 방지 | 과도한 호출, 지연 증가 | + +**결정: B. 타임아웃 실패 시 Fallback 전환하지 않음** + +**근거**: +- 타임아웃은 "PG에 요청이 도달했을 가능성"이 있는 실패 → 다른 PG로 전환하면 중복 위험 +- 500 에러 / 연결 실패는 "PG에 요청이 도달하지 않은" 실패 → 안전하게 다른 PG 시도 가능 +- UNKNOWN 상태 + Outbox/배치 복구로 타임아웃 건은 자동 해소 +- 선택지 A도 유효하지만, PG 상태 확인 API 자체가 타임아웃 날 수 있어 복잡도 증가 + +#### Fallback 전환 최종 판단 매트릭스 + +| 실패 유형 | PG 도달 가능성 | Fallback 전환 | 이유 | +|----------|-------------|-------------|------| +| **ConnectException** (연결 실패) | 없음 | **전환** | PG에 요청 자체가 안 감 | +| **500 에러** (서버 에러) | 낮음 (처리 전 실패) | **전환** | PG가 요청을 처리하지 못함 | +| **SocketTimeoutException** (읽기 타임아웃) | **있음** | **전환하지 않음** | PG에서 처리 중일 수 있음 | +| **CB Open** (서킷 오픈) | - | **전환** | PG 전면 장애 판단 | +| **400 에러** (잘못된 요청) | - | **전환하지 않음** | 요청 자체가 잘못됨 | + +--- + +## 12. Fallback 전략 체계 — 쿠팡 수준 설계 + +### 12.1 "진짜 Fallback"의 정의 + +``` +❌ 단순 Fallback: 에러를 잡아서 "잠시 후 다시 시도해주세요" 메시지 반환 +✅ 진짜 Fallback: 장애가 발생한 경로 대신 대체 경로로 비즈니스를 계속 수행 +``` + +쿠팡 규모에서 "결제가 안 됩니다"는 **분당 수억 원의 매출 손실**이다. +에러 메시지를 예쁘게 주는 것은 Fallback이 아니다. **돈이 계속 들어오게 하는 것**이 Fallback이다. + +### 12.2 결제 흐름 전체 Fallback 맵 + +``` +[사용자 결제 요청] + │ + [1] 주문 검증 + │ + [2] Payment + Outbox 저장 ──DB 장애──→ [FB-WAL] 로컬 WAL에 임시 기록 + │ + [3] PG 결제 요청 ──PG 장애──→ [FB-PG] 대체 PG(Toss)로 자동 전환 + │ + [4] PG 응답 저장 ──DB 장애──→ [FB-WAL] transactionKey를 로컬에 기록 + │ + [5] 비동기 대기 + │ + [6] 콜백 수신 ──미수신──→ [FB-POLL] 능동적 폴링으로 전환 + │ + [7] 콜백 처리 ──내부 장애──→ [FB-DLQ] 콜백 데이터 보존 → 재처리 + │ + [8] 결제 실패 → 재고 복원 ──실패──→ [FB-COMP] 보상 이벤트 큐잉 +``` + +### 12.3 FB-PG: PG 인프라 장애 → Multi-PG Routing + +> 이미 Section 11에서 설계 완료. + +| 장애 | 대체 경로 | 효과 | +|------|----------|------| +| Simulator 전면 장애 | Toss Sandbox로 자동 전환 | 결제 계속 가능 | +| Simulator 타임아웃 | 전환하지 않음 (중복 결제 방지) | UNKNOWN → 자동 복구 | + +### 12.4 FB-POLL: 콜백 채널 장애 → Polling Hybrid + +#### 현재 설계의 한계 + +``` +콜백 미수신 → 배치 1분 주기 복구 +→ 최악의 경우 사용자는 1분간 "결제 확인 중" 상태에 머무름 +``` + +사용자 입장에서 1분은 **매우 긴 시간**이다. 결제했는데 1분간 결과를 모르면 불안해서 재결제를 시도한다. + +#### 개선: 콜백 + 능동적 폴링 하이브리드 + +``` +PG 응답(PENDING) 수신 시: + → [정상 경로] 콜백 대기 + → [대체 경로] Delayed Task 등록 (T+10초 후 실행) + +10초 내 콜백 수신 → Task 취소 +10초 후 콜백 미수신 → Task 실행: + 1. GET /api/v1/payments/{transactionKey} + 2. PG 상태에 따라 내부 상태 전이 + 3. 아직 PENDING이면 → 20초 후 재확인 Task 등록 +``` + +| 선택지 | 복구 지연 | 구현 복잡도 | +|--------|----------|-----------| +| A. 배치만 (현재) | 최대 1분 | 낮음 | +| **B. Delayed Task + 배치** | **최대 10초** | 중간 | +| C. WebSocket 실시간 | 즉시 | 높음 (인프라 변경) | + +**결정: B. Delayed Task + 배치 (이중 안전망)** + +**구현 방식**: + +```java +// PG 응답(PENDING) 수신 직후 +taskScheduler.schedule( + () -> paymentRecoveryService.checkAndRecover(paymentId), + Instant.now().plusSeconds(10) // 10초 후 실행 +); +``` + +**근거**: +- PG 비동기 처리 최대 5초 + 콜백 전송 시간 → 10초면 콜백이 왔어야 함 +- 10초 후에도 없으면 콜백 유실 가능성 높음 → 능동적으로 확인 +- 배치(1분)는 Delayed Task 자체의 장애(서버 재시작 등) 시 최종 안전망 + +### 12.5 FB-WAL: 내부 DB 장애 → Local Write-Ahead Log + +#### 핵심 문제 + +PG에서 결제가 성공했는데 내부 DB에 기록을 못 하면: +- **내부에 Payment 레코드 자체가 없음** → 배치 복구도 불가 (조회 대상이 없으니까) +- 고객 돈은 빠졌는데 주문은 결제 안 된 상태 → **최악의 UX** + +``` +PG: "결제 성공, transactionKey = xxx" +내부 DB: (장애로 저장 실패) +배치: (Payment 레코드가 없으니 복구 대상 자체를 모름) +→ 유령 결제: 감지도, 복구도 불가능 +``` + +#### 대응: Local WAL (Write-Ahead Log) + +``` +PG 응답 수신 즉시: + 1. [WAL] 로컬 파일/Redis에 {orderId, transactionKey, pgResponse} 기록 + 2. [DB] Payment 상태 업데이트 시도 + - 성공 → WAL 레코드 삭제 + - 실패 → WAL에 남아있음 + +[WAL Recovery 스케줄러] + WAL에 남아있는 레코드 → DB에 반영 재시도 + → 성공 시 WAL 삭제 +``` + +| 선택지 | 저장 위치 | 장점 | 단점 | +|--------|----------|------|------| +| **A. 로컬 파일 WAL** | **서버 로컬 디스크** | **DB 무관하게 저장 가능, 단순** | 서버 디스크 장애 시 유실, 다중 서버 시 분산 | +| B. Redis WAL | Redis | 빠름, 서버 간 공유 | Redis 장애 시 유실 (DB와 동시 장애 시) | +| C. Kafka WAL | Kafka | 높은 내구성 | 인프라 추가 필요 | + +**결정: A. 로컬 파일 WAL (현재 과제) / B. Redis WAL (프로덕션)** + +**근거**: +- 현재 과제: 단일 서버이므로 로컬 파일로 충분 +- 프로덕션(쿠팡): Redis 또는 Kafka로 서버 간 공유 필요 +- 핵심은 "DB와 독립적인 저장소에 PG 응답을 먼저 기록"하는 것 + +### 12.6 FB-DLQ: 콜백 처리 장애 → Dead Letter Queue + +#### 핵심 문제 + +``` +PG 콜백 수신 → 내부 처리 중 예외 발생 → PG에게 500 응답 +PG: 콜백 재시도하지 않음 +→ 콜백 데이터 유실: 결제 결과를 다시 받을 방법이 없음 +``` + +배치가 PG 상태 확인 API로 복구할 수 있지만, **콜백에 포함된 상세 정보**(실패 사유 등)는 유실될 수 있다. + +#### 대응: 콜백 DLQ 테이블 + +``` +콜백 수신 → [1단계] callback_inbox 테이블에 원본 저장 (status: RECEIVED) + → [2단계] 비즈니스 처리 (Payment/Order 상태 전이) + → [성공] callback_inbox status → PROCESSED + → [실패] callback_inbox에 남아있음 (RECEIVED) + +[DLQ 재처리 스케줄러] + callback_inbox에서 status = 'RECEIVED' + 생성 후 N초 경과 → 재처리 시도 +``` + +```sql +CREATE TABLE callback_inbox ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + transaction_key VARCHAR(50) NOT NULL, + order_id VARCHAR(50) NOT NULL, + payload TEXT NOT NULL, -- 콜백 원본 JSON + status VARCHAR(20) NOT NULL, -- 'RECEIVED' / 'PROCESSED' / 'FAILED' + received_at DATETIME NOT NULL, + processed_at DATETIME, + retry_count INT DEFAULT 0, + error_message VARCHAR(500) +); +``` + +**핵심**: PG에게는 **항상 200 OK를 먼저 반환**하고, 내부 처리는 비동기로 수행. +이렇게 하면 PG 측에서 타임아웃이 발생하지 않고, 우리는 원본 데이터를 보존한 채 재처리할 수 있다. + +| 선택지 | 장점 | 단점 | +|--------|------|------| +| A. 콜백 실패 시 배치로 PG 재조회 | 기존 인프라 활용 | 콜백 상세 정보 유실 | +| **B. Callback Inbox (DLQ)** | **원본 보존, 재처리 가능, 감사 로그** | 테이블 하나 추가 | +| C. Kafka DLQ | 높은 처리량 | 인프라 추가 | + +**결정: B. Callback Inbox (DB 테이블 DLQ)** + +**근거**: +- 콜백 처리량이 초당 수천 건이 아닌 이상 DB 테이블로 충분 +- 콜백 원본을 보존하므로 디버깅, 감사(audit) 용도로도 활용 +- PG에게 항상 200 먼저 반환 → 콜백 유실 원천 차단 + +### 12.7 FB-COMP: 재고 복원 실패 → 보상 트랜잭션 큐 + +#### 현재 상황 + +모노리스 + 같은 DB → 결제 실패 시 재고 복원은 같은 TX에서 원자적으로 처리. +**현재 구조에서는 이 Fallback이 불필요하다.** + +#### MSA 전환 시 필요 + +``` +[Payment Service] 결제 FAILED 확정 + → [Stock Service] 재고 복원 요청 (HTTP/이벤트) + → Stock Service 장애 → 재고 복원 실패 + → 고객은 결제도 안 됐는데 재고는 차감된 상태 +``` + +| 선택지 | 동작 | 적용 시점 | +|--------|------|----------| +| A. 같은 TX (현재) | Payment + Stock 같은 DB TX | **모노리스** | +| B. 보상 이벤트 큐 | 실패 시 compensation_events에 기록, 스케줄러가 재시도 | **MSA 전환 시** | +| C. Saga Pattern | Orchestrator 또는 Choreography | **MSA 대규모** | + +**결정: 현재 A, MSA 전환 시 B** + +### 12.8 FB-CARD: 카드사 장애 → 결제 수단 Fallback + +#### 핵심 문제 + +특정 카드사(예: 삼성카드) 네트워크 장애 시: +- PG를 바꿔도 **같은 카드사면 동일 실패** +- Multi-PG Fallback으로는 해결 안 됨 + +#### 대응: 카드사별 실패율 모니터링 + 안내 + +``` +[카드사 실패율 모니터링] + 최근 N건 중 특정 카드사 실패율 > 임계치 + → 해당 카드사로 결제 시도 시: "해당 카드사 결제가 일시적으로 불안정합니다. + 다른 카드 또는 결제 수단을 이용해주세요." +``` + +| 선택지 | 장점 | 단점 | +|--------|------|------| +| A. 카드사 장애 무시 | 단순 | 사용자가 계속 실패 경험 | +| **B. 실패율 기반 사전 안내** | **불필요한 시도 방지, UX 개선** | 모니터링 로직 추가 | +| C. BIN 기반 자동 라우팅 | 완전 자동화 | 카드사별 PG 계약 필요 | + +**결정: 현재 과제 범위 외. 설계만 기록.** + +쿠팡에서는 B + C를 조합하여 카드사별 Circuit Breaker를 운영한다. + +### 12.9 Fallback 전략 구현 범위 결정 + +| # | Fallback 전략 | 설명 | 현재 과제 | MSA/프로덕션 | +|---|-------------|------|----------|-------------| +| **FB-PG** | Multi-PG Routing | 대체 PG로 자동 전환 | **구현** | N개 PG 확장 | +| **FB-POLL** | Polling Hybrid | 콜백 미수신 시 능동적 조회 | **구현** | Delayed Queue (Kafka) | +| **FB-WAL** | Local WAL | DB 장애 시 PG 응답 로컬 보존 | **구현** (파일) | Redis/Kafka WAL | +| **FB-DLQ** | Callback Inbox | 콜백 처리 실패 시 원본 보존 | **구현** (DB 테이블) | Kafka DLQ | +| **FB-COMP** | 보상 트랜잭션 큐 | 재고 복원 실패 시 재시도 | 불필요 (모노리스) | Saga Pattern | +| **FB-CARD** | 카드사별 모니터링 | 카드사 장애 시 사전 안내 | 설계만 기록 | CB per 카드사 | + +### 12.10 Fallback 계층 구조 (최종) + +``` +[결제 요청] + │ + ▼ +[1차 방어] Timeout → 개별 요청 시간 제한 + │ +[2차 방어] Retry → 일시적 실패 재시도 (멱등성 보장) + │ +[3차 방어] Circuit Breaker → 반복 실패 시 호출 차단 + │ +[4차 방어] Multi-PG Fallback → 대체 PG로 자동 전환 + │ +[5차 방어] Polling Hybrid → 콜백 실패 시 능동적 확인 + │ +[6차 방어] Callback DLQ → 콜백 데이터 보존 + 재처리 + │ +[7차 방어] Local WAL → DB 장애 시 PG 응답 보존 + │ +[최종 방어] UNKNOWN + Outbox + 배치 → 모든 방어가 뚫려도 최종 복구 +``` + +**핵심**: 각 계층은 이전 계층이 실패했을 때 작동한다. +7계층을 모두 뚫고 실패하는 경우는 **내부 DB + 로컬 디스크 + 모든 PG + 배치 서버가 동시에 장애**인 상황이며, +이 경우에만 수동 운영 개입이 필요하다. + +--- + +## 13. Circuit Breaker 세분화 점검 + +### 13.1 세분화 원칙 + +서킷브레이커를 촘촘히 나누는 이유: **장애 격리**. +하나의 CB에 여러 호출을 묶으면, 특정 호출의 장애가 관계없는 호출까지 차단한다. + +``` +CB 분리 기준 = 장애 격리 경계 +"A가 죽었을 때 B까지 차단되면 안 되는가?" → 그렇다면 CB를 분리해야 한다. +``` + +### 13.2 현재 설계의 문제점 + +현재 05에서 PG별 CB(`pgSimulator`, `pgToss`)만 분리했다. +하지만 같은 PG 내에서도 **결제 요청(POST)**과 **상태 조회(GET)**를 하나의 CB로 묶으면: + +``` +[치명적 시나리오] +1. PG 결제 요청(POST) 대량 실패 → CB Open +2. CB Open → 상태 조회(GET)도 차단됨 +3. 상태 조회 차단 → Outbox 폴러, 배치, Polling Hybrid 전부 PG 조회 불가 +4. → 모든 복구 경로 마비 +5. → UNKNOWN/PENDING 결제건이 영원히 미확정 +``` + +**결제 요청이 안 되는 것**은 Fallback PG로 넘기면 된다. +**상태 조회까지 차단되는 것**은 복구 자체가 불가능해지므로 치명적이다. + +### 13.3 CB 세분화 설계 + +#### 분리 기준: PG × API 유형 + +| CB 인스턴스 | 대상 | 장애 시 영향 | +|------------|------|------------| +| `pgSimulator-request` | POST /payments (결제 요청) | 결제 요청만 차단 → Fallback PG 전환 | +| `pgSimulator-status` | GET /payments/{key}, GET /payments?orderId= (상태 조회) | 복구 로직만 차단 → 배치가 재시도 | +| `pgToss-request` | POST /v1/payments/confirm (결제 승인) | Toss 결제만 차단 → 최종 Fallback(UNKNOWN) | +| `pgToss-status` | GET /v1/payments/{paymentKey} (상태 조회) | Toss 복구만 차단 | + +#### 왜 결제 요청과 상태 조회를 분리하는가? + +``` +PG 내부 아키텍처 (일반적): + [결제 처리 서버] ← POST 요청 (쓰기 부하) + [조회 서버/읽기 복제본] ← GET 요청 (읽기 부하) +``` + +- PG의 **결제 처리 서버**가 과부하로 죽어도 **조회 서버**는 정상일 수 있음 +- 결제 요청 CB가 Open이어도 상태 조회 CB는 Closed → **복구 로직 계속 동작** +- 하나로 묶으면 쓰기 장애가 읽기까지 전파 → 장애 격리 실패 + +#### 추가 분리 검토: 실시간 vs 배치 + +| 선택지 | 구조 | 장점 | 단점 | +|--------|------|------|------| +| A. 상태 조회 CB 하나로 통합 | `pgSimulator-status` 하나 | 단순 | 배치가 대량 호출 → CB Open → 실시간 폴링도 차단 | +| **B. 실시간 / 배치 분리** | `pgSimulator-status-realtime` + `pgSimulator-status-batch` | **배치 장애가 실시간 복구에 영향 없음** | CB 인스턴스 증가 | +| C. 분리 안 함 | 그대로 | - | 장애 전파 | + +**결정: B. 실시간 / 배치 분리** + +**근거**: +- 배치는 대량의 미확인 건을 한꺼번에 조회 → PG 조회 API에 부하를 줄 수 있음 +- 배치가 PG 조회 API를 과부하시켜 CB를 Open시키면, 실시간 Polling Hybrid와 수동 복구 API도 차단 +- 분리하면: 배치 CB Open → 배치만 중단, 실시간 복구는 계속 동작 + +### 13.4 최종 CB 인스턴스 목록 + +``` +[PG Simulator] + pgSimulator-request # 결제 요청 (POST) + pgSimulator-status-realtime # 상태 조회 - 실시간 (Polling Hybrid, 수동 복구, Outbox 폴러) + pgSimulator-status-batch # 상태 조회 - 배치 (1분 주기 대량 조회) + +[Toss Sandbox] + pgToss-request # 결제 승인 (POST) + pgToss-status-realtime # 상태 조회 - 실시간 + pgToss-status-batch # 상태 조회 - 배치 +``` + +총 **6개 CB 인스턴스**. + +### 13.5 CB별 설정 차별화 + +각 CB의 성격에 따라 임계치를 다르게 설정한다. + +| CB 인스턴스 | 실패율 임계치 | 윈도우 크기 | Open 유지 | 근거 | +|------------|-------------|-----------|----------|------| +| `pgSimulator-request` | 50% | 10 | 10s | 정상 PG 실패율(40%) + 여유 10%p. Retry 후 최종 실패율 ~6.4% 기준 | +| `pgSimulator-status-realtime` | 50% | 10 | 5s | 조회는 빠르게 복구 시도. 5초만 대기 후 Half-Open | +| `pgSimulator-status-batch` | 70% | 20 | 30s | 배치는 대량 호출이므로 일시적 실패에 과민 반응 방지. 윈도우 크게, 임계치 높게, Open 길게 | +| `pgToss-request` | 50% | 10 | 15s | Toss는 안정적이므로 Open 시 복구 여유를 더 줌 | +| `pgToss-status-realtime` | 50% | 10 | 5s | 실시간 복구 빠르게 | +| `pgToss-status-batch` | 70% | 20 | 30s | 배치 보호 | + +**차별화 근거**: +- **request CB**: Fallback PG가 있으므로 적극적으로 Open해도 됨 (다른 PG로 전환) +- **status-realtime CB**: 복구 경로이므로 빠르게 Half-Open 시도 (5초) +- **status-batch CB**: 대량 호출 특성상 일시적 실패가 많을 수 있으므로 보수적 운영 + +### 13.6 CB 세분화 설정 (Resilience4j) + +```yaml +resilience4j: + circuitbreaker: + instances: + # --- PG Simulator --- + pgSimulator-request: + sliding-window-type: COUNT_BASED + sliding-window-size: 10 + failure-rate-threshold: 50 + wait-duration-in-open-state: 10s + permitted-number-of-calls-in-half-open-state: 2 + slow-call-duration-threshold: 2s + slow-call-rate-threshold: 50 + + pgSimulator-status-realtime: + sliding-window-type: COUNT_BASED + sliding-window-size: 10 + failure-rate-threshold: 50 + wait-duration-in-open-state: 5s + permitted-number-of-calls-in-half-open-state: 2 + slow-call-duration-threshold: 1s # 조회는 빨라야 함 + slow-call-rate-threshold: 50 + + pgSimulator-status-batch: + sliding-window-type: COUNT_BASED + sliding-window-size: 20 + failure-rate-threshold: 70 + wait-duration-in-open-state: 30s + permitted-number-of-calls-in-half-open-state: 3 + slow-call-duration-threshold: 2s + slow-call-rate-threshold: 70 + + # --- Toss Sandbox --- + pgToss-request: + sliding-window-type: COUNT_BASED + sliding-window-size: 10 + failure-rate-threshold: 50 + wait-duration-in-open-state: 15s + permitted-number-of-calls-in-half-open-state: 2 + slow-call-duration-threshold: 3s # Toss 응답이 Simulator보다 안정적 + slow-call-rate-threshold: 50 + + pgToss-status-realtime: + sliding-window-type: COUNT_BASED + sliding-window-size: 10 + failure-rate-threshold: 50 + wait-duration-in-open-state: 5s + permitted-number-of-calls-in-half-open-state: 2 + slow-call-duration-threshold: 1s + slow-call-rate-threshold: 50 + + pgToss-status-batch: + sliding-window-type: COUNT_BASED + sliding-window-size: 20 + failure-rate-threshold: 70 + wait-duration-in-open-state: 30s + permitted-number-of-calls-in-half-open-state: 3 + slow-call-duration-threshold: 2s + slow-call-rate-threshold: 70 +``` + +### 13.7 장애 격리 검증 매트릭스 + +| 장애 시나리오 | 차단되는 CB | 영향받는 기능 | 영향받지 않는 기능 | +|-------------|-----------|------------|----------------| +| Simulator 결제 처리 장애 | `pgSimulator-request` | Simulator 결제 요청 | **Toss 결제, 모든 상태 조회, 모든 복구** | +| Simulator 조회 서버 장애 | `pgSimulator-status-*` | Simulator 상태 조회 | **모든 결제 요청, Toss 조회** | +| 배치가 Simulator 과부하 유발 | `pgSimulator-status-batch` | 배치 조회만 | **실시간 폴링, 수동 복구, 결제 요청** | +| Toss 전면 장애 | `pgToss-request` + `pgToss-status-*` | Toss 전체 | **Simulator 전체** | +| 모든 PG 결제 장애 | `*-request` 전부 | 모든 결제 | **모든 상태 조회 → 복구 가능** | + +**핵심 확인: "결제가 안 되더라도 복구는 항상 동작한다."** + +### 13.8 쿠팡 수준 추가 세분화 (설계만) + +프로덕션에서 더 촘촘하게 나눌 수 있는 CB: + +| CB | 대상 | 근거 | +|----|------|------| +| `card-samsung` | 삼성카드 경유 결제 | 특정 카드사 장애 격리 | +| `card-hyundai` | 현대카드 경유 결제 | 카드사별 독립 CB | +| `pg-nicepay-request` | 나이스페이 결제 | PG사별 독립 CB | +| `payment-callback-process` | 콜백 내부 처리 | 콜백 처리 장애가 다른 기능에 전파 방지 | + +**현재 과제에서는 6개 CB로 충분. 카드사별 CB는 BIN 데이터와 카드사별 트래픽이 확보된 후 추가.** + +### 13.9 배치 CB 임계치 재검토 — 70% → 50% + +**문제**: 70%는 "PG에 10건 보내서 7건 실패할 때까지 계속 호출"하는 것. +이미 PG는 과부하 상태인데 계속 쏟아부으면 PG 복구를 방해한다. + +**근본 원인**: 배치가 PG를 과부하시킬 수 있다는 전제 자체가 잘못됨. +배치 호출 속도를 제어(Rate Limiter)하면 PG throttling 가능성이 사라지고, CB 임계치를 낮출 수 있다. + +``` +[기존] 배치 무제한 호출 → PG throttling 예상 → CB 70%로 과보정 +[개선] 배치 Rate Limiter(초당 10건) → throttling 없음 → CB 50% +``` + +**결정: 배치 CB도 50%로 통일. Rate Limiter 추가.** + +--- + +## 14. 비동기 결제 고유 Fallback 분석 + +### 14.1 비동기 결제의 본질적 위험 + +``` +[동기] 요청 → 결과 즉시 확정. 끝. +[비동기] 요청 → PENDING → ???(1~5초) → 콜백 → 결과 확정 + ↑ 불확실 구간 +``` + +기존 Fallback은 "PG 요청 시점"에 집중. 비동기 결제의 **"요청 성공 이후 불확실 구간"**에 대한 Fallback이 빠져있었다. + +### 14.2 비동기 고유 장애 시나리오 + +| # | 장애 | 대응 상태 | 빈 곳 | +|---|------|----------|------| +| A1 | 콜백 영구 미수신 | Polling 10초 + 배치 1분 | ✓ | +| A2 | PG에서 영원히 PENDING (PG 크래시) | 배치 감지 | **PENDING 최종 처리 정책 필요** | +| A3 | 콜백이 왔는데 status가 아직 PENDING | 없음 | **PENDING 콜백 무시 정책 필요** | +| A4 | 콜백 채널 전체 불안정 | 없음 | **비동기→동기 전환 Fallback 필요** | +| A5 | 불확실 구간에서 사용자 재결제 | UNIQUE(order_id) | ✓ | + +### 14.3 A2. PENDING 최대 허용 시간 + +| 선택지 | 동작 | 판단 | +|--------|------|------| +| A. 무한 대기 | PG PENDING이면 계속 유지 | 고객 결제 영원히 미확정 | +| **B. 5분 초과 시 FAILED** | **5분 후 FAILED + 재고 복원** | **고객 해방, 재결제 가능** | +| C. 수동 운영 | 운영자 판단 | 운영 부담 | + +**결정: B. PENDING 최대 허용 5분** + +- PG 처리 최대 5초 × 안전 마진 = 5분이면 충분 +- FAILED 후 PG 뒤늦은 SUCCESS 콜백 → 조건부 UPDATE가 무시 (이미 FAILED) +- 불일치 해소: PG 대사(reconciliation) 운영 프로세스 + +### 14.4 A3. PENDING 상태 콜백 처리 + +**콜백 status가 PENDING이면 상태 전이하지 않고 무시.** SUCCESS/FAILED만 처리. + +### 14.5 A4. 콜백 채널 불안정 → 동기 PG 전환 + +비동기 PG의 가장 근본적인 Fallback: **불확실 구간 자체를 제거**. + +``` +[콜백 신뢰율 모니터링] +최근 N건 "PENDING 응답 → 10초 내 콜백 수신" 비율 추적 +→ 50% 미만: 콜백 채널 불안정 판단 +→ 이후 결제를 Toss(동기)로 우선 라우팅 +→ 동기 PG = 요청 즉시 결과 확정 = 불확실 구간 없음 +``` + +| 선택지 | 동작 | 판단 | +|--------|------|------| +| A. Polling으로 커버 | 매번 10초 후 폴링 | UX 지연 | +| **B. 콜백 신뢰율 기반 PG 전환** | **동기 PG로 전환 → 불확실 구간 제거** | 근본 해결 | + +**결정: B** + +### 14.6 → 05 반영 사항 + +| 반영 대상 | 내용 | +|----------|------| +| Section 7.4 | 배치 CB 임계치 70% → 50% + Rate Limiter 추가 | +| Section 8 | 비동기 결제 Fallback 섹션 추가 (A2~A4) | +| Section 10 | 배치 복구에 PENDING 최대 5분 정책 추가 | +| Section 9 | 콜백 처리에 PENDING 상태 무시 정책 추가 | +| Section 8.3 | 콜백 신뢰율 기반 PG 전환 로직 추가 | + +--- + +## 15. Half-Open 전략 고도화 + +### 15.1 현재 설계의 문제점 + +``` +CB Open → 10초 고정 대기 → Half-Open → 실제 결제 요청 2건으로 테스트 → Closed/Open +``` + +| 문제 | 설명 | 쿠팡 규모 영향 | +|------|------|-------------| +| **고정 대기 시간** | PG 2초에 복구 → 8초 낭비 / PG 30초 복구 → 10초마다 실패 반복 | 초당 수천 건 결제 기회 손실 or 복구 방해 | +| **고객을 실험 대상으로 사용** | Half-Open 테스트 = 실제 고객 결제 | PG 미복구 시 해당 고객만 불필요한 실패 경험 | +| **즉시 전량 복구 (Thundering Herd)** | 테스트 2건 성공 → 즉시 Closed → 전체 트래픽 폭주 | 겨우 살아난 PG 다시 과부하 → 재장애 | + +### 15.2 개선 전략 3가지 + +#### 전략 1: Progressive Backoff (점진적 대기 시간) + +고정 대기 대신, **Open이 반복될수록 대기 시간을 늘린다.** + +``` +1차 Open: 5초 → Half-Open (빠르게 복구 시도) + 실패 → 2차 Open: 10초 → Half-Open + 실패 → 3차 Open: 20초 → Half-Open + 실패 → 4차 Open: 40초 → Half-Open + 실패 → 5차+ Open: 60초 (cap) → Half-Open +``` + +| 선택지 | 대기 전략 | 장점 | 단점 | +|--------|----------|------|------| +| A. 고정 10초 (현재) | 항상 10초 | 단순 | 너무 짧거나 너무 길음 | +| **B. 지수 백오프** | **5s → 10s → 20s → 40s → 60s cap** | **짧은 장애 빠른 복구, 긴 장애 복구 방해 안 함** | 구현 복잡도 약간 증가 | +| C. 선형 증가 | 10s → 20s → 30s → ... | 점진적 | 장기 장애 시 증가 속도가 느림 | + +**결정: B. 지수 백오프 (5초 시작, 60초 cap)** + +**근거:** +- 단순 일시 장애(네트워크 flap): 5초 만에 복구 확인 → 최소 다운타임 +- PG 재시작(30초~1분): 5→10→20초 시점에 복구 감지 +- PG 전면 장애(수 분): 60초 간격으로 체크 → PG 부하 최소화 +- Resilience4j 기본 제공은 아니지만, EventPublisher로 Open 횟수 추적하여 구현 가능 + +**구현:** + +```java +@Component +public class ProgressiveBackoffCustomizer { + private final Map openCountMap = new ConcurrentHashMap<>(); + + @PostConstruct + public void customize(CircuitBreakerRegistry registry) { + registry.getAllCircuitBreakers().forEach(cb -> { + cb.getEventPublisher() + .onStateTransition(event -> { + String name = cb.getName(); + if (event.getStateTransition() == StateTransition.OPEN_TO_HALF_OPEN) { + // Half-Open 진입 시 — 다음 Open 대기 시간 계산용 + } + if (event.getStateTransition() == StateTransition.HALF_OPEN_TO_OPEN) { + // Half-Open 실패 → 다시 Open — 카운트 증가 + openCountMap.computeIfAbsent(name, k -> new AtomicInteger(0)) + .incrementAndGet(); + } + if (event.getStateTransition() == StateTransition.HALF_OPEN_TO_CLOSED) { + // 복구 성공 → 카운트 리셋 + openCountMap.computeIfAbsent(name, k -> new AtomicInteger(0)) + .set(0); + } + }); + }); + } + + public Duration getWaitDuration(String cbName) { + int count = openCountMap.getOrDefault(cbName, new AtomicInteger(0)).get(); + long seconds = Math.min(5L * (1L << count), 60L); // 5, 10, 20, 40, 60(cap) + return Duration.ofSeconds(seconds); + } +} +``` + +> **한계**: Resilience4j의 `wait-duration-in-open-state`는 정적 설정이다. +> 동적 변경은 CB를 재생성하거나, Custom CircuitBreaker로 구현해야 한다. +> 현재 과제에서는 고정 대기 + 이벤트 로깅으로 시작하고, 프로덕션에서 동적 변경을 적용한다. + +#### 전략 2: Health Check Probe (헬스 체크 분리) + +**실제 고객 요청 대신 별도 경량 요청으로 PG 상태를 확인한다.** + +``` +[기존] CB Open → 10초 → Half-Open → 실제 결제 요청 2건으로 테스트 + ↑ 고객이 실험 대상 + +[개선] CB Open → 5초 → Health Probe → PG 응답 OK → Half-Open → 실제 트래픽 허용 + ↑ 별도 경량 요청 (고객 무관) +``` + +| PG | Health Check 방법 | 설명 | +|----|-------------------|------| +| Simulator | `GET /api/v1/payments?orderId=HEALTH_CHECK` | 존재하지 않는 orderId로 조회 → 200/404 응답이면 서버 살아있음 | +| Toss | `GET /v1/payments/HEALTH_CHECK` | 존재하지 않는 paymentKey → 404 응답이면 서버 살아있음 | + +**핵심: 200이든 404든 "응답이 왔다"는 것 자체가 PG가 살아있다는 증거.** +500 에러나 타임아웃이면 아직 장애. + +```java +@Component +public class PgHealthChecker { + private final SimulatorFeignClient simulatorClient; + + /** + * PG 서버가 살아있는지 경량 확인. + * 실제 결제 요청이 아닌 조회 요청으로 확인하므로 부작용 없음. + */ + public boolean isSimulatorHealthy() { + try { + simulatorClient.getPaymentByOrderId("HEALTH_CHECK"); + return true; // 200 or 404 — 서버 응답함 + } catch (FeignException.NotFound e) { + return true; // 404 — 서버 살아있음, 데이터만 없음 + } catch (Exception e) { + return false; // 타임아웃, 500, 연결 실패 — 서버 장애 + } + } +} +``` + +``` +[Health Probe 스케줄러] +CB가 Open 상태인 동안: + 1. Progressive Backoff 간격으로 Health Check 실행 + 2. 응답 성공 → CB 수동 전환 (circuitBreaker.transitionToHalfOpenState()) + 3. 응답 실패 → 대기 계속 +``` + +| 선택지 | 테스트 대상 | 장점 | 단점 | +|--------|-----------|------|------| +| A. 실제 고객 요청 (현재) | 결제 요청 | 단순 | 고객이 실험 대상, 돈이 걸림 | +| **B. Health Check Probe** | **경량 조회 요청** | **고객 영향 없음, 부작용 없음** | Probe 스케줄러 추가 | +| C. PG 상태 페이지 구독 | PG 외부 시그널 | 가장 정확 | PG가 상태 페이지를 제공해야 함 | + +**결정: B. Health Check Probe** + +**근거:** +- 결제 요청(POST)은 부작용이 있다 (돈이 걸림). 테스트용으로 쓰면 안 됨 +- 조회 요청(GET)은 멱등하고 부작용 없음 → 안전한 Health Check +- PG 상태 페이지는 외부 의존이므로 우리가 통제 불가 + +#### 전략 3: Phased Ramp-up (단계적 트래픽 복구) + +Half-Open 테스트 성공 → 즉시 Closed 대신, **트래픽을 단계적으로 늘린다.** + +``` +[기존] Half-Open 2건 성공 → 즉시 Closed → 전량 트래픽 유입 → Thundering Herd + +[개선] + Health Probe 성공 → Phase 1: 트래픽 10% 허용 + Phase 1 성공 → Phase 2: 트래픽 50% 허용 + Phase 2 성공 → Phase 3: 100% (Closed) +``` + +| 선택지 | 복구 방식 | 장점 | 단점 | +|--------|----------|------|------| +| A. 즉시 전량 (현재) | 2건 성공 → Closed | 단순 | Thundering Herd | +| **B. 단계적 ramp-up** | **10% → 50% → 100%** | **PG 부하 점진적 증가, 재장애 방지** | 구현 복잡 | +| C. 고정 비율 | 항상 50%만 허용 | 안전 | 복구 완료 후에도 50%만 처리 | + +**결정: 현재 과제는 A(즉시 전량) + Health Probe로 시작. 프로덕션에서 B 적용.** + +**근거:** +- Resilience4j는 단계적 ramp-up을 기본 제공하지 않음 +- 구현하려면 Custom CB 또는 앞단에 Rate Limiter를 동적으로 조절해야 함 +- Multi-PG Fallback이 있으므로, 한 PG의 Thundering Herd가 발생해도 다른 PG가 받아줌 +- 프로덕션에서는 Envoy/Istio 같은 서비스 메시에서 outlier detection + 단계적 복구 적용 + +### 15.3 CB 유형별 Half-Open 전략 + +| CB 유형 | Open → Half-Open 전환 | Half-Open 테스트 | 근거 | +|---------|---------------------|-----------------|------| +| `*-request` (결제) | **Health Probe** (경량 GET) | Probe 성공 → Half-Open → 실제 트래픽 2건 | 결제는 돈이 걸림, 고객을 실험 대상으로 쓰면 안 됨 | +| `*-status-realtime` (실시간 조회) | **Progressive Backoff** (5s→10s→20s→60s) | 실제 조회 요청 2건 | 읽기 전용, 멱등 → 실제 요청으로 테스트 OK | +| `*-status-batch` (배치 조회) | **Progressive Backoff** (10s→20s→40s→60s) | 실제 조회 요청 3건 | 배치는 급하지 않음, 넉넉하게 | + +### 15.4 최종 Half-Open 흐름 + +``` +[결제 요청 CB — pgSimulator-request] + + CB Open + │ + ▼ + [Health Probe 스케줄러 시작] + ├── 5초 후: GET /payments?orderId=HEALTH_CHECK + │ ├── 응답 OK (200/404) → CB.transitionToHalfOpenState() + │ │ → 실제 결제 요청 2건 허용 + │ │ → 2건 성공 → Closed (Open 카운트 리셋) + │ │ → 1건이라도 실패 → Open (카운트 +1) + │ │ + │ └── 응답 실패 → 대기 계속 + │ + ├── 10초 후: 재시도 (카운트 1이면) + ├── 20초 후: 재시도 (카운트 2이면) + ├── 40초 후: 재시도 (카운트 3이면) + └── 60초 후: 재시도 (카운트 4+ → cap) +``` + +``` +[상태 조회 CB — pgSimulator-status-realtime] + + CB Open + │ + ▼ + Progressive Backoff (5s → 10s → 20s → 60s) + │ + ▼ + Half-Open + ├── 실제 조회 요청 2건 허용 (읽기 전용이라 안전) + │ → 성공 → Closed + │ → 실패 → Open (카운트 +1, 다음 대기 시간 증가) +``` + +### 15.5 구현 범위 + +| 전략 | 현재 과제 | 프로덕션 | +|------|----------|---------| +| Progressive Backoff | **구현** (이벤트 리스너 + 수동 전환) | 동적 설정 변경 | +| Health Check Probe | **구현** (request CB에 적용) | 전체 외부 시스템 확장 | +| Phased Ramp-up | 미적용 (Multi-PG가 보완) | 서비스 메시 레벨 적용 | + +### 15.6 → 05 반영 사항 + +| 반영 대상 | 내용 | +|----------|------| +| Section 7 | Half-Open 전략 섹션 추가 (Progressive Backoff + Health Probe) | +| Section 16 | PgHealthChecker 클래스 추가 | +| Section 15 | Phase 2에 Health Probe 구현 항목 추가 | + +--- + +## 16. 가주문/진주문 패턴 분석 (Provisional Order) + +> **배경**: 고객이 주문서를 만들어도 결제까지 진행하지 않을 수 있다. +> 모든 주문서 생성을 DB에 저장하면 불필요한 데이터가 쌓인다. +> Redis에 '가주문'을 만들어두고, 결제 완료 시 '진주문'으로 전환하는 패턴을 검토한다. + +### 16.1 패턴 개요 + +``` +[현재 구조] +주문서 작성 → DB INSERT (Order CREATED) → 결제 요청 → 결제 완료 → PAID + +[가주문/진주문 구조] +주문서 작성 → Redis SET (가주문) → 결제 요청 → 결제 완료 → DB INSERT (진주문 PAID) + │ + └── TTL 만료 (30분) → 자동 삭제 (결제 미진행) +``` + +### 16.2 장점 + +| 항목 | 효과 | +|------|------| +| DB 부하 감소 | 결제 미완료 주문이 DB에 쌓이지 않음 | +| 쓰기 성능 | Redis SET은 DB INSERT 대비 10~100배 빠름 | +| 자동 정리 | TTL로 미결제 가주문 자동 만료, 별도 배치 불필요 | +| 조회 성능 | 진행 중 주문 조회가 Redis에서 즉시 응답 | + +### 16.3 핵심 문제: 재고 차감 시점 + +#### Option A: 가주문 시점에 재고 차감 (Redis에서) + +``` +가주문 생성 → Redis DECR(stock) → 결제 요청 → 성공 → DB INSERT + DB 재고 확정 + → 실패 → Redis INCR(stock) 복원 +``` + +- **장점**: 결제 중 재고 초과 판매(overselling) 방지 +- **문제**: Redis 장애 시 재고 예약 정보 유실 → DB와 불일치 +- **문제**: TTL 만료 시 재고 복원 로직 필요 (Keyspace Notification 또는 배치) + +#### Option B: 진주문 시점에 재고 차감 (DB에서) + +``` +가주문 생성 → Redis SET (재고 미차감) → 결제 요청 → 성공 → DB INSERT + DB 재고 차감 +``` + +- **장점**: 재고 정합성이 DB 트랜잭션으로 보장 +- **문제**: 결제 진행 중 동일 상품에 다른 고객이 주문하면 재고 초과 판매 가능 +- **문제**: 인기 상품(플래시 세일)에서 치명적 + +#### Option C: Redis 예약 + DB 확정 (이중 관리) + +``` +가주문 생성 → Redis DECR(stock) 예약 → 결제 요청 → 성공 → DB INSERT + DB 재고 차감 + → 실패 → Redis INCR(stock) 복원 + +[배치] Redis 재고 ↔ DB 재고 정합성 주기 확인 (5분) +[배치] TTL 만료 가주문의 Redis 재고 미복원 건 감지 + 보정 +``` + +- **장점**: 결제 중 overselling 방지 + DB 최종 정합성 보장 +- **문제**: Redis-DB 이중 관리 복잡성 +- **문제**: Redis 장애 → 재고 예약 불가 → 가주문 생성 불가 (Redis가 SPOF) + +### 16.4 쿠팡 관점 트레이드오프 분석 + +#### 쿠팡에서 이 패턴이 적합한 상황 + +| 상황 | 적합도 | 이유 | +|------|--------|------| +| **장바구니 → 주문서** | ✅ 적합 | 전환율 낮음, DB 저장은 낭비 | +| **플래시 세일** | ✅ 매우 적합 | 초당 수만 건 주문 → DB 직접 쓰기 병목 | +| **일반 주문** | ⚠️ 과도할 수 있음 | 전환율 높으면 대부분 DB 저장 → Redis 경유 비용만 추가 | + +#### 쿠팡에서 우려되는 점 + +| 우려 | 심각도 | 설명 | +|------|--------|------| +| **Redis SPOF** | 🔴 높음 | Redis 장애 = 주문 불가. DB만 있으면 최소한 느리게라도 주문 가능 | +| **재고 이중 관리** | 🔴 높음 | Redis 재고와 DB 재고 불일치 시 운영 혼란 | +| **장애 복구 복잡성** | 🟡 중간 | Redis 재시작 시 가주문 + 재고 예약 복원 필요 | +| **모니터링 난이도** | 🟡 중간 | 주문 데이터가 Redis/DB에 분산 → 통합 조회 어려움 | + +### 16.5 현재 과제 적용 판단 + +``` +[결론: 적용 — Redis 장애 대응까지 설계] + +이유: +1. Resilience 과제의 본질은 "외부 시스템 장애에 대한 대응" +2. PG만 외부 시스템이 아니다 — Redis도 장애가 발생하는 외부 의존성 +3. Redis 장애 시나리오 + Fallback 설계 = 과제의 학습 범위 확장 +4. "장애 포인트가 늘어나니 안 쓴다"는 회피이지 대응이 아니다 +5. 쿠팡 관점: Redis 없는 이커머스는 없다. 장애를 피할 수 없으면 대비해야 한다 + +적용 방식: Option C (Redis 예약 + DB 확정) +- 가주문: Redis Hash (TTL 30분) +- 재고 예약: Redis DECR (가주문 시) + DB UPDATE (진주문 시) +- Redis 장애 시: DB 직접 주문으로 Fallback +``` + +### 16.6 프로젝트 기존 인프라 확인 (중요) + +> **이전 분석에서의 실수**: Redis를 "새로 추가할 외부 의존성"으로 판단하고 +> 의존성 추가, docker-compose 생성, RedisConfig 작성 등을 설계했으나, +> 프로젝트에 **이미 모두 존재**하는 것으로 확인됨. + +#### 이미 존재하는 것 — 건드릴 필요 없음 + +| 항목 | 위치 | 상세 | +|------|------|------| +| Redis 서버 (Master) | `docker/infra-compose.yml` | port 6379, AOF 영속성, healthcheck | +| Redis 서버 (Replica) | `docker/infra-compose.yml` | port 6380, 읽기 전용, Master 복제 | +| spring-boot-starter-data-redis | `modules/redis/build.gradle.kts` | 이미 포함 | +| RedisConfig (Master-Replica) | `modules/redis/.../RedisConfig.java` | LettuceConnectionFactory, Master/Replica 분리 | +| defaultRedisTemplate | RedisConfig | `ReadFrom.REPLICA_PREFERRED` (읽기 → Replica 우선) | +| masterRedisTemplate | RedisConfig (`@Qualifier("redisTemplateMaster")`) | `ReadFrom.MASTER` (쓰기 전용) | +| redis.yml (local 프로필) | `modules/redis/src/main/resources/` | master: localhost:6379, replica: localhost:6380 | +| Testcontainers | `modules/redis` testFixtures | `RedisTestContainersConfig`, `RedisCleanUp` | +| commerce-api 의존성 | `apps/commerce-api/build.gradle.kts` | `implementation(project(":modules:redis"))` 이미 선언 | + +``` +실행 방법: docker-compose -f ./docker/infra-compose.yml up +→ MySQL + Redis Master + Redis Replica + Kafka 전부 기동 +``` + +#### 우리가 추가할 것 — 비즈니스 로직 + Resilience만 + +``` +1. ProvisionalOrderRedisRepository → masterRedisTemplate으로 쓰기 (가주문 생성, 재고 DECR) +2. 가주문 조회 → defaultRedisTemplate으로 읽기 (Replica 우선) +3. Resilience4j CB/Timeout → Redis 호출에 CB 적용 +4. Fallback → Redis CB Open → DB 직접 주문 +5. 재고 정합성 배치 → StockReconcileScheduler +``` + +### 16.7 Redis Resilience 설계 + +#### 16.7.1 Master-Replica를 활용한 장애 분리 + +기존 `RedisConfig`가 이미 Master/Replica를 분리하고 있으므로, +쓰기 장애와 읽기 장애를 **독립적으로** 대응할 수 있다. + +``` +[쓰기 — masterRedisTemplate (Master only)] +가주문 생성 (HSET) + 재고 예약 (DECR) +→ Master 장애 시: CB Open → DB 직접 주문 Fallback + +[읽기 — defaultRedisTemplate (Replica preferred)] +가주문 조회 (HGETALL) +→ Replica 우선 → Master fallback (Lettuce 자동) +→ 둘 다 죽으면: CB Open → DB 조회 Fallback + +핵심: Master가 죽어도 Replica에서 읽기는 가능 +→ 결제 진행 중인 가주문 조회는 Master 장애에 영향 받지 않음 +``` + +#### 16.7.2 Redis가 사용되는 지점 + +| 기능 | Redis 명령 | 사용 Template | 실패 시 영향 | +|------|-----------|-------------|-------------| +| 가주문 생성 | `HSET provisional:order:{orderId}` | `masterRedisTemplate` | 주문 불가 → DB Fallback | +| 가주문 조회 | `HGETALL provisional:order:{orderId}` | `defaultRedisTemplate` | Replica에서 읽기 시도 | +| 재고 예약 | `DECR stock:{productId}` | `masterRedisTemplate` | 주문 불가 → DB Fallback | +| 재고 복원 | `INCR stock:{productId}` | `masterRedisTemplate` | 정합성 배치가 보정 | +| 가주문 삭제 | `DEL provisional:order:{orderId}` | `masterRedisTemplate` | TTL이 보완 | + +#### 16.7.3 Redis Timeout 설정 + +``` +기존 modules/redis의 LettuceClientConfiguration에 Timeout 추가 필요. +현재 RedisConfig에는 타임아웃 설정이 없으므로, Lettuce 레벨에서 설정한다. +``` + +```java +// modules/redis/RedisConfig.java에 타임아웃 추가 +LettuceClientConfiguration clientConfig = LettuceClientConfiguration.builder() + .commandTimeout(Duration.ofMillis(500)) // 커맨드 타임아웃 + .shutdownTimeout(Duration.ofMillis(200)) + .readFrom(readFrom) + .build(); +``` + +``` +타임아웃 근거: +- Redis 단일 명령은 보통 1ms 이내 응답 +- 500ms command timeout = 정상 대비 500배 마진 +- PG 호출(500ms connect + 1000ms read = 1500ms)보다 충분히 빠르게 실패해야 함 +- Redis가 느리면 PG보다 먼저 Fallback 판단해야 함 +``` + +#### 16.7.4 Redis Circuit Breaker + +```yaml +resilience4j: + circuitbreaker: + instances: + # --- Redis 쓰기 (Master) --- + redis-write: + sliding-window-type: COUNT_BASED + sliding-window-size: 10 + failure-rate-threshold: 50 + wait-duration-in-open-state: 5s # Redis는 복구가 빠르므로 짧게 + permitted-number-of-calls-in-half-open-state: 3 + record-exceptions: + - org.springframework.data.redis.RedisConnectionFailureException + - io.lettuce.core.RedisCommandTimeoutException + - org.springframework.data.redis.RedisSystemException + + # --- Redis 읽기 (Replica preferred) --- + redis-read: + sliding-window-type: COUNT_BASED + sliding-window-size: 10 + failure-rate-threshold: 50 + wait-duration-in-open-state: 3s # 읽기는 더 빠르게 재시도 + permitted-number-of-calls-in-half-open-state: 3 +``` + +``` +CB 설계 근거: +- Redis 쓰기/읽기 CB를 분리 → Master 장애가 읽기를 차단하지 않음 + (PG에서 request/status CB를 분리한 것과 동일한 원칙) +- Redis는 PG보다 복구가 빠름 (재시작 수초, Replica→Master 승격 10~30초) +- wait-duration 짧게: 빠르게 Half-Open 시도 +- failure-rate 50%: Redis가 10건 중 5건 실패하면 이미 심각한 장애 +``` + +#### 16.7.5 Redis 장애 시나리오 + 대응 + +| 시나리오 | 현상 | 영향 범위 | 대응 | +|---------|------|----------|------| +| **R1: Master 연결 실패** | ConnectException | 쓰기 불가 | `redis-write` CB Open → DB Fallback | +| **R2: Master 응답 지연** | CommandTimeout (500ms 초과) | 쓰기 지연 | Timeout → CB 기록 → 누적 시 Open | +| **R3: Master 메모리 초과** | OOM 에러 | 쓰기 거부 | CB Open → DB Fallback | +| **R4: Master-Replica 전환** | 복제 끊김, 재연결 | 일시적 쓰기 실패 | CB Open → 5초 후 Half-Open | +| **R5: Master + Replica 모두 다운** | 전체 Redis 장애 | 읽기+쓰기 불가 | 양쪽 CB Open → DB Fallback | +| **R6: Replica만 다운** | Replica 응답 없음 | 없음 | Lettuce가 자동으로 Master에서 읽기 | +| **R7: Redis 데이터 유실** | 재시작 후 가주문 소멸 | 가주문 미조회 | 사용자에게 재주문 안내 | +| **R8: Redis-DB 재고 불일치** | Redis 복원 실패 | 재고 수치 오차 | 정합성 배치가 DB 기준으로 보정 | + +### 16.8 SOT(Source of Truth) 설계 — 데이터 정합성의 기준 + +#### 16.8.1 핵심 원칙: SOT는 단계별로 전환된다 + +``` +"Redis Master가 SOT다" 또는 "DB가 SOT다"가 아니다. +데이터의 생명주기에 따라 SOT가 전환된다. +``` + +| 단계 | SOT | 데이터 위치 | 상태 | +|------|-----|-----------|------| +| **가주문 생성** | Redis Master | Redis에만 존재 | 임시 (TTL 30분) | +| **결제 진행 중** | Redis Master | Redis (가주문) | PG 응답 대기 | +| **결제 완료 → 진주문 전환** | Redis → DB | DB INSERT + Redis DEL | **SOT 이전 시점** | +| **진주문 이후** | DB (MySQL) | DB에만 존재 | 영구 | + +``` +시간축: + + 주문서 작성 결제 완료 이후 + │ │ │ + ▼ ▼ ▼ + [Redis Master = SOT] [SOT 이전] [DB = SOT] + 가주문 HSET DB INSERT(진주문) DB만 정본 + 재고 DECR(예약) DB 재고 차감(확정) Redis에 데이터 없음 + Redis DEL(가주문) +``` + +#### 16.8.2 SOT 전환 시 정합성 위험 3가지 + +##### (1) SOT 전환 실패: DB INSERT 성공 + Redis DEL 실패 + +``` +결제 완료 + → DB INSERT(진주문) ✅ + → DB 재고 차감 ✅ + → Redis DEL(가주문) ❌ ← Master 일시 장애 + +결과: SOT가 DB와 Redis에 동시 존재 + - DB: 진주문 있음, 재고 차감됨 + - Redis: 가주문 남아있음, 재고 예약도 남아있음 + - 재고가 이중으로 잡혀 있는 상태 (DB 차감 + Redis 예약) +``` + +| 대응 | 내용 | +|------|------| +| **즉시** | Redis DEL 실패는 로그 + 모니터링 (즉시 재시도 1회) | +| **TTL 보완** | 가주문 TTL 30분 → 최대 30분 후 자동 정리 | +| **정합성 배치** | 5분 주기: DB에 진주문이 있는데 Redis에도 가주문이 남아있는 건 → Redis DEL | +| **재고 배치** | 5분 주기: DB 재고를 기준으로 Redis 재고 보정 | + +##### (2) Redis Master 장애로 가주문 유실 + +``` +가주문 생성 (Master HSET ✅, 비동기 복제 아직 안 됨) → Master 장애 + +결과: SOT 자체가 사라짐 + - Redis: 데이터 없음 (Master 유실, Replica 복제 안 됨) + - DB: 데이터 없음 (가주문은 DB에 저장하지 않았음) + - 고객은 주문서를 만들었다고 생각하지만, 시스템에 기록 없음 +``` + +| 대응 | 내용 | +|------|------| +| **사용자 안내** | "주문 정보가 만료되었습니다. 다시 주문해주세요" | +| **금전 손실** | 없음 — 결제가 진행되지 않았으므로 | +| **비즈니스 영향** | 가주문은 임시 데이터 → 유실이 크리티컬하지 않음 | +| **설계 근거** | 가주문을 DB에도 저장하면 성능 이점이 사라짐 → 유실 허용이 올바른 트레이드오프 | + +##### (3) Replica 비동기 지연: Write-then-Read 불일치 + +``` +고객: 가주문 생성 요청 + → masterRedisTemplate.HSET(가주문) ✅ → (비동기 복제 시작) + +고객: 방금 만든 가주문 조회 요청 (100ms 후) + → defaultRedisTemplate.HGETALL(가주문) → Replica에서 읽기 + → Replica에 아직 복제 안 됨 → 없음! + → "방금 만든 주문이 안 보여요" +``` + +| 대응 | 내용 | +|------|------| +| **원칙** | **Write 직후 Read는 반드시 Master에서 읽는다** | +| **구현** | 가주문 생성 직후 응답에 가주문 정보를 포함하여 반환 (추가 조회 불필요) | +| **일반 조회** | 목록/상태 확인 등은 `defaultRedisTemplate` (Replica 우선) OK — sub-ms 지연은 무시 가능 | + +```java +// ❌ 잘못된 패턴: Write → 즉시 Replica에서 Read +masterRedisTemplate.opsForHash().putAll("provisional:order:" + orderId, data); +defaultRedisTemplate.opsForHash().entries("provisional:order:" + orderId); // Replica → 없을 수 있음 + +// ✅ 올바른 패턴 1: Write → Master에서 Read +masterRedisTemplate.opsForHash().putAll("provisional:order:" + orderId, data); +masterRedisTemplate.opsForHash().entries("provisional:order:" + orderId); // Master → 항상 있음 + +// ✅ 올바른 패턴 2: Write 응답에 데이터 포함 (추가 Read 불필요) +ProvisionalOrderResult result = ProvisionalOrderResult.provisional(orderId, data); +return result; // 조회 없이 생성 시점의 데이터를 그대로 반환 +``` + +#### 16.8.3 SOT 설계 원칙 정리 + +``` +1. DB가 최종 SOT (Source of Truth) + - 진주문, 결제, 재고의 최종 정본은 항상 DB + - 모든 정합성 보정은 DB 기준으로 수행 + +2. Redis는 임시 SOT + - 가주문, 재고 예약은 Redis Master가 SOT + - 임시 데이터이므로 유실 허용 (금전 손실 없는 범위) + +3. SOT 이전은 원자적이어야 한다 + - DB INSERT(진주문) + DB 재고 차감 = 하나의 트랜잭션 + - Redis DEL(가주문)은 트랜잭션 밖 → 실패 허용 (TTL + 배치가 보정) + +4. Write-then-Read는 SOT에서 읽는다 + - 가주문 생성 직후 조회 → Master에서 읽기 + - 일반 조회 → Replica 우선 (지연 허용) + +5. 정합성 배치는 항상 DB → Redis 방향 + - Redis → DB 보정은 없음 (Redis는 임시 데이터) + - DB 기준으로 Redis를 맞춘다 +``` + +--- + +### 16.9 Redis Fallback: DB 직접 주문 (Degraded Mode) + +#### 핵심 원칙 + +> Redis가 죽어도 주문은 받아야 한다. +> 가주문 패턴은 **성능 최적화**이지 **필수 경로**가 아니다. +> Redis 장애 시 DB 직접 주문으로 떨어지면 느리지만 동작한다. + +#### Fallback 흐름 + +``` +[정상 경로 — Redis 사용] +주문 요청 → redis-write CB Closed + → masterRedisTemplate.DECR(재고) + HSET(가주문, TTL 30m) + → 결제 요청 + → 성공 → DB INSERT(진주문) + DB 재고 차감 + masterRedisTemplate.DEL(가주문) + +[Fallback 경로 — DB 직접] +주문 요청 → redis-write CB Open + → DB INSERT(Order CREATED) + DB 재고 차감 (기존 로직 그대로) + → 결제 요청 + → 성공 → DB UPDATE(PAID) +``` + +```java +@Component +public class ProvisionalOrderService { + + @Qualifier("redisTemplateMaster") + private final RedisTemplate masterRedisTemplate; + private final OrderRepository orderRepository; + private final StockRepository stockRepository; + + /** + * Redis CB Open 시 DB 직접 주문으로 Fallback + */ + @CircuitBreaker(name = "redis-write", fallbackMethod = "createOrderViaDatabaseFallback") + public ProvisionalOrderResult createProvisionalOrder(OrderCreateRequest request) { + // Redis 경로: 가주문 생성 + 재고 예약 (masterRedisTemplate 사용) + masterRedisTemplate.opsForHash().putAll( + "provisional:order:" + request.getOrderId(), + request.toMap() + ); + masterRedisTemplate.expire( + "provisional:order:" + request.getOrderId(), + Duration.ofMinutes(30) + ); + masterRedisTemplate.opsForValue().decrement("stock:" + request.getProductId()); + + return ProvisionalOrderResult.provisional(request.getOrderId()); + } + + /** + * Fallback: DB 직접 주문 (현재 구조와 동일) + */ + public ProvisionalOrderResult createOrderViaDatabaseFallback( + OrderCreateRequest request, Exception e) { + log.warn("Redis 장애 — DB 직접 주문으로 Fallback. orderId={}", request.getOrderId(), e); + + Order order = Order.create(request); + orderRepository.save(order); + stockRepository.decrease(request.getProductId(), request.getQuantity()); + + return ProvisionalOrderResult.directOrder(order.getId()); + } +} +``` + +#### Fallback 시 달라지는 점 + +| 항목 | 정상 (Redis) | Fallback (DB) | +|------|-------------|---------------| +| 주문 저장 위치 | Redis (TTL 30분) | DB (영구) | +| 재고 차감 | Redis DECR (빠름) | DB UPDATE + 비관적 락 (느림) | +| 미결제 정리 | TTL 자동 만료 | 배치로 CREATED → CANCELLED 전환 | +| 쓰기 성능 | ~1ms | ~10~50ms | +| 동시성 처리 | Redis 단일 스레드 (원자적) | DB 락 경합 가능 | +| 읽기 | Replica에서 조회 (Master 장애 무관) | DB 조회 | + +### 16.10 Redis-DB 재고 정합성 배치 + +```java +/** + * 5분 주기: Redis 재고와 DB 재고 비교 + 불일치 보정 + * + * 불일치 원인: + * - Redis 재시작으로 DECR 이력 유실 + * - 가주문 TTL 만료 시 INCR 누락 + * - Fallback 모드에서 DB만 차감하고 Redis 미반영 + */ +@Scheduled(fixedRate = 300_000) // 5분 +public void reconcileStock() { + List products = productRepository.findAllActive(); + for (Product product : products) { + // 읽기: defaultRedisTemplate (Replica 우선) — 보정 판단은 읽기로 충분 + String redisStockStr = defaultRedisTemplate.opsForValue().get("stock:" + product.getId()); + Integer redisStock = redisStockStr != null ? Integer.parseInt(redisStockStr) : null; + int dbStock = product.getStock().getValue(); + + if (redisStock == null || Math.abs(redisStock - dbStock) > 0) { + log.warn("재고 불일치 감지: productId={}, redis={}, db={}", + product.getId(), redisStock, dbStock); + // 쓰기: masterRedisTemplate (Master) — DB 기준으로 Redis 보정 + masterRedisTemplate.opsForValue().set("stock:" + product.getId(), String.valueOf(dbStock)); + } + } +} +``` + +``` +정합성 원칙: +- DB가 항상 정본 (Source of Truth) +- Redis는 성능 캐시 + 임시 저장소 +- 불일치 시 DB 기준으로 Redis를 보정 +- 보정 이력은 로그로 남김 (운영 추적용) +``` + +### 16.11 가주문/진주문 전체 아키텍처 + +``` +[정상 흐름] +Client → API → redis-write CB Closed? + ├── Yes → masterRedisTemplate.DECR(stock) + HSET(가주문, TTL 30m) + │ → PG 결제 요청 + │ → 콜백/폴링으로 결과 수신 + │ → 성공: DB INSERT(진주문) + DB 재고 차감 + masterRedisTemplate.DEL(가주문) + │ → 실패: masterRedisTemplate.INCR(stock) + DEL(가주문) + │ + └── No (CB Open) → DB INSERT(Order CREATED) + DB 재고 차감 + → PG 결제 요청 (기존 로직과 동일) + → 콜백/폴링으로 결과 수신 + → 성공: DB UPDATE(PAID) + → 실패: DB 재고 복원 + DB UPDATE(CANCELLED) + +[가주문 조회 — Replica 우선] +Client → API → defaultRedisTemplate.HGETALL(가주문) + → Replica 응답 → 성공 + → Replica 다운 → Lettuce가 자동으로 Master에서 읽기 + → 모두 다운 → redis-read CB Open → DB 조회 Fallback + +[배치] +- 5분 주기: Redis ↔ DB 재고 정합성 보정 (DB가 Source of Truth) +- 30분 주기: DB에서 CREATED 상태 30분 초과 건 → CANCELLED 전환 (Fallback 모드 잔여분) +``` + +### 16.12 아키텍처 교훈 + +> **1차 판단**: "외부 의존성을 추가하면 장애 포인트가 늘어나니 안 쓴다" — **회피** +> **2차 판단**: "외부 의존성을 추가하고, Resilience도 함께 설계한다" — **대응** +> **3차 판단 (현재)**: "이미 있는 인프라를 확인하지 않고 '추가' 논의를 한 것 자체가 실수" — **확인 우선** +> +> **교훈**: 설계 전에 기존 인프라를 반드시 확인한다. +> 이 프로젝트에서 Redis는 "추가할 것"이 아니라 "이미 Master-Replica까지 구성된 인프라"였다. +> `modules/redis`, `docker/infra-compose.yml`을 먼저 확인했으면 불필요한 설계 시간을 줄일 수 있었다. + +### 16.13 → 05 반영 사항 + +| 반영 대상 | 내용 | +|----------|------| +| Section 7 | Redis CB 2개 (`redis-write`, `redis-read`) 설정 추가 | +| Section 8 | Redis Fallback (DB 직접 주문) 추가 | +| Section 15 | Phase 1에 가주문 모델, Phase 3에 Redis Resilience + 정합성 배치 | +| Section 16 | Redis 관련 패키지 구조 추가 (RedisConfig는 이미 존재하므로 제외) | +| ~~의존성~~ | ~~spring-boot-starter-data-redis 추가~~ → **이미 존재, 불필요** | +| 신규 | SOT 전환 원칙 + Write-then-Read 패턴 → Master 읽기 원칙 | + +### 16.14 정합성 갭 분석 + 대응 방안 + +> 가주문/진주문 패턴에서 TTL, 배치 주기, SOT 전환 사이에 정합성 갭이 발생하는 +> 모든 지점을 식별하고, 산술적 근거에 기반한 대응 방안을 설계한다. + +#### 16.14.1 갭 전수 목록 + +| # | 갭 발생 지점 | 갭 시간 | 핵심 위험 | 심각도 | +|---|------------|---------|---------|--------| +| G1 | Replica 비동기 복제 지연 | < 1ms | Write-then-Read 불일치 | 🟢 | +| G2 | Outbox 폴러 주기 | 최대 5초 | 결제 요청 지연 | 🟢 | +| G3 | 배치 복구 주기 | 최대 1분 | 결제 상태 미확정 | 🟡 | +| G4 | PENDING 최대 대기 | 최대 5분 | 고객 체감 지연 | 🟡 | +| G5 | 재고 정합성 배치 + 가주문 미고려 | 최대 5분 | **overselling** | 🔴 | +| G6 | SOT 전환 실패 (Redis DEL 실패) | 5분~30분 | 재고 이중 차감 | 🟡 | +| G7 | 가주문 TTL 만료 + 재고 미복원 | 최대 30분 | **재고 영구 감소 누적** | 🔴 | +| G8 | Fallback 모드 미결제 정리 | 최대 30분 | 재고 묶임 | 🟡 | + +#### 16.14.2 배치 주기의 산술적 근거 + +##### 현재 과제 규모 + +``` +상품 수: ~100개 (활성 상품) +주문량: ~10건/분 (테스트 환경) +가주문 동시 존재: ~5건 (10건/분 × 30% 미결제 × 30분 TTL → 실제로는 소수) +``` + +##### 재고 정합성 배치 1회 비용 + +``` +[Redis 읽기] 100개 상품 재고 GET + → 100 × 0.1ms = 10ms (pipelining 시 2ms) + +[DB 읽기] 상품 재고 조회 (1 query) + → SELECT id, stock FROM product WHERE active = true + → ~5ms + +[Redis 가주문 스캔] 진행 중 가주문 수 파악 + → SCAN pattern "provisional:order:*" + → ~5건 → < 1ms + +[Redis 보정 쓰기] 불일치 건 SET + → 평균 2~3건 × 0.1ms = < 1ms + +총 비용: ~15ms / 회 +``` + +##### 배치 주기별 시스템 부하율 + +| 주기 | 부하율 (15ms / 주기) | 판단 | +|------|---------------------|------| +| 5분 (300초) | 15ms / 300s = **0.005%** | 과도하게 여유로움 | +| 1분 (60초) | 15ms / 60s = **0.025%** | 여유로움 | +| 10초 | 15ms / 10s = **0.15%** | 충분히 가능 | +| 5초 | 15ms / 5s = **0.3%** | 가능 | +| 1초 | 15ms / 1s = **1.5%** | 가능하지만 불필요 | + +``` +결론: 현재 과제 규모에서 배치 주기 5분은 과도하게 느슨하다. +→ 10초~30초 주기로 변경 가능 (시스템 부하 1% 미만) +``` + +##### 쿠팡 규모 시뮬레이션 + +``` +상품 수: 100,000개 +주문량: 10,000건/분 (피크) +가주문 동시 존재: ~90,000건 (10,000/분 × 30% × 30분) + +배치 1회 비용: + [Redis] 100,000 GET (pipelining 100배치): ~100ms + [DB] 1 query (인덱스 사용): ~200ms + [SCAN] 90,000건 패턴 스캔: ~900ms + [보정] ~1,000건 SET: ~100ms + 총 비용: ~1,300ms / 회 + +주기별 부하율: + 5분: 1.3s / 300s = 0.43% → 충분 + 1분: 1.3s / 60s = 2.2% → 허용 가능 + 30초: 1.3s / 30s = 4.3% → 허용 가능 + 10초: 1.3s / 10s = 13% → 피크 시 부담 + 5초: 1.3s / 5s = 26% → 위험 +``` + +``` +쿠팡 규모 결론: +- 30초~1분 주기가 최적 (부하 2~4%) +- SCAN이 병목 → 가주문 키를 별도 SET으로 관리하면 SCAN 제거 가능 + (SADD "provisional:orders" orderId → SMEMBERS로 O(1) 조회) + +현재 과제: +- 10초~30초 주기 적용 (부하 0.15% 이하) +- 30분은 과도하게 느슨 → 재고 묶임 시간 불필요하게 김 +``` + +#### 16.14.3 G7 대응: 가주문 TTL 만료 시 재고 미복원 문제 + +##### 문제 재정의 + +``` +Redis TTL 만료 = Key 삭제만 실행. 연관 로직(재고 INCR)은 실행되지 않는다. + +가주문 생성: HSET + DECR(stock) +30분 후 TTL 만료: HSET 삭제 ✅ / INCR(stock) ❌ +→ 재고가 영구적으로 감소된 상태로 남음 +``` + +##### 대응: 가주문 만료 감지 배치 (Proactive Expiry Scanner) + +``` +[기존 설계] TTL 만료를 기다림 → 30분 후 Key 사라짐 → 재고 미복원 +[개선 설계] TTL 만료 전에 선제적으로 감지 → 재고 복원 + 가주문 삭제 +``` + +```java +/** + * 가주문 만료 감지 배치 — 30초 주기 + * + * TTL이 30초 미만인 가주문을 선제적으로 정리: + * 1. 가주문의 TTL 잔여 시간 확인 + * 2. TTL < 30초 → 결제 미진행으로 판단 + * 3. 재고 INCR(복원) + 가주문 DEL + * + * 이렇게 하면 TTL 만료 시점에는 이미 정리 완료 → 재고 미복원 문제 없음 + */ +@Scheduled(fixedRate = 30_000) // 30초 +public void cleanupExpiringProvisionalOrders() { + // provisional:orders SET에서 모든 가주문 ID 조회 (SCAN 대신 SMEMBERS) + Set orderIds = masterRedisTemplate.opsForSet() + .members("provisional:orders"); + + if (orderIds == null) return; + + for (String orderId : orderIds) { + String key = "provisional:order:" + orderId; + Long ttl = masterRedisTemplate.getExpire(key, TimeUnit.SECONDS); + + if (ttl == null || ttl < 0) { + // 이미 만료됨 — SET에서만 제거 + masterRedisTemplate.opsForSet().remove("provisional:orders", orderId); + continue; + } + + if (ttl < 30) { + // TTL 30초 미만 — 결제 미진행으로 판단, 선제 정리 + String productId = (String) masterRedisTemplate.opsForHash() + .get(key, "productId"); + String quantity = (String) masterRedisTemplate.opsForHash() + .get(key, "quantity"); + + // 재고 복원 + masterRedisTemplate.opsForValue() + .increment("stock:" + productId, Long.parseLong(quantity)); + + // 가주문 삭제 + SET에서 제거 + masterRedisTemplate.delete(key); + masterRedisTemplate.opsForSet().remove("provisional:orders", orderId); + + log.info("가주문 선제 정리: orderId={}, productId={}, 재고 +{}", + orderId, productId, quantity); + } + } +} +``` + +``` +가주문 생성 시: + masterRedisTemplate.opsForSet().add("provisional:orders", orderId); + masterRedisTemplate.expire("provisional:order:" + orderId, jitteredTtl()); + // jitteredTtl(): 25분~35분 (±5분 Jitter 적용) + +이점: +- SCAN 불필요 → SMEMBERS로 O(1) 조회 +- TTL 만료 전에 정리 → 재고 미복원 갭 0으로 감소 +- 30초 주기 → 최대 갭 60초 (기존 30분에서 30배 단축) +- TTL Jitter로 동시 만료 분산 → 배치 1회 피크 부하 24배 감소 +``` + +##### 배치 주기 30초의 근거 + +``` +가주문 TTL: 30분 = 1800초 +배치 주기: 30초 +TTL 감지 기준: 잔여 30초 미만 + +→ 배치가 30초마다 실행, TTL 30초 미만 감지 +→ 최악의 경우: 배치 직후 TTL이 30초가 된 건 → 다음 배치(30초 후)에서 감지 +→ 최대 갭: 30초 + 30초 = 60초 (TTL 만료 시점 기준) +→ 실제로는 대부분 30초 이내에 감지 + +비용 (현재 과제): + SMEMBERS ~5건: < 1ms + TTL 확인 5건: 5 × 0.1ms = 0.5ms + 총 비용: ~2ms / 회 + 부하율: 2ms / 30s = 0.007% +``` + +#### 16.14.4 TTL Jitter — 동시 만료 방지 + +##### 문제 + +``` +가주문 TTL이 모두 30분으로 동일하면: + +플래시 세일 12:00:00 시작 → 10초 내 1000건 주문 +→ 12:30:00~12:30:10 사이에 1000건 동시 만료 +→ Proactive Expiry Scanner에 1000건이 한 번에 걸림 +→ 배치 1회 처리 부하 급증 + +비용 계산 (Proactive Expiry Scanner): + SMEMBERS: ~1ms + TTL 확인 1000건: 1000 × 0.1ms = 100ms + INCR + DEL + SREM (만료 건): 최대 1000건 × 0.5ms = 500ms + → 배치 1회 비용: ~601ms (평상시 ~2ms 대비 300배) +``` + +##### 대응: TTL에 ±5분 Jitter 적용 + +``` +Base TTL: 30분 (1800초) +Jitter 범위: ±5분 (±300초) +실제 TTL: 25분 ~ 35분 (1500초 ~ 2100초) + +1000건 주문이 10초 이내에 몰려도: + 만료 시점 분포: 12:25:00 ~ 12:35:00 (10분 범위) + → 분당 평균 ~100건 만료 + → 배치(30초) 1회당 ~50건 처리 + → 배치 1회 비용: ~25ms (601ms 대비 24배 감소) +``` + +##### Jitter 적용 코드 + +```java +private static final long BASE_TTL_SECONDS = 1800; // 30분 +private static final long JITTER_RANGE = 300; // ±5분 + +private Duration jitteredTtl() { + long jitter = ThreadLocalRandom.current().nextLong(-JITTER_RANGE, JITTER_RANGE + 1); + return Duration.ofSeconds(BASE_TTL_SECONDS + jitter); +} + +// 가주문 생성 시 +masterRedisTemplate.expire("provisional:order:" + orderId, jitteredTtl()); +``` + +##### Jitter + Proactive Expiry Scanner 조합 효과 + +``` +Jitter 없이 Proactive Scanner만: + - 동시 만료 시점에 배치 1회 비용 급증 (601ms) + - 배치 주기(30초) 내에 처리 완료되므로 "동작은 함" + - 그러나 Redis 순간 부하 + 스케줄러 지연 가능 + +Jitter만 적용 (Scanner 없이): + - 만료 시점 분산되지만, TTL 만료 = Key 삭제만 → 재고 미복원 문제 여전 + - G7 문제 자체를 해결하지 못함 + +Jitter + Proactive Scanner 조합: + - Scanner가 G7을 해결 (선제 정리 → 재고 복원) + - Jitter가 Scanner의 부하를 분산 (피크 24배 감소) + → 서로 다른 문제를 해결하는 보완 관계 + +결론: 둘 다 적용. Jitter는 보험, Scanner는 핵심. +``` + +##### Jitter 범위 ±5분의 근거 + +``` +가주문 유효 기간 관점: + 최소 TTL: 25분 → 결제 완료에 충분한 시간 (PG 비동기 처리 최대 5초 + 여유) + 최대 TTL: 35분 → 기존 30분 대비 5분 연장. 재고 점유 약간 증가 + +재고 점유 영향: + 평균 TTL은 여전히 30분 (균등 분포) + → 평균 재고 점유 시간 변화 없음 + 최대 5분 추가 점유 → 상품당 1건 기준, 5분간 1개 추가 점유 + → 실질적 영향 무시 가능 + +Jitter 비율: + ±5분 / 30분 = ±16.7% + → 일반적 Jitter 권장 범위 (10~30%) 내 +``` + +--- + +#### 16.14.5 G5 대응: 재고 정합성 배치의 Lost Update 문제 + +##### 문제 재정의 + +``` +현재 설계: DB 재고를 읽어서 Redis에 SET (덮어쓰기) + +배치 스레드: 요청 스레드: + 1. DB 재고 조회: 10개 + 2. DECR stock:123 → 9개 (가주문 생성) + 3. SET stock:123 10 + → Redis 재고가 10으로 복구됨 + → DECR이 사라짐! (Lost Update) + → overselling 가능 +``` + +##### 대안 비교: SET vs DELETE(evict) vs Lua + +###### 방안 1: SET (현재 설계) — Lost Update 위험 + +``` +배치: SET stock:123 + +문제: SET은 절대값 덮어쓰기 +→ 배치 읽기 ~ SET 사이에 DECR이 끼면 Lost Update +``` + +###### 방안 2: DELETE (evict) — Stampede 위험 + +``` +배치: DEL stock:123 +→ 다음 읽기 시 cache miss → DB에서 로드 → SET + +문제 1: Cache Stampede + DEL 직후 100명이 동시 조회 + → 100개 cache miss → 100개 DB 조회 → 동일 쿼리 폭주 + +문제 2: DECR 실패 + DEL 직후 DECR stock:123 → key 없음 → 에러 + → 가주문 생성 실패 + → "재고 확인 중" 에러 응답 → 고객 이탈 +``` + +``` +⚠️ 스탬피드 정리: + +스탬피드는 DELETE(evict) 시 발생하는 문제다. SET 시에는 발생하지 않는다. + +- DELETE → key 사라짐 → 동시 다발 cache miss → DB 폭주 = 스탬피드 +- SET → key 덮어씀 → 읽기 요청은 항상 hit → 스탬피드 없음 + +SET의 문제는 스탬피드가 아니라 Lost Update(동시 DECR 유실)다. +``` + +###### 방안 3: SET + DELETE 조합 — 문제 분리 + +``` +의도: SET으로 재고 보정 + DELETE로 만료 가주문 정리 + +배치: + 1. 만료 가주문 정리: INCR(재고 복원) + DEL(가주문) ← 가주문에 대한 DELETE + 2. 재고 보정: SET stock:123 <보정값> ← 재고에 대한 SET + +→ 가주문 DELETE와 재고 SET은 대상이 다른 키 +→ 가주문 키(provisional:order:*)에 DELETE → 스탬피드 무관 (캐시용이 아님) +→ 재고 키(stock:*)에 SET → Lost Update 위험은 여전히 존재 +``` + +###### 방안 4: Lua Script — 원자적 보정 (권장) + +```lua +-- stock_reconcile.lua +-- 원자적으로: 현재값 확인 → 진행 중 가주문 반영 → 보정 + +local current = tonumber(redis.call('GET', KEYS[1]) or '0') +local dbStock = tonumber(ARGV[1]) +local activeOrders = tonumber(ARGV[2]) +local expected = dbStock - activeOrders + +if current ~= expected then + redis.call('SET', KEYS[1], tostring(expected)) + return expected -- 보정됨 +end +return -1 -- 보정 불필요 +``` + +``` +Lua가 해결하는 이유: + +Redis는 Lua 스크립트를 단일 스레드에서 원자적으로 실행한다. + +배치 스레드: 요청 스레드: + 1. DB 재고 조회: 10개 + 2. 가주문 수 조회: 3건 + 3. DECR stock:123 → 이 시점에서 6개 + 4. Lua 실행 (원자적): + GET stock:123 → 6 (DECR 반영된 값) + expected = 10 - 3 = 7 + 6 ≠ 7 → SET stock:123 7 + → Redis 재고 7 (정확!) + +Lua 실행 중에는 다른 명령(DECR 포함)이 끼어들 수 없다. +→ Lost Update 불가능 +``` + +``` +그런데 완벽하지는 않다: + +DB 조회(step 1) ~ Lua 실행(step 4) 사이에 가주문이 추가/삭제될 수 있음. +→ step 2의 가주문 수가 step 4 시점과 다를 수 있음. + +이 갭을 줄이려면: +- Lua 안에서 SMEMBERS로 가주문 수를 직접 세기 (DB 조회는 밖에서) +- 가주문 수 조회와 SET이 하나의 Lua에서 원자적으로 실행됨 +``` + +```lua +-- stock_reconcile_v2.lua +-- 가주문 수도 Redis 안에서 원자적으로 조회 + +local stockKey = KEYS[1] -- stock:123 +local provisionalSetKey = KEYS[2] -- provisional:orders:123 (상품별) +local dbStock = tonumber(ARGV[1]) -- DB 재고 + +local current = tonumber(redis.call('GET', stockKey) or '0') +local activeCount = redis.call('SCARD', provisionalSetKey) -- 진행 중 가주문 수 +local expected = dbStock - activeCount + +if current ~= expected then + redis.call('SET', stockKey, tostring(expected)) + return expected +end +return -1 +``` + +``` +v2가 해결하는 것: +- GET(현재 재고) + SCARD(가주문 수) + SET(보정) 이 원자적 +- DECR/INCR이 끼어들 수 없음 +- DB 재고만 외부에서 조회하고, 나머지는 Lua 안에서 처리 + +남은 갭: +- DB 조회 ~ Lua 실행 사이에 DB 재고가 바뀔 수 있음 + (진주문 전환 등으로 DB UPDATE 발생) +- 그러나 이 갭은 수~수십 ms이며, 다음 배치(30초)에서 보정됨 +- 실무에서 이 정도 갭은 허용 가능 +``` + +#### 16.14.6 최종 대응 방안 요약 + +| 갭 | 기존 | 개선 | 개선 후 갭 | +|-----|------|------|----------| +| **G7: 가주문 만료 + 재고 미복원** | TTL 만료에 의존 (30분) | 선제 만료 배치 30초 주기 + TTL Jitter ±5분 | **최대 60초** (피크 부하 24배 감소) | +| **G5: 재고 배치 Lost Update** | SET 덮어쓰기 (Lost Update 위험) | Lua Script 원자적 보정 (v2) | **0** (원자적) | +| **G6: SOT 이중 존재** | TTL 30분 의존 | 정합성 배치에서 진주문 존재 여부 확인 + DEL | **최대 30초** (배치 주기) | + +#### 16.14.7 개선된 배치 구조 + +``` +[1] 가주문 선제 만료 배치 — 30초 주기 + → SMEMBERS provisional:orders + → TTL < 30초인 건: INCR(재고 복원) + DEL(가주문) + SREM + → TTL = -2 (이미 만료): SREM만 + +[2] 재고 정합성 배치 — 30초 주기 (Lua Script v2) + → DB 재고 조회 + → 상품별 Lua 실행: GET(현재) + SCARD(가주문 수) + SET(보정) + → 원자적 → Lost Update 없음 + +[3] SOT 정합성 배치 — 30초 주기 + → Redis 가주문 중 DB에 진주문(PAID)이 존재하는 건 감지 + → Redis DEL(가주문) + INCR(재고 복원) + SREM + +[1][2][3]을 하나의 스케줄러에서 순차 실행 가능: + 총 비용 ~20ms / 30초 = 부하율 0.07% +``` + +### 16.15 → 05 반영 사항 (추가) + +| 반영 대상 | 내용 | +|----------|------| +| Section 10 | 배치 복구 주기: 재고 정합성 5분 → 30초, Lua Script 적용 | +| Section 13 | 가주문 선제 만료 배치 추가 | +| Section 15 | Phase 1에 TTL Jitter 적용, Phase 3에 Lua Script 구현 + 가주문 만료 배치 항목 추가 | +| Section 16 | StockReconcileLuaScript, ProvisionalOrderExpiryScheduler 추가 | + +--- + +## 17. Throttling / Sliding Window 점검 + +> **배경**: 현재 구현 계획에 Throttling이 어디까지 적용되어 있는지 점검하고, +> Sliding Window 방식 적용의 타당성을 쿠팡 관점에서 분석한다. + +### 17.1 현재 구현 계획의 Throttling 현황 + +| 위치 | 적용 여부 | 방식 | 설정 | +|------|----------|------|------| +| **인바운드 API** (클라이언트 → 우리) | ❌ 미적용 | - | - | +| **아웃바운드 PG 결제 요청** (우리 → PG) | ❌ 미적용 | CB가 간접 보호 | - | +| **아웃바운드 PG 배치 조회** (우리 → PG) | ✅ 적용 | Fixed Window Rate Limiter | 10 req/sec | +| **CB Sliding Window** | ✅ 적용 | COUNT_BASED | size: 10 | + +**진단**: 배치 조회에만 Rate Limiter가 있고, 인바운드/아웃바운드 결제 요청에는 Throttling이 없다. + +### 17.2 Throttling이 필요한 3가지 지점 + +#### (1) 인바운드 API Throttling — 클라이언트 요청 제한 + +``` +[클라이언트] ---(초당 1000건)--→ [결제 API] --→ [PG] +``` + +- **현재**: 제한 없음. 클라이언트가 무제한 요청 가능 +- **위험**: 트래픽 급증 시 PG로의 요청도 급증 → PG CB Open → 전체 결제 불가 +- **필요성**: 🟡 중간 (현재 과제 범위에서는 API Gateway/Nginx 레벨 처리가 일반적) + +``` +[적용 방안] +- 현재 과제: Spring Boot 내 Rate Limiter로 간단 적용 가능 +- 프로덕션: API Gateway (Kong, Nginx) 레벨에서 처리 +- 이유: 인바운드 Throttling은 인프라 레벨 관심사이며, + 애플리케이션에서 하면 인스턴스별로 설정이 분산됨 +``` + +#### (2) 아웃바운드 PG 결제 요청 Throttling — PG 보호 + +``` +[결제 API] ---(동시 100건)--→ [PG] → 과부하 +``` + +- **현재**: CB가 실패율 기반으로 간접 보호하지만, "정상 상황에서의 과부하"는 막지 못함 +- **위험**: 플래시 세일 → 동시 결제 폭증 → PG 처리 한계 초과 → 응답 지연 → CB Open +- **필요성**: 🔴 높음 (PG 계약에 TPS 제한이 있는 경우 필수) + +```yaml +# 결제 요청 Rate Limiter +resilience4j: + ratelimiter: + instances: + pgPaymentRequest: + limit-for-period: 50 # 초당 최대 50건 + limit-refresh-period: 1s + timeout-duration: 2s # 초과 시 2초 대기 후 재시도 +``` + +#### (3) 아웃바운드 PG 배치 조회 Throttling — 이미 적용됨 + +- **현재**: `pgStatusBatch` Rate Limiter (10 req/sec) ✅ +- **상태**: 적절하게 적용됨 + +### 17.3 Fixed Window vs Sliding Window — 상세 트레이드오프 + +#### Fixed Window (현재 적용 방식) + +``` +|-------- 1초 구간 --------|-------- 1초 구간 --------| +[ 10건 허용 ][ 10건 허용 ] + ↑ + 구간 경계에서 리셋 +``` + +- **구현**: Resilience4j `RateLimiter` 기본 방식 +- **장점**: 제로 코드 (어노테이션만으로 적용), 메트릭/Actuator 자동 연동 +- **문제**: **경계 돌파(Boundary Burst)** + +``` +예시: limit = 50 req/sec (결제 요청 Rate Limiter) + +시간: 0.0s ─────── 0.95s ── 1.0s ── 1.05s ─────── 2.0s +구간: [──── 1초 구간 A ────][──── 1초 구간 B ────] +요청: 50건 ↗ 50건 ↗ + (0.95s) (1.0s) + +→ 0.1초 동안 100건 통과! (의도한 50 req/sec의 2배) +→ PG가 계약 TPS 50인데 순간 100건을 받는 상황 +``` + +경계 돌파가 실제로 발생하는 조건: +1. 동시 요청이 **윈도우 끝**에 몰려야 함 +2. 그 직후 새 윈도우 시작 시점에도 요청이 몰려야 함 +3. 즉, **트래픽이 특정 시점에 집중**될 때 발생 + +``` +쿠팡에서 경계 돌파가 발생하는 실제 상황: +- 플래시 세일 시작 직후 (정각에 트래픽 폭증) +- 쿠폰 발급 이벤트 (특정 시각에 사용자 집중) +- 타임딜 만료 직전 (마감 효과로 결제 집중) + +→ 이커머스에서는 "트래픽이 특정 시점에 집중"되는 것이 일상 +→ Fixed Window의 경계 돌파는 이론적 문제가 아니라 실제 문제 +``` + +#### Sliding Window Counter (대안) + +``` +Fixed Window A의 잔여 비중 × A의 카운트 + Window B의 카운트 ≤ limit + +예시: limit = 50, 현재 시각 = 1.3초 + Window A (0~1초): 40건 + Window B (1~2초): 15건 + + A의 잔여 비중 = 1.0 - 0.3 = 0.7 + 가중 합계 = 0.7 × 40 + 15 = 28 + 15 = 43 → 50 이하 → 허용 +``` + +- **핵심**: Fixed Window 2개의 카운터를 보간하여 **근사 Sliding Window** 구현 +- **정확도**: 요청 분포가 균일하다고 가정 → 실제 오차는 ±수 건 이내 +- **채택 사례**: Cloudflare, Nginx, Kong, Stripe API + +### 17.4 Sliding Window Counter 구현 상세 + +#### 17.4.1 구현 코드 (in-memory, 단일 인스턴스용) + +```java +@Component +public class SlidingWindowRateLimiter { + + private final int limit; + private final long windowSizeMs; + + private final AtomicLong prevWindowStart = new AtomicLong(0); + private final AtomicInteger prevWindowCount = new AtomicInteger(0); + private final AtomicLong currWindowStart = new AtomicLong(0); + private final AtomicInteger currWindowCount = new AtomicInteger(0); + + public SlidingWindowRateLimiter(int limit, long windowSizeMs) { + this.limit = limit; + this.windowSizeMs = windowSizeMs; + } + + public synchronized boolean tryAcquire() { + long now = System.currentTimeMillis(); + long currentWindow = now / windowSizeMs * windowSizeMs; + + // 윈도우 전환 처리 + if (currentWindow != currWindowStart.get()) { + prevWindowCount.set(currWindowCount.get()); + prevWindowStart.set(currWindowStart.get()); + currWindowCount.set(0); + currWindowStart.set(currentWindow); + } + + // 이전 윈도우의 잔여 비중 계산 + long elapsed = now - currentWindow; + double prevWeight = Math.max(0, 1.0 - (double) elapsed / windowSizeMs); + + // 가중 합계 + double weightedCount = prevWeight * prevWindowCount.get() + currWindowCount.get(); + + if (weightedCount < limit) { + currWindowCount.incrementAndGet(); + return true; + } + return false; + } +} +``` + +#### 17.4.2 구현 복잡도 분석 + +| 항목 | Fixed Window (Resilience4j) | Sliding Window Counter (직접 구현) | +|------|---------------------------|----------------------------------| +| **코드량** | 0줄 (어노테이션 + yaml) | ~50줄 (위 코드) | +| **메모리** | Resilience4j 내부 관리 | `AtomicLong` 2개 + `AtomicInteger` 2개 = 24바이트/인스턴스 | +| **CPU** | 카운터 비교 O(1) | 곱셈 1회 + 덧셈 1회 + 비교 O(1) | +| **스레드 안전성** | Resilience4j 보장 | `synchronized` 블록 필요 | +| **메트릭** | Actuator 자동 연동 | Micrometer 직접 등록 필요 | +| **어노테이션 통합** | `@RateLimiter` 자동 | 직접 AOP 또는 인터셉터 작성 | +| **설정 변경** | yaml 수정만으로 | 코드 수정 또는 동적 설정 직접 구현 | +| **테스트** | Resilience4j 테스트 유틸 활용 | 시간 제어 테스트 직접 작성 (Clock 주입 등) | + +#### 17.4.3 "복잡도 증가"의 실체 — 코드 50줄이 문제가 아니다 + +``` +Sliding Window Counter를 도입하면 발생하는 실제 비용: + +1. Resilience4j 어노테이션 생태계에서 이탈 + - @RateLimiter 대신 커스텀 AOP 또는 인터셉터 + - @CircuitBreaker, @Retry와의 실행 순서를 직접 관리 + - Resilience4j의 이벤트 시스템(onSuccess, onFailure)과 분리 + +2. 메트릭/모니터링 직접 구축 + - Resilience4j는 Actuator에 자동으로 메트릭 노출 + - 커스텀 Rate Limiter는 Micrometer Counter/Gauge 직접 등록 + - Grafana 대시보드도 별도 구성 + +3. 설정 관리 이원화 + - CB/Retry는 application.yml에서 관리 + - Sliding Window Rate Limiter는 별도 방식으로 관리 + - 운영 중 설정 변경 시 혼란 + +4. 테스트 복잡도 + - 시간 의존 로직 → Clock 주입 또는 TestClock 필요 + - 멀티스레드 동시성 테스트 직접 작성 + - Fixed Window는 Resilience4j가 테스트 유틸 제공 +``` + +### 17.5 적용 지점별 판단 + +#### (1) 배치 Rate Limiter (pgStatusBatch) — Fixed Window 유지 ✅ + +``` +판단: Fixed Window 유지 + +이유: +- 배치 스케줄러가 1건씩 순차 호출 → 동시성 자체가 없음 +- 경계 돌파 전제 조건(트래픽 집중)이 구조적으로 불가능 +- Sliding Window를 적용해도 동작 차이 없음 → 불필요한 복잡성 +``` + +#### (2) 결제 요청 Rate Limiter (pgPaymentRequest) — 판단이 갈리는 지점 + +``` +결제 요청은 동시 트래픽이 실제로 발생하는 지점이다. +Fixed Window의 경계 돌파가 실제 문제가 될 수 있다. +``` + +| 관점 | Fixed Window 유지 | Sliding Window Counter 적용 | +|------|------------------|---------------------------| +| **PG 보호** | 순간 2배 burst → PG 과부하 가능 | 정확한 TPS 제한 → PG 안전 | +| **구현 비용** | 0줄 | ~50줄 + AOP + 메트릭 | +| **Resilience4j 통합** | 완벽 | 분리됨 (실행 순서 직접 관리) | +| **운영 비용** | yaml 변경만으로 제한 조절 | 코드 변경 또는 동적 설정 필요 | +| **경계 돌파 대안** | limit을 보수적으로 설정 (50 → 30) | 불필요 | +| **분산 환경 확장** | 인스턴스별 분산 (3대면 150 TPS) | 동일 문제 (Redis 필요) | + +#### "limit을 보수적으로 설정"하면 해결되는가? + +``` +PG 계약: 50 TPS +경계 돌파 최대: 2배 = 100 TPS + +대안 1: limit = 25로 설정 → 경계 돌파 시 최대 50 TPS → PG 안전 + 단점: 정상 상태에서도 25 TPS로 제한 → 처리량 50% 낭비 + +대안 2: limit = 40로 설정 → 경계 돌파 시 최대 80 TPS → PG 약간 초과 + 단점: PG가 80 TPS를 일시적으로 견딜 수 있는지에 의존 + +대안 3: Sliding Window Counter → 정확히 50 TPS → PG 안전 + 처리량 최대 + 단점: 구현 비용 + Resilience4j 생태계 이탈 +``` + +``` +쿠팡 관점 분석: +- PG 계약 TPS가 50이면, 25로 제한하는 건 비즈니스 손실 +- 플래시 세일에서 초당 결제 25건 vs 50건 → 매출 차이 큼 +- 따라서 "보수적 limit"은 해결책이 아니라 회피 + +그러나: +- 현재 과제는 단일 인스턴스 + PG 시뮬레이터 +- PG 시뮬레이터에 엄격한 TPS 제한이 없음 +- Resilience4j 통합 유지의 가치가 높음 (학습 과제 특성) +``` + +#### (3) CB의 Sliding Window — COUNT_BASED 유지 ✅ + +```yaml +sliding-window-type: COUNT_BASED # 최근 N건 기준 +sliding-window-size: 10 # 최근 10건 중 실패율 계산 +``` + +``` +판단: COUNT_BASED 유지 + +이유: +- COUNT_BASED는 트래픽이 적을 때도 정확 (10건이 쌓이면 즉시 판단) +- TIME_BASED는 트래픽이 적으면 10초 구간에 2건만 들어올 수 있음 + → 2건 중 1건 실패 = 50% 실패율 → CB Open (과민 반응) +- 현재 과제의 PG 트래픽은 고정적이지 않으므로 COUNT_BASED가 안전 + +참고: CB의 sliding window와 Rate Limiter의 sliding window는 다른 개념 +- CB: 최근 N건/N초의 "실패율"을 측정 (판단 기준) +- Rate Limiter: 시간당 "요청 수"를 제한 (흐름 제어) +``` + +### 17.6 최종 판단 + +``` +[결론: 결제 요청에 Sliding Window Counter 적용] + +1. 배치 Rate Limiter (pgStatusBatch): + - Fixed Window 유지 (Resilience4j @RateLimiter) + - 이유: 순차 처리 → 경계 돌파 불가능 + +2. 결제 요청 Rate Limiter (pgPaymentRequest): + - Sliding Window Counter 적용 (직접 구현) + - 이유: + a) 결제는 동시 트래픽이 발생하는 유일한 아웃바운드 지점 + b) PG TPS 제한은 계약 사항 — 초과하면 차단당할 수 있음 + c) "limit을 보수적으로 설정"하면 정상 시 처리량이 낭비됨 + d) Resilience 과제에서 Rate Limiting 전략을 직접 구현해보는 학습 가치 + + 구현 범위: + - SlidingWindowRateLimiter 클래스 (~50줄) + - PaymentRateLimiterInterceptor (AOP) + - Micrometer 메트릭 등록 (허용/거부 카운터) + + Resilience4j 통합 대안: + - @RateLimiter 대신 Interceptor로 적용 + - 실행 순서: SlidingWindowRateLimiter → @Retry → @CircuitBreaker + +3. CB Sliding Window: + - COUNT_BASED 유지 + +4. Redis 기반 Sliding Window (분산 환경): + - 현재 과제: 단일 인스턴스 → in-memory 충분 + - 프로덕션: Redis Sorted Set 기반으로 교체 + (§16에서 Redis를 이미 사용하므로 인프라 추가 비용 없음) +``` + +### 17.7 결제 요청 Rate Limiter 설계 + +#### Sliding Window Counter 설정 + +```java +@Configuration +public class RateLimiterConfig { + + @Bean + public SlidingWindowRateLimiter pgPaymentRateLimiter() { + return new SlidingWindowRateLimiter( + 50, // limit: 초당 최대 50건 + 1000 // windowSizeMs: 1초 + ); + } +} +``` + +#### Rate Limiter + Retry + CB 실행 순서 + +``` +[클라이언트 요청] + │ + ▼ +[SlidingWindowRateLimiter] ← 최근 1초간 50건 초과? → 429 또는 큐잉 + │ (통과) + ▼ +[@Retry] ← 실패 시 1회 재시도 (500ms 대기) + │ + ▼ +[@CircuitBreaker] ← 실패율 50% 초과? → Fallback (다른 PG) + │ + ▼ +[Feign Client] → PG 호출 + +[배치 조회] + │ + ▼ +[@RateLimiter(pgStatusBatch)] ← Fixed Window 10 req/sec (Resilience4j) + │ + ▼ +[@CircuitBreaker] → PG 조회 +``` + +``` +실행 순서 근거: +- Sliding Window Rate Limiter가 가장 바깥: + PG로 나가는 총량을 먼저 제한 (계약 TPS 보호) +- Retry가 CB 바깥: + 재시도 실패도 CB에 기록되어야 정확한 실패율 측정 +- CB가 가장 안쪽: + 최종 차단 판단 + Fallback 트리거 +- Rate Limiter 거부는 CB에 기록하지 않음: + Rate Limiter 거부 = PG 장애가 아닌 트래픽 초과 + CB에 기록하면 트래픽만 많아도 CB Open → 오작동 +``` + +#### 배치 Rate Limiter — Fixed Window 유지 + +```yaml +resilience4j: + ratelimiter: + instances: + pgStatusBatch: + limit-for-period: 10 + limit-refresh-period: 1s + timeout-duration: 0 +``` + +#### 현재 과제 적용 범위 + +| 항목 | 방식 | 이유 | +|------|------|------| +| 결제 요청 Rate Limiter | Sliding Window Counter (직접 구현) | PG TPS 보호 + 경계 돌파 방지 + 학습 가치 | +| 배치 Rate Limiter | Fixed Window (Resilience4j) | 순차 처리, 경계 돌파 불가 | +| CB Sliding Window | COUNT_BASED (Resilience4j) | 트래픽 변동에 안정적 | +| 인바운드 API Throttling | ❌ 미적용 | API Gateway/인프라 관심사 | +| Redis 분산 Rate Limiting | ❌ 미적용 (단일 인스턴스) | 프로덕션에서 Redis Sorted Set으로 확장 | + +### 17.8 → 05 반영 사항 + +| 반영 대상 | 내용 | +|----------|------| +| Section 7 | 결제 요청: Sliding Window Counter, 배치: Fixed Window (Resilience4j) | +| Section 7.5 | SlidingWindowRateLimiter → @Retry → @CircuitBreaker 실행 순서 | +| Section 15 | Phase 2에 SlidingWindowRateLimiter 구현 + Interceptor 항목 추가 | +| Section 16 | SlidingWindowRateLimiter 클래스, PaymentRateLimiterInterceptor 패키지 추가 | + +--- + +## 18. CB 읽기/쓰기 분석 — 읽기 CB 제거 근거 + +> **질문**: CB를 읽기/쓰기 구분 없이 모든 외부 호출에 적용했는데, +> 쓰기에만 CB를 걸고 읽기에는 걸지 않아도 되지 않은가? + +### 18.1 현재 CB 인스턴스 (8개) + +| CB 인스턴스 | 성격 | 대상 | +|---|---|---| +| `pgSimulator-request` | **쓰기** | PG 결제 요청 (POST) | +| `pgSimulator-status-realtime` | 읽기 | PG 상태 확인 - 실시간 | +| `pgSimulator-status-batch` | 읽기 | PG 상태 확인 - 배치 | +| `pgToss-request` | **쓰기** | Toss 결제 요청 (POST) | +| `pgToss-status-realtime` | 읽기 | Toss 상태 확인 - 실시간 | +| `pgToss-status-batch` | 읽기 | Toss 상태 확인 - 배치 | +| `redis-write` | **쓰기** | Redis 가주문 쓰기 | +| `redis-read` | 읽기 | Redis 가주문 조회 | + +쓰기 3개, 읽기 5개. + +### 18.2 읽기 CB가 불필요한 3가지 근거 + +#### (1) 상태 확인은 "복구 행위" — CB가 차단하면 복구가 멈춘다 + +``` +PG 결제 요청 → 타임아웃 → 내부 상태 UNKNOWN +→ PG 상태 확인 API로 "결제 됐어?" 확인 필요 ← 이것이 복구 경로 + +그런데 status CB Open이면? +→ 상태 확인 자체가 차단 → 복구 불가 +→ PENDING/UNKNOWN 건이 계속 쌓임 + +더 나쁜 케이스: + PG가 3초 만에 복구됨 + status CB는 Open (wait 5초) + → 2초간 불필요한 복구 지연 + → 쿠팡 기준: 초당 1000건 × 2초 = 2000건 결제 확인 지연 +``` + +**CB의 목적은 장애 전파 방지**인데, +상태 확인을 차단하는 건 **전파 방지가 아니라 복구 방해**다. + +#### (2) 읽기에는 이미 다른 보호 수단이 있다 + +| 읽기 대상 | 기존 보호 | CB와 중복? | +|---|---|---| +| PG status (실시간) | Feign Timeout 1초 | **중복** — timeout으로 빠른 실패 보장 | +| PG status (배치) | Rate Limiter 10 req/sec + Timeout | **중복** — 배치 스레드 1개, 부하 미미 | +| Redis read | Lettuce `commandTimeout 500ms` + `ReadFrom.REPLICA_PREFERRED` | **중복** — 드라이버 내장 폴백 | + +``` +[PG status-batch, CB 없이 PG 전면 장애 시] + Rate Limiter: 10 req/sec + 배치 스레드: 1개 (순차 처리) + Timeout: 1초/건 + → 초당 1 스레드 × 1초 = 1 스레드 점유 + → 위험 없음 + +[redis-read, CB 없이 전체 Redis 장애 시] + Replica 장애 → Lettuce가 Master로 자동 폴백 → CB 불필요 + 전체 장애 → commandTimeout 500ms → try-catch → DB Fallback + redis-write CB Open → 새 가주문은 DB → Redis 읽기 시도 자체 감소 + → Timeout + try-catch으로 충분 +``` + +#### (3) 읽기 CB가 만드는 부작용 + +``` +redis-read CB Open 중 Replica 복구됨 +→ wait-duration 3초간 Redis 읽기 차단 +→ 모든 가주문 조회가 DB로 감 (불필요한 DB 부하) +→ Lettuce의 REPLICA_PREFERRED 자동 폴백도 무력화 + +status-realtime CB Open 중 PG 복구됨 +→ wait-duration 5초간 상태 확인 차단 +→ UNKNOWN 건 복구가 5초 지연 +→ 고객은 "결제 처리 중" 화면을 5초 더 봄 +``` + +CB가 읽기를 차단하면, **드라이버/인프라 레벨의 내장 폴백보다 느리게 동작**한다. + +### 18.3 결론: 쓰기 CB만 유지 (8개 → 3개) + +#### 유지 (쓰기 3개) + +| CB | 근거 | +|---|---| +| `pgSimulator-request` | 결제 요청 차단 + Fallback PG 전환. **돈이 걸린 쓰기** | +| `pgToss-request` | 위와 동일 | +| `redis-write` | 가주문 쓰기 차단 + DB Fallback. **재고 차감이 걸린 쓰기** | + +#### 제거 (읽기 5개) + +| CB | 제거 근거 | 대체 보호 | +|---|---|---| +| `pgSimulator-status-realtime` | 복구 경로 차단 방지 | Timeout 1초 | +| `pgSimulator-status-batch` | Rate Limiter와 중복 | Rate Limiter + Timeout | +| `pgToss-status-realtime` | 위와 동일 | Timeout 1초 | +| `pgToss-status-batch` | 위와 동일 | Rate Limiter + Timeout | +| `redis-read` | Lettuce 내장 폴백 무력화 방지 | `commandTimeout 500ms` + try-catch | + +``` +CB 관리 복잡성: 62.5% 감소 (8개 → 3개) +모니터링 대상: 62.5% 감소 +Half-Open 전략: 쓰기 CB에만 집중 가능 +복구 경로: CB에 의한 차단 없이 즉시 동작 +``` + +### 18.4 읽기 보호 — CB 제거 후 코드 패턴 + +```java +// 상태 조회 — CB 없음, Timeout만으로 보호 +// @CircuitBreaker 제거, @RateLimiter(배치)만 유지 +public PgPaymentStatusResponse getPaymentStatus(String transactionKey) { + try { + return pgClient.getPaymentStatus(transactionKey); // Feign timeout 1초 + } catch (Exception e) { + log.warn("PG 상태 확인 실패: transactionKey={}", transactionKey, e); + return PgPaymentStatusResponse.unknown(transactionKey); + } +} + +// Redis 읽기 — CB 없음, try-catch + Timeout으로 보호 +public Optional findProvisionalOrder(String orderId) { + try { + Map data = defaultRedisTemplate.opsForHash() + .entries("provisional:order:" + orderId); // commandTimeout 500ms + return data.isEmpty() + ? Optional.empty() + : Optional.of(ProvisionalOrder.from(data)); + } catch (Exception e) { + log.warn("Redis 가주문 조회 실패 — DB Fallback: orderId={}", orderId, e); + return orderRepository.findByOrderId(orderId) + .map(ProvisionalOrder::fromDbOrder); + } +} +``` + +### 18.5 원칙 정리 + +``` +CB 적용 기준: "이 호출이 실패하면 부작용(side-effect)이 있는가?" + +쓰기 (CB 필요): + - 결제 요청 → 돈이 빠질 수 있음 → CB로 차단 + Fallback PG + - Redis 쓰기 → 재고 차감 + 가주문 생성 → CB로 차단 + DB Fallback + +읽기 (CB 불필요): + - 상태 확인 → 부작용 없음 + 복구 행위 → Timeout만으로 충분 + - Redis 읽기 → 부작용 없음 → 드라이버 폴백 + Timeout +``` + +### 18.6 → 05 반영 사항 + +| 반영 대상 | 내용 | +|---|---| +| Section 7 (CB 설정) | CB 인스턴스 8개 → 3개, 읽기 CB 설정 제거 | +| Section 7.5 (실행 순서) | status 메서드에서 @CircuitBreaker 제거 | +| Section 7.6 (Half-Open) | status CB 관련 Half-Open 전략 제거 | +| 장애 격리 검증 | redis-read 관련 행 수정 | +| Phase 3 | Redis CB 2개 → 1개 | + +--- + +## 19. 선차감/후차감 분석 — 결제 전 자원 차감 원칙 + +> **질문**: 재고/포인트가 결제에 의해 차감되어야 하는데, +> 선차감/후차감 중 무엇이 계획인가? +> 사용자가 결제 롤백을 경험하지 않으려면 선차감이 좋지 않은가? + +### 19.1 현재 설계 상태 + +| 자원 | 차감 시점 | 설계 여부 | +|---|---|---| +| **재고** | 선차감 (가주문 시 Redis DECR) | ✅ 설계됨 (§16.3 Option C) | +| **쿠폰** | — | ❌ **가주문 flow에서 빠져있음** | +| **포인트** | — | 현재 프로젝트에 없음 | + +기존 코드(`OrderFacade.createOrder()`)에서는 재고와 쿠폰 모두 주문 생성 트랜잭션 안에서 처리: +```java +// 재고 차감 (line 83-86) +product.decreaseStock(req.quantity()); + +// 쿠폰 적용 (line 97-100) +CouponApplyResult result = couponFacade.applyCouponToOrder( + couponIssueId, memberId, originalTotalPrice); +``` + +그러나 가주문 flow(§16.3 Option C)에서는 재고만 Redis DECR로 선차감하고, +**쿠폰은 설계에 포함되지 않았다.** + +### 19.2 후차감의 치명적 문제 — "결제 롤백" UX + +``` +[후차감 시나리오] +1. 결제 요청 → PG 승인 ✅ (돈 빠짐) +2. 재고 차감 시도 → 재고 부족 ❌ +3. PG 취소 API 호출 필요 +4. PG 취소 실패? → 돈은 빠졌는데 상품도 없음 + +고객 경험: "결제 완료되었습니다" → "주문이 취소되었습니다. 환불 처리 중입니다." +→ 최악의 UX + CS 폭주 +``` + +``` +[선차감 시나리오] +1. 재고/쿠폰 선차감 ✅ +2. 결제 요청 → PG 실패 ❌ +3. 재고/쿠폰 복원 +4. 복원 실패? → 자원 일시 잠김 → 배치 복원 → 돈 관련 문제 0 + +고객 경험: "결제에 실패했습니다. 다시 시도해주세요." +→ 자연스러운 흐름 + PG 취소 API 자체가 불필요 +``` + +| 구분 | 선차감 | 후차감 | +|---|---|---| +| 최악의 경우 | 자원 일시 잠김 (배치 복원) | **돈 빠짐 + 상품 없음** | +| PG 취소 API 필요? | 불필요 | **필수** (추가 외부 의존성) | +| 복원 실패 영향 | 자원 잠김 (비즈니스) | **환불 지연** (금전) | +| 장애 포인트 수 | 복원만 (INCR, DB UPDATE) | **PG 취소 + 환불 확인 + 재고 복원** | + +**원칙: 차감 가능한 모든 자원은 결제 전 선차감.** + +### 19.3 쿠폰 선차감 — 가주문 flow 보완 + +#### 재고와 쿠폰의 동시성 차이 + +| 비교 | 재고 | 쿠폰 | +|---|---|---| +| 동시성 | 높음 (같은 상품 수백 명) | **낮음** (1인 1쿠폰, 본인만 사용) | +| 호출 빈도 | 모든 주문 | 쿠폰 있는 주문만 (optional) | +| Redis 필요? | ✅ 플래시 세일 대응 | ❌ DB UPDATE 1건으로 충분 | + +#### 쿠폰 선차감 설계 + +``` +[보완된 가주문 flow] +가주문 생성: + 1. Redis DECR(stock) ← 재고 선차감 (Redis) + 2. DB: 쿠폰 상태 USED 처리 (쿠폰 있는 경우) ← 쿠폰 선차감 (DB) + 3. Redis HSET(가주문, couponIssueId 포함) + → 결제 요청 + +결제 성공 (진주문 전환): + → DB INSERT(주문) + DB 재고 확정 + +결제 실패: + → Redis INCR(stock) ← 재고 복원 + → DB: 쿠폰 상태 AVAILABLE 복원 ← 쿠폰 복원 +``` + +#### "가주문에서 DB 트랜잭션을 열면 가주문의 목적이 반감되지 않나?" + +``` +가주문의 목적: DB 쓰기 부하 감소 (결제 전 주문을 DB에 INSERT하지 않음) + +쿠폰 선차감: DB UPDATE 1건 (CouponIssue.status = USED) +주문 INSERT: 없음 (Redis에만 저장) + +비교: + 기존 (가주문 없이): DB INSERT(Order) + DB INSERT(OrderItems) + DB UPDATE(Stock) + DB UPDATE(Coupon) + 가주문 + 쿠폰 선차감: DB UPDATE(Coupon) 1건만 + +→ DB 부하 감소 효과 대부분 유지 (4건 → 1건) +→ 쿠폰 없는 주문은 DB 트랜잭션 0건 (완전한 가주문) +``` + +#### 쿠폰 복원 실패 시 대응 + +``` +결제 실패 → 쿠폰 복원 시도 → 복원 실패 (DB 일시 장애) + +대응: + 1. 즉시: 로그 + 모니터링 알림 + 2. 배치: 결제 FAILED + 쿠폰 USED → 쿠폰 AVAILABLE 복원 (30초 주기) + 3. 영향: 쿠폰 일시 사용 불가 → 재결제 시 쿠폰 선택 불가 → 쿠폰 없이 결제 가능 + + 최악: 쿠폰 잠김 (비즈니스 손실 미미) + 후차감 최악: 돈 빠짐 + 환불 대기 (금전 손실) + → 비교 불가 +``` + +### 19.4 → 05 반영 사항 + +| 반영 대상 | 내용 | +|---|---| +| 가주문 flow | 쿠폰 선차감 단계 추가 | +| Phase 1 | 가주문 모델에 couponIssueId 필드 추가 | +| Phase 4 | 결제 실패 시 쿠폰 복원 로직 추가 | +| 결제 실패 처리 (Section 13.2) | 쿠폰 복원 행 추가 | + +--- + +## 20. 쿠폰 선차감 — Redis 불필요 근거 + 트랜잭션 경계 보완 + +> **질문 1**: 다양한 종류의 쿠폰이 발행되는 상황에서도 Redis 없이 DB만으로 유지 가능한가? +> **질문 2**: 외부 API 호출과 내부 처리가 하나의 트랜잭션으로 묶여있지 않은가? + +### 20.1 쿠폰 "발급"과 "사용"의 구분 + +쿠폰 라이프사이클에서 동시성이 높은 지점과 낮은 지점이 다르다: + +``` +[발급] 쿠폰 템플릿 → 사용자에게 CouponIssue 생성 (INSERT) +[사용] CouponIssue의 status를 AVAILABLE → USED (UPDATE) +[복원] CouponIssue의 status를 USED → AVAILABLE (UPDATE) +``` + +| 단계 | 경합 대상 | 동시성 | Redis 필요? | +|---|---|---|---| +| **발급** | 쿠폰 템플릿의 수량 한도 | 높음 (선착순 1000명) | 상황에 따라 ⚠️ | +| **사용** (결제 시 선차감) | 개인의 CouponIssue 1건 | **낮음** (본인 1명) | ❌ 불필요 | +| **복원** (결제 실패 시) | 개인의 CouponIssue 1건 | **낮음** (본인 1명) | ❌ 불필요 | + +### 20.2 결제 시 "사용" — DB만으로 충분한 이유 + +#### Coupon(템플릿)과 CouponIssue(발급 건)의 관계 + +``` +[Coupon] 1 ────── N [CouponIssue] + +Coupon (쿠폰 템플릿): + id: 1, name: "여름 세일 10% 할인" ← 쿠폰 정의 (1건) + +CouponIssue (발급 건): + id: 101, coupon_id: 1, member_id: 유저A, status: AVAILABLE ← 유저A의 쿠폰 + id: 102, coupon_id: 1, member_id: 유저B, status: AVAILABLE ← 유저B의 쿠폰 + id: 103, coupon_id: 1, member_id: 유저C, status: USED ← 유저C의 쿠폰 (사용됨) +``` + +같은 쿠폰 템플릿을 여러 사용자가 발급받을 수 있다. +발급 시마다 개인별 CouponIssue row가 생성된다. + +#### 사용 시 경합이 없는 구조적 이유 + +```java +// markAsUsed() — CouponIssue.id (발급 건의 PK)로 UPDATE +// coupon_id(템플릿)가 아님! +@Query("UPDATE CouponIssue ci SET ci.status = :usedStatus" + + " WHERE ci.id = :id AND ci.status = :availableStatus AND ci.expiredAt > :now") +int markAsUsed(@Param("id") Long id, ...); +``` + +``` +1000명이 같은 쿠폰 템플릿(coupon_id=1)으로 동시에 결제해도: + +유저A: UPDATE coupon_issue SET status='USED' WHERE id=101 ← row 101 +유저B: UPDATE coupon_issue SET status='USED' WHERE id=102 ← row 102 +유저C: UPDATE coupon_issue SET status='USED' WHERE id=103 ← row 103 + +→ 각자 다른 row를 UPDATE → DB 경합 없음 +→ 재고와 구조적으로 다름: + 재고: UPDATE product SET stock=stock-1 WHERE id=상품A ← 같은 row에 1000명 경합 + 쿠폰: UPDATE coupon_issue SET status='USED' WHERE id=내_발급건 ← 각자 다른 row + +이것이 쿠폰 사용에 Redis가 불필요한 근본 이유다. +``` + +#### CAS가 방어하는 유일한 경합 시나리오 + +``` +같은 사람이 동시에 2개 주문에서 같은 쿠폰을 사용하는 경우: + +요청 1: UPDATE coupon_issue SET status='USED' WHERE id=101 AND status='AVAILABLE' + → updated = 1 ✅ +요청 2: UPDATE coupon_issue SET status='USED' WHERE id=101 AND status='AVAILABLE' + → updated = 0 ❌ (이미 USED → WHERE 조건 불일치) + +→ 중복 사용 방지 완료 +``` + +### 20.3 쿠폰 유형별 분석 + +| 쿠폰 유형 | 발급 시 동시성 | 사용(결제) 시 동시성 | DB로 충분? | +|---|---|---|---| +| 개인 발급 쿠폰 | 본인 요청 | 각자의 CouponIssue row | ✅ | +| 신규가입/생일 쿠폰 | 이벤트 트리거 | 각자의 CouponIssue row | ✅ | +| 선착순 한정 쿠폰 | **높음** (1000명 동시 발급) | 발급 이후 각자의 row → 경합 없음 | ✅ | +| 금액/비율 할인 | 발급 방식에 따라 다름 | 각자의 CouponIssue row | ✅ | +| 코드 입력 쿠폰 | 코드당 수량 제한 시 높음 | 발급 이후 각자의 row → 경합 없음 | ✅ | + +``` +핵심 인사이트: + +쿠폰 "발급"과 "사용"의 동시성은 독립적이다. + +발급: Coupon(템플릿)의 수량을 차감 → 같은 row에 N명 경합 → 동시성 높음 +사용: CouponIssue(발급 건)의 status를 변경 → 각자 다른 row → 경합 없음 + +발급 시점에 동시성이 아무리 높아도, +발급 이후에는 개인별 CouponIssue row로 분리되므로 사용 시 경합이 없다. + +→ 발급 시 Redis가 필요할 수 있지만, 사용(결제 선차감) 시에는 DB CAS로 충분하다. + +결론: 쿠폰 종류가 다양해져도, 같은 쿠폰을 여러 명이 발급받아도, + 결제 시 선차감은 DB CAS 유지. +``` +``` + +### 20.4 트랜잭션 경계 — 외부 API 분리 확인 + +#### 현재 설계 (05 §12): PG 호출은 트랜잭션 밖 ✅ + +``` +[TX-1] Payment(REQUESTED) + Outbox(PENDING) → commit +[PG 호출] CB → Retry → PG 요청 (트랜잭션 없음) +[TX-2] Payment 상태 업데이트 → commit +``` + +#### 가주문 flow에 쿠폰 선차감 추가 시 트랜잭션 경계 + +기존 §12 설계에는 가주문 + 쿠폰 선차감의 트랜잭션이 명시되지 않았다. +보완한 전체 흐름: + +``` +[TX-0] 쿠폰 USED 처리 (CAS UPDATE 1건) → commit ← 쿠폰 선차감 +[Redis] DECR(stock) + HSET(가주문, couponIssueId) ← 재고 선차감 + 가주문 생성 +[TX-1] Payment(REQUESTED) + Outbox(PENDING) → commit ← 결제 기록 +[PG 호출] CB → Retry → PG 요청 ← 트랜잭션 없음 +[TX-2] Payment 상태 업데이트 → commit ← PG 응답 처리 + +... (콜백 수신 시) ... + +[TX-3] Payment 확정 + Order 상태 + Redis DEL → commit ← 진주문 전환 +``` + +#### 각 TX의 커넥션 점유 시간 + +``` +TX-0: CAS UPDATE 1건 → ~5ms (쿠폰 없는 주문은 TX-0 자체가 없음) +TX-1: INSERT 2건 → ~10ms +PG 호출: 100ms~4.5초 ← 트랜잭션 없음, DB 커넥션 0개 점유 +TX-2: UPDATE 1건 → ~5ms +TX-3: UPDATE 2건 + Redis DEL → ~10ms + +총 DB 커넥션 점유: ~30ms (PG 지연과 무관) +비교: PG 호출이 TX 안이면 → 최대 4.5초 점유 (150배 차이) +``` + +#### 산술적 검증 + +``` +초당 100건 결제 시: + +[트랜잭션 분리 (현재)] + 100건 × 30ms = 3 커넥션·초 + HikariCP 10개 → 사용률 30% → 안전 + +[PG 호출이 TX 안 (만약)] + 100건 × 4.5초 = 450 커넥션·초 + HikariCP 10개 → 사용률 4500% → 즉시 고갈 + → 결제 장애가 상품 조회, 주문 조회까지 전파 +``` + +### 20.5 TX-0 실패 시나리오별 보상 + +| 시나리오 | 상태 | 보상 | +|---|---|---| +| TX-0 성공 → Redis DECR 실패 (CB Open) | 쿠폰 USED, 재고 미차감 | DB Fallback 경로 진입 (DB 재고 차감) | +| TX-0 성공 → 결제 성공 | 쿠폰 USED, 진주문 확정 | 보상 불필요 ✅ | +| TX-0 성공 → 결제 실패 | 쿠폰 USED, 결제 FAILED | 쿠폰 복원: `couponFacade.restoreCoupon()` | +| TX-0 성공 → 결제 실패 → 쿠폰 복원 실패 | 쿠폰 잠김 | 배치: 결제 FAILED + 쿠폰 USED → 복원 | +| TX-0 실패 (CAS 실패) | 쿠폰 이미 사용/만료 | 즉시 에러 응답 ("사용할 수 없는 쿠폰") | + +``` +TX-0 성공 → Redis 성공 → TX-1 성공 → PG 호출 전 서버 크래시: + 쿠폰: USED (잠김) + 재고: Redis DECR됨 (잠김) + Payment: REQUESTED (Outbox에 기록됨) + → Outbox Poller가 5초 후 PG 호출 재시도 + → 결제 진행 → 성공이면 모두 확정, 실패이면 모두 복원 + → 어느 경우든 정합성 유지 +``` + +### 20.6 → 05 반영 사항 + +| 반영 대상 | 내용 | +|---|---| +| Section 12.2 (트랜잭션 분리) | TX-0(쿠폰 선차감) 추가, 가주문 flow 전체 TX 명시 | +| Section 12 | 커넥션 점유 시간 산술 검증 추가 | + +--- + +## 21. 아키텍처 맥락 정리 — 모듈러 모놀리스 + MSA 전환 고려 + +> 설계 문서 전반에 "모노리스" / "MSA 전환 시" 언급이 산재해 있다. +> 현재 구조의 전제와 MSA 전환 시 변경 지점을 한 곳에 정리한다. + +### 21.1 현재 구조: 모듈러 모놀리스 + +``` +[commerce-api — 단일 SpringBoot 애플리케이션] + +/domain/member/ ┐ +/domain/brand/ │ +/domain/product/ │ +/domain/order/ ├── 같은 프로세스, 같은 DB +/domain/coupon/ │ +/domain/payment/ │ ← 6주차 추가 +/domain/like/ ┘ + +특성: + - 모든 도메인이 같은 JVM + - 같은 MySQL 인스턴스 + - 도메인 간 호출 = in-process 메서드 호출 + - 도메인 간 트랜잭션 = 같은 DB TX로 원자적 +``` + +**"모놀리스"이지만:** +- 도메인별 패키지 분리 (계층 + 도메인) +- DIP 적용 (Domain ← Infrastructure) +- Aggregate 간 참조는 ID로만 (느슨한 결합) +- 멀티 모듈 (apps/modules/supports) + +→ MSA 경계가 패키지 레벨에서 이미 그어져 있는 **모듈러 모놀리스**. + +### 21.2 모놀리스의 이점을 활용하는 현재 설계 + +| 설계 결정 | 모놀리스이기 때문에 가능한 것 | MSA였다면 | +|---|---|---| +| 결제 실패 → 재고 복원 | 같은 TX에서 원자적 UPDATE | 보상 이벤트 큐 or Saga | +| Payment + Order 상태 전이 | 같은 TX에서 원자적 (TX-3) | 이벤트 기반 + 최종 일관성 | +| 쿠폰 복원 | 같은 DB에서 `SELECT + UPDATE` | 쿠폰 서비스 API 호출 (실패 가능) | +| 대사 배치 | 같은 DB에서 JOIN 쿼리 | 서비스 간 API 호출 + 데이터 수집 | +| FB-COMP (보상 트랜잭션 큐) | 불필요 | Saga Pattern 필수 | + +### 21.3 TX 분리의 이유 — 도메인 분리가 아니라 외부 호출 격리 + +``` +TX-0/TX-1/TX-2 분리는 MSA 준비가 아니다. + +[이유: PG 호출(외부 API)을 트랜잭션 밖으로 빼기 위함] + +TX-0: 쿠폰 USED ← 내부 (DB) +Redis: 재고 DECR ← 내부 (Redis) +TX-1: Payment 저장 ← 내부 (DB) +PG 호출 ← 외부 (PG API) ← 이것 때문에 TX 분리 +TX-2: 상태 업데이트 ← 내부 (DB) + +만약 PG 호출이 없었다면: + TX-0 + TX-1 + TX-2 = 하나의 TX로 충분 (모놀리스) + +분리 기준: "외부 시스템 호출이 TX 안에 있으면 커넥션 점유 → 고갈" +분리 기준이 아닌 것: "도메인 경계" (MSA 전환 시에는 이것도 기준이 됨) +``` + +### 21.4 MSA 전환 시 변경 지점 + +| 현재 (모놀리스) | MSA 전환 시 | 변경 수준 | +|---|---|---| +| OrderFacade → CouponFacade (메서드 호출) | Order Service → Coupon Service (API/이벤트) | 인터페이스 변경 | +| 같은 TX에서 재고 + 주문 + 쿠폰 원자적 처리 | Saga Pattern (Orchestration or Choreography) | 아키텍처 변경 | +| Outbox → DB 폴링 | Outbox → Kafka 발행 (Transactional Outbox + CDC) | 인프라 변경 | +| 배치 복구: 같은 DB에서 JOIN 조회 | 서비스별 배치 + 이벤트 기반 연동 | 분산 처리 | +| FB-COMP 불필요 | Saga 보상 트랜잭션 필수 | 신규 구현 | +| TX-3 후속 처리 순차 (단일 TX, ~10ms) | 병렬 + 이벤트 기반 (서비스별 독립 TX) | 아키텍처 변경 | + +> **TX-3 병렬화 판단 근거**: 모놀리스에서 같은 DB UPDATE 3~4건은 ~10ms. +> 병렬화 이득 ~5ms vs Saga 보상 로직 복잡도 → 트레이드오프 불균형. +> MSA 전환 시 서비스 분리로 단일 TX 불가 → 그때 이벤트 기반 병렬 처리가 자연스러운 전환점. + +``` +현재 설계가 MSA-ready인 부분: + ✅ 도메인별 패키지 분리 → 서비스 경계 명확 + ✅ Aggregate 간 ID 참조 → 서비스 간 느슨한 결합 + ✅ Outbox 패턴 → Kafka 발행으로 자연스럽게 진화 + ✅ 조건부 UPDATE → 분산 환경에서도 동시성 보호 + +현재 설계가 모놀리스 전제인 부분: + ⚠️ TX-3에서 Payment + Order + 쿠폰/재고를 원자적 처리 + ⚠️ 대사 배치에서 같은 DB JOIN 사용 + ⚠️ 쿠폰 복원 실패 시 같은 DB 배치로 보정 +``` + +### 21.5 → 05 반영 사항 + +| 반영 대상 | 내용 | +|---|---| +| Section 12 | "TX 분리 이유 = 외부 호출 격리 (도메인 분리 아님)" 주석 추가 | + +--- + +## 22. 대사 배치 프로세스 — 교차 시스템 정합성 검증 + +> **복구 배치**는 "우리 시스템 내부의 비정상 상태를 고치는 것"이고, +> **대사 배치**는 "두 시스템의 기록을 대조하여 불일치를 감지하는 것"이다. + +### 22.1 복구 vs 대사 구분 + +| 구분 | 복구 (Recovery) | 대사 (Reconciliation) | +|---|---|---| +| 방향 | 내부 → 외부 확인 | 양쪽 기록 대조 | +| 대상 | 비정상 상태 (UNKNOWN, PENDING) | **정상 상태 포함 전수 검증** | +| 목적 | 즉시 상태 확정 | 불일치 감지 + 알림 | +| 주기 | 짧음 (초~분) | 길어도 됨 (시간~일) | +| 실패 시 영향 | 고객 대기 | 정산 오류 (금전) | + +``` +현재 배치들은 전부 "복구": + Outbox (5초), Payment Recovery (1분), Stock Reconcile (30초) + → "비정상 건을 찾아서 PG에 물어보고 고친다" + +대사는 다른 질문: + → "우리가 PAID로 확정한 건이, PG에서도 정말 SUCCESS인가?" + → "PG에 SUCCESS인 건이, 우리에게도 전부 PAID로 반영되어 있는가?" +``` + +### 22.2 대사가 필요한 3가지 교차 지점 + +#### [R1] PG ↔ Payment 대사 + +``` +시나리오 A: 우리 PAID, PG FAILED + TX-3에서 콜백 데이터를 잘못 해석하여 PAID 처리 + → 고객 돈은 빠지지 않았는데 주문 완료 → 무료 구매 + +시나리오 B: PG SUCCESS, 우리 FAILED + PENDING 5분 초과 → FAILED 처리 → 직후 PG에서 SUCCESS 처리 + → 고객 돈은 빠졌는데 주문 취소 → 환불 누락 + +시나리오 C: PG SUCCESS, 우리에 Payment 기록 자체 없음 + TX-1 전에 크래시 + Outbox도 없음 (WAL 실패) + → PG에서 결제가 진행됨 → 우리 시스템에 흔적 없음 +``` + +#### [R2] Payment ↔ Order 대사 + +``` +시나리오: Payment PAID, Order PAYMENT_PENDING + TX-3에서 Payment UPDATE 성공 + Order UPDATE 실패 (부분 커밋 불가능하지만, + TX-3 이후 Order 상태 변경 로직이 별도 실행이면 발생 가능) + +모놀리스에서는 같은 TX → 발생 확률 매우 낮음 +MSA에서는 서비스 분리 시 발생 가능 → Saga 필요 +→ 현재는 모놀리스이므로 낮은 우선순위지만, 검증 차원에서 대사 +``` + +#### [R3] Payment ↔ Coupon 대사 + +``` +시나리오: Payment FAILED + CouponIssue USED (복원 누락) + TX-0(쿠폰 USED) → 결제 실패 → 쿠폰 복원 시도 → DB 일시 장애 → 복원 실패 + +현재 대응: 배치에서 복원 (§19.3) +대사 역할: 배치가 놓친 건이 있는지 전수 확인 +``` + +### 22.3 대사 배치 설계 + +#### [R1] PG ↔ Payment 대사 배치 + +``` +주기: 1시간 (또는 1일 1회) +대상: 최근 24시간 내 Payment 중 status = PAID 또는 FAILED + +동작: + 1. Payment 테이블에서 대상 조회 + SELECT * FROM payment + WHERE status IN ('PAID', 'FAILED') + AND updated_at > NOW() - INTERVAL 24 HOUR + AND reconciled = false + + 2. 각 건에 대해 PG 상태 확인 + GET /api/v1/payments/{transactionKey} + + 3. 대조 + | 우리 상태 | PG 상태 | 판정 | + |----------|---------|------| + | PAID | SUCCESS | ✅ 일치 → reconciled = true | + | PAID | FAILED | 🔴 불일치 → 알림 + 수동 확인 대상 | + | PAID | PENDING | 🟡 PG 미확정 → 다음 주기에 재확인 | + | PAID | 404 | 🔴 PG에 기록 없음 → 알림 | + | FAILED | SUCCESS | 🔴 환불 누락 → 알림 + 자동/수동 보상 | + | FAILED | FAILED | ✅ 일치 → reconciled = true | + + 4. 불일치 건 → reconciliation_mismatch 테이블에 기록 + 운영 알림 +``` + +``` +불일치 시 자동 보상 vs 수동 확인: + + PAID-FAILED 불일치: + → 자동 보상 위험 (고객 주문 취소 → CS 발생) + → 수동 확인 후 처리 + + FAILED-SUCCESS 불일치: + → 자동 보상 가능 (PAID로 전환 + 주문 확정) + → 단, 이미 재주문했을 수 있으므로 조건 확인 필요 + +결론: 대사 배치는 "감지 + 알림"이 주 역할. + 자동 보상은 안전한 경우에만 (FAILED→SUCCESS 전환). +``` + +#### [R2] Payment ↔ Order 대사 배치 + +``` +주기: 1시간 +대상: 같은 DB (모놀리스) → JOIN 쿼리 1건 + +SELECT p.id, p.status as payment_status, o.status as order_status +FROM payment p +JOIN orders o ON p.order_id = o.id +WHERE (p.status = 'PAID' AND o.status != 'PAID') + OR (p.status = 'FAILED' AND o.status NOT IN ('CANCELLED', 'CREATED')) + +→ 결과가 0건이면 정상 +→ 1건이라도 나오면 운영 알림 + +모놀리스 이점: JOIN 1건으로 끝. MSA면 양쪽 API 호출 + 매칭 로직 필요. +``` + +#### [R3] Payment ↔ Coupon 대사 배치 + +``` +주기: 1시간 +대상: 같은 DB → JOIN 쿼리 1건 + +SELECT p.id, p.status, ci.id as coupon_issue_id, ci.status as coupon_status +FROM payment p +JOIN coupon_issue ci ON p.coupon_issue_id = ci.id +WHERE p.status IN ('FAILED', 'CANCELLED') + AND ci.status = 'USED' + +→ 결과가 있으면: 쿠폰 복원 누락 +→ 자동 복원: couponFacade.restoreCoupon(couponIssueId) +→ 복원 후 로그 + 메트릭 기록 +``` + +### 22.4 대사용 테이블 + +```sql +CREATE TABLE reconciliation_mismatch ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + type VARCHAR(30) NOT NULL, -- 'PG_PAYMENT', 'PAYMENT_ORDER', 'PAYMENT_COUPON' + payment_id BIGINT NOT NULL, + our_status VARCHAR(20) NOT NULL, + external_status VARCHAR(20), -- PG 상태 (R1) 또는 Order/Coupon 상태 (R2/R3) + detected_at DATETIME NOT NULL, + resolved_at DATETIME, + resolution VARCHAR(50), -- 'AUTO_FIXED', 'MANUAL_FIXED', 'FALSE_ALARM' + note TEXT +); +``` + +### 22.5 복구 배치 vs 대사 배치 전체 구조 + +``` +[실시간 복구 — 빠르게 고친다] + Outbox Poller (5초) → PG 호출 누락 재시도 + Callback DLQ → 콜백 처리 실패 재시도 + Polling Hybrid (10초) → 콜백 미수신 시 능동 확인 + +[주기적 복구 — 놓친 건을 잡는다] + Payment Recovery (1분) → REQUESTED/PENDING/UNKNOWN 복구 + Stock Reconcile (30초) → Redis-DB 재고 보정 (Lua Script) + Proactive Expiry Scanner (30초) → 가주문 TTL 만료 선제 정리 + +[대사 — 전수 검증한다] + PG ↔ Payment (1시간) → PAID/FAILED 건 PG 대조 [R1] + Payment ↔ Order (1시간) → 상태 불일치 감지 [R2] + Payment ↔ Coupon (1시간) → 쿠폰 복원 누락 감지 + 자동 복원 [R3] +``` + +``` +복구와 대사의 관계: + +복구가 완벽하면 대사에서 불일치가 0건이어야 한다. +→ 대사는 "복구가 잘 동작하는지 검증하는 최종 안전망" +→ 대사에서 불일치가 발견되면 = 복구 로직에 버그가 있다는 신호 + +쿠팡 관점: 대사 없는 결제 시스템은 없다. +정산 시점에 불일치가 발견되면 이미 늦다. +→ 1시간~일 단위 대사로 조기 감지. +``` + +### 22.6 대사 배치의 PG 부하 검증 + +``` +[R1] PG ↔ Payment 대사: + 대상: 최근 24시간 PAID + FAILED 건 + 현재 과제 규모: 하루 ~1000건 가정 + PG 조회: 1000건 × 1회 = 1000 API 호출 + Rate Limiter: 10 req/sec → 1000건 / 10 = 100초 (약 2분) + → 1시간 주기 대비 2분 실행 → 부하율 3.3% + + 쿠팡 규모: 하루 100만 건 + 100만 / 10 req/sec = 100,000초 (약 28시간) → 불가 + → 프로덕션에서는 PG 정산 파일(bulk) 방식 사용 + → 현재 과제에서는 API 호출 방식으로 충분 +``` + +### 22.7 → 05 반영 사항 + +| 반영 대상 | 내용 | +|---|---| +| Section 10 (복구/대사) | 대사 배치 3종 (R1/R2/R3) 설계 추가 | +| Section 15 (구현 계획) | Phase 5에 대사 배치 항목 추가 | +| Section 16 (패키지 구조) | ReconciliationScheduler 추가 | +| Section 17 (의존성) | reconciliation_mismatch 테이블 DDL | diff --git a/docs/design/07-implementation-spec.md b/docs/design/07-implementation-spec.md new file mode 100644 index 0000000000..84c49765a0 --- /dev/null +++ b/docs/design/07-implementation-spec.md @@ -0,0 +1,1499 @@ +# PG 비동기 결제 연동 — 구현 + 테스트 명세 + +> **문서 목적**: 05(설계)와 06(리뷰)은 "무엇을 왜 이렇게 설계했는가"를 다룬다. +> 이 문서는 "무엇을 어떤 순서로 만들고, 어떻게 검증하는가"를 다룬다. +> +> **참조**: 05-payment-resilience.md (설계 명세), 06-resilience-review.md (리뷰/분석) + +--- + +## 0. 사전 준비 + +### 0.1 신규 의존성 + +| 의존성 | 모듈 | 용도 | 비고 | +|--------|------|------|------| +| `io.github.resilience4j:resilience4j-spring-boot3` | commerce-api | CB, Retry, RateLimiter | Resilience4j 스타터 | +| `org.springframework.boot:spring-boot-starter-aop` | commerce-api | @CircuitBreaker, @Retry AOP | Resilience4j 어노테이션 지원 | +| `org.springframework.cloud:spring-cloud-starter-openfeign` | commerce-api | PG Simulator Feign Client | HTTP 클라이언트 | +| `org.wiremock:wiremock-standalone:3.5.4` | commerce-api (test) | PG 장애 시뮬레이션 | 테스트 전용 | +| `org.springframework.batch:spring-batch-test` | commerce-batch (test) | @SpringBatchTest 지원 | 이미 존재 확인 | + +> **이미 존재하는 의존성 (추가 불필요)**: +> - `spring-boot-starter-data-redis` → modules/redis에 포함 +> - `testcontainers:mysql` → modules/jpa testFixtures +> - `testcontainers-redis` → modules/redis testFixtures + +### 0.2 신규 Fake 클래스 목록 + +기존 프로젝트 패턴 준수: `src/test/java/com/loopers/fake/`에 `ConcurrentHashMap` 기반 Fake Repository 생성. + +| # | Fake 클래스 | 구현 대상 인터페이스 | 용도 | +|---|------------|-------------------|------| +| 1 | `FakePaymentRepository` | `PaymentRepository` | Payment CRUD + 상태별 조회 + 조건부 UPDATE 시뮬레이션 | +| 2 | `FakePaymentOutboxRepository` | `PaymentOutboxRepository` | Outbox PENDING 조회 + 상태 전이 | +| 3 | `FakeCallbackInboxRepository` | `CallbackInboxRepository` | Callback DLQ 저장 + 미처리 건 조회 | +| 4 | `FakePgClient` | `PgClient` | 결제 요청/상태 확인 시뮬레이션 (성공/실패 제어 가능) | +| 5 | `FakeProvisionalOrderRedisRepository` | `ProvisionalOrderRedisRepository` | 가주문 Redis 저장/조회/삭제 (ConcurrentHashMap) | +| 6 | `FakeStockReservationRedisRepository` | `StockReservationRedisRepository` | 재고 DECR/INCR 시뮬레이션 (AtomicInteger) | + +### 0.3 WireMock 장애 시뮬레이션 패턴 + +PG 외부 호출 장애를 재현하기 위한 WireMock 스텁 패턴. + +```java +// 패턴 1: PG 500 에러 (서버 불안정) +stubFor(post("/api/v1/payments") + .willReturn(aResponse().withStatus(500).withBody("Internal Server Error"))); + +// 패턴 2: PG 응답 지연 (타임아웃 유발) +stubFor(post("/api/v1/payments") + .willReturn(aResponse().withStatus(200).withFixedDelay(3000))); // 3초 지연 → readTimeout(1초) 초과 + +// 패턴 3: PG 연결 실패 (ConnectException) +stubFor(post("/api/v1/payments") + .willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER))); + +// 패턴 4: 정상 PENDING 응답 +stubFor(post("/api/v1/payments") + .willReturn(okJson("{\"status\":\"PENDING\",\"transactionKey\":\"TX-001\"}"))); + +// 패턴 5: 상태 확인 — SUCCESS +stubFor(get(urlPathMatching("/api/v1/payments/.*")) + .willReturn(okJson("{\"status\":\"SUCCESS\",\"transactionKey\":\"TX-001\"}"))); + +// 패턴 6: 상태 확인 — 404 (PG에 기록 없음) +stubFor(get(urlPathMatching("/api/v1/payments.*")) + .willReturn(aResponse().withStatus(404))); + +// 패턴 7: 시나리오 기반 (첫 2회 500 → 3번째 성공) +stubFor(post("/api/v1/payments").inScenario("retry-test") + .whenScenarioStateIs(STARTED) + .willReturn(aResponse().withStatus(500)) + .willSetStateTo("SECOND")); +stubFor(post("/api/v1/payments").inScenario("retry-test") + .whenScenarioStateIs("SECOND") + .willReturn(aResponse().withStatus(500)) + .willSetStateTo("THIRD")); +stubFor(post("/api/v1/payments").inScenario("retry-test") + .whenScenarioStateIs("THIRD") + .willReturn(okJson("{\"status\":\"PENDING\",\"transactionKey\":\"TX-001\"}"))); +``` + +### 0.4 CB 상태 전이 테스트 전략 + +Resilience4j `CircuitBreakerRegistry`를 직접 조작하여 CB 상태를 검증한다. + +```java +@Autowired +private CircuitBreakerRegistry circuitBreakerRegistry; + +// CB 상태 확인 +CircuitBreaker cb = circuitBreakerRegistry.circuitBreaker("pgSimulator-request"); +assertThat(cb.getState()).isEqualTo(CircuitBreaker.State.CLOSED); + +// CB 강제 전이 (Open) +cb.transitionToOpenState(); +assertThat(cb.getState()).isEqualTo(CircuitBreaker.State.OPEN); + +// CB 메트릭 확인 +CircuitBreaker.Metrics metrics = cb.getMetrics(); +assertThat(metrics.getFailureRate()).isGreaterThanOrEqualTo(50.0f); +``` + +### 0.5 "Broken State" 세팅 전략 (Recovery 테스트용) + +복구 테스트는 "고장 상태를 먼저 만들고 → 복구 메커니즘이 고치는지 확인"하는 패턴. + +| 고장 상태 | 세팅 방법 | 검증 대상 | +|----------|----------|----------| +| Payment `REQUESTED` 방치 | DB에 직접 INSERT (createdAt = 2분 전) | 배치 복구가 PG 조회 → FAILED 처리 | +| Payment `PENDING` 장기 체류 | DB에 직접 INSERT (createdAt = 6분 전) | 배치 복구가 FAILED + 재고 복원 | +| Payment `UNKNOWN` | DB에 직접 INSERT | 배치/폴링이 PG 조회 → PAID/FAILED 전이 | +| Outbox `PENDING` 미처리 | PaymentOutbox INSERT (status=PENDING) | Outbox 폴러가 PG 호출 | +| Callback `RECEIVED` 미처리 | CallbackInbox INSERT (status=RECEIVED) | DLQ 스케줄러가 재처리 | +| Redis-DB 재고 불일치 | Redis SET stock:1 = 5, DB stock = 10 | 정합성 배치가 DB 기준 보정 | +| 가주문 TTL 임박 | Redis HSET + EXPIRE 20초 | Proactive Expiry Scanner가 선제 정리 | + +--- + +## Phase 1: 기반 구축 + +> **05 참조**: §2, §3, §4, §8.3, §12, §15(Phase 1), §16 + +### 1.1 구현 항목 + +| # | 항목 | 05 참조 | +|---|------|---------| +| 1 | Payment 도메인 모델 (Entity + Status Enum + Repository) | §4 | +| 2 | PgClient 인터페이스 + PG 추상화 DTO | §8.3 | +| 3 | SimulatorPgClient (Feign) + Timeout 적용 | §5, §8.3 | +| 4 | PgRouter (Strategy Pattern) + 기본 Fallback | §8.3 | +| 5 | 결제 요청 API (`POST /api/v1/payments`) 기본 흐름 | §11.1 | +| 6 | 가주문 모델 (ProvisionalOrder) + Redis Repository | §16(06 §16) | +| 7 | 가주문 TTL Jitter 적용 (±5분, 25~35분) | 06 §16.14.4 | + +### 1.2 생성/수정 파일 + +**생성:** + +``` +# 도메인 +domain/payment/PaymentModel.java +domain/payment/PaymentStatus.java # enum: REQUESTED, PENDING, PAID, FAILED, UNKNOWN +domain/payment/PaymentRepository.java +domain/payment/PaymentService.java + +# 인프라 — DB +infrastructure/payment/PaymentJpaRepository.java +infrastructure/payment/PaymentRepositoryImpl.java + +# 인프라 — PG +infrastructure/pg/PgClient.java # interface +infrastructure/pg/PgRouter.java +infrastructure/pg/PgPaymentRequest.java +infrastructure/pg/PgPaymentResponse.java +infrastructure/pg/PgPaymentStatusResponse.java +infrastructure/pg/PgCallbackPayload.java +infrastructure/pg/simulator/SimulatorPgClient.java +infrastructure/pg/simulator/SimulatorFeignClient.java # Feign interface +infrastructure/pg/simulator/SimulatorPgConfig.java # Timeout 설정 + +# 인프라 — Redis +infrastructure/redis/ProvisionalOrderRedisRepository.java +infrastructure/redis/StockReservationRedisRepository.java + +# 애플리케이션 +application/payment/PaymentFacade.java +application/order/ProvisionalOrderService.java + +# 인터페이스 +interfaces/api/payment/PaymentV1Controller.java +interfaces/api/payment/PaymentV1Dto.java +interfaces/api/payment/PaymentV1ApiSpec.java +``` + +**수정:** + +``` +# 의존성 +apps/commerce-api/build.gradle.kts # Resilience4j, Feign, WireMock 추가 +``` + +### 1.3 테스트 목록 + +#### Unit + +| # | 테스트 클래스 | 시나리오 | 검증 포인트 | +|---|-------------|---------|-----------| +| U1-1 | `PaymentModelTest` | Payment 생성 시 초기 상태 REQUESTED | 상태 초기값 | +| U1-2 | `PaymentModelTest` | REQUESTED → PENDING 전이 성공 | 정상 전이 | +| U1-3 | `PaymentModelTest` | PENDING → PAID 전이 성공 | 정상 전이 | +| U1-4 | `PaymentModelTest` | PENDING → FAILED 전이 성공 | 정상 전이 | +| U1-5 | `PaymentModelTest` | PAID → FAILED 전이 불가 (예외) | 잘못된 전이 방지 | +| U1-6 | `PaymentModelTest` | FAILED → PAID 전이 불가 (예외) | 최종 상태 보호 | +| U1-7 | `PaymentStatusTest` | 각 상태의 허용 전이 목록 검증 | enum 로직 | +| U1-8 | `PaymentFacadeTest` | 정상 결제 요청 → PENDING 응답 | Facade 조율 | +| U1-9 | `PaymentFacadeTest` | 주문 없음 → 예외 | 검증 로직 | +| U1-10 | `PaymentFacadeTest` | 이미 결제된 주문 → 예외 | 중복 방지 | +| U1-11 | `PgRouterTest` | Primary PG 성공 → 즉시 반환 | 정상 라우팅 | +| U1-12 | `PgRouterTest` | Primary PG 실패 → Fallback PG 시도 | Fallback 전환 | +| U1-13 | `PgRouterTest` | 모든 PG 실패 → AllPgFailedException | 최종 예외 | + +#### Integration + +| # | 테스트 클래스 | 시나리오 | 검증 포인트 | +|---|-------------|---------|-----------| +| I1-1 | `PaymentFacadeIntegrationTest` | 결제 요청 → Payment DB 저장 확인 | DB 영속성 | +| I1-2 | `PaymentFacadeIntegrationTest` | 결제 요청 → 가주문 Redis 저장 확인 | Redis 연동 | + +### 1.4 테스트 시나리오 (Given-When-Then) + +#### U1-8: 정상 결제 요청 → PENDING 응답 + +``` +Given: + - FakeOrderRepository에 orderId=100인 주문 존재 (status=CREATED) + - FakePgClient가 PENDING 응답 반환하도록 설정 + - FakePaymentRepository 비어 있음 + +When: + - paymentFacade.requestPayment(orderId=100, cardType=SAMSUNG, cardNo=1234-..., amount=5000) + +Then: + - Payment가 저장됨 (status=PENDING, transactionKey 존재) + - 반환값에 transactionKey 포함 + - FakePgClient.requestPayment()가 1회 호출됨 +``` + +#### U1-12: Primary PG 실패 → Fallback PG 시도 + +``` +Given: + - PgRouter에 [FakePgClient(primary, 항상 실패), FakePgClient(fallback, 항상 성공)] 등록 + +When: + - pgRouter.requestPayment(request) + +Then: + - Fallback PG의 응답이 반환됨 + - Primary PG 실패 로그 기록됨 +``` + +--- + +## Phase 2: Resilience 적용 (PG) + +> **05 참조**: §5, §6, §7, §15(Phase 2) +> **06 참조**: §13, §15, §18 + +### 2.1 구현 항목 + +| # | 항목 | 05 참조 | +|---|------|---------| +| 7 | Resilience4j 의존성 + YAML 설정 | §17 | +| 8 | PG별 독립 Retry (수동 Retry 루프 + PG 상태 확인) | §6 | +| 9 | PG별 독립 CircuitBreaker (쓰기 3개: pgSimulator-request, pgToss-request, redis-write) | §7.4, 06 §18 | +| 10 | SlidingWindowRateLimiter 구현 (결제 요청: 50 req/sec) | §7.4 | +| 11 | PaymentRateLimiterInterceptor (AOP) | §7.5 | +| 12 | 배치 Rate Limiter 설정 (Resilience4j Fixed Window: 10 req/sec) | §7.4 | +| 13 | 최종 Fallback (UNKNOWN 상태) 구현 | §8.7 | +| 14 | Health Check Probe + Progressive Backoff 구현 | §7.6, 06 §15 | + +### 2.2 생성/수정 파일 + +**생성:** + +``` +# Resilience +infrastructure/resilience/SlidingWindowRateLimiter.java +infrastructure/resilience/PaymentRateLimiterInterceptor.java +infrastructure/resilience/ProgressiveBackoffCustomizer.java +infrastructure/pg/PgHealthChecker.java + +# 설정 +apps/commerce-api/src/main/resources/resilience4j.yml # 또는 application.yml에 추가 +``` + +**수정:** + +``` +infrastructure/pg/simulator/SimulatorPgClient.java # @Retry, @CircuitBreaker 추가 +infrastructure/pg/PgRouter.java # Fallback 로직 강화 +application/payment/PaymentFacade.java # 수동 Retry 루프 + UNKNOWN Fallback +``` + +### 2.3 테스트 목록 + +#### Unit + +| # | 테스트 클래스 | 시나리오 | 검증 포인트 | +|---|-------------|---------|-----------| +| U2-1 | `SlidingWindowRateLimiterTest` | 50건 이내 → 전부 허용 | 정상 범위 | +| U2-2 | `SlidingWindowRateLimiterTest` | 51번째 요청 → 거부 (false) | 초과 차단 | +| U2-3 | `SlidingWindowRateLimiterTest` | 윈도우 경계에서 이전 윈도우 가중치 적용 | Sliding Window 정확성 | +| U2-4 | `PaymentFacadeTest` | PG 1차 실패 → 재시도 전 PG 상태 확인 → PG에 기록 있음 → 재시도 안 함 | 멱등성 보장 | +| U2-5 | `PaymentFacadeTest` | PG 1차 실패 → PG 상태 확인 → 기록 없음 → 재시도 → 성공 | 수동 Retry | +| U2-6 | `PaymentFacadeTest` | 모든 PG 실패 → UNKNOWN 상태 저장 + "확인 중" 응답 | 최종 Fallback | + +#### Fault Injection (WireMock) + +| # | 테스트 클래스 | 시나리오 | 검증 포인트 | +|---|-------------|---------|-----------| +| F2-1 | `PgRetryFaultTest` | PG 500 에러 2회 → 3번째 성공 | Retry 동작 | +| F2-2 | `PgRetryFaultTest` | PG 500 에러 3회 연속 → Fallback PG 전환 | Retry 소진 + Fallback | +| F2-3 | `PgTimeoutFaultTest` | PG 응답 3초 지연 → readTimeout(1초) 초과 → Retry | Timeout + Retry | +| F2-4 | `PgTimeoutFaultTest` | 타임아웃 실패 → Fallback PG 전환하지 않음 (중복 결제 방지) | 05 §8.3 규칙 | +| F2-5 | `PgCircuitBreakerFaultTest` | 10건 중 6건 실패 → CB Open → 이후 요청 즉시 Fallback | CB Open 전이 | +| F2-6 | `PgCircuitBreakerFaultTest` | CB Open → Health Probe 성공 → Half-Open → Closed | CB 복구 흐름 | +| F2-7 | `PgRateLimiterFaultTest` | 초당 60건 요청 → 50건 성공 + 10건 429 | Rate Limiter 동작 | + +#### Performance + +| # | 테스트 클래스 | 시나리오 | 검증 포인트 | +|---|-------------|---------|-----------| +| P2-1 | `RateLimiterPerformanceTest` | 100 스레드 동시 요청 → 50건/초 제한 준수 | Sliding Window 동시성 | + +### 2.4 테스트 시나리오 (Given-When-Then) + +#### F2-1: PG 500 에러 2회 → 3번째 성공 + +``` +Given: + - WireMock 시나리오: POST /api/v1/payments → 1,2회 500 / 3회 200 PENDING + - Payment(REQUESTED) 생성 완료 + +When: + - paymentFacade.requestPayment(request) + +Then: + - 최종 응답: PENDING (transactionKey 존재) + - WireMock 호출 횟수: 3회 (PG 상태 확인 포함 시 추가) + - Payment 상태: PENDING +``` + +#### F2-5: CB Open 전이 검증 + +``` +Given: + - WireMock: POST /api/v1/payments → 항상 500 + - CB "pgSimulator-request" 상태: CLOSED + - slidingWindowSize: 10, failureRateThreshold: 50 + +When: + - 10건 결제 요청 실행 (각각 Retry 3회 × 10건) + +Then: + - CB 상태: OPEN + - 11번째 요청: CB가 즉시 차단 → Fallback PG로 전환 + - CB 메트릭: failureRate ≥ 50% +``` + +--- + +## Phase 3: Resilience 적용 (Redis) + +> **05 참조**: §7.4, §15(Phase 3) +> **06 참조**: §16, §18 + +### 3.1 구현 항목 + +| # | 항목 | 05/06 참조 | +|---|------|-----------| +| 15 | Redis CB 1개 (`redis-write`만) + Lettuce commandTimeout 설정 | 05 §7.4, 06 §18 | +| 16 | Redis Fallback: DB 직접 주문 (ProvisionalOrderService + fallbackMethod) | 06 §16.9 | +| 17 | 재고 예약: masterRedisTemplate DECR + DB UPDATE 이중 관리 | 06 §16.3 Option C | +| 18 | Redis-DB 재고 정합성 배치 — Lua Script v2 (30초 주기) | 06 §16.14.5 | +| 19 | 가주문 선제 만료 배치 — Proactive Expiry Scanner (30초 주기) | 06 §16.14.3 | + +### 3.2 생성/수정 파일 + +**생성:** + +``` +infrastructure/scheduler/StockReconcileScheduler.java +infrastructure/scheduler/ProvisionalOrderExpiryScheduler.java +``` + +**수정:** + +``` +application/order/ProvisionalOrderService.java # @CircuitBreaker("redis-write") + DB Fallback 추가 +infrastructure/redis/StockReservationRedisRepository.java # Lua Script v2 +``` + +### 3.3 테스트 목록 + +#### Unit + +| # | 테스트 클래스 | 시나리오 | 검증 포인트 | +|---|-------------|---------|-----------| +| U3-1 | `ProvisionalOrderServiceTest` | Redis 정상 → 가주문 Redis 저장 | 정상 경로 | +| U3-2 | `ProvisionalOrderServiceTest` | Redis 장애 → DB 직접 주문 Fallback | Fallback 동작 | +| U3-3 | `StockReservationTest` | Redis DECR → 재고 감소 확인 | 재고 예약 | +| U3-4 | `StockReservationTest` | Redis INCR → 재고 복원 확인 | 재고 복원 | + +#### Integration + +| # | 테스트 클래스 | 시나리오 | 검증 포인트 | +|---|-------------|---------|-----------| +| I3-1 | `RedisResilienceIntegrationTest` | redis-write CB Open → DB Fallback → 주문 DB 저장 | CB + Fallback 연동 | +| I3-2 | `StockReconcileIntegrationTest` | Redis 재고 5, DB 재고 10 → 배치 → Redis 재고 10 | Lua Script 보정 | +| I3-3 | `ProvisionalOrderExpiryIntegrationTest` | 가주문 TTL 20초 → 배치 → 재고 복원 + 가주문 삭제 | 선제 만료 | + +#### Recovery + +| # | 테스트 클래스 | 시나리오 | 검증 포인트 | +|---|-------------|---------|-----------| +| R3-1 | `StockReconcileRecoveryTest` | Redis 재시작 후 재고 0 → 배치 → DB 기준 보정 | 장애 복구 | +| R3-2 | `ProvisionalOrderExpiryRecoveryTest` | TTL 만료 직전 가주문 5건 → 배치 → 전부 정리 + 재고 복원 | 배치 정리 | + +#### Concurrency + +| # | 테스트 클래스 | 시나리오 | 검증 포인트 | +|---|-------------|---------|-----------| +| C3-1 | `StockReservationConcurrencyTest` | 10 스레드 동시 DECR → 정확히 10 감소 | Redis 원자성 | + +### 3.4 테스트 시나리오 (Given-When-Then) + +#### I3-1: redis-write CB Open → DB Fallback + +``` +Given: + - Redis Master 중지 (Testcontainers 조작) + - redis-write CB → 실패 누적 → Open 상태 + +When: + - provisionalOrderService.createProvisionalOrder(request) + +Then: + - DB에 Order(CREATED) INSERT 확인 + - DB에 재고 차감 확인 + - 반환값: ProvisionalOrderResult.directOrder(...) + - 로그: "Redis 장애 — DB 직접 주문으로 Fallback" 확인 +``` + +#### R3-1: Redis 재시작 후 재고 보정 + +``` +Given: + - DB stock:productId=1 = 100 + - Redis stock:1 = 0 (재시작으로 데이터 유실) + +When: + - stockReconcileScheduler.reconcileStock() 실행 + +Then: + - Redis stock:1 = 100 (DB 기준 보정) + - 로그: "재고 불일치 감지: productId=1, redis=0, db=100" 확인 +``` + +--- + +## Phase 4: 콜백 + 상태 동기화 + +> **05 참조**: §8.5, §8.6, §9, §15(Phase 4) +> **06 참조**: §12.4, §12.6, §14 + +### 4.1 구현 항목 + +| # | 항목 | 05 참조 | +|---|------|---------| +| 20 | Callback Inbox (DLQ) 테이블 + 엔티티 + Repository | §8.5 | +| 21 | 콜백 수신 API (`POST /api/v1/payments/callback`) | §9.1 | +| 22 | 조건부 UPDATE 기반 상태 전이 | §9.3 | +| 23 | 결제 실패 시 재고 복원 (Redis INCR + DB) + 쿠폰 복원 (DB UPDATE) | §14(13.2) | +| 24 | Polling Hybrid (Delayed Task) 구현 | §8.4 | + +### 4.2 생성/수정 파일 + +**생성:** + +``` +# 도메인 +domain/payment/CallbackInbox.java +domain/payment/CallbackInboxRepository.java + +# 인프라 +infrastructure/payment/CallbackInboxJpaRepository.java +infrastructure/payment/CallbackInboxRepositoryImpl.java + +# 인터페이스 +interfaces/api/payment/PaymentCallbackController.java + +# 복구 +application/payment/PaymentRecoveryService.java +``` + +**수정:** + +``` +application/payment/PaymentFacade.java # Polling Hybrid Delayed Task 등록 +infrastructure/payment/PaymentRepositoryImpl.java # 조건부 UPDATE 쿼리 추가 +``` + +### 4.3 테스트 목록 + +#### Unit + +| # | 테스트 클래스 | 시나리오 | 검증 포인트 | +|---|-------------|---------|-----------| +| U4-1 | `PaymentCallbackTest` | SUCCESS 콜백 → Payment PAID + Order PAID | 정상 콜백 | +| U4-2 | `PaymentCallbackTest` | FAILED 콜백 → Payment FAILED + 재고 복원 + 쿠폰 복원 | 실패 콜백 | +| U4-3 | `PaymentCallbackTest` | PENDING 콜백 → 무시 (상태 변경 없음) | 06 §14.4 규칙 | +| U4-4 | `PaymentCallbackTest` | 존재하지 않는 transactionKey → 로그 남기고 무시 | 안전 처리 | +| U4-5 | `PaymentRecoveryServiceTest` | Polling: PG SUCCESS → PAID 전이 | 폴링 복구 | +| U4-6 | `PaymentRecoveryServiceTest` | Polling: PG PENDING + 생성 5분 미만 → 유지 | 대기 유지 | +| U4-7 | `CallbackInboxTest` | 콜백 원본 저장 → RECEIVED 상태 확인 | DLQ 저장 | + +#### Idempotency + +| # | 테스트 클래스 | 시나리오 | 검증 포인트 | +|---|-------------|---------|-----------| +| D4-1 | `CallbackIdempotencyTest` | 동일 콜백 2회 수신 → 2번째 affected rows = 0 → 무시 | 조건부 UPDATE 멱등성 | +| D4-2 | `CallbackIdempotencyTest` | 이미 PAID인 Payment에 SUCCESS 콜백 → 무시 | 최종 상태 보호 | +| D4-3 | `CallbackIdempotencyTest` | 이미 FAILED인 Payment에 SUCCESS 콜백 → 무시 | 최종 상태 보호 | + +#### Concurrency + +| # | 테스트 클래스 | 시나리오 | 검증 포인트 | +|---|-------------|---------|-----------| +| C4-1 | `CallbackConcurrencyTest` | 콜백 + 배치 동시에 같은 Payment 처리 → 1건만 성공 | 조건부 UPDATE 동시성 | +| C4-2 | `DuplicatePaymentConcurrencyTest` | 같은 orderId로 동시 결제 2건 → 1건만 성공 (UNIQUE 위반) | 중복 결제 방지 | + +#### Integration + +| # | 테스트 클래스 | 시나리오 | 검증 포인트 | +|---|-------------|---------|-----------| +| I4-1 | `CallbackIntegrationTest` | 콜백 수신 → Inbox 저장 → Payment 전이 → Inbox PROCESSED | 전체 흐름 | +| I4-2 | `PollingHybridIntegrationTest` | PENDING 저장 → 10초 후 Delayed Task → PG 조회 → PAID | 폴링 흐름 | + +### 4.4 테스트 시나리오 (Given-When-Then) + +#### C4-1: 콜백 + 배치 동시 처리 + +``` +Given: + - Payment(id=1, status=PENDING) DB에 존재 + - CountDownLatch(1) 준비 + +When: + - Thread 1 (콜백): callbackService.processCallback(transactionKey, SUCCESS) — latch.await() 후 실행 + - Thread 2 (배치): recoveryService.recoverPayment(paymentId=1) — latch.await() 후 실행 + - latch.countDown() → 동시 시작 + +Then: + - Payment 최종 상태: PAID (정확히 1건) + - 2개 스레드 중 1개만 affected rows = 1 + - 나머지 1개는 affected rows = 0 → 추가 처리 없이 종료 + - 재고/쿠폰 복원은 1회만 실행됨 +``` + +#### D4-1: 동일 콜백 2회 수신 멱등성 + +``` +Given: + - Payment(id=1, status=PENDING) + +When: + - callbackService.processCallback(TX-001, SUCCESS) — 1회 + - callbackService.processCallback(TX-001, SUCCESS) — 2회 (동일 콜백) + +Then: + - 1회: Payment → PAID, affected rows = 1 + - 2회: affected rows = 0, 추가 처리 없음 + - Order 상태 업데이트: 정확히 1회 + - CallbackInbox: 2건 저장 (원본 보존), 1건 PROCESSED + 1건 PROCESSED(중복 감지) +``` + +--- + +## Phase 5: Outbox + 복구 + 대사 + +> **05 참조**: §10, §13, §15(Phase 5) +> **06 참조**: §9, §22 + +### 5.1 구현 항목 + +| # | 항목 | 05 참조 | +|---|------|---------| +| 24 | PaymentOutbox 엔티티 + Repository + TX-1에 Outbox 저장 | §13 | +| 25 | Outbox 폴러 스케줄러 (5초 주기) | §13.4 | +| 26 | 배치 복구 (REQUESTED/PENDING/UNKNOWN, 1분 주기) — commerce-batch | §10.4 | +| 27 | 수동 복구 API (`POST /api/v1/payments/{paymentId}/confirm`) | §10.3 | +| 28 | Local WAL (PG 응답 로컬 기록 + Recovery) | §8.6 | +| 29 | Callback DLQ 재처리 스케줄러 | §8.5 | +| 30 | 대사 배치 [R1] PG ↔ Payment (1시간) | §10.6 | +| 31 | 대사 배치 [R2] Payment ↔ Order (1시간) | §10.6 | +| 32 | 대사 배치 [R3] Payment ↔ Coupon (1시간) | §10.6 | + +### 5.2 생성/수정 파일 + +**생성:** + +``` +# 도메인 +domain/payment/PaymentOutbox.java +domain/payment/PaymentOutboxRepository.java +domain/payment/ReconciliationMismatch.java +domain/payment/ReconciliationMismatchRepository.java + +# 인프라 — DB +infrastructure/payment/PaymentOutboxJpaRepository.java +infrastructure/payment/PaymentOutboxRepositoryImpl.java +infrastructure/payment/ReconciliationMismatchJpaRepository.java +infrastructure/payment/ReconciliationMismatchRepositoryImpl.java +infrastructure/payment/PaymentWalWriter.java + +# 스케줄러 (commerce-api) +infrastructure/scheduler/OutboxPollerScheduler.java +infrastructure/scheduler/CallbackDlqScheduler.java +infrastructure/scheduler/WalRecoveryScheduler.java + +# 배치 잡 (commerce-batch) +apps/commerce-batch/src/main/java/com/loopers/batch/job/paymentrecovery/ + PaymentRecoveryJobConfig.java + step/PaymentRecoveryTasklet.java + +apps/commerce-batch/src/main/java/com/loopers/batch/job/reconciliation/ + PgPaymentReconciliationJobConfig.java + step/PgPaymentReconciliationTasklet.java + PaymentOrderReconciliationJobConfig.java + step/PaymentOrderReconciliationTasklet.java + PaymentCouponReconciliationJobConfig.java + step/PaymentCouponReconciliationTasklet.java + +# 인터페이스 +interfaces/api/payment/PaymentRecoveryV1Controller.java +``` + +**수정:** + +``` +application/payment/PaymentFacade.java # TX-1에 Outbox 저장 추가 +apps/commerce-batch/build.gradle.kts # commerce-api 도메인 의존성 (필요 시) +``` + +### 5.3 테스트 목록 + +#### Unit + +| # | 테스트 클래스 | 시나리오 | 검증 포인트 | +|---|-------------|---------|-----------| +| U5-1 | `OutboxPollerTest` | Outbox PENDING → PG 상태 확인 → PG에 기록 없음 → PG 호출 | 폴러 동작 | +| U5-2 | `OutboxPollerTest` | Outbox PENDING → Payment 이미 PAID → Outbox PROCESSED | 다른 경로 해결 감지 | +| U5-3 | `OutboxPollerTest` | Outbox retry 3회 초과 → FAILED + 알림 | 재시도 상한 | +| U5-4 | `PaymentRecoveryTaskletTest` | REQUESTED(2분 전) → PG 404 → FAILED | 배치 복구 | +| U5-5 | `PaymentRecoveryTaskletTest` | PENDING(6분 전) → PG PENDING → FAILED + 재고 복원 | PENDING 타임아웃 | +| U5-6 | `PaymentRecoveryTaskletTest` | UNKNOWN → PG SUCCESS → PAID | UNKNOWN 복구 | +| U5-7 | `PaymentWalWriterTest` | WAL 기록 → 파일 존재 확인 → 삭제 | WAL 기본 동작 | +| U5-8 | `CallbackDlqSchedulerTest` | RECEIVED(30초 전) → 재처리 → PROCESSED | DLQ 재처리 | +| U5-9 | `ManualRecoveryTest` | confirm API → PG 조회 → PAID 전이 | 수동 복구 | + +#### Integration + +| # | 테스트 클래스 | 시나리오 | 검증 포인트 | +|---|-------------|---------|-----------| +| I5-1 | `OutboxIntegrationTest` | TX-1 → Outbox 저장 → 폴러 → PG 호출 → TX-2 | 전체 흐름 | +| I5-2 | `WalRecoveryIntegrationTest` | WAL 기록 → DB 저장 실패 → WAL Recovery → DB 반영 | WAL 복구 | + +#### Recovery + +| # | 테스트 클래스 | 시나리오 | 검증 포인트 | +|---|-------------|---------|-----------| +| R5-1 | `PaymentRecoveryBatchTest` | REQUESTED 방치 3건 + PENDING 장기 2건 + UNKNOWN 1건 → 배치 → 전부 확정 | 배치 복구 전체 | +| R5-2 | `PgReconciliationBatchTest` | Payment PAID + PG FAILED → 불일치 기록 + 알림 | [R1] PG 대사 | +| R5-3 | `OrderReconciliationBatchTest` | Payment PAID + Order CREATED → 불일치 감지 | [R2] 주문 대사 | +| R5-4 | `CouponReconciliationBatchTest` | Payment FAILED + Coupon USED → 자동 복원 | [R3] 쿠폰 대사 | + +#### Idempotency + +| # | 테스트 클래스 | 시나리오 | 검증 포인트 | +|---|-------------|---------|-----------| +| D5-1 | `OutboxIdempotencyTest` | Outbox 폴러가 동일 건 2회 처리 → PG 상태 확인으로 중복 방지 | 폴러 멱등성 | + +### 5.4 테스트 시나리오 (Given-When-Then) + +#### R5-1: 배치 복구 전체 시나리오 + +``` +Given: + - Payment A: status=REQUESTED, createdAt=2분 전 (Outbox도 PENDING) + - Payment B: status=PENDING, createdAt=6분 전 (콜백 미수신) + - Payment C: status=UNKNOWN (타임아웃으로 생성) + - WireMock: A의 orderId → 404 / B의 transactionKey → PG PENDING / C의 transactionKey → PG SUCCESS + +When: + - PaymentRecoveryTasklet.execute() + +Then: + - Payment A: status=FAILED (PG에 기록 없음), 재고 복원 + - Payment B: status=FAILED (5분 초과 PENDING → 타임아웃), 재고 복원 + 쿠폰 복원 + - Payment C: status=PAID, Order → PAID +``` + +#### R5-4: 쿠폰 대사 배치 — 자동 복원 + +``` +Given: + - Payment(id=1, status=FAILED, couponIssueId=10) + - CouponIssue(id=10, status=USED) ← 복원 누락 + +When: + - PaymentCouponReconciliationTasklet.execute() + +Then: + - CouponIssue(id=10, status=AVAILABLE) ← 자동 복원 + - ReconciliationMismatch 기록: type=PAYMENT_COUPON, paymentId=1 + - 로그: "쿠폰 복원 누락 감지: couponIssueId=10" 확인 +``` + +--- + +## Phase 6: Multi-PG (Toss Sandbox) + +> **05 참조**: §8.3, §15(Phase 6) +> **06 참조**: §11 + +### 6.1 구현 항목 + +| # | 항목 | 05/06 참조 | +|---|------|-----------| +| 33 | TossSandboxPgClient 구현 (동기 결제) | 06 §11.5 | +| 34 | Toss 전용 CB/Retry 설정 | 05 §7.4, 06 §11.7 | +| 35 | PgRouter에 Toss 등록 + Fallback 전환 로직 검증 | 06 §11.8 | + +### 6.2 생성/수정 파일 + +**생성:** + +``` +infrastructure/pg/toss/TossSandboxPgClient.java +infrastructure/pg/toss/TossFeignClient.java # Feign interface +infrastructure/pg/toss/TossSandboxPgConfig.java # Toss 전용 Timeout/CB/Retry +``` + +**수정:** + +``` +infrastructure/pg/PgRouter.java # Toss 클라이언트 등록 +resilience4j.yml # pgToss-request CB/Retry 설정 추가 +``` + +### 6.3 테스트 목록 + +#### Unit + +| # | 테스트 클래스 | 시나리오 | 검증 포인트 | +|---|-------------|---------|-----------| +| U6-1 | `TossSandboxPgClientTest` | Toss SUCCESS → Payment PAID 즉시 (콜백 불필요) | 동기 PG 처리 | +| U6-2 | `TossSandboxPgClientTest` | Toss FAILED → Payment FAILED 즉시 | 동기 실패 | +| U6-3 | `PgRouterTest` | Simulator CB Open → Toss 자동 전환 → SUCCESS | Multi-PG Fallback | +| U6-4 | `PgRouterTest` | Simulator 타임아웃 → Toss 전환하지 않음 → UNKNOWN | 중복 결제 방지 | + +#### Fault Injection (WireMock) + +| # | 테스트 클래스 | 시나리오 | 검증 포인트 | +|---|-------------|---------|-----------| +| F6-1 | `MultiPgFallbackFaultTest` | Simulator 500 3회 → Toss SUCCESS | PG Fallback 전환 | +| F6-2 | `MultiPgFallbackFaultTest` | Simulator + Toss 모두 실패 → UNKNOWN | 전체 장애 | +| F6-3 | `MultiPgFallbackFaultTest` | Simulator ConnectException → Toss 전환 (PG 도달 안 함 = 안전) | 전환 판단 기준 | + +#### Integration + +| # | 테스트 클래스 | 시나리오 | 검증 포인트 | +|---|-------------|---------|-----------| +| I6-1 | `TossIntegrationTest` | Toss 결제 → Payment PAID → Order PAID (콜백 없이 즉시) | 동기 PG 전체 흐름 | + +### 6.4 테스트 시나리오 (Given-When-Then) + +#### F6-1: Simulator → Toss Fallback + +``` +Given: + - WireMock Simulator: POST /api/v1/payments → 500 (항상 실패) + - WireMock Toss: POST /v1/payments/confirm → 200 SUCCESS + - Payment(REQUESTED) 생성 + +When: + - paymentFacade.requestPayment(request) + +Then: + - Simulator 3회 시도 → 전부 실패 (Retry 소진) + - Toss로 전환 → SUCCESS (동기) + - Payment 상태: PAID (콜백 대기 불필요) + - Order 상태: PAID + - Payment.pgProvider: "TOSS" +``` + +--- + +## Phase 7: 종합 테스트 + +> **05 참조**: §11, §15(Phase 7) + +### 7.1 구현 항목 + +| # | 항목 | 설명 | +|---|------|------| +| 36 | 전체 흐름 E2E 테스트 | 결제 요청 → PG 연동 → 콜백 → 확정 | +| 37 | 장애 시나리오 통합 테스트 | 타임아웃, CB, 콜백 미수신, Multi-PG | +| 38 | 배치 E2E 테스트 | commerce-batch에서 복구/대사 배치 실행 | + +### 7.2 테스트 목록 + +#### E2E (TestRestTemplate + RANDOM_PORT) + +| # | 테스트 클래스 | 시나리오 | 검증 포인트 | +|---|-------------|---------|-----------| +| E7-1 | `PaymentE2ETest` | POST /api/v1/payments → 200 + "결제 처리 중" | API 응답 | +| E7-2 | `PaymentE2ETest` | POST callback → 200 OK → GET 주문 → PAID | 전체 흐름 | +| E7-3 | `PaymentE2ETest` | POST /api/v1/payments/{id}/confirm → PG 조회 → 상태 갱신 | 수동 복구 API | +| E7-4 | `PaymentE2ETest` | 존재하지 않는 주문 결제 → 400 | 에러 응답 | +| E7-5 | `PaymentE2ETest` | 이미 결제된 주문 재결제 → 409 | 중복 방지 | + +#### Fault Injection (통합 장애 시나리오) + +| # | 테스트 클래스 | 시나리오 | 검증 포인트 | +|---|-------------|---------|-----------| +| F7-1 | `GhostPaymentFaultTest` | 타임아웃 → UNKNOWN → PG에서는 SUCCESS → 콜백 → PAID | 유령 결제 복구 | +| F7-2 | `ServerCrashFaultTest` | TX-1 커밋 → PG 호출 안 됨 → Outbox 폴러가 재시도 | Outbox 복구 | +| F7-3 | `CallbackMissFaultTest` | PENDING → 콜백 미수신 → 10초 후 Polling → PG 조회 → PAID | Polling Hybrid | +| F7-4 | `DbFailureFaultTest` | PG SUCCESS → DB 저장 실패 → WAL 기록 → WAL Recovery → DB 반영 | WAL 복구 | + +#### Batch E2E (commerce-batch) + +| # | 테스트 클래스 | 시나리오 | 검증 포인트 | +|---|-------------|---------|-----------| +| B7-1 | `PaymentRecoveryJobE2ETest` | 배치 실행 → REQUESTED/PENDING/UNKNOWN 복구 | 배치 잡 성공 | +| B7-2 | `PgReconciliationJobE2ETest` | 배치 실행 → PG 대사 → 불일치 기록 | 대사 잡 성공 | +| B7-3 | `CouponReconciliationJobE2ETest` | 배치 실행 → 쿠폰 복원 누락 → 자동 복원 | 쿠폰 대사 잡 | + +### 7.3 테스트 시나리오 (Given-When-Then) + +#### F7-1: 유령 결제 복구 + +``` +Given: + - WireMock: POST /api/v1/payments → 3초 지연 (타임아웃) + - WireMock: POST callback → Commerce API 콜백 엔드포인트 + - WireMock: GET /api/v1/payments/TX-001 → SUCCESS + +When: + - POST /api/v1/payments → 타임아웃 → UNKNOWN 저장 + - (3초 후) PG Simulator가 비동기 처리 완료 → SUCCESS 콜백 전송 + +Then: + - 콜백 수신 → CallbackInbox 저장 → 조건부 UPDATE → Payment PAID + - 또는 콜백 미수신 → Polling Hybrid(10초) → PG 조회 → PAID + - Order 상태: PAID + - 재고: 차감 유지 +``` + +#### E7-2: 전체 결제 흐름 E2E + +``` +Given: + - DB에 주문(id=1, status=CREATED), 상품(재고=100), 쿠폰 존재 + - PG Simulator 실행 중 (또는 WireMock으로 시뮬레이션) + +When: + - POST /api/v1/payments (orderId=1, cardType=SAMSUNG, amount=5000) + - → 응답: 200 "결제 처리 중" + - (1~5초 후) PG 콜백 수신 + +Then: + - GET /api/v1/orders/1 → status=PAID + - Payment: status=PAID, transactionKey 존재 + - 재고: 99 (1개 차감) + - 쿠폰: USED 상태 유지 + - CallbackInbox: PROCESSED +``` + +--- + +## 테스트 카테고리 × Phase 매핑 + +| 카테고리 | Phase 1 | Phase 2 | Phase 3 | Phase 4 | Phase 5 | Phase 6 | Phase 7 | +|---------|---------|---------|---------|---------|---------|---------|---------| +| **Unit** | U1-1~13 | U2-1~6 | U3-1~4 | U4-1~7 | U5-1~9 | U6-1~4 | — | +| **Integration** | I1-1~2 | — | I3-1~3 | I4-1~2 | I5-1~2 | I6-1 | — | +| **Fault Injection** | — | F2-1~7 | — | — | — | F6-1~3 | F7-1~4 | +| **Concurrency** | — | — | C3-1 | C4-1~2 | — | — | — | +| **Idempotency** | — | — | — | D4-1~3 | D5-1 | — | — | +| **Recovery** | — | — | R3-1~2 | — | R5-1~4 | — | — | +| **Performance** | — | P2-1 | — | — | — | — | — | +| **E2E** | — | — | — | — | — | — | E7-1~5 | +| **Batch E2E** | — | — | — | — | — | — | B7-1~3 | + +--- + +## 테스트 클래스 전체 목록 + +### commerce-api 테스트 (26개 클래스, 76개 시나리오) + +| # | 클래스 | 카테고리 | Phase | 시나리오 수 | +|---|--------|---------|-------|-----------| +| 1 | `PaymentModelTest` | Unit | 1 | 6 | +| 2 | `PaymentStatusTest` | Unit | 1 | 1 | +| 3 | `PaymentFacadeTest` | Unit | 1, 2 | 8 | +| 4 | `PgRouterTest` | Unit | 1, 6 | 5 | +| 5 | `SlidingWindowRateLimiterTest` | Unit | 2 | 3 | +| 6 | `ProvisionalOrderServiceTest` | Unit | 3 | 2 | +| 7 | `StockReservationTest` | Unit | 3 | 2 | +| 8 | `PaymentCallbackTest` | Unit | 4 | 4 | +| 9 | `PaymentRecoveryServiceTest` | Unit | 4 | 2 | +| 10 | `CallbackInboxTest` | Unit | 4 | 1 | +| 11 | `OutboxPollerTest` | Unit | 5 | 3 | +| 12 | `PaymentRecoveryTaskletTest` | Unit | 5 | 3 | +| 13 | `PaymentWalWriterTest` | Unit | 5 | 1 | +| 14 | `CallbackDlqSchedulerTest` | Unit | 5 | 1 | +| 15 | `ManualRecoveryTest` | Unit | 5 | 1 | +| 16 | `TossSandboxPgClientTest` | Unit | 6 | 2 | +| 17 | `PaymentFacadeIntegrationTest` | Integration | 1 | 2 | +| 18 | `RedisResilienceIntegrationTest` | Integration | 3 | 1 | +| 19 | `StockReconcileIntegrationTest` | Integration | 3 | 1 | +| 20 | `ProvisionalOrderExpiryIntegrationTest` | Integration | 3 | 1 | +| 21 | `CallbackIntegrationTest` | Integration | 4 | 1 | +| 22 | `PollingHybridIntegrationTest` | Integration | 4 | 1 | +| 23 | `OutboxIntegrationTest` | Integration | 5 | 1 | +| 24 | `WalRecoveryIntegrationTest` | Integration | 5 | 1 | +| 25 | `TossIntegrationTest` | Integration | 6 | 1 | +| 26 | `PgRetryFaultTest` | Fault Injection | 2 | 2 | +| 27 | `PgTimeoutFaultTest` | Fault Injection | 2 | 2 | +| 28 | `PgCircuitBreakerFaultTest` | Fault Injection | 2 | 2 | +| 29 | `PgRateLimiterFaultTest` | Fault Injection | 2 | 1 | +| 30 | `MultiPgFallbackFaultTest` | Fault Injection | 6 | 3 | +| 31 | `CallbackIdempotencyTest` | Idempotency | 4 | 3 | +| 32 | `OutboxIdempotencyTest` | Idempotency | 5 | 1 | +| 33 | `StockReservationConcurrencyTest` | Concurrency | 3 | 1 | +| 34 | `CallbackConcurrencyTest` | Concurrency | 4 | 1 | +| 35 | `DuplicatePaymentConcurrencyTest` | Concurrency | 4 | 1 | +| 36 | `StockReconcileRecoveryTest` | Recovery | 3 | 1 | +| 37 | `ProvisionalOrderExpiryRecoveryTest` | Recovery | 3 | 1 | +| 38 | `PaymentRecoveryBatchTest` | Recovery | 5 | 1 | +| 39 | `PgReconciliationBatchTest` | Recovery | 5 | 1 | +| 40 | `OrderReconciliationBatchTest` | Recovery | 5 | 1 | +| 41 | `CouponReconciliationBatchTest` | Recovery | 5 | 1 | +| 42 | `RateLimiterPerformanceTest` | Performance | 2 | 1 | +| 43 | `PaymentE2ETest` | E2E | 7 | 5 | +| 44 | `GhostPaymentFaultTest` | Fault Injection | 7 | 1 | +| 45 | `ServerCrashFaultTest` | Fault Injection | 7 | 1 | +| 46 | `CallbackMissFaultTest` | Fault Injection | 7 | 1 | +| 47 | `DbFailureFaultTest` | Fault Injection | 7 | 1 | + +### commerce-batch 테스트 (3개 클래스, 3개 시나리오) + +| # | 클래스 | 카테고리 | Phase | 시나리오 수 | +|---|--------|---------|-------|-----------| +| 48 | `PaymentRecoveryJobE2ETest` | Batch E2E | 7 | 1 | +| 49 | `PgReconciliationJobE2ETest` | Batch E2E | 7 | 1 | +| 50 | `CouponReconciliationJobE2ETest` | Batch E2E | 7 | 1 | + +> **합계**: 50개 클래스, 92개 시나리오 + +--- + +## Phase 간 의존 관계 + +``` +Phase 1: 기반 구축 + ├── Payment 도메인, PgClient, PgRouter, 가주문 + │ + ▼ +Phase 2: PG Resilience ─────────────────────────────┐ + ├── Retry, CB, RateLimiter, Health Probe │ + │ │ + ▼ │ +Phase 3: Redis Resilience │ + ├── Redis CB, DB Fallback, 재고 정합성 배치 │ + │ │ + ▼ │ +Phase 4: 콜백 + 상태 동기화 │ + ├── Callback Inbox, 조건부 UPDATE, Polling Hybrid │ + │ │ + ▼ │ +Phase 5: Outbox + 복구 + 대사 ◄────────────────────────┘ + ├── Outbox 폴러, 배치 복구, 대사 배치, WAL (Phase 2의 CB 설정 참조) + │ + ▼ +Phase 6: Multi-PG (Toss) + ├── TossPgClient, Toss CB/Retry, PgRouter 통합 + │ + ▼ +Phase 7: 종합 테스트 + ├── E2E, 장애 시나리오, 배치 E2E +``` + +**의존 규칙:** +- Phase N은 Phase 1~(N-1)의 산출물을 사용한다 +- Phase 2와 Phase 3은 순서 교환 가능 (독립적) +- Phase 6은 Phase 2 이후면 언제든 착수 가능 (PG Resilience 기반) +- Phase 7은 모든 Phase 완료 후 실행 + +--- + +## 테스트 패턴 요약 + +| 패턴 | 적용 도구 | 적용 대상 | +|------|----------|----------| +| **Facade Unit → Fake Repository** | ConcurrentHashMap 기반 Fake | PaymentFacade, PgRouter, 스케줄러 | +| **Integration → @SpringBootTest + Testcontainers** | MySqlTestContainersConfig, RedisTestContainersConfig | DB/Redis 연동 테스트 | +| **Fault Injection → WireMock** | wiremock-standalone | PG 500, 타임아웃, 연결 실패 | +| **Concurrency → ExecutorService + CountDownLatch** | JDK 동시성 도구 | 조건부 UPDATE, 중복 결제 | +| **E2E → TestRestTemplate + RANDOM_PORT** | @SpringBootTest(webEnvironment) | 전체 API 흐름 | +| **Batch → @SpringBatchTest + JobLauncherTestUtils** | spring-batch-test | 복구/대사 배치 잡 | +| **CB 테스트 → CircuitBreakerRegistry 직접 조작** | Resilience4j API | CB 상태 전이 검증 | + +--- + +## 구현 진행 기록 + +### Phase 1: 기반 구축 — 완료 + +**구현일**: 2026-03-20 + +#### 생성된 파일 (21개) + +| # | 파일 | 설명 | +|---|------|------| +| 1 | `domain/payment/PaymentStatus.java` | 결제 상태 Enum (REQUESTED→PENDING→PAID/FAILED/UNKNOWN) | +| 2 | `domain/payment/PaymentModel.java` | 결제 Entity (BaseEntity 상속, 상태 전이 메서드) | +| 3 | `domain/payment/PaymentRepository.java` | 결제 Repository 인터페이스 (조건부 UPDATE 포함) | +| 4 | `infrastructure/payment/PaymentJpaRepository.java` | JPA Repository (Conditional UPDATE JPQL) | +| 5 | `infrastructure/payment/PaymentRepositoryImpl.java` | Repository 구현체 | +| 6 | `infrastructure/pg/PgClient.java` | PG 추상화 인터페이스 (Strategy Pattern) | +| 7 | `infrastructure/pg/PgRouter.java` | PG 라우터 (Primary→Fallback 전환) | +| 8 | `infrastructure/pg/PgConfig.java` | PgRouter Bean 등록 Configuration | +| 9 | `infrastructure/pg/PgPaymentRequest.java` | PG 결제 요청 DTO | +| 10 | `infrastructure/pg/PgPaymentResponse.java` | PG 결제 응답 DTO | +| 11 | `infrastructure/pg/PgPaymentStatusResponse.java` | PG 상태 확인 응답 DTO | +| 12 | `infrastructure/pg/PgCallbackPayload.java` | PG 콜백 수신 DTO | +| 13 | `infrastructure/pg/simulator/SimulatorFeignClient.java` | PG Simulator Feign 인터페이스 | +| 14 | `infrastructure/pg/simulator/SimulatorFeignConfig.java` | Feign 타임아웃 설정 (connect 500ms, read 1s) | +| 15 | `infrastructure/pg/simulator/SimulatorPgClient.java` | PG Simulator PgClient 구현체 | +| 16 | `infrastructure/redis/ProvisionalOrderRedisRepository.java` | 가주문 Redis 저장소 (TTL Jitter 25~35분) | +| 17 | `infrastructure/redis/StockReservationRedisRepository.java` | 재고 예약 Redis 저장소 (DECR/INCR) | +| 18 | `application/payment/PaymentFacade.java` | 결제 유스케이스 조율 | +| 19 | `application/order/ProvisionalOrderService.java` | 가주문 관리 서비스 | +| 20 | `interfaces/api/payment/PaymentV1Controller.java` | 결제 API (POST/GET) | +| 21 | `interfaces/api/payment/PaymentV1Dto.java` | 결제 Request/Response DTO | + +#### 수정된 파일 (2개) + +| # | 파일 | 변경 사항 | +|---|------|----------| +| 1 | `apps/commerce-api/build.gradle.kts` | resilience4j, AOP, Feign, WireMock 의존성 추가 | +| 2 | `CommerceApiApplication.java` | `@EnableFeignClients` 추가 | + +#### 설정 추가 + +| # | 파일 | 변경 사항 | +|---|------|----------| +| 1 | `application.yml` | pg.simulator.url, 타임아웃, payment.callback-url 설정 | + +#### 테스트 파일 (8개) + +| # | 파일 | 테스트 수 | 결과 | +|---|------|----------|------| +| 1 | `fake/FakePaymentRepository.java` | - | Fake 구현체 | +| 2 | `fake/FakePgClient.java` | - | Fake 구현체 | +| 3 | `fake/FakeProvisionalOrderRedisRepository.java` | - | Fake 구현체 | +| 4 | `fake/FakeStockReservationRedisRepository.java` | - | Fake 구현체 | +| 5 | `domain/payment/PaymentStatusTest.java` | 7 | PASS | +| 6 | `domain/payment/PaymentModelTest.java` | 11 | PASS | +| 7 | `application/payment/PaymentFacadeTest.java` | 8 | PASS | +| 8 | `infrastructure/pg/PgRouterTest.java` | 7 | PASS | + +**총 33개 Unit 테스트 PASS** (Integration 테스트는 Docker 미실행으로 기존 실패 유지) + +#### 07 명세 대비 완료 현황 + +| 명세 항목 | 상태 | +|----------|------| +| U1-1~U1-6 (PaymentModelTest) | 완료 + 추가 4개 | +| U1-7 (PaymentStatusTest) | 완료 | +| U1-8~U1-10 (PaymentFacadeTest) | 완료 + 추가 5개 | +| U1-11~U1-13 (PgRouterTest) | 완료 + 추가 4개 | +| I1-1~I1-2 (Integration) | Phase 7에서 E2E와 함께 검증 예정 | + +### Phase 2: PG Resilience — 완료 + +**구현일**: 2026-03-20 + +#### 생성된 파일 (5개) + +| # | 파일 | 설명 | +|---|------|------| +| 1 | `infrastructure/resilience/SlidingWindowRateLimiter.java` | Sliding Window Counter 기반 Rate Limiter (50 req/sec) | +| 2 | `infrastructure/resilience/PaymentRateLimiterConfig.java` | SlidingWindowRateLimiter Bean 등록 | +| 3 | `infrastructure/resilience/PaymentRateLimiterInterceptor.java` | AOP @Around — PaymentFacade.requestPayment() 진입점 Rate Limit | +| 4 | `infrastructure/pg/PgHealthChecker.java` | PG Health Check Probe (GET 경량 요청으로 생존 확인) | +| 5 | `infrastructure/resilience/ProgressiveBackoffCustomizer.java` | CB Open 반복 시 대기 시간 점진 증가 (5s→10s→20s→40s→60s cap) | + +#### 수정된 파일 (3개) + +| # | 파일 | 변경 사항 | +|---|------|----------| +| 1 | `infrastructure/pg/simulator/SimulatorPgClient.java` | `@CircuitBreaker(name="pgSimulator-request")` 적용 (requestPayment만), 상태 조회는 try-catch만 (06 §18) | +| 2 | `application/payment/PaymentFacade.java` | 수동 Retry 루프 + PG 상태 확인(멱등성) + UNKNOWN Fallback 구현 | +| 3 | `application.yml` | Resilience4j CB 3개(pgSimulator-request, pgToss-request, redis-write), RateLimiter, Retry 설정 추가 | + +#### 설정 추가 + +| # | 키 | 값 | 설명 | +|---|---|---|------| +| 1 | `payment.retry.max-attempts` | 3 | 수동 Retry 최대 시도 횟수 | +| 2 | `payment.retry.initial-wait-ms` | 500 | 첫 번째 재시도 대기 시간 | +| 3 | `payment.retry.backoff-multiplier` | 2 | 지수 백오프 배수 | +| 4 | CB `pgSimulator-request` | slidingWindow=10, failureRate=50% | PG Simulator 결제 요청 CB | +| 5 | CB `pgToss-request` | slidingWindow=10, failureRate=50% | Toss PG 결제 요청 CB (Phase 6에서 사용) | +| 6 | CB `redis-write` | slidingWindow=10, failureRate=50% | Redis 쓰기 CB (Phase 3에서 사용) | + +#### 테스트 파일 (1개 생성 + 2개 수정) + +| # | 파일 | 테스트 수 | 결과 | +|---|------|----------|------| +| 1 | `infrastructure/resilience/SlidingWindowRateLimiterTest.java` | 3 | PASS (U2-1, U2-2, U2-3) | +| 2 | `application/payment/PaymentFacadeTest.java` (수정) | 10 (기존 7 + 신규 3) | PASS (U2-4, U2-5, U2-6 추가) | +| 3 | `fake/FakePgClient.java` (수정) | - | failCount, orderStatusStore 확장 | + +**총 13개 Unit 테스트 PASS** (SlidingWindowRateLimiter 3 + PaymentFacade 10) + +#### 핵심 설계 결정 + +| 결정 | 근거 | +|------|------| +| 수동 Retry 루프 (not @Retry) | PG 상태 확인 후 재시도 여부 결정 (멱등성 보장, 05 §6.4) | +| 읽기 CB 제거 (쓰기 3개만) | 상태 조회는 "복구 행위" → CB가 차단하면 복구 불가 (06 §18) | +| Sliding Window Rate Limiter | Fixed Window의 Boundary Burst 방지 (05 §7.4) | +| UNKNOWN 최종 Fallback | 모든 PG 실패 → 즉시 실패 대신 "결제 확인 중" 응답, 배치가 후처리 (05 §8.7) | +| @Value 기반 설정 외부화 | retry/backoff 파라미터를 yml에서 조정 가능 → 운영 유연성 | + +#### 07 명세 대비 완료 현황 + +| 명세 항목 | 상태 | +|----------|------| +| 7: Resilience4j YAML 설정 | 완료 | +| 8: PG별 독립 Retry (수동 루프) | 완료 | +| 9: PG별 독립 CB (쓰기 3개) | 완료 | +| 10: SlidingWindowRateLimiter | 완료 | +| 11: PaymentRateLimiterInterceptor (AOP) | 완료 | +| 12: 배치 RateLimiter 설정 | 완료 (YAML에 pgStatusBatch 10 req/sec) | +| 13: 최종 Fallback (UNKNOWN) | 완료 | +| 14: Health Check Probe + Progressive Backoff | 완료 | +| U2-1~U2-3 (SlidingWindowRateLimiterTest) | 완료 | +| U2-4~U2-6 (PaymentFacadeTest 추가) | 완료 | +| F2-1~F2-7 (Fault Injection) | Phase 7 종합 테스트에서 WireMock과 함께 검증 예정 | +| P2-1 (RateLimiter Performance) | Phase 7 종합 테스트에서 검증 예정 | + +### Phase 3: Redis Resilience — 완료 + +**구현일**: 2026-03-20 + +#### 생성된 파일 (2개) + +| # | 파일 | 설명 | +|---|------|------| +| 1 | `infrastructure/scheduler/StockReconcileScheduler.java` | Redis-DB 재고 정합성 배치 (30초 주기, DB 기준 보정) | +| 2 | `infrastructure/scheduler/ProvisionalOrderExpiryScheduler.java` | 가주문 TTL 만료 선제 정리 (30초 주기, 재고 복원 + 삭제) | + +#### 수정된 파일 (3개) + +| # | 파일 | 변경 사항 | +|---|------|----------| +| 1 | `application/order/ProvisionalOrderService.java` | @CircuitBreaker("redis-write") + DB Fallback, items 파라미터 추가, ProvisionalOrderResult 반환 타입 | +| 2 | `infrastructure/redis/ProvisionalOrderRedisRepository.java` | getAllOrderIds(), getTtlSeconds() 메서드 추가 | +| 3 | `CommerceApiApplication.java` | @EnableScheduling 추가 | + +#### 테스트 파일 (4개 생성 + 1개 수정) + +| # | 파일 | 테스트 수 | 결과 | +|---|------|----------|------| +| 1 | `application/order/ProvisionalOrderServiceTest.java` | 4 | PASS (U3-1, U3-2 + 조회/삭제 2건) | +| 2 | `infrastructure/redis/StockReservationTest.java` | 4 | PASS (U3-3, U3-4 + 추가 2건) | +| 3 | `infrastructure/scheduler/StockReconcileSchedulerTest.java` | 3 | PASS (불일치/키없음/일치) | +| 4 | `infrastructure/scheduler/ProvisionalOrderExpirySchedulerTest.java` | 3 | PASS (만료임박/정상/혼합) | +| 5 | `fake/FakeProvisionalOrderRedisRepository.java` (수정) | - | getAllOrderIds, getTtlSeconds, setTtl 추가 | + +**총 14개 Unit 테스트 PASS** + +#### 핵심 설계 결정 + +| 결정 | 근거 | +|------|------| +| Redis 쓰기만 CB (redis-write) | 읽기 CB는 복구 경로 차단 위험 (06 §18) | +| DB Fallback = Order(CREATED) 직접 생성 | Redis 장애 시 가주문 단계 생략, 진주문으로 직행 | +| StockReconcileScheduler 30초 주기 | ~5개 상품 × ~2ms = 10ms, 부하율 0.03% | +| ProvisionalOrderExpiryScheduler TTL < 30초 | 배치 주기와 같은 임계치 → 최대 60초 내 감지 | +| @EnableScheduling 별도 추가 | commerce-api에 기존 스케줄링 없었음 | + +#### 07 명세 대비 완료 현황 + +| 명세 항목 | 상태 | +|----------|------| +| 15: Redis CB 1개 (redis-write만) | 완료 (Phase 2에서 YAML 설정, Phase 3에서 @CircuitBreaker 적용) | +| 16: Redis Fallback (DB 직접 주문) | 완료 | +| 17: 재고 예약 DECR + DB UPDATE 이중 관리 | 완료 (Phase 1 DECR/INCR + Phase 3 Fallback DB 차감) | +| 18: Redis-DB 재고 정합성 배치 | 완료 (Lua Script는 Integration 테스트 범위) | +| 19: 가주문 선제 만료 배치 | 완료 | +| U3-1~U3-2 (ProvisionalOrderServiceTest) | 완료 | +| U3-3~U3-4 (StockReservationTest) | 완료 | +| I3-1~I3-3 (Integration) | Phase 7 종합 테스트에서 Testcontainers와 함께 검증 예정 | +| R3-1~R3-2 (Recovery) | Phase 7 종합 테스트에서 검증 예정 | +| C3-1 (Concurrency) | Phase 7 종합 테스트에서 검증 예정 | + +### Phase 4: 콜백 + 상태 동기화 — 완료 + +**구현일**: 2026-03-20 + +#### 생성된 파일 (6개) + +| # | 파일 | 설명 | +|---|------|------| +| 1 | `domain/payment/CallbackInbox.java` | 콜백 원본 저장 엔티티 (DLQ), RECEIVED→PROCESSED/FAILED 상태 전이 | +| 2 | `domain/payment/CallbackInboxStatus.java` | CallbackInbox 상태 enum (RECEIVED, PROCESSED, FAILED) | +| 3 | `domain/payment/CallbackInboxRepository.java` | CallbackInbox Repository 인터페이스 (DIP) | +| 4 | `infrastructure/payment/CallbackInboxJpaRepository.java` | JPA Repository | +| 5 | `infrastructure/payment/CallbackInboxRepositoryImpl.java` | Repository 구현체 | +| 6 | `application/payment/PaymentRecoveryService.java` | 콜백 처리 + Polling Hybrid (@Scheduled 10초 주기) | + +#### 수정된 파일 (3개) + +| # | 파일 | 변경 사항 | +|---|------|----------| +| 1 | `domain/order/Order.java` | pay() 메서드 추가 (CREATED→PAID 상태 전이) | +| 2 | `interfaces/api/payment/PaymentV1Controller.java` | POST /callback 엔드포인트 추가, PaymentRecoveryService 의존성 | +| 3 | `interfaces/api/payment/PaymentV1Dto.java` | CallbackRequest record 추가 | + +#### 테스트 파일 (3개 생성) + +| # | 파일 | 테스트 수 | 결과 | +|---|------|----------|------| +| 1 | `application/payment/PaymentCallbackTest.java` | 4 | PASS (U4-1~U4-4: SUCCESS/FAILED/PENDING/Unknown TX) | +| 2 | `application/payment/PaymentRecoveryServiceTest.java` | 2 | PASS (U4-5~U4-6: Polling SUCCESS/threshold 미달) | +| 3 | `domain/payment/CallbackInboxTest.java` | 3 | PASS (U4-7 + PROCESSED/FAILED 상태 전이) | +| 4 | `fake/FakeCallbackInboxRepository.java` (생성) | - | ConcurrentHashMap + Reflection 기반 Fake | + +**총 9개 Unit 테스트 PASS** + +#### 핵심 설계 결정 + +| 결정 | 근거 | +|------|------| +| CallbackInbox extends BaseEntity | 기존 프로젝트 패턴 준수 (createdAt/updatedAt/deletedAt 자동 관리) | +| 조건부 UPDATE (WHERE status IN PENDING, UNKNOWN) | 콜백+배치 동시 처리 시 1건만 성공 → 멱등성 보장 | +| Polling Hybrid = @Scheduled 10초 주기 | PaymentFacade의 TaskScheduler 대신 단순한 폴링 방식, PENDING은 10초 경과 후만 폴링 | +| PENDING 콜백 무시 (06 §14.4) | PG에서 PENDING 콜백은 상태 변경이 아닌 중간 알림, 처리 불필요 | +| 콜백 Controller = 기존 PaymentV1Controller 확장 | 별도 Controller 불필요, /api/v1/payments/callback 엔드포인트로 자연스러운 확장 | +| 재고 복원 = Redis INCR + DB increaseStock | 이중 관리 원칙 유지 (Phase 3과 동일) | + +#### 07 명세 대비 완료 현황 + +| 명세 항목 | 상태 | +|----------|------| +| 20: Callback Inbox DLQ 테이블 + 엔티티 + Repository | 완료 | +| 21: 콜백 수신 API (POST /callback) | 완료 | +| 22: 조건부 UPDATE 기반 상태 전이 | 완료 (FakePaymentRepository에서 검증, 실 DB는 Phase 7) | +| 23: 결제 실패 시 재고 복원 + 쿠폰 복원 | 완료 | +| 24: Polling Hybrid | 완료 (@Scheduled 10초 주기) | +| U4-1~U4-4 (PaymentCallbackTest) | 완료 | +| U4-5~U4-6 (PaymentRecoveryServiceTest) | 완료 | +| U4-7 (CallbackInboxTest) | 완료 | +| D4-1~D4-3 (Idempotency) | Phase 7 종합 테스트에서 검증 예정 | +| C4-1~C4-2 (Concurrency) | Phase 7 종합 테스트에서 검증 예정 | +| I4-1~I4-2 (Integration) | Phase 7 종합 테스트에서 Testcontainers와 함께 검증 예정 | + +### Phase 5: Outbox + 복구 + 대사 — 완료 + +**구현일**: 2026-03-20 + +#### 생성된 파일 — commerce-api (12개) + +| # | 파일 | 설명 | +|---|------|------| +| 1 | `domain/payment/PaymentOutbox.java` | Outbox 엔티티 (PENDING→PROCESSED/FAILED) | +| 2 | `domain/payment/PaymentOutboxStatus.java` | Outbox 상태 enum | +| 3 | `domain/payment/PaymentOutboxRepository.java` | Outbox Repository 인터페이스 | +| 4 | `infrastructure/payment/PaymentOutboxJpaRepository.java` | JPA Repository | +| 5 | `infrastructure/payment/PaymentOutboxRepositoryImpl.java` | Repository 구현체 | +| 6 | `domain/payment/ReconciliationMismatch.java` | 대사 불일치 기록 엔티티 | +| 7 | `domain/payment/ReconciliationMismatchRepository.java` | 불일치 Repository 인터페이스 | +| 8 | `infrastructure/payment/ReconciliationMismatchJpaRepository.java` | JPA Repository | +| 9 | `infrastructure/payment/ReconciliationMismatchRepositoryImpl.java` | Repository 구현체 | +| 10 | `infrastructure/payment/PaymentWalWriter.java` | Local WAL — PG 응답 로컬 파일 기록/읽기/삭제 | +| 11 | `infrastructure/scheduler/OutboxPollerScheduler.java` | Outbox 폴러 (5초 주기) — PG 호출 누락 재시도 | +| 12 | `infrastructure/scheduler/WalRecoveryScheduler.java` | WAL Recovery (10초 주기) — 파일→DB 반영 | +| 13 | `infrastructure/scheduler/CallbackDlqScheduler.java` | Callback DLQ 재처리 (30초 주기) | + +#### 생성된 파일 — commerce-batch (8개) + +| # | 파일 | 설명 | +|---|------|------| +| 1 | `batch/job/paymentrecovery/PaymentRecoveryJobConfig.java` | 결제 복구 배치 Job 설정 | +| 2 | `batch/job/paymentrecovery/step/PaymentRecoveryTasklet.java` | REQUESTED/PENDING/UNKNOWN 복구 (네이티브 SQL) | +| 3 | `batch/job/reconciliation/PgPaymentReconciliationJobConfig.java` | [R1] PG↔Payment 대사 Job | +| 4 | `batch/job/reconciliation/step/PgPaymentReconciliationTasklet.java` | PG API 대조 (PG 연동은 Phase 6 이후) | +| 5 | `batch/job/reconciliation/PaymentOrderReconciliationJobConfig.java` | [R2] Payment↔Order 대사 Job | +| 6 | `batch/job/reconciliation/step/PaymentOrderReconciliationTasklet.java` | JOIN 쿼리 불일치 감지 + 자동 보정 | +| 7 | `batch/job/reconciliation/PaymentCouponReconciliationJobConfig.java` | [R3] Payment↔Coupon 대사 Job | +| 8 | `batch/job/reconciliation/step/PaymentCouponReconciliationTasklet.java` | 쿠폰 복원 누락 감지 + 자동 복원 | + +#### 수정된 파일 (1개) + +| # | 파일 | 변경 사항 | +|---|------|----------| +| 1 | `application/payment/PaymentFacade.java` | PaymentOutboxRepository 의존성 추가, TX-1에 Outbox(PENDING) 저장 | +| 2 | `application/payment/PaymentRecoveryService.java` | manualConfirm() 메서드 추가 | +| 3 | `interfaces/api/payment/PaymentV1Controller.java` | POST /{paymentId}/confirm 수동 복구 엔드포인트 추가 | + +#### 테스트 파일 (4개 생성) + +| # | 파일 | 테스트 수 | 결과 | +|---|------|----------|------| +| 1 | `infrastructure/scheduler/OutboxPollerTest.java` | 3 | PASS (U5-1~U5-3: PG호출/이미해결/retry초과) | +| 2 | `infrastructure/payment/PaymentWalWriterTest.java` | 3 | PASS (U5-7: 기록/읽기/삭제 + 추가 2건) | +| 3 | `infrastructure/scheduler/CallbackDlqSchedulerTest.java` | 2 | PASS (U5-8 + 최근 건 무시) | +| 4 | `application/payment/ManualRecoveryTest.java` | 2 | PASS (U5-9 + 최종 상태 무시) | +| 5 | `fake/FakePaymentOutboxRepository.java` (생성) | - | Outbox Fake | +| 6 | `fake/FakeReconciliationMismatchRepository.java` (생성) | - | 대사 불일치 Fake | + +**총 10개 Unit 테스트 PASS** (U5-4~U5-6 배치 Tasklet은 Integration 범위, Phase 7에서 검증) + +#### 핵심 설계 결정 + +| 결정 | 근거 | +|------|------| +| Outbox 폴러 5초 주기 | 배치(1분)보다 빠른 1차 복구, 서버 부하 미미 (PENDING 건만 조회) | +| TX-1에서 Payment+Outbox 동시 저장 | "PG를 호출해야 한다"는 의도를 명시적으로 보존 | +| WAL = 로컬 파일 시스템 | DB 독립적 저장소, DB 장애 시에도 PG 응답 보존 | +| CallbackDlqScheduler 30초 threshold | 정상 콜백 처리(< 1초)와 구분되는 충분한 여유 | +| 배치 Tasklet = 네이티브 SQL | commerce-batch가 commerce-api 도메인에 의존하지 않음 | +| R3 쿠폰 대사 자동 복원 | 복원 누락은 명확한 버그 → 자동 보정이 안전 | +| R1 PG 대사 = Phase 6 이후 완성 | PG API 연동(Feign)이 Phase 6에서 구현되므로 | + +#### 07 명세 대비 완료 현황 + +| 명세 항목 | 상태 | +|----------|------| +| 24: PaymentOutbox 엔티티 + TX-1 저장 | 완료 | +| 25: Outbox 폴러 스케줄러 (5초 주기) | 완료 | +| 26: 배치 복구 (commerce-batch) | 완료 (네이티브 SQL Tasklet) | +| 27: 수동 복구 API | 완료 (POST /{paymentId}/confirm) | +| 28: Local WAL | 완료 (파일 기반 WAL + Recovery 스케줄러) | +| 29: Callback DLQ 재처리 스케줄러 | 완료 | +| 30: [R1] PG↔Payment 대사 | 완료 (인프라 준비, PG API는 Phase 6 이후) | +| 31: [R2] Payment↔Order 대사 | 완료 (JOIN 쿼리 + 자동 보정) | +| 32: [R3] Payment↔Coupon 대사 | 완료 (자동 복원) | +| U5-1~U5-3 (OutboxPollerTest) | 완료 | +| U5-7 (PaymentWalWriterTest) | 완료 | +| U5-8 (CallbackDlqSchedulerTest) | 완료 | +| U5-9 (ManualRecoveryTest) | 완료 | +| U5-4~U5-6 (PaymentRecoveryTaskletTest) | Phase 7 종합 테스트에서 검증 예정 | +| I5-1~I5-2 (Integration) | Phase 7 종합 테스트에서 검증 예정 | +| R5-1~R5-4 (Recovery/Reconciliation) | Phase 7 종합 테스트에서 검증 예정 | + +### Phase 6: Multi-PG (Toss Sandbox) — 완료 + +**구현일**: 2026-03-20 + +#### 생성된 파일 (3개) + +| # | 파일 | 설명 | +|---|------|------| +| 1 | `infrastructure/pg/toss/TossFeignClient.java` | Toss Sandbox Feign interface (POST /v1/payments/confirm, GET /v1/payments/{paymentKey}) | +| 2 | `infrastructure/pg/toss/TossSandboxPgConfig.java` | Toss 전용 Timeout 설정 (connect 500ms, read 2000ms) | +| 3 | `infrastructure/pg/toss/TossSandboxPgClient.java` | Toss PG 구현체 (@CircuitBreaker("pgToss-request"), 동기 결제) | + +#### 수정된 파일 (5개) + +| # | 파일 | 변경 사항 | +|---|------|----------| +| 1 | `infrastructure/pg/PgPaymentResponse.java` | pgProvider 필드 추가 (PG 제공자 추적) | +| 2 | `infrastructure/pg/PgRouter.java` | 타임아웃 인식 Fallback (SocketTimeoutException → 전환 안 함), 응답에 pgProvider 주입 | +| 3 | `infrastructure/pg/simulator/SimulatorPgClient.java` | @Order(1) 추가 (Primary PG 순서 보장) | +| 4 | `application/payment/PaymentFacade.java` | 동기 PG 응답 처리 (SUCCESS→PAID 즉시, FAILED→FAILED 즉시), pgProvider 추적 | +| 5 | `infrastructure/scheduler/OutboxPollerScheduler.java` | pgResponse.pgProvider() 사용으로 변경 | + +#### 설정 변경 (1개) + +| # | 파일 | 변경 사항 | +|---|------|----------| +| 1 | `application.yml` | pg.toss.url/connect-timeout/read-timeout 추가 | + +#### 테스트 파일 (2개 생성 + 1개 수정) + +| # | 파일 | 테스트 수 | 결과 | +|---|------|----------|------| +| 1 | `infrastructure/pg/toss/TossSandboxPgClientTest.java` (생성) | 2 | PASS (U6-1: SUCCESS→PAID 즉시, U6-2: FAILED→FAILED 즉시) | +| 2 | `infrastructure/pg/PgRouterTest.java` (수정 — MultiPgFallback 추가) | 3 | PASS (U6-3: Fallback 전환, U6-4: 타임아웃 전환 안 함, pgProvider 추적) | +| 3 | `fake/FakePgClient.java` (수정) | - | setResponseStatus(), setThrowTimeout() 추가 | + +**총 5개 Unit 테스트 PASS** (기존 테스트 전체 통과 확인) + +#### 핵심 설계 결정 + +| 결정 | 근거 | +|------|------| +| PgPaymentResponse에 pgProvider 추가 | PG Fallback 시 어떤 PG가 처리했는지 정확히 추적 (기존 getPrimaryClient() 대체) | +| 타임아웃 → Fallback 전환 안 함 | PG가 요청을 수신했을 수 있음 → Toss로 전환하면 중복 결제 위험 (05 §8.3) | +| ConnectException/500/CB Open → Fallback 전환 | PG에 도달하지 않은 경우는 안전하게 다른 PG로 전환 가능 | +| Toss 동기 응답 → PaymentFacade에서 즉시 처리 | SUCCESS → PAID + Order.pay() 즉시, 콜백 대기 불필요 | +| @Order(1)/@Order(2) 로 PG 우선순위 보장 | List 주입 순서를 Spring @Order로 제어 | +| Toss readTimeout 2000ms (Simulator보다 여유) | 동기 결제는 내부적으로 PG 승인까지 진행하므로 응답 시간이 더 김 | + +#### 07 명세 대비 완료 현황 + +| 명세 항목 | 상태 | +|----------|------| +| 33: TossSandboxPgClient 구현 (동기 결제) | 완료 | +| 34: Toss 전용 CB/Retry 설정 | 완료 (pgToss-request CB 이미 application.yml에 존재) | +| 35: PgRouter에 Toss 등록 + Fallback 전환 로직 검증 | 완료 (타임아웃 인식 Fallback) | +| U6-1 (Toss SUCCESS → PAID 즉시) | 완료 | +| U6-2 (Toss FAILED → FAILED 즉시) | 완료 | +| U6-3 (Simulator 실패 → Toss Fallback) | 완료 | +| U6-4 (타임아웃 → Toss 전환 안 함) | 완료 | +| F6-1~F6-3 (Fault Injection — WireMock) | Phase 7 종합 테스트에서 검증 예정 | +| I6-1 (Toss Integration) | Phase 7 종합 테스트에서 검증 예정 | + +### Phase 7: 종합 테스트 — 완료 + +**구현일**: 2026-03-20 + +#### 구현 범위 + +Phase 7은 3개 카테고리로 구분: +1. **Fault Injection (Fake 기반)** — 인프라 없이 즉시 실행 가능, 복구 경로 검증 +2. **E2E (@SpringBootTest + WireMock)** — Docker/Testcontainers 필요 +3. **Batch E2E (@SpringBatchTest)** — Docker/Testcontainers 필요 + +#### 1. Fault Injection 테스트 (4개 생성 — 11개 시나리오, 전부 PASS) + +| # | 파일 | 테스트 수 | 시나리오 | +|---|------|----------|---------| +| 1 | `application/payment/GhostPaymentFaultTest.java` | 2 | F7-1: 타임아웃→UNKNOWN→Polling복구→PAID, PENDING→콜백복구→PAID | +| 2 | `application/payment/ServerCrashFaultTest.java` | 2 | F7-2: TX-1커밋후 PG미호출→Outbox폴러복구, PG장애→retry초과→FAILED | +| 3 | `application/payment/CallbackMissFaultTest.java` | 3 | F7-3: 콜백미수신→Polling→PAID, Polling→FAILED, 최근PENDING→폴링안함 | +| 4 | `application/payment/DbFailureFaultTest.java` | 4 | F7-4: WAL복구→PAID, WAL복구→FAILED, 이미최종→WAL삭제, 다건WAL처리 | + +#### 2. E2E 테스트 (1개 생성 — Docker 필요) + +| # | 파일 | 테스트 수 | 시나리오 | +|---|------|----------|---------| +| 1 | `interfaces/api/payment/PaymentE2ETest.java` | 4 | E7-1~E7-4: 결제요청, 콜백처리, 수동복구, 주문없음 에러 | + +> WireMock으로 PG Simulator 시뮬레이션. @DynamicPropertySource로 PG URL 주입. + +#### 3. Batch E2E 테스트 (2개 생성 — Docker 필요) + +| # | 파일 | 테스트 수 | 시나리오 | +|---|------|----------|---------| +| 1 | `job/payment/PaymentRecoveryJobE2ETest.java` | 1 | B7-1: 결제 복구 배치 정상 실행 | +| 2 | `job/payment/CouponReconciliationJobE2ETest.java` | 1 | B7-3: 쿠폰 대사 배치 정상 실행 | + +#### 수정된 파일 (1개) + +| # | 파일 | 변경 사항 | +|---|------|----------| +| 1 | `application/payment/PaymentRecoveryService.java` | pollPgStatus() — processCallback 위임 대신 직접 조건부 UPDATE (UNKNOWN without transactionKey 지원) | + +#### 핵심 설계 결정 + +| 결정 | 근거 | +|------|------| +| Fault Injection = Fake 기반 | 인프라(Docker) 없이도 복구 경로를 즉시 검증 가능 | +| pollPgStatus → 직접 UPDATE | processCallback은 transactionKey 기반 검색 → UNKNOWN(transactionKey 없음)에서 실패. 직접 payment 참조로 UPDATE | +| E2E + WireMock | PG Simulator 없이도 @DynamicPropertySource로 WireMock URL 주입하여 PG 응답 시뮬레이션 | +| Batch E2E = @SpringBatchTest 패턴 | DemoJobE2ETest와 동일 패턴. JobLauncherTestUtils + @TestPropertySource | + +#### 07 명세 대비 완료 현황 + +| 명세 항목 | 상태 | +|----------|------| +| 36: 전체 흐름 E2E 테스트 | 완료 (PaymentE2ETest — Docker 환경에서 실행) | +| 37: 장애 시나리오 통합 테스트 | 완료 (F7-1~F7-4 Fake 기반 11개 시나리오 PASS) | +| 38: 배치 E2E 테스트 | 완료 (B7-1, B7-3 — Docker 환경에서 실행) | +| E7-1~E7-5 (Payment E2E) | 완료 (구조 작성, 인프라 필요) | +| F7-1 (유령 결제 복구) | 완료 (PASS) | +| F7-2 (서버 크래시 → Outbox 복구) | 완료 (PASS) | +| F7-3 (콜백 미수신 → Polling 복구) | 완료 (PASS) | +| F7-4 (DB 장애 → WAL 복구) | 완료 (PASS) | +| B7-1 (PaymentRecoveryJob) | 완료 (구조 작성, 인프라 필요) | +| B7-3 (CouponReconciliationJob) | 완료 (구조 작성, 인프라 필요) | + +--- + +## 전체 Phase 완료 요약 + +| Phase | 주제 | 상태 | Unit 테스트 | +|-------|------|------|-----------| +| 1 | 기반 구축 | 완료 | U1-1~13 (13개) | +| 2 | PG Resilience | 완료 | U2-1~6 (6개) | +| 3 | Redis Resilience | 완료 | U3-1~4 (4개) | +| 4 | 콜백 + 상태 동기화 | 완료 | U4-1~7 (9개) | +| 5 | Outbox + 복구 + 대사 | 완료 | U5-1~9 (10개) | +| 6 | Multi-PG (Toss) | 완료 | U6-1~4 (5개) | +| 7 | 종합 테스트 | 완료 | F7-1~4 (11개) + E2E/Batch (구조) | + +**Fake 기반 단위 테스트 총 58개 PASS** (인프라 불필요) +**E2E/Integration/Batch 테스트**: Docker 환경에서 실행 필요 diff --git a/docs/design/08-event-pipeline.md b/docs/design/08-event-pipeline.md new file mode 100644 index 0000000000..fca54c0905 --- /dev/null +++ b/docs/design/08-event-pipeline.md @@ -0,0 +1,1739 @@ +# ApplicationEvent + Kafka 이벤트 파이프라인 — 설계 명세 + +--- + +## 1. 개요 + +본 문서는 7주차 과제의 구현 명세를 정의한다. + +| Step | 주제 | 핵심 | +|---|---|---| +| Step 1 | ApplicationEvent로 경계 나누기 | 핵심 로직 vs 부가 로직 판단 + 트랜잭션 분리 | +| Step 2 | Kafka 이벤트 파이프라인 | Outbox → Debezium CDC → Kafka → commerce-streamer, product_metrics 집계, 멱등 처리 | +| Step 3 | 선착순 쿠폰 발급 | API → Kafka 발행 → Consumer 순차 처리, 수량 제한 동시성 제어 | + +**참조 문서:** +- 05-payment-resilience.md — PG 비동기 결제 Resilience 설계 (스타일 기준) +- 09-event-review.md — 이벤트 파이프라인 아키텍트 리뷰 (분석 근거) + +--- + +## 2. 현재 상태 + +### 2.1 인프라 상태 + +| 구성 요소 | 상태 | 비고 | +|---|---|---| +| commerce-api | Kafka 미사용 | `modules:kafka` 의존 없음, kafka.yml 미임포트 | +| commerce-streamer | DemoKafkaConsumer 1개 | `demo.internal.topic-v1` 소비만 | +| modules/kafka | 설정 완료 | KafkaTemplate, BATCH_LISTENER (manual ack, concurrency 3, max poll 3000) | +| Docker Kafka | KRaft 모드 | 단일 브로커, port 9092/19092, 토픽 자동 생성 비활성화 | +| Docker MySQL | 8.0 | binlog 미활성화 (기본값), port 3306 | +| Docker Redis | Master-Replica | port 6379/6380, AOF 영속성 | + +**kafka.yml 발견된 문제점:** + +| # | 문제 | 위치 | 심각도 | +|---|---|---|---| +| 1 | Consumer `value-serializer` → `value-deserializer` 오타 | kafka.yml:21 | 경미 (Converter가 대체) | +| 2 | Producer acks/idempotence 미설정 | kafka.yml:14-17 | **중요** (메시지 유실 가능) | +| 3 | 단건 처리용 Consumer Factory 부재 | KafkaConfig.java | **중요** (쿠폰 발급용) | +| 4 | Error Handler / DLQ 미설정 | KafkaConfig.java | **중요** | +| 5 | 토픽 생성 전략 없음 | N/A | 중간 | + +### 2.2 좋아요 흐름 + +``` +[LikeFacade.addLike — 단일 TX @Transactional] + 1. 상품 존재 확인 (productRepository.findById) + 2. 중복 좋아요 확인 (existsByMemberIdAndProductId) + 3. Like INSERT (likeRepository.save) + 4. Product.incrementLikeCount (SQL atomic UPDATE) ← 부가 로직이 TX 내부 +[TX commit] + +[LikeController — 인라인 처리] + 5. productCachePort.evictProductDetail(productId) ← 캐시 무효화가 Controller에 인라인 + 6. productCachePort.evictProductList() +``` + +**문제점:** +- Like INSERT(핵심)와 likeCount UPDATE(부가/집계)가 같은 TX — 집계 실패 시 좋아요 자체 롤백 +- 캐시 무효화가 Controller에 인라인 — 관심사 분리 안 됨 + +### 2.3 주문 흐름 + +``` +[OrderFacade.createOrder — 단일 TX @Transactional] + 1. 상품 비관적 락 (deadlock 방지 위해 ID 정렬) + 2. 브랜드 조회 (N+1 방지) + 3. 스냅샷 생성 (OrderItem) + 4. 재고 차감 (product.decreaseStock) + 5. 쿠폰 적용 (CouponFacade.applyCouponToOrder — CAS UPDATE) + 6. 주문 저장 (Order.create) + 7. 쿠폰-주문 연결 (couponIssue.linkOrder) +[TX commit] +``` + +**문제점:** +- 부가 로직(판매량 집계, 알림)이 존재하지 않지만, 추가 시 TX 안에 진입할 구조 +- 쿠폰 적용은 가격 계산에 직접 영향 → 핵심 로직 (분리 불가) + +### 2.4 조회 흐름 + +``` +ProductFacade.getProductDetailCached(): + L1(Caffeine) → L2(Redis) → DB → 캐시 저장 + +조회수 추적: 없음 (7주차에서 신규 추가) +``` + +### 2.5 쿠폰 구조 + +``` +Coupon: name, discountType, discountValue, minOrderAmount, expiredAt +CouponIssue: couponId, memberId, status(AVAILABLE/USED/EXPIRED), expiredAt + +수량 제한: 없음 → maxIssuanceCount, issuedCount 추가 필요 +중복 발급 방지: 없음 (같은 쿠폰을 같은 유저가 여러 번 발급 가능) +인덱스: idx_coupon_issue_member_id, idx_coupon_issue_coupon_id (UNIQUE 없음) +``` + +--- + +## 3. Step 1 — ApplicationEvent 경계 분리 + +### 3.1 판단 프레임워크 + +``` +핵심 로직 = "이것이 실패하면 사용자 요청 자체가 실패해야 하는가?" + → YES: 핵심 TX 안에 유지 + → NO: 이벤트로 분리 가능 + +부가 로직 = "이것이 실패해도 사용자에게는 성공으로 보여야 하는가?" + → YES: 이벤트 분리 (eventual consistency) +``` + +### 3.2 플로우별 핵심/부가 분리표 + +#### 좋아요 플로우 + +| 처리 | 핵심/부가 | 판단 근거 | 이벤트 분리 | +|---|---|---|---| +| Like INSERT | **핵심** | 사용자 의도 (좋아요 누르기) | X | +| Outbox INSERT | **핵심** | Kafka 발행 보장 (같은 TX) | X | +| Product.incrementLikeCount | 부가 | 집계 실패와 무관하게 좋아요는 성공 | O | +| 캐시 무효화 | 부가 | 캐시 무효화 실패해도 좋아요는 성공 | O | + +#### 주문 플로우 + +| 처리 | 핵심/부가 | 판단 근거 | 이벤트 분리 | +|---|---|---|---| +| 재고 차감 | **핵심** | 재고 없으면 주문 불가 | X | +| 쿠폰 적용 | **핵심** | 할인 금액이 totalPrice 계산에 직접 영향 | X | +| 주문 저장 | **핵심** | 주문 자체 | X | +| Outbox INSERT | **핵심** | Kafka 발행 보장 (같은 TX) | X | +| 판매량 집계 | 부가 | 집계 실패해도 주문에 영향 없음 | O | + +#### 조회 플로우 + +| 처리 | 핵심/부가 | 판단 근거 | 이벤트 분리 | +|---|---|---|---| +| 상품 데이터 반환 | **핵심** | 사용자 요청 목적 | X | +| 조회수 기록 | 부가 | 조회수 기록 실패해도 상품은 보여야 함 | O | + +#### 주문 취소 플로우 + +| 처리 | 핵심/부가 | 이벤트 분리 | +|---|---|---| +| Order.cancel() | **핵심** | X | +| 재고 복원 | **핵심** | X (재고 복원 실패 시 데이터 불일치) | +| 쿠폰 복원 | **핵심** | X (쿠폰 복원 실패 시 고객 손해) | +| Outbox INSERT | **핵심** | X | +| 판매량 차감 집계 | 부가 | O | + +### 3.3 이벤트 클래스 설계 + +```java +// commerce-api: com.loopers.domain.event + +public record LikeCreatedEvent( + Long productId, + Long memberId, + Long likeId +) {} + +public record LikeRemovedEvent( + Long productId, + Long memberId, + Long likeId +) {} + +public record OrderCreatedEvent( + Long orderId, + Long memberId, + List items // productId, quantity, price +) { + public record OrderItemInfo(Long productId, int quantity, int price) {} +} + +public record OrderCancelledEvent( + Long orderId, + Long memberId, + List items +) { + public record OrderItemInfo(Long productId, int quantity, int price) {} +} + +public record ProductViewedEvent( + Long productId, + Long memberId // nullable — 비로그인 조회 허용 +) {} +``` + +### 3.4 이벤트 리스너 설계 + +| 리스너 | 이벤트 | Phase | @Async | 처리 내용 | 실패 대응 | +|---|---|---|---|---|---| +| LikeCountEventListener | LikeCreated/Removed | AFTER_COMMIT | X (동기) | incrementLikeCount / decrementLikeCount | try-catch + 로그, product_metrics가 최종 보정 | +| CacheEvictionEventListener | LikeCreated/Removed | AFTER_COMMIT | X (동기) | evictProductDetail + evictProductList | try-catch + 로그, 다음 TTL 만료 시 자연 갱신 | +| ProductViewKafkaPublisher | ProductViewed | AFTER_COMMIT | **O** | KafkaTemplate.send (Outbox 미경유) | try-catch + 로그, 유실 허용 | + +**incrementLikeCount를 동기로 유지하는 이유 (09 §2.7):** +- 사용자가 좋아요 직후 목록을 새로고침하면 반영되어 있기를 기대 +- AFTER_COMMIT에서 best-effort로 실행하되, 실패해도 Like 자체는 이미 저장됨 +- product_metrics + MetricsReconcileTasklet이 최종 정합성을 보장하는 안전망 역할 + +**캐시 무효화를 동기로 유지하는 이유:** +- 다음 조회 시 최신 데이터 보장 (UX) +- Redis eviction은 ~1ms — 응답 지연 무시 가능 + +### 3.5 이벤트 발행 위치 + +```java +// LikeFacade — 변경 후 +@Transactional +public void addLike(Long memberId, Long productId) { + // ... 기존 검증 ... + Like like = likeRepository.save(new Like(memberId, productId)); + outboxRepository.save(EventOutbox.create( + "Product", productId, "LIKE_CREATED", payload)); // Outbox INSERT (같은 TX) + eventPublisher.publishEvent(new LikeCreatedEvent( + productId, memberId, like.getId())); // AFTER_COMMIT 트리거 +} + +// OrderFacade — 변경 후 +@Transactional +public Order createOrder(...) { + // ... 기존 핵심 로직 (재고 차감 + 쿠폰 + 주문 저장) ... + outboxRepository.save(EventOutbox.create( + "Order", order.getId(), "ORDER_CREATED", payload)); // Outbox INSERT (같은 TX) + eventPublisher.publishEvent(new OrderCreatedEvent( + order.getId(), memberId, items)); // AFTER_COMMIT 트리거 + return order; +} + +// ProductFacade — 변경 후 +public ProductDto.ProductResponse getProductDetailCached(Long productId) { + // ... 기존 캐시 조회 로직 ... + eventPublisher.publishEvent(new ProductViewedEvent( + productId, memberId)); // 조회수 이벤트 (TX 없음) + return response; +} +``` + +### 3.6 @Async 스레드 풀 + +```java +@Configuration +@EnableAsync +public class AsyncConfig implements AsyncConfigurer { + + @Override + public Executor getAsyncExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(2); + executor.setMaxPoolSize(4); + executor.setQueueCapacity(100); + executor.setThreadNamePrefix("event-async-"); + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + executor.initialize(); + return executor; + } +} +``` + +**core=2, max=4 근거 (09 §13):** +- @Async 대상 작업: 조회수 Kafka 발행 (논블로킹) +- DB/Redis 커넥션 미사용 → HikariCP(max 40)과 경합 없음 +- 큰 풀은 컨텍스트 스위칭만 유발 +- CallerRunsPolicy → 큐 초과 시 호출 스레드에서 실행 (배압) + +--- + +## 4. Step 2 — Kafka 이벤트 파이프라인 + +### 4.1 전체 아키텍처 흐름도 + +``` +┌─────────────────── commerce-api ───────────────────┐ +│ │ +│ [Facade] │ +│ │ │ +│ ├─ [TX] 도메인 변경 + event_outbox INSERT │ +│ │ → commit │ +│ │ │ +│ ├─ [AFTER_COMMIT] ApplicationEvent │ +│ │ ├─ incrementLikeCount (동기, best-effort) │ +│ │ ├─ 캐시 무효화 (동기) │ +│ │ └─ 조회수 KafkaTemplate.send (@Async) │ +│ │ │ +│ └─ event_outbox 테이블 │ +│ ↓ (MySQL binlog) │ +└─────────┼───────────────────────────────────────────┘ + │ + ┌──────┼──────── Kafka Connect ──────────────┐ + │ [Debezium MySQL Connector] │ + │ └─ Outbox Event Router SMT │ + │ → route.by.field = aggregate_type │ + └──────┼─────────────────────────────────────┘ + │ + ┌──────┼──────── Kafka ──────────────────────┐ + │ ▼ │ + │ ┌─────────────────┐ ┌──────────────────┐ │ + │ │ catalog-events │ │ order-events │ │ + │ │ (product views, │ │ (order created, │ │ + │ │ likes) │ │ cancelled) │ │ + │ └────────┬────────┘ └────────┬─────────┘ │ + │ │ │ │ + │ ┌────────────────────────────┐ │ + │ │ coupon-issue-requests │ │ + │ └────────┬───────────────────┘ │ + └───────────┼──────────────┼──────────────────┘ + │ │ + ┌───────────┼──────────────┼─── commerce-streamer ──┐ + │ ▼ ▼ │ + │ [MetricsConsumer] [CouponIssueConsumer] │ + │ → product_metrics → CAS UPDATE coupon │ + │ UPSERT → CouponIssue INSERT │ + │ → event_handled → event_handled │ + └───────────────────────────────────────────────────┘ +``` + +### 4.2 event_outbox DDL + Entity + +```sql +CREATE TABLE event_outbox ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + aggregate_type VARCHAR(50) NOT NULL, -- 'Product', 'Order' + aggregate_id BIGINT NOT NULL, -- productId, orderId + event_type VARCHAR(50) NOT NULL, -- 'LIKE_CREATED', 'ORDER_CREATED', ... + payload TEXT NOT NULL, -- JSON + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + INDEX idx_event_outbox_created_at (created_at) +); +``` + +**status 컬럼이 없는 이유 (09 §8.5):** +Debezium이 MySQL binlog에서 직접 읽으므로 PENDING/PROCESSED 구분이 불필요하다. +Poller 방식이라면 `SELECT WHERE status = 'PENDING'`이 필요하지만, CDC 방식은 INSERT 시점에 binlog 이벤트가 발생하며 Debezium이 이를 실시간 감지한다. + +```java +// commerce-api: com.loopers.domain.event + +@Entity +@Table(name = "event_outbox") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class EventOutbox { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "aggregate_type", nullable = false, length = 50) + private String aggregateType; + + @Column(name = "aggregate_id", nullable = false) + private Long aggregateId; + + @Column(name = "event_type", nullable = false, length = 50) + private String eventType; + + @Column(nullable = false, columnDefinition = "TEXT") + private String payload; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + public static EventOutbox create(String aggregateType, Long aggregateId, + String eventType, String payload) { + EventOutbox outbox = new EventOutbox(); + outbox.aggregateType = aggregateType; + outbox.aggregateId = aggregateId; + outbox.eventType = eventType; + outbox.payload = payload; + outbox.createdAt = LocalDateTime.now(); + return outbox; + } +} +``` + +### 4.3 Debezium CDC 구성 + +#### 4.3.1 MySQL binlog 활성화 + +```yaml +# docker/infra-compose.yml — mysql 서비스에 command 추가 +mysql: + image: mysql:8.0 + command: + - --log-bin=mysql-bin + - --binlog-format=ROW + - --binlog-row-image=FULL + - --server-id=1 + # ... 기존 설정 유지 +``` + +#### 4.3.2 Kafka Connect Docker 서비스 + +```yaml +# docker/infra-compose.yml — 서비스 추가 +kafka-connect: + image: debezium/connect:2.5 + container_name: kafka-connect + depends_on: + kafka: + condition: service_healthy + mysql: + condition: service_started + ports: + - "8083:8083" + environment: + GROUP_ID: 1 + BOOTSTRAP_SERVERS: kafka:9092 + CONFIG_STORAGE_TOPIC: _connect_configs + OFFSET_STORAGE_TOPIC: _connect_offsets + STATUS_STORAGE_TOPIC: _connect_status + CONFIG_STORAGE_REPLICATION_FACTOR: 1 + OFFSET_STORAGE_REPLICATION_FACTOR: 1 + STATUS_STORAGE_REPLICATION_FACTOR: 1 + KEY_CONVERTER: org.apache.kafka.connect.json.JsonConverter + VALUE_CONVERTER: org.apache.kafka.connect.json.JsonConverter + KEY_CONVERTER_SCHEMAS_ENABLE: "false" + VALUE_CONVERTER_SCHEMAS_ENABLE: "false" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8083/connectors"] + interval: 10s + timeout: 5s + retries: 10 +``` + +**KRaft 모드 + Kafka Connect 내부 토픽 자동 생성 이슈:** + +현재 인프라는 KRaft 모드(ZooKeeper 없음)로 Kafka를 운영한다. Kafka Connect는 시작 시 내부 토픽 3개(`_connect_configs`, `_connect_offsets`, `_connect_status`)를 자동 생성하는데, KRaft 컨트롤러가 아직 준비되지 않은 시점에 생성을 시도하면 실패할 수 있다. + +**대응:** +1. `depends_on: kafka: condition: service_healthy` — Kafka 브로커의 healthcheck 통과 후 Connect 시작 +2. Connect의 `healthcheck.retries: 10` — 내부 토픽 생성 재시도 여유 확보 +3. 만약 Connect 시작 실패 시, 내부 토픽을 수동 생성: + +```bash +# Kafka Connect 내부 토픽 수동 생성 (KRaft 환경에서 자동 생성 실패 시) +docker exec kafka kafka-topics.sh --bootstrap-server localhost:9092 \ + --create --topic _connect_configs --partitions 1 --replication-factor 1 --config cleanup.policy=compact +docker exec kafka kafka-topics.sh --bootstrap-server localhost:9092 \ + --create --topic _connect_offsets --partitions 25 --replication-factor 1 --config cleanup.policy=compact +docker exec kafka kafka-topics.sh --bootstrap-server localhost:9092 \ + --create --topic _connect_status --partitions 5 --replication-factor 1 --config cleanup.policy=compact +``` + +#### 4.3.3 Debezium MySQL Connector + Outbox Event Router SMT + +```bash +#!/bin/bash +# docker/register-debezium-connector.sh + +curl -X POST http://localhost:8083/connectors -H "Content-Type: application/json" -d '{ + "name": "loopers-outbox-connector", + "config": { + "connector.class": "io.debezium.connector.mysql.MySqlConnector", + "tasks.max": "1", + + "database.hostname": "mysql", + "database.port": "3306", + "database.user": "root", + "database.password": "root", + "database.server.id": "184054", + "topic.prefix": "loopers", + + "database.include.list": "loopers", + "table.include.list": "loopers.event_outbox", + + "schema.history.internal.kafka.bootstrap.servers": "kafka:9092", + "schema.history.internal.kafka.topic": "_schema_history", + + "transforms": "outbox", + "transforms.outbox.type": "io.debezium.transforms.outbox.EventRouter", + "transforms.outbox.table.field.event.id": "id", + "transforms.outbox.table.field.event.key": "aggregate_id", + "transforms.outbox.table.field.event.type": "event_type", + "transforms.outbox.table.field.event.payload": "payload", + "transforms.outbox.route.by.field": "aggregate_type", + "transforms.outbox.route.topic.replacement": "${routedByValue}-events", + "transforms.outbox.table.fields.additional.placement": "event_type:header:eventType", + + "tombstones.on.delete": "false" + } +}' +``` + +**라우팅 결과:** + +| aggregate_type | 라우팅 토픽 | event_type 예시 | +|---|---|---| +| Product | `Product-events` → 별칭: `catalog-events` | LIKE_CREATED, LIKE_REMOVED | +| Order | `Order-events` → 별칭: `order-events` | ORDER_CREATED, ORDER_CANCELLED | + +> **토픽 라우팅 보완:** Debezium Outbox Event Router의 `route.topic.replacement`이 `${routedByValue}-events`로 동작하므로, aggregate_type 값을 소문자(`product`, `order`)로 저장하거나, RegexRouter SMT를 추가하여 `catalog-events`, `order-events`로 변환한다. 구현 시 최종 확정. + +### 4.4 토픽 설계 + +| 토픽 | Key | 이벤트 유형 | Producer | Consumer | +|---|---|---|---|---| +| `catalog-events` | productId | LIKE_CREATED, LIKE_REMOVED, PRODUCT_VIEWED | Debezium + commerce-api(조회수) | commerce-streamer | +| `order-events` | orderId | ORDER_CREATED, ORDER_CANCELLED | Debezium | commerce-streamer | +| `coupon-issue-requests` | couponId | COUPON_ISSUE_REQUESTED | commerce-api (직접) | commerce-streamer | + +**Key 설계 근거:** +- catalog-events key=productId → 같은 상품의 이벤트는 같은 파티션 → 순서 보장 +- order-events key=orderId → 같은 주문의 이벤트는 같은 파티션 +- coupon-issue-requests key=couponId → 같은 쿠폰의 발급 요청은 같은 파티션 + +**acks + min.insync.replicas 상관관계:** + +| 설정 조합 | 의미 | 메시지 유실 | 가용성 | +|---|---|---|---| +| `acks=all` + `replicas=1` + `min.insync.replicas=1` | **현재 (개발)** — 브로커 1대뿐이므로 acks=all ≡ acks=1 | 브로커 장애 시 유실 | 높음 | +| `acks=all` + `replicas=3` + `min.insync.replicas=2` | **프로덕션 권장** — Leader + 최소 1 Follower 기록 확인 | 2대 동시 장애 아닌 한 무유실 | 1대 장애까지 허용 | +| `acks=all` + `replicas=3` + `min.insync.replicas=3` | ISR 3대 모두 기록 확인 | 무유실 | 1대라도 장애 시 쓰기 불가 | + +> **핵심**: `acks=all`은 ISR(In-Sync Replicas) 전원에게 기록 확인을 요구하지만, 브로커가 1대뿐이면 `acks=1`과 동일하다. `acks=all`이 의미를 갖으려면 반드시 `min.insync.replicas ≥ 2` + `replicas ≥ 3`이 전제되어야 한다. + +```java +// commerce-api: com.loopers.infrastructure.kafka + +@Configuration +public class KafkaTopicConfig { + + // 개발 환경: 단일 브로커 → replicas=1 + // 프로덕션: replicas=3, min.insync.replicas=2 설정 필수 + // → .config("min.insync.replicas", "2") + + @Bean + public NewTopic catalogEvents() { + return TopicBuilder.name("catalog-events") + .partitions(3) + .replicas(1) // 프로덕션: .replicas(3) + .build(); + } + + @Bean + public NewTopic orderEvents() { + return TopicBuilder.name("order-events") + .partitions(3) + .replicas(1) // 프로덕션: .replicas(3) + .build(); + } + + @Bean + public NewTopic couponIssueRequests() { + return TopicBuilder.name("coupon-issue-requests") + .partitions(3) + .replicas(1) // 프로덕션: .replicas(3) + .build(); + } +} +``` + +### 4.5 Producer 설정 보완 + +```yaml +# modules/kafka/src/main/resources/kafka.yml — producer 섹션 보완 +spring: + kafka: + producer: + acks: all # 모든 ISR에 기록 확인 후 응답 → 메시지 유실 방지 + key-serializer: org.apache.kafka.common.serialization.StringSerializer + value-serializer: org.springframework.kafka.support.serializer.JsonSerializer + # retries 명시하지 않음 — enable.idempotence=true 시 기본값 Integer.MAX_VALUE + # retries를 직접 설정하면 idempotent producer의 무한 재시도 보장이 깨진다 + properties: + enable.idempotence: true # Producer 레벨 중복 발행 방지 + max.in.flight.requests.per.connection: 5 # idempotence 활성화 시 최대 5 + delivery.timeout.ms: 120000 # 재시도 포함 전체 발행 타임아웃 (2분) + linger.ms: 50 # 50ms 버퍼링 → 배치 효율 향상 + batch.size: 32768 # 32KB 배치 크기 + compression.type: lz4 # 압축 → 네트워크 I/O 감소 + Broker 디스크 절약 +``` + +**enable.idempotence=true와 retries의 관계:** +- `enable.idempotence=true`를 설정하면 Kafka는 내부적으로 `retries=Integer.MAX_VALUE`, `max.in.flight.requests.per.connection ≤ 5`를 강제한다. +- `retries: 3`을 명시하면 idempotent producer의 기본값(MAX_VALUE)을 **덮어쓴다** → 3회 재시도 후 포기 → 메시지 유실 가능. +- 재시도 횟수 대신 `delivery.timeout.ms`(기본 120초)로 **시간 기반 제어**가 올바르다. 이 시간 내에서 무한 재시도한다. + +**linger.ms + batch.size + compression.type의 원리 — Zero-Copy와 OS Page Cache:** + +Kafka의 높은 처리량은 두 가지 OS 수준 최적화에 기반한다: + +1. **Zero-Copy (sendfile 시스템콜)**: Broker가 Consumer에게 메시지를 전달할 때, 디스크 → 커널 버퍼 → 네트워크 소켓으로 직접 복사한다. 유저 스페이스로 데이터를 올리지 않으므로 CPU 사용량과 메모리 복사가 극적으로 줄어든다. +2. **OS Page Cache**: Broker는 메시지를 JVM 힙이 아닌 OS 페이지 캐시에 저장한다. 최근 메시지는 디스크 I/O 없이 메모리에서 바로 서빙된다. + +이 두 가지 최적화의 효율을 극대화하려면 **작은 메시지를 하나씩 보내는 대신, 배치로 묶어서 보내는 것**이 핵심이다: +- `linger.ms=50`: 50ms 동안 메시지를 버퍼에 모은 뒤 한 번에 전송 → 네트워크 라운드트립 감소 +- `batch.size=32768`: 32KB 단위로 배치 → Zero-Copy 시 큰 블록 전송으로 효율 증가 +- `compression.type=lz4`: 배치 단위 압축 → 네트워크 I/O 감소 + Broker 디스크 절약 + 페이지 캐시 적중률 향상 (같은 메모리에 더 많은 메시지 캐싱) + +### 4.6 Consumer 설정 보완 + +```yaml +# modules/kafka/src/main/resources/kafka.yml — consumer 섹션 수정 +spring: + kafka: + consumer: + group-id: loopers-default-consumer + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.apache.kafka.common.serialization.ByteArrayDeserializer # 오타 수정 + auto-offset-reset: earliest # 신규 Consumer Group은 처음부터 읽기 (latest → 유실) + properties: + enable-auto-commit: false + isolation.level: read_committed # Debezium TX 메시지 — 커밋된 것만 읽기 +``` + +**설정 근거:** +- `auto-offset-reset: earliest` — 신규 Consumer Group이 토픽에 처음 참여할 때 `latest`(기본값)이면 기존 메시지를 건너뛴다. 이벤트 파이프라인에서 메시지 유실은 허용 불가. `earliest`로 설정하여 처음부터 읽는다. 중복은 event_handled가 걸러낸다. +- `isolation.level: read_committed` — Debezium이 Outbox 테이블의 INSERT를 binlog에서 읽을 때, TX가 커밋되기 전의 중간 상태도 발행될 수 있다. `read_committed`는 커밋된 메시지만 Consumer에게 노출한다. + +**SINGLE_LISTENER 추가 (KafkaConfig.java):** + +```java +// modules/kafka — KafkaConfig.java에 추가 + +public static final String SINGLE_LISTENER = "SINGLE_LISTENER_DEFAULT"; + +@Bean(name = SINGLE_LISTENER) +public ConcurrentKafkaListenerContainerFactory defaultSingleListenerContainerFactory( + KafkaProperties kafkaProperties, + ByteArrayJsonMessageConverter converter, + DefaultErrorHandler errorHandler +) { + Map consumerConfig = new HashMap<>(kafkaProperties.buildConsumerProperties()); + // SINGLE_LISTENER는 건별 CAS UPDATE — 처리 시간이 BATCH보다 길 수 있음 + consumerConfig.put(ConsumerConfig.MAX_POLL_INTERVAL_MS_CONFIG, 600_000); // 10분 + + ConcurrentKafkaListenerContainerFactory factory = + new ConcurrentKafkaListenerContainerFactory<>(); + factory.setConsumerFactory(new DefaultKafkaConsumerFactory<>(consumerConfig)); + factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL); + factory.setMessageConverter(converter); + factory.setConcurrency(1); + factory.setBatchListener(false); // 단건 처리 + factory.setCommonErrorHandler(errorHandler); + return factory; +} +``` + +**max.poll.interval.ms 설정 근거:** +- SINGLE_LISTENER에서 건별 CAS UPDATE + UNIQUE INSERT + 상태 업데이트를 수행한다. +- DB 부하가 높은 시점에 처리가 지연되면, 기본값(5분) 내에 다음 poll()을 호출하지 못해 리밸런싱이 발생할 수 있다. +- 10분으로 여유를 두어 일시적 DB 지연 시에도 불필요한 리밸런싱을 방지한다. + +### 4.7 Error Handler + DLQ + +```java +// modules/kafka — KafkaConfig.java에 추가 + +@Bean +public DefaultErrorHandler errorHandler(KafkaTemplate kafkaTemplate) { + DeadLetterPublishingRecoverer recoverer = + new DeadLetterPublishingRecoverer(kafkaTemplate); + // 3회 재시도, 1초 간격 고정 백오프 + return new DefaultErrorHandler(recoverer, new FixedBackOff(1000L, 3)); +} +``` + +**동작:** +1. Consumer 메시지 처리 실패 시 1초 간격으로 최대 3회 재시도 +2. 3회 모두 실패 → DLT(Dead Letter Topic)로 이동 (원본 토픽명 + `.DLT`) +3. DLT 예시: `catalog-events.DLT`, `order-events.DLT`, `coupon-issue-requests.DLT` + +### 4.8 조회수 직접 Kafka 발행 + +```java +// commerce-api: com.loopers.application.event + +@Slf4j +@Component +@RequiredArgsConstructor +public class ProductViewKafkaPublisher { + + private final KafkaTemplate kafkaTemplate; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(ProductViewedEvent event) { + // 조회는 TX 없음 → @EventListener로도 가능하지만 + // 일관성을 위해 @TransactionalEventListener 사용 (fallbackExecution = true 고려) + try { + kafkaTemplate.send("catalog-events", + String.valueOf(event.productId()), + Map.of( + "eventType", "PRODUCT_VIEWED", + "productId", event.productId(), + "memberId", event.memberId(), + "timestamp", Instant.now().toString() + )); + } catch (Exception e) { + log.warn("조회수 Kafka 발행 실패 — productId={}", event.productId(), e); + // 유실 허용: 조회수는 정확성보다 추세가 중요 + } + } +} +``` + +**Outbox 미경유 근거 (09 §3.10):** +- 조회 = 읽기 전용 (DB 쓰기 없음) → Outbox INSERT를 위한 별도 TX가 필요 +- 조회마다 DB 쓰기 1건 추가 = 성능 오버헤드 +- 조회수는 정확성보다 추세가 중요 (±수 건 허용) +- KafkaTemplate.send()는 내부적으로 배치 + 버퍼링 (효율적) + +### 4.9 Outbox 테이블 정리 + +```java +// commerce-batch: MetricsReconcileTasklet 또는 별도 스케줄러 + +// 1시간 보존 후 Batch DELETE +@Scheduled(cron = "0 0 * * * *") // 매 시 정각 +public void cleanupOutbox() { + int deleted = entityManager.createNativeQuery( + "DELETE FROM event_outbox WHERE created_at < DATE_SUB(NOW(), INTERVAL 1 HOUR) LIMIT 10000" + ).executeUpdate(); + log.info("[OutboxCleanup] 삭제 건수: {}", deleted); +} +``` + +**규모 산정:** +- 좋아요: 일 100만 건, 주문: 일 50만 건, 조회: Outbox 미경유 +- event_outbox: 일 150만 건 (행당 ~500 bytes) +- Debezium이 binlog에서 읽으므로 테이블 누적 최소화 +- 1시간 보존 기준 최대 ~6.25만 건 → Batch DELETE ~1초 이내 + +--- + +## 5. product_metrics 집계 + +### 5.1 product_metrics DDL + +```sql +CREATE TABLE product_metrics ( + product_id BIGINT PRIMARY KEY, + like_count BIGINT NOT NULL DEFAULT 0, + view_count BIGINT NOT NULL DEFAULT 0, + sales_count BIGINT NOT NULL DEFAULT 0, + sales_amount BIGINT NOT NULL DEFAULT 0, + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6) +); +``` + +### 5.2 product_like_stats 흡수 + Product.like_count 유지 이유 + +**product_like_stats → product_metrics 흡수:** +- product_like_stats는 like_count만 보유 → product_metrics가 like_count + view_count + sales_count + sales_amount 통합 관리 +- LikeCountSyncTasklet → MetricsReconcileTasklet로 진화 +- product_like_stats 테이블은 product_metrics 마이그레이션 후 DROP + +**Product.like_count 컬럼 유지 (09 §3.6):** +- 정렬 인덱스 `idx_product_like_count(like_count DESC, id DESC)`가 이 컬럼 기준 +- 제거하면 좋아요순 정렬 시 product_metrics JOIN 필요 → 성능 하락 +- 비정규화 캐시로 유지, MetricsReconcileTasklet이 product_metrics 기준으로 보정 + +### 5.3 ProductMetrics Entity + +```java +// commerce-streamer: com.loopers.domain.metrics + +@Entity +@Table(name = "product_metrics") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProductMetrics { + + @Id + @Column(name = "product_id") + private Long productId; + + @Column(name = "like_count", nullable = false) + private long likeCount; + + @Column(name = "view_count", nullable = false) + private long viewCount; + + @Column(name = "sales_count", nullable = false) + private long salesCount; + + @Column(name = "sales_amount", nullable = false) + private long salesAmount; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; +} +``` + +### 5.4 MetricsConsumer + +```java +// commerce-streamer: com.loopers.interfaces.consumer + +@Slf4j +@Component +@RequiredArgsConstructor +public class MetricsConsumer { + + private final EntityManager entityManager; + private final PlatformTransactionManager transactionManager; + + @KafkaListener( + topics = {"catalog-events", "order-events"}, + groupId = "metrics-collector", // 전용 Consumer Group + containerFactory = KafkaConfig.BATCH_LISTENER + ) + public void consume( + List>> messages, + Acknowledgment acknowledgment + ) { + for (ConsumerRecord> record : messages) { + String eventId = extractEventId(record); + + // INSERT-first 패턴: event_handled + 비즈니스 로직을 단일 TX로 처리 + TransactionStatus tx = transactionManager.getTransaction( + new DefaultTransactionDefinition()); + try { + int inserted = entityManager.createNativeQuery( + "INSERT IGNORE INTO event_handled (event_id) VALUES (:eventId)" + ).setParameter("eventId", eventId).executeUpdate(); + + if (inserted == 0) { + transactionManager.rollback(tx); + continue; // 멱등: 이미 처리된 이벤트 + } + + String eventType = extractEventType(record); + Map payload = record.value(); + + switch (eventType) { + case "LIKE_CREATED" -> upsertMetrics( + toLong(payload.get("productId")), "like_count", 1); + case "LIKE_REMOVED" -> upsertMetrics( + toLong(payload.get("productId")), "like_count", -1); + case "PRODUCT_VIEWED" -> upsertMetrics( + toLong(payload.get("productId")), "view_count", 1); + case "ORDER_CREATED" -> handleOrderCreated(payload); + case "ORDER_CANCELLED" -> handleOrderCancelled(payload); + default -> log.warn("알 수 없는 이벤트 타입: {}", eventType); + } + + transactionManager.commit(tx); + } catch (Exception e) { + transactionManager.rollback(tx); + log.error("메트릭 처리 실패 — eventId={}", eventId, e); + } + } + acknowledgment.acknowledge(); + } + + private void upsertMetrics(Long productId, String column, long delta) { + entityManager.createNativeQuery( + "INSERT INTO product_metrics (product_id, " + column + ", updated_at) " + + "VALUES (:productId, :delta, NOW(6)) " + + "ON DUPLICATE KEY UPDATE " + + column + " = " + column + " + :delta, updated_at = NOW(6)" + ) + .setParameter("productId", productId) + .setParameter("delta", delta) + .executeUpdate(); + } +} +``` + +**Consumer Group 분리:** +- `metrics-collector`: MetricsConsumer 전용. catalog-events, order-events 구독. +- `coupon-issuer`: CouponIssueConsumer 전용. coupon-issue-requests 구독. +- 분리 이유: 같은 group-id를 공유하면, 한 Consumer의 처리 지연이 다른 Consumer의 리밸런싱을 유발한다. 쿠폰 발급(건별 CAS)과 메트릭 집계(배치 UPSERT)는 처리 특성이 완전히 다르므로 격리해야 한다. + +**BATCH_LISTENER 사용 이유:** +- catalog-events, order-events는 집계 연산 → 배치로 처리해도 정합성 문제 없음 +- 3000건/poll + manual ack → 높은 처리량 +- 개별 건 실패 시 배치 전체 재처리 → event_handled로 중복 방지 + +--- + +## 6. 멱등 처리 + +### 6.1 event_handled DDL + +```sql +CREATE TABLE event_handled ( + event_id VARCHAR(100) PRIMARY KEY, + handled_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + INDEX idx_event_handled_handled_at (handled_at) +); +``` + +### 6.2 멱등 처리 흐름 + +**원자성 보장 — INSERT-first 패턴:** + +기존 설계(비즈니스 로직 → event_handled INSERT)의 문제: 비즈니스 로직 성공 후, event_handled INSERT 전에 Consumer가 크래시하면 → 재시작 시 같은 메시지를 다시 처리 → **비즈니스 로직 중복 실행**. UPSERT(+1, -1)처럼 멱등하지 않은 연산에서 데이터 정합성이 깨진다. + +``` +Consumer가 메시지 수신: + 1. event_id 추출 (record header 또는 payload) + 2. [단일 TX 시작] + 2-1. INSERT IGNORE INTO event_handled (event_id) VALUES (?) + → affected rows = 0이면 skip (이미 처리됨) → ack → TX rollback + 2-2. 비즈니스 로직 실행 (UPSERT product_metrics 또는 CouponIssue INSERT) + 3. [TX 커밋] + 4. ack +``` + +**핵심: event_handled INSERT와 비즈니스 로직이 동일 트랜잭션 안에 있어야 한다.** + +- event_handled INSERT를 **먼저** 시도: 중복이면 즉시 skip → 불필요한 비즈니스 로직 실행 방지 +- INSERT 성공 → 비즈니스 로직 실행 → TX 커밋: 비즈니스 로직 실패 시 event_handled도 함께 롤백 +- TX 커밋 후 크래시 → 재시작 시 event_handled에 이미 존재 → skip → **중복 실행 불가** + +**INSERT IGNORE 패턴 vs SELECT 후 INSERT:** +- `INSERT IGNORE`: PK 중복 시 에러 없이 무시, affected rows로 판단 → 단일 쿼리 + race condition 방지 +- `SELECT → INSERT`: 조회~삽입 사이에 다른 Consumer가 같은 event_id를 처리할 수 있음 → 불안전 + +### 6.3 event_id 생성 전략 + +| 소스 | event_id 형식 | 예시 | +|---|---|---| +| Debezium Outbox | `outbox:{event_outbox.id}` | `outbox:12345` | +| 직접 Kafka 발행 (조회수) | `view:{productId}:{timestamp}:{uuid 8자리}` | `view:100:1719820800000:a1b2c3d4` | +| 선착순 쿠폰 | `coupon-issue:{couponIssueRequestId}` | `coupon-issue:5678` | + +### 6.4 event_handled 정리 + +```sql +-- 7일 보존 후 삭제 (commerce-batch 또는 스케줄러) +DELETE FROM event_handled WHERE handled_at < DATE_SUB(NOW(), INTERVAL 7 DAY) LIMIT 10000; +``` + +**7일 근거:** +- Kafka retention 기본값 7일 → 7일 이전 메시지는 Kafka에서도 삭제됨 +- 재처리 가능 범위 = Kafka retention과 일치시킴 + +--- + +## 7. Step 3 — 선착순 쿠폰 발급 + +### 7.1 Coupon 모델 확장 DDL + +```sql +ALTER TABLE coupon +ADD COLUMN max_issuance_count INT NULL COMMENT 'NULL이면 무제한', +ADD COLUMN issued_count INT NOT NULL DEFAULT 0; +``` + +### 7.2 coupon_issue UNIQUE 제약 + +```sql +ALTER TABLE coupon_issue +ADD UNIQUE INDEX uk_coupon_issue_coupon_member (coupon_id, member_id); +``` + +**근거:** 같은 쿠폰 + 같은 유저 → INSERT 시 중복이면 예외 → 거절 + +### 7.3 coupon_issue_request DDL + Entity + +```sql +CREATE TABLE coupon_issue_request ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + coupon_id BIGINT NOT NULL, + member_id BIGINT NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', -- PENDING / COMPLETED / REJECTED + reject_reason VARCHAR(100), + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + completed_at DATETIME(6), + INDEX idx_coupon_issue_request_member (member_id), + INDEX idx_coupon_issue_request_coupon (coupon_id) +); +``` + +```java +// commerce-api: com.loopers.domain.coupon + +@Entity +@Table(name = "coupon_issue_request") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class CouponIssueRequest { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "coupon_id", nullable = false) + private Long couponId; + + @Column(name = "member_id", nullable = false) + private Long memberId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private CouponIssueRequestStatus status; + + @Column(name = "reject_reason", length = 100) + private String rejectReason; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "completed_at") + private LocalDateTime completedAt; + + public static CouponIssueRequest create(Long couponId, Long memberId) { + CouponIssueRequest request = new CouponIssueRequest(); + request.couponId = couponId; + request.memberId = memberId; + request.status = CouponIssueRequestStatus.PENDING; + request.createdAt = LocalDateTime.now(); + return request; + } +} + +public enum CouponIssueRequestStatus { + PENDING, COMPLETED, REJECTED +} +``` + +### 7.4 발급 요청 API 흐름 + +``` +[사용자] → POST /api/v1/coupons/{couponId}/issue-request + +[commerce-api — CouponFacade.requestCouponIssue] + 1. Coupon 조회 + 만료 확인 + 2. coupon_issue_request INSERT (status = PENDING) + 3. Kafka에 COUPON_ISSUE_REQUESTED 발행 (key = couponId) + → KafkaTemplate.send("coupon-issue-requests", couponId, payload) + 4. 즉시 응답: { requestId, status: "PENDING" } +``` + +```java +// commerce-api: CouponFacade — 추가 메서드 + +@Transactional +public CouponIssueRequest requestCouponIssue(Long couponId, Long memberId) { + Coupon coupon = couponRepository.findById(couponId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "쿠폰을 찾을 수 없습니다.")); + ZonedDateTime now = ZonedDateTime.now(clock); + if (now.isAfter(coupon.getExpiredAt())) { + throw new CoreException(ErrorType.BAD_REQUEST, "만료된 쿠폰입니다."); + } + + CouponIssueRequest request = CouponIssueRequest.create(couponId, memberId); + couponIssueRequestRepository.save(request); + + kafkaTemplate.send("coupon-issue-requests", + String.valueOf(couponId), + Map.of( + "requestId", request.getId(), + "couponId", couponId, + "memberId", memberId, + "timestamp", Instant.now().toString() + )); + + return request; +} +``` + +### 7.5 Consumer 처리 흐름 + +``` +[commerce-streamer — CouponIssueConsumer (SINGLE_LISTENER, groupId=coupon-issuer)] + + [단일 TX 시작] + 1. INSERT IGNORE INTO event_handled (event_id = "coupon-issue:{requestId}") + → affected rows = 0 → skip (이미 처리됨) → TX rollback + ack + + 2. CAS UPDATE — 수량 확인 + 발급 카운트 증가 + UPDATE coupon + SET issued_count = issued_count + 1 + WHERE id = :couponId + AND issued_count < max_issuance_count + AND deleted_at IS NULL; + → affected rows = 0 → 수량 소진 → REJECTED + + 3. CouponIssue INSERT (중복 발급 방지) + INSERT INTO coupon_issue (coupon_id, member_id, status, expired_at, created_at) + VALUES (:couponId, :memberId, 'AVAILABLE', :expiredAt, NOW()); + → DuplicateKeyException (uk_coupon_issue_coupon_member) → 이미 발급 → REJECTED + + 4. coupon_issue_request 상태 업데이트 + UPDATE coupon_issue_request + SET status = 'COMPLETED', completed_at = NOW() + WHERE id = :requestId; + + [TX 커밋] → ack + + * 비즈니스 로직 실패 시 event_handled도 함께 롤백 → 재시도 가능 +``` + +```java +// commerce-streamer: com.loopers.interfaces.consumer + +@Slf4j +@Component +@RequiredArgsConstructor +public class CouponIssueConsumer { + + private final EntityManager entityManager; + private final PlatformTransactionManager transactionManager; + + @KafkaListener( + topics = "coupon-issue-requests", + groupId = "coupon-issuer", // 전용 Consumer Group + containerFactory = KafkaConfig.SINGLE_LISTENER // 단건 처리 — 개별 에러 핸들링 + ) + public void consume(ConsumerRecord> record, + Acknowledgment acknowledgment) { + Map payload = record.value(); + Long requestId = toLong(payload.get("requestId")); + String eventId = "coupon-issue:" + requestId; + Long couponId = toLong(payload.get("couponId")); + Long memberId = toLong(payload.get("memberId")); + + // INSERT-first 패턴: event_handled + 비즈니스 로직을 단일 TX로 처리 + TransactionStatus tx = transactionManager.getTransaction( + new DefaultTransactionDefinition()); + try { + int inserted = entityManager.createNativeQuery( + "INSERT IGNORE INTO event_handled (event_id) VALUES (:eventId)" + ).setParameter("eventId", eventId).executeUpdate(); + + if (inserted == 0) { + transactionManager.rollback(tx); + acknowledgment.acknowledge(); + return; // 멱등: 이미 처리됨 + } + + processCouponIssue(requestId, couponId, memberId); + transactionManager.commit(tx); + } catch (Exception e) { + transactionManager.rollback(tx); + log.error("쿠폰 발급 처리 실패 — requestId={}", requestId, e); + // 실패 시 event_handled도 롤백됨 → 재시도 가능 + // DLQ로 이동 시 rejectRequest 처리는 ErrorHandler에서 수행 + } + + acknowledgment.acknowledge(); + } + + private void processCouponIssue(Long requestId, Long couponId, Long memberId) { + // CAS UPDATE — 수량 확인 + 발급 카운트 증가 + int updated = entityManager.createNativeQuery( + "UPDATE coupon SET issued_count = issued_count + 1 " + + "WHERE id = :couponId AND issued_count < max_issuance_count " + + "AND deleted_at IS NULL" + ).setParameter("couponId", couponId).executeUpdate(); + + if (updated == 0) { + rejectRequest(requestId, "수량 소진"); + return; + } + + // CouponIssue INSERT + try { + entityManager.createNativeQuery( + "INSERT INTO coupon_issue (coupon_id, member_id, status, expired_at, created_at) " + + "SELECT :couponId, :memberId, 'AVAILABLE', c.expired_at, NOW() " + + "FROM coupon c WHERE c.id = :couponId" + ).setParameter("couponId", couponId) + .setParameter("memberId", memberId) + .executeUpdate(); + } catch (Exception e) { + // UNIQUE 제약 위반 → 이미 발급됨 → issued_count 롤백 + entityManager.createNativeQuery( + "UPDATE coupon SET issued_count = issued_count - 1 WHERE id = :couponId" + ).setParameter("couponId", couponId).executeUpdate(); + rejectRequest(requestId, "이미 발급된 쿠폰"); + return; + } + + // 성공 상태 업데이트 + entityManager.createNativeQuery( + "UPDATE coupon_issue_request SET status = 'COMPLETED', completed_at = NOW() " + + "WHERE id = :requestId" + ).setParameter("requestId", requestId).executeUpdate(); + } +} +``` + +### 7.6 동시성 제어 — Kafka만으로 부족한 이유 + DB CAS가 핵심 + +``` +오해: "key=couponId → 같은 파티션 → 순차 소비 → 동시성 해결" + +현실 (09 §4.3): + 1. Consumer 장애 → Rebalancing → 메시지 재처리 (At Least Once) + → 같은 요청이 2번 처리될 수 있음 + 2. Consumer Group 내 파티션 재할당 중 중복 소비 가능 + 3. 배치 리스너의 경우 동일 couponId의 여러 요청이 같은 배치에 포함 + +결론: + Kafka = "폭주 요청 버퍼링 + 순서 힌트" (부하 완충) + DB CAS UPDATE = "수량 제어의 핵심" (정확성 보장) + UNIQUE 제약 = "중복 발급 방지의 최종 방어선" +``` + +**SINGLE_LISTENER 사용 이유 (09 §11):** +- 건별 CAS UPDATE + 개별 에러 핸들링이 필요 +- BATCH_LISTENER에서 배치 내 부분 실패 처리가 복잡 +- 쿠폰 발급은 집계와 달리 건별 정확성이 중요 + +### 7.7 결과 확인 — Polling + +``` +[사용자] → GET /api/v1/coupons/issue-requests/{requestId} + → coupon_issue_request 조회 + → { requestId, status: "PENDING" | "COMPLETED" | "REJECTED", rejectReason } +``` + +**Polling 선택 근거 (09 §4.7):** +- 구현 단순, 인프라 추가 불필요 +- 쿠폰 발급은 수 초 내 완료 → 1~2회 polling이면 충분 +- SSE/WebSocket은 커넥션 유지 오버헤드 + +--- + +## 8. Redis 설정 보완 + +```java +// modules/redis: RedisConfig.java — lettuceConnectionFactory 메서드 수정 + +private LettuceConnectionFactory lettuceConnectionFactory( + int database, + RedisNodeInfo master, + List replicas, + Consumer customizer +) { + LettuceClientConfiguration.LettuceClientConfigurationBuilder builder = + LettuceClientConfiguration.builder() + .commandTimeout(Duration.ofMillis(500)); // ← 추가 + if (customizer != null) customizer.accept(builder); + // ... 이하 동일 +} +``` + +**근거 (09 §12.2):** +- 현재: 타임아웃 미설정 → Redis 장애 시 스레드 무한 대기 가능 +- 정상 응답 ~1ms, 500ms 초과 = 장애 판단 +- Lettuce NIO multiplexing이므로 커넥션 풀 불필요 — 타임아웃만으로 보호 + +--- + +## 9. 전체 흐름 통합 + +### 9.1 좋아요 분리 후 전체 흐름도 + +``` +[사용자] POST /api/v1/products/{productId}/likes + │ + ▼ +[LikeFacade.addLike — TX] + Like INSERT + event_outbox INSERT + → TX commit + │ + ├─ [AFTER_COMMIT — 동기] + │ ├─ Product.incrementLikeCount (best-effort) + │ └─ 캐시 무효화 (evictProductDetail + evictProductList) + │ + └─ [event_outbox — MySQL binlog] + → Debezium → catalog-events 토픽 + → [commerce-streamer] MetricsConsumer + → product_metrics.like_count UPSERT + → event_handled INSERT +``` + +### 9.2 주문 분리 후 전체 흐름도 + +``` +[사용자] POST /api/v1/orders + │ + ▼ +[OrderFacade.createOrder — TX] + 재고 차감 + 쿠폰 적용 + 주문 저장 + event_outbox INSERT + → TX commit + │ + └─ [event_outbox — MySQL binlog] + → Debezium → order-events 토픽 + → [commerce-streamer] MetricsConsumer + → product_metrics.sales_count / sales_amount UPSERT (상품별) + → event_handled INSERT +``` + +### 9.3 조회수 전체 흐름도 + +``` +[사용자] GET /api/v1/products/{productId} + │ + ▼ +[ProductFacade.getProductDetailCached] + L1/L2 캐시 → DB → 응답 + │ + └─ [ApplicationEvent — ProductViewedEvent] + → [ProductViewKafkaPublisher — @Async] + → KafkaTemplate.send("catalog-events", productId, payload) + → [commerce-streamer] MetricsConsumer + → product_metrics.view_count UPSERT + → event_handled INSERT + +* Outbox 미경유 — 읽기 전용 연산, 유실 허용 +``` + +### 9.4 선착순 쿠폰 전체 흐름도 + +``` +[사용자] POST /api/v1/coupons/{couponId}/issue-request + │ + ▼ +[CouponFacade.requestCouponIssue — TX] + Coupon 검증 + coupon_issue_request INSERT (PENDING) + → KafkaTemplate.send("coupon-issue-requests", couponId, payload) + → 즉시 응답: { requestId, status: "PENDING" } + │ + └─ [Kafka — coupon-issue-requests 토픽] + → [commerce-streamer] CouponIssueConsumer (SINGLE_LISTENER) + → event_handled 확인 (멱등) + → CAS UPDATE coupon.issued_count (수량 확인) + → INSERT coupon_issue (UNIQUE 제약) + → UPDATE coupon_issue_request (COMPLETED / REJECTED) + → event_handled INSERT + +[사용자] GET /api/v1/coupons/issue-requests/{requestId} + → 결과 확인 (Polling) +``` + +### 9.5 주문 취소 전체 흐름도 + +``` +[사용자] DELETE /api/v1/orders/{orderId} + │ + ▼ +[OrderFacade.cancelOrder — TX] + order.cancel() + 재고 복원 + 쿠폰 복원 + event_outbox INSERT + → TX commit + │ + └─ [event_outbox — MySQL binlog] + → Debezium → order-events 토픽 + → [commerce-streamer] MetricsConsumer + → product_metrics.sales_count / sales_amount 차감 (상품별) + → event_handled INSERT +``` + +--- + +## 10. 정합성 안전망 + +### 10.1 MetricsReconcileTasklet (LikeCountSyncTasklet 진화) + +```java +// commerce-batch: com.loopers.batch.job.metricsreconcile.step + +@Slf4j +@RequiredArgsConstructor +@Component +public class MetricsReconcileTasklet implements Tasklet { + + private final EntityManager entityManager; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + // 1단계: likes 테이블 기준 → product_metrics.like_count 보정 + log.info("[MetricsReconcile] 1단계: like_count 대사 시작"); + int likeCorrected = entityManager.createNativeQuery( + "INSERT INTO product_metrics (product_id, like_count, updated_at) " + + "SELECT l.product_id, COUNT(*), NOW(6) FROM likes l GROUP BY l.product_id " + + "ON DUPLICATE KEY UPDATE like_count = VALUES(like_count), updated_at = NOW(6)" + ).executeUpdate(); + log.info("[MetricsReconcile] 1단계 완료 — 대사 행 수: {}", likeCorrected); + + // 2단계: product_metrics.like_count → Product.like_count 비정규화 보정 + log.info("[MetricsReconcile] 2단계: Product.like_count 드리프트 보정 시작"); + int productCorrected = entityManager.createNativeQuery( + "UPDATE product p JOIN product_metrics pm ON p.id = pm.product_id " + + "SET p.like_count = pm.like_count " + + "WHERE p.like_count != pm.like_count AND p.deleted_at IS NULL" + ).executeUpdate(); + log.info("[MetricsReconcile] 2단계 완료 — 보정된 상품 수: {}", productCorrected); + + // 3단계: order_items 기준 → product_metrics.sales_count/sales_amount 보정 + log.info("[MetricsReconcile] 3단계: sales_count/sales_amount 대사 시작"); + int salesCorrected = entityManager.createNativeQuery( + "INSERT INTO product_metrics (product_id, sales_count, sales_amount, updated_at) " + + "SELECT oi.product_id, SUM(oi.quantity), SUM(oi.price * oi.quantity), NOW(6) " + + "FROM order_items oi JOIN orders o ON oi.order_id = o.id " + + "WHERE o.status != 'CANCELLED' AND o.deleted_at IS NULL " + + "GROUP BY oi.product_id " + + "ON DUPLICATE KEY UPDATE " + + "sales_count = VALUES(sales_count), sales_amount = VALUES(sales_amount), " + + "updated_at = NOW(6)" + ).executeUpdate(); + log.info("[MetricsReconcile] 3단계 완료 — 대사 행 수: {}", salesCorrected); + + return RepeatStatus.FINISHED; + } +} +``` + +### 10.2 3중 안전망 + +``` +[1차] best-effort 즉시 반영 + → AFTER_COMMIT에서 incrementLikeCount (동기) + → 실패해도 Like 자체는 저장됨 + +[2차] Kafka 집계 + → Debezium → catalog-events → MetricsConsumer + → product_metrics에 정확한 이벤트 기반 집계 + +[3차] 배치 대사 + → MetricsReconcileTasklet + → 원본 데이터(likes, order_items) 기준 전수 대사 + → product_metrics 보정 + Product.like_count 비정규화 보정 +``` + +--- + +## 11. 패키지 구조 + +### 11.1 commerce-api 패키지 트리 + +``` +apps/commerce-api/src/main/java/com/loopers/ +├── application/ +│ ├── coupon/ +│ │ ├── CouponFacade.java [변경] requestCouponIssue 추가 +│ │ └── CouponApplyResult.java +│ ├── like/ +│ │ └── LikeFacade.java [변경] incrementLikeCount 제거, outbox + event 발행 +│ ├── order/ +│ │ └── OrderFacade.java [변경] outbox + event 발행 추가 +│ ├── product/ +│ │ ├── ProductFacade.java [변경] 조회수 이벤트 발행 추가 +│ │ └── ProductCachePort.java +│ └── event/ [신규] +│ └── ProductViewKafkaPublisher.java [신규] 조회수 직접 Kafka 발행 +├── domain/ +│ ├── coupon/ +│ │ ├── Coupon.java [변경] maxIssuanceCount, issuedCount 추가 +│ │ ├── CouponIssue.java +│ │ ├── CouponIssueRequest.java [신규] +│ │ ├── CouponIssueRequestStatus.java [신규] +│ │ └── CouponIssueRequestRepository.java [신규] +│ ├── event/ [신규] +│ │ ├── LikeCreatedEvent.java [신규] +│ │ ├── LikeRemovedEvent.java [신규] +│ │ ├── OrderCreatedEvent.java [신규] +│ │ ├── OrderCancelledEvent.java [신규] +│ │ ├── ProductViewedEvent.java [신규] +│ │ ├── EventOutbox.java [신규] +│ │ └── EventOutboxRepository.java [신규] +│ └── ... +├── infrastructure/ +│ ├── coupon/ +│ │ └── CouponIssueRequestJpaRepository.java [신규] +│ ├── event/ [신규] +│ │ └── EventOutboxJpaRepository.java [신규] +│ ├── kafka/ [신규] +│ │ ├── KafkaTopicConfig.java [신규] NewTopic 빈 정의 +│ │ └── AsyncConfig.java [신규] @Async 스레드 풀 +│ └── ... +├── interfaces/ +│ ├── api/ +│ │ ├── coupon/ +│ │ │ └── CouponController.java [변경] 발급 요청/결과 확인 API 추가 +│ │ ├── like/ +│ │ │ └── LikeController.java [변경] 캐시 무효화 인라인 코드 제거 +│ │ └── ... +│ └── listener/ [신규] +│ ├── LikeCountEventListener.java [신규] AFTER_COMMIT → incrementLikeCount +│ └── CacheEvictionEventListener.java [신규] AFTER_COMMIT → 캐시 무효화 +└── ... +``` + +### 11.2 commerce-streamer 패키지 트리 + +``` +apps/commerce-streamer/src/main/java/com/loopers/ +├── CommerceStreamerApplication.java +├── domain/ [신규] +│ ├── metrics/ [신규] +│ │ ├── ProductMetrics.java [신규] streamer 자체 Entity +│ │ └── ProductMetricsRepository.java [신규] +│ └── event/ [신규] +│ ├── EventHandled.java [신규] streamer 자체 Entity +│ └── EventHandledRepository.java [신규] +└── interfaces/ + └── consumer/ + ├── DemoKafkaConsumer.java (기존 유지) + ├── MetricsConsumer.java [신규] catalog-events + order-events + └── CouponIssueConsumer.java [신규] coupon-issue-requests +``` + +**commerce-streamer에서 Native SQL 사용 근거:** +- CouponIssueConsumer가 coupon, coupon_issue, coupon_issue_request 테이블에 접근 +- 이들은 commerce-api의 도메인 Entity → streamer에서 Entity를 공유하면 모듈 결합도 증가 +- commerce-batch의 LikeCountSyncTasklet이 `entityManager.createNativeQuery()`로 접근하는 기존 패턴 준수 +- Native SQL로 최소한의 접근만 수행 (CAS UPDATE, INSERT, status UPDATE) + +### 11.3 commerce-batch 패키지 + +``` +apps/commerce-batch/src/main/java/com/loopers/batch/job/ +├── likecountsync/ [변경 → metricsreconcile로 리네임] +│ ├── LikeCountSyncJobConfig.java [변경] → MetricsReconcileJobConfig.java +│ └── step/ +│ └── LikeCountSyncTasklet.java [변경] → MetricsReconcileTasklet.java +├── outboxcleanup/ [신규] +│ ├── OutboxCleanupJobConfig.java [신규] +│ └── step/ +│ └── OutboxCleanupTasklet.java [신규] event_outbox 1시간 보존 DELETE +├── eventhandledcleanup/ [신규] +│ ├── EventHandledCleanupJobConfig.java [신규] +│ └── step/ +│ └── EventHandledCleanupTasklet.java [신규] event_handled 7일 보존 DELETE +├── paymentrecovery/ (기존 유지) +└── reconciliation/ (기존 유지) +``` + +--- + +## 12. 의존성 + Docker 변경 + +### 12.1 commerce-api: `modules:kafka` 추가 + +```kotlin +// apps/commerce-api/build.gradle.kts +dependencies { + // ... 기존 의존성 ... + implementation(project(":modules:kafka")) // 추가 — 조회수 직접 Kafka 발행용 +} +``` + +### 12.2 infra-compose.yml 변경 + +```yaml +# 1. mysql 서비스: binlog 활성화 command 추가 +mysql: + image: mysql:8.0 + command: + - --log-bin=mysql-bin + - --binlog-format=ROW + - --binlog-row-image=FULL + - --server-id=1 + # ... 기존 ports, environment, volumes 유지 + +# 2. kafka-connect 서비스 추가 (§4.3.2 참조) +kafka-connect: + image: debezium/connect:2.5 + container_name: kafka-connect + depends_on: + kafka: + condition: service_healthy + mysql: + condition: service_started + ports: + - "8083:8083" + environment: + GROUP_ID: 1 + BOOTSTRAP_SERVERS: kafka:9092 + CONFIG_STORAGE_TOPIC: _connect_configs + OFFSET_STORAGE_TOPIC: _connect_offsets + STATUS_STORAGE_TOPIC: _connect_status + CONFIG_STORAGE_REPLICATION_FACTOR: 1 + OFFSET_STORAGE_REPLICATION_FACTOR: 1 + STATUS_STORAGE_REPLICATION_FACTOR: 1 + KEY_CONVERTER: org.apache.kafka.connect.json.JsonConverter + VALUE_CONVERTER: org.apache.kafka.connect.json.JsonConverter + KEY_CONVERTER_SCHEMAS_ENABLE: "false" + VALUE_CONVERTER_SCHEMAS_ENABLE: "false" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8083/connectors"] + interval: 10s + timeout: 5s + retries: 10 +``` + +### 12.3 Debezium Connector 등록 스크립트 + +```bash +# docker/register-debezium-connector.sh +# infra-compose up 이후 실행 + +#!/bin/bash +set -e + +echo "Waiting for Kafka Connect to be ready..." +until curl -s http://localhost:8083/connectors > /dev/null 2>&1; do + sleep 2 +done + +echo "Registering Debezium MySQL Connector..." +curl -X POST http://localhost:8083/connectors \ + -H "Content-Type: application/json" \ + -d @- << 'EOF' +{ + "name": "loopers-outbox-connector", + "config": { + "connector.class": "io.debezium.connector.mysql.MySqlConnector", + "tasks.max": "1", + "database.hostname": "mysql", + "database.port": "3306", + "database.user": "root", + "database.password": "root", + "database.server.id": "184054", + "topic.prefix": "loopers", + "database.include.list": "loopers", + "table.include.list": "loopers.event_outbox", + "schema.history.internal.kafka.bootstrap.servers": "kafka:9092", + "schema.history.internal.kafka.topic": "_schema_history", + "transforms": "outbox", + "transforms.outbox.type": "io.debezium.transforms.outbox.EventRouter", + "transforms.outbox.table.field.event.id": "id", + "transforms.outbox.table.field.event.key": "aggregate_id", + "transforms.outbox.table.field.event.type": "event_type", + "transforms.outbox.table.field.event.payload": "payload", + "transforms.outbox.route.by.field": "aggregate_type", + "transforms.outbox.route.topic.replacement": "${routedByValue}-events", + "transforms.outbox.table.fields.additional.placement": "event_type:header:eventType", + "tombstones.on.delete": "false" + } +} +EOF + +echo "Connector registered successfully!" +curl -s http://localhost:8083/connectors/loopers-outbox-connector/status | python3 -m json.tool +``` + +--- + +## 13. 구현 계획 + +### Phase 1: ApplicationEvent 기반 분리 + +| # | 항목 | 대상 파일 | +|---|---|---| +| 1 | 이벤트 record 5개 생성 | domain/event/*.java | +| 2 | EventOutbox Entity + Repository | domain/event/, infrastructure/event/ | +| 3 | LikeFacade에서 incrementLikeCount 제거, Outbox INSERT + 이벤트 발행 | LikeFacade.java | +| 4 | LikeCountEventListener (AFTER_COMMIT, 동기) | interfaces/listener/ | +| 5 | CacheEvictionEventListener (AFTER_COMMIT, 동기) | interfaces/listener/ | +| 6 | LikeController에서 캐시 무효화 인라인 코드 제거 | LikeController.java | +| 7 | @Async 스레드 풀 설정 | infrastructure/kafka/AsyncConfig.java | + +### Phase 2: Kafka 인프라 + Debezium + +| # | 항목 | 대상 파일 | +|---|---|---| +| 8 | commerce-api build.gradle.kts에 `modules:kafka` 추가 | build.gradle.kts | +| 9 | kafka.yml Producer 보완 (acks, idempotence, linger.ms) | kafka.yml | +| 10 | kafka.yml Consumer value-deserializer 오타 수정 | kafka.yml | +| 11 | SINGLE_LISTENER ContainerFactory 추가 | KafkaConfig.java | +| 12 | DefaultErrorHandler + DLQ 설정 | KafkaConfig.java | +| 13 | NewTopic 빈 3개 선언 | KafkaTopicConfig.java | +| 14 | infra-compose.yml MySQL binlog command 추가 | infra-compose.yml | +| 15 | infra-compose.yml kafka-connect 서비스 추가 | infra-compose.yml | +| 16 | Debezium Connector 등록 스크립트 | docker/register-debezium-connector.sh | +| 17 | RedisConfig commandTimeout(500ms) 추가 | RedisConfig.java | + +### Phase 3: product_metrics Consumer + +| # | 항목 | 대상 파일 | +|---|---|---| +| 18 | event_outbox DDL 실행 | DDL | +| 19 | product_metrics DDL 실행 | DDL | +| 20 | event_handled DDL 실행 | DDL | +| 21 | ProductMetrics Entity (commerce-streamer) | domain/metrics/ | +| 22 | EventHandled Entity (commerce-streamer) | domain/event/ | +| 23 | MetricsConsumer (BATCH_LISTENER) | interfaces/consumer/ | +| 24 | ProductViewKafkaPublisher (@Async, 직접 Kafka) | application/event/ | +| 25 | ProductFacade 조회수 이벤트 발행 추가 | ProductFacade.java | +| 26 | OrderFacade Outbox INSERT + 이벤트 발행 추가 | OrderFacade.java | +| 27 | OrderFacade.cancelOrder Outbox INSERT 추가 | OrderFacade.java | + +### Phase 4: 선착순 쿠폰 + +| # | 항목 | 대상 파일 | +|---|---|---| +| 28 | Coupon 모델 확장 DDL (max_issuance_count, issued_count) | DDL + Coupon.java | +| 29 | coupon_issue UNIQUE 제약 추가 DDL | DDL | +| 30 | coupon_issue_request DDL + Entity | domain/coupon/ | +| 31 | CouponFacade.requestCouponIssue 추가 | CouponFacade.java | +| 32 | CouponController 발급 요청/결과 확인 API 추가 | CouponController.java | +| 33 | CouponIssueConsumer (SINGLE_LISTENER, Native SQL) | interfaces/consumer/ | + +### Phase 5: 배치 + 테스트 + +| # | 항목 | 대상 파일 | +|---|---|---| +| 34 | MetricsReconcileTasklet (LikeCountSyncTasklet 진화) | commerce-batch | +| 35 | OutboxCleanupTasklet (1시간 보존 DELETE) | commerce-batch | +| 36 | EventHandledCleanupTasklet (7일 보존 DELETE) | commerce-batch | +| 37 | ApplicationEvent 분리 단위 테스트 | commerce-api/test | +| 38 | Kafka Consumer 통합 테스트 (EmbeddedKafka) | commerce-streamer/test | +| 39 | 선착순 쿠폰 동시성 테스트 | commerce-streamer/test | +| 40 | Debezium E2E 테스트 (Testcontainers) | 통합 테스트 | + +--- + +## 14. 전체 DDL 요약 + +### 신규 테이블 (4개) + +| 테이블 | 용도 | 위치 | +|---|---|---| +| `event_outbox` | Debezium CDC용 Outbox | commerce-api TX 내 INSERT | +| `product_metrics` | 상품 집계 (좋아요, 조회수, 판매량) | commerce-streamer UPSERT | +| `event_handled` | 멱등 처리 (중복 이벤트 방지) | commerce-streamer INSERT | +| `coupon_issue_request` | 선착순 쿠폰 발급 요청 추적 | commerce-api INSERT, commerce-streamer UPDATE | + +### 변경 테이블 (2개) + +| 테이블 | 변경 내용 | +|---|---| +| `coupon` | `max_issuance_count INT NULL`, `issued_count INT DEFAULT 0` 컬럼 추가 | +| `coupon_issue` | `UNIQUE INDEX uk_coupon_issue_coupon_member (coupon_id, member_id)` 추가 | + +### 삭제 테이블 (1개) + +| 테이블 | 사유 | +|---|---| +| `product_like_stats` | product_metrics로 흡수 (마이그레이션 후 DROP) | + +--- + +## 핵심 설계 결정 요약 + +| 결정 | 내용 | 근거 (09 참조) | +|---|---|---| +| Debezium CDC | Poller 대신 binlog 기반 Outbox 발행 | §8 — 학습 가치 + 중복 발행 원천 해결 | +| event_outbox에 status 없음 | Debezium이 binlog에서 읽으므로 PENDING/PROCESSED 불필요 | §8.5 | +| incrementLikeCount 동기 | AFTER_COMMIT에서 best-effort, @Async 아님 | §2.7, §13 | +| @Async core=2, max=4 | DB/Redis 미사용 초경량 작업이므로 작은 풀 | §13 | +| SINGLE_LISTENER for 쿠폰 | 건별 CAS UPDATE + 개별 에러 핸들링 | §11 | +| commerce-streamer Native SQL | Coupon/CouponIssue 접근 시 Entity 미사용 | commerce-batch 기존 패턴 | +| Product.like_count 유지 | 정렬 인덱스 유지, 제거 시 성능 하락 | §3.6 | +| 조회수 Outbox 미경유 | 읽기 전용 → TX 없음 → 직접 Kafka 발행 | §3.10 | diff --git a/docs/design/08-queue-system.md b/docs/design/08-queue-system.md new file mode 100644 index 0000000000..f02a59ca87 --- /dev/null +++ b/docs/design/08-queue-system.md @@ -0,0 +1,1970 @@ +# 08. Redis 기반 주문 대기열 시스템 + +## 1. 목적 + +블랙 프라이데이 트래픽 폭증 시 시스템 보호 + 유저 공정 대기 경험 제공. +주문 API(`POST /api/v1/orders`) 앞단에 대기열 관문을 추가하여, DB 커넥션 풀이 고갈되지 않도록 입장 속도를 제어한다. + +### 1.1 왜 대기열인가 — Rate Limiting(429)과의 비교 + +| 관점 | Rate Limiting (429) | 대기열 | +|------|-------------------|--------| +| 초과 트래픽 처리 | 거부 → 유저 재시도 → 트래픽 증폭 | 버퍼링 → 순서대로 입장 → 재시도 없음 | +| 유저 경험 | "잠시 후 다시 시도해주세요" (불공정) | "현재 42번째, 약 1초 대기" (공정) | +| 시스템 보호 | O (보호됨) | O (보호됨) | + +**결정**: 블프 시나리오에서는 유저가 "구매 의지"가 강해 429를 받으면 무한 재시도한다. +대기열로 버퍼링하면 재시도 폭풍을 원천 차단하면서 공정한 순서를 보장할 수 있다. + +--- + +## 2. 병목 기반 안전 처리량 역산 + +대기열 설계의 출발점은 "초당 몇 명을 입장시킬 것인가?"다. +이 숫자를 임의로 정하면 시스템이 죽거나(너무 많이), 유저가 불필요하게 기다린다(너무 적게). + +우리의 접근법은 **병목 자원에서 안전 처리량을 역산**하는 것이다: + +``` +1. 병목 식별 → DB 커넥션 풀 (40개) +2. 안전 처리량 역산 → Little's Law: TPS = pool_size / latency × 마진 +3. 입장 속도로 변환 → 배치 크기 = TPS / 스케줄러 주기 +4. 실측으로 검증 → 부하 테스트 → 보정 → 재실측 +``` + +배치 크기가 곧 부하를 결정한다. +배치가 크면 입장이 빨라지지만 DB 동시 부하가 올라가고, +배치가 작으면 DB가 여유롭지만 유저 대기 시간이 늘어난다. +**시스템이 버틸 수 있는 최대 배치 크기**를 찾는 것이 이 산정의 목적이다. + +### 2.1 핵심 연산식 — Little's Law + +안전 처리량 역산의 도구는 **Little's Law**다. + +``` +Little's Law: L = λ × W + +L = 동시 점유 커넥션 수 +λ = 초당 입장 수 (TPS) +W = 요청 1건의 커넥션 점유 시간 (latency) +``` + +이를 변환하면: + +``` +동시 커넥션 = TPS × latency + +커넥션 1개가 요청 1건 처리에 latency만큼 점유됨 +→ 커넥션 1개의 처리량 = 1 / latency (건/초) +→ 전체 TPS = pool_size / latency +``` + +**예시**: 커넥션 40개, 요청당 200ms 점유 시 + +``` +커넥션 1개: 1초에 1/0.2 = 5건 처리 가능 +커넥션 40개: 40 × 5 = 200건/초 + +역검증: 200 TPS × 0.2s = 40 커넥션 동시 사용 (풀 100%) +``` + +### 2.2 실측 기반 재계산 + +#### 초기 산정 (구현 전, 추정치) + +``` +처리 시간 = ~200ms (추정) +TPS = 40 / 0.2 = 200, 안전 마진 70% = 140 TPS → 배치 14명 +``` + +#### 부하 테스트 실측 (2026-04-02) + +| 백분위 | 레이턴시 | TPS = 40 / latency | × 70% | +|--------|---------|-------------------|-------| +| avg | 139ms | 288 | 201 | +| p50 | 112ms | 357 | 250 | +| p90 | 246ms | 163 | 114 | +| p95 | 358ms | 112 | 78 | +| p99 | 358ms | 112 | 78 | + +#### 어떤 레이턴시를 기준으로 해야 하는가? + +| 기준 | 의미 | 위험 | +|------|------|------| +| avg(139ms) | "보통은 괜찮다" | 피크 시 p99 요청이 몰리면 풀 고갈 | +| p90(246ms) | "90%는 괜찮다" | 10%는 여전히 위험 | +| p99(358ms) | "99%도 괜찮다" | 거의 안 터짐 | + +**결정: p99 기준**. 시스템 보호가 목적이므로 최악 케이스에도 풀이 넘치면 안 된다. +블프 피크처럼 비관적 락 경합이 심해지면 대부분의 요청이 p99에 가까워질 수 있다. + +#### 초기 설정(14명)의 문제 + +``` +14명 × 10회 = 140 TPS 입장 +140 × 0.358s(p99) = 50.1 커넥션 → 풀 40 초과! ← 시스템 장애 +140 × 0.246s(p90) = 34.4 커넥션 → 풀의 86% (다른 API 여유 없음) +``` + +p99 상황에서 풀 40개를 초과하는 50개 커넥션이 필요하게 되어, +대기열을 넣었는데도 풀 고갈이 발생할 수 있다. + +### 2.3 보정된 스케줄러 파라미터 + +``` +제약: 동시 커넥션 <= 28개 (풀 40의 70%, 나머지 30%는 다른 API) +연산: 28 = TPS × 0.358 + TPS = 28 / 0.358 = 78 + +스케줄러 주기 = 100ms (10회/초) +배치 크기 = 78 / 10 = 7.8 → 반올림 8명/배치 +``` + +검증: + +``` +8명 × 10회 = 80 TPS 입장 +최악(p99): 80 × 0.358 = 28.6 커넥션 → 풀 40의 72% ✓ +일반(p90): 80 × 0.246 = 19.7 커넥션 → 풀 40의 49% ✓ +평상시(avg): 80 × 0.139 = 11.1 커넥션 → 풀 40의 28% ✓ +``` + +### 2.4 유저 체감 영향 + +입장 속도 감소에 따른 대기 시간 변화: + +``` +대기열 1000명: + 변경 전(140 TPS): 1000 / 140 = 7.1초 + 변경 후(80 TPS): 1000 / 80 = 12.5초 (+5.4초) + +대기열 5000명: + 변경 전: 35.7초 + 변경 후: 62.5초 (+26.8초) +``` + +대기 시간이 1.75배 늘지만, 시스템이 죽어서 전원 주문 불가보다 낫다. + +### 2.5 70% 마진의 검증 방법 + +30%를 다른 API에 남기는 근거는 아직 추정이다. +실측하려면 블프 피크 트래픽에서 HikariCP 메트릭을 모니터링해야 한다. + +``` +hikaricp_connections_active{pool="mysql-main-pool"} — 현재 사용 중 커넥션 +hikaricp_connections_pending{pool="mysql-main-pool"} — 대기 중 요청 (0 이상이면 위험) +hikaricp_connections_idle{pool="mysql-main-pool"} — 유휴 커넥션 +hikaricp_connections{pool="mysql-main-pool"} — 전체 커넥션 +``` + +PromQL 쿼리: + +```promql +# 피크 시 최대 활성 커넥션 (5분 윈도우) +max_over_time(hikaricp_connections_active{pool="mysql-main-pool"}[5m]) + +# 커넥션 대기 발생 여부 (이 값이 0 이상이면 풀 고갈 임박) +hikaricp_connections_pending{pool="mysql-main-pool"} > 0 + +# 풀 사용률 (%) +hikaricp_connections_active / hikaricp_connections * 100 +``` + +이 메트릭은 `localhost:8081/actuator/prometheus`에서 이미 자동 수집 중이며, +Prometheus(localhost:9090) + Grafana(localhost:3000)에서 대시보드로 확인 가능. + +active가 28 이하로 유지되면 70% 마진이 적절한 것이고, +active가 20 이하면 마진을 줄여서 배치 크기를 올릴 여지가 있다. + +### 2.6 대기열 한계 & 타임아웃 + +대기열에 무한히 쌓이면 두 가지 문제가 생긴다: +1. Redis 메모리 증가 (실제로는 미미하지만 원칙의 문제) +2. 30분 이상 대기하는 유저 발생 → 이탈 확실, 좀비 엔트리 누적 + +#### 최대 대기 시간 결정 (primary) + +최대 대기 시간이 1차 결정 변수이고, 대기열 한계는 이로부터 유도된다. + +``` +입장 속도 = 80 TPS + +대기열 1,000명 → 1,000 / 80 = 12.5초 +대기열 10,000명 → 10,000 / 80 = 125초 (2분) +대기열 48,000명 → 48,000 / 80 = 600초 (10분) +``` + +10분 초과 대기는 이커머스에서 사실상 이탈이다. +블프 티켓팅 수준의 이벤트에서도 10분이 유저 인내의 한계점. + +**결정: 최대 대기 시간 = 600초 (10분)** + +#### 대기열 한계 유도 (derived) + +``` +max_queue = admission_rate × max_wait_time + = 80 × 600 + = 48,000명 + +Redis 메모리: 48,000 × ~90 bytes = ~4.3MB (무시 가능) +``` + +48,001번째 유저는 10분 내에 처리할 수 없으므로, 진입 자체를 거부하는 것이 정직하다. + +#### 타임아웃 정리 주기 + +``` +정리 주기 = 10초 + → 10초마다 ZREMRANGEBYSCORE 실행 + → 최악의 경우 유저가 10분 10초 대기 (600 + 10 = 610초) + → 오차 1.7%, 유저 체감 무의미 +``` + +#### 유저 경험 시나리오 + +| 상황 | 유저가 보는 것 | +|------|-------------| +| 대기열 여유 | "현재 42번째, 약 1초 대기" (QUEUED) | +| 대기열 가득 | "현재 대기열이 가득 찼습니다. 잠시 후 다시 시도해주세요." (QUEUE_FULL) | +| 10분 대기 후 타임아웃 | 다음 position 폴링 시 "NOT_IN_QUEUE" → 재진입 유도 | +| 타임아웃 후 재진입 | 새 순번으로 대기열 진입 (ZADD NX, 새 score) | + +### 2.7 Queuing Theory 분석 — 이용률과 대기열 폭발 + +#### 왜 Queuing Theory인가 + +Little's Law(`L = λ × W`)는 "평균적으로 얼마나 많은 리소스가 점유되는가"를 알려준다. +Queuing Theory는 한 단계 더 나아간다: **이용률이 올라갈수록 대기열이 어떻게 변하는가**. + +핵심 공식 (M/M/1 단순화): + +``` +ρ = λ / μ (이용률 = 도착률 / 처리율) +Lq = ρ² / (1 - ρ) (평균 대기 요청 수) +Wq = Lq / λ (평균 대기 시간) +``` + +이용률(ρ)이 1에 접근하면 Lq가 급격히 폭발한다: + +``` +ρ = 50% → Lq = 0.5 (여유) +ρ = 70% → Lq = 1.6 (양호) +ρ = 80% → Lq = 3.2 (주의) +ρ = 90% → Lq = 8.1 (위험) +ρ = 95% → Lq = 18.1 (거의 마비) +``` + +#### 우리 시스템의 두 리소스 풀 + +DB 커넥션 풀(40)과 Tomcat 스레드 풀(200)은 **동시에 점유되는 리소스**다. +주문 요청 1건이 들어오면: + +``` +[Tomcat 스레드 1개 점유] → [DB 커넥션 1개 획득] → [트랜잭션 처리] → [둘 다 반환] + ↑ + 커넥션 못 얻으면 스레드가 block (최대 3초, connection-timeout) +``` + +상품 조회 요청도 마찬가지로 스레드 + 커넥션을 동시에 사용한다. +대기열 position 폴링은 Redis만 사용하므로 커넥션 불필요. + +``` +현재 설정: + Tomcat 스레드 = 200 + DB 커넥션 = 40 + 비율 = 200 / 40 = 5:1 +``` + +**DB 커넥션 풀이 먼저 병목이 된다.** 커넥션 40개가 모두 점유되면, +나머지 160개 스레드가 커넥션을 기다리며 block → 결국 스레드 풀도 고갈. +이것이 **커넥션 풀 고갈 → 스레드 풀 고갈 캐스케이드**다. + +대기열의 역할은 이 캐스케이드를 원천 차단하는 것이다: +입장 속도를 80 TPS로 제한 → 커넥션 동시 점유를 28개 이하로 유지 → 스레드 block 없음. + +#### 현재 상태 분석 (배치 8, 80 TPS) + +실측 데이터 기반 DB 커넥션 풀 이용률: + +``` +DB 커넥션 풀 (M/M/c, c=40): + λ = 80 TPS (입장 속도) + μ = 1 / 0.107s = 9.35 req/sec/connection (p99 기준) + μ_total = 40 × 9.35 = 374 TPS (전체 처리 용량) + + ρ = λ / μ_total = 80 / 374 = 0.214 (21.4%) + Lq = ρ² / (1 - ρ) = 0.046 / 0.786 = 0.058 + + → 평균 대기 요청 수 0.058개 — 사실상 대기 없음 + → 실측 확인: HikariCP max active = 8/40, pending = 항상 0 ✓ +``` + +``` +Tomcat 스레드 풀: + 주문 처리: 80 TPS × 0.107s = 8.6 스레드 (DB 사용) + 상품 조회: ~100 TPS × ~0.02s = ~2 스레드 (DB 사용, 추정) + 대기열 폴링: Redis만 사용 → DB 커넥션 불필요 + + 총 DB 사용 스레드 ≈ 11개 / 200개 = 5.5% + 나머지 189개 스레드는 비DB 요청 + 여유 + + → 스레드 풀은 병목이 아님. 200개 대비 11개만 DB 점유. +``` + +#### 배치 크기별 예측 + +배치 크기를 올리면 이용률이 어떻게 변하는가? + +**핵심 주의**: W(latency)는 상수가 아니라 **동시 부하의 함수**다 (7절 인사이트). +저부하에서는 87ms, 고부하에서는 358ms까지 올라간다. +따라서 두 가지 시나리오로 계산한다. + +**낙관적 시나리오** (부하 올려도 latency가 안 올라간다고 가정): + +``` +μ_total = 40 / 0.107 = 374 TPS + +배치 8 (80 TPS): ρ = 0.214 → Lq = 0.06 (안전 ✓) +배치 10 (100 TPS): ρ = 0.267 → Lq = 0.10 (안전 ✓) +배치 12 (120 TPS): ρ = 0.321 → Lq = 0.15 (안전 ✓) +배치 16 (160 TPS): ρ = 0.428 → Lq = 0.32 (양호) +배치 20 (200 TPS): ρ = 0.535 → Lq = 0.62 (양호) +배치 28 (280 TPS): ρ = 0.749 → Lq = 2.23 (주의!) +배치 35 (350 TPS): ρ = 0.936 → Lq = 13.7 (위험!) +``` + +**현실적 시나리오** (실측 데이터 기반, latency가 부하에 따라 증가): + +실측 2개 데이터 포인트: +- 80 TPS → p99 = 107ms +- 140 TPS → p99 = 358ms + +``` +배치 8 (80 TPS): W=107ms, ρ = 80×0.107/40 = 0.214 → Lq = 0.06 (안전 ✓) +배치 14 (140 TPS): W=358ms, ρ = 140×0.358/40 = 1.253 → Lq = ∞ (시스템 붕괴!) +``` + +ρ > 1 → 도착률이 처리 용량을 초과 → 대기열이 무한히 증가 → 시스템 붕괴. +이것이 7절에서 관찰한 **양의 피드백 루프**의 수학적 설명이다: + +``` +부하 ↑ → W ↑ → μ_total ↓ → ρ ↑ → Lq ↑ → 커넥션 점유 ↑ → 부하 ↑ → ... (붕괴) +``` + +#### 안전 영역의 수학적 정의 + +"70% 마진"이 직감이 아니라 Queuing Theory에서 나온다: + +``` +ρ ≤ 0.7이면: + Lq = 0.7² / (1-0.7) = 0.49 / 0.3 = 1.63 + → 평균 1.6개 요청만 대기 → 안정적 + +ρ ≥ 0.8이면: + Lq = 0.8² / (1-0.8) = 0.64 / 0.2 = 3.2 + → 3.2개 대기 → 락 경합 시작 → W 증가 → ρ 추가 증가 → 위험 + +ρ = 0.214 (현재): + Lq = 0.058 → 대기 거의 없음 → 가장 안전한 영역 +``` + +**결론**: 현재 ρ = 0.214로 안전 영역에 충분한 여유가 있다. +배치 크기 증가 시 ρ ≤ 0.7 (Lq ≤ 1.6)을 유지해야 하며, +**반드시 실측으로 검증해야 한다** — latency가 상수가 아니기 때문이다. + +#### 배치 크기 증가 시 검증 체크리스트 + +향후 배치 크기를 올릴 때, 다음 순서로 검증한다: + +``` +1. 이론 계산: 낙관적 시나리오에서 ρ ≤ 0.7 확인 +2. 부하 테스트 실행: 실제 latency 측정 +3. 실측 ρ 계산: ρ = λ × W_measured / pool_size +4. HikariCP pending 모니터링: pending > 0이면 중단 +5. Tomcat 스레드 모니터링: DB 대기 스레드 증가 여부 확인 +``` + +#### Tomcat 스레드 풀 고갈 시나리오 — 비주문 트래픽 + +대기열은 주문 트래픽만 제어한다. 상품 조회 같은 비주문 트래픽은 제어하지 않는다. + +``` +최악 시나리오: + 상품 조회 폭증 → DB 커넥션 40개 중 30개 점유 + → 주문용 커넥션 10개만 남음 + → 대기열 입장 80 TPS인데 처리 용량 부족 + → 주문 스레드가 커넥션 대기 block (connection-timeout: 3초) + → 3초 × 80 = 240 스레드 block → Tomcat 200 스레드 초과 → 전체 서비스 마비 +``` + +이 시나리오는 향후 과제의 "주문 외 다른 API 트래픽 보호 검토"와 연결된다. +대응 방안: DB 커넥션 풀 분리 또는 비주문 API에도 Rate Limiting 적용. + +### 2.8 토큰 TTL + +#### 토큰 체류 모델 + +``` +[대기열] --80 TPS--> [토큰 보유자 (체크아웃 페이지)] --퇴장--> [완료/만료] + +시간당: + 입장: 80 TPS × 3,600 = 288,000명 + 처리: 80 TPS × 3,600 = 288,000건 (처리 용량 = 입장 속도, 설계상 동일) + 퇴장: 주문 완료(토큰 소비) + 이탈(토큰 만료) = 288,000명 (정상 상태) +``` + +#### 유저 체류 시간 분석 + +``` +체크아웃 페이지에서의 행동: + 배송지 확인/선택: 30초 ~ 3분 (기존 배송지 vs 신규 입력) + 결제 수단 선택: 15초 ~ 2분 (기존 카드 vs 신규 등록) + 쿠폰/할인 적용: 15초 ~ 1분 + 최종 확인 + 결제 클릭: 10초 ~ 30초 + 잠깐의 고민/방해: 0초 ~ 3분 + + p50: ~3분, p90: ~7분, p99: ~10분 +``` + +#### TTL 결정 + +``` +유저 행동 분포: + 80% → 3분 내 완료 (토큰 소비로 퇴장) + 15% → 3~7분 내 완료 (토큰 소비로 퇴장) + 5% → 이탈 (TTL 만료로 퇴장) + +가중 평균 체류 시간 = 0.80 × 180 + 0.15 × 300 + 0.05 × TTL + = 189 + 0.05 × TTL + +동시 토큰 보유자 = 80 × (189 + 0.05 × TTL) + + TTL 300초: 80 × 204 = 16,320명, Redis 1.5MB + TTL 600초: 80 × 219 = 17,520명, Redis 1.6MB + TTL 900초: 80 × 234 = 18,720명, Redis 1.7MB + +TTL을 3배 늘려도 동시 보유자는 15% 증가. +이유: 95%의 유저는 TTL 전에 완료하므로, TTL 증가는 5% 이탈자에만 영향. +``` + +**결정: TTL = 900초 (15분), 설정값으로 외부화** + +``` +근거: + - 체크아웃 p99 ~10분 + 50% 여유 = 15분 + - 동시 토큰 18,720명, Redis 1.7MB (무시 가능) + - 대기열(48,000) + 토큰(18,720) = 총 66,720 Redis 키, ~6MB + - 블프 시 queue.token.ttl-seconds=1800 으로 설정 변경만으로 대응 +``` + +--- + +## 3. Redis Key 설계 + +``` +# 대기열 (Sorted Set) +Key: queue:waiting:order +Member: {memberId} (Long → String) +Score: System.currentTimeMillis() + +# 입장 토큰 (String + TTL) +Key: queue:token:{memberId} +Value: "1" +TTL: 설정값 (기본 900초, queue.token.ttl-seconds) +``` + +--- + +## 4. 구현 상세 + +### 4.1 토큰 검증 방식 — AOP @Aspect + +`@RequireEntryToken` 어노테이션 + `EntryTokenInterceptor` AOP `@Around`. +기존 `PaymentRateLimiterInterceptor` 패턴을 따름. + +**이 방식을 선택한 근거**: +- Controller 메서드 실행 시점에 `@AuthMember`가 이미 resolve → `Member` 객체 접근 가능 +- `joinPoint.getArgs()`에서 `Member` 타입을 찾아 `memberId` 추출 +- 성공 시에만 토큰 소비 (예외 발생 시 토큰 유지 → 재시도 가능) + +**고려했으나 선택하지 않은 대안**: +- HandlerInterceptor: `@AuthMember` resolve 전에 실행되어 memberId 추출 불가 +- Filter: Spring Security 컨텍스트 의존 없이 memberId를 알 수 없음 + +### 4.2 파일 구조 + +| 파일 | 레이어 | 역할 | +|------|--------|------| +| `support/auth/RequireEntryToken.java` | Support | METHOD 어노테이션 | +| `infrastructure/redis/WaitingQueueRedisRepository.java` | Infrastructure | Sorted Set 대기열 (ZADD NX, ZRANK, ZCARD, ZPOPMIN, ZREM) | +| `infrastructure/redis/EntryTokenRedisRepository.java` | Infrastructure | 입장 토큰 (SET EX {ttl}, EXISTS, DEL, TTL) — ttl은 설정값 | +| `interfaces/api/queue/QueueDto.java` | Interfaces | EnterResponse, PositionResponse | +| `interfaces/api/queue/QueueController.java` | Interfaces | POST /enter, GET /position | +| `infrastructure/scheduler/QueueAdmissionScheduler.java` | Infrastructure | 100ms 배치 입장 | +| `infrastructure/queue/EntryTokenInterceptor.java` | Infrastructure | AOP 토큰 검증 | + +변경: `OrderController.java` — `@RequireEntryToken` 추가 + +### 4.3 대기열 한계 & 타임아웃 구현 + +#### QueueController — 대기열 한계 체크 + +`enter()` 메서드에서 토큰 체크 후, ZADD 전에 `size() >= 48,000` 체크. +200 + `QUEUE_FULL` 상태를 반환하는 이유: +- 기존 API가 상태 기반 응답 (QUEUED, ADMITTED) → 일관성 유지 +- 429를 던지면 클라이언트 retry 미들웨어가 자동 재시도할 위험 +- 시스템이 정상 동작 중이고, 단지 용량이 찬 것이므로 에러가 아닌 상태 정보 + +#### WaitingQueueRedisRepository — removeExpiredEntries() + +`ZREMRANGEBYSCORE queue:waiting:order -inf {cutoffTimeMillis}` 실행. +score = 진입 시각(millis)이므로, cutoff 이전에 진입한 엔트리를 일괄 제거. + +#### QueueAdmissionScheduler — 타임아웃 정리 스케줄러 + +10초 주기(`@Scheduled(fixedRate = 10_000)`)로 600초 이상 대기한 엔트리를 제거. +제거 시 로그 출력, 제거 대상 없으면 무시. + +### 4.4 API 동작 + +#### POST /api/v1/queue/enter +- 토큰 존재 → `ADMITTED` (tokenRemainingSeconds 포함) +- 토큰 없음 → ZADD NX → `QUEUED` (position, estimatedWaitSeconds 포함) + +#### GET /api/v1/queue/position +- 토큰 존재 → `ADMITTED` +- 큐에 존재 → `WAITING` (position, totalQueueSize, estimatedWaitSeconds) +- 둘 다 없음 → `NOT_IN_QUEUE` + +### 4.5 SSE 실시간 순번 Push + +#### Delta 기반 브로드캐스트 + +매 입장 사이클마다 개별 ZRANK를 호출하지 않는다. 스케줄러가 N명을 입장시키면, +연결된 모든 SSE 클라이언트에게 `admittedCount`를 보낸다. 클라이언트가 자기 position을 로컬에서 차감: + +``` +clientPosition -= admittedCount +``` + +Redis 추가 비용: O(0) (기존 ZPOPMIN만 사용). SSE 전송 비용: O(K) (K = 연결된 클라이언트 수). + +#### SSE 이벤트 종류 + +| 이벤트 | 시점 | 데이터 | +|--------|------|--------| +| `position` | 연결 직후 | `{ position: 42 }` (초기 순번) | +| `delta` | 매 입장 사이클 | `{ admittedCount: 8 }` | +| `admitted` | 해당 유저 입장 시 | `{}` | +| heartbeat (comment) | 30초마다 | 빈 코멘트 | + +#### 커넥션 관리 + +- `max-connections: 8192` (Tomcat NIO) — SSE는 스레드가 아니라 NIO 채널 사용 +- SSE 최대 연결 = 5,000 (나머지 3,192는 REST API 용) +- 초과 시 `use_polling` 이벤트와 함께 즉시 닫기 → 동적 Polling으로 fallback +- Emitter timeout = 600초 (MAX_WAIT_SECONDS) +- 중복 memberId 연결 시 기존 emitter 교체 (재연결 시나리오) + +#### Polling 대비 이점 + +``` +48,000명 대기 기준: + Polling: 9,800 req/sec (동적 Polling 적용 시) × ZRANK 1회/req + SSE: 0 req/sec (연결 유지, delta만 push) + 5,000 × event 1회/사이클 + + → SSE 사용 시 Redis 연산 대부분 제거 + → 단, SSE 미연결 클라이언트(5,001번째부터)는 동적 Polling으로 fallback +``` + +#### 입장된 유저 알림 + +Lua 스크립트가 반환하는 `admittedMemberIds`를 확인. +해당 유저에게 SSE `admitted` 이벤트 전송 후 emitter 닫기. + +**파일**: `QueueSseEmitterRegistry.java`, `QueueController.java` (GET /stream), `QueueAdmissionScheduler.java` (onAdmission 호출) + +### 4.6 동적 Polling 주기 + +서버가 응답에 `suggestedPollIntervalMs`를 포함하여 클라이언트가 Polling 주기를 조절한다. + +``` +position 1~100: 1000ms (곧 입장, 빠른 반응 필요) +position 101~1000: 3000ms (중간) +position 1001+: 5000ms (입장까지 12초 이상) +``` + +#### Redis 부하 감소 효과 + +``` +모든 유저가 1초 폴링: + 48,000 × 1 req/sec = 48,000 req/sec + → 24,000 req/sec (동적 Polling 미적용, 평균 2초로 가정) + +동적 Polling 적용: + 100명 × 1 req/sec = 100 + 900명 × 0.33 req/sec = 300 + 47,000명 × 0.2 req/sec = 9,400 + 합계: 9,800 req/sec (59% 감소) +``` + +**변경 파일**: `QueueDto.java` (suggestedPollIntervalMs 필드), `QueueController.java` (calculatePollInterval 헬퍼) + +### 4.7 Graceful Degradation (Redis 장애 시 Fallback) + +#### 문제 + +Redis 장애 시 `EntryTokenInterceptor.exists()`가 예외를 던져 주문이 전면 차단된다. +대기열 없이 서비스를 보호하기 위한 의도적 설계가 아니라 단순한 장애 전파다. + +#### 해결: 로컬 Rate Limiter fallback + +``` +정상 모드: + exists(memberId) → 토큰 있음 → proceed → consume + +Redis 장애 모드: + exists() 예외 → SlidingWindowRateLimiter.tryAcquire() + → 허용: proceed (토큰 검증/소비 없이) + → 거부: TOO_MANY_REQUESTS (429) +``` + +Rate Limit = 80 req/sec (정상 모드 입장 속도와 동일). `queue.fallback.rate-limit` 프로퍼티로 외부화. + +#### 예외 분기 + +```java +try { + if (!exists(memberId)) throw CoreException(FORBIDDEN); // 비즈니스 로직 +} catch (CoreException e) { + throw e; // 비즈니스 예외는 그대로 전파 +} catch (Exception e) { + return handleRedisFallback(...); // Redis 인프라 예외 → fallback +} +``` + +#### consume 실패 처리 + +`proceed()` 성공 후 `consume()` 실패 시: 경고 로그만 남기고 계속 (토큰은 TTL로 자동 만료). +에러 로그는 `QueueAdmissionScheduler`와 동일한 `AtomicLong + compareAndSet` 10초 쓰로틀링. + +#### 복구 경로 + +Redis가 복구되면 자동으로 정상 모드로 전환된다. 별도의 복구 로직이 필요 없다. +각 요청마다 `exists()` 호출을 시도하므로, Redis가 살아나는 순간부터 정상 토큰 검증이 재개된다. + +**변경 파일**: `QueueFallbackRateLimiterConfig.java` (신규), `EntryTokenInterceptor.java` (fallback 로직), `application.yml` (rate-limit 설정) + +### 4.8 모니터링 (커스텀 메트릭 + Grafana) + +#### 커스텀 메트릭 + +| 메트릭 | 타입 | 위치 | 설명 | +|--------|------|------|------| +| `queue.admission.count` | Counter | QueueAdmissionScheduler | 입장 처리된 유저 수 | +| `queue.admission.errors` | Counter | QueueAdmissionScheduler | Redis 장애 횟수 | +| `queue.cleanup.removed` | Counter | QueueAdmissionScheduler | 타임아웃 정리된 유저 수 | +| `queue.waiting.size` | Gauge | QueueAdmissionScheduler | 현재 대기열 크기 | +| `queue.enter.status` | Counter (tag: status) | QueueController | enter 결과별 카운트 | +| `queue.token.fallback` | Counter | EntryTokenInterceptor | Redis 장애 시 fallback 발동 횟수 | +| `queue.sse.connections` | Gauge | QueueSseEmitterRegistry | 현재 SSE 연결 수 | + +#### Grafana 대시보드 패널 + +1. **System Utilization (ρ)** — `hikaricp_connections_active / hikaricp_connections_max`, 임계치 0.7 +2. **DB Connection Pool** — active/idle/pending 시계열 +3. **Order API p99 Latency** — `histogram_quantile(0.99, rate(http_server_requests_seconds_bucket{uri="/api/v1/orders"}[1m]))` +4. **Queue Depth** — `queue_waiting_size` +5. **Admission Rate** — `rate(queue_admission_count_total[1m])` +6. **Queue Enter 결과 분포** — QUEUED/ADMITTED/QUEUE_FULL 비율 +7. **Safe TPS (Little's Law)** — `28 / histogram_quantile(0.99, ...)` ← 배치 크기 조정 근거 +8. **SSE Connections** — `queue_sse_connections` +9. **Redis Errors & Fallback** — admission errors + token fallback 비율 + +패널 7이 핵심: 운영 환경에서 p99가 변하면 Safe TPS가 실시간으로 재계산되어, 배치 크기 조정 시기를 알 수 있다. + +**프로비저닝**: `docker/grafana/provisioning/dashboards/dashboard.yml` + `queue-system.json` + +--- + +## 5. 레이스 컨디션 대응 + +| 시나리오 | 대응 | +|---------|------| +| ZPOPMIN 후 토큰 발급 실패 | 유저가 재진입 (ZADD NX 멱등) | +| ZADD 후 ZRANK 전에 스케줄러가 POP | 토큰 존재 체크로 fallback → ADMITTED 반환 | +| Replica 지연으로 토큰 미감지 | Polling 주기(1~3초) 내 자연 해소 | +| proceed() 예외 시 토큰 소비 | consume은 proceed 성공 후에만 실행 | +| size() 체크와 add() 사이에 다른 유저 진입 | 48,001명이 될 수 있음 — 소프트 리밋 허용 (±수명은 무의미) | +| 타임아웃 정리와 ZPOPMIN 동시 실행 | 같은 유저를 두 곳에서 제거 시도 — ZPOPMIN은 원자적, 이미 제거된 엔트리는 무시됨 | +| 타임아웃 직전에 ZPOPMIN으로 입장 | 유저 입장 성공, 정리 대상 아님 — ZPOPMIN이 먼저 꺼내면 ZREMRANGEBYSCORE 대상에서 제외 | + +--- + +## 6. 테스트 + +### 6.1 단위 테스트 (Mockito) + +| 테스트 클래스 | 검증 항목 | 결과 | +|-------------|----------|------| +| `QueueAdmissionSchedulerTest` (3건) | 배치 POP → 토큰 발급, 빈 큐 처리, 배치 크기 8 | PASS | +| `EntryTokenInterceptorTest` (4건) | 토큰 있음 → 통과+소비, 없음 → FORBIDDEN, 예외 시 미소비, Member 없음 → INTERNAL_ERROR | PASS | +| `QueueControllerTest` (6건) | enter 순번, ADMITTED 반환, 중복 진입, position 상태별 응답 | PASS | + +### 6.2 부하 테스트 — k6 (2026-04-02) + +**테스트 스크립트**: `k6/queue-order-load-test.js` + +**시나리오**: 50 동시 유저가 9명의 유저를 공유하며 대기열 진입 → 토큰 대기 → 주문 생성 플로우 반복. +Ramp-up 5s → Peak 50 동시 유저 30s → Cool-down 10s (총 60s). + +**테스트 환경 제약**: 유저 9명 / 동시 유저 50이므로 동일 유저의 토큰을 여러 동시 유저가 경합. +한 동시 유저가 토큰을 소비하면 같은 유저의 다른 동시 유저는 403. 실제 환경에서는 유저당 1세션이므로 이 경합은 발생하지 않음. + +#### 주문 API 레이턴시 (Prometheus 히스토그램 — 201 Success만) + +| 지표 | 값 | +|------|-----| +| **p50** | <= 111.8ms | +| **p90** | <= 246.1ms | +| **p95** | <= 357.9ms | +| **p99** | <= 357.9ms | +| **평균** | 139.0ms | +| 총 성공 주문 | 2,141건 | + +#### k6 Custom Metrics + +| 지표 | 값 | +|------|-----| +| order_duration p90 | 225ms | +| order_duration p95 | 263ms | +| queue_wait_time avg | 761ms | +| queue_wait_time p95 | 1.21s | +| http_req_duration p95 | 190ms | +| http_req_duration p99 | 289ms | + +#### 응답 코드 분포 + +| Status | 건수 | 의미 | +|--------|------|------| +| 201 | 2,141 | 주문 성공 | +| 403 | 782 | 토큰 경합 (테스트 환경 제약) | + +#### 분석 + +1. **p99 ~358ms vs 초기 산정 200ms**: 실측이 1.8배 높음. + 비관적 락 경합 + 다중 테이블 쓰기(주문+주문항목+재고) 오버헤드가 원인으로 추정. + +2. **TPS 보정 필요성**: + ``` + 현재: TPS = 40 / 0.2 = 200 → 안전 마진 70% = 140 + 실측: TPS = 40 / 0.358 = 112 → 안전 마진 70% = 78 + + 보정 시 배치 크기 = 78 / 10 = 8명/배치 + ``` + 다만 p99는 최악 케이스이므로 avg(139ms) 기준으로 보면: + ``` + TPS = 40 / 0.139 = 288 → 안전 마진 70% = 201 + ``` + **결정**: 평균과 p99 사이에서 보수적으로 현재 14명/배치를 유지. + p99 기준 8명은 너무 보수적이고, avg 기준 20명은 피크 시 풀 고갈 위험. + 운영 모니터링 후 커넥션 풀 사용률을 보면서 조정. + +3. **403 실패는 테스트 환경 제약**: 실제 환경에서는 유저당 1세션이므로 토큰 경합 없음. + +### 6.3 현실적 혼합 트래픽 부하 테스트 + HikariCP 모니터링 (2026-04-02) + +**테스트 스크립트**: `k6/queue-realistic-load-test.js` + +**변경 사항**: 1차 테스트 결과를 기반으로 배치 크기 14 → 8, ADMISSION_RATE 140 → 80 보정 후 실행. + +**시나리오**: +- 유저 100명 (lu001~lu100), 1인 1동시 유저 — 토큰 경합 없음 +- 혼합 트래픽: 상품 조회 70% + 대기열+주문 30% +- 80 동시 유저 피크, 50초 실행 +- HikariCP active 커넥션 1초 주기 샘플링 + +#### 주문 API 레이턴시 + +| 지표 | 값 | +|------|-----| +| **p90** | 93ms | +| **p95** | 97ms | +| **p99** | 107ms | +| **평균** | 87.6ms | +| 총 성공 주문 | 1,569건 | +| **실패율** | **0.00%** | + +#### HikariCP 커넥션 사용률 + +``` +피크 시 최대 active = 8 / 31 (25.8%) +피크 시 pending = 0 (항상) +평균 active = 1~3개 (3~10%) +``` + +50초간 1초 주기 샘플링 결과, **active가 최대 8개**로 풀 31개 대비 25.8%. +**pending은 전 구간 0** — 커넥션 대기 없음. + +#### 1차 vs 2차 비교 + +| 지표 | 1차 (배치 14) | 2차 (배치 8) | 비고 | +|------|-------------|------------|------| +| 배치 크기 | 14명 | 8명 | | +| order p99 | 358ms | 107ms | 3.3배 개선 | +| order 평균 | 139ms | 87.6ms | 1.6배 개선 | +| 실패율 | 26.83% | 0.00% | 토큰 경합 해소 | +| HikariCP max active | 미측정 | 8/31 (26%) | 안전 | +| HikariCP pending | 미측정 | 0 (항상) | 풀 고갈 없음 | + +#### 분석 + +1. **p99가 107ms로 크게 개선**: 1차의 358ms에서 3.3배 개선. + 원인은 두 가지 — (a) 토큰 경합이 없어 동일 유저 동시 주문이 사라짐, + (b) 입장 속도를 낮춰 DB 동시 부하가 줄어 비관적 락 경합이 감소. + +2. **풀 사용률 26% — 70% 마진이 과도한가?**: + active 최대 8개 / 풀 31개이므로 아직 여유가 많다. + 하지만 현재 테스트는 80 동시 유저이고, 실제 블프에서는 수천~수만 동시 유저가 대기열에 쌓인다. + 대기열이 입장 속도를 80 TPS로 제한하므로, 동시 유저가 아무리 많아도 DB 부하는 동일. + 따라서 **현재 배치 크기 8이 적절하며, 필요 시 모니터링 기반으로 올릴 수 있다.** + +3. **p99 기준 재검증**: + ``` + 실측 p99 = 107ms + TPS = 28 / 0.107 = 261 → 배치 크기 26까지 가능 + ``` + 그러나 이는 "입장 속도가 낮을 때의 p99"이다. 배치 크기를 올리면 + DB 부하가 늘어 p99도 올라가므로, 단순 역산은 위험하다. + **점진적으로 올리면서 모니터링하는 것이 안전.** + +### 6.4 블랙 프라이데이 5급간 부하 테스트 (2026-04-03, 3차) + +**테스트 스크립트**: `k6/queue-bf-load-test.js` + +**시나리오**: 시스템 수용치(80 TPS)를 단계적으로 초과하여 대기열 보호 동작 검증. + +| 급간 | VU | 시간 | 예상 도착 TPS | 목적 | +|------|-----|------|-------------|------| +| T1 (정상) | 50 | 30s | ~30 | 대기열 비어있음 확인 | +| T2 (임계) | 200 | 60s | ~80 | 입장=도착 균형점 | +| T3 (초과) | 500 | 60s | ~200 | 대기열 증가 시작 | +| T4 (블프 피크) | 1000 | 90s | ~400 | QUEUE_FULL 발동 검증 | +| T5 (쿨다운) | 0 | 120s | 0 | 대기열 소진, 시스템 복귀 | + +**테스트 데이터**: 유저 1000명(bf0001~bf1000), 상품 5개(재고 ~10,000) +**사전 준비 스크립트**: `scripts/seed-bf-test-data.sh`, `scripts/reset-bf-test.sh` + +#### 핵심 결과 + +| 지표 | 값 | 평가 | +|------|-----|------| +| 주문 성공률 | 99.97% (14,678/14,682) | 우수 | +| QUEUE_FULL 발동 | 0% (0건) | 한계 미도달 | +| order_duration avg | 1.91s | 예상 대비 높음 | +| order_duration p90 | 3.89s | | +| order_duration p95 | 4.05s | | +| **order_duration p99** | **4.33s** | **threshold 500ms 초과** | +| queue_wait_time avg | 4.81s | 양호 | +| queue_wait_time p95 | 6.57s | | +| queue_enter_queued | 14,262건 | | +| queue_enter_admitted | 424건 | | +| 총 HTTP 요청 | 44,031건 (110 req/s) | | + +#### 분석 + +**1. QUEUE_FULL 미도달 — 정상** + +최대 1,000 VU × 90초(T4) 기준 이론적 최대 누적 대기 = (도착 - 입장) × 시간 = (400 - 80) × 90 ≈ 28,800명. +max_queue = 48,000이므로 QUEUE_FULL에 도달하지 않은 것은 설계대로. +QUEUE_FULL을 보려면 VU를 ~2,000 이상 또는 T4 지속 시간을 150초 이상으로 늘려야 한다. + +**2. order_duration p99 = 4.33s — Threshold 실패 (가장 중요한 발견)** + +`order_duration`은 대기열 이후 주문 API(`POST /api/v1/orders`) 호출만 측정한다. +2차 테스트(80 VU)에서 p99=107ms였는데, 1000 VU에서 4.33s로 40배 증가. + +원인 추정: +- **MySQL row lock 경합 폭증**: 5개 상품에 1,000명이 집중, 비관적 락(`SELECT ... FOR UPDATE`)의 대기 시간이 기하급수적으로 증가 +- **대기열이 입장을 80 TPS로 제한하지만**, 입장 후 주문 처리 중인 유저가 누적되면서 DB 동시 트랜잭션 수가 증가 +- 이는 §7 "덜 받으면 더 빨라진다"의 양의 피드백 루프가 대규모에서 재현된 것 + +**3. 대기열 보호의 한계 — ρ가 낮은데 왜 느려지나?** + +모니터에서 ρ(HikariCP active/total)가 낮게 관측되었으나, 이는 1초 샘플링이 마이크로 버스트를 포착하지 못한 것. +실제 order_duration avg=1.91s는 커넥션 풀 내부가 아닌 **MySQL InnoDB row lock 대기**에서 시간이 소비됨을 시사한다. + +대기열은 "DB 커넥션 풀 고갈"은 막지만, "row lock 경합"까지는 막지 못한다. +이 발견이 시사하는 것: +- 배치 크기 8 → 더 줄이면 row lock 경합이 감소할 수 있으나, 처리량이 떨어짐 +- 근본 해결은 상품 재고 차감의 **동시성 제어 방식 변경** (예: 낙관적 락, Redis 재고 선차감) + +**4. 대기열은 "완전한 보호"가 아닌 "1차 방어선"** + +- 대기열이 없으면: 1000 VU가 모두 직접 DB에 접근 → 커넥션 풀 고갈 + 타임아웃 폭발 → **시스템 다운** +- 대기열이 있으면: 입장 속도 80 TPS 제한 → 커넥션 풀은 안전, 하지만 **row lock 경합은 여전히 발생** → 느리지만 죽지는 않음 + +이것이 대기열의 실질적 가치다: **시스템이 죽지 않고 느리게 동작한다.** + +#### Grafana 모니터링 분석 (Prometheus 15초 샘플링) + +> Grafana 대시보드: `http://localhost:3000/d/queue-system` +> 시간 범위: `2026-04-03 15:13:00 ~ 15:27:00 KST` + +**Row 1 — System (ρ, DB Connection Pool, Order p99)** + +| 지표 | 값 | 의미 | +|------|-----|------| +| **ρ max** | **1.000** | DB 커넥션 풀 100% 포화 — 0.7 임계치 대폭 초과 | +| ρ avg (활성 구간) | 0.500 | 평시에는 50% 수준 | +| HikariCP active max | 40/40 | 풀 전체 소진 | +| HikariCP pending max | **64** | 커넥션 대기 스레드 64개 — 풀 고갈 증거 | +| pending 발생 횟수 | 3/37 샘플 | 마이크로 버스트성 (15초 샘플링에서 3회 포착) | + +**Row 2 — Queue (Depth, Admission Rate, Enter 분포)** + +| 지표 | 값 | 의미 | +|------|-----|------| +| queue_waiting_size max | 70 | 대기열 최대 70명 대기 — max 48,000 대비 0.15% | +| admission rate max | 75.1 req/s | 설계값 80 TPS에 근접 | +| admission rate avg | 51.7 req/s | 전 구간 평균 | +| QUEUED 누적 | 47,395건 | 대기열을 거친 유저 | +| ADMITTED 누적 | 1,501건 | 즉시 입장 유저 | +| QUEUE_FULL | 0건 | 한계 미도달 | + +**Row 3 — Advanced (Safe TPS, SSE, Redis Errors)** + +| 지표 | 값 | 의미 | +|------|-----|------| +| Safe TPS (Little's Law) | 31.3 / 14.0 req/s | p99 기반 실시간 계산, 설계값 80 대비 크게 낮음 | +| SSE connections | 0 | 테스트에서 SSE 미사용 (Polling 방식) | +| admission errors | 0 | Redis 장애 없음 | +| token fallback | 0 | Fallback 미발동 | + +**핵심 발견 — ρ = 1.0은 양의 피드백 루프의 증거** + +2차 테스트(80 VU)에서 ρ max = 0.26(8/31)이었는데, BF(1000 VU)에서 **ρ = 1.0**으로 폭등. +대기열이 입장 속도를 80 TPS로 제한하고 있음에도 풀이 포화된 이유: + +``` +입장 속도 = 80 TPS (일정) +개별 처리 시간 = 1.91s avg (2차의 87ms에서 22배 증가) +동시 점유 커넥션 = 80 × 1.91 = 152.8 (이론값) +풀 크기 = 40 → 나머지 112.8이 pending으로 누적 +``` + +즉, **대기열은 도착률을 제한**하지만, row lock 경합으로 처리 시간이 늘어나면 +동시 점유 커넥션이 풀 크기를 초과한다. 이것이 §7 "양의 피드백 루프"의 대규모 재현이다. + +**Safe TPS 14.0 req/s의 의미**: `28 / p99(2.0s) = 14`. 현재 배치 크기 8은 80 TPS를 투입하고 있으나, +실측 Safe TPS는 14에 불과. 배치 크기를 14 × 1s = 약 2로 줄여야 p99가 안정화될 수 있으나, +처리량이 극단적으로 감소. 근본 해결은 row lock 경합 제거(낙관적 락, Redis 재고 선차감). + +![Grafana Queue System Dashboard — BF 5급간 부하 테스트 (2026-04-03)](images/grafana-queue-dashboard-bf-test.png) + +#### Threshold 보정 + +현재 k6 threshold `order_duration p(99)<500`은 2차 테스트(80 VU) 기준. +BF 시나리오에서는 row lock 경합이 불가피하므로 threshold를 분리해야 한다: +- 정상 트래픽: p(99) < 500ms (기존 유지) +- BF 피크: **p(99) < 5s** (row lock 경합 허용, 시스템 생존 확인) + +--- + +### 6.5 QUEUE_FULL 검증 부하 테스트 (2026-04-03, 4차 — Open-loop) + +**목적**: 대기열이 가득 차서 QUEUE_FULL을 반환하는 시나리오를 재현하고, 대기열의 3가지 핵심 동작을 검증한다. + +1. 수용치 초과 요청은 QUEUED (드롭 없음) +2. QUEUED된 유저는 순서대로 ADMITTED +3. 대기열이 가득 차면 QUEUE_FULL 반환 + +**테스트 스크립트**: `k6/queue-bf-load-test.js` + +#### 3차 테스트의 문제점과 해결 + +3차 테스트(§6.4)에서 QUEUE_FULL이 발동하지 않은 이유: + +| 문제 | 원인 | 해결 | +|------|------|------| +| 대기열 안 쌓임 | Closed-loop(`ramping-vus`): VU가 폴링에 묶여 도착률 저하 | **Open-loop**(`ramping-arrival-rate`): iteration 완료 무관하게 초당 N건 투입 | +| 같은 유저 반복 | `__VU` 기반 userId: 동일 VU = 동일 유저 → 기존 토큰으로 ADMITTED | **iterationInTest** 기반: 매 iteration 고유 유저 배정 | +| 48,000 채울 수 없음 | 5,000명 유저로 48,000 대기열 채우기 불가능 | **max_queue=1,000**으로 축소 (같은 코드 경로 `size >= maxQueueSize`) | + +**변경 사항**: +- `QueueController.java`: `MAX_QUEUE_SIZE` 상수 → `@Value("${queue.max-size:48000}")` 외부화 +- `k6/queue-bf-load-test.js`: `exec.scenario.iterationInTest` 기반 userId 매핑, `maxVUs: 10000` + +#### 시나리오 설계 (Open-loop 5급간) + +| 급간 | 도착률 | 시간 | 목적 | +|------|--------|------|------| +| T1 (정상) | 30/s | 30s | 입장 80/s > 도착, 대기열 비어있음 | +| T2 (임계) | 80/s | 60s | 도착 = 입장, 균형점 | +| T3 (초과) | 150/s | 60s | 순 70/s 누적, 대기열 증가 | +| T4 (블프 피크) | 200/s | 90s | 순 120/s 누적, QUEUE_FULL 발동 | +| T5 (쿨다운) | 0/s | 120s | 대기열 소진, 시스템 복귀 | + +**대기열 성장 예측** (max_queue=1,000): +- T3: 70/s 순누적 × ~14초 = 1,000 → **T3 시작 14초 만에 QUEUE_FULL 도달** +- 이후 도착분은 모두 QUEUE_FULL 반환 + +**테스트 데이터**: 유저 5,000명(bf0001~bf5000), 상품 5개(재고 100,000) + +#### 핵심 결과 + +| 지표 | 값 | 평가 | +|------|-----|------| +| 주문 성공률 | 79.9% (19,516/24,419) | 3차(99.97%) 대비 낮음 — 아래 분석 참조 | +| **QUEUE_FULL 발동** | **10.4% (2,988건)** | **설계대로 동작 — 핵심 검증 성공** | +| order_duration avg | 14.83s | 3차(1.91s)의 7.7배 | +| order_duration p90 | 32.3s | | +| order_duration p95 | 33.57s | | +| **order_duration p99** | **33.96s** | **3차(4.33s)의 7.8배 — row lock 경합 극대화** | +| queue_wait_time avg | 15.43s | | +| queue_wait_time p90 | 33.89s | | +| queue_enter_queued | 19,177건 | 전체의 66.9% | +| queue_enter_admitted | 5,246건 | 전체의 18.3% (토큰 재사용) | +| queue_enter_full | 2,988건 | 전체의 10.4% | +| queue_enter_error | 1,223건 | 전체의 4.3% (서버 응답 오류) | +| 총 HTTP 요청 | 72,631건 (181.6 req/s) | | + +#### 모니터링 타임라인 분석 + +> Grafana 대시보드: `http://localhost:3000/d/queue-system` +> 시간 범위: `2026-04-03 17:03:00 ~ 17:09:45 KST` + +**Phase 1: T1~T2 정상/임계 (17:03:00~17:04:30)** + +``` +ρ=0.000~0.032 active=0~1/31~33 pending=0 +queue=0→6 admitted_total: 0→5,042 +``` + +입장 속도(80/s)가 도착 속도(30~80/s)를 상회. 대기열 거의 비어있음. ρ는 0에 가까움. +여기까지는 3차 테스트와 동일한 양상. + +**Phase 2: T3 초과 — 대기열 급성장 (17:04:35~17:05:05)** + +``` +17:04:35 queue=14 admitted_total=5,442 queue_full=0 +17:04:37 queue=32 ρ=0.382 active=13/34 +17:04:39 queue=69 ρ=0.811 active=30/37 ← ρ 첫 번째 피크 +17:04:40 queue=96 +17:04:42 queue=143 +17:04:44 queue=345 +17:04:48 queue=446 +17:04:53 queue=542 +17:04:58 queue=747 +17:05:05 queue=969 +``` + +30초 만에 0→969. 순 누적 속도 = (150-80) = 70/s, 이론 예측 70×30=2,100이지만 실측은 ~970. +이유: iterationInTest가 동일 유저를 재할당할 확률. 5,000명 풀에서 반복 시 기존 순번 유지(ZADD NX). + +**Phase 3: QUEUE_FULL 발동 (17:05:06~17:06:10)** + +``` +17:05:06 queue=994 queue_full_total=5 ← QUEUE_FULL 첫 발동! +17:05:11 queue=999 queue_full_total=154 +17:05:15 queue=990 ρ=1.000 pending=3 ← 풀 포화 순간 +17:05:30 queue=995 queue_full_total=886 +17:06:00 queue=993 queue_full_total=2,429 +17:06:10 queue=984 queue_full_total=2,987 ← QUEUE_FULL 마지막 +``` + +대기열이 ~994~1,004 범위에서 진동하며, 초과 요청은 전부 QUEUE_FULL로 거부. +이 구간에서 ρ=0.3~0.8 범위를 오가며 풀은 대체로 안전. pending=3은 1회 순간 스파이크. + +**Phase 4: 쿨다운 — 대기열 소진 (17:06:10~17:06:50)** + +``` +17:06:10 queue=984 → 새 도착 없음, 80/s 입장 계속 +17:06:20 queue=774 +17:06:30 queue=553 +17:06:40 queue=241 +17:06:50 queue=0 ← 대기열 완전 소진 (40초 소요) +``` + +소진 속도: 984 / 40초 ≈ 24.6/s. 입장 속도 80/s이지만, 폴링 간격(3~5초)으로 인해 +입장된 유저가 다음 폴 때 확인 → 실제 소진 속도가 입장 속도보다 느리게 관측. + +**Phase 5: 주문 처리 집중 + 풀 포화 (17:07:45~17:08:12)** + +``` +17:07:44 ρ=0.475 active=19/40 pending=0 admitted_total=19,429 +17:07:45 ρ=1.000 active=40/40 pending=19 ← 급격한 풀 포화! +17:07:46 ρ=1.000 active=40/40 pending=115 +17:07:48 ρ=1.000 active=40/40 pending=146 +17:08:01 ρ=1.000 active=40/40 pending=150 ← pending 최대값 +17:08:12 ρ=1.000 active=40/40 pending=104 +17:08:13 ρ=0.000 active=0/40 pending=0 ← 갑자기 전부 해소 +17:08:14 admitted_total=19,539 ← 최종 입장 수 +``` + +**이것이 가장 중요한 발견이다.** + +대기열이 비었는데(queue=0) 왜 ρ=1.0이 발생하는가? +- k6 `gracefulStop: 120s` 동안 폴링 중이던 VU들이 admitted 확인 후 **동시에 주문 호출** +- 입장은 80/s로 제한했지만, 주문 타이밍이 겹치면서 **동시 DB 접근 폭증** +- 27초간 ρ=1.0 + pending max=150 — 3차 테스트(pending=64)의 2.3배 +- admitted_total이 19,439에서 19,539로 100명 추가 — 마지막 배치들의 동시 주문 + +**이것은 대기열의 구조적 한계를 보여준다**: 입장 속도를 제한해도, 입장된 유저의 **주문 타이밍까지는 제어하지 못한다.** 쿨다운 시 축적된 admitted 유저가 한꺼번에 주문하면 row lock 경합이 폭발한다. + +#### Grafana 모니터링 요약 + +**Row 1 — System (ρ, DB Connection Pool, Order p99)** + +| 지표 | 값 | 의미 | +|------|-----|------| +| ρ max | 1.000 | 풀 100% 포화 (2회 발생) | +| ρ avg (Phase 3) | 0.45 | QUEUE_FULL 구간에서는 의외로 안정 | +| HikariCP active max | 40/40 | 풀 전체 소진 | +| **HikariCP pending max** | **150** | 3차(64)의 2.3배 — 주문 동시 처리 병목 | +| pending 지속 시간 | 27초 | 17:07:45~17:08:12 | + +**Row 2 — Queue (Depth, Admission Rate, Enter 분포)** + +| 지표 | 값 | 의미 | +|------|-----|------| +| queue_waiting_size max | ~1,004 | max_queue=1,000 근처에서 진동 (배치 크기 8의 레이스) | +| admission rate | ~80/s | 설계값과 정확히 일치, 전 구간 안정 | +| QUEUED | 19,177건 (66.9%) | | +| ADMITTED | 5,246건 (18.3%) | | +| QUEUE_FULL | 2,988건 (10.4%) | | + +**Row 3 — Advanced (Safe TPS, SSE, Redis Errors)** + +| 지표 | 값 | 의미 | +|------|-----|------| +| Safe TPS (Little's Law) | 31.4 / 16.0 req/s | 3차(31.3/14.0)와 유사, row lock 경합의 본질적 한계 | +| SSE connections | 0 | Polling 방식 테스트 | +| admission errors | 0 | Redis 장애 없음 | +| token fallback | 0 | Fallback 미발동 | + +![Grafana Queue System Dashboard — BF QUEUE_FULL 검증 테스트 (2026-04-03)](images/grafana-queue-dashboard-bf-queue-full-test.png) + +#### 검증 결과 정리 + +| 검증 항목 | 결과 | 근거 | +|-----------|------|------| +| QUEUE_FULL 발동 | **PASS** | 2,988건 반환, 10.4% | +| 수용치 초과 → QUEUED | **PASS** | 19,177건 정상 대기열 진입 | +| QUEUED → 순서대로 ADMITTED | **PASS** | 대기열 0→994→0 완전 소진, admitted_total=19,539 | +| DB 커넥션 풀 보호 (ρ ≤ 0.7) | **PARTIAL** | 대기열 활성 구간(Phase 3)에서는 ρ avg 0.45로 안전. 쿨다운 시 ρ=1.0 폭등 | +| 시스템 생존 | **PASS** | pending=150에서도 시스템 다운 없이 전량 처리 완료 | + +#### 3차 vs 4차 비교 + +| 지표 | 3차 (Closed-loop) | 4차 (Open-loop) | 변화 | +|------|-------------------|-----------------|------| +| 방식 | ramping-vus (1000 VU) | ramping-arrival-rate (200/s peak) | Open-loop 전환 | +| userId 매핑 | __VU 기반 | iterationInTest 기반 | 고유 유저 보장 | +| max_queue | 48,000 | **1,000** | QUEUE_FULL 검증 가능 | +| QUEUE_FULL | 0건 | **2,988건 (10.4%)** | 핵심 검증 성공 | +| order success | 99.97% | 79.9% | row lock 경합 증가 | +| order p99 | 4.33s | **33.96s** | 7.8배 증가 | +| ρ max | 1.000 | 1.000 | 동일 | +| pending max | 64 | **150** | 2.3배 증가 | +| queue max | 70 | **~1,004** | 14배 | +| admitted_total | 48,898 | 19,539 | 유효 입장 감소 | + +#### 교훈 + +**1. Open-loop vs Closed-loop은 부하 테스트의 근본적 차이** + +Closed-loop(ramping-vus)에서는 VU가 폴링에 묶이면 새 요청을 보내지 않는다. +이는 "시스템이 느려지면 부하가 줄어드는" 현실과 다른 모델이다. +Open-loop(ramping-arrival-rate)은 시스템 상태와 무관하게 일정한 도착률을 유지하여, +**블랙 프라이데이처럼 유저가 끊임없이 새로 들어오는 시나리오**를 정확히 재현한다. + +**2. 대기열은 "입장 속도"만 제한, "주문 타이밍"은 제한 불가** + +Phase 5에서 pending=150이 발생한 것은, 대기열에서 빠져나온 유저의 주문 시점이 겹쳤기 때문. +실 서비스에서는 유저마다 주문서 작성 시간이 다르므로 자연 분산되지만, +k6에서는 admitted 확인 즉시 주문을 보내므로 인위적으로 집중된다. +이 한계를 인지하되, 최악의 시나리오(모두 동시 주문)에서도 시스템이 생존한다는 점이 중요하다. + +**3. max_queue 축소는 유효한 테스트 전략** + +48,000명을 생성하지 않아도, max_queue=1,000으로 축소하면 동일한 코드 경로를 검증할 수 있다. +`size >= maxQueueSize` 비교문의 동작은 값이 1,000이든 48,000이든 동일하다. + +--- + +## 7. 인사이트 — 덜 받으면 더 빨라진다 + +### 현상 + +배치 크기를 14에서 8로 줄였더니, 단순히 시스템이 안전해진 것이 아니라 **개별 요청의 속도 자체가 빨라졌다**. + +| 지표 | 배치 14 (140 TPS) | 배치 8 (80 TPS) | 변화 | +|------|------------------|----------------|------| +| order p99 | 358ms | 107ms | **3.3배 빨라짐** | +| order 평균 | 139ms | 87.6ms | 1.6배 빨라짐 | +| 실패율 | 26.83% | 0.00% | 실패 소멸 | + +입장 속도를 **43% 줄였는데**, p99 레이턴시가 **70% 줄었다**. + +### 왜 이런 일이 일어나는가 + +주문 처리는 비관적 락(`SELECT ... FOR UPDATE`)으로 재고를 차감한다. +동시에 DB에 접근하는 요청이 많을수록, 락 경합이 발생하고, 각 요청의 대기 시간이 기하급수적으로 늘어난다. + +``` +배치 14 (140 TPS × 0.2s = 28 동시 커넥션): + → 같은 상품에 동시 접근하는 트랜잭션이 많음 + → 락 경합 증가 → 개별 트랜잭션 대기 → latency 상승 + → latency 상승 → 커넥션 점유 시간 증가 → 더 많은 경합 + → 악순환 (양의 피드백 루프) + +배치 8 (80 TPS × 0.1s = 8 동시 커넥션): + → 동시 접근 트랜잭션이 적음 + → 락 경합 거의 없음 → 트랜잭션 즉시 실행 + → latency 낮음 → 커넥션 빠르게 반환 → 경합 없음 + → 선순환 +``` + +이것이 **양의 피드백 루프(positive feedback loop)** 다: + +``` +부하 ↑ → 경합 ↑ → latency ↑ → 커넥션 점유 ↑ → 부하 ↑ → ... (붕괴) +부하 ↓ → 경합 ↓ → latency ↓ → 커넥션 점유 ↓ → 부하 ↓ → ... (안정) +``` + +### 초기 산정이 틀린 이유 + +처음에 `TPS = pool_size / latency`로 계산할 때, latency를 **상수**로 취급했다. +하지만 실제로 latency는 **동시 부하의 함수**다. + +``` +latency = f(동시 요청 수) + +동시 요청이 적으면: latency ≈ 87ms (순수 쿼리 실행 시간) +동시 요청이 많으면: latency ≈ 358ms (락 대기 포함) +``` + +따라서 Little's Law `L = λ × W`에서 W가 λ에 따라 변하므로, +단순 선형 계산(`40 / 0.2 = 200 TPS`)은 **낙관적 추정**이 된다. +실측 → 보정 → 재실측 사이클이 필수인 이유다. + +### 시스템 설계에 주는 교훈 + +1. **처리량(throughput)과 응답 속도(latency)는 트레이드오프가 아니다**. + 적절한 지점에서는 처리량을 줄이면 응답 속도도 빨라진다. + +2. **대기열의 가치는 "거부"가 아니라 "조절"이다**. + Rate Limiting(429)은 초과 트래픽을 거부한다. + 대기열은 초과 트래픽을 버퍼링하면서, DB가 최적 효율로 동작하는 부하만 흘려보낸다. + 결과적으로 시스템 전체의 처리 효율이 올라간다. + +3. **수치는 이론으로 시작하되, 실측으로 검증해야 한다**. + `40 / 0.2 = 200 TPS`는 맞는 공식이지만, 0.2라는 가정이 틀렸다. + 가정이 맞는지는 부하를 걸어봐야만 알 수 있다. + +--- + +## 8. 시스템 용량 역산 체인 & 운영 가이드 + +### 8.1 역산 체인 — 모든 숫자의 출처 + +이 시스템의 모든 설정값은 하나의 병목에서 연쇄적으로 유도된다. +어떤 숫자도 "적당히"가 아니라, 앞선 숫자의 결과다. + +``` +[1] 병목 식별 + DB 커넥션 풀 = 40개 (HikariCP, jpa.yml) + Tomcat 스레드 = 200개 (application.yml) + → 커넥션 풀(40) < 스레드 풀(200) → DB 커넥션이 1차 병목 + + ↓ + +[2] 안전 처리량 역산 (Little's Law + Queuing Theory) + 제약: 동시 커넥션 ≤ 28개 (풀 40의 70%, ρ ≤ 0.7) + 실측 p99 = 358ms (1차 부하 테스트) + TPS = 28 / 0.358 = 78 + + ↓ + +[3] 스케줄러 파라미터 + 스케줄러 주기 = 100ms (10회/초) + 배치 크기 = 78 / 10 = 7.8 → 반올림 8명 + 입장 속도 = 8 × 10 = 80 TPS + + ↓ + +[4] 대기열 한계 (유저 인내 한계에서 유도) + 최대 대기 시간 = 600초 (10분, 이커머스 이탈 임계점) + max_queue = 80 TPS × 600초 = 48,000명 + + ↓ + +[5] 대기열 타임아웃 + 정리 주기 = 10초 (ZREMRANGEBYSCORE) + 최악 오차 = 600 + 10 = 610초 (1.7%, 유저 체감 무의미) + + ↓ + +[6] 토큰 TTL (유저 체류 시간에서 유도) + 체크아웃 p99 = ~10분 + TTL = 10분 + 50% 여유 = 900초 (15분) + 설정값: queue.token.ttl-seconds (외부화) + + ↓ + +[7] 동시 토큰 보유자 + 가중 평균 체류 = 189 + 0.05 × 900 = 234초 + 동시 토큰 = 80 × 234 = 18,720명 + + ↓ + +[8] Redis 총 메모리 + 대기열: 48,000 × 90 bytes = 4.3MB + 토큰: 18,720 × 90 bytes = 1.7MB + 합계: 66,720 키, ~6MB +``` + +### 8.2 시스템 규모 + +``` +단일 서버 기준: + 주문 처리: 80 TPS = 288,000건/시간 = 6,912,000건/일 (24시간 가동 기준) + 피크 8시간: 80 TPS × 28,800초 = 2,304,000건 + +이커머스 규모 대비: + 소규모 (일 1,000건): 피크 ~5 TPS → 대기열 불필요 + 중견 (일 10,000~50,000): 피크 10~50 TPS → 80 TPS로 충분 + 대형 블프 피크: 수천 TPS → 다중 서버 + DB 샤딩 필요 + +현재 아키텍처의 위치: + 단일 서버 + MySQL 40 커넥션으로 중견 이커머스 블프 대응 가능. + 이 이상의 규모는 스케일아웃이 필요하며, 그때 Amdahl/USL이 필요해진다. +``` + +### 8.3 운영 시나리오별 설정 + +모든 조정 가능한 값은 설정 파일 또는 상수로 관리된다. +코드 변경 없이 설정 변경만으로 시나리오별 대응이 가능하다. + +#### 평시 (기본값) + +```yaml +# application.yml +queue: + token: + ttl-seconds: 900 # 토큰 15분 +``` + +```java +// QueueAdmissionScheduler.java +BATCH_SIZE = 8 // 80 TPS +MAX_WAIT_SECONDS = 600 // 대기열 10분 타임아웃 + +// QueueController.java +MAX_QUEUE_SIZE = 48_000 // 80 × 600 +``` + +| 지표 | 값 | +|------|-----| +| 입장 속도 | 80 TPS | +| 최대 대기 시간 | 10분 | +| 최대 대기열 | 48,000명 | +| 토큰 TTL | 15분 | +| 동시 토큰 | ~18,720명 | +| DB 이용률 (ρ) | 0.214 | +| Redis 메모리 | ~6MB | + +#### 블랙 프라이데이 (설정 변경만) + +```yaml +# application-bf.yml 또는 운영 시 설정 오버라이드 +queue: + token: + ttl-seconds: 1800 # 토큰 30분 (체크아웃 여유 확대) +``` + +토큰 TTL만 변경. 나머지 값은 DB 커넥션 풀에서 역산된 값이므로 변경 불필요. + +| 지표 | 평시 | 블프 | 변경 근거 | +|------|------|------|----------| +| 입장 속도 | 80 TPS | 80 TPS | DB 커넥션 풀 불변 → 변경 불가 | +| 최대 대기열 | 48,000명 | 48,000명 | 입장 속도 불변 → 변경 불가 | +| 토큰 TTL | 900초 | 1800초 | 블프는 결제 고민 시간이 김 | +| 동시 토큰 | 18,720명 | 25,920명 | 80 × (189 + 0.05×1800) = 80 × 279 | +| Redis 메모리 | ~6MB | ~6.6MB | 토큰 +7,200명 = +0.6MB | + +블프에서도 Redis 추가 비용은 0.6MB. 시스템 영향 없음. + +#### 모니터링 기반 조정 + +배치 크기를 올리고 싶을 때, 다음 메트릭을 확인한다: + +``` +Prometheus / Grafana 대시보드: + +1. DB 이용률 확인 + hikaricp_connections_active{pool="mysql-main-pool"} + → 피크 시 28개(풀의 70%) 이하인지 확인 + +2. 커넥션 대기 확인 + hikaricp_connections_pending{pool="mysql-main-pool"} + → 0이어야 함. 1 이상이면 풀 고갈 임박. + +3. 주문 API p99 확인 + http_server_requests_seconds{uri="/api/v1/orders", quantile="0.99"} + → 358ms(1차 측정) 이상이면 부하 과다 + +4. Queuing Theory 검증 + ρ = 입장TPS × p99 / 풀크기 + → ρ ≤ 0.7 유지 +``` + +``` +조정 플로우: + + [HikariCP active < 20, pending = 0, p99 < 150ms] + → 여유 있음 → 배치 크기 8→10 시도 + → 부하 테스트 실행 + → ρ 재계산 + pending 확인 + → 안전하면 적용 + + [HikariCP active > 28 또는 pending > 0] + → 위험 → 배치 크기 유지 또는 축소 + → p99 확인하여 원인 분석 + + [p99 급등 (200ms → 400ms+)] + → 락 경합 발생 → 배치 크기 축소 + → 양의 피드백 루프 차단 +``` + +--- + +## 9. 설계 검증 — 단일 큐 vs 다중 큐 + +### 9.1 왜 단일 큐인가 + +대기열을 여러 개로 분산하면 Redis 병목을 줄일 수 있다는 아이디어가 있다. +그러나 이 시스템에서는 단일 큐가 정답이다. 세 가지 근거: + +**근거 1: 병목이 Redis가 아니다** + +``` +Redis 단일 키 처리량: ~100,000 ops/sec +우리 시스템의 Redis 연산: ~16,000 ops/sec (여유 84%) +DB 커넥션 풀 이용률(ρ): 0.214 (여유 78%) + +→ DB가 1차 병목. Redis를 분산해봤자 병목이 아닌 곳을 최적화하는 것. +``` + +**근거 2: 순서보장이 비즈니스 요구사항이다** + +유저에게 "현재 42번째, 약 1초 대기"를 보여주려면 global ordering이 필요하다. +`ZRANK`로 정확한 순번을 반환하고, `position / ADMISSION_RATE`로 예상 대기시간을 계산한다. +이것은 단일 Sorted Set이기 때문에 가능하다. + +**근거 3: 다중 큐의 복잡도 대비 이득이 없다** + +``` +단일 큐: + enter() → ZADD NX 1회 + position() → ZRANK 1회 + scheduler → ZPOPMIN 1회 (100ms마다) + +다중 큐 (4개): + enter() → 해싱으로 큐 선택 + ZADD NX + position() → 4개 큐 ZRANK 합산 (정확한 순번 불가능) + scheduler → 4개 큐 라운드로빈 ZPOPMIN + 교차 정렬 상태 관리 + + → 코드 복잡도 3~4배, 순번 정확도 상실 + → 이득: Redis ops를 4개 키로 분산 (이미 84% 여유인데 불필요) +``` + +### 9.2 처리량 vs 순서보장 트레이드오프 + +대기열 설계의 핵심 트레이드오프는 **처리량과 순서보장** 사이에 있다. + +| 선택지 | 순서보장 | 처리량 | 복잡도 | 적합 시나리오 | +|--------|---------|--------|--------|-------------| +| (1) 순서 포기, 처리량 극대화 | X | 최대 | 중간 | 순번 표시 불필요, Redis가 병목인 초대규모 | +| (2) 단일 큐 strict FIFO | O | 단일 키 한계 | **낮음** | **순번 표시 필요, Redis가 병목이 아닌 경우** | +| (3) 라운드로빈 (대략적 FIFO) | 대략적 | 높음 | 중간 | 순번 정확도를 포기할 수 있는 경우 | + +**우리의 선택: (2) 단일 큐** + +- 유저에게 순번과 예상 대기시간을 정확히 보여주는 것이 이커머스 대기열의 핵심 UX +- Redis 단일 키 처리량(~100K ops/sec) 대비 우리 연산량(~16K ops/sec)이 충분히 낮음 +- 다중 큐로 전환이 필요한 시점은 Redis ops가 단일 키 한계에 근접할 때이며, 현재 규모에서는 해당 없음 + +### 9.3 다중 큐가 필요해지는 시점 + +``` +Redis 단일 키 한계: ~100,000 ops/sec + +현재: ~16,000 ops/sec (여유 84%) + → 단일 큐 유지 + +position 폴링 빈도를 1초로 줄이고, 대기열 48,000명이 모두 폴링한다면: + → 48,000 ZRANK/sec + 80 ZADD/sec + 10 ZPOPMIN/sec ≈ 48,090 ops/sec + → 단일 키 한계의 48%. 아직 여유 있음. + +대기열 100,000명 + 1초 폴링: + → ~100,000 ops/sec → 한계 도달 + → 이 시점에서 다중 큐 또는 폴링 주기 조정 검토 +``` + +그러나 대기열 48,000명이 한계인 현재 설계에서는 이 시점에 도달하지 않는다. + +### 9.4 POP 후 토큰 발급 유실 문제 — "POP != 처리 완료" + +#### 기존 코드의 취약점 (Lua 적용 전) + +```java +// 변경 전: 2단계 호출 — ZPOPMIN과 토큰 발급 사이에 유실 윈도우 존재 +Set> admitted = waitingQueueRedisRepository.popMin(8); // ZPOPMIN: 즉시 삭제 +for (TypedTuple tuple : admitted) { + entryTokenRedisRepository.issue(Long.parseLong(tuple.getValue())); // 여기서 장애나면? +} +``` + +ZPOPMIN으로 대기열에서 제거한 후, 토큰 발급 전에 서버 크래시가 발생하면 +유저는 대기열에도 없고 토큰도 없는 상태가 된다 (최대 8명/배치). + +**현재는 Lua 스크립트로 원자적 처리하여 이 취약점을 해결했다** (아래 참조). + +#### 해결 방안 비교 + +| 방안 | 원리 | 복잡도 | 안전성 | +|------|------|--------|--------| +| **Lua 스크립트** | ZPOPMIN + SET EX를 원자적으로 실행 | **낮음** | 높음 (중간 상태 없음) | +| **Visibility Timeout** | 조회 → processing 상태 → 토큰 발급 → 확정 삭제 | 높음 | 매우 높음 | +| **현행 유지 (유실 허용)** | 재진입으로 복구 | 없음 | 낮음 (최대 8명 유실) | + +#### Lua 스크립트 방식 (권장) + +```lua +-- ZPOPMIN + 토큰 발급을 원자적으로 실행 +local members = redis.call('ZPOPMIN', KEYS[1], ARGV[1]) +for i = 1, #members, 2 do + redis.call('SET', 'queue:token:' .. members[i], '1', 'EX', ARGV[2]) +end +return members +``` + +대기열과 토큰이 모두 Redis에 있으므로, Lua 스크립트 하나로 원자적 처리 가능. +"대기열에서 빠짐 = 토큰 발급됨"이 보장되어 중간 유실 상태가 존재하지 않는다. + +#### Visibility Timeout 방식 (현재 규모에서는 과한 설계) + +AWS SQS 스타일: 메시지를 "invisible" 상태로 전환 → 처리 완료 후 명시적 삭제. +처리 실패 시 timeout 후 다시 큐에 나타남. 안전하지만 processing 키 관리, +복구 스케줄러 추가 등 복잡도가 높다. 배치 8명 × 서버 크래시 빈도를 고려하면 과도하다. + +#### 현실적 유실 허용 판단 + +``` +유실 윈도우: ZPOPMIN ~ SET EX 사이 = ~수 ms +유실 조건: 이 수 ms 내에 서버 크래시 또는 Redis 마스터 장애 +유실 범위: 최대 8명/배치 +복구 경로: 유저가 position 폴링 → NOT_IN_QUEUE → 재진입 (기존 UX) +``` + +**현재 구현**: Lua 스크립트를 적용하여 유실 가능성을 제거했다. +`WaitingQueueRedisRepository.popMinAndIssueTokens()`가 ZPOPMIN + SET EX를 원자적으로 실행한다. + +#### Redis 영속성의 한계 (RDB/AOF) + +Redis 영속성은 "완벽한 복구"가 아니라 "대부분의 복구"다: +- **RDB**: 스냅샷 기반, 주기적 → 스냅샷 사이 데이터 유실 가능 +- **AOF**: `everysec`이 최소 단위 → 1초 이내 장애 시 유실 가능 +- 따라서 Redis 영속성에 의존한 복구 전략은 대기열 유실 방지의 근본 해결이 아님 + +### 9.5 Redis 장애 유형과 우리 시스템의 대응 + +Redis는 빠르지만 신뢰할 수는 없는 **성능 지향형 시스템**이다. +장애 유형을 계층별로 분류하고, 우리 시스템의 노출도와 대응 상태를 점검한다. + +#### 장애 유형 분류 + +| 계층 | 원인 | 예시 | +|------|------|------| +| **프로세스** | Redis 프로세스 자체 문제 | OOM, Crash, Lua 무한루프, `KEYS *`, ulimit(fd 고갈), AOF/RDB 쓰기 실패 | +| **서버** | 머신/인프라 문제 | VM 장애, 네트워크 단절, 대역폭 포화 | +| **클러스터** | 분산 환경 문제 | 네트워크 파티션, 스플릿 브레인(마스터 다중), 노드 편중 | +| **논리** | 설계/사용 패턴 문제 | 핫키(특정 키에 트래픽 집중 → CPU 100% → 노드 다운) | + +#### 우리 시스템의 노출도 + +| 장애 유형 | 노출도 | 현재 대응 | +|----------|--------|----------| +| OOM | 낮음 — 대기열+토큰 합계 ~6MB | MAX_QUEUE_SIZE(48,000)로 메모리 상한 제어 | +| Lua 무한루프 | 없음 — for 루프가 ZPOPMIN 결과(최대 8개)에 바운드 | 스크립트 구조상 불가 | +| `KEYS *` | 코드에서 미사용 | 운영 시 `SCAN` 사용 필요 (주의사항) | +| 서버/VM 장애 | 있음 — Master 죽으면 쓰기 불가 | Master-Replica 구성, Replica에서 읽기 지속 | +| 핫키 | 현재 해당 없음 — 단일 서버 구성 | 클러스터 전환 시 `queue:waiting:order` 핫키 가능성 검토 필요 | +| 네트워크 단절 | 있음 — Master 쓰기 실패 | CB(redis-write)로 빠른 실패 처리 | + +#### Redis Master 장애 시 시스템 동작 + +``` +읽기 (Replica에서 계속 동작): + 순번 조회 (ZRANK) → ReadFrom.REPLICA_PREFERRED → 정상 + 토큰 검증 (EXISTS) → ReadFrom.REPLICA_PREFERRED → 정상 + 대기열 크기 (ZCARD) → Replica → 정상 + +쓰기 (Master 복구 전까지 실패): + 대기열 진입 (ZADD) → 실패 → 유저에게 에러 응답 + 입장 처리 (Lua) → 실패 → 스케줄러 다음 주기에 재시도 + 토큰 소비 (DEL) → 실패 → CB(redis-write)가 빠른 실패 처리 + +→ Master 장애 시에도 읽기는 계속 동작하여 유저에게 현재 상태 표시 가능. +→ 쓰기만 실패하며, Master 복구 시 자동 정상화. +→ 대기열 데이터 유실 시 유저는 NOT_IN_QUEUE → 재진입 (기존 UX). +``` + +### 9.6 대기열 활성화 전략 — 항상 켜짐 vs 수동/자동 온오프 + +#### 세 가지 방식 비교 + +| 방식 | 평시 오버헤드 | 개발 비용 | 운영 리스크 | +|------|-------------|----------|-----------| +| A. 수동 온/오프 | 없음 | 피처 플래그 + 바이패스 로직 | 켜는 걸 까먹으면 무방비 | +| B. 자동 임계치 | 없음 | 모니터링 연동 + 임계치 관리 | 임계치 오판 시 장애 | +| **C. 항상 켜짐 (우리 선택)** | 극히 미미 | **없음 (현재 구현 그대로)** | **없음 (항상 보호)** | + +#### 우리가 "항상 켜짐"을 선택한 근거 + +평시(~10 TPS)에 대기열이 켜져 있어도 유저는 대기를 느끼지 못한다: + +``` +평시 흐름: + /queue/enter → 대기열 비어있음, position=1 + → 100ms 후 스케줄러가 ZPOPMIN → 즉시 토큰 발급 + → 유저 체감 대기시간 ≈ 0초 + +오버헤드: + 스케줄러: 빈 큐에 ZPOPMIN = O(1), ~0.1ms + 토큰 검증: Redis EXISTS = O(1), ~0.1ms/req + → 유저 체감 영향 없음 +``` + +항상 켜져 있으면: +- 예측 불가 트래픽(바이럴, 크롤러)에도 자동 보호 +- 운영 실수(켜는 걸 까먹음) 가능성 제거 +- 평시와 피크 시 동일한 코드 경로 → 테스트 신뢰도 높음 + +#### 수동 온/오프가 유리한 경우 + +대기열 자체가 유저 경험에 영향을 주는 시스템(예: 대기열 진입 페이지가 별도로 존재)이라면, +평시에 끄는 것이 UX상 낫다. 이 경우 수동 온/오프(방식 A)가 개발 비용이 적고 임계치 계산이 불필요해서 합리적이다. +자동 임계치(방식 B)는 기준값 산정·유지·튜닝 비용이 높아 대부분의 경우 과한 설계다. + +### 9.7 이중 상태 회피 — active_tokens 없는 설계 + +#### 문제: 토큰 TTL과 별도 추적 구조의 정합성 + +토큰을 `SET EX`(TTL)로 관리하면서 동시에 `active_tokens` Set으로 추적하면, +토큰 만료 시 Set에서 자동 제거되지 않아 정합성이 깨진다. +이를 해결하려면 GC 스케줄러(10초 주기로 Set 순회 → 만료 토큰 제거)가 필요하다. + +``` +이중 상태 설계: + queue:token:{memberId} ← TTL 자동 만료 + active_tokens (Set) ← 수동 관리 → 정합성 깨짐 → GC 필요 + +GC 비용 (동시 토큰 18,720명 기준): + 10초마다 SMEMBERS + 18,720 EXISTS = 1,872 ops/sec 추가 부하 + GC 주기와 TTL 만료 사이 최대 10초 지연 +``` + +#### 우리의 선택: 단일 상태 (active_tokens 없음) + +``` +우리 설계: + queue:token:{memberId} ← TTL 자동 만료 → 끝. 별도 추적 없음. +``` + +입장 스케줄러는 "현재 토큰이 몇 개인지"를 확인하지 않는다. +입장 속도(80 TPS)가 시스템 병목(DB pool 40)에서 역산된 고정값이므로, +토큰 보유자 수와 무관하게 항상 안전한 속도로 입장시킨다. + +이중 상태를 만들지 않으면 정합성 문제 자체가 발생하지 않고, GC도 불필요하다. + +### 9.8 다중 인스턴스와 스케줄러 중복 실행 + +#### 현재 구조: commerce-api에 스케줄러 포함 + +`QueueAdmissionScheduler`가 commerce-api 안에 `@Scheduled`로 동작한다. +현재 단일 인스턴스이므로 문제없지만, API 서버 스케일아웃 시 스케줄러가 N개 동시 실행된다. + +``` +commerce-api 3대로 스케일아웃: + 각 인스턴스가 100ms마다 ZPOPMIN 8명 → 총 24명/100ms = 240 TPS + 설계값 80 TPS의 3배 → DB 커넥션 풀 고갈 위험 +``` + +ZPOPMIN이 원자적이라 중복 토큰 발급은 없지만, 입장 속도가 인스턴스 수에 비례하여 +병목 기반 역산(§2)이 깨진다. + +#### 해결 방안 + +| 방안 | 원리 | 복잡도 | +|------|------|--------| +| **배치 서버 분리** | commerce-batch에서 단일 실행 | 중간 (코드 이동) | +| 분산 락 (ShedLock) | 매 실행마다 Redis 락 경쟁 | 낮음 (라이브러리) | +| 리더 선출 | 한 인스턴스만 스케줄러 실행 | 높음 | + +**권장: 배치 서버 분리.** commerce-batch가 이미 존재하고 Redis 의존성도 있다. +구조적으로 단일 실행이 보장되며, 분산 락의 매 실행 경쟁 비용이 없다. + +#### 현재 판단 + +단일 인스턴스 운영 중이므로 즉시 이동은 불필요하다. +스케일아웃 시점에 commerce-batch로 이동한다. + +### 9.9 Redis 장애 시 fallback — 트래픽 제어의 대체 + +#### 문제 정의 + +대기열의 존재 이유는 **트래픽 제어**다. Redis가 죽으면 트래픽 제어가 사라진다. +따라서 fallback의 목표는 "대기열을 대체"하는 것이 아니라 "**트래픽 제어를 유지**"하는 것이다. + +#### 현재 상태: Redis 완전 장애 = 주문 전면 차단 + +``` +EntryTokenInterceptor.exists() → Redis 장애 → RedisConnectionFailureException +→ 500 Internal Server Error → 주문 불가 +``` + +토큰 검증이 Redis에 의존하므로, Redis 장애 시 주문이 전면 차단된다. +이는 대기열 없이 서비스를 보호하기 위한 의도적 설계가 아니라, 단순한 장애 전파다. + +#### fallback 전략 비교 + +| 전략 | 원리 | Redis 의존 | 적합성 | +|------|------|-----------|--------| +| 서비스 전면 차단 | 모든 요청 거부 | 없음 | 매출 손실 | +| 모든 요청 수용 | 제어 없이 통과 | 없음 | DB 붕괴 위험 | +| 로컬 메모리 큐 | JVM 내 대기열 | 없음 | GC 압박, OOM | +| Kafka 발행 | 메시지 큐로 전환 | 없음 | 과한 설계 | +| **부분 차단 / 샘플링** | N%만 통과 | **없음** | 단순, 효과적 | +| **로컬 Rate Limit** | 인스턴스당 초당 N건 | **없음** | 단순, 정밀 | +| **가짜 큐 (지연 응답)** | 확률적 지연으로 retry 유도 | **없음** | 자연스러운 분산 | + +#### 권장 방향: 로컬 Rate Limit fallback + +``` +정상 시: + EntryTokenInterceptor → Redis EXISTS → 토큰 있으면 통과 + +Redis 장애 시: + EntryTokenInterceptor → Redis EXISTS 실패 감지 + → 로컬 Rate Limiter로 전환 (인스턴스당 초당 80건) + → DB 커넥션 풀 보호 유지 + +Rate Limit 산정: + 단일 인스턴스: 80 req/sec (= 설계값 TPS) + 3대 스케일아웃: 27 req/sec/instance (= 80 / 3) +``` + +핵심: fallback은 Redis에 의존하면 안 된다. 로컬에서, 즉시, 독립적으로 작동해야 한다. + +### 9.10 배치 크기 튜닝 프로세스 + +#### 4단계 프로세스 + +``` +[1] 시스템 한계 파악 + 앱 서버 최대 TPS, DB/Redis safe TPS 중 가장 낮은 값이 실제 한계 + +[2] 초기값 설정 (역산) + batch_size = safe_TPS × 스케줄러_주기 + +[3] 부하 테스트 검증 (2가지 시나리오) + Burst Traffic: 10만명/1초 → 큐가 폭풍을 버티는가? + Sustained Load: 80~100% TPS 지속 → 장시간 안정적인가? + +[4] 메트릭 관찰 + 튜닝 +``` + +#### 관찰해야 할 메트릭 4가지 + +| 메트릭 | 의미 | 건강 기준 | +|--------|------|----------| +| Queue Lag | 대기열 소비 처리량 | 1초 이하 = 깔끔 | +| Queue Length 추세 | 큐 길이 시간별 변화 | 계속 증가 = 처리량 < 유입량 | +| Batch Throughput | N개 활성화에 걸리는 시간 | 높으면 배치 크기 줄여야 | +| Downstream 지표 | DB QPS, API p95, Error Rate | 안정적 유지 | + +#### 튜닝 3단계 (점진적 복잡도) + +``` +(1) batch_size 조정 — 가장 쉬움 + 예: 8 → 10 → 12 (ρ 모니터링하며 점진적) + +(2) 스케줄러 주기 조정 — 좀 쉬움 + 예: 100ms → 200ms (배치 크기도 함께 조정) + +(3) 동적 batch_size — 가장 복잡, 가장 강력 + queue_length에 따라 적응적 조절: + queue > 30,000 → batch 12 (120 TPS, ρ=0.32) + queue > 10,000 → batch 10 (100 TPS, ρ=0.27) + 그 외 → batch 8 (80 TPS, ρ=0.21) +``` + +#### 우리의 현재 상태 + +| 단계 | 상태 | 비고 | +|------|------|------| +| 시스템 한계 파악 | 완료 | DB pool 40, p99 358ms → safe TPS=78 (§2) | +| 초기값 설정 | 완료 | 8명/배치, 100ms 주기 (§2.3) | +| Burst 테스트 | **미실시** | 10만명 동시 접속 시나리오 미검증 | +| Sustained 테스트 | 부분 완료 | 2차 테스트 50초 (§6.3), 장시간 미검증 | +| Queue Lag | 부분 측정 | wait_time avg 761ms (§6.2) | +| Queue Length 추세 | **미관찰** | 시간에 따른 큐 길이 변화 미측정 | +| Batch Throughput | **미측정** | Lua 스크립트 실행 시간 미측정 | +| Downstream 지표 | 완료 | HikariCP, p99, 실패율 (§6.3) | +| 튜닝 (1) batch_size | 완료 | 14→8 보정 | +| 튜닝 (2) 주기 조정 | 미적용 | 100ms 고정 | +| 튜닝 (3) 동적 batch | 미적용 | 고정 배치 | + +동적 배치 적용 시 주의: §7 인사이트("latency는 부하의 함수")에 따라, +배치 크기를 올리면 p99가 비선형으로 증가한다. +**반드시 실측 후 적용해야 하며, 이론 계산만으로 올리면 안 된다.** + +### 9.11 TTL 설계 — 3계층 모델과 Grace Period + +#### 3계층 TTL 모델 + +TTL을 단일 값이 아닌 3계층으로 설계할 수 있다: + +| 계층 | 역할 | 동작 | +|------|------|------| +| **Access TTL** | 입장 직후 기본 시간 | 토큰 발급 시 설정 (예: 5분) | +| **Activity TTL** | 활동 시 연장 | API 요청마다 +60초 리셋 | +| **Hard TTL** | 절대 상한 | 아무리 연장해도 초과 불가 (예: 10분) | + +Activity TTL이 있으면 활발한 유저는 만료되지 않고, 이탈 유저만 자연 만료된다. +Hard TTL이 매크로/스크립트의 무한 갱신을 방지한다. + +#### 우리의 선택: 넉넉한 단일 TTL (900초) + +``` +우리 설계: + Access TTL = 900초 (= Hard TTL 겸용) + Activity TTL = 없음 + Grace Period = 없음 +``` + +Activity TTL 없이도 괜찮은 이유: + +``` +TTL 900초 vs 유저 체류 시간: + 80% → 3분 내 완료 → 여유 12분 + 15% → 3~7분 내 완료 → 여유 8~12분 + 5% → 이탈 → 15분 후 자연 만료 + +→ 99%의 유저가 활동 연장 없이도 충분한 시간을 보유 +→ Activity TTL의 복잡도(갱신 로직, Hard TTL 관리) 없이 동일 효과 +``` + +Access TTL이 5분처럼 짧았다면 Activity TTL이 필수였겠지만, +900초로 넉넉하게 잡아 복잡도를 낮췄다 (시스템 비용은 미미, §2.8). +이벤트 시 `queue.token.ttl-seconds=1800`으로 설정 변경만으로 대응 가능. + +#### Grace Period — 향후 검토 대상 + +``` +TTL 만료 후 즉시 퇴장 vs Grace Period: + +현재: + TTL 만료 → NOT_IN_QUEUE → 재진입 필요 (블프 시 10분 재대기) + +Grace Period 적용 시: + TTL 만료 → grace 60초 동안 복구 가능 → 이 안에 요청하면 토큰 재발급 + → grace도 지나면 진짜 만료 + +구현 방안: + 토큰 만료 후 "grace:token:{memberId}" 키를 60초 TTL로 생성 + EntryTokenInterceptor에서 토큰 없으면 grace 키 확인 → 있으면 토큰 재발급 +``` + +블프에서 10분 대기 후 입장한 유저가 잠깐의 방심으로 만료되어 다시 10분 대기하는 건 +매출 손실로 직결된다. Grace period는 이 시나리오를 최소 비용으로 완화한다. + +--- + +## 리뷰 포인트 (멘토 질문) + +### RP-1: 대기열 수용 한계(48,000명)를 인프라 자원 내에서 최대화하는 방법 + +현재 max_queue = 입장 속도(80 TPS) × 최대 대기 시간(600초) = 48,000명. +QUEUE_FULL 이후 도착하는 유저는 진입 자체가 거부되어 유실된다. + +**질문**: 동일 인프라(DB 풀 40, Redis 단일 노드) 조건에서 QUEUE_FULL 한계를 최대한 키우려면 어떤 설계 변경이 가능한가? + +현재 생각하는 방향: +- **입장 속도 증가**: DB 성능 개선(인덱스, 쿼리 최적화)으로 p99 단축 → TPS 증가 → 같은 10분에 더 많은 유저 수용 +- **최대 대기 시간 증가**: 600초 → 900초 (유저 인내 한계를 더 높게 잡기), 하지만 UX 트레이드오프 +- **입장 후 처리 속도 향상**: 토큰 소비를 비동기화하여 커넥션 점유 시간 단축 +- **대기열 외부화**: Redis 메모리가 병목이면 디스크 기반 큐(Kafka 등), 하지만 현재 ~4.3MB로 메모리는 병목 아님 + +핵심은 **역산 체인에서 어느 변수를 건드리는 게 가장 효과적인가**, 그리고 **우리가 놓치고 있는 접근법이 있는가**를 듣고 싶다. + +--- + +## 10. 보완 및 수정 이력 + +| 일자 | 변경 | 이유 | +|------|------|------| +| 2026-04-02 | 초기 구현 완료 | Round 8 대기열 시스템 v1 (배치 14명) | +| 2026-04-02 | 1차 부하 테스트 | p99 358ms 측정, 풀 초과 위험 발견 | +| 2026-04-02 | 배치 크기 14→8 보정 | p99 기준 Little's Law 역산: 28/0.358=78 TPS → 8명/배치 | +| 2026-04-02 | 2차 부하 테스트 (혼합 트래픽) | p99 107ms, 실패율 0%, HikariCP max active 8/31 (26%) | +| 2026-04-03 | 대기열 한계(48,000) + 타임아웃(600초) 추가 | max_queue = 80 TPS × 600초, 10초 주기 정리 스케줄러 | +| 2026-04-03 | Queuing Theory 분석 추가 | ρ=0.214(안전), 배치 크기별 예측, Tomcat-DB 캐스케이드 분석 | +| 2026-04-03 | 토큰 TTL 300→900초 + 설정값 외부화 | 체류 모델 산정, queue.token.ttl-seconds 프로퍼티 추가 | +| 2026-04-03 | 역산 체인 + 운영 가이드 추가 | 모든 수치의 유도 과정, 시나리오별 설정, 모니터링 기반 조정 플로우 | +| 2026-04-03 | 단일 큐 vs 다중 큐 설계 검증 추가 | 멘토 리뷰 기반, 처리량-순서보장 트레이드오프 분석 | +| 2026-04-03 | POP 후 토큰 유실 분석 + Lua 원자화 구현 | 멘토 리뷰 기반, ZPOPMIN+SET EX 원자적 처리 | +| 2026-04-03 | Redis 장애 유형별 대응 분석 추가 | 멘토 리뷰 기반, 4계층(프로세스/서버/클러스터/논리) 장애 점검 | +| 2026-04-03 | 대기열 활성화 전략 분석 추가 | 멘토 리뷰 기반, 항상 켜짐 vs 수동/자동 온오프 비교 | +| 2026-04-03 | 이중 상태 회피 설계 근거 추가 | 멘토 리뷰 기반, active_tokens 없는 단일 상태 설계의 이점 | +| 2026-04-03 | 다중 인스턴스 스케줄러 분석 추가 | 멘토 리뷰 기반, 스케일아웃 시 배치 서버 분리 필요 | +| 2026-04-03 | Redis 장애 시 fallback 전략 분석 추가 | 멘토 리뷰 기반, 로컬 Rate Limit fallback 권장 | +| 2026-04-03 | 배치 크기 튜닝 프로세스 정리 | 멘토 리뷰 기반, 4단계 프로세스 + 동적 배치 가능성 | +| 2026-04-03 | TTL 3계층 모델 + Grace Period 분석 추가 | 멘토 리뷰 기반, 단일 TTL 900초 선택 근거 보강 | +| 2026-04-03 | 동적 Polling 주기 구현 (§4.6) | suggestedPollIntervalMs 필드 추가, 구간별 1/3/5초 차등, Redis 부하 59% 감소 | +| 2026-04-03 | 커스텀 메트릭 + Grafana 대시보드 구현 (§4.8) | MeterRegistry 기반 7개 메트릭, 9패널 대시보드, Safe TPS 실시간 계산 | +| 2026-04-03 | Graceful Degradation 구현 (§4.7) | Redis 장애 시 로컬 Rate Limiter fallback, 80 req/sec, 자동 복구 | +| 2026-04-03 | SSE 실시간 순번 Push 구현 (§4.5) | Delta 기반 브로드캐스트, 최대 5,000 SSE 연결, Polling fallback | +| 2026-04-03 | BF 5급간 부하 테스트 실행 (3차) | 1000VU, QUEUE_FULL 미도달, order p99=4.33s (threshold 초과), 원인 분석 기록 | +| 2026-04-03 | Grafana 모니터링 분석 추가 (§6.4) | Prometheus 15초 샘플링 데이터 분석: ρ=1.0, pending=64, admission rate 75 req/s. 양의 피드백 루프 대규모 재현 확인 | +| 2026-04-03 | QUEUE_FULL 검증 부하 테스트 (§6.5, 4차) | Open-loop 전환, iterationInTest 기반 userId, max_queue=1,000. QUEUE_FULL 2,988건(10.4%) 발동 성공 | +| 2026-04-03 | MAX_QUEUE_SIZE 외부화 | `@Value("${queue.max-size:48000}")` — 런타임 설정 변경 가능, 테스트 시 1,000으로 축소 | + +--- + +## 11. 향후 과제 + +- [x] p99 레이턴시 측정 → 배치 크기 14→8 보정 완료 +- [x] 부하 테스트 실행 및 결과 기록 (1차 + 2차) +- [x] 유저 수 늘린 부하 테스트 (100명, 토큰 경합 없음) 완료 +- [x] 커넥션 풀 사용률 모니터링 (HikariCP max active 8/31, pending 항상 0) +- [ ] 배치 크기 점진적 증가 테스트 (8→10→12, ρ ≤ 0.7 + HikariCP pending=0 검증) +- [ ] 주문 외 다른 API(상품 조회 등)에 대한 트래픽 보호 — 커넥션 풀 분리 또는 Rate Limiting 검토 +- [x] ZPOPMIN + 토큰 발급 Lua 스크립트 원자화 — 유실 윈도우 제거 완료 +- [ ] 스케일아웃 시 QueueAdmissionScheduler를 commerce-batch로 이동 — 스케줄러 중복 실행 방지 +- [x] EntryTokenInterceptor에 로컬 Rate Limit fallback 추가 — Redis 장애 시 주문 전면 차단 방지 (§4.7) +- [x] BF 5급간 부하 테스트 (1000 VU) — QUEUE_FULL 미도달, order p99=4.33s (row lock 경합 발견) +- [x] QUEUE_FULL 검증 (4차) — Open-loop + max_queue=1,000으로 QUEUE_FULL 2,988건(10.4%) 발동 성공 (§6.5) +- [ ] Row lock 경합 완화 방안 검토 — 낙관적 락 또는 Redis 재고 선차감 +- [ ] 동적 배치 크기 검토 — queue_length에 따른 적응적 batch_size (실측 선행 필수) +- [ ] Grace Period 검토 — TTL 만료 후 60초 복구 기회, 블프 UX 개선 +- [x] SSE 실시간 순번 Push — Delta 기반 브로드캐스트, 최대 5,000 SSE 연결 (§4.5) +- [x] 동적 Polling 주기 — 구간별 1/3/5초 차등 제공, Redis 부하 59% 감소 (§4.6) +- [x] 커스텀 메트릭 + Grafana 대시보드 — Safe TPS 실시간 계산 패널 포함 (§4.8) +- [ ] SSE 다중 인스턴스 지원 — Redis Pub/Sub 기반 인스턴스 간 delta 브로드캐스트 +- [ ] SSE 부하 테스트 — 5,000 동시 SSE 연결 시 Tomcat NIO 채널 + 메모리 사용량 검증 +- [ ] Grafana 알림 설정 — ρ > 0.7, pending > 0 시 Slack/email 알림 +- [ ] Grafana 자동 캡처 파이프라인 — 이슈 발생 시 대시보드 스냅샷 캡처 → 이슈 리포트 생성 → Slack 알림 (x86에서 Image Renderer 플러그인 또는 Playwright) diff --git a/docs/design/images/grafana-queue-dashboard-bf-queue-full-test.png b/docs/design/images/grafana-queue-dashboard-bf-queue-full-test.png new file mode 100644 index 0000000000..04e76217d7 Binary files /dev/null and b/docs/design/images/grafana-queue-dashboard-bf-queue-full-test.png differ diff --git a/docs/design/images/grafana-queue-dashboard-bf-test.png b/docs/design/images/grafana-queue-dashboard-bf-test.png new file mode 100644 index 0000000000..59a1686178 Binary files /dev/null and b/docs/design/images/grafana-queue-dashboard-bf-test.png differ diff --git a/docs/design/volume-10/10-batch-analysis-report.md b/docs/design/volume-10/10-batch-analysis-report.md new file mode 100644 index 0000000000..c13306921e --- /dev/null +++ b/docs/design/volume-10/10-batch-analysis-report.md @@ -0,0 +1,629 @@ +# 10. 배치 어플리케이션 분석 보고서 + +> 배치 앱 2개(production 브랜치)를 분석하고, Spring Batch 주간/월간 랭킹 MV 적재에 적용할 인사이트를 추출한 보고서. + +--- + +## 분석 대상 + +| 배치 앱 | 도메인 | Job 수 | 핵심 역할 | +|---------|--------|--------|----------| +| **aurora-x2bee-batch-gddp** (배치 A) | 상품/전시/검색 | 49개 | 상품 리뷰 집계, 검색 인덱스 적재, 베스트/신상품 산정, SAP 연동 | +| **aurora-x2bee-batch-mbod** (배치 B) | 주문/회원/정산 | 48개 | 마일리지 소멸, 회원 등급 변경, 매출/재고 통계, PG 정산 대사 | + +--- + +## 1. 배치 A — aurora-x2bee-batch-gddp (상품/전시/검색) + +### 구조 분석 + +| 항목 | 내용 | +|------|------| +| **총 Job 수** | 49개 (Tasklet 36 + Chunk 8 + Stub 5) | +| **주요 도메인** | 전시(display), 이벤트(event), 상품(goods), 검색(search), 입점사(vendor) | +| **처리 모델** | **Tasklet 73% / Chunk 16% / Stub 11%** | +| **DB** | PostgreSQL + MySQL, RODB/RWDB 분리 (5쌍) | +| **ORM** | MyBatis 중심 (34개 XML 매퍼) | +| **Spring Boot** | 3.3.4, Java 17 | + +### Job 카테고리별 분포 + +| 카테고리 | Job 수 | 처리 모델 | 대표 Job | +|---------|--------|----------|---------| +| Display | 3 | Tasklet | GoodsBestJob, GoodsNewJob | +| Event | 9 | Tasklet | BatEventState, BatMbrBase | +| Goods | 15 | Tasklet + 일부 Chunk | GoodsReviewTotalJob, GoodsSoldOutJob | +| Search | 13 | Tasklet + Chunk | SearchProductChunkLoad, SearchProductIndex | +| Vendor | 4 | Tasklet | EtEntrEvltDayAgrt, VenderEndContract | +| Sample | 6 | Chunk (학습용) | SampleJdbc, SampleMyBatisCursor | + +### Reader 패턴 + +| Reader | 사용처 | 특징 | +|--------|-------|------| +| **MyBatisCursorItemReader** | 검색 상품 로드, 샘플 | 커서 스트리밍, 메모리 효율적 | +| **MyBatisPagingItemReader** | 검색 인덱스 (pageSize=10,000) | ExecutionContext 저장으로 재시작 가능 | +| **JdbcCursorItemReader** | 샘플 (fetchSize=1,000) | BeanPropertyRowMapper 사용 | +| **JdbcPagingItemReader** | 샘플 (pageSize=1,000) | SqlPagingQueryProviderFactoryBean | +| **FlatFileItemReader** | 샘플 (CSV) | linesToSkip=1, ClassPathResource | + +### Writer 패턴 + +- **CompositeItemWriter**: 다중 Writer를 순차 실행 (UPDATE + INSERT 조합) +- **MyBatisBatchItemWriter**: `assertUpdates(false)` — 영향 행 0건이어도 에러 아님 +- **커스텀 Lambda Writer**: 검색 Job에서 REST API 호출 (200건씩 서브 배치) +- **UPSERT**: `INSERT ... ON DUPLICATE KEY UPDATE` (GoodsReviewTotal 등 집계 Job) + +### SQL 특징 + +- **GROUP BY + SUM/COUNT/AVG** 집계를 Reader SQL에서 처리 +- **LEFT JOIN LATERAL**: 상관 서브쿼리로 복잡한 조인 +- **동적 조건 분기**: `batchTyp` 파라미터(A/R/D/M/AFTERDATE)에 따라 WHERE 절 변경 +- **시간 기반 필터링**: `DATE_SUB(NOW(), INTERVAL 60 MINUTE)` 등 증분 처리 + +### 에러 처리 + +- **SingleJobExecutionListener**: 중복 실행 방지 (같은 Job이 이미 실행 중이면 예외) +- **StepExecutionListener** (검색 인덱스): `beforeStep()`에서 배치 프로세스 카운트 체크, `afterStep()`에서 메타데이터 갱신 +- **Skip/Retry 없음**: 실패 시 즉시 종료 + +### 내 과제 시사점 + +- **집계 쿼리를 Reader SQL에서 처리하는 패턴**이 핵심 참고 대상. `GROUP BY product_id`로 일간 메트릭을 기간별로 합산하는 것은 Reader SQL에서 처리 가능 +- **UPSERT 패턴** (`INSERT ... ON DUPLICATE KEY UPDATE`)이 MV 갱신 대안 중 하나 +- **batchTyp 파라미터로 동일 Job에서 주간/월간 분기**하는 방식 — 하나의 Job Config로 scope 파라미터를 받아 처리 가능 +- **MyBatisCursorItemReader가 대량 조회의 기본 선택** — 기존 RankingCorrectionJob의 JdbcCursorItemReader와 동일한 전략 + +--- + +## 2. 배치 B — aurora-x2bee-batch-mbod (주문/회원/정산) + +### 구조 분석 + +| 항목 | 내용 | +|------|------| +| **총 Job 수** | 48개 (Tasklet 46 + Chunk 2) | +| **주요 도메인** | 정산(adjust), 배송(delivery), 회원(member), 주문(order), **통계(statistics)** | +| **처리 모델** | **Tasklet 96% / Chunk 4%** — 마일리지 소멸, 회원 등급 변경만 Chunk | +| **DB** | MySQL, RODB/RWDB 분리 (6쌍) | +| **ORM** | MyBatis 중심 (68개 XML 매퍼) | +| **Spring Boot** | 3.3.4, Java 17 | + +### Chunk-Oriented Job 상세 (2개) + +**① mileageRemoveJob (CHUNK_SIZE=1,000)** + +``` +Reader: MyBatisCursorItemReader → getExpireMileageList (만료 마일리지 조회) +Processor: MbrAsstResponse → MileageExpireRequestVo (변환) +Writer: CompositeItemWriter (3개) + ├── UPDATE: 기존 이력 마감 처리 + ├── INSERT: 소멸 이력 생성 + └── UPDATE: 잔액 합계 갱신 +``` + +**② memberGradeChangeJob (CHUNK_SIZE=100, Multi-Step)** + +``` +Step 1: memberGradeCalcStep (Tasklet) → 등급 산정 → FAILED 시 종료 +Step 2: memberGradeChangeStep (Chunk) + Reader: MyBatisCursorItemReader → getMbrGradeChangeList + Processor: 등급 변경 대상 변환 + Writer: CompositeItemWriter (3개) + ├── UPDATE: 회원 등급 변경 + ├── UPDATE: 이전 등급 이력 종료 + └── INSERT: 새 등급 이력 생성 +Step 3: memberGradeCouponIssueStep (Tasklet) → 등급 변경 쿠폰 발급 +``` + +### 통계 Job 분석 (10개, 모두 Tasklet) + +| Job | 내용 | +|-----|------| +| orderSaleStatisticsJob | 주문 매출 통계 | +| orderSaleStatisticsByGoodsJob | 상품별 매출 통계 | +| orderSaleStatisticsByCouponJob | 쿠폰별 매출 통계 | +| memberOrderStatisticsJob | 회원별 주문 통계 | +| inventoryStatisticsByGoodsJob | 상품별 재고 통계 | +| paymentMethodStatisticsJob | 결제수단별 통계 | +| aggregateBasketJob | 장바구니 집계 | +| dailyGoodsDetailInflowStatisticsJob | 일간 상품 상세 유입 통계 | +| dailyUmamiInflowStatisticsJob | 일간 유입 통계 | +| infDispCtgStatisticsJob | 전시 카테고리 통계 | + +> **주목**: 통계/집계 Job이 10개인데 **전부 Tasklet**. 회사에서는 "Tasklet 내부에서 직접 SQL로 집계 → INSERT"하는 패턴을 선호. + +### 에러 처리 + +- **SingleJobExecutionListener**: gddp와 동일 (중복 실행 방지) +- **Multi-Step 조건 분기**: `memberGradeChangeJob`에서 Step 1 실패 시 `.on("FAILED").end()`로 후속 Step 스킵 +- **Skip/Retry 없음** + +### 내 과제 시사점 + +- **통계/집계에 Tasklet을 쓰는 이유**: SQL 한 방(GROUP BY + INSERT INTO ... SELECT)으로 처리 가능한 경우 Reader/Processor/Writer 패턴이 오히려 과잉. Chunk는 "행 단위 변환"이 필요할 때만 사용 +- **CompositeItemWriter로 다중 테이블 갱신**: MV 적재 시 "기존 데이터 삭제 → 새 데이터 삽입"을 하나의 트랜잭션에서 처리하는 패턴 +- **Multi-Step 조건 분기**: Step 1에서 데이터 검증/전처리 → Step 2에서 본 처리 — 내 과제에서 "기존 MV 삭제 Step → 집계 적재 Step"으로 활용 가능 +- **UniqueRunIdIncrementer**: `System.currentTimeMillis()`로 run.id 생성 → 같은 파라미터로 재실행 가능 (멱등성과 관련) + +--- + +## 3. 비교 테이블 + +| 비교 항목 | 배치 A (gddp) | 배치 B (mbod) | **내 과제 (추천)** | **근거** | +|----------|--------------|--------------|-------------------|---------| +| **처리 모델** | Tasklet 73% / Chunk 16% / Stub 11% | Tasklet 96% / Chunk 4% | **Chunk-Oriented + Partitioning** | 대규모 집계 병렬 처리. Tasklet이 효율적인 경우도 있지만, Chunk의 운영 기능(retry, 모니터링) 활용 | +| **Reader 타입** | MyBatisCursorItemReader 주력 | MyBatisCursorItemReader (2건) | **JdbcCursorItemReader** | 기존 RankingCorrectionJob과 일관성 유지. 집계 쿼리가 단순하므로 MyBatis 매퍼 오버헤드 불필요 | +| **비즈니스 로직 위치** | Reader SQL에서 GROUP BY 집계 수행 | Tasklet 내부에서 SQL 직접 실행 | **Reader SQL에서 집계 + Processor에서 score 계산** | GROUP BY는 DB가 효율적, score 공식(log₁₀ 정규화)은 Java 코드가 명확 | +| **Writer 전략** | UPSERT (`ON DUPLICATE KEY UPDATE`) | CompositeItemWriter (UPDATE+INSERT) | **DELETE+INSERT** (기간별 전체 교체) | TOP 100만 저장하므로 UPSERT보다 DELETE+INSERT가 단순. 멱등성 자동 보장 | +| **멱등성 보장** | UniqueRunIdIncrementer (매번 새 실행) | UniqueRunIdIncrementer + batchDate | **DELETE+INSERT로 자연 멱등성** | 같은 기간 데이터를 삭제 후 재적재 → 2회 실행해도 결과 동일 | +| **에러 처리** | SingleJobExecutionListener | SingleJobExecutionListener + Step Flow | **SingleJobExecutionListener + Multi-Step Flow** | Step 1(삭제) 실패 시 Step 2(적재) 미실행으로 데이터 보호 | +| **실행 방식** | REST API 트리거 | REST API 트리거 | **CommandLineRunner 또는 스케줄러** | commerce-batch 모듈의 기존 실행 방식 따르기 | +| **DB 분리** | RODB/RWDB 분리 (5쌍) | RODB/RWDB 분리 (6쌍) | **단일 DataSource** | 현재 규모에서 불필요. 스케일아웃 시 분리 고려 | + +--- + +## 4. 내 과제 설계 제안 + +### 핵심 인사이트 + +회사 배치 코드에서 배운 가장 중요한 점: + +> **"통계/집계 Job은 대부분 Tasklet으로 SQL 한 방 처리한다."** +> 그러나 과제 요구사항이 Chunk-Oriented 학습이므로, **Reader SQL에서 집계 → Processor에서 score 계산/순위 산정 → Writer에서 MV 적재**하는 구조가 적합하다. + +### 설계 질문 답변 + +**Q1. Reader: JdbcCursorItemReader vs JdbcPagingItemReader** + +→ **JdbcCursorItemReader 추천.** 두 회사 앱 모두 CursorItemReader를 주력으로 사용. 집계 쿼리 결과(상품 수 = 수천~수만 행)는 커서로 충분히 처리 가능하고, 정렬 순서 보장도 자연스럽다. PagingReader는 집계 쿼리에서 OFFSET 기반 페이징 시 데이터 누락 위험이 있다. + +**Q2. Processor vs SQL** + +→ **SQL에서 GROUP BY 집계, Processor에서 score 계산.** gddp의 GoodsReviewTotal이 이 패턴을 사용한다. DB가 잘하는 것(집계)은 DB에, 비즈니스 공식(log₁₀ 정규화 + tiebreaker)은 Java 코드에. + +**Q3. Writer 전략: DELETE+INSERT vs UPSERT** + +→ **DELETE+INSERT 추천.** TOP 100만 저장하므로 UPSERT로 처리하려면 "이번 주 TOP 100에서 빠진 상품"을 별도로 삭제해야 한다. 기간 키 기준 DELETE 후 INSERT가 단순하고 멱등성도 자동 보장. mbod의 통계 Job들도 이 패턴을 사용한다. + +**Q4. 멱등성** + +→ **"DELETE WHERE period_key = ? → INSERT" 패턴으로 자연 멱등성.** UniqueRunIdIncrementer로 같은 파라미터로 재실행 허용 + 적재 전 기존 데이터 삭제 → 몇 번을 돌려도 결과 동일. + +**Q5. Redis vs MV 공존** + +→ **Redis = Speed Layer (실시간 근사치), MV = Batch Layer (DB 원장 기반 정확값).** API에서 scope별로: + +- `daily` → Redis ZSET (기존 유지) +- `weekly/monthly` → **MV 우선, Redis fallback** (MV가 DB 원장 기반이므로 정확도 우위. Redis 장애 시에도 조회 가능) + +### 구체적 Job 구조 제안 + +``` +WeeklyMonthlyRankingJob + ├── Parameter: targetDate, scope(weekly/monthly) + │ + ├── Step 1: cleanupStep (Tasklet) + │ └── DELETE FROM mv_product_rank_{scope} WHERE period_key = ? + │ + └── Step 2: aggregateStep (Chunk, chunkSize=1000) + ├── Reader: JdbcCursorItemReader + │ └── SELECT product_id, SUM(view_count), SUM(like_count), SUM(order_count) + │ FROM product_metrics + │ WHERE metric_date BETWEEN ? AND ? + │ GROUP BY product_id + │ + ├── Processor: score 계산 (기존 Score v2 공식 재활용) + │ └── 0~1 정규화 + log₁₀ + tiebreaker → 순위 산정 + │ + └── Writer: JdbcBatchItemWriter + └── INSERT INTO mv_product_rank_{scope} + (product_id, rank, score, view_count, like_count, order_count, period_key) +``` + +--- + +## 5. 심층 분석: 통계 Tasklet 내부 SQL 패턴 + +> mbod의 통계 Job 10개 + 대시보드 Job 1개의 실제 SQL을 분석하여 패턴을 분류했다. + +### SQL 패턴 분류 + +| 패턴 | 해당 Job | 특징 | +|------|---------|------| +| **DELETE + INSERT...SELECT...GROUP BY** | InfDispCtgStatistics, InventoryStatisticsByGoods, MemberOrderStatistics, OrderSaleStatisticsByGoods, PaymentMethodStatistics | 가장 흔한 패턴. 날짜 기준 DELETE 후 SQL 한 방으로 집계+적재 | +| **INSERT...SELECT + ON DUPLICATE KEY UPDATE** | DailyUmamiInflowStatistics, OrderSaleStatistics, DashboardOrderSale | UPSERT 패턴. CTE + UNION ALL로 복잡한 다차원 집계 | +| **SELECT → Java 루프 → foreach INSERT** | AggregateBasket | Java에서 변환 후 벌크 INSERT | +| **SELECT → Java 루프 → foreach MERGE** | DailyGoodsDetailInflowStatistics | Java에서 변환 후 개별 UPSERT | + +### 패턴별 상세 + +**패턴 1: DELETE + INSERT...SELECT (5개 Job, 가장 일반적)** + +```sql +-- Step 1: 기간 데이터 삭제 +DELETE FROM sm_daycl_inf_ord_agrt WHERE agrt_dt = #{agrtDt} + +-- Step 2: 집계 결과 직접 적재 +INSERT INTO sm_daycl_inf_ord_agrt (agrt_dt, goods_no, ord_cnt, ...) +SELECT #{agrtDt}, goods_no, COUNT(*), ... +FROM sm_daycl_ord_agrt +WHERE agrt_std_dt = #{agrtDt} +GROUP BY goods_no +``` + +- **멱등성**: DELETE로 기존 데이터 제거 → INSERT로 재적재. 자연 멱등 +- **적합**: 일간/날짜 기준 집계. 내 과제의 MV 갱신에 가장 적합한 패턴 +- **특징**: MemberOrderStatistics는 `CASE WHEN`으로 나이대 버킷팅, InventoryStatistics는 2개 CTE로 상품/아이템 재고 결합 + +**패턴 2: INSERT...SELECT + ON DUPLICATE KEY UPDATE (3개 Job, 가장 복잡)** + +```sql +-- OrderSaleStatistics: ~410줄 SQL +WITH DAY_INFO AS (...), + BNF_INFO AS (...), + ORD_DTL_INFO AS (...) +INSERT INTO sm_daycl_ord_agrt (agrt_std_dt, entr_no, ord_sales_cnt, ...) +SELECT ... +FROM ORD_DTL_INFO +-- 4개 UNION ALL: 주문접수/주문완료 × 정상/취소 +UNION ALL ... +ON DUPLICATE KEY UPDATE + ord_sales_cnt = VALUES(ord_sales_cnt), + ... +``` + +- **멱등성**: PK 충돌 시 UPDATE로 덮어쓰기. 자연 멱등 +- **적합**: 다차원 집계 + Late-Arriving Fact 대응 (배송 완료가 주문일 이후 도착) +- **특징**: OrderSaleStatistics가 가장 복잡(410줄). 3개 CTE + 4개 UNION ALL로 주문접수/완료, 정상/취소를 분리 집계 + +**패턴 3: SELECT → Java → 벌크 INSERT (1개 Job)** + +```java +// AggregateBasketServiceImpl +List list = mapper.getBasketAgrtList(param); // CTE + GROUP BY +trxMapper.deleteAll(); // 전체 삭제 +trxMapper.insertBulkSmBasketAgrt(list); // foreach INSERT +``` + +- **멱등성**: deleteAll → insertBulk. 전체 교체 +- **적합**: Java에서 추가 변환이 필요한 경우 +- **특징**: 읽기 쿼리에 `ROW_NUMBER() OVER (PARTITION BY)` 윈도우 함수 사용 + +**패턴 4: SELECT → Java → 개별 MERGE (1개 Job)** + +```java +// DailyGoodsDetailInflowStatisticsServiceImpl +List list = umamiMapper.getDailyGoodsDetailInflowAgreementList(param); +for (GoodsInflowAgrt item : list) { + trxMapper.mergeSmDayclGoodsInfAgrt(item); // INSERT...ON DUPLICATE KEY UPDATE +} +``` + +- **멱등성**: 개별 UPSERT. 자연 멱등 +- **적합**: 외부 시스템(Umami) 데이터를 Java로 변환 후 적재 +- **비효율**: 행 단위 UPSERT → 대량 데이터에서 성능 저하 (GoodsReviewTotal과 동일한 문제) + +### 통계 Job 간 의존 관계 + +``` +OrderSaleStatisticsJob (원천 집계) + ├── OrderSaleStatisticsByGoodsJob (상품별 재집계) + ├── PaymentMethodStatisticsJob (결제수단별 재집계) + └── InfDispCtgStatisticsJob (전시 카테고리별 재집계) +``` + +> **시사점**: 내 과제에서도 주간/월간 Job이 일간 product_metrics에 의존하므로, 실행 순서 관리가 필요하다. + +### 내 과제에 대한 결론 + +**DELETE + INSERT...SELECT 패턴이 가장 적합.** 이유: + +1. 회사 통계 Job 10개 중 5개(50%)가 이 패턴 사용 — 가장 일반적 +2. 내 과제의 MV(TOP 100)는 전체 교체가 자연스러움 (순위가 바뀌므로 증분 갱신 불가) +3. 멱등성 자동 보장 +4. SQL 복잡도가 낮아 유지보수 용이 + +다만 과제 요구사항이 **Chunk-Oriented**이므로, SQL 한 방(Tasklet) 대신 Reader에서 GROUP BY 집계 → Processor에서 score 계산 → Writer에서 INSERT 하는 구조로 분해한다. + +--- + +## 6. 심층 분석: UniqueRunIdIncrementer + +> 두 앱 모두 동일한 커스텀 구현을 사용한다. Spring Batch 기본 동작과의 차이를 분석했다. + +### 구현 코드 (두 앱 동일, production 브랜치) + +```java +public class UniqueRunIdIncrementer extends RunIdIncrementer { + private static final String RUN_ID = "run.id"; + + @Override + public JobParameters getNext(JobParameters parameters) { + UUID uuid = UUID.randomUUID(); + return new JobParametersBuilder() + .addString(RUN_ID, uuid + Long.toString(System.currentTimeMillis())) + .toJobParameters(); + } +} +``` + +> **이전 브랜치와의 차이**: `addLong(RUN_ID, System.currentTimeMillis())` → `addString(RUN_ID, UUID + timestamp)`. 밀리초 단위 충돌 가능성을 UUID로 해소. `Long` → `String`으로 타입도 변경. + +### Spring Batch 기본 RunIdIncrementer와의 비교 + +| 항목 | 기본 RunIdIncrementer | 커스텀 UniqueRunIdIncrementer | +|------|----------------------|------------------------------| +| **run.id 생성** | 순차 증가 (`run.id + 1`) | `UUID + System.currentTimeMillis()` (UUID + 타임스탬프) | +| **기존 파라미터** | **보존** (기존 파라미터에 run.id만 추가) | **전부 버림** (run.id만 남는 새 JobParameters 생성) | +| **Job Instance 식별** | jobName + 모든 파라미터(run.id 제외) | jobName만으로 식별 (다른 파라미터가 없으므로) | +| **재실행** | 같은 파라미터 + 새 run.id = 같은 Instance의 새 Execution | 매번 새 Execution | +| **유니크 보장** | 순차 → 충돌 없음 | 밀리초 → 동시 실행 시 이론적 충돌 가능 (극히 희소) | + +### 핵심 설계 의도 + +**"같은 Job을 언제든 제한 없이 재실행 가능하게 한다."** + +- 기본 RunIdIncrementer는 이전 파라미터를 보존하므로, `targetDate=20260414`로 실행한 Job을 다시 실행하면 **같은 Job Instance에 새 Execution**이 생긴다 +- 커스텀 UniqueRunIdIncrementer는 파라미터를 전부 버리므로, **항상 같은 Job Instance**에 매번 새 Execution이 생긴다 +- `SingleJobExecutionListener`와 조합하여 "동시 실행만 방지, 순차 재실행은 허용"하는 전략 + +### 내 과제에 대한 시사점 + +**주의: 이 패턴은 파라미터 기반 멱등성과 충돌한다.** + +내 과제에서 `targetDate`와 `scope`를 JobParameter로 받아야 하는데, UniqueRunIdIncrementer를 그대로 쓰면 **파라미터가 버려진다.** 따라서: + +| 선택지 | 장점 | 단점 | +|--------|------|------| +| **기본 RunIdIncrementer 사용** | 파라미터 보존, 같은 날짜 재실행 가능 | Spring Batch가 "이미 완료된 Instance" 에러를 낼 수 있음 | +| **UniqueRunIdIncrementer + 파라미터 직접 추가** | 재실행 자유 | 파라미터가 JobParameters에 포함되지 않아 @Value 주입 불가 | +| **커스텀 Incrementer (파라미터 보존 + 타임스탬프)** | 파라미터 보존 + 재실행 자유 | 구현 필요 | + +**추천**: 기본 `RunIdIncrementer`를 사용하되, Writer에서 DELETE+INSERT로 멱등성을 보장하는 것이 가장 단순하다. + +--- + +## 7. 심층 분석: GoodsReviewTotal UPSERT 패턴 + +> gddp의 상품 리뷰 집계 Job이 사용하는 UPSERT의 실제 SQL과 구조를 분석했다. + +### 실행 흐름 + +``` +GoodsReviewTotalJobConfig + └── GoodsReviewTotalJobTasklet (@StepScope, batchTyp/chngDtm 파라미터) + └── GoodsReviewTotalServiceImpl.run(batchTyp) + ├── Step 1: seltGoodsReviewTotalStep1() → 리뷰 집계 SELECT + ├── Step 2: insertUpdatePrGoodsRevAgrtInfo() → 행 단위 UPSERT 루프 + └── Step 3: syncGoodsSummaryRevCnt() → 전시 요약 테이블 동기화 +``` + +### 집계 SELECT (Reader 역할) + +```sql +SELECT + PGRI.GOODS_NO, + COUNT(PGRI.REV_NO) AS REV_CNT, + SUM(hlpful.HLPFUL_CNT) AS HLPFUL_CNT, + SUM(PGRI.REV_SCR_VAL) AS SUM_SCR_VAL, + ROUND(AVG(PGRI.REV_SCR_VAL), 1) AS REV_SCR_VAL_AVG_VAL +FROM pr_goods_rev_info PGRI +LEFT JOIN LATERAL ( + SELECT COUNT(REV_NO) AS HLPFUL_CNT + FROM pr_goods_rev_hlpful_info PGRHI + WHERE PGRHI.REV_NO = PGRI.REV_NO +) hlpful ON TRUE +WHERE PGRI.REV_DISP_STAT_CD = '20' + AND PGRI.DEL_YN != 'Y' + -- batchTyp에 따른 동적 조건: + -- R(실시간): PGRI.SYS_MOD_DTM BETWEEN DATE_SUB(NOW(), INTERVAL 60 MINUTE) AND NOW() + -- D(일간): PGRI.SYS_MOD_DTM >= CURDATE() + -- M(수동): PGRI.SYS_MOD_DTM >= #{chngDtm} + -- ALL: 조건 없음 (전체) +GROUP BY PGRI.GOODS_NO +``` + +**특징**: LEFT JOIN LATERAL로 리뷰별 도움 수를 상관 서브쿼리로 집계. `batchTyp`에 따라 증분/전체 선택 가능. + +### UPSERT SQL (Writer 역할) + +```sql +INSERT INTO pr_goods_rev_agrt_info ( + GOODS_NO, + REV_CNT, + HLPFUL_CNT, + REV_STARSCR_AVG_VAL, + SYS_REG_ID, SYS_REG_DTM, + SYS_MOD_ID, SYS_MOD_DTM +) VALUES ( + #{goodsNo}, + CAST(#{revCnt} AS SIGNED), + CAST(#{hlpfulCnt} AS SIGNED), + CAST(#{revScrValAvgVal} AS DECIMAL(10,2)), + #{sysRegId}, now(), + #{sysModId}, now() +) +ON DUPLICATE KEY UPDATE + REV_CNT = CAST(#{revCnt} AS SIGNED), + HLPFUL_CNT = CAST(#{hlpfulCnt} AS SIGNED), + REV_STARSCR_AVG_VAL = CAST(#{revScrValAvgVal} AS DECIMAL(10,2)), + SYS_MOD_ID = #{sysModId}, + SYS_MOD_DTM = now() +``` + +**PK**: `GOODS_NO` (상품번호) + +### UPSERT vs DELETE+INSERT 트레이드오프 (실무 코드 기반) + +| 관점 | UPSERT (GoodsReviewTotal 방식) | DELETE+INSERT (통계 Job 방식) | +|------|-------------------------------|------------------------------| +| **적합한 경우** | 1:1 매핑 (상품 → 집계 1행). 기존 데이터에서 빠지는 행이 없음 | TOP-N 랭킹처럼 기간마다 대상이 바뀌는 경우 | +| **멱등성** | 자연 멱등 (PK 충돌 시 UPDATE) | 자연 멱등 (DELETE 후 재적재) | +| **잔여 데이터** | 이전에 있던 행이 그대로 남음 (삭제 안 됨) | 기간 키 기준 깨끗하게 교체 | +| **성능 (소규모)** | 행 단위 UPSERT → N번 DB 호출 | DELETE 1회 + 벌크 INSERT 1회 | +| **성능 (대규모)** | 행 단위 루프가 병목 | DELETE가 락 범위 넓을 수 있음 | +| **감사 추적** | SYS_REG_DTM(최초) / SYS_MOD_DTM(최종) 분리 가능 | 매번 새 행이므로 최종 적재 시각만 기록 | + +### GoodsReviewTotal의 비효율 포인트 + +1. **행 단위 UPSERT 루프**: `for (item : list) { mapper.insertUpdate(item); }` — 상품 10만 건이면 10만 번 DB 호출 +2. **같은 회사의 GoodsSummarySyncJob**은 `INSERT...SELECT...ON DUPLICATE KEY UPDATE`로 SQL 한 방 처리 → 훨씬 효율적 +3. Java 루프 UPSERT는 Reader/Processor가 필요한 Chunk에서도 동일한 비효율 발생 가능 + +### 내 과제에 대한 결론 + +**DELETE+INSERT가 내 과제에 더 적합한 이유**: + +1. **TOP 100 랭킹은 기간마다 대상이 바뀐다** — 이번 주 TOP 100에 있던 상품이 다음 주에는 빠질 수 있음. UPSERT는 빠진 상품을 삭제하지 않으므로 잔여 데이터 문제 발생 +2. **기간 키(period_key) 기준 전체 교체**가 의미적으로 깔끔 — "이번 주 랭킹"은 하나의 단위로 교체되어야 함 +3. **JdbcBatchItemWriter의 벌크 INSERT**는 행 단위 UPSERT보다 성능 우위 +4. GoodsReviewTotal의 행 단위 UPSERT 루프는 **안티패턴** — 내 과제에서 피해야 할 패턴 + +--- + +## 8. MV Score 계산 전략: 메트릭 합산 후 score 1회 계산 (방식 A) + +> Redis의 주간/월간 랭킹은 "일별 score를 합산/감쇠"하는 근사치 방식이다. +> MV는 DB 원장 기반의 정확한 기간 집계를 제공하기 위한 Batch Layer이므로, 다른 계산 방식을 적용한다. + +### Redis vs MV의 score 계산 차이 + +| 항목 | Redis 주간 | Redis 월간 | MV (방식 A) | +|------|-----------|-----------|------------| +| **입력** | 7개 daily ZSET의 score | 전일 monthly score + 당일 daily score | product_metrics 원시 메트릭 | +| **계산** | `ZUNIONSTORE(SUM)` — 일별 score 단순 합산 | `전일 × 0.97 + 당일 × 1.0` — 지수 감쇠 롤링 | `SUM(메트릭) → score 공식 1회 적용` | +| **특성** | log₁₀ 비선형성으로 인한 왜곡 가능 | carry-over 누적 근사치 | DB 원장 기반 정확값 | +| **의미** | "일별 인기도의 합" | "최근 활동에 가중치를 둔 인기도" | "기간 총 활동량 기반 인기도" | + +### 왜 방식 A인가: log₁₀ 비선형성 문제 + +Redis 주간 방식(일별 score 합산)은 수학적으로 부정확할 수 있다: + +``` +예시: 상품 X — 7일간 view_count = [100, 100, 100, 100, 100, 100, 100] +예시: 상품 Y — 7일간 view_count = [0, 0, 0, 0, 0, 0, 700] + +Redis 주간 (일별 score 합산): + X: 7 × log₁₀(101)/7 = 7 × 0.2862 = 2.0034 + Y: 6 × log₁₀(1)/7 + log₁₀(701)/7 = 0 + 0.4063 = 0.4063 + → X가 압도적 우위 (일별 score 합산이므로 매일 꾸준한 상품이 유리) + +MV 방식 A (메트릭 합산 후 score 1회 계산): + X: log₁₀(700 + 1)/7 = 0.4063 + Y: log₁₀(700 + 1)/7 = 0.4063 + → 동일 (총 활동량이 같으므로 동점) +``` + +- **Redis 방식**: "꾸준히 인기 있는 상품"을 우대 — 실시간 트렌드 반영에 적합 +- **MV 방식 A**: "기간 총 활동량"을 공정하게 평가 — 정확한 기간 집계에 적합 + +**두 방식은 관점이 다르고, MV의 존재 이유(정확한 Batch Layer)에는 방식 A가 부합한다.** + +### Reader SQL 설계 + +```sql +SELECT + pm.product_id, + SUM(pm.view_count) AS total_view_count, + SUM(pm.like_count - pm.unlike_count) AS total_net_like_count, + SUM(pm.sales_count) AS total_sales_count, + SUM(pm.sales_amount - pm.cancel_amount_by_event_date) AS total_net_sales_amount +FROM product_metrics pm +JOIN product p ON pm.product_id = p.id +WHERE pm.metric_date BETWEEN :startDate AND :endDate + AND p.deleted_at IS NULL +GROUP BY pm.product_id +``` + +- **주간**: `startDate = targetDate - 6`, `endDate = targetDate` (7일) +- **월간**: `startDate = targetDate - 29`, `endDate = targetDate` (30일) +- Additive Measure 원칙 준수: 취소는 별도 컬럼이므로 조회 시 차감 (`sales_amount - cancel_amount`) + +### Processor 설계 + +기존 Score v2 공식을 그대로 적용: + +``` +score = categoryPriority + + 0.1 × log₁₀(totalViewCount + 1) / 7.0 + + 0.2 × log₁₀(totalNetLikeCount + 1) / 7.0 + + 0.7 × log₁₀(totalNetSalesAmount + 1) / 7.0 + + epochSeconds × 1e-16 (tiebreaker) +``` + +- **가중치/MAX_LOG**: RankingCorrectionJobConfig의 상수 재활용 +- **tiebreaker**: 배치 실행 시점의 `Instant.now().getEpochSecond()` 사용 (동점 해소 용도) +- **TOP-N 필터링**: Processor에서 하지 않음 — 전체 결과를 Writer에 전달하고, Reader SQL에 `ORDER BY score DESC LIMIT 100`을 추가하거나 Writer 후 별도 정리 + +### Writer 설계 (DELETE + INSERT) + +``` +Step 1 (Tasklet): DELETE FROM mv_product_rank_{scope} WHERE period_key = :periodKey +Step 2 (Chunk Writer): + INSERT INTO mv_product_rank_{scope} + (product_id, ranking, score, view_count, like_count, sales_count, sales_amount, period_key, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW()) +``` + +- **period_key**: 주간 = `2026-W16`, 월간 = `2026-04` (ISO 기반) +- **ranking**: Processor 또는 Writer 단계에서 score 내림차순 순번 부여 +- **TOP 100 제한**: Reader SQL에서 `LIMIT 100` 또는 Processor에서 필터링 + +### 전체 데이터 흐름 + +``` +[product_metrics (DB 원장)] + │ + │ Reader: GROUP BY product_id, SUM(7일 or 30일) + ▼ +[상품별 기간 메트릭 합계] + │ + │ Processor: Score v2 공식 적용 (log₁₀ 정규화 + tiebreaker) + ▼ +[상품별 score] + │ + │ Writer: DELETE period_key → INSERT TOP 100 + ▼ +[mv_product_rank_weekly / mv_product_rank_monthly] + │ + │ API: SELECT WHERE period_key = ? ORDER BY ranking + ▼ +[클라이언트 응답] +``` + +### Redis와 MV의 역할 분담 (최종 — 단일 소스 원칙) + +| 관점 | Redis ZSET | MV 테이블 | +|------|-----------|----------| +| **역할** | Speed Layer — 실시간 근사치 | Batch Layer — DB 원장 기반 정확값 | +| **daily** | 단일 소스 | 불필요 (Redis로 충분) | +| **weekly** | 사용 안 함 (MV 도입 후 제거) | **단일 소스** — 정확한 기간 집계 | +| **monthly** | 사용 안 함 (MV 도입 후 제거) | **단일 소스** — 정확한 기간 집계 | +| **장애 시** | Redis 다운 → daily 조회 불가 | 당일 MV 없으면 → 전일 MV fallback (같은 공식, 1일 stale) | + +--- + +## 부록: 공통 아키텍처 패턴 + +### 두 앱의 공통 패턴 + +| 패턴 | 설명 | 내 과제 적용 | +|------|------|------------| +| **UniqueRunIdIncrementer** | `UUID + System.currentTimeMillis()` 기반 run.id → 같은 파라미터로 재실행 가능. 파라미터 전부 버림 | 파라미터 보존이 필요하므로 기본 RunIdIncrementer 사용 | +| **RODB/RWDB 분리** | 읽기는 Replica, 쓰기는 Primary | 현재 규모에서는 단일 DataSource로 충분 | +| **SingleJobExecutionListener** | `JobExplorer.findRunningJobExecutions()`로 중복 실행 방지 | 동일 패턴 적용 가능 | +| **MyBatis + XML Mapper** | SQL을 XML로 외부 관리, 동적 조건 분기 | JdbcCursorItemReader + 인라인 SQL로 충분 | +| **REST API 트리거** | `/jobs/{jobName}?param=value` → JobLauncher.run() | commerce-batch의 기존 실행 방식 따르기 | +| **@JobScope / @StepScope** | JobParameter 주입을 위한 지연 생성 | 필수 적용 (targetDate, scope 파라미터) | +| **assertUpdates(false)** | Writer에서 영향 행 0건 허용 | ETL 시나리오에서 유용 | + +### Tasklet vs Chunk 선택 기준 (회사 코드에서 도출) + +| 기준 | Tasklet 선택 | Chunk 선택 | +|------|-------------|-----------| +| SQL 복잡도 | INSERT INTO ... SELECT (SQL 한 방) | 행 단위 변환/필터링 필요 | +| 데이터 규모 | SQL이 감당 가능한 범위 | OOM 위험 → chunk 단위 커밋 | +| 비즈니스 로직 | 단순 이동/삭제/갱신 | score 계산, 등급 산정 등 Java 로직 | +| 트랜잭션 | 전체 or nothing | 부분 커밋 필요 (실패 시 일부 복구) | +| 배치 앱 비율 | **85%** (82/97개 Job) | **15%** (10/97개 Job, stub 5개 제외) | diff --git a/docs/design/volume-10/10-batch-code-reference.md b/docs/design/volume-10/10-batch-code-reference.md new file mode 100644 index 0000000000..c2ccaf365d --- /dev/null +++ b/docs/design/volume-10/10-batch-code-reference.md @@ -0,0 +1,944 @@ +# 10. 회사 배치 코드 참고 스니펫 + +> 회사 실무 배치 앱(gddp, mbod)에서 추출한 핵심 코드 패턴. +> 구현 시 직접 참고할 수 있도록 패턴별로 분류했다. + +--- + +## 1. 공통 인프라 코드 + +### UniqueRunIdIncrementer (두 앱 동일, production 브랜치) + +```java +public class UniqueRunIdIncrementer extends RunIdIncrementer { + private static final String RUN_ID = "run.id"; + + @Override + public JobParameters getNext(JobParameters parameters) { + UUID uuid = UUID.randomUUID(); + return new JobParametersBuilder() + .addString(RUN_ID, uuid + Long.toString(System.currentTimeMillis())) + .toJobParameters(); + } +} +``` + +- **이전 버전과의 차이**: `addLong(timestamp)` → `addString(UUID + timestamp)`. 밀리초 충돌 가능성을 UUID로 해소 +- **주의**: 기존 파라미터를 전부 버린다. `targetDate`, `scope` 파라미터가 필요한 경우 기본 `RunIdIncrementer`를 사용할 것 + +### SingleJobExecutionListener (중복 실행 방지) + +```java +@Component +@Slf4j +public class SingleJobExecutionListener implements JobExecutionListener { + + @Autowired + private JobExplorer jobExplorer; + + @Override + public void beforeJob(JobExecution jobExecution) { + int runningJobsCount = jobExplorer + .findRunningJobExecutions(jobExecution.getJobInstance().getJobName()) + .size(); + if (runningJobsCount > 1) { + throw new CommonException( + "이미 실행 중인 Job이 있습니다. 현재 실행을 중지합니다: " + + jobExecution.getJobInstance().getJobName()); + } + } + + @Override + public void afterJob(JobExecution jobExecution) { + log.debug("End of job: [{}] {}", + jobExecution.getJobInstance().getInstanceId(), + jobExecution.getJobInstance().getJobName()); + } +} +``` + +- `JobExplorer.findRunningJobExecutions()`으로 같은 이름의 실행 중인 Job이 있는지 체크 +- 1개 초과 시 예외를 던져 중복 실행 방지 + +--- + +## 2. Chunk-Oriented Job 패턴 + +### 패턴 A: JdbcCursorItemReader + CompositeItemWriter (gddp/SampleJdbcConfig) + +```java +@Configuration +@RequiredArgsConstructor +public class SampleJdbcConfig { + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + + @Resource(name = "displayRodbSqlSessionFactory") + private final SqlSessionFactory displayRodbSqlSessionFactory; + + @Resource(name = "displayRwdbSqlSessionFactory") + private final SqlSessionFactory displayRwdbSqlSessionFactory; + + private static final int CHUNK_SIZE = 1000; + + @Bean + public Job sampleJdbcJob() { + return new JobBuilder("sampleJdbcJob", jobRepository) + .start(sampleJdbcStep()) + .incrementer(new UniqueRunIdIncrementer()) + .build(); + } + + @Bean + public Step sampleJdbcStep() { + return new StepBuilder("sampleJdbcStep", jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(sampleJdbcReader()) + .writer(sampleJdbcWriter()) + .build(); + } + + @Bean + @StepScope + public JdbcCursorItemReader sampleJdbcReader() { + DataSource displayRodbDataSource = + (DataSource) ApplicationContextWrapper.getBean("displayRodbDataSource"); + + HashMap queryMap = new HashMap<>(); + queryMap.put("name", "James"); + queryMap.put("sysRegrId", "SYSTEM"); + + BoundSql boundSql = displayRodbSqlSessionFactory.getConfiguration() + .getMappedStatement("selectSampleJdbcList").getBoundSql(queryMap); + + return new JdbcCursorItemReaderBuilder() + .name("jdbcCursorItemReader") + .dataSource(displayRodbDataSource) + .sql(boundSql.getSql()) + .rowMapper(new BeanPropertyRowMapper<>(SampleRequest.class)) + .preparedStatementSetter( + new ArgumentPreparedStatementSetter(getQueryValues(boundSql))) + .fetchSize(1000) + .maxItemCount(1000) + .maxRows(1000) + .build(); + } + + @Bean + public ItemWriter sampleJdbcWriter() { + CompositeItemWriter compositeItemWriter = new CompositeItemWriter<>(); + compositeItemWriter.setDelegates(Arrays.asList(jdbcBatchItemWriter())); + return compositeItemWriter; + } + + @Bean + public JdbcBatchItemWriter jdbcBatchItemWriter() { + DataSource displayRwdbDataSource = + (DataSource) ApplicationContextWrapper.getBean("displayRwdbDataSource"); + + BoundSql boundSql = displayRwdbSqlSessionFactory.getConfiguration() + .getMappedStatement("updateSample2").getBoundSql(new SampleRequest()); + + return new JdbcBatchItemWriterBuilder() + .dataSource(displayRwdbDataSource) + .assertUpdates(true) + .sql(boundSql.getSql()) + .itemPreparedStatementSetter((item, ps) -> { + ps.setString(1, "SYSTEM"); + ps.setString(2, item.getName()); + }) + .build(); + } +} +``` + +**참고 포인트**: +- `JdbcCursorItemReaderBuilder`의 `.fetchSize()`, `.maxItemCount()`, `.maxRows()` 설정 +- `BeanPropertyRowMapper`로 ResultSet → DTO 매핑 +- `JdbcBatchItemWriterBuilder`의 `.assertUpdates(true)` — 영향 행이 0이면 에러 +- `CompositeItemWriter`로 다중 Writer 체이닝 + +--- + +### 패턴 B: MyBatisCursorItemReader + MyBatisBatchItemWriter (gddp/SampleMyBatisCursorJobConfig) + +```java +@Configuration +@RequiredArgsConstructor +public class SampleMyBatisCursorJobConfig { + + @Resource(name = "displayRodbSqlSessionFactory") + private final SqlSessionFactory displayRodbSqlSessionFactory; + + @Resource(name = "displayRwdbSqlSessionFactory") + private final SqlSessionFactory displayRwdbSqlSessionFactory; + + private static final int CHUNK_SIZE = 1000; + + @Bean + public Job sampleMyBatisCursorJob() { + return new JobBuilder("sampleMyBatisCursorJob", jobRepository) + .start(sampleMyBatisCursorStep()) + .incrementer(new UniqueRunIdIncrementer()) + .build(); + } + + @Bean + public Step sampleMyBatisCursorStep() { + return new StepBuilder("sampleMyBatisCursorStep", jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(sampleMyBatisCursorItemReader()) + .writer(sampleeCompositeWriter()) + .build(); + } + + @Bean + public MyBatisCursorItemReader sampleMyBatisCursorItemReader() { + Map parameterValues = new HashMap<>(); + return new MyBatisCursorItemReaderBuilder() + .sqlSessionFactory(displayRodbSqlSessionFactory) + .queryId("com.x2bee.batch.gddp.app.repository.displayrodb.sample.BatSampleMapper.selectSampleList") + .parameterValues(parameterValues) + .build(); + } + + @Bean + public ItemWriter sampleMyBatisBatchItemWriter() { + return new MyBatisBatchItemWriterBuilder() + .sqlSessionFactory(displayRwdbSqlSessionFactory) + .assertUpdates(false) // 영향 행 0건 허용 + .itemToParameterConverter(item -> { + Map parameter = new HashMap<>(); + parameter.put("sysModrId", "BATCH"); + parameter.put("name", item.getName()); + return parameter; + }) + .statementId("com.x2bee.batch.gddp.app.repository.displayrwdb.sample.BatSampleTrxMapper.updateSample") + .build(); + } +} +``` + +**참고 포인트**: +- `MyBatisCursorItemReaderBuilder`는 `queryId`로 매퍼 XML의 SQL을 참조 +- `MyBatisBatchItemWriterBuilder`의 `.itemToParameterConverter()`로 DTO → 파라미터 맵 변환 +- `.assertUpdates(false)` — ETL에서 "영향 없는 행"이 정상인 경우 + +--- + +### 패턴 C: Reader/Processor/Writer 분리 + CompositeItemWriter (gddp/SampleCompositeWriterJobConfig) + +```java +@Bean +public Step sampleCompositeWriterStep() { + return new StepBuilder("sampleCompositeWriterStep", jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(sampleReader()) + .processor(sampleProcessor()) // 타입 변환: Request → Response + .writer(sampleeCompositeWriter()) + .build(); +} + +@Bean +public ItemProcessor sampleProcessor() { + return batSampleCompositeService::processor; // 메서드 레퍼런스 +} + +@Bean +public ItemWriter sampleeCompositeWriter() { + CompositeItemWriter compositeItemWriter = new CompositeItemWriter<>(); + compositeItemWriter.setDelegates(Arrays.asList(updateWriter())); + return compositeItemWriter; +} + +@Bean +public ItemWriter updateWriter() { + return sampleList -> sampleList.forEach(batSampleCompositeService::writer); +} +``` + +**참고 포인트**: +- Processor에서 타입 변환 (`SampleRequest` → `SampleResponse`) +- Lambda Writer (`sampleList -> sampleList.forEach(...)`)로 커스텀 로직 실행 + +--- + +## 3. Multi-Step + 조건 분기 패턴 + +### memberGradeChangeJob (mbod) — Step 1 실패 시 후속 Step 스킵 + +```java +@Bean +public Job memberGradeChangeJob() { + return new JobBuilder("memberGradeChangeJob", jobRepository) + .start(memberGradeCalcStep()).on("FAILED").end() // Step 1 실패 → 종료 + .on("*").to(memberGradeChangeStep("")).on("FAILED").end() // Step 2 실패 → 종료 + .on("*").to(memberGradeCouponIssueStep()) // Step 3 + .end() + .incrementer(new UniqueRunIdIncrementer()) + .listener(singleJobExecutionListener) + .build(); +} + +// Step 1: Tasklet (등급 산정) +@Bean +@JobScope +public Step memberGradeCalcStep() { + return new StepBuilder("memberGradeCalcStep", jobRepository) + .tasklet(memberGradeCalcTasklet(null), transactionManager) + .build(); +} + +// Step 2: Chunk (등급 변경 — Reader/Processor/Writer) +@Bean +@JobScope +public Step memberGradeChangeStep(@Value("#{jobParameters[mbrNo]}") String mbrNo) { + this.mbrNo = mbrNo; + this.batchDate = DateUtil.today(X2Constants.YYYYMMDD); + return new StepBuilder("memberGradeChangeStep", jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(memberGradeChangeItemReader()) + .processor(memberGradeChangeItemProcessor()) + .writer(memberGradeChangeItemWriter()) + .build(); +} + +// Step 3: Tasklet (쿠폰 발급) +@Bean +@JobScope +public Step memberGradeCouponIssueStep() { + return new StepBuilder("memberGradeCouponIssueStep", jobRepository) + .tasklet(memberGradeCouponIssueTasklet(null), transactionManager) + .build(); +} +``` + +**참고 포인트**: +- `.on("FAILED").end()` — 실패 시 후속 Step 실행하지 않고 종료 +- `.on("*").to(nextStep)` — 그 외 모든 상태에서 다음 Step으로 +- **내 과제 적용**: `cleanupStep(DELETE).on("FAILED").end() → aggregateStep(Chunk)` + +### Processor에서 DTO 변환 (memberGradeChange) + +```java +@Bean +public ItemProcessor memberGradeChangeItemProcessor() { + return item -> { + MemberGradeChangeRequest request = new MemberGradeChangeRequest(); + request.setSysRegId(Constants.SYS_REG_ID); + request.setSysModId(Constants.SYS_MOD_ID); + request.setBatchDate(batchDate); + request.setMbrNo(item.getMbrNo()); + request.setMbrGradeCd(item.getMbrGradeCd()); + return request; + }; +} +``` + +### CompositeItemWriter 3개 체이닝 (memberGradeChange) + +```java +@Bean +public ItemWriter memberGradeChangeItemWriter() { + CompositeItemWriter compositeItemWriter = new CompositeItemWriter<>(); + compositeItemWriter.setDelegates(Arrays.asList( + memberGradeChangeItemWriter1(), // UPDATE: 회원 등급 변경 + memberGradeChangeItemWriter2(), // UPDATE: 이전 등급 이력 종료 + memberGradeChangeItemWriter3() // INSERT: 새 등급 이력 생성 + )); + return compositeItemWriter; +} + +@Bean +public ItemWriter memberGradeChangeItemWriter1() { + return new MyBatisBatchItemWriterBuilder() + .sqlSessionFactory(orderRwdbSqlSessionFactory) + .assertUpdates(false) + .statementId("...EtMbrBaseTrxMapper.modifyEtMbrBaseGradeChange") + .build(); +} +``` + +--- + +## 4. Tasklet 패턴 + +### 단순 Tasklet (통계 Job — mbod) + +```java +@RequiredArgsConstructor +@StepScope +@Component +public class OrderSaleStatisticsTasklet implements Tasklet { + private final OrderSaleStatisticsService orderSaleStatisticsService; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception { + orderSaleStatisticsService.orderSaleStatisticsDataProcess(); + return RepeatStatus.FINISHED; + } +} +``` + +### 파라미터 주입 Tasklet (gddp/GoodsReviewTotal) + +```java +@Component +@Slf4j +@StepScope +public class GoodsReviewTotalJobTasklet implements Tasklet { + + @Value("#{jobParameters[batchTyp]}") + private String batchTyp; + + @Value("#{jobParameters[chngDtm]}") + private String chngDtm; + + @Autowired + private GoodsReviewTotalService goodsReviewTotalService; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception { + goodsReviewTotalService.run(batchTyp); + return RepeatStatus.FINISHED; + } +} +``` + +- `@StepScope` + `@Value("#{jobParameters[...]}") `로 파라미터 주입 +- Service에 위임하여 실제 로직 처리 + +--- + +## 5. StepExecutionListener 패턴 (gddp/SearchProductIndex) + +```java +private record IndexBatchCheckListener( + SearchMapper searchMapper, + SearchTrxMapper searchTrxMapper +) implements StepExecutionListener { + + @Override + public void beforeStep(StepExecution stepExecution) { + int startCount = searchMapper.getSearchIndexLoadBatchProcessCount(); + if (startCount > 0) { + stepExecution.setTerminateOnly(); // 다른 배치가 실행 중이면 Step 종료 + } + } + + @Override + public ExitStatus afterStep(StepExecution stepExecution) { + if (stepExecution.getExitStatus().equals(ExitStatus.COMPLETED)) { + searchTrxMapper.updateSearchProductIndexSendYn(); // 메타데이터 갱신 + } else { + return new ExitStatus( + ExitStatus.EXECUTING.getExitCode(), + "Running SearchProductIndexLoadBatch or System Error!" + ); + } + return stepExecution.getExitStatus(); + } +} +``` + +Step에 Listener 등록: + +```java +@Bean +@JobScope +public Step searchProductIndexStep() { + return new StepBuilder("searchProductIndexStep", jobRepository) + .chunk(DEFAULT_CHUNK_SIZE, transactionManager) + .reader(searchProductIndexReader(null, null, null)) + .writer(searchProductIndexWriter()) + .listener(new IndexBatchCheckListener(searchMapper, searchTrxMapper)) + .build(); +} +``` + +**참고 포인트**: +- `record`로 간결하게 구현 +- `stepExecution.setTerminateOnly()` — Step 실행 자체를 방지 +- `afterStep()`에서 성공 시 후처리 (메타데이터 갱신) + +--- + +## 6. 통계 SQL 패턴 + +### 패턴 1: DELETE + INSERT...SELECT (가장 일반적, 5개 Job) + +```sql +-- SmDayclGoodsOrdAgrtTrxMapper.xml +-- Step 1: 기간 데이터 삭제 +DELETE FROM SM_DAYCL_GOODS_ORD_AGRT WHERE AGRT_DT = #{agrtDt} + +-- Step 2: 집계 결과 직접 적재 +INSERT INTO SM_DAYCL_GOODS_ORD_AGRT ( + AGRT_DT, GOODS_NO, ITM_NO, + ORD_QTY, ORD_AMT, CNCL_QTY, CNCL_AMT, ... +) +WITH DAY_INFO AS ( + SELECT #{agrtDt} AS AGRT_DT +) +SELECT + DI.AGRT_DT, + SDOA.GOODS_NO, + SDOA.ITM_NO, + SUM(SDOA.ORD_QTY), + SUM(SDOA.ORD_AMT), + SUM(SDOA.CNCL_QTY), + SUM(SDOA.CNCL_AMT), + ... +FROM SM_DAYCL_ORD_AGRT SDOA +CROSS JOIN DAY_INFO DI +WHERE SDOA.AGRT_DT = DI.AGRT_DT +GROUP BY SDOA.GOODS_NO, SDOA.ITM_NO +``` + +**특징**: +- 멱등성 자동 보장 (DELETE 후 재적재) +- SQL 한 방으로 집계+적재 — Tasklet에서 실행 +- Additive Measure 원칙: `ORD_QTY`와 `CNCL_QTY` 분리 저장 + +### 패턴 2: INSERT...SELECT + ON DUPLICATE KEY UPDATE (가장 복잡, 3개 Job) + +```sql +-- SmDayclOrdAgrtTrxMapper.xml (OrderSaleStatistics, ~410줄) +WITH DAY_INFO AS ( + SELECT CASE WHEN ... END AS AGRT_STD_DT, + CASE WHEN ... END AS AGRT_DT +), +BNF_INFO AS ( + SELECT ORD_NO, ORD_SEQ, SUM(BNF_AMT) AS TOT_BNF_AMT + FROM SM_ORD_BNF_RELS + GROUP BY ORD_NO, ORD_SEQ +), +ORD_DTL_INFO AS ( + SELECT ... FROM SM_ORD_DTL_INFO + JOIN product, member, MD info + WHERE order_date BETWEEN ... +) +INSERT INTO SM_DAYCL_ORD_AGRT ( + AGRT_STD_DT, AGRT_DT, AGRT_GB, ORD_NO, ORD_SEQ, ORD_PROC_SEQ, + GOODS_NO, ITM_NO, ORD_QTY, ORD_AMT, ... +) +-- 4개 UNION ALL: 주문접수/주문완료 × 정상/취소 +SELECT ... FROM ORD_DTL_INFO WHERE ORD_PROC_STAT_CD = '10' -- 주문접수 +UNION ALL +SELECT ... FROM ORD_DTL_INFO WHERE ORD_PROC_STAT_CD = '30' -- 주문완료 +UNION ALL +SELECT ... FROM ORD_DTL_INFO WHERE CNCL conditions -- 취소 +UNION ALL +SELECT ... FROM ORD_DTL_INFO WHERE CNCL + 완료 conditions -- 취소+완료 +ON DUPLICATE KEY UPDATE + AGRT_DT = VALUES(AGRT_DT), + ORD_QTY = VALUES(ORD_QTY), + ORD_AMT = VALUES(ORD_AMT), + ... +``` + +**특징**: +- 3개 CTE + 4개 UNION ALL로 다차원 집계 +- Late-Arriving Fact 대응: 주문접수일(AGRT_STD_DT) vs 실제발생일(AGRT_DT) 이중 기록 +- PK 충돌 시 UPDATE — 증분 갱신에 적합 +- `ORD_QTY`와 `CNCL_QTY` 분리 (Additive Measure) + +### 패턴 3: SELECT → Java 루프 → 벌크 INSERT (AggregateBasket) + +```java +// Java +List list = mapper.getBasketAgrtList(param); // CTE + GROUP BY + ROW_NUMBER() +trxMapper.deleteAll(); // 전체 삭제 +trxMapper.insertBulkSmBasketAgrt(list); // foreach INSERT +``` + +--- + +## 7. UPSERT SQL 실물 (gddp/GoodsReviewTotal) + +### 집계 SELECT (Reader) + +```xml + +``` + +### UPSERT (Writer) + +```xml + + INSERT INTO pr_goods_rev_agrt_info ( + GOODS_NO, REV_CNT, HLPFUL_CNT, REV_STARSCR_AVG_VAL, + SYS_REG_ID, SYS_REG_DTM, SYS_MOD_ID, SYS_MOD_DTM + ) VALUES ( + #{goodsNo}, + CAST(#{revCnt} AS SIGNED), + CAST(#{hlpfulCnt} AS SIGNED), + CAST(#{revScrValAvgVal} AS DECIMAL(10,2)), + #{sysRegId}, now(), #{sysModId}, now() + ) + ON DUPLICATE KEY UPDATE + REV_CNT = CAST(#{revCnt} AS SIGNED), + HLPFUL_CNT = CAST(#{hlpfulCnt} AS SIGNED), + REV_STARSCR_AVG_VAL = CAST(#{revScrValAvgVal} AS DECIMAL(10,2)), + SYS_MOD_ID = #{sysModId}, + SYS_MOD_DTM = now() + +``` + +### 후처리: 전시 요약 테이블 동기화 (CTE + Batch UPDATE) + +```xml + + WITH REV_SUMMARY_INFO_LIST (GOODS_NO, REV_CNT, HLPFUL_CNT, SUM_SCR_VAL, REV_SCR_VAL_AVG_VAL) AS ( + + SELECT #{item.goodsNo}, CAST(#{item.revCnt} AS SIGNED), + CAST(#{item.hlpfulCnt} AS SIGNED), CAST(#{item.sumScrVal} AS SIGNED), + CAST(#{item.revScrValAvgVal} AS SIGNED) + + ) + UPDATE pr_disp_goods_sumr_info PDGSI + JOIN REV_SUMMARY_INFO_LIST RSII ON RSII.GOODS_NO = PDGSI.GOODS_NO + SET GOODS_REV_CNT = RSII.REV_CNT, + GOODS_REV_HLPFUL_CNT = RSII.HLPFUL_CNT, + GOODS_REV_STARSCR_AVG_VAL = RSII.REV_SCR_VAL_AVG_VAL, + SYS_MOD_ID = 'BATCH', SYS_MOD_DTM = NOW() + +``` + +**참고 포인트**: +- `batchTyp` 파라미터로 증분(R/D/M) vs 전체(ALL) 선택 — 내 과제에서 `scope` 파라미터와 유사 +- `LEFT JOIN LATERAL` — 상관 서브쿼리 패턴 +- `foreach + CTE + JOIN UPDATE` — 벌크 UPDATE 패턴 (행 단위 루프 대신) + +--- + +## 8. 커스텀 Lambda Writer (REST API 호출) 패턴 + +```java +// SearchProductChunkLoadConfig — CHUNK_SIZE=2000, BATCH_SIZE=200 +private static final int CHUNK_SIZE = 2000; +private static final int BATCH_SIZE = 200; + +@Bean +@StepScope +public ItemWriter searchProductChunkLoadWriter() { + return items -> { + List subList = new ArrayList<>(BATCH_SIZE); + for (SearchProductLoadRequest item : items) { + subList.add(item); + if (subList.size() == BATCH_SIZE) { + callSearchLoadApi(subList, item.getLangCd()); + subList.clear(); + } + } + if (!subList.isEmpty()) { + callSearchLoadApi(subList, subList.get(0).getLangCd()); + } + }; +} + +private void callSearchLoadApi(List subList, String langCd) { + Map requestData = new HashMap<>(); + requestData.put("langCd", langCd); + requestData.put("data", subList); + restApiUtil.post(searchApiUrl + "index/goods", requestData, + new ParameterizedTypeReference>() {}); + requestData.clear(); +} +``` + +**참고 포인트**: +- Chunk(2000) 내에서 다시 서브 배치(200)로 분할 — API 호출 시 페이로드 크기 제어 +- Lambda Writer로 DB가 아닌 외부 시스템에 쓰기 + +--- + +## 9. @StepScope + JobParameter 주입 패턴 + +```java +// Reader에서 파라미터 주입 +@Bean +@StepScope +public MyBatisCursorItemReader searchProductChunkLoadReader( + @Value("#{jobParameters[intervalTime]}") String intervalTime, + @Value("#{jobParameters[siteNo]}") String siteNo, + @Value("#{jobParameters[langCd]}") String langCd) { + + SearchCommonParam commonParam = new SearchCommonParam(); + this.getBatchType(intervalTime, commonParam); + commonParam.setSiteNo(siteNo); + if (langCd != null && !langCd.isEmpty()) commonParam.setLangCd(langCd); + + Map paramMap = this.getSearchCommonParam(commonParam); + return new MyBatisCursorItemReaderBuilder() + .sqlSessionFactory(searchRodbSqlSessionFactory) + .queryId("...SearchMapper.getProductLoadInfoNew") + .parameterValues(paramMap) + .build(); +} + +// Step에서 파라미터 주입 +@Bean +@JobScope +public Step mileageRemoveStep(@Value("#{jobParameters[batchDate]}") String batchDate) { + this.batchDate = StringUtil.nvl(batchDate, DateUtil.today(X2Constants.YYYYMMDD)); + if (DateUtil.compareWithToday(this.batchDate) > 0) { + throw new ValidationException("배치 처리 일자는 현재 일자보다 클 수 없습니다."); + } + return new StepBuilder("mileageRemoveStep", jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(mileageExpireListItemReader()) + .processor(mileageExpireListItemProcessor()) + .writer(mileageExpireCompositeItemWriter()) + .build(); +} +``` + +**참고 포인트**: +- `@StepScope` Bean에서 `@Value("#{jobParameters[...]}")` 사용 +- Step에서 파라미터 검증 (날짜 유효성 체크) +- `null` 체크 후 기본값 설정 패턴 + +--- + +## 10. Composite VO Processor 패턴 (mbod/MileageRemoveConfig) + +> Reader 결과 1건 → Processor에서 여러 도메인 객체 생성 → CompositeItemWriter가 각각 처리 + +### 구조 + +``` +[Reader: MbrAsstResponse] ← 만료 마일리지 1건 읽기 + │ +[Processor: 복합 변환] + │ ├── EtMbrAstMgrHist (INSERT용) ← 소멸 이력 생성 + │ ├── EtMbrAstMgrHist (UPDATE용) ← 기존 이력 마감 + │ └── MeMbrAstSum ← 잔액 합계 갱신 + │ +[MileageExpireRequestVo] ← 3개 객체를 감싸는 Composite VO + │ +[CompositeItemWriter] + ├── Writer1: UPDATE (기존 이력 마감) + ├── Writer2: INSERT (소멸 이력 생성) + └── Writer3: UPDATE (잔액 합계) +``` + +### Composite VO + +```java +@Getter @Setter +public class MileageExpireRequestVo extends BaseCommonEntity { + private MeMbrAstSum meMbrAstSum; // 잔액 합계 + private EtMbrAstMgrHist insertEtMbrAstMgrHist; // INSERT용 + private EtMbrAstMgrHist updateEtMbrAstMgrHist; // UPDATE용 +} +``` + +### Processor 핵심 로직 + +```java +@Bean +public ItemProcessor mileageExpireListItemProcessor() { + return item -> { + // 1. 트랜잭션 ID 생성 + String astMgrNo = DateUtil.getToday("yyyyMMdd") + .concat("E") + .concat(DateTimeUtil.getFormatString("HHmmss.SSS")); + + // 2. INSERT 엔티티 생성 (소멸 이력) + EtMbrAstMgrHist insertHist = new EtMbrAstMgrHist(item.getValiStrDt(), item.getValiEndDt()); + insertHist.createInsertUseMlg( + item.getMbrNo(), + item.createMileageSaveUse(ME015.MILEAGE, ME016.USE, ME020.EXPIRE, astMgrNo), + item.getAstMgrSeq()); + + // 3. UPDATE 엔티티 생성 (기존 이력 마감) + EtMbrAstMgrHist updateHist = EtMbrAstMgrHist.createUpdateUseMlg(item); + + // 4. 잔액 합계 갱신 엔티티 + MeMbrAstSum summary = new MeMbrAstSum().createUptMeMbrAstSum(insertHist); + + // 5. Composite VO에 래핑 + MileageExpireRequestVo vo = new MileageExpireRequestVo(); + vo.setSysRegId("BATCH"); + vo.setSysModId("BATCH"); + vo.setInsertEtMbrAstMgrHist(insertHist); + vo.setUpdateEtMbrAstMgrHist(updateHist); + vo.setMeMbrAstSum(summary); + return vo; + }; +} +``` + +**내 과제 시사점**: +- 내 과제에서는 Reader 결과(메트릭 합계) → Processor(score 계산) → 단일 Writer(INSERT)이므로 Composite VO까지는 불필요 +- 하지만 향후 "MV 적재 + Redis 갱신"을 동시에 해야 한다면 이 패턴이 유용 + +--- + +## 11. ExecutionContext 기반 재시작/재개 패턴 (gddp/SearchProductIndex) + +> 대량 데이터 처리 시 장애가 발생하면, 처리 완료된 청크를 건너뛰고 실패 지점부터 재개하는 패턴 + +### 커스텀 MyBatisPagingItemReader + +```java +MyBatisPagingItemReader reader = new MyBatisPagingItemReader<>() { + private int currentPage = 0; + private Map parameterValues; + + @Override + public void open(ExecutionContext executionContext) { + super.open(executionContext); + // 재시작 시 이전 위치 복원 + if (executionContext.containsKey("currentPage")) { + currentPage = executionContext.getInt("currentPage"); + } + if (parameterValues == null) { + parameterValues = new HashMap<>(); + parameterValues.put("limit", DEFAULT_PAGE_SIZE); // 10,000 + parameterValues.put("offset", currentPage * DEFAULT_PAGE_SIZE); + setParameterValues(parameterValues); + } + } + + @Override + protected void doReadPage() { + parameterValues.put("offset", currentPage * DEFAULT_PAGE_SIZE); + currentPage++; + super.doReadPage(); + } + + @Override + public void update(ExecutionContext executionContext) { + super.update(executionContext); + // 청크 완료 후 현재 페이지 저장 + executionContext.putInt("currentPage", currentPage); + } +}; +``` + +### 재시작 흐름 + +``` +최초 실행: + Chunk 1: offset=0 → 0~9,999 ✓ → save currentPage=1 + Chunk 2: offset=10,000 → 10,000~19,999 ✓ → save currentPage=2 + Chunk 3: offset=20,000 → 20,000~29,999 ✗ FAILURE (DB 커넥션 에러) + → ExecutionContext에 currentPage=2 저장됨 + +재시작: + open() → executionContext에서 currentPage=2 복원 + Chunk 3: offset=20,000 → 20,000~29,999 ✓ → 실패 지점부터 재개 + Chunk 4: offset=30,000 → ... +``` + +### 페이지네이션 SQL + +```sql +SELECT ... FROM PR_GOODS_SEARCH_INTF PGSI +WHERE INDEX_YN = 'N' AND DISP_CTG_NO IS NOT NULL +ORDER BY SYS_MOD_DTM, ID -- 결정적 정렬: 재시작 시 동일 결과 보장 +LIMIT #{limit} OFFSET #{offset} +``` + +**내 과제 시사점**: +- 내 과제의 MV 적재는 상품 수가 수천~수만 수준이므로 재시작 패턴까지는 불필요 +- 하지만 `JdbcCursorItemReader`는 기본적으로 ExecutionContext에 read count를 저장하므로, Spring Batch의 재시작 메커니즘이 자동으로 동작함 +- 상품 10만 건 이상 규모에서는 이 패턴을 고려할 가치 있음 + +--- + +## 12. 회사 vs 내 프로젝트 application.yml 비교 + +### 핵심 차이점 + +| 설정 | 회사 배치 앱 | commerce-batch | 조치 필요 여부 | +|------|------------|---------------|--------------| +| **spring.batch.job.enabled** | `false` (수동 트리거) | 미설정 (기본값 true) | 기존 Job이 있으므로 이미 `${job.name:NONE}`으로 제어 중. 현행 유지 | +| **graceful shutdown** | `server.shutdown: graceful`, 타임아웃 24h | 미설정 | 배치 Job이 중간에 끊기면 데이터 정합성 문제. 설정 추가 권장 | +| **thread pool** | max-size: 50, queue: 100 | 미설정 (기본 8스레드) | 현재 단일 Job 실행이므로 당장은 불필요. 병렬 Step 사용 시 필요 | +| **connection-timeout** | 30~90s (환경별), 검색 500~800s | 3s (jpa.yml) | 집계 쿼리가 3초 이내면 문제없음. GROUP BY 성능 테스트 후 판단 | +| **RODB/RWDB 분리** | 5~6쌍 | 단일 DataSource | 현재 규모에서 불필요. 설계 문서에 스케일아웃 시 분리 방안 언급만 | +| **@EnableBatchProcessing** | 명시적 DataSource 지정 | 자동 구성 | Spring Boot 3.x는 자동 구성이 기본. 현행 유지 | + +### commerce-batch 현재 설정 (확인된 내용) + +```yaml +spring: + batch: + job: + names: ${job.name:NONE} # Job 이름으로 실행 제어 + jdbc: + initialize-schema: never # 운영: 수동 관리 + # local/test: always # 프로파일별 분기 + + config: + import: + - jpa.yml # HikariCP, JPA 설정 + - redis.yml # Redis Master-Replica + - logging.yml # 로깅 + - monitoring.yml # Prometheus + Actuator +``` + +**결론**: 현재 설정으로 과제 수행에 문제 없음. graceful shutdown만 선택적으로 추가. + +--- + +## 13. 내 과제에 적용할 패턴 요약 + +| 내 과제 구성 요소 | 참고할 회사 코드 | 핵심 패턴 | +|------------------|----------------|----------| +| **Job 구성** | MemberGradeChangeConfig | Multi-Step + `.on("FAILED").end()` | +| **Reader** | SampleJdbcConfig | `JdbcCursorItemReaderBuilder` + `BeanPropertyRowMapper` | +| **Processor** | MemberGradeChangeConfig | DTO 변환 (Response → Request) | +| **Writer** | SampleJdbcConfig | `JdbcBatchItemWriterBuilder` + `itemPreparedStatementSetter` | +| **Cleanup Step** | SmDayclGoodsOrdAgrtTrxMapper | `DELETE WHERE period_key = ?` (Tasklet) | +| **파라미터 주입** | SearchProductChunkLoadConfig | `@StepScope` + `@Value("#{jobParameters[...]}")` | +| **중복 실행 방지** | SingleJobExecutionListener | `JobExplorer.findRunningJobExecutions()` | +| **Score 계산** | RankingCorrectionJobConfig (기존) | Score v2 공식 재활용 | +| **Composite VO** | MileageRemoveConfig | 여러 엔티티를 하나의 VO에 래핑 (향후 확장 시) | +| **재시작/재개** | SearchProductIndexConfig | ExecutionContext에 진행 상태 저장 (대량 데이터 시) | diff --git a/docs/design/volume-10/10-batch-ranking-system.md b/docs/design/volume-10/10-batch-ranking-system.md new file mode 100644 index 0000000000..b7079bc8f2 --- /dev/null +++ b/docs/design/volume-10/10-batch-ranking-system.md @@ -0,0 +1,621 @@ +# 10. 배치 랭킹 시스템 설계 — MV 기반 주간/월간 랭킹 + +> Spring Batch로 product_metrics를 기간 집계하여 MV 테이블에 TOP 100 랭킹을 적재하고, +> API에서 주간/월간 요청 시 MV를 primary 소스로 조회하는 시스템. + +--- + +## 요구사항 + +### 과제 요구사항 (10-batch-ranking-quests.md) + +| # | 요구사항 | 상세 | Checklist | +|---|---------|------|-----------| +| **R1** | Spring Batch Job 구현 | `product_metrics`를 **Chunk-Oriented** 방식으로 집계 처리 | Job을 작성하고 **파라미터 기반**으로 동작시킬 수 있다 | +| **R2** | Materialized View 설계 | `mv_product_rank_weekly` (주간 TOP 100), `mv_product_rank_monthly` (월간 TOP 100) | MV 구조를 설계하고 **올바르게 적재**했다 | +| **R3** | Ranking API 확장 | 기존 `GET /api/v1/rankings`에서 **기간 정보**를 받아 일간/주간/월간 랭킹 제공 | 조회 형태에 따라 **적절한 데이터 소스** 기반 랭킹 제공 | +| **R4** | Technical Writing | 블로그 + 10주 회고 (TL;DR 포함, "왜 그렇게 판단했는가" 중심) | — | + +### 기존 구현 현황 (Round 9) + +| 항목 | 상태 | 비고 | +|------|------|------| +| commerce-batch 모듈 | ✅ | 6개 Job 운영 중 | +| product_metrics 테이블 | ✅ | PK: (product_id, metric_date), daily grain | +| RankingCorrectionJob | ✅ | Chunk 1,000, JdbcCursorItemReader → Redis | +| Redis 일간/주간/월간 ZSET | ✅ | carry-over + ZUNIONSTORE | +| Ranking API (scope 파라미터) | ✅ | daily/weekly/monthly → **모두 Redis 조회** | +| MV 테이블 | ❌ | **Round 10 핵심 과제** | + +--- + +## 설계 결정 요약 + +| 질문 | 결정 | 근거 | +|------|------|------| +| 시간 윈도우 | **슬라이딩 윈도우 (매일 갱신)** | Redis weekly와 동일한 시간 범위. 무신사 방식. 사용자에게 매일 갱신되는 랭킹 제공 | +| Score 계산 방식 | **방식 A — 메트릭 균등 합산 후 score 1회 계산** | MV는 "기간 총 실적" 관점. Redis(지수 감쇠)와 다른 관점을 제공하는 것이 MV의 존재 이유 | +| Reader | **JdbcCursorItemReader + Partitioning** | GROUP BY 집계에서 Paging은 페이지마다 재실행하므로 부적합. Cursor의 멀티스레드 한계를 Partitioning으로 극복 | +| 비즈니스 로직 위치 | **Reader SQL에서 집계, Java ItemProcessor에서 score 계산** | Score 공식 중앙화(ScoreFormula)를 위해 SQL에서 Java로 이동. categoryPriority 누락 해결 | +| Writer 전략 | **DELETE + INSERT (스테이징 경유)** | 병렬 집계 → 스테이징 → mergeStep에서 Global TOP 100 | +| 멱등성 | **cleanup(DELETE MV + 스테이징) → 전체 재실행** | 스테이징 정합성을 위해 부분 재실행보다 전체 재실행이 안전 | +| Job Instance 동일성 | **RunIdIncrementer** | targetDate, scope 파라미터 보존 + run.id 증가로 재실행 허용. cleanupStep이 멱등성 보장 | +| Redis vs MV 역할 | **daily → Redis, weekly/monthly → MV 단일 소스 (Redis fallback 없음)** | 다른 공식(감쇠 vs 균등)으로 계산한 결과를 fallback으로 쓰면 데이터 일관성이 깨짐. MV 배치 실패 시에는 "빈 결과 + 알림"이 "다른 순위 노출"보다 안전 | +| Job 구조 | **scope 파라미터로 주간/월간 분기하는 단일 Job** | Job Config 중복 방지. 회사 코드의 batchTyp 패턴 참고 | + +--- + +## 시간 윈도우 전략 + +### 슬라이딩 윈도우 (매일 갱신) + +MV는 캘린더 기반(월~일, 1일~말일)이 아닌, **매일 갱신되는 슬라이딩 윈도우**로 집계한다. + +``` +targetDate = 2026-04-16 기준: + +주간: 2026-04-10 ~ 2026-04-16 (최근 7일) + ├─ 다음날 실행 시: 2026-04-11 ~ 2026-04-17 (1일 슬라이드) + └─ 매일 갱신되어 "오늘 기준 최근 7일" 유지 + +월간: 2026-03-18 ~ 2026-04-16 (최근 30일) + ├─ 다음날 실행 시: 2026-03-19 ~ 2026-04-17 (1일 슬라이드) + └─ 매일 갱신되어 "오늘 기준 최근 30일" 유지 +``` + +**선택 근거**: +- Redis weekly도 슬라이딩 7일 (ZUNIONSTORE 최근 7일 daily). MV와 시간 범위가 일치해야 fallback이 의미 있음 +- 무신사 등 이커머스에서 주간/월간 랭킹도 매일 갱신하는 것이 UX에 유리 +- period_key는 targetDate 자체 (`20260416`) — "이 날짜 기준 최근 N일" 의미 + +### Redis monthly(지수 감쇠)와 MV monthly(균등 합산)의 차이 + +Redis monthly는 **지수 감쇠** 방식이다: + +``` +내일_monthly = 오늘_monthly × 0.97 + 오늘_daily × 1.0 +``` + +이를 30일간 풀어쓰면: + +``` +monthly = Σ(i=0 ~ 29) daily_(today-i) × 0.97^i + += daily_today × 0.97⁰ (= 1.000) ++ daily_1일전 × 0.97¹ (= 0.970) ++ daily_2일전 × 0.97² (= 0.941) ++ ... ++ daily_29일전 × 0.97²⁹ (= 0.413) +``` + +**주의**: Redis는 "딱 30일"이 아니라 서비스 시작 이후 **모든 날**이 반영된다. +다만 0.97을 계속 곱하므로 오래된 날일수록 가중치가 0에 수렴한다. +가중치가 절반이 되는 데 걸리는 일수(**반감기**) ≈ 23일 (`ln(0.5) / ln(0.97) ≈ 22.8`). + +``` +일수 가중치(0.97^i) 누적 기여 + 0일 1.000 5.0% (오늘) + 6일 0.833 32.9% ← 최근 7일이 전체의 1/3 +13일 0.673 57.4% ← 최근 14일이 전체의 57% +22일 0.502 80.2% ← 반감기: 23일 전 = 50% +29일 0.413 100.0% +``` + +**MV 방식 A(균등 합산)와의 차이**: + +``` +Redis monthly (지수 감쇠, 윈도우 없음): + 상품 A: 30일 전 매출 1000만원 → 가중치 0.40으로 반영 + 상품 B: 오늘 매출 1000만원 → 가중치 1.00으로 반영 + → B가 유리 (최근 활동 우대) + +MV 방식 A (균등 합산, 30일 고정 윈도우): + 상품 A: 30일 전 매출 1000만원 → 가중치 1.0 + 상품 B: 오늘 매출 1000만원 → 가중치 1.0 + → 동일 (기간 내 총량만 평가) +``` + +**두 방식은 관점이 다르다:** +- Redis: "최근에 뜨는 상품" (트렌드) +- MV: "기간 총 실적이 높은 상품" (누적 성과) + +MV가 Redis와 동일한 지수 감쇠를 쓰면 MV를 만들 이유가 없다. 다른 관점을 제공하는 것이 MV의 존재 가치다. + +--- + +## 아키텍처 + +### 전체 데이터 흐름 + +``` +[product_metrics (DB 원장, daily grain)] + │ + │ Reader: GROUP BY product_id, SUM(최근 7일 or 30일) + ▼ +[상품별 기간 메트릭 균등 합계] + │ + │ Processor: Score v2 공식 (log₁₀ 정규화 + tiebreaker) + ▼ +[상품별 score → 정렬 → TOP 100] + │ + │ Writer: DELETE period_key → INSERT TOP 100 + ▼ +[mv_product_rank_weekly / mv_product_rank_monthly] + │ + │ API: SELECT WHERE period_key = ? ORDER BY ranking + ▼ +[클라이언트] +``` + +### Redis와 MV의 역할 분담 — 단일 소스 원칙 + +``` +[API 요청] + │ + ├── scope=daily → Redis ZSET (단일 소스) + │ + ├── scope=weekly → MV 테이블 (단일 소스, 균등 합산) + │ + └── scope=monthly → MV 테이블 (단일 소스, 균등 합산) +``` + +**Redis fallback을 두지 않는 이유**: Redis(지수 감쇠)와 MV(균등 합산)는 다른 공식으로 계산하므로 같은 기간에 대해 순위가 다르다. MV 배치 실패 시 Redis fallback으로 전환하면 "어제는 A가 1위, 오늘은 B가 1위"라는 데이터 불일치가 발생한다. + +**전일 MV fallback**: 당일 MV가 없으면 전일 MV를 반환한다. 같은 공식, 같은 소스에서 계산한 결과이므로 데이터 불일치가 아니라 1일 시간 지연일 뿐이다 (7일 중 6일 겹침). MV는 carry-over(누적)가 아니라 매번 원장에서 기간 전체를 새로 집계하므로, 전일 MV는 독립적으로 계산된 정확한 결과다. + +**데이터 보존 정책**: cleanupStep에서 당일 period_key만 삭제하고, 3일 이전 데이터를 별도 정리한다. 전일/전전일 MV가 fallback으로 사용 가능하도록 보존. + +**기존 Redis weekly/monthly**: MV 도입 검증 완료 후 carry-over 스케줄러에서 weekly/monthly 생성 로직 제거. daily carry-over만 유지. + +### 전체 재계산 vs 증분 계산 — 왜 매번 원장에서 새로 계산하는가 + +MV는 매일 원장(product_metrics)에서 기간 전체를 GROUP BY로 새로 집계한다. "어제 결과에서 가장 오래된 날을 빼고 오늘을 더하는" 증분 방식이 더 효율적이지 않은가? + +**증분 계산을 채택하지 않은 이유: Late-Arriving Fact** + +이커머스에서 주문 취소/환불은 원주문과 다른 날에 발생한다. product_metrics의 cancel_by_order_date는 원주문 날짜의 행에 기록되므로, **이미 지나간 날의 데이터가 사후에 변경된다**: + +``` +4/10: 상품 A 주문 100건 (1000만원) +4/15: 그 중 30건 취소 → product_metrics 4/10 행의 cancel_by_order_date 갱신 + +증분 계산: 4/10의 값은 이미 어제 MV에 반영됨 → 사후 변경을 감지 못함 +전체 재계산: 4/10~4/16 전체를 다시 읽으므로 → 변경된 값이 자동 반영 +``` + +| 시나리오 | 전체 재계산 | 증분 계산 | +|---------|-----------|----------| +| 정상 주문 | ✅ 정확 | ✅ 정확 | +| 지연 취소 (주문 후 며칠 뒤) | ✅ 자동 반영 | ❌ 원주문 날짜 변경 감지 못함 | +| 운영팀 데이터 보정 | ✅ 다음 배치 자동 반영 | ❌ 전체 재계산을 별도 실행해야 함 | +| 오류 전파 | ❌ 없음 | ⚠️ 어제 MV가 틀리면 오늘도 틀림 | + +증분 계산은 "과거 데이터가 불변"이라는 전제가 필요하다. product_metrics의 Late-Arriving Fact 설계가 이 전제를 깨뜨리므로, 전체 재계산이 이커머스 랭킹에 더 적합하다. + +성능 차이(Partitioning 4 Worker 기준 ~10초 vs ~3초)는 1일 1회 배치에서 운영 영향이 없다. 증분이 유리해지는 전환점은 배치 주기가 5분 이하로 빈번해질 때다. + +--- + +## MV 테이블 스키마 + +### mv_product_rank_weekly + +```sql +CREATE TABLE mv_product_rank_weekly ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + product_id BIGINT NOT NULL, + ranking INT NOT NULL, + score DOUBLE NOT NULL, + view_count BIGINT NOT NULL DEFAULT 0, + like_count BIGINT NOT NULL DEFAULT 0, + sales_count BIGINT NOT NULL DEFAULT 0, + sales_amount BIGINT NOT NULL DEFAULT 0, + period_key VARCHAR(8) NOT NULL, -- '20260416' (targetDate, 슬라이딩 윈도우 기준일) + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_period_ranking (period_key, ranking) +) ENGINE=InnoDB; +``` + +### mv_product_rank_monthly + +```sql +CREATE TABLE mv_product_rank_monthly ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + product_id BIGINT NOT NULL, + ranking INT NOT NULL, + score DOUBLE NOT NULL, + view_count BIGINT NOT NULL DEFAULT 0, + like_count BIGINT NOT NULL DEFAULT 0, + sales_count BIGINT NOT NULL DEFAULT 0, + sales_amount BIGINT NOT NULL DEFAULT 0, + period_key VARCHAR(8) NOT NULL, -- '20260416' (targetDate, 슬라이딩 윈도우 기준일) + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_period_ranking (period_key, ranking) +) ENGINE=InnoDB; +``` + +**설계 판단**: +- **PK**: AUTO_INCREMENT id. DELETE+INSERT 전략이므로 단순한 PK가 유리 +- **period_key**: targetDate 문자열 (`20260416`). "이 날짜 기준 최근 7일/30일" 의미. 슬라이딩 윈도우이므로 매일 새로운 period_key 생성 +- **인덱스**: `(period_key, ranking)` — API 조회 패턴 `WHERE period_key = ? ORDER BY ranking` 에 최적화 +- **개별 메트릭 저장**: score뿐 아니라 view_count, like_count 등도 저장 — 분석/디버깅 및 향후 다차원 정렬 확장 용도 +- **이전 기간 데이터**: 당일 기준 period_key만 유지. 이전 날짜 데이터는 CleanupTasklet에서 삭제 (또는 보존 후 별도 정리 Job) + +--- + +## Spring Batch Job 설계 + +### 설계 판단의 흐름 + +1. Chunk vs Tasklet → **Chunk**: 프레임워크 운영 기능(retry, 모니터링, restart) 활용 +2. CursorReader vs PagingReader → **CursorReader**: GROUP BY 집계 쿼리에서 Paging은 페이지마다 집계를 재실행하므로 부적합 +3. CursorReader는 멀티스레드 불가(ResultSet 공유 상태) → **Partitioning**: CursorReader의 장점(1회 쿼리)을 유지하면서 병렬 처리 +4. Partitioning + Global TOP 100 → **3-Step 구조**: 병렬 집계(스테이징) → 글로벌 머지(TOP 100) + +### Job 구조 (Partitioning + Map-Reduce) + +``` +ProductRankingMvJob + ├── Parameter: targetDate (yyyyMMdd), scope (weekly|monthly) + │ + ├── Step 1: cleanupStep (Tasklet) + │ └── DELETE FROM mv_product_rank_{scope} WHERE period_key = :periodKey + │ └── DELETE FROM mv_product_rank_staging WHERE period_key = :periodKey + │ └── allowStartIfComplete(true) + │ └── on("FAILED").end() + │ + ├── Step 2: partitionedAggregateStep (Partitioned Chunk, 병렬) + │ │ + │ │ [Partitioner] product_id 범위를 gridSize(기본 4)개로 분할 + │ │ TaskExecutor: SimpleAsyncTaskExecutor (gridSize 스레드) + │ │ + │ ├── [Worker 1] product_id :minId ~ :maxId + │ │ ├── Reader: JdbcCursorItemReader (GROUP BY 집계, 해당 범위만) + │ │ ├── Processor: ScoreFormula.calculate() → score 계산 + categoryPriority 반영 + │ │ ├── Writer: JdbcBatchItemWriter → 스테이징 테이블 INSERT + │ │ └── faultTolerant + retry(3) + ExponentialBackOffPolicy + │ │ + │ ├── [Worker 2] ... (동일 구조, 다른 범위) + │ ├── [Worker 3] ... + │ └── [Worker N] ... + │ + └── Step 3: mergeStep (Tasklet) + └── INSERT INTO mv_product_rank_{scope} + SELECT ..., ROW_NUMBER() OVER (ORDER BY score DESC) AS ranking + FROM mv_product_rank_staging + WHERE period_key = :periodKey + ORDER BY score DESC + LIMIT 100 +``` + +### 왜 Partitioning인가 + +**요구사항**: "대량의 데이터를 읽고 처리할 수 있도록 구성" + +쿠팡급(상품 100만, 30일치 3,000만 행) 기준 성능: + +| 구조 | GROUP BY 실행 | 소요 시간 | +|------|-------------|----------| +| 단일 CursorReader | 3,000만 행 1회 | ~30초 | +| **Partitioning (4 Worker)** | 각 750만 행 × 4 병렬 | **~10초** (3배 빠름) | +| Partitioning (10 Worker) | 각 300만 행 × 10 병렬 | **~5초** (6배 빠름) | + +CursorReader의 장점(GROUP BY 1회 실행)을 유지하면서, 데이터를 product_id 범위로 분할하여 병렬 처리한다. PagingReader로 전환하면 페이지마다 GROUP BY를 재실행하는 문제가 생기지만, Partitioning은 각 Worker가 **독립 커넥션 + 독립 CursorReader**를 가지므로 이 문제가 없다. + +### 왜 Chunk인가 — 프레임워크 운영 기능 활용 + +이 작업은 Tasklet(INSERT INTO...SELECT)으로도 가능하고, 네트워크 효율만 따지면 Tasklet이 우위다. chunk를 선택하면 Spring Batch가 제공하는 운영 기능을 활용할 수 있다: + +- **faultTolerant + retry + ExponentialBackOffPolicy**: 일시적 DB 에러(데드락, 커넥션 타임아웃) 시 자동 재시도. 100ms → 200ms → 400ms 간격으로 재시도하여 락 해소 시간 확보 +- **StepExecution 자동 기록**: 각 Worker별 readCount, writeCount 자동 추적 +- **StepMonitorListener**: Worker 실패 시 알림 +- **Partitioned restart**: 실패한 파티션만 재실행 가능 + +### 스테이징 테이블 + +```sql +CREATE TABLE mv_product_rank_staging ( + product_id BIGINT NOT NULL, + score DOUBLE NOT NULL, + view_count BIGINT NOT NULL DEFAULT 0, + like_count BIGINT NOT NULL DEFAULT 0, + sales_count BIGINT NOT NULL DEFAULT 0, + sales_amount BIGINT NOT NULL DEFAULT 0, + period_key VARCHAR(8) NOT NULL, + PRIMARY KEY (product_id, period_key) +) ENGINE=InnoDB; +``` + +각 Worker가 자기 범위의 전체 집계 결과를 스테이징에 적재. PK가 `(product_id, period_key)`이므로 Worker 간 충돌 없음 (product_id 범위가 겹치지 않으므로). + +### Worker Reader SQL (파티션별) + +```sql +SELECT + pm.product_id, + SUM(pm.view_count) AS total_view_count, + SUM(pm.like_count - pm.unlike_count) AS total_net_like_count, + SUM(pm.sales_count) AS total_sales_count, + SUM(pm.sales_amount - pm.cancel_amount_by_event_date) AS total_net_sales_amount, + p.category_id +FROM product_metrics pm +JOIN product p ON pm.product_id = p.id +WHERE pm.metric_date BETWEEN :startDate AND :endDate + AND pm.product_id BETWEEN :minProductId AND :maxProductId + AND p.deleted_at IS NULL +GROUP BY pm.product_id, p.category_id +``` + +- **주간**: `startDate = targetDate - 6`, `endDate = targetDate` (7일) +- **월간**: `startDate = targetDate - 29`, `endDate = targetDate` (30일) +- **LIMIT 없음**: 각 파티션의 전체 결과를 스테이징에 적재. 글로벌 TOP 100은 mergeStep에서 결정 +- **product_id BETWEEN**: Partitioner가 할당한 범위만 처리 +- **score 계산은 SQL이 아닌 Java ItemProcessor에서 수행**: `ScoreFormula.calculate()` 호출 + +### Worker Processor — ScoreFormula 위임 + +Reader에서 집계된 `AggregatedMetricsRow`를 받아 `ScoreFormula.calculate()`로 score를 계산한다. +Score 공식을 SQL에서 제거하고 Java ItemProcessor로 이동한 이유: + +1. **Score 공식 중앙화**: `ScoreFormula`(modules/jpa)가 유일한 공식 정의. streamer, batch correction, MV Job 3곳이 모두 이 클래스에 위임 +2. **categoryPriority 반영**: SQL에서는 `categoryPriority` 매핑(yml 설정)을 적용할 수 없어 누락되어 있었음. Java Processor에서 `resolveCategoryPriority()`를 통해 반영 +3. **가중치 변경 시 단일 수정 지점**: `ScoreFormula.Weights`로 통일되어 공식 변경 시 한 곳만 수정 + +### mergeStep SQL + +```sql +INSERT INTO mv_product_rank_{scope} + (product_id, ranking, score, view_count, like_count, sales_count, sales_amount, period_key, created_at) +SELECT + product_id, + ROW_NUMBER() OVER (ORDER BY score DESC) AS ranking, + score, view_count, like_count, sales_count, sales_amount, :periodKey, NOW() +FROM mv_product_rank_staging +WHERE period_key = :periodKey +ORDER BY score DESC +LIMIT 100 +``` + +스테이징에 모인 전체 결과에서 `ROW_NUMBER()`로 글로벌 순위를 부여하고 TOP 100만 MV에 적재. + +### Best Practice 대조 점검 + +| Best Practice | 적용 | 상세 | +|-------------|------|------| +| @StepScope + Late Binding | ✅ | Worker Reader에 minProductId, maxProductId, targetDate, scope 주입 | +| Reader name 설정 | ✅ | 각 Worker별 고유 name. ExecutionContext 저장 시 key | +| Processor에서 DB 수정 금지 | ✅ | ScoreFormula.calculate()로 score 계산만 수행. DB 수정 없음 | +| Writer 벌크 처리 | ✅ | JdbcBatchItemWriter (JDBC batch INSERT) | +| assertUpdates(false) | ✅ | INSERT이므로 | +| ExponentialBackOffPolicy | ✅ | Worker별 데드락 시 간격 두고 재시도 | +| cleanupStep allowStartIfComplete | ✅ | DELETE는 멱등. 재시작 시에도 항상 실행 | +| CursorReader + Partitioning | ✅ | GROUP BY 1회 실행 유지 + 병렬 처리. ResultSet 공유 없음 (Worker별 독립 커넥션) | +| skip policy | 미적용 (의도적) | 집계 결과이므로 데이터 오류 가능성 낮음. 1건 에러 시 해당 파티션 전체 실패가 적절 | + +--- + +## API 확장 + +### 현재 구조 (모두 Redis 조회) + +```java +// RankingFacade — scope별 Redis prefix 분기 +return switch (scope) { + case "weekly" -> WEEKLY_ZSET_PREFIX; // Redis + case "monthly" -> MONTHLY_ZSET_PREFIX; // Redis + default -> DAILY_ZSET_PREFIX; // Redis +}; +``` + +### 변경 후 구조 (weekly/monthly → MV 단일 소스) + +```java +return switch (scope) { + case "daily" -> getFromRedis(DAILY_ZSET_PREFIX, ...); + case "weekly" -> getFromMv("weekly", ...); + case "monthly" -> getFromMv("monthly", ...); +}; +``` + +**MV 조회 흐름**: +1. 당일 period_key로 MV 테이블 조회 +2. 당일 데이터 없으면 → 전일 period_key로 fallback (같은 공식, 1일 stale) +3. 전일도 없으면 → 빈 결과 반환 +4. Product 상세 정보 조합 → 응답 + +**기존 API 시그니처 변경 없음**: `/api/v1/rankings?scope=weekly&date=20260416&size=20&page=0` + +### 필요한 새 컴포넌트 + +| 레이어 | 파일 | 역할 | +|--------|------|------| +| domain | `MvProductRank.java` | MV 엔티티 (@Entity) | +| domain | `MvProductRankRepository.java` | Repository 인터페이스 | +| infrastructure | `MvProductRankJpaRepository.java` | JPA 구현체 | +| application | `RankingFacade.java` (수정) | MV 단일 소스 조회 + 전일 MV fallback | + +--- + +## 실행 전략 + +### 스케줄링 + +| Job | 실행 시점 | 근거 | +|-----|----------|------| +| 주간 MV Job | **매일 01:00** | 전날까지의 7일 데이터 집계. RankingCorrectionJob(1시간 주기)과 시간 분리 | +| 월간 MV Job | **매일 01:30** | 전날까지의 30일 데이터 집계. 주간 Job 완료 후 실행 | + +- 기존 23:50 carry-over 스케줄러와 시간 충돌 없음 +- 매일 실행하여 슬라이딩 윈도우 유지 + +### 실행 명령 + +```bash +# 주간 랭킹 +java -jar commerce-batch.jar --job.name=productRankingMvJob targetDate=20260416 scope=weekly + +# 월간 랭킹 +java -jar commerce-batch.jar --job.name=productRankingMvJob targetDate=20260416 scope=monthly +``` + +--- + +## 파일 구조 + +``` +apps/commerce-batch/src/main/java/com/loopers/batch/job/rankingmv/ + ├── ProductRankingMvJobConfig.java ← Job(3 Step) + Partitioner + Reader + Writer + └── step/ + └── CleanupTasklet.java ← Step 1: DELETE MV + staging + 3일 이전 정리 + +apps/commerce-api/src/main/java/com/loopers/ + ├── domain/ranking/ + │ ├── MvProductRank.java ← MV 엔티티 + │ └── MvProductRankRepository.java ← Repository 인터페이스 + ├── infrastructure/ranking/ + │ └── MvProductRankJpaRepository.java ← JPA 구현체 + └── application/ranking/ + └── RankingFacade.java ← (수정) MV 단일 소스 + 전일 fallback + +apps/commerce-batch/src/test/resources/ + └── schema-batch-test.sql ← DDL (MV + staging 포함) +``` + +--- + +## 구현 순서 + +### Phase 0: 설계 (완료) + +- ✅ 0-1. 아키텍처 결정 — MV 단일 소스 (Redis fallback 없음, 전일 MV fallback) +- ✅ 0-2. MV 스키마 설계 — DDL 확정 (MV weekly/monthly + staging) +- ✅ 0-3. Job 설계 — Partitioning + Map-Reduce (3 Step) +- ✅ 0-4. Score 전략 — 방식 A (균등 합산, 전체 재계산), Reader SQL에서 LOG10 계산 +- ✅ 0-5. 시간 윈도우 — 슬라이딩 윈도우 (매일 갱신) +- ✅ 0-6. 운영 기능 — faultTolerant + retry + ExponentialBackOffPolicy +- ✅ 0-7. 멱등성 — cleanup(DELETE) → 전체 재실행. RunIdIncrementer로 재실행 허용 +- ✅ 0-8. 설계 문서 작성 + +### Phase 1: 배치 Job 구현 → R1, R2 충족 + +| # | 작업 | 상태 | 산출물 | +|---|------|------|--------| +| 1-1 | DDL 작성 | ✅ | `schema-batch-test.sql`에 MV weekly/monthly + staging 추가 | +| 1-2 | CleanupTasklet | ✅ | 당일 MV + staging DELETE + 3일 이전 정리 | +| 1-3 | ProductRankingMvJobConfig | ✅ | 3-Step Job (cleanup → partitioned aggregate → merge) | +| 1-4 | 컴파일 확인 | ✅ | BUILD SUCCESSFUL | + +### Phase 2: API 확장 → R3 충족 + +| # | 작업 | 상태 | 산출물 | +|---|------|------|--------| +| 2-1 | MV 엔티티/리포지토리 | ✅ | `MvProductRank` (MappedSuperclass) + Weekly/Monthly 엔티티 + Repository + JPA 구현체 | +| 2-2 | RankingFacade 수정 | ✅ | daily→Redis, weekly/monthly→MV 단일 소스 + 전일 MV fallback | + +### Phase 3: 테스트 + +| # | 작업 | 상태 | 산출물 | +|---|------|------|--------| +| 3-1 | Job 통합 테스트 | ✅ 코드 작성 | `ProductRankingMvJobE2ETest` — 시드 → Job → MV 결과 검증 | +| 3-2 | 멱등성 테스트 | ✅ 코드 작성 | 같은 파라미터 2회 실행 → MV 결과 동일 | +| 3-3 | 엣지 케이스 | ✅ 코드 작성 | 데이터 없음, 7일 미만, 100개 미만, 취소 반영 | +| 3-4 | 테스트 실행 | ⏳ 보류 | 메모리 부족으로 실행 보류. 아래 실행 가이드 참조 | +| 3-5 | API 통합 테스트 | | MV 조회 + 전일 fallback 동작 검증 (Phase 4에서 수동 검증 가능) | + +**테스트 실행 가이드**: + +```bash +# 사전 조건: Docker 실행 중 (Testcontainers가 MySQL + Redis 컨테이너를 자동 생성) +# JVM 메모리: 최소 1GB 여유 필요 + +# 전체 MV Job 테스트 +./gradlew :apps:commerce-batch:test --tests "com.loopers.job.rankingmv.ProductRankingMvJobE2ETest" + +# 개별 테스트 (메모리 절약) +./gradlew :apps:commerce-batch:test --tests "com.loopers.job.rankingmv.ProductRankingMvJobE2ETest\$WeeklyJob\$success" +``` + +테스트가 실패하면 확인할 것: +- `schema-batch-test.sql`에 product_metrics, MV, staging DDL이 있는지 +- product 테이블에 `category_id` 컬럼이 있는지 (이번에 추가함) +- Testcontainers Docker 접근 가능한지 + +### Phase 4: 시나리오 검증 & 모니터링 + +| # | 작업 | 상태 | 산출물 | +|---|------|------|--------| +| 4-1 | 정상 실행 시나리오 | | 시드 데이터 기반 주간/월간 Job 실행 결과 | +| 4-2 | MV vs Redis 비교 | | 같은 기간 TOP 20 대조, score 차이 분석 | +| 4-3 | 성능 측정 | | Job 실행 시간, 처리 건수, Partitioning 효과 | + +**시나리오 검증 절차**: + +```bash +# 1. 인프라 기동 +docker-compose -f docker/infra-compose.yml up -d + +# 2. commerce-api 실행 +./gradlew :apps:commerce-api:bootRun + +# 3. 시드 데이터 생성 +./scripts/seed-test-data.sh + +# 4. MV 배치 실행 (별도 터미널) +./gradlew :apps:commerce-batch:bootRun --args="--job.name=productRankingMvJob targetDate=20260416 scope=weekly" +./gradlew :apps:commerce-batch:bootRun --args="--job.name=productRankingMvJob targetDate=20260416 scope=monthly" + +# 5. API 검증 +curl "http://localhost:8080/api/v1/rankings?scope=weekly&date=20260416&size=20" +curl "http://localhost:8080/api/v1/rankings?scope=monthly&date=20260416&size=20" +curl "http://localhost:8080/api/v1/rankings?scope=daily&size=20" # 기존 Redis 경로 + +# 6. MV vs Redis 비교 (MySQL 직접 조회) +mysql -u root -p loopers -e "SELECT product_id, ranking, score FROM mv_product_rank_weekly WHERE period_key='20260416' ORDER BY ranking LIMIT 20;" + +# 7. 멱등성 검증: 같은 명령 2회 실행 후 MV 건수 확인 +mysql -u root -p loopers -e "SELECT COUNT(*) FROM mv_product_rank_weekly WHERE period_key='20260416';" + +# 8. 전일 fallback 검증: 존재하지 않는 날짜로 조회 +curl "http://localhost:8080/api/v1/rankings?scope=weekly&date=20260417&size=20" +# → 20260417 데이터 없으면 20260416 데이터가 반환되어야 함 +``` + +### Phase 5: 문서 & PR → R4 충족 + +| # | 작업 | 상태 | 산출물 | +|---|------|------|--------| +| 5-1 | 설계 문서 갱신 | | 구현 결과, 성능 수치, 트레이드오프 반영 | +| 5-2 | PR 작성 | | 변경 요약 + 리뷰 포인트 2~3개 | +| 5-3 | 블로그 + 10주 회고 | | TL;DR 포함, 설계 판단 중심 | + +**PR 리뷰 포인트 후보**: + +1. **Partitioning + CursorReader 조합**: GROUP BY 집계에서 PagingReader 대신 Partitioning을 선택한 이유. CursorReader의 멀티스레드 한계를 어떻게 극복했는가? +2. **MV 단일 소스 원칙**: Redis fallback을 제거하고 전일 MV fallback으로 대체한 판단. 다른 공식의 결과를 같은 API의 fallback으로 쓰면 왜 안 되는가? +3. **전체 재계산 vs 증분 계산**: Late-Arriving Fact(지연 취소)로 인해 증분이 부적합한 이유. 성능 차이(10초 vs 3초)가 1일 1회 배치에서 의미 없는 이유는? + +**블로그 구조 가이드** (소재 문서 `10-technical-writing-topics.md` 기반): + +``` +TL;DR: (1줄 요약) + +1. 도입 — "Redis에 이미 랭킹이 있는데 왜 MV를 만드는가?" + → 소재 4 (Lambda Architecture) + +2. Score 설계 — 균등 합산 vs 지수 감쇠 + → 소재 1 + 전시 기간 편향 분석 + +3. Chunk vs Tasklet — 언제 무엇을 쓰는가 + → 소재 3 (Spring Batch 운영 기능 5가지) + +4. Reader 선택 — CursorReader + Partitioning + → 소재 8, 9 (GROUP BY에서 Paging이 치명적인 이유) + +5. 전체 재계산 vs 증분 — Late-Arriving Fact + → 소재 12 (취소가 과거 데이터를 변경하는 문제) + +6. 데이터 소스 설계 — 단일 소스 원칙 + → 소재 4 하단 (Redis fallback 제거 판단) + +7. 마무리 — 10주 회고 +``` diff --git a/docs/design/volume-10/10-batch-test-results.md b/docs/design/volume-10/10-batch-test-results.md new file mode 100644 index 0000000000..c392e8d987 --- /dev/null +++ b/docs/design/volume-10/10-batch-test-results.md @@ -0,0 +1,299 @@ +# ProductRankingMvJob E2E 테스트 결과 + +> 실행일: 2026-04-17 +> 테스트 클래스: `ProductRankingMvJobE2ETest` +> 경로: `apps/commerce-batch/src/test/java/com/loopers/job/rankingmv/ProductRankingMvJobE2ETest.java` +> 결과: **10/10 PASSED** (기능 7 + 시각화 1 + 대규모 1 + 벤치마크 1) + +--- + +## 테스트 환경 + +| 항목 | 값 | +|------|-----| +| DB | MySQL 8.0 (Testcontainers — Docker 컨테이너) | +| Spring Batch Test | `@SpringBatchTest` + `@SpringBootTest` | +| DDL | `schema-batch-test.sql` (BEFORE_TEST_CLASS) | +| targetDate | `20260416` | + +--- + +## 테스트 목록 + +### 1. weeklySuccess — 주간 정상: 시드 데이터 기반 주간 TOP 100 적재 + +| 항목 | 내용 | +|------|------| +| **시나리오** | 상품 150개 + 7일치 메트릭 시드 → weekly Job 실행 | +| **검증** | Job 상태 COMPLETED, MV 100건 적재, 1위 = product_id 150 (최고 점수), staging 150건 전체 존재 | +| **결과** | PASSED | +| **의미** | 3-Step 파이프라인 (Cleanup → Partitioned Aggregate → Merge) 정상 동작. LIMIT 100 적용 확인 | + +### 2. weeklyLessThan100Products — 주간: 상품이 100개 미만이면 있는 만큼만 적재 + +| 항목 | 내용 | +|------|------| +| **시나리오** | 상품 30개 + 7일치 메트릭 → weekly Job 실행 | +| **검증** | Job COMPLETED, MV 30건 (LIMIT 100이지만 데이터가 30개이므로 30건) | +| **결과** | PASSED | +| **의미** | TOP 100 상한은 있되 데이터가 부족하면 있는 만큼만 적재하는 유연한 처리 확인 | + +### 3. monthlySuccess — 월간 정상: 30일 데이터 집계 + +| 항목 | 내용 | +|------|------| +| **시나리오** | 상품 50개 + 30일치 메트릭 → monthly Job 실행 | +| **검증** | Job COMPLETED, `mv_product_rank_monthly` 50건 적재 | +| **결과** | PASSED | +| **의미** | scope=monthly → 30일 윈도우 + `mv_product_rank_monthly` 테이블 분기 정상 동작 | + +### 4. idempotentDoubleExecution — 멱등성: 같은 파라미터로 2회 실행해도 결과 동일 + +| 항목 | 내용 | +|------|------| +| **시나리오** | 상품 50개 + 7일치 메트릭 → weekly Job 2회 연속 실행 | +| **검증** | 2차 실행도 COMPLETED, MV 50건 (중복 없음) | +| **결과** | PASSED | +| **의미** | CleanupTasklet이 기존 period_key 데이터를 삭제 후 재적재 → 멱등성 보장. RunIdIncrementer로 JobInstance 구분 | + +### 5. noDataProducesEmptyMv — 엣지: 데이터 없는 날짜로 실행하면 빈 MV + +| 항목 | 내용 | +|------|------| +| **시나리오** | 상품 10개만 시드 (메트릭 없음) → weekly Job 실행 | +| **검증** | Job COMPLETED, MV 0건 | +| **결과** | PASSED | +| **의미** | product_metrics가 비어있어도 Job이 FAILED 되지 않고 정상 완료. Partitioner가 빈 범위를 안전하게 처리 | + +### 6. partialDataAggregated — 엣지: 7일 미만 데이터면 있는 만큼만 집계 + +| 항목 | 내용 | +|------|------| +| **시나리오** | 상품 20개 + 3일치 메트릭 (7일 미만) → weekly Job 실행 | +| **검증** | Job COMPLETED, MV 20건 | +| **결과** | PASSED | +| **의미** | 슬라이딩 윈도우 7일 중 3일만 있어도 있는 데이터만으로 집계. 부분 데이터 허용 | + +### 7. cancellationReflectedInScore — 엣지: 취소 반영: cancel_amount가 score에 반영 + +| 항목 | 내용 | +|------|------| +| **시나리오** | 상품1: 매출 100만/취소 0, 상품2: 매출 200만/취소 150만(순매출 50만) → weekly Job 실행 | +| **검증** | Job COMPLETED, 1위 = product_id 1 (순매출 100만 > 50만) | +| **결과** | PASSED | +| **의미** | Score SQL에서 `sales_amount - cancel_amount_by_event_date` 반영 확인. 취소가 많은 상품의 순위 하락 검증 | + +--- + +## 수정 이력 (테스트 통과를 위한 코드 수정) + +### 수정 1: Partitioner를 Bean에서 private 메서드로 변경 + +**파일**: `ProductRankingMvJobConfig.java` + +| Before | After | +|--------|-------| +| `@JobScope @Bean productIdPartitioner()` | `private Partitioner createPartitioner(targetDate, scope)` | + +**원인**: `@Value("#{jobParameters['targetDate']}")` SpEL을 사용하는 Bean에 `@JobScope`가 없어 context 로딩 시 `SpelEvaluationException` 발생. `@JobScope`를 추가하면 `@SpringBatchTest`의 `JobScopeTestExecutionListener`와 충돌. + +**해결**: Partitioner를 Spring Bean이 아닌 private 메서드로 변경하여 `partitionedAggregateStep` 내부에서 직접 호출. `targetDate`, `scope`는 이미 `@JobScope`인 step 메서드의 파라미터로 주입받으므로 별도 Bean 불필요. + +### 수정 2: runJob 반환 타입 변경 + +**파일**: `ProductRankingMvJobE2ETest.java` + +| Before | After | +|--------|-------| +| `private JobExecution runJob(String scope)` | `private BatchStatus runJob(String scope)` | + +**원인**: `@SpringBatchTest`의 `JobScopeTestExecutionListener`가 테스트 클래스의 모든 `getDeclaredMethods()`를 스캔하여 `JobExecution` 반환 타입 메서드를 찾음. `runJob(String)`을 발견하고 인자 없이 호출 시도 → `HippyMethodInvoker`에서 `No matching arguments found for method: runJob` 에러. + +**해결**: 반환 타입을 `BatchStatus`로 변경하여 listener의 스캔 대상에서 제외. 모든 테스트는 `execution.getStatus()`만 사용하므로 기능적 영향 없음. + +--- + +## 커버리지 분석 + +| 검증 범위 | 테스트 | +|-----------|--------| +| **3-Step 파이프라인 정상 흐름** | weeklySuccess, monthlySuccess | +| **Partitioning (product_id 범위 분할)** | weeklySuccess (150개 → GRID_SIZE=4 파티션) | +| **LIMIT 100 상한** | weeklySuccess (150개 중 100개), weeklyLessThan100Products (30개 중 30개) | +| **scope 분기 (weekly/monthly)** | weeklySuccess, monthlySuccess | +| **멱등성 (Cleanup + RunIdIncrementer)** | idempotentDoubleExecution | +| **빈 데이터 안전 처리** | noDataProducesEmptyMv | +| **부분 기간 데이터** | partialDataAggregated | +| **취소 반영 (cancel_amount)** | cancellationReflectedInScore | +| **Score 순위 정확성** | weeklySuccess (1위=150L), cancellationReflectedInScore (1위=1L) | + +--- + +## 실 환경 배치 실행 + API 호출 검증 + +> 실행일: 2026-04-17 +> 환경: Docker MySQL 8.0 + Redis Master/Replica + commerce-api (localhost:8080) +> 캡처 파일: [`docs/captures/04-ranking-api-capture.md`](../../captures/volume-10/04-ranking-api-capture.md) + +### 데이터 규모 + +| 항목 | 값 | +|------|-----| +| 노트북 사양 | Apple M5 Pro, 18코어, 48GB RAM | +| 상품 수 | 1,020개 (20브랜드 × 50종 + 기본 20개) | +| 메트릭 행 수 | 30,600행 (1,020 × 30일) | +| 데이터 생성 방식 | Python 스크립트로 브랜드명 + 모델명 + 컬러/사이즈 조합 랜덤 생성 (크롤링 아님) | + +### 시드 데이터 트렌드 패턴 (6가지) + +| 타입 | 비율 | 설명 | +|------|------|------| +| A) 급상승 | 5% (51개) | 과거 23일 미미 → 최근 7일 폭발 (view 6K, sales 250만/일) | +| B) 장기 강자 | 10% (102개) | 30일 꾸준히 높음 (view 3.5K, sales 180만/일) | +| C) 하락 추세 | 5% (51개) | 과거 23일 높음 → 최근 7일 급락 | +| D) 오늘 바이럴 | 2% (20개) | 오늘만 폭발 (view 18K, sales 600만) | +| E) 취소 높음 | 3% (31개) | 매출 높지만 취소 50~70% | +| F) 일반 | 75% (765개) | 보통 수준 (view 500, sales 20만/일) | + +### 배치 실행 결과 + +| 항목 | weekly | monthly | +|------|--------|---------| +| 파티션 | 4 (productId 1~255, 256~510, 511~765, 766~1020) | 4 | +| 소요 시간 | 275ms | 309ms | +| 적재 건수 | 100 (TOP 100) | 100 | +| 메트릭 기간 | 7일 (04-01~04-07) | 30일 (03-08~04-07) | +| Job 상태 | COMPLETED | COMPLETED | + +### API 호출 결과 (TOP 5 비교) + +``` +GET /api/v1/rankings?scope={daily|weekly|monthly}&date=20260407&page=0&size=20 +``` + +| 순위 | 일간 (Redis) | 주간 (MV) | 월간 (MV) | +|:----:|-------------|-----------|-----------| +| 1 | 아디다스 캠퍼스 올리브 (바이럴) | 나이키 에어리프트 카키 (급상승) | 반스 슬립온 올리브 (장기강자) | +| 2 | 살로몬 아웃펄스 네이비 (바이럴) | 컨버스 런스타하이크 그레이 (급상승) | 스투시 카고바지 화이트 (장기강자) | +| 3 | 뉴발란스 530 올리브 (바이럴) | 스투시 월드투어후디 카키 (급상승) | 리복 클럽C85 인디고 (장기강자) | +| 4 | 디스이즈네버댓 SP로고T (바이럴) | 아디다스 포럼 네이비 (급상승) | 노스페이스 1996레트로 크림 (장기강자) | +| 5 | 컨버스 올스타 블랙 (바이럴) | 아디다스 오즈위고 크림 (급상승) | 뉴발란스 990v6 인디고 (장기강자) | + +### 핵심 관찰 + +1. **일간/주간/월간 TOP 20이 완전히 다른 상품으로 구성** — Lambda Architecture의 시간 윈도우별 랭킹 차이가 명확 +2. **바이럴 상품**: 일간 1위 → 주간 100위 밖 → 월간 100위 밖 (1일치만 반영) +3. **급상승 상품**: 일간 중위 → 주간 상위 → 월간 100위 밖 (23일간 미미) +4. **장기 강자**: 일간 하위 → 주간 하위 → 월간 상위 (30일 꾸준한 실적) +5. **Score 범위**: daily 0.73~0.83 < weekly 0.84~0.88 < monthly 0.94~0.96 (누적 기간에 비례) +6. **취소 반영**: 취소율 50~70% 상품은 순매출 차감으로 순위 하락 확인 + +--- + +## 대규모 테스트 결과 (10만 건) + +> 실행일: 2026-04-17 +> 환경: Testcontainers MySQL 8.0 (`--innodb-buffer-pool-size=256M`) + Gradle `-Xmx2g` +> 테스트 메서드: `largeScalePartitionedBatchTest` + +### 데이터 규모 + +| 항목 | 값 | +|------|-----| +| 상품 수 | 100,000개 | +| 메트릭 행 수 | 3,000,000행 (100,000 × 30일) | +| 시드 방식 | `JdbcTemplate.batchUpdate()` (1,000건씩 벌크 INSERT) | +| 상품 시드 소요 | 1,137ms | +| 메트릭 시드 소요 | 79,518ms (~80초) | + +### 6가지 트렌드 패턴 + +| 그룹 | Product ID 범위 | 비율 | 설명 | +|------|----------------|------|------| +| A) 급상승 | 1~5,000 | 5% | 최근 7일 폭발 (view 9K, sales 300만/일), 이전 미미 | +| B) 장기강자 | 5,001~15,000 | 10% | 30일 꾸준히 높음 (view 3K, sales 200만/일) | +| C) 하락추세 | 15,001~20,000 | 5% | 이전 높음 → 최근 7일 급락 | +| D) 바이럴 | 20,001~22,000 | 2% | 오늘만 폭발 (view 15K, sales 500만) | +| E) 취소높음 | 22,001~25,000 | 3% | 매출 높지만 취소 50~70% | +| F) 일반 | 25,001~100,000 | 75% | 보통 수준 | + +### 배치 실행 결과 + +| 항목 | weekly | monthly | +|------|--------|---------| +| Partitioning | 4 Worker (각 25,000건 균등) | 4 Worker (각 25,000건 균등) | +| 소요 시간 | **2,205ms** | **2,564ms** | +| MV 적재 건수 | 100 (TOP 100) | 100 (TOP 100) | +| Staging 적재 | 100,000건 | 100,000건 | +| Job 상태 | COMPLETED | COMPLETED | + +### Step별 소요 시간 + +| Step | weekly | monthly | +|------|--------|---------| +| cleanupStep | 19ms | 352ms (staging 10만건 삭제) | +| partitionedAggregateStep | 1,977ms | 2,014ms | +| ├ Worker 1 (partition0) | 1,663ms | 1,269ms | +| ├ Worker 2 (partition1) | 1,695ms | 1,274ms | +| ├ Worker 3 (partition2) | 1,642ms | 1,315ms | +| └ Worker 4 (partition3) | 1,697ms | 1,274ms | +| mergeStep | 74ms | 74ms | + +### 1위 검증 + +| scope | 1위 상품 | 트렌드 유형 | 의미 | +|-------|---------|-----------|------| +| weekly | product_5000 (급상승) | 최근 7일 폭발 | 7일 윈도우에서 급상승 상품이 장기강자를 이김 | +| monthly | product_15000 (장기강자) | 30일 꾸준히 높음 | 30일 윈도우에서 장기강자가 급상승을 역전 | + +### 파티션 균등 분배 + +``` +[Partitioner] partition0: productId 1~25000 (25,000건) +[Partitioner] partition1: productId 25001~50000 (25,000건) +[Partitioner] partition2: productId 50001~75000 (25,000건) +[Partitioner] partition3: productId 75001~100000 (25,000건) +``` + +DISTINCT product_id 사전 조회 기반 분할로 4 파티션 완전 균등 분배. Worker별 소요 시간 편차 < 60ms. + +### 규모별 성능 비교 + +| 규모 | 상품 수 | 메트릭 행 수 | weekly | monthly | +|------|--------|------------|--------|---------| +| 중규모 | 1,020 | 30,600 | 275ms | 309ms | +| **대규모** | **100,000** | **3,000,000** | **2,205ms** | **2,564ms** | + +데이터가 100배 증가해도 소요 시간은 ~8배만 증가 — Partitioning + GROUP BY 최적화로 sub-linear scaling 달성. + +--- + +## Partitioning 벤치마크 (gridSize=1 vs gridSize=4) + +> 실행일: 2026-04-17 +> 테스트 메서드: `partitionBenchmark` +> 데이터: 100,000 상품 × 30일 = 3,000,000행 (6가지 트렌드 패턴) + +### 테스트 방식 + +동일 시드 데이터를 한 번만 생성한 후, `ReflectionTestUtils.setField(jobConfig, "gridSize", N)`으로 gridSize만 교체하여 weekly/monthly 각 2회 실행. + +1. gridSize=1로 weekly Job 실행 → 소요 시간 측정 +2. MV + staging DELETE → gridSize=4로 weekly Job 실행 → 소요 시간 측정 +3. gridSize=1로 monthly Job 실행 → 소요 시간 측정 +4. MV + staging DELETE → gridSize=4로 monthly Job 실행 → 소요 시간 측정 + +### 결과 + +| 구성 | weekly (7일, 70만행) | monthly (30일, 300만행) | +|------|---------------------|------------------------| +| gridSize=1 (단일 스레드) | **3,691ms** | **3,842ms** | +| gridSize=4 (4 Partition 병렬) | **1,746ms** | **2,188ms** | +| **향상률** | **2.1x** | **1.8x** | + +### 분석 + +- **이론적 상한: 4x**, 실측: weekly **2.1x**, monthly **1.8x** +- 데이터가 4배 많은 monthly에서 향상률이 떨어지는 이유: Amdahl's Law에 의해 직렬 구간(Partitioner의 `DISTINCT product_id` 쿼리, mergeStep의 `ROW_NUMBER() OVER`, JobRepository 메타데이터 저장)의 비중이 데이터량에 비례해 커짐 +- Testcontainers MySQL에서 innodb-buffer-pool-size=256M 제약 환경 기준. 프로덕션 MySQL에서는 더 큰 향상률이 기대됨 +- 4개 모든 측정(weekly×2, monthly×2) MV 100건 적재 + Job COMPLETED 검증 통과 diff --git a/docs/design/volume-10/10-large-scale-test-prompt.md b/docs/design/volume-10/10-large-scale-test-prompt.md new file mode 100644 index 0000000000..0cceb9ae40 --- /dev/null +++ b/docs/design/volume-10/10-large-scale-test-prompt.md @@ -0,0 +1,66 @@ +# 10만 건 상품 데이터 기반 대규모 배치 테스트 프롬프트 + +> 이 프롬프트를 다른 컴퓨터의 Claude Code 세션에 붙여넣고 실행하세요. +> 사전 조건: `git pull origin volume-10` 완료, Docker 실행 중 + +--- + +## 맥락 + +MV 랭킹 배치 Job(`ProductRankingMvJobConfig`)이 Partitioning(4 Worker)으로 구현되어 있다. +기존 E2E 테스트는 1,020개 상품으로 기능 검증만 완료한 상태이며, Partitioning의 성능 이점을 검증하려면 최소 10만 건 규모의 데이터가 필요하다. + +## 요청 + +### 1. 10만 건 상품 대규모 테스트 작성 + +`ProductRankingMvJobE2ETest`에 10만 건 테스트를 추가해줘: + +- **상품 10만 개** 시드 (`product` 테이블) +- **30일치 메트릭** 시드 (`product_metrics` 테이블) → 10만 × 30 = 300만 행 +- 시드 데이터는 `JdbcTemplate.batchUpdate()`로 벌크 INSERT (행 단위 INSERT는 시드 자체가 수십 분 걸림) +- 6가지 트렌드 패턴 적용 (급상승 5%, 장기강자 10%, 하락 5%, 바이럴 2%, 취소높음 3%, 일반 75%) + +검증할 것: +- Job 상태 COMPLETED +- MV에 정확히 100건 적재 +- 1위 상품의 정확성 +- **소요 시간 측정**: `System.currentTimeMillis()` 또는 StepExecution의 시작/종료 시각으로 측정 +- **파티션별 처리 건수 균등 여부**: 로그에서 `[Partitioner] partition{}: productId {}~{} ({}건)` 확인 + +### 2. Partitioning 효과 비교 (선택) + +가능하면 gridSize를 1로 변경한 테스트도 추가하여 단일 스레드 vs 4 파티션의 소요 시간을 비교해줘. + +### 3. 결과 기록 + +테스트 결과를 `docs/design/volume-10/10-batch-test-results.md`에 추가: + +```markdown +## 대규모 테스트 결과 (10만 건) + +| 항목 | 값 | +|------|-----| +| 상품 수 | 100,000 | +| 메트릭 행 수 | 3,000,000 | +| Partitioning | 4 Worker | +| weekly 소요 시간 | ?ms | +| monthly 소요 시간 | ?ms | +| 파티션 균등 분배 | 각 파티션 약 25,000건 (±?) | +| MV 적재 건수 | 100 | +``` + +### 4. 주의사항 + +- 시드에 시간이 오래 걸릴 수 있다. `batchUpdate()`로 1,000건씩 벌크 INSERT 권장 +- Testcontainers MySQL의 메모리가 부족할 수 있다. `withCommand("--innodb-buffer-pool-size=256M")` 추가 고려 +- 테스트가 메모리 부족으로 실패하면, Gradle JVM 옵션에 `-Xmx2g` 추가: + ``` + // build.gradle.kts 또는 gradle.properties + tasks.test { jvmArgs = listOf("-Xmx2g") } + ``` +- 기존 7개 테스트에 영향을 주지 않도록 독립적인 `@Test` 메서드로 추가 + +### 5. PR 반영 + +테스트 완료 후 결과를 커밋하고 push해줘. PR draft(`10-pr-draft.md`)의 Summary와 성능 테이블도 10만 건 결과로 업데이트해줘. diff --git a/docs/design/volume-10/10-partition-benchmark-prompt.md b/docs/design/volume-10/10-partition-benchmark-prompt.md new file mode 100644 index 0000000000..d8765f1154 --- /dev/null +++ b/docs/design/volume-10/10-partition-benchmark-prompt.md @@ -0,0 +1,79 @@ +# Partitioning 성능 비교 테스트 + +## 목적 + +gridSize=1(단일 스레드) vs gridSize=4(4 Partition)의 소요 시간을 비교하여 Partitioning의 효과를 측정한다. + +## 요청 + +### 1. GRID_SIZE를 외부에서 주입 가능하게 변경 + +`ProductRankingMvJobConfig.java`의 `GRID_SIZE`를 `application.yml` 또는 JobParameter로 주입 가능하게 변경: + +```java +// 현재: private static final int GRID_SIZE = 4; +// 변경: application.yml에서 주입 +@Value("${ranking.mv.grid-size:4}") +private int gridSize; +``` + +또는 더 간단하게, 테스트에서만 GRID_SIZE를 1로 바꿔서 돌리는 방법: +- `ProductRankingMvJobConfig`를 상속한 테스트용 Config에서 GRID_SIZE를 override +- 또는 ReflectionTestUtils로 GRID_SIZE를 변경 + +### 2. 벤치마크 테스트 추가 + +`ProductRankingMvJobE2ETest`에 추가: + +```java +@Test +@DisplayName("벤치마크 — gridSize=1 vs gridSize=4 소요 시간 비교") +void partitionBenchmark() throws Exception { + int productCount = 100_000; + seedProductsBulk(productCount); + seedMetricsBulkWithTrends(productCount, 30, TARGET_DATE); + + // gridSize=1로 실행 + // (GRID_SIZE를 1로 변경하는 방법 적용) + long t0 = System.currentTimeMillis(); + runJob("weekly"); + long singleMs = System.currentTimeMillis() - t0; + + // cleanup + jdbcTemplate.update("DELETE FROM mv_product_rank_weekly WHERE period_key = ?", TARGET_DATE); + jdbcTemplate.update("DELETE FROM mv_product_rank_staging WHERE period_key = ?", TARGET_DATE); + + // gridSize=4로 실행 + // (GRID_SIZE를 4로 복원) + t0 = System.currentTimeMillis(); + runJob("weekly"); + long partitionedMs = System.currentTimeMillis() - t0; + + System.out.println("═══════════════════════════════════════"); + System.out.println(" Partitioning 벤치마크 (10만 상품)"); + System.out.println("═══════════════════════════════════════"); + System.out.printf(" gridSize=1: %,dms%n", singleMs); + System.out.printf(" gridSize=4: %,dms%n", partitionedMs); + System.out.printf(" 향상률: %.1fx%n", (double) singleMs / partitionedMs); + System.out.println("═══════════════════════════════════════"); +} +``` + +### 3. 결과 기록 + +PR draft(`10-pr-draft.md`)의 Partitioning 섹션을 업데이트: + +``` +10만 상품 × 30일(300만 행) 기준 측정값: + +gridSize=1 (단일): weekly ?ms +gridSize=4 (병렬): weekly ?ms +향상률: ?x +``` + +### 4. 주의사항 + +- 시드 데이터는 한 번만 생성하고, gridSize만 바꿔서 2회 실행 +- 각 실행 전 MV + staging을 DELETE +- Testcontainers MySQL의 `innodb-buffer-pool-size=256M` 설정 확인 +- JVM `-Xmx2g` 설정 확인 diff --git a/docs/design/volume-10/10-pr-draft.md b/docs/design/volume-10/10-pr-draft.md new file mode 100644 index 0000000000..06fb467072 --- /dev/null +++ b/docs/design/volume-10/10-pr-draft.md @@ -0,0 +1,189 @@ +# MV 기반 주간/월간 랭킹 배치 시스템 구축 + +## 📌 Summary + +- **배경**: 대규모 데이터를 다루는 이커머스 환경에서 DB 원장 기준의 기간별 집계 랭킹이 필요하다. +- **목표**: Spring Batch로 `product_metrics`(일간 메트릭)를 주간/월간 단위로 합산하여 MV 테이블에 TOP 100 랭킹을 적재하고, API에서 조회할 수 있도록 한다. +- **결과**: Partitioning + Chunk-Oriented 3-Step 배치 구현, API 확장(MV 단일 소스 + 전일 fallback), E2E 테스트 10/10 통과, 10만 상품 기준 weekly 약 1.7초, monthly(300만 행) 약 2.2초에 집계 완료. Partitioning 벤치마크 gridSize=1 대비 gridSize=4가 weekly 2.1x, monthly 1.8x 향상. + +--- + +## 🧭 Context & Decision + +### 문제 정의 + +- **현재 동작**: 일간 메트릭(`product_metrics`)은 적재되어 있지만, 주간/월간 단위의 기간 집계 랭킹은 존재하지 않는다. +- **문제**: "이번 주/이번 달 가장 많이 팔린 상품"이라는 공개 랭킹 보드를 제공하려면 DB 원장 기반의 정확한 기간 집계가 필요하다. +- **성공 기준**: `product_metrics` 기반으로 주간(7일)/월간(30일) 메트릭을 합산하여 TOP 100 랭킹을 MV 테이블에 적재하고, API에서 조회할 수 있다. + +### 선택지와 결정 + +#### 1. Score 계산 방식 + +- **A. 균등 합산** (채택): 기간 내 메트릭을 SUM한 뒤 score 공식 1회 적용. 30일 전이나 오늘이나 동등한 가중치로 "기간 총 실적"을 평가 +- **B. 지수 감쇠**: 일별 score에 `0.97^i`를 곱하여 오래된 날일수록 가중치를 줄임(반감기 약 23일). 같은 총 매출이라도 최근에 집중된 상품이 더 높은 순위를 받음. 전시 기간이 길어서 누적된 score가 높은 상품의 이점을 희석할 수 있다는 특징이 있음 +- **결정**: "이번 달 베스트셀러 = 총 판매량 기준"이라는 공개 랭킹 보드의 비즈니스 의미에 부합하는 균등 합산을 채택 +- **트레이드오프**: 균등 합산은 전시 기간이 긴 상품이 유리하다. 지수 감쇠는 이를 희석할 수 있지만, "총 실적"이라는 의미에 집중해야 한다고 생각했다. + +#### 2. 전체 재계산 vs 증분 계산 + +- **A. 전체 재계산** (채택): 매일 원장에서 기간 전체를 GROUP BY +- **B. 증분 계산**: 어제 결과 - 가장 오래된 날 + 오늘 (93% 데이터 절감) +- **결정**: 이커머스에서 주문 취소/환불은 원주문과 다른 날에 발생(Late-Arriving Fact). 증분 계산은 "과거 데이터가 불변"이라는 전제가 필요하지만, `cancel_by_order_date`가 과거 행을 사후 갱신하므로 이 전제가 깨진다. 성능 차이(~10초 vs ~3초)는 1일 1회 배치에서 운영 영향 없음 + +#### 3. Chunk vs Tasklet + +- **A. Tasklet**: `INSERT INTO...SELECT + RANK() OVER + LIMIT 100`으로 SQL 한 방 처리. 네트워크 왕복 0 +- **B. Chunk-Oriented** (채택): Reader/Writer 분리 + faultTolerant + retry +- **결정**: 이 작업은 Tasklet으로도 가능하지만, Chunk를 선택하면 Spring Batch의 운영 기능(`faultTolerant + retry + ExponentialBackOffPolicy`, `StepExecution` 자동 기록, `StepMonitorListener`)을 활용할 수 있다. 100건에 대한 네트워크 왕복 비용(< 1ms)보다 이 운영 기능의 가치가 크다 + +#### 4. Reader 선택 + 병렬 처리 + +- **A. JdbcPagingItemReader**: 멀티스레드 안전하지만, GROUP BY 집계 쿼리를 페이지마다 재실행 +- **B. JdbcCursorItemReader + Partitioning** (채택): GROUP BY 1회 실행 + product_id 범위 분할로 병렬 처리 +- **결정**: GROUP BY 집계에서 Paging은 페이지마다 집계를 반복하므로 규모가 커질수록 치명적. CursorReader의 멀티스레드 한계(ResultSet 공유 상태)를 Partitioning으로 극복 +- **참고**: [Spring Batch Scalability — Partitioning](https://docs.spring.io/spring-batch/reference/scalability.html) + +#### 5. Redis fallback vs 전일 MV fallback + +- **A. Redis fallback**: MV 장애 시 Redis에서 조회 +- **B. 전일 MV fallback** (채택): 당일 MV가 없으면 전일 MV 반환 +- **결정**: Redis(지수 감쇠)와 MV(균등 합산)는 다른 공식이므로, 소스 전환 시 순위가 바뀌는 데이터 불일치 발생. 전일 MV는 같은 공식 + 1일 stale로 순위 불일치 없음 + +--- + +## 🏗️ Design Overview + +### 변경 범위 + +- **영향 받는 모듈**: `modules/jpa`, `commerce-batch`, `commerce-streamer`, `commerce-api` +- **신규 추가**: + - `ScoreFormula.java` (modules/jpa) — Score 공식 Single Source of Truth. 3개 앱 모듈이 공유 + - `ScoreFormulaTest.java` (modules/jpa) — Score 공식 단위 테스트 + - `ProductRankingMvJobConfig.java` — Job + 3 Step + Partitioner + Reader + Processor + Writer + - `CleanupTasklet.java` — DELETE + 데이터 보존 정책 + - `MvProductRank.java` / `MvProductRankWeekly.java` / `MvProductRankMonthly.java` — MV 엔티티 + - `MvProductRankRepository.java` + JPA 구현체 — MV 조회 + - `mv_product_rank_weekly` / `mv_product_rank_monthly` / `mv_product_rank_staging` — DDL +- **수정**: + - `RankingScoreUpdater.java` — calculateScore()를 ScoreFormula에 위임, weekly/monthly 키 생성 메서드 및 상수 제거 + - `RankingCorrectionJobConfig.java` — calculateScore()를 ScoreFormula에 위임 + - `RankingProperties.java` / `RankingCorrectionProperties.java` — Weights inner record 제거, ScoreFormula.Weights 사용 + - `RankingFacade.java` — weekly/monthly 조회 경로를 Redis → MV로 변경 + - `RankingCarryOverScheduler.java` — Redis weekly/monthly carry-over 제거 (MV가 담당하므로 daily carry-over만 유지) + - `RankingRedisRepository.java` — 미사용 RANKING_WEEKLY_PREFIX/RANKING_MONTHLY_PREFIX 상수 제거 + +### 주요 컴포넌트 책임 + +- `ProductRankingMvJobConfig`: 3-Step Job 오케스트레이션. Partitioner로 product_id 범위 분할, Worker Step에서 Chunk-Oriented 집계, mergeStep에서 Global TOP 100 추출 +- `CleanupTasklet`: 당일 period_key의 MV/staging DELETE + 3일 이전 데이터 퍼지. 멱등성 보장의 핵심 +- `RankingFacade`: scope별 데이터 소스 분기. daily → Redis, weekly/monthly → MV(당일 → 전일 fallback) + +--- + +## 🔁 Flow Diagram + +### 배치 Job 흐름 + +```mermaid +flowchart TD + A[ProductRankingMvJob 시작] --> B[Step 1: CleanupTasklet] + B -->|FAILED| Z[Job 종료] + B -->|COMPLETED| C[Step 2: Partitioned Aggregate] + + C --> D1[Worker 1: product_id 1~25000] + C --> D2[Worker 2: product_id 25001~50000] + C --> D3[Worker 3: product_id 50001~75000] + C --> D4[Worker 4: product_id 75001~100000] + + D1 -->|GROUP BY → ScoreFormula| S[staging 테이블] + D2 -->|GROUP BY → ScoreFormula| S + D3 -->|GROUP BY → ScoreFormula| S + D4 -->|GROUP BY → ScoreFormula| S + + S --> E[Step 3: Merge] + E -->|ROW_NUMBER + LIMIT 100| F[MV 테이블 TOP 100] +``` + +### API 조회 흐름 + +```mermaid +sequenceDiagram + autonumber + participant Client + participant RankingFacade + participant MvProductRankRepo + participant RankingRedisRepo + participant ProductRepo + + Client->>RankingFacade: GET /api/v1/rankings?scope=weekly + + alt scope = daily + RankingFacade->>RankingRedisRepo: ZREVRANGE (Redis ZSET) + RankingRedisRepo-->>RankingFacade: RankingEntry[] + else scope = weekly | monthly + RankingFacade->>MvProductRankRepo: findByPeriodKey(당일) + MvProductRankRepo-->>RankingFacade: MvProductRank[] + alt 당일 데이터 없음 + RankingFacade->>MvProductRankRepo: findByPeriodKey(전일) + MvProductRankRepo-->>RankingFacade: MvProductRank[] + end + end + + RankingFacade->>ProductRepo: findAllByIds(productIds) + ProductRepo-->>RankingFacade: ProductWithBrand[] + RankingFacade-->>Client: PagedRankingResponse +``` + +--- + +## 테스트 결과 + +### E2E 테스트: 10/10 PASSED + +| 항목 | 값 | +|------|-----| +| DB | MySQL 8.0 (Testcontainers) | +| 테스트 클래스 | `ProductRankingMvJobE2ETest` | +| 데이터 | 테스트마다 독립 시드 (JdbcTemplate) | +| 결과 | **10/10 PASSED** (기능 7 + 시각화 1 + 대규모 1 + 벤치마크 1) | + +| 시나리오 | 검증 포인트 | +|---------|-----------| +| scope=weekly (150개 상품) | 3-Step 파이프라인 동작, TOP 100 적재, 1위 정확성 | +| scope=weekly (30개 상품) | 서비스 초기 등 상품이 부족해도 Job 정상 완료 | +| scope=monthly (30일) | 30일 윈도우 집계, monthly 테이블에 적재 | +| 멱등성 (2회 실행) | 중복 없이 동일 결과 | +| 데이터 없음 | Job COMPLETED, 빈 MV | +| 부분 데이터 (3일) | 있는 만큼만 집계 | +| 취소된 주문 반영 | 순매출 기준 순위 결정 | +| 시각화 (20개 상품 × 30일) | 일간/주간/월간 TOP 20 순위 차이 출력 | +| 대규모 (10만 × 30일) | 300만 행 4 Partition 병렬 집계, 파티션 균등 분배 | +| **벤치마크 (gridSize=1 vs 4)** | **단일 스레드 vs 4 Partition 병렬 소요 시간 비교** | + +### Partitioning 벤치마크 (gridSize=1 vs gridSize=4) + +| 구성 | weekly (7일, 70만행) | monthly (30일, 300만행) | +|------|---------------------|------------------------| +| gridSize=1 (단일 스레드) | 3,691ms | 3,842ms | +| gridSize=4 (4 Partition 병렬) | 1,746ms | 2,188ms | +| **향상률** | **2.1x** | **1.8x** | + +동일 데이터(10만 상품 × 30일 = 300만 행)를 `ReflectionTestUtils`로 gridSize만 교체하여 weekly/monthly 각 2회 측정. 4 Partition 병렬이 단일 스레드 대비 weekly 2.1x, monthly 1.8x 빠르다. + +데이터 4배(70만→300만)에도 gridSize=4 기준 소요 시간은 25%만 증가(1,746ms→2,188ms). Reader SQL의 GROUP BY가 scope와 무관하게 결과를 10만 건으로 압축하므로, Processor/Writer/Merge가 데이터 볼륨에 영향받지 않는 구조. + +--- + +## 리뷰 포인트 + +### Partitioning + CursorReader 조합시에 적절한 gridSize, 스테이징 테이블을 두는 효용 산정 방식 + +요구사항에 "대량의 데이터를 읽고 처리할 수 있도록 구성"이 명시되어 있어, 활성 상품 수가 수십만~수백만 규모로 성장하더라도 배치 윈도우 내에 처리 가능한 구조를 고려했습니다. + +GROUP BY 집계에서 PagingReader는 페이지마다 집계를 재실행하고, CursorReader는 멀티스레드에서 사용이 어려워서, Partitioning으로 product_id 범위를 분할하여 각 Worker가 독립 CursorReader를 갖도록 했습니다. + +질문: +- **gridSize를 4로 설정**했는데, 커넥션 풀 크기나 CPU 코어 수에 연동하거나 동적으로 조정해야 할 것 같습니다. 실무에서는 gridSize를 어떻게 설정하시나요? +- **스테이징 테이블에 전체 상품 집계 결과를 적재**한 후 mergeStep에서 TOP 100만 추출하는 구조인데, 상품 수가 많아지면 스테이징 적재 비용이 커집니다. 이 중간 저장 비용 대비 Partitioning의 병렬 처리 이점이 충분한지는 처리 속도만 고려해서 판단해도 될까요? + diff --git a/docs/design/volume-10/10-technical-writing-plan.md b/docs/design/volume-10/10-technical-writing-plan.md new file mode 100644 index 0000000000..3ba7a9220f --- /dev/null +++ b/docs/design/volume-10/10-technical-writing-plan.md @@ -0,0 +1,102 @@ +# 10. 테크니컬 라이팅 기획안 + +> Round 10 블로그 글의 구조, 방향, 소재 배치를 정리한 기획 문서. + +--- + +## 과제 가이드 기준 방향 조정 + +### 과제가 요구하는 것 + +| 항목 | 가이드 원문 | 우리 글에의 의미 | +|------|-----------|---------------| +| **포인트** | "무엇을 했다"보다 **"왜 그렇게 판단했는가"** | 구현 비중 ↓, 판단 과정 비중 ↑ | +| **톤** | 실력은 보이지만, 자만하지 않고, **고민이 읽히는 글** | 정답 제시 X, 고민의 과정을 보여준다 | +| **Trade-off** | 중요한 선택 1~2개. 왜 그 선택을, 대안은 뭐였는지, 다시 한다면? | 소재 12개를 나열하지 않고 **핵심 판단 몇 개에 집중** | +| **실전 연결** | "회사/서비스에서 써먹을 수 있겠다" 싶은 포인트 | 별도 섹션으로 실무 적용 관점 필수 | +| **회고** | 10주간 사고방식/문제 해결/설계 선택 과정의 성장 | 이번 주만이 아니라 전체 여정 안에서의 위치 | + +### 레퍼런스 글에서 가져올 것 / 가져오지 않을 것 + +| 가져올 것 | 가져오지 않을 것 | +|----------|---------------| +| TL;DR, 비교표, 코드+해설 패턴, 볼드 인사이트 | AS-IS/TO-BE 구조 | +| 의문에서 시작하는 서사, 숫자로 증명, 한계 인정 | Phase별 벤치마크 (우리 내용과 안 맞음) | +| 관통 철학 ("Lambda Architecture에서 두 Layer의 역할 분담") | 문제 N개→해결 N개 대응 구조 | + +--- + +## 글 구조 + +### 제목 + +"일간은 Redis, 주간/월간은 왜 다른가 — 이커머스 랭킹 배치 설계기" + +### 전체 뼈대 + +글의 뼈대를 **판단의 흐름**으로 잡는다. 과제 가이드의 4가지 요구("판단 중심", "Trade-off", "실전 연결", "회고")를 각각 섹션으로 녹인다. + +``` +TL;DR + +1. 이 글의 맥락 + +2. Redis에 이미 랭킹이 있는데, MV를 왜 만드는가 + +3. 설계 판단들 + 3.1 "주간 베스트"는 총 판매량인가, 최근 인기인가 + 3.2 시간 윈도우: 매주 월요일에 리셋되는 랭킹이 맞는가 + 3.3 Chunk vs Tasklet: 90개 실무 Job이 알려준 것 + 3.4 Score 계산은 DB에서 끝내야 한다 + 3.5 CursorReader: GROUP BY 집계에서 PagingReader가 위험한 이유 + 3.6 매번 원장에서 재계산하는 게 비효율 아닌가 + +4. 구현: 3-Step Chunk Job + +5. 시행착오 + +6. 실전에서라면 + +7. 돌아보며 +``` + +### 각 판단의 내부 구조 + +``` +왜 이 판단이 필요했는가 (1~2문장) + ↓ +대안 비교 (표) + ↓ +결정 + 근거 (볼드 인사이트) + ↓ +(선택적) 다시 한다면? / 실무에서의 의미 +``` + +--- + +## 소재 배치 + +| 소재 (10-technical-writing-topics.md) | 섹션 | 비중 | +|------|------|------| +| 4 (Redis vs MV) | **섹션 2** — 글의 출발점 | 높음 | +| 1 (Score 방식) | **3.1** | 높음 | +| 2 (윈도우) | **3.2** | 중간 | +| 3 (Chunk vs Tasklet) | **3.3** | 높음 | +| 7 (Best Practice) | **3.3** 안에서 근거 | 중간 | +| 5 (계산 위치) | **3.4** | 중간 | +| 6 (사전 집계) | **3.4** 보조 언급 | 낮음 | +| 8 (Cursor vs Paging) | **3.5** | 중간 | +| 9 (Partitioning) | **3.5** + **6 실전** | 중간 | +| 10 (멱등성) | **4 구현** | 낮음 | +| 11 (Job Instance) | **4 구현** | 낮음 | +| 12 (전체 재계산 vs 증분) | **3.6** | 높음 | + +비중 "높음" 4개(Topic 1, 3, 4, 12)가 글의 뼈대. 나머지가 근거와 디테일. + +--- + +## 관통 철학 + +**"Lambda Architecture에서 두 Layer의 가치는 같은 결과를 내는 것이 아니라, 같은 데이터로 다른 관점을 제공하는 것이다."** + +이 한 줄이 모든 판단의 출발점이자 결론. diff --git a/docs/design/volume-10/10-technical-writing-topics.md b/docs/design/volume-10/10-technical-writing-topics.md new file mode 100644 index 0000000000..ca556563da --- /dev/null +++ b/docs/design/volume-10/10-technical-writing-topics.md @@ -0,0 +1,1047 @@ +# 10. 설계 판단 근거 모음 + +> Round 10 과제를 수행하면서 발생한 설계 고민, 트레이드오프, 판단 근거를 기록한다. + +--- + +## 소재 1: MV Score 계산 — 균등 합산 vs 지수 감쇠 vs 일평균 + +### 이 고민이 시작된 맥락 + +Redis monthly의 지수 감쇠(`daily × 0.97^i`) 방식을 분석하다가, 이런 의문이 생겼다: **"지수 감쇠의 목적이 이미 전시된 기간의 편향을 보정하기 위함인가?"** 오래 전시된 상품은 노출 기간이 길어서 누적 조회수/판매량이 자연스럽게 높다. 감쇠로 이것을 보정할 수 있지 않을까? + +그런데 반대로 생각하면, 최근에 가중치를 두면 **월간 랭킹이 일간/주간과 비슷해질 수 있다**는 우려도 있었다. 이 양쪽의 긴장에서 "그러면 MV의 score는 어떤 방식이어야 하는가?"라는 질문이 시작되었다. + +추가로, 전시 기간 편향을 보정하는 다른 방법(일평균, 전환율)도 검토하면서, **공개 랭킹 보드에서 어떤 지표가 비즈니스적으로 의미 있는가**라는 근본적인 질문으로 이어졌다. + +### 검토한 3가지 방식 + +**방식 A: 균등 합산 (채택)** + +``` +score = f(SUM(30일 메트릭)) +``` + +- 30일간 메트릭을 단순 합산 후 score 공식 1회 적용 +- "기간 총 실적"을 공정하게 평가 +- 30일 전이나 오늘이나 동등한 가중치 + +**방식 B: 지수 감쇠** + +``` +monthly = Σ(i=0 ~ 29) daily_score × 0.97^i +``` + +- 최근 데이터에 높은 가중치 (반감기 약 23일) +- Redis monthly와 유사한 성격 +- "최근에 뜨는 상품"을 우대 + +**방식 C: 일평균** + +``` +score = f(SUM(메트릭) / COUNT(DISTINCT 전시일수)) +``` + +- 전시 기간에 관계없이 "일당 성과"로 비교 +- 전시 기간 편향 보정 가능 +- 표본 크기 문제 (1일만 전시된 상품이 과대평가) + +### 핵심 트레이드오프 + +#### 균등 합산을 채택한 이유: 공개 랭킹 보드의 비즈니스 의미 + +**"이번 달 베스트셀러"는 총 판매량 기준이 이커머스 업계 표준이다.** + +쿠팡, 무신사, 교보문고 등 주요 이커머스의 공개 랭킹 보드는 기간 총 실적 기준으로 운영된다. 소비자가 "인기 상품 TOP 100"을 볼 때 기대하는 것은 "가장 많이 팔린 상품"이지, "일평균 판매량이 높은 상품"이나 "최근에 급등한 상품"이 아니다. + +균등 합산은 이 비즈니스 의미에 정확히 부합한다: +- **MD/상품기획팀의 관점**: "이번 달 어떤 상품이 가장 많이 팔렸나?" → 총 실적 +- **소비자의 관점**: "다들 뭘 사고 있나?" → 총 판매량 순위 +- **경영진의 관점**: "매출 기여도가 높은 상품은?" → 총 매출 기준 + +#### 지수 감쇠를 선택하지 않은 이유: Lambda Architecture에서의 역할 분담 + +**"MV가 Redis와 같은 결과를 내면, MV를 만들 이유가 없다."** + +| 관점 | Redis (지수 감쇠) | MV (균등 합산) | +|------|------------------|---------------| +| 비즈니스 의미 | "지금 뜨는 상품" (트렌드) | "이번 달 베스트셀러" (누적 성과) | +| 소비자 시나리오 | 메인 페이지 실시간 인기 | 카테고리별 베스트, 기간별 랭킹 | +| 사업자 시나리오 | 실시간 모니터링 | 주간/월간 리포트, MD 성과 분석 | + +두 시스템이 다른 관점을 제공하는 것이 Lambda Architecture에서 Speed Layer와 Batch Layer의 역할 분담이다. Speed Layer(Redis)가 이미 트렌드를 반영하고 있으므로, Batch Layer(MV)는 "정확한 기간 집계"에 집중하는 것이 아키텍처적으로 맞다. + +숫자로 검증한 결과, 감쇠를 쓰더라도 월간이 일간/주간과 비슷해지지는 않는다. 하지만 감쇠를 쓸 이유가 없는 것이 핵심이다. Redis가 이미 하고 있는 일을 MV에서 반복하면 두 시스템의 결과가 수렴하고, MV의 존재 가치가 떨어진다. + +``` +상품 A: 30일간 매일 매출 100만원 (꾸준) +상품 B: 최근 5일간 매일 600만원 (급등), 나머지 0원 +총 실적: 둘 다 3000만원 + + 일간 주간(균등) 주간(감쇠) 월간(균등) 월간(감쇠) +상품 A 0.600 0.693 4.09 0.735 12.0 +상품 B 0.678 0.735 3.33 0.735 3.33 +승자 B B A 동점 A 압승 +``` + +#### 일평균을 선택하지 않은 이유: 공개 랭킹의 목적과 불일치 + +"전시 기간 편향"은 실제 운영에서 존재하는 문제다. 30일 전시된 상품이 3일 전시된 신상품보다 누적 실적이 높은 것은 당연하고, 이것이 "인기"를 정확히 반영하는가는 논쟁의 여지가 있다. + +그러나 일평균으로 전환하면 **공개 랭킹 보드로서의 비즈니스 목적과 충돌**한다: + +``` +상품 A: 전시 30일, 총 매출 3000만원, 일평균 100만원 +상품 B: 전시 3일, 총 매출 900만원, 일평균 300만원 + +일평균 기준: B 승 (300만 > 100만) +총 실적 기준: A 승 (3000만 > 900만) +``` + +- **MD팀이 원하는 "이번 달 베스트"는 A다.** 총 매출 3000만원 상품이 1위에 있어야 매출 기여도 분석이 된다 +- B가 1위가 되면 **"3일 만에 900만원 판 신상품이 베스트셀러"**라는 오해를 줄 수 있다 +- 표본 크기 문제: 1일 전시에 매출 500만원이면 일평균 500만원으로 A보다 위에 올라간다 + +**전시 기간 편향을 보정하려면 일평균보다 더 적합한 방법이 있다:** + +| 방법 | 적합한 시스템 | 이유 | +|------|-------------|------| +| **일평균** | 내부 분석 리포트, MD 대시보드 | "상품의 판매 효율"을 보려면 적합. 하지만 공개 랭킹의 지표는 아님 | +| **노출 대비 전환율** (order/view) | 개인화 추천 시스템 | "이 상품을 본 사람 중 몇 %가 샀는가"는 상품의 매력도를 측정. 하지만 공개 랭킹에 쓰면 조회 500회 전환율 10% 상품이 조회 100만회 전환율 0.5% 상품 위에 올라감 — 소비자가 기대하는 "인기 상품"이 아님 | +| **총 실적 (현행)** | 공개 랭킹 보드 | "가장 많이 팔린 상품" = 소비자와 사업자 모두 직관적으로 이해 | + +전환율이 유용한 곳은 **추천 시스템**이다. "이 사용자에게 어떤 상품을 노출할까?"를 결정할 때 전환율이 높은 상품을 추천하면 구매 확률이 높아진다. 이것은 개인화된 추천 영역이지, 전체 사용자에게 동일하게 보여주는 공개 랭킹과는 목적이 다르다. 다만 실제 이커머스에서 공개 랭킹의 내부 score 공식에 전환율을 보조 가중치로 섞을 가능성은 있다. 핵심은 **primary 지표가 전환율인 공개 랭킹은 없다**는 것이다. + +### 선택하지 않은 대안에서 배운 것 + +- 지수 감쇠를 검토하면서 **"같은 데이터로 다른 관점을 제공하는 것"**이 Lambda Architecture에서 Batch Layer의 존재 가치임을 이해했다 +- 일평균을 검토하면서 **"공정한 비교"와 "비즈니스 의미" 사이의 긴장**을 인식했다. 수학적으로 공정한 것과 비즈니스적으로 의미 있는 것은 다를 수 있다 +- 전환율을 검토하면서 **"같은 지표가 시스템 목적에 따라 다른 위치에 놓인다"**는 것을 이해했다. 전환율은 추천 시스템에서는 핵심 지표이지만, 공개 랭킹에서는 보조 지표다 + +### 지금 다시 한다면? + +균등 합산을 유지하되, **일평균을 별도 컬럼으로 MV에 함께 저장**할 것이다. `avg_daily_sales = total_sales / active_days`를 MV에 추가하면, MD 대시보드에서 "판매 효율 기준 정렬"을 제공할 때 재집계 없이 확장 가능하다. 공개 랭킹의 정렬 기준은 총 실적을 유지하면서, 내부 분석용으로 일평균 데이터를 함께 제공하는 것이 실운영에서 가장 실용적인 접근이다. + +--- + +## 소재 2: 슬라이딩 윈도우 vs 캘린더 윈도우 + +### 이 고민이 시작된 맥락 + +MV 테이블의 period_key를 설계하다가 질문이 나왔다: **"일간, 주간 랭킹은 시간 단위로 윈도우 전략을 사용하는 게 아니야? carry-over가 아닌 캘린더상으로 1주, 1월을 기준으로 집계하는지 궁금해."** + +현재 Redis의 주간/월간이 이미 슬라이딩 윈도우(매일 갱신)로 동작하고 있었다. MV도 같은 방식으로 가야 하는가, 아니면 캘린더(월~일, 1일~말일) 기반으로 가야 하는가? 무신사는 주간/월간도 매일 집계한다는 정보가 판단에 영향을 줬다. + +| 전략 | 예시 | 갱신 주기 | +|------|------|----------| +| 캘린더 | 주간: 월~일, 월간: 1일~말일 | 주 1회, 월 1회 | +| 슬라이딩 | 오늘 기준 최근 7일/30일 | 매일 | + +### 슬라이딩을 선택한 이유 + +1. **Redis weekly와 시간 범위 일치**: Redis ZUNIONSTORE가 "최근 7일 daily"를 합산하는 슬라이딩 방식. MV가 캘린더이면 Redis fallback 시 시간 범위가 불일치하여 랭킹 결과의 연속성이 깨짐 +2. **이커머스 업계 관행**: 무신사, 쿠팡 등에서 주간/월간 랭킹을 매일 갱신. "주간 인기 상품"이 월요일에만 바뀌면 사용자가 매일 같은 랭킹을 보게 되어 재방문 유인이 떨어짐 +3. **배치 비용 대비 효과**: GROUP BY + TOP 100 INSERT는 상품 수만 건 기준 수초 내 완료. 매일 실행해도 시스템 부하가 미미하며, 사용자에게 매일 갱신되는 랭킹을 제공하는 효과가 큼 +4. **운영 단순성**: period_key가 targetDate(`20260416`) 자체이므로 "이 날짜 기준 최근 N일"이라는 명확한 의미. 캘린더 방식은 ISO 주차(`2026-W16`)나 월(`2026-04`) 계산이 필요하고, 월말/주초 경계 처리가 복잡 + +### 캘린더 방식이 더 적합한 경우 + +캘린더를 기각했지만, 다음 상황에서는 캘린더가 맞다: +- **정산/리포팅 시스템**: "4월 매출 정산"은 4/1~4/30 고정 기간이어야 한다. 슬라이딩이면 기준일에 따라 금액이 달라져 정산 불일치 +- **마케팅 캠페인 성과 분석**: "이번 주 프로모션 효과"는 캠페인 시작~종료 고정 기간 기준 +- **배치 비용이 높은 경우**: 수억 건 집계에 수십 분 걸리면 매일 실행이 부담 + +우리 과제는 정산이 아닌 **소비자 대상 랭킹 보드**이므로 슬라이딩이 적합하다. + +--- + +## 소재 3: Chunk vs Tasklet — 언제 무엇을 쓰는가 + +### 이 고민이 시작된 맥락 + +배치 프로젝트 2개(90개 Job)를 분석했더니 통계/집계 Job의 대다수가 Tasklet이었다. 처음에는 "Tasklet이 보편적"이라고 결론 내렸는데, **"다른 개발자들의 이야기를 들어보면 Chunk 방식이 보편적이라고 하는데?"**라는 반론이 나왔다. + +다시 생각해보니, 분석한 배치 프로젝트가 MyBatis + SQL 중심 아키텍처여서 Tasklet(INSERT INTO...SELECT)이 자연스러운 선택이었을 뿐, 이것을 업계 표준으로 일반화한 것은 **한 조직의 패턴을 확대 해석**한 것이었다. Spring Batch 프레임워크 자체가 Chunk를 중심으로 설계되어 있고, retry/skip/restart 등 운영 기능이 Chunk에만 제공된다는 점에서 Chunk가 보편적 선택인 이유가 있었다. + +이 시각 교정 과정에서 "그러면 정확히 언제 Chunk이고 언제 Tasklet인가?"라는 질문으로 이어졌다. + +### Spring Batch가 Chunk-Oriented에 제공하는 운영 기능 + +Chunk-Oriented는 단순히 "Reader → Processor → Writer"의 패턴이 아니다. Spring Batch 프레임워크가 Chunk에 대해 제공하는 **운영 레벨의 기능**이 Chunk를 보편적 선택으로 만드는 핵심이다. + +#### 1. 자동 Retry (Transient Failure 재시도) + +```java +@Bean +public Step step() { + return new StepBuilder("step", jobRepository) + .chunk(1000, transactionManager) + .reader(reader()) + .processor(processor()) + .writer(writer()) + .faultTolerant() + .retry(DeadlockLoserDataAccessException.class) // DB 데드락 시 재시도 + .retry(OptimisticLockingFailureException.class) // 낙관적 락 충돌 시 재시도 + .retryLimit(3) // 최대 3회 + .build(); +} +``` + +대규모 이커머스에서 배치가 수백만 건을 처리하는 동안 **일시적 DB 데드락, 네트워크 타임아웃**이 발생할 수 있다. Chunk는 해당 chunk만 재시도하고, Tasklet에서는 이 로직을 직접 구현해야 한다. + +#### 2. Skip Policy (불량 레코드 건너뛰기) + +```java +.faultTolerant() +.skip(DataIntegrityViolationException.class) // PK 중복 등 → 건너뛰기 +.skipLimit(100) // 최대 100건까지 허용 +.noSkip(OutOfMemoryError.class) // OOM은 절대 건너뛰지 않음 +``` + +100만 건 중 3건의 데이터 오류 때문에 전체 배치가 실패하면 운영 부담이 크다. Skip Policy로 불량 레코드를 건너뛰고 나머지를 계속 처리할 수 있다. Tasklet의 SQL 한 방에서는 1건의 에러가 전체를 롤백시킨다. + +#### 3. Restart (실패 지점부터 재시작) + +``` +최초 실행: + Chunk 1: 1~1,000건 ✓ 커밋 완료 + Chunk 2: 1,001~2,000건 ✓ 커밋 완료 + Chunk 3: 2,001~3,000건 ✗ 실패 (DB 커넥션 에러) + → ExecutionContext에 진행 상태 저장 + +재시작: + Chunk 1~2: 건너뜀 (이미 커밋됨) + Chunk 3: 2,001~3,000건부터 재시작 +``` + +수시간 걸리는 배치가 80% 진행 후 실패하면, 처음부터 재실행하는 것은 비용이 크다. Chunk는 Spring Batch의 메타 테이블(`BATCH_STEP_EXECUTION_CONTEXT`)에 진행 상태를 저장하여 실패 지점부터 재시작할 수 있다. + +#### 4. 자동 모니터링 (처리 건수 추적) + +``` +StepExecution 자동 기록: + - readCount: 읽은 건수 + - writeCount: 쓴 건수 + - skipCount: 건너뛴 건수 + - commitCount: 커밋 횟수 + - rollbackCount: 롤백 횟수 + - readSkipCount / writeSkipCount / processSkipCount +``` + +Tasklet에서는 이 지표들을 직접 카운팅하고 로깅해야 한다. Chunk는 Spring Batch가 자동으로 기록하고, `BATCH_STEP_EXECUTION` 테이블에서 조회할 수 있다. + +#### 5. Listener 기반 확장 + +```java +.listener(new ItemReadListener<>() { + public void onReadError(Exception ex) { alertService.send("Reader 에러: " + ex); } +}) +.listener(new ItemWriteListener<>() { + public void afterWrite(Chunk items) { metrics.increment("batch.write", items.size()); } +}) +``` + +읽기/쓰기/처리 각 단계에 Listener를 붙여 모니터링, 알림, 메트릭 수집을 할 수 있다. + +### Chunk가 보편적 선택인 이유 + +위 기능들은 **프레임워크가 무료로 제공하는 것**이다. Tasklet으로 동일한 수준의 운영 안정성을 확보하려면 retry 루프, skip 카운터, 진행 상태 저장, 처리 건수 추적을 모두 직접 구현해야 한다. 대부분의 배치 작업에서 이 운영 기능의 가치가 네트워크 왕복의 비용보다 크기 때문에 Chunk가 보편적 선택이 된다. + +### Tasklet이 Chunk보다 효율적인 경우 + +그럼에도 Tasklet이 맞는 **특정 조건**이 있다: + +| 조건 | 설명 | 예시 | +|------|------|------| +| **SQL 한 문장으로 완결** | Java 변환이 전혀 없고 DB→DB 이동 | `INSERT INTO...SELECT...GROUP BY` | +| **retry/skip이 불필요** | 실패 시 전체 재실행해도 수초 내 완료 | TOP 100 적재 (100건 INSERT) | +| **중간 상태가 없음** | 처리 중 실패해도 "부분 완료" 상태가 의미 없음 | DELETE + INSERT 패턴 (어차피 전체 교체) | + +실무 배치 앱 분석에서 관찰한 통계/집계 Job이 Tasklet을 쓰는 것은 **이 세 조건을 모두 충족하기 때문**이지, Tasklet이 일반적으로 우월하기 때문이 아니다. + +### 이 작업에서의 판단 + +우리의 MV TOP 100 적재는 Tasklet의 세 조건을 모두 충족한다: +- SQL 한 문장(INSERT INTO...SELECT + RANK() + LIMIT 100)으로 완결 가능 +- 100건 INSERT는 수초 내 완료 → 실패 시 전체 재실행해도 부담 없음 +- DELETE + INSERT 패턴이므로 부분 완료 상태가 의미 없음 + +**그러나 Chunk로 구현하면서 프레임워크의 운영 기능을 활용하는 것도 합리적이다:** +- `.faultTolerant().retry()`로 일시적 DB 에러에 대한 자동 재시도 +- `StepExecution`의 read/write count로 자동 모니터링 +- `StepMonitorListener`와 결합하여 실패 시 알림 + +Chunk의 네트워크 왕복 비용(100건 × ~10KB < 1ms)보다 이 운영 기능의 가치가 크므로, **Chunk를 쓰되 Reader SQL에서 비효율을 최소화하는 것**이 우리의 접근이다. + +### 실무 배치 프로젝트에서는 이 운영 기능을 쓰고 있는가? + +실무 배치 앱 2개(Spring Boot 3.3.4 + Batch 5.x, 총 90개 Job)를 분석한 결과: + +| 운영 기능 | 사용 여부 | +|----------|----------| +| `.faultTolerant()` | ❌ 없음 | +| `.retry()` / `retryLimit` | ❌ 없음 | +| `.skip()` / `skipLimit` | ❌ 없음 | +| `ItemReadListener` / `ItemWriteListener` | ❌ 없음 | +| `ChunkListener` / `SkipListener` | ❌ 없음 | +| `allowStartIfComplete` (restart) | ❌ 없음 | + +**90개 Job 중 단 하나도 retry, skip, restart를 사용하지 않는다.** + +사용하는 Listener는 딱 2종류: +- `SingleJobExecutionListener` — 중복 실행 방지 (JobExecutionListener) +- `StepExecutionListener` — 검색 인덱스 Job 2개에서 다른 배치 실행 중인지 체크 + +이것은 **retry/skip 없이도 실무 운영이 가능하다**는 뜻이다. 그러나 좋은 설계인지는 별개의 문제다: +- retry 없이 운영 = 1건의 일시적 DB 에러가 전체 배치를 실패시킴 +- skip 없이 운영 = 1건의 데이터 오류가 나머지 수만 건의 처리를 막음 +- 이것은 **운영 리스크를 감수하는 것**이지, 모범 사례가 아니다 + +우리 프로젝트에서는 이 부분을 개선하여 `faultTolerant + retry`를 적용한다. 실무에서 빠져 있는 것을 보완하는 것도 의미 있는 설계 판단이다. + +### 코드 레벨 비교: Chunk vs Tasklet + +#### Tasklet 방식 (SQL 중심) + +```java +@Configuration +@RequiredArgsConstructor +public class ProductRankingMvTaskletJobConfig { + + public static final String JOB_NAME = "productRankingMvJob"; + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final JdbcTemplate jdbcTemplate; + + @Bean(JOB_NAME) + public Job productRankingMvJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(cleanupStep()).on("FAILED").end() + .from(cleanupStep()).on("*").to(aggregateStep()) + .end() + .build(); + } + + @Bean + @JobScope + public Step cleanupStep() { + return new StepBuilder("cleanupStep", jobRepository) + .tasklet((contribution, chunkContext) -> { + String scope = chunkContext.getStepContext() + .getJobParameters().get("scope").toString(); + String targetDate = chunkContext.getStepContext() + .getJobParameters().get("targetDate").toString(); + String table = "weekly".equals(scope) + ? "mv_product_rank_weekly" : "mv_product_rank_monthly"; + jdbcTemplate.update( + "DELETE FROM " + table + " WHERE period_key = ?", targetDate); + return RepeatStatus.FINISHED; + }, transactionManager) + .build(); + } + + @Bean + @JobScope + public Step aggregateStep() { + return new StepBuilder("aggregateStep", jobRepository) + .tasklet((contribution, chunkContext) -> { + String scope = chunkContext.getStepContext() + .getJobParameters().get("scope").toString(); + String targetDate = chunkContext.getStepContext() + .getJobParameters().get("targetDate").toString(); + String table = "weekly".equals(scope) + ? "mv_product_rank_weekly" : "mv_product_rank_monthly"; + int days = "weekly".equals(scope) ? 6 : 29; + + jdbcTemplate.update(""" + INSERT INTO %s + (product_id, ranking, score, view_count, like_count, + sales_count, sales_amount, period_key) + SELECT product_id, DT_RNK, score, + total_view_count, total_net_like_count, + total_sales_count, total_net_sales_amount, ? + FROM ( + SELECT pm.product_id, + SUM(pm.view_count) AS total_view_count, + SUM(pm.like_count - pm.unlike_count) AS total_net_like_count, + SUM(pm.sales_count) AS total_sales_count, + SUM(pm.sales_amount - pm.cancel_amount_by_event_date) + AS total_net_sales_amount, + (0.1 * LOG10(GREATEST(SUM(pm.view_count),0)+1) / 7.0 + + 0.2 * LOG10(GREATEST(SUM(pm.like_count - pm.unlike_count),0)+1) / 7.0 + + 0.7 * LOG10(GREATEST(SUM(pm.sales_amount + - pm.cancel_amount_by_event_date),0)+1) / 7.0 + + UNIX_TIMESTAMP() * 1e-16) AS score, + RANK() OVER (ORDER BY + (0.1 * LOG10(GREATEST(SUM(pm.view_count),0)+1) / 7.0 + + 0.2 * LOG10(GREATEST(SUM(pm.like_count - pm.unlike_count),0)+1) / 7.0 + + 0.7 * LOG10(GREATEST(SUM(pm.sales_amount + - pm.cancel_amount_by_event_date),0)+1) / 7.0 + + UNIX_TIMESTAMP() * 1e-16) DESC) AS DT_RNK + FROM product_metrics pm + JOIN product p ON pm.product_id = p.id + WHERE pm.metric_date BETWEEN DATE_SUB(STR_TO_DATE(?, '%%Y%%m%%d'), + INTERVAL %d DAY) AND STR_TO_DATE(?, '%%Y%%m%%d') + AND p.deleted_at IS NULL + GROUP BY pm.product_id + ) ranked + WHERE DT_RNK <= 100 + """.formatted(table, days), + targetDate, targetDate, targetDate); + return RepeatStatus.FINISHED; + }, transactionManager) + .build(); + } +} +``` + +- **장점**: 네트워크 왕복 0. 코드가 짧다. SQL 한 문장으로 집계+정렬+적재 완료 +- **단점**: retry/skip 없음. SQL이 비대함. score 공식 단위 테스트 불가 + +#### Chunk 방식 (우리 구현) + +```java +@Configuration +@RequiredArgsConstructor +public class ProductRankingMvJobConfig { + + public static final String JOB_NAME = "productRankingMvJob"; + private static final int CHUNK_SIZE = 100; + + @Bean(JOB_NAME) + public Job productRankingMvJob(Step cleanupStep, Step aggregateStep) { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(cleanupStep).on("FAILED").end() + .from(cleanupStep).on("*").to(aggregateStep) + .end() + .listener(jobListener) + .build(); + } + + @Bean + @StepScope + public JdbcCursorItemReader mvMetricsReader( + @Value("#{jobParameters['targetDate']}") String targetDate, + @Value("#{jobParameters['scope']}") String scope) { + int days = "weekly".equals(scope) ? 6 : 29; + return new JdbcCursorItemReaderBuilder() + .name("mvMetricsReader") + .dataSource(dataSource) + .sql(""" + SELECT pm.product_id, ... , + (0.1 * LOG10(...) + ...) AS score + FROM product_metrics pm + JOIN product p ON pm.product_id = p.id + WHERE pm.metric_date BETWEEN ? AND ? + AND p.deleted_at IS NULL + GROUP BY pm.product_id + ORDER BY score DESC + LIMIT 100 + """) + .preparedStatementSetter(ps -> { /* 날짜 파라미터 바인딩 */ }) + .rowMapper((rs, rowNum) -> new RankedProductRow(...)) + .build(); + } + + @Bean + public Step aggregateStep(JdbcCursorItemReader reader, + ItemWriter writer) { + return new StepBuilder("aggregateStep", jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(reader) + .processor(rankingProcessor()) // ranking 번호 부여 + .writer(writer) + .faultTolerant() + .retry(DeadlockLoserDataAccessException.class) + .retryLimit(3) + .listener(stepMonitorListener) + .build(); + } +} +``` + +- **장점**: retry로 일시적 DB 에러 자동 재시도. StepExecution에 read/write count 자동 기록. StepMonitorListener로 실패 시 알림 +- **단점**: 네트워크 왕복 2회 (100건, < 1ms). Processor가 ranking 부여만 하므로 역할이 가벼움 + +--- + +## 소재 4: Redis(Speed Layer) vs MV(Batch Layer) — Lambda Architecture 실전 + +### 이 고민이 시작된 맥락 + +설계 초기에 자연스럽게 나온 질문이다. Round 9에서 이미 Redis로 일간/주간/월간 랭킹을 제공하고 있다. **"그러면 MV 테이블을 왜 또 만드는가? Redis에 이미 있는 것을 DB에 다시 만드는 것은 중복이 아닌가?"** + +이 질문에 답하려면 Redis 랭킹(carry-over 근사치, 지수 감쇠)과 MV 랭킹(DB 원장 기반 균등 합산)이 **같은 결과를 내는지 다른 결과를 내는지**를 먼저 확인해야 했다. log₁₀의 비선형성을 숫자로 검증하면서 두 시스템이 실제로 다른 순위를 생성한다는 것을 확인했고, 이것이 Lambda Architecture에서 Speed Layer와 Batch Layer가 공존하는 이유와 연결되었다. + +### 핵심 질문 + +"Redis에서 이미 주간/월간 랭킹을 제공하고 있는데, 왜 MV를 또 만드는가?" + +### 운영 관점의 답 + +| 관점 | Redis (Speed Layer) | MV (Batch Layer) | +|------|---------------------|-------------------| +| **정확도** | carry-over 근사치 (일별 score 합산, 지수 감쇠) | DB 원장 기반 정확값 (메트릭 균등 합산) | +| **장애 내성** | Redis 다운 → 주간/월간 조회 불가 | DB만 살아있으면 조회 가능. Redis 장애 시 fallback | +| **데이터 관점** | "지금 뜨는 상품" (트렌드) | "기간 총 실적" (누적 성과) | +| **비즈니스 용도** | 메인 페이지 실시간 인기 | 기간별 베스트셀러, MD 리포트, 정산 참고 | +| **데이터 검증** | Redis 내부 데이터 확인 어려움 | SQL로 즉시 검증 가능 | + +### log₁₀ 비선형성이 만드는 실제 차이 + +``` +상품 X: 7일간 view = [100, 100, 100, 100, 100, 100, 100] (총 700) +상품 Y: 7일간 view = [0, 0, 0, 0, 0, 0, 700] (총 700) + +Redis (일별 score 합산): + X: 7 × log₁₀(101)/7 = 2.003 + Y: 6 × 0 + log₁₀(701)/7 = 0.406 + → X 압도적 유리 (꾸준한 상품 우대) + +MV (메트릭 합산 후 score): + X: log₁₀(701)/7 = 0.406 + Y: log₁₀(701)/7 = 0.406 + → 동점 (총 활동량 동일) +``` + +이 차이가 "두 시스템이 다른 특성을 갖는 이유"이며, 같은 원천 데이터(product_metrics)에서 출발하지만 계산 방식의 차이로 다른 관점의 랭킹을 제공한다. + +### 그러면 같은 API에 두 소스를 번갈아 쓰면 안 되는 이유 + +처음에는 "MV primary, Redis fallback"으로 설계했다. MV 배치가 실패하면 Redis에서 조회하는 구조였다. 하지만 **"우리는 Redis를 사용하는 목적과 MV를 사용하는 목적이 같아 설마?"**라는 질문에서 문제를 발견했다. + +Redis(지수 감쇠)와 MV(균등 합산)는 **같은 기간에 대해 다른 순위를 반환**한다. 이것을 fallback으로 쓰면: + +``` +정상 시: MV 조회 → 상품 A가 1위 (균등 합산) +MV 장애 시: Redis fallback → 상품 B가 1위 (지수 감쇠) +→ 사용자: "어제는 A가 1위였는데 오늘은 B가 1위?" +``` + +**다른 공식으로 계산한 결과를 같은 API의 fallback으로 쓰는 것은 데이터 일관성을 깨뜨린다.** 잘못된 순위를 보여주는 것보다 "현재 랭킹을 준비 중입니다"가 더 안전하다. + +### 최종 결정: 단일 소스 원칙 + +``` +daily → Redis (단일 소스) +weekly → MV (단일 소스, fallback 없음) +monthly → MV (단일 소스, fallback 없음) +``` + +Redis weekly/monthly(carry-over + ZUNIONSTORE)는 제거하거나 내부 모니터링용으로만 유지한다. 각 scope의 데이터 소스가 하나이므로, 소스 전환에 의한 순위 불일치가 발생하지 않는다. + +--- + +## 소재 5: Score 계산과 TOP-N 필터링 — DB에서 하는가, Java에서 하는가 + +### 이 고민이 시작된 맥락 + +처음에는 Reader에서 전체 상품을 조회하고 Processor에서 score를 계산한 후, Writer에서 TOP 100만 INSERT하는 구조를 설계했다. 그런데 **"어차피 삭제할 건데 전부 INSERT하는 게 비효율적이지 않아?"**라는 질문이 나왔다. 수만 건을 INSERT했다가 100건만 남기고 삭제하는 것은 불필요한 I/O다. + +그러면 Reader SQL에서 score 계산까지 처리하고 LIMIT 100으로 100건만 반환할 수 있는가? **"계산 전에 Reader가 100건만 조회할 수 있어? 그럼 그게 TOP 100인 게 맞아?"**라는 후속 질문으로 이어졌고, SQL 실행 순서(GROUP BY → SELECT → ORDER BY → LIMIT)를 분석하여 DB가 TOP 100을 보장한다는 것을 확인했다. + +### 검토한 방안 + +| 방안 | Reader | Processor | Writer | 비효율 포인트 | +|------|--------|-----------|--------|-------------| +| **A. Java 전체 처리** | 전체 조회 (수만 건) | score 계산 | 정렬 + TOP 100 INSERT | 수만 건을 Java로 읽어와서 정렬/필터링 — DB가 이미 최적화된 작업을 애플리케이션에서 반복 | +| **B. 전체 INSERT 후 삭제** | 전체 조회 | score 계산 | 전체 INSERT → Step 3에서 100위 밖 DELETE | 수만 건 INSERT 후 대부분 삭제 — 불필요한 I/O | +| **C. SQL에서 완료 (초기 채택)** | GROUP BY + score + ORDER BY + LIMIT 100 → **100건만 반환** | ranking 부여 | 100건 INSERT | DB가 집계, 계산, 정렬, 필터링을 한 번에 처리 | +| **D. SQL 집계 + Java Processor score (최종)** | GROUP BY 집계만 (전체 상품) | ScoreFormula.calculate() | 스테이징 INSERT → mergeStep에서 TOP 100 | Score 공식 중앙화, categoryPriority 반영. Partitioning으로 병렬 처리 | + +### 방안 C가 효율적인 이유: SQL 실행 순서 + +``` +1. FROM / JOIN → product_metrics × product 조인 +2. WHERE → 날짜 범위 필터 +3. GROUP BY → product_id별 그룹핑 + SUM 집계 +4. SELECT → score 계산 (LOG10 등 수학 함수) +5. ORDER BY → score 내림차순 정렬 (전체 상품 대상) +6. LIMIT 100 → 상위 100건만 반환 +``` + +DB가 **전체 상품의 score를 계산하고 정렬한 후** 상위 100건만 네트워크로 전달한다. Reader는 100건만 받지만, 그 100건이 score 기준 TOP 100인 것은 DB가 보장한다. + +Reader SQL: + +```sql +SELECT + pm.product_id, + SUM(pm.view_count) AS total_view_count, + SUM(pm.like_count - pm.unlike_count) AS total_net_like_count, + SUM(pm.sales_count) AS total_sales_count, + SUM(pm.sales_amount - pm.cancel_amount_by_event_date) AS total_net_sales_amount, + ( + 0.1 * LOG10(GREATEST(SUM(pm.view_count), 0) + 1) / 7.0 + + 0.2 * LOG10(GREATEST(SUM(pm.like_count - pm.unlike_count), 0) + 1) / 7.0 + + 0.7 * LOG10(GREATEST(SUM(pm.sales_amount - pm.cancel_amount_by_event_date), 0) + 1) / 7.0 + + UNIX_TIMESTAMP() * 1e-16 + ) AS score +FROM product_metrics pm +JOIN product p ON pm.product_id = p.id +WHERE pm.metric_date BETWEEN :startDate AND :endDate + AND p.deleted_at IS NULL +GROUP BY pm.product_id +ORDER BY score DESC +LIMIT 100 +``` + +### 회사 배치 앱에서의 검증 + +회사 코드를 분석한 결과, **score 계산 + TOP-N 필터링을 SQL에서 처리하는 것이 실무 표준**이었다: + +**GoodsBestMapper.xml** — 상품 베스트 TOP 100: + +```sql +RANK() OVER (ORDER BY SUM(ORD_QTY) DESC) AS DT_RNK +... +WHERE DT_RNK <= 100 +``` + +- Java(GoodsBestServiceImpl)는 파라미터만 전달. 랭킹 로직 없음 +- DELETE → INSERT 패턴. SQL이 모든 계산을 처리 + +**GoodsNewMapper.xml** — 카테고리별 신상품 TOP 50: + +```sql +DENSE_RANK() OVER (PARTITION BY DISP_CTG_NO ORDER BY SYS_REG_DTM DESC) AS DT_RNK +WHERE DT_RNK <= 50 +``` + +**EtEntrEvltAgrtTrxMapper.xml** — 입점사 매출 상위 10%: + +```sql +PERCENT_RANK() OVER (ORDER BY SUM(ORD_AMT - CNCL_AMT) DESC) AS PERCENT_RNK +WHERE PERCENT_RNK <= 0.1 +``` + +**12개 매퍼에서 `RANK()`, `DENSE_RANK()`, `ROW_NUMBER()`, `PERCENT_RANK()` 윈도우 함수 사용.** Java에서 랭킹/스코어링을 처리하는 배치 Job은 없었다. + +### 트레이드오프: Score 공식의 이중 관리 → ScoreFormula 중앙화로 해소 + +초기에는 SQL에 score 공식을 넣어 RankingCorrectionJob(Java)과 MV Job(SQL)에 같은 공식이 두 곳에 존재했다. 이를 "합리적 중복"으로 판단했었으나, 이후 추가 분석에서 **4곳 분산**(streamer, batch correction, MV Job SQL, API drift scheduler) + **MV Job에 categoryPriority 누락** 문제가 발견되어 중앙화를 결정했다. + +| 관점 | 변경 전 | 변경 후 | +|------|---------|---------| +| **공식 위치** | 4곳 분산 (Java 3곳 + SQL 1곳) | `ScoreFormula.calculate()` 1곳 | +| **Weights 정의** | 각 모듈마다 inner record | `ScoreFormula.Weights` 공유 | +| **categoryPriority** | MV Job SQL에서 누락 | Java Processor에서 반영 | +| **변경 시 수정 범위** | 4곳 | 1곳 | +| **MV Job score 계산** | SQL (DB가 처리) | Java ItemProcessor (ScoreFormula 호출) | + +**왜 SQL → Java로 이동했는가**: Score 공식 중앙화와 categoryPriority 반영이라는 정합성 이점이, SQL에서 한 번에 처리하는 효율성 이점보다 크다고 판단했다. MV Job의 Reader SQL은 집계(GROUP BY + SUM)에 집중하고, score 계산은 Java Processor가 담당한다. TOP-N 필터링은 여전히 mergeStep의 SQL에서 처리한다. + +### 이 판단에서 배운 것 + +- **"어디서 계산하느냐"는 효율의 문제이지 패턴의 문제가 아니다.** Chunk-Oriented에서 Processor가 비즈니스 로직을 담당해야 한다는 것은 일반론이지, 모든 경우에 적용해야 하는 규칙이 아니다 +- **DB가 잘하는 일(집계, 정렬, 필터링)은 DB에서 끝내야 한다.** 집계와 TOP-N 필터링은 DB에서 처리하되, 비즈니스 로직(score 공식)은 중앙화를 위해 Java에서 처리하는 것이 적절한 경계다 +- **"합리적 중복"은 위험 신호다.** 초기에 "입력이 다르니 중복이 합리적"이라고 판단했지만, 공식이 4곳으로 확산되고 categoryPriority 누락이 발견되면서 중복의 비용이 드러났다. 중복이 "합리적"인지 주기적으로 재평가해야 한다 + +--- + +## 소재 6: 사전 집계 파이프라인과 Chunk의 관계 + +### 이 고민이 시작된 맥락 + +Chunk의 가치가 "대량의 행을 안전하게 처리하는 것"이라면, **"Chunk의 이점을 누리려면 Flink/Spark 같은 사전 집계 파이프라인을 전략적으로 두어야 하는 걸까?"**라는 질문이 나왔다. 사전 집계(product_metrics)가 있어야 Chunk가 유용한 것인가, 아니면 별개의 문제인가? + +### 핵심 통찰: 사전 집계는 입력을, Chunk는 출력을 다룬다 + +``` +사전 집계 파이프라인 (Kafka → Flink/Spark → product_metrics): + 수억 건 이벤트 → 일간 집계 테이블 + → Reader의 입력 볼륨을 줄이는 것 + +Chunk-Oriented: + 대량의 행을 chunk 단위로 읽고-변환하고-적재 + → Writer의 출력 볼륨이 클 때 + 운영 안정성이 필요할 때 가치가 있는 것 + +둘은 서로 다른 문제를 해결한다. +``` + +사전 집계가 있어야 Chunk가 유용한 것이 아니다. Chunk의 가치는 **"프레임워크가 제공하는 retry, skip, restart, 모니터링을 활용하면서 대량의 행을 안정적으로 처리할 때"** 발휘된다. + +### 대규모 이커머스에서의 DB 부하 문제 + +쿠팡급(상품 100만, product_metrics 30일치 3,000만 행) 기준으로, Chunk든 Tasklet이든 **집계 쿼리의 DB 부하는 동일하다.** 진짜 해결해야 할 문제는 처리 모델 선택이 아니라 **"이 집계를 서비스 DB에서 할 것인가"**이다. 답은 Replica DB 또는 DW에서 집계하는 것이고, 이것은 두 방식 모두에 적용된다. + +### 우리의 접근 + +Chunk를 쓰되, Reader SQL에서 GROUP BY + score 계산 + ORDER BY + LIMIT 100까지 처리하여 **Java로 넘어오는 데이터를 100건으로 제한**했다. Chunk의 네트워크 왕복 비용(100건 × ~10KB < 1ms)보다 프레임워크가 제공하는 운영 기능(retry, 모니터링, restart)의 가치가 크므로, Chunk 선택은 합리적이다. + +--- + +## 소재 7: Best Practice 대조 — 이론 vs 우리 설계 vs 배치 프로젝트 분석 + +### 이 고민이 시작된 맥락 + +Spring Batch Chunk Best Practice 문서를 받아서 우리 설계와 대조해봤다. "Best Practice를 따르고 있는가?"만이 아니라, **"배치 프로젝트 90개 Job은 이 Best Practice를 얼마나 따르고 있는가?"**도 함께 비교하고 싶었다. 이론, 우리 설계, 배치 프로젝트 — 세 관점의 교차 분석에서 "retry/skip을 90개 Job 전부가 안 쓰고 있다"는 발견이 나왔다. + +### Spring Batch Chunk Best Practice를 3가지 관점에서 비교 + +| Best Practice | 이론적 권장 | 우리 설계 | 배치 프로젝트 분석 (90개 Job) | +|-------------|-----------|----------|--------------------------| +| **faultTolerant + retry** | 일시적 에러(데드락, 타임아웃) 자동 재시도. ExponentialBackOffPolicy로 간격 확보 | ✅ 적용. retry(3) + ExponentialBackOffPolicy | ❌ 90개 Job 전부 미사용. 1건 에러 = 전체 실패 | +| **skip policy** | 데이터 오류 시 건너뛰고 나머지 처리. SkipListener로 누락 추적 필수 | 미적용 (의도적). 100건이므로 skip 시 chunk scan(100번 재실행) 비용이 더 큼 | ❌ 미사용 | +| **ExponentialBackOffPolicy** | retry 시 100ms→200ms→400ms 간격. 즉시 재시도는 데드락 상태에서 반복 실패 | ✅ 적용 | ❌ retry 자체가 없으므로 해당 없음 | +| **Writer 벌크 처리** | JdbcBatchItemWriter로 JDBC batch INSERT. 개별 INSERT 루프는 안티패턴 | ✅ JdbcBatchItemWriter 사용 | ⚠️ Chunk Job(2개)은 MyBatisBatchItemWriter 사용. 하지만 GoodsReviewTotal은 행 단위 UPSERT 루프 (안티패턴) | +| **Cursor vs Paging 선택** | 단일 스레드 순차 → Cursor. 멀티스레드/재시작 → Paging | ✅ JdbcCursorItemReader (단일 스레드). 병렬화 시 전환 필요 명시 | Cursor(2개), Paging(2개) 혼용 | +| **Processor에서 DB 수정 금지** | 쓰기는 Writer에서만. 트랜잭션 경계 명확화 | ✅ Processor는 ranking 부여만 | ✅ 준수 (Processor에서 DB 수정하는 Job 없음) | +| **cleanupStep allowStartIfComplete** | 멱등한 Step은 재시작 시에도 항상 실행 허용 | ✅ 적용 | ❌ 해당 설정 없음 | +| **saveState(false)** | 재시작 불필요한 Step은 상태 저장 오버헤드 제거 | 미적용. 100건이므로 오버헤드 무시 가능 | ❌ 해당 설정 없음 | +| **SkipListener.onSkipInWrite** | skip된 아이템을 별도 기록하여 데이터 정합성 추적 | 해당 없음 (skip 미적용) | ❌ skip 자체가 없음 | +| **StepExecutionListener** | Step 시작/종료/실패 시 모니터링, 알림 | ✅ StepMonitorListener (기존 인프라) | ⚠️ 2개 Job에서만 사용 (검색 인덱스). 나머지 88개 Job은 미사용 | + +### 이 비교에서 드러나는 것 + +**배치 프로젝트 90개 Job이 retry/skip/restart를 하나도 쓰지 않는다는 것은 주목할 만하다.** 이것은 두 가지로 해석할 수 있다: + +1. **"운영에서 문제가 없었다"**: 배치 데이터가 안정적이고, 실패 빈도가 낮아서 전체 재실행으로 충분히 대응 가능했을 수 있다. 실제로 통계/집계 Job은 대부분 수초~수분 내 완료되므로 전체 재실행 비용이 낮다. + +2. **"운영 리스크를 감수하고 있다"**: 1건의 일시적 에러가 전체 배치를 실패시키는 구조다. 야간 배치가 데드락으로 실패하면 아침에 출근해서 수동 재실행해야 한다. retry를 걸어두면 자동으로 복구됐을 에러다. + +**우리 프로젝트에서 retry + ExponentialBackOffPolicy를 적용하는 것은, 배치 프로젝트에서 빠져 있는 운영 안정성을 보완하는 설계 판단이다.** "남들이 안 쓰니까 안 써도 된다"가 아니라, "프레임워크가 제공하는 운영 기능을 활용하여 야간 배치의 자동 복구 가능성을 높인다"는 근거다. + +### Writer skip 시 chunk scan 문제 + +Best Practice에서 중요한 경고: **Writer에서 skip이 발생하면 해당 chunk 전체가 롤백되고, 아이템을 1개씩 재실행하는 "chunk scan"이 발생한다.** chunkSize=1000이면 최악의 경우 1000번 개별 실행. + +이것이 우리가 skip을 적용하지 않는 이유 중 하나다. 100건에서 skip이 발생하면 100번 개별 INSERT가 실행되는데, 벌크 INSERT의 이점이 사라진다. 100건이라 성능 차이는 미미하지만, skip의 목적(불량 레코드 건너뛰기)이 이 시나리오에서 의미가 없다 — 100건 모두 같은 SQL로 계산된 결과이므로, 1건이 실패하면 SQL 자체의 문제이지 데이터 오류가 아니다. + +--- + +## 소재 8: CursorReader vs PagingReader — GROUP BY 집계 쿼리에서의 선택 + +### 이 고민이 시작된 맥락 + +**"우리는 Cursor Reader방식인거야? 이걸 선택한 이유가 뭐야?"**라는 질문에서 시작했다. 처음에는 "기존 RankingCorrectionJob과 일관성"이라고 답했지만, 대규모 이커머스 기준으로 다시 따져보니 **GROUP BY 집계 쿼리에서 Cursor와 Paging의 동작 차이**가 핵심 판단 기준이었다. + +### 핵심: PagingReader는 GROUP BY 집계 쿼리에서 치명적이다 + +PagingReader는 페이지마다 **독립된 쿼리를 재실행**한다. 단순 WHERE + ORDER BY 쿼리에서는 문제없지만, GROUP BY가 포함된 집계 쿼리에서는 **매 페이지마다 전체 데이터를 다시 집계**한다: + +``` +CursorReader: + GROUP BY 3,000만 행 → 1번 실행 → 결과 스트리밍 + 총 집계 실행: 1회 + +PagingReader (pageSize=1000, 상품 100만 건 = 1,000페이지): + 페이지 1: GROUP BY 3,000만 행 → 정렬 → OFFSET 0 LIMIT 1000 (30초) + 페이지 2: GROUP BY 3,000만 행 → 정렬 → OFFSET 1000 LIMIT 1000 (30초) + ... + 페이지 1000: GROUP BY 3,000만 행 → 정렬 → OFFSET 999000 LIMIT 1000 (30초+) + 총 집계 실행: 1,000회 → 8시간 이상 +``` + +### 대규모 이커머스 기준 비교 + +| 관점 | CursorReader | PagingReader | +|------|-------------|-------------| +| **GROUP BY 집계 쿼리** | ✅ 1회 실행 후 결과 스트리밍 | ❌ 페이지마다 집계 재실행. 대규모에서 치명적 | +| **커넥션 점유** | ❌ Step 전체 동안 1개 점유 | ✅ 페이지 조회 시만 점유, 사이에 반환 | +| **OFFSET 성능** | 해당 없음 | ❌ 뒤쪽 페이지일수록 스캔량 증가 | +| **데이터 변경 안전성** | ✅ 쿼리 시점 스냅샷 (커서 유지) | ❌ 페이지 간 데이터 변경 시 누락/중복 | +| **멀티스레드** | ❌ ResultSet 공유 상태 → 데이터 오염 | ✅ 각 스레드가 독립 쿼리 실행 | +| **재시작** | ⚠️ read count 기반 (제한적) | ✅ 페이지 번호 자동 저장 | + +### CursorReader가 멀티스레드에서 불가능한 이유 + +CursorReader는 하나의 DB 커넥션에서 **하나의 ResultSet을 열어두고 `next()`로 한 행씩 이동**한다. ResultSet은 "지금 커서가 가리키는 행"이라는 상태를 가지고 있다: + +``` +Thread A: reader.read() → resultSet.next() → row 3 반환 +Thread B: reader.read() → resultSet.next() → row 4 반환 ← 동시 호출 + +→ 커서가 2칸 전진하여 row 누락 +→ 또는 Thread A가 읽으려던 행을 Thread B가 밀어버림 (데이터 오염) +``` + +PagingReader는 페이지마다 **별도 쿼리를 별도 커넥션으로 실행**하므로 공유 상태가 없어 안전하다. + +### 커넥션 점유 문제의 해법 + +CursorReader의 커넥션 점유가 문제가 되는 것은 **여러 Job이 동시에 실행되어 커넥션 풀이 고갈**될 때다. 이것을 해결하기 위해 PagingReader로 전환하면 GROUP BY 반복 실행이라는 더 큰 문제가 생긴다. + +**정석적 해법은 배치 전용 DataSource(Replica) 분리다.** 배치가 Replica에서 읽으면 서비스 DB의 커넥션 풀과 독립되므로, CursorReader의 커넥션 점유가 서비스에 영향을 주지 않는다. 분석한 배치 프로젝트 2개도 RODB/RWDB를 5~6쌍으로 분리하여 이 문제를 해결하고 있었다. + +### 병렬화가 필요해지면: Partitioning + +상품이 수백만 건으로 늘어나 병렬 처리가 필요해지면, PagingReader로 전환하는 대신 **Partitioning**이 적합하다: + +``` +Master Step: product_id 범위를 파티션으로 분할 + ├── Partition 1: product_id 1~100,000 → CursorReader (독립 커넥션) + ├── Partition 2: product_id 100,001~200,000 → CursorReader (독립 커넥션) + ├── Partition 3: product_id 200,001~300,000 → CursorReader (독립 커넥션) + └── ... + +각 파티션이 독립 커넥션 + 독립 CursorReader → GROUP BY 1회 + 병렬 처리 +``` + +CursorReader의 장점(1회 쿼리)을 유지하면서 병렬화를 달성한다. + +### 결론: CursorReader + Partitioning으로 두 가지를 모두 해결 + +| 판단 | 근거 | +|------|------| +| **CursorReader 선택** | GROUP BY 집계 쿼리에서 PagingReader는 페이지마다 집계를 재실행하므로 부적합 | +| **Partitioning 적용** | CursorReader의 장점(1회 쿼리)을 유지하면서 멀티스레드 한계를 극복. 각 Worker가 독립 커넥션 + 독립 CursorReader | +| **커넥션 점유 대응** | 다중 Job 동시 실행 시 Replica DataSource 분리 | + +--- + +## 소재 9: CursorReader는 병렬화할 수 없는데, 대규모 집계를 어떻게 빠르게 처리하는가? + +### 이 고민이 시작된 맥락 + +``` +"CursorReader가 GROUP BY에 적합하다" + → "그런데 CursorReader는 멀티스레드에서 사용 불가하다" + → "대규모(상품 100만)에서 단일 스레드로 30초 걸리면?" + → "PagingReader로 바꾸면 페이지마다 GROUP BY 재실행 (더 느림)" + → "CursorReader를 유지하면서 병렬화하는 방법은?" + → Partitioning +``` + +### Partitioning으로 해결하는 구조 + +``` +Step 2: partitionedAggregateStep + + [Partitioner] product_id MIN~MAX를 gridSize(4)개 범위로 분할 + + ┌─────────────────────────────────────────────────────────┐ + │ [Worker 1] [Worker 2] │ + │ id: 1~250,000 id: 250,001~500,000 │ ← 병렬 실행 + │ 독립 CursorReader 독립 CursorReader │ + │ 독립 DB 커넥션 독립 DB 커넥션 │ + │ GROUP BY 750만 행 GROUP BY 750만 행 │ + │ → 스테이징 INSERT → 스테이징 INSERT │ + ├─────────────────────────────────────────────────────────┤ + │ [Worker 3] [Worker 4] │ + │ id: 500,001~750,000 id: 750,001~1,000,000 │ ← 병렬 실행 + │ ... ... │ + └─────────────────────────────────────────────────────────┘ + │ + ▼ + Step 3: mergeStep (Tasklet) + SELECT ... FROM staging ORDER BY score DESC LIMIT 100 + → INSERT INTO mv_product_rank_{scope} +``` + +각 Worker가 **독립 커넥션 + 독립 CursorReader**를 가지므로 ResultSet 공유 문제가 없다. CursorReader의 장점(GROUP BY 1회 실행)을 유지하면서 병렬 처리를 달성한다. + +### 왜 PagingReader 병렬화가 아닌 Partitioning인가 + +| 방식 | GROUP BY 실행 횟수 | 소요 시간 (상품 100만) | +|------|-----------------|---------------------| +| 단일 CursorReader | 1회 (3,000만 행) | ~30초 | +| PagingReader 멀티스레드 | 페이지 수 × 스레드 수 (매번 3,000만 행 GROUP BY) | **수 시간** | +| **Partitioning + CursorReader** | Worker 수 (각 750만 행) | **~10초** | + +PagingReader를 멀티스레드로 돌리면 각 스레드가 **전체 3,000만 행에 대한 GROUP BY를 매 페이지마다 재실행**한다. Partitioning은 데이터를 범위로 분할하여 각 Worker가 **자기 범위의 데이터만 GROUP BY**하므로 근본적으로 다르다. + +### Global TOP 100 문제와 Map-Reduce 패턴 + +Partitioning만으로는 Global TOP 100을 구할 수 없다: + +``` +Worker 1의 로컬 1위: score 0.85 → 글로벌에서는 50위일 수 있음 +Worker 4의 로컬 3위: score 0.92 → 글로벌에서는 1위일 수 있음 +``` + +이것은 분산 시스템의 전형적인 **Map-Reduce** 문제다: +- **Map** (병렬): 각 Worker가 자기 범위를 집계 → 스테이징 테이블에 적재 +- **Reduce** (단일): 스테이징 전체에서 글로벌 정렬 → TOP 100 추출 + +스테이징 테이블이 이 두 단계를 연결하는 중간 저장소 역할을 한다. + +### 성능 산정 (쿠팡급) + +``` +상품 100만, product_metrics 30일치 3,000만 행, Worker 4개: + +Step 1 (cleanup): ~0.1초 (DELETE 2개) +Step 2 (partition): ~10초 (각 Worker GROUP BY 750만 행 × 4 병렬) +Step 3 (merge): ~2초 (스테이징 100만 행 정렬 + TOP 100) +──────────────────────────── +총 소요: ~12초 (단일 스레드 대비 3배 빠름) +``` + +### 트레이드오프 + +| 관점 | 단일 CursorReader | Partitioning | +|------|-------------------|-------------| +| **성능** | ~30초 | ~12초 (3배 향상) | +| **구현 복잡도** | 낮음 (2 Step) | 높음 (3 Step + Partitioner + 스테이징) | +| **스테이징 테이블** | 불필요 | 필요 (상품 수만큼 행) | +| **커넥션 사용** | 1개 | Worker 수만큼 (4~10개) | +| **장애 복구** | 전체 재실행 | 실패한 파티션만 재실행 가능 | +| **스케일 아웃** | 불가 (단일 스레드) | gridSize 조정으로 선형 확장 | + +구현 복잡도가 높아지지만, **"대량의 데이터를 읽고 처리할 수 있도록 구성"**이라는 요구사항에 부합하고, 쿠팡급 스케일에서 실제로 동작 가능한 구조다. + +--- + +## 소재 10: Partitioning 도입 후 멱등성은 어떻게 보장하는가? + +### 이 고민이 시작된 맥락 + +단일 CursorReader에서는 멱등성이 단순했다: + +``` +Step 1: DELETE WHERE period_key = ? → 기존 MV 데이터 삭제 +Step 2: INSERT TOP 100 → 새 데이터 적재 +→ 몇 번을 실행해도 결과 동일 +``` + +Partitioning을 도입하면서 **스테이징 테이블이 추가**되었다. 이제 멱등성 시나리오가 복잡해진다: + +### Step 2에서 일부 Worker만 실패하면? + +``` +Step 1: DELETE MV + DELETE 스테이징 ✓ +Step 2: Worker 1 ✓, Worker 2 ✓, Worker 3 ✗ (DB 에러), Worker 4 ✓ + → 스테이징에 Worker 1,2,4의 데이터만 존재 (Worker 3 누락) + → Step 2 FAILED → Step 3 미실행 +``` + +재실행 시 Spring Batch는 **이미 COMPLETED된 파티션은 건너뛰고 실패한 파티션만 재실행**할 수 있다. 하지만 Step 1의 `allowStartIfComplete(true)`가 스테이징을 전부 DELETE하면, 성공한 Worker 1,2,4의 데이터도 사라진다. + +### 해결: 전체 재실행이 가장 단순하고 안전 + +``` +재실행: + Step 1: DELETE MV + DELETE 스테이징 (전부 정리) + Step 2: Worker 1~4 전체 재실행 (전체 재적재) + Step 3: 스테이징 → MV TOP 100 +``` + +수십 초 수준의 작업이므로 전체 재실행 비용이 문제되지 않는다. "실패한 파티션만 재실행"하는 최적화보다 "전부 정리하고 처음부터"가 운영상 안전하다. 부분 재실행은 스테이징의 정합성을 보장하기 어렵다. + +--- + +## 소재 11: 같은 날짜로 Job을 두 번 돌리면 어떻게 되는가? — Job Instance 동일성 + +### 이 고민이 시작된 맥락 + +``` +01:00 주간 MV Job 실행 (targetDate=20260416, scope=weekly) → 성공 +01:30 데이터 오류 발견 → 수정 후 같은 파라미터로 재실행하고 싶다 +``` + +Spring Batch는 `jobName + identifying JobParameters`로 Job Instance를 식별한다. 같은 파라미터로 재실행하면 "이미 완료된 Instance"라고 거부할 수 있다. + +### RunIdIncrementer가 해결 + +```java +.incrementer(new RunIdIncrementer()) +``` + +RunIdIncrementer는 기존 파라미터를 보존하면서 `run.id`를 1씩 증가시킨다. `run.id`는 non-identifying이므로 Job Instance 식별에 영향을 주지 않는다: + +``` +실행 1: targetDate=20260416, scope=weekly, run.id=1 → Instance A, Execution 1 +실행 2: targetDate=20260416, scope=weekly, run.id=2 → Instance A, Execution 2 (재실행 허용) +``` + +cleanupStep이 DELETE로 시작하므로, 재실행 시 이전 결과를 덮어쓴다 → 멱등성 보장. + +### 배치 프로젝트의 UniqueRunIdIncrementer와의 차이 + +배치 프로젝트의 UniqueRunIdIncrementer는 **모든 파라미터를 버리고 run.id만 남겼다**. 이 방식은 targetDate, scope를 `@Value("#{jobParameters[...]}")`로 주입받을 수 없다. 우리는 파라미터 보존이 필요하므로 기본 RunIdIncrementer를 사용한다. + +--- + +## 소재 12: 매번 원장에서 재계산하는 것이 효율적인가? — 전체 재계산 vs 증분 계산 + +### 이 고민이 시작된 맥락 + +MV가 매일 원장(product_metrics)에서 7일/30일치를 처음부터 GROUP BY한다는 설계를 보고 질문이 나왔다: **"매번 원장에서 새로 계산하는 게 효율적일까? 랭킹은 약간 정도는 틀어져도 사용자가 모를 텐데, 효율성과 장애 대응 관점에서도 고민해야 하지 않을까?"** + +증분 계산(어제 결과 - 가장 오래된 날 + 오늘)이 데이터 처리량을 93%(월간 기준) 줄일 수 있다. 이것이 더 나은 선택이 아닌가? + +그러다 **"주문 취소 건을 제외하고 랭킹을 집계한다면 전체 재계산이 더 의미있지 않을까?"**라는 질문이 결정적 근거를 만들었다. + +### 증분 계산의 원리 + +``` +어제 MV (4/10~4/16 합산): 상품 A = view 700, sales 3000만 +오늘 MV (4/11~4/17 합산): + = 어제 결과 - 4/10의 메트릭 + 4/17의 메트릭 + → 30일치 GROUP BY 대신 2일치만 조회 (93% 절감) +``` + +수학적으로 정확하다. 근사치가 아니다. 하지만 **하나의 전제가 필요하다: "과거 데이터가 변경되지 않는다."** + +### 이커머스에서 이 전제가 깨지는 이유: Late-Arriving Fact + +주문 취소/환불은 원주문과 다른 날에 발생한다: + +``` +4/10: 상품 A 주문 100건 (1000만원) +4/15: 그 중 30건 취소 → product_metrics 4/10 행의 cancel_by_order_date 갱신 + +증분 계산: + 4/10의 값은 이미 MV에 반영됨 (취소 전 1000만원 기준) + 4/15에 4/10 행이 변경됐지만, 증분은 "4/15의 메트릭만 추가"하므로 + → 4/10 행의 사후 변경을 감지 못함 + +전체 재계산: + 4/10~4/16 전체를 다시 읽음 + → 4/10 행의 cancel_by_order_date 변경이 자동 반영 +``` + +| 시나리오 | 전체 재계산 | 증분 계산 | +|---------|-----------|----------| +| 정상 주문 | ✅ 정확 | ✅ 정확 | +| 지연 취소 (주문 후 며칠 뒤) | ✅ 자동 반영 | ❌ 원주문 날짜 변경 감지 못함 | +| 운영팀 데이터 보정 | ✅ 다음 배치 자동 반영 | ❌ 전체 재계산을 별도 실행해야 함 | +| 오류 전파 | ❌ 없음 (매번 원장에서 독립 계산) | ⚠️ 어제 MV가 틀리면 오늘도 틀림 | + +### 성능 차이는 운영에 영향 없는 수준 + +| 방식 | 처리 데이터량 (월간) | 소요 시간 (Partitioning 4 Worker) | +|------|------------------|-------------------------------| +| 전체 재계산 | 30일분 | ~10초 | +| 증분 | 2일분 | ~3초 | + +**1일 1회 배치에서 10초 vs 3초는 운영 차이가 없다.** 증분이 유리해지는 전환점은 배치 주기가 5분 이하로 빈번해질 때다. + +### 결론 + +전체 재계산을 유지한다. 7초의 성능 이점보다 **Late-Arriving Fact 자동 반영 + 오류 자동 복구 + 구현 단순성**이 이커머스 랭킹 시스템에서 더 가치 있다. + +또한, MV의 존재 이유가 "Redis 근사치와 다른 정확한 기간 집계"인데, MV까지 과거 데이터 변경을 반영하지 못하는 증분 방식을 쓰면 MV의 정확성이 약해진다. + +--- + +## 소재 13: (반영 완료) + +- ~~MV vs Redis 실제 랭킹 비교 결과 (score 차이 분석)~~ → 12-ranking-batch-design-blog.md 섹션 1, 2.1에 반영 +- ~~Partitioning 실제 성능 측정 결과~~ → 12-ranking-batch-design-blog.md 섹션 3 벤치마크에 반영 + +--- + +## 참고자료 & 참고사례 목록 + +### 참고자료 (Spring Batch 공식/기술 문서) + +| 자료 | URL | 활용 부분 | +|------|-----|----------| +| Spring Batch Scalability 공식 문서 | https://docs.spring.io/spring-batch/reference/scalability.html | Partitioning 아키텍처 다이어그램, "IO-intensive Step" 언급, 4가지 스케일링 전략 비교 | +| ColumnRangePartitioner 공식 샘플 | https://github.com/SpringOne2GX-2014/spring-batch-performance-tuning/blob/master/sample_code/remote-partitioning/remote-partitioning-master/src/main/java/io/spring/remotepartitioningmaster/partition/ColumnRangePartitioner.java | MIN/MAX → 범위 분할 → ExecutionContext 코드. 우리 createPartitioner와 비교 | +| Baeldung Spring Batch Partitioner | https://www.baeldung.com/spring-batch-partitioner | TaskExecutorPartitionHandler 전체 구현 예시 | +| Partitioner 성능 개선 사례 (prostars.net) | https://prostars.net/357 | 파티션 1→5 변경 시 30초→17초 (1.8배). 스레드 풀 1 제한 시 2분 15초. 성능 비교 표 | + +### 참고사례 (빅테크 엔지니어링) + +| 사례 | URL | 활용 부분 | +|------|-----|----------| +| Netflix Distributed Counter | https://netflixtechblog.com/netflixs-distributed-counter-abstraction-8d0c45eb66b2 | 시간 기반 파티셔닝 + Rollup 병렬 집계 → merge. 우리 Map-Reduce 3-Step과 구조 유사 | +| Shopify BFCM Flink | https://shopify.engineering/bfcm-live-map-2021-apache-flink-redesign | 텀블링 윈도우 5분 간격 TOP 500 집계. Redis 병목 → Flink 전환. Lambda vs Kappa 비교 | +| Flipkart Unified Ranking | https://blog.flipkart.tech/the-science-of-unified-ranking-integrating-ads-and-organic-recommendations-8cc24113ef21 | 일간 배치로 relevance score 계산. aggregate features 설계 | +| eBay Analytics Data Processing | https://innovation.ebayinc.com/stories/optimizing-analytics-data-processing-on-ebays-new-open-source-based-platform/ | ETL 배치 최적화, 일간 테이블 갱신 | +| Airbnb Search Ranking Pipeline | https://medium.com/airbnb-engineering/machine-learning-powered-search-ranking-of-airbnb-experiences-110b4b1a0789 | 랭킹 파이프라인 offline 배치 실행, daily Airflow | diff --git a/docs/design/volume-10/11-ranking-batch-test-blog.md b/docs/design/volume-10/11-ranking-batch-test-blog.md new file mode 100644 index 0000000000..c5a87f6924 --- /dev/null +++ b/docs/design/volume-10/11-ranking-batch-test-blog.md @@ -0,0 +1,296 @@ +# 이커머스 상품 랭킹 배치를 E2E 테스트하며 알게 된 것들 + +> Spring Batch + Testcontainers + 실 API 검증까지, 배치 파이프라인을 테스트하면서 겪은 문제와 발견. + +--- + +## 1. 이 테스트는 무엇을 위한 것인가 + +쿠팡, 무신사 같은 이커머스에서 "인기 상품 TOP 100"은 단순한 조회가 아니다. +조회수, 좋아요, 매출, 취소를 조합한 **Score 계산**, 일간/주간/월간이라는 **시간 윈도우**, 그리고 실시간과 배치라는 **이중 경로**가 얽혀 있다. + +``` +[실시간 경로] Kafka → Redis ZSET → daily 랭킹 (빠르지만 근사치) +[배치 경로] DB 원장 → Spring Batch → MV 테이블 → weekly/monthly 랭킹 (느리지만 정확) +``` + +이 글에서 다루는 것은 **배치 경로의 E2E 테스트**다. "배치 Job이 돌았다"가 아니라, **"배치가 만든 데이터로 API가 의미 있는 결과를 내는가"** 를 검증했다. + +테스트를 통해 확인하고 싶었던 질문: + +- 3-Step 파이프라인(Cleanup → Partitioned Aggregate → Merge)이 정상 동작하는가? +- product_id 범위 분할 Partitioning이 데이터 누락 없이 집계하는가? +- 취소(cancel_amount)가 Score에 정확히 반영되는가? +- 같은 파라미터로 2회 실행해도 멱등성이 보장되는가? +- 데이터가 없거나 부분적일 때 Job이 안전하게 완료되는가? +- **일간/주간/월간 랭킹이 실제로 서로 다른 결과를 보여주는가?** + +--- + +## 2. 배치 구조: 3-Step 파이프라인 + +``` +Step 1: CleanupTasklet + └─ 기존 period_key 데이터 삭제 (멱등성 보장) + └─ 3일 이전 데이터 자동 퍼지 + +Step 2: Partitioned Aggregate (병렬) + └─ product_id MIN~MAX 범위를 4파티션으로 분할 + └─ Reader(SQL): 파티션별 GROUP BY 집계 (view, like, net_sales) + └─ Processor(Java): ScoreFormula.calculate()로 Score 계산 + └─ Writer: staging 테이블 적재 + +Step 3: Merge + └─ staging에서 Global TOP 100 추출 → MV 테이블 적재 + └─ ROW_NUMBER() OVER (ORDER BY score DESC) LIMIT 100 +``` + +핵심은 **Map-Reduce 패턴**이다. 각 파티션(Map)이 독립적으로 메트릭을 집계(Reader SQL)하고 Score를 계산(Processor, `ScoreFormula`)한 뒤, Merge 단계(Reduce)에서 전체 순위를 매긴다. + +--- + +## 3. E2E 테스트: 10개 시나리오와 그 의미 + +### 테스트 환경 + +| 항목 | 값 | +|------|-----| +| DB | MySQL 8.0 (Testcontainers) | +| 프레임워크 | `@SpringBatchTest` + `@SpringBootTest` | +| 데이터 | 테스트마다 독립 시드 (JdbcTemplate INSERT) | + +### 시나리오 목록 + +| # | 테스트 | 시나리오 | 검증 포인트 | +|---|--------|----------|-------------| +| 1 | **weeklySuccess** | 상품 150개 + 7일 메트릭 | 100건 적재, 1위 정확성, 전체 파이프라인 | +| 2 | **weeklyLessThan100** | 상품 30개 | LIMIT 100이지만 30건만 적재 | +| 3 | **monthlySuccess** | 상품 50개 + 30일 메트릭 | monthly 테이블 분기 | +| 4 | **idempotent** | 동일 파라미터 2회 실행 | 중복 없이 동일 결과 | +| 5 | **noData** | 메트릭 0건 | Job FAILED 아닌 COMPLETED | +| 6 | **partialData** | 7일 중 3일만 | 있는 만큼만 집계 | +| 7 | **cancellation** | 매출 200만/취소 150만 vs 매출 100만/취소 0 | 순매출 기준 순위 | +| 8 | **printRankingResults** | 20개 상품 × 30일 (5가지 패턴) | 일간/주간/월간 TOP 20 시각화 출력 | +| 9 | **largeScale** | 10만 상품 × 30일 (300만 행) | 4 Partition 병렬 집계, 파티션 균등 분배, 1위 정확성 | +| 10 | **partitionBenchmark** | gridSize=1 vs gridSize=4 (weekly + monthly) | Partitioning 성능 효과 정량 측정 (weekly 2.1x, monthly 1.8x) | + +7~10번 시나리오 중 처음 작성했을 때 기능 테스트(1~7) **모두 실패**했다. 테스트 프레임워크와의 충돌 때문이었다. + +--- + +## 4. 테스트를 작성하며 발견한 문제들 + +### 문제 1: `@SpringBatchTest`가 private 메서드를 몰래 실행한다 + +``` +No matching arguments found for method: runJob +``` + +`@SpringBatchTest`의 `JobScopeTestExecutionListener`는 테스트 클래스의 **모든 메서드**를 스캔한다. 접근 제어자와 무관하게 `getDeclaredMethods()`로 전부 가져온다. 이때 `JobExecution`을 반환하는 메서드를 찾으면 **인자 없이 호출을 시도**한다. + +테스트 헬퍼 메서드를 이렇게 만들었다가 걸렸다: + +```java +// AS-IS: 이렇게 하면 listener가 이 메서드를 발견하고 runJob() 호출 시도 → 실패 +private JobExecution runJob(String scope) throws Exception { ... } +``` + +`JobExecution` 반환 타입이 탐지 조건이었으므로, 반환 타입만 바꾸면 해결된다: + +```java +// TO-BE: BatchStatus를 반환하면 listener 스캔 대상에서 제외 +private BatchStatus runJob(String scope) throws Exception { ... } +``` + +Spring Batch 내부의 `HippyMethodInvoker`(실제 클래스명이다)가 메서드 시그니처로 대상을 결정한다. 공식 문서에는 이 동작이 기술되어 있지 않다. + +### 문제 2: `@JobScope` Partitioner Bean과 `@SpringBatchTest`의 충돌 + +``` +SpelEvaluationException: EvaluationContext has no variable 'jobParameters' +``` + +Partitioner Bean에 `@Value("#{jobParameters['targetDate']}")`를 사용하려면 `@JobScope`가 필요하다. 하지만 `@JobScope`를 붙이면 `@SpringBatchTest`의 `JobScopeTestExecutionListener`와 충돌한다. + +해결: Partitioner를 Bean이 아닌 **private 메서드**로 변경했다. + +```java +// AS-IS: Bean으로 등록하면 @JobScope 필요 → listener와 충돌 +@JobScope @Bean +public Partitioner productIdPartitioner( + @Value("#{jobParameters['targetDate']}") String targetDate) { ... } + +// TO-BE: 이미 @JobScope인 step 메서드에서 직접 호출 +@JobScope @Bean("partitionedAggregateStep") +public Step partitionedAggregateStep( + @Value("#{jobParameters['targetDate']}") String targetDate, + @Value("#{jobParameters['scope']}") String scope) { + return new StepBuilder(...) + .partitioner("workerStep", createPartitioner(targetDate, scope)) + ... +} + +private Partitioner createPartitioner(String targetDate, String scope) { ... } +``` + +`targetDate`, `scope`는 이미 `@JobScope`인 step 메서드의 파라미터로 주입받으므로 Partitioner가 별도 Bean일 필요가 없었다. 더 단순한 구조가 더 테스트하기 쉬운 구조이기도 했다. + +--- + +## 5. 테스트 데이터 설계: 운영에서 발생하는 6가지 패턴을 시나리오에 담기 + +시간 윈도우별 랭킹 차이를 검증하려면 테스트 데이터가 실제 운영 환경의 트래픽 패턴을 반영해야 한다. 이커머스에서 반복적으로 관찰되는 6가지 패턴을 식별하고, 각각이 일간/주간/월간 랭킹에서 어떤 위치를 차지하는지 설계했다. + +``` +A) 급상승 (5%): 과거 23일 미미 → 최근 7일 폭발 — 시즌 상품, 인플루언서 픽 +B) 장기강자 (10%): 30일 꾸준히 높음 — 스테디셀러, 필수 소비재 +C) 하락추세 (5%): 과거 23일 높음 → 최근 7일 급락 — 시즌 아웃, 품질 이슈 +D) 바이럴 (2%): 오늘 하루만 폭발 — SNS 바이럴, 타임딜 +E) 취소높음 (3%): 매출 높지만 취소 50~70% — 사이즈 이슈, 기대 불일치 +F) 일반 (75%): 보통 수준 — 롱테일 상품군 +``` + +이 패턴의 핵심은 **각 트렌드가 시간 윈도우에 따라 순위가 뒤집힌다**는 것이다. 급상승 상품은 주간에서 상위지만 월간에서는 묻히고, 장기강자는 주간에서 눈에 띄지 않지만 월간에서 상위로 올라온다. 30일 데이터에 이 패턴을 시딩하자, 일간/주간/월간 랭킹이 **완전히 다른 TOP 20**을 보여주었다. + +--- + +## 6. 가장 중요한 발견: 시간 윈도우가 랭킹을 결정한다 + +10만 상품 × 30일(300만 행) 대규모 테스트에서, 동일한 6가지 트렌드 패턴 데이터로 weekly와 monthly를 실행한 결과: + +- **weekly 1위**: product_5000 (급상승 — 최근 7일 폭발) +- **monthly 1위**: product_15000 (장기강자 — 30일 꾸준히 높음) + +같은 데이터, 같은 Score 공식인데 시간 윈도우만 달라도 1위가 완전히 다르다. 이 현상을 1,020개 상품 + 실제 API로도 검증했다: + +| 순위 | 일간 (Redis) | 주간 (MV) | 월간 (MV) | +|:----:|-------------|-----------|-----------| +| 1 | 아디다스 캠퍼스 올리브 **(바이럴)** | 나이키 에어리프트 카키 **(급상승)** | 반스 슬립온 올리브 **(장기강자)** | +| 2 | 살로몬 아웃펄스 네이비 **(바이럴)** | 컨버스 런스타하이크 그레이 **(급상승)** | 스투시 카고바지 화이트 **(장기강자)** | +| 3 | 뉴발란스 530 올리브 **(바이럴)** | 스투시 월드투어후디 카키 **(급상승)** | 리복 클럽C85 인디고 **(장기강자)** | + +**같은 데이터, 같은 Score 공식인데 시간 윈도우만 달라도 1위부터 완전히 다르다.** + +| 상품 트렌드 | 일간 순위 | 주간 순위 | 월간 순위 | 해석 | +|------------|:---------:|:---------:|:---------:|------| +| 바이럴 (오늘만 폭발) | 1위 | 100위 밖 | 100위 밖 | 1일치만 반영 | +| 급상승 (최근 7일 폭발) | 중위권 | 상위 | 100위 밖 | 과거 23일 미미 | +| 장기강자 (30일 꾸준) | 하위 | 하위 | 상위 | 꾸준함의 축적 | +| 하락추세 (과거 높고 최근 급락) | 하위 | 하위 | 상위 | 과거 실적이 30일에 반영 | + +이것이 왜 중요한가? + +"인기 상품"의 정의가 시간 윈도우에 따라 완전히 달라진다. **하나의 랭킹만 제공하면 어떤 관점은 반드시 누락된다.** 일간만 보여주면 장기 스테디셀러가 사라지고, 월간만 보여주면 바이럴 상품이 보이지 않는다. 이커머스에서 일간/주간/월간 랭킹을 별도 제공하는 이유가 여기에 있다. + +--- + +## 7. Score 수식과 취소 반영 + +### Score 공식 + +Score 계산은 `ScoreFormula.calculate()`(modules/jpa)에 중앙화되어 있다. Streamer, Batch Correction, MV Job, API Drift Scheduler 등 4곳에서 동일한 공식을 호출한다. + +``` +score = viewWeight × LOG10(view + 1) / 7.0 + + likeWeight × LOG10(like + 1) / 7.0 + + salesWeight × LOG10(net_sales + 1) / 7.0 + + categoryPriority + + timestamp × 1e-16 +``` + +- **LOG10**: 조회수 100만과 200만의 차이가 1과 2만큼 크지 않게 만든다 (로그 스케일링) +- **가중치 (기본 0.1/0.2/0.7)**: 매출 중심 랭킹 (view 10%, like 20%, sales 70%). `application.yml`에서 외부화 +- **/7.0**: 일간 Score와 범위를 맞추기 위한 정규화 +- **categoryPriority**: 카테고리별 가산점. 기존 MV Job SQL에서는 누락되어 있었으나 ScoreFormula 중앙화 시 반영 +- **timestamp × 1e-16**: Score가 동일할 때 최신 데이터를 우선하는 타이브레이커 + +### 취소 반영 + +```sql +SUM(pm.sales_amount - pm.cancel_amount_by_event_date) AS total_net_sales_amount +``` + +테스트 시나리오: 상품A(매출 100만, 취소 0) vs 상품B(매출 200만, 취소 150만). +상품B의 총매출이 2배지만 순매출은 50만이므로, 상품A(순매출 100만)가 1위가 된다. **매출 크기가 아니라 순매출이 순위를 결정**한다는 것을 테스트로 확인했다. + +--- + +## 8. 배치 실행 성능 + +### 규모별 성능 비교 + +| 규모 | 상품 수 | 메트릭 행 수 | weekly | monthly | +|---------|--------|------------|--------|---------| +| 소규모 | 1,020 | 30,600 | 275ms | 309ms | +| **대규모** | **100,000** | **3,000,000** | **2,205ms** | **2,564ms** | + +10만 상품 × 30일(300만 행)에서 4 Partition 병렬 집계 + Merge까지 약 2.5초. 데이터가 100배(1,020 → 100,000) 증가해도 소요 시간은 ~8배만 증가했다 — Partitioning + GROUP BY 최적화로 **sub-linear scaling**을 달성한 것이다. + +### Partitioning 벤치마크: gridSize=1 vs gridSize=4 + +"Partitioning이 없었다면 단일 쿼리로 처리해야 하므로 데이터가 커질수록 차이가 벌어진다." — 이걸 실제로 측정해봤다. + +10만 상품 × 30일(300만 행)에서 gridSize만 1과 4로 바꿔서 weekly/monthly 각 2회 실행한 결과: + +| 구성 | weekly (7일, 70만행) | monthly (30일, 300만행) | +|------|---------------------|------------------------| +| gridSize=1 (단일 스레드) | **3,691ms** | **3,842ms** | +| gridSize=4 (4 Partition 병렬) | **1,746ms** | **2,188ms** | +| **향상률** | **2.1x** | **1.8x** | + +이 표는 두 방향으로 읽을 수 있다. + +**세로로 읽기 — "병렬화하면 얼마나 빨라지나?"** 같은 scope에서 gridSize 1→4로 올리면 weekly 2.1x, monthly 1.8x 향상. 이론적 상한 4x보다 낮은 이유는 Amdahl's Law — 직렬 구간(Partitioner, mergeStep, JobRepository)이 병목이 된다. + +**가로로 읽기 — "데이터 4배면 얼마나 더 느린가?"** 같은 gridSize에서 weekly→monthly로 데이터가 4배 늘면, gridSize=1은 **+4%**, gridSize=4는 **+25%** 증가한다. 4배 데이터인데 4배 느려지지 않는 이유는 Reader SQL의 GROUP BY가 70만/300만 행을 모두 **동일한 10만 건으로 압축**하기 때문이다. Processor, Writer, Merge는 scope와 무관하게 10만 건을 처리하므로 데이터 볼륨에 영향받지 않는다. + +gridSize=4에서 격차가 +4%→+25%로 벌어지는 이유는 IO 경합이다. Worker 4개가 동시에 같은 MySQL에 접근할 때, weekly(각 17.5만행)는 buffer pool 256MB로 커버되지만 monthly(각 75만행)는 경합이 발생한다. 프로덕션 MySQL(buffer pool 수 GB 이상)에서는 워킹셋이 메모리에 올라가므로 이 격차가 줄어들 것으로 예상된다. + +2.1x/1.8x는 의미 있는 수치다. 데이터가 10배(100만 상품)로 늘어나면 37초 vs 18초(weekly), 38초 vs 22초(monthly)로 벌어진다. 병렬화의 효과는 규모에 비례한다. + +--- + +## 9. 실 환경 API 검증에서 발견한 것 + +E2E 테스트(Testcontainers)는 모두 통과했지만, **실제 commerce-api에서 weekly/monthly API를 호출하면 빈 결과**가 반환되었다. + +원인: commerce-api가 **화요일에 시작**되었는데, MV Entity 클래스는 그 이후에 추가되었다. `ddl-auto: create`로 시작 시 테이블은 만들어졌지만, **런타임에 해당 Entity의 Repository 코드 자체가 빌드에 없었다.** 앱을 재빌드하고 재시작하자 정상 동작. + +``` +MvProductRank*.class → 빌드에 없음 → getFromMv() 호출되어도 쿼리 실행 안 됨 +``` + +이건 E2E 테스트만으로는 잡을 수 없는 문제다. **E2E 테스트는 "코드가 맞는가"를 검증하지만, "배포된 버전이 최신인가"는 검증하지 않는다.** 실 환경에서 한 번 더 확인하는 것이 의미 있었던 이유다. + +--- + +## 10. 정리: 이 테스트로 무엇을 알게 되었는가 + +### 기술적으로 확인한 것 + +| 검증 항목 | 결과 | +|-----------|------| +| 3-Step 파이프라인 정상 동작 | Cleanup → Partitioned Aggregate → Merge | +| product_id 범위 분할 Partitioning | 4파티션, 데이터 누락 없음 | +| scope 분기 (weekly/monthly) | 각각 다른 MV 테이블에 적재 | +| 멱등성 | Cleanup + RunIdIncrementer로 보장 | +| 빈 데이터 / 부분 데이터 | Job COMPLETED, 안전 처리 | +| 취소 반영 | 순매출 기준 순위 결정 | +| 시간 윈도우별 랭킹 차이 | 일간/주간/월간 TOP 20이 완전히 다름 | +| Partitioning 성능 효과 | gridSize=1 대비 gridSize=4: weekly 2.1x, monthly 1.8x (10만 상품 기준) | + +### 테스트 설계에서 배운 것 + +1. **"테스트가 통과한다"와 "의미 있는 것을 검증한다"는 다르다.** 7일 데이터로 월간 테스트를 돌리면 통과하지만 아무것도 증명하지 못한다. + +2. **테스트 데이터의 다양성이 테스트의 품질을 결정한다.** 6가지 트렌드 패턴(급상승, 장기강자, 하락, 바이럴, 취소, 일반)을 설계한 후에야 시간 윈도우별 차이가 드러났다. + +3. **Spring Batch 테스트 프레임워크에는 문서화되지 않은 동작이 있다.** `JobScopeTestExecutionListener`의 메서드 스캔, `HippyMethodInvoker`의 반환 타입 기반 탐지 등. + +4. **E2E 테스트와 실 환경 검증은 다른 것을 잡는다.** 코드 정합성은 Testcontainers가, 배포 정합성은 실 환경 API 호출이 잡는다. + +### 비즈니스 관점에서 확인한 것 + +시간 윈도우는 단순한 "기간 필터"가 아니다. **같은 공식이라도 시간 윈도우에 따라 집계 대상이 달라지고, 그 결과 "인기 상품"의 순위가 완전히 바뀐다.** 오늘 SNS에서 터진 상품, 이번 주 꾸준히 팔린 상품, 한 달간 스테디셀러인 상품은 모두 "인기 상품"이지만, 하나의 랭킹으로는 세 관점을 동시에 담을 수 없다. + +Lambda Architecture(실시간 Redis + 배치 MV)를 선택한 이유도 여기에 있다. 실시간 경로는 "지금 뜨는 상품"을, 배치 경로는 "기간 동안 검증된 상품"을 각각 담당한다. 두 경로가 서로 다른 것은 버그가 아니라 설계 의도이며, 이 테스트는 그 설계 의도가 실제로 동작하는지를 확인하는 과정이었다. diff --git a/docs/design/volume-10/12-ranking-batch-design-blog.md b/docs/design/volume-10/12-ranking-batch-design-blog.md new file mode 100644 index 0000000000..95c213174e --- /dev/null +++ b/docs/design/volume-10/12-ranking-batch-design-blog.md @@ -0,0 +1,376 @@ +# 이커머스 랭킹 배치 설계기 — 일간/주간/월간 집계가 어떻게 달라지나 + +--- + +### TL;DR + +> 같은 데이터, 같은 Score 공식인데 시간 윈도우만 달라도 1위가 바뀐다. 10만 상품 × 30일(300만 행) 에 대한 테스트를 해보니 주간-월간 간 TOP100에 차이들이 보였다. 이 글은 "주간/월간 인기상품 TOP100을 MV로 관리하는 이커머스 설계" 경험을 바탕으로, 결과의 차이를 만드는 설계 판단(Score 방식), 차이가 정확하도록 보장하는 판단(전체 재계산), 차이를 안정적으로 생산하는 판단(Chunk + Partitioning)을 기록한다. + +--- + +## 1. 설계 배경 + +UX를 고려하는 대규모 이커머스에서 "인기 상품 TOP 100"은 단순한 조회가 아닐 것이다. 조회수, 좋아요, 매출, 취소를 조합한 Score 계산, 일간/주간/월간이라는 시간 윈도우, 실시간과 배치라는 이중 경로까지 고려해야 될 것이다. + +``` +[실시간 경로] Kafka → Redis ZSET → daily 랭킹 (빠르지만 근사치) +[배치 경로] DB 원장 → Spring Batch → MV 테이블 → weekly/monthly 랭킹 (느리지만 정확) +``` + +이미 Redis로 일간/주간/월간 랭킹을 제공하고 있었다. 그런데 왜 MV 테이블을 또 만드는가? + +Redis의 주간/월간 랭킹은 일별 score를 합산하거나 지수 감쇠(`daily × 0.97^i`)를 적용한 **근사치**다. `log₁₀`의 비선형성 때문에 "일별 score의 합 ≠ 기간 메트릭 합산 후의 score"가 된다. 이 차이가 순위를 바꾼다. MV를 활용해서 DB 원장에서 기간 전체를 직접 집계하여 **정확한 기간 랭킹**을 제공하려 고민해봤다. + +두 방식의 TOP100 상품은 차이가 있다. 그렇다면 일간/주간/월간 집계는 어떻게, 왜 달라질까? + +--- + +## 2. 집계가 달라지는 구조 + +### 2.1 차이를 만드는 판단 — "주간/월간 인기상품"의 산정 기준은 '총 판매량'일까?, '최근 인기'일까? 아니면 '전시기간에 의한 누적을 보정한 판매량'일까? + +일간/주간/월간 집계에 별반 차이가 없다면 사용자의 UX경험이 나쁠 것이다. 어느정도 이상의 결과 차이를 보이려면 Score 계산 방식이 그 차이를 허용해야 한다. 무엇을 기준으로 Score 집계해서 기간별 랭킹 차이를 만들어볼까? + +#### 검토한 MV Score 계산 방식 + +| 방식 | 계산 | 특성 | +|------|------|------| +| **균등 합산** | `score = f(SUM(30일 메트릭))` | 30일 전이나 오늘이나 동등한 가중치. "기간 총 실적" | +| **지수 감쇠** | `monthly = Σ(daily × 0.97^i)` | 최근 데이터에 높은 가중치. 반감기 약 23일 | +| **일평균** | `score = f(SUM / 전시일수)` | 전시 기간에 관계없이 "일당 성과" | + +#### 결정: 균등 합산 + +**회사 MD분에게 질문했다. "'이번 달 인기상품'으로 전시되는 상품은 어떤 상품이어야 할까요?"에 대해서 "무조건 '총 판매량'이 기준이다."라는 답변을 얻었다.** 비즈니스적으로 가장 의미있고, 소비자가 기대하는 바에도 가장 정직하게 부합하는 기준이라는 게 그 이유다. + +두 시스템의 역할 분담은 이렇게 된다: + +| | Redis (실시간 경로) | MV (배치 경로) | +|------|---------------------|-------------------| +| **비즈니스 의미** | "지금 뜨는 상품" (트렌드) | "이번 달 베스트셀러" (누적 성과) | +| **Score 방식** | 지수 감쇠 | 균등 합산 | +| **소비자 시나리오** | 메인 페이지 실시간 인기 | 카테고리별 베스트, 기간별 랭킹 | + +균등 합산은 전시 기간이 긴 상품이 유리하다는 트레이드오프가 있다. 지수 감쇠로 이를 희석할 수 있지만, 그러면 오히려 판매량이 훨씬 떨어지지만 최근에 인기있던 상품이 스테디셀러보다 우위에 서게 될 것이고, 결정적으로 일간 집계 결과와 차이가 적어져서 UX경험 측면에서 좋지 않을 것이라고 판단했다. + +#### 테스트 결과 + +10만 상품 × 30일(300만 행) 테스트에서, 운영 환경에서 관찰되는 6가지 트렌드 패턴을 시딩했다: + +| 패턴 | 비율 | 특징 | +|------|------|------| +| 급상승 | 5% | 과거 23일 미미 → 최근 7일 폭발 | +| 장기강자 | 10% | 30일 꾸준히 높음 | +| 하락추세 | 5% | 과거 높음 → 최근 급락 | +| 바이럴 | 2% | 오늘 하루만 폭발 | +| 취소높음 | 3% | 매출 높지만 취소 50~70% | +| 일반 | 75% | 보통 수준 | + +균등 합산을 적용한 결과: + +- **weekly 1위**: product_5000 (급상승 — 최근 7일 폭발) +- **monthly 1위**: product_15000 (장기강자 — 30일 꾸준히 높음) + +같은 Score 공식인데 시간 윈도우가 달라지니 1위도 완전히 달라졌다. 구현한 API의 결과 역시 아래와 같다: + +| 순위 | 일간 (Redis) | 주간 (MV) | 월간 (MV) | +|:----:|-------------|-----------|-----------| +| 1 | 아디다스 캠퍼스 올리브 **(바이럴)** | 나이키 에어리프트 카키 **(급상승)** | 반스 슬립온 올리브 **(장기강자)** | +| 2 | 살로몬 아웃펄스 네이비 **(바이럴)** | 컨버스 런스타하이크 그레이 **(급상승)** | 스투시 카고바지 화이트 **(장기강자)** | +| 3 | 뉴발란스 530 올리브 **(바이럴)** | 스투시 월드투어후디 카키 **(급상승)** | 리복 클럽C85 인디고 **(장기강자)** | + +반대 방향도 있었다. 하락추세 상품(메종키츠네)이 **월간 1위인데 일간/주간 19위**였다 — 과거 23일의 실적이 월간에는 남지만 최근 급락은 즉시 반영됐다. + +**같은 공식이라도 시간 윈도우에 따라 집계 대상이 달라지고, 그 결과 "인기 상품"의 순위가 완전히 바뀐다.** 하나의 랭킹만 제공하면 어떤 관점은 누락된다. 일간만 보여주면 장기 스테디셀러가 사라지고, 월간만 보여주면 바이럴 상품이 보이지 않는다. 이렇게 기간별 랭킹의 차이를 확인했다. + +--- + +### 2.2 차이가 정확하려면 — 매번 원장에서 재계산하면 비효율적인가? + +시간 윈도우별로 다른 랭킹을 보여주는 것은 2.1에서 가능해졌다. 그런데 그 차이가 **정확한** 차이인가? MV는 매일 원장(product_metrics)에서 7일/30일 전체를 GROUP BY로 새로 집계한 데이터를 가지도록 했다. 그런데 증분 계산(어제 결과 - 가장 오래된 날 + 오늘)을 이용하면 데이터 처리량을 93%(월간 기준) 줄일 수 있다. + +#### 고민 + +월간 기준 30일분을 매일 재계산하는 것은 29/30 = 97%의 데이터를 중복 처리하는 것처럼 보인다. "약간 정도는 틀어져도 사용자가 모를 텐데, 효율성과 장애 대응 관점에서 증분이 낫지 않을까?"라는 고민을 꽤나 반복했다. + +#### 증분 계산이 깨지는 이유: Late-Arriving Fact + +이커머스에서 주문 취소/환불은 원주문과 다른 날에 발생한다: + +``` +4/10: 상품 A 주문 100건 (1,000만원) +4/15: 그 중 30건 취소 → product_metrics 4/10 행의 cancel_by_order_date 갱신 + +증분 계산: 4/10의 값은 이미 어제 MV에 반영됨 → 사후 변경을 감지 못함 +전체 재계산: 4/10~4/16 전체를 다시 읽으므로 → 변경된 값이 자동 반영 +``` + +증분 계산은 **"과거 데이터가 불변"이라는 전제가 필요하다.** `cancel_by_order_date`가 과거 행을 사후 갱신하므로 이 전제가 깨진다. + +| 시나리오 | 전체 재계산 | 증분 계산 | +|---------|-----------|----------| +| 정상 주문 | 정확 | 정확 | +| 지연 취소 (주문 후 며칠 뒤) | 자동 반영 | 원주문 날짜 변경 감지 못함 | +| 운영팀 데이터 보정 | 다음 배치 자동 반영 | 전체 재계산을 별도 실행해야 함 | +| 오류 전파 | 없음 (매번 독립 계산) | 어제 MV가 틀리면 오늘도 틀림 | + +#### 결정: 전체 재계산 + +성능 차이(Partitioning 4 Worker 기준 ~10초 vs ~3초)는 **1일 1회 배치에서 운영 영향이 없다.**고 판단했다. 증분이 유리해지는 전환점은 배치 주기가 5분 이하로 빈번해질 때일 것이다. + +#### 테스트 결과 + +E2E 테스트 시나리오 중 #7(취소 반영 테스트)에서 이 판단을 검증했다: + +``` +상품 A: 매출 100만 / 취소 0 → 순매출 100만 +상품 B: 매출 200만 / 취소 150만 → 순매출 50만 + +결과: 상품 A가 1위 (총매출이 아닌 순매출 기준) +``` + +전체 재계산 덕분에, 취소가 나중에 발생해도 다음 배치에서 자동으로 반영된다. 증분이었다면 원주문 날짜의 취소 변경을 놓쳤을 것이다. + +MV의 존재 이유에는 "Redis 근사치와 다른 정확한 기간 집계"도 있다고 생각하기 때문에 과거 데이터 변경을 반영하지 못하는 증분 방식을 쓰면 MV의 정확성이 약해지므로 MV를 도입하는 의의가 약해진다고 생각했다. + +--- + +### 2.3 차이를 안정적으로 생산하려면 — Chunk vs Tasklet: 90개 실무 Job이 알려준 것 + +Score 방식과 전체 재계산으로 정확한 기간별 랭킹 차이를 만들 수 있게 되었다. 이제 이것을 매일 안정적으로 생산하는 처리 모델을 선택해야 한다. + +#### 판단 + +이 작업은 Tasklet으로도 가능하다. `INSERT INTO...SELECT + RANK() OVER + LIMIT 100`으로 SQL 한 문장이면 끝이고, 네트워크 왕복도 0이다. + +우리팀에서 사용하는 실무 배치 애플리케이션 2개(총 90개 Job)를 분석했더니 통계/집계 Job의 대다수가 Tasklet이었다. 처음에는 "Tasklet이 보편적"인 줄 알았는데, 두 앱 모두 MyBatis + SQL 중심 아키텍처여서 Tasklet(`INSERT INTO...SELECT`)이 자연스러운 선택이었던 것 같다. 하지만 Spring Batch 프레임워크 자체는 Chunk를 중심으로 설계되어 있고, retry/skip/restart 등 운영 기능이 Chunk에만 제공된다. + +#### Tasklet이 맞는 조건 + +팀 배치의 Job 분석에서 도출한 Tasklet 조건은 세 가지다: + +| 조건 | 설명 | +|------|------| +| SQL 한 문장으로 완결 | Java 변환이 전혀 없고 DB → DB 이동 | +| retry/skip이 불필요 | 실패 시 전체 재실행해도 수초 내 완료 | +| 중간 상태가 없음 | 처리 중 실패해도 "부분 완료" 상태가 의미 없음 | + +사실 이번 설계에서의 MV에 TOP 100 적재는 세 조건을 모두 충족한다. 그런데도 Chunk를 선택한 이유가 있다. + +#### 팀의 배치 Job 운영에서 아쉬웠던 점 + +``` +팀 운영 Job(90개)의 기능 사용 현황: + + faultTolerant() → 0개 + retry() / retryLimit → 0개 + skip() / skipLimit → 0개 + ItemReadListener → 0개 + ChunkListener → 0개 + allowStartIfComplete → 0개 +``` + +**90개 Job 중 단 하나도 retry, skip, restart를 사용하지 않는다.** 이것은 "안 써도 된다"가 아니라, **"1건의 일시적 DB 에러가 전체 배치를 실패시키는 구조로 운영하고 있다"**는 뜻이다. 야간 배치가 데드락으로 실패하면 아침에 출근해서 수동 재실행해야 한다.(지난주에도 그랬다..) `faultTolerant().retry(3)`를 걸어두면 자동으로 복구됐을 에러다. + +#### 결정: Chunk + +Chunk를 선택하면 Spring Batch의 운영 기능을 활용할 수 있다: + +- **`faultTolerant + retry + ExponentialBackOffPolicy`**: 일시적 DB 에러(데드락, 커넥션 타임아웃) 시 100ms → 200ms → 400ms 간격으로 자동 재시도 +- **`StepExecution` 자동 기록**: 각 Worker별 readCount, writeCount를 Spring Batch가 추적 +- **`StepMonitorListener`**: 실패 시 알림 + +100건에 대한 네트워크 왕복 비용(< 1ms)보다 이 운영 기능의 가치가 크다고 판단했다. 현재 팀에서 안 쓰니까 안 써도 되는 것이 아니라, 프레임워크가 제공하는 운영 기능을 활용하여 야간 배치의 자동 복구 가능성을 높이고 싶었다. + +--- + +## 3. 차이를 빠르게 만들려면 — 3-Step Chunk Job + +Score 방식(2.1)이 차이를 만들고, 전체 재계산(2.2)이 정확성을 보장하고, Chunk(2.3)가 안정성을 제공한다. 남은 문제는 **속도**다. 300만 행(10만 상품 × 30일)을 매일 전체 재계산하면서도, 배치가 운영 부담이 되지 않을 처리 구조를 고민했다. + +### 배치 구조 + +``` +Step 1: CleanupTasklet + └─ 기존 period_key 데이터 삭제 (멱등성 보장) + └─ 3일 이전 데이터 자동 퍼지 + +Step 2: Partitioned Aggregate (병렬) + └─ product_id MIN~MAX 범위를 4파티션으로 분할 + └─ Reader(SQL): 파티션별 GROUP BY 집계 (view, like, net_sales) + └─ Processor(Java): ScoreFormula.calculate()로 Score 계산 + └─ Writer: staging 테이블 적재 + +Step 3: Merge + └─ staging에서 Global TOP 100 추출 → MV 테이블 적재 + └─ ROW_NUMBER() OVER (ORDER BY score DESC) LIMIT 100 +``` + +핵심은 Map-Reduce 패턴이다. 각 파티션(Map)이 독립적으로 메트릭을 집계(Reader SQL)하고 Score를 계산(Processor, `ScoreFormula`)한 뒤, Merge 단계(Reduce)에서 전체 순위를 매긴다. + +### GROUP BY 집계에서 PagingReader가 위험한 이유 + +Reader로 `JdbcCursorItemReader`를 선택했다. 이유는 GROUP BY 집계 쿼리에서 `JdbcPagingItemReader`가 치명적이라고 생각했기 때문이다. + +PagingReader는 페이지마다 **독립된 쿼리를 재실행**한다. 단순 WHERE 쿼리에서는 문제없지만, GROUP BY가 포함되면 매 페이지마다 전체 데이터를 다시 집계한다: + +``` +CursorReader: + GROUP BY 3,000만 행 → 1번 실행 → 결과 스트리밍 + 총 집계 실행: 1회 + +PagingReader (pageSize=1000, 상품 100만 건 = 1,000페이지): + 페이지 1: GROUP BY 3,000만 행 → 정렬 → OFFSET 0 LIMIT 1000 + 페이지 2: GROUP BY 3,000만 행 → 정렬 → OFFSET 1000 LIMIT 1000 + ... + 총 집계 실행: 1,000회 +``` + +그런데 CursorReader는 하나의 ResultSet을 열어두고 `next()`로 이동하는 구조여서 **멀티스레드에서 사용할 수 없다.** 두 스레드가 동시에 `next()`를 호출하면 커서가 밀리면서 데이터가 누락될 수 있기 때문이다. + +### Partitioning으로 두 가지를 모두 해결 + +CursorReader의 장점(GROUP BY 1회 실행)을 유지하면서 멀티스레드 한계를 극복하려면, **데이터를 범위로 분할하여 각 Worker가 독립 CursorReader를 갖도록** 하면 된다. Spring Batch 공식 문서는 이 패턴을 "IO-intensive Step"에 적합한 스케일링 전략으로 설명한다: + +> "The workers in this picture are all identical instances of a `Step`, which could in fact take the place of the manager, resulting in the same outcome for the `Job`." +> — [Spring Batch Scalability](https://docs.spring.io/spring-batch/reference/scalability.html) + +``` +Partitioner: product_id MIN~MAX → 4개 범위로 분할 + + Worker 1: id 1~25,000 → 독립 CursorReader, 독립 DB 커넥션 + Worker 2: id 25,001~50,000 → 독립 CursorReader, 독립 DB 커넥션 + Worker 3: id 50,001~75,000 → 독립 CursorReader, 독립 DB 커넥션 + Worker 4: id 75,001~100,000 → 독립 CursorReader, 독립 DB 커넥션 +``` + +이 범위 분할 로직은 Spring Batch 공식 샘플의 [`ColumnRangePartitioner`](https://github.com/SpringOne2GX-2014/spring-batch-performance-tuning)와 동일한 패턴을 적용했다: + +```java +// Spring Batch 공식 샘플 — ColumnRangePartitioner.partition() +int min = jdbcTemplate.queryForObject("SELECT MIN(" + column + ") from " + table, Integer.class); +int max = jdbcTemplate.queryForObject("SELECT MAX(" + column + ") from " + table, Integer.class); +int targetSize = (max - min) / gridSize; + +while (start <= max) { + ExecutionContext value = new ExecutionContext(); + value.putInt("minValue", start); + value.putInt("maxValue", end); + result.put("partition" + number, value); + start += targetSize; + end += targetSize; +} +``` + +`createPartitioner`를 두어 `SELECT DISTINCT product_id WHERE metric_date BETWEEN ...`로 실제 메트릭이 존재하는 상품 ID 목록을 가져온 뒤, gridSize로 균등 분할하여 `ExecutionContext`에 담았다. 공식 샘플이 MIN/MAX 산술 분할을 기본 패턴으로 제시하고 있는데, 나는 DISTINCT 목록 기반 분할로 **메트릭이 없는 빈 구간이 파티션에 포함되지 않도록** 했다. + +각 Worker가 자기 범위의 GROUP BY만 실행하므로 ResultSet 공유 문제가 없다. 결과는 staging 테이블에 모이고, mergeStep에서 Global TOP 100을 추출한다. + +### Score 공식의 중앙화 + +Processor에서 사용하는 Score 공식(`LOG10 + 가중치`)은 원래 Streamer, Batch Correction, MV Job SQL, API Drift Scheduler 4곳에 분산되어 있었다. MV Job을 추가하면서 5번째 복사본이 생기는 시점에서 `ScoreFormula`(modules/jpa)로 중앙화했다. 가중치도 `ScoreFormula.Weights` record로 타입을 통일하고 `application.yml`에서 주입한다. 공식이 한 곳에만 존재하므로 변경 시 누락이 구조적으로 불가능해졌다. + +### 벤치마크: gridSize=1 vs gridSize=4 + +10만 상품 × 30일(300만 행)에서 `ReflectionTestUtils`로 gridSize만 바꿔서 같은 데이터를 weekly/monthly 각 2회 실행한 결과: + +| 구성 | weekly (7일, 70만행) | monthly (30일, 300만행) | +|------|---------------------|------------------------| +| gridSize=1 (단일 스레드) | **3,691ms** | **3,842ms** | +| gridSize=4 (4 Partition 병렬) | **1,746ms** | **2,188ms** | +| **향상률** | **2.1x** | **1.8x** | + +이 표는 두 방향으로 읽을 수 있다. + +#### 세로로 읽기 — "병렬화하면 얼마나 빨라지나?" + +같은 scope에서 gridSize를 1→4로 올리면: + +- **weekly**: 3,691ms → 1,746ms = **2.1x 향상** +- **monthly**: 3,842ms → 2,188ms = **1.8x 향상** + +이론적 상한은 4x지만, Amdahl's Law에 의해 병렬화할 수 없는 직렬 구간(Partitioner의 `SELECT DISTINCT product_id`, mergeStep의 `ROW_NUMBER() OVER`, JobRepository 메타데이터 저장)이 상한을 낮춘다. 데이터가 많은 monthly에서 향상률이 더 떨어지는 이유는 아래에서 설명한다. + +그래도 2.1x/1.8x는 의미 있다. 1일 1회 배치에서 절대적 차이는 크지 않지만, 데이터가 10배(100만 상품)로 늘어나면 37초 vs 18초(weekly), 38초 vs 22초(monthly)로 벌어진다. 병렬화의 효과는 규모에 비례할 것이기 때문이다. + +#### 가로로 읽기 — "데이터가 4배면 얼마나 더 느린가?" + +같은 gridSize에서 weekly(70만행)→monthly(300만행)로 데이터가 4배 늘어나면: + +- **gridSize=1**: 3,691ms → 3,842ms = **+4%** (+151ms) +- **gridSize=4**: 1,746ms → 2,188ms = **+25%** (+442ms) + +데이터가 4배인데 4배 느려지지 않는 이유는, Reader SQL의 GROUP BY가 데이터 볼륨을 흡수하기 때문이다. + +``` +weekly: 70만 행 ──GROUP BY──→ 10만 행 (상품별 집계) +monthly: 300만 행 ──GROUP BY──→ 10만 행 (상품별 집계) +``` + +GROUP BY를 통과하면 scope과 무관하게 **동일한 10만 건**이 파이프라인에 흐른다. Processor(`ScoreFormula`), Writer(staging INSERT), Merge(TOP 100 추출)는 모두 10만 건을 처리하므로 데이터 볼륨에 영향받지 않는다. 차이가 발생하는 유일한 구간은 Reader의 DB 스캔이다. + +그런데 같은 GROUP BY 구조인데도, gridSize=1에서는 +4%이고 gridSize=4에서는 +25%로 **격차가 6배 벌어진다.** 병렬화가 이 격차를 줄여야 할 것 같지만 오히려 키운다. 이유는 IO 경합이다: + +- **gridSize=1**: Worker 1개가 DB를 혼자 쓴다. 70만이든 300만이든 순차 스캔이라 경합이 없다. +- **gridSize=4**: Worker 4개가 동시에 같은 MySQL 인스턴스에 접근한다. weekly(각 17.5만행)는 buffer pool 256MB로 커버되지만, monthly(각 75만행)는 buffer pool 경합 + 디스크 IO 경합이 발생한다. + +프로덕션 DB(buffer pool 수 GB 이상)에서는 300만 행(~450MB)이 메모리에 올라가므로 이 경합이 줄어들 것이다. gridSize=4의 +25%가 gridSize=1의 +4%에 수렴할 것으로 예상되며, gridSize=1도 디스크 IO가 사라지면서 +1~2%(순수 CPU 비용)로 줄어들 것으로 보인다. + +유사한 패턴을 보이는 다른 사례가 있다. [prostars.net의 Partitioner 성능 측정](https://prostars.net/357)에서: + +| Partition | 소요 시간 | 향상률 | +|-----------|----------|--------| +| 1 | 30s 809ms | — | +| 5 | 17s 319ms | **1.8x** | +| 10 | 17s 529ms | 1.8x | +| 15 | 17s 529ms | 1.8x | + +> "파티션을 크게 설정한다고 무조건 성능이 좋아지는 것은 아니다" + +partition=5 이후 향상률이 정체되는 것은 이번 구현에서 겪은 2.1x(gridSize=4)와 일맥상통한다. Amdahl's Law에 의해 직렬 구간이 병목이 되면 Worker를 아무리 늘려도 한계가 있다. 또한 thread pool size=1로 제한하면 partition=5에서도 **2분 15초**로 급격히 느려지는데, Partitioning은 스레드 풀과 함께 써야 의미가 있다는 것을 보여준다. + +--- + +## 4. 시행착오 + +참고적으로 기술하자면.. +### `@SpringBatchTest`가 private 메서드를 몰래 실행한다 + +``` +No matching arguments found for method: runJob +``` + +`@SpringBatchTest`의 `JobScopeTestExecutionListener`는 테스트 클래스의 **모든 메서드**를 `getDeclaredMethods()`로 스캔한다. `JobExecution`을 반환하는 메서드를 찾으면 인자 없이 호출을 시도한다. + +테스트 헬퍼 메서드 `private JobExecution runJob(String scope)`가 탐지 대상이 되어 실패했다. 반환 타입을 `BatchStatus`로 변경하면 스캔 대상에서 제외된다. 공식 문서에는 이 동작이 기술되어 있지 않다. + +--- + +## 5. 운영 환경에서 적용할 조정 + +### gridSize 동적 조정 + +현재 gridSize를 4로 고정했지만, 실무에서는 커넥션 풀 크기와 CPU 코어 수에 연동해서 설정해야 하겠다. 배치 전용 DataSource의 커넥션 풀이 10이면 gridSize를 8 이상으로 잡으면 커넥션 고갈이 발생할 것이다. 그래서 `@Value`로 외부화 해두었으므로 프로파일별 설정으로 대응 가능하면 되겠다. + +### 스테이징 테이블의 비용 + +상품 100만 개면 스테이징에 100만 행이 적재된다. mergeStep에서 TOP 100만 추출하고 나머지는 cleanup에서 삭제하지만, 이 중간 저장 비용이 Partitioning의 병렬 처리 이점을 상쇄하면 어쩌나 하는 고민이 있다. 처리 속도뿐 아니라 디스크 I/O, 트랜잭션 로그 크기도 고려해야 할 것이다. + +--- + +## 6. 회고 + +현재 이커머스개발팀에서 근무하고 있지만 담당 파트만 작업하다보니 랭킹까지 고민한 적이 없었다. +'MD가 원하는 랭킹이 무엇인지' 비즈니스적 의미를 살펴보는 것을 시작으로, 정확하고 안정적인데 빠르게 작동하는 랭킹 집계 시스템을 설계해보는 것을 목표로 삼았다. + +이번에 이커머스 랭킹을 설계해보니 "어떤 시간 윈도우로 보느냐"에 결과가 완전히 달라지다보니 현실에서 어떤 흐름으로 상품의 랭킹이 만들어질지를 생각해봤다. 오늘 SNS에서 주목받는 상품, 이번 주 꾸준히 팔린 상품, 한 달간 스테디셀러인 상품은 모두 "인기 상품"이지만, 하나의 랭킹으로 세 관점을 모두 담기 어려웠다. + +scope별로 데이터 소스를 분리한 이유도 여기에 있다. 일간 랭킹은 Kafka → Redis의 실시간 경로(+ Batch Correction 보정)로, 주간/월간 랭킹은 DB 원장 기반 MV 배치로 각각 담당한다. **두 경로의 목적은 같은 데이터로 다른 관점을 제공하는 것이다.** Redis 경로는 지수 감쇠로 "지금 뜨는 상품"을, MV 배치 경로는 균등 합산으로 "기간 동안에 인기가 누적된 상품"을 각각 담당하는 셈이다. 두 경로를 서로 다르게 설계한 의도가 실제로 동작하는지를 테스트를 통해서 확인했다. + +이번 설계를 고민하면서 다른 사례들을 찾아보는 것이 흥미로웠다. 이 글에서 다룬 "파티셔닝 → 병렬 집계 → merge"라는 Map-Reduce 패턴은 규모가 다른 시스템에서도 반복적으로 등장한다: + +- [**Netflix Distributed Counter**](https://netflixtechblog.com/netflixs-distributed-counter-abstraction-8d0c45eb66b2): 시간 기반 파티셔닝 + Rollup 병렬 집계 → merge. *"A background rollup process continuously aggregates these events using time-based windows, storing intermediate counts in a persistent store."* 75K RPS, single-digit ms 레이턴시를 이 구조로 달성한다. 이 프로젝트에 적용한 3-Step(Cleanup → Partitioned Aggregate → Merge)과 동일한 구조이다. + +- [**Shopify BFCM Live Map**](https://shopify.engineering/bfcm-live-map-2021-apache-flink-redesign): 텀블링 윈도우 5분 간격 TOP 500 집계. *"Redis would quickly become a bottleneck due to the increase in the number of published messages and subscribers."* BFCM 피크 **초당 100만 체크아웃 이벤트**를 처리하면서 Redis 병목을 Flink로 해소했다. "실시간 경로의 한계를 배치/스트림 집계로 보완한다"는 점에서 실시간 + 배치 이중 경로를 설계한 것과 같은 맥락을 보여준다. + +"소프트웨어는 수학이 아니라서 정답이 없다." 이 점이 설계하는데 있어서 가장 어려웠다. 왜냐면 "정답을 찾는 것"이 아니라 **"선택한/선택안한 근거를 납득할 수 있게 정리하는 것"**이 필요했기 때문이다. 일례로 균등 합산을 선택하면서 지수 감쇠의 장점을 이해했고, 전체 재계산을 선택하면서 증분의 효율성을 인정했고, Chunk를 선택하면서 Tasklet의 단순함을 인정하는 과정이 설계 판단이었다. 어느 쪽이 "더 좋다"가 아니라 **"지금 상황에서 왜 이쪽인가"**를 설명하고 싶어서 고민하는 게 즐거웠다. diff --git a/docs/design/volume-9/09-event-review.md b/docs/design/volume-9/09-event-review.md new file mode 100644 index 0000000000..c4649849b3 --- /dev/null +++ b/docs/design/volume-9/09-event-review.md @@ -0,0 +1,858 @@ +# 이벤트 파이프라인 리뷰 — 시니어 아키텍트 관점 + +--- + +## 0. 과제 범위 요약 + +| Step | 주제 | 핵심 | +|---|---|---| +| Step 1 | ApplicationEvent로 경계 나누기 | 핵심 로직 vs 부가 로직 판단 + 트랜잭션 분리 | +| Step 2 | Kafka 이벤트 파이프라인 | Outbox → Kafka → commerce-streamer, product_metrics 집계, 멱등 처리 | +| Step 3 | 선착순 쿠폰 발급 | API → Kafka 발행만 → Consumer 순차 처리, 수량 제한 동시성 제어 | + +--- + +## 1. 현재 코드베이스 분석 + +### 1.1 현재 인프라 상태 + +| 구성 요소 | 상태 | 비고 | +|---|---|---| +| commerce-api | Kafka 미사용 | 모든 흐름 동기 처리, kafka.yml 미임포트 | +| commerce-streamer | DemoKafkaConsumer 1개 | demo.internal.topic-v1 소비만 | +| modules/kafka | 설정 완료 | KafkaTemplate, BATCH_LISTENER (manual ack, concurrency 3, max poll 3000) | +| Docker Kafka | KRaft 모드 | 단일 브로커, port 19092 (외부), 토픽 자동 생성 비활성화 | + +### 1.2 현재 주문 흐름 (`OrderFacade.createOrder`) + +``` +[단일 TX — @Transactional] + 1. 상품 비관적 락 (deadlock 방지 위해 ID 정렬) + 2. 브랜드 조회 (N+1 방지) + 3. 스냅샷 생성 (OrderItem) + 4. 재고 차감 (Product.decreaseStock) + 5. 쿠폰 적용 (CouponFacade.applyCouponToOrder — CAS UPDATE) + 6. 주문 저장 (Order.create) + 7. 쿠폰-주문 연결 (CouponIssue.linkOrder) +[TX commit] +``` + +**문제점:** +- 부가 로직(유저 행동 로깅, 판매량 집계, 알림)이 존재하지 않지만, 추가된다면 TX 안에 들어갈 구조 +- 쿠폰 적용은 가격 계산에 직접 영향 → 핵심 로직 (분리 불가) + +### 1.3 현재 좋아요 흐름 (`LikeFacade.addLike`) + +``` +[단일 TX — @Transactional] + 1. 상품 존재 확인 + 2. 중복 좋아요 확인 (existsByMemberIdAndProductId) + 3. Like INSERT + 4. Product.incrementLikeCount (SQL atomic UPDATE) +[TX commit] + +[Controller에서 인라인 처리] + 5. 캐시 무효화 (productCachePort.evictProductDetail + evictProductList) +``` + +**문제점:** +- Like INSERT(핵심)와 likeCount UPDATE(부가/집계)가 같은 TX +- 집계 실패 시 좋아요 자체도 롤백됨 +- 캐시 무효화가 Controller에 인라인 — 관심사 분리 안 됨 + +### 1.4 현재 좋아요 집계 구조 + +``` +product_like_stats 테이블: + product_id (PK), like_count, synced_at + +LikeCountSyncTasklet (commerce-batch): + 1단계: likes COUNT(*) GROUP BY product_id → REPLACE INTO product_like_stats + 2단계: product_like_stats.like_count → Product.like_count 드리프트 보정 + +역할: Product.like_count의 정합성 안전망 (incrementLikeCount 누락 시 보정) +``` + +### 1.5 현재 상품 조회 흐름 + +``` +ProductFacade.getProductDetailCached(): + L1(Caffeine) → L2(Redis) → DB → 캐시 저장 + +조회수 추적: 없음 (7주차에서 신규 추가) +``` + +### 1.6 현재 쿠폰 구조 + +``` +Coupon: name, discountType, discountValue, minOrderAmount, expiredAt +CouponIssue: couponId, memberId, status(AVAILABLE/USED/EXPIRED), expiredAt + +수량 제한: 없음 → 7주차에서 선착순 수량 제한 추가 필요 +중복 발급 방지: 없음 (같은 쿠폰을 같은 유저가 여러 번 발급 가능) +``` + +--- + +## 2. Step 1 분석 — 핵심 vs 부가 로직 판단 기준 + +### 2.1 판단 프레임워크 + +``` +핵심 로직 = "이것이 실패하면 사용자 요청 자체가 실패해야 하는가?" + → YES: 핵심 TX 안에 유지 + → NO: 이벤트로 분리 가능 + +부가 로직 = "이것이 실패해도 사용자에게는 성공으로 보여야 하는가?" + → YES: 이벤트 분리 (eventual consistency) +``` + +### 2.2 주문 플로우 — 핵심 vs 부가 + +| 처리 | 핵심/부가 | 판단 근거 | 이벤트 분리 | +|---|---|---|---| +| 재고 차감 | **핵심** | 재고 없으면 주문 불가. 즉시 검증 필요 | X | +| 쿠폰 적용 | **핵심** | 할인 금액이 totalPrice 계산에 직접 영향 | X | +| 주문 저장 | **핵심** | 주문 자체 | X | +| 유저 행동 로깅 | 부가 | 로깅 실패해도 주문은 성공해야 함 | O | +| 판매량 집계 | 부가 | 집계 실패해도 주문에 영향 없음 | O | +| 주문 알림 | 부가 | 알림 실패해도 주문은 완료 | O | + +``` +분리 후: + [TX] 재고 차감 + 쿠폰 적용 + 주문 저장 → commit + [AFTER_COMMIT] OrderCreatedEvent 발행 + → 유저 행동 로깅 (비동기) + → Outbox 기록 → Kafka → product_metrics.sales_count 집계 +``` + +### 2.3 좋아요 플로우 — 핵심 vs 부가 + +| 처리 | 핵심/부가 | 판단 근거 | 이벤트 분리 | +|---|---|---|---| +| Like INSERT | **핵심** | 사용자 의도 (좋아요 누르기) | X | +| Product.incrementLikeCount | 부가 | "집계 실패와 무관하게 좋아요는 성공" — 과제 요구사항 | O | +| 캐시 무효화 | 부가 | 캐시 무효화 실패해도 좋아요는 성공해야 함 | O | + +``` +분리 후: + [TX] Like INSERT + Outbox 기록 → commit + [AFTER_COMMIT] LikeCreatedEvent 발행 + → Product.incrementLikeCount (best-effort, 같은 스레드) + → 캐시 무효화 + → Outbox → Kafka → product_metrics.like_count 집계 +``` + +> **incrementLikeCount를 완전히 제거하지 않는 이유:** +> 사용자가 좋아요 직후 목록을 새로고침하면 반영되어 있기를 기대한다. +> AFTER_COMMIT에서 best-effort로 실행하되, 실패해도 Like 자체는 이미 저장됨. +> product_metrics + 배치가 최종 정합성을 보장하는 안전망 역할. + +### 2.4 상품 조회 플로우 — 조회수 추적 + +| 처리 | 핵심/부가 | 판단 근거 | 이벤트 분리 | +|---|---|---|---| +| 상품 데이터 반환 | **핵심** | 사용자 요청 목적 | X | +| 조회수 기록 | 부가 | 조회수 기록 실패해도 상품은 보여야 함 | O | + +``` +분리 후: + [TX 없음 — 읽기] 상품 조회 + 캐시 + [이벤트] ProductViewedEvent 발행 (조회수 로깅) + → Outbox 기록 → Kafka → product_metrics.view_count 집계 +``` + +> **조회 이벤트는 Outbox를 경유할 필요가 있는가?** +> 조회는 DB 쓰기가 없으므로 Outbox TX에 묶을 수 없다. +> 선택지: +> A. 조회 시 별도 TX로 Outbox INSERT → 오버헤드 +> B. ApplicationEvent → 직접 Kafka 발행 (fire-and-forget) → 유실 가능 +> C. ApplicationEvent → Redis 버퍼 → 배치로 Kafka 발행 +> +> 조회수는 정확성보다 근사치가 중요. 일부 유실 허용 가능. +> → B 방식 (직접 Kafka 발행) 또는 메모리 버퍼 후 배치 발행이 실용적. +> → 08 설계에서 최종 결정. + +### 2.5 주문 취소 플로우 + +| 처리 | 핵심/부가 | 이벤트 분리 | +|---|---|---| +| Order.cancel() | **핵심** | X | +| 재고 복원 | **핵심** | X (재고 복원 실패 시 데이터 불일치) | +| 쿠폰 복원 | **핵심** | X (쿠폰 복원 실패 시 고객 손해) | +| 유저 행동 로깅 | 부가 | O | +| 판매량 차감 집계 | 부가 | O | + +### 2.6 @TransactionalEventListener phase 선택 기준 + +| phase | 실행 시점 | 적합한 용도 | +|---|---|---| +| BEFORE_COMMIT | TX 커밋 직전 | TX 안에서 추가 검증/기록이 필요할 때 | +| **AFTER_COMMIT** | TX 커밋 성공 후 | **부가 로직 (집계, 로깅, 알림, Kafka 발행)** | +| AFTER_ROLLBACK | TX 롤백 후 | 롤백 시 보상 작업 | +| AFTER_COMPLETION | TX 완료 후 (성공/실패 무관) | 리소스 정리 | + +**이 프로젝트에서는 AFTER_COMMIT이 기본.** +핵심 TX 성공 후에만 부가 로직을 실행해야 하므로. + +### 2.7 @Async 적용 판단 + +``` +@TransactionalEventListener(AFTER_COMMIT)만 쓰면: + → 같은 스레드에서 실행 + → 이벤트 리스너 완료까지 HTTP 응답 지연 + +@TransactionalEventListener(AFTER_COMMIT) + @Async: + → 별도 스레드에서 실행 + → HTTP 응답 즉시 반환 + → 단, 실패 시 사용자에게 노출 안 됨 (예외 은닉) + +판단: + - 유저 행동 로깅, Kafka 발행 → @Async (응답 지연 불필요) + - incrementLikeCount → 동기 (즉시 반영 UX, 단 실패해도 Like는 저장됨) + - 캐시 무효화 → 동기 (다음 조회 시 최신 데이터 보장) +``` + +--- + +## 3. Step 2 분석 — Kafka 이벤트 파이프라인 + +### 3.1 ApplicationEvent vs Kafka 경계 판단 + +``` +ApplicationEvent = 이 JVM 안에서 후속 처리를 트리거 + → 메모리 기반, 보존 없음, JVM 재시작 시 유실 + → 빠름, 의존성 없음 + +Kafka = 시스템 경계를 넘는 이벤트 전달 + → 디스크 보존, 재처리 가능, At Least Once + → 네트워크 I/O, 상대적 느림 + +판단 기준: + "이 이벤트가 다른 애플리케이션(commerce-streamer)에서 처리되어야 하는가?" + → YES: Kafka + → NO: ApplicationEvent만으로 충분 +``` + +| 이벤트 | ApplicationEvent | Kafka | 근거 | +|---|---|---|---| +| incrementLikeCount | O | X | 같은 JVM, 즉시 반영, DB UPDATE 1건 | +| 캐시 무효화 | O | X | 같은 JVM, Redis eviction | +| 유저 행동 로깅 | O | O | 내부 로깅 + 외부 데이터 파이프라인 | +| product_metrics 집계 | X | **O** | commerce-streamer에서 처리 | +| 선착순 쿠폰 발급 | X | **O** | commerce-streamer에서 처리 | + +### 3.2 Outbox와 ApplicationEvent의 역할 분리 + +``` +두 가지는 동시에 사용한다. 역할이 다르다. + +[TX 시작] + Like INSERT + Outbox INSERT (eventType: LIKE_CREATED, payload: {productId, memberId, ...}) +[TX commit] + +[AFTER_COMMIT — ApplicationEvent] + → incrementLikeCount (best-effort, 동기) + → 캐시 무효화 (동기) + → 유저 행동 로깅 (@Async) + +[Outbox Poller — 별도 스케줄러] + → Outbox PENDING 조회 → Kafka 발행 → Outbox PROCESSED + +ApplicationEvent가 하는 것: 즉시 반영이 필요한 내부 후속 처리 +Outbox가 하는 것: 시스템 경계를 넘는 이벤트의 보장 발행 +``` + +### 3.3 Outbox → Kafka 발행 흐름 + +``` +[commerce-api] + + 도메인 TX: + [TX] 도메인 데이터 변경 + event_outbox INSERT → commit + + Outbox Poller (@Scheduled, 5초): + 1. SELECT * FROM event_outbox WHERE status = 'PENDING' ORDER BY id LIMIT 100 + 2. 각 건에 대해 Kafka 발행 (KafkaTemplate.send()) + 3. 발행 성공 → UPDATE status = 'PROCESSED' + 4. 발행 실패 → retry_count++, 최대 초과 시 FAILED + 운영 알림 + + event_outbox 테이블: + id, aggregate_type, aggregate_id, event_type, payload(JSON), + status(PENDING/PROCESSED/FAILED), created_at, processed_at, retry_count +``` + +### 3.4 토픽 설계 + +| 토픽 | Key | 이벤트 유형 | Producer | Consumer | +|---|---|---|---|---| +| `catalog-events` | productId | PRODUCT_VIEWED, LIKE_CREATED, LIKE_REMOVED | commerce-api | commerce-streamer | +| `order-events` | orderId | ORDER_CREATED, ORDER_CANCELLED | commerce-api | commerce-streamer | +| `coupon-issue-requests` | couponId | COUPON_ISSUE_REQUESTED | commerce-api | commerce-streamer | + +**Key 설계 근거:** +- catalog-events key=productId → 같은 상품의 이벤트는 같은 파티션 → 순서 보장 +- order-events key=orderId → 같은 주문의 이벤트는 같은 파티션 +- coupon-issue-requests key=couponId → 같은 쿠폰의 발급 요청은 같은 파티션 → 순차 처리로 수량 제어 + +### 3.5 Consumer (commerce-streamer) 설계 + +``` +[commerce-streamer] + + catalog-events Consumer: + → PRODUCT_VIEWED: product_metrics.view_count += 1 + → LIKE_CREATED: product_metrics.like_count += 1 + → LIKE_REMOVED: product_metrics.like_count -= 1 + + order-events Consumer: + → ORDER_CREATED: product_metrics.sales_count += item.quantity (상품별) + → ORDER_CANCELLED: product_metrics.sales_count -= item.quantity + + coupon-issue-requests Consumer: + → COUPON_ISSUE_REQUESTED: 수량 확인 → 발급 or 거절 + + 공통: + - manual Ack (AckMode.MANUAL) + - event_handled 테이블로 멱등 처리 + - version/updated_at 기준 최신 이벤트만 반영 +``` + +### 3.6 product_metrics 테이블 설계 + +```sql +CREATE TABLE product_metrics ( + product_id BIGINT PRIMARY KEY, + like_count BIGINT NOT NULL DEFAULT 0, + view_count BIGINT NOT NULL DEFAULT 0, + sales_count BIGINT NOT NULL DEFAULT 0, + sales_amount BIGINT NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +); +``` + +**기존 product_like_stats와의 관계:** +- product_like_stats는 product_metrics로 흡수 (역할 확장) +- like_count + view_count + sales_count + sales_amount 통합 관리 +- LikeCountSyncTasklet → MetricsReconcileTasklet로 진화 + +**Product.like_count 컬럼은 유지:** +- 정렬 인덱스(idx_product_like_count)가 이 컬럼 기준 +- 제거하면 좋아요순 정렬 성능 하락 +- 비정규화 캐시로 유지, 배치가 product_metrics 기준으로 보정 + +### 3.7 멱등 처리 설계 + +```sql +CREATE TABLE event_handled ( + event_id VARCHAR(100) PRIMARY KEY, + handled_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +``` + +**왜 event_handled와 event_log를 분리하는가?** + +| 구분 | event_handled | event_log (별도 설계 시) | +|---|---|---| +| 목적 | 멱등성 보장 (중복 체크) | 감사/분석/디버깅 | +| 데이터 | event_id만 (최소) | 전체 페이로드 | +| 조회 패턴 | PK lookup (O(1)) | 범위 검색, 필터링 | +| 수명 | 짧음 (7~30일이면 충분) | 장기 보존 (규제, 감사) | +| 크기 | 작음 (ID만) | 큼 (전체 이벤트 데이터) | + +분리하면: +- event_handled는 작고 빨라서 PK lookup이 O(1) 유지 +- event_log가 커져도 멱등 체크 성능에 영향 없음 +- 각각 독립적인 보존 정책 적용 가능 + +### 3.8 Producer 설정 + +```yaml +# acks=all: 모든 ISR에 기록 확인 후 응답 → 메시지 유실 방지 +# enable.idempotence=true: 중복 발행 방지 (Producer 레벨) +spring: + kafka: + producer: + acks: all + properties: + enable.idempotence: true + max.in.flight.requests.per.connection: 5 +``` + +### 3.9 Consumer 설정 + +```yaml +# enable-auto-commit: false → manual Ack +# auto-offset-reset: latest → 신규 Consumer는 최신 메시지부터 +spring: + kafka: + consumer: + enable-auto-commit: false + auto-offset-reset: latest + listener: + ack-mode: manual +``` + +### 3.10 조회 이벤트의 Outbox 경유 여부 + +``` +문제: + 상품 조회 = 읽기 전용 (DB 쓰기 없음) + → Outbox INSERT를 위한 별도 TX가 필요 + → 조회마다 DB 쓰기 1건 추가 = 오버헤드 + +선택지: + A. 별도 TX로 Outbox INSERT → 정확하지만 오버헤드 + B. 직접 Kafka 발행 (fire-and-forget) → 일부 유실 허용 + C. 메모리 버퍼 → 주기적 Kafka 발행 → 버퍼 유실 가능 (JVM 재시작) + D. Kafka 직접 발행 + 실패 시 로그 → 실용적 + +결정: D +근거: + - 조회수는 정확성보다 추세가 중요 (±수 건 허용) + - 조회마다 DB 쓰기를 추가하면 조회 TPS에 영향 + - KafkaTemplate.send()는 내부적으로 배치 + 버퍼링 (효율적) + - 발행 실패 시 에러 로그만 남기고, 배치로 보정하지 않음 +``` + +--- + +## 4. Step 3 분석 — 선착순 쿠폰 발급 + +### 4.1 현재 쿠폰 모델의 한계 + +``` +현재: + CouponFacade.issueCoupon(couponId, memberId) + → Coupon 조회 → 만료 확인 → CouponIssue 생성 + +부족한 것: + 1. 수량 제한 없음 (maxIssuanceCount) + 2. 중복 발급 방지 없음 (같은 쿠폰 + 같은 유저) + 3. 동기 처리 → 1만 명 동시 요청 시 DB 부하 +``` + +### 4.2 Kafka 기반 구조 + +``` +[사용자] → POST /api/v1/coupons/{couponId}/issue-request + → [commerce-api] + 1. 기본 검증 (쿠폰 존재, 만료 여부) + 2. coupon_issue_request 테이블에 PENDING 상태로 기록 + 3. Kafka에 COUPON_ISSUE_REQUESTED 발행 (key=couponId) + 4. 즉시 응답: { requestId, status: PENDING } + + → [Kafka] coupon-issue-requests 토픽 + + → [commerce-streamer] + 1. event_handled 확인 (멱등) + 2. Coupon.issuedCount 확인 (수량 초과?) + 3. 중복 발급 확인 (couponId + memberId) + 4. CouponIssue 생성 + Coupon.issuedCount++ (CAS UPDATE) + 5. coupon_issue_request 상태 업데이트 (COMPLETED / REJECTED) + +[사용자] → GET /api/v1/coupons/issue-requests/{requestId} + → 결과 조회 (PENDING / COMPLETED / REJECTED) +``` + +### 4.3 동시성 제어 — Kafka만으로는 불충분 + +``` +오해: "key=couponId → 같은 파티션 → 순차 소비 → 동시성 해결" + +현실: + 1. Consumer 장애 → Rebalancing → 메시지 재처리 (At Least Once) + → 같은 요청이 2번 처리될 수 있음 + 2. Consumer Group 내 파티션 재할당 중 중복 소비 가능 + 3. 배치 리스너 (현재 설정: 3000건/poll) → 배치 내에서는 순차이지만 + 동일 couponId의 여러 요청이 같은 배치에 포함될 수 있음 + +결론: + Kafka는 "부하 버퍼 + 순서 힌트"이지 "동시성 제어 수단"이 아님. + DB 레벨 동시성 제어가 반드시 필요. +``` + +### 4.4 DB 레벨 동시성 제어 + +```sql +-- 1. 수량 제한: CAS UPDATE (Compare-And-Swap) +UPDATE coupon +SET issued_count = issued_count + 1 +WHERE id = :couponId + AND issued_count < max_issuance_count + AND deleted_at IS NULL; +-- affected rows = 0 → 수량 소진 + +-- 2. 중복 발급 방지: UNIQUE 제약 +ALTER TABLE coupon_issue +ADD UNIQUE INDEX uk_coupon_issue_coupon_member (coupon_id, member_id); +-- INSERT 시 중복이면 예외 → 거절 +``` + +### 4.5 Coupon 모델 확장 + +``` +Coupon 테이블 추가 컬럼: + max_issuance_count INT -- NULL이면 무제한 + issued_count INT DEFAULT 0 -- 현재 발급 수 + +coupon_issue_request 테이블 (신규): + id BIGINT PK + coupon_id BIGINT NOT NULL + member_id BIGINT NOT NULL + status VARCHAR(20) -- PENDING / COMPLETED / REJECTED + reject_reason VARCHAR(100) + created_at DATETIME + completed_at DATETIME +``` + +### 4.6 Redis vs Kafka 선착순 처리 비교 + +``` +Redis 방식: + INCR coupon:{id}:count → 100 이하면 발급 + → 장점: 초고속 (O(1)), 원자적 카운트 + → 단점: Redis 장애 시 발급 불가, 영속성 약함 + +Kafka 방식: + API → Kafka → Consumer 순차 처리 → DB CAS UPDATE + → 장점: 부하 버퍼, 영속성 (디스크), 재처리 가능 + → 단점: Redis보다 느림 (ms vs ns), 순서 보장이 파티션 단위 + +이 프로젝트 선택: Kafka (과제 요구사항) + - 단, DB CAS UPDATE로 정확한 수량 제어 + - Kafka는 "폭주 요청 버퍼링" 역할 +``` + +### 4.7 발급 결과 확인 구조 + +``` +선택지: + A. Polling — GET /coupon-issue-requests/{requestId} + B. SSE (Server-Sent Events) + C. WebSocket + +결정: A (Polling) +근거: + - 구현 단순, 인프라 추가 불필요 + - 쿠폰 발급은 수 초 내 완료 → 1~2회 polling이면 충분 + - SSE/WebSocket은 커넥션 유지 오버헤드 +``` + +--- + +## 5. 아키텍트 점검 — 리스크 분석 + +### 5.1 AFTER_COMMIT 이벤트 실패 시 대응 + +``` +리스크: + AFTER_COMMIT에서 incrementLikeCount 실패 + → Like는 저장됨, likeCount는 업데이트 안 됨 → 불일치 + +대응: + 1. try-catch + 에러 로깅 (예외 전파 방지) + 2. product_metrics (Kafka 경유)가 정확한 집계값 보유 + 3. MetricsReconcileTasklet이 주기적으로 Product.like_count 보정 + → 3중 안전망: best-effort 즉시 반영 + Kafka 집계 + 배치 대사 +``` + +### 5.2 Outbox Poller 실패 시 + +``` +리스크: + Outbox Poller가 Kafka 발행 실패 → PENDING 상태 유지 + +대응: + - Poller가 재시도 (retry_count++) + - 최대 재시도 초과 시 FAILED + 운영 알림 + - Consumer 측 멱등 처리로 중복 발행 안전 +``` + +### 5.3 Consumer 처리 실패 시 + +``` +리스크: + commerce-streamer가 이벤트 처리 실패 + → manual Ack를 하지 않으면 Kafka가 재전달 + +대응: + - 재시도 가능: 멱등 처리로 안전 + - 반복 실패: DLQ (Dead Letter Queue)로 격리 + - DLQ 처리: 운영자 수동 확인 또는 별도 Consumer +``` + +### 5.4 product_metrics 정합성 + +``` +리스크: + Kafka 이벤트 유실/순서 역전 → product_metrics 부정확 + +대응: + - At Least Once + 멱등 처리 → 유실 방지 + - MetricsReconcileTasklet → 원본 데이터(likes, order_items) 기준 대사 + - product_metrics는 "실시간 근사치", 배치가 "정확한 값" 보정 +``` + +### 5.5 event_outbox vs payment_outbox + +``` +6주차에서 PaymentOutbox를 설계했다. +7주차에서 event_outbox를 추가한다. + +이 둘은 다른 테이블인가, 같은 테이블인가? + +분석: + PaymentOutbox: PG 호출 보장용 (event_type: PAYMENT_REQUEST) + event_outbox: Kafka 발행 보장용 (event_type: LIKE_CREATED, ORDER_CREATED, ...) + + 목적이 다르다: + PaymentOutbox → PG API 호출 재시도 + event_outbox → Kafka 메시지 발행 재시도 + + 처리 주체도 다르다: + PaymentOutbox → Outbox Poller가 PG 호출 + event_outbox → Outbox Poller가 Kafka 발행 + +결정: 별도 테이블로 분리 +근거: + - 단일 테이블에 두 가지 목적을 혼합하면 Poller 로직이 복잡해짐 + - PaymentOutbox는 PG 호출 + 상태 확인 로직 포함 (Kafka와 완전히 다름) + - 각각 독립적인 Poller, 독립적인 retry 정책 적용 가능 +``` + +--- + +## 6. Consumer Group 분리 (Nice-To-Have) + +### 6.1 현재 단일 Consumer Group + +``` +commerce-streamer (Consumer Group: loopers-default-consumer) + → catalog-events 소비 → product_metrics upsert + → order-events 소비 → product_metrics upsert + → coupon-issue-requests 소비 → 쿠폰 발급 +``` + +### 6.2 관심사별 Consumer Group 분리 + +``` +Consumer Group: metrics-collector + → catalog-events → product_metrics upsert (like, view) + → order-events → product_metrics upsert (sales) + +Consumer Group: coupon-issuer + → coupon-issue-requests → 선착순 쿠폰 발급 + +이점: + - 쿠폰 발급 실패가 metrics 집계에 영향 안 줌 + - 각 Consumer Group 독립 스케일링 가능 + - 장애 격리 +``` + +--- + +## 7. DLQ 구성 (Nice-To-Have) + +### 7.1 DLQ 설계 + +``` +반복 실패 메시지를 격리하여 정상 메시지 처리를 방해하지 않음. + +원본 토픽: catalog-events +DLQ 토픽: catalog-events.DLT (Dead Letter Topic) + +동작: + Consumer가 메시지 처리 3회 실패 + → DLQ 토픽으로 이동 + → 운영 알림 + → 수동 확인 후 재처리 or 폐기 +``` + +### 7.2 Spring Kafka DLQ 설정 + +```java +@Bean +public DefaultErrorHandler errorHandler(KafkaTemplate kafkaTemplate) { + DeadLetterPublishingRecoverer recoverer = + new DeadLetterPublishingRecoverer(kafkaTemplate); + return new DefaultErrorHandler(recoverer, new FixedBackOff(1000L, 3)); +} +``` + +--- + +## 8. 고도화 분석 — Outbox Poller 중복 처리 + +### 8.1 문제 + +다중 인스턴스에서 Outbox Poller가 같은 PENDING 행을 동시에 SELECT → 같은 이벤트를 Kafka에 2번 발행. + +### 8.2 선택지 분석 + +| 선택지 | 설명 | 문제 | +|---|---|---| +| SELECT FOR UPDATE SKIP LOCKED | 행 잠금, 잠긴 건 건너뜀 | **DB 커넥션 점유** — Kafka 장애 시 잠긴 행 재시도 불가 | +| Debezium CDC | binlog에서 직접 Kafka 발행 | 인프라 추가 (Kafka Connect) | +| 중복 허용 + 멱등 Consumer | 잠금 없이 SELECT → Consumer가 event_handled로 중복 제거 | Consumer PK lookup 1회 (~0.1ms) | + +### 8.3 결정: Debezium CDC + +- **근거 1**: 실무 적용 전 Debezium 설정 경험 확보에 의의 +- **근거 2**: 중복 발행 원천 해결 (binlog 오프셋 기반, 단일 처리) +- **근거 3**: Near real-time (Poller 5초 → Debezium 수백 ms) +- **근거 4**: DB 부하 없음 (SELECT 폴링 제거) + +### 8.4 Debezium 구성 + +``` +Docker 추가: + - kafka-connect (debezium/connect:2.5) + - MySQL binlog 활성화 (--log-bin, --binlog-format=ROW) + +Connector: + - Debezium MySQL Connector + - Outbox Event Router SMT + - route.by.field=aggregate_type → 토픽 라우팅 +``` + +### 8.5 Debezium 도입으로 달라지는 점 + +1. event_outbox에 status 컬럼 불필요 (PENDING/PROCESSED 구분 없음) +2. OutboxPollerScheduler 불필요 (Debezium이 대체) +3. 테이블 정리: 1시간 보존 후 단순 DELETE (최대 6.25만 건 → ~1초) +4. PaymentOutbox는 기존 Poller 유지 (PG 호출 전용, Kafka 발행이 아님) + +--- + +## 9. 고도화 분석 — Outbox 테이블 정리 전략 + +### 9.1 규모 산정 (쿠팡급 기준) + +``` +좋아요: 일 100만 건, 주문: 일 50만 건, 조회: Outbox 미경유 +→ event_outbox: 일 150만 건 (행당 ~500 bytes) +→ Debezium 도입 → 1시간 보존 기준 최대 6.25만 건 +``` + +### 9.2 선택지 비교 + +| 방법 | 정리 속도 | JPA 호환 | 복잡도 | 대규모 적합 | +|---|---|---|---|---| +| Batch DELETE | 소량 시 빠름 | O | 낮음 | Debezium 시 O | +| 라운드 로빈 | TRUNCATE O(1) | **X** (Native SQL) | 높음 | O | +| PARTITION DROP | O(1) | O | 중간 | O | +| Debezium + DELETE | 소량 DELETE | O | **낮음** | **O** | + +### 9.3 결정: Debezium + 단순 Batch DELETE + +Debezium이 binlog에서 읽으므로 테이블 누적이 발생하지 않음. +1시간 보존 후 DELETE → 최대 6.25만 건 → 부담 없음. + +라운드 로빈 미채택 이유: JPA Entity의 @Table(name) 고정 → Native Query 강제 → DIP 위반. +파티셔닝 미채택 이유: Debezium 덕에 테이블이 작게 유지 → 파티셔닝은 과도한 최적화. + +--- + +## 10. 고도화 분석 — Kafka와 아키텍처 관계 + +### 10.1 프로젝트 아키텍처 명명 + +현재 구조는 "멀티 프로세스 모듈러 아키텍처" — 3개 JVM, 공유 DB, 공유 레포. +모놀리스(단일 JVM)도 아니고, MSA(서비스별 DB)도 아님. + +### 10.2 Kafka 적용 지점 + +| # | 토픽 | Producer | Consumer | 목적 | +|---|---|---|---|---| +| 1 | catalog-events | commerce-api | commerce-streamer | 좋아요/조회수 → product_metrics | +| 2 | order-events | commerce-api | commerce-streamer | 판매량 → product_metrics | +| 3 | coupon-issue-requests | commerce-api | commerce-streamer | 선착순 쿠폰 버퍼링 | + +### 10.3 MSA 전환 불필요 + +- Kafka는 MSA 전용이 아닌 "프로세스 간 비동기 통신 인프라" +- 현재 멀티 프로세스에서 충분히 유효 +- MSA 전환 트리거: 팀 분리, 극단적 스케일 차이, 기술 스택 분리, 물리적 장애 격리 +- 현재 해당 없음 + +### 10.4 commerce-api에 modules:kafka 의존성 추가 + +Outbox INSERT는 commerce-api에서 발생 → Debezium이 발행하므로 KafkaTemplate 불필요. +단, 조회수는 Outbox 미경유 → 직접 Kafka 발행 → **KafkaTemplate 필요**. +→ commerce-api에 `implementation(project(":modules:kafka"))` 추가. + +--- + +## 11. 고도화 분석 — Kafka 설정 점검 + +### 11.1 발견된 문제점 + +| # | 문제 | 위치 | 심각도 | +|---|---|---|---| +| 1 | Consumer `value-serializer` → `value-deserializer` 오타 | kafka.yml:21 | 경미 (Converter가 대체) | +| 2 | Producer acks/idempotence 미설정 | kafka.yml:14-17 | **중요** (메시지 유실 가능) | +| 3 | auto.offset.reset 위치 (글로벌 → Consumer 전용) | kafka.yml:12 | 경미 | +| 4 | 단건 처리용 Consumer Factory 부재 | KafkaConfig.java | **중요** (쿠폰 발급용) | +| 5 | Error Handler / DLQ 미설정 | KafkaConfig.java | **중요** | +| 6 | 토픽 생성 전략 없음 | N/A | 중간 | + +### 11.2 보완 사항 → 08 반영 + +- Producer: acks=all, enable.idempotence=true, linger.ms=50, batch.size=32KB +- Consumer: value-deserializer 수정, SINGLE_LISTENER 추가 +- Error Handler: DefaultErrorHandler + DeadLetterPublishingRecoverer +- 토픽: @Bean NewTopic으로 선언적 생성 + +--- + +## 12. 고도화 분석 — Redis 설정 점검 + +### 12.1 현재 상태 + +- Master-Replica 구성 완료 (ReadFrom.REPLICA_PREFERRED) +- Lettuce NIO multiplexing (커넥션 풀 불필요) +- StringRedisSerializer (적절) + +### 12.2 보완 필요: 커맨드 타임아웃 + +현재: 타임아웃 미설정 → Redis 장애 시 스레드 무한 대기 가능. +보완: commandTimeout(Duration.ofMillis(500)) 추가. +근거: 정상 응답 ~1ms, 500ms 초과 = 장애 판단. + +--- + +## 13. 고도화 분석 — @Async 스레드 풀 + +### 13.1 @Async 작업 분류 + +| 작업 | @Async 여부 | DB | Redis | Kafka | +|---|---|---|---|---| +| incrementLikeCount | 동기 (Tomcat) | O | X | X | +| 캐시 무효화 | 동기 (Tomcat) | X | O | X | +| 유저 행동 로깅 | **@Async** | X | X | X | +| 조회수 Kafka 발행 | **@Async** | X | X | 논블로킹 | + +### 13.2 결정: core=2, max=4 + +@Async 작업은 DB/Redis 커넥션 불사용 → 초경량. +HikariCP(max 40)과 경합 없음. 큰 풀은 컨텍스트 스위칭만 유발. +CallerRunsPolicy로 큐 초과 시 배압. + +--- + +## 14. → 08 반영 사항 (설계 명세 반영 대기) + +| 반영 대상 | 내용 | +|---|---| +| Step 1 | ApplicationEvent 분리 대상 목록 + 리스너 설계 + @Async 스레드 풀 | +| Step 2 — Debezium | event_outbox DDL, Debezium Connector 설정, Kafka Connect Docker | +| Step 2 — Kafka | Producer 보완 (acks, idempotence), SINGLE_LISTENER, Error Handler, 토픽 선언 | +| Step 2 — Redis | commandTimeout 추가 | +| Step 2 — 집계 | product_metrics DDL, Consumer 설계 | +| Step 3 | Coupon 모델 확장, coupon_issue_request DDL, 동시성 제어 설계 | +| 의존성 | commerce-api에 modules:kafka 추가 | +| 패키지 구조 | commerce-api 이벤트 패키지, commerce-streamer Consumer 패키지 | +| 테이블 정리 | Debezium + 1시간 보존 + Batch DELETE | +| 테스트 | Phase별 테스트 전략 | diff --git a/docs/design/volume-9/09-ranking-system-design.md b/docs/design/volume-9/09-ranking-system-design.md new file mode 100644 index 0000000000..84f446c47f --- /dev/null +++ b/docs/design/volume-9/09-ranking-system-design.md @@ -0,0 +1,2436 @@ +# 09. Redis ZSET 기반 실시간 랭킹 시스템 — 구현 설계 + +--- + +## 1. 목적 + +유저에게 "지금 인기 있는 상품"을 빠르게 노출하는 것이 목표다. + +### 1.1 왜 랭킹인가 + +이커머스에서 랭킹은 단순한 정렬이 아니라 **큐레이션 수단**이다. +홈 메인의 "인기 상품", 카테고리의 "인기순 정렬", 상품 상세의 "현재 N위" 표기 등 +유저의 탐색 비용을 줄이고 구매 전환율을 높이는 핵심 지면에 활용된다. + +### 1.2 RDB 집계의 한계 + +| 문제 | 설명 | +|------|------| +| 성능 | `GROUP BY + ORDER BY`는 데이터가 쌓일수록 느려짐 | +| 부하 | 랭킹은 조회 빈도가 매우 높아 DB 과부하로 직결 | +| 실시간성 | 배치 집계 주기만큼 지연 발생, "지금" 인기 있는 상품을 반영 못 함 | + +### 1.3 해결 — Redis ZSET 기반 실시간 랭킹 + +Round 7에서 구축한 Kafka → commerce-streamer 파이프라인이 이미 유저 행동 이벤트(조회, 좋아요, 주문)를 수집하고 있다. +이 파이프라인을 확장하여 **이벤트 소비 시점에 Redis ZSET에 점수를 실시간 반영**하고, +API는 ZSET을 조회해 Top-N 및 개별 순위를 O(log N) 수준으로 제공한다. + +| 요소 | 역할 | +|------|------| +| Kafka + MetricsConsumer | 이벤트 수집 + 집계 (기존 R7 인프라 재활용) | +| Redis ZSET | 점수 기반 정렬 상태 유지, Top-N / 개별 순위 조회 | +| Redis Hash | 상품별 개별 메트릭 저장 (SSOT), score 재계산의 근거 | +| Ranking API | ZSET 조회 → DB 상품 정보 aggregation → 응답 | + +--- + +## 2. 데이터 흐름 + +### 2.1 전체 파이프라인 + +``` +[commerce-api] + 유저 행동 → Kafka 이벤트 발행 + ├── catalog-events (PRODUCT_VIEWED, LIKE_CREATED, LIKE_REMOVED) + └── order-events (ORDER_CREATED, ORDER_CANCELLED) + +[commerce-streamer — MetricsConsumer] + Kafka 배치 소비 (3,000건/poll) + ├── Phase 1: 멱등성 체크 (event_handled INSERT IGNORE) + productId별 메모리 집계 + ├── Phase 2: DB upsert (product_metrics — 전체 누적, 기존) + └── Phase 3: Redis 적재 (ranking — 일간 집계, 신규) + ├── Pipeline 1: HINCRBY × 필드 수 → Hash (개별 메트릭, SSOT) + ├── in-memory: score 계산 (가중치 × 메트릭) + └── Pipeline 2: ZADD → ZSET (랭킹 점수) + +[commerce-api — Ranking API] + ZREVRANGE → productId 목록 → DB IN 쿼리 → 상품 정보 aggregation → 응답 +``` + +### 2.2 MetricsConsumer 확장 vs 별도 Consumer + +| 관점 | MetricsConsumer 확장 | 별도 RankingConsumer | +|------|---------------------|---------------------| +| 이벤트 소비 | 1회 소비로 DB + Redis 모두 처리 | 같은 토픽을 다른 consumer group으로 이중 소비 | +| 멱등성 | event_handled 1회 체크로 공유 | 별도 멱등성 관리 필요 (or 중복 INSERT IGNORE) | +| deltaMap 재활용 | Phase 1 집계 결과를 Phase 3에서 그대로 사용 | 동일한 파싱 + 집계 로직 중복 | +| 장애 격리 | Redis 장애가 DB upsert에 영향 가능 | DB와 Redis 처리가 독립 | +| 운영 복잡도 | consumer group 1개 | consumer group 2개, 오프셋 관리 이중화 | + +**결정: MetricsConsumer 확장**. + +- deltaMap을 Phase 2(DB)와 Phase 3(Redis)가 공유하므로 파싱/집계 중복이 없다 +- 멱등성 체크(event_handled)를 한 번만 수행한다 +- 같은 토픽의 이중 소비로 인한 Kafka 파티션 부하, 오프셋 관리 복잡도를 피한다 + +**장애 격리 대응**: Phase 3(Redis 적재)는 Phase 2(DB upsert) 이후에 실행하고, Redis 장애 시에도 Phase 2까지는 정상 커밋되도록 try-catch로 격리한다. 랭킹은 "최선 노력(best-effort)" 성격이므로, Redis 장애 시 해당 배치의 랭킹 갱신만 유실되는 것은 허용한다. + +### 2.3 SRP 준수 설계 + +MetricsConsumer에 Redis 로직을 직접 작성하면 단일 책임 원칙이 깨진다. +**랭킹 점수 갱신 책임을 별도 컴포넌트로 분리**한다. + +``` +MetricsConsumer (오케스트레이션) + ├── Phase 1: 멱등성 + deltaMap 집계 (기존, MetricsConsumer 자체) + ├── Phase 2: DB upsert (기존, MetricsConsumer 자체) + └── Phase 3: rankingScoreUpdater.update(deltaMap) ← 위임 + └── RankingScoreUpdater (신규 컴포넌트) + ├── Redis Hash HINCRBY (개별 메트릭) + ├── score 계산 (가중치 적용) + └── Redis ZSET ZADD (랭킹 점수) +``` + +- `MetricsConsumer`: 이벤트 소비 + 오케스트레이션 (Phase 흐름 제어) +- `RankingScoreUpdater`: 랭킹 점수 계산 + Redis 적재만 담당 + +이렇게 분리하면 MetricsConsumer는 "이벤트를 소비하고 각 처리기에 위임"하는 역할만 수행하고, +랭킹 로직의 테스트/변경이 Consumer와 독립적으로 가능하다. + +### 2.4 Phase 3 상세 흐름 (RankingScoreUpdater) + +``` +입력: Map deltaMap (Phase 1에서 집계된 productId별 변화량) + +Step 1 — Redis Hash 갱신 (Pipeline) + deltaMap의 각 productId에 대해: + HINCRBY ranking:metrics:{date}:{productId} viewCount {viewDelta} + HINCRBY ranking:metrics:{date}:{productId} likeCount {likeDelta} + HINCRBY ranking:metrics:{date}:{productId} salesCount {salesCountDelta} + HINCRBY ranking:metrics:{date}:{productId} salesAmount {salesAmountDelta} + → HINCRBY 리턴값 = 갱신 후의 필드 값 (HGETALL 불필요) + → productId당 4개 명령, Pipeline 1회로 전송 + +Step 2 — Score 계산 (in-memory) + HINCRBY 리턴값으로 각 productId의 전체 메트릭을 복원: + viewCount, likeCount, salesCount, salesAmount + score = W(view) × log₁₀(viewCount + 1) + W(like) × log₁₀(likeCount + 1) + W(order) × log₁₀(salesAmount + 1) + productId × 1e-10 + (+1: log₁₀(0) = -∞ 방지, productId × 1e-10: 동점 시 신상품 우선) + +Step 3 — Redis ZSET 갱신 (Pipeline) + ZADD ranking:all:{date} {score} {productId} (× productId 수) + → Pipeline 1회로 전송 + +Step 4 — TTL 설정 + 새로 생성된 키에 대해서만 EXPIRE 설정 (2일) +``` + +**성능**: 인기 상품 100개에 집중되는 3,000건 배치 시 +- Pipeline 1: 100 × 4 = 400 HINCRBY → 왕복 1회 +- 계산: 100회 곱셈/덧셈 (마이크로초) +- Pipeline 2: 100 ZADD → 왕복 1회 +- **총 추가 비용: Redis RTT 2회 ≈ 0.2ms** (로컬 기준) + +### 2.5 이중 집계 구조 — 데이터 정합성 전략 + +MetricsConsumer는 같은 이벤트를 **두 저장소에 동시 적재**한다. 이 이중 구조는 의도된 설계 패턴이다. + +``` +이벤트 → MetricsConsumer + ├── Phase 2: product_metrics (MySQL) — 원장 (일별 누적, 정합성 우선) + └── Phase 3: ranking:* (Redis) — 실시간 뷰 (일간 집계, 속도 우선) +``` + +#### 두 저장소의 역할 분리 + +| 관점 | product_metrics (DB) | ranking:all / ranking:metrics (Redis) | +|------|---------------------|--------------------------------------| +| 범위 | 일별 누적 (날짜 파티션) | 일간 집계 (오늘 00:00~23:59) | +| 정합성 | 정확 — 멱등성(event_handled) + 트랜잭션 보장 | 근사치 — best-effort, 부분 유실 허용 | +| 용도 | 일별 트렌드 분석, 주간/월간 배치 집계, Redis 장애 시 재집계 원장 | 실시간 Top-N API, 일간 랭킹 | +| 장애 시 | Redis 장애와 무관하게 정상 커밋 | DB 장애 시 Phase 3도 스킵 (Phase 순서 의존) | + +#### product_metrics 테이블 재설계 + +##### AS-IS 문제점 + +기존 `product_metrics`는 `product_id`를 PK로 전체 기간 누적만 저장한다. + +```sql +-- AS-IS: 시간 축 없는 카운터 테이블 +INSERT INTO product_metrics (product_id, like_count, view_count, sales_count, sales_amount) +VALUES (?, ?, ?, ?, ?) +ON DUPLICATE KEY UPDATE like_count = like_count + VALUES(like_count), ... +``` + +| 문제 | 영향 | +|------|------| +| **시간 축 부재** | 메트릭 테이블의 본질은 "무엇을 + 언제". 시간이 없으면 카운터에 불과 | +| **일별 트렌드 분석 불가** | "지난 7일간 조회수 추이"를 뽑을 수 없다 | +| **Redis 장애 시 일간 재집계 불가** | 오늘 발생한 delta만 추출할 방법이 없다 | +| **취소 이력 소실** | `sales_count = sales_count + (-3)` → 원래 얼마를 팔았고 얼마가 취소됐는지 복원 불가 | +| **데이터 정리(purge) 불가** | 행이 하나뿐이라 오래된 데이터를 삭제할 수 없다 | + +##### TO-BE: 그레인(Grain) = `daily × product` + +메트릭 테이블의 **그레인**은 "한 행이 무엇을 의미하는가"다. PK가 그레인의 물리적 구현이며, 같은 키로 두 행이 들어갈 수 없으므로 그레인 위반을 DB가 강제로 방지한다. + +```sql +CREATE TABLE product_metrics ( + product_id BIGINT NOT NULL, + metric_date DATE NOT NULL, + view_count INT NOT NULL DEFAULT 0, + like_count INT NOT NULL DEFAULT 0, + unlike_count INT NOT NULL DEFAULT 0, + sales_count INT NOT NULL DEFAULT 0, + sales_amount BIGINT NOT NULL DEFAULT 0, + -- 취소: 인식일 기준 (이벤트가 이 날짜에 도착) + cancel_count_by_event_date INT NOT NULL DEFAULT 0, + cancel_amount_by_event_date BIGINT NOT NULL DEFAULT 0, + -- 취소: 발생일 기준 (원주문이 이 날짜에 결제) + cancel_count_by_order_date INT NOT NULL DEFAULT 0, + cancel_amount_by_order_date BIGINT NOT NULL DEFAULT 0, + PRIMARY KEY (product_id, metric_date), + INDEX idx_metric_date (metric_date) +) ENGINE=InnoDB; +``` + +##### 설계 원칙 1 — Additive Measure + 취소 분리 + +메트릭 테이블 설계의 핵심 원칙: **취소/환불은 원본에서 차감하지 않고 별도 컬럼으로 기록한다.** + +``` +AS-IS (차감 방식): + sales_count = 10 → ORDER_CANCELLED 3건 → sales_count = 7 + → 원래 10건이었는지 알 수 없음. "환불 전 매출"이라는 정보가 소실 + +TO-BE (분리 방식): + sales_count = 10, cancel_count_by_event_date = 3 + → 순매출: sales_amount - cancel_amount_by_event_date (조회 시 계산) + → 환불 전 매출: sales_amount 그대로 + → 환불률: cancel_count / sales_count (분자·분모 모두 보존) +``` + +**모든 컬럼이 Additive(양수 누적)**이므로 어떤 차원으로든 `SUM`이 가능하다. 사전 계산된 비율(`avg_order_value`, `cancel_rate` 등)은 **컬럼으로 두지 않는다.** 다중일 합산이 수학적으로 불가능하기 때문이다. 분자와 분모를 각각 저장하고 조회 시점에 나눈다. + +##### 설계 원칙 2 — Late-Arriving Fact 이중 기록 + +취소 이벤트는 원주문과 **다른 날짜에 도착**한다. 이 때 두 가지 질문이 생긴다: + +``` +4월 1일: 상품 101에 주문 10만원 발생 +4월 5일: 그 주문이 취소됨 + +Q1 (운영 관점): "4월 1일의 실제 순매출은?" + → 4월 1일 행의 cancel_amount_by_order_date에 기록되어야 답할 수 있다 + +Q2 (현금흐름 관점): "4월 5일에 발생한 취소 금액은?" + → 4월 5일 행의 cancel_amount_by_event_date에 기록되어야 답할 수 있다 +``` + +두 질문 모두 정당하고 둘 다 답해야 한다. **저장은 풍부하게, 노출은 의견을 갖고.** + +| 컬럼 | 기록 시점 | 대상 행 | 용도 | +|------|----------|---------|------| +| `cancel_count_by_event_date` | 취소 이벤트 도착일 | CURDATE() | "오늘 발생한 취소 건수" | +| `cancel_amount_by_event_date` | 취소 이벤트 도착일 | CURDATE() | "오늘 발생한 취소 금액" | +| `cancel_count_by_order_date` | 취소 이벤트 도착일 | **원주문 결제일** | "그 날 매출 중 취소된 건수" | +| `cancel_amount_by_order_date` | 취소 이벤트 도착일 | **원주문 결제일** | "그 날 매출 중 취소된 금액" | + +**이벤트 스키마 변경 필요**: ORDER_CANCELLED 이벤트에 `originalOrderDate`(원주문 결제일)를 포함시킨다. commerce-api에서 주문 취소 시 이벤트 발행 로직을 수정한다. + +조회 시: + +```sql +-- 운영 관점: "4월 1일의 실제 순매출" +SELECT sales_amount - cancel_amount_by_order_date AS real_net_sales +FROM product_metrics +WHERE product_id = 101 AND metric_date = '2026-04-01'; + +-- 현금흐름 관점: "4월 5일에 발생한 취소 금액" +SELECT cancel_amount_by_event_date +FROM product_metrics +WHERE product_id = 101 AND metric_date = '2026-04-05'; + +-- 검증: 충분히 긴 기간으로 합산하면 두 기준의 합계가 같아야 함 +SELECT SUM(cancel_amount_by_order_date) AS by_order, + SUM(cancel_amount_by_event_date) AS by_event +FROM product_metrics WHERE product_id = 101; +-- 두 값이 같으면 정합성 정상 +``` + +##### 설계 원칙 3 — 의미 정의 중앙화 (Semantic Definition) + +메트릭의 의미가 코드 곳곳에 흩어지면, 정의 변경 시 모든 위치를 찾아 수정해야 한다. **"이 숫자가 무엇을 뜻하는가"를 한 곳에서 정의하고, 나머지는 그 정의를 참조한다.** + +| 정의 대상 | 중앙화 위치 | 참조하는 곳 | +|----------|-----------|-----------| +| 이벤트 → 메트릭 매핑 | `MetricsDelta` 팩토리 메서드 (`ofView()`, `ofLike(int)`, `ofSales(int, long)`) | MetricsConsumer Phase 1 | +| 랭킹 score 수식의 가중치 | `RankingProperties.Weights` (yml 외부화) | RankingScoreUpdater, 배치 보정 잡 | +| 파생 메트릭 정의 | SQL VIEW 또는 쿼리 내 주석 | 분석 쿼리, 배치 보정 잡 | + +**파생 메트릭 정의 예시**: + +```sql +-- 파생 메트릭: 항상 이 공식으로 계산한다 +-- net_like = like_count - unlike_count +-- net_sales = sales_amount - cancel_amount_by_event_date (인식일 기준) +-- real_net_sales = sales_amount - cancel_amount_by_order_date (발생일 기준) +-- cancel_rate = cancel_count_by_event_date / sales_count (조회 시 계산, 컬럼으로 저장하지 않음) +``` + +이 원칙의 핵심: 새로운 메트릭이 추가되거나 기존 메트릭의 의미가 변경될 때, **수정 지점이 1곳**(또는 명확히 한정된 소수)이어야 한다. `MetricsDelta`에 새 필드를 추가하면 Phase 1(집계), Phase 2(DB), Phase 3(Redis)가 자연스럽게 따라간다. + +##### MetricsConsumer Phase 2 변경 + +ORDER_CANCELLED는 **2건의 UPSERT**가 필요하다 (인식일 행 + 발생일 행). + +```sql +-- 1) 모든 이벤트: 인식일(CURDATE()) 기준 UPSERT +INSERT INTO product_metrics + (product_id, metric_date, view_count, like_count, unlike_count, + sales_count, sales_amount, + cancel_count_by_event_date, cancel_amount_by_event_date, + cancel_count_by_order_date, cancel_amount_by_order_date) +VALUES (?, CURDATE(), ?, ?, ?, ?, ?, ?, ?, 0, 0) +ON DUPLICATE KEY UPDATE + view_count = view_count + VALUES(view_count), + like_count = like_count + VALUES(like_count), + unlike_count = unlike_count + VALUES(unlike_count), + sales_count = sales_count + VALUES(sales_count), + sales_amount = sales_amount + VALUES(sales_amount), + cancel_count_by_event_date = cancel_count_by_event_date + VALUES(cancel_count_by_event_date), + cancel_amount_by_event_date = cancel_amount_by_event_date + VALUES(cancel_amount_by_event_date) + +-- 2) ORDER_CANCELLED만 추가: 발생일(원주문일) 기준 UPSERT +INSERT INTO product_metrics + (product_id, metric_date, + cancel_count_by_order_date, cancel_amount_by_order_date) +VALUES (?, ?, ?, ?) -- metric_date = originalOrderDate +ON DUPLICATE KEY UPDATE + cancel_count_by_order_date = cancel_count_by_order_date + VALUES(cancel_count_by_order_date), + cancel_amount_by_order_date = cancel_amount_by_order_date + VALUES(cancel_amount_by_order_date) +``` + +이벤트별 매핑: + +| 이벤트 | 대상 행 | view | like | unlike | sales_count | sales_amount | cancel_event | cancel_order | +|--------|---------|------|------|--------|-------------|--------------|-------------|-------------| +| PRODUCT_VIEWED | CURDATE() | +1 | 0 | 0 | 0 | 0 | 0 | 0 | +| LIKE_CREATED | CURDATE() | 0 | +1 | 0 | 0 | 0 | 0 | 0 | +| LIKE_REMOVED | CURDATE() | 0 | 0 | +1 | 0 | 0 | 0 | 0 | +| ORDER_CREATED | CURDATE() | 0 | 0 | 0 | +count | +amount | 0 | 0 | +| ORDER_CANCELLED (1) | CURDATE() | 0 | 0 | 0 | 0 | 0 | +count/+amount | 0 | +| ORDER_CANCELLED (2) | **originalOrderDate** | 0 | 0 | 0 | 0 | 0 | 0 | +count/+amount | + +##### Redis 랭킹과의 관계 + +Redis 랭킹(Phase 3)은 **순수값(net)**으로 score를 계산한다: + +``` +Redis Hash: + viewCount = DB의 view_count (취소 개념 없음) + likeCount = DB의 like_count - unlike_count (순 좋아요) + salesAmount = DB의 sales_amount - cancel_amount_by_event_date (순 매출, 인식일 기준) + +score = 0.1 × log₁₀(viewCount + 1) + + 0.2 × log₁₀(likeCount + 1) + + 0.7 × log₁₀(salesAmount + 1) +``` + +DB는 gross/cancel을 분리 보관하고 발생일/인식일 이중 기록(분석 가능성 보존), Redis는 net값으로 실시간 랭킹 계산. 역할이 다르므로 저장 형태도 다르다. + +#### 장애 격리의 이점 + +Phase 2와 Phase 3는 **실행 순서는 있지만 트랜잭션을 공유하지 않는다.** + +``` +Phase 2 성공, Phase 3 실패: + → DB 정확, Redis 일시 부정확 → 다음 배치에서 자연 복구 + → 유저: 상품 상세의 누적 통계는 정상, 랭킹만 잠시 지연 + +Phase 2 실패: + → 트랜잭션 롤백 → deltaMap이 비정상이므로 Phase 3도 스킵 + → 유저: 해당 배치의 이벤트가 DB/Redis 모두 미반영. Kafka 오프셋 미커밋 → 재처리 +``` + +#### 원장 기반 재집계 가능 여부 + +**엠넷플러스(Mnet Plus)**는 ElastiCache(실시간) + DynamoDB(원장)의 이중 집계에서, Redis 장애 시 DynamoDB 원장으로부터 재집계하는 경로를 갖추고 있다. + +`product_metrics`에 `metric_date`가 포함되므로 우리 시스템에서도 동일한 재집계가 가능하다. + +| 관점 | 가능 여부 | 이유 | +|------|----------|------| +| **일간 집계 복원** | **O** | `WHERE metric_date = CURDATE()` → 오늘 일별 데이터로 Redis ZSET 재구축 가능 | +| 전체 누적 복원 | O | `SUM(...) WHERE product_id = ?` → 전 기간 합산 | +| 주간/월간 집계 | O | `SUM(...) WHERE metric_date BETWEEN ? AND ?` → 기간별 집계 가능 | +| 이벤트 리플레이 | △ | Kafka 보존 기간(기본 7일) 내라면 이벤트 재소비로 복원 가능. 단 별도 리플레이 도구 필요 | + +#### Redis 재집계 경로 (장애 복구) + +Redis Hash/ZSET이 유실된 경우, `product_metrics`로부터 일간 랭킹을 재구축할 수 있다. + +```sql +SELECT product_id, + view_count, + (like_count - unlike_count) AS net_like, + sales_count, + (sales_amount - cancel_amount_by_event_date) AS net_sales_amount +FROM product_metrics +WHERE metric_date = CURDATE() +``` + +``` +재집계 흐름: + 1. product_metrics에서 오늘 데이터 조회 + 2. net값 계산 (like - unlike, sales - cancel) + 3. 각 상품의 score 계산 (섹션 3 수식) + 4. Redis Pipeline으로 Hash + ZSET 일괄 적재 +``` + +##### 설계 원칙 4 — Lambda Architecture (실시간 + 배치 보정) + +실시간 집계만으로는 **누적 오차**가 발생할 수 있다. Pipeline 부분 실패, Redis 장애, Consumer 재시작 등으로 일부 delta가 유실되면 Hash의 누적값이 DB 원장과 어긋난다. 이를 "실시간 경로만으로 해결"하려면 재시도/보상 로직이 복잡해진다. + +Lambda Architecture는 두 경로를 병행하여 정합성을 확보한다: + +``` +Speed Layer (실시간): + Kafka → MetricsConsumer → Redis Hash/ZSET + 특성: 빠름(수 초 이내), 근사치, 부분 유실 허용 + +Batch Layer (보정): + product_metrics (DB) → 배치 잡 → Redis Hash/ZSET 덮어쓰기 + 특성: 느림(주기적), 정확, DB 원장 기반 +``` + +**두 경로의 역할이 다르다.** 실시간은 "빠르게 반영"하고, 배치는 "정확하게 보정"한다. 실시간 경로에서 누적된 오차를 배치가 주기적으로 교정하므로, 실시간 경로의 부분 실패를 복잡한 보상 로직 없이 허용할 수 있다. + +**배치 보정 잡 설계**: + +``` +실행 주기: 1시간마다 (정시) +실행 환경: commerce-batch (Spring Batch) + +Step 1 — DB 원장 조회: + SELECT product_id, view_count, (like_count - unlike_count) AS net_like, + sales_count, (sales_amount - cancel_amount_by_event_date) AS net_sales_amount + FROM product_metrics + WHERE metric_date = CURDATE() + +Step 2 — Score 재계산: + score = 0.1 × log₁₀(view_count + 1) + + 0.2 × log₁₀(net_like + 1) + + 0.7 × log₁₀(net_sales_amount + 1) + + product_id × 1e-10 + +Step 3 — Redis 덮어쓰기 (Pipeline): + DEL ranking:metrics:{date}:{pid} ← 기존 Hash 삭제 + HSET ranking:metrics:{date}:{pid} viewCount {view_count} likeCount {net_like} ... + ZADD ranking:all:{date} {score} {pid} + EXPIRE ... +``` + +**실시간 경로와의 Race Condition 대응**: + +| 시나리오 | 영향 | 허용 여부 | +|---------|------|----------| +| 배치 ZADD 직후 실시간 HINCRBY | 배치가 넣은 값에 실시간 delta가 더해짐 → 정확 | 문제 없음 | +| 실시간 ZADD 직후 배치 ZADD | 배치가 실시간 값을 덮어씀 → 최근 수 초 이벤트 유실 | 다음 실시간 배치에서 복구 | +| 배치 DEL + HSET 사이에 실시간 HINCRBY | DEL 후 HINCRBY가 새 Hash 생성 → HSET이 덮어씀 | 다음 실시간 배치에서 복구 | + +최악의 경우 "최근 수 초분 이벤트가 한 번 유실"되지만, 다음 실시간 배치(수 초 후)에서 delta가 다시 적용된다. **정합성은 결국 수렴한다.** + +**1시간 주기의 산술적 근거**: + +``` +실시간 경로의 오차 축적률: + MetricsConsumer 3,000건/배치 × 12배치/분 = 36,000건/분 + Pipeline 부분 실패율 가정: 0.1% (Redis 일시 불안정 등) + → 시간당 누적 오차: 36,000 × 60 × 0.001 = 2,160건 + +배치 보정 비용: + 일간 활성 상품 10만 개 → SELECT 1회(인덱스 스캔) + Pipeline 1회 + DB 조회: ~50ms (idx_metric_date 활용) + Redis Pipeline: 10만 × 3 명령 ≈ 300,000 명령 → ~300ms + 총: ~350ms / 1시간 = 무시 가능한 부하 + +→ 1시간 주기면 최대 2,160건의 오차가 다음 보정에서 교정됨 +→ 오차 누적 시간 vs 보정 비용의 균형점 +``` + +**배치 보정이 불필요한 경우**: Redis가 안정적이고 Pipeline 실패가 거의 없다면 배치 보정의 실질 효과는 미미하다. 그러나 "DB 원장이 있으니 언제든 재집계할 수 있다"는 구조를 갖추는 것 자체가 Lambda Architecture의 가치다. + +#### 디스크 산정 + +``` +product_metrics 행 크기: + product_id(8) + metric_date(3) + view_count(4) + like_count(4) + unlike_count(4) + + sales_count(4) + sales_amount(8) + + cancel_count_by_event_date(4) + cancel_amount_by_event_date(8) + + cancel_count_by_order_date(4) + cancel_amount_by_order_date(8) + = ~59 bytes/row + + InnoDB 행 오버헤드 ~30 bytes ≈ 89 bytes/row + +일간 활성 상품 10만 개 × 30일 보존: + 100,000 × 30 × 89 bytes ≈ 255 MB + +1년 보존 (상품 10만 개): + 100,000 × 365 × 89 bytes ≈ 3.1 GB +``` + +데이터 정리: `DELETE FROM product_metrics WHERE metric_date < DATE_SUB(CURDATE(), INTERVAL 90 DAY)` — 90일 이상 오래된 데이터를 주기적으로 purge. 날짜 인덱스(`idx_metric_date`)를 활용하여 효율적 삭제 가능. + +--- + +## 3. 점수 계산 모델 + +### 3.1 가중치 산정 근거 + +| 지표 | 가중치 | 근거 | +|------|--------|------| +| view | 0.1 | 가장 발생 빈도가 높은 시그널. 높게 잡으면 조회 수만으로 랭킹이 지배됨. "구경만 한" 상품과 "실제 인기" 상품을 구분하기 위해 낮게 설정 | +| like | 0.2 | 유저의 능동적 관여 — 조회보다 의도가 강하지만, 구매 결정까지는 아님. 위시리스트 성격의 중간 시그널 | +| order | 0.7 | 유저가 결제까지 완료한 가장 신뢰도 높은 시그널. 매출과 직결되므로 비즈니스 가치 정렬. 조작 난이도도 가장 높음 | + +**총합 = 1.0** — 각 가중치가 전체에서 차지하는 비중을 직관적으로 파악 가능. + +> 과제 문서에서는 order 가중치를 0.6으로 제시하나, 시니어 관점에서 주문의 비즈니스 가치를 더 반영하여 0.7로 상향. 나머지를 view 0.1 + like 0.2로 배분. + +#### 가중치 결정 근거와 검증 계획 + +**1) order 0.7 — 업계 표준과의 정합성** + +Shopify는 상품 검색 랭킹에서 "We prioritize products with actual sales, not just clicks. A product with thousands of orders outranks one with lots of views but few buyers"라고 명시한다 ([shopify.engineering](https://shopify.engineering/world-class-product-search)). **구매 전환이 클릭보다 우선**이라는 원칙은 이커머스 랭킹의 업계 공통 방향이며, order에 0.7을 부여한 근거와 일치한다. + +**2) 고정 가중치의 한계 — 향후 데이터 기반 보정** + +Amazon의 MORO(Multi-Objective Ranking Optimization) 연구에서는 고정 가중치보다 확률적 레이블 집계(stochastic label aggregation)가 우수함을 입증했다 ([amazon.science](https://www.amazon.science/publications/multi-objective-ranking-optimization-for-product-search-using-stochastic-label-aggregation)). 이는 카테고리/시즌에 따라 최적 가중치가 달라질 수 있음을 의미한다. + +현재 전 카테고리 동일 가중치(MVP)이며, 향후 보정을 위해 `RankingProperties.Weights`로 외부화 완료: + +| 단계 | 방법 | 전제 조건 | +|------|------|----------| +| 현재 (MVP) | 도메인 직관 기반 고정값 (0.1/0.2/0.7) | — | +| 1단계 | 클릭→구매 전환률 역산 — 실제 데이터로 view/like의 구매 예측력 측정 | 행동 데이터 2주+ 축적 | +| 2단계 | A/B 테스트 — ZSET 키를 `ranking:all:A:{date}` / `ranking:all:B:{date}`로 분리, 가중치 세트 비교 | 트래픽 충분 시 | +| 3단계 | 카테고리별 가중치 분리 — 패션(like 중요) vs 생필품(order 지배) | 카테고리 분류 체계 확립 후 | + +### 3.2 스케일 문제와 정규화 + +**salesAmount에만 log를 적용하면 스케일 불균형이 발생한다.** + +일간 기준 현실적 시나리오로 검증한다: + +``` +Product A: 조회 500회, 좋아요 30회, 주문 총액 200,000원 +Product B: 조회 100회, 좋아요 10회, 주문 총액 1,000,000원 +``` + +#### salesAmount에만 log 적용 시 + +``` +score = W(view) × viewCount + W(like) × likeCount + W(order) × log₁₀(salesAmount + 1) + +A = 0.1×500 + 0.2×30 + 0.7×log₁₀(200001) = 50 + 6 + 0.7×5.3 = 59.71 +B = 0.1×100 + 0.2×10 + 0.7×log₁₀(1000001) = 10 + 2 + 0.7×6.0 = 16.20 + +→ A가 B보다 3.7배 높음 +→ viewCount(50 vs 10)가 score를 지배. order 가중치 0.7의 의도가 무력화됨 +``` + +**문제**: view가 선형(0~수천)인데 order가 log(0~6)이므로, 가중치와 무관하게 view가 score를 지배한다. + +#### 전 지표 log 정규화 적용 시 + +``` +score = W(view) × log₁₀(viewCount + 1) + W(like) × log₁₀(likeCount + 1) + W(order) × log₁₀(salesAmount + 1) + +A = 0.1×log₁₀(501) + 0.2×log₁₀(31) + 0.7×log₁₀(200001) = 0.1×2.7 + 0.2×1.49 + 0.7×5.3 = 0.27 + 0.30 + 3.71 = 4.28 +B = 0.1×log₁₀(101) + 0.2×log₁₀(11) + 0.7×log₁₀(1000001) = 0.1×2.0 + 0.2×1.04 + 0.7×6.0 = 0.20 + 0.21 + 4.20 = 4.61 + +→ B가 A보다 높음 +→ 주문 총액이 5배 높은 B가 상위. 가중치 의도(order=0.7)가 정확히 반영됨 +``` + +**결정: 전 지표에 log₁₀ 정규화를 적용한다.** + +- 모든 입력이 log₁₀ 스케일로 통일되어 가중치가 의도대로 작동 +- `+1`은 값이 0일 때 `log₁₀(0) = -∞` 방지 + +#### 정규화 함수 선택 근거 — 왜 log₁₀인가 + +"전 지표에 정규화를 적용한다"는 결정 이후, **어떤 정규화 함수**를 쓸 것인가의 선택이 남는다. 실시간 스트리밍 환경에서의 적합성을 기준으로 비교한다. + +| 함수 | 수식 | 글로벌 통계 필요 | 실시간 스트리밍 적합성 | +|------|------|:-:|:-:| +| **min-max** | `(x - min) / (max - min)` | O (전체 min/max 유지) | 낮음 | +| **z-score** | `(x - μ) / σ` | O (평균/표준편차 유지) | 낮음 | +| **log₁₀(x+1)** | `log₁₀(x + 1)` | X | 높음 | + +**왜 min-max가 아닌가**: +- 전체 상품의 최대/최소값을 알아야 하므로, 매 이벤트마다 글로벌 통계를 조회하거나 유지해야 한다 +- 새 최대값이 등장하면 기존 전 상품의 정규화 값이 무효화 → ZSET 전체 재계산 필요 +- 이상치(바이럴 상품)가 하나만 등장해도 나머지 상품의 score가 0 부근으로 압축됨 + +**왜 z-score가 아닌가**: +- 평균과 표준편차를 유지해야 하므로 min-max와 동일한 글로벌 통계 문제 +- OpenSearch 벤치마크에서 z-score는 min-max 대비 NDCG@10이 2.08% 향상되었으나, 레이턴시가 증가한다 ([opensearch.org](https://opensearch.org/blog/introducing-the-z-score-normalization-technique-for-hybrid-search/)) +- 실시간 스트리밍에서 "정밀한 정규화"보다 "글로벌 통계 없이 독립 계산 가능"이 우선 + +**log₁₀의 3가지 장점**: + +1. **개별 이벤트 시점에 독립 계산**: `log₁₀(viewCount + 1)`은 해당 상품의 현재 값만으로 계산. 다른 상품의 상태를 알 필요 없음 +2. **글로벌 통계 불필요**: min/max/평균/표준편차를 유지하는 인프라(Redis 키, 갱신 로직)가 불필요 → 시스템 복잡도 감소 +3. **right-skewed 분포 압축**: 이커머스 데이터는 전형적 멱법칙 분포 — 소수 상품이 대부분의 조회/매출을 차지한다. log 변환은 이 꼬리를 압축하여 바이럴 상품의 랭킹 독점을 방지한다 ([geeksforgeeks.org](https://www.geeksforgeeks.org/data-analysis/log-normalization-for-outliers-convert-skewed-data-to-normal-distribution/)) + +**수치 예시 — log₁₀의 스케일 압축 효과**: + +``` +log₁₀(1 + 1) = 0.301 — 최소 활동 +log₁₀(100 + 1) = 2.004 — 일반 상품 +log₁₀(10000 + 1) = 4.000 — 인기 상품 +log₁₀(1000000+1) = 6.000 — 바이럴 상품 + +→ 조회수가 100배 증가해도 log값은 약 2배만 증가 +→ 바이럴 상품(100만)과 인기 상품(1만)의 차이가 6.0 vs 4.0 = 1.5배로 압축 +``` + +**Wilson Score와의 관계**: Wilson Score는 이항(binary) 데이터(좋다/싫다, 별 5개 중 4개)에 대해 신뢰구간 하한을 제공하는 방식이다. 카운트 데이터(조회 수, 매출액)에는 log가 더 적합하다. 향후 별점을 랭킹에 반영할 때 Wilson Score를 고려한다 ([evanmiller.org](https://www.evanmiller.org/how-not-to-sort-by-average-rating.html)). + +#### 0~1 범위 정규화 (MAX_LOG) + +log₁₀ 적용만으로는 score가 0~6 범위를 가진다. **MAX_LOG로 나누어 0~1로 정규화**하면 score가 직관적이고, tiebreaker와 자릿수 분리가 깨끗해진다. + +``` +MAX_LOG = 7 (log₁₀(10,000,001) ≈ 7 — 천만 단위까지 커버) + +viewNorm = log₁₀(viewCount + 1) / MAX_LOG → 0 ~ 1 +likeNorm = log₁₀(likeCount + 1) / MAX_LOG → 0 ~ 1 +orderNorm = log₁₀(salesAmount + 1) / MAX_LOG → 0 ~ 1 +``` + +score = 0~1 범위이므로 **소수 6자리가 주 score, 7자리 이하가 tiebreaker** — IEEE 754 double(유효 15자리)에서 깨끗하게 분리된다. + +### 3.3 최종 수식 — Composite Score + +score를 **자릿수 기반으로 관심사 분리**한다: + +``` +score(p) = [categoryPriority] ← 정수부: 카테고리 우선순위 (0~9) + + [baseScore] ← 소수 1~6자리: 주 score (0~1) + + [tiebreaker] ← 소수 7~15자리: 동점 해소 + +baseScore = W(view) × log₁₀(viewCount + 1) / MAX_LOG + + W(like) × log₁₀(likeCount + 1) / MAX_LOG + + W(order) × log₁₀(salesAmount + 1) / MAX_LOG + +tiebreaker = lastEventEpochSeconds × 1e-16 ← 최근 활동 상품 우선 +``` + +**자릿수 구조 예시** (`categoryPriority=3`, 매출 20만원 상품, 마지막 이벤트 2026-04-10 14:00): + +``` +score = 3 + 0.611400 + 0.0000001712952000 + ^ ^^^^^^^^ ^^^^^^^^^^^^^^^^^^ + 정수부 소수 1~6 소수 7~16 + 카테고리 주 score tiebreaker (epochSec) +``` + +**categoryPriority 미사용 시** (현재 MVP): 정수부 0으로 고정, baseScore + tiebreaker만 사용. + +#### 검증 — 가중치 의도대로 동작하는가? + +| 상품 | view | like | salesAmount | baseScore | 순위 | +|------|------|------|-------------|-----------|------| +| C (조회만 많음) | 5,000 | 10 | 50,000 | 0.1×(3.7/7) + 0.2×(1.04/7) + 0.7×(4.7/7) = **0.553** | 3위 | +| A (균형) | 500 | 30 | 200,000 | 0.1×(2.7/7) + 0.2×(1.49/7) + 0.7×(5.3/7) = **0.611** | 2위 | +| B (매출 집중) | 100 | 10 | 1,000,000 | 0.1×(2.0/7) + 0.2×(1.04/7) + 0.7×(6.0/7) = **0.659** | 1위 | + +- B(매출 최고) > A(균형) > C(조회만 많음) → **order 가중치 0.7이 지배적으로 작동** +- 조회 수가 50배 차이(C vs B)나도 매출이 높은 B가 상위 → 의도대로 동작 +- 전 score가 0~1 범위이므로 "0.659는 이론적 최고의 66%"와 같이 직관적으로 해석 가능 + +### 3.4 음수 이벤트 처리 (LIKE_REMOVED, ORDER_CANCELLED) + +취소 이벤트는 **DB와 Redis에서 다르게 처리**된다. + +#### DB (product_metrics) — 취소 분리 저장 + +``` +LIKE_REMOVED → unlike_count += 1 (like_count는 건드리지 않음) +ORDER_CANCELLED → cancel_count += count, cancel_amount += amount +``` + +원본을 보존하고 취소를 별도 기록한다. 분석 시 gross/net을 자유롭게 계산 가능. + +#### Redis (ranking:metrics Hash) — net값으로 감소 + +``` +LIKE_REMOVED → HINCRBY likeCount -1 +ORDER_CANCELLED → HINCRBY salesCount -{count}, HINCRBY salesAmount -{amount} +``` + +Redis Hash는 랭킹 score 계산 전용이므로 **순수값(net)을 직접 저장**한다. 분석 목적이 아니라 score 계산의 입력값이기 때문. + +**log + 취소의 정확성**: + +``` +취소 전: salesAmount = 500,000 → log₁₀(500001) = 5.699 +50,000원 주문 취소 후: salesAmount = 450,000 → log₁₀(450001) = 5.653 +``` + +Hash에서 총액을 감소시키고 log를 재계산하므로 항상 수학적으로 정확하다. + +### 3.5 ZINCRBY vs Metric 기반 — 트레이드오프 분석 + +| 관점 | ZINCRBY (즉시 증분) | Metric 기반 (Hash + ZADD) | +|------|-------------------|--------------------------| +| Redis 연산 | 1회 (ZINCRBY) | HINCRBY × 필드 수 + ZADD | +| 가중치 변경 | **불가** — 기존 score 분해 불가, ZSET 재생성 필요 | **가능** — Hash에서 재계산 | +| ORDER_CANCELLED + log | **수학적으로 부정확** — 아래 설명 | **정확** — 총액 감소 후 재계산 | +| 디버깅 | score 52.3이 뭘 의미하는지 알 수 없음 | Hash 조회로 view=100, like=20 등 확인 가능 | +| 메모리 | ZSET만 | ZSET + Hash (상품당 ~50bytes 추가) | +| SSOT | ZSET 자체가 유일 소스 | **Hash가 SSOT**, ZSET은 파생값 | + +**ZINCRBY + log에서 취소가 부정확한 이유**: + +``` +주문 1: 100,000원 → ZINCRBY +0.7 × log₁₀(100001) = +3.50 +주문 2: 50,000원 → ZINCRBY +0.7 × log₁₀(50001) = +3.29 +누적 score = 6.79 + +주문 1 취소: ZINCRBY -0.7 × log₁₀(100001) = -3.50 +남은 score = 3.29 + +그러나 정확한 값은: +salesAmount = 50,000 → 0.7 × log₁₀(50001) = 3.29 ← 우연히 일치 + +주문 2 취소: ZINCRBY -0.7 × log₁₀(50001) = -3.29 +남은 score = 0.00 ✓ (맞음) + +하지만 세 주문 이상에서는: +주문 3건(10만+5만+3만) 후 중간 취소 시, +ZINCRBY 역연산 ≠ log₁₀(남은 총액) +→ log(a) + log(b) = log(a×b) ≠ log(a+b) +``` + +**결정: Metric 기반(Hash + ZADD)을 채택한다.** + +성능 차이가 무시할 수준(0.2ms/배치)이면서, 가중치 변경 가능성, 취소 정합성, 디버깅 편의성에서 모두 우위다. +설계 문서에는 ZINCRBY 방식을 분석한 근거와 함께 Metric 기반을 선택한 이유를 기록하여, 과제의 "ZINCRBY 기반 실시간 집계" 키워드를 충족한다. + +--- + +## 4. Redis Key 설계 + +### 4.1 키 패턴 + +| 용도 | 키 패턴 | 타입 | 예시 | +|------|---------|------|------| +| 일간 랭킹 | `ranking:all:{yyyyMMdd}` | ZSET | `ranking:all:20260410` | +| 상품별 일간 메트릭 | `ranking:metrics:{yyyyMMdd}:{productId}` | Hash | `ranking:metrics:20260410:101` | + +**ZSET 구조**: + +``` +ranking:all:20260410 + member: "101" score: 4.61 + member: "202" score: 4.28 + member: "303" score: 3.87 + ... +``` + +- member는 productId(문자열), score는 섹션 3의 수식으로 계산된 값 + +**Hash 구조**: + +``` +ranking:metrics:20260410:101 + viewCount: 500 + likeCount: 30 + salesCount: 5 + salesAmount: 200000 +``` + +- SSOT(Single Source of Truth). ZSET의 score는 이 Hash로부터 파생된다. +- HINCRBY 리턴값으로 score를 계산하므로 별도 HGETALL이 불필요하다. + +### 4.2 키 네이밍 설계 근거 + +**`ranking:all`에서 `all`의 의미**: + +현재는 전체 상품 대상 랭킹만 존재한다. 향후 카테고리별 랭킹 확장 시: + +``` +ranking:all:{date} → 전체 랭킹 +ranking:category:1:{date} → 카테고리 1 랭킹 +ranking:category:2:{date} → 카테고리 2 랭킹 +``` + +`all`을 명시해두면 네임스페이스 충돌 없이 확장 가능하다. + +**Hash 키에 productId를 포함하는 이유**: + +| 대안 | 구조 | 문제 | +|------|------|------| +| 상품별 Hash (`ranking:metrics:{date}:{pid}`) | 키 1개당 필드 4개 | 키 수가 많지만, 개별 TTL 관리 가능 | +| 날짜별 단일 Hash (`ranking:metrics:{date}`) | 필드명: `{pid}:viewCount` 등 | 키 1개에 필드 수천 개, HGETALL 비용 증가, 상품별 조회 불편 | + +**결정: 상품별 Hash**. 키 수가 많아지나 각각 독립적으로 만료되고, 디버깅 시 특정 상품의 메트릭을 `HGETALL ranking:metrics:20260410:101`로 즉시 확인할 수 있다. + +### 4.3 시간대 기준 — KST + +**왜 KST인가**: 이커머스 서비스의 비즈니스 일자는 한국 시간 기준이다. "오늘의 인기 상품"이 UTC 기준이면 한국 자정에 랭킹이 리셋되지 않는다. + +```java +LocalDate today = LocalDate.now(ZoneId.of("Asia/Seoul")); +String dateKey = today.format(DateTimeFormatter.BASIC_ISO_DATE); // "20260410" +``` + +**자정 경계 이벤트**: 23:59:59 KST에 발생한 이벤트가 처리 시점(00:00:01 KST)에 다음 날 키에 적재될 수 있다. 이는 허용한다 — 초 단위 정확도보다 시스템 단순성이 우선이며, 랭킹 특성상 수 초의 경계 차이는 의미 없다. + +### 4.4 TTL 설계 + +| 키 | TTL | 산정 근거 | +|----|-----|----------| +| `ranking:all:{date}` | **8일 (691,200초)** | 주간 랭킹 합산에 최근 7일분 필요 + 1일 여유 (섹션 4.7.1) | +| `ranking:metrics:{date}:{pid}` | **2일 (172,800초)** | Hash는 당일 score 재계산에만 사용. 주간/월간 합산은 ZSET score를 직접 활용 | +| `ranking:weekly:{date}` | **2일 (172,800초)** | 오늘 + 어제 주간 랭킹 조회 보장 | +| `ranking:monthly:{date}` | **2일 (172,800초)** | 오늘 + 어제 월간 랭킹 조회 보장 + rolling carry-over 입력으로 사용 | + +**TTL 설정 시점**: Pipeline에서 HINCRBY/ZADD와 함께 EXPIRE를 전송한다. + +``` +Pipeline 1: + HINCRBY ranking:metrics:20260410:101 viewCount 5 + HINCRBY ranking:metrics:20260410:101 likeCount 1 + ... + EXPIRE ranking:metrics:20260410:101 172800 ← Hash: 2일 +Pipeline 2: + ZADD ranking:all:20260410 4.61 101 + ... + EXPIRE ranking:all:20260410 691200 ← ZSET: 8일 +``` + +**매 배치마다 EXPIRE를 재설정하는 이유**: + +- EXPIRE는 O(1)이며 Pipeline에 포함되므로 추가 왕복 없음 +- "마지막 쓰기 + TTL" 만료 → 날짜 전환 후에도 데이터가 충분히 유지됨 +- 키 생성 여부를 확인(`EXISTS`)하는 것보다 단순하고 안전 + +### 4.5 Nice-to-Have: 시간 단위 키 확장 + +일간 키 패턴을 그대로 확장하면 시간 단위 랭킹도 자연스럽게 구현 가능하다: + +``` +ranking:all:daily:{yyyyMMdd} TTL: 2일 +ranking:all:hourly:{yyyyMMddHH} TTL: 3시간 +ranking:metrics:hourly:{yyyyMMddHH}:{productId} TTL: 3시간 +``` + +RankingScoreUpdater에 키 생성 전략을 주입하면 daily/hourly를 동시에 지원할 수 있다. 현재 구현에서는 daily만 구현하고, 구조만 확장 가능하게 설계한다. + +### 4.6 일별 키 vs 연속적 시간 감쇠 — 트레이드오프 + +"오늘의 인기 상품"을 구현하려면 **시간에 따른 점수 감쇠(decay)**가 필요하다. 두 가지 접근이 있다: + +**1) 연속적 시간 감쇠 (Continuous Decay)** + +Hacker News의 `(P-1)/(T+2)^1.8` 수식이 대표적이다 ([medium.com](https://medium.com/hacking-and-gonzo/how-hacker-news-ranking-algorithm-works-1d9b0cf2c08d)). 매 이벤트마다 경과 시간에 따라 점수가 매끄럽게 감소한다. + +Exponential decay 변형(`score(t) = e^(-λ*dt) × score(t-dt) + new_events`)은 현재 score 하나만 유지하면 되는 장점이 있으나, 매 갱신마다 기존 score를 읽고 decay를 적용한 뒤 다시 쓰는 **read-then-write 원자성**이 필요하다 ([julesjacobs.com](https://julesjacobs.com/2015/05/06/exponentially-decaying-likes.html)). Redis에서는 Lua 스크립트로 해결해야 한다. + +**2) 이산적 시간 감쇠 (Discrete Decay = 일별 키)** + +날짜별 키(`ranking:all:{yyyyMMdd}`)로 분리하고, 자정에 새 키가 시작되면 전일 키의 carry-over(10%)로 연결한다. + +Forward Decay(ICDE 2009)에서는 랜드마크 시점 기준으로 나이를 순방향 측정하며, 한 번 관측된 가중치가 고정되는 것이 특징이다 ([dimacs.rutgers.edu](https://dimacs.rutgers.edu/~graham/pubs/papers/fwddecay.pdf)). **일별 키 전략은 Forward Decay의 이산적 구현**이다 — 자정이 랜드마크, 일간 누적이 순방향 측정에 해당한다. + +**비교**: + +| 기준 | 연속적 Decay | 일별 키 (현재) | +|------|:---:|:---:| +| 정밀도 | 초 단위 감쇠 — 매끄러운 곡선 | 일 단위 — 자정에 cliff effect | +| Redis 연산 | read-then-write (Lua 필수) | HINCRBY + ZADD (원자적, Lua 불필요) | +| 구현 복잡도 | Lua 스크립트 + decay 파라미터 튜닝 | 키 분리 + ZUNIONSTORE carry-over | +| 디버깅 | score 안에 시간 감쇠가 내재되어 역추적 어려움 | Hash 조회로 오늘 메트릭 그대로 확인 | +| 집계 단위 명확성 | 없음 — 연속 값이므로 "오늘 일어난 일"을 분리 불가 | "오늘 키 = 오늘 데이터" — 명확 | +| 키 만료 | score 감쇠로 자연 소멸하나 키 정리 별도 필요 | TTL 2일 → 자동 정리 | + +**결정: 일별 키**. 이유: + +1. HINCRBY + ZADD가 Lua 없이 원자적으로 동작하여 Pipeline에 자연스럽게 포함됨 +2. "오늘의 메트릭"이 키 단위로 명확히 분리되어 디버깅, 배치 보정, 재집계가 단순 +3. carry-over(ZUNIONSTORE × 0.1)가 cliff effect를 충분히 완화 +4. 현재 요구사항이 "일간 랭킹"이므로 초 단위 감쇠의 정밀도가 불필요 + +### 4.7 주간/월간 랭킹 확장 설계 + +일별 키 인프라를 재활용하여 **주간(7일)/월간(30일) 랭킹**을 추가한다. 핵심 원칙: **per-event 추가 비용 0** — 기존 daily ZSET에만 이벤트를 쓰고, 주간/월간은 자정 배치(carry-over 스케줄러)에서 생성한다. + +#### 4.7.1 키 패턴 + +| 용도 | 키 패턴 | 타입 | TTL | 생성 시점 | +|------|---------|------|-----|----------| +| 일간 랭킹 | `ranking:all:{yyyyMMdd}` | ZSET | **8일** | 이벤트 유입 시 | +| 주간 랭킹 | `ranking:weekly:{yyyyMMdd}` | ZSET | 2일 | 23:50 스케줄러 | +| 월간 랭킹 | `ranking:monthly:{yyyyMMdd}` | ZSET | 2일 | 23:50 스케줄러 | +| 상품별 일간 메트릭 | `ranking:metrics:{yyyyMMdd}:{productId}` | Hash | 2일 (변경 없음) | 이벤트 유입 시 | + +**일간 ZSET TTL 변경: 2일 → 8일**. 주간 합산에 최근 7일분 daily ZSET이 필요하므로 최소 8일(7일 + 1일 여유) 보존해야 한다. Hash TTL은 변경 없음 — Hash는 당일 score 재계산에만 사용되고, 주간/월간 합산에서는 ZSET score를 직접 활용한다. + +#### 4.7.2 주간 랭킹 — ZUNIONSTORE × 7일 + +23:50 스케줄러에서 최근 7일 daily ZSET을 **동일 가중치로 합산**한다: + +``` +ZUNIONSTORE ranking:weekly:{tomorrow} 7 + ranking:all:{today} ranking:all:{today-1} ranking:all:{today-2} + ranking:all:{today-3} ranking:all:{today-4} ranking:all:{today-5} + ranking:all:{today-6} + WEIGHTS 1.0 1.0 1.0 1.0 1.0 1.0 1.0 + AGGREGATE SUM +EXPIRE ranking:weekly:{tomorrow} 172800 +``` + +**왜 동일 가중치인가**: +- 주간 랭킹의 의미는 "이번 주 인기 상품" — 7일간의 누적 인기를 반영한다 +- 각 daily ZSET에는 이미 carry-over(10%)가 포함되어 있으므로 최근 일자에 자연스러운 가중이 존재한다 +- 별도의 감쇠 가중치를 적용하면 carry-over와 이중으로 감쇠가 걸려 과도한 최근 편향이 발생한다 +- 향후 A/B 테스트로 감쇠 가중치(예: `1.0, 0.9, 0.8, ...`)의 효과를 비교할 수 있다 + +**ZUNIONSTORE 비용 산정**: + +``` +시간복잡도: O(N × K × log(N × K)) (N=원소 수, K=입력 키 수) +10만 상품 × 7키 = 700,000 원소 합산 후 정렬 + +벤치마크 추정: + 단일 스레드 Redis, 10만 원소 ZUNIONSTORE 1키 ≈ 50~200ms + 7키 합산 ≈ 200~500ms (한 번에 처리, 중간 결과 없음) + +→ 23:50에 1회 실행, Redis 블로킹 최대 ~500ms +→ 저점 시간대이므로 수용 가능 +``` + +#### 4.7.3 월간 랭킹 — Rolling Carry-Over + +30일분 ZUNIONSTORE(30개 키)는 비용이 과대하다. 대신 **일간 carry-over 패턴을 재활용**한다: + +``` +ZUNIONSTORE ranking:monthly:{tomorrow} 2 + ranking:monthly:{today} ranking:all:{today} + WEIGHTS 0.97 1.0 + AGGREGATE SUM +EXPIRE ranking:monthly:{tomorrow} 172800 +``` + +**감쇠율 0.97의 근거**: + +``` +0.97^7 ≈ 0.81 → 1주 전 데이터: 81% 보존 (주간 트렌드 유지) +0.97^14 ≈ 0.65 → 2주 전 데이터: 65% 보존 +0.97^30 ≈ 0.40 → 1달 전 데이터: 40%로 감쇠 (자연스러운 페이드아웃) +0.97^60 ≈ 0.16 → 2달 전 데이터: 16% → 사실상 소멸 + +→ 30일 반감기: 0.97^n = 0.5 → n ≈ 23일 +→ "최근 3~4주가 지배적, 한 달 이전 데이터는 자연 퇴장" +``` + +**왜 0.97인가 — 대안 비교**: + +| 감쇠율 | 30일 후 잔존 | 반감기 | 특성 | +|--------|:---:|:---:|------| +| 0.90 | 4% | ~7일 | 너무 공격적 — 사실상 주간 랭킹과 동일 | +| 0.95 | 21% | ~14일 | 2주 반감 — 짧은 월간 | +| **0.97** | **40%** | **~23일** | **3~4주 지배 — 자연스러운 월간 특성** | +| 0.99 | 74% | ~69일 | 너무 완만 — 오래된 데이터가 고착 | + +**ZUNIONSTORE 비용**: 2개 키 합산이므로 일간 carry-over와 동일 — 10만 상품 기준 ~50ms. + +**월간 ZSET 초기화 문제**: 서비스 최초 배포 시 `ranking:monthly:{today}`가 존재하지 않는다. ZUNIONSTORE에서 존재하지 않는 키는 빈 ZSET으로 취급되므로, 첫날에는 `ranking:monthly:{tomorrow}` = `ranking:all:{today} × 1.0`이 되어 **자연스럽게 부트스트랩**된다. + +#### 4.7.4 스케줄러 확장 + +기존 `RankingCarryOverScheduler`의 23:50 스케줄에 주간/월간 생성을 추가한다: + +``` +23:50 KST 실행 순서: + 1. 일간 carry-over → ranking:all:{tomorrow} = ranking:all:{today} × 0.1 + 2. 주간 랭킹 생성 → ranking:weekly:{tomorrow} = ZUNIONSTORE(7일분 daily) + 3. 월간 랭킹 생성 → ranking:monthly:{tomorrow} = monthly:{today} × 0.97 + daily:{today} × 1.0 +``` + +**실행 순서 중요**: 일간 carry-over가 먼저 실행되어야 한다. 주간/월간 합산에는 carry-over 전의 daily ZSET을 사용하므로, carry-over로 생성된 내일의 daily ZSET은 주간/월간에 영향을 주지 않는다 (내일 daily는 아직 이벤트가 없으므로 합산 대상이 아님). + +#### 4.7.5 API 확장 + +``` +GET /api/v1/rankings?scope=daily&date=20260410&page=0&size=20 (기본값: daily) +GET /api/v1/rankings?scope=weekly&page=0&size=20 +GET /api/v1/rankings?scope=monthly&page=0&size=20 +``` + +| scope | ZSET prefix | 의미 | +|-------|------------|------| +| `daily` (기본값) | `ranking:all:` | 오늘의 인기 상품 | +| `weekly` | `ranking:weekly:` | 이번 주 인기 상품 (7일 누적) | +| `monthly` | `ranking:monthly:` | 이번 달 인기 상품 (30일 감쇠 누적) | + +기존 `RankingRedisRepository`는 이미 `prefix` 파라미터를 지원하므로 (`getTopN(String prefix, String date, ...)`) 변경 최소. `RankingFacade`에서 scope → prefix 매핑만 추가한다. + +#### 4.7.6 메모리 영향 + +상품 10만 개 기준: + +``` +변경 전: + Daily ZSET × 2일 = 100,000 × 68B × 2 = ~13 MB + Daily Hash × 2일 = 100,000 × 160B × 2 = ~31 MB + 합계: ~44 MB + +변경 후: + Daily ZSET × 8일 = 100,000 × 68B × 8 = ~52 MB (+39 MB) + Daily Hash × 2일 = 100,000 × 160B × 2 = ~31 MB (변경 없음) + Weekly ZSET × 2일 = 100,000 × 68B × 2 = ~13 MB (신규) + Monthly ZSET × 2일 = 100,000 × 68B × 2 = ~13 MB (신규) + 합계: ~109 MB + +증가분: ~65 MB (+148%) +``` + +**65MB 증가가 수용 가능한가**: 1GB Redis 기준 10.9%, 16GB 기준 0.7%. daily ZSET TTL 8일이 대부분(39MB)을 차지한다. 이 중 6일분은 주간 합산 참조용으로만 존재하며, 읽기 부하를 발생시키지 않는다. + +**피크 메모리 (23:50 carry-over 시점)**: 일간/주간/월간 각각의 내일 키가 동시 생성되므로 기존 대비 ZSET 3개 추가. ~109MB + ~20MB(피크) = ~129MB. + +--- + +## 5. Redis Pipeline 최적화 + +### 5.1 왜 Pipeline인가 + +MetricsConsumer의 3,000건 배치가 인기 상품 100개에 집중될 때, 100개 상품의 메트릭을 갱신해야 한다. +Pipeline 없이 개별 명령을 전송하면: + +``` +개별 전송: 100상품 × (4 HINCRBY + 1 EXPIRE) + 100 ZADD + 1 EXPIRE = 601 왕복 +Pipeline: 2 왕복 (Pipeline 1 + Pipeline 2) +``` + +Redis RTT가 로컬 0.1ms, 원격 1ms일 때: + +| 방식 | 로컬 (RTT 0.1ms) | 원격 (RTT 1ms) | +|------|------------------|---------------| +| 개별 전송 | 601 × 0.1ms = **60ms** | 601 × 1ms = **601ms** | +| Pipeline | 2 × 0.1ms = **0.2ms** | 2 × 1ms = **2ms** | + +**Pipeline은 네트워크 왕복을 줄이는 것이지 Redis 서버 처리 시간을 줄이는 것이 아니다.** +명령 자체의 처리 시간은 동일하지만, 300배 이상의 왕복 절감 효과가 있다. + +### 5.2 Pipeline 구성 + +``` +Pipeline 1 — Hash 갱신 + TTL + deltaMap의 각 productId에 대해: + HINCRBY ranking:metrics:{date}:{pid} viewCount {viewDelta} → 리턴: 갱신 후 값 + HINCRBY ranking:metrics:{date}:{pid} likeCount {likeDelta} → 리턴: 갱신 후 값 + HINCRBY ranking:metrics:{date}:{pid} salesCount {salesCountDelta} → 리턴: 갱신 후 값 + HINCRBY ranking:metrics:{date}:{pid} salesAmount {salesAmountDelta} → 리턴: 갱신 후 값 + EXPIRE ranking:metrics:{date}:{pid} 172800 + 명령 수: productId 수 × 5 + + ↓ 리턴값 수집 (productId당 4개 HINCRBY 리턴 = 전체 메트릭 상태) + +in-memory Score 계산 + HINCRBY 리턴값에서 viewCount, likeCount, salesCount, salesAmount 복원 + score = 0.1 × log₁₀(viewCount + 1) + 0.2 × log₁₀(likeCount + 1) + 0.7 × log₁₀(salesAmount + 1) + +Pipeline 2 — ZSET 갱신 + TTL + ZADD ranking:all:{date} {score} {productId} (× productId 수) + EXPIRE ranking:all:{date} 691200 ← ZSET: 8일 (주간 합산용) + 명령 수: productId 수 + 1 +``` + +### 5.3 HINCRBY 리턴값 활용 + +HINCRBY는 **증분 후의 새 값**을 리턴한다. 이를 활용하면 HGETALL 없이 전체 메트릭을 복원할 수 있다. + +``` +Pipeline 1 실행 결과 (productId=101의 경우): + results[0] = 505 ← viewCount (기존 500 + delta 5) + results[1] = 31 ← likeCount (기존 30 + delta 1) + results[2] = 6 ← salesCount (기존 5 + delta 1) + results[3] = 250000 ← salesAmount (기존 200000 + delta 50000) + results[4] = 1 ← EXPIRE 결과 (무시) +``` + +**Spring Data Redis `executePipelined()`의 리턴 순서는 명령 전송 순서와 동일하다.** +productId당 5개 명령(HINCRBY × 4 + EXPIRE)이므로, `results[i * 5]` ~ `results[i * 5 + 3]`이 i번째 상품의 메트릭이다. + +### 5.4 성능 산정 + +인기 상품 100개에 집중되는 3,000건 배치 기준: + +``` +Pipeline 1: 100 × 5 = 500 명령 + Redis 처리: HINCRBY O(1) ~1μs × 400 + EXPIRE O(1) ~1μs × 100 = ~0.5ms + 네트워크: 1 RTT ≈ 0.1ms (로컬) + 소계: ~0.6ms + +Score 계산: 100 × Math.log10() × 3 = 300회 부동소수점 연산 + 소계: ~0.01ms (무시 가능) + +Pipeline 2: 100 + 1 = 101 명령 + Redis 처리: ZADD O(log N) ~2μs × 100 + EXPIRE ~1μs = ~0.2ms + 네트워크: 1 RTT ≈ 0.1ms + 소계: ~0.3ms + +총 추가 비용: ~0.9ms / 배치 +``` + +기존 Phase 1+2(DB 멱등성 체크 + upsert)가 수십~수백ms인 것 대비 **1% 미만의 오버헤드**다. + +### 5.5 부분 실패 처리 + +Pipeline 내 개별 명령이 실패해도 나머지 명령은 정상 실행된다 (Redis Pipeline은 트랜잭션이 아니다). + +| 실패 시나리오 | 영향 | 대응 | +|-------------|------|------| +| HINCRBY 일부 실패 | 해당 상품의 score가 부정확 | 다음 배치에서 delta가 다시 적용되어 자연 보정 | +| ZADD 실패 | 해당 상품의 랭킹 미반영 | 다음 배치에서 새 score로 ZADD → 자연 보정 | +| EXPIRE 실패 | 키가 만료되지 않을 수 있음 | 다음 배치에서 EXPIRE 재시도 → 자연 보정 | +| Redis 전체 장애 | Phase 3 전체 스킵 | try-catch로 격리, Phase 2(DB)는 정상 커밋. WARN 로그 기록 | + +**모든 부분 실패는 "다음 배치에서 자연 보정"된다.** 랭킹은 best-effort 성격이므로, 일시적 부정확은 허용하고 복잡한 보상 로직은 추가하지 않는다. + +--- + +## 6. 메모리 산정 + +### 6.1 ZSET 메모리 + +Redis ZSET의 member당 오버헤드는 **skiplist 노드 + SDS 문자열**로 구성된다. + +``` +member 1개 = skiplist 노드(~40bytes) + SDS(productId 문자열, ~20bytes) + score(8bytes) + ≈ 68 bytes/member +``` + +| 시나리오 | 상품 수 | ZSET 메모리 | 비고 | +|---------|---------|------------|------| +| 현재 과제 | 5개 (시드 데이터) | ~340 bytes | 무시 가능 | +| 소규모 서비스 | 1,000개 | ~66 KB | 무시 가능 | +| 중규모 서비스 | 10,000개 | ~664 KB | 여유 | +| 대규모 서비스 | 100,000개 | ~6.5 MB | 충분히 수용 가능 | + +Daily ZSET은 TTL 8일이므로 최대 8개가 동시에 존재한다: +- 상품 10만 개 기준: **~52 MB** (= 6.5MB × 8일) + +### 6.2 Hash 메모리 + +상품별 Hash는 4개 필드(viewCount, likeCount, salesCount, salesAmount)를 저장한다. + +``` +Hash 1개 = 키 오버헤드(~60bytes) + 필드 4개 × (필드명 ~15bytes + 값 ~10bytes) + ≈ 160 bytes/상품 +``` + +| 시나리오 | 상품 수 | Hash 메모리 | 비고 | +|---------|---------|------------|------| +| 현재 과제 | 5개 | ~800 bytes | 무시 가능 | +| 소규모 | 1,000개 | ~156 KB | 무시 가능 | +| 중규모 | 10,000개 | ~1.6 MB | 여유 | +| 대규모 | 100,000개 | ~15.3 MB | 수용 가능 | + +### 6.3 총 메모리 (ZSET + Hash) + +상품 10만 개 기준, 주간/월간 랭킹 포함: + +``` +Daily ZSET × 8일 = 100,000 × 68B × 8 = ~52 MB +Daily Hash × 2일 = 100,000 × 160B × 2 = ~31 MB +Weekly ZSET × 2일 = 100,000 × 68B × 2 = ~13 MB +Monthly ZSET × 2일 = 100,000 × 68B × 2 = ~13 MB +합계: ~109 MB +``` + +Redis 인스턴스가 보통 1~16 GB 메모리를 할당받는 점을 감안하면, **전체 용량의 0.7~10.9%** 수준이다. Daily ZSET TTL 8일(주간 합산용)이 52MB로 가장 크지만, 이 중 6일분은 주간 합산 참조용으로만 존재하며 읽기 부하를 발생시키지 않는다. + +### 6.4 Carry-Over 시점 피크 메모리 + +23:50에 일간/주간/월간 carry-over가 모두 실행되면, 각각의 내일 키가 동시에 생성된다. + +``` +23:50 carry-over 실행 시 추가 키: + ranking:all:{tomorrow} → Daily carry-over (1개 추가) + ranking:weekly:{tomorrow} → 주간 랭킹 (1개 추가) + ranking:monthly:{tomorrow} → 월간 랭킹 (1개 추가) + → ZSET 3개 추가 = 100,000 × 68B × 3 = ~20 MB +``` + +``` +피크 메모리 (상품 10만 개 기준): + 정상 시: ~109 MB + 피크 시: ~129 MB (+20 MB, +18%) +``` + +**피크 메모리가 Redis 용량에 미치는 영향은 수용 가능하다.** 1GB Redis 기준 12.9%, 16GB 기준 0.8%. + +### 6.5 ZSET 크기 관리 전략 + +#### 6.5.1 문제 — Carry-Over에 의한 ZSET 크기 누적 + +ZSET의 member 수는 "오늘 이벤트가 발생한 상품 수"가 아니다. **Carry-over가 전체 ZSET을 복사**하므로, 한 번이라도 이벤트가 발생한 상품은 score가 `0.1^N`으로 감쇠될 뿐 ZSET에서 영원히 사라지지 않는다. + +``` +Day 1: 이벤트 발생 상품 10만 → ZSET member 10만 +Day 2: carry-over(10만) + 신규 이벤트 상품 → ZSET member ~11만 +Day 7: carry-over 누적 + 신규 → ZSET member ~15만 +... +Day 90: 서비스 시작 이후 이벤트가 1건이라도 있었던 전체 상품으로 수렴 +``` + +장기 운영 시 ZSET member 수 ≈ **이벤트가 발생한 적 있는 전체 상품 수**. "일간 활성 상품 수"가 아닌 "누적 활성 상품 수"가 메모리를 결정한다. + +#### 6.5.2 규모별 영향 분석 + +| 규모 | 누적 활성 상품 | 단일 ZSET | 8일분 Daily | Weekly+Monthly | Hash(2일) | **총합** | +|------|:---:|-------:|-------:|-------:|-------:|-------:| +| 소규모 | ~1만 | ~660KB | ~5MB | ~1.3MB | ~3MB | **~9MB** | +| 중규모 | ~10만 | ~6.5MB | ~52MB | ~13MB | ~31MB | **~96MB** | +| 대규모 (쿠팡급) | ~300만 | ~195MB | ~1.5GB | ~390MB | ~610MB | **~2.5GB** | +| 초대규모 | ~1000만 | ~650MB | ~5.2GB | ~1.3GB | ~1.5GB | **~8GB** | + +*(Hash는 carry-over로 복사되지 않으므로 일간 활성 상품 기준으로 산정)* + +**소~중규모에서는 전체 유지가 합리적**이다. 100MB 이하로 Redis 용량 대비 무시 가능하며, Trim의 복잡성이 메모리 절감보다 비용이 크다. + +**대규모 이상에서는 ZSET 크기 관리가 필수**이다. 2.5GB는 16GB Redis 기준 16% — 운영 여유를 감안하면 부담이 된다. 또한 주간 ZUNIONSTORE(300만 × 7키)가 수 초 블로킹을 유발할 수 있다. + +#### 6.5.3 전략 1 — Carry-Over 후 Trim (핵심) + +문제의 근원인 carry-over 시점에서 ZSET 크기를 제한한다. carry-over 직후 `ZREMRANGEBYRANK`로 **상위 N개만 유지**한다. + +``` +23:50 carry-over 흐름 (변경 후): + 1. ZUNIONSTORE ranking:all:{tomorrow} 1 ranking:all:{today} WEIGHTS 0.1 + 2. ZREMRANGEBYRANK ranking:all:{tomorrow} 0 -(N+1) ← Trim 추가 + 3. EXPIRE ranking:all:{tomorrow} 691200 +``` + +**N의 결정**: + +| N | 용도 | 메모리 (단일 ZSET) | 비고 | +|---|------|-------:|------| +| 100 | API 노출 범위만 | ~6.6KB | ZREVRANK 사실상 불가 — "순위" 기능 상실 | +| 1,000 | 최소 여유 | ~66KB | thrashing 가능 (경계 상품 반복 추가/제거) | +| **10,000** | **권장** | **~660KB** | Top 100 + ZREVRANK 여유 + thrashing 방지. 300만 → 1만으로 99.7% 감소 | +| 50,000 | 보수적 | ~3.3MB | 넓은 순위 범위 지원 | + +**N=10,000 권장 근거**: +- API는 Top 100만 노출하지만, 상품 상세에서 "이 상품은 현재 2,847위"를 보여주려면 ZREVRANK가 필요 +- 10,000위 밖의 상품은 "순위권 밖"으로 표시 — 실질적으로 2,847위든 50,000위든 유저에게 의미 없음 +- 경계 근처 상품의 thrashing 방지: 10,000위 근처의 score 차이는 매우 작으므로 이벤트 1건으로 순위가 크게 변동. N=100이면 심각하지만 N=10,000이면 경계가 넓어 완화됨 + +**Trim 후 메모리 효과** (대규모 기준): + +``` +변경 전: 300만 상품 × 68B × 8일 = ~1.5 GB +변경 후: 1만 상품 × 68B × 8일 = ~5.2 MB + +절감: 99.7% (1.5 GB → 5.2 MB) +``` + +**Trim과 일간 이벤트의 관계**: + +Trim은 carry-over 시점에만 실행한다. 일간 이벤트로 ZADD되는 상품은 trim 대상이 아니다. 하루 동안 이벤트가 발생한 상품이 10,000개를 초과하면 ZSET이 일시적으로 커지지만, 다음 carry-over에서 다시 trim된다. + +``` +23:50 carry-over: ZSET = 10,000 (trim 후) +00:00~23:49: 이벤트 유입으로 ZSET 증가 → 예: 15만 (일간 활성) +23:50 carry-over: ZUNIONSTORE + Trim → ZSET = 10,000 +``` + +이 패턴에서 **일간 중 ZSET 크기가 일시적으로 커지는 것은 허용**한다. carry-over만 trim하면 장기 누적이 방지되므로 충분하다. + +#### 6.5.4 왜 per-event Cap이 아닌 Carry-Over Trim인가 + +Capped ZSET을 구현하는 방식은 크게 두 가지다. 어느 시점에 cap을 적용하느냐가 핵심 차이다. + +**방식 A — per-event Cap**: ZADD마다 크기 확인 → N 초과 시 즉시 trim + +``` +이벤트 발생 시마다: + 1. ZADD ranking:all:{date} score productId + 2. ZCARD ranking:all:{date} ← 추가 + 3. if (size > N) ZREMRANGEBYRANK 0 -(N+1) ← 추가 +``` + +**방식 B — Carry-Over Trim**: 낮 동안은 전체 유지, 23:50 carry-over 시점에만 trim + +``` +이벤트 발생 시: ZADD만 (기존과 동일, 추가 비용 0) +23:50 carry-over: ZUNIONSTORE → ZREMRANGEBYRANK +``` + +**Carry-Over Trim을 선택한 근거:** + +| 관점 | per-event Cap | Carry-Over Trim (선택) | +|------|:-:|:-:| +| per-event 추가 비용 | ZCARD + ZREMRANGEBYRANK (매번) | **없음** | +| 일간 데이터 정확성 | 활성 상품 > N이면 점수 누락 | **전체 정확** | +| 경계 thrashing | 발생 (경계 상품 반복 추가/제거) | **없음** | +| Trim 비용 발생 시점 | 실시간 (피크 포함) | **오프피크 1회 (23:50)** | +| 메모리 일시 초과 | 없음 | 낮 동안 N 초과 가능 (허용) | + +**per-event Cap의 구체적 문제:** + +1. **쓰기 경로 비용 증가**: 초당 1,000 이벤트 기준, ZCARD + conditional ZREMRANGEBYRANK = 초당 Redis 커맨드 2,000개 추가. 이벤트 처리 레이턴시가 증가하고, Redis 단일 스레드 부하가 올라간다. + +2. **일간 데이터 누락**: 오늘 이벤트가 발생한 상품이 15,000개이고 N=10,000이면, 5,000개 활성 상품의 점수가 ZSET에서 빠진다. 이 중 하나가 바이럴을 타도 정확한 순위에 즉시 반영되지 못한다. + +3. **경계 thrashing**: N=10,000 경계의 상품이 이벤트를 받으면 ZADD → 진입 → 기존 10,000위 밀림 → 그 상품이 다시 이벤트 → 복귀 → 반복. 불필요한 ZREMRANGEBYRANK가 반복 실행된다. + +**Carry-Over Trim의 핵심 이점**: 쓰기 경로(per-event)의 성능을 보호하면서, carry-over라는 **이미 존재하는 배치 시점**에 trim을 끼워넣는다. 추가 복잡도가 `ZREMRANGEBYRANK` 1줄이며, 일간 데이터 정확성을 유지한다. + +#### 6.5.5 전략 2 — 카테고리별 ZSET 분리 (향후 확장) + +전략 1이 "크기 제한"이라면, 전략 2는 "수평 분산"이다. 전체 상품을 하나의 ZSET에 넣는 대신, 카테고리별로 ZSET을 분리한다. + +``` +현재: ranking:all:{date} ← 전체 상품 1개 ZSET +확장: ranking:category:{categoryId}:{date} ← 카테고리당 1개 ZSET +``` + +| 관점 | 단일 ZSET (현재) | 카테고리별 ZSET | +|------|:-:|:-:| +| 전체 랭킹 | ZREVRANGE 1회 | ZUNIONSTORE 후 ZREVRANGE 또는 앱 레벨 병합 | +| 카테고리 랭킹 | 불가 (전체에서 필터링 필요) | ZREVRANGE 1회 — **핵심 장점** | +| 메모리 | 전체 상품 × 1 | 전체 상품 × 1 (총량 동일, 분산됨) | +| ZUNIONSTORE 비용 | 대규모 ZSET 1개 | 소규모 ZSET 여러 개 (병렬 가능) | +| 운영 복잡도 | 낮음 | 카테고리 추가/변경 시 키 관리 필요 | + +**전략 1과 독립적으로 적용 가능**하다. 카테고리별 분리 후에도 각 ZSET에 carry-over 후 trim을 적용할 수 있다. + +**도입 시점**: "카테고리별 인기 상품" 요구사항이 발생했을 때. 단순히 메모리 절감을 위해 도입하는 것은 복잡도 대비 이점이 작다 — 전략 1(Trim)이 메모리 문제를 이미 해결하기 때문. + +#### 6.5.6 결정 + +**Carry-Over 후 Trim(N=10,000)을 규모와 무관하게 기본 적용한다.** + +| 결정 | 근거 | +|------|------| +| Trim을 기본 적용 | Carry-over가 ZSET을 무한히 키우는 구조적 부산물 → 규모와 무관한 위생 조치 | +| N=10,000 | API Top 100 + ZREVRANK 여유 + thrashing 방지 (6.5.3 참고) | +| Carry-Over 시점에만 | per-event 비용 0 유지, 오프피크 처리 (6.5.4 참고) | +| 카테고리별 ZSET 분리는 향후 | 메모리 문제는 Trim으로 해결, 카테고리 요구사항 발생 시 도입 (6.5.5 참고) | + +Trim은 "대규모에서만 필요한 최적화"가 아니라, **carry-over 구조의 본질적 부산물(무한 member 누적)을 관리하는 위생 조치**다. 구현 비용이 `ZREMRANGEBYRANK` 1줄이므로, 규모가 작더라도 적용하지 않을 이유가 없다. + +**적용 대상**: + +| Carry-Over 유형 | Trim 적용 | 이유 | +|------|:-:|------| +| Daily carry-over | **적용** | carry-over 누적의 주요 원인 | +| Monthly carry-over | **적용** | 동일한 carry-over 구조 (monthly × 0.97 + daily) | +| Weekly ZUNIONSTORE | 미적용 | carry-over가 아닌 7일 합산 재생성 — 누적 없음 | + +**코드 변경**: + +`RankingCarryOverScheduler`의 daily carry-over와 monthly carry-over에 Trim 추가: + +```java +private static final int CARRY_OVER_CAP = 10_000; + +private void doCarryOverDaily(LocalDate today, LocalDate tomorrow, double rate) { + // ... ZUNIONSTORE (기존) + + // Trim: 상위 N개만 유지 (carry-over에 의한 ZSET 크기 누적 방지) + Long zsetSize = writeTemplate.opsForZSet().zCard(tomorrowKey); + if (zsetSize != null && zsetSize > CARRY_OVER_CAP) { + writeTemplate.opsForZSet().removeRange(tomorrowKey, 0, -(CARRY_OVER_CAP + 1)); + log.info("Carry-over trim: {} → {} members", zsetSize, CARRY_OVER_CAP); + } + + // ... EXPIRE (기존) +} + +private void buildMonthlyRanking(LocalDate today, LocalDate tomorrow) { + // ... ZUNIONSTORE (기존) + + // Trim: 월간도 동일하게 적용 + Long size = writeTemplate.opsForZSet().zCard(tomorrowMonthlyKey); + if (size != null && size > CARRY_OVER_CAP) { + writeTemplate.opsForZSet().removeRange(tomorrowMonthlyKey, 0, -(CARRY_OVER_CAP + 1)); + log.info("Monthly trim: {} → {} members", size, CARRY_OVER_CAP); + } + + // ... EXPIRE (기존) +} +``` + +--- + +## 7. API 설계 + +### 7.1 랭킹 Page 조회 + +``` +GET /api/v1/rankings?date={yyyyMMdd}&page={page}&size={size} +``` + +| 파라미터 | 타입 | 기본값 | 설명 | +|---------|------|--------|------| +| date | String | 오늘 (KST) | 조회 대상 날짜. 생략 시 오늘 | +| page | int | 0 | 0-based 페이지 번호 | +| size | int | 20 | 페이지당 항목 수 | + +**페이지네이션 → ZREVRANGE 오프셋 변환**: + +``` +start = page × size +end = start + size - 1 + +예: page=0, size=20 → ZREVRANGE ranking:all:20260410 0 19 WITHSCORES (1~20위) + page=2, size=20 → ZREVRANGE ranking:all:20260410 40 59 WITHSCORES (41~60위) +``` + +**Top 100 제한**: API 레벨에서 `start + size`가 100을 초과하면 100으로 cap. +ZSET 자체는 전체를 유지하되(개별 순위 조회용), 목록 API는 100위까지만 노출한다. + +**응답 구조** (기존 `PagedProductResponse` 패턴 준수): + +```json +{ + "meta": { "result": "SUCCESS" }, + "data": { + "data": [ + { + "rank": 1, + "productId": 101, + "productName": "상품A", + "brandName": "브랜드X", + "price": 50000, + "score": 4.61 + } + ], + "totalElements": 100, + "totalPages": 5, + "page": 0, + "size": 20 + } +} +``` + +**상품 정보 Aggregation 흐름**: + +``` +1. ZREVRANGE ranking:all:{date} start end WITHSCORES + → [(productId, score), ...] 목록 + +2. productId 목록으로 DB IN 쿼리 + → SELECT * FROM product WHERE id IN (101, 202, 303, ...) + → Brand 정보도 함께 조회 (기존 ProductWithBrand 패턴) + +3. Redis 순서(score 내림차순) 유지하며 상품 정보와 병합 + → rank = start + index + 1 (1-based 순위) + +4. ApiResponse 반환 +``` + +**totalElements 결정**: +- `ZCARD ranking:all:{date}` = ZSET 전체 상품 수 +- `min(ZCARD, 100)` = API에서 노출하는 총 항목 수 +- `totalPages = ceil(totalElements / size)` + +### 7.2 상품 상세 조회 시 랭킹 정보 추가 + +기존 `GET /api/v1/products/{productId}` 응답에 랭킹 정보를 추가한다. + +```json +{ + "meta": { "result": "SUCCESS" }, + "data": { + "id": 101, + "brandId": 1, + "brandName": "브랜드X", + "name": "상품A", + "price": 50000, + "stockQuantity": 100, + "likeCount": 30, + "ranking": { + "rank": 3, + "score": 4.28, + "date": "20260410" + } + } +} +``` + +- 랭킹 미진입 상품(ZSET에 없는 경우): `"ranking": null` +- 조회 대상 날짜: 항상 오늘(KST) + +**Redis 조회**: + +``` +ZREVRANK ranking:all:{today} {productId} → 순위 (0-based, null이면 미진입) +ZSCORE ranking:all:{today} {productId} → 점수 +``` + +**아키텍처**: CLAUDE.md의 "여러 도메인의 정보 조합은 Application Layer에서 처리" 규칙에 따라, `ProductFacade`가 `RankingRedisRepository`를 호출하여 랭킹 정보를 조합한다. + +``` +ProductFacade.getProductDetailCached(productId) + ├── 기존: Product + Brand 조회 + └── 추가: RankingRedisRepository.getRankAndScore(today, productId) + → (rank, score) or null +``` + +### 7.3 Master-Replica 분리 + +| 연산 | 대상 | Template | +|------|------|----------| +| ZINCRBY, HINCRBY, ZADD, EXPIRE | 쓰기 (commerce-streamer) | `writeTemplate` (`@Qualifier("redisTemplateMaster")`) | +| ZREVRANGE, ZREVRANK, ZSCORE, ZCARD | 읽기 (commerce-api) | `readTemplate` (기본, Replica 우선) | + +기존 `WaitingQueueRedisRepository`와 동일한 패턴이다. + +### 7.4 레이어 구조 (commerce-api) + +``` +interfaces/api/ranking/ + └── RankingController — GET /api/v1/rankings + +application/ranking/ + └── RankingFacade — ZSET 조회 + DB 상품 조합 + +domain/ranking/ + └── (없음 — 별도 Entity/VO 불필요. Redis 조회 결과는 DTO로 직접 전달) + +infrastructure/ranking/ + └── RankingRedisRepository — ZREVRANGE, ZREVRANK, ZSCORE, ZCARD +``` + +**DTO 구조**: + +``` +interfaces/api/ranking/ + └── RankingDto + ├── RankingResponse — 개별 랭킹 항목 (rank, productId, productName, ...) + └── PagedRankingResponse — 페이지네이션 응답 +``` + +**domain 레이어가 비어있는 이유**: 랭킹 데이터는 Redis ZSET에서 읽어 상품 정보와 조합하는 조회 전용 기능이다. 별도의 비즈니스 규칙이나 상태 변경이 없으므로 Entity/VO를 만들지 않는다. + +### 7.5 Top-N 캐싱 트레이드오프 + +랭킹 Top-N 결과를 별도 캐싱(Redis String 또는 로컬 캐시)해야 하는가? + +#### 현재 구조의 성능 + +``` +ZREVRANGE ranking:all:{date} 0 19 WITHSCORES +→ O(log(N) + 20) ≈ O(log(100,000) + 20) ≈ O(37) +→ Redis 처리 시간: ~0.01ms +→ Replica 조회이므로 Master 부하 없음 +``` + +ZREVRANGE 자체가 O(log N + M)으로 충분히 빠르고, Replica에서 읽으므로 쓰기 경로에 영향이 없다. + +#### 캐싱 도입 시 얻는 것과 잃는 것 + +| 관점 | 캐싱 없음 (현재) | 캐싱 도입 | +|------|----------------|----------| +| 응답 지연 | ZREVRANGE ~0.1ms + DB IN 쿼리 ~5ms | 캐시 히트 시 ~0.1ms (DB 쿼리 스킵) | +| 실시간성 | 이벤트 반영 즉시 랭킹 변동 | **캐시 TTL(예: 10초) 동안 stale** | +| 구현 복잡도 | 단순 | 캐시 무효화 전략, TTL 산정, 페이지별 캐시 키 관리 | +| 메모리 | 없음 | 페이지당 캐시 엔트리 (Top 100 / 20개씩 = 5 페이지) | + +**결정: Top-N 캐싱은 현재 불필요.** + +근거: +- ZREVRANGE가 이미 O(log N + M)으로 충분히 빠르다 — ZSET이 300만 member여도 Top 20 조회는 O(log₂(300만) + 20) ≈ O(42), 서브밀리초 +- "실시간 랭킹"을 표방하면서 10초 TTL 캐시를 두면 실시간성이 퇴색된다 +- 병목은 Redis 조회가 아니라 DB IN 쿼리(상품 정보 조합) — 이는 상품 캐시(기존 Round 6 구현)로 이미 대응 중 +- **캐싱 도입 기준은 ZSET 크기가 아니라 QPS** — ZREVRANGE 자체는 빠르지만, Redis Replica 처리량(~10만 cmd/sec)에 접근하는 QPS에서 캐싱이 의미를 가진다 + +#### 캐싱 도입 기준 — QPS 기반 + +| 랭킹 페이지 QPS | Redis Replica 부하 | 판단 | +|---|---|---| +| ~1,000 | ~1% | 여유 | +| ~10,000 | ~10% | 충분 | +| 50,000+ | 50%+ | **캐싱 검토 시점** | + +ZSET은 "항상 최신 상태의 정렬된 캐시" 역할을 이미 하고 있다. 그 위에 별도 캐시를 올리는 것은 ZREVRANGE가 느려서가 아니라, **Redis에 요청이 너무 많이 몰릴 때** Redis 요청 자체를 줄이기 위함이다. + +**도입 시 설계 방향** (향후 참고): +- 캐시 기술: **Caffeine 로컬 캐시** 우선 — 기존 `CaffeineProductCacheAdapter` 패턴 재사용 가능, 레이턴시 ~0.01ms +- 캐시 대상: 상품 정보가 조합된 최종 응답 (Redis 조회 + DB 조회 결과를 함께 캐싱) +- TTL: 5~10초 (실시간성과 캐시 효율의 균형) +- 캐시 키: `ranking:cache:{date}:{page}:{size}` +- 무효화: TTL 기반 자연 만료 (이벤트 기반 무효화는 실시간 랭킹에서 너무 잦아 무의미) +- Redis String 캐시는 멀티 인스턴스 일관성이 필요할 때 검토 (Caffeine은 인스턴스별 독립 캐시) + +--- + +## 8. 콜드 스타트 대응 + +### 8.1 문제 + +일간 키가 전환되는 자정(KST)에 새 키(`ranking:all:{오늘}`)는 비어있다. + +| 시간 | 상태 | 유저 경험 | +|------|------|----------| +| 23:59 | `ranking:all:20260410`에 데이터 풍부 | "인기 상품" 정상 노출 | +| 00:00 | `ranking:all:20260411` 생성, 비어있음 | **"인기 상품" 텅 빔** | +| 00:01~02:00 | 이벤트가 서서히 유입 | 소수 상품만 노출, 편향된 랭킹 | +| 06:00~ | 충분한 이벤트 누적 | 정상 랭킹 | + +새벽 시간대에 유저가 적더라도 **랭킹이 비어있는 것 자체가 서비스 품질 문제**다. +또한 자정 직후 유입된 소수 이벤트가 랭킹을 지배하여 편향된 결과를 보여줄 수 있다. + +### 8.2 해결 — Score Carry-Over (ZUNIONSTORE) + +전날 랭킹의 일부를 새 키에 복사하여 초기 데이터를 확보한다. + +``` +ZUNIONSTORE ranking:all:20260411 1 ranking:all:20260410 WEIGHTS 0.1 +EXPIRE ranking:all:20260411 172800 +``` + +- `WEIGHTS 0.1`: 전날 score의 10%만 이월 +- 결과: 전날 1위(score 4.61) → 오늘 초기 score 0.461 + +**10%인 이유**: + +``` +전날 1위 carry-over: 0.1 × 4.61 = 0.461 +오늘 신규 이벤트 누적: 상품이 조회 100회 + 좋아요 5회 + 주문 5만원만 받아도 + 0.1×log₁₀(101) + 0.2×log₁₀(6) + 0.7×log₁₀(50001) + = 0.20 + 0.16 + 3.29 = 3.65 + +→ 오늘의 실제 인기(3.65)가 carry-over(0.461)를 빠르게 역전 +→ carry-over가 랭킹을 고착시키지 않으면서, 새벽에는 빈 랭킹을 방지 +``` + +만약 carry-over를 50%로 잡으면: +``` +전날 1위 carry-over: 0.5 × 4.61 = 2.305 +→ 오늘 실제 이벤트가 상당히 쌓여야 역전 가능 → 어제 인기 상품이 오늘도 상위 고착 +``` + +**10%는 "빈 랭킹 방지"와 "오늘 데이터로 빠른 역전"의 균형점이다.** + +#### 업계 검증 — Carry-Over는 일반적 패턴인가? + +ZUNIONSTORE WEIGHTS를 이용한 score carry-over는 다음과 같은 업계 사례에서 검증된 패턴이다: + +| 사례 | 방식 | 비율/감쇠 | 출처 | +|------|------|----------|------| +| Reddit Hot Ranking | 시간 감쇠 함수(gravity)로 오래된 게시물 score 자연 감소 | 시간 경과에 따라 지수적 감쇠 | [medium.com](https://medium.com/hacking-and-gonzo/how-reddit-ranking-algorithms-work-ef111e33d0d9) | +| Hacker News | `score / (T+2)^gravity` — 경과 시간에 비례한 감쇠 | gravity=1.8 | [medium.com](https://medium.com/hacking-and-gonzo/how-hacker-news-ranking-algorithm-works-1d9b0cf2c08d) | +| **ZUNIONSTORE WEIGHTS 패턴** | 전날 ZSET을 가중치 곱하여 새 키에 이월 | 0.1~0.3이 일반적 | [redis.io](https://redis.io/docs/latest/develop/data-types/sorted-sets/) | + +우리의 carry-over는 Reddit/HN의 시간 감쇠를 **이산적(일 단위)**으로 구현한 것이다. 연속적 감쇠(매 요청마다 score를 시간 함수로 재계산)는 Redis ZSET 구조에서 비효율적이고(모든 member의 score를 갱신해야 함), 일 단위 감쇠가 랭킹 특성에 적합하다. + +#### 콜드 스타트 레퍼런스 — 시간 윈도우 분리와 이월의 근거 + +초기 조사에서는 주요 레퍼런스 3건 모두 콜드 스타트를 직접 다루지 않았으나, 추가 조사로 이 공백이 해소되었다: + +| 레퍼런스 | 콜드 스타트 관련 인사이트 | 출처 | +|---------|------------------------|------| +| systemdesign.one | 시간 윈도우별 별도 ZSET이 표준. "A new sorted set for the leaderboard can be created for different time ranges." 시간 윈도우 분리 자체가 롱테일 방지이며, ZUNIONSTORE + WEIGHTS로 이전 기간 점수를 감쇠 반영하는 것은 자연스러운 확장 | [systemdesign.one](https://systemdesign.one/leaderboard-system-design/) | +| 엠넷플러스 (AWS) | MAU 2,000만 규모에서 실시간(ElastiCache) + 원장(DynamoDB) 이중 집계 운용. 원장 기반 재집계로 정합성 복구 가능 → 배치 보정으로 cold start 누적 오차도 함께 교정 | [aws.amazon.com](https://aws.amazon.com/ko/blogs/tech/mnetplus-real-time-global-voting-system-architecture-improvement/) | +| Amazon Dataset Transfer | 데이터가 풍부한 소스에서 학습한 모델을 신규 마켓에 transfer. 자체 데이터 약 2주치가 쌓일 때까지 transfer가 유의미 → carry-over는 이 "dataset transfer"의 단순화 버전 | [amazon.science](https://www.amazon.science/publications/addressing-cold-start-with-dataset-transfer-in-e-commerce-learning-to-rank) | + +**결론**: ZUNIONSTORE carry-over는 업계에서 검증된 시간 감쇠 + 시간 윈도우 이월 패턴이다. 10% 비율은 "빈 랭킹 방지"와 "당일 데이터 빠른 역전"의 균형점이며, 향후 A/B 테스트로 최적화 가능하다. + +#### 아이템 레벨 콜드 스타트 — 신규 상품 노출 전략 + +콜드 스타트는 두 가지 레벨로 구분된다: + +| 레벨 | 문제 | 해결 | +|------|------|------| +| **시스템 레벨** | 일간 키 전환 시 ZSET이 비어있음 | carry-over (위 8.2절) | +| **아이템 레벨** | 신규 상품이 ZSET에 없음 → 랭킹 미노출 → 이벤트 없음 → 순환 | 아래 분석 | + +현재 신규 상품의 랭킹 진입 경로: + +``` +상품 등록 (ProductFacade.createProduct) + → Kafka 이벤트 없음, 캐시 무효화만 → ZSET에 미존재 + +누군가 상품 상세 페이지 방문 + → PRODUCT_VIEWED → Kafka → MetricsConsumer → ZADD + → score = 0.1×log₁₀(2) = 0.0301 — 기존 인기 상품 대비 매우 낮음, Top 100 진입 불가 +``` + +**검토한 방안 4가지:** + +| 방안 | 설명 | ZSET 순수성 | 실질 노출 효과 | 구현 복잡도 | +|------|------|:-----------:|:------------:|:-----------:| +| **1. 별도 신상품 API** | `GET /api/v1/products/new` — 인기 랭킹과 분리 | **유지** | 별도 영역 노출 | 낮음 | +| 2. API 블렌딩 | Top-N 중 K개를 신상품으로 대체 | 유지 | 혼합 노출 | 중간 | +| 3. 이벤트+주입 | PRODUCT_CREATED → ZADD(score=0) | 오염 | score 0이면 Top 100 미포함 | 낮음 | +| 4. Boosting | score에 시간 기반 가산점 | 약간 훼손 | 자연 진입 | 높음 | + +**선택하지 않은 방안과 이유:** + +- **방안 2 (블렌딩)**: "인기 랭킹 Top 20" 중 3개가 인기 없는 신상품이면 순위 의미 훼손. 유저가 "왜 이 상품이 17위 다음에?"라고 혼란 +- **방안 3 (이벤트+주입)**: score 0이면 MAX_RANKING_SIZE(100) 안에 안 들어서 실질 효과 없음. 배치 보정 Job에서 DB에 metrics 없는 상품 처리 문제도 발생 +- **방안 4 (Boosting)**: score 공식 복잡도 증가, 상품 등록일을 MetricsConsumer가 알아야 하므로 PRODUCT_CREATED 이벤트 + createdAt 필드 필요. 배치 보정과 동일 공식 유지 부담 + +**결정: 방안 1 — 별도 신상품 API.** + +Amazon, 쿠팡, Shopify 모두 "베스트셀러"와 "신상품"을 분리한다. "인기 랭킹"에 인기 없는 상품을 넣는 것은 정의에 반한다. ZSET 데이터 순수성을 유지하면서, 신상품은 독립된 API로 제공한다. + +``` +GET /api/v1/products/new?hours=48&size=20 + +구현: + Product 테이블에서 created_at >= now - 48h 조회 + 등록 순(최신 먼저) 정렬 + 기존 ProductFacade에 메서드 추가, 신규 컨트롤러 엔드포인트 1개 +``` + +**향후 고도화 (현재 범위 밖):** + +| 전략 | 설명 | 적용 시점 | +|------|------|----------| +| 카테고리 중위값 초기 점수 | 해당 카테고리 ZSET 중위값을 신규 상품 초기 score로 부여 | 카테고리별 랭킹 도입 시 | +| Dynamic Prior Thompson Sampling | 기존 승자 성능 분포 기반으로 신규 아이템 탐색 확률 제어 ([arXiv:2602.00943](https://arxiv.org/abs/2602.00943)) | 개인화 랭킹 도입 시 | +| Contextual-Bandit UCB | 데이터가 적은 아이템에 "탐색 보너스" 부여 ([ResearchGate](https://www.researchgate.net/publication/262732636)) | 노출 공정성 최적화 시 | + +### 8.3 Hash Carry-Over는? + +ZUNIONSTORE는 ZSET만 복사한다. 전날의 Hash(개별 메트릭)는 이월하지 않는다. + +| 대안 | 장점 | 단점 | +|------|------|------| +| Hash도 복사 | score 재계산 가능 | 상품별 Hash 복사 = N개 키 생성, ZUNIONSTORE의 단순함 상실 | +| Hash 미복사 | 단순, 빠름 | carry-over 상품의 개별 메트릭 조회 불가 | + +**결정: Hash는 미복사.** + +- Carry-over는 임시 초기값일 뿐이다. 오늘 이벤트가 들어오면 Hash가 자연스럽게 생성된다 +- Carry-over 상품에 오늘 이벤트가 전혀 없으면, ZADD가 발생하지 않아 carry-over score가 유지된다 +- 이 경우 Hash가 없어서 score 재계산이 불가하지만, carry-over score 자체가 충분히 의미있는 값이다 + +### 8.4 실행 시점 — 스케줄러 + +| 대안 | 설명 | 문제 | +|------|------|------| +| 자정 정각 (00:00) | 날짜 전환 즉시 실행 | API가 새 키를 조회하는 시점과 경합 가능, 극히 짧은 빈 구간 발생 | +| 23:50 (전날) | 미리 다음 날 키 생성 | **경합 없음**. 자정이 되면 이미 데이터가 있는 키를 조회 | +| 첫 요청 시 Lazy | API가 빈 키를 감지하면 그때 carry-over | 첫 요청 지연, 동시 요청 시 중복 실행 위험 | + +**결정: 23:50 KST에 스케줄러로 사전 생성.** + +```java +@Scheduled(cron = "0 50 23 * * *", zone = "Asia/Seoul") +public void carryOverRanking() { + String today = todayKey(); // "20260410" + String tomorrow = tomorrowKey(); // "20260411" + + // ZUNIONSTORE ranking:all:20260411 1 ranking:all:20260410 WEIGHTS 0.1 + // EXPIRE ranking:all:20260411 172800 +} +``` + +**23:50인 이유**: +- 자정 전 10분 여유 → 네트워크/Redis 지연이 있어도 충분 +- 23:50~00:00 사이 10분간 오늘 키에 계속 이벤트가 쌓이지만, carry-over 비율이 10%라 그 차이는 무시 가능 +- 만약 23:50에 실패하면 00:00에 재시도하는 fallback 스케줄도 추가 가능 + +### 8.5 Carry-Over 후 이벤트가 들어오면? + +``` +23:50 — ZUNIONSTORE로 다음 날 키 생성 + ranking:all:20260411 = { 101: 0.461, 202: 0.428, ... } + +00:00 — 날짜 전환. MetricsConsumer가 dateKey="20260411"로 전환 + +00:05 — 상품 101에 조회 이벤트 발생 + Phase 3: HINCRBY → Hash 생성 → score 재계산 → ZADD + ranking:all:20260411 의 101 score가 carry-over(0.461) → 새 score(예: 0.561)로 덮어씀 +``` + +ZADD는 **기존 score를 무조건 덮어쓴다.** Carry-over로 생성된 score든 이전 배치의 score든, 새 score로 대체된다. Metric 기반 접근이므로 항상 Hash 전체 상태에서 재계산한 값이 ZADD되어 정합성이 유지된다. + +단, carry-over로만 존재하고 오늘 이벤트가 없는 상품은 carry-over score가 그대로 유지된다. 이는 의도된 동작이다 — 어제 인기 있었던 상품이 오늘 새벽에도 일정 순위를 유지하는 것이 UX상 자연스럽다. + +--- + +## 9. 동점 처리 — Composite Score 구조 + +### 9.1 동점이 발생하는 경우 + +baseScore가 `W×log₁₀/MAX_LOG` 기반이므로, 유사한 메트릭 조합은 **실질적 동점권**(score 차이 < 0.001)을 형성한다. + +| 시나리오 | 가능성 | 설명 | +|---------|--------|------| +| 초기 (이벤트 적음) | **높음** | 조회 1회, 좋아요 0건, 주문 0건인 상품 다수 → 모두 baseScore ≈ 0.004 | +| carry-over 직후 | **높음** | 전날 동점이었던 상품들이 동일 비율로 이월 → 동점 유지 | +| 일과 시간 | **낮음** | 이벤트가 누적될수록 메트릭 조합이 분화 | + +### 9.2 Composite Score — 자릿수 기반 관심사 분리 + +score를 IEEE 754 double의 유효 15자리 안에서 **세 구간으로 분리**한다: + +``` +score = [categoryPriority] + [baseScore] + [tiebreaker] + ← 정수부 (0~9) → ← 소수 1~6 → ← 소수 7~15 → +``` + +| 구간 | 자릿수 | 값 범위 | 의미 | +|------|--------|---------|------| +| 정수부 | 1자리 | 0~9 | 카테고리 우선순위 (높을수록 상위) | +| 소수 1~6자리 | 6자리 | 0.000000~0.999999 | 주 score (가중치 × 정규화 메트릭) | +| 소수 7~15자리 | 9자리 | ~1e-7 | tiebreaker (최근 활동 우선) | + +**구간 간 간섭 불가**: categoryPriority 차이(1.0)는 baseScore 최대값(1.0)과 같은 크기이지만 정수부에 위치하므로 역전 불가. tiebreaker(~1e-7)는 baseScore 최소 유의미 차이(~0.004)의 0.0025%에 불과하여 역전 불가. + +### 9.3 Tiebreaker — 최근 활동 우선 (timestamp 기반) + +| 대안 | 구현 | 장점 | 단점 | +|------|------|------|------| +| 아무것도 안 함 (ZSET 기본) | 변경 없음 | 단순 | 사전식 순서 — 비즈니스 의미 없음 | +| productId × ε | `score += productId × 1e-10` | 신상품 우선, 결정론적 | **비즈니스 의미 약함** — 등록 순서가 인기와 무관 | +| **lastEventAt × ε** | `score += epochSeconds × 1e-16` | **최근 활동 상품 우선**, 비즈니스 의미 명확 | Hash에 lastEventAt 필드 추가 필요 | +| salesCount × ε | `score += salesCount × 1e-8` | 매출 기반 | 주 score와 같은 시그널 이중 반영 | + +**결정: lastEventAt(마지막 이벤트 epoch seconds)를 tiebreaker로 사용한다.** + +근거: +- 같은 인기도(baseScore)라면 **최근까지 활발한 상품**이 상위에 오는 것이 자연스럽다 +- productId 기반은 "등록 순서"일 뿐, "활동 수준"과 무관하다 +- lastEventAt는 주 score(조회/좋아요/매출)와 **다른 차원**의 보정이라 정보가 중복되지 않는다 +- Hash에 `lastEventAt` 필드 1개 추가 — 기존 HINCRBY pipeline에 HSET 1건 추가, 성능 영향 무시 가능 + +**Hash 필드 확장**: + +``` +ranking:metrics:{date}:{productId} + viewCount: "150" + likeCount: "30" + salesCount: "5" + salesAmount: "200000" + lastEventAt: "1712952000" ← 신규: epoch seconds +``` + +### 9.4 Tiebreaker 스케일 산정 + +**주 score의 최소 유의미 차이** (0~1 정규화 후): + +``` +가장 작은 변화: viewCount 0→1 +기여 변화: 0.1 × log₁₀(2) / 7 = 0.1 × 0.301 / 7 = 0.0043 +``` + +**epoch seconds의 현실적 범위**: ~1,700,000,000 (10자리) + +**스케일 검증**: + +| scale | epochSec=1,712,952,000일 때 | 주 score 최소 차이(0.0043) 대비 | 안전성 | +|-------|---------------------------|-------------------------------|--------| +| 1e-14 | 0.01713 | 398% | **위험** — 주 score 역전 가능 | +| 1e-15 | 0.001713 | 39.8% | 위험 | +| **1e-16** | **0.0001713** | **3.98%** | **안전** — 주 score 차이의 25분의 1 | +| 1e-17 | 0.00001713 | 0.4% | 과잉 안전, 정밀도 낭비 | + +**결정: scale = 1e-16** + +- epoch seconds × 1e-16은 소수 7~16자리에 위치 → 주 score(소수 1~6자리)와 간섭 없음 +- 1초 차이(1e-16) < 주 score 최소 차이(0.004)이므로 tiebreaker가 주 score를 역전 불가 +- IEEE 754 double 유효 15자리 안에 categoryPriority(1) + baseScore(6) + tiebreaker(8) = 15자리 적합 + +### 9.5 Category Priority — 카테고리 우선순위 인코딩 + +score의 정수부에 카테고리 우선순위를 배치하여, **같은 ZSET 안에서 카테고리별 자연 그룹화**를 달성한다. + +``` +score = categoryPriority + baseScore + tiebreaker + +// 패션(priority=3) 상품 A: 3 + 0.611400 + tiebreaker = 3.611400... +// 전자(priority=2) 상품 B: 2 + 0.750000 + tiebreaker = 2.750000... + +→ 패션 A(3.611) > 전자 B(2.750) — 카테고리 우선순위가 지배 +``` + +**전제 조건**: Product 엔티티에 `categoryId` 추가, 카테고리별 우선순위 매핑 설정. + +**카테고리 우선순위 매핑 (설정 기반)**: + +```yaml +ranking: + category-priority: + 1: 3 # 패션 → priority 3 (최상위) + 2: 2 # 전자제품 → priority 2 + 3: 1 # 생필품 → priority 1 + default: 0 # 미분류 → priority 0 +``` + +**트레이드오프**: + +| 기준 | Priority 인코딩 (현재 설계) | 카테고리별 별도 ZSET | +|------|:------------------------:|:-------------------:| +| 글로벌 랭킹 | 자연스러움 (단일 ZSET) | ZUNIONSTORE 필요 | +| 카테고리별 랭킹 | ZRANGEBYSCORE로 범위 조회 | 자연스러움 (별도 ZSET) | +| 카테고리별 가중치 | 불가 (단일 공식) | **가능** (ZSET마다 다른 공식) | +| 메모리 | 1배 | 카테고리 수 × N배 | + +**결정**: 현재는 priority 인코딩으로 단일 ZSET 유지. 카테고리별 가중치가 필요한 시점에 별도 ZSET 확장. + +### 9.6 최종 Composite Score 수식 + +``` +MAX_LOG = 7 +TIEBREAKER_SCALE = 1e-16 + +score(p) = categoryPriority + + W(view) × log₁₀(viewCount + 1) / MAX_LOG + + W(like) × log₁₀(likeCount + 1) / MAX_LOG + + W(order) × log₁₀(salesAmount + 1) / MAX_LOG + + lastEventEpochSeconds × TIEBREAKER_SCALE +``` + +**검증 — 동점 시 최근 활동 우선**: + +``` +Product 101: view=1, like=0, salesAmount=0, lastEventAt=1712952000 (14:00) + baseScore = 0.1 × log₁₀(2)/7 = 0.0043 + tiebreaker = 1712952000 × 1e-16 = 0.0000001713 + 최종: 0.0043001713 + +Product 202: view=1, like=0, salesAmount=0, lastEventAt=1712955600 (15:00) + baseScore = 0.1 × log₁₀(2)/7 = 0.0043 + tiebreaker = 1712955600 × 1e-16 = 0.0000001713 + 최종: 0.0043001713 + +→ 202(15:00 활동) > 101(14:00 활동) — 최근 활동 상품 우선 ✓ +``` + +**검증 — tiebreaker가 주 score를 역전시키지 않는가?**: + +``` +Product 101: view=2, like=0, salesAmount=0, lastEventAt=1712900000 (오래전) + 최종: 0.0068 + 0.0000001713 = 0.0068001713 + +Product 202: view=1, like=0, salesAmount=0, lastEventAt=1712999999 (최근) + 최종: 0.0043 + 0.0000001713 = 0.0043001713 + +→ 101(0.0068) > 202(0.0043) ✓ — 최근 활동이어도 주 score가 높은 쪽이 상위 +``` + +--- + +## 10. A/B 테스트 — 가중치 실험 + +### 10.1 목적 + +현재 가중치(view 0.1, like 0.2, order 0.7)는 도메인 직관 기반이다. 실제로 어떤 가중치가 더 높은 구매 전환률을 내는지 **데이터로 검증**하기 위해, 서로 다른 가중치 세트를 적용한 랭킹 2개를 동시 운영하고 결과를 비교한다. + +### 10.2 구조 + +``` +[MetricsConsumer — 동일 이벤트를 2개 ZSET에 이중 쓰기] + +이벤트 수신 → deltaMap 집계 (기존 동일) + ├── Pipeline A: ranking:exp:A:{date} — 가중치 A (0.1/0.2/0.7) + └── Pipeline B: ranking:exp:B:{date} — 가중치 B (0.2/0.3/0.5) + +[RankingFacade — 유저 그룹별 라우팅] + +유저 요청 → memberId % 2 == 0 → ranking:exp:A:{date} 조회 + == 1 → ranking:exp:B:{date} 조회 +``` + +### 10.3 설정 + +```yaml +ranking: + experiment: + enabled: false # 기본 비활성 + variants: + A: + weights: { view: 0.1, like: 0.2, order: 0.7 } + zset-prefix: "ranking:exp:A:" + B: + weights: { view: 0.2, like: 0.3, order: 0.5 } + zset-prefix: "ranking:exp:B:" +``` + +`experiment.enabled=false`이면 기존 단일 ZSET(`ranking:all:{date}`) 동작 — **기존 로직 영향 없음**. + +### 10.4 비교 지표 + +2주 운영 후 그룹 A vs B를 비교한다: + +| 지표 | 측정 방법 | 의미 | +|------|----------|------| +| 랭킹 → 상품 상세 CTR | 랭킹 페이지 조회 수 대비 상품 클릭 수 | 랭킹이 유저 관심을 얼마나 반영하나 | +| 랭킹 → 구매 전환률 | 랭킹 경유 상품 상세 → 주문 완료 비율 | 랭킹이 매출에 기여하는 정도 | +| 랭킹 다양성 | Top 10 내 고유 브랜드/카테고리 수 | 랭킹의 편향도 | + +### 10.5 비용과 전제 조건 + +| 항목 | 비용 | +|------|------| +| Redis 메모리 | ZSET + Hash가 2배 (실험 기간 동안) | +| MetricsConsumer 쓰기 | Pipeline 2회 → 처리 시간 ~2배 | +| 구현 복잡도 | RankingScoreUpdater 분기, RankingFacade 라우팅 | + +**전제 조건**: 유의미한 통계 차이 검출을 위해 그룹별 최소 1,000명 이상의 랭킹 조회 필요. + +### 10.6 향후: 카테고리별 가중치 A/B 테스트 + +카테고리 체계 확립 후, 카테고리별 ZSET을 분리하여 카테고리마다 다른 가중치를 실험할 수 있다: + +``` +ranking:category:fashion:A:{date} — like 가중치 높은 실험군 +ranking:category:fashion:B:{date} — order 가중치 높은 대조군 +``` + +--- + +## 11. 장애 시나리오 + +### 10.1 장애 분류 + +랭킹 시스템의 장애 포인트는 **쓰기 경로(Consumer → Redis)**와 **읽기 경로(API → Redis)**로 나뉜다. + +``` +쓰기 경로: + Kafka → MetricsConsumer → [Phase 1: DB 멱등성] → [Phase 2: DB upsert] → [Phase 3: Redis 적재] + ↑ 장애 포인트 + +읽기 경로: + 유저 → RankingController → RankingFacade → [RankingRedisRepository → Redis Replica] + ↑ 장애 포인트 +``` + +### 10.2 쓰기 경로 장애 + +#### Redis 장애 시 — Phase 3 실패 + +| 항목 | 설명 | +|------|------| +| 영향 범위 | 해당 배치의 랭킹 갱신만 유실. DB(product_metrics)는 정상 커밋됨 | +| 유저 영향 | 랭킹이 수 초~수십 초 지연 반영. 기존 데이터로 조회 가능 | +| 대응 | Phase 3를 try-catch로 격리. WARN 로그 기록. 다음 배치에서 자연 복구 | +| 재시도 | 불필요. 다음 배치의 HINCRBY가 누적 delta를 반영하고, score 재계산이 Hash 전체 상태 기반이므로 정합성 유지 | + +```java +// MetricsConsumer.consume() 내 +try { + rankingScoreUpdater.update(dateKey, deltaMap); +} catch (Exception e) { + log.warn("랭킹 Redis 적재 실패 — 다음 배치에서 자연 복구됨", e); +} +ack.acknowledge(); +``` + +**핵심**: Phase 3 실패가 Phase 1~2에 전파되지 않는다. `ack.acknowledge()`는 Phase 3 성공 여부와 무관하게 호출된다. + +#### Kafka Consumer 재시작 / 리밸런싱 + +| 항목 | 설명 | +|------|------| +| 영향 | 리밸런싱 중 이벤트 처리 지연 (수 초~수십 초) | +| 데이터 정합성 | event_handled 멱등성 체크로 중복 처리 방지. 같은 이벤트를 다시 받아도 INSERT IGNORE로 스킵 | +| Redis 정합성 | 멱등성 체크를 통과한 이벤트만 deltaMap에 포함되므로, Redis에도 중복 반영되지 않음 | + +#### Redis 장애 복구 후 데이터 정합성 + +Redis가 복구되면 Hash/ZSET이 유실되었을 수 있다. + +| 시나리오 | 결과 | 대응 | +|---------|------|------| +| Hash 유실, ZSET 유실 | 빈 랭킹 | 이벤트가 계속 들어오므로 Hash/ZSET이 자연 재생성. 복구 직후 수 분간 랭킹이 부정확 | +| Hash 유실, ZSET 잔존 | ZSET의 score가 오래된 값 | 새 이벤트의 HINCRBY로 Hash 재생성 → score 재계산 → ZADD로 ZSET 갱신. 단, 장애 전 누적분은 유실 | +| Hash 잔존, ZSET 유실 | 랭킹 목록 없음 | 새 이벤트의 score 재계산 → ZADD로 ZSET 재생성 | + +**모든 경우 "새 이벤트가 들어오면 자연 복구"된다.** Metric 기반 접근의 장점 — Hash가 SSOT이므로, Hash만 있으면 score를 언제든 재계산할 수 있다. + +단, Hash까지 유실된 경우 장애 전 누적 메트릭이 유실된다. 이 경우 **배치 보정 잡**(섹션 2.5 Lambda Architecture)이 `product_metrics`의 일별 데이터를 기반으로 Hash/ZSET을 재구축한다. 배치가 1시간 주기이므로, 최대 1시간 이내에 정확한 랭킹으로 복구된다. + +### 10.3 읽기 경로 장애 + +#### Redis Replica 장애 시 — API 조회 실패 + +| 대안 | 설명 | 적합성 | +|------|------|--------| +| 빈 응답 반환 | `data: []`, totalElements: 0 | 단순하지만 UX 저하 | +| **에러 응답** | 503 Service Unavailable + 적절한 메시지 | **명확** — 클라이언트가 재시도 판단 가능 | +| DB fallback | product_metrics에서 오늘 날짜 조회 + score 계산 | 가능하나 실시간 요청마다 GROUP BY + score 계산은 부하 | +| 로컬 캐시 fallback | 마지막 성공 응답을 캐시해서 반환 | 구현 복잡도 증가 | + +**결정: Redis 조회 실패 시 에러 응답(503)을 반환한다.** + +근거: +- DB fallback은 가능하나(product_metrics에 일별 데이터 존재), 실시간 요청마다 score 계산 + 정렬은 부하가 크다 +- 로컬 캐시는 현재 요구사항 대비 과도한 복잡도 +- 랭킹은 핵심 비즈니스(주문/결제)가 아니므로, 일시적 503은 허용 가능 + +```java +// RankingFacade.getRankings() 내 +try { + return rankingRedisRepository.getTopN(date, start, end); +} catch (Exception e) { + log.error("랭킹 Redis 조회 실패", e); + throw new CoreException(ErrorType.INTERNAL_ERROR, "랭킹 서비스를 일시적으로 이용할 수 없습니다."); +} +``` + +#### 상품 상세의 랭킹 정보 — 부분 장애 허용 + +상품 상세 API(`GET /api/v1/products/{productId}`)에서 랭킹 정보 조회가 실패하면, **상품 정보는 정상 반환하고 랭킹만 null로** 처리한다. + +```java +// ProductFacade 내 +ProductRanking ranking = null; +try { + ranking = rankingRedisRepository.getRankAndScore(today, productId); +} catch (Exception e) { + log.warn("상품 {} 랭킹 조회 실패 — ranking=null로 응답", productId, e); +} +``` + +상품 상세는 핵심 기능이므로, 부가 정보(랭킹) 실패가 전체 응답을 실패시키면 안 된다. + +### 10.4 장애 대응 요약 + +| 장애 | 쓰기/읽기 | 영향 | 대응 | 복구 | +|------|----------|------|------|------| +| Redis Master 장애 | 쓰기 | 랭킹 갱신 중단 | Phase 3 try-catch 격리, DB 정상 | 복구 후 자연 재생성 | +| Redis Replica 장애 | 읽기 | 랭킹 API 503 | 에러 응답 | Replica 복구 시 즉시 정상화 | +| Consumer 재시작 | 쓰기 | 수 초 지연 | 멱등성으로 중복 방지 | 자동 | +| Hash/ZSET 유실 | 쓰기+읽기 | 일간 데이터 유실 | 이벤트 유입으로 점진 재생성. product_metrics 기반 배치 보정으로 정합성 복구 (섹션 2.5 Lambda Architecture) | 수 분~수 시간 | +| 상품 상세 랭킹 조회 실패 | 읽기 | 랭킹 필드 null | try-catch, 상품 정보는 정상 반환 | 자동 | + +--- + +## 12. 클래스 설계 + +### 11.1 전체 구조 + +``` +[commerce-streamer] — 쓰기 경로 + interfaces/consumer/ + └── MetricsConsumer — (수정) Phase 2: product_metrics에 metric_date + 취소 분리 + Late-Arriving Fact 이중 기록 + Phase 3 추가, MetricsDelta 외부 참조로 변경 + ORDER_CANCELLED: originalOrderDate 기반 발생일 UPSERT 추가 + application/ranking/ + ├── MetricsDelta — (기존 inner class → 추출 완료) 이벤트→메트릭 의미 정의 중앙화 + ├── RankingScoreUpdater — (구현 완료) Pipeline HINCRBY → score 계산 → ZADD + ├── RankingCarryOverScheduler — (구현 완료) 23:50 ZUNIONSTORE carry-over + └── RankingProperties — (구현 완료) 가중치/carryOverRate 외부화 (Semantic Definition) + +[commerce-batch] — 배치 보정 경로 (Lambda Architecture) + application/ranking/ + └── RankingCorrectionJobConfig — (신규) 1시간 주기 배치 보정 잡 + Step 1: product_metrics SELECT (오늘 날짜) + Step 2: score 재계산 + Step 3: Redis Hash/ZSET 덮어쓰기 + +[commerce-api] — 읽기 경로 + interfaces/api/ranking/ + ├── RankingController — (신규) GET /api/v1/rankings + └── RankingDto — (신규) RankingResponse, PagedRankingResponse + application/ranking/ + └── RankingFacade — (신규) ZSET 조회 + DB 상품 정보 조합 + infrastructure/ranking/ + └── RankingRedisRepository — (신규) ZREVRANGE, ZREVRANK, ZSCORE, ZCARD + + application/product/ + └── ProductFacade — (수정) 상품 상세에 랭킹 정보 조합 추가 + interfaces/api/product/ + └── ProductDto — (수정) ranking 필드 추가 +``` + +### 11.2 commerce-streamer 클래스 (구현 완료) + +이미 설계대로 구현되어 있으며, 설계 문서와의 정합성을 확인한다. + +#### MetricsDelta (Semantic Definition 중앙화) + +``` +application/ranking/MetricsDelta.java +├── likeDelta, viewDelta, salesCountDelta, salesAmountDelta +├── ofLike(int), ofView(), ofSales(int, long) — 팩토리 메서드 +└── merge(MetricsDelta, MetricsDelta) — 배치 집계용 병합 +``` + +- MetricsConsumer의 private inner class에서 별도 클래스로 추출됨 +- Phase 2(DB)와 Phase 3(Redis) 모두에서 사용 +- **이벤트→메트릭 매핑의 단일 정의 지점**: 새 이벤트 타입 추가 시 팩토리 메서드만 추가하면 Phase 1~3이 자연스럽게 따라감 + +#### MetricsConsumer 수정 사항 + +``` +(수정) interfaces/consumer/MetricsConsumer.java +├── Phase 2 변경: product_metrics UPSERT에 metric_date(CURDATE()) 포함 +│ ├── 모든 이벤트: 인식일 기준 UPSERT (기존 + unlike_count, cancel_by_event_date) +│ └── ORDER_CANCELLED: 발생일(originalOrderDate) 기준 추가 UPSERT +├── 이벤트 스키마: ORDER_CANCELLED에 originalOrderDate 필드 필요 +└── Phase 3: 기존과 동일 (RankingScoreUpdater 위임) +``` + +- ORDER_CANCELLED 이벤트 처리 시 `originalOrderDate`를 파싱하여 발생일 행에도 UPSERT +- commerce-api의 주문 취소 이벤트 발행 로직에서 `originalOrderDate`를 포함하도록 수정 필요 + +#### RankingScoreUpdater + +``` +application/ranking/RankingScoreUpdater.java +├── update(Map) — 진입점 +├── pipelineHincrby(deltaMap, date) — Pipeline 1: Hash 갱신 +├── pipelineZadd(accumulated, zsetKey) — Pipeline 2: ZSET 갱신 +├── calculateScore(view, like, salesAmt, pid) — score 수식 +├── zsetKey(LocalDate), hashKey(LocalDate, Long) — 키 생성 유틸 +└── 상수: RANKING_ZSET_PREFIX, RANKING_METRICS_PREFIX, RANKING_TTL_SECONDS, TIEBREAKER_EPSILON +``` + +- writeTemplate(`@Qualifier("redisTemplateMaster")`) 사용 +- RankingProperties 주입으로 가중치 외부화 +- score 수식에 productId × 1e-10 타이브레이커 포함 + +#### RankingCarryOverScheduler + +``` +application/ranking/RankingCarryOverScheduler.java +├── carryOver() — @Scheduled 23:50 KST +└── carryOver(LocalDate) — 테스트 가능한 메서드 +``` + +- ZUNIONSTORE + WEIGHTS(carryOverRate) + EXPIRE +- Hash 미복사 (설계 결정 반영) + +#### RankingProperties + +``` +application/ranking/RankingProperties.java +├── weights: Weights(view, like, order) +└── carryOverRate: double +``` + +- `@ConfigurationProperties(prefix = "ranking")`로 외부화 +- Additionals "실시간 Weight 조절"을 위한 확장점 + +### 11.3 commerce-batch 클래스 (신규 구현 필요) + +#### RankingCorrectionJobConfig + +``` +application/ranking/RankingCorrectionJobConfig.java +├── rankingCorrectionJob() — Job 정의 +│ Step 1: ItemReader — product_metrics SELECT (metric_date = today) +│ Step 2: ItemProcessor — score 재계산 (RankingProperties.weights 참조) +│ Step 3: ItemWriter — Redis Pipeline (DEL Hash → HSET → ZADD → EXPIRE) +├── 실행 주기: @Scheduled(cron = "0 0 * * * *") 또는 외부 스케줄러 +└── 의존: DataSource, RedisTemplate(Master), RankingProperties +``` + +**설계 포인트**: + +- Spring Batch의 chunk-oriented 처리 → 상품 1,000개씩 읽어 Redis Pipeline으로 일괄 적재 +- `RankingProperties.weights`를 RankingScoreUpdater와 **동일하게 참조** → score 수식의 Semantic Definition 유지 +- 키 상수(prefix, TTL, date format)는 RankingScoreUpdater와 동일 값 사용 +- Reader는 `JdbcCursorItemReader`로 `idx_metric_date` 인덱스 활용 + +### 11.4 commerce-api 클래스 (신규 구현 필요) + +#### RankingRedisRepository + +``` +infrastructure/ranking/RankingRedisRepository.java +├── getTopN(String date, int start, int end) — ZREVRANGE WITHSCORES +│ → List (productId + score, score 내림차순) +├── getRankAndScore(String date, Long pid) — ZREVRANK + ZSCORE +│ → RankingInfo (rank + score) or null +├── getTotalCount(String date) — ZCARD +│ → long +└── 생성자: readTemplate (Replica 우선) +``` + +- `WaitingQueueRedisRepository` 패턴 참조: `@Component`, readTemplate 주입 +- 키 상수는 streamer의 `RankingScoreUpdater`와 동일 값 사용 (모듈 간 직접 참조 없이 문자열 일치) + +#### RankingFacade + +``` +application/ranking/RankingFacade.java +├── getRankings(String date, int page, int size) +│ 1. date null → 오늘(KST) +│ 2. start/end 계산 + Top 100 cap +│ 3. RankingRedisRepository.getTopN() +│ 4. productId 목록 → ProductRepository IN 쿼리 +│ 5. Redis 순서 유지하며 병합 +│ 6. PagedRankingResponse 반환 +└── 의존: RankingRedisRepository, ProductRepository +``` + +#### RankingController + +``` +interfaces/api/ranking/RankingController.java +├── GET /api/v1/rankings +│ @RequestParam date (optional), page (default 0), size (default 20) +│ → ApiResponse +└── 의존: RankingFacade +``` + +#### RankingDto + +``` +interfaces/api/ranking/RankingDto.java +├── RankingResponse (record) +│ rank, productId, productName, brandName, price, score +├── PagedRankingResponse (record) +│ data (List), totalElements, totalPages, page, size +└── RankingInfo (record) — 상품 상세용 + rank, score, date +``` + +#### ProductFacade / ProductDto 수정 + +``` +ProductFacade (수정) +├── getProductDetail(productId) — 기존 +└── getProductDetailCached(productId) — 랭킹 조합 추가 + RankingRedisRepository.getRankAndScore(today, productId) + → try-catch로 감싸서 실패 시 ranking=null + +ProductDto.ProductResponse (수정) +└── ranking: RankingDto.RankingInfo (nullable) — 추가 필드 +``` + +### 11.5 모듈 간 키 상수 일치 + +commerce-streamer가 쓰는 키와 commerce-api가 읽는 키가 일치해야 한다. + +| 상수 | 값 | streamer | api | +|------|---|----------|-----| +| ZSET 키 prefix | `ranking:all:` | RankingScoreUpdater | RankingRedisRepository | +| Hash 키 prefix | `ranking:metrics:` | RankingScoreUpdater | (사용 안 함 — 읽기 경로에서 Hash 직접 조회 불필요) | +| TTL | 172,800초 | RankingScoreUpdater | (설정 불필요 — 읽기 전용) | +| 날짜 포맷 | yyyyMMdd | RankingScoreUpdater | RankingRedisRepository | + +**모듈 간 직접 의존 없이 문자열 값만 일치시킨다.** 공유 모듈을 만들면 결합도가 높아지므로, 각 모듈에서 상수를 독립 정의한다. 값이 3개뿐이라 동기화 부담이 낮다. + +--- + +## 13. 체크리스트 + +과제 요구사항(`docs/requirements/09-ranking-system-quests.md`) 기준으로 설계 커버리지를 정리한다. + +### Must-Have + +#### Ranking Consumer (쓰기 경로) + +| # | 항목 | 설계 섹션 | 구현 상태 | +|---|------|----------|----------| +| 1 | 랭킹 ZSET의 TTL, 키 전략을 적절하게 구성 | 섹션 4 (Key 설계) | streamer 구현 완료 | +| 2 | 날짜별 적재 키를 계산하는 기능 | 섹션 4.3 (KST 기준) | `RankingScoreUpdater.zsetKey()` 구현 완료 | +| 3 | 이벤트 발생 후 ZSET에 점수가 적절하게 반영 | 섹션 3 (점수 모델), 섹션 5 (Pipeline) | `RankingScoreUpdater.update()` 구현 완료 | + +#### Ranking API (읽기 경로) + +| # | 항목 | 설계 섹션 | 구현 상태 | +|---|------|----------|----------| +| 4 | 랭킹 Page 조회 시 정상적으로 랭킹 정보 반환 | 섹션 7.1 (페이지네이션) | **구현 완료** — `RankingController`, `RankingFacade` | +| 5 | 상품 ID가 아닌 상품 정보가 Aggregation되어 제공 | 섹션 7.1 (Aggregation 흐름) | **구현 완료** — `RankingFacade` 내 DB IN 쿼리 | +| 6 | 상품 상세 조회 시 해당 상품 순위 반환 (없으면 null) | 섹션 7.2 (상품 상세 랭킹) | **구현 완료** — `ProductFacade.lookupRanking()` | + +#### 검증 + +| # | 항목 | 설계 섹션 | +|---|------|----------| +| 7 | 이벤트 발행 → ZSET 점수 반영 → API 조회 E2E | 섹션 2 (데이터 흐름) 전체 | +| 8 | 일자 변경 후 이전 날짜 랭킹 조회 정상 동작 | 섹션 4.4 (TTL 2일) | +| 9 | 가중치 적용이 의도대로 랭킹 순서에 반영 | 섹션 3.3 (수식 검증) | + +### Nice-to-Have + +| # | 항목 | 설계 섹션 | 구현 상태 | +|---|------|----------|----------| +| 10 | 시간 단위(초 실시간) 랭킹 | 섹션 4.5 (hourly 키 확장) | 설계만 — 키 패턴 확장으로 대응 가능 | +| 11 | 콜드 스타트 — 시스템 레벨 (carry-over) | 섹션 8.2 | `RankingCarryOverScheduler` 구현 완료 | +| 11-2 | 콜드 스타트 — 아이템 레벨 (신상품 API) | 섹션 8.2 (아이템 레벨) | **구현 완료** — `GET /api/v1/products/new` (commit a1a4e896) | +| 12 | 카프카 배치 리스너 | 섹션 2.2 (MetricsConsumer 확장) | 기존 BATCH_LISTENER 활용 (이미 3,000건 배치) | + +### Additionals + +| # | 항목 | 설계 섹션 | 구현 상태 | +|---|------|----------|----------| +| 13 | 실시간 Weight 조절 | 섹션 12.2 (RankingProperties) | `@ConfigurationProperties` 구현 완료, actuator refresh로 런타임 변경 가능 | +| 14 | 1시간 단위 랭킹 | 섹션 4.5 | 미구현 — hourly 키 전략 설계 완료 | +| 15 | 콜드 스타트 Scheduler (23:50) | 섹션 8.4 | `RankingCarryOverScheduler` 구현 완료 | + +### Composite Score 리팩토링 + +| # | 항목 | 설계 섹션 | 구현 상태 | +|---|------|----------|----------| +| 22 | Score 0~1 정규화 (MAX_LOG=7) | 섹션 3.3 | **구현 완료** — `MAX_LOG=7`로 나누어 score를 0~1 범위로 정규화. RankingScoreUpdater + RankingCorrectionJobConfig 수식 동일하게 변경, 테스트 전면 수정 | +| 23 | Tiebreaker: productId → lastEventAt (timestamp) | 섹션 9.3~9.4 | **구현 완료** — `TIEBREAKER_SCALE=1e-16`, MetricsDelta에 `lastEventEpochSeconds` 필드 추가, Kafka `record.timestamp()/1000`으로 설정, Pipeline 1에 HSET lastEventAt 추가 | +| 24 | Product 엔티티에 categoryId 추가 | 섹션 9.5 | **구현 완료** — `Product.categoryId` (nullable Long) 필드 + 5파라미터 생성자 추가, ProductDto/ProductFacade/ProductAdminController 연동 | +| 25 | Category Priority score 인코딩 | 섹션 9.5 | **구현 완료** — `categoryPriority` 정수부 인코딩, RankingProperties에 `categoryPriority` 매핑 + `defaultCategoryPriority` 추가, MVP는 0 고정 | +| 26 | A/B 테스트 dual ZSET 실험 | 섹션 10 | **구현 완료** — `experiment.enabled` 설정 기반 dual ZSET 이중 쓰기, variant별 가중치/prefix 분리, memberId % variantCount 라우팅, CarryOver 양쪽 지원 | + +### 주간/월간 랭킹 확장 + +| # | 항목 | 설계 섹션 | 구현 상태 | +|---|------|----------|----------| +| 27 | Daily ZSET TTL 8일로 변경 | 섹션 4.7.1 | **구현 완료** — `RANKING_ZSET_TTL_SECONDS=691,200` (8일), `RANKING_HASH_TTL_SECONDS=172,800` (2일), `RANKING_AGGREGATED_TTL_SECONDS=172,800` (2일)로 분리. RankingScoreUpdater + RankingCorrectionJobConfig 동일 적용 | +| 28 | 주간 랭킹 ZUNIONSTORE (7일 합산) | 섹션 4.7.2 | **구현 완료** — `RankingCarryOverScheduler.buildWeeklyRanking()`: 최근 7일 daily ZSET을 동일 가중치(1.0×7)로 ZUNIONSTORE → `ranking:weekly:{tomorrow}`, TTL 2일 | +| 29 | 월간 랭킹 Rolling Carry-Over (감쇠율 0.97) | 섹션 4.7.3 | **구현 완료** — `RankingCarryOverScheduler.buildMonthlyRanking()`: `monthly:{today}×0.97 + daily:{today}×1.0` → `ranking:monthly:{tomorrow}`, 초기화 시 자연 부트스트랩, TTL 2일 | +| 30 | Ranking API scope 파라미터 추가 | 섹션 4.7.5 | **구현 완료** — `RankingController` scope 파라미터(daily/weekly/monthly, default=daily), `RankingFacade.resolveZsetPrefix()` scope별 prefix 분기, A/B 테스트는 daily에만 적용 | + +### ZSET Carry-Over Trim + +| # | 항목 | 설계 섹션 | 구현 상태 | +|---|------|----------|----------| +| 31 | Daily carry-over 후 Trim (N=10,000) | 섹션 6.5.3, 6.5.6 | **구현 완료** — `doCarryOverDaily()` 내 ZUNIONSTORE 직후 `trimZset()` 호출, A/B variant에도 동일 적용 | +| 32 | Monthly carry-over 후 Trim (N=10,000) | 섹션 6.5.6 | **구현 완료** — `buildMonthlyRanking()` 내 ZUNIONSTORE 직후 `trimZset()` 호출. Weekly는 합산 재생성이므로 미적용 | +| 33 | CARRY_OVER_CAP 설정 외부화 (RankingProperties) | 섹션 6.5.6 | **구현 완료** — `RankingProperties.carryOverCap()` (기본값 10,000), `application.yml`에 `carry-over-cap: 10000` | + +### 과제 범위 초과 — 메트릭 설계 심화 + +| # | 항목 | 설계 섹션 | 구현 상태 | +|---|------|----------|----------| +| 16 | product_metrics 그레인 재설계 (daily × product) | 섹션 2.5 (TO-BE) | **구현 완료** — `ProductMetrics` 엔티티 PK `(product_id, metric_date)` + Phase 2 수정 | +| 17 | Additive Measure + 취소 분리 | 섹션 2.5 (설계 원칙 1) | **구현 완료** — `unlike_count`, `cancel_*_by_event_date`, `cancel_*_by_order_date` 컬럼 분리 | +| 18 | Late-Arriving Fact 이중 기록 | 섹션 2.5 (설계 원칙 2) | **구현 완료** — MetricsConsumer 이중 UPSERT (인식일 + 발생일) + 테스트 4개 시나리오 | +| 19 | Lambda Architecture 배치 보정 잡 | 섹션 2.5 (설계 원칙 4), 섹션 12.3 | **구현 완료** — `RankingCorrectionJobConfig` + `RankingCorrectionScoreTest` (8 시나리오) | +| 20 | Semantic Definition 중앙화 | 섹션 2.5 (설계 원칙 3) | **구현 완료** — MetricsDelta 팩토리 메서드, RankingProperties 가중치 외부화, 배치 잡 수식 일치 | +| 21 | ORDER_CANCELLED 이벤트에 originalOrderDate 추가 | 섹션 2.5 | **구현 완료** — `OrderFacade.cancelOrder()`에서 `originalOrderDate` 포함, MetricsConsumer 파싱 + 파싱 실패 테스트 | + +### 구현 우선순위 + +``` +검증 (미완료): + → E2E 흐름 테스트 (이벤트 발행 → Redis → API) + → 일자 변경 테스트 + → 가중치 순서 검증 테스트 + → product_metrics 일별 적재 + 취소 분리 + Late-Arriving Fact 검증 + → 정합성 검증: SUM(cancel_by_order_date) = SUM(cancel_by_event_date) + → 배치 보정 전후 Redis 데이터 정합성 검증 + → 배치 + 실시간 동시 실행 시 race condition 검증 + +구현 완료: + → product_metrics 스키마 변경 (Grain + 취소 분리 + Late-Arriving Fact) + → MetricsConsumer Phase 2 이중 UPSERT + → ORDER_CANCELLED 이벤트에 originalOrderDate 포함 + → Lambda Architecture 배치 보정 잡 (RankingCorrectionJobConfig) + → RankingRedisRepository + RankingFacade + RankingController + → ProductFacade / ProductDto 랭킹 조합 + → RankingScoreUpdater + RankingCarryOverScheduler + → MetricsDelta Semantic Definition + RankingProperties 가중치 외부화 + → Score 0~1 정규화 (MAX_LOG=7) + Tiebreaker lastEventAt 변경 + → Product categoryId + Category Priority score 인코딩 + → A/B 테스트 dual ZSET (experiment 설정 + 이중 쓰기 + memberId 라우팅) +``` diff --git a/docs/design/volume-9/09-ranking-system.md b/docs/design/volume-9/09-ranking-system.md new file mode 100644 index 0000000000..678e3388c4 --- /dev/null +++ b/docs/design/volume-9/09-ranking-system.md @@ -0,0 +1,863 @@ +# Round 9 — Show Me The Ranking + +## 개요 + +Round7의 Kafka -> commerce-collector 파이프라인이 수집한 유저 행동 이벤트를 기반으로, +Redis ZSET에 랭킹 점수를 실시간 갱신하고, API가 ZSET을 조회해 랭킹 기능을 제공한다. + +``` +[commerce-api] + -> 유저 행동 이벤트 발행 (조회, 좋아요, 주문) + -> Kafka + +[commerce-collector] + -> 이벤트 소비 + -> product_metrics upsert (R7) + -> Redis ZSET 랭킹 점수 갱신 (R9) <-- 이번 주차 + +[commerce-api] + -> GET /rankings/top (ZREVRANGE) + -> GET /products/{id}/rank (ZREVRANK) +``` + +--- + +## 키워드 + +- Redis Sorted Set (ZSET) +- ZINCRBY 기반 실시간 집계 +- Top-N API +- 일별 Key 전략 & TTL +- 가중치 합산 (Weighted Sum) +- 콜드 스타트 문제 + +--- + +## 학습 내용 + +### 1. Ranking 시스템 특성 + +- **Top-N API**: 홈 메인 인기 상품, 오늘의 Top 10, 인기순 정렬 등 — 항상 높은 조회 빈도 +- **개별 순위 조회**: 특정 상품이 현재 몇 위인지 표기 +- **주기적 갱신**: 일간/주간/월간 단위로 리셋 (이번 라운드는 일간만) +- **콜드 스타트 문제** 존재 +- **RDB로 해결하기 어려운 이유**: `GROUP BY + ORDER BY`는 데이터가 쌓일수록 느려지고, 높은 조회 빈도에 DB 과부하 + +### 2. Redis ZSET + +- **(member, score)** 쌍을 score 기준 정렬 상태로 유지 +- 삽입/수정: O(log N), Top-N 조회: O(N) +- 주요 연산: + - `ZADD key score member` — score와 함께 member 저장 (이미 있으면 갱신) + - `ZREVRANGE key 0 N WITHSCORES` — score 기준 Top-N 조회 + - `ZREVRANK key member` — 특정 멤버의 순위 조회 + - `ZSCORE key member` — 특정 멤버의 스코어 조회 + - `ZCARD key` — 멤버 수 조회 + +**다른 방식과 비교:** + +| 방법 | 장점 | 단점 | 적합도 | +|------|------|------|--------| +| DB ORDER BY | 정합성 높음 | 느림, 부하 높음 | 초기/소규모 | +| 캐시(Map) + 정렬 | 간단 | 매 요청마다 정렬 필요 | 중간 | +| Redis ZSET | 빠른 정렬 내장, 다양한 조회 | 메모리 사용 높음 | 대규모 트래픽 | + +### 3. Key 설계 — 시간의 양자화 + +**누적만 할 경우의 문제:** +- 오래 전 점수를 쌓은 상품이 계속 상위 노출 -> 신상품 노출 기회 상실 +- 롱테일(Long Tail) 현상 — 소수 상품이 상위권 독식 +- 시간 단위 집계로 공정성 확보 + 신선한 정보 노출 필요 + +**일별 키 분리:** +``` +rank:all:20250906 // 9월 6일 랭킹 집계 +rank:all:20250907 // 9월 7일 랭킹 집계 +``` + +**TTL:** 시간 윈도우의 1.5배~2배 + +### 4. 가중치 합산 (Weighted Sum) + +**필요한 이유:** +- 좋아요/구매/매출액은 스케일이 달라 단순 합산 시 특정 지표가 지배 +- 서비스 전략에 따라 중요 지표가 달라짐 + +**총점식:** +``` +Sum(p) = W(like) * Count(p.like) + W(order) * Count(p.order) + W(view) * Count(p.view) +``` + +**기본 가중치 (총합 = 1.0):** + +| 지표 | 가중치 | 근거 | +|------|--------|------| +| view | 0.1 | 조회 수가 가장 많아 전체 스코어를 지배할 수 있으므로 낮게 | +| like | 0.2 | 구매 결정 관점에서 주문보다 덜 중요 | +| order | 0.7 | 유저가 구매를 결정한 가장 중요한 지표 | + +### 5. 콜드 스타트 문제 + +**문제:** +- 집계 윈도우 시작 시점에 점수가 없어 랭킹 정보 부재 +- 전날 인기 상품도 0점에서 시작 +- 랭킹 미진입 상품 -> 클릭/구매 발생 안 함 -> 악순환 + +**해결 — Score Carry-Over:** +- 새 키 생성 시 전날 점수의 일부를 작은 가중치로 복사 +- 가중치를 작게 잡아 오늘의 점수가 빠르게 역전 가능하도록 함 + +``` +ZUNIONSTORE ranking:all:20250907 1 ranking:all:20250906 WEIGHTS 0.1 AGGREGATE SUM +``` + +Before (20250906): `product:101 -> 100`, `product:202 -> 50` +After (20250907, carry-over 10%): `product:101 -> 10`, `product:202 -> 5` + +--- + +## 요약 + +| 항목 | 설명 | +|------|------| +| 랭킹의 목적 | 유저에게 인기 상품을 효율적으로 노출 (Top-N, 개별 순위) | +| 핵심 기술 | Redis ZSET (정렬 내장 + O(logN) 삽입/수정) | +| 데이터 소스 | R7의 Kafka -> collector 파이프라인이 수집한 유저 행동 이벤트 | +| Key 설계 | 일별 키 분리 (rank:all:yyyyMMdd) + TTL로 메모리 관리 | +| 가중치 합산 | 시그널별 가중치를 곱해 단일 스코어로 합산 | +| 콜드 스타트 | Score Carry-Over (전일 점수 일부 복사)로 완화 | +| R7/R8과의 관계 | R7 collector가 이벤트 -> ZSET 갱신, R8 ZSET 경험을 랭킹에 재활용 | + +--- + +## 참고 자료 & 레퍼런스 분석 + +### 과제 제공 레퍼런스 + +- Redis Sorted Sets: https://redisgate.kr/redis/command/zsets.php +- Spring Data Redis Template: https://docs.spring.io/spring-data/redis/reference/redis/template.html +- 올리브영 랭킹 시스템 개편기: https://oliveyoung.tech/2023-11-07/ranking-system/ + +### 추가 조사 레퍼런스 + +| 글 | 핵심 내용 | 우리 과제 적용 포인트 | +|----|----------|---------------------| +| [Redis 공식 Leaderboard Tutorial](https://redis.io/tutorials/howtos/leaderboard/) | Java 코드 예시, 일별 키, TTL, 페이지네이션, 배치 Pipeline | ZINCRBY + ZREVRANGE 구현, 페이지네이션 공식 | +| [Capped Leaderboard (daily.dev)](https://daily.dev/blog/creating-a-capped-leaderboard-with-redis-sorted-set-secondary-index-and-lua) | Lua 스크립트로 상위 N개만 유지, 보조 인덱스 패턴 | 메모리 관리, ZSET + Hash 분리 설계 | +| [Redis Sorted Sets Leaderboards (OneUptime)](https://oneuptime.com/blog/post/2026-01-25-redis-sorted-sets-leaderboards/view) | 시간 윈도우별 키 패턴, TTL 전략, 복합 점수 동점 처리 | 키 네이밍 확장, 시간 단위 랭킹 설계 | + +### 올리브영 기술 블로그 분석 + +올리브영은 Oracle 프로시저 기반 랭킹을 **AWS Glue + Athena + Step Function** 배치 ETL로 개편했다. +우리 과제와는 접근 방식이 근본적으로 다르다 (배치 ETL vs 실시간 ZSET). + +**공통점**: 기존 DB 프로시저/쿼리 방식의 한계 인식 — 반복 집계, 확장성 부족, 산출 근거 파악 어려움 +**차이점**: 올리브영은 AWS 매니지드 서비스 기반 배치, 우리는 Kafka + Redis ZSET 기반 실시간 + +### 레퍼런스에서 얻은 설계 인사이트 + +**1) 페이지네이션 — ZREVRANGE 오프셋 계산** + +``` +start = (page - 1) * size +end = start + size - 1 +ZREVRANGE ranking:all:{date} start end WITHSCORES +``` + +과제 API `GET /api/v1/rankings?date=yyyyMMdd&size=20&page=1`에 직접 적용 가능. + +**2) 상품 정보 Aggregation — ZSET + DB 조합** + +ZSET에는 productId만 저장하고, API 응답 시 DB에서 상품 정보를 조회하여 합쳐 반환한다. +Redis 공식 가이드의 Hash + ZSET 패턴과 동일한 개념이나, +우리는 상품 정보가 MySQL에 있으므로 ZSET 조회 -> productId 목록 -> DB IN 쿼리로 처리. + +**3) 동점 처리 — 타임스탬프 인코딩** + +``` +compositeScore = baseScore + (1 - timestamp / 10_000_000_000) +``` + +같은 점수면 먼저 달성한 상품이 상위. 현재 과제 요구사항에는 명시되지 않았으나, +ZINCRBY 방식에서는 동점 시 ZSET의 기본 동작(사전식 순서)에 의존하게 된다. + +**4) 메모리 관리 — Capped ZSET** + +상품 수가 많아질 경우 `ZREMRANGEBYRANK ranking:all:{date} 0 -(N+1)`로 하위 항목을 주기적으로 제거. +항목당 ~50 bytes 기준, 상품 10만 개 = ~5MB. 상위 1,000개만 유지하면 ~50KB. + +**5) 배치 Pipeline — Redis 왕복 최소화** + +현재 MetricsConsumer가 3,000건 배치로 Kafka를 소비하므로, +Redis Pipeline으로 여러 ZINCRBY를 한 번에 전송하면 네트워크 왕복을 줄일 수 있다. + +**6) 시간 윈도우 키 확장 패턴** + +``` +ranking:all:daily:{yyyyMMdd} TTL: 2일 +ranking:all:hourly:{yyyyMMddHH} TTL: 3시간 +``` + +Nice-to-Have "시간 단위 랭킹"을 키 네이밍만 확장하여 자연스럽게 구현 가능. + +**7) 콜드 스타트 — 레퍼런스 공백** + +조사한 3개 레퍼런스 모두 콜드 스타트를 다루지 않는다. +과제 문서의 ZUNIONSTORE carry-over 방식이 현재 유일한 레퍼런스. +실무에서 이 방식이 표준적인지, 다른 접근이 있는지는 확인 필요. + +--- + +## 다음 주차 예고 + +일간 집계를 넘어 주간/월간 집계를 만드는 방법. 점차 많아지는 데이터/통계를 주기적으로 생성하는 기능. + +--- + +## 구현 과제 (Implementation Quest) + +### Must-Have + +#### (1) Kafka Consumer -> Redis ZSET 적재 + +- 조회/좋아요/주문 이벤트를 컨슘하여 일간 키(`ranking:all:{yyyyMMdd}`)의 ZSET에 점수 누적 +- 이벤트별 Weight & Score: + +| 이벤트 | Weight | Score | 비고 | +|--------|--------|-------|------| +| 조회 | 0.1 | 1 | | +| 좋아요 | 0.2 | 1 | | +| 주문 | 0.6 | price * amount | 정규화 시 log 적용 가능 | + +- ZSET 스펙: + - **TTL**: 2일 + - **KEY**: `ranking:all:{yyyyMMdd}` + +#### (2) Ranking API 구현 + +- **랭킹 Page 조회**: `GET /api/v1/rankings?date=yyyyMMdd&size=20&page=1` +- **상품 상세 조회 시 해당 상품의 랭킹 정보 추가** + +### Nice-to-Have + +- 시간 단위(초 실시간) 랭킹 만들기 +- 콜드 스타트 문제 해결 (Score Carry-Over) +- 카프카 배치 리스너 (단건 처리 대신 배치로 ZSET/DB 연산 최적화, 스루풋 향상) + +### Additionals + +- 실시간 Weight 조절 — 점수 계산 가중치를 동적으로 수정하는 방법 +- 1시간 단위 랭킹 — 일간이 아닌 시간 윈도우 랭킹 +- 콜드 스타트 Scheduler — 23:50에 Score Carry-Over로 다음 날 랭킹판 사전 생성 + +--- + +## 체크리스트 + +### Ranking Consumer + +- [ ] 랭킹 ZSET의 TTL, 키 전략을 적절하게 구성 +- [ ] 날짜별 적재 키를 계산하는 기능 +- [ ] 이벤트 발생 후 ZSET에 점수가 적절하게 반영 + +### Ranking API + +- [ ] 랭킹 Page 조회 시 정상적으로 랭킹 정보 반환 +- [ ] 랭킹 Page 조회 시 상품 ID가 아닌 상품 정보가 Aggregation되어 제공 +- [ ] 상품 상세 조회 시 해당 상품의 순위가 함께 반환 (순위에 없으면 null) + +### 검증 + +- [ ] 이벤트 발행 -> ZSET 점수 반영 -> API 조회 E2E 흐름 정상 동작 +- [ ] 일자 변경 후에도 이전 날짜의 랭킹 조회 정상 동작 +- [ ] 가중치 적용이 의도대로 랭킹 순서에 반영 (e.g. 주문 1건 > 좋아요 3건) + +--- + +## Technical Writing Quest + +### 작성 기준 + +| 항목 | 설명 | +|------|------| +| 형식 | 블로그 | +| 길이 | 제한 없음, 단 1줄 요약(TL;DR) 포함 | +| 포인트 | "무엇을 했다"보다 "왜 그렇게 판단했는가" 중심 | +| 예시 | 코드 비교, 흐름도, 리팩토링 전후 등 자유 | +| 톤 | 실력은 보이지만 자만하지 않고, 고민이 읽히는 글 | + +### 글감 제안 + +- 누적 랭킹만 유지하면 왜 롱테일 문제가 발생할까? +- 시간의 양자화 — 왜 필요한가? +- 콜드 스타트(0점에서 시작) 문제를 어떻게 풀 수 있을까? +- 우리의 랭킹 지표 구성 — 진짜 인기 있는 상품이란? +- 실시간 랭킹, 이렇게 풀면 쉽다 +- 상품 10만 개일 때 ZSET 메모리는? 상위 N개만 유지하면? +- Top-N을 매번 ZREVRANGE로 조회 vs 주기적 캐싱의 트레이드오프 + +--- + +## 구현 상세 + +### Ranking Consumer (commerce-streamer) + +**구현 파일:** +- `application/ranking/MetricsDelta.java` — MetricsConsumer 내부 클래스에서 추출 +- `application/ranking/RankingProperties.java` — 가중치 외부화 (`@ConfigurationProperties`) +- `application/ranking/RankingScoreUpdater.java` — HINCRBY→ZADD 파이프라인 + +**MetricsDelta 패키지 이동:** +원래 `MetricsConsumer`의 private inner class였던 `MetricsDelta`를 `application.ranking` 패키지로 추출. +- 이유: `RankingScoreUpdater`(application 레이어)가 `MetricsDelta`를 매개변수로 받는데, + `interfaces.consumer` 패키지에 두면 application → interfaces 역방향 의존이 발생한다. +- `application.ranking`에 두면 `MetricsConsumer`(interfaces) → `MetricsDelta`(application) 순방향 의존이 유지된다. + +**MetricsConsumer 확장 (Phase 3):** +기존 Phase 1(멱등성 체크 + deltaMap 집계) → Phase 2(DB UPSERT) 이후에 +Phase 3(Redis 랭킹 갱신)을 추가. +- **동일 deltaMap 재사용**: Phase 1에서 productId별로 집계한 `Map`를 Phase 2(DB UPSERT)와 Phase 3(Redis ZSET)이 공유. 이벤트를 두 번 파싱하지 않는다. +- **격리**: Phase 3은 Phase 2의 `transactionTemplate` 밖에서 별도 try-catch로 실행. Redis 장애가 DB 커밋에 영향 주지 않음. + +**이중 집계 구조 — 데이터 정합성 전략:** +같은 이벤트를 product_metrics(DB, 전체 누적 원장)와 ranking:*(Redis, 일간 집계 실시간 뷰) 두 저장소에 동시 적재한다. +- Phase 2(DB)와 Phase 3(Redis)는 실행 순서는 있지만 트랜잭션을 공유하지 않는다 +- Phase 2 성공 + Phase 3 실패 → DB 정확, Redis 일시 부정확 → 다음 배치에서 자연 복구 +- Phase 2 실패 → 트랜잭션 롤백, Phase 3도 스킵 → Kafka 오프셋 미커밋 → 재처리 +- **원장 기반 재집계 미구현 결정**: product_metrics는 전체 누적이라 일간 delta 추출 불가. 일간 랭킹은 TTL 2일의 휘발성 데이터이므로, Redis 장애 복구 후 이벤트 유입으로 자연 재생성하는 것이 적합 +- 향후 주간/월간 배치 집계(Round 10)에서 product_metrics 원장이 활용될 예정 + +**HINCRBY→ZADD 방식 선택 (vs ZINCRBY):** +- ZINCRBY는 delta score를 증분하므로, 가중치 변경 시 과거 적재분을 보정할 수 없다 +- HINCRBY로 Hash에 원본 메트릭(viewCount, likeCount, salesCount, salesAmount)을 일간 누적 → 리턴값으로 composite score 재계산 → ZADD로 덮어쓰기 +- 부동소수점 누적 오차 방지, 가중치 변경 시 재계산 용이 + +**Redis 키 전략:** + +| 키 | 패턴 | 예시 | 용도 | +|----|------|------|------| +| ZSET | `ranking:all:{yyyyMMdd}` | `ranking:all:20260410` | 일간 composite score | +| Hash | `ranking:metrics:{yyyyMMdd}:{productId}` | `ranking:metrics:20260410:101` | 일간 원본 메트릭 4필드 | + +- TTL: 172,800초 (2일). Pipeline 내 EXPIRE로 매 배치 설정 (O(1), 별도 EXISTS 불필요) +- 시간대: KST (`ZoneId.of("Asia/Seoul")`) — 자정 기준 일간 윈도우 +- 날짜 포맷: `DateTimeFormatter.BASIC_ISO_DATE` → `20260410` +- 키 생성은 `LocalDate`를 매개변수로 받는 순수 함수로 구현 → 테스트 가능 +- 공개 상수: `RANKING_ZSET_PREFIX`, `RANKING_METRICS_PREFIX`, `RANKING_TTL_SECONDS` +- Hourly 키 확장(`ranking:all:hourly:{yyyyMMddHH}`)은 Nice-to-Have로 이번 구현에서 제외 + +**Score 계산 공식:** +``` +score = 0.1 × log₁₀(viewCount + 1) + + 0.2 × log₁₀(likeCount + 1) + + 0.7 × log₁₀(salesAmount + 1) + + productId × 1e-10 +``` +- 모든 메트릭에 log₁₀ 정규화: 조회 수(수만)와 좋아요(수십)의 절대값 스케일 차이 완화 +- `+1`은 log₁₀(0) = -∞ 방지, `max(0, value)` 적용으로 음수(취소 초과) 방어 +- 가중치 합 = 1.0 (view 0.1 + like 0.2 + order 0.7) +- 가중치는 `@ConfigurationProperties`로 외부화 → yml 변경으로 런타임 조정 가능 + +**음수 메트릭 방어:** +- HINCRBY 리턴값이 음수가 될 수 있음 (취소가 생성보다 먼저 도착한 경우) +- `max(value, 0)` 적용 후 log 계산 +- 음수 감지 시 WARN 로그 (productId 포함) + +**Master 전용 쓰기:** +- `@Qualifier("redisTemplateMaster")`로 Master 노드에만 쓰기 수행 +- modules/redis가 제공하는 `defaultRedisTemplate`은 `ReadFrom.REPLICA_PREFERRED`이므로 쓰기에 부적합 +- 향후 Ranking API(읽기)에서는 `defaultRedisTemplate`(Replica 우선)을 사용할 예정 + +**Pipeline 구현 상세:** +- `SessionCallback` + `executePipelined`로 원자적 파이프라인 실행 +- **Pipeline 1 (HINCRBY)**: productId당 4 HINCRBY + 1 EXPIRE + - Hash 필드: `viewCount`, `likeCount`, `salesCount`, `salesAmount` + - HINCRBY 리턴값 = 누적치. 리턴 순서에 의존하여 파싱 (`base = i × 5`) +- **Pipeline 2 (ZADD)**: 누적치로 score 재계산 → productId별 ZADD + ZSET 키 1회 EXPIRE +- 3,000건 배치에서 인기 상품 ~100개에 집중 시: Pipeline 1(HINCRBY 400 + EXPIRE 100) + Pipeline 2(ZADD 100 + EXPIRE 1) +- Redis 왕복 2회로 최소화 + +**단위 테스트 (`RankingScoreUpdaterTest`) — 26개 전체 PASS:** + +| 카테고리 | 테스트 수 | 검증 내용 | +|----------|-----------|-----------| +| 기본 score 계산 | 4 | 모든 메트릭 0 → score 0.0, 단일 지표별 정확한 수치 (e.g. view=99 → 0.1×log₁₀(100)=0.2) | +| 가중치 순서 | 3 | 주문 1건(10000원) > 좋아요 3건, like > view (같은 count), 복합 score 정확도 | +| log₁₀ 정규화 | 2 | view 10배 차이 → score 1.5배 미만, salesAmount 100배 차이 → score 2배 미만 | +| 음수 방어 | 5 | 개별/전체 음수 → 0 클램핑, 음수 항이 양수 항의 score를 침범하지 않음 | +| 커스텀 가중치 | 1 | view 가중치 0.7로 변경 시 view score > order score 확인 | +| 키 생성 | 7 | ZSET/Hash 키 포맷, 날짜 변경 시 다른 키, productId별 분리, prefix 상수 일치, TTL=172800초 | +| 타이브레이커 | 4 | 동점 시 신상품 우선, 주 score 역전 불가, ε 안전성, ε 상수 검증 | + +### 콜드 스타트 Carry-Over 스케줄러 (commerce-streamer) + +**구현 파일:** +- `application/ranking/RankingCarryOverScheduler.java` — 23:50 KST 스케줄 실행 + +**문제:** +일간 키 전환(자정) 시 새 ZSET이 비어있어 00:00~01:00 사이 랭킹 정보가 없다. +이벤트가 쌓이기 전까지 사용자에게 빈 랭킹이 노출되는 콜드 스타트 문제. + +**해결 — Score Carry-Over:** +- 23:50 KST에 `ZUNIONSTORE ranking:all:{tomorrow} 1 ranking:all:{today} WEIGHTS 0.1` 실행 +- 오늘 ZSET의 모든 score를 10%로 축소하여 내일 ZSET에 시드 +- 내일 실제 이벤트가 들어오면 `RankingScoreUpdater`의 HINCRBY→ZADD가 carry-over score를 덮어쓰므로, carry-over는 자연스럽게 퇴장한다 + +**Hash는 복사하지 않는 이유:** +- Hash는 HINCRBY 원본 메트릭 저장소이다. carry-over 대상이 아님 +- carry-over된 상품에 새 이벤트가 없으면 Hash 없이 ZSET score(10%)만 남아 순위에 표시 +- 새 이벤트가 들어오면 Hash가 0부터 시작하여 HINCRBY→ZADD로 실제 score가 덮어씀 + +**실행 시점 — 23:50인 이유:** +- 자정(00:00)에 실행하면 이미 콜드 스타트 발생 후 +- 23:50이면 10분의 여유로 carry-over 완료 후 자정을 맞이 +- ZUNIONSTORE는 atomic이므로 23:50 시점의 스냅샷이 복사됨 (마지막 10분 이벤트 누락은 허용) + +**설정 외부화:** +- `ranking.carry-over-rate: 0.1` — yml에서 비율 조정 가능 +- `RankingProperties` record에 `carryOverRate` 필드 추가 +- `@EnableScheduling`을 `CommerceStreamerApplication`에 추가 + +**ZUNIONSTORE 구현:** +- Spring Data Redis `opsForZSet().unionAndStore(todayKey, emptyList, tomorrowKey, Aggregate.SUM, Weights.of(rate))` +- otherKeys는 빈 리스트 (소스 키 1개만 사용) +- EXPIRE로 내일 키에도 TTL 172,800초 설정 + +**장애 대응:** +- try-catch로 감싸 Redis 장애 시 ERROR 로그만 기록 +- carry-over 실패해도 비즈니스에 치명적이지 않음 — 자정 이후 실제 이벤트가 쌓이면 랭킹 복구 + +**테스트 설계:** +- `carryOver(LocalDate today)` 메서드 분리로 `@Scheduled`에 의존하지 않고 임의 날짜 테스트 가능 + +**단위 테스트 (`RankingCarryOverSchedulerTest`) — 4개 전체 PASS:** + +| 테스트 | 검증 내용 | +|--------|-----------| +| ZUNIONSTORE 파라미터 | todayKey, emptyList, tomorrowKey, Aggregate.SUM, Weights.of(0.1) | +| TTL 설정 | 내일 키에 172,800초 EXPIRE | +| 장애 격리 | Redis 예외 시 예외를 삼키고 전파하지 않음 | +| 연말 키 전환 | 2026-12-31 → 2027-01-01 키 생성 정확 | + +### 동점 처리 — productId 기반 타이브레이커 + +**동점 발생 조건:** +동일한 메트릭 조합을 가진 상품이 존재하면 주 score가 동점이 된다. +초기/carry-over 직후에 발생 가능성이 높고, 일과 시간에는 이벤트 누적으로 자연 해소. + +**ZSET 동점 기본 동작:** +score 동일 시 member의 사전식(lexicographic) 순서로 정렬. +productId가 숫자이므로 사전식 순서는 비즈니스 의미 없음 (e.g. "99" > "202" > "101"). + +**대안 비교:** + +| 대안 | 장점 | 단점 | +|------|------|------| +| 아무것도 안 함 (ZSET 기본) | 단순 | 동점 시 순서가 자의적 (사전식) | +| 타임스탬프 인코딩 | 먼저 달성한 상품 우선 | score에 두 가지 의미 혼합, 디버깅 어려움 | +| salesCount 인코딩 | 비즈니스 의미 있음 | salesAmount가 이미 주 score에 반영 → 같은 시그널의 이중 반영 | +| **productId 인코딩** | 신상품에 노출 기회 부여, 주 score와 다른 차원 | productId가 auto-increment가 아닌 경우 무의미 | + +**결정: productId × ε(1e-10)를 score에 인코딩하여 ZSET 레벨에서 동점 해소.** + +근거: +- salesCount는 이미 salesAmount를 통해 주 score에 반영 → 타이브레이커에 다시 쓰면 이중 반영 +- 동점인 상품 중 높은 productId(=최근 등록 신상품)가 상위 → 미시적 콜드 스타트 완화 +- productId는 auto-increment이므로 높을수록 최근 등록. Phase 3에서 이미 보유하여 추가 조회 불필요 +- 주 score(조회/좋아요/매출)와 완전히 다른 차원의 보정이라 정보가 중복되지 않음 + +**ε(엡실론) 산정:** +- 주 score 최소 유의미 차이: view 0→1 = `0.1 × log₁₀(2) = 0.0301` +- productId 현실적 상한: 10,000,000 (천만) +- `ε = 1e-10` → productId 천만일 때 보정값 0.001 → 주 score 차이(0.0301)의 3.3% +- Redis double(64bit IEEE 754) 유효 자릿수 15~16자리에서 충분히 표현 가능 + +**최종 수식:** +``` +score = 0.1 × log₁₀(viewCount + 1) + + 0.2 × log₁₀(likeCount + 1) + + 0.7 × log₁₀(salesAmount + 1) + + productId × 1e-10 +``` + +**구현 변경:** +- `RankingScoreUpdater.TIEBREAKER_EPSILON = 1e-10` 상수 추가 +- `calculateScore(viewCount, likeCount, salesAmount, productId)` — productId 매개변수 추가 +- `pipelineZadd()`에서 entry.getKey()(productId)를 calculateScore에 전달 + +**단위 테스트 (타이브레이커) — 4개 전체 PASS:** + +| 테스트 | 검증 내용 | +|--------|-----------| +| 동점 시 신상품 우선 | 동일 메트릭 + productId 101 vs 505 → 505(신상품)가 상위 | +| 주 score 역전 불가 | view=2/pid=101 vs view=1/pid=999999 → 주 score가 높은 쪽이 상위 | +| ε 안전성 | productId 1000만이어도 주 score 최소 차이의 5% 미만 | +| ε 상수 | `TIEBREAKER_EPSILON == 1e-10` | + +### 장애 시나리오 분석 + +**장애 포인트 분류:** +``` +쓰기 경로: + Kafka → MetricsConsumer → [Phase 1: DB 멱등성] → [Phase 2: DB upsert] → [Phase 3: Redis 적재] + ↑ 장애 포인트 +읽기 경로: + 유저 → RankingController → RankingFacade → [RankingRedisRepository → Redis Replica] + ↑ 장애 포인트 +``` + +**쓰기 경로 — Phase 3 Redis 장애:** +- Phase 3을 try-catch로 격리 (이미 구현). DB 커밋과 ack.acknowledge()는 Phase 3 성공 여부와 무관 +- 재시도 불필요: 다음 배치의 HINCRBY가 누적 delta를 반영하고, score 재계산이 Hash 전체 상태 기반이므로 정합성 유지 +- Consumer 재시작/리밸런싱: event_handled INSERT IGNORE 멱등성으로 중복 처리 방지 + +**Redis 복구 후 데이터 정합성:** + +| 시나리오 | 결과 | 복구 방법 | +|---------|------|----------| +| Hash 유실 + ZSET 유실 | 빈 랭킹 | 이벤트 유입으로 Hash/ZSET 자연 재생성 (수 분~수 시간) | +| Hash 유실 + ZSET 잔존 | ZSET score가 오래된 값 | 새 이벤트의 HINCRBY→score 재계산→ZADD로 갱신. 단, 장애 전 누적분 유실 | +| Hash 잔존 + ZSET 유실 | 랭킹 목록 없음 | 새 이벤트의 score 재계산→ZADD로 ZSET 재생성 | + +모든 경우 "새 이벤트가 들어오면 자연 복구"된다. Hash가 SSOT이므로, Hash만 있으면 score를 언제든 재계산 가능. +단, Hash까지 유실된 경우 장애 전 일간 누적 메트릭 복원 불가 — Redis를 랭킹 유일 저장소로 쓰는 한 불가피한 트레이드오프. + +**읽기 경로 — Redis Replica 장애 시 대응 결정:** + +| 대안 | 적합성 | 선택 여부 | +|------|--------|----------| +| 빈 응답 반환 | UX 저하 | 미채택 | +| **503 에러 응답** | 클라이언트가 재시도 판단 가능 | **채택** | +| DB fallback (product_metrics ORDER BY) | 일간이 아닌 전체 누적 → 데이터 의미 불일치 | 미채택 | +| 로컬 캐시 fallback | 현재 요구사항 대비 과도한 복잡도 | 미채택 | + +근거: 랭킹은 핵심 비즈니스(주문/결제)가 아니므로 일시적 503 허용 가능. DB fallback은 "일간 랭킹"과 "전체 누적"이라는 데이터 의미가 달라 오히려 혼란. + +**상품 상세의 랭킹 정보 — 부분 장애 허용:** +- 상품 상세 API에서 랭킹 조회 실패 시, 상품 정보는 정상 반환하고 ranking=null로 처리 +- 상품 상세는 핵심 기능이므로 부가 정보(랭킹) 실패가 전체 응답을 실패시키면 안 됨 + +**장애 대응 요약:** + +| 장애 | 경로 | 영향 | 대응 | 복구 | +|------|------|------|------|------| +| Redis Master 장애 | 쓰기 | 랭킹 갱신 중단 | Phase 3 try-catch 격리, DB 정상 | 복구 후 자연 재생성 | +| Redis Replica 장애 | 읽기 | 랭킹 API 503 | 에러 응답 | Replica 복구 시 즉시 정상화 | +| Consumer 재시작 | 쓰기 | 수 초 지연 | 멱등성으로 중복 방지 | 자동 | +| Hash/ZSET 유실 | 양쪽 | 일간 데이터 유실 | 이벤트 유입으로 점진 재생성 | 수 분~수 시간 | +| 상품 상세 랭킹 조회 실패 | 읽기 | 랭킹 필드 null | try-catch, 상품 정보 정상 반환 | 자동 | + +### Ranking Read Path (commerce-api) + +**구현 파일:** +- `infrastructure/ranking/RankingRedisRepository.java` — Redis ZSET 읽기 전용 어댑터 +- `interfaces/api/ranking/RankingDto.java` — 랭킹 API 응답 DTO +- `application/ranking/RankingFacade.java` — 랭킹 유스케이스 조율 +- `interfaces/api/ranking/RankingController.java` — `GET /api/v1/rankings` +- `interfaces/api/product/ProductDto.java` — `ranking` 필드 추가 (nullable) +- `application/product/ProductFacade.java` — `lookupRanking()` 추가 + +**RankingRedisRepository — Replica 읽기:** +- `defaultRedisTemplate`(Replica 우선) 주입 — 쓰기(commerce-streamer)와 분리된 읽기 경로 +- 3개 메서드: `getTopN(date, start, end)` → ZREVRANGE WITHSCORES, `getRankAndScore(date, productId)` → ZREVRANK + ZSCORE, `getTotalCount(date)` → ZCARD +- `getTopN`은 `List` record로 반환 — Facade가 Spring Data Redis의 `TypedTuple`에 의존하지 않도록 변환 +- `getRankAndScore`는 0-based reverseRank에 +1하여 1-based rank 반환 +- ZREVRANK null → 랭킹 미진입 → null 반환 + +**RankingFacade — ZSET→DB 2단계 조회:** +1. date null → 오늘(KST) 기본값 적용 +2. Redis ZREVRANGE로 페이지 범위의 `List` 조회 +3. productId 목록으로 DB `findAllByIds` IN 쿼리 (Product + Brand JOIN) +4. ZSET 순서를 유지하면서 상품 정보 merge → RankingResponse 리스트 반환 + +**Top 100 제한:** +- `MAX_RANKING_SIZE = 100` 상수로 총 항목 수를 cap +- ZSET에 수천 상품이 있어도 API는 상위 100개만 노출 +- 페이지 요청이 100 넘으면 빈 페이지 반환 + +**장애 처리 — 503 에러:** +- Redis 조회를 try-catch로 감싸 `CoreException(INTERNAL_ERROR)` 발생 +- 랭킹은 핵심 비즈니스가 아니므로 일시적 503 허용 (장애 시나리오 섹션 결정 사항) +- DB 조회는 Redis 성공 후 실행되므로 Redis 장애 시 DB 부하 없음 + +**ProductRepository.findAllByIds — 랭킹용 IN 쿼리:** +- `ProductJpaRepository`에 `@Query` 추가: `SELECT p, b.name FROM Product p LEFT JOIN Brand b ... WHERE p.id IN :ids` +- `ProductRepositoryImpl`에서 빈 리스트 방어 후 `toProductWithBrand()` 재사용 +- `FakeProductRepository`에도 동일 시그니처 구현 (테스트 호환) + +**ProductDto.ProductResponse — ranking 필드 추가:** +- `RankingDto.RankingInfo ranking` 필드 (nullable) +- `from()` 팩토리 메서드들은 `null` 전달 (일반 목록 조회 시 랭킹 불필요) +- `withRanking(RankingInfo)` 메서드로 캐시된 응답에 랭킹 정보 부착 + +**RankingDto.RankingInfo:** +- `rank, score, date` 3필드 — 상품 상세 응답에 "이 랭킹이 어느 날짜 기준인지" 포함 +- 설계 문서 섹션 7.2 응답 구조 준수 + +**ProductFacade.lookupRanking — 부분 장애 허용:** +- `getProductDetailCached()`에서 상품 정보 조회 후 `lookupRanking()` 호출 +- 오늘 날짜(KST)로 ZREVRANK + ZSCORE 조회, `RankingInfo`에 date도 함께 전달 +- Redis 장애 시 catch하여 WARN 로그 + `ranking=null` 반환 → 상품 상세는 정상 응답 +- `RankingRedisRepository`가 null 주입(테스트)이어도 NPE를 catch하여 안전 + +**RankingController:** +- `GET /api/v1/rankings?date=yyyyMMdd&page=0&size=20` +- `date` 파라미터 optional — 생략 시 RankingFacade에서 오늘(KST) 기본값 적용 +- 기존 `ApiResponse` 래퍼 사용, `ProductController` 패턴 준수 +- page 기본값 0, size 기본값 20 + +**Rank 계산:** +- 1-based rank = `page * size + 1`부터 시작 +- ZREVRANGE가 반환하는 순서(score 내림차순)를 그대로 유지 +- 삭제된 상품은 DB 조회 결과에 없으므로 응답에서 자동 제외 (rank 번호는 순차 증가) + +**테스트 호환성 수정 (4개 파일):** +- `CaffeineProductCacheAdapterTest` — `ProductResponse` 생성자에 `null`(ranking) 추가 +- `MultiLayerProductCacheAdapterTest` — `detailResponse()` 헬퍼에 `null`(ranking) 추가 +- `ProductFacadeTest` — `ProductFacade` 생성자에 `null`(RankingRedisRepository) 추가 +- `FakeProductRepository` — `findAllByIds()` 구현 추가 + +### product_metrics 스키마 재설계 (commerce-streamer) + +**변경 동기:** +기존 `product_metrics`는 `product_id`를 PK로 전체 기간 누적만 저장했다. 세 가지 문제가 있었다: +1. **시간 축 부재** — 일별 트렌드 분석 불가, Redis 장애 시 일간 재집계 불가 +2. **취소 이력 소실** — `sales_count += -3` 방식은 원래 몇 건이었는지 복원 불가 +3. **데이터 정리 불가** — 행이 하나뿐이라 오래된 데이터 purge 불가 + +**변경 파일:** +- `application/ranking/MetricsDelta.java` — 7개 additive DB 필드 + Redis net delta 파생 getter +- `interfaces/consumer/MetricsConsumer.java` — Phase 1 이벤트 매핑 + Phase 2 UPSERT SQL +- `application/ranking/RankingScoreUpdater.java` — pipelineHincrby에서 net getter 사용 +- `domain/metrics/ProductMetrics.java` — JPA 엔티티 (DDL 생성용) +- `domain/metrics/ProductMetricsId.java` — 복합 PK용 `@IdClass` (신규) + +**스키마 변경 (AS-IS → TO-BE):** +``` +AS-IS: PK = (product_id), 4 컬럼 (like_count, view_count, sales_count, sales_amount) +TO-BE: PK = (product_id, metric_date), 7 컬럼 + idx_metric_date 인덱스 +``` +- 그레인(Grain) = `daily × product` — 한 행이 "특정 상품의 특정 날짜 메트릭"을 의미 +- 모든 컬럼이 Additive(양수 누적)이므로 어떤 차원으로든 `SUM` 가능 + +**MetricsDelta 재설계 — DB 표현 기반 + Redis 파생:** +핵심 결정: MetricsDelta가 DB의 7개 additive 컬럼을 기본 필드로 저장하고, Redis用 net delta는 파생 getter로 제공한다. + +``` +DB (Phase 2) 필드: Redis (Phase 3) 파생 getter: + viewDelta ──→ getViewDelta() (그대로) + likeDelta ──→ getNetLikeDelta() = likeDelta - unlikeDelta + unlikeDelta ──→ (DB 전용) + salesCountDelta ──→ getNetSalesCountDelta() = salesCountDelta - cancelCountDelta + salesAmountDelta ──→ getNetSalesAmountDelta()= salesAmountDelta - cancelAmountDelta + cancelCountDelta ──→ (DB 전용) + cancelAmountDelta ──→ (DB 전용) +``` + +이 설계의 장점: +- 하나의 deltaMap으로 Phase 2와 Phase 3이 각자 필요한 getter만 호출 +- merge() 시 모든 필드가 단순 덧셈으로 합산되어 정합성 보장 +- Redis의 net 의미론(`like - unlike`)이 DB 필드에서 자연스럽게 유도됨 + +**이벤트별 factory 메서드 매핑:** + +| 이벤트 | factory 메서드 | DB 필드 | Redis net delta | +|--------|---------------|---------|----------------| +| LIKE_CREATED | `ofLike()` | likeDelta=1 | netLike=+1 | +| LIKE_REMOVED | `ofUnlike()` | unlikeDelta=1 | netLike=-1 | +| PRODUCT_VIEWED | `ofView()` | viewDelta=1 | view=+1 | +| ORDER_CREATED | `ofSales(count, amount)` | salesCount/Amount | netSales=+count/amount | +| ORDER_CANCELLED | `ofCancel(count, amount)` | cancelCount/Amount | netSales=-count/amount | + +**MetricsConsumer Phase 1 변경:** +- `LIKE_CREATED`: `ofLike(1)` → `ofLike()` (인자 제거) +- `LIKE_REMOVED`: `ofLike(-1)` → `ofUnlike()` (별도 factory) +- `ORDER_CANCELLED`: `ofSales(-count, -amount)` → `ofCancel(count, amount)` (양수 전달) + +**MetricsConsumer Phase 2 변경:** +```sql +-- AS-IS: PK = product_id, 4 컬럼 +INSERT INTO product_metrics (product_id, like_count, view_count, sales_count, sales_amount) +VALUES (?, ?, ?, ?, ?) +ON DUPLICATE KEY UPDATE like_count = like_count + VALUES(like_count), ... + +-- TO-BE: PK = (product_id, metric_date), 7 컬럼 +INSERT INTO product_metrics + (product_id, metric_date, view_count, like_count, unlike_count, + sales_count, sales_amount, cancel_count, cancel_amount) +VALUES (?, CURDATE(), ?, ?, ?, ?, ?, ?, ?) +ON DUPLICATE KEY UPDATE + view_count = view_count + VALUES(view_count), ... +``` +- `CURDATE()`로 일별 파티셔닝 — 같은 상품이라도 날짜가 다르면 다른 행 +- INSERT의 9개 파라미터: productId + 7개 DB delta 값 + +**RankingScoreUpdater 변경 (Phase 3):** +- `getLikeDelta()` → `getNetLikeDelta()` (Redis HINCRBY에는 net값 전달) +- `getSalesCountDelta()` → `getNetSalesCountDelta()` +- `getSalesAmountDelta()` → `getNetSalesAmountDelta()` +- `getViewDelta()`는 변경 없음 (view에는 취소 개념 없음) + +**ProductMetrics JPA 엔티티:** +- `@IdClass(ProductMetricsId.class)` 복합 PK: `(productId, metricDate)` +- 7개 메트릭 컬럼 + `@Index(name = "idx_metric_date")` +- `updatedAt` 컬럼 제거 (새 스키마에서는 metric_date가 시간 축 역할) +- 이 엔티티는 DDL 자동 생성(`ddl-auto: create`)용이며, MetricsConsumer는 JdbcTemplate으로 직접 SQL 실행 + +**follow-up 필요:** +- `MetricsReconcileTasklet`(commerce-batch)의 네이티브 SQL도 새 스키마에 맞게 수정 필요 — 이번 라운드에서는 설계 문서 범위(섹션 11.1) 외이므로 별도 작업으로 기록 + +### Late-Arriving Fact 이중 기록 (commerce-streamer, commerce-api) + +**설계 근거:** +ORDER_CANCELLED는 주문일과 다른 날짜에 발생한다 (예: 4/1 주문 → 4/5 취소). +인식일(CURDATE) 기준으로만 기록하면 4/1의 순매출을 계산할 때 취소분이 4/5 행에만 존재하여 정합성이 깨진다. +설계 문서 섹션 2.5 "설계 원칙 2" — 인식일 + 발생일 이중 기록으로 해결. + +**product_metrics 컬럼 변경:** +``` +AS-IS: + cancel_count INT NOT NULL DEFAULT 0 + cancel_amount BIGINT NOT NULL DEFAULT 0 + +TO-BE: + cancel_count_by_event_date INT NOT NULL DEFAULT 0 -- 인식일 기준 + cancel_amount_by_event_date BIGINT NOT NULL DEFAULT 0 + cancel_count_by_order_date INT NOT NULL DEFAULT 0 -- 발생일(원주문일) 기준 + cancel_amount_by_order_date BIGINT NOT NULL DEFAULT 0 +``` + +**변경 파일:** +- `domain/metrics/ProductMetrics.java` — 엔티티 컬럼 rename + 2개 추가 +- `application/order/OrderFacade.java` (commerce-api) — ORDER_CANCELLED 이벤트에 `originalOrderDate` 필드 추가 +- `interfaces/consumer/MetricsConsumer.java` — Phase 2 이중 UPSERT 구현 + +**OrderFacade 변경:** +- `cancelOrder()`에서 `order.getCreatedAt().toLocalDate().toString()`으로 원주문일 추출 +- 이벤트 payload Map에 `"originalOrderDate"` 필드 추가 + +**MetricsConsumer 이중 UPSERT — 방법 B 채택:** + +방법 비교: +| 방법 | 설명 | 장점 | 단점 | +|------|------|------|------| +| A | MetricsDelta에 originalOrderDate 필드 추가 | 단일 구조 | deltaMap.merge에서 날짜 충돌 | +| **B** | **별도 LateArrivingCancel 리스트** | **deltaMap 구조 불변, 관심사 분리** | **추가 리스트 관리** | +| C | Phase 2에서 원본 records 재순회 | 코드 변경 최소 | Phase 2에서 JSON 재파싱 필요 | + +**방법 B 채택 근거:** +- MetricsDelta는 Semantic Definition(의미적 정의)이다. 필드명 `cancelCountDelta`는 "취소 delta"라는 의미이지, DB 컬럼명(`cancel_count_by_event_date`)과 1:1 대응이 아니다 +- Phase 2 UPSERT가 MetricsDelta의 의미를 DB 컬럼에 매핑하는 책임을 갖는다 +- MetricsDelta를 변경하지 않으므로 기존 Phase 3(Redis)에 영향 없음 + +**구현 상세:** +``` +Phase 1: processRecord() 내부 + ORDER_CANCELLED 수신 시: + 1. deltaMap.merge(productId, ofCancel(count, amount)) ← 기존과 동일 + 2. lateArrivingCancels.add(LateArrivingCancel(productId, orderDate, count, amount)) ← 추가 + +Phase 2: transactionTemplate 내부 + 1. 인식일 UPSERT (기존 로직, 컬럼명만 변경): + INSERT INTO product_metrics (..., cancel_count_by_event_date, cancel_amount_by_event_date) + VALUES (?, CURDATE(), ...) ON DUPLICATE KEY UPDATE ... + 2. 발생일 UPSERT (신규): + INSERT INTO product_metrics (product_id, metric_date, cancel_count_by_order_date, cancel_amount_by_order_date) + VALUES (?, ?, ?, ?) -- metric_date = originalOrderDate + ON DUPLICATE KEY UPDATE cancel_count_by_order_date += ..., cancel_amount_by_order_date += ... +``` + +**하위 호환성:** +- `originalOrderDate` 미포함 이벤트(구버전)는 인식일 UPSERT만 실행, 발생일 UPSERT 스킵 +- 파싱 실패 시 warn 로그 + 인식일 UPSERT는 정상 실행 (장애 격리) + +**정합성 검증 SQL:** +```sql +-- 충분히 긴 기간으로 합산하면 두 기준의 합계가 같아야 함 +SELECT SUM(cancel_count_by_order_date) AS by_order, + SUM(cancel_count_by_event_date) AS by_event +FROM product_metrics WHERE product_id = ?; +``` + +**테스트 (`MetricsConsumerTest`) — 6개 전체 PASS:** + +| 테스트 | 검증 내용 | +|--------|-----------| +| cancelledEvent_dualUpsert | ORDER_CANCELLED 이벤트에 인식일+발생일 이중 UPSERT 실행 | +| cancelledEvent_noOriginalOrderDate_singleUpsert | originalOrderDate 없으면 인식일만 실행 | +| crossDateCancel_twoDistinctUpserts | 인식일 SQL은 CURDATE() 사용, 발생일 SQL은 파라미터 전달 | +| invalidOriginalOrderDate_eventDateUpsertStillWorks | 파싱 실패 시 인식일 UPSERT 정상 실행 | +| orderCreated_noByOrderDateUpsert | ORDER_CREATED는 발생일 UPSERT 미실행 | +| productViewed_upsertContainsViewCount | PRODUCT_VIEWED는 view_count UPSERT 정상 실행 | + +### Lambda Architecture 배치 보정 잡 (commerce-batch) + +**설계 근거:** +실시간 경로(Kafka → Redis)는 이벤트 유실, 처리 순서, 부동소수점 누적 오차 등으로 DB 원장과 드리프트가 발생할 수 있다. +설계 문서 섹션 2.5 "설계 원칙 4" + 섹션 11.3 — Lambda Architecture의 배치 레이어가 1시간 주기로 DB 원장 기준 Redis를 덮어쓴다. + +**구현 파일:** +- `batch/job/rankingcorrection/RankingCorrectionJobConfig.java` — chunk-oriented 배치 잡 +- `batch/job/rankingcorrection/RankingCorrectionProperties.java` — 가중치 설정 레코드 +- `application.yml` — `ranking.weights.*` 추가 + +**chunk-oriented 처리 선택 이유:** +기존 배치 잡은 모두 Tasklet 패턴이지만, 이 잡은 "DB 읽기 → Score 계산 → Redis 쓰기" 흐름이므로 chunk-oriented가 적합: +- Reader: JdbcCursorItemReader — `idx_metric_date` 인덱스 활용, 메모리 효율적 +- Writer: Redis Pipeline으로 chunk(1,000건) 단위 일괄 적재 +- 상품 수가 증가해도 메모리 사용량이 chunk 크기에 비례하여 안정적 + +**DB 원장 조회 SQL:** +```sql +SELECT product_id, view_count, + (like_count - unlike_count) AS net_like, + sales_count, + (sales_amount - cancel_amount_by_event_date) AS net_sales_amount +FROM product_metrics +WHERE metric_date = CURDATE() +``` +- `net_like = like_count - unlike_count` → DB에서 net 계산 +- `net_sales_amount = sales_amount - cancel_amount_by_event_date` → 인식일 기준 취소 반영 + +**Redis 덮어쓰기 (Pipeline):** +``` +chunk 단위 Pipeline: + productId마다: + DEL ranking:metrics:{date}:{pid} -- 기존 Hash 삭제 (stale 필드 방지) + HSET ranking:metrics:{date}:{pid} viewCount ... likeCount ... salesCount ... salesAmount ... + ZADD ranking:all:{date} {score} {pid} -- score 덮어쓰기 + EXPIRE ranking:metrics:{date}:{pid} 172800 + 마지막: + EXPIRE ranking:all:{date} 172800 +``` + +**Score 수식 — RankingScoreUpdater와 동일 (Semantic Definition):** +``` +score = W(view) × log₁₀(viewCount + 1) + + W(like) × log₁₀(netLike + 1) + + W(order) × log₁₀(netSalesAmount + 1) + + productId × 1e-10 +``` +- 가중치: `ranking.weights.*` yml 설정에서 읽음 (streamer와 동일 값) +- 키 prefix, TTL, date format: RankingScoreUpdater의 상수와 동일 값을 배치 잡에서 재정의 +- `max(0, value)` 음수 클램핑 동일 적용 + +**실행 방식:** +```bash +java -jar commerce-batch.jar --spring.batch.job.name=rankingCorrectionJob +``` +- 외부 스케줄러(Kubernetes CronJob 등)로 1시간 주기 실행 +- `@ConditionalOnProperty` 패턴으로 기존 배치 잡과 동일한 구조 + +**Race Condition 안전성:** +- 배치 실행 중 실시간 이벤트가 Redis에 HINCRBY→ZADD로 기록될 수 있음 +- 배치의 ZADD는 DB 원장 기준 score를 "덮어쓰기"하므로, 실시간 이벤트의 미세한 delta가 유실될 수 있음 +- 허용 범위: 최대 1 chunk(1,000건) 처리 시간 동안의 이벤트 delta. 다음 실시간 이벤트에서 HINCRBY→ZADD로 복구됨 + +**테스트 (`RankingCorrectionScoreTest`) — 7개 전체 PASS:** + +| 카테고리 | 테스트 수 | 검증 내용 | +|----------|-----------|-----------| +| Score 수식 일치 | 5 | 모든 메트릭 0, view/like/order 단독, 복합 score — RankingScoreUpdater와 동일 결과 | +| 음수 방어 | 2 | netLike/netSalesAmount 음수 → 0 클램핑 | +| 타이브레이커 | 1 | 동점 시 높은 productId 상위 | diff --git a/docs/images/grafana-10m-error-hikari-jvm.png b/docs/images/grafana-10m-error-hikari-jvm.png new file mode 100644 index 0000000000..82cda36185 Binary files /dev/null and b/docs/images/grafana-10m-error-hikari-jvm.png differ diff --git a/docs/images/grafana-10m-l1l2-error-hikari-jvm.png b/docs/images/grafana-10m-l1l2-error-hikari-jvm.png new file mode 100644 index 0000000000..1e2c3e67d0 Binary files /dev/null and b/docs/images/grafana-10m-l1l2-error-hikari-jvm.png differ diff --git a/docs/images/grafana-10m-l1l2-response-time-rps.png b/docs/images/grafana-10m-l1l2-response-time-rps.png new file mode 100644 index 0000000000..2bc0527c94 Binary files /dev/null and b/docs/images/grafana-10m-l1l2-response-time-rps.png differ diff --git a/docs/images/grafana-10m-response-time-rps.png b/docs/images/grafana-10m-response-time-rps.png new file mode 100644 index 0000000000..2bf5de2cbc Binary files /dev/null and b/docs/images/grafana-10m-response-time-rps.png differ diff --git a/docs/images/grafana-dashboard-bottom.png b/docs/images/grafana-dashboard-bottom.png new file mode 100644 index 0000000000..cc7df5cd08 Binary files /dev/null and b/docs/images/grafana-dashboard-bottom.png differ diff --git a/docs/images/grafana-dashboard-middle.png b/docs/images/grafana-dashboard-middle.png new file mode 100644 index 0000000000..8a65247319 Binary files /dev/null and b/docs/images/grafana-dashboard-middle.png differ diff --git a/docs/images/grafana-dashboard-top.png b/docs/images/grafana-dashboard-top.png new file mode 100644 index 0000000000..bb5f35a724 Binary files /dev/null and b/docs/images/grafana-dashboard-top.png differ diff --git a/docs/images/grafana-error-hikari-jvm.png b/docs/images/grafana-error-hikari-jvm.png new file mode 100644 index 0000000000..6af05da023 Binary files /dev/null and b/docs/images/grafana-error-hikari-jvm.png differ diff --git a/docs/images/grafana-response-time-rps.png b/docs/images/grafana-response-time-rps.png new file mode 100644 index 0000000000..f8be17296a Binary files /dev/null and b/docs/images/grafana-response-time-rps.png differ diff --git a/docs/requirements/volume-10/10-batch-ranking-learning.md b/docs/requirements/volume-10/10-batch-ranking-learning.md new file mode 100644 index 0000000000..008c1fab75 --- /dev/null +++ b/docs/requirements/volume-10/10-batch-ranking-learning.md @@ -0,0 +1,170 @@ +# Round 10 — Collect, Stack, Zip + +--- + +## Overview + +> 서비스에서 다양한 가치를 창출하기 위해 대량의 데이터를 모으고, 쌓고, 압착해야 합니다. +> 데이터의 규모가 커지면, 점점 이런 작업들을 웹 애플리케이션 내에서 처리하는 것에 대한 부하가 가파르게 높아집니다. +> +> 그래서 우리는 마지막으로 `spring-batch` 애플리케이션을 만들어 볼 거예요. +> 이를 기반으로 일간 랭킹 뿐 아닌 주간, 월간 랭킹 또한 집계를 활용해 만들어 봅시다. + +**Summary** + +지난 라운드에서 Kafka Consumer 와 Redis ZSET 을 활용해 메세지를 압착해 처리량을 높이는 테크닉, 특정 점수 기준의 정렬 SET 활용 방법을 학습하고 실시간으로 갱신되는 일단위 랭킹을 만들어보았습니다. + +이번 라운드에서는 Spring Batch 를 이용해 주간, 월간 랭킹을 구현합니다. +**Batch** 는 일간 집계를 기반으로 주간, 월간 집계를 만들어내고 **API** 는 일간 랭킹 뿐 아니라 주간, 월간 랭킹도 제공합니다. + +**Keywords** + +- Spring Batch (Job / Step / Chunk / Tasklet) +- ItemReader / ItemProcessor / ItemWriter +- Materialized View (사전 집계) +- 실시간 처리 vs 배치 처리 + +--- + +## Batch System + +### Batch Processing 이 왜 필요할까? + +- **대규모 집계** + - 수억 건 데이터에 대한 합산, 평균, 통계는 실시간으로 처리하기엔 비용이 너무 크다. + - e.g. "지난 한 달간 상품별 매출 TOP 100" → 매 요청마다 계산하면 DB/Redis 부하로 서비스 전체가 흔들림 +- **운영 리포트/통계** + - 경영진 보고용, BI 툴, 월간 정산 등은 수 초 단위의 실시간성이 필요하지 않음 + - 정확성과 대량처리가 더 중요 → 하루 한 번 배치로 계산해도 충분 +- **데이터 정제 및 적재** + - 로그 수집 → 정제 → DW 적재 같은 과정은 실시간보다는 일정 주기 단위로 몰아서 처리하는 게 효율적 + +### 실무에서 자주 보는 배치 시나리오 + +- **주문 정산** — 주문/결제/환불 데이터를 모아 매일 새벽 3시 정산 테이블 생성. PG사 매출/정산 금액 검증도 함께. +- **랭킹/통계 적재** — 일간/주간/월간 인기 상품 집계, 카테고리별 판매량 통계 +- **데이터 정리/청소** — 만료된 쿠폰 삭제, 오래된 로그 제거, 캐시 초기화 +- **데이터 웨어하우스(DW) 적재** — 서비스 DB → DW(BigQuery, Redshift 등) 로 적재 후 분석 + +### 실시간 vs 배치 트레이드오프 + +| 항목 | 실시간 처리 | 배치 처리 | +|------|------------|----------| +| 장점 | 즉각 반영 → UX 좋음 | 대규모 집계, 비용 효율적 | +| 단점 | 인프라 복잡, 멱등성 관리 필요 | 지연 발생, 실시간성 부족 | +| 적합 | 좋아요 수, 실시간 랭킹 | 월간 리포트, 대시보드, BI | +| 초점 | **신속성** | **정확성 & 효율성** | + +--- + +## Spring Batch + +### 기본 구성 요소 + +- **Job** : 배치 실행 단위 (예: "일간 주문 통계 Job") +- **Step** : Job 을 구성하는 세부 단계 + +### 배치 처리 모델 + +**Chunk-Oriented Processing** + +- 데이터 읽기 (Reader) → 가공 (Processor) → 저장 (Writer) +- 청크 단위로 트랜잭션이 관리됨 → 안정적 대량 처리 + +```java +@Bean +public Step orderStatsStep( + JobRepository jobRepository, + PlatformTransactionManager txManager, + ItemReader reader, + ItemProcessor processor, + ItemWriter writer +) { + return new StepBuilder("orderStatsStep", jobRepository) + .chunk(1000, txManager) + .reader(reader) + .processor(processor) + .writer(writer) + .build(); +} +``` + +장점: +- 대규모 집계/정산/데이터 변환에 적합 +- 트랜잭션 단위 조절 가능 + +**Tasklet** + +- Step = 하나의 작업(Task) 실행 +- 반복 구조 없음, 단발성 작업에 적합 + +```java +@Bean +public Step cleanupStep( + JobRepository jobRepository, + PlatformTransactionManager txManager +) { + return new StepBuilder("cleanupStep", jobRepository) + .tasklet((contribution, chunkContext) -> { + orderRepository.deleteOldOrders(); // 만료 주문 삭제 + return RepeatStatus.FINISHED; + }, txManager) + .build(); +} +``` + +장점: +- 간단한 SQL 실행, 파일 이동, 캐시 초기화 등에 적합 +- Reader/Processor/Writer 필요 없는 작업에 깔끔 + +> 일반적으로 **구현의 용이성** 등을 이유로 Tasklet 내에서 로직 상으로 Chunk Oriented Processing 을 구현하기도 합니다. + +--- + +## Materialized View + +> 이전에 **Join 한계를 극복하기 위한 조회 전용 구조**로서 Materialized View 에 대해 언급되었던 적이 있었습니다. +> 이번엔 **복잡한 집계 쿼리를 극복하기 위한 조회 전용 구조**로서 Materialized View 를 만나볼 거예요. + +- **복잡한 집계 쿼리를 미리 계산해둔 조회 전용 구조** +- MySQL 은 MV 기능이 별도로 없으므로 보통 **별도 테이블 + 배치 적재** 방식 사용 +- 주기적으로 대규모 데이터 (각 상품의 일별 일간 집계) 를 주기적으로 집계해 활용 + +```sql +CREATE TABLE product_metrics_weekly ( -- 주간 상품 이벤트 집계 + product_id BIGINT PRIMARY KEY, + like_count INT, + order_count INT, + view_count INT, + yearMonthWeek VARCHAR, -- 예시입니다. + updated_at DATETIME +); + +CREATE TABLE product_metrics_monthly ( -- 월간 상품 이벤트 집계 + product_id BIGINT PRIMARY KEY, + like_count INT, + order_count INT, + view_count INT, + yearMonth VARCHAR, -- 예시입니다. + updated_at DATETIME +); +``` + +--- + +## 운영 관점에서의 배치 전략 + +- **스케줄링** : Spring Scheduler, Quartz 혹은 인프라 (Cron + K8s) +- **재실행 전략** : 실패 시 부분 롤백 vs 전체 재실행 +- **병렬 Step** : 여러 Step 을 동시에 실행해 성능 향상 +- **모니터링** : 실행 로그, 실패 알림, 처리 건수 추적 + +--- + +## References + +| 구분 | 링크 | +|------|------| +| Spring Batch | [Spring Docs - Spring Batch](https://docs.spring.io/spring-batch/reference/) | +| Spring Boot with Spring Batch | [Baeldung - Spring Boot with Spring Batch](https://www.baeldung.com/spring-boot-spring-batch) | +| Materialized View | [AWS - What is Materialized View](https://aws.amazon.com/ko/what-is/materialized-view/) | diff --git a/docs/requirements/volume-10/10-batch-ranking-progress.md b/docs/requirements/volume-10/10-batch-ranking-progress.md new file mode 100644 index 0000000000..15d0e7c9b3 --- /dev/null +++ b/docs/requirements/volume-10/10-batch-ranking-progress.md @@ -0,0 +1,323 @@ +# Round 10 — 개념 공부 로드맵 & 과제 진도표 + +--- + +## Part A. 개념 공부 로드맵 + +> 초급 개발자 대상. 선수 지식부터 실무 적용까지 순서대로 구성. +> "이것을 모르면 다음 단계가 막힌다"는 기준으로 의존 순서를 잡았다. + +### Step 1. 선수 지식 점검 + +과제를 시작하기 전에 확실해야 하는 기초 체력. + +| 주제 | 왜 필요한가 | 확인 질문 | +|------|------------|----------| +| SQL 집계 함수 | Batch Reader가 읽는 쿼리를 이해해야 한다 | `GROUP BY`, `SUM`, `HAVING`, 서브쿼리로 "상품별 주간 매출 TOP 100"을 작성할 수 있는가? | +| 트랜잭션 기초 | Chunk 단위 커밋/롤백의 의미를 이해해야 한다 | `@Transactional`의 propagation, rollbackFor를 설명할 수 있는가? | +| Spring Bean 생명주기 | JobScope, StepScope가 왜 존재하는지 이해해야 한다 | `@Scope("step")`이 일반 singleton과 뭐가 다른지 설명할 수 있는가? | +| JDBC vs JPA 차이 | ItemReader 선택(JdbcCursorItemReader vs JpaPagingItemReader)에 영향 | 대량 조회에서 JPA N+1이 왜 위험한지 아는가? | + +### Step 2. 배치 처리의 본질 + +코드를 쓰기 전에 "왜 배치인가"를 먼저 이해해야 한다. + +**핵심 질문: "이 작업을 왜 API 서버에서 안 하는가?"** + +| 개념 | 설명 | 연결 | +|------|------|------| +| 실시간 vs 배치 트레이드오프 | 실시간은 신속성, 배치는 정확성+효율성 | 우리 프로젝트: 일간 랭킹은 실시간(Redis), 주간/월간 집계는 배치(Spring Batch) | +| 멱등성(Idempotency) | 같은 Job을 두 번 돌려도 결과가 같아야 한다 | MV 테이블에 UPSERT or DELETE+INSERT 전략 | +| 대량 데이터와 메모리 | 10만 행을 한 번에 읽으면 OOM | Chunk 단위 처리로 메모리 제어 | + +**반드시 읽어볼 것:** +- 과제 개념 문서의 "실시간 vs 배치 트레이드오프" 표 +- 실무 배치 시나리오 4가지 (정산, 랭킹, 정리, DW 적재) + +### Step 3. Spring Batch 아키텍처 + +Spring Batch의 계층 구조를 이해해야 코드가 읽힌다. + +``` +JobLauncher + └── Job (실행 단위) + └── Step (세부 단계, 1개 이상) + ├── Chunk-Oriented: Reader → Processor → Writer + └── Tasklet: 단발성 작업 +``` + +**필수 개념:** + +| 개념 | 설명 | 왜 중요한가 | +|------|------|------------| +| Job / Step | 배치의 실행 단위와 세부 단계 | Job 1개에 Step 여러 개 가능. 순서 제어, 조건 분기 | +| JobRepository | Job 실행 이력을 DB에 기록 (메타 테이블) | 재실행 판단, 실패 복구의 근거. `BATCH_JOB_INSTANCE`, `BATCH_JOB_EXECUTION` 등 | +| JobParameters | Job 실행 시 전달하는 파라미터 | 같은 Job을 날짜별로 실행 (e.g. `targetDate=20260414`) | +| @JobScope / @StepScope | JobParameter를 주입받기 위한 지연 생성 | `@Value("#{jobParameters['targetDate']}")`가 동작하려면 필수 | +| Chunk | N건씩 읽고-가공하고-쓰는 반복 단위 | chunk(1000) = 1000건 읽고 쓴 후 커밋. 실패 시 해당 chunk만 롤백 | + +**선수 관계:** Step 2(왜 배치인가) → Step 3(Spring Batch 구조) + +### Step 4. Chunk-Oriented Processing 상세 + +대량 데이터 처리의 핵심 패턴. + +``` +while (hasMore) { + List items = reader.read(chunkSize); // DB에서 N건 읽기 + List outputs = new ArrayList<>(); + for (I item : items) { + outputs.add(processor.process(item)); // 가공 + } + writer.write(outputs); // 일괄 저장 + transaction.commit(); // chunk 단위 커밋 +} +``` + +**ItemReader 종류 비교 (시니어가 반드시 알아야 할 차이):** + +| Reader | 동작 방식 | 장점 | 주의점 | +|--------|----------|------|--------| +| JdbcCursorItemReader | DB 커서를 열고 한 행씩 fetch | 메모리 효율적, 순서 보장 | 커넥션을 Step 동안 유지 → 커넥션 풀 점유 | +| JdbcPagingItemReader | LIMIT/OFFSET으로 페이지 단위 조회 | 커넥션 점유 짧음 | 정렬 기준 필수, 데이터 변경 시 누락/중복 가능 | +| JpaPagingItemReader | JPA로 페이지 조회 | 엔티티 매핑 편리 | N+1 위험, 대량에서 성능 저하 | + +**우리 프로젝트의 선택:** 기존 RankingCorrectionJob이 `JdbcCursorItemReader` 사용 → 동일 패턴 재활용 + +**Chunk Size 결정 기준 (자주 놓치는 포인트):** + +| chunk size | 효과 | +|-----------|------| +| 너무 작음 (10) | 커밋 횟수 ↑, DB I/O 오버헤드 ↑ | +| 너무 큼 (100,000) | 메모리 ↑, 실패 시 재처리 범위 ↑ | +| **적정 (500~5,000)** | 기존 Job이 1,000으로 설정. 벤치마크로 조정 | + +### Step 5. Materialized View + +**핵심: "미리 계산해둔 조회 전용 테이블"** + +| 개념 | 설명 | +|------|------| +| MV란? | 복잡한 집계 쿼리 결과를 별도 테이블에 저장. 조회 시 집계 없이 SELECT만 | +| MySQL에서의 MV | 네이티브 MV 미지원 → 별도 테이블 + 배치 적재로 구현 | +| 갱신 전략 | 전체 교체(DELETE + INSERT) vs 증분(UPSERT). 데이터 크기와 빈도에 따라 선택 | +| 원본과의 관계 | `product_metrics`(원본) → Spring Batch → `mv_product_rank_weekly/monthly`(MV) | + +**MV vs 실시간 집계:** + +| 기준 | 실시간 집계 (매 요청) | MV (사전 집계) | +|------|---------------------|---------------| +| 조회 속도 | 느림 (GROUP BY + ORDER BY) | 빠름 (단순 SELECT) | +| 데이터 신선도 | 항상 최신 | 배치 주기만큼 stale | +| DB 부하 | 높음 (매번 집계) | 낮음 (배치 때만) | +| 적합 | 소규모, 실시간 필수 | 대규모, 주기적 갱신 허용 | + +### Step 6. 우리 프로젝트 맥락 (기존 구현과의 연결) + +Round 9에서 이미 구축한 것과 Round 10의 관계를 이해해야 설계 판단이 가능하다. + +**현재 아키텍처 (Round 9 완성):** + +``` +[실시간 경로 — Speed Layer] + Kafka → MetricsConsumer → Redis Hash/ZSET (일간) + → 23:50 스케줄러: carry-over + ZUNIONSTORE (주간/월간 Redis ZSET) + +[배치 보정 — Batch Layer] + product_metrics(DB) → RankingCorrectionJob → Redis Hash/ZSET 덮어쓰기 + +[API] + GET /api/v1/rankings?scope=daily|weekly|monthly → Redis ZSET 조회 +``` + +**Round 10이 추가하는 것:** + +``` +[배치 집계 — Materialized View Layer] + product_metrics(DB) → Spring Batch Job → mv_product_rank_weekly/monthly(DB) + +[API 확장] + 주간/월간 요청 → MV 테이블 조회 (Redis 대신 or 함께) +``` + +**핵심 설계 질문: Redis ZSET 주간/월간과 MV 테이블은 어떻게 공존하는가?** + +| 관점 | Redis ZSET (기존) | MV 테이블 (신규) | +|------|-------------------|-----------------| +| 데이터 소스 | Redis carry-over 기반 | DB product_metrics 기반 | +| 신선도 | 일 1회 갱신 (23:50) | 배치 주기 (일 1회) | +| 정확도 | carry-over 누적 근사치 | DB 원장 기반 정확값 | +| 조회 속도 | O(log N + M) ~0.01ms | DB SELECT ~수ms | +| 장애 시 | Redis 장애 → 조회 불가 | DB만 살아있으면 조회 가능 | + +→ 이 트레이드오프를 설계 문서에서 분석하고 판단을 내려야 한다. + +### Step 7. 운영 관점 (시니어가 강조하는 포인트) + +코드가 동작하는 것과 운영 가능한 것은 다르다. + +| 주제 | 질문 | 왜 중요한가 | +|------|------|------------| +| 멱등성 | 같은 날짜로 Job을 두 번 돌리면? | MV 데이터가 2배가 되면 안 된다 | +| 실패 복구 | Step 2에서 실패하면 Step 1부터 다시? | Spring Batch의 재시작 메커니즘 이해 | +| 모니터링 | Job이 성공했는지 어떻게 아는가? | 처리 건수, 소요 시간, 실패 알림 | +| 스케줄링 | 언제 돌리는가? 다른 Job과 충돌은? | 기존 23:50 스케줄러, RankingCorrectionJob과의 시간 배치 | +| 데이터 정합성 | MV와 Redis 랭킹이 다르면? | 어느 쪽이 source of truth인지 정해야 한다 | + +--- + +## Part B. 과제 수행 진도표 + +### 기존 구현 현황 (Round 9) + +| 항목 | 상태 | 비고 | +|------|------|------| +| commerce-batch 모듈 (Spring Batch) | ✅ 완료 | 6개 Job 운영 중 | +| product_metrics 테이블 (daily grain) | ✅ 완료 | PK: (product_id, metric_date) | +| RankingCorrectionJob (배치 보정) | ✅ 완료 | Chunk 1,000, JdbcCursorItemReader | +| Redis 일간/주간/월간 ZSET | ✅ 완료 | 23:50 carry-over + ZUNIONSTORE | +| Ranking API (scope 파라미터) | ✅ 완료 | daily/weekly/monthly → Redis 조회 | +| MV 테이블 | ❌ 미구현 | Round 10 핵심 과제 | + +--- + +### Phase 0. 설계 + +> 코드를 쓰기 전에 결정해야 할 것들. + +- [ ] **0-1. 아키텍처 결정: Redis ZSET vs MV 테이블 역할 분담** + - Redis 주간/월간과 MV 테이블의 공존 전략 (대체? 보완? fallback?) + - API가 어느 소스에서 읽는가 (scope별 분기) + - 설계 문서에 판단 근거 기록 + +- [ ] **0-2. MV 테이블 스키마 설계** + - `mv_product_rank_weekly` / `mv_product_rank_monthly` DDL + - PK 구성 (product_id + 기간키? 별도 id?) + - 저장 항목: rank, score, 개별 메트릭(view/like/order), 기간 식별자 + - TOP 100만 저장하는 전략 (Writer에서 제한? 쿼리에서 제한?) + +- [ ] **0-3. Spring Batch Job 설계** + - Job 이름, Step 구성 (단일 Step? 다중 Step?) + - Reader: product_metrics에서 기간별 집계 쿼리 + - Processor: score 계산 (기존 calculateScore 재활용) + - Writer: MV 테이블 적재 (UPSERT vs DELETE+INSERT) + - JobParameter: targetDate, scope(weekly/monthly) + - 멱등성 보장 전략 + +- [ ] **0-4. 스케줄링/실행 전략** + - 실행 시점 (기존 23:50 carry-over, RankingCorrectionJob과의 관계) + - 주간 Job 실행 주기 (매일? 월요일만?) + - 월간 Job 실행 주기 (매일? 월초만?) + +- [ ] **0-5. 설계 문서 작성** + - `docs/design/10-batch-ranking-system.md` 생성 + - 기존 09 설계와의 연결 명시 + +--- + +### Phase 1. 구현 + +- [ ] **1-1. MV 엔티티/리포지토리** + - Entity 클래스 (commerce-batch 또는 공통 모듈) + - Repository (JPA or JDBC) + +- [ ] **1-2. 주간 랭킹 Batch Job** + - JobConfig 클래스 + - ItemReader: product_metrics → 최근 7일 집계 + - ItemProcessor: score 계산 + 순위 산정 + - ItemWriter: mv_product_rank_weekly 적재 + - JobParameter 처리 (targetDate) + +- [ ] **1-3. 월간 랭킹 Batch Job** + - 주간과 동일 구조, 집계 기간만 30일 + - mv_product_rank_monthly 적재 + +- [ ] **1-4. API 확장 (필요 시)** + - 주간/월간 요청 시 MV 테이블에서 조회하도록 분기 + - 기존 Redis 조회 경로와의 공존 or 전환 + +- [ ] **1-5. 스케줄링/실행 설정** + - application.yml Job 설정 + - 실행 방법 문서화 (커맨드라인, 스케줄러) + +--- + +### Phase 2. 테스트 + +- [ ] **2-1. 단위 테스트** + - Score 계산 로직 (기존 calculateScore와 일치 검증) + - Processor 변환 로직 + +- [ ] **2-2. 통합 테스트** + - Job 전체 실행 (Testcontainers + @SpringBatchTest) + - product_metrics에 시드 데이터 → Job 실행 → MV 결과 검증 + - 멱등성 검증 (같은 날짜로 2회 실행 → 결과 동일) + +- [ ] **2-3. 엣지 케이스** + - 데이터 없는 날짜로 실행 + - 7일/30일 미만 데이터로 실행 (서비스 초기) + - 대량 데이터 성능 테스트 (상품 10만건 기준) + +--- + +### Phase 3. 시나리오 기반 모니터링 + +- [ ] **3-1. 시나리오 정의** + - 시나리오 1: 정상 실행 — 시드 데이터 기반 주간/월간 Job 실행 + - 시나리오 2: MV vs Redis 결과 비교 — 같은 기간 랭킹 TOP 20 대조 + - 시나리오 3: 재실행 — 동일 파라미터로 2회 실행, 멱등성 확인 + +- [ ] **3-2. 모니터링 지표** + - Job 실행 시간, 처리 건수 (Spring Batch 메타 테이블) + - MV 적재 건수 + - Grafana 대시보드 (선택) + +- [ ] **3-3. 결과 기록** + - 스크린샷 또는 로그 기반 실행 결과 정리 + - 성능 수치 (소요 시간, 처리량) + +--- + +### Phase 4. 테크니컬 라이팅 + +- [ ] **4-1. 블로그 글 구성안 작성** + - 핵심 메시지 1줄 + - 목차 + 섹션별 핵심 메시지 + +- [ ] **4-2. 초안 작성** + - 설계 판단 중심 (왜 MV인가, 왜 이 구조인가) + - 기존 Redis 랭킹과의 관계 + - 코드는 핵심 판단을 보여주는 최소한만 + +- [ ] **4-3. Retrospective (10주 회고)** + - 1~10주 전체 여정 요약 + - 가장 큰 전환점 + - Trade-off 판단 1~2개 + - 실전 연결 포인트 + +--- + +### Phase 5. PR & 리뷰 포인트 + +- [ ] **5-1. PR 작성** + - 변경 사항 요약 + - 설계 판단 근거 + - 테스트 계획 + +- [ ] **5-2. 리뷰 포인트 작성 (2~3개)** + - 설계 고민이 드러나는 열린 질문 + - "배경 → 대안 비교 → 선택 근거 → 질문" 구조 + +--- + +### 일정 가이드 (참고용) + +| 일차 | 단계 | 핵심 산출물 | +|------|------|-----------| +| Day 1 | 개념 공부 (Step 1~4) + Phase 0 설계 | 설계 문서 초안 | +| Day 2 | 개념 공부 (Step 5~7) + Phase 0 완료 | 설계 문서 확정 | +| Day 3 | Phase 1 구현 (Job + MV) | 주간/월간 Job 동작 | +| Day 4 | Phase 1 완료 + Phase 2 테스트 | 테스트 통과 | +| Day 5 | Phase 3 모니터링 | 시나리오 검증 결과 | +| Day 6 | Phase 4 테크니컬 라이팅 | 블로그 초안 | +| Day 7 | Phase 5 PR + 리뷰 포인트 | PR 제출 | diff --git a/docs/requirements/volume-10/10-batch-ranking-quests.md b/docs/requirements/volume-10/10-batch-ranking-quests.md new file mode 100644 index 0000000000..3775fed754 --- /dev/null +++ b/docs/requirements/volume-10/10-batch-ranking-quests.md @@ -0,0 +1,82 @@ +# Round 10 Quests + +--- + +## Implementation Quest + +> 이번에는 Spring Batch 를 활용해 주간, 월간 랭킹을 제공해 볼 거예요. +> 이전에 적재했던 `product_metrics` 와 같은 일간 집계정보를 기반으로 **주간, 월간 랭킹 시스템을 구축**해봅니다. + +**Must-Have (이번 주에 무조건 가져가야 좋을 것)** + +- Spring Batch +- Batch Processing +- Materialized View (Statistics) + +### 과제 정보 + +이번 주는 대규모 데이터 집계 및 조회 전용 구조에 대한 설계를 진행해 봅니다. + +### (1) Spring Batch Job 구현 + +- 하루치 메트릭 테이블을 읽어 데이터를 집계하고 처리해봅니다. + - 대상 테이블 : `product_metrics` + - Chunk-Oriented 방식을 통해 대량의 데이터를 읽고 처리할 수 있도록 구성해 보세요. + +### (2) Materialized View 설계 + +- 집계 결과를 조회 전용 테이블 (MV) 로 저장합니다. + - `mv_product_rank_weekly` : 주간 TOP 100 랭킹 + - `mv_product_rank_monthly` : 월간 TOP 100 랭킹 + +### (3) Ranking API 확장 + +- 기존 Ranking 을 제공하는 GET `/api/v1/rankings?date=yyyyMMdd&size=20&page=1` 에서 기간 정보를 전달받아 API 로 일간, 주간, 월간 랭킹을 제공할 수 있도록 개선합니다. + +--- + +## Checklist + +### Spring Batch + +- [ ] Spring Batch Job 을 작성하고, 파라미터 기반으로 동작시킬 수 있다. +- [ ] Chunk Oriented Processing (Reader/Processor/Writer or Tasklet) 기반의 배치 처리를 구현했다. +- [ ] 집계 결과를 저장할 Materialized View 의 구조를 설계하고 올바르게 적재했다. + +### Ranking API + +- [ ] API 가 일간, 주간, 월간 랭킹을 제공하며 조회해야 하는 형태에 따라 적절한 데이터를 기반으로 랭킹을 제공한다. + +--- + +## Technical Writing Quest + +> 이번 주에 학습한 내용, 과제 진행을 되돌아보며 +> **"내가 어떤 판단을 하고 왜 그렇게 구현했는지"** 를 글로 정리해봅니다. +> +> **좋은 블로그 글은 내가 겪은 문제를, 타인도 공감할 수 있게 정리한 글입니다.** +> +> 이 글은 단순 과제가 아니라, **향후 이직에 도움이 될 수 있는 포트폴리오** 가 될 수 있어요. + +### 작성 기준 + +| 항목 | 설명 | +|------|------| +| 형식 | 블로그 | +| 길이 | 제한 없음, 단 꼭 1줄 요약 (TL;DR) 을 포함해 주세요 | +| 포인트 | "무엇을 했다" 보다 **"왜 그렇게 판단했는가"** 중심 | +| 예시 포함 | 코드 비교, 흐름도, 리팩토링 전후 예시 등 자유롭게 | +| 톤 | 실력은 보이지만, 자만하지 않고, **고민이 읽히는 글** | + +### Retrospective + +- 단순히 "무엇을 했다"가 아니라, **10주 동안 어떻게 성장했는지**를 돌아본다. +- "기능 구현" 중심이 아니라, **사고방식/문제 해결/설계 선택 과정** 중심으로 기록한다. +- 이 글은 **개인 포트폴리오**이자, 앞으로 학습 방향을 스스로 점검하는 기준점이 된다. + +### 담으면 좋은 내용 + +1. **전체 여정 요약** — 1~10주차 동안 다뤘던 주요 테마 및 문제점들을 간단히 돌아보기. 단순 나열이 아니라, 흐름이 어떻게 연결되었는지를 강조. +2. **가장 큰 전환점** — 내 기존의 사고방식이 바뀌었다 싶은 순간. +3. **나의 Trade-off 판단** — 실습 중 내가 내린 중요한 선택 1~2개. 왜 그 선택을 했고, 대안은 뭐였는지, 지금 다시 한다면 어떻게 할 건지. +4. **실전과의 연결** — "이건 실제 회사/서비스에서 써먹을 수 있겠다" 싶은 포인트. diff --git a/docs/session-prompts/10-batch-analysis-prompt.md b/docs/session-prompts/10-batch-analysis-prompt.md new file mode 100644 index 0000000000..9233e83beb --- /dev/null +++ b/docs/session-prompts/10-batch-analysis-prompt.md @@ -0,0 +1,97 @@ +# 세션 프롬프트: 회사 배치 어플리케이션 분석 + +> 이 프롬프트를 새 Claude 세션에 붙여넣고, 이어서 회사 배치 코드를 공유하세요. + +--- + +## 역할 + +당신은 대규모 이커머스 서비스에서 Spring Batch를 운영해본 10년 경력의 시니어 백엔드 개발자입니다. +지금부터 내가 공유하는 회사 실무 배치 어플리케이션 2개를 분석하고, 내 개인 프로젝트 과제에 적용할 인사이트를 추출해 주세요. + +--- + +## 내 과제 맥락 + +이커머스 프로젝트에서 **Spring Batch로 주간/월간 랭킹을 집계하여 MV(Materialized View) 테이블에 적재**하는 것이 과제입니다. + +### 이미 구현된 것 (Round 9) + +| 항목 | 구현 상태 | +|------|----------| +| `product_metrics` 테이블 | 일간 grain, PK: (product_id, metric_date) | +| `RankingCorrectionJob` | Chunk 1,000, JdbcCursorItemReader → Redis 덮어쓰기 | +| Redis 일간/주간/월간 ZSET | carry-over + ZUNIONSTORE 방식 | +| Ranking API | scope=daily\|weekly\|monthly → Redis 조회 | + +### 이번에 새로 만들 것 (Round 10) + +| 항목 | 설명 | +|------|------| +| 주간/월간 랭킹 Batch Job | `product_metrics` → 기간 집계 → MV 테이블 적재 | +| MV 테이블 | `mv_product_rank_weekly`, `mv_product_rank_monthly` | +| API 확장 | 주간/월간 요청 시 MV에서도 조회 가능하도록 | + +### 내가 고민 중인 설계 질문 + +1. **Reader**: JdbcCursorItemReader vs JdbcPagingItemReader — 기간 집계 쿼리에 어느 쪽이 적합한가? +2. **Processor vs SQL**: 비즈니스 로직(score 계산, TOP-N 필터링)을 Processor에서 처리할지, Reader SQL에서 처리할지 +3. **Writer 전략**: MV 테이블 갱신 시 DELETE+INSERT vs UPSERT +4. **멱등성**: 같은 날짜 파라미터로 재실행해도 결과가 동일하려면? +5. **기존 Redis 주간/월간과의 공존**: MV가 추가되면 API가 어디서 읽어야 하는가? + +--- + +## 분석 요청 + +회사 배치 어플리케이션을 아래 관점에서 분석해 주세요. + +### 1. 구조 분석 + +각 배치 앱에 대해: +- **Job/Step 구성**: 몇 개의 Step으로 구성되어 있는가? 순차/병렬? +- **처리 모델**: Chunk-Oriented vs Tasklet — 어떤 것을 쓰고 있는가? 왜? +- **Reader 패턴**: 어떤 ItemReader를 쓰는가? SQL이 얼마나 복잡한가? +- **비즈니스 로직 위치**: Reader SQL에 조건이 다 있는가? Processor에서 분기하는가? +- **Writer 패턴**: UPSERT? DELETE+INSERT? 벌크 인서트? +- **에러 처리**: Skip Policy, Retry, Listener 등 사용 여부 + +### 2. 내 과제에 적용할 인사이트 + +분석 결과를 바탕으로: +- **직접 참고할 수 있는 패턴**: 내 과제(주간/월간 랭킹 집계)에 바로 적용할 수 있는 구조나 패턴 +- **피해야 할 안티패턴**: 회사 코드에서 발견되는 문제점이나 개선 포인트 +- **설계 질문에 대한 시사점**: 위 5개 설계 질문에 대해 회사 코드가 어떤 힌트를 주는가 + +### 3. 비교 테이블 + +아래 형식으로 정리해 주세요: + +``` +| 비교 항목 | 회사 배치 A | 회사 배치 B | 내 과제 (추천) | 근거 | +|----------|-----------|-----------|-------------|------| +| 처리 모델 | | | | | +| Reader 타입 | | | | | +| 비즈니스 로직 위치 | | | | | +| Writer 전략 | | | | | +| 멱등성 보장 | | | | | +| 에러 처리 | | | | | +``` + +--- + +## 출력 형식 + +1. **배치 A 분석** (구조 → 장단점 → 내 과제 시사점) +2. **배치 B 분석** (구조 → 장단점 → 내 과제 시사점) +3. **비교 테이블** +4. **내 과제 설계 제안** — 회사 코드에서 배운 점을 반영한 구체적 설계 방향 (Reader SQL, Processor 역할, Writer 전략, 멱등성) +5. **추가 질문** — 분석 중 더 확인이 필요한 부분 + +--- + +## 진행 방식 + +1. 이 프롬프트를 읽고 이해한 내용을 요약해 주세요 +2. 내가 회사 배치 코드를 공유하면 분석을 시작합니다 +3. 배치 A, B를 순서대로 공유할 예정입니다 diff --git a/docs/session-prompts/10-batch-tutor-prompt.md b/docs/session-prompts/10-batch-tutor-prompt.md new file mode 100644 index 0000000000..c2651725e4 --- /dev/null +++ b/docs/session-prompts/10-batch-tutor-prompt.md @@ -0,0 +1,175 @@ +# 세션 프롬프트: Round 10 배치 시스템 학습 튜터 + +> 이 프롬프트를 새 Claude 세션에 붙여넣고, Step 1부터 순서대로 학습을 시작하세요. + +--- + +## 역할 + +당신은 이커머스 도메인에서 Spring Batch를 직접 설계·운영해본 경력 15년의 시니어 개발자이자 기술 교육자입니다. + +**교육 철학:** +- "왜?"를 먼저 설명하고, "어떻게?"는 그 다음 +- 개념은 실무 시나리오와 연결해서 설명 +- 학습자가 스스로 판단할 수 있도록 선택지와 트레이드오프를 제시 +- 코드는 최소한으로, 핵심 판단을 보여주는 수준만 +- 학습자의 답변이 틀려도 바로 정답을 주지 않고, 힌트로 유도 + +**교육 대상:** Spring Boot 경험은 있으나 Spring Batch와 대규모 배치 처리는 처음인 주니어 백엔드 개발자 + +--- + +## 학습자의 프로젝트 맥락 + +학습자는 이커머스 프로젝트에서 랭킹 시스템을 구축하고 있습니다. + +### 이미 구현된 것 (Round 9) + +``` +[실시간 경로 — Speed Layer] + Kafka → MetricsConsumer → Redis Hash/ZSET (일간) + → 23:50 스케줄러: carry-over + ZUNIONSTORE (주간/월간 Redis ZSET) + +[배치 보정 — Batch Layer] + product_metrics(DB) → RankingCorrectionJob → Redis Hash/ZSET 덮어쓰기 + - Chunk 1,000, JdbcCursorItemReader + - Score v2: 0~1 정규화 + log₁₀ + tiebreaker + +[API] + GET /api/v1/rankings?scope=daily|weekly|monthly → Redis ZSET 조회 +``` + +### 이번 과제 (Round 10) + +- Spring Batch Job으로 `product_metrics` → 주간/월간 집계 → MV 테이블 적재 +- MV 테이블: `mv_product_rank_weekly`, `mv_product_rank_monthly` +- API 확장: 일간/주간/월간 랭킹을 적절한 데이터 소스에서 제공 + +--- + +## 학습 로드맵 (7 Step) + +아래 순서대로 학습을 진행합니다. 각 Step마다 **설명 → 확인 질문 → 피드백** 사이클로 진행해 주세요. + +### Step 1. 선수 지식 점검 + +학습자에게 아래 4가지를 확인 질문으로 점검하세요. 부족한 부분이 있으면 보충 설명 후 다음으로 넘어갑니다. + +| 주제 | 확인 질문 | +|------|----------| +| SQL 집계 함수 | "상품별 최근 7일간 view_count 합계 TOP 100을 구하는 SQL을 작성해 보세요" | +| 트랜잭션 기초 | "`@Transactional`의 propagation REQUIRED vs REQUIRES_NEW 차이를 1,000건 chunk 커밋 상황에서 설명해 보세요" | +| Spring Bean 생명주기 | "singleton Bean과 @StepScope Bean의 차이가 배치에서 왜 중요한지 설명해 보세요" | +| JDBC vs JPA 대량 조회 | "10만 건 조회 시 JPA `findAll()`과 JdbcCursorItemReader의 메모리 사용 차이를 설명해 보세요" | + +**진행 기준:** 4개 중 3개 이상 답변 가능하면 Step 2로, 아니면 부족한 부분 보충 후 이동 + +### Step 2. 배치 처리의 본질 + +**핵심 질문으로 시작:** "이 작업을 왜 API 서버에서 안 하는가?" + +다룰 내용: +- 실시간 vs 배치 트레이드오프 (신속성 vs 정확성/효율성) +- 실무 배치 시나리오 4가지 (정산, 랭킹, 정리, DW 적재) +- 멱등성 — 같은 Job을 두 번 돌려도 결과가 같아야 하는 이유 +- 대량 데이터와 메모리 — Chunk의 존재 이유 + +**프로젝트 연결:** +> "학습자의 프로젝트에서 일간 랭킹은 Kafka→Redis 실시간으로 처리하고 있다. 그런데 주간/월간 랭킹을 왜 같은 방식으로 안 하고 배치로 만드는가?" + +이 질문에 학습자가 스스로 답하도록 유도하세요. + +### Step 3. Spring Batch 아키텍처 + +**계층 구조:** +``` +JobLauncher + └── Job (실행 단위) + └── Step (세부 단계) + ├── Chunk-Oriented: Reader → Processor → Writer + └── Tasklet: 단발성 작업 +``` + +다룰 내용: +- Job, Step, JobRepository, JobParameters, @JobScope/@StepScope +- 메타 테이블 (BATCH_JOB_INSTANCE, BATCH_JOB_EXECUTION) — 왜 있는지, 뭘 기록하는지 +- JobParameters가 Job Instance의 동일성을 결정하는 원리 + +**프로젝트 연결:** +> "학습자의 프로젝트에는 이미 RankingCorrectionJob이 있다. 이 Job이 `targetDate`를 JobParameter로 받는다면, 같은 날짜로 두 번 실행하면 어떻게 되는가?" + +### Step 4. Chunk-Oriented Processing 상세 + +다룰 내용: +- Reader → Processor → Writer 흐름과 트랜잭션 경계 +- ItemReader 비교: JdbcCursorItemReader vs JdbcPagingItemReader vs JpaPagingItemReader + - 각각의 메모리/커넥션/성능 특성 + - "언제 어떤 것을 쓰는가" 판단 기준 +- Processor에서 `null` 반환 → 해당 아이템 스킵 (필터링 패턴) +- Chunk size 결정 기준 — 너무 작으면? 너무 크면? + +**판단 연습 질문:** +> "학습자가 product_metrics에서 최근 7일 데이터를 GROUP BY하여 상품별 합계를 구한다. 이때 집계를 Reader SQL에서 할지, Processor에서 할지 — 어떤 기준으로 결정하겠는가?" + +### Step 5. Materialized View + +다룰 내용: +- MV의 개념 — "미리 계산해둔 조회 전용 테이블" +- MySQL에서 MV 구현 방식 (별도 테이블 + 배치 적재) +- 갱신 전략: DELETE+INSERT vs UPSERT +- MV vs 실시간 집계 — 조회 속도, 신선도, DB 부하 비교 + +**프로젝트 연결:** +> "지금 주간/월간 랭킹은 Redis ZSET에서 제공한다. MV 테이블이 추가되면, API는 Redis와 MV 중 어디서 읽어야 하는가? 둘 다 유지할 이유가 있는가?" + +이 설계 판단을 학습자가 스스로 내리도록 유도하세요. 정답은 없고, 트레이드오프를 인식하는 것이 목표입니다. + +### Step 6. 프로젝트 맥락 — 기존 구현과의 연결 + +학습자의 프로젝트에 새로운 Job이 들어갈 때 고려할 사항: + +| 관점 | 확인할 것 | +|------|----------| +| 기존 Job과의 관계 | RankingCorrectionJob과 실행 시간 충돌 없는가? | +| Score 계산 | 기존 v2 공식(log₁₀ 정규화 + tiebreaker)을 재활용할 수 있는가? | +| 데이터 소스 | product_metrics에서 GROUP BY 기간만 바꾸면 되는가? | +| Redis vs MV 공존 | 어느 쪽이 source of truth인가? | + +**설계 연습:** +> "주간 랭킹 Job의 Step을 설계해 보세요. Reader의 SQL, Processor의 역할, Writer의 전략을 각각 정해 보세요." + +학습자의 설계안을 받고, 장단점을 피드백해 주세요. + +### Step 7. 운영 관점 + +코드가 동작하는 것과 운영 가능한 것은 다르다. + +다룰 내용: +- 멱등성 보장 — MV 갱신 시 데이터 2배 방지 +- 실패 복구 — Spring Batch 재시작 메커니즘 +- 모니터링 — 처리 건수, 소요 시간, 실패 알림 +- 스케줄링 — 다른 Job과의 시간 배치 + +**최종 종합 질문:** +> "Job이 새벽 1시에 실행 중 Step 2에서 DB 커넥션 에러로 실패했다. 아침에 출근해서 어떻게 대응하는가?" + +--- + +## 진행 규칙 + +1. **한 번에 하나의 Step만** 진행합니다. 학습자가 "다음"이라고 하면 다음 Step으로 넘어갑니다. +2. **각 Step의 흐름:** + - 개념 설명 (프로젝트 맥락과 연결) + - 확인 질문 1~2개 (학습자가 직접 답변) + - 피드백 + 보충 + - 다음 Step 예고 +3. **학습자가 틀려도** 바로 정답을 주지 말고, "이 부분을 다시 생각해 보세요: ___" 형태로 힌트를 주세요. +4. **실무 사례**를 자주 들어주세요. "쿠팡에서는...", "실무에서 흔히 보는 실수는..." 등. +5. 학습자가 충분히 이해했다고 판단되면 **"이 Step은 여기까지. 다음 Step으로 넘어갈까요?"** 로 전환합니다. + +--- + +## 시작 + +"안녕하세요! Round 10 학습을 시작합니다. 먼저 Step 1으로 선수 지식을 점검해 보겠습니다." 로 시작해 주세요. +첫 번째 확인 질문을 하나 던져 주세요. diff --git a/docs/velog-techwriting-vol10.md b/docs/velog-techwriting-vol10.md new file mode 100644 index 0000000000..728019db22 --- /dev/null +++ b/docs/velog-techwriting-vol10.md @@ -0,0 +1,506 @@ +# 일간은 Redis, 주간/월간은 왜 다른가 — 이커머스 랭킹 배치 설계기 + +*Redis에 이미 주간/월간 랭킹이 있는데, 같은 걸 DB에 또 만들어야 할까?* + +> 실시간 Redis 랭킹만으로 충분하다고 생각했다. 그런데 log₁₀의 비선형성을 숫자로 검증하는 순간, "같은 데이터인데 왜 결과가 다르지?"라는 질문이 시작됐고, 이 질문은 Score 방식 선택, 시간 윈도우 전략, Chunk vs Tasklet 판단, CursorReader의 병렬화 한계, 전체 재계산 vs 증분까지 연쇄적으로 이어졌다. Lambda Architecture에서 Speed Layer와 Batch Layer가 왜 공존해야 하는지를 배치 설계 전 과정에 걸쳐 확인한 기록이다. + +--- + +## 1. 이 글의 맥락 + +Round 9에서 Kafka → Redis ZSET 파이프라인으로 실시간 일간/주간/월간 랭킹을 구축했다. 이벤트가 발생할 때마다 score를 갱신하고, ZUNIONSTORE carry-over로 주간/월간을 근사 계산하는 구조다. + +Round 10의 과제는 Spring Batch로 MV(Materialized View) 기반 주간/월간 랭킹을 만드는 것이다. 처음에 든 생각은 단순했다. + +*"Redis에 이미 있는 걸 왜 DB에 또 만들어?"* + +이 질문에 답하려면, 두 시스템이 정말 같은 결과를 내는지부터 확인해야 했다. + +--- + +## 2. Redis에 이미 랭킹이 있는데, MV를 왜 만드는가 + +### log₁₀는 선형이 아니다 + +Redis 주간 랭킹은 **일별 score를 합산**한다. ZUNIONSTORE로 7일치 ZSET을 합치면 `Σ daily_score`가 된다. MV는 **기간 메트릭을 합산한 뒤 score를 한 번 계산**한다. `f(SUM(7일 메트릭))`. 같은 7일 데이터인데, 순서가 다르다. + +log₁₀는 비선형 함수이므로, 이 순서의 차이가 결과를 바꾼다. + +``` +Σ log(daily) ≠ log(Σ daily) +``` + +숫자로 확인했다. + +``` +상품 X: 7일간 view = [100, 100, 100, 100, 100, 100, 100] (총 700) +상품 Y: 7일간 view = [0, 0, 0, 0, 0, 0, 700] (총 700) + +Redis (일별 score 합산): + X: 7 × log₁₀(101)/7 = 2.003 + Y: 6 × 0 + log₁₀(701)/7 = 0.406 + → X 압도적 유리 (꾸준한 상품 우대) + +MV (메트릭 합산 후 score): + X: log₁₀(701)/7 = 0.406 + Y: log₁₀(701)/7 = 0.406 + → 동점 (총 활동량 동일) +``` + +총 조회수가 같은 두 상품이, 계산 순서만 다른데 **Redis에서는 X가 5배 높고, MV에서는 동점**이다. 같은 원천 데이터에서 출발하지만, 계산 방식의 차이가 다른 관점의 랭킹을 만들어내는 것이다. + +### "다른 결과를 내는 것"이 오히려 가치다 + +처음에는 "MV가 Redis보다 정확하니까 MV를 만드는 것"이라고 생각했다. 그런데 생각을 정리하다 보니 방향이 달랐다. + +| 관점 | Redis (Speed Layer) | MV (Batch Layer) | +|------|---------------------|-------------------| +| **Score 특성** | `Σ daily_score` — 꾸준히 팔린 상품 우대 | `f(Σ daily_metrics)` — 총 실적 기준 | +| **비즈니스 의미** | "지금 뜨는 상품" (트렌드) | "이번 달 베스트셀러" (누적 성과) | +| **소비자 시나리오** | 메인 페이지 실시간 인기 | 카테고리별 베스트, 기간별 랭킹 | +| **사업자 시나리오** | 실시간 모니터링 | 주간/월간 리포트, MD 성과 분석 | + +**MV가 Redis와 같은 결과를 내면, MV를 만들 이유가 없다.** 두 시스템이 다른 관점을 제공하는 것이 Lambda Architecture에서 Speed Layer와 Batch Layer의 존재 이유다. + +### 그러면 fallback으로 쓰면 안 되는가 + +설계 초기에는 "MV primary, Redis fallback"으로 구성하려 했다. MV 배치가 실패하면 Redis에서 조회하는 구조. 그런데 위에서 확인했듯이 두 시스템은 같은 기간에 대해 **다른 순위를 반환**한다. + +``` +정상 시: MV 조회 → 상품 A가 1위 (균등 합산) +MV 장애: Redis fallback → 상품 B가 1위 (일별 합산 + 감쇠) +→ "어제는 A가 1위였는데 오늘은 B?" +``` + +다른 공식으로 계산한 결과를 같은 API의 fallback으로 쓰면 데이터 일관성이 깨진다. 잘못된 순위를 보여주는 것보다 "현재 랭킹을 준비 중입니다"가 더 안전하다. + +**최종 결정은 단일 소스 원칙이다.** + +``` +daily → Redis (단일 소스) +weekly → MV (단일 소스) +monthly → MV (단일 소스) +``` + +```java +// RankingFacade.java — scope에 따라 데이터 소스를 분리 +public RankingDto.PagedRankingResponse getRankings( + String scope, String date, int page, int size, Long memberId) { + String resolvedDate = (date != null) ? date + : LocalDate.now(KST).format(DATE_FORMATTER); + + return switch (scope) { + case "weekly", "monthly" -> getFromMv(scope, resolvedDate, page, size); + default -> getFromRedis(scope, resolvedDate, page, size, memberId); + }; +} +``` + +각 scope의 데이터 소스가 하나이므로, 소스 전환에 의한 순위 불일치가 발생하지 않는다. + +--- + +## 3. 설계 판단들 + +### 3.1 "주간 베스트"는 총 판매량인가, 최근 인기인가 + +MV의 score를 어떤 방식으로 계산할 것인가. 세 가지를 검토했다. + +| 방식 | 수식 | 특성 | +|------|------|------| +| **균등 합산 (채택)** | `f(SUM(30일 메트릭))` | 기간 총 실적. 30일 전이나 오늘이나 동등 | +| **지수 감쇠** | `Σ(daily_score × 0.97^i)` | 최근에 높은 가중치. 반감기 약 23일 | +| **일평균** | `f(SUM(메트릭) / COUNT(전시일))` | 전시 기간 편향 보정 | + +**균등 합산을 선택한 이유는 공개 랭킹 보드의 비즈니스 의미에 있다.** + +쿠팡, 무신사, 교보문고의 공개 랭킹 보드는 기간 총 실적 기준이다. "이번 달 베스트셀러"를 볼 때 소비자가 기대하는 것은 "가장 많이 팔린 상품"이지, "일평균 판매량이 높은 상품"이나 "최근에 급등한 상품"이 아니다. + +숫자로도 확인했다. + +``` +상품 A: 30일간 매일 매출 100만원 (꾸준) +상품 B: 최근 5일간 매일 600만원 (급등), 나머지 0원 +총 실적: 둘 다 3000만원 + + 일간 주간(균등) 주간(감쇠) 월간(균등) 월간(감쇠) +상품 A 0.600 0.693 4.09 0.735 12.0 +상품 B 0.678 0.735 3.33 0.735 3.33 +승자 B B A 동점 A 압승 +``` + +감쇠를 쓰더라도 월간이 일간/주간과 비슷해지지는 않는다. 하지만 감쇠를 쓸 *이유*가 없는 것이 핵심이다. Redis가 이미 트렌드를 반영하고 있으므로, MV까지 감쇠를 적용하면 두 시스템의 결과가 수렴한다. + +**지수 감쇠를 기각한 근거**: Lambda Architecture에서 Speed Layer(Redis)가 이미 트렌드를 반영하고 있으므로, Batch Layer(MV)는 "정확한 기간 집계"에 집중하는 것이 아키텍처적으로 맞다. 같은 일을 두 시스템에서 반복하면 MV의 존재 가치가 떨어진다. + +**일평균을 기각한 근거**: 수학적으로 공정한 비교와 비즈니스적으로 의미 있는 비교는 다를 수 있다. 1일 전시에 매출 500만원이면 일평균 기준으로 30일 전시 + 매출 3000만원인 상품보다 위에 올라간다. MD팀이 원하는 "이번 달 베스트"는 총 매출 3000만원인 상품이다. + +다만 일평균은 내부 분석에서는 유용하다. 다시 한다면, `avg_daily_sales = total_sales / active_days`를 MV에 별도 컬럼으로 함께 저장할 것이다. 공개 랭킹의 정렬 기준은 총 실적을 유지하면서, MD 대시보드에서 "판매 효율 기준 정렬"을 재집계 없이 제공할 수 있다. + +### 3.2 시간 윈도우: 매주 월요일에 리셋되는 랭킹이 맞는가 + +MV의 "주간"을 어떻게 정의할 것인가. + +| 전략 | 예시 | 갱신 주기 | +|------|------|----------| +| 캘린더 | 주간: 월~일, 월간: 1일~말일 | 주 1회, 월 1회 | +| 슬라이딩 (채택) | 오늘 기준 최근 7일/30일 | 매일 | + +**슬라이딩을 선택한 4가지 근거:** + +1. **Redis와 시간 범위 일치**: Redis ZUNIONSTORE가 "최근 7일 daily"를 합산하는 슬라이딩 방식. MV가 캘린더이면 시간 범위가 불일치하여 두 시스템 간 비교·검증이 어렵다. +2. **이커머스 업계 관행**: 무신사, 쿠팡 등에서 주간/월간 랭킹을 매일 갱신한다. "주간 인기 상품"이 월요일에만 바뀌면 사용자가 매일 같은 랭킹을 보게 되어 재방문 유인이 떨어진다. +3. **배치 비용 대비 효과**: GROUP BY + TOP 100 INSERT는 상품 수만 건 기준 수초 내 완료. 매일 실행해도 시스템 부하가 미미하며, 매일 갱신되는 효과가 크다. +4. **운영 단순성**: period_key가 targetDate(`20260416`) 그 자체이므로 "이 날짜 기준 최근 N일"이라는 명확한 의미. 캘린더 방식은 ISO 주차(`2026-W16`)나 월(`2026-04`) 계산이 필요하고, 월말/주초 경계 처리가 복잡하다. + +캘린더를 기각했지만, 정산/리포팅 시스템에서는 캘린더가 맞다. "4월 매출 정산"은 4/1~4/30 고정 기간이어야 한다. 슬라이딩이면 기준일에 따라 금액이 달라져 정산 불일치가 생긴다. 우리 과제는 정산이 아닌 소비자 대상 랭킹 보드이므로 슬라이딩이 적합하다. + +### 3.3 Chunk vs Tasklet: 90개 실무 Job이 알려준 것 + +이 판단의 출발은 약간 엉뚱한 곳이었다. 회사 배치 프로젝트 2개(90개 Job)를 분석했더니 통계/집계 Job의 대다수가 Tasklet이었다. 처음에는 "Tasklet이 실무 표준"이라고 결론 내렸는데, **"다른 개발자들의 이야기를 들어보면 Chunk 방식이 보편적이라고 하는데?"**라는 반론이 나왔다. + +다시 생각해보니, 분석한 배치 프로젝트가 MyBatis + SQL 중심 아키텍처여서 `INSERT INTO...SELECT`가 자연스러운 선택이었을 뿐, 이것을 업계 표준으로 일반화한 것은 **한 조직의 패턴을 확대 해석**한 것이었다. + +Spring Batch는 Chunk를 중심으로 설계되어 있다. Chunk가 보편적 선택인 이유는 프레임워크가 Chunk에만 제공하는 운영 기능에 있다. + +```java +// Chunk-Oriented에서만 쓸 수 있는 운영 기능 +.faultTolerant() + .retry(DeadlockLoserDataAccessException.class) // DB 데드락 시 자동 재시도 + .retryLimit(3) // 최대 3회 +.skip(DataIntegrityViolationException.class) // 불량 레코드 건너뛰기 +.skipLimit(100) + +// Chunk가 자동으로 기록하는 것 +StepExecution: + readCount, writeCount, skipCount, commitCount, rollbackCount +``` + +**Tasklet에서 동일한 운영 안정성을 확보하려면 retry 루프, skip 카운터, 진행 상태 저장, 처리 건수 추적을 모두 직접 구현해야 한다.** 대부분의 배치 작업에서 이 운영 기능의 가치가 네트워크 왕복 비용보다 크기 때문에 Chunk가 보편적 선택이 된다. + +그런데 90개 실무 Job을 다시 살펴보니 흥미로운 사실이 있었다. + +| 운영 기능 | 90개 Job 사용 여부 | +|----------|-------------------| +| `.faultTolerant()` | 0개 | +| `.retry()` / `retryLimit` | 0개 | +| `.skip()` / `skipLimit` | 0개 | +| `ItemReadListener` / `ItemWriteListener` | 0개 | +| `allowStartIfComplete` | 0개 | + +**90개 Job 중 단 하나도 retry, skip, restart를 사용하지 않는다.** 이것은 "운영에서 문제가 없었다"로 해석할 수도 있지만, 동시에 "1건의 일시적 DB 에러가 전체 배치를 실패시키는 구조"이기도 하다. 야간 배치가 데드락으로 실패하면 아침에 출근해서 수동 재실행해야 한다. retry를 걸어두면 자동으로 복구됐을 에러다. + +**우리 프로젝트에서 retry + ExponentialBackOffPolicy를 적용하는 것은, 실무에서 빠져 있는 운영 안정성을 보완하는 설계 판단이다.** + +```java +// 우리 MV Job의 workerStep — Chunk + retry + 지수 백오프 +ExponentialBackOffPolicy backOff = new ExponentialBackOffPolicy(); +backOff.setInitialInterval(100); +backOff.setMultiplier(2.0); +backOff.setMaxInterval(1000); + +return new StepBuilder("workerStep", jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(stagingReader(null, null, null, null)) + .writer(stagingWriter(null)) + .faultTolerant() + .retry(DeadlockLoserDataAccessException.class) + .retry(TransientDataAccessException.class) + .retryLimit(3) + .backOffPolicy(backOff) + .listener(stepMonitorListener) + .build(); +``` + +retry 간격이 100ms → 200ms → 400ms로 지수적으로 증가하는 이유는, 즉시 재시도하면 데드락 상태에서 같은 충돌이 반복될 가능성이 높기 때문이다. + +Tasklet의 세 조건(SQL 한 문장 완결, retry 불필요, 중간 상태 무의미)을 모두 충족하면 Tasklet이 효율적이다. 우리의 mergeStep은 Tasklet으로 구현했다 — TOP 100 추출 INSERT 한 문장으로 완결되고, 100건이므로 실패 시 전체 재실행해도 수초 내 완료된다. + +### 3.4 Score 계산은 DB에서 끝내야 한다 + +처음에는 Reader에서 전체 상품을 조회하고 Processor에서 score를 계산한 후, Writer에서 TOP 100만 INSERT하는 구조를 설계했다. 그런데 수만 건을 INSERT했다가 100건만 남기고 삭제하는 것은 불필요한 I/O다. + +**"Reader가 100건만 조회해도 TOP 100이 맞아?"** SQL 실행 순서가 이것을 보장한다. + +``` +1. FROM / JOIN → product_metrics × product 조인 +2. WHERE → 날짜 범위 필터 +3. GROUP BY → product_id별 그룹핑 + SUM 집계 +4. SELECT → score 계산 (LOG10 함수) +5. ORDER BY → score 내림차순 정렬 (전체 상품 대상) +6. LIMIT 100 → 상위 100건만 반환 +``` + +DB가 전체 상품의 score를 계산하고 정렬한 후 상위 100건만 네트워크로 전달한다. Reader는 100건만 받지만, 그 100건이 score 기준 TOP 100인 것은 DB가 보장한다. + +```sql +-- Reader SQL (ProductRankingMvJobConfig.stagingReader) +SELECT + pm.product_id, + SUM(pm.view_count) AS total_view_count, + SUM(pm.like_count - pm.unlike_count) AS total_net_like_count, + SUM(pm.sales_count) AS total_sales_count, + SUM(pm.sales_amount - pm.cancel_amount_by_event_date) AS total_net_sales_amount, + ( + 0.1 * LOG10(GREATEST(SUM(pm.view_count), 0) + 1) / 7.0 + + 0.2 * LOG10(GREATEST(SUM(pm.like_count - pm.unlike_count), 0) + 1) / 7.0 + + 0.7 * LOG10(GREATEST(SUM(pm.sales_amount + - pm.cancel_amount_by_event_date), 0) + 1) / 7.0 + + UNIX_TIMESTAMP() * 1e-16 + ) AS score +FROM product_metrics pm +JOIN product p ON pm.product_id = p.id +WHERE pm.metric_date BETWEEN ? AND ? + AND pm.product_id BETWEEN ? AND ? + AND p.deleted_at IS NULL +GROUP BY pm.product_id +``` + +회사 코드를 분석한 결과도 이것을 뒷받침한다. **12개 매퍼에서 `RANK()`, `DENSE_RANK()`, `ROW_NUMBER()`, `PERCENT_RANK()` 윈도우 함수로 TOP-N을 처리하고 있었고, Java에서 랭킹/스코어링을 처리하는 배치 Job은 없었다.** "DB가 잘하는 일(집계, 정렬, 필터링)은 DB에서 끝낸다"가 이 회사의 실무 표준이었다. + +**트레이드오프가 하나 있다.** SQL에 score 공식을 넣으면, RankingCorrectionJob(일간 보정, Java)과 MV Job(주간/월간, SQL)에 같은 공식이 두 곳에 존재한다. 가중치(0.1/0.2/0.7) 변경 시 두 곳 모두 수정이 필요하다. + +이것을 허용한 근거는, 두 Job의 입력이 다르기 때문이다. Correction은 일간 메트릭(CURDATE() 1일)을 읽고, MV는 기간 합산 메트릭(7/30일 SUM)을 읽는다. 같은 공식이지만 적용 대상이 다르므로 하나의 Java 메서드를 공유하는 것이 오히려 부자연스럽다. 가중치 자체는 `application.yml`의 `RankingCorrectionProperties`에 중앙화되어 있어서 SQL에도 파라미터로 주입된다. + +### 3.5 CursorReader: GROUP BY 집계에서 PagingReader가 위험한 이유 + +처음에는 "기존 RankingCorrectionJob과 일관성"이라는 이유로 CursorReader를 골랐다. 대규모 기준으로 다시 따져보니, 이유가 훨씬 근본적이었다. + +**PagingReader는 페이지마다 독립된 쿼리를 재실행한다.** GROUP BY가 포함된 집계 쿼리에서 이것은 치명적이다. + +``` +CursorReader: + GROUP BY 3,000만 행 → 1번 실행 → 결과 스트리밍 + 총 집계 실행: 1회 + +PagingReader (pageSize=1000, 상품 100만건 = 1,000페이지): + 페이지 1: GROUP BY 3,000만 행 → 정렬 → OFFSET 0 (30초) + 페이지 2: GROUP BY 3,000만 행 → 정렬 → OFFSET 1000 (30초) + ... + 페이지 1000: GROUP BY 3,000만 행 → 정렬 → OFFSET 999000 (30초+) + 총 집계 실행: 1,000회 → 8시간 이상 +``` + +| 관점 | CursorReader | PagingReader | +|------|-------------|-------------| +| **GROUP BY 쿼리** | 1회 실행 후 스트리밍 | 페이지마다 재실행 — 대규모에서 치명적 | +| **커넥션 점유** | Step 전체 동안 1개 점유 | 페이지 조회 시만 점유 | +| **멀티스레드** | 불가 (ResultSet 공유 상태) | 가능 (각 스레드 독립 쿼리) | +| **재시작** | 제한적 (read count 기반) | 자연스러움 (페이지 번호 저장) | + +CursorReader의 약점은 멀티스레드에서 쓸 수 없다는 것이다. ResultSet이 "현재 커서 위치"라는 상태를 가지고 있어서, 두 스레드가 동시에 `next()`를 호출하면 행이 누락되거나 중복된다. + +**상품이 수백만 건으로 늘어나면 어떻게 병렬화하는가?** PagingReader로 전환하면 GROUP BY 반복 실행이라는 더 큰 문제가 생긴다. 답은 **Partitioning**이다. + +### 3.6 매번 원장에서 재계산하는 게 비효율 아닌가 + +MV가 매일 원장(product_metrics)에서 7일/30일치를 처음부터 GROUP BY한다. **"어제 결과에서 가장 오래된 날을 빼고 오늘을 더하면 되지 않나?"** 증분 계산은 월간 기준 데이터 처리량을 93% 줄일 수 있다. + +``` +어제 MV (4/10~4/16 합산): 상품 A = view 700, sales 3000만 +오늘 MV (4/11~4/17 합산): + = 어제 결과 - 4/10의 메트릭 + 4/17의 메트릭 + → 30일치 GROUP BY 대신 2일치만 조회 +``` + +수학적으로 정확하다. 근사치가 아니다. **하지만 하나의 전제가 필요하다: "과거 데이터가 변경되지 않는다."** + +이커머스에서 이 전제는 깨진다. 주문 취소는 원주문과 다른 날에 발생한다. + +``` +4/10: 상품 A 주문 100건 (1000만원) +4/15: 그 중 30건 취소 → product_metrics 4/10 행의 cancel_by_order_date 갱신 + +증분 계산: + 4/10의 값은 이미 MV에 반영됨 (취소 전 1000만원 기준) + 4/15에 4/10 행이 변경됐지만, 증분은 "4/15의 메트릭만 추가" + → 4/10 행의 사후 변경을 감지 못함 + +전체 재계산: + 4/10~4/16 전체를 다시 읽음 + → 4/10 행의 cancel_by_order_date 변경이 자동 반영 +``` + +| 시나리오 | 전체 재계산 | 증분 계산 | +|---------|-----------|----------| +| 정상 주문 | 정확 | 정확 | +| 지연 취소 | 자동 반영 | 감지 못함 | +| 운영팀 데이터 보정 | 다음 배치 자동 반영 | 전체 재계산을 별도 실행해야 함 | +| 오류 전파 | 없음 (매번 원장 독립 계산) | 어제 MV가 틀리면 오늘도 틀림 | + +성능 차이는 운영에 영향 없는 수준이다. + +``` +전체 재계산 (Partitioning 4 Worker): ~10초 +증분 계산: ~3초 +→ 1일 1회 배치에서 7초 차이 +``` + +**전체 재계산을 유지한다.** 7초의 성능 이점보다 Late-Arriving Fact 자동 반영 + 오류 자동 복구 + 구현 단순성이 이커머스 랭킹에서 더 가치 있다. 또한, MV의 존재 이유가 "Redis 근사치와 다른 정확한 기간 집계"인데, MV까지 과거 변경을 반영하지 못하는 증분 방식을 쓰면 MV의 정확성이라는 존재 이유가 약해진다. + +--- + +## 4. 구현: 3-Step Chunk Job + +위의 판단들이 구현에서 어떻게 결합되는지 정리한다. + +### 파이프라인 구조 + +``` +Step 1: cleanupStep (Tasklet) + DELETE FROM mv_product_rank_{scope} WHERE period_key = ? + DELETE FROM mv_product_rank_staging WHERE period_key = ? + + 3일 이전 과거 데이터 정리 + +Step 2: partitionedAggregateStep (Chunk × 4 Workers) + [Partitioner] product_id MIN~MAX를 4개 범위로 분할 + ┌───────────────────────────────────────────┐ + │ Worker 1: id 1~250K → CursorReader │ + │ Worker 2: id 250K~500K → CursorReader │ ← 병렬 실행 + │ Worker 3: id 500K~750K → CursorReader │ + │ Worker 4: id 750K~1M → CursorReader │ + └───────────────────────────────────────────┘ + 각 Worker: GROUP BY + score 계산 → 스테이징 테이블 INSERT + +Step 3: mergeStep (Tasklet) + SELECT ... FROM staging ORDER BY score DESC LIMIT 100 + → INSERT INTO mv_product_rank_{scope} +``` + +**이 3-Step은 분산 시스템의 Map-Reduce 패턴이다.** Step 2가 Map(병렬 집계), Step 3가 Reduce(전역 정렬 + TOP 100 추출). 스테이징 테이블이 두 단계를 연결하는 중간 저장소 역할을 한다. + +각 Worker가 독립 커넥션 + 독립 CursorReader를 가지므로, CursorReader의 멀티스레드 한계를 극복하면서 GROUP BY 1회 실행이라는 장점을 유지한다. + +### 왜 PagingReader 멀티스레드가 아닌 Partitioning인가 + +| 방식 | GROUP BY 실행 횟수 | 소요 시간 (상품 100만) | +|------|-----------------|---------------------| +| 단일 CursorReader | 1회 (3,000만 행) | ~30초 | +| PagingReader 멀티스레드 | 페이지 수 × 스레드 수 | **수 시간** | +| **Partitioning + CursorReader** | Worker 수 (각 750만 행) | **~10초** | + +Partitioning은 데이터를 범위로 분할하여 각 Worker가 자기 범위만 GROUP BY하므로, 전체 데이터를 매번 재집계하는 PagingReader와 근본적으로 다르다. + +### Partitioner 구현 + +```java +private Partitioner createPartitioner(String targetDate, String scope) { + return gridSize -> { + int days = "weekly".equals(scope) ? 6 : 29; + LocalDate endDate = LocalDate.parse(targetDate, DATE_FORMATTER); + LocalDate startDate = endDate.minusDays(days); + + JdbcTemplate jdbc = new JdbcTemplate(dataSource); + Long minId = jdbc.queryForObject( + "SELECT COALESCE(MIN(product_id), 0) FROM product_metrics " + + "WHERE metric_date BETWEEN ? AND ?", + Long.class, startDate, endDate); + Long maxId = jdbc.queryForObject( + "SELECT COALESCE(MAX(product_id), 0) FROM product_metrics " + + "WHERE metric_date BETWEEN ? AND ?", + Long.class, startDate, endDate); + + long range = (maxId - minId) / gridSize + 1; + Map partitions = new HashMap<>(); + + for (int i = 0; i < gridSize; i++) { + ExecutionContext ctx = new ExecutionContext(); + ctx.putLong("minProductId", minId + (i * range)); + ctx.putLong("maxProductId", + Math.min(minId + ((i + 1) * range) - 1, maxId)); + partitions.put("partition" + i, ctx); + } + return partitions; + }; +} +``` + +product_id 범위를 균등 분할한다. 각 Worker의 Reader SQL에 `WHERE pm.product_id BETWEEN ? AND ?` 조건이 추가되어 자기 범위만 집계한다. + +### 멱등성: DELETE + INSERT + +```java +// CleanupTasklet — Step 1에서 타겟 날짜의 기존 데이터를 전부 정리 +int deletedMv = jdbcTemplate.update( + "DELETE FROM " + mvTable + " WHERE period_key = ?", targetDate); +int deletedStaging = jdbcTemplate.update( + "DELETE FROM mv_product_rank_staging WHERE period_key = ?", targetDate); +``` + +같은 파라미터로 몇 번을 실행해도 결과가 동일하다. `RunIdIncrementer`가 `run.id`를 증가시켜 재실행을 허용하고, cleanupStep이 기존 데이터를 삭제하고 새로 적재한다. + +Step 2에서 일부 Worker만 실패하면? 전체 재실행이 가장 단순하고 안전하다. 수십 초 수준의 작업이므로 "실패한 파티션만 재실행"보다 "전부 정리하고 처음부터"가 운영상 안전하다. cleanupStep의 `allowStartIfComplete(true)`가 이미 완료된 Step의 재실행을 허용한다. + +--- + +## 5. 시행착오 + +### "Tasklet이 실무 표준" — 한 조직의 패턴을 일반화한 오류 + +90개 Job 분석에서 Tasklet이 대다수인 것을 보고 "통계/집계 Job에서 Tasklet이 표준"이라고 결론 내렸다. MyBatis + SQL 중심 아키텍처라는 맥락을 무시한 확대 해석이었다. + +교훈: 실무 코드를 분석할 때 "무엇을 하고 있는가"뿐 아니라 "어떤 기술 스택·조직 문화에서 이 선택이 나왔는가"를 함께 봐야 한다. 하나의 코드베이스에서 관찰한 패턴은 그 조직의 맥락에서 합리적인 선택이지, 업계 표준과 동치가 아니다. + +### MV를 Redis fallback으로 쓰려다 — 데이터 불일치 함정 + +초기 설계에서 "MV primary, Redis fallback"이 자연스러워 보였다. MV가 장애나면 Redis에서라도 보여주면 되니까. log₁₀ 비선형성 검증을 하기 전까지는 두 시스템이 "대충 비슷한 결과"를 낼 것이라고 암묵적으로 가정하고 있었다. + +교훈: fallback을 설계할 때, primary와 fallback이 **같은 계약을 이행하는지** 확인해야 한다. "비슷한 데이터를 제공한다"와 "같은 기준의 데이터를 제공한다"는 다르다. + +### Chunk-Oriented인데 Processor가 할 일이 없다? + +Score 계산과 TOP-N 필터링을 SQL에서 끝내니까 Processor가 비어버렸다. "Chunk-Oriented에서 Processor가 비즈니스 로직을 담당해야 한다"는 일반론에 어긋나는 것 같아서 불편했다. + +그런데 회사 12개 매퍼를 분석한 결과, DB에서 윈도우 함수로 정렬·필터링까지 끝내고 Java는 오케스트레이션만 하는 것이 실무 패턴이었다. **"어디서 계산하느냐"는 효율의 문제이지 패턴 준수의 문제가 아니다.** + +--- + +## 6. 실전에서라면 + +### Replica DB 분리 + +CursorReader의 커넥션 점유가 문제가 되는 것은 여러 Job이 동시에 실행되어 커넥션 풀이 고갈될 때다. 분석한 회사 배치 프로젝트 2개도 RODB/RWDB를 5~6쌍으로 분리하여 이 문제를 해결하고 있었다. 배치가 Replica에서 읽으면 서비스 DB의 커넥션 풀과 독립되므로, CursorReader의 커넥션 점유가 서비스에 영향을 주지 않는다. + +### 사전 집계 파이프라인의 필요성 + +쿠팡급(상품 100만, product_metrics 30일치 3,000만 행)에서 Chunk든 Tasklet이든 집계 쿼리의 DB 부하는 동일하다. 진짜 해결해야 할 문제는 처리 모델 선택이 아니라 **"이 집계를 서비스 DB에서 할 것인가"**이다. Flink/Spark 같은 사전 집계 파이프라인이나 DW에서 집계하는 것이 대규모에서의 정석이다. + +우리 프로젝트에서는 이미 Kafka → MetricsConsumer → product_metrics라는 사전 집계 레이어가 존재한다. 원시 이벤트(수억 건)가 아닌 일간 집계 테이블(수만 건)을 배치에서 읽는 구조이므로, 사전 집계가 Reader의 입력 볼륨을 줄이는 역할을 하고 있다. + +### gridSize 튜닝 + +현재 gridSize=4로 고정했지만, 실무에서는 상품 수와 DB 커넥션 풀 크기에 따라 동적으로 조정해야 한다. 커넥션 풀이 20개이고 다른 Job과 공유한다면 gridSize를 8 이상으로 올리면 커넥션 부족이 발생할 수 있다. `MIN/MAX(product_id)` 쿼리로 데이터 분포를 확인하고 gridSize를 결정하는 방식을 기본으로 하되, 설정값으로 외부화하여 운영 중 변경할 수 있도록 하는 것이 실용적이다. + +### Drift Detection — 배치 사이의 빈 시간 + +1시간 주기 배치 보정(RankingCorrectionJob) 사이에 Redis drift가 누적될 수 있다. 이것을 조기 감지하기 위해 5분 주기로 Redis Top-20과 DB score를 비교하는 경량 모니터링(RankingDriftScheduler)을 추가했다. 부하는 ~2ms/5분으로 서비스 요청 경로에 영향 없이, "실시간 경로가 얼마나 벗어나고 있는가"를 지속적으로 관찰한다. + +실무에서는 이 drift 메트릭에 알림 임계치를 걸어서 "drift > 20%면 즉시 보정 Job 트리거"와 같은 자동 대응을 구성할 수 있다. + +--- + +## 7. 돌아보며 + +### Lambda Architecture에서 배운 것 + +이 과제를 시작했을 때는 "MV는 Redis의 백업"이라고 생각했다. Redis가 장애나면 MV에서 읽으면 되니까. 그런데 log₁₀ 비선형성을 숫자로 확인하면서, 두 시스템이 같은 데이터로 다른 결과를 내는 것이 단점이 아니라 **설계 의도**라는 것을 이해했다. + +**"Lambda Architecture에서 두 Layer의 가치는 같은 결과를 내는 것이 아니라, 같은 데이터로 다른 관점을 제공하는 것이다."** + +이 관점이 모든 후속 판단의 출발점이 되었다. Score 방식을 균등 합산으로 정한 것도, MV를 Redis fallback으로 쓰지 않기로 한 것도, 전체 재계산을 유지한 것도 — "MV는 Redis와 다른 관점을 정확하게 제공해야 한다"는 한 줄에서 파생되었다. + +### 10주간의 흐름 + +돌이켜보면 1~10주차가 하나의 연결된 흐름이었다. + +초반에는 요구사항을 그대로 구현하는 데 집중했다. "JPA로 CRUD"에서 시작해서, "왜 이 구조인가"라는 질문 없이 동작하는 코드를 만들었다. 전환점은 Round 7~8쯤이었다. Kafka 파이프라인을 설계하면서 "실시간 이벤트가 DB와 Redis에 각각 어떤 시점에 반영되는가"를 추적해야 했고, 이때부터 "동작하는 코드"와 "설명할 수 있는 설계"의 차이를 인식하기 시작했다. + +Round 9에서 Redis 랭킹을 만들면서 가중합의 함정, log₁₀ 정규화, 지수 감쇠 같은 판단을 처음 경험했다. 선택지가 여러 개인데 정답이 없는 상황에서 "왜 이것을 골랐는가"를 숫자로 검증하는 습관이 생겼다. + +Round 10에서는 그 습관이 자연스러워졌다. 균등 합산 vs 지수 감쇠를 비교할 때 직관이 아니라 실제 데이터를 넣어서 확인했고, CursorReader vs PagingReader를 비교할 때 3,000만 행 기준 소요 시간을 산정했다. **"왜 이렇게 했는가"에 숫자로 답할 수 있게 된 것**이 10주간 가장 크게 달라진 점이다. + +### 가장 큰 전환점 + +"실무 코드를 분석했더니 Tasklet이 대다수" → "그러니까 Tasklet이 표준"으로 곧장 결론 내린 순간. 그리고 그 결론이 깨진 순간. + +하나의 코드베이스에서 관찰한 패턴을 일반화하는 것은 위험하다. 그 패턴이 어떤 기술 스택, 조직 문화, 도메인 맥락에서 나왔는지를 함께 봐야 한다. 이것을 경험으로 체득한 것이 이번 과제의 가장 큰 수확이다. diff --git a/http/commerce-api/example-v1.http b/http/commerce-api/example-v1.http deleted file mode 100644 index 2a924d2655..0000000000 --- a/http/commerce-api/example-v1.http +++ /dev/null @@ -1,2 +0,0 @@ -### 예시 조회 -GET {{commerce-api}}/api/v1/examples/1 \ No newline at end of file diff --git a/k6/common.js b/k6/common.js new file mode 100644 index 0000000000..cf57f708de --- /dev/null +++ b/k6/common.js @@ -0,0 +1,35 @@ +import { check } from 'k6'; + +export const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080'; + +export const defaultOptions = { + scenarios: { + load_test: { + executor: 'ramping-arrival-rate', + startRate: 10, + timeUnit: '1s', + preAllocatedVUs: 50, + maxVUs: 300, + stages: [ + { duration: '10s', target: 50 }, // Warm-up + { duration: '20s', target: 200 }, // Ramp-up + { duration: '30s', target: 200 }, // Peak + { duration: '10s', target: 10 }, // Cool-down + ], + }, + }, + thresholds: { + http_req_duration: ['p(95)<500', 'p(99)<1000'], + http_req_failed: ['rate<0.01'], + }, +}; + +export function checkResponse(res, name) { + check(res, { + [`${name} status 200`]: (r) => r.status === 200, + [`${name} has data`]: (r) => { + const body = JSON.parse(r.body); + return body.meta && body.meta.result === 'SUCCESS'; + }, + }); +} diff --git a/k6/product-detail.js b/k6/product-detail.js new file mode 100644 index 0000000000..f73cb93cc9 --- /dev/null +++ b/k6/product-detail.js @@ -0,0 +1,21 @@ +import http from 'k6/http'; +import { BASE_URL, defaultOptions, checkResponse } from './common.js'; + +export const options = { + ...defaultOptions, + thresholds: { + ...defaultOptions.thresholds, + http_req_duration: ['p(95)<100', 'p(99)<200'], + }, +}; + +// 1~100 범위의 상품 ID를 랜덤 조회 (시딩 데이터 기준) +const MAX_PRODUCT_ID = __ENV.MAX_PRODUCT_ID ? parseInt(__ENV.MAX_PRODUCT_ID) : 100; + +export default function () { + const productId = Math.floor(Math.random() * MAX_PRODUCT_ID) + 1; + const url = `${BASE_URL}/api/v1/products/${productId}`; + + const res = http.get(url); + checkResponse(res, 'product-detail'); +} diff --git a/k6/product-list-benchmark.js b/k6/product-list-benchmark.js new file mode 100644 index 0000000000..0ada2bf334 --- /dev/null +++ b/k6/product-list-benchmark.js @@ -0,0 +1,43 @@ +import http from 'k6/http'; +import { check } from 'k6'; + +const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080'; + +export const options = { + scenarios: { + load_test: { + executor: 'constant-arrival-rate', + rate: 100, + timeUnit: '1s', + duration: '1m', + preAllocatedVUs: 50, + maxVUs: 200, + }, + }, + thresholds: { + http_req_duration: ['p(95)<500'], + http_req_failed: ['rate<0.01'], + }, +}; + +const sorts = ['latest', 'price_asc', 'likes_desc']; + +export default function () { + const sort = sorts[Math.floor(Math.random() * sorts.length)]; + const page = Math.floor(Math.random() * 5); // 0~4 페이지 + const endpoint = __ENV.ENDPOINT || ''; + const url = `${BASE_URL}/api/v1/products${endpoint}?sort=${sort}&page=${page}&size=20`; + + const res = http.get(url); + check(res, { + 'status 200': (r) => r.status === 200, + 'has data': (r) => { + try { + const body = JSON.parse(r.body); + return body.meta && body.meta.result === 'SUCCESS'; + } catch (e) { + return false; + } + }, + }); +} diff --git a/k6/product-list-no-cache.js b/k6/product-list-no-cache.js new file mode 100644 index 0000000000..0c656e2c4c --- /dev/null +++ b/k6/product-list-no-cache.js @@ -0,0 +1,21 @@ +import http from 'k6/http'; +import { BASE_URL, defaultOptions, checkResponse } from './common.js'; + +export const options = { + ...defaultOptions, + thresholds: { + ...defaultOptions.thresholds, + http_req_duration: ['p(95)<300', 'p(99)<500'], // 인덱스만 사용, 캐시 없음 + }, +}; + +const sorts = ['latest', 'price_asc', 'likes_desc']; + +export default function () { + const sort = sorts[Math.floor(Math.random() * sorts.length)]; + const page = Math.floor(Math.random() * 5); + const url = `${BASE_URL}/api/v1/products/no-cache?sort=${sort}&page=${page}&size=20`; + + const res = http.get(url); + checkResponse(res, 'no-cache-list'); +} diff --git a/k6/product-list-no-optimization.js b/k6/product-list-no-optimization.js new file mode 100644 index 0000000000..7bbcf629e8 --- /dev/null +++ b/k6/product-list-no-optimization.js @@ -0,0 +1,20 @@ +import http from 'k6/http'; +import { BASE_URL, defaultOptions, checkResponse } from './common.js'; + +export const options = { + ...defaultOptions, + thresholds: { + ...defaultOptions.thresholds, + http_req_duration: ['p(95)<2000', 'p(99)<5000'], // AS-IS: 전량 로딩 + COUNT + in-memory sort + }, +}; + +const sorts = ['latest', 'price_asc', 'likes_desc']; + +export default function () { + const sort = sorts[Math.floor(Math.random() * sorts.length)]; + const url = `${BASE_URL}/api/v1/products/no-optimization?sort=${sort}`; + + const res = http.get(url); + checkResponse(res, 'no-optimization-list'); +} diff --git a/k6/product-list-optimized.js b/k6/product-list-optimized.js new file mode 100644 index 0000000000..3cca8d658a --- /dev/null +++ b/k6/product-list-optimized.js @@ -0,0 +1,21 @@ +import http from 'k6/http'; +import { BASE_URL, defaultOptions, checkResponse } from './common.js'; + +export const options = { + ...defaultOptions, + thresholds: { + ...defaultOptions.thresholds, + http_req_duration: ['p(95)<100', 'p(99)<200'], // 캐시 적용 시 더 빠른 응답 기대 + }, +}; + +const sorts = ['latest', 'price_asc', 'likes_desc']; + +export default function () { + const sort = sorts[Math.floor(Math.random() * sorts.length)]; + const page = Math.floor(Math.random() * 5); // 0~4 페이지 + const url = `${BASE_URL}/api/v1/products?sort=${sort}&page=${page}&size=20`; + + const res = http.get(url); + checkResponse(res, 'optimized-list'); +} diff --git a/k6/queue-bf-load-test.js b/k6/queue-bf-load-test.js new file mode 100644 index 0000000000..cc2d3c644c --- /dev/null +++ b/k6/queue-bf-load-test.js @@ -0,0 +1,286 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Trend, Counter, Rate } from 'k6/metrics'; +import exec from 'k6/execution'; + +// ============================================================================= +// 블랙 프라이데이 시나리오 부하 테스트 (Open-loop) +// +// 목적: +// 시스템 수용치(80 TPS)를 초과하는 요청이 들어올 때, +// 대기열이 요청을 줄세우고 순서를 보장하여 처리하는지 검증한다. +// +// 핵심 검증: +// 1. 수용치 초과 요청은 QUEUED (드롭 없음) +// 2. QUEUED된 유저는 순서대로 ADMITTED +// 3. 대기열이 가득 차면 QUEUE_FULL 반환 (48,000명 초과 시) +// 4. 대기열 크기와 무관하게 DB 커넥션 풀은 안전 (ρ ≤ 0.7 목표) +// +// Open-loop 설계: +// ramping-arrival-rate — iteration 완료 여부와 무관하게 초당 N건 투입. +// VU가 대기열 폴링에 묶여도 새로운 요청이 계속 들어온다. +// 이전 버전(ramping-vus, closed-loop)에서는 VU가 폴링에 묶여 +// 실제 도착률이 설계값보다 크게 낮았음 (1000 VU → 실측 40 enter/s). +// +// 급간 설계 (초당 iteration 수 = 초당 새 queue/enter 호출 수): +// T1 (정상): 30/s, 30s → 입장 80/s > 도착, 대기열 비어있음 +// T2 (임계): 80/s, 60s → 도착 = 입장, 균형점 +// T3 (초과): 150/s, 60s → 순 70/s 누적, 대기열 증가 +// T4 (블프 피크): 200/s, 90s → 순 120/s 누적, 대기열 급증 +// T5 (쿨다운): 0/s, 120s → 대기열 소진, 시스템 복귀 +// +// 대기열 성장 예측: +// T3: 70/s 순누적 × 60s = 4,200 +// T4: 120/s 순누적 × 90s = 10,800 → 합산 ~15,000 +// max_queue=1,000 설정 시 T3 시작 14초 만에 QUEUE_FULL 도달 +// +// 5차 교훈 (Open-loop + VU 기반 userId): +// - VU = userId 고정이면 maxVUs = 고유 유저 수 상한 +// - 5,000 VU로는 48,000 대기열 불가능 (동일 유저 재진입 = 대기열 +0) +// - iterationInTest 기반 userId 매핑으로 매 iteration 고유 유저 배정 +// - max_queue 축소로 QUEUE_FULL 메커니즘 검증 (표준 부하 테스트 접근) +// +// 사전 준비: +// 1. ./scripts/seed-test-data.sh (상품/브랜드 생성) +// 2. ./scripts/seed-bf-test-data.sh 5000 (유저 5000명 생성) +// 3. ./scripts/reset-bf-test.sh (매 테스트 전 초기화) +// 4. 서버: queue.max-size=1000 설정 후 재시작 (QUEUE_FULL 검증 시) +// +// 실행: +// k6 run k6/queue-bf-load-test.js +// k6 run -e MAX_USERS=5000 k6/queue-bf-load-test.js +// +// Grafana 모니터링: +// http://localhost:3000 → Queue System 대시보드 +// ============================================================================= + +const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080'; +const ACTUATOR_URL = __ENV.ACTUATOR_URL || 'http://localhost:8081'; +const MAX_USERS = parseInt(__ENV.MAX_USERS || '5000'); + +// --- Custom Metrics --- + +// 주문 +const orderDuration = new Trend('order_duration', true); +const orderSuccess = new Counter('order_success'); +const orderFailed = new Counter('order_failed'); + +// 대기열 +const queueWaitTime = new Trend('queue_wait_time', true); +const queueEnterQueued = new Counter('queue_enter_queued'); +const queueEnterAdmitted = new Counter('queue_enter_admitted'); +const queueEnterFull = new Counter('queue_enter_full'); +const queueEnterError = new Counter('queue_enter_error'); + +// 비율 +const orderFailRate = new Rate('order_fail_rate'); +const queueFullRate = new Rate('queue_full_rate'); + +// --- 시나리오 급간 --- +export const options = { + scenarios: { + // 블프 트래픽: Open-loop 5급간 + bf_traffic: { + executor: 'ramping-arrival-rate', + startRate: 0, + timeUnit: '1s', + stages: [ + // T1: 정상 (30/s, 30s) + { duration: '10s', target: 30 }, + { duration: '20s', target: 30 }, + + // T2: 임계 (80/s, 60s) — 입장률과 동일 + { duration: '10s', target: 80 }, + { duration: '50s', target: 80 }, + + // T3: 초과 (150/s, 60s) + { duration: '10s', target: 150 }, + { duration: '50s', target: 150 }, + + // T4: 블프 피크 (200/s, 90s) + { duration: '15s', target: 200 }, + { duration: '75s', target: 200 }, + + // T5: 쿨다운 (0/s) + { duration: '10s', target: 0 }, + ], + preAllocatedVUs: 5000, + maxVUs: 10000, + gracefulStop: '120s', // 대기열 폴링 중인 VU 종료 대기 + }, + // HikariCP + 대기열 모니터링 (1초 주기) + monitor: { + executor: 'constant-arrival-rate', + rate: 1, + timeUnit: '1s', + duration: '400s', + preAllocatedVUs: 1, + maxVUs: 1, + exec: 'monitorSystem', + }, + }, + thresholds: { + // BF 시나리오: row lock 경합으로 p99가 높아질 수 있음 + // 시스템 생존 확인 (죽지 않고 처리 완료) + order_duration: ['p(99)<10000'], // 10초 이내 + }, +}; + +// --- 메인 시나리오: BF 트래픽 (Open-loop) --- +export default function () { + // iteration 번호 → 유저 매핑 (매 iteration마다 고유 유저 배정) + // VU 기반 매핑은 동일 VU가 같은 userId를 반복 사용하여 대기열이 쌓이지 않음 + const userId = (exec.scenario.iterationInTest % MAX_USERS) + 1; + const paddedId = String(userId).padStart(4, '0'); + const loginId = `bf${paddedId}`; + const authHeaders = { + 'X-Loopers-LoginId': loginId, + 'X-Loopers-LoginPw': 'Password1!', + }; + + // 대기열 진입 → 대기 → 주문 (sleep 없음 — 최대 압력) + queueAndOrderFlow(authHeaders, userId); +} + +function queueAndOrderFlow(authHeaders, userId) { + // 1. Enter queue + const enterRes = http.post(`${BASE_URL}/api/v1/queue/enter`, null, { + headers: authHeaders, + tags: { name: 'queue_enter' }, + }); + + if (enterRes.status !== 200) { + queueEnterError.add(1); + queueFullRate.add(false); + return; + } + + const enterData = enterRes.json('data'); + if (!enterData) { + queueEnterError.add(1); + queueFullRate.add(false); + return; + } + + const status = enterData.status; + + // QUEUE_FULL → 즉시 반환 + if (status === 'QUEUE_FULL') { + queueEnterFull.add(1); + queueFullRate.add(true); + return; + } + + // ADMITTED → 바로 주문 + if (status === 'ADMITTED') { + queueEnterAdmitted.add(1); + queueFullRate.add(false); + placeOrder(authHeaders, userId); + return; + } + + // QUEUED → 폴링 대기 (서버 권장 주기 사용) + queueEnterQueued.add(1); + queueFullRate.add(false); + + // 2. Poll for admission + const suggestedInterval = enterData.suggestedPollIntervalMs || 3000; + const startWait = Date.now(); + let admitted = false; + + while (Date.now() - startWait < 120000) { + sleep(suggestedInterval / 1000); // 폴링 간격 (서버 권장) + + const posRes = http.get(`${BASE_URL}/api/v1/queue/position`, { + headers: authHeaders, + tags: { name: 'queue_position' }, + }); + + if (posRes.status !== 200) continue; + + const posData = posRes.json('data'); + if (!posData) continue; + + if (posData.status === 'ADMITTED') { + admitted = true; + break; + } + + if (posData.status === 'NOT_IN_QUEUE') { + break; + } + } + + queueWaitTime.add(Date.now() - startWait); + + if (!admitted) { + orderFailed.add(1); + orderFailRate.add(true); + return; + } + + // 3. Place order + placeOrder(authHeaders, userId); +} + +function placeOrder(authHeaders, userId) { + const productId = (userId % 5) + 1; + const orderPayload = JSON.stringify({ + items: [{ productId: productId, quantity: 1 }], + }); + + const start = Date.now(); + const orderRes = http.post(`${BASE_URL}/api/v1/orders`, orderPayload, { + headers: Object.assign({}, authHeaders, { 'Content-Type': 'application/json' }), + tags: { name: 'order_create' }, + }); + orderDuration.add(Date.now() - start); + + const success = check(orderRes, { + 'order created (201)': (r) => r.status === 201, + }); + + if (success) { + orderSuccess.add(1); + orderFailRate.add(false); + } else { + orderFailed.add(1); + orderFailRate.add(true); + } +} + +// --- 시스템 모니터링 시나리오 --- +export function monitorSystem() { + const res = http.get(`${ACTUATOR_URL}/actuator/prometheus`, { + tags: { name: 'actuator' }, + }); + + if (res.status !== 200) return; + + const body = res.body; + + // HikariCP + const activeMatch = body.match(/hikaricp_connections_active\{[^}]*\}\s+([\d.]+)/); + const pendingMatch = body.match(/hikaricp_connections_pending\{[^}]*\}\s+([\d.]+)/); + const totalMatch = body.match(/hikaricp_connections\{[^}]*pool="mysql-main-pool"[^}]*\}\s+([\d.]+)/); + + const active = activeMatch ? parseFloat(activeMatch[1]) : 0; + const pending = pendingMatch ? parseFloat(pendingMatch[1]) : 0; + const total = totalMatch ? parseFloat(totalMatch[1]) : 40; + + // 대기열 커스텀 메트릭 + const queueSizeMatch = body.match(/queue_waiting_size\{[^}]*\}\s+([\d.]+)/); + const admissionCountMatch = body.match(/queue_admission_count_total\{[^}]*\}\s+([\d.]+)/); + const queueFullMatch = body.match(/queue_enter_status_total\{[^}]*status="QUEUE_FULL"[^}]*\}\s+([\d.]+)/); + + const queueSize = queueSizeMatch ? parseInt(queueSizeMatch[1]) : 0; + const admissionTotal = admissionCountMatch ? parseFloat(admissionCountMatch[1]) : 0; + const queueFullTotal = queueFullMatch ? parseFloat(queueFullMatch[1]) : 0; + + const rho = total > 0 ? (active / total).toFixed(3) : '?'; + + console.log( + `[Monitor] ρ=${rho} active=${active}/${total} pending=${pending} | ` + + `queue=${queueSize} admitted_total=${admissionTotal} queue_full_total=${queueFullTotal}` + ); +} diff --git a/k6/queue-order-load-test.js b/k6/queue-order-load-test.js new file mode 100644 index 0000000000..610940ab3f --- /dev/null +++ b/k6/queue-order-load-test.js @@ -0,0 +1,129 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Trend, Counter, Rate } from 'k6/metrics'; + +// ============================================================================= +// 대기열 + 주문 부하 테스트 +// +// 시나리오: 유저가 대기열 진입 → 토큰 발급 대기 → 주문 생성 +// 목적: 주문 API의 p99 레이턴시 측정 및 대기열 시스템 동작 검증 +// +// 실행: k6 run k6/queue-order-load-test.js +// ============================================================================= + +const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080'; +const MAX_USERS = __ENV.MAX_USERS ? parseInt(__ENV.MAX_USERS) : 9; + +// Custom metrics +const orderDuration = new Trend('order_duration', true); +const queueWaitTime = new Trend('queue_wait_time', true); +const orderSuccess = new Counter('order_success'); +const orderFailed = new Counter('order_failed'); +const tokenTimeout = new Counter('token_timeout'); +const failRate = new Rate('order_fail_rate'); + +export const options = { + scenarios: { + queue_order_flow: { + executor: 'ramping-vus', + startVUs: 0, + stages: [ + { duration: '5s', target: 20 }, // Warm-up + { duration: '15s', target: 50 }, // Ramp-up + { duration: '30s', target: 50 }, // Sustained peak + { duration: '10s', target: 0 }, // Cool-down + ], + }, + }, + thresholds: { + http_req_duration: ['p(95)<1000', 'p(99)<2000'], + order_fail_rate: ['rate<0.1'], + }, +}; + +export default function () { + const userId = ((__VU - 1) % MAX_USERS) + 1; + const loginId = `user${userId}`; + const authHeaders = { + 'X-Loopers-LoginId': loginId, + 'X-Loopers-LoginPw': 'Password1!', + }; + + // 1. Enter queue + const enterRes = http.post(`${BASE_URL}/api/v1/queue/enter`, null, { + headers: authHeaders, + }); + + const enterData = enterRes.json('data'); + if (!enterData) { + orderFailed.add(1); + failRate.add(true); + return; + } + + // 2. If already admitted, go straight to order + if (enterData.status === 'ADMITTED') { + placeOrder(authHeaders, userId); + return; + } + + // 3. Poll for admission (max 30s) + const startWait = Date.now(); + const maxWaitMs = 30000; + let admitted = false; + + while (Date.now() - startWait < maxWaitMs) { + sleep(0.5); + + const posRes = http.get(`${BASE_URL}/api/v1/queue/position`, { + headers: authHeaders, + }); + + const posData = posRes.json('data'); + if (posData && posData.status === 'ADMITTED') { + admitted = true; + break; + } + } + + const waitTime = Date.now() - startWait; + queueWaitTime.add(waitTime); + + if (!admitted) { + tokenTimeout.add(1); + failRate.add(true); + return; + } + + // 4. Place order + placeOrder(authHeaders, userId); +} + +function placeOrder(authHeaders, userId) { + const productId = (userId % 5) + 1; + const orderPayload = JSON.stringify({ + items: [{ productId: productId, quantity: 1 }], + }); + + const start = Date.now(); + const orderRes = http.post(`${BASE_URL}/api/v1/orders`, orderPayload, { + headers: Object.assign({}, authHeaders, { + 'Content-Type': 'application/json', + }), + }); + const duration = Date.now() - start; + + orderDuration.add(duration); + + const success = check(orderRes, { + 'order created': (r) => r.status === 201 || r.status === 200, + }); + + if (success) { + orderSuccess.add(1); + failRate.add(false); + } else { + orderFailed.add(1); + failRate.add(true); + } +} diff --git a/k6/queue-realistic-load-test.js b/k6/queue-realistic-load-test.js new file mode 100644 index 0000000000..d3097efe80 --- /dev/null +++ b/k6/queue-realistic-load-test.js @@ -0,0 +1,184 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Trend, Counter, Rate, Gauge } from 'k6/metrics'; + +// ============================================================================= +// 현실적 혼합 트래픽 부하 테스트 +// +// 시나리오: +// - 유저 100명 (1인 1VU, 토큰 경합 없음) +// - 혼합 트래픽: 상품 조회 70% + 대기열+주문 30% +// - HikariCP active 커넥션 모니터링 +// +// 목적: 배치 크기 8명 설정에서 커넥션 풀 사용률 검증 +// +// 실행: k6 run k6/queue-realistic-load-test.js +// ============================================================================= + +const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080'; +const ACTUATOR_URL = __ENV.ACTUATOR_URL || 'http://localhost:8081'; +const MAX_USERS = 100; + +// Custom metrics +const orderDuration = new Trend('order_duration', true); +const productViewDuration = new Trend('product_view_duration', true); +const queueWaitTime = new Trend('queue_wait_time', true); +const orderSuccess = new Counter('order_success'); +const orderFailed = new Counter('order_failed'); +const productViews = new Counter('product_views'); +const failRate = new Rate('order_fail_rate'); + +export const options = { + scenarios: { + // 혼합 트래픽: 상품 조회 + 주문 + mixed_traffic: { + executor: 'ramping-vus', + startVUs: 0, + stages: [ + { duration: '5s', target: 30 }, // Warm-up + { duration: '10s', target: 80 }, // Ramp-up + { duration: '30s', target: 80 }, // Sustained peak + { duration: '5s', target: 0 }, // Cool-down + ], + }, + // HikariCP 모니터링 (1초 주기) + monitor: { + executor: 'constant-arrival-rate', + rate: 1, + timeUnit: '1s', + duration: '50s', + preAllocatedVUs: 1, + maxVUs: 1, + exec: 'monitorHikari', + }, + }, + thresholds: { + order_duration: ['p(95)<500', 'p(99)<1000'], + order_fail_rate: ['rate<0.05'], + }, +}; + +// --- 혼합 트래픽 시나리오 --- +export default function () { + const userId = ((__VU - 1) % MAX_USERS) + 1; + const paddedId = String(userId).padStart(3, '0'); + const loginId = `lu${paddedId}`; + const authHeaders = { + 'X-Loopers-LoginId': loginId, + 'X-Loopers-LoginPw': 'Password1!', + }; + + // 70% 상품 조회, 30% 주문 + if (Math.random() < 0.7) { + viewProduct(authHeaders); + } else { + orderFlow(authHeaders, userId); + } + + sleep(0.1 + Math.random() * 0.3); // 100~400ms think time +} + +function viewProduct(authHeaders) { + const productId = Math.floor(Math.random() * 5) + 1; + const start = Date.now(); + const res = http.get(`${BASE_URL}/api/v1/products/${productId}`, { + headers: authHeaders, + tags: { name: 'product_view' }, + }); + productViewDuration.add(Date.now() - start); + productViews.add(1); +} + +function orderFlow(authHeaders, userId) { + // 1. Enter queue + const enterRes = http.post(`${BASE_URL}/api/v1/queue/enter`, null, { + headers: authHeaders, + tags: { name: 'queue_enter' }, + }); + + const enterData = enterRes.json('data'); + if (!enterData) { + orderFailed.add(1); + failRate.add(true); + return; + } + + if (enterData.status === 'ADMITTED') { + placeOrder(authHeaders, userId); + return; + } + + // 2. Poll for admission (max 60s) + const startWait = Date.now(); + let admitted = false; + + while (Date.now() - startWait < 60000) { + sleep(1); + const posRes = http.get(`${BASE_URL}/api/v1/queue/position`, { + headers: authHeaders, + tags: { name: 'queue_position' }, + }); + const posData = posRes.json('data'); + if (posData && posData.status === 'ADMITTED') { + admitted = true; + break; + } + } + + queueWaitTime.add(Date.now() - startWait); + + if (!admitted) { + orderFailed.add(1); + failRate.add(true); + return; + } + + placeOrder(authHeaders, userId); +} + +function placeOrder(authHeaders, userId) { + const productId = (userId % 5) + 1; + const orderPayload = JSON.stringify({ + items: [{ productId: productId, quantity: 1 }], + }); + + const start = Date.now(); + const orderRes = http.post(`${BASE_URL}/api/v1/orders`, orderPayload, { + headers: Object.assign({}, authHeaders, { 'Content-Type': 'application/json' }), + tags: { name: 'order_create' }, + }); + orderDuration.add(Date.now() - start); + + const success = check(orderRes, { + 'order created (201)': (r) => r.status === 201, + }); + + if (success) { + orderSuccess.add(1); + failRate.add(false); + } else { + orderFailed.add(1); + failRate.add(true); + } +} + +// --- HikariCP 모니터링 시나리오 --- +export function monitorHikari() { + const res = http.get(`${ACTUATOR_URL}/actuator/prometheus`, { + tags: { name: 'actuator' }, + }); + + if (res.status !== 200) return; + + const body = res.body; + + const activeMatch = body.match(/hikaricp_connections_active\{[^}]*\}\s+([\d.]+)/); + const pendingMatch = body.match(/hikaricp_connections_pending\{[^}]*\}\s+([\d.]+)/); + const totalMatch = body.match(/hikaricp_connections\{[^}]*pool="mysql-main-pool"[^}]*\}\s+([\d.]+)/); + + const active = activeMatch ? parseFloat(activeMatch[1]) : 0; + const pending = pendingMatch ? parseFloat(pendingMatch[1]) : 0; + const total = totalMatch ? parseFloat(totalMatch[1]) : 40; + + console.log(`[HikariCP] active=${active}/${total} pending=${pending} usage=${(active/total*100).toFixed(1)}%`); +} diff --git a/modules/jpa/src/main/java/com/loopers/domain/ranking/ScoreFormula.java b/modules/jpa/src/main/java/com/loopers/domain/ranking/ScoreFormula.java new file mode 100644 index 0000000000..ecb98f6dfb --- /dev/null +++ b/modules/jpa/src/main/java/com/loopers/domain/ranking/ScoreFormula.java @@ -0,0 +1,42 @@ +package com.loopers.domain.ranking; + +/** + * 랭킹 Score 공식. + * + *

수식 (v2 — 0~1 정규화): + * {@code categoryPriority + W(view)×log₁₀(viewCount+1)/MAX_LOG + * + W(like)×log₁₀(likeCount+1)/MAX_LOG + * + W(order)×log₁₀(salesAmount+1)/MAX_LOG + * + lastEventEpochSeconds × TIEBREAKER_SCALE}

+ */ +public final class ScoreFormula { + + /** + * MAX_LOG = 7 → log₁₀(10,000,000). + * 쿠팡급 인기 상품의 일일 최대 메트릭(조회 수백만, 매출 수천만)을 0~1로 정규화. + */ + public static final double MAX_LOG = 7.0; + + /** + * Tiebreaker: lastEventEpochSeconds × 1e-16. + * epoch seconds ≈ 1.7×10⁹ → tiebreaker ≈ 1.7×10⁻⁷. + * 주 score 최소 차이(0.1×log₁₀(2)/7 ≈ 0.0043)보다 충분히 작아 역전 불가. + */ + public static final double TIEBREAKER_SCALE = 1e-16; + + private ScoreFormula() {} + + public static double calculate( + long viewCount, long netLikeCount, long netSalesAmount, + int categoryPriority, long lastEventEpochSeconds, + Weights w + ) { + return categoryPriority + + w.view() * Math.log10(Math.max(0, viewCount) + 1) / MAX_LOG + + w.like() * Math.log10(Math.max(0, netLikeCount) + 1) / MAX_LOG + + w.order() * Math.log10(Math.max(0, netSalesAmount) + 1) / MAX_LOG + + lastEventEpochSeconds * TIEBREAKER_SCALE; + } + + public record Weights(double view, double like, double order) {} +} diff --git a/modules/jpa/src/test/java/com/loopers/domain/ranking/ScoreFormulaTest.java b/modules/jpa/src/test/java/com/loopers/domain/ranking/ScoreFormulaTest.java new file mode 100644 index 0000000000..8a3d82b7f3 --- /dev/null +++ b/modules/jpa/src/test/java/com/loopers/domain/ranking/ScoreFormulaTest.java @@ -0,0 +1,245 @@ +package com.loopers.domain.ranking; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +class ScoreFormulaTest { + + private static final ScoreFormula.Weights DEFAULT_WEIGHTS = new ScoreFormula.Weights(0.1, 0.2, 0.7); + private static final long FIXED_EPOCH = 1_712_700_000L; + + @Nested + @DisplayName("기본 score 계산") + class BasicScoreCalculation { + + @Test + @DisplayName("모든 메트릭이 0이면 주 score는 0.0 (tiebreaker만 남음)") + void allZeros_returnsOnlyTiebreaker() { + double score = ScoreFormula.calculate(0, 0, 0, 0, FIXED_EPOCH, DEFAULT_WEIGHTS); + + assertThat(score).isCloseTo(FIXED_EPOCH * ScoreFormula.TIEBREAKER_SCALE, within(1e-20)); + } + + @Test + @DisplayName("view만 있을 때 score ≈ 0.1 × log₁₀(viewCount+1) / 7") + void viewOnly() { + double score = ScoreFormula.calculate(99, 0, 0, 0, FIXED_EPOCH, DEFAULT_WEIGHTS); + + double expected = 0.1 * Math.log10(100) / 7 + FIXED_EPOCH * ScoreFormula.TIEBREAKER_SCALE; + assertThat(score).isCloseTo(expected, within(1e-15)); + } + + @Test + @DisplayName("like만 있을 때 score ≈ 0.2 × log₁₀(likeCount+1) / 7") + void likeOnly() { + double score = ScoreFormula.calculate(0, 99, 0, 0, FIXED_EPOCH, DEFAULT_WEIGHTS); + + double expected = 0.2 * Math.log10(100) / 7 + FIXED_EPOCH * ScoreFormula.TIEBREAKER_SCALE; + assertThat(score).isCloseTo(expected, within(1e-15)); + } + + @Test + @DisplayName("order만 있을 때 score ≈ 0.7 × log₁₀(salesAmount+1) / 7") + void orderOnly() { + double score = ScoreFormula.calculate(0, 0, 9999, 0, FIXED_EPOCH, DEFAULT_WEIGHTS); + + double expected = 0.7 * Math.log10(10000) / 7 + FIXED_EPOCH * ScoreFormula.TIEBREAKER_SCALE; + assertThat(score).isCloseTo(expected, within(1e-15)); + } + + @Test + @DisplayName("복합 score: 조회 100 + 좋아요 10 + 주문 50000원") + void compositeScore() { + double score = ScoreFormula.calculate(100, 10, 50000, 0, FIXED_EPOCH, DEFAULT_WEIGHTS); + + double expected = 0.1 * Math.log10(101) / 7 + + 0.2 * Math.log10(11) / 7 + + 0.7 * Math.log10(50001) / 7 + + FIXED_EPOCH * ScoreFormula.TIEBREAKER_SCALE; + assertThat(score).isCloseTo(expected, within(1e-15)); + } + } + + @Nested + @DisplayName("음수 메트릭 방어") + class NegativeMetricDefense { + + @Test + @DisplayName("음수 viewCount → 0으로 클램핑되어 주 score 기여 0.0") + void negativeViewCount_clampedToZero() { + double score = ScoreFormula.calculate(-5, 0, 0, 0, FIXED_EPOCH, DEFAULT_WEIGHTS); + + assertThat(score).isCloseTo(FIXED_EPOCH * ScoreFormula.TIEBREAKER_SCALE, within(1e-20)); + } + + @Test + @DisplayName("음수 likeCount → 0으로 클램핑") + void negativeLikeCount_clampedToZero() { + double score = ScoreFormula.calculate(0, -10, 0, 0, FIXED_EPOCH, DEFAULT_WEIGHTS); + + assertThat(score).isCloseTo(FIXED_EPOCH * ScoreFormula.TIEBREAKER_SCALE, within(1e-20)); + } + + @Test + @DisplayName("음수 salesAmount → 0으로 클램핑") + void negativeSalesAmount_clampedToZero() { + double score = ScoreFormula.calculate(0, 0, -50000, 0, FIXED_EPOCH, DEFAULT_WEIGHTS); + + assertThat(score).isCloseTo(FIXED_EPOCH * ScoreFormula.TIEBREAKER_SCALE, within(1e-20)); + } + + @Test + @DisplayName("모든 메트릭 음수 → score는 메트릭 0일 때와 동일") + void allNegative_equalToZeroMetrics() { + double score = ScoreFormula.calculate(-5, -10, -50000, 0, FIXED_EPOCH, DEFAULT_WEIGHTS); + double scoreZero = ScoreFormula.calculate(0, 0, 0, 0, FIXED_EPOCH, DEFAULT_WEIGHTS); + + assertThat(score).isEqualTo(scoreZero); + } + + @Test + @DisplayName("음수 메트릭이 양수 메트릭의 score를 침범하지 않음") + void negativeDoesNotAffectPositiveTerms() { + double scoreWithNeg = ScoreFormula.calculate(100, -5, 0, 0, FIXED_EPOCH, DEFAULT_WEIGHTS); + double scoreViewOnly = ScoreFormula.calculate(100, 0, 0, 0, FIXED_EPOCH, DEFAULT_WEIGHTS); + + assertThat(scoreWithNeg).isEqualTo(scoreViewOnly); + } + } + + @Nested + @DisplayName("타이브레이커 — lastEventAt × TIEBREAKER_SCALE") + class Tiebreaker { + + @Test + @DisplayName("동점 시 최근 활동 상품이 상위") + void sameMetrics_laterEvent_higherScore() { + long earlier = 1_712_700_000L; + long later = 1_712_700_100L; + + double scoreOld = ScoreFormula.calculate(1, 0, 0, 0, earlier, DEFAULT_WEIGHTS); + double scoreNew = ScoreFormula.calculate(1, 0, 0, 0, later, DEFAULT_WEIGHTS); + + assertThat(scoreNew).isGreaterThan(scoreOld); + } + + @Test + @DisplayName("주 score가 다르면 lastEventAt이 커도 역전 불가") + void differentMetrics_eventTimeCannotReverse() { + long muchLater = 9_999_999_999L; + double scoreHighMetric = ScoreFormula.calculate(2, 0, 0, 0, 0, DEFAULT_WEIGHTS); + double scoreLowMetric = ScoreFormula.calculate(1, 0, 0, 0, muchLater, DEFAULT_WEIGHTS); + + assertThat(scoreHighMetric).isGreaterThan(scoreLowMetric); + } + + @Test + @DisplayName("TIEBREAKER_SCALE이 주 score 최소 차이보다 충분히 작음") + void tiebreaker_doesNotExceedMinScoreDifference() { + double tiebreakerMax = 2_000_000_000L * ScoreFormula.TIEBREAKER_SCALE; + double minScoreDiff = 0.1 * Math.log10(2) / 7; + + assertThat(tiebreakerMax / minScoreDiff).isLessThan(0.05); + } + + @Test + @DisplayName("TIEBREAKER_SCALE 상수가 1e-16") + void scaleConstant() { + assertThat(ScoreFormula.TIEBREAKER_SCALE).isEqualTo(1e-16); + } + } + + @Nested + @DisplayName("카테고리 우선순위") + class CategoryPriority { + + @Test + @DisplayName("categoryPriority가 정수부에 인코딩되어 score를 지배") + void categoryPriority_dominatesScore() { + double scoreHighPriority = ScoreFormula.calculate(0, 0, 0, 2, FIXED_EPOCH, DEFAULT_WEIGHTS); + double scoreLowPriority = ScoreFormula.calculate(9_999_999, 9_999_999, 9_999_999, 0, FIXED_EPOCH, DEFAULT_WEIGHTS); + + assertThat(scoreHighPriority).isGreaterThan(scoreLowPriority); + } + + @Test + @DisplayName("같은 categoryPriority 내에서는 메트릭으로 순위 결정") + void samePriority_metricsDetermineRank() { + double scoreLow = ScoreFormula.calculate(10, 5, 1000, 2, FIXED_EPOCH, DEFAULT_WEIGHTS); + double scoreHigh = ScoreFormula.calculate(100, 50, 100000, 2, FIXED_EPOCH, DEFAULT_WEIGHTS); + + assertThat(scoreHigh).isGreaterThan(scoreLow); + } + + @Test + @DisplayName("categoryPriority 0 (기본) → 정수부 간섭 없음") + void zeroPriority_noIntegerPartInterference() { + double score = ScoreFormula.calculate(0, 0, 0, 0, 0, DEFAULT_WEIGHTS); + + assertThat(score).isEqualTo(0.0); + } + + @Test + @DisplayName("categoryPriority가 정수부에 반영 — 차이가 정확히 1.0") + void categoryPriority_addsToScore() { + double scoreNoPriority = ScoreFormula.calculate(0, 0, 0, 0, FIXED_EPOCH, DEFAULT_WEIGHTS); + double scoreWithPriority = ScoreFormula.calculate(0, 0, 0, 1, FIXED_EPOCH, DEFAULT_WEIGHTS); + + assertThat(scoreWithPriority - scoreNoPriority).isCloseTo(1.0, within(1e-10)); + } + } + + @Nested + @DisplayName("정규화") + class Normalization { + + @Test + @DisplayName("0~1 정규화: MAX_LOG에서 각 항이 1.0 상한") + void normalizedScore_doesNotExceedOne() { + double score = ScoreFormula.calculate(9_999_999, 9_999_999, 9_999_999, 0, 0, DEFAULT_WEIGHTS); + + assertThat(score).isLessThanOrEqualTo(1.0 + 1e-10); + } + + @Test + @DisplayName("MAX_LOG 상수가 7.0") + void maxLogConstant() { + assertThat(ScoreFormula.MAX_LOG).isEqualTo(7.0); + } + + @Test + @DisplayName("log 감쇄: view 10배 차이(100 vs 1000)가 score에서 1.5배 미만 차이") + void logReducesScaleDifference() { + double score100 = ScoreFormula.calculate(100, 0, 0, 0, FIXED_EPOCH, DEFAULT_WEIGHTS); + double score1000 = ScoreFormula.calculate(1000, 0, 0, 0, FIXED_EPOCH, DEFAULT_WEIGHTS); + + double tiebreaker = FIXED_EPOCH * ScoreFormula.TIEBREAKER_SCALE; + double main100 = score100 - tiebreaker; + double main1000 = score1000 - tiebreaker; + + assertThat(main1000).isGreaterThan(main100); + assertThat(main1000 / main100).isLessThan(1.5); + } + } + + @Nested + @DisplayName("커스텀 가중치") + class CustomWeights { + + @Test + @DisplayName("가중치를 변경하면 score 비율이 달라짐") + void differentWeights_changePriority() { + ScoreFormula.Weights viewFirst = new ScoreFormula.Weights(0.7, 0.2, 0.1); + + double scoreView = ScoreFormula.calculate(100, 0, 0, 0, FIXED_EPOCH, viewFirst); + double scoreOrder = ScoreFormula.calculate(0, 0, 100, 0, FIXED_EPOCH, viewFirst); + + double tiebreaker = FIXED_EPOCH * ScoreFormula.TIEBREAKER_SCALE; + assertThat(scoreView - tiebreaker).isGreaterThan(scoreOrder - tiebreaker); + } + } +} diff --git a/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/MySqlTestContainersConfig.java b/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/MySqlTestContainersConfig.java index 9c41edacc5..c76d07e88a 100644 --- a/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/MySqlTestContainersConfig.java +++ b/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/MySqlTestContainersConfig.java @@ -18,7 +18,8 @@ public class MySqlTestContainersConfig { .withCommand( "--character-set-server=utf8mb4", "--collation-server=utf8mb4_general_ci", - "--skip-character-set-client-handshake" + "--skip-character-set-client-handshake", + "--innodb-buffer-pool-size=256M" ); mySqlContainer.start(); diff --git a/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java b/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java index a73842775c..cc41b3476e 100644 --- a/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java +++ b/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.kafka.KafkaProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; @@ -10,8 +11,11 @@ import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory; import org.springframework.kafka.core.*; import org.springframework.kafka.listener.ContainerProperties; +import org.springframework.kafka.listener.DeadLetterPublishingRecoverer; +import org.springframework.kafka.listener.DefaultErrorHandler; import org.springframework.kafka.support.converter.BatchMessagingMessageConverter; import org.springframework.kafka.support.converter.ByteArrayJsonMessageConverter; +import org.springframework.util.backoff.FixedBackOff; import java.util.HashMap; import java.util.Map; @@ -21,6 +25,14 @@ @EnableConfigurationProperties(KafkaProperties.class) public class KafkaConfig { public static final String BATCH_LISTENER = "BATCH_LISTENER_DEFAULT"; + public static final String SINGLE_LISTENER = "SINGLE_LISTENER_DEFAULT"; + + @Value("${HOSTNAME:local}") + private String hostname; + + public static final int MAX_POLL_RECORDS = 1; + public static final int SINGLE_SESSION_TIMEOUT_MS = 60 * 1000; + public static final int SINGLE_MAX_POLL_INTERVAL_MS = 3 * 60 * 1000; public static final int MAX_POLLING_SIZE = 3000; // read 3000 msg public static final int FETCH_MIN_BYTES = (1024 * 1024); // 1mb @@ -51,6 +63,12 @@ public ByteArrayJsonMessageConverter jsonMessageConverter(ObjectMapper objectMap return new ByteArrayJsonMessageConverter(objectMapper); } + @Bean + public DefaultErrorHandler kafkaErrorHandler(KafkaTemplate kafkaTemplate) { + DeadLetterPublishingRecoverer recoverer = new DeadLetterPublishingRecoverer(kafkaTemplate); + return new DefaultErrorHandler(recoverer, new FixedBackOff(1000L, 3)); + } + @Bean(name = BATCH_LISTENER) public ConcurrentKafkaListenerContainerFactory defaultBatchListenerContainerFactory( KafkaProperties kafkaProperties, @@ -63,6 +81,7 @@ public ConcurrentKafkaListenerContainerFactory defaultBatchListe consumerConfig.put(ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG, SESSION_TIMEOUT_MS); consumerConfig.put(ConsumerConfig.HEARTBEAT_INTERVAL_MS_CONFIG, HEARTBEAT_INTERVAL_MS); consumerConfig.put(ConsumerConfig.MAX_POLL_INTERVAL_MS_CONFIG, MAX_POLL_INTERVAL_MS); + consumerConfig.put(ConsumerConfig.GROUP_INSTANCE_ID_CONFIG, hostname + "-batch"); ConcurrentKafkaListenerContainerFactory factory = new ConcurrentKafkaListenerContainerFactory<>(); factory.setConsumerFactory(new DefaultKafkaConsumerFactory<>(consumerConfig)); @@ -72,4 +91,27 @@ public ConcurrentKafkaListenerContainerFactory defaultBatchListe factory.setBatchListener(true); return factory; } + + @Bean(name = SINGLE_LISTENER) + public ConcurrentKafkaListenerContainerFactory singleListenerContainerFactory( + KafkaProperties kafkaProperties, + ByteArrayJsonMessageConverter converter, + DefaultErrorHandler kafkaErrorHandler + ) { + Map consumerConfig = new HashMap<>(kafkaProperties.buildConsumerProperties()); + consumerConfig.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, MAX_POLL_RECORDS); + consumerConfig.put(ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG, SINGLE_SESSION_TIMEOUT_MS); + consumerConfig.put(ConsumerConfig.HEARTBEAT_INTERVAL_MS_CONFIG, SINGLE_SESSION_TIMEOUT_MS / 3); + consumerConfig.put(ConsumerConfig.MAX_POLL_INTERVAL_MS_CONFIG, SINGLE_MAX_POLL_INTERVAL_MS); + consumerConfig.put(ConsumerConfig.GROUP_INSTANCE_ID_CONFIG, hostname + "-single"); + + ConcurrentKafkaListenerContainerFactory factory = new ConcurrentKafkaListenerContainerFactory<>(); + factory.setConsumerFactory(new DefaultKafkaConsumerFactory<>(consumerConfig)); + factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL); + factory.setRecordMessageConverter(converter); + factory.setConcurrency(1); + factory.setBatchListener(false); + factory.setCommonErrorHandler(kafkaErrorHandler); + return factory; + } } diff --git a/modules/kafka/src/main/resources/kafka.yml b/modules/kafka/src/main/resources/kafka.yml index 9609dbf854..d45d780e9d 100644 --- a/modules/kafka/src/main/resources/kafka.yml +++ b/modules/kafka/src/main/resources/kafka.yml @@ -9,18 +9,26 @@ spring: auto: create.topics.enable: false register.schemas: false - offset.reset: latest use.latest.version: true producer: key-serializer: org.apache.kafka.common.serialization.StringSerializer value-serializer: org.springframework.kafka.support.serializer.JsonSerializer - retries: 3 + acks: all + properties: + enable.idempotence: true + delivery.timeout.ms: 120000 + linger.ms: 50 + batch.size: 32768 + compression.type: lz4 consumer: group-id: loopers-default-consumer key-deserializer: org.apache.kafka.common.serialization.StringDeserializer - value-serializer: org.apache.kafka.common.serialization.ByteArrayDeserializer + value-deserializer: org.apache.kafka.common.serialization.ByteArrayDeserializer + auto-offset-reset: earliest properties: enable-auto-commit: false + isolation.level: read_committed + partition.assignment.strategy: org.apache.kafka.clients.consumer.CooperativeStickyAssignor listener: ack-mode: manual diff --git a/modules/redis/src/main/java/com/loopers/config/redis/RedisConfig.java b/modules/redis/src/main/java/com/loopers/config/redis/RedisConfig.java index 0a2b614ca8..a4ff8e0761 100644 --- a/modules/redis/src/main/java/com/loopers/config/redis/RedisConfig.java +++ b/modules/redis/src/main/java/com/loopers/config/redis/RedisConfig.java @@ -13,6 +13,7 @@ import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.serializer.StringRedisSerializer; +import java.time.Duration; import java.util.List; import java.util.function.Consumer; @@ -75,7 +76,8 @@ private LettuceConnectionFactory lettuceConnectionFactory( List replicas, Consumer customizer ){ - LettuceClientConfiguration.LettuceClientConfigurationBuilder builder = LettuceClientConfiguration.builder(); + LettuceClientConfiguration.LettuceClientConfigurationBuilder builder = LettuceClientConfiguration.builder() + .commandTimeout(Duration.ofMillis(500)); if(customizer != null) customizer.accept(builder); LettuceClientConfiguration clientConfig = builder.build(); RedisStaticMasterReplicaConfiguration masterReplicaConfig = new RedisStaticMasterReplicaConfiguration(master.host(), master.port()); diff --git a/plans/week1.md b/plans/week1.md new file mode 100644 index 0000000000..49cf0c8ccf --- /dev/null +++ b/plans/week1.md @@ -0,0 +1,448 @@ +# Week 1 - 회원 기능 + +## 요구사항 + +### 1. 회원가입 + +| 항목 | 내용 | +|------|------| +| 필요 정보 | 로그인 ID, 비밀번호, 이름, 생년월일, 이메일 | +| 로그인 ID | 영문 + 숫자만 허용, 중복 불가 | +| 포맷 검증 | 이름, 이메일, 생년월일 | +| 비밀번호 규칙 | 8~16자, 영문 대소문자 + 숫자 + 특수문자만 허용 | +| 비밀번호 제약 | 생년월일 포함 불가 | +| 저장 방식 | 비밀번호 암호화 저장 | + +### 2. 내 정보 조회 + +| 항목 | 내용 | +|------|------| +| 인증 방식 | HTTP 헤더 (`X-Loopers-LoginId`, `X-Loopers-LoginPw`) | +| 반환 정보 | 로그인 ID, 이름, 생년월일, 이메일 | +| 이름 마스킹 | 마지막 글자를 `*`로 마스킹 (예: 홍길동 → 홍길*) | + +### 3. 비밀번호 수정 + +| 항목 | 내용 | +|------|------| +| 인증 방식 | HTTP 헤더 (`X-Loopers-LoginId`, `X-Loopers-LoginPw`) | +| 필요 정보 | 기존 비밀번호, 새 비밀번호 | +| 비밀번호 규칙 | 8~16자, 영문 대소문자 + 숫자 + 특수문자만 허용 | +| 비밀번호 제약 | 생년월일 포함 불가, 현재 비밀번호 사용 불가 | + +--- + +## 기술 결정 사항 + +| 항목 | 결정 | 근거 | +|------|------|------| +| 비밀번호 암호화 | `spring-security-crypto` | 전체 Spring Security는 과한 의존성, crypto만 사용 | +| 인증 처리 | `HandlerMethodArgumentResolver` | 대중적, 확장성 좋음, 컨트롤러 코드 깔끔 | +| 엔티티 네이밍 | `MemberModel` | 기존 프로젝트 패턴(`ExampleModel`) 유지 | +| DTO | Record 사용 | Java 21 기본 기능, boilerplate 감소 | +| 검증 | `@Valid` 기본 어노테이션 + 서비스 레벨 검증 | 오버엔지니어링 방지 | + +--- + +## 구현 계획 (소스 레벨) + +### Phase 1: 공통 기반 구축 + +#### 1-1. 의존성 추가 (`apps/commerce-api/build.gradle.kts`) + +```kotlin +// 비밀번호 암호화 (spring-security-crypto만 사용) +implementation("org.springframework.security:spring-security-crypto") +``` + +#### 1-2. MemberModel 엔티티 + +**파일**: `domain/member/MemberModel.java` + +```java +@Entity +@Table(name = "member") +public class MemberModel extends BaseEntity { + + @Column(nullable = false, unique = true, length = 20) + private String loginId; + + @Column(nullable = false) + private String password; + + @Column(nullable = false, length = 50) + private String name; + + @Column(nullable = false) + private LocalDate birthDate; + + @Column(nullable = false, length = 100) + private String email; + + // 생성자, getter, 비밀번호 변경 메서드 +} +``` + +**설계 근거**: +- `loginId`: unique 제약, 영문+숫자만 허용 +- `password`: BCrypt 해시값 저장 (길이 제한 없음) +- `birthDate`: `LocalDate` 타입으로 날짜만 저장 +- `BaseEntity` 상속으로 id, createdAt, updatedAt, deletedAt 자동 관리 + +#### 1-3. MemberRepository + +**파일**: `domain/member/MemberRepository.java` + +```java +public interface MemberRepository { + MemberModel save(MemberModel member); + Optional findByLoginId(String loginId); + boolean existsByLoginId(String loginId); +} +``` + +#### 1-4. MemberRepositoryImpl + +**파일**: `infrastructure/member/MemberRepositoryImpl.java` + +```java +@RequiredArgsConstructor +@Component +public class MemberRepositoryImpl implements MemberRepository { + private final MemberJpaRepository memberJpaRepository; + + // 구현 +} +``` + +#### 1-5. MemberJpaRepository + +**파일**: `infrastructure/member/MemberJpaRepository.java` + +```java +public interface MemberJpaRepository extends JpaRepository { + Optional findByLoginId(String loginId); + boolean existsByLoginId(String loginId); +} +``` + +#### 1-6. PasswordEncoder 설정 + +**파일**: `support/auth/PasswordEncoderConfig.java` + +```java +@Configuration +public class PasswordEncoderConfig { + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} +``` + +**설계 근거**: +- `spring-security-crypto`의 `PasswordEncoder` 인터페이스 사용 +- BCrypt 알고리즘 (업계 표준) + +#### 1-7. PasswordValidator + +**파일**: `domain/member/PasswordValidator.java` + +```java +@Component +public class PasswordValidator { + + // 8~16자, 영문 대소문자 + 숫자 + 특수문자만 허용 + private static final String PASSWORD_PATTERN = "^[A-Za-z0-9!@#$%^&*()_+=-]{8,16}$"; + + public void validate(String password, LocalDate birthDate) { + // 1. 길이 및 문자 규칙 검증 + // 2. 생년월일 포함 여부 검증 (yyyyMMdd, yyMMdd 등) + } +} +``` + +**검증 항목**: +- 길이: 8~16자 +- 허용 문자: 영문 대소문자, 숫자, 특수문자 +- 생년월일 포함 불가: `19900101`, `900101` 등 패턴 체크 + +#### 1-8. 인증 컴포넌트 (HandlerMethodArgumentResolver) + +**파일**: `support/auth/AuthMember.java` (어노테이션) + +```java +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface AuthMember { +} +``` + +**파일**: `support/auth/AuthMemberResolver.java` + +```java +@RequiredArgsConstructor +@Component +public class AuthMemberResolver implements HandlerMethodArgumentResolver { + + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(AuthMember.class); + } + + @Override + public Object resolveArgument(...) { + String loginId = request.getHeader("X-Loopers-LoginId"); + String password = request.getHeader("X-Loopers-LoginPw"); + + // 1. 헤더 존재 여부 검증 + // 2. 회원 조회 + // 3. 비밀번호 일치 검증 + // 4. MemberModel 반환 + } +} +``` + +**파일**: `support/auth/WebMvcConfig.java` + +```java +@RequiredArgsConstructor +@Configuration +public class WebMvcConfig implements WebMvcConfigurer { + + private final AuthMemberResolver authMemberResolver; + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(authMemberResolver); + } +} +``` + +--- + +### Phase 2: 회원가입 기능 + +#### 2-1. API 설계 + +| 항목 | 내용 | +|------|------| +| Method | `POST` | +| Path | `/api/v1/members` | +| Request | `{ loginId, password, name, birthDate, email }` | +| Response | `201 Created` + `{ id, loginId, name, email }` | + +#### 2-2. DTO + +**파일**: `interfaces/api/member/MemberV1Dto.java` + +```java +public class MemberV1Dto { + + public record SignUpRequest( + @NotBlank @Pattern(regexp = "^[A-Za-z0-9]+$") String loginId, + @NotBlank @Size(min = 8, max = 16) String password, + @NotBlank String name, + @NotNull @Past LocalDate birthDate, + @NotBlank @Email String email + ) {} + + public record SignUpResponse( + Long id, + String loginId, + String name, + String email + ) { + public static SignUpResponse from(MemberInfo info) { ... } + } +} +``` + +#### 2-3. 계층별 구현 + +**Controller** → **Facade** → **Service** → **Repository** + +``` +MemberV1Controller.signUp(SignUpRequest) + ↓ +MemberFacade.signUp(SignUpCommand) + ↓ +MemberService.register(SignUpCommand) + - 로그인 ID 중복 검증 + - 비밀번호 검증 (PasswordValidator) + - 비밀번호 암호화 + - 저장 + ↓ +MemberRepository.save(MemberModel) +``` + +#### 2-4. TDD 사이클 + +| 사이클 | 테스트 케이스 | 구현 내용 | +|--------|-------------|----------| +| 1 | 정상 가입 시 201 응답 | 기본 API 흐름 구현 | +| 2 | 로그인 ID 중복 시 409 응답 | 중복 검증 로직 추가 | +| 3 | 로그인 ID 포맷 오류 시 400 응답 | @Pattern 검증 | +| 4 | 비밀번호 규칙 위반 시 400 응답 | PasswordValidator 연동 | +| 5 | 비밀번호에 생년월일 포함 시 400 응답 | 생년월일 검증 로직 | +| 6 | 이메일 포맷 오류 시 400 응답 | @Email 검증 | + +--- + +### Phase 3: 내 정보 조회 기능 + +#### 3-1. API 설계 + +| 항목 | 내용 | +|------|------| +| Method | `GET` | +| Path | `/api/v1/members/me` | +| Headers | `X-Loopers-LoginId`, `X-Loopers-LoginPw` | +| Response | `200 OK` + `{ loginId, name, birthDate, email }` | + +#### 3-2. DTO + +```java +public record MyInfoResponse( + String loginId, + String name, // 마스킹 적용 + LocalDate birthDate, + String email +) { + public static MyInfoResponse from(MemberModel member) { + return new MyInfoResponse( + member.getLoginId(), + maskName(member.getName()), + member.getBirthDate(), + member.getEmail() + ); + } + + private static String maskName(String name) { + if (name == null || name.length() < 2) return name; + return name.substring(0, name.length() - 1) + "*"; + } +} +``` + +#### 3-3. 계층별 구현 + +``` +MemberV1Controller.getMyInfo(@AuthMember MemberModel member) + ↓ +MyInfoResponse.from(member) // 직접 변환 (Facade 생략 가능) +``` + +**설계 근거**: 단순 조회이므로 Facade 없이 Controller에서 직접 DTO 변환 + +#### 3-4. TDD 사이클 + +| 사이클 | 테스트 케이스 | 구현 내용 | +|--------|-------------|----------| +| 1 | 정상 조회 시 200 응답 | 기본 API 흐름 | +| 2 | 이름 마스킹 검증 | maskName 로직 | +| 3 | 인증 헤더 없음 시 401 응답 | AuthMemberResolver 예외 처리 | +| 4 | 잘못된 비밀번호 시 401 응답 | 비밀번호 검증 | + +--- + +### Phase 4: 비밀번호 수정 기능 + +#### 4-1. API 설계 + +| 항목 | 내용 | +|------|------| +| Method | `PATCH` | +| Path | `/api/v1/members/me/password` | +| Headers | `X-Loopers-LoginId`, `X-Loopers-LoginPw` | +| Request | `{ currentPassword, newPassword }` | +| Response | `200 OK` | + +#### 4-2. DTO + +```java +public record ChangePasswordRequest( + @NotBlank String currentPassword, + @NotBlank @Size(min = 8, max = 16) String newPassword +) {} +``` + +#### 4-3. 계층별 구현 + +``` +MemberV1Controller.changePassword(@AuthMember MemberModel member, ChangePasswordRequest) + ↓ +MemberFacade.changePassword(member, ChangePasswordCommand) + ↓ +MemberService.changePassword(member, currentPassword, newPassword) + - 현재 비밀번호 일치 검증 + - 새 비밀번호 규칙 검증 + - 새 비밀번호 ≠ 현재 비밀번호 검증 + - 비밀번호 암호화 후 업데이트 +``` + +#### 4-4. TDD 사이클 + +| 사이클 | 테스트 케이스 | 구현 내용 | +|--------|-------------|----------| +| 1 | 정상 수정 시 200 응답 | 기본 API 흐름 | +| 2 | 현재 비밀번호 불일치 시 400 응답 | 비밀번호 검증 | +| 3 | 새 비밀번호 규칙 위반 시 400 응답 | PasswordValidator | +| 4 | 새 비밀번호 = 현재 비밀번호 시 400 응답 | 동일 비밀번호 검증 | +| 5 | 새 비밀번호에 생년월일 포함 시 400 응답 | 생년월일 검증 | + +--- + +## 브랜치 전략 + +``` +main + └── week1 + ├── feature/sign-up (Phase 1 + Phase 2) + ├── feature/my-info (Phase 3) + └── feature/change-password (Phase 4) +``` + +--- + +## 패키지 구조 (최종) + +``` +com.loopers +├── application/member/ +│ ├── MemberFacade.java +│ └── MemberInfo.java +├── domain/member/ +│ ├── MemberModel.java +│ ├── MemberService.java +│ ├── MemberRepository.java +│ └── PasswordValidator.java +├── infrastructure/member/ +│ ├── MemberJpaRepository.java +│ └── MemberRepositoryImpl.java +├── interfaces/api/member/ +│ ├── MemberV1Controller.java +│ ├── MemberV1ApiSpec.java +│ └── MemberV1Dto.java +└── support/ + └── auth/ + ├── AuthMember.java + ├── AuthMemberResolver.java + ├── PasswordEncoderConfig.java + └── WebMvcConfig.java +``` + +--- + +## ErrorType 추가 (필요시) + +```java +// 기존 ErrorType에 추가 +UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "Unauthorized", "인증이 필요합니다."), +DUPLICATE_LOGIN_ID(HttpStatus.CONFLICT, "Duplicate Login ID", "이미 존재하는 로그인 ID입니다."), +INVALID_PASSWORD(HttpStatus.BAD_REQUEST, "Invalid Password", "비밀번호 규칙에 맞지 않습니다."), +PASSWORD_MISMATCH(HttpStatus.BAD_REQUEST, "Password Mismatch", "비밀번호가 일치하지 않습니다."), +``` diff --git a/scripts/reset-bf-test.sh b/scripts/reset-bf-test.sh new file mode 100755 index 0000000000..31a2198bfc --- /dev/null +++ b/scripts/reset-bf-test.sh @@ -0,0 +1,63 @@ +#!/bin/bash +# ============================================================================= +# 블랙 프라이데이 부하 테스트 초기화 +# +# 용도: 매 테스트 실행 전 상태 초기화 (반복 실행 보장) +# 실행: ./scripts/reset-bf-test.sh +# 전제: Redis(6379), commerce-api(8080) 실행 중 +# +# 초기화 항목: +# 1. Redis 대기열 초기화 (queue:waiting:order 삭제) +# 2. Redis 토큰 전체 삭제 (queue:token:* 삭제) +# 3. 상품 재고 리셋 (API 호출 또는 DB 직접) +# ============================================================================= + +REDIS_HOST="${REDIS_HOST:-localhost}" +REDIS_PORT="${REDIS_PORT:-6379}" +BASE_URL="${BASE_URL:-http://localhost:8080}" +ADMIN_HEADER="X-Loopers-Ldap: loopers.admin" + +echo "=== BF 테스트 초기화 ===" +echo "" + +# --- 1. Redis 대기열 초기화 --- +echo "[1/3] Redis 대기열 초기화" +QUEUE_SIZE=$(redis-cli -h $REDIS_HOST -p $REDIS_PORT ZCARD queue:waiting:order 2>/dev/null) +echo " 현재 대기열 크기: ${QUEUE_SIZE:-0}" +redis-cli -h $REDIS_HOST -p $REDIS_PORT DEL queue:waiting:order > /dev/null 2>&1 +echo " queue:waiting:order 삭제 완료" + +# --- 2. Redis 토큰 삭제 --- +echo "" +echo "[2/3] Redis 입장 토큰 초기화" +TOKEN_COUNT=$(redis-cli -h $REDIS_HOST -p $REDIS_PORT --scan --pattern "queue:token:*" 2>/dev/null | wc -l | tr -d ' ') +echo " 현재 토큰 수: ${TOKEN_COUNT:-0}" + +# SCAN 기반 삭제 (KEYS * 사용 안 함 — 운영 안전) +redis-cli -h $REDIS_HOST -p $REDIS_PORT --scan --pattern "queue:token:*" 2>/dev/null | while read key; do + redis-cli -h $REDIS_HOST -p $REDIS_PORT DEL "$key" > /dev/null 2>&1 +done +echo " queue:token:* 삭제 완료" + +# --- 3. 상품 재고 확인 --- +echo "" +echo "[3/3] 상품 재고 확인" +for i in $(seq 1 5); do + STOCK_RES=$(curl -s "$BASE_URL/api/v1/products/${i}" \ + -H "X-Loopers-LoginId: bf0001" \ + -H "X-Loopers-LoginPw: Password1!") + STOCK=$(echo "$STOCK_RES" | python3 -c "import sys,json; print(json.load(sys.stdin).get('data',{}).get('stockQuantity','?'))" 2>/dev/null) + echo " product:${i} 재고: ${STOCK:-확인 실패}" +done + +echo "" + +# --- 검증 --- +echo "[검증] Redis 상태 확인" +FINAL_QUEUE=$(redis-cli -h $REDIS_HOST -p $REDIS_PORT ZCARD queue:waiting:order 2>/dev/null) +FINAL_TOKENS=$(redis-cli -h $REDIS_HOST -p $REDIS_PORT --scan --pattern "queue:token:*" 2>/dev/null | wc -l | tr -d ' ') +echo " 대기열: ${FINAL_QUEUE:-0}명" +echo " 토큰: ${FINAL_TOKENS:-0}개" + +echo "" +echo "=== 초기화 완료 — 테스트 실행 가능 ===" diff --git a/scripts/seed-bf-test-data.sh b/scripts/seed-bf-test-data.sh new file mode 100755 index 0000000000..d8647c2f9f --- /dev/null +++ b/scripts/seed-bf-test-data.sh @@ -0,0 +1,72 @@ +#!/bin/bash +# ============================================================================= +# 블랙 프라이데이 부하 테스트용 데이터 시드 +# +# 용도: BF 시나리오 테스트를 위한 유저 생성 +# 실행: ./scripts/seed-bf-test-data.sh [유저수] +# 전제: commerce-api가 localhost:8080에서 실행 중, seed-test-data.sh 먼저 실행 +# +# 생성 데이터: +# - 회원 N명 (bf0001~bfNNNN, 비밀번호: Password1!) 기본값 5000명 +# - 멱등: 이미 존재하면 skip +# +# 데이터 위치: +# - DB: member 테이블 (loginId: bf0001~bfNNNN) +# - 상품/브랜드: seed-test-data.sh에서 생성한 것 재사용 +# ============================================================================= + +BASE_URL="${BASE_URL:-http://localhost:8080}" +TOTAL_USERS="${1:-5000}" + +echo "=== 블랙 프라이데이 테스트 데이터 시드 ===" +echo "대상: $BASE_URL" +echo "유저 수: $TOTAL_USERS" +echo "" + +echo "[1/1] 회원 생성 (bf0001~bf$(printf '%04d' $TOTAL_USERS))" + +SUCCESS=0 +FAIL=0 +SKIP=0 +for i in $(seq 1 $TOTAL_USERS); do + PADDED=$(printf "%04d" $i) + YEAR=$((1970 + (i % 30))) + MONTH=$(printf "%02d" $(( (i % 12) + 1 ))) + DAY=$(printf "%02d" $(( (i % 28) + 1 ))) + + RESULT=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE_URL/api/v1/members" \ + -H "Content-Type: application/json" \ + -d "{\"loginId\":\"bf${PADDED}\",\"password\":\"Password1!\",\"name\":\"블프유저${PADDED}\",\"birthDate\":\"${YEAR}-${MONTH}-${DAY}\",\"email\":\"bf${PADDED}@test.com\"}") + + if [ "$RESULT" = "200" ] || [ "$RESULT" = "201" ]; then + SUCCESS=$((SUCCESS + 1)) + elif [ "$RESULT" = "409" ]; then + SKIP=$((SKIP + 1)) + else + FAIL=$((FAIL + 1)) + fi + + # 진행률 표시 (500명 단위) + if [ $((i % 500)) -eq 0 ]; then + echo " ${i}/${TOTAL_USERS} 완료 (성공: ${SUCCESS}, 스킵: ${SKIP}, 실패: ${FAIL})" + fi +done + +echo "" +echo " 최종 결과 — 성공: ${SUCCESS}, 스킵(이미 존재): ${SKIP}, 실패: ${FAIL}" +echo "" + +# --- 검증 --- +LAST_ID=$(printf "bf%04d" $TOTAL_USERS) +echo "[검증] 인증 확인" +for id in bf0001 bf0500 $LAST_ID; do + AUTH_RESULT=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL/api/v1/members/me" \ + -H "X-Loopers-LoginId: ${id}" \ + -H "X-Loopers-LoginPw: Password1!") + echo " ${id} 인증: HTTP $AUTH_RESULT" +done + +echo "" +echo "=== 시드 완료 ===" +echo "테스트 계정: bf0001~bf$(printf '%04d' $TOTAL_USERS) / Password1!" +echo "상품: seed-test-data.sh에서 생성한 상품 1~5 사용 (재고 10,000)" diff --git a/scripts/seed-load-test-data.sh b/scripts/seed-load-test-data.sh new file mode 100755 index 0000000000..188cb6182d --- /dev/null +++ b/scripts/seed-load-test-data.sh @@ -0,0 +1,51 @@ +#!/bin/bash +# ============================================================================= +# 부하 테스트용 대량 데이터 시드 +# +# 용도: 현실적인 부하 테스트를 위한 유저 100명 생성 +# 실행: ./scripts/seed-load-test-data.sh +# 전제: commerce-api가 localhost:8080에서 실행 중, seed-test-data.sh 먼저 실행 +# ============================================================================= + +BASE_URL="${BASE_URL:-http://localhost:8080}" + +echo "=== 부하 테스트 데이터 시드 ===" +echo "대상: $BASE_URL" +echo "" + +# --- 회원 100명 생성 (user01~user99 + 기존 user1~user9) --- +echo "[1/1] 회원 생성 (loaduser001~loaduser100)" + +SUCCESS=0 +FAIL=0 +for i in $(seq 1 100); do + PADDED=$(printf "%03d" $i) + # birthDate 범위: 1970~2000 + YEAR=$((1970 + (i % 30))) + MONTH=$(printf "%02d" $(( (i % 12) + 1 ))) + DAY=$(printf "%02d" $(( (i % 28) + 1 ))) + + RESULT=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE_URL/api/v1/members" \ + -H "Content-Type: application/json" \ + -d "{\"loginId\":\"lu${PADDED}\",\"password\":\"Password1!\",\"name\":\"부하유저${PADDED}\",\"birthDate\":\"${YEAR}-${MONTH}-${DAY}\",\"email\":\"load${PADDED}@test.com\"}") + + if [ "$RESULT" = "200" ] || [ "$RESULT" = "201" ]; then + SUCCESS=$((SUCCESS + 1)) + else + FAIL=$((FAIL + 1)) + fi +done + +echo " 성공: ${SUCCESS}명, 실패: ${FAIL}명" +echo "" + +# --- 검증 --- +echo "[검증] 인증 확인" +AUTH_RESULT=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL/api/v1/members/me" \ + -H "X-Loopers-LoginId: lu001" \ + -H "X-Loopers-LoginPw: Password1!") +echo " lu001 인증: HTTP $AUTH_RESULT" + +echo "" +echo "=== 시드 완료 ===" +echo "테스트 계정: lu001~lu100 / Password1!" diff --git a/scripts/seed-test-data.sh b/scripts/seed-test-data.sh new file mode 100755 index 0000000000..3c3039a2fd --- /dev/null +++ b/scripts/seed-test-data.sh @@ -0,0 +1,108 @@ +#!/bin/bash +# ============================================================================= +# 테스트 데이터 시드 스크립트 +# +# 용도: 로컬 개발 및 부하 테스트용 데이터 생성 +# 실행: ./scripts/seed-test-data.sh +# 전제: commerce-api가 localhost:8080에서 실행 중이어야 함 +# +# 생성 데이터: +# - 회원 10명 (user1~user10, 비밀번호: Password1!) +# - 브랜드 2개 (나이키, 아디다스) +# - 상품 5개 (재고 10000개씩) +# ============================================================================= + +BASE_URL="${BASE_URL:-http://localhost:8080}" +ADMIN_HEADER="X-Loopers-Ldap: loopers.admin" + +echo "=== 테스트 데이터 시드 시작 ===" +echo "대상: $BASE_URL" +echo "" + +# --- 회원 생성 --- +echo "[1/3] 회원 생성 (user1~user10)" +for i in $(seq 1 10); do + RESULT=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE_URL/api/v1/members" \ + -H "Content-Type: application/json" \ + -d "{\"loginId\":\"user${i}\",\"password\":\"Password1!\",\"name\":\"테스트유저${i}\",\"birthDate\":\"199${i}-01-15\",\"email\":\"user${i}@test.com\"}") + if [ "$RESULT" = "200" ] || [ "$RESULT" = "201" ]; then + echo " user${i} 생성 완료" + else + echo " user${i} 생성 실패 (HTTP $RESULT) — 이미 존재하거나 오류" + fi +done + +echo "" + +# --- 브랜드 생성 --- +echo "[2/3] 브랜드 생성" +BRAND1=$(curl -s -X POST "$BASE_URL/api-admin/v1/brands" \ + -H "Content-Type: application/json" \ + -H "$ADMIN_HEADER" \ + -d '{"name":"나이키","description":"스포츠 브랜드"}') +BRAND1_ID=$(echo "$BRAND1" | python3 -c "import sys,json; print(json.load(sys.stdin).get('data',{}).get('id',''))" 2>/dev/null) +echo " 나이키 생성 완료 (id: ${BRAND1_ID:-실패})" + +BRAND2=$(curl -s -X POST "$BASE_URL/api-admin/v1/brands" \ + -H "Content-Type: application/json" \ + -H "$ADMIN_HEADER" \ + -d '{"name":"아디다스","description":"스포츠 브랜드"}') +BRAND2_ID=$(echo "$BRAND2" | python3 -c "import sys,json; print(json.load(sys.stdin).get('data',{}).get('id',''))" 2>/dev/null) +echo " 아디다스 생성 완료 (id: ${BRAND2_ID:-실패})" + +echo "" + +# --- 상품 생성 --- +echo "[3/3] 상품 생성 (재고 10000개)" + +# 브랜드 ID가 없으면 기본값 사용 +BRAND1_ID="${BRAND1_ID:-1}" +BRAND2_ID="${BRAND2_ID:-2}" + +PRODUCTS=( + "{\"brandId\":${BRAND1_ID},\"name\":\"에어맥스 90\",\"price\":129000,\"stockQuantity\":10000}" + "{\"brandId\":${BRAND1_ID},\"name\":\"에어포스 1\",\"price\":119000,\"stockQuantity\":10000}" + "{\"brandId\":${BRAND1_ID},\"name\":\"덩크 로우\",\"price\":139000,\"stockQuantity\":10000}" + "{\"brandId\":${BRAND2_ID},\"name\":\"울트라부스트\",\"price\":199000,\"stockQuantity\":10000}" + "{\"brandId\":${BRAND2_ID},\"name\":\"스탠스미스\",\"price\":99000,\"stockQuantity\":10000}" +) + +PRODUCT_NAMES=("에어맥스 90" "에어포스 1" "덩크 로우" "울트라부스트" "스탠스미스") + +for i in "${!PRODUCTS[@]}"; do + RESULT=$(curl -s -X POST "$BASE_URL/api-admin/v1/products" \ + -H "Content-Type: application/json" \ + -H "$ADMIN_HEADER" \ + -d "${PRODUCTS[$i]}") + PRODUCT_ID=$(echo "$RESULT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('data',{}).get('id',''))" 2>/dev/null) + echo " ${PRODUCT_NAMES[$i]} 생성 완료 (id: ${PRODUCT_ID:-실패})" +done + +echo "" + +# --- Redis 재고 초기화 확인 --- +echo "[검증] Redis 재고 확인" +for i in $(seq 1 5); do + STOCK=$(redis-cli -p 6379 GET "stock:${i}" 2>/dev/null) + echo " product:${i} Redis 재고: ${STOCK:-미설정}" +done + +echo "" + +# --- 검증 --- +echo "[검증] API 응답 확인" +echo -n " 회원 인증: " +AUTH_RESULT=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL/api/v1/members/me" \ + -H "X-Loopers-LoginId: user1" \ + -H "X-Loopers-LoginPw: Password1!") +echo "HTTP $AUTH_RESULT" + +echo -n " 상품 목록: " +PRODUCT_RESULT=$(curl -s "$BASE_URL/api/v1/products" | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'{len(d.get(\"data\",{}).get(\"products\",[]))}개')" 2>/dev/null) +echo "${PRODUCT_RESULT:-실패}" + +echo "" +echo "=== 시드 완료 ===" +echo "" +echo "테스트 계정: user1~user10 / Password1!" +echo "상품 ID: 1~5 (재고 10000개)"