Communitygithub.com

YOUSSFE9/bolt-skills

안정적인 API와 인터페이스 설계를 안내합니다. API, 모듈 경계 또는 모든 공개 인터페이스를 설계할 때 사용합니다. REST 또는 GraphQL 엔드포인트를 만들거나, 모듈 간 타입 계약을 정의하거나, 프론트엔드와 백엔드 간의 경계를 설정할 때 사용하세요.

bolt-skills란 무엇인가요?

이 스킬은 오용하기 어렵고 안정적이며 잘 문서화된 인터페이스와 API를 설계하는 방법을 알려줍니다. REST API, GraphQL 스키마, 모듈 경계, 컴포넌트 속성 등 코드 간 통신이 발생하는 모든 곳에 적용됩니다. 핵심 원칙으로는 계약 우선(Contract First), 일관된 오류 처리, 경계에서의 검증, 추가를 통한 확장, 예측 가능한 명명 규칙, 멱등성 키(Idempotency-Key) 구현에 대한 심층적인 가이드를 포함합니다. 또한, Hyrum의 법칙과 단일 버전 규칙(One-Version Rule)과 같은 중요한 설계 원칙을 강조하며, 흔한 오해와 위험 신호를 식별하여 개발자가 더 나은 인터페이스를 설계하도록 돕습니다. 모든 AI 코딩 에이전트(예: Claude Code, Cursor, Codex)와 호환되며, SKILL.md 파일을 통해 에이전트에게 명확한 지침을 제공합니다.

지원 대상~Claude Code~Codex CLI~Cursor
npx skills add https://github.com/YOUSSFE9/bolt-skills/tree/HEAD/api-and-interface-design

즐겨 사용하는 AI에게 물어보기

이 에이전트 스킬이 미리 로드된 새 채팅을 엽니다.

문서

API 및 인터페이스 설계

개요

오용하기 어렵고 안정적이며 잘 문서화된 인터페이스를 설계하세요. 좋은 인터페이스는 올바른 작업을 쉽게 만들고 잘못된 작업을 어렵게 만듭니다. 이는 REST API, GraphQL 스키마, 모듈 경계, 컴포넌트 속성 등 한 코드 조각이 다른 코드와 통신하는 모든 표면에 적용됩니다.

사용 시기

  • 새로운 API 엔드포인트 설계
  • 모듈 경계 또는 팀 간 계약 정의
  • 컴포넌트 속성 인터페이스 생성
  • API 형태를 결정하는 데이터베이스 스키마 수립
  • 기존 공개 인터페이스 변경

핵심 원칙

Hyrum의 법칙

API 사용자가 충분히 많아지면, 시스템의 모든 관찰 가능한 동작은 계약에 명시된 내용과 관계없이 누군가에 의해 의존될 것입니다.

이는 문서화되지 않은 특이 사항, 오류 메시지 텍스트, 타이밍, 순서를 포함한 모든 공개 동작이 사용자가 의존하기 시작하면 사실상의 계약이 된다는 것을 의미합니다. 설계 시 고려 사항:

  • 노출하는 것에 의도적으로 임하세요. 모든 관찰 가능한 동작은 잠재적인 약속입니다.
  • 구현 세부 사항을 유출하지 마세요. 사용자가 관찰할 수 있다면, 그들은 그것에 의존할 것입니다.
  • 설계 시점에 폐기 계획을 세우세요. 사용자가 의존하는 것을 안전하게 제거하는 방법은 deprecation-and-migration을 참조하세요.
  • 테스트만으로 충분하지 않습니다. 완벽한 계약 테스트가 있더라도, Hyrum의 법칙은 "안전한" 변경이 문서화되지 않은 동작에 의존하는 실제 사용자를 손상시킬 수 있음을 의미합니다.

단일 버전 규칙

