PG 40% 실패율에서 살아남기

KwonMoYang·2026년 3월 20일

결제에 장애 대응을 한 겹씩 쌓아올린 기록

지금부터 하는 이야기는 PG 시뮬레이터(요청 성공률 60%, 비동기 처리 성공률 70%, 종합 성공률 42%)를 상대로 커머스 결제 시스템의 장애 대응을 설계한 과정입니다. 실제 PG사의 실패율은 이보다 훨씬 낮지만, 극단적인 환경이 더 좋은 질문을 만들어줬습니다.


TL;DR

PG 장애 하나로 서비스 전체가 죽을 수 있다. Timeout → Retry → CircuitBreaker → Bulkhead → TX 분리를 한 겹씩 쌓으면서, 각 레이어가 이전 레이어의 빈틈을 메우는 과정을 체득해보자.


아무것도 안 하면 어떻게 될까

처음 PG 시뮬레이터를 띄우고 결제 요청을 날려봤다.

=== 시도 1 === SUCCESS  {... transactionKey: "20260320:TR:5ffc99"}
=== 시도 2 === FAIL     "현재 서버가 불안정합니다."
=== 시도 3 === SUCCESS  {...transactionKey: "20260320:TR:f4cbbf"}
=== 시도 4 === FAIL     "현재 서버가 불안정합니다."
=== 시도 5 === SUCCESS  {...transactionKey: "20260320:TR:f655b3"}

5번 중 2번 실패. 40%다. 동전보다 결제 성공률이 낮다.

근데 진짜 문제는 실패 자체가 아니었다. 실패를 어떻게 처리하느냐에 따라 결제 하나의 실패가 서비스 전체의 마비로 이어질 수 있다는 거였다.

처음 짠 코드를 보자.

@Transactional
public PaymentInfo requestPayment(Long userId, PaymentCommand command) {
    order.startPayment();
    payment = paymentService.save(payment);

    // PG에 결제 요청 — 여기서 문제가 시작된다
    PgPaymentResponse pgResponse = pgClient.requestPayment(pgRequest, userId);

    payment.assignTransactionKey(pgResponse.transactionKey());
    return PaymentInfo.from(payment);
}

깔끔해 보인다. 근데 이 코드에 시한폭탄이 두 개 들어있다.


시한폭탄 1: PG가 응답 안 하면?

타임아웃 설정이 없으면, PG가 멈출 때 스레드가 영원히 기다린다. Tomcat 스레드 200개가 하나씩 잡혀가다가 결국 상품 조회, 장바구니, 회원 정보 — PG와 아무 상관없는 API까지 응답을 못 한다.

시한폭탄 2: @Transactional 안에서 외부 호출

@Transactional이 시작되면 HikariCP에서 DB 커넥션을 가져온다. PG 응답을 기다리는 동안에도 이 커넥션을 쥐고 놓지 않는다.

DB 작업:   ~20ms
PG 대기:   100~500ms (타임아웃 시 3초!)
───────────────────
커넥션 점유: 520ms+

HikariCP 기본 풀이 10개다. 동시 결제 10건이면 커넥션이 바닥난다. 그 순간부터 DB를 쓰는 모든 API가 ConnectionTimeout으로 죽는다.

결제 하나 때문에 서비스 전체가 멈추는 거다.

여기서부터 장애 대응을 한 겹씩 쌓아올리기 시작했다.


1겹: Timeout — "기다리지 않는다"

@Bean
public RestTemplate pgRestTemplate(PgProperties pgProperties) {
    return new RestTemplateBuilder()
        .rootUri(pgProperties.baseUrl())
        .setConnectTimeout(Duration.ofMillis(1000))  // 연결 1초
        .setReadTimeout(Duration.ofMillis(3000))      // 응답 3초
        .build();
}

PG 정상 응답이 100~500ms이니까, 3초면 6배 마진이다. 3초 안에 응답이 없으면 SocketTimeoutException → 스레드 해방.

이것만으로 "PG 장애 → 서비스 전체 마비"의 연쇄를 끊었다.

하지만 여전히 40%는 실패한다. 성공률은 그대로 60%.


2겹: Retry — "한 번 더 해본다"

PG의 40% 실패는 "일시적 실패"다. 서버가 잠깐 과부하인 거지, 같은 요청을 다시 보내면 성공할 수 있다.

resilience4j:
  retry:
    instances:
      pg:
        max-attempts: 3
        wait-duration: 500ms
        enable-exponential-backoff: true
        exponential-backoff-multiplier: 2

3번 재시도하면 실패 확률이 0.4 × 0.4 × 0.4 = 6.4%. 성공률이 60% → 93.6%로 뛴다.

