Ch.1에서 만든 블로그 프로젝트에 서버/클라이언트 컴포넌트 설계를 적용합니다. 구체적으로:
BlogLayout 리팩터링 - 전체 클라이언트 → 서버 컴포넌트 + CategorySidebar 분리Ch.1의 블로그 레이아웃은 useState 하나 때문에 전체가 클라이언트 컴포넌트였습니다. 인터랙션이 필요한 사이드바만 분리합니다.
// app/components/CategorySidebar.tsx
'use client';
import { useState } from 'react';
export function CategorySidebar() {
const [isOpen, setIsOpen] = useState(true);
return (
<>
<button onClick={() => setIsOpen(!isOpen)}>
카테고리 {isOpen ? '닫기' : '열기'}
</button>
{isOpen && (
<ul>
<li>React</li>
<li>Next.js</li>
<li>TypeScript</li>
</ul>
)}
</>
);
}
// app/blog/layout.tsx - 블로그 레이아웃(이제 서버 컴포넌트)
import { CategorySidebar } from '../components/CategorySidebar';
export default function BlogLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<>
<aside>
<CategorySidebar />
</aside>
{children}
</>
);
}
사이드바와 본문의 구분감을 위해 약간의 스타일링을 추가하겠습니다.
// app/blog/layout.tsx - 블로그 레이아웃(이제 서버 컴포넌트)
import { CategorySidebar } from '../components/CategorySidebar';
export default function BlogLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<section className='flex'>
<aside className='pr-8 mr-8 border-r'>
<CategorySidebar />
</aside>
{children}
</section>
);
}
변경 전후를 비교하면:
| Before | After | |
|---|---|---|
BlogLayout | 클라이언트 컴포넌트 | 서버 컴포넌트 |
CategorySidebar | 레이아웃에 인라인 | 별도 클라이언트 컴포넌트 |
블로그 글 상세 페이지에 좋아요 버튼을 추가합니다. 페이지는 서버 컴포넌트로 유지하고, 인터랙션이 필요한 작은 컴포넌트만 클라이언트로 분리하는 구조입니다.
// app/components/LikeButton.tsx
'use client';
import { useState } from 'react';
export function LikeButton() {
const [isLiked, setIsLiked] = useState(false);
return (
<button onClick={() => setIsLiked(!isLiked)}>
{isLiked ? '❤️' : '🤍'}
</button>
);
}
// app/blog/[slug]/page.tsx - 글 상세 페이지(서버 컴포넌트)
import { LikeButton } from '@/app/components/LikeButton';
export default async function Post({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
return (
<div>
<h1>{slug}</h1>
<p>이 글의 내용이 여기에 표시됩니다.</p>
<LikeButton />
</div>
);
}
페이지 자체는 서버 컴포넌트로 렌더링되고, 브라우저에서 실행되는 인터랙션 코드는 LikeButton 컴포넌트뿐입니다.
글 목록을 실시간으로 필터링하는 검색 기능을 추가합니다. 검색은 사용자 입력에 반응해야 하므로 클라이언트 컴포넌트가 필요합니다.
// app/components/SearchablePostList.tsx
'use client';
import { useState } from 'react';
import Link from 'next/link';
type Post = {
id: number;
slug: string;
title: string;
};
export function SearchablePostList({ posts }: { posts: Post[] }) {
const [query, setQuery] = useState('');
const filtered = posts.filter((post) =>
post.title.toLowerCase().includes(query.toLowerCase()),
);
return (
<>
<input
type='text'
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder='글 검색...'
/>
<ul>
{filtered.map((post) => (
<li key={post.id}>
<Link href={`/blog/${post.slug}`}>{post.title}</Link>
</li>
))}
</ul>
</>
);
}
// app/blog/page.tsx - 글 목록 페이지(서버 컴포넌트)
import { SearchablePostList } from '../components/SearchablePostList';
const posts = [
{ id: 1, slug: 'nextjs-routing', title: 'Next.js 라우팅 이해하기' },
{ id: 2, slug: 'react-server-components', title: 'React 서버 컴포넌트란?' },
];
export default function Blog() {
return (
<div>
<h1>글 목록</h1>
<SearchablePostList posts={posts} />
</div>
);
}
posts)는 서버에서 준비한 뒤, React가 전달할 수 있는 형태로 클라이언트 컴포넌트에 props로 넘겨줍니다. 이렇게 하면 데이터 조회는 서버에서 처리하고, 검색 같은 인터랙션은 클라이언트에서 처리하는 구조로 역할을 나눌 수 있습니다.
완성된 프로젝트의 서버/클라이언트 경계를 확인해볼까요?
app/
├── layout.tsx [서버] 루트 레이아웃
├── page.tsx [서버] 홈 페이지
├── about/
│ └── page.tsx [서버] 소개 페이지
└── blog/
├── [slug]/
│ └── page.tsx [서버] 글 상세 페이지
├── layout.tsx [서버] 블로그 레이아웃 ✅ (리팩터링 결과)
└── page.tsx [서버] 글 목록 페이지 (데이터 준비)
├── components/
│ ├── CategorySidebar.tsx [클라이언트] 'use client' - 카테고리 토글
│ ├── LikeButton.tsx [클라이언트] 'use client' - 좋아요 상태
│ └── SearchablePostList.tsx [클라이언트] 'use client' - 검색 입력
컴포넌트 트리로 보면:
RootLayout [서버]
└── BlogLayout [서버]
├── CategorySidebar [클라이언트] ← 'use client' 경계
└── Blog [서버] 글 목록 페이지
│ └── SearchablePostList [클라이언트] ← 'use client' 경계
└── Post [서버] 글 상세 페이지
└── LikeButton [클라이언트] ← 'use client' 경계
'use client' 경계가 모두 트리의 끝 가까이 위치한 것을 확인할 수 있습니다.
| 설계 판단 | 근거 |
|---|---|
| BlogLayout → 서버 컴포넌트 | 인터랙션이 필요한 사이드바만 분리하면 레이아웃은 서버 유지 가능 |
| CategorySidebar → 클라이언트 | useState로 토글 상태 관리 → 클라이언트 필수 |
| LikeButton → 클라이언트 | useState + onClick → 클라이언트 필수 |
| SearchablePostList → 클라이언트 | useState + onChange로 실시간 필터링 → 클라이언트 필수 |
| Post → 서버 컴포넌트 | 데이터를 표시할 뿐 인터랙션 없음 → 서버 유지 |
posts 데이터를 props로 전달 | 검색 필터링이 클라이언트에서 실행되기 때문 |
이번 챕터에서는 서버 컴포넌트와 클라이언트 컴포넌트를 언제, 왜 구분해서 쓰는지를 집중적으로 다뤘습니다.
useState, onClick 등)을 담당합니다.'use client' 경계는 트리 끝에 배치 - 클라이언트 경계를 최대한 작게 만들어서, 서버에서 처리할 수 있는 부분을 최대화하는 설계 원칙을 배웠습니다.children이나 다른 prop을 활용해 서버 컴포넌트를 클라이언트 컴포넌트 안에 배치하는 방법을 익혔습니다.