Software Architecture/Distributed Systems

Distributed Systems - 12. 회복탄력성

회복탄력성 패턴 — 장애가 전파되는 것을 막는 법

2015년 한 시스템에서 한 하위 서비스가 30초간 응답 지연을 일으켰다. 호출하던 상위 서비스가 스레드 풀을 모두 점유당해 응답 불능에 빠졌다. 그 상위 서비스를 호출하던 더 상위 서비스도 같은 운명을 겪었다. 4단계 위의 서비스까지 멈췄다 — 한 서비스의 지연이 연쇄적으로 전파돼 전체 장애가 됐다. 01편(분산의 8가지 오해)에서 본 "네트워크는 신뢰할 수 없다"를 코드로 방어하지 않은 결과. 이 장애를 막는 패턴들이 회복탄력성(resilience)이다. timeout, retry, circuit breaker, bulkhead, load shedding — 이 패턴들이 분산 시스템의 장애를 국소화하고 전파를 차단한다.

비유로 감 잡기 — 배의 격벽과 퓨즈

배에 격벽(bulkhead) 이 있다. 선체가 여러 구획으로 나뉘어, 한 구획에 물이 차도 다른 구획은 안전하다. 한 침수가 배 전체를 가라앉히지 않게 하는 물리적 격리. 타이타닉이 침몰한 이유 중 하나가 격벽이 충분히 높지 않아 물이 넘어갔기 때문이라는 분석이 있다.

전기 회로의 퓨즈 또는 차단기(circuit breaker). 과부하가 걸리면 회로를 끊어, 기기가 타버리는 걸 막는다. 정상이 되면 다시 연결. 회로를 계속 살려두지 않고 끊어냄으로써 — 다른 기기와 전체 시스템을 보호.

분산 시스템의 회복탄력성 패턴이 이 두 가지와 같다. bulkhead는 자원을 격리해 한 서비스 장애가 다른 서비스의 자원을 고갈시키지 않게 하고, circuit breaker는 과부하 시 호출을 끊어, 호출자가 같은 운명을 겪지 않게 한다.

비유의 한계 — 배/전기 비유는 '물리적 강제'를 강조하지만, 소프트웨어에서는 코드가 자발적으로 이 패턴들을 채택해야 한다. 또한 배 격벽은 항상 작동하지만, 소프트웨어는 설정이 잘못되면 무력화된다. 비유는 '자발적 채택'에서 무너진다.

다섯 가지 핵심 패턴

Martin Fowler가 "CircuitBreaker" 글(martinfowler.com)에서 정리하고, Michael Nygard가 <Release It! 2판>(2018)에서 종합한 회복탄력성 패턴들을 다룬다.

1. Timeout — 가장 기본적 방어

호출마다 최대 대기 시간을 둔다. 응답이 그 시간을 넘기면 실패로 간주.

class InventoryClient {
    // 타임아웃 2초
    public Inventory check(ItemId id) {
        return httpClient.withTimeout(Duration.ofSeconds(2))
                         .get("/inventory/" + id.value());
    }
}

timeout이 없으면 — 호출자가 무한정 대기하다가 스레드 고갈. timeout은 "무한 대기"를 "제한된 대기"로 바꾼다. 모든 원격 호출에 기본으로 들어가야 할 방어.

주의 — timeout은 임의 값이어야 한다. 너무 짧으면 정상 요청도 실패하고, 너무 길면 방어가 안 된다. 보통 — 정상 응답 시간의 p99에 여유를 둔 값. 50ms 정상 응답이면 500ms~2초 timeout이 흔한 설정.

2. Retry (지수 백오프) — 일시적 장애 회복

일시적 네트워크 오류는 재시도로 회복된다. 단, 일정 간격을 두고 점점 늘리는 게 효과적.

class RetrySpec {
    int maxAttempts = 3;
    Duration initialBackoff = Duration.ofMillis(100);
    double multiplier = 2.0;  // 지수
    Duration maxBackoff = Duration.ofSeconds(5);
}

class RetryableClient {
    public Inventory check(ItemId id) {
        return tryRetry(() -> inventoryClient.check(id), retrySpec);
    }

