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

문제 상황

사용자 대시보드에서 OpenAI API를 활용한 실시간 분석 기능을 운영 중이었다. 배포 후 며칠간 서버 메모리 사용량이 계속 증가하다가 결국 OOM으로 인스턴스가 재시작되는 문제가 반복됐다.

모니터링 결과 스트리밍 응답을 처리하는 엔드포인트에서 메모리가 해제되지 않고 있었다.

원인 분석

기존 코드는 다음과 같았다.

app.post('/api/analyze', async (req, res) => {
  const stream = await openai.chat.completions.create({
    model: 'gpt-4o',
    messages: req.body.messages,
    stream: true
  });

  for await (const chunk of stream) {
    res.write(JSON.stringify(chunk));
  }
  res.end();
});

클라이언트가 중간에 연결을 끊어도 서버 측 스트림은 계속 동작하고 있었다. AbortController를 사용하지 않아 OpenAI API 요청이 취소되지 않았고, 이벤트 리스너도 정리되지 않았다.

해결 방법

app.post('/api/analyze', async (req, res) => {
  const abortController = new AbortController();
  
  req.on('close', () => {
    abortController.abort();
  });

  try {
    const stream = await openai.chat.completions.create({
      model: 'gpt-4o',
      messages: req.body.messages,
      stream: true
    }, {
      signal: abortController.signal
    });

    for await (const chunk of stream) {
      if (abortController.signal.aborted) break;
      res.write(JSON.stringify(chunk));
    }
    res.end();
  } catch (error) {
    if (error.name === 'AbortError') {
      console.log('Request aborted by client');
    } else {
      throw error;
    }
  }
});

클라이언트 연결이 끊기면 즉시 OpenAI API 요청을 취소하도록 수정했다. 배포 후 메모리 사용량이 안정화됐고, 불필요한 API 비용도 절감됐다.

교훈

스트리밍 API를 다룰 때는 항상 클린업 로직을 함께 구현해야 한다. 특히 외부 API 호출이 포함된 경우 비용과 직결되므로 더욱 주의가 필요하다.