WebSocket과 Server-Sent Events 선택 기준

WebSocket과 Server-Sent Events 선택 기준

한눈에 보기

WebSocket은 양방향 메시지와 자체 프로토콜이 필요한 채팅·게임에 적합하다. SSE는 서버 단방향 스트림, 자동 재연결, 일반 HTTP 인프라 활용이 장점이다.

목차

왜 이 문제가 생기는가

화면에 새 알림을 바로 표시해야 한다는 요구를 들으면 WebSocket부터 떠올리기 쉽다. 하지만 클라이언트가 서버로 보내는 것은 기존 HTTP 요청이면 충분하고, 서버가 상태 변경만 밀어 주면 되는 경우도 많다. 이때 양방향 프로토콜과 별도 메시지 계약을 운영하는 비용이 요구보다 커질 수 있다.

반대로 채팅이나 협업 편집처럼 양쪽이 짧은 메시지를 계속 교환한다면 SSE만으로는 클라이언트→서버 통신을 별도 HTTP 요청으로 구성해야 한다. 선택 기준은 “실시간인가?” 하나가 아니라 방향, 빈도, 재연결, 메시지 유실 허용, 배포 인프라를 함께 보는 것이다.

이 글의 예제에 관하여

작업 진행률과 채팅 코드는 프로토콜 선택을 설명하기 위한 가상 예제다. 실제 서비스 endpoint나 메시지 형식을 사용하지 않았다.

먼저 메시지 방향을 확인한다

요구 후보
서버가 작업 진행률을 화면에 전달 SSE 우선 검토
알림·시세처럼 서버 이벤트를 연속 전달 SSE 또는 WebSocket
채팅 메시지를 양쪽이 자주 교환 WebSocket
멀티플레이 입력처럼 낮은 지연의 양방향 메시지 WebSocket
몇 분에 한 번 상태 확인 polling
앱이 백그라운드여도 OS 알림 필요 Web Push·모바일 push

SSE도 클라이언트가 fetch로 명령을 보내고 서버 이벤트로 결과를 받는 구조를 만들 수 있다. 따라서 “양쪽 모두 데이터가 존재한다”가 아니라 양방향 메시지를 얼마나 자주, 어떤 지연으로 교환하는가를 본다.

SSE는 HTTP 응답을 계속 스트리밍한다

서버는 text/event-stream 응답을 닫지 않고 이벤트를 순서대로 보낸다.

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

id: event-101
event: progress
data: {"jobId":"job-7","percent":30}

id: event-102
event: progress
data: {"jobId":"job-7","percent":60}

각 이벤트는 빈 줄로 구분되고 event, data, id, retry 같은 필드를 사용할 수 있다. 브라우저에서는 EventSource가 파싱과 재연결을 맡는다.

const stream = new EventSource("/api/jobs/job-7/events");

stream.addEventListener("progress", (event) => {
  const message = JSON.parse(event.data);
  renderProgress(message.percent);
});

stream.addEventListener("done", (event) => {
  renderResult(JSON.parse(event.data));
  stream.close();
});

stream.onerror = () => {
  showConnectionState("reconnecting");
};

SSE의 메시지는 텍스트 기반이다. 큰 바이너리 데이터를 계속 보내는 용도라면 인코딩 비용과 프로토콜 요구를 다시 검토한다. 또한 브라우저와 HTTP 버전, origin별 연결 제한도 대상 환경에서 확인해야 한다.

WebSocket은 양방향 메시지 채널을 만든다

WebSocket은 HTTP로 연결을 시작한 뒤 양방향 프레임을 주고받는 연결로 전환한다. 연결 이후 애플리케이션은 메시지 종류, 요청과 응답의 상관관계, 오류 형식을 직접 정의한다.

const socket = new WebSocket("wss://realtime.example.test/chat");

socket.addEventListener("open", () => {
  socket.send(JSON.stringify({
    type: "join-room",
    roomId: "room-12",
  }));
});

socket.addEventListener("message", (event) => {
  const message = JSON.parse(event.data);

  if (message.type === "chat-message") {
    appendMessage(message.payload);
  }
});

메시지 envelope을 명시하면 확장하기 쉽다.

type ClientMessage =
  | { type: "join-room"; requestId: string; roomId: string }
  | { type: "send-message"; requestId: string; roomId: string; text: string };

type ServerMessage =
  | { type: "ack"; requestId: string }
  | { type: "chat-message"; eventId: string; payload: unknown }
  | { type: "error"; requestId?: string; code: string };

HTTP 상태 코드와 endpoint가 제공하던 의미를 메시지 계약에서 다시 만들어야 한다. 스키마 버전, 최대 크기, 알 수 없는 type 처리, 권한 오류, 중복 메시지 처리를 문서화한다.

재연결 이후 유실된 이벤트 처리

오래 유지되는 연결은 언젠가 끊어진다. Wi-Fi 전환, 노트북 절전, proxy timeout, 서버 배포가 원인이 될 수 있다. 자동 재연결만으로 끊어진 동안의 이벤트가 복원되지는 않는다.

SSE는 이벤트에 id를 붙이면 재연결 요청에서 마지막 ID를 전달할 수 있다. 서버는 그 이후 이벤트를 replay할지 정책을 정한다.

