본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 05. 20:56

브라우저로 LLM 토큰 스트리밍하기: 프로덕션 환경의 SSE 설정

요약

LLM 응답의 체감 대기 시간을 줄이기 위해 SSE(Server-Sent Events)를 활용한 토큰 스트리밍 구현 방법을 다룹니다. POST 요청 지원과 AbortController를 통한 취소 제어가 가능한 fetch API 기반의 프로덕션 환경 설정 가이드를 제공합니다.

핵심 포인트

  • SSE를 통한 토큰 스트리밍으로 사용자 경험(UX) 개선
  • POST 요청 지원을 위해 EventSource 대신 fetch API 사용
  • AbortController를 활용한 스트림 취소 및 에러 처리
  • Ollama 및 Claude 모델의 스트림 소비 및 재방출 로직

스피너(Spinner)는 거짓말입니다. 사용자에게 무엇이 일어나고 있는지는 알려주지 않으면서 무언가 진행 중이라는 사실만 전달할 뿐입니다. spectr-ai가 보안 보고서를 생성할 때, LLM은 15초에서 40초에 걸쳐 텍스트를 토큰(token) 단위로 생성합니다. 만약 제가 전체 응답을 기다렸다가 페이지에 한꺼번에 뿌린다면, 사용자는 그 시간 내내 아무것도 없는 화면을 멍하니 바라보게 될 것입니다. 하지만 각 토큰이 도착하는 대로 스트리밍(streaming)한다면, ChatGPT와 똑같이 보고서가 사용자 눈앞에서 스스로 작성되는 것처럼 보일 것입니다. 대기 시간은 동일하지만, 체감되는 느낌은 완전히 다릅니다.

얼마 전 저는 진행률 표시줄(progress bars)을 위한 SSE(Server-Sent Events)에 대해 다루었습니다. 서버가 몇 개의 stepprogress 이벤트를 보내면 클라이언트가 바(bar)를 움직이는 방식이었죠. 이번에는 토큰 스트리밍 버전입니다. 몇 개의 불연속적인 진행 이벤트 대신, 서버는 모델에서 실시간으로 나오는 수백 개의 텍스트 파편(fragments)을 전달합니다. 전송 방식은 동일하지만(fetch 스트림을 통한 Server-Sent Events), 소스(source), 파싱(parsing), 그리고 실패 모드(failure modes)가 다릅니다.

다음은 전체 프로덕션 설정입니다. 모델 자체의 스트림을 소비(consume)하여 다시 방출(re-emit)하는 Next.js 15 Route Handler, 토큰이 도착하는 대로 렌더링하는 클라이언트 리더(client reader), 그리고 작업이 40초 동안 실행될 때 실제로 필요한 취소(cancellation) 및 에러 처리(error handling)를 포함합니다.

왜 그냥 EventSource를 사용하지 않는가

브라우저의 EventSource는 SSE를 위한 명백한 도구이며, 재연결(reconnection)을 무료로 처리해 줍니다. 하지만 이는 GET 요청만 수행할 수 있습니다. spectr-ai는 본문(body)에 계약 소스와 선택된 모델을 담아 POST 요청을 보내기 때문에 EventSource는 사용할 수 없습니다. 우리는 fetchresponse.body.getReader()를 사용하여 응답 스트림을 수동으로 읽습니다. 또한 이 방식은 EventSource가 깔끔하게 노출하지 않는, 취소를 위한 AbortController를 사용할 수 있게 해줍니다.

필요 사항EventSourcefetch + reader
본문이 포함된 POST아니요
...
단발성 LLM 요청의 경우 어차피 자동 재연결을 원하지 않을 것입니다. 재연결이 발생하면 생성이 다시 시작되어 비용이 두 번 청구될 수 있기 때문입니다.

소스: 모델의 스트림 소비하기

Ollama와 Claude 모두 스트리밍 (streaming)이 가능합니다. Ollama는 /v1/chat/completions 경로에 OpenAI 호환 엔드포인트 (endpoint)를 노출하며, stream: true 설정 시 자체적인 SSE 라인인 `data: {json}

을 반환하고 data: [DONE]`으로 종료됩니다. 따라서 서버는 두 가지 일을 동시에 수행하고 있습니다. 즉, 모델을 읽는 SSE 클라이언트 (client)이자 브라우저에 쓰는 SSE 서버 (server) 역할을 합니다.

다음은 모델의 HTTP 스트림 (stream)을 일반 텍스트 델타 (delta)의 비동기 반복자 (async iterator)로 변환하는 헬퍼 (helper) 함수입니다:

// lib/stream-model.ts
interface ChatChunk {
  choices: { delta: { content?: string } }[];
...

버퍼 (buffer) 로직은 진행 표시줄 (progress-bar) 포스트의 클라이언트 리더 (client reader)와 동일한 개념입니다. TCP 청크 (chunk)는 메시지 경계를 준수하지 않습니다. 한 번의 read() 호출이 data: 라인의 절반만 가져올 수도 있습니다. 을 기준으로 분할하고 마지막 조각을 다음 읽기까지 유지하면, 잘린 객체를 JSON.parse 하려 시도하는 상황을 방지할 수 있습니다.

Claude의 경우 형태가 다르지만 (Anthropic SDK는 for await를 사용할 수 있는 타입이 지정된 stream을 제공하며, content_block_delta 이벤트를 포함합니다), 서버 관점에서의 규약 (contract)은 동일합니다. 즉, 텍스트 델타의 비동기 제너레이터 (async generator)입니다. streamModel의 본문만 교체하면 이 포스트의 나머지 부분은 변경할 필요가 없습니다.

서버: 토큰을 재방출하는 라우트 핸들러 (Route Handler)

이제 해당 제너레이터를 ReadableStream으로 감싸고 라우트 핸들러 (Route Handler)에서 반환합니다. 각 토큰은 고유한 SSE 이벤트 (event)가 되어 클라이언트가 이를 즉시 렌더링할 수 있습니다.

// app/api/report/route.ts
import { NextRequest } from "next/server";
import { streamModel } from "@/lib/stream-model";
...

여기서 세 가지 요소가 제 역할을 다합니다.

  1. request.signal이 끝까지 전달됩니다. 브라우저가 중단(abort)되면 Next.js는 request.signal을 중단시키고, 저는 이를 streamModel로 전달하며, 이는 다시 모델 fetch로 전달됩니다. UI에서 중지 버튼을 누르면 실제로 모델의 생성이 중단됩니다. GPU를 소모하며 떠도는 40초짜리 고립된 작업(orphaned jobs)이 발생하지 않습니다.

  2. no-transformX-Accel-Buffering: no. 이것들은 버퍼링 방지 헤더(anti-buffering headers)입니다. no-transform은 프록시가 본문을 gzip으로 압축하여 버퍼링하지 않도록 지시하며, X-Accel-Buffering: no는 nginx의 응답 버퍼(response buffer)를 비활성화합니다. 이 헤더들이 없다면 프록시가 토큰을 붙잡고 있다가 마지막에 한꺼번에 쏟아낼 수 있으며, 이는 스트리밍의 목적을 완전히 무색하게 만듭니다. 이는 진행률 표시줄(progress bars)보다 토큰 스트리밍에서 훨씬 더 중요한데, 토큰은 몇 밀리초마다 도착하며 어떤 버퍼링이라도 즉각적으로 눈에 띄기 때문입니다.

