NestJS Circuit Breaker 패턴

Circuit Breaker가 필요한 이유

마이크로서비스 환경에서 외부 API나 다른 서비스 호출이 실패하면 연쇄 장애(Cascading Failure)가 발생한다. 응답 없는 서비스에 계속 요청을 보내면 스레드 풀이 고갈되고, 요청이 쌓이면서 호출하는 쪽까지 다운된다. Circuit Breaker는 전기 회로의 차단기처럼, 실패가 임계치를 넘으면 요청 자체를 차단하여 시스템 전체를 보호한다.

이 글에서는 NestJS에서 Circuit Breaker 패턴을 구현하는 방법을 다룬다. opossum 라이브러리 기반 구현, Custom Decorator 패턴, Interceptor 통합, 그리고 모니터링까지 실전 패턴을 심화하여 설명한다.

Circuit Breaker 상태 머신

┌──────────┐  실패 임계치 초과  ┌──────────┐
│  CLOSED  │ ──────────────→ │   OPEN   │
│ (정상)   │                  │ (차단)   │
└────┬─────┘                  └────┬─────┘
     │                              │
     │  성공                  resetTimeout 경과
     │                              │
     │         ┌──────────┐         │
     └──────── │HALF-OPEN │ ←───────┘
               │(시험 허용)│
               └──────────┘
                │       │
           성공 │       │ 실패
           ↓ CLOSED    ↓ OPEN
  • CLOSED: 정상 상태. 모든 요청을 백엔드에 전달. 실패율을 추적한다.
  • OPEN: 차단 상태. 요청을 백엔드에 보내지 않고 즉시 실패(또는 fallback) 반환.
  • HALF-OPEN: 시험 상태. 제한된 요청만 통과시켜 복구 여부를 확인.

opossum 설치 및 기본 사용

opossum은 Node.js에서 가장 널리 쓰이는 Circuit Breaker 라이브러리다.

npm install opossum
npm install -D @types/opossum

기본 적용

import CircuitBreaker from 'opossum';
import { Injectable, HttpException, HttpStatus } from '@nestjs/common';
import { HttpService } from '@nestjs/axios';
import { firstValueFrom } from 'rxjs';

@Injectable()
export class PaymentService {
  private readonly breaker: CircuitBreaker;

  constructor(private readonly http: HttpService) {
    // Circuit Breaker로 감쌀 함수
    const callPaymentApi = async (orderId: string, amount: number) => {
      const { data } = await firstValueFrom(
        this.http.post('https://payment-api.internal/charge', {
          orderId,
          amount,
        }),
      );
      return data;
    };

    this.breaker = new CircuitBreaker(callPaymentApi, {
      timeout: 5000,          // 요청 타임아웃 5초
      errorThresholdPercentage: 50,  // 실패율 50% 초과 시 OPEN
      resetTimeout: 30000,    // OPEN 후 30초 뒤 HALF-OPEN
      rollingCountTimeout: 10000,    // 10초 윈도우 기준
      rollingCountBuckets: 10,       // 윈도우를 10개 버킷으로 나눔
      volumeThreshold: 5,     // 최소 5건 이상이어야 판정
    });

    // 이벤트 리스너
    this.breaker.on('open', () =>
      console.warn('[PaymentBreaker] OPEN - 결제 API 차단'));
    this.breaker.on('halfOpen', () =>
      console.info('[PaymentBreaker] HALF-OPEN - 시험 요청 허용'));
    this.breaker.on('close', () =>
      console.info('[PaymentBreaker] CLOSED - 정상 복구'));

    // Fallback 정의
    this.breaker.fallback((orderId: string) => ({
      status: 'QUEUED',
      message: '결제 시스템 일시 장애. 주문이 대기열에 등록되었습니다.',
      orderId,
    }));
  }

  async charge(orderId: string, amount: number) {
    try {
      return await this.breaker.fire(orderId, amount);
    } catch (error) {
      if (error.code === 'EOPENBREAKER') {
        throw new HttpException(
          '결제 서비스 일시 중단',
          HttpStatus.SERVICE_UNAVAILABLE,
        );
      }
      throw error;
    }
  }
}

