← all posts
DEV 2026.05.02 · 17 min read Intermediate

MSA 탄력성 패턴 — 장애는 차단하고, 서비스는 살린다

Circuit Breaker의 상태 전이부터 Bulkhead 격리, Exponential Backoff, Timeout 설계, Fallback 전략, Kubernetes 자가 치유까지 — MSA 탄력성 패턴의 통합 철학을 추적한다.


마이크로서비스는 수십 개의 서비스가 서로를 호출하는 구조다. 하나가 느려지면 그걸 기다리는 스레드가 쌓이고, 스레드가 고갈되면 그 서비스를 호출하는 또 다른 서비스가 멈춘다. 이 연쇄 반응을 Cascading Failure라 부른다. 그렇다면 MSA에서 탄력성(resilience)을 설계한다는 것은 정확히 무엇을 막으려는 것인가?

장애의 연쇄를 끊는 회로 차단기

Circuit Breaker는 전기 과전류 차단기에서 이름을 빌렸다. 문제가 있는 서비스로의 호출을 미리 차단해 불필요한 자원 낭비를 막는다.

Resilience4j의 Circuit Breaker는 CLOSED → OPEN → HALF-OPEN 세 상태를 순환한다.

CLOSED (정상)  ──실패율 초과──→  OPEN (차단)
     ↑                               │
     │                     waitDuration 경과
     │                               ↓
     └──성공률 회복──  HALF-OPEN (탐색)

CLOSED 상태에서는 모든 요청이 통과하고 슬라이딩 윈도우에 성공/실패를 기록한다. 실패율이 failureRateThreshold(예: 50%)를 넘으면 OPEN으로 전환되어 이후 모든 요청은 즉시 CallNotPermittedException을 받는다. 네트워크 왕복이 없으니 스레드가 대기하지 않는다.

waitDurationInOpenState(기본 60초)가 지나면 HALF-OPEN으로 전환된다. 여기서 permittedNumberOfCallsInHalfOpenState만큼(예: 3개) 제한된 요청을 실제로 보내 성공률을 측정한다. 기준을 넘으면 CLOSED로 복구, 그렇지 않으면 다시 OPEN으로 돌아간다.

슬라이딩 윈도우는 두 종류다. COUNT_BASED는 최근 N개 호출을 기준으로 하므로 QPS가 낮으면 윈도우 시간이 늘어난다. TIME_BASED는 최근 N초 동안의 호출을 기준으로 하므로 QPS 변동에 더 예측 가능하게 반응한다. QPS가 일정한 서비스라면 COUNT_BASED가 단순하고, 트래픽 변동이 큰 서비스라면 TIME_BASED가 적합하다.

격벽으로 장애를 가두는 Bulkhead

Circuit Breaker가 장애 서비스로의 호출을 차단한다면, Bulkhead는 장애가 다른 서비스의 자원을 잠식하지 못하도록 격리한다.

결제·재고·배송 서비스를 공유 스레드 풀 100개로 호출한다고 하자. 결제 서비스가 30초 응답을 시작하면 100개 스레드가 전부 결제 응답을 기다리게 되고, 재고·배송 호출은 스레드를 얻지 못해 함께 멈춘다. Bulkhead는 서비스별로 스레드 풀을 분리해 이 전파를 막는다.

구현 방식은 두 가지다. Semaphore 방식은 동시 호출 수만 제한하므로 메모리 오버헤드가 작지만 진정한 격리는 아니다. Thread Pool 방식은 서비스별로 독립된 스레드 풀을 만들어 완전한 격리를 제공하지만 스레드당 약 1MB의 메모리와 컨텍스트 스위칭 비용이 따른다.

스레드 풀 크기는 Little’s Law로 추정한다.

L=λ×WL = \lambda \times W

LL은 필요한 스레드 수, λ\lambda는 초당 요청 수, WW는 평균 응답 시간(초)이다. 결제 서비스가 초당 100 요청에 평균 응답 2초라면 이론적으로 200개가 필요하다. 실제로는 메모리 한계와 안전 계수를 적용해 제한한다.

트레이드오프

Thread Pool Bulkhead는 격리가 강하지만 스레드 풀 수 × 스레드 크기만큼 메모리를 고정 소비한다. Semaphore는 가볍지만 한 서비스의 느린 호출이 다른 호출을 블로킹할 수 있다. 빠른 서비스엔 Semaphore, 느리고 중요한 서비스엔 Thread Pool이 일반적인 선택이다.

재시도가 장애를 키우지 않으려면

일시적 장애에 재시도는 합리적이다. 문제는 모든 클라이언트가 동시에 재시도할 때다. 서버가 복구를 시도하는 순간 재시도 트래픽이 몰려 복구를 방해한다 — 이것이 Thundering Herd다.

Exponential Backoff는 재시도마다 대기 시간을 지수적으로 늘린다.

delay=initialDelay×2attempt\text{delay} = \text{initialDelay} \times 2^{\text{attempt}}

