본문으로 건너뛰기

프리렌더링과 정적 셸

Next.js 16.3, cacheComponents를 켠 상태를 기준으로 합니다.

// next.config.ts
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
cacheComponents: true,
};

export default nextConfig;

이 옵션을 켜면 use cache 지시어를 쓸 수 있고, 이 문서에서 설명할 프리렌더링이 기본 동작이 됩니다. use cache는 async 함수나 컴포넌트의 결과를 캐시해 두라는 표시입니다.

프리렌더링이란 무엇인가

요청이 도착하기 전에 컴포넌트를 미리 실행해서 결과를 만들어 두는 것입니다. 빌드할 때 하거나, 재검증할 때 백그라운드에서 합니다.

한 가지 짚고 갈 것이 있습니다. 프리렌더링은 SSG·ISR·SSR과 나란히 놓고 고르는 네 번째 선택지가 아닙니다. SSG와 ISR을 이루는 부품이고, SSR은 그것을 쓰지 않는 쪽입니다.

방식프리렌더를 쓰는가적용 단위요청이 왔을 때
SSG예. 빌드 때 한 번페이지 전체만들어 둔 결과를 그대로 전달
ISR예. 빌드 때 + 주기가 지나면 백그라운드에서 다시페이지 전체만들어 둔 결과를 전달하고, 필요하면 뒤에서 재생성
SSR아니오페이지 전체를 그때 렌더링
PPR페이지의 일부프리렌더된 부분을 먼저 보내고, 나머지만 렌더링해서 이어 붙임

SSG와 ISR의 차이는 만들어 둔 것을 다시 만드느냐뿐입니다. 둘 다 프리렌더링 위에 있습니다. Next.js가 재검증 중의 백그라운드 렌더링까지 프리렌더링으로 치는 이유이기도 합니다. SSR만 프리렌더를 아예 쓰지 않습니다.

PPR도 새로운 전략이라기보다 프리렌더의 적용 단위를 페이지에서 페이지 일부로 내린 것입니다. 그래서 한 페이지가 SSG의 응답 속도와 SSR의 최신성을 동시에 가질 수 있습니다.

어디서 만드는가가 아니라 언제 만드는가입니다

프리렌더링은 렌더링 위치와 무관합니다. 빌드 머신이든 배포된 서버든 어차피 브라우저 밖이고, 달라지는 것은 요청 전이냐 요청 후냐 하나입니다.

데이터를 가져오는 일과도 별개가 아닙니다. 오히려 데이터가 가능 여부를 결정합니다. 컴포넌트가 await fetch(...)를 하면 그 응답 없이는 HTML을 만들 수 없습니다. 그래서 다음 두 질문은 같은 질문입니다.

"이 부분을 미리 만들 수 있는가" = "이 부분에 들어갈 데이터를 미리 확보할 수 있는가"

use cache가 렌더링 이야기에 계속 나오는 이유가 이것입니다. 캐시는 데이터를 미리 확보하는 수단이고, 확보돼야 그 부분이 미리 만들어집니다. 공식 문서가 이 내용을 Caching 페이지에 둔 것도 같은 이유입니다.

한 페이지 안에서 정적인 부분과 요청마다 달라지는 부분이 갈라진다는 것이 핵심입니다. 그 결과물을 정적 셸이라고 부릅니다.

정적 셸이란 무엇인가

셸(shell)은 껍데기라는 뜻입니다. 완성된 페이지가 아니라, 사용자마다 달라지는 자리는 비워 둔 채 미리 만들어 둘 수 있는 부분만 모아 놓은 HTML입니다.

대시보드 페이지를 예로 봅니다.

import { cookies } from 'next/headers';
import { Suspense } from 'react';

async function UserGreeting() {
const cookieStore = await cookies();
const theme = cookieStore.get('theme')?.value || 'light';
return <p>테마: {theme}</p>;
}

export default function Page() {
return (
<>
<h1>대시보드</h1>
<Suspense fallback={<p>불러오는 중</p>}>
<UserGreeting />
</Suspense>
</>
);
}

이 페이지의 정적 셸은 이렇게 생겼습니다.

<h1>대시보드</h1>
<p>불러오는 중</p>

<h1>은 누가 요청하든 같으니 빌드할 때 미리 만들어 둘 수 있습니다. 테마는 쿠키를 읽어야 알 수 있으니 "불러오는 중"으로 자리만 잡아 둡니다.

