IT 지식 목록
IT 지식

분산 시스템 장애 전파 차단: Circuit Breaker 패턴으로 견고한 서비스 만들기

분산 시스템에서 연쇄 장애를 방지하고 서비스 복원력을 높이는 Circuit Breaker 패턴의 핵심 원리, Python 코드 예제, 실무 적용 사례 및 흔한 실수와 해결법을 상세히 설명합니다.

00
분산 시스템 장애 전파 차단: Circuit Breaker 패턴으로 견고한 서비스 만들기

오늘날 대부분의 현대 애플리케이션은 여러 서비스가 서로 통신하며 동작하는 분산 시스템 형태로 구축됩니다. 마이크로서비스 아키텍처는 이러한 분산 시스템의 대표적인 예시죠. 각 서비스는 독립적으로 배포되고 확장될 수 있어 개발 생산성과 시스템 유연성을 높여주지만, 동시에 복잡한 의존성 문제를 야기합니다. 한 서비스의 장애가 다른 서비스로 전파되어 전체 시스템을 마비시키는 '연쇄 장애(Cascading Failure)'는 분산 시스템에서 가장 흔하면서도 치명적인 문제입니다.

상상해보세요. 사용자에게 상품 목록을 보여주는 서비스(Service A)가 상품 상세 정보를 제공하는 서비스(Service B)를 호출하고, Service B는 다시 재고 관리 서비스(Service C)를 호출한다고 가정해 봅시다. 만약 Service C가 어떤 이유로 과부하 상태에 빠지거나 응답이 지연된다면 어떻게 될까요? Service B는 Service C의 응답을 기다리느라 지연되고, 결국 Service A도 Service B의 응답을 기다리느라 지연됩니다. 이렇게 되면 Service A를 호출하는 모든 요청이 쌓여 결국 Service A마저도 장애 상태에 빠지고, 이는 전체 시스템의 가용성을 심각하게 저해하게 됩니다.

이러한 문제를 해결하기 위해 등장한 강력한 디자인 패턴 중 하나가 바로 Circuit Breaker(회로 차단기) 패턴입니다. 이 패턴은 서비스 간의 의존성을 관리하고, 한 부분의 장애가 전체 시스템으로 확산되는 것을 효과적으로 방지하여 시스템의 안정성과 복원력을 크게 향상시킵니다.

Circuit Breaker 패턴이란 무엇이며 왜 중요한가?

Circuit Breaker 패턴은 넷플릭스(Netflix)와 같은 대규모 분산 시스템을 운영하는 기업들이 시스템의 복원력을 높이기 위해 적극적으로 도입한 패턴입니다. 이름에서 알 수 있듯이, 이 패턴은 전기 회로의 차단기와 유사한 방식으로 동작합니다. 전기 회로 차단기는 과도한 전류가 흐를 때 회로를 자동으로 끊어 추가적인 손상이나 화재를 방지하죠.

소프트웨어의 Circuit Breaker 패턴도 마찬가지입니다. 특정 서비스나 외부 의존성(데이터베이스, 캐시, 외부 API 등)에 대한 호출이 일정 횟수 이상 실패하거나 응답 시간이 너무 길어지면, Circuit Breaker는 해당 호출을 일시적으로 '차단'합니다. 즉, 더 이상 실패할 것이 분명한 호출을 계속 시도하지 않고 즉시 실패(fail fast) 응답을 반환하여, 호출하는 쪽의 자원(스레드, 메모리 등)이 불필요하게 소모되는 것을 막고, 장애가 발생한 서비스가 복구될 시간을 벌어줍니다.

이 패턴이 중요한 이유는 다음과 같습니다:

  • 시스템 안정성 향상: 연쇄 장애를 방지하여 전체 시스템의 가용성을 높입니다.
  • 자원 보호: 실패할 호출을 계속 시도하지 않음으로써, 호출하는 서비스의 자원(스레드 풀, 네트워크 연결 등)이 고갈되는 것을 막습니다.
  • 사용자 경험 개선: 서비스가 완전히 멈추는 대신, 일부 기능이 제한되더라도 빠르게 응답하여 사용자에게 최소한의 서비스를 제공할 수 있습니다 (예: 캐시된 데이터 반환, 기본값 제공).
  • 장애 복구 시간 단축: 장애가 발생한 서비스가 과도한 요청으로 인해 더욱 악화되는 것을 막고, 안정적으로 복구될 수 있는 환경을 제공합니다.

