Skip to content

wlaud2000/PayFlow

Repository files navigation

PayFlow

한정 수량 상품 선착순 동시 결제를 처리하는 고신뢰 결제 처리 시스템 재고 초과 판매 · 중복 결제 · PG사 장애를 방어하도록 설계됐습니다.

Java Spring Boot Kafka Redis Resilience4j


목차


프로젝트 소개

카드사는 제휴 가맹점과 함께 한정 수량 특가 캠페인을 정기적으로 운영한다. 캠페인 오픈 순간 수천 명의 동시 결제 요청이 몰리는 시나리오에서 세 가지 장애 유형을 방어하는 것이 이 시스템의 핵심 목표다.

장애 유형 발생 조건 방어 전략
재고 초과 판매 동시 요청이 같은 재고를 동시에 차감 Redis Lua Script (원자적 선차감) + 낙관적 락 (DB 최종 정합성)
중복 결제 승인 네트워크 순단 후 사용자 재시도 PaymentEvent 멱등성 키 (idempotency_key UNIQUE)
PG사 장애 전파 Toss API 응답 지연 → 스레드 풀 고갈 Resilience4j Circuit Breaker + Fallback FailureReason 저장

기술 스택

분류 기술 선택 이유
Language / Framework Java 21, Spring Boot 4.1.0
Database MySQL, Spring Data JPA 결제 데이터 영속성. @Version 낙관적 락으로 동시 차감 충돌 방어
Cache Redis + Lua Script 단일 스레드 원자성으로 재고 차감 충돌 0건. 분산 환경 수평 확장 용이
Message Broker Apache Kafka 결제 승인 후 재고·포인트를 비동기 이벤트로 분리. Saga 보상 트랜잭션으로 부분 실패 복구
Resilience Resilience4j CB + Retry + Fallback 통합. PG사 장애가 전체 서비스로 전파되지 않도록 격리
HTTP Client WebClient (Reactor) 논블로킹 비동기 HTTP + 타임아웃 설정. 외부 API 응답 지연이 스레드 풀을 잠식하지 않도록 방어
Observability MDC + Prometheus + Grafana traceId 기반 구조화 로깅으로 단일 요청 전체 추적. CB 상태 변화·Consumer Lag 실시간 대시보드
Test k6, @EmbeddedKafka, WireMock 피크 트래픽(200 VU) 시뮬레이션 + Kafka 전체 플로우 통합 테스트
Security Spring Security + JWT
Infra Docker Compose

시스템 아키텍처

flowchart LR
    C([클라이언트])

    subgraph app["PayFlow — Spring Boot"]
        direction TB
        API["REST API\n/payments/confirm"]
        PS["PaymentService\n@Transactional"]
        TPC["TossPaymentsClient\n@CircuitBreaker @Retry"]
        OUT["OutboxScheduler\n5초 주기 폴링"]
        SC["StockConsumer\nstock-service"]
        CC["CompensationConsumer\ncompensation-service"]
    end

    subgraph mq["Kafka Topics"]
        T1["payment-approved"]
        T2["payment-stock-decreased"]
        T3["payment-stock-failed"]
        DLQ["*.dlq\nDead Letter Queue"]
    end

    subgraph store["데이터 저장소"]
        DB[(MySQL)]
        RD[(Redis)]
    end

    EXT[TossPayments API]

    C -->|"① POST /payments/confirm"| API
    API --> PS
    PS -->|"② PG사 결제 승인"| TPC
    TPC <-->|HTTP| EXT
    PS -->|"③ payment.complete\n+ outbox INSERT\n단일 트랜잭션"| DB
    PS -->|"④ HTTP 200 즉시 반환"| C

    OUT -->|"⑤ PENDING 폴링"| DB
    OUT -->|"⑥ 발행"| T1

    SC -->|"⑦ 구독"| T1
    SC -->|"⑧ Lua Script\n원자적 재고 차감"| RD
    SC -->|"⑨ 성공"| T2
    SC -->|"⑨ 재고 부족"| T3

    CC -->|구독| T3
    CC -->|"보상: Toss 취소 API"| EXT
    CC -->|"Payment CANCELLED"| DB

    SC -.->|"3회 실패"| DLQ
    CC -.->|"3회 실패"| DLQ
Loading

ERD

