Skip to content

필사 모드: REST API 설계 베스트 프랙티스 2025: 개발자가 반드시 알아야 할 API 설계 원칙과 실전 패턴

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.
원문 렌더가 준비되기 전까지 텍스트 가이드로 표시합니다.

들어가며

API 경제의 규모는 2027년까지 13.7조 달러에 이를 것으로 예측됩니다. Stripe, Twilio, SendGrid 같은 API-first 기업들이 수십억 달러의 가치를 만들어내고 있으며, 모든 현대 소프트웨어는 API를 통해 연결됩니다. 잘 설계된 API는 개발자 경험(DX)을 극대화하고, 유지보수를 쉽게 하며, 비즈니스 성장의 핵심 동력이 됩니다.

이 글에서는 REST API 설계의 기초부터 고급 패턴까지, 2025년 현재 업계 표준과 실전에서 검증된 베스트 프랙티스를 체계적으로 다룹니다. Richardson Maturity Model, URL 설계, HTTP 메서드, 상태 코드, 페이지네이션, 에러 처리, 버전 관리, 보안, OpenAPI까지 모든 주제를 코드 예제와 함께 설명합니다.

1. API 설계가 중요한 이유

API 경제의 성장

| 지표 | 수치 |

| -------------------------------- | -------------------- |

| 글로벌 API 경제 규모 (2027 예측) | 13.7조 달러 |

| Salesforce 매출 중 API 비중 | 50% 이상 |

| 평균 기업이 사용하는 API 수 | 15,000+ |

| API-first 기업 성장률 | 전통 기업 대비 2.6배 |

나쁜 API 설계의 비용

- 개발자 온보딩 시간 증가 (평균 2주 추가)

- 고객 이탈률 증가 (나쁜 DX는 30% 높은 이탈률)

- 유지보수 비용 기하급수적 증가

- Breaking change로 인한 클라이언트 장애

API-First 설계 원칙

API-First란 애플리케이션 구현 전에 API 명세를 먼저 설계하는 접근 방식입니다.

1단계: OpenAPI 명세 먼저 작성

openapi: 3.1.0

info:

title: User Service API

version: 1.0.0

paths:

/api/v1/users:

get:

summary: List users

parameters:

- name: page

in: query

schema:

type: integer

responses:

'200':

description: Success

설계 순서:

1. API 명세 작성 (OpenAPI)

2. 명세 리뷰 및 합의

3. Mock 서버로 프론트엔드 개발 시작

4. 백엔드 구현

5. Contract Testing

2. REST 기초: Richardson Maturity Model

Leonard Richardson이 제안한 REST API 성숙도 모델은 4단계로 나뉩니다.

Level 0: The Swamp of POX

모든 요청이 하나의 엔드포인트로 전송됩니다. SOAP 스타일과 유사합니다.

POST /api HTTP/1.1

Content-Type: application/json

{

"action": "getUser",

"userId": 123

}

Level 1: Resources

URI를 통해 개별 리소스를 식별하지만, HTTP 메서드를 제대로 활용하지 않습니다.

POST /api/users/123 HTTP/1.1

Content-Type: application/json

{

"action": "get"

}

Level 2: HTTP Verbs

HTTP 메서드를 올바르게 사용하여 리소스에 대한 작업을 표현합니다. 대부분의 REST API가 이 수준입니다.

GET /api/users/123 HTTP/1.1

Accept: application/json

PUT /api/users/123 HTTP/1.1

Content-Type: application/json

{

"name": "Updated Name",

"email": "user@example.com"

}

Level 3: Hypermedia Controls (HATEOAS)

응답에 관련 리소스 링크를 포함하여 클라이언트가 동적으로 API를 탐색할 수 있습니다.

{

"id": 123,

"name": "John Doe",

"email": "john@example.com",

"_links": {

"self": { "href": "/api/v1/users/123" },

"orders": { "href": "/api/v1/users/123/orders" },

"update": { "href": "/api/v1/users/123", "method": "PUT" },

"delete": { "href": "/api/v1/users/123", "method": "DELETE" }

}

}

REST의 6가지 제약 조건

| 제약 조건 | 설명 |

| --------------------- | -------------------------------------------- |

| Client-Server | 클라이언트와 서버의 관심사 분리 |

| Stateless | 각 요청은 독립적, 서버에 세션 저장 안 함 |

| Cacheable | 응답에 캐시 가능 여부 명시 |

| Uniform Interface | 일관된 인터페이스로 상호작용 |

