본문으로 건너뛰기
Advertisement

서버/클라이언트 컴포넌트 — RSC 개념, "use client", 컴포넌트 경계 설계

React Server Components란?

**React Server Components(RSC)**는 React 18에서 도입되고 Next.js App Router가 전면 채택한 패러다임입니다. 컴포넌트를 서버에서 렌더링하여 클라이언트로 HTML을 스트리밍합니다.

전통적인 렌더링 vs RSC

방식동작특징
CSR (Client-Side Rendering)브라우저에서 JS 실행초기 로딩 느림, 번들 크기 큼
SSR (Server-Side Rendering)서버에서 HTML 생성 후 클라이언트에서 하이드레이션매 요청마다 전체 컴포넌트 트리 처리
RSC서버에서 렌더링, JS 없이 HTML 스트리밍JS 번들 없음, DB 직접 접근, 빠른 초기 로딩

RSC의 핵심 장점

  1. 번들 크기 감소: 서버 컴포넌트의 의존성은 클라이언트 JS에 포함되지 않습니다
  2. 백엔드 직접 접근: DB, 파일 시스템, 내부 API에 직접 접근 가능
  3. 데이터 페칭 간소화: async/await를 컴포넌트 레벨에서 바로 사용
  4. 보안 강화: API 키, 민감 데이터가 클라이언트로 노출되지 않음
  5. 자동 코드 분할: 필요한 시점에 클라이언트 코드만 전송

서버 컴포넌트 기초

App Router에서 모든 컴포넌트는 기본적으로 서버 컴포넌트입니다. 아무런 지시어 없이 작성하면 서버에서 실행됩니다.

서버 컴포넌트의 특징

// app/components/UserProfile.tsx — 서버 컴포넌트 (기본값)
import { db } from '@/lib/database';

// ✅ 가능한 것들
// - async/await 최상위 레벨 사용
// - 데이터베이스 직접 쿼리
// - 파일 시스템 접근
// - 환경 변수 (서버 전용) 접근
// - 다른 서버 컴포넌트 렌더링
// - 클라이언트 컴포넌트 렌더링 (props로 데이터 전달)

async function UserProfile({ userId }: { userId: string }) {
// DB 직접 쿼리 — 클라이언트에 노출되지 않음
const user = await db.query('SELECT * FROM users WHERE id = ?', [userId]);
const posts = await db.query('SELECT * FROM posts WHERE user_id = ? LIMIT 5', [userId]);

return (
<div className="profile">
<h1>{user.name}</h1>
<p>{user.email}</p>
<PostList posts={posts} />
</div>
);
}

// ❌ 서버 컴포넌트에서 불가능한 것들
// - useState, useEffect 등 React 훅 사용
// - onClick 등 이벤트 핸들러 사용
// - 브라우저 전용 API (window, document) 접근
// - 'use client' 컴포넌트를 서버 컴포넌트로 임포트

서버 컴포넌트에서 데이터 페칭

// app/blog/[slug]/page.tsx
interface Post {
id: number;
title: string;
content: string;
author: { name: string; avatar: string };
publishedAt: string;
tags: string[];
}

async function getPost(slug: string): Promise<Post> {
const res = await fetch(`https://api.example.com/posts/${slug}`, {
next: { revalidate: 3600 }, // ISR: 1시간마다 재검증
});

if (!res.ok) {
if (res.status === 404) return null as any;
throw new Error(`포스트 로드 실패: ${res.status}`);
}

return res.json();
}

export default async function BlogPostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await getPost(slug);

return (
<article className="max-w-3xl mx-auto py-12">
<header className="mb-8">
<h1 className="text-4xl font-bold">{post.title}</h1>
<div className="flex items-center gap-3 mt-4">
<img
src={post.author.avatar}
alt={post.author.name}
className="w-10 h-10 rounded-full"
/>
<div>
<p className="font-medium">{post.author.name}</p>
<time className="text-sm text-gray-500">
{new Date(post.publishedAt).toLocaleDateString('ko-KR')}
</time>
</div>
</div>
</header>
<div className="prose">{post.content}</div>
</article>
);
}

클라이언트 컴포넌트

파일 최상단에 "use client" 지시어를 추가하면 클라이언트 컴포넌트가 됩니다.

클라이언트 컴포넌트가 필요한 경우

'use client';

