CLOVA 건강검진 챗봇

fetch + ReadableStream로 LLM 응답 실시간 스트리밍

"실시간처럼 보이게" 흉내만 내던 챗봇 응답을 실제 스트리밍으로 바꿨다. 그러자 숨어 있던 무한 로딩 버그가 따라 나왔다.

문제 1: 가짜 스트리밍

기존에는 LLM 응답 전체를 한 번에 받은 뒤, 프론트엔드에서 한 글자씩 출력해 실시간처럼 보이게 했다. 응답이 짧을 땐 괜찮지만, 길어질수록 "다 받을 때까지 기다리는 시간"이 그대로 지연으로 쌓였다.

해결 1: 진짜 스트리밍 (fetch + ReadableStream)

response.bodyReadableStream이다. getReader()로 리더를 얻으면 전체를 기다리지 않고 도착하는 청크부터 읽을 수 있다.

const res = await fetch("/api/chat", { method: "POST", body });
const reader = res.body.getReader(); // ReadableStream → 청크 리더
const decoder = new TextDecoder();   // 바이트 → 문자
 
while (true) {
  const { done, value } = await reader.read();
  if (done) break;                   // done=true → 스트림 종료
  // value = Uint8Array 청크. stream:true로 청크 경계에서 한글이 잘리지 않게
  appendToMessage(decoder.decode(value, { stream: true }));
}

reader.read()가 돌려주는 { done, value }done은 끝났는지, value는 이번에 도착한 Uint8Array 청크다. 멀티바이트인 한글은 청크 경계에서 잘릴 수 있어, TextDecoder{ stream: true }로 경계를 이어 붙여 깨짐을 막았다. 응답이 생성되는 즉시 화면에 흘러나오고, 응답 중에는 로딩 아이콘과 ●(텍스트 인디케이터)로 진행 중임을 알렸다.

챗봇 스트리밍 응답과 EventStream
챗봇 스트리밍 응답과 EventStream(status·token 이벤트).

문제 2: status 이벤트 3개 → 무한 로딩

스트리밍을 붙이고 나니 새 버그가 생겼다. 모델 응답에서 event:status가 3개 올 때, 응답이 끝나도 로딩 아이콘이 사라지지 않았다.

원인

기존 로직은 status 이벤트가 하나만 온다고 전제했다. 그런데 모델이 status를 2개 이상 보낼 수 있었고, status 메시지의 id는 모두 streaming이었다.

1. 첫 status  → id:'streaming' 메시지 생성
2. 둘째 status → 새 id:'streaming' 메시지 추가 (기존 유지)
3. 셋째 status → 또 id:'streaming' 메시지 추가
4. result     → 마지막 메시지만 대체
5. 결과: id:'streaming' 메시지가 여전히 남아 무한 로딩

해결 2: 마지막 streaming 메시지 덮어쓰기

status 이벤트가 오면 새로 추가하지 않고, 마지막 streaming 메시지를 덮어써 항상 streaming 메시지가 하나만 존재하도록 보장했다.

1. 첫 status  → id:'streaming' 생성
2. 둘째 status → 기존 'streaming' 덮어쓰기
3. 셋째 status → 기존 'streaming' 덮어쓰기
4. result     → 마지막 메시지를 완료 상태로 대체
5. 결과: 로딩 정상 종료

token 이벤트는 마지막 메시지 하나만 업데이트하면 됐다 (이미 streaming 메시지가 하나로 보장되므로).

무한 로딩 해결 코드 diff
마지막 메시지가 streaming이면 새로 추가하지 않고 덮어쓰도록 수정.

끝이 없는 응답이라 따로 챙긴 예외

일반 요청은 "받았다/실패했다"가 끝이지만, 스트리밍은 받는 도중에 끊길 수 있다. 그래서 세 가지를 의식하고 다뤘다.

  • 응답 지연 — 첫 청크가 오기 전까지 로딩 인디케이터로 "진행 중"을 명시 (빈 화면을 만들지 않음)
  • 중간 끊김 — 도중에 끊겨도 이미 받은 텍스트는 버리지 않고 화면에 보존. streaming 메시지를 하나로 보장한 위 구조 덕에, 끊겨도 마지막 상태가 그대로 남는다
  • 중복 status — 위에서 본 무한 로딩처럼, "이벤트가 한 번만 온다"는 전제를 깨고 들어오는 경우를 방어 (덮어쓰기로 단일화)

스트리밍은 "한 번에 다 받는다"는 가정이 깨진 모델이라, 정상 흐름보다 중간에 무슨 일이 생기는가를 먼저 그려야 했다.

결과

  • 응답 시작 체감 3~5초 → 1초 이내 (첫 텍스트가 보이기까지)
  • status가 여러 개 와도 로딩이 정상 종료
  • 끊김·지연에도 받은 내용은 보존되어 대화가 끊겨 보이지 않음