| Layered System | 중간 계층(프록시, 로드밸런서) 허용 |

| Code on Demand (선택) | 서버가 클라이언트에 실행 가능 코드 전송 가능 |

3. URL 설계: 네이밍 컨벤션과 패턴

핵심 원칙

- **명사를 사용하라** (동사 X) — 리소스를 표현

- **복수형을 사용하라** — `/users`, `/orders`, `/products`

- **소문자와 하이픈** — `/user-profiles` (snake_case나 camelCase 아님)

- **계층 구조** — `/users/123/orders/456`

- **동사는 예외적 경우에만** — `/users/123/activate` (상태 변경 action)

URL 설계 20가지 예시

컬렉션

GET /api/v1/users # 사용자 목록

POST /api/v1/users # 사용자 생성

개별 리소스

GET /api/v1/users/123 # 특정 사용자 조회

PUT /api/v1/users/123 # 사용자 전체 수정

PATCH /api/v1/users/123 # 사용자 부분 수정

DELETE /api/v1/users/123 # 사용자 삭제

중첩 리소스

GET /api/v1/users/123/orders # 사용자의 주문 목록

GET /api/v1/users/123/orders/456 # 특정 주문 조회

POST /api/v1/users/123/orders # 주문 생성

필터링

GET /api/v1/users?role=admin # 관리자만 조회

GET /api/v1/products?category=electronics&min_price=100

검색

GET /api/v1/users/search?q=john # 사용자 검색

상태 변경 (action)

POST /api/v1/users/123/activate # 계정 활성화

POST /api/v1/orders/456/cancel # 주문 취소

POST /api/v1/payments/789/refund # 결제 환불

배치 작업

POST /api/v1/users/batch # 일괄 생성

DELETE /api/v1/users/batch # 일괄 삭제

서브 리소스 (singleton)

GET /api/v1/users/123/profile # 사용자 프로필

PUT /api/v1/users/123/avatar # 아바타 업데이트

중첩 vs 플랫 — 언제 무엇을 쓸까?

| 패턴 | 예시 | 장점 | 단점 |

| ---------- | ------------------------- | ---------------- | ----------------- |

| 중첩 | `/users/123/orders` | 소유 관계 명확 | 깊은 중첩 시 복잡 |

| 플랫 | `/orders?user_id=123` | 독립적 접근 가능 | 관계 불명확 |

| 하이브리드 | 2단계까지 중첩, 이후 쿼리 | 균형 잡힌 접근 | - |

> **실전 가이드**: 중첩은 최대 2단계까지만 사용하세요. `/users/123/orders`는 OK, `/users/123/orders/456/items/789/reviews`는 너무 깊습니다.

4. HTTP 메서드 심층 분석

메서드별 의미론

GET 리소스 조회 (읽기) — 안전, 멱등

POST 리소스 생성 (쓰기) — 안전하지 않음, 비멱등

PUT 리소스 전체 교체 (덮어쓰기) — 안전하지 않음, 멱등

PATCH 리소스 부분 수정 — 안전하지 않음, 비멱등(일반적)

DELETE 리소스 삭제 — 안전하지 않음, 멱등

OPTIONS 지원 메서드 확인 — 안전, 멱등

HEAD GET과 동일, 본문 없음 — 안전, 멱등

멱등성(Idempotency) 심층 이해

멱등성이란 같은 요청을 1번 보내든 100번 보내든 결과(서버 상태)가 동일하다는 것입니다.

| 메서드 | 멱등 | 안전 | 설명 |

| ------- | ---- | ---- | -------------------------------------------------- |

| GET | O | O | 리소스 상태 변경 없음 |

| HEAD | O | O | GET과 동일, 본문 없음 |

| OPTIONS | O | O | 메타데이터만 반환 |

| PUT | O | X | 같은 데이터로 여러 번 덮어써도 결과 동일 |

| DELETE | O | X | 이미 삭제된 리소스 삭제 시도는 404이지만 상태 동일 |

| POST | X | X | 매번 새로운 리소스 생성 |

| PATCH | X | X | 상대적 변경 시 결과가 달라질 수 있음 |

PUT vs PATCH 차이

// PUT: 전체 리소스 교체 (모든 필드 필요)

PUT /api/v1/users/123

{

"name": "John Doe",

"email": "john@example.com",

"age": 30,

"role": "admin"

}

// PATCH: 부분 수정 (변경 필드만)

PATCH /api/v1/users/123

