2026년 3월 28일

서킷 브레이커 패턴: 분산 시스템의 연쇄 장애를 막는 견고한 방패

130
서킷 브레이커 패턴: 분산 시스템의 연쇄 장애를 막는 견고한 방패

서킷 브레이커 패턴: 분산 시스템의 연쇄 장애를 막는 견고한 방패

서킷 브레이커 패턴: 분산 시스템의 연쇄 장애를 막는 견고한 방패

오늘날 대부분의 현대 소프트웨어 시스템은 더 이상 하나의 거대한 애플리케이션으로 존재하지 않습니다. 대신, 작고 독립적인 서비스들이 서로 통신하며 하나의 큰 시스템을 이루는 마이크로서비스 아키텍처나 분산 시스템 형태로 구축되는 경우가 많습니다. 이러한 아키텍처는 유연성, 확장성, 독립적인 배포 등 많은 장점을 제공하지만, 동시에 새로운 종류의 복잡성과 도전 과제를 안겨줍니다. 그중 하나가 바로 '연쇄 장애(Cascading Failure)'입니다.

한 서비스의 작은 문제가 다른 서비스로 전파되어 전체 시스템을 마비시키는 상황은 분산 시스템에서 흔히 발생할 수 있는 악몽 같은 시나리오입니다. 마치 도미노처럼 한 번 쓰러지기 시작하면 걷잡을 수 없이 무너져 내리는 것이죠. 이러한 연쇄 장애로부터 시스템을 보호하고, 견고하고 복원력 있는 서비스를 구축하기 위한 필수적인 디자인 패턴 중 하나가 바로 '서킷 브레이커(Circuit Breaker) 패턴'입니다.

1. 개념 소개: 분산 시스템의 안전장치, 서킷 브레이커

1. 개념 소개: 분산 시스템의 안전장치, 서킷 브레이커

정의

서킷 브레이커 패턴은 전기 회로의 차단기와 유사하게, 불안정한 서비스에 대한 호출을 일시적으로 중단하여 시스템의 복원력을 높이는 디자인 패턴입니다. 이는 특정 서비스가 과부하 상태이거나 장애를 겪고 있을 때, 해당 서비스에 대한 추가 요청을 막고 즉시 실패를 반환함으로써, 호출하는 서비스(클라이언트)가 무한정 기다리거나 자원을 소모하는 것을 방지합니다. 결과적으로, 장애가 발생한 서비스가 회복할 시간을 벌어주고, 다른 서비스로의 장애 전파를 차단하여 전체 시스템의 안정성을 유지하는 데 기여합니다.

탄생 배경

마이크로서비스 아키텍처가 확산되면서, 수많은 서비스들이 네트워크를 통해 서로 통신하게 되었습니다. 이때, 한 서비스가 느리게 응답하거나 완전히 다운되면, 이 서비스에 의존하는 다른 서비스들도 대기 상태에 빠지거나 결국 타임아웃으로 실패하게 됩니다. 이러한 대기 상태가 길어지면 시스템의 스레드나 연결 풀이 고갈되어, 결국 모든 서비스가 장애를 겪는 연쇄적인 시스템 다운으로 이어질 수 있습니다.

넷플릭스와 같은 대규모 분산 시스템을 운영하는 기업들은 이러한 문제에 직면했고, 이를 해결하기 위해 '넷플릭스 힐트릭스(Netflix Hystrix)'와 같은 라이브러리를 개발하며 서킷 브레이커 패턴을 널리 알렸습니다. 힐트릭스는 이제 유지보수가 중단되었지만, 그 핵심 아이디어인 서킷 브레이커 패턴은 현대 분산 시스템의 필수적인 요소로 자리 잡았습니다.

왜 중요한가?

