๐ ์ฑ ๊ด๋ฆฌ API ํ ๋จ๊ณ์ฉ ๋ง๋ค๊ธฐ โ Next.js Route Handler ํํ ๋ฆฌ์ผ
๋ชฉ์ฐจ13
์ฝ๋ ๋ถ: useState ๋ง ๋ฐฐ์ด React ์
๋ฌธ์
๋ง๋๋ ๊ฒ: ์ฑ
์ ๋ชฉ๋ก(GET) / ์์ธ(GET) / ๋ฑ๋ก(POST) / ์์ (PUT) ํ ์ ์๋ ์์ ์น ์ฑ
์ด ๊ธ์ ์ ๋ ํ๊บผ๋ฒ์ ๋ง๋ค์ง ์์ต๋๋ค. ํ ๋จ๊ณ์ API ํ ๊ฐ + ๊ทธ๊ฑธ ์ฐ๋ ํ๋ฉด ํ ๊ฐ ๋ง ๋ง๋ค๊ณ , ๋งค๋ฒ ๋์์ ๋์ผ๋ก ํ์ธํ ๋ค ๋ค์ ๋จ๊ณ๋ก ๋์ด๊ฐ๋๋ค.
STEP 1 ํ๋ก์ ํธ ๋ง๋ค๊ธฐ STEP 2 ์ฑ ๋ฐ์ดํฐ ์ค๋น (๋ฉ๋ชจ๋ฆฌ ์ ์ฅ์) STEP 3 GET /api/books โ ์ฑ ๋ชฉ๋ก STEP 4 ์ฑ ๋ชฉ๋ก ํ๋ฉด (์๋ฒ ์ปดํฌ๋ํธ) STEP 5 GET /api/books/[id] โ ์ฑ ์์ธ STEP 6 ์ฑ ์์ธ ํ๋ฉด (๋์ ๋ผ์ฐํธ) STEP 7 POST /api/books โ ์ ์ฑ ๋ฑ๋ก STEP 8 ์ ์ฑ ๋ฑ๋ก ํ๋ฉด (ํด๋ผ์ด์ธํธ ํผ) STEP 9 PUT /api/books/[id] โ ์ฑ ์์ STEP 10 ์ฑ ์์ ํ๋ฉด (์๋ฒ+ํด๋ผ์ด์ธํธ ์กฐํฉ)
๊ฐ STEP ๋์ "ํ์ธํ๊ธฐ" ๋ฅผ ๊ผญ ํด ๋ณด์ธ์. ๊ฑฐ๊ธฐ์ ๋์์ด ์ ๋๋ฉด ๋ค์ STEP ์ผ๋ก ๋์ด๊ฐ์ง ๋ง๊ณ ๋ฉ์ถฐ์ ์์ธ์ ์ฐพ๋ ๊ฒ ๋น ๋ฆ ๋๋ค.
0. ๊ทธ ์ ์ โ Route Handler ๊ฐ ๋ญ๊ฐ์?
์ง๊ธ๊น์ง ์ฐ๋ฆฌ๋ ํ์ด์ง ํ์ผ (page.tsx) ๋ง ๋ง๋ค์์ด์. ํ์ด์ง ํ์ผ์ ์ฌ๋์ด ๋ณด๋ ํ๋ฉด์ ๊ทธ๋ฆฝ๋๋ค.
Route Handler ๋ ์ฌ๋์ด ๋ณด๋ ํ๋ฉด์ด ์๋๋ผ, ๋ค๋ฅธ ํ๋ก๊ทธ๋จ(๋ธ๋ผ์ฐ์ fetch, ๋ชจ๋ฐ์ผ ์ฑ, ๋ค๋ฅธ ์๋ฒ ๋ฑ)์ด ๋ฐ์ดํฐ๋ฅผ ์์ฒญํ๊ธฐ ์ํด ํธ์ถํ๋ ์์ ํจ์์ ๋๋ค. JSON ๊ฐ์ ๋ฐ์ดํฐ๋ฅผ ๋๋ ค์ฃผ์ฃ .
app/api/books/route.ts โ URL /api/books (ํ์ด์ง ์๋, API) app/books/page.tsx โ URL /books (์ฌ๋์ด ๋ณด๋ ํ์ด์ง)
ํด๋ ์์น๋ ๊ฑฐ์ ๊ฐ์ต๋๋ค. ๋ค๋ฅธ ์ ์ ๋จ ํ๋: ํ์ผ ์ด๋ฆ์ด route.ts ์ธ๊ฐ, page.tsx ์ธ๊ฐ ์
๋๋ค.
| ์ข ๋ฅ | ํ์ผ ์ด๋ฆ | ๋ฌด์์ ๋๋ ค์ฃผ๋ | ๋๊ฐ ํธ์ถ |
|---|---|---|---|
| ํ์ด์ง | page.tsx | HTML ํ๋ฉด | ์ฌ์ฉ์๊ฐ ์ฃผ์์ฐฝ์ URL ์ ์ ๋ ฅ |
| Route Handler | route.ts | JSON ๋ฐ์ดํฐ | ์ฝ๋์์ fetch("/api/...") |
Route Handler ์ ๊ธฐ๋ณธ ๋ชจ์
// app/api/hello/route.ts import { NextResponse } from "next/server"; export async function GET() { return NextResponse.json({ message: "์๋ " }); }
์ ํ์ผ์ ๋ง๋ค๊ณ curl http://localhost:3000/api/hello ๋ฅผ ํ๋ฉด {"message":"์๋
"} ์ด ์ต๋๋ค. ์ด๊ฒ ์ ๋ถ์์.
export async function GET(...)โ GET ์์ฒญ ์ฒ๋ฆฌexport async function POST(...)โ POST ์์ฒญ ์ฒ๋ฆฌexport async function PUT(...)โ PUT ์์ฒญ ์ฒ๋ฆฌexport async function DELETE(...)โ DELETE ์์ฒญ ์ฒ๋ฆฌ
๊ฐ์ ํ์ผ์ GET/POST/PUT ์ ํจ๊ป ๋ ์ ์์ด์. URL ์ ๊ฐ๊ณ HTTP ๋ฉ์๋๋ง ๋ค๋ฅธ ๊ฑฐ์ฃ .
์, ์ด์ ๋ง๋ค์ด ๋ด ์๋ค.
STEP 1. ํ๋ก์ ํธ ๋ง๋ค๊ธฐ
ํฐ๋ฏธ๋์์ ์์ ํด๋๋ก ์ด๋ ํ:
npx create-next-app@15.5.4 book-api-tutorial \ --typescript --tailwind --app \ --no-src-dir --no-turbopack --no-eslint \ --import-alias "@/*" --use-npm --yes
์ต์
์ด ๋ง์ง๋ง ํ ์ค์ด์์. ๋๋๋ฉด book-api-tutorial/ ํด๋๊ฐ ์๊น๋๋ค.
cd book-api-tutorial
1-1. ํฌํธ ๋ณ๊ฒฝ (์ ํ)
๋ค๋ฅธ Next ํ๋ก์ ํธ์ ์ ๊ฒน์น๊ฒ ์ฐ๋ฆฌ ํํ ๋ฆฌ์ผ์ 3300๋ฒ ํฌํธ๋ก ๊ณ ์ ํฉ๋๋ค. package.json ์์:
"scripts": { "dev": "next dev -p 3300", "build": "next build", "start": "next start -p 3300" }
1-2. layout ๋จ์ํ
app/layout.tsx ๋ฅผ ํต์งธ๋ก ๋ฐ๊ฟ๋๋ค.
// app/layout.tsx import type { Metadata } from "next"; import "./globals.css"; export const metadata: Metadata = { title: "์ฑ ๊ด๋ฆฌ API ํํ ๋ฆฌ์ผ", description: "Next.js Route Handler ๋ก GET/POST/PUT ๋ง๋ค๊ธฐ", }; export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { return ( <html lang="ko"> <body className="min-h-screen bg-stone-50 text-stone-900 antialiased"> {children} </body> </html> ); }
1-3. ํ ํ์ด์ง ๋น์ฐ๊ธฐ
app/page.tsx ๋ฅผ ํต์งธ๋ก ๋ฐ๊ฟ๋๋ค.
// app/page.tsx import Link from "next/link"; export default function HomePage() { return ( <main className="mx-auto max-w-2xl px-6 py-16"> <h1 className="text-3xl font-black">์ฑ ๊ด๋ฆฌ API ํํ ๋ฆฌ์ผ</h1> <p className="mt-2 text-stone-600"> Next.js Route Handler ๋ก GET/POST/PUT ์ ์ฐจ๋ก๋๋ก ๋ง๋ค์ด ๋ด ๋๋ค. </p> <ul className="mt-8 space-y-3"> <li> <Link href="/books" className="block rounded-lg border border-stone-200 bg-white p-4 hover:border-amber-500" > <span className="text-lg font-bold">๐ ์ฑ ๋ชฉ๋ก ๋ณด๊ธฐ</span> </Link> </li> </ul> </main> ); }
1-4. ์๋ฒ ๋์ฐ๊ธฐ
npm install # ์ฒ์ ํ ๋ฒ๋ง npm run dev
http://localhost:3300 ์ผ๋ก ์ ์ํด "๐ ์ฑ
๋ชฉ๋ก ๋ณด๊ธฐ" ์นด๋๋ฅผ ํด๋ฆญํ๋ฉด ์์ง ๋ง๋ค์ง ์์ ํ์ด์ง๋ผ 404 ๊ฐ ๋น๋๋ค. ๋ค์ STEP ๋ถํฐ ์ฑ์ ๊ฐ๋๋ค.
โ ํ์ธํ๊ธฐ
- ํ์ "์ฑ ๊ด๋ฆฌ API ํํ ๋ฆฌ์ผ" ์ ๋ชฉ์ด ๋ณด์ ๋๋ค.
- ์ฝ์์ ์๋ฌ๊ฐ ์์ต๋๋ค.
STEP 2. ์ฑ ๋ฐ์ดํฐ ์ค๋น
API ๊ฐ ๋๋ ค์ค ๋ฐ์ดํฐ๋ฅผ ์ด๋๊ฐ์ ๋์ด์ผ ํด์. ์ค์ ์๋น์ค๋ผ๋ฉด ๋ฐ์ดํฐ๋ฒ ์ด์ค(MySQL, PostgreSQL ๋ฑ) ๋ฅผ ์ฐ์ง๋ง, ์ด๋ฒ ํํ ๋ฆฌ์ผ์ API ํ๋ฆ ์ตํ๊ธฐ ๊ฐ ๋ชฉ์ ์ด๋ผ์ ๋ฉ๋ชจ๋ฆฌ์ ๋ก๋๋ค.
โ ๏ธ ๋จ์ : ์๋ฒ๋ฅผ ๋ค์ ์์ํ๋ฉด ๋ฐ์ดํฐ๊ฐ ์ด๊ธฐ ์ํ๋ก ๋์๊ฐ๋๋ค. ํ์ต์ฉ์ด๋ผ ์ผ๋ถ๋ฌ ๋จ์ํ๊ฒ ๋ ๊ฑฐ์์.
lib/ ํด๋๋ฅผ ๋ง๋ค๊ณ ๊ทธ ์์ books.ts ๋ฅผ ๋ง๋ญ๋๋ค.
// lib/books.ts export type Book = { id: number; title: string; author: string; publishedYear: number; }; // dev ๋ชจ๋์ hot-reload ๊ฐ ์ผ์ด๋๋ ๋ฐ์ดํฐ๊ฐ ์ ์ง๋๋๋ก // globalThis ์ ์ ์ฅํฉ๋๋ค. (ํ์ต ๋ฒ์ ๋ฐ์ด๋ผ ๊ทธ๋๋ก ๋ฌ๋ ๋ฉ๋๋ค.) type GlobalStore = { __books?: Book[]; __nextId?: number; }; const store = globalThis as unknown as GlobalStore; if (!store.__books) { store.__books = [ { id: 1, title: "์ด๋ฆฐ ์์", author: "์ํ ์ฅํ๋ฆฌ", publishedYear: 1943 }, { id: 2, title: "๋ฐ๋ฏธ์", author: "ํค๋ฅด๋ง ํค์ธ", publishedYear: 1919 }, { id: 3, title: "1984", author: "์กฐ์ง ์ค์ฐ", publishedYear: 1949 }, ]; store.__nextId = 4; } export function getAllBooks(): Book[] { return store.__books!; } export function getBookById(id: number): Book | undefined { return store.__books!.find((b) => b.id === id); } export function createBook(data: Omit<Book, "id">): Book { const newBook: Book = { id: store.__nextId!, ...data }; store.__nextId!++; store.__books!.push(newBook); return newBook; } export function updateBook( id: number, data: Omit<Book, "id">, ): Book | null { const idx = store.__books!.findIndex((b) => b.id === id); if (idx === -1) return null; store.__books![idx] = { id, ...data }; return store.__books![idx]; }
ํ์ด ๋ณด๊ธฐ
๐น type Book โ ์ฑ
ํ ๊ถ์ ๋ชจ์์ ๋ฏธ๋ฆฌ ์ ์ด ๋ ๊ฒ์
๋๋ค. id, title, author, publishedYear ๋ค ํ๋๋ฅผ ๊ฐ๋ ๊ฐ์ฒด.
๐น getAllBooks, getBookById, createBook, updateBook โ ๊ฐ๊ฐ GET ๋ชฉ๋ก, GET ์์ธ, POST, PUT ๋ผ์ฐํธ์์ ํ ๋ฒ์ฉ ๋ถ๋ฅด๊ฒ ๋ฉ๋๋ค.
๐น globalThis ๋ถ๋ถ โ Next.js dev ์๋ฒ๊ฐ ํ์ผ์ ์์ ํ ๋๋ง๋ค ๋ชจ๋์ ๋ค์ ๋ถ๋ฌ์ค๋๋ฐ, ๊ทธ๋๋ง๋ค books ๋ณ์๊ฐ ์ด๊ธฐํ๋๋ฉด ๊ณค๋ํฉ๋๋ค. globalThis ๋ผ๋ "ํ๋ก์ธ์ค ์ ์ญ" ๊ณต๊ฐ์ ๋ฐ์ดํฐ๋ฅผ ๋๋ฉด hot-reload ๊ฐ ์ผ์ด๋๋ ์ด์๋จ์์. ์ง๊ธ์ "์ด๊ฒ ์ ๋ณด์ด๋ ๊ฒ์ฒ๋ผ ๋ฌด์" ํด๋ ๋ฉ๋๋ค.
โ ํ์ธํ๊ธฐ
ํ๋ฉด ๋ณํ๋ ์์ต๋๋ค. ํ์ผ ํ ๊ฐ๋ฅผ ๋ง๋ค์์ ๋ฟ. TypeScript ๋นจ๊ฐ ์ค์ด ์ ๋จ๋ฉด OK.
STEP 3. GET /api/books โ ์ฑ ๋ชฉ๋ก API
๋๋์ด ์ฒซ Route Handler ์
๋๋ค. app/api/books/route.ts ๋ฅผ ๋ง๋ค์ด์. (app/api/books/ ํด๋๋ฅผ ์๋ก ๋ง๋ค์ด์ผ ํฉ๋๋ค.)
// app/api/books/route.ts import { NextResponse } from "next/server"; import { getAllBooks } from "@/lib/books"; export async function GET() { const books = getAllBooks(); return NextResponse.json({ books }); }
์ด๊ฒ ์ ๋ถ์ ๋๋ค.
ํ์ด ๋ณด๊ธฐ
- ํจ์ ์ด๋ฆ์ด ๋ฐ๋์
GET์ด์ด์ผ ํฉ๋๋ค (๋๋ฌธ์). ๋ค๋ฅธ ์ด๋ฆ์ ์ธ์ ๋ชป ํด์. NextResponse.json(...)๊ฐ JSON ์๋ต์ ๋ง๋ค์ด ์ค๋๋ค.- ์๋ต ๋ชจ์์
{ books }๋ก ํ ์ด์ : ๊ฐ์ฒด๋ก ๊ฐ์ธ๋ฉด ๋์ค์ ๋ค๋ฅธ ํ๋(์:{ books, totalCount })๋ฅผ ์ถ๊ฐํ๊ธฐ ์ข์์. ๊ทธ๋ฅ ๋ฐฐ์ด์ ๋ฐ๋ก ๋๋ ค์ค๋ ๋ฉ๋๋ค.
โ ํ์ธํ๊ธฐ
dev ์๋ฒ๊ฐ ์ผ์ง ์ํ์์ ์ ํฐ๋ฏธ๋์ ์ด๊ณ :
curl http://localhost:3300/api/books
์ด๋ฐ ์๋ต์ด ์์ผ ํฉ๋๋ค:
{"books":[{"id":1,"title":"์ด๋ฆฐ ์์","author":"์ํ ์ฅํ๋ฆฌ","publishedYear":1943}, ...]}
๋ธ๋ผ์ฐ์ ๋ก http://localhost:3300/api/books ๋ฅผ ์ด์ด๋ ๊ฐ์ JSON ์ด ๋ณด์
๋๋ค.
์ ์ ๋๋ฉด?
app/api/books/route.ts์ ์์น(ํด๋ ์ด๋ฆ, ํ์ผ ์ด๋ฆ)๋ฅผ ๋ค์ ํ์ธํ์ธ์.app/api/book/route.ts(book) ์ฒ๋ผ ํ ๊ธ์๋ผ๋ ๋ค๋ฅด๋ฉด URL ์ด ์ ๋ง์์.
STEP 4. ์ฑ ๋ชฉ๋ก ํ๋ฉด โ ์๋ฒ ์ปดํฌ๋ํธ์์ fetch
API ๊ฐ ์ด์ ์์ผ๋ ๊ทธ๊ฑธ ๋ถ๋ฅด๋ ํ๋ฉด์ ๋ง๋ญ์๋ค.
app/books/page.tsx ๋ฅผ ๋ง๋ญ๋๋ค (app/books/ ํด๋๋ถํฐ).
// app/books/page.tsx import Link from "next/link"; type Book = { id: number; title: string; author: string; publishedYear: number; }; async function fetchBooks(): Promise<Book[]> { const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? "http://localhost:3300"; const res = await fetch(`${baseUrl}/api/books`, { cache: "no-store" }); const json = (await res.json()) as { books: Book[] }; return json.books; } export default async function BooksPage() { const books = await fetchBooks(); return ( <main className="mx-auto max-w-2xl px-6 py-12"> <Link href="/" className="text-sm text-amber-600 hover:underline"> โ ํ์ผ๋ก </Link> <div className="mt-2 flex items-center justify-between"> <h1 className="text-2xl font-black">๐ ์ฑ ๋ชฉ๋ก</h1> </div> <p className="mt-1 text-sm text-stone-500"> ์ด {books.length}๊ถ์ ์ฑ ์ด ์์ต๋๋ค. </p> <ul className="mt-6 space-y-3"> {books.map((book) => ( <li key={book.id}> <Link href={`/books/${book.id}`} className="block rounded-lg border border-stone-200 bg-white p-4 hover:border-amber-500" > <h2 className="text-lg font-bold">{book.title}</h2> <p className="text-sm text-stone-600"> {book.author} ยท {book.publishedYear}๋ </p> </Link> </li> ))} </ul> </main> ); }
ํ์ด ๋ณด๊ธฐ
๐น "use client" ๊ฐ ์์ด์ โ ์๋ฒ ์ปดํฌ๋ํธ
์ด ํ์ผ์ ์๋ฒ์์ ์คํ๋ฉ๋๋ค. ๊ทธ๋์ ํจ์์ async ๋ฅผ ๋ถ์ด๊ณ await ์ผ๋ก ๋ฐ์ดํฐ๋ฅผ ๊ฐ์ ธ์ฌ ์ ์์ด์. useState / useEffect ๊ฐ ํ์ ์์ต๋๋ค.
๐น ์ ์ ๋ URL?
fetch("/api/books") ๋ผ๊ณ ์๋ ๊ฒฝ๋ก๋ก ์ฐ๊ณ ์ถ์ง๋ง, ์ด ์ฝ๋๋ Node.js ์๋ฒ์์ ์คํ๋๋ฏ๋ก "์ด๋์ ๋ดค์ ๋ ์๋ ๊ฒฝ๋ก์ธ์ง" ์ ์ ์์ต๋๋ค. ๊ทธ๋์ ์ ๋ URL์ด ํ์ํด์. process.env.NEXT_PUBLIC_BASE_URL ์ด ์ ์๋ผ ์์ผ๋ฉด ๊ทธ๊ฑธ ์ฐ๊ณ , ์์ผ๋ฉด http://localhost:3300 ์ผ๋ก ํด๋ฐฑํฉ๋๋ค.
๐น cache: "no-store"
"์๋ก ๋ค์ด์ฌ ๋๋ง๋ค ๋ฌด์กฐ๊ฑด ๋ค์ ๊ฐ์ ธ์" ๋ผ๋ ์ต์
. ์ฑ
์ ์๋ก ๋ฑ๋กํ์๋ง์ ๋ชฉ๋ก์ ๋ฐ์๋๊ฒ ํ๋ ค๋ฉด ์บ์๋ฅผ ๋๋ ๊ฒ ์ข์ต๋๋ค.
โ ํ์ธํ๊ธฐ
๋ธ๋ผ์ฐ์ ์์ http://localhost:3300/books ๋ก ์ด๋.
- ์ฑ 3๊ถ(์ด๋ฆฐ ์์, ๋ฐ๋ฏธ์, 1984)์ด ๋ณด์ ๋๋ค.
- "์ด 3๊ถ์ ์ฑ ์ด ์์ต๋๋ค." ๊ฐ ํ์๋ฉ๋๋ค.
- ๊ฐ ์ฑ ํญ๋ชฉ์ ํด๋ฆญํ๋ฉด ์์ง ๋ง๋ค์ง ์์ ์์ธ ํ์ด์ง๋ผ 404. ๋ค์ STEP ์์ ๋ง๋ญ๋๋ค.
STEP 5. GET /api/books/[id] โ ์ฑ ์์ธ API
ํน์ ID ์ ์ฑ ํ ๊ถ๋ง ๋๋ ค์ฃผ๋ API ์ ๋๋ค. ๋์ ๋ผ์ฐํธ ๊ฐ ์ฒ์ ๋ฑ์ฅํด์.
ํด๋ ์ด๋ฆ์ ๋๊ดํธ๋ก ๊ฐ์ธ๋ฉด Next.js ๋ ๊ทธ ์๋ฆฌ์ ์ด๋ค ๊ฐ์ด๋ ๋ค์ด์ฌ ์ ์๋ค๊ณ ์ธ์ํฉ๋๋ค.
app/api/books/[id]/route.ts ๋ฅผ ๋ง๋ญ๋๋ค. (ํด๋ ์ด๋ฆ์ด ์ง์ง [id] ์
๋๋ค โ ๋๊ดํธ ํฌํจ!)
// app/api/books/[id]/route.ts import { NextResponse } from "next/server"; import { getBookById } from "@/lib/books"; type RouteContext = { params: Promise<{ id: string }> }; export async function GET(_request: Request, ctx: RouteContext) { const { id } = await ctx.params; const bookId = Number(id); if (!bookId) { return NextResponse.json( { error: "์๋ชป๋ ์ฑ ID ์ ๋๋ค." }, { status: 400 }, ); } const book = getBookById(bookId); if (!book) { return NextResponse.json( { error: "์ฑ ์ ์ฐพ์ ์ ์์ต๋๋ค." }, { status: 404 }, ); } return NextResponse.json({ book }); }
ํ์ด ๋ณด๊ธฐ
๐น ๋ ๋ฒ์งธ ์ธ์ ctx
GET/POST/PUT ํจ์์ ๋ ๋ฒ์งธ ์ธ์์ params ๊ฐ ๋ค์ด ์์ด์. URL /api/books/2 ๋ผ๋ฉด ctx.params ๋ { id: "2" } ๊ฐ์ ๋ชจ์์ด ๋ฉ๋๋ค.
๐น await ctx.params
Next.js 15 ๋ถํฐ params ๊ฐ Promise ๊ฐ ๋์ต๋๋ค. ๊ทธ๋์ await ๊ฐ ํ์ํด์. ํ ๋ฒ๋ง ์ธ์ฐ๋ฉด ๋ฉ๋๋ค.
๐น Number(id)
URL ์์ ๋ค์ด์จ id ๋ ํญ์ ๋ฌธ์์ด์
๋๋ค. "2" ๋ฅผ ์ซ์ 2 ๋ก ๋ฐ๊ฟ์ผ getBookById(2) ์ ๋งค์นญ๋ฉ๋๋ค.
๐น { status: 400 }, { status: 404 }
HTTP ์ํ ์ฝ๋. ์๋ชป๋ ์
๋ ฅ์ด๋ฉด 400, ์์์ด ์์ผ๋ฉด 404. ์ด๋ ๊ฒ ์ ์ด๋๋ฉด fetch ์ชฝ์์ res.ok ๋ก ์ ์/์ค๋ฅ๋ฅผ ํ๋จํ ์ ์์ด์.
๐น _request
์ฌ์ฉ ์ ํ๋ ์ธ์ ์ด๋ฆ ์์ _ ๋ฅผ ๋ถ์ด๋ ๊ด๋ก์
๋๋ค. "์ด ์ธ์๋ ์ผ๋ถ๋ฌ ์ ์จ์" ๋ผ๋ ํ์.
โ ํ์ธํ๊ธฐ
# ์ ์ curl http://localhost:3300/api/books/2 # โ {"book":{"id":2,"title":"๋ฐ๋ฏธ์",...}} # ์๋ ์ฑ curl -i http://localhost:3300/api/books/999 # โ HTTP/1.1 404 Not Found # {"error":"์ฑ ์ ์ฐพ์ ์ ์์ต๋๋ค."}
STEP 6. ์ฑ ์์ธ ํ๋ฉด
app/books/[id]/page.tsx ๋ฅผ ๋ง๋ญ๋๋ค.
// app/books/[id]/page.tsx import Link from "next/link"; import { notFound } from "next/navigation"; type Book = { id: number; title: string; author: string; publishedYear: number; }; type PageProps = { params: Promise<{ id: string }> }; async function fetchBook(id: string): Promise<Book | null> { const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? "http://localhost:3300"; const res = await fetch(`${baseUrl}/api/books/${id}`, { cache: "no-store" }); if (!res.ok) return null; const json = (await res.json()) as { book: Book }; return json.book; } export default async function BookDetailPage({ params }: PageProps) { const { id } = await params; const book = await fetchBook(id); if (!book) notFound(); return ( <main className="mx-auto max-w-2xl px-6 py-12"> <Link href="/books" className="text-sm text-amber-600 hover:underline"> โ ๋ชฉ๋ก์ผ๋ก </Link> <div className="mt-2 flex items-center justify-between"> <h1 className="text-3xl font-black">{book.title}</h1> </div> <p className="mt-2 text-stone-600"> ์ง์์ด <strong>{book.author}</strong> </p> <p className="text-stone-600"> ์ถ๊ฐ <strong>{book.publishedYear}๋ </strong> </p> </main> ); }
ํ์ด ๋ณด๊ธฐ
๐น ํ์ด์ง์ params ๋ Promise
Route Handler ์ ๋๊ฐ์ด ํ์ด์ง์ params ๋ await ๊ฐ ํ์ํฉ๋๋ค.
๐น notFound()
404 ํ์ด์ง๋ก ๋ณด๋ด ์ฃผ๋ ํฌํผ. ์ฑ
์ด ์์ผ๋ฉด ํธ์ถํ๋ฉด Next.js ๊ฐ ์์์ ์ฒ๋ฆฌํฉ๋๋ค.
โ ํ์ธํ๊ธฐ
http://localhost:3300/books/2โ "๋ฐ๋ฏธ์" ์์ธ ํ์ด์งhttp://localhost:3300/books/999โ Next.js ์ ๊ธฐ๋ณธ 404 ํ์ด์ง
STEP 7. POST /api/books โ ์ ์ฑ ๋ฑ๋ก
์ด์ ๋ถํฐ ๋ฐ์ดํฐ๊ฐ ๋ฐ๋๋๋ค. ๊ฐ์ app/api/books/route.ts ํ์ผ์ ์ด์ด POST ํจ์๋ฅผ ์ถ๊ฐ ํฉ๋๋ค.
// app/api/books/route.ts import { NextResponse, type NextRequest } from "next/server"; import { createBook, getAllBooks } from "@/lib/books"; export async function GET() { const books = getAllBooks(); return NextResponse.json({ books }); } export async function POST(request: NextRequest) { // 1) ๋ณธ๋ฌธ ์ฝ๊ธฐ let body: unknown; try { body = await request.json(); } catch { return NextResponse.json( { error: "์๋ชป๋ ์์ฒญ ๋ณธ๋ฌธ์ ๋๋ค (JSON ์ด ์๋๋๋ค)." }, { status: 400 }, ); } // 2) ํ๋ ๊บผ๋ด๊ธฐ const { title, author, publishedYear } = body as { title?: string; author?: string; publishedYear?: number; }; // 3) ๊ฒ์ฆ if (!title || typeof title !== "string" || title.trim().length === 0) { return NextResponse.json( { error: "์ ๋ชฉ(title)์ด ํ์ํฉ๋๋ค." }, { status: 400 }, ); } if (!author || typeof author !== "string" || author.trim().length === 0) { return NextResponse.json( { error: "์ง์์ด(author)๊ฐ ํ์ํฉ๋๋ค." }, { status: 400 }, ); } if (typeof publishedYear !== "number" || !Number.isInteger(publishedYear)) { return NextResponse.json( { error: "์ถ๊ฐ์ฐ๋(publishedYear)๋ ์ ์์ฌ์ผ ํฉ๋๋ค." }, { status: 400 }, ); } // 4) ์ ์ฅ const newBook = createBook({ title: title.trim(), author: author.trim(), publishedYear, }); // 5) 201 Created ๋ก ์๋ต return NextResponse.json({ book: newBook }, { status: 201 }); }
POST ํจ์๋ GET ๋ณด๋ค ๊ธธ์ด ๋ณด์ด์ง๋ง, ๋ค ๋ถ๋ถ์ผ๋ก ๋๋๋ฉด ๋จ์ํฉ๋๋ค.
ํ์ด ๋ณด๊ธฐ
1) await request.json()
ํด๋ผ์ด์ธํธ๊ฐ ๋ณด๋ธ JSON ๋ณธ๋ฌธ์ ์ฝ์ต๋๋ค. ๋ง์ฝ ๋ณธ๋ฌธ์ด JSON ์ด ์๋๋ฉด request.json() ์ด ์์ธ๋ฅผ ๋์ง๋ฏ๋ก try/catch ๋ก ๊ฐ์์ด์.
2) ํ๋ ๊บผ๋ด๊ธฐ
body ์ ํ์
์ unknown ์
๋๋ค (์ธ๋ถ์์ ๋ฐ์ ๊ฑฐ๋๊น ์์ ์ ์ํด). ๊ทธ๋์ as ๋ก "์์ ์๋ ์๋ ํ๋" ๋ฅผ ์ ์ด ์ค๋๋ค.
3) ๊ฒ์ฆ
๋น ๋ฌธ์์ด, ์๋ชป๋ ํ์
, ๋๋ฝ๋ ํ๋๋ฅผ ๋ชจ๋ ๋ง์์ผ ํฉ๋๋ค. ๊ฒ์ฆ ์์ด ๊ทธ๋๋ก ์ ์ฅํ๋ฉด ๋น ์ฑ
์ด ์๋ฉ ๋ค์ด์ฌ ์ ์์ด์.
4) ์ ์ฅ
createBook ํ ๋ฒ ๋ถ๋ฅด๋ฉด ๋.
5) 201 Created
์ ์์์ ๋ง๋ค์์ ๋์ ํ์ค ์ํ ์ฝ๋. ๊ตณ์ด 200 ์ด๋ผ๋ ๋์์ ํ์ง๋ง ๊ด๋ก๋ฅผ ๋ฐ๋ฅด๋ ๊ฒ ์ข์ต๋๋ค.
โ ํ์ธํ๊ธฐ
# ์ ์ ๋ฑ๋ก curl -X POST http://localhost:3300/api/books \ -H "Content-Type: application/json" \ -d '{"title":"๋ ธ๋ฅด์จ์ด์ ์ฒ","author":"๋ฌด๋ผ์นด๋ฏธ ํ๋ฃจํค","publishedYear":1987}' # โ {"book":{"id":4,"title":"๋ ธ๋ฅด์จ์ด์ ์ฒ",...}} # ๊ฒ์ฆ ์คํจ curl -X POST http://localhost:3300/api/books \ -H "Content-Type: application/json" \ -d '{"title":"","author":"x","publishedYear":2000}' # โ {"error":"์ ๋ชฉ(title)์ด ํ์ํฉ๋๋ค."} # ๋ชฉ๋ก์ ์ถ๊ฐ๋๋์ง ํ์ธ curl http://localhost:3300/api/books # โ books ๋ฐฐ์ด์ 4๋ฒ ์ฑ ์ด ์ถ๊ฐ๋ผ ์์ด์ผ ํฉ๋๋ค.
STEP 8. ์ ์ฑ ๋ฑ๋ก ํ๋ฉด โ ํด๋ผ์ด์ธํธ ํผ
API ๊ฐ ๋์ํ๋ ์ฌ๋๋ ์ธ ์ ์๋ ํ๋ฉด์ ๋ง๋ญ๋๋ค. ์
๋ ฅ ํผ์ useState ๊ฐ ํ์ํ๋ฏ๋ก ํด๋ผ์ด์ธํธ ์ปดํฌ๋ํธ ๋ก ๋ง๋ค์ด์.
app/books/new/page.tsx ๋ฅผ ๋ง๋ญ๋๋ค.
// app/books/new/page.tsx "use client"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useState } from "react"; export default function NewBookPage() { const router = useRouter(); const [title, setTitle] = useState(""); const [author, setAuthor] = useState(""); const [year, setYear] = useState(""); const [error, setError] = useState(""); const [submitting, setSubmitting] = useState(false); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); setError(""); setSubmitting(true); try { const res = await fetch("/api/books", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title, author, publishedYear: Number(year), }), }); const json = await res.json(); if (!res.ok) { throw new Error(json.error ?? "๋ฑ๋ก ์คํจ"); } router.push(`/books/${json.book.id}`); router.refresh(); } catch (err) { setError(err instanceof Error ? err.message : "๋ฑ๋ก ์คํจ"); setSubmitting(false); } } return ( <main className="mx-auto max-w-md px-6 py-12"> <Link href="/books" className="text-sm text-amber-600 hover:underline"> โ ๋ชฉ๋ก์ผ๋ก </Link> <h1 className="mt-2 text-2xl font-black">๐ ์ ์ฑ ๋ฑ๋ก</h1> <form onSubmit={handleSubmit} className="mt-6 space-y-4"> <div> <label className="block text-sm font-semibold text-stone-700">์ ๋ชฉ</label> <input type="text" value={title} onChange={(e) => setTitle(e.target.value)} required className="mt-1 w-full rounded-md border border-stone-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-amber-400" /> </div> <div> <label className="block text-sm font-semibold text-stone-700">์ง์์ด</label> <input type="text" value={author} onChange={(e) => setAuthor(e.target.value)} required className="mt-1 w-full rounded-md border border-stone-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-amber-400" /> </div> <div> <label className="block text-sm font-semibold text-stone-700"> ์ถ๊ฐ์ฐ๋ </label> <input type="number" value={year} onChange={(e) => setYear(e.target.value)} required min={0} max={9999} className="mt-1 w-full rounded-md border border-stone-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-amber-400" /> </div> {error && ( <p className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600"> {error} </p> )} <button type="submit" disabled={submitting} className="w-full rounded-md bg-amber-500 px-4 py-2 font-semibold text-white hover:bg-amber-600 disabled:opacity-50" > {submitting ? "๋ฑ๋ก ์ค..." : "๋ฑ๋ก"} </button> </form> </main> ); }
๊ทธ๋ฆฌ๊ณ app/books/page.tsx ์ ํค๋ ๋ถ๋ถ์ ์ด์ง ๊ณ ์ณ "์ ์ฑ
๋ฑ๋ก" ๋ฒํผ์ ๋ณด์ฌ ์ค๋๋ค. h1 ์ด ๋ค์ด ์๋ div ๋ฅผ ํต์งธ๋ก ๋ค์์ผ๋ก ๋ฐ๊ฟ๋๋ค.
<div className="mt-2 flex items-center justify-between"> <h1 className="text-2xl font-black">๐ ์ฑ ๋ชฉ๋ก</h1> <Link href="/books/new" className="rounded-md bg-amber-500 px-3 py-1.5 text-sm font-semibold text-white hover:bg-amber-600" > + ์ ์ฑ ๋ฑ๋ก </Link> </div>
ํ์ด ๋ณด๊ธฐ
๐น "use client"
ํ์ผ ์ฒซ ์ค. ์ด ํ ์ค์ด ์์ด์ผ useState ๋ฅผ ์ธ ์ ์์ต๋๋ค.
๐น fetch("/api/books", { method: "POST", ... })
๋ธ๋ผ์ฐ์ ์์๋ ์๋ ๊ฒฝ๋ก๊ฐ ์ ๋์ํด์. ์ ๋ URL ์ ์จ๋ ๋ฉ๋๋ค.
๐น headers: { "Content-Type": "application/json" }
"๋ณด๋ด๋ ๋ณธ๋ฌธ์ JSON ์
๋๋ค" ๋ผ๋ ํ์. ์ด๊ฑธ ๋นผ๋ฉด ์๋ฒ๊ฐ ๋ณธ๋ฌธ์ ํ
์คํธ๋ก ์ธ์ํด์ request.json() ์ด ์คํจํ ์ ์์ด์.
๐น body: JSON.stringify({...})
JavaScript ๊ฐ์ฒด๋ฅผ JSON ๋ฌธ์์ด๋ก ๋ณํํฉ๋๋ค.
๐น router.push(\/books/${json.book.id}`)`
๋ฑ๋ก ์ฑ๊ณต ํ ์๋ก ๋ง๋ค์ด์ง ์ฑ
์ ์์ธ ํ์ด์ง๋ก ์ด๋.
๐น router.refresh()
์๋ฒ ์ปดํฌ๋ํธ(๋ชฉ๋ก ํ์ด์ง) ์ ๋ฐ์ดํฐ๋ฅผ ๋ค์ ๊ฐ์ ธ์ค๋ผ๊ณ ์ํค๋ ์ ํธ. ์ ๋ถ๋ฅด๋ฉด ๋ค์์ ๋ชฉ๋ก์ผ๋ก ๋์๊ฐ ๋ ์ ์ฑ
์ด ์ ๋ณด์ผ ์๋ ์์ด์.
โ ํ์ธํ๊ธฐ
/books์์ "+ ์ ์ฑ ๋ฑ๋ก" ๋ฒํผ์ด ๋ณด์ ๋๋ค.- ํด๋ฆญ โ
/books/newํผ - "ํ ์ง", "๋ฐ๊ฒฝ๋ฆฌ", "1969" ์ ๋ ฅ โ "๋ฑ๋ก" ํด๋ฆญ
/books/4(๋๋ ๋ ํฐ ID) ์์ธ ํ์ด์ง๋ก ์ด๋- ๋ชฉ๋ก์ผ๋ก ๋์๊ฐ๋ฉด ์ ์ฑ ์ด ์ถ๊ฐ๋ผ ์์ต๋๋ค.
STEP 9. PUT /api/books/[id] โ ์ฑ ์์ API
app/api/books/[id]/route.ts ๋ฅผ ์ด์ด PUT ํจ์๋ฅผ ์ถ๊ฐ ํฉ๋๋ค.
// app/api/books/[id]/route.ts import { NextResponse } from "next/server"; import { getBookById, updateBook } from "@/lib/books"; type RouteContext = { params: Promise<{ id: string }> }; export async function GET(_request: Request, ctx: RouteContext) { const { id } = await ctx.params; const bookId = Number(id); if (!bookId) { return NextResponse.json({ error: "์๋ชป๋ ์ฑ ID ์ ๋๋ค." }, { status: 400 }); } const book = getBookById(bookId); if (!book) { return NextResponse.json({ error: "์ฑ ์ ์ฐพ์ ์ ์์ต๋๋ค." }, { status: 404 }); } return NextResponse.json({ book }); } export async function PUT(request: Request, ctx: RouteContext) { const { id } = await ctx.params; const bookId = Number(id); if (!bookId) { return NextResponse.json({ error: "์๋ชป๋ ์ฑ ID ์ ๋๋ค." }, { status: 400 }); } let body: unknown; try { body = await request.json(); } catch { return NextResponse.json( { error: "์๋ชป๋ ์์ฒญ ๋ณธ๋ฌธ์ ๋๋ค (JSON ์ด ์๋๋๋ค)." }, { status: 400 }, ); } const { title, author, publishedYear } = body as { title?: string; author?: string; publishedYear?: number; }; if (!title || typeof title !== "string" || title.trim().length === 0) { return NextResponse.json({ error: "์ ๋ชฉ(title)์ด ํ์ํฉ๋๋ค." }, { status: 400 }); } if (!author || typeof author !== "string" || author.trim().length === 0) { return NextResponse.json({ error: "์ง์์ด(author)๊ฐ ํ์ํฉ๋๋ค." }, { status: 400 }); } if (typeof publishedYear !== "number" || !Number.isInteger(publishedYear)) { return NextResponse.json( { error: "์ถ๊ฐ์ฐ๋(publishedYear)๋ ์ ์์ฌ์ผ ํฉ๋๋ค." }, { status: 400 }, ); } const updated = updateBook(bookId, { title: title.trim(), author: author.trim(), publishedYear, }); if (!updated) { return NextResponse.json({ error: "์ฑ ์ ์ฐพ์ ์ ์์ต๋๋ค." }, { status: 404 }); } return NextResponse.json({ book: updated }); }
POST ์ ๊ฑฐ์ ๋๊ฐ์ง๋ง ํ ๊ฐ์ง ์ฐจ์ด:
- POST ๋ ์ ์์์ ๋ง๋ ๋ค โ id ๊ฐ ์๋ต์์ ์๊น๋๋ค.
- PUT ์ ์๋ ์์์ ํต์งธ๋ก ๋ฐ๊พผ๋ค โ id ๊ฐ URL ์ ๋ฏธ๋ฆฌ ๋ค์ด ์๊ณ , ๋ณธ๋ฌธ์ id ๋ฅผ ์ ๋ณด๋ ๋๋ค.
โ ํ์ธํ๊ธฐ
# ์ ์ ์์ curl -X PUT http://localhost:3300/api/books/2 \ -H "Content-Type: application/json" \ -d '{"title":"๋ฐ๋ฏธ์ (๊ฐ์ ํ)","author":"ํค๋ฅด๋ง ํค์ธ","publishedYear":1919}' # โ {"book":{"id":2,"title":"๋ฐ๋ฏธ์ (๊ฐ์ ํ)",...}} # ์๋ ์ฑ ์์ curl -i -X PUT http://localhost:3300/api/books/999 \ -H "Content-Type: application/json" \ -d '{"title":"x","author":"y","publishedYear":2000}' # โ HTTP/1.1 404 Not Found # ํ์ธ curl http://localhost:3300/api/books/2 # โ {"book":{"id":2,"title":"๋ฐ๋ฏธ์ (๊ฐ์ ํ)",...}}
STEP 10. ์ฑ ์์ ํ๋ฉด โ ์๋ฒ + ํด๋ผ์ด์ธํธ ์กฐํฉ
์์ ํ๋ฉด์ ๋ ๊ฐ์ง๊ฐ ๋์์ ํ์ํด์:
- ๊ธฐ์กด ์ฑ ๋ฐ์ดํฐ โ ํผ์ ๋ฏธ๋ฆฌ ์ฑ์์ผ ํจ (์๋ฒ ์ปดํฌ๋ํธ์์ fetch)
- ์ฌ์ฉ์ ์ ๋ ฅ โ useState ๊ฐ ํ์ํ ํด๋ผ์ด์ธํธ ํผ
๊ฐ์ฅ ๊น๋ํ ๋ฐฉ๋ฒ: ํ์ผ ๋ ๊ฐ๋ก ๋๋๊ธฐ.
app/books/[id]/edit/ โโ page.tsx โ ์๋ฒ ์ปดํฌ๋ํธ, ์ฑ ๋ฐ์ดํฐ fetch โโ EditBookForm.tsx โ ํด๋ผ์ด์ธํธ ์ปดํฌ๋ํธ, ํผ
10-1. ์๋ฒ ์ปดํฌ๋ํธ โ page.tsx
// app/books/[id]/edit/page.tsx import Link from "next/link"; import { notFound } from "next/navigation"; import EditBookForm from "./EditBookForm"; type Book = { id: number; title: string; author: string; publishedYear: number; }; type PageProps = { params: Promise<{ id: string }> }; async function fetchBook(id: string): Promise<Book | null> { const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? "http://localhost:3300"; const res = await fetch(`${baseUrl}/api/books/${id}`, { cache: "no-store" }); if (!res.ok) return null; const json = (await res.json()) as { book: Book }; return json.book; } export default async function EditBookPage({ params }: PageProps) { const { id } = await params; const book = await fetchBook(id); if (!book) notFound(); return ( <main className="mx-auto max-w-md px-6 py-12"> <Link href={`/books/${book.id}`} className="text-sm text-amber-600 hover:underline" > โ ์์ธ๋ก </Link> <h1 className="mt-2 text-2xl font-black">โ๏ธ ์ฑ ์์ </h1> <EditBookForm bookId={book.id} initialTitle={book.title} initialAuthor={book.author} initialYear={book.publishedYear} /> </main> ); }
์๋ฒ์์ ์ฑ ๋ฐ์ดํฐ๋ฅผ ๋ฐ์ props ๋ก ํผ์ ๋๊ฒจ ์ค๋๋ค. ํผ์ด ์ฒ์๋ถํฐ ๊ธฐ์กด ๊ฐ์ผ๋ก ์ฑ์์ง ์ํ๋ก ๋ณด์ด๊ฒ ํ๋ ํจํด์ด์์.
10-2. ํด๋ผ์ด์ธํธ ์ปดํฌ๋ํธ โ EditBookForm.tsx
// app/books/[id]/edit/EditBookForm.tsx "use client"; import { useRouter } from "next/navigation"; import { useState } from "react"; type Props = { bookId: number; initialTitle: string; initialAuthor: string; initialYear: number; }; export default function EditBookForm({ bookId, initialTitle, initialAuthor, initialYear, }: Props) { const router = useRouter(); const [title, setTitle] = useState(initialTitle); const [author, setAuthor] = useState(initialAuthor); const [year, setYear] = useState(String(initialYear)); const [error, setError] = useState(""); const [submitting, setSubmitting] = useState(false); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); setError(""); setSubmitting(true); try { const res = await fetch(`/api/books/${bookId}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title, author, publishedYear: Number(year), }), }); const json = await res.json(); if (!res.ok) { throw new Error(json.error ?? "์์ ์คํจ"); } router.push(`/books/${bookId}`); router.refresh(); } catch (err) { setError(err instanceof Error ? err.message : "์์ ์คํจ"); setSubmitting(false); } } return ( <form onSubmit={handleSubmit} className="mt-6 space-y-4"> <div> <label className="block text-sm font-semibold text-stone-700">์ ๋ชฉ</label> <input type="text" value={title} onChange={(e) => setTitle(e.target.value)} required className="mt-1 w-full rounded-md border border-stone-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-amber-400" /> </div> <div> <label className="block text-sm font-semibold text-stone-700">์ง์์ด</label> <input type="text" value={author} onChange={(e) => setAuthor(e.target.value)} required className="mt-1 w-full rounded-md border border-stone-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-amber-400" /> </div> <div> <label className="block text-sm font-semibold text-stone-700"> ์ถ๊ฐ์ฐ๋ </label> <input type="number" value={year} onChange={(e) => setYear(e.target.value)} required min={0} max={9999} className="mt-1 w-full rounded-md border border-stone-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-amber-400" /> </div> {error && ( <p className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600"> {error} </p> )} <div className="flex gap-2"> <button type="button" onClick={() => router.back()} className="flex-1 rounded-md border border-stone-300 px-4 py-2 font-semibold text-stone-700 hover:bg-stone-100" > ์ทจ์ </button> <button type="submit" disabled={submitting} className="flex-1 rounded-md bg-amber-500 px-4 py-2 font-semibold text-white hover:bg-amber-600 disabled:opacity-50" > {submitting ? "์ ์ฅ ์ค..." : "์ ์ฅ"} </button> </div> </form> ); }
10-3. ์์ธ ํ์ด์ง์ "์์ " ๋ฒํผ ๋ฌ๊ธฐ
app/books/[id]/page.tsx ์์ h1 ๋ถ๋ถ์ ๋ค์์ผ๋ก ๋ฐ๊ฟ๋๋ค.
<div className="mt-2 flex items-center justify-between"> <h1 className="text-3xl font-black">{book.title}</h1> <Link href={`/books/${book.id}/edit`} className="rounded-md border border-stone-300 px-3 py-1.5 text-sm font-semibold text-stone-700 hover:bg-stone-100" > ์์ </Link> </div>
ํ์ด ๋ณด๊ธฐ
๐น useState(initialTitle)
์ด๊น๊ฐ์ผ๋ก props ๋ฅผ ๋๊ธฐ๋ฉด ํผ์ด ๊ทธ ๊ฐ์ผ๋ก ์์ํฉ๋๋ค. ์ฌ์ฉ์๊ฐ ํ์ดํํ๋ฉด ๊ทธ๋๋ถํฐ setState ๊ฐ ์ผํ๊ธฐ ์์ํด์.
๐น ์ ๋ ํ์ผ๋ก ๋๋ ?
ํ ํ์ผ์์ ๋ค ํ๋ ค๋ฉด "use client" ์์์ await fetch ๋ฅผ ํด์ผ ํ๋๋ฐ, ๊ทธ๋ฌ๋ ค๋ฉด useState + useEffect ๊ฐ ํ์ํฉ๋๋ค. ์๋ฒ์์ ๋ฐ์ props ๋ก ๋๊ธฐ๋ฉด ๊น๋ํ๊ฒ ๋ฉ๋๋ค.
๐น router.back()
์ทจ์ ๋ฒํผ์ ์ง์ ํ์ด์ง๋ก ๋์๊ฐ๊ธฐ. ์๋ก ์ง์
ํ ๊ฒฝ๋ก๊ฐ ์์ผ๋ฉด ๋์ํ์ง ์์ ์๋ ์์ผ๋ ์ค๋ฌด์์๋ router.push("/books") ์ฒ๋ผ ๋ช
์์ ์ธ ๊ฒฝ๋ก๊ฐ ๋ ์์ ํฉ๋๋ค.
โ ํ์ธํ๊ธฐ
- ์์ธ ํ์ด์ง
/books/2โ "์์ " ๋ฒํผ ํด๋ฆญ - ํผ์ด ๋ฐ๋ฏธ์ / ํค๋ฅด๋ง ํค์ธ / 1919 ๋ก ๋ฏธ๋ฆฌ ์ฑ์์ ธ ์์ต๋๋ค.
- ์ ๋ชฉ์ "๋ฐ๋ฏธ์ (2ํ)" ์ผ๋ก ๋ฐ๊พธ๊ณ ์ ์ฅ
- ๋ค์ ์์ธ ํ์ด์ง๋ก ์ด๋, ์ ๋ชฉ์ด ๋ฐ๋์ด ์์ต๋๋ค.
- ๋ชฉ๋ก์ผ๋ก ๋์๊ฐ๋ ์ ๋ชฉ์ด ๋ฐ๋์ด ์์ต๋๋ค.
๐ ๋ง๋ฌด๋ฆฌ โ ์ฐ๋ฆฌ๊ฐ ๋ง๋ ๊ฒ
app/ โโ page.tsx โ ํ โโ api/ โ โโ books/ โ โโ route.ts โ GET ๋ชฉ๋ก + POST ๋ฑ๋ก โ โโ [id]/ โ โโ route.ts โ GET ์์ธ + PUT ์์ โโ books/ โโ page.tsx โ ์ฑ ๋ชฉ๋ก ํ๋ฉด โโ new/page.tsx โ ์ฑ ๋ฑ๋ก ํผ โโ [id]/ โโ page.tsx โ ์ฑ ์์ธ ํ๋ฉด โโ edit/ โโ page.tsx โ ์ฑ ์์ (์๋ฒ fetch) โโ EditBookForm.tsx โ ์ฑ ์์ (ํด๋ผ์ด์ธํธ ํผ) lib/ โโ books.ts โ ๋ฉ๋ชจ๋ฆฌ ๋ฐ์ดํฐ ์ ์ฅ์
์ธ์๋ ๋ค์ฏ ๊ฐ์ง
app/api/.../route.ts= Route Handler.export async function GET/POST/PUT์ ๋ด๋ณด๋ธ๋ค.- ๋ ๋ฒ์งธ ์ธ์์
ctx.params๊ฐ ๋์ ๋ผ์ฐํธ์ ๋ณ์ ๊ฐ.await๊ฐ ํ์. - POST/PUT ๋ณธ๋ฌธ ์
await request.json()์ผ๋ก ์ฝ๊ณ , ๋ฐ๋์ ๊ฒ์ฆ. NextResponse.json(data, { status: N })๋ก JSON + ์ํ์ฝ๋ ์๋ต.- ์๋ฒ ์ปดํฌ๋ํธ์ fetch ๋ ์ ๋ URL, ํด๋ผ์ด์ธํธ์ fetch ๋ ์๋ URL ๋ก.
์์ฃผ ๋ง๋๋ ์๋ฌ
Q. /api/books ๊ฐ 404 ์
๋๋ค.
โ ํ์ผ ์ด๋ฆ์ด route.ts ๊ฐ ๋ง๋์ง ํ์ธ. routes.ts (s ๋ถ์) ๋๋ Route.ts (๋๋ฌธ์) ๋ฉด ์ธ์ ์ ๋ฉ๋๋ค.
Q. cookies() ๊ฐ ์ด์ฉ๊ณ ํ๋ ์๋ฌ
โ ์ฐ๋ฆฌ ํํ ๋ฆฌ์ผ์์ ์ ์๋๋ค. ๋น์ทํ ์๋ฌ๋ก params ๊ฐ ์ ํ๋ฆฌ๋ฉด await ctx.params ํ๋์ง ํ์ธ.
Q. POST ๊ฐ 400 ์ผ๋ก ๋จ์ด์ง๋๋ค.
โ ์์ฒญ ํค๋์ Content-Type: application/json ์ด ์๋์ง, ๋ณธ๋ฌธ์ด ์ง์ง JSON ์ธ์ง ํ์ธ.
Q. ํผ ๋ฑ๋ก์ ๋๋๋ฐ ๋ชฉ๋ก์ ์ ๋ณด์
๋๋ค.
โ router.refresh() ๋ฅผ ๋ถ๋ฅด์ธ์. ๋๋ ์๋ฒ ์ปดํฌ๋ํธ์ fetch ์ cache: "no-store" ๊ฐ ์๋์ง ํ์ธ.
Q. ์๋ฒ๋ฅผ ๋ค์ ์์ํ๋๋ ๋ฑ๋กํ ์ฑ
์ด ์ฌ๋ผ์ก์ด์.
โ ๋ฉ๋ชจ๋ฆฌ ์ ์ฅ์๋ผ ์ ์์
๋๋ค. ์ค์ ์๋น์ค์์ DB ๋ฅผ ์๋๋ค.
๋ค์ ๋์
- DELETE ์ถ๊ฐ โ ๊ฐ์
[id]/route.ts์export async function DELETE(...)ํ ๋ฒ ๋. - ๊ฒ์ โ
GET /api/books?q=์ด๋ฆฐ์ฒ๋ผ query ๋ฐ๊ธฐ (request.nextUrl.searchParams.get("q")). - ํ์ด์ง โ
?page=0&size=10์ถ๊ฐ. - DB ๋ก ์ฎ๊ธฐ๊ธฐ โ ๋ฉ๋ชจ๋ฆฌ โ SQLite โ Postgres ์์ผ๋ก ํ์ฅ.
์๊ณ ํ์ จ์ด์. ์ด์ API ๋ง๋๋ ๊ฒ ๋ ์ด์ ์ด๋ ต์ง ์์ ๊ฑฐ์์ ๐
๋๊ธ
๋๊ธ์ ์์ฑํ๋ ค๋ฉด ์ด ํ์ํฉ๋๋ค.