현대 백엔드 시스템과 배치 자동화 파이프라인은 결제 게이트웨이, 소셜 로그인, 공공데이터 API, 사내 마이크로서비스 등 수많은 외부 원격 서비스와 긴밀하게 연동됩니다. 그러나 네트워크 통신은 본질적으로 불안정하며, 외부 서비스는 언제든지 일시적 과부하, 배포 장애, 네트워크 단절 등의 문제에 직면할 수 있습니다.
외부 서비스에 장애가 발생했을 때 호출자(Caller) 측에서 적절한 보호 메커니즘 없이 단순 재시도(Retry)만을 반복하거나 무한정 응답을 대기하면, 애플리케이션의 커넥션 풀(Connection Pool)과 비동기 작업 워커 스레드가 급격히 고갈됩니다. 결국 단 하나의 외부 의존성 장애가 내부 핵심 서비스 전체의 중단으로 이어지는 연쇄 장애(Cascading Failure)가 발생합니다. 본 글에서는 이러한 위험을 원천 격리하기 위한 서킷 브레이커(Circuit Breaker) 패턴의 상태 전이 메커니즘과 파이썬 기반 비동기 Fail-fast 탄력성 설계법을 체계적으로 살펴봅니다.
1. 연쇄 장애의 발생 메커니즘: 대기 큐 적체와 리소스 고갈
외부 서비스 호출 시 타임아웃을 10초로 설정한 비동기 워커 풀(워커 수: 100개)을 가정해 보겠습니다. 정상적인 상황에서는 외부 API 응답이 100ms 이내에 반환되므로 초당 수백 건의 요청을 원활히 처리할 수 있습니다.
- 응답 지연(Latency Spike) 발생: 외부 API 서버의 부하로 인해 응답 시간이 100ms에서 타임아웃 직전인 9.8초로 지연되기 시작합니다.
- 스레드/커넥션 잠식: 요청 처리 속도가 유입 속도를 따라가지 못하면서, 100개의 워커 스레드가 모두 외부 응답을 기다리며 블로킹 상태에 묶입니다.
- 내부 큐 포화 및 타 기능 마비: 외부 연동과 무관한 다른 작업까지 대기 큐에서 대기하다가 타임아웃으로 실패하게 됩니다.
즉, 시스템을 마비시키는 것은 외부 서비스의 완전한 다운보다 ‘느린 응답(Slow Response)’인 경우가 훨씬 많습니다. 실패가 확실시되는 상황에서는 불필요한 호출을 즉시 차단하는 빠른 실패(Fail-fast) 전략이 필수적입니다.
2. 서킷 브레이커의 3대 핵심 상태와 전이 원리
서킷 브레이커는 가정용 전기 차단기에서 유래한 패턴으로, 외부 서비스의 오류율이 임계치를 초과하면 회로를 열어(Open) 이후의 호출을 즉시 차단하고, 일정 시간이 지나면 점진적으로 정상 복구(Half-Open)를 시도하는 유한 상태 머신(Finite State Machine)입니다.
| 상태 | 동작 방식 | 상태 전이 조건 |
|---|---|---|
| Closed (닫힘, 정상) | 모든 요청이 외부 서비스로 정상 전달됩니다. 호출 결과(성공/실패/지연)가 슬라이딩 윈도우에 기록됩니다. | 설정된 윈도우 내 실패율(예: 50% 이상) 또는 느린 호출 비율이 임계치 초과 시 Open으로 전이 |
| Open (열림, 차단) | 외부 서비스 호출을 일절 수행하지 않고, 즉시 CircuitBreakerOpenException 예외를 발생시키거나 Fallback을 반환합니다. |
쿨다운 시간(Sleep Window, 예: 30초) 경과 후 Half-Open으로 전이 |
| Half-Open (반열림, 시험) | 제한된 수(예: 5건)의 시험 요청(Probe Request)만 외부로 통과시켜 서비스의 정상 회복 여부를 판별합니다. | 시험 요청 성공 시 Closed로 복구 단 1건이라도 실패 시 즉시 Open으로 복귀 |
3. 파이썬 비동기 서킷 브레이커 구현
asyncio 기반의 마이크로서비스 환경에서 슬라이딩 윈도우 오류율 감지와 쿨다운 타이머를 적용한 경량 서킷 브레이커 구현 예시입니다.
import asyncio
import time
from enum import Enum
from typing import Callable, Any
class CircuitState(Enum):
CLOSED = "CLOSED"
OPEN = "OPEN"
HALF_OPEN = "HALF_OPEN"
class CircuitBreakerOpenException(Exception):
# 서킷 브레이커가 열려 있어 요청이 즉시 차단되었음을 알리는 예외
pass
class AsyncCircuitBreaker:
def __init__(
self,
failure_threshold: int = 5,
recovery_timeout: float = 30.0,
half_open_max_trials: int = 3
):
self.failure_threshold = failure_threshold
self.recovery_timeout = recovery_timeout
self.half_open_max_trials = half_open_max_trials
self.state = CircuitState.CLOSED
self.failure_count = 0
self.last_state_change = time.time()
self.half_open_success_count = 0
async def call(self, func: Callable, *args, **kwargs) -> Any:
current_time = time.time()
# 1. Open 상태에서 쿨다운 시간이 경과했는지 검사
if self.state == CircuitState.OPEN:
if current_time - self.last_state_change >= self.recovery_timeout:
self.state = CircuitState.HALF_OPEN
self.half_open_success_count = 0
self.last_state_change = current_time
else:
# 빠른 실패 (Fail-fast): 네트워크 I/O 없이 즉시 반환
raise CircuitBreakerOpenException("Circuit is OPEN. Fail-fast triggered.")
# 2. 외부 원격 함수 실행
try:
result = await func(*args, **kwargs)
self._on_success()
return result
except Exception as e:
self._on_failure(e)
raise e
def _on_success(self):
if self.state == CircuitState.HALF_OPEN:
self.half_open_success_count += 1
if self.half_open_success_count >= self.half_open_max_trials:
# 시험 요청 전원 성공 시 완전 정상 복구
self.state = CircuitState.CLOSED
self.failure_count = 0
self.last_state_change = time.time()
elif self.state == CircuitState.CLOSED:
self.failure_count = 0
def _on_failure(self, error: Exception):
# 4xx 클라이언트 오류 등 비즈니스 예외는 제외하고, 네트워크/타임아웃/5xx만 반영 권장
self.failure_count += 1
self.last_state_change = time.time()
if self.state in (CircuitState.CLOSED, CircuitState.HALF_OPEN):
if self.failure_count >= self.failure_threshold or self.state == CircuitState.HALF_OPEN:
self.state = CircuitState.OPEN
4. 실무 아키텍처 설계 시 핵심 고려사항
- 예외의 명확한 분류(Classification): 대상 API가 반환하는 모든 에러를 서킷 실패로 간주해서는 안 됩니다. 파라미터 검증 실패나 인증 오류와 같은
4xx Client Error는 외부 서버의 다운이 아니므로 실패 카운트에서 제외하고,5xx Server Error및Connection Timeout만 집계해야 합니다. - 우아한 대비책(Fallback) 전략: 서킷이 Open되었을 때 사용자에게 단순히 500 에러를 던지는 대신, 캐시된 이전 버전의 데이터 반환, 비동기 큐에 작업 적재 후 “처리 중” 응답, 또는 축소된 기능(Graceful Degradation)을 제공하는 폴백 경로를 구성해야 합니다.
- 로컬 인스턴스 격리 vs Redis 분산 상태 공유: 단일 서버 단위로 서킷 상태를 메모리에 보관하면 네트워크 오버헤드가 없지만 신규 인스턴스 증가 시 장애 감지가 늦어질 수 있습니다. 반면 Redis 기반 중앙 집중 서킷 브레이커는 모든 노드가 장애 상태를 즉시 공유할 수 있으나 Redis 자체의 네트워크 지연과 가용성을 함께 고려해야 합니다.
5. 결론
네트워크를 통과하는 모든 원격 통신은 언젠가 반드시 실패한다는 전제하에 아키텍처를 수립해야 합니다. 지연 발생 시 무한정 대기하거나 무분별하게 재시도하는 대신, 서킷 브레이커를 통해 외부 장애를 국소적으로 격리하고 빠른 실패(Fail-fast)를 유도하는 것은 대규모 배치와 마이크로서비스 백엔드의 탄력성을 보장하는 핵심 엔지니어링 원칙입니다.