사이드바에 코인 리스트를 달았다.
각기 다른 API를 사용하여
1. 코인 리스트를 받아온다.
2. 각 코인의 현재가, 변동률, 거래금액(TICKER API)를 받아와
3. 코인 리스트 순서에 맞게 정렬한다.
패칭의 조건은 아래와 같다.
1. coinList의 경우 리스트 정렬이 바뀔때
2. 부가적인 Ticker 정보의 경우 1분이 지날때
그런데 문제는 불필요한 패칭이 일어난다는 점.
1. 1분이 지나지 않았는데도, 사이드바를 여닫을 때마다 & 정렬 변경 시마다 coinList와 coinTicker api 둘 다 새로 패칭됨
2. 또한 첫 렌더링 시 ui 가 정말정말 느리다는 점!
..이들을 해결해 보겠다.
아래는 기존 코드이다.
import { create } from "zustand" ;
import { fetchCoin } from "../api" ;
import { useEffect } from "react" ;
interface Market {
coinCode: string;
englishName: string;
koreanName: string;
rsi: string;
like?: boolean;
}
// zustand store 인터페이스
interface CoinListStore {
markets: Market[];
loading: boolean;
sortList: string;
fetchCoinList: (sortList: string) => Promise<void>;
setSortList: (coinState: string) => void;
}
const useCoinListStore = create<CoinListStore>((set) => ({
markets: [],
loading: false ,
sortList: "" , // 정렬 기본값 좋아요 순
fetchCoinList: async (sortList: string) => {
// 이미 로딩 중이면 중복 호출 방지
if (useCoinListStore.getState().loading) return ;
set({ loading: true });
try {
const data = await fetchCoin(sortList);
const formattedMarkets = data.map((market: { rsi: string }) => ({
...market,
rsi: !isNaN(Number(market.rsi))
? parseFloat(market.rsi).toFixed( 1 )
: market.rsi,
}));
set({ markets: formattedMarkets, loading: false });
} catch (error) {
console.error( "코인 리스트 에러:" , error);
set({ loading: false });
}
},
setSortList: (sortList: string) => {
// 현재 스토어의 sortList와 새로 들어온 sortList가 다른지 확인
if (sortList !== useCoinListStore.getState().sortList) {
set({ sortList });
}
},
}));
export default function useCoinList() {
const { markets, fetchCoinList, setSortList, sortList } = useCoinListStore();
const isInitialLoad = !useCoinListStore.getState().markets.length;
useEffect(() => {
if (isInitialLoad) {
fetchCoinList(sortList);
console.log( "sortList변경. 이후 코인리스트 새로 패칭됨" , sortList);
}
}, [sortList]);
return { markets, setSortList };
}
1. 사이드바 on/off시 무조건적인 패칭 해결
zustand에 저장된 마켓의 길이가 0일 경우에만 패칭을 받아오게 하면,
sortList가 변경될 때와 첫 렌더링 시에만 패칭이 된다!
const isInitialLoad = !useCoinListStore.getState().markets.length;
문제는, zustand의 list set 부분에서 기존 list와 새 list를 구분하는 로직이 작동하지 않는다는 것. log 찍어보면 두개 다 동일하게 나온다. 그러나 이를 해결한다 해도 useEffect 내에서 sortList 가 패칭 트리거이기에 1분 무시 & 정렬 변경 시 패칭이 됨. 이를 위해 정렬을 변경할때 마다 1분 동안 저장해둬야 싶었으나, 사용자가 리스트의 정렬 순서를 1분 이내에 여러번 바꿔가며 볼 이유가 있을까? 하는 의문이 든다..
(작동 안하는 sorting 구분 코드)
setSortList: (sortList: string) => {
// 현재 스토어의 sortList와 새로 들어온 sortList가 다른지 확인
if (sortList !== useCoinListStore.getState().sortList) {
set({ sortList });
}
},
}));
그래도 한번 패칭된 sortList에 따른 coinList api의 response는 언제나 같은 값을 반환하니, zustand의 캐싱 기능을 통해 정렬 별 리스트 값을 저장해 두겠다.
변경 코드:
1. 기존에 사용하던 zustand에 캐쉬 데이터를 추가한다. 캐쉬에 sortList 별로 값을 캐싱할 예정이다.
// zustand store 인터페이스
interface CoinListStore {
markets: Market[];
loading: boolean;
sortList: string;
marketsCache: Record<string, Market[]>;
fetchCoinList: (sortList: string) => Promise<void>;
setSortList: (coinState: string) => void;
}
2. get을 구조분해 할당 해준다. 참고로 store.getState()나 get이나 동일한 state를 반환한다고 한다.
if (useCoinListStore.getState().loading) return ; -> const { marketsCache, loading } = get(); if (loading) return;
3. sortList가 빈 값인 상태에서, 저장단계 set에서 기존의 캐시 상태와 변경된 캐시 상태를 비교하여, 빈 sortList용 캐시에 rsi 값이 변환된 response를 저장한다.
const useCoinListStore = create<CoinListStore>((set, get) => ({
markets: [],
loading: false ,
sortList: "" , // 정렬 기본값 좋아요 순
marketsCache: {},
fetchCoinList: async (sortList: string) => {
const { marketsCache, loading } = get();
if (loading) return ; // 이미 로딩 중이면 중복 호출 방지
if (marketsCache[sortList]) {
set({ markets: marketsCache[sortList] });
return ; // 캐시된 데이터가 있으면 API 호출 없이 사용
}
set({ loading: true });
try {
const data = await fetchCoin(sortList);
const formattedMarkets = data.map((market: { rsi: string }) => ({
...market,
rsi: !isNaN(Number(market.rsi))
? parseFloat(market.rsi).toFixed( 1 )
: market.rsi,
}));
set((state) => ({
markets: formattedMarkets,
marketsCache: { ...state.marketsCache, [sortList]: formattedMarkets }, // 기존 marketsCache를 유지하면서, 현재 sortList에 대한 데이터만 추가.
loading: false ,
})); //(state):기존 상태 참조&새 상태 만듬
} catch (error) {
set({ loading: false });
console.error( "코인 리스트 에러:" , error);
}
},
setSortList: (sortList: string) => {
if (sortList !== get().sortList) {
set({ sortList }); // 새로 들어온 sortList와 현재 스토어의 get().sortList가 다를때만 변경
}
},
}));
위쪽의 if문에서 sortList별 캐시 저장 유무를 확인하면서도, setSortList: (sortList: string) => { } 에서 sortList값이 기존과 다를 경우에만 변경된다. 정렬 드롭다운에서 기존의 옵션을 클릭하면 아무 변화도 일어나지 않는다.
또한 useCoinList 내부의 useEffect문은 sortList값이 변경될때만 패칭된다. 여기서 하나 깨달은 점:
const isInitialLoad = !useCoinListStore.getState().markets.length; //sidbar 렌더링 시 재호출 방지
useEffect 내에서 market값이 빈 값일 경우, 즉 최초 렌더링 시에도 패칭이 필요하기에 위의 조건문을 걸어뒀는데,
실제로 최초 렌더링때 도움되기 보다는 사이드바 여닫을 시 렌더링이 발생하는 문제를 방지하고 있었다.
그런데 정렬 변경이 먹히지 않는다. 당연하다.. 배열길이가 달라질 경우에만 화면 전환으로 넘어가도록 했으니..
const prevSortList = useRef(sortList); // sortList의 이전 값을 저장
사이드 바 온 오프시, 정렬 변경 시 둘다 fetchCoinList가 발생하지 않게 하고 싶기에
변경 전 sortList를 ref로 감싸 저장 후 사이드바 렌더링 시 sortList와 다른 값일때만 fetchCoinList 되도록 하였다.
useCoinListStore.getState().sortList,
sortList
이 두개 값 (현재 스토어의 값과 새로 들어온 값) 이 다를 경우에만 fetchCoinList 호출하면 되지 않냐 싶지만,,
화면을 띄워둔 상태서 정렬을 변경할 경우 fetchCoinList가 작동하지 않는다.
(예: 기본 -> 좋아요 순 ) 위 두개의 값 모두 변경된 like값으로 뜨기 때문이다.
console.log( "sortList변경:" , useCoinListStore.getState().sortList, sortList, "기존값:" , prevSortList.current );
해당 값을 패칭 부분에서 찍으면 앞에거 두개가 같게 나옴.
아래의 setSortList에서 이미 값을 통일시켰기 때문.. 변경 전 값은 ref에 따로 저장해둔다.
setSortList: (sortList: string) => {
if (sortList !== get().sortList) {
set({ sortList }); // 새로 들어온 sortList와 현재 스토어의 get().sortList가 다를때만 변경
}
},
그런데 또 다른 문제를 발견했다. 최조 렌더링 시 아래 코드의 log가 총 4번 찍히고 있었다..
useEffect(() => {
console.log( "sortList변경:" , sortList);
fetchCoinList(sortList);
}, [sortList]);
return { markets, setSortList };
}
2) useCoinList.ts?t=1743233966461:50 sortList변경: 2) useCoinList.ts?t=1743233966461:50 sortList변경:
처음에는 아래와 같이 fetchCoinList 내부에 set을 남발해서 발생하는 문제인가 싶었다..
✅ useEffect는 처음 마운트될 때 한 번 실행됨 → (1번째 로그) ✅ fetchCoinList 실행 중 loading 상태 변경으로 렌더링 → (2번째 로그) ✅ fetchCoinList 완료 후 markets 및 marketsCache 변경으로 렌더링 → (3번째 로그) ✅ setSortList가 상태를 변경하면서 또 렌더링될 가능성 → (4번째 로그)
그래서 set(loading: true) 을 없애 set을 하나 줄였더니, 최초 렌더링 시 패칭이 5~6번 발생하는 참사가 일어났다..
로딩 중 중복 호출 방지의 역할이 정말 크다는 걸 실감하였다.
그러다 렌더링 중복은 2번 일어남을 확인했는데,
잘 보면 뒤쪽의 로그가 연하게 나타나고 있다. 이는 strict mode로 인한 것이다.
이중 호출은 개발 모드에서만 발생하고, 실제 프로덕션 빌드에서는 한 번만 실행된다.
set이 존재하는 한 렌더링 중복이 일어나는 듯 하다. 어쩔수 없다.
일단!! 아래 문제는 해결함. 총 2시간 10분이 소요되었다.
1. 1분이 지나지 않았는데도, 사이드바를 여닫을 때마다 & 정렬 변경 시마다 coinList가 새로 패칭됨: 해결
전체 코드
import { create } from "zustand" ;
import { fetchCoin } from "../api" ;
import { useEffect, useRef } from "react" ;
interface Market {
coinCode: string;
englishName: string;
koreanName: string;
rsi: string;
like?: boolean;
}
// zustand store 인터페이스
interface CoinListStore {
markets: Market[];
loading: boolean;
sortList: string;
marketsCache: Record<string, Market[]>;
fetchCoinList: (sortList: string) => Promise<void>;
setSortList: (coinState: string) => void;
}
const useCoinListStore = create<CoinListStore>((set, get) => ({
markets: [],
loading: false ,
sortList: "" , // 정렬 기본값 좋아요 순
marketsCache: {},
fetchCoinList: async (sortList: string) => {
const { marketsCache, loading } = get();
if (loading) return ; // 이미 로딩 중이면 중복 호출 방지
if (marketsCache[sortList]) {
set({ markets: marketsCache[sortList] }); // 기존 상태와 다를 때만 set
return ;
}
set({ loading: true }); //로딩 true로 중복호출 방지
try {
const data = await fetchCoin(sortList);
const formattedMarkets = data.map((market: { rsi: string }) => ({
...market,
rsi: !isNaN(Number(market.rsi))
? parseFloat(market.rsi).toFixed( 1 )
: market.rsi,
}));
set((state) => ({
markets: formattedMarkets,
marketsCache: { ...state.marketsCache, [sortList]: formattedMarkets }, // 기존 marketsCache를 유지하면서, 현재 sortList에 대한 데이터만 추가.
loading: false ,
})); //(state):기존 상태 참조&새 상태 만듬
} catch (error) {
set({ loading: false });
console.error( "코인 리스트 에러:" , error);
}
},
setSortList: (sortList: string) => {
if (sortList !== get().sortList) {
set({ sortList }); // 새로 들어온 sortList와 현재 스토어의 get().sortList가 다를때만 변경
}
},
}));
export default function useCoinList() {
const { markets, fetchCoinList, setSortList, sortList } = useCoinListStore();
const prevSortList = useRef(sortList); // sortList의 이전 값을 저장
const isInitialLoad = !useCoinListStore.getState().markets.length; //sidbar 렌더링 시 재호출 방지
useEffect(() => {
if (prevSortList.current !== sortList || isInitialLoad) {
fetchCoinList(sortList);
prevSortList.current = sortList;
}
}, [sortList]);
return { markets, setSortList };
}