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

문제 상황

사용자가 업로드한 CSV 파일을 파싱하여 DB에 저장하는 API를 운영 중이었다. 초기에는 작은 파일만 다뤘기 때문에 fs.readFile로 전체 파일을 메모리에 올린 뒤 처리했다.

const data = await fs.readFile(filePath, 'utf-8');
const rows = data.split('\n').map(row => row.split(','));

그런데 최근 고객사에서 150MB 규모의 CSV를 업로드하면서 서버 메모리 부족으로 프로세스가 죽는 현상이 발생했다. PM2로 재시작되긴 했지만 근본적인 해결이 필요했다.

스트림 기반 파싱으로 전환

csv-parser 라이브러리와 Node.js 스트림을 활용해 청크 단위로 처리하도록 변경했다.

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

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

결과

  • 메모리 사용량: 1.2GB → 120MB로 감소
  • 150MB 파일 처리 시간: 12초 (기존 8초에서 소폭 증가)
  • 더 이상 메모리 부족 에러 없음

처리 시간이 약간 늘어났지만 안정성이 더 중요했다. 배치 단위 DB 삽입으로 쿼리 수도 줄일 수 있었다.

추가 개선 사항

스트림 백프레셔 문제를 고려해 pause()resume()을 활용한 흐름 제어도 추가했다. DB 삽입이 느릴 경우 스트림을 일시정지하여 메모리 버퍼가 쌓이는 것을 방지했다.

Node.js 스트림은 처음엔 복잡해 보이지만, 대용량 데이터 처리에서는 필수적인 패턴이라는 걸 다시 한번 확인했다.