본문 바로가기
Backend2026년 9월 19일10분 읽기

격벽과 부하 제한 — 장애 전파를 끊는 나머지 절반

YS
김영삼
조회 202
격벽과 부하 제한 — 장애 전파를 끊는 나머지 절반

회복탄력성 이야기를 하면 대개 타임아웃과 서킷 브레이커에서 끝난다. 그런데 실제 대형 장애의 회고를 읽어 보면 반복되는 패턴이 있다. 한 의존성이 느려졌고, 그것을 기다리는 요청이 자원을 다 먹었고, 무관한 기능까지 멈췄다.

타임아웃은 대기 시간을 제한하고 서킷 브레이커는 반복 실패를 끊는다. 하지만 "자원을 나눠 쓰는 구조" 자체는 그대로다. 그 구조를 고치는 것이 격벽(bulkhead)이고, 한계를 넘었을 때 일부를 포기하는 것이 부하 제한(load shedding)이다.

배의 격벽이라는 비유가 정확하다. 선체에 구멍이 나도 격벽이 있으면 그 구역만 침수된다. 격벽이 없으면 배 전체가 가라앉는다. 소프트웨어에서 침수되는 건 스레드, 커넥션, 메모리다.

자원이 고갈되는 산수

# 리틀의 법칙: 동시 처리 수 L = 도착률 λ × 평균 체류시간 W
#
#  평상시:   λ=500 req/s,  W=0.05s  →  L = 25     (스레드 50개로 충분)
#  의존성 지연: λ=500 req/s,  W=5s    →  L = 2500   (스레드 50개는 즉시 고갈)
#
# 처리 시간이 100배 늘면 필요한 동시 처리 수도 100배가 된다.
# 자원이 유한하므로 고갈되고, 그 순간 무관한 요청까지 굶는다.
핵심 통찰 장애는 에러로 번지지 않는다. 대기로 번진다. 빨리 실패하면 자원이 돌아오지만, 기다리면 자원이 잠긴다. 그래서 모든 회복탄력성 기법의 첫 줄은 "기다리는 시간에 상한을 둔다"이다.

격벽 — 자원 풀을 나눈다

가장 단순하고 효과적인 격벽은 의존성마다 별도의 동시 실행 상한을 두는 것이다. 결제 API가 느려져도 조회 API용 자원은 남아 있게 만든다.

// 의존성별 세마포어로 동시 실행 수 제한 (개념)
class Bulkhead {
  constructor(limit) { this.limit = limit; this.active = 0; this.queue = []; }

  async run(fn, queueTimeoutMs = 200) {
    if (this.active >= this.limit) {
      // 대기열에 넣되, 대기 시간 상한을 둔다
      const admitted = await this.waitForSlot(queueTimeoutMs);
      if (!admitted) throw new Error('bulkhead full');   // 빠른 실패
    }
    this.active++;
    try { return await fn(); }
    finally { this.active--; this.release(); }
  }
}

// 의존성마다 별도 인스턴스
const payments  = new Bulkhead(20);   // 느려져도 20개까지만 점유
const catalog   = new Bulkhead(100);
const search    = new Bulkhead(50);
격벽을 적용할 지점
외부 API 호출 — 서비스별로 분리
DB 커넥션 풀 — 읽기/쓰기, 또는 기능별로 분리
스레드 풀 — 사용자 요청 처리용과 배치·백그라운드용 분리
인증·인가 경로 — 여기가 막히면 전부 막히므로 별도 자원
느리고 덜 중요한 기능 — 리포트 생성, 내보내기 등

네 번째 항목이 특히 중요하다. 인증 경로는 모든 요청이 거치므로, 그 경로가 일반 트래픽과 자원을 공유하면 단일 실패 지점이 된다. 실제 대형 SaaS 장애에서 반복되는 패턴이다.

부하 제한 — 못 할 일은 빨리 거절한다

용량을 넘는 요청이 들어오면 선택지는 둘뿐이다. 전부 느려지거나, 일부를 거절하거나. 전자는 모두가 실패하는 결과로 이어진다. 후자가 옳다.

방식판단 기준특징
동시 실행 수 제한현재 처리 중인 수구현이 단순, 상한 설정이 어렵다
큐 대기 시간 기반큐에서 기다린 시간부하에 자동 적응, 권장
CPU·자원 기반시스템 사용률간접 지표라 반응이 늦을 수 있음
우선순위 기반요청의 중요도중요 트래픽 보호에 효과적
// 큐 대기 시간 기반 부하 제한 (가장 실용적)
const MAX_QUEUE_WAIT_MS = 150;

async function handle(req) {
  const enqueuedAt = Date.now();
  await acquireWorker();                       // 워커 확보 대기
  const waited = Date.now() - enqueuedAt;

  if (waited > MAX_QUEUE_WAIT_MS) {
    // 이미 오래 기다린 요청은 처리해도 클라이언트가 포기했을 가능성이 높다
    releaseWorker();
    return reply(503, { retryAfter: 1 });      // 빠르게 거절
  }
  try { return await process(req); }
  finally { releaseWorker(); }
}

