안녕하세요,오늘은 React의 혁신적인 기능인 서버 컴포넌트(React Server Components, RSC)에 대해 자세히 알아보겠습니다. 서버 컴포넌트는 React 개발 방식을 크게 바꿀 수 있는 중요한 기술입니다. 이 글에서는 서버 컴포넌트의 작동 원리, 기존 렌더링 방식과의 차이점, 그리고 실제 개발에서 어떻게 활용할 수 있는지 살펴보겠습니다.
React 서버 컴포넌트는 서버에서 렌더링되고 클라이언트로 전송되는 컴포넌트입니다. 기존의 클라이언트 컴포넌트와 달리, 서버 컴포넌트는 서버에서만 실행되며 클라이언트 번들에 포함되지 않습니다. 이를 통해 더 나은 성능과 사용자 경험을 제공할 수 있습니다.
렌더링 접근 방식의 차이점을 명확히 이해하는 것이 중요합니다
서버 컴포넌트가 어떻게 작동하는지 내부 메커니즘을 살펴보겠습니다:
use client 지시어를 통해 클라이언트/서버 컴포넌트 구분renderToPipeableStream() 함수가 React 트리를 렌더링createFromFetch()가 스트리밍 데이터 수신JSON.parse() reviver 함수로 특수 데이터 타입 복원use() 훅을 사용하여 Promise 렌더링React 서버 컴포넌트에서는 컴포넌트를 서버 또는 클라이언트에서 실행할지 구분해야 합니다
// 서버 컴포넌트 (별도 지시어 없음)
export default function ServerComponent() {
// 서버에서만 실행되는 코드
return <div>서버에서 렌더링됨</div>;
}
'use client'; // 클라이언트 컴포넌트 지시어
import { useState } from 'react';
export default function ClientComponent() {
const [count, setCount] = useState(0);
return (
<div>
<p>카운트: {count}</p>
<button onClick={() => setCount(count + 1)}>증가</button>
</div>
);
}
| 기능 | 서버 컴포넌트 | 클라이언트 컴포넌트 |
|---|---|---|
| 데이터 fetch | ✅ | ✅ (useEffect 사용) |
| 파일 시스템 접근 | ✅ | ❌ |
| 백엔드 리소스 접근 | ✅ | ❌ |
| 환경 변수 접근 (서버) | ✅ | ❌ |
| useState, useEffect | ❌ | ✅ |
| 이벤트 핸들러 | ❌ | ✅ |
| 브라우저 API | ❌ | ✅ |
| 번들 크기에 영향 | ❌ | ✅ |
서버 컴포넌트를 효과적으로 활용하기 위한 패턴을 살펴보겠습니다
// 서버 컴포넌트에서 직접 데이터 페칭
async function ProductPage({ id }) {
// 서버에서 직접 데이터베이스 쿼리
const product = await db.product.findUnique({ where: { id } });
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<ProductActions id={product.id} /> {/* 클라이언트 컴포넌트 */}
</div>
);
}
// 서버 컴포넌트
export default async function Page() {
const data = await fetchData();
return (
<Layout>
<ServerContent data={data} />
<ClientInteractive id={data.id} /> {/* Props로 데이터 전달 */}
</Layout>
);
}
// 클라이언트 컴포넌트
'use client';
function ClientInteractive({ id }) {
const [state, setState] = useState(initialState);
// 상호작용 로직...
return <div>{/* 인터랙티브 UI */}</div>;
}
// 서버 액션 정의
'use server';
export async function submitForm(formData) {
const name = formData.get('name');
const email = formData.get('email');
// 서버에서 데이터 처리
await db.user.create({ data: { name, email } });
// 리다이렉트 또는 상태 반환
return { success: true };
}
// 클라이언트 컴포넌트에서 사용
'use client';
import { submitForm } from './actions';
import { useFormState } from 'react-dom';
export function ContactForm() {
const [state, formAction] = useFormState(submitForm, { success: false });
return (
<form action={formAction}>
<input name="name" required />
<input name="email" type="email" required />
<button type="submit">제출</button>
{state.success && <p>성공적으로 제출되었습니다!</p>}
</form>
);
}
React 서버 컴포넌트의 내부 구현은 매우 복잡하지만, 주요 부분을 간략히 살펴볼 수 있습니다
// 클라이언트 참조인지 확인
function isClientReference(reference) {
return reference.$$typeof === CLIENT_REFERENCE_TAG;
}
// 서버에서 컴포넌트 처리
function attemptResolveElement(request, type, key, ref, props, prevThenableState) {
if (typeof type === "function") {
if (isClientReference(type)) {
// 클라이언트 컴포넌트는 참조만 반환
return [REACT_ELEMENT_TYPE, type, key, props];
}
// 서버 컴포넌트는 실행
prepareToUseHooksForComponent(prevThenableState);
const result = type(props);
// Promise인 경우 지연 래퍼로 감싸기
if (typeof result === "object" && result !== null && typeof result.then === "function") {
return createLazyWrapperAroundWakeable(result);
}
return result;
}
// ...
}
// 청크 직렬화 및 스트리밍
function processModelChunk(request, id, model) {
const json = stringify(model, request.toJSON);
const row = id.toString(16) + ':' + json + '\n';
return stringToChunk(row);
}
// 직렬화 처리기 (JSON.stringify replacer)
function resolveModelToJSON(request, parent, key, value) {
if (value === REACT_ELEMENT_TYPE) {
return '$'; // 심볼 단순화
}
// 서버/클라이언트 컴포넌트 처리
if (typeof value === "object" && value !== null &&
((value).$$typeof === REACT_ELEMENT_TYPE || (value).$$typeof === REACT_LAZY_TYPE)) {
// 컴포넌트 처리 로직...
}
// 클라이언트 참조 처리
if (isClientReference(value)) {
return serializeClientReference(request, parent, key, value);
}
// Promise 처리
if (typeof value.then === "function") {
const promiseId = serializeThenable(request, value);
return serializePromiseID(promiseId);
}
// 기본값 처리
return value;
}
// 청크 처리 및 복원
function parseModelString(response, parentObject, key, value) {
if (value[0] === "$") {
if (value === "$") {
return REACT_ELEMENT_TYPE;
}
switch (value[1]) {
case "L": { // 지연 청크
const id = parseInt(value.substring(2), 16);
const chunk = getChunk(response, id);
return createLazyChunkWrapper(chunk);
}
case "S": { // 심볼
return Symbol.for(value.substring(2));
}
// 기타 타입 처리...
}
}
return value;
}
// Promise 렌더링
function use(usable) {
if (usable !== null && typeof usable === "object" && typeof usable.then === "function") {
const thenable = usable;
// Promise 상태 추적 로직...
switch (thenable.status) {
case "fulfilled":
return thenable.value;
case "rejected":
throw thenable.reason;
default:
// Suspense 로직...
throw SuspenseException;
}
}
// ...
}
app/
layout.js # 루트 레이아웃 (서버 컴포넌트)
page.js # 홈페이지 (서버 컴포넌트)
dashboard/
layout.js # 대시보드 레이아웃 (서버 컴포넌트)
page.js # 대시보드 페이지 (서버 컴포넌트)
actions.js # 서버 액션
client-components/
form.js # 클라이언트 컴포넌트
// app/posts/[id]/page.js
export default async function PostPage({ params }) {
const post = await fetch(`https://api.example.com/posts/${params.id}`)
.then(res => res.json());
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
<CommentSection postId={post.id} />
</article>
);
}
// app/actions.js
'use server';
export async function createPost(formData) {
const title = formData.get('title');
const content = formData.get('content');
// 서버에서 데이터 처리
const result = await db.post.create({
data: { title, content }
});
revalidatePath('/posts');
return result;
}
// app/new-post/page.js
import { createPost } from '../actions';
export default function NewPostPage() {
return (
<form action={createPost}>
<input name="title" required />
<textarea name="content" required />
<button type="submit">포스트 작성</button>
</form>
);
}
React 서버 컴포넌트는 웹 개발의 새로운 패러다임을 제시합니다. 서버와 클라이언트의 경계를 컴포넌트 단위로 세분화하여 각각의 장점을 최대한 활용할 수 있게 해줍니다. 이는 성능 향상, 코드 분리, 개발 경험 개선 등 다양한 이점을 제공합니다.
서버 컴포넌트를 잘 활용하기 위해서는
1. 서버/클라이언트 경계 명확히 구분하기
2. 서버에서 처리할 작업과 클라이언트에서 처리할 작업 구분
3. 데이터 흐름 최적화
4. Next.js와 같은 프레임워크 활용