목차
34 장

종합 실습 — 풀스택 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 516장
컴포넌트 모델RSC + ‘use client’24장
데이터 페칭RSC 안 직접 fetch25장
변경Server Actions27장
낙관적 업데이트useOptimistic28장
DBSQLite + Drizzle ORM21장 + 25장
검증zod21장
인증Auth.js v532장
단위 테스트Vitest + Testing Library29장
E2EPlaywright30장
성능next/font + Lighthouse CI + Web Vitals31장
호스팅Vercel33장
관측성Sentry + PostHog33장

이 책의 4~5부에서 결정한 기준을 그대로 가져왔습니다. 캡스톤에서 새 도구를 도입하지 않습니다.

13단계 흐름 #

전체 작업은 다음 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-todo

2장 (Vite 셋업)의 정신은 같습니다. 도구가 강요하지 않는 한 최소한의 기본값으로 시작합니다. 16장 (TypeScript 셋업)에서 다룬 strict: true, noUncheckedIndexedAccess: truetsconfig.json에서 확인합니다.

폴더 구조는 다음 정도로 시작하겠습니다.

src/
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장에서 다룬 타이핑 습관과 자연스럽게 이어집니다.

src/db/schema.ts
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의 입력 검증에서 만나게 됩니다.

src/lib/schemas.ts
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 두 갈래를 동시에 받겠습니다.

src/auth.ts
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/
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으로 보냅니다.

src/app/(app)/layout.tsx
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 라우트도, 클라이언트 상태도 거치지 않습니다.

src/app/(app)/page.tsx
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로 내립니다.

목록 컴포넌트는 표시만 합니다.

src/components/todo-list.tsx
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와 폼)의 정확한 패턴입니다.

src/lib/actions.ts
'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}> 한 줄로 연결됩니다.

src/app/(app)/todos/new/page.tsx
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입니다.

src/components/todo-item.tsx
'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에 담습니다. 새로고침 / 공유 / 뒤로가기에 친화적입니다.

src/app/(app)/page.tsx — 확장
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에 반영하는 작은 책임만 갖습니다.

src/components/todo-search.tsx
'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 친화적이고, 첫 페인트부터 올바른 테마로 렌더되니 깜빡임이 없습니다.

src/lib/theme.ts
'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',
  });
}
src/app/layout.tsx
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)에서 다룬 패턴입니다.

src/components/todo-item.test.tsx
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개의 목록은 다음 정도가 자연스럽습니다.

  1. TodoItem — optimistic toggle
  2. TodoList — 빈 목록 안내문
  3. TodoSearch — 입력 시 URL이 갱신되는지
  4. lib/schemas — zod 입력 검증의 에지 케이스 두세 개

11. E2E 테스트 #

Playwright로 “회원가입 → 로그인 → Todo 추가 → 완료"를 한 시나리오로 묶습니다. 30장 (Playwright)에서 다룬 패턴입니다.

e2e/flows/happy-path.spec.ts
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)의 도구로 일찍 한 번, 출시 직전 한 번 더 점검합니다.

Lighthouse CI
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. Deploy

SQLite는 로컬 학습용으로 좋지만 production에서는 단일 파일이 한계가 됩니다. 본 캡스톤은 Turso (libSQL)로 끊는 게 가장 매끈합니다. SQLite 호환이라 코드를 거의 바꾸지 않고 옮길 수 있습니다.

Sentry와 PostHog는 wizard 한 줄씩으로 셋업되고, 첫 24시간 동안 새 에러가 흐르는지, autocapture funnel이 그려지는지를 가장 먼저 확인합니다.

책 전체를 한 표로 #

본 캡스톤이 어디서 어떤 챕터를 적용했는지 한 페이지에 정리해 두겠습니다.

