Next.js에서 동적 데이터를 URL에 인코딩하는 Precompute 패턴

작성일:2026.08.05|수정일:2026.08.05|조회수:10

Next.js에서 동적 데이터를 URL에 인코딩하는 Precompute 패턴

이 포스트는 Vercel의 Next.js 팀 소속 개발자 Aurora Scharff가 자신의 블로그에 올린 The Precompute Pattern: Encoding Dynamic Data into URLs in Next.js 게시글을 번역한 것이다. 번역하는 과정에서 다소 의역이 있을 수 있으며, 일부 번역에는 사견이 포함되어있기도 하다.

Next.js 16의 cache components 이전에는 page가 fully static이거나 fully dynamic인 경우가 많았다. layout에서 cookies()headers()를 한 번 호출하면, 그 아래에 중첩된 모든 page가 dynamic rendering으로 강제되었다. Precompute pattern은 request-specific data를 URL에 encode해 이 문제를 우회하는 방식이었다. dynamic rendering을 known variant를 가진 static generation으로 바꾸는 것이다.

cacheComponents가 있으면 대부분의 경우 이 pattern은 더 이상 필요하지 않다. 하지만 production, 특히 큰 e-commerce 팀에서는 여전히 사용된다. Vercel Flags SDK가 formalize한 개념이기도 하고, i18n library가 locale routing에 사용하는 방식과도 같은 계열이다. 이 글에서는 commerce demo branch를 바탕으로 이 pattern이 어떻게 동작하는지 살펴보고, high cardinality, ISR limitation, cache components 이후의 trade-off를 생각해본다.

문제: Dynamic Rendering

cookies()headers() 같은 dynamic API를 호출하는 component는 dynamic rendering으로 opt-in된다. root layout에서 이런 일이 발생하면 영향 범위가 특히 넓다. root layout은 그 아래의 모든 page를 감싸기 때문이다. e-commerce app에서 header를 위해 auth state를 확인하는 예를 보자.

TSX
// app/layout.tsxexport default async function RootLayout({  children,}: {  children: React.ReactNode;}) {  const isLoggedIn = await isAuthenticated(); // reads cookies()  return (    <html>      <body>        <Header isLoggedIn={isLoggedIn} />        <main>{children}</main>      </body>    </html>  );}

cookies() 호출은 product listing, category page, marketing page처럼 원래는 fully shareable한 page까지 모두 dynamic으로 만든다. 사용자별로 달라지는 부분은 login button이나 personalized recommendation 하나일 수 있지만, dynamic call 하나가 전체 route tree로 cascade된다. 팀들은 이를 route group 분리나 client-side personalized content fetching으로 우회해왔다. Precompute pattern은 또 다른, 조금 더 구조화된 접근이다.

오늘날 cache components는 이 specific problem을 다른 방식으로 해결한다. 뒤에서 다룰 것이다. 하지만 Precompute pattern은 그보다 먼저 존재했고, 다른 use case에서는 여전히 의미가 있다.

Precompute Pattern

component 안에서 cookies() 같은 dynamic API를 읽는 대신, middleware, 지금의 proxy에서 dynamic data를 한 번 resolve하고 URL의 hidden segment로 encode한다. page는 그 값을 일반 parameter처럼 보고, known variant별로 static generation될 수 있다.

흐름은 다음과 같다.

  1. request가 proxy에 도착한다.
  2. proxy가 cookies() 또는 다른 dynamic API를 읽고 precomputed context를 결정한다.
  3. context를 encode해 path segment로 URL 앞에 붙인다.
  4. Next.js는 encoded segment를 포함한 request로 rewrite한다.
  5. page는 dynamic API를 직접 호출하는 대신 params에서 context를 읽는다.

page가 cookies()headers()를 직접 호출하지 않고 params만 읽기 때문에 static render가 가능하다. known variant에 대해 generateStaticParams를 제공하면 Next.js는 build time에 미리 생성하거나 ISR로 cache할 수 있다.

구현

다음은 위에서 언급한 commerce demo branch의 단순화된 예시다. 여기서는 loggedIn boolean 하나만 encode하지만, 실제 setup에서는 locale, feature flags, A/B test variant, user type, currency 등 request에서 resolve할 수 있는 값을 넣을 수 있다.

Precomputed Context 정의하기

context의 shape과 encode/decode 함수를 정의한다. 예시에서는 별도 file에 둔다. context는 URL을 깔끔하게 유지하기 위해 base64url로 serialize한다.

