2026년 3월 16일

서킷 브레이커 패턴: 분산 시스템의 회복탄력성을 높이는 지혜

240
서킷 브레이커 패턴: 분산 시스템의 회복탄력성을 높이는 지혜

서킷 브레이커 패턴: 분산 시스템의 회복탄력성을 높이는 지혜

서킷 브레이커 패턴: 분산 시스템의 회복탄력성을 높이는 지혜

1. 개념 소개

1. 개념 소개

현대의 소프트웨어 시스템은 더 이상 하나의 거대한 애플리케이션으로 존재하지 않습니다. 마이크로서비스 아키텍처, 클라우드 네이티브 환경, 그리고 수많은 외부 API 연동은 시스템을 '분산'시키는 것이 일반적입니다. 이러한 분산 시스템은 유연성과 확장성이라는 엄청난 이점을 제공하지만, 동시에 새로운 종류의 도전을 안겨줍니다. 바로 부분적인 장애가 전체 시스템의 마비로 이어질 수 있다는 점입니다.

상상해 보세요. 여러분의 서비스가 중요한 외부 결제 시스템 API를 호출하고 있습니다. 평소에는 아무 문제 없지만, 어느 날 이 결제 시스템에 일시적인 장애가 발생하여 응답이 지연되거나 오류를 반환하기 시작합니다. 여러분의 서비스는 계속해서 이 결제 시스템을 호출할 것이고, 응답을 기다리느라 자체 스레드나 연결 풀이 고갈될 수 있습니다. 결국, 결제 시스템의 장애가 여러분의 서비스 전체로 전파되어, 결제와 무관한 다른 기능들마저 동작하지 않게 되는 **연쇄적인 실패(Cascading Failure)**가 발생할 수 있습니다. 이는 사용자 경험을 심각하게 저해하고, 비즈니스에 막대한 손실을 초래할 수 있습니다.

서킷 브레이커(Circuit Breaker) 패턴은 이러한 연쇄적인 실패를 방지하기 위해 고안된 디자인 패턴입니다. 전기 회로의 차단기(Circuit Breaker)와 같은 원리로 작동합니다. 과부하가 걸리거나 단락이 발생하면 자동으로 회로를 끊어 더 큰 손상을 막는 것처럼, 소프트웨어 서킷 브레이커는 특정 서비스에 대한 호출이 지속적으로 실패할 경우, 해당 서비스로의 호출을 일시적으로 차단하여 시스템의 추가적인 부하를 줄이고, 실패한 서비스가 복구될 시간을 벌어줍니다.

이 패턴은 2010년 Michael Nygard의 저서 "Release It!"에서 처음 소개되었으며, 분산 시스템의 **회복탄력성(Resilience)**을 높이는 핵심적인 방법론으로 자리 잡았습니다. 여러분이 마이크로서비스를 개발하든, 외부 API를 연동하든, 서킷 브레이커 패턴의 이해와 적용은 견고하고 안정적인 시스템을 구축하는 데 필수적인 지식입니다.

2. 핵심 원리 설명

2. 핵심 원리 설명

서킷 브레이커 패턴은 크게 세 가지 상태(State)를 가지며, 이 상태들을 전환하며 외부 서비스 호출을 제어합니다.

  1. Closed (닫힘) 상태:

    • 정의: 서비스 호출이 정상적으로 이루어지는 초기 상태입니다. 차단기가 닫혀 있어 전기가 흐르듯, 모든 요청이 외부 서비스로 전달됩니다.
    • 작동: 일정 시간 동안 연속적인 실패나 에러율이 특정 임계값(Threshold)을 넘지 않으면 이 상태를 유지합니다.
    • 전환: 만약 실패 횟수나 에러율이 임계값을 초과하면, 서킷 브레이커는 Open 상태로 전환됩니다.
  2. Open (열림) 상태:

    • 정의: 외부 서비스에 장애가 발생했다고 판단되어, 모든 호출을 즉시 차단하고 직접 에러를 반환하는 상태입니다. 차단기가 열려 전기가 흐르지 않듯, 외부 서비스로의 요청이 완전히 막힙니다.
    • 작동: 이 상태에서는 외부 서비스로의 실제 호출 없이 즉시 실패를 반환합니다(예: 미리 정의된 fallback 응답, 예외 발생). 이는 부하가 걸린 외부 서비스를 보호하고, 호출하는 서비스의 자원 고갈을 막아줍니다.
    • 전환: 일정 시간(예: 쿨다운 기간, 재시도 지연 시간)이 경과하면, 서킷 브레이커는 Half-Open 상태로 전환됩니다. 이는 외부 서비스가 복구되었을 가능성을 확인하기 위함입니다.
  3. Half-Open (반쯤 열림) 상태:

    • 정의: Open 상태에서 일정 시간이 지나 외부 서비스의 복구 가능성을 탐색하는 상태입니다. 제한된 수의 요청만 외부 서비스로 통과시킵니다.
    • 작동: 이 상태에서는 소수의 '시험 요청'만 외부 서비스로 보내 실제 상태를 확인합니다.
      • 만약 시험 요청이 성공하면, 외부 서비스가 복구되었다고 판단하고 Closed 상태로 전환됩니다.
      • 만약 시험 요청이 실패하면, 여전히 장애가 지속된다고 판단하고 다시 Open 상태로 돌아갑니다.
    • 전환: 시험 요청의 성공/실패 여부에 따라 Closed 또는 Open 상태로 전환됩니다.

