SSE로 스트리밍할 때 에러를 다루는 법
최근에 과제와 개인 사이드프로젝트로 LLM 챗앱을 만들었습니다. EventSource 하나 열고 토큰을 받아 화면에 이어붙이면 끝나는 일이라고 생각했습니다. 실제로 연결을 여는 코드는 세 줄이면 충분했으니까요.
문제는 그다음이었습니다. 잘 되던 화면이 어떤 날은 중간에 멈추고, 어떤 날은 아무 반응이 없고, 어떤 날은 콘솔에만 조용히 예외가 찍혔습니다. 이 질문을 붙들고 하나씩 뜯어보다 보니, 실패가 한 종류가 아니라는 걸 알게 됐습니다.
그 정리를 이 글에 남깁니다.
이번 글에서는 SSE가 뭔지 가볍게 정리하고, React에서 LLM 응답을 스트리밍할 때 실제로 마주치는 세 가지 에러 케이스와 그걸 핸들링하는 방법을 다룹니다.
- 응답 데이터 형식이 중간에 달라지는 경우
- 연결 자체가 실패하는 경우
- 연결은 살아있는데 스트림이 조용히 멈추는 경우
1. SSE는 서버가 한 방향으로 밀어주기만 한다
SSE는 서버가 클라이언트로 단방향으로 데이터를 계속 밀어주는 표준입니다. HTTP 연결 하나를 열어두고, 서버가 끊지 않은 채 이벤트를 조금씩 흘려보내는 방식이죠.
WebSocket과 자주 비교되는데, 핵심 차이는 이렇습니다.
| SSE | WebSocket | |
|---|---|---|
| 방향 | 서버 → 클라 (단방향) | 양방향 |
| 프로토콜 | 그냥 HTTP | 별도 핸드셰이크(ws://) |
| 자동 재연결 | 브라우저가 기본 제공 | 직접 구현 |
| 데이터 | 텍스트(UTF-8) | 텍스트/바이너리 |
LLM 응답 스트리밍은 "서버가 토큰을 일방적으로 보내주기만 하면 되는" 상황이라 SSE가 딱 맞습니다.
브라우저에 EventSource라는 내장 API가 있어서 클라이언트 코드도 짧습니다.
const es = new EventSource("/api/chat/stream");
es.addEventListener("message", (event) => {
console.log(event.data); // 서버가 보낸 한 덩어리
});
2. 빈 줄 하나가 이벤트의 경계다
EventSource가 받는 응답의 본문 포맷이 바로 Event Stream입니다.
서버는 Content-Type: text/event-stream 으로 응답하고, 본문은 다음 규칙을 따르는 평범한 텍스트입니다.
event: text_delta
data: {"content": "안녕"}
event: text_delta
data: {"content": "하세요"}
event: done
data: {}
규칙은 다음과 같습니다.
- 한 줄은
필드: 값형태입니다. 주요 필드는event,data,id,retry. - 빈 줄(
\n\n)이 하나의 이벤트 경계입니다. 빈 줄을 만나야 "이벤트 하나 완성"으로 보고 디스패치됩니다. event를 생략하면 기본 이벤트 이름은message입니다. (그래서 위 1번 예시가message리스너로 잡힌 거죠)data가 여러 줄이면\n으로 이어붙입니다.retry: 3000은 "연결 끊기면 3초 뒤 재연결해" 라는 브라우저용 힌트입니다.id는 마지막으로 받은 이벤트 위치입니다. 재연결 시 브라우저가Last-Event-ID헤더로 다시 보내줘서, 서버가 이어받기를 지원할 수 있습니다.
여기서 LLM 앱에 중요한 포인트가 두 가지 있습니다.
첫째, 이벤트에 이름을 붙이면 클라이언트가 분기하기 쉬워집니다.
보통 텍스트 토큰(text_delta), 코드/아티팩트(artifact_delta), 종료 신호(done) 정도로 나눕니다.
es.addEventListener("text_delta", onTextDelta);
es.addEventListener("done", onDone);
둘째, data 안에 뭐가 들어올지는 전적으로 서버 약속에 달려 있습니다.
JSON을 넣기로 했어도, 서버가 토큰을 쪼개다 깨진 JSON을 보내거나 예상 못한 형식을 끼워넣을 수 있습니다.
바로 이 지점이 첫 번째 에러 처리(4-1)로 이어집니다.
3. 그릴 값과 읽을 값을 나눠야 했다 (LLM 챗앱 기준)
스트리밍 로직은 상태가 많아서 커스텀 훅으로 빼는 게 깔끔합니다. 핵심은 두 종류의 값을 구분하는 겁니다.
- 화면에 그려야 하는 값 →
useState(streamText,streaming) - 핸들러가 최신값을 읽어야 하는데 리렌더는 필요 없는 값 →
useRef(EventSource 인스턴스, 누적 버퍼)
왜 누적 버퍼를 useRef로 두냐면, addEventListener로 등록한 핸들러는 등록 시점의 클로저를 들고 있어서 streamText state를 직접 읽으면 오래된 값(stale)을 봅니다. ref는 항상 최신을 가리킵니다.
import { useRef, useState } from "react";
export function useChatStream() {
const [streamText, setStreamText] = useState("");
const [streaming, setStreaming] = useState(false);
const esRef = useRef<EventSource | null>(null);
const bufferRef = useRef(""); // 누적 텍스트의 단일 출처
const open = (sessionId: string) => {
const es = new EventSource(`/${sessionId}/stream`);
esRef.current = es;
setStreaming(true);
bufferRef.current = "";
es.addEventListener("text_delta", (event) => {
const { content } = JSON.parse(event.data) as { content: string };
bufferRef.current += content;
setStreamText(bufferRef.current);
});
es.addEventListener("done", () => {
setStreaming(false);
es.close();
esRef.current = null;
});
};
return { streamText, streaming, open };
}
4. 세 갈래로 갈라진 실패
4-1. 깨진 JSON 한 조각이 스트림 전체를 멈춘 것처럼 보인다
text_delta 핸들러에서 JSON.parse(event.data) 를 그냥 호출하고 있죠.
서버가 토큰을 잘게 쪼개다 보면 {"content": "안 처럼 JSON이 깨진 채로 한 청크가 날아오는 경우가 생깁니다.
또는 우리가 약속한 { content } 형식이 아닌 다른 메타데이터나 에러 객체가 같은 채널로 끼어들 수도 있고요.
JSON.parse는 이때 예외를 던집니다. 핸들러가 통째로 터지면 그 청크만 잃는 게 아닙니다. 잘못하면 누적 버퍼가 오염되거나 스트림 전체가 멈춘 것처럼 보입니다.
원칙: 우리가 아는 형식만 반영하고, 모르는 건 조용히 무시한다.
핸들러 안에서 파싱을 try/catch로 감싸고, 실패하면 아무것도 안 하면 됩니다.
포인트는 bufferRef를 건드리기 전에 파싱하는 겁니다. 그래야 파싱이 실패해도 버퍼와 상태가 그대로 유지됩니다.
es.addEventListener("text_delta", (event) => {
let content: string;
try {
const parsed = JSON.parse(event.data) as { content?: unknown };
// 형식 검증: content가 문자열일 때만 우리 데이터로 인정
if (typeof parsed.content !== "string") return;
content = parsed.content;
} catch {
return; // 깨진 JSON은 무시 — 버퍼/상태 미변경
}
// 여기 도달 = 검증 통과한 안전한 값
bufferRef.current += content;
setStreamText(bufferRef.current);
});
JSON.parse 성공 여부뿐 아니라 typeof 검증까지 넣은 게 중요합니다.
파싱은 됐지만 content가 없거나 숫자인 "다른 형식"도 여기서 함께 걸러지니까요.
스트리밍은 한두 청크 무시해도 사용자 경험에 거의 티가 안 납니다. 무시할 수 있는 에러는 무시하는 게 가장 견고한 선택입니다.
4-2. error가 떴다고 바로 끊으면 더 큰 문제가 생긴다
이건 데이터 문제가 아니라 전송 계층 문제입니다. 와이파이가 끊기거나, 서버가 연결을 못 받거나, 중간에 끊기거나.
이때는 text_delta 같은 named event가 아니라 EventSource의 네이티브 error 이벤트가 뜹니다.
여기서 중요한 부분은 error만 보고 바로 에러처리를 하면 더 큰 문제가 생길 수 있다는 겁니다.
EventSource는 연결이 끊기면 브라우저가 알아서 재연결을 시도하는데, 그 재시도 과정에서도 error가 뜨니까요. 무턱대고 close() 하면 브라우저의 자동 재연결을 우리 손으로 죽이는 셈입니다.
그래서 readyState로 상황을 가릅니다.
EventSource.CONNECTING(재연결 시도 중) → 그냥 둡니다. 브라우저가 알아서 다시 붙습니다.EventSource.CLOSED(영구 단절) → 이때만 개입합니다.
es.addEventListener("error", (event) => {
const target = event.target as EventSource;
// 재연결 시도 중이면 브라우저에게 맡긴다
if (target.readyState !== EventSource.CLOSED) return;
// 여기 도달 = 영구 단절. 우리가 처리.
});
한 번이라도 붙은 적이 있는지가 갈림길이다
그런데 error 하나로는 정보가 부족합니다. 애초에 연결이 된 적이 있긴 한지를 알 수 없으니까요.
- 서버에 아예 못 붙은 경우 (잘못된 URL, 서버 다운, 인증 실패) — 재연결해도 똑같이 실패할 가능성이 큽니다.
- 잘 붙어서 한참 받다가 끊긴 경우 (일시적 네트워크 끊김) — 재연결하면 이어받을 가능성이 높습니다.
이 둘을 구분하려면 연결이 성공한 시각을 기록해두면 됩니다.
EventSource는 연결이 실제로 열리면 open 이벤트를 쏴줍니다. 이걸 useRef에 찍어두는 거죠.
const connectedAtRef = useRef<number | null>(null); // open된 시각, 한 번도 안 열렸으면 null
es.addEventListener("open", () => {
connectedAtRef.current = performance.now();
});
Date.now()대신performance.now()를 쓰면 시스템 시계가 바뀌어도 영향받지 않는 단조 증가 시간이라 "경과 시간" 측정에 더 안전합니다.
이제 error 시점에 이 값을 보면 상황이 보입니다.
es.addEventListener("error", (event) => {
const target = event.target as EventSource;
if (target.readyState !== EventSource.CLOSED) return;
if (connectedAtRef.current === null) {
// open이 한 번도 안 옴 = 초기 연결 실패
// → 서버/인증/URL 문제일 확률이 높음. 재시도 의미가 적으니 빨리 사용자에게 알림.
fail("서버에 연결하지 못했어요.");
return;
}
const aliveMs = performance.now() - connectedAtRef.current;
if (aliveMs > STABLE_THRESHOLD_MS) {
// 충분히 오래 붙어있다 끊김 = 일시적 단절
// → 재연결 카운터를 리셋하고 다시 시도할 가치가 있음
reconnectRef.current = 0;
}
reconnect(); // 아래 4-2 재연결 로직
});
핵심은 오래 안정적으로 붙어있었으면 그건 성공한 연결로 치고, 재연결 카운터를 리셋한다는 겁니다. 이렇게 안 하면, 30분 동안 잘 쓰다가 잠깐 끊긴 사용자도 "재연결 3회 초과"로 막혀버립니다. 연결 시각을 그런 엣지케이스는 피할 수 있습니다.
브라우저는 포기할 줄 모른다
브라우저의 자동 재연결은 참 좋지만, 무한히 시도한다는 게 문제입니다. 서버가 진짜로 죽었으면 브라우저는 끝없이 재연결을 두드리고, 사용자는 멈춘 화면만 보게 됩니다. 그래서 정책상 최대 재연결 횟수를 정하고, 그걸 넘으면 우리가 직접 끊고 사용자에게 알려야 합니다.
이 카운터를 왜 useState가 아니라 useRef로 두냐면 —
재연결 횟수가 바뀐다고 화면을 다시 그릴 필요가 없고, 핸들러 안에서 즉시 최신값을 읽고 증가시켜야 하기 때문입니다. state라면 비동기 업데이트와 stale 클로저 때문에 카운트가 어긋납니다.
const MAX_RECONNECT = 3;
const STABLE_THRESHOLD_MS = 10_000; // 10초 이상 붙어있었으면 안정 연결로 간주
const reconnectRef = useRef(0);
const reconnect = () => {
if (reconnectRef.current >= MAX_RECONNECT) {
// 정책상 한도 초과 → 우리가 직접 끊고 알림
esRef.current?.close();
esRef.current = null;
setStreaming(false);
fail("응답 연결이 불안정해요. 잠시 후 다시 시도해 주세요.");
return;
}
reconnectRef.current += 1;
// EventSource는 close 후 새 인스턴스로 다시 여는 게 가장 깔끔해요.
// (네이티브 자동 재연결을 우리 정책으로 대체)
esRef.current?.close();
open(currentSessionId); // 같은 세션으로 재오픈
};
참고: 브라우저 네이티브 재연결에 맡길지, 직접
close → open으로 제어할지는 선택입니다. 네이티브에 맡기면 횟수 제어가 어렵고, 직접 제어하면Last-Event-ID이어받기를 직접 신경 써야 합니다. LLM 앱에선 "몇 번까지만 시도하고 깔끔히 포기"가 중요할 때가 많아서 직접 제어하는 쪽을 선호하는 편입니다.
그리고 open() 안에서 연결이 성공적으로 완료되면(done 수신) 카운터를 0으로 되돌려주는 것도 잊지 말아야 합니다. 다음 메시지 턴에 영향이 가지 않도록 말이죠.
4-3. 아무 신호도 오지 않는 게 제일 무섭다 (stall)
이게 제일 까다로운 케이스입니다.
연결은 멀쩡히 열려있는데(readyState === OPEN), 토큰이 더는 안 오고, 그렇다고 done도 안 옵니다.
서버 쪽 LLM 호출이 행(hang)에 걸렸거나, 프록시가 데이터를 물고 안 흘려보내는 상황이죠.
문제는 이때 error 이벤트가 안 뜬다는 겁니다. 연결이 끊긴 게 아니니까요.
4-1(형식)도 4-2(단절)도 안 잡히는, 조용한 멈춤입니다. 사용자는 타이핑 인디케이터만 영원히 보게 되죠.
이건 브라우저가 알려줄 수 없으니 우리가 직접 타임아웃을 걸어야 합니다.
방법은 "마지막으로 청크를 받은 시각"을 useRef에 기록하고, 일정 시간 이상 잠잠하면 우리가 끊는 겁니다.
const IDLE_LIMIT_MS = 15_000; // 15초 동안 아무 청크도 없으면 멈춤으로 판단
const lastChunkAtRef = useRef(0);
const idleTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
// 청크를 받을 때마다 "마지막 수신 시각"을 갱신
const markChunk = () => {
lastChunkAtRef.current = performance.now();
};
const startIdleWatch = () => {
lastChunkAtRef.current = performance.now();
idleTimerRef.current = setInterval(() => {
const idle = performance.now() - lastChunkAtRef.current;
if (idle > IDLE_LIMIT_MS) {
// 연결은 살아있지만 스트림이 멈춤 → 우리가 끊고 알림
stopIdleWatch();
esRef.current?.close();
esRef.current = null;
setStreaming(false);
fail("응답이 지연되고 있어요. 다시 시도해 주세요.");
}
}, 1_000);
};
const stopIdleWatch = () => {
if (idleTimerRef.current) clearInterval(idleTimerRef.current);
idleTimerRef.current = null;
};
그리고 text_delta 핸들러에서 markChunk()를 호출하고, open 시 startIdleWatch(), done/에러/언마운트 시 stopIdleWatch()를 불러주면 됩니다.
es.addEventListener("text_delta", (event) => {
// ...4-1의 파싱/검증...
markChunk(); // 살아있다는 신호 갱신
bufferRef.current += content;
setStreamText(bufferRef.current);
});
더 견고하게 하려면 서버가 일정 주기로 하트비트(주석 라인
: ping\n\n또는 빈 이벤트)를 보내게 하고, 클라이언트는 그걸 받아도markChunk()를 호출하게 하면 됩니다. 그러면 "토큰은 잠깐 뜸하지만 연결은 건강한" 상태와 "진짜 멈춤"을 더 정확히 가를 수 있습니다.
마지막으로, 어떤 경로로 끝나든 정리(cleanup)는 한 곳으로 모으는 게 좋습니다.
done, 영구 단절, idle 타임아웃, 컴포넌트 언마운트 — 전부 close() + 타이머 해제 + ref 비우기를 거쳐야 누수나 유령 토스트가 안 생깁니다.
useEffect(() => {
return () => {
// 언마운트 시 무조건 정리
esRef.current?.close();
esRef.current = null;
stopIdleWatch();
};
}, []);
마무리
SSE는 여는 코드 자체는 세 줄이지만, 실패 모드가 세 갈래라는 걸 알고 나면 설계가 달라집니다.
| 실패 모드 | 신호 | 대응 |
|---|---|---|
| 형식이 깨짐 (4-1) | JSON.parse throw / 다른 형식 | 검증 후 무시 |
| 연결 단절 (4-2) | 네이티브 error + CLOSED | open 시각으로 초기실패/중간끊김 구분 → 횟수 제한 재연결 |
| 조용한 멈춤 (4-3) | 아무 신호 없음 | idle 타임아웃으로 직접 끊기 |
그리고 이걸 가능하게 한 공통 도구가 useRef였습니다.
EventSource 인스턴스, 누적 버퍼, 재연결 카운터, 연결 시각, 마지막 수신 시각 —
리렌더는 필요 없지만 핸들러가 최신값을 즉시 읽어야 하는 값들이 전부 ref로 갑니다.
스트리밍 화면이 이유 없이 멈춘 것 같다면, 셋 중 어느 갈래인지부터 갈라보면 좋겠습니다. 어디를 봐야 할지가 정해지면 나머지는 생각보다 금방 풀릴지도 모릅니다.