Next.js 인사이드 - 내부 동작 원리와 아키텍처 완벽 분석
Next.js의 내부 동작 원리를 깊이 있게 분석합니다. React Server Components 아키텍처, 렌더링 파이프라인, RSC Payload, 스트리밍, 캐싱 메커니즘, 라우팅 시스템까지 - Next.js가 어떻게 빠르고 효율적인 웹 애플리케이션을 만드는지 기술적으로 파헤칩니다.
📋 핵심 내용 요약
- RSC 아키텍처: React Server Components의 동작 원리와 설계 철학
- 렌더링 파이프라인: 요청부터 화면 표시까지의 전체 과정
- RSC Payload: 서버-클라이언트 통신의 핵심 데이터 구조
- 스트리밍: 점진적 렌더링과 Suspense의 내부 메커니즘
- 캐싱 전략: 4가지 캐시 레이어의 작동 방식
- 라우팅 시스템: App Router의 내부 구조와 최적화
🤖 AI 요약
Next.js 15는 단순한 React 프레임워크가 아닙니다. React Server Components(RSC), 스트리밍 아키텍처, 정교한 캐싱 시스템이 결합된 현대적인 웹 플랫폼입니다.
왜 Next.js는 빠를까? Next.js는 전통적인 SSR과 달리 컴포넌트 단위로 서버/클라이언트를 분리합니다. 서버에서는 데이터베이스 직접 접근과 무거운 연산을 수행하고, 클라이언트에는 인터랙티브한 부분만 JavaScript로 전송합니다. 이를 통해 JavaScript 번들 크기를 최소화하고 초기 로딩을 극적으로 개선합니다.
RSC Payload란? RSC Payload는 서버 컴포넌트의 렌더링 결과를 클라이언트에 전달하는 특수한 JSON 형식입니다. HTML이 아닌 React 트리 구조를 유지하면서도 직렬화 가능한 형태로, 클라이언트에서 React 컴포넌트 트리에 효율적으로 통합됩니다.
스트리밍의 마법 Next.js는 페이지를 한 번에 렌더링하지 않습니다. Suspense 경계를 활용해 준비된 부분부터 순차적으로 스트리밍합니다. 사용자는 데이터 로딩을 기다리지 않고 즉시 UI를 볼 수 있으며, 느린 부분은 로딩 스피너를 보여줍니다.
4중 캐싱 시스템 Next.js는 Request Memoization, Data Cache, Full Route Cache, Router Cache - 4개 레이어의 정교한 캐싱으로 성능을 극대화합니다. 각 레이어는 서로 다른 수명과 목적을 가지며, 개발자는 필요에 따라 세밀하게 제어할 수 있습니다.
App Router의 혁신 파일 시스템 기반 라우팅은 단순해 보이지만, 내부적으로는 병렬 라우팅, 인터셉트 라우팅, 중첩 레이아웃을 지원하는 복잡한 시스템입니다. 모든 것이 트리 구조로 관리되며, 변경된 부분만 선택적으로 리렌더링합니다.
이 글에서는 이러한 모든 메커니즘을 코드와 다이어그램으로 상세히 분석합니다.
Next.js가 어떻게 동작하는지 깊이 있게 알아봅시다.
React Server Components (RSC) 아키텍처
RSC의 탄생 배경
전통적인 React 애플리케이션은 두 가지 극단 사이에서 고민했습니다:
Server-Side Rendering (SSR):
- 장점: 빠른 초기 로딩, SEO 친화적
- 단점: 모든 JavaScript를 클라이언트에 전송, 인터랙티브하려면 hydration 필요
Client-Side Rendering (CSR):
- 장점: 풍부한 인터랙션
- 단점: 느린 초기 로딩, 데이터 fetching waterfall
RSC는 이 두 가지의 장점을 결합한 새로운 패러다임입니다.
RSC의 핵심 개념
// Server Component (기본)
// 이 컴포넌트는 서버에서만 실행됩니다
async function BlogPost({ id }: { id: string }) {
// 직접 데이터베이스 접근 - 클라이언트에 노출되지 않음
const post = await db.post.findUnique({ where: { id } });
// 이 컴포넌트의 JavaScript는 클라이언트로 전송되지 않습니다
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
{/* Client Component는 여기에 중첩 가능 */}
<LikeButton postId={id} />
</article>
);
}
// Client Component
'use client';
function LikeButton({ postId }: { postId: string }) {
const [liked, setLiked] = useState(false);
// 이 컴포넌트의 JavaScript는 클라이언트로 전송됩니다
return (
<button onClick={() => setLiked(!liked)}>
{liked ? '❤️' : '🤍'}
</button>
);
} 컴포넌트 분류:
| 특징 | Server Component | Client Component |
|---|---|---|
| 실행 위치 | 서버만 | 서버(초기) + 클라이언트 |
| JavaScript 번들 | 포함 안됨 | 포함됨 |
| 데이터 페칭 | async/await 직접 | useEffect + fetch |
| 상태 관리 | 불가능 | useState, useReducer |
| 브라우저 API | 사용 불가 | 사용 가능 |
| 이벤트 핸들러 | 불가능 | 가능 |
RSC의 제약사항과 규칙
1. Server Component는 Client Component를 import 가능
// ✅ 가능
// ServerComponent.tsx
import ClientButton from './ClientButton'; // 'use client'
export default function ServerComponent() {
return <ClientButton />;
} 2. Client Component는 Server Component를 import 불가
// ❌ 불가능
'use client';
import ServerComponent from './ServerComponent';
// ✅ 대신 props로 전달
export default function ClientWrapper({ children }) {
return <div>{children}</div>;
}
// 사용처
<ClientWrapper>
<ServerComponent />
</ClientWrapper> 3. Context Provider는 Client Component
// providers.tsx
'use client';
export function Providers({ children }) {
return (
<ThemeProvider>
<AuthProvider>
{children}
</AuthProvider>
</ThemeProvider>
);
} 렌더링 파이프라인 Deep Dive
Next.js의 요청 처리 과정을 단계별로 분석해봅시다.
1단계: 요청 수신
사용자 브라우저
↓
GET /blog/hello-world
↓
Next.js Server (Node.js / Edge Runtime)
↓
App Router (라우트 매칭) Next.js는 파일 시스템 라우팅을 사용하지만, 실제로는 트리 구조로 변환됩니다:
// 내부적으로 생성되는 라우트 트리
{
segment: '',
children: {
blog: {
segment: 'blog',
children: {
'[slug]': {
segment: '[slug]',
page: () => import('./app/blog/[slug]/page.tsx')
}
}
}
}
} 2단계: Server Component 렌더링
// app/blog/[slug]/page.tsx
async function Page({ params }: { params: { slug: string } }) {
// 1. 데이터 페칭 (서버에서 실행)
const post = await fetch('https://api.example.com/posts/' + params.slug)
.then(res => res.json());
// 2. React Element 생성
return (
<article>
<h1>{post.title}</h1>
<Content>{post.content}</Content>
</article>
);
} React는 이 컴포넌트를 렌더링하면서:
- 함수를 실행하여 React Element 트리 생성
- 비동기 컴포넌트는 대기 (await 완료까지)
- RSC Payload로 직렬화
3단계: RSC Payload 생성
RSC Payload는 JSON과 유사하지만 React 전용 형식입니다:
// RSC Payload 예시 (간소화)
{
// 컴포넌트 타입
"type": "article",
// Props
"props": {},
// 자식 노드
"children": [
{
"type": "h1",
"props": {},
"children": "Hello World"
},
{
// Client Component 참조
"type": "$L1", // Lazy 컴포넌트 참조
"props": { "initialLikes": 42 }
}
]
} 특수 마커:
$L{n}: Client Component 참조 (Lazy)$S{n}: Suspense 경계$E{n}: Error 경계
4단계: HTML 스트리밍
Next.js는 페이지를 한 번에 보내지 않고 청크 단위로 스트리밍합니다:
<!DOCTYPE html>
<html>
<head>
<script src="/chunks/main.js" defer></script>
</head>
<body>
<div id="__next">
<!-- 즉시 렌더링 가능한 부분 -->
<header>...</header>
<!-- Suspense 경계 - 플레이스홀더 -->
<div id="suspense-1">
<div class="skeleton-loading">Loading...</div>
</div>
<!-- 스크립트로 나중에 교체 -->
<script>
// 데이터가 준비되면 이 스크립트가 전송됨
self.__next_f.push([1, "suspense-1", "<article>...</article>"])
</script>
</div>
</body>
</html> 5단계: 클라이언트 Hydration
// 클라이언트에서 실행되는 코드 (간소화)
function hydrateRoot(container, rscPayload) {
// 1. RSC Payload 파싱
const reactTree = parseRSCPayload(rscPayload);
// 2. Client Component 로드
const clientComponents = await loadClientComponents(reactTree);
// 3. React 트리 구성
const root = createRoot(container);
// 4. 인터랙티브하게 만들기
root.render(reactTree);
} RSC Payload 상세 분석
RSC Payload는 Next.js의 핵심입니다. 실제 구조를 살펴봅시다.
Payload 형식
// RSC Payload는 배열의 배열 형식
[
// [rowId, type, key, props]
[0, "$", "article", {}],
[1, "$", "h1", {}],
[2, "t", "Hello World"], // 텍스트 노드
[3, "$L", "components/LikeButton.tsx", { "postId": "123" }], // Lazy
] Row 타입:
$: HTML 엘리먼트$L: Client Component (Lazy)t: 텍스트 노드$S: Suspense 경계$E: Error 경계
직렬화 규칙
Server Component는 직렬화 가능한 데이터만 클라이언트로 전달할 수 있습니다:
// ✅ 직렬화 가능
const data = {
string: "hello",
number: 42,
boolean: true,
null: null,
array: [1, 2, 3],
object: { a: 1 },
date: new Date(), // ISO 문자열로 변환
};
// ❌ 직렬화 불가능
const invalid = {
function: () => {}, // 함수는 전달 불가
symbol: Symbol('test'), // 심볼 불가
classInstance: new MyClass(), // 클래스 인스턴스 불가
}; Payload 최적화
Next.js는 Payload를 최적화합니다:
// 중복 제거
// Before
{
user: { id: 1, name: "John" },
posts: [
{ author: { id: 1, name: "John" } },
{ author: { id: 1, name: "John" } }
]
}
// After (참조로 변경)
{
"$1": { id: 1, name: "John" },
"user": "$1",
"posts": [
{ "author": "$1" },
{ "author": "$1" }
]
} 스트리밍과 Suspense 메커니즘
Suspense의 동작 원리
Suspense는 Promise를 throw하는 메커니즘입니다:
// React 내부 (간소화)
function renderComponent(Component) {
try {
const result = Component();
return result;
} catch (thrown) {
if (thrown instanceof Promise) {
// Promise가 throw됨 = 컴포넌트가 suspend
thrown.then(() => {
// Promise 완료 후 재렌더링
retryRender(Component);
});
// Fallback UI 반환
return <SuspenseFallback />;
}
throw thrown; // 실제 에러는 다시 throw
}
} 실전 예제
// app/dashboard/page.tsx
import { Suspense } from 'react';
export default function Dashboard() {
return (
<div>
{/* 즉시 렌더링 */}
<Header />
{/* 데이터 로딩 중에는 Skeleton 표시 */}
<Suspense fallback={<StatsSkeleton />}>
<Stats /> {/* 느린 데이터 페칭 */}
</Suspense>
{/* 다른 Suspense 경계 - 독립적으로 로딩 */}
<Suspense fallback={<ChartSkeleton />}>
<Chart /> {/* 또 다른 느린 데이터 */}
</Suspense>
</div>
);
}
async function Stats() {
// 느린 API 호출
const stats = await fetch('https://api.example.com/stats', {
cache: 'no-store'
}).then(r => r.json());
return <StatsDisplay data={stats} />;
} 스트리밍 타임라인
시간 →
0ms: HTML Shell 전송
├─ <Header />
├─ <StatsSkeleton /> ← Suspense fallback
└─ <ChartSkeleton /> ← Suspense fallback
500ms: Stats 데이터 준비 완료
→ <Stats /> HTML 청크 스트리밍
→ 클라이언트에서 Skeleton → Stats 교체
800ms: Chart 데이터 준비 완료
→ <Chart /> HTML 청크 스트리밍
→ 클라이언트에서 Skeleton → Chart 교체 네트워크 레벨에서 본 스트리밍
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Transfer-Encoding: chunked
<!-- 첫 번째 청크 -->
<!DOCTYPE html>
<html>
<body>
<div id="__next">
<header>Navigation</header>
<div id="S:1">Loading stats...</div>
<!-- 두 번째 청크 (500ms 후) -->
<template id="S:1:data">
<div class="stats">
<h2>Statistics</h2>
<p>Users: 1,234</p>
</div>
</template>
<script>
$RC(1, "S:1"); // Replace Content
</script>
<!-- 세 번째 청크 (800ms 후) -->
<template id="S:2:data">
<div class="chart">...</div>
</template>
<script>
$RC(2, "S:2");
</script> 캐싱 시스템 완벽 분석
Next.js는 4가지 레이어의 캐시를 사용합니다.
1. Request Memoization (요청 중복 제거)
범위: 단일 렌더링 트리 내 수명: 서버 요청 처리 동안만
// 내부 구현 (간소화)
const requestCache = new Map();
export function fetch(url, options) {
const key = generateKey(url, options);
// 같은 렌더링 중 동일한 요청은 재사용
if (requestCache.has(key)) {
return requestCache.get(key);
}
const promise = originalFetch(url, options);
requestCache.set(key, promise);
return promise;
} 예제:
// app/page.tsx
async function Page() {
// 이 두 컴포넌트가 같은 URL을 fetch하면?
return (
<>
<UserProfile userId="1" />
<UserPosts userId="1" />
</>
);
}
async function UserProfile({ userId }) {
// 첫 번째 호출 - 실제 네트워크 요청
const user = await fetch('/api/user/' + userId);
return <div>{user.name}</div>;
}
async function UserPosts({ userId }) {
// 두 번째 호출 - 캐시에서 즉시 반환!
const user = await fetch('/api/user/' + userId);
return <div>{user.posts.length} posts</div>;
} 2. Data Cache (지속적 캐시)
범위: 서버 전체 수명: 재배포 전까지 (설정 가능)
// 기본 - 무기한 캐시
fetch('https://api.example.com/data');
// 시간 기반 재검증 (60초)
fetch('https://api.example.com/data', {
next: { revalidate: 60 }
});
// 캐시 없음
fetch('https://api.example.com/data', {
cache: 'no-store'
});
// On-demand 재검증
revalidateTag('posts'); 내부 저장소:
Next.js는 Data Cache를 파일 시스템에 저장합니다:
.next/
└── cache/
└── fetch-cache/
├── 1a2b3c4d.body ← 응답 본문
└── 1a2b3c4d.meta ← 메타데이터 (헤더, 만료시간 등) 3. Full Route Cache (정적 생성)
범위: 서버 전체 수명: 재배포 전까지
빌드 시 생성되는 정적 페이지:
.next/
└── server/
└── app/
├── blog.html ← Static HTML
├── blog.rsc ← RSC Payload
└── blog/
└── [slug]/
├── post-1.html
└── post-1.rsc 동적 페이지 vs 정적 페이지:
// 정적 (Full Route Cache 사용)
export default async function StaticPage() {
const data = await fetch('https://api.example.com/data', {
next: { revalidate: 3600 } // 1시간 캐시
});
return <div>{data}</div>;
}
// 동적 (캐시 안됨)
export default async function DynamicPage() {
const data = await fetch('https://api.example.com/data', {
cache: 'no-store' // 캐시 없음
});
return <div>{data}</div>;
}
// 동적으로 만드는 다른 방법들
export const dynamic = 'force-dynamic'; // 페이지 레벨 설정
export const revalidate = 0; // 매 요청마다 재검증 4. Router Cache (클라이언트)
범위: 브라우저 메모리 수명: 세션 동안 (설정 가능)
클라이언트 사이드 네비게이션 시:
// 사용자가 링크 클릭
<Link href="/blog/post-1">Read more</Link>
// 1. Router Cache 확인
const cachedPayload = routerCache.get('/blog/post-1');
if (cachedPayload && !isExpired(cachedPayload)) {
// 2. 캐시 히트 - 즉시 렌더링
render(cachedPayload);
} else {
// 3. 캐시 미스 - 서버에서 RSC Payload 가져오기
const payload = await fetch('/blog/post-1?_rsc=1');
routerCache.set('/blog/post-1', payload);
render(payload);
} 캐시 무효화:
// 1. 프로그래밍 방식
import { useRouter } from 'next/navigation';
const router = useRouter();
router.refresh(); // 현재 경로 재검증
// 2. Server Action에서 자동
'use server';
export async function createPost() {
await db.post.create(...);
revalidatePath('/blog'); // 자동으로 Router Cache도 무효화
} 캐시 흐름도
요청 → Request Memoization (동일 렌더링 내)
↓ miss
Data Cache (서버 저장소)
↓ miss
Full Route Cache (빌드 시 생성)
↓ miss
실제 데이터 소스
↓
응답 ← Router Cache (클라이언트) 라우팅 시스템의 내부
파일 시스템 → 라우트 트리 변환
app/
├── page.tsx → /
├── about/
│ └── page.tsx → /about
├── blog/
│ ├── page.tsx → /blog
│ └── [slug]/
│ ├── page.tsx → /blog/:slug
│ └── edit/
│ └── page.tsx → /blog/:slug/edit
└── (dashboard)/ → / (route group, URL에 포함 안됨)
├── layout.tsx
└── settings/
└── page.tsx → /settings 변환된 트리:
{
segment: '',
layout: LayoutComponent,
page: HomePageComponent,
children: [
{
segment: 'about',
page: AboutPageComponent
},
{
segment: 'blog',
page: BlogListComponent,
children: [
{
segment: '[slug]',
dynamic: true,
page: BlogPostComponent,
children: [
{
segment: 'edit',
page: EditPostComponent
}
]
}
]
},
{
segment: 'settings',
layout: DashboardLayoutComponent,
page: SettingsPageComponent
}
]
} 병렬 라우트 (Parallel Routes)
app/
└── dashboard/
├── @analytics/
│ └── page.tsx
├── @team/
│ └── page.tsx
└── layout.tsx // app/dashboard/layout.tsx
export default function DashboardLayout({
children,
analytics, // @analytics/page.tsx
team, // @team/page.tsx
}) {
return (
<div>
{children}
<div className="grid grid-cols-2">
{analytics}
{team}
</div>
</div>
);
} 병렬 렌더링:
// 내부적으로 Promise.all 사용
const [analyticsPayload, teamPayload, childrenPayload] = await Promise.all([
renderSlot('@analytics'),
renderSlot('@team'),
renderSlot('children')
]); 인터셉트 라우트 (Intercepting Routes)
app/
├── photos/
│ └── [id]/
│ └── page.tsx → /photos/123 (전체 페이지)
└── feed/
├── page.tsx → /feed
└── (..)photos/
└── [id]/
└── page.tsx → 모달로 표시 // feed/(..)photos/[id]/page.tsx
export default function PhotoModal({ params }) {
return (
<Modal>
<Image src={'/photos/' + params.id} />
</Modal>
);
}
// photos/[id]/page.tsx
export default function PhotoPage({ params }) {
return (
<div className="full-page">
<Image src={'/photos/' + params.id} />
</div>
);
} 동작 원리:
/feed에서 /photos/123 클릭
→ 클라이언트 네비게이션
→ 인터셉트 라우트 매칭
→ 모달 표시 (feed/(..)photos/[id]/page.tsx)
브라우저 직접 입력 or 새로고침: /photos/123
→ 서버 렌더링
→ 일반 라우트 매칭
→ 전체 페이지 표시 (photos/[id]/page.tsx) 빌드 프로세스 분석
Next.js 빌드 단계
npm run build 1. TypeScript 컴파일
├─ .ts/.tsx → .js
└─ 타입 체크
2. Route 분석
├─ 파일 시스템 스캔
├─ 정적/동적 라우트 분류
└─ 의존성 그래프 생성
3. 컴파일 (Webpack / Turbopack)
├─ 서버 번들 생성
│ ├─ Server Components
│ ├─ API Routes
│ └─ Middleware
└─ 클라이언트 번들 생성
├─ Client Components
├─ 공통 청크 분리
└─ 코드 스플리팅
4. 정적 생성 (Static Generation)
├─ 정적 라우트 렌더링
├─ generateStaticParams 실행
└─ HTML + RSC Payload 생성
5. 최적화
├─ 이미지 최적화
├─ 폰트 최적화
├─ CSS 압축
└─ JavaScript 압축 (Terser/SWC)
6. 출력
└─ .next/ 디렉토리 생성 빌드 출력 구조
.next/
├── cache/ # 캐시 데이터
│ └── fetch-cache/ # Data Cache
├── server/ # 서버 사이드 파일
│ ├── app/ # App Router 페이지
│ │ ├── blog.html # 정적 HTML
│ │ └── blog.rsc # RSC Payload
│ └── chunks/ # 서버 청크
├── static/ # 정적 자산
│ ├── chunks/ # 공통 청크
│ ├── css/ # CSS 파일
│ └── media/ # 이미지, 폰트
└── build-manifest.json # 빌드 메타데이터 Code Splitting 전략
Next.js는 자동으로 코드를 분할합니다:
// 자동 분할
// app/blog/page.tsx → chunks/app-blog-page.js
// app/about/page.tsx → chunks/app-about-page.js
// 수동 분할 (dynamic import)
import dynamic from 'next/dynamic';
const HeavyComponent = dynamic(() => import('./HeavyComponent'), {
loading: () => <Spinner />,
ssr: false // 클라이언트에서만 로드
}); 청크 분류:
main.js # React, ReactDOM 등 핵심 라이브러리
framework.js # Next.js 런타임
app-layout.js # 루트 레이아웃
app-blog-page.js # /blog 페이지
app-blog-[slug]-page.js # /blog/[slug] 페이지 Tree Shaking
Next.js는 사용하지 않는 코드를 자동으로 제거합니다:
// library.ts
export function used() {
return 'This is used';
}
export function unused() {
return 'This is never used';
}
// app/page.tsx
import { used } from './library';
export default function Page() {
return <div>{used()}</div>;
}
// 빌드 결과: unused() 함수는 번들에 포함되지 않음 성능 최적화 기법
1. Partial Prerendering (실험적)
Next.js 15의 혁신적 기능:
// next.config.js
module.exports = {
experimental: {
ppr: true
}
};
// app/page.tsx
export default function Page() {
return (
<div>
{/* 정적 부분 - 빌드 시 렌더링 */}
<Header />
<StaticContent />
{/* 동적 부분 - 요청 시 렌더링 */}
<Suspense fallback={<Skeleton />}>
<DynamicContent />
</Suspense>
</div>
);
} 작동 방식:
빌드 시:
정적 부분 → HTML 생성
동적 부분 → "구멍(hole)" 남김
요청 시:
정적 HTML 즉시 전송
동적 부분 렌더링
구멍에 스트리밍으로 채움 2. Image Optimization
Next.js Image 컴포넌트의 내부:
import Image from 'next/image';
<Image
src="/photo.jpg"
width={800}
height={600}
alt="Photo"
priority
/>
// 실제 렌더링 결과
<img
srcset="
/_next/image?url=/photo.jpg&w=640&q=75 640w,
/_next/image?url=/photo.jpg&w=750&q=75 750w,
/_next/image?url=/photo.jpg&w=828&q=75 828w,
/_next/image?url=/photo.jpg&w=1080&q=75 1080w,
/_next/image?url=/photo.jpg&w=1200&q=75 1200w
"
sizes="(max-width: 768px) 100vw, 800px"
src="/_next/image?url=/photo.jpg&w=1200&q=75"
loading="lazy"
decoding="async"
/> 최적화 프로세스:
- 온디맨드 최적화: 빌드 시가 아닌 첫 요청 시 최적화
- 캐싱: 최적화된 이미지는 캐시에 저장
- 포맷 변환: WebP, AVIF 자동 변환 (브라우저 지원 시)
- 리사이징: 요청된 크기에 맞게 조정
- 압축: 품질 파라미터로 압축
3. 폰트 최적화
import { Inter, Noto_Sans_KR } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
display: 'swap',
variable: '--font-inter'
});
// 빌드 시 다운로드되어 self-hosted됨
// /_next/static/media/inter.woff2 최적화:
- Self-hosting: 구글 폰트를 빌드 시 다운로드
- CSS 인라인: font-face를 HTML에 인라인
- Preload: 중요한 폰트는 preload
- Font-display: swap으로 FOIT 방지
4. Third-Party Scripts
import Script from 'next/script';
<Script
src="https://example.com/script.js"
strategy="lazyOnload" // 페이지 로드 후 로드
/> 전략:
beforeInteractive: HTML 파싱 전 (critical)afterInteractive: 페이지 인터랙티브 후 (기본값)lazyOnload: 모든 리소스 로드 후worker: Web Worker에서 실행 (실험적)
메모리 관리와 가비지 컬렉션
Server Component 메모리
Server Component는 요청 처리 후 즉시 정리됩니다:
async function ServerComponent() {
// 이 데이터는 렌더링 후 메모리에서 해제됨
const largeData = await fetchLargeDataset();
return <DataDisplay data={largeData} />;
}
// 렌더링 완료 후:
// - largeData는 가비지 컬렉션 대상
// - RSC Payload만 클라이언트로 전송
// - 서버 메모리 즉시 회수 클라이언트 메모리
Router Cache 관리:
// 내부 구현 (간소화)
class RouterCache {
private cache = new Map();
private maxSize = 50; // 최대 캐시 항목
set(key, value) {
if (this.cache.size >= this.maxSize) {
// LRU: 가장 오래된 항목 제거
const oldestKey = this.cache.keys().next().value;
this.cache.delete(oldestKey);
}
this.cache.set(key, value);
}
} 디버깅과 성능 분석
React DevTools
Server Components는 특별하게 표시됩니다:
ComponentTree
├─ Layout (Server)
├─ Header (Server)
├─ Navigation (Client)
│ └─ MenuItem (Client)
└─ Content (Server)
└─ LikeButton (Client) Next.js 디버그 모드
# 상세한 로그 출력
NODE_OPTIONS='--inspect' next dev
# 렌더링 정보 표시
NEXT_DEBUG_LOG=1 next build 성능 측정
// Server Component 성능
import { unstable_noStore as noStore } from 'next/cache';
export default async function SlowComponent() {
const start = Date.now();
const data = await fetchData();
const duration = Date.now() - start;
console.log('Render duration:', duration + 'ms');
return <div>{data}</div>;
} Lighthouse 분석
Next.js는 Lighthouse 점수를 극대화합니다:
Performance: 95+
✓ First Contentful Paint < 1.8s
✓ Largest Contentful Paint < 2.5s
✓ Total Blocking Time < 200ms
✓ Cumulative Layout Shift < 0.1
Best Practices: 100
✓ HTTPS
✓ 이미지 최적화
✓ 브라우저 에러 없음
SEO: 100
✓ 메타태그
✓ 구조화 데이터
✓ 크롤링 가능 실전 팁과 Best Practices
1. Server Component 최대 활용
// ❌ 나쁜 예: 모든 것을 Client Component로
'use client';
export default function Page() {
const [data, setData] = useState(null);
useEffect(() => {
fetch('/api/data').then(r => r.json()).then(setData);
}, []);
return <div>{data?.title}</div>;
}
// ✅ 좋은 예: Server Component로 데이터 페칭
export default async function Page() {
const data = await fetch('/api/data').then(r => r.json());
return (
<div>
<h1>{data.title}</h1>
<InteractiveButton /> {/* Client Component는 필요한 곳만 */}
</div>
);
} 2. 적절한 캐싱 전략
// 자주 변하지 않는 데이터 - 긴 재검증
const posts = await fetch('/api/posts', {
next: { revalidate: 3600 } // 1시간
});
// 실시간 데이터 - 캐시 없음
const liveData = await fetch('/api/live', {
cache: 'no-store'
});
// 사용자별 데이터 - 캐시 없음
const userData = await fetch('/api/user/' + userId, {
cache: 'no-store'
}); 3. 효율적인 Suspense 사용
// ❌ 나쁜 예: 전체를 하나의 Suspense로
<Suspense fallback={<PageSkeleton />}>
<SlowComponent1 />
<SlowComponent2 />
<SlowComponent3 />
</Suspense>
// ✅ 좋은 예: 독립적인 Suspense 경계
<div>
<Suspense fallback={<Skeleton1 />}>
<SlowComponent1 />
</Suspense>
<Suspense fallback={<Skeleton2 />}>
<SlowComponent2 />
</Suspense>
<Suspense fallback={<Skeleton3 />}>
<SlowComponent3 />
</Suspense>
</div> 4. 에러 처리
// app/blog/[slug]/error.tsx
'use client';
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div>
<h2>Something went wrong!</h2>
<p>{error.message}</p>
<button onClick={reset}>Try again</button>
</div>
);
} 5. 메타데이터 최적화
// 정적 메타데이터
export const metadata = {
title: 'My Page',
description: 'Page description',
openGraph: {
images: ['/og-image.jpg'],
},
};
// 동적 메타데이터
export async function generateMetadata({ params }) {
const post = await getPost(params.id);
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
images: [post.coverImage],
},
};
} 마무리
Next.js의 내부 동작 원리를 깊이 있게 살펴봤습니다.
핵심 요약:
- RSC 아키텍처: 서버/클라이언트 컴포넌트 분리로 번들 크기 최소화
- RSC Payload: 효율적인 서버-클라이언트 통신 메커니즘
- 스트리밍: Suspense 기반의 점진적 렌더링
- 4중 캐싱: Request, Data, Route, Router 캐시의 조화
- App Router: 파일 시스템 기반의 정교한 라우팅 시스템
- 빌드 최적화: Tree shaking, code splitting, 압축
배운 내용을 적용하면:
- ✅ 더 빠른 초기 로딩
- ✅ 작은 JavaScript 번들
- ✅ 더 나은 SEO
- ✅ 향상된 사용자 경험
Next.js는 단순한 프레임워크가 아니라 현대적 웹 개발의 정수를 담은 플랫폼입니다.
더 알아보기
Happy optimizing! 🚀
질문이나 피드백이 있으신가요?
이 심화 가이드에 대한 질문이나 제안사항이 있다면 언제든 연락주세요!
📧 이메일: [email protected]