Next.js - CSR에서 SSR로: 유저 페이지의 성능 최적화 여정(Feat. 네트워크 폭포 없애기)

Maria Kim·2024년 12월 27일
1
post-thumbnail

배경

요리 레시피를 공유하는 커뮤니티를 개발하고 있다.
유저 페이지(user page)는 유저가 작성한 모든 레시피 게시물을 확인할 수 있다.

  • 로그인한 사용자가 자신의 유저 페이지를 확인하는 경우
    - 1% 경우
    - 작성한 게시물 확인 가능
    - 게시물 수정/삭제 가능
    • 게시물 추가/수정/삭제 시 해당 페이지에 바로 반영되어야 함
  • 그 외의 유저 페이지
    - 99% 경우
    - 해당 페이지의 유저가 작성한 게시물 확인 가능

문제

현재 1%의 사용을 차지는 로그인한 사용자의 유저 페이지를 최신으로 유지하기 위해
해당 페이지를 클라이언트에서 렌더링(CSR)하고 있다.

사진이 많이 있고 많은 데이터가 포함될 수 있는 화면인 만큼
1%의 사용성을 위해 CSR을 하기에는

너무 느리다.

모든 사용자가 같은 화면을 확인할 수 있는 만큼
99%의 경우에 해당하는, 로그인한 유저의 페이지가 아닌 페이지를 미리 그릴 수 없을까?
ISR/SSR을 사용해 미리 마크업을 가져올 수 없을까?

  • 일반 유저 페이지 / 99% 경우
    - 오래된 데이터 사용 가능
  • 로그인한 사용자 자신의 유저 페이지 / 1% 경우
    - 최신 데이터 사용해야 함

해야 하는 일

  • 서버에서 최대한 HTML Markup 만들기
  • 사용자가 데이터 변경 시 변경된 데이터 바로 반영하기
    - 최신 데이터 반영
  • 일반 사용자의 페이지가 아닌 경우 오래된 데이터 사용하기

시도

  • 최신 데이터 유지 및 반영
  • 클라이언트/서버 데이터 분리
    -> react-query 사용

서버 렌더링에서 받은 데이터를 initData로 사용하기

  • 서버 렌더링 시 데이터를 받고 -> 해당 데이터를 react-query의 initData로 사용
  • 서버 렌더링 시 화면 그리지 않기
  • 로그인 유저가 자신의 화면을 보는 경우에만 최신 데이터로 유지하기

코드

  • 서버 컴포넌트
async function List({ username }: { username: string }) {
  const { ok, data: recipes } = await getRecipes(username, 'username');

  if (!ok) return notFound();
  return (
    <section>
      <div className={style.tabs}>
        <span className={style.tab}>
          <Icon icon='grid' /> RECIPES
        </span>
      </div>
      <RecipeList recipes={recipes} />
    </section>
  );
}
  • 클라이언트 컴포넌트
'use client';

function RecipeList({ recipes }: Props) {
  const userInfo = getUserInfo();
  const params = useParams<UserPageParams>();
  const [isClient, setIsClient] = useState(false);
  const isLoggedInUserPage = params.username === userInfo.username;

  useEffect(() => {
    setIsClient(true);
  }, []);

  const { data, error } = useQuery(
    recipeListOptions({
      query: params.username || '',
      type: 'username',
      enabled: isLoggedInUserPage,
      initialData: recipes,
    }),
  );

  if (!isClient) return null;

  if (error) return notFound();

  return <Cards recipes={data} />;
}

export default RecipeList;

해결한 사항

  • 서버 컴포넌트에서 데이터를 받아옴 -> 데이터를 요청 시간 절약
  • 일반 유저 페이지의 경우 최신 데이터 요청을 보내지 않음

해결하지 못한 사항

  • HTML 마크업은 아직 클라이언트에서 만듦 -> 아직 오래 걸림

서버에서 페이지 전체를 렌더링 하자

  • 클라이언트를 사용해야 하는 곳을 제거하자
    - 로그인한 사용자 확인
    -> 클라이언트에서 react-query를 일단 활성화하고
    -> 일반 유저 페이지에서 최신 요청을 최소화 -> staleTime를 높이기
    -> 로그인한 사용자의 유저 페이지 데이터 -> 데이터 변경 시 해당 query 무효화해서 최신 데이터를 유지(invalidateQueries 사용)
    • useParams -> props로 params 받기

알아야 할 사항

  • 서버에서 렌더링 한 것과 클라이언트에서 렌더링 한 마크업이 다르면 에러가 발생함
  • 'use client'를 사용한 클라이언트 컴포넌트 경우에도 Next.js는 최대한 서버에서 렌더링 하기 위해 렌더링을 시도함
  • useEffect 안의 코드는 클라리언트에서만 실행됨
    - 그래서 typeof window !== 'undefined'와 같이 클라리언트에서만 실행될 수 있는 코드는 useEffect 안에 넣어야함

