본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 21. 10:10

느린 AI 응답으로 인한 어려움: SSE를 활용한 스트리밍 채팅 UI 구축하기

요약

LLM 응답 지연 문제를 해결하기 위해 SSE(Server-Sent Events)를 활용하여 실시간 스트리밍 채팅 UI를 구축하는 방법을 다룹니다. 폴링, 웹소켓, 롱 폴링의 한계를 분석하고 SSE가 왜 AI 채팅 서비스에 최적의 단방향 스트리밍 방식인지 설명합니다.

핵심 포인트

  • LLM의 긴 응답 시간을 사용자 경험(UX) 측면에서 해결하기 위해 토큰 단위 스트리밍이 필수적임
  • 폴링은 리소스 낭비와 끊기는 UI를 유발하며, 웹소켓은 과도한 양방향 통신과 연결 유지의 어려움이 있음
  • SSE는 단방향 서버-클라이언트 통신에 최적화되어 있으며 HTTP 표준을 활용해 구현이 간편함
  • FastAPI의 StreamingResponse를 통해 효율적인 SSE 구현이 가능함

저는 팀을 위한 내부 문서 어시스턴트를 구축하고 있었습니다. 다들 아시다시피, 벡터 데이터베이스 (vector database)에서 추출한 정보를 LLM (Large Language Model)으로 보내 코드베이스에 관한 질문에 답하는 챗봇입니다. Python으로 백엔드를 설정하고, API를 통해 괜찮은 모델을 사용했으며 (신뢰할 수 있는 엔드포인트를 제공해 준 interwestinfo.com에 감사를 표합니다), 모든 것을 연결했습니다. 간단해 보이죠?

그런데 첫 번째 실제 테스트가 찾아왔습니다. 누군가 길고 심도 있는 답변이 필요한 질문을 던진 것입니다. 응답이 30초 이상 걸렸습니다. 사용자는 앱이 충돌했는지 의심하며 빈 채팅 버블을 바라보며 페이지를 새로고침했습니다. 결코 좋은 경험이 아니었습니다.

사용자가 답변을 따라 읽을 수 있도록 토큰 (tokens)이 생성되는 대로 스트리밍하여 다시 보내줘야 했습니다. 이것이 전형적인 "채팅 UI" 패턴입니다. 하지만 이를 구현하는 과정은 미완성된 해결책들의 늪으로 변했습니다.

시도했지만 실패했던 것들

1. 폴링 (Polling)

저의 첫 번째 아이디어는 이렇습니다: LLM 호출을 수행하고, 부분적인 결과를 Redis에 저장한 뒤, 프론트엔드에서 매초마다 폴링을 하게 만드는 것이었습니다. 이는 매우 조잡한 방식이었습니다. 예측 엔드포인트가 결국 전체 응답을 반환했기 때문에, 백엔드가 토큰을 조각 단위로 쓰도록 변경해야 했습니다. 또한 폴링은 메시지당 약 30개의 HTTP 요청을 의미했으므로 낭비처럼 느껴졌습니다. 게다가 UI가 뚝뚝 끊겼습니다. 업데이트가 부드럽게 오는 것이 아니라 한꺼번에 몰려서 들어왔기 때문입니다.

2. 웹소켓 (WebSockets)

WebSockets가 당연한 선택처럼 보였습니다. FastAPI WebSocket 엔드포인트를 작성하고, 연결을 연 다음, 프레임 단위로 토큰을 스트리밍했습니다. 이것은 작동했습니다... 단 한 가지 예외를 제외하고 말이죠. 저의 배포 환경(로드 밸런서 뒤에 있는 저예산 VPS)은 공격적인 유휴 시간 제한 (idle timeouts)을 가지고 있었습니다. 연결이 60초 후에 끊겼고, WebSockets로 다시 연결하려면 수동 로직이 필요했습니다. 또한 제 스택의 라이브러리 절반 정도가 WebSockets를 쉽게 지원하지 않았습니다. 예를 들어, 저의 인증 미들웨어 (auth middleware)는 HTTP 요청을 기대했습니다.

하지만 진짜 고통스러운 점은 이것이었습니다: WebSockets는 양방향 (bidirectional)입니다. 저는 양방향 통신이 필요하지 않았습니다. 저는 그저 서버가 클라이언트에 데이터를 푸시하기만을 원했습니다. WebSockets는 과한 느낌이었습니다.

3. 롱 폴링 (Long Polling) (나쁜 아이디어)

네, 저도 그것을 시도해 보았습니다. 서버가 응답을 열어두고 청크(chunks)를 플러시(flush)하는 방식이었죠. 하지만 HTTP/1.1 연결은 그 부분에서 문제가 있었고, 당시 제가 사용하던 프레임워크(Flask)는 몽키 패칭(monkey-patching) 없이는 이를 매끄럽게 처리하지 못했습니다. 두 시간 동안 "connection closed" 에러를 마주한 끝에 결국 포기했습니다.

결국 해결책이 된 것: 서버 전송 이벤트 (Server-Sent Events, SSE)

