노션 방명록 위젯에 Socket.io 도입하기

김 주현·2023년 7월 8일
0

이전 포스팅에서 Socket.io를 본격적으로 도입하기 전에 찍먹을 해보았었는데, 이번엔 실제로 API 서버에서 Socket.io로 전환해보고자 한다. 아무래도 혼자서 이것저것 하다보니 역시 이번에도 정석은 아닐 수도 있으나, 내가 생각하기엔 이정도라면 코드 읽기에 불편함이 없지 않나~ 싶은 정도라고 생각하며 포스팅을 시작해보겠다.

데이터 송수신 흐름

기존에는 Client에서 API Server로 요청을 하면, DB에 접근해서 값을 가져오거나 조작해서 결과를 반환하는 구조였다.

기존 API 송수신 타임라인

전형적인 API 송수신 구조라 설명을 덧붙일 것은 없지만, 이렇게 하게 된다면 어떤 Client가 DB를 조작했을 때, 해당 Client만 바뀐 값을 반영할 수 있으며 현재 접속해있는 다른 Client들에게 반영이 되지 않는다. 이를 실시간으로 바꾸고자 했으므로, WebSocket을 이용하기로 했다.

여기에서, 궁금증이 하나 생겼다.

API Server와 WebSocket Server를 따로 두어야 하는가?

사실은 처음에는 별 생각 없이 WebSocket Server에서 특정 커맨드를 받으면 DB에 접근하면 되겠지~ 였는데, 막상 구현하려고 보니 이 둘의 역할이 다르지 않나 싶은 생각이 문득 들었다.

API Server의 역할은 DB의 조회/조작이며, WebSocket Server는 실시간 통신을 위함이므로 이 둘의 역할은 다른 것 같은데, 그러므로 드는 생각이, 그렇다면 서버를 두 개를 두어야 하는가? 였다.

이에 대해서 많은 생각을 했었는데,, 내가 내린 결론은 이것이었다: 유지보수와 관심사 분리를 위해선 따로 두는 것이 맞지만, 현재 나의 서비스 규모에는 굳이 필요가 없다.

ChatGPT의 대답

나의 멘토 ChatGPT 센세에게도 물어봤을 때, 일반적인 아키텍처에서는 따로 두는 것이 권장된다고 말을 해주었는데, 여기에서 파생된 질문들은 다음과 같았다.

  1. WebSocket Server에서 DB를 직접 접근하는 케이스는 일반적인 케이스가 아닌가?
  2. 한 서버에서 WebSocket과 API 둘다 관리하는 건 서버에 부담이 큰 것인가?
  3. (만약 분리를 한다면) WebSocket Server에서 API Server로 직접 요청을 하는 것인가?
    • 서버에서 API Server로 다시 요청을 하는 구조가 익숙하지 않아서 생긴 궁금증
    • Client에서 WebSocket Server에도 보내고, API Server에도 요청하는 건 불필요한 것 같아서
  4. (만약 분리를 한다면) WebSocket Server와 API Server는 따로 배포를 해야하는 것인가?

이런 질문들에 의견을 얻고 싶어서 내가 참여하고 있는 프론트엔드 오픈채팅방에 질문을 올려봤는데,,

오픈채팅방에서의 질문

다만 돌아오는 답변은 없었다.... (지금 생각해보니 이건 확실히 프론트엔드 쪽보다는 백엔드 쪽 이야기인 것 같긴 하다)

그래애서, 다시 곰곰이 생각해보았다. 내가 여기에서 중요하게 여기는 포인트는 무엇인가를 따져봤을 때 다음과 같았다.

  1. 관심사 분리가 필요한 영역인가?
  2. 서버를 두 개 만들 필요가 있는가?
  3. 그럴 필요가 있을 만큼 이 서비스의 트래픽이 큰가?

관심사 분리는 나의 욕심으론 분명 하고 싶은 영역이지만, 서버를 두 개 만들고 배포를 따로따로 진행하기엔 이 서비스가 그 만큼의 트래픽을 발생하지 않을 거라 생각했다. 그러므로 나는 결정했다. WebSocket Server에서 DB에 접근하겠다! (절대 귀찮아서 그런 거 아님. 암튼 아님.)

WebSocket + DB 데이터 흐름

