경계
use client가 필요한 최소 범위인가.
경계를 넘는 props가 직렬화 가능한가.
비밀 키가 클라이언트로 새지 않는가.
첫날에 정할 것과 나중에 정해도 되는 것
# 1. 프로젝트pnpm create next-app@latest my-app # TypeScript, App Router, @/* alias
# 2. shadcn/ui ← cssVariables는 반드시 truepnpm dlx shadcn@latest init
# 3. 포매팅 — 미루면 나중에 diff가 지옥이 된다pnpm add -D prettier prettier-plugin-tailwindcss
# 4. 접근성 린트pnpm add -D eslint-plugin-jsx-a11y
# 5. 폼·검증pnpm add react-hook-form @hookform/resolvers zodui/ · shared/ · _components/)isOpen vs open)ui/ 수정 정책 — 누가, 어떤 조건에서, 리뷰는 어떻게/dev/components 페이지 하나라도다섯 개 모두 나중에 하면 몇 배로 비싸진다. 특히 다크 모드는 나중에 붙이면 토큰 구조를 다시 짜야 한다.
// app/dev/components/page.tsx — 개발 환경에서만import { notFound } from 'next/navigation'
export default function ComponentCatalog() { if (process.env.NODE_ENV === 'production') notFound() return ( <div className="mx-auto max-w-5xl space-y-12 p-8"> <Section title="Button"> <Button>기본</Button> <Button variant="outline">아웃라인</Button> <Button variant="destructive">삭제</Button> </Section> <Section title="EmptyState"> <EmptyState title="항목이 없습니다" action={<Button>추가</Button>} /> </Section> </div> )}Storybook보다 훨씬 싸고, 16장에서 말한 **“있는 줄 몰라서 다시 만드는” 문제의 90%**를 해결한다. 필요해지면 그때 Storybook으로 옮긴다.
거의 모든 앱에 있는 화면이다. 이 조합이 표준이다.
// app/(app)/posts/page.tsxexport default async function PostsPage({ searchParams }: PageProps<'/posts'>) { const { q, sort, page } = await searchParams return ( <> <PageHeader title="글" action={<Button asChild><Link href="/posts/new">새 글</Link></Button>} /> <PostFilters /> {/* 클라이언트 — URL만 조작 */} <Suspense key={`${q}-${sort}-${page}`} fallback={<TableSkeleton />}> <PostTable q={q} sort={sort} page={page} /> {/* 서버 */} </Suspense> </> )}Suspense의 key가 중요하다 — 검색어가 바뀌면 스켈레톤이 다시 나온다export function ConfirmDialog({ trigger, title, description, confirmText = '확인', onConfirm,}: Props) { return ( <AlertDialog> <AlertDialogTrigger asChild>{trigger}</AlertDialogTrigger> <AlertDialogContent> <AlertDialogHeader> <AlertDialogTitle>{title}</AlertDialogTitle> <AlertDialogDescription>{description}</AlertDialogDescription> </AlertDialogHeader> <AlertDialogFooter> <AlertDialogCancel>취소</AlertDialogCancel> <AlertDialogAction onClick={onConfirm}>{confirmText}</AlertDialogAction> </AlertDialogFooter> </AlertDialogContent> </AlertDialog> )}'use client'export function PostRow({ post, deleteAction }) { const [isDeleting, setIsDeleting] = useState(false)
return ( <tr className={cn('transition-opacity', isDeleting && 'opacity-40')}> <td>{post.title}</td> <td> <ConfirmDialog trigger={<Button variant="ghost" size="icon" aria-label="삭제">🗑</Button>} title="이 글을 삭제할까요?" description="되돌릴 수 없습니다." onConfirm={async () => { setIsDeleting(true) const res = await deleteAction(post.id) if (res?.error) { setIsDeleting(false); toast.error(res.error) } }} /> </td> </tr> )}실패하면 되돌리고 토스트로 알린다.
성공하면 revalidateTag로 목록이 갱신되며 행이 사라진다. (7장)
| 단계 | 언제 | 어떻게 |
|---|---|---|
| ① 첫 진입 | 데이터가 아예 없음 | 스켈레톤 (실제 레이아웃 크기로) |
| ② 갱신 중 | 필터를 바꿔 다시 조회 | 기존 내용을 흐리게 유지 |
| ③ 액션 중 | 저장·삭제 버튼을 누름 | 버튼만 disabled + 라벨 변경 |
use client — 앱 전체가 클라이언트가 된다fetch('/api/...') — 자기 자신을 HTTP로 부르는 낭비proxy.ts만 믿는 인증 — Server Function은 그 경로를 안 탈 수 있다await 줄줄이 — Promise.all 또는 컴포넌트별 조회Suspense에 key 누락 — 필터 바꿔도 스켈레톤이 안 나온다`text-${color}-500` 은 절대 동작하지 않는다@apply로 컴포넌트 클래스 만들기 — BEM으로 되돌아간다cn() 없이 className 이어붙이기 — 오버라이드가 안 먹힌다bg-zinc-900. 테마 교체가 불가능해진다ui/에 도메인 로직 삽입 — 재사용성이 죽는다--diff 없이 방치 — 업스트림 보안·접근성 수정을 영영 못 받는다cssVariables: false — 되돌리기 매우 어렵다경계
use client가 필요한 최소 범위인가.
경계를 넘는 props가 직렬화 가능한가.
비밀 키가 클라이언트로 새지 않는가.
데이터
병렬로 가져올 수 있는데 순차인가. 변경 후 재검증을 했는가. Server Function에 인증·검증·인가가 있는가.
스타일
원시 색을 직접 쓴 곳이 없는가.
cn()으로 className을 병합했는가.
ui/ 변경이 있다면 표시와 사유가 있는가.
접근성
아이콘 버튼에 이름이 있는가. 색만으로 정보를 전달하지 않는가. 키보드로 조작 가능한가.
작은 화면 하나로 시작한다. 전면 도입 금지
토큰을 먼저 정한다 — 브랜드 색, radius, 다크 모드
shared/ 층을 만들며 첫 5개 컴포넌트를 쌓는다
/dev/components 카탈로그를 만든다
ui/ 수정 정책을 문서화한다
두 번째 프로젝트가 생기면 레지스트리를 검토한다
| 질문 | 답 |
|---|---|
| MUI를 쓰던 프로젝트인데 | 공존 가능하다. 새 화면부터 shadcn으로 |
| Pages Router에서 App Router로 | 라우트 단위로 점진 이전이 지원된다 |
| CSS Modules와 Tailwind 공존 | 가능하다. 새 컴포넌트만 Tailwind |
| styled-components를 쓰고 있다 | RSC와 충돌한다. 이 전환은 미루기 어렵다 (9장) |
| Tailwind v3에서 v4로 | 공식 업그레이드 도구가 있다. @config로 단계적 이전 |
공존이 대부분 가능한 이유는 Tailwind가 클래스 기반이고 shadcn이 내 레포의 파일이기 때문이다. 충돌할 전역 런타임이 없다.
style · baseColor · cssVariables (나중에 못 바꾼다)ui/ 정책 · 카탈로그use client