근데 여기서 중요한 게 있다. 모든 예외를 재시도하면 안 된다.

예외재시도?이유
타임아웃, 연결 실패O일시적 네트워크 문제
500, 502, 503OPG 서버 일시 장애
400, 404X우리 요청이 잘못된 것. 3번 보내도 3번 실패

고민: 4번 재시도하면 더 좋지 않나?

3번이면 1 - 0.4³ = 93.6%, 4번이면 1 - 0.4⁴ = 97.4%. 4번이 더 좋아 보인다.

하지만 최악의 경우를 계산해봐야 한다.

재시도 횟수성공 확률최악 소요 시간 (타임아웃 3초 + 대기)
1회60%3초
3회93.6%~10.5초
4회97.4%~14초
5회98.5%~17.5초

4회면 사용자가 14초를 기다린다. 결제 버튼 누르고 14초. 대부분의 사용자는 그 전에 "뒤로가기"를 누른다. 그래서 3회로 했다. 성공률과 응답 시간 사이의 트레이드오프에서, 10초가 사용자 인내심의 한계라고 판단했다.

고민: 재시도 간격을 왜 바꿨나

처음에는 고정 500ms로 했다. 단순하고 예측 가능하니까.

근데 생각해보면, 동시에 실패한 100개의 요청이 500ms 후에 동시에 100개를 다시 보낸다. PG가 막 복구됐는데 100개가 한꺼번에 들이닥치면 또 죽을 수 있다. 이게 Thundering Herd 문제다.

고정 간격:    500ms → 500ms → 500ms   (100명이 동시에 재시도)
지수 백오프:  500ms → 1000ms → 2000ms  (시간차로 분산)

지수 백오프로 바꾸면 1차 재시도는 500ms 후, 2차는 1초 후, 3차는 2초 후. 요청들이 자연스럽게 시간차로 퍼진다.

하지만 PG가 완전히 죽으면? 3번 재시도해도 3번 다 실패한다. 요청 1건에 최악 10.5초. 이게 100명이면 100개 스레드가 각각 10초씩 잡힌다.


3겹: CircuitBreaker — "더 이상 보내지 않는다"

서킷브레이커는 누전 차단기다. 계속 실패하는 요청을 끊어서 시스템을 보호한다.

resilience4j:
  circuitbreaker:
    instances:
      pg:
        sliding-window-size: 10
        minimum-number-of-calls: 10
        failure-rate-threshold: 50
        wait-duration-in-open-state: 10s
        automatic-transition-from-open-to-half-open-enabled: true

최근 10건 중 50% 이상 실패하면 서킷이 열린다. 열린 동안에는 PG에 요청을 아예 안 보내고 즉시 Fallback을 실행한다.

CLOSED (정상)   → 요청 보냄, 결과 기록
  ↓ 실패율 50% 초과
OPEN (차단)     → PG 호출 안 함! 즉시 "잠시 후 다시 시도해주세요" (수 ms)
  ↓ 10초 경과
HALF-OPEN (시험) → 3건만 시험 호출
  ↓ 성공하면 CLOSED / 실패하면 다시 OPEN

스레드 점유 시간이 10초 → 거의 0으로 줄어든다.

근데 서킷이 안 열렸다

달고 나서 테스트했더니, 10번 연속 실패해도 서킷이 안 열렸다.

원인을 찾는 데 꽤 걸렸다. minimum-number-of-calls기본값이 100이었다.

sliding-window-size: 10으로 "최근 10건을 관찰하겠다"고 했는데, minimum-number-of-calls가 100이면 100번 호출될 때까지 실패율 계산 자체를 안 한다. 10번 연속 실패해도 서킷은 꿈쩍도 안 한다.

이거 말고도 밟은 함정이 더 있다.

함정증상해결
minimum-number-of-calls 기본값 100서킷이 안 열림10으로 명시
record-exceptions 미설정400 에러도 실패로 집계 → 엉뚱하게 서킷 오픈5xx만 record
automatic-transition 기본값 false새벽에 서킷이 영원히 OPENtrue로 설정
slow-call-threshold > readTimeout느린 호출 감지 사실상 비활성readTimeout보다 작게

ghtjr410/resilience4j-lab 레포에서 이 함정들을 테스트 코드로 증명해둔 게 있었다. 공식 문서에 설정은 다 나와있지만, 설정의 조합이 만드는 함정은 직접 겪어봐야 알게 된다.

고민: @Retry와 @CircuitBreaker를 어디에 붙일까

이 부분에서 꽤 고민했다. 두 어노테이션을 같은 클래스에 붙이면 Aspect 순서가 암묵적이라 의도치 않은 동작이 발생할 수 있다.

