고양이 갤러리 (종합 프로젝트) - 미들웨어제거버전

튜토리얼 — 고양이 갤러리 (종합 프로젝트)
마지막 종합 프로젝트입니다. 귀여운 고양이 갤러리를 만들면서 App Router · Context · Zustand · 폼 · fetch · localStorage 를 한 화면에서 다 만납니다.
이 튜토리얼의 원칙 — 한 번에 다 만들지 않습니다.
기능을 더할 때마다 그때 필요한 것만 만듭니다. 목록만 보여줄 땐 Zustand 가 없어도 돼요. "저장" 이 등장하는 순간 비로소 Zustand 를 꺼냅니다. store 도 처음부터 액션을 다 짜지 않고, 기능이 생길 때마다 한 액션씩 키워갑니다. 그게 실제 개발 방식이고, 각 도구가 "왜 필요한지" 가 손에 잡히는 방법이에요.
완성하면 이렇게 동작합니다.
- 🐈 갤러리에서 고양이들을 둘러본다 (그리드 ↔ 리스트)
- 💖 마음에 드는 아이를 "저장" 한다 — 새로고침해도 남는다
- ⭐ 디테일 페이지에서 별점·메모를 단다
- 📚 즐겨찾기 페이지에서 저장한 아이들만, 별점·메모와 함께 본다
만드는 순서와 — 그때 새로 꺼내는 도구
| 단계 | 만드는 기능 | 이 단계에서 새로 필요한 것 |
|---|---|---|
| 1 | 목록 (/cats, 그리드/리스트) | API(Route Handler), fetch, Context(뷰 모드) — Zustand는 아직 ❌ |
| 2 | 저장 버튼 + 헤더 배지 | Zustand 등장 — store v1(toggleFavorite) + localStorage 직접 핸들링 |
| 3 | 상세 페이지 (/cats/[id]) | dynamic route + fetch (도구 재사용) |
| 4 | 별점·메모 | store에 setRating·setNote 추가 + 폼 |
| 5 | 즐겨찾기 페이지 (/cats/favorites) | store에 clearAll 추가 + selector |
왜 Context와 Zustand를 둘 다 쓰나 — 둘 다 여러 컴포넌트가 공유하는 상태지만 성격이 달라요. Zustand=데이터(저장·별점·메모, 새로고침에도 남아야 함), Context=UI 취향(그리드/리스트, 세션 한정). 단, 이건 미리 외울 게 아니라 — 1·2단계를 만들다 보면 자연히 느껴집니다.
1단계 — 목록 보기 (Zustand 없이!)
목록만 보여 줄 거예요. 저장도, 별점도 아직 없습니다. 그래서 Zustand 도 필요 없어요. 필요한 건 ① 데이터(API) ② 그걸 가져와 그리는 화면 ③ 그리드/리스트 전환(Context) 뿐.
1-1. 데이터와 API
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
상세 페이지(3단계)에서 쓸 단일 조회도 미리 만들어 둡니다. (API는 둘 다 데이터 영역이라 함께 둬요.)
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); }
확인 ✅ — 개발 서버를 켜고 /api/cats → 6마리 JSON, /api/cats/2 → 치즈 한 마리.
1-2. Context — 그리드/리스트 전환
목록을 두 가지 방식으로 보고 싶습니다. 토글 버튼과 갤러리가 같은 "보기 모드" 를 공유 해야 하죠. 두 컴포넌트가 떨어져 있으니 Context 로 공유합니다. (이건 새로고침에 안 남아도 되는 UI 취향이라 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"); // 기본 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; }
1-3. 레이아웃과 헤더 (배지 없음)
/cats 로 시작하는 페이지에 공통 헤더 + Provider 를 씌웁니다. 지금 헤더에는 배지가 없습니다 — 아직 저장 기능이 없으니 셀 게 없거든요. 배지는 2단계에서 더합니다.
components/cats/Header.tsx
import Link from "next/link"; // 1단계 버전 — 아직 상태를 구독하지 않으니 평범한(서버) 컴포넌트. export default function Header() { return ( <header className="flex items-center justify-between border-b p-4"> <Link href="/cats" className="text-2xl font-bold"> 🐱 고양이 갤러리 </Link> <nav> <Link href="/cats">갤러리</Link> </nav> </header> ); }
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> ); }
1-4. 갤러리 + 카드 + 토글
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/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> ); }
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"; import ViewModeToggle from "@/components/cats/ViewModeToggle"; export default function CatsPage() { return ( <> <ViewModeToggle /> <CatGallery /> </> ); }
확인 ✅ — /cats 에서 6마리가 그리드로, "📋 리스트" 로 전환됩니다. 여기까지 Zustand 한 줄도 없습니다. 목록 보기엔 정말 필요 없으니까요.
2단계 — 저장 버튼 (여기서 Zustand 도입)
이제 "저장" 이 등장합니다. 저장한 고양이 목록은 — 카드 버튼·헤더 배지·(나중에) 디테일·즐겨찾기 페이지 — 여러 곳이 같이 봐야 합니다. useState 로는 한 컴포넌트에 갇혀서 안 돼요. 이때 비로소 Zustand 가 필요해집니다.
2-1. Zustand 설치
npm install zustand
2-2. Zustand 가 뭐였더라
스토어는 하나의 객체 입니다. 그 안에 ① 데이터(state)와 ② 데이터를 바꾸는 함수(actions)가 함께 삽니다.
import { create } from "zustand"; const useStore = create((set) => ({ count: 0, // ① 데이터 increase: () => set((s) => ({ count: s.count + 1 })), // ② action }));
create(...)— 스토어를 만든다.set— 상태를 바꾼다.set((state) => 새조각)또는set(새조각).- 컴포넌트에서는
useStore(selector)로 필요한 조각만 꺼낸다.
2-3. 스토어 v1 — 지금 필요한 것만
지금 필요한 건 딱 두 가지입니다 — 저장 목록과 저장 토글. 별점·메모는 4단계에서, 모두 비우기는 5단계에서 — 지금은 안 만듭니다.
저장 항목 하나의 모양부터:
export type Favorite = { id: string; rating: number; // 0~5 (지금은 0으로 시작, 별점은 4단계에서 씀) note: string; // (메모도 4단계에서) };
저장 목록은 Record<string, Favorite> — id 를 키로 하는 객체로 둡니다.
왜 배열이 아니라 객체? "이 id 가 저장됐나?" 를 아주 자주 확인합니다(버튼 색 등). 배열이면 매번
find로 훑지만, 객체면favorites[id]한 번에 끝납니다.
그리고 새로고침에도 남아야 하니 localStorage 와 묶습니다. persist 미들웨어는 안 씁니다 — 대신 우리가 직접 읽고 씁니다(타이밍이 코드에 다 보이고, hydration 도 안전해요).
stores/useCollectionStore.ts — v1
import { create } from "zustand"; export type Favorite = { id: string; rating: number; note: string; }; type CollectionState = { favorites: Record<string, Favorite>; }; type CollectionActions = { loadFromStorage: () => void; // localStorage → 스토어 toggleFavorite: (id: string) => void; }; const STORAGE_KEY = "cat-collection"; // ── localStorage 직접 핸들링 ── function loadFavorites(): Record<string, Favorite> { if (typeof window === "undefined") return {}; // 서버엔 localStorage 없음 try { const saved = localStorage.getItem(STORAGE_KEY); return saved ? JSON.parse(saved) : {}; } catch { return {}; } } function saveFavorites(favorites: Record<string, Favorite>) { if (typeof window === "undefined") return; localStorage.setItem(STORAGE_KEY, JSON.stringify(favorites)); } export const useCollectionStore = create<CollectionState & CollectionActions>( (set) => ({ // 항상 빈 상태로 시작 → 서버/클라이언트 첫 렌더 일치 → hydration 안전 favorites: {}, loadFromStorage: () => set({ favorites: loadFavorites() }), toggleFavorite: (id) => set((state) => { const next = { ...state.favorites }; if (next[id]) { delete next[id]; // 있으면 빼고 } else { next[id] = { id, rating: 0, note: "" }; // 없으면 추가 } saveFavorites(next); // 바뀔 때마다 직접 저장 return { favorites: next }; }), }), ); // ── selector ── export const selectFavoriteCount = (state: CollectionState) => Object.keys(state.favorites).length;
설계의 핵심.
favorites: {}로 시작 — 스토어 생성 시(서버 포함) 빈 객체. localStorage 는loadFromStorage로 나중에(useEffect) 불러옵니다. 그래서 서버와 클라이언트 첫 렌더가 둘 다 빈 상태로 일치 → hydration 경고가 안 납니다. 어제 배운 "안전한 초기값 + useEffect" 패턴이에요.- 불변 업데이트 —
{ ...state.favorites }로 새 객체를 만들어 바꿉니다 (6장 규칙).
2-4. Selector — 자세히
스토어 맨 아래 함수가 selector 입니다.
export const selectFavoriteCount = (state) => Object.keys(state.favorites).length;
Selector 는 "스토어 상태에서 필요한 값을 꺼내거나 계산해 돌려주는 함수" 입니다. (state) => 값. 쓸 때:
const count = useCollectionStore(selectFavoriteCount); // "스토어야, selectFavoriteCount 가 돌려주는 값만 구독할게."
왜 함수로 빼나 — 네 가지 이유
- 부분 구독. 컴포넌트는 selector 가 돌려준 값이 바뀔 때만 다시 렌더링됩니다. 헤더 배지는 "총 개수" 만 구독하니, 어떤 고양이의 메모가 바뀌어도 개수가 같으면 다시 안 그려져요.
- 파생값을 한 곳에. "개수" 계산을 컴포넌트마다 반복하지 않습니다. 정의를 바꾸면 한 곳만 고쳐도 모두 따라옵니다.
- 재사용. 헤더도 즐겨찾기 페이지도 같은
selectFavoriteCount를 가져다 씁니다. - 안정적 참조. 모듈 스코프에 두면 매 렌더마다 새 함수가 안 만들어져 비교가 빠릅니다.
단순히 한 조각만 꺼낼 땐
(s) => s.favorites처럼 인라인도 OK(이 튜토리얼도 그렇게 씁니다). 계산이 들어가거나 여러 곳에서 쓰면 이름 있는 selector 로 뺍니다.
2-5. Object.keys 문법
Object.keys 는 객체를 받아 그 키들을 문자열 배열로 돌려주는 내장 함수입니다.
const favorites = { "1": {...}, "3": {...} }; Object.keys(favorites); // → ["1", "3"] (키만) Object.keys(favorites).length; // → 2 (그래서 "저장 개수")
Object.keys(favorites) 처럼 객체를 통째로 인자로 건넵니다 — Math.max(1,5,3) 에 숫자를 통째로 주고 최댓값을 받는 것과 같은 문법. 친구로 Object.values(값만), Object.entries([키,값] 쌍)도 있습니다.
2-6. 저장 버튼 컴포넌트
components/cats/FavoriteButton.tsx
"use client"; import { useCollectionStore } from "@/stores/useCollectionStore"; export default function FavoriteButton({ id }: { id: string }) { const isFavorite = useCollectionStore((s) => s.favorites[id] !== undefined); const toggleFavorite = useCollectionStore((s) => s.toggleFavorite); return ( <button data-testid={`fav-btn-${id}`} aria-pressed={isFavorite} onClick={(e) => { e.preventDefault(); // 카드 전체가 <Link> 라 페이지 이동을 막는다 e.stopPropagation(); toggleFavorite(id); }} className={"mt-2 rounded px-2 py-1 text-sm " + (isFavorite ? "bg-pink-500 text-white" : "bg-gray-200 text-gray-700")} > {isFavorite ? "💖 저장됨" : "🤍 저장"} </button> ); }
2-7. 카드에 버튼 달기
1단계의 CatCard.tsx 에 FavoriteButton 을 더합니다 — import 한 줄과, 두 모드의 카드 안에 <FavoriteButton id={cat.id} /> 한 줄씩. 갱신된 전체 코드:
"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> ); }
2-8. 헤더에 배지 + localStorage 불러오기
이제 헤더가 저장 개수를 구독 하고, 앱이 뜰 때 localStorage 를 불러옵니다. 상태를 구독하니 — 헤더가 클라이언트 컴포넌트가 됩니다("use client" 추가). 1단계의 평범한 헤더가 여기서 한 단계 자랍니다.
components/cats/Header.tsx — 갱신
"use client"; import { useEffect } from "react"; import Link from "next/link"; import { useCollectionStore, selectFavoriteCount } from "@/stores/useCollectionStore"; export default function Header() { const count = useCollectionStore(selectFavoriteCount); const loadFromStorage = useCollectionStore((s) => s.loadFromStorage); // 앱이 처음 뜰 때 localStorage 에서 한 번 불러온다. // useEffect 안 → hydration 이 끝난 뒤 실행 → 안전. useEffect(() => { loadFromStorage(); }, [loadFromStorage]); 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> {/* 즐겨찾기 페이지는 5단계에서 만듭니다 (지금 누르면 404) */} <Link href="/cats/favorites" data-testid="nav-favorites"> 💖 즐겨찾기{" "} <span data-testid="favorite-badge" className="font-bold">({count})</span> </Link> </nav> </header> ); }
확인 ✅ — 갤러리에서 💖 를 누르면 핑크색 "💖 저장됨" 으로 바뀌고, 헤더 배지가 (1) 로. 새로고침해도 그대로 — toggleFavorite 가 localStorage 에 저장하고, 헤더의 loadFromStorage 가 다시 불러오니까요. (즐겨찾기 링크는 아직 404 — 5단계에서 채웁니다.)
3단계 — 상세 페이지
기존 도구를 재사용 합니다. 새 라우트(/cats/[id])와 단일 fetch 뿐, 새 store 액션은 없어요.
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> {/* 별점·메모 폼은 4단계에서 여기 추가 */} </article> ); }
의존성 [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} />; }
확인 ✅ — 카드를 클릭하면 디테일이 열리고, 여기서도 저장 버튼이 동작합니다.
4단계 — 별점·메모 (스토어를 키운다)
이제 별점·메모가 필요합니다. store v1 엔 그걸 바꾸는 액션이 없죠. 딱 두 액션만 추가 합니다 — setRating, setNote.
4-1. 스토어에 두 액션 추가
useCollectionStore.ts 의 CollectionActions 타입에 두 줄, 그리고 create 안에 두 액션을 추가합니다.
// 타입에 추가 type CollectionActions = { loadFromStorage: () => void; toggleFavorite: (id: string) => void; setRating: (id: string, rating: number) => void; // ← 추가 setNote: (id: string, note: string) => void; // ← 추가 }; // create 안에 추가 setRating: (id, rating) => set((state) => { if (!state.favorites[id]) return state; // 저장 안 한 건 무시 const next = { ...state.favorites, [id]: { ...state.favorites[id], rating }, }; saveFavorites(next); return { favorites: next }; }), setNote: (id, note) => set((state) => { if (!state.favorites[id]) return state; const next = { ...state.favorites, [id]: { ...state.favorites[id], note }, }; saveFavorites(next); return { favorites: next }; }),
두 액션 모두 같은 모양 — "저장 안 한 고양이면 무시(return state), 맞으면 그 항목만 바꿔 새 객체를 만들고 저장." store 가 한 번에 다 만들어진 게 아니라 필요할 때 두 줄씩 자랐다 는 걸 보세요.
4-2. 별점·메모 폼 (저장해야만 보임)
components/cats/RatingNoteForm.tsx
"use client"; import { useCollectionStore } from "@/stores/useCollectionStore"; export default function RatingNoteForm({ id }: { id: string }) { const favorite = useCollectionStore((s) => s.favorites[id]); const setRating = useCollectionStore((s) => s.setRating); const setNote = useCollectionStore((s) => s.setNote); // 저장 안 한 고양이면 폼을 숨긴다 if (!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> ); }
4-3. 디테일에 폼 추가
CatDetail.tsx 의 <article> 안 마지막(주석 자리)에 한 줄 더합니다.
import RatingNoteForm from "./RatingNoteForm"; // ... <RatingNoteForm id={cat.id} />
확인 ✅ — 디테일에서 💖 저장 → 별점·메모 폼이 나타남 → 별 클릭·메모 입력이 저장됩니다. 새로고침해도 그대로.
5단계 — 즐겨찾기 페이지 (스토어를 한 번 더 키운다)
마지막 기능. 저장한 아이들만 모아 보고, "모두 비우기" 도 넣습니다. store 에 마지막 액션 clearAll 을 추가합니다.
5-1. 스토어에 clearAll 추가
// 타입에 추가 clearAll: () => void; // create 안에 추가 clearAll: () => { saveFavorites({}); set({ favorites: {} }); },
이로써 store 가 완성됐습니다 — loadFromStorage, toggleFavorite(2단계), setRating·setNote(4단계), clearAll(5단계). 다섯 액션이 다섯 단계에 걸쳐 하나씩 자란 거예요.
5-2. 즐겨찾기 화면
저장 목록(스토어)과 고양이 정보(API) 두 데이터를 합치는 게 포인트.
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"; export default function FavoritesView() { 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 (loading) return <p className="p-4">불러오는 중…</p>; const favoriteCats = allCats.filter((c) => favorites[c.id]); if (favoriteCats.length === 0) { return ( <div className="space-y-2 p-4"> <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 는 정적 라우트를 동적보다 먼저 매치합니다./cats/favorites는 정적이라 favorites 페이지가,/cats/3만[id]로 갑니다.
이제 2단계에서 만든 헤더의 "💖 즐겨찾기" 링크가 더 이상 404 가 아닙니다.
확인 ✅ — 헤더의 "💖 즐겨찾기" → 저장한 아이들이 별점·메모와 함께. "모두 비우기" 로 초기화.
전체 확인
/cats 에서 차례로:
- 6마리 그리드 → "📋 리스트" 전환 ✓
- 두 마리 💖 → 헤더 배지
(2)✓ - 카드 클릭 → 디테일 → 💖 → ★★★★ + 메모 ✓
- "💖 즐겨찾기" → 저장한 아이들 + 별점·메모 ✓
- 새로고침 → 저장·별점·메모 그대로 ✓
- 뷰 모드만 그리드로 초기화 (Context 는 영속 X — 의도된 동작) ✓
정리 — "필요할 때 하나씩"
이 프로젝트의 진짜 교훈은 고양이가 아니라 만드는 순서 입니다.
- 1단계(목록) 엔 Zustand 가 없었습니다. 목록 보기엔 정말 필요 없으니까요.
- 2단계(저장) 에서 "여러 곳이 공유하는 데이터" 가 생기자 그제서야 Zustand 를 꺼냈고, store 도
toggleFavorite하나로 시작했습니다. - 4·5단계 에서 별점·메모·비우기가 필요해질 때마다 store 에 액션을 하나씩 더했습니다.
도구는 필요가 생기는 순간 꺼내고, 구조는 기능과 함께 자라게 하세요. 처음부터 모든 걸 짜려 하면 막막하지만, "지금 이 기능에 뭐가 필요하지?" 만 물으면 길이 보입니다. 그게 큰 앱을 만드는 실제 방법이에요.
함께 쓴 것들 — App Router(layout·dynamic·route handler), fetch + ignore 패턴, Context(뷰 모드), Zustand(저장/별점/메모) + selector, localStorage 직접 핸들링, 제어 컴포넌트.
보너스 / 연습 거리
- 별점순 정렬 — 즐겨찾기 페이지를 별점 높은 순으로.
Object.values(favorites)+sort. - localStorage 커스텀 훅 —
load/save를useLocalStorage스타일 훅으로 묶기. - 검색 — 갤러리 위 검색칸 +
useMemo로 거른 목록. - 다크 모드 Context — 두 번째 Context 추가, Provider 중첩.

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