erDiagram
    MEMBER {
        bigint id PK
        varchar email UK
        varchar password
        varchar name
        datetime created_at
        datetime updated_at
    }

    PRODUCT {
        bigint id PK
        varchar name
        bigint price
        int stock
        bigint version
        text campaign_description
        datetime created_at
        datetime updated_at
    }

    ORDERS {
        bigint id PK
        bigint member_id FK
        bigint product_id FK
        int quantity
        varchar status
        datetime created_at
        datetime updated_at
    }

    PAYMENT {
        bigint id PK
        bigint order_id FK
        bigint amount
        varchar status
        varchar pg_transaction_id
        varchar failure_reason
        varchar compensation_reason
        datetime created_at
        datetime updated_at
    }

    PAYMENT_EVENT {
        bigint id PK
        bigint payment_id FK
        varchar idempotency_key UK
        varchar event_type
        varchar status
        datetime created_at
        datetime updated_at
    }

    OUTBOX_EVENTS {
        varchar event_id PK
        varchar aggregate_id
        varchar event_type
        varchar topic
        varchar message_key
        text payload
        varchar status
        int retry_count
        datetime processed_at
        datetime created_at
        datetime updated_at
    }

    PROCESSED_EVENT {
        bigint id PK
        varchar event_id UK
        datetime processed_at
    }

    MEMBER ||--o{ ORDERS : "places"
    PRODUCT ||--o{ ORDERS : "ordered in"
    ORDERS ||--|| PAYMENT : "paid by"
    PAYMENT ||--o{ PAYMENT_EVENT : "logged as"
    PAYMENT ||--o{ OUTBOX_EVENTS : "publishes"
Loading

결제 상태 머신

Payment 상태

stateDiagram-v2
    [*] --> PENDING : Payment 생성\n(POST /payments/request)

    PENDING --> COMPLETED : Toss 승인 성공\n(pg_transaction_id 세팅)
    PENDING --> FAILED : Toss 거절 / 타임아웃\n(FailureReason 저장)
    PENDING --> CANCELLED : 사용자 취소 요청

    COMPLETED --> REFUNDED : 환불 요청\n(Toss 취소 API 호출)
    COMPLETED --> CANCELLED : Saga 보상 트랜잭션\n(재고 부족 / 포인트 실패)
Loading

Order 상태

stateDiagram-v2
    [*] --> PENDING : 주문 생성\n(POST /orders)

    PENDING --> PAYING : 결제 요청\n(POST /payments/request)
    PAYING --> PAID : Payment COMPLETED 연동
    PAYING --> CANCELLED : Payment FAILED / CANCELLED

    PAID --> REFUNDED : Payment REFUNDED 연동
    PAID --> CANCELLED : Saga 보상 트랜잭션
Loading

핵심 구현

1. 멱등성 키 — 중복 결제 승인 방지

문제

네트워크 순단 또는 사용자 재시도 시 동일 주문에 대해 결제 승인 요청이 두 번 들어올 수 있다. payment.complete()가 두 번 호출되면 PG사에 이중 승인이 발생한다.

선택 — PaymentEvent 테이블 + idempotency_key UNIQUE

결제 요청 시점(POST /payments/request)에 {orderId}:{eventType} 형식의 멱등성 키를 생성해 PAYMENT_EVENT 테이블에 저장한다. DB UNIQUE 제약이 중복 처리를 막는 최후 방어선이다.

POST /payments/request
  → Payment(PENDING) 생성
  → PaymentEvent(PENDING) 생성 — idempotency_key = "{orderId}:CONFIRM"

POST /payments/confirm (첫 번째 요청)
  → PaymentEvent 조회 → status: PENDING → 정상 처리 → COMPLETED

POST /payments/confirm (재시도)
  → PaymentEvent 조회 → status: COMPLETED → 즉시 반환 (이미 처리된 요청)

결과

동일 orderId에 대한 confirm 요청이 두 번 들어와도 두 번째 요청은 DB 조회 후 즉시 반환한다. PG사 호출 없이 중복을 차단해 이중 과금을 방지한다.


2. 동시성 제어 — Redis Lua Script + 낙관적 락 이중 레이어

문제

200명이 동시에 같은 상품의 재고를 차감하면 UPDATE product SET stock = stock - 1 WHERE id = ?가 동시에 실행되어 재고가 정확히 줄어들지 않는다.

선택 — Redis Lua Script (선차감) + JPA 낙관적 락 (DB 최종 정합성)

두 레이어가 보호하는 대상이 다르다. Redis는 속도, DB는 신뢰성이라는 각자의 강점을 살린다.

① Redis Lua Script — 원자적 선차감
   local stock = redis.call('GET', KEYS[1])
   if tonumber(stock) < tonumber(ARGV[1]) then return -1 end  -- 재고 부족
   return redis.call('DECRBY', KEYS[1], ARGV[1])              -- 원자적 차감
   → Redis 단일 스레드: 재고 조회 + 차감이 불가분하게 실행 → 충돌 없음

② JPA 낙관적 락 — DB 최종 정합성
   UPDATE product SET stock = ?, version = ? + 1
   WHERE id = ? AND version = ?  -- version 불일치 시 ObjectOptimisticLockingFailureException
   → @Transactional(propagation = REQUIRES_NEW) + 3회 지수 백오프 재시도
   → Redis 장애 시에도 DB가 최종 방어선

결과

낙관적 락만 (Before) Redis Lua + 낙관적 락 (After)
에러율 21.90% 0.20%
낙관적 락 재시도 다수 발생 0회 (Redis가 선차감)

Kafka 비동기 전환 이후: 재고 차감이 Consumer에서 비동기로 처리되어 결제 응답 경로에서 낙관적 락 재시도 오버헤드가 완전히 제거됨.


3. Circuit Breaker — PG사 장애 격리

문제

Toss Payments API가 응답하지 않으면 해당 스레드는 타임아웃(수초)까지 블로킹된다. 동시 요청 N건이면 N개 스레드가 모두 블로킹 → 스레드 풀 고갈 → 결제 외 API도 응답 불가. PG사 장애 하나가 전체 서비스 장애로 전파된다.

선택 — Resilience4j Circuit Breaker + Retry + Fallback

CLOSED (정상) ─── 실패율 50% 초과 ──→ OPEN (차단)
                                           │
                                      10초 경과
                                           ↓
                                      HALF_OPEN (테스트)
                                      ├── 3회 성공 → CLOSED
                                      └── 실패 → OPEN
설정 이유
failureRateThreshold 50% 절반 이상 실패 시 빠른 차단
minimumNumberOfCalls 5 소량 요청에서 CB가 과민 반응하지 않도록
waitDurationInOpenState 10s PG사 단기 장애 후 자동 복구 대기
permittedCallsInHalfOpen 3 복구 확인에 최소 샘플 보장

Fallback — FailureReason 분류 저장

CB 차단 / Timeout / Retry 소진 세 가지 장애 원인을 분류해 Payment에 저장한다. 운영자가 장애 로그 없이도 어떤 이유로 결제가 실패했는지 DB에서 조회할 수 있다.

private FailureReason classifyFailureReason(Throwable t) {
    if (t instanceof CallNotPermittedException) return FailureReason.CIRCUIT_OPEN;
    if (t instanceof TimeoutException)          return FailureReason.TIMEOUT;
    return FailureReason.RETRY_EXHAUSTED;
}

결과

Toss API 장애 시 CB가 OPEN 전환 → 이후 요청은 즉시 Fallback 반환 → 스레드 블로킹 없음. 10초 후 HALF_OPEN → 3회 성공 시 자동 복구.


4. MDC + 모니터링 — traceId 기반 장애 추적

문제

장애 발생 시 로그에 어떤 요청이 어디서 실패했는지 추적이 불가하다. 여러 요청이 동시에 처리되면 로그가 섞여 특정 요청의 전체 흐름을 파악하기 어렵다.

선택 — MDC traceId + Prometheus/Grafana

모든 HTTP 요청마다 UUID 앞 8자리를 traceId로 생성해 MDC에 저장한다. 해당 요청의 모든 로그에 traceId가 자동으로 포함되어 한 번의 grep으로 전체 흐름을 추적할 수 있다.

2025-01-15 10:23:45 [traceId=a1b2c3d4] INFO  PaymentService - 결제 승인 시작: paymentId=42
2025-01-15 10:23:45 [traceId=a1b2c3d4] WARN  TossPaymentsClient - Retry 발생: 1회차
2025-01-15 10:23:46 [traceId=a1b2c3d4] INFO  OutboxScheduler - 이벤트 발행: eventId=xxx
2025-01-15 10:23:51 [traceId=a1b2c3d4] INFO  StockConsumer - 재고 차감 완료: productId=1

Prometheus / Grafana 모니터링

대시보드 핵심 지표
결제 현황 결제 성공/실패 수, TPS
Circuit Breaker CB 상태 변화, OPEN 진입 횟수
Kafka Consumer Consumer Lag, 처리 속도

결제 대시보드 Circuit Breaker 대시보드


5. Kafka + Saga 보상 트랜잭션

Before — 동기 처리 구조의 문제

TossConfirmResponse tossResponse = tossPaymentsClient.confirm(...); // PG사 승인
payment.complete(tossResponse.paymentKey());                        // DB 저장
redisStockService.decreaseStock(productId, quantity);               // Redis 차감
productStockService.decreaseWithOptimisticLock(productId, quantity); // DB 차감 (재시도 포함)
return PaymentResponse.from(payment);  // ← (4)까지 완료 후 응답

문제 1 — 응답 지연: 사용자는 재고 처리가 끝날 때까지 기다려야 한다. 포인트 서비스가 추가되면 지연은 선형으로 증가한다.

문제 2 — 부분 실패 불일치: PG사 승인 후 DB 처리 실패 시 돈은 빠져나갔는데 주문은 PENDING 상태가 된다.

왜 단일 서비스에서 Kafka를 선택했는가

비교 항목 Spring Events Kafka
JVM 크래시 내성 없음 (메모리 소실) 있음 (브로커 디스크 영속화)
서비스 분리 용이성 낮음 (JVM 내부 통신) 높음 (Consumer만 이전)
이벤트 유실 방지 Outbox 추가 필요 기본 제공

결제 도메인에서 이벤트 유실은 이중 판매로 이어진다. Spring Events는 JVM 크래시 시 이벤트 유실을 막을 수 없다. Outbox를 추가하면 결국 Kafka 유사 구조가 되므로, 검증된 Kafka를 직접 사용하는 것이 낫다.

After — Outbox 패턴 + Choreography Saga

① confirmPayment() 트랜잭션
   payment.complete() + outbox_events INSERT  ← 단일 트랜잭션 (원자적)
   → HTTP 200 즉시 반환 (재고 처리 대기 없음)

② OutboxScheduler (5초 주기)
   PENDING 이벤트 → Kafka 발행 → PUBLISHED 마킹
   → JVM 크래시 후에도 재처리 가능 (at-least-once)

③ StockConsumer
   payment-approved 수신 → Redis Lua 재고 차감
   → 성공: payment-stock-decreased 발행
   → 재고 부족: payment-stock-failed 발행 (보상 트리거)

④ CompensationConsumer (재고 부족 보상)
   payment-stock-failed 수신
   → Toss 취소 API 호출 → Payment CANCELLED

멱등성 처리 — 중복 실행 방어

Kafka는 at-least-once 전달이다. Consumer 크래시 후 재시작 시 같은 이벤트를 두 번 처리할 수 있다. ProcessedEvent 테이블에 처리한 eventId를 저장해 중복 실행을 방어한다.

DLQ — 처리 실패 메시지 격리

Consumer가 3회 재시도 후에도 실패하면 전용 DLQ 토픽에 격리한다. DLQ 격리로 해당 메시지 처리에 실패해도 Consumer가 다음 메시지 처리를 계속할 수 있다.

payment-approved     → (3회 실패) → payment-stock-dlq
payment-stock-failed → (3회 실패) → payment-compensation-dlq

성능 비교 — @Async vs Outbox

방식 P95 TPS Kafka 전달 보장
@Async + @TransactionalEventListener 210ms 238 req/s 없음 (크래시 시 유실)
Outbox 패턴 (채택) 196ms 251 req/s at-least-once

Outbox가 @Async보다 성능과 신뢰성 모두 우위다. 이유: @Async는 thread pool 제출 오버헤드가 HTTP 요청 경로에 남는다. Outbox는 이미 진행 중인 DB 트랜잭션에 INSERT 한 건을 추가하는 것으로 끝난다.


성능 테스트 결과

테스트 환경

항목
도구 k6
시나리오 로그인 → 주문 생성 → 결제 요청 → 결제 확인
부하 패턴 warmup 50 VU (30s) → peak 200 VU (60s) → ramp-down (30s)
Toss API WireMock stub (즉시 200 응답)

Phase 1 — HikariCP 튜닝 + 낙관적 락 재시도 최적화

단계 HikariCP pool 재시도 TPS P95 에러율 payment confirmed
1차 10 (기본값) 없음 274 req/s 19.68ms 21.90% 34% ❌
2차 50 없음 271 req/s 33.88ms 22.15% 33% ❌
3차 50 3회 지수 백오프 274 req/s 57.4ms 0.20% 99% ✅

에러율 21.90% 원인: ObjectOptimisticLockingFailureException. pool 증가만으로는 해결 안 됨 → 재시도 로직 추가로 해결

Phase 2 — Kafka 비동기 전환 최적화

단계 P95 TPS 에러율 Kafka 전달 보장
동기 처리 (기준) 57.4ms 274 req/s 0.20%
Kafka 도입 (@Async 없음) 251.5ms 231 req/s 0.00% 없음
@Async 추가 210.6ms 238 req/s 0.00% 없음
Outbox 패턴 (최종) 196.1ms 251.8 req/s 0.00% at-least-once

P95 57ms → 251ms 악화 원인: afterCommit() 콜백이 HTTP 요청 스레드에서 동기 실행됨. @Async로 Kafka I/O 분리 → 40ms 개선. Outbox 전환 → 추가 14ms 개선 + 이벤트 유실 방지.

Grafana 대시보드

결제 현황 대시보드 Circuit Breaker 상태 대시보드


트러블슈팅

1. responseTimeout + Retry 미작동 이슈

현상

WebClient에 responseTimeout(Duration.ofSeconds(3))을 설정했는데, Toss API가 3초를 초과해도 Retry가 트리거되지 않고 즉시 Fallback으로 빠졌다.

원인

WebClient의 responseTimeout은 내부적으로 ReadTimeoutException을 발생시키고, Reactor Netty가 이것을 WebClientRequestException으로 래핑해서 던진다. Resilience4j Retry의 retryExceptions 조건이 TimeoutException.class를 체크하는데, 래핑된 예외(WebClientRequestException)는 TimeoutException과 매칭되지 않아 재시도 없이 CB 실패 카운트만 올라갔다.

대응

Retry의 retryOnException 조건을 WebClientRequestException의 cause가 ReadTimeoutException인 경우를 포함하도록 수정했다.

개선 방향

타임아웃을 WebClient가 아닌 Resilience4j TimeLimiter로 설정하면 Retry와 같은 레이어에서 예외를 던지므로 래핑 문제가 발생하지 않는다.


2. @TransactionalEventListener(AFTER_COMMIT) 선택 이유

배경

결제 승인 후 Kafka 이벤트를 발행하는 시점을 고민했다.

@EventListener를 사용하면 이벤트 발행 즉시 실행된다. 트랜잭션이 롤백되더라도 이벤트는 이미 발행된 상태가 된다. TossPayments 승인은 성공했지만 DB 저장이 실패해 롤백되는 경우, Kafka에는 이벤트가 있는데 DB에는 payment 레코드가 없는 불일치가 발생한다.

@TransactionalEventListener(AFTER_COMMIT)을 사용하면 트랜잭션 커밋 성공 후에만 실행된다. 롤백 시 이 메서드는 호출되지 않으므로 불일치를 방지할 수 있다.

최종 결정 — Outbox 패턴으로 대체

@TransactionalEventListener + @Async는 JVM 크래시 시 afterCommit() 콜백이 실행되지 않아 이벤트가 유실될 수 있다. 결제 도메인에서 이벤트 유실은 재고 미차감(이중 판매)으로 이어진다. 최종적으로 confirmPayment() 트랜잭션에 outbox_events INSERT를 포함시키는 Outbox 패턴으로 전환해 at-least-once 전달을 보장했다.


3. Kafka 발행 실패 시 이벤트 유실 — Known Limitation + Outbox 확장 방향

초기 구현의 한계

// [Known Limitation] Kafka 발행 실패 시 이벤트 유실 가능
// 결제 완료 → JVM 크래시 → afterCommit() 미실행 → 재고 미차감
log.error("[PaymentEventPublisher] 이벤트 발행 실패", ex);
// ← 로그만 남기고 재처리 수단 없음

Outbox 패턴으로 해결

payment.complete() + outbox_events INSERT를 동일 DB 트랜잭션으로 묶었다. 트랜잭션 커밋 성공 = Outbox 레코드 보장. JVM이 크래시하더라도 재시작 후 PENDING 레코드를 자동 재처리한다.

남은 한계 (현재 Known Limitation)

멀티 인스턴스 환경에서 OutboxScheduler가 여러 인스턴스에서 동시에 실행되면 같은 이벤트를 중복 발행할 수 있다. Consumer의 ProcessedEvent 멱등성으로 중복 처리는 방지되지만, 중복 발행 자체를 막으려면 ShedLock(DB 기반 분산 락)을 추가해야 한다.


실행 방법

# 1. 인프라 기동
docker-compose up -d

# 2. 애플리케이션 실행
./gradlew bootRun --args='--spring.profiles.active=local'

# 3. 부하 테스트 (k6 필요)
k6 run load-test/payment-flow.js

About

한정 수량 상품 선착순 동시 결제를 처리하는 고신뢰 결제 처리 시스템 (동시성 제어 · 멱등성 · Circuit Breaker)

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors