SSE(Server-Sent Events)란 무엇인가
SSE는 Server-Sent Events의 약자로, 서버가 클라이언트로 실시간 데이터를 단방향으로 푸시(push)할 수 있게 해주는 웹 기술임 클라이언트가 먼저 요청을 보내고 서버는 그 연결을 끊지 않은 채, 새로운 데이터가 생길 때마다 지속적으로 응답을 보내는 방식임
주로 실시간 알림, 주식 시세 업데이트, 라이브 피드 등 서버에서 클라이언트로 일방적인 데이터 전송이 필요한 경우에 매우 유용함
SSE vs 웹소켓, 그리고 한계
SSE는 실시간 이벤트 전송에 유용하지만 만능은 아님. 웹소켓과 비교했을 때 명확한 장단점이 존재함
장점
- 간단한 구현
EventSourceAPI(이 예제에선 안 썼지만)는 사용이 매우 간편하며 자동 재연결 기능까지 내장함. 서버 측도 기존 HTTP 서버에서 헤더 설정만 추가하면 됨 - HTTP 친화적 (HTTP/2의 강력한 이점)
- HTTP/1.1의 한계: 브라우저는 동일 도메인당 최대 6개 연결 제한이 있어, SSE가 1개를 점유하면 다른 API 요청이 지연(Head-of-Line Blocking)될 수 있음
- HTTP/2의 해결책: HTTP/2는 하나의 TCP 연결 내에서 여러 요청/응답을 스트림(Stream)으로 다중화(Multiplexing)함. 즉, SSE 연결(스트림 1)이 열려 있는 상태에서도 다른 API 요청(스트림 2, 3)이 동일한 TCP 연결을 통해 지연 없이 동시에 처리됨
- 결론: 프로덕션 환경에서는 6개 연결 제한 회피를 위해 HTTP/2 (HTTPS) 환경 구성을 강력히 권장함
- 유지보수 용이 프로토콜 자체가 단순하여 디버깅이나 유지보수가 웹소켓보다 쉬움
한계
- 단방향 통신 (One-way)
가장 큰 한계로, 오직 서버에서 클라이언트로만 데이터를 보낼 수 있음. 클라이언트가 서버로 데이터를 보내려면 별도의
fetch나axios를 사용한 POST 요청이 필요함 - 연결 수 제한 (HTTP/1.1) 위에서 언급했듯이, HTTP/1.1 환경에서는 브라우저의 도메인당 연결 수 제한(보통 6개)에 영향을 받음
프로토콜: 표준 HTTP
SSE는 웹소켓(WebSocket)처럼 ws:// 같은 별도의 프로토콜을 사용하지 않음
우리가 일반적으로 사용하는 표준 HTTP/1.1 (또는 HTTP/2) 프로토콜 위에서 동작함
핵심 동작 원리는, 클라이언트의 최초 HTTP GET 요청에 대해 서버가 연결을 종료(close)하지 않고, 응답을 스트리밍하는 것임
SSE 연결 (서버 측 설정)
클라이언트가 SSE 연결을 요청할 때(예: /events), 서버는 일반적인 HTML이나 JSON이 아닌, 이벤트 스트림임을 알리는 특정 HTTP 헤더로 응답해야 함
Content-Type: text/event-stream(필수)Connection: keep-alive(연결 유지)Cache-Control: no-cache(중간 프록시나 브라우저가 응답을 캐시하지 않도록 방지)
// Node.js (Express 예시)
// npm install express
import express from "express";
const app = express();
// 연결된 모든 클라이언트(res 객체)를 저장
const clients = new Set();
app.get("/events", (req, res) => {
// 1. SSE 연결을 위한 필수 헤더 설정
res.writeHead(200, {
"Content-Type": "text/event-stream",
Connection: "keep-alive",
"Cache-Control": "no-cache",
});
// 2. 연결된 클라이언트를 Set에 추가
clients.add(res);
console.log("Client connected");
// 3. 클라이언트가 연결을 끊었을 때의 처리
req.on("close", () => {
clients.delete(res);
console.log("Client disconnected");
});
});
app.listen(3000, () => console.log("SSE server listening on port 3000"));
SSE 연결 (클라이언트 측 - EventSource 미사용)
브라우저에는 EventSource라는 SSE 전용 API가 있지만, 여기서는 SSE가 HTTP 위에서 어떻게 동작하는지 명확히 보기 위해 fetch API의 스트리밍 기능을 사용함
fetch를 사용하면 서버의 응답 바디(body)를 ReadableStream으로 받아 청크(chunk) 단위로 처리할 수 있음
// Client-side (Browser JS)
async function connectSSE() {
let response;
try {
response = await fetch("http://localhost:3000/events");
} catch (error) {
console.error("Connection failed:", error);
return;
}
// 1. 응답 바디에서 ReadableStream의 리더(reader)를 가져옴
const reader = response.body.getReader();
// 2. 텍스트 디코더 준비 (바이너리 데이터를 UTF-8 텍스트로 변환)
const decoder = new TextDecoder();
// 3. 스트림이 끝날 때(done)까지 무한 루프
while (true) {
const { done, value } = await reader.read();
if (done) {
console.log("Stream finished");
break; // 서버가 연결을 종료함
}
// 4. 수신된 바이너리 청크(value)를 텍스트로 디코딩
const rawChunk = decoder.decode(value);
// (이후 이 청크를 파싱하는 로직이 필요함)
parseStreamChunk(rawChunk);
}
}
// (파싱 로직은 아래 '이벤트 파싱' 섹션 참고)
connectSSE();
SSE 이벤트 형식과 송신 (서버 측)
SSE는 정해진 텍스트 형식을 사용함. 가장 중요한 필드는 data:임
각 메시지는 두 번의 개행(\n\n)으로 구분됨
data: [보낼 데이터](필수)event: [이벤트 이름](옵션, 클라이언트가 이벤트를 구분할 때 사용)id: [메시지 ID](옵션, 재연결 시 마지막 ID를 서버로 보냄)
clients Set에 저장된 res 객체에 res.write()를 사용해 데이터를 전송함
// Node.js (Express) - 서버 어디에서든 호출 가능
// 모든 연결된 클라이언트에게 브로드캐스트하는 함수
function broadcast(eventName, data) {
// 1. (옵션) 'update'라는 이름의 이벤트를 지정
const message =
`event: ${eventName}\n` +
// 2. (필수) 실제 데이터를 'data:' 필드에 담아 전송
`data: ${data}\n` +
// 3. (필수) 메시지의 끝을 알리는 개행 전송
`\n`;
for (const res of clients) {
res.write(message);
}
}
// 2초마다 모든 클라이언트에게 'update' 이벤트 전송
setInterval(() => {
broadcast("update", `This is message number ${Date.now()}`);
}, 2000);
JSON 데이터 전송 시 주의점
data: 필드가 여러 줄일 경우 파서는 이를 \n으로 이어붙임
이는 JSON을 여러 줄의 data: 필드로 쪼개어 보내면 안 된다는 것을 의미함
// 서버에서 JSON 전송 시
const payload = { user: "test", value: 123 };
// [Good ✅] JSON은 반드시 한 줄의 data 필드로 전송
broadcast("update", JSON.stringify(payload));
// (전송되는 텍스트: 'event: update\ndata: {"user":"test","value":123}\n\n')
// [Bad ❌] 아래와 같이 여러 data 라인에 걸쳐 JSON을 쪼개면
// res.write(`data: {"user": "test",\n`)
// res.write(`data: "value": 123}\n\n`)
// 클라이언트 파서는 망가진 텍스트('{"user": "test",\n"value": 123}')를 받게 되어
// JSON.parse()에서 오류가 발생함
SSE 이벤트 파싱 (클라이언트 측 - EventSource 미사용)
서버가 보낸 데이터 청크는 메시지 경계(\n\n)와 정확히 일치하지 않을 수 있음
따라서 클라이언트는 데이터를 버퍼(buffer)에 쌓아두고, 메시지 구분자(\n\n)가 나타날 때마다 파싱해야 함
// Client-side (Browser JS)
let buffer = ""; // 수신된 청크를 임시 저장할 버퍼
// connectSSE()의 read() 루프에서 호출되는 함수
function parseStreamChunk(rawChunk) {
// 1. 버퍼에 새로 수신된 청크를 추가
buffer += rawChunk;
// 2. 버퍼에서 메시지 구분자(\n\n)를 찾음
let boundary = buffer.indexOf("\n\n");
// 3. 구분자가 존재하면 (즉, 메시지가 하나 이상 완성되면)
while (boundary !== -1) {
// 4. 완성된 메시지 한 개를 추출 (구분자 앞까지)
const rawMessage = buffer.substring(0, boundary);
// 5. 버퍼에서 처리된 메시지+구분자 제거
buffer = buffer.substring(boundary + 2);
// 6. 추출한 메시지 파싱
parseMessage(rawMessage);
// 7. 버퍼에 또 다른 메시지가 있는지 확인
boundary = buffer.indexOf("\n\n");
}
}
// 수신된 raw 메시지를 파싱하는 헬퍼 함수
function parseMessage(rawMessage) {
const lines = rawMessage.split("\n");
let eventType = "message"; // event: 필드가 없으면 기본값 'message'
let eventData = "";
for (const line of lines) {
if (line.startsWith("event:")) {
eventType = line.substring(6).trim();
} else if (line.startsWith("data:")) {
// data 필드가 여러 줄일 경우를 대비해 (JSON이 아닌 경우)
eventData += line.substring(5).trim();
}
}
if (eventData) {
console.log(`[Event Received] Type: ${eventType}, Data: ${eventData}`);
// 여기서 DOM을 업데이트하거나 알림을 표시하는 등 실제 작업 수행
}
}
하트비트 (Heartbeat)
일부 프록시 서버나 방화벽은 일정 시간 동안 데이터 전송이 없으면 해당 연결을 유휴(idle) 상태로 간주하고 강제로 종료할 수 있음 이를 방지하기 위해 서버는 주기적으로 의미 없는 데이터(주석)를 보내 연결이 살아있음을 알려야 함. 이를 하트비트라고 함
SSE 스펙에서 콜론(:)으로 시작하는 라인은 주석으로 취급되며 클라이언트에서 무시됨
// Node.js (Express) - 서버 측
// 15초마다 하트비트 전송
setInterval(() => {
// : 주석은 클라이언트의 message 이벤트로 전달되지 않음
const heartbeatMessage = ": heartbeat\n\n";
for (const res of clients) {
res.write(heartbeatMessage);
}
}, 15000);
클라이언트 측에서는 parseMessage 함수가 event:나 data:가 아닌 라인을 무시하므로 별도 코드가 필요 없음
서버에서 특정 연결 강제 종료
인증 만료, 중복 로그인 등의 이유로 서버가 특정 사용자의 연결을 선별적으로 종료해야 할 수 있음
// Node.js (Express) - /events 핸들러
app.get("/events", (req, res) => {
// ... 헤더 설정 ...
// (실제로는 JWT 등에서 파싱한 ID)
res.locals.userId = "user-" + Math.floor(Math.random() * 1000);
clients.add(res);
req.on("close", () => {
clients.delete(res);
});
});
// (어드민 API 등) 특정 사용자를 종료시켜야 할 때
function kickUser(userIdToKick, reason) {
for (const res of clients) {
if (res.locals.userId === userIdToKick) {
// 1. 클라이언트가 처리할 수 있는 에러 이벤트를 전송
const message = `event: error\ndata: ${JSON.stringify({
code: "KICKED",
reason: reason,
})}\n\n`;
res.write(message);
// 2. 스트림을 정상적으로 종료 (클라이언트의 req.on('close')가 호출됨)
res.end();
console.log(`Kicked user: ${userIdToKick}`);
break;
}
}
}
// 예시: 5초 뒤 특정 유저 강제 종료
setTimeout(() => {
// (실제로는 ID를 특정해야 함)
if (clients.size > 0) {
const userToKick = clients.values().next().value.locals.userId;
kickUser(userToKick, "Session expired");
}
}, 5000);
마무리
SSE는 단방향 실시간 데이터(알림, 시세, 피드) 전송이 필요할 때 구현이 간단하고 효율적인 훌륭한 선택지임
반면, 채팅이나 실시간 온라인 게임처럼 클라이언트와 서버가 지속적으로 데이터를 주고받는 양방향 통신이 필수적이라면 웹소켓이 적합함
프로젝트의 요구사항을 명확히 파악하고, 단방향인지 양방향인지를 기준으로 적절한 기술 스펙을 고르는 것이 중요함
참고자료
- MDN - Server-Sent Events 사용하기 https://developer.mozilla.org/ko/docs/Web/API/Server-Sent_Events/Using_Server-Sent_Events
- WHATWG - Server-Sent Events 스펙 https://html.spec.whatwg.org/multipage/server-sent-events.html