Node.js 스트림으로 대용량 CSV 파싱 성능 개선하기

문제 상황

데이터 분석팀에서 매일 생성되는 200MB 크기의 CSV 파일을 처리하는 배치 작업이 있었다. 기존에는 fs.readFileSync로 전체 파일을 메모리에 로드한 뒤 파싱했는데, 파일 크기가 커지면서 힙 메모리 부족으로 프로세스가 종료되는 문제가 발생했다.

기존 코드

const fs = require('fs');
const parse = require('csv-parse/lib/sync');

const data = fs.readFileSync('large-file.csv', 'utf-8');
const records = parse(data, { columns: true });

records.forEach(record => {
  // DB 저장 로직
});

메모리 사용량이 약 2GB까지 증가했고, 파일이 더 커지면 OOM이 발생했다.

스트림 기반으로 전환

csv-parse 라이브러리의 스트림 API를 사용하여 청크 단위로 처리하도록 변경했다.

const fs = require('fs');
const { parse } = require('csv-parse');
const { pipeline } = require('stream/promises');

const parser = parse({
  columns: true,
  skip_empty_lines: true
});

const processRecord = async (record) => {
  // DB 저장 로직
  await db.insert(record);
};

const writableStream = new Writable({
  objectMode: true,
  async write(record, encoding, callback) {
    try {
      await processRecord(record);
      callback();
    } catch (error) {
      callback(error);
    }
  }
});

await pipeline(
  fs.createReadStream('large-file.csv'),
  parser,
  writableStream
);

결과

  • 메모리 사용량: 2GB → 200MB (약 1/10 감소)
  • 처리 시간: 45초 → 32초 (배압 제어 덕분에 DB 부하도 분산)
  • 500MB 파일도 안정적으로 처리 가능

배운 점

Node.js 스트림은 학습 곡선이 있지만, 대용량 데이터 처리에서는 필수다. pipeline을 사용하면 에러 핸들링과 백프레셔를 자동으로 처리해준다. 앞으로 파일 처리는 기본적으로 스트림을 고려해야겠다.