{

"age": 31

}

POST vs PUT 생성 시

POST /api/v1/users → 서버가 ID 생성 (201 Created + Location 헤더)

PUT /api/v1/users/123 → 클라이언트가 ID 지정 (존재하면 교체, 없으면 생성)

5. 상태 코드 전략

2xx 성공

| 코드 | 이름 | 사용 시기 |

| ---- | ---------- | ------------------------------------------ |

| 200 | OK | GET 성공, PUT/PATCH 성공 시 |

| 201 | Created | POST로 리소스 생성 시 (Location 헤더 포함) |

| 202 | Accepted | 비동기 작업 접수 (나중에 처리) |

| 204 | No Content | DELETE 성공, PUT 성공 (본문 없음) |

3xx 리다이렉션

| 코드 | 이름 | 사용 시기 |

| ---- | ----------------- | ------------------------------ |

| 301 | Moved Permanently | 리소스 영구 이동 (캐시됨) |

| 302 | Found | 임시 리다이렉트 |

| 304 | Not Modified | 캐시 유효 (ETag/Last-Modified) |

4xx 클라이언트 오류

| 코드 | 이름 | 사용 시기 |

| ---- | -------------------- | ---------------------------------- |

| 400 | Bad Request | 잘못된 요청 형식, 유효성 검증 실패 |

| 401 | Unauthorized | 인증 필요 (토큰 없음/만료) |

| 403 | Forbidden | 인증됨 but 권한 없음 |

| 404 | Not Found | 리소스 없음 |

| 405 | Method Not Allowed | 해당 리소스에 허용되지 않는 메서드 |

| 409 | Conflict | 리소스 충돌 (중복 생성 등) |

| 422 | Unprocessable Entity | 문법은 맞지만 의미적 오류 |

| 429 | Too Many Requests | 요청 제한 초과 (Rate Limiting) |

5xx 서버 오류

| 코드 | 이름 | 사용 시기 |

| ---- | --------------------- | ------------------------------ |

| 500 | Internal Server Error | 예상치 못한 서버 오류 |

| 502 | Bad Gateway | 업스트림 서버 오류 |

| 503 | Service Unavailable | 서비스 일시 불가 (유지보수 등) |

| 504 | Gateway Timeout | 업스트림 서버 타임아웃 |

상태 코드 선택 플로우

요청 성공?

├── YES → 데이터 반환? → 200 OK

│ 리소스 생성? → 201 Created

│ 비동기 작업? → 202 Accepted

│ 본문 없음? → 204 No Content

└── NO → 인증 문제? → 401 / 403

요청 문제? → 400 / 422

리소스 없음? → 404

충돌? → 409

서버 문제? → 500 / 502 / 503

6. 페이지네이션 패턴

Offset 기반 페이지네이션

가장 간단하지만, 대규모 데이터에서 성능 문제가 있습니다.

GET /api/v1/users?page=3&size=20

{

"data": [

{ "id": 41, "name": "User 41" },

{ "id": 42, "name": "User 42" }

],

"pagination": {

"page": 3,

"size": 20,

"total_items": 1000,

"total_pages": 50,

"has_next": true,

"has_prev": true

}

}

-- SQL 구현

SELECT * FROM users

ORDER BY id

LIMIT 20 OFFSET 40;

-- 문제: OFFSET 10000이면 10000개를 건너뛰어야 함 → 성능 저하

Cursor 기반 페이지네이션

대규모 데이터에 적합합니다. 커서(마지막 항목의 ID)를 사용합니다.

GET /api/v1/users?cursor=eyJpZCI6NDJ9&limit=20

{

"data": [

{ "id": 43, "name": "User 43" },

{ "id": 44, "name": "User 44" }

],

"pagination": {

"next_cursor": "eyJpZCI6NjJ9",

"has_next": true,

"limit": 20

}

}

// Node.js 구현

async function getCursorPaginatedUsers(cursor, limit = 20) {

let query = 'SELECT * FROM users'

const params = []

if (cursor) {

const decoded = JSON.parse(Buffer.from(cursor, 'base64').toString())

query += ' WHERE id > $1'

params.push(decoded.id)

}

query += ' ORDER BY id ASC LIMIT $' + (params.length + 1)

params.push(limit + 1) // +1로 다음 페이지 존재 여부 확인

const rows = await db.query(query, params)

const hasNext = rows.length > limit

const data = hasNext ? rows.slice(0, limit) : rows

const nextCursor = hasNext

? Buffer.from(JSON.stringify({ id: data[data.length - 1].id })).toString('base64')

: null

return { data, pagination: { next_cursor: nextCursor, has_next: hasNext, limit } }

}

