본문 바로가기
Frontend2026년 8월 28일20분 읽기

React Server Components 실전 — RSC·Server Actions·스트리밍으로 데이터 패칭 재설계

YS
김영삼
조회 12
React Server Components 실전 — RSC·Server Actions·스트리밍으로 데이터 패칭 재설계

RSC는 '더 빠른 리액트'가 아니라, 데이터가 어디서 흐르는지를 다시 그리는 아키텍처다.

서버 컴포넌트에서 DB를 직접 읽고, 클라이언트로는 결과만 스트리밍으로 흘려보낸다. useEffect 워터폴과 거대한 클라이언트 번들이 사라지는 대신, '경계'를 손으로 그어야 하는 새로운 책임이 생긴다. 이 글은 그 경계를 어디에 긋고, 어디서 함정에 빠지는지를 실전 코드로 정리한다.

0 KB
서버 컴포넌트가 보내는 JS
1 왕복
서버 내 병렬 패칭으로 축소
'use client'
번들 경계를 여는 단 한 줄
스트리밍의 유일한 단위

React Server Components(RSC)는 2020년 RFC로 등장해 2023년 Next.js App Router의 기본이 되었고, 2026년 현재는 React 19의 안정 기능으로 자리 잡았다. 이름 때문에 오해가 많다. RSC는 서버사이드 렌더링(SSR)의 대체재가 아니다. SSR은 '클라이언트 컴포넌트를 서버에서 미리 HTML로 그려주는 것'이고, RSC는 '아예 클라이언트로 내려가지 않고 서버에서만 실행되는 컴포넌트'다. 둘은 함께 쓰인다. 이 차이를 잡고 시작해야 나머지가 맞물린다.

서버 컴포넌트와 클라이언트 컴포넌트의 경계

App Router에서 모든 컴포넌트는 기본이 서버 컴포넌트다. 파일 맨 위에 'use client'를 적는 순간, 그 파일과 그 파일이 import하는 모듈 트리는 클라이언트 번들로 들어간다. 즉 'use client'는 '이 컴포넌트를 클라이언트로 만든다'가 아니라 '여기서부터 클라이언트 경계가 시작된다'는 선언이다.

핵심 판별법은 간단하다. 상태(useState), 생명주기·effect(useEffect), 브라우저 API(window·localStorage), 이벤트 핸들러(onClick)가 필요하면 클라이언트 컴포넌트다. 데이터를 읽고, 조합하고, 레이아웃을 그리는 것뿐이라면 서버 컴포넌트로 남겨라. 실무 규칙은 '경계를 잎(leaf)까지 밀어내라'이다. 인터랙티브한 작은 조각만 클라이언트로 만들고, 그 위를 감싸는 트리는 서버에 둔다.

// app/product/[id]/page.tsx  — 서버 컴포넌트 (기본, 'use client' 없음)
import { db } from '@/lib/db'
import { AddToCart } from './add-to-cart' // 클라이언트 컴포넌트

export default async function ProductPage({ params }) {
  // 서버에서 직접 DB 접근 — 클라이언트로 내려가지 않는다
  const product = await db.product.findUnique({ where: { id: params.id } })

  return (
    <article>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      {/* 인터랙션이 필요한 잎만 클라이언트 컴포넌트로 */}
      <AddToCart productId={product.id} price={product.price} />
    </article>
  )
}
// app/product/[id]/add-to-cart.tsx
'use client'  // 여기서부터 클라이언트 경계
import { useState } from 'react'

export function AddToCart({ productId, price }: { productId: string; price: number }) {
  const [qty, setQty] = useState(1)
  return (
    <div>
      <button onClick={() => setQty(q => Math.max(1, q - 1))}>-</button>
      <span>{qty}</span>
      <button onClick={() => setQty(q => q + 1)}>+</button>
    </div>
  )
}
참고클라이언트 컴포넌트가 서버 컴포넌트를 자식으로 받는 것은 가능하다. children이나 다른 prop 슬롯으로 서버 컴포넌트 엘리먼트를 넘기면 된다. 다만 클라이언트 컴포넌트 파일 안에서 서버 컴포넌트를 직접 import할 수는 없다. import 하는 순간 그 서버 컴포넌트도 클라이언트 번들로 끌려 들어가기 때문이다.