Circuit Breaker의 핵심 원리: 세 가지 상태와 전환

Circuit Breaker 패턴의 핵심은 세 가지 상태(State)와 이들 간의 전환 로직에 있습니다. 이 상태들을 이해하는 것이 패턴을 올바르게 적용하는 데 가장 중요합니다.

1. Closed (닫힘) 상태:

  • 정의: 기본 상태입니다. 서비스 호출이 정상적으로 이루어지고 있습니다.
  • 동작: 모든 요청이 실제 서비스로 전달됩니다.
  • 전환 조건:
    • 일정 횟수 이상의 실패(Threshold)가 감지되거나,
    • 평균 응답 시간이 특정 임계치를 초과하면, Open 상태로 전환됩니다.
    • 비유: 전기 회로가 정상적으로 연결되어 전류가 흐르는 상태입니다.

2. Open (열림) 상태:

  • 정의: 서비스 호출이 실패 임계치를 초과하여 차단된 상태입니다.
  • 동작: 이 상태에서는 실제 서비스로의 호출이 이루어지지 않고, 즉시 Fallback 응답(예: 예외 발생, 기본값 반환, 캐시 데이터 반환)을 반환합니다. 이로써 실패한 서비스에 대한 추가적인 부하를 막고, 호출하는 서비스의 자원을 보호합니다.
  • 전환 조건:
    • 일정 시간(Timeout)이 경과하면, Half-Open 상태로 전환됩니다. 이 시간은 차단된 서비스가 복구될 충분한 시간을 주기 위함입니다.
    • 비유: 과부하로 인해 차단기가 내려가 회로가 끊어진 상태입니다. 더 이상 전류가 흐르지 않습니다.

3. Half-Open (반쯤 열림) 상태:

  • 정의: Open 상태에서 일정 시간이 경과한 후, 서비스가 복구되었는지 확인하기 위해 제한된 수의 호출을 허용하는 상태입니다.
  • 동작: 소수의 테스트 요청만 실제 서비스로 전달됩니다.
  • 전환 조건:
    • 테스트 요청이 성공하면, 서비스가 복구되었다고 판단하고 Closed 상태로 전환됩니다.
    • 테스트 요청이 다시 실패하면, 서비스가 아직 복구되지 않았다고 판단하고 다시 Open 상태로 전환됩니다.
    • 비유: 차단기를 수동으로 올려보고 다시 작동하는지 확인하는 상태입니다. 성공하면 계속 켜두고, 실패하면 다시 내립니다.

상태 전이 다이어그램 (개념적 묘사):

[Closed] --(실패 임계치 초과)--> [Open]
   ^                               |
   |                               |
   |                               V
   |                          (타임아웃 경과)
   |                               |
   |                               V
   |                           [Half-Open]
   |                             ^   |
   |                             |   |
   (성공) -----------------------|   |-------------------- (실패)

이 다이어그램은 Circuit Breaker가 어떻게 상태를 변경하며 시스템의 안정성을 관리하는지 보여줍니다. Closed 상태에서 실패가 쌓이면 Open으로, Open 상태에서 일정 시간 후 Half-Open으로, Half-Open에서 테스트 호출 결과에 따라 다시 Closed 또는 Open으로 전환되는 흐름입니다.

이러한 상태 관리를 통해 Circuit Breaker는 서비스 장애를 감지하고, 확산을 방지하며, 복구 시점을 지능적으로 판단하여 시스템의 복원력을 극대화합니다.

코드 예제: Python으로 Circuit Breaker 구현하기

Circuit Breaker 패턴은 다양한 프로그래밍 언어에서 라이브러리로 제공되거나 직접 구현할 수 있습니다. 여기서는 Python을 사용하여 간단한 예제를 살펴보겠습니다.

예제 1: pybreaker 라이브러리 활용

pybreaker는 Python에서 Circuit Breaker 패턴을 쉽게 적용할 수 있도록 도와주는 라이브러리입니다. 먼저 설치해야 합니다: pip install pybreaker

