밝을희 클태

싱글톤 패턴을 활용한 중앙 집중식 WebSocket 관리 방식 본문

PongWorld 프로젝트

싱글톤 패턴을 활용한 중앙 집중식 WebSocket 관리 방식

huipark 2024. 3. 11. 02:11

프로젝트를 진행하면서 하나의 WebSocket으로 여러 컴포넌트에서 각기 다른 onmessage 이벤트를 등록해야 하는 일이 생겼다. 그래서 어떻게 로직을 작성할지 고민하다가 싱글톤 패턴의 중앙 집중식 WebSocket을 구현하기로 했다.

 

일단 위처럼 구현을 하게 되면 장점은

  1. 트래픽과 서버 부하를 줄일 수 있다
  2. 구현이 단순해진다

단점은

  1. 하나의 웹소켓에 의존을 하면 해당 WebSocket에 문제가 생기면 프로젝트 전체에 문제가 생길 수 있다
  2. 사용자가 많거나 데이터가 많아지면 병목 현상이 생길 수 있다

등이 있다. 그런데 일단 우리는 WebSocket으로 큰 데이터를 다룰 일이 없을 거 같아서 구현이 단순한 싱글톤 패턴의 WebSocket 방식을 택했다.

 

일단 BaseWebSocket Class를 만들어준다.

 공통적으로 변하지 않는 send 이벤트와 connect 함수를 정의해준다.

 getWS() 함수 같은 경우는 페이지에 새로고침이 일어나거나 WebSocket의 연결의 상태를 확인하기 위해 this.ws를 return 해준다.

import {getToken} from '../tokenManager.js';

export default class BaseWebSocket {
  constructor() {
    this.ws = null;
  }

  connect(url) {
    this.ws = new WebSocket(`${url}?token=${getToken()}`);

    this.ws.onerror = async error => {
      console.error('WebSocket 에러 발생:');
    };
  }

  send(message) {
    if (this.ws && this.ws.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify(message));
    } else {
      console.error('WebSocket이 연결되지 않았습니다.');
    }
  }
  
  close() {
    if (this.ws) {
      this.ws.close();
    }
  }

  getWS() {
    return this.ws;
  }
}

 그리고 싱글톤 WebSocket Class를 만들어주는데 Class의 인스턴스화 없이 클래스 레벨에서 접근을 해야 하므로 instance와 getInstance() 함수를 static으로 만들어 준다. 처음에 setEvent() 함수 없이 바로 클래스에서 message에 접근을 했는데 컴포넌트마다 각자 다른 event를 등록을 해줘야 해서 각자 WebSocket에 Event를 바인딩할 수 있게 해 주었다.

import BaseWebSocket from './BaseWebSocket.js';
import {getToken, refreshAccessToken} from '../tokenManager.js';

class ConnectionSocket extends BaseWebSocket {
  static instance = null;

  constructor() {
    super();
  }

  async connect(url) {
    console.log('Connection WebSocket 연결 시도중');
    super.connect(url);

    return new Promise(async (resolve, reject) => {
      this.ws.onopen = () => {
        console.log('Connection WebSocket 연결 성공!');
        resolve();
      };

      this.ws.onclose = () => {
        console.log('Connection WebSocket 닫힘');
      };
    });
  }

  setEvent(handler) {
    if (handler) {
      this.ws.onmessage = e => {
        handler(JSON.parse(e.data));
      };
    }
  }

  static getInstance() {
    if (!ConnectionSocket.instance) {
      ConnectionSocket.instance = new ConnectionSocket();
    }
    return ConnectionSocket.instance;
  }
}

const cws = ConnectionSocket.getInstance();
export default cws;

 마지막으로 컴포넌트를 이동할 때나 새로고침이 있을 때 checkConnectionSocket() 함수를 이용해서 현재 WebSocket이 정상적인지 판단을 해서 다시 연결을 할 수 있는 로직이다. 그리고 onmessageEventHandler() 함수를 인자로 전달해 줘서 컴포넌트 상황에 맞는 onmessage Event를 등록할 수 있다.

import cws from './WebSocket/ConnectionSocket.js';
import {refreshAccessToken, getToken} from './tokenManager.js';

export const checkConnectionSocket = async handler => {
  return new Promise(async (resolve, reject) => {
    if (!cws.getWS()) {
      try {
        await connectionSocketConnect(handler);
        console.log('connectionSocket 재연결!');
        resolve();
      } catch (error) {
        console.log('checkConnectionSocket Error : ', error);
        reject(error);
      }
    } else resolve();
    cws.setEvent(handler);
  });
};

export const connectionSocketConnect = async handler => {
  if (!getToken().length) await refreshAccessToken();
  await cws.connect('ws://127.0.0.1:8000/ws/connection/');
  cws.setEvent(handler);
};

 실제로 사용은 이런 식으로 할 수 있다. 처음에 this를 bind 안 하고 그냥 로직을 작성했는데 this가 전혀 엉뚱한 곳을 가리켰다. 내가 기대한 동작은 아래 Class를 가리키기를 기대했는데 window 전역객체를 가리키고 있었다. 그래서 알아본 결과  메서드를 callback함수 인자로 줄 경우 메서드가 일반 함수로 호출이 돼서 this는 전역객체를 가리키게 되는 것 이었다. 그래서 따로 바인딩을 해줘서 해당 클래스를 가르키게 했다.

import AbstractView from '../../AbstractView.js';
import {getToken, refreshAccessToken} from '../../tokenManager.js';
import cws from '../../WebSocket/ConnectionSocket.js';
import {
  checkConnectionSocket,
  connectionSocketConnect,
} from '../../webSocketManager.js';

function findUser(id, rooms) {
	...
}

export default class extends AbstractView {
  constructor(params) {
 	...
  }

  async getHtml() {
 	...
  }

  updateUserList(chattingRooms) {
    ...
  }

  async renderPrevChat(chatRoomID) {
     ...
  }

  renderChat(data, moreChatLog) {
   	...
  }

  loadPreviousMessagesOnScroll() {
   	...
  }

  sendWebSocket() {
   	...
  }

  async bindUserListEvents(chattingRooms) {
 	...
  }

  async getChattingRoom() {
   	...
  }

  async afterRender() {
  	...
    
    await checkConnectionSocket(this.socketEventHendler.bind(this));
	
    ...
  }

  async socketEventHendler(message) {
    if (message.chatroom_id) {
      this.$chatRoom.innerHTML = '';
      const chatRoomdID = await message.chatroom_id;
      this.renderPrevChat(chatRoomdID);
      try {
      } catch (error) {
        console.error(error);
      }
    } else if (message.type === 'private_chat') {
      this.renderChat(message);
    }
    console.log('onMessage : ', message);
  }
}