LLM 스트리밍 응답 구현 — SSE·WebSocket 연동과 오류 처리

LLM API가 응답 전체를 한 번에 돌려주기를 기다리면 사용자는 수 초~수십 초를 빈 화면 앞에서 기다립니다. 스트리밍은 모델이 토큰을 생성하는 즉시 클라이언트로 흘려보내 첫 글자가 표시되는 시점을 극적으로 앞당깁니다. 이 글에서는 스트리밍 응답을 브라우저와 서버 사이에 연결하는 두 가지 방법인 SSE(Server-Sent Events)와 WebSocket의 차이, API가 돌려주는 delta 청크를 파싱하는 방법, 연결이 끊기거나 타임아웃이 발생할 때의 대응 전략, 그리고 배압(backpressure) 문제까지 코드 예시와 함께 다룹니다.

스트리밍 응답이란 — 왜 필요한가

일반적인 HTTP 요청은 클라이언트가 요청을 보내고, 서버가 처리를 완료한 뒤 전체 응답을 한 번에 반환합니다. LLM은 출력을 앞에서부터 순서대로 생성하는 자기회귀(autoregressive) 구조이기 때문에, 응답 전체가 완성되기까지 기다렸다가 한꺼번에 내보내는 것은 불필요한 지연입니다. 스트리밍 API는 모델이 토큰을 생성할 때마다 그 결과를 즉시 전송합니다.

사용자 경험 측면에서 스트리밍의 효과는 분명합니다. 응답 생성에 10초가 걸리더라도, 스트리밍이 있으면 사용자는 0.3~0.5초 안에 첫 글자를 볼 수 있습니다. 이는 체감 대기 시간을 수십 분의 일로 줄여 줍니다. 또한 스트리밍 도중에 응답이 이미 충분하다고 판단되면 생성을 중단할 수 있어 비용을 아끼는 데도 유용합니다. Anthropic의 공식 문서(Streaming Messages)와 OpenAI의 스트리밍 가이드 모두 이 방식을 기본 권장 패턴으로 제시합니다.

SSE vs WebSocket — 선택 기준

스트리밍 데이터를 브라우저에 전달하는 방법은 크게 두 가지입니다. SSE는 HTTP 위에서 동작하는 단방향 서버→클라이언트 채널이고, WebSocket은 양방향 전이중(full-duplex) 연결입니다. LLM 스트리밍에서 대부분의 경우 SSE가 적합합니다.

항목SSE (Server-Sent Events)WebSocket
통신 방향서버→클라이언트 단방향양방향 전이중
프로토콜HTTP/1.1·HTTP/2 위별도 ws:// 프로토콜
자동 재연결브라우저가 기본 지원직접 구현 필요
프록시·CDN 호환HTTP 표준이라 대부분 통과업그레이드 헤더 필요, 일부 프록시 차단
연결 유지 비용단방향이라 비교적 낮음상태 유지 오버헤드 있음
LLM 스트리밍 적합도★★★★★ (단방향이면 충분)★★★ (실시간 양방향 필요 시)

LLM 스트리밍의 데이터 흐름을 보면, 사용자 입력은 최초 HTTP POST 한 번으로 서버에 전달되고, 그 이후는 서버가 생성 결과를 클라이언트로 내보내기만 합니다. 즉 실질적으로 단방향입니다. 이런 경우 SSE가 더 단순하고 안정적입니다. WebSocket은 실시간 코드 자동 완성처럼 사용자가 타이핑하는 도중에도 서버와 상호작용해야 하거나, 음성 입력과 텍스트 출력을 동시에 처리하는 멀티모달 세션처럼 진정한 양방향 채널이 필요할 때 선택합니다.

HTTP/2 멀티플렉싱과 SSE 연결 한도

HTTP/1.1 환경에서 브라우저는 같은 도메인에 대해 최대 6개의 연결을 허용합니다. SSE 연결이 6개를 초과하면 새 요청이 블로킹됩니다. HTTP/2를 사용하면 단일 TCP 연결에서 수십~수백 개의 스트림을 멀티플렉싱하므로 이 제한이 사라집니다. 프로덕션에서는 반드시 HTTP/2 이상을 사용하세요.

