본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
15장 : 최신 웹 기술 동향

실시간 웹 통신

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

일반적인 HTTP 요청은 브라우저가 먼저 요청하고 서버가 한 번 응답하면 끝납니다.

채팅, 알림, 진행률처럼 서버의 새 상태를 바로 받아야 하는 화면에서는 다른 통신 전략이 필요합니다.

이 절에서는 폴링, Server-Sent Events(SSE), WebSocket의 차이를 이해하고 상황에 맞는 방식을 선택합니다.

아래 다이어그램은 허용 지연 시간과 통신 방향에 따라 실시간 방식을 고르는 기준을 보여줍니다.


실시간의 의미

웹에서 실시간은 변화가 생긴 직후 사용자가 알아야 한다는 요구사항을 뜻합니다.

모든 기능이 밀리초 단위의 양방향 연결을 필요로 하는 것은 아닙니다.

게시글 목록을 1분마다 갱신해도 충분하다면 일반 재검증이 가장 단순합니다.

알림을 몇 초 안에 받아야 한다면 폴링이나 SSE를 고려할 수 있습니다.

사용자끼리 메시지를 주고받는 채팅은 WebSocket이 자연스럽습니다.

기술을 고르기 전에 허용 지연 시간과 통신 방향을 먼저 정합니다.


폴링

폴링은 브라우저가 일정한 간격으로 서버에 새 데이터가 있는지 묻는 방식입니다.

async function refreshNotifications() {
  const response = await fetch('/api/notifications?after=42');
  const notifications = await response.json();
  renderNotifications(notifications);
}

let timerId;

function startPolling() {
  if (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만 있으면 구현할 수 있고 연결 복구도 단순합니다.

변화가 없어도 요청을 반복하므로 주기가 짧을수록 서버와 네트워크 비용이 늘어납니다.

탭이 보이지 않을 때는 요청을 줄이거나 중지합니다.

탭이 다시 보일 때 startPolling()은 즉시 한 번 갱신한 뒤 타이머를 재개합니다.

아래 다이어그램은 폴링 주기와 요청 비용, 데이터 지연의 관계를 비교합니다.


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.onerror = () => {
  showConnectionState('재연결 중');
};

서버는 text/event-stream 형식으로 응답합니다.

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);

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

app.listen(4100);

SSE의 id: 필드는 브라우저가 재접속할 때 Last-Event-ID 헤더로 전달합니다.

위 예제는 다음 ID를 이어 붙이는 최소 흐름이며, 운영 서버는 이벤트를 영속 저장하고 해당 ID 이후의 누락 이벤트를 먼저 재전송해야 실제 복구가 됩니다.

각 이벤트는 빈 줄로 끝납니다.

콜론으로 시작하는 줄은 연결을 유지하기 위한 주석 이벤트입니다.

예제의 notificationTimersend()가 실제 이벤트를 전송하는 흐름을 보여주기 위한 교육용 알림 생산자입니다.

운영 코드에서는 이 타이머 대신 메시지 브로커나 데이터베이스 변경 구독에서 알림을 받아 send()를 호출합니다.

사용자가 연결을 닫으면 하트비트, 알림 생산자와 외부 구독을 모두 정리합니다.

아래 다이어그램은 SSE 연결에서 이벤트·하트비트·재접속이 이어지는 흐름을 정리합니다.


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

ws://는 암호화하지 않은 연결이고 wss://는 TLS로 보호한 연결입니다.

운영 환경에서는 HTTPS와 함께 wss://를 사용합니다.

브라우저가 보낸 JSON은 신뢰할 수 없는 입력이므로 서버에서 타입, 길이와 권한을 검증합니다.

아래 다이어그램은 WebSocket 연결 수립과 양방향 메시지 교환을 보여줍니다.


메시지 계약 설계

실시간 연결에서도 메시지의 종류와 데이터 모양을 명시해야 합니다.

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

연결에 성공하면 attempt를 0으로 되돌립니다.

재접속 뒤 놓친 메시지를 가져올 수 있도록 마지막 이벤트 ID나 메시지 시각을 서버에 전달합니다.

재접속이 곧 데이터 복구를 의미하는 것은 아닙니다.

아래 다이어그램은 연결 끊김부터 백오프·재접속·누락 메시지 복구까지의 상태 변화를 보여줍니다.


인증과 보안

실시간 연결도 일반 API와 같은 인증과 권한 검사가 필요합니다.

연결이 열릴 때 세션을 검증하고 구독할 수 있는 채널인지 확인합니다.

메시지를 받을 때마다 현재 사용자가 해당 작업을 할 수 있는지도 검사합니다.

브라우저 URL의 쿼리 문자열에 장기 액세스 토큰을 넣으면 로그와 방문 기록에 남을 수 있습니다.

쿠키 기반 세션이나 짧게 만료되는 연결용 토큰을 사용합니다.

메시지 크기와 전송 빈도를 제한하여 메모리 고갈과 스팸을 막습니다.


방식 선택 기준

변경 빈도가 낮고 지연을 허용할 수 있으면 폴링을 사용합니다.

서버에서 브라우저로 알림과 진행률을 보낼 때는 SSE가 단순합니다.

양방향 메시지가 자주 오가는 채팅과 협업 기능에는 WebSocket을 사용합니다.

연결 수가 많은 서비스에서는 프록시의 타임아웃, 서버 인스턴스 간 메시지 전달과 배포 중 연결 종료를 함께 고려합니다.

실시간 기술은 빠르게 보이는 화면보다 끊김 후에도 상태를 복구하는 설계가 더 중요합니다.

마지막 다이어그램으로 폴링·SSE·WebSocket의 방향·복구·운영 비용을 한 번에 비교합니다.