뒤늦은 Next.js 16

최근에 v16.3이 업데이트 됐다. 16? 언제 메이저버전이.. 바뀌었지

회사 프로젝트에서는 SPA가 메인이라 Next.js를 사용하지 않고 있습니다. 제 지식은 v15에서 멈춰있었는데요. 최근에 v16.3으로 업데이트된 Next.js를 보고. 문득 메이저버전이 바뀌었구나라는걸 깨달았습니다..

v16을 출시한지도 1년이 다 돼가고 있더군요. 참으로 반성할 일입니다.🫢 오랜 시간이 지난만큼 잘 정리된 글도 많겠지만, v13이후 메이저 업데이트는 매번 큰 축이 변한다라고 느꼈습니다.

직접 테스트해보면서 이해한 큰 축의 변화를 기록해봤습니다. 회사에서 React만 만지는 저로선 직접 테스트해보지 않으면 금방 휘발되버리니까요..

컴포넌트 하나가 페이지 전체를 동적으로 만든다

서버컴포넌트가 기본이 되면서 모든 페이지는 기본적으로 build때 페이지를 미리 만들어놓습니다, 이후 요청이오면 만들어놓은 페이지를 서빙하죠. 하지만 페이지가 빌드때 미리 알수없는 정보를 쓰는 컴포넌트를 렌더링한다면 어떻게될까요?

// app/page.tsx
export default async function HomePage() {
  return (
    <main>
      <DynamicComponent />
    </main>
  );
}

// dynamic-component.tsx
export const DynamicComponent = async () => {
  const cookie = await cookies(); // no information at build time

  if (cookie.get("auth")?.value !== "admin") {
    return <div>you're not admin!</div>;
  }

  return (<div>Dynamic Component</div>);
};

cookie는 빌드타임때 알 수 없습니다, 그래서 DynamicComponent가 포함된 페이지 전부를 dynamic-page로 처리하게 됩니다. 즉 저 한곳때문에 전체 페이지가 pre-rendering을 건너뛰어 성능상 이점을 누릴수없던거죠.

예시는 depth가 낮지만 깊어질수록 이지점을 리뷰때 발견하는것도 쉽지 않을겁니다. 16부터는 이런 이유들때문에 cacheComponents옵션을 통해 이런 경계를 타이트하게 가져가도록 강제할 수 있습니다.

// next.config.ts
const nextConfig: NextConfig = {
  cacheComponents: true,
};

export default nextConfig;

해당옵션을 켠뒤 build를 다시 하면 다음과같이 빌드 에러가 납니다.

image

Route “/”: A component accessed data, headers, params, searchParams, or a short-lived cache without a Suspense boundary nor a “use cache” above it.

메세지에 앞으로 설명할 모든게 담겨있습니다. 현재 root-route(page.tsx)를 문제삼고있고, fetch, cookies등은 ux를 위해 <Suspense /> 경계를 설정하라고 하네요. 왜그럴까요?

여기서 v15에서부터 ppr 필드로 opt-in하게 설정 가능했던 Partial Prerendering라는 개념이 나와야합니다.

동일한 경로에서 정적 렌더링과 동적 렌더링의 이점을 결합하는 렌더링 전략

공식문서에선 위와같이 소개하고있네요. <Suspense>를 이용해 빌드시점엔 fallback으로 페이지를 채우고, 요청시에 dynamic한 부분은 스트리밍으로 나중에 채워주는 방법입니다.

export default async function HomePage() {
  return (
    <main>
      <Suspense fallback={<div>fallback</div>}>
        <DynamicComponent />
      </Suspense>
    </main>
  );
}
image

빌드가 잘 됩니다. root-route가 반쯤 채워진 원으로 Partial Prerender가 적용되었네요. dynamic-routes 페이지들도 한번 살펴봅시다.

// post/[slug]/page.tsx
export default async function PostPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params; // what is the value at build-time?

  return (
    <Suspense>
      <Post slug={slug} />
    </Suspense>
  );
}

// post.tsx
export const Post = async ({ slug }: { slug: string }) => {
  const post = await getPost(slug);

  if (!post) {
    notFound();
  }

  return (<main>...</main>);
};

