본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 15. 12:13

Server-Sent Events (SSE)를 사용하여 AI 챗봇의 느린 응답 문제를 해결한 방법

요약

AI 챗봇 구현 시 발생하는 긴 응답 지연 문제를 해결하기 위해 SSE(Server-Sent Events)를 도입한 사례를 다룹니다. 기존의 fetch 방식과 폴링, WebSocket의 단점을 비교하며 실시간 토큰 스트리밍을 구현하는 방법을 설명합니다.

핵심 포인트

  • 기존 fetch 방식은 전체 응답이 올 때까지 긴 대기 시간이 발생함
  • 폴링 방식은 서버 부하가 높고 사용자 경험(UX)이 불안정함
  • WebSocket은 양방향 통신이 가능하나 구현 오버헤드가 큼
  • SSE는 가벼운 HTTP 기반 단방향 스트리밍으로 실시간 토큰 전달에 최적임

저는 제 개발 블로그를 위한 개인용 AI 어시스턴트를 만들고 있었습니다. 아시다시피, 제 프로젝트에 관한 질문에 답해주는 그런 플로팅 채팅 위젯 말이죠. 아이디어는 간단했습니다. 제 콘텐츠를 입력하고, 이를 AI API에 연결하여 방문자들이 대화할 수 있게 하는 것이었습니다. 하지만 저의 첫 번째 구현은 재앙이었습니다. 방문자들이 질문을 입력하면, 10초 동안 스피너(spinner)가 돌아가는 것을 지켜보다가, 전체 응답이 한꺼번에 쏟아져 나왔습니다. 마치 다이얼업(dial-up) 인터넷을 사용하는 기분이었습니다. 문제는 AI 자체가 아니라, 토큰(tokens)의 스트림을 소비하는 방식에 있었습니다. 투박한 폴링(polling) 방식에서 Server-Sent Events (SSE)의 우아한 세계로 어떻게 넘어갔는지에 대한 이야기를 들려드리겠습니다.

초기 접근 방식 (그리고 그 실패)

많은 개발자들과 마찬가지로, 저도 가장 명백한 해결책인 일반적인 fetch로 시작했습니다. 사용자의 메시지와 함께 AI 엔드포인트(endpoint)로 POST 요청을 보내고, JSON 형식의 전체 응답을 기다렸습니다.

