Skip to content

필사 모드: Next.js 16 아키텍처 — Turbopack과 Cache Components 깊이 보기

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

들어가며

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은 정적과 동적의 오랜 이분법을 한 페이지 안에서 녹여냈습니다.

핵심은 도구가 아니라 사고방식입니다. "이 영역은 정적인가 동적인가", "캐시한다고 선언했는가", "경계를 잎에 가깝게 두었는가"를 습관처럼 묻는다면, 프레임워크의 추상화가 오히려 명료한 설계 언어가 되어 줄 것입니다. 세부 동작과 기본값은 버전에 따라 달라질 수 있으니, 실제 적용 시에는 공식 문서를 함께 확인하시길 권합니다.


참고 자료

현재 단락 (1/227)

Next.js는 단순한 React 프레임워크를 넘어, 렌더링 전략과 캐시 계층, 그리고 빌드 도구까지 하나로 묶은 풀스택 플랫폼으로 진화했습니다. Next.js 16에 이르러 두 ...

작성 글자: 0원문 글자: 8,918작성 단락: 0/227