Custom Decorator로 선언적 적용

매번 서비스마다 CircuitBreaker를 수동으로 생성하면 보일러플레이트가 많다. 데코레이터로 선언적으로 적용하자.

// circuit-breaker.decorator.ts
import CircuitBreaker from 'opossum';

export interface CircuitBreakerOptions {
  timeout?: number;
  errorThresholdPercentage?: number;
  resetTimeout?: number;
  volumeThreshold?: number;
  fallback?: (...args: any[]) => any;
}

const DEFAULT_OPTIONS: CircuitBreakerOptions = {
  timeout: 5000,
  errorThresholdPercentage: 50,
  resetTimeout: 30000,
  volumeThreshold: 5,
};

const breakerRegistry = new Map<string, CircuitBreaker>();

export function UseCircuitBreaker(
  name: string,
  options: CircuitBreakerOptions = {},
): MethodDecorator {
  return (target, propertyKey, descriptor: PropertyDescriptor) => {
    const originalMethod = descriptor.value;
    const mergedOptions = { ...DEFAULT_OPTIONS, ...options };

    descriptor.value = async function (...args: any[]) {
      const key = `${target.constructor.name}.${String(propertyKey)}.${name}`;

      if (!breakerRegistry.has(key)) {
        const breaker = new CircuitBreaker(
          originalMethod.bind(this),
          mergedOptions,
        );

        if (mergedOptions.fallback) {
          breaker.fallback(mergedOptions.fallback);
        }

        breaker.on('open', () =>
          console.warn(`[CircuitBreaker:${name}] OPEN`));
        breaker.on('close', () =>
          console.info(`[CircuitBreaker:${name}] CLOSED`));

        breakerRegistry.set(key, breaker);
      }

      return breakerRegistry.get(key)!.fire(...args);
    };

    return descriptor;
  };
}

// 상태 조회 유틸
export function getCircuitBreakerStatus(): Record<string, any> {
  const status: Record<string, any> = {};
  for (const [key, breaker] of breakerRegistry) {
    const stats = breaker.stats;
    status[key] = {
      state: breaker.opened ? 'OPEN' : breaker.halfOpen ? 'HALF-OPEN' : 'CLOSED',
      successes: stats.successes,
      failures: stats.failures,
      rejected: stats.rejects,
      timeout: stats.timeouts,
    };
  }
  return status;
}

데코레이터 사용

@Injectable()
export class NotificationService {
  constructor(private readonly http: HttpService) {}

  @UseCircuitBreaker('slack-api', {
    timeout: 3000,
    errorThresholdPercentage: 60,
    resetTimeout: 60000,
    fallback: (channel: string, message: string) => ({
      queued: true,
      message: '알림이 대기열에 저장되었습니다.',
    }),
  })
  async sendSlackNotification(channel: string, message: string) {
    const { data } = await firstValueFrom(
      this.http.post('https://slack.com/api/chat.postMessage', {
        channel,
        text: message,
      }),
    );
    return data;
  }

  @UseCircuitBreaker('email-api', {
    timeout: 10000,
    errorThresholdPercentage: 40,
    resetTimeout: 120000,
  })
  async sendEmail(to: string, subject: string, body: string) {
    // 이메일 발송 로직
  }
}

Interceptor로 글로벌 Circuit Breaker

특정 외부 호출 전체에 Circuit Breaker를 일괄 적용하려면 Interceptor를 사용한다.

// circuit-breaker.interceptor.ts
import {
  Injectable,
  NestInterceptor,
  ExecutionContext,
  CallHandler,
  HttpException,
  HttpStatus,
} from '@nestjs/common';
import { Observable, catchError, throwError, timeout } from 'rxjs';
import { Reflector } from '@nestjs/core';

interface BreakerState {
  failures: number;
  successes: number;
  state: 'CLOSED' | 'OPEN' | 'HALF_OPEN';
  lastFailureTime: number;
  nextAttempt: number;
}

