서버 컴포넌트와 클라이언트 컴포넌트, 언제 뭘 써야 할까?
안녕하세요! 포테코입니다.
Next.js 13에서 도입된 App Router는 React의 서버 컴포넌트를 기반으로 한 혁신적인 아키텍처예요. 이번 글에서는 서버 컴포넌트와 클라이언트 컴포넌트의 개념부터 실전 활용법, 서버 액션까지 실무 관점에서 다뤄볼게요.
서버 컴포넌트 vs 클라이언트 컴포넌트
서버 컴포넌트 (Server Components)
서버 컴포넌트는 서버에서만 렌더링되는 컴포넌트예요. 기본적으로 Next.js의 모든 컴포넌트는 서버 컴포넌트입니다.
서버 컴포넌트는 브라우저로 JavaScript 번들이 전송되지 않아 번들 크기를 줄일 수 있습니다.
번들 크기 감소의 이점: 초기 로딩 속도 향상, 네트워크 비용 감소, 모바일 사용자 경험 개선, SEO 및 Core Web Vitals 개선, 빌드 시간 단축 및 배포 속도 향상
서버 컴포넌트의 특징:
- 서버에서만 실행되며, 클라이언트로 전송되지 않음
- 데이터베이스나 파일 시스템에 직접 접근 가능
- API 키나 비밀 정보를 안전하게 사용 가능
- 번들 크기에 포함되지 않음
클라이언트 컴포넌트 (Client Components)
클라이언트 컴포넌트는 브라우저에서 실행되는 전통적인 React 컴포넌트예요. "use client" 지시어를 파일 상단에 추가하면 클라이언트 컴포넌트가 됩니다.
"use client";
import { useState } from "react";
export function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
);
}클라이언트 컴포넌트의 특징:
- 브라우저에서 실행되며, JavaScript 번들에 포함됨
- React Hooks (
useState,useEffect등) 사용 가능 - 브라우저 API (
window,localStorage등) 접근 가능 - 이벤트 핸들러 (
onClick,onChange등) 사용 가능
언제 무엇을 사용해야 할까?
서버 컴포넌트를 사용해야 하는 경우
- 데이터 페칭: 데이터베이스나 API에서 데이터를 가져올 때
- 백엔드 리소스 접근: 파일 시스템, 환경 변수 등에 접근할 때
- 보안이 중요한 정보: API 키, 토큰 등 민감한 정보를 사용할 때
- 큰 의존성: 서버에서만 필요한 무거운 라이브러리를 사용할 때
// ✅ 서버 컴포넌트 예시
import { db } from "@/lib/db";
export default async function UserList() {
// 서버에서 직접 데이터베이스 쿼리
const users = await db.user.findMany();
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}클라이언트 컴포넌트를 사용해야 하는 경우
- 인터랙티브 요소: 버튼 클릭, 폼 입력 등 사용자 상호작용
- 상태 관리:
useState,useReducer등으로 로컬 상태 관리 - 라이프사이클:
useEffect로 부수 효과 처리 - 브라우저 API:
window,localStorage,navigator등 사용
// ✅ 클라이언트 컴포넌트 예시
"use client";
import { useState, useEffect } from "react";
export function ThemeToggle() {
const [theme, setTheme] = useState("light");
useEffect(() => {
document.documentElement.setAttribute("data-theme", theme);
}, [theme]);
return (
<button onClick={() => setTheme(theme === "light" ? "dark" : "light")}>
Toggle Theme
</button>
);
}컴포넌트 구성 전략
컴포넌트 트리 구조
최적의 성능을 위해 서버 컴포넌트를 기본으로 사용하고, 필요한 부분만 클라이언트 컴포넌트로 분리하는 것이 좋아요.
App (서버)
├── Header (서버)
│ └── ThemeToggle (클라이언트) ← 인터랙티브 필요
├── UserList (서버) ← 데이터 페칭
│ └── UserCard (서버)
└── Footer (서버)서버 컴포넌트와 클라이언트 컴포넌트는 함께 사용할 수 있습니다. 서버 컴포넌트가 클라이언트 컴포넌트를 import하고 렌더링할 수 있지만, 클라이언트 컴포넌트는 서버 컴포넌트를 직접 import할 수 없습니다.
실전 패턴: 서버 + 클라이언트 조합
// app/products/page.tsx (서버 컴포넌트)
import { db } from "@/lib/db";
import { ProductList } from "@/components/products/ProductList";
export default async function ProductsPage() {
// 서버에서 데이터 페칭
const products = await db.product.findMany();
return (
<div>
<h1>Products</h1>
{/* 클라이언트 컴포넌트에 서버 데이터 전달 */}
<ProductList initialProducts={products} />
</div>
);
}// components/products/ProductList.tsx (클라이언트 컴포넌트)
"use client";
import { useState } from "react";
interface ProductListProps {
initialProducts: Product[];
}
export function ProductList({ initialProducts }: ProductListProps) {
const [products, setProducts] = useState(initialProducts);
const [filter, setFilter] = useState("");
const filteredProducts = products.filter((p) =>
p.name.toLowerCase().includes(filter.toLowerCase())
);
return (
<div>
<input
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="Search products..."
/>
<ul>
{filteredProducts.map((product) => (
<li key={product.id}>{product.name}</li>
))}
</ul>
</div>
);
}서버 액션 (Server Actions)
서버 액션은 서버에서 실행되는 함수로, 클라이언트 컴포넌트에서 직접 호출할 수 있어요. "use server" 지시어를 사용해서 정의합니다.
기본 사용법
// app/actions/user.ts
"use server";
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
export async function createUser(formData: FormData) {
const name = formData.get("name") as string;
const email = formData.get("email") as string;
// 서버에서 데이터베이스에 저장
const user = await db.user.create({
data: { name, email },
});
// 캐시 재검증
revalidatePath("/users");
return user;
}// components/UserForm.tsx (클라이언트 컴포넌트)
"use client";
import { createUser } from "@/app/actions/user";
import { useTransition } from "react";
export function UserForm() {
const [isPending, startTransition] = useTransition();
async function handleSubmit(formData: FormData) {
startTransition(async () => {
await createUser(formData);
});
}
return (
<form action={handleSubmit}>
<input name="name" placeholder="Name" required />
<input name="email" type="email" placeholder="Email" required />
<button type="submit" disabled={isPending}>
{isPending ? "Creating..." : "Create User"}
</button>
</form>
);
}서버 액션의 장점
- API 라우트 불필요: 별도의 API 엔드포인트를 만들 필요가 없음
- 타입 안정성: TypeScript로 타입 체크 가능
- 자동 최적화: Next.js가 자동으로 최적화
- 프로그레시브 향상: JavaScript가 없어도 폼 제출 가능
서버 액션은 서버에서만 실행되므로, 클라이언트 사이드 로직이나 브라우저 API를 사용할 수 없습니다. 또한 항상 비동기 함수여야 합니다.
실전 예제: Todo 앱 구현
서버 컴포넌트, 클라이언트 컴포넌트, 서버 액션을 모두 활용한 Todo 앱을 구현해볼게요.
1. 서버 액션 정의
// app/actions/todo.ts
"use server";
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
export async function createTodo(formData: FormData) {
const title = formData.get("title") as string;
if (!title || title.trim() === "") {
throw new Error("Title is required");
}
await db.todo.create({
data: {
title: title.trim(),
completed: false,
},
});
revalidatePath("/todos");
}
export async function toggleTodo(id: string, completed: boolean) {
await db.todo.update({
where: { id },
data: { completed },
});
revalidatePath("/todos");
}
export async function deleteTodo(id: string) {
await db.todo.delete({
where: { id },
});
revalidatePath("/todos");
}2. 서버 컴포넌트 (페이지)
// app/todos/page.tsx
import { db } from "@/lib/db";
import { TodoList } from "@/components/todos/TodoList";
export default async function TodosPage() {
// 서버에서 데이터 페칭
const todos = await db.todo.findMany({
orderBy: { createdAt: "desc" },
});
return (
<div className="container mx-auto p-8">
<h1 className="text-3xl font-bold mb-8">My Todos</h1>
<TodoList initialTodos={todos} />
</div>
);
}3. 클라이언트 컴포넌트 (Todo 리스트)
// components/todos/TodoList.tsx
"use client";
import { useState, useTransition } from "react";
import { createTodo, toggleTodo, deleteTodo } from "@/app/actions/todo";
import { TodoItem } from "./TodoItem";
import { TodoForm } from "./TodoForm";
interface Todo {
id: string;
title: string;
completed: boolean;
}
interface TodoListProps {
initialTodos: Todo[];
}
export function TodoList({ initialTodos }: TodoListProps) {
const [todos, setTodos] = useState(initialTodos);
const [isPending, startTransition] = useTransition();
async function handleCreate(formData: FormData) {
startTransition(async () => {
await createTodo(formData);
// 서버 액션 후 데이터를 다시 가져오거나 낙관적 업데이트
});
}
async function handleToggle(id: string, completed: boolean) {
const previousTodos = todos;
// 낙관적 업데이트
setTodos((prev) =>
prev.map((todo) =>
todo.id === id ? { ...todo, completed } : todo
)
);
startTransition(async () => {
try {
await toggleTodo(id, completed);
} catch (error) {
// 에러 발생 시 이전 상태로 복원
setTodos(previousTodos);
}
});
}
async function handleDelete(id: string) {
const previousTodos = todos;
// 낙관적 업데이트
setTodos((prev) => prev.filter((todo) => todo.id !== id));
startTransition(async () => {
try {
await deleteTodo(id);
} catch (error) {
// 에러 발생 시 이전 상태로 복원
setTodos(previousTodos);
}
});
}
return (
<div className="space-y-4">
<TodoForm onSubmit={handleCreate} isPending={isPending} />
<ul className="space-y-2">
{todos.map((todo) => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={handleToggle}
onDelete={handleDelete}
/>
))}
</ul>
</div>
);
}4. 클라이언트 컴포넌트 (Todo 폼)
// components/todos/TodoForm.tsx
"use client";
import { useRef } from "react";
interface TodoFormProps {
onSubmit: (formData: FormData) => void;
isPending: boolean;
}
export function TodoForm({ onSubmit, isPending }: TodoFormProps) {
const formRef = useRef<HTMLFormElement>(null);
async function handleSubmit(formData: FormData) {
await onSubmit(formData);
formRef.current?.reset();
}
return (
<form ref={formRef} action={handleSubmit} className="flex gap-2">
<input
name="title"
placeholder="Add a new todo..."
className="flex-1 px-4 py-2 border rounded-lg"
required
disabled={isPending}
/>
<button
type="submit"
disabled={isPending}
className="px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50"
>
{isPending ? "Adding..." : "Add"}
</button>
</form>
);
}5. 클라이언트 컴포넌트 (Todo 아이템)
// components/todos/TodoItem.tsx
"use client";
interface Todo {
id: string;
title: string;
completed: boolean;
}
interface TodoItemProps {
todo: Todo;
onToggle: (id: string, completed: boolean) => void;
onDelete: (id: string) => void;
}
export function TodoItem({ todo, onToggle, onDelete }: TodoItemProps) {
return (
<li className="flex items-center gap-3 p-3 border rounded-lg">
<input
type="checkbox"
checked={todo.completed}
onChange={(e) => onToggle(todo.id, e.target.checked)}
className="w-5 h-5"
/>
<span
className={`flex-1 ${
todo.completed ? "line-through text-gray-500" : ""
}`}
>
{todo.title}
</span>
<button
onClick={() => onDelete(todo.id)}
className="px-3 py-1 text-red-500 hover:bg-red-50 rounded"
>
Delete
</button>
</li>
);
}베스트 프랙티스
1. 컴포넌트 경계 최소화
클라이언트 컴포넌트는 필요한 최소한의 부분만으로 제한하는 것이 좋아요.
// ❌ 나쁜 예: 전체를 클라이언트 컴포넌트로
"use client";
export function ProductPage() {
const [products, setProducts] = useState([]);
useEffect(() => {
fetch("/api/products").then((res) => res.json()).then(setProducts);
}, []);
return <div>{/* ... */}</div>;
}
// ✅ 좋은 예: 필요한 부분만 클라이언트 컴포넌트로
// app/products/page.tsx (서버)
export default async function ProductPage() {
const products = await getProducts();
return <ProductList initialProducts={products} />;
}
// components/ProductList.tsx (클라이언트)
"use client";
export function ProductList({ initialProducts }) {
const [filter, setFilter] = useState("");
// ...
}2. 서버 액션 타입 안정성
서버 액션에 TypeScript 타입을 명시해서 타입 안정성을 보장하는 것이 좋아요.
"use server";
interface CreateUserInput {
name: string;
email: string;
}
export async function createUser(input: CreateUserInput) {
// 타입 체크가 보장됨
const user = await db.user.create({
data: input,
});
return user;
}3. 에러 처리
서버 액션에서 적절한 에러 처리를 구현하는 것이 중요해요.
"use server";
export async function createUser(formData: FormData) {
try {
const name = formData.get("name") as string;
const email = formData.get("email") as string;
if (!name || !email) {
return { error: "Name and email are required" };
}
const user = await db.user.create({
data: { name, email },
});
revalidatePath("/users");
return { success: true, user };
} catch (error) {
return { error: "Failed to create user" };
}
}4. 낙관적 업데이트
사용자 경험을 향상시키기 위해 낙관적 업데이트를 활용하면 좋아요.
낙관적 업데이트는 서버 응답을 기다리지 않고 UI를 먼저 업데이트하는 기법입니다. 서버 요청이 실패하면 이전 상태로 롤백합니다.
성능 최적화
1. 번들 크기 최소화
서버 컴포넌트를 최대한 활용해서 클라이언트 번들 크기를 줄이는 것이 중요해요.
// ✅ 서버 컴포넌트: 번들에 포함되지 않음
import { heavyLibrary } from "heavy-library";
export default function HeavyComponent() {
const data = heavyLibrary.process();
return <div>{data}</div>;
}2. 스트리밍과 Suspense
Suspense를 활용해서 점진적으로 콘텐츠를 로드할 수 있어요.
import { Suspense } from "react";
export default function Page() {
return (
<div>
<Suspense fallback={<div>Loading users...</div>}>
<UserList />
</Suspense>
<Suspense fallback={<div>Loading posts...</div>}>
<PostList />
</Suspense>
</div>
);
}주의사항
1. 클라이언트 컴포넌트에서 서버 컴포넌트 import 불가
// ❌ 불가능
"use client";
import ServerComponent from "./ServerComponent";
export function ClientComponent() {
return <ServerComponent />; // 에러!
}
// ✅ 가능: props로 전달
"use client";
export function ClientComponent({ children }: { children: React.ReactNode }) {
return <div>{children}</div>;
}
// 사용
<ClientComponent>
<ServerComponent />
</ClientComponent>2. 서버 컴포넌트에서 브라우저 API 사용 불가
// ❌ 불가능
export default function ServerComponent() {
const [count, setCount] = useState(0); // 에러!
useEffect(() => {}, []); // 에러!
window.localStorage.getItem("key"); // 에러!
}
// ✅ 가능: 클라이언트 컴포넌트로 분리
("use client");
export function ClientComponent() {
const [count, setCount] = useState(0);
// ...
}마무리
서버 컴포넌트와 클라이언트 컴포넌트를 올바르게 활용하면 이런 이점들이 있어요.
- 성능 향상: 번들 크기 감소, 초기 로딩 속도 개선
- 개발자 경험: 타입 안정성, 간단한 데이터 페칭
- 사용자 경험: 빠른 페이지 로드, 부드러운 인터랙션
서버 컴포넌트를 기본으로 사용하고, 인터랙티브가 필요한 부분만 클라이언트 컴포넌트로 분리하는 것이 Next.js App Router의 핵심 철학이에요.
서버 컴포넌트와 클라이언트 컴포넌트를 적절히 활용하면 더 빠르고 효율적인 웹 애플리케이션을 만들 수 있어요.
앞으로도 Next.js의 최신 기능들을 활용한 실전 사례와 팁을 계속 공유할 예정이니 기대해 주세요!
