- Published on
Next.js 16 아키텍처 — Turbopack과 Cache Components 깊이 보기
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 들어가며
- 1. App Router 전체 구조
- 2. 서버 컴포넌트와 클라이언트 컴포넌트의 경계
- 3. 렌더링 모델: SSR / SSG / ISR / PPR
- 4. Cache Components — PPR과 use cache의 결합
- 5. 요청 생명주기 다이어그램
- 6. 캐시 계층 정리
- 7. Turbopack — Rust 기반 기본 번들러
- 8. 마이그레이션과 함정
- 9. 실무 적용 시나리오
- 마치며
- 참고 자료
들어가며
Next.js는 단순한 React 프레임워크를 넘어, 렌더링 전략과 캐시 계층, 그리고 빌드 도구까지 하나로 묶은 풀스택 플랫폼으로 진화했습니다. Next.js 16에 이르러 두 가지 큰 변화가 자리를 잡았습니다. 하나는 Rust로 작성된 번들러 Turbopack이 기본값이자 안정 단계로 들어선 것이고, 다른 하나는 부분 사전 렌더링(PPR)과 use cache를 묶은 Cache Components 모델입니다.
이 글은 "어떤 API를 호출하는가"보다 "프레임워크 안에서 요청이 어떻게 흐르고, 무엇이 어디서 렌더링되며, 결과가 어느 계층에 캐시되는가"를 그림으로 풀어내는 데 집중합니다. 버전마다 세부 동작이 달라질 수 있으므로, 정확한 플래그와 기본값은 항상 공식 문서(nextjs.org)를 함께 확인하시길 권합니다.
1. App Router 전체 구조
App Router는 파일 시스템 기반 라우팅을 한 단계 더 밀어붙여, 디렉터리 자체가 라우트 세그먼트가 되고 특수 파일이 각 세그먼트의 역할을 정의합니다.
app/
├── layout.tsx ← 루트 레이아웃 (모든 페이지 공통, 서버 컴포넌트)
├── page.tsx ← "/" 라우트 진입점
├── loading.tsx ← Suspense 경계의 폴백 UI
├── error.tsx ← 에러 경계 (클라이언트 컴포넌트)
├── not-found.tsx ← 404 처리
├── (marketing)/ ← 라우트 그룹 (URL에 영향 없음)
│ └── about/
│ └── page.tsx ← "/about"
└── dashboard/
├── layout.tsx ← 중첩 레이아웃 (dashboard 하위 공통)
├── page.tsx ← "/dashboard"
└── [id]/
└── page.tsx ← "/dashboard/:id" 동적 세그먼트
각 세그먼트의 특수 파일은 렌더링 트리에서 정해진 위치로 합성됩니다. 레이아웃은 중첩되며 상태를 보존하고, 그 사이에 Suspense와 에러 경계가 자동으로 끼어듭니다.
┌──────────────────────────────────────────────┐
│ RootLayout │
│ ┌──────────────────────────────────────────┐ │
│ │ DashboardLayout │ │
│ │ ┌────────────────┐ ┌──────────────────┐ │ │
│ │ │ <ErrorBoundary>│ │ <Suspense> │ │ │
│ │ │ error.tsx │ │ loading.tsx │ │ │
│ │ │ ▼ │ │ ▼ │ │ │
│ │ │ page.tsx ────────────▶ 렌더 결과 │ │ │
│ │ └────────────────┘ └──────────────────┘ │ │
│ └──────────────────────────────────────────┘ │
└──────────────────────────────────────────────┘
레이아웃은 자식이 바뀌어도 다시 마운트되지 않습니다. 이 점이 페이지 전환 시 사이드바나 헤더 상태를 유지하는 핵심입니다.
2. 서버 컴포넌트와 클라이언트 컴포넌트의 경계
App Router의 가장 근본적인 개념은 **React Server Components(RSC)**입니다. 기본적으로 모든 컴포넌트는 서버 컴포넌트이며, 상호작용이 필요한 지점에서만 "use client"로 경계를 긋습니다.
서버 컴포넌트 (기본) 클라이언트 컴포넌트 ("use client")
──────────────────────── ──────────────────────────────
DB/파일/시크릿 직접 접근 O 브라우저 API/이벤트 핸들러 O
async/await 데이터 패칭 O useState/useEffect 훅 O
번들에 JS 포함 X 번들에 JS 포함 O
이벤트 핸들러 X 서버 시크릿 접근 X
경계의 핵심 규칙은 "한 번 클라이언트면 그 아래는 모두 클라이언트"라는 점입니다. 다만 서버 컴포넌트를 클라이언트 컴포넌트의 children/props로 끼워 넣는 패턴으로 이 경계를 우회할 수 있습니다.
// server-page.tsx (서버 컴포넌트)
import ClientShell from './client-shell'
import ServerWidget from './server-widget'
export default async function Page() {
const data = await fetchData() // 서버에서만 실행
return (
<ClientShell>
{/* ServerWidget은 서버에서 렌더되어 RSC 페이로드로 전달됨 */}
<ServerWidget data={data} />
</ClientShell>
)
}
// client-shell.tsx
'use client'
import { useState } from 'react'
export default function ClientShell({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false)
// children(=ServerWidget)은 이미 직렬화된 RSC 트리, 다시 렌더 안 함
return <div onClick={() => setOpen((v) => !v)}>{open && children}</div>
}
이 합성 패턴은 "상호작용 껍데기는 클라이언트, 무거운 데이터 렌더링은 서버"라는 이상적인 분리를 가능하게 합니다.
직렬화 경계
서버에서 클라이언트로 props를 넘길 때는 직렬화 가능한 값만 전달됩니다. 함수, 클래스 인스턴스, Date 외 복잡한 객체는 경계를 넘지 못합니다. 서버 액션("use server")만이 함수를 경계 너머로 참조 가능하게 만드는 예외입니다.
[서버 컴포넌트] ──직렬화 가능한 props──▶ [클라이언트 컴포넌트]
│ ▲
└──── "use server" 액션 참조 ────────────┘
(함수 자체가 아닌 참조 ID 전달)
3. 렌더링 모델: SSR / SSG / ISR / PPR
Next.js는 하나의 코드베이스 안에서 여러 렌더링 전략을 섞어 쓸 수 있습니다. 핵심은 "이 콘텐츠가 정적인가, 동적인가, 그리고 언제 다시 만들어지는가"입니다.
┌─────────┬───────────────────┬──────────────────┬────────────────┐
│ 전략 │ 생성 시점 │ 캐시 │ 적합한 경우 │
├─────────┼───────────────────┼──────────────────┼────────────────┤
│ SSG │ 빌드 타임 │ CDN 영구 │ 블로그/문서 │
│ ISR │ 빌드 + 주기 재생성 │ CDN + revalidate │ 상품/뉴스 │
│ SSR │ 매 요청 │ 캐시 없음(기본) │ 개인화/실시간 │
│ PPR │ 정적 셸 + 동적 구멍│ 셸은 CDN, 구멍은 │ 혼합 페이지 │
│ │ │ 요청 시 스트림 │ │
└─────────┴───────────────────┴──────────────────┴────────────────┘
PPR(Partial Prerendering)의 직관
전통적으로 한 페이지는 "전부 정적" 또는 "전부 동적" 중 하나여야 했습니다. PPR은 이 이분법을 깹니다. 정적인 **셸(shell)**을 빌드 타임에 만들어 즉시 내려주고, 동적인 부분만 Suspense 경계로 표시해 요청 시점에 스트리밍으로 채웁니다.
요청 ──▶ [정적 셸 즉시 전송 (CDN 캐시)]
┌────────────────────────────────┐
│ 헤더 / 네비 / 레이아웃 (정적) │ ◀── TTFB 매우 빠름
│ ┌──────────────────────────┐ │
│ │ <Suspense> │ │
│ │ 동적 영역 (요청 시) │ │ ◀── 스트리밍으로
│ │ 예: 사용자 카트, 추천 │ │ 이어서 도착
│ └──────────────────────────┘ │
│ 푸터 (정적) │
└────────────────────────────────┘
사용자는 정적 셸을 거의 즉시 보고, 동적 구멍은 준비되는 대로 채워집니다. 정적/동적의 장점을 한 페이지에서 동시에 누리는 셈입니다.
4. Cache Components — PPR과 use cache의 결합
Next.js 16의 캐시 모델은 명시성을 강조하는 방향으로 정리되었습니다. 과거의 암묵적 캐싱이 혼란을 부르자, 무엇을 캐시할지 개발자가 명시적으로 선언하는 방식으로 무게중심이 옮겨졌습니다. 그 중심에 use cache 지시문이 있습니다.
// 함수 단위 캐싱
async function getProducts(category: string) {
'use cache'
const res = await fetch(`https://api.example.com/products/${category}`)
return res.json()
}
// 컴포넌트 단위 캐싱 + 재검증 태그
import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache'
async function ProductList({ category }: { category: string }) {
'use cache'
cacheLife('hours') // 캐시 수명 프로파일
cacheTag(`cat-${category}`) // 태그 기반 무효화
const products = await getProducts(category)
return <ul>{/* ... */}</ul>
}
use cache로 감싼 영역은 캐시 가능한 정적 영역으로 취급되고, 감싸지 않은 채 동적 데이터(쿠키, 헤더, searchParams 등)에 접근하는 영역은 자연스럽게 PPR의 "동적 구멍"이 됩니다. 즉 Cache Components는 PPR의 정적/동적 경계를 use cache 선언으로 결정하는 모델입니다.
Cache Components 결정 흐름
┌───────────────────────────────────────────────────┐
│ 컴포넌트/함수에 'use cache' 가 있는가? │
└───────────────┬───────────────────┬───────────────┘
예 (있음) 아니오 (없음)
▼ ▼
┌────────────────┐ ┌────────────────────┐
│ 정적 셸에 포함 │ │ 동적 데이터 접근? │
│ CDN/빌드 캐시 │ │ (cookies/headers) │
└────────────────┘ └─────┬──────────┬───┘
예 ▼ 아니오 ▼
┌──────────────┐ ┌──────────────┐
│ 동적 구멍 │ │ 정적 처리 가능 │
│ 요청 시 스트림 │ │ │
└──────────────┘ └──────────────┘
캐시 수명과 태그 무효화
cacheLife는 "얼마나 오래", cacheTag + revalidateTag는 "언제 깨뜨릴지"를 담당합니다.
import { revalidateTag } from 'next/cache'
export async function updateProduct(category: string) {
'use server'
await db.update(/* ... */)
revalidateTag(`cat-${category}`) // 해당 태그가 붙은 캐시만 무효화
}
5. 요청 생명주기 다이어그램
하나의 요청이 들어와 응답으로 나가기까지의 전체 흐름을 정리하면 다음과 같습니다.
브라우저
│ GET /dashboard/42
▼
┌─────────────────────────────────────────────────────────┐
│ Edge / CDN 계층 │
│ 정적 셸 캐시 적중? ──예──▶ 즉시 셸 반환 (스트림 시작) │
│ │ 아니오 │
└─────────┼───────────────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────┐
│ Next.js 서버 런타임 │
│ 1) 라우트 매칭 (app/dashboard/[id]/page.tsx) │
│ 2) middleware 실행 (인증/리다이렉트) │
│ 3) 레이아웃 → 페이지 RSC 렌더 시작 │
│ ├─ 'use cache' 영역 → 데이터 캐시 조회 │
│ └─ 동적 영역 → 데이터 패칭(fetch/DB) │
│ 4) RSC 페이로드 생성 + HTML 스트리밍 │
└─────────┬───────────────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────┐
│ 데이터 / 캐시 계층 │
│ Data Cache (fetch 결과) · Full Route Cache (RSC) │
│ 외부 DB / API / 파일 시스템 │
└─────────────────────────────────────────────────────────┘
▼
브라우저: HTML 스트림 수신 → 하이드레이션 → 상호작용 가능
6. 캐시 계층 정리
Next.js에는 성격이 다른 여러 캐시가 층층이 쌓여 있습니다. 어느 계층이 어떤 단위를 캐시하는지 구분하는 것이 디버깅의 출발점입니다.
┌────────────────────────────────────────────────────────────┐
│ 1. Router Cache (클라이언트 메모리) │
│ - 방문한 라우트의 RSC 페이로드를 브라우저에 보관 │
│ - 뒤로가기/prefetch 시 즉시 렌더 │
├────────────────────────────────────────────────────────────┤
│ 2. Full Route Cache (서버 빌드/런타임) │
│ - 정적 라우트의 RSC + HTML 결과 │
├────────────────────────────────────────────────────────────┤
│ 3. Data Cache (서버, 영속) │
│ - fetch 결과 / 'use cache' 함수 결과 │
│ - cacheLife / revalidateTag 로 제어 │
├────────────────────────────────────────────────────────────┤
│ 4. Request Memoization (단일 요청 수명) │
│ - 같은 요청 내 동일 fetch 중복 제거 │
└────────────────────────────────────────────────────────────┘
| 캐시 | 위치 | 수명 | 무효화 방법 |
|---|---|---|---|
| Router Cache | 클라이언트 | 세션/시간 | router.refresh, 내비게이션 |
| Full Route Cache | 서버 | 배포까지 | 재배포, revalidate |
| Data Cache | 서버 | 영속 | revalidateTag, revalidatePath |
| Request Memo | 서버 | 요청 1회 | 자동 |
7. Turbopack — Rust 기반 기본 번들러
Next.js 16에서 Turbopack은 개발 서버와 빌드의 기본 번들러로 자리 잡았습니다. webpack 시대 대비 개발 서버 시작이 약 4배, 갱신(렌더) 반영이 약 50% 빨라졌다고 공식적으로 안내되어 왔습니다. 정확한 수치는 프로젝트 규모와 버전에 따라 달라질 수 있습니다.
webpack (기존) Turbopack (Rust)
────────────────────────── ──────────────────────────
JS 기반 단일 스레드 위주 Rust 멀티코어 병렬 처리
전체 그래프 재계산 경향 함수 단위 증분 캐싱
큰 앱일수록 시작 느림 요청한 라우트만 온디맨드 컴파일
HMR 점진적 변경분만 최소 재계산
Turbopack의 핵심은 요청 기반 증분 컴파일과 세밀한 캐싱입니다. 전체 앱을 미리 다 빌드하지 않고, 실제로 접근한 라우트와 모듈만 컴파일하며, 변경이 생기면 영향받은 부분만 다시 계산합니다.
파일 변경 (button.tsx)
│
▼
┌──────────────────────────────┐
│ 의존성 그래프에서 영향 추적 │
│ button.tsx → toolbar.tsx → │
│ page.tsx │
└──────────────┬───────────────┘
▼
영향받은 노드만 무효화 후 재계산
(그래프의 나머지는 캐시 그대로)
│
▼
HMR 패치를 브라우저로 전송
8. 마이그레이션과 함정
App Router와 Cache Components로 옮겨갈 때 자주 부딪히는 지점을 정리합니다.
8.1 async 데이터 API
요청별 데이터 접근 API(cookies, headers, params, searchParams 등)가 비동기로 다뤄지는 방향으로 정리되었습니다. 동기로 접근하던 코드는 await를 붙여야 합니다.
// 변경된 패턴
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
return <div>{id}</div>
}
8.2 명시적 캐싱으로의 전환
과거에는 fetch가 기본적으로 캐시되는 등 암묵적 동작이 있었습니다. 새 모델에서는 캐시를 원하면 use cache로 명시하는 사고방식으로 바꾸는 것이 안전합니다. "캐시되겠지"라는 가정 대신 "캐시한다고 선언했는가"를 확인하세요.
8.3 흔한 함정 체크리스트
[ ] 클라이언트 컴포넌트에 서버 전용 모듈(fs, db)을 import 하지 않았는가
[ ] 서버→클라이언트 props가 모두 직렬화 가능한가 (함수/클래스 제외)
[ ] 'use client' 경계를 너무 위쪽에 둬서 번들이 비대해지지 않았는가
[ ] 동적 데이터(cookies/headers) 접근 영역을 Suspense로 감쌌는가
[ ] revalidateTag 의 태그 문자열이 cacheTag 와 정확히 일치하는가
[ ] 환경 변수 중 NEXT_PUBLIC_ 접두사 여부를 혼동하지 않았는가
8.4 경계 위치가 성능을 좌우한다
"use client"를 트리 상단(레이아웃)에 두면 그 아래 모든 것이 클라이언트 번들로 끌려 들어갑니다. 상호작용이 필요한 잎 노드에 가깝게 경계를 내려두는 것이 번들 크기와 초기 로딩에 결정적입니다.
나쁜 예 좋은 예
───────────── ─────────────
RootLayout ("use client") RootLayout (server)
└─ 전부 클라이언트 번들 └─ Page (server)
└─ LikeButton ("use client")
← 상호작용 잎만 클라이언트
9. 실무 적용 시나리오
전형적인 대시보드 페이지를 Cache Components 관점에서 설계해 봅니다.
/dashboard/[id]
├─ Layout (정적, 'use cache') ← 네비/사이드바
├─ ProfileHeader (동적: cookies 사용) ← 사용자별, Suspense 구멍
├─ StatsPanel ('use cache', cacheLife) ← 5분 캐시, 공용 통계
└─ ActivityFeed (동적: 실시간) ← 스트리밍, Suspense 구멍
이렇게 하면 네비와 공용 통계는 CDN/캐시에서 즉시 내려오고, 사용자별·실시간 영역만 요청 시 채워집니다. 페이지 하나가 정적의 속도와 동적의 신선함을 동시에 갖는 구조입니다.
마치며
Next.js 16의 아키텍처는 "명시성"과 "증분성"이라는 두 키워드로 요약됩니다. Cache Components는 캐시를 암묵에서 명시로 끌어올려 예측 가능하게 만들었고, Turbopack은 빌드를 전체 재계산에서 증분 계산으로 바꿔 개발 경험을 끌어올렸습니다. PPR은 정적과 동적의 오랜 이분법을 한 페이지 안에서 녹여냈습니다.
핵심은 도구가 아니라 사고방식입니다. "이 영역은 정적인가 동적인가", "캐시한다고 선언했는가", "경계를 잎에 가깝게 두었는가"를 습관처럼 묻는다면, 프레임워크의 추상화가 오히려 명료한 설계 언어가 되어 줄 것입니다. 세부 동작과 기본값은 버전에 따라 달라질 수 있으니, 실제 적용 시에는 공식 문서를 함께 확인하시길 권합니다.