import time
import random
from pybreaker import CircuitBreaker, CircuitBreakerError

# 외부 서비스 호출을 시뮬레이션하는 함수
# 50% 확률로 실패한다고 가정
def external_service_call():
    if random.random() < 0.5: # 50% 확률로 실패
        print("외부 서비스 호출 실패!")
        raise ConnectionError("외부 서비스에 연결할 수 없습니다.")
    else:
        print("외부 서비스 호출 성공!")
        return "데이터 from 외부 서비스"

# Circuit Breaker 인스턴스 생성
# fail_max: Closed 상태에서 허용되는 최대 실패 횟수 (이후 Open으로 전환)
# reset_timeout: Open 상태에서 Half-Open 상태로 전환되기까지의 시간 (초)
# exclude: Circuit Breaker가 처리하지 않을 예외 타입 (예: 비즈니스 로직 예외)
breaker = CircuitBreaker(fail_max=3, reset_timeout=5, exclude=[ValueError])

print("--- Circuit Breaker 테스트 시작 ---")

for i in range(1, 15):
    print(f"\n--- 시도 {i} ---")
    try:
        # Circuit Breaker를 통해 서비스 호출
        result = breaker.call(external_service_call)
        print(f"결과: {result}")
    except CircuitBreakerError:
        print("Circuit Breaker가 Open 상태입니다. 호출이 차단되었습니다.")
        # Open 상태일 때의 Fallback 로직
        print("Fallback: 캐시된 데이터 또는 기본값 반환")
    except ConnectionError as e:
        print(f"서비스 호출 중 예외 발생: {e}")
        print(f"현재 Circuit Breaker 상태: {breaker.current_state.name}")
    except Exception as e:
        print(f"예상치 못한 오류: {e}")

    # Open 상태에서 Half-Open으로 전환될 시간을 기다리기 위해
    # reset_timeout 값에 따라 대기 시간을 조절
    if breaker.current_state.name == "OPEN":
        print(f"Circuit Breaker가 OPEN 상태입니다. {breaker.reset_timeout}초 대기 후 Half-Open 시도.")
        time.sleep(breaker.reset_timeout + 1) # reset_timeout보다 조금 더 대기

print("\n--- Circuit Breaker 테스트 종료 ---")

코드 설명:

  • external_service_call 함수는 50% 확률로 ConnectionError를 발생시켜 외부 서비스의 불안정성을 시뮬레이션합니다.
  • CircuitBreaker 객체를 생성할 때 fail_max=3은 3번 연속 실패하면 Open 상태로 전환됨을 의미합니다. reset_timeout=5Open 상태에서 5초 후에 Half-Open 상태로 전환됨을 의미합니다.
  • breaker.call(external_service_call)을 통해 서비스 호출을 Circuit Breaker로 감쌉니다.
  • CircuitBreakerError는 Circuit Breaker가 Open 상태일 때 발생하는 예외로, 이때 대체 로직(Fallback)을 수행할 수 있습니다.
  • time.sleep을 사용하여 Open 상태에서 Half-Open으로 전환되는 시간을 시뮬레이션합니다.

이 코드를 실행하면, 처음에는 호출이 성공하거나 실패하다가, 실패 횟수가 fail_max에 도달하면 CircuitBreakerError가 발생하며 Open 상태로 진입하는 것을 볼 수 있습니다. Open 상태에서는 실제 서비스 호출 없이 즉시 CircuitBreakerError를 발생시키며, reset_timeout이 지나면 Half-Open 상태로 전환되어 다시 서비스 호출을 시도합니다.

예제 2: 간단한 수동 구현 (개념 이해 목적)

pybreaker와 같은 라이브러리를 사용하지 않고, Circuit Breaker의 핵심 로직을 직접 구현해 보면 패턴의 동작 방식을 더 깊이 이해할 수 있습니다. 이 예제는 기본적인 상태 전환만을 보여주며, 실제 프로덕션 환경에서는 더 많은 예외 처리와 파라미터가 필요합니다.

import time
import random

