Next.js 서버 컴포넌트 가이드
1. 서버 컴포넌트 개념
클라이언트 컴포넌트와의 차이점
클라이언트 컴포넌트에서는 모든 렌더링이 클라이언트에서 발생합니다. 반면 Next.js는 기본적으로 서버에서 렌더링을 수행하고 HTML 결과만 클라이언트로 전송합니다.
// app/page.tsx - 기본이 서버 컴포넌트
export default async function Page() {
const response = await fetch('https://api.example.com/posts');
const data = await response.json();
return <div>{data}</div>;
}
2. 정적 생성 vs 동적 생성
서버 컴포넌트가 렌더링되는 시점은 데이터 페칭 방식에 따라 결정됩니다.
빌드 타임 (정적 생성 - SSG)
빌드 시점에 한 번 렌더링되고 정해진 시간 동안 캐시된 HTML을 재사용합니다.
// app/blog/[id]/page.tsx
export default async function BlogPost({ params }: { params: { id: string } }) {
const response = await fetch(`https://api.example.com/posts/${params.id}`, {
next: { revalidate: 3600 } // 1시간마다 다시 생성
});
const post = await response.json();
return <article>{post.title}</article>;
}
요청 타임 (동적 생성 - SSR)
매 요청마다 서버에서 렌더링합니다.
// app/dashboard/page.tsx
export default async function Dashboard() {
const response = await fetch('https://api.example.com/user/me', {
next: { revalidate: 0 } // 캐시 없음 = 매 요청마다 새로 생성
});
const data = await response.json();
return <div>{data}</div>;
}
- 매 요청마다 렌더링: 항상 최신 데이터를 제공합니다
- 서빙 속도: 상대적으로 느립니다
- 적합한 경우: 대시보드나 개인화된 페이지
렌더링 타이밍 비교
3. Props를 통한 클라이언트 컴포넌트 데이터 전달
서버 컴포넌트에서 페칭한 데이터를 클라이언트 컴포넌트에 전달하는 가장 기본적인 방식입니다.
기본 패턴
// app/page.tsx - 서버 컴포넌트
async function Page() {
const response = await fetch('https://api.example.com/posts');
const posts = await response.json();
// 클라이언트 컴포넌트에 Props로 전달
return <PostList posts={posts} />;
}
// app/components/PostList.tsx - 클라이언트 컴포넌트
'use client';
import { useState } from 'react';
interface Post {
id: string;
title: string;
}
export default function PostList({ posts }: { posts: Post[] }) {
const [filter, setFilter] = useState('');
const filteredPosts = posts.filter(post =>
post.title.includes(filter)
);
return (
<div>
<input
onChange={(e) => setFilter(e.target.value)}
placeholder="필터링"
/>
{filteredPosts.map(post => (
<div key={post.id}>{post.title}</div>
))}
</div>
);
}
데이터 흐름
4. Tanstack Query와의 연동
Tanstack Query는 클라이언트 전용
Tanstack Query는 클라이언트 상태 관리 라이브러리입니다. 따라서 서버 컴포넌트와 직접 연동할 수 없습니다.
// ❌ 불가능 - 서버 컴포넌트에서 Tanstack Query 사용 불가
async function Page() {
const { data } = useQuery({...}); // Error!
}
올바른 연동 방식: initialData 패턴
서버에서 초기 데이터를 페칭하고 클라이언트에서 Tanstack Query의 initialData로 활용합니다.
// app/page.tsx - 서버 컴포넌트
async function Page() {
const response = await fetch('https://api.example.com/posts', {
next: { revalidate: 60 } // 캐시된 데이터 즉시 반환
});
const initialPosts = await response.json();
return <PostList initialPosts={initialPosts} />;
}
// components/PostList.tsx - 클라이언트 컴포넌트
'use client';
import { useQuery } from '@tanstack/react-query';
interface Post {
id: string;
title: string;
}
export default function PostList({ initialPosts }: { initialPosts: Post[] }) {
const { data: posts = initialPosts, isLoading } = useQuery({
queryKey: ['posts'],
queryFn: () => fetch('/api/posts').then(r => r.json()),
initialData: initialPosts, // ✅ 초기값으로 사용
staleTime: 60000, // 60초 동안 신선함
gcTime: 5 * 60 * 1000, // 캐시 유지 시간
});
if (isLoading && !posts) return <div>로딩 중...</div>;
return (
<div>
{posts.map(post => (
<div key={post.id}>{post.title}</div>
))}
</div>
);
}
동작 흐름
이 방식의 장점
- 초기 렌더링 속도: 서버에서 데이터를 준비하므로 로딩 시간이 없습니다
- 자동 상태 관리: Tanstack Query가 캐싱과 재시도를 자동으로 처리합니다
- 최신 데이터: 백그라운드에서 자동으로 새 데이터를 페칭합니다
- 오류 처리: Tanstack Query의 재시도 메커니즘을 활용합니다
- 서버와 클라이언트의 캐시 정책을 일치시켜야 합니다
- initialData는 JSON 직렬화 가능해야 합니다
- staleTime 설정이 중요합니다
5. 캐싱 정책
Next.js의 캐싱은 여러 레이어에서 동작합니다.
5.1 외부 API 캐싱 (Request Deduplication)
동일한 URL에 대한 여러 요청을 자동으로 합칩니다.
// 다음 코드는 하나의 요청으로 통합됨
const posts1 = await fetch('/api/posts'); // 1번 요청
const posts2 = await fetch('/api/posts'); // 중복 제거 (같은 요청)
5.2 Data Cache (시간 기반 캐싱)
next 옵션의 revalidate 값으로 캐시 시간을 제어합니다.
// 1시간마다 갱신
const data = await fetch('https://api.example.com/posts', {
next: { revalidate: 3600 }
});
// 매 요청마다 새로 페칭 (캐시 없음)
const data = await fetch('https://api.example.com/user', {
next: { revalidate: 0 }
});
| revalidate 값 | 동작 |
|---|---|
| 정수 (예: 3600) | 지정된 초 동안 캐시 후 재생성 |
| 0 또는 false | 캐시 없음 - 매 요청마다 새로 페칭 |
| 생략 | 무한 캐시 (기본값) |
5.3 실제 캐싱 시나리오
시나리오: 서버 컴포넌트 + API Route + 클라이언트 Tanstack Query
// app/api/posts/route.ts
export async function GET() {
const response = await fetch('https://external-api.com/posts', {
next: { revalidate: 60 } // 외부 API 캐시: 60초
});
const data = await response.json();
return Response.json(data);
}
// app/page.tsx - 서버 컴포넌트 (빌드/요청 타임)
async function Page() {
const response = await fetch('http://localhost:3000/api/posts');
const posts = await response.json();
// API Route 호출 → 외부 API 캐시 확인 → 60초 내면 캐시된 값 반환
return <PostList initialPosts={posts} />;
}
// components/PostList.tsx - 클라이언트
'use client';
export default function PostList({ initialPosts }: { initialPosts: unknown[] }) {
const { data } = useQuery({
queryKey: ['posts'],
queryFn: () => fetch('/api/posts').then(r => r.json()),
initialData: initialPosts,
staleTime: 0, // 즉시 stale 취급
});
return <div>{/* 렌더링 로직 */}</div>;
}
캐싱 타이밍 다이어그램
캐시 레이어 구조
🔹 페이지 새로고침 시 초기화
🔹 가장 빠른 응답 속도
🔹 서버 재시작 시 초기화
🔹 빠른 응답 속도
🔹 가장 느린 응답 속도
🔹 항상 최신 데이터
5.4 Preload vs Prefetch 비교
Next.js에서 성능 최적화를 위해 두 가지 메커니즘을 제공합니다. Preload (서버 컴포넌트)와 Prefetch (클라이언트 컴포넌트)입니다.
Preload (서버 컴포넌트)
서버 컴포넌트로 렌더링된 페이지의 Link 컴포넌트는 viewport에 보이는 순간 자동으로 해당 페이지를 미리 로드합니다.
// app/page.tsx - 서버 컴포넌트
export default async function HomePage() {
const response = await fetch('https://api.example.com/posts');
const posts = await response.json();
return (
<div>
<h1>블로그</h1>
{posts.map((post: { id: string; title: string }) => (
<Link key={post.id} href={`/blog/${post.id}`}>
{/* ✅ 이 Link가 viewport에 보이면 자동 Preload */}
{post.title}
</Link>
))}
</div>
);
}
📄 HTML (마크업)
📦 JS 번들 (클라이언트 컴포넌트가 있으면)
Prefetch (클라이언트 컴포넌트)
클라이언트에서는 Link 컴포넌트의 prefetch 속성으로 명시적으로 미리 로드를 제어합니다.
'use client';
import Link from 'next/link';
interface Post {
id: string;
title: string;
}
export default function BlogList({ posts }: { posts: Post[] }) {
return (
<div>
{posts.map(post => (
<Link
key={post.id}
href={`/blog/${post.id}`}
prefetch={true} // ✅ 명시적으로 prefetch 활성화 (기본값)
>
{post.title}
</Link>
))}
</div>
);
}
// prefetch={false} 로 설정하면 미리 로드하지 않습니다
// <Link href={`/blog/${post.id}`} prefetch={false}>
❌ RSC Payload 없음
❌ 데이터 없음
❌ HTML 없음
실제 비교: 타이밍
5.5 3가지 핵심 개념: JS 번들 vs RSC Payload vs HTML
Preload와 Prefetch를 정확히 이해하려면 다운로드되는 것들의 차이를 알아야 합니다.
개념 정의
1️⃣ JavaScript 번들 (JS Bundle)
// 번들에 포함되는 것들
- React 컴포넌트 로직
- useState, useEffect 등 클라이언트 훅
- 이벤트 핸들러
- 상태 관리 라이브러리
- 라우팅 로직
// 예: LikeButton.tsx - 클라이언트 컴포넌트
'use client';
import { useState } from 'react';
export function LikeButton() {
const [liked, setLiked] = useState(false); // ← 번들에 포함
return (
<button onClick={() => setLiked(!liked)}> {/* ← 번들에 포함 */}
{liked ? '❤️' : '🤍'}
</button>
);
}
// 번들 크기: 보통 30KB ~ 100KB
// 저장 위치: 브라우저 캐시 (CDN에서 다운로드)
2️⃣ RSC Payload (React Server Component Payload)
// RSC Payload에 포함되는 것들
- 서버 렌더링 결과
- 데이터
- 클라이언트 컴포넌트 참조
- Props
// 예: BlogDetail.tsx - 서버 컴포넌트
async function BlogDetail({ params }: { params: { id: string } }) {
const response = await fetch(`/api/posts/${params.id}`);
const post = await response.json(); // ← 데이터
return (
<article>
<h1>{post.title}</h1> {/* ← RSC Payload에 포함 */}
<LikeButton /> {/* ← 클라이언트 컴포넌트 참조만 포함 */}
</article>
);
}
// RSC Payload 내용:
{
"type": "Component",
"name": "BlogDetail",
"props": {
"title": "Next.js 완벽 가이드", // ← 데이터
"content": "..."
},
"children": [
{
"type": "ClientComponent",
"jsModuleId": "123" // ← JS 번들 참조
}
]
}
// 페이로드 크기: 보통 5KB ~ 50KB
// 저장 위치: 클라이언트 메모리 (라우터 캐시)
3️⃣ HTML
<!-- 서버에서 미리 렌더링한 마크업 -->
<article>
<h1>Next.js 완벽 가이드</h1>
<p>이 글에서는...</p>
<button>🤍</button> <!-- 클라이언트 컴포넌트 자리 -->
</article>
<!-- HTML은 RSC Payload에 포함됨 -->
<!-- 크기: 1KB ~ 20KB -->
<!-- 저장 위치: 클라이언트 메모리 -->
4가지 시나리오 비교
시나리오 1: 서버 컴포넌트만 있는 페이지
// app/blog/[id]/page.tsx - 모두 서버 컴포넌트
async function BlogDetail({ params }: { params: { id: string } }) {
const response = await fetch(`/api/posts/${params.id}`);
const post = await response.json();
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
);
}
클라이언트 코드 없음
렌더링 결과 + 데이터
RSC Payload에
시나리오 2: 클라이언트 컴포넌트만 있는 페이지
// app/blog/[id]/page.tsx - 모두 클라이언트 컴포넌트
'use client';
import { useQuery } from '@tanstack/react-query';
interface Post {
id: string;
title: string;
content: string;
}
export default function BlogDetail({ params }: { params: { id: string } }) {
const { data: post } = useQuery<Post>({
queryKey: ['post', params.id],
queryFn: () => fetch(`/api/posts/${params.id}`).then(r => r.json()),
});
return (
<article>
<h1>{post?.title}</h1>
<p>{post?.content}</p>
</article>
);
}
useQuery 및 컴포넌트 로직
클라이언트에서 처리
클라이언트에서 만듦
시나리오 3: 혼합 페이지 (서버 + 클라이언트)
// app/blog/[id]/page.tsx - 서버 컴포넌트
async function BlogDetail({ params }: { params: { id: string } }) {
const response = await fetch(`/api/posts/${params.id}`);
const post = await response.json();
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
<LikeButton /> {/* ← 클라이언트 컴포넌트 */}
</article>
);
}
// components/LikeButton.tsx - 클라이언트 컴포넌트
'use client';
export function LikeButton() {
const [liked, setLiked] = useState(false);
return (
<button onClick={() => setLiked(!liked)}>
{liked ? '❤️' : '🤍'}
</button>
);
}
LikeButton 코드
post 데이터
RSC Payload에
Network 탭에서 실제로 보는 것
// 서버 컴포넌트 Preload/Prefetch
GET /blog/123?_rsc=abc123
Status: 200
Size: 15KB
Content-Type: application/octet-stream
// ↑ RSC Payload (이진 형식)
// 클라이언트 컴포넌트 Prefetch
GET /_next/static/chunks/blog-[id]-abc123.js
Status: 200
Size: 45KB
Content-Type: application/javascript
// ↑ JS 번들 (JavaScript 코드)
종합 비교 표
| 항목 | JS 번들 | RSC Payload | HTML |
|---|---|---|---|
| 뭔가? | 실행 코드 | 렌더링 결과 + 데이터 | 마크업 |
| 크기 | 30-100KB | 5-50KB | 1-20KB |
| 포함 대상 | 클라이언트 컴포넌트 | 서버 컴포넌트 결과 | 서버 렌더링 결과 |
| Prefetch 시 다운로드 | ✅ 클라이언트만 | ✅ 서버만 | ✅ 서버만 |
| 언제 사용 | 페이지 이동 후 렌더링 | 페이지 이동 후 렌더링 | 즉시 표시 |
| 저장 위치 | 브라우저 캐시 (CDN) | 클라이언트 메모리 | 클라이언트 메모리 |
5.5.5 혼합 구성 패턴: 최적의 선택
실제로는 순수 서버 컴포넌트나 순수 클라이언트 컴포넌트보다 혼합 구성이 가장 좋은 경우가 많습니다.
패턴: 서버에서 initialData 페칭 → 클라이언트에서 관리
// app/blog/[id]/page.tsx - 서버 컴포넌트
// 역할: 초기 데이터 페칭 + 레이아웃
async function BlogPage({ params }: { params: { id: string } }) {
// ✅ 서버에서 초기 데이터 fetch
const response = await fetch(`/api/posts/${params.id}`, {
next: { revalidate: 60 }
});
const initialPost = await response.json();
return <BlogDetail initialPost={initialPost} />;
}
// components/BlogDetail.tsx - 클라이언트 컴포넌트
// 역할: 상호작용 + 데이터 동기화
'use client';
import { useQuery } from '@tanstack/react-query';
interface Post {
id: string;
title: string;
content: string;
}
export default function BlogDetail({ initialPost }: { initialPost: Post }) {
const { data: post = initialPost, refetch } = useQuery({
queryKey: ['post', initialPost.id],
queryFn: () => fetch(`/api/posts/${initialPost.id}`).then(r => r.json()),
initialData: initialPost,
staleTime: 60000,
});
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
<button onClick={() => refetch()}>새로고침</button>
<LikeButton postId={post.id} />
</article>
);
}
이 방식의 장점
3가지 구성 비교
| 구성 | 초기 로딩 | 상호작용 | 번들 크기 | 데이터 동기화 | 추천도 |
|---|---|---|---|---|---|
| 순수 서버 | ⚡⚡ 매우 빠름 | ❌ 불가 | 작음 | ❌ 불가 | ⭐⭐ |
| 순수 클라이언트 | ⏱️ 느림 | ✅ 우수 | 큼 | ✅ 우수 | ⭐⭐ |
| 혼합 (권장) | ⚡ 빠름 | ✅ 우수 | 중간 | ✅ 우수 | ⭐⭐⭐⭐⭐ |
실제 시나리오별 최적 선택
• 가격이나 기본 정보 표시
• 약관이나 문서
• 좋아요나 댓글 기능
• 필터링이나 정렬 기능
• 채팅이나 스트리밍
• 실시간 알림
혼합 구성이 더 나은 이유
1️⃣ 사용자 경험
사용자 클릭 → RSC Payload 완료될 때까지 대기 → 표시
(네트워크가 느리면 오래 걸립니다)
사용자 클릭 → initialData로 즉시 표시 ⚡
백그라운드 동기화 시작
(항상 빠릅니다)
2️⃣ 번들 크기 최적화
// 순수 클라이언트 (큰 번들)
'use client';
import axios from 'axios'; // 20KB
import lodash from 'lodash'; // 70KB
import dateFns from 'date-fns'; // 50KB
// ... 많은 라이브러리
// 혼합 (작은 번들)
'use client';
import { useState } from 'react'; // React 기본만
// 나머지는 서버에서 처리
3️⃣ SEO 최적화
// 순수 클라이언트 (SEO 안 좋음)
'use client';
export default function BlogDetail() {
const { data: post } = useQuery({...});
// 초기에는 HTML이 없음 → 검색 엔진이 못 봄
return <h1>{post?.title}</h1>;
}
// 혼합 (SEO 좋음)
async function BlogPage({ params }: { params: { id: string } }) {
const response = await fetch(`/api/posts/${params.id}`);
const post = await response.json();
// ✅ 서버 렌더링 HTML이 있음 → 검색 엔진이 볼 수 있음
return <BlogDetail initialPost={post} />;
}
5.6 실전: 언제 뭘 쓸까?
가장 추천하는 패턴: 혼합 구성
대부분의 경우 서버에서 초기값 fetch → 클라이언트에서 상호작용이 최적입니다.
패턴 1: 블로그 + 댓글 (읽기 + 쓰기)
// app/blog/[id]/page.tsx - 서버 컴포넌트
async function BlogPage({ params }: { params: { id: string } }) {
// 1️⃣ 초기 포스트 데이터 (변하지 않음)
const response = await fetch(`/api/posts/${params.id}`, {
next: { revalidate: 3600 }
});
const post = await response.json();
return (
<div>
<BlogContent post={post} /> {/* 클라이언트 */}
<Comments postId={params.id} /> {/* 클라이언트 */}
</div>
);
}
// components/BlogContent.tsx - 클라이언트
'use client';
interface Post {
title: string;
content: string;
}
export function BlogContent({ post }: { post: Post }) {
const [liked, setLiked] = useState(false);
return (
<article>
<h1>{post.title}</h1>
<button onClick={() => setLiked(!liked)}>
{liked ? '❤️' : '🤍'}
</button>
</article>
);
}
// components/Comments.tsx - 클라이언트
'use client';
import { useQuery } from '@tanstack/react-query';
interface Comment {
id: string;
content: string;
}
export function Comments({ postId }: { postId: string }) {
// 2️⃣ 댓글은 실시간으로 fetch (변함)
const { data: comments = [] } = useQuery<Comment[]>({
queryKey: ['comments', postId],
queryFn: () => fetch(`/api/comments?postId=${postId}`).then(r => r.json()),
});
return (
<div>
{comments.map(c => <Comment key={c.id} comment={c} />)}
</div>
);
}
• 댓글은 백그라운드에서 동기화됩니다
• 좋아요는 즉시 반응합니다
• SEO가 최적화됩니다 (서버 렌더링 HTML)
패턴 2: 전자상거래 (상품 정보 + 사용자 상호작용)
// app/products/[id]/page.tsx - 서버 컴포넌트
async function ProductPage({ params }: { params: { id: string } }) {
// 1️⃣ 상품 정보 (자주 안 바뀜)
const response = await fetch(`/api/products/${params.id}`, {
next: { revalidate: 86400 }
});
const product = await response.json();
return (
<div>
<ProductDetail initialProduct={product} />
<Reviews productId={params.id} />
</div>
);
}
// components/ProductDetail.tsx - 클라이언트
'use client';
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
interface Product {
id: string;
name: string;
price: number;
}
export function ProductDetail({ initialProduct }: { initialProduct: Product }) {
const [quantity, setQuantity] = useState(1);
// 2️⃣ 가격은 실시간으로 확인 (주식처럼 변할 수 있음)
const { data: product = initialProduct } = useQuery<Product>({
queryKey: ['product', initialProduct.id],
queryFn: () => fetch(`/api/products/${initialProduct.id}`).then(r => r.json()),
initialData: initialProduct,
staleTime: 30000, // 30초
});
return (
<div>
<h1>{product.name}</h1>
<p>₩{product.price}</p>
<input
type="number"
value={quantity}
onChange={e => setQuantity(Number(e.target.value))}
/>
<button onClick={() => addToCart(product.id, quantity)}>
장바구니 추가
</button>
</div>
);
}
• 가격 변동은 백그라운드에서 감시합니다
• 사용자 상호작용이 즉시 반응합니다
• 장바구니 추가 같은 서버 액션이 가능합니다
패턴 3: 대시보드 (초기값 있는 실시간 데이터)
// app/dashboard/page.tsx - 서버 컴포넌트
async function DashboardPage() {
// 1️⃣ 초기 통계 (사용자가 보기 좋게)
const response = await fetch('https://api.example.com/stats', {
next: { revalidate: 0 } // 항상 새로운 데이터
});
const initialStats = await response.json();
return <DashboardContent initialStats={initialStats} />;
}
// components/DashboardContent.tsx - 클라이언트
'use client';
import { useQuery } from '@tanstack/react-query';
interface Stats {
users: number;
revenue: number;
}
export function DashboardContent({ initialStats }: { initialStats: Stats }) {
// 2️⃣ 실시간 통계 (자동 갱신)
const { data: stats = initialStats } = useQuery<Stats>({
queryKey: ['stats'],
queryFn: () => fetch('/api/stats').then(r => r.json()),
initialData: initialStats,
staleTime: 10000, // 10초마다 갱신
refetchInterval: 30000, // 30초마다 자동 갱신
});
return (
<div>
<Chart data={stats} /> {/* 초기값으로 즉시 표시 */}
<Summary data={stats} />
</div>
);
}
• 자동으로 갱신됩니다 (실시간 데이터)
• 사용자가 항상 최신 데이터를 봅니다
순수 서버 컴포넌트를 쓰는 경우
- 상호작용이 없습니다
- 데이터가 변하지 않습니다
- 정적 페이지입니다 (블로그 글이나 약관 등)
// 예: 약관 페이지
async function TermsPage() {
const response = await fetch('https://api.example.com/terms');
const terms = await response.json();
return <div>{terms.content}</div>;
}
순수 클라이언트 컴포넌트를 쓰는 경우
- 데이터가 매우 자주 변합니다
- 초기값이 필요하지 않습니다
- SEO가 불필요합니다
// 예: 실시간 채팅
'use client';
interface Message {
id: string;
text: string;
}
export function ChatRoom({ roomId }: { roomId: string }) {
const { data: messages } = useQuery<Message[]>({
queryKey: ['chat', roomId],
queryFn: () => fetch(`/api/chat/${roomId}`).then(r => r.json()),
refetchInterval: 1000, // 1초마다
});
return <div>{messages?.map(m => <Message key={m.id} message={m} />)}</div>;
}
혼합 구성을 쓰는 경우 (대부분)
- 일부 데이터는 정적입니다 (상품명이나 설명)
- 일부 데이터는 동적입니다 (가격이나 재고)
- 좋은 초기 로딩 시간이 필요합니다
- SEO가 필요합니다
핵심 정리
| 항목 | Preload | Prefetch |
|---|---|---|
| 사용 대상 | 서버 컴포넌트 | 클라이언트 컴포넌트 |
| 로드 내용 | 페이지 + 데이터 | JS 번들만 |
| 제어 방식 | 자동 | 수동 (prefetch 속성) |
| 사용자 경험 | 매우 빠름 ⚡⚡ | 빠름 ⚡ |
| 적합한 경우 | 정적이나 준정적 페이지 | 동적이나 실시간 콘텐츠 |
| 캐싱 효율 | 매우 높음 | 중간 |
6. 서버 컴포넌트의 장점
6.1 초기 렌더링 속도 향상 (FCP 및 LCP)
• Largest Contentful Paint (LCP)가 개선됩니다
• 사용자가 빠르게 콘텐츠를 확인할 수 있습니다
6.2 번들 크기 감소
서버 컴포넌트에서 사용하는 라이브러리는 클라이언트 번들에 포함되지 않습니다.
// 서버 컴포넌트 (번들에 미포함)
import axios from 'axios'; // ← 서버에서만 실행
import { expensiveFunction } from 'heavy-library';
async function ServerComponent() {
const data = await expensiveFunction();
return <div>{data}</div>;
}
// 클라이언트 컴포넌트 (번들에 포함)
'use client';
import axios from 'axios'; // ← 번들 크기 증가
import { smallFunction } from 'react-utils'; // ← 불필요하면 번들에 포함
function ClientComponent() {
return <div>...</div>;
}
• 페이지 로딩 속도가 향상됩니다
• 모바일 사용자 경험이 개선됩니다
6.3 민감한 정보 보호
API 키나 데이터베이스 연결 정보 등 민감한 정보를 안전하게 처리합니다.
// 서버 컴포넌트 (안전 ✅)
async function Dashboard() {
const response = await fetch('https://api.example.com/admin', {
headers: {
'Authorization': `Bearer ${process.env.SECRET_KEY}`
// ✅ 서버에서만 실행되고 클라이언트에 노출되지 않음
}
});
const data = await response.json();
return <div>{data}</div>;
}
// 클라이언트 컴포넌트 (위험 ❌)
'use client';
useEffect(() => {
fetch('https://api.example.com/admin', {
headers: {
'Authorization': `Bearer ${process.env.NEXT_PUBLIC_KEY}`
// ❌ 클라이언트에서 실행되므로 사용자 브라우저에 노출됨
}
});
});
6.4 데이터베이스에 직접 접근
서버 컴포넌트에서는 REST API를 거치지 않고 데이터베이스에 직접 접근할 수 있습니다.
// 서버 컴포넌트
import { db } from '@/lib/db';
interface User {
id: string;
name: string;
}
async function UserList() {
// API를 거치지 않고 직접 DB 쿼리
const users = await db.query<User>('SELECT * FROM users');
return <div>{users.map(u => <p key={u.id}>{u.name}</p>)}</div>;
}
• 네트워크 왕복 시간을 절감합니다
• 데이터 접근 로직을 단순화합니다
6.5 비교표
| 항목 | 서버 컴포넌트 | 클라이언트 컴포넌트 |
|---|---|---|
| 초기 로딩 | ⚡ 빠름 | ⏱️ 느림 |
| 번들 크기 | 📦 작음 | 📦 큼 |
| 보안 | 🔒 우수 | ⚠️ 취약 |
| 상호작용 | ❌ 불가 | ✅ 가능 |
| 데이터 페칭 | 📡 직접 가능 | 📡 API를 거쳐야 함 |
| 상태 관리 | ❌ 불가 | ✅ 가능 |
모범 사례
전체 아키텍처 패턴
// app/page.tsx - 서버 컴포넌트 (데이터 페칭)
async function Page() {
const response = await fetch('https://api.example.com/posts', {
next: { revalidate: 60 }
});
const initialData = await response.json();
return <PostContainer initialData={initialData} />;
}
// components/PostContainer.tsx - 서버 컴포넌트 (레이아웃)
function PostContainer({ initialData }: { initialData: unknown[] }) {
return (
<div className="container">
<PostFilters /> {/* 클라이언트 컴포넌트 */}
<PostList initialData={initialData} /> {/* 클라이언트 컴포넌트 */}
</div>
);
}
// components/PostList.tsx - 클라이언트 컴포넌트 (인터랙션)
'use client';
import { useQuery } from '@tanstack/react-query';
function PostList({ initialData }: { initialData: unknown[] }) {
const { data: posts = initialData } = useQuery({
queryKey: ['posts'],
queryFn: () => fetch('/api/posts').then(r => r.json()),
initialData,
staleTime: 60000,
});
return (
<div>
{/* 렌더링 로직 */}
</div>
);
}
// components/PostCard.tsx - 클라이언트 컴포넌트 (상호작용)
'use client';
interface Post {
id: string;
title: string;
}
function PostCard({ post }: { post: Post }) {
const [liked, setLiked] = useState(false);
return (
<div>
<h3>{post.title}</h3>
<button onClick={() => setLiked(!liked)}>
{liked ? '❤️' : '🤍'}
</button>
</div>
);
}
역할 분담
결론
Next.js 16에서 서버 컴포넌트는 성능과 보안 그리고 사용자 경험을 모두 개선하는 핵심 기능입니다. 올바른 캐싱 전략과 함께 Tanstack Query를 활용하면 최고의 개발 경험을 얻을 수 있습니다.
• 상호작용이 필요한 부분만 클라이언트로 분리합니다
• 캐싱 정책을 명확히 정의합니다
• Props로 초기 데이터를 전달한 후 Tanstack Query로 동기화합니다
'je개발 복습 > Next.js' 카테고리의 다른 글
| [ Next.js 공식문서 ] Cache Componet, PPR (0) | 2025.11.12 |
|---|