(꼼꼼 프로젝트) streaming 방식의 tanstack query

Updated:

배경

꼼꼼 프로젝트에서는 tanstack query를 적극 활용하였다.

tanstack query가 서버 상태를 관리하는데는 좋지만 Next.js와 함께 사용할 때 리액트처럼 클라이언트 컴포넌트에서만 사용한다면 생기는 단점이 있다.

예를 들면 자유게시판 전체 페이지의 페이지 네이션을 생각해보자.

기존 CSR를 이용한 페이지네이션 방식의 단점

  1. Streaming을 이용한 Next.js 만의 fetch 방식을 사용할 수 없다.
  2. SSR시에 장점인 Hydration 이전의 HTML 구조를 가져와서 TTFB를 줄일 수 있다는 장점을 누릴 수 없다.
  3. SSR시 스켈레톤을 Suspense fallback으로도 넣을 수 있기 때문에 tanstack query의 isPending을 활용한 스켈레톤을 그대로 구현할 수 있다.

개인적으로는 Streaming 방식을 사용할 수 없는 것이 제일 치명적이라고 생각했다.

Promise.all을 이용한 병렬 fetch를 사용해도 비슷한 효과가 나오겠지만 최선이 아니기 때문에 결합할 수 있는 방법이 있다면 결합하고 방법이 없다면 SSR (Streaming) 과 Tanstack query (병렬 fetching) 사이의 규칙을 만들어서 둘다 쓰는 것이 좋아보였다.

구현

streaming 방식의 query

이를 해결하기 위해 찾아보던 중 공식문서에서 답을 찾게 되었다.

https://tanstack.com/query/latest/docs/framework/react/guides/advanced-ssr#streaming-with-server-components

읽어보면 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="mb-1 flex items-center gap-1 text-base font-medium text-text-primary">
          <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 이외에도 존재한다.

낙관적 업데이트가 그 중 하나인데 쉽게 로직을 설명해보면 다음과 같다.

  1. 현재 쿼리를 취소한다. (fetch 실행전)
  2. 현재 쿼리를 저장한다.
  3. 성공했다고 가정하고 쿼리를 변경시킨다.
  4. 쿼리가 성공하면 쿼리를 실제 데이터와 연결 (fetch 완료 이후)
    1. 쿼리가 실패하면 쿼리를 2번에서 저장된 쿼리로 되돌린다.

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

Streaming + 무한스크롤 + 낙관적 업데이트

고민을 해보니 위의 세가지 (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의 타입을 읽으려고 더 노력했던 것 같다.

결론적으로는 다음의 구조만 알면 쉬웠다.

  1. setQueryData<받는 데이터, 쿼리 키, 주는 데이터>
  2. 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-l border-border-primary border-opacity-10 pl-4 text-xs font-medium leading-3 text-text-disabled md:text-sm md:leading-[14px]"
    suppressHydrationWarning
    >
    {timeDiff}{" "}
    {comment.updatedAt !== comment.createdAt && "(수정됨)"}
</time>

후기

Tanstack Query에 푹 빠져서 쿼리 관리를 했던 소중한 경험이었다.

귀찮고 반복적인 일은 전부 함수로 만들어 놓은 개발의 세계에서 당연히 있겠지 싶었던 기능들의 집합체가 Tanstack query지 않나 싶었다.

새로운 기술을 배우는 건 늘 트러블 슈팅이 따르지만 그걸 해내는 쾌감이 너무 좋아서 계속 배우게 되는 것 같다.

더 성장해보자.

꼼꼼 프로젝트 진행 중에 겪은 경험들입니다. 오늘도 많이 성장한 것 같아요!

읽어주셔서 감사합니다! 지켜봐주세요!

감사합니다.

Categories:

Updated:

Leave a comment