class SimpleCircuitBreaker:
    def __init__(self, failure_threshold=3, reset_timeout=5):
        self.failure_threshold = failure_threshold # 실패 임계치
        self.reset_timeout = reset_timeout       # Open 상태 유지 시간
        self.failures = 0                        # 현재 실패 횟수
        self.last_failure_time = 0               # 마지막 실패 시간
        self.state = "CLOSED"                    # 현재 상태: CLOSED, OPEN, HALF_OPEN

    def call(self, func, *args, **kwargs):
        if self.state == "OPEN":
            # Open 상태에서 reset_timeout이 지났는지 확인
            if time.time() - self.last_failure_time > self.reset_timeout:
                self.state = "HALF_OPEN"
                print("  -> Circuit Breaker 상태: HALF_OPEN")
            else:
                print(f"  Circuit Breaker가 {self.state} 상태입니다. 호출 차단.")
                raise CircuitBreakerOpenError("Circuit Breaker is open.")
        
        if self.state == "HALF_OPEN":
            print("  -> Half-Open 상태: 테스트 호출 시도 중...")
            try:
                result = func(*args, **kwargs)
                # 테스트 호출 성공 시 CLOSED로 전환
                self.state = "CLOSED"
                self.failures = 0
                print("  -> 테스트 호출 성공! Circuit Breaker 상태: CLOSED")
                return result
            except Exception as e:
                # 테스트 호출 실패 시 다시 OPEN으로 전환
                self.state = "OPEN"
                self.last_failure_time = time.time()
                print(f"  -> 테스트 호출 실패! Circuit Breaker 상태: OPEN. 원인: {e}")
                raise CircuitBreakerOpenError("Circuit Breaker is open after half-open test failure.")

        # CLOSED 상태
        try:
            result = func(*args, **kwargs)
            self.failures = 0 # 성공 시 실패 횟수 초기화
            print(f"  서비스 호출 성공. 현재 실패 횟수: {self.failures}")
            return result
        except Exception as e:
            self.failures += 1
            self.last_failure_time = time.time()
            print(f"  서비스 호출 실패. 현재 실패 횟수: {self.failures}")
            if self.failures >= self.failure_threshold:
                self.state = "OPEN"
                print(f"  -> 실패 임계치({self.failure_threshold}) 도달! Circuit Breaker 상태: OPEN")
            raise e # 원래 예외 다시 발생

class CircuitBreakerOpenError(Exception):
    """Circuit Breaker가 Open 상태일 때 발생하는 예외."""
    pass

# 테스트용 외부 서비스
def unreliable_service():
    if random.random() < 0.6: # 60% 확률로 실패
        raise ConnectionError("서비스 연결 실패!")
    return "서비스 데이터"

# Circuit Breaker 인스턴스 생성
breaker = SimpleCircuitBreaker(failure_threshold=2, reset_timeout=3)

print("--- Simple Circuit Breaker 테스트 시작 ---")
for i in range(1, 10):
    print(f"\n--- 시도 {i} --- (현재 CB 상태: {breaker.state})")
    try:
        data = breaker.call(unreliable_service)
        print(f"  받은 데이터: {data}")
    except CircuitBreakerOpenError as e:
        print(f"  Circuit Breaker가 열려있어 호출 차단: {e}")
    except ConnectionError as e:
        print(f"  원래 서비스 예외: {e}")
    
    time.sleep(1) # 매 시도마다 1초 대기

print("\n--- Simple Circuit Breaker 테스트 종료 ---")

코드 설명:

  • SimpleCircuitBreaker 클래스는 failure_threshold, reset_timeout 등의 파라미터와 failures, last_failure_time, state 변수로 Circuit Breaker의 상태를 관리합니다.
  • call 메서드 내에서 현재 상태(CLOSED, OPEN, HALF_OPEN)에 따라 다른 로직을 수행합니다.
  • OPEN 상태에서는 reset_timeout이 지났는지 확인하여 HALF_OPEN으로 전환하거나, 여전히 OPEN 상태임을 알리고 CircuitBreakerOpenError를 발생시킵니다.
  • HALF_OPEN 상태에서는 한 번의 테스트 호출을 시도하고, 성공 여부에 따라 CLOSED 또는 OPEN으로 전환합니다.
  • CLOSED 상태에서는 실제 서비스 호출을 시도하고, 실패 시 failures 카운트를 증가시키며 failure_threshold에 도달하면 OPEN으로 전환합니다.