비유: 여러분이 방문하려는 인기 식당이 있다고 가정해 봅시다.

  • Closed (닫힘) 상태: 평소처럼 줄을 서서 식당에 들어갑니다. (모든 요청이 통과)
  • 어느 날, 식당 입구에 "재료 소진" 또는 "갑작스러운 내부 사정"이라는 팻말이 붙고, 식당 직원이 더 이상 손님을 받지 않는다고 말합니다. 여러분은 헛걸음하고 돌아옵니다. (연속적인 실패 -> Open 상태)
  • Open (열림) 상태: 식당이 문을 닫았으니, 여러분은 아예 그 식당 쪽으로 가지 않고 다른 식당을 찾아봅니다. (외부 서비스 호출 차단, 즉시 fallback)
  • 한두 시간 후, '혹시나' 하는 마음에 다시 식당 앞을 지나갑니다. (쿨다운 기간 경과 -> Half-Open 상태)
  • Half-Open (반쯤 열림) 상태: 식당 문이 조금 열려 있고, 직원이 한두 팀만 들여보내고 있습니다.
    • 만약 들어간 팀이 성공적으로 식사를 마치고 나오면, 식당이 다시 정상 영업을 한다고 판단합니다. (시험 요청 성공 -> Closed 상태로 복귀)
    • 만약 들어간 팀이 다시 나오며 "아직 안 되는 게 많아요"라고 말하면, 식당이 아직 정상이 아니라고 판단하고 다시 발길을 돌립니다. (시험 요청 실패 -> Open 상태로 재전환)

이러한 상태 전이를 통해 서킷 브레이커는 장애가 발생한 서비스를 보호하고, 호출하는 서비스의 자원 소모를 최소화하며, 장애가 복구되면 자동으로 정상 상태로 돌아가는 스마트한 회복 전략을 제공합니다.

3. 코드 예제 2개

여기서는 Python과 JavaScript (Node.js)로 서킷 브레이커의 핵심 로직을 구현하는 예제를 보여드립니다. 실제 라이브러리들은 더 많은 기능(메트릭 수집, 이벤트 발행 등)을 제공하지만, 여기서는 핵심적인 상태 관리와 전환에 집중합니다.

3.1. Python 예제

import time
import random
from enum import Enum

# 서킷 브레이커의 상태를 정의합니다.
class CircuitBreakerState(Enum):
    CLOSED = "Closed"
    OPEN = "Open"
    HALF_OPEN = "Half-Open"