서킷 브레이커 패턴은 다음과 같은 이유로 현대 분산 시스템에서 매우 중요합니다.

  • 복원력(Resilience) 향상: 한 서비스의 장애가 전체 시스템으로 전파되는 것을 막아, 시스템이 부분적인 장애 상황에서도 핵심 기능을 유지할 수 있도록 돕습니다.
  • 시스템 안정성 유지: 호출하는 서비스가 장애 서비스 때문에 자원 고갈(스레드 고갈, 메모리 부족 등)을 겪는 것을 방지하여, 시스템 전체의 안정적인 운영을 보장합니다.
  • 사용자 경험 개선: 서비스가 응답하지 않을 때 무작정 기다리게 하는 대신, 빠르게 실패를 반환하고 대체 기능(Fallback)을 제공함으로써 사용자에게 더 나은 경험을 제공할 수 있습니다.
  • 빠른 장애 감지 및 복구: 서킷 브레이커의 상태 변화를 통해 서비스의 건강 상태를 빠르게 인지하고, 문제가 발생한 서비스를 신속하게 격리하여 복구 시간을 단축할 수 있습니다.

2. 핵심 원리 설명: 세 가지 상태와 비유

2. 핵심 원리 설명: 세 가지 상태와 비유

서킷 브레이커 패턴의 핵심은 세 가지 상태(State)와 이들 간의 전환 로직에 있습니다. 이 세 가지 상태는 마치 전기 회로의 차단기가 '켜짐', '꺼짐', 그리고 '다시 켜볼까?' 하는 시도와 유사합니다.

서킷 브레이커의 세 가지 상태

  1. Closed (닫힘/정상 상태):

    • 모든 요청이 정상적으로 대상 서비스로 전달됩니다.
    • 이 상태에서는 서비스 호출의 성공/실패 여부를 지속적으로 모니터링합니다.
    • 일정 시간 동안 실패율이 설정된 임계치(Threshold)를 초과하거나, 특정 횟수 이상의 연속적인 실패가 발생하면 서킷은 Open 상태로 전환됩니다.
    • 비유: 전등 스위치가 켜져 있어서 전류가 정상적으로 흐르는 상태.
  2. Open (열림/차단 상태):

    • 서킷 브레이커가 열려 있는 상태로, 대상 서비스로의 모든 요청을 즉시 차단하고, 미리 정의된 대체 응답(Fallback)을 반환합니다. 실제 서비스 호출은 이루어지지 않습니다.
    • 이 상태는 대상 서비스가 회복할 시간을 벌어주기 위한 것입니다.
    • 일정 시간(예: 30초, 1분 등)이 지나면 서킷은 자동으로 Half-Open 상태로 전환됩니다.
    • 비유: 과부하로 인해 전기 차단기가 내려가서 전류가 완전히 끊긴 상태. 해당 회로의 전등은 꺼져 있습니다.
  3. Half-Open (반쯤 열림/부분 개방 상태):

    • Open 상태에서 일정 시간이 지난 후, 서킷 브레이커는 대상 서비스가 회복되었는지 확인하기 위해 Half-Open 상태로 전환됩니다.
    • 이 상태에서는 제한된 수의 요청(예: 단 한 번의 요청 또는 소수의 요청)만 대상 서비스로 통과시킵니다.
    • 만약 이 시험 요청이 성공하면, 서비스가 회복되었다고 판단하고 서킷은 다시 Closed 상태로 돌아갑니다.
    • 만약 시험 요청이 실패하면, 서비스가 아직 회복되지 않았다고 판단하고 서킷은 다시 Open 상태로 돌아갑니다.
    • 비유: 차단기가 내려간 후, '혹시 이제 괜찮아졌을까?' 하고 조심스럽게 스위치를 다시 올려보는 시도. 성공하면 계속 켜두고, 실패하면 다시 내립니다.

상태 전이 다이어그램 (텍스트 설명)

[Closed] ----------------------------------------> [Open]
   ^                                                 |
   | (실패율 임계치 초과 또는 연속 실패)             | (일정 시간 경과)
   |                                                 V
   |<---------------------------------------------- [Half-Open]
   | (시험 요청 성공)                                  | (시험 요청 실패)
   |                                                 |
   ---------------------------------------------------

