본문 바로가기
tech epilogue

Next.js App Router

by rami_ 2026. 7. 14.

1. Next.js가 채워주는 것들

React 자체는 '컴포넌트를 어떻게 렌더링할지'만 정의하는 라이브러리다. 라우팅, 번들링, 서버렌더링, 코드 스플리팅은 전혀 다루지 않는다.

Next.js는 React를 프로덕션에서 쓰기 편하게 만든 프레임워크이다. 파일 기반 라우팅을 가지고 있고, 렌더링 전략의 선택권을 가지고 있다. 라우트 단위로 번들을 쪼개서 필요한 것들을 로드해 자동 코드 스플리팅이 되며, webpack(또는 최근에는 Turbopack)을 내부적으로 구성해 빌드 파이프라인을 만든다.

 

2. 렌더링 전략 : 페이지 단위에서 컴포넌트 단위로

Next.js에서 렌더링 전략이 핵심이다.

  • SSG(Static Site Generation) : 빌드 타임에 HTML을 미리 만들어둠. generateStaticParams 같은 함수로 어떤 페이지를 미리 만들지 결정
  • SSR(Server Side Rendering) : 요청이 올 때마다 서버에서 렌더링. 데이터가 실시간성이 중요할 때.
  • ISR(Incremental Static Regeneration) : SSG인데 일정 주기로 백그라운드에서 재생성. revalidate 옵션으로 캐시 무효화 타이밍 제어. 

이게 페이지 전체 단위가 아니라 컴포넌트 단위로 섞일 수 있다는게 App router의 진짜 힘이다.

 

3. App Router의 근본 원리: React Server Components

App Router의 근본 원리는 React Server Components이다.

Pages Router 시절엔 위의 전략들이 '페이지'단위 였는데, APP Router(v13+)는 아예 React 자체의 렌더링 모델을 바꿨다.

 

 

1. 기본적으로 모든 컴포넌트는 서버 컴포넌트이다. 서버에서만 실행되고, 번들에 JS로 안 실려 나간다.

2. 'use client' 지시어를 명시해야 클라이언트 컴포넌트가 되고, 그때 비로소 하이드레이션 대상이 된다.(이 부분은 하이드레이션과 함께 다음 섹션에서 자세히 다룹니다.)

3. 서버 컴포넌트는 DB 접근, 파일 시스템 접근을 컴포넌트 안에서 바로 할 수 있다(API 레이어를 안거쳐도 된다)

  • Before : 전통적인 SPA/Pages Router 구조에서는 '브라우저 -> fetch -> API Route -> DB 쿼리 -> JSON 응답 -> 클라이언트가 파싱 -> 렌더링' 이런 흐름이 강제되었다.
  • After : 서버 컴포넌트 안에서 직접 접근
// app/users/[id]/page.tsx (서버 컴포넌트)
import { db } from '@/lib/db'; // DB 클라이언트를 컴포넌트 파일에서 바로 import

export default async function UserPage({ params }: { params: { id: string } }) {
  const user = await db.user.findUnique({ where: { id: params.id } }); // 직접 쿼리
  return <div>{user.name}</div>;
}

 

여기에는 API 라우트가 없다. 컴포넌트 함수 자체가 async라서 그 안에서 await로 DB 쿼리를 바로 날릴 수 있다. 이게 가능한 이유는 이 코드가 오직 서버에서만 실행되기 때문이다. 클라이언트로 넘어갈 일이 없으니 DB커넥션 스트링이 노출될 위험도 없다.

실질적인 이점으로는

  • 왕복 제거(round trip) : 클라이언트가 API fetch하고 응답을 기다리는 네트워크 왕복 자체가 사라진다.  서버 안에서 렌더링과 데이터 페칭이 한 번에 끝난다.
  • API 라우트 유지보수 부담 감소 : 단순 조회용 API 엔드포인트를 따로 만들필요가 없어진다. 단 외부에서 호출해야하는 API라면 여전히 필요하다.
  • 타입 안정성 : DB 쿼리 결과 타입이 컴포넌트 Props 까지 끊김없이 이어진다. JSON 직렬화 과정에서 타입이 깨지는 문제가 없다

결과적으로 클라이언트로 보내는 JS번들이 많이 줄어들게 된다.

 

