카테고리 없음

[Design Doc] PG 외부 연동 장애 대응 설계 (6주차 · 6팀 · 조수현)

Coding-Su 2026. 6. 26. 17:10
728x90

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 트래픽 → 0

CB가 없다면 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 도입이 필요하다.
728x90