TS
// utils/request-context.tsexport interface RequestContextData {  loggedIn: boolean;  // Examples of other data you could include:  //   locale?: string;              // 'en', 'no', 'sv'  //   theme?: 'light' | 'dark';  //   userType?: 'b2c' | 'b2b';  //   featureFlags?: string[];      // ['newCheckout', 'betaFeatures']  //   region?: string;              // 'eu', 'us', 'asia'  //   currency?: string;            // 'USD', 'EUR', 'NOK'  //   experiments?: Record<string, string>; // A/B testing variants}export function encodeRequestContext(data: RequestContextData): string {  const jsonString = JSON.stringify(data);  return Buffer.from(jsonString).toString("base64url");}export function decodeRequestContext(encoded: string): RequestContextData {  try {    const jsonString = Buffer.from(encoded, "base64url").toString();    const data = JSON.parse(jsonString);    return {      loggedIn: typeof data.loggedIn === "boolean" ? data.loggedIn : false,    };  } catch {    return { loggedIn: false };  }}export function getRequestContext(params: {  requestContext: string;}): RequestContextData {  return decodeRequestContext(params.requestContext);}

encoding 결과는 eyJsb2dnZWRJbiI6dHJ1ZX0 같은 짧은 URL-safe string이다. 이 값이 proxy가 모든 request 앞에 붙이는 hidden path segment가 된다.

Proxy에서 encode하기

proxy는 cookie를 읽고, encoded context를 첫 번째 URL segment로 넣어 rewrite한다.

TS
// proxy.tsimport { NextResponse } from "next/server";import { encodeRequestContext } from "@/utils/request-context";import type { NextRequest } from "next/server";function isUserAuthenticated(request: NextRequest): boolean {  return !!request.cookies.get("selectedAccountId")?.value;}export function proxy(request: NextRequest) {  const encodedContext = encodeRequestContext({    loggedIn: isUserAuthenticated(request),  });  const nextUrl = new URL(    `/${encodedContext}${request.nextUrl.pathname}${request.nextUrl.search}`,    request.url  );  return NextResponse.rewrite(nextUrl, { request });}export const config = {  matcher: ["/((?!api|_next/static|_next/image|favicon.ico|.*\\..*).*)"],};

browser에는 여전히 /products가 보이지만, server에서는 /eyJsb2dnZWRJbiI6dHJ1ZX0/products로 route된다. encoded segment는 사용자에게 보이지 않는다.

Component에서 context 읽기

이전에 cookies()isAuthenticated()를 호출하던 component는 이제 decoded precomputed context를 읽는다. page나 component는 requestContext param을 받고, getRequestContext로 decode한다. 즉 이 param을 필요로 하는 component까지 전달해야 하므로 prop drilling이 생길 수 있다.

TSX
import { getRequestContext } from "@/utils/request-context";export default async function UserProfile({  params,}: {  params: Promise<{ requestContext: string }>;}) {  const { requestContext } = await params;  const { loggedIn } = getRequestContext({ requestContext });  if (!loggedIn) {    return <LoginButton />;  }  return <ProfileMenu />;}

cookies() call이 없으므로 dynamic rendering도 없다. layout 자체는 auth state를 resolve할 필요 없이 header와 children만 렌더링할 수 있다.

TSX
// app/[requestContext]/layout.tsxexport default async function RequestContextLayout({  children,}: LayoutProps<"/[requestContext]">) {  return (    <>      <Header rightContent={<UserProfile />} />      <main>{children}</main>    </>  );}

[requestContext] param은 variant를 구분하지만, layout이 직접 이 값을 읽을 필요는 없다.

generateStaticParams로 variant 미리 생성하기

page를 fully static으로 만들려면 generateStaticParams가 known precomputed context variant를 반환해야 한다. 여기서는 logged out과 logged in 두 가지다.

TS
import { encodeRequestContext } from "@/utils/request-context";import type { RequestContextData } from "@/utils/request-context";export async function generateStaticParams() {  const contexts: RequestContextData[] = [    { loggedIn: false },    { loggedIn: true },  ];  return contexts.map(context => {    return {      requestContext: encodeRequestContext(context),    };  });}

build output에서는 두 variant가 static page로 생성된 것을 확인할 수 있다.