sequenceDiagram
    participant C as Client
    participant S as Event Server
    C->>S: stream 연결
    S-->>C: id 101
    S-->>C: id 102
    Note over C,S: 연결 끊김
    C->>S: 재연결 + Last-Event-ID 102
    S-->>C: id 103부터 replay

서버가 모든 이벤트를 영구 보관할 수는 없다. 보관 기간이 지났다면 “전체 상태를 REST API로 다시 조회하라”는 reset 이벤트를 보낼 수 있다.

WebSocket도 마지막 처리 event ID나 sequence를 재접속 handshake에 포함해 비슷한 복구를 직접 구현할 수 있다. 채팅 전송처럼 중복이 문제라면 client message ID와 서버 unique constraint로 멱등성을 보장한다.

연결 유지와 backpressure

연결은 열려 있지만 중간 장비가 idle 연결을 종료할 수 있다. heartbeat를 두고 마지막 수신 시각이 기준을 넘으면 연결을 다시 만든다. 단, 클라이언트 수가 많을 때 heartbeat 주기가 짧으면 그 자체가 큰 트래픽이 된다.

생산 속도가 소비 속도보다 빠른 backpressure도 중요하다. 서버가 초당 수천 이벤트를 보내는데 브라우저가 렌더링하지 못하면 메모리와 지연이 계속 쌓인다.

// 진행률처럼 중간 값을 모두 보여 줄 필요가 없다면 최신 값만 반영한다.
let latestProgress = null;
let scheduled = false;

function receiveProgress(message) {
  latestProgress = message;
  if (scheduled) return;

  scheduled = true;
  requestAnimationFrame(() => {
    scheduled = false;
    renderProgress(latestProgress.percent);
  });
}

이벤트 성격에 따라 샘플링, 합치기, 버퍼 제한, 연결 종료 정책을 정한다. 결제 완료처럼 하나도 잃으면 안 되는 이벤트와 마우스 위치처럼 최신 값만 중요한 이벤트를 같은 큐 정책으로 처리하면 안 된다.

인증과 배포 인프라에서 생기는 차이

SSE의 브라우저 EventSource는 사용자 정의 요청 헤더를 자유롭게 설정하기 어렵다. same-origin 쿠키를 사용하거나, 제한된 수명의 연결 토큰, fetch 기반 스트림 같은 방식을 위협 모델에 맞게 선택한다. 토큰을 query에 넣으면 로그와 브라우저 기록에 남을 수 있으므로 장기 자격 증명을 그대로 넣지 않는다.

WebSocket도 연결 handshake에서 인증한 뒤 연결 중 권한 변경과 토큰 만료를 어떻게 반영할지 정해야 한다. 로그인 해제 후 기존 연결이 계속 이벤트를 받지 않도록 서버 측 연결 폐기 경로가 필요하다.

운영에서는 다음을 확인한다.

polling과 push가 더 적합한 경우

데이터가 5분에 한 번 바뀌는데 항상 연결을 유지할 필요는 없다. 조건부 GET과 간격 조절 polling이 더 단순하고 장애 복구도 쉽다. 탭이 숨겨졌을 때 간격을 늘릴 수도 있다.

모바일 앱이 종료되거나 백그라운드에 있을 때 장기 연결을 유지하는 것은 운영체제 정책과 배터리 제약을 받는다. 사용자에게 알림을 보여 주는 목적이라면 Web Push나 APNs/FCM 같은 push 전달 체계를 검토한다.

실시간처럼 보이는 UI도 낙관적 업데이트와 짧은 polling으로 제품 요구를 충족할 수 있다. 복잡한 연결 인프라를 도입하기 전에 허용 지연과 유실 허용 범위를 숫자로 합의한다.

선택 기준 표

기준 SSE WebSocket
기본 방향 서버 → 클라이언트 양방향
기반 HTTP 응답 스트림 WebSocket 프로토콜
브라우저 재연결 기본 지원 직접 구현
메시지 형식 UTF-8 텍스트 이벤트 텍스트·바이너리 프레임
요청 헤더 제어 EventSource에서 제한적 handshake 설계 가능
적합한 예 알림, 진행률, 로그 스트림 채팅, 협업, 게임
선택 전 질문

메시지는 어느 방향으로 얼마나 자주 흐르는가? 몇 초의 지연을 허용하는가? 재연결 동안 유실을 허용하는가? 순서와 중복은 어떻게 처리하는가? 현재 proxy와 서버가 장기 연결을 얼마나 수용할 수 있는가?

WebSocket은 양방향 메시지와 자체 프로토콜이 필요한 채팅·게임에 적합하다. SSE는 서버 단방향 스트림, 자동 재연결, 일반 HTTP 인프라 활용이 장점이다.

결론

서버 단방향 이벤트와 HTTP 기반의 단순한 복구가 필요하면 SSE를, 낮은 지연의 빈번한 양방향 메시지가 필요하면 WebSocket을 우선 검토한다. 어느 쪽이든 연결 끊김, 이벤트 replay, 중복, backpressure, 인증 만료와 배포 시 drain을 설계해야 한다. 변경 빈도가 낮거나 백그라운드 전달이 목적이라면 polling과 push가 더 작은 해법일 수 있다.

관련 노트