본문 바로가기

실시간 웹 통신

폴링·SSE·WebSocket의 통신 방향과 연결 비용을 비교하고 알림과 채팅에 맞는 방식을 선택합니다.

일반적인 HTTP API 조회에서는 브라우저가 먼저 요청하고, 서버의 응답 본문을 다 받으면 그 요청이 끝납니다. HTTP에서도 응답을 오래 열어 두고 조금씩 보내는 스트리밍(streaming)이 가능하며, 이 글에서 볼 SSE가 그 예입니다. 채팅이나 알림, 진행률처럼 서버의 새 상태를 바로 받아야 하는 화면에는 요청 한 번에 응답 한 번으로 끝나는 방식과는 다른 전략이 필요합니다.

이 글에서는 폴링(polling), Server-Sent Events(SSE), WebSocket의 차이를 이해하고 상황에 맞는 방식을 고릅니다. 이어 메시지의 형식을 정하는 방법과 연결이 끊긴 뒤 상태를 복구하는 방법, 실시간 연결의 인증과 보안을 살펴봅니다.

폴링·SSE·WebSocket은 허용 지연과 통신 방향으로 비교합니다

REALTIME TRANSPORT

폴링·SSE·WebSocket은 허용 지연과 통신 방향으로 비교합니다

새 상태를 언제 알아야 하는지와 누가 먼저 메시지를 보내야 하는지 정합니다. 이 절의 알림 예제는 폴링과 SSE, 양방향 채팅 예제는 WebSocket으로 비교할 수 있습니다.

Polling · 반복 요청

브라우저 → 서버: 새 알림?
브라우저 ← 서버: 응답

예제는10초마다 HTTP 요청합니다. 변화가 없어도 요청하고, 주기를 줄이면 요청량이 늘어납니다.

탭이 숨겨지면 중단하고 다시 보이면 즉시 갱신합니다. 일반 HTTP 요청의 오류 처리가 필요합니다.

SSE · 서버 → 브라우저

EventSource 연결 요청
서버 → 브라우저: 이벤트

하나의 HTTP 스트림에서 텍스트 이벤트를 받습니다. 알림·진행률처럼 서버 전달이 중심인 기능에 적합합니다.

EventSource는 기본 재접속을 처리하지만 누락 이벤트 재전송은 서버가 구현해야 합니다.

WebSocket · 양방향

브라우저 ↔ 서버
연결 뒤 메시지 교환

채팅·협업처럼 양쪽에서 메시지를 자주 보내는 기능에 적합합니다. 운영 연결은 wss://로 보호합니다.

자동 재접속은 직접 구현합니다. 메시지마다 입력·권한을 검사하고 누락 복구도 설계합니다.

세 방식 모두: 연결과 데이터 복구를 검증

인증, 프록시 타임아웃, 동시 연결 수, 서버 간 메시지 전달과 배포 중 종료를 확인합니다. 마지막 이벤트·메시지 ID와 중복 처리 정책으로 끊김 이후 상태를 맞춥니다.

즉시 갱신이 필요하지 않다면

탐색·포커스·사용자 새로고침 때 일반 HTTP로 재검증하는 방법도 가능합니다. 필요한 지연 범위를 충족하는지 먼저 판단합니다.

Long polling도 가능한 선택

서버가 이벤트나 타임아웃까지 HTTP 응답을 보류하고, 응답 뒤 클라이언트가 다시 요청합니다. 대기 연결과 프록시 제한을 함께 확인합니다.

위 그림은 새 상태를 언제 알아야 하는지(허용 지연 시간)와 누가 먼저 메시지를 보내는지(통신 방향)로 세 방식을 비교합니다. 폴링은 브라우저가 서버에 새 알림이 있는지 반복해서 묻는 방식으로, 이 글의 예제는 10초마다 HTTP 요청을 보냅니다. 변화가 없어도 요청하므로 주기를 줄이면 요청량이 늘고, 탭이 숨겨지면 멈췄다가 다시 보이면 바로 갱신하며, 일반 HTTP 요청과 같은 오류 처리가 필요합니다.

SSE는 브라우저가 연결을 요청한 뒤 하나의 HTTP 스트림으로 서버의 텍스트 이벤트를 받는 방식이라, 알림과 진행률처럼 서버가 보내는 일이 중심인 기능에 알맞습니다. EventSource가 기본 재접속을 처리하지만, 끊긴 동안 놓친 이벤트를 다시 보내는 일은 서버가 구현해야 합니다. WebSocket은 연결한 뒤 브라우저와 서버가 서로 메시지를 주고받는 방식으로, 채팅이나 협업처럼 양쪽에서 자주 보내는 기능에 알맞습니다. 운영 연결은 wss://로 보호하고, 자동 재접속은 직접 구현하며, 메시지마다 입력과 권한을 검사하고 누락 복구도 설계해야 합니다.

그림의 공통 항목처럼, 세 방식 모두 인증과 프록시의 타임아웃, 동시 연결 수, 서버 사이의 메시지 전달, 배포 중의 연결 종료를 확인해야 합니다. 끊긴 뒤의 상태는 마지막 이벤트나 메시지의 ID와 중복 처리 규칙으로 맞춥니다. 즉시 갱신이 꼭 필요하지 않다면 페이지 이동이나 포커스, 사용자의 새로고침 때 일반 HTTP로 다시 확인하는 방법도 있습니다. 서버가 이벤트가 생기거나 타임아웃이 될 때까지 응답을 미루고, 응답을 받은 브라우저가 곧바로 다시 요청하는 롱 폴링(long polling)도 쓸 수 있지만, 대기하는 연결과 프록시의 제한을 함께 확인해야 합니다.


실시간의 의미

웹에서 실시간이란 변화가 생긴 직후 사용자가 그 사실을 알아야 한다는 요구사항입니다. 모든 기능에 밀리초 단위의 양방향 연결이 필요하지는 않습니다. 게시글 목록을 1분마다 갱신해도 충분하다면 일반적인 재검증이 가장 단순하고, 알림을 몇 초 안에 받아야 한다면 폴링이나 SSE를 검토할 수 있으며, 사용자끼리 메시지를 주고받는 채팅에는 WebSocket이 자연스럽습니다. 그래서 기술을 고르기 전에 허용 지연 시간과 통신 방향부터 정합니다.

이 글의 예제는 HTTP 요청과 응답, JSON, async/await, 이벤트 리스너와 타이머를 알고 있으면 따라가기 쉽습니다. 아래 클라이언트 코드는 각각 다른 방식을 보여 주는 조각이며, 화면을 그리는 renderNotifications, renderNotification, renderMessage, showConnectionState는 애플리케이션에서 구현할 함수라고 가정합니다.


폴링

폴링은 브라우저가 일정한 간격으로 서버에 새 데이터가 있는지 묻는 방식입니다. 다음 코드는 마지막으로 받은 알림 ID를 lastId에 기억해 두고, 10초마다 그 뒤의 알림만 요청합니다. requestInFlight는 앞 요청이 끝나기 전에 다음 요청이 겹치지 않게 막고, visibilitychange 리스너는 탭이 보이지 않는 동안 반복을 멈췄다가 다시 보이면 이어 갑니다.

let lastId = 42; // 이미 화면에 반영한 마지막 알림 ID
let requestInFlight = false;

