2026년 6월 30일

마스터하기: 서킷 브레이커 패턴 - 분산 시스템의 견고성을 위한 안전장치

50
마스터하기: 서킷 브레이커 패턴 - 분산 시스템의 견고성을 위한 안전장치

마스터하기: 서킷 브레이커 패턴 - 분산 시스템의 견고성을 위한 안전장치

마스터하기: 서킷 브레이커 패턴 - 분산 시스템의 견고성을 위한 안전장치

현대 소프트웨어 개발은 마이크로서비스 아키텍처와 클라우드 기반 시스템이 주류를 이루면서, 수많은 서비스들이 서로 복잡하게 얽혀 통신하는 분산 환경이 일반화되었습니다. 이러한 환경에서는 하나의 서비스에서 발생한 작은 장애가 전체 시스템으로 확산되어 연쇄적인 서비스 중단을 일으키는 '캐스케이딩 실패(Cascading Failure)' 위험이 항상 존재합니다. 마치 고속도로의 한 구간에서 발생한 사고가 전체 도로를 마비시키는 것과 같습니다.

이러한 문제에 대응하기 위해 등장한 강력한 도구가 바로 '서킷 브레이커(Circuit Breaker) 패턴'입니다. 이 패턴은 전기 회로의 차단기에서 영감을 받아, 장애가 발생한 외부 서비스에 대한 호출을 일시적으로 차단하여 시스템 전체의 안정성을 확보하고, 장애 서비스가 복구될 시간을 벌어주는 역할을 합니다. 10년 경력의 개발자로서 저는 수많은 시스템에서 이 패턴이 어떻게 장애를 예방하고, 시스템의 회복력을 극적으로 향상시키는지 직접 경험했습니다. 초중급 개발자분들도 이 핵심 개념을 정확히 이해하고 실무에 적용할 수 있도록, 서킷 브레이커 패턴의 모든 것을 함께 파헤쳐 보겠습니다.

1. 개념 소개: 정의, 탄생 배경, 왜 중요한지

1. 개념 소개: 정의, 탄생 배경, 왜 중요한지

정의

서킷 브레이커 패턴은 외부 서비스 호출에 대한 실패를 감지하고, 일정 횟수 이상의 실패가 발생하면 해당 서비스로의 추가 호출을 일시적으로 중단하여 시스템 자원의 낭비를 막고, 장애 전파를 방지하는 디자인 패턴입니다. 마치 과부하 시 자동으로 전원을 차단하는 전기 회로 차단기처럼 작동합니다.

탄생 배경

전통적인 모놀리식 아키텍처에서는 한 모듈의 문제가 전체 애플리케이션에 영향을 줄 가능성이 낮았습니다. 하지만 마이크로서비스와 같은 분산 시스템에서는 수십, 수백 개의 서비스가 네트워크를 통해 서로 통신합니다. 이 과정에서 다음과 같은 문제가 발생할 수 있습니다.

  1. 네트워크 지연/장애: 외부 서비스가 일시적으로 느려지거나 응답하지 않을 수 있습니다.
  2. 서비스 과부하: 특정 서비스에 요청이 몰려 처리 능력을 초과할 수 있습니다.
  3. 서비스 중단: 외부 서비스가 완전히 다운될 수 있습니다.

이러한 상황에서 클라이언트 서비스가 장애가 발생한 서비스에 계속해서 요청을 보내면, 다음과 같은 연쇄적인 문제가 발생합니다.

  • 클라이언트 서비스의 스레드가 외부 서비스 응답을 기다리느라 고갈될 수 있습니다.
  • 네트워크 리소스가 불필요한 요청으로 낭비됩니다.
  • 장애가 발생한 서비스는 불필요한 요청으로 인해 복구 시간이 더 길어질 수 있습니다.
  • 결과적으로 클라이언트 서비스 자체도 장애에 빠져 전체 시스템이 마비되는 '캐스케이딩 실패'가 발생합니다.

서킷 브레이커 패턴은 이러한 캐스케이딩 실패를 방지하고, 시스템의 견고성(Resilience)을 확보하기 위해 탄생했습니다.