Keyset 기반 페이지네이션

복합 정렬에 적합합니다.

GET /api/v1/users?after_date=2025-01-15&after_id=42&limit=20&sort=created_at

-- SQL 구현: created_at + id 복합 키

SELECT * FROM users

WHERE (created_at, id) > ('2025-01-15', 42)

ORDER BY created_at ASC, id ASC

LIMIT 20;

페이지네이션 비교표

| 특성 | Offset | Cursor | Keyset |

| --------------------- | ------------- | -------- | ------------- |

| 구현 난이도 | 쉬움 | 보통 | 어려움 |

| 특정 페이지 이동 | O | X | X |

| 대규모 성능 | 나쁨 | 좋음 | 좋음 |

| 데이터 변경 시 일관성 | 나쁨 | 좋음 | 좋음 |

| 복합 정렬 | O | 제한적 | O |

| 총 개수 제공 | O | 비용 큼 | 비용 큼 |

| 사용 예시 | 관리자 페이지 | SNS 피드 | 시계열 데이터 |

7. 필터링, 정렬, 필드 선택

필터링 패턴

단순 필터

GET /api/v1/products?category=electronics&brand=apple

비교 연산자

GET /api/v1/products?price_gte=100&price_lte=500

배열 필터

GET /api/v1/products?tags=wireless,bluetooth

날짜 범위

GET /api/v1/orders?created_after=2025-01-01&created_before=2025-03-31

정렬

단일 정렬

GET /api/v1/products?sort=price

내림차순

GET /api/v1/products?sort=-price

복합 정렬

GET /api/v1/products?sort=-created_at,name

필드 선택 (Sparse Fieldsets)

필요한 필드만 요청 (응답 크기 감소)

GET /api/v1/users?fields=id,name,email

관련 리소스 포함

GET /api/v1/users/123?include=orders,profile

{

"id": 123,

"name": "John Doe",

"email": "john@example.com"

}

고급 필터링: RSQL/FIQL 스타일

RQL (Resource Query Language) 스타일

GET /api/v1/products?filter=category==electronics;price=gt=100;brand=in=(apple,samsung)

8. 버전 관리 전략

3가지 접근 방식

URL Path 버전 (가장 일반적)

GET /api/v1/users/123

GET /api/v2/users/123

Header 버전

GET /api/users/123

Accept: application/vnd.myapp.v2+json

Query Parameter 버전

GET /api/users/123?version=2

비교표

| 특성 | URL Path | Header | Query Param |

| --------------- | ---------------------- | --------------------------- | -------------- |

| 가시성 | 매우 높음 | 낮음 | 보통 |

| 캐싱 | 쉬움 | 복잡 | 보통 |

| 구현 난이도 | 쉬움 | 보통 | 쉬움 |

| 브라우저 테스트 | 쉬움 | 어려움 | 쉬움 |

| API 라우팅 | 명확 | 추가 로직 필요 | 추가 로직 필요 |

| 사용 기업 | GitHub, Stripe, Google | Microsoft, GitHub (GraphQL) | AWS, Netflix |

버전 관리 베스트 프랙티스

1. Major 버전만 관리 (v1, v2) — Minor/Patch는 하위 호환

2. 최소 2개 버전 동시 지원

3. Deprecation 정책 공개 (최소 6개월 유예)

4. Sunset 헤더 사용

Sunset: Sat, 01 Mar 2026 00:00:00 GMT

Deprecation: true

Link: <https://api.example.com/v2/users>; rel="successor-version"

9. 에러 처리: RFC 9457 Problem Details

RFC 9457 표준 에러 형식

2023년에 RFC 7807을 대체한 RFC 9457은 HTTP API의 표준 에러 응답 형식을 정의합니다.

{

"type": "https://api.example.com/errors/validation",

"title": "Validation Failed",

"status": 422,

"detail": "요청 본문에 유효하지 않은 필드가 있습니다.",

"instance": "/api/v1/users",

"timestamp": "2025-03-15T10:30:00Z",

"errors": [

{

"field": "email",

"code": "INVALID_FORMAT",

"message": "유효한 이메일 주소를 입력하세요."

},

{

"field": "age",

"code": "OUT_OF_RANGE",

"message": "나이는 0에서 150 사이여야 합니다."

}

]

}

