Tanstack Query와 SSR을 활용한 대시보드 페이지 리팩토링

유의진·2025년 3월 8일
0

찍찍이

목록 보기
6/6

이전에 만들었던 공통 API 함수를 리펙토링 하였다.

대시보드 페이지

현재 대시보드 페이지는 이렇게 생겼다.

대시보드는 4개의 주요 컴포넌트로 이루어져 있고, 각각의 컴포넌트가 별도의 API 요청을 수행하여 데이터를 가져오도록 되어 있다.

export default function DashBoardPage() {
  return (
    <>
      <Header title="대시보드" />
      <PageContainer>
        <Follower />
        <RecentTodos />
        <MyProgress />
        <GoalList />
      </PageContainer>
    </>
  );
}

현재 문제점

기존에는 isLoading을 활용하여 데이터를 불러오는 동안 로딩 UI (스켈레톤 UI)를 보여주고 있다. 하지만 이 방식에는 두가지 문제가 있다.

1. 클라이언트에서만 로딩 상태를 관리해야 한다.

  • 서버에서 렌더링된 화면에는 데이터가 포함되지 않기 때문에, 브라우저가 API 요청을 보내 데이터를 가져올 때까지 빈 화면이 표시된다.

2. 코드의 가독성이 저해된다는 점이다.

  • isLoading이 컴포넌트 내부 곳곳에서 사용되고 있어, 코드가 한눈에 이해되지 않을 수 있다.

이를 해결하기 위해 useSuspenseQuery를 활용한다면 SSR에서 데이터를 미리 패칭하여 첫 화면부터 데이터를 포함할 수 있고, 코드도 더 간결해진다.

최근 등록한 할일 부분 적용

이 중에서 최근 등록한 할일을 담당하는 RecentTodos 컴포넌트는 다음과 같이 구현되어 있다.

'use client';

// ...

export const RecentTodos = () => {
  const { todos, isLoading } = useRecentTodosQuery();
  const { goals } = useGoalsQuery();

  const hasTodos = todos.length > 0;
  const hasGoals = goals.length > 0;

  return (
    <DashboardItemContainer title="최근 등록한 할일" className="relative">
      {isLoading && <TodoListSkeleton />}

      {!isLoading && !hasGoals && (
		// ...
      )}

      {!isLoading && hasGoals && !hasTodos && (
        // ...
      )}

      {!isLoading && hasGoals && hasTodos && (
		// ...
      )}
    </DashboardItemContainer>
  );
};

다른 컴포넌트에서도 같은 방식으로 로딩 상태를 관리하고 있어, isLoading을 사용하지 않고 Suspense를 활용하는 방식으로 리팩토링하려고 한다.

먼저, 최근 등록한 할일을 불러오는 useRecentTodosQueryuseQuery를 사용해 API 요청을 보낸다.

// ...

export const recentTodosOptions = (): UseQueryOptions<
  RecentTodosResponse,
  AxiosError
> => ({
  queryKey: [QUERY_KEYS.RECENT_TODOS],
  queryFn: () =>
    GET<RecentTodosResponse>(
      `${API_ENDPOINTS.TODOS.GET_ALL}?lastTodoId=0&size=3`,
    ),
});

export const useRecentTodosQuery = () => {
  const { data, ...etc } = useQuery(recentTodosOptions());
  const todos = data?.data.content ?? [];

  return { todos, ...etc };
};

여기 useQueryuseSuspenseQuery로만 변경하기만 하면 된다.

하지만 에러가 발생했다.

Uncaught Error: Switched to client rendering because the server rendering errored: document is not defined

이 에러는 서버 렌더링 중에서 document 객체를 참조면서, 강제로 클라이언트 렌더링으로 변경되었다는 의미이다.

그 원인은 useSuspenseQuery로 변경하면서 브라우저에서만 접근 가능한 document.cookie를 SSR에서도 호출하려고 했기 때문이다.

현재 프로젝트에서는 axiosInstance를 커스텀하여 사용하고 있었고, 토큰 값을 헤더에 주입하기 위해서 브라우저의 쿠키에서 값을 가져오고 있었다.
이 작업에서 document.cookie를 사용했기 때문에 SSR 환경에서는 오류가 발생한 것이다.

이를 해결하기 위해, 서버에서는 document 객체를 참조할 수 없도록 클라이언트 환경에서만 토큰을 주입할 수 있도록 수정했다.

axiosInstance.interceptors.request.use(
  (config) => {
    if (typeof window !== 'undefined') {
		// 쿠키 설정 코드
    }

    return config;
  },
  (error) => {
    return Promise.reject(error);
  },
);

그렇지만 이렇게 해도 여전히 같은 에러가 발생했다.

이제 Tanstack Query의 Hydration API를 활용해서 SSR에서 데이터를 미리 패칭하는 방식을 적용해보려고 한다.

Hydration API 적용

먼저 이 블로그를 참고해 ServerFetchBoundary라는 컴포넌트를 만들었다.

https://velog.io/@sik02/Next.js에서-react-query를-왜-써

