· 6분 · API

LLM 응답 스트리밍: UX와 비용에 중요한 이유

LLM 응답 스트리밍: UX와 비용에 중요한 이유

채팅 UI가 ‘고장 난’ 것처럼 느껴지게 만든 900ms

몇 달 전, 저는 수치상으로는 종단 간 지연 시간이 충분히 양호한 고객 지원 어시스턴트를 디버깅하고 있었습니다. 대부분의 응답이 4~7초 안에 완료되었습니다. 모델은 도구를 선택하고, 계정 관련 맥락을 검색하고, 유용한 답변을 생성하고 있었습니다.

하지만 사용자들은 이를 싫어했습니다.

이유는 간단했습니다. 처음 3.8초 동안 UI가 빈 상태로 멈춰 있었기 때문입니다.

로딩 표시 문구도, 부분 응답도, 모델이 질문을 이해했다는 표시도 없었습니다. 어시스턴트의 빈 말풍선만 있었습니다. 동일한 엔드포인트에서 Server-Sent Events로 토큰을 스트리밍하도록 바꾸자 평균 전체 완료 시간은 거의 달라지지 않았습니다. 하지만 많은 요청에서 처음으로 출력이 화면에 나타나기까지의 시간이 약 700~900ms로 줄었고, 제품은 즉시 더 빠르게 느껴졌습니다.

이것이 스트리밍이 중요한 진짜 이유입니다.

LLM 응답 스트리밍은 마법이 아닙니다. 모델이 더 빨리 생각하게 만들지 않습니다. 전체 토큰 수를 줄이지도 않습니다. 요금을 자동으로 줄여 주지도 않습니다. 하지만 사용자가 체감할 수 있도록 상호작용 흐름을 바꾸며, 애플리케이션이 취소, 렌더링, 콘텐츠 검토, 사용량 집계, 백프레셔를 더 세밀하게 제어할 수 있게 합니다.

이 글에서는 SSE 스트리밍이 실제로 작동하는 방식, 측정해야 할 항목, 과금에서 주의가 필요한 부분, 그리고 제가 프로덕션 시스템에서 사용하는 클라이언트 측 패턴을 살펴봅니다.


스트리밍 응답을 사용하는 가장 큰 이점은 무엇인가요?

스트리밍 응답의 가장 큰 이점은 체감 지연 시간입니다. 사용자는 전체 생성 시간 동안 로딩 표시를 바라보는 대신 수백 밀리초 안에 첫 토큰을 볼 수 있습니다. 전체 생성 시간은 거의 변하지 않습니다. 달라지는 것은 첫 토큰까지 걸리는 시간이며, 이것이 사용자가 실제로 체감하는 수치입니다. 완료까지 9초가 걸려도 400ms 후부터 나타나기 시작하는 답변은 빠르게 느껴집니다. 같은 답변이 9초 후 한꺼번에 전달되면 고장 난 것처럼 느껴집니다.

부가적인 이점도 있습니다. 스트리밍을 사용하면 부분 출력을 점진적으로 렌더링하고, 사용자가 충분한 내용을 확인했을 때 생성을 조기에 취소하여 출력 토큰을 절약하고, 긴 응답에서 프록시의 읽기 시간 초과를 피할 수 있습니다. 하지만 한 가지만 기억한다면 이것입니다. 스트리밍은 모델을 더 빠르게 만드는 것이 아니라, 사용자 경험에서 기다림을 없애 줍니다.

스트리밍의 실제 의미

비스트리밍 LLM 요청을 보내면 서버는 보통 모델이 응답 생성을 마칠 때까지 기다린 다음, 단일 JSON 페이로드를 반환합니다:

{
  "id": "resp_123",
  "model": "some-model",
  "output_text": "Sure — here is the answer...",
  "usage": {
    "input_tokens": 812,
    "output_tokens": 436
  }
}

스트리밍에서는 출력이 준비되는 대로 서버가 이벤트를 순차적으로 전송합니다. 가장 일반적인 전송 방식은 서버 전송 이벤트(Server-Sent Events)이며, 일반적으로 다음과 같이 줄여 부릅니다: SSE.

