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 스트림은 처음엔 복잡해 보이지만, 대용량 데이터 처리에서는 필수적인 패턴이라는 걸 다시 한번 확인했다.