params를 빌드타임때 알 수 없으니 당연히 build-error가 발생할겁니다. generateStaticParams를 이용해 빌드타임때 일부 slug를 제공하면 문제가 없어보입니다.

export async function generateStaticParams() {
  return [{ slug: "1" }, { slug: "2" }, { slug: "3" }];
}

export default async function PostPage({...})
image

ppr이 됐습니다. 당연한 결과입니다. 하지만 이쯤됐을때 의문이 듭니다. 왜 옵션이름이 ppr이 더 어울려보이는데 16부터 cacheComponents로 바뀐걸까요?

먼저 cacheComponents를 켜게되면 기본 캐시축이 dynamic으로 바뀐다는 사실을 알아야합니다. 여기서 dynamic은 dynamic-page가 아닌 모든걸 캐싱 안하겠다 라는 말이니 주의해주세요.

캐시축의 반전, use cache

v15 까지는 기본 캐시축이 static입니다. 즉 모든걸 캐싱하겠다 입니다. 하여 개발때 발견하지못한 캐싱문제를 production 빌드시에 발견하는 문제가 있었습니다.

v16에선 내가 원할때 캐싱하겠다가 기본전제입니다. 이걸 use cache라는 지시어로 처리합니다.

// page.tsx
export default function HomePage() {
  return (
    <main>
      <Suspense fallback={<div>fallback</div>}>
        <PostList />
      </Suspense>
    </main>
  );
}
// post-list.tsx
export const PostList = async () => {
  const posts = await getPosts();

  return (
    <ul>
      {posts.map((post: Post) => (
        <li key={post.id}>
          <Link href={`/blog/${post.id}`}>
            <h2>{post.title}</h2>
          </Link>
          <p>{post.body}</p>
        </li>
      ))}
    </ul>
  );
};

위에 언급했듯이 fetch를 사용하기때문에 Suspense경계가 필요하고, build시 page는 ppr입니다. 여기서 ppr을 유발하는 post-list컴포넌트를 use cache를 써서 캐싱하면 어떻게될까요?

export const PostList = async () => {
  "use cache";
  const posts = await getPosts();

  return <ul>...</ul>;
};
image

build 결과 root-route가 static이 됐습니다! 빌드시점에 기본적으로 모든 함수를 돌립니다. 근데 use cache지시어를 만났으니 컴포넌트 결과를 캐싱해뒀다가 요청이왔을때 그걸 건네주는겁니다. 즉 요청때 페이지를 만드는게아니니 static-page처리가 된거죠.

기본 기조가 원하는곳에만 캐싱해라! 라고 바뀌었기때문에 use cache는 모듈,컴포넌트,함수 레벨에 설정할 수 있습니다, 캐싱범위는 직관적으로 use cache가 위치한 컨텍스트 전부가 되겠네요.

캐시수명 설정

수명조절은 use cache지시어 내 cacheLife()가 위치함으로써 가능합니다. 기본값은 아래와 같고, next.config.ts내 기본값 수정도 가능하며, 커스텀 life또한 생성 가능합니다

  • cacheLife값들은 공식문서에서 확인할 수 있습니다.
cacheLife({
    stale: 60 * 5, // 5 min
    revalidate: 60 * 15, // 15 min
    expire: Infinity // never expired
})

//next.config.ts
const nextConfig = {
  cacheComponents: true,
  cacheLife: {
    // Redefine the 'default' profile
    default: {
      stale: 60 * 5, // 5 min
      revalidate: 60 * 60, // 1 hour
      expire: 60 * 60 * 24, // 1 day
    },
    customLife:{
        stale: 60 * 3, // 3 min
        revalidate: 60 * 5, // 5 min
        expire: 60 * 60 * 24 * 7, // 7 days
    }
  },
}

export default nextConfig

아까 예시에서 PostList컴포넌트의 캐시수명을 짧게 설정 후 빌드해보겠습니다. 빌드타임때 실행된 결과물을 캐싱해두긴 하지만 너무 짧아 페이지 요청시점에 이미 revalidate 시간이 지나 새롭게 만들 가능성이 높을겁니다.

