Contents
see List목표: '타임아웃이 나는 외부 API'를 장애가 아니라 제어 가능한 이벤트로 만들기
서비스가 내부 마이크로서비스뿐 아니라 결제, 검색, 지도, 알림 같은 외부 API에 의존할수록 장애 전파 경로가 급격히 늘어납니다. 네트워크 일시 장애, 상대 서비스 지연, 스로틀링은 결국 사용자가 느끼는 응답 지연으로 이어지기 쉽습니다. 이 문서는 Spring Boot 환경에서 외부 API 호출을 타임아웃, 재시도, 회로차단기(Circuit Breaker)로 구조화해, 장애 범위를 넓히지 않고 서비스 안정성을 확보하는 실무 구성안을 정리합니다.
포인트는 어렵게 가져갈 것이 아니라, 운영에서 바로 적용 가능한 최소 구성을 제시하는 것입니다. 핵심은 성능을 무조건 극대화하는 것이 아니라, 고장 전파를 빠르게 차단하고 복구 동선을 확보하는 데 있습니다. 특히 동시 요청이 늘어나는 시간대에는 1개의 느린 의존성이 스레드 풀과 메모리를 점진적으로 잠식하므로, 연결·읽기 타임아웃, 재시도 정책, 회로차단기 임계값은 반드시 기본값이 아니라 실제 부하 지표 기반으로 조정해야 합니다.
1) WebClient에서 먼저 선제 제한 걸기: 타임아웃과 커넥션 정책
외부 API 호출의 첫 번째 방어선은 타임아웃입니다. 시간 제한이 없는 호출은 내부 스레드/리액티브 체인을 오래 묶어두어 장애가 증식합니다. 아래 예시는 Reactor Netty 기반 WebClient를 구성해 연결 타임아웃, 응답 타임아웃, 소켓 읽기 타임아웃을 분리하고, 쓰기 실패 시 빠르게 에러로 떨어지게 하는 최소 구성입니다.
import io.netty.channel.ChannelOption;
import io.netty.handler.timeout.ReadTimeoutHandler;
import io.netty.handler.timeout.WriteTimeoutHandler;
import java.time.Duration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpHeaders;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.netty.http.client.HttpClient;
import reactor.netty.resources.ConnectionProvider;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
@Configuration
public class OutboundHttpClientConfig {
@Bean
public WebClient partnerApiClient() {
ConnectionProvider provider = ConnectionProvider.builder("partner-pool")
.maxConnections(200)
.pendingAcquireTimeout(Duration.ofSeconds(2))
.maxIdleTime(Duration.ofSeconds(30))
.build();
HttpClient httpClient = HttpClient.create(provider)
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 2500)
.doOnConnected(conn -> conn
.addHandlerLast(new ReadTimeoutHandler(3))
.addHandlerLast(new WriteTimeoutHandler(3))
)
.responseTimeout(Duration.ofSeconds(5));
return WebClient.builder()
.baseUrl("https://api.example.com")
.defaultHeader(HttpHeaders.CONTENT_TYPE, "application/json")
.clientConnector(new ReactorClientHttpConnector(httpClient))
.build();
}
}
이 구성을 실제 운영에서 사용할 때는 pool 크기와 타임아웃을 팀 내 SLA와 의존성 특성에 맞춰야 합니다. 예를 들어 검색 API는 응답이 길게 걸릴 수 있으므로 읽기 타임아웃은 길게 두되, 결제/인증 API는 더 짧게 가져가야 합니다.
2) 재시도는 지능적으로: 즉시 재시도보다 backoff+조건 필터
모든 실패에 무조건 재시도하면 오히려 장애를 키웁니다. 5xx 또는 네트워크 타임아웃처럼 일시 오류에만, 시도 간 간격을 벌리는 backoff 전략을 써야 합니다. 동시에 재시도 횟수가 늘어나는 호출을 로그로 구분해 모니터링해야 원인 분석이 가능합니다.
import reactor.core.Exceptions;
import reactor.core.publisher.Mono;
import reactor.util.retry.Retry;
import java.time.Duration;
public Mono<PartnerResponse> callWithRetry(WebClient webClient) {
return webClient.get()
.uri("/v1/rate")
.retrieve()
.bodyToMono(PartnerResponse.class)
.timeout(Duration.ofSeconds(6))
.retryWhen(
Retry.backoff(2, Duration.ofMillis(200))
.maxBackoff(Duration.ofSeconds(2))
.jitter(0.2)
.filter(throwable ->
throwable instanceof java.net.ConnectException
|| throwable instanceof java.util.concurrent.TimeoutException
|| (throwable instanceof org.springframework.web.reactive.function.client.WebClientResponseException
&& ((org.springframework.web.reactive.function.client.WebClientResponseException) throwable).getStatusCode().is5xxServerError())
)
)
.onErrorMap(ex -> new ExternalApiException("partner-api call failed", ex));
}
주의할 점은 에러 클래스를 명확히 분리해 재시도 실패와 최종 실패 로그를 구분하는 것입니다. 사용자 응답은 실패를 조용히 감추지 말고, 복구 가능한 대안 처리(캐시 응답, 대기열 적재, 비동기 알림)와 연결해야 체감 장애를 줄일 수 있습니다.
3) Circuit Breaker로 연쇄 장애 차단: 임계값의 운영 기준
Resilience4j를 붙이면 특정 의존성이 불안정할 때 자동으로 호출을 차단하고, 정상 복구 전까지 짧은 실패 응답만 반환하도록 제어할 수 있습니다. 아래는 Java에서 바로 붙일 수 있는 최소 예입니다.
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import org.springframework.stereotype.Service;
@Service
public class RateService {
private final WebClient partnerApiClient;
public RateService(WebClient partnerApiClient) {
this.partnerApiClient = partnerApiClient;
}
@CircuitBreaker(name = "partnerApi", fallbackMethod = "fallbackRate")
public Mono<PartnerRate> getRate() {
return partnerApiClient.get()
.uri("/v1/exchange-rate")
.retrieve()
.bodyToMono(PartnerRate.class);
}
private Mono<PartnerRate> fallbackRate(Throwable ex) {
return Mono.just(PartnerRate.ofDefault(1350.0));
}
}
운영 시점에서는 failureRateThreshold(허용 실패율), slowCallRateThreshold(응답 지연 비율), waitDurationInOpenState(회로가 열린 뒤 복구 검사 주기) 값이 핵심입니다. 값은 0~100의 임계 실험만으로 끝내지 말고, p95/p99 응답시간과 동시성 지표를 함께 보면서 조정해야 합니다.
4) 모니터링에 필요한 최소 지표 6개
- 의존성별 호출 성공률(5xx/4xx/timeout 비율)
- 재시도 횟수와 재시도 후 최종 성공률
- 회로차단기 상태(닫힘/반개방/개방) 전환 횟수
- WebClient 커넥션 풀 사용률과 대기 큐 길이
- 호출별 p95·p99·최대 응답시간
- 폴백 응답 비율(기본값 반환, 캐시 응답, 지연 큐 적재 등)
이 지표를 함께 보면 재시도 남용, 임계값 과도 설정, 외부 의존성 회복 지연을 빠르게 구분할 수 있습니다. 특히 폴백 비율이 높아지면 사용자에게는 일부 기능이 느슨해져도 서비스는 유지되므로, 고객 알림 메시지와 영업 KPI에 영향을 줄 수 있는지 팀 합의를 점검하세요.
5) 운영 체크리스트로 마무리
- WebClient의 연결 타임아웃·응답 타임아웃을 API 특성별로 분리했는가?
- 재시도는 예외 유형별로 필터링하고 Backoff가 적용되는가?
- 회로차단기 임계값이 정적 상수로 고정되어 있지 않고 운영 지표로 조정되는가?
- 폴백 경로에 캐시, 대체값, 비동기 처리 등 사용자 안전망이 준비되어 있는가?
- timeout·retry·circuitbreak 상태를 대시보드에서 한 화면에서 추적 가능한가?
- 장애 발생 시 재현 테스트(Chaos)와 복구 drill을 월 1회 이상 수행하는가?
요약하면, Spring에서 외부 API 안정성은 "빠르게 실패하고, 늦게 복구를 시도"가 아니라 제어된 실패를 통해 서비스 운영권을 유지하는 설계입니다. 위 항목을 기본 템플릿처럼 팀 공통으로 두면, 기능 릴리즈 속도는 유지하면서 장애 영향 범위를 크게 줄일 수 있습니다.
spring
| No | 작성일 | Title |
|---|---|---|
| 109 | 2014. 12. 23. | [ spring ] transaction propagation [ 트랜젝션 전파 ] 관리 |
| 103 | 2014. 10. 29. | [ spring ] 스프링에서 스케줄링(Schedule) 등록하는 방법 |
| 44 | 2014. 09. 29. | [ spring security ] 스프링 시큐리티 xml 설정 |
| 43 | 2014. 09. 29. | [ spring security ] 스프링 시큐어리티 access 옵션 |
| 40 | 2014. 09. 29. | [ mybatis ] 마이바티스 map 이나 bean 을 이용하지 않고 2개이상 파라메터 전달하기 |
| 39 | 2014. 09. 26. | [ spring ] view(jsp) 에서 htmlEscape 사용하기 |
| 32 | 2014. 09. 26. | [ spring ] properties 파일 쉽게 사용하기(jsp,javs 소스) |
| 31 | 2014. 09. 25. | [ spring ] 스프링에서 aop(aspectj) 와 트랜젝션 순서 정하기 |