Next.js App Router 서버 컴포넌트와 클라이언트 컴포넌트 차이
Next.js App Router에서 컴포넌트를 나누는 첫 기준은 화면이 아니라 실행 환경이다.
서버에서 끝낼 수 있는 작업은 Server Component에 두고, 브라우저의 상호작용이 필요한 작은 영역만 Client Component로 만든다.
이 구분을 단순히 정적 화면과 동적 화면의 차이로 이해하면 금방 막힌다.
데이터가 자주 바뀌어도 Server Component일 수 있고, 화면에 글자만 보여도 브라우저 상태를 읽는다면 Client Component가 필요할 수 있다.
두 컴포넌트의 핵심 차이
Server Component는 서버 환경에서 실행되고 컴포넌트 코드 자체가 브라우저 JavaScript 번들에 포함되지 않는다.
데이터베이스나 내부 API에 가까운 곳에서 데이터를 읽고, 비밀 키를 노출하지 않은 채 UI를 만들 수 있다.
Client Component는 브라우저에서 상호작용할 수 있는 컴포넌트다.
상태, 이벤트 처리, effect, 브라우저 API처럼 클라이언트 JavaScript가 필요한 기능을 사용할 수 있다.
두 컴포넌트의 역할을 간단히 나누면 다음과 같다.
| 기준 | Server Component | Client Component |
|---|---|---|
| 기본 여부 | App Router의 기본 | 'use client'로 경계 선언 |
| 데이터 접근 | 데이터베이스, 내부 서비스, 비밀 값에 직접 접근 가능 | 브라우저에 공개해도 되는 데이터만 접근 |
| 상호작용 | 이벤트 handler와 로컬 상태 사용 불가 | 이벤트 handler와 로컬 상태 사용 가능 |
| 브라우저 API | window, localStorage 사용 불가 | 브라우저에서 사용 가능 |
| 브라우저 JavaScript | 컴포넌트 코드가 전송되지 않음 | 경계 아래의 코드가 client bundle에 포함됨 |
- Server Component
- App Router의 기본
- Client Component
'use client'로 경계 선언
- Server Component
- 데이터베이스, 내부 서비스, 비밀 값에 직접 접근 가능
- Client Component
- 브라우저에 공개해도 되는 데이터만 접근
- Server Component
- 이벤트 handler와 로컬 상태 사용 불가
- Client Component
- 이벤트 handler와 로컬 상태 사용 가능
- Server Component
window,localStorage사용 불가- Client Component
- 브라우저에서 사용 가능
- Server Component
- 컴포넌트 코드가 전송되지 않음
- Client Component
- 경계 아래의 코드가 client bundle에 포함됨
기본값인 Server Component
App Router의 page.tsx와 layout.tsx는 별도 선언이 없으면 Server Component다.
Server Component에서 import한 컴포넌트도 client 경계를 만나기 전까지 서버 쪽 모듈 그래프에 머문다.
따라서 데이터를 읽기 위한 컴포넌트에 별도의 directive를 붙일 필요가 없다.
// app/posts/[slug]/page.tsx
type Post = {
title: string
content: string
}
async function getPost(slug: string): Promise<Post> {
const response = await fetch(`https://api.example.com/posts/${slug}`)
if (!response.ok) {
throw new Error('게시글을 불러오지 못했습니다.')
}
return response.json()
}
export default async function PostPage({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
const post = await getPost(slug)
return (
<article>
<h2>{post.title}</h2>
<p>{post.content}</p>
</article>
)
}컴포넌트를 async 함수로 만들고 렌더링에 필요한 데이터를 바로 기다릴 수 있다는 점도 Server Component의 장점이다.
서버에서 만든 결과는 React Server Component Payload에 담겨 브라우저의 화면 갱신에 사용된다.
Server Component가 항상 요청마다 실행된다는 뜻은 아니다.
정적 생성, 캐시 설정, 요청 시 렌더링 여부에 따라 실제 실행 시점은 달라질 수 있다.
use client가 만드는 경계
Client Component가 필요하면 파일 맨 위에서 import보다 먼저 'use client'를 작성한다.
// app/ui/like-button.tsx
'use client'
import { useState } from 'react'
export default function LikeButton({ initialLikes }: { initialLikes: number }) {
const [likes, setLikes] = useState(initialLikes)
return (
<button type="button" onClick={() => setLikes((current) => current + 1)}>
좋아요 {likes}
</button>
)
}'use client'는 파일 하나의 실행 위치만 표시하는 주석이 아니다.
이 파일을 서버 쪽에서 import하는 지점에 서버와 클라이언트의 모듈 경계가 생긴다.
해당 파일이 import하는 모듈과 그 아래의 의존성도 client bundle에 들어갈 수 있다.
그래서 모든 컴포넌트에 'use client'를 반복해서 붙일 필요는 없다.
반대로 상위 layout.tsx에 무심코 붙이면 로고, 본문, 포맷 함수처럼 상호작용과 관계없는 코드까지 클라이언트 그래프로 넓어질 수 있다.
Client Component가 필요한 기능
다음 기능이 컴포넌트에 필요하면 Client Component 경계를 둔다.
useState,useReducer를 사용하는 로컬 상태onClick,onChange,onSubmit같은 이벤트 handleruseEffect,useLayoutEffect같은 lifecycle 로직window,document,localStorage,navigator같은 브라우저 API- React context provider와 context를 소비하는 컴포넌트
- 위 기능에 의존하는 custom hook과 서드파티 UI 라이브러리
브라우저 API는 Client Component의 렌더링 본문보다 event handler나 effect 안에서 접근하는 편이 안전하다.
Client Component도 첫 화면에서는 서버가 HTML을 미리 만드는 과정에 참여할 수 있기 때문이다.
// app/ui/copy-button.tsx
'use client'
export default function CopyButton({ text }: { text: string }) {
async function copy() {
await navigator.clipboard.writeText(text)
}
return (
<button type="button" onClick={copy}>
주소 복사
</button>
)
}이벤트가 발생한 뒤 navigator.clipboard에 접근하므로 서버의 사전 렌더링 과정에서는 브라우저 API를 실행하지 않는다.
서버 데이터와 작은 client 경계의 조합
데이터를 가져오기 위해 페이지 전체를 Client Component로 바꿀 필요는 없다.
페이지는 서버에 두고 상호작용에 필요한 값만 작은 Client Component에 전달하면 된다.
// app/posts/[slug]/page.tsx
import LikeButton from '@/app/ui/like-button'
import { getPost } from '@/lib/posts'
export default async function PostPage({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
const post = await getPost(slug)
return (
<article>
<h2>{post.title}</h2>
<p>{post.content}</p>
<LikeButton initialLikes={post.likes} />
</article>
)
}제목과 본문, 데이터 요청 코드는 서버에 남는다.
브라우저로 보내야 하는 JavaScript는 LikeButton 경계와 그 의존성으로 좁아진다.
이 구조는 보안 경계에도 도움이 된다.
데이터베이스 연결 코드와 비밀 환경 변수는 server-only 모듈에 두고, Client Component에는 화면에 필요한 최소 데이터만 전달할 수 있다.
props 직렬화 기준
Server Component에서 Client Component로 넘기는 props는 React가 전송할 수 있는 값이어야 한다.
문자열, 숫자, boolean, 배열, plain object처럼 구조가 분명한 데이터는 일반적으로 경계를 통과할 수 있다.
React가 지원하는 직렬화 타입은 JSON보다 넓어서 Date, Map, Set, Promise, JSX 같은 값도 포함한다.
하지만 일반 함수, 임의의 class instance, 등록되지 않은 Symbol처럼 실행 환경에 묶인 값은 그대로 넘길 수 없다.
다음처럼 서버에서 만든 일반 callback을 Client Component에 전달하면 직렬화할 수 없다.
// Server Component
export default async function Page() {
const post = await getPost()
return (
<Editor
post={{ id: post.id, title: post.title }}
onSave={() => savePost(post)}
/>
)
}Client Component의 클릭 처리는 Client Component 안에 두고, 저장 요청은 Route Handler나 Server Function 같은 명시적인 서버 진입점으로 연결한다.'use server'로 선언한 Server Function은 일반 함수와 달리 Client Component에 전달할 수 있는 예외다.
class instance를 받았다면 필요한 필드만 plain object로 바꿔 넘기는 편이 경계를 읽기 쉽다.
<Profile
user={{
id: user.id,
displayName: user.displayName,
}}
/>props를 최소화하면 직렬화 오류뿐 아니라 RSC Payload 크기와 컴포넌트 결합도도 줄일 수 있다.
Server Component를 children으로 전달하는 조합
Client Component가 Server Component 파일을 직접 import하면 그 의존성을 client graph로 끌어들이게 된다.
데이터베이스 접근처럼 서버에서만 가능한 코드가 섞여 있다면 빌드 오류나 잘못된 실행 환경으로 이어진다.
Client Component 안에 서버에서 만든 UI를 배치하려면 서버 부모가 JSX를 children이나 다른 slot prop으로 전달한다.
// app/ui/modal.tsx
'use client'
import { useState } from 'react'
export default function Modal({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false)
return (
<>
<button type="button" onClick={() => setOpen(true)}>
장바구니 열기
</button>
{open && (
<section role="dialog" aria-label="장바구니">
{children}
<button type="button" onClick={() => setOpen(false)}>
닫기
</button>
</section>
)}
</>
)
}// app/cart.tsx
import { getCart } from '@/lib/cart'
export default async function Cart() {
const cart = await getCart()
return <p>상품 {cart.items.length}개</p>
}// app/page.tsx
import Cart from './cart'
import Modal from './ui/modal'
export default function Page() {
return (
<Modal>
<Cart />
</Modal>
)
}Page가 서버에서 Cart와 Modal을 조합하고, Modal은 이미 준비된 children을 표시한다.
렌더 트리에서는 Server Component가 Client Component 안에 보이지만, Modal 모듈이 Cart 모듈을 직접 import하지 않는다는 점이 핵심이다.
context provider의 배치
React context는 Server Component에서 직접 만들거나 소비할 수 없다.
테마나 client 전역 상태가 필요하면 provider를 Client Component로 만들고 서버의 layout에서 감싼다.
provider는 가능한 한 트리의 깊은 위치에 두는 편이 좋다.
루트의 <html> 전체를 client 경계로 만들기보다 실제로 context가 필요한 children 영역만 감싸면 서버에 남길 수 있는 범위가 커진다.
실무 선택 기준
컴포넌트를 새로 만들 때는 Server Component로 시작하는 편이 단순하다.
그다음 브라우저에서 실행해야 하는 이유가 생기는 지점을 찾아 client 경계를 추가한다.
선택 순서는 다음처럼 정리할 수 있다.
- 데이터 조회, 문서 구조, 텍스트 렌더링은 서버에 둔다.
- 상태, 이벤트, effect, 브라우저 API가 필요한 가장 작은 컴포넌트를 찾는다.
- 그 파일에만
'use client'를 붙이고 import되는 의존성을 확인한다. - 서버 데이터는 필요한 필드만 직렬화 가능한 props로 넘긴다.
- 서버 UI가 client UI 안에 보여야 하면 직접 import 대신
children이나 slot을 사용한다. - provider와 서드파티 client library도 실제 사용 범위 가까이에 배치한다.
검색 화면을 예로 들면 페이지 제목, 초기 검색 결과, 데이터 요청은 서버에 둘 수 있다.
검색어 입력, 자동 완성, 필터 열기 버튼만 Client Component로 나누면 된다.
자주 생기는 오해
Client Component라는 이름이 첫 화면부터 브라우저에서만 렌더링된다는 뜻은 아니다.
Next.js는 첫 방문 때 Server Component Payload와 Client Component를 이용해 HTML을 미리 만들고, 브라우저에서는 hydration으로 이벤트를 연결한다.
Server Component라는 이름도 매 요청마다 서버에서 실행된다는 뜻은 아니다.
빌드 시점의 정적 렌더링과 캐시 정책에 따라 실행 시점은 달라진다.
'use server'는 Server Component를 표시하는 directive가 아니다.
Server Component에는 별도의 directive가 없으며, 'use server'는 브라우저에서 호출할 수 있는 Server Function을 선언한다.
'use client'는 해당 파일만 client code로 만든다는 뜻도 아니다.
그 파일에서 이어지는 module dependency가 client graph에 포함되므로 경계 위치가 bundle 크기에 직접 영향을 준다.
Client Component 아래에 보이는 모든 UI가 반드시 Client Component가 되는 것도 아니다.
서버 부모가 JSX를 children으로 전달하면 Server Component의 결과를 Client Component의 slot에 배치할 수 있다.
정리
App Router에서는 Server Component가 기본이고 Client Component는 필요한 상호작용을 더하는 경계다.
서버에는 데이터 접근과 비밀 값, 상호작용 없는 UI를 남기고 브라우저에는 상태와 이벤트에 필요한 코드만 보낸다.
좋은 분리는 파일 개수를 늘리는 데 있지 않다.
client 경계를 작게 유지하고, 경계를 넘는 props와 의존성을 명확하게 만드는 데 있다.
참고 자료





