이번에 회사의 프로젝트를 진행하면서 지금까지 해본적 없던 기술들을 사용하면서 알게 된 내용을 기록하기 위해 오랜만에 블로그에 글을 작성하려고 한다.
이번 프로젝트는 기본적으로 웹소켓 통신을 통해 받아온 데이터들로 작동을 한다. 좀 더 자세하게는 다양한 코인들에 대한 정보들을 사용한다.
코인과 주식 등과 같은 데이터들은 너무나도 순식간에 값이 변경되고, 변경에 매우 예민하다. 따라서, 단 1초의 순간에도 어떠한 변경이 일어날지 모르기 때문에 더 철저하게 관리되어야 했다.
처음 프로젝트의 방향은 웹소켓 통신이 아닌 스냅샷 통신을 하고, 사용자가 필요로 할 때 새로고침을 통해 데이터를 받아오게 하는 방향으로 잡혀있었다. 하지만 그렇게 진행했을 때의 사용자가 가질 불편과 서비스의 메리트가 매우 떨어진다고 생각하여 웹소켓 통신을 통해 데이터를 받기로 수정이 되었다.
웹소켓 통신을 처음 사용해보는 나는 많은 트러블 슈팅이 있을거라고 생각했고, 기능구현에 초점을 두고 개발을 시작했다.
웹소켓 통신이 무엇인지는 당장 구글에 웹소켓 통신만 검색해도 수도없이 많은 정보가 떨어지기 때문에 이곳에는 적지 않고 순수하게 내가 어떤 순서와 방향으로 개발을 했는지만 적을 생각이다.
먼저, 심플하게 아무 생각없이 브라우저 자체의 web socket api를 사용하여 통신을 시도했다.
let socket = new WebSocket("wss://웹소켓 통신 baseurl");
socket.onopen = function() {
alert("소켓 연결!");
socket.send(조회하고자 하는 marketcode);
};
socket.onmessage = function(e) {
alert(`서버로부터 받은 데이터: ${e.data}`);
};
socket.onclose = function(e) {
if (e.wasClean) {
alert("소켓 닫힘");
} else {
alert('강제로 소켓 닫힘');
}
};
socket.onerror = function(error) {
alert(error);
};
데이터를 확인하고자 할때 console.log 대신 alert를 사용했던 이유는 순수하게 핸드쉐이크 후에 데이터가 정상적으로 잘 들어오는지 확인이 우선적이였기 때문에 무수히 받아들여와지는 데이터와 로그가 보기 불편해서 alert를 통해 confirm전에 한번만 확인해보고자 alert를 사용했다.
데이터가 에러없이 받아와지는지는 확인할 수 있었다.
하지만, 팀원 한분이 웹소켓의 상태관리가 좀 많이 힘들거라고 그부분을 많이 생각해보라고 하셨다. 그 이후에 나는 데이터를 받아와서 그걸 뿌려주어 화면에 보이는거에서 끝낼수가 없었다.
분명 우리 서비스에서는 해당 웹소켓을 열어야하는 상황과 닫아도 되는 상황이 존재했다. 계속해서 웹소켓을 열어놓은 상태로 가만히 두면 안된다는 것이다. 웹소켓 데이터가 필요없는, 컴포넌트가 보여지지 않는 상황에 적재적소 대응을 해야하는 것이였다.
가장 먼저 생각난 방법은 useEffect를 통해 조회할때 필요로하는 marketcode를 의존성 배열에 넣고, 컴포넌트가 mount될 때와, marketcode가 변경될 때마다 WebSocket 생성자를 새로 생성해서 해당 웹소켓 데이터를 상태로 관리하고, useEffect return문을 통해 해당 컴포넌트가 unMount될 때 socket을 close시키고, 웹소켓 데이터 상태를 빈배열로 만드는 것이였다.
const [socketData, setSocketData] = useState(null);
useEffect(() => {
let socket = new WebSocket(SOCKET_BASE_URL);
socket.onopen = function () {
socket.send(조회하고자 하는 marketcode);
};
socket.onmessage = function (e) {
const data = e.data;
setSocketData(data);
};
socket.onclose = function (e) {
if (e.wasClean) {
setSocketData(null);
} else {
setSocketData(null);
}
};
return () => {
socket.close();
};
}, [marketCodes]);
이렇게 작성했을 때, 기능상으로 문제는 크게 없지만 marketCodes가 변경될때마다 새로운 소켓 연결을 하기 때문에 의도치 않은 불필요한 새로운 소켓 연결을 하게 될 수도 있다. 따라서 useRef를 통해 동일한 socket 인스턴스를 계속 유지시키면서 불필요한 리렌더링을 막고, 컴포넌트가 unmount될 때 안전하게 소켓을 닫고, 다시 mount되거나 marketCodes가 바뀔때 다시 안전하게 새로운 소켓을 열게 했다.
const socketRef = useRef(null);
useEffect(() => {
socketRef.current = new WebSocket(SOCKET_BASE_URL);
socketRef.current.onopen = function () {
socketRef.current.send(조회하고자 하는 marketcode);
};
socketRef.current.onmessage = function (e) {
const data = e.data;
setSocketData(data);
};
socketRef.current.onclose = function (e) {
setSocketData(null);
};
return () => {
if (socketRef.current) {
socketRef.current.close();
}
};
}, [marketCodes]);
위와 같이 웹소켓 데이터가 필요한 컴포넌트가 mount될 때 useRef에 socket 객체가 들어갈 공간을 잡아주었다.
비록 모든 코드들을 올리지는 못하지만, 이렇게 소켓 연결과 소켓 상태는 마무리 지었다.
그다음의 문제는 blob형태로 받아와지는 대량의 데이터처리다.
기본적으로 웹소켓에서 데이터를 전송할 때는 대용량의 이진데이터 처리, 효율적인 데이터 전송을 위해 blob형태로 데이터를 전송한다.
따라서 blob형태의 데이터를 문자열 데이터로 변환을 해주어야한다.
나는 blob형태의 데이터를 문자열로 인코딩해주는 함수를 유틸함수로 만들었다.
const socketDataEncoder = <T>(socketData: ArrayBuffer): T | undefined => {
const encoder = new TextDecoder('utf-8');
const rawData = new Uint8Array(socketData);
try {
const data = JSON.parse(encoder.decode(rawData)) as T;
return data;
} catch (error) {
console.error(error);
return undefined;
}
};
export default socketDataEncoder;
이진 데이터를 encoder.decode(rawData)를 통해 UTF-8 문자열로 디코딩하고, JSON.parse를 통해 JSON객체로 파싱해주는 간단하다면 간단한 유틸함수이다.
데이터가공은 했지만, 진짜 문제는 계속 밀려들어오는 데이터관리이다. 내가 조회하고자하는 코인들의 marketCode를 넣어주어 요청을 보내면 여러가지 코인에 대한 정보와 시세 등의 데이터들이 밀려 들어온다. 데이터 크기도 크고, 데이터 상태가 계속해서 바뀌기때문에 계속해서 컴포넌트의 리렌더링이 발생한다. 이를 해결하기 위해 구글링을 엄청많이 한것 같다.
여러 블로그와 자료들을 찾아보던 중 해볼만한 방법을 찾을 수 있었다. 바로 소켓 통신을 통해 받아와지는 데이터들을 의도적으로 쓰로틀링을 건 상태로 다른 공간에 한번 더 저장하고 그 데이터들이 들어가있는 공간에서 데이터처리를 하여 데이터 상태를 바꿔주는 방법이다.
즉, 받아온 데이터들을 바로 모조리 데이터 상태에 넣어버리는 것이 아니라 일정 시간동안 다른 공간에 쌓아두고 그 공간에서 코인별 가장 마지막에 업데이트된 데이터만 buffer로 빼와서 재정렬 후 데이터상태에 집어넣는 방식이다.
순서대로 보기좋게 작성해보면
- 데이터 상태가 아닌 다른 공간을 만들어서 해당 공간에 소켓을 통해 받아와지는 모든 데이터들을 집어넣는다.
- 쓰로틀링을 주어 일정 시간이 지나면 각 코인별로 마지막에 변경된 데이터만을 가지고 buffer를 만들어 정렬해서 집어넣는다.
- buffer에 저장되어있는 정렬된 코인별 마지막 데이터들을 데이터 상태로 return한다.
위와 같은 방법으로 상태변경을 내가 의도적으로 쓰로틀링을 주어 컨트롤할 수 있게 되었다.
최적화에는 끝이 없지만 이처럼 한단계 한단계 최적화와 리팩토링을 하다보면 최적화에 많이 가까워질 수 있 수는 있겠다는 생각이 들었고, 위와 같이 buffer를 활용하여 최적화하는 방법은 생각하지도 못했던 방법이였는데, 이렇게도 상태변경에 대해 의도적으로 쓰로틀링을 걸 수 있구나라는 좋은 아이디어를 얻은 것 같아서 의미있는 회사 업무였다고 생각한다.
항상 고정적인 리액트 훅들만 사용하여 최적화를 하려고하는 생각도 고쳐야할 것 같다는 생각을 한다.
비록 예시코드로 간단간단하게만 이 포스트에 작성했지만, 현재 해당 프로젝트에 도입되어 있는 정말 많은 생각을 하게만드는 코드들도 많기 때문에 좀더 뜯어보고 공부해서 예시코드를 통해 블로그 포스팅을 할 예정이다.
다음 포스팅은 아마 aws cli 설정부터 docker-compose 설정까지 내가 한 부분을 적을 것 같다.