React

SSE 기반 LLM 응답 스트리밍 구현기

빈빠끄 2026. 5. 19. 20:58

LLM 이 뭘하고 있는지 사용자는 궁금해한다

챗봇 기반 문서편집 AI 서비스를 개발하다 보면 LLM이 문서 및 자료를 분석하고, 사고 과정(thinking)을 거치고, 편집 명령을 생성하고, 최종 메시지를 작성하기까지 평균 10~30초가 걸린다. 그때 사용자가 원하는 건 단순한 로딩바가 아닌 "지금 AI가 뭘 하고 있는지" 아는 것이였다. 문서를 검색하는 중인지, 생각하는 중인지, 편집을 시작했는지. 그 과정이 실시간으로 보여야 했다.


폴링 vs WebSocket vs SSE 비교

폴링(Polling): 가장 단순하지만, LLM 응답의 특성과 맞지 않았다. 토큰 단위로 생성되는 텍스트를 0.5초마다 긁어오면 불필요한 요청이 대량 발생하고, 실시간 느낌도 살릴 수 없다.

WebSocket: 양방향 통신이 가능하다는 장점이 있지만, 우리 유스케이스는 "서버 → 클라이언트" 단방향이 대부분이었다. 사용자가 질문을 보내는 건 HTTP POST 한 번이면 충분하고, 이후엔 서버가 스트리밍으로 쏴주기만 하면 된다. WebSocket은 연결 유지 비용, 프록시 호환성, 재연결 로직까지 직접 관리해야 해서 오버엔지니어링이었다.

SSE(Server-Sent Events): HTTP 기반 단방향 스트리밍. 브라우저 EventSource API를 그대로 쓸 수 있고, 자동 재연결이 내장되어 있다. 요구사항에 맞아 보였다.

기준 Polling WebSocket SSE
방향 단방향(요청-응답) 양방향 단방향(서버→클라이언트)
실시간성 낮음 높음 높음 
연결 비용 요청마다 새 연결 상시 연결 유지 단일 HTTP 스트림 유지
재연결 직접 구현 직접 구현 브라우저 내장
LLM 스트리밍 적합도 낮음  높음(과잉) 최적

 

SSE를 골랐지만, 하나로는 안 됐다

처음에는 SSE 하나로 끝날 줄 알았다. 그런데 LLM 요청은 POST body와 인증 헤더가 필요했고, EventSource는 GET만 지원했다. 결국 LLM 응답 스트림은 fetch로 직접 읽고, 장시간 서버 푸시 이벤트만 EventSource로 유지했다. 결과적으로 두 종류의 스트리밍 코드가 생겼지만, 각각의 제약을 생각하면 이 구성이 가장 단순했다.

LLM 응답 스트리밍 - fetch + ReadableStream

서버는 text/event-stream 형식으로 응답하고, 클라이언트는 그 스트림을 fetch + ReadableStream으로 직접 읽어 파싱한다. 전송 포맷은 SSE를 따르되, 소비 방식만 fetch 기반으로 가져간 셈이다.

// Main Process에서 LLM 서버와 SSE 스트리밍

const res = await fetch(`${this.baseUrl}/api/v2/answers`, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    Authorization: `Bearer ${params.accessToken}`,
    Accept: 'text/event-stream',  // SSE 형식 요청
  },
  body: JSON.stringify(params),
  signal: params.abortSignal,
});


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

while (true) {
  const { value, done } = await reader.read();
  if (done) {
    // 남은 버퍼 처리 후 종료
    if (buffer.trim()) yield* this.processBufferData(buffer.trim());
    yield { done: true };
    break;
  }

  const chunk = decoder.decode(value, { stream: true });
  buffer += chunk;

  // 줄 단위 분할 — 마지막 줄은 불완전할 수 있으므로 버퍼에 보관
  const lines = buffer.split(/\r?\n/);
  buffer = lines.pop() || '';

  for (const line of lines) {
    yield* this.processLine(line);
  }
}

핵심은 AsyncGenerator 패턴이다. yield*로 파싱된 결과를 하나씩 내보내면, 호출 측에서 for await...of로 자연스럽게 소비할 수 있다. 버퍼링 로직도 간결해진다.

서버 푸시 이벤트 - EventSource

로그 수집 명령 같은 서버 푸시 이벤트는 EventSource를 그대로 사용했다. GET 기반이고 장시간 연결을 유지해야 하는 용도에는 여전히 적합하다.

// Renderer Process에서 서버 이벤트 수신

const es = new EventSource(`${baseUrl}/api/v2/events/stream?user_id=${userId}`);

es.addEventListener('open', () => {
  reconnectAttempts = 0;
  lastMessageTime = Date.now();
  startHeartbeatCheck();

});

es.addEventListener('ping', () => {
  lastMessageTime = Date.now();  // keep-alive 수신 시간 업데이트
});

다만 EventSource의 내장 재연결만으로는 운영 환경에서 부족했다. 네트워크가 바뀌거나 백그라운드 전환 이후, 연결은 살아 있는 것처럼 보이지만 메시지가 오지 않는 half-open 상태가 생겼다. 프록시나 서버의 idle timeout으로 끊긴 연결을 브라우저가 즉시 감지하지 못하는 경우도 있었다. 결국 지수 백오프 + 지터 기반 재연결하트비트 타임아웃 감지30분 주기 자가 복구 로직을 직접 얹었다.

// 지수 백오프: 1s → 2s → 4s → 8s → 16s + 랜덤 지터
function computeBackoffDelayMs(): number {
  const backoff = BASE_RECONNECT_DELAY_MS * 2 ** (reconnectAttempts - 1);
  const jitter = Math.floor(Math.random() * MAX_JITTER_MS);
  return backoff + jitter;
}

 

"자동 재연결이 내장되어 있다"고 써놓고 결국 직접 구현한 셈이니, 비교표의 SSE 장점이 실제로는 반쯤만 맞았다.

 


구현 디테일

1. 스트리밍 파서: 불완전한 청크 대응

LLM 응답은 네트워크 상황에 따라 청크가 잘리는 위치가 매번 달라진다. `\n\n---\n\n` 구분자 중간에서 잘릴 수도 있고, JSON의 중괄호가 반만 올 수도 있다.

여기서 파싱하는 대상은 표준 SSE의 event: / data: 필드 자체라기보다, SSE transport 위에 얹은 애플리케이션 레벨의 델타 포맷이다. 서버는 thinking, retrieve, editDocument, message 같은 작업 단계를 텍스트 블록으로 내려주고, 클라이언트는 이를 AgentDelta로 변환한다.

이를 위해 상태형 파서(stateful parser)를 만들었다. createAgentResponseParser()는 내부에 carry 버퍼를 유지하면서, push()로 새 텍스트가 들어올 때마다 완성된 블록만 파싱해서 반환한다.

export function createAgentResponseParser() {
  let carry = '';         // 미완성 블록 누적 버퍼
  let currentStep = '';   // thinking | message | response 상태 추적

  function push(textChunk: string): AgentDelta[] {
    // thinking 시작/종료 패턴 매칭
    if (textChunk.includes('op: thinking\ncontent: ')) {
      currentStep = 'thinking';
      return [{ op: 'thinking', status: 'start' }];
    }

    // thinking 중이면 time 패턴으로 종료 감지
    if (currentStep === 'thinking') {
      const timeMatch = textChunk.match(/\ntime: (\d+)s\n\n---\n\n/);
      
      if (timeMatch) {
        currentStep = 'response';
        return [{ op: 'thinking', status: 'end', time: timeMatch[1] }];
      }
      return [{ op: 'thinking', content: textChunk }];
    }

    // 블록 구분자로 분리, 마지막은 미완성이므로 보류
    carry += textChunk;
    const parts = carry.split(/\n\n+---\n\n+/);
    carry = parts.pop() ?? '';

    // 완성된 블록만 파싱
    return parts.map(parseBlockToDelta).filter(Boolean);
  }

  function flush(): AgentDelta[] { /* 잔여 버퍼 최종 처리 */ }

  return { push, flush };
}

 

이 파서는 op 종류가 늘어날 때마다 if 분기가 하나씩 추가되는 구조라, 지금도 꽤 복잡하다. thinking, webSearch, retrieve, editDocument, message 각각 시작/종료 패턴이 다르고, 멀티라인 처리 규칙도 다르다. 파서와 UI 핸들러 양쪽을 동시에 수정해야 하는 점이 현재 가장 큰 유지보수 부담이다.

 

2. 자동 스크롤: 단순하지만 까다로운 UX

스트리밍 중 자동 스크롤은 "바닥 근처에 있으면 따라가고, 위로 올라갔으면 멈추는" 단순한 규칙이지만, 구현은 까다로웠다.

const DYNAMIC_SCROLL_THRESHOLD = Math.round(dynamicMinHeight * 0.5);

useLayoutEffect(() => {
  if (!isLastMessage || !messageRef.current) return;

  // 유저가 질문한 직후 → 무조건 바닥으로
  if (isLoadingMessage) {
    scrollToBottom();
    return;
  }

  // 스트리밍 중 → 바닥에서 threshold 이내일 때만 따라감
  const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
  if (distanceFromBottom <= DYNAMIC_SCROLL_THRESHOLD) {
    scrollToBottom();
  }
}, [isLastMessage, message.content, isLoadingMessage]);

긴 응답의 경우 메시지 중앙 고정도 필요했다. 사용자가 스트리밍을 따라가는 상태일 때만 적용하고, 위로 스크롤해 이전 내용을 읽기 시작하면 자동 이동을 멈춘다. throttle(200ms) + requestAnimationFrame으로 스트리밍 중인 메시지를 화면 중앙에 유지한다.

// 스트리밍 메시지 화면 중앙 고정
const centerMessageById = useCallback((messageId: string) => {
  const el = container.querySelector(`[data-message-id="${messageId}"]`);
  const containerCenter = containerRect.top + containerRect.height / 2;
  const elCenter = elRect.top + elRect.height / 2;
  const delta = elCenter - containerCenter;

  container.scrollTop = Math.max(0, Math.min(
    container.scrollTop + delta,
    container.scrollHeight - container.clientHeight
  ));
}, []);