이 다이어그램은 Closed 상태에서 실패 임계치 초과 시 Open으로, Open에서 일정 시간 경과 시 Half-Open으로, Half-Open에서 시험 요청 성공 시 Closed로, 실패 시 Open으로 돌아가는 서킷 브레이커의 기본적인 상태 전이를 보여줍니다.

3. 코드 예제 (Python)

실제 시스템에서는 pybreaker와 같은 라이브러리를 사용하여 서킷 브레이커를 구현하는 것이 일반적입니다. 여기서는 pybreaker 라이브러리를 활용한 예제를 보여드리겠습니다.

먼저, pybreaker 라이브러리를 설치해야 합니다:

pip install pybreaker

예제 1: 기본적인 서킷 브레이커 구현

이 예제는 pybreaker를 사용하여 간단한 서킷 브레이커를 설정하고, 실패와 성공에 따라 상태가 어떻게 변하는지 보여줍니다.

import time
from pybreaker import CircuitBreaker, CircuitBreakerError

# 실패 임계치: 2번의 연속 실패 시 Open
# 재시도 타임아웃: Open 상태에서 5초 후 Half-Open
# 오류 유형: 예외 발생 시 실패로 간주
circuit_breaker = CircuitBreaker(fail_max=2, reset_timeout=5, exclude=[TypeError])

# 가상의 불안정한 서비스
def unreliable_service_call():
    # 5번 중 3번은 성공, 2번은 실패한다고 가정
    if unreliable_service_call.counter < 3: # 처음 3번은 성공
        unreliable_service_call.counter += 1
        print(f"[{time.strftime('%H:%M:%S')}] Service call SUCCESS. (count: {unreliable_service_call.counter})")
        return "Data from service"
    else: # 이후 2번은 실패
        unreliable_service_call.counter += 1
        print(f"[{time.strftime('%H:%M:%S')}] Service call FAILURE! (count: {unreliable_service_call.counter})")
        raise RuntimeError("Service is down!")

unreliable_service_call.counter = 0 # 서비스 호출 횟수 추적

print("--- 1. Closed 상태에서 실패 시도 ---")
for i in range(5):
    try:
        # 서킷 브레이커로 서비스 호출 래핑
        result = circuit_breaker.call(unreliable_service_call)
        print(f"Result: {result}")
    except CircuitBreakerError:
        print(f"[{time.strftime('%H:%M:%S')}] Circuit Breaker is OPEN! Service call blocked.")
    except Exception as e:
        print(f"[{time.strftime('%H:%M:%S')}] Service failed with: {e}")
    time.sleep(1) # 1초 대기

print("\n--- 2. Open 상태에서 대기 및 Half-Open으로 전환 ---")
print(f"[{time.strftime('%H:%M:%S')}] Waiting for reset_timeout ({circuit_breaker.reset_timeout}s)...")
time.sleep(circuit_breaker.reset_timeout + 1) # reset_timeout보다 조금 더 대기하여 Half-Open으로 전환 유도

print(f"[{time.strftime('%H:%M:%S')}] Circuit Breaker current state: {circuit_breaker.current_state}")

print("\n--- 3. Half-Open 상태에서 시험 요청 ---")
# Half-Open 상태에서 첫 번째 호출은 서비스로 전달됨
try:
    result = circuit_breaker.call(unreliable_service_call)
    print(f"Result: {result}")
    print(f"[{time.strftime('%H:%M:%S')}] Circuit Breaker should be CLOSED now: {circuit_breaker.current_state}")
except CircuitBreakerError:
    print(f"[{time.strftime('%H:%M:%S')}] Circuit Breaker is OPEN! Service call blocked. (Half-Open test failed)")
except Exception as e:
    print(f"[{time.strftime('%H:%M:%S')}] Service failed during Half-Open test: {e}")