class CircuitBreaker:
    def __init__(self, failure_threshold=3, recovery_timeout=5, half_open_test_limit=1):
        """
        서킷 브레이커를 초기화합니다.
        :param failure_threshold: Open 상태로 전환되기 위한 연속 실패 횟수 임계값.
        :param recovery_timeout: Open 상태에서 Half-Open 상태로 전환되기까지의 시간(초).
        :param half_open_test_limit: Half-Open 상태에서 허용되는 시험 요청 성공 횟수.
        """
        self.state = CircuitBreakerState.CLOSED
        self.failure_threshold = failure_threshold
        self.recovery_timeout = recovery_timeout
        self.half_open_test_limit = half_open_test_limit

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

    def _should_open(self):
        """Closed 상태에서 Open 상태로 전환할지 결정합니다."""
        return self.failure_count >= self.failure_threshold

    def _should_half_open(self):
        """Open 상태에서 Half-Open 상태로 전환할지 결정합니다."""
        if self.state == CircuitBreakerState.OPEN and self.last_failure_time:
            return (time.time() - self.last_failure_time) > self.recovery_timeout
        return False

    def _should_close_from_half_open(self):
        """Half-Open 상태에서 Closed 상태로 전환할지 결정합니다."""
        return self.success_count_in_half_open >= self.half_open_test_limit

    def _should_reopen_from_half_open(self):
        """Half-Open 상태에서 다시 Open 상태로 전환할지 결정합니다."""
        # Half-Open 상태에서 실패가 발생하면 즉시 Open으로 돌아갑니다.
        return self.failure_count > 0 and self.state == CircuitBreakerState.HALF_OPEN

    def call(self, func, *args, **kwargs):
        """
        주어진 함수(외부 서비스 호출)를 서킷 브레이커 보호 하에 실행합니다.
        """
        # Open 상태이면 즉시 실패를 반환합니다.
        if self.state == CircuitBreakerState.OPEN:
            if self._should_half_open():
                self.state = CircuitBreakerState.HALF_OPEN
                self.success_count_in_half_open = 0 # Half-Open 진입 시 성공 카운트 초기화
                print(f"[{time.time():.2f}] Circuit Breaker: OPEN -> HALF-OPEN")
            else:
                print(f"[{time.time():.2f}] Circuit Breaker: OPEN. Service unavailable.")
                raise CircuitBreakerOpenException("Circuit breaker is open.")

        # Half-Open 상태이면 제한된 요청만 통과시킵니다.
        if self.state == CircuitBreakerState.HALF_OPEN:
            print(f"[{time.time():.2f}] Circuit Breaker: HALF-OPEN. Testing service...")
            try:
                result = func(*args, **kwargs)
                self.success_count_in_half_open += 1
                if self._should_close_from_half_open():
                    self.reset()
                    print(f"[{time.time():.2f}] Circuit Breaker: HALF-OPEN -> CLOSED. Service recovered.")
                return result
            except Exception as e:
                self.failure_count += 1 # Half-Open 상태에서 실패 시 실패 카운트 증가
                self.last_failure_time = time.time()
                self.state = CircuitBreakerState.OPEN # 실패 시 즉시 Open으로 돌아감
                print(f"[{time.time():.2f}] Circuit Breaker: HALF-OPEN -> OPEN. Service still failing. Error: {e}")
                raise CircuitBreakerOpenException("Service failed during half-open test.") from e
        
        # Closed 상태이면 정상적으로 함수를 호출합니다.
        elif self.state == CircuitBreakerState.CLOSED:
            try:
                result = func(*args, **kwargs)
                # 성공하면 실패 카운트 초기화
                self.failure_count = 0
                return result
            except Exception as e:
                self.failure_count += 1
                self.last_failure_time = time.time()
                print(f"[{time.time():.2f}] Circuit Breaker: Failure detected ({self.failure_count}/{self.failure_threshold}). Error: {e}")
                if self._should_open():
                    self.state = CircuitBreakerState.OPEN
                    print(f"[{time.time():.2f}] Circuit Breaker: CLOSED -> OPEN.")
                raise CircuitBreakerException("Service call failed.") from e

    def reset(self):
        """서킷 브레이커를 초기 상태(Closed)로 리셋합니다."""
        self.state = CircuitBreakerState.CLOSED
        self.failure_count = 0
        self.last_failure_time = None
        self.success_count_in_half_open = 0

class CircuitBreakerException(Exception):
    """서킷 브레이커 관련 기본 예외."""
    pass

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

# --- 외부 서비스 시뮬레이션 ---
def flaky_service(succeed_ratio=0.5):
    """일정 확률로 성공하거나 실패하는 외부 서비스."""
    if random.random() < succeed_ratio:
        print(f"[{time.time():.2f}] Service call: SUCCESS")
        return "Data from service"
    else:
        print(f"[{time.time():.2f}] Service call: FAILURE")
        raise ValueError("Service internal error")

