Skip to content
Published on

API 디자인 완전 가이드 — REST·OpenAPI·Versioning·Pagination·Idempotency·Webhook을 2025년 기준으로 한 번에 정리

Authors

프롤로그 — "좋은 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 RequestsRate 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-openapiTS First, schema 자동 생성
Typebox타입 친화적 스키마
Hono + zod-openapiHono 라우터 + OpenAPI 자동
tRPC + trpc-openapitRPC → OpenAPI 변환
FastAPI (Python)Pydantic + OpenAPI 기본
go-swagger, oapi-codegenGo
Prism, MockoonMock server
SpectralLinting
Stoplight, Readme, ScalarDocs 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 (큰 데이터엔 비추)
무한 스크롤, APICursor
내부 배치 처리, 큰 테이블 → 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 AHTTP POSTService 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

방식특징예시
WebhookHTTP POST 푸시Stripe, GitHub
QueuePull, 1:1 (Worker)SQS, RabbitMQ
StreamPull, 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가지

  1. 모든 에러 200 반환 → 클라이언트 디버깅 지옥
  2. URL에 verb (/getUserById) → RESTful 위반
  3. Pagination 없이 list 반환 → 10만 개 리소스 한 번에
  4. Idempotency 없는 결제 API → 중복 차지 사고
  5. 에러 메시지 일관성 없음 → 클라이언트 분기 폭발
  6. Breaking change without version → 신뢰 상실
  7. Webhook 서명 검증 스킵 → 위조 공격
  8. Rate Limit 없이 공개 → DDoS / 비용 폭탄
  9. ID에 순차 정수 노출 → 열거(enumeration) 공격
  10. Deprecation 통보 없이 삭제 → 고객 장애

마무리 — "API는 계약이다"

좋은 API의 본질은 계약. 한 번 공개하면:

  • 수정 어렵다
  • 고객 코드가 의존한다
  • Breaking change는 비용이다

그래서 신중하게 설계하고, 철저하게 문서화하고, 오래 유지해야 한다.

2025년 API 디자인의 10계명:

  1. 리소스 중심 URL, 복수형
  2. HTTP 메서드와 상태 코드 정확히
  3. 에러 RFC 9457 형식
  4. OpenAPI 3.1 Single Source of Truth
  5. 버저닝 정책 명확
  6. Cursor-based Pagination
  7. Idempotency Key POST
  8. Rate Limit + 표준 헤더
  9. Webhook 서명 + 재시도 + 대시보드
  10. 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로. 다음 글에서.