의존성마다 쓸 수 있는 자원을 따로 떼어 두는 것. 배가 한 구역에 물이 차도 가라앉지 않게 격벽을 두는 데서 온 이름이다.
무엇을 막나
스레드 풀 200개를 모든 외부 호출이 공유한다
추천 서비스가 느려졌다 (부가 기능)
- 추천 호출이 스레드 200개를 다 점유한다
- 주문 · 결제 같은 핵심 기능까지 스레드를 못 얻는다
- 부가 기능 하나가 서비스 전체를 삼킨다
격리한다
- 결제 — 최대 50
- 주문 — 최대 100
- 추천 — 최대 20 ← 추천이 아무리 느려도 20을 넘지 못한다
@Bulkhead(name = "recommend", type = Bulkhead.Type.SEMAPHORE)
List<Item> recommend(Long userId) { return client.get(userId); }
resilience4j.bulkhead.instances.recommend:
maxConcurrentCalls: 20
maxWaitDuration: 0 # 자리가 없으면 즉시 실패 (기다리지 않는다)
두 가지 방식
- 세마포어 방식 — 같은 스레드에서 동시 호출 수만 제한한다 (가볍다)
- 스레드 풀 방식 — 의존성마다 별도 스레드 풀을 준다 (완전 격리·비용이 크다)
가상 스레드 시대에 더 중요해졌다
예전에는 스레드 풀 자체가 암묵적인 벌크헤드였다
- "톰캣 풀 200" = 동시 외부 호출 200 이라는 상한
가상 스레드는 그 브레이크가 없다
- 5,000개가 동시에 느린 의존성으로 몰릴 수 있다
- 자원마다 상한을 '명시적으로' 걸어야 한다
private static final Semaphore RECOMMEND = new Semaphore(20);
List<Item> recommend(Long userId) throws InterruptedException {
if (!RECOMMEND.tryAcquire(0, TimeUnit.MILLISECONDS)) return List.of(); // 폴백
try { return client.get(userId); } finally { RECOMMEND.release(); }
}
rate limiter와의 차이
- 벌크헤드 — '동시에 몇 개' 를 제한한다 (진행 중인 작업 수)
- rate limiter — '단위 시간에 몇 번' 을 제한한다 (호출 빈도)
외부 API 가 분당 1000회 제한이면 → rate limiter 느린 의존성이 자원을 삼키는 게 문제면 → 벌크헤드
타임아웃이 없으면 전부 무의미하다
타임아웃 없이 벌크헤드만 걸면
- 20개 슬롯이 영원히 안 돌아온다
- 그 의존성은 완전히 멈춘 것과 같다
순서: 타임아웃 → 벌크헤드 → 서킷 브레이커
면접 함정
- ❌ "벌크헤드는 성능을 떨어뜨린다" → 최악의 경우 전체가 멈추는 것을 막는 보험이다.
- ❌ "서킷 브레이커가 있으면 벌크헤드는 불필요" → 회로가 열리기 전까지 자원을 삼키는 걸 막는 게 벌크헤드다.
무엇을 격리 단위로 삼나
- 외부 의존성별 — 결제 · 배송 · 추천 (가장 흔하다)
- API 중요도별 — 핵심 API 와 부가 API 의 스레드 풀을 나눈다
- 테넌트별 — 특정 고객사의 폭주가 다른 고객사에 번지지 않게
톰캣 레벨에서 나누기
# 중요 API 만 별도 커넥터·포트로 분리하는 구성도 있다
server.tomcat.threads.max: 200
# 부가 기능은 별도 인스턴스로 아예 분리하는 것이 가장 확실한 격리다
폴백을 반드시 짝지운다
@Bulkhead(name = "recommend", fallbackMethod = "empty")
List<Item> recommend(Long userId) { return client.get(userId); }
List<Item> empty(Long userId, Throwable t) { return List.of(); } // 추천이 없어도 화면은 뜬다
폴백이 없으면 벌크헤드는 그냥 에러를 앞당길 뿐이다. "부가 기능은 없어도 된다"를 코드로 표현하는 것이 격리의 완성이다.