async function refreshNotifications() {
  if (document.hidden || requestInFlight) return;
  requestInFlight = true;

  try {
    const response = await fetch(`/api/notifications?after=${lastId}`);
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    const notifications = await response.json();
    if (!Array.isArray(notifications)) throw new Error('알림 배열이 필요합니다');

    renderNotifications(notifications);
    if (notifications.length > 0) {
      lastId = notifications[notifications.length - 1].id;
    }
    showConnectionState('갱신 완료');
  } catch (error) {
    showConnectionState('알림을 갱신하지 못했습니다');
  } finally {
    requestInFlight = false;
  }
}

let timerId;

function startPolling() {
  if (document.hidden || timerId !== undefined) return;
  void refreshNotifications();
  timerId = setInterval(refreshNotifications, 10_000);
}

function stopPolling() {
  if (timerId === undefined) return;
  clearInterval(timerId);
  timerId = undefined;
}

document.addEventListener('visibilitychange', () => {
  if (document.hidden) {
    stopPolling();
  } else {
    startPolling();
  }
});

startPolling();

일반적인 HTTP API만 있으면 구현할 수 있고 연결을 복구하는 방법도 단순합니다. 하지만 변화가 없어도 요청을 반복하므로, 주기가 짧을수록 서버와 네트워크의 비용이 늘어납니다. 그래서 탭이 보이지 않을 때는 요청을 줄이거나 멈춥니다.

위 코드에서 visibilitychange 이벤트는 탭이 숨겨지면 stopPolling()을, 다시 보이면 startPolling()을 호출합니다. startPolling()은 곧바로 한 번 갱신을 시도한 뒤 타이머를 다시 시작하며, 이전 요청이 아직 진행 중이면 그 결과를 기다리고 다음 주기에 다시 조회합니다. requestInFlight는 느린 요청과 새 요청이 겹치지 않게 막고, finally는 요청이 실패해도 다음 시도를 할 수 있게 플래그를 되돌립니다. clearInterval()은 앞으로의 타이머 호출을 멈출 뿐, 이미 시작한 HTTP 요청을 취소하지는 않습니다.

이 예제의 API는 after보다 큰 숫자 ID의 알림을 ID 오름차순 배열로 돌려준다고 정했습니다. 42는 예제에서 정한 처음의 완료 ID이며, 받은 알림을 화면에 반영한 뒤 마지막 ID를 갱신해 같은 알림을 다시 받지 않게 합니다. 운영 코드라면 응답의 각 필드도 검증하고, 권한 오류와 네트워크 실패에 맞춰 다시 시도하는 규칙을 정합니다. MDN의 Fetch 사용 안내가 설명하듯 HTTP 404나 500 응답만으로는 fetch()가 거부되지 않으므로 response.ok를 따로 확인해야 합니다.

폴링에서는 주기가 짧을수록 데이터의 지연은 줄고 요청 비용은 늘어나는 상충 관계가 생깁니다. 또 10초는 타이머가 목표로 하는 간격일 뿐이며, 네트워크 지연과 진행 중인 요청, 브라우저의 타이머 지연 때문에 실제 갱신은 더 늦어질 수 있습니다.


SSE로 서버 알림 받기

SSE는 하나의 HTTP 연결을 열어 둔 채 서버가 브라우저로 텍스트 이벤트를 계속 보내는 방식입니다. 통신 방향은 서버에서 브라우저로 향하는 한쪽이며, 브라우저의 EventSource가 연결과 기본 재접속을 관리합니다.

client.js
const events = new EventSource('/api/notifications/stream');

events.addEventListener('notification', (event) => {
  const notification = JSON.parse(event.data);
  renderNotification(notification);
});

events.onopen = () => {
  showConnectionState('연결됨');
};

events.onerror = () => {
  showConnectionState(events.readyState === EventSource.CLOSED
    ? '연결 종료'
    : '재연결 중');
};

function stopNotifications() {
  events.close();
}

addEventListener('notification', …)은 서버가 event: notification이라는 이름으로 보낸 이벤트만 받습니다. 화면에서 이 구독을 더 쓰지 않거나 사용자가 로그아웃하면 stopNotifications()를 호출해 연결을 닫습니다. HTML 표준의 Server-Sent Events에서 CLOSED는 재접속을 시도하지 않는 상태입니다. 기본 재접속이 모든 종료에 적용되는 것은 아니며, 서버가 HTTP 204 No Content로 응답해 재접속을 멈추게 할 수도 있습니다.

서버는 text/event-stream 형식으로 응답합니다. 아래 서버는 Express가 설치된 별도의 학습 환경에서 실행하는 교육용 예제로, /api 요청을 이 서버로 보내는 프록시나 같은 출처에서 실행하는 클라이언트가 필요합니다. 실제 개인 알림을 다루는 운영 서버로 그대로 배포하지는 않습니다.

server.mjs
import express from 'express';

const app = express();

app.get('/api/notifications/stream', (request, response) => {
  response.setHeader('Content-Type', 'text/event-stream');
  response.setHeader('Cache-Control', 'no-cache');
  response.setHeader('Connection', 'keep-alive');
  response.flushHeaders();

  const send = (notification) => {
    response.write(`id: ${notification.id}\n`);
    response.write('event: notification\n');
    response.write(`data: ${JSON.stringify(notification)}\n\n`);
  };

  const heartbeat = setInterval(() => {
    response.write(': keep-alive\n\n');
  }, 15_000);

  const requestedLastId = Number(request.get('last-event-id') ?? 0);
  let notificationId = Number.isSafeInteger(requestedLastId) && requestedLastId >= 0
    ? requestedLastId
    : 0;
  const notificationTimer = setInterval(() => {
    notificationId += 1;
    send({
      id: notificationId,
      message: `새 알림 ${notificationId}`,
      createdAt: new Date().toISOString(),
    });
  }, 10_000);

  response.on('close', () => {
    clearInterval(heartbeat);
    clearInterval(notificationTimer);
  });
});

app.listen(4100);

각 이벤트는 id:, event:, data: 같은 필드 줄로 이루어지고 빈 줄로 끝납니다. 콜론으로 시작하는 줄은 주석으로, 위 코드는 15초마다 : keep-alive를 보내 연결이 유휴 상태로 끊기지 않게 합니다. id: 필드의 값은 브라우저가 재접속할 때 Last-Event-ID 헤더로 다시 보내며, 위 서버는 이 값을 읽어 다음 번호부터 알림을 만듭니다.

예제의 notificationTimer는 send()가 실제 이벤트를 보내는 흐름을 보여 주기 위해 10초마다 가상의 알림을 만드는 교육용 생산자입니다. 여러 사용자가 공유하는 실제 이벤트 기록을 저장하지 않으므로, 끊긴 동안의 알림을 다시 보내지도 못합니다. 운영 서버라면 이벤트를 저장해 두고 전달받은 ID 이후의 누락 이벤트를 조회해야 실제로 복구됩니다. 또 Last-Event-ID는 브라우저가 마지막으로 받은 이벤트를 알리는 값일 뿐, 그 이벤트를 화면에 반영하는 데 성공했다는 뜻은 아닙니다. 처리 실패까지 복구해야 한다면 애플리케이션이 완료한 ID와 확인 규칙을 따로 설계합니다. 운영 코드에서는 이 타이머 대신 메시지 브로커나 데이터베이스의 변경 구독에서 알림을 받아 send()를 호출하고, 사용자가 연결을 닫으면 하트비트와 알림 생산자, 외부 구독을 모두 정리합니다.