이 수동 구현은 Circuit Breaker의 내부 동작 원리를 명확하게 보여주지만, 실제 상용 서비스에서는 pybreaker와 같이 잘 테스트되고 기능이 풍부한 라이브러리를 사용하는 것이 좋습니다.

실무 적용 사례

Circuit Breaker 패턴은 분산 시스템의 다양한 지점에서 활용될 수 있습니다.

  1. 마이크로서비스 간 통신: 가장 일반적인 시나리오입니다. 한 마이크로서비스가 다른 마이크로서비스의 API를 호출할 때, 호출하는 쪽에 Circuit Breaker를 적용하여 호출 대상 서비스의 장애가 호출하는 서비스로 전파되는 것을 막습니다. 이는 API Gateway나 서비스 메시(Service Mesh) 계층에서 자동으로 제공되기도 합니다.
  2. 외부 의존성 호출: 데이터베이스, 캐시 서버(Redis, Memcached), 메시지 큐(Kafka, RabbitMQ), 혹은 서드파티 외부 API(결제 서비스, SMS 발송 서비스 등)에 대한 호출에도 Circuit Breaker를 적용할 수 있습니다. 이러한 외부 시스템의 장애는 예측하기 어렵고, 서비스 전체에 큰 영향을 미칠 수 있기 때문에 Circuit Breaker를 통해 보호하는 것이 필수적입니다.
  3. 비동기 작업 큐 처리: 메시지 큐에서 메시지를 가져와 처리하는 워커(Worker) 프로세스가 특정 외부 서비스에 의존하는 경우, 해당 외부 서비스가 장애 상태일 때 워커가 계속해서 실패하는 작업을 시도하는 것을 Circuit Breaker로 방지할 수 있습니다. 이를 통해 큐에 메시지가 쌓이는 것을 막고, 워커의 자원 소모를 줄일 수 있습니다.
  4. 클라이언트 라이브러리: 개발자가 직접 Circuit Breaker를 구현하기 어렵거나, 특정 외부 서비스에 특화된 클라이언트 라이브러리를 제공할 때, 해당 라이브러리 내부에 Circuit Breaker 로직을 포함하여 사용자 편의성과 안정성을 높일 수 있습니다.

자주 하는 실수와 해결법

