TestForge | Aidevops | 📊 Plogger ✍️ Blog 📚 Docs
plogger

AI DevOps Korea

AI 서비스 개발, 운영, 성능개선을 하나의 루프로 연결합니다

aidevops.kr에서 LLMOps, RAG, AI Agent, 관측성, 평가, 비용-성능 최적화를 실전 운영 관점으로 정리합니다.

React Server Components 완전 정복 — RSC와 Server Actions

· 수정 4월 15일
React Server Components 완전 정복 — RSC와 Server Actions 다이어그램
이 그림은 서버 렌더링 작업, 스트리밍 페이로드, 클라이언트 아일랜드, 변경 후 갱신 경로를 분리해 보여 주어 RSC 경계를 더 명확하게 이해하도록 돕습니다.
React Server Components(RSC)는 단순히 "서버에서 렌더링하는 React"가 아닙니다. 핵심은 렌더링 위치를 옮기는 것이 아니라, **어떤 코드를 클라이언트에 보내지 않을 것인가**를 더 정교하게 설계하는 데 있습니다.

많은 글이 RSC를 SSR의 확장처럼 설명하지만, 실무에서 체감하는 차이는 더 큽니다.

  • 서버에서만 필요한 라이브러리를 번들에서 제거할 수 있습니다.
  • 데이터를 가져오는 코드와 화면 구조를 더 가깝게 둘 수 있습니다.
  • 인터랙션이 필요한 부분만 클라이언트로 내릴 수 있습니다.

하지만 반대로 경계를 잘못 잡으면 App Router 프로젝트가 오히려 더 복잡해지기도 합니다. 이 글은 “RSC를 어떻게 쓰는가”보다 “어디까지 RSC로 둘 것인가”에 초점을 맞춥니다.

RSC가 진짜로 해결하는 문제

기존 React 애플리케이션은 보통 두 가지 비용을 동시에 안고 있었습니다.

  • 데이터를 가져오기 위한 서버 로직
  • 사용자 상호작용을 위한 클라이언트 로직

문제는 이 둘이 섞이면서, 서버에서만 필요했던 코드까지 클라이언트 번들에 끌려 내려오는 경우가 많았다는 점입니다. 마크다운 파서, 데이터 포매터, 복잡한 질의 조합 로직이 대표적입니다.

RSC는 이 문제를 이렇게 풀어냅니다.

  • 기본값을 서버 컴포넌트로 둔다
  • 브라우저에서 상태와 이벤트가 필요한 곳만 클라이언트 컴포넌트로 전환한다

즉, 최적화의 중심이 “클라이언트 번들을 줄이는 것”으로 이동합니다.

가장 중요한 설계 원칙: 경계를 늦게 내리기

App Router에서 흔한 실수는 페이지 전체를 너무 일찍 'use client'로 바꾸는 것입니다. 한 번 클라이언트 경계 안으로 들어오면 그 하위 트리는 클라이언트 번들의 영향을 받기 쉬워집니다.

좋은 기준은 단순합니다.

  • 데이터 조회, 권한 체크, 마크다운 변환, 포맷팅은 서버에 남긴다
  • 폼 상태, 모달 열기/닫기, 클릭 이벤트 같은 인터랙션만 클라이언트로 보낸다
// app/posts/[id]/page.tsx
import { marked } from 'marked'
import { db } from '@/lib/db'
import { LikeButton } from '@/components/LikeButton'

export default async function PostPage({
  params,
}: {
  params: { id: string }
}) {
  const post = await db.post.findUnique({
    where: { id: params.id },
  })

  if (!post) {
    return notFound()
  }

  const html = marked(post.content)

  return (
    <article>
      <h1>{post.title}</h1>
      <LikeButton postId={post.id} initialCount={post.likeCount} />
      <div dangerouslySetInnerHTML={{ __html: html }} />
    </article>
  )
}

여기서 marked와 DB 호출은 클라이언트로 내려가지 않습니다. 이 점이 RSC의 가장 현실적인 장점입니다. 특히 대시보드, 문서형 서비스, 커머스 상세 페이지처럼 읽기 중심 화면에서 효과가 큽니다.

Client Component는 “인터랙션 섬”처럼 다루기

'use client'

import { useState, useTransition } from 'react'
import { toggleLike } from '@/app/actions'

type Props = {
  postId: string
  initialCount: number
}

export function LikeButton({ postId, initialCount }: Props) {
  const [count, setCount] = useState(initialCount)
  const [isPending, startTransition] = useTransition()

  return (
    <button
      disabled={isPending}
      onClick={() =>
        startTransition(async () => {
          const nextCount = await toggleLike(postId)
          setCount(nextCount)
        })
      }
    >
      {isPending ? 'Updating...' : `❤️ ${count}`}
    </button>
  )
}

핵심은 버튼 하나를 위해 페이지 전체를 클라이언트 컴포넌트로 만들지 않는 것입니다. 이 패턴은 흔히 “interactive island”에 가깝게 작동합니다.