왜 중요한지

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

  • 장애 전파 방지: 가장 핵심적인 역할입니다. 하나의 서비스 장애가 전체 시스템으로 확산되는 것을 효과적으로 차단합니다.
  • 빠른 실패 (Fail Fast): 실패할 것이 명확한 요청에 대해 외부 서비스 호출 없이 즉시 실패 응답을 반환하여, 사용자는 불필요한 대기 없이 빠르게 피드백을 받을 수 있습니다. 이는 사용자 경험을 개선하고, 클라이언트 서비스의 리소스 낭비를 줄입니다.
  • 서비스 복구 시간 확보: 장애가 발생한 서비스는 불필요한 요청 부하에서 벗어나 안정적으로 복구될 시간을 벌 수 있습니다.
  • 시스템 안정성 및 회복력 증대: 예측 불가능한 외부 환경 변화나 서비스 장애에도 불구하고 시스템 전체의 가용성과 안정성을 높여줍니다. 이는 SRE(Site Reliability Engineering)에서 강조하는 핵심 가치 중 하나입니다.

2. 핵심 원리 설명 (비유와 다이어그램 활용)

2. 핵심 원리 설명 (비유와 다이어그램 활용)

서킷 브레이커 패턴의 핵심은 '상태 머신(State Machine)'을 기반으로 작동한다는 것입니다. 마치 전기 회로 차단기가 '켜짐', '꺼짐' 상태를 가지는 것처럼, 서킷 브레이커는 주로 세 가지 상태를 가집니다.

비유: 여러분이 식당에서 주문을 한다고 상상해 봅시다.

  • Closed (닫힘) 상태: 식당이 정상 운영 중입니다. 모든 손님의 주문을 받습니다. (정상적인 서비스 호출)
  • Open (열림) 상태: 갑자기 주방에서 불이 났습니다! 식당은 더 이상 주문을 받지 않고, "지금은 영업하지 않습니다"라고 즉시 알려줍니다. (서비스 장애 시 호출 차단)
  • Half-Open (반쯤 열림) 상태: 불이 진압되고 청소를 마쳤습니다. 식당 주인은 조심스럽게 소수의 단골손님에게만 "혹시 식사 가능할까요?"라고 물어봅니다. 문제가 없으면 다시 영업을 시작하고, 문제가 발생하면 다시 문을 닫습니다. (장애 서비스 복구 확인 시도)

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

  1. Closed (닫힘) 상태:

    • 설명: 모든 요청이 외부 서비스로 정상적으로 전달되는 상태입니다. 서킷 브레이커는 이 상태에서 실패 횟수를 모니터링합니다.
    • 전이: 실패 횟수(또는 실패율)가 미리 정의된 임계치(Threshold)를 초과하면, 서킷 브레이커는 Open 상태로 전환됩니다.
  2. Open (열림) 상태:

    • 설명: 외부 서비스에 장애가 발생했다고 판단된 상태입니다. 이 상태에서는 모든 요청이 외부 서비스로 전달되지 않고, 즉시 실패(예: 예외 발생, 기본값 반환) 처리됩니다.
    • 전이: Open 상태로 전환된 후, 미리 정해진 재시도 대기 시간(Reset Timeout)이 경과하면, 서킷 브레이커는 Half-Open 상태로 전환됩니다. 이 시간 동안 외부 서비스는 복구될 기회를 얻습니다.
  3. Half-Open (반쯤 열림) 상태:

    • 설명: 외부 서비스가 복구되었는지 확인하기 위해 제한된 수의 요청만 외부 서비스로 전달하는 상태입니다.
    • 전이:
      • Half-Open 상태에서 보낸 테스트 요청이 모두 성공하면, 서킷 브레이커는 외부 서비스가 복구되었다고 판단하고 Closed 상태로 돌아갑니다.
      • 만약 테스트 요청 중 하나라도 실패하면, 서킷 브레이커는 아직 복구가 되지 않았다고 판단하고 다시 Open 상태로 돌아갑니다.

상태 전이 다이어그램

+----------------+
|     Closed     |  <-- 초기 상태
| (정상 작동)    |
+-------+--------+
        |  실패 임계치 초과
        v
+-------+--------+
|      Open      |  <-- 장애 감지, 호출 차단
| (호출 차단)    |
+-------+--------+
        |  재시도 대기 시간 경과
        v
+-------+--------+
|   Half-Open    |  <-- 복구 시도 (제한된 요청)
| (복구 확인 중) |
+-------+--------+
        |  성공         |  실패
        v              v
+-------+--------+ +-------+--------+
|     Closed     | |      Open      |
+----------------+ +----------------+

3. 코드 예제 2개 (Python 또는 JavaScript, 주석 포함)

여기서는 Python을 사용하여 서킷 브레이커의 핵심 원리를 직접 구현하고, 실용적인 폴백(Fallback) 기능을 추가하는 예제를 보여드리겠습니다.

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

이 예제는 Closed, Open, Half-Open 세 가지 상태를 가지는 간단한 CircuitBreaker 클래스를 구현합니다. 외부 서비스 호출은 simulate_service_call 함수로 시뮬레이션합니다.