다른 수강생의 PR에서 실제로 이 문제가 발생했다. @CircuitBreakerfallbackMethod가 원본 예외를 CoreException으로 변환 → @Retryignore-exceptions에 해당 → Retry가 영원히 재시도하지 않는 구조적 버그. 성공률이 51% → 6%로 급락했다고 한다.

나는 두 어노테이션을 다른 클래스에 분리했다.

// PaymentFacade.java — @CircuitBreaker (Fallback에서 비즈니스 처리 가능)
@CircuitBreaker(name = "pg", fallbackMethod = "requestPaymentFallback")
public PaymentInfo requestPayment(...) { ... }

// PgPaymentGateway.java — @Retry (PG 호출만 재시도)
@Retry(name = "pg")
public PgPaymentResponse requestPayment(...) { ... }

이렇게 하면 호출 순서가 자연스럽게 CB(outer) → Retry(inner) → PG가 된다. Retry가 3번 시도한 최종 결과만 CB에 전달되니까, CB는 실패를 1건으로만 기록한다.

왜 이 순서가 맞는지 잠깐 생각해보면:

CB(outer) → Retry(inner): Retry가 3번 시도 → CB에 "1건 실패" 기록
                           → 10명 연속 실패해야 서킷 오픈

Retry(outer) → CB(inner): Retry가 CB를 3번 호출 → CB에 "3건 실패" 기록
                           → 4명만 실패해도 서킷 오픈 (3배 증폭)

같은 어노테이션인데 순서만 바뀌면 서킷이 3배 빨리 열린다. 이건 공식 문서에서도 쉽게 놓칠 수 있는 부분이다.


4겹: Bulkhead — "동시에 너무 많이 보내지 않는다"

서킷브레이커는 실패율 50% 이상에서 열린다. 그러면 40~49% 구간에서는?

서킷은 닫혀있으니 모든 요청이 PG로 간다. Retry까지 포함하면 스레드 하나당 점유가 길어지고, Tomcat 200개 스레드가 전부 결제에 묶일 수 있다.

resilience4j:
  bulkhead:
    instances:
      pg:
        max-concurrent-calls: 20
        max-wait-duration: 0  # 초과 시 즉시 거부

PG 동시 호출을 20개로 제한한다. 21번째부터는 보내지도 않고 바로 실패. 나머지 180개 스레드는 상품 조회, 장바구니 등 다른 API가 쓴다.


5겹: TX 분리 — "DB 커넥션을 놓아준다"

사실 이게 가장 근본적인 변경이었다. Resilience4j를 아무리 잘 달아도, @Transactional 안에서 PG를 호출하면 DB 커넥션 점유 문제는 그대로다.

// Before: 단일 @Transactional (520ms+ 커넥션 점유)
@Transactional
public PaymentInfo requestPayment(...) {
    order.startPayment();           // DB ~5ms
    payment = save(payment);        // DB ~10ms
    pgGateway.requestPayment(...);  // PG 100~500ms ← 이 동안 커넥션 점유!
    payment.assignTransactionKey(); // DB ~5ms
}
// After: TX 분리 (DB 커넥션 20ms만 점유)
public PaymentInfo requestPayment(...) {
    // TX-1: DB 작업만 (~15ms), 커밋 → 커넥션 반환
    PaymentModel payment = paymentService.preparePayment(userId, command);

    // NO TX: PG 호출 — DB 커넥션 안 잡음!
    PgPaymentResponse pgResponse = pgGateway.requestPayment(pgRequest, userId);

    // TX-2: 결과 저장 (~5ms), 커밋 → 커넥션 반환
    paymentService.assignTransactionKey(payment.getId(), pgResponse.transactionKey());
}
BeforeAfter
DB 커넥션 점유520ms+~20ms
동시 결제 10건 시풀 고갈여유
PG 장애 시 DB 영향전체 API 마비영향 없음

고민: 트랜잭션을 쪼개면 정합성은?

단일 @Transactional의 가장 큰 장점은 원자성이다. PG 호출이 실패하면 주문 상태 변경과 결제 레코드가 모두 롤백된다. 깔끔하다.

TX를 분리하면 이 원자성을 포기하게 된다. TX-1이 커밋된 후 PG 호출이 실패하면:

DB 상태:
  주문: PAYMENT_PENDING (TX-1에서 커밋됨, 되돌릴 수 없음)
  결제: PENDING, transactionKey=null (고아)

PG 상태:
  아무것도 없음 (요청이 안 갔거나, 갔지만 응답을 못 받은 것)

두 시스템의 상태가 어긋나는 고아 Payment 문제다.

