서버 컴포넌트와 클라이언트 컴포넌트

서버 컴포넌트와 클라이언트 컴포넌트 (Server and Client Components)

Next.js에서는 레이아웃과 페이지가 기본적으로 서버 컴포넌트(Server Component) 로 동작해요. 서버 컴포넌트를 쓰면 데이터를 서버에서 가져오고, UI 일부를 서버에서 렌더링하며, 결과를 선택적으로 캐시하고, 클라이언트로 스트리밍할 수 있어요. 한편 상호작용이나 브라우저 API가 필요하면 클라이언트 컴포넌트(Client Component) 로 기능을 얹을 수 있죠. 이 글에서는 두 컴포넌트가 어떻게 동작하는지, 언제 무엇을 써야 하는지, 그리고 어떻게 함께 조합하는지 살펴볼게요.

출처: Next.js 공식 문서 - Server and Client Components

언제 서버/클라이언트 컴포넌트를 쓸까?

클라이언트 환경과 서버 환경은 할 수 있는 일이 서로 달라요. 서버·클라이언트 컴포넌트는 이런 각각의 환경에서, 활용 사례에 맞춰 로직을 실행할 수 있게 해 줘요.

클라이언트 컴포넌트가 필요할 때는 이런 경우예요.

  • 상태와 이벤트 핸들러 — 예: onClick, onChange
  • 라이프사이클 로직 — 예: useEffect
  • 브라우저 전용 API — 예: localStorage, window, navigator.geolocation
  • 커스텀 훅

서버 컴포넌트가 필요할 때는 이런 경우예요.

  • 데이터 원천(DB나 API)에 가까운 곳에서 데이터를 가져올 때
  • API 키, 토큰 등 시크릿을 클라이언트에 노출하지 않고 사용할 때
  • 브라우저로 보내는 JavaScript 양을 줄일 때
  • FCP(First Contentful Paint)를 개선하고 콘텐츠를 점진적으로 스트리밍할 때

예를 들어 <Page>는 게시물 데이터를 가져오는 서버 컴포넌트이고, 거기서 클라이언트 상호작용을 담당하는 <LikeButton>에 props로 데이터를 넘겨주는 구조가 전형적이에요.

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>
      <main>
        <h1>{post.title}</h1>
        <LikeButton likes={post.likes} />
      </main>
    </div>
  )
}
'use client'

import { useState } from 'react'

export default function LikeButton({ likes }: { likes: number }) {
  // ...
}

Next.js에서 서버·클라이언트 컴포넌트는 어떻게 동작하나요?

서버에서

서버에서 Next.js는 React의 API로 렌더링을 조율해요. 렌더링 작업은 라우트 세그먼트(레이아웃·페이지)별로, 그리고 표시 여부와 무관하게 병렬 라우트 슬롯까지 포함해 조각(chunk)으로 나눠져요.

  • 서버 컴포넌트는 RSC Payload(React Server Component Payload)라는 특별한 데이터 형식으로 렌더링돼요.
  • 클라이언트 컴포넌트와 RSC Payload는 HTML을 프리렌더링하는 데 쓰여요.

RSC Payload는 렌더링된 서버 컴포넌트 트리의 간결한 직렬화 표현이에요. 클라이언트에서 React가 브라우저 DOM을 갱신할 때 사용하며, 서버 컴포넌트의 렌더링 결과, 클라이언트 컴포넌트가 렌더링될 자리의 플레이스홀더와 그 JavaScript 파일 참조, 서버에서 클라이언트 컴포넌트로 넘긴 props를 담아요.

클라이언트에서 (첫 로드)

클라이언트에서는 다음 순서로 진행돼요.

  1. HTML — 라우트의 빠른 비인터랙티브 미리보기를 사용자에게 즉시 보여줘요.
  2. RSC Payload — 클라이언트·서버 컴포넌트 트리를 조정(reconcile)해요.
  3. JavaScript — 클라이언트 컴포넌트를 하이드레이션(hydration)하고 앱을 인터랙티브하게 만들어요.

하이드레이션은 정적 HTML을 인터랙티브하게 만들기 위해 React가 이벤트 핸들러를 DOM에 부착하는 과정이에요.

이후 네비게이션에서

  • RSC Payload는 즉시 네비게이션을 위해 프리페칭·캐시돼요.
  • 클라이언트 컴포넌트는 서버 렌더링 HTML 없이 클라이언트에서 전부 렌더링돼요.

예시: 클라이언트 컴포넌트 사용하기

파일 맨 위, import 위에 "use client" 지시어를 추가하면 클라이언트 컴포넌트를 만들 수 있어요.

'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"는 서버와 클라이언트 모듈 그래프(트리) 사이의 경계(boundary) 를 선언하는 역할을 해요. 한 파일이 "use client"로 표시되면, 그 파일의 모든 import와 직접 렌더링하는 컴포넌트는 클라이언트 번들에 포함돼요. 그러니 클라이언트용으로 의도한 모든 컴포넌트에 일일이 지시어를 넣을 필요는 없어요.

반면 서버 컴포넌트를 children이나 다른 props로 넘기는 경우엔 이 규칙이 적용되지 않아요. 그 컴포넌트들은 클라이언트 컴포넌트의 모듈 그래프로 import되지 않고, 서버에서 렌더링된 출력으로서 클라이언트 컴포넌트에 전달돼요.