import time
import random
from functools import wraps

class CircuitBreakerState:
    """서킷 브레이커의 상태를 나타내는 열거형."""
    CLOSED = "CLOSED"
    OPEN = "OPEN"
    HALF_OPEN = "HALF_OPEN"

class CircuitBreaker:
    def __init__(self, failure_threshold=3, recovery_timeout_seconds=5, half_open_test_requests=1):
        """
        CircuitBreaker 초기화.

        :param failure_threshold: Closed 상태에서 Open 상태로 전환될 실패 횟수 임계치.
        :param recovery_timeout_seconds: Open 상태에서 Half-Open 상태로 전환될 대기 시간 (초).
        :param half_open_test_requests: Half-Open 상태에서 성공해야 Closed로 전환될 요청 수.
        """
        self.state = CircuitBreakerState.CLOSED
        self.failure_threshold = failure_threshold
        self.recovery_timeout_seconds = recovery_timeout_seconds
        self.half_open_test_requests = half_open_test_requests

        self.failure_count = 0
        self.last_failure_time = None
        self.success_count_in_half_open = 0

        print(f"서킷 브레이커 초기화: 상태={self.state}, 실패 임계치={failure_threshold}, 복구 대기={recovery_timeout_seconds}s")

    def __call__(self, func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            if self.state == CircuitBreakerState.OPEN:
                # Open 상태일 때 복구 대기 시간이 지났는지 확인
                if time.time() - self.last_failure_time > self.recovery_timeout_seconds:
                    self.state = CircuitBreakerState.HALF_OPEN
                    self.success_count_in_half_open = 0 # Half-Open 진입 시 성공 카운트 초기화
                    print(f"[CB] 상태 전환: OPEN -> HALF_OPEN (복구 대기 시간 경과)")
                else:
                    # 복구 대기 시간 내라면 즉시 실패
                    print(f"[CB] OPEN 상태: 서비스 호출 차단. 남은 대기 시간: {round(self.recovery_timeout_seconds - (time.time() - self.last_failure_time))}s")
                    raise CircuitBreakerOpenException("Circuit Breaker is OPEN. Service is unavailable.")

            if self.state == CircuitBreakerState.HALF_OPEN:
                try:
                    result = func(*args, **kwargs)
                    self.success_count_in_half_open += 1
                    print(f"[CB] HALF_OPEN 상태: 테스트 요청 성공 ({self.success_count_in_half_open}/{self.half_open_test_requests})")
                    if self.success_count_in_half_open >= self.half_open_test_requests:
                        self.state = CircuitBreakerState.CLOSED
                        self.failure_count = 0
                        print(f"[CB] 상태 전환: HALF_OPEN -> CLOSED (테스트 요청 성공)")
                    return result
                except Exception as e:
                    self.state = CircuitBreakerState.OPEN
                    self.last_failure_time = time.time()
                    self.failure_count = 1 # Open으로 돌아갈 때 실패 카운트는 1로 초기화 (새로운 시작)
                    print(f"[CB] HALF_OPEN 상태: 테스트 요청 실패 ({e}). 상태 전환: HALF_OPEN -> OPEN")
                    raise CircuitBreakerOpenException(f"Circuit Breaker is OPEN after Half-Open failure: {e}")

            # Closed 상태 또는 Half-Open 상태에서 성공 시
            try:
                result = func(*args, **kwargs)
                if self.state == CircuitBreakerState.CLOSED:
                    self.failure_count = 0 # 성공 시 실패 카운트 초기화
                return result
            except Exception as e:
                if self.state == CircuitBreakerState.CLOSED:
                    self.failure_count += 1
                    self.last_failure_time = time.time()
                    print(f"[CB] CLOSED 상태: 요청 실패 ({e}). 실패 카운트: {self.failure_count}/{self.failure_threshold}")
                    if self.failure_count >= self.failure_threshold:
                        self.state = CircuitBreakerState.OPEN
                        print(f"[CB] 상태 전환: CLOSED -> OPEN (실패 임계치 초과)")
                raise # 원래 예외 다시 발생

        return wrapper

class CircuitBreakerOpenException(Exception):
    """서킷 브레이커가 Open 상태일 때 발생하는 예외."""
    pass

# --- 서비스 시뮬레이션 ---
@CircuitBreaker(failure_threshold=3, recovery_timeout_seconds=7, half_open_test_requests=1)
def simulate_external_service(succeed=True):
    """
    외부 서비스 호출을 시뮬레이션하는 함수.
    succeed=False일 때만 실패를 발생시킵니다.
    """
    if not succeed and random.random() < 0.8: # 80% 확률로 실패
        raise ConnectionError("Simulated external service connection error.")
    time.sleep(0.1) # 서비스 처리 시간
    return "Service Call Successful!"

# --- 테스트 실행 ---
if __name__ == "__main__":
    print("--- 1. 정상 작동 구간 ---")
    for i in range(5):
        try:
            print(f"[{i+1}] 호출 결과: {simulate_external_service(succeed=True)}")
        except CircuitBreakerOpenException as e:
            print(f"[{i+1}] 서킷 브레이커 예외: {e}")
        except Exception as e:
            print(f"[{i+1}] 일반 예외: {e}")
        time.sleep(0.5)

    print("\n--- 2. 장애 발생 및 Open 상태 전환 ---")
    for i in range(5):
        try:
            print(f"[{i+1}] 호출 결과: {simulate_external_service(succeed=False)}")
        except CircuitBreakerOpenException as e:
            print(f"[{i+1}] 서킷 브레이커 예외: {e}")
        except Exception as e:
            print(f"[{i+1}] 일반 예외: {e}")
        time.sleep(0.5)

    print("\n--- 3. Open 상태 유지 및 Half-Open 전환 대기 ---")
    print("서비스가 OPEN 상태이므로 호출이 차단됩니다.")
    for i in range(5): # Open 상태에서 호출 시도
        try:
            print(f"[{i+1}] 호출 결과: {simulate_external_service(succeed=True)}") # 실제 서비스는 성공 가능하지만 CB가 막음
        except CircuitBreakerOpenException as e:
            print(f"[{i+1}] 서킷 브레이커 예외: {e}")
        except Exception as e:
            print(f"[{i+1}] 일반 예외: {e}")
        time.sleep(0.5)

    print("\n--- 4. Half-Open 상태에서 복구 시도 (성공 시 Closed) ---")
    print("Open 상태 대기 시간이 지나 Half-Open으로 전환됩니다.")
    time.sleep(7) # recovery_timeout_seconds 경과
    for i in range(5):
        try:
            # Half-Open 상태에서 테스트 요청 시도 (이번에는 성공)
            print(f"[{i+1}] 호출 결과: {simulate_external_service(succeed=True)}")
        except CircuitBreakerOpenException as e:
            print(f"[{i+1}] 서킷 브레이커 예외: {e}")
        except Exception as e:
            print(f"[{i+1}] 일반 예외: {e}")
        time.sleep(0.5)

    print("\n--- 5. 다시 장애 발생 시 Open 상태로 돌아가는 경우 ---")
    time.sleep(2) # Closed 상태에서 다시 시작
    for i in range(5):
        try:
            print(f"[{i+1}] 호출 결과: {simulate_external_service(succeed=False)}") # 다시 실패 유도
        except CircuitBreakerOpenException as e:
            print(f"[{i+1}] 서킷 브레이커 예외: {e}")
        except Exception as e:
            print(f"[{i+1}] 일반 예외: {e}")
        time.sleep(0.5)

    print("\n--- 6. Open 상태 대기 후 Half-Open에서 실패 시 Open으로 복귀 ---")
    time.sleep(7) # recovery_timeout_seconds 경과
    try:
        # Half-Open 상태에서 테스트 요청 시도 (이번에는 실패)
        print(f"[1] 호출 결과: {simulate_external_service(succeed=False)}")
    except CircuitBreakerOpenException as e:
        print(f"[1] 서킷 브레이커 예외: {e}")
    except Exception as e:
        print(f"[1] 일반 예외: {e}")
    time.sleep(0.5) # 다시 OPEN 상태가 됨
    print(f"현재 서킷 브레이커 상태: {simulate_external_service.__wrapped__.__self__.state}") # 데코레이터 인스턴스의 상태 확인

예제 2: 폴백(Fallback) 기능을 포함한 서킷 브레이커 (Python)

서킷 브레이커가 Open 상태일 때 무조건 예외를 발생시키는 대신, 미리 정의된 대체 로직(Fallback)을 실행하도록 만들면 사용자 경험을 더욱 부드럽게 유지할 수 있습니다.

import time
import random
from functools import wraps

class CircuitBreakerState:
    CLOSED = "CLOSED"
    OPEN = "OPEN"
    HALF_OPEN = "HALF_OPEN"

class CircuitBreaker:
    def __init__(self, failure_threshold=3, recovery_timeout_seconds=5, half_open_test_requests=1, fallback_func=None):
        self.state = CircuitBreakerState.CLOSED
        self.failure_threshold = failure_threshold
        self.recovery_timeout_seconds = recovery_timeout_seconds
        self.half_open_test_requests = half_open_test_requests
        self.fallback_func = fallback_func # 폴백 함수 추가

        self.failure_count = 0
        self.last_failure_time = None
        self.success_count_in_half_open = 0

        print(f"서킷 브레이커 초기화: 상태={self.state}, 실패 임계치={failure_threshold}, 복구 대기={recovery_timeout_seconds}s, 폴백={True if fallback_func else False}")

    def __call__(self, func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            if self.state == CircuitBreakerState.OPEN:
                if time.time() - self.last_failure_time > self.recovery_timeout_seconds:
                    self.state = CircuitBreakerState.HALF_OPEN
                    self.success_count_in_half_open = 0
                    print(f"[CB] 상태 전환: OPEN -> HALF_OPEN (복구 대기 시간 경과)")
                else:
                    print(f"[CB] OPEN 상태: 서비스 호출 차단. 남은 대기 시간: {round(self.recovery_timeout_seconds - (time.time() - self.last_failure_time))}s")
                    if self.fallback_func:
                        print("[CB] 폴백 함수 실행.")
                        return self.fallback_func(*args, **kwargs)
                    raise CircuitBreakerOpenException("Circuit Breaker is OPEN. Service is unavailable.")

            if self.state == CircuitBreakerState.HALF_OPEN:
                try:
                    result = func(*args, **kwargs)
                    self.success_count_in_half_open += 1
                    print(f"[CB] HALF_OPEN 상태: 테스트 요청 성공 ({self.success_count_in_half_open}/{self.half_open_test_requests})")
                    if self.success_count_in_half_open >= self.half_open_test_requests:
                        self.state = CircuitBreakerState.CLOSED
                        self.failure_count = 0
                        print(f"[CB] 상태 전환: HALF_OPEN -> CLOSED (테스트 요청 성공)")
                    return result
                except Exception as e:
                    self.state = CircuitBreakerState.OPEN
                    self.last_failure_time = time.time()
                    self.failure_count = 1
                    print(f"[CB] HALF_OPEN 상태: 테스트 요청 실패 ({e}). 상태 전환: HALF_OPEN -> OPEN")
                    if self.fallback_func:
                        print("[CB] 폴백 함수 실행 (Half-Open 실패).")
                        return self.fallback_func(*args, **kwargs)
                    raise CircuitBreakerOpenException(f"Circuit Breaker is OPEN after Half-Open failure: {e}")

            try:
                result = func(*args, **kwargs)
                if self.state == CircuitBreakerState.CLOSED:
                    self.failure_count = 0
                return result
            except Exception as e:
                if self.state == CircuitBreakerState.CLOSED:
                    self.failure_count += 1
                    self.last_failure_time = time.time()
                    print(f"[CB] CLOSED 상태: 요청 실패 ({e}). 실패 카운트: {self.failure_count}/{self.failure_threshold}")
                    if self.failure_count >= self.failure_threshold:
                        self.state = CircuitBreakerState.OPEN
                        print(f"[CB] 상태 전환: CLOSED -> OPEN (실패 임계치 초과)")
                if self.fallback_func:
                    print("[CB] 폴백 함수 실행 (Closed 상태 실패).")
                    return self.fallback_func(*args, **kwargs)
                raise

        return wrapper

class CircuitBreakerOpenException(Exception):
    pass

# --- 서비스 시뮬레이션 ---
def default_fallback_response(*args, **kwargs):
    """서킷 브레이커가 열렸을 때 반환할 기본 응답."""
    return "Fallback Response: Service is currently unavailable, please try again later."

@CircuitBreaker(failure_threshold=3, recovery_timeout_seconds=7, half_open_test_requests=1, fallback_func=default_fallback_response)
def simulate_external_service_with_fallback(succeed=True):
    if not succeed and random.random() < 0.8:
        raise ConnectionError("Simulated external service connection error.")
    time.sleep(0.1)
    return "Service Call Successful!"

# --- 테스트 실행 ---
if __name__ == "__main__":
    print("--- 1. 정상 작동 구간 (폴백 포함) ---")
    for i in range(5):
        try:
            print(f"[{i+1}] 호출 결과: {simulate_external_service_with_fallback(succeed=True)}")
        except Exception as e:
            print(f"[{i+1}] 예외 발생: {e}")
        time.sleep(0.5)

    print("\n--- 2. 장애 발생 및 Open 상태 전환 (폴백 실행) ---")
    for i in range(5):
        try:
            print(f"[{i+1}] 호출 결과: {simulate_external_service_