클라이언트 컴포넌트도 서버에서 먼저 렌더링된다

2023년 11월 30일 · #공부


Next 13버전 이후 App Router 를 사용하게 되면, 서버 컴포넌트와 클라이언트 컴포넌트라는 개념을 마주하게 된다. "use client" 를 tsx 파일 최상단에 선언하면 클라이언트 컴포넌트를 사용할 수 있다.

서버 컴포넌트는 서버에서 렌더링하고 클라이언트 컴포넌트는 렌더링을 보류 했다가 클라이언트에서 렌더링 될 것이라고 처음에는 생각했다. 하지만 내 의도대로 동작하지 않았고 좀 더 알아보았다.

나는 "use client" 가 선언 된 컴포넌트 내에서 localStorage 에 접근하도록 코드를 짰다. 그리고 이러한 에러 메시지를 마주하게 되었다. "localStorage is not defined."

'음? 이것은 클라이언트 컴포넌트인데, 왜 서버에서 localStorage 를 찾지?' 라고 생각했다. 이것은 내가 잘못 알고 있었던 것인데, Next는 서버와 클라이언트 컴포넌트 모두에 대해 정적 HTML 미리보기를 먼저 서버에서 렌더링 해둔다. 사용자에게 빠르게 페이지 콘텐츠(비대화형 미리보기)를 보여주기 위해서이다. (여기서 비대화형 미리보기란, non-interactive initial preview. 즉 사용자 인터렉션이 불가능한 상태를 말한다.)

사용자에게 비대화형 미리보기를 먼저 제공한 다음에는 자바스크립트 다운로드가 완료 되고, 리액트가 실행된다. 그리고 페이지의 요소들이 인터렉션 가능한 요소들로 채워지게 된다. 이러한 과정을 hydrate 라고 한다.

What is hydration?

Hydration is the process of attaching event listeners to the DOM, to make the static HTML interactive. Behind the scenes, hydration is done with the hydrateRoot React API.

하이드레이션은 정적 HTML을 인터랙티브하게 만들기 위해 이벤트 리스너를 DOM에 연결하는 프로세스입니다. 백그라운드에서 하이드레이션은 하이드레이트 루트를 사용하여 수행됩니다.

그렇다면 localStorage 는 어떻게 클라이언트에서 사용할 수 있을까?

일단 위에서 말했듯 서버에서는 localStorage 가 정의되지 않았다는 것을 기억해야한다. 그리고 아래와 같이 정의하자.

if (typeof window !== 'undefined') {
    // 여기는 서버에서 실행하지 않음
    localStorage.getItem("...")
}

localStorage 가 정의되지 않은 서버 단에서는 if문 브라켓 내에 있는 코드는 실행하지 않을 것이다. typeof localStorage !== 'undefined' 라고 써도 되긴 하는데, localStorage도 window의 메서드 중 하나이기 때문에 똑같다.

서버단에서 특정 컴포넌트를 아예 렌더링하지 않고 싶을 때가 있을 수 있다. 그게 어떤 경우일까? 서버에서 내려주는 정적 렌더링과 클라이언트에서 처음으로 보여줘야 할 데이터가 서로 다른 경우이다. 나는 로컬 스토리지를 이용해 다크모드를 작업할 때 그러한 상황을 겪었다.

서버는 로컬 스토리지에서 사용자가 어떤 모드를 설정 해 놓았는지 모른다. 그래서 일단 기본값인 light mode의 아이콘 🌞 를 렌더링 한다. 그리고 클라이언트에서 수화 되면서 사용자가 설정한 dark mode의 아이콘 🌜 로 변경이 된다. 이렇게 되면 사용자에게 아이콘이 🌞 → 🌜 로 변경되는 경험을 주게 되어 좋지 않다. 그 뿐 아니라 Text content did not match. Server: "🌞" Client: "🌜" 이러한 에러도 마주하게 된다.

그렇담 이것은 어떻게 해결할 수 있을까?

첫번째로, 위 에러를 보여주지 않는 방법이다. suppressHydrationWarning 프롭을 true 로 넘겨준다. 이것은 해당 innerText 를 가지고 있는 요소에 직접 넣어줘야 한다.

<div suppressHydrationWarning> ← 부모에게 주면 안된다.
    <button suppressHydrationWarning> ← 여기에 넣어주자.
        {theme === 'light' ? '🌞' : '🌜'}
    </button>
</div>

이 방법으로 에러가 더 이상 뜨지는 않았지만 아이콘은 서버에서 내려준 🌞 만 보여졌다.

두번째로, 아예 서버단 에서는 컴포넌트를 렌더링 하지 않는 방법을 사용해 보고자 했다. 아래와 같은 컴포넌트 사용을 고려해볼 수 있다. useEffect가 클라이언트 컴포넌트에서만 동작하는 것을 이용한 일종의 트릭이다.

// useEffect를 활용한 방법
'use client';

import { ReactNode, useEffect, useState } from 'react';

interface Props { children: JSX.Element }

export default function NoSSRRendering({ children }: Props) {
  const [isClientRendering, setClientRendering] = useState(false);
  useEffect(() => {
    setClientRendering(true);
  }, []);
  if (!isClientRendering) {
    return null;
  }
  return children;
};

하지만 useEffect는 불필요한 렌더를 야기할 수 있기 때문에 찝찝했다. 더 좋은 방법이 필요했고 찾아내었다. next/dynamic을 활용한 방법이다. 이 방법은 공식 문서에도 나와 있는 방법이다. (Skipping SSR 참고)

// next/dynamic을 활용한 방법
'use client';

import dynamic from 'next/dynamic';

type Props = { children: JSX.Element };

const NoSSRRendering = ({ children }: Props) => {
  return children;
};

export default dynamic(() => Promise.resolve(NoSSRRendering), {
  ssr: false,
});

위 두 방법으로 구현한 컴포넌트를 아래와 같이 사용할 수 있다. 서버사이드에서는 NoSSRRendering 컴포넌트 하위에 있는 SomeClientOnlyComponent는 렌더 되지 않는다.

<NoSSRRendering>
    <SomeClientOnlyComponent />
</NoSSRRendering>

이렇게 함으로써 정적 HTML을 보여주는 상태에서는 아이콘을 보여주지 않다가, 수화된 후에 제대로 된 아이콘을 사용자에게 보여줄 수 있게 되었다.

끝.