[Next.js] Props must be serializable for components in the "use client" entry file

windowook·2024년 12월 5일
post-thumbnail

에러 메세지

프로젝트의 리팩토링을 진행하면서 갖가지의 버그와 에러를 만나봤습니다.
이번 게시물에서 다뤄 볼 에러는 Next.js 14 앱 라우터를 사용하며 만났던 에러 중 하나인 클라이언트 컴포넌트에서 props의 직렬화입니다.

관련 링크

https://nextjs.org/docs/app/building-your-application/rendering/composition-patterns#passing-props-from-server-to-client-components-serialization

문제의 컴포넌트

components/auth/shared/index.tsx

'use client';

import { useState } from 'react';
import Signup from '../signup';
import Signin from '../signin';
import AuthBackgroundCards from './background-cards';
import LogoImage from './logo-image';

export default function Auth() {
  const [view, setView] = useState('SIGNIN');

  return (
    <main className="area h-screen w-screen flex justify-center items-center">
      <AuthBackgroundCards />
      <div className="flex flex-col items-center gap-4">
        <LogoImage size={140} />
        {view === 'SIGNUP' ? (
          <Signup setView={setView} />
        ) : (
          <Signin setView={setView} />
        )}
      </div>
    </main>
  );
}

Auth 컴포넌트는 루트 페이지에서 사용하는 컴포넌트입니다. 로그인 후 저장한 액세스 토큰을 조건으로 메인 레이아웃을 보여주거나 Auth 컴포넌트를 보여주죠. Auth는 그래서 로그인 또는 회원가입을 하는 폼을 가진 Signin, Signup 컴포넌트를 자식 컴포넌트로 두고 view에 따라서 이 두 컴포넌트 중 하나를 보여주는 컴포넌트입니다.

Auth에서는 useState로 view를 상태로 관리합니다. 자식 컴포넌트에 상태 변경을 위임하고 있죠.
Signup, Signin 두 컴포넌트에서 각각 view를 변경시키는 setView를 props로 전달받습니다.

일반적으로 페이지 라우터 방식을 사용하는 Next.js 프로젝트나 리액트 프로젝트라면 setView 함수를 그대로 넘겨도 클라이언트 컴포넌트에서는 props는 반드시 직렬화 가능해야한다는 메세지가 안 뜨겠지만 앱 라우터는 'use client'를 명시한 클라이언트 컴포넌트의 엔트리 파일에서 해당 규칙을 지키지 않을 경우, 자식 컴포넌트에서 props를 확인하면 경고 메세지가 뜨게 됩니다.

서버 컴포넌트에서는 서버에서 HTML을 미리 생성한 후 클라이언트에 전달하고, API 요청 없이 서버에서 직접 데이터를 가져올 수 있습니다. 그래서 데이터의 직렬화가 문제되지 않습니다. 하지만 클라이언트 컴포넌트는 브라우저에서 실행되므로, 서버에서 데이터를 직렬화하여 JSON 형태로 전달받아야 합니다. 그래서 서버 컴포넌트-클라이언트 컴포넌트 간의 props 전달이라면 직렬화 가능한 데이터만 담을 수 있습니다. 이는 반대로 '클라이언트 컴포넌트끼리는 직렬화되지 않은 데이터도 props로 전달할 수 있다'라는 원칙을 준수한다고도 할 수 있죠.

여기서 저는 의문이 생겼습니다. '지금 문제는 부모와 자식이 모두 클라이언트 컴포넌트인데, 왜 오류가 생기지?' 그 이유는 Next.js는 기본적으로 SSR을 사용하기 때문에 RootLayout의 기본은 서버 컴포넌트로 작동하여 SSR로 생성됩니다. 그럼 서버에서 실행된다는 뜻이며, 하위 클라이언트 컴포넌트끼리 props를 넘겨줄 때도 부모 컴포넌트의 props를 JSON으로 직렬화하려고 시도합니다.

현재 setView의 경우 useState 훅에서 생성한 setter function이기 때문에 직렬화가 당연히 불가능합니다. 그럼 이벤트 핸들러 내부에 setter function을 위치시키고 핸들러를 props로 넘기면 해결될까요?

