OpenAI API 스트리밍 응답 처리 시 메모리 누수 해결

문제 상황

사내 챗봇 서비스에 GPT-4 API를 붙이고 스트리밍 응답을 구현했다. 초기 테스트는 문제없었지만, 일주일 정도 운영 후 Node.js 프로세스의 메모리 사용량이 계속 증가하는 현상이 발견됐다.

원인 분석

OpenAI의 스트리밍 API는 SSE(Server-Sent Events) 방식을 사용한다. 우리 코드는 fetch로 스트림을 받아 처리했는데, 에러 발생이나 클라이언트 연결 종료 시 스트림을 제대로 닫지 않았다.

// 문제가 있던 코드
async function streamChat(prompt) {
  const response = await fetch('https://api.openai.com/v1/chat/completions', {
    method: 'POST',
    headers: { /* ... */ },
    body: JSON.stringify({ stream: true, /* ... */ })
  });
  
  const reader = response.body.getReader();
  while (true) {
    const {done, value} = await reader.read();
    if (done) break;
    // 처리 로직
  }
}

클라이언트가 중간에 연결을 끊어도 reader가 계속 메모리에 남아있었다.

해결 방법

AbortController를 사용해 명시적으로 요청을 취소하고, finally 블록에서 리소스를 정리했다.

async function streamChat(prompt, signal) {
  const controller = new AbortController();
  let reader;
  
  try {
    const response = await fetch('https://api.openai.com/v1/chat/completions', {
      method: 'POST',
      headers: { /* ... */ },
      body: JSON.stringify({ stream: true, /* ... */ }),
      signal: controller.signal
    });
    
    reader = response.body.getReader();
    
    while (true) {
      const {done, value} = await reader.read();
      if (done) break;
      // 처리 로직
    }
  } catch (error) {
    if (error.name === 'AbortError') {
      console.log('Stream aborted');
    }
    throw error;
  } finally {
    if (reader) {
      await reader.cancel();
    }
    controller.abort();
  }
}

Express 미들웨어에서 클라이언트 연결 종료를 감지하도록 추가했다.

app.post('/api/chat', (req, res) => {
  const controller = new AbortController();
  
  req.on('close', () => {
    controller.abort();
  });
  
  streamChat(req.body.prompt, controller.signal);
});

결과

수정 후 일주일간 모니터링한 결과 메모리 사용량이 안정적으로 유지됐다. 스트리밍 API 사용 시 리소스 정리는 필수였다.