threshold 값(dynamicMinHeight * 0.5)은 휴리스틱으로 정했다. 여러 화면 크기에서 "자연스럽다"고 느끼는 값을 감으로 잡은 것이지, 사용자 행동 데이터로 검증한 건 아니다. 마지막 메시지의 minHeight도 컨테이너 높이의 80%로 설정했는데, 짧은 응답에서 과도한 여백이 생기는 부작용이 있어서 작업 완료 후 offsetHeight를 비교해 인라인 스타일을 제거하는 보정 로직까지 들어갔다. 깔끔한 해결이라고 하기는 어렵다.

 

3. 응답 중간 끊김 대응: AbortController 관통

사용자가 "중지" 버튼을 누르거나, 네트워크가 끊기거나, 페어링된 문서 창이 닫힐 때 스트리밍을 중단해야 한다. 이를 위해 AbortController를 파이프라인 전체에 관통시켰다.

[사용자 중지] → eventBus.emit('STOP_AGENT')

  → useChatMessages.stopAgent()

    → abortRunningMessages()    // 모든 진행 중 메시지를 aborted 상태로

    → stopAndResetAgent()       // AgentContext 상태 초기화

      → stopAgent(runId)        // IPC → Main Process

        → orchestrator.abort()  // AbortController.abort()

          → LlmClient stream 중단

각 메시지는 start → end | aborted | error 상태를 가지며, 중단 시 해당 시점의 모든 진행 중 메시지가 aborted로 전환된다. 이렇게 하면 UI에서 "AI가 여기까지 작업하다가 중단됨"을 보여줄 수 있다.

다만 이 흐름이 한 번에 나온 건 아니다. 처음에는 stopAndResetAgent를 직접 호출했는데, 그러면 메시지 상태가 start인 채로 남아서 UI가 어중간한 상태에 빠졌다. 그래서 abortRunningMessages를 먼저 호출하고, 그다음 Agent를 중단하는 순서로 바꿨다. 

 

4. 아키텍처: Electron IPC를 관통하는 이벤트 파이프라인

Electron 앱은 Main Process ↔ Renderer Process 간 경계가 있다. Renderer에서 직접 LLM 스트림을 읽으면 파싱 로직이 UI 코드와 섞이니까, Main Process의 AgentRuntimeOrchestrator가 스트리밍과 파싱을 담당하고 Renderer는 파싱된 AgentDelta만 받아서 UI를 업데이트하도록 나눴다.

Renderer Main Process LLM Server
AichatForm
sendMessage()
   
↓ IPC AgentRuntimeOrchestrator
LlmClient.stream()
/api/v2/answers
(SSE)
AgentContextProvider
subscribe(event)
↓ fetch + ReadableStream  
SseParser
createAgentResponseParser()
push(chunk)AgentDelta[]
 
useChatMessages
handleAgentEvent()
↓ emitToParent
← IPC event
 
   
ChatMessages
(UI 렌더링)
   

의도는 "Renderer가 원시 스트림을 직접 만지지 않게 하자"였다. 실제로 파싱 로직은 Main Process에 격리되어 있고, Renderer는 AgentDelta 타입만 알면 된다. 하지만 완전한 분리는 아니었다. 렌더러에서 op 종류마다 분기하면서 메시지 상태를 직접 조작하고, isWorkingisDocumentEditing 같은 상태를 별도로 관리한다. 


결과: 첫 피드백 시간 1~2초

실제 LLM 처리 시간은 동일하다. 줄어든 건 없다. 달라진 건 사용자가 메시지 전송 후 실시간으로 상태 변화를 볼수 있다는 것이다.

- Thinking 단계: "AI가 생각 중입니다"와 함께 사고 과정이 실시간으로 표시

- 검색/편집 단계: "문서에서 검색 중...", "문서 편집을 시작합니다..." 등 단계별 피드백

- 메시지 단계: 토큰 단위 스트리밍으로 타이핑 효과

- 중단 피드백: 즉각적인 상태 전환으로 "내 요청이 반영됐다"는 확신 제공

스트리밍 적용 후에는 첫 피드백이 1~2초 안에 도착한다. LLM이 더 빨라진 게 아니라, 중간 과정을 보여줌으로써 기다리는 시간이 지켜보는 시간으로 바뀐 것이다.


현재 한계

- EventSource의 GET 제한으로 LLM 스트리밍에는 fetch 기반 SSE를 별도 구현해야 했다. SSE를 골랐지만 표준 API 하나로 커버되지는 않았다.

- 상태형 파서의 복잡도가 높다. op 종류가 늘어날 때마다 파서와 UI 핸들러 양쪽을 수정해야 한다.

- 자동 스크롤의 threshold 값이 휴리스틱 기반이다. 다양한 화면 크기와 콘텐츠 길이에서의 최적값을 아직 데이터로 검증하지 못했다.