Next.js의 App Router로 프로젝트를 개발하다 보면 ‘use client’를 자주 사용하게 된다.

Next.js 12 버전까지는 getServerSideProps를 사용해서 페이지 단위로 SSR을 구현했다.
하지만 13 버전에서 App Router가 도입되면서 접근 방식이 완전히 바뀌었다. 모든 컴포넌트가 기본적으로 서버 컴포넌트로 동작하고, 클라이언트에서 상태 관리나 이벤트 핸들러가 필요한 경우에만
‘use client’를 파일 최상단에 작성하면 된다.

“그냥 최상단에 ‘use client’만 사용하면 CSR로 동작하는구나!” 라고만 생각하고 내부적으로 어떻게 동작하는지 모르고 사용했다…

Next.js의 공식 문서에서도

“use client” is used to declare a boundary between a Server and Client Component modules.

“use client는 서버와 클라이언트 컴포넌트 모듈 간의 경계를 선언하는 것"이라고 설명한다. 하지만 내부적으로 어떻게 동작하는지는 나와있지 않았다.

그래서 어떻게 내부적으로 동작하는지 찾아보기로 했다. 개발자 도구를 열어 Network 탭을 확인하고, Next.js 소스코드를 찾아보고, React Server Component(RSC)의 동작 방식을 확인해봤다.


React 18과 React Server Component(RSC)

‘use client’를 이해하려면 먼저 React 18의 Server Components를 알아야 한다.

원래 React는 모든 컴포넌트가 클라이언트에서 실행된다. 예를 들어 블로그 포스트를 보여주는 컴포넌트를 만든다고 하면, 데이터를 가져오는 것도 렌더링하는 것도 모두 브라우저에서 일어난다.

function BlogPost() {
  const [post, setPost] = useState(null);

  useEffect(() => {
    fetch("/api/post").then((res) => setPost(res));
  }, []);

  return <article>{post?.content}</article>;
}

모든 컴포넌트 코드가 JavaScript 번들에 포함되어 번들 크기가 커지고, 데이터 페칭도 클라이언트에서 일어나 느렸다.

React 18은 이 문제를 해결하기 위해 서버에서 실행되는 컴포넌트를 도입했다.

// Server Component
async function BlogPost() {
  const post = await axiosClient.getPostList();
  return <article>{post.content}</article>;
}

이제 서버 전용 코드를 직접 사용할 수 있고, 이 컴포넌트 코드는 JavaScript 번들에 포함되지 않는다. 데이터 페칭도 서버 내부에서 일어나니 훨씬 빠르다.

하지만 서버 컴포넌트는 인터렉티브한 상호작용할 수 없다. useState, useEffect, onClick 같은 것을 쓸 수 없다는 뜻이다. 상호작용이 필요하면 Client Component를 써야 한다. 이때 사용하는 게 ‘use client’다.


실제로 한번 확인해보기

실무와 비슷한 예제를 만들어서 내부적으로 어떻게 동작하는지 테스트해봤다. 서버에서 사용자 데이터를 조회하고, 클라이언트 컴포넌트로 검색/정렬 기능을 구현하는 코드다.

// app/page.tsx (Server Component)
import UserTable from "./UserTable";

async function getUsers() {
  // 서버에서 DB 조회
  return [
    { id: 1, name: "김테스트", email: "kim@example.com", role: "Admin" },
    { id: 2, name: "이테스트", email: "lee@example.com", role: "User" },
    // ...
  ];
}

export default async function Dashboard() {
  const serverTime = new Date().toISOString();
  const users = await getUsers();

  return (
    <div>
      <h1>사용자 관리</h1>
      <p>서버 렌더링 시간: {serverTime}</p>
      <UserTable initialData={users} />
    </div>
  );
}
// app/UserTable.tsx (Client Component)
"use client";

import { useState } from "react";

export default function UserTable({ initialData }) {
  const [searchTerm, setSearchTerm] = useState("");
  const [sortBy, setSortBy] = useState("name");

  const filtered = initialData.filter((user) => user.name.includes(searchTerm));

  return (
    <div>
      <input
        type="text"
        placeholder="검색..."
        onChange={(e) => setSearchTerm(e.target.value)}
      />
      {/* 테이블 렌더링 */}
    </div>
  );
}

