웹 개발

React 서버 컴포넌트 심화: 풀스택 아키텍처 패턴

Jihoon Park|웹 개발자
2026-01-15
18분 소요
#React#서버 컴포넌트#Next.js#RSC#풀스택
React 서버 컴포넌트 심화: 풀스택 아키텍처 패턴

React 서버 컴포넌트(RSC)는 2023년 Next.js 13 App Router와 함께 정식 출시되어, 2025년 현재 프로덕션 환경에서 널리 사용되고 있습니다. 전통적인 CSR/SSR의 한계를 극복하고, 컴포넌트 단위로 렌더링 전략을 선택할 수 있는 혁신적인 패러다임입니다.

RSC가 웹 개발 패러다임을 바꾸는 이유

기존 접근법의 한계

전통적인 CSR (Client-Side Rendering):

  • 번들 크기가 증가할수록 초기 로딩 느림
  • 모든 라이브러리가 클라이언트로 전송
  • SEO 초기 대응 어려움

전통적인 SSR (Server-Side Rendering):

  • 전체 페이지 단위 렌더링
  • Hydration 비용 (클라이언트에서 재실행)
  • 인터랙티브 컴포넌트와 정적 컴포넌트 구분 불가

RSC의 혁신

React 서버 컴포넌트는 컴포넌트 레벨에서 렌더링 전략을 선택할 수 있습니다.

특성서버 컴포넌트클라이언트 컴포넌트
실행 위치서버클라이언트
번들 포함포함 안됨포함됨
데이터 페칭직접 가능 (async/await)useEffect/SWR 필요
인터랙션불가능가능 (useState, event handlers)
패키지 사용서버 전용 (fs, database 등)브라우저 전용 (DOM API)
재렌더링페이지 네비게이션 시상태 변경 시

핵심 이점:

  1. 번들 크기 혁신: 서버 컴포넌트는 번들에 포함되지 않음 → 100KB+ 라이브러리도 부담 없음
  2. 워터폴 제거: 컴포넌트 내부에서 직접 데이터 페칭 → 병렬 처리 가능
  3. 자동 코드 스플리팅: 클라이언트 컴포넌트만 분리 전송
  4. Streaming: 준비된 컴포넌트부터 점진적 렌더링
React 서버 컴포넌트 아키텍처 / React Server Components Server Side Database PostgreSQL API Routes REST/GraphQL Server Components Render on server, zero JS bundle Server Actions Direct server mutations Serialization Boundary Client Side Client Components useState, useEffect, event handlers Browser State Local state Event Handlers onClick, etc. Hydration Interactive after JS loads RSC Payload Server Action
Server and Client components work together across the serialization boundary

Server/Client 경계 설계 원칙

가장 중요한 의사결정은 "어떤 컴포넌트를 서버/클라이언트로 만들 것인가"입니다.

기본 원칙

서버 컴포넌트 기본 (Default):

  • Next.js App Router에서 모든 컴포넌트는 기본적으로 서버 컴포넌트
  • 'use client' 지시어가 없으면 서버 컴포넌트

클라이언트 컴포넌트로 만들어야 하는 경우:

  • 상태 관리 (useState, useReducer)
  • 이벤트 핸들러 (onClick, onChange 등)
  • 브라우저 API (window, localStorage)
  • React Hooks (useEffect, useContext 등)
  • 클래스 컴포넌트

직렬화 경계 (Serialization Boundary)

서버 컴포넌트에서 클라이언트 컴포넌트로 전달되는 props는 직렬화 가능해야 합니다.

전달 가능:

  • JSON 직렬화 가능한 데이터 (string, number, boolean, array, plain object)
  • React 노드 (JSX)
  • Promise (특별 처리)

전달 불가능:

  • 함수 (이벤트 핸들러 제외)
  • 클래스 인스턴스
  • Date, Map, Set (JSON.stringify 시 손실)
