TL;DR
FeignClient로 PG를 호출하는 결제 시스템에서 Timeout + Retry + CircuitBreaker 조합을 적용해 PG 장애가 전체 서비스로 번지는 Cascading Failure를 차단했다. 타임아웃/CB Open 시 PENDING 유지 + 30초 배치 복구로 상태 정합성을 보장한다.
Introduction & Goals
Context / Background
결제 요청 시 우리 서버가 PG(Payment Gateway)를 FeignClient로 동기 호출한다. PG는 외부 시스템이므로 응답 지연이나 장애를 제어할 수 없다. Tomcat 스레드 풀(기본 200개)은 PG 응답을 기다리는 동안 블로킹 상태로 묶이며, 스레드가 고갈되면 결제와 무관한 상품 조회나 주문 생성까지 503을 반환하게 된다. 이것이 Cascading Failure다.
Goals
- PG 장애 시 결제 외 기능(상품 조회, 주문 등)은 정상 동작한다
- 타임아웃/CircuitBreaker Open 시 Payment는 즉시 FAILED가 아닌 PENDING을 유지하고, 30초 배치가 PG 상태를 확인해 최종 상태로 수렴한다
- PG 500 에러는 Retry 3회 소진 후 즉시 FAILED 처리하고 주문을 취소한다
Detailed Design
System Architecture
PG 호출 흐름에 Resilience4j를 통해 3단 방어선을 적용한다.
Client
└─ PaymentController
└─ PaymentFacade (@Transactional 없음 — TX 분리 핵심)
├─ [TX 1] PaymentService.create() → Payment(PENDING) 저장 & DB 커넥션 반납
└─ [TX 외부] PgClient.requestPayment()
└─ CircuitBreaker
└─ Retry (500 에러만, 최대 3회)
└─ TimeLimiter (readTimeout 400ms)
└─ FeignClient → PG트랜잭션 분리가 핵심이다. PaymentFacade에 @Transactional을 두지 않아 Payment 저장(TX 1) 커밋 직후 DB 커넥션을 반납한다. 이후 PG 호출이 느려지거나 CB가 열려도 DB 커넥션 풀에 영향을 주지 않는다.
장애 유형별 처리 흐름
| 장애 유형 | 처리 방식 | 최종 상태 |
|---|---|---|
| PG 500 에러 | Retry 3회 → 소진 시 즉시 FAILED + 주문 취소 | FAILED |
| Timeout (400ms 초과) | Retry 없음, PENDING 유지 → 배치 복구 | PENDING → 배치가 수렴 |
| CircuitBreaker Open | PG 호출 차단, PENDING 유지 → 배치 복구 | PENDING → 배치가 수렴 |
| PG 정상 응답 | transactionKey 저장, PENDING 유지 | PENDING → 콜백으로 수렴 |
500은 Retry하고 Timeout은 Retry하지 않는 이유: PG 500은 createTransaction 이전 실패이므로 PG에 기록이 없다. 중복 결제 위험 없이 재시도 가능하다. 반면 Timeout은 PG가 처리 중일 수 있어 재시도하면 이중 결제가 발생한다.
Resilience4j 설정
feign:
client:
config:
pg-client:
connectTimeout: 1000
readTimeout: 400 # PG 최대 지연 500ms 기준 — 타임아웃 시나리오 재현 의도
resilience4j:
circuitbreaker:
instances:
pgCircuit:
sliding-window-size: 10
failure-rate-threshold: 50
slow-call-duration-threshold: 2s
slow-call-rate-threshold: 50
wait-duration-in-open-state: 10s
permitted-number-of-calls-in-half-open-state: 2
retry:
instances:
pgRetry:
max-attempts: 3
retry-exceptions:
- feign.RetryableException # 503, 네트워크 오류
- java.net.SocketTimeoutException
ignore-exceptions:
- SocketTimeoutException # Timeout은 재시도 안 함
slow-call 설정이 중요하다. PG가 죽지는 않았지만 모든 요청이 3~4초씩 걸리는 상황에서 실패율은 0%이지만 스레드는 이미 고갈되고 있다. 이 설정 없이는 CB가 열리지 않는다.
PENDING 복구 배치
콜백이 메인 경로, 배치는 안전망이다.
@Scheduled(fixedDelay = 30000)
대상: status = PENDING AND created_at < now() - 30s
PG 조회 결과:
SUCCESS + 주문 PENDING → Payment SUCCESS + Order CONFIRMED
SUCCESS + 주문 CANCELLED → 로그 기록 + 수동 처리 대상 표시
FAILED → Payment FAILED + cancelBySystem + 재고 복구
기록 없음 → Payment FAILED + cancelBySystem + 재고 복구
PENDING → 건드리지 않음, 다음 사이클에 재시도트레이드오프: split-commit
recoverAsFailure()에서 @Transactional을 제거해 failByOrderId()가 먼저 독립 커밋된다.
왜 하나의 트랜잭션으로 묶으면 안 되는가? cancelBySystem() 예외 시 failByOrderId()도 함께 롤백돼 결제가 PENDING으로 남는다. 배치가 30초 후 같은 건을 다시 처리하고 또 같은 예외가 발생하는 무한 재시도 루프가 된다.
허용하는 불일치: failByOrderId() 커밋 후 cancelBySystem()이 실패하면 결제는 FAILED지만 주문이 CANCELLED 되지 않은 상태가 일시적으로 발생할 수 있다. 결제가 FAILED로 확정되면 배치 재시도는 없으므로 운영팀 수동 보정 또는 향후 Outbox Pattern으로 해결한다.
Alternatives Considered
| 옵션 | Pros | Cons |
|---|---|---|
| A. Timeout만 적용 | 구현 단순 | PG 지속 장애 시 매 요청마다 Timeout까지 스레드 점유, Cascading Failure 완전 차단 불가 |
| B. Timeout + Retry만 적용 | A보다 회복력 향상 | 반복 실패 시 Retry Storm 위험, CB 없으면 이미 죽은 PG에 계속 호출 |
| C. Timeout + Retry + CircuitBreaker 조합 | 장애 확산 차단 + 일시 장애 회복 + 빠른 Fallback | 설정값 튜닝 필요, 구현 복잡도 증가 |
선택 근거: C안. CB가 열리면 스레드가 즉시 반납되고 PG에 트래픽이 끊겨 회복 시간을 준다. PENDING + 배치 조합으로 Timeout/CB Open 케이스의 정합성을 보장한다. Retry는 일시적 500 에러에만 적용해 Retry Storm을 방지한다.
Cross-cutting Concerns
Observability
K6로 30 TPS 부하를 주면서 PG 지연 시나리오를 돌리면 CB 동작을 p99로 관찰할 수 있다.
[CB Closed — PG 지연 발생]
http_req_duration p99 → 급등 (Timeout 400ms 대기)
http_req_failed rate → 증가
[CB Open 전환 시점]
p99 → 갑자기 낮아짐 ← 핵심 관찰 포인트
fallback 비율 → 증가
[CB Open 구간]
p99 → 회복 (Fallback은 즉시 반환)
PG 트래픽 → 0CB가 없다면 p99가 내내 높은 상태를 유지한다. "PG 장애 → 일정 시간 후 p99 급락" 패턴이 보이면 CB가 제대로 동작한다는 증거다.
Scalability
PENDING 건수가 많아지면 배치 실행 시간이 길어질 수 있다. 건수 제한(페이징) 처리로 대응 가능하다.
Known Limitations
handleCallback()FAILED 경로에서getById()(잠금 없음) →cancelBySystem()(FOR UPDATE) 사이에 사용자 수동 취소가 끼어드는 TOCTOU 레이스 컨디션이 존재한다. 30초 배치가 PENDING을 재확인해 최종 상태로 수렴되므로 허용한다.- PG SUCCESS + 주문 CANCELLED 케이스는 pg-simulator에 취소 API가 없어 자동화 불가다. 실무에서는 PG 취소 API 호출로 자동화 가능하다.
- split-commit의 완전한 해결은 Outbox Pattern 도입이 필요하다.