[Next.js 16] Cache Components, Partial Prerender (1)

Keonwoo Kim·2025년 12월 17일
post-thumbnail

머리말

Cache components를 써도 partial prerender가 안 되고 fully dynamic으로 나오는 문제가 있었는데, root layout에 "use cache: private"를 써서 생긴 문제였다.

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <Suspense>
      <RootLayoutContent>{children}</RootLayoutContent>
    </Suspense>
  );
}

async function RootLayoutContent({ children }: { children: React.ReactNode }) {
  "use cache: private";

  const number = Math.random();

  return (
    <html lang="en">
      <body
        className={`${geistSans.variable} ${geistMono.variable} antialiased`}
      >
        <p>Number: {number}</p>
        {children}
      </body>
    </html>
  );
}

"use cache: private"를 사용하면 요청마다 렌더되면서 number = Math.random() 값이 바뀌게 된다.

PPR의 static shell을 가져가면서 headers()cookies()도 사용할 수 있게는 안 되는 걸까?

뭐.. 이런 의문이 쌓여 static shell이 정확히 어떻게 렌더되어 저장되고 어떤 부분이 PPR이 되는지 알아보고 싶었다.

실험 - Sections

Section 0 - Asynchronous Server Component w/o cache

import { fetchRandomNumber } from "~/utils/mocks";

export async function AsyncSection() {
  const { value } = await fetchRandomNumber();
  return <section data-index={0}>Async Section: {value}</section>;
}

캐시 없이 매 요청마다 fetch 등으로 직접 데이터를 받아온 후 렌더된 결과만을 클라이언트에 넘겨주는 컴포넌트의 예시이다.

Section 1 - Asynchronous Server Component w/ cache

import { cacheLife, cacheTag } from "next/cache";
import { fetchRandomNumber } from "~/utils/mocks";

export async function CachedAsyncSection() {
  "use cache";
  cacheTag("random-number");
  cacheLife("minutes");

  const { value, timestamp } = await fetchRandomNumber();
  return (
    <section data-index={1}>
      Cached Async Section: {value} @ {timestamp}
    </section>
  );
}

Section 0과 같은 값을 "use cache"를 이용하여 캐싱한 컴포넌트이다.

Section 2 - Pass Promise and Use with use

// index.tsx
import { fetchRandomNumber } from "~/utils/mocks";
import { UseSectionContent } from "./content";

export function UseSection() {
  const promise = fetchRandomNumber();
  return <UseSectionContent promise={promise} />;
}

// content.tsx
"use client";

import { use } from "react";

export function UseSectionContent({
  promise,
}: {
  promise: Promise<{ value: number; timestamp: number }>;
}) {
  const { value, timestamp } = use(promise);
  return (
    <section data-index={2}>
      Use Section: {value} @ {timestamp}
    </section>
  );
}

스트리밍 방식으로 데이터를 전달한 후 client component에서 use로 resolve하는 방식이다.

실험 - Pages

Cache components를 활성화하면 uncached data는 Suspense 바운더리 없이는 쓸 수 없다! 즉 다음과 같은 네 가지 경우가 있겠다.

import { Suspense } from "react";
import { AsyncSection } from "../_sections/0-async";
import { CachedAsyncSection } from "../_sections/1-cached-async";
import { UseSection } from "../_sections/2-use";

export default function Page() {
  return (
    <>
      <Suspense fallback={<p>Loading 0 - async section</p>}>
        <AsyncSection />
      </Suspense>

      <CachedAsyncSection />

      <Suspense fallback={<p>Loading 1 - cached async section</p>}>
        <CachedAsyncSection />
      </Suspense>

      <Suspense fallback={<p>Loading 2 - use section</p>}>
        <UseSection />
      </Suspense>
    </>
  );
}

