TL;DR: 외부 결제 시스템 연동에서 장애를 막는 네 가지 방법
결제 기능을 구현할 때, 우리 서비스는 외부 PG(Payment Gateway) 시스템과 통신해야 한다. 문제는 이 외부 시스템이 항상 정상이라는 보장이 없다는 점이다. 응답이 느려질 수 있고, 아예 응답이 오지 않을 수도 있다. PG가 죽었는데 우리 서비스까지 같이 죽는 상황, 이른바 장애 전파(Failure Propagation) 를 어떻게 막을 것인가가 이 글의 주제다.
이 글에서는 PG Simulator를 연동하면서 Resilience4j의 Circuit Breaker, Timeout, Fallback, Retry를 단계적으로 적용한 과정을 정리한다. 각 패턴이 어떤 문제를 해결하는지, 그리고 왜 그 순서로 적용해야 하는지에 초점을 맞춘다.
1. PG 연동의 현실적 조건
2. 보호 없는 연동의 위험
3. Timeout — 기다림의 상한선을 정한다
4. Circuit Breaker — 실패를 인식하고 차단한다
5. Fallback — 실패해도 사용자 경험을 지킨다
6. Retry — 일시적 실패를 자동으로 극복한다
7. 콜백 유실 대응 — Polling 기반 상태 복구
1. PG 연동의 현실적 조건
이번에 연동한 PG Simulator의 스펙은 다음과 같다.
| 요청 성공 확률 | 60% |
| 요청 지연 | 100ms ~ 500ms |
| 처리 지연 | 1s ~ 5s |
| 처리 결과 | 성공 70%, 한도 초과 20%, 잘못된 카드 10% |
이 스펙에서 설계에 직접적으로 영향을 주는 특성은 두 가지다.
비동기 처리 모델: 결제 요청에 대해 PG는 즉시 transactionId를 응답하고, 실제 승인 결과는 이후 콜백(callback)으로 비동기 전달한다. 요청의 수신과 처리가 시간적으로 분리되어 있으므로, 요청 성공이 곧 결제 성공을 의미하지 않는다. 이 간극을 어떻게 관리할 것인지가 첫 번째 설계 과제다.
높은 요청 실패율: 요청 성공 확률이 60%라는 것은, 정상 운영 상태에서도 10건 중 4건은 실패한다는 의미다. 이는 버그가 아니라 외부 시스템 연동에서 감수해야 하는 조건이다. 보호 장치 없이 이 시스템을 호출하면, 실패한 요청마다 타임아웃 대기가 발생하고, 스레드 점유가 누적되어 서비스 전체의 처리량이 저하된다.
2. 보호 없는 연동의 위험
Resilience 패턴을 적용하기 전에, 보호 장치가 없으면 어떤 일이 벌어지는지 짚어보자.
PG가 장애 상태에 빠져서 응답을 5초씩 지연시키는 상황을 가정하자. 우리 서비스의 결제 API를 호출할 때마다 PG 응답을 기다리느라 스레드가 5초간 블로킹된다. 톰캣의 스레드 풀이 200개라면, 초당 40건의 결제 요청만 들어와도 모든 스레드가 PG 응답을 기다리며 묶여버린다. 결제뿐 아니라 상품 조회, 주문 목록 같은 다른 API까지 응답 불가 상태에 빠진다.
이것이 장애 전파다. PG 하나의 문제가 우리 서비스 전체의 문제가 되는 것이다. 이 문제를 해결하기 위해 Timeout, Circuit Breaker, Fallback, Retry를 순서대로 적용한다.
3. Timeout — 기다림의 상한선을 정한다
해결하는 문제
PG의 응답 지연이 비정상적으로 길어질 때, 호출 측 스레드가 무기한 블로킹되는 것을 방지한다. Timeout은 "얼마나 기다릴 것인가"에 대한 상한선을 설정하는 가장 기본적인 보호 장치다.
두 레벨의 Timeout
Timeout은 단일 지점이 아니라, 서로 다른 계층에서 이중으로 설정한다.
HTTP 클라이언트 레벨 — RestClient의 connectTimeout과 readTimeout으로 네트워크 수준의 지연을 제어한다. connectTimeout은 TCP 연결 수립 자체가 실패하는 경우, readTimeout은 연결은 수립되었으나 응답 데이터가 도착하지 않는 경우에 각각 대응한다.
pg:
base-url: http://localhost:9090
connect-timeout: 1000 # 연결 타임아웃 1초
read-timeout: 3000 # 읽기 타임아웃 3초
Resilience4j TimeLimiter — HTTP 타임아웃이 개별 네트워크 호출에 대한 제약이라면, TimeLimiter는 비즈니스 오퍼레이션 전체에 대한 시간 상한이다. PG 호출 전후의 직렬화, 로깅, 트랜잭션 처리 등을 포함한 전체 흐름이 지정된 시간 내에 완료되지 않으면 강제로 중단한다.
resilience4j:
timelimiter:
instances:
pgPayment:
timeoutDuration: 3s
설계 판단: 3초의 근거
PG의 정상 요청 지연이 100ms~500ms이므로, 3초는 정상 상한의 6배에 해당한다. 이 값은 두 가지 제약 사이의 균형점이다. 너무 짧으면 정상 범위의 요청까지 타임아웃으로 절단하여 불필요한 실패를 만들고, 너무 길면 장애 시 스레드 점유 시간이 늘어나 Timeout의 보호 효과 자체가 희석된다.
Timeout의 한계
Timeout은 개별 요청의 대기 시간을 제한할 뿐, 실패가 반복되는 패턴 자체를 인식하지 못한다. PG가 장애 상태에 빠져 있어도, 매 요청마다 3초를 소진한 뒤에야 실패를 확인한다. 필요한 것은 개별 요청이 아닌 시스템 상태 수준의 판단, 즉 "지금 이 시스템에 요청을 보내는 것 자체가 무의미하다"는 결정이다. 이것이 Circuit Breaker의 역할이다.
4. Circuit Breaker — 실패를 인식하고 차단한다
해결하는 문제
외부 시스템이 장애 상태일 때, 요청을 계속 보내는 대신 빠르게 실패(fail-fast)시켜서 우리 서비스를 보호한다.
Circuit Breaker의 상태 전이
서킷브레이커는 전기 회로의 차단기에서 이름을 빌려왔다. 세 가지 기본 상태가 있다.
CLOSED: 정상 상태. 회로가 닫혀 있으므로 전류(요청)가 흐른다. 모든 요청을 통과시키면서 성공/실패를 기록한다. 실패율이 임계값을 넘으면 OPEN으로 전이된다.
OPEN: 차단 상태. 회로가 열려 있으므로 전류가 끊긴다. 요청을 아예 보내지 않고 즉시 CallNotPermittedException을 던진다. 설정된 대기 시간이 지나면 HALF_OPEN으로 전이된다.
HALF_OPEN: 탐색 상태. 제한된 수의 요청만 통과시켜서 외부 시스템이 회복되었는지 확인한다. 결과에 따라 CLOSED로 복귀하거나 다시 OPEN으로 돌아간다.
CLOSED ──(실패율 초과)──▶ OPEN ──(대기시간 경과)──▶ HALF_OPEN
▲ │
└──────(실패율 정상)──────────────────────────────────┘
(실패율 초과)──▶ 다시 OPEN
설정과 각 값의 의도
resilience4j:
circuitbreaker:
instances:
pgPayment:
slidingWindowType: COUNT_BASED
slidingWindowSize: 10
minimumNumberOfCalls: 5
failureRateThreshold: 50
waitDurationInOpenState: 30s
permittedNumberOfCallsInHalfOpenState: 3
slowCallDurationThreshold: 2s
slowCallRateThreshold: 80
각 설정의 의도를 하나씩 풀어보자.
slidingWindowSize: 10 — 최근 10건의 호출 결과를 기반으로 실패율을 계산한다. 이 값이 관측 범위를 결정한다. 너무 크면 과거의 오래된 데이터까지 반영되어 반응이 느려지고, 너무 작으면 일시적 실패에 과민 반응한다.
minimumNumberOfCalls: 5 — 윈도우 안에 최소 5건이 쌓여야 실패율 계산을 시작한다. 서버 시작 직후나 HALF_OPEN에서 복귀한 직후처럼 데이터가 적을 때, 2건 중 1건 실패(50%)로 서킷이 열리는 소표본 오판을 방지하는 안전장치다.
failureRateThreshold: 50 — 실패율 50% 이상이면 OPEN. PG 요청 성공 확률이 60%이므로, 정상 상태에서도 실패율이 40% 근처까지 올라갈 수 있다. 50%는 "정상적인 실패 수준을 넘어섰다"는 판단 기준이다.
waitDurationInOpenState: 30s — OPEN 상태에서 30초간 대기한 후 HALF_OPEN으로 전이한다. PG가 복구되는 데 걸리는 예상 시간에 맞춰 설정한다. 너무 짧으면 회복 전에 탐색해서 다시 OPEN이 되고, 너무 길면 복구 후에도 불필요하게 오래 차단된다.
permittedNumberOfCallsInHalfOpenState: 3 — HALF_OPEN에서 3건만 탐색한다. 이 3건의 결과로 복구 여부를 판단한다.
slowCallDurationThreshold / slowCallRateThreshold — 실패뿐 아니라 지연도 장애로 인식한다. 응답이 2초 이상 걸리는 호출이 80%를 넘으면 서킷을 연다. 이건 "실패는 안 했지만 사실상 장애 상태"인 경우를 잡아낸다.
데코레이터 적용 순서
요청 → [ TimeLimiter → CircuitBreaker ] → PG 호출
TimeLimiter가 바깥, CircuitBreaker가 안쪽이다. 이 순서가 중요한 이유는, 개별 호출이 타임아웃으로 실패한 결과가 CircuitBreaker의 실패 집계에 반영되어야 하기 때문이다. 순서가 반대면 CircuitBreaker가 타임아웃 실패를 인지하지 못한다.
5. Fallback — 실패해도 사용자 경험을 지킨다
해결하는 문제
Circuit Breaker가 요청을 차단하거나 Timeout이 발생했을 때, 사용자에게 의미 있는 응답을 제공한다.
예외 유형별 Fallback 전략
Fallback은 "모든 실패에 동일한 응답"이 아니라, 예외의 성격에 따라 다른 전략을 적용해야 한다.
CallNotPermittedException (서킷 OPEN): PG가 장애 상태라고 이미 판단된 상황이다. 요청 자체를 보내지 않았으므로 PG 쪽에 결제가 걸려 있을 가능성이 없다. 사용자에게 "현재 결제 시스템이 불안정하여 잠시 후 다시 시도해주세요"라고 안내한다.
TimeoutException: 요청은 PG에 도달했을 수 있다. PG가 실제로 결제를 처리했지만 응답만 늦은 것일 수 있다. 이 경우 단순히 "실패"로 처리하면 안 된다. 결제 상태를 PENDING으로 유지하고, 나중에 콜백이나 Polling으로 실제 결과를 확인해야 한다. 사용자에게는 "결제 요청이 접수되었으며 결과를 확인 중입니다"라고 안내한다.
기타 PG 통신 예외: 네트워크 오류 등 명확한 실패. "결제 처리 중 오류가 발생했습니다"로 안내한다.
이 구분이 중요한 이유는 Timeout 상황에서의 결제 상태 모호성 때문이다. 요청이 PG에 도달했는지 안 했는지 우리 쪽에서는 알 수 없으므로, "실패"라고 단정 짓지 않고 "확인 필요" 상태로 남겨둬야 한다.
6. Retry — 일시적 실패를 자동으로 극복한다
해결하는 문제
네트워크 순간 끊김, PG 서버의 일시적 과부하 같은 일시적 실패(transient failure) 에 대해 자동으로 재시도하여 성공률을 올린다.
성공 확률의 변화
PG 요청 성공 확률이 60%일 때, 재시도 횟수에 따른 최종 성공 확률은 다음과 같다.
| 시도 횟수 | 최종 성공 확률 |
| 1회 (재시도 없음) | 60% |
| 2회 (재시도 1회) | 84% |
| 3회 (재시도 2회) | 93.6% |
1회 성공 확률이 p일 때, n회 시도의 최종 성공 확률은 1 - (1-p)^n이다. 재시도 2회만 추가해도 성공률이 60%에서 93.6%로 올라간다.
설정과 설계 판단
resilience4j:
retry:
instances:
pgPayment:
maxAttempts: 3
waitDuration: 500ms
retryExceptions:
- com.example.infrastructure.pg.PgClientException
- java.util.concurrent.TimeoutException
ignoreExceptions:
- java.lang.IllegalArgumentException
왜 3회인가: 성공 확률 93.6%는 실용적으로 충분하며, 4회 이상은 개선폭 대비 지연 시간이 과도해진다.
왜 500ms 고정 간격인가: PG의 일시적 과부하가 원인이라면 지수 백오프(exponential backoff)가 더 적합할 수 있다. 하지만 PG Simulator의 요청 지연이 100ms~500ms 수준으로 짧고, 고정 간격으로도 충분히 부하 분산이 된다고 판단했다. 실제 운영 환경에서는 지수 백오프 + jitter 조합이 더 안전하다.
retryExceptions의 선택 기준: 재시도해서 결과가 달라질 수 있는 예외만 대상으로 한다. PgClientException(통신 실패)과 TimeoutException은 일시적일 수 있으므로 재시도 대상이다. IllegalArgumentException(잘못된 파라미터)은 재시도해도 동일하게 실패하므로 제외한다.
CallNotPermittedException은 절대 재시도하지 않는다: 서킷이 OPEN인 상태에서 재시도하면 즉시 실패만 반복할 뿐이다. 재시도 3회가 모두 CallNotPermittedException으로 실패하면, 의미 없는 지연만 1.5초(500ms × 3) 추가된다.
데코레이터 최종 적용 순서
요청 → [ Retry → TimeLimiter → CircuitBreaker ] → PG 호출
Retry가 가장 바깥이다. 개별 시도가 실패하면(TimeLimiter 타임아웃이든, CircuitBreaker 차단이든) Retry가 전체를 다시 시도한다. 단, CircuitBreaker가 OPEN이면 Retry를 해도 무의미하므로, CallNotPermittedException은 retryExceptions에서 제외해야 한다.
7. 콜백 유실 대응 — Polling 기반 상태 복구
비동기 결제에서 한 가지 더 고려할 문제가 있다. 콜백이 오지 않는 경우다.
네트워크 장애, PG 서버 장애, 우리 서버 재시작 등의 이유로 콜백이 유실될 수 있다. 이때 Payment 상태가 PENDING에 영원히 머무르게 된다.
3중 안전망 설계
1차: 콜백 수신: 정상적인 경우 PG가 처리 완료 후 콜백을 보내고, 우리가 상태를 갱신한다. 가장 빠른 경로다.
2차: 스케줄러 Polling: PENDING 상태로 일정 시간(예: 5분) 이상 방치된 건을 주기적으로 찾아서, PG에 직접 조회하고 상태를 갱신한다. 콜백 유실에 대한 자동 복구 장치다.
3차: 수동 복구 API: 스케줄러로도 복구되지 않는 건(PG 조회 API도 장애인 경우 등)에 대해 운영자가 직접 처리할 수 있는 Admin API다.
스케줄러 설계 시 고려할 점
복구 시도 횟수 제한 — 스케줄러가 무한히 같은 건을 재시도하지 않도록, recoveryAttemptCount를 관리하고 최대 횟수(예: 5회)를 초과하면 UNKNOWN 상태로 전환하여 수동 확인 대상으로 분류한다.
콜백과 스케줄러의 경합 — 콜백이 늦게 도착해서 스케줄러와 동시에 같은 건을 처리하려는 상황이 발생할 수 있다. reconcile 로직에서 이미 최종 상태(SUCCESS/FAIL)인 건은 무시하도록 방어 코드를 넣어야 한다. 낙관적 락이나 상태 전이 검증으로 해결할 수 있다.
상태 흐름 요약
결제요청 → PENDING ──(콜백 수신)──→ SUCCESS / FAIL
│
├──(스케줄러 조회)──→ SUCCESS / FAIL
│
└──(최대 재시도 초과)──→ UNKNOWN → 수동 확인
8. 정리: 각 패턴이 지키는 것
| 패턴 | 보호 대상 | 부재 시 발생하는 문제 |
| Timeout | 개별 요청의 대기 시간 상한 | 스레드 무한 블로킹, 스레드 풀 고갈 |
| Circuit Breaker | 장애 상태의 인식과 빠른 차단 | 장애 서비스에 대한 반복 호출, 자원 고갈 |
| Fallback | 실패 시 사용자에 대한 응답 품질 | 500 에러, 맥락 없는 에러 메시지 |
| Retry | 일시적 실패에 대한 자동 복구 | 단발성 네트워크 오류가 곧바로 최종 실패 |
| Polling | 콜백 유실에 대한 상태 정합성 | PENDING 상태 영구 방치 |
각 패턴은 독립적으로도 의미를 갖지만, 단독으로는 보호 범위에 빈틈이 생긴다.
Timeout은 개별 호출의 대기 시간을 제한하고, Circuit Breaker는 시스템 수준에서 장애를 감지하여 차단하고, Fallback은 실패가 사용자 경험으로 직접 전달되는 것을 완화하고, Retry는 일시적 실패의 자동 복구로 성공률을 끌어올리고, Polling은 비동기 처리에서 유실된 결과를 보정한다. 이 다섯 가지가 조합되었을 때 비로소 요청 단위, 시스템 단위, 사용자 단위, 데이터 단위의 보호가 빈틈 없이 성립한다.
외부 시스템 연동 설계의 출발점은 하나의 전제다. 실패는 예외 상황이 아니라 정상 운영의 일부라는 것이다. 이 전제를 수용하면 위의 패턴들은 장애 대비를 위한 부가 기능이 아니라, 시스템이 정상적으로 동작하기 위한 필수 구조가 된다.
'Programming > Technical Writing' 카테고리의 다른 글
| kafka를 통한 이벤트 기반 아키텍처 (0) | 2026.03.27 |
|---|---|
| 조회 병목 해소 과정: 쿼리, 인덱스, 비정규화, Redis (0) | 2026.03.13 |
| 동시성 제어: 트랜잭션 락은 언제 쓰는가 (0) | 2026.03.06 |
| 협업/분리/확장 관점에서 DIP 쓰는 법 (0) | 2026.02.27 |
| 주니어는 모르는 ERD 설계의 함정들 (1) | 2026.02.13 |