// ✅ 클라이언트 컴포넌트에서 사용 가능한 것들
// - useState, useReducer, useEffect 등 모든 훅
// - onClick, onChange, onSubmit 등 이벤트 핸들러
// - window, document 등 브라우저 API
// - 사용자 인터랙션 처리
// - localStorage, sessionStorage 접근
// - Web Sockets, EventSource

기본 클라이언트 컴포넌트 예제

// components/Counter.tsx
'use client';

import { useState } from 'react';

export default function Counter({ initialCount = 0 }: { initialCount?: number }) {
const [count, setCount] = useState(initialCount);

return (
<div className="flex items-center gap-4">
<button
onClick={() => setCount((c) => c - 1)}
className="px-3 py-1 bg-gray-200 rounded"
>
-
</button>
<span className="text-xl font-bold">{count}</span>
<button
onClick={() => setCount((c) => c + 1)}
className="px-3 py-1 bg-blue-500 text-white rounded"
>
+
</button>
</div>
);
}
// 서버 컴포넌트에서 클라이언트 컴포넌트 사용
// app/page.tsx — 서버 컴포넌트
import Counter from '@/components/Counter';

export default function HomePage() {
return (
<main>
<h1></h1>
{/* 서버에서 initialCount를 계산하거나 DB에서 조회 후 props로 전달 */}
<Counter initialCount={42} />
</main>
);
}

폼과 이벤트 처리

// components/SearchBar.tsx
'use client';

import { useState, useTransition } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';

export default function SearchBar() {
const router = useRouter();
const searchParams = useSearchParams();
const [query, setQuery] = useState(searchParams.get('q') ?? '');
const [isPending, startTransition] = useTransition();

const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
startTransition(() => {
const params = new URLSearchParams(searchParams.toString());
if (query) {
params.set('q', query);
} else {
params.delete('q');
}
router.push(`/search?${params.toString()}`);
});
};

return (
<form onSubmit={handleSearch} className="flex gap-2">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="검색어를 입력하세요..."
className="flex-1 px-4 py-2 border rounded-lg"
/>
<button
type="submit"
disabled={isPending}
className="px-4 py-2 bg-blue-500 text-white rounded-lg disabled:opacity-50"
>
{isPending ? '검색 중...' : '검색'}
</button>
</form>
);
}

컴포넌트 경계 설계

원칙: 최대한 서버에서, 필요한 곳만 클라이언트로

페이지 (서버)
├── 헤더 (서버) — DB에서 사용자 정보 조회
│ └── NavMenu (클라이언트) — 드롭다운 메뉴 인터랙션
├── 메인 콘텐츠 (서버) — DB에서 데이터 조회
│ ├── ArticleList (서버) — 정적 렌더링
│ │ └── LikeButton (클라이언트) — 좋아요 버튼 클릭
│ └── Sidebar (서버)
│ └── FilterPanel (클라이언트) — 필터 UI 상태
└── 푸터 (서버)

올바른 분리 예제

// app/dashboard/page.tsx — 서버 컴포넌트
import { db } from '@/lib/database';
import StatsCard from './StatsCard';
import RecentActivity from './RecentActivity';
import QuickActions from './QuickActions'; // 클라이언트 컴포넌트

async function getDashboardData() {
const [stats, activities] = await Promise.all([
db.getStats(),
db.getRecentActivities(),
]);
return { stats, activities };
}

export default async function DashboardPage() {
const { stats, activities } = await getDashboardData();

return (
<div className="grid grid-cols-3 gap-6 p-6">
{/* 서버에서 데이터를 조회하고 props로 전달 */}
<StatsCard title="총 사용자" value={stats.totalUsers} />
<StatsCard title="오늘 방문자" value={stats.todayVisitors} />
<StatsCard title="활성 세션" value={stats.activeSessions} />

<div className="col-span-2">
<RecentActivity activities={activities} />
</div>

{/* 클라이언트 컴포넌트: 인터랙션 필요 */}
<QuickActions />
</div>
);
}
// components/StatsCard.tsx — 서버 컴포넌트 (인터랙션 없음)
interface StatsCardProps {
title: string;
value: number;
trend?: number;
}

