콘텐츠로 이동

2. 서버 컴포넌트

use client는 스위치가 아니라 경계다

React 컴포넌트를 서버에서도 실행할 수 있게 만들고, 그에 필요한 라우팅·번들링·캐싱·배포를 한 덩어리로 묶은 것.

“서버에서도”가 핵심이고 나머지는 부속이다. 이 전환이 App Router(Next.js 13)에서 시작해 지금(16.x)까지 다듬어지고 있다. Pages Router는 여전히 동작하지만, 새 프로젝트의 기본은 App Router다.

flowchart LR
    A["서버<br/>빈 HTML"] --> B["브라우저<br/>JS 번들 전부 다운로드"]
    B --> C["React 실행<br/>컴포넌트 렌더"]
    C --> D["useEffect 발동<br/>fetch 시작"]
    D --> E["로딩 스피너"]
    E --> F["데이터 도착<br/>다시 렌더"]

    classDef bad  fill:#fee2e2,stroke:#dc2626,color:#7f1d1d
    classDef mute fill:#f1f5f9,stroke:#94a3b8,color:#334155
    class A,E bad
    class B,C,D,F mute
  • 사용자는 흰 화면 → 스켈레톤 → 실제 내용 세 단계를 본다
  • 데이터를 가져오는 코드가 브라우저에 있으니 API 서버가 따로 필요하다
  • API 키를 브라우저에 둘 수 없으니 또 다른 서버를 만든다

서버가 기본값, 클라이언트는 명시

섹션 제목: “서버가 기본값, 클라이언트는 명시”

App Router에서는 아무 표시가 없으면 서버 컴포넌트다.

// app/posts/page.tsx — 표시 없음 = 서버 컴포넌트
import { db } from '@/lib/db'
export default async function PostsPage() {
// 이 쿼리는 서버에서만 실행된다. 브라우저는 이 코드를 본 적도 없다.
const posts = await db.post.findMany({ orderBy: { createdAt: 'desc' } })
return (
<ul>
{posts.map((p) => (
<li key={p.id}>{p.title}</li>
))}
</ul>
)
}

async 컴포넌트가 자연스럽게 동작한다. 이건 서버 컴포넌트만의 특권이다.

브라우저가 필요한 것을 쓰려면 파일 맨 위에 지시자를 붙인다.

'use client' // ← 이 파일부터는 브라우저로 간다
import { useState } from 'react'
import { Button } from '@/components/ui/button'
export function Counter() {
const [n, setN] = useState(0)
return <Button onClick={() => setN(n + 1)}>눌린 횟수: {n}</Button>
}

useState, useEffect, onClick, window, localStorage브라우저가 필요한 것을 쓰면 use client가 필요하다.

서버 컴포넌트 클라이언트 컴포넌트
훅 (useState, useEffect…)
이벤트 핸들러 (onClick)
window · localStorage
Context 제공 (<Provider>)
async 컴포넌트
DB · 파일시스템 직접 접근
비밀 환경변수
Node.js 전용 모듈

use client는 스위치가 아니라 경계다

섹션 제목: “use client는 스위치가 아니라 경계다”

가장 흔한 오해다. use client는 그 파일 하나가 아니라 거기서부터 아래 전부를 클라이언트로 만든다.

flowchart TB
    P["app/page.tsx — 서버"] --> H["Header — 서버"]
    P --> S["SearchBox — 'use client' ← 경계"]
    P --> L["PostList — 서버"]
    S --> SG["Suggestions — 표시 없어도 클라이언트"]
    SG --> HL["Highlight — 역시 클라이언트"]

    classDef ok   fill:#dcfce7,stroke:#16a34a,color:#14532d
    classDef warn fill:#fef3c7,stroke:#d97706,color:#78350f
    classDef bad  fill:#fee2e2,stroke:#dc2626,color:#7f1d1d
    class P,H,L ok
    class S warn
    class SG,HL bad
'use client'
export default function Layout({ children }) {
const [open, setOpen] = useState(false)
return (
<div>
<Sidebar open={open} />
<button onClick={() => setOpen(!open)}>메뉴</button>
{children}
</div>
)
}

children 안의 모든 것이 클라이언트로 딸려간다. 차트 라이브러리, 에디터, 날짜 피커가 그 아래에 있으면 번들이 폭발한다.

클라이언트가 서버를 감쌀 수 있다

