본문 바로가기
Projects,Activity/DizzyCode(React)

[WebSocket] 비정상 종료 핸들링 전략

by 그냥하는거지뭐~ 2024. 7. 31.

의도적으로 연결을 끊는 상황을 제외하고 비정상적으로 웹소켓 연결이 끊어졌을 때 재연결하는 로직이 없다면 사용자 경험에 치명적일 것이다. 대략적인 class 구조는 다음과 같으며, 하나씩 채워보자. 
 

c.f) secondary token 관련해서는 다음 글을 참고하자 => https://hwanheejung.tistory.com/43

 

import SockJS from 'sockjs-client';
import { Client, StompSubscription } from '@stomp/stompjs';

class WebSocketClient {
  constructor(url) {
    this.url = url;
    this.client = null;
    // 추가
  }

  async getSecondaryToken() {
    // secondary token 발급 로직
  }

  async connect() {
    const ST = await this.getSecondaryToken();
    if (ST) {
      const socket = new SockJS(`${this.url}?token=${ST}`);
      this.client = new Client({
        webSocketFactory: () => socket,
        connectHeaders: {
          Authorization: `Bearer ${localStorage.getItem('accessToken')}`,
        },
        onConnect: () => {
          this.handleConnect();
        },
        onDisconnect: () => {
          this.handleDisconnect();
        },
        debug: (str) => {
          console.log(`STOMP Debug: ${str}`);
        },
      });
      
      socket.onclose = (e) => {
        this.onClose(e);
      };
      
      this.client.activate();
      this.startPing();
    }
  }

  handleConnect() {
    // connect 핸들링
  }

  handleDisconnect() {
    // disconnect 핸들링
    this.reconnect(); // 재연결 시도
  }
  
  onClose(e) {
    // onclose 이벤트
  }

  startPing() {
    // ping 시작
  }

  stopPing() {
    // ping 종료
  }

  reconnect() {
    // 재연결 로직 
  }

  calculateReconnectDelay() {
    
  }

  deactivate() {
    this.stopPing();
    this.client?.deactivate();
  }
}

export default WebSocketClient;

 
 
전략 1: Ping/Pong 