실시간 연결·인증·메시지 시퀀스 — 상태 추적

실제 SSE 예제의 연결·notification id/data·주석 heartbeat·종료 타이머 정리와 WebSocket 메시지 계약을 구분한다. 현재 연결/마지막 ID/자원 상태로 같은 수신 사례를 추적할 가치가 있다.

SSE의 이벤트·하트비트와 WebSocket 메시지를 구별합니다

MESSAGE FLOW

SSE의 이벤트·하트비트와 WebSocket 메시지를 구별합니다

SSE 예제는 EventSource 요청에 text/event-stream으로 응답하고, 이름이 notification인 이벤트를 전달합니다. WebSocket은 별도 연결에서 join·chat 메시지를 양방향으로 교환합니다.

SSE: 연결 요청과 서버 사건

SSE: 연결 요청과 서버 사건notification은 10초 생산자의 사용자 사건이고 15초 주석 heartbeat와 다릅니다. 종료 시 알림·하트비트 타이머를 모두 정리합니다.브라우저서버EventSource GETtext/event-streamnotification주석 heartbeat연결 종료
SSE: 연결 요청과 서버 사건notification은 10초 생산자의 사용자 사건이고 15초 주석 heartbeat와 다릅니다. 종료 시 알림·하트비트 타이머를 모두 정리합니다.브라우저서버EventSource GETtext/event-streamnotification주석 heartbeat연결 종료

notification은 10초 생산자의 사용자 사건이고 15초 주석 heartbeat와 다릅니다. 종료 시 알림·하트비트 타이머를 모두 정리합니다.

WebSocket: 두 끝이 보내는 메시지

WebSocket: 두 끝이 보내는 메시지SSE와 별도 연결입니다. 실제 SSE 예제에는 운영용 인증·인가 전체가 없고, 운영에서는 연결 권한과 각 입력 메시지의 종류·길이·권한을 확인합니다.브라우저서버별도 WS 연결join / chat메시지 응답
WebSocket: 두 끝이 보내는 메시지SSE와 별도 연결입니다. 실제 SSE 예제에는 운영용 인증·인가 전체가 없고, 운영에서는 연결 권한과 각 입력 메시지의 종류·길이·권한을 확인합니다.브라우저서버별도 WS 연결join / chat메시지 응답

SSE와 별도 연결입니다. 실제 SSE 예제에는 운영용 인증·인가 전체가 없고, 운영에서는 연결 권한과 각 입력 메시지의 종류·길이·권한을 확인합니다.

원래 설명과 상세 기준

SSE · 연결과 알림

GET /api/notifications/stream
← Content-Type: text/event-stream
← id: 43
← event: notification
← data: {"id":43,...}
← 빈 줄로 이벤트 끝

서버의 교육용 생산자는10초마다 send()합니다. 브라우저는 notification 리스너에서 event.data를 JSON으로 읽습니다.

SSE · 생존과 종료

: keep-alive

서버의15초 주석 하트비트는 notification 이벤트가 아닙니다. 연결 종료 시 하트비트·알림 생산자 타이머를 모두 정리합니다.

재접속 시 Last-Event-ID를 전달할 수 있습니다. 예제는 다음 ID를 이어가며, 실제 누락 이벤트를 재전송하려면 서버 저장과 조회가 필요합니다.

WebSocket · 보내기와 받기

open → send({type:"join",...})
OPEN일 때 send({type:"chat",...})
message → JSON.parse(event.data)

브라우저와 서버 모두 연결 뒤 메시지를 보낼 수 있습니다. 이 연결의 메시지와 SSE의 data/event/id 행 형식은 서로 다릅니다.

공통 · 인증·메시지 검증

서버는 연결 시 세션·구독 권한을 검사하고, 사용자 입력의 종류·길이·권한을 다시 확인합니다. 작성자 신원은 클라이언트가 보낸 사용자 ID 대신 인증된 연결에서 정합니다.

SSE 예제에는 이 운영용 인증·인가 전체가 구현되어 있지 않습니다. 연결 성공과 검증 완료를 구분합니다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
  • 뒤의 연결·알림 id 43·주석 heartbeat·timer 정리는 본문 SSE 예제의 수명 추적입니다. WebSocket은 첫 장면에서 전달 계약만 독립 비교하며 SSE 처리의 다음 단계가 아닙니다.
관계도의 보충 설명
원도표의 전체 관계·조건·설명
구분내용
원도표 설명서버의 교육용 생산자는 10초마다 send()합니다. 브라우저는 notification 리스너에서 event.data를 JSON으로 읽습니다.
원도표 설명서버의 15초 주석 하트비트는 notification 이벤트가 아닙니다. 연결 종료 시 하트비트·알림 생산자 타이머를 모두 정리합니다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
SSE 연결 · WS 계약 비교SSE: 서버→client 사건 / WebSocket: 양방향 메시지추적할 SSE 연결을 열고 별도 WebSocket의 양방향 계약과 비교한다두 API를 같은 양방향 스트림으로 읽지 않는다
SSE · 현재 알림알림: id 43 / heartbeat: 알림 아님
  • 알림: id43
알림 id 43을 업무 사건으로 처리한다연결 유지 신호와 사용자 알림을 구분한다
SSE · 소유자 종료연결: 종료 / timer: 해제닫기와 timer 해제를 수행한다연결 소유자가 연결과 반복 자원을 함께 정리한다

실시간 연결·인증·메시지 시퀀스 — 상태 추적

실제 SSE 예제의 연결·notification id/data·주석 heartbeat·종료 타이머 정리와 WebSocket 메시지 계약을 구분한다. 현재 연결/마지막 ID/자원 상태로 같은 수신 사례를 추적할 가치가 있다.

SSE의 이벤트·하트비트와 WebSocket 메시지를 구별합니다

MESSAGE FLOW

SSE의 이벤트·하트비트와 WebSocket 메시지를 구별합니다

SSE 예제는 EventSource 요청에 text/event-stream으로 응답하고, 이름이 notification인 이벤트를 전달합니다. WebSocket은 별도 연결에서 join·chat 메시지를 양방향으로 교환합니다.

SSE: 연결 요청과 서버 사건

SSE: 연결 요청과 서버 사건notification은 10초 생산자의 사용자 사건이고 15초 주석 heartbeat와 다릅니다. 종료 시 알림·하트비트 타이머를 모두 정리합니다.브라우저서버EventSource GETtext/event-streamnotification주석 heartbeat연결 종료
SSE: 연결 요청과 서버 사건notification은 10초 생산자의 사용자 사건이고 15초 주석 heartbeat와 다릅니다. 종료 시 알림·하트비트 타이머를 모두 정리합니다.브라우저서버EventSource GETtext/event-streamnotification주석 heartbeat연결 종료

notification은 10초 생산자의 사용자 사건이고 15초 주석 heartbeat와 다릅니다. 종료 시 알림·하트비트 타이머를 모두 정리합니다.

WebSocket: 두 끝이 보내는 메시지

