서킷 브레이커

2025-09-01


서킷 브레이커

현대의 소프트웨어 아키텍처, 특히 여러 서비스가 분산되어 상호작용하는 마이크로서비스 환경에서 안정적인 시스템을 구축하는 것은 중요하다. 이러한 분산 시스템에서는 네트워크 호출이 빈번하며, 개별 서비스의 장애는 언제든 발생할 수 있는 필연적인 요소이다.

단순한 오류 처리 메커니즘을 넘어 시스템 전체의 안정성과 복원력을 보장하기 위한 전략이 요구된다. 서킷 브레이커 패턴은 이러한 맥락에서 등장한 설계 전략으로 시스템의 일부에서 발생한 장애가 전체 시스템으로 전파되는 것을 방지하는 역할을 한다.

분산 시스템에서의 연쇄 장애

분산 시스템에서 하나의 서비스 장애가 어떻게 전체 시스템을 마비시키는지 이해해야 한다. 특정 다운스트림 서비스가 느려지거나 응답하지 않게 되면, 해당 서비스를 동기적으로 호출하는 업스트림 서비스들은 응답을 기다리며 스레드 풀, 소켓, 메모리와 같은 시스템 리소스를 고갈시키기 시작한다.

한 컴포넌트의 지연 시간 증가나 오류 발생이 다른 컴포넌트들의 재시도나 부하를 유발하고, 이는 다시 원래 문제 지점의 부하를 가중시키는 결과를 낳는다. 이러한 도미노 효과는 국지적인 결함이 순식간에 시스템 전체의 장애로 확산되는 장애로 귀결된다.

서킷 브레이커 패턴

이런 파국적인 연쇄 장애를 막기 위한 해결책이 바로 서킷 브레이커 패턴이다. 서킷 브레이커는 전기 회로의 서킷 브레이커에서 영감을 받아 설계된 소프트웨어 패턴으로, 물리적 회로 차단기가 과부하로부터 전기 회로를 보호하기 위해 전류의 흐름을 차단하듯, 소프트웨어 서킷 브레이커는 장애가 발생한 서비스로 향하는 요청의 흐름을 일시적으로 차단하여 시스템 전체를 보호한다.

서킷 브레이커의 기본 아이디어는 다음과 같이 요약할 수 있다.

  1. 보호하려는 함수 호출을 서킷 브레이커로 감싼다.
  2. 이 객체는 실패를 감시하다가, 실패 횟수가 특정 임계값에 도달하면 회로를 차단한다.
  3. 이후 모든 호출은 보호된 함수를 실제로 호출하지 않고 즉시 오류를 반환한다.

이는 문제가 발생한 서비스에 더 이상의 부하를 가하지 않고 스스로 복구할 시간을 준다. 동시에 호출하는 측에서는 빠르게 실패하여 리소스를 낭비하지 않도록 하는 것이 핵심이다.

단순한 재시도 패턴이 장애 발생 후에만 반응하는 대응적 방식인 반면, 서킷 브레이커는 예방적 접근법을 취한다. 재시도 메커니즘은 현재 작업의 범위를 벗어나는 과거의 실패 기록 없이, 일시적인 오류가 해결되었기를 바라며 맹목적으로 작업을 재시도하지만, 서킷 브레이커는 상태를 가진 프록시로서 작동한다. 최근 호출의 결과를 지속적으로 추적하고 기록한다. 이 기록을 통해 단일 실패가 아닌 실패의 패턴을 감지할 수 있다. 실패율이 설정된 임계값을 초과하면, 서킷 브레이커는 후속 호출 역시 실패할 가능성이 높다고 예측한다. 이러한 예측된 실패가 더 많은 시스템 리소스를 소모하기 전에 사전에 차단하여 결과적으로 서킷 브레이커는 오류 처리를 단순한 대응에서 관찰된 상태에 기반한 사전 예방적인 전략으로 전환시킨다.

상태 머신

서킷 브레이커 패턴의 기능적 핵심은 내부 상태 머신에 있다. 이 상태 머신은 전기 회로 차단기의 동작을 모방하여 시스템의 안정성을 동적으로 관리한다.

닫힘 (Closed)

