[CS] 결제 시스템 - 서킷 브레이커로 장애 차단

Bronze_Yun·2026년 8월 28일

PocketPay

목록 보기
2/4
post-thumbnail

PG사를 이용한 결제 시스템을 직접 구축하면서 가장 신경 쓴 부분 중 하나는 외부 결제 서버의 장애를 우리 서비스 전체의 장애로 만들지 않는 것이었습니다.

결제 승인 과정에서 API 서버는 PG사처럼 외부 시스템을 호출합니다. 문제는 외부 시스템의 응답이 느려지거나 계속 실패하더라도, 별도의 보호 장치가 없다면 우리 서버는 요청이 들어올 때마다 같은 외부 호출을 시도한다는 점입니다.

이 상태로 요청이 계속 쌓이면 결제만 실패하는 것으로 끝나지 않습니다. 외부 응답을 기다리는 Tomcat 요청 스레드가 누적되고, 결국 상품 조회처럼 결제와 직접 관련 없는 API도 요청을 처리할 스레드를 얻지 못해 느려질 수 있습니다.

이번 글에서는 다음 내용을 정리합니다.

  • Retry만 적용했을 때 지속 장애를 버티기 어려웠던 이유
  • Resilience4j Circuit Breaker를 적용한 방식
  • 동일한 PG 타임아웃 부하에서 Circuit Breaker 도입 전후를 비교한 결과

외부 결제 장애에서 고려한 세 가지

외부 결제 서버 장애에 대응하려면 아래 세 가지를 함께 고려해야 했습니다.

  • 일시적인 오류는 다시 시도할 수 있어야 한다. 짧은 네트워크 단절이나 순간적인 타임아웃은 재시도로 정상 처리될 수 있습니다.
  • 지속적으로 장애가 발생하는 서버에는 요청을 계속 보내면 안 된다. 성공 가능성이 낮은 호출을 반복하면 장애를 더 키울 수 있습니다.
  • 외부 서버가 복구되면 자동으로 정상 흐름으로 돌아와야 한다. 운영자가 수동으로 설정을 바꾸지 않아도 제한된 요청으로 복구 여부를 확인해야 합니다.

처음에는 Retry만으로 충분할 것이라 생각했습니다. 하지만 장애가 잠깐이 아니라 일정 시간 지속되는 순간, Retry는 복구 장치가 아니라 실패 요청과 자원 점유 시간을 늘리는 장치가 될 수 있었습니다.


Retry만으로 외부 장애를 버틸 수 있을까? 🤔

Retry가 필요한 이유

외부 API 호출 실패가 항상 실제 장애를 뜻하는 것은 아닙니다.

  • 순간적인 네트워크 지연
  • 일시적인 연결 단절
  • 짧은 구간의 타임아웃
  • 외부 서버의 순간적인 과부하

이런 오류는 잠시 기다린 뒤 다시 호출하면 성공할 가능성이 있습니다. 그래서 결제 서버 호출에 Retry를 적용하고, 제한된 횟수와 backoff를 두고 재시도하도록 구성했습니다.

@Retry(name = "pgClient")
@PostMapping("/mock-pg/approve")
ApprovalResponse approve(
        @RequestHeader("Idempotency-Key") String idempotencyKey,
        @RequestBody ApprovalRequest request
);

중요한 점은 모든 실패를 무조건 재시도하지 않는 것입니다.잔액 부족 같이 다시 호출해도 결과가 달라지지 않는 4xx 오류까지 재시도하면 같은 실패만 반복하게 됩니다.

따라서 네트워크 예외, 타임아웃, 외부 서버의 5xx 응답처럼 다시 시도할 가치가 있는 실패만 Retry 대상으로 분류했습니다. 현재 max-attempts는 최초 호출을 포함해 2회이므로, 한 요청에서 PG 호출은 최대 2번 발생합니다.

장애가 지속되면 Retry가 만드는 문제

Retry는 일시적인 실패에는 효과적이지만 외부 서버가 완전히 장애 상태라면 이야기가 달라집니다.

사용자 요청 1건
  ↓
PG 호출 타임아웃
  ↓
backoff 후 재시도
  ↓
다시 타임아웃
  ↓
최종 실패 처리

성공할 가능성이 거의 없는 상황에서도 모든 요청이 최대 시도 횟수와 타임아웃을 소모합니다. 그동안 우리 서버의 요청 스레드는 외부 응답을 기다리고, 장애가 난 PG 서버에는 추가 호출이 전달됩니다.

즉 Retry만 적용하면 장애 중인 서버를 계속 두드리면서 우리 서버의 자원까지 장시간 점유하는 구조가 될 수 있습니다.


