실시간 단방향 통신을 위한 Server-Sent Events(SSE)

실시간 단방향 통신을 위한 Server-Sent Events(SSE)

ChatGPT에게 질문을 던지면 답변이 한 글자씩 타이핑되듯 흘러나오는 모습, 다들 익숙하시죠? 증권 앱의 시세가 새로고침 없이 실시간으로 바뀌거나, 웹 대시보드에 알림이 띵 하고 도착하는 것도 마찬가지인데요. 이런 화면들의 공통점은 서버에서 새로운 데이터가 생길 때마다 클라이언트가 곧바로 받아본다는 것입니다.

실시간 양방향 통신을 위한 웹소켓(WebSocket) 글에서 우리는 HTTP의 한계와 이를 극복하는 웹소켓을 살펴봤는데요. 그런데 위 예시를 가만히 보면 한 가지 공통점이 더 있습니다. 데이터가 서버에서 클라이언트로 한 방향으로만 흐른다는 점입니다. 채팅이나 온라인 게임처럼 클라이언트도 끊임없이 서버로 메시지를 보내야 하는 상황이 아니라면, 굳이 양방향 통신을 위한 웹소켓까지 동원할 필요가 있을까요? 🤔

바로 이럴 때 가볍게 쓸 수 있는 기술이 Server-Sent Events(이하 SSE)입니다. 이 글에서는 SSE가 무엇이고 어떻게 동작하는지, 그리고 웹소켓과 비교했을 때 언제 SSE를 선택하면 좋을지 함께 알아보겠습니다.

Server-Sent Events란?

Server-Sent Events는 이름 그대로 서버가 보내는(server-sent) 이벤트를 클라이언트가 받아보는 기술입니다. 서버와 클라이언트가 연결을 한 번 맺어두면, 그 연결을 유지한 채로 서버가 원할 때마다 클라이언트로 데이터를 밀어보낼(push) 수 있습니다.

가장 큰 특징은 웹소켓처럼 별도의 프로토콜이 아니라 우리가 늘 쓰던 HTTP 위에서 그대로 동작한다는 점입니다. 클라이언트가 평범한 HTTP 요청을 한 번 보내면, 서버는 그 응답을 끝내지 않고 계속 열어둔 채로 데이터를 조금씩 흘려보냅니다.

서버에서 클라이언트로 데이터를 전달하는 가장 원시적인 방법은 클라이언트가 주기적으로 서버에 물어보는 폴링(polling)입니다. 하지만 “새로운 거 있어요?”라고 1초마다 물어보는데 정작 변화는 1분에 한 번뿐이라면, 대부분의 요청은 헛수고가 됩니다. SSE는 이런 낭비 없이 변화가 생긴 바로 그 순간에 서버가 먼저 알려준다는 점에서 훨씬 효율적입니다.

SSE는 어떻게 동작하나요?

SSE 연결도 시작은 평범한 HTTP 요청입니다. 다만 클라이언트가 “나는 이벤트 스트림을 받고 싶다”는 신호로 Accept: text/event-stream 헤더를 보내고, 서버는 응답 헤더의 Content-Typetext/event-stream으로 지정합니다.

서버 응답 헤더
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive

웹소켓이 101 Switching Protocols로 프로토콜을 전환했던 것과 달리, SSE는 그냥 200 OK 응답입니다. 프로토콜을 바꾸지 않고 평범한 HTTP 응답을 길게 끌어서 쓰는 셈이죠. HTTP 상태 코드가 헷갈린다면 웹 개발자를 위한 HTTP 상태 코드 안내서를 함께 참고해보세요.

연결이 열리고 나면 서버는 정해진 텍스트 포맷으로 메시지를 흘려보냅니다. 가장 기본은 data: 필드이고, 메시지 하나는 빈 줄(줄바꿈 두 번, \n\n)로 끝납니다.

이벤트 스트림 포맷
data: 안녕하세요

data: 두 번째 메시지입니다

event: price
data: {"symbol":"AAPL","price":192.5}
id: 42

retry: 5000
data: 재연결 간격은 5초입니다

여기서 data는 실제로 전달할 데이터입니다. event는 이벤트에 이름을 붙여 클라이언트가 종류별로 다르게 처리할 수 있게 해주고, id는 메시지마다 고유 번호를 매겨 재연결 시 이어받기를 가능하게 합니다. 마지막으로 retry는 연결이 끊겼을 때 클라이언트가 몇 밀리초 후에 다시 연결할지를 알려줍니다.

SSE 클라이언트: EventSource

좋은 소식은 브라우저가 SSE를 위한 EventSource라는 API를 기본으로 제공한다는 것입니다. 웹소켓에서 WebSocket 객체를 만들었던 것처럼, SSE에서는 EventSource 객체를 만들기만 하면 됩니다.

const source = new EventSource("/events");

이 한 줄이면 브라우저가 알아서 서버에 연결을 맺고, 서버가 보내는 메시지를 기다립니다. 서버에서 데이터가 도착하면 message 이벤트가 발생하는데, 웹소켓과 똑같이 onmessage 속성이나 addEventListener로 처리할 수 있습니다.

source.onopen = () => {
  console.log("서버와 연결되었습니다.");
};

source.onmessage = (event) => {
  console.log("받은 데이터:", event.data);
};

source.onerror = (error) => {
  console.error("연결에 문제가 생겼습니다:", error);
};

이벤트를 다루는 방식이 낯설다면 자바스크립트로 이벤트 처리하기를 함께 보시면 좋습니다.

서버가 event: 필드로 이름을 붙여 보낸 메시지는 그 이름으로 따로 받을 수 있습니다. 예를 들어 위 포맷 예시의 price 이벤트는 이렇게 처리합니다.

source.addEventListener("price", (event) => {
  const data = JSON.parse(event.data);
  console.log(`${data.symbol}의 현재가는 ${data.price}입니다.`);
});

여기서 SSE의 가장 큰 장점이 하나 드러납니다. 바로 자동 재연결입니다. 네트워크가 잠깐 끊기더라도 EventSource가 알아서 다시 연결을 시도합니다. 게다가 서버가 id: 필드로 메시지마다 번호를 붙여뒀다면, 브라우저는 재연결할 때 마지막으로 받은 번호를 Last-Event-ID 헤더에 담아 보냅니다. 서버는 이 값을 보고 “아, 42번까지 받았으니 43번부터 보내면 되겠구나” 하고 빠진 메시지를 이어서 보내줄 수 있죠. 웹소켓에서는 이런 재연결과 메시지 복구를 직접 구현해야 하지만, SSE는 표준에 포함되어 있어 공짜로 얻는 셈입니다.

연결을 닫고 싶을 때는 close() 메서드를 호출하면 됩니다.

source.close();

SSE 서버 구현하기

이번에는 서버 쪽을 살펴보겠습니다. SSE 서버의 핵심은 응답을 끝내지 않고 열어둔 채로, 약속된 포맷의 텍스트를 계속 써 내려가는 것입니다.

자바스크립트로 Node.js 서버를 구현한다면 Express로 다음과 같이 작성할 수 있습니다.

Express 설치
bun add express
# 또는 npm install express
server.js
const express = require("express");
const app = express();

app.get("/events", (req, res) => {
  // SSE를 위한 응답 헤더 설정
  res.writeHead(200, {
    "Content-Type": "text/event-stream",
    "Cache-Control": "no-cache",
    Connection: "keep-alive",
  });

  // 1초마다 현재 시각을 클라이언트로 전송
  const timer = setInterval(() => {
    res.write(`data: ${new Date().toISOString()}\n\n`);
  }, 1000);

  // 클라이언트가 연결을 끊으면 타이머 정리
  req.on("close", () => clearInterval(timer));
});

app.listen(8080, () => console.log("http://localhost:8080"));

여기서 두 가지가 중요합니다. 첫째, res.end()를 호출하지 않습니다. 응답을 끝내버리면 연결이 닫히기 때문에, 데이터를 보낼 때마다 res.write()만 사용합니다. 둘째, 각 메시지를 반드시 \n\n으로 끝맺어야 브라우저가 하나의 완결된 메시지로 인식합니다.

차세대 자바스크립트 런타임인 Bun에서는 ReadableStream을 응답으로 돌려주는 방식으로 더 간결하게 작성할 수 있습니다.

server.ts
Bun.serve({
  port: 8080,
  fetch(req) {
    const encoder = new TextEncoder();

    const stream = new ReadableStream({
      start(controller) {
        const timer = setInterval(() => {
          controller.enqueue(
            encoder.encode(`data: ${new Date().toISOString()}\n\n`),
          );
        }, 1000);

        // 연결이 끊기면 타이머 정리
        req.signal.addEventListener("abort", () => clearInterval(timer));
      },
    });

    return new Response(stream, {
      headers: {
        "Content-Type": "text/event-stream",
        "Cache-Control": "no-cache",
        Connection: "keep-alive",
      },
    });
  },
});

여기서 사용한 ReadableStream이 생소하다면 자바스크립트의 Streams API에서 더 자세히 다루고 있으니 참고해보세요.

