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