필사 모드: React Server Components와 Next.js App Router 완전 정복 — RSC 프로토콜, Server Actions, PPR, Streaming (2025)
한국어> "The future of React is not about rendering faster. It's about rendering less." — Dan Abramov (RSC introduction, 2020 Dec)
2020년 12월 21일, Dan Abramov와 Lauren Tan이 "React Server Components" 발표 영상을 올렸다. 당시 대부분의 개발자는 "또 새로운 걸 배워야 하나?"라고 반응했다. 2025년이 된 지금, **RSC는 Next.js 14/15의 기본값**이고, Remix, TanStack Start, RedwoodJS가 모두 RSC를 품으려 한다. 이것은 React 역사상 가장 큰 패러다임 변화다.
그러나 RSC는 복잡하다. "왜 'use server'랑 'use client'가 필요한가?" "이게 SSR과 뭐가 다른가?" "언제 어디서 뭐가 렌더링되는가?" 이 글은 그 모든 질문에 답하는 지도다.
1. React 렌더링의 역사 — 4단계
1단계 — CSR (2013-2015)
`create-react-app` 시절의 모델:
1. 서버가 빈 HTML + JS 번들 전송
2. 브라우저가 JS 실행 → 컴포넌트 렌더링 → DOM 생성
단점:
- **First Paint 느림** — JS 로드 + 실행
- **SEO 부족** — 초기 HTML에 콘텐츠 없음
- **네트워크 워터폴** — 데이터 페치 → 렌더 → 자식 페치
2단계 — SSR (2017+)
Next.js 등이 `ReactDOMServer.renderToString`으로 서버에서 HTML 생성:
1. 서버가 **렌더링된 HTML** 전송 (FCP 빠름)
2. 브라우저가 **hydrate** — 이벤트 리스너 붙이기
단점:
- **서버 렌더는 번들 크기에 영향 없음** (클라이언트 JS 여전)
- **Hydration TTI가 오히려 더 느림** (HTML + JS 둘 다)
- **getServerSideProps 워터폴**
3단계 — SSG / ISR (2019+)
빌드 시 또는 주기적으로 HTML 생성:
- 정적 사이트의 속도 + React의 상호작용
- Vercel, Netlify의 전성기
그러나 동적 콘텐츠에는 부족.
4단계 — RSC (2020 발표, 2022 상용화)
**서버에서만 렌더링되는 컴포넌트**. 클라이언트에 JS가 없다.
// 완전히 서버에서만 실행됨, 번들에 안 들어감
async function ProductList() {
const products = await db.query('...') // DB 직접 접근!
return (
{products.map(p => <ProductCard key={p.id} product={p} />)}
)
}
**혁명의 본질**: 서버 로직(DB 질의, 파일 IO)이 **React 컴포넌트 안에** 자연스럽게 들어간다. API 레이어 없이.
2. Server Component vs Client Component
세 종류의 컴포넌트
| 타입 | 실행 위치 | 번들 포함 | 사용법 |
|---|---|---|---|
| **Server Component** | 서버에서만 | X | `'use server'` 불필요, 기본값 (App Router) |
| **Client Component** | 서버+클라이언트 | O | `'use client'` 명시 |
| **Shared Component** | 양쪽 | 조건부 | 지시어 없음 |
"use client" 지시어
'use client'
export default function Counter() {
const [n, setN] = useState(0)
return <button onClick={() => setN(n+1)}>{n}</button>
}
이 파일과 **여기서 import하는 모든 것**이 클라이언트 번들에 들어간다. "Client Boundary"를 만드는 마커.
"use server" 지시어
// actions.ts
'use server'
export async function createPost(formData: FormData) {
await db.posts.insert({ ... })
}
// Component.tsx
**Server Action** — 클라이언트에서 호출하지만 서버에서 실행되는 함수. 내부적으로는 RPC 엔드포인트로 변환.
경계(boundary)의 규칙
1. Server → Server: 자유 (그냥 함수 호출)
2. Server → Client: `children` prop 또는 serializable props로 전달
3. Client → Client: 일반 React
4. Client → Server: **Server Action**으로만
흔한 오해 — "서버 컴포넌트는 SSR이다"
**아니다.** SSR은 "서버에서도 한 번 렌더링"하는 것. RSC는 "**서버에서만** 렌더링"하는 것. 클라이언트 번들에 아예 없다.
- SSR Client Component: 서버에서 HTML 생성 + 클라이언트에서 hydrate
- Server Component: 서버에서만 실행, JS는 클라이언트에 보내지지 않음
3. RSC Flight Protocol — 직렬화의 기술
왜 새 프로토콜이 필요한가
RSC가 생성하는 건 HTML이 아니다. **컴포넌트 트리의 직렬화된 표현**이다.
이유:
- 클라이언트가 이후 **네비게이션** 시 새 서버 컴포넌트를 받아 기존 트리와 병합해야
- "이 위치에 이 Client Component + props를 붙여라"를 표현해야
Flight Format — 스트리밍 JSON
1:"$Sreact.suspense"
2:{"children":["$","h1",null,{"children":"Hello"}]}
3:I[{"id":"Counter","chunks":["..."]}]
0:["$","$1",null,{"children":["$","div",null,{"children":["$","$L3",null,{"initial":0}]}]}]
- `$` 접두어로 특수 값(컴포넌트, Suspense, 참조) 표현
- `$L3`는 "Client Component 3번을 lazy load" 지시
- 스트리밍으로 전송 — 나중에 resolve되는 부분은 늦게
장점
- **증분 렌더링** — 각 청크가 준비되는 대로 전송
- **번들 분할** — Client Component 참조만 있으므로 필요한 청크만 로드
- **hydration 최적화** — 이미 렌더된 부분은 re-render 안 함
벤치마크
같은 페이지 SSR vs RSC:
- TTFB: 비슷 (둘 다 서버 렌더)
- **JS 번들**: RSC가 30-70% 작음 (서버 코드 제외)
- **TTI**: RSC가 빠름 (hydrate할 컴포넌트 적음)
4. Next.js App Router — 컨벤션의 언어
파일 시스템 기반 라우팅
app/
layout.tsx # 루트 레이아웃
page.tsx # 홈
blog/
layout.tsx # 블로그 레이아웃
page.tsx # 블로그 목록
[slug]/
page.tsx # 개별 글
loading.tsx # 로딩 UI
error.tsx # 에러 UI
@sidebar/ # 병렬 라우팅
default.tsx
page.tsx
특수 파일 규약
| 파일 | 역할 |
|---|---|
| `page.tsx` | URL에 매칭되는 컴포넌트 |
| `layout.tsx` | 공유 레이아웃 (리렌더 안됨) |
| `template.tsx` | layout과 유사하지만 매번 리렌더 |
| `loading.tsx` | Suspense fallback 자동 래핑 |
| `error.tsx` | Error Boundary 자동 래핑 |
| `not-found.tsx` | 404 페이지 |
| `default.tsx` | 병렬 라우팅의 기본값 |
| `route.ts` | API 엔드포인트 |
Layout Nesting — 진짜 장점
app/layout.tsx (웹사이트 프레임)
└─ app/blog/layout.tsx (블로그 프레임)
└─ app/blog/[slug]/page.tsx (글 본문)
페이지 간 이동 시 **layout은 리렌더 안됨**. 사이드바 state가 유지됨. Pages Router에서는 불가능했던 일.
병렬 라우팅 — `@slot`
app/
layout.tsx
@team/
page.tsx
@analytics/
page.tsx
export default function Layout({ children, team, analytics }) {
return (
<>
{children}
{team}
{analytics}
</>
)
}
한 화면에 **여러 독립 라우트**. 대시보드에 이상적.
Intercepting Routes — `(..)`
사진 클릭 시 모달로 열되, URL은 `/photo/123`으로. 새로고침하면 전체 페이지로. Instagram 같은 UX.
5. 데이터 페칭의 혁명
전통 방식 (Pages Router)
// 페이지별로만 데이터 페칭 가능
export async function getServerSideProps() {
const data = await fetch('...')
return { props: { data } }
}
컴포넌트 깊은 곳에서 데이터 필요하면? Prop drilling 또는 전역 상태.
App Router — 어디서든 async 가능
// 깊이 중첩된 서버 컴포넌트
async function UserProfile({ userId }) {
const user = await db.users.find(userId)
return <div>{user.name}</div>
}
컴포넌트 트리 어디서든 `await`. React가 병렬로 실행.
자동 요청 중복 제거 (Dedup)
같은 렌더에서 같은 `fetch` 여러 번 → 실제로는 **한 번만**.
// 두 컴포넌트가 같은 URL fetch
async function A() {
const user = await fetch('/api/user/1').then(r => r.json())
return ...
}
async function B() {
const user = await fetch('/api/user/1').then(r => r.json()) // 캐시됨
return ...
}
React 18의 `cache()` API로도 가능.
네트워크 워터폴 방지
// 병렬 페치
async function Page() {
const userPromise = getUser()
const postsPromise = getPosts()
const [user, posts] = await Promise.all([userPromise, postsPromise])
return <UI user={user} posts={posts} />
}
순차는 금물. 독립 데이터는 항상 병렬.
6. Server Actions — fetch의 종말?
기본 사용
// actions.ts
'use server'
export async function createPost(formData: FormData) {
const title = formData.get('title') as string
await db.posts.insert({ title })
revalidatePath('/blog')
}
// page.tsx
export default function NewPost() {
return (
)
}
JavaScript가 비활성화되어도 동작 (form이 서버로 POST). Progressive Enhancement!
useActionState — 에러와 pending 처리
'use client'
const [state, action, isPending] = useActionState(createPost, null)
{state?.error && <p>{state.error}</p>}
useOptimistic — 즉각 피드백
'use client'
const [optimisticMsgs, addOptimistic] = useOptimistic(messages,
(state, newMsg) => [...state, { ...newMsg, sending: true }]
)
async function send(formData) {
addOptimistic({ text: formData.get('text') })
await sendMessage(formData)
}
사용자 관점에서는 **즉시** 반영, 실제 저장은 뒤에서. Twitter/WhatsApp UX.
Server Actions vs API Routes
- API Route: REST 엔드포인트, 외부에서도 접근
- Server Action: 내부 컴포넌트에서만, 자동 보안 토큰, CSRF 방어
**내부 뮤테이션**은 Server Action, **외부 API**는 Route Handler.
7. 캐싱의 4계층 — 가장 헷갈리는 부분
Next.js의 캐시 모델은 유명할 정도로 복잡하다.
1. Request Memoization (요청 중복 제거)
- 같은 렌더 내에서 같은 `fetch` = 1번
- React 제공, Next.js 확장
2. Data Cache
- `fetch()`가 기본으로 영속 캐시 (Vercel 환경)
- `{ cache: 'no-store' }` 또는 `{ next: { revalidate: 60 } }`로 제어
- **2024년 Next.js 15부터 기본이 `no-store`로 변경** — 혼란 회피
3. Full Route Cache
- 정적 라우트의 HTML + RSC payload 전체 캐시
- 빌드 시 또는 ISR로 생성
4. Router Cache (클라이언트)
- 브라우저에서 방문한 라우트의 RSC payload 메모리 캐시
- 뒤로가기/페이지 전환 즉시
- 수동 무효화: `router.refresh()`
무효화
- `revalidatePath('/blog')` — 특정 경로
- `revalidateTag('posts')` — 태그 단위
- 재방문해도 stale-while-revalidate 동작
2024-2025 간소화 시도
Next.js 15의 "stable cache semantics":
- `fetch` 기본 캐시 → **opt-in으로 변경** (`export const fetchCache = 'default-cache'`)
- `dynamicIO` flag — 데이터 페치를 명시적으로 정적/동적 표시
- `'use cache'` 지시어 (실험적) — 함수 단위 캐시 선언
8. Streaming과 Suspense
스트리밍의 개념
서버가 HTML/RSC를 **완성된 후** 한 번에 보내는 게 아니라, **준비되는 대로 청크로** 보냄.
자동 Suspense
// page.tsx
export default function Page() {
return (
<>
</>
)
}
`loading.tsx`가 있으면 자동으로:
→ Header는 즉시 렌더, SlowComponent 기다리는 동안 fallback 표시, 준비되면 교체.
수동 Suspense 경계
export default function Page() {
return (
<>
</>
)
}
두 개가 **독립적으로** 로드. 빨리 끝나는 게 먼저 나옴.
스트리밍 HTML의 세부
- `<html><head>...` 먼저 전송
- 각 Suspense boundary가 resolved되면 `<script>`로 해당 HTML 주입
- hydration도 순차적(Selective Hydration)
TTFB vs FMP 트레이드오프
- 전통 SSR: 모두 기다린 후 전송 (TTFB 느림, FMP도 늦음)
- 스트리밍: 즉시 시작 (TTFB 빠름, FMP 빠름, 일부는 나중)
9. PPR — Partial Prerendering (Next.js 15)
문제
- **전부 정적**: 빠름, but 동적 데이터 못 씀
- **전부 동적**: 느림, 캐시 못 씀
- 현실: **일부는 정적, 일부는 동적**
해결 — 하나의 페이지에 혼합
export const experimental_ppr = true
export default function Page() {
return (
<>
</>
)
}
작동 원리
1. 빌드 시 정적 부분을 미리 렌더 → shell
2. 요청 시 동적 부분만 렌더 → streaming으로 결합
3. 결과: 정적 속도 + 동적 유연성
현재 (2025)
- Next.js 15에서 experimental
- Vercel에서 production 사용 중
- 2026년 안정화 예상
10. React Server Components vs SolidStart vs Qwik
SolidStart
- Solid.js 기반 (React보다 빠름, reactive 원시 타입)
- "islands" 모델 + RSC-like 서버 함수
- Vite 기반, 덜 복잡
Qwik City
- **Resumability** — hydration 없음
- HTML에 상태를 직렬화, 인터랙션 시 해당 코드만 다운로드
- 초기 JS: 1KB 미만 가능
- "instant app" 철학
TanStack Start
- Tanner Linsley(React Query) 주도
- Vite 기반, TypeScript-first
- 2025년 beta, RSC 지원 계획
Remix (이제 React Router v7)
- "Use the Platform" — 웹 표준 사랑
- 2024년 말 React Router v7로 통합
- RSC 지원 2025년 도입
선택 기준
| 조건 | 추천 |
|---|---|
| 팀이 React 익숙, 대규모 | **Next.js (RSC)** |
| 성능 극한, 작은 앱 | **Qwik** |
| Solid.js 선호 | **SolidStart** |
| 타입 안전성 최우선 | **TanStack Start** |
| Use the Platform 철학 | **React Router v7** |
11. 실제 마이그레이션 — Pages → App Router
점진적 이행
같은 Next.js 프로젝트에 **`pages/`와 `app/`을 공존** 가능.
app/ # 새 라우트 App Router
about/page.tsx
pages/ # 기존 라우트 유지
index.tsx
api/...
이행 순서
1. **새 기능부터** `app/`에
2. 기존 **API routes**는 그대로 (`route.ts`로 옮기든 말든)
3. 페이지별로 점진 이행 (큰 페이지부터 or 간단한 것부터)
4. `_app.tsx` → `app/layout.tsx`로 통합
함정
- **CSS 충돌** — Pages Router의 `styles/globals.css` 이중 로드
- **middleware** — 공유되지만 일부 API 차이
- **Image 컴포넌트** — `next/image`는 동일
- **useRouter** — `next/navigation`으로 변경 필수
12. 흔한 실수 TOP 10
1. **모든 곳에 `'use client'`** — RSC 이점 사라짐
2. **Server Component에 useState** — 안 됨, TypeScript가 막아줌
3. **Client Component에서 DB 직접 접근** — 보안 사고, 자동 차단
4. **`window`를 Server Component에서** — 존재 안 함
5. **Large fetch를 매 요청마다** — 캐시 전략 필수
6. **중첩 Suspense 없음** — 전체가 같이 로딩
7. **네트워크 워터폴** — `await` 순차
8. **Server Action에서 큰 객체 전달** — 직렬화 비용
9. **revalidatePath 누락** — mutation 후 UI 안 바뀜
10. **클라이언트 상태를 Server Component에 의존** — 불가능
13. App Router 체크리스트
- [ ] **기본은 Server Component** — `'use client'`는 필요할 때만
- [ ] **layout의 무리한 재사용** — 리렌더 안 됨 확인
- [ ] **Suspense boundary** — 느린 부분 독립 격리
- [ ] **loading.tsx** — 페이지별 로딩 UI
- [ ] **error.tsx** — 페이지별 에러 경계
- [ ] **병렬 데이터 페칭** — `Promise.all` 활용
- [ ] **Server Action 검증** — zod로 입력 검증
- [ ] **revalidate 전략** — path/tag 기반
- [ ] **Dynamic/Static 명시** — `export const dynamic = 'force-static'`
- [ ] **TypeScript strict** — 타입 유지
- [ ] **Turbopack dev** — `next dev --turbo`
- [ ] **bundle analysis** — `@next/bundle-analyzer`
마치며 — "서버는 다시 중요해졌다"
RSC는 단순한 최적화가 아니다. **"프론트엔드와 백엔드의 경계를 컴포넌트 레벨까지 끌어내린" 패러다임 변화다.** 10년 전 "SPA가 미래"라고 외쳤던 업계가, 이제는 "서버가 다시 중요하다"고 말한다. 그러나 이것은 과거로의 회귀가 아니라 **새로운 합성**이다 — 서버의 DB 접근 편의성 + 클라이언트의 상호작용성.
React 팀이 말한 "Use the Platform" 철학은 웹 표준(form, HTML 스트리밍, progressive enhancement)을 React에 녹여넣은 것이다. 2030년의 React는 지금과 많이 다를 것이다. 하지만 그 방향은 이미 보인다: **작은 클라이언트 번들, 빠른 첫 화면, 서버에서 해결할 수 있는 것은 서버에서, 복잡성은 프레임워크로.**
다음 글 예고 — TypeScript 타입 시스템의 깊이 — 2026년의 표준
RSC가 런타임의 혁명이었다면, **TypeScript**는 지난 10년간의 언어 혁명이다. 다음 글에서는:
- **TypeScript의 철학** — Gradual typing의 성공 공식
- **구조적 타이핑** — nominal과의 대비
- **Generic의 깊이** — Conditional, Mapped, Template Literal Types
- **`satisfies` vs `as`** — 2022년의 작은 혁명
- **타입 수준 프로그래밍** — Type Challenges의 세계
- **모듈 해석의 복잡성** — NodeNext, ESM/CJS interop
- **tsc의 한계와 대안** — SWC, esbuild, Bun
- **TypeScript Go 포팅 (2025)** — Anders Hejlsberg의 10배 빠른 컴파일러
- **Zod, ArkType, Valibot** — 런타임 검증의 진화
- **Effect-TS** — 함수형 타입 시스템의 프런티어
- **tRPC와 end-to-end 타입 안전성**
개발자 도구의 가장 중요한 혁명을 정리하는 여정.
> "With Server Components, you don't have to choose between 'it's a rich app' and 'it's fast'. You get both." — Sebastian Markbåge (React core, RSC architect)
현재 단락 (1/302)
2020년 12월 21일, Dan Abramov와 Lauren Tan이 "React Server Components" 발표 영상을 올렸다. 당시 대부분의 개발자는 "또 새로운 걸 ...