에러 응답 구현 (Node.js/Express)

// 에러 클래스 정의

class ApiError extends Error {

constructor(status, type, title, detail, errors = []) {

super(detail)

this.status = status

this.type = type

this.title = title

this.detail = detail

this.errors = errors

}

}

// 팩토리 함수

const ApiErrors = {

notFound: (resource, id) =>

new ApiError(

404,

'https://api.example.com/errors/not-found',

'Resource Not Found',

`${resource} with id ${id} was not found.`

),

validationFailed: (errors) =>

new ApiError(

422,

'https://api.example.com/errors/validation',

'Validation Failed',

'The request body contains invalid fields.',

errors

),

rateLimited: (retryAfter) =>

new ApiError(

429,

'https://api.example.com/errors/rate-limit',

'Rate Limit Exceeded',

`Too many requests. Retry after ${retryAfter} seconds.`

),

}

// Express 에러 핸들러

app.use((err, req, res, next) => {

if (err instanceof ApiError) {

return res.status(err.status).json({

type: err.type,

title: err.title,

status: err.status,

detail: err.detail,

instance: req.originalUrl,

timestamp: new Date().toISOString(),

errors: err.errors.length > 0 ? err.errors : undefined,

})

}

// 예상치 못한 에러

console.error('Unexpected error:', err)

res.status(500).json({

type: 'https://api.example.com/errors/internal',

title: 'Internal Server Error',

status: 500,

detail: 'An unexpected error occurred.',

instance: req.originalUrl,

timestamp: new Date().toISOString(),

})

})

다국어 에러 메시지

// i18n 에러 메시지

const errorMessages = {

INVALID_EMAIL: {

ko: '유효한 이메일 주소를 입력하세요.',

en: 'Please enter a valid email address.',

ja: '有効なメールアドレスを入力してください。',

},

FIELD_REQUIRED: {

ko: '필수 입력 항목입니다.',

en: 'This field is required.',

ja: '必須入力項目です。',

},

}

function getErrorMessage(code, lang = 'en') {

return errorMessages[code]?.[lang] || errorMessages[code]?.['en'] || code

}

10. Rate Limiting과 Throttling

알고리즘 비교

Token Bucket

버킷 용량: 100 토큰

보충 속도: 10 토큰/초

[요청 도착] → 버킷에 토큰 있음? → YES → 토큰 소비, 요청 처리

→ NO → 429 응답

class TokenBucket {

constructor(capacity, refillRate) {

this.capacity = capacity

this.tokens = capacity

this.refillRate = refillRate

this.lastRefill = Date.now()

}

consume() {

this.refill()

if (this.tokens > 0) {

this.tokens--

return true

}

return false

}

refill() {

const now = Date.now()

const elapsed = (now - this.lastRefill) / 1000

this.tokens = Math.min(this.capacity, this.tokens + elapsed * this.refillRate)

this.lastRefill = now

}

}

Sliding Window Log

class SlidingWindowLog {

constructor(windowMs, maxRequests) {

this.windowMs = windowMs

this.maxRequests = maxRequests

this.logs = new Map() // key -> timestamps[]

}

isAllowed(key) {

const now = Date.now()

const windowStart = now - this.windowMs

if (!this.logs.has(key)) {

this.logs.set(key, [])

}

const timestamps = this.logs.get(key).filter((ts) => ts > windowStart)

this.logs.set(key, timestamps)

if (timestamps.length < this.maxRequests) {

timestamps.push(now)

return true

}

return false

}

}

Rate Limit 응답 헤더

HTTP/1.1 200 OK

X-RateLimit-Limit: 1000

X-RateLimit-Remaining: 998

X-RateLimit-Reset: 1678886400

HTTP/1.1 429 Too Many Requests

X-RateLimit-Limit: 1000

X-RateLimit-Remaining: 0

X-RateLimit-Reset: 1678886400

Retry-After: 30

Content-Type: application/problem+json

{

"type": "https://api.example.com/errors/rate-limit",

"title": "Rate Limit Exceeded",

"status": 429,

"detail": "You have exceeded the rate limit. Please retry after 30 seconds."

}

Rate Limit 티어 설계

| 티어 | 요청/분 | 요청/일 | 동시 연결 |

| ---------- | ------- | --------- | --------- |

| Free | 60 | 1,000 | 5 |

| Basic | 300 | 10,000 | 20 |

| Pro | 1,000 | 100,000 | 50 |

