반응형 웹을 모바일에서 열었을 때 화면 크기는 문제없었습니다. 그런데 링크를 누를 때마다 아주 잠깐씩 기다리는 느낌이 남았습니다.
화면 전환과 애니메이션을 아무리 다듬어도 서버 응답을 기다리는 시간까지 CSS로 줄일 수는 없었습니다. 웹을 진짜 앱처럼 빠릿하게 만들고 싶어서 Next.js가 페이지를 이동할 때 무엇을 받고, 어디에 저장하는지 다시 보기 시작했습니다.
그러던 중 Next.js 16.3이 나왔습니다.
이번 버전에는 페이지 이동과 캐시 전략을 크게 바꾸는 기능이 들어갔습니다. /chat/1, /chat/2처럼 같은 화면 구조를 공유하는 링크마다 공통 RSC를 따로 받지 않고, App Shell 하나를 라우트 단위로 미리 받아 재사용할 수 있습니다.
문제는 공식 설명에 RSC Payload, App Shell, Client Cache 같은 용어가 한꺼번에 나온다는 점입니다. Next.js를 막 배우는 입장에서는 “첫 페이지만 SSR이고 다음부터는 CSR 아닌가?”, “prefetch는 자바스크립트 청크만 받는 것 아닌가?”부터 막힐 수 있습니다.
저도 정확히 그 지점에서 헷갈렸습니다. 이 글에서는 캐시가 무엇인지부터 시작해 RSC, prefetch, 'use cache', Cache Components와 React Activity까지 하나씩 연결해보겠습니다. 기능을 따로 외우기보다 페이지 이동 한 번이 어떤 흐름으로 이어지는지 이해하는 게 목표입니다.
Next.js에서 캐시는 한 군데만 가리키지 않습니다.
'use cache' 결과와 미리 렌더한 페이지를 저장합니다.Activity는 엄밀히 말하면 데이터나 응답을 저장하는 캐시는 아닙니다. 다만 Next.js가 이미 방문한 화면을 다시 만드는 비용을 줄이려고 쓰는 브라우저 쪽 보존 영역이라 이 글에서는 같은 지도에 넣었습니다. 네 영역은 서로 다른 시간을 줄입니다.
Next.js 캐시 지도
┌────────────────────── 브라우저 ──────────────────────┐
│ │
│ ① 리소스 캐시 ② Next.js Client Cache │
│ ───────────── ─────────────────────── │
│ JavaScript / CSS RSC Payload / App Shell │
│ 이미지 방문·prefetch한 라우트 정보 │
│ │
│ ③ React Activity │
│ ───────────────── │
│ 최근 화면의 React state + 실제 DOM │
└──────────────────────────┬────────────────────────────┘
│ RSC 요청
▼
┌──────────────────────── 서버 ─────────────────────────┐
│ │
│ ④ 서버·원격 캐시 │
│ ───────────────── │
│ 'use cache' 결과 / prerender된 HTML·RSC / ISR │
└───────────────────────────────────────────────────────┘
서버 캐시가 맞으면 DB 조회와 서버 렌더 시간을 줄일 수 있습니다. 그래도 응답은 네트워크를 건너옵니다. 클릭 직후부터 앱처럼 반응하려면 브라우저의 Client Cache가 목적지 RSC나 App Shell을 이미 가지고 있어야 합니다.
반대로 Client Cache가 있어도 새 화면의 React 트리를 계산하고 DOM을 만드는 비용은 남습니다. 이 부분은 Activity와 Concurrent Rendering에서 다시 보겠습니다.
다음 페이지에도 서버 컴포넌트가 있다면 서버 작업이 남습니다. 이 구분부터 바로잡아보겠습니다.
용어부터 짧게 나눠 보겠습니다.
Next.js App Router의 첫 페이지는 대략 세 종류의 결과를 받습니다.
첫 페이지 진입
서버
├─ HTML → JavaScript보다 먼저 화면을 보여 준다
├─ RSC Payload → 서버 컴포넌트 트리를 설명한다
└─ Client JS chunks → 클라이언트 컴포넌트를 hydrate한다
RSC Payload에는 서버 컴포넌트의 렌더 결과, 클라이언트 컴포넌트가 들어갈 자리, 양쪽 사이에 건넨 props가 들어 있습니다. HTML 파일과 같지는 않습니다. React가 현재 트리와 합칠 수 있도록 직렬화한 데이터에 가깝습니다.
'use client'를 붙인 컴포넌트도 첫 진입 때 서버가 HTML을 미리 만들 수 있습니다. 'use client'는 “CSR만 써라”라는 뜻이 아닙니다. 이 경계 아래의 코드를 브라우저용 JavaScript에도 넣고 hydrate하라는 뜻입니다.
사용자가 <Link>를 눌러 다음 페이지로 갈 때는 문서 전체 HTML을 다시 받지 않습니다. Next.js가 필요한 RSC Payload와 아직 받지 않은 클라이언트 청크를 가져옵니다. React는 RSC Payload를 현재 트리에 합친 뒤 필요한 DOM만 고칩니다.
클라이언트 페이지 이동
RSC Payload ──→ React 트리 갱신 ──→ 필요한 DOM만 commit
Client JS ──→ 새 Client Component가 있을 때 추가 로드
그러니 “첫 페이지만 SSR이고 이후에는 전부 CSR”이라고 보면 서버 컴포넌트 구간을 놓칩니다. 반대로 새 서버 데이터를 읽는 컴포넌트가 없고 필요한 클라이언트 코드도 이미 받았다면 SPA에 가까운 전환으로 보일 수 있습니다. App Router의 이동을 SSR과 CSR 중 하나로만 나누기 어려운 이유입니다.
서버 컴포넌트는 브라우저가 실행할 JavaScript를 줄여 줍니다. 마크다운 파서, DB 접근 코드, 서버 전용 라이브러리를 클라이언트 번들에 넣지 않아도 됩니다. 사용자는 그 코드를 다운로드하고 파싱하고 실행할 필요가 없습니다.
이 장점만으로도 RSC는 충분히 의미가 있습니다.
다만 앱 같은 페이지 이동까지 원한다면 캐시가 중요합니다. 다음 화면의 서버 컴포넌트 결과가 브라우저에 없으면 사용자가 클릭한 뒤 RSC Payload를 요청합니다.
클릭 → 서버 요청 → RSC 생성 → 응답 → React 렌더 → 화면 변경
서버가 빠르고 서버 캐시도 맞았더라도 네트워크 왕복 시간은 남습니다. 클릭한 뒤 첫 변화가 서버 응답 뒤에 오면 사용자는 화면이 잠깐 멈췄다고 느낄 수 있습니다.
그래서 Next.js는 방문했거나 prefetch한 라우트의 RSC Payload를 브라우저 메모리의 Client Cache에 넣습니다. 예전 문서와 글에서는 이를 Router Cache라고도 불렀습니다. 새로고침하면 사라지는 메모리 캐시이며 Cache Storage나 Local Storage와는 다릅니다.
정리하면 이렇습니다.
이 셋을 나눠야 “RSC를 썼는데 왜 페이지 이동이 느리지?”라는 질문에 답할 수 있습니다.
Next.js는 모든 화면의 JavaScript를 첫 페이지에서 한꺼번에 받지 않습니다. 라우트마다 코드를 나누고, 현재 화면에 필요한 청크부터 보냅니다. 이를 code splitting이라고 부릅니다.
다음 라우트로 처음 이동할 때 그 라우트의 청크를 받으면 지연이 생길 수 있습니다. <Link>의 prefetch는 사용자가 누르기 전에 필요한 JavaScript, CSS와 RSC Payload를 가져옵니다.
여기서 두 짐의 역할이 다릅니다.
| 종류 | 들어 있는 것 | 주로 저장하는 곳 |
|---|---|---|
| JavaScript 청크 | 클라이언트 컴포넌트 코드 | 브라우저 리소스 캐시·모듈 런타임 |
| RSC Payload | 서버 컴포넌트 결과와 트리 정보 | Next.js Client Cache |
| HTML | 첫 진입 때 보여 줄 문서 | 브라우저·CDN·서버 prerender 캐시 |
서버 컴포넌트를 늘리면 JavaScript 청크는 작아질 수 있습니다. 그렇다고 페이지 이동에 필요한 RSC Payload까지 사라지지는 않습니다. 반대로 JavaScript 청크를 이미 받았어도 새 서버 데이터가 필요하면 RSC 요청은 남습니다.
번들 크기, 요청 개수, RSC Payload 크기를 같은 숫자로 보면 안 되는 이유입니다.
여기까지는 기존 App Router에서도 중요했던 구분입니다. 16.3은 이 가운데 다음 라우트의 RSC를 어떤 단위로 미리 받아 둘지를 바꿨습니다.
16.3의 Instant Navigations와 Partial Prefetching을 쓰려면 현재 두 옵션을 켭니다.
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
}
export default nextConfig
16.3 안정판에서는 opt-in 기능입니다. Next.js 팀은 다음 메이저 버전에서 이 동작을 기본값으로 넣을 계획이라고 밝혔습니다.
두 옵션을 켜면 화면 안에 들어온 기본 <Link>가 목적지 전체 대신 App Shell을 prefetch합니다.
App Shell은 해당 라우트에서 URL별 값에 의존하지 않는 공통 화면입니다. /chat/[id]를 예로 들어보겠습니다.
/chat/[id]
├─ 채팅 화면의 공통 프레임 ┐
├─ 메시지 입력창 │ App Shell
├─ 헤더의 공통 버튼 │ 한 번 받아서 공유
├─ 메시지 목록 Skeleton ┘
└─ id별 채팅방 제목과 메시지 ← URL별 RSC
16.2까지는 /chat/1, /chat/2, /chat/3을 가리키는 링크가 보일 때 같은 공통 UI를 URL마다 prefetch할 수 있었습니다. 16.3은 /chat/[id]용 App Shell 하나를 받아 여러 링크가 함께 씁니다.
여기서 “라우트당 하나”는 /chat/1과 /chat/2를 각각 세지 않습니다. 파일 시스템의 /chat/[id] 패턴 하나를 뜻합니다.
App Shell은 layout.tsx에만 들어 있지 않습니다. page.tsx 안의 컴포넌트도 URL과 무관하면 포함할 수 있습니다. 동기적으로 만들 수 있는 UI, <Suspense> fallback, 충분한 수명을 준 'use cache' 결과도 들어갈 수 있습니다.
16.3의 변화가 중요한 이유가 여기에 있습니다. 레이아웃보다 더 안쪽에 있는 페이지 공통 UI도 하나의 재사용 가능한 shell로 다룹니다.
다만 Next.js가 공통 UI와 URL별 UI를 나누는 위치는 컴포넌트 구조에 따라 달라집니다. params를 너무 위에서 읽으면 공통으로 묶을 수 있는 범위도 함께 작아집니다.
params를 아래에서 읽는다동적 라우트의 페이지 맨 위에서 await params를 하면 그 아래 트리 전체가 특정 URL에 묶입니다.
// 공통 화면까지 id에 묶이는 구조
export default async function ChatPage({ params }: PageProps<'/chat/[id]'>) {
const { id } = await params
const messages = await getMessages(id)
return <ChatScreen messages={messages} />
}
공통 화면을 먼저 반환하고, params가 필요한 부분만 자식 컴포넌트로 내리면 Next.js가 더 많은 UI를 App Shell에 넣을 수 있습니다.
import { Suspense } from 'react'
export default function ChatPage({ params }: PageProps<'/chat/[id]'>) {
return (
<ChatFrame>
<MessageComposer />
<Suspense fallback={<MessageListSkeleton />}>
<Messages params={params} />
</Suspense>
</ChatFrame>
)
}
async function Messages({ params }: Pick<PageProps<'/chat/[id]'>, 'params'>) {
const { id } = await params
const messages = await getMessages(id)
return <MessageList messages={messages} />
}
ChatFrame, MessageComposer, MessageListSkeleton은 모든 채팅방이 공유합니다. id를 읽고 메시지를 가져오는 부분만 URL마다 새로 받습니다.
이 구조가 Partial Prefetching의 핵심입니다. 공통 화면은 브라우저가 미리 가지고 있고, URL별 데이터는 작은 <Suspense> 경계 안으로 스트리밍합니다.
기본 <Link>는 여기까지 준비합니다. 특정 id의 데이터도 클릭 전에 받고 싶을 때 prefetch={true}가 필요합니다.
prefetch={true}는 페이지 어디까지 받을까?Partial Prefetching을 켠 16.3에서는 링크를 세 경우로 나눌 수 있습니다.
| 링크 | 미리 받는 범위 | 요청 단위 |
|---|---|---|
<Link href="/chat/1"> | /chat/[id]의 공통 App Shell | 라우트당 한 번 |
<Link href="/chat/1" prefetch> | App Shell + id=1로 미리 풀 수 있는 내용 | 링크마다 |
<Link href="/chat/1" prefetch={false}> | 없음 | 클릭한 뒤 요청 |
prefetch={true}를 붙이면 Next.js가 링크의 params, searchParams, 전체 URL을 미리 압니다. 그리고 그 값으로 목적지 컴포넌트 트리를 더 내려갑니다. 이를 Runtime Prefetching이라고 부릅니다.
그래도 페이지 끝까지 무조건 받지는 않습니다.
'use cache'로 수명을 정한 결과도 포함할 수 있습니다.fetch를 만나면 <Suspense> fallback에서 멈춥니다.가령 채팅방 제목은 몇 분간 캐시해도 괜찮고 메시지는 항상 최신이어야 한다고 해보겠습니다.
import { cacheLife } from 'next/cache'
async function getChatHeader(id: string) {
'use cache'
cacheLife('minutes')
return db.chat.findHeader(id)
}
prefetch={true} 링크는 id를 미리 넣어 getChatHeader(id) 결과까지 준비할 수 있습니다. 캐시하지 않은 메시지 목록은 fallback에 남습니다. 사용자가 클릭하면 채팅방 제목은 바로 보이고 메시지는 뒤이어 옵니다.
이 기능에는 비용도 있습니다. 기본 App Shell은 라우트당 한 번만 요청하지만, prefetch={true}는 링크마다 서버를 호출할 수 있습니다. 상품 카드 100개에 모두 붙이면 URL별 prefetch도 100번 실행할 수 있습니다.
목록이 많다면 기본 shell만 받고, hover나 focus처럼 클릭 의도가 보일 때 URL별 prefetch를 시작하는 방법도 있습니다.
자동 prefetch는 프로덕션에서만 실행합니다. 개발 서버의 Network 탭만 보고 판단하면 실제 동작과 다르게 보일 수 있습니다.
그렇다면 Next.js는 어떤 서버 결과를 클릭 전에 준비해도 된다고 판단할까요? 그 경계를 명시하는 도구가 'use cache'와 cacheLife()입니다.
'use cache'가 무엇을 저장할까?'use cache'는 함수나 컴포넌트의 반환값을 캐시 대상으로 표시합니다. 함수 인자와 바깥에서 참조한 값도 캐시 키에 들어갑니다.
async function getProduct(id: string) {
'use cache'
return db.product.find(id)
}
getProduct('1')과 getProduct('2')는 서로 다른 캐시 항목을 씁니다. /product/[id]처럼 URL에 변수가 있어도 데이터가 얼마나 자주 바뀌는지 안다면 id별로 캐시할 수 있습니다.
cacheLife()를 같이 쓰면 수명도 정할 수 있습니다.
stale: 브라우저가 서버 확인 없이 Client Cache를 바로 써도 되는 시간revalidate: 서버가 백그라운드에서 새 결과를 만들 시점expire: 오래 요청이 없었을 때 다음 요청이 새 결과를 기다리는 시점기본 profile은 현재 stale: 5분, revalidate: 15분, expire: never입니다.
import { cacheLife } from 'next/cache'
async function ProductSummary({ id }: { id: string }) {
'use cache'
cacheLife({
stale: 5 * 60,
revalidate: 15 * 60,
expire: 24 * 60 * 60,
})
const product = await db.product.find(id)
return <Summary product={product} />
}
App Shell에는 stale이 5분 이상인 캐시 결과가 들어갑니다. 더 짧은 데이터는 shell 밖에 두고 <Suspense> 뒤에서 받습니다.
예전부터 있던 experimental.staleTimes는 별개의 전역 설정입니다. cacheLife가 'use cache' 결과의 수명을 설명한다면, staleTimes는 라우트 세그먼트를 브라우저 Client Cache에서 얼마나 재사용할지 정합니다. 기본값은 dynamic page가 0초, static page나 완전 prefetch가 5분입니다. 이름은 비슷하지만 서버 결과 캐시와 클라이언트 라우터 캐시를 섞어 보면 안 됩니다.
여기서 /product/[id]가 동적 라우트인데도 id별로 캐시한다는 말이 어색하게 들릴 수 있습니다. Next.js 문서가 dynamic이라는 단어를 여러 뜻으로 쓰기 때문입니다.
Next.js 문서에서 dynamic은 서로 다른 뜻으로 나옵니다.
[id]처럼 URL에 변수가 있는 dynamic segmentstaleTimes.dynamic이 가리키는 클라이언트 캐시 분류/product/[id]라고 해서 항상 매 요청마다 서버가 새로 계산하지는 않습니다. generateStaticParams로 일부 id를 미리 만들 수도 있고, getProduct(id)에 'use cache'를 붙여 id별 결과를 저장할 수도 있습니다.
또 params와 searchParams는 URL마다 바뀌는 값입니다. 공통 App Shell에는 넣을 수 없습니다. prefetch={true}를 쓸 때만 링크별 URL을 미리 풀어서 더 깊은 RSC를 준비할 수 있습니다.
cookies()와 headers()는 URL 데이터가 아니라 세션 데이터입니다. 일반 'use cache' 안에서 직접 읽을 수는 없습니다. 바깥에서 값을 읽어 캐시 함수의 인자로 넘기거나, 브라우저 세션에만 저장하는 'use cache: private'를 씁니다.
동적 라우트와 캐시의 관계는 이렇게 정리할 수 있습니다.
[id]같은 dynamic segment와 요청마다 새로 계산하는 dynamic rendering은 다른 개념입니다. id를 캐시 키로 사용해 URL별 데이터를 캐시할 수 있고, 모든 id가 공유하는 UI는 16.3의 App Shell로 한 번만 prefetch할 수 있습니다.
여기까지는 첫 방문 전에 서버 결과와 App Shell을 준비하는 이야기였습니다. cacheComponents를 켜면 사용자가 한 번 방문하고 떠난 화면을 다루는 방식도 달라집니다.
cacheComponents: true를 켰을 때 눈에 띄는 점은 두 가지입니다. 비동기 작업의 경계를 직접 정해야 하고, 페이지를 이동할 때 Next.js가 React Activity를 사용합니다.
둘은 서로 다른 시점을 맡습니다.
<Suspense>나 'use cache'로 처리합니다.이 차이를 알아야 “prefetch할 때 다음 화면의 DOM도 미리 그려 두나?”라는 질문에도 답할 수 있습니다.
Cache Components를 켜면 Next.js는 네트워크 요청이나 DB 조회를 암묵적으로 캐시하지 않습니다. 비동기 작업을 만났을 때 개발자가 세 가지 중 하나를 고르길 요구합니다.
<Suspense>로 감싸 fallback을 먼저 보여 주고 결과를 스트리밍합니다.'use cache'를 붙여 이전 결과를 즉시 보여 줍니다.export const instant = false로 이 라우트가 서버 응답을 기다린다는 점을 명시합니다.import { Suspense } from 'react'
export default function Page() {
return (
<Suspense fallback={<ProductSkeleton />}>
<ProductList />
</Suspense>
)
}
async function ProductList() {
const products = await db.product.findMany()
return <List products={products} />
}
처음에는 <Suspense>를 강요하는 것처럼 느껴질 수 있습니다. 이유는 단순합니다. 클릭 직후 서버 응답이 없어도 무엇을 보여 줄지 Next.js가 알아야 App Shell을 만들 수 있기 때문입니다.
fallback을 보여 줄지, 캐시 결과를 보여 줄지, 아예 기다릴지는 데이터의 성격에 따라 달라집니다. 재고처럼 최신 값이 중요하면 작은 <Suspense> 경계 안에서 스트리밍하고, 블로그 글처럼 자주 바뀌지 않으면 'use cache'로 수명을 주는 식입니다.
Cache Components의 조금 특이한 부분은 여기에 있습니다. Next.js는 사용자가 떠난 최근 화면을 곧바로 unmount하지 않고 React <Activity>로 숨깁니다.
Activity는 자식 DOM에 display: none을 주고 React state와 실제 DOM 노드를 남깁니다. Effect는 cleanup하고, 화면을 다시 보여 줄 때 Effect를 새로 실행합니다. 입력 중인 폼, 펼쳐 둔 <details>, DOM 안의 스크롤 같은 상태를 복원할 수 있습니다.
아직 방문하지 않은 목적지
RSC/JS만 prefetch → DOM은 아직 없음
방문한 뒤 떠난 화면
Activity hidden → DOM과 state 보존, Effect cleanup
Partial Prefetching은 첫 방문 전에 네트워크 지연을 줄입니다. Activity는 방문했던 화면으로 돌아갈 때 렌더와 mount 비용을 줄입니다.
DOM을 계속 남기면 메모리도 사용합니다. Next.js는 최근 화면을 최대 3개까지 보존하고 그보다 오래된 화면은 버립니다. 동영상이나 iframe처럼 DOM 자체가 동작을 이어 가는 요소는 display: none만으로 멈추지 않을 수 있으니 Effect cleanup도 확인할 필요가 있습니다.
Activity가 실제 DOM을 남긴다는 설명을 보면, prefetch도 목적지 화면을 뒤에서 미리 mount할 것처럼 느껴집니다. 저도 처음에는 다음 화면의 가상 DOM이나 실제 DOM까지 미리 만들어 두는 줄 알았습니다.
하지만 Next.js App Router의 라우트 prefetch는 그렇게 하지 않습니다. prefetch 단계에서는 JavaScript, CSS, RSC Payload를 받아 캐시에 둡니다. 사용자가 클릭하면 React가 목적지 트리를 렌더하고 DOM에 commit합니다. 네트워크 왕복을 줄여도 복잡한 화면의 첫 렌더 비용은 남습니다.
React의 <Activity> API 자체는 처음부터 mode="hidden"으로 둔 컴포넌트를 낮은 우선순위로 미리 렌더할 수 있습니다. 코드와 Suspense 기반 데이터를 준비하고 나중에 visible로 바꿔 보여 주는 용도입니다.
다만 Next.js가 라우트 전환에 Activity를 쓰는 대상은 이미 방문한 최근 화면입니다. 아직 방문하지 않은 모든 링크의 목적지 DOM을 숨겨서 쌓지는 않습니다. 링크가 많을수록 메모리, DOM 노드, 렌더 시간이 함께 늘기 때문입니다.
정리하면 Partial Prefetching은 첫 방문 전에 재사용할 RSC App Shell을 Client Cache에 저장하고, Activity는 방문한 뒤 떠난 화면의 DOM과 state를 보존합니다.
저도 선언형 렌더링 라이브러리를 만들면서 복잡한 위젯 트리를 처음 mount할 때 200ms가 넘는 경우를 봤습니다. 필요한 파일과 RSC를 전부 미리 받아도 React 트리를 계산하고 실제 DOM을 만들면 메인 스레드는 그 시간만큼 일합니다.
Google의 INP 기준에서 200ms 이하는 “좋음” 구간입니다. 브라우저가 200ms를 넘긴 화면을 막는다는 뜻은 아닙니다. 사용자 입력부터 다음 화면 paint까지 얼마나 빠르게 반응했는지 평가하는 기준입니다.
탭을 누를 때마다 렌더와 DOM commit이 메인 스레드를 오래 잡으면 네트워크가 빨라도 화면은 답답합니다.
React는 Concurrent Rendering으로 이 문제를 다룹니다. 다음 화면의 렌더를 낮은 우선순위로 처리하고, 클릭이나 입력처럼 급한 작업이 들어오면 렌더를 잠시 멈출 수 있습니다. 계산 중 데이터가 바뀌면 오래된 렌더를 버리고 최신 상태로 다시 시작할 수도 있습니다.
현재 화면은 계속 보여 준다
│
├─ 다음 트리 계산 ── 중단 가능 ── 재개 또는 폐기
│
└─ 계산 완료 ── commit ── 화면 교체
이 구조는 그래픽스의 더블 버퍼링(Double Buffering)과 닮았습니다. 화면에는 Front Buffer를 계속 보여 주면서 Back Buffer에 다음 프레임을 그리고, 준비를 마치면 두 버퍼를 교체하는 방식입니다.
React Fiber도 현재 UI를 나타내는 current 트리와 다음 결과를 계산하는 workInProgress 트리를 alternate로 연결합니다. React 소스에서도 이를 double buffering pooling technique이라고 부릅니다.
이 방식이 총 연산량을 없애지는 않습니다. 대신 긴 렌더 하나가 사용자 입력을 계속 막지 않도록 우선순위를 나눕니다.
React가 props와 state를 한 렌더 시점의 스냅샷으로 다루고, 렌더 함수의 순수성을 강조하는 이유도 여기에 있습니다. 렌더가 외부 값을 마음대로 바꾸지 않으면 React가 계산을 멈추고 다시 시작하거나 오래된 결과를 버려도 상태가 꼬이지 않습니다.
가상 DOM은 트리를 계산하고 비교하는 비용을 만듭니다. 대신 같은 구조 덕분에 서버 렌더, 중단 가능한 렌더, 우선순위 처리, Activity 같은 기능도 함께 사용할 수 있습니다. 가상돔을 쓴다고 느리다고 하기엔, 가상돔이기 때문에 할 수 있는 최적화가 존재하는 셈이죠.
여기까지가 프레임워크 안에서 일어나는 흐름입니다. 서버 캐시를 실제로 어디에 저장하고 어떻게 재검증할지는 배포 환경이 결정합니다.
2026년 8월 현재 Cloudflare의 OpenNext 문서는 Next.js 16의 minor·patch 버전과 PPR, Composable Caching('use cache')을 지원한다고 적었습니다. “Cloudflare에서는 Cache Components를 쓸 수 없다”고 단정할 단계는 지났습니다.
다만 Vercel과 똑같은 저장소 구성을 자동으로 얻지는 않습니다. 지속 캐시와 재검증을 쓰려면 R2 기반 Incremental Cache, Queue, Tag Cache 등을 용도에 맞게 설정합니다. OpenNext 문서에는 cache interception이 PPR과 함께 동작하지 않는다는 제한도 남아 있습니다.
16.3이 출시된 직후라면 partialPrefetching까지 실제 배포에서 원하는 대로 움직이는지 production build와 Network 탭으로 한 번 더 확인하는 편이 안전합니다. 프레임워크 옵션뿐 아니라 배포 어댑터가 캐시를 어디에 저장하고 어떻게 지우는지도 함께 볼 필요가 있습니다.
다만 필자는 아직 캐시컴포넌트를 활성화 했을때 클라우드 플레어에서 버그가 존재해 다른 런타임에서 사용하고 있습니다.
지금까지의 흐름을 짧게 연결하면 아래와 같습니다.
Next.js App Router의 첫 진입에서는 HTML, RSC Payload, 클라이언트 컴포넌트용 JavaScript를 받습니다. 이후 클라이언트 이동에서는 문서 전체 HTML 대신 필요한 RSC Payload와 JavaScript 청크를 받습니다.
브라우저의 리소스 캐시는 JavaScript와 CSS를 저장하고, Next.js Client Cache는 방문하거나 prefetch한 RSC Payload를 메모리에 저장합니다. 서버에서는
'use cache'로 데이터나 컴포넌트 결과를 캐시하고cacheLife로 수명을 정합니다.Next.js 16.3의 Partial Prefetching은
/chat/[id]처럼 같은 라우트가 공유하는 App Shell을 한 번만 prefetch합니다.prefetch={true}를 붙인 링크만params와searchParams를 미리 풀어 URL별 캐시 콘텐츠까지 추가로 받습니다. 캐시하지 않은 데이터는 Suspense fallback 뒤에서 클릭 후 스트리밍합니다.Cache Components는 최근에 방문한 화면을 Activity로 숨겨 state와 실제 DOM을 보존합니다. 아직 방문하지 않은 목적지 DOM까지 prefetch하는 것은 아니므로 첫 렌더 비용은 남습니다. React는 그 계산을 Concurrent Rendering으로 중단하고 재개할 수 있지만, 총연산량 자체를 없애지는 않습니다.
헷갈리기 쉬운 지점도 짧게 정리해보겠습니다.
| 헷갈리기 쉬운 지점 | 정리 |
|---|---|
'use client'는 CSR인가요? | 첫 진입 때 SSR할 수 있으며, 브라우저에서 hydrate할 경계를 뜻합니다. |
| RSC는 어디에 캐시하나요? | 방문·prefetch한 RSC Payload를 브라우저 메모리의 Next.js Client Cache에 둘 수 있습니다. |
| 서버 캐시 hit면 네트워크 요청도 없나요? | 서버 계산은 줄지만 요청은 남습니다. Client Cache가 맞아야 클릭 뒤 왕복을 피할 수 있습니다. |
[id] 라우트는 캐시할 수 없나요? | id를 캐시 키로 삼아 URL별로 저장할 수 있고, 공통 UI는 App Shell로 공유할 수 있습니다. |
prefetch={true}는 전체 페이지를 받나요? | URL별 값을 미리 풀지만, 캐시하지 않은 작업을 만나면 Suspense fallback에서 멈춥니다. |
| Activity는 prefetch한 DOM을 저장하나요? | 아직 방문하지 않은 목적지가 아니라 최근 방문한 화면의 DOM과 state를 보존합니다. |
서버의 'use cache'는 계산 시간을 줄이고, Client Cache는 네트워크 왕복을 줄입니다. Activity는 다시 돌아온 화면의 mount 비용을 줄이고, Concurrent Rendering은 남은 계산이 사용자 입력을 오래 막지 않도록 나눕니다. 같은 “빠르다”를 만들지만 줄이는 시간이 서로 다릅니다.
제가 원했던 앱 같은 느낌도 모든 데이터가 0ms에 도착해서 나오지는 않았습니다. 클릭한 순간 보여 줄 공통 화면을 브라우저가 이미 가지고 있고, URL별 서버 결과가 작은 Suspense 경계 안으로 들어올 때 훨씬 자연스러웠습니다.
cacheLife<Activity>createWorkInProgress이 내용은 Next.js 16.3 안정판이 나오기 전부터 Canary 버전으로 실제 프로덕트에 적용해봤습니다. 모바일에서도 링크를 누른 직후 화면이 반응하게 만들고 싶었기 때문입니다.
실제 운영 중인 컴윗에 적용하면서 공통 App Shell을 먼저 보여 주고, URL별 서버 데이터는 작은 Suspense 경계 안에서 받도록 화면 구조를 나눴습니다. cacheComponents도 켜서 최근 페이지의 state와 DOM을 보존했습니다.
이때 화면 전환에는 ssgoi를 함께 쓰고 있었습니다. 둘의 역할은 달랐습니다. RSC와 캐시는 다음 화면을 클릭 전에 준비했고, 화면 전환은 준비한 두 화면 사이의 움직임을 처리했습니다. 전환 애니메이션만으로는 늦게 도착하는 RSC를 감출 수 없었고, 캐시만으로는 화면이 바뀌는 맥락까지 이어 주지 못했습니다.
실제로는 모든 링크를 prefetch={true}로 바꾸는 것보다 공통 App Shell의 범위를 넓히는 쪽이 먼저였습니다. 그다음 URL별 데이터 가운데 미리 받아도 되는 부분만 'use cache'로 표시하고, 최신 값이 필요한 부분은 Suspense 뒤에 남겼습니다. 적용 후에는 프로덕션 Network 탭에서 링크별 RSC 요청 수와 클릭 뒤 첫 화면이 바뀌는 시점을 함께 확인했습니다.
추신: 사용자는 실제 프로덕트에 Next.js 16.3의 캐시 전략을 적용해달라고 요청했습니다. 이 글은 OpenAI Codex가 구현 과정에서 자료를 조사하고 동작을 확인하며 알게 된 내용을 기록한 글입니다.
스벨트전도사님 안녕하세요! 글 정말 잘 읽었습니다. 다름이 아니라 저도 뭔가 이런 의문이 들었을 때 그에 대한 지식을 어떻게 효율적으로 습득할 수 있을지 요즘 고민이 있어서요, 스벨트전도사님께서는 어떤 방식으로 이런 디테일한 내부 동작에 대해 어떤 식으로 정보를 얻으셨는지 궁금합니다!