✍️ 필사 모드: React Server Components & Next.js App Router Deep Dive — RSC, Streaming, Cache, Server Actions 완전 정복 (2025)
한국어TL;DR
- React Server Components (RSC): React 18 (2022)에 도입된 새 컴퓨팅 모델. 일부 컴포넌트가 서버에서만 실행되고 클라이언트에 JS 번들로 전달되지 않음.
- vs SSR: SSR은 HTML 생성 후 hydration. RSC는 서버와 클라이언트를 컴포넌트 단위로 나누고 네트워크를 투명하게 가로지름.
"use client"지시어: 파일 최상단에 선언. "이 파일부터의 컴포넌트 트리는 클라이언트에서 돈다"는 네트워크 경계 표시.- Payload 포맷: RSC는 스트리밍 직렬화된 컴포넌트 트리를 서버에서 클라이언트로 전송. HTML이 아닌 구조화된 트리 데이터.
- Server Actions:
"use server"함수 직접 호출 → Next.js가 RPC로 변환.<form action={myAction}>같이 사용. - Next.js App Router (2023+): 파일 시스템 기반 라우팅 + layout/loading/error 규약 + RSC 기본.
- 4층 캐시: Request Memoization (per-request dedup) + Data Cache (fetch level) + Full Route Cache (HTML) + Router Cache (client).
- Streaming & Suspense: 서버가 준비된 부분을 먼저 전송, 나머지는 나중에.
loading.tsx로 route-level skeleton. - Turbopack: Webpack 대체, Rust 기반. 2024+ 안정화 시작.
- Edge vs Node Runtime: Edge는 빠른 cold start + 제한 API, Node는 완전 API + 일반 서버.
1. 왜 React Server Components인가
1.1 React의 역사적 문제
React의 전통 모델:
[Server] [Client]
HTML 응답 (빈 페이지) → JS 다운로드 → React 실행 → DOM 생성
문제:
- 첫 렌더 느림: JS 다운로드 → parse → execute → DOM. 수 초 걸림.
- SEO 부족: 처음에 HTML이 비어있어서 검색 크롤러가 못 봄.
- 거대한 번들: 모든 컴포넌트가 JS로 다운로드.
1.2 SSR의 등장
Server-Side Rendering (Next.js 초기):
[Server] [Client]
React → HTML 생성 → HTML 다운로드 (즉시 보임)
→ JS 다운로드
→ Hydration (이벤트 연결)
이점:
- 첫 페인트 빠름.
- SEO 좋음.
- 같은 React 코드 재사용.
여전히 문제:
- 모든 컴포넌트가 두 번 실행 (서버 + 클라이언트).
- 모든 컴포넌트 코드가 여전히 JS 번들에.
- DB 조회 같은 일이 서버에서만 돼도 코드는 클라이언트에 전달됨.
1.3 SSG와 ISR
Static Site Generation (SSG): 빌드 타임에 HTML 생성.
- 장점: 매우 빠름.
- 단점: 동적 데이터 어려움, 빌드 시간 폭발.
Incremental Static Regeneration (ISR): SSG + 주기적 재생성.
- 장점: 정적 속도 + 신선한 데이터.
- 단점: 스테일 데이터, 캐시 복잡도.
1.4 RSC — 근본적 해결
2020년 Dan Abramov가 처음 제시. 2022년 React 18에 preview. 2023년 Next.js App Router로 주류화.
핵심 아이디어:
"어떤 컴포넌트는 서버에서만 돌고, 어떤 컴포넌트는 클라이언트에서 돈다. 둘을 명시적으로 구분한다."
- Server Components: 서버에서만 실행. 클라이언트에 JS 번들로 보내지 않음. DB, 파일시스템, 무거운 라이브러리 자유롭게 사용.
- Client Components: 기존 React 컴포넌트.
useState,onClick등 사용 가능. 클라이언트 JS에 포함.
결과:
- 대부분 컴포넌트를 서버로 → 번들 크기 감소.
- SEO는 자동 (HTML 형태).
- 데이터 fetch가 컴포넌트에 자연스럽게 (async 함수).
- 인터랙티브한 부분만 클라이언트.
2. Server Components 기본
2.1 첫 번째 예제
// app/page.jsx
// 기본: 서버 컴포넌트
import db from '@/lib/db';
export default async function HomePage() {
const posts = await db.posts.findMany();
return (
<div>
<h1>Posts</h1>
{posts.map(post => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
</article>
))}
</div>
);
}
관찰:
async함수: 서버 컴포넌트는 async 가능. 평범한await로 데이터 로드.- DB 직접 호출: 클라이언트 번들에 포함되지 않으므로 안전.
useState불가: 서버 컴포넌트는 상태 없음.onClick불가: 이벤트 핸들러는 클라이언트 컴포넌트로.
2.2 Client Component
인터랙티브 요소가 필요하면 "use client":
// app/components/LikeButton.jsx
"use client";
import { useState } from 'react';
export default function LikeButton({ postId }) {
const [liked, setLiked] = useState(false);
return (
<button onClick={() => setLiked(!liked)}>
{liked ? '❤️' : '🤍'}
</button>
);
}
"use client"지시어는 파일 최상단에.useState, 이벤트 핸들러, 브라우저 API 사용 가능.- 이 파일의 모든 export는 클라이언트 컴포넌트.
2.3 혼합 사용
// app/page.jsx (서버)
import db from '@/lib/db';
import LikeButton from './components/LikeButton'; // 클라이언트 컴포넌트
export default async function HomePage() {
const posts = await db.posts.findMany();
return (
<div>
{posts.map(post => (
<article key={post.id}>
<h2>{post.title}</h2>
<LikeButton postId={post.id} /> {/* 클라이언트 컴포넌트 */}
</article>
))}
</div>
);
}
서버 컴포넌트가 클라이언트 컴포넌트를 렌더링한다. 서버에서 트리가 생성되고, 클라이언트 컴포넌트는 placeholder + props로 직렬화.
2.4 규칙
서버 컴포넌트 ✓:
- async 가능.
- DB, 파일시스템, 환경변수 접근.
- 다른 서버 컴포넌트 import.
- 클라이언트 컴포넌트 import 가능.
서버 컴포넌트 ✗:
useState,useEffect,useReducer등 hook.- 이벤트 핸들러 (
onClick,onChange). useContext(Context API 일부 제한).- 브라우저 API (
window,document).
클라이언트 컴포넌트 ✓:
- 기존 React의 모든 것.
클라이언트 컴포넌트 ✗:
- async 컴포넌트.
- 서버 전용 코드 (DB, fs) 직접 import.
2.5 "use client"의 진짜 의미
흔한 오해: "이 컴포넌트는 클라이언트에서만 돈다".
실제로는: "여기부터의 import 트리는 클라이언트 번들에 포함된다".
// Button.jsx
"use client";
import { useState } from 'react';
import MyComponent from './MyComponent'; // 이 파일은 자동으로 client
import { helper } from './utils'; // helper도 client에 포함
"use client"는 네트워크 경계. 이 경계 안쪽의 모든 모듈이 클라이언트 번들에 들어감. 경계 바깥은 서버에만.
2.6 Server → Client Props
서버 컴포넌트가 클라이언트 컴포넌트에 props를 전달할 때, 그 props는 직렬화되어 전송:
// 서버
<LikeButton postId={post.id} userId={user.id} />
postId, userId는 JSON으로 직렬화 가능 → OK.
직렬화 불가능한 것:
- 함수 (클로저).
- React element (단, 제한적 예외).
- Map, Set (일부).
- 클래스 인스턴스.
- Date (가능하지만 주의).
함수를 넘기고 싶으면 Server Action 사용 (아래).
3. 직렬화 Payload
3.1 RSC Payload
서버가 클라이언트에 보내는 것은 HTML이 아니라 직렬화된 React 트리:
1:D{"type":"article","props":{...},"children":[...]}
2:{"type":"$L3","props":{"postId":"abc"}}
3:I["./LikeButton.js","LikeButton"]
...
이것은 "Wire Format". 각 줄:
- 숫자: 참조 ID.
D: DOM element.I: Client component import (file + export 이름).$L: Reference to another line.
3.2 Streaming 구조
전체 트리를 한 번에 보내는 대신 점진적 스트리밍:
서버 시작
→ [첫 chunk 전송] Header 컴포넌트 렌더링 완료
→ [두 번째 chunk] 메인 콘텐츠 일부
→ [세 번째 chunk] 느린 데이터 (await)
→ [완료]
클라이언트가 첫 chunk를 받자마자 렌더 시작 가능. Time to First Byte (TTFB) 개선.
3.3 Client Component 처리
서버가 클라이언트 컴포넌트를 만나면:
<LikeButton postId="abc" />
- 실제 컴포넌트 코드 실행 안 함.
- Reference + props만 직렬화:
{type: ClientRef('LikeButton'), props: {postId: 'abc'}} - 클라이언트가 받아서:
- Reference에서 실제
LikeButton컴포넌트를 번들에서 찾음. - Props로 인스턴스화.
- 마운트.
- Reference에서 실제
결과: 컴포넌트 코드는 클라이언트 번들에 미리 있었고, 서버 트리는 "여기에 이 컴포넌트를 배치해라"는 청사진만 제공.
3.4 HTML과의 관계
사용자가 첫 방문 시:
- 서버가 RSC를 평가 → HTML 생성 (SSR).
- HTML과 함께 RSC Payload도 포함 (script tag).
- 클라이언트가 HTML 렌더 + JS 로드.
- JS가 RSC Payload를 읽고 hydration.
이후 네비게이션:
- 클라이언트가 RSC Payload만 요청 (HTML 아님).
- 새 RSC 트리 적용.
- React가 비교 후 DOM 업데이트.
"전통적 MPA 느낌 + SPA 속도"를 동시에.
4. Streaming & Suspense
4.1 Suspense와 RSC
Suspense는 "이 컴포넌트가 대기 중일 때 무엇을 표시할지".
import { Suspense } from 'react';
export default async function Page() {
return (
<div>
<Header />
<Suspense fallback={<Loading />}>
<SlowComponent />
</Suspense>
</div>
);
}
async function SlowComponent() {
await new Promise(r => setTimeout(r, 3000)); // 느린 데이터
return <div>Loaded!</div>;
}
서버가 한 번에 모두 렌더하는 대신:
- Header 바로 렌더 → 첫 chunk.
- Suspense boundary에서 fallback 렌더 → 보내기.
- SlowComponent 완료 시 → 업데이트 chunk.
- 클라이언트가 fallback을 실제 컴포넌트로 교체.
4.2 Multiple Suspense
여러 Suspense가 병렬:
<Suspense fallback={<PostsLoading />}>
<Posts /> {/* 2초 */}
</Suspense>
<Suspense fallback={<UsersLoading />}>
<Users /> {/* 1초 */}
</Suspense>
- Users가 먼저 도착 → UsersLoading이 실제 컴포넌트로 교체.
- Posts 도착 → PostsLoading 교체.
Progressive streaming. 사용자가 데이터를 기다리는 인상 ↓.
4.3 use() Hook
새로운 use hook이 Promise를 처리:
// Client component
"use client";
import { use } from 'react';
function PostContent({ postPromise }) {
const post = use(postPromise); // Suspense throw
return <h1>{post.title}</h1>;
}
서버 컴포넌트가 promise를 props로 넘기고, 클라이언트가 use()로 기다림.
4.4 loading.tsx
App Router의 특별 파일:
app/
├── layout.tsx
├── page.tsx
└── loading.tsx ← 자동 Suspense boundary
page.tsx가 로딩 중이면 loading.tsx가 자동 표시. 개발자가 <Suspense>를 수동으로 쓸 필요 없음.
5. Server Actions
5.1 동기 필요
전통 웹에선 form submit → 서버가 처리. React는 이것을 어렵게 만들었다 — API 엔드포인트 + fetch + loading 상태 + 에러 처리.
5.2 Server Actions 도입
// app/actions.ts
"use server";
import { revalidatePath } from 'next/cache';
export async function createPost(formData: FormData) {
const title = formData.get('title');
await db.posts.create({ data: { title } });
revalidatePath('/posts');
}
사용:
// app/posts/new/page.tsx
import { createPost } from '@/app/actions';
export default function NewPostPage() {
return (
<form action={createPost}>
<input name="title" />
<button type="submit">Create</button>
</form>
);
}
이것이 전부다. Next.js가 자동으로:
- Server action을 HTTP endpoint로 노출.
- Form submit을 그 endpoint에 POST.
- 결과를 클라이언트에 반영.
No fetch, no loading state, no API route. React 19의 killer 기능.
5.3 동작 원리
빌드 시:
"use server"표시된 함수를 감지.- 각 함수에 고유 ID 할당.
- 클라이언트 번들에 stub 생성:
const createPost = (formData) => { return fetch('/_action/createPost', { method: 'POST', body: encodeFormData(formData) }); }; - 서버에서 POST
/_action/createPost를 받아 실제 함수 실행.
클라이언트 개발자 관점: 함수를 직접 호출하는 것처럼 보임. 실제로는 RPC.
5.4 Progressive Enhancement
JavaScript 없어도 작동:
<form action={fn}>: 서버가 이 폼을 일반 form submission으로 처리.- Fallback이 자동.
JavaScript 있으면:
- Async, optimistic update, 부드러운 전환.
"JS-first" + "HTML-first"의 장점 결합.
5.5 Optimistic Update
"use client";
import { useOptimistic } from 'react';
function PostList({ posts }) {
const [optimisticPosts, addOptimisticPost] = useOptimistic(
posts,
(state, newPost) => [...state, newPost]
);
async function action(formData) {
addOptimisticPost({ title: formData.get('title'), pending: true });
await createPost(formData); // server action
}
return (
<>
<form action={action}>
<input name="title" />
<button>Submit</button>
</form>
{optimisticPosts.map(post => (
<div style={{ opacity: post.pending ? 0.5 : 1 }}>{post.title}</div>
))}
</>
);
}
서버 응답 기다리지 않고 즉시 UI 업데이트. 실패 시 자동 rollback.
5.6 Security
Server Actions은 publicly exposed POST endpoints다. 보안 고려:
- Authentication: action 내부에서 user 확인.
- Authorization: 해당 user가 이 action을 할 권한 있는지.
- Input validation: zod 같은 라이브러리.
- CSRF: Next.js가 일부 자동 처리, 하지만 중요 action은 추가 검증.
"Function이니까 안전"이 아님 — 일반 API처럼 다뤄야.
6. Next.js App Router — 파일 기반 라우팅
6.1 구조
app/
├── layout.tsx ← 루트 레이아웃
├── page.tsx ← "/" 페이지
├── globals.css
├── about/
│ └── page.tsx ← "/about"
├── blog/
│ ├── layout.tsx ← /blog 하위의 레이아웃
│ ├── page.tsx ← "/blog"
│ ├── loading.tsx ← /blog 로딩 상태
│ ├── error.tsx ← /blog 에러 boundary
│ └── [slug]/
│ └── page.tsx ← "/blog/my-post"
└── api/
└── posts/
└── route.ts ← "/api/posts" handler
컨벤션 파일:
page.tsx: 라우트의 컴포넌트.layout.tsx: 해당 경로와 하위의 레이아웃.loading.tsx: Suspense fallback.error.tsx: Error boundary.not-found.tsx: 404.route.ts: API handler.
6.2 Layout Nesting
Layout은 자동 중첩:
// app/layout.tsx
export default function RootLayout({ children }) {
return (
<html>
<body>
<Header />
{children}
<Footer />
</body>
</html>
);
}
// app/blog/layout.tsx
export default function BlogLayout({ children }) {
return (
<div className="blog">
<BlogSidebar />
{children}
</div>
);
}
// app/blog/[slug]/page.tsx
export default function BlogPost() {
return <article>...</article>;
}
렌더 결과:
RootLayout
Header
BlogLayout
BlogSidebar
BlogPost
Footer
레이아웃은 네비게이션 간 보존된다. /blog/a → /blog/b로 이동해도 BlogLayout은 재렌더 없음.
6.3 Dynamic Routes
[slug]: 단일 세그먼트 파라미터.
[...slug]: Catch-all.
[[...slug]]: Optional catch-all.
// app/blog/[slug]/page.tsx
export default function Page({ params }) {
return <div>Post: {params.slug}</div>;
}
6.4 Route Groups
괄호로 그룹화, URL에 영향 없음:
app/
├── (marketing)/
│ ├── layout.tsx
│ ├── page.tsx ← "/"
│ └── about/
│ └── page.tsx ← "/about"
├── (app)/
│ ├── layout.tsx
│ ├── dashboard/
│ │ └── page.tsx ← "/dashboard"
│ └── settings/
│ └── page.tsx ← "/settings"
두 그룹이 다른 layout 사용. URL에는 그룹 이름이 나타나지 않음.
6.5 Parallel Routes
한 layout에 여러 children을 동시에:
app/
├── @team/
│ └── page.tsx
├── @analytics/
│ └── page.tsx
└── layout.tsx
// app/layout.tsx
export default function Layout({ children, team, analytics }) {
return (
<div>
{children}
<aside>{team}</aside>
<section>{analytics}</section>
</div>
);
}
세 경로가 동시에 표시. 대시보드 같은 복잡한 UI에.
6.6 Intercepting Routes
/feed에서 사진을 클릭하면 모달로 열고, 직접 URL 접근 시엔 전체 페이지:
app/
├── feed/
│ ├── page.tsx
│ └── @modal/
│ └── (...)photo/
│ └── [id]/
│ └── page.tsx
├── photo/
│ └── [id]/
│ └── page.tsx
(...) 구문이 "같은 레벨의 다른 경로를 가로챔". Instagram, Twitter 같은 "modal on top" 패턴.
7. Next.js 캐시 시스템 — 4층 복잡
7.1 Request Memoization
같은 요청 안에서 중복 fetch 자동 제거.
// layout.tsx
const user = await fetch('/api/user');
// page.tsx (같은 요청)
const user = await fetch('/api/user'); // 캐시에서 반환
React의 cache 함수 기반. React 내장 기능.
7.2 Data Cache (Fetch-level)
fetch가 영구 캐시:
// 기본: 영구 캐시
const data = await fetch('https://api.example.com/data');
// 재검증 옵션
const data = await fetch(url, { next: { revalidate: 60 } }); // 60초 후 stale
// 캐시 안 함
const data = await fetch(url, { cache: 'no-store' });
// 태그
const data = await fetch(url, { next: { tags: ['posts'] } });
Next.js가 fetch를 패치해서 결과를 디스크에 저장. 같은 URL + 같은 option이면 네트워크 없이 반환.
7.3 Full Route Cache
렌더링된 HTML + RSC payload를 캐시:
Build time:
/blog/post-a → HTML + RSC payload 저장
/blog/post-b → HTML + RSC payload 저장
Runtime:
GET /blog/post-a → 캐시된 HTML 즉시 반환
Static route는 자동 Full Route Cache. Dynamic route (쿠키 사용, URL 파라미터 등)는 캐시 안 됨.
7.4 Router Cache (Client)
브라우저의 React Router 캐시. 네비게이션 시:
/a 방문 → /a의 RSC 저장
/b 방문 → /b의 RSC 저장
/a로 back → 캐시된 /a 사용 (network 안 함)
30초-5분 수명. Prefetch 때도 사용.
7.5 Revalidation
캐시를 무효화하는 방법:
1. Time-based:
// 10분마다
export const revalidate = 600;
2. Path-based:
import { revalidatePath } from 'next/cache';
export async function createPost() {
// ...
revalidatePath('/blog');
}
3. Tag-based:
fetch(url, { next: { tags: ['posts'] } });
import { revalidateTag } from 'next/cache';
revalidateTag('posts');
7.6 실무 혼란
"이 페이지가 왜 업데이트 안 돼?" → 어느 레이어가 캐시 중인지.
디버깅:
- Request memo: dev에서 리셋. 동일 요청만.
- Data cache:
revalidate,no-store, 또는 tag 사용. - Full route cache:
dynamic = 'force-dynamic'또는 사용자별 데이터. - Router cache:
router.refresh()또는 30초 대기.
이 복잡도가 개발자 불만의 원인. 2024년 Next.js 팀이 **"cache first"에서 "explicit cache"**로 기본값을 바꾸는 논의 진행.
7.7 Next.js 15의 변화
Next 15 (2024)에서 "cache less by default":
fetch기본 캐시 안 함.GETroute handler 기본 동적.- Client router cache 기본 캐시 안 함.
암묵적 캐시로 인한 버그 감소. 명시적 unstable_cache() 사용 권장.
8. Static vs Dynamic Rendering
8.1 Static (기본)
빌드 타임에 렌더. 가장 빠름.
export default async function Page() {
const data = await fetch('https://api.example.com/data');
return <div>{data.title}</div>;
}
기본적으로 static. data가 build time에 fetch되고 HTML 저장.
8.2 Dynamic Signals
어떤 API를 쓰면 자동으로 dynamic:
cookies(),headers()— 요청별 정보.searchParams— URL 쿼리.fetch(url, { cache: 'no-store' }).
import { cookies } from 'next/headers';
export default async function Page() {
const cookieStore = cookies();
const theme = cookieStore.get('theme');
// ...
}
이 페이지는 매 요청마다 렌더. Static 캐시 불가.
8.3 Dynamic 강제
export const dynamic = 'force-dynamic';
특정 페이지를 항상 dynamic. "절대 캐시하지 마".
8.4 Static 강제
export const dynamic = 'force-static';
Dynamic API 사용 시 빌드 에러.
8.5 Partial Pre-Rendering (PPR)
Next 14+ 실험. 한 페이지에서 일부는 static, 일부는 dynamic 스트리밍.
export default function Page() {
return (
<div>
<StaticHeader /> {/* static, 즉시 */}
<Suspense fallback={<Spinner />}>
<DynamicContent /> {/* dynamic, 스트리밍 */}
</Suspense>
</div>
);
}
SSG의 속도 + SSR의 유연성. 2024년 아직 실험, 2025년 안정화 예정.
9. Middleware
9.1 역할
요청이 페이지에 도달하기 전 실행:
// middleware.js
import { NextResponse } from 'next/server';
export function middleware(request) {
const auth = request.cookies.get('auth');
if (!auth && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ['/dashboard/:path*', '/api/:path*'],
};
용도:
- 인증 체크.
- A/B 테스트.
- 국제화 (언어 감지).
- Bot 차단.
- 헤더 수정.
9.2 Edge Runtime
Middleware는 Edge Runtime에서 실행 — Node.js가 아닌 Web API 호환 환경.
장점:
- 빠른 cold start.
- 전 세계 엣지에 배포 (Vercel, Cloudflare).
- 낮은 레이턴시.
단점:
- 제한된 API (Node 모듈 없음).
- 작은 번들 크기 제한.
- Stream 제한.
9.3 Matcher
export const config = {
matcher: [
'/((?!api|_next/static|_next/image|favicon.ico).*)',
],
};
정규식으로 어떤 경로에 적용할지 정의.
10. Route Handlers (API Routes)
10.1 구조
// app/api/posts/route.ts
import { NextResponse } from 'next/server';
export async function GET(request: Request) {
const posts = await db.posts.findMany();
return NextResponse.json(posts);
}
export async function POST(request: Request) {
const body = await request.json();
const post = await db.posts.create({ data: body });
return NextResponse.json(post, { status: 201 });
}
- 파일명:
route.ts(또는.js). - export된 함수 이름이 HTTP 메서드.
- Server Actions과 다른 점: 표준 HTTP API, 외부 client에서 사용 가능.
10.2 언제 Route Handler vs Server Action
- Server Action: 같은 Next 앱 내부의 서버 기능. Form submit, 버튼 클릭.
- Route Handler: 외부 API, webhook, OAuth callback, 파일 업로드, streaming 응답.
10.3 Streaming Response
export async function GET() {
const stream = new ReadableStream({
async start(controller) {
for (let i = 0; i < 10; i++) {
controller.enqueue(`Chunk ${i}\n`);
await new Promise(r => setTimeout(r, 500));
}
controller.close();
},
});
return new Response(stream);
}
SSE (Server-Sent Events), LLM streaming 응답에 유용.
11. Edge vs Node Runtime
11.1 두 가지 런타임
Next.js는 두 런타임을 지원:
Node.js Runtime (기본):
- 완전한 Node.js API.
- Prisma, 큰 라이브러리 OK.
- 일반 서버 또는 Vercel Functions.
Edge Runtime:
- Web API 서브셋 (fetch, streams, crypto).
- Cold start ~ms.
- 전 세계 분산 (Vercel Edge, Cloudflare Workers).
- 제한: 50MB 번들, 작은 메모리.
11.2 선택
// 페이지 레벨
export const runtime = 'edge'; // 또는 'nodejs'
Edge에 적합:
- 간단한 API.
- 지역 기반 로직.
- Middleware.
- 이미지 최적화.
Node에 적합:
- DB 직접 (일부 DB 드라이버는 Edge 미지원).
- 무거운 연산.
- 큰 의존성.
11.3 Edge의 제약
fs모듈 없음.Buffer제한적 (Uint8Array 권장).__dirname없음.- 긴 실행 불가 (timeout ~30s).
많은 DB 드라이버가 Edge 미지원 → Node 또는 Edge-compatible 대안(Neon, PlanetScale, Turso).
12. Turbopack
12.1 Webpack의 한계
Next.js는 오래 Webpack 사용. 대형 프로젝트에서:
- Cold start: 수십 초.
- HMR: 수백 ms ~ 수 초.
- Build: 분 단위.
Vercel이 직접 해결하기로.
12.2 Turbopack 등장
2022년 Vercel의 Tobias Koppers (Webpack 창시자)가 Turbopack 발표. Rust로 재작성.
특징:
- Incremental: 변경만 재빌드.
- Parallelism: Rust로 멀티 코어.
- Memoization: 의존성 그래프 캐시.
- Webpack 호환 목표: 거의 동일한 API.
12.3 성능
Webpack vs Turbopack:
- Cold start: 10-20배 빠름.
- HMR: 10-15배 빠름.
- Large app (Vercel dashboard): 실시간 수준.
12.4 상태
- Dev mode (turbopack in
next dev --turbo): 2024+ 안정. - Build (
next build --turbo): 2025년 안정화 중.
Next 14.1부터 dev 기본, Next 15.0부터 build 옵션.
13. 배포와 최적화
13.1 Vercel
Vercel (Next 팀 회사)이 "first-class" 배포:
- Git push → 자동 배포.
- Preview deployment per PR.
- Edge network.
- Analytics + Speed Insights.
13.2 Self-hosted
next build + next start로 어디서나:
- Docker.
- AWS (ECS, EC2).
- Kubernetes.
Next.js 공식 Docker 가이드 존재. 일부 기능 (이미지 최적화)에 Sharp 필요.
13.3 Static Export
// next.config.js
module.exports = {
output: 'export',
};
next build → 순수 정적 HTML. Server features 사용 불가 (API routes, ISR, Server actions).
Static hosting (Netlify, Cloudflare Pages, GitHub Pages)에 적합.
13.4 이미지 최적화
import Image from 'next/image';
<Image
src="/photo.jpg"
alt="Photo"
width={500}
height={300}
priority
/>
Next.js가:
- 자동 WebP/AVIF 변환.
- Responsive sizes.
- Lazy loading.
- Placeholder blur.
13.5 Font 최적화
import { Inter } from 'next/font/google';
const inter = Inter({ subsets: ['latin'] });
export default function Layout({ children }) {
return (
<html className={inter.className}>
<body>{children}</body>
</html>
);
}
빌드 타임에 폰트 다운로드 + self-host → CLS 방지, 빠른 로딩.
14. 마이그레이션 (Pages → App Router)
14.1 점진적
두 라우터 공존 가능:
pages/
├── index.tsx ← 기존 pages router
└── api/
└── hello.ts
app/
├── dashboard/
│ └── page.tsx ← 새 app router
점진적으로 pages/의 페이지를 app/로 옮김.
14.2 주요 차이
Pages Router:
getServerSideProps,getStaticProps.- API routes (pages/api/).
- 모든 컴포넌트가 client component.
_app.tsx,_document.tsx.
App Router:
- 서버 컴포넌트 기본.
- async in components.
- File-based layouts.
- Server Actions.
- Streaming + Suspense.
14.3 Data Fetching 변환
Before:
export async function getServerSideProps() {
const data = await fetch(...);
return { props: { data } };
}
export default function Page({ data }) {
return <div>{data}</div>;
}
After:
export default async function Page() {
const data = await fetch(...);
return <div>{data}</div>;
}
훨씬 간단.
15. 주의사항과 함정
15.1 "use client" 남용
전체 앱이 "use client"이면 RSC 이점 없음. 기본은 서버 컴포넌트로 유지.
힌트:
- 인터랙티브 최하위 컴포넌트만 client.
- 데이터 fetch는 서버 컴포넌트.
- 상태 없는 컴포넌트는 서버.
15.2 Context Over-Dependence
전통 React에서 Context를 많이 썼다. 서버 컴포넌트는 Context 제한적:
createContext서버 에서 만들 수 없음.- Provider는 클라이언트 컴포넌트.
- 서버 컴포넌트 상위에 Provider 둘 수 없음.
해결: props drilling 또는 server-side data passing (SSR 데이터를 props로 전달).
15.3 직렬화 불가 데이터
<ClientComponent
onClick={() => console.log('hi')} // 함수 직렬화 불가!
/>
해결: 함수 대신 Server Action 또는 href 사용.
15.4 Hydration 미스매치
서버와 클라이언트의 렌더 결과가 다르면 에러:
// BAD
<div>{new Date().toLocaleString()}</div>
서버 시각 != 클라이언트 시각 → mismatch. 해결: useEffect로 클라이언트에서만 렌더 또는 suppressHydrationWarning.
15.5 Environment Variables
- 서버에서만: 일반 env var.
- 클라이언트에서도:
NEXT_PUBLIC_prefix 필수.
DATABASE_URL=... # 서버 only
NEXT_PUBLIC_API_URL=... # 클라이언트도
Bundling 시점에 결정. dynamic env var 필요하면 별도 전략.
16. 대안
16.1 Remix / React Router
Ryan Florence와 Michael Jackson의 프레임워크. RSC 지원 추가 중 (React Router 7.0).
특징:
- Web standard API (Fetch, Request, Response).
- Nested routes + loaders.
- Progressive enhancement first.
16.2 Astro
"아일랜드 아키텍처". 기본은 static HTML, 인터랙티브 부분만 JS. React 외 Vue, Svelte 등도 지원.
Content-heavy 사이트(블로그, 문서)에 강점.
16.3 SvelteKit
Svelte의 meta framework. RSC 없지만 유사한 개념. 컴파일러 기반, 매우 작은 번들.
16.4 SolidStart
Solid.js의 meta framework. "서버 함수" 개념이 Server Actions과 유사.
16.5 TanStack Start
TanStack (React Query 저자)의 신작. 2024년 발표. Server functions + streaming.
17. 학습 로드맵
1단계: 기본
- Next.js 공식 튜토리얼 (https://nextjs.org/learn).
- 간단한 앱 만들기.
- Server vs Client component 구분 익히기.
2단계: 패턴
- Server Actions 활용.
- Suspense + loading.tsx 패턴.
- Parallel/Intercepting routes.
3단계: 캐시 이해
- 4층 캐시 실험.
revalidatePath,revalidateTag.- Next 15의 새 기본값.
4단계: 최적화
- Bundle analyzer.
- Performance profiling.
- Edge vs Node 결정.
자료:
- https://nextjs.org/docs.
- Lee Robinson (Vercel) 블로그/영상.
- Theo Browne YouTube.
- Dan Abramov의 RSC 관련 글들.
책:
- "Fluent React" — Tejas Kumar.
- "Server-Side Rendering in React" (예정).
18. 요약 — 한 장 정리
┌─────────────────────────────────────────────────────┐
│ RSC & Next.js App Router Cheat Sheet │
├─────────────────────────────────────────────────────┤
│ RSC 기본: │
│ Server Components: 서버에서만, async 가능 │
│ Client Components: "use client", 기존 React │
│ Props는 서버 → 클라이언트 직렬화 │
│ │
│ "use client": │
│ 파일 최상단 지시어 │
│ 네트워크 경계 │
│ 이 아래 모듈은 클라이언트 번들 │
│ │
│ Streaming: │
│ Suspense boundary │
│ RSC payload 점진적 전송 │
│ loading.tsx 자동 Suspense │
│ │
│ Server Actions: │
│ "use server" 함수 │
│ <form action={fn}> │
│ 자동 RPC │
│ revalidatePath/revalidateTag │
│ useOptimistic │
│ │
│ App Router 파일: │
│ page.tsx 라우트 컴포넌트 │
│ layout.tsx 중첩 레이아웃 │
│ loading.tsx Suspense fallback │
│ error.tsx Error boundary │
│ not-found.tsx 404 │
│ route.ts API handler │
│ middleware.ts 요청 전 처리 │
│ │
│ Advanced Routes: │
│ [slug] dynamic │
│ [...slug] catch-all │
│ (group) route group │
│ @modal parallel route │
│ (...)photo intercepting route │
│ │
│ 캐시 4층: │
│ 1. Request Memoization (per request) │
│ 2. Data Cache (fetch level) │
│ 3. Full Route Cache (build) │
│ 4. Router Cache (client nav) │
│ │
│ Rendering: │
│ Static (기본) │
│ Dynamic (cookies/headers/searchParams) │
│ Streaming (Suspense) │
│ PPR (experimental) │
│ │
│ Runtime: │
│ Node.js (기본, 완전 API) │
│ Edge (빠른 cold start, 제한) │
│ │
│ Turbopack: │
│ Rust 기반 번들러 │
│ 10-20x Webpack │
│ Dev 안정, Build 안정화 중 │
│ │
│ 함정: │
│ "use client" 남용 │
│ Context 제한 │
│ 직렬화 불가 props │
│ Hydration mismatch │
│ Env var 경계 (NEXT_PUBLIC_) │
└─────────────────────────────────────────────────────┘
19. 퀴즈
Q1. RSC와 전통 SSR의 근본적 차이는?
A. SSR은 렌더링 전략, RSC는 컴퓨팅 모델. SSR은 "전체 React 앱을 서버에서 실행 → HTML 반환 → 클라이언트에서 hydration"으로 모든 컴포넌트가 서버와 클라이언트에서 모두 돈다. 모든 컴포넌트 코드가 JS 번들에 포함. RSC는 컴포넌트를 두 타입으로 나눔: Server Components는 서버에서만 실행되고 JS 번들에 전혀 포함되지 않음 (DB 호출, 큰 라이브러리 자유롭게). Client Components는 기존대로 양쪽. 결과: 대부분 컴포넌트가 서버로 이동 → 번들 크기 극적 감소 + 데이터 fetch가 컴포넌트에 자연스럽게. "SSR + 추가"가 아니라 근본적으로 다른 모델. 같은 페이지 안에서 서버 전용 코드와 클라이언트 코드가 컴포넌트 단위로 공존.
Q2. "use client" 지시어의 진짜 의미는?
A. **"이 파일은 클라이언트"가 아니라 "여기부터의 import 트리가 클라이언트 번들에 포함된다"**는 네트워크 경계 선언. 파일 최상단에 "use client"를 두면 그 파일과 그 파일이 import하는 모든 모듈이 클라이언트 번들에 들어간다. 이것이 왜 한 파일 안에 "use client"가 여러 번 나올 수 없는지의 이유 — 파일 전체가 경계. 서버 컴포넌트가 클라이언트 컴포넌트를 import하면 bundler가 경계를 감지하고 import를 reference로 교체한다. 서버는 "이 위치에 이 클라이언트 컴포넌트를 렌더링해라"는 참조만 보내고, 실제 코드는 이미 클라이언트 번들에 있다. 이 메커니즘이 RSC의 핵심 엔지니어링.
Q3. Server Actions가 기존 API route보다 나은 이유는?
A. 보일러플레이트 제거와 Progressive Enhancement. 전통 패턴: (1) API route 작성, (2) 클라이언트에서 fetch 호출, (3) loading state 관리, (4) 에러 처리, (5) 성공 시 UI 업데이트 — 각각 분리된 코드. Server Action은 함수 하나로 끝: "use server" 선언 + <form action={myFunc}>. Next.js가 자동으로 RPC endpoint 생성, 폼 데이터 직렬화, 호출, 응답 처리. Progressive Enhancement 보너스: JS가 없어도 폼은 네이티브 submission으로 작동 — JS 있으면 optimistic update + 부드러운 전환이 추가됨. useOptimistic과 결합하면 "즉시 UI 업데이트 + 서버 확인 + 실패 시 rollback"을 단 몇 줄로 구현. "복잡도를 프레임워크에 밀어넣고 개발자가 비즈니스 로직에 집중"의 완벽한 예.
Q4. Next.js의 "4층 캐시"가 왜 혼란스러운가?
A. 각 층이 다른 시점과 다른 범위에서 작동하고, 기본값이 암묵적이어서. (1) Request Memoization — 한 요청 안에서 같은 fetch를 중복 제거 (React cache). (2) Data Cache — fetch 결과를 영구 저장 (기본), revalidate 또는 cache: 'no-store'로 제어. (3) Full Route Cache — 렌더링된 HTML+RSC payload를 build time에 저장. (4) Router Cache — 클라이언트 네비게이션 결과 30초-5분 저장. 문제: "이 페이지가 왜 업데이트 안 돼?"에 답하려면 어느 층이 캐시하는지 알아야 하는데, 각 층의 트리거가 명시적이지 않다. revalidatePath vs revalidateTag vs router.refresh() 언제 어떤 걸? Next 15에서 "cache less by default" 기본값 변경 — fetch가 기본 캐시 안 함, 명시적 unstable_cache() 사용 권장. 암묵적 → 명시적으로의 이동.
Q5. RSC payload가 HTML이 아닌 이유는?
A. 컴포넌트 트리 정보 + 스트리밍 + 부분 업데이트 지원. HTML만 보내면 "이미 렌더링된 결과"만 전달 — 클라이언트가 이를 React 트리로 역변환하려면 어려움. RSC payload는 구조화된 wire format: 각 노드가 React element의 직렬화(type, props, children), 클라이언트 컴포넌트는 reference로. 장점: (1) Streaming — 청크 단위 전송, Suspense boundary마다 점진적 업데이트, (2) 클라이언트 컴포넌트 통합 — 참조를 번들에서 찾아 인스턴스화, (3) 재수화 없음 — React 트리가 이미 있으므로 client navigation 시 HTML 재파싱 불필요, (4) 부분 업데이트 — 새 페이지 방문 시 HTML 교체 대신 트리의 변경된 부분만 적용. 이것이 "전통 MPA처럼 느껴지지만 SPA처럼 빠른" 경험의 기술적 기반. HTML은 첫 방문 시 SEO용으로 같이 전송되지만 이후 네비게이션은 RSC payload만.
Q6. Parallel Routes가 해결하는 UI 패턴은?
A. 한 레이아웃에 여러 독립적 섹션이 동시 표시되는 복잡 UI. 대시보드가 대표적: 중앙 메인 + 사이드바 팀 정보 + 하단 분석 차트 — 셋이 다른 데이터, 다른 로딩 상태, 독립적 에러. 전통 방식: 한 page.tsx에 세 섹션을 직접 포함 → 각 섹션의 Suspense/error를 수동 관리, URL이 상태를 반영 안 함 (예: 메인만 다른 뷰로 바꾸고 싶어도). Parallel Routes: @team/page.tsx, @analytics/page.tsx 같은 named slot들을 같은 layout이 동시에 받음. 각 slot이 독립적 파일 트리를 가지고 loading.tsx, error.tsx가 각자 작동. URL도 독립 — 메인만 /dashboard/overview로 바꿔도 team/analytics는 유지. Instagram의 "모달 on 피드" 같은 intercepting routes와 결합하면 매우 복잡한 UI도 파일 구조로 표현 가능.
Q7. Edge Runtime과 Node Runtime 중 언제 어느 것을 써야 하는가?
A. Cold start 민감도 vs API 요구사항의 트레이드오프. Edge Runtime 적합: (1) Middleware — 모든 요청 앞에서 실행되니 cold start 최소화 필수, (2) 간단 API — Weather, geo 기반 리다이렉트, A/B test routing, (3) Streaming proxy — LLM 응답 스트리밍 같은 I/O 중심, (4) 전 세계 저지연 필요 — Vercel Edge/Cloudflare Workers로 수백 PoP 분산. Node Runtime 적합: (1) DB 직접 접근 — 많은 ORM/driver가 Edge 미지원 (Prisma 일부, Mongoose 등), (2) 무거운 라이브러리 — Edge 번들 제한(50MB) 초과, (3) 파일시스템 — fs 필요, (4) 긴 실행 — Edge는 보통 30s 제한. 현대 추세: DB는 Edge 호환 서버리스 DB (Neon, PlanetScale, Turso, Supabase) 사용해 Edge에서도 DB 접근 가능. "모든 것을 Edge로"는 이상적이지만 현실은 mixed — 보통 middleware + 간단 API는 Edge, 복잡한 페이지/API는 Node.
이 글이 도움이 됐다면 다음 포스트도 확인해 보세요:
- "React Fiber Internals Deep Dive" — 클라이언트 React의 내부.
- "Rust Tokio Async Runtime" — Turbopack이 Rust로 쓰인 이유.
- "CDN & Edge Caching Strategies" — Edge Runtime의 인프라.
- "OAuth 2.0 & OIDC Deep Dive" — Next.js 앱의 인증 패턴.
현재 단락 (1/806)
- **React Server Components (RSC)**: React 18 (2022)에 도입된 새 컴퓨팅 모델. 일부 컴포넌트가 **서버에서만 실행**되고 클라이언트에 J...