좋은 RSC 설계는 대부분 다음 질문에서 시작합니다.

  • 이 상태가 정말 브라우저에 있어야 하는가
  • 이 이벤트 핸들러가 정말 브라우저에서 실행되어야 하는가
  • 이 라이브러리가 사용자에게 전송될 이유가 있는가

Server Actions는 폼 처리의 위치를 바꾼다

Server Actions는 “API 라우트를 안 써도 된다”는 기능 소개로 소비되기 쉽지만, 더 중요한 변화는 쓰기 요청의 중심을 컴포넌트 가까이로 끌어온다는 점입니다.

// app/actions.ts
'use server'

import { z } from 'zod'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'

const CreatePostSchema = z.object({
  title: z.string().min(1).max(100),
  content: z.string().min(10),
  tags: z.string().transform((value) =>
    value
      .split(',')
      .map((tag) => tag.trim())
      .filter(Boolean),
  ),
})

export async function createPost(formData: FormData) {
  const raw = {
    title: formData.get('title'),
    content: formData.get('content'),
    tags: formData.get('tags'),
  }

  const parsed = CreatePostSchema.safeParse(raw)
  if (!parsed.success) {
    return { errors: parsed.error.flatten().fieldErrors }
  }

  const post = await db.post.create({
    data: {
      ...parsed.data,
      authorId: await getCurrentUserId(),
    },
  })

  revalidatePath('/posts')
  redirect(`/posts/${post.id}`)
}
// app/posts/new/page.tsx
import { createPost } from '@/app/actions'

export default function NewPostPage() {
  return (
    <form action={createPost}>
      <input name="title" placeholder="제목" />
      <textarea name="content" placeholder="내용" />
      <input name="tags" placeholder="태그 (쉼표 구분)" />
      <SubmitButton />
    </form>
  )
}

이 접근은 특히 다음 경우에 강합니다.

  • HTML form과 잘 맞는 CRUD 화면
  • DB 변경 후 캐시 무효화가 자연스럽게 이어져야 하는 경우
  • 클라이언트 상태관리보다 서버 기준의 단순한 쓰기 플로우가 더 적합한 경우

반대로 모바일 앱, 외부 파트너 API, 복잡한 public API가 함께 있는 환경이라면 전통적인 API 레이어가 여전히 더 명확할 수 있습니다. Server Actions가 모든 API 설계를 대체하는 것은 아닙니다.

Streaming과 Suspense는 “빠르게 다 보여주기”보다 “먼저 보여줄 것을 고르는 기술”이다

RSC 환경에서 Suspense는 UX 최적화의 핵심입니다. 중요한 건 모든 데이터를 기다렸다가 한 번에 그리는 게 아니라, 사용자가 먼저 봐야 하는 조각을 우선 렌더링하는 것입니다.

import { Suspense } from 'react'

export default function DashboardPage() {
  return (
    <div className="grid grid-cols-2 gap-4">
      <UserProfile />

      <Suspense fallback={<StatsSkeleton />}>
        <RevenueStats />
      </Suspense>

      <Suspense fallback={<OrdersSkeleton />}>
        <RecentOrders />
      </Suspense>
    </div>
  )
}

async function RevenueStats() {
  const stats = await fetchRevenueStats()
  return <StatsCard data={stats} />
}

실무에서는 “어떤 컴포넌트를 Suspense로 감쌀 것인가”가 UX 설계와 직결됩니다. 전체 페이지를 한 번에 감싸면 스트리밍의 장점이 줄고, 너무 잘게 쪼개면 로딩 조각이 많아져 오히려 산만해집니다.

캐싱을 이해하지 못하면 RSC 프로젝트는 쉽게 혼란스러워진다

App Router에서 자주 발생하는 혼선은 “왜 데이터가 안 바뀌지?” 혹은 “왜 매번 다시 불리지?” 같은 질문입니다. 이는 대부분 캐싱 모델을 제대로 이해하지 못해서 생깁니다.

async function getPost(id: string) {
  const res = await fetch(`https://api.example.com/posts/${id}`, {
    next: {
      revalidate: 3600,
      tags: [`post-${id}`],
    },
  })

  if (!res.ok) {
    throw new Error('Failed to fetch post')
  }

  return res.json()
}
import { revalidateTag } from 'next/cache'

export async function updatePost(id: string, data: PostData) {
  await db.post.update({
    where: { id },
    data,
  })

  revalidateTag(`post-${id}`)
}

캐싱 전략을 잡을 때는 아래처럼 분류해 두는 편이 좋습니다.

  • 자주 바뀌지 않는 읽기 데이터: revalidate
  • 특정 변경 이후만 무효화하면 되는 데이터: tags + revalidateTag
  • 항상 최신이어야 하는 실시간 데이터: noStore()

중요한 건 기술 옵션을 외우는 게 아니라, 도메인 데이터마다 기대 일관성 수준이 다르다는 사실을 인정하는 것입니다.

