2026-05-19·[기술 문서]
브라우저에서 SSE 스트리밍, 뭘 써야 하나
AI 채팅 UI를 만들다가 스트리밍이 한 번에 쏟아지는 현상을 디버깅했다. EventSource와 fetch+getReader()의 차이, 그리고 브라우저가 스트림을 다루는 방식을 정리했다.
ChatGPT 이후로 AI 서비스에서 텍스트가 실시간으로 흘러오는 UI는 거의 기본이 됐다.
이 방식을 쓰는 이유는 간단하다. AI가 응답을 생성하는 데는 시간이 걸리는데, 다 만들어질 때까지 기다렸다가 한 번에 보여주면 사용자 입장에서는 그냥 멍하니 기다려야 한다. 생성되는 대로 흘려보내면 읽기 시작할 수 있고, 체감 대기 시간도 훨씬 짧아진다. 같은 응답 시간이어도 느낌이 완전히 다르다.
회사에서도 AI 에이전트 채팅 기능을 만들게 됐다. 당연히 이 UX도 구현해야 했다. 서버에서 스트림이 오면, 브라우저에서 실시간으로 받아 화면에 뿌려주는 것.
브라우저에서 SSE를 받는 API는 두 가지다. EventSource와 fetch + getReader(). 동작 방식이 생각보다 많이 다르고, 그 차이는 이상한 현상을 디버깅하다가 발견했다.
직접 겪으면서 알게 된 것
이걸 구현하다가 이상한 현상을 만났다. 네트워크 탭에서는 분명히 응답이 청크 단위로 흘러오는 게 보이는데, 화면에는 마지막 순간에 텍스트가 한 번에 튀어나왔다.
처음엔 fetch + getReader() 기반의 스트리밍 훅을 뜯어봤다. 문제가 있긴 했다. reader를 여러 번 여는 구조였고, SSE 이벤트 경계가 청크 중간에서 끊기는 경우를 안정적으로 처리하지 못했다. 버퍼 기반 파싱으로 교체하고, \r?\n\r?\n도 대응했다. 여전히 마지막에 한 번에 나왔다.
(이 시점에서 꽤 현타가 왔다. 분명 뭔가 고쳤는데 달라진 게 없는 느낌.)
원인을 좁히려면 변수를 하나씩 제거해야 한다. 챗 UI, 메시지 리스트, markdown 렌더러, 커스텀 훅 — 이것들 중 뭔가가 관계 있을 수도 있으니까 전부 없앤 최소 테스트 페이지를 만들었다. 거기서 같은 엔드포인트를 EventSource로도 붙이고 fetch + getReader()로도 붙였다. 그리고 하나씩 바꿔보기 시작했다.
결과가 달랐다. EventSource로 읽으면 실시간 렌더. fetch + getReader()로 읽으면 종료 시점에 한 번에.
curl -N으로 route handler를 직접 두드려봤더니 1초 간격으로 청크가 순차 출력됐다. 서버는 무죄였다. text/plain으로 Content-Type을 바꿔봤다. 결과 같았다. read() 콜마다 타임스탬프를 찍어봤다.
10시 0분 56초.908 chunk="data: 첫 번째"
10시 0분 56초.919 chunk="data: 두 번째"
10시 0분 56초.926 chunk="data: 세 번째"
10시 0분 56초.935 chunk="data: 네 번째"
10시 0분 56초.941 done
청크 경계는 살아 있었다. 근데 밀리초 단위로 연속으로 찍혔다. 서버에서 1초 간격으로 보내는 게 JS 레이어에선 한꺼번에 쏟아지고 있었다.
그때 다른 브라우저로 열어봤다. 실시간으로 렌더됐다.
이 순간 용의자가 하나로 좁혀졌다. 브라우저 자체에 깔린 무언가. 확장 프로그램 목록을 열었다.
결정적인 코드가 있었다:
const response = await origFetch.apply(this, args);
const cloned = response.clone();
responseBody = await cloned.text(); // 여기
return response;
특정 확장이 window.fetch를 MAIN world에서 후킹하고, 모든 응답을 clone().text()로 끝까지 읽은 다음에야 반환하고 있었다. text()는 응답이 완료될 때까지 기다리니까, 페이지 코드의 fetch() Promise는 전체 응답 완료 후에야 resolve됐다. 그 시점에 getReader()를 붙이니 청크가 한 번에 도착한 것처럼 보인 거다.
솔직히 충격이었다. 스트리밍 파서, Content-Type, Next.js route, production 서버를 다 뒤집었는데 범인이 거기 있었다.
왜 이런 차이가 생기는가
EventSource는 window.fetch를 호출해서 동작하는 API가 아니다. 브라우저가 text/event-stream 응답을 받기 위해 별도의 EventSource 연결을 열고, 받은 데이터를 이벤트로 만들어준다. 그래서 이번처럼 확장 프로그램이 window.fetch만 후킹한 경우에는 그 monkey patch의 영향을 받지 않았다.
fetch는 응답 헤더가 도착하는 시점에 Promise가 resolve된다. body가 다 오기를 기다리지 않는다. response.body는 ReadableStream이고, 서버가 chunk를 보낼 때마다 스트림에 enqueue되어 read()로 꺼낼 수 있다.
네트워크 탭에서 실시간으로 데이터가 보이는 이유도 여기에 있다. 네트워크 탭은 소켓 레벨에서 캡처하기 때문에, JS fetch Promise가 resolve됐는지와 무관하게 데이터 도착 시점을 그대로 보여준다.
response.clone()은 내부적으로 ReadableStream을 tee한다. 원본과 복사본이 같은 byte source를 공유하는 구조다. clone().text()를 await하면 복사본을 끝까지 읽고, 그동안 원본 쪽에도 데이터가 버퍼링된다. 확장이 이 과정을 await하는 동안 페이지 코드의 fetch Promise는 블록된다.
데이터는 실시간으로 브라우저에 도착하고 있었다. 다만 JS 코드가 그 데이터에 접근할 수 있는 시점이 전체 응답 완료 후였을 뿐이다.
결국 현재 구조는 이렇게 됐다.
브라우저 → EventSource GET /api/stream
서버 → 내부에서 AI 서버로 POST
AI 서버 → 스트리밍 응답
서버 → SSE로 브라우저에 relay
브라우저는 항상 EventSource로 GET을 받고, POST가 필요한 건 서버가 중간에서 처리한다. 구조는 한 단계 늘었지만 실시간성은 안정적으로 확보됐다.
오래 헤맨 이유 중 하나는, EventSource와 fetch + getReader()가 브라우저 내부에서 어떻게 다르게 동작하는지 제대로 모른 채 쓰고 있었다는 거다. 둘 다 SSE를 읽는 방법이라고만 알고 있었고, fetch가 window.fetch를 거친다는 것, ReadableStream이 어떻게 동작하는지 — 이런 건 디버깅하면서 처음 제대로 파봤다.
처음 가설 목록에 확장 프로그램이 있었는데 계속 뒤로 밀었다. 다른 브라우저 테스트 하나가 며칠 고생을 줄여줬을 수도 있다.
EventSource는 어디서 왔나
fetch가 지금처럼 널리 쓰이기 전부터 브라우저에는 서버가 데이터를 밀어줘야 하는 상황이 있었다. 주가 업데이트, 알림, 실시간 피드. 당시 방법은 크게 둘이었다.
폴링 — 클라이언트가 주기적으로 서버에 "새 데이터 있어요?"를 묻는 방식. 구현은 쉬운데 낭비가 심하다. 아무것도 없어도 요청은 계속 나간다.
롱 폴링 — 서버가 새 데이터가 생길 때까지 응답을 안 보내는 방식. 폴링보다 낫지만 연결 관리가 복잡하다.
SSE는 이 문제를 HTTP 위에서 해결하려는 시도였다. WHATWG HTML 표준의 Server-sent events 섹션에 정의되어 있고, 발상 자체는 단순하다. 브라우저가 HTTP 연결을 유지한 채로, 서버가 원할 때마다 텍스트를 밀어넣는다.
data: 첫 번째 토큰\n\n
data: 두 번째 토큰\n\n
EventSource는 이 프로토콜을 브라우저가 직접 구현한 API다. WebSocket은 양방향 통신을 위한 별도 프로토콜이고, SSE는 HTTP 연결 위에서 서버에서 클라이언트로 이벤트를 보내는 단방향 모델이다. 그래서 필요한 기능이 서버에서 브라우저로 흘려보내는 것뿐이라면 SSE 쪽이 단순할 때가 많다.
반면 fetch + getReader()는 SSE 전용 API가 아니라 Fetch API와 Streams API를 조합해서 응답 바이트를 직접 읽는 방식이다. EventSource가 SSE 프로토콜을 처리해주는 고수준 API라면, fetch + getReader()는 더 낮은 수준의 스트림 처리 도구에 가깝다.
ReadableStream이 뭔가
fetch로 받은 응답에서 .body에 접근하면 ReadableStream이 나온다. 이름 그대로 읽을 수 있는 스트림이다. 데이터가 한 번에 다 오는 게 아니라, 조각(chunk) 단위로 흘러오는 걸 순서대로 꺼낼 수 있는 구조다.
const response = await fetch('/api/stream');
const reader = response.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
// value는 Uint8Array — raw bytes
console.log(new TextDecoder().decode(value));
}
reader.read()를 호출할 때마다 다음 청크가 올 때까지 기다렸다가 반환한다. 서버가 1초에 한 토큰씩 보내면, read()도 1초에 한 번씩 resolve된다. 이게 정상 동작이다.
여기서 중요한 건 value가 Uint8Array라는 것이다. 날것의 바이트다. 텍스트로 쓰려면 TextDecoder로 직접 변환해야 하고, SSE 포맷이면 data: ...\n\n을 직접 파싱해야 한다. ReadableStream은 프로토콜을 모른다. 그냥 바이트가 흘러오는 파이프다.
EventSource는 그 위에서 SSE 프로토콜을 이해하는 고수준 클라이언트다. 내부적으로는 같은 HTTP 연결에서 바이트를 받지만, 브라우저가 직접 파싱해서 이벤트로 만들어준다.
const es = new EventSource('/api/stream');
es.onmessage = (e) => {
console.log(e.data); // 이미 파싱된 문자열, "data: " 접두사 없음
};
추상화 레벨이 다르다. ReadableStream은 "바이트를 꺼내는 것"이고, EventSource는 "이벤트를 받는 것"이다.
SSE 서버가 이런 걸 보낸다고 하면:
data: 안녕\n\n
data: 하세요\n\n
ReadableStream으로 읽으면 이게 한 청크로 올 수도 있고, 두 개로 나뉘어 올 수도 있고, 심지어 data: 안녕\n\nda + ta: 하세요\n\n처럼 이벤트 경계 중간에서 잘릴 수도 있다. 파싱 코드가 이 모든 경우를 처리해야 한다.
EventSource는 그냥 e.data에 "안녕"이 오고, 다음엔 "하세요"가 온다.
두 방식을 나란히 놓으면
EventSource의 제약은 하나다. GET만 된다. 요청 body를 직접 실을 수 없다.
// EventSource
const es = new EventSource('/api/stream');
es.onmessage = (e) => console.log(e.data);
es.onerror = () => es.close();
fetch + getReader()는 POST도 되고, binary나 NDJSON 같은 포맷도 처리할 수 있다. 범용성은 높지만 SSE 파싱은 직접 해야 한다.
// fetch + getReader
const res = await fetch('/api/stream', {
method: 'POST',
body: JSON.stringify(payload),
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// \n\n 기준으로 이벤트 파싱
const events = buffer.split(/\r?\n\r?\n/);
buffer = events.pop() ?? '';
for (const event of events) {
const line = event.replace(/^data: /, '').trim();
if (line) console.log(line);
}
}
| EventSource | fetch + getReader | |
|---|---|---|
| HTTP 메서드 | GET만 | GET / POST 모두 |
| SSE 파싱 | 브라우저 처리 | 직접 구현 |
| 자동 재연결 | 있음 | 없음 |
| 범용성 | SSE 전용 | 스트림 전반 |
뭘 써야 하나
결론부터 말하면, 목적에 따라 다르고, 잘못 쓰면 이번처럼 디버깅에 며칠 쓴다.
AI 토큰 스트리밍 같은 실시간 UI가 목적이라면 EventSource가 낫다.
브라우저가 파싱을 맡아줘서 구현이 단순하고, 미들웨어나 브라우저 확장 프로그램의 간섭에 강하다. 연결이 끊기면 자동으로 재연결도 된다. Last-Event-ID 헤더를 활용하면 어디서부터 이어받을지도 서버가 처리할 수 있다.
POST body가 필요하다면 fetch + getReader()를 쓰거나, Next.js route handler가 중간에서 POST를 처리하고 브라우저에는 GET SSE로 relay하는 구조로 해결할 수 있다. 실제로 OpenAI, Anthropic 같은 AI API들은 POST로 프롬프트를 보내고 SSE 포맷으로 응답을 받는다. 클라이언트에서 EventSource를 못 쓰는 이유가 POST 때문이다.
이 문제로 며칠을 썼는데, 그게 억울하기보다는 브라우저가 fetch와 EventSource를 내부에서 어떻게 다르게 처리하는지 제대로 파보는 계기가 됐다. 알고 나면 단순한 차이인데, 모르고 있을 때의 디버깅은 생각보다 훨씬 멀리까지 가게 됐다.