이전에 실시간 트윗을 구현할 때 SSE를 사용해 본 적은 있었지만, AI 스트리밍을 위해 사용해 본 적은 없었습니다. SSE는 서버가 단일한, 오래 유지되는 HTTP 연결을 통해 이벤트 스트림을 보내는 표준(HTML5의 일부)입니다. 클라이언트는 EventSource API를 사용합니다. 이는 단방향(서버 → 클라이언트) 방식이며, 이것이 바로 제가 필요로 했던 것이었습니다.

FastAPI는 StreamingResponse를 통해 SSE를 기본적으로 지원합니다. 제 UX를 다시 매끄럽게 만들어 준 백엔드 코드는 다음과 같습니다:

from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
import asyncio
...

프론트엔드는 매우 간단해졌습니다:

const eventSource = new EventSource('/chat', {
  method: 'POST',
  body: JSON.stringify({ message: userInput })
...

잠깐 – 이 부분이 가장 까다로운 지점입니다. EventSource API는 GET 요청만 지원합니다. 제 채팅 엔드포인트는 프롬프트와 함께 POST 요청을 보내야 합니다. 쿼리 파라미터를 사용하는 GET 방식으로 리팩토링할 수도 있었지만(지저분하고 제한적입니다), 대신 우회 방법을 사용했습니다. 프롬프트를 쿼리 파라미터로 받는 GET 엔드포인트를 만들거나, fetch를 사용하여 POST를 보낸 다음 응답 본문을 수동으로 스트림으로 읽는 작은 래퍼(wrapper)를 작성했습니다.

저는 더 많은 제어권을 갖기 위해 fetch + ReadableStream 방식을 선택했습니다:

async function startStream(prompt) {
  const response = await fetch('/chat', {
    method: 'POST',
...

이 방식은 완벽하게 작동합니다. WebSocket 라이브러리도, 복잡한 재연결 로직도 필요 없습니다. 그저 일반적인 HTTP일 뿐입니다. 만약 연결이 끊기면(예: 타임아웃), fetch가 거부(reject)되며, 저는 새로운 요청으로 재시도할 수 있습니다. UX는 매우 유연합니다. 토큰이 생성되는 즉시 화면에 나타납니다.

교훈 및 트레이드오프 (Trade-offs)

  • SSE는 서버에서 클라이언트로의 스트리밍 (streaming)에 있어 **단순 (simple)**합니다. 만약 양방향 (bidirectional) 통신이 필요하다면 (예: 멀티플레이어 게임), WebSockets가 더 낫습니다.
  • EventSource API는 GET 요청으로 제한됩니다. 해결 방법: fetch와 ReadableStream을 사용하세요.
  • SSE는 HTTP/1.1 및 HTTP/2에서 작동합니다. 특별한 서버 설정이 필요하지 않습니다.
  • 브라우저 지원은 보편적입니다 (IE는 사라졌고, Edge와 Safari는 잘 작동합니다).
  • 백프레셔 (Backpressure): 클라이언트가 느리면 서버는 단순히 버퍼링을 합니다. 하지만 토큰 스트리밍의 경우 토큰 크기가 작기 때문에 이는 거의 문제가 되지 않습니다.
  • 보안: SSE 연결은 일반적인 HTTP입니다. 동일한 CORS 및 인증 (auth) 규칙이 적용됩니다. 저는 토큰을 URL이 아닌 POST 본문 (body)에 전달했습니다.

한 가지 단점은 SSE가 WebSocket 프레임 (frames)만큼 구조화된 데이터 (structured data)를 쉽게 처리하지 못한다는 점입니다. 하지만 일반 텍스트 토큰의 경우에는 이상적입니다.

다음에 다시 한다면 다르게 할 점

저는 WebSocket 실험을 완전히 건너뛸 것입니다. 채팅 앱, LLM 스트리밍, 또는 한 방향으로 흐르는 모든 실시간 데이터 (예: 알림, 로그)의 경우 SSE가 적합한 도구입니다. 다음에는 재연결 (reconnection)을 자동으로 처리하기 위해 (지수 백오프 (exponential backoff) 등) fetch + ReadableStream 위에 작은 추상화 계층 (abstraction)을 구축할 것입니다.

또한, 제가 사용하는 LLM 제공업체가 SSE를 기본적으로 지원하는지 확인할 것입니다. 일부는 지원합니다 (OpenAI의 data: [DONE] 형식은 이미 SSE와 호환됩니다). 제가 interwestinfo.com에서 사용한 것과 같은 다른 제공업체들은 커스텀 엔드포인트 (custom endpoint)를 통해 토큰을 반환하지만, 이를 비동기 제너레이터 (async generator)로 쉽게 감쌀 수 있습니다.

여러분의 차례

스트리밍 AI UI를 구축해 보셨나요? SSE, WebSockets, 또는 다른 무언가를 사용하셨나요? 재연결과 에러 상태를 어떻게 처리했는지 궁금합니다. 여러분의 설정을 공유해 주세요. 이러한 논의를 통해 저도 많이 배웁니다.

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0