서버에서 직접 패칭 — useEffect 워터폴의 종말

전통적인 CSR에서 데이터 패칭은 '렌더 → useEffect → fetch → setState → 리렌더'의 순서였다. 컴포넌트가 중첩되면 각 레벨이 자기 데이터를 기다렸다가 렌더되고, 그 후에야 자식이 패칭을 시작하는 워터폴이 생긴다. 서버 컴포넌트에서는 컴포넌트 함수 자체를 async로 만들고 await로 데이터를 기다린다. 네트워크가 서버 내부(대개 같은 리전의 DB/API)에서 일어나므로 왕복 비용이 클라이언트-서버 왕복보다 훨씬 싸다.

서로 의존하지 않는 데이터는 반드시 병렬로 시작하라. 순차 await는 그 자체로 서버 안에 워터폴을 다시 만든다. Promise.all로 동시에 띄우는 것이 기본이다.

// 나쁜 예 — 서버 안에서 순차 워터폴 (user를 다 기다린 뒤에야 posts 시작)
const user = await getUser(id)
const posts = await getPosts(id)   // user와 무관한데 직렬로 대기

// 좋은 예 — 독립 데이터는 병렬로
const [user, posts] = await Promise.all([
  getUser(id),
  getPosts(id),
])

Suspense로 스트리밍하기

느린 데이터 하나가 페이지 전체를 잡아두게 두지 마라. 느린 부분을 <Suspense>로 감싸면, React는 준비된 부분(셸)을 먼저 HTML로 흘려보내고 느린 부분은 준비되는 대로 이어서 스트리밍한다. 사용자는 폴백(스켈레톤)을 보다가 데이터가 도착하면 그 자리에 채워지는 것을 본다. TTFB와 체감 로딩이 극적으로 좋아진다.

// app/dashboard/page.tsx
import { Suspense } from 'react'

export default function Dashboard() {
  return (
    <section>
      <h1>대시보드</h1>
      {/* 빠른 요약은 즉시, 느린 리포트는 스트리밍 */}
      <Suspense fallback={<SummarySkeleton />}>
        <Summary />
      </Suspense>
      <Suspense fallback={<ReportSkeleton />}>
        <SlowReport />   {/* async 서버 컴포넌트, 3초 걸려도 페이지를 막지 않음 */}
      </Suspense>
    </section>
  )
}

async function SlowReport() {
  const data = await getHeavyAnalytics() // 느린 집계 쿼리
  return <ReportChart data={data} />
}
참고Next.js의 loading.tsx는 라우트 세그먼트 전체를 감싸는 Suspense의 편의 문법이다. 세밀한 스트리밍(위젯 단위)이 필요하면 loading.tsx 대신 컴포넌트 내부에 <Suspense>를 직접 배치하라. 폴백을 여러 개로 쪼갤수록 첫 콘텐츠가 빨리 뜬다.

Server Actions — 폼과 뮤테이션

읽기가 서버 컴포넌트라면, 쓰기는 Server Actions다. 함수 상단(또는 파일 상단)에 'use server'를 붙이면 그 함수는 서버에서만 실행되고, 클라이언트에서는 자동 생성된 RPC 엔드포인트를 통해 호출된다. API 라우트를 손으로 만들 필요가 없다. <form action={fn}>에 직접 연결하면 JS가 로드되기 전에도 폼이 동작하는 점진적 향상이 공짜로 따라온다.

// app/todos/actions.ts
'use server'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
import { auth } from '@/lib/auth'
import { z } from 'zod'

