Tips & Tricks (업데이트: 2026. 6. 3.)

Claude Code로 첫 REST API 만들기: CRUD, 검증, 테스트 입문

Claude Code로 첫 Express REST API를 만듭니다. CRUD, 검증, 오류 설계, 테스트와 실행 가능한 코드를 다룹니다.

Claude Code로 첫 REST API 만들기: CRUD, 검증, 테스트 입문

처음 REST API를 만들 때 어려운 점은 Express route를 입력하는 일이 아닙니다. 실제로 어려운 부분은 쓸 만한 API가 갖춰야 할 요소를 아는 것입니다. 명확한 URL, 올바른 HTTP method, 입력 검증, 예측 가능한 오류 응답, 그리고 코드가 동작한다는 것을 보여 주는 작은 테스트가 필요합니다.

이 글에서는 Claude Code와 함께 Express 기반 Todo API를 만듭니다. 프로젝트를 만들고, CRUD endpoint를 추가하고, JSON 입력을 검증하고, 적절한 status code를 반환하며, curl과 Node.js 내장 test runner로 확인합니다. 코드는 Node.js 22/24 LTS 이상 또는 더 최신 버전을 기준으로 합니다.

Claude Code 자체가 아직 낯설다면 Claude Code 시작 가이드부터 읽어 보세요. 테스트를 더 깊게 다루려면 Claude Code API testingClaude Code Zod validation도 이어서 보면 좋습니다.

초보자가 알아야 할 용어

REST API는 HTTP를 통해 resource를 읽고 변경하는 설계 방식입니다. 이 글의 resource는 todo입니다. 쉽게 말하면, 다른 앱이 URL을 호출하고 JSON을 주고받은 뒤 status code로 결과를 판단하는 창구입니다.

용어쉬운 뜻예시
EndpointAPI의 URL 입구GET /todos
Method어떤 행동인지 나타내는 HTTP 동사GET, POST, PUT, DELETE
Status code결과를 나타내는 숫자200, 201, 400, 404
JSON데이터를 주고받는 가벼운 텍스트 형식{ "title": "Learn REST" }
Validation입력이 올바른지 확인하는 처리title 거부
Idempotency같은 요청을 반복해도 최종 상태가 같음같은 PUT 두 번 보내기

공식 참고 문서로는 MDN의 HTTP request methods, HTTP status codes, Express routing, Express error handling, Node.js test runner를 추천합니다.

만들 API

이 API는 Todo를 메모리에 저장합니다. 서버를 다시 시작하면 초기 데이터로 돌아갑니다. 데이터베이스를 붙이기 전에 REST 형태를 이해하기 위한 의도적인 단순화입니다.

작업Method와 URL성공 status
상태 확인GET /health200 OK
Todo 목록GET /todos200 OK
Todo 하나 읽기GET /todos/:id200 OK
Todo 생성POST /todos201 Created
Todo 교체PUT /todos/:id200 OK
Todo 삭제DELETE /todos/:id204 No Content

작은 예제지만 실제 업무와 연결됩니다. 첫 번째 유스케이스는 리뷰 요청, 티켓, 운영 작업을 다루는 내부 task API입니다. 두 번째는 React, Vue, Astro 화면을 먼저 만들 때 쓰는 mock backend입니다. 세 번째는 문의나 뉴스레터 등록 API로, titleemailmessage로 바꾸면 같은 검증과 오류 설계를 적용할 수 있습니다.

Claude Code에는 완료 조건까지 전달하기

사용할 stack, endpoint, 오류 동작, 확인 방법을 함께 전달하세요. “API 만들어 줘”처럼 모호하게 요청하면 실행은 되지만 설계를 검토하기 어려운 코드가 나오기 쉽습니다.

Create a beginner Todo REST API with Express 5 and Node.js ES Modules.

Requirements:
- Provide package.json and server.js
- Add GET /health, GET /todos, GET /todos/:id, POST /todos, PUT /todos/:id, DELETE /todos/:id
- Accept JSON input
- title must be 1 to 120 characters; completed must be boolean
- Return JSON errors for 400, 404, and 500
- Keep PUT idempotent: repeating the same request should not change the final resource state
- Include curl requests and a node:test example

여기서 idempotency 조건이 중요합니다. PUT은 resource 교체를 의미합니다. 같은 body를 두 번 보내면 Todo의 최종 상태가 같아야 합니다. 데이터가 같아도 updatedAt을 매번 바꾸면 코드와 REST 설명이 어긋납니다.

프로젝트 만들기

mkdir claude-rest-api
cd claude-rest-api
npm init -y
npm install express

package.json을 다음처럼 바꿉니다.

{
  "name": "claude-rest-api",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "node --watch server.js",
    "start": "node server.js",
    "test": "node --test"
  },
  "dependencies": {
    "express": "^5.0.0"
  }
}