SSR은 적용했지만, 새로고침 했을 때 기대했던 결과가 나오지 않았다.
클라이언트에서 요청했을 때와 동일하게 로딩 UI가 나타나고 SSR이 적용되었음에도 UI가 거의 같은 속도로 렌더링되었다..

이유를 찾아보니, RecentTodos 컴포넌트에 목표(goals)를 받아오는 쿼리도 있었고 이 부분은 여전히 useQuery를 사용하고 있었다. useSuspenseQuery로 변경한 후에 적용이 되었다.

로딩의 결과는 이렇게 나타난다.

변경 전

변경 후

내 진행 상황 부분 적용

MyProgress 컴포넌트에도 SSR을 적용했다.

하지만 이 컴포넌트는 애니메이션이 포함되어 있었다. 애니메이션이 useEffect에서 실행되기 때문에 SSR에서는 애니메이션이 동작하지 않았고, 클라이언트 렌더링과 차이가 없었다.

다시 클라이언트 렌더링 방식으로 되돌렸다.

팔로워 현황, 목표 별 할 일 부분 적용

이 두 컴포넌트는 무한 스크롤을 위한 useInfiniteQuery가 적용되어 있다.

useSuspenseInfiniteQuery로 변경하고 위에서 했던 작업과 비슷하게 적용해보았지만, 에러가 발생하였다.

TypeError: Cannot read properties of undefined (reading 'length')

// ...

export const todosOfGoalsOptions = (
  token?: string,
): UseSuspenseInfiniteQueryOptions<
  TodosOfGoalsResponse,
  AxiosError,
  BaseInfiniteQueryResponse<TodosOfGoalsResponse[]>
> => ({
  queryKey: [QUERY_KEYS.TODOS_OF_GOALS],
  queryFn: ({ pageParam = 0 }) =>
    GET<TodosOfGoalsResponse>(
      `${API_ENDPOINTS.TODOS.GET_GOALS}?lastGoalId=${pageParam}&size=5`,
      token,
    ),
  getNextPageParam: (lastPage) => {
    const nextCursor = lastPage.data.nextCursor;
    return nextCursor !== 0 ? nextCursor : undefined;
  },
  initialPageParam: 0,
});

export const useTodosOfGoalsQuery = () => {
  const { data, ...etc } = useSuspenseInfiniteQuery(todosOfGoalsOptions());
  const goals = data?.pages.flatMap((page) => page.data.content) ?? [];

  return { goals, ...etc };
};

에러가 getNextPageParam에서 발생했다. 처음에는 서버에서 받아온 데이터의 타입과 getNextPageParam이 요구하는 타입이 다르다고 생각해, 받아온 데이터의 형태를 수정하고 다시 해보았지만 해결되지 않았다.

그래서 getNextPageParam의 타입을 확인해보았다.

// getNextPageParam
interface InfiniteQueryPageParamsOptions<TQueryFnData = unknown, TPageParam = unknown> extends InitialPageParam<TPageParam> {
    /**
     * This function can be set to automatically get the previous cursor for infinite queries.
     * The result will also be used to determine the value of `hasPreviousPage`.
     */
    getPreviousPageParam?: GetPreviousPageParamFunction<TPageParam, TQueryFnData>;
    /**
     * This function can be set to automatically get the next cursor for infinite queries.
     * The result will also be used to determine the value of `hasNextPage`.
     */
    getNextPageParam: GetNextPageParamFunction<TPageParam, TQueryFnData>;
}

// GetNextPAgeParamFuction
type GetNextPageParamFunction<TPageParam, TQueryFnData = unknown> = (lastPage: TQueryFnData, allPages: Array<TQueryFnData>, lastPageParam: TPageParam, allPageParams: Array<TPageParam>) => TPageParam | undefined | null;

하지만, 내가 발생한 에러와 공식 타입의 정의가 달라, 문제 해결이 쉽지 않았다.
결국은 클라이언트 렌더링 방식을 유지하기로 했다. 이후에 더 깊이 공부한 후에 다시 적용해볼 예정이다.

결론

대시보드 페이지에서 최근 등록한 할일 부분만 서버사이드 렌더링(SSR)을 적용하고, 나머지 부분은 클라이언트 렌더링(CSR)을 유지했다.

SSR과 SCR의 차이점인 초기 로딩 속도에서 큰 차이를 체감할 수 있었다. 각 기술의 장단점을 비교하며 프로젝트에 어떻게 적용할지에 대한 인사이트를 얻을 수 있었다.

로딩 속도를 개선한 경험은 사용자 경험의 중요성을 다시 한번 깨닫게 해주었고 문제 해결 과정에서 Tanstack Query와 SSR에 대해 더 깊이 이해할 수 있었다.

아직 해결하지 못한 에러를 수정하기 위해 더 많은 학습이 필요하다는 것을 느꼈고 문제를 해결하는 능력을 키우기 위해 지속적으로 공부해야겠다.

profile
안녕하세요. 프론트엔드 개발 공부를 하고 있습니다.

0개의 댓글

관련 채용 정보