Use Cases (업데이트: 2026. 6. 2.)

Claude Code로 프로덕션 API 개발하기: OpenAPI, Next.js, Zod, CI

Claude Code로 프로덕션 API를 만드는 방법. OpenAPI 계약, Next.js Route Handler, Zod 검증, 테스트와 CI까지 다룹니다.

Claude Code로 프로덕션 API 개발하기: OpenAPI, Next.js, Zod, CI

Claude Code로 API를 만들 때 가장 위험한 요청은 “동작하는 엔드포인트 하나 만들어줘”에서 끝나는 것입니다. 데모에서는 충분해 보이지만, 프로덕션 API에는 계약, 입력 검증, 인증, 멱등성, 레이트 리밋, 일관된 오류 형식, 로그, 테스트, CI가 필요합니다. 이 항목들이 빠지면 나중에 운영 장애나 리뷰 지연으로 되돌아옵니다.

이 글은 Claude Code를 단순 코드 생성기가 아니라 프로덕션 API 개발 파트너로 사용하는 방법을 정리합니다. 순서는 계약 우선입니다. 먼저 OpenAPI로 API의 약속을 고정하고, Next.js Route Handler로 구현하며, Zod로 외부 입력을 검증하고, Vitest와 GitHub Actions로 팀에 넘길 수 있는 상태를 만듭니다.

Masa가 작은 주문 API를 실험했을 때 처음에는 “POST /orders를 만들어줘”라고만 지시했습니다. 그 결과 오류 응답 모양, 재시도 처리, 로그 필드가 매번 달랐습니다. OpenAPI 계약, 인증 경계, 멱등 규칙, 오류 envelope, CI 명령까지 프롬프트에 넣자 리뷰 기준이 훨씬 명확해졌습니다.

OpenAPI로 먼저 계약을 고정한다

OpenAPI는 HTTP API의 경로, 메서드, 요청 본문, 응답, 인증 방식을 기계가 읽을 수 있게 쓰는 표준입니다. 쉽게 말하면 구현 전에 정하는 API 약속입니다. 공식 기준은 OpenAPI Specification을 확인합니다.

openapi: 3.1.0
info:
  title: Orders API
  version: 1.0.0
paths:
  /api/orders:
    post:
      operationId: createOrder
      summary: Create an order
      security:
        - bearerAuth: []
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema:
            type: string
            minLength: 8
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateOrder"
      responses:
        "201":
          description: Order created
        "400":
          description: Invalid request
        "401":
          description: Missing or invalid token
        "409":
          description: Idempotency key reused with another payload
        "429":
          description: Rate limit exceeded
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
  schemas:
    CreateOrder:
      type: object
      required: [customerId, items, currency]
      properties:
        customerId:
          type: string
          minLength: 3
        currency:
          type: string
          enum: [JPY, USD, EUR]
        items:
          type: array
          minItems: 1

Claude Code에는 다음처럼 “무엇을 만들지”보다 “무엇을 깨면 안 되는지”를 명확히 전달합니다.

openapi.yaml을 계약으로 보고 Next.js App Router의 POST /api/orders를 구현하세요.
Zod로 requestBody를 검증하고, Authorization: Bearer <token>을 입구에서 확인하세요.
Idempotency-Key는 필수이며 같은 key와 같은 payload는 같은 응답을 재생합니다.
같은 key로 다른 payload가 오면 409를 반환합니다.
60초 단위 레이트 리밋, 통일된 오류 envelope, Vitest 테스트, GitHub Actions CI도 포함하세요.

Claude Code의 기본 동작은 Claude Code overview를, Route Handler 문법은 Next.js Route Handlers를 기준으로 확인하세요.

Next.js와 Zod로 입력 경계를 닫는다

API 경계는 외부에서 온 데이터를 처음으로 믿지 않는 지점입니다. 브라우저, 모바일 앱, 파트너 시스템, webhook은 누락된 필드, 오래된 enum, 잘못된 JSON, 중복 요청을 보낼 수 있습니다. Zod는 TypeScript에서 런타임 검증을 해 주는 라이브러리이며, 공식 문서는 Zod입니다.

아래 코드는 app/api/orders/route.ts에 그대로 둘 수 있는 샘플입니다. 로컬 실행을 위해 Map을 사용하지만, 실제 운영에서는 주문 저장소, 멱등성 저장소, 레이트 리밋을 DB나 Redis 같은 공유 저장소로 옮겨야 합니다.

import { z } from "zod";

export const runtime = "nodejs";

