Next.js App Router(2) - 서버 vs 클라이언트 컴포넌트
Next.js App Router에서 가장 큰 변화점은 기본적으로 모든 컴포넌트는 Server Component(서버 컴포넌트)라는 것입니다.
이번 글에서는 Server Component와 Client Component에 대해 알아보겠습니다.
1. Server Component의 장점과 역할
- 서버 환경(Node.js)에서 렌더링
- DB 쿼리, 외부 API 호출, 비밀 키·토큰 활용 등 브라우저에서 노출되면 안 되는 모든 작업을 안전하게 처리할 수 있습니다.
- 예시: 서버 컴포넌트 내에서 직접 DB에 접근하여 데이터를 가져오고, 이를 UI에 반영
- HTML 렌더링 및 전송
- 서버에서 렌더링 된 HTML이 브라우저로 전달되어, JS 번들 없이도 빠르게 콘텐츠가 보임
- React의 클라이언트 훅, 브라우저 API 미사용
- useState, useEffect 등은 서버 컴포넌트에서는 동작하지 않음
- 자동 캐싱 및 스트리밍 지원
- 서버 컴포넌트의 결과는 Next.js가 자동으로 캐싱 및 중복제거(deduplication), 스트리밍 렌더링 지원
// app/posts/page.tsx – 서버 컴포넌트 예시
export default async function PostsPage() {
const res = await fetch('https://api.example.com/posts');
const posts = await res.json();
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
서버 컴포넌트에 대해 알아봤으니 이제는 어떤 상황에서 클라이언트 컴포넌트로 만들어야 되는지 알아보겠습니다.
2. 클라이언트 컴포넌트로 넘어가는 기준
기본적으로 서버 컴포넌트가 기본값이지만 다음과 같은 경우에는 클라이언트 컴포넌트가 필요합니다.
- 사용자 상호작용(UI 이벤트 처리: 클릭, 입력 등)
- React 상태(useState, useReducer), 효과(useEffect) 사용
- 브라우저 API(window, localStorage, geolocation 등) 사용
- 외부 UI 라이브러리, 커스텀 훅 등 클라이언트 환경 필요시
클라이언트 컴포넌트로 만드는 방법은 무척 간단합니다.
파일 상단에 "use client" 지시자만 추가하면 해당 파일(및 자식 컴포넌트)이 클라이언트 번들에 포함됩니다.
// app/like-button.tsx – 클라이언트 컴포넌트 예시
'use client';
import { useState } from 'react';
export default function LikeButton({ initialLikes }: { initialLikes: number }) {
const [likes, setLikes] = useState(initialLikes);
return <button onClick={() => setLikes(likes + 1)}>👍 좋아요 {likes}개</button>;
}
이렇게만 보면 궁금한 점이 생길 겁니다.
React Query와 같은 라이브러리를 설정할 경우 Provider를 만들어 layout을 감싸는 식의 예시가 많기 때문입니다.
클라이언트 컴포넌트의 자식 컴포넌트는 클라이언트 컴포넌트로 변경되지만, props나 children으로 전달되는 컴포넌트의 경우 부모가 클라이언트 컴포넌트여도 서버 컴포넌트가 유지됩니다.
3. 서버 컴포넌트를 유지하기 위한 Provider 패턴 활용 예시
그래서 전역 상태/테마/인증 등 Context Provider를 활용하려면 최상단을 클라이언트 컴포넌트로 만들기보다는, 최소 범위만 클라이언트 화하는 것이 중요합니다.
// app/theme-provider.tsx – 클라이언트 Provider 패턴 예시
'use client';
import { createContext, useState } from 'react';
export const ThemeContext = createContext({
theme: 'light',
setTheme: (theme: string) => {},
});
export default function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState('light');
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
// app/layout.tsx – 루트에서 Provider 활용
import ThemeProvider from './theme-provider';
export default function RootLayout({ children }) {
return (
<html lang="ko">
<body>
<ThemeProvider>
{children}
</ThemeProvider>
</body>
</html>
);
}
4. props를 통한 데이터 전달 흐름
그리고 App Router에서 중요한 방식 중 하나는 서버 컴포넌트 → 클라이언트 컴포넌트 방향으로만 데이터 전달이 가능하다는 겁니다.
서버에서 데이터 패칭 → props로 하위 클라이언트 컴포넌트에 전달하는 방식을 통해 처리하여야 합니다.
그리고 클라이언트 컴포넌트의 상태 변화는 서버에 직접 반영 불가능 합니다.(필요시 API 호출 등으로 처리.)
// app/page.tsx – 서버 컴포넌트
import LikeButton from './like-button';
export default async function Page() {
const data = await fetchData();
return (
<main>
<h1>{data.title}</h1>
<LikeButton initialLikes={data.likes} />
</main>
);
}
// 서버 컴포넌트 : 데이터 패칭과 UI 기본 렌더링 담당
// 클라이언트 컴포넌트 : 상호작용(좋아요 버튼 상태) 담당
5. "조심스럽게 client를 섞어 쓰자" – 성능 중심 사고
지금까지 Next.js를 사용하던 개발자라면 언제 클라이언트 컴포넌트를 사용할지, 서버 컴포넌트를 사용할지 헷갈릴 수밖에 없기 때문에 다음에서 제가 정한 기본적인 원칙을 소개하겠습니다.
- 기본은 서버 컴포넌트!
- 반드시 필요한 곳(상호작용, 상태 관리)에만 클라이언트 컴포넌트 활용
- 번들 사이즈, 리렌더링 범위 최소화
- 클라이언트 컴포넌트는 경계가 올라갈수록(상위에 위치할수록) 전체 트리가 클라이언트 번들로 전환되므로 하위에서만 사용 권장
저는 위와 같은 원칙을 기본으로 구성하고 있으며, 여기에 자신의 스타일에 맞춰 더하거나 빼는 식으로 자신만의 원칙을 정하시면 좋을 것 같습니다.
협업 과정에서 Next.js App Router의 이점을 살리면서 개발하는 방식은 "클라이언트로 올릴 필요 없는 UI는 무조건 서버 컴포넌트"라는 마음가짐인 것 같습니다.
(덕분에 App Router를 사용하면서 코드 스플리팅을 좀 더 신경 쓰게 되었다는 소소하지만 큰 장점도 있었습니다. 😂)