# --- 사용 예제 ---
if __name__ == "__main__":
    cb = CircuitBreaker(failure_threshold=3, recovery_timeout=5, half_open_test_limit=2) # 3회 연속 실패 시 Open, 5초 후 Half-Open, Half-Open에서 2회 성공 시 Closed
    
    print("--- 1. 정상 작동 구간 ---")
    for _ in range(5):
        try:
            cb.call(flaky_service, succeed_ratio=0.9) # 거의 성공
        except CircuitBreakerException as e:
            print(f"Caught exception: {e}")
        time.sleep(0.5)

    print("\n--- 2. 장애 발생 및 Open 상태 전환 ---")
    for i in range(10):
        try:
            print(f"Attempt {i+1}:")
            cb.call(flaky_service, succeed_ratio=0.1) # 거의 실패
        except CircuitBreakerOpenException as e:
            print(f"Caught exception (Circuit Breaker OPEN): {e}")
        except CircuitBreakerException as e:
            print(f"Caught exception (Service Failure): {e}")
        time.sleep(0.5)

    print("\n--- 3. Open 상태 유지 및 Half-Open으로 전환 대기 ---")
    print("Waiting for recovery timeout (5 seconds)...")
    time.sleep(5) # recovery_timeout 만큼 대기

    print("\n--- 4. Half-Open 상태 진입 및 복구 시도 ---")
    for i in range(5):
        try:
            print(f"Attempt {i+1}:")
            # Half-Open에서 복구를 시뮬레이션하기 위해 성공 확률을 높여봅니다.
            cb.call(flaky_service, succeed_ratio=0.7) 
        except CircuitBreakerOpenException as e:
            print(f"Caught exception (Circuit Breaker OPEN): {e}")
        except CircuitBreakerException as e:
            print(f"Caught exception (Service Failure): {e}")
        time.sleep(0.5)

    print("\n--- 5. 완전히 복구된 후 다시 정상 작동 ---")
    for _ in range(5):
        try:
            cb.call(flaky_service, succeed_ratio=0.9) # 거의 성공
        except CircuitBreakerException as e:
            print(f"Caught exception: {e}")
        time.sleep(0.5)

3.2. JavaScript (Node.js) 예제

Node.js 환경에서는 비동기 호출이 일반적이므로, Promise를 활용하여 구현합니다.

const CircuitBreakerState = {
    CLOSED: "Closed",
    OPEN: "Open",
    HALF_OPEN: "Half-Open"
};

class CircuitBreaker {
    constructor(options = {}) {
        this.failureThreshold = options.failureThreshold || 3; // Open 상태로 전환되기 위한 연속 실패 횟수 임계값
        this.recoveryTimeout = options.recoveryTimeout || 5000; // Open 상태에서 Half-Open 상태로 전환되기까지의 시간(ms)
        this.halfOpenTestLimit = options.halfOpenTestLimit || 1; // Half-Open 상태에서 허용되는 시험 요청 성공 횟수

        this.state = CircuitBreakerState.CLOSED;
        this.failureCount = 0;
        this.lastFailureTime = null;
        this.successCountInHalfOpen = 0;

        this.debug = options.debug || false;
    }

    _log(message) {
        if (this.debug) {
            console.log(`[${new Date().toISOString()}] Circuit Breaker: ${message}`);
        }
    }

