Node.js 8 Stream 처리 중 메모리 누수 해결

문제 상황

회사 프로젝트에서 100MB 이상의 대용량 파일을 S3에 업로드하는 기능을 구현했다. 로컬 테스트에서는 문제가 없었는데, 스테이징 환경에서 여러 사용자가 동시에 업로드하면 서버 메모리가 계속 증가하다가 결국 프로세스가 죽는 현상이 발생했다.

원인 분석

Node.js 8의 Stream을 사용했는데, 두 가지 문제가 있었다.

  1. Stream을 제대로 종료하지 않아 이벤트 리스너가 메모리에 남아있었다
  2. Backpressure를 고려하지 않아 빠른 읽기가 느린 쓰기를 압도했다

해결 방법

const fs = require('fs');
const AWS = require('aws-sdk');
const s3 = new AWS.S3();

function uploadToS3(filePath, bucket, key) {
  return new Promise((resolve, reject) => {
    const readStream = fs.createReadStream(filePath);
    const params = {
      Bucket: bucket,
      Key: key,
      Body: readStream
    };

    readStream.on('error', (err) => {
      readStream.destroy();
      reject(err);
    });

    s3.upload(params, (err, data) => {
      readStream.destroy();
      if (err) reject(err);
      else resolve(data);
    });
  });
}

핵심은 에러 발생 시와 완료 시 모두 stream.destroy()를 명시적으로 호출하는 것이었다. 또한 S3 SDK가 내부적으로 backpressure를 처리해주기 때문에 직접 Stream을 S3에 전달하는 방식으로 변경했다.

결과

메모리 사용량이 안정화되었고, 동시에 20개 파일을 업로드해도 문제없이 동작했다. process.memoryUsage()로 모니터링한 결과 heapUsed가 일정 수준 이상 올라가지 않았다.

Stream 사용 시 명시적인 리소스 정리가 얼마나 중요한지 배웠다.