| Enterprise | 10,000 | 1,000,000 | 200 |

11. 보안

OAuth2 플로우 요약

Authorization Code Flow (서버 앱):

1. 사용자 → 인증 서버 (로그인)

2. 인증 서버 → 클라이언트 (Authorization Code)

3. 클라이언트 → 인증 서버 (Code + Client Secret → Access Token)

4. 클라이언트 → 리소스 서버 (Access Token)

Authorization Code + PKCE (SPA/모바일):

1. code_verifier 생성

2. code_challenge = SHA256(code_verifier)

3. 인증 요청에 code_challenge 포함

4. 토큰 교환 시 code_verifier 전송하여 검증

JWT 베스트 프랙티스

1. Access Token 만료: 15분 (최대 1시간)

2. Refresh Token 만료: 7일 (최대 30일)

3. 서명 알고리즘: RS256 (비대칭) 또는 ES256 권장

4. 민감 정보를 페이로드에 넣지 말 것

5. aud, iss, exp 클레임 반드시 검증

6. httpOnly 쿠키에 저장 (XSS 방지)

7. Token Rotation 적용

API Key 관리

Header 방식 (권장)

GET /api/v1/users HTTP/1.1

Authorization: Bearer <access_token>

X-API-Key: <api_key>

절대 URL에 포함하지 말 것

BAD: /api/v1/users?api_key=abc123

CORS 설정

// Express CORS 설정

const cors = require('cors')

app.use(

cors({

origin: ['https://app.example.com', 'https://admin.example.com'],

methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],

allowedHeaders: ['Content-Type', 'Authorization', 'X-API-Key'],

credentials: true,

maxAge: 86400, // Preflight 캐시 24시간

})

)

입력 유효성 검증

// Zod를 사용한 요청 검증

const { z } = require('zod')

const CreateUserSchema = z.object({

name: z.string().min(2).max(100),

email: z.string().email(),

age: z.number().int().min(0).max(150).optional(),

role: z.enum(['user', 'admin', 'moderator']).default('user'),

})

// 미들웨어

function validate(schema) {

return (req, res, next) => {

const result = schema.safeParse(req.body)

if (!result.success) {

return res.status(422).json({

type: 'https://api.example.com/errors/validation',

title: 'Validation Failed',

status: 422,

errors: result.error.issues.map((issue) => ({

field: issue.path.join('.'),

code: issue.code,

message: issue.message,

})),

})

}

req.body = result.data

next()

}

}

app.post('/api/v1/users', validate(CreateUserSchema), createUser)

12. OpenAPI 3.1

스펙 구조

openapi: 3.1.0

info:

title: E-Commerce API

description: E-Commerce REST API

version: 1.0.0

contact:

name: API Team

email: api@example.com

servers:

- url: https://api.example.com/v1

description: Production

- url: https://staging-api.example.com/v1

description: Staging

paths:

/products:

get:

operationId: listProducts

tags: [Products]

summary: List all products

parameters:

- name: category

in: query

schema:

type: string

- name: sort

in: query

schema:

type: string

enum: [price, -price, name, -name, created_at]

- name: cursor

in: query

schema:

type: string

- name: limit

in: query

schema:

type: integer

default: 20

maximum: 100

responses:

'200':

description: Successful response

content:

application/json:

schema:

type: object

properties:

data:

type: array

items:

$ref: '#/components/schemas/Product'

pagination:

$ref: '#/components/schemas/CursorPagination'

'429':

$ref: '#/components/responses/RateLimited'

components:

schemas:

Product:

type: object

required: [id, name, price]

properties:

id:

type: integer

format: int64

name:

type: string

minLength: 1

maxLength: 200

price:

type: number

format: decimal

minimum: 0

category:

type: string

created_at:

type: string

format: date-time

CursorPagination:

type: object

properties:

next_cursor:

type: string

nullable: true

has_next:

type: boolean

limit:

type: integer

responses:

RateLimited:

description: Rate limit exceeded

headers:

X-RateLimit-Limit:

schema:

type: integer

Retry-After:

schema:

type: integer

content:

application/problem+json:

schema:

$ref: '#/components/schemas/ProblemDetail'

securitySchemes:

BearerAuth:

type: http

scheme: bearer

bearerFormat: JWT

ApiKeyAuth:

type: apiKey

in: header

name: X-API-Key

security:

- BearerAuth: []

- ApiKeyAuth: []

Contract-First vs Code-First