RSC에서 자주 나오는 오해

1. 서버 컴포넌트는 항상 빠르다

반은 맞고 반은 틀립니다. 서버 컴포넌트는 번들 절감에는 유리하지만, 느린 백엔드 호출이 많다면 여전히 느릴 수 있습니다. 결국 병목은 “어디서 렌더링하느냐”보다 “무엇을 기다리느냐”에 있습니다.

2. Server Actions가 있으면 API가 필요 없다

내부 웹 애플리케이션에서는 맞을 수 있지만, 공개 API, 모바일 클라이언트, 서드파티 연동이 있으면 별도 API 레이어는 계속 필요합니다.

3. 'use client'를 붙여도 큰 차이 없다

실제로는 하위 트리와 의존성 전파에 큰 차이를 만듭니다. 조심해서 최소 범위에만 붙여야 합니다.

팀 단위에서 유용한 설계 기준

RSC는 개인 프로젝트보다 팀 프로젝트에서 기준이 더 중요합니다. 다음 정도는 합의해 두면 좋습니다.

  • 기본은 서버 컴포넌트로 작성한다
  • 브라우저 상태와 이벤트가 필요할 때만 클라이언트로 전환한다
  • 데이터 fetching은 가능한 한 화면 가까이에 둔다
  • 캐시 무효화 규칙은 도메인 단위로 문서화한다
  • Server Actions의 반환 형식과 에러 형식을 일관되게 맞춘다

이 기준이 없으면 코드리뷰가 매번 취향 싸움이 되기 쉽습니다.

언제 RSC가 특히 잘 맞는가

  • 콘텐츠 중심 서비스
  • 관리자 대시보드
  • 커머스 상품/주문 관리 화면
  • 서버 데이터 의존도가 높고 인터랙션은 부분적인 화면

반대로 캔버스 기반 편집기, 실시간 협업 화면, 복잡한 drag-and-drop UI처럼 클라이언트 상태와 상호작용이 중심인 제품은 RSC의 체감 이점이 상대적으로 작을 수 있습니다.

마무리

React Server Components의 본질은 “React를 서버에서 돌린다”가 아니라, 클라이언트에 꼭 필요한 코드만 남기는 아키텍처적 선택입니다. 이 관점이 서야 Client Component 경계, Server Actions, 캐싱, 스트리밍이 하나의 그림으로 연결됩니다.

실무에서는 기능을 추가할 때마다 “이 로직은 브라우저에 있어야 하는가?”를 먼저 묻는 습관이 중요합니다. RSC는 그 질문에 정직하게 답할수록 더 큰 효과를 냅니다.

운영 환경에서 어려워지는 지점

  • Server Components는 클라이언트 번들 압력을 낮추지만 데이터 패칭, 상호작용, 캐시 수명에 대한 소유권을 더 엄격하게 생각하게 만든다.
  • 서버 모듈과 클라이언트 모듈 사이 경계는 앱을 단순하게도, 파편화되게도 만들 수 있는 새로운 아키텍처 seam이다.
  • 잘못 배치된 클라이언트 컴포넌트는 큰 의존성 트리를 조용히 브라우저 번들로 다시 끌고 온다.

중요한 아키텍처 결정

  • 읽기 중심 렌더링과 직접 데이터 접근은 서버 컴포넌트로 옮기고, 상호작용 중심 영역만 의도적으로 클라이언트에 남긴다.
  • 복잡한 모델을 client component로 넘길 때는 직렬화 경계를 API 설계 일부로 본다.
  • 캐시와 재검증 정책은 멀리 떨어진 헬퍼가 아니라 데이터 접근 경로 옆에 둔다.

실무 예시

실용적인 분리는 리스트는 서버에 두고 상호작용 섬만 클라이언트에 두는 방식이다.

export default async function OrdersPage() {
  const orders = await getOrders()

  return (
    <div>
      <OrdersTable orders={orders} />
      <ClientFilters />
    </div>
  )
}

피해야 할 안티패턴

  • 경계 설계를 피하려고 거의 모든 컴포넌트를 client component로 만드는 것.
  • 깊게 중첩되고 불안정한 객체를 서버-클라이언트 경계 너머로 넘기는 것.
  • Server Components가 백엔드 성능 작업을 대체한다고 생각하는 것.

운영 체크리스트

  • use client 파일이 클라이언트 번들에 주는 영향을 추적한다.
  • 서버 데이터 경로의 캐시 적중률과 재검증 동작을 측정한다.
  • 직렬화 비용과 payload 크기를 리뷰한다.
  • 느린 서버 의존성에서 fallback UX를 테스트한다.

최종 판단

Server Components는 읽기 중심 렌더링과 클라이언트 상호작용의 경계를 더 날카롭게 만들 때 가치가 크다. 무분별하게 쓰면 소유권만 더 흐린 새 경계가 늘어난다.

Continue Reading

다음으로 읽기 좋은 글

다음 탐색

이 주제를 시스템 관점으로 더 이어서 보기