✍️ 필사 모드: API 디자인 완전 가이드 — REST·OpenAPI·Versioning·Pagination·Idempotency·Webhook을 2025년 기준으로 한 번에 정리
한국어프롤로그 — "좋은 API는 사랑받는다"
2025년 4월, Stripe API 문서를 열어본다.
- 한 페이지 안에 요청, 응답, SDK 예시가 다 있다
- 에러는 일관된 코드 + 친절한 메시지
- 버전은 명확 (
Stripe-Version: 2024-12-18) - Rate limit, Idempotency, Webhook signature 모두 표준화
왜 Stripe가 "최고의 API"로 불리는가? 일관성, 예측 가능성, 문서화. 그리고 이건 운이 아니라 설계다.
성능(Ep 18)이 기술적 품질이라면, API 설계는 사용자 경험 — 개발자 사용자. 좋은 API는:
- 예측 가능하다 — 첫 endpoint 보면 나머지가 짐작됨
- 안정적이다 — Breaking change 거의 없음
- 관대하다 — 부분 성공, idempotency, 에러 복구
- 투명하다 — Rate limit, quotas, deprecation을 미리 알림
이 글은 Season 2 Ep 19 — API 디자인 완전 가이드. REST Maturity부터 OpenAPI, Versioning, Pagination, Idempotency, Rate Limiting, Webhook, Async API까지.
1부 — Richardson Maturity Model — REST의 수준
4단계
Level 0: 단일 엔드포인트 + POST로 모든 것
POST /api
{ "action": "getUser", "id": 1 }
Level 1: 리소스별 URL
GET /users
POST /users/createUser (아직 verb in URL)
Level 2: HTTP 메서드 사용
GET /users/1
POST /users
PUT /users/1
DELETE /users/1
Level 3: HATEOAS (Hypermedia)
GET /orders/1
{
"id": 1, "status": "paid",
"_links": {
"self": { "href": "/orders/1" },
"cancel": { "href": "/orders/1/cancel" }
}
}
현실: 대부분 API는 Level 2. Level 3 HATEOAS는 이론적 우아함 대비 구현 오버헤드 커서 드묾.
2부 — REST API 설계 원칙 10가지
1. Resource-oriented URL
✅ /users/123/orders
❌ /getUserOrders?userId=123
2. 복수형 사용
✅ /users, /orders, /products
❌ /user, /order (단일성/복수성 섞임)
3. HTTP 메서드 의미 지키기
| Method | 의미 | Idempotent |
|---|---|---|
| GET | 읽기 (안전) | ✅ |
| POST | 생성 | ❌ |
| PUT | 전체 교체 | ✅ |
| PATCH | 부분 업데이트 | 설계에 따라 |
| DELETE | 삭제 | ✅ |
4. Status Code 올바르게
200 OK — 성공
201 Created — 생성됨 (Location 헤더)
202 Accepted — 비동기 시작
204 No Content — 성공, 본문 없음
400 Bad Request — 입력 잘못
401 Unauthorized — 인증 안 됨
403 Forbidden — 권한 없음
404 Not Found
409 Conflict — 상태 충돌
422 Unprocessable Entity — 검증 실패
429 Too Many Requests — Rate limit
500 Internal Server Error
502 Bad Gateway — 업스트림 에러
503 Service Unavailable — 일시 장애
504 Gateway Timeout
흔한 실수: 모든 에러를 200으로 반환하고 body에 { "error": ... }. 절대 금지.
5. 일관된 에러 포맷 — RFC 7807 / RFC 9457
{
"type": "https://example.com/errors/insufficient-funds",
"title": "Insufficient funds",
"status": 422,
"detail": "Account balance $50 is less than required $100",
"instance": "/accounts/12345/transactions/abc",
"errors": [
{ "field": "amount", "message": "must be <= balance" }
],
"requestId": "req_abc123"
}
RFC 9457 (Problem Details): 2023년 RFC 7807 대체. 공식 표준.
6. Response Shape 일관성
✅ 모든 리소스가 같은 envelope
{
"data": { ... },
"meta": { "requestId": "...", "timestamp": "..." }
}
✅ 또는 bare object (일관만 되면 OK)
{ "id": 1, "name": "Alice", ... }
중요: 프로젝트 내에서 한 방식. 섞이면 안 됨.
7. 시간은 ISO 8601 + UTC
{ "createdAt": "2026-04-15T12:34:56Z" }
8. 금액은 정수 (minor unit)
// ✅
{ "amount": 10000, "currency": "KRW" } // 10,000원
// ❌ float은 정확성 문제
{ "amount": 100.50 }
9. Boolean 이름
✅ isActive, hasPermission, canEdit
❌ active, permission (명확하지 않음)
10. ID는 UUID or 문자열
// ✅ (미래 확장 여지)
{ "id": "usr_01HX9G7..." }
// ❌ 정수 노출 (시퀀스 유추 가능, 보안)
{ "id": 12345 }
팁: Prefix (usr_, ord_) — 디버그 시 ID만 보고 리소스 타입 알 수 있음.
3부 — OpenAPI 3.1 실전
기본 구조
openapi: 3.1.0
info:
title: Example API
version: 1.0.0
description: |
# Overview
이 API는 ...
servers:
- url: https://api.example.com/v1
paths:
/users:
get:
summary: List users
operationId: listUsers
parameters:
- name: limit
in: query
schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
- name: cursor
in: query
schema: { type: string }
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/UserList'
components:
schemas:
User:
type: object
required: [id, email]
properties:
id: { type: string, pattern: '^usr_' }
email: { type: string, format: email }
createdAt: { type: string, format: date-time }
UserList:
type: object
properties:
data:
type: array
items: { $ref: '#/components/schemas/User' }
nextCursor: { type: string, nullable: true }
Zod로 Single Source of Truth
import { z } from 'zod';
import { generateSchema } from '@anatine/zod-openapi';
export const User = z.object({
id: z.string().startsWith('usr_'),
email: z.string().email(),
createdAt: z.string().datetime(),
});
export type User = z.infer<typeof User>;
// OpenAPI spec 생성
const openApiSchema = generateSchema(User);
2025 도구
| 도구 | 용도 |
|---|---|
| Zod + @anatine/zod-openapi | TS First, schema 자동 생성 |
| Typebox | 타입 친화적 스키마 |
| Hono + zod-openapi | Hono 라우터 + OpenAPI 자동 |
| tRPC + trpc-openapi | tRPC → OpenAPI 변환 |
| FastAPI (Python) | Pydantic + OpenAPI 기본 |
| go-swagger, oapi-codegen | Go |
| Prism, Mockoon | Mock server |
| Spectral | Linting |
| Stoplight, Readme, Scalar | Docs UI |
Spec-first vs Code-first
Spec-first: OpenAPI YAML 먼저 → 서버/클라이언트 생성
장점: 팀 간 계약, 설계 먼저
단점: 동기화 번거로움, 런타임 검증 분리
Code-first: 코드에서 스키마 선언 → OpenAPI 생성
장점: 타입 통일, 런타임 검증 자동
단점: spec 팀과 협의 지연
2025 현실: Code-first (Zod, Pydantic)이 우세. 스펙은 파생물.
4부 — Versioning 전략
5가지 방법
1. URL Path
https://api.example.com/v1/users
https://api.example.com/v2/users
장점: 명확, 캐시 친화적 단점: 모든 endpoint 중복
2. Header
Accept: application/vnd.example.v1+json
장점: URL 깔끔 단점: 디버그 어려움, 헷갈림
3. Custom Header
X-API-Version: 2024-12-18
Stripe 방식. 날짜 기반 버전.
4. Query Parameter
/users?api-version=2
단점: RESTful 아님
5. Content Negotiation
Accept: application/json; version=2
2025 권장
공개 API: URL path (v1, v2) + 날짜 헤더
내부 API: Header 기반, rolling update
모바일: URL path, 오래된 앱도 지원
Stripe의 날짜 버전 (영감 받기)
매일 새 버전 가능 (breaking change 시만 발표)
각 버전은 영원히 유지 (고객 옵트인)
내부적으로 "버전 translator" 운영
함의: 버저닝은 오래 지원할 각오. 짧게 유지 못 하면 고객 신뢰 상실.
Breaking vs Non-breaking Change
Non-breaking (OK):
- 새 필드 추가
- 새 endpoint
- 새 optional parameter
- 새 enum 값 (클라이언트가 unknown 처리하면)
Breaking (버전 올려야):
- 필드 제거/이름 변경
- 필드 타입 변경
- required field 추가
- URL 구조 변경
- 에러 코드 변경
5부 — Pagination — 3가지 방식
1. Offset-based
GET /users?limit=20&offset=40
{ "data": [...], "total": 1000, "offset": 40, "limit": 20 }
장점: 간단, "페이지 5" 같은 UI 친화 단점:
- Deep offset 느림 (OFFSET 100000은 100000+20 스캔)
- Concurrent insert 시 중복/누락
2. Cursor-based
GET /users?limit=20&cursor=eyJpZCI6MTAwfQ==
{
"data": [...],
"nextCursor": "eyJpZCI6MTIwfQ==",
"hasMore": true
}
장점: Deep pagination 빠름, 일관된 순서 단점: 랜덤 액세스 불가, UI에서 "페이지 번호" 어려움
Cursor 내용: 보통 마지막 row의 ID + 정렬 값을 base64 인코딩.
3. Keyset-based (Seek Method)
-- offset 방식 (느림)
SELECT * FROM users ORDER BY id LIMIT 20 OFFSET 100000;
-- keyset (빠름)
SELECT * FROM users WHERE id > 100020 ORDER BY id LIMIT 20;
장점: 매우 빠름 (인덱스 활용) 단점: 정렬 기준 변경 시 복잡
선택 가이드
UI에 "1 2 3 ... 10" 필요 → Offset (큰 데이터엔 비추)
무한 스크롤, API → Cursor
내부 배치 처리, 큰 테이블 → Keyset
6부 — Idempotency
왜 필요한가
사용자가 "결제" 버튼 클릭 → 네트워크 타임아웃 → 재시도
서버는 같은 요청 2번 받음 → 결제 2번 일어남 → 사고
해결: Idempotency Key
Stripe 방식 (표준)
POST /charges HTTP/1.1
Idempotency-Key: f8a9b2c3-...-xyz
Content-Type: application/json
{ "amount": 10000, "currency": "KRW" }
서버 로직:
1. Idempotency-Key 검사
2. 있으면:
- 같은 요청 body 이면: 저장된 응답 반환
- 다른 body 이면: 409 Conflict
3. 없으면: 신규 처리 + 결과 저장 (24시간 TTL)
어떤 메서드에 필요한가
- GET, PUT, DELETE: 기본적으로 idempotent
- POST: Idempotency Key 적용 필수
- PATCH: 설계에 따라
구현 예시 (Node.js + Redis)
async function idempotentHandler(req, res) {
const key = req.headers['idempotency-key'];
if (!key) return res.status(400).json({ error: 'Idempotency-Key required' });
// Lock으로 동시 요청 방지
const lock = await redis.set(`lock:${key}`, '1', 'NX', 'EX', 30);
if (!lock) {
// 이미 처리 중 → 잠깐 기다렸다 결과 확인
await sleep(500);
}
const cached = await redis.get(`idempotency:${key}`);
if (cached) {
const { bodyHash, response } = JSON.parse(cached);
const currentHash = hash(req.body);
if (bodyHash !== currentHash) {
return res.status(409).json({ error: 'Conflicting request' });
}
return res.status(response.status).json(response.body);
}
// 실제 처리
const result = await processPayment(req.body);
await redis.set(
`idempotency:${key}`,
JSON.stringify({ bodyHash: hash(req.body), response: { status: 200, body: result } }),
'EX',
86400
);
return res.json(result);
}
7부 — Rate Limiting
3가지 알고리즘
1. Token Bucket
버킷에 토큰이 일정 속도로 쌓임 (100개/초)
요청은 토큰 1개 소비
토큰 없으면 거부
버스트 허용 (버킷 크기 = 200 → 순간 200 요청 OK)
2. Leaky Bucket
요청이 큐에 쌓임
일정 속도로 처리 (100개/초)
큐 넘치면 거부
버스트 제어 (일정 속도 보장)
3. Sliding Window
지난 1분간 요청 수 카운트
1000 초과 시 거부
Redis sorted set으로 구현
트레이드오프:
| 알고리즘 | 버스트 | 정확성 | 구현 난이도 |
|---|---|---|---|
| Token Bucket | 허용 | 중간 | 쉬움 |
| Leaky Bucket | 제한 | 높음 | 중간 |
| Sliding Window | 제한 | 매우 높음 | 어려움 |
응답 헤더 표준
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 42
X-RateLimit-Reset: 1697050800
또는 RFC 9466 (draft):
RateLimit-Limit: 100, 100;w=60
RateLimit-Remaining: 42
RateLimit-Reset: 58
429 에러 + Retry-After
HTTP/1.1 429 Too Many Requests
Retry-After: 60
Content-Type: application/json
{
"error": "Rate limit exceeded",
"retryAfter": 60
}
구현 도구
- Redis + Lua script: 원자적 카운팅
- Cloudflare Rate Limiting: Edge 레벨
- Envoy Rate Limit service: Service Mesh
- Kong, Tyk, Traefik: API Gateway 내장
8부 — Webhook 설계
Webhook이란
Service A → HTTP POST → Service B
이벤트 발생 시 Service B URL로 알림
예: GitHub push → CI, Stripe payment → 내 서버
7가지 필수 요소
1. Signing (서명)
Header: X-Example-Signature: t=1697050800,v1=abc123...
Body: {"event": "payment.succeeded", ...}
서명 계산: HMAC-SHA256(secret, timestamp + "." + body)
목적:
- Origin 확인 (정말 Stripe가 보냈는지)
- Tampering 방지
function verifyWebhook(body: string, signature: string, secret: string): boolean {
const [t, v1] = signature.split(',').map(s => s.split('=')[1]);
const timestamp = parseInt(t);
if (Math.abs(Date.now()/1000 - timestamp) > 300) return false; // 5분 이내
const expected = hmacSha256(secret, `${t}.${body}`);
return timingSafeEqual(expected, v1);
}
2. Timestamp (재전송 공격 방지)
5분 이내만 허용.
3. Retry with Backoff
재시도 스케줄 예시:
1분 → 5분 → 15분 → 1시간 → 6시간 → 24시간
최대 3일까지
실패 시 알림 / 대시보드 표시
4. Idempotency
Webhook ID 포함 → 수신자가 중복 처리 안 하도록.
{ "id": "evt_123...", "event": "payment.succeeded", ... }
5. Ordered vs Unordered
Ordered: 순서 보장 (복잡, 속도 느림)
Unordered: 순서 보장 X (빠름, 수신자가 재정렬 or latest-wins)
Stripe: Unordered. 수신자는 최신 상태를 API로 재확인.
6. Version
Accept-Version: 2024-12-18 (헤더) 또는
{"apiVersion": "2024-12-18", "event": "..."}
7. Testing / Replay
- 대시보드에서 이벤트 수동 재전송
- 테스트 모드: Stripe CLI
stripe listen - Signing secret로컬 dev에서 위조 검증 끄기 X (보안 습관)
수신자 체크리스트
1. 서명 검증 (무조건 먼저)
2. Timestamp 확인 (5분 이내)
3. Idempotency (이벤트 ID로 중복 체크)
4. 200 즉시 반환 (긴 처리는 큐로)
5. 비동기 처리 (Bull, Celery 등)
6. 실패 시 5xx 반환 → 발신자가 재시도
9부 — Async API와 이벤트 기반
CloudEvents — CNCF 표준
{
"specversion": "1.0",
"type": "com.example.order.created",
"source": "/orders",
"id": "evt_abc",
"time": "2026-04-15T12:00:00Z",
"datacontenttype": "application/json",
"data": { "orderId": 123, "amount": 10000 }
}
장점: 표준화된 envelope. Knative, Argo Events, Kafka 등 범용.
AsyncAPI — API 명세 for Events
asyncapi: 3.0.0
info:
title: Order Service Events
version: 1.0.0
channels:
orderCreated:
address: orders.created
messages:
orderCreatedMessage:
payload:
type: object
properties:
orderId: { type: string }
amount: { type: integer }
도구: AsyncAPI Generator → 문서/클라이언트 생성.
Webhook vs Queue vs Stream
| 방식 | 특징 | 예시 |
|---|---|---|
| Webhook | HTTP POST 푸시 | Stripe, GitHub |
| Queue | Pull, 1:1 (Worker) | SQS, RabbitMQ |
| Stream | Pull, 1:N, 순서 | Kafka, Kinesis |
선택: 외부 통합 → Webhook, 내부 작업 → Queue, 감사/분석 → Stream.
10부 — GraphQL vs gRPC vs tRPC vs REST 심화
언제 무엇?
REST (+ OpenAPI):
- 외부 공개 API
- 캐시 중요
- 다양한 클라이언트 (웹, 모바일, 서버)
GraphQL:
- 복잡한 데이터 그래프
- 프론트가 필드 선택 원함
- 모바일 (under-fetching 중요)
gRPC:
- 내부 마이크로서비스
- 고성능 바이너리
- 다양한 언어 간
tRPC:
- 모노레포 TS 풀스택
- 빠른 개발, 타입 자동
- 외부 클라이언트 없음
REST + TypeSpec — 2025 부상
// typespec
model User {
id: string;
email: string;
}
@route("/users")
interface Users {
@get list(): User[];
@get read(@path id: string): User;
}
Microsoft가 만든 IDL. OpenAPI 컴파일. 간결함.
11부 — 보안과 인증
인증 방식
| 방식 | 특징 |
|---|---|
| API Key | 간단, 로테이션 어려움 |
| OAuth 2.0 | 사용자 위임, 복잡 |
| JWT | 무상태, 취소 어려움 |
| Session Cookie | 취소 쉬움, CSRF 주의 |
| mTLS | 서비스 간, 강력 |
API Key 베스트 프랙티스
1. Prefix (sk_live_, pk_test_)로 구분
2. 최소 32자 무작위
3. 해시 저장 (비교는 hash == hash)
4. 권한 분리 (read-only, admin 등)
5. 로테이션 쉽게 (old + new 동시 유효 기간)
6. Secrets Scanner 제휴 (GitHub secret scanning)
Rate Limit + Auth
API Key별 rate limit 다르게
- Free tier: 100/min
- Paid: 1000/min
- Enterprise: 무제한 (단, soft limit 알림)
CORS
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization, Idempotency-Key
Access-Control-Max-Age: 86400
주의: Access-Control-Allow-Origin: * + Credentials: true는 브라우저가 거부. 명확한 origin 지정.
12부 — Deprecation과 Sunset
Sunset Header (RFC 8594)
Sunset: Wed, 01 Jan 2027 00:00:00 GMT
Deprecation: true
Link: <https://docs.example.com/v2>; rel="successor-version"
정책:
- 6개월 전: Deprecation 헤더 시작
- 3개월 전: 경고 메일/대시보드
- 1개월 전: 마지막 알림
- Sunset 후: 410 Gone 반환
마이그레이션 지원
- Migration guide 문서
- Side-by-side 버전 둘 다 지원 기간
- Usage dashboard — 고객이 자신의 API 사용량 확인 가능
- CLI 경고: Stripe의
stripe-node버전별 알림
13부 — 6개월 로드맵
1개월차: REST 원칙 10가지. HTTP 메서드/상태 코드/에러 포맷 정리 2개월차: OpenAPI 3.1 스펙 작성. Zod + zod-openapi 연동 3개월차: Pagination 3방식 구현. Cursor 인코딩/디코딩 4개월차: Idempotency + Rate Limiting Redis로 구현 5개월차: Webhook 시스템 설계. 서명, 재시도, 대시보드 6개월차: API 문서 자동화 (Scalar, Mintlify). SDK 생성 (oapi-codegen)
14부 — 체크리스트 12개
- OpenAPI 3.1 스펙 자동 생성 (code-first)
- HTTP 메서드/상태 코드 일관성
- 에러 포맷 RFC 9457 준수
- Idempotency-Key POST에 필수화
- Rate Limit + RateLimit 헤더
- Cursor-based Pagination (신규 엔드포인트)
- Webhook 서명 (HMAC-SHA256)
- Webhook 재시도 + 대시보드
- API Versioning 정책 문서
- Sunset/Deprecation 헤더
- CORS 명시적 origin
- SDK 또는 클라이언트 자동 생성
15부 — 안티패턴 10가지
- 모든 에러 200 반환 → 클라이언트 디버깅 지옥
- URL에 verb (
/getUserById) → RESTful 위반 - Pagination 없이 list 반환 → 10만 개 리소스 한 번에
- Idempotency 없는 결제 API → 중복 차지 사고
- 에러 메시지 일관성 없음 → 클라이언트 분기 폭발
- Breaking change without version → 신뢰 상실
- Webhook 서명 검증 스킵 → 위조 공격
- Rate Limit 없이 공개 → DDoS / 비용 폭탄
- ID에 순차 정수 노출 → 열거(enumeration) 공격
- Deprecation 통보 없이 삭제 → 고객 장애
마무리 — "API는 계약이다"
좋은 API의 본질은 계약. 한 번 공개하면:
- 수정 어렵다
- 고객 코드가 의존한다
- Breaking change는 비용이다
그래서 신중하게 설계하고, 철저하게 문서화하고, 오래 유지해야 한다.
2025년 API 디자인의 10계명:
- 리소스 중심 URL, 복수형
- HTTP 메서드와 상태 코드 정확히
- 에러 RFC 9457 형식
- OpenAPI 3.1 Single Source of Truth
- 버저닝 정책 명확
- Cursor-based Pagination
- Idempotency Key POST
- Rate Limit + 표준 헤더
- Webhook 서명 + 재시도 + 대시보드
- Deprecation 통보 + Sunset 헤더
다음 글은 Season 2 Ep 20 — SaaS 아키텍처 완전 가이드. Multi-tenancy, Billing, Feature Flag, Audit Log, RBAC/ABAC, Usage Metering까지. API를 만들었다면, 그걸 SaaS로 파는 법을 알아보자.
다음 글 예고 — "SaaS 아키텍처 완전 가이드: Multi-tenancy·Billing·Feature Flag·Audit"
Season 2 Ep 20은:
- Multi-tenant 전략 (Silo/Pool/Bridge)
- Billing + Subscription (Stripe Billing, Lago)
- Feature Flag (LaunchDarkly, Unleash)
- Audit Log 설계
- RBAC vs ABAC
- Usage Metering + Pricing
- SSO (SAML, OIDC)
- Data export / Import
좋은 API를 SaaS로. 다음 글에서.
현재 단락 (1/483)
2025년 4월, Stripe API 문서를 열어본다.