WebSocket: 두 끝이 보내는 메시지SSE와 별도 연결입니다. 실제 SSE 예제에는 운영용 인증·인가 전체가 없고, 운영에서는 연결 권한과 각 입력 메시지의 종류·길이·권한을 확인합니다.브라우저서버별도 WS 연결join / chat메시지 응답
WebSocket: 두 끝이 보내는 메시지SSE와 별도 연결입니다. 실제 SSE 예제에는 운영용 인증·인가 전체가 없고, 운영에서는 연결 권한과 각 입력 메시지의 종류·길이·권한을 확인합니다.브라우저서버별도 WS 연결join / chat메시지 응답

SSE와 별도 연결입니다. 실제 SSE 예제에는 운영용 인증·인가 전체가 없고, 운영에서는 연결 권한과 각 입력 메시지의 종류·길이·권한을 확인합니다.

원래 설명과 상세 기준

SSE · 연결과 알림

GET /api/notifications/stream
← Content-Type: text/event-stream
← id: 43
← event: notification
← data: {"id":43,...}
← 빈 줄로 이벤트 끝

서버의 교육용 생산자는10초마다 send()합니다. 브라우저는 notification 리스너에서 event.data를 JSON으로 읽습니다.

SSE · 생존과 종료

: keep-alive

서버의15초 주석 하트비트는 notification 이벤트가 아닙니다. 연결 종료 시 하트비트·알림 생산자 타이머를 모두 정리합니다.

재접속 시 Last-Event-ID를 전달할 수 있습니다. 예제는 다음 ID를 이어가며, 실제 누락 이벤트를 재전송하려면 서버 저장과 조회가 필요합니다.

WebSocket · 보내기와 받기

open → send({type:"join",...})
OPEN일 때 send({type:"chat",...})
message → JSON.parse(event.data)

브라우저와 서버 모두 연결 뒤 메시지를 보낼 수 있습니다. 이 연결의 메시지와 SSE의 data/event/id 행 형식은 서로 다릅니다.

공통 · 인증·메시지 검증

서버는 연결 시 세션·구독 권한을 검사하고, 사용자 입력의 종류·길이·권한을 다시 확인합니다. 작성자 신원은 클라이언트가 보낸 사용자 ID 대신 인증된 연결에서 정합니다.

SSE 예제에는 이 운영용 인증·인가 전체가 구현되어 있지 않습니다. 연결 성공과 검증 완료를 구분합니다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
  • 뒤의 연결·알림 id 43·주석 heartbeat·timer 정리는 본문 SSE 예제의 수명 추적입니다. WebSocket은 첫 장면에서 전달 계약만 독립 비교하며 SSE 처리의 다음 단계가 아닙니다.

관계도의 보충 설명

원도표의 전체 관계·조건·설명
구분내용
원도표 설명서버의 교육용 생산자는 10초마다 send()합니다. 브라우저는 notification 리스너에서 event.data를 JSON으로 읽습니다.
원도표 설명서버의 15초 주석 하트비트는 notification 이벤트가 아닙니다. 연결 종료 시 하트비트·알림 생산자 타이머를 모두 정리합니다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
SSE 연결 · WS 계약 비교SSE: 서버→client 사건 / WebSocket: 양방향 메시지추적할 SSE 연결을 열고 별도 WebSocket의 양방향 계약과 비교한다두 API를 같은 양방향 스트림으로 읽지 않는다
SSE · 현재 알림알림: id 43 / heartbeat: 알림 아님
  • 알림: id43
알림 id 43을 업무 사건으로 처리한다연결 유지 신호와 사용자 알림을 구분한다
SSE · 소유자 종료연결: 종료 / timer: 해제닫기와 timer 해제를 수행한다연결 소유자가 연결과 반복 자원을 함께 정리한다

위 그림의 위쪽은 SSE의 흐름입니다. 브라우저가 EventSource로 GET /api/notifications/stream을 요청하면 서버는 text/event-stream으로 응답하고, id: 43, event: notification, data: {...} 줄과 빈 줄로 이벤트 하나를 보냅니다. 10초마다 생기는 notification은 사용자에게 보여 줄 이벤트이고, 15초마다 보내는 주석 하트비트는 연결을 유지하기 위한 줄이라서 notification 리스너에 전달되지 않습니다. 연결이 닫히면 서버는 하트비트와 알림 생산자의 타이머를 모두 정리합니다.

그림의 아래쪽은 다음 절에서 다룰 WebSocket의 흐름으로, SSE와는 별도의 연결입니다. 연결이 열리면 브라우저가 join 메시지를, 연결이 OPEN일 때 chat 메시지를 보내고, 서버의 메시지는 message 이벤트에서 JSON.parse(event.data)로 읽습니다. 양쪽 모두 메시지를 보낼 수 있으며, 이 메시지는 SSE의 data/event/id 줄 형식과 다릅니다. 그림의 공통 항목처럼 서버는 연결할 때 세션과 구독 권한을 검사하고, 받은 입력의 종류와 길이, 권한을 다시 확인하며, 작성자는 클라이언트가 보낸 사용자 ID가 아니라 인증된 연결 정보로 정합니다. 이 글의 SSE 예제에는 이런 운영용 인증과 인가가 구현되어 있지 않으므로, 연결에 성공한 것과 검증을 마친 것을 구별합니다.

SSE는 HTTP/2 없이 쓰면 브라우저와 도메인마다 열 수 있는 연결 수가 적게 제한된다는 점도 기억해 둡니다. MDN의 EventSource 문서는 이 제한이 브라우저당 도메인마다 6개 정도라서, 여러 탭에서 SSE를 열면 금방 닿을 수 있다고 안내합니다.


WebSocket으로 양방향 통신하기

WebSocket은 연결을 만든 뒤 브라우저와 서버가 양쪽에서 메시지를 보낼 수 있는 방식입니다. 채팅이나 협업 편집, 실시간 게임처럼 양쪽의 메시지가 잦은 기능에 알맞습니다. HTTP 연결을 WebSocket으로 업그레이드하는 과정과 프레임의 형식은 WebSocket과 실시간 통신에서 자세히 다룹니다.

client.js
const socket = new WebSocket('wss://example.com/chat');

socket.addEventListener('open', () => {
  socket.send(JSON.stringify({ type: 'join', roomId: 'general' }));
});

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

function sendChat(content) {
  if (socket.readyState !== WebSocket.OPEN) {
    return;
  }

  socket.send(JSON.stringify({ type: 'chat', content }));
}

open 이벤트에서 방에 들어가는 join 메시지를 보내고, sendChat()은 연결이 WebSocket.OPEN 상태일 때만 chat 메시지를 보냅니다. ws://는 암호화하지 않은 연결이고 wss://는 TLS로 보호한 연결이므로, 운영 환경에서는 HTTPS와 함께 wss://를 씁니다. 브라우저가 보낸 JSON은 믿을 수 없는 입력이므로 서버에서 타입과 길이, 권한을 검증합니다. 앞에서 본 실시간 연결과 메시지 흐름의 그림 아래쪽이 이 연결과 양방향 메시지 교환을 보여 줍니다.

더 깊이: 브라우저가 보내는 프레임은 왜 가려져 있을까

WebSocket 프로토콜을 정한 RFC 6455는 클라이언트가 서버로 보내는 모든 프레임을 마스킹(masking)하도록 요구하고, 반대로 서버가 보내는 프레임은 마스킹하지 말라고 정합니다. 마스킹은 프레임마다 예측할 수 없는 새 키를 정해 그 키로 내용을 섞는 것이라, 같은 메시지를 두 번 보내도 네트워크에 흐르는 바이트는 매번 달라집니다. 브라우저가 이 일을 자동으로 하므로 자바스크립트 코드에서 따로 할 일은 없습니다.

