스트림으로 나눠 보낸다는 사실만으로 메모리 사용이 제한되지는 않는다. 소비자가 더 받을 수 없다는 신호를 생산자가 따라야 하고, 스트림 바깥에서 시작한 비동기 작업에도 한도가 있어야 한다. Node.js의 write()가 false를 반환한 뒤에는 다음 chunk를 기다리게 하고, 지속적인 과부하와 취소는 별도의 업무 정책으로 다룬다.
CSV를 나눠 보내는데도 서버가 버거워지는 장면
운영자가 큰 보고서를 내려받는 기능을 생각해 보자. 서버는 행을 읽어 CSV 조각으로 만들고, 사용자의 느린 연결로 보낸다. 파일 전체를 한 번에 만들지 않았으니 안전하다고 생각하기 쉽다. 하지만 다음 조각을 만드는 속도가 내보내는 속도보다 빠르면, 아직 보내지 못한 조각은 어딘가에 남는다.
가령 입력 8 MiB/s, 출력 2 MiB/s가 10초 유지된다고 가정하면, 처음 비어 있던 대기 공간에 쌓이는 데이터는 (8 − 2) × 10 = 60 MiB다. 압축·복사·객체 오버헤드를 제외한 가상 계산이며 실측이 아니다. 버퍼를 두 배로 늘리면 가득 찰 때까지의 시간을 벌 수 있지만, 두 속도의 차이는 그대로다.
백프레셔(backpressure, 배압)는 소비자가 감당할 만큼만 다음 데이터를 생산하도록 뒤에서 앞으로 전달하는 흐름 제어다. 데이터를 작은 조각으로 나누는 것과, 다음 조각을 만들 시기를 제어하는 것은 함께 설계해야 한다. 이미 DB 결과를 배열 전체로 읽어 놓았다면 뒤의 전송 스트림을 멈춰도 그 배열은 남는다.
이 글의 업무 상황은 설명용 사례다. 실험은 macOS arm64의 Node.js v22.23.1에서 지연을 넣은 가상 소비자로 실행했다. 2026-10-13 확인한 Node 22 최신 문서는 v22.23.3을 표시한다. 핵심 설명은 실행 버전의 고정 문서와 대조했으며, 최신 패치 런타임이나 실제 CSV 서버를 실행한 결과로 표현하지 않는다.
false는 이미 접수된 조각을 다시 보내라는 뜻이 아니다
Node의 Writable은 쓰기를 접수한 뒤, 내부에 남은 양을 기준으로 write()의 반환값을 정한다. v22.23.1의 write 문서에 따르면 false를 받으면 다음 쓰기를 멈추고 drain을 기다려야 한다.
여기서 놓치기 쉬운 점은 false를 반환한 그 chunk도 이미 접수됐다는 것이다. 같은 chunk를 다시 보내면 중복이다. drain은 쓰기를 재개할 시점을 알리는 신호이지, 상대 사용자가 파일을 저장했다거나 DB가 커밋했다는 업무 확인이 아니다. true 역시 업무 완료 통지가 아니다.
// 정상 동작 원리만 보여 주는 일부 코드다.
// 오류·닫힘·취소까지 관리하는 완성 함수는 아니다.
const canContinue = sink.write(chunk);
if (!canContinue) await once(sink, 'drain');
// 대기가 끝난 뒤 '다음' chunk를 만든다.
이 몇 줄만 떼어 운영 코드에 붙이면 종료 처리가 빠지기 쉽다. drain 전에 오류나 닫힘이 생기거나, 생산 중인 외부 호출이 끝나지 않을 수 있다. 완성된 흐름에는 오류·취소·최종 완료까지 묶어야 한다. 아래 실험에서 수동 루프는 정상 경로의 차이를 보여 주는 데만 사용하고, 실패 경로는 pipeline()으로 검사했다.
같은 64개 조각을 보낸 두 루프
각 chunk는 4 KiB, 총 64개다. Writable의 highWaterMark를 16 KiB로 명시했고, 소비자는 각 쓰기를 타이머로 지연한 뒤 완료 callback을 부른다. 요청한 지연은 1ms이며 실제 처리 속도가 정확히 1ms라는 뜻은 아니다.
| 실행 방식 | 관측한 최대 writableLength | 처리 완료 |
|---|---|---|
| false를 무시하고 64개를 연속 쓰기 | 262,144 bytes = 256 KiB | 64개 |
| false 이후 drain을 기다리기 | 16,384 bytes = 16 KiB | 64개 |
이때 writableLength는 이 Writable에 남아 있는 처리 중·대기 데이터의 양이다. 프로세스의 전체 메모리인 RSS가 아니다. 두 루프 모두 같은 양을 끝까지 처리했지만, 생산을 기다리게 한 쪽은 대기 데이터가 크게 쌓이지 않았다. 메모리를 16배 절약했다거나 처리량이 향상됐다는 실험은 아니다. 유한한 입력과 이 소비자에서 내부 쓰기 대기량을 비교한 결과다.
본문의 설명도는 소비자의 처리 방향과 생산자에게 돌아가는 대기 신호를 그린 것이다. 실제 서버 모니터링 화면이 아니며, 버퍼의 폭이 물리적 메모리 상한을 나타내지도 않는다.
highWaterMark로 보이지 않는 세 곳
공식 Buffering 설명은 highWaterMark를 엄격한 메모리 한도와 구분한다. 다음 세 경우에는 설정 숫자만 보고 용량을 예측하면 틀린다.
한 chunk가 기준보다 클 수 있다. 실험에서는 16 KiB 기준의 Writable에 32 KiB chunk 하나를 썼다. 반환값은 false였지만 writableLength는 32,768 bytes였다. 처음부터 너무 큰 행·메시지를 만든다면 백프레셔가 그 조각을 자동으로 쪼개 주지 않는다. 입력 한 건의 최대 크기도 따로 제한해야 한다.
객체 모드의 단위는 bytes가 아니다. objectMode: true, highWaterMark: 2인 소비자에 1 MiB Buffer를 담은 객체 하나를 넣었다. 관측값은 writableLength=1, 반환값은 true였다. 이는 1 byte가 아니라 객체 1개다. 객체 개수와 객체가 붙잡고 있는 데이터 크기를 함께 봐야 한다. 문자열 처리에서도 모드에 따라 계수 단위가 달라지므로 이 글의 Buffer 단위를 그대로 적용하지 않는다.
파이프라인에는 버퍼가 여럿 있다. Readable, Writable뿐 아니라 Transform의 읽기 쪽과 쓰기 쪽에도 각각 버퍼가 있다. DB 드라이버의 미리 가져오기, 압축기가 유지하는 상태, 이미 시작된 외부 호출의 데이터는 또 다른 공간이다. 하나의 Writable 길이가 작아도 다른 곳이 계속 늘어날 수 있다.
설계 검토에서는 다음처럼 추산 범위를 나누는 편이 낫다. 이는 메모리 사용량을 정확히 예측하는 공식이 아니라 놓친 저장 공간을 찾는 목록이다.
살펴볼 데이터 = 각 단계의 처리 중·대기 chunk
+ 읽어 둔 페이지/배치
+ 진행 중 비동기 작업이 보관한 입력·결과
+ 변환 상태·복사본·런타임 오버헤드
입력 한 건의 크기와 단계별 동시 실행 수가 정해져 있어야 유용한 용량 가정을 세울 수 있다. highWaterMark를 크게 하면 소비자가 빨라진다는 보장은 없다. 작게 해도 잦은 대기의 비용이 생길 수 있으므로 실제 부하에서 처리량·지연·메모리를 함께 측정해야 한다.
on('data', async ...)에 숨어 있는 작업 대기열
스트림 버퍼를 조절했는데도 외부 API 호출 수가 계속 늘어나는 경우가 있다. 다음 코드는 한 항목의 저장을 기다리지만, 다음 data 이벤트의 발생까지 기다리게 하지는 않는다.
// 순차 처리를 기대하면 안 되는 패턴
source.on('data', async row => {
await saveRow(row);
});
Node의 EventEmitter 문서는 리스너를 호출하고 그 반환값을 버리는 동작을 설명한다. 리스너가 async 함수여도 이벤트 발행자가 그 Promise의 완료를 기다리는 구조가 아니다. await에서 실행이 멈춘 동안 다른 이벤트의 리스너가 실행될 수 있다.
합성 입력 12개에 지연 작업을 붙인 실험에서는 위 패턴의 최대 진행 작업이 12개, for await 안에서 각 작업을 기다린 대조군은 1개였다. 스트림에서 꺼낸 뒤의 작업이 쌓였으므로 Writable 버퍼만 관측해서는 이 문제를 설명하기 어렵다.
// 순차 소비의 핵심. saveRow의 오류·타임아웃도 호출자가 처리해야 한다.
for await (const row of source) {
await saveRow(row);
}
순차 처리가 항상 요구되는 것은 아니다. 외부 서비스가 동시 호출을 감당하고 순서가 필요 없다면 제한된 병렬 처리가 유리할 수 있다. 그때는 실행 중 작업 수와 실행 전 대기 수를 모두 제한한다. 세마포어 안에서는 4개만 실행해도, 모든 입력을 순회하며 수십만 개의 대기 Promise를 먼저 만들었다면 별도 대기열이 생긴다.
반대로 순차 소비는 데이터 원천이 이미 가져온 전체 배열까지 없애 주지는 않는다. 입력을 필요할 때 읽는 커서·페이지 조회와 연결해야 한다. 위 코드가 실제 DB 드라이버의 커서를 취소하거나 닫는지는 드라이버별로 검증할 일이다.
오류와 취소도 같은 방향으로 연결하기
가능하면 개별 이벤트 처리보다 node:stream/promises의 pipeline()으로 흐름의 완료와 오류를 한곳에서 받는 편이 낫다. 공식 pipeline 문서는 async generator와 AbortSignal을 함께 사용하는 방법을 제공한다.
아래는 구조를 보여 주는 설계 예다. readNextChunk와 destination은 실제 입력·출력 구현으로 교체해야 하며, 이 코드 자체가 실행 가능한 DB 내보내기 프로그램은 아니다.
import { pipeline } from 'node:stream/promises';
await pipeline(
async function* ({ signal }) {
try {
while (true) {
signal.throwIfAborted();
const chunk = await readNextChunk({ signal });
if (chunk === null) break;
yield chunk;
}
} finally {
await closeSource();
}
},
destination,
{ signal: controller.signal },
);
실제 구현에서는 바깥에서 pipeline()의 거절을 처리해야 한다. 요청 기한이나 사용자의 취소를 controller에 연결하고, readNextChunk도 그 signal을 받아 중단하도록 만들어야 한다. 이름만 signal 인자를 받는 함수가 내부의 긴 작업을 계속한다면 즉시 종료되지 않는다. 자원 정리 함수가 실패했을 때 원래 실패 이유를 잃지 않는지도 확인한다.
오류 처리에 관한 공식 주의점도 중요하다. pipeline은 오류 때 아직 끝나지 않은 스트림을 destroy한다. HTTP 응답과 직접 연결하면 오류 뒤에 원하는 오류 본문을 보내기 전에 소켓이 닫힐 수 있다. 실패한 스트림을 그대로 재사용하면 리스너 문제도 생길 수 있다. 요청별 새 인스턴스, 응답 전 검사, 전송 도중 실패 시 사용자에게 보일 상태를 따로 설계해야 한다.
이번 다운로드 예제는 실제 DB나 HTTP 대신 유한한 생성기와 지연 Writable을 사용한다. 정상 완료, 생산자 오류, 소비자 오류, 명시적 취소를 각각 실행해 생성기의 finally 실행·소비자 destroy·타이머 정리를 확인했다. 취소는 64개를 모두 생산하기 전에 종료됐고 AbortError로 구분됐다. 버린 대기 데이터를 성공 처리 건수로 세지 않았다.
이미 저장되거나 외부로 전송된 효과를 취소가 되돌리지는 않는다. 보고서 다운로드를 중단했을 때 일부 파일을 완성본으로 취급하지 않는 규칙, 업무 데이터 적재 중 실패했을 때 재시도 시작점을 찾는 규칙은 스트림의 종료와 별도다.
생산을 멈출 수 없을 때 정할 것
백프레셔가 잘 작동하려면 기다리라는 신호가 실제 생산량을 줄일 수 있어야 한다. 아직 읽지 않은 파일은 기다릴 수 있지만, 외부 장치에서 이미 도착한 이벤트를 무한히 되돌릴 수는 없다. 다음 표는 제품 기본값이 아닌 업무 정책을 고르기 위한 판단표다.
| 상황 | 먼저 선택할 정책 | 확인할 대가 |
|---|---|---|
| 다시 읽을 수 있는 파일·조회 | 필요할 때 읽고 소비자를 기다림 | 연결·커서 점유 시간과 전체 기한 |
| 요청이 잠깐 몰리고 재시도 가능 | 유한한 대기열 후 수용 거절 | 호출자의 재시도 폭주와 사용자 안내 |
| 현재 상태만 중요한 화면 갱신 | 중간 상태를 합치거나 최신 값으로 교체 | 감사·결제 이벤트에는 적용 불가 |
| 모든 이벤트 보존이 필요 | 용량·보관 기한이 있는 내구 큐와 유입 제어 | 저장 비용·지연·중복 처리·큐가 찼을 때의 거절 |
입력이 출력보다 계속 빠르면 유한한 공간과 무손실, 무제한 수용을 동시에 유지할 수 없다. 디스크에 옮겨도 한계에 도달하는 시점을 늦출 뿐이다. 큐를 택했다면 길이뿐 아니라 가장 오래 기다린 항목의 나이와 남은 처리 기한도 봐야 한다.
한 사용자의 느린 연결이 다른 사용자의 일을 막는지도 확인한다. 예를 들어 여러 구독자에게 보내는 기능에서 가장 느린 한 명을 모두가 기다릴지, 사용자별 유한한 버퍼를 두고 초과한 연결을 끊을지는 다른 설계다. 이 글에서는 다중 구독자나 실제 네트워크 전파를 시험하지 않았다.
내 코드에 적용할 작은 재현 절차
실행 코드와 실제 실행 결과 JSON을 내려받아 같은 폴더에 놓는다. 추가 패키지 없이 다음 명령으로 10개 합성 사례를 다시 실행할 수 있다.
node --version
node backpressure-lab.mjs > result.json
정상 종료하면 JSON의 casesPassed는 10이다. 단언이 실패하면 0이 아닌 종료 코드가 나온다. 버퍼 대조·큰 chunk·객체 단위·비동기 동시성·정상/오류/취소가 검사 대상이며, 중단 순간의 세부 생산 건수는 환경의 스케줄링에 따라 달라질 수 있다. 이 코드는 입력을 최대 64개로 제한해 잘못된 루프도 의도적으로 무한히 돌리지 않는다.
실제 기능에 옮길 때는 같은 입력으로 소비자만 느리게 한 다음, 다음 기록을 남겨 보자.
입력 원천 / 다음 데이터를 만드는 시점:
한 항목 최대 크기 / 각 버퍼의 단위와 기준:
진행 중 비동기 작업 수 / 실행 전 대기 수:
느려질 때 관측: 버퍼 길이, 대기 항목 나이, 전체 메모리, 완료량
기한·오류·사용자 취소가 입력까지 전달되는 경로:
멈춘 뒤 남은 작업·열린 자원 / 이미 발생한 외부 효과:
버릴 수 없는 데이터와 공간 초과 시 수용 정책:
버퍼만 안정되고 진행 작업이 늘어난다면 비동기 호출의 시작 지점을, 모든 대기열이 안정됐는데 응답이 늦다면 요청의 지연과 임계 경로를 살펴볼 차례다. 이 재현 절차를 다시 쓸 때는 글의 저장 기능을, 후속 주제를 찾을 때는 소프트웨어 글 모음과 RSS를 이용할 수 있다.
- write()의 false는 방금 보낸 chunk의 거절이 아니라 다음 쓰기를 멈추라는 신호다.
- highWaterMark는 임계값이다. 큰 chunk·큰 객체·여러 버퍼·별도 비동기 작업까지 포함한 메모리 상한은 아니다.
- async data 리스너 안의 await만으로 입력 흐름이 느려지지 않는다. 소비 방식과 동시 실행 수를 함께 정한다.
- 오류·취소 때 생산도 멈추는지 확인한다. 지속 과부하는 대기만으로 해결되지 않으므로 수용 정책이 필요하다.
근거와 원자료
자료의 발행 시점과 이번 글에서 참고한 범위를 함께 기록합니다.
- Stream — Node.js v22.23.1 Documentation ↗Node.js · v22.23.1
Buffering, writable.write/writableLength, drain, _write, promise pipeline AbortSignal and error/HTTP/reuse caveats. Fixed documentation for local runtime; synthetic lab is original, not an official benchmark.
- Events — Node.js v22.23.1 Documentation ↗Node.js · v22.23.1
EventEmitter listener invocation is synchronous and return values are discarded. Async data listener concurrency is independently exercised in the finite synthetic lab; no actual database.
- Stream — Node.js 22.x current documentation ↗Node.js · 2026-10-13 조회: v22.23.3
Current 22.x page displayed v22.23.3. Fixed v22.23.3 JSON archived and Buffering/write/_write/pipeline desc sets identical to v22.23.1. Latest patch runtime not executed.
갱신 기록
첫 원고. Node.js v22.23.1에서 유한한 합성 스트림 10개 사례를 실행했다. 실제 RSS·처리량·네트워크·DB 성능 실험은 아니다.
오류를 발견했다면 →