스트리밍 API 응답 파싱 — delta 처리

Anthropic과 OpenAI 모두 스트리밍 응답을 Server-Sent Events 형식의 텍스트 스트림으로 반환합니다. 각 줄은 data: {JSON} 형태이며, 스트림 종료는 data: [DONE](OpenAI) 또는 event: message_stop(Anthropic) 이벤트로 표시됩니다.

핵심 개념은 delta입니다. 각 청크가 전체 응답을 담는 것이 아니라, 직전 상태에서 새로 추가된 텍스트 조각만 담습니다. 클라이언트는 delta를 받을 때마다 기존 텍스트에 이어 붙여 전체 응답을 누적 조립합니다.

# Anthropic 스트리밍 이벤트 흐름 (개념)
event: message_start
data: {"type":"message_start","message":{"id":"msg_...","role":"assistant",...}}

event: content_block_start
data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}

event: content_block_delta          # 토큰마다 반복
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"안녕"}}

event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"하세요"}}

event: message_delta
data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5}}

event: message_stop
data: {"type":"message_stop"}

OpenAI 형식에서는 choices[0].delta.content 필드에 delta 텍스트가 담깁니다. 두 형식 모두 파싱 원칙은 같습니다. 줄 단위로 읽고, data: 접두사를 제거한 뒤 JSON으로 파싱하고, 타입 필드를 보고 텍스트 delta만 추출해 누적 문자열에 붙입니다.

// 범용 SSE 스트림 파서 (TypeScript 개념 예시)
async function* parseSSEStream(stream: ReadableStream<Uint8Array>) {
  const reader = stream.getReader();
  const decoder = new TextDecoder();
  let buffer = '';

  try {
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      buffer += decoder.decode(value, { stream: true });

      const lines = buffer.split('n');
      buffer = lines.pop() ?? '';   // 마지막 미완성 줄은 다음 청크와 합침

      for (const line of lines) {
        if (!line.startsWith('data: ')) continue;
        const payload = line.slice(6).trim();
        if (payload === '[DONE]') return;  // OpenAI 종료 신호
        try { yield JSON.parse(payload); } catch { /* 빈 줄·비JSON 무시 */ }
      }
    }
  } finally {
    reader.releaseLock();
  }
}

서버 구현 — Next.js App Router와 Express 예시

Next.js 14+ App Router (Route Handler)

Next.js App Router에서는 Response 생성자에 ReadableStream을 직접 넘겨 스트리밍을 구현합니다. Vercel AI SDK의 StreamingTextResponse를 쓰면 더 간결하지만, 아래는 SDK 없이 원리를 보여주는 예시입니다.

// app/api/chat/route.ts
import Anthropic from '@anthropic-ai/sdk';

const client = new Anthropic();

export async function POST(req: Request) {
  const { messages } = await req.json();

  const stream = await client.messages.stream({
    model: 'claude-opus-4-5',
    max_tokens: 1024,
    messages,
  });

  // Anthropic SDK의 ReadableStream을 그대로 응답에 연결
  const readable = new ReadableStream({
    async start(controller) {
      const encoder = new TextEncoder();
      for await (const chunk of stream) {
        if (
          chunk.type === 'content_block_delta' &&
          chunk.delta.type === 'text_delta'
        ) {
          // SSE 형식으로 인코딩해서 전송
          controller.enqueue(
            encoder.encode(`data: ${JSON.stringify({ text: chunk.delta.text })}nn`)
          );
        }
      }
      controller.enqueue(encoder.encode('data: [DONE]nn'));
      controller.close();
    },
  });

  return new Response(readable, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache, no-transform',
      'X-Accel-Buffering': 'no',   // Nginx 버퍼링 비활성화
    },
  });
}

Express 서버

