API 및 인터페이스 설계
개요
안정적이고 잘 문서화된 인터페이스를 설계하여 오용을 방지합니다. 좋은 인터페이스는 올바른 작업을 쉽게 하고 잘못된 작업을 어렵게 만듭니다. 이는 REST API, GraphQL 스키마, 모듈 경계, 컴포넌트 props 및 코드 간 통신이 발생하는 모든 표면에 적용됩니다.
사용 시기
- 새 API 엔드포인트 설계
- 모듈 경계 또는 팀 간 계약 정의
- 컴포넌트 prop 인터페이스 생성
- API 형태를 결정하는 데이터베이스 스키마 수립
- 기존 공개 인터페이스 변경
핵심 원칙
Hyrum의 법칙
API 사용자가 충분히 많은 경우, 계약에 명시된 내용과 관계없이 시스템의 모든 관찰 가능한 동작은 누군가에게 의존됩니다.
이는 문서화되지 않은 특성, 오류 메시지 텍스트, 타이밍 및 순서를 포함한 모든 공개 동작이 사용자가 의존하게 되면 사실상의 계약이 된다는 것을 의미합니다. 설계 시 고려 사항:
- 노출하는 내용에 의도적으로 접근하세요. 모든 관찰 가능한 동작은 잠재적인 약속입니다.
- 구현 세부 사항을 유출하지 마세요. 사용자가 관찰할 수 있다면 의존하게 됩니다.
- 설계 단계에서 폐지(Deprecation)를 계획하세요. 안전하게 제거하는 방법은
deprecation-and-migration을 참조하세요. - 테스트만으로는 충분하지 않습니다. 완벽한 계약 테스트가 있어도 Hyrum의 법칙에 따라 '안전한' 변경이 문서화되지 않은 동작에 의존하는 실제 사용자를 중단시킬 수 있습니다.
One-Version Rule
소비자가 동일한 종속성 또는 API의 여러 버전 중에서 선택하도록 강요하지 마십시오. 다른 소비자가 동일한 항목의 다른 버전을 필요로 할 때 다이아몬드 종속성 문제가 발생합니다. 한 번에 하나의 버전만 존재하는 세상을 위해 설계하세요 — 포크(fork) 대신 확장(extend)하세요.
1. 계약 우선(Contract First)
구현하기 전에 인터페이스를 정의하세요. 계약이 명세이며 구현은 그 뒤를 따릅니다.
// 먼저 계약 정의
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. 일관된 오류 의미론(Consistent Error Semantics)
하나의 오류 전략을 선택하고 모든 곳에서 사용하세요:
// REST: HTTP 상태 코드 + 구조화된 오류 본문
// 모든 오류 응답이 동일한 형태를 따름
interface APIError {
error: {
code: string; // 기계 판독 가능: "VALIDATION_ERROR"
message: string; // 사람 판독 가능: "Email is required"
details?: unknown; // 필요 시 추가 컨텍스트
};
}
// 상태 코드 매핑
// 400 → 클라이언트가 잘못된 데이터 전송
// 401 → 인증되지 않음
// 403 → 인증되었지만 권한 없음
// 404 → 리소스를 찾을 수 없음
// 409 → 충돌 (중복, 버전 불일치)
// 422 → 유효성 검사 실패 (의미적으로 유효하지 않음)
// 500 → 서버 오류 (내부 세부 사항 노출 금지)
패턴을 혼합하지 마세요. 일부 엔드포인트가 예외를 throw하고, 다른 엔드포인트가 null을 반환하며, 또 다른 엔드포인트가 { error }를 반환하면 소비자는 동작을 예측할 수 없습니다.
3. 경계에서 검증(Validate at Boundaries)
내부 코드를 신뢰하세요. 외부 입력이 시스템에 진입하는 경계에서 검증하세요:
// 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. 수정보다 추가 선호(Prefer Addition Over Modification)
기존 소비자를 중단시키지 않고 인터페이스를 확장하세요:
// 좋음: 선택적 필드 추가
interface CreateTaskInput {
title: string;
description?: string;
priority?: 'low' | 'medium' | 'high'; // 나중에 추가, 선택적
labels?: string[]; // 나중에 추가, 선택적
}
// 나쁨: 기존 필드 유형 변경 또는 필드 제거
interface CreateTaskInput {
title: string;
// description: string; // 제거됨 — 기존 소비자 중단
priority: number; // 문자열에서 변경됨 — 기존 소비자 중단
}
5. 예측 가능한 명명(Predictable Naming)
| 패턴 | 규칙 | 예시 |
|---|---|---|
| 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" |
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 → 작업에 댓글 추가
페이지네이션(Pagination)
목록 엔드포인트 페이지네이션:
// 요청
GET /api/tasks?page=1&pageSize=20&sortBy=createdAt&sortOrder=desc
// 응답
{
"data": [...],
"pagination": {
"page": 1,
"pageSize": 20,
"totalItems": 142,
"totalPages": 8
}
}
필터링(Filtering)
필터에 쿼리 파라미터 사용:
GET /api/tasks?status=in_progress&assignee=user123&createdAfter=2025-01-01
부분 업데이트 (PATCH)
부분 객체 수락 — 제공된 것만 업데이트:
// 제목만 변경, 나머지는 유지
PATCH /api/tasks/123
{ "title": "Updated title" }
TypeScript 인터페이스 패턴
변형을 위한 판별 유니온(Discriminated Unions) 사용
// 좋음: 각 변형이 명시적
// (type TaskStatus = ... 코드 생략, 번역 없음)
입력/출력 분리
// 입력: 호출자가 제공하는 것
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> { ... }
일반적인 합리화(Rationalizations)
| 합리화 | 현실 |
|---|---|
| "API 문서는 나중에 작성할게요" | 타입 자체가 문서입니다. 먼저 정의하세요. |
| "지금은 페이지네이션이 필요 없어요" | 항목이 100개를 넘는 순간 필요해집니다. 처음부터 추가하세요. |
| "PATCH는 복잡하니까 PUT을 사용합시다" | PUT은 매번 전체 객체를 필요로 합니다. PATCH가 실제로 클라이언트가 원하는 것입니다. |
| "필요할 때 API를 버전업하면 됩니다" | 버전 관리 없는 호환되지 않는 변경은 소비자를 중단시킵니다. 처음부터 확장을 위해 설계하세요. |
| "문서화되지 않은 동작은 아무도 사용하지 않아요" | Hyrum의 법칙: 관찰 가능하면 누군가 의존합니다. 모든 공개 동작을 약속으로 취급하세요. |
| "두 버전을 유지하면 됩니다" | 여러 버전은 유지보수 비용을 증가시키고 다이아몬드 종속성 문제를 만듭니다. One-Version Rule을 선호하세요. |
| "내부 API에는 계약이 필요 없어요" | 내부 소비자도 여전히 소비자입니다. 계약은 결합을 방지하고 병렬 작업을 가능하게 합니다. |
위험 신호(Red Flags)
- 조건에 따라 다른 형태를 반환하는 엔드포인트
- 엔드포인트 간 일관되지 않은 오류 형식
- 경계 대신 내부 코드 전체에 흩어진 검증 로직
- 기존 필드의 호환되지 않는 변경 (유형 변경, 제거)
- 페이지네이션이 없는 목록 엔드포인트
- REST URL의 동사 (
/api/createTask,/api/getUsers) - 검증 또는 정제 없이 사용되는 타사 API 응답
검증(Verification)
API 설계 후:
- 모든 엔드포인트에 타입화된 입력 및 출력 스키마가 있음
- 오류 응답이 단일 일관된 형식을 따름
- 검증이 시스템 경계에서만 수행됨
- 목록 엔드포인트가 페이지네이션을 지원함
- 새 필드는 추가적이며 선택적임 (하위 호환성)
- 명명이 모든 엔드포인트에서 일관된 규칙을 따름
- API 문서 또는 타입이 구현과 함께 커밋됨
Codex Companion Files
이 재구축된 스킬은 관련 로컬 리소스를 번들링하여 Codex가 필요할 때 동일한 스킬 디렉토리에서 로드할 수 있도록 합니다.