Dev

실시간 스트리밍 응답 LLM Chat 서비스

실시간 스트리밍 응답 LLM Chat 서비스
Photo by Bernd 📷 Dittrich / Unsplash

On this page

개요

LLM(대형 언어 모델)을 활용한 Chat 서비스를 만들면서, 기존 방식으로는 긴 응답 시간을 기다리는 것이 사용자 경험에 좋지 않은 영향을 미친다는 것을 깨달았습니다. 응답이 완전히 처리될 때까지 기다려야 하는 구조는 사용자에게 답변이 지연된 것처럼 느껴지게 했습니다. 이를 해결하기 위해 SSE(Server-Sent Events)를 이용해 실시간 스트리밍 응답 방식을 도입하여 사용자 경험을 크게 개선했습니다.

이번 글에서는 제가 어떻게 기존의 Chat 서비스를 스트리밍 기반으로 전환했는지, 그리고 그로 인해 사용자 인터페이스(UI)와 경험이 어떻게 개선되었는지를 공유하려 합니다.


문제점

기존의 API 통신 방식에서는 axios를 사용하여 Chat GPT API에 요청을 보내고, 서버로부터 응답이 전부 도착한 후에야 사용자에게 메시지를 전달했습니다. 이 방식은 큰 모델의 긴 응답 시간이 있을 때 사용자가 아무런 피드백 없이 기다리게 되어, 느리다는 인식을 주기 쉽습니다.


해결 방법: 스트리밍 기반의 처리

이 문제를 해결하기 위해 SSE(Server-Sent Events)를 활용하여 서버가 응답을 생성하는 대로 실시간으로 데이터를 전송하고, 클라이언트에서 이를 즉시 렌더링하는 방식을 도입했습니다.

SSE는 한 번의 HTTP 연결로 서버에서 발생하는 이벤트를 클라이언트에 실시간으로 전달할 수 있게 해주는 기술입니다. 이를 활용하면 서버의 응답을 스트리밍 형태로 받아서 사용자에게 실시간으로 출력할 수 있습니다.


변경된 코드 분석

1. index.tsx 변경사항

클라이언트 측에서 스트리밍 처리

index.tsx 파일에서는 기존에 axios로 요청을 보내고 전부 응답을 받은 후 처리하던 로직을 fetch로 대체하여 스트리밍 데이터를 처리할 수 있도록 변경했습니다.

const handleSendMessage = async (message: string) => {
  // Fetch를 사용하여 서버에 메시지 전송
  const response = await fetch('/api/chat', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ message }),
  })

  const reader = response.body?.getReader()
  if (!reader) throw new Error('No reader')

  // 비동기로 서버에서 오는 데이터를 스트림 처리
  let assistantMessage: Message = { role: 'assistant', content: '' }
  setCurrentChat(prev => ({
    ...prev!,
    messages: [...prev!.messages, assistantMessage],
  }))

  while (true) {
    const { done, value } = await reader.read()
    if (done) break

    const chunk = new TextDecoder().decode(value)
    const lines = chunk.split('\\n').filter(line => line.trim() !== '')

    for (const line of lines) {
      const message = line.replace(/^data: /, '')
      if (message === '[DONE]') {
        setIsLoading(false)
        return
      }
      try {
        const parsed = JSON.parse(message)
        assistantMessage.content += parsed.content
        setCurrentChat(prev => ({
          ...prev!,
          messages: [...prev!.messages.slice(0, -1), { ...assistantMessage }],
        }))
      } catch (error) {
        console.error('Error parsing SSE message:', error)
      }
    }
  }
}

주요 변경 사항:

  • axiosfetch: fetch는 스트리밍 응답을 쉽게 처리할 수 있기 때문에 사용했습니다.
  • ReadableStream 사용: 서버에서 오는 데이터를 스트리밍 방식으로 읽기 위해 ReadableStream을 사용했습니다.
  • 각 스트리밍 청크를 수신하고 즉시 메시지를 업데이트하여, 사용자가 실시간으로 응답을 받을 수 있도록 했습니다.

