TQ hydration prefetching을 선언적으로 사용할 수는 없나?? - 미해결..😢
TQ hydration prefetching을 선언적으로 사용할 수는 없나??
Next에서 tanstackQeury의 prefetching을 사용하며 느낀 불편함과 이를 개선해본 경험을 공유하기 위해 작성해봤다.
만약 Next와 TanstackQuery를 함께 쓰기로 했다면?
Next의 수많은 장점과 TanstackQuery의 수많은 장점 중 함께 사용한다면 좋은 시너지를 낼 수 있는 방법을 생각해보자.
Next를 쓰는 장점이 뭐야?
Next에서 SSR을 사용했을 때의 장점 중 하나는 데이터를 서버에서 패칭해서 HTML을 만들어 보내줄 수 있다
TQ를 쓰는 장점이 뭐야?
데이터를 캐싱해주고, 로딩, 에러 처리 등 데이터를 손쉽게 관리할 수 있다.
이 둘의 장점을 합치면 뭐야
데이터를 서버에서 패칭하고 캐싱하여 손쉽게 관리 할 수 있다.
How?
이를 위해 TanstackQuery 공식문서에서는 서버에서 프리패칭하는 방법을 다음과 같이 설명하고 있다.
공식 문서에 나온 예시에 따르면 next App router에서 데이터를 프리패칭하기 위해서는 아래와 같이 할 수 있다
- 프리패칭하는 컴포넌트를 호출하는 곳에서 먼저 데이터를 프리패칭해주고
// server component
export default async function PostsPage() {
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryKey: ["posts"],
queryFn: getPosts,
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<Posts />
</HydrationBoundary>
);
}
- 데이터를 사용하는 곳에서 다시 useQuery로 데이터를 가져다 쓴다 (정확히는 프리패칭 이후이니 캐시 값을 가져다 쓰는 것이다.)
// client component
"use client";
export default function Posts() {
const { data } = useQuery({ queryKey: ["posts"], queryFn: getPosts });
// ...
}
나는 여기서 두가지 불편한 점을 느꼈다.
Problem
1. 프리패칭 데이터가 필요한 곳 마다 상위 server component와 사용하는 client componetd 에 각각 로직이 필요하다 .
상위 server component
에서는 queryClient
를 새롭게 만들고, 데이터를 prefetch
한 후 dehydreate
한 데이터를 state
에 담은 HydrationBoundary
를 설정해줘야하고
사용하는 client component
에서는 useQuery
를 통해 prefetch
하는 option
과 동일한 값을 useQuery에 넘겨줘 불러온다.
⇒ 이거를 하나의 커스텀 훅으로 만들어서 2곳에 퍼져있는 작업을 한 곳으로 모을 수 없을까?
2. queryClient를 새롭게 만들어야 하는 비용이 발생한다.
만약 여러곳에서 데이터를 사용한다면 프리패칭 한 이후에도 다른 곳에서 쓸 때 똑같이 HydrationBoundary
을 해줘야 한다.
이말은 사용하는 페이지마다 프리패칭을 진행하기 위해 queryClient
를 새롭게 만들어야 하고, 하나의 데이터를 받기 위해 모든 캐시값을 갱신하는 불필요한 비용이 발생한다.
내가 할 수 있는 행동을 생각해봤다.
-
순응한다.
→ 그냥 프리패칭 하면서 새롭게
queryClient
를 만든다.→ 다 뜻이 있는겨~
-
응용한다.
→
HydrationBoundary
을 사용할 모든 페이지 상단에 배치해서 한 번만queryClient
를 새롭게 만든다.→ like 전역상태관리쓰~
-
개선한다.
→
queryClient
를 새롭게 만드는 대신 현재있는queryClient
와 비교해서 프리패칭 할 데이터만queryClient
에 추가해준다.→ TK dodo씨도 문제를 인식하고 있었다
도..도전..!!
Solution
이라고 적었지만 완벽하게 해결 한 것은 하나도 없다ㅎ(헤헤,,,난 말하는 감자당 헤헿)
아래의 과정은 solution 이라기보단 solution을 찾는 과정들이었다ㅎㅎ
1. 응집도를 올려보자!
먼저 첫번째 문제라고 생각했던 두 곳에서 수정을 해야하는 응집도 부분의 개선을 생각해봤다.
Q1. 이게.. 문제가 맞아?
먼저 Tanstack query는 SWR방식을 따르고 있다(간단히 말하면 없는것보단 오래된거 보여주는게 낫다)
만약 프리패칭과 실제 사용하는 것을 한번의 로직으로 합친다고 가정했을 때 아래와 같은 상황을 생각해보자
만약에 프리패칭 한 이후 실제로 사용하는 컴포넌트에서 사용하기 전에 데이터가 바뀐다면?
가장 최신 데이터를 보여 줄 수 없다. 따라서 SWR에 위배된다.
즉, 새롭게 변경될 수 있는 데이터는 프리패칭 했다고 해도 사용할 때 다시 요청을 보내 갱신하는게 맞다.
Q2. 그럼 문제인 경우는?
만약 프리패칭 이후 변경되지 않는 데이터라면?(단 한 번만 받아오면 되는 데이터라면)
이럴 경우 공식문서의 예제를 조금 변형해서 개선할 수 있다.
server component 프리패칭한 후 client components에서는 캐싱된 데이터를 쓰는 것이다.
"use client";
export default function Posts() {
const queryClient = useQueryClient();
const { data } = queryClient.getQueryData(["posts"]);
// ...
}
이렇게 함으로써 프리패칭 된 데이터를 사용해 매번 데이터를 호출하지 않아도 된다.
하지만 typescript를 쓴다면 문제가 있다.
queryClient.getQueryData
로 받아온 데이터는 캐시된 값의 타입 추론이 안되어 타입을 직접 지정해줘야 한다.
이를 해결할 수 있는 방식인 바로
queryOptions를 사용하는 방법이다.
// getPostsOptions.ts
export const getPostsOptions = () =>
queryOptions({ queryKey: ["posts"], queryFn: getPosts });
// server component
export default async function PostsPage() {
const queryClient = new QueryClient();
await queryClient.prefetchQuery(getPostsOptions());
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<Posts />
</HydrationBoundary>
);
}
// client component
("use client");
export default function Posts() {
const { data } = useQuery(getPostsOptions().queryKey);
// ...
}
v5에 새롭게 추가된 메서드로 퀴리에 들어가는 queryOptions
객체를 생성할 때 사용할 수 있다.
queryOptions가 뭔데?
queryOptions도 JS로 본다면 그냥 객체를 리턴해준다.
하지만 그냥 JS 객체로 생성하는 것과 queryOptions를 사용했을 때 달라지는 점은 바로 ‘타입을 추론’ 할 수 있다는 것이다.
그 이유는 queryOptions를 보면
// queryOptions.ts
export function queryOptions<
TQueryFnData = unknown,
TError = DefaultError,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey
>(
options: UndefinedInitialDataOptions<TQueryFnData, TError, TData, TQueryKey>
): UndefinedInitialDataOptions<TQueryFnData, TError, TData, TQueryKey> & {
queryKey: DataTag<TQueryKey, TQueryFnData>;
};
export function queryOptions<
TQueryFnData = unknown,
TError = DefaultError,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey
>(
options: DefinedInitialDataOptions<TQueryFnData, TError, TData, TQueryKey>
): DefinedInitialDataOptions<TQueryFnData, TError, TData, TQueryKey> & {
queryKey: DataTag<TQueryKey, TQueryFnData>;
};
export function queryOptions(options: unknown) {
return options;
}
단순히 options 값을 리턴해주지만 이 안에 DataTag
를 통해서 타입을 생성해준다.
DataTag
는 어떻게 동작하길래 이게 가능할까?
// queryClient-MRqjmcRa.d.ts
declare const dataTagSymbol: unique symbol;
type DataTag<TType, TValue> = TType & {
[dataTagSymbol]: TValue;
};
이와같이 제네릭 타입으로 받은 TType에 추가로 유니크한 심볼키의 value타입으로 Tvalue라는 제네릭 타입을 넣어 줌으로써 초기 unknown 이던 타입이 데이터가 들어오면 데이터에 맞게 타입을 추론해주는 것이다…!!(솔직히 완벽히 이해는 안됐다,,, TS실력이슈,,ㅜ)
이렇게 DataTag로 만들어진 queryOptions를 통해 getQueryData를 하게 되면
// QeuryKey
type QueryKey = ReadonlyArray<unknown>;
// getQueryData
getQueryData
<TQueryFnData = unknown,
TTaggedQueryKey extends QueryKey = QueryKey,
TInferredQueryFnData = TTaggedQueryKey extends DataTag<unknown, infer TaggedValue>
? TaggedValue
: TQueryFnData>
(queryKey: TTaggedQueryKey): TInferredQueryFnData | undefined;
IDE에서 타입을 비교해보면
queryOptions로 키값을 넣어줄 경우 캐싱 데이터에 맞게 타입추론이 되지만
그냥 JS 객체로 넣어줄 경우 unknown 타입으로 나오는 것을 확인할 수 있다.
이를 통해 두곳에서 수정을 해줘야했던 queryOptions을 한 곳으로 모음으로써 유지보수가 조금 더 편해졌다. 아주 간단한 코드지만 효과는 상당했다..!!!
→ 어? 이거라면…?? TS로 데이터를 받기전에 그 데이터로 타입을 만들고 싶으면?? (+ index signature) 여기서도 as 해결 가능하지 않을까..??
2. queryClient를 매번 새롭게 만들어야 하나?
내가 할 수 있는 행동 1번은 그냥 공식 문서 따르면 되니깐 pass~
2번은 리액트에서 state를 사용하는 것처럼 상단의 공통부모에다가 프리패칭을 하면된다.
자 바로 본론으로 들어가서… 개선 할 수 있는지 봐보자…!
개인적으로 개선해보기 위해 여러가지 시도를 해봤다.
⇒ 이문제는 TQ측에서도 인지하고 있는 문제로 이후 dehydrateNew를 제공할 예정이라고한다 ⇒ 나..나도 할 수 있다..!! (머리를 쥐어짜내봐!!!)
-
먼저 기존에 만들어 놓은 queryClient를 쓰면 안되나??
여기에는 두가지 문제점이 발생했다.
프리패칭을 위해서는
await
을 사용해야 하는데client component
에서는async/await
을 사용할 수 없다.'use client'; ... // useQueryClient로 기존의 queryClient 사용 -> 클라이언트 컴포넌트를 써야해서 async/await을 못씀. export async function Test() { const queryClient = useQueryClient(); await queryClient.prefetchQuery({ queryKey: ['prefetching'], queryFn: getPosts }); const dehydratedState = dehydrate(queryClient); return ( <div className="flex flex-col items-center gap-10"> <p>TEST</p> <HydrationBoundary state={dehydratedState}> <Prefetching /> </HydrationBoundary> </div> ); } export default Test;
근데 신기하게도 한 번은 작동하지만 다른 곳 갔다오면 에러를 뱉는다 (모징?)
이것을 피하기 위해 즉시실행 함수를 사용해봤다.
'use client'; ... export function Test() { const queryClient = useQueryClient(); (async () => await queryClient.prefetchQuery({ queryKey: ['prefetching'], queryFn: getPosts }))(); const dehydratedState = dehydrate(queryClient); return ( <div className="flex flex-col items-center gap-10"> <p>TEST</p> <HydrationBoundary state={dehydratedState}> <Prefetching /> </HydrationBoundary> </div> ); } export default Test;
하지만 이렇게 하면 프리패칭이 작동하지 않는다..!!
-
그러면 next의 내장 fetch로 프리패칭 후 한다면??
댓글남기기