TXT
Route (app)                                    Size  First Load JS┌ ○ /[requestContext]                          ...   ...├ ├ /eyJsb2dnZWRJbiI6ZmFsc2V9├ └ /eyJsb2dnZWRJbiI6dHJ1ZX0

첫 generation 이후 page는 매 request마다 server를 때리는 대신 CDN cache에서 제공된다.

Flags SDK Precompute

이 pattern은 Aurora Scharff가 만든 것이 아니다. Vercel Flags SDK에서 “precompute” pattern으로 formalize되어 있다. SDK는 flag value를 encrypted URL segment로 encode하는 precompute function과 build-time generation을 위한 generatePermutations helper를 제공한다.

TS
import { type NextRequest, NextResponse } from "next/server";import { precompute } from "flags/next";import { marketingFlags } from "./flags";export const config = { matcher: ["/"] };export async function proxy(request: NextRequest) {  const code = await precompute(marketingFlags);  const nextUrl = new URL(    `/${code}${request.nextUrl.pathname}${request.nextUrl.search}`,    request.url  );  return NextResponse.rewrite(nextUrl, { request });}

page는 request time에 flag를 live evaluation하지 않고, precomputed code에서 flag 값을 읽는다.

TSX
import { marketingFlags, showBanner } from "../../flags";export default async function Page({  params,}: {  params: Promise<{ code: string }>;}) {  const { code } = await params;  const banner = await showBanner(code, marketingFlags);  return <div>{banner ? <p>Welcome</p> : null}</div>;}

Flags SDK는 encryption(FLAGS_SECRET 환경 변수 필요), build-time rendering을 위한 generatePermutations, 새 조합을 lazy caching하기 위한 ISR까지 처리한다. 예시의 구현은 설명을 위해 base64url encoding만 사용했지만, underlying idea는 같다.

High Cardinality와 e-commerce trade-off

High cardinality는 특정 dimension이 가질 수 있는 값이 많다는 뜻이다. product/[id] 같은 e-commerce route는 그 자체로 high cardinality다. 수천 개의 product가 있으면 수천 개의 page가 된다. Precompute pattern은 그 위에 또 다른 dimension을 추가한다. encoded data(auth state, locale, currency, feature flag)가 기존 route variant 전체를 곱한다.

TXT
app/├── [requestContext]/│   ├── page.tsx              # home│   ├── all/page.tsx          # product listing│   ├── product/[id]/page.tsx # product detail (thousands of products)│   ├── cart/page.tsx│   ├── about/page.tsx│   └── user/page.tsx

로그인 상태만 있어도 route는 두 variant가 된다. 여기에 locale 세 개와 currency 네 개를 추가하면, 모든 product page가 catalog 전체에서 24 variant를 갖는다. build-time generation은 비현실적이 되고, cache가 너무 많은 조합으로 분산되므로 ISR cache hit rate도 떨어진다.

Aurora Scharff가 함께 일한 e-commerce 팀들은 precomputed context에 무엇을 넣을지 선택적으로 결정한다고 한다. auth state와 locale은 cardinality가 낮고 page의 넓은 영역에 영향을 주므로 좋은 후보가 될 수 있다. variant가 많은 feature flag나 A/B test도 precompute할 수는 있지만, 가장 흔한 조합만 pre-generate하고 나머지는 ISR에 맡긴다. Flags SDK 문서도 하나의 global flag group보다 page별로 scope가 나뉜 여러 flag group을 사용하라고 권장한다. permutation count를 제한하기 위해서다.

ISR 자체에도 trade-off가 있다. ISR은 incremental static regeneration을 위해 설계된 것이지, incremental static generation을 완벽히 해결하기 위한 것은 아니다. generateStaticParams로 pre-generate되지 않은 param 조합을 처음 request하면 render는 blocking된다. 사용자는 full page가 만들어질 때까지 기다린다. 그 사이 fallback shell이 즉시 제공되지 않는다. 이 limitation을 해결하기 위해 Next.js team은 generic fallback shell을 즉시 제공하고 background에서 full page를 만드는 fallback upgrading을 작업 중이라고 한다. 그 전까지 ISR을 통한 on-demand generation은 새 variant마다 cold-start penalty를 만든다.

또 CSS 변경처럼 모든 page에 영향을 줄 수 있는 deploy가 있을 때 ISR cache가 날아가는 문제도 있다. progressive generation에 ISR을 사용하면 read 대비 write가 너무 많아질 수 있다. Cache components는 더 granular한 control을 이론적으로 제공하지만, ISR level에서 무엇이 cache되고 evict되는지 제어하는 문제는 여전히 open question이다.

