Server-Sent Events
고친 사람 github-actions[bot]
Server-Sent Events 는 서버가 새 소식이 생길 때마다 브라우저로 밀어 보내게 해 줍니다. 브라우저가 요청을 한 번 걸어 두면 서버는 응답을 끝내지 않고 소식을 이어 붙입니다. 소식은 서버에서 브라우저로 한 방향으로만 흐릅니다.
쉽고 빠른 이해
서버가 할 말이 생기면 브라우저에 곧바로 흘려보내는 규칙입니다. 주문 화면에서 「배송 중」이 새로고침 없이 바로 뜨는 것이 이 규칙이 하는 일입니다.
이게 없으면 브라우저가 새 소식이 있는지 몇 초마다 되물어야 합니다. 되묻는 사이에 소식이 늦고, 아무 일도 없을 때도 요청이 오갑니다.
어떻게 도는가:
- 브라우저가 평범한 웹 요청을 하나 보냅니다
- 서버는 답을 시작만 하고 끝내지 않습니다. 소식이 생길 때마다 그 답에 한 토막씩 덧붙입니다
- 연결이 끊기면 브라우저가 알아서 다시 붙습니다. 이때 마지막으로 받은 번호를 알려 줍니다
대가도 있습니다. 브라우저 쪽에서는 이 연결로 말을 걸 수 없습니다. 서버는 붙어 있는 브라우저 수만큼 연결을 계속 들고 있어야 합니다. 보낼 수 있는 것도 글자뿐입니다.
상세
라디오 방송을 떠올리면 모양이 잡힙니다. 청취자는 주파수를 한 번 맞추기만 합니다. 그 뒤로는 방송국이 보낼 것이 생길 때마다 흘려보냅니다. 청취자는 켜 둔 채 듣기만 합니다.
Server-Sent Events 가 그 방송에 해당합니다. 브라우저가 연결을 한 번 열어 두면 서버가 소식을 생기는 대로 내려보냅니다. 브라우저는 그 연결로 서버에 말을 걸지 않습니다.
서버가 먼저 소식을 미는 문제
HTTP(HyperText Transfer Protocol, 하이퍼텍스트 전송 프로토콜)는 클라이언트가 요청하면 서버가 응답하는 한 쌍으로 돌아갑니다. 클라이언트는 요청을 보내는 쪽입니다. 이 문서에서 클라이언트는 곧 브라우저를 가리킵니다. 응답은 언제나 앞선 요청의 답입니다. 그래서 서버는 자기가 먼저 말을 꺼낼 방법이 없습니다.
그런데 알림·진행률·시세처럼 소식이 서버 쪽에서 먼저 생기는 화면이 있습니다. 가장 단순한 해법은 브라우저가 정해진 간격으로 계속 물어보는 폴링입니다. 조금 나은 해법은 서버가 소식이 생길 때까지 답을 붙들고 있는 롱 폴링입니다. 둘 다 소식 하나를 받으면 요청을 새로 걸어야 합니다.
Server-Sent Events 는 한 걸음 더 갑니다. 응답 하나를 끝내지 않고 열어 둡니다. 그 응답에 소식을 한 토막씩 계속 덧붙입니다. 소식이 몇 개가 오든 요청은 처음 한 번뿐입니다.
새로운 전송 규칙을 만든 것이 아닙니다. HTTP 응답 위에 글자 형식 하나를 정한 것입니다.
이 형식은 HTML(HyperText Markup Language, 하이퍼텍스트 마크업 언어) 표준 안에 정의되어 있습니다. 그 표준을 관리하는 단체는 WHATWG(Web Hypertext Application Technology Working Group)입니다.
끝나지 않는 응답 하나
이 절은 요청 하나와 응답 하나가 오가는 모습을 실제 글자로 보입니다. 먼저 낱말 둘을 정해 둡니다. 서버가 보내는 소식 한 토막을 이벤트라고 부릅니다. 이벤트가 줄지어 흐르는 응답 본문 전체는 이벤트 스트림이라고 부릅니다.
HTTP 요청과 응답의 앞머리에는 헤더가 붙습니다. 헤더는 이름: 값 꼴의 줄로 적는 부가
정보입니다. 본문이 어떤 형식인지, 어떤 형식을 받고 싶은지 같은 것을 여기에 적습니다.
형식은 MIME(Multipurpose Internet Mail Extensions) 타입이라 부르는 이름표로 가리킵니다.
이벤트 스트림의 이름표는 text/event-stream 입니다.
브라우저는 GET 요청을 보냅니다. Accept 헤더에 text/event-stream 을 적어 이벤트 스트림을
받겠다고 알립니다.
GET /events HTTP/1.1
Host: example.com
Accept: text/event-stream
서버는 상태 코드 200 으로 답을 시작합니다. 200 은 요청이 성공했다는 뜻입니다. 본문의
형식을 알리는 Content-Type 헤더에는 text/event-stream 을 적습니다.
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Cache-Control: no-cache 는 이 응답을 저장해 두었다가 확인 없이 다시 내주지 말라는
표시입니다. 새 소식이 흘러야 할 응답이 저장된 옛 사본으로 바뀌면 안 되기 때문입니다.
응답을 닫지 않는 것이 핵심입니다. 헤더를 보낸 뒤로 서버는 소식이 생길 때마다 본문에 이벤트를 하나씩 써 넣습니다. 브라우저는 응답이 다 오기를 기다리지 않고 이벤트가 올 때마다 읽습니다.
sequenceDiagram
participant 브라우저
participant 서버
브라우저->>서버: GET · text/event-stream 을 받겠다
서버-->>브라우저: 200 · text/event-stream · 응답을 닫지 않는다
Note over 서버: 소식이 생길 때까지 기다린다
서버-->>브라우저: 이벤트 하나
서버-->>브라우저: 이벤트 하나
Note over 브라우저,서버: 어느 한쪽이 연결을 닫을 때까지 이어진다
화살표가 전부 서버에서 브라우저로 향합니다. 브라우저가 서버에 무언가 보내고 싶으면 이 연결이 아니라 별도의 HTTP 요청을 씁니다.
이벤트 스트림의 생김새
본문은 줄 단위 글자입니다. 한 줄은 이름: 값 꼴입니다. 빈 줄 하나가 이벤트 하나의 끝을
표시합니다. 글자는 언제나 UTF-8(Unicode Transformation Format 8-bit)로 적습니다.
: 연결 유지용 주석
event: order
id: 41
data: {"orderId": 7, "status": "배송 중"}
data: 첫 줄
data: 둘째 줄
retry: 5000
위 본문에는 이벤트가 둘 들어 있습니다. 첫 이벤트는 이름과 번호가 붙은 주문 소식입니다. 둘째는 이름 없이 두 줄짜리 내용만 실었습니다. 줄 이름은 넷뿐입니다.
| 줄 이름 | 담는 것 |
|---|---|
data |
이벤트의 내용. 여러 줄이면 받는 쪽이 줄바꿈으로 이어 붙인다 |
event |
이벤트의 이름. 없으면 message 로 본다 |
id |
이벤트의 번호. 다시 붙을 때 어디까지 받았는지 알리는 데 쓴다 |
retry |
끊긴 뒤 다시 붙기 전에 기다릴 시간. 밀리초 단위 숫자다 |
콜론으로 시작하는 줄은 주석입니다. 받는 쪽이 읽고 버립니다. 쓸모는 연결 유지입니다.
아무 이벤트도 안 흐르는 시간이 길면 중간 장비가 연결을 죽은 것으로 보고 끊습니다. 브라우저와 서버 사이에 끼어 요청을 대신 날라 주는 프록시가 그런 장비입니다. 서버가 주기적으로 주석 한 줄을 흘리면 연결에 무언가 계속 지나가므로 끊기지 않습니다.
data 에 무엇을 담을지는 정해져 있지 않습니다. 그냥 글자입니다. 구조가 있는 값을 보내려면
위 예처럼 JSON(JavaScript Object Notation)으로 적어 넣는 경우가 많습니다.
끊기면 다시 붙는 규칙
오래 열어 두는 연결은 언젠가 끊깁니다. 망이 흔들리거나 서버가 재시작하기도 합니다. 이 규칙은 끊긴 뒤를 브라우저가 맡도록 정해 두었습니다. 개발자가 다시 붙는 코드를 짜지 않아도 됩니다.
브라우저는 연결이 끊기면 잠시 기다렸다가 같은 주소로 다시 요청을 보냅니다. 기다리는 시간은
서버가 retry 줄로 바꿀 수 있습니다.
다시 보내는 요청에는 Last-Event-ID 헤더가 붙습니다. ID(identifier, 식별자)는 이벤트에 붙은
번호를 말합니다. 이 헤더에는 마지막으로 받은 이벤트의 id 값이 실립니다. 서버는 그 번호를
보고 끊긴 사이에 놓친 이벤트부터 다시 보낼 수 있습니다.
GET /events HTTP/1.1
Accept: text/event-stream
Last-Event-ID: 41
놓친 이벤트를 채워 주는 일은 서버 몫입니다. 서버가 지나간 이벤트를 어딘가 남겨 두지 않으면 번호를 받아도 채울 것이 없습니다.
sequenceDiagram
participant 브라우저
participant 서버
서버-->>브라우저: 이벤트 · id 41
Note over 브라우저,서버: 연결이 끊긴다
Note over 브라우저: retry 만큼 기다린다
브라우저->>서버: GET · Last-Event-ID 41
Note over 서버: 지난 이벤트를 남겨 두었어야 42부터 채울 수 있다
서버-->>브라우저: 이벤트 · id 42
서버-->>브라우저: 이벤트 · id 43
다시 붙지 않는 경우도 있습니다. 서버가 200 이 아닌 상태 코드로 답하거나 Content-Type 이
text/event-stream 이 아니면, 브라우저는 연결을 실패로 보고 더는 시도하지 않습니다. 서버가
「이제 그만 붙어라」를 말하는 방법이 이것입니다.
브라우저 쪽 연결은 세 상태를 오갑니다.
stateDiagram-v2
[*] --> 연결중
연결중 --> 열림: 200 과 text/event-stream 을 받았다
연결중 --> 닫힘: 다른 상태 코드나 다른 형식이 왔다
열림 --> 연결중: 연결이 끊겼다 · 기다렸다 다시 요청한다
열림 --> 닫힘: 페이지가 스스로 닫았다
닫힘 --> [*]
끊김에서 다시 붙는 화살표가 이 그림의 요점입니다. 열림에서 끊기면 닫힘이 아니라 연결중으로 돌아갑니다.
브라우저의 EventSource
브라우저에서는 JavaScript 객체 EventSource 하나로 이 규칙을 씁니다. 주소를 넘겨 만들면 그 순간 요청이 나갑니다. 이벤트가 올 때마다 등록한 함수가 불립니다.
const source = new EventSource("/events");
source.onmessage = (e) => {
console.log(e.data); // 이름 없는 이벤트
};
source.addEventListener("order", (e) => {
console.log(e.lastEventId, e.data); // event: order 로 온 이벤트
});
이름 없는 이벤트는 message 로 들어옵니다. event 줄로 이름을 붙인 이벤트는 그 이름으로
따로 받습니다. 다시 붙기와 Last-Event-ID 는 이 객체가 알아서 처리합니다. 그만 받으려면
source.close() 를 부릅니다.
이 객체에는 요청 헤더를 직접 넣을 방법이 없습니다. 고를 수 있는 것은 주소, 그리고 다른
도메인으로 보낼 때 쿠키를 함께 실을지 정하는 withCredentials 하나뿐입니다. 그래서 인증
토큰을 헤더에 싣는 서비스는 쿠키로 인증합니다. 아니면 EventSource 대신 fetch 로
요청을 보내고 응답 본문을 도착하는 대로 조금씩 읽는 코드를 씁니다. 이 길이면 헤더는 마음대로
넣을 수 있습니다. 대신 이벤트 나누기와 다시 붙기를 직접 짜야 합니다.
오래 열린 응답이 치르는 대가
첫째는 연결을 들고 있는 비용입니다. 브라우저 만 개가 붙어 있으면 서버는 응답 만 개를 동시에 열어 두어야 합니다. 스레드는 서버 안에서 일을 하나씩 맡아 도는 실행 단위입니다. 요청마다 스레드 하나를 붙잡는 서버라면 스레드가 먼저 바닥납니다.
그래서 적은 스레드로 많은 연결을 돌리는 구조와 잘 맞습니다. 논블로킹 입출력은 읽을 데이터가 아직 없을 때 스레드를 멈춰 세우지 않는 입출력 방식입니다. 이벤트 루프는 스레드 하나가 준비된 연결을 차례로 돌며 처리하는 구조입니다. 둘을 함께 쓰면 스레드 몇 개로 연결 수만 개를 감당합니다.
둘째는 중간 장비입니다. 서버 바로 앞에 서서 요청을 받아 넘기는 장비를 리버스 프록시라고
합니다. 일부 프록시와 리버스 프록시는 응답이 다 모일 때까지 모아 두었다가 한꺼번에 넘깁니다.
이것을 응답 버퍼링이라고 합니다. 리버스 프록시로 흔히 쓰는 nginx 도 응답 버퍼링
(proxy_buffering)이 기본으로 켜져 있습니다.
sequenceDiagram
participant 서버
participant 리버스 프록시
participant 브라우저
서버->>리버스 프록시: 이벤트 1
서버->>리버스 프록시: 이벤트 2
서버->>리버스 프록시: 이벤트 3
Note over 리버스 프록시: 넘기지 않고 쌓아 둔다
리버스 프록시->>브라우저: 이벤트 1 · 2 · 3 을 한꺼번에
버퍼링하는 장비가 끼면 이벤트가 제때 안 가고 뭉쳐서 늦게 도착합니다. 이 경로만큼은 버퍼링을 꺼 두어야 합니다.
셋째는 연결 수 상한입니다. HTTP/1.1 에서 브라우저 대부분은 한 도메인에 동시에 여는 연결을 6개로 묶어 둡니다. 탭을 여러 개 열어 탭마다 스트림을 하나씩 걸면 그 상한을 금방 채웁니다. 그러면 같은 도메인의 다른 요청이 줄을 섭니다. HTTP/2 는 연결 하나에 여러 요청을 섞어 보내므로 이 문제가 크게 줄어듭니다.
넷째는 담을 수 있는 것입니다. 본문이 UTF-8 글자라 이미지 같은 바이트 덩어리는 그대로 못 보냅니다. 글자로 바꿔 보내면 크기가 불어납니다.
다섯째는 서버가 여럿일 때입니다. 어떤 브라우저가 서버 1 에 붙어 있으면 그 브라우저에 갈 소식은 서버 1 이 보내야 합니다. 소식이 서버 2 에서 생기면 서버끼리 옮겨 줄 중계가 따로 필요합니다. 소식을 한 곳에 올리면 구독한 서버 모두가 받아 가는 발행-구독 방식이 흔히 그 일을 합니다.
다른 실시간 전달 방식과 견주기
서버의 소식을 브라우저에 전하는 방식은 넷이 나란히 섭니다. 누가 먼저 말을 거는지와 연결 하나가 얼마나 오래 사는지로 견주면 이렇습니다.
| 방식 | 누가 말을 거나 | 연결이 사는 동안 | 소식 하나를 받으면 |
|---|---|---|---|
| 폴링 | 브라우저만 | 곧바로 답을 받고 끝 | 다음 물음 때 받는다 |
| 롱 폴링 | 브라우저만 | 소식이나 타임아웃으로 답할 때까지 | 응답이 끝나고 다시 묻는다 |
| Server-Sent Events | 서버만 | 닫을 때까지 | 열린 응답에 이어 붙는다 |
| 웹소켓 | 양쪽 다 | 닫을 때까지 | 열린 연결로 곧바로 간다 |
웹소켓과 가르는 것은 방향입니다. 웹소켓은 HTTP 연결을 다른 규칙으로 갈아타서 양쪽이 아무 때나 보냅니다. Server-Sent Events 는 갈아타지 않고 HTTP 응답으로 남습니다. 그래서 로그인 확인이나 요청 기록처럼 서버에 이미 있는 HTTP 처리를 손대지 않고 씁니다. 다시 붙기와 놓친 이벤트 번호도 규칙 안에 들어 있습니다.
소식이 서버에서만 나오는 화면에 맞습니다. 알림 목록, 작업 진행률, 시세판, 답을 글자 단위로 흘려보내는 채팅형 화면이 그렇습니다. 브라우저도 자주 말을 걸어야 하는 채팅방이나 게임이라면 웹소켓 쪽이 맞습니다.
소식이 드물고 몇 초 늦어도 괜찮다면 폴링으로 충분합니다. 소식을 곧바로 받아야 하는데 길목의 중간 장비가 오래 열린 응답을 끊거나 쌓아 둔다면, 답 하나마다 응답을 끝내는 롱 폴링이 대안으로 남습니다.
flowchart TD
A{"브라우저도 이 연결로 자주 말을 거나"} -->|그렇다| W["웹소켓"]
A -->|아니다| B{"소식을 곧바로 받아야 하나"}
B -->|아니다| P["폴링"]
B -->|그렇다| C{"중간 장비가 오래 열린 응답을 그냥 흘려보내나"}
C -->|그렇다| S["Server-Sent Events"]
C -->|아니다| L["롱 폴링"]
관련 항목
Server-Sent Events 와 겨루는 실시간 전달 방식
폴링 · 롱 폴링 · 웹소켓 · Comet · 웹훅 · 푸시 알림 · HTTP 스트리밍
Server-Sent Events 가 올라타는 프로토콜
HTTP · HTTP/1.1 · HTTP/2 · HTTP/3 · HTTPS · TCP · TLS · 청크 전송 인코딩
Server-Sent Events 를 정의하는 표준과 단체
Server-Sent Events 를 받는 브라우저 쪽 장치
EventSource · JavaScript · 브라우저 · 쿠키 · CORS
이벤트 스트림을 이루는 구성 요소
이벤트 · 이벤트 스트림 · 헤더 · 상태 코드 · JSON
끊긴 연결을 이어 붙이는 장치
재연결 · 재시도 · 지수 백오프 · 하트비트 · 타임아웃 · 멱등성
응답이 지나가는 중간 장비
프록시 · 리버스 프록시 · 로드 밸런서 · nginx · 버퍼링 · 방화벽
열린 응답을 여럿 감당하는 서버 구조
논블로킹 입출력 · 이벤트 루프 · 비동기 · 스레드 풀 · 파일 디스크립터 · C10K 문제
여러 서버 사이에서 소식을 나르는 수단
다른 이름: server-sent events · 서버 전송 이벤트 · 서버 센트 이벤트