  3. runtime = "nodejs"maxDuration. 토큰 생성은 오래 걸립니다. Vercel에서는 기본 함수 타임아웃(function timeout) 설정으로 인해 보고서 중간에 연결이 끊길 수 있습니다. maxDuration을 설정하고 지원하는 가장 느린 모델에 맞춰 예산을 할당하세요.

백프레셔 (Backpressure): 읽는 쪽(Reader) 존중하기

ReadableStream에는 내장된 백프레셔 (Backpressure) 기능이 있습니다. controller.enqueue를 호출하면 데이터는 내부 큐(internal queue)로 들어갑니다. 만약 클라이언트가 모델이 생성하는 속도보다 느리게 읽는다면 (느린 네트워크, 백그라운드로 전환된 탭 등), 해당 큐가 가득 차게 되고 controller.desiredSize는 0 또는 그 이하로 떨어집니다.

텍스트 토큰의 경우 데이터 양이 충분히 작아서 이에 대응할 필요가 거의 없지만, 만약 크기가 큰 구조화된 청크(structured chunks)를 함께 스트리밍한다면, 더 많은 데이터를 인큐(enqueue)하기 전에 공간이 생길 때까지 await 할 수 있습니다:

async function backpressuredSend(
  controller: ReadableStreamDefaultController,
  bytes: Uint8Array,
...

백프레셔 측면에서의 더 큰 이점은 앞서 언급한 중단 신호(abort signal)입니다. 가장 비용이 많이 드는 작업은 이미 페이지를 벗어난 클라이언트를 위해 계속해서 토큰을 생성하는 것입니다. 취소(Cancellation)는 백프레셔를 극한까지 적용한 것입니다. 소비자가 사라졌으므로, 아무것도 생성하지 마십시오.

클라이언트: 토큰 읽기 및 렌더링

리더(reader)는 프로그레스 바(progress-bar) 클라이언트와 유사하지만, 한 가지 차이점이 있습니다. 프로그레스 값을 교체하는 대신, 각 토큰을 누적된 텍스트에 추가(append)합니다.

// lib/read-report.ts
type ReportEvent =
  | { type: "token"; text: string }
...

그리고 React 컴포넌트입니다. 이 방식이 빠르게 느껴지게 만드는 비결은 ref에 추가한 뒤 state로 플러시(flush)하는 것입니다. 이렇게 하면 수백 개의 급격한 토큰 업데이트가 서로 충돌하며 수백 번의 리렌더링 폭풍(re-render storms)을 일으키는 것을 방지할 수 있습니다.

"use client";

import { useState, useRef, useCallback } from "react";
...

status === "streaming" 상태일 때 깜빡이는 커서는 비용이 거의 들지 않으면서도 실시간 느낌을 효과적으로 전달합니다. pre 태그에 적용된 whitespace-pre-wrap은 마지막에 마크다운 렌더러(markdown renderer)를 통해 처리하기 전까지 모델의 마크다운 간격을 보존합니다.

실제로 문제가 되는 실패 모드들

200 응답 이후의 에러. 이는 프로그레스 바 포스트에서 언급한 것과 동일한 함정이지만, 여기서는 더 치명적입니다. Response를 반환하는 순간, 상태 코드는 200으로 고정됩니다. 만약 모델이 300번째 토큰에서 중단된다면, 500 에러를 보낼 수 없습니다. 대신 스트림(stream) 내부에서 error 이벤트를 보내야 하며, 클라이언트는 이를 보고 예외를 발생시켜야 합니다. HTTP 상태 코드에 의존하는 모든 에러 핸들링(error handling)은 첫 번째 바이트가 전송되는 즉시 무용지물이 됩니다.

취소(Cancellation)는 양방향입니다. 클라이언트가 중단(abort)하더라도 서버가 이를 인지해야 합니다. 이것이 모든 fetchrequest.signal이 스레드(threaded)되어 있는 이유입니다. 또한 클라이언트 측에서 if (controller.signal.aborted) return 구문을 사용하여 의도적인 중단 시 무서운 에러 메시지가 깜빡이지 않도록 방어합니다.

청크 경계에서의 불완전한 토큰(Half-tokens). data: 라인은 서버-모델 측과 브라우저 측 모두에서 두 번의 TCP 읽기(read)에 걸쳐 나뉘어 들어올 수 있습니다. 두 리더 모두 동일한 '분할 후 꼬리 부분 유지(split-and-hold-the-tail)' 버퍼를 사용합니다. 이를 생략하면 부하가 걸린 상황에서 무작위적인 JSON.parse 충돌이 발생하며, 이는 로컬 환경(localhost)에서는 절대 재현할 수 없는 문제입니다.

프록시 버퍼링(Proxy buffering)이 효과를 없애버립니다. 만약 토큰들이 마지막에 한꺼번에 큰 덩어리로 도착한다면, 헤더 설정이 잘못된 것입니다. no-transformX-Accel-Buffering: no를 확인하고, 앱 앞단에 있는 요소(CDN, 개발용 프록시 등)에서 다시 버퍼링(re-buffering)을 수행하고 있지 않은지 확인하십시오.

핵심 요약 (Takeaway)

진행률 표시줄(Progress bars)과 토큰 스트림(token streams)은 동일한 SSE 레일을 타지만, 토큰 스트리밍은 훨씬 더 높은 수준의 정밀도를 요구합니다. 이제 당신은 SSE 클라이언트인 동시에 서버가 되며, 버퍼링 버그는 밀리초(millisecond) 단위의 해상도로 드러납니다. 또한, 취소(cancellation) 요청은 모델까지 완전히 전달되어야 합니다. 그렇지 않으면 아무도 읽지 않을 작업에 대해 비용을 지불하게 됩니다. 버튼에서 모델 fetch까지 하나의 AbortController를 연결하고, 안티-버퍼링(anti-buffering) 헤더를 설정하며, 양쪽 끝에서 테일 버퍼(tail buffer)를 사용하여 파싱하십시오. 이것이 프로덕션 설정의 전부입니다.

이 기능은 현재 spectr-ai에서 실행 중입니다. 계약서를 붙여넣으면 보안 보고서가 토큰 단위로 작성되는 것을 지켜볼 수 있으며, 중지 버튼을 누르면 GPU도 함께 멈춥니다. 코드는 GitHub에서 확인할 수 있습니다: https://github.com/pavelEspitia/spectr-ai

AI 자동 생성 콘텐츠

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

원문 바로가기
0

댓글

0