요청이 오면 순서는 이렇게 됩니다.

  1. 서버가 위 HTML을 먼저 보냅니다. 미리 만들어 둔 것이라 기다릴 것이 없습니다
  2. 사용자는 이 화면을 바로 봅니다
  3. 그동안 서버는 쿠키를 읽어 UserGreeting을 완성합니다
  4. 완성되면 같은 응답에 이어서 그 조각을 보내고, 브라우저가 "불러오는 중" 자리를 바꿔 끼웁니다
스트리밍이 무엇인가요

4번처럼 응답을 나눠 보내는 것을 스트리밍이라고 합니다. 서버가 응답을 한 번에 완성해서 보내지 않고, 준비되는 조각부터 보냅니다. 첫 조각을 보낸 뒤에도 연결을 끊지 않고 열어 둡니다.

  • 안 쓰면 서버가 모든 데이터를 다 모을 때까지 사용자는 빈 화면을 봅니다
  • 쓰면 완성된 부분이 먼저 보이고, 느린 부분은 자리만 잡아 뒀다가 나중에 채워집니다

<Suspense>는 그 "나중에 채울 자리"를 어디로 할지 정하는 표시입니다. 표시가 없으면 서버는 그 부분이 끝날 때까지 아무것도 내보내지 못합니다.

미리 만들어 두는 것이라 요청이 왔을 때 원본 서버까지 가지 않고 CDN에서 바로 나갈 수 있습니다. 직접 방문이 빠른 이유입니다.

셸은 두 가지 형태로 만들어집니다.

형태쓰이는 상황
HTML주소창에 URL을 넣거나 새로고침해서 들어왔을 때
RSC 페이로드다른 페이지에서 링크를 눌러 이동해 왔을 때

RSC 페이로드는 브라우저의 React가 화면을 그리는 데 쓰는 데이터 형태입니다. 이미 페이지가 떠 있는 상태에서 이동할 때는 HTML 문서를 통째로 다시 받을 필요가 없어서 이쪽을 씁니다. 어느 쪽으로 들어오든 완성된 내용을 즉시 받게 하려고 두 벌을 만들어 둡니다.

이렇게 정적인 부분을 미리 만들고 나머지는 스트리밍으로 채우는 방식을 Partial Prerendering(PPR) 이라고 하며, Cache Components를 켜면 기본 동작이 됩니다.

프리렌더 판정 기준

그럼 무엇이 셸에 들어가고 무엇이 빠지는지가 문제입니다. 빌드할 때 Next.js는 라우트의 컴포넌트 트리를 렌더링하는데, 각 컴포넌트가 무엇을 쓰느냐에 따라 네 갈래로 갈립니다.

쓰는 것프리렌더 결과
use cache결과가 캐시되고 정적 셸에 포함됩니다. 단 수명이 너무 짧으면 제외됩니다
<Suspense>fallback이 셸에 들어가고 본문은 요청 시점에 스트리밍됩니다
예측 가능한 값프리렌더 중에 완료되어 자동으로 셸에 포함됩니다
랜덤값·타임스탬프명시적으로 처리해야 합니다

use cache를 붙인 함수나 컴포넌트는 빌드할 때 한 번 실행되고 그 결과가 셸에 그대로 들어갑니다. <Suspense>는 앞서 본 대로 나중에 채울 자리를 표시해 두는 것이라, fallback만 셸에 들어가고 본문은 요청 시점에 채워집니다.

아래 두 가지는 이름만으로 무엇이 해당하는지 알기 어려우니 따로 봅니다.

예측 가능한 값

모듈 import, 동기 I/O, 순수 계산입니다. 매번 같은 결과가 나오므로 빌드 때 실행해서 정적 HTML에 넣어 버립니다. fs.readFileSyncbetter-sqlite3, node:sqlite 같은 동기 API DB 조회도 여기 해당합니다. 요청별 데이터가 필요하다면 조회 전에 connection()을 불러야 합니다.

폰트나 설정 파일처럼 요청과 무관한 리소스를 비동기로 읽는 경우에는 렌더링 중이 아니라 모듈 스코프에서 한 번 읽는 편이 낫습니다.

import { readFile } from 'node:fs/promises';

// 모듈 스코프에서 한 번만 읽는다
const content = await readFile('./config.json', 'utf-8');
const items = JSON.parse(content).items ?? [];