Express에서는 res.setHeader로 SSE 헤더를 설정하고 res.write로 청크를 흘려보냅니다. 반드시 res.flushHeaders()를 먼저 호출해 헤더를 즉시 전송해야 클라이언트가 스트림으로 인식합니다.

// Express SSE 라우트 예시
app.post('/api/chat', async (req, res) => {
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');
  res.setHeader('X-Accel-Buffering', 'no');
  res.flushHeaders();   // 헤더를 즉시 클라이언트에 전송

  try {
    const stream = await client.messages.stream({ /* ... */ });
    for await (const chunk of stream) {
      if (chunk.type === 'content_block_delta' && chunk.delta.type === 'text_delta') {
        res.write(`data: ${JSON.stringify({ text: chunk.delta.text })}nn`);
      }
    }
    res.write('data: [DONE]nn');
  } catch (err) {
    res.write(`event: errorndata: ${JSON.stringify({ message: err.message })}nn`);
  } finally {
    res.end();
  }
});

프론트엔드 렌더링 최적화

스트리밍 텍스트를 프론트엔드에서 표시할 때 가장 흔한 실수는, delta가 도착할 때마다 React state를 업데이트해 렌더링을 유발하는 것입니다. 토큰이 초당 30~100개씩 들어올 때 동기적으로 setState를 호출하면 브라우저 메인 스레드가 렌더링을 처리하다 밀립니다.

// 비효율적인 방법 — 토큰마다 setState
eventSource.onmessage = (e) => {
  const { text } = JSON.parse(e.data);
  setContent(prev => prev + text);  // 매 토큰마다 리렌더링
};

// 개선된 방법 — requestAnimationFrame으로 배치
let buffer = '';
let raf: number | null = null;

eventSource.onmessage = (e) => {
  const { text } = JSON.parse(e.data);
  buffer += text;
  if (!raf) {
    raf = requestAnimationFrame(() => {
      setContent(prev => prev + buffer);
      buffer = '';
      raf = null;
    });
  }
};

Markdown을 실시간으로 렌더링하는 경우, 스트리밍 도중에는 마크다운 파싱을 생략하고 원문 텍스트만 표시하다가, 스트림 종료 후 한 번만 파싱해 최종 HTML을 그리는 전략이 성능과 UX 모두에 낫습니다. 파싱 중에 볼 수 없는 마크다운 구문이 깜빡이는 현상도 줄어듭니다.

또한 스크롤 동작에 주의해야 합니다. 텍스트가 추가될 때 자동으로 스크롤을 아래로 내리되, 사용자가 스크롤을 직접 위로 올렸다면 자동 스크롤을 중단하는 로직을 넣어야 합니다. 사용자가 이미 본 내용을 다시 보려고 올라간 상태에서 화면이 자동으로 튀어 내려가면 매우 불편합니다.

청크 중단·타임아웃 처리

스트리밍 연결은 일반 HTTP 요청보다 오래 유지되므로 다양한 이유로 끊어질 수 있습니다. 네트워크 불안정, 서버 타임아웃, LLM API의 rate limit 초과, 모델 서버 오류가 모두 원인이 될 수 있습니다. 각 상황마다 대응 전략이 다릅니다.

오류 유형증상대응 전략
네트워크 단절EventSource의 onerror 발생, readyState → CONNECTINGSSE 자동 재연결 활용 (Last-Event-ID 헤더로 이어받기)
서버 타임아웃일정 시간 후 연결 종료, 청크 중단AbortController로 클라이언트 타임아웃 설정, 재시작 여부 사용자에게 안내
LLM API 오류 (5xx)스트림 중간에 error 이벤트지수 백오프 후 재시도, 이미 받은 부분은 보존
컨텍스트 길이 초과stop_reason: max_tokens 또는 오류사용자에게 알림, 이어 생성(continuation) 옵션 제공
사용자 중단 요청사용자가 “중지” 클릭AbortController.abort()로 fetch 취소, 서버도 연결 감지해 LLM API 호출 중단

