최근에 Using WebSockets with React 라는 글을 접하게 되면서, 기존 아키텍처를 개선해 보기로 했습니다.
기존 아키텍처는 Zustand를 사용해 모든 실시간 데이터를 단일 스토어에서 관리하는 방식을 사용하였습니다.
(단순하고 직관적인 방식을 사용)
interface MarketStreamState {
streamData: StreamData;
lastUpdate: number;
}
interface MarketStreamAction {
updateMarketData: (data: StreamData) => void;
}
const useMarketStreamStore = create<MarketStreamState & MarketStreamAction>()(
devtools((set) => ({
streamData: {},
lastUpdate: Date.now(),
updateMarketData: (data) =>
set(
(state) => ({
...state,
streamData: data,
lastUpdate: Date.now(),
}),
false,
"marketStreamStore"
),
}))
);
// 웹소켓 설정
socket.on(WEBSOCKET_EVENTS.CONSOLIDATED_MARKET_DATA, (data: StreamData) => {
useMarketStreamStore.getState().updateMarketData(data);
});
export default useMarketStreamStore;
해당 방식은 구현은 간단하나 다음과 같은 문제들을 가지고 있었습니다.
서버에서 업데이트 된 전체 streamData를 받을 때마다 Zustand 스토어의 상태가 통째로 교체됩니다. 이는 수백 개의 코인 중 단 하나의 가격만 변경되어도 전체 목록이 다시 그려지게 됩니다.
그래서 개별 데이터는 따로 비교과정(diffing)을 해주는 별도의 코드가 필요했습니다. 총 store에 한번 useState에 한번 더 저장해서 총 2번의 전체 데이터를 저장해주는 비효율적인 코드였습니다.
import type { TableBodyProps } from "@/features/dashboard/components/table/body/TableBody";
import { getTradingPair } from "@/features/dashboard/services/getTradingPairDetail";
import type { TradingPairDetail } from "@/types/tradingPair";
export const arePropsEqual = (
prevProps: TableBodyProps,
nextProps: TableBodyProps
) => {
if (
prevProps.coin.base_asset !== nextProps.coin.base_asset ||
prevProps.index !== nextProps.index ||
prevProps.refExchange !== nextProps.refExchange ||
prevProps.compExchange !== nextProps.compExchange
) {
return false;
}
const prevRefTradingPair = getTradingPair(
prevProps.coin.base_asset,
prevProps.refExchange
);
const nextRefTradingPair = getTradingPair(
nextProps.coin.base_asset,
nextProps.refExchange
);
const prevCompTradingPair = getTradingPair(
prevProps.coin.base_asset,
prevProps.compExchange
);
const nextCompTradingPair = getTradingPair(
nextProps.coin.base_asset,
nextProps.compExchange
);
const prevRefData =
prevProps.streamData[prevRefTradingPair]?.[prevProps.refExchange];
const nextRefData =
nextProps.streamData[nextRefTradingPair]?.[nextProps.refExchange];
const prevCompData =
prevProps.streamData[prevCompTradingPair]?.[prevProps.compExchange];
const nextCompData =
nextProps.streamData[nextCompTradingPair]?.[nextProps.compExchange];
const compareData = (prev: TradingPairDetail, next: TradingPairDetail) => {
if (!prev && !next) return true;
if (!prev || !next) return false;
return (
prev.price === next.price &&
prev.volume === next.volume &&
prev.changeRate === next.changeRate
);
};
if (
!compareData(prevRefData, nextRefData) ||
!compareData(prevCompData, nextCompData)
) {
return false;
}
return true;
};
지금봐도 어떻게 작성했는지 모르겠네요
해당 과정을 메모이제이션해서 변경점이 있을때마다 전체 컴포넌트를 리렌더 시키는 방식으로 진행했었습니다. (비효율적)
새로운 아키텍처의 핵심은 Snapshot and Update 방식과 Tanstack query의 캐시 관리 능력을 결합하는 것입니다.
export const useExchangeMarketStream = () => {
const queryClient = useQueryClient();
useEffect(() => {
const initSocket = () => {
const socket = io(
process.env.NEXT_PUBLIC_SERVER_URL_DEV || "http://localhost:3000",
{
path: "/socket.io/",
transports: ["websocket"],
}
);
socket.on("connect", () => {
console.log("ws connected");
const payload: IPayload = {
baseExchange: "upbit",
compareExchange: "binance",
baseMarket: "KRW",
compareMarket: "USDT",
};
socket.emit("subscribe-comparison", payload);
});
// Snapshot data
socket.on("comparison-snapshot", (snapshot) => {
Object.entries(snapshot).forEach(([ticker, data]) => {
const queryKey = ["coin", ticker];
queryClient.setQueryData(queryKey, data);
});
});
// Update data
socket.on("comparison-update", (update: UpdateTickerData) => {
Object.entries(update).forEach(([coin, newData]) => {
const queryKey = ["coin", coin];
const existingData = queryClient.getQueryData(queryKey);
if (existingData) {
const updatedData = {
...existingData,
[newData.exchange]: newData,
};
queryClient.setQueryData(queryKey, updatedData);
}
});
});
return () => {
socket.close();
};
};
initSocket();
}, [queryClient]);
};
update데이터는 곧바로 캐싱된 데이터에 덮어쓰기 됩니다. 이는 기존 불필요한 diffing 과정을 두줄의 코드로 생략하게 됩니다.
이번 리팩토링으로 실시간 데이터를 더욱 실시간에 가깝게 만들었고 Tanstack Query의 캐시 관리 기능을 활용해서 기존 코드보다 더 직관적이고 유지보수하기 쉬운 코드로 만들었습니다.