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

문제 상황

고객사 데이터 마이그레이션 작업 중 10GB가 넘는 CSV 파일을 처리해야 했다. 초기 구현은 fs.readFile()로 전체 파일을 메모리에 올린 후 파싱하는 방식이었는데, 실행 후 몇 분 만에 FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed 에러가 발생했다.

해결 방법

fs.createReadStream()readline 모듈을 조합해 라인 단위로 스트리밍 처리하도록 변경했다.

const fs = require('fs');
const readline = require('readline');

const rl = readline.createInterface({
  input: fs.createReadStream('large_data.csv'),
  crlfDelay: Infinity
});

let count = 0;
rl.on('line', (line) => {
  const columns = line.split(',');
  // DB insert 로직
  processRow(columns);
  count++;
  if (count % 10000 === 0) {
    console.log(`Processed ${count} rows`);
  }
});

rl.on('close', () => {
  console.log(`Total ${count} rows processed`);
});

결과

  • 메모리 사용량: 2.1GB → 180MB
  • 처리 시간: OOM으로 실패 → 약 45분 소요
  • Node.js 프로세스가 안정적으로 종료까지 완료

추가 개선사항

DB insert가 병목이라 Promise.all()로 배치 처리를 시도했으나, 동시 연결 수 제한에 걸렸다. 결국 100개씩 묶어서 순차 처리하는 방식으로 절충했다.

let batch = [];
rl.on('line', async (line) => {
  batch.push(line.split(','));
  if (batch.length >= 100) {
    await insertBatch(batch);
    batch = [];
  }
});

스트림 처리는 I/O 바운드 작업에서 필수적이다. 특히 Node.js 8에서는 util.promisify()가 추가되어 콜백 기반 스트림 처리도 async/await로 깔끔하게 작성할 수 있게 되었다.