    <T> T tryRetry(Supplier<T> action, RetrySpec spec) {
        Duration backoff = spec.initialBackoff();
        Exception lastError = null;
        for (int i = 0; i < spec.maxAttempts; i++) {
            try {
                return action.get();
            } catch (TransientException e) {
                lastError = e;
                Thread.sleep(backoff.toMillis());
                // 다음 백오프 — 지수 증가, 최대값 제한
                backoff = min(backoff.multipliedBy((long) spec.multiplier()), spec.maxBackoff);
                // 무작위성(jitter) 추가 — 동시 재시도 폭주 방지
                backoff = backoff.plus(ThreadLocalRandom.current().nextLong(50));
            }
        }
        throw new RetryExhaustedException(lastError);
    }
}

지수 백오프(exponential backoff) — 재시도 간격을 2배씩 늘려, 점진적으로 시스템이 회복될 기회를 준다. Jitter(무작위성) 추가가 중요하다 — 여러 클라이언트가 같은 시간에 같은 간격으로 재시도하면 동시 폭주가 되기 때문.

주의 — 모든 오류를 재시도하면 안 된다. 400 Bad Request, 401 Unauthorized 같은 비즈니스 오류는 재시도해도 같은 결과. 503 Service Unavailable, 504 Gateway Timeout, 네트워크 오류만 재시도 대상.

3. Circuit Breaker — 연속 실패 시 차단

연속 실패가 임계치를 넘으면 일정 시간 호출을 차단(fast fail). 한 서비스가 완전히 죽어 있을 때, 매번 timeout·재시도로 자원을 낭비하지 않게 한다.

class CircuitBreaker {
    enum State { CLOSED, OPEN, HALF_OPEN }

    State state = State.CLOSED;
    int failureCount = 0;
    int threshold = 20;  // 20회 실패 시 open
    Duration openDuration = Duration.ofSeconds(30);
    Instant openedAt;

    synchronized <T> T execute(Supplier<T> action) {
        switch (state) {
            case OPEN:
                if (Instant.now().isAfter(openedAt.plus(openDuration))) {
                    state = State.HALF_OPEN;  // 시도해봄
                } else {
                    throw new CircuitOpenException();  // fast fail
                }
                // fall through to HALF_OPEN handling
            case HALF_OPEN:
                try {
                    T result = action.get();
                    state = State.CLOSED;  // 회복됨
                    failureCount = 0;
                    return result;
                } catch (Exception e) {
                    state = State.OPEN;  // 다시 open
                    openedAt = Instant.now();
                    throw e;
                }
            case CLOSED:
                try {
                    T result = action.get();
                    failureCount = 0;
                    return result;
                } catch (Exception e) {
                    if (++failureCount >= threshold) {
                        state = State.OPEN;
                        openedAt = Instant.now();
                    }
                    throw e;
                }
        }
    }
}

세 상태 — CLOSED(정상), OPEN(차단, fast fail), HALF_OPEN(시험 삼아 시도). OPEN에서 일정 시간 지나면 HALF_OPEN으로 가서 한 번 시도해보고, 성공하면 CLOSED, 실패하면 다시 OPEN. 이 사이클이 — 서비스 회복을 감지하면서, 죽어 있는 동안 자원 낭비를 막는다.

flowchart LR
    C[CLOSED<br/>정상 호출] -->|실패 임계 초과| O[OPEN<br/>fast fail]
    O -->|대기 시간 경과| H[HALF_OPEN<br/>시험 호출]
    H -->|성공| C
    H -->|실패| O

4. Bulkhead — 자원 격리

서비스별로 자원(스레드 풀, 커넥션 풀, 메모리)을 따로 둬서 — 한 서비스 장애가 다른 서비스의 자원을 고갈시키지 않게 한다.

// 안티패턴 — 모든 하위 서비스가 같은 스레드 풀 공유
class SharedPoolClient {
    ExecutorService pool = Executors.newFixedThreadPool(100);

    void callServiceA() { pool.submit(() -> serviceA.call()); }
    void callServiceB() { pool.submit(() -> serviceB.call()); }
    // serviceA가 느려지면 풀을 점유 — serviceB도 못 돌아감
}

