또 오랜만에 쓰는 포스트다.. 자꾸 오랜만에 쓰면안되지만 어쩌다보니 2024년은 결혼과 첫등기 등 큰 이벤트를 몰아겪는 해가 됐다.. 아직도 정신없이 집을 정리중이긴 하다..🥹 하지만! 최근에 테스트코드가 없는 우리 서비스의 굵직한 기능인 "채팅" 기능을 리팩토링 하면서 "내가 이렇게 했구나"를 기록하기 위해 오랜만에 포스트를 작성한다!
원 개발자도 없다! 테스트 코드도 없다!
사실 엄두가 나질 않았다.. 물론 책임져서 해야할 이유도 없었다. 하지만 점점 사용자가 늘어날 것 같은 기미가 보이는 시점에, 이대로 두면 안될것 같았다. 리팩토링을 결심한 이유는 다음과 같다.
한눈에 보기 힘든 비지니스 로직
현재 프로젝트 상태관리는 약간 복잡하다 오래된 레거시 코드는 redux + redux-saga
를 이용하고 있고, 비교적 현재의 서버상태관리는 React-Query
를 이용하고 있다 (이것도 v4긴 하지만..) 문제는 redux + redux-saga
로 관리하는 부분은 비지니스 로직이 컴포넌트에 보이지않고 saga
에 집중되어 있는 경향이 강하다는 것이었다.
function* saga(){
const channel = eventChannel((ws,emit) => {
ws.onopen = (data) => {
emit({msgType:'open',data})
}
ws.onerror = (data) => {
emit({msgType:'error',data})
}
ws.onmessage = (data) => {
emit({msgType:'message',data})
}
})
yield takeEvery(channel,function*(data){
const state = yield select(state); // 전역 상태
if(data.msgType === "open"){
// openHandler
}
if(data.msgType === "error"){
// errorHandler
}
if(data.msgType === "message"){
// messageHandler
}
})
}
대략 이와같은 형태이며 websocket 이벤트 처리 및 연결이 redux-saga의 eventChannel에 한 모듈로 묶여있는 형태였다. websocket 이벤트를 통해 동적인 처리를 할때 컴포넌트안에 비지니스 로직이 집중되어있지 않고 해당 모듈로 분산될 수 밖에 없는 구조여서 유지보수에도 좋지 않아보였고 실제로도 그랬다.
상당한 보일러플레이트
모듈 구조를 보면 알 수 있겠지만 웹소켓 메세지에 의존성을 갖고있는 모든상태가 전역상태 처리 되어있어야 한다. 즉 간단한 로컬상태로 처리할 수 있는 부분도 전역상태로 처리해야 한다는 것이다. 하지만 redux + redux-saga + typescript
는 상당한 보일러플레이트가 존재한다.
그러다 보니 간단한 코드역시 장황해지고, 이는 곧 안좋은 dx로 이어진다. 추가로 점차 redux + redux-saga를 걷어내면서 다른 상태관리 라이브러리로 전환하고싶은 야망을 가지고 있었기때문에 더 보기 안좋았다. 이러한 이유로 리팩토링을 결심하게 됐다. 동기가 좀 약해보일 순 있지만.. 프로젝트의 웹소켓 연결이 3개정도 된다. 모두가 다 위와같은 구조로 짜여있다고 생각하면 충분한 동기가 된다 🥹🥹
호기롭게 시작했지만 무엇을 어떻게 해야할까..
테스트 코드가 없기때문에 과감한 리팩토링은 지양했다. 기존 로직에 문제가 있던 부분과 보완해야할 부분을 추가하고, 세부 비지니스 로직은 그대로 가져가고 구조만 바꾸는 형태로 방향을 잡아갔다.
ping-pong 워커 구현
기존 웹소켓이 간헐적으로 끊기는 현상이 존재했고 찾아보니 서버와 주기적인 ping-pong을 통해 안정적인 연결을 가져가야 한다고 들었다. setInterval
로 처리를 했지만 예상과 타이머동작이 다를 때가 존재했다.
브라우저는 비활성 상태일때 최소한의 유지를 위해 리소스 최적화를 진행하는데 이 과정에서 setInterval, setTimeout
등의 동작이 더 지연될 수 있다고 한다. 해결방법으로 Web API중 하나인 WebWorker API
를 쓴다고 한다.
WebWorker
는 별도의 쓰레드에서 동작을 가능케한다. 즉 리소스최적화가 진행되는 메인스레드와는 별개이기때문에. 메인스레드보다 안정적으로 타이머가 보장될 수 있다는 이야기였다.
// component.tsx
useEffect(() => {
const worker = new Worker('worker.js');
worker.postMessage('open');
worker.onmessage = (e) => {
if(e.data === 'ping'){
socket.send('ping to server')
}
}
}, []);
// worker.js
let interval;
self.onmessage = (e) => {
if (interval) {
clearInterval(interval);
}
switch (e.data) {
case 'open':
interval = setInterval(() => {
postMessage('ping');
}, time);
break;
case 'close':
interval = undefined;
break;
}
};
컴포넌트가 마운트 되면 worker가 ping-pong을 주고받기위한 setInterval
을 트리거 하기위해 open
메세지를 보낸다.
worker에서 주기적으로 client에 ping
메세지를 보내게 되고, client는 worker로 부터 받은 메세지가 ping
인 경우 socket에 ping to server
메세지를 보내고(onmessage 핸들러) 이게 진짜 서버로 보내는 ping이 되는거다. (서버로 부터 pong이 오지않은 경우 핸들링 등 세부구현이라 생략하겠다)
근데 고민이생겼다. 굳이 별도의 모듈로 관리할 필요성이있을까 였다. worker.js
는 단순한기능 한가지만 수행 할뿐 별다른 유지보수 예정이 없다고 봐도 무방하다.
그래서 그냥 string-script를 매개변수로 받아 Blob을 통해 worker를 return하는 함수를 만들고, 위의 로직을 넣어 ping-pong worker를 생성하는 함수를 만들어 웹소켓 연결이 open
될때마다 사용하게끔 변경했다.
javascript
function getWorker(script: string) {
const blob = new Blob([script], { type: 'application/javascript' });
const worker = new Worker(URL.createObjectURL(blob));
return worker;
};
function getPingPongWorker(time) {
return getWorker(
`
let interval;
self.onmessage = (e) => {
switch (e.data) {
case '${OPEN}':
if (interval) {
clearInterval(interval);
}
interval = setInterval(() => {
postMessage('${PING}');
}, 1000 * ${time});
break;
case '${CLOSE}':
if (interval) {
clearInterval(interval);
}
interval = undefined;
break;
}
};
`,
);
}
class CustomWebSocket {
constructor(url:string){
this.setWorker();
}
setWorker(){
const {interval,ping} = this.workerOptions;
const worker = getPingPongWorker(3000);
if(this.worker){
this.worker.postMessage(CLOSE);
this.worker.terminate();
}
worker.onmessage = (e) => {
if(e.data === PING){
this.socket.send(ping);
}
}
worker.postMessage(OPEN);
this.worker = worker;
}
}
close, error
이벤트때 핸들링될 reconnect함수도 별도로 생성하고 내부에서 setWorker를 실행하기 때문에 이미 열려있는 worker가 존재할경우 제거해주는 로직을 추가했다.
컴포넌트에서 핸들러 추가하기
리팩토링의 가장 큰 목표는 무의미한 전역상태 걷어내기다. 위에도 말했지만 테스트코드도 없기에 무작정 걷어낼 순 없으니 기존 구현은 건들지 않으면서 컴포넌트에서 handler를 추가하는 방향으로 진행했다. 그래서 구현되어있는 부분은 두고, onmessage 핸들러에 추가되어야 하는 부부은 해당 컴포넌트에서 추가할 수 있게 수정했다.
type SocketEventMap = Partial<{
[key in SocketEventKey]: NonNullable<WebSocket[key]>;
}>;
class CustomWebSocket {
constructor(url:string, socketEventMap:SocketEventMap){
this.setWorker();
this.setSocketEvent();
}
setSocketEvent(socketEventMap:SocketEventMap){
objectEntries(socketEventMap).forEach(([key,handler]) => {
this.socket[key] = handler;
})
}
addEvent(key,handler){
this.socket.addEventListener(key,handler);
}
removeEvent(key,handler){
this.socket.removeEventListener(key,handler);
}
reconnect(time: number = 3000){
if (this.isReconnect) return;
this.isReconnect = true; // false 변경은 onopen에
setTimeout(() => {
this.setSocket();
this.setSocketEvent();
this.setWorkerSetting();
}, time);
}
send(msg:string | Record<string,unknown>) {
if(this.socket.readyState !== WebSocket.OPEN) return;
this.socket.send(typeof msg === "string" ? msg : JSON.stringify(msg))
}
}
mainSocket = new CustomWebSocket(URL,{
onopen:() => {
// ... 기존로직
},
// ... onclose, onerror
onmessage:() => {
// ... 기존로직
}
})
먼저 기존 redux-saga
에 작성되어 있던 이벤트처리를 그대로 전달하기 위해 SocketEventMap이란 Mapped타입을 생성했다. 이 맵을 매개변수로 받게끔 수정한 뒤에 기존구현을 해당 인자로 넣어주고 컴포넌트에선 addEvent, removeEvent로 마운트/언마운트시 핸들러를 추가제거 할 수 있다.
추가로 부족하다고 생각한 부분을 추가했다. reconnect함수 라던지 send시에 type이 string이 아니라면 JSON으로 보내야 하는데 함수화가 안돼있었던 부분이라던지.
마무리 이후 테스트
포스팅하면서 정말 간단한 작업이었는데 당시엔 왜그리 생각이 많았나 싶기도 하고 아직도 잘된걸까에 대한 찝찝함은 계속남았다.. 실력이 부족하니 그러겠지..🥹 피드백해줄 사람이 없다는건 슬프다..
이외에도 채팅 서비스에 부족한 기능들 추가 등 작업을 진행했고 부족한부분이 많이 개선된게 눈에 보여서 좋았다. 웹소켓 소스에 대한 부족함은 몇개 보이긴 하지만 차차 추가해나가는걸로 하고 마무리를 한다 🎊