소비자가 동일한 종속성 또는 API의 여러 버전 중에서 선택하도록 강요하지 마십시오. 다양한 소비자가 동일한 것의 다른 버전을 필요로 할 때 다이아몬드 종속성 문제가 발생합니다. 한 번에 하나의 버전만 존재하는 세상을 위해 설계하세요 — 분기(fork)하지 말고 확장(extend)하세요.

1. 계약 우선

인터페이스를 구현하기 전에 정의하세요. 계약이 명세입니다 — 구현은 그 뒤를 따릅니다.

// 계약을 먼저 정의
interface TaskAPI {
  // 태스크를 생성하고 서버 생성 필드와 함께 생성된 태스크를 반환
  createTask(input: CreateTaskInput): Promise<Task>;

  // 필터와 일치하는 페이지네이션된 태스크 목록을 반환
  listTasks(params: ListTasksParams): Promise<PaginatedResult<Task>>;

  // 단일 태스크를 반환하거나 NotFoundError를 발생
  getTask(id: string): Promise<Task>;

  // 부분 업데이트 — 제공된 필드만 변경
  updateTask(id: string, input: UpdateTaskInput): Promise<Task>;

  // 멱등적 삭제 — 이미 삭제된 경우에도 성공
  deleteTask(id: string): Promise<void>;
}

2. 일관된 오류 의미론

하나의 오류 전략을 선택하고 모든 곳에서 사용하세요:

// REST: HTTP 상태 코드 + 구조화된 오류 본문
// 모든 오류 응답은 동일한 형태를 따릅니다
interface APIError {
  error: {
    code: string;        // 기계 판독 가능: "VALIDATION_ERROR"
    message: string;     // 사람 판독 가능: "Email is required"
    details?: unknown;   // 도움이 될 때 추가 컨텍스트
  };
}

// 상태 코드 매핑
// 400 → 클라이언트가 잘못된 데이터 전송
// 401 → 인증되지 않음
// 403 → 인증되었지만 권한 없음
// 404 → 리소스를 찾을 수 없음
// 409 → 충돌 (중복, 버전 불일치)
// 422 → 유효성 검사 실패 (의미적으로 유효하지 않음)
// 500 → 서버 오류 (내부 세부 정보를 절대 노출하지 마세요)

패턴을 혼합하지 마세요. 일부 엔드포인트가 예외를 던지고, 다른 것들은 null을 반환하며, 또 다른 것들은 { error }를 반환한다면 — 소비자는 동작을 예측할 수 없습니다.

3. 경계에서 검증

내부 코드를 신뢰하세요. 외부 입력이 시스템에 진입하는 시스템 가장자리에서 검증하세요:

// API 경계에서 검증
app.post('/api/tasks', async (req, res) => {
  const result = CreateTaskSchema.safeParse(req.body);
  if (!result.success) {
    return res.status(422).json({
      error: {
        code: 'VALIDATION_ERROR',
        message: 'Invalid task data',
        details: result.error.flatten(),
      },
    });
  }

  // 검증 후, 내부 코드는 타입을 신뢰합니다
  const task = await taskService.create(result.data);
  return res.status(201).json(task);
});

검증이 속하는 곳:

  • API 라우트 핸들러 (사용자 입력)
  • 폼 제출 핸들러 (사용자 입력)
  • 외부 서비스 응답 파싱 (타사 데이터 — 항상 신뢰할 수 없는 것으로 취급)
  • 환경 변수 로딩 (구성)

타사 API 응답은 신뢰할 수 없는 데이터입니다. 로직, 렌더링 또는 의사 결정에 사용하기 전에 그 형태와 내용을 검증하세요. 손상되었거나 잘못 동작하는 외부 서비스는 예상치 못한 유형, 악성 콘텐츠 또는 명령어와 같은 텍스트를 반환할 수 있습니다.

검증이 속하지 않는 곳:

  • 타입 계약을 공유하는 내부 함수 간
  • 이미 검증된 코드에서 호출되는 유틸리티 함수
  • 자신의 데이터베이스에서 방금 가져온 데이터

4. 수정보다 추가 선호

