본문 바로가기
카테고리 없음

폴링 주기를 아무리 조절해도 안 되던 실시간 알림

by 테크구루스 2026. 7. 6.

 

폴링 주기를 아무리 조절해도 안 되던 실시간 알림을, 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으로 넘어간 건 단순히 기술을 바꾼 게 아니라, "실패를 어떻게 다룰 것인가"를 새로 배우는 과정이었습니다. 지금이라면 처음부터 재연결과 중복 제거를 기본 골격으로 깔고 시작하겠죠. 실시간이란 결국 연결이 끊긴 순간을 얼마나 우아하게 메우느냐의 문제라는 걸, 그때 몸으로 익혔습니다.