// 핵심: 오래 기다린 요청을 버리는 것이 전체 처리량을 지킨다.
// 이미 타임아웃된 요청을 처리하는 것은 순수한 낭비다.
왜 대기 시간 기준인가 동시 실행 수 상한은 "얼마로 잡을 것인가"가 어렵고, 하드웨어나 코드가 바뀌면 다시 맞춰야 한다. 반면 큐 대기 시간은 시스템이 감당 못 하고 있다는 직접 신호다. 용량이 변해도 기준이 그대로 유효하다.

우선순위를 두면 더 좋다

모든 요청이 같은 가치를 갖지는 않는다. 과부하 상황에서 무엇을 먼저 버릴지 정해 두면, 같은 용량으로 더 중요한 일을 지킬 수 있다.

  1. 결제·주문 완료 — 마지막까지 지킨다.
  2. 로그인·인증 — 막히면 아무것도 못 한다.
  3. 조회·검색 — 일부 실패해도 재시도 가능.
  4. 추천·개인화 — 없어도 기본 기능은 동작한다.
  5. 분석 이벤트·로그 전송 — 가장 먼저 버린다.

구현은 요청 헤더나 경로로 등급을 판단하고, 부하 수준에 따라 낮은 등급부터 거절하는 형태가 일반적이다. 중요한 건 이 순위를 장애 전에 합의해 두는 것이다. 장애 중에 정하려 하면 늦는다.

적응형 동시성 제어

상한을 사람이 정하는 대신, 시스템이 스스로 찾게 하는 방법도 있다. 네트워크 혼잡 제어와 같은 원리다. 지연이 늘면 동시 실행 수를 줄이고, 안정되면 늘린다.

// 가법 증가 / 승법 감소 (AIMD) 방식의 개념
let limit = 50;
const MIN = 5, MAX = 500;

function onSuccess(latencyMs, baselineMs) {
  if (latencyMs < baselineMs * 1.5) {
    limit = Math.min(MAX, limit + 1);          // 여유 있으면 조금씩 증가
  }
}

function onOverload() {                         // 타임아웃·거절·지연 급증
  limit = Math.max(MIN, Math.floor(limit * 0.8));  // 즉시 크게 감소
}

// 증가는 천천히, 감소는 빠르게 — 과부하에서 빨리 빠져나오기 위함

설계 점검

장애 전파 차단 점검표
모든 외부 호출에 타임아웃이 있는가 (기본값 무한대인 클라이언트가 없는가)
의존성별로 자원 풀이 분리돼 있는가
인증 경로가 일반 트래픽과 자원을 공유하고 있지 않은가
과부하 시 거절 경로가 있는가 — 그리고 그 거절이 빠른가
큐 대기 시간을 측정하고 있는가
요청 우선순위가 정의돼 있는가
헬스체크가 "프로세스 생존"이 아니라 "처리 가능"을 반영하는가
자원 고갈 상태에서 자동 재시작되는 조건이 있는가

마지막 항목이 종종 빠진다. 자원이 완전히 고갈된 프로세스는 스스로 회복하지 못하는 경우가 많다. 실제 대형 장애의 마지막 단계가 거의 항상 재시작인 이유다. 그렇다면 그 재시작을 자동화하는 편이 낫다.

자주 묻는 질문

타임아웃과 서킷 브레이커만으로는 왜 부족한가요?

두 기법은 개별 호출의 대기와 반복 실패를 다루지만, 여러 의존성이 같은 스레드·커넥션 풀을 공유하는 구조 자체는 바꾸지 못합니다. 한 의존성이 느려지면 공유 자원이 고갈되어 무관한 기능까지 영향을 받습니다.

격벽은 어떻게 구현하나요?

의존성마다 별도의 동시 실행 상한(세마포어)이나 별도의 커넥션 풀을 두는 것이 기본입니다. 대기열에 진입 상한 시간을 두어, 자리를 못 잡으면 빠르게 실패하도록 만드는 것이 중요합니다.

부하 제한 기준은 무엇으로 잡는 게 좋나요?

큐 대기 시간이 실용적입니다. 동시 실행 수 상한은 적정값을 찾기 어렵고 환경이 바뀌면 다시 맞춰야 하지만, 대기 시간은 시스템이 감당하지 못한다는 직접적인 신호라 용량이 변해도 기준이 유효합니다.

요청을 거절하면 사용자 경험이 나빠지지 않나요?

전부 느려지는 것보다 낫습니다. 과부하에서 모든 요청을 받으면 대부분이 타임아웃으로 실패하고 자원은 낭비됩니다. 일부를 빠르게 거절하면 나머지는 정상 응답을 받고, 거절된 쪽도 재시도 안내를 즉시 받을 수 있습니다.

우선순위는 어떻게 정하나요?

결제·주문 완료와 인증을 최우선으로 두고, 추천·개인화·분석 이벤트를 먼저 버리는 순서가 일반적입니다. 중요한 것은 장애가 나기 전에 이 순위를 합의하고 코드에 반영해 두는 것입니다.

적응형 동시성 제어가 필요한가요?

트래픽 변동이 크고 의존성 지연이 불규칙한 환경에서 유용합니다. 지연이 늘면 상한을 빠르게 줄이고 안정되면 천천히 늘리는 방식으로, 사람이 상수를 관리하는 부담을 줄일 수 있습니다. 다만 동작을 이해하고 관측할 수 있어야 도입 가치가 있습니다.

댓글 0

아직 댓글이 없습니다.
Ctrl+Enter로 등록