| 접근 방식 | 장점 | 단점 |

| -------------- | ---------------------------------------------- | ------------------------ |

| Contract-First | 명세 기반 합의, Mock 서버 즉시 생성, 코드 생성 | 명세 유지 관리 추가 비용 |

| Code-First | 빠른 개발, 코드가 진실의 원천 | 명세와 코드 불일치 가능 |

코드 생성

OpenAPI Generator로 클라이언트 SDK 생성

npx @openapitools/openapi-generator-cli generate \

-i openapi.yaml \

-g typescript-axios \

-o ./generated/client

서버 스텁 생성

npx @openapitools/openapi-generator-cli generate \

-i openapi.yaml \

-g nodejs-express-server \

-o ./generated/server

13. API 테스트

Postman/Newman으로 자동화 테스트

// Postman Test Script 예시

pm.test('Status code is 200', () => {

pm.response.to.have.status(200)

})

pm.test('Response has correct structure', () => {

const json = pm.response.json()

pm.expect(json).to.have.property('data')

pm.expect(json.data).to.be.an('array')

pm.expect(json).to.have.property('pagination')

})

pm.test('Pagination cursor exists', () => {

const json = pm.response.json()

if (json.data.length > 0) {

pm.expect(json.pagination).to.have.property('next_cursor')

}

})

Newman으로 CI/CD 파이프라인에서 실행

newman run collection.json \

--environment production.json \

--reporters cli,junit \

--reporter-junit-export results.xml

Contract Testing with Pact

// Consumer Test (프론트엔드)

const { PactV3 } = require('@pact-foundation/pact')

const provider = new PactV3({

consumer: 'WebApp',

provider: 'UserService',

})

describe('User API', () => {

it('fetches a user by ID', async () => {

await provider

.given('user 123 exists')

.uponReceiving('a request for user 123')

.withRequest({

method: 'GET',

path: '/api/v1/users/123',

headers: { Accept: 'application/json' },

})

.willRespondWith({

status: 200,

headers: { 'Content-Type': 'application/json' },

body: {

id: 123,

name: 'John Doe',

email: 'john@example.com',

},

})

.executeTest(async (mockServer) => {

const response = await fetch(`${mockServer.url}/api/v1/users/123`, {

headers: { Accept: 'application/json' },

})

expect(response.status).toBe(200)

})

})

})

k6 부하 테스트

// k6 로드 테스트

export const options = {

stages: [

{ duration: '30s', target: 50 }, // 램프업

{ duration: '1m', target: 100 }, // 유지

{ duration: '30s', target: 0 }, // 램프다운

],

thresholds: {

http_req_duration: ['p(95)<500'], // 95% 요청 500ms 이내

http_req_failed: ['rate<0.01'], // 에러율 1% 미만

},

}

export default function () {

const res = http.get('https://api.example.com/v1/products?limit=20')

check(res, {

'status is 200': (r) => r.status === 200,

'response time < 500ms': (r) => r.timings.duration < 500,

'has data array': (r) => JSON.parse(r.body).data.length > 0,

})

sleep(1)

}

14. 실전 사례: 대표 API 설계 패턴

Stripe API

Stripe는 업계 최고의 API DX를 제공합니다.

Stripe의 핵심 설계 원칙:

1. 일관된 리소스 명명 (/v1/customers, /v1/charges, /v1/subscriptions)

2. Expand 파라미터로 관련 리소스 포함

3. 멱등성 키 (Idempotency-Key 헤더)

4. 상세한 에러 응답

5. 자동 페이지네이션 (has_more + starting_after)

Stripe 스타일 요청

POST /v1/charges HTTP/1.1

Authorization: Bearer sk_test_xxx

Idempotency-Key: unique-request-id-123

Content-Type: application/x-www-form-urlencoded

amount=2000&currency=usd&source=tok_visa

GitHub API

GitHub API 특징:

1. REST + GraphQL 하이브리드

2. 조건부 요청 (ETag, If-None-Match)

3. Link 헤더 페이지네이션

4. Hypermedia (URL 템플릿)

5. Rate Limit 명확한 표시

GitHub 조건부 요청

GET /repos/octocat/hello-world HTTP/1.1

If-None-Match: "etag-value"

304 Not Modified (캐시 사용)

또는 200 OK (새 ETag 포함)

Twitter(X) API v2

Twitter API v2 특징:

1. 필드 선택 (fields 파라미터)

2. Expansion으로 관련 객체 포함

