🌐 웹개발

React Server Components

RSC

서버에서 렌더링되는 React 컴포넌트. 번들 크기 감소, 직접 DB 접근.

📖 상세 설명

React Server Components(RSC)는 React 팀이 개발한 새로운 컴포넌트 패러다임으로, 컴포넌트를 서버에서만 실행하고 렌더링 결과만 클라이언트로 전송합니다. 기존 SSR(Server-Side Rendering)과 달리 RSC는 JavaScript 번들에 포함되지 않아 번들 크기가 획기적으로 줄어듭니다. 무거운 라이브러리(moment.js, lodash 등)를 서버 컴포넌트에서만 사용하면 클라이언트로 전송되지 않습니다.

RSC의 핵심 특징은 async/await를 컴포넌트에서 직접 사용할 수 있다는 점입니다. 서버 컴포넌트는 데이터베이스, 파일 시스템, 내부 API에 직접 접근할 수 있으며 useEffect나 React Query 없이 데이터를 가져옵니다. 이는 Waterfall 요청 문제를 해결하고, 서버와 데이터베이스 간 낮은 레이턴시를 활용할 수 있게 합니다.

Server Components와 Client Components는 명확히 구분됩니다. 클라이언트 컴포넌트는 파일 상단에 'use client' 지시어를 추가해야 하며, useState, useEffect, 이벤트 핸들러, 브라우저 API 사용이 필요할 때 사용합니다. 서버 컴포넌트는 기본값이며, 클라이언트 컴포넌트를 자식으로 포함할 수 있지만 그 반대는 불가능합니다(props로 전달은 가능).

Next.js 13+ App Router가 RSC의 대표적 구현체입니다. page.tsx, layout.tsx, loading.tsx, error.tsx가 기본적으로 서버 컴포넌트로 동작하며, Streaming SSR과 결합하여 점진적 렌더링이 가능합니다. React 19에서는 Server Actions도 도입되어 form 제출과 mutation을 서버에서 직접 처리할 수 있습니다.

💻 코드 예제

Server Component (Next.js App Router)
// app/posts/page.tsx - 서버 컴포넌트 (기본값)
// 'use client' 없으면 서버 컴포넌트
import { prisma } from '@/lib/prisma';
import { formatDate } from 'date-fns'; // 이 라이브러리는 번들에 포함 안 됨!

// async 컴포넌트 - 서버에서만 가능
export default async function PostsPage() {
    // DB 직접 접근 (API 불필요)
    const posts = await prisma.post.findMany({
        include: { author: true },
        orderBy: { createdAt: 'desc' },
        take: 10,
    });

    // 무거운 데이터 처리도 서버에서
    const processedPosts = posts.map(post => ({
        ...post,
        excerpt: post.content.slice(0, 150),
        formattedDate: formatDate(post.createdAt, 'yyyy년 M월 d일'),
    }));

    return (
        <main className="container mx-auto py-8">
            <h1 className="text-3xl font-bold mb-6">최신 게시글</h1>

            <div className="grid gap-4">
                {processedPosts.map(post => (
                    <article key={post.id} className="p-6 border rounded-lg">
                        <h2 className="text-xl font-semibold">{post.title}</h2>
                        <p className="text-gray-600 mt-2">{post.excerpt}...</p>
                        <div className="mt-4 text-sm text-gray-500">
                            <span>{post.author.name}</span>
                            <span className="mx-2">•</span>
                            <span>{post.formattedDate}</span>
                        </div>
                    </article>
                ))}
            </div>
        </main>
    );
}

// 메타데이터도 서버에서 생성
export async function generateMetadata() {
    const totalPosts = await prisma.post.count();

    return {
        title: `게시글 목록 (${totalPosts}개)`,
        description: '최신 게시글을 확인하세요',
    };
}
Client Component + Server Actions
// app/posts/actions.ts - Server Actions
'use server';

import { prisma } from '@/lib/prisma';
import { revalidatePath } from 'next/cache';

