---
title: CRUD API 만들기
description: Mandu의 Contract와 Filling API를 활용하여 타입 안전한 CRUD API를 처음부터 끝까지 만들어 봅니다.
---

이 가이드에서는 간단한 Todo 앱의 CRUD API를 만들어 봅니다. Contract로 API 명세를 정의하고, Filling으로 서버 로직을 구현한 뒤, Island으로 클라이언트 UI를 연결합니다.

## 1. 프로젝트 설정

```bash
bun create mandu todo-app
cd todo-app
```

## 2. 스키마 정의

먼저 공유 스키마를 정의합니다. 이 스키마는 Contract, 타입 추론, 런타임 검증에 모두 사용됩니다.

```typescript
// src/shared/types/todo.ts
import { z } from "zod";

export const TodoSchema = z.object({
  id: z.string().uuid(),
  title: z.string().min(1, "제목을 입력하세요"),
  done: z.boolean().default(false),
  createdAt: z.string().datetime(),
});

export const CreateTodoSchema = TodoSchema.omit({
  id: true,
  createdAt: true,
});

export type Todo = z.infer<typeof TodoSchema>;
export type CreateTodo = z.infer<typeof CreateTodoSchema>;
```

## 3. Contract 정의

API의 요청/응답 스키마를 Contract로 명세합니다.

```typescript
// src/shared/contracts/todo.contract.ts
import { Mandu } from "@mandujs/core";
import { z } from "zod";
import { TodoSchema, CreateTodoSchema } from "../types/todo";

export default Mandu.contract({
  description: "Todo CRUD API",
  tags: ["todos"],

  request: {
    GET: {
      query: z.object({
        page: z.coerce.number().default(1),
        limit: z.coerce.number().default(20),
        done: z.coerce.boolean().optional(),
      }),
    },
    POST: {
      body: CreateTodoSchema,
    },
    PUT: {
      body: z.object({
        id: z.string().uuid(),
        title: z.string().min(1).optional(),
        done: z.boolean().optional(),
      }),
    },
    DELETE: {
      body: z.object({
        id: z.string().uuid(),
      }),
    },
  },

  response: {
    200: z.object({ todos: z.array(TodoSchema) }),
    201: z.object({ todo: TodoSchema }),
    400: z.object({ error: z.string() }),
    404: z.object({ error: z.string() }),
  },
});
```

## 4. 서버 로직 (Filling)

Contract에 맞춰 서버 로직을 Filling으로 작성합니다.

```typescript
// app/api/todos/route.ts
import { Mandu } from "@mandujs/core";
import type { Todo } from "../../../src/shared/types/todo";

// 간단한 메모리 스토어 (실제로는 DB 사용)
const todos: Todo[] = [];

export default Mandu.filling()
  .purpose("Todo CRUD API")
  .tags("todos", "crud")

  // GET /api/todos - 목록 조회
  .get(async (ctx) => {
    const page = Number(ctx.query.page ?? 1);
    const limit = Number(ctx.query.limit ?? 20);
    const done = ctx.query.done;

    let filtered = todos;
    if (done !== undefined) {
      filtered = todos.filter((t) => t.done === (done === "true"));
    }

    const start = (page - 1) * limit;
    const paged = filtered.slice(start, start + limit);

    return ctx.json({ todos: paged });
  })

  // POST /api/todos - 생성
  .post(async (ctx) => {
    const body = await ctx.body<{ title: string; done?: boolean }>();

    const todo: Todo = {
      id: crypto.randomUUID(),
      title: body.title,
      done: body.done ?? false,
      createdAt: new Date().toISOString(),
    };

    todos.push(todo);
    return ctx.json({ todo }, 201);
  })

  // PUT /api/todos - 수정
  .put(async (ctx) => {
    const body = await ctx.body<{
      id: string;
      title?: string;
      done?: boolean;
    }>();

    const index = todos.findIndex((t) => t.id === body.id);
    if (index === -1) {
      return ctx.json({ error: "Todo를 찾을 수 없습니다" }, 404);
    }

    if (body.title !== undefined) todos[index].title = body.title;
    if (body.done !== undefined) todos[index].done = body.done;

    return ctx.json({ todos });
  })

  // DELETE /api/todos - 삭제
  .delete(async (ctx) => {
    const body = await ctx.body<{ id: string }>();
    const index = todos.findIndex((t) => t.id === body.id);

    if (index === -1) {
      return ctx.json({ error: "Todo를 찾을 수 없습니다" }, 404);
    }

    todos.splice(index, 1);
    return ctx.json({ todos });
  });
```

## 5. 페이지 (SSR + Loader)

Loader로 서버 데이터를 가져오고 페이지 컴포넌트에 주입합니다.

```typescript
// app/todos/route.ts
import { Mandu } from "@mandujs/core";

export default Mandu.filling()
  .loader(async (ctx) => {
    const res = await fetch("http://localhost:3333/api/todos");
    const data = await res.json();
    return { todos: data.todos };
  });
```

