Node.js 스트림 파이프라인으로 대용량 CSV 처리 개선

문제 상황

데이터 분석팀에서 요청한 배치 작업이 있었다. 매일 새벽 2시에 외부 API로부터 받은 CSV 파일을 파싱해서 DB에 저장하는 작업인데, 파일 크기가 점점 커지면서 메모리 부족으로 프로세스가 죽기 시작했다.

기존 코드는 대략 이런 식이었다.

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

const data = fs.readFileSync('./large-file.csv', 'utf8');
const rows = parseCsv(data); // 전체를 메모리에 적재
await bulkInsert(rows);

200MB가 넘는 파일을 처리할 때 힙 메모리가 한계에 도달했다.

스트림 파이프라인으로 전환

Node.js 스트림을 사용하면 파일을 청크 단위로 읽어서 처리할 수 있다. 전체 파일을 메모리에 올리지 않고도 순차 처리가 가능하다.

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

const batchSize = 1000;
let buffer = [];

const writeStream = new Writable({
  objectMode: true,
  async write(row, encoding, callback) {
    buffer.push(row);
    
    if (buffer.length >= batchSize) {
      const batch = buffer.splice(0, batchSize);
      await db.bulkInsert(batch);
    }
    callback();
  },
  async final(callback) {
    if (buffer.length > 0) {
      await db.bulkInsert(buffer);
    }
    callback();
  }
});

pipeline(
  fs.createReadStream('./large-file.csv'),
  csv(),
  writeStream,
  (err) => {
    if (err) console.error('Pipeline failed:', err);
    else console.log('Pipeline succeeded');
  }
);

결과

  • 메모리 사용량: 1.2GB → 150MB
  • 처리 시간: 큰 차이 없음 (DB 쓰기가 병목)
  • 안정성: 파일 크기에 관계없이 일정한 메모리 사용

스트림 API가 처음엔 낯설었지만, backpressure 처리까지 자동으로 해주는 pipeline 함수 덕분에 생각보다 간단하게 해결됐다. 대용량 데이터를 다룰 때는 스트림을 먼저 고려하는 게 맞다.