WebSocket에서 DB를 접근하겠다는 맨 처음의 생각으로 돌아오고 나서 가장 먼저 한 일은 데이터의 흐름을 그려보는 것이었다. 나는 보통 큰 흐름을 잡고 가는 걸 좋아라해서, 이런 식으로 타임라인을 그려보는 것을 선호한다.

Client 입장 시

Client 입장 시 데이터 흐름

이렇게 이벤트 흐름을 그려보면 한 눈에 알아볼 수 있어서 전체적인 흐름을 쉽게 파악할 수 있다.

Client Event 발생 시

Event 발생 시 데이터 흐름

초기에 주고 받는 데이터의 흐름과 별 다른 건 없지만, 다른 점은 이벤트를 요청한 Client 뿐만 아니라 다른 클라이언트들에게도 이벤트 결과를 Emit해주고, 각 클라이언트들은 해당 Event에 대한 Callback을 받고 있다는 점이다.

이 두 케이스를 일반화했을 때 생각해볼 수 있는 건 다음과 같다고 생각했다.

  1. 다른 Client에게도 알려야 하는 이벤트
  2. 특정 Client에게만 필요한 이벤트

예를 들어, 유저가 입/퇴장하는 이벤트는 1번의 경우에 해당한다. 특정 Client가 이전의 히스토리를 요청하는 경우는 2번의 경우에 해당한다. 이를 다르게 표현한다면, 언제 이벤트가 발생될지 몰라서 계속 듣고 있어야 하는 1번의 경우와, 내가 직접 일으켜서 결과를 받는 2번의 경우가 있다.

즉, 늘 듣고 있어야(Listen) 하는 이벤트한 번만(Once) 들으면 되는 경우가 나뉘어진다. 또한, 한 번만 들으면 되는 경우는 내가 직접 일으키는 이벤트라는 사실도 포함되어 있다. 이 포인트를 생각하고 구현에 들어가보자.

사실 한 번만 들어도 되는 경우도 on 메서드를 통해 계속 듣고 있어도 되지만, 간헐적으로 발생하는 이벤트를 계속 들을 필요가 있나? 싶어서 once 메서드를 이용했다.

API Server에서 WebSocket Server로

먼저, 기존 API Server에서는 이벤트 요청을 어떤 식으로 처리했는지 살펴보자.

API Server에서의 기본 동작

히스토리를 불러오는 동작으로 예시를 들어보자. currentPage가 바뀜에 따라서 데이터에 요청을 하고, 데이터를 받으면 콜백에 데이터를 넘겨주어 저장된 히스토리를 바꿔주는 형식이다. 코드는 일부만 발췌하였다.

// CommentHistry.jsx

function App() {
  const storeDispatch = useDispatch();

  const { storedCommentHistory } = useSelector((state) => state.commentHistoryInfo);
  const { currentPage } = useSelector((state) => state.pageInfo);
  
  const { fetchingState, dataDispatch } = useDataFetcher();

  const dispatchCallbacks = {
    onSuccess: (dispatchType, response) =>
      storeDispatch(updateCommentHistory({ dispatchType, response })),
    onError: (dispatchType, error) => console.log(error),
  };

  useEffect(() => {
    dataDispatch(DISPATCH_TYPE.GET_HISTORY_BY_PAGE, dispatchCallbacks, currentPage);
  }, [currentPage]);

  return (
      <CommentHistory commentHistory={storedCommentHistory} />
      {fetchingState.isLoading && <LoadingComponent />}
  );
}

기본적으로 CommentHistory 컴포넌트에 redux에 저장된 storedCommentHistory를 넘겨준다. 만약 currentPage가 바뀐다면 dataDispatch를 통해 데이터를 가져온다. 가져온 데이터는 dispatchCallbacks에 의해서 성공 시에 onSuccess로, 실패 시에 onError로 분기된다.

onSuccess로 넘어오면 storeDispatch를 통해 미리 정의된 updateCommentHistory Reducer를 통해 storedCommentHistory를 조작한다.

그 과정에서 fetchingState를 반환하여 로딩 상태를 제어한다.

여기에서 좀 더 나아가면 react-query를 이용해서 client state를 제어하겠지만,, 이걸 만들 때는 관련 지식이 없어서 손대지 못했다(ㅋㅋ)

