
WebSocket 실전 패턴: 실시간 알림을 직접 구현하며 배운 것들
실시간 알림을 구현하려고 WebSocket을 공부했다. HTTP 폴링부터 Supabase Realtime까지, 실시간 통신의 선택지와 실전 패턴을 정리했다.

실시간 알림을 구현하려고 WebSocket을 공부했다. HTTP 폴링부터 Supabase Realtime까지, 실시간 통신의 선택지와 실전 패턴을 정리했다.
로버트 C. 마틴(Uncle Bob)이 제안한 클린 아키텍처의 핵심은 무엇일까요? 양파 껍질 같은 계층 구조와 의존성 규칙(Dependency Rule)을 통해 프레임워크와 UI로부터 독립적인 소프트웨어를 만드는 방법을 정리합니다.

HTTP는 무전기(오버) 방식이지만, 웹소켓은 전화기(여보세요)입니다. 채팅과 주식 차트가 실시간으로 움직이는 기술적 비밀.

프링글스 통(Stack)과 맛집 대기 줄(Queue). 가장 기초적인 자료구조지만, 이걸 모르면 재귀 함수도 메시지 큐도 이해할 수 없습니다.

페이스북은 왜 REST API를 버렸을까? 원하는 데이터만 쏙쏙 골라 담는 GraphQL의 매력과 치명적인 단점 (캐싱, N+1 문제) 분석.