단계본 챕터에서의 적용원 챕터
프로젝트 셋업Next.js + TS strict2장 / 16장
DB 스키마Drizzle $inferSelect로 도메인 타입17장
zod 검증폼 입력 → server 검증21장
인증Auth.js v5 GitHub + credentials32장
레이아웃App Router 라우트 그룹23장
Todo 목록RSC 안 DB 직접 쿼리24장 / 25장
Todo 추가Server Action + revalidatePath27장
토글useOptimistic + useTransition28장
검색URL searchParams = 진실 출처10장 / 13장
다크 모드Cookie 기반 SSR 친화12장
단위 테스트Vitest + Testing Library29장
E2EPlaywright preview deploy30장
성능Lighthouse + Web Vitals31장
배포Vercel + Turso33장
관측Sentry + PostHog33장

본 챕터 안에서 새로 도입한 패턴이 없습니다. 모든 도구가 이 책 안에서 이미 한 번씩 다뤘던 도구입니다. 캡스톤의 가치가 거기에 있습니다. 이 책을 따라온 사람은 본 챕터에서 새로 배울 게 없어야 정상입니다.

react-todo-app 5편과의 비교 #

항목react-todo-app 5편본 챕터
사용자단일인증된 사용자별 분리
저장localStorageDB (SQLite / Turso)
상태useStateRSC + Server Action
라우팅없음 (단일 페이지)App Router
타입JS / 옅은 TSDB 스키마 → 도메인 타입
변경클라이언트만Server Action
다크 모드localStorage (깜빡임)Cookie (SSR 친화)
테스트없음Vitest + Playwright
배포정적 호스팅Vercel + 관측성

5편을 끝낸 사람이 본 챕터로 옮겨오면 같은 도메인이 풀스택으로 어떻게 변형되는지 한 번에 보입니다. 5편은 사이트에 무료로 그대로 남아 있으니, 부담 없이 비교하며 읽을 수 있습니다.

직접 해보기 — 한 사이클 끝까지 #

이 책의 마지막 실습입니다. 본 챕터의 13단계를 본인 손으로 한 번 끝까지 돌려 보세요. 한 번에 완성하지 않아도 됩니다. 단계마다 PR 한 개씩, 1~2주에 걸쳐 천천히 쌓아도 좋습니다.

  1. 1~3단계 — 프로젝트 / DB / 인증 셋업. 로컬에서 회원가입 + 로그인이 도는 것을 본다.
  2. 4~7단계 — 레이아웃과 Todo CRUD + optimistic 토글. 사용자 한 명으로 손으로 써 보며 UX를 다듬는다.
  3. 8~9단계 — 검색 / 태그 / 다크 모드.
  4. 10~11단계 — Vitest 4개 + Playwright 한 시나리오. CI를 GitHub Actions로 붙인다.
  5. 12~13단계 — Lighthouse 첫 측정 → Vercel 배포 → Sentry / PostHog 셋업.

여기까지 끝내고 나면, 이 책의 모든 도구가 본인 손으로 직접 한 번씩 동작한 상태가 됩니다. 이게 이 책이 약속한 목표 지점입니다.

연습문제 #

  1. server vs client 분담. 본 캡스톤의 다음 컴포넌트들이 server인지 client인지 답하고 이유를 적어 보세요. (a) TodoList, (b) TodoItem, (c) TodoSearch, (d) RootLayout, (e) 새 Todo 추가 폼 페이지. 클라이언트로 떨어져야 하는 최소 단위를 정확히 식별하는 것이 핵심입니다.
  2. URL을 진실의 출처로. 본 챕터에서 검색어와 태그는 클라이언트 상태 대신 URL searchParams에 두었습니다. 다음 항목들은 어떻게 다룰지 답하세요. (a) “완료된 항목만 보기” 토글, (b) 페이지네이션 (현재 페이지 번호), (c) 정렬 (생성일 / 마감일), (d) 다크 모드. 어느 것이 URL 친화이고 어느 것이 Cookie 또는 server state가 자연스러운지 구분합니다.
  3. 테스트 우선순위. 본 챕터에서 처음 잡을 테스트 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가 가장 먼저 읽을 챕터입니다.

X