서론: Next.js의 새로운 패러다임
Next.js는 React 기반의 프레임워크로, 버전 13부터 도입된 App Router와 함께 서버 컴포넌트(React Server Components)를 정식으로 지원하기 시작했습니다. 이는 웹 개발 패러다임의 큰 변화를 가져왔으며, 프론트엔드 개발자들에게 서버와 클라이언트의 경계를 더욱 유연하게 활용할 수 있는 가능성을 열어주었습니다.
기존의 React 애플리케이션은 주로 클라이언트 측에서 실행되는 코드로 구성되었습니다. Next.js의 기존 Pages Router에서도 서버 사이드 렌더링(SSR)이나 정적 생성(SSG)을 지원했지만, 컴포넌트 자체는 여전히 클라이언트에서 하이드레이션(hydration) 과정을 거쳐 완전히 상호작용 가능한 상태가 되었습니다.
하지만 이제 React와 Next.js는 컴포넌트를 서버에서 실행할지, 클라이언트에서 실행할지를 개발자가 선택할 수 있게 해주었습니다. 이는 단순한 기술적 변화를 넘어서 애플리케이션 설계와 성능 최적화에 대한 새로운 접근법을 제시합니다.
이 블로그 포스트에서는 Next.js의 서버 컴포넌트와 클라이언트 컴포넌트의 차이점, 각각의 장단점, 그리고 실제 프로젝트에서 어떻게 효과적으로 활용할 수 있는지에 대해 심층적으로 알아보겠습니다.
또한 실습 예제를 통해 두 가지 컴포넌트 타입을 어떻게 조합하여 최적의 웹 애플리케이션을 구축할 수 있는지 배워보겠습니다.
서버 컴포넌트와 클라이언트 컴포넌트의 기본 개념
서버 컴포넌트(Server Component)
서버 컴포넌트는 서버에서만 실행되는 React 컴포넌트입니다. Next.js 13 이상의 App Router에서는 모든 컴포넌트가 기본적으로 서버 컴포넌트로 취급됩니다. 이는 이전의 Pages Router와는 완전히 다른 접근 방식입니다.
서버 컴포넌트의 핵심 특징은 다음과 같습니다:
- 서버에서만 실행되며, 클라이언트로 JavaScript 코드가 전송되지 않습니다.
- 서버의 리소스(데이터베이스, 파일 시스템 등)에 직접 접근할 수 있습니다.
- React의
useState
,useEffect
와 같은 클라이언트 사이드 훅을 사용할 수 없습니다. - 브라우저 API(localStorage, window 객체 등)에 접근할 수 없습니다.
- 렌더링 결과만 HTML로 클라이언트에 전송됩니다.
// app/page.js - 기본적으로 서버 컴포넌트
export default async function HomePage() {
// 서버에서 직접 데이터를 가져올 수 있음
const response = await fetch('https://api.example.com/data');
const data = await response.json();
return (
<div>
<h1>서버 컴포넌트 예제</h1>
<ul>
{data.map(item => (
<li key={item.id}>{item.title}</li>
))}
</ul>
</div>
);
}
위 코드에서 HomePage
컴포넌트는 서버에서 실행되므로, 클라이언트로 JavaScript 코드가 전송되지 않습니다. 데이터 페칭 로직은 서버에서만 실행되고, 최종 HTML만 클라이언트로 전송됩니다.
클라이언트 컴포넌트(Client Component)
클라이언트 컴포넌트는 전통적인 React 컴포넌트처럼 클라이언트(브라우저)에서 실행됩니다. Next.js의 App Router에서 클라이언트 컴포넌트를 사용하려면 파일 상단에 'use client'
지시어를 추가해야 합니다.
클라이언트 컴포넌트의 핵심 특징은 다음과 같습니다:
- 브라우저에서 실행되며, JavaScript 코드가 클라이언트로 전송됩니다.
- React의 모든 기능(
useState
,useEffect
, 이벤트 핸들러 등)을 사용할 수 있습니다. - 브라우저 API(localStorage, window 객체 등)에 접근할 수 있습니다.
- 사용자 상호작용을 처리할 수 있습니다.
- 서버 컴포넌트를 자식으로 가질 수 없습니다(중요한 제약사항).
'use client'
// 위 지시어를 통해 클라이언트 컴포넌트로 지정
import { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<h2>카운터: {count}</h2>
<button onClick={() => setCount(count + 1)}>증가</button>
</div>
);
}
위 예제에서 Counter
컴포넌트는 'use client'
지시어를 통해 클라이언트 컴포넌트로 지정되었습니다. 이 컴포넌트는 상태 관리와 이벤트 처리를 위해 클라이언트에서 실행됩니다.
서버 컴포넌트의 특징과 장점
1. 향상된 성능
서버 컴포넌트의 가장 큰 장점은 클라이언트로 전송되는 JavaScript 번들 크기를 줄일 수 있다는 것입니다. 서버 컴포넌트의 코드는 클라이언트에 전송되지 않고, 렌더링 결과만 HTML로 전송되기 때문입니다.
// 이 컴포넌트의 코드는 클라이언트로 전송되지 않음
import { heavyServerLibrary } from 'some-big-library';
export default function ServerComponent() {
const processedData = heavyServerLibrary.process();
return <div>{processedData}</div>;
}
위 예제에서 heavyServerLibrary
는 서버에서만 실행되며, 클라이언트에는 결과 HTML만 전송됩니다. 이는 특히 데이터 처리 로직이 복잡하거나 대용량 라이브러리를 사용하는 경우 큰 성능 이점을 제공합니다.
2. 직접적인 데이터 접근
서버 컴포넌트는 서버의 리소스에 직접 접근할 수 있습니다. 이는 API 레이어를 거치지 않고도 데이터베이스나 파일 시스템에 접근할 수 있다는 것을 의미합니다.
import { db } from '@/lib/database';
import { readFile } from 'fs/promises';
export default async function BlogPost({ id }) {
// 데이터베이스에 직접 접근
const post = await db.posts.findUnique({ where: { id } });
// 파일 시스템에 직접 접근
const markdown = await readFile(`./content/${post.slug}.md`, 'utf-8');
return (
<article>
<h1>{post.title}</h1>
<div>{markdown}</div>
</article>
);
}
이 기능은 백엔드 API를 별도로 구축하지 않고도 데이터에 접근할 수 있게 해주므로, 풀스택 애플리케이션 개발을 크게 단순화합니다.
3. 보안 강화
서버 컴포넌트에서 사용되는 민감한 정보(API 키, 데이터베이스 자격 증명 등)는 클라이언트에 노출되지 않습니다. 이는 보안 측면에서 큰 이점을 제공합니다.
// API 키는 클라이언트에 노출되지 않음
const API_KEY = process.env.API_KEY;
export default async function SecureComponent() {
const data = await fetch(`https://api.service.com/data?key=${API_KEY}`);
const result = await data.json();
return <div>{result.displayValue}</div>;
}
4. SEO 최적화
서버 컴포넌트는 서버에서 렌더링되므로, 검색 엔진 크롤러가 컨텐츠를 쉽게 인덱싱할 수 있습니다. 이는 SEO 측면에서 중요한 이점입니다.
클라이언트 컴포넌트의 특징과 장점
1. 상호작용 가능한 UI
클라이언트 컴포넌트의 가장 큰 장점은 사용자 상호작용을 처리할 수 있다는 것입니다. 폼, 버튼, 애니메이션 등 사용자와 상호작용하는 UI 요소는 클라이언트 컴포넌트로 구현해야 합니다.
'use client'
import { useState } from 'react';
export default function ContactForm() {
const [formData, setFormData] = useState({
name: '',
email: '',
message: ''
});
const handleChange = (e) => {
setFormData({ ...formData, [e.target.name]: e.target.value });
};
const handleSubmit = async (e) => {
e.preventDefault();
// 폼 제출 로직
await fetch('/api/contact', {
method: 'POST',
body: JSON.stringify(formData)
});
alert('메시지가 전송되었습니다!');
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
name="name"
value={formData.name}
onChange={handleChange}
placeholder="이름"
/>
<input
type="email"
name="email"
value={formData.email}
onChange={handleChange}
placeholder="이메일"
/>
<textarea
name="message"
value={formData.message}
onChange={handleChange}
placeholder="메시지"
/>
<button type="submit">전송</button>
</form>
);
}
2. 브라우저 API 접근
클라이언트 컴포넌트는 localStorage, sessionStorage, window 객체 등 브라우저 API에 접근할 수 있습니다.
'use client'
import { useEffect, useState } from 'react';
export default function ThemeToggle() {
const [isDark, setIsDark] = useState(false);
useEffect(() => {
// localStorage 접근
const savedTheme = localStorage.getItem('theme');
if (savedTheme === 'dark') {
setIsDark(true);
document.documentElement.classList.add('dark-mode');
}
}, []);
const toggleTheme = () => {
setIsDark(!isDark);
if (!isDark) {
document.documentElement.classList.add('dark-mode');
localStorage.setItem('theme', 'dark');
} else {
document.documentElement.classList.remove('dark-mode');
localStorage.setItem('theme', 'light');
}
};
return (
<button onClick={toggleTheme}>
{isDark ? '라이트 모드로 전환' : '다크 모드로 전환'}
</button>
);
}
3. 클라이언트 사이드 라이브러리 사용
차트, 애니메이션, 인터랙티브 맵 등 클라이언트 사이드 라이브러리를 사용해야 하는 경우 클라이언트 컴포넌트가 필요합니다.
'use client'
import { useEffect, useRef } from 'react';
import Chart from 'chart.js/auto';
export default function AnalyticsChart({ data }) {
const chartRef = useRef(null);
useEffect(() => {
if (chartRef.current) {
const chart = new Chart(chartRef.current, {
type: 'bar',
data: {
labels: data.labels,
datasets: [{
label: '월별 방문자 수',
data: data.values,
backgroundColor: 'rgba(75, 192, 192, 0.6)'
}]
}
});
return () => chart.destroy();
}
}, [data]);
return <canvas ref={chartRef} />;
}
4. 즉각적인 UI 업데이트
사용자 입력에 즉시 반응해야 하는 기능(자동 완성, 실시간 유효성 검사 등)은 클라이언트 컴포넌트에서 구현하는 것이 좋습니다.
'use client'
import { useState } from 'react';
export default function SearchBox() {
const [query, setQuery] = useState('');
const [suggestions, setSuggestions] = useState([]);
const handleChange = async (e) => {
const value = e.target.value;
setQuery(value);
if (value.length > 2) {
const res = await fetch(`/api/suggestions?q=${value}`);
const data = await res.json();
setSuggestions(data);
} else {
setSuggestions([]);
}
};
return (
<div className="search-container">
<input
type="text"
value={query}
onChange={handleChange}
placeholder="검색어를 입력하세요"
/>
{suggestions.length > 0 && (
<ul className="suggestions">
{suggestions.map(item => (
<li key={item.id}>{item.text}</li>
))}
</ul>
)}
</div>
);
}
실제 프로젝트에서의 선택 가이드라인
Next.js 프로젝트에서 서버 컴포넌트와 클라이언트 컴포넌트 중 어떤 것을 선택해야 할지 결정하는 가이드라인을 제시합니다.
서버 컴포넌트를 사용해야 할 때
- 데이터 페칭이 필요한 경우: 서버에서 직접 데이터를 가져오는 것이 클라이언트에서 API를 호출하는 것보다 일반적으로 더 빠르고 안전합니다.
- 민감한 정보를 다루는 경우: API 키, 토큰 등 민감한 정보는 서버 컴포넌트에서만 사용하여 클라이언트에 노출되지 않도록 합니다.
- SEO가 중요한 페이지: 서버 컴포넌트는 검색 엔진 최적화에 유리합니다.
- 큰 의존성을 사용하는 경우: 번들 크기를 줄이기 위해 대형 라이브러리는 서버 컴포넌트에서 사용하는 것이 좋습니다.
- 정적인 UI 요소: 사용자 상호작용이 필요 없는 정적 콘텐츠는 서버 컴포넌트로 구현합니다.
클라이언트 컴포넌트를 사용해야 할 때
- 상호작용이 필요한 UI: 사용자 입력, 클릭 이벤트 등 상호작용이 필요한 요소는 클라이언트 컴포넌트로 구현해야 합니다.
- React 훅 사용:
useState
,useEffect
,useReducer
등의 React 훅을 사용해야 하는 경우 클라이언트 컴포넌트가 필요합니다. - 브라우저 API 사용: localStorage, window 객체, Web API 등 브라우저 전용 기능을 사용해야 할 때 클라이언트 컴포넌트를 사용합니다.
- 클라이언트 사이드 라이브러리 통합: 차트, 애니메이션, 드래그 앤 드롭 등의 클라이언트 사이드 라이브러리를 사용할 때 클라이언트 컴포넌트가 필요합니다.
- 실시간 업데이트: WebSocket 연결이나 실시간 업데이트가 필요한 기능은 클라이언트 컴포넌트에서 구현합니다.
서버 컴포넌트와 클라이언트 컴포넌트 혼합 사용 패턴
실제 애플리케이션에서는 서버 컴포넌트와 클라이언트 컴포넌트를 적절히 조합하여 사용하는 것이 일반적입니다. 다음은 효과적인 패턴들입니다.
1. "클라이언트 컴포넌트 섬" 패턴
이 패턴은 서버 컴포넌트 트리 안에 필요한 부분만 클라이언트 컴포넌트로 분리하는 방식입니다. 대부분의 UI는 서버 컴포넌트로 구현하고, 상호작용이 필요한 부분만 클라이언트 컴포넌트로 구현합니다.
// app/blog/[slug]/page.js (서버 컴포넌트)
import { db } from '@/lib/database';
import LikeButton from '@/components/LikeButton'; // 클라이언트 컴포넌트
import CommentSection from '@/components/CommentSection'; // 클라이언트 컴포넌트
export default async function BlogPost({ params }) {
const post = await db.posts.findUnique({
where: { slug: params.slug }
});
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
{/* 클라이언트 컴포넌트 섬 */}
<LikeButton postId={post.id} initialLikes={post.likes} />
<CommentSection postId={post.id} />
</article>
);
}
이 예제에서 대부분의 블로그 포스트 UI는 서버 컴포넌트로 렌더링되지만, 좋아요 버튼과 댓글 섹션만 클라이언트 컴포넌트로 구현되어 있습니다.
2. "서버에서 데이터 로드, 클라이언트에서 렌더링" 패턴
이 패턴은 서버에서 데이터를 로드하고, 그 데이터를 props로 클라이언트 컴포넌트에 전달하는 방식입니다.
// app/dashboard/page.js (서버 컴포넌트)
import { db } from '@/lib/database';
import DashboardUI from '@/components/DashboardUI'; // 클라이언트 컴포넌트
export default async function DashboardPage() {
// 서버에서 데이터 로드
const analytics = await db.analytics.findMany();
const userInfo = await db.users.findUnique({ where: { id: 'current-user' } });
// 데이터를 클라이언트 컴포넌트에 전달
return <DashboardUI analytics={analytics} userInfo={userInfo} />;
}
// components/DashboardUI.js (클라이언트 컴포넌트)
'use client'
import { useState } from 'react';
import AnalyticsChart from './AnalyticsChart';
import UserSettings from './UserSettings';
export default function DashboardUI({ analytics, userInfo }) {
const [activeTab, setActiveTab] = useState('overview');
return (
<div className="dashboard">
<nav className="tabs">
<button
onClick={() => setActiveTab('overview')}
className={activeTab === 'overview' ? 'active' : ''}
>
개요
</button>
<button
onClick={() => setActiveTab('settings')}
className={activeTab === 'settings' ? 'active' : ''}
>
설정
</button>
</nav>
{activeTab === 'overview' && <AnalyticsChart data={analytics} />}
{activeTab === 'settings' && <UserSettings user={userInfo} />}
</div>
);
}
이 패턴은 서버의 데이터 접근 이점과 클라이언트의 상호작용 기능을 결합합니다.
3. "프레젠테이션 래퍼" 패턴
클라이언트 컴포넌트는 서버 컴포넌트를 직접 임포트할 수 없지만, 서버 컴포넌트는 클라이언트 컴포넌트를 자식으로 전달할 수 있습니다. 이를 활용한 패턴입니다.
// components/ClientWrapper.js (클라이언트 컴포넌트)
'use client'
import { useState } from 'react';
export default function ClientWrapper({ children }) {
const [isExpanded, setIsExpanded] = useState(false);
return (
<div className={`wrapper ${isExpanded ? 'expanded' : 'collapsed'}`}>
<button onClick={() => setIsExpanded(!isExpanded)}>
{isExpanded ? '접기' : '펼치기'}
</button>
{isExpanded && children}
</div>
);
}
// app/complex-data/page.js (서버 컴포넌트)
import { db } from '@/lib/database';
import ClientWrapper from '@/components/ClientWrapper';
import ServerDataDisplay from '@/components/ServerDataDisplay'; // 서버 컴포넌트
export default async function ComplexDataPage() {
const complexData = await db.getComplexData();
return (
<div>
<h1>복잡한 데이터 분석</h1>
<ClientWrapper>
{/* 서버 컴포넌트를 children으로 전달 */}
<ServerDataDisplay data={complexData} />
</ClientWrapper>
</div>
);
}
이 패턴을 사용하면 클라이언트 컴포넌트(ClientWrapper)가 서버 컴포넌트(ServerDataDisplay)를 감싸는 구조가 가능해집니다.
실습: 간단한 블로그 애플리케이션 만들기
이제 서버 컴포넌트와 클라이언트 컴포넌트를 모두 활용한 간단한
블로그 애플리케이션을 만들어 봅시다.
1. 프로젝트 설정
먼저, Next.js 프로젝트를 생성합니다.
npx create-next-app@latest my-blog --app --typescript
cd my-blog
2. 블로그 데이터 모델 생성
간단한 예제를 위해 로컬 파일 시스템에 JSON 형식으로 블로그 데이터를 저장하겠습니다.
// data/posts.json
[
{
"id": "1",
"title": "Next.js 서버 컴포넌트 소개",
"slug": "intro-to-server-components",
"content": "Next.js 13에서 도입된 서버 컴포넌트는 프론트엔드 개발의 패러다임을 바꾸고 있습니다...",
"date": "2025-01-15",
"author": "김개발",
"likes": 42
},
{
"id": "2",
"title": "클라이언트 컴포넌트 활용하기",
"slug": "using-client-components",
"content": "클라이언트 컴포넌트는 사용자 상호작용이 필요한 UI를 개발할 때 필수적입니다. 이 글에서는 효과적인 활용법을 알아봅니다...",
"date": "2025-02-03",
"author": "이프론트",
"likes": 27
},
{
"id": "3",
"title": "서버와 클라이언트 컴포넌트 함께 사용하기",
"slug": "combining-server-and-client-components",
"content": "최적의 웹 애플리케이션을 위해서는 서버와 클라이언트 컴포넌트를 적절히 조합해야 합니다...",
"date": "2025-03-20",
"author": "박풀스택",
"likes": 36
}
]
3. 블로그 홈페이지 (서버 컴포넌트)
홈페이지는 블로그 포스트 목록을 보여주는 서버 컴포넌트로 구현합니다.
// app/page.tsx (서버 컴포넌트)
import Link from 'next/link';
import fs from 'fs/promises';
import path from 'path';
// 서버에서 데이터 로드 함수
async function getPosts() {
const filePath = path.join(process.cwd(), 'data/posts.json');
const data = await fs.readFile(filePath, 'utf8');
return JSON.parse(data);
}
export default async function HomePage() {
const posts = await getPosts();
return (
<div className="container mx-auto py-8">
<h1 className="text-3xl font-bold mb-6">Next.js 블로그</h1>
<div className="grid gap-6">
{posts.map(post => (
<article key={post.id} className="border rounded-lg p-6 shadow-sm">
<h2 className="text-xl font-semibold mb-2">
<Link href={`/blog/${post.slug}`} className="text-blue-600 hover:underline">
{post.title}
</Link>
</h2>
<div className="text-gray-500 mb-3">
{post.date} · {post.author} · {post.likes} 좋아요
</div>
<p className="text-gray-700">{post.content.substring(0, 150)}...</p>
</article>
))}
</div>
</div>
);
}
이 컴포넌트는 서버에서 실행되며, 파일 시스템에서 직접 블로그 데이터를 읽어옵니다. 이는 서버 컴포넌트의 장점인 서버 리소스에 직접 접근하는 기능을 보여줍니다.
4. 블로그 포스트 페이지 (서버 컴포넌트)
개별 블로그 포스트 페이지도 서버 컴포넌트로 구현하고, 좋아요 버튼만 클라이언트 컴포넌트로 분리합니다.
// app/blog/[slug]/page.tsx (서버 컴포넌트)
import fs from 'fs/promises';
import path from 'path';
import { notFound } from 'next/navigation';
import LikeButton from '@/components/LikeButton'; // 클라이언트 컴포넌트
// 서버에서 데이터 로드 함수
async function getPostBySlug(slug: string) {
const filePath = path.join(process.cwd(), 'data/posts.json');
const data = await fs.readFile(filePath, 'utf8');
const posts = JSON.parse(data);
return posts.find((post: any) => post.slug === slug);
}
export default async function BlogPostPage({ params }: { params: { slug: string } }) {
const post = await getPostBySlug(params.slug);
if (!post) {
notFound();
}
return (
<div className="container mx-auto py-8">
<article className="prose lg:prose-xl mx-auto">
<h1>{post.title}</h1>
<div className="text-gray-500 mb-6">
{post.date} · {post.author}
</div>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
{/* 클라이언트 컴포넌트 섬 */}
<div className="mt-8">
<LikeButton postId={post.id} initialLikes={post.likes} />
</div>
</article>
</div>
);
}
5. 좋아요 버튼 (클라이언트 컴포넌트)
사용자 상호작용이 필요한 좋아요 버튼은 클라이언트 컴포넌트로 구현합니다.
// components/LikeButton.tsx (클라이언트 컴포넌트)
'use client'
import { useState } from 'react';
interface LikeButtonProps {
postId: string;
initialLikes: number;
}
export default function LikeButton({ postId, initialLikes }: LikeButtonProps) {
const [likes, setLikes] = useState(initialLikes);
const [liked, setLiked] = useState(false);
const handleLike = async () => {
if (!liked) {
// 실제 애플리케이션에서는 API 호출로 DB 업데이트
setLikes(prev => prev + 1);
setLiked(true);
try {
// 예시 API 호출
await fetch(`/api/posts/${postId}/like`, { method: 'POST' });
} catch (error) {
// 오류 발생 시 상태 롤백
console.error('Failed to update like', error);
setLikes(prev => prev - 1);
setLiked(false);
}
}
};
return (
<button
onClick={handleLike}
className={`flex items-center gap-2 px-4 py-2 rounded-full ${
liked ? 'bg-red-100 text-red-600' : 'bg-gray-100 text-gray-600'
}`}
disabled={liked}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill={liked ? 'currentColor' : 'none'}
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path>
</svg>
{likes} 좋아요
</button>
);
}
6. 댓글 섹션 (클라이언트 컴포넌트)
댓글 기능도 사용자 상호작용이 필요하므로 클라이언트 컴포넌트로 구현합니다.
// components/CommentSection.tsx (클라이언트 컴포넌트)
'use client'
import { useState, useEffect } from 'react';
interface Comment {
id: string;
author: string;
content: string;
date: string;
}
interface CommentSectionProps {
postId: string;
}
export default function CommentSection({ postId }: CommentSectionProps) {
const [comments, setComments] = useState<Comment[]>([]);
const [newComment, setNewComment] = useState('');
const [authorName, setAuthorName] = useState('');
const [isLoading, setIsLoading] = useState(false);
// 댓글 로드
useEffect(() => {
async function loadComments() {
setIsLoading(true);
try {
// 실제 애플리케이션에서는 API 호출
const res = await fetch(`/api/posts/${postId}/comments`);
const data = await res.json();
setComments(data);
} catch (error) {
console.error('Failed to load comments', error);
} finally {
setIsLoading(false);
}
}
loadComments();
}, [postId]);
// 댓글 작성
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!newComment.trim() || !authorName.trim()) return;
const tempComment: Comment = {
id: Date.now().toString(),
author: authorName,
content: newComment,
date: new Date().toISOString().slice(0, 10)
};
// 낙관적 UI 업데이트
setComments(prev => [...prev, tempComment]);
setNewComment('');
try {
// 실제 애플리케이션에서는 API 호출
await fetch(`/api/posts/${postId}/comments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ author: authorName, content: newComment })
});
} catch (error) {
console.error('Failed to post comment', error);
// 오류 발생 시 UI 롤백
setComments(prev => prev.filter(c => c.id !== tempComment.id));
}
};
return (
<div className="mt-10">
<h3 className="text-xl font-semibold mb-4">댓글</h3>
{isLoading ? (
<p>댓글을 불러오는 중...</p>
) : (
<div className="space-y-4 mb-6">
{comments.length === 0 ? (
<p className="text-gray-500">첫 번째 댓글을 작성해보세요!</p>
) : (
comments.map(comment => (
<div key={comment.id} className="border-b pb-4">
<div className="flex justify-between mb-1">
<span className="font-medium">{comment.author}</span>
<span className="text-gray-500 text-sm">{comment.date}</span>
</div>
<p>{comment.content}</p>
</div>
))
)}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-3">
<div>
<label htmlFor="author" className="block mb-1">이름</label>
<input
type="text"
id="author"
value={authorName}
onChange={e => setAuthorName(e.target.value)}
className="w-full border rounded px-3 py-2"
required
/>
</div>
<div>
<label htmlFor="comment" className="block mb-1">댓글</label>
<textarea
id="comment"
value={newComment}
onChange={e => setNewComment(e.target.value)}
className="w-full border rounded px-3 py-2 min-h-[100px]"
required
/>
</div>
<button
type="submit"
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
>
댓글 작성
</button>
</form>
</div>
);
}
7. 서버 액션을 활용한 API 처리
Next.js 서버 액션을 활용하여 좋아요와 댓글 기능의 백엔드 로직을 구현할 수 있습니다.
// app/actions.ts
'use server'
import fs from 'fs/promises';
import path from 'path';
const postsPath = path.join(process.cwd(), 'data/posts.json');
const commentsPath = path.join(process.cwd(), 'data/comments.json');
// 좋아요 업데이트
export async function likePost(postId: string) {
try {
const data = await fs.readFile(postsPath, 'utf8');
let posts = JSON.parse(data);
const postIndex = posts.findIndex((post: any) => post.id === postId);
if (postIndex !== -1) {
posts[postIndex].likes += 1;
await fs.writeFile(postsPath, JSON.stringify(posts, null, 2), 'utf8');
return { success: true };
}
return { success: false, error: 'Post not found' };
} catch (error) {
console.error('Error updating likes:', error);
return { success: false, error: 'Failed to update likes' };
}
}
// 댓글 추가
export async function addComment(postId: string, author: string, content: string) {
try {
// comments.json 파일이 없으면 생성
try {
await fs.access(commentsPath);
} catch {
await fs.writeFile(commentsPath, JSON.stringify({}), 'utf8');
}
const data = await fs.readFile(commentsPath, 'utf8');
let comments = JSON.parse(data);
if (!comments[postId]) {
comments[postId] = [];
}
const newComment = {
id: Date.now().toString(),
author,
content,
date: new Date().toISOString().slice(0, 10)
};
comments[postId].push(newComment);
await fs.writeFile(commentsPath, JSON.stringify(comments, null, 2), 'utf8');
return { success: true, comment: newComment };
} catch (error) {
console.error('Error adding comment:', error);
return { success: false, error: 'Failed to add comment' };
}
}
// 댓글 불러오기
export async function getComments(postId: string) {
try {
try {
await fs.access(commentsPath);
} catch {
await fs.writeFile(commentsPath, JSON.stringify({}), 'utf8');
return [];
}
const data = await fs.readFile(commentsPath, 'utf8');
const comments = JSON.parse(data);
return comments[postId] || [];
} catch (error) {
console.error('Error loading comments:', error);
return [];
}
}
이제 클라이언트 컴포넌트에서 서버 액션을 직접 호출할 수 있습니다.
// components/LikeButton.tsx의 handleLike 함수 수정
import { likePost } from '@/app/actions';
const handleLike = async () => {
if (!liked) {
setLikes(prev => prev + 1);
setLiked(true);
try {
const result = await likePost(postId);
if (!result.success) {
throw new Error(result.error);
}
} catch (error) {
console.error('Failed to update like', error);
setLikes(prev => prev - 1);
setLiked(false);
}
}
};
// components/CommentSection.tsx 수정
import { getComments, addComment } from '@/app/actions';
// useEffect 내부 수정
useEffect(() => {
async function loadComments() {
setIsLoading(true);
try {
const data = await getComments(postId);
setComments(data);
} catch (error) {
console.error('Failed to load comments', error);
} finally {
setIsLoading(false);
}
}
loadComments();
}, [postId]);
// handleSubmit 함수 수정
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!newComment.trim() || !authorName.trim()) return;
const tempId = Date.now().toString();
const tempComment = {
id: tempId,
author: authorName,
content: newComment,
date: new Date().toISOString().slice(0, 10)
};
setComments(prev => [...prev, tempComment]);
setNewComment('');
try {
const result = await addComment(postId, authorName, newComment);
if (!result.success) {
throw new Error(result.error);
}
// 서버에서 생성된 실제 ID로 댓글 업데이트
setComments(prev =>
prev.map(c => c.id === tempId ? result.comment : c)
);
} catch (error) {
console.error('Failed to post comment', error);
setComments(prev => prev.filter(c => c.id !== tempId));
}
};
성능 비교 및 최적화 팁
서버 컴포넌트와 클라이언트 컴포넌트는 각각 다른 성능 특성을 가집니다. 다음은 성능 최적화를 위한 팁들입니다.
1. 번들 크기 최적화
서버 컴포넌트를 사용하면 클라이언트 번들 크기를 크게 줄일 수 있습니다. 특히 대형 의존성 라이브러리가 있는 경우 서버 컴포넌트로 전환하는 것만으로도 큰 성능 향상을 얻을 수 있습니다.
예를 들어, 날짜 처리를 위해 date-fns
나 moment.js
와 같은 라이브러리를 사용하는 경우, 이를 서버 컴포넌트에서만 사용하도록 하여 클라이언트 번들에 포함되지 않게 할 수 있습니다.
// DateFormatter.tsx (서버 컴포넌트)
import { format, parseISO } from 'date-fns';
import { ko } from 'date-fns/locale';
export default function DateFormatter({ dateString }: { dateString: string }) {
const date = parseISO(dateString);
const formattedDate = format(date, 'yyyy년 MM월 dd일', { locale: ko });
return <time dateTime={dateString}>{formattedDate}</time>;
}
이 컴포넌트는 서버에서만 실행되므로 date-fns
라이브러리는 클라이언트 번들에 포함되지 않습니다.
2. 스트리밍과 서스펜스
Next.js 13 이상에서는 스트리밍과 서스펜스를 활용하여 점진적 페이지 로딩을 구현할 수 있습니다. 이는 특히 데이터 로딩이 오래 걸리는 경우 사용자 경험을 개선하는 데 도움이 됩니다.
// app/blog/[slug]/page.tsx
import { Suspense } from 'react';
import PostContent from '@/components/PostContent'; // 서버 컴포넌트
import CommentSection from '@/components/CommentSection'; // 클라이언트 컴포넌트
import LoadingComments from '@/components/LoadingComments';
export default function BlogPostPage({ params }: { params: { slug: string } }) {
return (
<div className="container mx-auto py-8">
{/* 포스트 내용 로딩 */}
<PostContent slug={params.slug} />
{/* 댓글 섹션은 별도로 스트리밍 */}
<Suspense fallback={<LoadingComments />}>
<CommentSection postId={params.slug} />
</Suspense>
</div>
);
}
이렇게 하면 포스트 내용이 먼저 로드되고, 댓글은 준비되는 대로 나중에 로드됩니다.
3. 이미지 최적화
Next.js의 Image 컴포넌트를 사용하면 이미지 최적화를 자동으로 처리할 수 있습니다. 서버 컴포넌트에서도 Image 컴포넌트를 사용할 수 있습니다.
// components/BlogImage.tsx (서버 컴포넌트)
import Image from 'next/image';
export default function BlogImage({ src, alt, width, height }: {
src: string;
alt: string;
width: number;
height: number;
}) {
return (
<div className="my-6">
<Image
src={src}
alt={alt}
width={width}
height={height}
className="rounded-lg"
priority={false}
/>
<p className="text-center text-gray-500 mt-2">{alt}</p>
</div>
);
}
4. 적절한 캐싱 전략
Next.js 13 이상에서는 서버 컴포넌트의 데이터 요청에 대해 세밀한 캐싱 제어가 가능합니다. fetch
함수에 cache
및 revalidate
옵션을 사용하여 데이터 캐싱 전략을 설정할 수 있습니다.
// app/blog/page.tsx
async function getPosts() {
// 기본적으로 캐시됨 (force-cache)
const response = await fetch('https://api.example.com/posts');
return response.json();
}
// 10초마다 재검증
async function getRecentPosts() {
const response = await fetch('https://api.example.com/recent-posts', {
next: { revalidate: 10 }
});
return response.json();
}
// 항상 최신 데이터 가져오기
async function getRealtimeData() {
const response = await fetch('https://api.example.com/stats', {
cache: 'no-store'
});
return response.json();
}
자주 발생하는 오류와 해결법
Next.js에서 서버 컴포넌트와 클라이언트 컴포넌트를 사용할 때 자주 발생하는 오류와 해결 방법을 알아봅시다.
1. "useState/useEffect는 서버 컴포넌트에서 사용할 수 없습니다"
Error: useState/useEffect can only be used in Client Components. Add the "use client" directive at the top of the file to use it.
해결 방법:
- 컴포넌트가 React 훅을 사용해야 한다면 파일 상단에
'use client'
지시어를 추가합니다. - 또는 훅을 사용하는 부분만 별도의 클라이언트 컴포넌트로 분리합니다.
// Button.tsx (클라이언트 컴포넌트)
'use client'
import { useState } from 'react';
export default function Button() {
const [clicked, setClicked] = useState(false);
return (
<button onClick={() => setClicked(!clicked)}>
{clicked ? '클릭됨!' : '클릭하세요!'}
</button>
);
}
2. "서버 컴포넌트를 클라이언트 컴포넌트로 임포트할 수 없습니다"
Error: You're importing a Server Component from a Client Component, but this pattern isn't supported yet.
해결 방법:
- 서버 컴포넌트는 클라이언트 컴포넌트의 자식으로 전달해야 합니다.
- "프레젠테이션 래퍼" 패턴을 사용합니다.
// app/page.tsx (서버 컴포넌트)
import ClientWrapper from '@/components/ClientWrapper'; // 클라이언트 컴포넌트
import ServerComponent from '@/components/ServerComponent'; // 서버 컴포넌트
export default function Page() {
return (
<ClientWrapper>
<ServerComponent />
</ClientWrapper>
);
}
3. "브라우저 전용 API에 접근할 수 없습니다"
ReferenceError: window is not defined
ReferenceError: document is not defined
ReferenceError: localStorage is not defined
해결 방법:
- 브라우저 API를 사용하는 코드는 반드시 클라이언트 컴포넌트에서 실행되어야 합니다.
useEffect
훅 내에서 브라우저 API를 사용하여 서버 렌더링 시 오류를 방지합니다.
'use client'
import { useEffect, useState } from 'react';
export default function ThemeDetector() {
const [prefersDark, setPrefersDark] = useState(false);
useEffect(() => {
// 브라우저에서만 실행
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
setPrefersDark(mediaQuery.matches);
const handler = (e) => setPrefersDark(e.matches);
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
}, []);
return (
<div>
현재 {prefersDark ? '다크' : '라이트'} 모드를 선호하고 있습니다.
</div>
);
}
4. "서버 액션이 함수를 직렬화할 수 없습니다"
Error: Functions cannot be passed directly to Server Components unless you explicitly expose it by marking it with "use server".
해결 방법:
- 서버 컴포넌트에 전달하는 prop이 함수를 포함하지 않도록 합니다.
- 서버 액션을 사용하는 경우
'use server'
지시어를 추가합니다.
// app/actions.ts
'use server'
export async function handleFormSubmit(formData: FormData) {
const name = formData.get('name');
const email = formData.get('email');
// 서버에서 데이터 처리
console.log('Handling form submission for:', name, email);
return { success: true };
}
결론 및 앞으로의 발전 방향
Next.js의 서버 컴포넌트와 클라이언트 컴포넌트는 각각의 장단점이 있으며, 이를 적절히 조합하여 사용하는 것이 최적의 웹 애플리케이션을 구축하는 비결입니다.
핵심 요약
- 서버 컴포넌트는 번들 크기 감소, 서버 리소스 직접 접근, 보안 강화, SEO 최적화 등의 장점이 있습니다. 데이터 페칭, 정적 콘텐츠, 큰 의존성이 있는 경우 적합합니다.
- 클라이언트 컴포넌트는 사용자 상호작용, React 훅 사용, 브라우저 API 접근 등의 장점이 있습니다. 폼, 애니메이션, 실시간 업데이트가 필요한 경우 적합합니다.
- 혼합 사용 패턴은 "클라이언트 컴포넌트 섬", "서버에서 데이터 로드, 클라이언트에서 렌더링", "프레젠테이션 래퍼" 등이 있으며, 애플리케이션의 요구사항에 맞게 적절히 활용해야 합니다.
- 성능 최적화는 번들 크기 최적화, 스트리밍과 서스펜스, 이미지 최적화, 적절한 캐싱 전략 등의 방법으로 가능합니다.
앞으로의 발전 방향
React와 Next.js의 서버 컴포넌트 패러다임은 아직 발전 초기 단계입니다. 앞으로의 발전 방향은 다음과 같을 것으로 예상됩니다:
- 더 나은 하이브리드 패턴: 서버와 클라이언트 컴포넌트 간의 더 원활한 상호작용을 위한 새로운 패턴과 API가 등장할 것입니다.
- 전용 라이브러리 생태계: 서버 컴포넌트에 최적화된 라이브러리와 도구들이 더 많이 개발될 것입니다.
- 더 강력한 서버 액션: 서버 컴포넌트와 클라이언트 컴포넌트 간의 데이터 흐름을 더 쉽게 관리할 수 있는 기능이 추가될 것입니다.
- 풀스택 개발 간소화: 백엔드와 프론트엔드 사이의 경계가 더욱 희미해지며, 풀스택 개발이 더 간소화될 것입니다.
- AI 통합: 서버 컴포넌트의 강력한 서버 접근성을 활용한 AI 기능 통합이 더 쉬워질 것입니다.
서버 컴포넌트와 클라이언트 컴포넌트의 등장은 웹 개발의 패러다임을 변화시키고 있습니다.
두 접근 방식의 장단점을 이해하고 적절히 활용함으로써, 더 빠르고, 더 안전하며,
더 사용자 친화적인 웹 애플리케이션을 구축할 수 있습니다.
React와 Next.js 생태계가 계속 발전함에 따라, 서버 컴포넌트와 클라이언트 컴포넌트를 활용한 개발 방식도 더욱 세련되고 효율적으로 변화할 것입니다. 이러한 변화에 발맞추어 지속적으로 학습하고 적응하는 것이 현대 프론트엔드 개발자에게 필수적인 역량이 될 것입니다.
서버 컴포넌트와 클라이언트 컴포넌트의 조화로운 활용은 단순한 기술적 선택을 넘어, 사용자 경험과 개발자 경험을 모두 향상시키는 핵심 전략입니다. 이 두 가지 접근 방식을 이해하고 마스터하는 것이 Next.js 개발의 새로운 표준이 되었습니다.
'프론트엔드' 카테고리의 다른 글
React 상태 관리 라이브러리 총정리 - Redux, Recoil, Zustand 비교 (0) | 2025.05.13 |
---|---|
Next.js vs React: 신입 개발자가 선택할 프레임워크는? 2025년 기준 (1) | 2025.05.09 |