이 규칙은 암호화가 아니라 중간 장비를 지키기 위한 것입니다. RFC 6455의 보안 고려 사항에 따르면, WebSocket을 이해하지 못하는 캐시 프록시 앞에서 공격자가 자기 서버와 연결을 연 뒤 널리 쓰이는 스크립트를 요청하는 HTTP 요청처럼 보이는 바이트를 보내면, 프록시가 그 뒤의 응답을 그 스크립트로 잘못 캐시할 수 있습니다. 그러면 같은 프록시 뒤의 다른 사용자가 악성 스크립트를 받게 됩니다. 마스킹은 페이지의 스크립트가 네트워크에 나갈 바이트를 마음대로 정하지 못하게 해 이런 공격을 막습니다.

연결이 살아 있는지 확인하는 핑(ping)과 퐁(pong)도 같은 프로토콜의 제어 프레임입니다. RFC 6455는 핑을 받은 쪽이 퐁으로 답해야 한다고 정하며, 핑은 연결을 유지하거나 상대가 응답하는지 확인하는 데 쓸 수 있습니다. 브라우저의 WebSocket 객체에는 핑을 보내는 메서드가 없으므로, 클라이언트 쪽에서 연결 상태를 확인하고 싶다면 type: "ping" 같은 종류의 애플리케이션 메시지를 따로 정해 주고받습니다. 규칙은 RFC 6455에 있습니다.


메시지 형식 설계

실시간 연결에서도 메시지의 종류와 데이터의 모양을 미리 정해 두어야 합니다. 서버와 클라이언트가 이 모양에 합의해 두지 않으면, 한쪽이 필드 이름을 바꾸는 순간 다른 쪽은 오류 없이 빈 값을 읽게 됩니다. 다음은 채팅 메시지 하나를 이렇게 정한 예입니다.

{
  "type": "chat",
  "messageId": "m-1024",
  "roomId": "general",
  "content": "안녕하세요",
  "sentAt": "2026-07-18T10:30:00.000Z"
}

type은 받는 쪽이 어떤 처리 함수를 쓸지 고르는 기준입니다. messageId는 다시 보낸 메시지나 두 번 받은 메시지를 구별하는 데 쓰고, sentAt은 문자열 형식과 시간대를 고정해 둡니다. 서버는 클라이언트가 보낸 사용자 ID를 그대로 믿지 않고, 인증된 연결 정보로 작성자를 정합니다. 이처럼 메시지를 감싸는 형식에서는 식별자와 종류, 본문, 시각의 역할을 분명히 나눕니다.


연결 끊김과 재접속

모바일 네트워크나 절전 상태에서는 연결이 자주 끊길 수 있으므로, 실시간 기능은 정상 연결뿐 아니라 다시 연결하는 흐름까지 함께 설계해야 합니다. WebSocket에는 자동 재접속이 없기 때문에, 아래 예제는 상한이 있는 단계별 대기 시간을 직접 적용합니다.

const delays = [1_000, 2_000, 5_000, 10_000, 30_000];
let attempt = 0;

function scheduleReconnect(connect) {
  const delay = delays[Math.min(attempt, delays.length - 1)];
  attempt += 1;
  setTimeout(connect, delay);
}

function resetReconnectDelay() {
  attempt = 0;
}

scheduleReconnect()는 시도할 때마다 1초, 2초, 5초, 10초로 대기 시간을 늘리고, 그 뒤로는 30초를 계속 씁니다. 연결에 성공하면 open 이벤트 처리기에서 resetReconnectDelay()를 호출해 attempt를 0으로 되돌리고, 그러면 다음 끊김 때 다시 짧은 대기부터 시작합니다. 운영에서는 대기 시간을 배수로 늘리는 지수 백오프(exponential backoff)와, 대기 시간에 무작위 값을 더해 많은 클라이언트가 같은 순간에 다시 접속하지 않게 하는 지터(jitter)를 함께 설계합니다.

다시 연결한 뒤에는 놓친 메시지를 가져올 수 있도록 마지막으로 처리한 이벤트 ID나 서버가 정한 순서를 서버에 전달합니다. 시각만 기준으로 삼으면 같은 시각의 메시지가 여러 개 생길 수 있으므로, 이를 구별할 식별자와 조회 규칙이 필요합니다. 이미 받은 ID는 다시 적용하지 않고, 복구 조회와 새 실시간 메시지 사이에 빈 구간이 생기지 않게 합니다. 서버가 기록을 보관하는 기간이 지났다면 권한을 확인한 최신 전체 상태를 다시 읽는 규칙도 필요합니다. 다시 연결된 것과 데이터가 복구된 것은 서로 다른 일입니다.

실시간 재연결·재생·복구 상태 — 상태 추적

마지막 처리 ID·끊김·대기·새 연결·누락 조회·중복 제거·수신 재개가 실제로 다른 상태다. 본문 미구현 replay를 개념 모델로 명시하고 재연결 성공과 복구 완료를 구별한다.

재접속 뒤 누락 메시지를 복구해야 같은 상태로 돌아갑니다

RECONNECT & RECOVERY

재접속 뒤 누락 메시지를 복구해야 같은 상태로 돌아갑니다

연결을 다시 여는 일과 놓친 데이터를 메우는 일은 다릅니다. 마지막으로 반영한 ID를 기준으로 재전송·중복 제거를 수행하고, 다시 실시간 수신으로 전환합니다.

재연결과 누락 복구는 별도의 계약

재연결과 누락 복구는 별도의 계약그림의 로그 조회·재생·중복 제거는 운영 복구 계약입니다. 교재 SSE 코드는 ID 다음 숫자의 교육용 사건을 생성하며 실제 누락 로그 저장·재전송을 구현하지 않습니다. 재연결만으로 exactly-once를 보장하지 않습니다.끊김재시도복구 계약정상정상 수신처리 완료 ID 저장끊김 / 재시도 대기SSE와 WS 방식 구별새 연결인증 / 구독 권한저장 로그 조회마지막 완료 ID 이후ID 중복 제거순서대로 반영실시간 수신 재개완료 ID 전진
재연결과 누락 복구는 별도의 계약그림의 로그 조회·재생·중복 제거는 운영 복구 계약입니다. 교재 SSE 코드는 ID 다음 숫자의 교육용 사건을 생성하며 실제 누락 로그 저장·재전송을 구현하지 않습니다. 재연결만으로 exactly-once를 보장하지 않습니다.끊김재시도복구 계약정상정상 수신처리 완료 ID저장끊김 / 재시도대기SSE와 WS 방식구별새 연결인증 / 구독권한저장 로그 조회마지막 완료 ID이후ID 중복 제거순서대로 반영실시간 수신재개완료 ID 전진

그림의 로그 조회·재생·중복 제거는 운영 복구 계약입니다. 교재 SSE 코드는 ID 다음 숫자의 교육용 사건을 생성하며 실제 누락 로그 저장·재전송을 구현하지 않습니다. 재연결만으로 exactly-once를 보장하지 않습니다.

