AI 응답 스트리밍 UI를 구현하는 패턴

LLM 응답은 모델이 토큰을 하나씩 생성하므로, 전체 문장이 완성될 때까지 기다렸다가 한 번에 보여 주면 사용자는 수 초간 빈 화면을 마주합니다. 스트리밍 UI는 생성되는 토큰을 즉시 클라이언트로 흘려보내 화면에 누적 표시해, 실제 처리 시간은 같아도 체감 속도를 크게 끌어올립니다. 구현의 핵심은 세 가지입니다. 서버가 응답을 끊어서 보내는 전송 채널(주로 SSE), 클라이언트가 조각을 받아 이어 붙이는 누적 렌더링, 그리고 중단·에러·마크다운 부분 파싱 같은 경계 상황 처리입니다. 아래에서 프로토콜 선택부터 함정까지 순서대로 정리합니다.

스트리밍이 체감 속도를 높이는 이유

완성형 응답 방식에서 사용자가 느끼는 대기 시간은 모델이 마지막 토큰까지 생성하는 데 걸리는 전체 시간입니다. 답변이 길수록 이 시간은 길어지고, 그동안 화면에는 로딩 스피너만 돕니다. 반면 스트리밍은 첫 토큰이 만들어지는 순간부터 글자를 보여 주므로, 사용자가 체감하는 응답 시작 시점이 첫 토큰까지의 지연, 즉 TTFT(Time To First Token)로 당겨집니다. 전체 생성 시간이 동일해도 사용자는 “즉시 반응한다”고 느낍니다.

이것은 단순한 착시가 아니라 인지 부하의 문제입니다. 사람은 글이 흘러나오기 시작하면 그 자체를 진행 신호로 받아들여 기다림을 견딥니다. 빈 화면에서 5초를 기다리는 것과, 첫 글자가 0.5초 만에 나오고 나머지가 흐르는 것은 측정상 같은 5초라도 만족도가 전혀 다릅니다. 특히 답변이 수백 토큰을 넘어가는 대화형 서비스에서는 스트리밍 적용 여부가 제품 완성도를 가르는 기본 요소가 됩니다.

다만 스트리밍이 만능은 아닙니다. 짧은 분류 결과나 JSON 한 덩어리처럼 부분적으로 보여 줄 의미가 없는 출력은 스트리밍의 이점이 적고, 오히려 부분 파싱 부담만 늘립니다. 사람이 읽어 내려가는 긴 산문형 답변일 때 효과가 가장 큽니다.

전송 채널 — SSE와 WebSocket

서버가 토큰을 끊어서 보내려면 단방향이든 양방향이든 지속 연결이 필요합니다. 대표적인 두 선택지가 Server-Sent Events(SSE)와 WebSocket입니다. 둘은 목적이 다르므로 LLM 스트리밍에는 보통 SSE가 더 적합합니다.

SSE는 HTTP 위에서 동작하는 서버→클라이언트 단방향 스트림입니다. 응답 헤더를 text/event-stream으로 두고 연결을 열어 둔 채 데이터를 한 줄씩 밀어냅니다. 브라우저에는 EventSource라는 표준 API가 있고(MDN Server-sent events 문서 참고), 자동 재연결과 이벤트 ID 기반 복구가 기본 제공됩니다. LLM 응답은 본질적으로 “서버가 만든 토큰을 일방적으로 받는” 구조라 단방향 SSE와 잘 맞습니다.

WebSocket은 양방향 풀듀플렉스 연결입니다. 클라이언트도 수시로 서버에 메시지를 보내야 하는 협업 편집, 실시간 게임, 음성처럼 양쪽이 동시에 말하는 시나리오에 강합니다. 하지만 단순 텍스트 스트리밍에는 프로토콜이 무겁고, 재연결·하트비트·프레이밍을 직접 관리해야 하는 부담이 있습니다.

항목SSEWebSocket
방향서버→클라이언트 단방향양방향
기반 프로토콜HTTP별도 업그레이드(ws/wss)
자동 재연결EventSource 기본 제공직접 구현
프록시·인프라 호환HTTP라 무난중간 장비 설정 필요할 때 있음
LLM 텍스트 스트리밍 적합도높음과한 경우 많음
클라이언트→서버 동시 통신불가(별도 요청)가능

한 가지 주의할 점은 표준 EventSource가 GET만 지원하고 커스텀 헤더를 붙이기 어렵다는 것입니다. 인증 토큰을 헤더로 보내거나 POST 본문에 긴 프롬프트를 실어야 한다면, EventSource 대신 fetch의 응답 본문 스트림(Streams API)을 직접 읽어 SSE 형식을 파싱하는 방식을 많이 씁니다. 이 경우 자동 재연결은 직접 구현해야 합니다.

서버에서 토큰 단위로 전송하기

서버 쪽 역할은 모델 SDK가 주는 스트림을 받아, 클라이언트가 읽을 수 있는 형식으로 다시 흘려보내는 것입니다. LLM 제공사 SDK는 대부분 스트리밍 모드를 지원해, 응답을 이터러블이나 비동기 제너레이터로 돌려줍니다. 서버는 이 조각을 받아 SSE 라인 형식(data: ...nn)으로 감싸 응답 본문에 써 넣습니다.

// 서버: 모델 스트림을 SSE로 중계 (개념 예시, 웹 표준 Response 기준)
export async function POST(req) {
  const { messages } = await req.json();
  const modelStream = await llm.chat({ messages, stream: true });

  const encoder = new TextEncoder();
  const body = new ReadableStream({
    async start(controller) {
      try {
        for await (const chunk of modelStream) {
          const token = chunk.delta ?? "";
          if (token) {
            // SSE 한 이벤트 = "data: nn"
            controller.enqueue(
              encoder.encode(`data: ${JSON.stringify({ token })}nn`)
            );
          }
        }
        controller.enqueue(encoder.encode(`data: [DONE]nn`));
      } catch (err) {
        controller.enqueue(
          encoder.encode(`event: errorndata: ${JSON.stringify({ message: String(err) })}nn`)
        );
      } finally {
        controller.close();
      }
    },
  });

  return new Response(body, {
    headers: {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache, no-transform",
      "Connection": "keep-alive",
    },
  });
}

여기서 두 가지가 중요합니다. 첫째, 토큰을 그냥 텍스트로 보내지 말고 JSON으로 감싸면 개행·특수문자가 SSE 라인 규칙을 깨뜨리는 사고를 막을 수 있습니다. 둘째, Cache-Controlno-transform을 넣어 중간 프록시가 응답을 버퍼링·압축하지 않도록 막아야 합니다. 일부 리버스 프록시나 게이트웨이는 기본적으로 응답을 모아서 한 번에 내보내는데, 그러면 스트리밍이 무력화되어 결국 완성형처럼 동작합니다.

또한 서버리스·엣지 환경에서는 응답을 끊지 않고 흘려보내는 것이 런타임에 따라 제약이 있을 수 있으므로, 스트리밍 응답을 공식 지원하는지 배포 대상 문서를 확인하는 편이 안전합니다.

클라이언트 누적 렌더링

클라이언트는 도착한 조각을 상태에 이어 붙이고 화면을 갱신합니다. 핵심은 “전체 텍스트를 누적하는 변수 하나”를 두고, 새 토큰이 올 때마다 거기에 더한 뒤 그 누적값을 렌더링하는 것입니다. 토큰을 개별 DOM 노드로 추가하기보다, 누적 문자열을 통째로 다시 렌더링하는 편이 상태 관리가 단순하고 버그가 적습니다.

// 클라이언트: fetch 스트림을 읽어 SSE 파싱 후 누적
async function streamChat(messages, onToken) {
  const res = await fetch("/api/chat", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ messages }),
  });
  const reader = res.body.getReader();
  const decoder = new TextDecoder();
  let buffer = "";
  let full = "";

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

    // 완성된 이벤트(빈 줄 구분)만 처리, 나머지는 버퍼에 보관
    const events = buffer.split("nn");
    buffer = events.pop() ?? "";
    for (const evt of events) {
      const line = evt.replace(/^data: /, "");
      if (line === "[DONE]") return full;
      const { token } = JSON.parse(line);
      full += token;     // 누적
      onToken(full);     // 누적값으로 렌더
    }
  }
  return full;
}

여기서 자주 빠뜨리는 부분이 경계에 걸친 조각의 버퍼링입니다. 네트워크는 SSE 이벤트 단위로 데이터를 끊어 주지 않습니다. 한 번의 read()가 이벤트 중간에서 잘릴 수도 있고, 여러 이벤트가 한꺼번에 올 수도 있습니다. 그래서 받은 바이트를 버퍼에 모은 뒤 이벤트 구분자(빈 줄)로 잘라 완성된 것만 처리하고, 잘린 마지막 조각은 다음 read()까지 버퍼에 남겨 둬야 합니다. 이 처리를 생략하면 JSON 파싱이 가끔 깨지는, 재현이 어려운 버그가 생깁니다.

렌더링 빈도도 신경 써야 합니다. 토큰 하나마다 무거운 리렌더를 트리거하면, 초당 수십 개씩 들어오는 토큰이 메인 스레드를 압박해 입력 반응이 끊깁니다. 일정 시간 간격이나 일정 글자 수마다 묶어서 갱신하거나, 애니메이션 프레임에 맞춰 갱신을 모으면 부드럽게 유지됩니다.

중단과 에러 처리

사용자가 “그만”을 누르거나 페이지를 떠나면, 진행 중인 요청을 실제로 끊어야 합니다. 화면만 멈추고 서버 연결이 살아 있으면 토큰은 계속 생성되어 비용이 청구되고 리소스가 낭비됩니다. 표준 AbortControllerfetch에 연결해 두면 중단 시 연결을 닫을 수 있습니다.

const controller = new AbortController();
fetch("/api/chat", { signal: controller.signal, /* ... */ });
// 중단 버튼
stopBtn.onclick = () => controller.abort();

에러는 두 층위로 나눠 다뤄야 합니다. 하나는 연결이 끊기거나 타임아웃되는 전송 계층 에러이고, 다른 하나는 모델이 도중에 실패하거나 콘텐츠 필터에 걸려 스트림 중간에 오류 이벤트를 보내는 애플리케이션 계층 에러입니다. 앞서 서버 예시에서 event: error로 별도 이벤트를 흘려보낸 이유가 이것입니다. 클라이언트는 이미 일부 텍스트를 보여 준 상태에서 에러가 날 수 있으므로, “지금까지 받은 내용은 유지하되 끝에 실패 표시를 덧붙이는” 식의 부분 실패 UX를 설계해 두면 사용자 혼란이 적습니다.

화면을 멈추는 것과 요청을 끊는 것은 다릅니다.

중단 버튼이 단지 렌더링 루프만 멈추게 만들어져 있으면, 서버에서는 응답 생성이 끝까지 진행되어 토큰 비용이 그대로 발생합니다. 반드시 AbortController로 네트워크 연결을 끊고, 서버 측에서도 클라이언트 연결 종료를 감지해 모델 호출을 취소하도록 연결해야 실제로 비용이 멈춥니다.

마크다운 점진 렌더링의 함정

LLM 답변은 대부분 마크다운입니다. 그런데 마크다운을 토큰이 올 때마다 곧바로 HTML로 변환하면, 아직 닫히지 않은 문법 때문에 화면이 깨집니다. 코드 블록을 여는 ```이 도착했는데 닫는 부분이 아직 안 왔다면, 파서는 그 시점에서 문서를 어떻게 해석해야 할지 알 수 없습니다.

미완성 문법증상대응
열린 코드펜스 “`이후 전체가 코드로 보이거나 깨짐닫힘 전까지 임시로 닫아 렌더
잘린 표 행표 레이아웃 붕괴완성된 행만 표로, 나머지는 텍스트
열린 강조/링크 [..(괄호·기호 노출관용적 파서로 부분 허용
잘린 목록 항목마지막 항목 깜빡임스트림 종료 시 최종 재렌더

실무 대응은 크게 세 가지입니다. 첫째, 깨진 입력에 관대한(부분 마크다운을 견디는) 파서를 쓰는 것입니다. 둘째, 렌더링 직전에 열린 코드펜스 개수가 홀수면 임시 닫는 펜스를 붙여 깨짐을 막고, 다음 토큰에서 다시 계산하는 것입니다. 셋째, 스트리밍 중에는 가볍게 보여 주다가 스트림이 끝나면 전체 텍스트로 한 번 더 정식 렌더링해 최종 형태를 확정하는 것입니다. 세 방법을 섞어 쓰면 화면 깜빡임을 줄이면서도 최종 결과가 정확합니다.

또 하나, 토큰 경계가 글자 경계와 일치하지 않을 수 있다는 점도 한글·이모지에서 문제가 됩니다. UTF-8 멀티바이트 문자가 두 청크에 걸쳐 잘리면 깨진 글자가 잠깐 보입니다. 디코더의 스트림 모드(decode(value, { stream: true }))를 쓰면 바이트 경계 문제는 상당 부분 해결됩니다.

백프레셔 — 생산과 소비 속도의 불일치

백프레셔는 데이터를 만드는 쪽이 소비하는 쪽보다 빠를 때 생기는 압력입니다. 스트리밍에서는 서버가 토큰을 내보내는 속도, 네트워크 전송 속도, 클라이언트가 렌더링하는 속도가 서로 다를 수 있습니다. 소비가 느린데 생산을 막지 않으면 중간 버퍼가 계속 부풀어 메모리를 잡아먹습니다.

다행히 표준 Streams API는 백프레셔를 구조적으로 다룹니다. ReadableStream의 컨트롤러는 큐가 가득 차면 신호를 주고, reader.read()가 호출되어 소비될 때까지 생산을 자연스럽게 늦춥니다. 서버에서 controller.enqueue()로 마구 밀어 넣기보다, 소비 속도에 맞춰 흘려보내는 구조를 따르면 됩니다. 클라이언트 쪽에서는 앞서 말한 렌더 묶음(batching)이 곧 소비 속도 조절 장치 역할을 합니다.

실무에서 백프레셔 문제가 드러나는 전형은, 사용자가 탭을 백그라운드로 보냈을 때입니다. 브라우저가 비활성 탭의 타이머·렌더링을 늦추면 소비가 급격히 느려지는데, 이때 누적 버퍼가 커지지 않도록 데이터 구조를 단순 누적 문자열로 유지하고 중간 가공을 최소화하는 것이 안전합니다.

구현 점검 체크리스트

  • 서버 응답 헤더에 text/event-streamno-transform이 들어가 프록시 버퍼링을 막았는가
  • 받은 청크를 버퍼에 모아 이벤트 구분자로 자르고, 잘린 마지막 조각을 다음까지 보관하는가
  • 토큰마다가 아니라 일정 간격·프레임 단위로 묶어 렌더링하는가
  • 중단 버튼이 AbortController로 실제 연결을 끊고 서버 호출도 취소하는가
  • 마크다운은 스트리밍 중 관대 렌더 + 종료 후 정식 재렌더로 이중 처리하는가
  • 스트림 중간 에러를 별도 이벤트로 받아 부분 실패 UX로 표시하는가

자주 묻는 질문

SSE와 WebSocket 중 무엇을 써야 하나요?
서버가 토큰을 일방적으로 흘려보내는 LLM 텍스트 응답이라면 단방향 SSE가 보통 더 단순하고 적합합니다. 음성·협업 편집처럼 클라이언트도 동시에 계속 데이터를 보내야 하는 양방향 시나리오에서만 WebSocket의 이점이 분명해집니다.
표준 EventSource로 충분한가요?
GET 요청에 인증·본문 제약이 없다면 자동 재연결까지 주는 EventSource가 편합니다. 다만 POST로 긴 프롬프트를 보내거나 커스텀 헤더로 토큰을 실어야 하면 fetch + Streams API로 직접 SSE를 파싱하는 방식이 일반적이며, 이때 재연결은 직접 구현해야 합니다.
스트리밍을 켰는데 한꺼번에 몰려서 나옵니다.
중간 프록시나 게이트웨이가 응답을 버퍼링하는 경우가 가장 흔합니다. 서버 응답에 Cache-Control: no-transform을 두고, 압축·버퍼링 설정을 끄세요. 서버리스·엣지 런타임이 스트리밍을 지원하는지도 함께 확인해야 합니다.
중단을 눌러도 비용이 계속 나갑니다.
화면만 멈추고 네트워크 연결이 유지되면 서버는 생성을 끝까지 진행합니다. AbortController.abort()로 연결을 끊고, 서버에서도 연결 종료를 감지해 모델 호출을 취소해야 실제 토큰 생성이 멈춥니다.
스트리밍 중 마크다운이 깜빡이며 깨집니다.
닫히지 않은 코드펜스·표 때문입니다. 부분 입력에 관대한 파서를 쓰고, 열린 펜스를 임시로 닫아 렌더한 뒤 스트림 종료 시 전체를 한 번 더 정식 렌더링하면 깜빡임을 줄이면서 최종 결과를 정확히 맞출 수 있습니다.

이 글은 MDN과 WHATWG의 SSE·Streams API 표준, 주요 LLM 제공사의 스트리밍 API 문서에 기술된 일반 동작을 정리한 것입니다. 런타임의 스트리밍 지원 범위, 프록시·게이트웨이의 버퍼링 동작, 제공사별 SSE 이벤트 형식은 환경에 따라 다르므로 실제 적용 전 각 플랫폼의 공식 문서를 확인하시기 바랍니다. 코드는 원리 이해를 위한 개념 예시입니다.

위로 스크롤