# 시험 요청이 실패하면 다시 Open 상태가 되므로, 한 번 더 호출해서 확인
if circuit_breaker.current_state.name == 'open':
    print("\n--- 4. 다시 Open 상태일 경우, 추가 호출 확인 ---")
    try:
        result = circuit_breaker.call(unreliable_service_call)
        print(f"Result: {result}")
    except CircuitBreakerError:
        print(f"[{time.strftime('%H:%M:%S')}] Circuit Breaker is still OPEN! Service call blocked.")
    except Exception as e:
        print(f"[{time.strftime('%H:%M:%S')}] Service failed with: {e}")

print(f"\n[{time.strftime('%H:%M:%S')}] Final Circuit Breaker state: {circuit_breaker.current_state}")

코드 설명:

  • CircuitBreaker(fail_max=2, reset_timeout=5): 2번의 연속 실패 시 서킷을 Open하고, Open 상태에서 5초 후 Half-Open으로 전환됩니다.
  • unreliable_service_call(): 의도적으로 실패를 발생시키는 가상의 서비스입니다.
  • circuit_breaker.call(unreliable_service_call): 이 메서드를 통해 unreliable_service_call을 호출합니다. 서킷 브레이커가 Open 상태일 때는 CircuitBreakerError를 발생시킵니다.
  • 예제 실행 시, 처음 몇 번의 성공 후 연속 실패가 발생하면 서킷이 Open되고, 이후 reset_timeout이 지나면 Half-Open으로 전환되어 시험 요청을 보냅니다. 시험 요청의 성공/실패에 따라 다시 Closed 또는 Open으로 돌아갑니다.

예제 2: Fallback 함수 적용

서킷 브레이커가 Open 상태일 때, 단순히 에러를 반환하는 대신 사용자에게 의미 있는 대체 응답을 제공하는 Fallback 함수를 사용할 수 있습니다.

import time
from pybreaker import CircuitBreaker, CircuitBreakerError

circuit_breaker_with_fallback = CircuitBreaker(fail_max=2, reset_timeout=5)

# 가상의 불안정한 서비스 (항상 실패한다고 가정)
def always_failing_service():
    print(f"[{time.strftime('%H:%M:%S')}] Always Failing Service called.")
    raise RuntimeError("This service always fails!")

# Fallback 함수: 서비스가 실패할 때 대신 호출됨
def fallback_data_from_cache():
    print(f"[{time.strftime('%H:%M:%S')}] Fallback: Returning cached data.")
    return "Data from local cache (stale but available)"

print("--- Fallback 기능 테스트 ---")
for i in range(5):
    try:
        # 서킷 브레이커로 서비스 호출 래핑, fallback 인자 전달
        result = circuit_breaker_with_fallback.call(always_failing_service, fallback=fallback_data_from_cache)
        print(f"Result: {result}")
    except CircuitBreakerError:
        # fallback 함수가 있으면 CircuitBreakerError가 발생하지 않음
        print(f"[{time.strftime('%H:%M:%S')}] Circuit Breaker is OPEN! Fallback should have been called.")
    except Exception as e:
        print(f"[{time.strftime('%H:%M:%S')}] Service failed with: {e}")
    time.sleep(1)

print(f"\n[{time.strftime('%H:%M:%S')}] Final Circuit Breaker state: {circuit_breaker_with_fallback.current_state}")

코드 설명:

  • circuit_breaker_with_fallback.call(always_failing_service, fallback=fallback_data_from_cache): fallback 인자에 fallback_data_from_cache 함수를 전달했습니다.
  • always_failing_service(): 이 서비스는 항상 RuntimeError를 발생시킵니다.
  • 서비스가 실패하여 서킷 브레이커가 Open되면, pybreaker는 자동으로 fallback_data_from_cache 함수를 호출하고 그 결과를 반환합니다. 이로 인해 CircuitBreakerError가 애플리케이션 레벨로 전파되지 않고, 사용자에게 캐시된 데이터를 제공하는 등의 대응이 가능해집니다.

4. 실무 적용 사례