웹소켓과 무엇이 다를까요?

SSE와 웹소켓은 둘 다 실시간 통신을 위한 기술이라 자주 비교되는데요. 핵심 차이는 통신의 방향입니다.

구분Server-Sent Events웹소켓(WebSocket)
통신 방향서버 → 클라이언트 (단방향)양방향
기반 프로토콜HTTP 그대로HTTP에서 업그레이드(ws/wss)
데이터 형식텍스트(UTF-8)만텍스트와 바이너리 모두
자동 재연결내장(Last-Event-ID)직접 구현
브라우저 APIEventSourceWebSocket

웹소켓은 클라이언트와 서버가 대등하게 서로 메시지를 주고받는 반면, SSE는 오직 서버가 클라이언트로 보내기만 합니다. 그래서 채팅이나 온라인 게임처럼 클라이언트도 실시간으로 입력을 보내야 하는 경우에는 웹소켓이 적합합니다. 반대로 실시간 알림, 뉴스 피드, 진행 상황 표시, 그리고 AI 응답 스트리밍처럼 서버가 일방적으로 알려주기만 하면 되는 경우에는 SSE가 훨씬 간단하고 잘 어울립니다.

실제로 OpenAI나 Anthropic의 AI 모델 API가 응답을 토큰 단위로 흘려보낼 때 바로 이 SSE를 사용합니다. 한 글자씩 타이핑되는 그 효과의 뒤편에 SSE가 있는 셈이죠.

SSE를 쓸 때 알아둘 점

편리한 SSE에도 몇 가지 주의할 점이 있습니다.

우선 SSE는 단방향이기 때문에 클라이언트에서 서버로 데이터를 보내려면 별도로 평범한 HTTP 요청을 사용해야 합니다. 다행히 이건 큰 문제가 아닙니다. 받기는 EventSource로, 보내기는 fetch() 함수로 처리하면 되니까요.

조금 더 까다로운 제약은 EventSource API 자체에 있습니다. EventSource는 GET 요청만 지원하고, 요청에 커스텀 헤더를 붙이거나 본문(body)을 담을 수 없습니다. 그래서 Authorization 헤더로 토큰을 보내거나 POST로 긴 데이터를 함께 전송해야 하는 경우에는 EventSource만으로는 부족합니다. 이럴 때는 fetch로 직접 요청을 보내고 응답 본문을 ReadableStream으로 읽으면서 text/event-stream 포맷을 손수 파싱하는 방식을 씁니다.

const response = await fetch("/chat", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    Authorization: `Bearer ${token}`,
  },
  body: JSON.stringify({ prompt: "안녕하세요" }),
});

const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();

while (true) {
  const { value, done } = await reader.read();
  if (done) break;
  console.log(value); // "data: ..." 형태의 청크를 직접 파싱
}

마지막으로 연결 수 제한도 기억해두면 좋습니다. HTTP/1.1에서는 브라우저가 같은 도메인에 대해 동시에 여는 연결이 보통 6개로 제한됩니다. SSE 연결도 이 숫자를 차지하기 때문에, 탭을 여러 개 열어두면 금방 한도에 다다를 수 있습니다. 다행히 HTTP/2 이상에서는 하나의 연결을 여러 스트림으로 나눠 쓰기 때문에 이 문제가 크게 완화됩니다. 또한 SSE는 UTF-8 텍스트만 전송할 수 있어서, 이미지 같은 바이너리 데이터를 실시간으로 주고받아야 한다면 SSE보다 웹소켓이 더 알맞습니다.

마치며

지금까지 서버가 클라이언트로 데이터를 밀어보내는 단방향 실시간 통신 기술인 Server-Sent Events를 살펴봤습니다. SSE는 평범한 HTTP 위에서 동작하고 브라우저의 EventSource API와 자동 재연결을 공짜로 제공하기 때문에, 서버가 일방적으로 알려주기만 하면 되는 상황에서는 웹소켓보다 훨씬 가볍게 쓸 수 있는 선택지입니다.

정리하자면 “클라이언트도 실시간으로 보내야 하는가?”라는 질문 하나로 둘을 가를 수 있습니다. 그렇다면 웹소켓을, 서버가 보내주기만 하면 된다면 SSE를 고르면 됩니다. 웹소켓 호환성이 걱정된다면 Socket.IO처럼 환경에 따라 적절한 기술을 자동으로 선택해주는 라이브러리도 좋은 대안이 됩니다.

더 자세한 내용은 EventSource 인터페이스를 다루는 MDN 공식 문서를 참고하세요.

This work is licensed under CC BY 4.0 CC BY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord