SSE (Server-Sent Events)는 서버가 클라이언트에게 일방적으로 데이터를 지속해서 보내는(Push) 표준 기술이다.
HTTP 프로토콜 위에서 동작하므로, WebSocket처럼 별도의 프로토콜 없이 기존 웹 인프라를 그대로 활용할 수 있다.
핵심 특징
EventSource API는 네트워크 연결이 끊어졌을 때 자동으로 재연결을 시도하는 기능이 내장되어 있다.ws:// 같은 별도 프로토콜이 아닌, 익숙한 HTTP/HTTPS 위에서 동작하여 구현과 디버깅이 비교적 쉽다.text/event-stream이라는 단순한 텍스트 형식으로 데이터를 주고받는다.SSE vs WebSocket 비교
| 구분 | SSE | WebSocket |
|---|---|---|
| 통신 방향 | 단방향 (서버→클라이언트) | 양방향 |
| 주요 용도 | 알림, 데이터 스트리밍 | 채팅, 온라인 게임 |
| 재연결 | API 자체 지원 (자동) | 직접 구현 필요 |
| 프로토콜 | HTTP/HTTPS | ws://, wss:// |
프론트엔드에서 SSE를 사용하는 방법은 크게 세 가지이며, 인증 헤더를 보낼 수 있는 EventSourcePolyfill를 권장한다.
| 방법 | 핵심 특징 | 장점 | 단점 |
|---|---|---|---|
EventSource (네이티브) | 브라우저 기본 API | 간단함, 설치 불필요 | 커스텀 헤더 불가 |
EventSourcePolyfill (권장) | 기능 확장 라이브러리 | 커스텀 헤더 가능, 호환성 | 외부 의존성 추가 |
fetch + ReadableStream | 저수준(Low-level) API | 최고의 유연성 | 매우 복잡, 재연결 직접 구현 |
EventSource: Authorization 헤더를 보낼 수 없어 인증이 필요한 실무 API에는 사용하기 어렵다.EventSourcePolyfill: headers 옵션을 통해 인증 토큰을 쉽게 전송할 수 있다.fetch + ReadableStream: 스트림을 직접 제어해야 하는 매우 특수한 상황(예: 스트림 변환)이 아니면 지양.EventSourcePolyfill을 사용한 기본적인 클라이언트/서버 연동 예시이다.
서버 (Node.js/Express 예시)
백엔드에서는 Content-Type: text/event-stream 헤더와 함께 정해진 형식(data:, id:, event:)으로 데이터를 보내야 한다.
const express = require('express');
const cors = require('cors');
const app = express();
const PORT = 3001;
app.use(cors());
// SSE 엔드포인트 설정
app.get('/sse', (req, res) => {
// 1. 응답 헤더 설정
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders(); // 즉시 헤더 전송
// 인증 헤더 확인 (Polyfill을 사용하는 이유!)
const token = req.headers.authorization;
console.log(`클라이언트 연결 완료. (인증 토큰: ${token})`);
// 2. 1초마다 데이터 전송
const intervalId = setInterval(() => {
const data = { time: new Date().toLocaleTimeString() };
// SSE 데이터 형식: "data: JSON문자열\n\n"
res.write(`data: ${JSON.stringify(data)}\n\n`);
}, 1000);
// 3. 클라이언트 연결 종료 시 처리
req.on('close', () => {
clearInterval(intervalId);
res.end();
console.log('클라이언트 연결 종료.');
});
});
app.listen(PORT, () => {
console.log(`SSE 서버가 http://localhost:${PORT} 에서 실행 중입니다.`);
});
클라이언트 (React, EventSourcePolyfill 사용)
클라이언트는 EventSourcePolyfill 객체를 생성하여 서버에 연결하고, addEventListener로 특정 이벤트를 수신한다.
// 1. EventSourcePolyfill 객체 생성
const eventSource = new EventSourcePolyfill('http://localhost:3001/sse', {
headers: {
Authorization: `Bearer ${accessToken}`
},
heartbeatTimeout: 86400000 // 타임아웃 시간 길게 설정 (옵션)
});
// 2. 'message' 이벤트 리스너 등록 (기본 이벤트)
eventSource.onmessage = (event) => {
// event.data는 서버가 보낸 JSON 문자열
const data = JSON.parse(event.data);
timeDisplay.textContent = data.time;
console.log('메시지 수신:', data);
};
// 3. 'open' 이벤트 리스너 (연결 성공 시)
eventSource.onopen = () => {
console.log('SSE 연결 성공!');
};
// 4. 'error' 이벤트 리스너 (에러 발생 시)
eventSource.onerror = (error) => {
console.error('EventSource 에러 발생:', error);
// event-stream이 아니기 때문에 error인 경우
const isNotStream = error.status === 200;
// 에러 발생 시 연결이 자동으로 닫히므로, 필요 시 여기서 재연결 로직 구현 가능
eventSource.close();
};
EventSourcePolyfill 코드를 까보면 아래와 같이 event-stream이 아닌 경우 status 200 && error로 확인 가능함.
// EventSourcePolyfill eventsource.js
var contentTypeRegExp = /^text\/event\-stream(;.*)?$/i;
var onStart = function (status, statusText, contentType, headers) {
if (currentState === CONNECTING) {
if (status === 200 && contentType != undefined && contentTypeRegExp.test(contentType)) {
currentState = OPEN;
wasActivity = Date.now();
retry = initialRetry;
es.readyState = OPEN;
var event = new ConnectionEvent('open', {
status: status,
statusText: statusText,
headers: headers
});
es.dispatchEvent(event);
fire(es, es.onopen, event);
} else {
var message = '';
if (status !== 200) {
if (statusText) {
statusText = statusText.replace(/\s+/g, ' ');
}
message = "EventSource's response has a status " + status + ' ' + statusText + ' that is not 200. Aborting the connection.';
} else {
message =
"EventSource's response has a Content-Type specifying an unsupported type: " +
(contentType == undefined ? '-' : contentType.replace(/\s+/g, ' ')) +
'. Aborting the connection.';
}
close();
var event = new ConnectionEvent('error', {
status: status,
statusText: statusText,
headers: headers
});
es.dispatchEvent(event);
fire(es, es.onerror, event);
console.error(message);
}
}
};
SPA에서의 생명주기 관리
React, Vue 등 SPA 환경에서는 컴포넌트가 화면에서 사라질 때(unmount) sse.close()를 호출하여 반드시 연결을 끊어야 한다. 그렇지 않으면 불필요한 연결이 계속 남아 메모리 누수 및 성능 저하의 원인이 된다.
// React useEffect 훅을 이용한 생명주기 관리
useEffect(() => {
const sse = new EventSourcePolyfill(...);
// ... 이벤트 리스너 등록 ...
// cleanup 함수: 컴포넌트가 unmount될 때 실행됨
return () => {
sse.close();
};
}, []); // 의존성 배열이 비어있어 마운트 시 한 번만 실행
Nginx 등 리버스 프록시 설정
Nginx는 기본적으로 백엔드의 응답을 버퍼링했다가 한번에 클라이언트로 보내는데, 이는 SSE의 스트리밍 동작을 방해한다. SSE 엔드포인트에 대해서는 반드시 프록시 버퍼링을 꺼야 한다.
# /etc/nginx/conf.d/default.conf
location /events {
proxy_pass http://your-backend-server;
proxy_set_header Connection '';
proxy_http_version 1.1;
# (핵심) 프록시 버퍼링 비활성화
proxy_buffering off;
# 캐시 관련 헤더 설정
proxy_cache off;
}