45.1 서버가 먼저 말 걸 수 있어요?
지금까지 본 폴링과 롱폴링에는 공통된 답답함이 있었어요. 언제나 브라우저가 먼저 물어봐야 서버가 답한다는 거였죠. 그런데 알림이나 시세처럼 서버 쪽에서 소식이 계속 생기는 화면에선, 서버가 먼저 말을 걸어주는 게 훨씬 자연스러워요. 그걸 표준으로 만든 게 SSE(Server-Sent Events, 서버 전송 이벤트)예요.
SSE의 그림은 이래요. 브라우저가 연결을 한 번 열어두면, 그 연결을 끊지 않고 서버가 소식이 생길 때마다 그 관으로 데이터를 졸졸 흘려보내요. 라디오를 켜두면 방송국이 알아서 소리를 보내주는 것과 같아요. 나는 채널만 맞춰두면 되고, 새 소식이 오는 시점은 방송국이 정하죠. 게다가 이게 별도 서버 기술이 아니라 평범한 HTTP 위에서 돈다는 게 매력이에요. 저는 알림 기능을 웹소켓으로 만들려다, SSE로 바꾸고 코드가 반토막 난 뒤로 단방향 실시간엔 늘 이걸 먼저 떠올려요.
45.2 EventSource는 어떻게 써요?
브라우저 쪽은 놀랄 만큼 간단해요. EventSource라는 내장 객체 하나가 다 해줘요.
const es = new EventSource('/api/stream');
es.onmessage = (e) => {
const data = JSON.parse(e.data);
render(data); // 화면에 그리기
};
es.onerror = (e) => {
// 연결 문제 발생 시 호출됨
};
딱 세 부분이에요. 첫째, new EventSource(주소)로 연결을 열어요. 이 순간 브라우저가 그 주소로 연결을 걸고, 끊지 않은 채 소식을 기다려요. 둘째, onmessage는 서버가 데이터를 흘려보낼 때마다 자동으로 불려요. 받은 값은 e.data에 문자열로 담겨 있어서, JSON이면 JSON.parse로 풀어 쓰면 돼요. 셋째, onerror는 연결에 문제가 생겼을 때 불려요.
결과. 이 코드를 켜두면, 브라우저는 아무것도 안 물어봐요. 그냥 조용히 관을 열고 기다리다가, 서버가 뭔가 보내는 순간 onmessage가 저절로 돌면서 화면이 갱신돼요. setInterval도, 스스로를 다시 부르는 재귀 호출도 없어요. 폴링의 그 반복 요청이 통째로 사라진 거죠.
45.3 서버는 뭘 보내야 해요?
브라우저가 이렇게 편한 대신, 규칙은 서버가 지켜요. 두 가지만 맞추면 돼요. 첫째, 응답 헤더의 종류를 text/event-stream으로 정해요. 이게 "이건 한 번 주고 끝나는 응답이 아니라 계속 흐르는 방송이야"라고 브라우저에 알리는 표시예요. 둘째, 메시지를 정해진 글자 형식으로 흘려보내요.
data: {"user":"민지","text":"안녕하세요"}
data: {"user":"준","text":"반가워요"}
규칙은 단순해요. 한 줄을 data: 로 시작하고, 뒤에 보낼 내용을 붙여요. 그리고 메시지 하나가 끝났다는 표시로 빈 줄을 하나 넣어요. 이 빈 줄이 "여기까지가 한 덩어리"라는 마침표 역할을 해요. 브라우저는 이 형식을 알아서 해석해서, data 뒤의 내용을 e.data에 담아 onmessage를 불러줘요. 그래서 개발자는 파싱을 신경 쓸 필요가 없어요. 형식만 지키면 나머지는 브라우저가 다 처리해주니까요.
45.4 연결이 끊기면 어떻게 돼요?
여기가 SSE의 진짜 강점이에요. 실시간 연결은 지하철에서 신호가 끊기듯 언제든 툭 끊길 수 있어요. 폴링이야 다음 요청 때 알아서 복구되지만, 계속 열어두는 연결은 끊기면 다시 이어줘야 하죠. SSE는 이 재연결을 브라우저가 알아서 해줘요.
연결이 끊기면 onerror가 한 번 불리고, 브라우저는 잠깐 기다렸다가 같은 주소로 다시 연결을 시도해요. 우리가 재연결 코드를 한 줄도 안 짜도 그냥 돼요. 여기에 하나 더 있어요. 서버가 메시지에 id: 줄로 번호를 붙여 보내두면, 재연결할 때 브라우저가 Last-Event-ID라는 헤더에 마지막으로 받은 번호를 실어 보내요.
id: 101
data: {"text":"열번째 알림"}
서버는 이 번호를 보고 "아, 이 사람 101번까지 받았으니 102번부터 다시 보내면 되겠네" 하고 빠진 소식만 채워 줄 수 있어요. 끊긴 사이에 온 알림을 놓치지 않게 하는 장치죠. 롱폴링으로 이걸 다 손으로 구현하려면 꽤 번거로운데, SSE는 표준에 들어 있어요. 이게 제가 단방향 실시간에서 SSE를 아끼는 가장 큰 이유예요.
45.5 이벤트 종류를 나눌 수 있어요?
알림 하나에도 종류가 여럿일 수 있죠. 새 댓글, 좋아요, 시스템 공지처럼요. 이걸 다 onmessage 하나로 받아 안에서 갈라도 되지만, SSE는 이름표를 붙이는 방법을 줘요. 서버가 event: 줄로 이름을 달아 보내요.
event: notice
data: {"text":"점검 예정입니다"}
브라우저 쪽에선 그 이름으로 따로 받아요.
es.addEventListener('notice', (e) => {
const data = JSON.parse(e.data);
showNotice(data);
});
이렇게 하면 notice라는 이름의 소식은 이 함수로, 이름 없는 기본 소식은 onmessage로 갈려서 들어와요. 종류마다 처리 함수를 깔끔하게 나눌 수 있는 거죠. 규모가 커질수록 이 이름표가 코드를 정돈해줘요. 저는 알림 종류가 세 개 넘어가면 무조건 event 이름을 나눠서, 나중에 종류가 늘어도 함수만 하나 더 붙이면 되게 해둬요.
45.6 SSE의 한계는요?
좋은 만큼 분명한 선도 있어요. 세 가지만 기억하세요.
SSE의 한계
1. 단방향: 서버에서 브라우저로만 흐름. 브라우저가 보낼 땐 따로 fetch를 써야 함.
2. 텍스트 전용: 이미지 같은 이진 데이터는 못 보냄. 글자만.
3. 연결 수 제한: 옛 방식(HTTP/1.1) 브라우저는 한 도메인에 6개까지만 연결.
첫째가 핵심이에요. SSE는 받기 전용이에요. 채팅처럼 내가 자주 보내기도 해야 하면 SSE만으론 반쪽이에요. 물론 보내는 건 평범한 fetch POST로 하고 받는 것만 SSE로 쓰는 조합도 가능하지만, 오갈 게 많으면 그때가 웹소켓 자리예요. 셋째 연결 수 제한은 요즘 쓰는 최신 연결 방식(HTTP/2)에선 크게 완화되니 너무 겁먹진 마세요. 이 세 선만 알면, SSE를 어디까지 밀어붙일 수 있는지 감이 잡혀요.
45.7 언제 SSE가 맞아요?
정리 기준을 드릴게요. 아래가 많이 겹치면 SSE가 답이에요.
SSE가 잘 맞는 경우
1. 서버가 소식을 밀어주고, 브라우저는 받기만 한다
2. 소식이 자주, 불규칙하게 온다
3. 끊겨도 자동으로 이어지고 빠진 걸 채우고 싶다
4. 웹소켓 서버까지는 부담스럽고 HTTP로 끝내고 싶다
실시간 알림, 시세와 지표 대시보드, 진행률 표시(파일 변환 30%, 60%...), 뉴스 속보 피드가 전부 SSE의 무대예요. 이런 단방향 화면에 웹소켓을 쓰는 건 대개 과해요. 저는 판단할 때 딱 하나만 물어요. "브라우저가 서버로 보낼 게 있나?" 없으면 SSE, 있고 자주면 웹소켓. 이 질문이 여러분의 시간을 많이 아껴줄 거예요.
45.8 정리하면요?
SSE를 한 장으로 접어볼게요.
핵심
1. SSE는 서버가 브라우저로 소식을 흘려보내는 단방향 실시간이에요.
2. 브라우저는 new EventSource와 onmessage 딱 둘이면 받아요.
3. 서버는 text/event-stream 헤더에 "data:" 형식으로 흘려보내요.
4. 재연결과 놓친 소식 채우기(Last-Event-ID)를 브라우저가 알아서 해줘요.
SSE의 진짜 가치는 받기만 하는 실시간을 가장 적은 코드로 만든다는 데 있어요. 폴링의 반복 요청도 없고, 롱폴링의 재귀 호출도 없고, 재연결도 공짜죠. 그러니 실시간이 필요할 때 무턱대고 웹소켓을 꺼내기 전에, "혹시 나 받기만 하면 되는 거 아냐?"를 먼저 물어보세요. 그 한 번의 질문으로 절반의 프로젝트는 훨씬 단순해진다는 것, 그게 이번 이야기에서 챙겨 가셨으면 하는 알맹이예요.