tsx
// 잘못된 예
// app/page.tsx (Server Component)
import ClientComponent from './ClientComponent';

export default async function Page() {
  const data = await fetchData();

  return (
    <ClientComponent
      onUpdate={() => console.log('update')} // ❌ 함수 전달 불가
      date={new Date()} // ❌ Date 객체 직렬화 문제
    />
  );
}

// 올바른 예
export default async function Page() {
  const data = await fetchData();

  return (
    <ClientComponent
      data={data} // ✅ plain object
      timestamp={new Date().toISOString()} // ✅ string
    />
  );
}

컴포지션 패턴 (Composition Pattern)

서버 컴포넌트를 클라이언트 컴포넌트의 children으로 전달하는 패턴입니다.

tsx
// ClientWrapper.tsx
'use client';
import { useState } from 'react';

export function ClientWrapper({ children }: { children: React.ReactNode }) {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div>
      <button onClick={() => setIsOpen(!isOpen)}>
        {isOpen ? 'Hide' : 'Show'}
      </button>
      {isOpen && <div>{children}</div>}
    </div>
  );
}

// app/page.tsx (Server Component)
import { ClientWrapper } from './ClientWrapper';

export default async function Page() {
  const data = await fetchData();

  return (
    <ClientWrapper>
      {/* 이 부분은 여전히 서버 컴포넌트! */}
      <ServerDataDisplay data={data} />
    </ClientWrapper>
  );
}

이 패턴의 장점:

  • ServerDataDisplay는 서버에서 렌더링되어 HTML로 전달
  • ClientWrapper의 번들에 포함되지 않음
  • 서버 전용 라이브러리 사용 가능

Data Fetching 패턴

Async 서버 컴포넌트

서버 컴포넌트는 async/await를 사용할 수 있습니다.

tsx
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';

async function getPost(slug: string) {
  const res = await fetch(`https://api.example.com/posts/${slug}`, {
    next: { revalidate: 3600 } // 1시간 캐시
  });

  if (!res.ok) return null;
  return res.json();
}

export default async function BlogPost({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const post = await getPost(slug);

  if (!post) notFound();

  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  );
}

워터폴 방지 (Parallel Fetching)

컴포넌트 계층 구조에서 순차적 데이터 페칭은 워터폴을 만듭니다.

tsx
// ❌ 워터폴 발생
export default async function Page() {
  const user = await fetchUser(); // 1초
  const posts = await fetchPosts(user.id); // 1초
  const comments = await fetchComments(user.id); // 1초
  // 총 3초!

  return <div>...</div>;
}

// ✅ 병렬 페칭
export default async function Page() {
  const userPromise = fetchUser();
  const postsPromise = fetchPosts();
  const commentsPromise = fetchComments();

  const [user, posts, comments] = await Promise.all([
    userPromise,
    postsPromise,
    commentsPromise,
  ]);
  // 총 1초!

  return <div>...</div>;
}

React.cache() 활용

동일한 데이터 요청을 중복 제거합니다.

tsx
// lib/data.ts
import { cache } from 'react';

export const getUser = cache(async (id: string) => {
  console.log(`Fetching user ${id}`); // 한 번만 실행
  const res = await fetch(`https://api.example.com/users/${id}`);
  return res.json();
});

// app/profile/page.tsx
import { getUser } from '@/lib/data';

export default async function ProfilePage() {
  const user = await getUser('123');
  return <UserProfile user={user} />;
}

// components/UserProfile.tsx
import { getUser } from '@/lib/data';

export async function UserProfile({ user }: { user: any }) {
  const detailedUser = await getUser(user.id); // 캐시에서 가져옴, 재요청 안함
  return <div>{detailedUser.name}</div>;
}

Server Actions 실전 활용

Server Actions는 클라이언트에서 서버 함수를 직접 호출할 수 있는 RPC 메커니즘입니다.

폼 처리