이게 RSC의 존재 이유이다. RSC가 나온 배경 자체가 React 앱이 점점 무거워지는 문제를 해결하려고 했다.

전통적인 SPA는 컴포넌트 로직(마크업 생성 코드), 데이터 패칭 코드, 상태 관리 로직, 이 셋을 다루는 라이브러리 전부가 하나의 번들로 묶여서 클라이언트로 전송했다. 사용자는 앱을 쓰기도 전에 이 모든걸 다운로드하고 파싱하고 실행해야 했다.

// ❌ 과거 방식: 마크다운 파서 같은 무거운 라이브러리가 클라이언트에 실림
'use client'
import { marked } from 'marked'; // ~30KB
import DOMPurify from 'dompurify'; // ~20KB

export function ArticleRenderer({ markdown }: { markdown: string }) {
  const html = DOMPurify.sanitize(marked(markdown));
  return <div dangerouslySetInnerHTML={{ __html: html }} />;
}
// ✅ 서버 컴포넌트로: 이 라이브러리들이 클라이언트 번들에서 완전히 빠짐
import { marked } from 'marked';
import DOMPurify from 'dompurify';

export async function ArticleRenderer({ markdown }: { markdown: string }) {
  const html = DOMPurify.sanitize(marked(markdown)); // 서버에서 파싱 끝내고 순수 HTML만 전달
  return <div dangerouslySetInnerHTML={{ __html: html }} />;
}

 

두 코드의 렌더링 결과는 동일하다. 하지만 아래 버전은 marked, DOMPurify 자체가 클라이언트로 단 1바이트도 전송되지 않는다. 파싱은 서버에서 끝내고 클라이언트는 완성된 결과만 받는 구조이다. 

 

 

4. 하이드레이션 — 서버와 클라이언트가 만나는 지점

'use client' 지시어는 런타임 로직이 아니라 컴파일러/번들러에게 주는 경계선 표시이다. 

'use client'
import { useState } from 'react';

export function Counter() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

 

Next.js 빌드 과정에서 이 파일과, 이 파일이 import하는 모든 하위 모듈을 클라이언트 번들에 포함시키기로 표시한다. 그리고 서버에서 렌더링 할 때, 이 컴포넌트는 "지금 여기 클라이언트 컴포넌트가 있다"라는 마커만 RSC Payload에 남기고, 실제 인터랙티브 로직은 클라이언트에서 하이드레이션이 될 때 채워진다.

 

하이드레이션이 정확히 뭘 하는가.

서버가 만든 HTML은 이미 눈에 보이는 상태로 도착한다.

하지만 이 시점엔 onClick같은 이벤트 핸들러가 아직 안 붙어있는 '죽어있는 HTML'이다.

 

 

 

 

전이(propagation)규칙 

전이가 자식 방향으로 가는 이유는 '브라우저가 이 코드를 실행하려면 뭐가 필요한가'라는 질문에 대한 답이다. 클라이언트 컴포넌트를 실행하는데 필요한건 그게 import하는 하위 모듈들이지, 그걸 렌더링 트리 어딘가에서 호출하는 상위 서버컴포넌트가 아니다.

'use client'
import { Chart } from './Chart';   // Chart도 클라이언트 번들에 포함됨

export function Dashboard() {
  return <Chart />;
}

 

Dashboard가 Chart를 import한다는건, Dashboard를 브라우저에서 실행하려면 Chart의 코드도 브라우저에 있어야 한다는 뜻이다. 번들러 입장에서 '이 파일을 클라이언트로 보내야한다'고 결정했으면, 그 파일이 import하는 모듈도 함께 보내지 않으면 애초에 실행이 되지 지 않는다(Chart가 없으면 Dashboard 함수 자체가 참조 에러로 깨지기때문이다).

 

지금까지는 '읽기(rendering)'의 경계가 어떻게 사라지는지 봤다면, 이번엔 '쓰기(mutation)'의 경계 차례다.

 

5. Server Actions : 쓰기(mutation)의 경계도 흐려지다

async function updateUser(formData: FormData) {
  'use server'
  await db.user.update({ where: { id: formData.get('id') }, data: { name: formData.get('name') } });
}

 

'use server'는 함수 본문은 절대 클라이언트로 보내지 말고, 클라이언트에는 이 함수를 호출할 수 있는 참조만 남겨라는 표시이다.