1차 100ms, 2차 200ms, 3차 400ms로 증가한다. 여기에 Jitter를 더하면 클라이언트들의 재시도 시점이 분산된다. Full Jitterrandom(0, base)로 완전 분산되고, Equal Jitterbase/2 + random(0, base/2)로 최솟값을 보장하면서 분산된다.

재시도에서 놓치기 쉬운 함정이 멱등성이다. GET 요청은 몇 번 재시도해도 같은 결과지만, 결제 POST를 재시도하면 중복 차감이 발생할 수 있다. 해결책은 Idempotency Key다. 클라이언트가 요청마다 고유 키를 헤더에 담아 보내면, 서버는 같은 키의 요청을 중복 처리 없이 저장된 결과를 반환한다.

Timeout — 대기 시간에 상한을 두어라

타임아웃이 없으면 느린 서비스 하나가 스레드 풀 전체를 잡아먹는다. 스레드는 대기 상태로 자원을 점유하며, 새 요청들은 처리할 스레드를 찾지 못해 큐에 쌓이다가 서버 전체가 멈춘다.

타임아웃은 두 단계로 나뉜다. Connection Timeout은 TCP 연결 수립에 걸리는 시간으로, 서버가 완전히 다운됐을 때 트리거된다(보통 300500ms). Read Timeout은 연결 후 응답을 기다리는 시간으로, 서버가 응답을 돌려주기 전에 처리가 느릴 때 트리거된다(서비스 특성에 따라 110초).

적절한 Read Timeout 값은 **P99 응답 시간 × 안전 계수(1.2~1.5)**로 결정한다. P50 기준으로 잡으면 정상 요청의 절반이 타임아웃되고, P90으로 잡으면 10%가 여전히 희생된다. P99는 이상 요청 1% 미만만 실패 처리하는 균형점이다.

Timeout, Retry, Circuit Breaker를 함께 쓸 때는 순서가 중요하다. Circuit Breaker가 OPEN이면 타임아웃까지 기다리지 않고 즉시 거절한다. 그러지 않으면 재시도 3회 × 타임아웃 2초 = 최소 6초가 하나의 요청에 소모된다.

Fallback — 기능을 죽이지 말고 줄여라

장애를 감지했다고 서비스가 살아나는 건 아니다. 사용자 경험을 유지하려면 Fallback 전략이 필요하다.

Fallback에는 세 가지 수준이 있다. 기본값 반환은 가장 단순하다. 추천 서비스가 다운됐을 때 빈 리스트나 하드코딩된 인기 상품을 반환한다. 캐시 데이터 반환은 직전에 성공했을 때 Redis에 저장해 둔 데이터를 돌려준다. 약간 오래된 데이터지만 서비스는 유지된다. 기능 축소 운영은 핵심 기능은 살리고 부가 기능만 제거한다. 주문 생성은 되지만 마일리지 적립은 나중에 처리하는 식이다.

Fallback Chain도 구성할 수 있다. 실제 서비스 실패 → 캐시 시도 → 캐시 없으면 기본값. 각 단계가 실패해도 다음 단계로 넘어간다.

그러나 모든 오류를 마스킹하면 안 된다. 추천·리뷰·배송비 조회 같은 부가 기능은 마스킹해도 되지만, 결제·재고 확인·주소 검증 같은 핵심 비즈니스 로직 실패를 조용히 넘기면 오버셀링, 중복 결제, 법적 문제로 이어진다. 마스킹의 기준은 단순하다 — “이 기능이 실패했을 때 사용자에게 알리지 않아도 되는가?”

Kubernetes Probe — 자가 치유의 마지막 층위

위의 패턴들이 모두 실패해 서비스 자체가 데드락에 빠지거나 OOM으로 죽었다면, Kubernetes가 자동으로 재시작한다. 이것이 **자가 치유(Self-Healing)**다.

핵심은 Liveness ProbeReadiness Probe를 분리하는 것이다.

  • Liveness: “앱이 살아있나?” — 실패 시 컨테이너 재시작. JVM, 데드락 감지만 포함.
  • Readiness: “요청 처리 준비됐나?” — 실패 시 트래픽 차단(재시작 아님). DB, 캐시, 외부 서비스 포함.

둘을 같은 엔드포인트(/actuator/health)로 설정하면 치명적이다. DB 연결이 일시적으로 끊겼을 때 Readiness가 DOWN이 되는 건 맞다(트래픽 차단). 하지만 Liveness도 DOWN이 되면 Pod를 재시작한다. 재연결 중이던 DB 연결이 끊기고 또 재시작하는 반복에 빠진다.

/actuator/health/readiness/actuator/health/liveness를 각각 연결하면, DB 재연결 시나리오에서 Liveness는 UP을 유지해 Pod를 살려두고, Readiness가 DOWN으로 트래픽을 차단한 채 자가 복구를 기다린다.

정리

  • Circuit Breaker는 실패율 기반으로 장애 서비스 호출을 차단하고, HALF-OPEN에서 복구를 탐색한다.
  • Bulkhead는 서비스별 스레드 풀을 격리해 한 서비스의 장애가 다른 서비스로 전파되지 못하게 막는다.
  • Exponential Backoff + Jitter는 재시도 요청을 시간적으로 분산시켜 Thundering