본문 바로가기
TYLER SONGBlog
← Blog index
CS6 min read

SSE: HTTP 연결 하나로 서버 이벤트를 지속 전송하는 단방향 스트리밍

서버 전송 이벤트(SSE, Server-Sent Events)는 서버가 하나의 HTTP 응답을 닫지 않고 브라우저에 텍스트 이벤트를 계속 보내는 방식이다. 브라우저의 이벤트 소스(EventSource)는 연결 종료 시 자동으로 재연결하며 마지막 이벤트 식별자를 전달한다. 알림, 작업 진행률, 실시간 로그처럼 서버에서 클라이언트로만 데이터가 흐르는 기능에 적

By 송민성

1. 개념

서버 전송 이벤트(SSE, Server-Sent Events)는 HTTP 연결을 유지하면서 서버가 클라이언트에 여러 이벤트를 순차적으로 전송하는 기술이다.

서버는 응답의 콘텐츠 유형(Content-Type)을 text/event-stream으로 지정한다. 각 이벤트는 UTF-8 텍스트 필드로 구성되며 빈 줄로 구분한다.

text
id: 42
event: progress
data: {"percent":80}

주요 필드는 다음과 같다.

  • data: 클라이언트에 전달할 데이터
  • event: 이벤트 종류
  • id: 이벤트 식별자
  • retry: 연결이 끊겼을 때 재연결까지 기다릴 시간
  • :로 시작하는 줄: 클라이언트가 무시하는 주석이며 연결 유지 신호로 활용 가능

SSE는 서버에서 클라이언트로만 데이터를 보내는 단방향 통신이다. 클라이언트가 서버에 데이터를 보내야 하면 별도의 HTTP 요청을 사용한다.

2. 왜 사용하는가

일반 HTTP 요청은 서버가 응답을 완료하면 연결 단위의 작업이 끝난다. 클라이언트가 새로운 정보를 받으려면 주기적으로 서버에 요청하는 폴링(polling)이 필요하다.

text
폴링:
클라이언트 → 요청 → 서버
클라이언트 → 요청 → 서버
클라이언트 → 요청 → 서버

SSE:
클라이언트 → 연결 요청 → 서버
클라이언트 ← 이벤트 1 ← 서버
클라이언트 ← 이벤트 2 ← 서버
클라이언트 ← 이벤트 3 ← 서버

SSE는 다음 조건에서 폴링보다 적합하다.

  • 서버에서 새 데이터가 발생하는 시점이 일정하지 않다.
  • 수 초보다 짧은 지연으로 변경 사항을 전달해야 한다.
  • 통신 방향이 대부분 서버에서 클라이언트 방향이다.
  • 메시지가 텍스트 또는 JSON으로 표현 가능하다.
  • 연결 복구 기능을 직접 복잡하게 구현하고 싶지 않다.

양방향 통신, 이진 데이터, 빈번한 클라이언트 메시지가 필요하면 웹소켓(WebSocket)이 더 적합하다.

3. 동작 원리

연결 수립

브라우저는 일반적인 HTTP GET 요청을 보낸다.

http
GET /events HTTP/1.1 Accept: text/event-stream

서버는 응답을 끝내지 않고 연결을 유지한다.

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

이벤트 전송

하나의 이벤트는 여러 필드를 줄바꿈으로 나열하고 마지막에 빈 줄을 추가한다.

text
id: 1
event: notification
data: {"message":"배포가 시작되었습니다."}

id: 2
event: notification
data: {"message":"배포가 완료되었습니다."}

data 필드가 여러 줄이면 브라우저는 각 값을 줄바꿈으로 연결한다.

text
data: 첫 번째 줄
data: 두 번째 줄

재연결

연결이 끊기면 이벤트 소스(EventSource)는 자동으로 재연결을 시도한다. 이전 이벤트에 id가 있었다면 브라우저는 마지막 이벤트 식별자(Last-Event-ID)를 요청에 포함할 수 있다.

http
GET /events HTTP/1.1 Accept: text/event-stream Last-Event-ID: 42

서버가 이벤트 이력을 보관한다면 43번 이벤트부터 다시 전송할 수 있다. 이력을 보관하지 않으면 재연결은 가능하지만 누락된 이벤트 복구는 불가능하다.

연결 유지

프록시나 로드 밸런서는 일정 시간 동안 데이터가 없는 연결을 종료할 수 있다. 서버는 주기적으로 주석 형태의 하트비트(heartbeat)를 전송해 연결이 살아 있음을 알린다.

text
: heartbeat

4. 코드 예제

다음 예제는 Node.js 내장 HTTP 서버만 사용한다. 연결된 모든 클라이언트에 1초마다 진행률 이벤트를 전송한다.

