현록

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 ComponentClient Component
기본 여부App Router의 기본'use client'로 경계 선언
데이터 접근데이터베이스, 내부 서비스, 비밀 값에 직접 접근 가능브라우저에 공개해도 되는 데이터만 접근
상호작용이벤트 handler와 로컬 상태 사용 불가이벤트 handler와 로컬 상태 사용 가능
브라우저 APIwindow, localStorage 사용 불가브라우저에서 사용 가능
브라우저 JavaScript컴포넌트 코드가 전송되지 않음경계 아래의 코드가 client bundle에 포함됨
기본 여부
Server Component
App Router의 기본
Client Component
'use client'로 경계 선언
데이터 접근
Server Component
데이터베이스, 내부 서비스, 비밀 값에 직접 접근 가능
Client Component
브라우저에 공개해도 되는 데이터만 접근
상호작용
Server Component
이벤트 handler와 로컬 상태 사용 불가
Client Component
이벤트 handler와 로컬 상태 사용 가능
브라우저 API
Server Component
window, localStorage 사용 불가
Client Component
브라우저에서 사용 가능
브라우저 JavaScript
Server Component
컴포넌트 코드가 전송되지 않음
Client Component
경계 아래의 코드가 client bundle에 포함됨

기본값인 Server Component

App Router의 page.tsxlayout.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 같은 이벤트 handler
  • useEffect, 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가 서버에서 CartModal을 조합하고, 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 경계를 추가한다.

선택 순서는 다음처럼 정리할 수 있다.

  1. 데이터 조회, 문서 구조, 텍스트 렌더링은 서버에 둔다.
  2. 상태, 이벤트, effect, 브라우저 API가 필요한 가장 작은 컴포넌트를 찾는다.
  3. 그 파일에만 'use client'를 붙이고 import되는 의존성을 확인한다.
  4. 서버 데이터는 필요한 필드만 직렬화 가능한 props로 넘긴다.
  5. 서버 UI가 client UI 안에 보여야 하면 직접 import 대신 children이나 slot을 사용한다.
  6. 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와 의존성을 명확하게 만드는 데 있다.

참고 자료

관련 포스트
Next.js App Router에서 검색 날짜와 사이트맵 관리하기 thumbnail
Next.js App Router에서 검색 날짜와 사이트맵 관리하기
Next.js App Router 블로그에서 사용자에게 보이는 날짜, Open Graph modifiedTime, JSON-LD dateModified, sitemap lastModified를 같은 기준으로 연결하는 방법을 정리합니다.
Next.js 16 Cache Components와 use cache 정리 thumbnail
Next.js 16 Cache Components와 use cache 정리
Next.js 16에서 도입된 Cache Components와 use cache directive를 기준으로 App Router 캐싱 모델의 변화, cacheLife, cacheTag, revalidateTag 사용 흐름을 정리합니다.
Next.js App Router에서 레이아웃 사용하기 thumbnail
Next.js App Router에서 레이아웃 사용하기
Next.js App Router에서 app/layout.tsx, page.tsx, 중첩 layout을 사용해 공통 UI를 구성하는 방법을 정리합니다. Pages Router 시절의 수동 Layout 컴포넌트 패턴과 어떤 점이 다른지도 함께 봅니다.
next image blurDataURL 직접 부여하기 thumbnail
next image blurDataURL 직접 부여하기
Next.js의 Image 컴포넌트에서 blur placeholder를 사용할 때 자동 생성되는 경우와 직접 blurDataURL을 만들어야 하는 경우를 정리합니다. 정적 import, public 경로, 원격 이미지, 빌드 시점 생성 전략을 함께 다룹니다.
Next.js에서 path alias 설정하기 (feat. @/components) thumbnail
Next.js에서 path alias 설정하기 (feat. @/components)
오늘은 Next.js에서 import 시 복잡한 relative path 대신 absolute path 사용을 위한 설정법을 알아봅시다. file path를 상대경로로 지정하다보면 유지보수면에서도 복잡하고, path를 지정할 때마다 경로가 헷갈려서 발생하는 오류는 덤입니다. 이럴 때 path에 대한 alias를 설정하면 코드는 확 깔끔해질 것입니다.  아래 예제를 통해서 따라해봅시다.
Next.js에서 레이아웃 사용하기 thumbnail
Next.js에서 레이아웃 사용하기
홈페이지를 구성할 때, 우리는 대체로 네비게이션과 푸터, 플로팅 버튼 등을 포함합니다. 개발단계에서 이들 컴포넌트를 페이지마다 일일이 import하는 것은 매우 비효율적인 일입니다. 만약 이들처럼 대부분의 페이지에서 보여줘야될 내용이 있다면, 레이아웃 컴포넌트를 통해 손쉽게 유지보수할 수 있을 것입니다.