SSE는 연결을 오래 유지하고 특수한 콘텐츠 유형을 사용하는 HTTP 응답일 뿐입니다:

Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive

서버는 하나의 JSON 객체 대신 작은 이벤트들을 차례로 전송합니다:

event: message.delta
data: {"delta":"Sure"}

event: message.delta
data: {"delta":" — here"}

event: message.delta
data: {"delta":" is"}

event: message.delta
data: {"delta":" the answer..."}

event: message.done
data: {"finish_reason":"stop","usage":{"input_tokens":812,"output_tokens":436}}

API마다 이벤트 이름과 스키마는 다르지만, 흐름은 대체로 같습니다:

  1. HTTP 연결을 엽니다.
  2. 요청 메타데이터와 프롬프트를 전송합니다.
  3. 청크를 순차적으로 수신합니다.
  4. 현재 어시스턴트 메시지에 청크를 추가합니다.
  5. 중단 사유와, 대개 사용량 메타데이터까지 포함된 최종 이벤트를 수신합니다.
  6. 스트림을 닫습니다.

실제로 대부분의 LLM ‘스트리밍’은 이벤트당 토큰 하나를 전송하는 방식이 아닙니다. 공급자는 효율성을 위해 여러 토큰을 청크로 묶을 수 있습니다. 문자 하나, 단어의 일부, 여러 단어 또는 구조화된 JSON 델타를 수신할 수 있습니다. 각 청크가 완전한 단어나 문장이라고 가정하는 클라이언트를 만들지 마세요.


지연 시간: 전체 시간과 첫 토큰까지의 시간

가장 중요한 구분은 다음 두 항목 사이의 차이입니다: 전체 지연 시간 및 첫 토큰까지의 시간.

지표측정 대상중요한 이유
첫 바이트까지의 시간, TTFB서버에서 첫 바이트를 수신하기까지의 시간라우팅, 인증, 대기열 처리, 모델 스케줄링 포함
첫 토큰까지의 시간, TTFT처음 생성된 토큰/청크가 사용자에게 표시되기까지의 시간체감 반응성을 좌우하는 주요 요인
초당 토큰 수첫 토큰 이후의 출력 생성 속도긴 답변이 얼마나 빨리 완료되는지 결정
전체 완료 시간전체 응답을 수신하기까지의 시간자동화, 도구 호출, 배치 작업에 중요
활용 가능한 답변까지의 시간사용자가 행동에 필요한 정보를 충분히 얻기까지의 시간전체 완료 시점보다 이른 경우가 많음

채팅 UX에서는 일반적으로 TTFT이 전체 시간보다 더 중요합니다. 800ms 안에 시작해서 8초 후 완료되는 응답은 4초 후 한꺼번에 나타나는 응답보다 더 좋게 느껴지는 경우가 많습니다.

시스템 간 워크플로에서는 스트리밍의 중요성이 더 낮은 경우가 많습니다. 전체 JSON 객체가 완성되기 전에는 다음 단계를 실행할 수 없다면, 스트리밍은 구현 복잡성만 높일 수 있습니다.

Python에서 TTFT 측정하기

첫 청크 지연 시간과 전체 스트림 시간을 측정하는 간단한 패턴입니다. API 형식은 의도적으로 범용 형태로 작성했습니다. URL과 페이로드를 사용하는 공급자에 맞게 조정하세요.

import json
import time
import requests

url = "https://api.example.com/v1/chat/completions"

payload = {
    "model": "sonnet-4.6",
    "stream": True,
    "messages": [
        {"role": "user", "content": "Explain SSE streaming in 5 bullet points."}
    ]
}

headers = {
    "Authorization": "Bearer $API_KEY",
    "Content-Type": "application/json",
    "Accept": "text/event-stream",
}

start = time.perf_counter()
first_chunk_at = None
chars = 0