'use cache'가 이 pattern을 불필요하게 만드는 경우

Next.js 16의 cacheComponents가 있으면 많은 경우 Precompute pattern은 불필요해진다. dynamic rendering을 피하기 위해 데이터를 URL에 encode하는 대신, 개별 component에 'use cache'를 사용하고 Partial Prerendering이 static content와 dynamic content를 나누게 할 수 있다.

commerce demo의 main branch에서는 layout이 auth check를 await하지 않고 promise로 provider에 넘긴다.

TSX
// app/layout.tsxexport default async function RootLayout({ children }: LayoutProps<"/">) {  const loggedIn = getIsAuthenticated(); // no await, no blocking  return (    <html lang="en">      <body>        <AuthProvider loggedIn={loggedIn}>          <Header />          <main>{children}</main>        </AuthProvider>      </body>    </html>  );}

auth check를 await하지 않으므로 layout rendering을 block하지 않는다. promise는 AuthProvider를 통해 흐르고, 필요한 곳에서 resolve된다. UserProfile 같은 server component는 getCurrentAccount()를 통해 직접 await할 수 있고, client component는 use()로 promise를 unwrap하는 hook을 통해 접근한다.

TSX
// features/auth/components/AuthProvider.tsxexport const useLoggedIn = () => {  const { loggedIn } = useAuth();  return use(loggedIn);};

어느 쪽이든 auth state를 소비하는 component만 suspend되고, page의 나머지는 즉시 render된다. 이것은 sharing data with Client Components pattern을 따른다.

dynamic API에 의존하지 않는 component는 'use cache'로 독립적으로 cache된다.

TSX
// features/product/components/FeaturedProducts.tsxexport default async function FeaturedProducts() {  "use cache";  cacheTag("featured-product");  const products = await getFeaturedProducts(4);  return (    <div>      {products.map(product => (        <ProductCard key={product.id} /* ... */ />      ))}    </div>  );}

UserProfile 안의 cookies() call은 그 component만 dynamic하게 만든다. FeaturedProducts, Hero, FeaturedCategories 같은 cached component는 즉시 제공되는 statically generated shell의 일부가 되고, dynamic user profile은 progressive하게 stream된다.

그렇다고 cache components가 모든 경우를 해결하는 것은 아니다. 실제 e-commerce setup에서는 region, currency, user type, feature flags에 따라 cached content 자체가 달라질 수 있다. 그런 값은 cached component가 무엇을 렌더링하는지를 바꾸므로 단순히 dynamic으로 suspend할 수 없다. 이런 경우 Precompute pattern이나 Flags SDK는 'use cache'와 함께 여전히 유용할 수 있다.

rootParams: 빠진 조각

앞서 말했듯 requestContext를 params로 계속 전달해야 하는 것이 이 pattern의 가장 큰 ergonomic pain point 중 하나다. 앞으로의 rootParams 기능은 [locale]이나 [requestContext] 같은 top-level dynamic segment에 대해 이 문제를 해결한다. component tree를 통해 값을 thread하는 대신 component가 parameter를 직접 import할 수 있다.

TSX
import { locale } from "next/root-params";async function CachedComponent() {  "use cache";  const currentLocale = await locale();  // ...}

이 값은 자동으로 'use cache'의 cache key가 되므로, cached component는 manual prop passing 없이 locale 또는 다른 root parameter별로 달라질 수 있다. Aurora Scharff는 next-intl cache components post에서 rootParamssetRequestLocale과 explicit locale threading을 없애는 과정을 다뤘다.

Precompute pattern에서는 precomputed hash를 prop drilling 없이 tree 어디서든 읽을 수 있다는 뜻이다. feature flag value의 hash를 모든 page의 첫 URL segment로 두는 pattern을 쓰는 팀이라면, build를 만족시키기 위해 모든 page에 placeholder generateStaticParams를 둘 필요도 줄어든다.

전체 구현은 GitHub에서 볼 수 있고, commerce demo의 main branch는 같은 application을 'use cache'와 함께 구현한 버전을 보여준다.

이 글이 도움이 되었기를 바란다. 질문이나 의견이 있다면 Aurora Scharff에게 알려주고, 더 많은 업데이트를 보고 싶다면 그녀의 BlueskyX를 팔로우하면 된다. Happy coding! 🚀

댓글

댓글을 불러오는 중...