export default function StatsCard({ title, value, trend }: StatsCardProps) {
return (
<div className="bg-white rounded-xl shadow p-6">
<h3 className="text-sm text-gray-500">{title}</h3>
<p className="text-3xl font-bold mt-2">{value.toLocaleString()}</p>
{trend !== undefined && (
<p className={`text-sm mt-1 ${trend >= 0 ? 'text-green-500' : 'text-red-500'}`}>
{trend >= 0 ? '▲' : '▼'} {Math.abs(trend)}%
</p>
)}
</div>
);
}
// components/QuickActions.tsx — 클라이언트 컴포넌트 (인터랙션 있음)
'use client';

import { useState } from 'react';

const actions = [
{ label: '새 포스트', href: '/dashboard/posts/new' },
{ label: '사용자 초대', href: '/dashboard/invite' },
{ label: '리포트 생성', href: '/dashboard/reports' },
];

export default function QuickActions() {
const [expanded, setExpanded] = useState(false);

return (
<div className="bg-white rounded-xl shadow p-6">
<button
onClick={() => setExpanded(!expanded)}
className="w-full text-left font-semibold"
>
빠른 작업 {expanded ? '▲' : '▼'}
</button>
{expanded && (
<ul className="mt-4 space-y-2">
{actions.map((action) => (
<li key={action.href}>
<a href={action.href} className="block p-2 hover:bg-gray-50 rounded">
{action.label}
</a>
</li>
))}
</ul>
)}
</div>
);
}

children prop으로 서버 컴포넌트 끌어올리기

클라이언트 컴포넌트 안에 서버 컴포넌트를 직접 임포트할 수 없습니다. 하지만 children prop을 통해 서버 컴포넌트를 클라이언트 컴포넌트 내부에 렌더링할 수 있습니다.

// ❌ 잘못된 방법 — 클라이언트 컴포넌트에서 서버 컴포넌트 임포트
'use client';

import ServerComponent from './ServerComponent'; // 에러! 서버 컴포넌트를 여기서 import 불가

export default function ClientWrapper() {
return (
<div>
<ServerComponent /> {/* 작동하지 않음 */}
</div>
);
}
// ✅ 올바른 방법 — children으로 전달
// components/ClientWrapper.tsx — 클라이언트 컴포넌트
'use client';

import { useState } from 'react';

export default function ClientWrapper({
children,
}: {
children: React.ReactNode;
}) {
const [open, setOpen] = useState(false);

return (
<div>
<button onClick={() => setOpen(!open)}>
{open ? '접기' : '펼치기'}
</button>
{open && <div className="mt-4">{children}</div>}
</div>
);
}
// app/page.tsx — 서버 컴포넌트
import ClientWrapper from '@/components/ClientWrapper';
import ServerContent from '@/components/ServerContent';

export default function Page() {
return (
<ClientWrapper>
{/* ServerContent는 서버에서 렌더링되어 children으로 전달됨 */}
<ServerContent />
</ClientWrapper>
);
}
// components/ServerContent.tsx — 서버 컴포넌트
import { db } from '@/lib/database';