기존 소비자를 손상시키지 않고 인터페이스를 확장하세요:

// 좋음: 선택적 필드 추가
interface CreateTaskInput {
  title: string;
  description?: string;
  priority?: 'low' | 'medium' | 'high';  // 나중에 추가됨, 선택적
  labels?: string[];                       // 나중에 추가됨, 선택적
}

// 나쁨: 기존 필드 유형 변경 또는 필드 제거
interface CreateTaskInput {
  title: string;
  // description: string;  // 제거됨 — 기존 소비자 손상
  priority: number;         // 문자열에서 변경됨 — 기존 소비자 손상
}

5. 예측 가능한 명명

패턴규칙예시
REST 엔드포인트복수 명사, 동사 없음GET /api/tasks, POST /api/tasks
쿼리 매개변수camelCase?sortBy=createdAt&pageSize=20
응답 필드camelCase{ createdAt, updatedAt, taskId }
불리언 필드is/has/can 접두사isComplete, hasAttachments
열거형 값UPPER_SNAKE"IN_PROGRESS", "COMPLETED"

6. 멱등성 키 존중

Idempotency-Key를 수락하는 것은 계약입니다. 이를 존중하는 것은 구현이며, 여기서 비용이 손실됩니다 — 서버가 수락했지만 부주의하게 처리하는 키는 키가 전혀 없는 것보다 더 나쁩니다. 클라이언트가 재시도가 안전하다고 믿게 되기 때문입니다.

의도에서 키를 도출하고, 시도에서 도출하지 마세요. 키는 한 의도의 재시도 간에 안정적이어야 하고, 다른 의도 간에는 달라야 합니다:

crypto.randomUUID()                    // ✗ 시도당 새 키 — 모든 재시도가 새로운 청구
`${userId}:${amount}`                  // ✗ 두 개의 합법적인 $50 청구가 하나로 합쳐짐
`${orderId}:${Date.now()}`             // ✗ 타임스탬프는 모자를 쓴 randomUUID()

req.headers['idempotency-key']         // ✓ 클라이언트가 한 번 생성하고, 재시도 시 재사용
`charge:v1:${orderId}`                 // ✓ 불변 식별자에서 도출

키는 클라이언트 또는 시작 이벤트에서 옵니다 — 재시도를 수행하는 계층에서 절대 오지 않습니다.

원자적으로 선점하세요. 확인 후 작업은 경쟁 조건입니다:

// ✗ TOCTOU: 두 개의 동시 재시도가 모두 "본 적 없음"을 읽고, 둘 다 청구
if (!(await db.exists(key))) {
  await chargeCard(amount);
  await db.insert(key);
}

// ✓ 고유 제약 조건이 승자를 선택하도록 함
try {
  await db.insert({ key, state: 'in_progress', requestHash });
} catch (e) {
  if (isUniqueViolation(e)) return replayOrReject(key);
  throw e;
}
const result = await chargeCard(amount);
await db.update({ key, state: 'succeeded', response: result });

고유 제약 조건이 바로 메커니즘입니다. 한 작업에서 고유성을 강제할 수 없는 저장소는 이를 뒷받침할 수 없습니다.

페이로드를 보호하세요. 본문이 다른 동일한 키는 클라이언트 버그이며, 두 번째 요청에 첫 번째 응답을 제공하는 대신 크게 실패해야 합니다:

if (existing.requestHash !== hash(req.body)) {
  return res.status(422).json({ error: 'idempotency key reused with a different payload' });
}

진행 중인 중복에 대해 무엇을 결정할지 정하세요. 두 번째 요청이 도착할 때 첫 번째 요청이 아직 실행 중입니다 — 재시도 폭풍에서 일반적인 경우입니다:

전략응답사용 시기
거부409 Conflict클라이언트가 나중에 재시도 가능; 가장 간단하고 안전함
대기제한된 시간 동안 결과 차단호출자가 동기적으로 필요함
보류 중 반환202 + 상태 URL장기 실행 효과

