분산 시스템에서 "실패하지 않는 서비스"는 존재하지 않는다. 우리가 만드는 모든 API는 항상 다음 네 가지 실패 모델에 노출되어 있다.
실패는 "예외 케이스"가 아니라 "항상 일정 확률로 일어나는 사건"이다. 시니어 백엔드 엔지니어의 역할은 "실패가 발생했을 때 전파를 어디에서 끊을 것인가" 를 설계하는 것이다. 한 다운스트림의 지연이 내 스레드풀을 다 먹어치우고, 그것이 업스트림의 SLA를 깨뜨리고, 결국 전체 플랫폼이 시나리오 그대로 죽는 cascading failure를 막는 것이 핵심이다.
면접에서 "외부 API가 느려지면 어떻게 대응하시나요?" 라는 질문은 사실상 Timeout → Retry → Circuit Breaker → Bulkhead → Fallback → Backpressure → Graceful Shutdown 의 스택을 차례로 이해하고 있느냐는 질문이다. 이 문서는 그 전체 스택을 실행 가능한 수준으로 정리한다.
| 계층 | 목적 | 실패 시 효과 |
|---|---|---|
| Timeout | 응답이 오지 않는 호출을 일정 시간 후 포기 | 스레드 / 커넥션 반납, 자원 회수 |
| Retry | 일시적 실패를 자동 재시도 | 가용성 향상 (단, 폭주 위험) |
| Circuit Breaker | 지속적으로 실패하는 대상을 빠르게 차단 | 빠른 실패 + 복구 프로브 |
| Bulkhead | 자원을 격리해서 한 영역의 고갈이 다른 영역을 못 건드리게 | 부분 실패로 제한 |
| Fallback | 실패 시 대체 응답 제공 | 사용자 경험 유지 |
| Backpressure | 유입 속도를 처리 속도에 맞춤 | 큐 폭주 방지 |
| Graceful Shutdown | 배포·축소 시 in-flight 보존 | 5xx 최소화 |
이 스택은 "아무거나 다 붙이면 된다" 가 아니라 "계층적 조합을 잘못 짜면 오히려 장애를 확대시킨다" 는 점이 중요하다. 특히 Timeout과 Retry는 Circuit Breaker 없이 붙이면 재시도 폭주(retry storm)를 유발한다.
Timeout이 없는 호출은 resilience 전략의 대상이 될 수 없다. "언제 실패로 간주할 것인가" 가 정의되지 않았기 때문이다.
Timeout은 보통 세 계층으로 구분한다.
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(2))
.version(HttpClient.Version.HTTP_2)
.build();
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create("https://inventory.internal/api/v1/stock"))
.timeout(Duration.ofSeconds(1)) // read + 전체 응답 대기
.GET()
.build();
JDK HttpClient는 connectTimeout과 request의 timeout만 제공한다. Read timeout을 따로 세밀 조정하고 싶다면 Netty 기반 클라이언트(Reactor Netty, OkHttp)를 쓴다.
spring:
datasource:
hikari:
connection-timeout: 3000 # 풀에서 커넥션 얻기까지 대기 (ms)
validation-timeout: 1000
max-lifetime: 1800000
idle-timeout: 600000
maximum-pool-size: 20
추가로 JDBC URL 옵션에 socket timeout을 반드시 걸어준다. MySQL 기준:
jdbc:mysql://db:3306/app?connectTimeout=2000&socketTimeout=3000&useSSL=true&serverTimezone=Asia/Seoul
socketTimeout이 없으면 네트워크 장애 시 커넥션이 영원히 블로킹되어 커넥션풀이 즉시 고갈된다. 장애 사례 중 가장 흔한 패턴이다.
ClientOptions options = ClientOptions.builder()
.timeoutOptions(TimeoutOptions.enabled(Duration.ofMillis(200)))
.socketOptions(SocketOptions.builder()
.connectTimeout(Duration.ofSeconds(1))
.keepAlive(true)
.build())
.disconnectedBehavior(DisconnectedBehavior.REJECT_COMMANDS)
.build();
Redis는 응답이 매우 빠르기 때문에 timeout을 짧게(수십~수백 ms) 잡아야 한다. 길게 잡으면 Redis 한 번의 네트워크 지연에 내 스레드가 통째로 물린다.
Retry는 강력하지만 잘못 쓰면 장애를 직접 만든다. 세 가지 전제를 반드시 확인한다.
RetryConfig config = RetryConfig.custom()
.maxAttempts(3)
.intervalFunction(IntervalFunction.ofExponentialRandomBackoff(
/* initialInterval */ Duration.ofMillis(100),
/* multiplier */ 2.0,
/* randomizationFactor */ 0.5))
.retryOnException(ex ->
ex instanceof IOException
|| ex instanceof TimeoutException
|| (ex instanceof HttpServerErrorException hse
&& hse.getStatusCode().is5xxServerError()))
.failAfterMaxAttempts(true)
.build();
Retry retry = Retry.of("inventoryApi", config);
재시도는 trunk(끝단) 한 곳에서만 돌리는 것이 원칙이다. A→B→C 호출 체인에서 A, B, C 모두가 각자 3번씩 재시도하면 총 호출은 27배가 된다. 업스트림 장애는 이 순간 끝장난다.
규칙:
Circuit Breaker는 "지속적으로 실패하는 대상을 일정 기간 차단해서, 의미 없는 호출을 빠르게 실패시키는" 장치다. 세 상태를 갖는다.
CircuitBreakerConfig config = CircuitBreakerConfig.custom()
.slidingWindowType(SlidingWindowType.COUNT_BASED)
.slidingWindowSize(50)
.minimumNumberOfCalls(20)
.failureRateThreshold(50.0f) // 50% 이상 실패 시 open
.slowCallRateThreshold(80.0f)
.slowCallDurationThreshold(Duration.ofSeconds(1))
.waitDurationInOpenState(Duration.ofSeconds(10))
.permittedNumberOfCallsInHalfOpenState(5)
.automaticTransitionFromOpenToHalfOpenEnabled(true)
.recordExceptions(IOException.class, TimeoutException.class)
.ignoreExceptions(BusinessValidationException.class)
.build();
CircuitBreaker cb = CircuitBreaker.of("inventoryApi", config);
Supplier<Stock> decorated = CircuitBreaker
.decorateSupplier(cb, () -> inventoryClient.getStock(sku));
핵심은 ignoreExceptions 설정이다. 비즈니스 예외(예: "재고 없음")는 시스템 실패가 아니므로 실패율 계산에 포함하면 안 된다. 이걸 놓치면 정상 동작 중에도 회로가 열린다.
Resilience4j의 데코레이션 순서는 바깥쪽이 먼저 실행된다. 일반적으로 권장되는 순서:
Bulkhead → TimeLimiter → CircuitBreaker → Retry → 실제 호출
즉 가장 안쪽에 Retry, 그 바깥에 CircuitBreaker. 이렇게 해야 Retry가 열린 회로를 계속 때리지 않는다. 반대로 두면 Retry가 서킷을 넘어서 계속 재시도를 시도하게 된다.
Bulkhead는 배의 격벽에서 따온 이름이다. 한 부분이 침수되어도 전체가 가라앉지 않도록 자원을 물리적으로 격리한다.
전형적인 실패 사례: Tomcat default worker thread가 200인데, 이 중 190개가 느려진 결제 API 호출로 블록되어 있으면, 빠르게 끝나야 할 상품 조회 API도 나머지 10개 쓰레드로 처리해야 한다. 결국 상품 조회까지 장애로 전파된다.
ThreadPoolBulkheadConfig tpCfg = ThreadPoolBulkheadConfig.custom()
.maxThreadPoolSize(20)
.coreThreadPoolSize(10)
.queueCapacity(50)
.build();
ThreadPoolBulkhead paymentBulkhead =
ThreadPoolBulkhead.of("paymentApi", tpCfg);
결제 API 전용 스레드풀을 따로 둬서, 결제 장애가 전체 tomcat worker를 잠식하지 않게 한다.
또는 Spring WebFlux / reactive 환경에서는 Schedulers.newBoundedElastic(...) 을 도메인별로 분리해서 같은 효과를 낸다.
PoolingHttpClientConnectionManager 의 route별 제한 활용)"쇼핑몰 홈 API가 추천 서비스 호출이 느려지자 전체 홈이 3초 이상 지연됨"이라는 장애는 bulkhead 부재의 전형이다. 홈 컴포지션에서 추천을 독립 스레드풀 + 200ms timeout + 서킷 으로 감싸고, 실패 시 "인기 상품 캐시" 로 fallback하면 추천 API가 죽어도 홈은 정상 응답한다.
Fallback은 "실패를 숨기는 것"이 아니라 "실패했을 때 무엇을 보여줄 것인가" 에 대한 제품 결정이다.
Supplier<Recommendations> recoCall = () -> recoClient.get(userId);
Supplier<Recommendations> withFallback = () -> {
try {
return CircuitBreaker.decorateSupplier(cb, recoCall).get();
} catch (Exception e) {
meterRegistry.counter("reco.fallback").increment();
return popularCache.getOrDefault(category, Recommendations.empty());
}
};
Fallback은 항상 메트릭으로 측정해야 한다. "Fallback으로 응답했다"는 곧 사용자에게 열화된 경험을 줬다는 뜻이기 때문에, fallback rate는 SLO의 핵심 지표가 된다.
Resilience는 "실패 처리"뿐 아니라 "과부하를 받지 않기" 이기도 하다. 서버가 처리 속도보다 빠르게 요청을 받으면 큐가 무한히 쌓이고, 결국 OOM이나 전체 지연으로 이어진다.
server.tomcat.accept-count, max-connections, max-threads를 유한하게 잡는다.Project Reactor의 Flux.onBackpressureBuffer(maxSize, overflowStrategy), limitRate(prefetch) 등으로 producer → consumer 간 요청 속도를 제어한다.
Flux.from(incoming)
.onBackpressureBuffer(1000, BufferOverflowStrategy.DROP_OLDEST)
.limitRate(100)
.flatMap(this::handle, /* concurrency */ 32)
.subscribe();
Retry-After 헤더 동반.이 구분은 면접에서 "429와 503의 차이는?" 으로 자주 나온다. 핵심은 "누구의 책임인가" 다. 429는 클라이언트 책임, 503은 서버 측 일시 상태.
개별 패턴보다 훨씬 중요한 것이 이들의 조합 규칙이다.
X-Deadline-Ms 헤더로 넘긴다.후보자 경험(오리진 처리 시스템, 쿠팡·NHN 트래픽 운영)에서 가장 자주 마주치는 이슈 중 하나다. 배포·오토스케일 축소 때 in-flight 요청을 중간에 끊으면 사용자는 500을 본다.
server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 30s
이 설정만으로 Spring은 SIGTERM을 받으면:
그런데 Spring 혼자서는 부족하다. 이유는 K8s의 iptables / kube-proxy가 Pod를 Endpoints에서 제거하는 시점 과 Pod에 SIGTERM을 보내는 시점 이 병렬이기 때문이다. 정리 전 잠깐 동안 해당 Pod로 새 트래픽이 계속 들어올 수 있다.
spec:
terminationGracePeriodSeconds: 60
containers:
- name: app
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 10"]
흐름:
preStop 훅 실행sleep 10 동안 LB/서비스 메쉬가 해당 Pod를 드레인preStop 종료 후 SIGTERM 전달terminationGracePeriodSeconds 안에 정상 종료추가 체크 항목:
/actuator/health/liveness 와 /actuator/health/readiness 를 분리. 종료 시 readiness가 먼저 실패 → 트래픽 차단 → SIGTERM.consumer.wakeup() + close(Duration) 로 rebalance를 깔끔하게 유도.allowPoolSuspension 대신, Spring lifecycle에 맞춰 자연 종료되도록 둔다.Resilience는 "동작했는지 확인할 수 있어야" 의미가 있다. Resilience4j는 Micrometer 통합을 기본 제공한다.
노출해야 할 핵심 메트릭:
resilience4j.circuitbreaker.state{name="inventoryApi"} → Closed / Open / Half-Openresilience4j.circuitbreaker.calls{kind="failed|successful|not_permitted|slow"}resilience4j.retry.calls{kind="successful_with_retry|failed_with_retry|successful_without_retry"}resilience4j.bulkhead.available.concurrent.callshttp_server_requests_seconds_bucket + fallback counter대시보드 3종(Grafana 기준):
알람은 "회로가 N분 이상 열려 있음", "fallback rate > 5%", "재시도율 > 10%" 를 기본으로 둔다.
로컬에서 실제로 장애를 주입해가며 확인하는 것이 학습에 가장 효과적이다. 최소 세 가지 도구를 준비한다.
# fake_upstream.py
from flask import Flask, jsonify
import random, time
app = Flask(__name__)
@app.get("/flaky")
def flaky():
r = random.random()
if r < 0.3:
time.sleep(5)
if r < 0.5:
return "boom", 500
return jsonify(ok=True)
pip install flask
python fake_upstream.py
build.gradle 에 io.github.resilience4j:resilience4j-spring-boot3, spring-boot-starter-actuator, spring-boot-starter-web 추가 후:
@RestController
@RequiredArgsConstructor
class DemoController {
private final RestClient restClient = RestClient.create("http://localhost:5000");
@GetMapping("/call")
@CircuitBreaker(name = "upstream", fallbackMethod = "fallback")
@Retry(name = "upstream")
@TimeLimiter(name = "upstream")
public CompletableFuture<String> call() {
return CompletableFuture.supplyAsync(() ->
restClient.get().uri("/flaky").retrieve().body(String.class));
}
public CompletableFuture<String> fallback(Throwable ex) {
return CompletableFuture.completedFuture("degraded-response");
}
}
resilience4j:
timelimiter:
instances:
upstream:
timeout-duration: 1s
retry:
instances:
upstream:
max-attempts: 3
wait-duration: 200ms
enable-exponential-backoff: true
exponential-backoff-multiplier: 2
circuitbreaker:
instances:
upstream:
sliding-window-size: 20
minimum-number-of-calls: 10
failure-rate-threshold: 50
wait-duration-in-open-state: 10s
permitted-number-of-calls-in-half-open-state: 3
management:
endpoints.web.exposure.include: health,metrics,prometheus,circuitbreakers
brew install hey # 또는 apt install hey
hey -z 60s -c 50 http://localhost:8080/call
/actuator/circuitbreakers 를 주기적으로 찍어서 상태 전이를 눈으로 확인한다. 부하 중 fake_upstream.py에 추가 sleep을 넣거나 500 비율을 높여서 Open 진입을 재현한다.
시니어 레벨에서 기대되는 답변 구조는 다음과 같다.
여기에 본인 경험을 붙이면 답변이 단단해진다. 예시: "이전 서비스에서 결제 PG 한 곳의 응답 지연이 우리 주문 API 스레드풀을 잠식해서 홈 화면까지 영향을 준 적이 있었고, 이후 PG 호출을 독립 스레드풀 + 500ms timeout + 서킷으로 묶어 blast radius를 주문 도메인 안으로 제한했습니다."
socketTimeout 없는 JDBC URL → 커넥션풀 즉시 고갈Retry-After 를 제공한다.server.shutdown: graceful + K8s preStop sleep + readiness probe 분리가 적용되어 있다.