원래 설명과 상세 기준
  1. 정상 수신 · 마지막 완료 ID 보관

    메시지를 검증해 화면 상태에 반영한 뒤 마지막으로 처리한 ID나 순서를 저장합니다.

  2. 끊김 감지 · 재시도 대기

    WebSocket 예제는1·2·5·10·30초의 상한 있는 단계별 대기를 사용합니다. 운영에서는 지수 백오프·jitter를 검토합니다. SSE의 기본 재접속은 EventSource가 처리합니다.

  3. 새 연결 · 인증과 구독 권한 확인

    새 연결에 유효한 세션과 채널 권한을 확인합니다. 인증·권한 문제라면 필요한 사용자 조치를 안내하고 무한 재시도를 피합니다.

  4. 누락 복구 · 마지막 ID 이후 조회

    서버에 저장된 이벤트에서 마지막 완료 ID 이후를 가져옵니다. SSE Last-Event-ID는 기준을 전달하는 수단이며, 그 값만으로 서버에 누락 데이터가 생기지는 않습니다.

  5. 중복 제거 · 순서대로 반영

    messageId로 중복을 구분하고 상태에 반영한 뒤 완료 ID를 전진시킵니다. 로그 보관 범위를 벗어났다면 전체 상태를 다시 읽는 재동기화 정책도 필요합니다.

  6. 실시간 수신 재개

    복구 조회와 새 메시지 수신 사이에 빈 구간이 생기지 않게 설계합니다. WebSocket 연결 성공 시 예제의 attempt를0으로 되돌립니다.

교재 코드가 보여주는 범위

SSE 서버는 전달받은 ID 다음 숫자로 교육용 이벤트를 생성합니다. 끊긴 동안의 실제 이벤트 저장·재전송은 구현되어 있지 않습니다. WebSocket 대기 함수도 연결·복구 구현 전체가 아닙니다.

운영에서 확인할 범위

ID의 의미·로그 보관 기간·순서·중복·재동기화 경로를 정합니다. 서버 재시작, 여러 인스턴스, 재배포 중에도 이 복구 계약이 유지되는지 확인합니다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
  • 원래 본문의 구현과 도표가 설명한 설계 보강을 분리한다. 서사에 포함한 확장 동작은 assumptions에 명시하고 이미 구현된 안전장치로 주장하지 않는다.
관계도의 보충 설명
원도표의 전체 관계·조건·설명
구분내용
원도표 조건·주석정상 수신 · 마지막 완료 ID 보관: 메시지를 검증해 화면 상태에 반영한 뒤 마지막으로 처리한 ID나 순서를 저장합니다.
원도표 설명WebSocket 예제는 1·2·5·10·30초의 상한 있는 단계별 대기를 사용합니다. 운영에서는 지수 백오프·jitter를 검토합니다. SSE의 기본 재접속은 EventSource가 처리합니다.
원도표 조건·주석끊김 감지 · 재시도 대기: WebSocket 예제는 1·2·5·10·30초의 상한 있는 단계별 대기를 사용합니다. 운영에서는 지수 백오프·jitter를 검토합니다. SSE의 기본 재접속은 EventSource가 처리합니다.
원도표 조건·주석새 연결 · 인증과 구독 권한 확인: 새 연결에 유효한 세션과 채널 권한을 확인합니다. 인증·권한 문제라면 필요한 사용자 조치를 안내하고 무한 재시도를 피합니다.
원도표 조건·주석누락 복구 · 마지막 ID 이후 조회: 서버에 저장된 이벤트에서 마지막 완료 ID 이후를 가져옵니다. SSE Last-Event-ID는 기준을 전달하는 수단이며, 그 값만으로 서버에 누락 데이터가 생기지는 않습니다.
원도표 조건·주석중복 제거 · 순서대로 반영: messageId로 중복을 구분하고 상태에 반영한 뒤 완료 ID를 전진시킵니다. 로그 보관 범위를 벗어났다면 전체 상태를 다시 읽는 재동기화 정책도 필요합니다.
원도표 설명복구 조회와 새 메시지 수신 사이에 빈 구간이 생기지 않게 설계합니다. WebSocket 연결 성공 시 예제의 attempt를 0으로 되돌립니다.
원도표 조건·주석실시간 수신 재개: 복구 조회와 새 메시지 수신 사이에 빈 구간이 생기지 않게 설계합니다. WebSocket 연결 성공 시 예제의 attempt를 0으로 되돌립니다.
원도표 설명교재 코드가 보여 주는 범위
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
끊기기 전마지막 처리: 기록된 ID / 연결: 열림처리한 마지막 알림 ID를 기록한다수신 ID와 처리 완료 위치를 구분한다
재연결 요청연결: 새 연결 / replay: 별도 서버 계약마지막 위치를 보내고 server replay를 요청한다본문 client 연결만으로 server replay가 구현되지는 않는다
재전달 판정새 ID: 처리 후 위치 갱신 / 중복 ID: 중복 처리 안 함누락 사건은 처리하고 중복 ID는 건너뛴다재연결만으로 exactly-once가 보장되지는 않는다

실시간 재연결·재생·복구 상태 — 상태 추적

마지막 처리 ID·끊김·대기·새 연결·누락 조회·중복 제거·수신 재개가 실제로 다른 상태다. 본문 미구현 replay를 개념 모델로 명시하고 재연결 성공과 복구 완료를 구별한다.

재접속 뒤 누락 메시지를 복구해야 같은 상태로 돌아갑니다

RECONNECT & RECOVERY

재접속 뒤 누락 메시지를 복구해야 같은 상태로 돌아갑니다

연결을 다시 여는 일과 놓친 데이터를 메우는 일은 다릅니다. 마지막으로 반영한 ID를 기준으로 재전송·중복 제거를 수행하고, 다시 실시간 수신으로 전환합니다.

재연결과 누락 복구는 별도의 계약

재연결과 누락 복구는 별도의 계약그림의 로그 조회·재생·중복 제거는 운영 복구 계약입니다. 교재 SSE 코드는 ID 다음 숫자의 교육용 사건을 생성하며 실제 누락 로그 저장·재전송을 구현하지 않습니다. 재연결만으로 exactly-once를 보장하지 않습니다.끊김재시도복구 계약정상정상 수신처리 완료 ID 저장끊김 / 재시도 대기SSE와 WS 방식 구별새 연결인증 / 구독 권한저장 로그 조회마지막 완료 ID 이후ID 중복 제거순서대로 반영실시간 수신 재개완료 ID 전진
재연결과 누락 복구는 별도의 계약그림의 로그 조회·재생·중복 제거는 운영 복구 계약입니다. 교재 SSE 코드는 ID 다음 숫자의 교육용 사건을 생성하며 실제 누락 로그 저장·재전송을 구현하지 않습니다. 재연결만으로 exactly-once를 보장하지 않습니다.끊김재시도복구 계약정상정상 수신처리 완료 ID저장끊김 / 재시도대기SSE와 WS 방식구별새 연결인증 / 구독권한저장 로그 조회마지막 완료 ID이후ID 중복 제거순서대로 반영실시간 수신재개완료 ID 전진

그림의 로그 조회·재생·중복 제거는 운영 복구 계약입니다. 교재 SSE 코드는 ID 다음 숫자의 교육용 사건을 생성하며 실제 누락 로그 저장·재전송을 구현하지 않습니다. 재연결만으로 exactly-once를 보장하지 않습니다.