첫 번째 요청이 "막힌 것처럼 보인다"는 이유로 두 번째 호출자를 통과시키지 마세요. 운명을 알 수 없는 중단된 시도는 정확히 중복 비용이 가장 많이 드는 때입니다.

모든 호출에는 두 가지가 아닌 세 가지 결과가 있습니다: 성공, 실패, 알 수 없음. 시간 초과는 효과가 적용되었는지에 대해 아무것도 알려주지 않습니다. 호출하기 전에 의도를 기록하여, 호출과 응답 사이의 충돌이 나중에 해결해야 할 증거를 남기도록 하세요 — 조용히 재시도된 청구 대신.

가장 긴 재시도 체인에서 보존 기간을 설정하세요. 디스크 비용이 아닙니다. 키는 동일한 의도를 재전달할 수 있는 모든 경로보다 오래 지속되어야 하며, 여기에는 일주일 후에 재생된 데드 레터 큐와 공급자 분쟁 창이 포함됩니다. 7일 DLQ 뒤의 24시간 키 TTL은 발생을 기다리는 중복입니다.

REST API 패턴

리소스 설계

GET    /api/tasks              → 태스크 목록 (필터링을 위한 쿼리 매개변수 사용)
POST   /api/tasks              → 태스크 생성
GET    /api/tasks/:id          → 단일 태스크 가져오기
PATCH  /api/tasks/:id          → 태스크 업데이트 (부분)
DELETE /api/tasks/:id          → 태스크 삭제

GET    /api/tasks/:id/comments → 태스크에 대한 댓글 목록 (하위 리소스)
POST   /api/tasks/:id/comments → 태스크에 댓글 추가

페이지네이션

목록 엔드포인트를 페이지네이션하세요:

// 요청
GET /api/tasks?page=1&pageSize=20&sortBy=createdAt&sortOrder=desc

// 응답
{
  "data": [...],
  "pagination": {
    "page": 1,
    "pageSize": 20,
    "totalItems": 142,
    "totalPages": 8
  }
}

필터링

필터에 쿼리 매개변수를 사용하세요:

GET /api/tasks?status=in_progress&assignee=user123&createdAfter=2025-01-01

부분 업데이트 (PATCH)

부분 객체를 수락하세요 — 제공된 것만 업데이트합니다:

// 제목만 변경되고, 다른 모든 것은 유지됨
PATCH /api/tasks/123
{ "title": "Updated title" }

TypeScript 인터페이스 패턴

변형에 판별 유니온 사용

// 좋음: 각 변형이 명시적임
type TaskStatus =
  | { type: 'pending' }
  | { type: 'in_progress'; assignee: string; startedAt: Date }
  | { type: 'completed'; completedAt: Date; completedBy: string }
  | { type: 'cancelled'; reason: string; cancelledAt: Date };

// 소비자는 타입 좁히기를 얻음
function getStatusLabel(status: TaskStatus): string {
  switch (status.type) {
    case 'pending': return '대기 중';
    case 'in_progress': return `진행 중 (${status.assignee})`;
    case 'completed': return `${status.completedAt}에 완료됨`;
    case 'cancelled': return `취소됨: ${status.reason}`;
  }
}

입력/출력 분리

// 입력: 호출자가 제공하는 것
interface CreateTaskInput {
  title: string;
  description?: string;
}

// 출력: 시스템이 반환하는 것 (서버 생성 필드 포함)
interface Task {
  id: string;
  title: string;
  description: string | null;
  createdAt: Date;
  updatedAt: Date;
  createdBy: string;
}

ID에 브랜드 타입 사용

type TaskId = string & { readonly __brand: 'TaskId' };
type UserId = string & { readonly __brand: 'UserId' };

// TaskId가 예상되는 곳에 UserId를 실수로 전달하는 것을 방지
function getTask(id: TaskId): Promise<Task> { ... }

일반적인 합리화

