Intro
tl;dr PG 연결 실패(ConnectException)만 골라서 재시도하고, 서킷브레이커로 장애 전파를 차단한 뒤, k6 부하 테스트로 CLOSED→OPEN→HALF_OPEN→CLOSED 전체 라이프사이클 돌리기@

본론
1. PG 장애 하나로 결제가 멈춰버렸다?
결제 시스템을 구현하면서 가장 먼저 부딪힌 문제는 PG사 API가 실패하는 방식이 하나가 아니라는 것이었습니다.
- 연결 불가 (ConnectException) — PG 서버 자체가 죽었거나 네트워크가 끊겼을 때
- 응답 지연 (ReadTimeout) — PG 서버가 요청을 받았지만 처리가 늦어질 때
- 5xx 에러 — PG 서버가 요청을 받았지만 내부 오류로 실패했을 때
내부 서비스 간 통신이라면 "실패하면 재시도"로 퉁칠 수 있습니다. 하지만 결제는 다릅니다.
ReadTimeout이 발생했다고 가정해보겠습니다. 클라이언트 입장에서는 타임아웃이지만, PG 서버는 이미 요청을 받아서 카드사에 승인을 요청하고 있을 수 있습니다.
...이 상태에서 재시도하면?
[최초 요청] → PG 서버 수신 → 카드사 승인 처리 중... → ReadTimeout (클라이언트)
[재시도 1] → PG 서버 수신 → 새 transactionKey 발급 → 카드사 승인
[재시도 2] → PG 서버 수신 → 새 transactionKey 발급 → 카드사 승인
↑ 한 건의 주문에 3건의 결제가 발생
PG 시뮬레이터가 멱등키를 지원하지 않는 환경에서, 매 요청마다 새로운 transactionKey가 발급됩니다.
재시도 = 이중 결제인 거죠.
2. 왜 모든 예외를 재시도하면 안 되는가
ConnectException과 ReadTimeout의 근본적 차이에 있습니다.
| 예외 | TCP 핸드셰이크 | 서버에 요청 도달 | 재시도 안정성 |
| ConnectException | 실패 | X (미도달) | 안전 - 서버가 요청을 본 적 없음 |
| ReadTimeout | 성공 | O (처리 중일 수 있음) | 위험 - 이중 결제 가능 |
ConnectException은 TCP 핸드셰이크 자체가 실패한 것이니, 서버에 요청이 도달한 적이 없습니다. 재시도해도 안전합니다. 반면 ReadTimeout은 연결은 성공했고 서버가 요청을 수신한 상태입니다. 서버 내부에서 무슨 일이 벌어지고 있는지 클라이언트는 알 수 없죠.
안티패턴 시연: naive retry
이 차이를 테스트로 확인해봅시다.
@Test
@DisplayName("안티패턴: ReadTimeout 후 무작정 재시도 → 이중 결제 발생")
void naiveRetry_onReadTimeout_causesDuplicatePayment() {
AtomicInteger pgCallCount = new AtomicInteger(0);
Runnable pgSimulator = pgCallCount::incrementAndGet;
// naive retry: 예외 타입 구분 없이 모든 예외에 재시도
int naiveMaxRetries = 3;
for (int i = 0; i < naiveMaxRetries; i++) {
pgSimulator.run(); // 매번 PG에 새 결제 요청 도달
}
// PG에 3건 도달 → 서로 다른 transactionKey → 이중 결제
assertThat(pgCallCount.get()).isEqualTo(3);
}
PG에 3번 요청이 도달하고, 각각 다른 `transactionKey`가 발급됩니다. 고객 카드에서 3번 빠져나가는 거죠.
선택적 재시도: ConnectExceptionPredicate
해결 방법은 재시도 가능한 예외만 골라내는 판별기(Predicate)를 만드는 것이었습니다.
public class ConnectExceptionPredicate implements Predicate<Throwable> {
@Override
public boolean test(Throwable throwable) {
Throwable cause = throwable;
while (cause != null) {
if (cause instanceof ConnectException) {
return true;
}
cause = cause.getCause();
}
return false;
}
}
왜 instanceof를 한 번만 확인하지 않고 while 루프로 cause chain을 순회할까요? Feign이 예외를 감싸기 때문입니다.
실제로 발생하는 예외 구조는 이렇습니다.
RetryableException
└─ FeignException
└─ ConnectException ← 여기까지 파고들어야 찾을 수 있음
throwable instanceof ConnectException으로는 절대 잡히지 않습니다. cause chain을 끝까지 순회해야 원인 예외를 찾을 수 있어요.
4xx vs 5xx 에러 처리
PG 서버가 응답을 준 경우에도 상태 코드에 따라 처리가 달라야 합니다.
public class PgFeignErrorDecoder implements ErrorDecoder {
@Override
public Exception decode(String methodKey, Response response) {
int status = response.status();
if (status >= 400 && status < 500) {
// 4xx: 클라이언트 오류 → 재시도해도 같은 결과, CB에도 기록하지 않음
return new CoreException(ErrorType.BAD_REQUEST, ...);
}
if (status >= 500) {
// 5xx: 서버 일시 오류 → 재시도 대상, CB에 실패로 기록
return new RetryableException(status, ...);
}
return defaultDecoder.decode(methodKey, response);
}
}
| 예외 | 서버 도달 | 재시도 | CB 기록 | 근거 |
| ConnectException | X | O (안전) | 실패 | 요청 미도달, 재시도해도 부작용 없음 |
| ReadTimeout | O (처리 중) | X (위험) | 실패 | 서버가 이미 처리 중일 수 있음 |
| 4xx | O | X | 무시 | 클라이언트 오류, 재시도해도 동일한 결과 |
| 5xx | O | O | 실패 | 서버 일시 오류, 재시도로 복구 가능 |
resilience4j:
retry:
instances:
pg-payment-request:
max-attempts: 3
wait-duration: 500ms
retry-exception-predicate: com.xxx.infrastructure.pg.ConnectExceptionPredicate
ignore-exceptions:
- com.xxx.support.error.CoreException # 4xx → 재시도 안 함
retry-exception-predicate에 ConnectExceptionPredicate를 지정하면, 이 Predicate가 true를 반환하는 예외(ConnectException)만 재시도합니다. CoreException(4xx에서 변환)은 ignore-exceptions로 아예 재시도 대상에서 제외했습니다.
3. 서킷브레이커 상태 전이 - k6 + Grafana