JS 번들 크기 줄이기

클라이언트 JavaScript 번들 크기를 줄이려면 UI의 큰 부분을 클라이언트 컴포넌트로 표시하지 말고, 상호작용이 필요한 특정 컴포넌트에만 'use client'를 추가하는 게 좋아요.

예를 들어 <Layout>은 로고·네비게이션 링크처럼 대부분 정적인 요소지만, 상호작용하는 검색 바를 하나 포함한다고 해 볼게요. <Search />만 클라이언트 컴포넌트면 되고, 레이아웃의 나머지는 서버 컴포넌트로 남겨 둘 수 있어요.

// Client Component
import Search from './search'
// Server Component
import Logo from './logo'

// Layout is a Server Component by default
export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <>
      <nav>
        <Logo />
        <Search />
      </nav>
      <main>{children}</main>
    </>
  )
}

서버에서 클라이언트 컴포넌트로 데이터 넘기기

서버 컴포넌트에서 클라이언트 컴포넌트로 props를 이용해 데이터를 넘길 수 있어요. 이때 클라이언트 컴포넌트에 넘기는 props는 React가 직렬화할 수 있어야 해요.

서버·클라이언트 컴포넌트 섞어 쓰기 (Interleaving)

서버 컴포넌트를 클라이언트 컴포넌트의 prop으로 넘길 수도 있어요. 그러면 클라이언트 컴포넌트 안에 서버 렌더링 UI를 시각적으로 중첩할 수 있죠.

흔한 패턴은 children을 이용해 <ClientComponent> 안에 슬롯을 만드는 거예요. 예를 들어 서버에서 데이터를 가져오는 <Cart> 컴포넌트를, 클라이언트 상태로 표시 여부를 토글하는 <Modal> 컴포넌트 안에 넣는 식이에요.

'use client'

export default function Modal({ children }: { children: React.ReactNode }) {
  return <div>{children}</div>
}

상위 서버 컴포넌트(예: <Page>)에서 <Modal>의 자식으로 <Cart>를 전달할 수 있어요.

import Modal from './ui/modal'
import Cart from './ui/cart'

export default function Page() {
  return (
    <Modal>
      <Cart />
    </Modal>
  )
}

이 패턴에서 서버 컴포넌트는 클라이언트 컴포넌트에 props로 전달될 때도 서버에서 미리 렌더링돼요. RSC Payload에는 그 서버 컴포넌트의 렌더링 결과와, 클라이언트 컴포넌트가 렌더링될 자리의 플레이스홀더·JavaScript 파일 참조가 담겨요.

Context 프로바이더

React context는 테마 같은 전역 상태를 공유할 때 흔히 쓰이는데, 서버 컴포넌트에서는 React context가 지원되지 않아요. context를 쓰려면 children을 받는 클라이언트 컴포넌트를 만들면 돼요.

'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>
}

여기서 ThemeProvider{children}만 감싸고 <html> 문서 전체를 감싸지 않는다는 점이 핵심이에요. 프로바이더는 트리에서 가능한 한 깊이 렌더링하는 게 좋아요. 그래야 Next.js가 서버 컴포넌트의 정적 부분을 최적화하기 쉬워져요.

서드파티 컴포넌트

클라이언트 전용 기능에 의존하는 서드파티 컴포넌트를 쓸 때는, 그것을 클라이언트 컴포넌트로 한 번 감싸면 예상대로 동작하게 만들 수 있어요. 예를 들어 acme-carousel 패키지의 <Carousel />useState를 쓰는데 아직 "use client" 지시어가 없다면, 서버 컴포넌트에서 직접 쓰면 에러가 나요. 클라이언트 컴포넌트로 감싸주면 해결돼요.

'use client'

import { Carousel } from 'acme-carousel'

export default Carousel

환경 오염 방지 (Preventing environment poisoning)

JavaScript 모듈은 서버·클라이언트 컴포넌트 양쪽에서 공유될 수 있어서, 실수로 서버 전용 코드를 클라이언트로 import할 수 있어요. 클라이언트에 노출되면 안 되는 API_KEY를 담은 함수가 대표적인 예시죠.

Next.js에서는 NEXT_PUBLIC_ 접두사가 붙은 환경 변수만 클라이언트 번들에 포함돼요. 접두사가 없으면 빈 문자열로 치환되므로, 클라이언트에서 실행돼도 제대로 동작하지 않아요. 실수로 클라이언트 컴포넌트에서 쓰는 걸 막으려면 server-only 패키지를 쓰면 돼요. 서버 전용 코드가 있는 파일에 import 'server-only'를 추가하면, 그 모듈을 클라이언트 컴포넌트로 import하려는 순간 빌드 타임 에러가 나요. 반대로 window 객체처럼 클라이언트 전용 로직을 표시할 때는 client-only 패키지를 써요.

npm install server-only

Next.js는 server-only/client-only import를 내부적으로 처리해서, 모듈이 잘못된 환경에서 쓰일 때 더 명확한 에러 메시지를 띄워 줘요. NPM에서 받은 패키지 내용물 자체를 쓰는 건 아니에요.

더 알아보기 (Learn more)

  • use client — 컴포넌트를 클라이언트에서 렌더링하게 하는 use client 지시어
  • Server and Client Boundary — App Router에서 서버·클라이언트 컴포넌트가 어디서 실행되고 경계가 어떻게 동작하는지