Node.js 스트림으로 대용량 CSV 파일 처리 최적화
문제 상황
재택근무 중 백오피스 담당자가 110만 건의 주문 데이터를 CSV로 업로드하려다 서버가 멈추는 이슈를 제보했다. 기존 코드는 fs.readFileSync로 파일을 전부 메모리에 올린 후 파싱하는 구조였다.
const data = fs.readFileSync(filePath, 'utf-8');
const rows = data.split('\n').map(row => row.split(','));
// 메모리에 전체 데이터 적재
500MB 파일 기준으로 Node.js 프로세스 메모리 사용량이 2GB를 넘어가며 OOM 발생.
해결 방법
스트림 기반 처리로 변경했다. csv-parser 라이브러리를 사용해 청크 단위로 읽으면서 DB에 배치 삽입하는 방식으로 개선했다.
const fs = require('fs');
const csv = require('csv-parser');
const { pipeline } = require('stream');
const batchSize = 1000;
let batch = [];
fs.createReadStream(filePath)
.pipe(csv())
.on('data', async (row) => {
batch.push(row);
if (batch.length >= batchSize) {
await db.bulkInsert(batch);
batch = [];
}
})
.on('end', async () => {
if (batch.length > 0) {
await db.bulkInsert(batch);
}
});
결과
- 메모리 사용량: 2GB → 150MB
- 처리 시간: timeout → 약 3분
- 안정성: OOM 없이 200만 건까지 테스트 완료
배치 크기를 1000으로 설정한 이유는 PostgreSQL의 단일 쿼리 파라미터 제한과 네트워크 왕복 비용을 고려한 결과였다. 100으로 줄이면 처리 시간이 2배 이상 늘어났고, 5000으로 늘리면 큰 차이가 없었다.
추가 개선
stream.pipeline을 사용하면 에러 핸들링과 백프레셔 처리가 더 안전해진다.
const { Transform } = require('stream');
const batchTransform = new Transform({
objectMode: true,
async transform(chunk, encoding, callback) {
// 배치 처리 로직
callback();
}
});
pipeline(
fs.createReadStream(filePath),
csv(),
batchTransform,
(err) => {
if (err) console.error('Pipeline failed', err);
}
);
스트림 방식에 익숙해지면 메모리 효율이 중요한 대부분의 I/O 작업에 적용할 수 있다.