한양과학기술고등학교

에듀테크 프로젝트 대시보드

Server-Sent Events (SSE)

Server-Sent Events (SSE)

웹소켓처럼 연결을 열어두지만, 말은 서버만 한다 — 단방향이라 더 단순하고, 끊기면 브라우저가 알아서 다시 잇는다

주가 시세, 배포 진행 로그, 알림처럼 서버 → 클라이언트 한 방향으로만 데이터가 계속 흘러가면 되는 경우가 많다. 이럴 때 굳이 양방향 연결인 웹소켓을 쓰지 않고, 평범한 HTTP 응답을 끝나지 않는 스트림으로 열어두는 것이 SSE다. 브라우저 내장 EventSource API 하나로 연결·파싱·재연결까지 다 처리된다.

추가 프로토콜 업그레이드가 없어 방화벽·프록시 호환성이 좋고, 연결이 끊기면 클라이언트가 자동으로 재연결하며 마지막으로 받은 이벤트 ID부터 이어받을 수 있다 — 이 두 가지가 웹소켓과 가장 다른 지점이다.

① 라이브 스트림 데모 — 실시간 알림 흉내내기

"연결"을 누르면 서버가 무작위 간격으로 이벤트를 push한다고 가정하고 스트림을 흉내낸다. "네트워크 끊기"를 눌러 연결을 강제로 끊어보면, retry 시간 뒤 자동 재연결하며 마지막 id 이후부터 이어받는 과정이 보인다.

연결 안 됨 — GET /stream/notifications
0수신 이벤트
-마지막 id
0재연결 횟수
▶ 연결 버튼을 눌러 스트림을 시작해보세요.

② text/event-stream 포맷 뜯어보기

응답의 Content-Type이 text/event-stream이면, 서버는 연결을 닫지 않고 아래 형식의 텍스트 블록을 계속 흘려보낸다. 빈 줄(\n\n)이 이벤트 하나의 끝을 의미한다.

HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive

# 이벤트 1
id: 101
event: price_update
data: {"symbol":"KOSPI","price":2650.3}

# 이벤트 2 (여러 줄 data는 이어붙여짐)
id: 102
event: alert
data: {"level":"warn",
data: "msg":"거래량 급증"}

# 재연결 시 대기 시간(ms) 지정
retry: 3000
필드의미
id이벤트 식별자. 재연결 시 Last-Event-ID 헤더로 서버에 다시 전달됨
event이벤트 이름. 클라이언트에서 addEventListener(name, ...)로 구분 수신
data실제 내용(보통 JSON 문자열). 여러 줄이면 개행으로 합쳐짐
retry연결이 끊겼을 때 재시도까지 기다릴 시간(ms)

③ 클라이언트 코드 — EventSource

const es = new EventSource('/stream/notifications');

es.addEventListener('price_update', (e) => {
  const data = JSON.parse(e.data);
  // e.lastEventId 로 마지막 id 확인 가능
  console.log(data.symbol, data.price);
});

es.onerror = () => {
  // 연결이 끊기면 브라우저가 retry 시간 뒤 자동 재연결
  // 이때 Last-Event-ID 헤더를 자동으로 붙여 보냄
};
재연결은 브라우저가 알아서 한다. 따로 재시도 로직을 짤 필요 없이, EventSource가 끊기면 retry 값만큼 기다렸다가 스스로 재접속하고, 요청 헤더에 Last-Event-ID: 102 처럼 마지막 id를 실어 보낸다. 서버는 이 값을 보고 놓친 이벤트부터 다시 흘려주면 된다.

④ 서버 쪽 코드는 어떤 모습일까 (FastAPI)

from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
import asyncio, json

app = FastAPI()

async def event_gen(last_id: int):
    eid = last_id
    while True:
        await asyncio.sleep(2)
        eid += 1
        payload = json.dumps({"price": 2650 + eid})
        yield f"id: {eid}\nevent: price_update\ndata: {payload}\n\n"

@app.get("/stream/notifications")
async def stream(request: Request):
    last_id = int(request.headers.get("last-event-id", 0))
    return StreamingResponse(event_gen(last_id), media_type="text/event-stream")

⑤ SSE vs 웹소켓 vs 폴링, 언제 무엇을 쓸까

상황추천이유
서버 → 클라이언트 단방향 스트림SSE구현 단순, HTTP 그대로, 자동 재연결·Last-Event-ID 내장
채팅처럼 양쪽 다 실시간 전송웹소켓양방향 저지연 통신이 필요할 때만
몇 분에 한 번 갱신이면 충분폴링연결 유지 자체가 필요 없는 낮은 빈도

단점도 있다: SSE는 텍스트 전용이라 바이너리를 못 보내고, 브라우저별로 한 origin당 동시 연결 수 제한(HTTP/1.1 기준 보통 6개)이 있다. 또 오직 서버→클라이언트 방향만 가능해, 클라이언트가 뭔가 보내려면 별도의 일반 HTTP 요청을 함께 써야 한다.

한 줄 정리

SSE는 "서버만 계속 말하는" 가벼운 실시간 채널이다. 평범한 HTTP 위에서 동작해 인프라 호환성이 좋고, EventSource가 재연결·이어받기까지 알아서 처리해준다는 점이 웹소켓과의 핵심 차이다.