이제는 그만.. Suspense + Hydrate 이슈

Gee·2024년 3월 19일
1
post-custom-banner

해당 글은 Next.js 13버전, page router 기준으로 작성되었습니다.

작년에 진행한 프로젝트에서 Hydration 이슈가 빈번히 발생했습니다. 처음에는 화면이 정상적으로 작동하는 것으로 보아 큰 문제가 없을 것으로 생각했지만, 계속해서 경고 메시지를 보게 되어 피로감이 누적되었고, Next.js 프레임워크를 충분히 이해하지 못한 것 같은 느낌이 들었습니다. 그래서 해당 이슈를 해결하기로 결심했습니다.

최종적으로 발견한 대부분의 Hydration 이슈는 Suspense 객체와 dynamic import (ssr: false)를 함께 사용할 때 발생했습니다. 이 두 가지를 함께 사용하는 것이 이론적으로나 기능적으로 문제가 있는 것은 아니지만, 필자가 경험한 예외적인 상황들을 설명하고자 합니다.

문제 상황

상황 설명을 위해 예시 코드를 준비하였습니다.

[코드1]

const ClientComponent = dynamic(() => import('components/ClientComponent'), {
    ssr: false,
    loading: () => <div>ClientComponent loading!</div>
});

const App = () => {
  return (
      <>
        <div id="client-component-id">
          <Suspense fallback={<div>loading...</div>}>
            <ClientComponent />
          </Suspense>
        </div>
        <div id="server-component-id">
          <ServerComponent />
        </div>
      </>
  )
}

[코드2]

export const testA = 'Hellow World!'

const ClientComponent = () => {
  const {data} = useQuery(testApiFetch(), { suspense: true });
  
  return(
    <div>Client!</div>
  )
}

[코드3]

import testA from './ClientComponent'

const ServerComponent = () => {
  return (
    <div>{testA}</div>
  )
}
  1. [코드1] : Client Side 에서 렌더링 할 ClientComponent(dynamic ssr: false) 에 Suspense 객체로 감싸둔 상태
  • dynamic import ssr: false 로 ClientComponent 를 Client Side에서 JS file을 로드시키는 것이고 (lazy-loading) 입니다.
  • Suspense 는 ClientComponent 내부에 비동기로 호출하는 API의 Loading 상태를 선언적으로 보여주기 위해서 사용하였습니다 (코드2).
  1. [코드 2], [코드 3] : ClientComponent 에서 export 하는 무언가(변수, 함수.. 등)를 ServerComponent에서 import 하는 상황

위의 상황을 이해하셨을까요 ?
다시 정리하자면, ClientComponent에 Suspense 객체는 해당 컴포넌트 내에서 비동기로 호출하는 API (react-query) 상태를 선언적으로 보여주기 위해서 사용하였습니다. ClientComponent를 Client-Side-Rendering을 시키기위해 dynamic import (ssr:false)를 사용하여 lazy loading을 시켰습니다.각각의 기능은 알아서 동작을 하였는데요.

다만, 코드2와 코드3에서 문제가 발생할 부분이 생깁니다. 코드2는 ClientComponent 코드인데요. ClientComponent에서 testA라는 객체를 export 합니다. export한 testA는 ServerComponent에서 사용하고 있습니다.
ServerComponent는 서버로 부터 넘어오는 html에서 이미 컴포넌트가 로드되어 넘어오게 됩니다. 그럼, 해당 컴포넌트에서 부르고 있던 testA변수를 export하는 ClientComponent는 불려진 상태일까요 ?
이미 Server-Side-Rendering 시에 ClientComponent 컴포넌트를 부른 상태입니다!

이를 확인하기 위해, chunk file이 분리되었는 지 확인하였는데 분리되어 있지 않았습니다.

react-loadable-manifest.json
: React Loadable 라이브러리가 생성하는 파일 중 하나입니다. 이 파일은 React 애플리케이션에서 코드 분할을 관리하는 데 사용됩니다.

Server Side Rendering 때 ClientComponent가 이미 불러와진게 왜 문제가 될까요 ?

