실시간 웹 통신
폴링·SSE·WebSocket의 통신 방향과 연결 비용을 비교하고 알림과 채팅에 맞는 방식을 선택합니다.
일반적인 HTTP 요청은 브라우저가 먼저 요청하고 서버가 한 번 응답하면 끝납니다.
채팅, 알림, 진행률처럼 서버의 새 상태를 바로 받아야 하는 화면에서는 다른 통신 전략이 필요합니다.
이 절에서는 폴링, Server-Sent Events(SSE), WebSocket의 차이를 이해하고 상황에 맞는 방식을 선택합니다.
아래 다이어그램은 허용 지연 시간과 통신 방향에 따라 실시간 방식을 고르는 기준을 보여줍니다.
항상 WebSocket부터 고르지 않는다. 변화 빈도, 허용 지연과 메시지 방향이 선택 기준이다.
- 폴링
요청 반복 브라우저가 일정 주기로 묻는다. 변화가 드물고 수 초 지연을 허용할 때 단순하다.
- 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()은 즉시 한 번 갱신한 뒤 타이머를 재개합니다.
아래 다이어그램은 폴링 주기와 요청 비용, 데이터 지연의 관계를 비교합니다.
주기를 짧게 하면 새 데이터를 빨리 보지만 변화가 없어도 요청을 반복한다.
- 요청 비용 높음1초
반응은 빠르지만 분당 60회 요청한다.
- 요청 비용 중간10초
알림처럼 수 초 지연을 허용할 때 균형점이 된다.
- 요청 비용 낮음60초
비용은 낮지만 최대 1분 늦게 보인다.
SSE로 서버 알림 받기
SSE는 하나의 HTTP 연결을 유지하면서 서버가 브라우저로 텍스트 이벤트를 계속 보내는 방식입니다.
통신 방향은 서버에서 브라우저로 한쪽입니다.
브라우저의 EventSource가 연결과 기본 재접속을 관리합니다.
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 형식으로 응답합니다.
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 이후의 누락 이벤트를 먼저 재전송해야 실제 복구가 됩니다.
각 이벤트는 빈 줄로 끝납니다.
콜론으로 시작하는 줄은 연결을 유지하기 위한 주석 이벤트입니다.
예제의 notificationTimer는 send()가 실제 이벤트를 전송하는 흐름을 보여주기 위한 교육용 알림 생산자입니다.
운영 코드에서는 이 타이머 대신 메시지 브로커나 데이터베이스 변경 구독에서 알림을 받아 send()를 호출합니다.
사용자가 연결을 닫으면 하트비트, 알림 생산자와 외부 구독을 모두 정리합니다.
아래 다이어그램은 SSE 연결에서 이벤트·하트비트·재접속이 이어지는 흐름을 정리합니다.
EventSource는 단방향 스트림을 열고 연결이 끊기면 재접속한다. 서버는 구독과 타이머를 정리해야 한다.
- 연결
브라우저가 text/event-stream 응답을 연다.
- 이벤트
서버가 event와 data 줄을 빈 줄로 구분해 보낸다.
- 하트비트
주석 이벤트로 프록시와 연결의 유휴 종료를 막는다.
- 끊김
브라우저·네트워크 종료를 서버가 close 이벤트로 감지한다.
- 재접속
EventSource가 다시 연결하고 필요하면 마지막 ID 뒤부터 받는다. ↻ 01 연결로
WebSocket으로 양방향 통신하기
WebSocket은 연결을 만든 뒤 브라우저와 서버가 양쪽에서 메시지를 보낼 수 있습니다.
채팅, 협업 편집, 실시간 게임처럼 양방향 메시지가 잦은 기능에 적합합니다.
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 연결 수립과 양방향 메시지 교환을 보여줍니다.
HTTP 업그레이드 뒤 브라우저와 서버가 독립적으로 메시지를 보낸다. 수신 값은 양쪽 모두 검증한다.
- 사용자 입력 전송
Browser open 상태를 확인하고 type과 payload가 있는 메시지를 보낸다.
- 권한 확인 후 브로드캐스트
Server 연결의 인증 정보로 작성자를 결정하고 방 참여 권한을 확인한다.
메시지 계약 설계
실시간 연결에서도 메시지의 종류와 데이터 모양을 명시해야 합니다.
{
"type": "chat",
"messageId": "m-1024",
"roomId": "general",
"content": "안녕하세요",
"sentAt": "2026-07-18T10:30:00.000Z"
}type은 수신 측이 메시지 처리기를 선택하는 기준입니다.
messageId는 재전송이나 중복 수신을 구분하는 데 사용합니다.
sentAt은 문자열 형식과 시간대 기준을 고정합니다.
서버는 클라이언트가 보낸 사용자 ID를 그대로 믿지 않고 인증된 연결 정보에서 작성자를 결정합니다.
아래 다이어그램은 메시지 봉투의 식별자·종류·본문·시각이 맡는 역할을 구분합니다.
본문만 보내면 중복과 순서를 다루기 어렵다. 처리에 필요한 메타데이터를 안정적인 계약으로 묶는다.
- 1messageId
식별자 재전송이나 중복 수신을 같은 메시지로 식별한다.
- 2type
종류 수신 측이 chat, join, typing 처리기를 선택한다.
- 3payload
본문 메시지 종류에 필요한 실제 데이터를 담고 서버에서 검증한다.
- 4sentAt · sequence
시각·순서 시간대와 순서를 고정해 누락 범위와 표시 순서를 복구한다.
연결 끊김과 재접속
모바일 네트워크와 절전 상태에서는 연결이 자주 끊길 수 있습니다.
실시간 기능은 정상 연결뿐 아니라 재접속 흐름을 함께 설계해야 합니다.
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나 메시지 시각을 서버에 전달합니다.
재접속이 곧 데이터 복구를 의미하는 것은 아닙니다.
아래 다이어그램은 연결 끊김부터 백오프·재접속·누락 메시지 복구까지의 상태 변화를 보여줍니다.
연결 성공만으로 상태가 같아지지 않는다. 백오프와 마지막 확인 위치를 함께 관리한다.
- 연결됨
마지막 sequence를 저장하며 메시지를 처리한다.
- 연결 끊김
전송을 멈추고 UI에 재연결 상태를 표시한다.
- 백오프 대기
1초, 2초, 5초처럼 간격을 늘려 서버 폭주를 막는다.
- 재접속
인증을 갱신하고 마지막 sequence를 서버에 보낸다.
- 누락 복구
그 이후 메시지를 받은 뒤 실시간 스트림으로 돌아간다.
인증과 보안
실시간 연결도 일반 API와 같은 인증과 권한 검사가 필요합니다.
연결이 열릴 때 세션을 검증하고 구독할 수 있는 채널인지 확인합니다.
메시지를 받을 때마다 현재 사용자가 해당 작업을 할 수 있는지도 검사합니다.
브라우저 URL의 쿼리 문자열에 장기 액세스 토큰을 넣으면 로그와 방문 기록에 남을 수 있습니다.
쿠키 기반 세션이나 짧게 만료되는 연결용 토큰을 사용합니다.
메시지 크기와 전송 빈도를 제한하여 메모리 고갈과 스팸을 막습니다.
방식 선택 기준
변경 빈도가 낮고 지연을 허용할 수 있으면 폴링을 사용합니다.
서버에서 브라우저로 알림과 진행률을 보낼 때는 SSE가 단순합니다.
양방향 메시지가 자주 오가는 채팅과 협업 기능에는 WebSocket을 사용합니다.
연결 수가 많은 서비스에서는 프록시의 타임아웃, 서버 인스턴스 간 메시지 전달과 배포 중 연결 종료를 함께 고려합니다.
실시간 기술은 빠르게 보이는 화면보다 끊김 후에도 상태를 복구하는 설계가 더 중요합니다.
마지막 다이어그램으로 폴링·SSE·WebSocket의 방향·복구·운영 비용을 한 번에 비교합니다.