const schema = z.object({ title: z.string().min(1).max(200) })

export async function createTodo(formData: FormData) {
  const session = await auth()           // 반드시 서버에서 인가 확인
  if (!session) throw new Error('Unauthorized')

  // 클라이언트 입력은 항상 다시 검증 — Server Action은 공개 엔드포인트다
  const { title } = schema.parse({ title: formData.get('title') })

  await db.todo.create({ data: { title, userId: session.userId } })
  revalidatePath('/todos')               // 캐시 무효화 → UI 자동 갱신
}
// app/todos/new-todo.tsx
'use client'
import { useFormStatus } from 'react-dom'
import { createTodo } from './actions'

function SubmitBtn() {
  const { pending } = useFormStatus()    // 액션 진행 상태를 폼에서 구독
  return <button disabled={pending}>{pending ? '추가 중…' : '추가'}</button>
}

export function NewTodo() {
  return (
    <form action={createTodo}>
      <input name="title" required />
      <SubmitBtn />
    </form>
  )
}
주의Server Action은 사실상 공개 POST 엔드포인트다. 누구나 임의의 인자로 호출할 수 있다고 가정하라. 반드시 (1) 인증·인가를 액션 내부에서 확인하고, (2) 모든 입력을 zod 등으로 재검증하며, (3) 클라이언트에서 넘어온 ID의 소유권을 확인해야 한다. 클라이언트에서 걸어둔 검증은 UX일 뿐 보안 경계가 아니다.

직렬화 경계와 props 규칙

서버 컴포넌트가 클라이언트 컴포넌트에 넘기는 props는 네트워크를 건너간다. 따라서 직렬화 가능한 값만 넘길 수 있다. 함수(이벤트 핸들러), 클래스 인스턴스, Date를 제외한 복잡한 객체, Symbol 등은 넘길 수 없다. 예외적으로 Server Action 함수 참조는 넘길 수 있는데, 이는 실제 함수가 아니라 서버 엔드포인트를 가리키는 ID로 직렬화되기 때문이다.

서버→클라이언트 props가능?대안
string·number·boolean·null가능그대로 전달
plain object·array·Date·Map·Set가능중첩도 직렬화됨
일반 함수·콜백(onClick 등)불가클라이언트 컴포넌트 안에서 정의
'use server' 액션 참조가능엔드포인트 ID로 직렬화
클래스 인스턴스·Symbol불가plain object로 변환 후 전달

실무 팁: DB에서 온 객체(예: ORM 엔티티, Decimal 타입)를 그대로 넘기다 직렬화 에러를 만나는 경우가 많다. 경계에서 Number(price), date.toISOString()처럼 plain 값으로 정규화해서 넘기는 습관을 들여라. 또 하나, 넘기는 데이터를 필요한 필드로만 좁혀라 — 전체 엔티티를 넘기면 그만큼 RSC 페이로드가 커지고 클라이언트로 흘러간다.

캐싱 — 요청 메모이제이션·데이터 캐시·재검증

RSC 데이터 패칭에는 층위가 다른 캐시가 겹쳐 있다. 이걸 구분 못 하면 '왜 데이터가 안 바뀌지' 혹은 '왜 매번 다시 부르지'로 하루를 태운다.

  • 요청 메모이제이션(Request Memoization): 한 번의 렌더 동안 같은 fetch()(같은 URL·옵션)를 여러 컴포넌트가 호출해도 실제로는 한 번만 나간다. React의 cache()로 DB 함수에도 같은 효과를 줄 수 있다. 범위는 '단일 요청' 안이다.
  • 데이터 캐시(Data Cache): Next.js가 fetch 결과를 요청 간·배포 간에 영속적으로 보관한다. revalidate 옵션으로 수명을 정한다. 여기서 SSG/ISR 동작이 나온다.
  • 재검증(Revalidation): 시간 기반(next: { revalidate: 60 })과 온디맨드(revalidatePath/revalidateTag) 두 가지. 뮤테이션 뒤에는 온디맨드로 정확히 무효화하는 게 정석이다.