포인트는, 데이터를 요청할 때는 콜백을 주어 결과값을 핸들링했다는 것.

WebSocket Server에서의 동작

먼저 Socket Instance를 생성해주어야 했는데, 이 생성해주는 위치가 참 애매했다. 이 녀석을 어쨌든 전역으로 퍼트려줘서 필요한 컴포넌트들이 접근할 수 있게 해주어야 했다. 그렇다고 Context를 쓰자니 Depth가 깊어져서 싫고, Redux를 쓰자니 Instance가 상태는 아니지 않나 싶은 생각이 들었다. 그러므로 선택한 건,, 그냥 모듈 스코프에 선언해서 쓰자! 였다.

Socket Instance 생성

socket.js 파일을 만들고 아래와 같이 Socket Instance를 만들고 export 해주었다.

// socket.js

import { Socket, io } from 'socket.io-client';

const EVENT_TYPE = {
  CONNECT: 'connect',
  DISCONNET: 'disconnet',
  USER_ENTER: 'user_enter',
  USER_LEAVE: 'user_leave',
  MESSAGE: 'message',
  MESSAGE_UPDATE: 'message_update',
  MESSAGE_DELETE: 'message_delete',
  HISTORY_LOAD: 'history_load',
  PASSWORD_COMPARE: 'password_compare',
};

/** @type {Socket} */
const socket = io(import.meta.env.VITE_WEBSOCKET_URL);

export { EVENT_TYPE, socket };

겸사겸사 내가 사용할 이벤트들을 정의해두었다.

Custom Hook, usePacket 생성

WebSocket도 기본적으로 이벤트가 발생했을 때 콜백으로 받는 형태는 똑같다. 다만, 실시간 통신임이 추가되었고, 위에서 언급한 계속 듣고 있는 이벤트와 한 번만 필요한 이벤트가 구분되어야 했다. 이를 위해서 Custom Hook을 생성해서 이벤트를 관리해주었다.

// usePacket.js

import { EVENT_TYPE, socket } from '@Utils/socket';

/**
 * @param {EVENT_TYPE} eventName
 * @returns {boolean}
 */
const hasEvent = (eventName) => socket.hasListeners(eventName);

export const usePacket = () => {
  /**
   * @param {EVENT_TYPE} packetType
   * @param {Function} callback
   */
  const alwaysOn = (packetType, callback) => {
    if (!hasEvent(packetType)) {
      socket.on(packetType, callback);
    }
  };

  /**
   * @param {EVENT_TYPE} packetType
   * @param {*} data
   * @param {Function} callback
   */
  const onceOn = (packetType, data, callback) => {
    if (!hasEvent(packetType)) {
      socket.once(packetType, callback);
    }

    socket.emit(packetType, data);
  };

  /**
   * @param {EVENT_TYPE} packetType
   * @param {*} data
   */
  const sendPacket = (packetType, data) => {
    socket.emit(packetType, data);
  };

  return { alwaysOn, onceOn, sendPacket };
};

Typescript를 얼른 배우든가 해야지 원.....

alwaysOn은 항상 듣고 있어야 하는 이벤트. 없을 경우에만 등록해준다. 참고로 이벤트를 두 번 등록하면 이벤트가 두 번 발생한다. 어떻게 알았냐구요? 저도 알고 싶지 않았습니다...

onceOn은 한 번만 들어야 하는 이벤트. 이 이벤트는 앞서 언급한 것과 같이 본인이 필요해서 보내는 이벤트이기 때문에, 서버에 보내는 동작까지 포함한다. (emit)

sendPacket은 내가 서버에 보내는 메서드. 보통 alwaysOn으로 듣고 있는 이벤트를 보내고 싶을 때 쓸 용도이다.

usePacket을 이용해서 이벤트 받기

먼저 항상 들어야 하는 이벤트과 한 번만 듣는 이벤트를 구분해주어야 한다.

  • 항상 들어야 하는 이벤트: CONNECT, DISCONNET, USER_ENTER, USER_LEAVE, MESSAGE, MESSAGE_UPDATE, MESSAGE_DELETE
  • 한 번만 듣는 이벤트: HISTORY_LOAD, PASSWORD_COMPARE

항상 들어야하는 이벤트

항상 들어야 하는 이벤트는 렌더 초기에 한 번만 등록을 해주어야 하므로, 다음과 같이 등록한다.

// Dashboard/index.jsx

import { usePacket } from '@Hooks/usePacket';

const App = () => {
  const { alwaysOn } = usePacket(); 

  const onConnect = () => {};
  const onMessage = (message) => {};
  const onMessageDelete = (message) => {};
  const onMessageUpdate = (message) => {};
  const onUserEnter = (userId) => {};
  const onUserLeave = (userId) => {};

  useEffect(() => {
    alwaysOn(EVENT_TYPE.CONNECT, onConnect);
    alwaysOn(EVENT_TYPE.MESSAGE, onMessage);
    alwaysOn(EVENT_TYPE.MESSAGE_DELETE, onMessageDelete);
    alwaysOn(EVENT_TYPE.MESSAGE_UPDATE, onMessageUpdate);
    alwaysOn(EVENT_TYPE.USER_ENTER, onUserEnter);
    alwaysOn(EVENT_TYPE.USER_LEAVE, onUserLeave);
  }, []);
}

여기에 각각 이벤트에 맞는 로직을 처리해주면 된다. 만약, MESSAGE에 대한 핸들링을 한다 싶으면 다음과 같이 하면 된다.

// Dashboard/index.jsx

import { useDispatch, useSelector } from 'react-redux';
import { UPDATE_TYPE, updateConversations } from '@Store/ConversationSlice';

const App = () => {
  const storeDispatch = useDispatch();
  
  const { loadedConversations } = useSelector((state) => state.ConversationSlice);
  
  /**
   * @param {UPDATE_TYPE} type 
   * @param {*} data 
   */
  const updateConversation = (type, data) =>
    storeDispatch(updateConversations({ updateType: type, messageData: data }));

  const onMessage = (data) => {
    console.log(`${data.userName}(${data.uid})가 보냈다잉: `, data);

    updateConversation(UPDATE_TYPE.MESSAGE_ADDED, data);
  };
  
  return (
    <ConversationHistory conversations={loadedConversations} />
  )
}

한 번만 들어야 하는 이벤트

한 번만 들어야 하는 이벤트는 특정 상황이 왔을 때 이벤트를 요청해주면 되므로 다음과 같이 로직을 짜면 된다.

// Dashboard/index.jsx

import { usePacket } from '@Hooks/usePacket';

const App = () => {
  const { onceOn } = usePacket(); 
  
  const onHistoryLoad = (result) => {};
  
  useEffect(() => {
    onceOn(
      EVENT_TYPE.HISTORY_LOAD,
      { currentPage },
      onHistoryLoad
    );

  }, [currentPage]);
}

역시 콜백함수를 통해 결과를 핸들링하면 된다.

usePacket을 이용해서 이벤트 보내기

이번에는 반대로 이벤트를 보내보자. 보내는 건 더 쉽다.

// Dashboard/index.jsx

const App = () => {
  const { sendPacket } = usePacket();

  const handleSubmit = () => {
    const messagePacket = {
      uid,
      userName,
      userPassword,
      userProfile,
      commentType: messageType,
      commentDate: new Date(),
      commentContent: message,
      commentReply: replyData,
    };

    sendPacket(EVENT_TYPE.MESSAGE, messagePacket);
  }
}

sendPacket에는 결과를 받는 콜백함수가 없는데, 그 이유는 해당 메시지는 이미 항상 듣고 있는 이벤트이기 때문이다. 따라서, 따로 콜백함수를 받지 않아도 이미 콜백을 등록해놨기 때문에 핸들링을 할 수 있는 것이다.

결과

초기 로딩

초기 로딩

다른 Client 입장

다른 Client 입장

메시지 전송

메시지 전송

공감 추가

공감 추가

메시지 삭제

메시지 삭제

후기

역시 뭔가 실시간으로 인터렉션이 되는 머시깽이를 만드니까 재미가 쏠쏠하다. 이제 구조를 변경해야 하는 부담감은 언제나 큰 것을 빼면 ,,, 거기다가 구조를 잘 짜려고 막 고민하다보니까 개발 기간이 계속계속 길어지는 것도 부담스럽구! 그렇지만 이렇게 직접 한번 설계해보고 해야 나중에 이런 시간이 줄어드니까. 잘하고 있다 나 자신!

profile
FE개발자 가보자고🥳

0개의 댓글