이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- Spring 백엔드에서 timeout, connection pool, retry, circuit breaker를 어디에 명시해야 하는가?
- WebClient, RestClient, Resilience4j, Actuator 설정을 운영 관측과 어떻게 연결하는가?
- 설정값을 바꿀 때 어떤 테스트와 지표를 확인해야 하는가?
개요
Spring 애플리케이션에서 resilience는 라이브러리를 추가하는 것으로 끝나지 않는다. HTTP client timeout, connection pool, retry, circuit breaker, bulkhead, fallback, metric, alert가 함께 맞아야 운영에서 의미가 있다.
가장 위험한 상태는 기본값에 의존하는 것이다. 개발 환경에서는 빠르게 실패했지만 운영에서는 무한정 기다리거나, retry는 동작하지만 attempt metric이 없어 장애를 키우는지 줄이는지 모르는 일이 생긴다. 설정은 코드와 문서에 명시되어야 하고, 설정의 효과는 지표로 확인되어야 한다.
왜 백엔드 개발자가 알아야 하는가
Spring은 추상화를 잘 제공하지만, 네트워크 장애는 추상화 아래의 timeout과 pool에서 발생한다.
RestClient나WebClient를 만들었지만 connect/read timeout을 명시하지 않았다.- 모든 외부 API가 같은
WebClient.Builder와 같은 pool을 공유한다. - Resilience4j annotation은 붙였지만 actuator metric을 보지 않는다.
- retry와 circuit breaker 순서를 이해하지 못해 실패가 예상과 다르게 처리된다.
- 운영 profile과 test profile의 timeout 값이 달라 장애 재현이 어렵다.
전문 백엔드 개발자는 “설정했다”에서 멈추지 않고 “어떤 dependency에 어떤 정책이 적용되고, 어떤 metric으로 확인되는가”까지 설명할 수 있어야 한다.
요청 흐름에서의 위치
Spring resilience 설정은 outbound dependency 호출 경계에 둔다.
Controller
→ Service
→ dependency-specific client bean
→ timeout / pool
→ retry / circuit breaker / bulkhead
→ external API
→ metric / trace / log중요한 것은 dependency별 client bean과 정책을 분리하는 것이다. 결제 API와 추천 API가 같은 timeout, 같은 pool, 같은 fallback을 가져야 할 이유는 거의 없다.
원리
Spring에서 resilience 설정은 네 층으로 나누어 보는 편이 좋다.
| 층 | 예시 | 확인할 것 |
|---|---|---|
| HTTP client | WebClient, RestClient, Reactor Netty, Apache HC | connect/read/pool/idle timeout |
| Resilience | Resilience4j Retry, CircuitBreaker, Bulkhead, TimeLimiter | threshold, attempts, wait, rejected count |
| Observability | Micrometer, Actuator, OpenTelemetry | metric name, tag, dashboard, trace span |
| Configuration | application.yml, profile, config server | 환경별 값, 변경 이력, rollback |
이 중 하나라도 빠지면 운영 판단이 어려워진다. 예를 들어 retry를 설정했지만 metric이 없으면 retry가 성공률을 높이는지 downstream을 압박하는지 알 수 없다.
WebClient 설정 예시
외부 API별로 connection provider와 timeout을 분리한다.
@Bean
WebClient paymentClient(WebClient.Builder builder) {
ConnectionProvider provider = ConnectionProvider.builder("payment-pool")
.maxConnections(50)
.pendingAcquireTimeout(Duration.ofMillis(500))
.maxIdleTime(Duration.ofSeconds(20))
.maxLifeTime(Duration.ofMinutes(5))
.build();
HttpClient httpClient = HttpClient.create(provider)
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 2_000)
.responseTimeout(Duration.ofSeconds(3))
.doOnConnected(conn -> conn
.addHandlerLast(new ReadTimeoutHandler(3))
.addHandlerLast(new WriteTimeoutHandler(3)));
return builder
.baseUrl("https://payment.example.com")
.clientConnector(new ReactorClientHttpConnector(httpClient))
.defaultHeader("User-Agent", "order-service")
.build();
}확인할 것은 값 자체보다 분리다.
payment-pool처럼 dependency별 이름을 둔다.- pool acquire timeout과 connect timeout과 response timeout을 구분한다.
- max idle time은 LB/proxy idle timeout보다 짧게 맞춘다.
- 이 client의 metric이 dependency 이름으로 구분되는지 본다.
RestClient 설정 예시
Spring MVC 기반 서비스에서 blocking client를 쓴다면 request factory에 timeout을 명시한다.
@Bean
RestClient inventoryRestClient(RestClient.Builder builder) {
SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
requestFactory.setConnectTimeout(Duration.ofSeconds(2));
requestFactory.setReadTimeout(Duration.ofSeconds(3));
return builder
.baseUrl("https://inventory.example.com")
.requestFactory(requestFactory)
.defaultHeader("User-Agent", "order-service")
.build();
}Apache HttpClient 같은 pool 기반 factory를 쓰면 connection pool, idle eviction, per-route max도 함께 명시해야 한다.
Resilience4j 설정 예시
운영에서는 코드보다 application.yml로 dependency별 정책을 드러내는 편이 추적하기 쉽다.
resilience4j:
retry:
instances:
payment:
max-attempts: 2
wait-duration: 150ms
retry-exceptions:
- java.io.IOException
- java.util.concurrent.TimeoutException
circuitbreaker:
instances:
payment:
sliding-window-size: 50
minimum-number-of-calls: 20
failure-rate-threshold: 50
slow-call-rate-threshold: 50
slow-call-duration-threshold: 1s
wait-duration-in-open-state: 10s
permitted-number-of-calls-in-half-open-state: 5
bulkhead:
instances:
payment:
max-concurrent-calls: 30
max-wait-duration: 100ms이 값들은 payment dependency에 맞춘 예시다. 추천 API, 알림 API, 배송 API에는 다른 값이 필요할 수 있다.
실무에서 자주 만나는 문제
WebClient.Builder하나로 모든 dependency를 호출한다.- pool과 timeout이 섞여 장애 격리가 어렵다.
- dependency별 client bean을 만든다.
- annotation을 붙이고 metric을 확인하지 않는다.
- breaker가 열렸는지, bulkhead가 reject했는지 모른다.
- Actuator와 Micrometer metric을 dashboard에 올린다.
- test에서 mock이 즉시 응답해 timeout 경로를 검증하지 않는다.
- 운영 장애는 느린 응답과 partial failure에서 나온다.
- delay, reset, 500, 429, slow body를 테스트한다.
- 설정을 profile별로 흩어놓고 의미를 문서화하지 않는다.
- 운영 값이 왜 그런지 아무도 모르게 된다.
- dependency별 timeout inventory를 둔다.
디버깅 방법
운영에서 먼저 확인할 것은 “정책이 실제로 적용되었는가”다.
# Actuator metric 이름 확인
curl -s http://localhost:8080/actuator/metrics | jq '.names[]' \
| grep -E 'resilience4j|http|reactor|tomcat|hikaricp'
# 특정 circuit breaker metric 확인 예시
curl -s http://localhost:8080/actuator/metrics/resilience4j.circuitbreaker.state확인할 항목은 다음이다.
- dependency별 client bean이 사용되는가?
- connection pool metric이 dependency 이름으로 구분되는가?
- retry attempt와 exhausted count가 보이는가?
- breaker state transition이 기록되는가?
- bulkhead rejected count가 알림으로 이어지는가?
- timeout exception이 connect/read/pool acquire로 구분되는가?
테스트에서는 실패 모드를 의도적으로 만든다.
dependency returns 503
dependency delays first byte
dependency accepts connection but never responds
dependency returns 429 with Retry-After
dependency succeeds after first timeout이 시나리오마다 retry, breaker, fallback, idempotency가 기대대로 동작하는지 확인한다.
코드로 확인하기
서비스 코드는 외부 호출 경계를 명확히 보여야 한다.
@Service
public class PaymentGateway {
private final WebClient paymentClient;
public PaymentGateway(WebClient paymentClient) {
this.paymentClient = paymentClient;
}
@CircuitBreaker(name = "payment", fallbackMethod = "paymentUnavailable")
@Bulkhead(name = "payment")
@Retry(name = "payment")
public Mono<PaymentResult> approve(PaymentCommand command) {
return paymentClient.post()
.uri("/payments")
.header("Idempotency-Key", command.idempotencyKey())
.bodyValue(command)
.retrieve()
.bodyToMono(PaymentResult.class);
}
private Mono<PaymentResult> paymentUnavailable(PaymentCommand command, Throwable cause) {
return Mono.error(new PaymentTemporarilyUnavailableException(cause));
}
}이 예시에서는 fallback을 성공처럼 꾸미지 않는다. 결제는 critical dependency이므로 “대체 성공”보다 명확한 실패와 상태 조회가 더 안전하다.
주니어가 자주 하는 오해
- Resilience4j annotation만 붙이면 안정성이 생긴다고 생각한다.
- timeout, pool, metric, fallback 정책이 없으면 반쪽짜리다.
- 모든 외부 API에 같은 설정을 복사한다.
- dependency별 중요도, latency, rate limit, idempotency가 다르다.
- 테스트에서 500만 확인하면 충분하다고 생각한다.
- timeout, slow response, connection reset, 429, half-open 회복을 봐야 한다.
- metric은 나중에 붙여도 된다고 생각한다.
- resilience 설정은 metric이 없으면 운영에서 맞았는지 틀렸는지 알 수 없다.
시니어의 설계 판단 기준
- dependency별 client, timeout, pool, retry, breaker, fallback을 하나의 표로 관리한다.
- 설정값 변경은 SLO, error budget, downstream capacity, 사용자 경험을 기준으로 리뷰한다.
- resilience 정책은 코드 리뷰에서 “최악의 점유 시간”과 “중복 처리 가능성”까지 본다.
- critical dependency는 fallback보다 명확한 실패와 reconciliation을 우선한다.
- optional dependency는 fallback과 degrade mode를 미리 설계한다.
인프라 협업 포인트
- Service Mesh나 Gateway retry/breaker와 앱 Resilience4j 설정이 중복되지 않는지 맞춘다.
- LB/proxy timeout과 Spring HTTP client timeout의 순서를 함께 표로 만든다.
- metric 이름, tag, dashboard owner를 인프라/플랫폼 팀과 맞춘다.
- config 변경이 즉시 반영되는지, 재시작이 필요한지, rollback 방법은 무엇인지 확인한다.
실전 팁
- 개인 프로젝트라도 외부 API client를 하나 만들 때 connect/read/pool timeout을 명시한다.
- 운영에서는 resilience 설정 변경 전후로 p95/p99, retry count, breaker state, downstream error rate를 비교한다.
- dependency 장애 주입 테스트를 CI나 staging smoke test에 넣는다.
- fallback 응답은 사용자에게 제한을 드러내고, metric에는 fallback reason을 남긴다.
- 설정값은 코드 주석보다 runbook과 dashboard에 연결한다.
위험 신호!
- HTTP client timeout이 기본값이다.
- 모든 dependency가 같은 WebClient와 같은 pool을 공유한다.
- Resilience4j metric이 Actuator에 노출되지 않는다.
- retry와 breaker 설정은 있는데 idempotency 정책이 없다.
- fallback이 성공 응답과 구분되지 않는다.
확인 질문
확인 질문
- Spring에서 dependency별 WebClient를 분리하는 이유는 무엇인가?
- pool, timeout, metric, retry, breaker를 dependency 특성에 맞추고 장애 전파를 줄이기 위해서다.
- resilience 설정을 운영에서 확인하려면 무엇이 필요한가?
- retry attempt, breaker state, bulkhead rejected, timeout 종류, pool 상태 같은 metric과 로그가 필요하다.
- critical dependency의 fallback은 왜 조심해야 하는가?
- 결제나 주문처럼 정확성이 중요한 작업에서 대체 성공을 주면 실제 상태와 사용자 인식이 어긋날 수 있기 때문이다.