| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | |
| 7 | 8 | 9 | 10 | 11 | 12 | 13 |
| 14 | 15 | 16 | 17 | 18 | 19 | 20 |
| 21 | 22 | 23 | 24 | 25 | 26 | 27 |
| 28 | 29 | 30 |
- 빌더 패턴
- 배치
- 스프링
- redis
- lv1
- 추상클래스
- GoF 23
- 자바
- java
- Spring
- 코드카타
- 토스
- 스케줄러
- 계산기
- 스프링 배치
- 이펙티브 자바
- DB
- 트러블슈팅
- Spring Batch
- 디자인 패턴
- 김영한
- 로드밸런서
- 프록시 패턴
- 템플릿 메서드 패턴
- spring boot
- Til
- Effective Java
- 프로그래머스
- 성능 개선
- 백엔드
- Today
- Total
김코딩
[ 내돈 네돈 챌린지 ] 외부 API 장애 전파 방지: Circuit Breaker & Retry 패턴 본문
들어가며
지금까지의 개선 과정:
1. 트랜잭션 분리로 커넥션 점유 시간 96.6% 감소
2. Saga 패턴으로 데이터 정합성 보장
한가지 문제가 더 남았습니다.
시나리오:
- 토스 API가 느려지거나 장애가 발생한다면?
- 우리 서버의 모든 요청이 토스 API 응답을 기다리며 블로킹
- 결과: 토스의 장애가 우리 시스템 전체로 전파
이번에는 이 부분의 해결 과정을 공유하겠습니다.
1. 문제 상황: 장애 전파
1.1 토스 API 장애 시나리오
// 토스 전용 타임아웃 설정
private ClientHttpRequestFactory createTossRequestFactory() {
SimpleClientHttpRequestFactory factory =
new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(Duration.ofSeconds(5)); // 연결: 5초
factory.setReadTimeout(Duration.ofSeconds(30)); // 응답: 30초 (토스 권장)
return factory;
}
@Override
public PaymentConfirmResponse confirmAndChargePoint(Long userId, PaymentConfirmRequest request) {
userService.getProfile(userId);
// 1. 토스 결제 API 호출
TossConfirmResult tossConfirmResult = tossClient.confirmPayment(
request.getPaymentKey(), request.getOrderId(), request.getAmount());
// 토스 서버 장애 시 여기서 30초 타임아웃 대기
// ... 후처리 과정
}
문제:
- 토스 API 응답 시간: 정상인 경우 -> 500ms, 토스 서버 장애 발생 시(타임 아웃) -> 응답 타임 아웃 최대 30초
- 동시 요청 50개 -> 모두 30초씩 대기
- 스레드 풀 고갈 -> 전체 시스템 응답 불가
1.2 장애 전파 과정

2. 해결 방안: Circuit Breaker(서킷 브레이커) + Retry
2.1 서킷 브레이커 패턴이란?
서킷 브레이커 패턴은 전기 회로의 차단기(Circuit Breaker)에서 착안한 장애 전파 방지 패턴입니다.
집에서 전기 과부하가 발생하면 차단기가 자동으로 전기를 차단해서 집 전체가 화재로 번지는 것을 막아주는 것처럼, 외부 API에 장애가 발생했을 때 자동으로 API 호출을 차단해서 우리 시스템 전체가 장애로 번지는 것을 막아주는 패턴입니다.
2.2 서킷 브레이커 동작 원리