서킷 브레이커 패턴은 분산 시스템의 다양한 계층에서 시스템의 안정성과 복원력을 높이는 데 활용될 수 있습니다.

  • 마이크로서비스 간 통신: 가장 흔한 사용 사례입니다. 주문 서비스가 재고 서비스에 의존하고 있을 때, 재고 서비스의 장애가 주문 서비스로 전파되어 전체 주문 처리 시스템이 마비되는 것을 방지합니다. 재고 서비스가 다운되면, 서킷 브레이커는 재고 확인 요청을 차단하고 "재고 확인 불가"와 같은 응답을 즉시 반환하여 주문 서비스가 불필요하게 대기하는 것을 막습니다.
  • 외부 API 연동: 결제 게이트웨이, SMS 발송 서비스, 지도 API 등 외부 서드파티 서비스와 연동할 때 특히 유용합니다. 외부 서비스의 장애는 우리 시스템이 통제할 수 없으므로, 서킷 브레이커를 통해 외부 서비스의 장애가 우리 시스템에 미치는 영향을 최소화하고, 필요 시 캐시된 데이터를 반환하거나 사용자에게 양해 메시지를 표시하는 등의 Fallback 처리를 할 수 있습니다.
  • 데이터베이스 및 캐시 시스템 연결: 데이터베이스나 Redis와 같은 캐시 시스템에 과부하가 걸리거나 연결 문제가 발생했을 때, 서킷 브레이커를 통해 추가적인 연결 요청을 막아 해당 시스템이 복구될 시간을 벌어줄 수 있습니다. 이 경우, 읽기 요청에 대해서는 오래된 캐시 데이터를 반환하는 Fallback 전략을 사용할 수 있습니다.
  • 메시징 시스템 소비자 (Consumer): 메시지 큐(Kafka, RabbitMQ 등)에서 메시지를 가져와 처리하는 컨슈머가 다운스트림 서비스(예: 데이터 저장, 다른 API 호출)의 장애로 인해 메시지 처리에 실패할 수 있습니다. 이때 서킷 브레이커를 사용하여 다운스트림 서비스의 장애를 감지하고 메시지 처리를 일시 중단하거나, 실패 메시지를 데드 레터 큐(Dead Letter Queue)로 보내는 등의 조치를 취할 수 있습니다. 이는 무한 재처리 루프에 빠지는 것을 방지하고 메시지 시스템의 부하를 줄입니다.

5. 자주 하는 실수와 해결법

