React Server Components (RSC) 심층 가이드: Next.js App Router 활용
React Server Components(RSC)의 개념, 동작 원리, 장점, 그리고 Next.js App Router에서 RSC를 활용하는 방법을 심층적으로 탐구합니다. 프론트엔드 개발의 새로운 패러다임을 이해하고 실전 코드 예제를 통해 RSC를 마스터하세요.
React Server Components (RSC) 심층 가이드: Next.js App Router 활용
현대 웹 개발 환경은 사용자 경험과 성능 최적화에 대한 요구가 끊임없이 증가하고 있습니다. 이러한 요구에 발맞춰 React 생태계는 혁신적인 변화를 시도하고 있으며, 그 중심에 바로 React Server Components (RSC)가 있습니다. RSC는 클라이언트-사이드 렌더링(CSR)과 서버-사이드 렌더링(SSR)의 장점을 결합하여 개발자에게 더 나은 성능과 유연성을 제공합니다. 본 가이드에서는 RSC의 개념, 동작 원리, 주요 장점, 그리고 Next.js App Router에서 RSC를 효과적으로 활용하는 방법에 대해 심층적으로 탐구하며 실전 코드 예제를 통해 RSC를 마스터하는 데 도움을 드리고자 합니다.
React Server Components (RSC)란 무엇인가요?
React Server Components는 React 18부터 도입된 새로운 아키텍처로, 컴포넌트의 렌더링 위치를 서버와 클라이언트 중 어디로 할지 개발자가 직접 선택할 수 있도록 합니다. 기존 React 애플리케이션은 대부분 클라이언트에서 JavaScript를 다운로드하여 렌더링하는 CSR 방식이 주를 이루었으며, SSR은 초기 로딩 성능 개선을 위해 서버에서 HTML을 미리 생성하여 제공하는 방식이었습니다. RSC는 이 두 가지 방식의 단점을 보완하고 장점을 극대화하기 위해 탄생했습니다.
RSC의 핵심 아이디어는 서버에서 렌더링된 컴포넌트 트리를 클라이언트로 전송하고, 클라이언트는 이 트리를 바탕으로 UI를 구성하는 것입니다. 이때 서버 컴포넌트는 오직 React 컴포넌트 그래프의 일부분만을 렌더링하며, 클라이언트에서 필요한 최소한의 JavaScript 번들만을 전송합니다. 이를 통해 웹 애플리케이션의 초기 로딩 속도를 획기적으로 개선하고, 클라이언트의 JavaScript 번들 크기를 줄이는 데 기여합니다.
RSC의 주요 장점과 한계
React Server Components는 웹 애플리케이션 개발에 여러 가지 강력한 이점을 제공하지만, 동시에 고려해야 할 한계점도 존재합니다.
RSC의 장점
- 번들 사이즈 감소 및 초기 로딩 성능 개선:
- 서버 컴포넌트는 클라이언트로 JavaScript 코드를 전송하지 않으므로, 클라이언트 번들 크기가 크게 줄어듭니다. 이는 특히 초기 로딩 시 다운로드 및 파싱해야 할 JavaScript 양이 감소하여 페이지 로딩 속도가 빨라지는 효과를 가져옵니다.
- 무거운 라이브러리(예: 마크다운 파서, 데이터베이스 클라이언트)를 서버 컴포넌트에서 직접 사용할 수 있어, 이들의 코드가 클라이언트 번들에 포함되지 않습니다.
- 데이터 페칭 최적화:
- 서버 컴포넌트는 서버 환경에서 직접 데이터베이스에 접근하거나 API를 호출할 수 있습니다. 이는 클라이언트에서 데이터를 페칭할 때 발생할 수 있는 네트워크 왕복(round-trip) 횟수를 줄여주고, 보안적으로 민감한 데이터 접근 로직을 클라이언트로부터 분리할 수 있게 합니다.
- 데이터 페칭 로직이 컴포넌트 내부에 co-located 되어 개발 편의성이 향상됩니다.
- 보안 강화:
- 데이터베이스 연결 정보나 API 키와 같은 민감한 정보는 서버 컴포넌트 내부에 안전하게 보관될 수 있습니다. 클라이언트로 노출될 위험이 없어 보안성이 향상됩니다.
- 스트리밍 및 점진적 렌더링:
- RSC는 Suspense와 함께 사용하여 서버에서 렌더링된 컴포넌트를 점진적으로 클라이언트에 스트리밍할 수 있습니다. 이는 사용자에게 콘텐츠가 더 빨리 나타나는 것처럼 보이게 하여 체감 성능을 향상시킵니다.
RSC의 한계
- 클라이언트 상호작용 제한:
- 서버 컴포넌트는
useState,useEffect,onClick과 같은 클라이언트 전용 React Hook이나 이벤트 핸들러를 사용할 수 없습니다. 이는 서버 컴포넌트가 클라이언트에서 재렌더링되지 않고, 오직 서버에서만 렌더링되기 때문입니다.
- 서버 컴포넌트는
- 클라이언트-사이드 JS 의존성:
- 브라우저 API (예:
window,localStorage)에 직접 접근할 수 없습니다.
- 브라우저 API (예:
- 학습 곡선:
- 기존 React 개발 방식에 익숙한 개발자에게는 서버와 클라이언트 컴포넌트의 명확한 분리 및 상호작용 방식에 대한 새로운 이해가 필요합니다.
Client Components와 Server Components의 구분
RSC 아키텍처에서 가장 중요한 개념은 Client Components와 Server Components를 명확히 구분하는 것입니다. Next.js App Router는 기본적으로 모든 컴포넌트를 Server Component로 간주합니다.
Server Components
- 기본 설정: Next.js App Router에서는 모든 컴포넌트가 기본적으로 Server Component로 동작합니다. 별도의 지시어 없이
page.tsx,layout.tsx등은 모두 Server Component입니다. - 특징:
- 서버에서 렌더링되며, 클라이언트로 JavaScript 번들을 전송하지 않습니다.
-
async/await를 사용하여 서버에서 직접 데이터 페칭을 할 수 있습니다. - Node.js 환경의 API (예: 파일 시스템 접근, 데이터베이스 쿼리)를 직접 사용할 수 있습니다.
-
useState,useEffect,useRef,onClick등 클라이언트 전용 React Hook 및 이벤트 핸들러를 사용할 수 없습니다. -
'use client'지시어 없이 선언됩니다.
Client Components
- 선언 방식: 파일 상단에
'use client'지시어를 명시적으로 추가해야 합니다. - 특징:
- 클라이언트에서 렌더링되며, 클라이언트 번들에 JavaScript 코드가 포함됩니다.
-
useState,useEffect등 클라이언트 전용 React Hook과 이벤트 핸들러를 사용하여 인터랙티브한 UI를 구현할 수 있습니다. - 브라우저 API (예:
window,localStorage)에 접근할 수 있습니다. - 서버에서 데이터 페칭을 직접 수행하기보다는, 서버 컴포넌트로부터 데이터를 props로 받거나 클라이언트에서 API를 호출해야 합니다.
언제 무엇을 사용해야 할까요?
다음 표는 Client Components와 Server Components의 주요 특징을 비교합니다.
| 기능/특징 | Server Components | Client Components |
|---|---|---|
| 렌더링 위치 | 서버 (Node.js 환경) | 클라이언트 (브라우저) |
| JavaScript 번들 | 클라이언트로 전송되지 않음 | 클라이언트로 전송됨 |
| 데이터 페칭 | async/await로 직접 DB/API 호출 가능 | 서버 컴포넌트로부터 props 받거나 클라이언트에서 API 호출 |
| React Hooks | useState, useEffect 등 사용 불가 | useState, useEffect 등 사용 가능 |
| 이벤트 핸들러 | onClick, onChange 등 사용 불가 | onClick, onChange 등 사용 가능 |
| 브라우저 API | window, localStorage 등 사용 불가 | window, localStorage 등 사용 가능 |
| 보안 | 민감한 정보(API 키, DB 연결) 서버에 유지 가능 | 민감한 정보 노출 위험 |
| 파일 선언 | 기본값 (지시어 없음) | 'use client' 지시어 필요 |
일반적인 권장 사항:
- Server Component 우선: 가능한 한 Server Component를 사용하세요. 이는 성능과 보안에 이점을 줍니다.
- Client Component는 인터랙션이 필요할 때만:
useState,useEffect, 이벤트 핸들러 등 클라이언트 전용 기능이 필요한 경우에만 Client Component로 전환하세요. - 데이터 페칭: Server Component에서 데이터를 페칭하고, 필요하다면 Client Component에 props로 전달하세요.
Next.js App Router와 RSC
Next.js 13부터 도입된 App Router는 React Server Components를 핵심 아키텍처로 채택하고 있습니다. App Router의 모든 컴포넌트(페이지, 레이아웃, 컴포넌트)는 기본적으로 Server Component로 간주됩니다. 이는 개발자가 별도의 설정 없이 RSC의 이점을 누릴 수 있도록 합니다.
App Router의 RSC 동작 방식
- 초기 요청: 사용자가 페이지에 처음 접근하면, Next.js 서버는 해당 페이지와 관련된 모든 Server Component를 렌더링합니다.
- 데이터 페칭: Server Component 내에서 필요한 데이터를 직접 페칭합니다. 이는 네트워크 지연을 최소화하고 병렬 처리를 통해 효율성을 높입니다.
- HTML 및 RSC 페이로드 생성: 서버는 렌더링된 Server Component의 HTML과 함께, Client Component가 필요로 하는 JavaScript 번들 정보 및 Server Component의 "React Server Component 페이로드"를 생성합니다. 이 페이로드에는 클라이언트에서 UI를 재구성하는 데 필요한 정보가 담겨 있습니다.
- 클라이언트로 전송: 생성된 HTML, JavaScript 번들, 그리고 RSC 페이로드가 클라이언트로 전송됩니다.
- 클라이언트 렌더링: 클라이언트는 HTML을 즉시 표시하여 사용자에게 빠른 초기 렌더링 경험을 제공합니다. 이후 JavaScript 번들이 로드되면, React는 RSC 페이로드를 사용하여 UI를 하이드레이션(hydrate)하고 인터랙티브한 Client Component를 활성화합니다.
이러한 과정을 통해 Next.js App Router는 초기 로딩 성능을 최적화하고, 클라이언트와 서버 간의 작업을 효율적으로 분배합니다.
실전 코드 예제: RSC와 Client Components 함께 사용하기
이제 실제 코드 예제를 통해 Server Components와 Client Components를 Next.js App Router 환경에서 어떻게 함께 사용하는지 살펴보겠습니다.
1. Server Component로 데이터 페칭 및 UI 렌더링
app/page.tsx는 기본적으로 Server Component입니다. 여기서는 서버에서 사용자 목록을 가져와 표시하는 예제를 구현합니다.
// app/page.tsx
import { User } from '@/types/user'; // 사용자 타입을 정의했다고 가정
async function getUsers(): Promise<User[]> {
// 실제 API 호출 또는 데이터베이스 쿼리
const res = await fetch('https://jsonplaceholder.typicode.com/users');
if (!res.ok) {
throw new Error('Failed to fetch users');
}
return res.json();
}
export default async function HomePage() {
const users = await getUsers(); // 서버에서 직접 데이터 페칭
return (
<div className="container mx-auto p-4">
<h1 className="text-2xl font-bold mb-4">사용자 목록 (Server Component)</h1>
<ul className="list-disc pl-5">
{users.map((user) => (
<li key={user.id} className="mb-2">
<span className="font-semibold">{user.name}</span> ({user.email})
</li>
))}
</ul>
{/* Client Component를 Server Component 내부에 import하여 사용 */}
<InteractiveCounter />
</div>
);
}
// types/user.ts (예시)
export interface User {
id: number;
name: string;
username: string;
email: string;
address: {
street: string;
suite: string;
city: string;
zipcode: string;
geo: {
lat: string;
lng: string;
};
};
phone: string;
website: string;
company: {
name: string;
catchPhrase: string;
bs: string;
};
}이 HomePage 컴포넌트는 서버에서 getUsers 함수를 호출하여 데이터를 가져오고, 이를 바탕으로 UI를 렌더링합니다. 클라이언트로 전송되는 JavaScript는 최소화됩니다.
2. Client Component로 인터랙티브한 UI 구현
이제 버튼 클릭 시 숫자가 증가하는 카운터 컴포넌트를 만들어보겠습니다. 이는 인터랙션이 필요하므로 Client Component로 작성해야 합니다.
// components/InteractiveCounter.tsx
'use client'; // 이 파일은 Client Component임을 명시합니다.
import React, { useState } from 'react';
export default function InteractiveCounter() {
const [count, setCount] = useState(0);
return (
<div className="mt-8 p-4 border rounded-lg shadow-md bg-white">
<h2 className="text-xl font-semibold mb-2">인터랙티브 카운터 (Client Component)</h2>
<p className="text-lg mb-4">현재 카운트: <span className="font-bold text-blue-600">{count}</span></p>
<button
onClick={() => setCount(count + 1)}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors duration-200"
>
증가
</button>
<button
onClick={() => setCount(count - 1)}
className="ml-2 px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600 transition-colors duration-200"
>
감소
</button>
</div>
);
}
위에서 작성한 InteractiveCounter 컴포넌트는 useState 훅을 사용하므로 'use client' 지시어가 반드시 필요합니다. 이 컴포넌트는 클라이언트에서 JavaScript를 다운로드하여 실행됩니다.
3. Server Component와 Client Component 조합
Server Component인 HomePage에서 Client Component인 InteractiveCounter를 import 하여 사용하는 것은 전혀 문제가 되지 않습니다.
// app/page.tsx (업데이트)
import { User } from '@/types/user';
import InteractiveCounter from '@/components/InteractiveCounter'; // Client Component import
async function getUsers(): Promise<User[]> {
// ... (이전과 동일)
const res = await fetch('https://jsonplaceholder.typicode.com/users');
if (!res.ok) {
throw new Error('Failed to fetch users');
}
return res.json();
}
export default async function HomePage() {
const users = await getUsers();
return (
<div className="container mx-auto p-4">
<h1 className="text-2xl font-bold mb-4">사용자 목록 (Server Component)</h1>
<ul className="list-disc pl-5">
{users.map((user) => (
<li key={user.id} className="mb-2">
<span className="font-semibold">{user.name}</span> ({user.email})
</li>
))}
</ul>
{/* Server Component 내부에서 Client Component 렌더링 */}
<InteractiveCounter />
</div>
);
}
이렇게 Server Component 내부에 Client Component를 배치하면, Server Component는 서버에서 렌더링되고, 해당 Client Component는 클라이언트로 전송되어 하이드레이션됩니다. 이는 RSC 아키텍처의 핵심적인 강점 중 하나로, 필요한 부분만 클라이언트에서 인터랙티브하게 만들 수 있도록 합니다.
주의사항: Client Component 내에서 Server Component를 직접 import 하여 사용하는 것은 불가능합니다. Client Component는 클라이언트에서 실행되기 때문에 서버에서만 렌더링되는 Server Component를 가져올 수 없습니다. 하지만 Server Component를 Client Component의 자식으로 props로 전달하는 패턴은 가능합니다. 이를 "Server Component as a prop"이라고 합니다.
// components/ClientWrapper.tsx
'use client';
import React from 'react';
// children은 Server Component가 될 수 있습니다.
export default function ClientWrapper({ children }: { children: React.ReactNode }) {
// Client Component 내부 로직 (예: useState, useEffect)
return (
<div className="border-dashed border-2 border-gray-400 p-4 mt-4">
<p className="text-gray-600 mb-2">이 컨텐츠는 Client Wrapper 안에 있습니다.</p>
{children} {/* Server Component를 props로 받아 렌더링 */}
</div>
);
}
// app/page.tsx (업데이트)
import ClientWrapper from '@/components/ClientWrapper';
// ... (기존 코드)
export default async function HomePage() {
const users = await getUsers();
return (
// ... (기존 코드)
<InteractiveCounter />
<ClientWrapper>
{/* ClientWrapper의 children으로 Server Component를 전달 */}
<h3 className="text-xl font-bold mt-4">Server Component 자식</h3>
<p>이 텍스트는 Server Component에서 렌더링됩니다.</p>
</ClientWrapper>
// ... (기존 코드)
);
}
이 패턴을 통해 Server Component는 여전히 서버에서 렌더링되지만, Client Component에 의해 "감싸져" 클라이언트에서 인터랙티브한 부분과 함께 표시될 수 있습니다.
RSC 개발 시 고려사항 및 Best Practices
RSC를 효과적으로 활용하기 위한 몇 가지 고려사항과 모범 사례를 정리했습니다.
- Server Component 우선주의: 가능한 모든 컴포넌트를 Server Component로 시작하세요. 클라이언트 인터랙션, 브라우저 API 접근, 또는 React Hooks가 명확하게 필요한 경우에만
'use client'지시어를 추가하여 Client Component로 전환합니다. - 데이터 페칭: 모든 데이터 페칭 로직은 Server Component에서 수행하는 것이 가장 효율적입니다.
fetchAPI는 자동으로 요청을 중복 제거하고 캐싱하여 성능을 최적화합니다. - Client Component의 경계 최소화:
'use client'를 사용하는 파일은 해당 파일과 그 자식 컴포넌트들을 모두 Client Component로 만듭니다. 따라서 인터랙션이 필요한 최소한의 부분에만'use client'를 적용하고, 가능한 한 트리의 상단에 두지 않도록 합니다. - Suspense 활용: 복잡한 UI나 느린 데이터 페칭이 있는 경우,
<Suspense>컴포넌트를 사용하여 로딩 상태를 관리하고 사용자 경험을 개선하세요. Suspense는 Server Component와 Client Component 모두에서 사용할 수 있습니다. - Props 전달: Server Component에서 Client Component로 데이터를 전달할 때는 직렬화 가능한(serializable) 데이터만 전달해야 합니다. 함수, 클래스 인스턴스, Date 객체 등은 Client Component로 직접 전달할 수 없습니다. 필요한 경우 JSON 직렬화 가능한 형태로 변환하여 전달해야 합니다.
- 보안: 민감한 정보(API 키, 데이터베이스 자격 증명)는 Server Component 내에서만 사용하고, 절대 Client Component로 전달하지 마세요.
-
node_modules와'use client': Client Component에서 import하는 모든 모듈은 클라이언트 번들에 포함됩니다. 따라서node_modules내부의 라이브러리도 클라이언트 번들에 포함될 수 있으므로, 불필요한 라이브러리가 포함되지 않도록 주의해야 합니다.
마무리
React Server Components는 현대 웹 개발의 성능과 사용자 경험을 한 단계 끌어올리는 중요한 기술입니다. Next.js App Router와 함께 RSC를 이해하고 활용함으로써, 개발자들은 더 빠르고 효율적이며 보안에 강한 애플리케이션을 구축할 수 있습니다. Server Components와 Client Components의 명확한 구분 및 적절한 활용은 초기 로딩 성능 최적화, JavaScript 번들 크기 감소, 그리고 효율적인 데이터 페칭 전략 수립에 필수적입니다. RSC는 React 생태계의 미래를 형성하는 핵심 요소이며, 이 새로운 패러다임에 대한 깊은 이해는 프론트엔드 개발자로서의 경쟁력을 한층 강화시켜 줄 것입니다.
관련 게시글
Progressive Web App (PWA) 구축 실전: Next.js와 React를 활용한 PWA 개발 가이드
Next.js와 React 기반 PWA 구축 실전 가이드. Service Worker, Web App Manifest 설정 및 next-pwa 활용법을 통해 사용자 경험을 향상시키세요.
CSS Container Queries: 반응형 웹 디자인의 새로운 지평
CSS Container Queries를 활용하여 컴포넌트 기반 반응형 웹 디자인을 구현하는 방법을 심층적으로 알아봅니다. React, Next.js 환경에서 실전 예제를 통해 강력한 기능을 경험하세요.
Next.js Middleware Routing Deep Dive
Next.js Middleware를 활용하여 강력한 라우팅 제어, 인증, 국제화 및 요청/응답 조작 방법을 심층적으로 알아봅니다. 실전 코드 예제로 Next.js 애플리케이션의 유연성을 극대화하세요.