
React Server Components 심화: 직렬화 규칙과 실제 패턴
Server Component에서 함수를 props로 전달했더니 에러가 났다. 직렬화 경계를 이해하고 나니 RSC의 진짜 패턴이 보였다.

Server Component에서 함수를 props로 전달했더니 에러가 났다. 직렬화 경계를 이해하고 나니 RSC의 진짜 패턴이 보였다.
왜 CPU는 빠른데 컴퓨터는 느릴까? 80년 전 고안된 폰 노이만 구조의 혁명적인 아이디어와, 그것이 남긴 치명적인 병목현상에 대해 정리했습니다.

ChatGPT는 질문에 답하지만, AI Agent는 스스로 계획하고 도구를 사용해 작업을 완료한다. 이 차이가 왜 중요한지 정리했다.

결제 API 연동이 끝이 아니었다. 중복 결제 방지, 환불 처리, 멱등성까지 고려하니 결제 시스템이 왜 어려운지 뼈저리게 느꼈다.

직접 가기 껄끄러울 때 프록시가 대신 갔다 옵니다. 내 정체를 숨기려면 Forward Proxy, 서버를 보호하려면 Reverse Proxy. 같은 대리인인데 누구 편이냐가 다릅니다.

Next.js 프로젝트에서 Server Component를 쓰기 시작했다. 데이터를 서버에서 가져와서 Client Component로 내려주는 게 전부였다. 간단해 보였다.
// app/page.tsx (Server Component)
async function Home() {
const posts = await fetchPosts();
const handleClick = () => {
console.log('Clicked!');
};
return <PostList posts={posts} onClick={handleClick} />;
}
에러가 터졌다. "Functions cannot be passed directly to Client Components." 뭐가 문제지? 그냥 함수 하나 전달한 건데.
이 에러를 고치면서 깨달았다. Server Component는 React의 패러다임을 완전히 바꾼 거였다. 컴포넌트가 서버에서 실행되고, 그 결과가 네트워크를 타고 클라이언트로 간다. 그 경계에서 직렬화(serialization)가 일어난다. 함수는 직렬화할 수 없다. 그래서 안 된다.
이 간단한 사실을 이해하고 나니, RSC의 모든 패턴이 보이기 시작했다.
Server Component와 Client Component 사이에는 보이지 않는 벽이 있다. 이 벽을 데이터가 넘어갈 때, JSON처럼 변환되어야 한다. 이걸 직렬화(serialization)라고 부른다.
서버에서 클라이언트로 데이터를 보내는 건 택배를 보내는 것과 같다. 택배 상자에 넣을 수 있는 것만 보낼 수 있다.
// ❌ 직렬화할 수 없는 것들
<ClientComponent
onClick={() => {}} // 함수
user={new User('John')} // 클래스 인스턴스
date={new Date()} // Date 객체
promise={fetchData()} // Promise
/>
// ✅ 직렬화할 수 있는 것들
<ClientComponent
count={42} // 숫자
name="John" // 문자열
user={{ name: 'John', id: 1 }} // 순수 객체
tags={['react', 'nextjs']} // 배열
/>
이 규칙이 이해되니까, 왜 React팀이 "use client" 디렉티브를 만들었는지, 왜 children 패턴이 중요한지, 모든 게 연결됐다.
처음엔 "use client"를 "이 컴포넌트는 클라이언트에서 실행돼"라고 이해했다. 틀렸다.
// app/components/Counter.tsx
'use client';
export function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
"use client"의 진짜 의미는 "여기가 직렬화 경계야. 이 파일과 이 파일이 import하는 모든 것은 클라이언트 번들에 포함돼"다.
반대로 "use server"는 Server Action을 만들 때 쓴다.
// app/actions.ts
'use server';
export async function createPost(formData: FormData) {
const title = formData.get('title');
await db.posts.create({ title });
revalidatePath('/posts');
}
이건 "이 함수는 항상 서버에서만 실행돼. 클라이언트에서 호출하면 RPC처럼 동작해"라는 뜻이다. 직렬화 경계를 역방향으로 넘는 거다.
함수를 props로 못 넘긴다면, 어떻게 Server Component에서 Client Component로 로직을 전달할까? 정답은 넘기지 않는 거다. 대신 컴포지션을 쓴다.
// ❌ 이렇게 하지 마
// app/page.tsx (Server)
async function Page() {
const posts = await fetchPosts();
const handleClick = () => {}; // 함수를 만들어서
return <PostList posts={posts} onClick={handleClick} />; // 전달할 수 없다
}
// ✅ 이렇게 해
// app/page.tsx (Server)
async function Page() {
const posts = await fetchPosts();
return (
<PostList>
{posts.map(post => (
<PostCard key={post.id} post={post} />
))}
</PostList>
);
}
// app/components/PostList.tsx (Client)
'use client';
export function PostList({ children }: { children: React.ReactNode }) {
const [filter, setFilter] = useState('all');
return (
<div>
<FilterButtons onFilterChange={setFilter} />
<div>{children}</div>
</div>
);
}
마치 러시아 인형(Matryoshka)처럼, Server Component가 Client Component를 감싸고, 그 안에 다시 Server Component를 넣을 수 있다. Server → Client → Server 컴포지션이 가능하다.
왜냐하면 children은 이미 렌더링된 결과(React Element)이기 때문이다. 함수가 아니라 데이터다. 직렬화할 수 있다.
Server Component의 가장 큰 장점은 컴포넌트 자체가 async 함수가 될 수 있다는 거다.
// app/dashboard/page.tsx
async function DashboardPage() {
// 병렬로 데이터 페칭
const [user, posts, analytics] = await Promise.all([
fetchUser(),
fetchPosts(),
fetchAnalytics(),
]);
return (
<div>
<UserProfile user={user} />
<PostList posts={posts} />
<AnalyticsChart data={analytics} />
</div>
);
}
폭포수(waterfall) 문제도 해결할 수 있다.
// ❌ 폭포수 문제 (순차 실행)
async function Page() {
const user = await fetchUser();
const posts = await fetchPosts(user.id); // user를 기다린 후 실행
return <PostList posts={posts} />;
}
// ✅ Suspense로 스트리밍
async function Page() {
const userPromise = fetchUser();
return (
<div>
<Suspense fallback={<UserSkeleton />}>
<UserProfile userPromise={userPromise} />
</Suspense>
<Suspense fallback={<PostsSkeleton />}>
<PostList userPromise={userPromise} />
</Suspense>
</div>
);
}
async function PostList({ userPromise }: { userPromise: Promise<User> }) {
const user = await userPromise;
const posts = await fetchPosts(user.id);
return <div>{posts.map(post => <PostCard post={post} />)}</div>;
}
같은 Promise를 여러 컴포넌트에 전달하면, React가 자동으로 중복 제거(deduplication)해준다. 한 번만 fetch한다.
Next.js의 fetch는 자동으로 캐싱된다.
// 기본적으로 무한 캐시 (Static)
async function getPost(id: string) {
const res = await fetch(`https://api.example.com/posts/${id}`);
return res.json();
}
// 10초마다 재검증 (ISR)
async function getPost(id: string) {
const res = await fetch(`https://api.example.com/posts/${id}`, {
next: { revalidate: 10 }
});
return res.json();
}
// 캐싱 안 함 (Dynamic)
async function getPost(id: string) {
const res = await fetch(`https://api.example.com/posts/${id}`, {
cache: 'no-store'
});
return res.json();
}
Redis나 CDN 캐시처럼, 데이터를 여러 레벨에서 캐싱한다. 차이점은 이게 컴포넌트 레벨에서 자동으로 일어난다는 거다.
수동 재검증도 가능하다.
// app/actions.ts
'use server';
import { revalidatePath, revalidateTag } from 'next/cache';
export async function createPost(formData: FormData) {
await db.posts.create({ title: formData.get('title') });
// 특정 경로의 캐시 무효화
revalidatePath('/posts');
// 또는 태그로 무효화
revalidateTag('posts');
}
// 데이터 페칭 시 태그 지정
async function getPosts() {
const res = await fetch('https://api.example.com/posts', {
next: { tags: ['posts'] }
});
return res.json();
}
실수로 서버 코드를 클라이언트에서 import하면? 보안 문제가 생긴다. DB 비밀번호가 번들에 포함될 수 있다.
// lib/db.ts
import 'server-only'; // 이 패키지는 클라이언트에서 import하면 에러 발생
export async function query(sql: string) {
// DB 접근 코드
return await db.query(sql);
}
반대로 client-only 패키지도 있다.
// lib/analytics.ts
import 'client-only';
export function trackEvent(name: string) {
if (typeof window !== 'undefined') {
gtag('event', name);
}
}
마치 TypeScript의 타입 체크처럼, 빌드 타임에 실수를 잡아준다.
Server Component의 가장 큰 장점은 클라이언트 번들에 포함되지 않는다는 거다.
// app/page.tsx (Server)
import { marked } from 'marked'; // 31KB 라이브러리
import { format } from 'date-fns'; // 68KB 라이브러리
async function BlogPost({ slug }: { slug: string }) {
const post = await fetchPost(slug);
const html = marked(post.content);
const formattedDate = format(new Date(post.date), 'PPP');
return (
<article>
<h1>{post.title}</h1>
<time>{formattedDate}</time>
<div dangerouslySetInnerHTML={{ __html: html }} />
</article>
);
}
marked와 date-fns가 클라이언트 번들에 안 들어간다. 서버에서 실행되고, HTML 결과만 클라이언트로 간다. 99KB를 아낀 거다.
Client Component에서 같은 걸 하면?
'use client';
import { marked } from 'marked'; // 클라이언트 번들에 포함됨
import { format } from 'date-fns'; // 클라이언트 번들에 포함됨
export function BlogPost({ post }: { post: Post }) {
const html = marked(post.content);
const formattedDate = format(new Date(post.date), 'PPP');
return (
<article>
<h1>{post.title}</h1>
<time>{formattedDate}</time>
<div dangerouslySetInnerHTML={{ __html: html }} />
</article>
);
}
사용자가 99KB를 더 다운로드한다. 모바일에서는 치명적이다.
// ❌ 안 됨
// app/page.tsx (Server)
import { useContext } from 'react';
async function Page() {
const theme = useContext(ThemeContext); // 에러!
return <div className={theme}>...</div>;
}
해결: Context Provider를 Client Component로 만들고, children으로 Server Component 전달
// ✅ 해결
// app/providers.tsx (Client)
'use client';
export function Providers({ children }: { children: React.ReactNode }) {
return <ThemeProvider>{children}</ThemeProvider>;
}
// app/layout.tsx (Server)
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
// ❌ 안 됨
// app/components/ClientWrapper.tsx (Client)
'use client';
import { ServerStats } from './ServerStats'; // Server Component
export function ClientWrapper() {
const [show, setShow] = useState(false);
return show ? <ServerStats /> : null; // 작동은 하지만 ServerStats가 Client Component가 됨
}
해결: children이나 props로 전달
// ✅ 해결
// app/components/ClientWrapper.tsx (Client)
'use client';
export function ClientWrapper({ children }: { children: React.ReactNode }) {
const [show, setShow] = useState(false);
return show ? children : null;
}
// app/page.tsx (Server)
async function Page() {
return (
<ClientWrapper>
<ServerStats /> {/* Server Component로 유지됨 */}
</ClientWrapper>
);
}
// ❌ 안 됨
'use client';
export async function UserProfile() { // 에러!
const user = await fetchUser();
return <div>{user.name}</div>;
}
해결: 부모 Server Component에서 데이터 페칭하거나, useEffect 사용
// ✅ 해결 1: 부모에서 페칭
// app/page.tsx (Server)
async function Page() {
const user = await fetchUser();
return <UserProfile user={user} />;
}
// ✅ 해결 2: useEffect (비추천, waterfall 문제)
'use client';
export function UserProfile() {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser().then(setUser);
}, []);
return user ? <div>{user.name}</div> : <div>Loading...</div>;
}
Server Component를 쓰면서 깨달은 건, 이게 단순한 기능 추가가 아니라는 거다. React의 멘탈 모델을 바꾼 거다.
핵심은 직렬화 경계다. 서버에서 클라이언트로 데이터가 넘어갈 때, JSON처럼 변환되어야 한다. 함수는 안 된다. 클래스도 안 된다. 이 제약이 모든 패턴을 만들었다.
"use client"는 "클라이언트 실행"이 아니라 "직렬화 경계"를 표시한다"use server"는 함수를 역방향으로 직렬화한다 (RPC처럼)그리고 성능 이점이 엄청나다. Server Component는 클라이언트 번들에 안 들어간다. 무거운 라이브러리를 서버에서만 쓸 수 있다. 사용자는 더 빠른 앱을 쓴다.
함수 하나를 props로 못 넘긴다는 제약이, 더 나은 아키텍처를 만들었다. 제약이 때로는 길이 된다는 걸 다시 한 번 배웠다.