서킷 브레이커 패턴은 강력하지만, 잘못 구현하거나 설정하면 오히려 문제를 야기할 수 있습니다.

  • 자주 하는 실수 1: 너무 낮은 임계치 (fail_max) 설정

    • 문제: 서킷 브레이커가 너무 민감하게 반응하여, 일시적인 네트워크 지연이나 사소한 오류에도 불구하고 쉽게 Open 상태로 전환될 수 있습니다. 이는 서비스가 실제로는 정상인데도 불필요하게 차단되는 결과를 초래합니다.
    • 해결법: 충분한 모니터링 데이터를 기반으로 시스템의 평균적인 실패율과 최대 허용 가능한 실패율을 분석하여 현실적인 임계치를 설정해야 합니다. 일반적으로 연속 실패 횟수보다는 '롤링 윈도우(Rolling Window)' 내의 실패율을 기준으로 하는 것이 더 견고합니다. (예: 최근 1분 동안 100개 요청 중 10개 이상 실패 시 Open)
  • 자주 하는 실수 2: 너무 짧은 Open 상태 타임아웃 (reset_timeout) 설정

    • 문제: 서비스가 아직 완전히 회복되지 않았는데 Half-Open 상태로 너무 빨리 전환되면, 시험 요청이 다시 실패하고 서킷이 즉시 Open으로 돌아가는 '핑퐁(ping-pong)' 현상이 발생할 수 있습니다. 이는 서비스 회복을 방해하고 서킷 브레이커의 효율성을 떨어뜨립니다.
    • 해결법: 대상 서비스의 평균적인 복구 시간을 고려하여 reset_timeout을 설정해야 합니다. 또한, Half-Open 상태에서 재시도 간격을 점진적으로 늘리는 지수 백오프(Exponential Backoff) 전략을 함께 사용하는 것을 고려할 수 있습니다.
  • 자주 하는 실수 3: Fallback 로직 부재 또는 불완전

    • 문제: 서킷 브레이커가 Open되었을 때, 단순히 예외를 발생시키거나 빈 응답을 반환하는 것만으로는 사용자 경험을 해칠 수 있습니다. Fallback이 없다면, 사용자에게는 단순히 "서비스 에러"만 보이게 됩니다.
    • 해결법: 사용자 관점에서 의미 있는 Fallback 로직을 구현해야 합니다. 예를 들어, 캐시된 데이터를 반환하거나, 기본값을 제공하거나, 사용자에게 현재 서비스 이용이 어렵다는 메시지를 명확히 전달해야 합니다. 이는 부분적인 기능 저하(Graceful Degradation)를 통해 전체 사용자 경험을 유지하는 데 중요합니다.
  • 자주 하는 실수 4: 로깅 및 모니터링 부족

    • 문제: 서킷 브레이커의 상태 변화(Closed -> Open, Open -> Half-Open 등)나 실패율 지표를 제대로 모니터링하지 않으면, 시스템에 문제가 발생했을 때 원인 파악 및 진단이 어려워집니다.
    • 해결법: 서킷 브레이커의 모든 상태 변화 이벤트를 로깅하고, 실패율, 성공률, 현재 상태 등의 메트릭을 수집하여 대시보드에서 시각화해야 합니다. 이를 통해 시스템의 건강 상태를 실시간으로 파악하고, 문제가 발생했을 때 빠르게 대응할 수 있습니다.
  • 자주 하는 실수 5: 테스트 부족

    • 문제: 실제 장애 상황을 시뮬레이션하여 서킷 브레이커가 의도대로 작동하는지 검증하지 않으면, 실제 장애 발생 시 서킷 브레이커가 제대로 동작하지 않아 오히려 문제를 키울 수 있습니다.
    • 해결법: 개발 및 테스트 환경에서 의도적으로 서비스 장애를 주입(fault injection)하여 서킷 브레이커의 동작을 검증해야 합니다. 카오스 엔지니어링(Chaos Engineering) 기법을 활용하여 실제 운영 환경에서 서킷 브레이커의 복원력을 주기적으로 테스트하는 것도 좋은 방법입니다.

6. 더 공부할 리소스 추천

서킷 브레이커 패턴은 분산 시스템을 구축하는 모든 개발자에게 필수적인 지식입니다. 더 깊이 있는 이해를 위해 다음 리소스들을 추천합니다.

  • Martin Fowler의 블로그 - Circuit Breaker:

  • Netflix Hystrix (Legacy):

    • https://github.com/Netflix/Hystrix
    • 현재는 유지보수가 중단되었지만, 서킷 브레이커 패턴을 대중화시킨 라이브러리입니다. 그 설계 철학과 문서는 여전히 패턴을 이해하는 데 많은 도움이 됩니다.
  • Pybreaker 라이브러리 문서:

  • Resilience4j (Java):

    • https://resilience4j.github.io/resilience4j/ (영문)
    • Java 생태계에서 Hystrix를 대체하는 대표적인 복원력 라이브러리입니다. Java 개발자라면 이 라이브러리의 문서를 참고하는 것이 좋습니다.
  • 서적 "Release It!" by Michael T. Nygard:

    • 분산 시스템의 안정성과 복원력에 대한 고전적인 명저입니다. 서킷 브레이커를 포함한 다양한 복원력 패턴에 대해 심도 있게 다룹니다.

서킷 브레이커 패턴은 단지 코드를 몇 줄 추가하는 것을 넘어, 시스템의 장애 상황을 예측하고 이에 대비하는 설계 철학을 담고 있습니다. 이 패턴을 잘 이해하고 적용한다면, 여러분의 시스템은 훨씬 더 견고하고 안정적으로 동작할 것입니다.