서킷 브레이커의 3가지 상태
실제 회로를 기준으로 전구가 외부 API 또는 Callee에 해당하고, Power Source가 클라이언트(다른 서버를 호출하는 서버) 또는 Caller에 해당한다. 그리고 회로 차단기에는 크게 CLOSED, OPEN, HALF_OPEN 3가지 상태가 존재하는데, 각각의 상태를 정리하면 다음과 같다.
- CLOSED: circuit이 닫힌 상태로, 외부 호출이 정상상태이다.
- OPEN: circuit이 열린 상태로, 오류(실패호출/느린호출)가 발생했을 때 OPEN 상태로 전환된다.
- HALF_OPEN: OPEN 상태가 발생한 이후 일정 시간이 지난 상태이다. 이때 상태가 정상적이라면 CLOSED로, 아니라면 다시 OPEN으로 전환된다.
2.3 Retry 패턴이란?
Retry 패턴은 일시적인 장애가 발생했을 때 작업을 자동으로 재시도하는 패턴입니다.
네트워크는 항상 불안정할 수 있습니다. 외부 API를 호출했는데 네트워크 순간 끊김, 서버 순간적인 과부하, 타임아웃 등으로 실패할 수 있습니다. 하지만 이런 실패가 영구적인 장애가 아니라 일시적인 문제라면, 조금 있다가 다시 시도하면 성공할 가능성이 높습니다.
전략:
- 최대 2번 시도 (원본 1 + 재시도 1)
- 2초 대기 후 재시도 (지수 백오프: 2s, 4s, ...)
- 네트워크 오류만 재시도 (ConnectException, SocketTimeoutException 등)
Retry가 안전한 이유: Idempotency-Key
- 같은 요청을 여러 번 보내도 토스는 한 번만 처리
- 중복 결제 걱정 없음
3. 서킷 브레이커 구현
3.1 Resilience4j 의존성
// Circuit Breaker
implementation 'io.github.resilience4j:resilience4j-spring-boot3:2.2.0'
implementation 'io.github.resilience4j:resilience4j-circuitbreaker:2.2.0'
implementation 'io.github.resilience4j:resilience4j-retry:2.2.0
3.2 설정(application.yml)
# Resilience4j 설정
resilience4j:
circuitbreaker:
instances:
# 토스 환경 최적화 설정
tossPayments:
failure-rate-threshold: 50 # 실패율 50% 이상시 OPEN
minimum-number-of-calls: 10 # 최소 10번 호출 후 판단
wait-duration-in-open-state: 45s # OPEN 상태 45초 유지
permitted-number-of-calls-in-half-open-state: 3 # HALF-OPEN에서 3번 테스트
sliding-window-size: 20 # 최근 20번 호출 기준
sliding-window-type: count_based # 개수 기준
automatic-transition-from-open-to-half-open-enabled: true # 자동으로 HALF-OPEN 전환
retry:
instances:
tossPayments:
max-attempts: 2 # 멱등성 키 덕분에 안전하게 2번 시도
wait-duration: 2s # 2초 대기
exponential-backoff-multiplier: 2 # 지수적 증가 (2s, 4s)
retry-exceptions: # 네트워크 문제만 재시도
- java.net.ConnectException # 연결 실패
- java.net.SocketTimeoutException # 응답 타임아웃
- org.springframework.web.client.ResourceAccessException # 리소스 접근 실패
- java.util.concurrent.TimeoutException # 일반적인 타임아웃
설정
CircuitBreaker:
- 최근 20번 중 10번 이상 호출했을 때
- 실패율이 50% 이상이면 -> OPEN
- 45초 동안 호출 차단 -> HALF_OPEN
- 3번 테스트해서 성공하면 -> CLOSED
Retry:
- 연결 실패, 응답 타임아웃, 리소스 접근 실패, 일반적인 타임아웃 시 2초후 재시도
- 멱등성 키 덕분에 안전하게 재시도 가능
3.3 코드 구현
1. Configuration 설정
@Configuration
public class ResilienceConfig {
@Bean
public CircuitBreakerRegistry circuitBreakerRegistry() {
return CircuitBreakerRegistry.ofDefaults(); // YML 설정 자동 로드
}
@Bean
public RetryRegistry retryRegistry() {
return RetryRegistry.ofDefaults(); // YMl 설정 자동 로드
}
}
2. RestClient 설정
@Component
@RequiredArgsConstructor
@Slf4j
public class TossClientImpl implements TossClient {
@Qualifier("tossRestClient")
private final RestClient tossRestClient;
private final TossPaymentsConfig tossPaymentsConfig;
private final CircuitBreakerRegistry circuitBreakerRegistry;
private final RetryRegistry retryRegistry;
@Override
public TossConfirmResult confirmPayment(String paymentKey, String orderId, int amount) {
CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker(
"tossPayments");
Retry retry = retryRegistry.retry("tossPayments");
// 1. callTossConfirm 메서드를 retry로 감싸서 실행해라
// 2. retry를 circuitBreaker로 감싸서 실행해라
return circuitBreaker.executeSupplier(() ->
retry.executeSupplier(() ->
callTossConfirm(paymentKey, orderId, amount)
)
);
}
private TossConfirmResult callTossConfirm(String paymentKey, String orderId, int amount) {
// 요청 바디 생성
TossConfirmRequest tossConfirmRequest = new TossConfirmRequest(
paymentKey,
orderId,
amount
);
log.info("토스페이먼츠 confirm API 호출 - paymentKey: {}, orderId: {}", paymentKey, orderId);
TossConfirmResponse response = tossRestClient.post()
.uri(tossPaymentsConfig.getBaseUrl() + "/payments/confirm")
.body(tossConfirmRequest)
.header("Authorization", createAuthorizationHeader())
.header("Idempotency-Key", createConfirmIdempotencyKey(orderId))
.contentType(MediaType.APPLICATION_JSON)
.retrieve()
.body(TossConfirmResponse.class);
log.info("토스페이먼츠 confirm API 성공 - status: {}", response.getStatus());
return new TossConfirmResult(
response.getPaymentKey(),
response.getOrderId(),
response.getStatus(),
response.getMethod(),
response.getRequestedAt(),
response.getApprovedAt(),
response.getTotalAmount()
);
}
private String createAuthorizationHeader() {
String credentials = tossPaymentsConfig.getSecretKey() + ":";
String encodedCredentials = Base64.getEncoder()
.encodeToString(credentials.getBytes(StandardCharsets.UTF_8));
return "Basic " + encodedCredentials;
}
private String createConfirmIdempotencyKey(String orderId) {
return "confirm" + orderId;
}
}
실행 순서:
1. callTossConfirm() 호출
2. 실패 시 -> Retry가 2초 후 재시도
3. 재시도도 실패 -> CircuitBreaker가 실패 카운트
4. 실패율 50% 초과 -> OPEN(이후 호출 즉시 차단)
4. 서킷 브레이커 동작 확인
4.1 MockTossClient를 통한 장애 시뮬레이션
실제 토스 결제를 통해서는 부하테스트를 진행할 수 없으므로 서킷 브레이커를 실행시키기 위해서 MockTossClient를 통해서 예외 상황을 발생시켰습니다.
@Override
public TossConfirmResult confirmPayment(String paymentKey, String orderId, int amount) {
CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker("tossPayments");
Retry retry = retryRegistry.retry("tossPayments");
// Circuit Breaker + Retry 적용
return circuitBreaker.executeSupplier(() ->
retry.executeSupplier(() ->
callMockConfirm(paymentKey, orderId, amount)
)
);
}
private TossConfirmResult callMockConfirm(String paymentKey, String orderId, int amount) {
log.info("[MOCK] 토스 결제 승인 - orderId: {}, amount: {}", orderId, amount);
// 테스트: 100% 확률로 실패
if (true) {
log.error("[MOCK] 토스 API 타임아웃 시뮬레이션");
throw new RestClientException("토스 API 타임아웃");
}
// 지연 시뮬레이션 (500-600ms)
try {
Thread.sleep(ThreadLocalRandom.current().nextLong(500, 600));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
log.info("[MOCK] 토스 결제 승인 성공");
return new TossConfirmResult(
paymentKey,
orderId,
"DONE",
"카드",
OffsetDateTime.now(),
OffsetDateTime.now(),
amount
);
}
4.2 Circuit Breaker를 적용하지 않았을 때(모두 실패하는 경우)

설정:
- 30명의 유저가 5초동안 1번씩 요청(총 요청 30개)
- 예상 -> 모두 30초씩 기다리고 실패 응답을 받는다

테스트 결과:
- 예상대로 모두 30초 이상 기다리고 실패 응답을 받았다(타임아웃 30초 설정)
4.3 Circuit Breaker를 적용하였을 때(모두 실패하는 경우)

설정:
- 30명의 유저가 30초동안 2번의 요청(서킷 브레이커는 새로운 요청만 차단하므로 Loop Count 2로 설정)
- 예상 -> 서킷브레이커가 오픈되기 전에는 30초씩 기다리다가 서킷브레이커가 오픈되고나서 즉시 실패 응답 반환

테스트 결과:
- 서킷 브레이커가 열리기 전에는 max 값으로 31838ms가 나왔다.
- 서킷 브레이커가 열린 후에는 13ms로 빠른 실패 응답을 전달해주었다.
| 서킷 브레이커 적용 전 | 서킷 브레이커 적용 후 | |
| 응답 속도 | 31초(31000ms) | 13ms |
마치며
드디어 결제 시스템에 대한 장애 대응 시리즈가 모두 끝났습니다.
1. 트랜잭션 경계 재설계를 통한 DB 커넥션 점유 시간 감소
2. Saga 패턴을 통한 보상 트랜잭션
3. Circuit Breaker를 통한 외부 API 장애 대응
쉽지 않은 모험이었지만 한층 더 성장했다고 생각이 듭니다.
'개발팁' 카테고리의 다른 글
| [ FINSight ] AI 요약 API 재설계를 통한 요약 비용 100배 절감 (0) | 2025.12.02 |
|---|---|
| [ FINSight ] 단일 서버 환경에서 JWT 대신 Session을 선택한 이유 (0) | 2025.12.01 |
| [ 내돈 네돈 챌린지 ] 결제 시스템 데이터 정합성 보장: Saga 패턴 구현기 (0) | 2025.11.27 |
| [ 내돈 네돈 챌린지 ] 결제 API 성능 개선기: 트랜잭션 경계 재설계로 DB 커넥션 점유 시간 96% 단축 (0) | 2025.11.27 |
| 인덱스(Index)란 무엇인가? (0) | 2025.11.25 |