즉 클라이언트가 가지고 있는건 실제 로직이 아니라 '이 함수를 호출하면 서버로 요청이 간다'는 포인터 같은 것이다.

 

기존 API 라우트 흐름 

클라이언트: onSubmit → fetch('/api/users/update', { method: 'POST', body: JSON.stringify(data) })
     ↓
서버: app/api/users/update/route.ts에서 요청 파싱 → DB 업데이트 → JSON 응답
     ↓
클라이언트: 응답 받아서 state 업데이트, 에러 핸들링 등 수동 처리

 

폼 하나 다루려면 API 라우트 파일을 하나 더 만들어야 했고, 클라이언트 쪽에서도 fetch로직, 로딩상태, 에러상태를 전부 직접 짜야했다.

 

Server Action 코드

// actions.ts
'use server'

export async function updateUser(formData: FormData) {
  const id = formData.get('id') as string;
  const name = formData.get('name') as string;
  await db.user.update({ where: { id }, data: { name } });
}
// UserForm.tsx (서버 컴포넌트도, 클라이언트 컴포넌트도 될 수 있음)
import { updateUser } from './actions';

export default function UserForm({ userId }: { userId: string }) {
  return (
    <form action={updateUser}>
      <input type="hidden" name="id" value={userId} />
      <input type="text" name="name" />
      <button type="submit">저장</button>
    </form>
  );
}

 

여기서 핵심은 <form action={updateUser}> - HTML의 action 속성에 함수를 직접 넘길 수 있다는것. API 라우트 파일도, fetch 코드도, 클라이언트쪽 이벤트 핸들러도 필요 없다.

 

내부 동작 원리 

이게 가능한 이유는 빌드타임에 Next.js가 'use server'가 붙은 함수마다 고유 ID를 부여한다.

클라이언트는 이 함수의 실제 코드 대신, '이 ID로 서버에 POST 요청을 보내라'는 얇은 참조만 번들에 포함된다.

폼이 제출되면 : 

1. 브라우저가 내부적으로 그 ID를 담아 서버에 POST 요청

2. 서버가 ID로 원래 함수를 찾아 실행(FormData도 함께 전달)

3. 결과(또는 리다이렉트, 재검증 신호)를 클라이언트로 응답

 

즉 겉보기엔 '함수 호출'처럼 보이지만, 내부적으로 여전히 네트워크 요청이 오간다.

API 라우트를 '직접 안 만들어도 되는'게 아니라 Next.js가 그 라우트를 자동으로 만들어주는 것에 가깝다.

 

Progressive Enhancement - JS 없이도 동작함

<form action={updateUser}>은 JS가 로드 되기전에도 동작한다. 일반 HTML 폼 제출처럼 브라우저 네이티브 동작으로 서버에 요청이 간다. JS가 늦게 로드되거나 실패해도 폼 제출 자체는 살아있다는 의미이다. 이게 점진적 향상이다.

'use client'
import { useTransition } from 'react';
import { updateUser } from './actions';

export function UserFormClient({ userId }: { userId: string }) {
  const [isPending, startTransition] = useTransition();

  function handleSubmit(formData: FormData) {
    startTransition(async () => {
      await updateUser(formData);
    });
  }

  return (
    <form action={handleSubmit}>
      <input type="hidden" name="id" value={userId} />
      <input type="text" name="name" />
      <button disabled={isPending}>{isPending ? '저장 중...' : '저장'}</button>
    </form>
  );
}

 

클라이언트 컴포넌트 안에서도 Server Action을 호출할 수 있고, useTransition으로 로딩 상태(isPending)을 얻을 수 있다. 이전에 다뤘던 useFormStatus도 이 맥락에서 자연스럽게 쓰이는 훅이다.

 

데이터 재검증(revalidatePath)과의 연결

Server Action은 단순히 DB만 바꾸고 끝나는게 아니라, 보통 그 뒤에 캐시를 무효화하는 코드가 따라온다.

'use server'
import { revalidatePath } from 'next/cache';

export async function updateUser(formData: FormData) {
  await db.user.update({ /* ... */ });
  revalidatePath('/users'); // 이 경로의 서버 컴포넌트 캐시를 무효화 → 최신 데이터로 재렌더링
}

 

