이전에 만들었던 공통 API 함수를 리펙토링 하였다.
현재 대시보드 페이지는 이렇게 생겼다.
대시보드는 4개의 주요 컴포넌트로 이루어져 있고, 각각의 컴포넌트가 별도의 API 요청을 수행하여 데이터를 가져오도록 되어 있다.
export default function DashBoardPage() {
return (
<>
<Header title="대시보드" />
<PageContainer>
<Follower />
<RecentTodos />
<MyProgress />
<GoalList />
</PageContainer>
</>
);
}
기존에는 isLoading
을 활용하여 데이터를 불러오는 동안 로딩 UI (스켈레톤 UI)를 보여주고 있다. 하지만 이 방식에는 두가지 문제가 있다.
1. 클라이언트에서만 로딩 상태를 관리해야 한다.
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를 활용하는 방식으로 리팩토링하려고 한다.
먼저, 최근 등록한 할일을 불러오는 useRecentTodosQuery
는 useQuery
를 사용해 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 };
};
여기 useQuery
를 useSuspenseQuery
로만 변경하기만 하면 된다.
하지만 에러가 발생했다.
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에서 데이터를 미리 패칭하는 방식을 적용해보려고 한다.
먼저 이 블로그를 참고해 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에 대해 더 깊이 이해할 수 있었다.
아직 해결하지 못한 에러를 수정하기 위해 더 많은 학습이 필요하다는 것을 느꼈고 문제를 해결하는 능력을 키우기 위해 지속적으로 공부해야겠다.