export async function createPost(formData: FormData) {
    const title = formData.get('title') as string;
    const content = formData.get('content') as string;

    await prisma.post.create({
        data: { title, content, authorId: 1 }
    });

    // 캐시 무효화 - 목록 페이지 갱신
    revalidatePath('/posts');
}

export async function deletePost(id: number) {
    await prisma.post.delete({ where: { id } });
    revalidatePath('/posts');
}

// -------------------------------------------------

// components/CreatePostForm.tsx - 클라이언트 컴포넌트
'use client';

import { useFormStatus } from 'react-dom';
import { createPost } from '@/app/posts/actions';

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

    return (
        <button
            type="submit"
            disabled={pending}
            className="bg-blue-500 text-white px-4 py-2 rounded disabled:opacity-50"
        >
            {pending ? '저장 중...' : '게시글 작성'}
        </button>
    );
}

export function CreatePostForm() {
    return (
        <form action={createPost} className="space-y-4">
            <input
                name="title"
                placeholder="제목"
                required
                className="w-full border p-2 rounded"
            />
            <textarea
                name="content"
                placeholder="내용"
                required
                rows={5}
                className="w-full border p-2 rounded"
            />
            <SubmitButton />
        </form>
    );
}

// -------------------------------------------------

// app/posts/new/page.tsx - 서버 컴포넌트에서 클라이언트 컴포넌트 사용
import { CreatePostForm } from '@/components/CreatePostForm';

export default function NewPostPage() {
    return (
        <main className="max-w-2xl mx-auto py-8">
            <h1 className="text-2xl font-bold mb-6">새 게시글 작성</h1>
            {/* 클라이언트 컴포넌트 삽입 */}
            <CreatePostForm />
        </main>
    );
}

🗣️ 실무 대화 예시

💼 아키텍처 논의
"기존 React + API 구조에서 Next.js App Router로 전환하면 뭐가 좋아져요?"

"API 엔드포인트 만들고 fetch하는 보일러플레이트가 사라져요. 서버 컴포넌트에서 DB 직접 쿼리하니까 N+1 문제도 쉽게 해결되고, Prisma 같은 ORM을 프론트엔드 코드에서 바로 쓸 수 있어요. 번들 크기도 30-50% 줄어들 수 있습니다."
🔍 코드 리뷰
"이 컴포넌트에서 useState 쓰려는데 'use client'가 없다고 에러나요."

"서버 컴포넌트에서는 Hooks 사용 못 해요. 상태가 필요한 부분만 작은 클라이언트 컴포넌트로 분리하세요. 예를 들어 목록은 서버에서, 좋아요 버튼만 클라이언트로 하면 번들 크기 최소화하면서 인터랙션도 가능해요."
📱 기술 면접
"SSR과 React Server Components의 차이점이 뭔가요?"

"SSR은 서버에서 HTML을 생성하지만 컴포넌트 JavaScript도 클라이언트로 보내서 Hydration합니다. RSC는 서버 컴포넌트의 JavaScript를 아예 보내지 않아요. 클라이언트에는 렌더링 결과(React Tree)만 전송되어 번들 크기가 획기적으로 줄어듭니다."

⚠️ 주의사항

  • 직렬화 가능한 props만 - 서버 컴포넌트에서 클라이언트 컴포넌트로 전달하는 props는 JSON 직렬화 가능해야 합니다. 함수, Date 객체, Map/Set 등은 전달할 수 없습니다.
  • 'use client' 경계 이해 - 클라이언트 컴포넌트가 import하는 모든 것도 클라이언트 번들에 포함됩니다. 클라이언트 컴포넌트는 필요한 최소 범위로 분리하세요.
  • 캐싱 전략 필수 - 서버 컴포넌트가 매 요청마다 DB 쿼리하면 성능이 떨어집니다. revalidate, cache, unstable_cache 등으로 적절히 캐싱하세요.

🔗 관련 용어

📚 더 배우기