4 min read

Socket.io 연결보다 복구

“분명 로컬에서는 잘 됐는데, 왜 밖에서는 실시간 데이터가 변경이 안 되나요?”

실시간 서비스를 배포하고 나면 가장 많이 듣는 CS 중 하나입니다. 실제로 저희 서비스에서도 WebSocket 을 사용하여 실시간 데이터를 서빙하기 때문에, 실시간 데이터가 변경되지 않는 것은매우 치명적인 문제입니다.

와이파이 환경에서는 new WebSocket() 한 줄이면 모든 게 완벽해 보이곤 합니다. 하지만 사용자가 지하철 터널을 지나거나, 엘리베이터를 타서 LTE와 와이파이를 오가는 순간, 실시간 연결은 소리 없이 죽어버립니다.

단순히 연결이 끊기는 게 문제는 물론 아닙니다. 진짜 문제는 ‘연결이 끊겼다는 사실조차 모르는 상태’가 존재한다는 것이었죠.

TCP는 살아있지만 데이터는 흐르지 않는 ‘좀비 커넥션’

브라우저의 WebSocket API는 생각보다 불친절합니다. 네트워크가 물리적으로 차단되어도 onclose 이벤트가 즉시 발생하지 않는 경우가 있습니다. OS 레벨의 TCP Keep-alive가 동작하기까지는 너무 긴 시간이 걸리고, 그동안 사용자는 메시지가 오지 않는 빈 화면만 바라볼 수 있습니다.

우리는 이 문제를 해결하기 위해 애플리케이션 레벨의 하트비트(Heartbeat)를 직접 구현해야 했습니다. 단순히 서버가 살아있는지 확인하는 수준을 넘어, 일정 시간 동안 응답이 없으면 클라이언트가 먼저 연결을 ‘살해’하고 재연결을 시도하게 만드는 것이 핵심입니다.

// 단순히 살아있음을 확인하는 게 아니라, '죽었을 때 확실히 죽이는' 로직
const HEARTBEAT_INTERVAL = 30000;
const PONG_TIMEOUT = 5000;

function setupHeartbeat(ws) {
  let pingTimeout;

  const sendPing = () => {
    // 서버에 PING 전송
    ws.send(JSON.stringify({ type: 'PING' }));
    
    // 5초 안에 PONG이 안 오면 이 연결은 가망이 없다고 판단
    pingTimeout = setTimeout(() => {
      console.warn('⚠️ 응답 지연. 연결을 강제로 종료하고 재연결 로직을 태웁니다.');
      ws.close(); 
    }, PONG_TIMEOUT);
  };

  ws.onmessage = (event) => {
    const data = JSON.parse(event.data);
    if (data.type === 'PONG') {
      // 정상 응답이 오면 타임아웃 해제
      clearTimeout(pingTimeout);
    }
  };

  const intervalId = setInterval(sendPing, HEARTBEAT_INTERVAL);
  
  ws.onclose = () => {
    clearInterval(intervalId);
    clearTimeout(pingTimeout);
  };
}

재연결 폭풍이 서버를 덮칠 때

서버 배포를 위해 인스턴스를 재시작하는 순간, 수만 명의 클라이언트가 동시에 onclose를 감지합니다. 이때 모든 클라이언트가 setTimeout(() => connect(), 1000) 처럼 정직하게 1초 뒤에 재연결을 시도하면 어떻게 될까요? 서버는 살아나자마자 수만 개의 커넥션 요청을 한꺼번에 받게 되고, 다시 부하를 견디지 못해 뻗어버리는 ‘Thundering Herd’ 현상이 발생합니다.

이때 필요한 것이 **지수 백오프(Exponential Backoff)**와 **지터(Jitter)**입니다. 재연결 대기 시간을 2배씩 늘리되, 여기에 무작위성을 섞어 요청 시점을 분산시키는 것이죠.

function connectWithRetry(attempt = 0) {
  // 1초, 2초, 4초... 최대 30초까지 대기 시간 증가
  const baseDelay = Math.min(1000 * Math.pow(2, attempt), 30000);
  
  // 요청이 겹치지 않도록 0~1초 사이의 무작위 값(Jitter) 추가
  const jitter = Math.random() * 1000; 

  setTimeout(() => {
    const ws = new WebSocket(WS_URL);
    
    ws.onopen = () => {
      console.log('✅ 연결 성공!');
      attempt = 0; // 성공 시 시도 횟수 초기화
    };

    ws.onclose = () => {
      connectWithRetry(attempt + 1);
    };
  }, baseDelay + jitter);
}

끊겼던 찰나의 메시지를 복구하는 법

재연결에 성공했다고 해서 끝이 아닙니다. 연결이 끊겨있던 3초 동안 서버에서 발행된 메시지는 어디로 갔을까요? WebSocket은 기본적으로 메시지 전달을 보장하지 않습니다.

우리는 이를 해결하기 위해 모든 메시지에 **시퀀스 번호(Sequence Number)**를 부여했습니다. 클라이언트가 재연결될 때 “나 마지막으로 105번 메시지까지 받았어”라고 서버에 알려주면, 서버는 Redis 등에 임시 저장해둔 메시지 큐에서 106번부터의 데이터를 다시 밀어넣어 줍니다.

이 구조가 갖춰지고 나서야 비로소 사용자는 터널을 지나온 뒤에도 대화의 흐름을 놓치지 않게 되었습니다.

결국 실시간은 ‘연결’보다 ‘복구’의 예술입니다

WebSocket을 다루다 보면 깨닫게 되는 진리가 있습니다. 완벽하게 유지되는 연결은 환상에 가깝다는 것입니다. 진정한 고수의 실력은 연결이 얼마나 오래 유지되느냐가 아니라, 연결이 끊겼을 때 얼마나 우아하고 빠르게, 그리고 데이터 손실 없이 복구하느냐에서 결정됩니다.

여러분의 서비스는 지금 이 순간에도 수없이 끊기고 있을지 모릅니다. 하지만 사용자가 그 사실을 전혀 눈치채지 못하고 있다면, 여러분은 이미 훌륭한 실시간 시스템을 구축한 셈입니다.