고양이 갤러리 (종합 프로젝트)

튜토리얼 — 고양이 갤러리 (종합 프로젝트)
마지막 종합 프로젝트입니다. Todo 말고 — 귀여운 고양이 갤러리를 만들겠습니다. App Router · Zustand · Context · 폼 · fetch · localStorage 영속성을 한 화면에서 다 만나는 작은 앱이에요.
11단계를 순서대로 따라가면, 마지막에는 다음이 동작합니다.
- 🐈 갤러리에서 고양이들을 둘러본다
- 💖 마음에 드는 아이를 즐겨찾기 한다
- ⭐ 디테일 페이지에서 별점과 메모를 단다
- 🖼 그리드 ↔ 리스트 보기를 전환한다
- 📚 즐겨찾기 페이지에서 내가 모은 아이들만 본다
- 🔄 새로고침해도 모두 그대로 남아 있다
요구사항 ↔ 배운 것
| # | 요구 | 사용 개념 | 어디서 배웠나 |
|---|---|---|---|
| 1·2 | 여러 라우트 (/cats, /cats/[id], /cats/favorites) | App Router, dynamic segment, route group · layout | Next.js |
| 1·2 | 서버 데이터 | Route Handler (app/api/cats/route.ts 등) | 7장 |
| 1·2 | 클라이언트에서 fetch | useEffect + ignore 패턴 | 7~8장 |
| 3 | 별점/메모 입력 | 제어 컴포넌트 + 폼 | 9장 |
| 3·6·7 | 즐겨찾기·별점·메모 영속 | Zustand + persist + selector | 14장 |
| 4 | 뷰 모드 (grid/list) — UI 취향 | Context | 12장 |
| 7 | 새로고침해도 유지 | localStorage (Zustand persist) + useHydrated | 8장·14장 |
왜 Context와 Zustand를 같이 쓰나 — 둘 다 "여러 컴포넌트가 같이 쓰는 상태"이지만 성격이 다릅니다.
- Zustand (영속 데이터) — 사용자가 모은 데이터. 새로고침해도 살아남아야 함. 즐겨찾기·별점·메모.
- Context (UI 취향) — 이번 세션의 보기 방식. 새로고침하면 초기로 돌아가도 됨. 뷰 모드.
"데이터는 Zustand, 한 화면의 UI 상태는 Context" — 현업의 자연스러운 구분입니다.
폴더 구조
types/cat.ts ← Cat 타입 app/api/cats/ ├─ _data.ts ← 고양이 데이터 (서버 측) ├─ route.ts ← GET /api/cats └─ [id]/route.ts ← GET /api/cats/[id] stores/useCollectionStore.ts ← Zustand: 즐겨찾기/별점/메모 hooks/useHydrated.ts ← Next.js SSR + persist 안전 훅 contexts/ViewModeContext.tsx ← Context: grid/list 뷰 모드 app/cats/ ├─ layout.tsx ← 헤더 + Provider ├─ page.tsx ← /cats (갤러리) ├─ [id]/page.tsx ← /cats/[id] (디테일) └─ favorites/page.tsx ← /cats/favorites components/cats/ ├─ Header.tsx ├─ CatGallery.tsx ├─ CatCard.tsx ├─ FavoriteButton.tsx ├─ ViewModeToggle.tsx ├─ CatDetail.tsx ├─ RatingNoteForm.tsx └─ FavoritesView.tsx e2e/cats.spec.ts
0단계 — 사전 준비
zustand가 깔려 있어야 합니다.
npm install zustand
1단계 — 데이터와 API
먼저 서버 역할입니다. 고양이 한 마리의 모양을 타입으로 정하고, 데이터와 두 개의 Route Handler를 만듭니다.
types/cat.ts
export type Cat = { id: string; name: string; breed: string; emoji: string; age: number; description: string; };
app/api/cats/_data.ts
이 파일은 서버 데이터 원본입니다. 파일명 앞의 _는 Next.js의 약속으로 — "이 폴더 안에서만 쓰는 모듈, 라우트 아님"이라는 표시입니다.
import type { Cat } from "@/types/cat"; export const CATS: Cat[] = [ { id: "1", name: "까망이", breed: "코리안 숏헤어", emoji: "🐈⬛", age: 3, description: "조용하고 점잖아요. 무릎냥이." }, { id: "2", name: "치즈", breed: "오렌지 태비", emoji: "🐈", age: 2, description: "장난기 많고 활발해요." }, { id: "3", name: "나비", breed: "삼색이", emoji: "🐱", age: 4, description: "도도하지만 다정해요." }, { id: "4", name: "호롱이", breed: "러시안 블루", emoji: "😺", age: 1, description: "호기심 가득한 아기 고양이." }, { id: "5", name: "코코", breed: "스코티시 폴드", emoji: "😸", age: 5, description: "둥글둥글한 외모가 매력." }, { id: "6", name: "미미", breed: "페르시안", emoji: "😻", age: 2, description: "긴 털을 자랑하는 우아한 공주님." }, ];
app/api/cats/route.ts — 목록
import { NextResponse } from "next/server"; import { CATS } from "./_data"; export function GET() { return NextResponse.json(CATS); }
app/api/cats/[id]/route.ts — 단일
import { NextResponse } from "next/server"; import { CATS } from "../_data"; export async function GET( _req: Request, { params }: { params: Promise<{ id: string }> }, ) { const { id } = await params; const cat = CATS.find((c) => c.id === id); if (!cat) { return NextResponse.json({ error: "없는 고양이" }, { status: 404 }); } return NextResponse.json(cat); }
확인 ✅ — 개발 서버를 켜고 curl http://localhost:3100/api/cats 또는 브라우저로 /api/cats 를 열면 6마리 데이터가 JSON으로 보입니다. /api/cats/2 는 치즈 한 마리만.
2단계 — Zustand 스토어 (즐겨찾기 데이터)
사용자가 모은 데이터는 새로고침해도 살아남아야 합니다. Zustand + persist 미들웨어로요. 별점과 메모도 함께 저장합니다.
hooks/useHydrated.ts
Next.js SSR + persist의 표준 짝입니다. 서버 렌더 시점엔 localStorage가 없어 빈 상태로 그려지고, 클라이언트 hydration 직후 진짜 값으로 바뀝니다 — 그 짧은 사이를 안전하게 처리해 깜빡임과 hydration 경고를 막습니다.
import { useEffect, useState } from "react"; export function useHydrated(): boolean { const [hydrated, setHydrated] = useState(false); useEffect(() => setHydrated(true), []); return hydrated; }
stores/useCollectionStore.ts
import { create } from "zustand"; import { persist, createJSONStorage } from "zustand/middleware"; export type Favorite = { id: string; rating: number; // 0~5 — 0이면 아직 별점 안 매김 note: string; }; type CollectionState = { favorites: Record<string, Favorite>; // id → Favorite, 즉시 조회용 }; type CollectionActions = { toggleFavorite: (id: string) => void; setRating: (id: string, rating: number) => void; setNote: (id: string, note: string) => void; clearAll: () => void; }; export const useCollectionStore = create<CollectionState & CollectionActions>()( persist( (set) => ({ favorites: {}, toggleFavorite: (id) => set((state) => { const next = { ...state.favorites }; if (next[id]) { delete next[id]; // 이미 있으면 빼고 } else { next[id] = { id, rating: 0, note: "" }; // 없으면 추가 } return { favorites: next }; }), setRating: (id, rating) => set((state) => { if (!state.favorites[id]) return state; // 즐겨찾기 안 한 건 무시 return { favorites: { ...state.favorites, [id]: { ...state.favorites[id], rating } }, }; }), setNote: (id, note) => set((state) => { if (!state.favorites[id]) return state; return { favorites: { ...state.favorites, [id]: { ...state.favorites[id], note } }, }; }), clearAll: () => set({ favorites: {} }), }), { name: "cat-collection", storage: createJSONStorage(() => localStorage), }, ), ); // ── selectors ─────────────────────────────────────────── export const selectFavoriteCount = (state: CollectionState) => Object.keys(state.favorites).length; export const selectFavoriteIds = (state: CollectionState) => Object.keys(state.favorites);
favorites를 객체로 두는 이유 — "이 id가 즐겨찾기에 있는가?"를 매번 확인해야 합니다(버튼 색깔, 별점 표시 등). 배열이면 find로 매번 훑어야 하지만, Record<id, Favorite>이면 favorites[id] 한 번에 끝나요. 큰 목록에서 차이가 큽니다.
3단계 — Context (뷰 모드)
grid/list 두 가지 보기 모드를 토글합니다. 이건 이번 세션의 UI 취향 이라 새로고침에 안 남아도 OK — Zustand가 아니라 Context로 갑니다.
contexts/ViewModeContext.tsx
"use client"; import { createContext, useContext, useState } from "react"; export type ViewMode = "grid" | "list"; type ViewModeContextValue = { viewMode: ViewMode; setViewMode: (next: ViewMode) => void; }; const ViewModeContext = createContext<ViewModeContextValue | null>(null); export function ViewModeProvider({ children }: { children: React.ReactNode }) { const [viewMode, setViewMode] = useState<ViewMode>("grid"); return ( <ViewModeContext.Provider value={{ viewMode, setViewMode }}> {children} </ViewModeContext.Provider> ); } export function useViewMode() { const ctx = useContext(ViewModeContext); if (ctx === null) { throw new Error("useViewMode는 <ViewModeProvider> 안에서만 사용할 수 있습니다."); } return ctx; }
12장에서 만든 다른 Context들과 모양이 똑같습니다 — 익숙해진 패턴 그대로.
4단계 — 레이아웃과 헤더
/cats로 시작하는 모든 페이지에 공통 헤더와 Provider를 씌웁니다. App Router의 layout.tsx가 그 역할입니다.
components/cats/Header.tsx
"use client"; import Link from "next/link"; import { useCollectionStore, selectFavoriteCount } from "@/stores/useCollectionStore"; import { useHydrated } from "@/hooks/useHydrated"; export default function Header() { const hydrated = useHydrated(); const count = useCollectionStore(selectFavoriteCount); return ( <header className="flex items-center justify-between border-b p-4"> <Link href="/cats" className="text-2xl font-bold"> 🐱 고양이 갤러리 </Link> <nav className="flex gap-4"> <Link href="/cats">갤러리</Link> <Link href="/cats/favorites" data-testid="nav-favorites"> 💖 즐겨찾기{" "} <span data-testid="favorite-badge" className="font-bold"> ({hydrated ? count : 0}) </span> </Link> </nav> </header> ); }
useHydrated로 초기엔 0, hydration 후엔 실제 count를 보여 줍니다.
app/cats/layout.tsx
import Header from "@/components/cats/Header"; import { ViewModeProvider } from "@/contexts/ViewModeContext"; export default function CatsLayout({ children }: { children: React.ReactNode }) { return ( <ViewModeProvider> <Header /> <div className="mx-auto max-w-3xl">{children}</div> </ViewModeProvider> ); }
이 한 파일로 /cats, /cats/[id], /cats/favorites 세 라우트가 모두 같은 헤더와 Provider 안에 들어갑니다. 그게 layout.tsx의 힘이에요.
app/cats/page.tsx — 임시 스텁
export default function CatsPage() { return <p className="p-4">갤러리를 만드는 중입니다…</p>; }
확인 ✅ — /cats 를 열면 헤더가 보입니다. 본문은 임시 메시지. 다음 단계에서 갤러리를 채워 넣습니다.
5단계 — 갤러리 페이지
/cats에 고양이 카드들을 그립니다. ViewMode는 일단 grid만 (toggle은 10단계에서).
components/cats/CatCard.tsx
"use client"; import Link from "next/link"; import type { Cat } from "@/types/cat"; import type { ViewMode } from "@/contexts/ViewModeContext"; export default function CatCard({ cat, viewMode }: { cat: Cat; viewMode: ViewMode }) { if (viewMode === "list") { return ( <Link href={`/cats/${cat.id}`} data-testid={`cat-card-${cat.id}`} className="flex items-center gap-3 rounded border p-3 hover:bg-gray-50" > <span className="text-4xl">{cat.emoji}</span> <div className="flex-1"> <p className="font-bold">{cat.name}</p> <p className="text-sm text-gray-600">{cat.breed} · {cat.age}살</p> </div> </Link> ); } return ( <Link href={`/cats/${cat.id}`} data-testid={`cat-card-${cat.id}`} className="block rounded border p-4 text-center hover:bg-gray-50" > <div className="text-6xl">{cat.emoji}</div> <div className="mt-2 font-bold">{cat.name}</div> <div className="text-sm text-gray-600">{cat.breed}</div> </Link> ); }
components/cats/CatGallery.tsx
"use client"; import { useEffect, useState } from "react"; import type { Cat } from "@/types/cat"; import { useViewMode } from "@/contexts/ViewModeContext"; import CatCard from "./CatCard"; export default function CatGallery() { const { viewMode } = useViewMode(); const [cats, setCats] = useState<Cat[]>([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(false); useEffect(() => { let ignore = false; async function load() { try { const res = await fetch("/api/cats"); if (!res.ok) throw new Error(); const data: Cat[] = await res.json(); if (!ignore) setCats(data); } catch { if (!ignore) setError(true); } finally { if (!ignore) setLoading(false); } } load(); return () => { ignore = true; }; }, []); if (loading) return <p className="p-4" data-testid="gallery-loading">불러오는 중…</p>; if (error) return <p className="p-4 text-red-600">불러오기에 실패했습니다.</p>; return ( <div data-testid="cat-gallery" data-view-mode={viewMode} className={ viewMode === "grid" ? "grid grid-cols-2 gap-4 p-4 md:grid-cols-3" : "space-y-3 p-4" } > {cats.map((cat) => ( <CatCard key={cat.id} cat={cat} viewMode={viewMode} /> ))} </div> ); }
app/cats/page.tsx — 갱신
스텁을 갤러리로 교체합니다.
import CatGallery from "@/components/cats/CatGallery"; export default function CatsPage() { return <CatGallery />; }
확인 ✅ — /cats 에서 6마리 고양이가 그리드로 보입니다. 카드를 클릭하면 /cats/[id] 로 가지만 아직 그 페이지는 404 — 7단계에서 만듭니다.
6단계 — 즐겨찾기 버튼 + 배지
카드에 💖 버튼을 추가해, 누르면 헤더 배지가 갱신되게 합니다.
components/cats/FavoriteButton.tsx
"use client"; import { useCollectionStore } from "@/stores/useCollectionStore"; import { useHydrated } from "@/hooks/useHydrated"; export default function FavoriteButton({ id }: { id: string }) { const hydrated = useHydrated(); const isFavorite = useCollectionStore((s) => s.favorites[id] !== undefined); const toggleFavorite = useCollectionStore((s) => s.toggleFavorite); const active = hydrated && isFavorite; return ( <button data-testid={`fav-btn-${id}`} aria-pressed={active} onClick={(e) => { e.preventDefault(); // 카드의 Link 가 같이 클릭되지 않도록 e.stopPropagation(); toggleFavorite(id); }} className={ "mt-2 rounded px-2 py-1 text-sm " + (active ? "bg-pink-500 text-white" : "bg-gray-200 text-gray-700") } > {active ? "💖 저장됨" : "🤍 저장"} </button> ); }
preventDefault + stopPropagation — 카드 전체가 <Link>인 상황에서 버튼을 누르면 페이지 이동까지 일어나 버립니다. 이 두 줄로 막아요.
CatCard.tsx — 갱신 (버튼 추가)
"use client"; import Link from "next/link"; import type { Cat } from "@/types/cat"; import type { ViewMode } from "@/contexts/ViewModeContext"; import FavoriteButton from "./FavoriteButton"; export default function CatCard({ cat, viewMode }: { cat: Cat; viewMode: ViewMode }) { if (viewMode === "list") { return ( <Link href={`/cats/${cat.id}`} data-testid={`cat-card-${cat.id}`} className="flex items-center gap-3 rounded border p-3 hover:bg-gray-50" > <span className="text-4xl">{cat.emoji}</span> <div className="flex-1"> <p className="font-bold">{cat.name}</p> <p className="text-sm text-gray-600">{cat.breed} · {cat.age}살</p> </div> <FavoriteButton id={cat.id} /> </Link> ); } return ( <Link href={`/cats/${cat.id}`} data-testid={`cat-card-${cat.id}`} className="block rounded border p-4 text-center hover:bg-gray-50" > <div className="text-6xl">{cat.emoji}</div> <div className="mt-2 font-bold">{cat.name}</div> <div className="text-sm text-gray-600">{cat.breed}</div> <FavoriteButton id={cat.id} /> </Link> ); }
확인 ✅ — 갤러리에서 💖를 누르면 헤더의 💖 즐겨찾기 (1) 배지 숫자가 즉시 바뀝니다. 다시 누르면 빠집니다. 새로고침해도 그대로 — persist 미들웨어의 보상.
7단계 — 디테일 페이지
/cats/[id] 동적 라우트입니다. params가 Promise로 들어오는 게 Next.js 15+ 의 새로운 모양.
components/cats/CatDetail.tsx
"use client"; import { useEffect, useState } from "react"; import type { Cat } from "@/types/cat"; import FavoriteButton from "./FavoriteButton"; export default function CatDetail({ id }: { id: string }) { const [cat, setCat] = useState<Cat | null>(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(false); useEffect(() => { let ignore = false; async function load() { try { const res = await fetch(`/api/cats/${id}`); if (!res.ok) throw new Error(); const data: Cat = await res.json(); if (!ignore) setCat(data); } catch { if (!ignore) setError(true); } finally { if (!ignore) setLoading(false); } } load(); return () => { ignore = true; }; }, [id]); if (loading) return <p className="p-4">불러오는 중…</p>; if (error || !cat) return <p className="p-4 text-red-600">고양이를 찾을 수 없어요.</p>; return ( <article className="space-y-4 p-4"> <div className="rounded border p-6 text-center"> <div className="text-8xl">{cat.emoji}</div> <h1 data-testid="cat-name" className="mt-2 text-3xl font-bold">{cat.name}</h1> <p className="text-gray-600"> {cat.breed} · {cat.age}살 </p> <p className="mt-3">{cat.description}</p> <div className="mt-3 flex justify-center"> <FavoriteButton id={cat.id} /> </div> </div> </article> ); }
useEffect의 의존성이 [id]라 — 카드 사이를 빠르게 이동할 때 이전 요청의 응답이 화면을 덮어쓰지 않도록 ignore 패턴을 그대로 씁니다 (8장).
app/cats/[id]/page.tsx
import CatDetail from "@/components/cats/CatDetail"; export default async function CatDetailPage({ params, }: { params: Promise<{ id: string }>; }) { const { id } = await params; return <CatDetail id={id} />; }
페이지(서버 컴포넌트)에서 동적 segment를 받아 클라이언트 컴포넌트에 prop으로 넘기는 게 표준 패턴이에요.
확인 ✅ — 갤러리의 카드를 클릭하면 디테일 페이지가 열립니다. 큰 이모지, 이름, 설명, 즐겨찾기 버튼. 다른 카드를 빠르게 눌러도 화면이 꼬이지 않습니다.
8단계 — 별점·메모 폼
즐겨찾기 한 고양이에는 별점과 메모를 매길 수 있게 합니다. 즐겨찾기 안 했으면 폼이 안 나옵니다 — 자연스러운 UX.
components/cats/RatingNoteForm.tsx
"use client"; import { useCollectionStore } from "@/stores/useCollectionStore"; import { useHydrated } from "@/hooks/useHydrated"; export default function RatingNoteForm({ id }: { id: string }) { const hydrated = useHydrated(); const favorite = useCollectionStore((s) => s.favorites[id]); const setRating = useCollectionStore((s) => s.setRating); const setNote = useCollectionStore((s) => s.setNote); if (!hydrated || !favorite) { return ( <p data-testid="rating-form-hint" className="text-sm text-gray-500"> 먼저 💖 저장하면 별점과 메모를 매길 수 있어요. </p> ); } return ( <div className="space-y-3 rounded border p-4"> <div> <label className="block text-sm">별점</label> <div data-testid="rating-buttons" className="flex gap-1"> {[1, 2, 3, 4, 5].map((n) => ( <button key={n} data-testid={`rate-${n}`} onClick={() => setRating(id, n)} className={ "text-3xl " + (n <= favorite.rating ? "text-yellow-400" : "text-gray-300") } > ★ </button> ))} </div> <p data-testid="rating-value" className="text-xs text-gray-500"> 현재 별점: {favorite.rating}점 </p> </div> <div> <label htmlFor="note" className="block text-sm">메모</label> <textarea id="note" data-testid="note-input" className="block w-full rounded border px-2 py-1" rows={3} value={favorite.note} onChange={(e) => setNote(id, e.target.value)} placeholder="이 고양이에 대한 메모" /> </div> </div> ); }
별점 5개는 [1,2,3,4,5].map으로 그립니다. n <= favorite.rating 이면 노란색, 아니면 회색 — 비교 한 줄로 색 분기.
CatDetail.tsx — 폼 추가
CatDetail 의 <article> 안 마지막에 <RatingNoteForm id={cat.id} /> 한 줄을 추가합니다.
import RatingNoteForm from "./RatingNoteForm"; ... return ( <article className="space-y-4 p-4"> <div className="rounded border p-6 text-center"> ... </div> <RatingNoteForm id={cat.id} /> </article> );
확인 ✅ — 디테일 페이지에서 💖를 누른 뒤 별을 클릭하면 노란색으로 채워지고, 메모를 쓰면 글자가 저장됩니다. 새로고침 후 다시 와도 그대로 — Zustand persist의 보상.
9단계 — 즐겨찾기 페이지
/cats/favorites에서 모은 아이들만 봅니다. 두 데이터 소스를 합치는 게 포인트 — Zustand의 favorites(어떤 id가 저장됐는가)와 API의 cats(고양이 정보).
components/cats/FavoritesView.tsx
"use client"; import { useEffect, useState } from "react"; import Link from "next/link"; import type { Cat } from "@/types/cat"; import { useCollectionStore } from "@/stores/useCollectionStore"; import { useHydrated } from "@/hooks/useHydrated"; export default function FavoritesView() { const hydrated = useHydrated(); const favorites = useCollectionStore((s) => s.favorites); const clearAll = useCollectionStore((s) => s.clearAll); const [allCats, setAllCats] = useState<Cat[]>([]); const [loading, setLoading] = useState(true); useEffect(() => { let ignore = false; fetch("/api/cats") .then((r) => r.json()) .then((data: Cat[]) => { if (!ignore) { setAllCats(data); setLoading(false); } }) .catch(() => { if (!ignore) setLoading(false); }); return () => { ignore = true; }; }, []); if (!hydrated || loading) { return <p className="p-4">불러오는 중…</p>; } const favoriteCats = allCats.filter((c) => favorites[c.id]); if (favoriteCats.length === 0) { return ( <div className="p-4 space-y-2"> <p data-testid="no-favorites">아직 즐겨찾기한 고양이가 없어요.</p> <Link href="/cats" className="text-blue-600 underline"> 갤러리로 가기 </Link> </div> ); } return ( <div className="space-y-3 p-4"> <div className="flex items-center justify-between"> <h2 className="text-xl font-bold"> 💖 내 즐겨찾기 ({favoriteCats.length}) </h2> <button data-testid="clear-favorites" onClick={clearAll} className="text-sm text-red-600" > 모두 비우기 </button> </div> <ul className="space-y-2"> {favoriteCats.map((cat) => { const fav = favorites[cat.id]; return ( <li key={cat.id} data-testid={`fav-item-${cat.id}`} className="rounded border p-3" > <div className="flex items-center gap-3"> <span className="text-4xl">{cat.emoji}</span> <div className="flex-1"> <Link href={`/cats/${cat.id}`} className="font-bold"> {cat.name} </Link> <p className="text-sm text-gray-600">{cat.breed}</p> <p data-testid={`fav-rating-${cat.id}`} className="text-sm text-yellow-500" > {"★".repeat(fav.rating)} <span className="text-gray-300"> {"★".repeat(5 - fav.rating)} </span> </p> {fav.note && ( <p data-testid={`fav-note-${cat.id}`} className="mt-1 text-sm text-gray-700" > {fav.note} </p> )} </div> </div> </li> ); })} </ul> </div> ); }
app/cats/favorites/page.tsx
import FavoritesView from "@/components/cats/FavoritesView"; export default function FavoritesPage() { return <FavoritesView />; }
왜 /cats/favorites인데 [id]로 안 잡힐까? Next.js App Router는 정적 라우트를 동적 라우트보다 먼저 매치합니다. /cats/favorites는 정적, /cats/[id]는 동적 — /cats/favorites 요청은 정적 라우트가 가져가고, /cats/3만 동적으로 갑니다.
확인 ✅ — 헤더의 "💖 즐겨찾기"를 누르면 모은 아이들 목록. 별점과 메모가 함께 보이고, "모두 비우기" 버튼으로 깔끔히 초기화 가능.
10단계 — 뷰 모드 토글
이제 grid ↔ list 를 실제로 토글할 수 있게 합니다.
components/cats/ViewModeToggle.tsx
"use client"; import { useViewMode, type ViewMode } from "@/contexts/ViewModeContext"; const OPTIONS: { value: ViewMode; label: string }[] = [ { value: "grid", label: "🔲 그리드" }, { value: "list", label: "📋 리스트" }, ]; export default function ViewModeToggle() { const { viewMode, setViewMode } = useViewMode(); return ( <div data-testid="view-mode-toggle" className="flex justify-end gap-2 p-4 pb-0"> {OPTIONS.map((opt) => { const active = viewMode === opt.value; return ( <button key={opt.value} data-testid={`view-${opt.value}`} aria-pressed={active} onClick={() => setViewMode(opt.value)} className={ "rounded px-3 py-1 text-sm " + (active ? "bg-blue-500 text-white" : "bg-gray-200") } > {opt.label} </button> ); })} </div> ); }
app/cats/page.tsx — 갱신
토글을 갤러리 위에 둡니다.
import CatGallery from "@/components/cats/CatGallery"; import ViewModeToggle from "@/components/cats/ViewModeToggle"; export default function CatsPage() { return ( <> <ViewModeToggle /> <CatGallery /> </> ); }
확인 ✅ — /cats 위쪽에 토글 두 버튼이 보이고, 누르면 갤러리가 그리드 ↔ 리스트로 바뀝니다.
11단계 — 전체 확인
11단계가 다 끝났습니다. /cats에서 다음을 차례로 해 보세요.
- 6마리가 그리드로 보임 ✓
- 마음에 드는 두 마리에 💖 — 헤더 배지가
(2)✓ - 카드를 클릭 → 디테일 페이지 → ★★★★ 별 4개 + 메모 입력 ✓
- 뷰 모드 "📋 리스트" → 카드들이 한 줄씩 늘어남 ✓
- "💖 즐겨찾기" 페이지 → 두 마리가 별점·메모와 함께 ✓
- 페이지를 새로고침 → 즐겨찾기·별점·메모 그대로 ✓
- 뷰 모드만 그리드로 돌아감 — Context는 영속 X, 의도된 동작 ✓
핵심 설계 결정 다시 보기
| 결정 | 왜 |
|---|---|
| 즐겨찾기를 Zustand에 | 영속 데이터, 여러 라우트가 공유 (/cats/[id]·/cats/favorites·헤더 배지) |
| 뷰 모드를 Context에 | 이번 세션의 UI 취향. 새로고침에 안 남아도 OK |
favorites를 Record<id, Favorite> | id 조회 O(1). 카드마다 "내가 저장했나?" 확인이 빈번 |
| selector 함수 모듈 스코프 | selectFavoriteCount 한 번만 만들어 재사용 |
useHydrated | persist + Next.js 의 hydration 깜빡임/경고 차단 |
Route Handler에 _data.ts | _ 접두사로 "라우트 아닌 모듈" 표시 |
app/cats/layout.tsx | 세 라우트가 공통 Provider · 헤더를 공유 |
정리
지금까지 배운 거의 모든 것이 한 화면에 다 들어왔습니다.
- Next.js App Router — layout, dynamic segment, route handler
- fetch + ignore 패턴 — 라우트가 빠르게 바뀌는 디테일 페이지에서 필수
- Context — UI 취향 (뷰 모드)
- Zustand + persist — 영속 데이터 (즐겨찾기 / 별점 / 메모)
- selectors + useHydrated — Zustand 의 현업 패턴
- 제어 컴포넌트 — 별점 버튼, 메모 textarea
핵심 메시지 — "데이터는 Zustand, UI 취향은 Context", 이 한 줄을 손에 익혔으면 큰 앱도 무서울 게 없어요.
보너스 / 연습 거리
- 즐겨찾기 페이지의 정렬 — 별점 높은 순으로 정렬. 빈 별점(
rating === 0)은 뒤로. - 검색 — 갤러리 위에 검색칸을 두고 이름/품종에 필터링.
useMemo로 거른 배열을 메모. - 다크 모드 Context — 두 번째 Context(
ThemeContext)를 추가. Provider를 중첩하면 끝. - 공유 가능한 즐겨찾기 URL —
/cats/favorites?ids=1,3,5식으로 URL에 즐겨찾기 id를 실어 보내는 "공유" 버튼.useSearchParams활용. - API에 의도적 지연 —
/api/cats/[id]에 sleep을 넣고, 카드를 빠르게 바꿔 ignore 패턴이 실제로 일하는 걸 확인.

댓글
댓글을 작성하려면 이 필요합니다.