// 개선 — 서비스별 분리된 풀
class BulkheadClient {
    ExecutorService poolA = Executors.newFixedThreadPool(20);  // A 전용
    ExecutorService poolB = Executors.newFixedThreadPool(20);  // B 전용
    ExecutorService poolC = Executors.newFixedThreadPool(20);  // C 전용

    void callServiceA() { poolA.submit(() -> serviceA.call()); }
    void callServiceB() { poolB.submit(() -> serviceB.call()); }
    // serviceA가 느려도 poolB는 안전 — serviceB 정상 동작
}

격리는 — 서비스 A 장애가 전체 시스템을 멈추지 않게 한다. "타이타닉 한 구획 침수가 배를 가라앉히지 않게"와 같다.

5. Load Shedding — 부하 시 거부

시스템이 처리할 수 있는 것보다 많은 요청이 오면, 일부를 거부한다. 전체가 멈추는 것보다 일부가 거부당하는 게 낫다.

class LoadShedder {
    Semaphore permits = new Semaphore(1000);  // 동시 처리 1000개 제한

    public Response handle(Request req) {
        if (!permits.tryAcquire()) {
            // 처리량 한도 도달 — 503 Service Unavailable
            return Response.serviceUnavailable()
                .withHeader("Retry-After", "60")
                .build();
        }
        try {
            return process(req);
        } finally {
            permits.release();
        }
    }
}

일부 요청을 거부하면 — 받아들인 요청은 정상 처리된다. "전체가 느려지면서 아무것도 안 되는" 상태보다 나은 trade-off. nginx, HAProxy, AWS API Gateway 같은 인프라가 이 패턴을 흔히 구현.

패턴 비교 — 언제 무엇을?

패턴 다루는 문제 부작용 항상 필요?
Timeout 무한 대기 방지 정상 요청도 실패 가능 모든 원격 호출에
Retry 일시적 장애 회복 중복 요청 (멱등성 필요), 폭주 위험 일시적 오류가 예상되면
Circuit Breaker 죽은 서비스 자원 낭비 방지 회복 감지 지연 (HALF_OPEN 주기) 하위 서비스가 자주 죽으면
Bulkhead 장애 전파 차단 자원 비효율 (풀 여유분) 여러 하위 서비스가 있으면
Load Shedding 과부하 시 전체 붕괴 방지 일부 사용자 불만 트래픽 폭주 가능성 있으면

설계 사례 — Resilience4j로 회복탄력성 적용

Java 생태계에서 가장 널리 쓰이는 라이브러리 Resilience4j로 다섯 패턴을 종합해 본다.

// Resilience4j — 다섯 패턴을 합친 데코레이터
class ResilientOrderService {
    private final CircuitBreaker circuitBreaker;
    private final Retry retry;
    private final TimeLimiter timeLimiter;
    private final Bulkhead bulkhead;
    private final RateLimiter rateLimiter;

    ResilientOrderService() {
        circuitBreaker = CircuitBreaker.of("payment", CircuitBreakerConfig.custom()
            .failureRateThreshold(50)             // 50% 실패 시 open
            .waitDurationInOpenState(Duration.ofSeconds(30))
            .slidingWindowSize(20)
            .build());

        retry = Retry.of("payment", RetryConfig.custom()
            .maxAttempts(3)
            .waitDuration(Duration.ofMillis(500))
            .retryOnException(e -> e instanceof TransientException)
            .build());

        timeLimiter = TimeLimiter.of("payment", Duration.ofSeconds(2));
        bulkhead = Bulkhead.of("payment", BulkheadConfig.custom()
            .maxConcurrentCalls(20)  // 동시 20개 호출 제한
            .build());
    }

    // 다섯 패턴을 겹쳐서 적용
    public OrderId placeOrder(OrderRequest req) {
        Supplier<OrderId> raw = () -> paymentClient.charge(req);

        // Decorator 체인 — 바깥에서 안으로: bulkhead → circuit breaker → retry → time limit
        Supplier<OrderId> decorated = Decorators.ofSupplier(raw)
            .withBulkhead(bulkhead)
            .withCircuitBreaker(circuitBreaker)
            .withRetry(retry)
            .decorate();

        // time limiter는 비동기로 감싸서
        try {
            return timeLimiter.executeFutureSupplier(
                () -> CompletableFuture.supplyAsync(decorated));
        } catch (TimeoutException e) {
            throw new OrderTimeoutException(e);
        } catch (CircuitBreakerOpenException e) {
            // 회로 열림 — 대기열로
            pendingQueue.enqueue(req);
            throw new OrderQueuedException();
        }
    }
}

