종합 실습 — 풀스택 Todo 앱 완성하기
1~33장의 패턴을 하나의 동작하는 풀스택 서비스로 엮습니다. RSC · Server Actions · DB · 인증 · 테스트까지.
33장으로 5부 (운영 · 테스트 · 배포)가 마무리됐습니다. 본 챕터는 6부 (종합 실습)의 단독 챕터로, 1~33장에서 익힌 모든 도구를 한 풀스택 Todo 앱 안에 하나의 흐름으로 엮어 보겠습니다.
캡스톤의 의미는 단순합니다. 도구를 따로따로 익혀 두면 “각각은 알겠는데 한 서비스 안에서 어떻게 맞물리는지가 안 보이는” 단계에서 멈춥니다. 본 챕터는 그 공백을 메우는 챕터입니다. 코드를 처음부터 다 적어 두지는 않고, 각 단계가 책의 어느 챕터를 어떻게 적용하는지를 따라가는 가이드 투어 형식으로 갑니다. 실제 동작하는 저장소 한 벌을 옆에 두고 같이 읽으면 가장 효과가 큽니다.
기존 react-todo-app 5편의 단순 클라이언트 Todo와 본 챕터의 차이는 분명합니다. 5편은 useState와 localStorage로 단일 사용자의 할 일 목록을 다루는 입문 시리즈입니다. 본 챕터는 같은 도메인 (할 일 관리)을 가져와 DB 영속, 인증된 사용자별 분리, RSC + Server Actions, 테스트, 배포, 관측까지 풀스택 한 사이클로 옮깁니다.
무엇을 만들 것인가 #
풀스택 Todo 앱 — 인증된 사용자별 할 일 관리 서비스. 기능은 다음과 같이 잡겠습니다.
- 회원가입 / 로그인 (Auth.js v5 — 32장)
- Todo CRUD (제목 / 설명 / 마감일 / 완료 여부)
- 태그 필터 + 검색 (URL searchParams 동기화)
- 완료 토글 시 낙관적 업데이트 (
useOptimistic— 28장) - 마감 임박 알림 배지
- 다크 모드 토글 (테마 Cookie로 SSR 친화)
이 한 앱 안에 이 책이 다룬 거의 모든 도구가 들어옵니다. 의도적으로 작게 잡았습니다. 도메인 자체는 누구나 한눈에 이해되니, 시선이 도구의 맞물림에 집중됩니다.
기술 스택 — 이 책의 기준 그대로 #
| 영역 | 선택 | 책 챕터 |
|---|---|---|
| 프레임워크 | Next.js 15 (App Router) | 23장 |
| 언어 | TypeScript 5 | 16장 |
| 컴포넌트 모델 | RSC + ‘use client’ | 24장 |
| 데이터 페칭 | RSC 안 직접 fetch | 25장 |
| 변경 | Server Actions | 27장 |
| 낙관적 업데이트 | useOptimistic | 28장 |
| DB | SQLite + Drizzle ORM | 21장 + 25장 |
| 검증 | zod | 21장 |
| 인증 | Auth.js v5 | 32장 |
| 단위 테스트 | Vitest + Testing Library | 29장 |
| E2E | Playwright | 30장 |
| 성능 | next/font + Lighthouse CI + Web Vitals | 31장 |
| 호스팅 | Vercel | 33장 |
| 관측성 | Sentry + PostHog | 33장 |
이 책의 4~5부에서 결정한 기준을 그대로 가져왔습니다. 캡스톤에서 새 도구를 도입하지 않습니다.
13단계 흐름 #
전체 작업은 다음 13단계로 진행합니다. 각 단계는 책의 어느 챕터의 패턴을 적용하는지 함께 적어 두었습니다.
1. 프로젝트 셋업 (2장 + 16장)
2. DB 모델과 스키마 (21장 + 25장)
3. 인증 (32장)
4. 레이아웃과 라우팅 (23장)
5. Todo 목록 — RSC (24장 + 25장)
6. Todo 추가 — Server Action (27장)
7. 완료 토글 — Optimistic UI (28장)
8. 검색과 태그 필터 (10장 + 21장)
9. 다크 모드 (12장)
10. 컴포넌트 테스트 (29장)
11. E2E 테스트 (30장)
12. 성능 점검 (31장)
13. 배포와 관측 (33장)각 단계가 의도적으로 작게 잡혀 있습니다. 한 단계가 한 PR 정도의 분량입니다.
1. 프로젝트 셋업 #
Next.js 15 + TypeScript로 새 프로젝트를 시작하겠습니다.
pnpm create next-app@latest fullstack-todo --typescript --eslint --app --tailwind
cd fullstack-todo2장 (Vite 셋업)의 정신은 같습니다. 도구가 강요하지 않는 한 최소한의 기본값으로 시작합니다. 16장 (TypeScript 셋업)에서 다룬 strict: true, noUncheckedIndexedAccess: true을 tsconfig.json에서 확인합니다.
폴더 구조는 다음 정도로 시작하겠습니다.
src/
├── app/ # App Router 루트
│ ├── (auth)/ # 인증 라우트 그룹
│ ├── (app)/ # 로그인 후 라우트 그룹
│ └── layout.tsx
├── components/ # 재사용 컴포넌트
├── db/ # Drizzle schema와 client
├── lib/ # Server Action, zod 스키마, 유틸
└── auth.ts # Auth.js 설정라우트 그룹 (auth)와 (app)으로 인증 전후를 분리합니다. 13장 (라우팅)에서 다룬 패턴입니다.
2. DB 모델과 스키마 #
SQLite + Drizzle ORM으로 시작합니다. SQLite는 단일 파일이라 학습용으로 마찰이 적고, Drizzle는 TypeScript first 라 16~21장에서 다룬 타이핑 습관과 자연스럽게 이어집니다.
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
import { relations } from 'drizzle-orm';
export const users = sqliteTable('users', {
id: text('id').primaryKey(),
email: text('email').notNull().unique(),
hashedPassword: text('hashed_password'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
});
export const todos = sqliteTable('todos', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id),
title: text('title').notNull(),
description: text('description'),
dueAt: integer('due_at', { mode: 'timestamp' }),
completed: integer('completed', { mode: 'boolean' }).notNull().default(false),
tags: text('tags', { mode: 'json' }).$type<string[]>().default([]),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
});
export const todosRelations = relations(todos, ({ one }) => ({
user: one(users, { fields: [todos.userId], references: [users.id] }),
}));
export type Todo = typeof todos.$inferSelect;
export type NewTodo = typeof todos.$inferInsert;$inferSelect / $inferInsert가 핵심입니다. DB 스키마에서 타입이 그대로 빠져나오니, 도메인 타입을 따로 손으로 적지 않습니다. 17장 (props와 children 타이핑)에서 다룬 “타입은 한 곳에서만 정의” 원칙이 DB까지 이어집니다.
zod 스키마는 Server Action의 입력 검증에서 만나게 됩니다.
import { z } from 'zod';
export const todoCreateSchema = z.object({
title: z.string().min(1, '제목은 비울 수 없습니다').max(200),
description: z.string().max(2000).optional(),
dueAt: z.coerce.date().optional(),
tags: z.array(z.string()).default([]),
});
export type TodoCreateInput = z.infer<typeof todoCreateSchema>;21장 (fetch와 API 타이핑)에서 다룬 패턴입니다. 폼 입력 → zod → 검증된 객체 → DB의 흐름을 한 줄로 표현합니다.
3. 인증 #
Auth.js v5로 GitHub OAuth + credentials 두 갈래를 동시에 받겠습니다.
import NextAuth from 'next-auth';
import GitHub from 'next-auth/providers/github';
import Credentials from 'next-auth/providers/credentials';
import { DrizzleAdapter } from '@auth/drizzle-adapter';
import { db } from '@/db/client';
import { verifyPassword } from '@/lib/password';
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: DrizzleAdapter(db),
providers: [
GitHub,
Credentials({
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
},
authorize: async (credentials) => {
const user = await db.query.users.findFirst({
where: (u, { eq }) => eq(u.email, credentials.email as string),
});
if (!user?.hashedPassword) return null;
const ok = await verifyPassword(
credentials.password as string,
user.hashedPassword,
);
return ok ? { id: user.id, email: user.email } : null;
},
}),
],
session: { strategy: 'jwt' },
});32장 (인증과 세션)에서 다룬 정확한 패턴입니다. 세션을 어디서든 한 줄로 꺼냅니다.
import { auth } from '@/auth';
const session = await auth();
if (!session?.user) {
redirect('/login');
}이 한 줄이 RSC, Route Handler, Server Action 어느 단계에서도 같은 모양으로 동작합니다.
4. 레이아웃과 라우팅 #
App Router의 라우트 그룹으로 인증 전후를 분리하겠습니다.
app/
├── (auth)/
│ ├── login/page.tsx
│ └── signup/page.tsx
├── (app)/
│ ├── layout.tsx # 사이드바 + 사용자 메뉴
│ ├── page.tsx # Todo 목록 (기본)
│ └── todos/
│ ├── new/page.tsx
│ └── [id]/page.tsx
└── layout.tsx # 루트 레이아웃 (테마 / 폰트)(app)/layout.tsx 안에서 세션을 확인하고, 미로그인 사용자를 /login으로 보냅니다.
import { auth } from '@/auth';
import { redirect } from 'next/navigation';
import { Sidebar } from '@/components/sidebar';
export default async function AppLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await auth();
if (!session?.user) redirect('/login');
return (
<div className="grid grid-cols-[240px_1fr] min-h-screen">
<Sidebar user={session.user} />
<main className="p-6">{children}</main>
</div>
);
}23장 (App Router)의 layout 중첩 모델이 그대로입니다. 이 layout은 server component로 도니, 세션 확인이 한 번에 끝나고 클라이언트로 sentinel 정보가 흘러 들어가지 않습니다.
5. Todo 목록 — RSC #
Todo 목록은 server component에서 DB를 직접 쿼리합니다. fetch도, API 라우트도, 클라이언트 상태도 거치지 않습니다.
import { auth } from '@/auth';
import { db } from '@/db/client';
import { todos } from '@/db/schema';
import { eq, desc } from 'drizzle-orm';
import { TodoList } from '@/components/todo-list';
export default async function TodosPage() {
const session = await auth();
const items = await db
.select()
.from(todos)
.where(eq(todos.userId, session!.user!.id))
.orderBy(desc(todos.createdAt));
return <TodoList items={items} />;
}24장 (Server vs Client Components)에서 다룬 그림이 그대로 실제 코드가 됐습니다. 페이지 컴포넌트는 server, 목록 UI의 인터랙티브 부분만 client로 내립니다.
목록 컴포넌트는 표시만 합니다.
import type { Todo } from '@/db/schema';
import { TodoItem } from './todo-item';
export function TodoList({ items }: { items: Todo[] }) {
if (items.length === 0) {
return <p className="text-muted">아직 할 일이 없습니다. 추가해 보세요.</p>;
}
return (
<ul className="space-y-2">
{items.map((t) => (
<TodoItem key={t.id} todo={t} />
))}
</ul>
);
}이 컴포넌트는 ‘use client’를 붙이지 않았습니다. RSC의 일반적 합리적 기본값입니다. 인터랙티브한 부분은 TodoItem 안에서 토글 버튼만 client로 떨어집니다.
6. Todo 추가 — Server Action #
추가 폼은 Server Action 한 함수로 끝납니다. 27장 (Server Actions와 폼)의 정확한 패턴입니다.
'use server';
import { auth } from '@/auth';
import { db } from '@/db/client';
import { todos } from '@/db/schema';
import { todoCreateSchema } from './schemas';
import { revalidatePath } from 'next/cache';
import { nanoid } from 'nanoid';
export async function createTodo(formData: FormData) {
const session = await auth();
if (!session?.user) throw new Error('Unauthorized');
const parsed = todoCreateSchema.safeParse({
title: formData.get('title'),
description: formData.get('description'),
dueAt: formData.get('dueAt') || undefined,
tags: formData.getAll('tags'),
});
if (!parsed.success) {
return { error: parsed.error.flatten().fieldErrors };
}
await db.insert(todos).values({
id: nanoid(),
userId: session.user.id,
...parsed.data,
createdAt: new Date(),
});
revalidatePath('/');
}폼 컴포넌트는 <form action={createTodo}> 한 줄로 연결됩니다.
import { createTodo } from '@/lib/actions';
export default function NewTodoPage() {
return (
<form action={createTodo} className="space-y-3">
<input name="title" required className="input" placeholder="제목" />
<textarea name="description" className="input" placeholder="설명 (선택)" />
<input name="dueAt" type="date" className="input" />
<button type="submit" className="btn">추가</button>
</form>
);
}19장 (이벤트와 폼 타이핑)에서 본 FormData 좁히기 패턴이 zod와 결합되면서 타입 안전한 입력 검증이 됐습니다. API 라우트, fetch 호출, 클라이언트 상태가 모두 빠졌습니다.
7. 완료 토글 — Optimistic UI #
완료 체크박스는 응답을 기다리지 않고 즉시 UI가 바뀌도록 useOptimistic을 씁니다. 28장 (React 19 신기능)에서 다룬 hook입니다.
'use client';
import { useOptimistic, useTransition } from 'react';
import type { Todo } from '@/db/schema';
import { toggleTodo } from '@/lib/actions';
export function TodoItem({ todo }: { todo: Todo }) {
const [optimistic, setOptimistic] = useOptimistic(
todo,
(state, next: boolean) => ({ ...state, completed: next }),
);
const [pending, startTransition] = useTransition();
return (
<li className="flex items-center gap-2">
<input
type="checkbox"
checked={optimistic.completed}
disabled={pending}
onChange={(e) => {
const next = e.target.checked;
startTransition(async () => {
setOptimistic(next);
await toggleTodo(todo.id, next);
});
}}
/>
<span className={optimistic.completed ? 'line-through text-muted' : ''}>
{optimistic.title}
</span>
</li>
);
}useOptimistic가 server의 응답을 기다리지 않고 UI를 먼저 바꿉니다. Server Action이 실패해 revalidate가 일어나면 자동으로 원래 상태로 되돌아옵니다. 클라이언트 상태를 따로 들고 있지 않아도 됩니다.
8. 검색과 태그 필터 #
검색어와 태그 필터는 클라이언트 상태가 아니라 URL searchParams에 담습니다. 새로고침 / 공유 / 뒤로가기에 친화적입니다.
export default async function TodosPage({
searchParams,
}: {
searchParams: Promise<{ q?: string; tag?: string }>;
}) {
const { q, tag } = await searchParams;
const session = await auth();
const items = await db.query.todos.findMany({
where: (t, { and, eq, like, sql }) => and(
eq(t.userId, session!.user!.id),
q ? like(t.title, `%${q}%`) : undefined,
tag ? sql`json_extract(${t.tags}, '$') LIKE ${'%' + tag + '%'}` : undefined,
),
orderBy: (t, { desc }) => desc(t.createdAt),
});
return <TodoList items={items} />;
}검색 입력은 client 컴포넌트지만, 입력 값을 URL에 반영하는 작은 책임만 갖습니다.
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import { useTransition } from 'react';
export function TodoSearch() {
const router = useRouter();
const params = useSearchParams();
const [, startTransition] = useTransition();
return (
<input
defaultValue={params.get('q') ?? ''}
placeholder="검색"
onChange={(e) => {
const next = new URLSearchParams(params);
if (e.target.value) next.set('q', e.target.value);
else next.delete('q');
startTransition(() => router.replace(`?${next}`));
}}
/>
);
}10장 (조건부 렌더링)의 정신과 같습니다. 한 가지 진실의 출처를 정하고, UI는 그 출처를 표시할 뿐입니다. 여기서는 URL이 진실의 출처입니다.
9. 다크 모드 #
테마는 Cookie에 저장합니다. SSR 친화적이고, 첫 페인트부터 올바른 테마로 렌더되니 깜빡임이 없습니다.
'use server';
import { cookies } from 'next/headers';
export async function setTheme(theme: 'light' | 'dark') {
(await cookies()).set('theme', theme, {
maxAge: 60 * 60 * 24 * 365,
sameSite: 'lax',
});
}import { cookies } from 'next/headers';
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const theme = (await cookies()).get('theme')?.value ?? 'light';
return (
<html lang="ko" data-theme={theme} className={theme}>
<body>{children}</body>
</html>
);
}12장 (조건부 렌더링)에서 다룬 “상태가 한 곳에서만 산다"의 변주입니다. localStorage가 아니라 Cookie 이기 때문에 server도 그 상태를 처음부터 알고 첫 응답을 만듭니다.
10. 컴포넌트 테스트 #
Vitest + Testing Library로 핵심 컴포넌트 3~4개를 시작점으로 잡습니다. 29장 (Vitest)에서 다룬 패턴입니다.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { TodoItem } from './todo-item';
vi.mock('@/lib/actions', () => ({
toggleTodo: vi.fn().mockResolvedValue(undefined),
}));
describe('TodoItem', () => {
it('체크박스 클릭 시 즉시 UI가 변경된다 (optimistic)', async () => {
const user = userEvent.setup();
render(
<TodoItem todo={{
id: '1', userId: 'u', title: '책 사기', description: null,
dueAt: null, completed: false, tags: [], createdAt: new Date(),
}} />,
);
const checkbox = screen.getByRole('checkbox');
expect(checkbox).not.toBeChecked();
await user.click(checkbox);
expect(checkbox).toBeChecked();
expect(screen.getByText('책 사기')).toHaveClass('line-through');
});
});29장에서 다룬 “사용자가 보는 것을 테스트한다"의 표어가 그대로입니다. 내부 상태가 아니라 사용자가 클릭하고 화면이 어떻게 바뀌는지를 확인합니다.
처음 잡을 테스트 4개의 목록은 다음 정도가 자연스럽습니다.
TodoItem— optimistic toggleTodoList— 빈 목록 안내문TodoSearch— 입력 시 URL이 갱신되는지lib/schemas— zod 입력 검증의 에지 케이스 두세 개
11. E2E 테스트 #
Playwright로 “회원가입 → 로그인 → Todo 추가 → 완료"를 한 시나리오로 묶습니다. 30장 (Playwright)에서 다룬 패턴입니다.
import { test, expect } from '@playwright/test';
test('회원가입부터 Todo 완료까지', async ({ page }) => {
const email = `user-${Date.now()}@example.com`;
await page.goto('/signup');
await page.getByLabel('Email').fill(email);
await page.getByLabel('Password').fill('correct-horse-battery-staple');
await page.getByRole('button', { name: '가입' }).click();
await page.waitForURL('/');
await page.getByRole('link', { name: '새 Todo' }).click();
await page.getByLabel('제목').fill('첫 할 일');
await page.getByRole('button', { name: '추가' }).click();
await expect(page.getByText('첫 할 일')).toBeVisible();
await page.getByRole('checkbox').click();
await expect(page.getByText('첫 할 일')).toHaveClass(/line-through/);
});이 한 테스트가 본 챕터의 1~9단계를 한 번에 검증합니다. CI의 PR preview deploy 위에서 매번 돌리면, production 빌드 전용 버그까지 잡힙니다 (33장에서 다룬 패턴).
12. 성능 점검 #
31장 (Web Vitals)의 도구로 일찍 한 번, 출시 직전 한 번 더 점검합니다.
pnpm dlx @lhci/cli@latest autorun --upload.target=temporary-public-storage본 앱의 첫 페인트는 RSC가 DB까지 한 번에 끊어 응답해 주는 모양이라 LCP가 자연스럽게 좋게 나옵니다. INP는 useOptimistic 덕분에 토글 즉시 반응합니다. 31장의 lab 데이터를 처음 출시 시점에 한 번 캡처해 두면, 이후 회귀를 비교할 수 있습니다.
production의 실 사용자 분포는 33장에서 다룬 PostHog로 받습니다. lab과 실 사용자 환경의 차이는 작지 않습니다.
13. 배포와 관측 #
Vercel에 한 번에 올립니다. 33장의 절차를 그대로 따르겠습니다.
1. GitHub repo push
2. Vercel: New Project → repo 선택
3. 환경변수 입력:
- DATABASE_URL (Turso / Cloudflare D1 / Vercel Postgres 중 택)
- AUTH_SECRET
- AUTH_GITHUB_ID / AUTH_GITHUB_SECRET
- NEXT_PUBLIC_POSTHOG_KEY / NEXT_PUBLIC_POSTHOG_HOST
- NEXT_PUBLIC_SENTRY_DSN
4. DeploySQLite는 로컬 학습용으로 좋지만 production에서는 단일 파일이 한계가 됩니다. 본 캡스톤은 Turso (libSQL)로 끊는 게 가장 매끈합니다. SQLite 호환이라 코드를 거의 바꾸지 않고 옮길 수 있습니다.
Sentry와 PostHog는 wizard 한 줄씩으로 셋업되고, 첫 24시간 동안 새 에러가 흐르는지, autocapture funnel이 그려지는지를 가장 먼저 확인합니다.
책 전체를 한 표로 #
본 캡스톤이 어디서 어떤 챕터를 적용했는지 한 페이지에 정리해 두겠습니다.
| 단계 | 본 챕터에서의 적용 | 원 챕터 |
|---|---|---|
| 프로젝트 셋업 | Next.js + TS strict | 2장 / 16장 |
| DB 스키마 | Drizzle $inferSelect로 도메인 타입 | 17장 |
| zod 검증 | 폼 입력 → server 검증 | 21장 |
| 인증 | Auth.js v5 GitHub + credentials | 32장 |
| 레이아웃 | App Router 라우트 그룹 | 23장 |
| Todo 목록 | RSC 안 DB 직접 쿼리 | 24장 / 25장 |
| Todo 추가 | Server Action + revalidatePath | 27장 |
| 토글 | useOptimistic + useTransition | 28장 |
| 검색 | URL searchParams = 진실 출처 | 10장 / 13장 |
| 다크 모드 | Cookie 기반 SSR 친화 | 12장 |
| 단위 테스트 | Vitest + Testing Library | 29장 |
| E2E | Playwright preview deploy | 30장 |
| 성능 | Lighthouse + Web Vitals | 31장 |
| 배포 | Vercel + Turso | 33장 |
| 관측 | Sentry + PostHog | 33장 |
본 챕터 안에서 새로 도입한 패턴이 없습니다. 모든 도구가 이 책 안에서 이미 한 번씩 다뤘던 도구입니다. 캡스톤의 가치가 거기에 있습니다. 이 책을 따라온 사람은 본 챕터에서 새로 배울 게 없어야 정상입니다.
react-todo-app 5편과의 비교 #
| 항목 | react-todo-app 5편 | 본 챕터 |
|---|---|---|
| 사용자 | 단일 | 인증된 사용자별 분리 |
| 저장 | localStorage | DB (SQLite / Turso) |
| 상태 | useState | RSC + Server Action |
| 라우팅 | 없음 (단일 페이지) | App Router |
| 타입 | JS / 옅은 TS | DB 스키마 → 도메인 타입 |
| 변경 | 클라이언트만 | Server Action |
| 다크 모드 | localStorage (깜빡임) | Cookie (SSR 친화) |
| 테스트 | 없음 | Vitest + Playwright |
| 배포 | 정적 호스팅 | Vercel + 관측성 |
5편을 끝낸 사람이 본 챕터로 옮겨오면 같은 도메인이 풀스택으로 어떻게 변형되는지 한 번에 보입니다. 5편은 사이트에 무료로 그대로 남아 있으니, 부담 없이 비교하며 읽을 수 있습니다.
직접 해보기 — 한 사이클 끝까지 #
이 책의 마지막 실습입니다. 본 챕터의 13단계를 본인 손으로 한 번 끝까지 돌려 보세요. 한 번에 완성하지 않아도 됩니다. 단계마다 PR 한 개씩, 1~2주에 걸쳐 천천히 쌓아도 좋습니다.
- 1~3단계 — 프로젝트 / DB / 인증 셋업. 로컬에서 회원가입 + 로그인이 도는 것을 본다.
- 4~7단계 — 레이아웃과 Todo CRUD + optimistic 토글. 사용자 한 명으로 손으로 써 보며 UX를 다듬는다.
- 8~9단계 — 검색 / 태그 / 다크 모드.
- 10~11단계 — Vitest 4개 + Playwright 한 시나리오. CI를 GitHub Actions로 붙인다.
- 12~13단계 — Lighthouse 첫 측정 → Vercel 배포 → Sentry / PostHog 셋업.
여기까지 끝내고 나면, 이 책의 모든 도구가 본인 손으로 직접 한 번씩 동작한 상태가 됩니다. 이게 이 책이 약속한 목표 지점입니다.
연습문제 #
- server vs client 분담. 본 캡스톤의 다음 컴포넌트들이 server인지 client인지 답하고 이유를 적어 보세요. (a)
TodoList, (b)TodoItem, (c)TodoSearch, (d)RootLayout, (e) 새 Todo 추가 폼 페이지. 클라이언트로 떨어져야 하는 최소 단위를 정확히 식별하는 것이 핵심입니다. - URL을 진실의 출처로. 본 챕터에서 검색어와 태그는 클라이언트 상태 대신 URL searchParams에 두었습니다. 다음 항목들은 어떻게 다룰지 답하세요. (a) “완료된 항목만 보기” 토글, (b) 페이지네이션 (현재 페이지 번호), (c) 정렬 (생성일 / 마감일), (d) 다크 모드. 어느 것이 URL 친화이고 어느 것이 Cookie 또는 server state가 자연스러운지 구분합니다.
- 테스트 우선순위. 본 챕터에서 처음 잡을 테스트 4개를 제시했습니다. 만약 시간이 절반밖에 없다면 어느 두 개를 먼저 작성하겠습니까. 그 두 개를 고른 이유를 한 줄 적어 보세요. (힌트: 회귀 시 사용자에게 가장 크게 보이는 항목부터.)
한 줄 요약: 캡스톤은 새 도구를 배우는 단계가 아니라 이 책의 모든 도구가 한 풀스택 Todo 앱 안에서 어떻게 맞물리는지 확인하는 단계이고, RSC + Server Action + zod + Drizzle + Auth.js + useOptimistic + Vitest + Playwright + Vercel + Sentry / PostHog가 13단계의 PR 분량 흐름으로 자연스럽게 이어진다. 새로 배울 게 없는 것이 본 챕터의 정상 상태이고, 그 지점에 도달한 사람이 이 책이 약속한 목표 지점에 선 사람이다.
다음 챕터 #
본 챕터로 1~34장의 본문이 모두 마무리됩니다. 다음 부록 A — 옛 리액트 코드 마이그레이션은 본문이 다루지 않은 한 영역, Class component / Pages Router / Redux-only / fetch-on-mount 시대의 코드를 이 책의 modern 스타일로 옮기는 절차를 한곳에 묶었습니다. 본문이 아니라 부록인 까닭은 분명합니다. 이 책은 처음부터 modern만 가르치자는 책이니, 옛 코드와의 매핑은 본문의 흐름을 흐리지 않게 부록으로 분리했습니다. 옛 코드베이스를 들고 이 책을 따라오는 독자에게는 부록 A가 가장 먼저 읽을 챕터입니다.