pnpm buildpnpm start를 하면 .next/server/[...path].{html,meta,prefetch.rsc} 등이 생성되는 것을 알 수 있다.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <link
      rel="stylesheet"
      href="/_next/static/chunks/59ecb22ae94a8c90.css"
      data-precedence="next"
    />
    <link
      rel="preload"
      as="script"
      fetchPriority="low"
      href="/_next/static/chunks/3ad953dd13e287b7.js"
    />
    <script src="/_next/static/chunks/74c6a17b8b217cbe.js" async=""></script>
    <script src="/_next/static/chunks/89849193f523f886.js" async=""></script>
    <script
      src="/_next/static/chunks/turbopack-ee0342bc6742f5c0.js"
      async=""
    ></script>
    <script src="/_next/static/chunks/fd988c8ed579e6ad.js" async=""></script>
    <script src="/_next/static/chunks/c88b82a5d58ad558.js" async=""></script>
    <meta name="next-size-adjust" content="" />
    <title>Create Next App</title>
    <meta name="description" content="Generated by create next app" />
    <link
      rel="icon"
      href="/favicon.ico?favicon.0b3bf435.ico"
      sizes="256x256"
      type="image/x-icon"
    />
    <script src="/_next/static/chunks/a6dad97d9634a72d.js" nomodule=""></script>
  </head>
  <body
    class="geist_a71539c9-module__T19VSG__variable geist_mono_8d43a2aa-module__8Li5zG__variable antialiased"
  >
    <div hidden=""><!--$--><!--/$--></div>
    <!--&--><!--&--><!--$?--><template id="B:0"></template>
    <p>Loading 0 - async section</p>
    <!--/$-->
    <section data-index="1">
      Cached Async Section:
      <!-- -->313<!-- -->
      @
      <!-- -->18:15:29.563
    </section>
    <!--$-->
    <section data-index="1">
      Cached Async Section:
      <!-- -->313<!-- -->
      @
      <!-- -->18:15:29.563
    </section>
    <!--/$--><!--$?--><template id="B:1"></template>
    <p>Loading 2 - use section</p>
    <!--/$--><!--$--><!--/$--><!--/&--><!--/&-->
    <script>
      requestAnimationFrame(function () {
        $RT = performance.now();
      });
    </script>
    <script
      src="/_next/static/chunks/3ad953dd13e287b7.js"
      id="_R_"
      async=""
    ></script>
  </body>
</html>

결과를 보면 알겠지만, Suspense로 감싼 부분이더라도 "use cache"로 캐싱되어 있다면 prerender가 되어 있는 것을 알 수 있다. 재미있는 점은 cached component는 Suspense로 감싸는 것이 무의미하다는 점이다!

하지만 나머지 캐싱 없이 Suspense 안에 있는 부분들은 모두 template과 fallback으로 대체되어 있다.

<!--$?--><template id="B:0"></template>
<p>Loading 0 - async section</p>
<!--/$-->

또 이 html 파일은 서버가 돌아가면서 실제로 업데이트되는 html 파일이다! 서버는 요청을 받았을 때 이 html 파일을 전송하면서 스트리밍을 시작한다.

.next/server/[...page].prefetch.rsc도 보자.

1:"$Sreact.fragment"
2:I[57628,["/_next/static/chunks/fd988c8ed579e6ad.js"],"default"]
3:I[15929,["/_next/static/chunks/fd988c8ed579e6ad.js"],"default"]
4:"$Sreact.suspense"
8:I[2991,["/_next/static/chunks/c88b82a5d58ad558.js"],"UseSectionContent"]
a:I[53323,["/_next/static/chunks/fd988c8ed579e6ad.js"],"OutletBoundary"]
c:I[53323,["/_next/static/chunks/fd988c8ed579e6ad.js"],"ViewportBoundary"]
e:I[53323,["/_next/static/chunks/fd988c8ed579e6ad.js"],"MetadataBoundary"]
10:I[11737,[],"default"]
11:I[50412,["/_next/static/chunks/fd988c8ed579e6ad.js"],"IconMark"]
:HL["/_next/static/chunks/59ecb22ae94a8c90.css","style"]
:HL["/_next/static/media/797e433ab948586e-s.p.dbea232f.woff2","font",{"crossOrigin":"","type":"font/woff2"}]
:HL["/_next/static/media/caa3a2e1cccd8315-s.p.853070df.woff2","font",{"crossOrigin":"","type":"font/woff2"}]
0:{"P":null,"b":"mBvv-8jKQck9aTb88x5b-","c":["","sync"],"q":"","i":false,"f":[[["",{"children":["sync",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],[["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/59ecb22ae94a8c90.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"className":"geist_a71539c9-module__T19VSG__variable geist_mono_8d43a2aa-module__8Li5zG__variable antialiased","children":["$","$L2",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L3",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]}]}]]}],{"children":[["$","$1","c",{"children":[null,["$","$L2",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L3",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","forbidden":"$undefined","unauthorized":"$undefined"}]]}],{"children":[["$","$1","c",{"children":[[["$","$4",null,{"fallback":["$","p",null,{"children":"Loading 0 - async section"}],"children":"$L5"}],"$L6",["$","$4",null,{"fallback":["$","p",null,{"children":"Loading 1 - cached async section"}],"children":"$L7"}],["$","$4",null,{"fallback":["$","p",null,{"children":"Loading 2 - use section"}],"children":["$","$L8",null,{"promise":"$@9"}]}]],[["$","script","script-0",{"src":"/_next/static/chunks/c88b82a5d58ad558.js","async":true,"nonce":"$undefined"}]],["$","$La",null,{"children":["$","$4",null,{"name":"Next.MetadataOutlet","children":"$@b"}]}]]}],{},null,true,false]},null,true,false]},null,true,false],["$","$1","h",{"children":[null,["$","$Lc",null,{"children":"$@d"}],["$","div",null,{"hidden":true,"children":["$","$Le",null,{"children":["$","$4",null,{"name":"Next.Metadata","children":"$@f"}]}]}],["$","meta",null,{"name":"next-size-adjust","content":""}]]}],true]],"m":"$undefined","G":["$10",[]],"S":true}
6:["$","section",null,{"data-index":1,"children":["Cached Async Section: ",313," @ ","18:15:29.563"]}]
7:["$","section",null,{"data-index":1,"children":["Cached Async Section: ",313," @ ","18:15:29.563"]}]
d:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]
f:[["$","title","0",{"children":"Create Next App"}],["$","meta","1",{"name":"description","content":"Generated by create next app"}],["$","link","2",{"rel":"icon","href":"/favicon.ico?favicon.0b3bf435.ico","sizes":"256x256","type":"image/x-icon"}],["$","$L11","3",{}]]
b:null

