한정 수량 상품 선착순 동시 결제를 처리하는 고신뢰 결제 처리 시스템 재고 초과 판매 · 중복 결제 · PG사 장애를 방어하도록 설계됐습니다.
카드사는 제휴 가맹점과 함께 한정 수량 특가 캠페인을 정기적으로 운영한다. 캠페인 오픈 순간 수천 명의 동시 결제 요청이 몰리는 시나리오에서 세 가지 장애 유형을 방어하는 것이 이 시스템의 핵심 목표다.
| 장애 유형 | 발생 조건 | 방어 전략 |
|---|---|---|
| 재고 초과 판매 | 동시 요청이 같은 재고를 동시에 차감 | 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
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"
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(재고 부족 / 포인트 실패)
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 보상 트랜잭션
문제
네트워크 순단 또는 사용자 재시도 시 동일 주문에 대해 결제 승인 요청이 두 번 들어올 수 있다.
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사 호출 없이 중복을 차단해 이중 과금을 방지한다.
문제
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에서 비동기로 처리되어 결제 응답 경로에서 낙관적 락 재시도 오버헤드가 완전히 제거됨.
문제
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회 성공 시 자동 복구.
문제
장애 발생 시 로그에 어떤 요청이 어디서 실패했는지 추적이 불가하다. 여러 요청이 동시에 처리되면 로그가 섞여 특정 요청의 전체 흐름을 파악하기 어렵다.
선택 — 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, 처리 속도 |
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 응답) |
| 단계 | 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 증가만으로는 해결 안 됨 → 재시도 로직 추가로 해결
| 단계 | 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 개선 + 이벤트 유실 방지.
현상
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와 같은 레이어에서 예외를 던지므로 래핑 문제가 발생하지 않는다.
배경
결제 승인 후 Kafka 이벤트를 발행하는 시점을 고민했다.
@EventListener를 사용하면 이벤트 발행 즉시 실행된다.
트랜잭션이 롤백되더라도 이벤트는 이미 발행된 상태가 된다.
TossPayments 승인은 성공했지만 DB 저장이 실패해 롤백되는 경우,
Kafka에는 이벤트가 있는데 DB에는 payment 레코드가 없는 불일치가 발생한다.
@TransactionalEventListener(AFTER_COMMIT)을 사용하면 트랜잭션 커밋 성공 후에만 실행된다.
롤백 시 이 메서드는 호출되지 않으므로 불일치를 방지할 수 있다.
최종 결정 — Outbox 패턴으로 대체
@TransactionalEventListener + @Async는 JVM 크래시 시 afterCommit() 콜백이 실행되지 않아 이벤트가 유실될 수 있다. 결제 도메인에서 이벤트 유실은 재고 미차감(이중 판매)으로 이어진다. 최종적으로 confirmPayment() 트랜잭션에 outbox_events INSERT를 포함시키는 Outbox 패턴으로 전환해 at-least-once 전달을 보장했다.
초기 구현의 한계
// [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