@Injectable()
export class CircuitBreakerInterceptor implements NestInterceptor {
  private readonly breakers = new Map<string, BreakerState>();
  private readonly threshold = 5;
  private readonly resetTimeout = 30000;

  constructor(private readonly reflector: Reflector) {}

  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const key = `${context.getClass().name}.${context.getHandler().name}`;
    const state = this.getState(key);

    // OPEN 상태: 즉시 차단
    if (state.state === 'OPEN') {
      if (Date.now() < state.nextAttempt) {
        throw new HttpException(
          {
            statusCode: HttpStatus.SERVICE_UNAVAILABLE,
            message: 'Service temporarily unavailable (circuit open)',
            circuitBreaker: key,
            retryAfter: Math.ceil(
              (state.nextAttempt - Date.now()) / 1000,
            ),
          },
          HttpStatus.SERVICE_UNAVAILABLE,
        );
      }
      // resetTimeout 경과 → HALF_OPEN
      state.state = 'HALF_OPEN';
    }

    return next.handle().pipe(
      catchError((error) => {
        this.recordFailure(key);
        return throwError(() => error);
      }),
    );
  }

  private getState(key: string): BreakerState {
    if (!this.breakers.has(key)) {
      this.breakers.set(key, {
        failures: 0,
        successes: 0,
        state: 'CLOSED',
        lastFailureTime: 0,
        nextAttempt: 0,
      });
    }
    return this.breakers.get(key)!;
  }

  private recordFailure(key: string) {
    const state = this.getState(key);
    state.failures++;
    state.lastFailureTime = Date.now();

    if (state.failures >= this.threshold) {
      state.state = 'OPEN';
      state.nextAttempt = Date.now() + this.resetTimeout;
      console.warn(`[CircuitBreaker] ${key} → OPEN`);
    }
  }
}

Retry + Circuit Breaker 조합

Retry와 Circuit Breaker를 함께 사용할 때는 순서가 중요하다. Retry가 안쪽, Circuit Breaker가 바깥에 위치해야 한다.

import CircuitBreaker from 'opossum';
import { setTimeout } from 'timers/promises';

// Retry 로직을 포함한 함수
async function fetchWithRetry<T>(
  fn: () => Promise<T>,
  maxRetries = 3,
  baseDelay = 1000,
): Promise<T> {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await fn();
    } catch (error) {
      if (attempt === maxRetries) throw error;

      // Exponential backoff + jitter
      const delay = baseDelay * Math.pow(2, attempt)
        + Math.random() * 1000;
      console.warn(
        `Retry ${attempt + 1}/${maxRetries} after ${delay}ms`,
      );
      await setTimeout(delay);
    }
  }
  throw new Error('Unreachable');
}

@Injectable()
export class ExternalApiService {
  private readonly breaker: CircuitBreaker;

  constructor(private readonly http: HttpService) {
    // Circuit Breaker가 Retry를 감싸는 구조
    const callWithRetry = async (url: string) => {
      return fetchWithRetry(async () => {
        const { data } = await firstValueFrom(
          this.http.get(url, { timeout: 3000 }),
        );
        return data;
      }, 2, 500);  // 최대 2번 재시도
    };

    this.breaker = new CircuitBreaker(callWithRetry, {
      timeout: 15000,  // Retry 포함 전체 타임아웃
      errorThresholdPercentage: 50,
      resetTimeout: 30000,
      volumeThreshold: 3,
    });
  }

  async fetchData(url: string) {
    return this.breaker.fire(url);
  }
}

Bulkhead 패턴 조합

Circuit Breaker와 Bulkhead(격벽) 패턴을 조합하면 동시 요청 수도 제한할 수 있다.

class Bulkhead {
  private active = 0;
  private queue: Array<() => void> = [];

  constructor(
    private readonly maxConcurrent: number,
    private readonly maxQueue: number,
  ) {}

