Syw.Frontend

๐Ÿ“˜ Next.js ๊ธฐ์ดˆ ๊ฐ•์ขŒ

1๋‹จ๊ณ„. Next.js ์ž…๋ฌธ (๊ฐœ๋… & ํ™œ์šฉ ๊ธฐ์ดˆ)

1-6. API Routes ๋งŒ๋“ค๊ธฐ & ํด๋ผ์ด์–ธํŠธ ์—ฐ๋™

0) ๊ฐœ๋… ํ•œ ์ค„ ์š”์•ฝ

  • app/api/**/route.ts ํŒŒ์ผ์„ ๋งŒ๋“ค๋ฉด ์„œ๋ฒ„ ๋ผ์šฐํŠธ๊ฐ€ ๋ฉ๋‹ˆ๋‹ค.
  • HTTP ๋ฉ”์„œ๋“œ๋ณ„๋กœ export async function GET/POST/DELETE๋ฅผ ๊ตฌํ˜„ํ•ฉ๋‹ˆ๋‹ค.
  • ํด๋ผ์ด์–ธํŠธ์—์„œ๋Š” fetch('/api/...')๋กœ ํ˜ธ์ถœํ•ฉ๋‹ˆ๋‹ค.

1) ์„œ๋ฒ„: Todo API ๋งŒ๋“ค๊ธฐ

(1) ๊ฐ„๋‹จํ•œ ๋ฉ”๋ชจ๋ฆฌ ์ €์žฅ์†Œ (ํ•™์Šต์šฉ)

์„œ๋ฒ„๋ฆฌ์Šค ํ™˜๊ฒฝ์—์„œ๋Š” ํ˜ธ์ถœ๋งˆ๋‹ค ์ดˆ๊ธฐํ™”๋  ์ˆ˜ ์žˆ์œผ๋‹ˆ ํ•™์Šต/๊ฐœ๋… ํ™•์ธ์šฉ์ž…๋‹ˆ๋‹ค.

app/api/todos/store.ts

export type Todo = { id: string; text: string; createdAt: number };

let todos: Todo[] = [];

export const store = {
  findAll: () => todos.slice().sort((a, b) => b.createdAt - a.createdAt),
  add: (text: string) => {
    const t = { id: crypto.randomUUID(), text, createdAt: Date.now() };
    todos.push(t);
    return t;
  },
  remove: (id: string) => {
    const before = todos.length;
    todos = todos.filter((t) => t.id !== id);
    return todos.length !== before;
  },
};

(2) ๋ชฉ๋ก ์กฐํšŒ / ์ƒ์„ฑ (GET, POST)

app/api/todos/route.ts

import { NextResponse } from "next/server";
import { store } from "./store";

export async function GET() {
  // ์บ์‹œ ๋น„ํ™œ์„ฑํ™” (ํ•ญ์ƒ ์ตœ์‹  ๋ฐ์ดํ„ฐ)
  return NextResponse.json(store.findAll(), {
    headers: { "Cache-Control": "no-store" },
  });
}

export async function POST(req: Request) {
  try {
    const body = await req.json();
    const text = String(body?.text ?? "").trim();
    if (!text) {
      return NextResponse.json({ error: "text is required" }, { status: 400 });
    }
    const created = store.add(text);
    return NextResponse.json(created, { status: 201 });
  } catch {
    return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
  }
}

(3) ๊ฐœ๋ณ„ ์‚ญ์ œ (DELETE)

app/api/todos/[id]/route.ts

import { NextResponse } from "next/server";
import { store } from "../store";

type Params = { params: { id: string } };

export async function DELETE(_: Request, { params }: Params) {
  const ok = store.remove(params.id);
  if (!ok) return NextResponse.json({ error: "Not Found" }, { status: 404 });
  return NextResponse.json({ ok: true });
}

โœ… ์—ฌ๊ธฐ๊นŒ์ง€๋กœ /api/todos (GET/POST), /api/todos/:id (DELETE) ์ค€๋น„ ์™„๋ฃŒ!


2) ํด๋ผ์ด์–ธํŠธ: API ์—ฐ๋™ Todo ํŽ˜์ด์ง€

5๊ฐ•์˜ ๋กœ์ปฌ ์ƒํƒœ๋งŒ ์“ฐ๋˜ ์˜ˆ์ œ๋ฅผ API ํ˜ธ์ถœ ๊ธฐ๋ฐ˜์œผ๋กœ ๋ฐ”๊ฟ‰๋‹ˆ๋‹ค.

(์Šคํƒ€์ผ์€ 5๊ฐ•์—์„œ ๋งŒ๋“  card.module.scss, todo.module.scss๋ฅผ ์žฌ์‚ฌ์šฉ)

app/todo-api/page.tsx

"use client";

import { useEffect, useState } from "react";
import card from "../../styles/card.module.scss";
import styles from "../../styles/todo.module.scss";

type Todo = { id: string; text: string; createdAt: number };