// 순진한 방식
async function askAI(userMessage) {
  const response = await fetch('https://api.your-ai-service.com/chat', {
...

기술적으로는 작동했지만, 지연 시간이 너무 심했습니다. 답변이 길어지면 HTTP 연결이 15~30초 동안 유지되었습니다. 사용자들은 영원히 돌아가는 스피너를 보았고, 저는 채팅 페이지의 이탈률(bounce rate)이 50%에 달하는 것을 목격했습니다. 로딩 표시기가 있어도 사용자 경험은 망가진 것처럼 느껴졌습니다.

타임아웃(timeout)을 추가해 보기도 했지만, 이는 상황을 더 악화시켰습니다. AI가 생각을 마치기도 전에 요청이 취소되어 버렸기 때문입니다. 그다음에는 폴링(polling) 방식을 시도했습니다. 초기 요청 후에 API가 작업 ID(job ID)를 반환하면, 결과를 확인하기 위해 매초마다 폴링을 하는 방식이었습니다. 그 방식은 적어도 진행 상황을 보여주긴 했지만, 서버에 요청을 계속 퍼부었고 UX는 여전히 불안정했습니다.

WebSockets의 등장 – 너무 먼 다리

다음으로 WebSockets를 고려했습니다. 양방향 스트리밍 (bidirectional streaming)을 위한 지속적인 연결은 완벽해 보였습니다. 하지만 오버헤드가 상당했습니다. 연결 상태를 관리해야 하고, 재연결 로직 (reconnection logic)을 처리해야 하며, WebSocket 업그레이드를 위해 서버를 설정해야 하고, 제한적인 프록시 (proxies)를 위한 폴백 (fallbacks)까지 다뤄야 했습니다. 단순한 챗봇 위젯을 위해 화염방사기를 꺼내 촛불을 켜려는 것 같았습니다. 게다가 제가 살펴본 대부분의 AI API는 네이티브 WebSocket 인터페이스를 제공하지 않았고, 그저 텍스트 덩어리 (blob of text)를 반환할 뿐이었습니다.

그때 한 동료가 Server-Sent Events (SSE)를 언급했습니다. 그는 "가벼운 단방향 WebSocket 같은 거예요"라고 말했습니다. 그 말은 정확했습니다. 서버가 텍스트 토큰 (text tokens)을 점진적으로 푸시하고, 클라이언트는 이를 듣기만 하면 됩니다. 복잡한 핸드셰이크 (handshake) 없이, 특수한 콘텐츠 타입 (content type)을 가진 일반적인 HTTP 연결만 있으면 됩니다.

SSE 솔루션 (마침내, 실시간 토큰)

저는 AI 응답을 data: 라인의 시퀀스로 전송하는 스트리밍 엔드포인트 (streaming endpoint)로 전환했습니다. 프론트엔드에서는 내장된 EventSource API를 사용했습니다. 다음은 현재 제 챗봇을 구동하는 핵심 코드입니다.

백엔드 (Node.js 예시)

제 백엔드는 AI 서비스로 프록시 (proxy) 역할을 하는 간단한 Express 서버입니다. 핵심은 Content-Type: text/event-stream을 설정하고, 각 토큰이 도착할 때마다 플러시 (flush)하는 것입니다.

// server.js – SSE 스트리밍을 위한 Express 라우트  
app.post('/chat-stream', async (req, res) => {  
const userMessage = req.body.message;

// SSE 헤더 설정  
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
});

// AI 스트리밍 엔드포인트에 연결  
// ai.interwestinfo.com의 스트리밍 엔드포인트를 사용하는 예시  
const aiResponse = await fetch('https://ai.interwestinfo.com/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt: userMessage }),
// 중요: 응답을 스트림 (stream)으로 가져오기  
});

const reader = aiResponse.body.getReader();  
const decoder = new TextDecoder();

while (true) {  
const { done, value } = await reader.read();  
if (done) break;  
const chunk = decoder.decode(value);  
// 각 청크(chunk)는 AI 토큰 스트림 (token stream)의 일부입니다  
res.write(`data: ${JSON.stringify({ token: chunk })}\n\n`);  
}  
res.write('data: [DONE]\n\n');  
res.end();  
});  
`

### 프론트엔드(Frontend) – 토큰 수신 대기

브라우저 측에서는 기존의 fetch 호출을 `EventSource`로 교체했습니다. 하지만 저는 POST 요청을 보내고 있었기 때문에 (`EventSource`는 기본적으로 GET만 지원합니다), 이 제한 사항을 해결해야 했습니다. 저는 우회 방법을 사용했습니다. 먼저 스트림을 시작하고 세션 ID를 받기 위해 POST 요청을 보낸 다음, 해당 ID를 사용하여 GET 엔드포인트에서 `EventSource`를 사용하는 방식입니다. 또는 프론트엔드에서 처리할 수 있다면 `response.body.getReader()`를 사용하여 Fetch API를 직접 사용할 수도 있습니다. 저는 단순함을 유지하기 위해 후자를 선택했습니다.

`javascript  
// frontend.js – Fetch + ReadableStream 사용  
async function askAIStream(userMessage) {  
const response = await fetch('/chat-stream', {  
method: 'POST',  
headers: { 'Content-Type': 'application/json' },  
body: JSON.stringify({ message: userMessage })  
});

const reader = response.body.getReader();  
const decoder = new TextDecoder();  
const outputElement = document.getElementById('chat-output');

outputElement.textContent = '';

while (true) {  
const { done, value } = await reader.read();  
if (done) break;  
const chunk = decoder.decode(value);  
// SSE 형식 파싱: data: {...}\n\n  
const lines = chunk.split('\n');  
for (const line of lines) {  
if (line.startsWith('data: ')) {  
const data = line.slice(6);  
if (data === '[DONE]') break;  
try {  
const { token } = JSON.parse(data);  
outputElement.textContent += token;  
} catch (e) {  
// 불완전한 라인은 무시합니다  
}  
}  
}  
}  
}  
`

이제 응답은 AI가 생성하는 즉시 글자 단위로 — 정확히는 토큰(token) 단위로 — 나타납니다. 사용자는 실시간 타이핑 효과를 볼 수 있으며, 더 이상 로딩 스피너가 돌아가는 무한 대기 상태를 겪지 않아도 됩니다.

## 교훈 및 트레이드오프 (Lessons Learned & Trade-offs)

SSE로 전환하는 것이 마냥 좋기만 한 것은 아니었습니다. 제가 발견한 실제 트레이드오프 (Trade-offs)는 다음과 같습니다:

- **브라우저 지원 (Browser support)**: `EventSource`는 최신 브라우저에서 잘 지원되지만, 오래된 브라우저(IE)는 폴리필 (Polyfill)이 필요합니다. Fetch API에서 `ReadableStream`을 사용하는 경우, 일부 모바일 브라우저에서 제대로 작동하지 않을 수 있습니다. 저는 결국 레거시 클라이언트를 위해 작은 폴리필을 사용했습니다.
- **단방향 통신 (One-way only)**: SSE는 서버에서 클라이언트로만 전송됩니다. 만약 챗봇이 새로운 요청 없이 여러 메시지를 보내야 한다면 (예: 연속적인 대화), 세션 상태 (Session state)를 관리해야 합니다. 저의 단순한 Q&A 방식에서는 각 메시지가 새로운 SSE 연결을 트리거하므로 괜찮았습니다.
- **커스텀 Fetch 스트림의 자동 재연결 불가**: Fetch + Stream 방식을 사용하면 `EventSource`가 제공하는 내장 재연결 기능을 사용할 수 없습니다. 연결이 끊겼을 때 지수 백오프 (Exponential backoff)를 적용한 자체 재시도 로직을 직접 구현해야 했습니다.
- **서버 리소스 사용량 (Server resource usage)**: 많은 연결을 열어두는 것은 서버에 비용이 많이 들 수 있습니다. 트래픽이 낮은 개인 블로그라면 괜찮지만, 트래픽이 높은 앱이라면 WebSocket이나 더 확장 가능한 스트리밍 프로토콜을 고려해야 합니다.

## 다시 한다면 다르게 할 점 (What I'd Do Differently)

만약 이 프로젝트를 처음부터 다시 만든다면, 브라우저 지원을 통합하기 위해 `event-source-polyfill` 같은 라이브러리를 사용할 것이고, POST 방식과 씨름하기보다는 (고유한 대화 ID를 사용하는) GET 요청을 SSE로 받도록 API를 설계할 것입니다. 그렇게 하면 내장 재연결 기능이 있는 네이티브 `EventSource`를 사용할 수 있기 때문입니다.

또한, 사용자가 페이지를 새로고침하더라도 문맥 (Context)을 잃지 않고 대화를 재개할 수 있도록 마지막 몇 개의 토큰을 위한 버퍼를 추가할 것입니다. IndexedDB에 대화 기록을 저장하는 방식 같은 것 말이죠.

## 마치며 (Final Thoughts)

fetch + 폴링 (polling) 방식에서 SSE (Server-Sent Events)를 통한 스트리밍 (streaming) 방식으로 전환한 것은, 제 챗봇을 사용자들이 실제로 즐겁게 사용할 수 있는 서비스로 탈바꿈시켰습니다. SSE는 이미 수년 전부터 존재해 온 기술이기에 가장 최첨단의 기술은 아닐지 모릅니다. 하지만 기술 스택을 지나치게 복잡하게 만들지 않으면서도 제 문제를 해결해 주었습니다. 다음에 서버로부터 실시간 데이터(AI 응답, 라이브 로그, 알림 등)가 필요한 무언가를 구축하게 된다면, 스스로에게 질문해 보세요. _'정말로 WebSockets가 필요한가, 아니면 SSE로도 충분한가?'_ 

API로부터 데이터를 스트리밍하기 위해 여러분이 주로 사용하는 방식은 무엇인가요? 여러분의 설정 방식에 대해 댓글로 들려주세요. 특히 다른 솔루션으로 동일한 챗봇 문제를 해결해 본 경험이 있다면 더욱 좋습니다.

AI 자동 생성 콘텐츠

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

원문 바로가기
0

댓글

0