Node.js 스트림 메모리 누수 디버깅
문제 상황
대용량 CSV 파일을 처리하는 API 서버에서 메모리 사용량이 계속 증가하는 현상이 발생했다. PM2로 모니터링한 결과 처리 건수가 늘어날수록 메모리가 회수되지 않았다.
원인 분석
clinic 도구로 힙 덤프를 분석한 결과, 스트림 이벤트 리스너가 누적되고 있었다. 기존 코드는 다음과 같았다.
const fs = require('fs');
const csv = require('csv-parser');
app.post('/upload', async (req, res) => {
const results = [];
fs.createReadStream(req.file.path)
.pipe(csv())
.on('data', (data) => results.push(data))
.on('end', () => {
processData(results);
res.json({ success: true });
});
});
문제는 에러 발생 시나 중간에 요청이 취소될 때 스트림을 제대로 정리하지 않았다는 점이었다.
해결 방법
스트림을 명시적으로 관리하고, 에러 핸들링과 정리 로직을 추가했다.
app.post('/upload', async (req, res) => {
const results = [];
const stream = fs.createReadStream(req.file.path)
.pipe(csv());
stream.on('data', (data) => results.push(data));
stream.on('end', () => {
processData(results);
res.json({ success: true });
});
stream.on('error', (err) => {
stream.destroy();
res.status(500).json({ error: err.message });
});
req.on('close', () => {
stream.destroy();
});
});
추가로 pipeline을 사용하는 방식으로 리팩토링하면 더 안전하게 처리할 수 있다.
const { pipeline } = require('stream/promises');
try {
await pipeline(
fs.createReadStream(req.file.path),
csv(),
async function* (source) {
for await (const chunk of source) {
results.push(chunk);
yield chunk;
}
}
);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
결과
수정 후 메모리 사용량이 안정화되었고, 24시간 부하 테스트에서도 문제가 재현되지 않았다. 스트림 사용 시 반드시 생명주기를 관리해야 한다는 교훈을 얻었다.