// 시간 기반 재검증 — 60초 동안 캐시, 이후 백그라운드 갱신
const res = await fetch('https://api.example.com/prices', {
  next: { revalidate: 60, tags: ['prices'] },
})

// 캐시하지 않기 — 매 요청 최신 (예: 대시보드 실시간 값)
const live = await fetch(url, { cache: 'no-store' })

// DB 함수 메모이제이션 — 한 렌더 안에서 중복 호출 제거
import { cache } from 'react'
export const getUser = cache(async (id: string) => {
  return db.user.findUnique({ where: { id } })
})
// 온디맨드 재검증 — 뮤테이션 후 관련 캐시만 정확히 무효화
'use server'
import { revalidateTag } from 'next/cache'

export async function updatePrice(id: string, value: number) {
  await db.price.update({ where: { id }, data: { value } })
  revalidateTag('prices')   // 'prices' 태그가 붙은 fetch 캐시만 갱신
}
참고캐싱 기본값은 프레임워크·버전마다 다르다. 초기 App Router는 fetch를 공격적으로 캐시했지만, 이후 버전은 '기본 캐시 안 함'으로 방향을 틀었다. 버전에 의존하지 말고 캐시 의도를 항상 명시하라 — 캐시하려면 revalidate/tags를, 항상 최신이 필요하면 no-store를 적는다. 명시가 곧 문서다.

흔한 함정

1) 서버 컴포넌트에서 클라이언트 훅 사용. useState·useEffect'use client' 없는 파일에 쓰면 즉시 에러다. 반대로, 되도록 서버로 두려다 훅이 필요해지면 그 잎만 떼어 클라이언트 컴포넌트로 만들어라.

2) secret 노출. 서버 컴포넌트에서 읽은 process.env.API_SECRET 값을 클라이언트 컴포넌트 props로 넘기면 RSC 페이로드에 실려 브라우저로 나간다. 비밀은 서버 경계 안에서만 쓰고, 절대 props로 넘기지 마라. server-only 패키지를 import하면 서버 전용 모듈이 실수로 클라이언트에 딸려가는 것을 빌드 타임에 막을 수 있다.

// lib/secrets.ts — 이 모듈이 클라이언트 번들에 섞이면 빌드 실패
import 'server-only'
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)

3) 번들이 오히려 커짐. 최상단 레이아웃에 'use client'를 얹으면 그 아래 전부가 클라이언트로 끌려간다. 경계는 항상 아래로 밀어라. 4) 무심한 순차 await로 서버 워터폴을 만드는 것, 5) 뮤테이션 후 재검증 누락으로 화면이 안 바뀌는 것도 단골이다.

언제 RSC를 쓰지 말아야 하나

RSC는 만능이 아니다. 고빈도 실시간·인터랙션 위주 앱(트레이딩 화면, 협업 화이트보드, 게임 UI, 그리기 툴)은 상태가 클라이언트에 있어야 하므로 대부분 클라이언트 컴포넌트가 된다. 여기서 RSC를 억지로 끼우면 왕복만 늘어난다. 서버가 없는 배포(순수 정적 호스팅, 브라우저 확장, 오프라인 우선 PWA)도 서버 런타임 전제가 깨진다.

이미 잘 도는 SPA에 클라이언트 데이터 라이브러리(React Query/SWR)와 REST/GraphQL이 안정적으로 갖춰져 있다면, RSC 전환의 비용 대비 효과를 냉정히 보라. RSC와 클라이언트 캐시 라이브러리는 공존 가능하지만 캐시 경계가 이중이 되어 멘탈 모델이 복잡해진다. 판단 기준은 하나다 — 데이터가 서버에 있고, 화면이 그 데이터를 읽어 그리는 것이 주라면 RSC가 이긴다. 인터랙션과 클라이언트 상태가 주인공이면 클라이언트가 이긴다.

