write()의 반환값을 읽을 때는 먼저 두 상황을 구별해야 한다. 방금 보낸 청크가 거절됐는가, 아니면 다음 청크를 보내기 전에 기다려야 하는가. 정상적인 Node.js Writable에서 backpressure 때문에 나온 false는 두 번째 질문에 답한다. 이 값을 쓰기 실패로 해석해 같은 청크를 다시 보내면 중복이 생길 수 있다.
이 글은 2026년 11월 7일 KST에 공식 문서와 본문 코드의 실행 결과를 다시 확인한 기술 노트다. Node.js v24.22.1에서 메모리 안의 Writable을 만들어 정상 진행, 잘못된 재전송, 강제 종료를 비교했다. 실제 파일·소켓·외부 API는 사용하지 않았으며, 저장 장치의 내구성이나 원격 서비스의 처리 결과는 검증하지 않았다.
false가 나온 호출에서 A는 어디까지 갔는가
공식 write() 설명은 청크를 받아들인 뒤의 버퍼 상태로 반환값을 설명한다. backpressure로 false가 나오면 추가 쓰기를 멈추고 drain을 기다린다. 이 규칙을 이미 종료·파괴됐거나 오류가 난 스트림의 수락 보장으로 넓혀 읽으면 안 된다.
실험에서는 highWaterMark: 1을 지정하고 ASCII 문자 A를 한 번 보낸다. 목적지의 처리 콜백은 아직 호출하지 않는다. 이때 반환값은 false, writableLength는 1이고, 직접 만든 write 구현은 이미 A를 받았다. A가 전달됐다는 관찰과 처리가 끝났다는 판정은 여기서 분리된다.
임계값을 1로 줄인 이유는 큰 데이터 없이 흐름 제어를 관찰하기 위해서다. 이 값은 일반적인 메모리 상한이 아니다. 공식 Buffering 설명도 highWaterMark를 임계값으로 구분한다.
복사해서 실행하는 최소 실험
아래 전체 코드를 test.mjs로 저장한 뒤 실행한다. Node.js 기본 모듈만 쓰므로 패키지 설치는 필요 없다. .mjs는 strict mode로 실행되며, 명령의 옵션은 처리되지 않은 Promise 거부도 실패로 드러내기 위한 것이다. 예제는 테스트 소스와 출력 외에 파일을 만들거나 바꾸지 않는다.
node --version
node --unhandled-rejections=strict test.mjs
실험의 release()가 보관한 처리 콜백을 직접 호출한다. 임의의 지연 시간이나 실행 속도에 기대지 않고, 관찰한 다음에 처리를 풀어 주는 순서다. 이는 설명용 합성 목적지이며 실무용 파일 저장기의 구현은 아니다.
import assert from 'node:assert/strict';
import { Writable } from 'node:stream';
import { finished } from 'node:stream/promises';
import { once } from 'node:events';
// .mjs is always strict mode. No network or destination file is used.
function controlledSink() {
const seen = [];
const pending = [];
const sink = new Writable({
highWaterMark: 1,
write(chunk, encoding, callback) {
seen.push(chunk.toString());
pending.push(callback);
},
});
return {
sink, seen,
release() {
assert.equal(pending.length, 1);
pending.shift()();
},
};
}
async function run(label, retrySameChunk) {
const { sink, seen, release } = controlledSink();
const done = finished(sink, { cleanup: true });
// Attach rejection handling immediately, even before the final await.
done.catch(() => {});
const canContinue = sink.write('A');
assert.equal(canContinue, false);
assert.deepEqual(seen, ['A']);
assert.equal(sink.writableLength, 1);
console.log(`${label}: write(A)=${canContinue}, seen=${seen.join('')}, length=${sink.writableLength}`);
const drained = once(sink, 'drain');
release();
await drained;
console.log(`${label}: drain, length=${sink.writableLength}, finished=${sink.writableFinished}`);
if (retrySameChunk) {
// Intentionally wrong: false was misread as rejection of A.
assert.equal(sink.write('A'), false);
const drainedAgain = once(sink, 'drain');
release();
await drainedAgain;
}
assert.equal(sink.write('B'), false);
// No next chunk remains. End, then wait for completion, not another drain.
sink.end();
release();
await done;
const expected = retrySameChunk ? 'AAB' : 'AB';
assert.equal(seen.join(''), expected);
assert.equal(sink.writableFinished, true);
console.log(`${label}: finish, seen=${seen.join('')}`);
}
await run('correct', false);
await run('wrong-retry', true);
// A separate failure path: false is not a promise of eventual success.
const { sink, release } = controlledSink();
const done = finished(sink, { cleanup: true });
const checked = assert.rejects(done, /synthetic failure/);
assert.equal(sink.write('X'), false);
sink.destroy(new Error('synthetic failure'));
release();
await checked;
assert.equal(sink.writableFinished, false);
console.log('destroy: rejected, finished=false');
console.log('PASS');
실행 결과: AB와 AAB의 차이
확인 환경의 버전 출력은 v24.22.1이었다. 위 코드를 실행해 얻은 출력은 다음과 같다. 다른 환경에서는 먼저 버전을 기록하고 assertion 통과 여부를 함께 확인한다.
correct: write(A)=false, seen=A, length=1
correct: drain, length=0, finished=false
correct: finish, seen=AB
wrong-retry: write(A)=false, seen=A, length=1
wrong-retry: drain, length=0, finished=false
wrong-retry: finish, seen=AAB
destroy: rejected, finished=false
PASS
두 경로의 첫 줄은 같다. A가 처리 구현에 도착했지만 콜백이 보류돼 있어 false가 나온다. 정상 경로는 콜백을 풀고 drain을 확인한 다음 B로 넘어간다. 잘못된 경로는 같은 시점에 A를 한 번 더 보낸다. 최종 관찰값 AAB는 스트림이 임의로 복제한 결과가 아니라, 호출자가 A를 두 번 쓴 결과다.
여기서 seen은 처리 함수가 받은 청크의 기록이다. 데이터베이스의 중복 행이나 원격 서버의 중복 요청을 직접 재현한 것은 아니다. 다만 반환값 하나를 근거로 같은 청크를 재전송하는 분기에서 입력 자체가 중복된다는 점은 확인할 수 있다.
drain, finish, 저장 완료를 따로 기록한다
drain 직후 출력에는 finished=false가 남아 있다. 이 실험에서 A의 처리를 풀었다고 전체 입력이 끝난 것은 아니다. B가 남아 있으므로 생산자는 다음 청크로 진행한다. 공식 문서에서도 drain은 쓰기를 재개할 시점, finish는 end() 이후 기반 시스템으로 데이터 처리가 끝난 시점으로 설명한다.
예제의 마지막 B에서도 반환값은 false다. 그러나 다음에 보낼 청크가 없으므로 다시 drain을 기다리는 대신 end()를 호출하고 finished()로 종료 결과를 확인한다. 마지막 청크까지 보낸 상태와 중간에 일시 정지한 상태를 같은 대기 분기로 묶지 않는다.
이 메모리 목적지에서 finish가 증명하는 범위는 등록한 쓰기 처리가 끝났다는 데까지다. 파일의 전원 장애 대비 영속성, 원격 서버의 저장 완료, 업무 트랜잭션 커밋은 각 목적지의 별도 계약과 확인 절차가 필요하다. 이 구분을 로그에도 적용하려면 단순히 “저장 성공”이라고 쓰기보다 어떤 단계가 끝났는지를 남기는 편이 명확하다.
오류 경로를 빼면 대기 코드가 완성되지 않는다
마지막 테스트는 X를 쓴 뒤 의도적으로 destroy(new Error(...))를 호출한다. finished()의 거부를 확인하고 writableFinished가 false인지 검사한다. 앞서 나온 false가 최종 성공을 약속하지 않는다는 경계를 별도로 확인하는 것이다. destroy() 설명도 처리 중인 쓰기가 끝나지 않을 수 있음을 명시한다.
본문의 once(sink, 'drain')는 정상 경로를 정해 놓은 실험에서만 사용한다. 임의의 실무 목적지에는 오류뿐 아니라 조기 close, 취소, 끝나지 않는 대기까지 함께 처리해야 한다. finished()를 만들어 두는 것만으로 별개의 drain 대기가 자동 취소되지는 않는다. 단순한 스트림 연결이라면 pipeline()을 먼저 검토하고, 수동 생산 루프가 꼭 필요하면 종료 감시와 대기 취소를 하나의 수명 주기로 설계한다.
중복 쓰기를 진단할 때 확인하는 순서
첫째, false가 나온 청크의 식별자와 다음 호출의 식별자를 비교한다. 처리 콜백의 오류와 error 이벤트도 함께 확인한다. false만 기록한 로그로는 backpressure와 최종 실패를 구별할 수 없다.
둘째, drain 이후의 분기가 같은 청크를 재전송하는지, 다음 청크로 진행하는지 읽는다. 예제에서는 두 분기의 차이를 retrySameChunk 한 곳에 모아 결과를 비교했다.
셋째, 생산 중·입력 종료·처리 완료를 나눈다. 더 보낼 데이터가 있으면 재개 시점을 기다리고, 모두 보냈으면 종료 결과를 확인한다. 오류 뒤 재시도가 필요한지는 목적지의 중복 방지 규칙과 이미 처리된 범위를 근거로 따로 결정한다.
넷째, 성공이라고 부르는 근거를 적는다. 메모리 처리 함수의 수신, 스트림 종료, 서버 응답, 저장 확정 가운데 실제로 관찰한 단계가 무엇인지 남긴다. 이 실험에서는 A와 B가 한 번씩 처리 구현에 전달되고 finish까지 도달했는지를 확인했다.
명령 출력 수집 한도는 MCP exec의 maxBuffer와 출력 크기 진단에서 다뤘다. 여기서 살핀 문제는 수집 용량보다 앞선 호출자의 분기다. false 다음에 같은 청크를 다시 보내는 코드가 있다면, 버퍼 크기를 늘리기 전에 그 재전송 조건부터 확인한다.