  async execute<T>(fn: () => Promise<T>): Promise<T> {
    if (this.active >= this.maxConcurrent) {
      if (this.queue.length >= this.maxQueue) {
        throw new Error('Bulkhead queue full');
      }
      await new Promise<void>((resolve) => this.queue.push(resolve));
    }

    this.active++;
    try {
      return await fn();
    } finally {
      this.active--;
      if (this.queue.length > 0) {
        const next = this.queue.shift()!;
        next();
      }
    }
  }
}

@Injectable()
export class ResilientService {
  private readonly bulkhead = new Bulkhead(10, 50);
  private readonly breaker: CircuitBreaker;

  constructor(private readonly http: HttpService) {
    const callApi = async (url: string) => {
      // Bulkhead로 동시 요청 제한
      return this.bulkhead.execute(async () => {
        const { data } = await firstValueFrom(
          this.http.get(url, { timeout: 5000 }),
        );
        return data;
      });
    };

    this.breaker = new CircuitBreaker(callApi, {
      timeout: 10000,
      errorThresholdPercentage: 50,
      resetTimeout: 30000,
    });
  }
}

모니터링: Prometheus 메트릭

import { Injectable } from '@nestjs/common';
import { Counter, Gauge } from 'prom-client';
import { getCircuitBreakerStatus } from './circuit-breaker.decorator';

@Injectable()
export class CircuitBreakerMetrics {
  private readonly stateGauge = new Gauge({
    name: 'circuit_breaker_state',
    help: 'Circuit breaker state (0=closed, 1=open, 2=half-open)',
    labelNames: ['name'],
  });

  private readonly requestCounter = new Counter({
    name: 'circuit_breaker_requests_total',
    help: 'Total circuit breaker requests',
    labelNames: ['name', 'result'],
  });

  collectMetrics() {
    const statuses = getCircuitBreakerStatus();
    for (const [name, status] of Object.entries(statuses)) {
      const stateValue =
        status.state === 'CLOSED' ? 0
        : status.state === 'OPEN' ? 1 : 2;
      this.stateGauge.set({ name }, stateValue);
      this.requestCounter.inc(
        { name, result: 'success' }, status.successes);
      this.requestCounter.inc(
        { name, result: 'failure' }, status.failures);
      this.requestCounter.inc(
        { name, result: 'rejected' }, status.rejected);
    }
  }
}

// Health 엔드포인트에 Circuit Breaker 상태 포함
@Controller('health')
export class HealthController {
  @Get('circuit-breakers')
  getCircuitBreakerHealth() {
    const status = getCircuitBreakerStatus();
    const allClosed = Object.values(status)
      .every((s: any) => s.state === 'CLOSED');

    return {
      status: allClosed ? 'UP' : 'DEGRADED',
      breakers: status,
    };
  }
}

설정 가이드라인

파라미터 권장값 설명
timeout P99 latency × 2 정상 응답 시간의 2배
errorThresholdPercentage 50% 너무 낮으면 일시적 오류에도 OPEN
resetTimeout 30~60초 백엔드 복구 시간 고려
volumeThreshold 5~10 최소 요청 수 미달 시 판정 안 함
rollingCountTimeout 10초 실패율 측정 윈도우

마무리

Circuit Breaker는 마이크로서비스 환경에서 연쇄 장애를 차단하는 핵심 방어 메커니즘이다. NestJS에서는 opossum 라이브러리와 Custom Decorator를 조합하면 선언적으로 적용할 수 있다.

실무 적용 핵심:

  • Fallback 필수: OPEN 상태에서도 사용자에게 의미 있는 응답 반환
  • Retry는 안쪽: Circuit Breaker → Retry → 실제 호출 순서로 감싸기
  • volumeThreshold 설정: 트래픽이 적을 때 오판 방지
  • 모니터링 필수: Prometheus 메트릭으로 OPEN 상태 즉시 감지

NestJS Health Check·Graceful Shutdown 글에서 헬스체크와 결합하면 장애 감지부터 차단까지 일관된 체계를 구축할 수 있다. Spring 환경이라면 Spring Resilience4j 서킷브레이커를 참고하자.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux