LLM 스트리밍 응답 처리: UX를 개선하는 청크 처리와 중단 전략

AI

LLM 스트리밍SSE스트리밍 UXReactAbortController

이 글은 누구를 위한 것인가

  • LLM API를 연동하고 응답을 스트리밍으로 보여주고 싶은 프론트엔드·풀스택 개발자
  • ChatGPT처럼 글자가 실시간으로 나타나는 UX를 구현하려는 팀
  • 스트리밍 중 사용자가 "중단" 버튼을 눌렀을 때 안전하게 처리하는 방법이 필요한 엔지니어

들어가며

LLM 응답이 전체가 완성될 때까지 기다리는 방식은 사용자 경험에 치명적이다. GPT-4o 기준 한 응답이 완성되는 데 10~30초가 걸릴 수 있고, 그동안 화면에 아무것도 안 나타나면 사용자는 "고장났나?"라고 느낀다.

스트리밍을 쓰면 첫 글자가 0.3~0.5초 안에 나타나고, 사용자는 응답이 진행 중임을 즉각 확인할 수 있다. 체감 응답속도가 10배 이상 빨라진다.

이 글은 bluefoxdev.kr의 LLM API 연동 실전 가이드 를 참고하고, 스트리밍 UX 구현 관점에서 확장하여 작성했습니다.


1. 스트리밍 전송 방식 비교

1.1 SSE vs WebSocket

[SSE (Server-Sent Events)]
클라이언트 ──────────────────→ 서버 (HTTP 요청 1회)
클라이언트 ←── chunk1 ←── 서버
클라이언트 ←── chunk2 ←── 서버
클라이언트 ←── [DONE] ←── 서버
단방향, HTTP/1.1 호환, 자동 재연결

[WebSocket]
클라이언트 ←──────────────→ 서버 (양방향 연결)
클라이언트 ←── chunk1 ←── 서버
클라이언트 ──→ "중단" ──→ 서버
양방향, 빠름, 별도 프로토콜 업그레이드 필요
항목SSEWebSocket
방향단방향 (서버→클라이언트)양방향
프로토콜HTTPws://
브라우저 지원광범위광범위
LLM 스트리밍적합과할 수 있음
중단 처리AbortController메시지 전송
프록시 친화성좋음설정 필요

LLM 텍스트 스트리밍은 SSE가 더 단순하고 적합하다.


2. 백엔드 스트리밍 구현

2.1 Next.js API Route (App Router)

// app/api/chat/route.ts
import Anthropic from '@anthropic-ai/sdk';
import { NextRequest } from 'next/server';

const client = new Anthropic();

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

  const encoder = new TextEncoder();
  
  const stream = new ReadableStream({
    async start(controller) {
      try {
        const anthropicStream = await client.messages.stream({
          model: 'claude-opus-4-7',
          max_tokens: 1024,
          messages,
        });

        for await (const chunk of anthropicStream) {
          if (chunk.type === 'content_block_delta' && 
              chunk.delta.type === 'text_delta') {
            const data = `data: ${JSON.stringify({ text: chunk.delta.text })}\n\n`;
            controller.enqueue(encoder.encode(data));
          }
        }

        controller.enqueue(encoder.encode('data: [DONE]\n\n'));
        controller.close();
      } catch (error) {
        controller.enqueue(
          encoder.encode(`data: ${JSON.stringify({ error: 'Stream failed' })}\n\n`)
        );
        controller.close();
      }
    },
  });

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

2.2 Express.js + OpenAI

// server/routes/chat.ts
import express from 'express';
import OpenAI from 'openai';

const router = express.Router();
const openai = new OpenAI();

router.post('/stream', async (req, res) => {
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');
  res.flushHeaders();

  const { messages } = req.body;

  try {
    const stream = await openai.chat.completions.create({
      model: 'gpt-4o',
      messages,
      stream: true,
    });

    for await (const chunk of stream) {
      const text = chunk.choices[0]?.delta?.content ?? '';
      if (text) {
        res.write(`data: ${JSON.stringify({ text })}\n\n`);
      }
    }

    res.write('data: [DONE]\n\n');
    res.end();
  } catch (error) {
    res.write(`data: ${JSON.stringify({ error: 'Stream error' })}\n\n`);
    res.end();
  }

  // 클라이언트 연결 끊기면 스트림 중단
  req.on('close', () => {
    stream?.controller.abort();
  });
});

3. 프론트엔드 스트리밍 소비

3.1 React Hook: useStreamingResponse

// hooks/useStreamingResponse.ts
import { useState, useRef, useCallback } from 'react';

interface UseStreamingOptions {
  onChunk?: (chunk: string) => void;
  onComplete?: (fullText: string) => void;
  onError?: (error: Error) => void;
}

export function useStreamingResponse(options: UseStreamingOptions = {}) {
  const [text, setText] = useState('');
  const [isStreaming, setIsStreaming] = useState(false);
  const [error, setError] = useState<Error | null>(null);
  const abortControllerRef = useRef<AbortController | null>(null);

  const startStream = useCallback(async (
    endpoint: string,
    body: object
  ) => {
    // 기존 스트림 중단
    abortControllerRef.current?.abort();
    abortControllerRef.current = new AbortController();

    setText('');
    setIsStreaming(true);
    setError(null);

    let fullText = '';

    try {
      const response = await fetch(endpoint, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(body),
        signal: abortControllerRef.current.signal,
      });

      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      if (!response.body) throw new Error('No response body');

      const reader = response.body.getReader();
      const decoder = new TextDecoder();

      while (true) {
        const { done, value } = await reader.read();
        if (done) break;

        const lines = decoder.decode(value).split('\n');

        for (const line of lines) {
          if (!line.startsWith('data: ')) continue;
          const data = line.slice(6);
          if (data === '[DONE]') break;

          try {
            const parsed = JSON.parse(data);
            if (parsed.text) {
              fullText += parsed.text;
              setText(fullText);
              options.onChunk?.(parsed.text);
            }
            if (parsed.error) throw new Error(parsed.error);
          } catch {
            // JSON 파싱 실패 무시 (빈 줄 등)
          }
        }
      }

      options.onComplete?.(fullText);
    } catch (err) {
      if (err instanceof Error && err.name === 'AbortError') return; // 사용자 중단
      const error = err instanceof Error ? err : new Error('Unknown error');
      setError(error);
      options.onError?.(error);
    } finally {
      setIsStreaming(false);
    }
  }, [options]);

  const abort = useCallback(() => {
    abortControllerRef.current?.abort();
    setIsStreaming(false);
  }, []);

  return { text, isStreaming, error, startStream, abort };
}

3.2 스트리밍 채팅 컴포넌트

// components/StreamingChat.tsx
import { useState } from 'react';
import { useStreamingResponse } from '@/hooks/useStreamingResponse';
import { StreamingMarkdown } from './StreamingMarkdown';

export function StreamingChat() {
  const [input, setInput] = useState('');
  const [messages, setMessages] = useState<Array<{role: string, content: string}>>([]);

  const { text, isStreaming, error, startStream, abort } = useStreamingResponse({
    onComplete: (fullText) => {
      setMessages(prev => [...prev, { role: 'assistant', content: fullText }]);
    },
  });

  const handleSubmit = async () => {
    if (!input.trim() || isStreaming) return;

    const userMessage = { role: 'user', content: input };
    const newMessages = [...messages, userMessage];
    setMessages(newMessages);
    setInput('');

    await startStream('/api/chat', { messages: newMessages });
  };

  return (
    <div className="flex flex-col h-screen">
      <div className="flex-1 overflow-y-auto p-4 space-y-4">
        {messages.map((msg, i) => (
          <div key={i} className={`p-3 rounded-lg ${
            msg.role === 'user' ? 'bg-blue-50 ml-8' : 'bg-gray-50 mr-8'
          }`}>
            {msg.content}
          </div>
        ))}

        {/* 현재 스트리밍 중인 응답 */}
        {isStreaming && (
          <div className="bg-gray-50 mr-8 p-3 rounded-lg">
            <StreamingMarkdown text={text} />
            <span className="inline-block w-1 h-4 bg-gray-400 animate-pulse ml-1" />
          </div>
        )}

        {error && (
          <div className="text-red-500 text-sm">오류: {error.message}</div>
        )}
      </div>

      <div className="p-4 border-t flex gap-2">
        <textarea
          value={input}
          onChange={(e) => setInput(e.target.value)}
          onKeyDown={(e) => {
            if (e.key === 'Enter' && !e.shiftKey) {
              e.preventDefault();
              handleSubmit();
            }
          }}
          placeholder="메시지를 입력하세요..."
          className="flex-1 border rounded p-2 resize-none"
          rows={2}
          disabled={isStreaming}
        />
        {isStreaming ? (
          <button
            onClick={abort}
            className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
          >
            중단
          </button>
        ) : (
          <button
            onClick={handleSubmit}
            disabled={!input.trim()}
            className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50"
          >
            전송
          </button>
        )}
      </div>
    </div>
  );
}

4. 마크다운 점진적 렌더링

스트리밍 중 마크다운을 렌더링할 때 코드 블록이 미완성 상태에서 깨지는 문제가 있다.

4.1 버퍼링 전략

// components/StreamingMarkdown.tsx
import { useMemo } from 'react';
import ReactMarkdown from 'react-markdown';

interface StreamingMarkdownProps {
  text: string;
}

function sanitizeIncompleteMarkdown(text: string): string {
  let result = text;

  // 미완성 코드 블록 처리: ``` 개수가 홀수면 닫기
  const codeBlockCount = (result.match(/```/g) || []).length;
  if (codeBlockCount % 2 !== 0) {
    result += '\n```';
  }

  // 미완성 인라인 코드 처리
  const inlineCodeCount = (result.match(/`/g) || []).length;
  if (inlineCodeCount % 2 !== 0) {
    result += '`';
  }

  // 미완성 볼드/이탤릭 처리
  const boldCount = (result.match(/\*\*/g) || []).length;
  if (boldCount % 2 !== 0) {
    result += '**';
  }

  return result;
}

export function StreamingMarkdown({ text }: StreamingMarkdownProps) {
  const safeText = useMemo(() => sanitizeIncompleteMarkdown(text), [text]);

  return (
    <ReactMarkdown
      className="prose prose-sm max-w-none"
      components={{
        code({ className, children }) {
          const isBlock = className?.includes('language-');
          if (isBlock) {
            return (
              <pre className="bg-gray-900 text-gray-100 p-4 rounded overflow-x-auto">
                <code>{children}</code>
              </pre>
            );
          }
          return <code className="bg-gray-100 px-1 rounded">{children}</code>;
        },
      }}
    >
      {safeText}
    </ReactMarkdown>
  );
}

5. 청크 버퍼링으로 렌더링 성능 최적화

LLM이 매우 빠르게 토큰을 내보낼 때 매 청크마다 리렌더링하면 성능이 저하된다.

// hooks/useBufferedStreaming.ts
import { useState, useRef, useCallback, useEffect } from 'react';

export function useBufferedStreaming(bufferMs = 50) {
  const [displayText, setDisplayText] = useState('');
  const bufferRef = useRef('');
  const timerRef = useRef<NodeJS.Timeout | null>(null);

  const flush = useCallback(() => {
    setDisplayText(bufferRef.current);
    timerRef.current = null;
  }, []);

  const appendChunk = useCallback((chunk: string) => {
    bufferRef.current += chunk;

    if (!timerRef.current) {
      timerRef.current = setTimeout(flush, bufferMs);
    }
  }, [bufferMs, flush]);

  const reset = useCallback(() => {
    bufferRef.current = '';
    setDisplayText('');
    if (timerRef.current) {
      clearTimeout(timerRef.current);
      timerRef.current = null;
    }
  }, []);

  useEffect(() => {
    return () => {
      if (timerRef.current) clearTimeout(timerRef.current);
    };
  }, []);

  return { displayText, appendChunk, reset };
}

6. 스트리밍 상태 처리 패턴

[스트리밍 상태 전이도]

idle ──→ loading ──→ streaming ──→ complete
  ↑           ↓           ↓
  └── abort ──┘     error ──→ idle

각 상태의 UI:
- idle: 입력창 활성화, 전송 버튼 활성화
- loading: 스피너 표시, 입력 비활성화
- streaming: 텍스트 증가, 커서 깜박임, 중단 버튼 표시
- complete: 전체 텍스트 고정, 입력창 재활성화
- error: 에러 메시지, 재시도 버튼
type StreamState = 'idle' | 'loading' | 'streaming' | 'complete' | 'error';

const UI_BY_STATE: Record<StreamState, { showStop: boolean; inputDisabled: boolean }> = {
  idle:      { showStop: false, inputDisabled: false },
  loading:   { showStop: false, inputDisabled: true },
  streaming: { showStop: true,  inputDisabled: true },
  complete:  { showStop: false, inputDisabled: false },
  error:     { showStop: false, inputDisabled: false },
};

마무리

LLM 스트리밍 구현의 핵심은 세 가지다.

  1. SSE로 단방향 스트리밍: 대부분의 LLM 채팅 시나리오에서 WebSocket보다 단순하고 충분하다
  2. AbortController로 중단 처리: 서버 측 abort와 클라이언트 측 신호를 연결해야 API 비용이 낭비되지 않는다
  3. 마크다운 점진적 렌더링: 미완성 코드 블록을 임시로 닫아줘야 렌더링이 깨지지 않는다

체감 응답 속도는 실제 속도보다 더 중요하다. 스트리밍 하나로 사용자 만족도가 크게 달라진다.