typescript
import { createServer, type ServerResponse } from "node:http"; const clients = new Set<ServerResponse>(); let eventId = 0; let percent = 0; const server = createServer((request, response) => { if (request.url === "/") { response.writeHead(200, { "Content-Type": "text/html; charset=utf-8", }); response.end(` <!doctype html> <html lang="ko"> <head> <meta charset="UTF-8" /> <title>SSE 예제</title> </head> <body> <h1>작업 진행률</h1> <progress id="progress" max="100" value="0"></progress> <output id="value">0%</output> <script> const progress = document.querySelector("#progress"); const value = document.querySelector("#value"); const source = new EventSource("/events"); source.addEventListener("progress", (event) => { const data = JSON.parse(event.data); progress.value = data.percent; value.textContent = data.percent + "%"; }); source.onerror = () => { value.textContent = "연결 복구 중"; }; </script> </body> </html> `); return; } if (request.url === "/events") { response.writeHead(200, { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-cache, no-transform", "Connection": "keep-alive", "X-Accel-Buffering": "no", }); response.write("retry: 3000\n"); response.write(": connected\n\n"); clients.add(response); request.on("close", () => { clients.delete(response); }); return; } response.writeHead(404); response.end("찾을 수 없습니다."); }); setInterval(() => { percent = (percent + 10) % 110; eventId += 1; const message = [ `id: ${eventId}`, "event: progress", `data: ${JSON.stringify({ percent })}`, "", "", ].join("\n"); for (const client of clients) { client.write(message); } }, 1000); setInterval(() => { for (const client of clients) { client.write(": heartbeat\n\n"); } }, 15_000); server.listen(3000, () => { console.log("http://localhost:3000"); });

실행한다.

shell
npm install --save-dev tsx npx tsx server.ts

브라우저에서 http://localhost:3000을 열면 진행률이 실시간으로 갱신된다.

5. 시간 복잡도 또는 성능 특성

클라이언트가 n개 연결되어 있을 때 하나의 이벤트를 전체 클라이언트에 전송하는 시간복잡도는 O(n)이다. 서버가 각 연결의 응답 객체를 저장하므로 연결 관리에 필요한 메모리는 O(n)이다.

메시지 크기를 m바이트라고 하면 전체 클라이언트에 한 번 전송하는 데이터양은 O(nm)이다. 초당 r개의 이벤트를 보낸다면 대략적인 전송량은 다음과 같다.

text
초당 전송량 ≈ 클라이언트 수 × 초당 이벤트 수 × 이벤트 크기

SSE 연결은 오래 유지되므로 서버와 프록시가 많은 동시 연결을 처리할 수 있어야 한다. HTTP/2에서는 여러 요청을 하나의 네트워크 연결에 다중화(multiplexing)할 수 있어 여러 SSE 스트림을 사용할 때 HTTP/1.1보다 연결 관리에 유리하다.

전송 지연은 애플리케이션 서버만으로 결정되지 않는다. 웹 서버, 압축 계층, 역방향 프록시(reverse proxy)가 작은 응답 조각을 모아 버퍼링하면 이벤트가 즉시 전달되지 않을 수 있다.

6. 실무 사용 사례

작업 진행률

파일 변환, 데이터 내보내기, 인공지능 추론처럼 오래 걸리는 작업의 상태를 전달한다. 작업 시작은 POST 요청으로 처리하고 진행 상황은 SSE로 구독한다.

text
POST /jobs              → 작업 생성
GET /jobs/123/events    → 진행률 구독

알림 피드

새 댓글, 주문 상태 변경, 관리자 알림처럼 서버에서 발생한 사건을 브라우저에 전달한다.

실시간 로그

배포 로그와 배치 작업 로그를 생성 순서대로 표시한다. 로그 유실이 문제라면 이벤트 id와 영속 저장소를 함께 사용한다.

스트리밍 응답

서버에서 생성되는 텍스트 조각을 순차적으로 전송한다. 클라이언트 입력은 별도 HTTP 요청으로 보내고 생성 결과는 SSE 스트림으로 받는다.

7. 주의할 점

프록시 버퍼링

프록시가 응답을 모아 전송하면 실시간성이 사라진다. 엔진엑스(Nginx)를 사용한다면 SSE 경로의 버퍼링을 비활성화한다.

text
location /events {
    proxy_pass http://app;
    proxy_buffering off;
    proxy_cache off;
    proxy_read_timeout 1h;
}

인증 정보 전달

브라우저의 기본 이벤트 소스(EventSource) 생성자는 임의의 HTTP 헤더를 설정하는 기능을 제공하지 않는다. 인증 토큰을 URL 쿼리에 넣으면 서버 로그, 브라우저 기록, 분석 도구에 노출될 수 있다.

같은 출처(same-origin) 환경에서는 보안 쿠키를 사용하는 방식이 적합하다. 교차 출처 요청에서 쿠키를 사용하려면 서버의 교차 출처 리소스 공유(CORS) 설정과 withCredentials 옵션을 함께 맞춰야 한다.

typescript
const source = new EventSource("https://api.example.com/events", { withCredentials: true, });

이벤트 중복

서버가 이벤트를 전송한 직후 연결이 끊기면 클라이언트가 해당 이벤트를 처리했는지 서버가 확정하기 어렵다. 재연결 후 같은 이벤트가 다시 전달될 수 있으므로 중요한 처리는 이벤트 id를 기준으로 멱등성(idempotency)을 확보한다.

느린 클라이언트

클라이언트가 데이터를 읽는 속도보다 서버의 전송 속도가 빠르면 송신 버퍼가 커질 수 있다. Node.js의 response.write()가 false를 반환하면 배압(backpressure)이 발생한 상태다. 큰 이벤트나 높은 전송 빈도를 사용한다면 drain 이벤트 대기, 이벤트 병합, 느린 연결 종료 정책이 필요하다.

연결 종료 처리

클라이언트가 페이지를 닫거나 네트워크를 잃으면 서버의 연결 목록에서 해당 응답을 제거해야 한다. 정리하지 않으면 메모리와 연결 자원이 계속 남는다.

8. 핵심 정리

  • SSE는 text/event-stream 응답을 유지하며 서버 이벤트를 전송하는 단방향 HTTP 스트리밍 방식이다.
  • 이벤트는 data, event, id, retry 필드와 빈 줄로 구성한다.
  • 이벤트 소스(EventSource)는 연결 종료 시 자동 재연결하며 마지막 이벤트 식별자를 이용할 수 있다.
  • 알림, 진행률, 로그처럼 서버 중심의 텍스트 스트림에 적합하다.
  • 실무에서는 프록시 버퍼링, 하트비트, 인증, 이벤트 중복, 배압, 연결 정리를 함께 처리해야 한다.