이걸 감수하면서까지 TX를 분리한 이유는, 정합성 문제는 복구할 수 있지만, DB 커넥션 고갈은 서비스 전체를 죽이기 때문이다. 고아는 Polling으로 60초 내에 복구할 수 있다. 하지만 커넥션 고갈은 모든 API를 즉시 마비시킨다.

고아 Payment를 복구하기 위해 Polling 스케줄러를 만들었다.

@Scheduled(fixedDelay = 60_000)
public void pollPendingPayments() {
    List<PaymentModel> pending = paymentService.getPendingPayments();
    int consecutiveFailures = 0;

    for (PaymentModel payment : pending) {
        try {
            paymentFacade.syncPaymentStatus(payment.getId());
            consecutiveFailures = 0;
        } catch (Exception e) {
            if (++consecutiveFailures >= 3) {
                log.error("PG 연속 3건 실패 — 사이클 중단");
                break;
            }
        }
    }
}

60초마다 PENDING 결제를 PG에 조회한다. transactionKey가 없는 고아도 orderId로 PG에 물어볼 수 있다.

fail-fast가 핵심이다. PG가 완전히 죽었을 때 PENDING 100건을 다 조회하면 100 × 3초 = 300초. 연속 3건 실패하면 "PG가 죽었다"로 판단하고 즉시 중단한다.


전체 그림

요청 → Bulkhead (동시 20개)
       → CircuitBreaker (50% 실패 시 차단)
         → Retry (3회, 지수 백오프)
           → PG 호출 (timeout 3초)

실패 시:
  Fallback → "잠시 후 다시 시도해주세요"
  TX-1은 이미 커밋 → 3-Tier 안전망이 복구
    1차: PG 콜백 (정상 도착 시)
    2차: Polling (60초 주기 자동)
    3차: 수동 API (/payments/{id}/sync)
레이어역할없으면?
Timeout무한 대기 방지스레드 영원히 잠김 → 서비스 마비
Retry일시적 실패 복구성공률 60% 고정
CircuitBreaker장애 시 빠른 실패매 요청 10초 대기 → 스레드 고갈
Bulkhead동시 호출 제한Tomcat 전체 스레드 잠김
TX 분리DB 커넥션 보호PG 대기 중 커넥션 고갈
Polling고아 복구PENDING 영구 잔류

아직 남은 고민들

완성은 아니다. 풀지 못한 질문들이 남아있다.

Fallback에서 사용자에게 뭘 보여줘야 하나?

현재는 "결제 시스템이 불안정합니다. 잠시 후 다시 시도해주세요"를 반환한다. 하지만 TX-1이 이미 커밋되어서 주문은 PAYMENT_PENDING 상태다. 사용자 입장에서는 "결제가 된 건가, 안 된 건가?" 혼란스럽다. Polling이 60초 후에 복구하지만, 그 60초 동안 사용자 경험은?

설정값이 시뮬레이터에 과적합된 건 아닌가?

failure-rate-threshold: 50%는 40% 실패율 시뮬레이터에 맞춘 값이다. 실제 PG 실패율이 1~5%인 운영 환경에서는 어떤 값이 적절할까? 30%로 낮추면 일시적 spike에도 서킷이 열릴 수 있고, 너무 높으면 장애 감지가 느려진다. 이건 실제 트래픽 데이터 없이는 답하기 어렵다.

PG에서는 결제가 됐는데 우리는 실패 처리한 경우는?

타임아웃으로 실패 처리했지만, PG에서는 실제로 카드 승인이 된 경우. 현재는 Polling이 60초 후에 SUCCESS로 맞춰주지만, 그 사이에 사용자가 결제를 다시 시도하면 이중 결제가 발생할 수 있다. 멱등성 키나 결제 상태 확인 후 재시도 같은 추가 장치가 필요하다.


마치며

장애 대응은 한 방에 완성되지 않았다.

Timeout만으로 부족해서 Retry를 추가했고, Retry만으로 부족해서 CircuitBreaker를 추가했고, 그걸로도 부족해서 Bulkhead를 추가했고, 이 모든 게 @Transactional 안에 있어서 DB가 죽어서 TX를 분리했고, TX를 분리했더니 고아가 생겨서 Polling을 추가했다.

각 레이어는 독립적이 아니다. 이전 레이어의 빈틈을 메우는 관계다. "왜 이게 필요한가"의 답은 항상 "이전 것만으로는 못 막는 시나리오가 있기 때문"이었다.

PG 시뮬레이터의 40%는 극단적이다. 실제로는 이 정도가 아닐 거다. 하지만 이 환경에서 시스템을 지탱하는 구조를 만들어봤기 때문에, 진짜 장애가 왔을 때 어디를 봐야 하는지는 알게 됐다.

profile
Dot Your moment.

0개의 댓글