Next.js에서 AI 응답 스트리밍하기: SSE, Fetch Streams, 그리고 프로덕션 환경에서 발생하는 문제들
요약
Next.js App Router 환경에서 AI 응답을 스트리밍할 때 발생하는 기술적 도전 과제와 해결 방법을 다룹니다. SSE, Fetch Streams 활용법부터 프로덕션 환경의 버퍼링 문제, AbortSignal을 통한 비용 절감 전략을 설명합니다.
핵심 포인트
- Next.js App Router에서 AI SDK를 활용한 SSE 기반 스트리밍 구현 방식
- 프록시 및 압축 미들웨어로 인한 스트림 버퍼링 문제와 해결 설정
- AbortSignal을 사용하여 사용자 이탈 시 모델 호출을 중단하고 비용 절감
- fetch API와 ReadableStream을 이용한 클라이언트 측 스트림 소비
헤드라인: Next.js App Router에서 AI 응답을 스트리밍하려면 스트리밍 Response를 반환하는 하나의 Route Handler와 하나의 클라이언트 훅이 필요합니다. AI SDK의 streamText와 useChat이 와이어 프로토콜 (wire protocol)을 처리합니다. 진짜 어려운 부분은 그 주변 코드에 있습니다. 스트림을 하나의 블롭 (blob)으로 버퍼링하는 프록시, 토큰 청크 (token chunks)를 삼켜버리는 압축, 진행 중인 생성을 중단시키는 새로고침, 그리고 아무도 읽지 않는 토큰에 대해 계속 비용을 지불할지 여부를 결정하는 중단 (abort) 배선 등이 그것입니다.
핵심 요약 (Key takeaways)
- Next.js App Router에서의 스트리밍은 스트리밍된 Response를 반환하는 Route Handler입니다. AI SDK의
streamText와toUIMessageStreamResponse()는 SSE 기반의 메시지 스트림을 방출하며,@ai-sdk/react의useChat훅이 이를 소비합니다. - 채팅 UI는 브라우저의
EventSourceAPI를 사용하지 않습니다.EventSource는 바디 (body)가 없는 GET 요청만 지원하므로, AI 채팅 클라이언트는fetch를 통해 POST 요청을 보내고ReadableStream리더를 통해 SSE 형식의 응답을 읽습니다. - 로컬에서는 작동하지만 프로덕션 환경에서 한꺼번에 도착하는 스트림은 거의 항상 중간 버퍼링 (intermediary buffering) 때문입니다 — 압축 미들웨어 (compression middleware), nginx의
proxy_buffering, 또는 기업용 프록시가 원인입니다.Cache-Control: no-transform과X-Accel-Buffering: no설정이 대부분의 사례를 해결합니다. - 요청의
AbortSignal을streamText에 전달하면 탭이 닫힐 때 업스트림 모델 호출이 취소되어 즉시 토큰 소모를 중단할 수 있습니다. - 페이지 새로고침은 기본 fetch 스트림을 중단시킵니다. 서버의
onFinish에서 완료된 메시지를 유지(persist)하세요. 생성 중간의 연속성이 실제 제품 요구 사항인 경우에만 재개 가능한 스트림 (resumable streams)을 고려하십시오.
지난 1년 동안 제가 출시한 모든 AI 기능 — 채팅 어시스턴트, 요약기, 보고서 생성기 — 은 출력을 토큰 단위로 스트리밍합니다. 텍스트가 즉시 나타나면 사용자들은 긴 생성 과정을 기다려 주지만, 아무 반응 없는 스피너 (spinner)를 보고는 훨씬 더 빨리 이탈합니다. 스트리밍 코드 자체는 짧아졌습니다. 그 주변의 실패 모드 (failure modes)들이 제 메모를 채우고 있습니다.
Next.js에서 AI 응답을 스트리밍하려면 무엇이 필요한가?
하나의 Route Handler와 하나의 훅(hook)이 필요합니다. 서버에서는 AI SDK(언어 모델 호출을 위한 Vercel의 TypeScript 툴킷)의 streamText가 모델 호출을 시작하고 즉시 반환하며, toUIMessageStreamResponse()는 그 결과를 SDK의 SSE 기반 UI 메시지 스트림 프로토콜을 따르는 스트리밍 Response로 변환합니다. 클라이언트에서는 @ai-sdk/react의 useChat이 대화 내용을 POST하고, 스트림을 파싱하며, 데이터가 도착함에 따라 다시 렌더링(re-render)합니다.
// app/api/chat/route.ts
import { streamText, convertToModelMessages, type UIMessage } from "ai";
...
"use client";
import { useChat } from "@ai-sdk/react";
...
서버리스(serverless) 환경에서는 두 가지 세부 사항이 중요합니다. 함수가 생성(generation)이 완료될 때까지 유지되어야 하므로, 저는 라우트에서 maxDuration을 내보냅니다(export). Vercel의 Fluid Compute는 응답을 스트리밍하며 기본 실행 제한 시간은 300초입니다. 그리고 모델을 AI Gateway의 provider/model 문자열로 전달하는데, 이렇게 하면 제공자(provider)를 변경할 때 의존성(dependency)을 교체하는 대신 코드 한 줄만 수정하면 됩니다.
SSE, WebSockets, 또는 일반 fetch 스트리밍 중 무엇을 사용해야 할까?
서버에서 클라이언트로 토큰을 스트리밍할 때는 fetch를 통해 읽는 SSE 형식이 올바른 기본값이며, 이것이 AI SDK가 구현하고 있는 방식입니다. Server-Sent Events (SSE)는 서버가 하나의 긴 수명을 가진 응답에 data: 라인을 작성하는 일반 HTTP 와이어 포맷(wire format)입니다. WebSockets는 음성 대화, 멀티플레이어 커서, 협업 편집과 같이 트래픽이 진정으로 양방향(bidirectional)일 때만 그 가치를 발휘합니다. 채팅창의 경우에는 별도의 이득 없이 연결 상태 관리와 인프라 복잡성만 추가할 뿐입니다.
제가 이해하는 데 시간이 좀 걸렸던 미묘한 지점은 다음과 같습니다: 브라우저의 내장 EventSource API는 채팅 앱에서 사용하는 방식이 아닙니다. EventSource는 GET 요청만 보낼 수 있고 요청 본문(request body)이나 커스텀 헤더(custom headers)를 첨부할 수 없는 반면, 채팅 요청은 대화 기록을 POST로 보내야 합니다. 따라서 AI SDK는 일반적인 fetch POST를 보내고, ReadableStream 리더를 통해 응답 본문에서 SSE 형식을 파싱합니다. 이를 통해 EventSource의 제한 사항 없이 SSE 와이어 포맷(wire format)을 사용할 수 있지만, 대신 자동 재연결(automatic reconnection) 기능은 포기해야 합니다. 이는 나중에 설명할 재개 가능한 스트림(resumable streams)이 메워주는 바로 그 간극입니다.
| 고려 사항 | fetch + SSE 형식 | EventSource | WebSockets |
|---|---|---|---|
| 방향 | 서버 → 클라이언트 | 서버 → 클라이언트 | 양방향 (Bidirectional) |
| ... |
왜 로컬에서는 스트리밍이 잘 되는데 프로덕션에서는 한꺼번에 도착하나요?
함수와 브라우저 사이의 무언가가 버퍼링(buffering)을 하고 있기 때문입니다. 가장 흔한 원인은 압축 미들웨어(Compression middleware)입니다. gzip과 brotli는 압축을 위해 완전한 청크(chunks)를 원하므로, 작은 토큰 델타(token deltas)들은 응답이 끝날 때까지 압축기의 버퍼에 머물러 있다가 스트림이 하나의 블록으로 한꺼번에 도착하게 됩니다. 리버스 프록시(Reverse proxies)가 두 번째 원인입니다. nginx의 proxy_buffering은 기본적으로 업스트림(upstream) 응답 전체를 수집합니다. 기업용 프록시나 일부 백신 프로그램도 동일하게 동작하며, 이는 전적으로 사용자의 통제 범위를 벗어난 영역입니다.
// 버퍼링에 취약한 헤더를 가진 로우(raw) 스트리밍 Response
return new Response(stream, {
headers: {
...
Vercel에서는 스트리밍 응답이 기본적으로 버퍼링 없이 통과되므로, 저는 오직 셀프 호스팅(self-hosted) 배포 환경에서만 이 문제를 디버깅해 왔습니다. 전형적인 실패 사례는 1년 동안 아무도 건드리지 않은 nginx 설정 뒤에 있는 Next.js입니다. 만약 nginx를 통해 프록시를 사용한다면, X-Accel-Buffering: no를 보내거나 해당 경로에 대해 proxy_buffering을 비활성화해야 하며, 어떤 압축 레이어에서도 text/event-stream 응답은 제외해야 합니다.
아무도 읽지 않는 토큰에 대해 비용을 지불하지 않으려면 어떻게 해야 하나요?
와이어(Wire)를 엔드 투 엔드(end to end)로 중단시킵니다. 들어오는 Request는 사용자가 탭을 닫거나, 페이지를 이동하거나, 중지 버튼을 클릭할 때 발생하는 AbortSignal을 포함합니다 (useChat 훅은 fetch를 중단하는 stop()을 노출합니다). 이를 streamText의 abortSignal 옵션으로 전달하면 취소 신호가 모델 제공자(model provider)에게 전달되어, 모델이 생성을 중단하고 과금도 중단됩니다.
const result = streamText({
model: "anthropic/claude-sonnet-4-6",
messages: convertToModelMessages(messages),
...
위 코드 조각에는 명확한 트레이드오프(trade-off)가 존재합니다. consumeStream()은 중단 신호(abort signal)와 반대로 동작합니다. 이는 생성 과정을 클라이언트 연결로부터 분리하여, 모델이 끝까지 실행되도록 하고 연결이 끊긴 후에도 onFinish가 전체 메시지를 저장할 수 있게 합니다. 연결 시 중단(Abort-on-disconnect)은 토큰을 절약하지만 부분적인 응답을 잃게 되며, 끝까지 소비(consume-to-completion)는 응답은 유지하지만 모든 토큰에 대해 비용을 지불합니다. 저는 비용이 저렴한 대화형 채팅에는 중단 신호를 연결하고, 비용이 많이 드는 긴 글 생성에는 소비 후 저장(consume-plus-persist) 방식을 사용합니다. 실수하는 부분은 기능별로 정책을 정하는 대신, 하나의 정책을 전역적으로 선택하는 것입니다.
스트리밍 도중 사용자가 새로고침하면 어떻게 되나요?
기본 설정에서는 fetch 스트림이 페이지와 함께 종료되며, 만약 중단 신호가 연결되어 있다면 서버 측 생성도 함께 종료됩니다. 페이지를 다시 불러온 후에는 onFinish가 저장했던 내용이 채팅에 표시됩니다. 즉, 완료된 메시지는 살아남지만, 진행 중이던 메시지는 사라집니다. 대부분의 채팅 제품에서 저는 이를 수용 가능한 수준이라고 생각하며, 메시지가 어딘가에 여전히 존재한다고 가장하는 것보다 더 정직한 방식이라고 봅니다.
FAQ
Q: ChatGPT 스타일의 응답을 스트리밍하려면 WebSockets가 필요한가요?
A: 아니요. 토큰 스트리밍은 서버에서 클라이언트로 향하는 단방향이며, fetch POST를 통한 SSE(Server-Sent Events) 형식이 일반적인 HTTP 상에서 이를 처리합니다. WebSockets는 음성이나 협업 편집과 같은 양방향 트래픽을 위한 것입니다.
Q: 왜 nginx 뒤에서는 AI 스트림이 작동하지 않나요?
A: nginx는 기본적으로 업스트림 응답을 버퍼링(buffering)합니다. 응답에 X-Accel-Buffering: no를 보내거나 해당 라우트에 대해 proxy_buffering을 비활성화하고, 압축 계층(compression layer)이 스트림을 다시 버퍼링하지 않도록 확인해야 합니다.
Q: Vercel과 같은 서버리스(serverless) 플랫폼에서도 스트리밍이 작동하나요?
A: 네. Vercel Functions는 응답을 스트리밍하며, Fluid Compute가 전체 생성 과정 동안 함수를 활성 상태로 유지합니다. 기본 실행 제한 시간은 300초이며, maxDuration을 통해 라우트별로 설정할 수 있습니다.
Q: 사용자가 스트리밍 도중에 탭을 닫으면 AI 메시지를 어떻게 저장하나요?
A: 서버에서 consumeStream()을 호출하여 생성이 끝까지 완료되도록 하고, onFinish에서 전체 메시지를 영구 저장(persist)해야 합니다. 이는 토큰을 아끼기 위해 중단(aborting)하는 방식과 충돌하며, 각 라우트는 하나의 동작 방식을 선택해야 합니다.
Q: EventSource로 POST 바디(body)를 보낼 수 있나요?
A: 아니요. EventSource는 GET 요청만 생성하며, 바디나 커스텀 헤더를 포함할 수 없습니다. 이것이 바로 AI 채팅 클라이언트가 대신 fetch POST를 통해 ReadableStream 리더로부터 SSE 형식을 읽는 이유입니다.
원문은 devya.dev에 처음 게시되었습니다. 또한 eng-ahmed.com에서도 확인하실 수 있습니다. Devya Solutions에서 제작하였습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기