const handleView = newView:string => setView(newView);

아쉽게도 이런 핸들러 함수를 하나 만들어서 props로 넘긴다고 해서 해결되지 않습니다.
일반 함수도 마찬가지로 직렬화가 불가능한 데이터입니다. 그럼 해결 방법으로 어떤 것들이 있는걸까요?

해결 방법

1. 'use client'를 명시하지 않기

클라이언트 컴포넌트라는 것을 명시하지 않는 것입니다.
그럼 기본적으로 앱 라우터에서는 해당 컴포넌트를 서버 컴포넌트로 간주합니다.

하지만 이 방법은 useState, useEffect와 같은 훅을 사용해야하는 상황에서는 의미없습니다.
서버 컴포넌트에서는 클라이언트에서만 사용 가능한 훅을 사용할 수 없기 때문이죠.
그렇다면 setter 함수도, 일반 함수도 props로 넘길 수 없는 상황에서 어떤 방법을 사용할지 뭔가 떠오르시지 않나요?

2. 전역 상태로 변경하기

zustand나 Redux, Recoil과 같은 라이브러리를 사용해도 좋습니다. 하지만 지금 제 상황처럼 한정적인 범위에서 2 레이어만 가진 컴포넌트 트리에서만 필요한데 굳이 store를 하나 더 생성해서 메모리를 잡아먹을 필요는 없을 수도 있습니다.

이럴 때는 Context API를 사용할 수 있겠죠.

components/auth/shared/auth-view-provider.tsx

import { createContext, useContext, useState, ReactNode } from 'react';

interface AuthViewContextType {
  view: string;
  setView: (value: string) => void;
}

const AuthViewContext = createContext<AuthViewContextType | undefined>(
  undefined,
);

export function AuthViewProvider({ children }: { children: ReactNode }) {
  const [view, setView] = useState('SIGNIN');
  return (
    <AuthViewContext.Provider value={{ view, setView }}>
      {children}
    </AuthViewContext.Provider>
  );
}

export function useAuthView() {
  const context = useContext(AuthViewContext);
  if (context === undefined) {
    throw new Error('컨텍스트가 제대로 지정되지 않았습니다');
  }
  return context;
}

AuthViewProvider를 생성했습니다. 이제 이 Provider를 Auth에 감싸줍니다.

export default function Auth() {
  const { view } = useAuthView();

  return (
    <AuthViewProvider>
      <main className="area h-screen w-screen flex justify-center items-center">
        <AuthBackgroundCards />
        <div className="flex flex-col items-center gap-4">
          <LogoImage size={140} />
          {view === 'SIGNUP' ? <Signup /> : <Signin />}
        </div>
      </main>
    </AuthViewProvider>
  );
}

그런 다음 useAuthView로 view를 사용하면 됩니다. 자식 컴포넌트인 Signup과 Signin에서는

  const { setView } = useAuthView();

위처럼 setView만 가져와 사용하면 되겠죠. 타입 명시도 다시 할 필요 없습니다.

이렇게 ESLint에서 띄우는 경고 메세지가 깔끔히 사라졌죠:)

3. dynamic import

최상위 컴포넌트 HomePage

import dynamic from "next/dynamic";

const ParentComponent = dynamic(() => import("../components/ParentComponent"), {
  ssr: false, // 클라이언트에서만 실행
});

export default function HomePage() {
  return <ParentComponent />;
}

부모 컴포넌트 ParentComponent

"use client";

import { useState } from "react";
import ChildComponent from "./ChildComponent";

export default function ParentComponent() {
  const [count, setCount] = useState(0);

  return <ChildComponent setCount={setCount} />; // ✅ setter 전달 가능
}

상위 컴포넌트에서 dynamic import로 코드 스플리팅을 하여 ParentComponent를 반드시 클라이언트 사이드 렌더링이 되게 한다면, props로 넘겨줘도 오류가 생기지 않습니다.

profile
안녕하세요

0개의 댓글