Design

Circuit Breaker Pattern

Seung-o 2023. 8. 1. 07:13

개요

많은 서버들에서는 안정성을 위해 Circuit Breaker 패턴을 취하고 있다. 본 페이지에서는 Circuit Breaker 패턴의 정의와 구현 방식에 대해 정리해본다.

 

Circuit Breaker Pattern 이란?

배경

서비스 개발 도중 외부 의존성은 피해갈 수 없다. 가령, 게시물을 올리는 서비스를 생각해보자. 개발자는 이미지를 버퍼로 받아서 Google Cloud Storage 나 AWS DynamoDB 등에 이미지를 업로드한다. 이 때, 예기치 못하게 Google Cloud Storage 나 AWS DynamoDB에 장애가 생긴다면, 어떻게 될까? 해당 API는 장애를 겪게 되고, 경우에 따라서는 장애가 다른 곳까지 전파될 수도 있다. 물론 적절한 에러 핸들링이 조치에 도움이 될 때도 있지만, MSA ( Microservice Architecture ) 를 향해 갈수록 그리고 서비스의 규모가 커질수록 외부 의존성에 대한 트랙킹과 핸들링이 어려워진다. 외부 의존성에서 발생하는 예측불가한 에러들에 대해 타 서비스로의 영향을 최소화하기 위한 등장한 것이 바로 Circuit Breaker 패턴이다.

 

정의

Circuit Breaker 패턴은 분산 시스템에서 서비스 간의 의존성과 네트워크 호출을 관리하며, 장애 상황에서 서비스 간의 전파를 제어하는 데 도움이 되는 디자인 패턴이다. 이 패턴은 서비스 간의 통신에서 발생할 수 있는 장애나 지연 시간 등의 문제로부터 시스템을 보호하고, 서비스의 격리와 회복력을 향상시킨다.

  1. Closed(정상 상태): 초기 상태로, 서비스 간의 호출이 정상적으로 수행된다. 이 때, 간헐적으로 요청이 실패한다면, 재요청 로직을 통해 서비스를 다시 호출할 수 있다. 재요청 로직을 수행하다가, 실패 횟수가 임계치 이상이 되면 다음에 나오는 Open 상태로 이전된다.
  2. Open(차단 상태): 서비스 간의 호출이 장애로 인해 요청이 특정 임계치 이상 실패할 때, Circuit Breaker는 일정한 시간 동안 차단 상태로 전환된다. 이 때, 추가적인 호출은 차단되고 미리 정의된 실패 응답이 반환된다.
  3. Half-Open(반 열림 상태): 일정 시간 동안 차단 상태가 유지된 후, Circuit Breaker는 일부 요청을 다시 허용한다. 이를 통해 서비스의 회복 상태를 확인하고 문제가 지속되는지 여부를 판단할 수 있다.

아래는 전반적인 프로세스 모식도이다.

 

장점

  • 서비스 격리: Circuit Breaker를 사용하여 장애 서비스로부터의 호출을 차단함으로써 다른 서비스에 장애가 전파되는 것을 방지한다.
  • 실패 처리: 실패한 호출을 빠르게 처리하여 장애 상황에서 빠른 응답을 제공한다.
  • 회복력 향상: 일정 시간 동안 장애 상태를 감지하고, 회복 상태가 되면 서비스를 다시 사용 가능한 상태로 변경한다.

적용 방법

Node 진영에서의 적용 방법을 중점으로 설명하고자 한다.

 

직접 구현

LogRocket 블로그를 참고하여 작성하였다. 재요청 로직 등이 많이 부실하게 작성된 편인데, 대략적인 Circuit Breaker 패턴의 로직 흐름을 이해하는 정도의 레퍼런스로 보면 좋을 것 같다.

 

circuit-breaker-state.enum.ts

export enum CircuitBreakerState {
  OPENED = "OPENED",
  CLOSED = "CLOSED",
  HALF = "HALF"
}

 

circuit-breaker.class.ts

import * as axios from 'axios';
import { Request } from 'express';
import { CircuitBreakerState } from './circuit-breaker.enum.ts';

export class CircuitBreaker {
  private request = null;
  private state: CircuitBreakerState = CircuitBreakerState.CLOSED;
  private failureCount: number;
  private failureThreshold: number; // Circuit을 OPEN 상태로 바꾸는 임계치
  private resetAfter: number; // OPEN 상태의 Circuit을 HALF 상태로 전환하기 위해 기록하는 현재 시간
  private timeout: number; // OPEN 상태의 Circuit을 HALF 상태로 전환하기 위해 대기하는 시간

  constructor(request: Request, options) {
    this.request = request;
    this.state = CircuitBreakerStates.CLOSED; // 상태는 CLOSED로 초기화한다
    this.failureCount = 0;
    this.resetAfter = Date.now(); 

    if (options) {
      this.failureThreshold = options.failureThreshold;
      this.timeout = options.timeout;
    } else {
      this.failureThreshold = 5;
      this.timeout = 5000;
    }
  }

  async fire() {
    if (this.state === CircuitBreakerState.OPENED) {
      if (this.resetAfter <= Date.now()) {
        this.state = CircuitBreakerState.HALF;
      } else {
        throw new Error('Circuit이 OPEN 상태입니다.');
      }
    }
    try {
      const response = await axios(this.request);
      if (response.status === 200) return this.success(response.data);
      return this.failure(response.data);
    }
    catch(err) {
      return this.failure(err.message);
    }
  } 

  success(data) {
    this.failureCount = 0
    if (this.state === CircuitBreakerState.HALF) {
      this.state = CircuitBreakerState.CLOSED;
    }
    return data;
  }