닫힌 상태는 서킷 브레이커의 기본 상태로, 정상적으로 흐르는 상태와 같다. 이 상태에서 서킷 브레이커는 투명한 프록시처럼 동작하며, 모든 요청을 보호된 작업으로 정상적으로 전달한다.

하지만 단순히 요청을 전달하는 것에 그치지 않는다. 서킷 브레이커는 각 호출의 결과를 지속적으로 모니터링한다. 타임아웃, 연결 오류, 특정 HTTP 상태 코드 등 실패로 간주되는 작업이 발생할 때마다 내부의 실패 카운터를 증가시킨다. 성공적인 호출은 이 카운터를 초기화하거나 롤링 윈도우 방식의 성공률 계산에 반영된다.

열림 (Open)

실패 횟수나 비율이 사전에 정의된 임계값을 초과하면, 서킷 브레이커는 열린 상태로 전환된다.

열린 상태에서는 보호된 작업에 대한 모든 후속 요청을 실제로 실행하지 않고 즉시 거부한다. 호출자에게는 즉각적인 오류를 반환하거나 미리 정의된 대체 메커니즘을 실행한다. 이것이 바로 "빠른 실패" 메커니즘의 핵심이다.

열린 상태로 전환되는 즉시 서킷 브레이커는 리셋 타이머를 시작한다. 이 타임아웃 기간 동안 회로는 열린 상태를 유지하며, 다운스트림 서비스가 추가적인 요청 부담 없이 스스로 복구할 수 있는 시간을 제공한다.

반-열림 (Half-Open)

열린 상태의 리셋 타임아웃이 만료되면, 서킷 브레이커는 반-열림 상태로 전환된다. 이 상태는 서비스의 회복 여부를 조심스럽게 확인하기 위한 시험 단계다.

반-열림 상태에서는 제한된 수의 요청만을 보호된 작업으로 전달한다. 이러한 시험적 요청의 결과가 다음 상태를 결정하는 중요한 분기점이 된다. 요청이 성공하면 서킷 브레이커는 서비스가 복구되었다고 판단하고 닫힌 상태로 전환한다. 반대로 단 하나의 요청이라도 실패하면, 문제가 여전히 지속되고 있다고 간주하고 즉시 열린 상태로 되돌아가며 리셋 타이머를 다시 시작한다.

구현해보기

이제 실제 코드로 구현해보자. 운영을 위한 것이 아닌 학습용으로 작성하기 위해 많은 부분에서 단순화하여 구현한다. (슬라이딩 윈도우, 지수 백오프 등은 생략)

전체 구현 코드는 Gist에서 확인할 수 있다.

기본 구조와 상태 정의

먼저 서킷 브레이커의 기본 구조를 정의해보자.

type CircuitBreakerState = 'CLOSED' | 'OPEN' | 'HALF_OPEN';

