Post

Next.js (5. Server and Client Components) 정리

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 순서로 처리된다.

  1. HTML — 빠른 비인터랙티브 미리보기를 즉시 화면에 표시한다.
  2. RSC Payload — Client Component와 Server Component 트리를 조정(reconcile)한다.
  3. 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)할 수 있어야 한다.
use API를 사용하면 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를 내부적으로 처리해 잘못된 환경에서 사용 시 더 명확한 에러 메시지를 제공한다.

This post is licensed under CC BY 4.0 by the author.