사이드 프로젝트에 알림 기능을 붙이기로 했다. 누군가 내 게시물에 댓글을 달면 화면 상단에 알림이 뜨는, 그 흔한 기능이다. 구현 난이도는 별로 안 높을 것 같았다.
그런데 막상 시작하려니 막막했다. 서버에서 클라이언트에게 "이벤트가 생겼어요"라고 먼저 알려주는 방식이 HTTP의 기본 동작 방식과 맞지 않았다. HTTP는 클라이언트가 요청하면 서버가 응답하는 구조다. 서버가 먼저 말을 걸 수 없다.
HTTP는 편지다. 클라이언트가 편지를 보내야(요청) 서버가 답장을 한다(응답). 서버가 먼저 편지를 쓸 수 없다. 새로운 소식이 생겼다고 서버가 갑자기 클라이언트 집 문을 두드릴 수 없는 구조다.
그래서 실시간 기능을 구현하는 방법을 찾아봤다. 생각보다 선택지가 많았다. 폴링, 롱 폴링, SSE, WebSocket. 각각 언제 쓰는 건지 정리해본다.
가장 단순한 방법부터 시도했다. 그냥 5초마다 서버에 "새 알림 있어요?" 하고 물어보는 방식이다.
// 폴링: 5초마다 새 알림 확인
function startPolling(userId: string) {
const interval = setInterval(async () => {
const response = await fetch(`/api/notifications?userId=${userId}`);
const { notifications } = await response.json();
if (notifications.length > 0) {
showNotifications(notifications);
}
}, 5000);
return () => clearInterval(interval); // 정리 함수
}
동작은 했다. 알림도 뜬다. 그런데 문제가 두 가지 있었다.
첫째, 응답 지연이다. 알림이 생겨도 최대 5초를 기다려야 한다. 누군가 댓글을 달았는데 5초 뒤에 알림이 뜨면 실시간이라는 느낌이 전혀 없다.
둘째, 서버 부하다. 사용자가 100명이면 5초마다 100번의 요청이 온다. 새 알림이 없어도 서버는 계속 응답해야 한다. 실제로 알림이 발생하는 순간은 하루에 몇 번 안 될 수도 있는데, 365일 24시간 쉬지 않고 서버를 두드리는 셈이다.
이건 마치 5초마다 우체통을 열어보는 것과 같다. 편지가 올 때까지 계속 열었다 닫았다 반복하는 것. 우체부는 하루에 한 번 오는데, 나는 하루에 17,000번 우체통을 열어보는 비효율이다.
폴링 간격을 1초로 줄이면 반응성은 좋아지지만 서버 부하는 5배가 된다. 간격을 30초로 늘리면 서버는 편해지지만 사용성이 나빠진다. 어느 쪽도 좋지 않다.
실시간 통신 방식이 어떻게 발전했는지 이해하니 각 선택지의 장단점이 와닿았다.
롱 폴링은 폴링의 변형이다. 요청을 보내면 서버가 즉시 응답하지 않는다. 새로운 이벤트가 생길 때까지 연결을 열어둔다. 이벤트가 생기면 그때 응답하고, 클라이언트는 받자마자 다시 요청을 보낸다.
우체통 비유로 다시 생각하면, 우체통 앞에 서서 기다리는 것이다. 5초마다 열어보는 대신, 문을 열어놓고 편지가 올 때까지 서 있는다. 편지가 오면 받아서 읽고, 다시 열어놓고 기다린다.
불필요한 요청은 줄었다. 하지만 여전히 HTTP 요청-응답 구조 위에서 동작하는 우회책이다. 타임아웃 처리, 에러 재시도, 연결 관리가 복잡해진다. 그리고 서버 쪽에서 연결을 들고 있어야 하니 Node.js가 아닌 전통적인 스레드 기반 서버에서는 확장성 문제가 생긴다.
SSE는 HTTP 위에서 서버가 클라이언트에게 단방향으로 데이터를 스트리밍하는 방식이다. 클라이언트가 한 번 연결을 맺으면, 서버는 이벤트가 생길 때마다 데이터를 밀어넣는다.
SSE는 라디오 방송이다. 라디오를 틀면(연결 수립) 방송국은 계속 신호를 보낸다. 청취자가 따로 "지금 뭐 나와요?" 하고 물어볼 필요가 없다. 방송국이 새 노래를 틀면 알아서 들린다. 단, 청취자가 방송국에 직접 메시지를 보낼 수는 없다. 단방향이다.
SSE는 알림처럼 서버→클라이언트 단방향 통신에 꽤 적합하다. HTTP/2에서는 다수의 SSE 연결을 하나의 TCP 연결로 다중화할 수 있어 효율적이다. 브라우저 내장 기능(EventSource)을 쓰기 때문에 라이브러리가 필요 없다.
단점은 양방향이 안 된다는 것이다. 채팅이나 협업 편집처럼 클라이언트도 실시간으로 서버에 데이터를 보내야 하는 경우엔 SSE만으로 해결이 안 된다.
WebSocket은 HTTP 핸드셰이크로 시작하지만, 연결이 성립되면 HTTP가 아닌 완전한 양방향 채널이 된다. 클라이언트도, 서버도, 언제든 먼저 메시지를 보낼 수 있다.
WebSocket은 전화 통화다. 편지(HTTP)는 주고받는 데 시간이 걸리고, 상대방이 읽기 전까지는 답을 모른다. 전화는 연결되면 양쪽이 동시에 말할 수 있다. "어, 지금 댓글 달았어"라고 서버가 말하면 클라이언트가 바로 듣는다. 클라이언트가 "그 댓글 읽었다고 표시해줘"라고 말하면 서버도 바로 듣는다.
| 방식 | 방향 | 오버헤드 | 복잡도 | 적합한 용도 |
|---|---|---|---|---|
| 폴링 | 클→서 | 높음 | 낮음 | 간단한 상태 확인 |
| 롱 폴링 | 클→서 | 중간 | 중간 | 낮은 빈도 알림 |
| SSE | 서→클 | 낮음 | 낮음 | 알림, 피드, 대시보드 업데이트 |
| WebSocket | 양방향 | 낮음 | 높음 | 채팅, 협업, 실시간 게임 |
WebSocket을 직접 구현하려면 고려할 게 많다. 연결 관리, 재연결 로직, 스케일링, 서버리스 환경에서의 제약. 그래서 내가 이미 쓰고 있던 Supabase의 Realtime 기능을 먼저 살펴봤다.
Supabase Realtime은 데이터베이스 변경 사항을 WebSocket으로 클라이언트에게 브로드캐스트한다. PostgreSQL의 논리적 복제(logical replication)를 활용해서, 테이블에 행이 삽입/수정/삭제되면 구독 중인 클라이언트에게 알린다.
설정이 거의 없다는 게 핵심이다. 별도 서버 없이, 이미 있는 Supabase 프로젝트에서 바로 쓸 수 있다.
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
// notifications 테이블에 새 행이 삽입될 때마다 실시간으로 수신
function subscribeToNotifications(userId: string, onNew: (notification: Notification) => void) {
const channel = supabase
.channel(`notifications:${userId}`)
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'notifications',
filter: `user_id=eq.${userId}`,
},
(payload) => {
onNew(payload.new as Notification);
}
)
.subscribe((status) => {
if (status === 'SUBSCRIBED') {
console.log('실시간 알림 구독 시작');
}
});
// 컴포넌트 언마운트 시 구독 해제
return () => {
supabase.removeChannel(channel);
};
}
React에서 쓰면 이렇게 된다.
// React 컴포넌트에서 사용
function NotificationBell({ userId }: { userId: string }) {
const [notifications, setNotifications] = useState<Notification[]>([]);
useEffect(() => {
const unsubscribe = subscribeToNotifications(userId, (notification) => {
setNotifications((prev) => [notification, ...prev]);
// 토스트 메시지 표시
toast(`새 알림: ${notification.message}`);
});
return unsubscribe; // 언마운트 시 정리
}, [userId]);
return (
<button className="relative">
<BellIcon />
{notifications.length > 0 && (
<span className="badge">{notifications.length}</span>
)}
</button>
);
}
데이터베이스에 알림 행을 삽입하는 것만으로 끝이다. 나머지는 Supabase가 처리한다. WebSocket 서버를 따로 운영할 필요가 없고, 스케일링 걱정도 없다. 소규모 프로젝트에는 이게 최선이라는 결론을 내렸다.
규모가 커지거나, Supabase 없는 환경이거나, 더 정교한 제어가 필요하면 WebSocket을 직접 다뤄야 한다. 직접 구현하면서 가장 많이 삽질한 부분이 재연결 로직이었다.
WebSocket 연결은 끊어진다. 네트워크가 불안정하거나, 서버가 재시작되거나, 클라이언트가 잠깐 오프라인 상태가 되거나. 연결이 끊어졌을 때 그냥 "연결이 끊겼습니다"를 보여주는 건 나쁜 UX다. 자동으로 다시 연결을 시도해야 한다. 그런데 단순히 즉시 재연결을 시도하면 서버에 폭탄이 떨어진다. 수천 명이 동시에 연결이 끊기고 동시에 재연결을 시도하면 서버가 버티지 못한다.
지수 백오프(Exponential Backoff)가 해결책이다.
class ReliableWebSocket {
private ws: WebSocket | null = null;
private reconnectAttempts = 0;
private maxReconnectAttempts = 5;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private isIntentionallyClosed = false;
constructor(
private url: string,
private onMessage: (data: unknown) => void,
private onStatusChange?: (status: 'connected' | 'disconnected' | 'reconnecting') => void
) {
this.connect();
}
private connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
console.log('WebSocket 연결됨');
this.reconnectAttempts = 0; // 성공하면 카운터 리셋
this.onStatusChange?.('connected');
};
this.ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
this.onMessage(data);
} catch {
console.error('메시지 파싱 실패:', event.data);
}
};
this.ws.onclose = (event) => {
if (this.isIntentionallyClosed) return; // 의도적으로 닫은 경우 재연결 안 함
this.onStatusChange?.('disconnected');
this.scheduleReconnect();
};
this.ws.onerror = (error) => {
console.error('WebSocket 에러:', error);
// onerror 뒤에 onclose가 항상 오므로, 여기서 재연결 처리 안 해도 됨
};
}
private scheduleReconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('최대 재연결 시도 횟수 초과');
return;
}
// 지수 백오프: 1초, 2초, 4초, 8초, 16초
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
// 지터(jitter) 추가: 동시 재연결 폭발 방지
const jitter = Math.random() * 1000;
this.onStatusChange?.('reconnecting');
this.reconnectAttempts++;
this.reconnectTimer = setTimeout(() => {
console.log(`재연결 시도 ${this.reconnectAttempts}/${this.maxReconnectAttempts}`);
this.connect();
}, delay + jitter);
}
send(data: unknown) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data));
} else {
console.warn('WebSocket이 열려있지 않습니다. 메시지 전송 실패.');
}
}
close() {
this.isIntentionallyClosed = true;
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
this.ws?.close();
}
}
핵심은 두 가지다.
첫째, 지수 백오프: 재연결 간격을 1초, 2초, 4초, 8초처럼 지수적으로 늘린다. 서버가 다운됐을 때 모든 클라이언트가 동시에 재연결을 시도하는 "재연결 폭풍"을 막는다.
둘째, 지터(Jitter): 백오프 간격에 무작위 값을 더한다. 1,000명이 정확히 같은 타이밍에 재연결을 시도하는 상황을 분산시킨다. AWS와 같은 클라우드 서비스들이 권장하는 패턴이다.
Vercel, Netlify 같은 서버리스 환경에서는 WebSocket을 직접 운용하기 어렵다. 함수가 요청당 실행되고 종료되는 구조라서, 영속적인 TCP 연결이 불가능하다. 이 경우 선택지는 세 가지다.
내 프로젝트는 Next.js on Vercel이라 WebSocket 서버를 따로 띄우지 않고 Supabase Realtime을 선택한 이유가 여기 있다.
WebSocket 연결이 맺어진 상태에서도 방화벽이나 프록시가 오랫동안 활동이 없는 연결을 강제로 끊을 수 있다. 특히 모바일 네트워크에서 자주 발생한다. 이걸 방지하려면 주기적으로 ping을 보내야 한다.
서버에서는 핑을 받으면 퐁으로 응답하고, 클라이언트는 일정 시간 안에 퐁이 오지 않으면 연결이 끊긴 것으로 판단하고 재연결을 시도한다. WebSocket 프로토콜에는 ping/pong 프레임이 내장되어 있지만, 애플리케이션 레벨에서도 따로 구현하는 경우가 많다.
연결이 끊긴 동안 발생한 이벤트는 누락된다. 재연결 후 어디서부터 다시 받아야 하는지 처리가 필요하다. 가장 단순한 방법은 재연결 시 마지막으로 받은 이벤트의 타임스탬프를 서버에 전달하고, 그 이후 이벤트를 모두 받는 것이다.
그리고 사용자에게 현재 연결 상태를 보여주는 게 중요하다. "오프라인 모드입니다. 재연결 중..." 같은 표시가 없으면 사용자는 알림이 왜 안 오는지 모른다. 혼란스러운 UX가 된다.
결국 이게 핵심이었다.
폴링: 이미 있는 REST API를 그대로 쓰면서 빠르게 붙여야 할 때. 빈도가 낮고 지연이 허용될 때.
SSE: 서버→클라이언트 단방향 알림. 서버리스 환경에서도 일부 작동. 소셜 피드, 실시간 대시보드 업데이트처럼 단방향으로 충분할 때.
WebSocket: 채팅, 협업 편집, 실시간 게임처럼 양방향이 필요할 때. 서버 인프라를 직접 제어할 수 있을 때.
Supabase Realtime: 이미 Supabase를 쓰고 있고, 데이터베이스 변경을 트리거로 알림을 보내야 할 때. 소규모 팀, 서버리스 환경, 빠른 구현이 필요할 때. 소규모 팀의 90%는 여기서 끝난다.
직접 구현하지 않아도 되는 문제는 직접 구현하지 않는 게 낫다. Supabase Realtime 한 줄로 해결되는 걸 WebSocket 서버 구축에 시간을 쓸 필요가 없다. 스케일이 커지고 더 세밀한 제어가 필요해질 때 그때 직접 구현으로 넘어가면 된다.