섹션 제목: “클라이언트가 서버를 감쌀 수 있다”

반대 방향이 되는지가 헷갈리는 지점이다. 정답: children으로 넘기면 된다.

// ❌ 클라이언트 컴포넌트가 서버 컴포넌트를 직접 import — 불가능
'use client'
import { ServerPostList } from './post-list' // 서버 컴포넌트
export function Panel() {
return <div><ServerPostList /></div> // 클라이언트로 끌려들어간다
}
// ✅ children으로 받으면 된다
'use client'
export function Panel({ children }) {
const [open, setOpen] = useState(true)
return <div>{open && children}</div>
}
// 서버 컴포넌트(page.tsx)에서 조립
<Panel>
<ServerPostList /> {/* 서버에서 렌더된 결과가 들어간다 */}
</Panel>

경계를 넘는 props는 직렬화되어야 한다

섹션 제목: “경계를 넘는 props는 직렬화되어야 한다”
넘길 수 있다 넘길 수 없다
문자열, 숫자, 불리언, null 함수 (onClick={handleClick})
배열, 평범한 객체 클래스 인스턴스 (ORM 모델 객체 등)
Date, Map, Set Symbol
Promise (React가 처리해 준다) 클로저를 가진 무엇이든
JSX (children 포함)
Server Function 참조

서버에서 클라이언트로 가는 것의 정체

섹션 제목: “서버에서 클라이언트로 가는 것의 정체”

서버 컴포넌트의 렌더 결과는 HTML만이 아니다. RSC 페이로드라는 별도 형식도 함께 간다.

flowchart LR
    A["서버 컴포넌트<br/>렌더"] --> B["RSC 페이로드<br/>직렬화된 트리"]
    B --> C["HTML<br/>첫 방문용"]
    B --> D["클라이언트 이동 시<br/>이 페이로드만 전송"]
    D --> E["React 가 기존 트리에 merge<br/>클라이언트 상태 유지"]

    classDef key  fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
    classDef ok   fill:#dcfce7,stroke:#16a34a,color:#14532d
    classDef mute fill:#f1f5f9,stroke:#94a3b8,color:#334155
    class B key
    class E ok
    class A,C,D mute
  • 첫 방문 → HTML을 받아 즉시 보인다
  • 링크 클릭 → 전체 페이지가 아니라 RSC 페이로드만 받는다
  • 그래서 클라이언트 상태(스크롤, 입력값, 열린 모달)가 유지된 채 화면이 바뀐다
  1. 서버가 HTML을 보낸다 → 사용자에게 보인다. 하지만 버튼을 눌러도 반응이 없다
  2. 클라이언트 컴포넌트의 JS가 도착한다
  3. React가 그 HTML에 이벤트 핸들러를 붙인다 → 이제 동작한다

2~3단계 사이의 간극이 짧을수록 좋은 앱이다. 서버 컴포넌트를 많이 쓸수록 하이드레이션할 대상이 줄어든다.

  • 디렉터리app/
    • layout.tsx 서버
    • providers.tsx ‘use client’ — Provider 껍데기만
    • page.tsx 서버 — 데이터 조회
    • 디렉터리_components/ 밑줄 = 라우팅에서 제외
      • revenue.tsx 서버
      • filter.tsx ‘use client’
  • 디렉터리components/
    • 디렉터리ui/ shadcn/ui가 관리하는 영역
    • 디렉터리shared/ 여러 라우트가 쓰는 우리 컴포넌트
  • 디렉터리lib/
    • db.ts 서버 전용 — ‘server-only’
    • utils.ts 양쪽 다 쓰는 순수 함수

_components처럼 밑줄로 시작하는 폴더는 라우팅에서 제외된다. 라우트 전용 조각을 페이지 옆에 두는 데 쓴다.

  • Next.js의 본질은 React를 서버에서 실행하는 것이다
  • App Router에서 서버 컴포넌트가 기본값, 클라이언트는 use client로 명시
  • use client는 스위치가 아니라 경계다 — 아래 전부가 클라이언트가 된다
  • 경계를 잎사귀로 밀수록 번들이 작아진다
  • 클라이언트가 서버 컴포넌트를 쓰려면 children으로 받는다 (import는 안 된다)
  • 경계를 넘는 props는 직렬화 가능해야 한다 — 함수와 ORM 객체가 단골 사고