Circuit Breaker로 연쇄 장애 줄이기
Circuit Breaker로 연쇄 장애 줄이기
Circuit Breaker는 최근 호출의 실패가 임계값을 넘으면 회로를 열어 의존성 호출을 즉시 거절한다. 일정 시간이 지난 뒤 Half-Open 상태에서 제한된 탐색 요청으로 회복을 확인한다. Timeout·재시도·동시성 제한을 대체하는 기능이 아니며, 대상과 연산별로 적절한 경계를 정하고 상태 전이를 관측해야 한다.
목차
- #느린 의존성이 내 서비스까지 멈추게 한다
- #재시도와 Circuit Breaker의 역할은 다르다
- #Closed Open Half-Open 상태
- #단순 연속 실패보다 시간 창을 사용한다
- #무엇을 실패로 집계할지 정한다
- #Half-Open에서는 탐색 요청 수를 제한한다
- #회로를 어느 범위로 나눌 것인가
- #TypeScript로 상태 머신 구성하기
- #Fallback은 안전한 기능 저하일 때만 사용한다
- #Timeout Bulkhead Rate Limit과 함께 둔다
- #분산 인스턴스의 회로 상태
- #테스트와 운영 관측
- #마무리
- #참고 자료
- #관련 노트
느린 의존성이 내 서비스까지 멈추게 한다
상품 페이지가 추천 API를 동기 호출한다고 하자.
app.get("/products/:id", async (req, res) => {
const product = await productRepository.findById(
req.params.id,
);
const recommendations =
await recommendationApi.load(req.params.id);
res.json({
product,
recommendations,
});
});
추천 API의 평소 응답은 100ms지만 장애 중 10초 뒤 timeout이 난다. 사용자 요청이 초당 100개라면 장애가 10초 지속되는 동안 대략 1,000개의 호출이 대기할 수 있다.
동시 대기 호출 ≈ 초당 요청 수 × 평균 대기 시간
≈ 100 × 10초
≈ 1,000
대기 호출은 Promise만 점유하는 것이 아니다. HTTP 소켓, 연결 풀 슬롯, 요청 본문, 사용자 문맥, 로그 추적 객체가 유지된다. 추천 API와 무관한 요청까지 이벤트 루프 지연과 메모리 압박을 받을 수 있다.
여기에 각 호출이 세 번 재시도하면 의존성에는 더 많은 요청이 간다.
flowchart LR
A[추천 API 지연] --> B[호출 대기 증가]
B --> C[연결·메모리 점유]
C --> D[상품 API 지연]
D --> E[클라이언트·상위 계층 재시도]
E --> ACircuit Breaker는 최근 실패를 기억한다. 실패가 지속되는 동안 원격 호출을 시도하지 않고 빠르게 거절해 자원을 보호한다.
회로가 열리면 의존성 호출은 성공하지 않는다. 대신 오래 기다린 실패를 즉시 알 수 있는 실패나 안전한 fallback으로 바꿔 전체 시스템의 장애 범위를 제한한다.
재시도와 Circuit Breaker의 역할은 다르다
두 패턴은 함께 언급되지만 질문이 다르다.
| 패턴 | 질문 | 동작 |
|---|---|---|
| Timeout | 한 시도를 얼마나 기다릴까 | 제한 시간 뒤 취소 |
| Retry | 다음 시도는 성공할 가능성이 있는가 | 지연 후 다시 호출 |
| Circuit Breaker | 지금 호출을 시도할 가치가 있는가 | 실패 가능성이 높으면 즉시 거절 |
| Bulkhead | 이 의존성이 쓸 수 있는 자원은 얼마인가 | 동시성·풀 격리 |
| Rate Limit | 일정 시간에 얼마나 호출할 수 있는가 | 초과 요청 제한 |
Circuit Breaker가 없으면 각 사용자 요청이 자신만의 재시도 정책을 처음부터 수행한다.
의존성 장기 장애
사용자 요청 A → 3번 실패
사용자 요청 B → 다시 3번 실패
사용자 요청 C → 다시 3번 실패
회로가 열리면 이후 요청은 의존성에 도달하지 않는다.
요청 A의 실패가 임계값 도달
회로 Open
요청 B → 즉시 CircuitOpenError
요청 C → 즉시 CircuitOpenError
재시도 로직은 CircuitOpenError를 일시 네트워크 오류처럼 다시 시도하지 않아야 한다. 회로가 스스로 정한 탐색 시점까지 기다린다.
Closed Open Half-Open 상태
Closed
정상 상태다. 호출을 통과시키고 결과를 기록한다. 시간 창 안의 실패율과 최소 호출 수가 임계값을 넘으면 Open으로 이동한다.
Open
호출을 원격으로 보내지 않고 즉시 거절한다. 열린 시각과 다음 탐색 가능 시각을 기록한다.
Half-Open
Open 유지 시간이 지난 뒤 제한된 호출만 통과시킨다. 탐색 호출이 충분히 성공하면 Closed로 돌아가고, 실패하면 즉시 Open으로 돌아간다.
stateDiagram-v2
[*] --> Closed
Closed --> Open: 최소 표본 충족 + 실패율 초과
Open --> HalfOpen: openDuration 경과
HalfOpen --> Closed: 탐색 성공 임계값 충족
HalfOpen --> Open: 탐색 호출 실패상태 전이는 원자적이어야 한다. 동시에 여러 요청이 Open 만료를 발견하면 모두 탐색 호출을 보내는 문제가 생길 수 있다.
단순 연속 실패보다 시간 창을 사용한다
“5번 실패하면 Open”만 사용하면 트래픽 규모를 반영하지 못한다.
서비스 A: 1분에 10,000회 중 5회 실패 → 실패율 0.05%
서비스 B: 1분에 6회 중 5회 실패 → 실패율 83%
같은 5회라도 의미가 다르다. 최근 시간 창의 실패율과 최소 표본 수를 함께 본다.
type CircuitThresholds = Readonly<{
windowMs: number;
minimumCalls: number;
failureRate: number;
openDurationMs: number;
halfOpenMaxCalls: number;
halfOpenSuccesses: number;
}>;
최근 30초
최소 호출 20회 이상
실패율 50% 이상
→ Open
최소 호출 수가 없으면 시작 직후 한 번 실패한 것만으로 회로가 열릴 수 있다.
느린 호출도 실패로 볼 수 있다
응답은 200이지만 8초가 걸리면 상위 요청에는 실패와 비슷하다. slowCallThresholdMs와 느린 호출 비율을 별도로 둘 수 있다.
type CallOutcome =
| { kind: "success"; durationMs: number }
| { kind: "failure"; durationMs: number }
| { kind: "ignored"; durationMs: number };
다만 느린 호출 임계값은 실제 p95·p99와 사용자 deadline을 기준으로 정한다. 평소에도 1초가 걸리는 작업에 500ms를 설정하면 회로가 계속 열린다.
Sliding Window 구현 방식
모든 호출을 배열에 영원히 보관하지 않는다.
- 시간 기반 버킷: 최근 N초의 성공·실패 수 집계
- 개수 기반 ring buffer: 최근 N개 결과만 보관
- 라이브러리 제공 rolling statistics 사용
가상 구현에서는 작은 배열로 설명할 수 있지만 고트래픽 운영에서는 버킷 기반 집계가 적합하다.
무엇을 실패로 집계할지 정한다
모든 예외가 의존성 장애는 아니다.
| 결과 | 회로 실패 집계 | 이유 |
|---|---|---|
| 연결 실패 | 포함 | 대상 접근 불가 |
| timeout | 포함 | 지연으로 사용할 수 없음 |
| HTTP 502·503·504 | 포함 | 의존성 일시 장애 가능 |
| HTTP 429 | 정책에 따라 포함 | 대상 과부하·quota 신호 |
| HTTP 400 | 제외 | 호출자 요청 오류 |
| HTTP 401·403 | 보통 제외·별도 알림 | 설정·권한 오류 |
도메인 재고 없음 |
제외 | 정상 업무 결과 |
| 호출자 Abort | 제외 | 의존성 상태와 무관할 수 있음 |
| CircuitOpenError | 제외 | 실제 호출하지 않음 |
분류 함수를 주입한다.
type CircuitResult =
| "success"
| "failure"
| "ignored";
function classifyForCircuit(
error: unknown,
): CircuitResult {
if (error instanceof CircuitOpenError) {
return "ignored";
}
if (error instanceof RequestAbortedError) {
return "ignored";
}
if (error instanceof HttpResponseError) {
if ([502, 503, 504].includes(error.status)) {
return "failure";
}
return "ignored";
}
if (
error instanceof NetworkError ||
error instanceof TimeoutError
) {
return "failure";
}
return "ignored";
}
401이 모든 인스턴스에서 발생하면 자격 증명 오류로 호출이 계속 실패할 수 있다. 회로 집계에서 제외하더라도 별도 fast-fail과 알림이 필요하다.
결제 카드 거절이나 재고 부족을 실패로 집계하면 정상 트래픽 패턴 때문에 회로가 열릴 수 있다.
Half-Open에서는 탐색 요청 수를 제한한다
Open 시간이 끝났다고 모든 요청을 한꺼번에 통과시키면 회복 중인 서비스에 다시 파동이 간다.
Open 동안 대기·유입된 요청 1,000개
30초 뒤 Open 만료
1,000개가 동시에 통과
의존성 다시 과부하
Half-Open 상태에서는 소수만 허용한다.
type HalfOpenState = Readonly<{
kind: "half-open";
inFlight: number;
successes: number;
}>;
function canProbe(
state: HalfOpenState,
maxCalls: number,
): boolean {
return state.inFlight < maxCalls;
}
나머지 요청은 즉시 거절하거나 안전한 fallback으로 보낸다. 큐에 무한 대기시키면 빠른 실패라는 목적이 사라진다.
탐색 성공 기준도 정한다.
탐색 최대 동시 호출 2개
연속 성공 3회 → Closed
한 번 실패 → Open
실패 후 Open 시작 시각을 계속 뒤로 미루지 않도록 상태 전이를 한 곳에서 관리한다.
회로를 어느 범위로 나눌 것인가
회로 하나를 모든 외부 호출에 공유하면 한 endpoint 장애가 다른 정상 기능을 막는다.
payment-api
├─ POST /charges 장애
├─ GET /charges/:id 정상
└─ POST /refunds 정상
반대로 요청마다 새 Circuit Breaker를 만들면 실패 상태가 공유되지 않아 의미가 없다.
일반적인 키 후보는 다음과 같다.
dependency + operation + region
const breakers = new CircuitBreakerRegistry();
const chargeBreaker = breakers.get({
dependency: "payment-api",
operation: "create-charge",
region: "ap-northeast",
});
너무 세분화하면 표본이 부족하고 관리할 회로가 많아진다. 다음 기준으로 묶는다.
- 같은 자원 풀과 장애 원인을 공유하는가?
- timeout과 SLO가 같은가?
- fallback 정책이 같은가?
- 독립적으로 배포·확장되는 endpoint인가?
- 트래픽이 임계값을 계산할 만큼 충분한가?
DB 전체에 회로를 하나 두면 읽기 replica 장애가 쓰기까지 막을 수 있다. 반대로 쿼리마다 만들면 지나치게 많다. 읽기·쓰기 풀이나 업무 기능 단위가 현실적인 경계일 수 있다.
TypeScript로 상태 머신 구성하기
다음은 원리를 설명하기 위한 단일 프로세스용 예시다. 운영에서는 검증된 라이브러리의 동시성, rolling window, telemetry 기능을 우선 검토한다.
type CircuitState =
| { kind: "closed" }
| {
kind: "open";
openedAt: number;
retryAt: number;
}
| {
kind: "half-open";
inFlight: number;
successes: number;
};
type Outcome = Readonly<{
at: number;
result: "success" | "failure";
}>;
class CircuitBreaker {
#state: CircuitState = { kind: "closed" };
#outcomes: Outcome[] = [];
constructor(
private readonly thresholds:
CircuitThresholds,
private readonly now:
() => number = Date.now,
) {}
async execute<T>(
operation: () => Promise<T>,
classify:
(error: unknown) => CircuitResult,
): Promise<T> {
this.#moveOpenToHalfOpenIfDue();
this.#assertCallAllowed();
this.#markHalfOpenCallStarted();
const startedAt = this.now();
try {
const value = await operation();
this.#recordSuccess(
this.now() - startedAt,
);
return value;
} catch (error) {
const result = classify(error);
if (result === "failure") {
this.#recordFailure(
this.now() - startedAt,
);
} else {
this.#finishIgnoredCall();
}
throw error;
}
}
허용 여부를 확인한다.
#assertCallAllowed(): void {
if (this.#state.kind === "open") {
throw new CircuitOpenError({
retryAt: this.#state.retryAt,
});
}
if (
this.#state.kind === "half-open" &&
this.#state.inFlight >=
this.thresholds.halfOpenMaxCalls
) {
throw new CircuitOpenError({
reason: "half_open_probe_limit",
});
}
}
Open 유지 시간이 지나면 Half-Open으로 이동한다.
#moveOpenToHalfOpenIfDue(): void {
if (
this.#state.kind === "open" &&
this.now() >= this.#state.retryAt
) {
this.#state = {
kind: "half-open",
inFlight: 0,
successes: 0,
};
this.#emitTransition("half-open");
}
}
Closed에서는 최근 시간 창의 실패율을 계산한다.
#recordFailure(durationMs: number): void {
if (this.#state.kind === "half-open") {
this.#open("half_open_probe_failed");
return;
}
this.#appendOutcome("failure");
const recent = this.#recentOutcomes();
if (
recent.length >=
this.thresholds.minimumCalls &&
failureRate(recent) >=
this.thresholds.failureRate
) {
this.#open("failure_rate_exceeded");
}
}
#recordSuccess(durationMs: number): void {
if (this.#state.kind === "half-open") {
const successes =
this.#state.successes + 1;
const inFlight =
Math.max(0, this.#state.inFlight - 1);
if (
successes >=
this.thresholds.halfOpenSuccesses
) {
this.#state = { kind: "closed" };
this.#outcomes = [];
this.#emitTransition("closed");
return;
}
this.#state = {
kind: "half-open",
successes,
inFlight,
};
return;
}
this.#appendOutcome("success");
}
#open(reason: string): void {
const openedAt = this.now();
this.#state = {
kind: "open",
openedAt,
retryAt:
openedAt +
this.thresholds.openDurationMs,
};
this.#emitTransition("open", reason);
}
#appendOutcome(
result: Outcome["result"],
): void {
this.#outcomes.push({
at: this.now(),
result,
});
const cutoff =
this.now() - this.thresholds.windowMs;
this.#outcomes =
this.#outcomes.filter(
(outcome) => outcome.at >= cutoff,
);
}
}
예시는 상태 머신의 핵심만 보여준다. 동일 이벤트 루프에서는 동기 상태 변경이 끼어들지 않지만 Worker Thread, 여러 프로세스, 원격 상태 저장소를 사용하면 원자적 compare-and-set이 필요하다. 또한 ignored 호출의 Half-Open inFlight 감소, 이벤트 전송 실패 격리, 시간 창의 효율적인 버킷화 같은 세부 구현을 빠뜨리지 않아야 한다.
Circuit Breaker는 happy path보다 상태 경쟁, 통계 창, 취소, telemetry가 어렵다. 예시 코드는 개념 학습용으로 두고 운영에는 사용하는 런타임과 HTTP 클라이언트에 맞는 라이브러리를 검토한다.
Fallback은 안전한 기능 저하일 때만 사용한다
추천 API 장애 때 빈 추천 목록을 반환하는 것은 합리적일 수 있다.
async function loadRecommendations(
productId: string,
): Promise<Recommendation[]> {
try {
return await recommendationBreaker.execute(
() =>
recommendationApi.load(productId),
classifyForCircuit,
);
} catch (error) {
if (
error instanceof CircuitOpenError ||
isTemporaryDependencyError(error)
) {
return [];
}
throw error;
}
}
하지만 결제 승인 실패를 성공처럼 반환하면 데이터 무결성이 깨진다.
// 위험한 fallback
catch {
return {
approved: true,
source: "fallback",
};
}
Fallback 선택지는 다음과 같다.
| 기능 | 가능한 fallback | 주의점 |
|---|---|---|
| 추천 | 빈 목록·최근 캐시 | 오래된 데이터 표시 가능 |
| 프로필 이미지 | 기본 이미지 | 낮은 위험 |
| 환율 표시 | 마지막 정상값 + 시각 | 거래 계산에는 사용 금지 |
| 결제 승인 | 즉시 실패·대기 상태 | 성공으로 가장하면 안 됨 |
| 재고 차감 | 큐에 명령 저장 | 중복과 순서 보장 필요 |
캐시 fallback에는 freshness를 표시하고 만료 상한을 둔다.
type CachedRate = Readonly<{
value: number;
fetchedAt: number;
}>;
function canUseStaleRate(
rate: CachedRate,
now: number,
): boolean {
return now - rate.fetchedAt <= 60_000;
}
사용자에게 기능 저하를 숨길지 알려줄지도 제품 결정이다. “최신 정보가 아닐 수 있음”을 표시해야 하는 업무도 있다.
Timeout Bulkhead Rate Limit과 함께 둔다
Circuit Breaker만 추가해도 첫 임계값에 도달하기 전 호출은 여전히 오래 기다릴 수 있다.
flowchart LR
A[Caller] --> B[Rate Limit]
B --> C[Bulkhead 동시성 제한]
C --> D[Circuit Breaker]
D --> E[Retry]
E --> F[Timeout이 있는 실제 호출]구체적인 순서는 라이브러리와 정책에 따라 달라질 수 있지만 책임은 분리한다.
Timeout
한 호출이 자원을 점유할 최대 시간을 정하고 Circuit Breaker가 느린 실패를 관찰할 수 있게 한다.
Bulkhead
회로가 열리기 전에도 해당 의존성이 사용할 수 있는 동시 연결 수를 제한한다.
const recommendationSemaphore =
new Semaphore(20);
await recommendationSemaphore.run(
() => breaker.execute(call, classify),
);
Retry
Closed 상태의 일시 오류를 소수 재시도한다. CircuitOpenError는 재시도하지 않는다. 여러 실패 시도가 회로 통계에 개별 집계되는지 최종 작업 하나로 집계되는지 정책을 정한다.
Rate Limit
회복 직후 트래픽이 한꺼번에 돌아가지 않도록 평상시 호출률을 제한할 수 있다.
회로 앞에 무제한 작업 큐가 있으면 Open 동안 요청이 메모리에 계속 쌓일 수 있다. 큐 상한과 빠른 거절도 함께 필요하다.
분산 인스턴스의 회로 상태
API Pod가 20개면 메모리 Circuit Breaker도 20개다.
Pod A: 실패를 많이 관찰해 Open
Pod B: 아직 호출 수가 적어 Closed
Pod C: 새로 시작해 통계 없음
이 상태가 반드시 잘못된 것은 아니다.
로컬 회로의 장점
- 원격 상태 저장소 없이 빠르다.
- 해당 인스턴스의 연결·네트워크 상태를 반영한다.
- 중앙 저장소 장애가 회로 판단을 막지 않는다.
- 구현과 장애 모드가 단순하다.
공유 회로의 장점과 비용
- 전체 트래픽의 표본을 빠르게 모은다.
- 모든 인스턴스가 동시에 의존성을 보호할 수 있다.
- 운영자가 강제로 Open하는 기능을 중앙화할 수 있다.
대신 상태 저장소 자체의 가용성, 네트워크 지연, 원자적 전이, Half-Open 탐색 조정이 필요하다. 공유 상태 저장소가 실패했을 때 fail-open과 fail-closed 중 무엇을 선택할지도 정해야 한다.
대부분은 인스턴스 로컬 회로로 시작하고 서비스 메시나 Gateway가 제공하는 중앙화된 기능을 활용한다. 전역 quota처럼 모든 인스턴스가 함께 지켜야 하는 문제는 Circuit Breaker보다 분산 rate limit의 책임에 가깝다.
운영자 강제 상태는 일반 통계와 분리한다.
type AdministrativeOverride =
| "none"
| "force-open"
| "force-closed";
force-closed는 장애 의존성에 트래픽을 쏟을 위험이 있으므로 만료 시간, 권한, 감사 로그가 필요하다.
테스트와 운영 관측
상태 전이 테스트
시계를 주입하면 sleep 없이 검증할 수 있다.
it("opens after the failure threshold", async () => {
const clock = new FakeClock();
const breaker = createTestBreaker(clock);
await recordCalls(breaker, [
"failure",
"failure",
"success",
"failure",
]);
expect(breaker.state()).toBe("open");
});
it("allows only limited half-open probes", async () => {
const clock = new FakeClock();
const breaker = createOpenBreaker(clock);
clock.advanceBy(30_000);
const calls = await Promise.allSettled([
breaker.execute(pendingCall, classify),
breaker.execute(pendingCall, classify),
breaker.execute(pendingCall, classify),
]);
expect(
calls.filter(isCircuitOpenRejection),
).toHaveLength(1);
});
분류 테스트
도메인 거절과 호출자 취소가 회로를 열지 않는지 확인한다.
it.each([
[new TimeoutError(), "failure"],
[new HttpResponseError(503), "failure"],
[new HttpResponseError(400), "ignored"],
[new RequestAbortedError(), "ignored"],
])(
"classifies %p as %s",
(error, expected) => {
expect(classifyForCircuit(error))
.toBe(expected);
},
);
장애 주입
1. 의존성 응답을 5초 지연시킨다.
2. 최소 표본 전까지 timeout이 예상대로 제한되는지 본다.
3. 실패율 임계값 뒤 회로가 Open 되는지 본다.
4. Open 동안 실제 의존성 요청 수가 멈추는지 본다.
5. 유지 시간 뒤 탐색 요청만 통과하는지 본다.
6. 의존성 복구 후 Closed로 돌아오는지 본다.
메트릭
circuit_state{dependency,operation}- 상태 전이 횟수와 이유
- short-circuit된 호출 수
- Closed 상태의 성공·실패·느린 호출 비율
- Half-Open 탐색 성공률
- Open 유지 시간
- fallback 사용 횟수
- 의존성 동시 호출 수와 큐 거절 수
상태는 closed=0, half_open=1, open=2처럼 gauge로 표현할 수 있다. requestId나 원본 URL을 라벨로 넣지 않고 정규화한 dependency와 operation을 사용한다.
로그
매 short-circuit마다 동일한 오류 로그를 남기면 장애 중 로그가 폭증한다. 상태 전이는 반드시 기록하고 개별 거절 로그는 샘플링한다.
{
"dependency": "recommendation-api",
"operation": "load-related-products",
"from": "closed",
"to": "open",
"reason": "failure_rate_exceeded",
"failureRate": 0.65,
"sampleCount": 40,
"retryAt": "2025-08-19T10:00:30.000Z",
"message": "circuit state changed"
}
운영 체크리스트
마무리
의존성이 잠깐 실패할 때는 백오프 재시도가 도움이 된다. 장애가 지속되거나 응답이 계속 느릴 때 모든 새 요청이 같은 실패를 다시 확인하도록 두면 내 서비스의 연결과 메모리까지 소진된다.
Circuit Breaker는 최근 호출을 근거로 실패 가능성이 높은 원격 호출을 잠시 차단하고, Half-Open에서 제한된 탐색 요청으로 회복을 확인한다. 회로의 목적은 의존성을 고치는 것이 아니라 실패가 번지는 범위를 제한하는 것이다.
대상별 시간 창과 최소 표본, 실패 분류, 탐색 동시성, 안전한 fallback을 함께 설계해야 한다. Timeout으로 한 시도를 제한하고, Bulkhead로 자원을 격리하며, Retry는 Closed 상태의 일시 실패에만 사용한다. 다음 글에서는 이 모든 패턴의 출발점인 외부 호출의 시간 예산을 더 자세히 다룬다.