지금부터 하는 이야기는 PG 시뮬레이터(요청 성공률 60%, 비동기 처리 성공률 70%, 종합 성공률 42%)를 상대로 커머스 결제 시스템의 장애 대응을 설계한 과정입니다. 실제 PG사의 실패율은 이보다 훨씬 낮지만, 극단적인 환경이 더 좋은 질문을 만들어줬습니다.
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);
}
깔끔해 보인다. 근데 이 코드에 시한폭탄이 두 개 들어있다.
타임아웃 설정이 없으면, PG가 멈출 때 스레드가 영원히 기다린다. Tomcat 스레드 200개가 하나씩 잡혀가다가 결국 상품 조회, 장바구니, 회원 정보 — PG와 아무 상관없는 API까지 응답을 못 한다.
@Transactional이 시작되면 HikariCP에서 DB 커넥션을 가져온다. PG 응답을 기다리는 동안에도 이 커넥션을 쥐고 놓지 않는다.
DB 작업: ~20ms
PG 대기: 100~500ms (타임아웃 시 3초!)
───────────────────
커넥션 점유: 520ms+
HikariCP 기본 풀이 10개다. 동시 결제 10건이면 커넥션이 바닥난다. 그 순간부터 DB를 쓰는 모든 API가 ConnectionTimeout으로 죽는다.
결제 하나 때문에 서비스 전체가 멈추는 거다.
여기서부터 장애 대응을 한 겹씩 쌓아올리기 시작했다.
@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%.
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, 503 | O | PG 서버 일시 장애 |
| 400, 404 | X | 우리 요청이 잘못된 것. 3번 보내도 3번 실패 |
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초씩 잡힌다.
서킷브레이커는 누전 차단기다. 계속 실패하는 요청을 끊어서 시스템을 보호한다.
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 | 새벽에 서킷이 영원히 OPEN | true로 설정 |
slow-call-threshold > readTimeout | 느린 호출 감지 사실상 비활성 | readTimeout보다 작게 |
ghtjr410/resilience4j-lab 레포에서 이 함정들을 테스트 코드로 증명해둔 게 있었다. 공식 문서에 설정은 다 나와있지만, 설정의 조합이 만드는 함정은 직접 겪어봐야 알게 된다.
이 부분에서 꽤 고민했다. 두 어노테이션을 같은 클래스에 붙이면 Aspect 순서가 암묵적이라 의도치 않은 동작이 발생할 수 있다.
다른 수강생의 PR에서 실제로 이 문제가 발생했다. @CircuitBreaker의 fallbackMethod가 원본 예외를 CoreException으로 변환 → @Retry의 ignore-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배 빨리 열린다. 이건 공식 문서에서도 쉽게 놓칠 수 있는 부분이다.
서킷브레이커는 실패율 50% 이상에서 열린다. 그러면 40~49% 구간에서는?
서킷은 닫혀있으니 모든 요청이 PG로 간다. Retry까지 포함하면 스레드 하나당 점유가 길어지고, Tomcat 200개 스레드가 전부 결제에 묶일 수 있다.
resilience4j:
bulkhead:
instances:
pg:
max-concurrent-calls: 20
max-wait-duration: 0 # 초과 시 즉시 거부
PG 동시 호출을 20개로 제한한다. 21번째부터는 보내지도 않고 바로 실패. 나머지 180개 스레드는 상품 조회, 장바구니 등 다른 API가 쓴다.
사실 이게 가장 근본적인 변경이었다. 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());
}
| Before | After | |
|---|---|---|
| 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%는 극단적이다. 실제로는 이 정도가 아닐 거다. 하지만 이 환경에서 시스템을 지탱하는 구조를 만들어봤기 때문에, 진짜 장애가 왔을 때 어디를 봐야 하는지는 알게 됐다.