tsx
// app/contact/actions.ts
'use server';

import { z } from 'zod';
import { revalidatePath } from 'next/cache';

const contactSchema = z.object({
  name: z.string().min(2),
  email: z.string().email(),
  message: z.string().min(10),
});

export async function submitContact(formData: FormData) {
  const rawData = {
    name: formData.get('name'),
    email: formData.get('email'),
    message: formData.get('message'),
  };

  const validated = contactSchema.safeParse(rawData);

  if (!validated.success) {
    return { error: validated.error.flatten().fieldErrors };
  }

  // 데이터베이스 저장
  await db.contacts.create({ data: validated.data });

  // 캐시 무효화
  revalidatePath('/contact');

  return { success: true };
}

// app/contact/page.tsx
import { submitContact } from './actions';

export default function ContactPage() {
  return (
    <form action={submitContact}>
      <input name="name" required />
      <input name="email" type="email" required />
      <textarea name="message" required />
      <button type="submit">Submit</button>
    </form>
  );
}

useFormState & useFormStatus

React 19의 새로운 훅으로 폼 상태 관리를 개선합니다.

tsx
'use client';
import { useFormState, useFormStatus } from 'react-dom';
import { submitContact } from './actions';

function SubmitButton() {
  const { pending } = useFormStatus();

  return (
    <button type="submit" disabled={pending}>
      {pending ? 'Submitting...' : 'Submit'}
    </button>
  );
}

export function ContactForm() {
  const [state, formAction] = useFormState(submitContact, null);

  return (
    <form action={formAction}>
      <input name="name" />
      {state?.error?.name && <p className="error">{state.error.name}</p>}

      <input name="email" />
      {state?.error?.email && <p className="error">{state.error.email}</p>}

      <textarea name="message" />
      {state?.error?.message && <p className="error">{state.error.message}</p>}

      <SubmitButton />

      {state?.success && <p className="success">Message sent!</p>}
    </form>
  );
}

Streaming & Suspense 아키텍처

Streaming을 활용하면 느린 컴포넌트가 전체 페이지 렌더링을 차단하지 않습니다.

loading.tsx

Next.js App Router의 loading.tsx는 자동으로 Suspense 경계를 생성합니다.

tsx
// app/dashboard/loading.tsx
export default function Loading() {
  return <div>Loading dashboard...</div>;
}

// app/dashboard/page.tsx (느린 데이터 페칭)
export default async function DashboardPage() {
  const data = await fetchSlowData(); // 3초 소요
  return <Dashboard data={data} />;
}

세밀한 Suspense 경계

tsx
import { Suspense } from 'react';

export default async function DashboardPage() {
  return (
    <div>
      <h1>Dashboard</h1>

      {/* 빠른 데이터는 즉시 렌더링 */}
      <QuickStats />

      {/* 느린 데이터는 별도 Suspense */}
      <Suspense fallback={<ChartSkeleton />}>
        <RevenueChart />
      </Suspense>

      <Suspense fallback={<TableSkeleton />}>
        <RecentOrders />
      </Suspense>
    </div>
  );
}

async function RevenueChart() {
  const data = await fetchRevenueData(); // 2초
  return <Chart data={data} />;
}

async function RecentOrders() {
  const orders = await fetchOrders(); // 1초
  return <OrdersTable orders={orders} />;
}

장점:

  • 빠른 콘텐츠 먼저 표시 (QuickStats)
  • 느린 컴포넌트는 준비되는 대로 스트리밍
  • 각 Suspense는 독립적으로 해결

캐싱 전략

Next.js 14/15는 복잡한 캐싱 레이어를 제공합니다.

Fetch Cache

tsx
// 캐싱 안함
fetch('https://api.example.com/data', { cache: 'no-store' });

// 정적 생성 (무기한 캐시)
fetch('https://api.example.com/data', { cache: 'force-cache' });

