2. 서버 컴포넌트
use client는 스위치가 아니라 경계다
Next.js를 한 문장으로
섹션 제목: “Next.js를 한 문장으로”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 키를 브라우저에 둘 수 없으니 또 다른 서버를 만든다
flowchart LR
A["서버<br/>컴포넌트 실행<br/>DB 직접 조회"] --> B["완성된 HTML<br/>+ 최소 JS"]
B --> C["브라우저<br/>바로 보임"]
C --> D["인터랙션 필요한<br/>부분만 하이드레이션"]
classDef ok fill:#dcfce7,stroke:#16a34a,color:#14532d
classDef mute fill:#f1f5f9,stroke:#94a3b8,color:#334155
class A,C ok
class B,D mute- 데이터 조회가 컴포넌트 안에서 일어난다.
await db.query(...)를 바로 쓴다 - 그 코드는 브라우저로 전송되지 않는다. 번들에 포함조차 안 된다
- 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 안의 모든 것이 클라이언트로 딸려간다.
차트 라이브러리, 에디터, 날짜 피커가 그 아래에 있으면 번들이 폭발한다.
// 레이아웃은 서버 컴포넌트로 둔다export default function Layout({ children }) { return ( <div> <SidebarToggle /> {/* 상태를 가진 이 조각만 클라이언트 */} {children} </div> )}상태를 가진 작은 조각만 분리한다. 같은 화면이라도 브라우저로 가는 JS가 4~5배 차이 난다.
클라이언트가 서버를 감쌀 수 있다
섹션 제목: “클라이언트가 서버를 감쌀 수 있다”반대 방향이 되는지가 헷갈리는 지점이다. 정답: 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 페이로드만 받는다
- 그래서 클라이언트 상태(스크롤, 입력값, 열린 모달)가 유지된 채 화면이 바뀐다
하이드레이션
섹션 제목: “하이드레이션”- 서버가 HTML을 보낸다 → 사용자에게 보인다. 하지만 버튼을 눌러도 반응이 없다
- 클라이언트 컴포넌트의 JS가 도착한다
- 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처럼 밑줄로 시작하는 폴더는 라우팅에서 제외된다.
라우트 전용 조각을 페이지 옆에 두는 데 쓴다.
2장 요약
섹션 제목: “2장 요약”- Next.js의 본질은 React를 서버에서 실행하는 것이다
- App Router에서 서버 컴포넌트가 기본값, 클라이언트는
use client로 명시 use client는 스위치가 아니라 경계다 — 아래 전부가 클라이언트가 된다- 경계를 잎사귀로 밀수록 번들이 작아진다
- 클라이언트가 서버 컴포넌트를 쓰려면
children으로 받는다 (import는 안 된다) - 경계를 넘는 props는 직렬화 가능해야 한다 — 함수와 ORM 객체가 단골 사고