자주 묻는 질문

RSC와 SSR은 같은 건가요?

아니요. SSR은 클라이언트 컴포넌트를 서버에서 미리 HTML로 렌더해 초기 화면을 빠르게 주는 기법이고, 그 컴포넌트의 JS는 여전히 브라우저로 내려가 하이드레이션됩니다. RSC는 아예 클라이언트로 내려가지 않고 서버에서만 실행되어 JS 번들을 0으로 만듭니다. App Router는 둘을 함께 씁니다 — 서버 컴포넌트는 RSC로, 클라이언트 컴포넌트는 SSR + 하이드레이션으로 처리됩니다.

Server Actions와 API 라우트, 언제 무엇을 쓰나요?

앱 내부의 폼 제출·뮤테이션은 Server Actions가 간결합니다. 별도 엔드포인트·타입 안전성·점진적 향상이 공짜로 따라오죠. 반면 외부에서 호출하는 공개 API(모바일 앱, 웹훅 수신, 서드파티 연동)나 REST 규약이 필요한 경우, 커스텀 응답 헤더·스트리밍 응답이 필요한 경우는 Route Handler(API 라우트)가 맞습니다. 둘 다 공개 엔드포인트이므로 인증·검증은 어느 쪽이든 필수입니다.

서버 컴포넌트에서 useState를 정말 못 쓰나요?

네. 서버 컴포넌트는 한 번 실행되고 끝나므로 상태·effect·이벤트 핸들러가 없습니다. 상태가 필요한 부분만 'use client'를 붙인 별도 컴포넌트로 분리해 잎에 두세요. 서버 컴포넌트는 그 클라이언트 컴포넌트에 초기 데이터를 props로 넘기는 역할을 합니다. 이 '서버가 데이터, 클라이언트가 상호작용' 분업이 RSC 설계의 핵심입니다.

데이터를 바꿨는데 화면이 안 바뀝니다. 왜죠?

십중팔구 캐시 무효화 누락입니다. 뮤테이션(Server Action)이 성공한 뒤 관련 경로나 태그를 revalidatePath/revalidateTag로 무효화해야 다음 렌더에서 새 데이터를 읽습니다. fetch에 revalidate 시간을 걸어뒀다면 그 시간 동안 낡은 값이 나올 수 있으니, 즉시 반영이 필요하면 온디맨드 재검증을 쓰세요. 실시간이 필요한 값은 cache: 'no-store'로 캐시를 끕니다.

클라이언트 번들이 예상보다 큰데 원인을 어떻게 찾나요?

대개 'use client'가 트리 위쪽에 붙어 아래 전부를 클라이언트로 끌어온 경우입니다. 번들 분석기(@next/bundle-analyzer)로 어떤 모듈이 클라이언트 청크에 들어갔는지 확인하고, 경계를 인터랙티브한 잎까지 내리세요. 무거운 라이브러리(마크다운 파서, 차트, 날짜 라이브러리)를 서버 컴포넌트 안에서만 쓰도록 옮기면 클라이언트 번들에서 통째로 빠집니다.

Suspense 폴백은 얼마나 잘게 쪼개야 하나요?

'의미 있는 콘텐츠 블록' 단위가 기준입니다. 페이지 전체를 하나의 폴백으로 감싸면 스트리밍 이점이 거의 없고, 반대로 너무 잘게 쪼개면 스켈레톤이 우수수 나타났다 사라지며 레이아웃이 출렁입니다(CLS 악화). 빠른 데이터는 셸에 즉시 그리고, 느린 위젯(집계 리포트, 추천 목록)만 개별 Suspense로 감싸 독립적으로 스트리밍하는 것이 균형점입니다.

댓글 0

아직 댓글이 없습니다.
Ctrl+Enter로 등록