SSE로 스트리밍할 때 에러를 다루는 법
2026-06-11최근 과제와 개인 사이드프로젝트에서 LLM 챗앱을 만들었어요. 그 과정에서 SSE의 에러 처리를 어떻게 다뤄야 할지 고민하다 이 글을 작성하게 됐어요.
이번 글에서는 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. Event Stream이란
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 앱에 중요한 포인트 두 가지가 있어요.
(1) 이벤트에 이름을 붙이면 클라이언트가 분기하기 쉬워요.
보통 텍스트 토큰(text_delta), 코드/아티팩트(artifact_delta), 종료 신호(done) 정도로 나눠요.
es.addEventListener("text_delta", onTextDelta);
es.addEventListener("done", onDone);
(2) data 안에 뭐가 들어올지는 서버 약속에 달려있어요.
JSON을 넣기로 했어도, 서버가 토큰을 쪼개다 깨진 JSON을 보내거나 예상 못한 형식을 끼워넣을 수 있어요.
바로 이 지점이 첫 번째 에러 처리(4-1)로 이어져요.
3. React에서 보통 쓰는 예시 코드 (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. 응답 데이터 형식이 달라지는 경우
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. 연결 자체가 실패하는 경우
이건 데이터 문제가 아니라 전송 계층 문제예요. 와이파이가 끊기거나, 서버가 연결을 못 받거나, 중간에 끊기거나.
이때는 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회 초과"로 막혀버려요. 연결 시각을 그런 엣지케이스는 피할 수 있어요.
재연결 횟수를 useRef로 카운팅하기
브라우저의 자동 재연결은 참 좋지만, 무한히 시도한다는 게 문제예요. 서버가 진짜로 죽었으면 브라우저는 끝없이 재연결을 두드리고, 사용자는 멈춘 화면만 보게 돼요. 그래서 정책상 최대 재연결 횟수를 정하고, 그걸 넘으면 우리가 직접 끊고 사용자에게 알려야 해요.
이 카운터를 왜 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로 가요.