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

- Name
- Youngju Kim
- @fjvbn20031
- 들어가며
- 1. API 설계가 중요한 이유
- 2. REST 기초: Richardson Maturity Model
- 3. URL 설계: 네이밍 컨벤션과 패턴
- 4. HTTP 메서드 심층 분석
- 5. 상태 코드 전략
- 6. 페이지네이션 패턴
- 7. 필터링, 정렬, 필드 선택
- 8. 버전 관리 전략
- 9. 에러 처리: RFC 9457 Problem Details
- 10. Rate Limiting과 Throttling
- 11. 보안
- 12. OpenAPI 3.1
- 13. API 테스트
- 14. 실전 사례: 대표 API 설계 패턴
- 15. 퀴즈
- 참고 자료
- 마무리
들어가며
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 로드 테스트
import http from 'k6/http'
import { check, sleep } from '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¤cy=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. 퀴즈
Q1. PUT과 PATCH의 주요 차이점은 무엇인가요?
PUT은 리소스 전체를 교체(모든 필드 필요)하고, PATCH는 리소스의 일부 필드만 수정합니다. PUT은 멱등성이 보장되지만, PATCH는 상대적 변경(예: 카운터 증가)의 경우 멱등성이 보장되지 않을 수 있습니다.
Q2. Richardson Maturity Model Level 3은 무엇을 의미하나요?
Level 3은 HATEOAS(Hypermedia as the Engine of Application State)를 의미합니다. API 응답에 관련 리소스에 대한 링크를 포함하여, 클라이언트가 하드코딩된 URL 없이 API를 동적으로 탐색할 수 있게 합니다. 진정한 REST의 완성 단계입니다.
Q3. Offset 페이지네이션의 문제점과 대안은?
Offset 페이지네이션은 OFFSET이 커질수록 데이터베이스가 건너뛸 행이 많아져 성능이 저하됩니다. 또한 데이터가 추가/삭제되면 중복이나 누락이 발생할 수 있습니다. 대안으로 Cursor 기반 페이지네이션이 있으며, 마지막 항목의 ID를 커서로 사용하여 WHERE id 조건으로 효율적으로 조회합니다.
Q4. HTTP 401과 403의 차이는?
401 Unauthorized는 인증이 필요하다는 의미입니다 (토큰 없음 또는 만료). 403 Forbidden은 인증은 되었지만 해당 리소스에 대한 권한이 없다는 의미입니다. 즉, 401은 "누구세요?"이고, 403은 "당신은 알지만 여기 접근할 수 없어요"입니다.
Q5. API 버전 관리에서 URL Path 방식의 장단점은?
장점: 가시성이 높고, 캐싱이 쉽고, 브라우저에서 바로 테스트 가능하며, 라우팅이 명확합니다. 단점: URL이 길어지고, 버전 변경 시 모든 클라이언트가 URL을 업데이트해야 하며, 같은 리소스에 대한 여러 URL이 존재하게 됩니다.
Q6. RFC 9457 Problem Details의 필수 필드는?
RFC 9457의 핵심 필드는 type (에러 유형 URI), title (사람이 읽을 수 있는 에러 요약), status (HTTP 상태 코드), detail (구체적인 설명), instance (문제가 발생한 URI)입니다. type과 title은 사실상 필수이며, 나머지는 선택이지만 포함하는 것이 권장됩니다.
Q7. Token Bucket과 Sliding Window 알고리즘의 차이는?
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는 개발자 경험을 향상시키고, 시스템 간 통합을 용이하게 하며, 비즈니스 가치를 창출합니다.
핵심을 다시 정리하면:
- 리소스 중심 URL 설계 — 명사, 복수형, 최대 2단계 중첩
- 올바른 HTTP 메서드 — 의미론과 멱등성 이해
- 정확한 상태 코드 — 클라이언트가 에러를 올바르게 처리하도록
- Cursor 페이지네이션 — 대규모 데이터에 필수
- RFC 9457 에러 형식 — 일관되고 유용한 에러 응답
- URL Path 버전 관리 — 가장 실용적인 선택
- Rate Limiting — API 안정성과 공정한 사용 보장
- OpenAPI 3.1 — 명세 자동화와 코드 생성
이 원칙들을 적용하여 개발자들이 사랑하는 API를 만들어 보세요.