export default function TodoApiPage() {
  const [todos, setTodos] = useState<Todo[]>([]);
  const [input, setInput] = useState("");
  const [loading, setLoading] = useState(true);
  const [submitting, setSubmitting] = useState(false);

  const load = async () => {
    setLoading(true);
    try {
      const res = await fetch("/api/todos", { cache: "no-store" });
      const data = await res.json();
      setTodos(data);
    } finally {
      setLoading(false);
    }
  };

  const add = async (e: React.FormEvent) => {
    e.preventDefault();
    const text = input.trim();
    if (!text) return;
    setSubmitting(true);
    try {
      const res = await fetch("/api/todos", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ text }),
      });
      if (!res.ok) {
        const err = await res.json().catch(() => ({}));
        alert(err?.error || "์ถ”๊ฐ€ ์‹คํŒจ");
        return;
      }
      setInput("");
      await load();
    } finally {
      setSubmitting(false);
    }
  };

  const remove = async (id: string) => {
    const res = await fetch(`/api/todos/${id}`, { method: "DELETE" });
    if (!res.ok) {
      alert("์‚ญ์ œ ์‹คํŒจ");
      return;
    }
    // ๋‚™๊ด€์  ์—…๋ฐ์ดํŠธ (๋ฆฌ์ŠคํŠธ์—์„œ ์ฆ‰์‹œ ์ œ๊ฑฐ)
    setTodos((prev) => prev.filter((t) => t.id !== id));
  };

  useEffect(() => {
    load();
  }, []);

  return (
    <section className={card.card}>
      <h1 className={card.title}>Todo ์•ฑ (API ์—ฐ๋™)</h1>
      <p className={card.desc}>API Routes๋กœ ์ƒ์„ฑ/์กฐํšŒ/์‚ญ์ œ๋ฅผ ๊ตฌํ˜„ํ–ˆ์Šต๋‹ˆ๋‹ค.</p>

      <form className={styles.form} onSubmit={add}>
        <inputplaceholder="ํ•  ์ผ์„ ์ž…๋ ฅํ•˜์„ธ์š”"
          value={input}
          onChange={(e) => setInput(e.target.value)}
          aria-label="ํ•  ์ผ ์ž…๋ ฅ"
          disabled={submitting}
        />
        <button className={styles.add} type="submit" disabled={submitting}>
          {submitting ? "์ถ”๊ฐ€ ์ค‘..." : "์ถ”๊ฐ€"}
        </button>
      </form>

      {loading ? (
        <p style={{ marginTop: "1rem" }}>๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...</p>
      ) : (
        <div className={card.list}>
          {todos.length === 0 && <p>ํ•  ์ผ์ด ์—†์Šต๋‹ˆ๋‹ค.</p>}
          {todos.map((todo) => (
            <div key={todo.id} className={styles.item}>
              <span className={styles.text}>{todo.text}</span>
              <button className={styles.remove} onClick={() => remove(todo.id)}>
                ์‚ญ์ œ
              </button>
            </div>
          ))}
        </div>
      )}
    </section>
  );
}

3) ์บ์‹œ ์ „๋žต ๊ฐ„๋‹จ ๊ฐ€์ด๋“œ

  • API ์‘๋‹ต ์ตœ์‹ ํ™”๊ฐ€ ์ค‘์š”ํ•˜๋ฉด
    • ์„œ๋ฒ„: Cache-Control: no-store ํ—ค๋”(์œ„ ์˜ˆ์‹œ)
    • ํด๋ผ์ด์–ธํŠธ: fetch(..., { cache: "no-store" })
  • ์ •์ /์ฃผ๊ธฐ์  ์žฌ๊ฒ€์ฆ์ด ์ข‹๋‹ค๋ฉด
    • fetch(url, { next: { revalidate: 60 } }) ์‚ฌ์šฉ(ISR ๋А๋‚Œ)

4) ์˜ค๋ฅ˜/์—ฃ์ง€ ์ผ€์ด์Šค ํŒ

  • ์„œ๋ฒ„์—์„œ JSON ํŒŒ์‹ฑ ์˜ค๋ฅ˜: try/catch๋กœ 400 ์‘๋‹ต
  • ํ•„์ˆ˜ ํ•„๋“œ ๊ฒ€์ฆ: ๋นˆ ๋ฌธ์ž์—ด ๋“ฑ ๋ฐฉ์ง€
  • ์„œ๋ฒ„๋ฆฌ์Šค ์ฃผ์˜: ๋ฉ”๋ชจ๋ฆฌ ์ €์žฅ์†Œ๋Š” ํ˜ธ์ถœ๋งˆ๋‹ค ์ดˆ๊ธฐํ™” ๊ฐ€๋Šฅ โ†’ DB๋กœ ๊ต์ฒด ํ•„์š”

5) ์˜ค๋Š˜์˜ ์ •๋ฆฌ

  • app/api/**/route.ts๋กœ ์„œ๋ฒ„ ์—”๋“œํฌ์ธํŠธ ์ •์˜
  • GET/POST/DELETE ๊ตฌํ˜„์œผ๋กœ Todo API ์™„์„ฑ
  • ํด๋ผ์ด์–ธํŠธ ํŽ˜์ด์ง€์—์„œ fetch๋กœ ์ƒ์„ฑ/์กฐํšŒ/์‚ญ์ œ ์—ฐ๋™
  • ์บ์‹œ ์ „๋žต๊ณผ ์„œ๋ฒ„๋ฆฌ์Šค ํŠน์„ฑ ์ดํ•ด

๐Ÿ“š ์ €์žฅ์†Œ

GitHub - heroyooi/next-tutorial at ch1_6

๐Ÿ’ฌ ๋Œ“๊ธ€

    โ€ป ๋กœ๊ทธ์ธ ํ›„ ๋Œ“๊ธ€์„ ์ž‘์„ฑํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.