이전 포스팅에서 Socket.io
를 본격적으로 도입하기 전에 찍먹을 해보았었는데, 이번엔 실제로 API 서버에서 Socket.io
로 전환해보고자 한다. 아무래도 혼자서 이것저것 하다보니 역시 이번에도 정석은 아닐 수도 있으나, 내가 생각하기엔 이정도라면 코드 읽기에 불편함이 없지 않나~ 싶은 정도라고 생각하며 포스팅을 시작해보겠다.
기존에는 Client에서 API Server로 요청을 하면, DB에 접근해서 값을 가져오거나 조작해서 결과를 반환하는 구조였다.
전형적인 API 송수신 구조라 설명을 덧붙일 것은 없지만, 이렇게 하게 된다면 어떤 Client가 DB를 조작했을 때, 해당 Client만 바뀐 값을 반영할 수 있으며 현재 접속해있는 다른 Client들에게 반영이 되지 않는다. 이를 실시간으로 바꾸고자 했으므로, WebSocket을 이용하기로 했다.
여기에서, 궁금증이 하나 생겼다.
사실은 처음에는 별 생각 없이 WebSocket Server에서 특정 커맨드를 받으면 DB에 접근하면 되겠지~ 였는데, 막상 구현하려고 보니 이 둘의 역할이 다르지 않나 싶은 생각이 문득 들었다.
API Server의 역할은 DB의 조회/조작이며, WebSocket Server는 실시간 통신을 위함이므로 이 둘의 역할은 다른 것 같은데, 그러므로 드는 생각이, 그렇다면 서버를 두 개를 두어야 하는가? 였다.
이에 대해서 많은 생각을 했었는데,, 내가 내린 결론은 이것이었다: 유지보수와 관심사 분리를 위해선 따로 두는 것이 맞지만, 현재 나의 서비스 규모에는 굳이 필요가 없다.
나의 멘토 ChatGPT 센세에게도 물어봤을 때, 일반적인 아키텍처에서는 따로 두는 것이 권장된다고 말을 해주었는데, 여기에서 파생된 질문들은 다음과 같았다.
이런 질문들에 의견을 얻고 싶어서 내가 참여하고 있는 프론트엔드 오픈채팅방에 질문을 올려봤는데,,
다만 돌아오는 답변은 없었다.... (지금 생각해보니 이건 확실히 프론트엔드 쪽보다는 백엔드 쪽 이야기인 것 같긴 하다)
그래애서, 다시 곰곰이 생각해보았다. 내가 여기에서 중요하게 여기는 포인트는 무엇인가를 따져봤을 때 다음과 같았다.
관심사 분리는 나의 욕심으론 분명 하고 싶은 영역이지만, 서버를 두 개 만들고 배포를 따로따로 진행하기엔 이 서비스가 그 만큼의 트래픽을 발생하지 않을 거라 생각했다. 그러므로 나는 결정했다. WebSocket Server에서 DB에 접근하겠다! (절대 귀찮아서 그런 거 아님. 암튼 아님.)
WebSocket에서 DB를 접근하겠다는 맨 처음의 생각으로 돌아오고 나서 가장 먼저 한 일은 데이터의 흐름을 그려보는 것이었다. 나는 보통 큰 흐름을 잡고 가는 걸 좋아라해서, 이런 식으로 타임라인을 그려보는 것을 선호한다.
이렇게 이벤트 흐름을 그려보면 한 눈에 알아볼 수 있어서 전체적인 흐름을 쉽게 파악할 수 있다.
초기에 주고 받는 데이터의 흐름과 별 다른 건 없지만, 다른 점은 이벤트를 요청한 Client 뿐만 아니라 다른 클라이언트들에게도 이벤트 결과를 Emit해주고, 각 클라이언트들은 해당 Event에 대한 Callback을 받고 있다는 점이다.
이 두 케이스를 일반화했을 때 생각해볼 수 있는 건 다음과 같다고 생각했다.
예를 들어, 유저가 입/퇴장하는 이벤트는 1번의 경우에 해당한다. 특정 Client가 이전의 히스토리를 요청하는 경우는 2번의 경우에 해당한다. 이를 다르게 표현한다면, 언제 이벤트가 발생될지 몰라서 계속 듣고 있어야 하는 1번의 경우와, 내가 직접 일으켜서 결과를 받는 2번의 경우가 있다.
즉, 늘 듣고 있어야(Listen) 하는 이벤트와 한 번만(Once) 들으면 되는 경우가 나뉘어진다. 또한, 한 번만 들으면 되는 경우는 내가 직접 일으키는 이벤트라는 사실도 포함되어 있다. 이 포인트를 생각하고 구현에 들어가보자.
사실 한 번만 들어도 되는 경우도 on 메서드를 통해 계속 듣고 있어도 되지만, 간헐적으로 발생하는 이벤트를 계속 들을 필요가 있나? 싶어서 once 메서드를 이용했다.
먼저, 기존 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
를 통해 storedCommentHistor
y를 조작한다.
그 과정에서 fetchingState
를 반환하여 로딩 상태를 제어한다.
여기에서 좀 더 나아가면 react-query를 이용해서 client state를 제어하겠지만,, 이걸 만들 때는 관련 지식이 없어서 손대지 못했다(ㅋㅋ)
포인트는, 데이터를 요청할 때는 콜백을 주어 결과값을 핸들링했다는 것.
먼저 Socket Instance를 생성해주어야 했는데, 이 생성해주는 위치가 참 애매했다. 이 녀석을 어쨌든 전역으로 퍼트려줘서 필요한 컴포넌트들이 접근할 수 있게 해주어야 했다. 그렇다고 Context를 쓰자니 Depth가 깊어져서 싫고, Redux를 쓰자니 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 };
겸사겸사 내가 사용할 이벤트들을 정의해두었다.
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으로 듣고 있는 이벤트를 보내고 싶을 때 쓸 용도이다.
먼저 항상 들어야 하는 이벤트과 한 번만 듣는 이벤트를 구분해주어야 한다.
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]);
}
역시 콜백함수를 통해 결과를 핸들링하면 된다.
이번에는 반대로 이벤트를 보내보자. 보내는 건 더 쉽다.
// 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
에는 결과를 받는 콜백함수가 없는데, 그 이유는 해당 메시지는 이미 항상 듣고 있는 이벤트이기 때문이다. 따라서, 따로 콜백함수를 받지 않아도 이미 콜백을 등록해놨기 때문에 핸들링을 할 수 있는 것이다.
역시 뭔가 실시간으로 인터렉션이 되는 머시깽이를 만드니까 재미가 쏠쏠하다. 이제 구조를 변경해야 하는 부담감은 언제나 큰 것을 빼면 ,,, 거기다가 구조를 잘 짜려고 막 고민하다보니까 개발 기간이 계속계속 길어지는 것도 부담스럽구! 그렇지만 이렇게 직접 한번 설계해보고 해야 나중에 이런 시간이 줄어드니까. 잘하고 있다 나 자신!