export default async function ServerContent() {
const data = await db.getLatestData();
return (
<ul>
{data.map((item: any) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
}

서버 액션 (Server Actions)

서버에서 실행되는 함수를 클라이언트에서 직접 호출합니다. 폼 제출과 데이터 변경에 이상적입니다.

기본 서버 액션

// app/actions.ts
'use server';

import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { db } from '@/lib/database';

export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;

if (!title || !content) {
return { error: '제목과 내용은 필수입니다' };
}

const post = await db.posts.create({
data: { title, content, publishedAt: new Date() },
});

revalidatePath('/blog'); // 블로그 목록 캐시 무효화
redirect(`/blog/${post.slug}`);
}

export async function deletePost(id: string) {
await db.posts.delete({ where: { id } });
revalidatePath('/blog');
}

export async function updatePost(id: string, formData: FormData) {
const updates = {
title: formData.get('title') as string,
content: formData.get('content') as string,
};

await db.posts.update({ where: { id }, data: updates });
revalidatePath('/blog');
revalidatePath(`/blog/${id}`);
}

폼에서 서버 액션 사용

// app/blog/new/page.tsx — 서버 컴포넌트
import { createPost } from '@/app/actions';

export default function NewPostPage() {
return (
<div className="max-w-2xl mx-auto py-12 px-4">
<h1 className="text-2xl font-bold mb-8">새 포스트 작성</h1>
{/* action에 서버 액션 함수 직접 전달 */}
<form action={createPost} className="space-y-6">
<div>
<label htmlFor="title" className="block font-medium mb-1">
제목
</label>
<input
id="title"
name="title"
type="text"
required
className="w-full px-4 py-2 border rounded-lg"
/>
</div>
<div>
<label htmlFor="content" className="block font-medium mb-1">
내용
</label>
<textarea
id="content"
name="content"
rows={10}
required
className="w-full px-4 py-2 border rounded-lg"
/>
</div>
<button
type="submit"
className="px-6 py-2 bg-blue-500 text-white rounded-lg"
>
발행
</button>
</form>
</div>
);
}

클라이언트 컴포넌트에서 서버 액션 사용

// components/DeleteButton.tsx
'use client';

import { useTransition } from 'react';
import { deletePost } from '@/app/actions';

export default function DeleteButton({ postId }: { postId: string }) {
const [isPending, startTransition] = useTransition();

const handleDelete = () => {
if (!confirm('정말 삭제하시겠습니까?')) return;

startTransition(async () => {
await deletePost(postId);
});
};

return (
<button
onClick={handleDelete}
disabled={isPending}
className="px-4 py-2 bg-red-500 text-white rounded disabled:opacity-50"
>
{isPending ? '삭제 중...' : '삭제'}
</button>
);
}

useActionState로 폼 상태 관리

// components/PostForm.tsx
'use client';

import { useActionState } from 'react';
import { createPost } from '@/app/actions';

interface FormState {
error?: string;
success?: boolean;
}

export default function PostForm() {
const [state, formAction, isPending] = useActionState<FormState, FormData>(
async (prevState, formData) => {
const result = await createPost(formData);
if (result?.error) return { error: result.error };
return { success: true };
},
{}
);

return (
<form action={formAction} className="space-y-4">
{state.error && (
<div className="p-3 bg-red-50 text-red-600 rounded">{state.error}</div>
)}
{state.success && (
<div className="p-3 bg-green-50 text-green-600 rounded">
포스트가 성공적으로 발행되었습니다!
</div>
)}
<input name="title" placeholder="제목" className="w-full border p-2 rounded" />
<textarea name="content" rows={6} className="w-full border p-2 rounded" />
<button type="submit" disabled={isPending}>
{isPending ? '발행 중...' : '발행'}
</button>
</form>
);
}

실전 예제 — 커머스 상품 페이지

서버/클라이언트 컴포넌트를 적절히 분리한 실전 예제입니다.

// app/products/[id]/page.tsx — 서버 컴포넌트
import { notFound } from 'next/navigation';
import { db } from '@/lib/database';
import AddToCartButton from '@/components/AddToCartButton'; // 클라이언트
import ProductGallery from '@/components/ProductGallery'; // 클라이언트
import ReviewList from '@/components/ReviewList'; // 서버
import RelatedProducts from '@/components/RelatedProducts'; // 서버

interface Product {
id: string;
name: string;
price: number;
description: string;
images: string[];
stock: number;
rating: number;
reviewCount: number;
}

async function getProduct(id: string): Promise<Product | null> {
return db.products.findUnique({ where: { id } });
}

export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const product = await getProduct(id);

if (!product) notFound();

return (
<div className="max-w-6xl mx-auto py-12 px-4">
<div className="grid grid-cols-2 gap-12">
{/* 이미지 갤러리: 클라이언트 (슬라이드 인터랙션) */}
<ProductGallery images={product.images} name={product.name} />

{/* 상품 정보: 서버 */}
<div>
<h1 className="text-3xl font-bold">{product.name}</h1>
<div className="flex items-center gap-2 mt-2">
<span className="text-yellow-500">{'★'.repeat(Math.round(product.rating))}</span>
<span className="text-gray-500">({product.reviewCount}개 리뷰)</span>
</div>
<p className="text-2xl font-bold mt-4">
{product.price.toLocaleString('ko-KR')}
</p>
<p className="text-gray-600 mt-4">{product.description}</p>

{/* 장바구니 버튼: 클라이언트 (클릭 이벤트) */}
<AddToCartButton
productId={product.id}
stock={product.stock}
/>
</div>
</div>

{/* 리뷰 목록: 서버 (DB 직접 조회) */}
<div className="mt-16">
<h2 className="text-2xl font-bold mb-6">리뷰</h2>
<ReviewList productId={product.id} />
</div>

{/* 관련 상품: 서버 */}
<div className="mt-16">
<h2 className="text-2xl font-bold mb-6">관련 상품</h2>
<RelatedProducts productId={product.id} />
</div>
</div>
);
}
// components/AddToCartButton.tsx — 클라이언트 컴포넌트
'use client';

import { useState, useTransition } from 'react';
import { addToCart } from '@/app/actions';

interface Props {
productId: string;
stock: number;
}

export default function AddToCartButton({ productId, stock }: Props) {
const [quantity, setQuantity] = useState(1);
const [added, setAdded] = useState(false);
const [isPending, startTransition] = useTransition();

const handleAddToCart = () => {
startTransition(async () => {
await addToCart(productId, quantity);
setAdded(true);
setTimeout(() => setAdded(false), 2000);
});
};

if (stock === 0) {
return (
<button disabled className="w-full mt-6 py-3 bg-gray-300 text-gray-500 rounded-lg">
품절
</button>
);
}

return (
<div className="mt-6 space-y-3">
<div className="flex items-center gap-3">
<label className="font-medium">수량</label>
<select
value={quantity}
onChange={(e) => setQuantity(Number(e.target.value))}
className="border rounded px-2 py-1"
>
{Array.from({ length: Math.min(stock, 10) }, (_, i) => i + 1).map((n) => (
<option key={n} value={n}>{n}</option>
))}
</select>
<span className="text-sm text-gray-500">재고: {stock}</span>
</div>
<button
onClick={handleAddToCart}
disabled={isPending || added}
className="w-full py-3 bg-blue-500 text-white rounded-lg font-medium
hover:bg-blue-600 disabled:opacity-50 transition-colors"
>
{isPending ? '추가 중...' : added ? '✓ 추가됨' : '장바구니에 담기'}
</button>
</div>
);
}
// components/ReviewList.tsx — 서버 컴포넌트
import { db } from '@/lib/database';

interface Review {
id: string;
rating: number;
title: string;
content: string;
author: string;
createdAt: string;
}

export default async function ReviewList({ productId }: { productId: string }) {
const reviews: Review[] = await db.reviews.findMany({
where: { productId },
orderBy: { createdAt: 'desc' },
take: 10,
});

if (reviews.length === 0) {
return <p className="text-gray-500">아직 리뷰가 없습니다.</p>;
}

return (
<ul className="space-y-6">
{reviews.map((review) => (
<li key={review.id} className="border-b pb-6">
<div className="flex items-center justify-between">
<span className="font-medium">{review.author}</span>
<span className="text-yellow-500">{'★'.repeat(review.rating)}</span>
</div>
<h3 className="font-semibold mt-2">{review.title}</h3>
<p className="text-gray-600 mt-1">{review.content}</p>
<time className="text-sm text-gray-400 mt-2 block">
{new Date(review.createdAt).toLocaleDateString('ko-KR')}
</time>
</li>
))}
</ul>
);
}

Suspense와 스트리밍

서버 컴포넌트는 <Suspense>와 함께 사용하여 느린 데이터 페칭을 스트리밍으로 처리합니다.

// app/dashboard/page.tsx
import { Suspense } from 'react';
import UserStats from '@/components/UserStats';
import RecentOrders from '@/components/RecentOrders';
import Notifications from '@/components/Notifications';

export default function DashboardPage() {
return (
<div className="space-y-6">
<h1>대시보드</h1>

{/* 빠른 컴포넌트: Suspense 없이 바로 렌더링 */}
<UserStats />

{/* 느린 컴포넌트: 각각 독립적으로 스트리밍 */}
<div className="grid grid-cols-2 gap-6">
<Suspense fallback={<OrderSkeleton />}>
<RecentOrders />
</Suspense>

<Suspense fallback={<NotificationSkeleton />}>
<Notifications />
</Suspense>
</div>
</div>
);
}

function OrderSkeleton() {
return (
<div className="animate-pulse space-y-3">
{[...Array(5)].map((_, i) => (
<div key={i} className="h-16 bg-gray-200 rounded"></div>
))}
</div>
);
}

function NotificationSkeleton() {
return (
<div className="animate-pulse space-y-3">
{[...Array(3)].map((_, i) => (
<div key={i} className="h-12 bg-gray-200 rounded"></div>
))}
</div>
);
}

고수 팁

1. 경계 결정 체크리스트

컴포넌트에 다음이 있으면 → 클라이언트 컴포넌트:
✓ useState, useReducer, useEffect
✓ onClick, onChange, onSubmit 등 이벤트 핸들러
✓ window, document, navigator
✓ 사용자 인터랙션 기반 UI 변화

다음만 있다면 → 서버 컴포넌트 유지:
✓ DB 쿼리, API 호출
✓ 파일 시스템 접근
✓ 서버 전용 환경 변수
✓ 정적 렌더링 (애니메이션 없음)

2. 클라이언트 컴포넌트 최하위로 내리기

// ❌ 비효율 — 불필요하게 큰 범위가 클라이언트 컴포넌트
'use client';

export default function ArticlePage({ article }: { article: any }) {
const [liked, setLiked] = useState(false);

return (
<article>
{/* 이 부분은 인터랙션 없음 — 서버에서 렌더링하는 게 더 좋음 */}
<h1>{article.title}</h1>
<p>{article.content}</p>
<img src={article.image} alt={article.title} />

{/* 인터랙션은 여기만 */}
<button onClick={() => setLiked(!liked)}>
{liked ? '❤️' : '🤍'}
</button>
</article>
);
}
// ✅ 개선 — 클라이언트 컴포넌트를 최소 범위로 분리
// components/LikeButton.tsx — 클라이언트
'use client';
import { useState } from 'react';

export default function LikeButton() {
const [liked, setLiked] = useState(false);
return (
<button onClick={() => setLiked(!liked)}>
{liked ? '❤️' : '🤍'}
</button>
);
}

// app/articles/[slug]/page.tsx — 서버 (대부분)
import LikeButton from '@/components/LikeButton';

export default async function ArticlePage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const article = await getArticle(slug);

return (
<article>
<h1>{article.title}</h1>
<p>{article.content}</p>
<img src={article.image} alt={article.title} />
<LikeButton /> {/* 클라이언트 컴포넌트는 이것만 */}
</article>
);
}

