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

문제 상황

고객사 주문 데이터 마이그레이션 작업 중, 600MB 크기의 CSV 파일을 처리해야 했다. 기존 코드는 fs.readFile로 전체 파일을 메모리에 올린 후 파싱했는데, 프로덕션 환경에서 JavaScript heap out of memory 에러가 발생했다.

// 기존 코드 - 메모리 오버플로우 발생
const data = await fs.readFile('orders.csv', 'utf-8');
const rows = data.split('\n');

해결 방법

Node.js 스트림과 csv-parser 라이브러리를 조합하여 청크 단위로 처리하도록 변경했다. 스트림은 데이터를 작은 단위로 나눠 처리하므로 전체 파일을 메모리에 올릴 필요가 없다.

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

const processCSV = (filePath) => {
  return new Promise((resolve, reject) => {
    const results = [];
    let batchCount = 0;

    fs.createReadStream(filePath)
      .pipe(csv())
      .on('data', async (row) => {
        results.push(row);
        
        // 1000개씩 배치 처리
        if (results.length >= 1000) {
          batchCount++;
          await saveBatch(results.splice(0, 1000));
          console.log(`Batch ${batchCount} processed`);
        }
      })
      .on('end', async () => {
        if (results.length > 0) {
          await saveBatch(results);
        }
        resolve();
      })
      .on('error', reject);
  });
};

결과

  • 메모리 사용량: 1.2GB → 120MB
  • 처리 시간: 큰 차이 없음 (약 45초)
  • 안정성: 더 큰 파일도 처리 가능

배치 단위를 조정하면 메모리와 성능 사이 밸런스를 맞출 수 있다. 1000개 단위가 우리 케이스에서는 최적이었다.

추가 개선

pipeline API를 사용하면 에러 핸들링이 더 깔끔하다.

const { pipeline } = require('stream/promises');

await pipeline(
  fs.createReadStream(filePath),
  csv(),
  async function* (source) {
    for await (const row of source) {
      yield processRow(row);
    }
  }
);

스트림은 Node.js의 핵심 기능이지만 실전에서 제대로 쓸 기회가 많지 않았다. 이번 작업으로 확실히 익힐 수 있었다.