Suspense 객체는 비동기 데이터 패칭에 대한 처리뿐만 아니라, 컴포넌트를 Lazy Loading 할 때도 사용됩니다. (React에서는 React.lazy()와 함께 사용했었죠.)
ClientComponent는 Client-Side-Rendering 때 lazy loading을 할 컴포넌트였는데, 이미 해당 컴포넌트 파일은 불려와져 있었습니다. 그래서, 비동기적으로 컴포넌트를 불러올 필요가 없기때문에 바로 컴포넌트를 찍어버린 것이죠.

function LoadableComponent(props: any, ref: any) {
    useLoadableModule()

    const state = (React as any).useSyncExternalStore(
      subscription.subscribe,
      subscription.getCurrentValue,
      subscription.getCurrentValue
    )

    React.useImperativeHandle(
      ref,
      () => ({
        retry: subscription.retry,
      }),
      []
    )

    return React.useMemo(() => {
      if (state.loading || state.error) {
        return React.createElement(opts.loading, {
          isLoading: state.loading,
          pastDelay: state.pastDelay,
          timedOut: state.timedOut,
          error: state.error,
          retry: subscription.retry,
        })
      } else if (state.loaded) {
        return React.createElement(resolve(state.loaded), props)
      } else {
        return null
      }
    }, [props, state])
  }

  LoadableComponent.preload = () => init()
  LoadableComponent.displayName = 'LoadableComponent'

  return React.forwardRef(LoadableComponent)

해당코드는 dynamic import (ssr:false) 구현 코드인데요. 일부만 발췌하였습니다. 'state.loaded' 값이 있는 경우, 바로 해당 객체를 resolve를 하게 됩니다. 'state.loading || state.error'인 경우에는 option의 loading 상태를 보여주게 됩니다.

바로 컴포넌트를 그려냈을 때의 문제점은 무엇일까요?
Hydration을 할 때, Server-Side-Rendering때 Client로 보냈던 DOM 구조와 Client-Side-Rendering 때의 DOM 구조가 다르기 때문입니다.
둘다 loading 상태를 보여주는 것이 아닌(state.loading), Client-Side-Rendering 할 때, hydration 시점에서는 이미 ClientComponent가 그려진 상태인 것이죠(state.loaded).

[서버 측 DOM구조]

[클라이언트 측 DOM구조]

<div id="client-component">
  <div>Client!</div>
</div>
<div id="server-component">
  <div>Hello World!</div>
</div>

해결 방안

해당 이슈에 대한 문제는 크게 두가지가 있었습니다.

  1. Client Side 에서 렌더링할 Component(dynamic import ssr:false) 에 Suspense 컴포넌트로 감싸둔 패턴
  2. 1번 패턴은 그대로 사용하되, ClientComponent에서 export 한 무언가를 ServerComponent에서 사용하는 것

2번 문제에 대해 고민 하였을 때, 컴포넌트를 매번 만들때마다 Client / Server 를 나누어서 만들수는 없다고 생각이 들었습니다. 그리고, 2번을 행했다고해도 1번이 사전 조건으로 되어있지 않으면 Hydrate 이슈는 일어나지 않았습니다.

그래서 궁극적으로 1번 문제에 대한 패턴을 고민하게 되었고, 아래와 같은 결론을 내리게 되었습니다.

Client Side 에서 렌더링할 Component에 Suspense 컴포넌트로 감싸둔 패턴을 권장하지 않는다.

Client Side에서 렌더링할 Component에 Suspense객체를 감싸게 되면, Lazy Loading 기능 측면에서는 혼동될 수 있고, 위와 같이 알 수 없는 경로로 인해 이미 ClientComponent 파일이 load 되어서 Suspense 가 동작하지 않고 Hydrate 이슈가 발생할수도 있습니다.

물론, 여기서 Suspense 기능은 Data Fetching에 대한 선언적 UI를 나타내기 위함이겠지만요.

해당 패턴을 쓰지 않기 위해서는 ClientComponent 내부를 Suspense로 한번 더 감싸는 것이 좋을 거 같습니다.

const SuspenseTest = (Component: () => JSX.Element | ReactNode) => {
  return (props?: any) => {
    return (
      <Suspense fallback={<div>???</div>}>
        <Component {...props} />
      </Suspense>
    );
  };
};
const ClientComponent = () => {
  const {data} = useClientQuery(..., { suspense: true })
  return <div></div>
}

export default SuspenseHOC(ClientComponent)

참고 자료

profile
작은 실패, 빠른 피드백, 다시 시도
post-custom-banner

0개의 댓글