server.js

import express from "express";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";

const PORT = Number(process.env.PORT ?? 3000);

function createHttpError(status, message, details) {
  const error = new Error(message);
  error.status = status;
  error.details = details;
  return error;
}

function hasOwn(object, key) {
  return Object.prototype.hasOwnProperty.call(object, key);
}

function parseId(rawId) {
  const id = Number(rawId);
  return Number.isInteger(id) && id > 0 ? id : null;
}

function validateTodoInput(body, { partial = false } = {}) {
  const errors = [];

  if (!partial || hasOwn(body, "title")) {
    if (typeof body.title !== "string" || body.title.trim().length === 0) {
      errors.push({ field: "title", message: "title is required" });
    } else if (body.title.trim().length > 120) {
      errors.push({ field: "title", message: "title must be 120 characters or fewer" });
    }
  }

  if (!partial || hasOwn(body, "completed")) {
    if (typeof body.completed !== "boolean") {
      errors.push({ field: "completed", message: "completed must be a boolean" });
    }
  }

  return errors;
}

export function createApp() {
  const app = express();

  let nextId = 3;
  const todos = [
    {
      id: 1,
      title: "Read MDN HTTP status docs",
      completed: false,
      updatedAt: "2026-06-03T00:00:00.000Z"
    },
    {
      id: 2,
      title: "Ask Claude Code to review API errors",
      completed: true,
      updatedAt: "2026-06-03T00:00:00.000Z"
    }
  ];

  app.use(express.json({ limit: "32kb" }));

  app.get("/health", (req, res) => {
    res.json({ status: "ok", uptime: process.uptime() });
  });

  app.get("/todos", (req, res) => {
    res.json({ data: todos, count: todos.length });
  });

  app.get("/todos/:id", (req, res, next) => {
    const id = parseId(req.params.id);
    if (!id) return next(createHttpError(400, "id must be a positive integer"));

    const todo = todos.find((item) => item.id === id);
    if (!todo) return next(createHttpError(404, "todo not found"));

    res.json({ data: todo });
  });

  app.post("/todos", (req, res, next) => {
    const errors = validateTodoInput(req.body);
    if (errors.length > 0) {
      return next(createHttpError(400, "invalid request body", errors));
    }

    const todo = {
      id: nextId,
      title: req.body.title.trim(),
      completed: req.body.completed,
      updatedAt: new Date().toISOString()
    };
    nextId += 1;
    todos.push(todo);

    res.status(201).location(`/todos/${todo.id}`).json({ data: todo });
  });

  app.put("/todos/:id", (req, res, next) => {
    const id = parseId(req.params.id);
    if (!id) return next(createHttpError(400, "id must be a positive integer"));

    const errors = validateTodoInput(req.body);
    if (errors.length > 0) {
      return next(createHttpError(400, "invalid request body", errors));
    }

    const index = todos.findIndex((item) => item.id === id);
    if (index === -1) return next(createHttpError(404, "todo not found"));

    const previous = todos[index];
    const nextTodo = {
      ...previous,
      title: req.body.title.trim(),
      completed: req.body.completed
    };

    const changed =
      previous.title !== nextTodo.title || previous.completed !== nextTodo.completed;

    todos[index] = {
      ...nextTodo,
      updatedAt: changed ? new Date().toISOString() : previous.updatedAt
    };

    res.json({ data: todos[index] });
  });

  app.delete("/todos/:id", (req, res, next) => {
    const id = parseId(req.params.id);
    if (!id) return next(createHttpError(400, "id must be a positive integer"));

    const index = todos.findIndex((item) => item.id === id);
    if (index === -1) return next(createHttpError(404, "todo not found"));

    todos.splice(index, 1);
    res.status(204).send();
  });

  app.use((req, res, next) => {
    next(createHttpError(404, `route not found: ${req.method} ${req.originalUrl}`));
  });

  app.use((err, req, res, next) => {
    const status = Number.isInteger(err.status) && err.status >= 400 ? err.status : 500;
    const body = {
      error: {
        status,
        message: status === 500 ? "Internal Server Error" : err.message
      }
    };

    if (err.details) {
      body.error.details = err.details;
    }

    res.status(status).json(body);
  });

  return app;
}

const currentFile = fileURLToPath(import.meta.url);
const startedFile = process.argv[1] ? resolve(process.argv[1]) : "";

if (startedFile === currentFile) {
  createApp().listen(PORT, () => {
    console.log(`REST API listening on http://localhost:${PORT}`);
  });
}

핵심은 route만 있는 것이 아니라는 점입니다. 입력을 검증하고, JSON 오류 형태를 맞추고, 상황에 맞는 status code를 반환합니다.

curl로 확인하기

npm run dev
curl -i http://localhost:3000/health

curl -i http://localhost:3000/todos

curl -i -X POST http://localhost:3000/todos \
  -H "Content-Type: application/json" \
  -d '{"title":"Write API tests","completed":false}'

curl -i -X POST http://localhost:3000/todos \
  -H "Content-Type: application/json" \
  -d '{"title":"","completed":false}'

curl -i -X PUT http://localhost:3000/todos/1 \
  -H "Content-Type: application/json" \
  -d '{"title":"Read MDN HTTP status docs","completed":true}'

curl -i -X DELETE http://localhost:3000/todos/2

성공뿐 아니라 실패도 확인하세요. 정상 경로에서만 동작하는 API는 실제 frontend에 붙이기 부족합니다.

node:test 추가하기

server.test.js를 만듭니다.

import assert from "node:assert/strict";
import test from "node:test";
import { createApp } from "./server.js";

function listen(app) {
  return new Promise((resolve) => {
    const server = app.listen(0, () => resolve(server));
  });
}

async function request(baseUrl, path, options = {}) {
  const response = await fetch(`${baseUrl}${path}`, {
    headers: { "Content-Type": "application/json", ...options.headers },
    ...options
  });
  const text = await response.text();
  return {
    response,
    body: text ? JSON.parse(text) : null
  };
}

test("creates, updates, and deletes a todo", async (t) => {
  const server = await listen(createApp());
  t.after(() => server.close());
  const baseUrl = `http://127.0.0.1:${server.address().port}`;

  const created = await request(baseUrl, "/todos", {
    method: "POST",
    body: JSON.stringify({ title: "Test the API", completed: false })
  });
  assert.equal(created.response.status, 201);
  assert.equal(created.body.data.title, "Test the API");

  const id = created.body.data.id;
  const updated = await request(baseUrl, `/todos/${id}`, {
    method: "PUT",
    body: JSON.stringify({ title: "Test the API", completed: true })
  });
  assert.equal(updated.response.status, 200);
  assert.equal(updated.body.data.completed, true);

  const deleted = await request(baseUrl, `/todos/${id}`, { method: "DELETE" });
  assert.equal(deleted.response.status, 204);
});

test("rejects invalid todo input", async (t) => {
  const server = await listen(createApp());
  t.after(() => server.close());
  const baseUrl = `http://127.0.0.1:${server.address().port}`;

  const result = await request(baseUrl, "/todos", {
    method: "POST",
    body: JSON.stringify({ title: "", completed: false })
  });

  assert.equal(result.response.status, 400);
  assert.equal(result.body.error.details[0].field, "title");
});
npm test

테스트는 작지만 안전망이 됩니다. 나중에 Claude Code에 데이터베이스, OpenAPI, 인증을 추가하게 할 때 이 테스트가 계속 통과하도록 요구할 수 있습니다.

흔한 실수

/getTodos 같은 endpoint 이름을 피하세요. 동사는 method가 맡게 합니다: GET /todos, POST /todos, PUT /todos/:id.

항상 200을 반환하지 마세요. 잘못된 입력은 400, 없는 resource는 404, 생성 성공은 201, 응답 body 없는 삭제 성공은 204가 자연스럽습니다.

frontend 검증에만 기대지 마세요. API는 직접 호출될 수 있으므로 server에서도 반드시 검증해야 합니다.

client에 stack trace를 노출하지 마세요. 자세한 내용은 server log에 남기고 응답은 제어된 JSON으로 돌려줍니다.

idempotency를 실수로 깨지 마세요. 같은 PUT이 매번 timestamp나 random 값을 바꾸면 반복 요청 후 최종 상태가 안정적이지 않습니다.

다음 단계와 CTA

다음에는 한 번에 하나씩만 추가하세요. 데이터베이스, 더 강한 schema validation, API 문서, 인증 순서가 안전합니다. 오류 설계는 Claude Code error handling patterns도 참고하세요.

자신의 프로젝트로 연습하려면 무료 치트시트를 열어 두고 Claude Code에 요청하세요. 재사용 가능한 prompt, review checklist, CLAUDE.md template은 Claude Code 제품 라이브러리에서 확인할 수 있습니다. 팀 온보딩, API review rule, permission design까지 정리하려면 Claude Code 교육 및 상담을 이용하세요.

이 글의 내용을 실제로 따라 해 보니, 초보자에게 가장 큰 전환점은 CRUD 코드 자체가 아니었습니다. 빈 title400, 없는 ID는 404, 같은 PUT 반복은 같은 최종 상태를 유지한다는 점을 성공과 실패로 나란히 확인하는 과정이었습니다. 그때 REST는 URL 목록이 아니라 frontend와 backend 사이의 계약으로 이해됩니다.

#claude-code #rest-api #beginner #typescript #backend #tutorial
무료

무료 PDF: Claude Code 치트시트

이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.

개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.

Masa

작성자 소개

Masa

Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.