선택적 재시도로 이중 결제는 막았지만, PG 서버가 아예 죽어있는 상황에서는 재시도 자체가 무의미합니다. 매 요청마다 ConnectException → 500ms 대기 → 재시도 → ConnectException → 500ms 대기 → 재시도를 반복하면서 Tomcat 스레드만 잡아먹게 되죠. 서킷브레이커는 이런 상황에서 "어차피 실패할 호출은 아예 시도하지 않겠다"는 판단을 내려주는 역할을 맡습니다.
그래서 코드에 PgClientAdapter에 @CircuitBreaker와 @Retry를 함께 부착했습니다.
@CircuitBreaker(name = "pg-client")
@Retry(name = "pg-payment-request", fallbackMethod = "requestPaymentFallback")
public PgPaymentResult requestPayment(PgPaymentCommand command) { ... }
Resilience4j의 기본 우선순위에서 Retry는 CircuitBreaker보다 바깥에 위치합니다.
[Retry] ← 가장 바깥: 실패 시 재시도 판단
└─ [CircuitBreaker] ← 중간: OPEN이면 즉시 차단
└─ [Feign 호출] ← 가장 안쪽: 실제 HTTP 요청
이 순서가 중요합니다. Feign 호출이 실패하면 CB가 먼저 그 실패를 슬라이딩 윈도우에 기록하고, 그 다음 Retry가 재시도 여부를 판단합니다. 만약 Retry가 안쪽이고 CB가 바깥이면, Retry가 3번 다 시도한 뒤에야 CB에 "1건 실패"로 기록됩니다. 실제로는 3번 실패했는데 CB는 1번만 본 셈이니, 장애 감지가 3배 느려지겠죠.
설정을 다 했으니 실제로 동작하는지 확인해야 합니다. k6 부하 테스트로 PG Mock 서버를 기동/중지하며 CB의 전체 라이프사이클을 재현했습니다.
CB 설정
| 항목 | 값 | 의미 |
| sliding-window-type | COUNT_BASED | 최근 N회 호출 기반 |
| sliding-window-size | 10 | 최근 10회 호출을 기록 |
| minimum-number-of-calls | 5 | 5회부터 실패율 평가 시작 |
| failure-rate-threshold | 50% | 50% 초과 시 OPEN |
| wait-duration-in-open-state | 10s | OPEN 상태 유지 시간 |
| permitted-number-of-calls-in-half-open-state | 3 | HALF_OPEN에서 시험 호출 수 |
여기서 slidingWindowSize와 minimumNumberOfCalls의 차이를 이해하는 게 중요했습니다.
처음에는 slidingWindowSize=10이면 "10번 호출이 쌓여야 평가가 시작되겠지"라고 생각했습니다. 하지만 minimumNumberOfCalls=5를 별도로 설정하면, 5번째 호출부터 실패율 평가가 시작됩니다. PG가 죽은 뒤 5번 연속 실패하면 5/5 = 100% > 50%로 즉시 OPEN이 됩니다.
만약 minimumNumberOfCalls 없이 slidingWindowSize=10만 설정했다면, 10번의 호출이 모두 채워질 때까지 실패율 평가가 시작되지 않아 장애 감지가 느려졌을 겁니다.
시뮬레이션 타임라인

