Tips & Tricks

Claude Code로 REST API 초고속 설계·구현·테스트 | OpenAPI 명세서부터 프로덕션까지

Claude Code를 활용해 OpenAPI 명세서 생성부터 프로덕션 TypeScript 코드까지 REST API를 end-to-end로 개발하는 방법을 소개합니다. Hono, zod 유효성 검사, vitest 테스트 자동 생성을 실제 동작하는 코드 예제와 함께 설명합니다.

REST API 설계에 Claude Code가 왜 적합한가

REST API 개발은 반복적인 패턴으로 가득합니다: 엔드포인트 정의, 유효성 검사, 에러 처리, 테스트 — 모두 수작업으로 작성해야 하죠. 이 보일러플레이트를 처리하다 보면 정작 중요한 비즈니스 로직에 집중하기 어렵습니다.

저는 Masa, 사내 시스템 API 현대화 프로젝트를 담당하는 DX 엔지니어입니다. Claude Code 도입 전 엔드포인트당 표준 공수는 “설계 30분 → 구현 2시간 → 테스트 1시간”이었습니다. Claude Code를 전면 도입한 후 설계 10분 → 구현 30분 → 테스트 15분으로 단축됐습니다.

Claude Code가 API 개발에 강한 3가지 이유

  1. OpenAPI 명세를 컨텍스트로 이해 — YAML을 붙여 넣으면 즉시 명세에 맞는 코드를 생성
  2. TypeScript 타입 시스템 숙달 — 처음부터 타입 안전한 코드를 작성해 줌
  3. 다양한 테스트 패턴 숙지 — 정상 케이스, 에러 케이스, 엣지 케이스 테스트를 한 번에 생성

1. Claude Code로 OpenAPI 명세서 자동 생성

프롬프트 예시

다음 요구사항으로 OpenAPI 3.1 명세서(YAML)를 생성해주세요.

리소스: Task (작업 관리)
필드:
  - id: UUID
  - title: string (1–200자)
  - description: string (선택)
  - status: "todo" | "in_progress" | "done"
  - priority: "low" | "medium" | "high" (기본값: medium)
  - assignee_id: UUID (선택)
  - due_date: ISO8601 형식 (선택)
  - created_at: ISO8601 형식
  - updated_at: ISO8601 형식

엔드포인트:
  - GET /tasks (페이지네이션 포함 목록)
  - GET /tasks/:id
  - POST /tasks
  - PUT /tasks/:id (전체 업데이트)
  - PATCH /tasks/:id (부분 업데이트)
  - DELETE /tasks/:id

응답 형식: { data, meta, errors }
에러 코드: 400, 401, 403, 404, 422, 500
인증: Bearer Token (Authorization 헤더)

생성된 OpenAPI YAML (발췌)

openapi: 3.1.0
info:
  title: Task Management API
  version: 1.0.0
  description: 작업 관리 API OpenAPI 명세서

servers:
  - url: https://api.example.com/v1
    description: 프로덕션
  - url: http://localhost:3000/v1
    description: 개발

components:
  schemas:
    Task:
      type: object
      required: [id, title, status, priority, created_at, updated_at]
      properties:
        id:
          type: string
          format: uuid
        title:
          type: string
          minLength: 1
          maxLength: 200
        status:
          type: string
          enum: [todo, in_progress, done]
        priority:
          type: string
          enum: [low, medium, high]
          default: medium

2. Hono로 타입 안전한 핸들러 구현

// src/index.ts
import { Hono } from "hono";
import { cors } from "hono/cors";
import { logger } from "hono/logger";
import { prettyJSON } from "hono/pretty-json";
import { taskRouter } from "./routes/tasks";

const app = new Hono();

// 미들웨어
app.use("*", cors());
app.use("*", logger());
app.use("*", prettyJSON());

// 라우터 등록
app.route("/v1/tasks", taskRouter);

// 글로벌 에러 핸들러
app.onError((err, c) => {
  console.error(err);
  return c.json(
    { data: null, meta: null, errors: [{ code: "INTERNAL_ERROR", message: "서버 내부 오류가 발생했습니다" }] },
    500
  );
});

export default app;

작업 라우터 구현

// src/routes/tasks.ts
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { TaskCreateSchema, TaskUpdateSchema, TaskPatchSchema, TaskQuerySchema } from "../schemas/task";
import { TaskService } from "../services/taskService";

export const taskRouter = new Hono();
const taskService = new TaskService();

// 작업 목록 조회
taskRouter.get("/", zValidator("query", TaskQuerySchema), async (c) => {
  const query = c.req.valid("query");
  const result = await taskService.findAll(query);
  return c.json({
    data: result.tasks,
    meta: { total: result.total, page: query.page, per_page: query.per_page },
    errors: null,
  });
});

// ID로 작업 조회
taskRouter.get("/:id", async (c) => {
  const id = c.req.param("id");
  const task = await taskService.findById(id);
  if (!task) {
    return c.json(
      { data: null, meta: null, errors: [{ code: "NOT_FOUND", message: "작업을 찾을 수 없습니다", field: "id" }] },
      404
    );
  }
  return c.json({ data: task, meta: null, errors: null });
});

// 작업 생성
taskRouter.post("/", zValidator("json", TaskCreateSchema), async (c) => {
  const body = c.req.valid("json");
  const task = await taskService.create(body);
  return c.json({ data: task, meta: null, errors: null }, 201);
});

// 작업 삭제
taskRouter.delete("/:id", async (c) => {
  const id = c.req.param("id");
  const deleted = await taskService.delete(id);
  if (!deleted) {
    return c.json(
      { data: null, meta: null, errors: [{ code: "NOT_FOUND", message: "작업을 찾을 수 없습니다", field: "id" }] },
      404
    );
  }
  return c.json({ data: null, meta: null, errors: null }, 204);
});

3. Zod 유효성 검사 스키마 자동 생성

// src/schemas/task.ts
import { z } from "zod";

const TaskStatus = z.enum(["todo", "in_progress", "done"]);
const TaskPriority = z.enum(["low", "medium", "high"]);

export const TaskSchema = z.object({
  id: z.string().uuid(),
  title: z.string().min(1, "제목은 최소 1자 이상이어야 합니다").max(200, "제목은 200자 이하여야 합니다"),
  description: z.string().nullable(),
  status: TaskStatus,
  priority: TaskPriority,
  assignee_id: z.string().uuid().nullable(),
  due_date: z.string().datetime().nullable(),
  created_at: z.string().datetime(),
  updated_at: z.string().datetime(),
});

export const TaskCreateSchema = z.object({
  title: z.string().min(1).max(200),
  description: z.string().optional(),
  status: TaskStatus.default("todo"),
  priority: TaskPriority.default("medium"),
  assignee_id: z.string().uuid().optional(),
  due_date: z.string().datetime().optional(),
});

export const TaskUpdateSchema = z.object({
  title: z.string().min(1).max(200),
  description: z.string().nullable(),
  status: TaskStatus,
  priority: TaskPriority,
  assignee_id: z.string().uuid().nullable(),
  due_date: z.string().datetime().nullable(),
});

export const TaskPatchSchema = TaskUpdateSchema.partial();

export const TaskQuerySchema = z.object({
  page: z.coerce.number().int().min(1).default(1),
  per_page: z.coerce.number().int().min(1).max(100).default(20),
  status: TaskStatus.optional(),
  priority: TaskPriority.optional(),
});

export type Task = z.infer<typeof TaskSchema>;
export type TaskCreate = z.infer<typeof TaskCreateSchema>;
export type TaskQuery = z.infer<typeof TaskQuerySchema>;

도입 전/후 비교

지표Claude Code 도입 전Claude Code 도입 후
스키마 정의 공수엔드포인트당 30–45분5–10분 (프롬프트 작성 포함)
타입 에러 발생률프로젝트 후반에 빈번처음부터 거의 제로
유효성 검사 누락코드 리뷰마다 지적엣지 케이스 자동 커버
명세서/코드 불일치2–3주 만에 구식이 됨항상 동기화 (명세에서 생성)

4. Vitest로 테스트 자동 생성

// src/routes/tasks.test.ts
import { describe, it, expect, vi } from "vitest";
import app from "../index";
import { TaskService } from "../services/taskService";

vi.mock("../services/taskService");

const mockTask = {
  id: "550e8400-e29b-41d4-a716-446655440000",
  title: "테스트 작업",
  description: "테스트용 작업입니다",
  status: "todo" as const,
  priority: "medium" as const,
  assignee_id: null,
  due_date: null,
  created_at: "2026-04-25T00:00:00.000Z",
  updated_at: "2026-04-25T00:00:00.000Z",
};

describe("GET /v1/tasks", () => {
  it("정상 케이스: 작업 목록 반환", async () => {
    vi.mocked(TaskService.prototype.findAll).mockResolvedValue({ tasks: [mockTask], total: 1 });
    const res = await app.request("/v1/tasks", { headers: { Authorization: "Bearer valid-token" } });
    expect(res.status).toBe(200);
    const body = await res.json();
    expect(body.data).toHaveLength(1);
  });

  it("에러 케이스: per_page 최댓값 초과", async () => {
    const res = await app.request("/v1/tasks?per_page=101", { headers: { Authorization: "Bearer valid-token" } });
    expect(res.status).toBe(400);
  });
});

describe("POST /v1/tasks", () => {
  it("정상 케이스: 작업 생성", async () => {
    vi.mocked(TaskService.prototype.create).mockResolvedValue(mockTask);
    const res = await app.request("/v1/tasks", {
      method: "POST",
      headers: { "Content-Type": "application/json", Authorization: "Bearer valid-token" },
      body: JSON.stringify({ title: "새 작업" }),
    });
    expect(res.status).toBe(201);
  });

  it("에러 케이스: 제목이 빈 문자열", async () => {
    const res = await app.request("/v1/tasks", {
      method: "POST",
      headers: { "Content-Type": "application/json", Authorization: "Bearer valid-token" },
      body: JSON.stringify({ title: "" }),
    });
    expect(res.status).toBe(400);
  });
});

5. 에러 처리 패턴

// src/lib/errors.ts
export class AppError extends Error {
  constructor(
    public readonly code: string,
    public readonly message: string,
    public readonly statusCode: number,
    public readonly field?: string
  ) {
    super(message);
    this.name = "AppError";
  }
}

export const Errors = {
  notFound: (resource: string) => new AppError("NOT_FOUND", `${resource}을(를) 찾을 수 없습니다`, 404),
  unauthorized: () => new AppError("UNAUTHORIZED", "인증이 필요합니다", 401),
  forbidden: () => new AppError("FORBIDDEN", "이 리소스에 접근할 권한이 없습니다", 403),
  conflict: (message: string) => new AppError("CONFLICT", message, 409),
  internal: () => new AppError("INTERNAL_ERROR", "서버 내부 오류가 발생했습니다", 500),
};

3가지 자주 빠지는 함정

함정 1: UUID 유효성 검사 누락

Claude Code는 :id 경로 파라미터를 단순 문자열로 처리하는 경우가 있습니다. UUID 형식 검증 없이 잘못된 쿼리가 DB에 도달할 수 있습니다.

// 잘못된 예: 원시 문자열을 DB에 직접 전달
const task = await db.findById(c.req.param("id"));

// 올바른 예: 먼저 UUID 형식 검증
const idSchema = z.string().uuid("유효한 UUID 형식이 아닙니다");
const parsed = idSchema.safeParse(c.req.param("id"));
if (!parsed.success) {
  return c.json({ data: null, meta: null, errors: [{ code: "INVALID_ID", message: "ID 형식이 올바르지 않습니다" }] }, 400);
}
const task = await db.findById(parsed.data);

함정 2: Content-Type 헤더 검증 누락

Content-Type: application/json 없이 PUT/POST 요청이 오면, Hono는 body를 빈 객체로 처리합니다. zod 유효성 검사를 통과해 빈 데이터가 저장될 수 있습니다.

// src/middleware/contentType.ts
import { createMiddleware } from "hono/factory";

export const requireJson = createMiddleware(async (c, next) => {
  if (["POST", "PUT", "PATCH"].includes(c.req.method)) {
    const contentType = c.req.header("Content-Type");
    if (!contentType?.includes("application/json")) {
      return c.json(
        { data: null, meta: null, errors: [{ code: "UNSUPPORTED_MEDIA_TYPE", message: "Content-Type: application/json이 필요합니다" }] },
        415
      );
    }
  }
  await next();
});

함정 3: 날짜 필드의 타임존 문제

z.string().datetime()은 UTC 형식만 허용합니다. 프론트엔드에서 2026-04-25T09:00:00+09:00을 보내면 거부됩니다.

// 잘못된 예
due_date: z.string().datetime()

// 올바른 예: 타임존 오프셋 허용
due_date: z.string().datetime({ offset: true }).optional()

정리

REST API 개발에 Claude Code를 통합하면 프로덕션까지의 시간을 크게 단축할 수 있습니다:

  1. OpenAPI 명세를 먼저 생성 — 이후 모든 단계의 기반이 됩니다
  2. Zod 스키마는 명세에서 생성 — 직접 작성하면 명세와 코드 간 불일치가 발생합니다
  3. 정상 케이스·에러 케이스·엣지 케이스 테스트를 명시적으로 요청 — 지시 없이는 정상 케이스만 작성됩니다
  4. 함정을 프롬프트에 미리 포함 — UUID 검증, Content-Type 확인, 타임존 처리

이 워크플로를 도입한 이후 엔드포인트당 개발 시간이 약 70% 감소했습니다.

단, Claude Code 출력물을 검토 없이 프로덕션에 배포하는 것은 위험합니다. 보안과 비즈니스 로직은 반드시 사람이 검토해야 합니다. Claude Code는 빠른 초안 작성 도구이며, 최종 품질 책임은 개발자에게 있습니다.


관련 글:

#claude-code #rest-api #openapi #typescript #backend

Claude Code 워크플로우를 한 단계 업그레이드하세요

지금 바로 Claude Code에 복사해 쓸 수 있는 검증된 프롬프트 템플릿 50선.

무료 제공

무료 PDF: 5분 완성 Claude Code 치트시트

이메일 주소만 등록하시면 A4 한 장짜리 치트시트 PDF를 즉시 보내드립니다.

개인정보는 엄격하게 관리하며 스팸은 보내지 않습니다.

Masa

이 글을 작성한 사람

Masa

Claude Code를 적극 활용하는 엔지니어. 10개 언어, 2,000페이지 이상의 테크 미디어 claudecode-lab.com을 운영 중.