const CreateOrderSchema = z.object({
  customerId: z.string().min(3),
  currency: z.enum(["JPY", "USD", "EUR"]),
  items: z.array(z.object({
    sku: z.string().min(1),
    quantity: z.number().int().positive().max(99),
  })).min(1),
  note: z.string().max(500).optional(),
});

type Order = z.infer<typeof CreateOrderSchema> & {
  id: string;
  status: "accepted";
  createdAt: string;
};

const orders = new Map<string, Order>();
const idempotency = new Map<string, { fingerprint: string; status: number; body: unknown }>();
const buckets = new Map<string, { count: number; resetAt: number }>();

export function __resetForTests() {
  orders.clear();
  idempotency.clear();
  buckets.clear();
}

function send(status: number, body: unknown, headers: Record<string, string> = {}) {
  return Response.json(body, { status, headers });
}

function fail(status: number, code: string, message: string, requestId: string, details?: unknown) {
  return send(status, { error: { code, message, requestId, ...(details ? { details } : {}) } });
}

function actor(req: Request) {
  const expected = process.env.API_TOKEN;
  const raw = req.headers.get("authorization") ?? "";
  const token = raw.startsWith("Bearer ") ? raw.slice(7) : "";
  return expected && token === expected ? token.slice(0, 12) : null;
}

function allowed(key: string) {
  const now = Date.now();
  const current = buckets.get(key);
  if (!current || current.resetAt <= now) {
    buckets.set(key, { count: 1, resetAt: now + 60_000 });
    return true;
  }
  if (current.count >= 30) return false;
  current.count += 1;
  return true;
}

export async function POST(req: Request) {
  const requestId = req.headers.get("x-request-id") ?? crypto.randomUUID();
  const who = actor(req);
  if (!who) return fail(401, "unauthorized", "Invalid API token.", requestId);
  if (!allowed(who)) return fail(429, "rate_limited", "Too many requests.", requestId);

  const idempotencyKey = req.headers.get("idempotency-key");
  if (!idempotencyKey || idempotencyKey.length < 8) {
    return fail(400, "missing_idempotency_key", "Idempotency-Key is required.", requestId);
  }

  const rawBody = await req.text();
  const cacheKey = `${who}:${idempotencyKey}`;
  const cached = idempotency.get(cacheKey);
  if (cached && cached.fingerprint !== rawBody) {
    return fail(409, "idempotency_conflict", "Same key was used with another payload.", requestId);
  }
  if (cached) return send(cached.status, cached.body, { "x-idempotent-replay": "true" });

  let payload: unknown;
  try {
    payload = JSON.parse(rawBody);
  } catch {
    return fail(400, "invalid_json", "Request body must be JSON.", requestId);
  }

  const parsed = CreateOrderSchema.safeParse(payload);
  if (!parsed.success) {
    return fail(400, "validation_failed", "Request does not match the contract.", requestId, parsed.error.flatten());
  }

  const order: Order = { ...parsed.data, id: crypto.randomUUID(), status: "accepted", createdAt: new Date().toISOString() };
  orders.set(order.id, order);

  const body = { data: order, meta: { requestId } };
  idempotency.set(cacheKey, { fingerprint: rawBody, status: 201, body });
  console.info("orders.create", { requestId, orderId: order.id, itemCount: order.items.length });
  return send(201, body, { "x-request-id": requestId });
}

오류 envelope, 멱등성, 레이트 리밋, 관측성

오류 envelope는 모든 실패 응답이 공유하는 형태입니다. API마다 오류 모양이 다르면 클라이언트는 재시도할지, 사용자에게 보여줄지, 지원팀에 넘길지 판단하기 어렵습니다.

{
  "error": {
    "code": "validation_failed",
    "message": "Request does not match the contract.",
    "requestId": "6f0c9c0f-6db7-4bdf-930b-7cc7d13f3f77",
    "details": {
      "fieldErrors": {
        "items": ["Array must contain at least 1 element(s)"]
      }
    }
  }
}

멱등성은 같은 작업을 다시 보내도 두 번째 부작용이 생기지 않는 성질입니다. 주문, 결제, 이메일 발송, 포인트 지급에서 특히 중요합니다. 레이트 리밋은 짧은 시간에 너무 많은 호출이 들어오는 것을 막습니다. 관측성은 나중에 무슨 일이 있었는지 추적할 수 있는 상태를 뜻하며, requestId, 작업명, 리소스 ID, 처리 시간, 오류 코드가 핵심입니다.

실제 적용 사례는 세 가지 이상입니다. B2B SaaS 주문 API는 관리자 화면과 파트너 연동이 같은 API를 호출하므로 인증과 멱등성이 필요합니다. 내부 승인 도구는 더블 클릭이나 브라우저 재전송으로 같은 승인이 두 번 실행되는 것을 막아야 합니다. Webhook 수신 API는 외부 서비스가 실패 이벤트를 재전송하므로 이벤트 ID나 멱등 키로 중복을 제거해야 합니다. 공개 무료 API는 429 응답과 로그가 없으면 사용자도 운영자도 원인을 찾기 어렵습니다.

테스트와 CI를 완료 조건으로 둔다

테스트를 나중으로 미루면 거의 작성되지 않습니다. Claude Code 요청에 테스트를 포함하면 리뷰가 행동 중심으로 바뀝니다. 아래 Vitest는 HTTP 서버를 띄우지 않고 Route Handler를 직접 호출합니다.

import { beforeEach, describe, expect, it } from "vitest";
import { POST, __resetForTests } from "../app/api/orders/route";

const validOrder = {
  customerId: "cus_123",
  currency: "JPY",
  items: [{ sku: "book-001", quantity: 2 }],
};

function req(body: unknown, headers: Record<string, string> = {}) {
  return new Request("http://localhost/api/orders", {
    method: "POST",
    headers: {
      "content-type": "application/json",
      authorization: "Bearer test-token",
      "idempotency-key": crypto.randomUUID(),
      ...headers,
    },
    body: JSON.stringify(body),
  });
}

describe("POST /api/orders", () => {
  beforeEach(() => {
    process.env.API_TOKEN = "test-token";
    __resetForTests();
  });

  it("creates an order", async () => {
    const res = await POST(req(validOrder));
    expect(res.status).toBe(201);
    expect((await res.json()).data.status).toBe("accepted");
  });

  it("rejects invalid input", async () => {
    const res = await POST(req({ ...validOrder, items: [] }));
    expect(res.status).toBe(400);
    expect((await res.json()).error.code).toBe("validation_failed");
  });

  it("returns 409 for conflicting idempotency reuse", async () => {
    const key = "order-key-001";
    await POST(req(validOrder, { "idempotency-key": key }));
    const res = await POST(req({ ...validOrder, currency: "USD" }, { "idempotency-key": key }));
    expect(res.status).toBe(409);
  });
});

CI는 사람이 쓴 인수인계 문서보다 오래 남습니다. GitHub Actions는 공식 Workflow syntax를 기준으로 작성하고, OpenAPI lint와 API 테스트를 같이 실행합니다.

name: api-contract
on:
  pull_request:
    paths:
      - "app/api/**"
      - "tests/**/*.route.test.ts"
      - "openapi.yaml"
jobs:
  api-contract:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npx @redocly/cli lint openapi.yaml
      - run: npx vitest run tests/**/*.route.test.ts

흔한 함정과 수익화 연결

첫 번째 함정은 OpenAPI와 Zod가 따로 변하는 것입니다. 계약에서 필수인 필드가 런타임에서 optional이면 클라이언트 SDK와 서버 동작이 어긋납니다. 두 번째는 같은 멱등 키를 무조건 허용하는 것입니다. 같은 키와 다른 payload는 409로 막아야 합니다. 세 번째는 메모리 레이트 리밋을 그대로 운영에 넣는 것입니다. 인스턴스가 여러 개면 제한이 느슨해집니다. 네 번째는 오류와 로그에 토큰, 주소, 결제 정보 같은 민감한 값을 남기는 것입니다.

팀이 Claude Code를 백엔드 개발에 도입하려 한다면 Claude Code 교육 및 상담으로 연결하고, 개인 개발자에게는 무료 치트시트가 자연스러운 다음 단계입니다. 관련 글로는 API 테스트 자동화API 버전 관리 전략을 함께 보면 좋습니다.

실제로 이 흐름을 써 보니 단순한 “엔드포인트 생성”보다 리뷰가 쉬웠습니다. 409 멱등 충돌, Zod fieldErrors, requestId 로그, OpenAPI lint가 처음부터 들어가면 Claude Code의 결과물이 데모 코드가 아니라 팀이 이어받을 수 있는 API 출발점이 됩니다.

#Claude Code #API 개발 #OpenAPI #Next.js #Zod #CI
무료

무료 PDF: Claude Code 치트시트

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

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

Masa

작성자 소개

Masa

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