AI 지연 시간 제어하기: Server-Sent Events (SSE)를 이용한 스트리밍 응답
요약
AI 기반 자동 완성 기능의 지연 시간 문제를 해결하기 위해 Server-Sent Events(SSE)를 활용한 스트리밍 응답 구현 방법을 소개합니다. 기존의 폴링이나 웹소켓 방식의 한계를 분석하고, SSE를 통해 사용자에게 실시간으로 청크 단위의 데이터를 전달하는 최적의 방안을 다룹니다.
핵심 포인트
- 전체 응답을 기다리는 동기식 방식은 사용자 경험(UX)을 저해함
- SSE는 단방향 텍스트 스트리밍에 최적화된 가벼운 표준임
- WebSockets 대비 구현이 단순하며 자동 재연결을 지원함
- fetch API와 Readable Stream을 통해 세밀한 데이터 파싱 가능
나는 내가 만든 AI 기반 자동 완성 기능이 자랑스러웠다. 그러다 사용자들이 지연 시간(lag)에 대해 불평하기 시작했다.
모든 키 입력마다 AI 모델에 대한 요청이 발생했고, 전체 JSON 응답을 받기 위해 2~3초를 기다려야 하는 상황은 UI가 고장 난 것처럼 느껴지게 만들었다. 디바운싱(debouncing), 캐싱(caching), 심지어 스피너(spinner)를 보여주는 것까지 시도해 보았지만, 핵심적인 문제는 해결되지 않았다. 사용자가 무엇인가를 보기 위해서는 전체 답변이 나올 때까지 기다려야만 했다.
결국 나는 Server-Sent Events (SSE)를 사용하여 AI 응답을 청크(chunk) 단위로 스트리밍함으로써 이 문제를 해결했다.
문제점: 체감 성능이 전부다
나는 대규모 언어 모델(Large Language Model, LLM)을 사용하여 의미론적 제안을 제공하는 검색창을 만들고 있었다. 모델 자체는 빨랐지만, 전체 흐름이 동기식(synchronous)이었다:
- 사용자가 몇 글자를 입력함
- 300ms 디바운스 (debounce)
- 부분 쿼리가 포함된 HTTP POST 요청
- 서버가 AI API 호출 (1~2초)
- 서버가 전체 응답을 반환
- 클라이언트가 렌더링
4단계와 5단계를 합치면 사용자는 1~2초 동안 아무것도 볼 수 없다는 뜻이었다. 로딩 스피너가 있어도 느리게 느껴졌다.
낙관적 UI (Optimistic UI, 캐시된 결과 보여주기)를 시도해 보았지만, 이는 모델에게 질문하는 목적 자체를 무색하게 만들었다. 병렬 요청(parallel requests)도 시도해 보았지만, 병목 현상은 AI API 자체에 있었다.
결과가 도착하는 대로 부분적인 결과를 보여줄 방법이 필요했다.
처음에 시도했던 것들 (그리고 왜 실패했는가)
폴링 (Polling)
서버가 작업 ID(job ID)를 반환하게 한 뒤, 결과를 위해 폴링하는 방식을 고려했다. 하지만 폴링은 불필요한 HTTP 오버헤드를 추가하며, 지연 시간과 서버 부하 사이의 트레이드오프(tradeoff)를 발생시킨다.
웹소켓 (WebSockets)
WebSockets도 작동하지만, 단순한 단방향 스트림을 구현하기에는 무겁다. 연결 상태 관리, 재연결 처리를 해야 하며 양쪽 모두에서 라이브러리를 사용해야 한다.
fetch를 이용한 청크 HTTP 응답 (Chunked HTTP response)
fetch()로부터 Readable Stream을 읽을 수 있다. 이것이 내가 결국 사용하게 된 방식이다. 하지만 제대로 파싱(parse)하지 않으면 부분적인 데이터를 놓치게 된다.
마침내 해결책이 된 것: Server-Sent Events (SSE)
SSE는 서버가 단일한 장기 유지 HTTP 연결(long-lived HTTP connection)을 통해 텍스트 이벤트(text events)를 전송하는 표준입니다. 클라이언트는 EventSource API를 사용하거나, 더 세밀한 제어를 위해 수동 파싱(parsing)이 포함된 fetch를 사용합니다.
AI 스트리밍에 SSE가 완벽한 이유는 다음과 같습니다:
- 단방향임 (서버 → 클라이언트)
- 텍스트 기반임 (JSON 청크(chunks)를 보내기 쉬움)
- 연결이 끊어지면 자동으로 재연결됨 (
EventSource사용 시) - 서버에 추가 라이브러리가 필요 없음 — 헤더(headers)만 설정하면 됨
제가 이를 어떻게 구현했는지 소개합니다.
서버 측: Express를 사용한 Node.js
// server.js
import express from 'express';
import fetch from 'node-fetch';
...
참고: 정확한 파싱(parsing) 방식은 사용하는 AI 제공업체에 따라 다릅니다. 어떤 곳은 토큰(tokens)을 줄(lines) 단위로 보내고, 어떤 곳은 JSON으로 보냅니다. 버퍼링(buffering)과 분할(splitting)이 필요할 수도 있습니다.
클라이언트 측: Fetch API 사용 (EventSource보다 더 세밀한 제어 가능)
// client.js
async function getSuggestions(query) {
const response = await fetch(`/stream-suggestions?q=${encodeURIComponent(query)}`);
...
중요: 클라이언트는 불완전한 JSON을 유연하게 처리해야 합니다. 실제로 AI는 한 번에 하나의 토큰을 보낼 수 있으므로, 토큰을 축적한 다음 완전한 객체(objects)로 파싱해야 합니다. 저는 간단한 상태 머신(state machine)을 사용했습니다: 토큰을 수집하고, 한 줄이 완성되면 파싱하는 방식입니다.
UX가 어떻게 변했는가
스트리밍을 통해 첫 단어가 300ms 이내에 나타났습니다. 사용자는 제안 내용이 글자 하나하나씩 생성되는 것을 볼 수 있었습니다. 체감 지연 시간(perceived latency)이 사라졌습니다. 사용자는 시스템이 자신을 기다리게 만드는 것이 아니라, 함께 생각하고 있다고 느꼈습니다.
자동 완성(autocomplete)은 실시간 경험이 되었습니다. 지표도 개선되었습니다:
- 첫 시각적 응답 시간(Time to first visual response): 2.1s → 0.3s
- 사용자 참여도 (세션당 키 입력 횟수): 40% 증가
- 불만 사항 소멸
트레이드오프(Trade-offs) 및 한계
복잡성
SSE는 몇 가지 움직이는 요소들을 추가합니다. 연결 끊김, 재연결, 그리고 버퍼 파싱(buffer parsing)을 처리해야 합니다. 브라우저의 EventSource는 재연결을 자동으로 수행해주지만, GET 요청만 가능합니다 (커스텀 헤더를 사용할 수 없음). 이것이 제가 리더(reader)와 함께 fetch를 사용한 이유입니다.
백프레셔 (Backpressure)
AI가 클라이언트가 렌더링할 수 있는 속도보다 빠르면 이벤트가 큐(queue)에 쌓이게 됩니다. 제 경우에는 DOM 업데이트 비용이 저렴했기 때문에 문제가 되지 않았습니다. 하지만 무거운 렌더링이 필요한 경우에는 이벤트를 배치(batching) 처리하는 것을 고려해 보세요.
브라우저 지원 (Browser support)
SSE는 잘 지원됩니다 (IE11은 이제 사라졌으니까요). 하지만 response.body.getReader()를 사용하려면 최신 브라우저가 필요합니다. 오래된 브라우저의 경우, 폴링(polling) 방식으로 전환하거나 폴리필(polyfill)을 사용하세요.
스트리밍을 사용하지 말아야 할 때
- AI 응답이 항상 짧다면 (< 100ms), SSE의 오버헤드를 감수할 가치가 없습니다.
- 양방향 통신이 필요한 경우 (예: 스트림 중간에 클라이언트가 데이터를 전송해야 하는 경우), WebSockets를 사용하세요.
- 백엔드에서 스트리밍을 지원할 수 없다면 (예: 콜드 스타트가 발생하는 서버리스 함수), 다른 아키텍처가 필요합니다.
다음에 제가 다르게 시도할 점
저는 fetch를 기반으로 EventSource와 유사한 API를 제공하는 @microsoft/fetch-event-source와 같은 라이브러리를 사용할 것입니다. 이 라이브러리는 재연결(reconnection), 백오프(backoff), 파싱(parsing)을 자동으로 처리합니다. 하지만 학습 목적이라면 가공되지 않은(raw) fetch만으로도 충분합니다.
또한, 서버가 가공되지 않은 토큰 청크(token chunks) 대신 진행 상황 이벤트(예: data: {"partial": "hello"})를 방출하도록 설계하겠습니다. 그렇게 하면 클라이언트가 구조를 추측할 필요가 없습니다.
배운 점
스트리밍은 단순히 속도에 관한 것이 아니라, **인식 (perception)**에 관한 것입니다. 느리더라도 점진적으로 보여주는 UI가 빠르지만 정적인 UI보다 언제나 승리합니다. 사용자들은 전체 응답이 올 때까지 기다리는 것보다 답변이 단어 하나하나 나타나는 것을 보는 것을 더 선호합니다.
SSE는 무거운 인프라 없이도 저에게 그러한 제어권을 주었습니다. 다음에 느리게 느껴지는 AI 기능을 구축하게 된다면, 스트리밍을 가장 먼저 고려해 보세요.
AI 지연 시간(latency)을 처리하는 여러분만의 방식은 무엇인가요? 스트리밍을 시도해 보셨나요, 아니면 여전히 전체 응답에 의존하시나요? 댓글을 통해 여러분의 경험을 들려주세요.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기