๋ฉ”์ธ ์ฝ˜ํ…์ธ ๋กœ ๋ฐ”๋กœ๊ฐ€๊ธฐ
๋…ธํŠธ ๋ชฉ๋ก

๐Ÿ“š ์ฑ… ๊ด€๋ฆฌ API ํ•œ ๋‹จ๊ณ„์”ฉ ๋งŒ๋“ค๊ธฐ โ€” Next.js Route Handler ํŠœํ† ๋ฆฌ์–ผ

๊น€์„ฑ๋ฐ•840
๋ชฉ์ฐจ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.tsxHTML ํ™”๋ฉด์‚ฌ์šฉ์ž๊ฐ€ ์ฃผ์†Œ์ฐฝ์— URL ์„ ์ž…๋ ฅ
Route Handlerroute.tsJSON ๋ฐ์ดํ„ฐ์ฝ”๋“œ์—์„œ 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()
์„œ๋ฒ„ ์ปดํฌ๋„ŒํŠธ(๋ชฉ๋ก ํŽ˜์ด์ง€) ์˜ ๋ฐ์ดํ„ฐ๋ฅผ ๋‹ค์‹œ ๊ฐ€์ ธ์˜ค๋ผ๊ณ  ์‹œํ‚ค๋Š” ์‹ ํ˜ธ. ์•ˆ ๋ถ€๋ฅด๋ฉด ๋‹ค์Œ์— ๋ชฉ๋ก์œผ๋กœ ๋Œ์•„๊ฐˆ ๋•Œ ์ƒˆ ์ฑ…์ด ์•ˆ ๋ณด์ผ ์ˆ˜๋„ ์žˆ์–ด์š”.

โœ… ํ™•์ธํ•˜๊ธฐ

  1. /books ์—์„œ "+ ์ƒˆ ์ฑ… ๋“ฑ๋ก" ๋ฒ„ํŠผ์ด ๋ณด์ž…๋‹ˆ๋‹ค.
  2. ํด๋ฆญ โ†’ /books/new ํผ
  3. "ํ† ์ง€", "๋ฐ•๊ฒฝ๋ฆฌ", "1969" ์ž…๋ ฅ โ†’ "๋“ฑ๋ก" ํด๋ฆญ
  4. /books/4 (๋˜๋Š” ๋” ํฐ ID) ์ƒ์„ธ ํŽ˜์ด์ง€๋กœ ์ด๋™
  5. ๋ชฉ๋ก์œผ๋กœ ๋Œ์•„๊ฐ€๋ฉด ์ƒˆ ์ฑ…์ด ์ถ”๊ฐ€๋ผ ์žˆ์Šต๋‹ˆ๋‹ค.

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. ์ฑ… ์ˆ˜์ • ํ™”๋ฉด โ€” ์„œ๋ฒ„ + ํด๋ผ์ด์–ธํŠธ ์กฐํ•ฉ

์ˆ˜์ • ํ™”๋ฉด์€ ๋‘ ๊ฐ€์ง€๊ฐ€ ๋™์‹œ์— ํ•„์š”ํ•ด์š”:

  1. ๊ธฐ์กด ์ฑ… ๋ฐ์ดํ„ฐ โ€” ํผ์— ๋ฏธ๋ฆฌ ์ฑ„์›Œ์•ผ ํ•จ (์„œ๋ฒ„ ์ปดํฌ๋„ŒํŠธ์—์„œ fetch)
  2. ์‚ฌ์šฉ์ž ์ž…๋ ฅ โ€” 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") ์ฒ˜๋Ÿผ ๋ช…์‹œ์ ์ธ ๊ฒฝ๋กœ๊ฐ€ ๋” ์•ˆ์ „ํ•ฉ๋‹ˆ๋‹ค.

โœ… ํ™•์ธํ•˜๊ธฐ

  1. ์ƒ์„ธ ํŽ˜์ด์ง€ /books/2 โ†’ "์ˆ˜์ •" ๋ฒ„ํŠผ ํด๋ฆญ
  2. ํผ์ด ๋ฐ๋ฏธ์•ˆ / ํ—ค๋ฅด๋งŒ ํ—ค์„ธ / 1919 ๋กœ ๋ฏธ๋ฆฌ ์ฑ„์›Œ์ ธ ์žˆ์Šต๋‹ˆ๋‹ค.
  3. ์ œ๋ชฉ์„ "๋ฐ๋ฏธ์•ˆ (2ํŒ)" ์œผ๋กœ ๋ฐ”๊พธ๊ณ  ์ €์žฅ
  4. ๋‹ค์‹œ ์ƒ์„ธ ํŽ˜์ด์ง€๋กœ ์ด๋™, ์ œ๋ชฉ์ด ๋ฐ”๋€Œ์–ด ์žˆ์Šต๋‹ˆ๋‹ค.
  5. ๋ชฉ๋ก์œผ๋กœ ๋Œ์•„๊ฐ€๋„ ์ œ๋ชฉ์ด ๋ฐ”๋€Œ์–ด ์žˆ์Šต๋‹ˆ๋‹ค.

๐ŸŽ‰ ๋งˆ๋ฌด๋ฆฌ โ€” ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ๊ฒƒ

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 โ† ๋ฉ”๋ชจ๋ฆฌ ๋ฐ์ดํ„ฐ ์ €์žฅ์†Œ

์™ธ์›Œ๋‘˜ ๋‹ค์„ฏ ๊ฐ€์ง€

  1. app/api/.../route.ts = Route Handler. export async function GET/POST/PUT ์„ ๋‚ด๋ณด๋‚ธ๋‹ค.
  2. ๋‘ ๋ฒˆ์งธ ์ธ์ž์˜ ctx.params ๊ฐ€ ๋™์  ๋ผ์šฐํŠธ์˜ ๋ณ€์ˆ˜ ๊ฐ’. await ๊ฐ€ ํ•„์š”.
  3. POST/PUT ๋ณธ๋ฌธ ์€ await request.json() ์œผ๋กœ ์ฝ๊ณ , ๋ฐ˜๋“œ์‹œ ๊ฒ€์ฆ.
  4. NextResponse.json(data, { status: N }) ๋กœ JSON + ์ƒํƒœ์ฝ”๋“œ ์‘๋‹ต.
  5. ์„œ๋ฒ„ ์ปดํฌ๋„ŒํŠธ์˜ 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 ๋งŒ๋“œ๋Š” ๊ฒŒ ๋” ์ด์ƒ ์–ด๋ ต์ง€ ์•Š์„ ๊ฑฐ์˜ˆ์š” ๐Ÿ“š

๋Œ“๊ธ€

๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...

๋Œ“๊ธ€์„ ์ž‘์„ฑํ•˜๋ ค๋ฉด ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.