📚 책 관리 API 한 단계씩 만들기 — Next.js Route Handler 튜토리얼

읽는 분: 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 만드는 게 더 이상 어렵지 않을 거예요 📚

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