ISR의 revalidate는 '시간 기반'이고 revalidatePath, revalidateTag는 '이벤트 기반(mutation이 일어났을 때)' 재검증이다.

즉 사용자가 데이터를 수정하는 순간, 그 데이터를 보여주는 다른 페이지들도 즉시 최신 상태로 갱신될 수 있다.

 

보안 유의점

'use server' 함수가 클라이언트에서 '호출 가능한 엔드포인트'가 된다는건, 사실상 공개 API처럼 취급해야 한다는 뜻이다.

함수 내부에서 별도로 :

- 인증상태 체크

- 입력값 검증(클라이언트가 보낸 FormData를 신뢰하면 안 됨 - zod 같은 걸로 파싱/검증)

- 권한 체크(이 유저가 이 리소스를 수정할 권한이 있는지)

를 반드시 해줘야 한다. '서버에서만 실행된다'는 것과 '누구나 호출할 수 있다'는 별개의 문제이다.

 

6. 서버 컴포넌트와 클라이언트 컴포넌트 변별 기준

클라이언트 전용 기능이 필요한가?

다음 중 하나라도 해당하면 클라이언트 컴포넌트여야 한다.

  • 상태(state)를 가짐 : useState, useReducer 사용
  • 생명주기/부수효과 : useEffect, useLayoutEffect
  • 브라우저 API 접근 : window, localStorage, document, IntersectionObserver
  • 이벤트 핸들러 : onClick, onChange, onSubmit 등(사용자 상호작용)
  • 커스텀 훅 사용 : 위 기능들을 내부적으로 쓰는 훅이면 전이됨
  • Context API : useContext로 값을 구독하는 쪽

반대로 다음에 해당하면 서버 컴포넌트로 남겨두는게 이득이다.

  • 데이터 페칭만 하고 결과를 그대로 렌더링
  • 정적인 레이아웃, 텍스트, 마크업 구조
  • DB/파일 시스템 직접접근이 필요한 경우(서버컴포넌트만 가능)
  • 민감한 로직(API 키, 인증 토큰 처리) - 클라이언트로 노출되면 안되는 것들

 

만약 컴포넌트 안에 버튼 하나 있는데 그럼 전체를 클라이언트로 만들어야 하나?

아니다.

이게 제일 중요한 포인트인데, 상호작용이 필요한 부분만 잘라서 별도 클라이언트 컴포넌트로 분리하고, 나머지는 서버 컴포넌트가 감싸는 구조로 가야한다.

// ServerPage.tsx (서버 컴포넌트)
export default async function ProductPage() {
  const product = await fetchProduct(); // 서버에서 직접 fetch
  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <AddToCartButton productId={product.id} /> {/* 클라이언트 컴포넌트 */}
    </div>
  );
}


// AddToCartButton.tsx
'use client'
export function AddToCartButton({ productId }: { productId: string }) {
  const [loading, setLoading] = useState(false);
  return <button onClick={() => addToCart(productId)}>담기</button>;
}

 

이렇게 하면 상품설명 같은 무거운 정적 컨텐츠는 번들에 안 실리고, 버튼 로직만 클라이언트 JS로 내려간다.

 

Leaf 컴포넌트로 밀어내기 전략

전체 트리를 서버로 시작해서, 상호작용이 필요한 지점만 트리의 잎(leaf) 쪽으로 클라이언트 경계를 내려보내는게 정석이다. 흔한 실수는 최상위 레이아웃에 'use client'를 걸어버려서 그 하위 전체가 클라이언트가 되는 경우이다.

❌ Layout ('use client')
    └── Header
    └── Sidebar  
    └── Content (전부 클라이언트가 되어버림)

✅ Layout (서버)
    └── Header (서버)
    └── Sidebar (서버)
    └── InteractiveWidget ('use client', 여기만)

 

판단이 애매할 때의 원칙

'서버에서 할 수 있는데 굳이 클라이언트에 뒀나?'를 자문하는게 습관이 되면 좋다. 기본 값은 서버, 필요할 때만 예외적으로 클라이언트로 접근하는게 App Router의 설계 철학과 맞아 떨어진다.

 

7.  클라이언트 컴포넌트 안에서 서버 컴포넌트를 자식으로 넣기

문제 상황

'use client'
import ServerComponent from './ServerComponent' // ❌ 에러 발생