export default function Page() {
return (
<ul>
{items.map((item) => (
<li key={item.id}>{item.value}</li>
))}
</ul>
);
}

컴포넌트 안에서 await readFile()을 하면 캐시되지 않은 데이터로 취급되어 use cache<Suspense> 중 하나를 요구받습니다.

랜덤값과 타임스탬프

Math.random(), Date.now(), crypto.randomUUID()처럼 실행할 때마다 값이 달라지는 것들입니다. 빌드 때 한 번 실행해서 굳혀 버리면 곤란하니, 둘 중 하나를 골라야 합니다.

// 요청마다 다른 값이 필요하다면 connection()으로 요청 시점까지 미루고 Suspense로 감싼다
import { connection } from 'next/server';

async function UniqueContent() {
await connection();
const uuid = crypto.randomUUID();
return <p>요청 ID: {uuid}</p>;
}
// 모두에게 같은 값을 보여도 된다면 캐시한다
export default async function Page() {
'use cache';
const buildId = crypto.randomUUID();
return <p>빌드 ID: {buildId}</p>;
}
참고

performance.now()는 계측용이라 예외입니다. Next.js가 막지 않습니다. 대신 화면에 렌더링하지 말고 로거나 메트릭으로 넘기라는 것이 문서의 안내입니다.

프리렌더를 막는 것들

프리렌더는 요청이 없는 상태에서 컴포넌트를 실행하는 일입니다. 그래서 요청이 있어야만 알 수 있는 값을 만나면 더 진행할 수 없습니다. 공식 문서는 이것들을 요청 시점 API로 묶어 부릅니다.

API무엇을 읽는가
cookies()브라우저가 이번 요청에 실어 보낸 쿠키로그인 세션, 테마 설정
headers()요청에 붙어 온 HTTP 헤더User-Agent, Accept-Language
searchParams주소의 ? 뒤에 붙은 값/search?q=shoesq
params주소의 동적 구간/shop/[slug]slug

cookies()를 예로 보면 분명합니다. 쿠키는 브라우저에 저장되어 있고, 브라우저는 그 도메인으로 요청을 보낼 때마다 저장된 쿠키를 헤더에 자동으로 붙입니다.

GET /dashboard
Cookie: theme=dark; session=abc123

cookies()는 서버가 보관해 둔 값을 꺼내 오는 것이 아니라 지금 들어온 이 요청에 붙어 온 저 헤더를 읽습니다. 그래서 요청이 없는 빌드 시점에는 읽을 것 자체가 없습니다.

캐시하지 않은 데이터 페치도 마찬가지입니다. 매 요청 최신 값이 필요한 fetch는 빌드 때 미리 실행해 둘 수 없습니다.

주소는 브라우저가 먼저 압니다

searchParamsparams를 보면 의문이 생깁니다. 주소만 정해지면 알 수 있는 값인데 왜 프리렌더를 막을까요. 네 가지는 성질이 둘로 나뉩니다.

언제 값이 정해지는가
cookies(), headers()사용자가 정해져야 압니다
searchParams, params주소가 정해지면 압니다

그래도 빌드 시점에는 넷 다 못 씁니다. 이유는 "동적 데이터라서"가 아니라 빌드할 때 그 값을 알 수 없어서입니다. 어떤 주소로 들어올지 모르고 쿼리 조합은 무한합니다. paramsgenerateStaticParams로 목록을 알려 줄 수 있지만, searchParams에는 그런 수단이 없습니다.

대신 주소가 정해지는 순간부터는 달라집니다. <Link href="/search?q=shoes">를 프리페치할 때는 URL이 확정돼 있으므로 Next.js가 클릭 전에 그 값으로 미리 렌더링해 둘 수 있습니다. 빌드 시점에 못 풀릴 뿐, 값을 아는 시점이 오면 풀립니다.

그리고 searchParams에는 서버를 거치지 않는 길이 하나 더 있습니다. 브라우저는 자기가 어떤 주소에 있는지 이미 알고 있으므로, 클라이언트 컴포넌트에서 읽으면 페이지는 정적으로 프리렌더된 채로 남습니다.

'use client';
import { useSearchParams } from 'next/navigation';

function Results() {
const q = useSearchParams().get('q'); // 브라우저가 URL에서 직접 읽는다
// ...
}

프리렌더 중에는 아직 값이 없으므로 <Suspense> 경계가 필요합니다. 정리하면 주소는 브라우저가 알고, 서버는 요청을 받아야 압니다. 그 값으로 서버에서 할 일(DB 조회 같은)이 없다면 클라이언트에서 읽어 정적 셸을 그대로 유지하는 편이 낫습니다.

Next.js가 그냥 넘어가지 않습니다

개발 서버로 그 페이지에 들어가면 화면 위 오버레이에 경고가 뜨면서, 어느 컴포넌트 때문에 이 라우트를 미리 만들 수 없는지 알려 줍니다. 공식 문서는 이 경고를 인사이트(insight) 라고 부르는데, 특별한 게 아니라 수정 방법 카드가 붙은 개발 모드 경고입니다. blocking-route는 그 경고에 붙은 이름입니다.

카드로 제시되는 선택지는 세 가지입니다.

카드하는 일
Stream<Suspense>로 감싼다
Cacheuse cache로 캐시한다
Block이 라우트는 검사하지 않겠다고 설정한다

Next.js가 이걸 굳이 띄우는 이유는 모든 라우트가 정적 셸을 가진다는 규칙을 지키려 하기 때문입니다. 셸을 만들 수 없는 코드는 개발 중에 붙잡습니다.

React에서 쓰던 그 Suspense와 같은 건가요

같은 컴포넌트에 같은 메커니즘입니다. "이 자리는 아직 준비 안 됐으니 fallback을 보여줘라"라는 역할은 동일합니다. 달라지는 것은 실행 위치입니다.

흔히 쓰는 클라이언트 Suspense이 문서의 Suspense
실행 위치브라우저서버 (빌드 시점 또는 요청 시점)
기다리는 것React.lazy 청크, 클라이언트 데이터서버 데이터, 요청 시점 API
fallback이 나타나는 곳브라우저의 DOM전송되는 HTML 안에 박혀서 나감

다만 Next.js가 역할을 하나 더 얹었습니다. 순수 React에서 <Suspense>는 로딩 UI를 위한 선택이라 안 써도 그냥 늦게 뜰 뿐입니다. Cache Components에서는 경계가 "여기까지가 정적 셸"이라는 판정선이 되기 때문에, 없으면 프리렌더 자체가 실패합니다.

Suspense가 동적으로 만드는 것이 아닙니다

<Suspense>는 비동기 작업이 끝날 때까지 보여 줄 fallback을 제공할 뿐, 컴포넌트를 동적 렌더링으로 전환하는 스위치가 아닙니다. 동기 작업만 하는 컴포넌트는 <Suspense>로 감싸도 프리렌더 중에 그대로 완료됩니다.

⭐️ 정적 셸을 최대한 넓히기

비동기 작업이 트리에서 깊이 있을수록 더 많은 부분이 프리렌더됩니다.

레이아웃 최상단에서 params를 푸는 흔한 코드를 봅니다.

// app/shop/[slug]/layout.tsx
export default async function Layout({
children,
params,
}: LayoutProps<'/shop/[slug]'>) {
const { slug } = await params;

return (
<div>
<Sidebar />
<h1>{slug}</h1>
{children}
</div>
);
}

sluggenerateStaticParams로 제공되지 않은 값이라면 이것은 요청 시점 데이터이고, 최상단에서 await했으므로 레이아웃 전체가 프리렌더 불가가 됩니다. Sidebar는 slug와 아무 관계가 없는데도 함께 끌려갑니다.

await 지점을 아래로 내립니다.

// app/shop/[slug]/layout.tsx
import { Suspense } from 'react';

// async가 아니다. 이 레이아웃은 params를 await하지 않는다
export default function Layout({ children, params }: LayoutProps<'/shop/[slug]'>) {
return (
<div>
<Sidebar />
<Suspense fallback={<h1>불러오는 중</h1>}>
{/* 경계 안에서 await하므로 셸은 그대로 만들어진다 */}
{params.then(({ slug }) => (
<SlugHeading slug={slug} />
))}
</Suspense>
{children}
</div>
);
}
정적 셸요청 시점
최상단에서 await없음레이아웃 전체
경계 안에서 awaitSidebar, children, fallbackSlugHeading

Promise를 await하지 않고 그대로 아래로 내려보내는 것이 핵심입니다. params 전체를 자식에게 넘겨 거기서 await해도 됩니다. 같은 원리가 cookies(), headers(), searchParams, 일반 데이터 페치에 모두 적용됩니다.