2. chat.ts 변경사항

서버 측에서 스트리밍 응답 구현

chat.ts 파일에서는 서버가 스트리밍으로 응답할 수 있도록 SSE 헤더를 설정하고, OpenAI API로부터 받은 스트리밍 데이터를 클라이언트에 전송하는 로직을 추가했습니다.

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache, no-transform',
    'Connection': 'keep-alive',
  })

  try {
    const response = await axios.post(
      '<https://api.openai.com/v1/chat/completions>',
      {
        // OpenAI API에 스트리밍 옵션 추가
        stream: true,
      },
      {
        headers: {
          'Authorization': `Bearer ${process.env.OPENAI_API_KEY}`,
        },
        responseType: 'stream',
      }
    )

    response.data.on('data', (chunk: Buffer) => {
      const lines = chunk.toString().split('\\n').filter(line => line.trim() !== '')
      for (const line of lines) {
        const message = line.replace(/^data: /, '')
        if (message === '[DONE]') {
          res.write(`data: [DONE]\\n\\n`)
          res.end()
          return
        }
        try {
          const parsed = JSON.parse(message)
          const content = parsed.choices[0].delta.content
          if (content) {
            res.write(`data: ${JSON.stringify({ content })}\\n\\n`)
          }
        } catch (error) {
          console.error('Error parsing SSE message:', error)
        }
      }
    })
  } catch (error) {
    res.write(`data: ${JSON.stringify({ error: 'Error processing your request' })}\\n\\n`)
    res.end()
  }
}

주요 변경 사항:

  • SSE 헤더 설정: Content-Type'text/event-stream'으로 설정하고, 캐시를 비활성화하는 등의 옵션을 추가했습니다.
  • OpenAI API의 스트리밍 처리: OpenAI API 요청 시 stream: true 옵션을 활성화하고, 스트리밍 응답을 받아서 클라이언트로 전송합니다.
  • 각 청크가 수신될 때마다 실시간으로 데이터를 파싱하여 클라이언트에 전송합니다.

스트리밍 방식의 장점

이렇게 스트리밍 방식을 적용한 후, 사용자 경험은 크게 개선되었습니다.

  • 빠른 피드백: AI가 답변을 생성하는 즉시 사용자에게 전달되므로, 사용자는 응답이 실시간으로 생성되는 과정을 볼 수 있습니다.
  • 응답 시간 단축 체감: 실제로 처리되는 총 응답 시간은 동일할 수 있지만, 사용자가 실시간으로 답변을 확인할 수 있기 때문에 기다리는 시간이 짧게 느껴집니다.
  • UX 향상: 사용자는 더 이상 아무런 피드백 없이 기다리지 않아도 되며, 즉시 결과를 확인할 수 있어 더욱 직관적이고 만족스러운 경험을 제공합니다.

결론

이번 스트리밍 기반의 LLM Chat 서비스 개선 작업을 통해 실시간 피드백의 중요성을 깨달았습니다. 사용자가 기다리는 동안 계속해서 피드백을 제공하는 방식은 단순한 성능 개선 이상의 사용자 경험(UX) 향상으로 이어질 수 있습니다.

SSE를 활용한 실시간 스트리밍 응답은 특히 대화형 AI 서비스와 같이 응답 시간이 중요한 서비스에 강력한 도구가 될 수 있습니다. 앞으로 더 많은 기능을 개선하고 최적화하며 더 나은 사용자 경험을 제공할 수 있도록 노력할 계획입니다.

Subscribe to Keun's Story newsletter and stay updated.

Don't miss anything. Get all the latest posts delivered straight to your inbox. It's free!
Great! Check your inbox and click the link to confirm your subscription.
Error! Please enter a valid email address!