원래 설명과 상세 기준
  1. 정상 수신 · 마지막 완료 ID 보관

    메시지를 검증해 화면 상태에 반영한 뒤 마지막으로 처리한 ID나 순서를 저장합니다.

  2. 끊김 감지 · 재시도 대기

    WebSocket 예제는1·2·5·10·30초의 상한 있는 단계별 대기를 사용합니다. 운영에서는 지수 백오프·jitter를 검토합니다. SSE의 기본 재접속은 EventSource가 처리합니다.

  3. 새 연결 · 인증과 구독 권한 확인

    새 연결에 유효한 세션과 채널 권한을 확인합니다. 인증·권한 문제라면 필요한 사용자 조치를 안내하고 무한 재시도를 피합니다.

  4. 누락 복구 · 마지막 ID 이후 조회

    서버에 저장된 이벤트에서 마지막 완료 ID 이후를 가져옵니다. SSE Last-Event-ID는 기준을 전달하는 수단이며, 그 값만으로 서버에 누락 데이터가 생기지는 않습니다.

  5. 중복 제거 · 순서대로 반영

    messageId로 중복을 구분하고 상태에 반영한 뒤 완료 ID를 전진시킵니다. 로그 보관 범위를 벗어났다면 전체 상태를 다시 읽는 재동기화 정책도 필요합니다.

  6. 실시간 수신 재개

    복구 조회와 새 메시지 수신 사이에 빈 구간이 생기지 않게 설계합니다. WebSocket 연결 성공 시 예제의 attempt를0으로 되돌립니다.

교재 코드가 보여주는 범위

SSE 서버는 전달받은 ID 다음 숫자로 교육용 이벤트를 생성합니다. 끊긴 동안의 실제 이벤트 저장·재전송은 구현되어 있지 않습니다. WebSocket 대기 함수도 연결·복구 구현 전체가 아닙니다.

운영에서 확인할 범위

ID의 의미·로그 보관 기간·순서·중복·재동기화 경로를 정합니다. 서버 재시작, 여러 인스턴스, 재배포 중에도 이 복구 계약이 유지되는지 확인합니다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
  • 원래 본문의 구현과 도표가 설명한 설계 보강을 분리한다. 서사에 포함한 확장 동작은 assumptions에 명시하고 이미 구현된 안전장치로 주장하지 않는다.

관계도의 보충 설명

원도표의 전체 관계·조건·설명
구분내용
원도표 조건·주석정상 수신 · 마지막 완료 ID 보관: 메시지를 검증해 화면 상태에 반영한 뒤 마지막으로 처리한 ID나 순서를 저장합니다.
원도표 설명WebSocket 예제는 1·2·5·10·30초의 상한 있는 단계별 대기를 사용합니다. 운영에서는 지수 백오프·jitter를 검토합니다. SSE의 기본 재접속은 EventSource가 처리합니다.
원도표 조건·주석끊김 감지 · 재시도 대기: WebSocket 예제는 1·2·5·10·30초의 상한 있는 단계별 대기를 사용합니다. 운영에서는 지수 백오프·jitter를 검토합니다. SSE의 기본 재접속은 EventSource가 처리합니다.
원도표 조건·주석새 연결 · 인증과 구독 권한 확인: 새 연결에 유효한 세션과 채널 권한을 확인합니다. 인증·권한 문제라면 필요한 사용자 조치를 안내하고 무한 재시도를 피합니다.
원도표 조건·주석누락 복구 · 마지막 ID 이후 조회: 서버에 저장된 이벤트에서 마지막 완료 ID 이후를 가져옵니다. SSE Last-Event-ID는 기준을 전달하는 수단이며, 그 값만으로 서버에 누락 데이터가 생기지는 않습니다.
원도표 조건·주석중복 제거 · 순서대로 반영: messageId로 중복을 구분하고 상태에 반영한 뒤 완료 ID를 전진시킵니다. 로그 보관 범위를 벗어났다면 전체 상태를 다시 읽는 재동기화 정책도 필요합니다.
원도표 설명복구 조회와 새 메시지 수신 사이에 빈 구간이 생기지 않게 설계합니다. WebSocket 연결 성공 시 예제의 attempt를 0으로 되돌립니다.
원도표 조건·주석실시간 수신 재개: 복구 조회와 새 메시지 수신 사이에 빈 구간이 생기지 않게 설계합니다. WebSocket 연결 성공 시 예제의 attempt를 0으로 되돌립니다.
원도표 설명교재 코드가 보여 주는 범위
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
끊기기 전마지막 처리: 기록된 ID / 연결: 열림처리한 마지막 알림 ID를 기록한다수신 ID와 처리 완료 위치를 구분한다
재연결 요청연결: 새 연결 / replay: 별도 서버 계약마지막 위치를 보내고 server replay를 요청한다본문 client 연결만으로 server replay가 구현되지는 않는다
재전달 판정새 ID: 처리 후 위치 갱신 / 중복 ID: 중복 처리 안 함누락 사건은 처리하고 중복 ID는 건너뛴다재연결만으로 exactly-once가 보장되지는 않는다

위 그림은 연결이 끊긴 뒤 다시 정상 수신으로 돌아오는 상태 변화를 보여 줍니다. 정상 수신 중에는 메시지를 검증해 화면 상태에 반영한 뒤 마지막으로 처리한 ID나 순서를 저장합니다. 끊김을 감지하면 재시도를 기다리는데, WebSocket 예제는 1·2·5·10·30초의 단계별 대기를 쓰고 SSE는 EventSource가 기본 재접속을 처리합니다. 새 연결에서는 세션과 채널 권한이 유효한지 확인하며, 인증이나 권한 문제라면 사용자에게 필요한 조치를 안내하고 끝없이 다시 시도하지 않습니다.

이어 서버에 저장된 이벤트에서 마지막 완료 ID 이후의 것을 가져옵니다. SSE의 Last-Event-ID는 그 기준을 전달하는 수단일 뿐이라, 이 값만으로 서버에 누락 데이터가 생기지는 않습니다. 가져온 메시지는 messageId로 중복을 걸러 순서대로 반영하고 완료 ID를 앞으로 옮기며, 보관 범위를 벗어났다면 전체 상태를 다시 읽어 맞춥니다. 그다음에 실시간 수신을 다시 시작합니다.

그림은 이런 로그 조회와 재전송, 중복 제거를 운영에서 지켜야 할 복구 규칙으로 정리하며, 이 글의 코드가 보여 주는 범위는 그보다 좁다고 구별합니다. SSE 서버는 전달받은 ID의 다음 번호로 교육용 이벤트를 만들 뿐 끊긴 동안의 실제 이벤트를 저장하거나 다시 보내지 않고, WebSocket의 대기 함수도 연결과 복구의 전체 구현은 아닙니다. 운영에서는 ID의 의미와 로그 보관 기간, 순서와 중복, 다시 맞추는 경로를 정하고, 서버가 재시작하거나 여러 인스턴스로 나뉘거나 다시 배포되는 동안에도 이 규칙이 유지되는지 확인합니다. 다시 연결하는 것만으로 메시지를 정확히 한 번(exactly-once) 처리한다고 보장할 수는 없습니다.


인증과 보안

