- Published on
React Server Components와 Next.js App Router 완전 정복 — RSC 프로토콜, Server Actions, PPR, Streaming (2025)
- Authors

- Name
- Youngju Kim
- @fjvbn20031
"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 시절의 모델:
- 서버가 빈 HTML + JS 번들 전송
- 브라우저가 JS 실행 → 컴포넌트 렌더링 → DOM 생성
단점:
- First Paint 느림 — JS 로드 + 실행
- SEO 부족 — 초기 HTML에 콘텐츠 없음
- 네트워크 워터폴 — 데이터 페치 → 렌더 → 자식 페치
2단계 — SSR (2017+)
Next.js 등이 ReactDOMServer.renderToString으로 서버에서 HTML 생성:
- 서버가 렌더링된 HTML 전송 (FCP 빠름)
- 브라우저가 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 (
<ul>
{products.map(p => <ProductCard key={p.id} product={p} />)}
</ul>
)
}
혁명의 본질: 서버 로직(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'
import { useState } from 'react'
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
<form action={createPost}>...</form>
Server Action — 클라이언트에서 호출하지만 서버에서 실행되는 함수. 내부적으로는 RPC 엔드포인트로 변환.
경계(boundary)의 규칙
- Server → Server: 자유 (그냥 함수 호출)
- Server → Client:
childrenprop 또는 serializable props로 전달 - Client → Client: 일반 React
- 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'
import { revalidatePath } from 'next/cache'
export async function createPost(formData: FormData) {
const title = formData.get('title') as string
await db.posts.insert({ title })
revalidatePath('/blog')
}
// page.tsx
import { createPost } from './actions'
export default function NewPost() {
return (
<form action={createPost}>
<input name="title" />
<button type="submit">Create</button>
</form>
)
}
JavaScript가 비활성화되어도 동작 (form이 서버로 POST). Progressive Enhancement!
useActionState — 에러와 pending 처리
'use client'
const [state, action, isPending] = useActionState(createPost, null)
<form action={action}>
{state?.error && <p>{state.error}</p>}
<button disabled={isPending}>Submit</button>
</form>
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')dynamicIOflag — 데이터 페치를 명시적으로 정적/동적 표시'use cache'지시어 (실험적) — 함수 단위 캐시 선언
8. Streaming과 Suspense
스트리밍의 개념
서버가 HTML/RSC를 완성된 후 한 번에 보내는 게 아니라, 준비되는 대로 청크로 보냄.
자동 Suspense
// page.tsx
export default function Page() {
return (
<>
<Header />
<SlowComponent /> // DB 느림
<Footer />
</>
)
}
loading.tsx가 있으면 자동으로:
<Suspense fallback={<Loading />}>
<Page />
</Suspense>
→ Header는 즉시 렌더, SlowComponent 기다리는 동안 fallback 표시, 준비되면 교체.
수동 Suspense 경계
export default function Page() {
return (
<>
<Header />
<Suspense fallback={<ProductSkeleton />}>
<SlowProducts />
</Suspense>
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews />
</Suspense>
<Footer />
</>
)
}
두 개가 독립적으로 로드. 빨리 끝나는 게 먼저 나옴.
스트리밍 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 (
<>
<Header /> {/* 정적 */}
<Hero /> {/* 정적 */}
<Suspense fallback={<Skeleton />}>
<DynamicCart /> {/* 동적, 매 요청마다 */}
</Suspense>
</>
)
}
작동 원리
- 빌드 시 정적 부분을 미리 렌더 → shell
- 요청 시 동적 부분만 렌더 → streaming으로 결합
- 결과: 정적 속도 + 동적 유연성
현재 (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/...
이행 순서
- 새 기능부터
app/에 - 기존 API routes는 그대로 (
route.ts로 옮기든 말든) - 페이지별로 점진 이행 (큰 페이지부터 or 간단한 것부터)
_app.tsx→app/layout.tsx로 통합
함정
- CSS 충돌 — Pages Router의
styles/globals.css이중 로드 - middleware — 공유되지만 일부 API 차이
- Image 컴포넌트 —
next/image는 동일 - useRouter —
next/navigation으로 변경 필수
12. 흔한 실수 TOP 10
- 모든 곳에
'use client'— RSC 이점 사라짐 - Server Component에 useState — 안 됨, TypeScript가 막아줌
- Client Component에서 DB 직접 접근 — 보안 사고, 자동 차단
window를 Server Component에서 — 존재 안 함- Large fetch를 매 요청마다 — 캐시 전략 필수
- 중첩 Suspense 없음 — 전체가 같이 로딩
- 네트워크 워터폴 —
await순차 - Server Action에서 큰 객체 전달 — 직렬화 비용
- revalidatePath 누락 — mutation 후 UI 안 바뀜
- 클라이언트 상태를 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
satisfiesvsas— 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)