실시간 단방향 통신을 위한 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으로 지정합니다.

클라이언트 요청 헤더
GET /events HTTP/1.1
Host: example.com
Accept: text/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 상태 코드 안내서를 함께 참고해보세요.

응답 헤더 세 줄은 각자 역할이 다른데요, 중요도도 제각각입니다. 가장 핵심은 Content-Type: text/event-stream입니다. 브라우저의 EventSource는 바로 이 헤더를 보고 “아, 이건 이벤트 스트림이구나” 하고 응답 본문을 한 줄씩 파싱합니다. 이 헤더가 없으면 그냥 평범한 텍스트 응답으로 취급돼서 SSE가 성립하지 않죠.

Cache-Control: no-cache는 실무에서 생각보다 중요합니다. 스트림은 계속 살아있는 응답인데, 중간에 낀 프록시나 브라우저 캐시가 이걸 일반 응답으로 오해해 버퍼링하거나 캐싱해버리면 이벤트가 실시간으로 도착하지 않고 뭉텅이로 몰려오거나 아예 끊깁니다. “로컬에선 잘 되는데 배포하면 이벤트가 안 온다”는 증상의 상당수가 이 캐싱·버퍼링 문제라, no-cache로 미리 막아두는 것이 좋습니다.

반면 Connection: keep-alive는 있어도 그만 없어도 그만에 가깝습니다. HTTP/1.1에서는 연결 유지가 이미 기본값이라 명시하지 않아도 되고, HTTP/2 이상에서는 아예 쓰면 안 되는 헤더거든요. 그래서 이 줄은 “연결을 길게 유지한다”는 SSE의 성격을 눈으로 보여주는 관용적인 표현에 가깝습니다.

연결이 열리고 나면 서버는 정해진 텍스트 포맷으로 메시지를 흘려보냅니다. 가장 기본은 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 서버의 핵심은 응답을 끝내지 않고 열어둔 채로, 약속된 포맷의 텍스트를 계속 써 내려가는 것입니다.

차세대 자바스크립트 런타임인 Bun에서는 별도 프레임워크 없이 기본 기능만으로도 SSE 서버를 만들 수 있습니다. ReadableStream을 그대로 Response로 돌려줄 수 있어서, 이 스트림에 데이터를 계속 흘려보내는 방식으로 구현합니다.

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",
      },
    });
  },
});

여기서 두 가지가 중요합니다. 첫째, 스트림을 Response로 돌려준 뒤 닫지 않고 controller.enqueue()로 데이터를 계속 밀어 넣습니다. 스트림을 끝내버리면 연결이 닫히기 때문이죠. 둘째, 각 메시지를 반드시 \n\n으로 끝맺어야 브라우저가 하나의 완결된 메시지로 인식합니다. 여기서 사용한 ReadableStream이 생소하다면 자바스크립트의 Streams API에서 더 자세히 다루고 있으니 참고해보세요.

이렇게 헤더를 일일이 설정하고 \n\n을 직접 붙이는 게 번거롭다면, 웹 표준 기반의 경량 프레임워크 Hono를 쓰면 한결 간결해집니다. Hono의 streamSSE 헬퍼가 text/event-stream 헤더 설정과 메시지 포맷팅을 알아서 처리해주기 때문이죠.

Hono 설치
bun add hono
server.ts
import { Hono } from "hono";
import { streamSSE } from "hono/streaming";

const app = new Hono();

app.get("/events", (c) => {
  return streamSSE(c, async (stream) => {
    // 클라이언트가 연결을 끊으면 stream.aborted가 true가 됩니다
    while (!stream.aborted) {
      await stream.writeSSE({ data: new Date().toISOString() });
      await stream.sleep(1000);
    }
  });
});

export default {
  port: 8080,
  fetch: app.fetch,
};

stream.writeSSE({ data })만 호출하면 data: ...\n\n 포맷을 알아서 만들어주고, stream.sleep()으로 전송 간격을 조절합니다. 앞선 예제에서 직접 챙겼던 헤더 설정과 \n\n 처리가 사라져 코드가 훨씬 깔끔해졌죠. Hono가 처음이라면 Hono 기본 사용법에서 라우팅과 미들웨어까지 함께 살펴보세요.

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

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

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

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

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

활용 사례: MCP는 SSE를 어떻게 쓸까요?

앞서 AI 응답 스트리밍 이야기를 꺼냈는데요, 요즘 AI 생태계에서 SSE가 쓰이는 대표적인 사례가 하나 더 있습니다. 바로 AI 애플리케이션과 외부 도구를 연결하는 표준인 MCP(Model Context Protocol)입니다.

MCP 서버는 로컬에서 표준 입출력(stdio)으로 붙기도 하지만, 원격으로 연결할 때는 HTTP를 사용합니다. 그리고 이 원격 통신에서 서버가 클라이언트로 메시지를 밀어보내는 통로로 SSE를 활용합니다.

초기 스펙에서는 “HTTP+SSE”라고 해서 엔드포인트를 둘로 나눴습니다. 서버가 클라이언트로 보내는 SSE 스트림용 엔드포인트(GET) 하나와, 클라이언트가 서버로 요청을 보내는 엔드포인트(POST) 하나를 따로 둔 것이죠. SSE가 단방향이다 보니 반대 방향은 별도 POST로 메꾼 구조입니다.

이후 스펙이 개선되면서 지금은 “Streamable HTTP”라는 방식으로 단일 엔드포인트로 통합됐습니다. 클라이언트가 POST로 요청을 보내면, 서버는 상황에 따라 두 가지로 응답합니다.

  • 간단한 경우: Content-Type: application/json으로 JSON 응답 하나를 바로 돌려줍니다.
  • 스트리밍이 필요한 경우: Content-Type: text/event-stream으로 SSE 스트림을 열어, 도구 실행의 중간 진행 상황이나 알림을 흘려보낸 뒤 마지막에 최종 결과를 담아 스트림을 닫습니다.

흥미로운 점은 이 글에서 다룬 개념들이 여기에 그대로 등장한다는 것입니다. 클라이언트는 Accept: text/event-stream으로 스트림을 받을 준비가 됐음을 알리고, 서버는 Content-Type: text/event-stream으로 화답합니다. 심지어 메시지마다 붙는 id 필드는 연결이 끊겼을 때 Last-Event-ID로 이어받기를 하는 데 그대로 쓰입니다.

MCP가 웹소켓 대신 SSE를 고른 이유도 앞선 비교표와 맞닿아 있습니다. 대부분의 트래픽은 서버가 클라이언트에게 결과와 진행 상황을 흘려보내는 단방향이고, 클라이언트의 요청은 평범한 HTTP POST로 충분합니다. 덕분에 기존 HTTP 인프라(프록시, 인증, 로드밸런서)를 그대로 활용할 수 있다는 것이 큰 장점이죠.

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.0CCBY

개발자를 위한 뉴스레터

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

Discord