Node.js 스트림으로 대용량 CSV 파싱 메모리 문제 해결

문제 상황

고객사에서 주문 데이터 대량 업로드 기능을 사용하던 중 500MB 이상의 CSV 파일 처리 시 서버가 죽는 현상이 반복됐다. PM2 로그를 확인하니 heap out of memory 에러였다.

기존 코드는 multer로 파일을 받아 fs.readFile로 전체를 메모리에 올린 뒤 csv-parser로 처리하는 구조였다.

const buffer = await fs.readFile(file.path);
const records = parse(buffer, { columns: true });
// 메모리에 전체 데이터 적재

해결 방법

fs.createReadStream과 csv-parser의 스트림 API를 조합해 chunk 단위로 처리하도록 변경했다.

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

const processCSV = (filePath) => {
  return new Promise((resolve, reject) => {
    const results = [];
    let batch = [];
    
    fs.createReadStream(filePath)
      .pipe(csv())
      .on('data', async (row) => {
        batch.push(row);
        
        if (batch.length >= 1000) {
          stream.pause();
          await db.insertMany(batch);
          batch = [];
          stream.resume();
        }
      })
      .on('end', async () => {
        if (batch.length > 0) {
          await db.insertMany(batch);
        }
        resolve();
      })
      .on('error', reject);
  });
};

결과

  • 800MB CSV 파일 처리 시 메모리 사용량: 1.2GB → 60MB
  • 처리 시간은 오히려 30% 단축 (배치 insert 효과)
  • 서버 크래시 없이 안정적으로 동작

추가 개선

worker_threads를 사용해 파싱 작업을 별도 스레드로 분리하는 방안도 검토했으나, 현재 처리량에서는 스트림만으로 충분했다. 향후 동시 업로드가 많아지면 고려할 예정이다.

Node.js의 스트림은 러닝커브가 있지만 대용량 데이터 처리에서는 필수적이다. backpressure 제어를 위해 pause/resume을 적절히 사용하는 것이 핵심이었다.