Coin Chart - 웹소켓 데이터 처리
오랜만에 돌아온 코인 차트
어쩌다보니 정리가 조금 밀렸다. 막상 하다보니 이것 저것 다 건들게 되어서 쪼개서 정리하는게 의미가 없어졌다. 그래서 웹소켓 데이터를 어떻게 처리하게 되었는지 알아보겠다.
기존 동작
저번 포스팅에서는 symbol, 즉 가상 화폐 리스트만 가져와서 처리를 했었다. 하지만 가상 화폐 거래소에서 가장 중요한 것은 실시간 데이터 처리다. 현재 화폐의 가격이 어떤지 알아야 거래를 진행하기 때문이다.
useEffect(() => { const lowerSymbol = symbol.toLowerCase(); const socket = new WebSocket(`${BASE_WS_URL}/${lowerSymbol}@depth20`); socket.onmessage = (event) => { const data: Order = JSON.parse(event.data); setOrderData({ asks: data.asks, bids: data.bids }); }; socket.onerror = (error) => { console.error('WebSocket 오류:', error); }; return () => { if (socket && socket.readyState === WebSocket.OPEN) { socket.close(); } }; }, [symbol]);
이 코드는 주문 리스트(매도, 매수)데이터를 받아오는 웹소켓이다. 이렇게 useEffect를 통해 각 컴포넌트에서 웹소켓을 일일히 연결하고 있다. 이렇게 했을때 문제점은 각 웹소켓을 일일히 관리해야 한다는 점이다. 아무래도 여러 곳에 분산되어 있는 것은 유지보수 측면에서 좋지 않다. 그래서 이 웹소켓을 한곳에서 관리하도록 할것이다.
전역 상태를 활용한 웹소켓 데이터 사용
웹소켓 통합
우선 나는 symbol을 dynamic route로 받고있다. 즉, /trade/BTCUSDT와 같은 경로로 접근했을때 뒤에 있는 가상화폐 종류를 받아 해당 화폐의 데이터를 받아야한다.
interface WebSocketStore { symbol: string; miniTicker: Miniticker[]; depthUpdate: DepthDateType; aggTrade: AggTrade[]; symboInfo: Miniticker | null; setDepthUpdate: (data: DepthDateType) => void; setSymbol: (data: string) => void; setminiTicker: (data: Miniticker[]) => void; setAggTrade: (data: AggTrade[]) => void; connectWebSocket: () => void; disconnectWebSocket: () => void; }
그래서 전역 상태로 관리하는 데이터가 이렇게 많아졌다. 왜냐하면 동적 경로를 통해 받은 화폐를 setSymbol하게 되는데, 하나의 웹소켓에서 데이터를 다룰 예정이기 때문이다.
이 부분은 따로 나눠야할듯하다. 너무 데이터가 집중되어 있어서 코드를 보기 너무 어렵다.
const connectWebSocket = () => { const symbol = get().symbol; if (!symbol) return; if (!socket) { socket = new WebSocket(BASE_WS_URL); } socket.onopen = () => { const subscribeMessage = { method: 'SUBSCRIBE', params: [ '!miniTicker@arr@3000ms', `${symbol.toLowerCase()}@depth`, `${symbol.toLowerCase()}@aggTrade`, ], id: 1, }; socket?.send(JSON.stringify(subscribeMessage)); }; socket.onmessage = handleWebSocketMessage; socket.onerror = (error) => console.error('WebSocket 오류:', error); socket.onclose = () => { console.warn('WebSocket 연결이 종료되었습니다.'); socket = null; }; }; const disconnectWebSocket = () => { if (socket) { socket.onmessage = null; socket.onerror = null; socket.onclose = null; socket.close(); socket = null; } };
그래서 해당 store에서 웹소켓에 모든 데이터에 접속하는 것이다. 위의 코드에서 만든 connectWebSocket은 symbol을 설정할때 연결하도록 해준다.
... setSymbol: (newSymbol: string) => { const currentSymbol = get().symbol; if (currentSymbol === newSymbol) return; if (!socket) { // 웹소켓 연결된 이력 없면 웹소켓 연결 set({ symbol: newSymbol }); get().connectWebSocket(); } ...
symbol이 없거나, 현재 symbol과 동일할 경우에는 웹소켓을 다시 연결하지 않도록 방지해줬다.
export default function Page({ params, }: { params: Promise<{ name: string }>; }) { const { name: symbolName } = use(params); const setSymbol = useWebSocketStore((state) => state.setSymbol); useEffect(() => { setSymbol(symbolName); }, [symbolName]); ...
이제 해당 페이지에서 경로를 받아서 symbol을 설정해주면 된다. 그러면 기존 코드에서 각 컴포넌트에서 여러 개의 웹소켓에 연결하는 방식에서 한개의 웹소켓을 통합된다.
이건 기존에 여러개의 웹소켓에 접속하는 모습이다.
변경한 이후에는 한개의 웹소켓만 확인가능하다.
웹소켓 데이터 처리
그렇다면 한번에 받아오는 데이터를 어떻게 처리하는지 보자. 엄청 복잡하진 않다. 웹소켓을 통해 데이터를 받아오게되면
메세지마다 어떤 데이터인지 보내주고 있기 때문에 이벤트 이름을 기반으로 구분해서 이벤트 처리를 해주면 된다.
const handleWebSocketMessage = (event: MessageEvent) => { const data = JSON.parse(event.data); if ( Array.isArray(data) && data.length > 0 && data[0].e === '24hrMiniTicker' ) { ... } else if (data.e === 'depthUpdate') { ... } else if (data.e === 'aggTrade') { ... } };
이제 웹소켓에서 새로운 메세지를 받아올때마다 해당 로직을 통해 이벤트를 구분하고 상태를 업데이트한다.
가상화폐 리스트
24hrMiniTicker라는 이벤트 이름으로 데이터를 받는다. 가상화폐의 가격 변동, 가격 등 간단한 데이터를 받는다.
const existingMiniTicker = get().miniTicker; const symbol = get().symbol; const tickerMap = new Map( existingMiniTicker.map((item) => [item.s, item]) ); data.forEach((symbol) => { tickerMap.set(symbol.s, symbol); }); const currentSymbolInfo = tickerMap.get(symbol); set({ symboInfo: currentSymbolInfo }); set({ miniTicker: Array.from(tickerMap.values()) });
매번 메세지마다 동일한 데이터가 오는 것이 아니라 변동이 일어난 데이터만 받아온다. 그래서 매번 모든 리스트를 새로 설정하게 되면 갑자기 사라지는 데이터가 존재한다. 그래서 기존 데이터를 받아와서 업데이트 하는 방식으로 구현했다. get().miniTicker를 통해 기존 데이터를 받아서 Map으로 만들어 key와 해당 값으로 만들어준다. 그래서 이미 있는 데이터는 새롭에 업데이트하고, 없는 데이터는 새로 만든다. 중복을 허용하지 않는 map의 특성을 활용한 것이다.
추가적으로 현재 symbol에 대한 데이터도 여기에서 활용한다. 거래량, 가격 변동, 현재 가격등 필요한 데이터가 전부 있기 때문에 현재 symbol을 통해 map에서 데이터를 찾아 설정한다.
오더 테이블
투자자들이 생성한 매도, 매수 주문을 확인하는 데이터이다. 우선 데이터가 어떻게 오는지 보겠다.
우리는 여기에서 a, b만 사용할 것이다. ask, bid 데이터로 생성된 주문 가격과 수량이 담겨있다. 데이터가 현재 데이터로 바로 사용할 수 있다면 좋겠지만 실제 주문과는 약간 다르다. 새롭게 생성되거나 기존 주문에서 수량이 변경된 주문만 받아온다. 이 데이터를 그대로 사용했을때 문제점은 체결되지 않았음에도 주문이 사라진다는 점이다. 그래서 기존 데이터와 비교해서 바뀐 것들만 업데이트하고, 변동없는 데이터는 유지시켜주는 것이다.
export const mergeDepthData = ( newData: string[][], oldData: string[][], isBid: boolean ) => { // 기존 데이터 map으로 변환 : 새로운 데이터랑 통합해서 업데이트 및 추가 목적 const mergedMap = new Map(oldData.map(([price, amount]) => [price, amount])); // 새로운 데이터 추가 및 업데이트 newData.forEach(([price, amount]) => { if (amount !== '0.00000000') { mergedMap.set(price, amount); } else { mergedMap.delete(price); // 수량이 0이면 삭제 } }); return Array.from(mergedMap) .sort((a, b) => isBid ? parseFloat(b[0]) - parseFloat(a[0]) : parseFloat(a[0]) - parseFloat(b[0]) ) // asks 오름차순, bids 내림차순 .slice(0, 20); };
각 데이터를 병합처리해주는 로직이다. 기존 데이터와 새로운 데이터를 받아서 map으로 변경해 처리한다. 수량이 0인 경우 삭제, 기존에 있는 데이터는 유지, 새로운 데이터 업데이트해주는 방식이다.
const depthData: DepthUpdate = data; const currentData = get().depthUpdate; const askList = mergeDepthData( depthData.a || [], currentData.asks, false ).reverse(); // 가격 낮은 순 const bidList = mergeDepthData(depthData.b || [], currentData.bids, true); // 가격 높은 순 set({ depthUpdate: { asks: askList, bids: bidList } });
이렇게 만든 데이터를 설정해준다. askList는 컴포넌트에서 역순으로 되어있어야해서 reverse를 시켜줬다.
실시간 거래 데이터
실시간으로 체결된 데이터를 확인할 수 있다. 한번에 한개씩만 받기 때문에 엄청 복잡한 내용은 없다.
const currentData = get().aggTrade; set((state) => ({ aggTrade: [{ ...data }, ...state.aggTrade.slice(0, 29)], }));
매번 데이터를 받으면 새로 추가하고, 너무 길어지는 것을 방지하기 위해 30개씩 끊어서 업데이트 한다.
개선점
여기에서 문제는 처음에는 모든 데이터가 없기 때문에 페이지에 처음 접근하는 시점에서는 모든 곳이 빈칸이라는 것이다. 그래서 초기 데이터 패칭이 필요하다. 그리고 초기 데이터를 기반으로 업데이트 해주면 더욱 자연스러운 동작이 될것이다.
마무리
나름 만족스러운 결과이다. 처음 목표였던 웹소켓을 하나로 합치는 것을 이뤘고, 각 데이터는 기존과 동일하게 처리하고 있기 때문이다. 하나 아쉬운것은 웹소켓 데이터를 다루는 전역 상태가 너무 커졌다는 점이다. 각 데이터에 대해서 store를 나눠서 관리하는 것이 더 좋지 않을까하는 생각이 있다.
단순히 데이터 표기만 하지 않고 뭔가 생산적인 기능도 넣으면 좋을 것 같다.
개의 댓글
1
오랜만에 돌아온 코인 차트
기존 동작
전역 상태를 활용한 웹소켓 데이터 사용
웹소켓 통합
웹소켓 데이터 처리
가상화폐 리스트
오더 테이블
실시간 거래 데이터
개선점
마무리