SSE로 스트리밍할 때 에러를 다루는 법
최근 과제와 개인 사이드 프로젝트로 LLM 챗앱을 만들었습니다. EventSource 하나 열고 토큰을 받아 화면에 이어 붙이면 끝나는 일이라고 생각했습니다. 연결을 여는 코드는 짧았지만, 중간에 멈춘 화면을 설명하는 일은 달랐습니다.
잘못된 데이터가 들어온 것인지, 연결이 끊긴 것인지, 연결은 열려 있지만 생성이 멈춘 것인지 구분해야 했습니다. 세 경우를 같은 에러 메시지로 처리하면 재시도 방법도 잘못 정하게 됩니다. 이 글에서는 이벤트 경계부터 짚고, 한 응답의 시작과 종료를 하나의 생명주기로 관리해보겠습니다.
네트워크에서 받은 조각이 곧 이벤트는 아니다
SSE는 HTTP 응답을 열어두고 서버가 UTF-8 텍스트 이벤트를 전달하는 방식입니다. 브라우저의 EventSource는 이벤트 이름과 데이터를 해석하고, 연결이 끊기면 재연결도 수행합니다. 클라이언트에서 서버로 메시지를 보내는 통로는 별도로 필요합니다.
event: text_delta
id: 1
data: {"content":"안녕"}
event: text_delta
id: 2
data: {"content":"하세요"}
event: done
data: {}
이벤트는 빈 줄로 끝납니다. 네트워크가 {"content":"안과 녕"}을 나눠 전달해도 브라우저는 SSE 이벤트의 끝까지 버퍼링합니다. 그래서 TCP나 HTTP 청크가 잘렸다는 이유만으로 event.data의 JSON이 자동으로 깨지지는 않습니다. 서버가 불완전한 JSON 뒤에 이벤트 종료용 빈 줄을 넣었거나, JSON이 아닌 데이터를 같은 이벤트로 보냈다면 파싱이 실패할 수 있습니다.
그림에서 앞의 두 경계는 브라우저가, 마지막 JSON 스키마는 애플리케이션이 책임집니다. event가 없으면 message, 있으면 지정한 이름의 리스너로 전달됩니다. id는 자동 재연결 시 Last-Event-ID로 전달될 수 있지만, 이어받을 이벤트를 보관하고 재전송하는 것은 서버의 책임입니다. SSE 이벤트 형식
본문 한 조각을 버리고 성공이라고 표시해도 될까
JSON 파싱에 성공해도 content가 문자열인지 확인해야 합니다. 타입 단언은 런타임 데이터를 검증하지 않습니다. null도 유효한 JSON이므로 객체인지부터 확인합니다.
function readContent(data: string): string {
const value: unknown = JSON.parse(data);
if (
typeof value !== "object" || value === null ||
!("content" in value) || typeof value.content !== "string"
) {
throw new Error("text_delta 형식이 맞지 않습니다.");
}
return value.content;
}
여기서 실패한 이벤트를 조용히 버리는 정책은 신중해야 합니다. 답변의 한 단어가 빠지면 부정문이나 코드의 의미가 달라질 수 있습니다. 무시해도 되는 부가 이벤트와 반드시 순서대로 받아야 하는 본문 이벤트를 계약으로 나누는 편이 좋습니다. 이 글의 예제는 본문이 잘못되면 부분 응답을 남기되 실패 상태로 종료합니다.
연결 복구와 답변 이어받기는 다른 일이다
네이티브 error 이벤트는 서버가 보낸 업무 오류 JSON과 다릅니다. 연결이 복구 중이면 readyState가 CONNECTING일 수 있고, 더 이상 재연결하지 않는 상태면 CLOSED입니다. error마다 새 EventSource를 만들면 브라우저의 자동 재연결과 앱의 재연결이 겹칠 수 있습니다.
여기서는 한 인스턴스의 자동 재연결을 사용하되, 연속 오류 횟수와 대기 시간을 제한하겠습니다. 직접 인스턴스를 다시 만드는 방식도 가능하지만 그 경우 마지막 이벤트 ID와 버퍼를 앱에서 복원해야 합니다. 둘을 한 코드에 섞지 않는 것이 출발점입니다.
서버 계약도 필요합니다. 아래 예제는 본문 이벤트 ID가 1부터 연속 증가하며, 재연결 시 같은 생성 작업의 남은 이벤트를 보내준다고 가정합니다. 이미 받은 ID는 무시하고 다음 ID가 건너뛰면 실패로 처리합니다. 새 연결마다 모델 생성을 다시 시작하는 서버라면 이 규칙으로 이어받을 수 없습니다.
살아 있는 연결이 답변의 진전을 뜻하지는 않는다
연결이 OPEN이어도 프록시가 응답을 모아두거나 모델 생성이 멈추면 error가 오지 않습니다. 마지막으로 신호를 받은 시간과 마지막 본문이 진행된 시간을 따로 봐야 합니다.
SSE의 : ping 주석은 연결 유지에 도움이 되지만 자바스크립트 리스너에는 전달되지 않습니다. 앱이 하트비트를 관찰하려면 다음처럼 데이터가 있는 이름 붙은 이벤트가 필요합니다.
event: heartbeat
data: {}
하트비트는 연결 확인 타이머만 갱신합니다. 본문 진행 타이머까지 갱신하면 서버가 하트비트만 보내는 동안 무한히 기다릴 수 있습니다. 반대로 긴 추론이나 도구 실행이 정상인 제품에서는 본문 대기 한도를 더 길게 하거나 별도의 진행 이벤트를 정의해야 합니다.
종료 경로를 한곳에 모으면 중복 처리가 줄어든다
다음은 앞의 계약을 적용한 브라우저용 컨트롤러입니다. 15초 연결 대기, 45초 본문 대기, 연속 오류 3회는 예시 정책입니다. 모델의 첫 토큰 지연과 서비스 요구에 맞춰 조정해야 합니다.
type StreamState = "connecting" | "streaming" | "done" | "failed" | "cancelled";
export function createChatStream(
url: string,
onText: (text: string) => void,
onState: (state: StreamState, reason?: string) => void,
) {
const es = new EventSource(url);
let ended = false;
let buffer = "";
let lastId = 0;
let consecutiveErrors = 0;
let openedAt: number | null = null;
let lastSignalAt = performance.now();
let lastProgressAt = lastSignalAt;
const finish = (state: "done" | "failed" | "cancelled", reason?: string) => {
if (ended) return;
ended = true;
clearInterval(watch);
es.close();
onState(state, reason);
};
const watch = window.setInterval(() => {
const now = performance.now();
if (now - lastSignalAt > 15_000) {
finish("failed", "연결 신호를 받지 못했습니다.");
} else if (now - lastProgressAt > 45_000) {
finish("failed", "본문 생성이 지연되고 있습니다.");
}
}, 1000);
onState("connecting");
es.addEventListener("open", () => {
if (ended) return;
openedAt = performance.now();
lastSignalAt = openedAt;
onState("streaming");
});
es.addEventListener("heartbeat", () => {
if (!ended) lastSignalAt = performance.now();
});
es.addEventListener("text_delta", (event: MessageEvent) => {
if (ended) return;
const id = Number(event.lastEventId);
if (!Number.isSafeInteger(id) || id < 1) {
finish("failed", "본문 이벤트 ID가 올바르지 않습니다.");
return;
}
if (id <= lastId) return; // 재전송된 이벤트
if (id !== lastId + 1) {
finish("failed", "본문 이벤트가 누락됐습니다.");
return;
}
let content: string;
try {
content = readContent(event.data);
} catch {
finish("failed", "응답 데이터 형식을 확인할 수 없습니다.");
return;
}
buffer += content;
lastId = id;
lastSignalAt = performance.now();
if (content.length > 0) {
lastProgressAt = lastSignalAt;
if (openedAt !== null && lastSignalAt - openedAt >= 10_000) {
consecutiveErrors = 0;
}
}
onText(buffer);
});
es.addEventListener("done", () => finish("done"));
es.addEventListener("app_error", () => finish("failed", "서버가 생성을 종료했습니다."));
es.addEventListener("error", () => {
if (ended) return;
consecutiveErrors += 1;
if (es.readyState === EventSource.CLOSED || consecutiveErrors >= 3) {
finish("failed", "연결을 복구하지 못했습니다.");
} else {
onState("connecting"); // 같은 인스턴스의 자동 재연결을 기다림
}
});
return () => finish("cancelled");
}
finish의 ended 검사는 done, timeout, 취소가 비슷한 시점에 들어와도 종료를 한 번만 처리하도록 합니다. 실패해도 buffer를 지우지 않으므로 받은 부분까지 보여주면서 완료되지 않았음을 표시할 수 있습니다. 콜백은 예외를 던지지 않는 UI 갱신 함수라는 전제입니다.
오류 횟수는 실제 HTTP 요청 횟수와 동일한 지표가 아닙니다. 이 예제는 관찰한 연속 error 이벤트를 제한합니다. 서버가 보내는 하트비트에는 본문용 ID를 붙이지 않는 계약도 필요합니다. 다른 이벤트가 같은 순번을 소비하면 본문 누락 판정이 틀릴 수 있습니다.
React는 연결의 수명과 화면을 이어준다
화면에 그릴 텍스트와 상태는 state에 둡니다. 취소 함수는 ref에 두고 새 응답을 열기 전에 이전 연결을 종료합니다. 컨트롤러의 지역 변수는 같은 연결의 핸들러들이 공유하므로 React의 오래된 state를 읽는 문제가 없습니다.
const stopRef = useRef<null | (() => void)>(null);
function start(url: string) {
stopRef.current?.();
setText("");
stopRef.current = createChatStream(url, setText, (state, reason) => {
setState(state);
setError(reason ?? null);
});
}
useEffect(() => () => stopRef.current?.(), []);
이 코드는 연결 연결부만 보여주는 예입니다. useRef, useEffect, 텍스트·상태·에러의 useState는 컴포넌트에서 준비합니다. cleanup과 새 요청 시작이 같은 취소 경로를 거치게 하는 것이 핵심입니다.
실패를 넣어봐야 스트림이 끝나는 방식을 알 수 있다
로컬 테스트 서버에서 정상 본문 뒤 done, 잘못된 JSON, 같은 ID 재전송, 순번 누락, 연결 중단, 하트비트만 지속되는 경우를 나눠보면 좋습니다. 정상 경로만 확인하면 종료 타이머가 남거나 이전 응답이 새 응답을 덮어쓰는 문제를 놓치기 쉽습니다.
부분 응답을 남길지, 재연결할지, 새로운 생성을 시작할지는 제품의 선택입니다. 다만 그 선택을 하려면 멈춤의 종류와 서버의 이어받기 계약이 먼저 분명해야 합니다. useRef가 상태를 보관해줄 수는 있어도, 무엇을 정상 완료라고 부를지까지 정해주지는 않았습니다.