실시간 연결에도 일반 API와 같은 인증과 권한 검사가 필요합니다. 연결이 열릴 때 세션을 검증하고 구독할 수 있는 채널인지 확인하며, 메시지를 받을 때마다 현재 사용자가 그 작업을 할 수 있는지도 검사합니다. OWASP의 WebSocket 보안 지침도 연결을 인증한 뒤에 메시지마다 권한을 확인하라고 안내합니다.

브라우저 URL의 쿼리 문자열에 오래 쓰는 액세스 토큰을 넣으면 서버 로그와 방문 기록에 남을 수 있습니다. 쿠키 기반 세션이나 짧게 만료되는 연결용 토큰을 쓸 수 있지만, 만료 시간을 줄여도 URL이 로그에 남는 일 자체가 없어지지는 않습니다. 또 브라우저의 EventSource 생성자에는 Authorization 같은 임의의 헤더를 지정하는 옵션이 없으므로, 인증 정보를 어떻게 전달할지도 선택한 방식에 맞춰 설계합니다.

WebSocket 핸드셰이크(handshake)에서는 요청의 Origin이 허용된 출처인지 확인하되, Origin 검사가 인증을 대신하지는 않습니다. 세션이 만료되거나 사용자가 로그아웃하면 열려 있는 연결과 권한도 해제합니다. 마지막으로 메시지의 크기와 보내는 빈도를 제한해 메모리 고갈과 스팸을 막습니다.


방식 선택 기준

변경이 드물고 지연을 허용할 수 있으면 폴링을 씁니다. 서버에서 브라우저로 알림과 진행률을 보낼 때는 SSE가 단순하고, 양방향 메시지가 자주 오가는 채팅과 협업 기능에는 WebSocket을 씁니다. 연결 수가 많은 서비스라면 프록시의 타임아웃, 서버 인스턴스 사이의 메시지 전달, 배포 중의 연결 종료를 함께 고려합니다.

이 글 첫머리의 실시간 전송 방식 그림은 세 방식의 방향과 복구 방법, 운영 비용을 한 번에 비교해 보여 줍니다. 어느 방식을 고르든, 화면이 빨라 보이는 것보다 연결이 끊긴 뒤에도 상태를 복구할 수 있는지가 실시간 기능의 품질을 좌우합니다.


정리

  • 실시간 방식을 고르기 전에 허용 지연 시간과 통신 방향을 먼저 정하며, 즉시 갱신이 필요 없다면 일반 재검증으로도 충분합니다.
  • 폴링은 일반 HTTP API만으로 구현할 수 있지만 주기가 짧을수록 비용이 늘고, 탭이 숨겨지면 멈추며 response.ok를 확인해야 합니다.
  • SSE는 하나의 HTTP 스트림으로 서버의 텍스트 이벤트를 받으며, EventSource가 기본 재접속과 Last-Event-ID 전달을 맡지만 누락 이벤트의 재전송은 서버가 구현합니다.
  • WebSocket은 양방향 메시지에 알맞고, 운영에서는 wss://를 쓰며 자동 재접속과 메시지 검증을 직접 구현합니다.
  • 메시지 형식에서는 종류와 식별자, 본문, 시각의 역할을 나누고, 작성자는 인증된 연결 정보로 정합니다.
  • 다시 연결하는 것과 데이터를 복구하는 것은 다른 일이므로, 마지막 완료 ID와 중복 제거, 전체 상태를 다시 읽는 규칙을 함께 설계합니다.
  • 실시간 연결도 연결할 때와 메시지마다 인증과 권한을 확인하고, URL에 장기 토큰을 넣지 않으며, 메시지 크기와 빈도를 제한합니다.

연습 문제

1. 보고서 진행률

사용자가 HTTP POST로 보고서 생성을 요청합니다. 그 뒤의 진행률은 서버가 보내며, 페이지에서 서버로 계속 입력을 보낼 필요는 없습니다. 서버와 프록시가 HTTP 스트림을 지원한다면 어떤 방식이 알맞을까요? 빠른 양방향 채팅도 같은 방식 하나로 꼭 처리해야 할까요?

해설 보기

진행률을 받는 데에는 SSE가 알맞습니다. 보고서 생성은 일반 POST로 시작하고, 진행률은 서버에서 브라우저로 보내는 SSE로 나눠 받을 수 있습니다. SSE가 단방향이라고 해서 브라우저의 다른 HTTP 요청까지 막는 것은 아닙니다.

양방향 메시지가 자주 오가는 채팅은 같은 방식으로 묶을 필요 없이 WebSocket을 따로 검토합니다. 실제로 고를 때는 서버와 프록시의 스트리밍 지원, 허용 지연 시간도 함께 확인합니다.

2. 폴링 요청 수와 지연

보이는 탭 1,000개가 0초에 바로 한 번 조회하고, 이후 10초 간격으로 폴링합니다. 모든 요청은 다음 주기 전에 성공하고, 탭 전환이나 타이머 지연은 없다고 가정합니다. 0초 이상 60초 미만 동안 요청은 모두 몇 번 생길까요? 데이터가 10.1초에 바뀌면 다음 조회까지 대략 얼마나 기다릴까요?

해설 보기

탭 하나는 0, 10, 20, 30, 40, 50초에 6번 요청하므로 모두 1,000 × 6 = 6,000번입니다. 60초의 요청은 범위에 들어가지 않습니다.

10.1초의 변화는 20초의 조회에서 처음 확인되므로 약 9.9초를 기다리고, 여기에 응답 지연이 더해집니다. 이 값은 문제의 이상적인 조건에서 계산한 것이며, 실제 타이머가 10초 안의 갱신을 보장하지는 않습니다.

3. 중복과 누락이 섞인 재전송

화면은 메시지 42까지 반영했습니다. 연결이 끊긴 동안 서버 기록에 43, 44, 45가 쌓였고, 다시 연결한 뒤 43, 44, 44, 45가 전달되었습니다. 무엇을 반영하고, 완료 ID는 언제 앞으로 옮길까요? 다른 경우로, 서버가 이미 50번 이전의 기록을 지웠다면 완료 ID를 45로 바꾸기만 해도 복구된 것일까요?

해설 보기

43, 44, 45를 순서대로 한 번씩 반영하고 두 번째 44는 건너뜁니다. 완료 ID는 각 메시지를 검증해 실제 상태에 적용한 뒤에 앞으로 옮깁니다.

42 이후의 기록이 남아 있지 않다면 빠진 내용을 숫자만으로 다시 만들 수 없으므로, 완료 ID를 바꾸는 것은 복구가 아닙니다. 서버가 허용하는 최신 전체 상태를 다시 읽고, 그 상태에 맞는 위치부터 수신을 이어 가야 합니다. 연결 복구와 이벤트 재전송, 중복 제거, 상태 복구는 각각 따로 확인해야 하는 일입니다.

이것으로 15장과 웹 입문 트랙을 마칩니다. 15장에서는 설치하고 오프라인에서도 쓸 수 있는 PWA, 브라우저에서 컴파일된 코드를 실행하는 WebAssembly, 미리 만든 페이지와 API를 나누는 JAMstack, 그리고 폴링과 SSE, WebSocket으로 서버의 새 상태를 받는 실시간 통신까지 최신 웹 기술의 흐름을 살펴봤습니다. 처음부터 다시 훑어보고 싶다면 웹 입문 교재의 목차로 돌아가세요.