Phase 1: PG 정상 — CB CLOSED (T+0 ~ T+17s)
PG Mock 서버가 정상 가동 중입니다. 모든 결제 요청이 PG까지 도달해서 성공 응답을 받습니다.
- 응답 시간: 200~600ms (PG 시뮬레이터의 100~300ms 인위적 지연 + Spring 처리)
- CB 상태: CLOSED (정상)
Phase 2: PG 장애 — CB OPEN (T+17s ~ T+36s)
T+15s에 PG Mock 프로세스를 강제 종료합니다. 이후 들어오는 결제 요청은 전부 ConnectException이 발생합니다.
- T+17s: CB가 CLOSED → OPEN으로 전이 (PG 중지 2초 만에 감지!)
- minimumNumberOfCalls(5) 충족 후 failureRate 100% > 50% → OPEN - OPEN 상태에서는 PG 호출 없이 즉시 Fallback을 반환합니다
- 응답 시간: ~90ms (DB 쓰기 + Fallback 반환, PG 호출 없음)
252ms → 90ms, 64% 응답 시간 단축. PG가 죽었는데 오히려 더 빨라진 겁니다.
CB가 없었다면? 모든 요청이 PG에 ConnectException을 던지고, 재시도까지 3번씩 시도하고, 그 동안 Tomcat 스레드를 점유하고... 장애가 우리 서비스까지 전파됐을 겁니다.
Phase 3: PG 복구 — HALF_OPEN → CLOSED (T+36s ~)
T+30s에 PG Mock 서버를 재시작합니다. 하지만 CB는 아직 OPEN입니다.
- T+35s: waitDurationInOpenState(10s)가 경과하면 CB가 OPEN → HALF_OPEN으로 전이
- HALF_OPEN에서 3건의 시험 호출(permittedNumberOfCallsInHalfOpenState)을 PG에 보냄
- 3건 모두 성공 → failureRate = 0% < 50% → HALF_OPEN → CLOSED
수동 개입 없이 자동으로 복구를 감지했습니다.
k6 통계
cb_open_responses..............: 90 0.707/s
cb_recovery_responses..........: 270 2.121/s
payment_latency................: avg=252.72ms min=77ms med=267.5ms max=609ms
http_req_failed................: 0.00% 0 out of 867
iterations.....................: 360 2.828/s
867건의 요청 중 HTTP 실패율 0%. PG가 죽었던 구간에서도 사용자는 200 OK를 받았습니다. 다만 응답 내용이 "결제 확인 중(PENDING)"이었을 뿐.
| CB 상태 | 평균 응답 시간 | PG 호출 | 사용자 체감 |
| CLOSED (정상) | 252ms | O | 즉시 결제 완료 |
| OPEN (장애) | ~90ms | X (즉시 Fallback) | "결제 확인 중" |
| CLOSED (복구) | 252ms | O | 즉시 결제 완료 |
4. Fallback - PG가 실패해도 결제는 멈추지 않는다
CB가 OPEN이거나 재시도가 모두 실패하면, Fallback이 실행됩니다.
private PgPaymentResult requestPaymentFallback(PgPaymentCommand command, Throwable t) {
log.warn("PG 결제 요청 최종 실패, Fallback: orderId={}, error={}",
command.orderId(), t.getMessage());
return PgPaymentResult.fallback("PG 응답 지연 — 결제 확인 중: " + t.getMessage());
}
예외를 던지는 대신 PENDING 상태의 정상 응답을 반환합니다. 사용자는 500 에러 대신 "결제 확인 중" 화면을 보게 되고, PG가 복구되면 콜백으로 최종 상태가 확정됩니다.
이 설계가 가능한 건 결제 요청 시점에 이미 PENDING 상태의 Payment를 DB에 저장해뒀기 때문입니다. PG 호출은 트랜잭션 밖에서 수행되므로, PG가 실패해도 Payment 레코드는 살아있습니다.
CB에 기록할 예외와 무시할 예외
resilience4j:
circuitbreaker:
instances:
pg-client:
record-exceptions: # CB 실패로 기록
- java.io.IOException # ConnectException 포함
- java.util.concurrent.TimeoutException
- feign.RetryableException # 5xx에서 변환
ignore-exceptions: # CB에서 완전히 무시
- com.loopers.support.error.CoreException # 4xx에서 변환
4xx 에러(`CoreException`)는 클라이언트가 잘못된 요청을 보낸 것이지 PG 서버 장애가 아닙니다. 이걸 CB 실패로 기록하면, 잘못된 카드번호를 입력한 사용자가 많을 때 CB가 열려버리는 어이없는 상황이 발생할 수 있어요. 그래서 ignore-exceptions로 CB 집계에서 제외했습니다.
PENDING 잔류 보정
콜백이 유실되면 Payment가 PENDING 상태로 남습니다. 이를 위해 동기화 API를 만들었습니다.
POST /api/v1/payments/sync
→ PENDING 상태의 Payment 목록 조회
→ 각 건별로 PG에 상태 조회 (getPaymentStatus)
→ SUCCESS/FAIL로 상태 갱신
getPaymentStatus는 조회 API이므로 멱등합니다. 여기서는 ConnectExceptionPredicate 대신 IOException, TimeoutException을 포함한 일반적인 재시도 정책을 적용했습니다. 조회는 몇 번을 재시도해도 부작용이 없으니까요!
pg-payment-status:
max-attempts: 3
wait-duration: 1s
exponential-backoff-multiplier: 2 # 1s → 2s → 4s
retry-exceptions:
- java.io.IOException
- java.util.concurrent.TimeoutException
- feign.RetryableException
P.S.
ReadTimeout Fallback은 데이터 정합성 관점에서는 안전하지만, UX 관점에서는 아쉬운 부분이 있습니다. 사용자는 "결제 확인 중" 화면에서 PG 콜백이 올 때까지 기다려야 합니다. PENDING 체류 시간이 길어지면 사용자 이탈로 이어질 수 있고, 이 한계치를 어떻게 산정할지는 좀 더 고민해야겠죠. 정책을 어떻게 하느냐가 중요한 것 같습니다.
PG사가 멱등키(Idempotency Key)를 지원한다면, 같은 멱등키로 재요청했을 때 중복 결제 없이 기존 결과를 반환받을 수 있습니다. 이 경우 ReadTimeout도 안전하게 재시도할 수 있어서, PENDING 체류 시간을 대폭 줄일 수 있을 겁니다.
이번 작업을 하면서 가장 크게 배운 건, 서킷브레이커의 역할이 장애를 막는 것이 아니라 장애가 전파되지 않도록 격리하는 것이라는 점이었습니다. PG가 죽으면 결제는 실패합니다! 그건 어쩔 수 없어요. 하지만 PG 장애 때문에 우리 서비스의 Tomcat 스레드가 고갈되고, 상품 조회나 주문 목록 같은 멀쩡한 API까지 먹통이 되는 건 막을 수 있습니다.
Fail Fast..