```tsx
// app/todos/page.tsx
import TodoList from "./todo-list.island";
import type { Todo } from "../../src/shared/types/todo";

interface Props {
  todos: Todo[];
}

export default function TodosPage({ todos }: Props) {
  return (
    <div>
      <h1>할 일 목록</h1>
      <TodoList initialTodos={todos} />
    </div>
  );
}
```

## 6. Island (클라이언트 상호작용)

클라이언트에서 상호작용이 필요한 부분을 Island으로 작성합니다.

```tsx
// app/todos/todo-list.island.tsx
import { island } from "@mandujs/core";
import { useState } from "react";
import type { Todo } from "../../src/shared/types/todo";

export default island("load", ({ initialTodos }: { initialTodos: Todo[] }) => {
  const [todos, setTodos] = useState(initialTodos);
  const [title, setTitle] = useState("");

  const addTodo = async () => {
    if (!title.trim()) return;
    const res = await fetch("/api/todos", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ title }),
    });
    const { todo } = await res.json();
    setTodos((prev) => [...prev, todo]);
    setTitle("");
  };

  const toggleTodo = async (id: string, done: boolean) => {
    await fetch("/api/todos", {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ id, done: !done }),
    });
    setTodos((prev) =>
      prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t))
    );
  };

  const deleteTodo = async (id: string) => {
    await fetch("/api/todos", {
      method: "DELETE",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ id }),
    });
    setTodos((prev) => prev.filter((t) => t.id !== id));
  };

  return (
    <div>
      <div style={{ display: "flex", gap: "8px", marginBottom: "16px" }}>
        <input
          value={title}
          onChange={(e) => setTitle(e.target.value)}
          onKeyDown={(e) => e.key === "Enter" && addTodo()}
          placeholder="새 할일 입력..."
        />
        <button onClick={addTodo}>추가</button>
      </div>

      <ul style={{ listStyle: "none", padding: 0 }}>
        {todos.map((todo) => (
          <li
            key={todo.id}
            style={{
              display: "flex",
              alignItems: "center",
              gap: "8px",
              padding: "8px 0",
            }}
          >
            <input
              type="checkbox"
              checked={todo.done}
              onChange={() => toggleTodo(todo.id, todo.done)}
            />
            <span
              style={{
                flex: 1,
                textDecoration: todo.done ? "line-through" : "none",
              }}
            >
              {todo.title}
            </span>
            <button onClick={() => deleteTodo(todo.id)}>삭제</button>
          </li>
        ))}
      </ul>

      <p>{todos.filter((t) => !t.done).length}개 남음</p>
    </div>
  );
});
```

## 7. Action 패턴 (대안)

REST 엔드포인트 대신 Filling의 Action 패턴을 사용할 수도 있습니다. 이 방식은 loader와 연계하여 자동 revalidation이 가능합니다.

```typescript
// app/todos/route.ts (Action 패턴)
import { Mandu } from "@mandujs/core";
import type { Todo } from "../../src/shared/types/todo";

const todos: Todo[] = [];

export default Mandu.filling()
  .loader(async () => ({
    todos: [...todos],
  }))
  .action("create", async (ctx) => {
    const { title } = await ctx.body<{ title: string }>();
    const todo: Todo = {
      id: crypto.randomUUID(),
      title,
      done: false,
      createdAt: new Date().toISOString(),
    };
    todos.push(todo);
    return ctx.ok({ created: true });
  })
  .action("toggle", async (ctx) => {
    const { id } = await ctx.body<{ id: string }>();
    const todo = todos.find((t) => t.id === id);
    if (todo) todo.done = !todo.done;
    return ctx.ok({ toggled: true });
  })
  .action("delete", async (ctx) => {
    const { id } = await ctx.body<{ id: string }>();
    const index = todos.findIndex((t) => t.id === id);
    if (index !== -1) todos.splice(index, 1);
    return ctx.ok({ deleted: true });
  });
```

Action 호출 시 loader가 자동으로 다시 실행되어 최신 데이터가 반환됩니다.

```typescript
// 클라이언트에서 Action 호출
const res = await fetch("/todos", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "X-Requested-With": "ManduAction",
  },
  body: JSON.stringify({ _action: "create", title: "새 할일" }),
});

const { actionData, loaderData } = await res.json();
// loaderData.todos 에 최신 목록이 포함됩니다
```

## 8. 실행 및 확인

```bash
bun run dev
```

- `http://localhost:3333/todos` -- Todo 페이지
- `http://localhost:3333/api/todos` -- API 엔드포인트

## 정리

이 가이드에서 다룬 내용:

| 단계 | 사용한 기능 | 파일 |
|------|-------------|------|
| 스키마 | Zod | `src/shared/types/todo.ts` |
| API 명세 | Contract | `src/shared/contracts/todo.contract.ts` |
| 서버 로직 | Filling | `app/api/todos/route.ts` |
| SSR 데이터 | Loader | `app/todos/route.ts` |
| 페이지 | React 컴포넌트 | `app/todos/page.tsx` |
| 상호작용 | Island | `app/todos/todo-list.island.tsx` |