개발자 도구 Network 탭을 열고 페이지를 로드하면 아래와 같이 내부적으로 되어있는걸 볼 수 있다.

use-client

self.__next_f.push([1,"51:I[\"[project]/app/UserTable.tsx [app-client]\",
  [\"/_next/static/chunks/app_layout.js\",
   \"/_next/static/chunks/app_page.js\"],
  \"default\"]"])

위의 코드를 보면 Client component UserTable 컴포넌트에 대한 정보가 담겨있었다.

51:I["[project]/app/UserTable.tsx [app-client]", [...], "default"]

51는 ID고, I는 Import 지시를 의미한다. [app-client]는 클라이언트 전용이라는 표시고, 배열에는 실제 JavaScript 파일 경로들이 들어있다.

여기서 중요한 점은. 서버는 UserTable의 실제 코드를 보내지 않는다. 대신 “어디서 다운로드할지” 정보만 보낸다.


Sources 탭에서 확인

이번엔 Sources 탭을 열어봤다. _next/static/chunks/ 폴더 아래에 Desktop_blog_use-client-demo_07aa7c29._.js 같은 파일들이 찾아볼 수 있었다.

이 파일을 열어보니 실제로 UserTable 컴포넌트 코드가 들어있었다.

use-client2

function UserTable({ initialData }) {
  const [searchTerm, setSearchTerm] = useState("");
  const [sortBy, setSortBy] = useState("name");

  const filtered = initialData.filter((user) => user.name.includes(searchTerm));

  // ...
}

UserTable은 별도의 JavaScript 파일로 분리되어 있었고, 이 파일은 클라이언트에서만 다운로드되는 것 같았다. Network 탭에서 본 참조 정보가 바로 이 파일을 가리키는 것이었다.


RSC Payload가 무엇인가

Network 탭에서 본 self.__next_f.push([1, "..."]) 형태의 데이터가 바로 RSC Payload다. React Server Component Payload의 약자다.

서버 컴포넌트를 렌더링한 결과를 브라우저로 전달하기 위한 특수한 포맷이다. HTML처럼 완성된 마크업이 아니라, React가 클라이언트에서 트리를 재구성할 수 있도록 직렬화된 데이터다.

일반 HTML 태그는 그대로 전송되지만, 클라이언트 컴포넌트는 실제 코드 대신 “어디서 가져올지” 참조 정보만 들어있다.

예를 들어 서버에서 이런 JSX를 렌더링하면:

<div>
  <h1>사용자 관리</h1>
  <UserTable initialData={users} />
</div>

이렇게 변환된다:

// HTML 태그들
[
  "$",
  "div",
  null,
  { children: [["$", "h1", null, { children: "사용자 관리" }]] },
];

// 클라이언트 컴포넌트 정보
('51:I["[project]/app/UserTable.tsx",[청크경로들],"default"]');

일반 HTML 태그는 그대로 직렬화하고, 클라이언트 컴포넌트는 참조 정보만 보내는 식이다.


RSC Payload는 어디서 만들어지나?

Next.js와 React 소스코드를 뒤져봤다.

Next.js는 React를 내장하고 있었다. node_modules/next/dist/compiled/react-server-dom-webpack/ 폴더에 server.edge.js 같은 파일들이 있었다.

서버에서 페이지를 렌더링할 때 Next.js는 이렇게 React의 함수를 호출한다 (소스 코드).

ComponentMod.renderToReadableStream(
  RSCPayload,
  clientReferenceManifest.clientModules,
  { onError: ... }
)

그럼 React는 뭘 하는 걸까? React의 실제 구현을 찾아봤다 (소스 코드).

이 코드는 컴포넌트 트리를 순회하면서 각 컴포넌트를 처리한다. HTML 태그는 ["$","div",null,{...}] 형태로 직렬화하고, 클라이언트 컴포넌트는 참조 정보(51:I[...])를 생성한다. 서버 컴포넌트 함수는 실행해서 결과만 직렬화한다.


브라우저는 어떻게 받을까?

서버에서 self.__next_f.push([1, “…”]) 형태로 데이터를 보내면, 브라우저는 이걸 어떻게 처리할까?

Next.js는 페이지 HTML에 self.__next_f라는 전역 배열을 만들어둔다. 그리고 push 메서드를 오버라이드해서 데이터가 들어올 때마다 특정 함수를 호출하도록 만든다. 서버에서 스트리밍으로 데이터를 보내면, 브라우저는 받는 즉시 이 함수를 실행한다.

실제 브라우저 측 코드를 찾아봤다 (소스 코드).

function nextServerDataCallback(seg) {
  if (seg[0] === 0) {
    // 초기화
    initialServerDataBuffer = [];
  } else if (seg[0] === 1) {
    // 텍스트 데이터 (RSC Payload)
    initialServerDataBuffer.push(seg[1]);
  } else if (seg[0] === 2) {
    // Form State
    initialFormStateData = seg[1];
  } else if (seg[0] === 3) {
    // 바이너리 데이터
    const decodedChunk = atob(seg[1]);
    initialServerDataBuffer.push(decodedChunk);
  }
}

seg[0]은 데이터 타입을 나타낸다. 0이면 초기화 신호고, 1이면 텍스트(RSC Payload 대부분), 2는 Form State, 3은 바이너리 데이터다.

서버에서 self.__next_f.push([1, "..."])로 보내면, 브라우저에서 nextServerDataCallback([1, "..."])이 호출되는 구조다. 이 함수가 데이터를 버퍼에 모아뒀다가 React에게 전달한다.

서버에서 self.__next_f.push([1, “…”])로 보내면, 브라우저에서 nextServerDataCallback([1, “…”])이 호출되는 구조였다.


전체 흐름

확인한 것들을 정리하면 이렇다.

서버에서는 먼저 페이지 요청을 받으면 서버 컴포넌트를 실행한다. 그리고 React의 renderToReadableStream을 호출해서 컴포넌트 트리를 순회한다. 이때 HTML 태그는 직렬화하고 클라이언트 컴포넌트는 참조 정보만 생성한다. 이렇게 만들어진 RSC Payload를 self.__next_f.push([1, "..."]) 형태로 브라우저에 전송한다.

브라우저는 이 데이터를 받아서 nextServerDataCallback을 호출한다. seg[0]을 확인해서 데이터 타입을 판단하고 저장한다. RSC Payload를 파싱하다가 “51:I[…]” 같은 참조를 발견하면 해당 청크 파일을 다운로드한다. 청크 다운로드가 완료되면 그 안의 UserTable.js를 실행하고, React 트리를 재구성한 다음 하이드레이션을 진행한다.


실제로 확인한 것들

Network 탭에서 확인한 건 RSC Payload가 self.__next_f.push([1, "..."]) 형태로 전송된다는 것이었다. 클라이언트 컴포넌트는 51:I[...] 같은 참조로 전송되고, 실제 코드는 전송되지 않았다.

Sources 탭에서는 클라이언트 컴포넌트가 _next/static/chunks/ 아래 별도 파일로 분리되어 있는 걸 확인했다. 파일 안에 실제 컴포넌트 코드가 있었다. 하지만 원본 .tsx 파일은 Sources에 없었다.

소스코드에서는 Next.js가 renderToReadableStream을 호출하고, React가 RSC Payload 생성 로직을 처리하고, 브라우저에서 nextServerDataCallback로 처리하는 걸 확인했다.

동작 방식을 정리하면, 서버는 클라이언트 컴포넌트를 참조로 변환하고, RSC Payload로 참조 정보만 전송한다. 그러면 브라우저가 참조를 보고 청크 파일을 다운로드해서 실행하는 방식이었다.


마치며

‘use client’ 한 줄 뒤에 이런 메커니즘이 숨어있었다.

RSC Payload라는 특수한 포맷이 존재하고, 클라이언트 컴포넌트는 참조로만 전송된다. 실제 코드는 별도 청크 파일로 분리되어 있고, 브라우저가 필요할 때 다운로드한다.

단순히 “클라이언트에서 실행된다"가 아니라, 어떻게 분리되고 전달되는지를 실제로 확인할 수 있었다.


참고 자료

공식 문서:

확인한 소스 코드:

관련 글: