들어가며
이번 시간에는 서버 상태 관리 라이브러리를 직접 구현해본다. 만드는 과정에서 마주친 문제점들과 해결 방법을 하나씩 따라가 보자.
문제점 1 : 서버 상태가 분산되어있다.
현재 문제점은 서버 상태가 분산되어있다는 것이다. 서버 데이터를 불러오는 각 컴포넌트에서 데이터를 호출한다면 서버 데이터를 동시에 불러오는 처음에는 괜찮겠지만, 이후에 서버 데이터를 다시 불러온다면 문제가 생길 것이다. 예시와 함께 살펴보자.
아래와 같은 구조의 프로젝트가 있다.
App
├── A
└── B
A와 B에서 모두 서버 데이터가 필요하다고 가정해보자. A와 B 각 컴포넌트에서 모두 서버 데이터 호출을 해서 각 서버 상태를 관리할 수 있다. 하지만 그렇게 할 경우 SSOT를 위반하게 된다. 다시 말해, A컴포넌트에서 서버 데이터를 수정하여 다시 서버 데이터를 불러올 때, B의 서버 상태는 업데이트가 되지 않는다. 각 컴포넌트에서 서버 상태의 원천이 다르기 때문이다.
SSOT(Single Source Of Truth)
: 데이터의 단일 출처
하나의 데이터는 하나의 출처에서만 관리되어야 한다는 원칙이다.
SSOT를 준수하기 위해 App에서 서버 데이터를 전달받아, 이를 A와 B에서 사용하면 된다.
그런데 프로젝트 구조가 더 복잡해진다면?
App
├── A
│ ├── A1
│ │ ├── A1-1 // 👈 서버 데이터 필요
│ │ └── A1-2
│ │ └── A1-2-1 // 👈 서버 데이터 필요
│ └── A2
│ ├── A2-1
│ │ ├── A2-1-1 // 👈 서버 데이터 필요
│ │ └── A2-1-2
│ └── A2-2
├── B
│ ├── B1
│ │ ├── B1-1
│ │ └── B1-2 // 👈 서버 데이터 필요
│ └── B2
│ ├── B2-1 // 👈 서버 데이터 필요
│ └── B2-2
└────────── B2-2-1 // 👈 서버 데이터 필요
위와 같은 프로젝트 구조에서, 각 컴포넌트에서 서버 데이터를 필요로 한다면? 모든 컴포넌트에 props로 서버 데이터를 전달하면 프로젝트의 복잡도는 점점 올라갈 것이다.
이를 해결하기 위해서 Context API를 이용해 상위 컴포넌트에서 서버 상태를 관리할 수 있다. Context Provider를 이용해 모든 하위 컴포넌트에서 상태에 접근할 수 있도록 하는 것이다.
export const useQueryClient = () => useContext(QueryClientContext);
export default function QueryProvider({ children }: QueryProviderProps) {
const [data, setData] = useState<Record<string, unknown>>({});
const [status, setStatus] = useState<Record<string, Status>>({});
const setQueryData = (queryKey: string, data: unknown) => {
setData((prev) => ({ ...prev, [queryKey]: data }));
};
const getQueryData = (queryKey: string) => {
return data[queryKey];
};
const setQueryStatus = (queryKey: string, status: Status) => {
setStatus((prev) => ({ ...prev, [queryKey]: status }));
};
const getQueryStatus = (queryKey: string) => {
return status[queryKey] ?? "idle";
};
return (
<QueryClientContext.Provider
value={{
getQueryData,
setQueryData,
getQueryStatus,
setQueryStatus,
}}
>
{children}
</QueryClientContext.Provider>
);
}
서버 데이터인 data와 서버 데이터의 상태인 status 두 개의 상태를 만들고, 이에 대한 접근자와 설정자 메서드를 Context Provider에 전달했다. 그리고 각 서버 데이터를 구분하기 위해 객체에 저장을 했으며 key에는 서버 데이터를 구분하는 식별자(queryKey, 문자열), value에는 서버 데이터(data) 혹은 서버 데이터의 상태(status)를 저장했다.
그리고 이 useQueryClient를 useQuery로 래핑하여, 서버 데이터를 불러오는 로직을 추상화했다.
export default function useQuery<T>({ queryKey, queryFn }: UseQueryProps<T>) {
const { getQueryData, setQueryData, getQueryStatus, setQueryStatus } = useQueryClient();
const fetchData = async (forceFetch = false) => {
setQueryStatus(queryKey, "loading");
try {
if (getQueryData(queryKey) && !forceFetch) {
setQueryStatus(queryKey, "success");
return;
}
const response = await queryFn();
setQueryData(queryKey, response);
setQueryStatus(queryKey, "success");
} catch (error) {
setQueryStatus(queryKey, "error");
}
};
useEffect(() => {
fetchData();
}, []);
const refetch = () => fetchData(true);
return {
data: getQueryData(queryKey) as T,
status: getQueryStatus(queryKey),
fetchData,
refetch,
};
}
위 useFetch를 사용하는 코드는 다음과 같다.
function UserList() {
const { data, status, refetch } = useQuery({
queryKey: "users",
queryFn: () => fetch("/api/users").then((res) => res.json()),
});
if (status === "loading") return <div>Loading...</div>;
if (status === "error") return <div>Error!</div>;
return (
<div>
{data.map((user) => (
<div key={user.id}>{user.name}</div>
))}
<button onClick={refetch}>Refresh</button>
</div>
);
}
이렇게 useQuery를 사용하면 서버 데이터를 쉽게 가져올 수 있으며, 상태에 따른 UI 처리도 간단하게 할 수 있다. 또한 refetch 함수를 통해 언제든지 서버 데이터를 새로고침할 수 있다.
서버 데이터를 전달받으면 data는 다음과 같은 객체가 된다.
{
"users": [
{ id: 1, name: "John" },
{ id: 2, name: "Jane" }
],
"posts": [
{ id: 1, title: "Hello World" },
{ id: 2, title: "Second Post" }
]
}
이처럼 각각의 서버 데이터는 queryKey를 통해 구분되어 저장된다. 이렇게 하면 여러 컴포넌트에서 동일한 서버 데이터에 접근할 수 있게 된다.
정리하면 SSOT문제를 해결하고자 상태를 위로 끌어올렸으며, Props Drilling을 해결하고자 QueryProvider에서 Context API를 활용해 상태를 하위 컴포넌트들에 전달했다. 그리고 useQuery를 통해 서버로부터 데이터를 전달받아 저장하는 로직을 추상화했다.
문제점 2 : 여러 컴포넌트에서 데이터를 호출하면 각 컴포넌트마다 상태를 호출한다.
위와 같은 방식으로 쿼리를 요청하면 useQuery를 호출하는 곳마다 전부 서버 데이터를 불러올 것이다. 네트워크 창을 살펴보면 동일한 api를 여러 번 호출하는 것을 확인할 수 있다.
왜냐하면 서버 데이터를 불러오는 useEffect는 컴포넌트가 마운트된 이후 비동기적으로 호출되기 때문이다. 이를 해결하기 위해 우리는 promise객체를 Map객체에 저장할 것이다.
싱글톤으로 Map객체를 만들고, 이에 대한 접근자/설정자 함수를 만들었다.
const queryPromises = new Map<string, Promise<unknown>>();
export function getQueryPromise(queryKey: string) {
return queryPromises.get(queryKey);
}
export function setQueryPromise(queryKey: string, promise: Promise<unknown>) {
queryPromises.set(queryKey, promise);
}
export function clearQueryPromise(queryKey: string) {
queryPromises.delete(queryKey);
}
그리고 이 Map의 key에는 QueryKey를, value에는 Promise객체를 저장한다. 만약 QueryKey에 해당하는 Promise객체가 있다면 해당 객체를 재활용한다. 여기서 Map의 변화는 렌더링에 영향을 미치지 않기(Promise가 resolve될 때 렌더링에 영향을 미친다) 때문에 JavaScript 객체로 Map을 저장했다.
export default function useQuery<T>({ queryKey, queryFn }: UseQueryProps<T>) {
const { getQueryData, setQueryData, getQueryStatus, setQueryStatus } = useQueryClient();
const fetchData = async (forceFetch = false) => {
setQueryStatus(queryKey, "loading");
try {
if (getQueryData(queryKey) && !forceFetch) {
setQueryStatus(queryKey, "success");
return;
}
let promise = getQueryPromise(queryKey);
if (!promise || forceFetch) {
promise = queryFn();
setQueryPromise(queryKey, promise);
}
const response = await promise;
setQueryData(queryKey, response);
setQueryStatus(queryKey, "success");
clearQueryPromise(queryKey);
} catch (error) {
setQueryStatus(queryKey, "error");
clearQueryPromise(queryKey);
}
};
useEffect(() => {
fetchData();
}, []);
const refetch = () => fetchData(true);
return {
data: getQueryData(queryKey) as T,
status: getQueryStatus(queryKey),
fetchData,
refetch,
};
}
getQueryPromise로 Promise객체를 가져오고, 이 Promise가 끝날 때까지 기다린다. 아래와 같이 기존 Promise객체가 있다면 해당 Promise를 꺼내와 기다리도록 할 수 있다.
let promise = getQueryPromise(queryKey);
if (!promise || forceFetch) {
promise = queryFn();
setQueryPromise(queryKey, promise);
}
const response = await promise;
❓ Promise객체 앞에 await만 붙여줘도 되요?
await는 Promise를 기다리는 연산자이기 때문에 위와 같이 Promise객체 앞에 붙여주면 Promise가 fulfill/reject될 때까지 기다린다.
그런데 의아해할 수 있는 점은 이 Promise가 Map객체에서 꺼내온 객체라는 점이다. Promise는 이와 같이 값으로 다룰 수 있으며, 어딘가에 저장해두고 재활용할 수 있다는 특징이 있다.
이제 동일한 서버 데이터에 대해 한 번만 호출한다.
문제점 3 : 모든 컴포넌트에서 리렌더링이 일어난다.
Context API는 Provider의 value가 변경되면, 해당 Context를 구독하고 있는 모든 컴포넌트가 리렌더링된다. 위 방식같은 경우 data와 status를 하위 컴포넌트에서 모두 공유하고 있기 때문에, 데이터를 패칭해 data가 변경될 경우 모든 구독 중인 컴포넌트가 리렌더링이 일어나게 된다.
코드를 예시로 살펴보자.
function App() {
return (
<QueryProvider>
<A />
<B />
</QueryProvider>
);
}
App 컴포넌트에는 A와 B 컴포넌트가 있다.
그리고 A와 B 각각 서버 데이터를 불러오고 있다.
function A() {
const { data, refetch } = useQuery<string>({
queryFn: getDataA,
queryKey: "data",
});
return (
<div>
{data}
<button onClick={refetch}>refetch</button>
</div>
);
}
function B() {
const { data, refetch } = useQuery<string>({
queryFn: getDataB,
queryKey: "dataB",
});
return (
<div>
{data}
<button onClick={refetch}>refetch</button>
</div>
);
}
만약 A 컴포넌트에서 refetch를 한다면?
B컴포넌트도 함께 리렌더링이 될 것이다. 왜냐하면 A와 B 모두 동일한 상태(data)를 Context API를 통해 구독하고 있기 때문이다.
이를 해결하기 위한 가장 쉬운 방법은 컨텍스트를 분리하는 것이다. 현재 Context를 구독하는 모든 컴포넌트가 리렌더링이 일어나는 이유는 모든 서버 데이터 요청을 하나의 Context에서 관리하기 때문이다. 아래와 같이 각 서버 데이터 별로 Context를 나누면 해결할 수 있다.
function App() {
return (
<QueryProviderA>
<QueryProviderB>
<A />
<B />
</QueryProviderB>
</QueryProviderA>
);
}
하지만 호출해야하는 서버 데이터가 계속해서 늘어난다면? 유지보수가 상당히 어려워질 것이다. 하나의 스토어로 관리할 수 있는 방법을 모색해보자.
다른 방법으로는 useSyncExternalStore를 사용할 수 있다.
: 외부 store를 구독할 수 있는 React Hook
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?)컴포넌트의 최상위 레벨에서 호출해 외부 데이터 저장소에서 값을 읽어올 수 있다.
subscribe: store를 구독하고 구독을 취소하는 함수를 반환한다.getSnapShot: store에서 데이터의 스냅샷을 읽는다.
기존에는 useState로 상태를 만들었다면, JavaScript 객체로 데이터를 만들고 useSyncExternalStore을 이용해 JavaScript 객체를 구독하여 리액트 상태로 만든다. 이 때 값을 수정할 때 구독한 객체에만 상태 변화를 알리기 때문에 모든 컴포넌트가 리렌더링되지 않고, 수정한 데이터에 접근하는 컴포넌트만 렌더링이 될 것이다.
- 데이터를 담을 객체와 이에 접근하는 함수를 만든다.
const dataStore: Record<string, unknown> = {};
export const getQueryData = (key: string) => dataStore[key];
- 데이터가 수정되었을 때 실행되는 함수(listener)를 담을 객체를 만든다.
const dataListeners: Record<string, Set<Listener>> = {};
- 데이터를 수정하는 함수를 만든다.
export const setQueryData = (key: string, value: unknown) => {
dataStore[key] = value;
dataListeners[key]?.forEach((cb) => cb());
};
이 함수는 dataStore의 값을 수정하고, dataListeners를 순회하며 콜백 함수를 실행한다.
- 구독하는 함수를 만든다.
export const subscribeQueryData = (key: string, cb: Listener) => {
if (!dataListeners[key]) dataListeners[key] = new Set();
dataListeners[key].add(cb);
return () => dataListeners[key].delete(cb);
};
dataListeners에 해당 콜백 함수를 담는다.
이 때 Set를 사용해 동일한 콜백 함수가 여러번 등록되는 것을 막는다. 또한 클린업 함수에서 delete를 이용해 빠르게 연산을 할 수 있다.
useSyncExternalStore에 구독 함수와, snapshot함수(getQueryData)를 전달한다.
export function useQueryData<T>(key: string): T {
return useSyncExternalStore(
(cb) => subscribeQueryData(key, cb),
() => getQueryData(key) as T
);
}
- 이제 이 훅을 사용해서 데이터에 접근하고, setQueryData로 데이터를 수정할 수 있다.
export default function useQuery<T>({ queryKey, queryFn }: UseQueryProps<T>) {
const data = useQueryData<T>(queryKey);
const fetchData = async (forceFetch = false) => {
// ..
const response = await promise;
setQueryData(queryKey, response);
- 해결 후
실제 코드는 아래 링크에서 확인할 수 있다.
정리
만들다 보니 문제가 문제를 불렀다. 상태를 위로 끌어올리니 중복 요청이 생겼고, Promise를 캐싱하니 이번엔 리렌더링이 문제였다. 그렇게 Context API → Promise 캐싱 → useSyncExternalStore까지 온 것이다.
제일 재밌었던 건 useSyncExternalStore였다. react query가 왜 Context가 아니라 외부 스토어를 쓰는지 몸으로 이해하게 됐다. 다음 편에서는 이걸 rollup으로 빌드해서 진짜 npm에 배포해보겠다. 앞으로도 다른 라이브러리에서 제공하는 기능들을 직접 구현해봐도 재밌을 거 같다.