Circuit Breaker로 실패를 빠르게 차단하기 ⚡

지속 장애에 대응하기 위해 PG 클라이언트에 Circuit Breaker를 함께 적용했습니다.

Circuit Breaker의 역할은 단순합니다.

최근 호출의 실패율을 관찰하다가 장애라고 판단되면 외부 호출을 잠시 차단하고 빠르게 실패 처리한다.

전기 회로에서 과전류가 발생했을 때 차단기가 회로를 끊어 더 큰 피해를 막는 것과 같은 원리입니다. PG 서버가 정상적이지 않을 때 호출 경로를 잠시 끊어 외부 장애가 API 서버 전체로 번지는 것을 제한합니다.

@Retry(name = "pgClient")
@CircuitBreaker(name = "pgClient")
@PostMapping("/mock-pg/approve")
ApprovalResponse approve(
        @RequestHeader("Idempotency-Key") String idempotencyKey,
        @RequestBody ApprovalRequest request
);

CLOSED — 정상적으로 요청을 전달

평상시에는 CLOSED 상태입니다. 요청을 PG 서버로 전달하면서 최근 호출의 성공과 실패를 기록합니다. 최소 호출 수가 채워지고 실패율이 임계값을 넘으면 OPEN으로 전환됩니다.

OPEN — 외부 호출을 차단

OPEN 상태에서는 PG 서버를 실제로 호출하지 않습니다. 호출은 CallNotPermittedException으로 빠르게 거절되고, 현재 결제 처리의 공통 예외 흐름에서 TIMEOUT_UNKNOWN 상태로 기록됩니다.

try {
    pgClient.approve(
            idempotencyKey,
            new ApprovalRequest(paymentKey, pgAmount, order.getOrderNumber())
    );
} catch (FeignException e) {
    if (isUserFault(e)) {
        Payment payment = paymentStateService.markPaymentFailed(
                paymentId,
                String.valueOf(e.status()),
                e.contentUTF8()
        );
        return PaymentResponse.from(payment, orderNumber);
    }

    Payment payment = paymentStateService.markTimeoutUnknown(paymentId);
    return PaymentResponse.from(payment, orderNumber);
} catch (Exception e) {
    Payment payment = paymentStateService.markTimeoutUnknown(paymentId);
    return PaymentResponse.from(payment, orderNumber);
}

외부 서버의 타임아웃까지 매번 기다리지 않기 때문에 PG 호출이 점유하는 시간을 줄일 수 있습니다. 다만 요청 인증, 멱등성 처리, 주문 조회, 결제 생성과 실패 상태 기록 같은 내부 작업까지 사라지는 것은 아닙니다.

HALF_OPEN — 복구 여부를 제한적으로 확인

OPEN 상태가 영원히 유지되면 PG 서버가 복구돼도 다시 요청을 보낼 수 없습니다. 설정한 대기 시간이 지나면 HALF_OPEN으로 전환하고 제한된 수의 시험 요청만 통과시킵니다.

  • 시험 요청이 성공하면 CLOSED로 전환
  • 시험 요청이 다시 실패하면 OPEN으로 복귀

상태 지표를 통해 CLOSED, OPEN, HALF_OPEN 전환을 확인할 수 있습니다. 지속적인 타임아웃 테스트에서는 HALF_OPEN에서 허용된 시험 요청도 실패하므로 다시 OPEN으로 돌아갑니다.


Resilience4j 설정

테스트에서는 최근 호출 10건을 기준으로 실패율을 계산하는 Count-based Sliding Window를 사용했습니다.

resilience4j:
  retry:
    instances:
      pgClient:
        max-attempts: 2

  circuitbreaker:
    instances:
      pgClient:
        sliding-window-type: COUNT_BASED
        sliding-window-size: 10
        minimum-number-of-calls: 10
        failure-rate-threshold: 50
        wait-duration-in-open-state: 10s
        permitted-number-of-calls-in-half-open-state: 3
        automatic-transition-from-open-to-half-open-enabled: true
설정의미
sliding-window-size실패율 계산에 사용할 최근 호출 수
minimum-number-of-calls실패율 계산을 시작하기 위한 최소 호출 수
failure-rate-thresholdOPEN으로 전환할 실패율 기준
wait-duration-in-open-stateOPEN 상태를 유지할 시간
permitted-number-of-calls-in-half-open-state복구 확인을 위해 허용할 시험 요청 수
max-attempts최초 호출을 포함한 최대 시도 횟수