export function ClientWrapper() {
  const [open, setOpen] = useState(false);
  return (
    <div>
      <button onClick={() => setOpen(!open)}>토글</button>
      {open && <ServerComponent />} {/* 클라이언트 파일 안에서 서버 컴포넌트를 import */}
    </div>
  );
}

 

'use client' 파일 안에서 서버 컴포넌트를 직접 import하는 순간, 그 서버 컴포넌트도 클라이언트 번들에 강제로 포함된다. 즉 서버 컴포넌트로 남겨두는 의미가 사라진다.

 

해결책 : children prop으로 받기

클라이언트 컴포넌트는 서버 컴포넌트를 import하면 안되지만, props로 받는건 문제가 없다. React 트리 관점에서 children은 이미 렌더링된 결과물(React element)이지 클라이언트 컴포넌트가 그 내부 코드를 알 필요가 없다.

 

// ClientWrapper.tsx
'use client'
export function ClientWrapper({ children }: { children: React.ReactNode }) {
  const [open, setOpen] = useState(false);
  return (
    <div>
      <button onClick={() => setOpen(!open)}>토글</button>
      {open && children} {/* 이미 렌더링된 서버 컴포넌트 결과를 그냥 끼워넣음 */}
    </div>
  );
}

// ParentPage.tsx (서버 컴포넌트)
import { ClientWrapper } from './ClientWrapper';
import { ServerComponent } from './ServerComponent';

export default function Page() {
  return (
    <ClientWrapper>
      <ServerComponent /> {/* 서버에서 렌더링된 채로 전달됨 */}
    </ClientWrapper>
  );
}

 

이렇게 하면 ServerComponent는 여전히 서버에서 렌더링되고, 그 결과 (HTML/RSC payload)만 ClientWrapper의 children 슬롯에 꽂히는 구조가 된다. ClientWrapper 입장에선 'children이 뭔지' 몰라도 되고 그냥 그 렌더링만 위임하면 그만이다.

 

이 패턴이 유용한 경우

  • 모달, 아코디언, 탭처럼 '열고 닫는 상태'는 클라이언트가 관리하지만, 그 안의 컨텐츠는 무거운 데이터 페칭이 필요한 경우
  • 레이아웃 래퍼(예: 애니메이션 컨테이너)는 클라이언트인데, 실제 컨텐츠는 서버 컴포넌트 경우

 

8. 자주 빠지는 안티패턴 4가지

안티패턴 1. 최상위 Provider에 'use client'를 걸어서 전체트리가 오염되는 경우

// ❌ layout.tsx
'use client'
export default function RootLayout({ children }) {
  return (
    <ThemeProvider>
      {children} {/* children도 클라이언트 경계 안에 갇혀버림 */}
    </ThemeProvider>
  );
}

 

client으로 받으니 위 1번 패턴처럼 안전해보이지만, 실제로는 RootLayout 자체가 'use client'라서 이 파일과 그 안의 모든 로직이 번들에 포함된다. Context Provider가 필요하면 Provider만 별도 클라이언트 컴포넌트로 분리하고, layout자체는 서버로 남겨야 한다.

 

// ✅ Providers.tsx
'use client'
export function Providers({ children }) {
  return <ThemeProvider>{children}</ThemeProvider>;
}

// ✅ layout.tsx (서버 컴포넌트로 유지)
import { Providers } from './Providers';
export default function RootLayout({ children }) {
  return <Providers>{children}</Providers>;
}

 

안티패턴2. 데이터 패칭을 클라이언트 컴포넌트 안에서 하는 경우

// ❌
'use client'
export function ProductList() {
  const [products, setProducts] = useState([]);
  useEffect(() => {
    fetch('/api/products').then(r => r.json()).then(setProducts);
  }, []);
  return <ul>{products.map(p => <li key={p.id}>{p.name}</li>)}</ul>;
}

 

이러면 클라이언트에서 API 라우트를 또 거치는 워터폴이 생기고, 초기 로딩에 빈 화면 -> 로딩 -> 데이터 순서로 깜빡임이 생겨. 서버 컴포넌트에서 직접 fetch하면 이 왕복이 사라진다.

 

// ✅
export async function ProductList() {
  const products = await getProducts(); // 서버에서 직접 DB/API 접근
  return <ul>{products.map(p => <li key={p.id}>{p.name}</li>)}</ul>;
}

 

