Node.js 스트림 백프레셔 처리 제대로 하기

문제 상황

파일 업로드 후 S3에 전송하는 API에서 500MB 이상 파일 처리 시 메모리 사용량이 2GB를 넘어가며 OOM이 발생했다. 스트림을 사용하고 있었지만 제대로 된 백프레셔 처리가 없었다.

기존 코드는 이랬다:

const stream = fs.createReadStream(filePath);
stream.on('data', async (chunk) => {
  await uploadChunk(chunk); // 비동기 처리
});

문제는 data 이벤트 핸들러가 async 함수라는 점이었다. 업로드가 느려도 스트림은 계속 데이터를 읽어 메모리에 쌓였다.

해결 방법

Node.js 15부터 안정화된 pipelineTransform 스트림을 활용했다.

const { pipeline } = require('stream/promises');
const { Transform } = require('stream');

const uploadTransform = new Transform({
  async transform(chunk, encoding, callback) {
    try {
      await uploadChunk(chunk);
      callback();
    } catch (err) {
      callback(err);
    }
  }
});

await pipeline(
  fs.createReadStream(filePath),
  uploadTransform
);

Transform 스트림의 transform 콜백은 callback을 호출하기 전까지 다음 청크를 읽지 않는다. 이를 통해 자연스럽게 백프레셔가 처리된다.

추가 개선

멀티파트 업로드를 위해 청크 사이즈를 조절하는 로직도 추가했다:

const chunkTransform = new Transform({
  transform(chunk, encoding, callback) {
    this.buffer = this.buffer ? Buffer.concat([this.buffer, chunk]) : chunk;
    
    if (this.buffer.length >= 5 * 1024 * 1024) {
      this.push(this.buffer);
      this.buffer = null;
    }
    callback();
  },
  flush(callback) {
    if (this.buffer) this.push(this.buffer);
    callback();
  }
});

결과적으로 메모리 사용량은 100MB 이하로 유지되며 안정적으로 동작한다. 스트림을 쓴다고 끝이 아니라 백프레셔를 제대로 이해하고 구현하는 것이 중요했다.