LabHub

블로그

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

한국어English日本語

"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 생성

단점:

2단계 — SSR (2017+)

Next.js 등이 ReactDOMServer.renderToString으로 서버에서 HTML 생성:

  1. 서버가 렌더링된 HTML 전송 (FCP 빠름)
  2. 브라우저가 hydrate — 이벤트 리스너 붙이기

단점:

3단계 — SSG / ISR (2019+)

빌드 시 또는 주기적으로 HTML 생성:

그러나 동적 콘텐츠에는 부족.

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)의 규칙

  1. Server → Server: 자유 (그냥 함수 호출)
  2. Server → Client: children prop 또는 serializable props로 전달
  3. Client → Client: 일반 React
  4. Client → Server: Server Action으로만

흔한 오해 — "서버 컴포넌트는 SSR이다"

아니다. SSR은 "서버에서도 한 번 렌더링"하는 것. RSC는 "서버에서만 렌더링"하는 것. 클라이언트 번들에 아예 없다.


3. RSC Flight Protocol — 직렬화의 기술

왜 새 프로토콜이 필요한가

RSC가 생성하는 건 HTML이 아니다. 컴포넌트 트리의 직렬화된 표현이다.

이유:

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}]}]}]

장점

벤치마크

같은 페이지 SSR vs RSC:


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.tsxURL에 매칭되는 컴포넌트
layout.tsx공유 레이아웃 (리렌더 안됨)
template.tsxlayout과 유사하지만 매번 리렌더
loading.tsxSuspense fallback 자동 래핑
error.tsxError Boundary 자동 래핑
not-found.tsx404 페이지
default.tsx병렬 라우팅의 기본값
route.tsAPI 엔드포인트

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

내부 뮤테이션은 Server Action, 외부 API는 Route Handler.


7. 캐싱의 4계층 — 가장 헷갈리는 부분

Next.js의 캐시 모델은 유명할 정도로 복잡하다.

1. Request Memoization (요청 중복 제거)

2. Data Cache

3. Full Route Cache

4. Router Cache (클라이언트)

무효화

2024-2025 간소화 시도

Next.js 15의 "stable cache semantics":


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의 세부

TTFB vs FMP 트레이드오프


9. PPR — Partial Prerendering (Next.js 15)

문제

해결 — 하나의 페이지에 혼합

export const experimental_ppr = true

export default function Page() {
  return (
    <>
      <Header />  {/* 정적 */}
      <Hero />    {/* 정적 */}
      <Suspense fallback={<Skeleton />}>
        <DynamicCart />  {/* 동적, 매 요청마다 */}
      </Suspense>
    </>
  )
}

작동 원리

  1. 빌드 시 정적 부분을 미리 렌더 → shell
  2. 요청 시 동적 부분만 렌더 → streaming으로 결합
  3. 결과: 정적 속도 + 동적 유연성

현재 (2025)


10. React Server Components vs SolidStart vs Qwik

SolidStart

Qwik City

TanStack Start

Remix (이제 React Router v7)

선택 기준

조건추천
팀이 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.tsxapp/layout.tsx로 통합

함정


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 체크리스트


마치며 — "서버는 다시 중요해졌다"

RSC는 단순한 최적화가 아니다. "프론트엔드와 백엔드의 경계를 컴포넌트 레벨까지 끌어내린" 패러다임 변화다. 10년 전 "SPA가 미래"라고 외쳤던 업계가, 이제는 "서버가 다시 중요하다"고 말한다. 그러나 이것은 과거로의 회귀가 아니라 새로운 합성이다 — 서버의 DB 접근 편의성 + 클라이언트의 상호작용성.

React 팀이 말한 "Use the Platform" 철학은 웹 표준(form, HTML 스트리밍, progressive enhancement)을 React에 녹여넣은 것이다. 2030년의 React는 지금과 많이 다를 것이다. 하지만 그 방향은 이미 보인다: 작은 클라이언트 번들, 빠른 첫 화면, 서버에서 해결할 수 있는 것은 서버에서, 복잡성은 프레임워크로.


다음 글 예고 — TypeScript 타입 시스템의 깊이 — 2026년의 표준

RSC가 런타임의 혁명이었다면, TypeScript는 지난 10년간의 언어 혁명이다. 다음 글에서는:

개발자 도구의 가장 중요한 혁명을 정리하는 여정.


"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)

댓글

아직 댓글이 없습니다.

로그인하면 댓글을 쓸 수 있습니다