서킷 브레이커 패턴
개요
주문 서비스가 환율 API를 불러 결제 금액을 계산한다고 하자. 환율 API가 멈춰서 응답하지 않으면 호출은 타임아웃(예: 2초)이 지나야 실패로 돌아온다. 장애가 몇 분 이어지면 그동안 들어온 요청이 모두 2초씩 기다리면서 스레드와 연결을 붙잡고, 서비스는 응답하지 못하는 API에 요청을 계속 보낸다. 재시도까지 붙어 있으면 요청 하나가 보내는 호출 수만 늘어난다.
서킷 브레이커(circuit breaker)는 의존성 호출을 감싸서 최근 실패를 세는 객체다. 실패가 정해 둔 기준(threshold)에 이르면 한동안 의존성을 부르지 않고 호출을 바로 실패로 돌려준다. 타임아웃을 기다리지 않고 곧바로 실패를 알리는 이 동작을 빠른 실패(fail fast)라고 한다. 이름은 전기 회로 차단기에서 왔고, Microsoft 문서도 세 상태가 전기 차단기의 동작을 흉내 낸다고 설명한다.1
브레이커는 세 상태를 오간다. 닫힘(Closed)은 평소 상태로, 호출을 의존성에 넘기고 실패를 센다. 열림(Open)은 호출을 의존성에 넘기지 않고 바로 거절하는 상태다. 열린 뒤 열림 시간(open duration)이 지나면 반열림(Half-Open)이 되어, 시험 호출(trial call)로 의존성이 회복했는지 확인한다. 시험이 성공하면 닫히고, 실패하면 다시 열린다. 이 글의 예제에서는 환율 API가 의존성이고, 연속 실패 3번이 기준, 30초가 열림 시간이다.
구조
Closed와 Open 상자의 아래 칸은 상태를 바꾸지 않는 내부 전이다. Azure 문서는 열림 타이머가 끝나면 Half-Open으로 바뀐다고 설명하지만,1 이 글의 코드는 Polly처럼 타이머를 두지 않는다. 열림 시간이 지난 뒤 처음 들어온 호출이 상태를 HalfOpen으로 옮기고, 그 호출이 그대로 시험 호출이 된다.2
failure는 정책이 실패로 세는 결과다. 범위 넓힌 예제의 InvalidRequest처럼 세지 않는 오류는 어느 상태에서도 전이를 일으키지 않으므로, HalfOpen에서 이런 오류가 나면 상태가 그대로이고 다음 호출이 다시 시험 호출이 된다.
역사
Michael Nygard는 2007년 Pragmatic Bookshelf에서 낸 Release It!에서 Circuit Breaker를 다뤘다. 출판사가 공개한 목차에서 5장 "Stability Patterns"는 5.1 Use Timeouts, 5.2 Circuit Breaker, 5.3 Bulkheads 순으로 시작한다.3 2018년 1월에 나온 2판의 목차에서 Circuit Breaker는 Timeouts, Bulkheads, Fail Fast 등과 함께 "Stability Patterns" 장에 들어 있다.4
Martin Fowler는 2014년 bliki 글 "CircuitBreaker"에서 Nygard가 연쇄 장애를 막는 이 패턴을 널리 알렸다고 적고, Ruby 코드로 구조를 설명했다. 실패가 기준에 이르면 브레이커가 트립(trip)되어 회로가 열리고, 이후 호출은 보호 대상을 부르지 않고 바로 오류를 받는다. 재설정 시간이 지난 뒤 반열림 상태에서 들어온 호출은 시험 호출이 되어, 성공하면 브레이커를 재설정하고 실패하면 다시 기다린다. Fowler의 예제 코드에서 실패 카운터는 타임아웃마다 1씩 늘고 성공하면 0으로 돌아간다.5
Microsoft Azure Architecture Center는 브레이커를 실패할 수 있는 작업의 프록시(호출을 대신 받아 넘기는 대리 객체)로 설명한다. 이 문서에서 Closed의 실패 카운터는 시간 기준이라 주기적으로 초기화되고, 정해진 간격 안에 정해진 수만큼 실패해야 회로가 열린다. Half-Open은 제한된 수의 요청만 통과시켜, 회복 중인 서비스에 요청이 한꺼번에 몰리지 않게 한다.1
Netflix의 Hystrix는 원격 시스템에 접근하는 지점을 격리하는 지연·장애 허용 라이브러리로, 격리 기능과 함께 circuit breaker를 제공했다. README는 Hystrix가 더 이상 활발히 개발되지 않는 유지 관리 모드이고 1.5.18이 마지막 릴리스라고 밝히며, 새 내부 프로젝트에는 resilience4j 같은 활발한 프로젝트를 쓰겠다고 적었다.6 resilience4j의 CircuitBreaker는 최근 N번의 호출(개수 기반)이나 최근 N초 동안의 호출(시간 기반)을 슬라이딩 윈도로 모아 실패율과 느린 호출 비율을 계산한다.7
왜 사용하는가?
열린 동안 호출자는 타임아웃을 기다리지 않고 바로 결과를 받는다. Azure 문서는 타임아웃을 기다리며 막힌 요청이 메모리, 스레드, DB 연결 같은 자원을 붙잡아 관련 없는 기능까지 실패하게 만들 수 있다고 설명한다.8 회로가 열리면 새 호출은 이 자원을 붙잡고 기다리지 않고, 장애가 난 의존성도 줄어든 요청 속에서 회복할 시간을 얻는다. 이미 실행 중인 호출을 끝내는 일은 타임아웃과 취소 처리가 맡는다. 반열림 상태는 시험 호출만 통과시키므로, 막 회복한 의존성에 평소 부하가 한꺼번에 몰리지도 않는다.
상태 전이는 의존성의 상태를 알려 주는 신호이기도 하다. Fowler는 브레이커의 상태 변화를 모두 로그로 남기고, 더 깊은 모니터링을 위해 브레이커가 자기 상태를 드러내야 한다고 적었다.5
브레이커는 의존성을 고쳐 주지 않고, 거절된 호출을 어떻게 처리할지도 호출자의 몫이다. Azure 문서가 드는 선택지는 기능을 잠시 줄이기, 같은 데이터를 다른 작업으로 얻기, 사용자에게 나중에 다시 시도하라고 알리기 등이다. 기준과 열림 시간도 직접 맞춰야 한다. 너무 오래 열어 두면 의존성이 회복한 뒤에도 거절이 이어지고, 너무 빨리 반열림으로 넘어가면 상태가 자주 바뀐다.9
무엇을 실패로 셀지도 정해야 한다. Fowler는 모든 오류가 회로를 열어서는 안 되며, 일부는 일반 로직에서 처리할 정상적인 실패라고 적었다.5 요청 형식이 틀렸다는 오류는 의존성이 살아서 응답했다는 뜻이므로 세지 않는다. 브레이커를 두는 단위도 결과를 바꾸는데, 브레이커 하나를 서로 독립된 대상(예: 여러 샤드) 전체에 쓰면 한 대상의 장애 때문에 정상인 대상까지 막힐 수 있다.9
Azure 문서는 메모리 안의 자료구조처럼 애플리케이션 안에서 접근하는 자원에는 브레이커가 부담만 더하고, 비즈니스 로직의 예외 처리를 대신하는 용도로도 맞지 않다고 적는다.10
.NET에서는 Polly가 서킷 브레이커 전략을 제공한다. 기본값에서는 30초 표본 기간(SamplingDuration) 안에 최소 100번(MinimumThroughput) 실행되고 실패 비율이 0.1(FailureRatio)에 이르면 열리며, 5초(BreakDuration) 동안 열려 있다. 열린 동안 실행되지 않은 호출은 BrokenCircuitException을 받는다.2 Microsoft.Extensions.Resilience와 Microsoft.Extensions.Http.Resilience는 Polly 위에 만든 패키지로, AddCircuitBreaker로 이 전략을 파이프라인에 넣는다.11 이 글의 예제는 패키지 없이 상태 전이만 직접 구현한다.
재시도·타임아웃과 함께 쓸 때
재시도 패턴은 일시적 실패가 곧 풀리리라 보고 같은 호출을 다시 보낸다. 서킷 브레이커는 반대로 실패할 가능성이 큰 호출을 보내지 않는다. 둘을 같이 쓸 때 Azure 문서는 재시도가 브레이커를 거쳐 호출하되, 브레이커가 일시적 장애가 아니라고 알리면 재시도를 멈추라고 한다.1 브레이커를 통과한 재시도 시도도 실패로 세어지므로, 재시도는 회로가 열리는 시점을 앞당긴다. Polly의 브레이커는 재시도를 스스로 하지 않고 Retry 전략과 조합해 쓰며,2 Microsoft.Extensions.Resilience 파이프라인은 전략을 추가한 순서대로 실행하므로 순서를 정해야 한다.11 Microsoft.Extensions.Http.Resilience의 표준 핸들러는 바깥에서 안쪽으로 레이트 리미터, 전체 타임아웃, 재시도, 서킷 브레이커, 시도별 타임아웃을 쌓는다. 재시도는 브레이커 바깥에, 시도별 타임아웃은 브레이커 안쪽에 있는 셈이다.12
브레이커는 호출이 끝나야 성공인지 실패인지 안다. 응답하지 않는 의존성에 긴 타임아웃을 두면, 브레이커가 실패를 세기 전에 많은 호출이 스레드를 붙잡고 기다린다.9 그래서 타임아웃 패턴으로 느린 호출을 정해진 시간 안에 실패로 바꾸고, 브레이커는 그 실패를 센다. 앞의 표준 핸들러도 시도별 타임아웃을 브레이커 안쪽에 두고, 브레이커는 직접 실패나 타임아웃이 너무 많이 감지되면 실행을 막는다.12 resilience4j처럼 정해진 시간(slowCallDurationThreshold)을 넘긴 호출을 느린 호출로 따로 세어 별도 기준으로 회로를 여는 구현도 있다.7
예제
연속 실패 3번이면 열리고 30초 동안 열려 있는 브레이커로 환율 API를 감싼다. 가짜 API는 처음 세 번은 timeout으로 실패하고 그 뒤로는 성공한다. 시간은 TimeProvider를 상속한 ManualClock으로 주입한다. TimeProvider는 .NET이 시간을 추상화해 둔 클래스다.13 ManualClock은 GetUtcNow만 재정의했고 브레이커는 시간을 GetUtcNow로만 읽으므로, Advance를 부를 때만 시간이 흐르고 예제는 30초를 실제로 기다리지 않는다. 결과는 성공, 실패, 거절을 구분하는 CallResult 값으로 돌려준다.
using System;
using System.Collections.Generic;
var clock = new ManualClock();
var api = new FakeRatesApi(failFirst: 3);
var breaker = new CircuitBreaker(clock, failureThreshold: 3, openDuration: TimeSpan.FromSeconds(30));
Call(1);
Call(2);
Call(3);
Call(4);
clock.Advance(TimeSpan.FromSeconds(10));
Call(5);
clock.Advance(TimeSpan.FromSeconds(20));
Call(6);
Call(7);
Console.WriteLine($"api calls: {api.Calls}");
void Call(int n)
{
CallResult result = breaker.Execute(api.GetRate);
Console.WriteLine($"t={clock.Seconds}s call {n}: {result}");
}
enum CircuitState { Closed, Open, HalfOpen }
enum Outcome { Ok, Failed, Rejected }
readonly record struct CallResult(Outcome Outcome, string Detail)
{
public override string ToString() => $"{Outcome} ({Detail})";
}
// 시험용 시계. GetUtcNow만 재정의하며, 그 값은 Advance를 부를 때만 바뀐다.
sealed class ManualClock : TimeProvider
{
private static readonly DateTimeOffset Start = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
private DateTimeOffset now = Start;
public override DateTimeOffset GetUtcNow() => now;
public void Advance(TimeSpan by) => now += by;
public int Seconds => (int)(now - Start).TotalSeconds;
}
// 처음 failFirst번은 시간 초과로 실패하고, 그 뒤로는 성공하는 가짜 환율 API.
sealed class FakeRatesApi(int failFirst)
{
public int Calls { get; private set; }
public CallResult GetRate()
{
Calls++;
return Calls <= failFirst
? new CallResult(Outcome.Failed, "timeout")
: new CallResult(Outcome.Ok, "USD/KRW 1380");
}
}
sealed class CircuitBreaker(TimeProvider clock, int failureThreshold, TimeSpan openDuration)
{
private int consecutiveFailures;
private DateTimeOffset openedAt;
public CircuitState State { get; private set; } = CircuitState.Closed;
public CallResult Execute(Func<CallResult> operation)
{
// 열린 지 openDuration이 지났으면 반열림으로 옮기고, 이번 호출을 시험 호출로 보낸다.
if (State == CircuitState.Open && clock.GetUtcNow() - openedAt >= openDuration)
{
MoveTo(CircuitState.HalfOpen, "open duration elapsed");
}
// 열린 동안에는 의존성을 부르지 않고 바로 거절한다.
if (State == CircuitState.Open)
{
return new CallResult(Outcome.Rejected, "circuit open");
}
CallResult result = operation();
if (result.Outcome == Outcome.Ok)
{
OnSuccess();
return result;
}
OnFailure();
return result;
}
private void OnSuccess()
{
consecutiveFailures = 0;
if (State == CircuitState.HalfOpen)
{
MoveTo(CircuitState.Closed, "trial call succeeded");
}
}
private void OnFailure()
{
consecutiveFailures++;
if (State == CircuitState.HalfOpen)
{
Trip("trial call failed");
return;
}
if (consecutiveFailures >= failureThreshold)
{
Trip($"{consecutiveFailures} consecutive failures");
}
}
private void Trip(string reason)
{
openedAt = clock.GetUtcNow();
MoveTo(CircuitState.Open, reason);
}
private void MoveTo(CircuitState next, string reason)
{
Console.WriteLine($" state {State} -> {next}: {reason}");
State = next;
}
}t=0s call 1: Failed (timeout)
t=0s call 2: Failed (timeout)
state Closed -> Open: 3 consecutive failures
t=0s call 3: Failed (timeout)
t=0s call 4: Rejected (circuit open)
t=10s call 5: Rejected (circuit open)
state Open -> HalfOpen: open duration elapsed
state HalfOpen -> Closed: trial call succeeded
t=30s call 6: Ok (USD/KRW 1380)
t=30s call 7: Ok (USD/KRW 1380)
api calls: 5호출 4와 5는 API에 닿지 않고 거절되어, 호출 일곱 번 가운데 API 호출은 다섯 번이다. 상태 전이 줄은 그 전이를 일으킨 호출의 결과 줄보다 먼저 찍힌다.
범위 넓힌 예제
무엇을 실패로 셀지, 몇 번에 열지, 얼마나 열어 둘지를 BreakerPolicy 한곳에 모았다. 실패는 ErrorKind로 구분하고 Timeout과 Unavailable만 실패로 센다. InvalidRequest는 요청 자체가 잘못되었다는 응답이라 세지 않는다. 환율 조회는 읽기 전용이어서 다시 보내도 서버 상태가 바뀌지 않으므로, 브레이커 바깥에 최대 2번 시도하는 재시도를 두었다. 실패한 시도 때문에 회로가 열렸으면 재시도는 백오프를 기다리지 않고 멈추고, 거절된 결과도 다시 시도하지 않는다. 재고 API는 별도 브레이커로 감싼다. 출력의 failures는 연속 실패 수다.
using System;
using System.Collections.Generic;
var clock = new ManualClock();
// 의존성마다 브레이커를 따로 둔다. 환율 API가 죽어도 재고 API 호출은 막지 않는다.
var policy = new BreakerPolicy(
FailureThreshold: 3,
OpenDuration: TimeSpan.FromSeconds(30),
CountsAsFailure: kind => kind is ErrorKind.Timeout or ErrorKind.Unavailable);
var ratesApi = new ScriptedApi(["timeout", "timeout", "invalid", "unavailable", "timeout", "ok"]);
var stockApi = new ScriptedApi(["ok"]);
var rates = new CircuitBreaker("rates", clock, policy);
var stock = new CircuitBreaker("stock", clock, policy);
GetRate(1);
GetRate(2);
GetRate(3);
GetRate(4);
Print("stock", 1, 1, stock.Execute(stockApi.Call), stock);
clock.Advance(TimeSpan.FromSeconds(30));
GetRate(5);
clock.Advance(TimeSpan.FromSeconds(14));
GetRate(6);
clock.Advance(TimeSpan.FromSeconds(16));
GetRate(7);
Console.WriteLine($"rates api calls: {ratesApi.Calls}, stock api calls: {stockApi.Calls}");
// 환율 조회는 읽기 전용이라 다시 보내도 서버 상태가 바뀌지 않는다. 그래서 재시도해도 된다.
// 재시도는 브레이커 바깥에 두고, 브레이커가 열려 있거나 거절하면 더 시도하지 않는다.
void GetRate(int request)
{
const int maxAttempts = 2;
for (int attempt = 1; ; attempt++)
{
CallResult result = rates.Execute(ratesApi.Call);
Print("rates", request, attempt, result, rates);
bool retryable = result.Outcome == Outcome.Failed && policy.CountsAsFailure(result.Kind);
if (!retryable || attempt == maxAttempts)
{
return;
}
// 이 실패로 회로가 열렸으면 백오프를 기다리지 않고 멈춘다. 다음 시도는 어차피 거절된다.
if (rates.State == CircuitState.Open)
{
Console.WriteLine(" retry stopped: circuit open");
return;
}
// 실제 코드라면 백오프만큼 기다린다. 여기서는 가짜 시계를 1초 앞으로 돌린다.
clock.Advance(TimeSpan.FromSeconds(1));
}
}
void Print(string name, int request, int attempt, CallResult result, CircuitBreaker breaker) =>
Console.WriteLine($"t={clock.Seconds}s {name} #{request} try {attempt}: {result} [failures {breaker.ConsecutiveFailures}]");
enum CircuitState { Closed, Open, HalfOpen }
enum Outcome { Ok, Failed, Rejected }
enum ErrorKind { None, Timeout, Unavailable, InvalidRequest, CircuitOpen }
readonly record struct CallResult(Outcome Outcome, ErrorKind Kind)
{
public override string ToString() => Outcome == Outcome.Ok ? "Ok" : $"{Outcome} {Kind}";
}
// 무엇을 실패로 셀지, 몇 번에 열지, 얼마나 열어 둘지를 한곳에 모은 정책.
sealed record BreakerPolicy(int FailureThreshold, TimeSpan OpenDuration, Func<ErrorKind, bool> CountsAsFailure);
// 시험용 시계. GetUtcNow만 재정의하며, 그 값은 Advance를 부를 때만 바뀐다.
sealed class ManualClock : TimeProvider
{
private static readonly DateTimeOffset Start = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
private DateTimeOffset now = Start;
public override DateTimeOffset GetUtcNow() => now;
public void Advance(TimeSpan by) => now += by;
public int Seconds => (int)(now - Start).TotalSeconds;
}
// 정해 둔 순서대로 응답하는 가짜 API.
sealed class ScriptedApi(string[] script)
{
public int Calls { get; private set; }
public CallResult Call()
{
string next = script[Calls];
Calls++;
return next switch
{
"ok" => new CallResult(Outcome.Ok, ErrorKind.None),
"timeout" => new CallResult(Outcome.Failed, ErrorKind.Timeout),
"unavailable" => new CallResult(Outcome.Failed, ErrorKind.Unavailable),
"invalid" => new CallResult(Outcome.Failed, ErrorKind.InvalidRequest),
_ => throw new InvalidOperationException($"unknown script step: {next}"),
};
}
}
sealed class CircuitBreaker(string name, TimeProvider clock, BreakerPolicy policy)
{
private DateTimeOffset openedAt;
public CircuitState State { get; private set; } = CircuitState.Closed;
public int ConsecutiveFailures { get; private set; }
public CallResult Execute(Func<CallResult> operation)
{
if (State == CircuitState.Open && clock.GetUtcNow() - openedAt >= policy.OpenDuration)
{
MoveTo(CircuitState.HalfOpen, "open duration elapsed");
}
if (State == CircuitState.Open)
{
return new CallResult(Outcome.Rejected, ErrorKind.CircuitOpen);
}
CallResult result = operation();
if (result.Outcome == Outcome.Ok)
{
OnSuccess();
return result;
}
// 잘못된 요청은 상대가 살아 있다는 응답이다. 실패로도 성공으로도 세지 않는다.
if (!policy.CountsAsFailure(result.Kind))
{
return result;
}
OnFailure();
return result;
}
private void OnSuccess()
{
ConsecutiveFailures = 0;
if (State == CircuitState.HalfOpen)
{
MoveTo(CircuitState.Closed, "trial call succeeded");
}
}
private void OnFailure()
{
ConsecutiveFailures++;
if (State == CircuitState.HalfOpen)
{
// 시험 호출이 실패하면 다시 열고, 열린 시각을 지금으로 옮겨 openDuration을 새로 센다.
Trip("trial call failed");
return;
}
if (ConsecutiveFailures >= policy.FailureThreshold)
{
Trip($"{ConsecutiveFailures} consecutive failures");
}
}
private void Trip(string reason)
{
openedAt = clock.GetUtcNow();
MoveTo(CircuitState.Open, reason);
}
private void MoveTo(CircuitState next, string reason)
{
Console.WriteLine($" {name}: {State} -> {next}: {reason}");
State = next;
}
}t=0s rates #1 try 1: Failed Timeout [failures 1]
t=1s rates #1 try 2: Failed Timeout [failures 2]
t=1s rates #2 try 1: Failed InvalidRequest [failures 2]
rates: Closed -> Open: 3 consecutive failures
t=1s rates #3 try 1: Failed Unavailable [failures 3]
retry stopped: circuit open
t=1s rates #4 try 1: Rejected CircuitOpen [failures 3]
t=1s stock #1 try 1: Ok [failures 0]
rates: Open -> HalfOpen: open duration elapsed
rates: HalfOpen -> Open: trial call failed
t=31s rates #5 try 1: Failed Timeout [failures 4]
retry stopped: circuit open
t=45s rates #6 try 1: Rejected CircuitOpen [failures 4]
rates: Open -> HalfOpen: open duration elapsed
rates: HalfOpen -> Closed: trial call succeeded
t=61s rates #7 try 1: Ok [failures 0]
rates api calls: 6, stock api calls: 1#1의 두 시도는 모두 실패로 세어져, 재시도가 실패 수를 늘린다. #2의 InvalidRequest는 실패 수를 2로 둔다. #3의 첫 시도가 세 번째 실패가 되어 t=1s에 회로가 열리고, 재시도는 회로가 열린 것을 보고 두 번째 시도 없이 멈춘다. 바로 뒤의 #4는 거절된다. 같은 시각 재고 API는 자기 브레이커가 닫혀 있어 정상으로 호출된다. t=31s의 시험 호출이 실패하자 회로가 다시 열리고(재시도도 여기서 멈춘다) 열린 시각이 31초로 옮겨져, t=45s 호출은 거절되고 t=61s의 시험 호출이 성공해 회로가 닫힌다.
이 브레이커는 한 스레드에서 호출이 차례로 들어온다고 가정한다. 여러 호출이 동시에 들어오면 반열림 상태에서 시험 호출이 여러 개 지나갈 수 있고, 실패 수와 상태를 고치는 코드도 서로 경쟁한다. 실제 구현은 잠금이나 원자적 연산으로 상태를 보호하고, 반열림에서 허용할 호출 수를 정한다(resilience4j의 permittedNumberOfCallsInHalfOpenState). 연속 실패 수는 성공 한 번에 0으로 돌아가므로, 성공과 실패가 섞이는 장애에서는 회로가 열리지 않을 수 있다. 그래서 라이브러리는 Polly처럼 표본 기간 안의 실패 비율과 최소 처리량을 쓰거나 resilience4j처럼 슬라이딩 윈도를 쓴다. 또 이 예제의 가짜 API는 timeout을 스스로 돌려줄 뿐이고, 느린 호출을 실제로 끊는 타임아웃은 구현하지 않았다.
TypeScript 예제
첫 C# 예제와 같은 시나리오다. 시계는 밀리초를 돌려주는 Clock 인터페이스로 주입하고, CallResult의 outcome은 문자열 리터럴 유니온으로 성공, 실패, 거절을 구분한다.
type CircuitState = "Closed" | "Open" | "HalfOpen";
type CallResult = { outcome: "Ok" | "Failed" | "Rejected"; detail: string };
interface Clock { now(): number; }
// 시험용 시계. advance를 부를 때만 시간이 흐른다.
class ManualClock implements Clock {
#ms = 0;
now(): number { return this.#ms; }
advance(ms: number): void { this.#ms += ms; }
get seconds(): number { return this.#ms / 1000; }
}
// 처음 failFirst번은 시간 초과로 실패하고, 그 뒤로는 성공하는 가짜 환율 API.
class FakeRatesApi {
calls = 0;
constructor(private readonly failFirst: number) {}
getRate = (): CallResult => {
this.calls++;
return this.calls <= this.failFirst
? { outcome: "Failed", detail: "timeout" }
: { outcome: "Ok", detail: "USD/KRW 1380" };
};
}
class CircuitBreaker {
#state: CircuitState = "Closed";
#consecutiveFailures = 0;
#openedAt = 0;
constructor(
private readonly clock: Clock,
private readonly failureThreshold: number,
private readonly openDurationMs: number,
) {}
execute(operation: () => CallResult): CallResult {
// 열린 지 openDuration이 지났으면 반열림으로 옮기고, 이번 호출을 시험 호출로 보낸다.
if (this.#state === "Open" && this.clock.now() - this.#openedAt >= this.openDurationMs) {
this.#moveTo("HalfOpen", "open duration elapsed");
}
// 열린 동안에는 의존성을 부르지 않고 바로 거절한다.
if (this.#state === "Open") {
return { outcome: "Rejected", detail: "circuit open" };
}
const result = operation();
if (result.outcome === "Ok") {
this.#onSuccess();
return result;
}
this.#onFailure();
return result;
}
#onSuccess(): void {
this.#consecutiveFailures = 0;
if (this.#state === "HalfOpen") this.#moveTo("Closed", "trial call succeeded");
}
#onFailure(): void {
this.#consecutiveFailures++;
if (this.#state === "HalfOpen") {
this.#trip("trial call failed");
return;
}
if (this.#consecutiveFailures >= this.failureThreshold) {
this.#trip(`${this.#consecutiveFailures} consecutive failures`);
}
}
#trip(reason: string): void {
this.#openedAt = this.clock.now();
this.#moveTo("Open", reason);
}
#moveTo(next: CircuitState, reason: string): void {
console.log(` state ${this.#state} -> ${next}: ${reason}`);
this.#state = next;
}
}
const clock = new ManualClock();
const api = new FakeRatesApi(3);
const breaker = new CircuitBreaker(clock, 3, 30_000);
function call(n: number): void {
const result = breaker.execute(api.getRate);
console.log(`t=${clock.seconds}s call ${n}: ${result.outcome} (${result.detail})`);
}
call(1);
call(2);
call(3);
call(4);
clock.advance(10_000);
call(5);
clock.advance(20_000);
call(6);
call(7);
console.log(`api calls: ${api.calls}`);t=0s call 1: Failed (timeout)
t=0s call 2: Failed (timeout)
state Closed -> Open: 3 consecutive failures
t=0s call 3: Failed (timeout)
t=0s call 4: Rejected (circuit open)
t=10s call 5: Rejected (circuit open)
state Open -> HalfOpen: open duration elapsed
state HalfOpen -> Closed: trial call succeeded
t=30s call 6: Ok (USD/KRW 1380)
t=30s call 7: Ok (USD/KRW 1380)
api calls: 5출력은 C# 예제와 같다. 이 예제의 호출은 동기 함수라 겹치지 않지만, 의존성 호출이 await를 거치면 스레드가 하나여도 반열림 상태의 호출 여러 개가 동시에 진행될 수 있다.
같이 보기
C# 예제는 각각 독립된 .NET 10 콘솔 프로그램이다.
TypeScript 예제는 ES2022 이상으로 컴파일한 JavaScript를 Node.js에서 실행한다.
각주
- Microsoft Azure Architecture Center. Circuit Breaker pattern (2025). Solution. ↩
- Polly. Circuit breaker resilience strategy. ↩
- Michael T. Nygard. Release It!: Design and Deploy Production-Ready Software. Pragmatic Bookshelf (2007). 출판사 목차 발췌 PDF. 5장 Stability Patterns, 5.2 Circuit Breaker. 발췌본은 2009년 2월 P3.0 인쇄본이다. ↩
- Michael Nygard. Release It! Second Edition. Pragmatic Bookshelf (2018). 출판사 페이지. 목차의 Stability Patterns 장. ↩
- Martin Fowler. CircuitBreaker (2014). ↩
- Netflix. Hystrix README. Hystrix Status. ↩
- resilience4j. CircuitBreaker. ↩
- Microsoft Azure Architecture Center. Circuit Breaker pattern (2025). Context and problem. ↩
- Microsoft Azure Architecture Center. Circuit Breaker pattern (2025). Problems and considerations. ↩
- Microsoft Azure Architecture Center. Circuit Breaker pattern (2025). When to use this pattern. ↩
- Microsoft Learn. Introduction to resilient app development (2025). ↩
- Microsoft Learn. Build resilient HTTP apps: Key development patterns (2026). Standard resilience handler defaults. ↩
- Microsoft Learn. TimeProvider Class. ↩