Next.js 서버 컴포넌트와 클라이언트 컴포넌트 (Server and Client Components)
Next.js 공식 문서 Getting Started: Server and Client Components 내용을 바탕으로, App Router 기준의 Server / Client Components 개념과 사용 패턴을 정리한 자료입니다.
1. 개요
- App Router에서 레이아웃과 페이지는 기본적으로 Server Component입니다.
- Server Component를 사용하면:
- 서버에서 데이터를 가져와 UI를 렌더링하고
- 결과를 캐시한 뒤
- 클라이언트로 스트리밍할 수 있습니다.
- 브라우저 상호작용(이벤트 핸들러, 상태 관리,
window등)이 필요할 때는 Client Component를 사용하여 기능을 얹습니다.
이 문서는 Next.js에서 Server / Client Components가 어떻게 동작하는지, 언제 어떤 것을 사용해야 하는지를 예제와 함께 설명합니다.
2. 언제 Server / Client Components를 사용할까?
서버와 클라이언트 환경은 역할과 가능한 기능이 다릅니다.
Server / Client Components는 이 차이를 활용해, 각 환경에 맞는 로직을 배치하도록 돕는 개념입니다.
2.1 Client Component를 사용할 때
다음과 같은 경우에는 Client Component를 사용합니다.
- **상태 관리(state)**와 이벤트 핸들러가 필요할 때
- 예:
onClick,onChange등
- 예:
- 라이프사이클 로직이 필요할 때
- 예:
useEffect등
- 예:
- 브라우저 전용 API를 사용할 때
- 예:
localStorage,window,Navigator.geolocation등
- 예:
- 커스텀 훅이 클라이언트 환경을 전제로 할 때
2.2 Server Component를 사용할 때
다음과 같은 경우에는 Server Component를 사용합니다.
- 데이터베이스나 외부 API로부터 서버 가까운 곳에서 데이터를 가져올 때
- API 키, 토큰 등 비밀 값을 클라이언트에 노출하지 않고 사용해야 할 때
- 브라우저로 전송되는 JavaScript 양을 줄이고 싶을 때
- First Contentful Paint(FCP)를 개선하고, 콘텐츠를 점진적으로 스트리밍하고 싶을 때
2.3 간단한 예시 구조
app/[id]/page.tsx(Server Component)- 게시글 데이터를 가져오고
LikeButton에 props로 전달
app/ui/like-button.tsx(Client Component)'use client'지시문useState등 클라이언트 상태/이벤트 처리
이 패턴으로, 데이터 로딩은 서버에서, 상호작용은 클라이언트에서 처리합니다.
3. Next.js에서 Server / Client Components가 동작하는 방식
3.1 서버에서 (On the server)
Next.js는 서버에서 React의 API를 사용하여 렌더링을 조율합니다.
렌더링 작업은 각 라우트 세그먼트(레이아웃/페이지 단위)별로 분할됩니다.
- Server Component는 **React Server Component Payload(RSC Payload)**라는 특별한 데이터 형식으로 렌더링됩니다.
- Client Component와 RSC Payload는 **HTML을 미리 생성(pre-render)**하는 데 사용됩니다.
RSC Payload란?
RSC Payload는 서버에서 렌더링된 Server Component 트리를 이진 형식으로 표현한 데이터입니다.
클라이언트에서 이 데이터를 이용해 DOM을 업데이트합니다.
RSC Payload에는 다음 정보가 포함됩니다.
- Server Component 렌더링 결과
- Client Component가 렌더링될 위치와 해당 JS 파일에 대한 참조
- Server Component에서 Client Component로 전달된 props
3.2 클라이언트 (첫 로드 시)
클라이언트에서는 다음 순서로 동작합니다.
- HTML
- 사용자에게 빠른 비인터랙티브 프리뷰를 보여 줍니다.
- RSC Payload
- Server / Client Component 트리를 동기화(reconcile)하는 데 사용됩니다.
- JavaScript
- Client Component를 hydrate하여 실제 인터랙션이 가능하도록 만듭니다.
Hydration: React가 이미 렌더링된 HTML에 이벤트 핸들러를 붙여서 인터랙티브하게 만드는 과정입니다.
3.3 이후 내비게이션 (Subsequent Navigations)
다음 라우트로 이동할 때는:
- 해당 라우트의 RSC Payload가 미리 가져와(prefetch) 캐시에 저장됩니다.
- 이후 내비게이션에서는 서버 렌더링된 HTML 없이 클라이언트에서만 Client Component를 그립니다.
즉, 첫 진입은 SSR + Hydration, 이후 전환은 클라이언트 사이드 전환 위주로 이루어집니다.
4. 예제와 패턴
4.1 Client Component 만들기 ("use client")
Client Component를 만들려면 파일 상단에 "use client" 지시문을 추가합니다. 이 지시문은 import보다 위에 위치해야 합니다.
// app/ui/counter.tsx
'use client'
import { useState } from 'react'
export default function Counter() {
const [count, setCount] = useState(0)
return (
<div>
<p>{count} likes</p>
<button onClick={() => setCount(count + 1)}>Click me</button>
</div>
)
}
"use client"가 선언되면, 해당 파일과 그 안에서 import하는 컴포넌트/모듈은 모두 클라이언트 번들에 포함됩니다.
따라서, 클라이언트 전용 컴포넌트를 묶어두는 엔트리(루트) 컴포넌트에만 "use client"를 붙이고, 그 아래 자식 컴포넌트에는 다시 붙일 필요가 없습니다.
4.2 JS 번들 크기 줄이기 (Reducing JS bundle size)
클라이언트 JavaScript 번들 크기를 줄이기 위해서는, 다음 전략을 사용하는 것이 좋습니다.
- 큰 영역 전체를 Client Component로 만드는 대신,
실제로 인터랙션이 필요한 부분만 Client Component로 분리합니다.
예: 레이아웃의 대부분은 정적이지만, 검색창만 인터랙티브한 경우
// app/layout.tsx
// Client Component
import Search from './search'
// Server Component
import Logo from './logo'
// Layout은 기본이 Server Component
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<>
<nav>
<Logo />
<Search />
</nav>
<main>{children}</main>
</>
)
}
// app/ui/search.tsx
'use client'
export default function Search() {
// ...
}
Layout은 Server Component로 유지하면서,Search만 Client Component로 분리하여 필요한 부분만 클라이언트 번들에 포함합니다.
4.3 Server에서 Client Component로 데이터 전달 (Passing data via props)
Server Component에서 Client Component로는 props를 통해 데이터를 전달합니다.
// app/[id]/page.tsx (Server Component)
import LikeButton from '@/app/ui/like-button'
import { getPost } from '@/lib/data'
export default async function Page({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const post = await getPost(id)
return <LikeButton likes={post.likes} />
}
// app/ui/like-button.tsx (Client Component)
'use client'
export default function LikeButton({ likes }: { likes: number }) {
// ...
}
- Server Component에서 데이터(fetch)를 수행하고,
- 해당 결과를 Client Component에 직접 props로 전달합니다.
또한, React의 use 훅을 사용해 Server Component에서 Client Component로 스트리밍하는 패턴도 있습니다(공식 문서 예제 참고).
단, Client Component로 전달되는 props는 직렬화 가능한(serializable) 값이어야 합니다.
4.4 Server / Client Components 섞어서 사용하기 (Interleaving)
Server Component를 Client Component의 children으로 전달할 수 있습니다.
이렇게 하면 클라이언트 상태를 갖는 UI 안에 서버 렌더링된 UI를 시각적으로 중첩할 수 있습니다.
예:
<Modal>은 Client Component (열림/닫힘 상태 관리)<Cart>는 Server Component (서버에서 장바구니 데이터 fetch)
// app/ui/modal.tsx (Client Component)
'use client'
export default function Modal({ children }: { children: React.ReactNode }) {
return <div>{children}</div>
}
// app/page.tsx (Server Component)
import Modal from './ui/modal'
import Cart from './ui/cart'
export default function Page() {
return (
<Modal>
<Cart />
</Modal>
)
}
이 패턴에서:
- 모든 Server Component는 서버에서 미리 렌더링되고,
- RSC Payload 안에는 Client Component 트리 안에서 Server Component가 들어갈 위치 정보가 담깁니다.
4.5 Context Provider 사용 (Context providers)
React의 Context는 전역 상태(예: 테마)를 공유할 때 자주 사용합니다.
하지만 Server Component에서는 Context를 직접 사용할 수 없습니다.
따라서, Context Provider는 Client Component로 만들고, Server Component(예: layout)에서 이 Provider로 앱을 감싸는 식으로 사용합니다.
// app/theme-provider.tsx (Client Component)
'use client'
import { createContext } from 'react'
export const ThemeContext = createContext({})
export default function ThemeProvider({
children,
}: {
children: React.ReactNode
}) {
return <ThemeContext.Provider value="dark">{children}</ThemeContext.Provider>
}
// app/layout.tsx (Server Component)
import ThemeProvider from './theme-provider'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html>
<body>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
)
}
- Server Component인
RootLayout이 Client Component인ThemeProvider를 직접 렌더링합니다. - 트리 아래쪽에 있는 Client Component들은 자유롭게
ThemeContext를 사용할 수 있습니다.
팁: Provider는 가능한 한 트리의 깊은 위치에 두는 것이 좋습니다.
예제처럼{children}만 감싸면, 나머지 Server Component 부분을 Next.js가 더 잘 최적화할 수 있습니다.
4.6 써드파티 컴포넌트 사용 (Third-party components)
클라이언트 전용 기능에 의존하는 써드파티 컴포넌트는 Client Component로 감싸서 사용하는 것이 안전합니다.
예: acme-carousel 라이브러리의 <Carousel /> 컴포넌트가 useState를 사용하지만 "use client"가 붙어 있지 않은 경우
// app/gallery.tsx (Client Component)
'use client'
import { useState } from 'react'
import { Carousel } from 'acme-carousel'
export default function Gallery() {
const [isOpen, setIsOpen] = useState(false)
return (
<div>
<button onClick={() => setIsOpen(true)}>View pictures</button>
{/* Client Component 안에서 사용하면 정상 동작 */}
{isOpen && <Carousel />}
</div>
)
}
Server Component에서 바로 <Carousel />을 사용하면, Next.js는 이 컴포넌트가 클라이언트 전용 기능을 사용하는지 알 수 없기 때문에 에러가 발생합니다.
이때는 다음처럼 래퍼 Client Component를 하나 만들면 됩니다.
// app/carousel.tsx (Client Component)
'use client'
import { Carousel } from 'acme-carousel'
export default Carousel
이제 Server Component에서 바로 사용할 수 있습니다.
// app/page.tsx (Server Component)
import Carousel from './carousel'
export default function Page() {
return (
<div>
<p>View pictures</p>
{/* Client Component로 래핑된 Carousel */}
<Carousel />
</div>
)
}
라이브러리 제작자에게는, 클라이언트 전용 기능을 사용하는 엔트리 포인트에는
"use client"를 직접 추가하는 것이 권장됩니다.
5. 환경 오염(Environment Poisoning) 방지
Server / Client Components가 모듈을 공유할 수 있기 때문에, 서버 전용 코드를 실수로 클라이언트에서 import하는 문제가 생길 수 있습니다.
예를 들어:
// lib/data.ts
export async function getData() {
const res = await fetch('https://external-service.com/data', {
headers: {
authorization: process.env.API_KEY,
},
})
return res.json()
}
- 이 함수는 **노출되면 안 되는
API_KEY**를 사용합니다. - Next.js는
NEXT_PUBLIC_로 시작하는 환경 변수만 클라이언트 번들에 포함하고, 그렇지 않은 변수는 빈 문자열로 대체합니다. - 이 함수가 클라이언트에서 실행되면, 의도대로 동작하지 않고 보안상도 문제가 될 수 있습니다.
5.1 server-only / client-only 패키지
이런 실수를 막기 위해, Next.js에서는 server-only / client-only 패키지 사용을 지원합니다.
// lib/data.ts (서버 전용 코드)
import 'server-only'
export async function getData() {
const res = await fetch('https://external-service.com/data', {
headers: {
authorization: process.env.API_KEY,
},
})
return res.json()
}
- 이렇게 하면, 이 모듈을 Client Component에서 import하려고 할 때 빌드 타임 에러가 발생합니다.
- 반대로, 브라우저 전용 로직(
window접근 등)을 포함하는 모듈에는client-only를 사용할 수 있습니다.
Next.js는 내부적으로 server-only / client-only import를 해석해, 잘못된 환경에서 사용될 경우 더 친절한 에러 메시지를 제공합니다.
server-only/client-only패키지를 설치하는 것은 선택 사항이지만, 린트 규칙에서 미사용 의존성을 경고하는 경우 정식으로 설치해 두면 편리합니다.
6. 요약
- App Router에서는 레이아웃과 페이지가 기본적으로 Server Component이며, 데이터 페칭과 보안이 필요한 로직은 서버에서 처리합니다.
- 상태, 이벤트, 브라우저 API, 커스텀 훅 등 인터랙션이 필요한 부분만 Client Component로 분리하여, JS 번들과 성능을 최적화합니다.
"use client"는 Server / Client 모듈 그래프의 **경계(boundary)**를 선언하는 역할을 하며, 한 번 선언한 파일과 그 자식들은 모두 클라이언트 번들에 포함됩니다.- Server Component에서 Client Component로는 props로 데이터를 전달하고, 필요하면
use훅을 통해 스트리밍 패턴도 사용할 수 있습니다. - Context Provider, 써드파티 컴포넌트 등 클라이언트 전용 기능은 Client Component로 래핑한 뒤 Server Component 트리에 섞어서 사용할 수 있습니다.
server-only/client-only패키지와 환경 변수 규칙(NEXT_PUBLIC_)을 활용하여, 서버 전용/클라이언트 전용 코드가 잘못된 환경에서 실행되는 문제를 예방합니다.