코드

'use client';

function RecipeList({ recipes }: Props) {
  const params = useParams<UserPageParams>();
  
  const { data, error } = useQuery(
    recipeListOptions({
      query: params.username || '',
      type: 'username',
      initialData: recipes,
      staleTime: 180000, // 3 minutes
    }),
  );

  if (error) return notFound();

  return <Cards recipes={data} />;
}

export default RecipeList;

리펙토링 - 최신 데이터를 쓰도록하자

이 부분은 react-query와 관련 있다.
서버에서 data를 받아와 initialData에 사용하는 방식은
prefetch의 한 방법이다.

하지만, 이 방법은 사용에 따라 문제가 생길 수 있다.
query의 initalData는 한 번 추가되어 캐시되면 다시는 컴포넌트가 리렌더링되어 initial에 새로 추가되어도 변경되지 않는다. 새로 받은 데이터가 더 새로운 데이터여도 말이다. 그래서 페이지를 앞뒤로 움직이거나, 새로 페이지를 받아와도 오래된 데이터가 쓰일 수 있다.

If you are calling useQuery in a component deeper down in the tree you need to pass the initialData down to that point

If you are calling useQuery with the same query in multiple locations, passing initialData to only one of them can be brittle and break when your app changes since. If you remove or move the component that has the useQuery with initialData, the more deeply nested useQuery might no longer have any data. Passing initialData to all queries that needs it can also be cumbersome.

There is no way to know at what time the query was fetched on the server, so dataUpdatedAt and determining if the query needs refetching is based on when the page loaded instead
If there is already data in the cache for a query, initialData will never overwrite this data, even if the new data is fresher than the old one.

To understand why this is especially bad, consider the getServerSideProps example above. If you navigate back and forth to a page several times, getServerSideProps would get called each time and fetch new data, but because we are using the initialData option, the client cache and data would never be updated.

tanstack query 공식문서 - ssr

그렇기 때문에
intial에 데이터를 넣는 방식이 아닌 prefetchQuery를 사용해서 dehydrate하는 아래의 방식이 더 추천된다.

queryClient를 새로 만들고 HydrationBoundary를 계속 사용해도 문제가 없다.

tanstack query 공식문서 - advanced ssr

코드

  • page 서버 컴포넌트
async function List({ username }: { username: string }) {
  const queryClient = new QueryClient();

  await queryClient.prefetchQuery(
    recipeListOptions({
      query: username || '',
      type: 'username',
    }),
  );
  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <section>
        <div className={style.tabs}>
          <span className={style.tab}>
            <Icon icon='grid' /> RECIPES
          </span>
        </div>
        <RecipeList />
      </section>
    </HydrationBoundary>
  );
}
  • 클라이언트 컴포넌트
'use client';

function RecipeList() {
  const params = useParams<UserPageParams>();

  const { data, error } = useQuery(
    recipeListOptions({
      query: params.username || '',
      type: 'username',
      staleTime: 180000, // 3 minutes
    }),
  );

  if (error) return notFound();

  return <Cards recipes={data ?? []} />;
}

export default RecipeList;

결과


위와 같은 유저 페이지의 경우
헤더에 1개, 리스트에 2개의 사진이 있는 것을 확인할 수 있다.

개선 전 / 리스트가 클라이언트에서 렌더링 되는 경우

  • 이 모든 과정이 클라이언트에서 진행되기 때문에
  • HTML을 받아옴 -> JS가 리스트 HTML 생성 -> HTML에 있는 사진 요청의 과정이 있음을 확인할 수 있다.
  • 이미지 요청하기까지 1초가 넘는 시간이 걸림을 확인할 수 있다.
  • 중요한 리스트보다 다른 요청이 먼저 진행됨을 확인할 수 있다.


  • 서버에서 리스트 HTML을 받았기 때문에 바로 사진을 요청할 수 있다.
  • 화면에 필요하고 중요한 3개의 요청이 바로 진행 된다.
  • HTML을 받아옴 -> 사진 요청
  • 이미지 요청하기까지 450초가 걸리며 50%가 넘는 요청 시간 감소를 확인할 수 있다.

마무리

오늘도 이렇게
조금 빠른 사진 요청으로
조금 더 나은
UX를 만들 수 있게 되었다. 🥹

참고 사이트

tanstack query 공식문서 - request waterfalls
tanstack query 공식문서 - ssr
tanstack query 공식문서 - advanced ssr

위 3개 문서는
Next.js + React Query 조합을 생각 중이라면 꼭 읽어보면 좋을 문서이다.

profile
Frontend Developer, who has business in mind.

0개의 댓글

관련 채용 정보