이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- Bulkhead는 어떤 리소스를 나누어 장애 전파를 막는가?
- 스레드, connection, queue, tenant 격리가 성능과 운영 복잡도를 어떻게 바꾸는가?
- 핵심 기능과 부가 기능을 어떤 기준으로 다른 리소스 풀에 배치해야 하는가?
개요
Bulkhead는 배의 격벽처럼 한 구역에 물이 들어와도 배 전체가 침몰하지 않게 하는 구조다. 시스템에서는 스레드, connection, queue, worker, tenant quota를 나누어 한 기능의 장애가 다른 기능을 굶기지 않게 만든다.
예를 들어 배송 조회 API가 느려졌을 때 배송 조회용 thread pool이 따로 있으면 그 pool만 고갈된다. 주문 생성이나 로그인은 다른 pool을 사용하므로 계속 처리될 수 있다.
Circuit Breaker가 실패 의존성 호출을 차단하는 상태 기계라면, Bulkhead는 실패가 전파될 수 있는 리소스 경계를 미리 나누는 설계다.
격리 대상
| 리소스 | 전파 방식 | 격리 예시 |
|---|---|---|
| Web thread | 느린 요청이 요청 처리 스레드를 점유한다 | endpoint별 executor 분리 |
| HTTP client pool | 특정 provider 호출이 connection을 독점한다 | provider별 connection pool |
| DB connection | 비핵심 쿼리가 pool을 잡는다 | read/write pool 또는 batch pool 분리 |
| Queue | 낮은 우선순위 작업이 핵심 작업을 밀어낸다 | priority queue 분리 |
| Worker | 느린 consumer가 전체 consumer group을 막는다 | 작업 유형별 worker 분리 |
| Tenant quota | 한 tenant가 전체 자원을 쓴다 | tenant별 concurrency 제한 |
격리는 “무엇이 공유되어 있나?”를 찾는 일에서 시작한다.
Semaphore와 Thread Pool
Bulkhead 구현은 크게 semaphore 방식과 thread pool 방식으로 볼 수 있다.
| 방식 | 장점 | 비용 |
|---|---|---|
| Semaphore Bulkhead | 가볍고 호출 스레드를 유지한다 | 호출 스레드가 외부 호출을 기다린다 |
| Thread Pool Bulkhead | 느린 작업을 별도 스레드에 가둔다 | context 전환, queue, pool sizing 비용이 생긴다 |
짧고 빠른 외부 호출은 semaphore로 충분할 수 있다. 느린 provider나 blocking I/O는 별도 thread pool로 격리하는 편이 안전할 수 있다.
Pool 크기 판단
Bulkhead 크기는 감으로 정하면 안 된다.
배송 조회 peak = 300 rps
provider p95 latency = 200ms
provider 장애 시 timeout = 1s
허용할 최대 동시 배송 호출 = 30
주문 상세 전체 worker = 200배송 조회 bulkhead를 30으로 제한하면 배송 장애가 나도 최대 30개 동시 호출만 붙잡는다. 나머지 주문 상세 요청은 fallback을 받거나 배송 정보를 제외하고 응답할 수 있다.
Queue를 둘 것인가
Thread pool bulkhead에는 queue가 따라올 수 있다. queue를 크게 두면 거절은 줄지만 지연과 메모리 사용이 늘어난다.
pool size = 20
queue size = 0 -> 즉시 거절, 빠른 fallback
queue size = 100 -> 대기 허용, 오래된 응답 가능배송 위치처럼 오래 기다린 값이 의미 없는 경우 queue를 작게 두는 편이 낫다. 정산 처리처럼 반드시 처리해야 하는 작업은 HTTP 요청 안 queue가 아니라 durable queue로 보내야 한다.
기능 우선순위
격리 기준은 업무 중요도와 실패 허용도다.
| 기능 | 중요도 | 격리 전략 |
|---|---|---|
| 로그인 | 핵심 | 별도 thread와 DB pool 보호 |
| 주문 생성 | 핵심 | 결제/재고 timeout과 idempotency |
| 배송 조회 | 부가 | 작은 bulkhead와 fallback |
| 추천 상품 | 부가 | 빠른 실패 또는 숨김 |
| 관리자 export | 낮음 | 별도 queue와 낮은 우선순위 worker |
핵심 기능과 부가 기능이 같은 pool을 쓰면 부가 기능 장애가 핵심 기능을 잠식한다.
DB Bulkhead
외부 API뿐 아니라 DB도 격리 대상이다.
관리자 대량 export가 사용자 주문 API와 같은 connection pool을 쓰면 export가 connection을 오래 잡아 주문 API가 timeout될 수 있다.
가능한 선택은 다음이다.
- export 전용 read replica 사용
- batch 전용 connection pool 분리
- export 동시 실행 수 제한
- 사용자 API와 batch의 transaction timeout 분리
DB 격리는 정합성과도 연결된다. replica를 쓰면 최신성이 약해질 수 있으므로 export 용도와 사용자 화면 용도를 분리해야 한다.
Tenant 격리
B2B SaaS에서는 한 tenant의 대량 요청이 다른 tenant를 느리게 만들 수 있다. 이때 tenant별 concurrency limit이나 quota가 bulkhead 역할을 한다.
tenant A export concurrency = 3
tenant B export concurrency = 3
global export concurrency = 30tenant별 격리는 공정성을 높이지만 quota 정책, 예외 처리, 고객 커뮤니케이션이 필요하다.
Spring 경계
Resilience4j Bulkhead를 쓰면 의존성별 동시 실행 수를 제한할 수 있다.
@Bulkhead(name = "shippingClient", fallbackMethod = "shippingBusy")
public ShippingStatus getShipping(String orderId) {
return shippingProvider.getStatus(orderId);
}fallback은 “bulkhead가 가득 찼다”는 사실을 정상 배송 상태로 숨기면 안 된다.
private ShippingStatus shippingBusy(String orderId, BulkheadFullException e) {
return ShippingStatus.temporarilyUnavailable(orderId);
}관측 지표
Bulkhead는 다음 지표가 없으면 운영하기 어렵다.
- bulkhead available permits
- bulkhead rejected count
- thread pool active count
- queue depth
- queue wait time
- dependency latency
- fallback rate by reason
거절 수가 높다고 무조건 나쁜 것은 아니다. 부가 기능을 빠르게 거절해 핵심 기능을 보호하고 있다면 의도한 동작일 수 있다.
위험 신호
- 모든 외부 API client가 같은 connection pool을 쓴다.
- 관리자 export와 사용자 API가 같은 DB pool을 쓴다.
- thread pool queue가 너무 커서 장애가 늦게 드러난다.
- bulkhead full을 500으로만 반환하고 사용자 경험을 설계하지 않았다.
- pool 크기를 늘리는 대응만 반복한다.
개인 프로젝트 기준
- 핵심 기능과 부가 기능을 하나씩 나누어 어떤 리소스를 공유하는지 적는다.
- 외부 API 하나에 동시 실행 제한을 둔다.
- bulkhead full 상황을 재현하고 fallback을 확인한다.
- pool 크기와 queue 크기의 이유를 짧게 남긴다.
기업 운영 기준
- 기능별, provider별, tenant별 리소스 경계를 문서화한다.
- pool sizing 변경은 장애 회고와 capacity 계획에 남긴다.
- bulkhead rejected count와 사용자 영향 지표를 함께 본다.
- 낮은 우선순위 작업은 별도 queue와 별도 worker로 운영한다.
확인 질문
확인 질문
- 확인 질문: Bulkhead가 Circuit Breaker와 다른 점은 무엇인가?
- 답변: Circuit Breaker는 실패 의존성 호출을 상태 기반으로 차단하고, Bulkhead는 리소스를 분리해 실패 반경을 제한한다.
- 확인 질문: thread pool queue를 크게 두면 왜 위험한가?
- 답변: 거절은 줄지만 오래된 작업과 긴 대기가 쌓여 장애를 늦게 발견하고 사용자 응답도 늦어진다.
- 확인 질문: tenant별 concurrency 제한이 bulkhead인 이유는 무엇인가?
- 답변: 한 tenant의 폭주가 다른 tenant의 자원을 잠식하지 못하게 리소스 사용 경계를 나누기 때문이다.