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

문제 상황

데이터 분석팀에서 매일 제공하는 CSV 파일(약 12GB)을 파싱해서 DB에 적재하는 배치 작업이 있었다. 기존에는 fs.readFileSync로 파일을 통째로 읽어서 처리했는데, 파일 크기가 커지면서 메모리 부족으로 프로세스가 죽는 현상이 반복됐다.

기존 코드의 문제

const fs = require('fs');
const csv = require('csv-parser');

const data = fs.readFileSync('large-data.csv', 'utf8');
const rows = data.split('\n').map(parseCsvRow);
// 메모리에 전체 데이터 로드 -> OOM

파일 전체를 메모리에 올리기 때문에 Node.js의 기본 힙 사이즈(약 1.4GB)를 초과했다.

스트림 기반 해결

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

const batchInsert = new Transform({
  objectMode: true,
  highWaterMark: 1000,
  async transform(row, encoding, callback) {
    try {
      await db.insert(row);
      callback();
    } catch (err) {
      callback(err);
    }
  }
});

fs.createReadStream('large-data.csv')
  .pipe(csv())
  .pipe(batchInsert)
  .on('finish', () => console.log('완료'))
  .on('error', (err) => console.error(err));

개선 결과

  • 메모리 사용량: 2.1GB → 200MB
  • 처리 시간: 약간 개선 (I/O 대기 시간 중복 처리)
  • 안정성: OOM 에러 완전히 해결

highWaterMark를 조정해서 버퍼 크기를 제어할 수 있었고, objectMode로 객체 단위 스트림 처리가 가능했다.

추가 최적화

배치 삽입을 위해 일정 개수씩 모아서 bulk insert 하도록 개선했다.

let batch = [];
const BATCH_SIZE = 1000;

const batchInsert = new Transform({
  objectMode: true,
  async transform(row, encoding, callback) {
    batch.push(row);
    if (batch.length >= BATCH_SIZE) {
      await db.bulkInsert(batch);
      batch = [];
    }
    callback();
  },
  async flush(callback) {
    if (batch.length > 0) {
      await db.bulkInsert(batch);
    }
    callback();
  }
});

이후 처리 시간이 기존 대비 40% 단축됐다. 대용량 파일 처리에는 스트림이 답이었다.