안티패턴 3. 큰 서버 컴포넌트 트리 전체를 클라이언트 컴포넌트로 감싸는 경우

// ❌
'use client'
export function Dashboard({ initialData }) {
  const [filter, setFilter] = useState('all');
  return (
    <div>
      <FilterButtons onFilterChange={setFilter} />
      <HeavyStatsTable data={initialData} /> {/* 필터링만 필요한데 통째로 클라이언트 */}
      <ChartSection data={initialData} />
    </div>
  );
}

 

필터 상태만 클라이언트 필요한데, 대시보드 전체가 클라이언트 컴포넌트가 되면서 무거운 테이블/차트 렌더링 로직까지 전부 JS 번들로 내려간다. 

 

안티패턴 4. use client 경계를 너무 세분화 안 해서 상태 하나 때문에 전체가 딸려가는 경우

반대로 너무 안일하게 '이 폼 전체가 상태 필요하니까 다 클라이언트'해버리는 경우도 흔하다. 실제로 폼 필드 하나하나를 서버에서 초기 렌더링 하고, 상태를 다루는 wrapper만 얇게 클라이언트로 만드는게 이상적이다.

 

핵심 원칙을 한 줄로 정리하면 '클라이언트 경계는 최대한 늦게, 최대한 얇게 그어라'. 상태나 이벤트가 필요한 지점까지는 서버로 밀어붙이고, 그 지점에서 최소한의 클라이언트로 컴포넌트로 감싼 뒤 children으로 나머지를 다시 서버에 넘기는 구조가 Next.js App Router의 이상적인 형태이다.

 

9. 실전 예제: 폼 필드 단위로 서버/클라이언트 쪼개기

// ❌ SignupForm.tsx - 폼 전체가 클라이언트가 되어버림
'use client'
import { useState } from 'react';

export function SignupForm() {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');

  return (
    <form>
      <label>이름</label>
      <input value={name} onChange={(e) => setName(e.target.value)} />

      <label>이메일</label>
      <input value={email} onChange={(e) => setEmail(e.target.value)} />

      <label>비밀번호</label>
      <input value={password} onChange={(e) => setPassword(e.target.value)} />

      <button type="submit">가입하기</button>
    </form>
  );
}

 

9.1. 상태관리만 담당하는 얇은 클라이언트 컴포넌트

// FieldWrapper.tsx
'use client'
import { useState, cloneElement, isValidElement } from 'react';

interface FieldWrapperProps {
  name: string;
  defaultValue?: string;
  children: React.ReactElement; // 서버에서 렌더링된 input을 그대로 받음
}

export function FieldWrapper({ name, defaultValue = '', children }: FieldWrapperProps) {
  const [value, setValue] = useState(defaultValue);

  if (!isValidElement(children)) return children;

  // 서버가 만들어준 input에 value/onChange만 주입
  return cloneElement(children, {
    value,
    onChange: (e: React.ChangeEvent<HTMLInputElement>) => setValue(e.target.value),
    name,
  } as any);
}

 

이 컴포넌트는 input의 생김새를 전혀 모른다. 그냥 상태 하나 들고 있다가 children에 주입만 해줘. 즉 '무엇을 그릴지'는 서버가, '어떻게 상태를 관리할지'는 클라이언트가 나눠 맡는 구조이다.

 

9.2. 필드 마크업은 서버 컴포넌트로

// FormField.tsx (서버 컴포넌트, 'use client' 없음)
interface FormFieldProps {
  label: string;
  type?: string;
  placeholder?: string;
}

export function FormField({ label, type = 'text', placeholder }: FormFieldProps) {
  return (
    <div className="field-group">
      <label className="field-label">{label}</label>
      <input
        type={type}
        placeholder={placeholder}
        className="field-input"
        // value/onChange는 아직 없음 - FieldWrapper가 나중에 주입
      />
    </div>
  );
}

 

label 텍스트, 클래스 명, 레이아웃 같은 정적인 마크업은 여기서 서버가 다 만든다. 이 컴포넌트 자체는 클라이언트 번들에 안실린다.

 

9.3. 조립 : 서버 컴포넌트가 폼 전체를 구성

