Next.js (5. Server and Client Components) 정리
0. 서버 컴포넌트와 클라이언트 컴포넌트
Next.js에서 레이아웃과 페이지는 기본적으로 Server Component다.
서버에서 데이터를 가져오고 UI를 렌더링하며, 결과를 캐시하고 클라이언트로 스트리밍할 수 있다.
반면, 브라우저 API나 인터렉션이 필요한 경우에만 Client Component를 사용한다.
1. 언제 무엇을 사용할까
1-1. 사용 기준
서버와 클라이언트 환경은 각각 다른 기능을 가지므로, 사용 목적에 따라 컴포넌트를 선택한다.
| 필요한 기능 | 사용할 컴포넌트 |
|---|---|
상태(useState), 이벤트 핸들러(onClick, onChange) | Client Component |
라이프사이클 로직 (useEffect) | Client Component |
브라우저 전용 API (localStorage, window 등) | Client Component |
| 커스텀 훅 | Client Component |
| DB / API에서 데이터 가져오기 | Server Component |
| API 키, 토큰 등 민감한 정보 처리 | Server Component |
| 클라이언트로 보내는 JS 양 줄이기 | Server Component |
| FCP 개선, 콘텐츠 점진적 스트리밍 | Server Component |
1-2. 기본 패턴 — 서버에서 데이터, 클라이언트에서 인터랙션
서버에서 데이터를 가져오고, 인터랙션이 필요한 부분만 Client Component로 분리하는 것이 기본 패턴이다.
<Page>는 Server Component로 포스트 데이터를 가져오고,
인터랙션이 필요한 <LikeButton>은 Client Component로 분리해 props로 데이터를 전달한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 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 (
<div>
<h1>{post.title}</h1>
{/* ... */}
<LikeButton likes={post.likes} />
</div>
)
}
1
2
3
4
5
6
7
8
9
// app/ui/like-button.tsx — Client Component
'use client'
import { useState } from 'react'
export default function LikeButton({ likes }: { likes: number }) {
// 클라이언트 인터랙션 처리
}
2. Next.js에서의 동작 원리
2-1. 서버에서의 렌더링
서버에서는 라우트 세그먼트(레이아웃, 페이지) 단위로 렌더링 작업이 분리된다.
- Server Component
- React Server Component Payload(RSC Payload)라는 특수 데이터 형식으로 렌더링된다.
- Client Component + RSC Payload
- HTML로 프리렌더링된다.
RSC Payload란?
렌더링된 Server Component 트리의 압축된 바이너리 표현이다.
클라이언트에서 React가 DOM을 업데이트하는 데 사용하며, 아래 정보를 담고 있다.
- Server Component의 렌더링 결과
- Client Component가 렌더링될 위치와 JS 파일 참조
- Server Component에서 Client Component로 전달되는 props
2-2. 클라이언트 최초 로드
클라이언트에서는 HTML → RSC Payload → JavaScript 순서로 처리된다.
- HTML — 빠른 비인터랙티브 미리보기를 즉시 화면에 표시한다.
- RSC Payload — Client Component와 Server Component 트리를 조정(reconcile)한다.
- JavaScript — Client Component를 하이드레이션해 애플리케이션을 인터랙티브하게 만든다.
하이드레이션(Hydration)이란?
React가 정적 HTML에 이벤트 핸들러를 연결해 인터랙티브하게 만드는 과정이다.
2-3. 이후 내비게이션
이후 내비게이션에서는 RSC Payload를 캐시하여 즉각적인 이동이 가능하다.
- RSC Payload — 프리페칭되어 캐시된다.
- Client Component — 서버 렌더링된 HTML 없이 클라이언트에서 완전히 렌더링된다.
3. 주요 패턴과 활용
3-1. Client Component 만들기 — "use client"
파일 상단에 "use client"를 추가하면 Client Component가 된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 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"는 Server와 Client 모듈 그래프 사이의 경계(boundary)를 선언한다.
한 파일에 선언하면 해당 파일에서 import하는 모든 모듈과 하위 컴포넌트가 클라이언트 번들에 포함된다.
따라서 모든 컴포넌트에 일일이 추가할 필요는 없다.
3-2. JS 번들 크기 줄이기
"use client"는 꼭 필요한 인터랙티브 컴포넌트에만 추가해 클라이언트 번들 크기를 최소화한다.
예를 들어, <Layout>은 대부분 정적인 요소(로고, 네비게이션 링크)로 구성되지만,
인터랙티브한 검색창 <Search />만 Client Component로 분리하면 나머지는 Server Component로 유지된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// app/layout.tsx — Server Component (기본값)
import Search from './search' // Client Component
import Logo from './logo' // Server Component
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<>
<nav>
<Logo />
<Search />
</nav>
<main>{children}</main>
</>
)
}
1
2
3
4
5
6
7
// app/ui/search.tsx
'use client'
export default function Search() {
// 검색 인터랙션 처리
}
3-3. Server → Client 데이터 전달
Server Component에서 Client Component로 데이터를 전달할 때는 props를 사용한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 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} />
}
1
2
3
4
5
6
7
// app/ui/like-button.tsx — Client Component
'use client'
export default function LikeButton({ likes }: { likes: number }) {
// ...
}
Client Component에 전달하는 props는 React가 직렬화(serialize)할 수 있어야 한다.
useAPI를 사용하면 Server Component에서 Client Component로 데이터를 스트리밍할 수도 있다.
3-4. Server와 Client 컴포넌트 혼합
Server Component를 Client Component의 prop으로 전달하면, Client Component 안에 서버 렌더링 UI를 중첩할 수 있다.
대표적인 패턴은 children prop을 활용해 Client Component 안에 슬롯을 만드는 방식이다.
예를 들어, 서버에서 데이터를 가져오는 <Cart>를 클라이언트 상태로 토글되는 <Modal> 안에 넣는 구조다.
1
2
3
4
5
6
7
// app/ui/modal.tsx — Client Component
'use client'
export default function Modal({ children }: { children: React.ReactNode }) {
return <div>{children}</div>
}
1
2
3
4
5
6
7
8
9
10
11
12
// 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가 렌더링될 위치에 대한 참조가 포함된다.
3-5. Context Provider
React context는 Server Component에서 지원되지 않으므로, children을 받는 Client Component로 Provider를 감싸야 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// app/theme-provider.tsx
'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>
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 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>
)
}
Provider는 트리에서 최대한 깊은 위치에 렌더링한다.
ThemeProvider가 전체<html>대신{children}만 감싸는 이유도 이 때문이다.
Next.js가 Server Component의 정적인 부분을 더 잘 최적화할 수 있다.
3-6. 서드파티 컴포넌트
"use client"가 없는 서드파티 컴포넌트를 Server Component에서 사용하면 에러가 발생한다.
예를 들어, acme-carousel 패키지의 <Carousel />은 내부적으로 useState를 사용하지만 "use client" 선언이 없다.
이 경우 직접 Client Component로 래핑하면 Server Component에서도 안전하게 사용할 수 있다.
1
2
3
4
5
6
7
// app/carousel.tsx — 래퍼 Client Component
'use client'
import { Carousel } from 'acme-carousel'
export default Carousel
1
2
3
4
5
6
7
8
9
10
11
// app/page.tsx — Server Component
import Carousel from './carousel'
export default function Page() {
return (
<div>
<Carousel />
</div>
)
}
라이브러리 제작자라면:
클라이언트 전용 기능에 의존하는 진입점에
"use client"를 추가하면 사용자가 별도의 래퍼 없이 Server Component에서 바로 import할 수 있다.
4. 환경 오염 방지
4-1. server-only / client-only 패키지
JavaScript 모듈은 Server와 Client 컴포넌트 양쪽에서 공유될 수 있어, 서버 전용 코드가 클라이언트로 유출될 위험이 있다.
예를 들어, 아래 함수는 API_KEY를 사용하므로 클라이언트에 노출되면 안 된다.
1
2
3
4
5
6
7
8
9
10
// 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()
}
Next.js에서 NEXT_PUBLIC_ 접두사가 없는 환경 변수는 클라이언트 번들에서 빈 문자열로 대체된다.
즉, getData()를 클라이언트에서 import해도 의도대로 동작하지 않는다.
실수를 빌드 타임 에러로 잡으려면 server-only 패키지를 사용한다.
1
pnpm add server-only
1
2
3
4
5
6
7
8
9
10
11
12
// 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하면 빌드 타임에 에러가 발생한다.
| 패키지 | 용도 |
|---|---|
server-only | 서버 전용 모듈 표시. Client Component에서 import 시 빌드 에러 |
client-only | 클라이언트 전용 모듈 표시 (window 등 접근 코드에 사용) |
Next.js는
server-only/client-only를 내부적으로 처리해 잘못된 환경에서 사용 시 더 명확한 에러 메시지를 제공한다.