export class CircuitBreaker {
  private state: CircuitBreakerState = 'CLOSED';
  private failureThreshold: number = 5;
  private successThreshold: number = 3;
  private failures: number = 0;
  private successes: number = 0;
  private retryTime: number = 10000;
  private lastFailureTime: number = 0;

상태 타입을 명시적으로 정의하여 타입 안전성을 보장하고, 각 임계값과 카운터를 private 필드로 관리한다. failureThreshold는 회로를 여는 기준이 되는 실패 횟수이고, successThreshold는 반-열린 상태에서 닫힌 상태로 복귀하기 위한 성공 횟수다.

상태 전환 관리

상태 전환은 next() 메서드를 통해 중앙집중식으로 관리한다.

private next(state: CircuitBreakerState): void {
  this.state = state;
  if (state === 'CLOSED') {
    this.failures = 0;
    this.successes = 0;
  } else if (state === 'OPEN') {
    this.lastFailureTime = Date.now();
    this.failures = 0;
  } else if (state === 'HALF_OPEN') {
    this.successes = 0;
  }
}

각 상태로 전환할 때마다 관련 카운터들을 적절히 초기화한다. 특히 열린 상태로 전환할 때는 lastFailureTime을 기록하여 나중에 타임아웃 계산에 사용한다.

성공과 실패 처리

성공과 실패에 대한 처리 로직은 현재 상태에 따라 다르게 동작한다.

private handleSuccess(): void {
  if (this.state === 'HALF_OPEN') {
    this.successes++;
    if (this.successes >= this.successThreshold) {
      this.next('CLOSED');
    }
  } else {
    this.failures = 0;
  }
}

private handleFailure(): void {
  if (this.state === 'HALF_OPEN') {
    this.next('OPEN');
  } else {
    this.failures++;
    if (this.failures >= this.failureThreshold) {
      this.next('OPEN');
    }
  }
}

반-열린 상태에서의 처리가 핵심이다. 성공 시에는 연속 성공 횟수를 누적하여 임계값에 도달하면 완전 복구로 판단한다. 반면 실패 시에는 즉시 열린 상태로 되돌아가는 시험 호출 방식으로 동작한다. 반-열림에서 허용되는 시험 호출은 제한된 수나 비율로 제어되며, 이들의 누적 결과를 바탕으로 상태 전이가 결정된다.

call

실제 작업을 실행하는 call() 메서드는 서킷 브레이커의 모든 로직이 집약된 부분이다.

public async call<T>(action: () => Promise<T>): Promise<T> {
  if (this.state === 'OPEN') {
    if (Date.now() - this.lastFailureTime >= this.retryTime) {
      this.next('HALF_OPEN');
    } else {
      return Promise.reject(new CircuitOpenError());
    }
  }

  try {
    const result = await action();
    this.handleSuccess();
    return result;
  } catch (error) {
    this.handleFailure();
    throw error;
  }
}

열린 상태에서는 먼저 타임아웃을 확인한다. 충분한 시간이 지났다면 반-열린 상태로 전환하여 탐색을 시도하고, 그렇지 않다면 즉시 실패를 반환한다. 실제 작업 실행 후에는 결과에 따라 적절한 핸들러를 호출하여 상태를 업데이트한다.

조금 더 생각해보기

대체 전략의 중요성

서킷 브레이커의 주 기능인 호출 방지는 상응하는 대체 전략 없이는 불완전하다. 이 둘의 조합은 서비스의 점진적 성능 저하를 가능하게 한다. 회로가 열린 상태일 때, 시스템은 단순히 오류를 반환하는 대신 대체 메커니즘을 트리거할 수 있다. 예를 들어, 이전에 성공한 호출로부터 얻은 캐시된 데이터를 반환하거나, 미리 정의된 기본값이나 정적 응답을 제공하여 최소한의 기능을 보장할 수 있다. 경우에 따라 주 서비스 대신 기능이 다소 부족한 대안 서비스를 호출하거나, 즉시 처리가 필요하지 않은 작업은 큐에 쌓아 서비스가 복구되었을 때 나중에 처리하도록 하는 방법도 있을 것이다. 이러한 전략들은 완전한 실패 대신 부분적이거나 대안적인 서비스를 제공함으로써 사용자 경험을 개선하고, 서킷 브레이커와 결합하여 장애 상황에서도 시스템이 우아하게 성능을 저하시킬 수 있게 한다.

서킷 브레이커와 재시도

두 패턴은 종종 혼동되지만 목적은 분명히 다르다. 재시도 패턴은 일시적이고 단기적인 장애(예: 순간적인 네트워크 끊김, 잠깐의 데드락 등)를 다루기 위한 호출 단위의 로컬 정책이다. 많은 구현은 지수 백오프, 지터, 최대 시도 횟수 같은 완화 기법을 포함하며, 이는 서버 폭주를 완화하기 위한 모범 사례다.

반면 서킷 브레이커 패턴은 더 오래 지속되거나 시스템적 장애에 대한 시스템 수준의 보호 장치다. 연속적인 실패 패턴을 감지해 불필요한 호출을 차단함으로써 리소스 고갈을 방지하는 것이 목표다. 이 둘은 상호보완적으로 설계된다. 예를 들어 재시도 로직을 호출 전에 적용하되, 반복적인 실패가 누적되면 이는 서킷 브레이커의 실패 카운터로 이어져 회로가 열리게 된다. 이럴 때 재시도 로직은 서킷 브레이커가 열린 상태임을 확인하여 즉시 재시도를 중단하도록 해야 한다.

결론

본 글에서 살펴본 서킷 브레이커 패턴은 단순한 코드 기법을 넘어, 예측 불가능한 장애가 빈번한 환경에서 시스템이 최소한의 서비스 품질을 유지하도록 돕는 핵심 도구다.