자원을 여러 칸으로 격리해 한 칸이 침수돼도 전체가 가라앉지 않게 하는 패턴. 배의 격벽(bulkhead)에서 온 이름이다.
무엇을 막는가
공유 스레드 풀 (200개)
├─ 결제 API 호출 (느려짐: 30초) → 스레드 190개 점유
├─ 상품 조회 → 스레드를 못 받음 ✗
└─ 로그인 → 스레드를 못 받음 ✗
결제 하나가 느려져서 전혀 무관한 로그인까지 막힌다
자원을 공유하는 한, 한 곳의 문제가 전체로 번진다. 서킷 브레이커가 "호출을 끊는" 방식이라면, 벌크헤드는 "자원을 나누는" 방식이다.
격리 후
- 결제 풀 — (50개) → 다 막혀도 50개만 손실
- 조회 풀 — (100개) → 정상 동작 ✅
- 로그인 풀 (50개) — → 정상 동작 ✅
결제 기능은 실패하지만 나머지는 살아 있다. 이것이 부분 장애다.
두 가지 구현
① 스레드 풀 격리
ExecutorService paymentPool = Executors.newFixedThreadPool(50);
ExecutorService searchPool = Executors.newFixedThreadPool(100);
완전히 분리되어 확실하지만, 스레드 수가 늘어 컨텍스트 스위칭 비용이 는다.
호출이 별도 스레드에서 일어나므로 ThreadLocal(보안 컨텍스트, MDC)이
전파되지 않는 문제도 있다.
② 세마포어 격리
Semaphore paymentLimit = new Semaphore(50);
같은 스레드에서 실행하되 동시 진입 수만 제한한다. 가볍고 ThreadLocal도 유지된다. 대신 호출이 블로킹되면 호출한 스레드가 그대로 묶인다 — 타임아웃이 반드시 필요하다.
실무 기준 — 외부 시스템 호출처럼 오래 걸릴 수 있으면 스레드 풀 격리, 내부 처리라면 세마포어 격리.
어느 층위에서 나눌 수 있나
| 층위 | 예 |
|---|---|
| 스레드 풀 | 기능별로 분리 |
| 커넥션 풀 | 조회용/쓰기용 DataSource 분리 |
| 인스턴스 | 배치 전용 서버를 따로 |
| 셀 기반 아키텍처 | 고객군을 완전히 분리된 셀로 (한 셀 장애가 그 고객만) |
커넥션 풀 분리가 실무에서 특히 효과적이다. 무거운 통계 쿼리가 커넥션을 다 쓰면 일반 조회까지 막히는데, 풀을 나누면 격리된다.
크기를 어떻게 정하나
너무 작으면 → 정상 상황에서도 대기 발생 (스스로 병목이 됨)
- 너무 크면 — → 격리 효과가 없다 (다 함께 죽는 것과 같음)
기준: 해당 기능의 피크 동시 요청 수 + 여유
- 전체 합이 시스템 총 자원을 넘지 않게
중요 — 각 칸의 합이 전체 자원보다 크면 격리가 무의미하다. "결제 100 + 조회 100 + 로그인 100 = 300, 실제 스레드는 200"이면 결국 서로 잠식한다.
서킷 브레이커·타임아웃과의 조합
세 패턴은 층이 다르다.
- 타임아웃 — : 개별 호출이 무한정 기다리지 않게 (가장 기본)
- 벌크헤드 — : 자원을 나눠 전파 자체를 물리적으로 차단
- 서킷 브레이커 — : 고장난 대상 호출을 아예 생략
타임아웃 없는 벌크헤드는 반쪽짜리다 — 격리된 칸이 결국 다 차 버린다. 셋을 함께 쓰는 것이 표준 구성이다.