  failure(data) {
    this.failureCount += 1;
    if (
      this.state === CircuitBreakerState.HALF ||
      this.failureCount >= this.failureThreshold
    ) {
			/** 필요에 따라 알림 로직을 추가 */
      this.state = CircuitBreakerState.OPENED;
      this.resetAfter = Date.now() + this.timeout;
    }
    return data;
  }
}

 

main.ts

// 요청 샘플
const request = axios fetchDataFromExternalVendor();

// 요청을 CircuitBreaker 인스턴스로 랩핑
const circuitBreakerObject = new CircuitBreaker(request, { failureThreshold: 4, timeout: 4000 });

circuitBreakerObject.fire()
  .then((data) => console.log(`Data: ${data}`))
  .catch((err) => console.log(`Error: ${err.message}`);

 

NPM 라이브러리 활용

사용할 수 있는 라이브러리는 여러가지 있겠지만, 필자는 cockatiel을 사용하여 구현하였다. 이것은 Polly 라고 하는 오픈 소스를 기반으로 작성되었는데, 이 오픈 소스는 최근까지도 활발하게 배포되었으며 많은 contributer와 stars를 지니고 있다. 다음은 Polly Repository 내 ReadMe 를 요약한 것이다.

 

Polly는 .NET 기반의 강인성(resilience)과 임시 오류 처리를 위한 라이브러리로, 개발자들이 Retry, Circuit Breaker, Timeout, Bulkhead Isolation, Rate-limiting, Fallback 등의 정책들을 표현할 수 있도록 도와줍니다. 특히 네트워크 호출, 데이터베이스 연결, 외부 서비스 호출 등과 같이 임시 오류가 발생할 수 있는 작업들을 처리하는 데 유용합니다. 
Polly는 매우 다양한 오류 처리 기능과 유연한 구성 옵션을 제공하며, 스레드 안전(thread-safe)하게 사용할 수 있습니다. 개발자들은 폭넓은 .NET 플랫폼을 지원하는 다양한 타깃으로 Polly를 사용할 수 있으며, .NET Standard 1.1 이상과 .NET Framework 4.6.1 이상을 지원합니다.
주요 기능
Retry(재시도): 네트워크 호출 등에서 일시적인 오류가 발생할 때, 지정된 조건에 따라 요청을 재시도할 수 있습니다. 실패한 요청에 대해 일정 횟수만큼 재시도하거나, 지정된 간격으로 재시도할 수 있습니다.Circuit Breaker(서킷 브레이커): 지정된 임계치를 초과하는 오류가 발생하면 서킷을 차단하여 빠르게 실패를 반환하는 방식으로 오류가 전파되는 것을 방지합니다. 서킷이 차단되면 잠시 후에 다시 시도하거나 fallback 로직으로 대체할 수 있습니다.Timeout(제한 시간 초과): 작업의 수행 시간이 너무 오래 걸릴 경우, 지정된 시간 내에 작업이 완료되지 않으면 오류를 반환하도록 설정할 수 있습니다.Bulkhead Isolation(벌크헤드 격리): 서로 다른 작업들 사이에 격리된 리소스를 할당하여 오류가 하나의 작업에서 발생하여도 다른 작업에 영향을 미치지 않도록 합니다.Rate-limiting(요청 속도 제한): 네트워크 호출 등의 요청을 일정 속도로 제한하여 서버의 부하를 관리합니다.Fallback(폴백): 주요 작업이 실패할 경우 대체 작업(fallback)을 수행하여 사용자에게 오류 대신 대체 결과를 제공합니다.
Polly는 .NET Foundation의 멤버로서 개발되고 있으며, 오픈 소스 프로젝트입니다. Polly를 사용하여 .NET 애플리케이션의 강인성과 임시 오류 처리를 보다 효과적으로 구현할 수 있습니다.

 

사용 방법은 아래와 같다.

 

설치

$ npm install --save cockatiel // npm을 사용하는 경우
$ yarn add cockatiel // yarn을 사용하는 경우

구현

import {
  ConsecutiveBreaker,
  ExponentialBackoff,
  retry,
  handleAll,
  circuitBreaker,
  wrap,
} from 'cockatiel';
import { database } from './my-db';

// 재요청 옵션
const retryPolicy = retry(handleAll, { maxAttempts: 3, backoff: new ExponentialBackoff() });

// Circuit Breaker 옵션
const circuitBreakerPolicy = circuitBreaker(handleAll, {
  halfOpenAfter: 10 * 1000,
  breaker: new ConsecutiveBreaker(5),
});

// 재요청 옵션과 Circuit Breaker 옵션을 랩핑하여 함수를 구성한다
const retryWithBreaker = wrap(retryPolicy, circuitBreakerPolicy);

exports.handleRequest = async (req, res) => {
  const data = await retryWithBreaker.execute(() => database.getInfo(req.params.id));
  return res.json(data);
};

 

단일한 요청에 대해서 뿐만 아니라, 외부 의존성이 개입되는 모든 요청에 대해 이 패턴을 적용하기 위해 NestJS 에서는 데코레이터로 랩핑 로직을 묶을 수도 있다. 만약 적절한 데코레이터로 구현이 되었다면, 다음과 같은 호출만으로 패턴을 바로 적용시킬 수도 있다.

import { Injectable } from '@nestjs/common';
import { CircuitBreaker } from './circuit-breaker';

@Injectable()
export class MyService {
  @CircuitBreaker()
  async callExternalService(): Promise<any> {
    // 외부 서비스 호출
  }
}

 

결론

자신의 서비스 규모, 특성, 구조에 따라 Circuit Breaker 패턴을 적절하게 활용하면, 조금 더 안정성 있는 어플리케이션을 만들 수 있을 것이라고 기대한다.