with requests.post(url, headers=headers, json=payload, stream=True) as r:
    r.raise_for_status()

    for raw_line in r.iter_lines(decode_unicode=True):
        if not raw_line:
            continue

        if not raw_line.startswith("data: "):
            continue

        data = raw_line[len("data: "):]

        if data == "[DONE]":
            break

        event = json.loads(data)

        # Provider-specific: this may be event["delta"], event["choices"][0]["delta"], etc.
        delta = event.get("delta", "")

        if delta and first_chunk_at is None:
            first_chunk_at = time.perf_counter()

        chars += len(delta)
        print(delta, end="", flush=True)

end = time.perf_counter()

print("\n\n--- metrics ---")
print(f"ttft_ms={((first_chunk_at or end) - start) * 1000:.0f}")
print(f"total_ms={(end - start) * 1000:.0f}")
print(f"chars={chars}")

흔히 놓치는 점이 있습니다. 응답을 버퍼링하는 로컬 프록시, 서버리스 함수 또는 리버스 프록시로 스트리밍을 테스트하면 모델이 스트리밍하지 않는다고 생각할 수 있습니다. 모델은 올바르게 스트리밍하고 있지만, 인프라가 응답이 완료될 때까지 청크를 보류하고 있을 수 있습니다.

저는 다음과 같은 경우에 이를 경험했습니다:

  • Nginx 버퍼링이 활성화된 경우.
  • 스트리밍 응답을 지원하지 않는 클라우드 함수.
  • 더 큰 버퍼가 채워지기를 기다리는 압축 미들웨어.
  • 반환 전에 전체 응답을 구성하는 웹 프레임워크.
  • 작은 청크들을 합치는 기업용 프록시.

Nginx에서는 다음 설정이 중요한 경우가 많습니다:

location /api/llm/stream {
    proxy_pass http://app_backend;
    proxy_http_version 1.1;

    proxy_buffering off;
    proxy_cache off;

    chunked_transfer_encoding on;
    add_header X-Accel-Buffering no;
}

SSE와 WebSocket 비교

SSE만이 유일한 스트리밍 방식은 아닙니다. WebSocket도 널리 사용되며, 특히 양방향 애플리케이션에서 흔합니다. 하지만 대부분의 LLM 채팅 인터페이스에서는 SSE가 더 간단한 기본 선택입니다.

기능SSEWebSocket
방향서버에서 클라이언트로양방향
프로토콜일반 HTTP 응답WebSocket 업그레이드
브라우저 지원네이티브 EventSource; 다음과도 함께 사용 가능: fetch 스트림네이티브 WebSocket
재연결 동작기본 제공 개념이지만 앱 로직은 여전히 필요직접 구현
로드 밸런서 친화성대체로 더 쉬움추가 설정이 필요한 경우가 있음
적합한 용도토큰 스트리밍, 진행 상황 이벤트, 간단한 채팅실시간 협업, 오디오, 여러 턴으로 이어지는 실시간 세션
요청 본문 지원EventSource 은 GET만 지원하며, 다음을 사용: fetch POST 스트리밍용완전한 양방향 메시지

이 EventSource 브라우저 API는 편리하지만 GET 요청만 지원합니다. 대부분의 LLM 호출에는 POST 본문과 인증 헤더가 필요하므로 저는 보통 다음을 사용해 스트리밍합니다. fetch() 및 ReadableStream.


브라우저 클라이언트 패턴: Fetch + ReadableStream

최소한의 브라우저 구현은 다음과 같습니다.

async function streamChat({ messages, signal, onDelta, onDone }) {
  const res = await fetch("/api/chat/stream", {
    method: "POST",
    headers: {
      "Content-Type": "application/json"
    },
    body: JSON.stringify({
      model: "gpt-5.5",
      stream: true,
      messages
    }),
    signal
  });

  if (!res.ok) {
    throw new Error(`HTTP ${res.status}`);
  }

  const reader = res.body.getReader();
  const decoder = new TextDecoder("utf-8");

  let buffer = "";

  while (true) {
    const { value, done } = 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) {
      const trimmed = line.trim();

      if (!trimmed.startsWith("data:")) continue;

      const data = trimmed.slice(5).trim();

      if (data === "[DONE]") {
        onDone?.();
        return;
      }

      const event = JSON.parse(data);

      if (event.delta) {
        onDelta(event.delta);
      }
    }
  }

  onDone?.();
}

