Claude Code로 실무 API 설계하기: OpenAPI, 테스트, Breaking Change 검사
Claude Code로 REST API를 설계하는 실전 흐름. OpenAPI, Mock, 테스트, 버전, 보안, 함정을 다룹니다.
API 설계는 보기 좋은 URL을 만드는 일이 아닙니다. 클라이언트가 무엇을 보낼 수 있고, 서버가 무엇을 돌려주며, 실패했을 때 어떻게 복구할 수 있는지 미리 정하는 계약입니다.
이 계약이 흐리면 프론트엔드, 모바일 앱, 파트너 연동, 테스트, 모니터링이 각자 다른 해석으로 움직입니다. 나중에 고칠수록 비용이 커지므로, 작더라도 명확한 계약을 먼저 만드는 편이 안전합니다.
Claude Code는 API 설계에 유용하지만, 단순 코드 생성기로 쓰면 부족합니다. OpenAPI 초안을 만들고, 엔드포인트를 리뷰하고, Mock과 테스트 예시를 만들고, 기존 클라이언트를 깨뜨리는 변경을 확인하도록 나누어 지시해야 합니다.
작업 기준은 공식 문서를 사용합니다: OpenAPI Specification, RFC 9110 HTTP Semantics, JSON Schema docs, OWASP API Security Top 10. 구현까지 이어가려면 프로덕션 API 개발, API 테스트 자동화, API 버전 관리도 함께 보면 좋습니다.
API 설계가 정하는 것
API는 다른 프로그램이 사용하는 화면입니다. 사람용 화면은 버튼, 문구, 배치로 의미를 전달하지만 API는 path, HTTP method, status code, JSON field, schema, 예시, error body로 의미를 전달합니다.
초보자는 먼저 다섯 가지를 정한다고 생각하면 됩니다.
| 항목 | 쉬운 설명 | 예 |
|---|---|---|
| Resource | API가 다루는 명사 | orders, customers, invoices |
| Operation | 그 명사에 하는 일 | GET, POST, PATCH, DELETE |
| Schema | JSON 형태와 입력 규칙 | items는 1개 이상 |
| Error | 실패를 알리는 방식 | 400, 401, 403, 404, 422와 상세 메시지 |
| Compatibility | 기존 사용자를 깨지 않는 약속 | 필수 요청 필드 추가는 breaking change |
REST를 어렵게 시작할 필요는 없습니다. 실무에서는 URL은 명사로 두고, 동작은 HTTP method로 표현하는 습관이 중요합니다. POST /orders/create보다 POST /orders, GET /getOrder?id=ord_123보다 GET /orders/ord_123이 테스트와 문서화에 유리합니다.
Claude Code 실무 흐름
한 번에 설계, 구현, 테스트, 문서를 모두 맡기지 마세요. 리뷰 가능한 단위로 나누면 결과가 안정됩니다.
flowchart TD
A["업무 규칙 정리"] --> B["OpenAPI 계약 초안"]
B --> C["HTTP, schema, security 리뷰"]
C --> D["Mock 서버와 API 테스트 생성"]
D --> E["CI에서 breaking change 검사"]
E --> F["구현, 문서화, 공개"]
OpenAPI는 HTTP API를 기계가 읽을 수 있는 계약으로 표현합니다. JSON Schema는 JSON의 모양과 제약을 설명하는 어휘입니다. HTTP status code는 성공과 실패의 의미를 공유하는 언어입니다. Claude Code는 이 세 가지를 연결해 주지만, 최종 판단 기준은 공식 사양과 테스트입니다.
Masa가 작은 검증 프로젝트에서 먼저 엔드포인트 목록만 생성시켰을 때도 첫 결과는 괜찮았습니다. 그러나 인증, pagination, idempotency key, 오류 상세를 나중에 추가하니 수정 범위가 커졌습니다. 처음부터 “클라이언트가 실패 후 어떻게 복구하는가”를 묻는 편이 더 실무적입니다.
복사해서 실행하는 예제
아래 예제는 외부 패키지 없이 Node만 사용합니다. OpenAPI, Mock server, breaking change 검사를 한 흐름으로 확인할 수 있습니다.
mkdir api-design-lab
cd api-design-lab
mkdir docs examples
node --version
docs/openapi.yaml을 만듭니다. 공식 OpenAPI 페이지에서는 최신 공개 버전을 확인할 수 있지만, 이 예제는 툴 호환성이 넓은 3.1 형식으로 시작합니다.
openapi: 3.1.0
info:
title: Orders API
version: 1.0.0
servers:
- url: https://api.example.com
paths:
/v1/orders:
post:
summary: Create an order
operationId: createOrder
security:
- bearerAuth: []
parameters:
- name: Idempotency-Key
in: header
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateOrderRequest"
responses:
"201":
description: Created
content:
application/json:
schema:
$ref: "#/components/schemas/Order"
"422":
description: Validation error
content:
application/json:
schema:
$ref: "#/components/schemas/Problem"
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
schemas:
CreateOrderRequest:
type: object
required: [customerId, items]
properties:
customerId:
type: string
minLength: 3
items:
type: array
minItems: 1
items:
type: object
required: [sku, quantity]
properties:
sku:
type: string
minLength: 3
quantity:
type: integer
minimum: 1
Order:
type: object
required: [id, status, customerId, total]
properties:
id:
type: string
status:
type: string
enum: [accepted, cancelled]
customerId:
type: string
total:
type: integer
Problem:
type: object
required: [type, title, status, detail]
properties:
type:
type: string
title:
type: string
status:
type: integer
detail:
type: string
errors:
type: array
items:
type: object
examples/mock-server.mjs를 만듭니다.
import { createServer } from "node:http";
import { randomUUID } from "node:crypto";
function readJson(req) {
return new Promise((resolve, reject) => {
let body = "";
req.on("data", (chunk) => {
body += chunk;
if (body.length > 1_000_000) req.destroy(new Error("Body too large"));
});
req.on("end", () => {
if (!body) return resolve({});
try {
resolve(JSON.parse(body));
} catch (error) {
reject(error);
}
});
req.on("error", reject);
});
}
function send(res, status, body, headers = {}) {
res.writeHead(status, {
"content-type": "application/json; charset=utf-8",
"x-content-type-options": "nosniff",
...headers,
});
res.end(JSON.stringify(body, null, 2));
}
function problem(status, title, detail, errors = []) {
return {
type: "https://example.com/problems/request",
title,
status,
detail,
errors,
};
}
function validateOrder(input) {
const errors = [];
if (typeof input.customerId !== "string" || input.customerId.length < 3) {
errors.push({
path: "customerId",
message: "customerId must be a string with 3+ characters",
});
}
if (!Array.isArray(input.items) || input.items.length === 0) {
errors.push({ path: "items", message: "items must contain at least one item" });
}
for (const [index, item] of (input.items ?? []).entries()) {
if (typeof item.sku !== "string" || item.sku.length < 3) {
errors.push({
path: `items.${index}.sku`,
message: "sku must be a string with 3+ characters",
});
}
if (!Number.isInteger(item.quantity) || item.quantity < 1) {
errors.push({
path: `items.${index}.quantity`,
message: "quantity must be a positive integer",
});
}
}
return errors;
}
const server = createServer(async (req, res) => {
const url = new URL(req.url ?? "/", "http://localhost");
if (req.method === "GET" && url.pathname === "/health") {
return send(res, 200, { ok: true });
}
const customerMatch = url.pathname.match(/^\/v1\/customers\/([a-z0-9-]+)$/);
if (req.method === "GET" && customerMatch) {
return send(res, 200, {
id: customerMatch[1],
name: "Aki Tanaka",
plan: "pro",
});
}
if (req.method === "POST" && url.pathname === "/v1/orders") {
const idempotencyKey = req.headers["idempotency-key"];
if (!idempotencyKey) {
return send(
res,
400,
problem(400, "Missing Idempotency-Key", "POST /v1/orders requires the header.")
);
}
try {
const body = await readJson(req);
const errors = validateOrder(body);
if (errors.length > 0) {
return send(res, 422, problem(422, "Invalid request body", "Fix errors.", errors));
}
return send(
res,
201,
{
id: `ord_${randomUUID()}`,
status: "accepted",
customerId: body.customerId,
total: 4200,
},
{ location: "/v1/orders/example" }
);
} catch {
return send(res, 400, problem(400, "Malformed JSON", "Request body must be JSON."));
}
}
return send(res, 404, problem(404, "Not found", `${req.method} ${url.pathname} is undefined.`));
});
server.listen(3000, () => {
console.log("Mock API running at http://localhost:3000");
});
서버를 실행하고 다른 터미널에서 호출합니다.
node examples/mock-server.mjs
curl -i http://localhost:3000/health
curl -i -X POST http://localhost:3000/v1/orders \
-H "content-type: application/json" \
-H "idempotency-key: demo-001" \
-d '{"customerId":"cus_123","items":[{"sku":"book-1","quantity":2}]}'
curl -i -X POST http://localhost:3000/v1/orders \
-H "content-type: application/json" \
-H "idempotency-key: demo-002" \
-d '{"customerId":"x","items":[]}'
examples/contract-check.mjs도 만듭니다. 이 코드는 의도적으로 실패합니다.
import assert from "node:assert/strict";
const previous = {
paths: {
"/v1/orders": {
post: {
request: {
required: ["customerId", "items"],
properties: ["customerId", "items", "couponCode"],
},
response: {
required: ["id", "status", "customerId", "total"],
properties: ["id", "status", "customerId", "total"],
},
},
},
},
};
const next = structuredClone(previous);
next.paths["/v1/orders"].post.request.required.push("shippingAddress");
next.paths["/v1/orders"].post.response.properties =
next.paths["/v1/orders"].post.response.properties.filter((name) => name !== "total");
function diffContract(oldSpec, newSpec) {
const breaking = [];
for (const [path, methods] of Object.entries(oldSpec.paths)) {
for (const [method, oldOperation] of Object.entries(methods)) {
const newOperation = newSpec.paths[path]?.[method];
if (!newOperation) {
breaking.push(`${method.toUpperCase()} ${path} was removed`);
continue;
}
const oldRequired = new Set(oldOperation.request.required);
for (const field of newOperation.request.required) {
if (!oldRequired.has(field)) {
breaking.push(`${method.toUpperCase()} ${path} now requires "${field}"`);
}
}
const newResponseFields = new Set(newOperation.response.properties);
for (const field of oldOperation.response.properties) {
if (!newResponseFields.has(field)) {
breaking.push(`${method.toUpperCase()} ${path} removed response "${field}"`);
}
}
}
}
return breaking;
}
const breaking = diffContract(previous, next);
console.log(breaking.join("\n") || "No breaking changes found");
assert.equal(breaking.length, 0, "Breaking API changes detected");
node examples/contract-check.mjs
여기서는 실패가 정상입니다. 새 필수 요청 필드와 제거된 응답 필드를 잡아내면 CI에서 같은 사고를 막을 수 있습니다.
Claude Code 프롬프트
실무에서는 초안 작성, 리뷰, 예제 생성, 호환성 검사를 분리해서 요청합니다.
claude -p "
전자상거래 주문 API의 OpenAPI 초안을 docs/openapi.yaml에 작성해 주세요.
리소스는 customers, orders, invoices입니다.
각 엔드포인트에 summary, operationId, requestBody, responses, examples, bearerAuth를 넣어 주세요.
OpenAPI 3.1과 JSON Schema 스타일 제약을 사용해 주세요.
"
claude -p "
docs/openapi.yaml을 API 설계 리뷰어 관점으로 읽어 주세요.
먼저 Findings를 심각도순으로 출력하고, 아직 파일은 수정하지 마세요.
RFC 9110 method/status 의미, 모호한 schema, pagination, idempotency,
인증, OWASP API Security의 일반적인 위험을 확인해 주세요.
"
claude -p "
docs/openapi.yaml에서 Node.js Mock server와 API test 예제를 생성해 주세요.
성공, 인증 실패, validation 실패, 없는 resource를 포함해 주세요.
긴 줄은 150자 이하로 유지하고 README에 실행 명령을 적어 주세요.
"
claude -p "
현재 docs/openapi.yaml과 HEAD 버전을 비교해 주세요.
수정 제안 전에 breaking change를 먼저 나열해 주세요.
삭제된 path, 새 필수 필드, 제거된 response field,
status code 변경, auth scope 변경을 확인해 주세요.
"
현실적인 사용 사례
첫 번째는 SaaS 주문 API입니다. 관리자 화면, 과금 작업, 메일 알림, 회계 export가 모두 같은 Order를 읽습니다. total, 통화, 세금, 취소 상태가 불명확하면 모든 연동이 흔들립니다.
두 번째는 모바일 profile API입니다. 오래된 앱 버전은 몇 달 동안 남아 있을 수 있습니다. 응답 필드를 갑자기 없애거나 enum 의미를 바꾸면 즉시 업데이트할 수 없는 클라이언트가 깨집니다.
세 번째는 B2B 파트너 API입니다. 외부 개발자는 내부 규칙을 모릅니다. 안정적인 error code, rate limit, retry 가이드, sandbox, 예시가 없으면 지원 문의가 문서 역할을 하게 됩니다.
네 번째는 사내 admin API입니다. 내부용이어도 객체 단위 권한은 필요합니다. 사용자가 ID를 안다고 해서 다른 tenant의 주문을 읽을 수 있으면 설계 오류입니다.
실패 사례와 함정
흔한 실패는 /cancelOrder, /getUserOrders처럼 path에 동사를 넣는 것입니다. 기능이 늘수록 이름이 겹치고 규칙이 깨집니다. 먼저 resource를 정하고, 필요한 경우 POST /orders/{id}/cancellation 같은 subresource로 표현하세요.
모든 오류를 HTTP 200으로 반환하는 것도 위험합니다. monitoring, SDK, retry, client branch가 모두 복잡해집니다. 잘못된 JSON은 400, 인증 없음은 401, 권한 없음은 403, 없음은 404, 의미상 유효하지 않은 입력은 422처럼 구분합니다.
POST 재시도 안전성을 빼먹는 것도 비쌉니다. 주문 생성이나 결제 시작은 timeout 후 재전송될 수 있습니다. Idempotency-Key를 처음부터 설계에 넣어 두면 중복 실행을 줄일 수 있습니다.
Schema가 예시만 있고 제약이 없는 경우도 문제입니다. null이 삭제인지 미정인지, page size 상한이 얼마인지, 배열이 비어도 되는지 명확히 해야 합니다.
Versioning, Error, Schema, Security
Versioning은 path에 /v1을 붙이는 것만으로 끝나지 않습니다. 팀이 breaking change 기준을 공유해야 합니다. 필수 요청 필드 추가, 응답 필드 삭제, status 의미 변경, 권한 scope 강화, enum 의미 변경은 기존 클라이언트를 깨뜨릴 수 있습니다.
Error 설계는 호출자가 다음에 무엇을 해야 하는지 알려야 합니다. Invalid request만으로는 부족합니다. type, title, status, detail을 고정하고, 필요하면 field-level errors를 넣으세요. 운영 환경에서는 stack trace, SQL, 내부 ID를 노출하지 않습니다.
Schema는 예시보다 제약이 중요합니다. ID 형식, 최소 길이, 배열 상한, pagination 기본값, timezone 규칙을 적으면 클라이언트가 보내기 전에 검증할 수 있습니다.
Security는 authentication과 authorization을 분리해 생각합니다. Bearer token이 있어도 GET /orders/{id}가 다른 tenant의 주문을 반환하면 실패입니다. API key를 query에 넣지 말고, 민감한 응답 필드를 줄이고, 위험 작업에는 rate limit과 audit log를 둡니다.
수익화와 상담 CTA
API 설계를 검색하는 독자는 실제 프로젝트에 가깝습니다. 공개 연동, 모바일 백엔드, 팀 리뷰 규칙, 파트너 API 같은 과제가 이미 있을 가능성이 큽니다.
ClaudeCodeLab은 Claude Code 기반 API 설계 리뷰, OpenAPI 정리, 테스트 자동화, breaking change 검사 도입을 도울 수 있습니다. 팀은 교육 및 상담에서 시작하고, 개인 개발자는 무료 리소스로 흐름을 익힐 수 있습니다.
실제 검증 결과
이 글의 코드는 Node v24.14.1에서 확인했습니다. GET /health는 200, 정상 POST /v1/orders는 201, 빈 items는 422를 반환했습니다. contract-check.mjs는 의도대로 실패하며 새 필수 필드와 제거된 응답 필드를 출력했습니다. Claude Code 프롬프트에서 OpenAPI, Mock, 오류 설계, CI 호환성 검사까지 이어지는 작은 실습으로 사용할 수 있습니다.
무료 PDF: Claude Code 치트시트
이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.
개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.
작성자 소개
Masa
Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.
관련 글
Obsidian 메모를 CLAUDE.md로 바꾸는 Claude Code 워크플로
Obsidian 작업 메모를 CLAUDE.md 운영 노트로 정리해 Claude Code 세션의 문맥 반복을 줄입니다.
Claude Code Revenue CTA Routing: 글에서 PDF, Gumroad, 상담으로 보내기
독자 의도에 따라 무료 PDF, Gumroad 상품, 상담으로 나누는 Claude Code CTA 설계입니다.
Claude Code 팀 인계 규칙: 리뷰 증거, 권한, 롤백, 수익 경로까지 넘기는 법
Claude Code 작업을 팀에 넘길 때 필요한 증거, 권한 규칙, 롤백, 무료 PDF, Gumroad, 상담 경로 체크리스트.