Node.js 스트림으로 대용량 CSV 파일 처리 최적화

문제 상황

재택근무 중 백오피스 담당자가 110만 건의 주문 데이터를 CSV로 업로드하려다 서버가 멈추는 이슈를 제보했다. 기존 코드는 fs.readFileSync로 파일을 전부 메모리에 올린 후 파싱하는 구조였다.

const data = fs.readFileSync(filePath, 'utf-8');
const rows = data.split('\n').map(row => row.split(','));
// 메모리에 전체 데이터 적재

500MB 파일 기준으로 Node.js 프로세스 메모리 사용량이 2GB를 넘어가며 OOM 발생.

해결 방법

스트림 기반 처리로 변경했다. csv-parser 라이브러리를 사용해 청크 단위로 읽으면서 DB에 배치 삽입하는 방식으로 개선했다.

const fs = require('fs');
const csv = require('csv-parser');
const { pipeline } = require('stream');

const batchSize = 1000;
let batch = [];

fs.createReadStream(filePath)
  .pipe(csv())
  .on('data', async (row) => {
    batch.push(row);
    if (batch.length >= batchSize) {
      await db.bulkInsert(batch);
      batch = [];
    }
  })
  .on('end', async () => {
    if (batch.length > 0) {
      await db.bulkInsert(batch);
    }
  });

결과

  • 메모리 사용량: 2GB → 150MB
  • 처리 시간: timeout → 약 3분
  • 안정성: OOM 없이 200만 건까지 테스트 완료

배치 크기를 1000으로 설정한 이유는 PostgreSQL의 단일 쿼리 파라미터 제한과 네트워크 왕복 비용을 고려한 결과였다. 100으로 줄이면 처리 시간이 2배 이상 늘어났고, 5000으로 늘리면 큰 차이가 없었다.

추가 개선

stream.pipeline을 사용하면 에러 핸들링과 백프레셔 처리가 더 안전해진다.

const { Transform } = require('stream');

const batchTransform = new Transform({
  objectMode: true,
  async transform(chunk, encoding, callback) {
    // 배치 처리 로직
    callback();
  }
});

pipeline(
  fs.createReadStream(filePath),
  csv(),
  batchTransform,
  (err) => {
    if (err) console.error('Pipeline failed', err);
  }
);

스트림 방식에 익숙해지면 메모리 효율이 중요한 대부분의 I/O 작업에 적용할 수 있다.

Node.js 스트림으로 대용량 CSV 파일 처리 최적화