    async call(func, ...args) {
        // Open 상태이면 즉시 실패를 반환합니다.
        if (this.state === CircuitBreakerState.OPEN) {
            if (Date.now() - this.lastFailureTime > this.recoveryTimeout) {
                this.state = CircuitBreakerState.HALF_OPEN;
                this.successCountInHalfOpen = 0; // Half-Open 진입 시 성공 카운트 초기화
                this._log("OPEN -> HALF-OPEN");
            } else {
                this._log("OPEN. Service unavailable.");
                throw new CircuitBreakerOpenException("Circuit breaker is open.");
            }
        }

        // Half-Open 상태이면 제한된 요청만 통과시킵니다.
        if (this.state === CircuitBreakerState.HALF_OPEN) {
            this._log("HALF-OPEN. Testing service...");
            try {
                const result = await func(...args);
                this.successCountInHalfOpen++;
                if (this.successCountInHalfOpen >= this.halfOpenTestLimit) {
                    this.reset();
                    this._log("HALF-OPEN -> CLOSED. Service recovered.");
                }
                return result;
            } catch (error) {
                this.failureCount++;
                this.lastFailureTime = Date.now();
                this.state = CircuitBreakerState.OPEN; // 실패 시 즉시 Open으로 돌아감
                this._log(`HALF-OPEN -> OPEN. Service still failing. Error: ${error.message}`);
                throw new CircuitBreakerOpenException(`Service failed during half-open test: ${error.message}`);
            }
        }

        // Closed 상태이면 정상적으로 함수를 호출합니다.
        if (this.state === CircuitBreakerState.CLOSED) {
            try {
                const result = await func(...args);
                this.failureCount = 0; // 성공하면 실패 카운트 초기화
                return result;
            } catch (error) {
                this.failureCount++;
                this.lastFailureTime = Date.now();
                this._log(`Failure detected (${this.failureCount}/${this.failureThreshold}). Error: ${error.message}`);
                if (this.failureCount >= this.failureThreshold) {
                    this.state = CircuitBreakerState.OPEN;
                    this._log("CLOSED -> OPEN.");
                }
                throw new CircuitBreakerException(`Service call failed: ${error.message}`);
            }
        }
    }

    reset() {
        this.state = CircuitBreakerState.CLOSED;
        this.failureCount = 0;
        this.lastFailureTime = null;
        this.successCountInHalfOpen = 0;
    }
}

class CircuitBreakerException extends Error {
    constructor(message) {
        super(message);
        this.name = "CircuitBreakerException";
    }
}

class CircuitBreakerOpenException extends CircuitBreakerException {
    constructor(message) {
        super(message);
        this.name = "CircuitBreakerOpenException";
    }
}

// --- 외부 서비스 시뮬레이션 ---
async function flakyService(succeedRatio = 0.5) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            if (Math.random() < succeedRatio) {
                console.log(`[${new Date().toISOString()}] Service call: SUCCESS`);
                resolve("Data from service");
            } else {
                console.log(`[${new Date().toISOString()}] Service call: FAILURE`);
                reject(new Error("Service internal error"));
            }
        }, 100); // 비동기 호출 시뮬레이션
    });
}

// --- 사용 예제 ---
async function main() {
    const cb = new CircuitBreaker({
        failureThreshold: 3,
        recoveryTimeout: 5000, // 5초
        halfOpenTestLimit: 2,
        debug: true
    });

    console.log("--- 1. 정상 작동 구간 ---");
    for (let i = 0; i < 5; i++) {
        try {
            await cb.call(flakyService, 0.9); // 거의 성공
        } catch (e) {
            console.error(`Caught exception: ${e.message}`);
        }
        await new Promise(res => setTimeout(res, 500));
    }

    console.log("\n--- 2. 장애 발생 및 Open 상태 전환 ---");
    for (let i = 0; i < 10; i++) {
        try {
            console.log(`Attempt ${i + 1}:`);
            await cb.call(flakyService, 0.1); // 거의 실패
        } catch (e) {
            if (e instanceof CircuitBreakerOpenException) {
                console.error(`Caught exception (Circuit Breaker OPEN): ${e.message}`);
            } else {
                console.error(`Caught exception (Service Failure): ${e.message}`);
            }
        }
        await new Promise(res => setTimeout(res, 500));
    }

    console.log("\n--- 3. Open 상태 유지 및 Half-Open으로 전환 대기 ---");
    console.log("Waiting for recovery timeout (5 seconds)...");
    await new Promise(res => setTimeout(res, 5000)); // recovery_timeout 만큼 대기

    console.log("\n--- 4. Half-Open 상태 진입 및 복구 시도 ---");
    for (let i = 0; i < 5; i++) {
        try {
            console.log(`Attempt ${i + 1}:`);
            await cb.call(flakyService, 0.7); // Half-Open에서 복구를 시뮬레이션하기 위해 성공 확률을 높여봅니다.
        } catch (e) {
            if (e instanceof CircuitBreakerOpenException) {
                console.error(`Caught exception (Circuit Breaker OPEN): ${e.message}`);
            } else {
                console.error(`Caught exception (Service Failure): ${e.message}`);
            }
        }
        await new Promise(res => setTimeout(res, 500));
    }

    console.log("\n--- 5. 완전히 복구된 후 다시 정상 작동 ---");
    for