폴링 주기를 아무리 조절해도 안 되던 실시간 알림을, WebSocket으로 옮기며 배운 것들로 정리해봤습니다.

폴링으로 실시간을 흉내 내던 시절
몇 해 전 사내 협업 툴에 알림 기능을 붙일 때 이야기입니다. 누군가 나를 멘션하거나 문서에 댓글을 달면 종 모양 아이콘에 빨간 점이 떠야 했죠. 저는 가장 익숙한 방식으로 시작했습니다. setInterval로 일정 주기마다 서버에 "나한테 새 알림 있나요?"라고 물어보는 polling이었습니다.
// 5초마다 새 알림 개수를 물어보는 주기적 요청
setInterval(async () => {
const res = await fetch("/api/notifications/unread");
const { count } = await res.json();
updateBadge(count); // 배지 갱신
}, 5000);
처음엔 5초 주기였습니다. 로컬에서는 그럭저럭 괜찮아 보였지만, 실제로 써 보니 "실시간"이라는 말이 무색했습니다. 옆자리 동료가 멘션을 남기고 "확인했어요?"라고 물어보면, 제 화면엔 아직 배지가 안 떠 있었죠. 5초가 이렇게 길게 느껴질 줄 몰랐습니다.
그래서 주기를 2초로 줄였습니다. 체감은 조금 나아졌지만 이번엔 다른 곳이 아팠습니다. 접속자가 300명 남짓이던 시점에, 알림 엔드포인트의 요청 수가 분당 9,000건 근처까지 올라갔습니다. 그중 대부분은 "새 알림 없음"이라는 빈 응답이었죠. 실제로 알림이 생기는 순간은 하루에 몇 번뿐인데, 서버는 종일 "없어요"를 반복해서 답하느라 CPU와 DB 커넥션을 갉아먹고 있었습니다.
당시 APM 대시보드를 열어 보니, 알림 조회 쿼리가 전체 DB 질의의 상위권에 올라와 있었습니다. 사용자 한 명이 화면을 켜 두기만 해도 2초마다 커넥션을 하나씩 빌려 갔으니, 접속자가 늘어날수록 커넥션 풀이 먼저 비명을 질렀죠. 무언가 잘못 흘러가고 있다는 신호였습니다.

여기서 폴링의 근본적인 딜레마를 절감했습니다. 주기를 짧게 잡으면 서버 부하와 빈 응답 낭비가 커지고, 길게 잡으면 알림이 늦게 도착합니다. 이 둘은 같은 손잡이의 양쪽 끝이라, 한쪽을 당기면 반대쪽이 나빠집니다. 아무리 숫자를 만지작거려도 "빠르면서 가벼운" 지점은 존재하지 않았죠.
문제의 본질은 방향이었습니다. 정작 새 소식을 아는 쪽은 서버인데, 정보가 없는 클라이언트가 계속 물어보는 구조였으니까요. 아는 쪽이 먼저 말해 주면 될 일을, 모르는 쪽이 쉬지 않고 문을 두드리고 있었던 겁니다. 주기를 3초, 4초로 바꿔 가며 A/B로 비교해 봐도 결론은 같았습니다. 폴링이라는 틀 안에서는 어떤 숫자를 넣어도 만족스러운 답이 나오지 않는다는 것, 그러니 손잡이가 아니라 구조 자체를 바꿔야 한다는 것이었죠.
WebSocket은 무엇을 바꾸는가
WebSocket은 이 방향을 뒤집습니다. 요청마다 연결을 새로 맺고 끊는 HTTP와 달리, 한 번 맺은 연결을 계속 열어 두고 그 위로 데이터를 주고받습니다. 그래서 이벤트가 생기면 서버가 먼저 클라이언트로 push할 수 있고, 통신도 양방향이죠.
연결은 평범한 HTTP 요청으로 시작합니다. 클라이언트가 Upgrade: websocket 헤더와 함께 무작위로 만든 Sec-WebSocket-Key를 담아 보내면, 서버가 101 Switching Protocols로 답하며 프로토콜을 바꾸는 handshake를 거칩니다. 이 악수가 끝나면 그때부터는 같은 TCP 연결이 WebSocket 통로로 바뀌고, 이후로는 요청·응답이라는 왕복 구조에 얽매이지 않게 됩니다.
폴링과 비교하면 차이가 확연합니다. 폴링은 매 요청마다 헤더와 쿠키를 다시 실어 보내고 연결을 맺느라 오버헤드가 반복되지만, WebSocket은 한 번 맺은 연결 위로 필요한 데이터만 얇게 흘려보냅니다. 무엇보다 "서버가 먼저 말을 걸 수 있다"는 점이 핵심이죠. 클라이언트가 굳이 물어보지 않아도, 새 소식이 생긴 그 순간에 서버가 바로 알려 줄 수 있으니까요.