Circuit Breaker 패턴은 강력하지만, 잘못 사용하면 오히려 문제를 악화시키거나 시스템을 불안정하게 만들 수 있습니다.

  1. 너무 낮은 failure_threshold 설정:
    • 문제: 아주 작은 일시적인 네트워크 오류나 부하 증가에도 Circuit Breaker가 너무 쉽게 Open 상태로 전환되어, 실제로는 정상적으로 작동할 수 있는 서비스 호출까지 차단하게 됩니다. 이는 과도한 fail fast로 이어져 서비스의 가용성을 불필요하게 떨어뜨릴 수 있습니다.
    • 해결법: 시스템의 특성, 네트워크 환경, 서비스의 안정성 등을 고려하여 충분한 failure_threshold를 설정해야 합니다. 일반적으로 5~10회 정도의 실패를 허용하는 것이 합리적이며, 지표를 모니터링하며 최적의 값을 찾아야 합니다.
  2. 너무 짧거나 긴 reset_timeout 설정:
    • 문제: reset_timeout이 너무 짧으면, 장애가 발생한 서비스가 아직 복구되지 않았는데 Half-Open 상태로 전환되어 다시 실패하고 Open 상태로 돌아가는 악순환이 반복될 수 있습니다. 반대로 너무 길면, 서비스가 복구되었음에도 불구하고 오랫동안 Open 상태를 유지하여 불필요하게 서비스를 차단하게 됩니다.
    • 해결법: 서비스의 평균 복구 시간, 모니터링 시스템의 알림 주기 등을 고려하여 적절한 reset_timeout을 설정해야 합니다. 일반적으로 수십 초에서 수 분 정도가 사용됩니다.
  3. Fallback 로직 부재 또는 부적절:
    • 문제: Circuit Breaker가 Open 상태일 때 호출을 차단하는 것은 좋지만, 이때 어떤 대안을 제공할지 Fallback 로직이 없다면 사용자에게 단순히 오류 메시지만 보여주게 됩니다. Fallback 로직이 너무 복잡하거나, 심지어 Fallback 로직 자체도 장애를 유발할 수 있습니다.
    • 해결법: Open 상태일 때, 캐시된 데이터 반환, 기본값 제공, 빈 목록 반환, 혹은 사용자에게 친화적인 오류 메시지를 보여주는 등 안전하고 빠르게 실패할 수 있는 Fallback 로직을 반드시 구현해야 합니다. Fallback 로직은 가능한 한 간단하고 의존성이 없도록 설계해야 합니다.
  4. 모니터링 부재:
    • 문제: Circuit Breaker의 상태 변화(Closed -> Open, Open -> Half-Open -> Closed 등)를 모니터링하지 않으면, 언제 어떤 Circuit Breaker가 Open 상태가 되었는지, 왜 그렇게 되었는지 알기 어렵습니다. 이는 장애 진단과 복구를 어렵게 만듭니다.
    • 해결법: 모든 Circuit Breaker의 상태 변화를 로깅하고, 메트릭(예: Prometheus, Grafana)으로 수집하여 대시보드에서 시각화해야 합니다. Open 상태로 전환될 때는 알림(Alert)을 발생시켜 운영팀이 즉시 인지하고 대응할 수 있도록 해야 합니다.
  5. Circuit Breaker 중첩 사용 및 관리 복잡성:
    • 문제: 여러 계층에서 Circuit Breaker를 중첩하여 사용하면, 어느 계층의 Circuit Breaker가 먼저 열릴지 예측하기 어렵고, 전체 시스템의 동작을 이해하고 디버깅하기 복잡해집니다.
    • 해결법: 시스템의 특정 지점(예: 외부 서비스 호출 직전)에 Circuit Breaker를 집중적으로 적용하고, 각 Circuit Breaker의 목적과 파라미터를 명확히 정의하는 것이 좋습니다. 서비스 메시와 같은 기술은 이러한 관리를 중앙 집중화하는 데 도움을 줄 수 있습니다.

더 공부할 리소스 추천

Circuit Breaker 패턴은 분산 시스템을 설계하고 운영하는 데 있어 필수적인 지식입니다. 더 깊이 학습하고 싶다면 다음 리소스들을 추천합니다.

  • Martin Fowler의 Circuit Breaker 패턴: 패턴의 창시자 중 한 명인 Martin Fowler의 블로그 글은 Circuit Breaker의 개념과 원리를 가장 명확하게 설명하고 있습니다.
  • Netflix Hystrix (Legacy): 넷플릭스에서 개발한 Hystrix는 Circuit Breaker 패턴을 널리 알린 라이브러리입니다. 현재는 유지보수 모드이며, 더 이상 활발하게 개발되지 않지만, 그 설계 철학과 개념은 여전히 유효합니다. Hystrix의 문서를 통해 Circuit Breaker, Fallback, Bulkhead 등 관련 패턴들을 학습할 수 있습니다.
  • Resilience4j (Java): Hystrix의 대안으로 많이 사용되는 Java 기반의 라이브러리입니다. Circuit Breaker 외에도 Rate Limiter, Retry, Bulkhead 등 다양한 복원력 패턴을 제공합니다. Java 생태계에 있다면 이 라이브러리의 문서를 살펴보는 것이 좋습니다.
  • Python pybreaker 라이브러리 문서: 본문에서 사용한 pybreaker 라이브러리의 공식 문서를 통해 더 많은 기능과 고급 사용법을 익힐 수 있습니다.
  • "Release It!" by Michael T. Nygard: 이 책은 복원력 있는 소프트웨어 시스템을 구축하는 데 필요한 다양한 패턴과 실용적인 조언을 담고 있습니다. Circuit Breaker 패턴을 포함한 여러 디자인 패턴에 대한 깊이 있는 통찰을 얻을 수 있습니다.

Circuit Breaker 패턴은 단순한 코드 조각을 넘어, 분산 시스템의 철학을 담고 있는 중요한 개념입니다. 이 패턴을 이해하고 올바르게 적용한다면, 여러분의 시스템은 예측 불가능한 장애 속에서도 굳건히 버텨낼 수 있는 견고함을 갖추게 될 것입니다.