SSE의 가장 큰 장점 중 하나는 브라우저가 자동으로 재연결을 시도한다는 것입니다. 연결이 끊기면 기본적으로 3초 후 재연결을 시도하며, 서버는 retry: 필드로 이 간격을 조정할 수 있습니다. 재연결 시 브라우저는 Last-Event-ID 헤더를 자동으로 붙이므로, 서버에서 이를 활용하면 중단된 지점 이후부터 스트림을 이어보낼 수 있습니다.

// 클라이언트: AbortController로 스트리밍 타임아웃 처리
const controller = new AbortController();
const TIMEOUT_MS = 30_000;   // 30초 타임아웃

const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);

try {
  const res = await fetch('/api/chat', {
    method: 'POST',
    body: JSON.stringify({ messages }),
    signal: controller.signal,
  });
  clearTimeout(timeoutId);   // 연결 성공 시 타이머 해제

  for await (const chunk of parseSSEStream(res.body!)) {
    // delta 처리
  }
} catch (err) {
  if (err.name === 'AbortError') {
    console.warn('스트리밍 타임아웃 또는 사용자 중단');
  }
}

배압(Backpressure) 관리

배압은 데이터 생산 속도가 소비 속도를 앞지를 때 발생하는 문제입니다. LLM 스트리밍에서는 모델이 토큰을 생성하는 속도보다 클라이언트가 처리·표시하는 속도가 느린 경우에 해당합니다. 일반적인 텍스트 스트리밍에서는 토큰 크기가 작고 표시 비용이 낮아 배압이 문제가 되지 않지만, 스트리밍 응답을 가공해 다른 시스템에 전달하거나, 여러 LLM의 출력을 합쳐 다운스트림으로 전달하는 파이프라인에서는 배압이 중요한 설계 항목이 됩니다.

Node.js의 ReadableStream Web Streams API는 배압을 기본으로 지원합니다. 컨트롤러의 desiredSize가 0 이하가 되면 스트림이 생산을 잠시 멈추라는 신호를 보냅니다. Fetch API를 통해 스트림을 소비할 때 소비자가 read()를 느리게 호출하면 자연스럽게 배압이 걸립니다.

// 배압을 인식하는 TransformStream 예시 (개념)
const transform = new TransformStream({
  async transform(chunk, controller) {
    // controller.desiredSize가 0 이하면 소비자가 따라오지 못하는 중
    if (controller.desiredSize !== null && controller.desiredSize <= 0) {
      // 잠시 대기하거나 청크를 내부 버퍼에 쌓기
      await waitForDrain();
    }
    controller.enqueue(chunk);
  },
});

서버 사이드에서 배압이 더 실질적인 문제가 되는 상황은, 스트리밍 응답을 실시간으로 데이터베이스에 저장하거나, WebSocket으로 연결된 여러 클라이언트에 브로드캐스트하거나, 외부 웹훅으로 전달할 때입니다. 이런 경우 다운스트림 처리가 느려지면 메모리에 데이터가 쌓이지 않도록 배압을 적용해야 합니다. Node.js Streams의 pipe() 메서드나 Web Streams의 pipeTo()는 배압 신호를 자동으로 상류로 전파하므로, 직접 구현보다 이 메커니즘을 활용하는 것이 안전합니다.

  • LLM API 호출 서버에서 X-Accel-Buffering: no 헤더를 설정해 Nginx가 응답을 버퍼링하지 않도록 한다
  • Vercel / Cloudflare Workers 배포 시 엣지 함수의 스트리밍 지원 여부와 타임아웃 제한을 사전에 확인한다
  • 스트리밍 응답을 DB에 저장할 때는 스트림 완료 후 한 번에 쓰거나, 청크를 배치로 묶어 쓰기 빈도를 줄인다
  • 클라이언트 연결이 끊어졌는지 감지해 LLM API 호출을 조기에 중단한다 (비용 절감)
  • Markdown 코드 블록처럼 닫히지 않은 구문이 스트림 중간에 표시되면 UI가 깨질 수 있으므로, 증분 파싱 라이브러리를 사용하거나 완성된 블록 단위로만 렌더링한다