파일을 보면 :HL로 시작하는 줄 이외에는 모두 $hexInt:$serializedComponent처럼 되어 있다. "0"번의 관심 있는 부분을 보면 (formatted)

{
  "children": [
    [
      "$",
      "$1", // <------------------- 1:"$Sreact.fragment" - <Fragment>
      "c",
      {
        "children": [
          [
            [
              "$",
              "$4", // <---------- 4:"$Sreact.suspense" - <Suspense>
              null,
              {
                "fallback": [
                  "$",
                  "p", // <------- <p> 태그
                  null,
                  { "children": "Loading 0 - async section" }
                ],
                "children": "$L5" // <--- .prefetch.rsc 파일에 "5:..."가 없으므로
                                  //      static file로 저장되지 않았다는 것을 알 수 있음
              }
            ],
            "$L6", // <---------- 6:["$","section",null,{"data-index":1,"children":["Cached Async Section: ",313," @ ","18:15:29.563"]}]
            [
              "$",
              "$4",
              null,
              {
                "fallback": [
                  "$",
                  "p",
                  null,
                  { "children": "Loading 1 - cached async section" }
                ],
                "children": "$L7" // <---------- 7:["$","section",null,{"data-index":1,"children":["Cached Async Section: ",313," @ ","18:15:29.563"]}]
              }
            ],
            [
              "$",
              "$4",
              null,
              {
                "fallback": [
                  "$",
                  "p",
                  null,
                  { "children": "Loading 2 - use section" }
                ],
                // 8:I[2991,["/_next/static/chunks/c88b82a5d58ad558.js"],"UseSectionContent"]
                //   는 클라이언트 컴포넌트이므로 별도 chunk 파일이 생성됨
                // "$@9"는 promise로 서버에서 실행 후 스트리밍 형태로 전달됨 
                "children": ["$", "$L8", null, { "promise": "$@9" }]
              }
            ]
          ],
          [
            [
              "$",
              "script",
              "script-0",
              {
                "src": "/_next/static/chunks/c88b82a5d58ad558.js",
                "async": true,
                "nonce": "$undefined"
              }
            ]
          ],
          [
            "$",
            "$La",
            null,
            {
              "children": [
                "$",
                "$4",
                null,
                { "name": "Next.MetadataOutlet", "children": "$@b" }
              ]
            }
          ]
        ]
      }
    ],
    {},
    null,
    true,
    false
  ]
}

으로, 생각대로 partial prerender가 동작하고 있는 것을 알 수 있었다.

Static html file에 들어있지 않은 promise의 경우에는 <script> 태그에 데이터를 담은 후, 함수를 실행시켜 해당하는 DOM을 채워넣는 방식으로 작동한다. 아래 부분이 스트리밍을 통해 추가된 부분이다.

따라서 별도의 JavaScript 파일을 받는 것은 아니지만 <script> 태그에 의존하기 때문에, (static html file에 prerender 되어 있지 않은) Suspense 바운더리 내의 컴포넌트들의 경우에는 JavaScript 없이는 렌더가 되지 않는다.

맺음말

다음 포스트에서는 Context나 React Query의 HydrationBoundary의 동작과 "use cache: private"이나 "use cache: remote" 등 directive가 어떻게 동작하는지 알아보면 좋을 것 같습니다 총총

0개의 댓글