fetch + ReadableStream로 LLM 응답 실시간 스트리밍
"실시간처럼 보이게" 흉내만 내던 챗봇 응답을 실제 스트리밍으로 바꿨다. 그러자 숨어 있던 무한 로딩 버그가 따라 나왔다.
문제 1: 가짜 스트리밍
기존에는 LLM 응답 전체를 한 번에 받은 뒤, 프론트엔드에서 한 글자씩 출력해 실시간처럼 보이게 했다. 응답이 짧을 땐 괜찮지만, 길어질수록 "다 받을 때까지 기다리는 시간"이 그대로 지연으로 쌓였다.
해결 1: 진짜 스트리밍 (fetch + ReadableStream)
response.body는 ReadableStream이다. 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 }로 경계를 이어 붙여 깨짐을 막았다. 응답이 생성되는 즉시 화면에 흘러나오고,
응답 중에는 로딩 아이콘과 ●(텍스트 인디케이터)로 진행 중임을 알렸다.

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 메시지가 하나로 보장되므로).

streaming이면 새로 추가하지 않고 덮어쓰도록 수정.끝이 없는 응답이라 따로 챙긴 예외
일반 요청은 "받았다/실패했다"가 끝이지만, 스트리밍은 받는 도중에 끊길 수 있다. 그래서 세 가지를 의식하고 다뤘다.
- 응답 지연 — 첫 청크가 오기 전까지 로딩 인디케이터로 "진행 중"을 명시 (빈 화면을 만들지 않음)
- 중간 끊김 — 도중에 끊겨도 이미 받은 텍스트는 버리지 않고 화면에 보존.
streaming메시지를 하나로 보장한 위 구조 덕에, 끊겨도 마지막 상태가 그대로 남는다 - 중복
status— 위에서 본 무한 로딩처럼, "이벤트가 한 번만 온다"는 전제를 깨고 들어오는 경우를 방어 (덮어쓰기로 단일화)
스트리밍은 "한 번에 다 받는다"는 가정이 깨진 모델이라, 정상 흐름보다 중간에 무슨 일이 생기는가를 먼저 그려야 했다.
결과
- 응답 시작 체감 3~5초 → 1초 이내 (첫 텍스트가 보이기까지)
status가 여러 개 와도 로딩이 정상 종료- 끊김·지연에도 받은 내용은 보존되어 대화가 끊겨 보이지 않음