LabHub
배우기 러닝패스 코스

SSE — 서버가 먼저 말하는 법 · 재접속했더니 3분이 비어 있었다 · 이론

끊기는 것은 정상이다, 비는 것이 사고다

LabHub 에서 이어서 보기

한 줄 요약

SSE 의 재접속은 브라우저가 공짜로 해 주지만, 빈 자리를 메우는 것은 서버의 일이다.
그 연결 고리가 id 필드와 Last-Event-ID 헤더 둘뿐이다.

왜 이게 필요했나

지하철에서 알림 화면을 열어 두면 연결은 몇 분에 한 번씩 끊긴다. 브라우저는
알아서 다시 붙는다 — 여기까지는 EventSource 가 해 준다. 문제는 **끊겨 있던
동안 서버가 보낸 것들**이다.

그냥 다시 붙기만 하면 그 구간은 영영 사라진다. 알림 세 개가 없었던 일이 되고,
주문 상태는 "결제 완료" 에서 갑자기 "배송 중" 으로 뛴다. 반대로 서버가 늘
처음부터 다시 보내면 같은 알림이 두 번 뜬다. 둘 다 사용자가 바로 알아채는
종류의 고장이다.

명세는 이 문제를 아주 작은 장치 하나로 푼다. 서버가 이벤트마다 id 를 붙이면
브라우저가 마지막으로 본 id 를 기억했다가, 다시 붙을 때 Last-Event-ID
요청 헤더로 돌려준다. 서버는 그 뒤부터 보내면 된다.

어떻게 동작하나

명세가 정한 것은 생각보다 촘촘하다

[HTML 표준 9.2](https://html.spec.whatwg.org/multipage/server-sent-events.html)
의 파싱 규칙은 한 줄씩 읽으며 이렇게 움직인다. 기억해야 할 것은 셋이다 —
data 버퍼, event type 버퍼, 그리고 last event ID 버퍼.

빈 줄            → 이벤트를 내보낸다콜론으로 시작    → 그 줄은 무시 (주석 · 하트비트)콜론이 있다      → 앞이 필드 이름, 뒤가 값. 값이 공백으로 시작하면 하나만 뗀다콜론이 없다      → 줄 전체가 필드 이름, 값은 빈 문자열

필드별 처리도 명세에 그대로 적혀 있다.

| 필드 | 규칙 |
|---|---|
| data | 값을 버퍼에 붙이고 개행 하나를 더 붙인다 |
| event | 이벤트 타입 버퍼를 그 값으로 바꾼다 |
| id | 값에 U+0000 NULL 이 없을 때만 마지막 id 버퍼를 바꾼다 |
| retry | 값이 ASCII 숫자로만 이루어졌을 때만 재접속 시간을 바꾼다 |
| 그 밖 | 무시한다 |

여기서 사람들이 가장 자주 틀리는 두 가지가 있다. 첫째, **마지막 id 버퍼는
이벤트를 내보낸 뒤에도 초기화되지 않는다.** data 버퍼와 event type 버퍼만
비워진다. 그래서 id 없는 이벤트가 이어져도 재접속에 쓸 번호는 그대로 남는다.
둘째, data 버퍼가 빈 문자열이면 이벤트를 내보내지 않는다. retry: 만 적힌
블록이나 주석만 있는 블록은 설정이지 이벤트가 아니다.

끝이 잘린 이벤트는 버린다

명세는 못을 박는다 — 파일이 마지막 빈 줄 전에 끝나면 **그 불완전한 이벤트는
내보내지 않는다.** 연결이 이벤트 한복판에서 끊겼을 때 반쪽짜리 JSON 을 화면에
올리지 않기 위한 규칙이고, 동시에 Last-Event-ID실제로 다 받은 것까지만
가리키게 해 준다. 이어 받기가 정확해지는 이유가 여기에 있다.

재접속은 브라우저가, 빈 자리는 서버가

재접속 시각도 명세에 있다. 브라우저는 에러를 던지고 readyState
CONNECTING 으로 바꾼 뒤 재접속 시간만큼 기다렸다가 다시 붙는다. 그
시간의 초기값은 구현에 맡겨져 있고(명세는 "몇 초 정도" 라고만 적는다),
서버가 retry: 로 바꿀 수 있다. 이전 시도가 실패했으면 브라우저가 지수 후퇴를
더 넣어도 된다고 명세가 허용한다.

그리고 다시 붙을 때, 마지막 id 문자열이 빈 문자열이 아닐 때만
Last-Event-ID 헤더를 싣는다. 서버는 헤더가 없으면 "처음부터", 있으면 "그
뒤부터" 로 갈라 처리하면 된다.

서버 쪽 짝은 되돌려 줄 수 있는 창이다. 최근 N 개를 고리 버퍼에 들고 있다가
요청받은 id 뒤를 돌려준다. 창을 벗어난 id 라면 남은 것을 보내는 대신 "이어 받을
수 없다" 를 알려 전체를 다시 받게 해야 한다. 모르는 id 에 있는 것만 보내면
가운데가 비고, 그 사실을 아무도 모른다.

이어 받기는 서버가 기억하는 만큼만 된다

클라이언트가 헤더를 잘 실어 보내도, 서버가 아무것도 기억하지 않으면 이어 받기는
성립하지 않는다. 되돌림 창의 크기는 결국 얼마나 오래 끊겨 있어도 되는가
정하는 값이다. 창이 100개인데 초당 50개를 보내는 스트림이라면 2초짜리 보험밖에
안 된다. 창을 시간으로 잡을지 개수로 잡을지, 그리고 벗어났을 때 전체 재전송이
감당되는 크기인지를 먼저 계산하고 숫자를 정해야 한다.

현장에서 만나는 모습

LLM 토큰 스트리밍에서 흔한 사고가 있다. 토큰마다 id 를 붙이지 않아서 재접속이
늘 처음부터가 되고, 사용자는 같은 문장이 두 번 찍히는 것을 본다. 반대로 id 는
붙였는데 서버가 아무것도 기억하지 않아, 이어 받기 요청에 늘 빈 스트림을
돌려주는 경우도 있다 — 이쪽은 "가끔 답이 중간에 멈춘다" 로 접수된다.

id 를 정수로 파싱한 팀도 있었다. 명세에서 id 는 **NULL·LF·CR 만 없으면 되는
문자열**이라, 샤딩 때문에 b7-1042 같은 id 를 쓰기 시작한 날 이어 받기가 통째로
죽었다. 예외는 서버 로그에만 남고 화면은 조용히 처음부터 다시 받았다.

다음 실습에서 할 것

명세의 파싱 규칙을 그대로 구현한다. 조각으로 쪼개진 입력, 세 가지 줄 끝,
주석, 콜론 없는 줄, NULL 이 든 id, 숫자가 아닌 retry, 그리고 잘린 마지막
이벤트. 여기에 서버 쪽 되돌림 창과 재접속 헤더까지 붙인다.