본문 바로가기
Backend2026년 7월 23일6분 읽기

백프레셔(Backpressure) — 빠른 생산자와 느린 소비자의 흐름 제어

YS
김영삼
조회 4
백프레셔(Backpressure) — 빠른 생산자와 느린 소비자의 흐름 제어

백프레셔(backpressure)는 데이터를 만들어내는 쪽(생산자)이 처리하는 쪽(소비자)보다 빠를 때, 소비자가 "잠깐, 나 아직 이거 처리 중이야"라고 상류로 신호를 보내 흐름 속도를 늦추는 메커니즘입니다. 한마디로 느린 쪽이 빠른 쪽에게 브레이크를 거는 것이죠.

이 개념을 제대로 이해하지 못하면, 겉으로는 멀쩡하다가 트래픽이 몰리는 순간 메모리가 폭발하며 프로세스가 죽는 서버를 만들게 됩니다. 나도 예전에 그런 서버를 하나 운영했습니다. 평소엔 잘 돌다가 새벽 배치가 돌 때만 OOM으로 죽었어요. 범인은 백프레셔를 무시한 스트림 파이프였습니다.

왜 필요한가 — 큐가 무한히 자라는 순간

생산자와 소비자 사이에는 대부분 버퍼(큐)가 있습니다. 생산 속도가 소비 속도보다 조금 빠른 정도면 버퍼가 그 차이를 흡수합니다. 문제는 이 불균형이 지속될 때입니다. 버퍼는 계속 차오르고, 결국 두 가지 중 하나가 일어납니다.

  • 버퍼가 메모리에 있다면 → 메모리를 다 먹고 프로세스가 죽습니다(OOM).
  • 버퍼에 상한이 있다면 → 넘치는 데이터를 버리거나(drop), 아니면 상류를 멈춰야 합니다.

백프레셔는 세 번째 선택지, 즉 "상류를 멈춘다"를 자동화한 것입니다. 소비자가 힘들면 그 신호가 큐를 거쳐 생산자까지 전파되고, 생산자는 스스로 속도를 줄입니다.

Node.js 스트림에서의 실제 동작

Node.js 스트림은 백프레셔를 언어 차원에서 지원하는 대표적인 예입니다. 핵심은 write()의 반환값입니다. 이게 false를 반환하면 "내부 버퍼가 highWaterMark를 넘었으니 그만 써라"는 뜻입니다.

// 나쁜 예 — 반환값을 무시하고 계속 write
readable.on('data', (chunk) => {
  writable.write(chunk); // false를 무시 → 버퍼 무한 증가
});
// 좋은 예 — pipe가 알아서 백프레셔를 처리
readable.pipe(writable);
// 수동으로 한다면
readable.on('data', (chunk) => {
  const ok = writable.write(chunk);
  if (!ok) {
    readable.pause();                 // 상류를 멈추고
    writable.once('drain', () => {    // 버퍼가 비면
      readable.resume();              // 다시 흐르게
    });
  }
});

pipe()pipeline()을 쓰면 이 pause/resume 춤을 알아서 춰줍니다. 그래서 나는 웬만하면 손으로 write 루프를 돌리지 말고 pipeline()을 쓰라고 권합니다. 에러 전파랑 리소스 정리까지 챙겨주거든요.

메시지 큐와 네트워크에서는 어떻게

스트림 밖으로 나가면 백프레셔의 형태가 조금 달라집니다. 어디서 어떻게 신호를 주느냐가 시스템마다 다릅니다.

계층백프레셔 수단
TCP수신 윈도우(receive window)로 송신 속도 제어
gRPC / HTTP/2스트림 단위 flow-control 윈도우
Kafka 컨슈머poll 기반 pull — 소비자가 가져올 때만 읽음
Reactive Streamsrequest(n) — 소비자가 받을 개수를 요청

여기서 중요한 통찰 하나. push 모델은 백프레셔를 만들기 어렵고, pull 모델은 태생적으로 백프레셔가 있습니다. Kafka 컨슈머가 안정적인 이유가 이겁니다. 소비자가 준비됐을 때만 poll()로 당겨오니까 밀려서 터질 일이 구조적으로 적어요.

백프레셔 대신 흔히 저지르는 실수

백프레셔가 필요한 자리에 엉뚱한 걸 넣는 경우를 자주 봅니다.

  • 무한 버퍼 — "일단 큐에 다 받아두자"는 폭탄 돌리기입니다. 상한 없는 큐는 결국 OOM으로 끝납니다.
  • 버퍼 + 무제한 재시도 — 소비자가 느린 근본 원인은 그대로인데 재시도만 늘리면 부하가 더 커집니다.
  • 드롭을 침묵으로 처리 — 넘치면 버리는 건 유효한 전략이지만(로그·메트릭 같은 곳), 버렸다는 사실을 기록하지 않으면 데이터가 조용히 사라집니다.
개인적으로는 "이 큐의 최대 크기는 얼마이고, 넘치면 무슨 일이 벌어지는가?"에 답하지 못하는 큐는 프로덕션에 두지 않습니다. 답이 "모른다"면 그건 미래의 장애를 예약해 둔 것입니다.

자주 묻는 질문

백프레셔와 레이트 리미팅은 같은 건가요?

비슷하지만 방향이 다릅니다. 레이트 리미팅은 "초당 몇 개까지"처럼 미리 정한 고정 한도로 입구를 막는 것이고, 백프레셔는 소비자의 실시간 상태에 따라 동적으로 상류를 조절합니다. 둘을 함께 쓰면 좋습니다. 레이트 리미터로 최악의 폭주를 막고, 백프레셔로 그 안에서 흐름을 다듬는 식이죠.

버퍼를 그냥 크게 잡으면 안 되나요?

버퍼는 순간적인 불균형(버스트)을 흡수하는 용도지 지속적인 불균형의 해법이 아닙니다. 생산이 소비보다 계속 빠르면 아무리 큰 버퍼도 언젠가 찹니다. 큰 버퍼는 문제를 늦출 뿐 지연 시간(latency)만 키우고 결국 터집니다.

드롭(버리기)은 언제 정당한가요?

데이터가 최신값만 의미 있거나(예: 실시간 위치, 센서값), 손실을 감당할 수 있는 로그·메트릭 같은 경우입니다. 이때도 drop 카운터를 메트릭으로 남겨 얼마나 버리는지 관측할 수 있어야 합니다. 결제·주문처럼 유실이 곧 사고인 데이터는 절대 드롭하면 안 되고, 그때는 상류를 멈추는 진짜 백프레셔가 답입니다.

Reactive Streams의 request(n)은 뭐가 다른가요?

소비자가 "지금 n개까지 처리할 수 있어"라고 명시적으로 요청하고, 생산자는 그 개수만큼만 밀어 넣습니다. 백프레셔를 프로토콜 규격으로 못 박은 것이라, 라이브러리끼리 조합해도 흐름 제어가 자동으로 맞물립니다. RxJava, Project Reactor 같은 구현이 이 규격을 따릅니다.

댓글 0

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