UI에서는 다음과 같습니다.

const controller = new AbortController();

let assistantText = "";

streamChat({
  messages,
  signal: controller.signal,
  onDelta(delta) {
    assistantText += delta;
    renderAssistantMessage(assistantText);
  },
  onDone() {
    markMessageComplete();
  }
});

// Cancel button
document.querySelector("#stop").onclick = () => {
  controller.abort();
};

모든 청크를 단순하게 렌더링하지 마세요

아주 작은 청크가 올 때마다 React 상태를 업데이트하면 의도치 않게 클라이언트 측 성능 문제가 생길 수 있습니다. 일부 스트림은 초당 수십 개의 청크를 생성합니다. 매번 큰 채팅 트리를 다시 렌더링하는 것은 낭비입니다.

실제로 저는 보통 애니메이션 프레임 단위로 UI 업데이트를 모아서 처리합니다.

let pending = "";
let scheduled = false;

function onDelta(delta) {
  pending += delta;

  if (!scheduled) {
    scheduled = true;

    requestAnimationFrame(() => {
      assistantText += pending;
      pending = "";
      scheduled = false;
      setAssistantText(assistantText);
    });
  }
}

이렇게 하면 출력이 실시간으로 표시되는 느낌을 유지하면서 인터페이스를 부드럽게 만들 수 있습니다.


부분 출력을 올바르게 처리하기

스트리밍에서는 애플리케이션이 불완전한 텍스트를 받습니다. 당연한 이야기처럼 들리지만, 부분 출력을 최종 출력으로 취급해서 생기는 버그가 많습니다.

Markdown은 일시적으로 유효하지 않을 수 있습니다

스트리밍 중에는 Markdown 형식이 올바르지 않을 수 있습니다.

Here is the query:

