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

문제 상황

사용자가 업로드한 CSV 파일을 파싱해서 DB에 저장하는 기능을 구현했는데, 50만 건 정도의 데이터가 들어오자 서버가 Out of Memory로 죽어버렸다. 기존에는 fs.readFile로 전체 파일을 메모리에 올린 후 처리하고 있었다.

스트림 기반 처리로 전환

파일을 한 번에 읽지 않고 스트림으로 청크 단위로 읽어서 처리하도록 변경했다. csv-parser 라이브러리와 함께 사용하니 구현도 간단했다.

const fs = require('fs');
const csv = require('csv-parser');
const { promisify } = require('util');
const pipeline = promisify(require('stream').pipeline);

async function processCsv(filePath) {
  const results = [];
  let batch = [];
  const BATCH_SIZE = 1000;

  await pipeline(
    fs.createReadStream(filePath),
    csv(),
    async function* (source) {
      for await (const row of source) {
        batch.push(row);
        
        if (batch.length >= BATCH_SIZE) {
          await saveToDB(batch);
          batch = [];
        }
      }
      if (batch.length > 0) {
        await saveToDB(batch);
      }
    }
  );
}

배치 처리 추가

스트림으로 읽는 것만으로는 부족했다. 한 건씩 DB에 insert하면 너무 느려서 1000건씩 배치로 모아서 bulk insert를 수행하도록 했다.

async function saveToDB(batch) {
  const values = batch.map(row => 
    `('${row.name}', '${row.email}', '${row.phone}')`
  ).join(',');
  
  await db.query(
    `INSERT INTO users (name, email, phone) VALUES ${values}`
  );
}

결과

  • 메모리 사용량: 1.2GB → 120MB
  • 처리 시간: 50만 건 기준 약 3분
  • 더 이상 메모리 에러 없이 안정적으로 동작

스트림은 Node.js의 핵심 개념 중 하나인데 실제로 써보니 왜 중요한지 체감이 됐다. 대용량 파일 처리가 필요한 경우 처음부터 스트림 기반으로 설계하는 게 나을 것 같다.

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