콘텐츠로 이동
Study Note프론트엔드

2. 서버 컴포넌트

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

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

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

클라이언트 전용 렌더의 순서 — 빈 HTML부터 데이터 도착 후 재렌더까지
  • 사용자는 흰 화면 → 스켈레톤 → 실제 내용 세 단계를 본다
  • 데이터를 가져오는 코드가 브라우저에 있으니 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는 그 파일 하나가 아니라 거기서부터 아래 전부를 클라이언트로 만든다.

'use client' 경계 아래로는 전부 클라이언트가 되는 컴포넌트 트리
'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, SetSymbol
Promise (React가 처리해 준다)클로저를 가진 무엇이든
JSX (children 포함)
Server Function 참조

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

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

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

RSC 페이로드가 첫 방문 HTML과 클라이언트 이동 시 트리 merge로 갈라지는 흐름
  • 첫 방문 → 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 객체가 단골 사고