3 분 소요

실험 공고 목록을 보여줄 때 필터링 기능이 존재하고, 필터는 쿼리 파라미터로 관리하고 있어요.
pre-rendering 되어 컴포넌트가 HTML에 그려져서 응답받으면, 로딩 → 전체 데이터 → 필터링된 데이터로 UI 변경이 발생하여 UX에 악영향을 미쳐요.
CSR로 필터링을 처리했을 때 깜박임 또는 로딩 UI가 길어지는 문제가 발생하여, SSR로 필터링된 데이터를 빠르게 보여주어 UX를 개선했어요.

🔥 결론

홈화면을 SSR로 그리면서 쿼리 파라미터로 필터링된 데이터를 사용자에게 빠르게 제공할 수 있게 되었어요.

CSR SSR

🚨 문제 상황

앞서 말했듯이 필터링된 데이터를 사용자에게 빠르게 보여줘야하는데 클라이언트에서 처리했더니 지연이 발생했어요. 사용자는 아래 3가지 상황에서 필터링된 데이터를 보게 돼요.

  1. 사용자가 직접 필터를 추가하는 경우
  2. 참여자로 로그인한 직후 또는 홈화면으로 들어올 때 참여자 로그인 정보가 있는 경우
  3. 필터링이 걸린 상태에서 새로고침하는 경우

위 3가지 상황 중에 1번은 상호작용에 의한 데이터 변경, 2번과 3번은 페이지 로드 시점의 데이터 변경이예요. 따라서 1번은 문제가 없지만, 2번과 3번 상황은 페이지가 로드될 때 노출되어야 하는 정보가 필터링에 따라 달라져야 해요.

nextjs app router에선 서버 컴포넌트가 디폴트이기 때문에, pre-rendering 되어 HTML에 컴포넌트가 그려져서 넘어와요.
이때 2번과 3번처럼 필터링 정보에 따라 다른 데이터를 노출해야 하는 상황을 클라이언트에서 처리하는 경우 깜박임이 발생해요. 전체 데이터를 노출했다가 쿼리 파라미터가 변경되고 필터링된 데이터를 노출하는거죠.

  1. HTML pre-rendering (로딩 UI)
  2. 공고 목록 API 요청 (전체 데이터)
  3. 유저 정보 API 요청 + 쿼리 파라미터 업데이트 (전체 데이터)
  4. 참여자 정보 기반으로 공고 목록 API 요청 (전체 데이터)
  5. 전체 페이지 로딩 완료 (필터링된 데이터)

그래서 잘못된 데이터를 노출하는 것보단 로딩 UI를 보여주기로 결정했어요.
이러다 보니 유저 정보 응답을 기다린 후 공고 목록 조회가 완료되어야 사용자는 필터링된 데이터를 볼 수 있어요.

  1. HTML pre-rendering (로딩 UI)
  2. 유저 정보 API 요청 + 쿼리 파라미터 업데이트 (로딩 UI)
  3. 참여자 정보 기반으로 공고 목록 API 요청 (로딩 UI)
  4. 전체 페이지 로딩 완료 (필터링된 데이터)
처음 HTML을 내려줄 때 필터링된 데이터를 내려받을 순 없을까? → SSR

✅ 문제 해결

페이지 레벨의 Home 컴포넌트를 서버 컴포넌트로 처리하고, SSR을 적용했어요.

  1. Home 컴포넌트에서 HTTP request URL을 파싱하여 주입받는 searchParams으로 필터 정보를 얻고, next-auth의 session으로 인증 정보를 확인해요.
  2. 인증 정보를 확인하여 참여자면 유저 정보를 가져와 자동 적용될 필터값을 구하고, 이를 기반으로 공고 목록을 조회해요.
  3. 하이드레이션 이후 추가 네트워크 요청이 발생하지 않도록 prefetchQuery로 주입해요.
// src/app/home/page.tsx
interface HomePageProps {
  searchParams: {
    [k in keyof ExperimentPostListFilters]?: string;
  };
}

export default async function Home({ searchParams }: HomePageProps) {
  const session = await getServerSession(authOptions);
  const queryClient = getQueryClient();
  const fetchClient = createSSRFetchClient(session?.accessToken);
  const hasQueryParams = Object.keys(searchParams).length > 0;

  const initialUserInfo =
    session?.role === Role.participant
      ? await fetchClient.get<ParticipantResponse | ResearcherResponse>(
          API_URL.me(session.role.toLowerCase())
        )
      : null;

  const initialGender =
    initialUserInfo && isParticipantInfo(initialUserInfo)
      ? initialUserInfo.gender
      : undefined;
  const initialAge =
    initialUserInfo && isParticipantInfo(initialUserInfo)
      ? calculateAgeFromBirthDate(initialUserInfo.birthDate)
      : undefined;

  const parsedParamsResult = URLFilterSchema().safeParse({
    gender: hasQueryParams ? undefined : initialGender,
    age: hasQueryParams ? undefined : initialAge,
    ...searchParams,
  });

  const filters: ExperimentPostListFilters = parsedParamsResult.success
    ? { ...parsedParamsResult.data, count: POST_PER_PAGE }
    : { recruitStatus: "ALL", count: POST_PER_PAGE };

  const queryParams = getQueryParamsToString({ ...filters });
  const initialPosts = await fetchClient.get<ExperimentPostResponse>(
    API_URL.postList(queryParams),
    {
      requireAuth: false,
      next: { tags: ["experiment-posts"] },
    }
  );

  if (session?.role) {
    await queryClient.prefetchQuery({
      queryKey: queryKey.userInfo(session.role),
      queryFn: () => Promise.resolve(initialUserInfo),
    });
  }

  await queryClient.prefetchInfiniteQuery({
    queryKey: queryKey.post(filters),
    queryFn: () => Promise.resolve(initialPosts),
    initialData: {
      pages: [initialPosts],
      pageParams: [1],
    },
    initialPageParam: 1,
  });

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <DefaultLayout>
        <Banner />
        <ExperimentPostContainer
          initialPosts={initialPosts}
          initialGender={hasQueryParams ? undefined : initialGender}
          initialAge={hasQueryParams ? undefined : initialAge}
        />
      </DefaultLayout>
    </HydrationBoundary>
  );
}

🥕 효과

Next.js에서 앱 전체를 렌더링할 때의 플로우를 다시 정리해봤어요.

  1. 서버에서 전체 앱에 대한 데이터를 가져온다.
  2. 서버에서 전체 앱을 HTML로 렌더링하고 응답으로 보낸다. (pre-rendering)
  3. 클라이언트에서 전체 앱에 대한 JS 번들을 로드한다.
  4. 응답받은 HTML에 이벤트 핸들러가 부착되어 상호작용할 수 있는 화면이 만들어진다. (hydration)
  5. 클라이언트 로직을 수행한다.

쿼리파라미터 필터링 로직을 수행할 때, SSR을 적용하면 1번에서 필터링된 데이터를 가져오고 2번에서 필터링된 데이터를 바로 그릴 수 있어요.
반면 CSR로 수행하면 1~4번까지 수행하고 5번에서 유저 정보 응답을 기다린 후 공고 목록 조회가 완료되어야 사용자는 필터링된 데이터를 볼 수 있어요.

SSR은 기존보다 1번 과정의 시간은 좀더 늘어나겠지만 서버에서 데이터 페칭을 수행해 더 빠르고 브라우저에 의존하지 않는 이점이 있어요.

결론적으로 SSR을 도입하여 초기 로딩 속도를 개선하고, 유의미한 데이터를 사용자가 빠르게 볼 수 있게 되었어요.

📘 Reference

댓글남기기