Skip to content

필사 모드: React Server Components와 Next.js App Router 완전 정복 — RSC 프로토콜, Server Actions, PPR, Streaming (2025)

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.
원문 렌더가 준비되기 전까지 텍스트 가이드로 표시합니다.

> "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" 발표 영상을 올렸다. 당시 대부분의 개발자는 "또 새로운 걸 ...

작성 글자: 0원문 글자: 9,677작성 단락: 0/302