클라이언트 코드는 오히려 폴링보다 단순해집니다. 타이머도, 반복 요청도 필요 없죠.
// 연결을 한 번 열고, 서버가 보내주는 알림을 그때그때 수신
const ws = new WebSocket("wss://api.example.com/ws");
ws.onmessage = (e) => {
const noti = JSON.parse(e.data);
showToast(noti); // 도착 즉시 화면에 표시
};
서버 쪽도 개념은 간단합니다. 접속한 사용자별로 열린 연결을 들고 있다가, 알림 이벤트가 발생하면 해당 사용자의 소켓으로 메시지를 흘려보내면 됩니다.
// 사용자 ID로 열린 소켓을 찾아 알림을 전송
function pushToUser(userId, payload) {
const socket = sockets.get(userId);
if (socket?.readyState === 1) { // 1 = OPEN
socket.send(JSON.stringify(payload));
}
}
전환 직후 효과는 분명했습니다. 몇 초씩 지연되던 배지가 사실상 즉시 떴고, 종일 쏟아지던 빈 응답도 사라졌죠. 분당 수천 건이던 알림 요청이 접속 시점의 연결 수립 한 번으로 대체되니, 서버 로그가 눈에 띄게 조용해졌습니다. 그렇게 애를 먹이던 커넥션 풀도 한숨을 돌렸고요. 옆자리 동료가 멘션을 남기자마자 제 화면에 토스트가 뜨는 걸 처음 봤을 때는, 그 짧은 지연 하나가 이렇게 큰 차이였나 싶어 조금 허탈하기까지 했습니다.
옮기며 부딪힌 현실적 문제들
다만 "연결을 계속 열어 둔다"는 전제가 새로운 숙제를 잔뜩 안겨 줬습니다. 폴링은 매번 끊고 다시 붙으니 실패에 관대했지만, WebSocket은 그 연결 하나에 모든 걸 걸기 때문이죠. 제가 실제로 고생한 지점들을 정리하면 이렇습니다.
- 연결 끊김과 재연결: 와이파이가 잠깐 끊기거나 노트북이 절전에서 깨어나면 소켓이 조용히 죽어 있었습니다. 처음엔 끊기자마자 즉시 재연결을 시도하게 짰는데, 서버가 잠깐 흔들리자 수백 개 클라이언트가 동시에 재접속을 퍼부어 상황을 더 악화시켰죠. 결국 재시도 간격을 1초, 2초, 4초로 늘려 가는 exponential backoff를 넣어 진정시켰습니다.
- 하트비트(ping/pong): 중간의 프록시나 로드 밸런서가 유휴 연결을 말없이 끊어 버려서, 클라이언트는 여전히 "연결됨"으로 알고 있는데 실제론 죽어 있는 경우가 있었습니다. 30초마다 ping을 주고받아 살아 있는지 확인하고, pong이 안 오면 죽은 것으로 보고 재연결하도록 바꿨습니다.
- 중복 메시지 처리: 가장 오래 붙잡았던 문제였습니다. 재연결하는 그 짧은 틈에 놓친 알림을 서버가 다시 보내 주도록 했더니, 이번엔 같은 알림 토스트가 두세 번씩 뜨는 버그가 생겼죠. 결국 알림마다 고유
eventId를 부여하고, 클라이언트가 최근에 처리한 ID를 기억해 두었다가 중복이면 버리는 방식으로 정리했습니다. - 여러 대 서버로 확장: 서버를 여러 대로 늘리자 사용자 A는 1번 서버에, 그를 멘션한 B는 2번 서버에 붙는 상황이 생겼습니다. 이러면 A의 소켓을 2번 서버가 알 수 없죠. 그래서 Redis의 pub/sub을 중간에 두고, 어느 서버가 알림 이벤트를 받든 채널로 broadcast하면 A가 붙은 서버가 자기 소켓으로 전달하도록 했습니다.
- 인증 토큰 전달: 브라우저 WebSocket API는 커스텀 헤더를 못 붙여서, 평소처럼
Authorization헤더에 토큰을 실을 수 없었습니다. 저는 연결 URL의 쿼리 대신 handshake 직후 첫 메시지로 토큰을 보내 검증하는 방식을 택했습니다. 로그에 토큰이 남는 걸 피하고 싶었거든요.
돌아보면 폴링에서 WebSocket으로 넘어간 건 단순히 기술을 바꾼 게 아니라, "실패를 어떻게 다룰 것인가"를 새로 배우는 과정이었습니다. 지금이라면 처음부터 재연결과 중복 제거를 기본 골격으로 깔고 시작하겠죠. 실시간이란 결국 연결이 끊긴 순간을 얼마나 우아하게 메우느냐의 문제라는 걸, 그때 몸으로 익혔습니다.