자주 묻는 질문

SSE와 WebSocket 중 어떤 것을 써야 하나요?
LLM 텍스트 스트리밍처럼 서버에서 클라이언트로 데이터를 흘려보내기만 하는 단방향 흐름에는 SSE가 적합합니다. SSE는 HTTP 표준 위에서 동작해 프록시·CDN·방화벽 통과가 쉽고, 브라우저가 자동 재연결을 지원합니다. 사용자 입력 도중에도 실시간으로 서버와 상호작용해야 하는 경우(음성 입출력 동시 처리, 실시간 협업 편집 등)에는 WebSocket을 선택하세요.
스트리밍 도중 연결이 끊기면 어떻게 되나요?
SSE를 사용하면 브라우저가 자동으로 재연결을 시도합니다. 서버에서 각 이벤트에 id: 필드를 붙이면, 재연결 시 브라우저가 Last-Event-ID 헤더를 보내 마지막으로 받은 위치를 알려줍니다. 서버는 이를 활용해 중단된 이후 지점부터 이어서 스트리밍할 수 있습니다. LLM API 호출을 재시작해야 하는 경우라면, 이미 생성된 텍스트를 컨텍스트에 넣어 이어 생성하는 방식을 쓸 수 있습니다.
스트리밍 응답에서 Markdown은 어떻게 렌더링하나요?
스트리밍 도중에는 코드 블록이나 강조 구문이 완성되지 않은 상태로 들어오므로 일반 마크다운 파서를 바로 적용하면 구문이 깨집니다. 실무에서는 스트리밍 도중 원문 텍스트를 <pre>white-space: pre-wrap 컨테이너에 출력하고, 스트림이 완료된 뒤 마크다운을 한 번만 파싱해 최종 HTML을 그리거나, 증분 마크다운 파싱을 지원하는 라이브러리(예: marked의 lexer 단위 사용)를 활용합니다.
타임아웃은 어느 정도로 설정해야 하나요?
LLM 스트리밍에는 두 가지 타임아웃이 있습니다. 첫 청크가 도착하기까지의 연결 타임아웃(일반적으로 10~30초)과, 청크 사이 간격이 너무 길 때를 감지하는 유휴 타임아웃입니다. 연결 타임아웃은 AbortController와 setTimeout으로 구현하고, 유휴 타임아웃은 마지막 청크를 받은 시각을 기록해 일정 시간(예: 15~30초) 이상 다음 청크가 없으면 연결을 종료하는 방식으로 구현합니다. 정확한 값은 모델과 최대 출력 길이에 따라 조정합니다.
Vercel이나 Cloudflare에서 스트리밍이 안 될 때 확인할 사항은?
Vercel에서는 Edge Runtime과 Node.js Runtime의 스트리밍 동작이 다르고, 기본 함수 실행 제한이 있습니다. Pro 플랜 이상에서는 제한 시간을 늘릴 수 있습니다. Cloudflare Workers는 스트리밍을 지원하지만 응답 본문이 완전히 버퍼링되는 설정이 있을 수 있습니다. 두 환경 모두 Content-Type: text/event-stream을 정확히 설정하고, 엣지 캐시가 응답을 버퍼링하지 않도록 Cache-Control: no-store를 명시하세요.

SSE(Server-Sent Events)와 EventSource·ReadableStream의 표준 동작은 MDN 공식 문서에서 확인할 수 있습니다.

이 글은 Anthropic Streaming Messages API 공식 문서, OpenAI 스트리밍 가이드, MDN Web Docs의 EventSource 및 ReadableStream 명세, RFC 6455 WebSocket 프로토콜을 바탕으로 작성되었습니다. 코드 예시는 개념 설명을 위한 단순화된 형태이며, 실제 프로덕션 적용 시에는 각 SDK의 최신 문서와 에러 처리 가이드를 확인하시기 바랍니다.

위로 스크롤