이 한 메서드에 다섯 패턴이 다 들어간다. bulkhead가 동시 호출 수를 제한하고, circuit breaker가 죽은 서비스 호출을 fast fail 하고, retry가 일시적 장애를 회복하고, time limiter가 무한 대기를 막는다. 결제 서비스가 30초 지연돼도 주문 서비스는 2초 안에 응답한다 (또는 적절히 우회).

회복탄력성 위반/오용 감지

신호 의미
원격 호출에 timeout 없음 무한 대기 위험 — 스레드 고갈
모든 오류를 무조건 재시도 비즈니스 오류(400)까지 재시도 — 자원 낭비
재시도에 jitter 없음 동시 재시도 폭주 — 시스템에 더 큰 부하
Circuit breaker 없이 실패하는 호출 반복 죽은 서비스에 계속 호출 — 자원 낭비
한 스레드 풀로 모든 하위 서비스 호출 장애 전파 위험 — bulkhead 부재
부하 폭주 시 모든 요청 느려지기만 함 load shedding 부재 — 전체 붕괴
회로 open 시 폴백 없음 사용자가 빈 응답을 받음 — UX 부재

회복탄력성과 8가지 오해

01편(분산의 8가지 오해)에서 본 8가지 오해 중 — 특히 1(신뢰성), 2(지연), 8(균일성)이 회복탄력성의 동기다.

  • 오해 1 (신뢰성) → timeout, retry, circuit breaker
  • 오해 2 (지연) → timeout, bulkhead
  • 오해 8 (균일성) → 부하 분산 시 가중치, 느린 노드 회피

회복탄력성은 — 8가지 오해를 코드로 방어하는 구체적 패턴들이다. 이 패턴들이 없는 분산 시스템은 — 장애가 발생하면 연쇄로 전파되고, 결국 전체가 멈춘다. 8가지 오해를 "믿지 않겠다"고 선언하는 것만으로는 부족하고 — 코드로 그것을 보여줘야 한다.

11번 서비스 디스커버리·게이트웨이로

11편(서비스 디스커버리·API 게이트웨이)은 — 회복탄력성의 일부(특히 load shedding, 부하 분산)를 인프라 수준에서 다루는 패턴이다. API 게이트웨이는 — 인증·속도 제한·라우팅을 한 곳에 모으는 패턴이지만, 동시에 장애 전파를 막는 방어막이기도 하다. 클라이언트가 직접 여러 서비스에 닿는 대신 게이트웨이에만 닿으면 — 게이트웨이가 circuit breaker, timeout, 부하 분산을 일괄 적용할 수 있다. 회복탄력성을 코드에서 인프라로 옮기는 형태.

회복탄력성의 핵심 통찰 — 분산 시스템에서 장애는 "예외적 상황"이 아니라 기본 상황이다. 네트워크는 분단되고, 노드는 죽고, 서비스는 느려진다. 이걸 "때때로 일어나는 불행"으로 다루면 장애 때마다 붕괴한다. "항상 일어나는 것"으로 전제하고 코드로 방어하면, 장애가 일어나도 시스템은 동작한다. 회복탄력성은 그 관점의 전환을 코드로 구현한 것이다.


참고

  • Nygard — <Release It! Design and Deploy Production-Ready Software> (2nd ed, Pragmatic Bookshelf, 2018), 회복탄력성 패턴 종합 — 접근 2026-07-20
  • Fowler — "CircuitBreaker" (martinfowler.com/bliki/CircuitBreaker.html) — 접근 2026-07-20
  • Richards, Ford — (O'Reilly, 2020), 회복탄력성 장
  • Resilience4j Documentation — https://resilience4j.readme.io — 접근 2026-07-20
  • Hystrix (Netflix) — 원조 회복탄력성 라이브러리, 현재 maintenance 모드, Resilience4j가 계승 — 접근 2026-07-20
  • Kleppmann — (O'Reilly, 2017), Ch.8 (분산 시스템의 어려움 — 결함 처리)