벌크헤드 패턴
개요
주문 서버가 결제 서비스와 상품 카탈로그 서비스를 호출하고, 외부 호출은 한꺼번에 2개까지만 보내도록 제한해 두었다고 하자. 어느 날 결제 서비스가 응답을 멈추면 결제 호출 두 개가 그 2칸을 차지한 채 끝나지 않는다. 뒤이어 온 카탈로그 호출은 멀쩡한 서비스를 부르는데도 칸을 얻지 못해 catalog: rejected by shared로 끝나고, 결제 장애 하나로 카탈로그 기능까지 멈춘다.
벌크헤드(bulkhead) 패턴은 이런 자원을 의존성마다 따로 나눈다. 이름은 배의 선체를 여러 칸으로 나누는 격벽에서 왔다. 결제용 칸 2개와 카탈로그용 칸 2개를 따로 두면 결제 서비스가 멈춰도 결제 칸만 차고, 카탈로그 호출은 자기 칸을 써서 정상으로 끝난다. 결제 칸이 모자라 거절되는 것은 결제 호출뿐이다.
이 글의 예제에서 나누는 자원은 동시 실행 칸(slot)이고, 칸 하나가 진행 중인 호출 하나를 뜻한다. 칸 수를 세는 세마포어(semaphore)는 남은 칸 수를 담은 카운터로, 호출이 칸을 얻으면 수를 하나 줄이고 호출이 끝나면 하나 돌려놓는다. 의존성 하나에 배정한 세마포어와 그 한도를 묶은 것이 구획(bulkhead)이고, 호출 코드는 부르려는 의존성의 구획을 거쳐 호출한다.
칸이 모두 찼을 때 어떻게 할지도 정해야 한다. 바로 거절하면 호출자가 실패를 빨리 알고 다른 응답을 고를 수 있는데, 이를 빠른 실패(fail fast)라고 한다. 정해진 길이의 대기열(queue)을 두면 칸이 빌 때까지 몇 개의 호출이 기다리고, 대기열까지 차면 그때부터 거절한다.
칸 대신 연결 풀(connection pool), 스레드 풀, 메시지 큐, 서비스 인스턴스를 나누기도 하고, 의존성 대신 고객(테넌트)별로 나누기도 한다. 어느 쪽이든 한 의존성이나 한 고객에게 문제가 생겨도 바닥나는 것은 그 몫의 자원뿐이다.1
구조
그림은 범위 넓힌 예제에 나오는 타입을 보여 준다. Program은 결제용과 카탈로그용 Bulkhead를 따로 만든다. Bulkhead는 SemaphoreSlim으로 실행 칸을, admitted로 실행 중인 호출과 대기 중인 호출의 합을 세며, 둘 다 한도에 닿으면 기다리지 않고 Rejected를 돌려준다.
역사
Microsoft의 Azure 아키텍처 센터는 이 패턴의 이름이 선체를 나눈 구획(bulkhead)에서 왔다고 설명한다. 선체가 뚫려도 손상된 구획에만 물이 차서 배가 가라앉지 않는다는 것이다. 같은 문서는 이 패턴을 애플리케이션의 요소를 풀로 격리해 한 요소가 실패해도 나머지가 계속 동작하게 하는 방법으로 정의하며, 셀 기반 아키텍처(cell-based architecture)라고도 부른다고 적었다.2
Michael Nygard의 Release It!은 운영 환경에서 시스템이 버티게 하는 설계를 다룬 책으로, 2007년 Pragmatic Bookshelf에서 처음 나왔다. 출판사가 공개한 초판 목차를 보면 5장 Stability Patterns에 5.1 Use Timeouts, 5.2 Circuit Breaker에 이어 5.3 Bulkheads 절이 있다.3 2018년 1월에 나온 2판의 목차에도 Stability Patterns 아래에 Timeouts, Circuit Breaker, Bulkheads가 같은 순서로 있다.4
Netflix의 Hystrix 문서는 의존성끼리 서로 격리하는 데 벌크헤드 패턴을 쓴다고 설명한다. 의존성마다 별도 스레드 풀을 두어 한 의존성이 쓸 수 있는 자원을 묶어 두고, 스레드 풀 대신 세마포어로 의존성별 동시 호출 수를 제한하는 방법도 함께 다룬다.5
.NET의 Polly는 v7에서 Policy.Bulkhead(maxParallelization, maxQueuingActions)로 이 패턴을 제공했다. v7에서 v8로 옮기는 마이그레이션 가이드는 이 정책이 v8에서 rate limiter 전략으로 대체되었고 새 대응물이 ConcurrencyLimiter라고 설명한다. 같은 가이드에 따르면 AddConcurrencyLimiter(permitLimit, queueLimit)를 쓰려면 Polly.RateLimiting 패키지가 필요하다.6 이 전략은 System.Threading.RateLimiting 패키지의 API 위에 얹은 얇은 층이고, 그 패키지의 ConcurrencyLimiter는 자원에 대한 동시 접근을 관리하는 RateLimiter 구현이다.78
왜 사용하는가?
벌크헤드는 느린 의존성 하나가 다른 기능까지 끌고 내려가는 연쇄 장애를 막는다. 예제에서 결제 서비스가 멈춘 동안에도 카탈로그 호출은 성공한다. Azure 문서는 장애를 구획 안에 가두는 것, 장애 중에도 일부 기능을 유지하는 것, 중요한 소비자에게 따로 자원을 배정해 서비스 수준을 다르게 주는 것을 이점으로 든다.9
칸이 찼을 때 바로 거절하면 호출자는 응답 없는 호출을 기다리며 스레드나 연결을 붙잡고 있지 않고, 대체 응답을 보낼지 나중에 다시 시도할지를 곧바로 정할 수 있다.
벌크헤드는 이미 칸을 차지한 느린 호출을 끝내 주지는 않는다. 결제 칸을 차지한 호출은 결제 서비스가 응답하거나 호출이 취소될 때까지 칸을 놓지 않으므로, 칸이 언제 돌아오는지에 상한을 두려면 타임아웃 패턴이 필요하다. Hystrix 문서도 세마포어로 격리하면 부하는 덜어낼 수 있지만, 느린 호출에서 시간 초과로 빠져나오게 해 주지는 않는다고 적었다.10 계속 실패하는 의존성에 호출을 보내지 않는 일은 서킷 브레이커 패턴이 맡는다. Azure 문서도 재시도, 서킷 브레이커, 스로틀링과 함께 쓰는 것을 검토하라고 한다.11
나눈 자원은 서로 빌려 쓰지 못하므로, 결제 칸이 비어 있어도 카탈로그 호출이 몰리면 카탈로그 칸만 모자란다. 구획을 잘게 나눌수록 놀고 있는 용량이 늘고, 구획마다 정하고 감시할 설정도 늘어난다. Azure 문서는 자원을 덜 효율적으로 쓰는 것을 받아들일 수 없거나 추가 복잡도가 필요 없을 때는 이 패턴이 맞지 않을 수 있다고 적었다.12
프로세스 안의 세마포어는 그 프로세스 안의 호출만 나눈다. 서버 인스턴스가 여러 대면 한도는 인스턴스마다 따로 적용되고, CPU나 메모리처럼 프로세스 전체가 함께 쓰는 자원은 나뉘지 않는다. 이런 자원까지 격리하려면 서비스를 별도 가상 머신, 컨테이너, 프로세스로 나눠 배포한다.11
칸 크기와 대기열
구획마다 정할 숫자는 두 개로, 동시에 실행할 수 있는 호출 수와 칸이 찼을 때 기다릴 수 있는 호출 수다. 대기열이 0이면 칸이 없을 때 바로 거절한다. 대기열을 두면 잠깐의 몰림은 흡수하지만, 기다리는 호출도 메모리와 호출자의 응답 시간을 쓴다. 대기열에 상한이 없으면 멈춘 의존성 앞에 대기 호출이 끝없이 쌓이므로 구획을 나눈 의미가 사라진다.
크기는 의존성이 평소에 동시에 몇 개의 호출을 처리하는지와 응답 시간 분포를 재어 정하고, 운영 중에는 구획별로 사용 중인 칸 수와 거절 수를 지표로 남긴다. Azure 문서도 구획마다 성능과 SLA를 감시하라고 한다.11 같은 문서는 API 관리 서비스의 사용량 제한이나 컨테이너 자원 한도처럼 플랫폼이 이미 제공하는 격리 장치가 있으면, 그것을 애플리케이션 코드로 다시 만들지 말라고 권한다.13 Polly의 rate limiter 전략에 리미터를 따로 넘기지 않으면 PermitLimit 1000, QueueLimit 0인 동시 실행 제한기를 기본으로 쓰고, 실행을 거절하면 RateLimiterRejectedException을 던진다.14
거절과 의존성 실패는 구분해서 돌려준다. "구획이 찼다"는 호출하는 쪽 서버가 스스로 내린 결정이고, "의존성이 실패했다"는 상대 서비스의 문제다. 범위 넓힌 예제는 이를 Rejected와 Failed로 나누어 호출자가 서로 다르게 처리할 수 있게 한다.
예제
결제 서비스가 멈춘 상황을 TaskCompletionSource 게이트로 만든다. 게이트는 프로그램이 SetResult를 부를 때까지 끝나지 않는 작업이라서, 실제 시간을 기다리지 않고도 응답하지 않는 서비스를 흉내 낸다. 같은 호출 순서를 구획 하나를 함께 쓸 때와 의존성마다 구획을 둘 때로 두 번 실행한다. Wait(0)은 칸이 없으면 기다리지 않고 바로 false를 돌려준다.15
using System;
using System.Threading;
using System.Threading.Tasks;
var shared = new Bulkhead("shared", 2);
await Scenario("one shared bulkhead", payments: shared, catalog: shared);
await Scenario("separate bulkheads", payments: new Bulkhead("payments", 2), catalog: new Bulkhead("catalog", 2));
static async Task Scenario(string title, Bulkhead payments, Bulkhead catalog)
{
Console.WriteLine($"== {title} ==");
// 결제 서비스가 멈춘 상황: 게이트를 열기 전까지 결제 호출은 끝나지 않는다.
var paymentGate = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
var pay1 = payments.RunAsync(() => paymentGate.Task);
var pay2 = payments.RunAsync(() => paymentGate.Task);
Console.WriteLine("payment 3: " + await payments.RunAsync(() => paymentGate.Task));
Console.WriteLine("catalog: " + await catalog.RunAsync(() => Task.FromResult("ok")));
paymentGate.SetResult("ok");
Console.WriteLine("payment 1: " + await pay1);
Console.WriteLine("payment 2: " + await pay2);
}
sealed class Bulkhead(string name, int slots)
{
private readonly SemaphoreSlim semaphore = new(slots, slots);
public async Task<string> RunAsync(Func<Task<string>> call)
{
// 빈 칸이 없으면 기다리지 않고 바로 거절한다. 칸을 얻지 못했으니 반납할 것도 없다.
if (!semaphore.Wait(0)) return "rejected by " + name;
try
{
return await call();
}
finally
{
semaphore.Release();
}
}
}== one shared bulkhead ==
payment 3: rejected by shared
catalog: rejected by shared
payment 1: ok
payment 2: ok
== separate bulkheads ==
payment 3: rejected by payments
catalog: ok
payment 1: ok
payment 2: ok구획을 함께 쓰면 카탈로그 호출까지 거절되고, 나누면 결제의 세 번째 호출만 결제 구획에서 거절된다.
범위 넓힌 예제
결제 구획은 실행 칸 2개와 대기열 1개, 카탈로그 구획은 칸 2개에 대기열 없이 만든다. 결제 호출 네 개를 보내면 두 개는 실행되고, 세 번째는 대기열에서 기다리고, 네 번째는 바로 Rejected를 받는다. 이어서 pay-2를 예외로 실패시키면 finally가 칸을 돌려놓고, 기다리던 pay-3이 그 칸으로 실행된다. SemaphoreSlim은 Wait와 Release의 짝을 맞추는 일을 프로그래머에게 맡기므로, 실패한 호출이 칸을 반납하지 않으면 칸이 하나씩 사라지고 결국 구획 전체가 거절만 하게 된다.16
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
var payments = new Bulkhead("payments", maxConcurrent: 2, maxQueue: 1);
var catalog = new Bulkhead("catalog", maxConcurrent: 2, maxQueue: 0);
// 가짜 결제 게이트웨이: 호출마다 게이트가 하나 있고, 프로그램이 직접 열거나 실패시킨다.
var gates = new Dictionary<string, TaskCompletionSource<string>>();
foreach (var id in new[] { "pay-1", "pay-2", "pay-3", "pay-4", "pay-5" })
gates[id] = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
Task<string> Pay(string id) => gates[id].Task;
var pay1 = payments.RunAsync(() => Pay("pay-1"));
var pay2 = payments.RunAsync(() => Pay("pay-2"));
var pay3 = payments.RunAsync(() => Pay("pay-3")); // 칸이 없어 대기열에서 기다린다.
var pay4 = payments.RunAsync(() => Pay("pay-4")); // 대기열도 차서 바로 거절된다.
Console.WriteLine("after 4 payment calls: " + payments.Describe());
Console.WriteLine("pay-4: " + Describe(await pay4));
Console.WriteLine("catalog-1: " + Describe(await catalog.RunAsync(() => Task.FromResult("item list"))));
gates["pay-2"].SetException(new InvalidOperationException("gateway reset"));
Console.WriteLine("pay-2: " + Describe(await pay2));
gates["pay-3"].SetResult("charged pay-3"); // pay-2가 반납한 칸으로 실행된다.
Console.WriteLine("pay-3: " + Describe(await pay3));
gates["pay-1"].SetResult("charged pay-1");
Console.WriteLine("pay-1: " + Describe(await pay1));
Console.WriteLine("after all calls: " + payments.Describe());
gates["pay-5"].SetResult("charged pay-5");
Console.WriteLine("pay-5: " + Describe(await payments.RunAsync(() => Pay("pay-5"))));
static string Describe(BulkheadResult<string> result) => result switch
{
BulkheadResult<string>.Ok ok => "ok: " + ok.Value,
BulkheadResult<string>.Rejected r => $"rejected: {r.Bulkhead} full (running {r.Running}, queued {r.Queued})",
BulkheadResult<string>.Failed f => "failed: " + f.Error.Message,
_ => "unknown result",
};
abstract record BulkheadResult<T>
{
public sealed record Ok(T Value) : BulkheadResult<T>;
public sealed record Rejected(string Bulkhead, int Running, int Queued) : BulkheadResult<T>;
public sealed record Failed(Exception Error) : BulkheadResult<T>;
}
sealed class Bulkhead(string name, int maxConcurrent, int maxQueue)
{
private readonly SemaphoreSlim slots = new(maxConcurrent, maxConcurrent);
private int admitted; // 실행 중 + 대기 중인 호출 수
public int Running => maxConcurrent - slots.CurrentCount;
public int Queued => Volatile.Read(ref admitted) - Running;
public string Describe() => $"{name} running={Running} queued={Queued}";
public async Task<BulkheadResult<T>> RunAsync<T>(Func<Task<T>> call)
{
// 실행 칸과 대기열이 모두 찼으면 기다리지 않고 거절 결과를 돌려준다.
if (Interlocked.Increment(ref admitted) > maxConcurrent + maxQueue)
{
Interlocked.Decrement(ref admitted);
return new BulkheadResult<T>.Rejected(name, Running, Queued);
}
var acquired = false;
try
{
// 칸이 없으면 대기열에서 기다린다. 대기 중 예외가 나도 아래 finally가 계수를 되돌린다.
await slots.WaitAsync();
acquired = true;
return new BulkheadResult<T>.Ok(await call());
}
catch (Exception ex)
{
// 의존성 경계: 예외를 실패 결과로 바꾼다. 형식과 스택을 잃지 않도록 예외를 그대로 담는다.
return new BulkheadResult<T>.Failed(ex);
}
finally
{
// admitted는 항상 줄이고, 칸은 실제로 얻었을 때만 반납한다.
Interlocked.Decrement(ref admitted);
if (acquired) slots.Release();
}
}
}after 4 payment calls: payments running=2 queued=1
pay-4: rejected: payments full (running 2, queued 1)
catalog-1: ok: item list
pay-2: failed: gateway reset
pay-3: ok: charged pay-3
pay-1: ok: charged pay-1
after all calls: payments running=0 queued=0
pay-5: ok: charged pay-5이 예제에서는 대기열에서 기다리는 시간에 상한이 없어서 pay-3이 다른 호출이 칸을 반납할 때까지 계속 기다린다. 실제 코드에서는 WaitAsync에 제한 시간이나 CancellationToken을 넘기고 시간이 지나면 거절해야 한다. 이때 칸을 얻지 못한 호출은 Release하지 않고 admitted만 하나 줄여야 한다. 예제의 RunAsync는 대기도 try 안에 두고, finally에서 admitted는 항상 줄이되 칸은 acquired가 참일 때만 반납하므로 대기 중 취소 예외가 나도 계수가 새지 않는다. 제한 시간을 넣을 때는 WaitAsync(timeout)이 돌려주는 bool을 acquired에 담고, 거짓이면 Rejected를 돌려주면 된다. 실행 중인 호출에도 타임아웃이 없어서 결제 서비스가 끝내 응답하지 않으면 pay-1은 칸을 놓지 않는다. SemaphoreSlim은 기다리는 호출이 들어오는 순서를 보장하지 않으므로17 순서가 중요하면 별도의 대기열이 필요하다. Running과 Queued는 두 값을 따로 읽어 계산하므로, 호출이 몰리는 동안에는 근사값이다. 구획은 프로세스 하나 안에서만 유효하고, 거절 수나 사용 중인 칸 수를 지표로 내보내지도 않는다.
TypeScript 예제
첫 C# 예제와 같은 상황을 TypeScript로 옮겼다. 이 예제는 하나의 JavaScript 실행 환경 안에서 호출을 받으므로, 남은 칸 수를 담은 숫자로 동시 호출을 제한한다. async 함수는 첫 await 전까지 동기적으로 실행되므로, run이 불린 순간 칸 확인과 차감이 끝난다. 게이트는 open을 부를 때까지 끝나지 않는 Promise다.
type CallResult =
| { ok: true; value: string }
| { ok: false; error: "BULKHEAD_FULL"; bulkhead: string };
// 남은 칸 수만 세는 작은 세마포어. 기다리지 않고 바로 성공 여부를 알려 준다.
class Semaphore {
#available: number;
constructor(slots: number) { this.#available = slots; }
tryAcquire(): boolean {
if (this.#available === 0) return false;
this.#available -= 1;
return true;
}
release(): void { this.#available += 1; }
}
class Bulkhead {
readonly #slots: Semaphore;
constructor(readonly name: string, slots: number) { this.#slots = new Semaphore(slots); }
async run(call: () => Promise<string>): Promise<CallResult> {
// 칸을 얻지 못하면 거절한다. 얻지 못한 칸은 반납하지 않는다.
if (!this.#slots.tryAcquire()) return { ok: false, error: "BULKHEAD_FULL", bulkhead: this.name };
try {
return { ok: true, value: await call() };
} finally {
this.#slots.release();
}
}
}
// 직접 열 때까지 끝나지 않는 게이트. 멈춘 결제 서비스를 흉내 낸다.
function gate(): { promise: Promise<string>; open: (value: string) => void } {
let open: (value: string) => void = () => {};
const promise = new Promise<string>((resolve) => { open = resolve; });
return { promise, open };
}
function describe(result: CallResult): string {
return result.ok ? result.value : "rejected by " + result.bulkhead;
}
async function scenario(title: string, payments: Bulkhead, catalog: Bulkhead): Promise<void> {
console.log(`== ${title} ==`);
const paymentGate = gate();
const pay1 = payments.run(() => paymentGate.promise);
const pay2 = payments.run(() => paymentGate.promise);
console.log("payment 3: " + describe(await payments.run(() => paymentGate.promise)));
console.log("catalog: " + describe(await catalog.run(() => Promise.resolve("ok"))));
paymentGate.open("ok");
console.log("payment 1: " + describe(await pay1));
console.log("payment 2: " + describe(await pay2));
}
async function main(): Promise<void> {
const shared = new Bulkhead("shared", 2);
await scenario("one shared bulkhead", shared, shared);
await scenario("separate bulkheads", new Bulkhead("payments", 2), new Bulkhead("catalog", 2));
}
void main();== one shared bulkhead ==
payment 3: rejected by shared
catalog: rejected by shared
payment 1: ok
payment 2: ok
== separate bulkheads ==
payment 3: rejected by payments
catalog: ok
payment 1: ok
payment 2: ok칸을 얻지 못한 호출은 try에 들어가기 전에 돌아가므로 반납할 칸도 없다. 이 세마포어에는 대기열이 없고, 칸을 차지한 호출이 끝나지 않으면 칸도 돌아오지 않는다.
같이 보기
C# 예제는 각각 독립된 .NET 10 콘솔 프로그램이다.
TypeScript 예제는 ES2022 이상으로 컴파일한 JavaScript를 Node.js에서 실행한다.
각주
- Microsoft Learn. Bulkhead pattern. Azure Architecture Center. Solution 절의 의존성별 연결 풀 예, Problems and considerations 절의 프로세스·스레드 풀·세마포어·큐·테넌트 분할. ↩
- Microsoft Learn. Bulkhead pattern. Azure Architecture Center. 도입부와 Tip. ↩
- Michael T. Nygard. Release It! Design and Deploy Production-Ready Software. Pragmatic Bookshelf, 2007. 출판사 목차 PDF (P3.0 인쇄본, 2009년 2월), 5.3 Bulkheads, 121쪽. ↩
- Michael Nygard. Release It! Second Edition: Design and Deploy Production-Ready Software. Pragmatic Bookshelf, 2018. 출판사 목차, Create Stability > Stability Patterns. ↩
- Netflix. How it Works. Hystrix Wiki. ↩
- Polly. Migration guide from v7 to v8. Bulkhead 절. ↩
- Polly. Rate limiter resilience strategy. ↩
- Microsoft Learn. ConcurrencyLimiter Class. ↩
- Microsoft Learn. Bulkhead pattern. Azure Architecture Center. Solution 절의 benefits 목록. ↩
- Netflix. How it Works. Hystrix Wiki. 세마포어 격리 설명. ↩
- Microsoft Learn. Bulkhead pattern. Azure Architecture Center. Problems and considerations 절. ↩
- Microsoft Learn. Bulkhead pattern. Azure Architecture Center. When to use this pattern 절. ↩
- Microsoft Learn. Bulkhead pattern. Azure Architecture Center. Problems and considerations 절의 built-in platform controls 항목. ↩
- Polly. Rate limiter resilience strategy. Defaults 표와 예외 목록. ↩
- Microsoft Learn. SemaphoreSlim.Wait Method. Wait(Int32) Remarks. ↩
- Microsoft Learn. SemaphoreSlim Class. Remarks. ↩
- Microsoft Learn. SemaphoreSlim Class. Remarks: 여러 스레드가 기다릴 때 FIFO나 LIFO 같은 순서를 보장하지 않는다. ↩