일정 시간 동안 통신하지 않는 경우 연결이 끊어질 수 있다(ex. AWS EC2). 맥북 프로 2014 13인치에서는 통신이 없는 경우 1-2분 만에 연결이 끊어진다고 한다(참고: https://mytory.net/archives/14526). 따라서 일정 간격으로 ping을 보내 연결이 살아있다는 신호를 보낸다.
다음 코드를 보면 constructor에 pingInterval을 30초로 설정해주어, 30초마다 주기적으로 ping 메시지를 보낸다. 

class WebSocketClient {
  constructor(url) {
    ...
    this.pingInterval = 30000;
    this.pingIntervalId = null;
  }

  startPing() {
    this.pingIntervalId = setInterval(() => {
      if (this.client && this.client.connected) {
        this.client.send('/app/ping', {}, JSON.stringify({ type: 'ping' }));
      }
    }, this.pingInterval);
  }

  stopPing() {
    if (this.pingIntervalId) {
      clearInterval(this.pingIntervalId);
      this.pingIntervalId = null;
    }
  }
}

 
 

전략 2: Exponential Backoff w/ Jitter

Jitter는 네트워크 통신에서 데이터 패킷의 전송 지연 시간을 일정하지 않고 변동되는 현상으로, 이를 의도적으로 활용해 무작위성을 높일 수 있다. 여러 클라이언트가 동시에 재연결을 시도할 경우에 서버나 네트워크에 부담이 될 수 있으므로, 각 클라이언트마다 랜덤하게 지연 시간을 설정하여 과부하를 최소화시킬 수 있다. 이를 통해 특정 패턴에 의한 문제 발생을 방지할 수 있어 안정성이 향상된다. 
 
또한, 재연결 시도가 반복해서 실패하는 경우, 클라이언트가 짧은 간격으로 계속해서 재연결을 시도하게 되면 서버에 부담이 갈 수 있으므로, 재연결 시도의 간격을 exponentially 늘려감으로써 서버가 회복되는 시간을 확보할 수 있으며, 여러 클라이언트가 동시에 재연결을 시도한다고 했을 때도 요청이 재연결 시간에 따라 분산되므로 충돌을 최소화할 수 있다. 

class WebSocketClient {
  constructor(url) {
  	...
    this.baseReconnectDelay = 1000;
	...
  }
  ...
  reconnect() {
    let reconnectDelay = this.calculateReconnectDelay();
    setTimeout(() => {
      this.connect();
    }, reconnectDelay);
  }
  
  calculateReconnectDelay() {
    let delay = this.baseReconnectDelay * Math.pow(2, this.reconnectAttempts);
    let jitter = Math.random() * 5000;
    return delay + jitter;
  }
  ...
}

 

전략 3: 최대 재시도 횟수 설정

재연결 시도가 계속해서 실패할 경우 영원히 시도를 할 수는 없으니 최대 재시도 횟수를 설정해서 무한 재시도 요청을 방지하자. 최대 시도 횟수를 설정해두고, 이를 초과할 경우에 다른 조치를 취하도록 예외처리를 해주었다. 

class WebSocketClient {
  constructor(url) {
  	...
    this.reconnectAttempts = 0;
    this.maxReconnectAttempts = 10; // 최대 재연결 시도 횟수
	...
  }
  ...
  reconnect() {
    if (this.reconnectAttempts < this.maxReconnectAttempts) {
      let reconnectDelay = this.calculateReconnectDelay();
      setTimeout(() => {
        this.reconnectAttempts++;
        this.connect();
      }, reconnectDelay);
    } else {
      console.error('Max reconnect attempts reached');
      // 사용자에게 알림을 주거나 다른 조치 취하기
    }
  }
  ...
}

 
 

전략 4: 커스텀 code 활용하기 

웹소켓 연결이 종료될 때에 발생하는 onclose 이벤트에는 code, reason이 포함되어 있어서 event.code를 활용하여 의도된 종료와 비정상 종료를 구분할 수 있다. 
 
*Ref.1 에서 확인할 수 있듯, 1000은 정상종료, 1015까지의 나머지는 비정상종료이며, 4000~4999 사이에서 커스텀 코드를 지정해 줄 수 있다. 예를 들어, 의도한 종료이지만 재연결이 필요할 경우에 1000번을 사용하는 대신 커스텀 코드를 사용해서 의도를 명확히 하는 것이 더 좋을 것이다. 즉, custom code를 4000으로 지정했다면, 4000이 아닐 경우에 재연결 로직을 수행할 수 있다. 
 
event.wasClean 속성도 있는데, 이는 연결이 깨끗하게 종료되었는지를 판단하는 속성이다. 깨끗하게 종료(true)된 경우는 의도적으로 브라우저에서 연결을 끊은 경우(새로고침)이거나, close() 메서드가 실행되었을 경우이고, false는 인터넷 연결이 불안정하거나 서버 문제 등의 다양한 이유로 웹소켓 서버가 갑자기 죽었을 때를 들 수 있다. 다음 코드를 보면 e.wasClean이 false이고, e.code가 1000이 아닌 경우를 비정상 종료로 간주했다. 

1000: 연결이 정상적으로 종료됨
1000 이외: 다양한 이유로 연결이 비정상적으로 종료됨
4000: 비즈니스 로직에서 의도적으로 설정한 종료 코드. 의도된 종료

 

onClose(e) {
    if (e.code !== 4000) { // Custom close code
      if (!e.wasClean && e.code !== 1000) {
        console.warn(`WebSocket closed unexpectedly with code: ${e.code}, reason: ${e.reason}`);
        // Additional handling: Notify user, send logs to server, etc.
      }
      this.reconnect();
    }
}

 


Reference

1. CloseEvent: code property https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent/code
2. RFC: recovering abnormal closure https://www.rfc-editor.org/rfc/rfc6455.html#section-7.2.3
3. https://mytory.net/archives/14526