```sql
SELECT *
FROM users
WHERE

코드 펜스가 아직 닫히지 않았을 수 있습니다. 표에는 헤더 행만 있을 수도 있습니다. 링크는 절반만 출력된 상태일 수 있습니다. 렌더러는 이런 상태를 처리할 수 있어야 합니다.

채팅 UI에서는 다음 방식을 선호합니다.

  • Markdown을 점진적으로 렌더링하되, 불완전한 입력에도 안전하게 처리합니다.
  • 위험한 HTML을 비활성화합니다.
  • 메시지가 완성될 때까지 비용이 많이 드는 구문 강조 처리를 피합니다.
  • 눈에 거슬리지 않는 “생성 중” 상태를 표시합니다.
  • 스트림이 끝난 후 최종 서식 처리를 다시 실행합니다.

JSON 스트리밍은 더 어렵습니다

모델에 JSON을 요청하고 스트리밍하면 중간 상태는 대개 유효하지 않은 JSON입니다. 이는 구조화된 UI를 실시간으로 업데이트하려는 앱에 문제가 됩니다.

예를 들면:

{
  "title": "Incident Summary",
  "severity": "high",
  "steps": [
    "Restart

아직은 이를 파싱할 수 없습니다.

일반적으로 사용하는 접근 방식은 세 가지입니다.

  1. 완료될 때까지 기다리기, 그런 다음 한 번만 파싱합니다.
  2. 줄 단위로 구분된 JSON 사용하기, 이 경우 완성된 각 줄을 독립적으로 파싱할 수 있습니다.
  3. 의미 단위의 이벤트 스트리밍하기, 원시 JSON 대신 사용합니다.

구조화된 스트리밍에는 이벤트 형식의 출력을 선호합니다.

event: section
data: {"title":"Summary"}

event: bullet
data: {"text":"Database failover started at 09:42 UTC."}

event: bullet
data: {"text":"Read latency remained elevated for 6 minutes."}

이 패턴은 더 많은 오케스트레이션이 필요하지만, 안정적으로 렌더링하기가 훨씬 쉽습니다.


사용자가 “중지”를 누르면 실제로 일어나는 일

취소 기능은 스트리밍의 장점 중 충분히 주목받지 못하는 기능입니다.

비스트리밍 요청에서는 사용자가 페이지를 떠나도 모델이 서버에서 계속 생성할 수 있습니다. 제공업체와 백엔드에 따라 이 생성 과정에서 출력 토큰이 계속 소비될 수 있습니다.

스트리밍에서는 클라이언트가 HTTP 요청을 중단할 수 있습니다.

const controller = new AbortController();

fetch("/api/chat/stream", {
  method: "POST",
  body: JSON.stringify(payload),
  signal: controller.signal
});

// Later
controller.abort();

하지만 취소가 항상 전체 경로에 걸쳐 즉시 이루어지는 것은 아닙니다.

여러 계층이 있습니다.

  • 브라우저가 fetch를 중단합니다.
  • 백엔드가 연결 종료를 감지합니다.
  • 백엔드가 업스트림 제공업체 요청을 취소합니다.
  • 제공업체가 생성을 중지합니다.
  • 이미 처리하거나 생성한 토큰에 대해 사용량을 계산합니다.

흔히 놓치는 함정이 있습니다. 백엔드가 스트림을 프록시하면서 취소를 업스트림으로 전파하지 않으면, 사용자의 브라우저는 연결을 끊어도 서버는 완료될 때까지 모델의 출력을 계속 읽습니다.

Node 스타일의 의사 코드로 표현하면 다음과 같습니다.

app.post("/api/chat/stream", async (req, res) => {
  const upstreamController = new AbortController();

  req.on("close", () => {
    upstreamController.abort();
  });

  const upstream = await fetch(PROVIDER_URL, {
    method: "POST",
    headers: providerHeaders,
    body: JSON.stringify({
      ...req.body,
      stream: true
    }),
    signal: upstreamController.signal
  });

  res.setHeader("Content-Type", "text/event-stream");

  for await (const chunk of upstream.body) {
    res.write(chunk);
  }

  res.end();
});

이것이 “UI가 멈췄다”와 “모델이 멈췄다”의 차이입니다.


스트리밍과 비용: 바뀌는 것과 바뀌지 않는 것

스트리밍은 사용량을 확인하는 시점을 바꿉니다. 일반적으로 과금의 기본 단위는 바꾸지 않습니다.

대부분의 LLM API는 다음과 같은 항목을 기준으로 요금을 부과합니다.

  • 입력 토큰.
  • 출력 토큰 수.
  • 프롬프트 캐싱이 적용되는 경우 캐시된 입력 토큰.
  • API에 따라 도구 호출 또는 특수 모달리티.
  • 경우에 따라 특정 모델 계열의 추론 토큰 또는 내부적으로 예산이 할당된 연산량.

스트리밍 자체가 일반적으로 할인을 제공하는 것은 아닙니다. 모델이 출력 토큰 700개를 생성한다면, 점진적으로 도착하든 하나의 최종 페이로드로 도착하든 출력 토큰 700개를 사용량에 반영해야 한다고 예상해야 합니다.

스트리밍이 비용에 영향을 미치는 부분은 운영 측면입니다.

시나리오비용에 미치는 영향
사용자가 토큰 80개 이후 취소취소가 전파되면 나머지 출력 생성을 피할 수 있습니다
UI가 충분한 답변을 조기에 감지앱이 의도적으로 생성을 중지할 수 있습니다
백엔드가 업스트림 요청을 중단하지 못함클라이언트 연결이 끊겨도 전체 생성 비용을 지불할 수 있습니다
사용량이 최종 이벤트에서만 전달됨중단된 스트림은 제공업체 측 로그나 추정치가 필요할 수 있습니다
긴 답변을 기본적으로 스트리밍사용 경험이 더 매끄럽게 느껴져 사용자가 더 많이 소비할 수 있습니다
텍스트 스트리밍 전에 도구 호출TTFT은 증가할 수 있지만 출력 토큰 비용은 그대로일 수 있습니다

사용량 집계 시 주의할 점

많은 스트리밍 API는 스트림 끝에서 사용량 메타데이터를 보냅니다.

{
  "type": "message.done",
  "usage": {
    "input_tokens": 1240,
    "output_tokens": 389
  },
  "finish_reason": "stop"
}

최종 이벤트 전에 스트림이 끊기면 애플리케이션이 사용량을 받지 못할 수 있습니다. 그렇다고 사용량이 반드시 없다는 뜻은 아닙니다.

내부 집계를 위해 저는 보통 세 가지 필드를 저장합니다.

{
  "request_id": "chat_01j...",
  "status": "client_aborted",
  "usage": null,
  "estimated_output_chars": 1842,
  "final_usage_received": false
}

그런 다음 제공업체가 요청 로그나 사용량 이벤트를 제공하면 나중에 대조해 정산합니다. 제공하지 않는 경우에도 최소한 제품 분석용으로 대략 추정할 수 있으며, 과금 시 근거 없는 정밀도를 제시하는 일을 피할 수 있습니다.

약관에서 명시적으로 허용하지 않는 한 문자 수 추정치로 최종 사용자에게 요금을 청구하지 마세요. 토큰화 방식은 모델과 언어에 따라 다릅니다.


백엔드 아키텍처 패턴

프로덕션 환경의 스트리밍 경로는 일반적으로 다음과 같습니다.

Browser
  |
  | POST /api/chat/stream
  v
App Backend
  |
  | provider streaming request
  v
LLM API

저는 제공업체 키를 브라우저에 직접 노출하는 경우가 거의 없습니다. 백엔드를 사용하면 다음 기능을 제공할 수 있습니다.

  • 인증 및 권한 부여.
  • 사용자 또는 워크스페이스별 요청 속도 제한.
  • 프롬프트 구성 및 검색.
  • 도구 실행.
  • 민감 정보 가림 처리 및 로깅 제어.
  • 제공업체 장애 시 대체 제공업체로 전환.
  • 취소 전파.
  • 사용량 기록.

Python FastAPI 백엔드의 최소한의 스트리밍 응답은 다음과 같습니다.

import json
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
import httpx

app = FastAPI()

PROVIDER_URL = "https://api.example.com/v1/chat/completions"

@app.post("/api/chat/stream")
async def chat_stream(request: Request):
    body = await request.json()

    async def event_generator():
        async with httpx.AsyncClient(timeout=None) as client:
            async with client.stream(
                "POST",
                PROVIDER_URL,
                headers={
                    "Authorization": "Bearer YOUR_PROVIDER_KEY",
                    "Content-Type": "application/json",
                    "Accept": "text/event-stream",
                },
                json={
                    **body,
                    "stream": True,
                },
            ) as upstream:
                async for line in upstream.aiter_lines():
                    if await request.is_disconnected():
                        break

                    if not line:
                        continue

                    # Optionally transform provider-specific events here.
                    yield line + "\n\n"

    return StreamingResponse(
        event_generator(),
        media_type="text/event-stream",
        headers={
            "Cache-Control": "no-cache",
            "X-Accel-Buffering": "no",
        },
    )

실제로 저는 보통 제공업체 이벤트를 하나의 내부 스키마로 정규화합니다.

{
  "type": "delta",
  "text": "partial text here"
}

그런 다음:

{
  "type": "done",
  "finish_reason": "stop",
  "usage": {
    "input_tokens": 812,
    "output_tokens": 436
  }
}

하나의 인터페이스에서 Claude, GPT, Gemini, Llama, Qwen, Kimi 같은 여러 모델을 지원한다면 이것이 중요합니다. 제공업체마다 스트림의 동작 방식이 조금씩 다릅니다. 초기에 정규화하면 프런트엔드를 단순하게 유지할 수 있으며, 이것이 바로 원하는 결과입니다.


TTFT은 모델만의 문제가 아닙니다

TTFT이 좋지 않으면 사람들은 흔히 모델부터 탓합니다. 때로는 그 판단이 맞습니다. 더 큰 모델, 더 긴 컨텍스트, 더 많은 연산이 필요한 추론 모드는 첫 토큰 지연 시간을 늘릴 수 있습니다. 하지만 첫 토큰이 나오기 전에는 많은 처리 과정이 있습니다.

영향을 미치는 요인은 다음과 같습니다.

  • TLS 연결 설정.
  • 백엔드의 콜드 스타트.
  • 프롬프트 구성 및 검색.
  • 벡터 데이터베이스 지연 시간.
  • 생성 전 도구 호출.
  • 제공업체 측 대기열 처리.
  • 긴 입력 컨텍스트 수집 및 처리.
  • 안전성 검사 또는 정책 계층.
  • 프록시 버퍼링.
  • 클라이언트 렌더링 지연.

실무에서 사용하는 트레이스는 다음 항목을 구분해야 합니다.

{
  "trace_id": "req_abc",
  "timings_ms": {
    "auth": 12,
    "retrieval": 84,
    "prompt_build": 9,
    "provider_connect": 141,
    "ttfb": 612,
    "first_delta": 804,
    "stream_complete": 5820
  }
}

검색에 600ms가 걸리고 모델의 첫 델타가 그로부터 250ms 후에 도착한다면, 모델을 바꿔도 UX가 개선되지 않을 수 있습니다. 제공업체 연결 설정이 대부분의 시간을 차지한다면 연결 재사용이 도움이 될 수 있습니다. 프런트엔드가 문단 전체가 완성될 때까지 렌더링을 기다린다면, 스트림은 정상 작동하지만 UI 정책이 이를 숨기고 있는 것입니다.


스트리밍을 사용하지 않아야 할 때

스트리밍이 항상 그만한 가치가 있는 것은 아닙니다.

저는 다음과 같은 경우 스트리밍을 사용하지 않습니다.

  • 분류나 라우팅처럼 응답이 매우 짧은 경우.
  • 호출자가 완전한 JSON이 필요한 다른 서비스인 경우.
  • 출력을 표시하기 전에 전체 콘텐츠 검토를 거쳐야 하는 경우.
  • 요청이 작업 큐나 배치 파이프라인에서 실행되는 경우.
  • 프레임워크나 호스팅 계층 때문에 스트리밍의 안정성이 떨어지는 경우.
  • 제품에 점진적으로 표시되는 텍스트가 아니라 완결된 답변이 필요한 경우.

UX 측면의 단점도 있습니다. 스트리밍은 모델이 답변 도중 방향을 바꾸는 모습을 드러낼 수 있습니다. 사용자는 채팅에서 이런 현상을 어느 정도 용인하지만, 중요한 판단이 걸린 워크플로에서는 완성도가 떨어져 보일 수 있습니다. 법률, 의료, 금융 또는 규정 준수 요구가 많은 인터페이스에서는 가드레일을 통과한 후에만 스트리밍하거나, 내부 버퍼로 스트리밍한 뒤 안전한 청크를 공개하는 방식을 선택할 수 있습니다.


실용적인 클라이언트 측 패턴

대부분의 채팅 제품에 권장하는 패턴은 다음과 같습니다.

1. 텍스트가 도착하기 전에 상태 표시하기

어시스턴트 말풍선을 비워 두지 마세요. 명확한 상태를 표시하세요.

  • “맥락을 읽는 중…”
  • “문서를 검색하는 중…”
  • “답변을 작성하는 중…”
  • “계속 작업 중…”

이는 텍스트 생성 전에 도구 호출이 이루어질 때 특히 유용합니다.

2. 실제로 작동하는 중지 버튼 제공하기

중지 버튼은 단순한 UX 장식이 아닙니다. 올바르게 구현하면 출력 토큰을 절약할 수 있습니다.

다음을 확인하세요.

  • 브라우저가 요청을 중단합니다.
  • 백엔드가 연결 종료를 감지합니다.
  • 백엔드가 제공업체로 보낸 요청을 중단합니다.
  • 요청을 취소된 것으로 기록합니다.

3. 점진적으로 렌더링하고 마지막에 서식 적용하기

스트리밍 중에는:

  • 텍스트를 추가합니다.
  • Markdown을 가볍게 렌더링합니다.
  • 처리 비용이 큰 구문 강조는 피합니다.
  • 스크롤 동작을 예측 가능하게 유지합니다.

완료 후에는:

  • Markdown을 다시 파싱합니다.
  • 코드에 구문 강조를 적용합니다.
  • 복사 버튼을 활성화합니다.
  • 시스템에서 지원한다면 인용이나 참조를 첨부합니다.

4. 부분 메시지를 신중하게 저장하기

스트림이 중간에 실패하면 대화 기록을 어떻게 처리할지 결정하세요.

선택지:

  • 어시스턴트의 부분 메시지를 “중단됨” 표시와 함께 유지합니다.
  • 완전히 제거합니다.
  • 사용자가 같은 프롬프트로 다시 시도할 수 있게 합니다.
  • 모델에 부분적인 맥락을 이어서 생성하도록 요청합니다.

저는 보통 부분 메시지를 눈에 띄게 표시한 채로 유지합니다. 알림 없이 삭제하면 사용자가 혼란스러워하고, 완료된 것처럼 표시하면 더 큰 문제가 됩니다.

5. 이벤트 정규화하기

프런트엔드가 제공업체별로 서로 다른 다섯 가지 스트리밍 형식을 알아야 해서는 안 됩니다. 백엔드에서 정규화하세요.

{"type":"delta","text":"Hello"}
{"type":"tool_start","name":"search_docs"}
{"type":"tool_result","name":"search_docs","status":"ok"}
{"type":"done","finish_reason":"stop","usage":{"input_tokens":100,"output_tokens":50}}
{"type":"error","message":"upstream timeout"}

이렇게 하면 모델 계열을 전환하거나 요청을 동적으로 라우팅하기도 쉬워집니다. 여러 모델을 지원하는 API 계층을 사용한다면 이러한 정규화가 이미 게이트웨이에 포함되어 있을 수 있습니다. 그렇지 않다면 직접 구축할 가치가 있습니다.


실무 핵심 요점

  • 스트리밍은 전체 생성 과정의 비용을 없애거나 즉시 완료되게 하는 것이 아니라, 첫 출력이 화면에 표시되기까지의 시간을 줄여 체감 지연을 개선합니다.
  • TTFT, 전체 완료 시간, 초당 토큰 수를 각각 측정하세요.
  • SSE는 일반적으로 LLM 텍스트 스트리밍에 가장 간단한 전송 방식입니다. 다음을 사용하세요. fetch() POST 본문이 필요할 때는 스트림을 사용하세요.
  • 프록시와 프레임워크의 버퍼링에 주의하세요. 많은 “스트리밍 버그”는 인프라의 버퍼링 버그입니다.
  • 부분 출력은 유효하지 않은 것으로 취급하세요. Markdown이 불완전할 수 있고, JSON이 파싱되지 않을 수 있으며, 코드 블록이 아직 닫히지 않았을 수 있습니다.
  • 취소로 비용을 절약하려면 중단 신호가 브라우저에서 백엔드를 거쳐 제공업체까지 전달되어야 합니다.
  • 사용량 메타데이터는 스트림의 마지막에 도착하는 경우가 많으므로, 중단된 스트림의 사용량은 신중하게 집계해야 합니다.
  • 아주 작은 청크가 도착할 때마다 UI 전체를 다시 렌더링하지 마세요. 애니메이션 프레임을 사용해 업데이트를 묶어 처리하세요.
  • 제공업체별 스트림 이벤트는 백엔드 내부에서 정규화하세요.
  • 짧은 분류 작업, 배치 작업, 엄격한 구조화 출력 또는 표시 전에 전체 콘텐츠 검토가 필요한 워크플로에서는 스트리밍을 사용하지 마세요.
A

이 블로그는 AI Prime Tech가 발행합니다. 기사는 AI의 도움을 받아 초안을 작성합니다. 모델 ID, 가격, 한도 등 버전별 세부 정보는 활용하기 전에 공식 문서와 대조해 확인하세요.

더 저렴하게 Claude API 이용하기

API 키 하나로 Claude Opus 5.5, Sonnet 5, Haiku 4.5, Fable 5.1 및 GPT-6개 모델을 이용하세요. 구독 없이 사용한 만큼만 결제합니다.

API 키 받기 →
AI Prime Tech는 독립적인 타사 API 게이트웨이입니다. Claude™ 및 Anthropic®는 Anthropic, PBC의 상표입니다. 제휴 또는 보증 관계를 의미하지 않습니다.