3. context는 클라이언트 전용 — Provider 패턴

// components/providers/ThemeProvider.tsx
'use client';

import { createContext, useContext, useState } from 'react';

type Theme = 'light' | 'dark';

const ThemeContext = createContext<{
theme: Theme;
toggleTheme: () => void;
} | null>(null);

export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>('light');

return (
<ThemeContext.Provider
value={{ theme, toggleTheme: () => setTheme((t) => (t === 'light' ? 'dark' : 'light')) }}
>
{children}
</ThemeContext.Provider>
);
}

export function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('ThemeProvider 내부에서 사용하세요');
return ctx;
}
// app/layout.tsx — 서버 컴포넌트이지만 Provider는 클라이언트 컴포넌트
import { ThemeProvider } from '@/components/providers/ThemeProvider';

export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ko">
<body>
{/* Provider는 클라이언트 컴포넌트, children은 서버 컴포넌트 가능 */}
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
);
}

4. 서버 전용 코드 보호 — server-only 패키지

npm install server-only
// lib/database.ts
import 'server-only'; // 이 파일이 클라이언트 번들에 포함되면 빌드 에러

import { PrismaClient } from '@prisma/client';

const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };

export const db = globalForPrisma.prisma ?? new PrismaClient();

if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db;

5. React의 cache()로 중복 요청 제거

// lib/queries.ts
import { cache } from 'react';
import { db } from './database';

// 같은 렌더 사이클에서 동일 인자로 호출 시 캐시됨
export const getUser = cache(async (id: string) => {
return db.users.findUnique({ where: { id } });
});

export const getPost = cache(async (slug: string) => {
return db.posts.findUnique({ where: { slug } });
});
// 여러 컴포넌트에서 동일 쿼리를 호출해도 DB에는 한 번만 요청
// app/page.tsx
async function UserGreeting({ userId }: { userId: string }) {
const user = await getUser(userId); // DB 쿼리
return <p>안녕하세요, {user?.name}</p>;
}

async function UserAvatar({ userId }: { userId: string }) {
const user = await getUser(userId); // 캐시에서 반환 (DB 쿼리 없음)
return <img src={user?.avatar} alt={user?.name} />;
}
Advertisement