Node.js 비동기 처리 중 발생한 메모리 누수 해결

문제 상황

사용자가 업로드한 CSV 파일을 파싱하는 API를 운영 중이었는데, 특정 시점 이후 서버 메모리 사용량이 계속 증가하다가 결국 프로세스가 종료되는 문제가 발생했다.

초기에는 단순히 힙 크기를 늘려서 해결하려 했지만, 근본적인 해결책이 아니라는 것을 깨달았다.

원인 분석

Node.js의 --inspect 플래그와 Chrome DevTools를 사용해 힙 스냅샷을 비교했다. csv-parser를 사용하는 부분에서 stream 객체들이 계속 메모리에 남아있었다.

// 문제가 있던 코드
app.post('/upload', (req, res) => {
  const results = [];
  fs.createReadStream(req.file.path)
    .pipe(csv())
    .on('data', (data) => results.push(data))
    .on('end', () => {
      res.json(results);
    });
});

에러 발생 시 stream이 제대로 정리되지 않았고, 파일 디스크립터도 열린 채로 남아있었다.

해결 방법

에러 핸들링을 추가하고, stream을 명시적으로 종료하도록 수정했다.

app.post('/upload', (req, res) => {
  const results = [];
  const stream = fs.createReadStream(req.file.path);
  
  stream
    .pipe(csv())
    .on('data', (data) => results.push(data))
    .on('end', () => {
      stream.destroy();
      res.json(results);
    })
    .on('error', (err) => {
      stream.destroy();
      res.status(500).json({ error: err.message });
    });
});

추가로 lsof 명령어로 열린 파일 디스크립터 수를 모니터링하도록 했다.

교훈

Node.js에서 stream을 다룰 때는 반드시 에러 처리와 명시적인 리소스 해제가 필요하다. 특히 파일이나 네트워크 stream은 더욱 주의해야 한다. 메모리 프로파일링 도구를 활용하면 이런 문제를 조기에 발견할 수 있다.