여기서 오해하기 쉬운 지점이 있습니다. 자식 컴포넌트로 옮기는 것만으로는 효과가 없습니다.

// 자식이 await하지만 <Suspense>가 없다
export default function Layout({ children, params }: LayoutProps<'/shop/[slug]'>) {
return (
<div>
<Sidebar />
<SlugHeading params={params} />
{children}
</div>
);
}

레이아웃 함수 자체는 끝납니다. 그런데 React가 <SlugHeading> 자리에 무엇을 넣을지 모르니 트리가 완성되지 않고, 결국 HTML도 만들어지지 않습니다.

이 "완성되지 않음"은 그 자리에 머물지 않고 부모로 전파됩니다. 자식 결과가 없으면 부모도 자기 출력을 완성할 수 없고, 그 부모도 마찬가지입니다. 막는 것이 없으면 전파가 루트까지 이어지고, 그러면 셸이 하나도 남지 않습니다. blocking-route 경고가 뜨는 상황이 이것입니다.

<Suspense>는 그 전파를 자기 선에서 끊습니다. 안쪽이 끝나지 않았어도 fallback으로 채워 내보내므로, 부모로 전파될 미완성이 남지 않습니다.

Layout
├── Sidebar ← 셸에 포함
└── <Suspense> ← 전파가 여기서 끊긴다
└── SlugHeading
└── await params ← 전파가 시작되는 지점

그래서 셸은 경계 바깥 전부입니다. 요청 시점 API가 "여기부터 못 만든다"를 정하고, <Suspense>는 그 판정이 어디까지 전파될지를 자릅니다. 경계를 얼마나 좁게 치느냐가 셸의 크기입니다.

셸이 만들어지는가
최상단에서 await아니오
자식으로 내렸지만 경계 없음아니오
자식으로 내리고 <Suspense>로 감쌈

편지에 비유하면 이렇습니다. 세 번째 문단에 쓸 내용을 아직 모른다면, 그 문단을 누가 쓰든 편지를 부칠 수 없습니다. <Suspense>"이 자리는 나중에 채움"이라고 적어 두고 일단 부치는 것입니다.

정리하면 요청 시점 API를 쓰지 말라는 것이 아니라, 값을 꺼내는 지점을 <Suspense> 경계 아래로 내리라는 것입니다. 컴포넌트를 아래로 옮긴 것이 아니라 경계를 그은 것이 효과입니다. 그리고 그 경계가 작을수록 fallback으로 대체되는 화면도 작아집니다.

세 층이 한 페이지에

공식 문서의 Static, cached, and streaming 예제가 지금까지의 내용을 한 화면에 모아 놓은 형태입니다.

import { Suspense } from 'react';
import { cookies } from 'next/headers';
import { cacheLife, cacheTag } from 'next/cache';
import Link from 'next/link';

export default function BlogPage() {
return (
<>
{/* 정적 - 자동으로 프리렌더된다 */}
<header>
<h1>블로그</h1>
<nav>
<Link href="/"></Link> | <Link href="/about">소개</Link>
</nav>
</header>

{/* 캐시된 콘텐츠 - 정적 셸에 포함된다 */}
<BlogPosts />

{/* 요청 시점 콘텐츠 - 스트리밍된다 */}
<Suspense fallback={<p>설정을 불러오는 중</p>}>
<UserPreferences />
</Suspense>
</>
);
}

// 모두가 같은 글 목록을 본다. 한 시간 단위로 갱신
async function BlogPosts() {
'use cache';
cacheLife('hours');
cacheTag('posts');

const res = await fetch('https://api.vercel.app/blog');
const posts: Post[] = await res.json();
return <section>{/* ... */}</section>;
}

// 쿠키에 담긴 값에 의존하는 UI
async function UserPreferences() {
const theme = (await cookies()).get('theme')?.value || 'light';
return <aside>테마: {theme}</aside>;
}
영역언제 만들어지는가어디에
헤더빌드 시점정적 셸
글 목록빌드 시점 (캐시)정적 셸
사용자 설정요청 시점fallback만 셸에, 본문은 스트리밍

여기서 cookies()를 읽어도 라우트 전체가 동적으로 넘어가지 않습니다. <Suspense> 경계가 그 영향을 가두고, 나머지는 첫 HTML에 실려 먼저 나갑니다.

예외: 봇과 크롤러

주의

브라우저는 정적 셸을 즉시 받지만 봇과 크롤러는 User-Agent로 감지되어 다르게 처리됩니다. 완성된 문서가 필요하므로 Next.js는 셸을 건너뛰고 요청 시점에 페이지 전체를 렌더링한 뒤, 렌더가 끝나면 완성된 HTML을 보냅니다.

셸을 재사용하지 않고 다시 렌더링한다는 뜻이므로, 프리렌더 때 이미 끝났던 작업이 봇에게는 요청 시점에 다시 실행됩니다. 셸이 빌드 시점에만 존재하는 값에 의존하고 있으면 사람에게는 뜨는 페이지가 크롤러에게는 실패합니다. 셸이 쓰는 데이터는 요청 시점에도 접근 가능해야 합니다.

정리

  • 프리렌더링은 요청이 오기 전에 컴포넌트를 미리 실행해 결과를 만들어 두는 것입니다.
  • 그 결과물인 정적 셸은 사용자마다 달라지는 자리를 비워 둔 채 미리 만든 HTML입니다.
  • 비워 둔 자리는 요청 시점에 스트리밍으로 채워지고, <Suspense>가 그 자리를 지정합니다.
  • 셸에는 use cache 결과, <Suspense> fallback, 예측 가능한 값이 들어갑니다.
  • 랜덤값과 시각은 connection() + <Suspense>이거나 use cache, 둘 중 하나를 골라야 합니다.
  • 요청 시점 API는 프리렌더를 막습니다. 쓰지 말라는 뜻이 아니라 <Suspense> 경계 아래에서 값을 꺼내라는 뜻입니다.
  • 최상단 await는 그 컴포넌트 전체를 셸에서 밀어냅니다. Promise는 내려보내고 경계 안에서 풉니다.
  • 자식으로 옮기기만 해서는 효과가 없습니다. 경계를 그어야 거기서 셸이 끊깁니다.
  • 봇과 크롤러에게는 셸이 재사용되지 않습니다. 셸이 쓰는 데이터는 요청 시점에도 접근 가능해야 합니다.

예상 질문

프리렌더링과 SSG는 어떻게 다른가요?

다른 것이 아니라 포함 관계입니다. 프리렌더링은 "요청 전에 미리 렌더링해 둔다"는 동작이고, SSG는 그 동작을 페이지 전체에 적용한 방식입니다. ISR도 마찬가지이고 거기에 재생성이 붙을 뿐입니다. 비교 대상을 찾는다면 SSG와 PPR입니다. 같은 프리렌더링을 페이지 전체에 쓰느냐, 페이지 일부에 쓰느냐의 차이입니다.

<Suspense>로 감싸면 그 컴포넌트가 동적 렌더링으로 바뀌나요?

아닙니다. <Suspense>는 비동기 작업이 끝날 때까지 보여 줄 fallback을 제공할 뿐입니다. 동기 작업만 하는 컴포넌트는 <Suspense>로 감싸도 프리렌더 중에 그대로 완료됩니다.

요청 시점 API를 쓰면 왜 프리렌더가 실패하나요?

프리렌더는 요청이 없는 상태에서 컴포넌트를 실행하는 일인데, cookies()searchParams는 요청이 도착해야 값이 정해지기 때문입니다. Next.js는 이 경우 개발 모드 오버레이에 blocking-route 경고를 띄우고 수정 카드를 함께 보여 줍니다.

레이아웃 최상단에서 await params를 하면 무엇이 문제인가요?

generateStaticParams로 제공되지 않은 파라미터는 요청 시점 데이터라서, 최상단에서 await하면 레이아웃 전체가 프리렌더 불가가 됩니다. 파라미터와 무관한 Sidebar 같은 영역까지 함께 끌려갑니다. Promise를 그대로 아래로 내려보내 <Suspense> 경계 안에서 풀면 나머지는 셸에 남습니다.

봇에게는 왜 정적 셸을 보내지 않나요?

크롤러는 완성된 문서가 필요하기 때문입니다. 셸을 먼저 보내고 나중에 채우는 방식은 브라우저에서만 의미가 있어서, 봇에게는 요청 시점에 페이지 전체를 렌더링한 뒤 완성된 HTML을 보냅니다.

참고 자료