3. 스트리밍 엔드포인트

4. Rate Limit 세분화

5. OAuth2 + App-only Auth

Twitter 스타일: 필드 선택 + expansion

GET /2/tweets?ids=123,456&tweet.fields=created_at,public_metrics&expansions=author_id&user.fields=username

15. 퀴즈

PUT은 리소스 전체를 교체(모든 필드 필요)하고, PATCH는 리소스의 일부 필드만 수정합니다. PUT은 멱등성이 보장되지만, PATCH는 상대적 변경(예: 카운터 증가)의 경우 멱등성이 보장되지 않을 수 있습니다.

Level 3은 HATEOAS(Hypermedia as the Engine of Application State)를 의미합니다. API 응답에 관련 리소스에 대한 링크를 포함하여, 클라이언트가 하드코딩된 URL 없이 API를 동적으로 탐색할 수 있게 합니다. 진정한 REST의 완성 단계입니다.

Offset 페이지네이션은 OFFSET이 커질수록 데이터베이스가 건너뛸 행이 많아져 성능이 저하됩니다. 또한 데이터가 추가/삭제되면 중복이나 누락이 발생할 수 있습니다. 대안으로 Cursor 기반 페이지네이션이 있으며, 마지막 항목의 ID를 커서로 사용하여 WHERE id 조건으로 효율적으로 조회합니다.

401 Unauthorized는 인증이 필요하다는 의미입니다 (토큰 없음 또는 만료). 403 Forbidden은 인증은 되었지만 해당 리소스에 대한 권한이 없다는 의미입니다. 즉, 401은 "누구세요?"이고, 403은 "당신은 알지만 여기 접근할 수 없어요"입니다.

장점: 가시성이 높고, 캐싱이 쉽고, 브라우저에서 바로 테스트 가능하며, 라우팅이 명확합니다. 단점: URL이 길어지고, 버전 변경 시 모든 클라이언트가 URL을 업데이트해야 하며, 같은 리소스에 대한 여러 URL이 존재하게 됩니다.

RFC 9457의 핵심 필드는 type (에러 유형 URI), title (사람이 읽을 수 있는 에러 요약), status (HTTP 상태 코드), detail (구체적인 설명), instance (문제가 발생한 URI)입니다. type과 title은 사실상 필수이며, 나머지는 선택이지만 포함하는 것이 권장됩니다.

Token Bucket은 버킷에 토큰이 일정 속도로 보충되고, 요청 시 토큰을 소비하는 방식입니다. 버스트 트래픽을 허용합니다. Sliding Window는 시간 윈도우 내의 요청 수를 추적하여, 윈도우가 이동하면서 오래된 요청을 제거합니다. 더 정밀하지만 메모리를 더 사용합니다.

참고 자료

- RFC 9110: HTTP Semantics

- RFC 9457: Problem Details for HTTP APIs

- RFC 6749: OAuth 2.0 Authorization Framework

- OpenAPI Specification 3.1.0

- Roy Fielding의 REST 논문 (2000)

- Stripe API Reference

- GitHub REST API Documentation

- Google API Design Guide

- Microsoft REST API Guidelines

- JSON:API Specification

마무리

REST API 설계는 기술적인 결정 이상의 의미를 갖습니다. 좋은 API는 개발자 경험을 향상시키고, 시스템 간 통합을 용이하게 하며, 비즈니스 가치를 창출합니다.

핵심을 다시 정리하면:

1. **리소스 중심 URL 설계** — 명사, 복수형, 최대 2단계 중첩

2. **올바른 HTTP 메서드** — 의미론과 멱등성 이해

3. **정확한 상태 코드** — 클라이언트가 에러를 올바르게 처리하도록

4. **Cursor 페이지네이션** — 대규모 데이터에 필수

5. **RFC 9457 에러 형식** — 일관되고 유용한 에러 응답

6. **URL Path 버전 관리** — 가장 실용적인 선택

7. **Rate Limiting** — API 안정성과 공정한 사용 보장

8. **OpenAPI 3.1** — 명세 자동화와 코드 생성

이 원칙들을 적용하여 개발자들이 사랑하는 API를 만들어 보세요.

현재 단락 (1/760)

API 경제의 규모는 2027년까지 13.7조 달러에 이를 것으로 예측됩니다. Stripe, Twilio, SendGrid 같은 API-first 기업들이 수십억 달러의 가치를 만...

작성 글자: 0원문 글자: 19,967작성 단락: 0/760