// SignupForm.tsx (서버 컴포넌트)
import { FormField } from './FormField';
import { FieldWrapper } from './FieldWrapper';
import { SubmitButton } from './SubmitButton'; // 아래 4번에서 다룸

export default function SignupForm() {
  return (
    <form>
      <FieldWrapper name="name">
        <FormField label="이름" placeholder="홍길동" />
      </FieldWrapper>

      <FieldWrapper name="email">
        <FormField label="이메일" type="email" placeholder="you@example.com" />
      </FieldWrapper>

      <FieldWrapper name="password">
        <FormField label="비밀번호" type="password" />
      </FieldWrapper>

      <SubmitButton />
    </form>
  );
}

 

여기서 중요한 지점은 FieldWrapper는 클라이언트 컴포넌트지만 children으로 받는 <FormField />는 서버에서 이미 렌더링된 결과물이다.

 

9.4. 제출 버튼도 별도로 얇게 분리

// SubmitButton.tsx
'use client'
import { useFormStatus } from 'react-dom';

export function SubmitButton() {
  const { pending } = useFormStatus();
  return (
    <button type="submit" disabled={pending}>
      {pending ? '제출 중...' : '가입하기'}
    </button>
  );
}

 

버튼 하나만 딱 필요한 만큼의 클라이언트 로직(pending 상태)만 가짐.

 

이 구조의 실제 효과

  • FormField(label, input 마크업, 스타일)는 번들에 포함 안 됨 : 필드가 몇 개든 JS 크기에 영향 없음.
  • 클라이언트로 내려가는 건 FieldWrapper의 상태 관리 로직 + SubmitButton의 pending 로직 뿐
  • 폼 필드 디자인을 바꿔도 서버 컴포넌트만 수정하면 되고 클라이언트 번들은 그대로

 

다만 이 패턴은 간단한 uncontrolled/controlled 필드에 한해서 잘 맞는다. React Hook Form처럼 register(), useFormContext() 같은 훅 기반 라이브러리를 쓰면, 훅 자체가 클라이언트 전용이라 이 정도로 잘게 쪼개기 어렵다. 폼 전체를 하나의 클라이언트 아일랜드로 인정하고 그 폼을 감싸는 페이지 레이아웃(헤더, 사이드바, 정적 안내 텍스트)만 서버로 유지하는 절충안이 현실적이다.

 

 


10. 공부를 마치며

 

App Router는 처음 실무에 들여올 때부터 써왔지만, 사실 그동안은 감으로 썼다. 상태나 이벤트가 필요해 보이면 일단 'use client'부터 붙이고 넘어가는 식이었다. 동작은 했으니까 문제라고 느낀 적도 별로 없었다.

 

이번에 원리를 제대로 파고들면서 알게 된 건, App Router는 라우팅 도구가 아니라 React 컴포넌트가 어디서 실행되는가라는 질문을 아예 새로 던지는 것이었다는 점이다. 그 질문 하나가 데이터 페칭, 번들 크기, 폼 처리, 컴포넌트를 쪼개는 방식까지 전부 다시 설계하게 만든다.

 

정리하면서 제일 뼈아팠던 순간은, 예전에 짜둔 컴포넌트들을 다시 열어봤을 때였다. 최상위 레이아웃에 'use client'를 걸어서 그 밑에 있던 정적인 텍스트, 레이아웃까지 전부 클라이언트 번들로 끌고 들어간 코드가 한둘이 아니었다. 그땐 "일단 상태 하나 쓰니까 클라이언트겠지" 정도로만 생각했지, 그 판단 하나가 하위 트리 전체를 딸려 보낸다는 감각은 없었다.

 

'use client'가 런타임 지시어가 아니라 컴파일 경계라는 걸 이해하고 나서야, 왜 그게 문제였는지가 한 번에 이해됐다. 번들러에게 "이 파일과 여기서 import하는 모든 걸 클라이언트로 보내라"는 표시일 뿐이라는 걸 알고 나니, 전이 규칙이 왜 자식 방향으로만 가는지, children prop으로 감싸는 게 왜 필요한지도 자연스럽게 따라왔다.

 

원칙은 결국 하나로 정리된다. 기본값은 서버, 필요할 때만 예외적으로 클라이언트. 이 문장 하나가 이번에 다룬 판별 기준, 안티패턴, 실전 패턴 전부의 근거였다.