export const PostList = async () => {
  "use cache";
  cacheLife("seconds");

  const posts = await getPosts();

  return <ul>...</ul>;
};
image

수명이 짧으면 빌드때 아예 ppr로 처리해버리네요. 충분히 납득될만합니다. 공식문서에 자동으로 ppr처리되는 시간을 가이드해두었네요.

이렇게 캐시를 적용할 수명과 범위를 opt-in하게 설정할수 있게 됐습니다. 이에맞춰 그 캐싱을 tag로써 관리 가능하게 cacheTag()란 함수가 생겼습니다. 보통 명시적으로 캐시를 무효화 시키기위해 사용될겁니다.

이에관해 16에서는 기존 revalidateTag()함수의 required한 2번째인자가 생겼고, 새로생긴 updateTag() 함수도 있습니다.

export const PostList = async () => {
  "use cache";
  cacheTag("post-list");

  const posts = await getPosts();

  const upsertAction = async () => {
    "use server"
    // run upsert action..
    updateTag("post-list");
    // or
    revalidateTag("post-list", "max");
    // or revalidateTag("post-list", { expire : number })
  };

  return (<ul>...</ul>);
};

tag로써 cache를 무효화 하기위한 목적은 두 함수 모두 동일합니다. 다만 fresh 데이터를 받아오는동안 사용자 요청이 들어오면 어떻게 될까요? 여기서 두 함수의 미묘한 차이가 생깁니다.

revalidateTag는 태그된 데이터를 그 즉시 stale로 마킹해둡니다. 그럼 이후 요청부터 fresh한 데이터를 받아올건데요, 그 사이 요청들에게는 기존 stale한 데이터를 응답합니다. 2번째 인자는 얼마동안 stale한 데이터를 보여줄거냐에 대한 값으로, max 같은 프로필 문자열이나 {expire:number}로 상세시간을 정해줄수 있습니다.

cacheLife() 에도 expire필드가 존재했죠? 그런고로 cacheLife에 값으로 사용될수 있었던 seconds등 사용도 가능합니다! custom까지 포함해서요.

반면 updateTag는 데이터를 받아오는 동안 들어온 사용자의 요청은 blocking합니다. 즉 stale한 데이터를 아예 보여줄생각이 아예 없는거죠. 그래서 시간설정하는 option도 없습니다. 단 ServerAction에서만 사용 가능합니다.

Note: 두 함수 모두 데이터를 받아오는걸 직접 트리거하지 않습니다. 태그만 stale로 즉시 마킹하고, 액션실행이후 요청온 url에 대한 페이지를 다시 렌더링해서 클라이언트로 보낼때 다시 함수가 실행되기때문에 다시 받아와지는겁니다.

기타 업데이트

가장 체감이 되는건 Turbopack이 default가 된겁니다. 덕분에 dev와 build 속도 체감이 어마어마해졌습니다. 이외에도 middleware.ts → proxy.ts renaming 및 15에서 예고했던 deprecated될 예정들의 적용 등이 있었습니다.


Next.js는 v13의 Pages Router에서 App Router로의 대격변 이후, v16에 오기까지 정말 많은 변화들이 있었습니다.

너무 커져버린 클라이언트 번들을 문제삼아 react팀이 고안한 서버컴포넌트,액션 이라는 개념이 Next.js v13~14를 거쳐 ssr환경과 통합되었습니다. 자연스레 hydration으로 인해 동일한 js를 2번실행하는 문제를 해결하고, 디테일한 캐싱 전략으로 성능이점을 챙겼습니다. 하지만 Next.js를 통한 개발이 점점 어색해지고, 디버깅하기 힘들다는 의견등이 생겼습니다.

v16은 이런 문제를 해결하기위해 정적, 동적인 경계를 좀더 타이트하게 가져가도록 강제하고, 캐싱을 opt-in으로해 production간 괴리를 줄인 업데이트라고 느꼈습니다. Next.js를 사용하는 주변 프로젝트를 보면 메이저버전이 제각각이고, 어떤 프로젝트에서 일할지도 모르는데.. 무려 1년이나 늦은 저를 반성해야겠습니다.🥲


참조