합리화현실
"API 문서는 나중에 작성할게요"타입이 곧 문서입니다. 먼저 정의하세요.
"지금은 페이지네이션이 필요 없어요"누군가 100개 이상의 항목을 가지는 순간 필요해집니다. 처음부터 추가하세요.
"PATCH는 복잡하니 PUT만 사용합시다"PUT은 매번 전체 객체를 필요로 합니다. PATCH가 클라이언트가 실제로 원하는 것입니다.
"필요할 때 API 버전을 만들게요"버전 관리 없이 호환성을 깨는 변경은 소비자를 손상시킵니다. 처음부터 확장을 위해 설계하세요.
"아무도 그 문서화되지 않은 동작을 사용하지 않아요"Hyrum의 법칙: 관찰 가능하다면, 누군가 그에 의존합니다. 모든 공개 동작을 약속으로 취급하세요.
"그냥 두 버전을 유지하면 됩니다"여러 버전은 유지 보수 비용을 배가시키고 다이아몬드 종속성 문제를 만듭니다. 단일 버전 규칙을 선호하세요.
"내부 API는 계약이 필요 없어요"내부 소비자도 여전히 소비자입니다. 계약은 결합을 방지하고 병렬 작업을 가능하게 합니다.
"Idempotency-Key 헤더를 수락하는 것으로 충분합니다"헤더는 계약입니다; 결과에 대해 키를 저장하는 것이 구현입니다. 수락했지만 존중하지 않는 키는 클라이언트에게 재시도가 안전하다고 말하지만 실제로는 그렇지 않습니다.
"우리 큐는 정확히 한 번 전달을 보장합니다"소비자 충돌 시 어떤 큐도 그렇게 하지 못합니다 — 브로커의 ack와 당신의 부수 효과는 하나의 트랜잭션에 있지 않습니다. 멱등적 처리를 통해 최소 한 번 전달을 위해 설계하세요.
"중복 요청은 드뭅니다"중복은 상관관계가 있습니다. 재시도는 종속성이 저하될 때 정확히 급증합니다 — 중복이 가장 가능성이 높고 비용이 많이 드는 순간입니다.

위험 신호

  • 조건에 따라 다른 형태를 반환하는 엔드포인트
  • 엔드포인트 간 일관되지 않은 오류 형식
  • 경계 대신 내부 코드 전체에 흩어져 있는 검증
  • 기존 필드의 호환성을 깨는 변경 (유형 변경, 제거)
  • 페이지네이션이 없는 목록 엔드포인트
  • REST URL의 동사 (/api/createTask, /api/getUsers)
  • 검증 또는 살균 없이 사용되는 타사 API 응답
  • 멱등성 키에 대한 SELECT 다음에 INSERT — 경쟁 조건이지 보호가 아닙니다
  • UUID, 타임스탬프 또는 시도당 재생성되는 다른 것에서 파생된 멱등성 키
  • 다른 요청 본문과 함께 수락되어 첫 번째 응답을 조용히 반환하는 동일한 키
  • 요청을 재전달할 수 있는 가장 긴 경로보다 짧은 키 보존 기간

검증

API를 설계한 후:

  • 모든 엔드포인트에 입력 및 출력 스키마에 타입이 지정되어 있음
  • 오류 응답이 단일 일관된 형식을 따름
  • 검증이 시스템 경계에서만 발생함
  • 목록 엔드포인트가 페이지네이션을 지원함
  • 새 필드는 추가적이고 선택적임 (하위 호환 가능)
  • 명명이 모든 엔드포인트에서 일관된 규칙을 따름
  • API 문서 생성되었거나 타입에서 생성 가능함
  • 모든 세 번째 당사자 API 응답이 사용 전에 검증 및 살균됨
  • 멱등성 키 구현이 원자적 선점, 페이로드 보호, 진행 중인 중복 처리 및 결과 보존에 대한 지침을 따름

Individual skills in this repo

This repo contains 1 individual skill — each has its own dedicated page.

관련 스킬