임계값을 무조건 낮춘다고 안전해지는 것은 아닙니다. 너무 민감하면 몇 번의 우연한 실패로 정상 요청까지 차단하고, 너무 둔감하면 장애 상황에서도 많은 요청이 PG 서버로 전달됩니다. 실제 운영에서는 평상시 오류율, 트래픽, 타임아웃과 Retry의 전체 시간 예산을 함께 고려해야 합니다.


부하 테스트 설계

Circuit Breaker의 효과를 확인하기 위해 동일한 PG 타임아웃 상황에서 다음 두 조건을 비교했습니다.

조건Circuit Breaker
Before미적용
After적용

두 테스트에 동일한 부하와 자원 설정을 사용했습니다.

  • 결제 승인 API: 35 RPS
  • 상품 조회 API: 10 RPS
  • PG 장애: TIMEOUT 지속
  • 테스트 시간: 180초
  • Tomcat 최대 요청 스레드: 200개
  • Retry: 최초 호출 포함 최대 2회

결제 요청과 함께 상품 조회 요청을 계속 보낸 이유는, 결제 장애가 관련 없는 API의 응답시간까지 악화시키는지 확인하기 위해서입니다. 상품 조회는 같은 Tomcat 스레드풀과 DB 커넥션풀을 사용하는 비장애 API 역할을 합니다.


결과 1 — Circuit Breaker가 지속적인 스레드 포화를 해소했는가

초록색은 Circuit Breaker 적용 전의 Before, 노란색은 Circuit Breaker를 적용한 After의 Tomcat busy 스레드입니다.

Before에서는 결제 부하가 시작된 뒤 스레드가 최대값인 200개까지 증가했고, 테스트가 종료될 때까지 포화 상태가 유지됐습니다.

After도 장애 초기에는 CLOSED 상태에서 실제 PG 호출과 Retry를 수행하므로 일시적으로 200개에 도달했습니다. Circuit Breaker는 타임아웃 결과가 반환된 후에 실패를 인식하며, 이미 실행 중인 호출을 취소하지는 않기 때문입니다.

하지만 장애가 확인된 이후에는 신규 PG 호출이 차단되고 기존 요청이 종료되면서 busy 스레드가 빠르게 감소했습니다. 이후 대부분 약 20~50개 수준에서 움직였습니다.

따라서 Circuit Breaker가 초기 순간 포화를 완전히 제거하지는 않았지만, 장애가 지속되는 동안 Tomcat 스레드 200개가 계속 고갈되는 상황을 해소했다고 볼 수 있습니다.


결과 2 — 실제 결제 부하는 유지됐는가

초록색은 들어온 결제 요청, 노란색은 Circuit Breaker가 차단한 PG 호출입니다.

결제 요청은 테스트 동안 약 35 RPS로 유지됐습니다. 즉 After에서 Tomcat 스레드가 감소한 것은 부하 발생기가 요청을 덜 보냈기 때문이 아닙니다.

Circuit Breaker가 동작한 뒤에는 차단 요청도 약 30~38 RPS로 결제 요청 수와 비슷하게 유지됐습니다. 이는 신규 결제 요청 대부분이 PG 타임아웃 호출까지 진행하지 않고 차단됐다는 뜻입니다.

두 선이 정확히 일치하지 않는 이유는 다음과 같습니다.

  • Circuit Breaker가 열리기 전에 시작된 호출
  • HALF_OPEN에서 허용된 시험 호출 3건
  • Retry로 인한 외부 호출 횟수 차이
  • Prometheus 수집 주기와 rate() 계산 구간의 차이

상태 지표와 요청 지표를 함께 확인함으로써 회로의 상태만 바뀐 것이 아니라, 실제로 PG에 전달되는 장애 호출이 줄고 내부 스레드가 회수됐다는 것을 확인했습니다.


결과 3 — 비장애 상품 API의 p95·p99는 개선됐는가

Before — Circuit Breaker 미적용:

After — Circuit Breaker 적용:

상품 조회 APIBeforeAfter개선율
p957.21초2.54초약 64.8% 감소
p997.75초3.28초약 57.7% 감소
max8.82초4.42초약 49.9% 감소

Before에서는 Tomcat 스레드가 200개로 계속 포화됐습니다. 이 때문에 결제와 직접 관련 없는 GET /api/products/{id} 요청도 Tomcat 요청 대기열에서 오래 기다렸고, p95가 7.21초, p99가 7.75초까지 증가했습니다.

After에서는 Circuit Breaker가 장애 호출을 차단한 뒤 스레드 포화가 해소됐고, 상품 조회 API의 p95는 2.54초, p99는 3.28초로 개선됐습니다.

