
Codefug Blog
infinite scroll, optimistic update 그리고 streaming 방식이 결합된 쿼리 처리 ( useSuspenseQuery, useSuspenseInfiniteQuery )
꼼꼼 프로젝트에서는 tanstack query를 적극 활용하였다.
tanstack query가 서버 상태를 관리하는데는 좋지만 Next.js와 함께 사용할 때 리액트처럼 클라이언트 컴포넌트에서만 사용한다면 생기는 단점이 있다.
예를 들면 자유게시판 전체 페이지의 페이지 네이션을 생각해보자.
개인적으로는 Streaming 방식을 사용할 수 없는 것이 제일 치명적이라고 생각했다.
Promise.all을 이용한 병렬 fetch를 사용해도 비슷한 효과가 나오겠지만 최선이 아니기 때문에 결합할 수 있는 방법이 있다면 결합하고 방법이 없다면 SSR (Streaming) 과 Tanstack query (병렬 fetching) 사이의 규칙을 만들어서 둘다 쓰는 것이 좋아보였다.
이를 해결하기 위해 찾아보던 중 공식문서에서 답을 찾게 되었다.
읽어보면 streaming 방식과 tanstackquery를 동시에 쓸 수 있는 단서를 찾을 수 있다.
prefetchQuery 자체를 SSR로 생각해서 스켈레톤을 먼저 던지고 그 다음에 prefetchQuery가 끝나면 해당되는 컴포넌트가 렌더링되는 방식으로 구현된다.
이로써 tanstack query의 이점과 streaming 방식을 결합할 수 있게 된 것이다.
문서를 참고해서 queryClient를 처리해준 후
useQuery > useSuspenseQuery
export function useArticleQuery(articleId: number) {
return useSuspenseQuery({
queryKey: ["article", { articleId }],
queryFn: () => getArticlesArticleId({ articleId }),
staleTime: 2000,
});
}
prefetchQuery를 사용하는 queryClient를 dehydrate해서 껍데기로 만든 후 내려준다.
Suspense로 감싸져 있는 컴포넌트들은 내부에서 사용하는 query가 fetch되기 전까지 fallback이 그 자리를 대신하게 된다. (Streaming 방식)
export default async function Page({ params: { boardId } }: Props) {
const articleId = Number(boardId);
const queryClient = getQueryClient();
queryClient.prefetchInfiniteQuery({
queryKey: ["comments", { articleId }],
queryFn: ({ pageParam }) =>
getArticlesArticleIdComments({ cursor: pageParam, articleId }),
initialPageParam: 0,
});
queryClient.prefetchQuery({
queryKey: ["getUser"],
queryFn: getUser,
});
queryClient.prefetchQuery({
queryKey: ["article", { articleId }],
queryFn: () => getArticlesArticleId({ articleId }),
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<GtmPageView pageTitle={`boardId: ${boardId}`} />
<section className="border-b border-black border-opacity-10 dark:border-b dark:border-white dark:border-opacity-10">
<header className="text-text-primary mb-1 flex items-center gap-1 text-base font-medium">
<Image src="/icons/medal.svg" alt="Medal" width={16} height={16} />
<h2 className="selection:bg-inherit">베스트 랭킹</h2>
</header>
<Suspense fallback={<SkeletonRankingChart />}>
<ArticleRankingChart />
</Suspense>
</section>
<Suspense fallback={<SkeletonArticleHeader />}>
<ArticleHeader articleId={articleId} />
</Suspense>
<Suspense fallback={<SkeletonArticleContent />}>
<ArticleContent articleId={articleId} />
</Suspense>
<Suspense fallback={<SkeletonArticleLikeSection />}>
<LikeSection articleId={articleId} />
</Suspense>
<Suspense fallback={<SkeletonCommentForm />}>
<CommentForm articleId={articleId} />
</Suspense>
<Suspense fallback={<SkeletonCommentList />}>
<CommentsList articleId={articleId} />
</Suspense>
</HydrationBoundary>
);
}
위 영상은 아래 코드로 통신 처리를 늘린 영상이다.
new Promise((r) => setTimeout(r, ms));
페이지네이션에 Suspense가 적용되지 않는 버그가 있었다.
SearchParams가 변경되는 것을 Suspense가 읽지 못하여 Streaming 방식의 렌더링이 작동하지 않는 것인데
Suspense 자체에 SeachParams를 key로 넣어서 각 Suspense가 다른 것으로 인식하도록 하여 문제를 해결하였다.
<Suspense key={JSON.stringify(searchParams)} fallback={<SkeletonCardList />}>
<ArticlesList searchParams={searchParams} />
</Suspense>
UX를 향상시키는 방법에는 Streaming 이외에도 존재한다.
낙관적 업데이트가 그 중 하나인데 쉽게 로직을 설명해보면 다음과 같다.
fetch 실행전은 onMutation, fetch 완료 이후는 onError, onSettled를 이용해서 구현한다.
쉽게 생각해보면 좋아요 버튼을 예로 들 수 있다.
좋아요 버튼을 아무리 연타해도 좋아요, 좋아요 취소가 느려진 경험은 없을 것이다.
이번 프로젝트에서도 실제로 좋아요 버튼을 구현해야 했다.
다음의 코드를 보면 위에서 설명한 로직이 잘 담겨있는 것을 확인할 수 있다.
export function useHandleArticleLikeMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ articleId, isLiked }: HandleArticleLikeMutationProps) =>
isLiked
? deleteArticlesArticleIdLike({ articleId })
: postArticlesArticleIdLike({ articleId }),
onMutate: async ({ articleId, isLiked }) => {
// 쿼리 취소
await queryClient.cancelQueries({
queryKey: ["article", { articleId }],
});
// 현재 쿼리 가져옴.
const previousArticle =
queryClient.getQueryData<GetArticlesArticleIdResponse>([
"article",
{ articleId },
]);
// 됐다고 가정하고 쿼리 수정
queryClient.setQueryData<
GetArticlesArticleIdResponse,
[string, { articleId: number }],
GetArticlesArticleIdResponse
>(["article", { articleId }], (input) =>
input
? {
...input,
isLiked: isLiked === false,
likeCount:
isLiked === false ? input.likeCount + 1 : input.likeCount - 1,
}
: input,
);
return { previousArticle };
},
// 취소시 원래 쿼리로 바꿈
onError: (error, { articleId }, context) => {
if (context?.previousArticle) {
queryClient.setQueryData<GetArticlesArticleIdResponse>(
["article", { articleId }],
context.previousArticle,
);
}
},
// 성공, 에러 어느쪽이든 실제 데이터와 연결
onSettled: (data, error, { articleId }) => {
queryClient.invalidateQueries({
queryKey: ["article", { articleId }],
});
},
});
}
결과적으로 아무리 좋아요를 눌러도 느리게 동작하지 않는 것을 확인할 수 있다.
무한스크롤은 특정 부분이 감지되면 fetch를 보내는 로직을 활용해서 마치 스크롤이 무한으로 있는 것처럼 데이터를 늘려나가는 방식이다.
프론트엔드 환경에서는 굉장히 흔한 패턴이기 때문에 Tanstack query에서는 이를 지원해주는 훅을 갖고 있다.
const {
data: postsData,
isPending,
isError,
fetchNextPage,
} = useInfiniteQuery({
queryKey: ["posts"],
queryFn: ({ pageParam }) => getPosts(pageParam, PAGE_LIMIT),
initialPageParam: 0,
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) =>
lastPage.hasMore ? lastPageParam + 1 : undefined,
});
fetchNextPage
라는 함수를 이용해서 fetch를 보내고 getNextPageParam을 이용해서 마지막인지를 판별한다.
하지만 이는 Suspense를 사용할 수는 없는데 다행히 Tanstack Query에서는 Suspense가 첨가된 InfiniteQuery인 useSuspenseInfiniteQuery를 지원해주고 있었다.
https://tanstack.com/query/latest/docs/framework/react/guides/migrating-to-v5#new-hooks-for-suspense
고민을 해보니 위의 세가지 (suspense, 무한스크롤, 낙관적 업데이트) 를 모두 적용할 수 있는 부분이 댓글 이라는 생각이 들었다.
댓글은 streaming방식과 무한스크롤 방식으로 구현하면 좋고 이를 수정, 추가, 삭제할 때 낙관적 업데이트가 적용된다면 엄청난 UX 향상이 있지 않나 싶었다.
제일 까다로운건 타입스크립트였다 (안에까지 들어가서 분석하면서 만드는게 참 쉽지 않았다...)
export function usePostCommentsMutation() {
const queryClient = useQueryClient();
const mockTime = new Date().toISOString();
return useMutation({
mutationFn: ({ articleId, content }: PostCommentsMutation) =>
postArticlesArticleIdComments({
articleId,
data: { content },
}),
onMutate: async ({ articleId, content, id, image, nickname }) => {
await queryClient.cancelQueries({
queryKey: ["comments", { articleId }],
});
const previousComments = queryClient.getQueryData<
InfiniteData<GetArticlesArticleIdCommentsResponse>
>(["comments", { articleId }]);
if (previousComments) {
queryClient.setQueryData<
InfiniteData<
GetArticlesArticleIdCommentsResponse,
GetArticlesArticleIdCommentsResponse["nextCursor"]
>,
[string, { articleId: number }],
InfiniteData<
GetArticlesArticleIdCommentsResponse,
GetArticlesArticleIdCommentsResponse["nextCursor"]
>
>(
["comments", { articleId }],
(
input:
| InfiniteData<
GetArticlesArticleIdCommentsResponse,
GetArticlesArticleIdCommentsResponse["nextCursor"]
>
| undefined,
) => {
if (!input) {
return {
pages: [
{
nextCursor: 0,
list: [
{
writer: {
image,
nickname,
id,
},
updatedAt: mockTime,
createdAt: mockTime,
content,
id: -1,
},
],
},
],
pageParams: [0],
};
}
return {
pages: [
{
nextCursor: input.pages[0].nextCursor,
list: [
{
writer: {
image,
nickname,
id,
},
updatedAt: mockTime,
createdAt: mockTime,
content,
id: -1,
},
...input.pages[0].list,
],
},
...input.pages.slice(1),
],
pageParams: input.pageParams,
};
},
);
}
return { previousComments };
},
onError: (error, variables, context) => {
showToast("error", "댓글 등록에 실패했습니다.");
if (context?.previousComments) {
queryClient.setQueryData<
InfiniteData<GetArticlesArticleIdCommentsResponse>
>(
["comments", { articleId: variables.articleId }],
context.previousComments,
);
}
},
onSettled: (data, error, variables) => {
queryClient.invalidateQueries({
queryKey: ["comments", { articleId: variables.articleId }],
});
queryClient.invalidateQueries({
queryKey: ["article", { articleId: variables.articleId }],
});
},
});
}
중요한건 useInfiniteQuery를 거친 데이터(쿼리)는 서버에서 주는 데이터 구조와는 다르다는 것
pages 단위의 배열과 pageParams를 가진 객체가 최상단 구조이다.
결론적으로 pages[0]
부분만 바로 낙관적 업데이트 시켜주면 된다.
추가 로직을 구현했으니 삭제, 수정 로직도 비슷하게 구현할 수 있다.
댓글 삭제의 코드는 다음과 같다.
export function useDeleteCommentsMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ commentId }: DeleteCommentsMutation) =>
deleteCommentsCommentId({
commentId,
}),
onMutate: async ({ articleId, commentId }) => {
await queryClient.cancelQueries({
queryKey: ["comments", { articleId }],
});
const previousComments = queryClient.getQueryData<
InfiniteData<GetArticlesArticleIdCommentsResponse>
>(["comments", { articleId }]);
queryClient.setQueryData<
| InfiniteData<
GetArticlesArticleIdCommentsResponse,
GetArticlesArticleIdCommentsResponse["nextCursor"]
>
| undefined,
[string, { articleId: number }],
InfiniteData<
GetArticlesArticleIdCommentsResponse,
GetArticlesArticleIdCommentsResponse["nextCursor"]
>
>(["comments", { articleId }], (input) => {
if (!input) {
return input;
} else {
return {
pages: input.pages.map((page) => ({
nextCursor: page.nextCursor,
list: page.list.filter((comment) => comment.id !== commentId),
})),
pageParams: input.pageParams,
};
}
});
return { previousComments };
},
onError: (error, variables, context) => {
if (context?.previousComments) {
queryClient.setQueryData<
InfiniteData<GetArticlesArticleIdCommentsResponse>
>(
["comments", { articleId: variables.articleId }],
context.previousComments,
);
}
},
onSettled: (data, error, variables) => {
queryClient.invalidateQueries({
queryKey: ["comments", { articleId: variables.articleId }],
});
},
});
}
댓글 수정 코드
export function usePatchCommentsMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ commentId, data }: PatchCommentsMutation) =>
patchCommentsCommentId({ data, commentId }),
onMutate: async ({ articleId, data: { content }, commentId }) => {
await queryClient.cancelQueries({
queryKey: ["comments", { articleId }],
});
const previousComments = queryClient.getQueryData<
InfiniteData<GetArticlesArticleIdCommentsResponse>
>(["comments", { articleId }]);
queryClient.setQueryData<
InfiniteData<
GetArticlesArticleIdCommentsResponse,
GetArticlesArticleIdCommentsResponse["nextCursor"]
>,
[string, { articleId: number }],
InfiniteData<
GetArticlesArticleIdCommentsResponse,
GetArticlesArticleIdCommentsResponse["nextCursor"]
>
>(["comments", { articleId }], (input) => {
if (!input) {
return input;
}
return {
pages: input.pages.map((page) => ({
nextCursor: page.nextCursor,
list: page.list.map((comment) => {
if (comment.id === commentId) {
return {
...comment,
updatedAt: new Date().toISOString(),
content,
};
}
return comment;
}),
})),
pageParams: input.pageParams,
};
});
return { previousComments };
},
onError: (error, { articleId }, context) => {
showToast("error", "댓글 수정에 실패했습니다.");
if (context?.previousComments) {
queryClient.setQueryData<
InfiniteData<GetArticlesArticleIdCommentsResponse>
>(["comments", { articleId }], context.previousComments);
}
},
onSettled: (data, error, { articleId }) => {
queryClient.invalidateQueries({
queryKey: ["comments", { articleId }],
});
},
});
}
사실 코드 구현보다도 setQueryData와 InfiniteData의 타입을 읽으려고 더 노력했던 것 같다.
결론적으로는 다음의 구조만 알면 쉬웠다.
setQueryData<받는 데이터, 쿼리 키, 주는 데이터>
InfiniteData<전체 데이터 구조, pageParam의 구조>
생각해보니 댓글을 올리는 mutation이 pending 상태일 때나 그것과 연결된 쿼리가 아직 fetch하는 중일 때 댓글을 날려버리면 mutation이 엉켜서 문제가 발생한다.
네이버는 댓글 관련 문제를 어떻게 해결하는지 보기 위해 네이버 웹툰 댓글을 같이 확인하면서 개발 중이었는데 거기선 이 문제를 연속 입력시 도배 방지로 댓글을 막는 것으로 해결한 것 같았다.
댓글 관련 infiniteQuery를 가져와서 isFetching (query가 fetch중인지 확인) 을 읽고, mutation의 isPending (mutation 실행 중인 상태 확인)을 읽어서 제한 조건에 해당하면 alert를 꺼내도록 로직을 구현해서 문제를 해결하였다.
export default function CommentForm({ boardId }: ArticleCommentProps) {
const { register, handleSubmit, setValue } = useForm({
mode: "onSubmit",
resolver: yupResolver(schema),
});
const articleId = Number(boardId);
const { data: user } = useUserQuery();
const { isFetching } = useArticlesCommentsQuery(articleId);
const { mutate, isPending } = usePostCommentsMutation();
const onSubmit: SubmitHandler<CommentForm> = (formData) => {
if (isFetching || isPending) {
alert(
"도배 방지를 위하여 글을 입력한 후 일정시간 동안 추가입력을 제한하고 있습니다.",
);
return;
}
if (!user) {
alert("잠시 후에 다시 시도해주세요.");
return;
}
mutate({
articleId,
content: formData.content,
id: user.id,
image: user.image,
nickname: user.nickname,
});
setValue("content", "");
};
const onInvalid = (error: any) => {
alert(error.content.message);
};
댓글 수정을 구현할 때 하이드레이션 에러가 발생했다.
time때문에 발생한 것인데 서버에서 dehydrate된 query에서의 time과 클라이언트 단에서 나온 time이 달라서 발생한 문제였다.
new Date()로 구현되어 있기 때문에 다를 수 밖에 없었다.
이를 해결하기 위해 suppressHydrationWarning 이용해서 SSR 제거하였고 낙관적 업데이트를 정상적으로 적용할 수 있었다.
<time
className="border-border-primary text-text-disabled border-l border-opacity-10 pl-4 text-xs font-medium leading-3 md:text-sm md:leading-[14px]"
suppressHydrationWarning
>
{timeDiff} {comment.updatedAt !== comment.createdAt && "(수정됨)"}
</time>
Tanstack Query에 푹 빠져서 쿼리 관리를 했던 소중한 경험이었다.
귀찮고 반복적인 일은 전부 함수로 만들어 놓은 개발의 세계에서 당연히 있겠지 싶었던 기능들의 집합체가 Tanstack query지 않나 싶었다.
새로운 기술을 배우는 건 늘 트러블 슈팅이 따르지만 그걸 해내는 쾌감이 너무 좋아서 계속 배우게 되는 것 같다.
더 성장해보자.
꼼꼼 프로젝트 진행 중에 겪은 경험들입니다. 오늘도 많이 성장한 것 같아요!
읽어주셔서 감사합니다! 지켜봐주세요!
감사합니다.