// 시간 기반 재검증 (ISR)
fetch('https://api.example.com/data', {
  next: { revalidate: 3600 } // 1시간
});

// 태그 기반 재검증
fetch('https://api.example.com/posts', {
  next: { tags: ['posts'] }
});

revalidatePath & revalidateTag

Server Actions에서 캐시를 무효화합니다.

tsx
'use server';
import { revalidatePath, revalidateTag } from 'next/cache';

export async function createPost(formData: FormData) {
  const post = await db.posts.create({
    data: { title: formData.get('title'), content: formData.get('content') }
  });

  // 특정 경로 무효화
  revalidatePath('/blog');

  // 특정 태그 무효화
  revalidateTag('posts');

  return { success: true };
}

Route Segment Config

페이지 레벨에서 캐싱 전략 설정:

tsx
// app/blog/page.tsx
export const dynamic = 'force-dynamic'; // 항상 동적
export const revalidate = 3600; // 1시간마다 재생성

export default async function BlogPage() {
  const posts = await fetchPosts();
  return <PostsList posts={posts} />;
}

인증/인가 패턴

미들웨어 인증

tsx
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { getToken } from '@/lib/auth';

export async function middleware(request: NextRequest) {
  const token = await getToken(request);

  if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  return NextResponse.next();
}

export const config = {
  matcher: ['/dashboard/:path*', '/profile/:path*'],
};

서버 컴포넌트에서 세션 확인

tsx
// lib/auth.ts
import { cache } from 'react';
import { cookies } from 'next/headers';

export const getCurrentUser = cache(async () => {
  const cookieStore = await cookies();
  const token = cookieStore.get('session');

  if (!token) return null;

  const user = await verifyToken(token.value);
  return user;
});

// app/dashboard/page.tsx
import { getCurrentUser } from '@/lib/auth';
import { redirect } from 'next/navigation';

export default async function DashboardPage() {
  const user = await getCurrentUser();

  if (!user) {
    redirect('/login');
  }

  return <Dashboard user={user} />;
}

POEMA 풀스택 아키텍처 사례

POEMA 웹사이트에서 실제로 적용한 RSC 패턴을 소개합니다.

포트폴리오 (Static Generation)

tsx
// app/[locale]/portfolio/page.tsx
import { caseStudies } from '@/data/marketing/case-studies';

export default function PortfolioPage() {
  return (
    <div>
      {caseStudies.map(study => (
        <CaseStudyCard key={study.id} study={study} />
      ))}
    </div>
  );
}

// 빌드 타임에 정적 생성
export const dynamic = 'force-static';

블로그 (ISR)

tsx
// app/[locale]/blog/page.tsx
export const revalidate = 3600; // 1시간마다 재생성

export default async function BlogPage() {
  const posts = await getPosts();
  return <PostsList posts={posts} />;
}

문의 폼 (Server Actions)

tsx
// app/[locale]/contact/actions.ts
'use server';
export async function submitContactForm(formData: FormData) {
  // 이메일 전송, DB 저장 등
  await sendEmail({
    to: 'contact@poema.co.kr',
    subject: 'New Contact',
    body: formData.get('message'),
  });

  return { success: true };
}

마무리

React 서버 컴포넌트는 풀스택 React 애플리케이션의 새로운 기준입니다. 올바른 서버/클라이언트 경계 설계, 효율적인 데이터 페칭, Streaming을 통한 UX 개선이 핵심입니다.

핵심 포인트:

  1. 기본은 서버 컴포넌트, 필요할 때만 'use client'
  2. 컴포지션 패턴으로 번들 크기 최소화
  3. Suspense로 점진적 렌더링
  4. Server Actions로 타입 안전한 서버 통신
  5. 적절한 캐싱 전략으로 성능 최적화

POEMA는 Next.js 14/15와 React 서버 컴포넌트를 활용한 현대적인 풀스택 웹 애플리케이션 개발을 전문으로 합니다.

관련 글