다만 이 결과를 "상품 API가 전혀 영향을 받지 않았다"고 해석할 수는 없습니다. After의 전체 p95에도 Circuit Breaker가 열리기 전 초기 타임아웃 구간과 HALF_OPEN 시험 호출 구간이 포함되기 때문입니다.

180초 동안 상품 조회를 10 RPS로 실행하면 약 1,800건의 요청이 발생합니다. 장애 초반 수십 초 동안 지연된 상품 요청은 전체 요청의 5%를 넘을 수 있으므로, 후반에 빠르게 정상화되더라도 전체 p95에는 계속 반영됩니다.

따라서 정확한 결론은 다음과 같습니다.

Circuit Breaker가 PG 장애로 인한 상품 API의 p95를 약 64.8%, p99를 약 57.7% 줄여 장애 전파를 크게 완화했다.


Retry와 Circuit Breaker는 무엇이 다른가

구분RetryCircuit Breaker
목적일시적인 실패 복구지속적인 장애 차단
동작 단위하나의 요청을 다시 시도여러 요청의 결과를 바탕으로 호출 경로 차단
장애가 짧을 때재시도로 성공할 수 있음대부분 CLOSED 유지
장애가 길 때호출량과 대기 시간 증가 가능OPEN 전환 후 빠르게 실패 처리
핵심 효과성공 가능성 향상장애 전파 완화와 자원 보호

Retry는 "이번 요청을 한 번 더 시도하면 성공할 수 있는가?"를 해결하고, Circuit Breaker는 "현재 이 서버에 요청을 보내는 것이 의미 있는가?"를 판단합니다.

두 기능은 서로를 대체하기보다 함께 사용할 때 효과가 있습니다. 다만 Retry 횟수가 지나치게 많거나 타임아웃이 길면 회로가 열리기 전까지 자원을 오래 점유하므로 Timeout → Retry → Circuit Breaker의 전체 시간 예산을 함께 계산해야 합니다.


전체 흐름 정리

결제 승인 요청 수신
  ↓
멱등키 검증 및 내부 결제 데이터 생성
  ↓
Retry 실행 — 최초 호출 포함 최대 2회
  ↓
각 시도에서 Circuit Breaker 상태 확인
  ├── OPEN
  │     └── PG 호출 없이 차단
  │           ↓
  │       TIMEOUT_UNKNOWN 처리
  │
  ├── HALF_OPEN
  │     └── 허용된 시험 요청만 PG 호출
  │
  └── CLOSED
        └── PG 호출 허용
              ├── 성공
              │     ├── Circuit Breaker 성공 기록
              │     └── 결제 승인 결과 저장
              │
              ├── PG 4xx
              │     ├── Circuit Breaker 집계 제외
              │     ├── Retry 미수행
              │     └── 결제 FAILED 처리
              │
              └── PG 5xx·네트워크·TIMEOUT
                    ├── Circuit Breaker 실패 기록
                    ├── 임계치 초과 시 OPEN
                    └── 시도 횟수가 남으면 Retry
                          ↓
                    다시 Circuit Breaker 상태 확인

Retry만 사용했을 때는 PG 서버가 장애 상태여도 모든 요청이 타임아웃과 재시도를 수행했습니다. 그 결과 Tomcat 스레드 200개가 장시간 포화되고 상품 조회 API의 p95가 7.21초까지 증가했습니다.

Circuit Breaker를 함께 적용한 뒤에는 장애가 확인된 시점부터 신규 PG 호출을 빠르게 차단하고, HALF_OPEN의 제한된 요청으로만 복구 여부를 확인했습니다. 동일한 33 RPS 부하에서도 지속적인 스레드 포화가 해소됐고 상품 조회 API의 p95는 2.54초로 감소했습니다.

이번 테스트에서 얻은 가장 큰 결론은 Circuit Breaker가 외부 장애를 없애는 기능은 아니라는 것입니다. PG 서버가 장애라면 결제는 여전히 정상 처리할 수 없습니다.

대신 실패할 요청마다 긴 타임아웃을 반복하지 않게 하고, 우리 서버의 자원을 더 빠르게 회수하며, 외부 장애가 상품·주문·회원 API로 번지는 범위를 줄여줍니다. 즉 Circuit Breaker의 핵심은 성공률을 억지로 높이는 것이 아니라 실패의 범위와 지속 시간을 통제하는 것이었습니다.

완전한 자원 격리가 필요하다면 Circuit Breaker와 함께 Bulkhead, 별도 실행 풀, 요청률 제한 등을 추가로 고려해야 합니다.

profile
정답이 꼭 정답이 아니다.

0개의 댓글