잘 설계된 REST API를 구축하는 것은 현대 개발자에게 가장 중요한 기술 중 하나입니다. 훌륭한 API는 직관적이고, 일관되며, 안전하고, 유지보수가 쉽습니다. 이 종합 가이드는 URL 명명 규칙과 HTTP 메서드부터 인증, 페이지네이션, 속도 제한, 일반적인 실수까지 모든 것을 다룹니다.
URL 명명 규칙
좋은 REST API의 기반은 잘 설계된 URL에서 시작됩니다. 엔드포인트는 예측 가능하고, 읽기 쉬우며, 일관된 패턴을 따라야 합니다.
동사가 아닌 명사 사용
REST 리소스는 명사로 식별되어야 합니다. HTTP 메서드가 이미 동작을 나타내므로 URL 경로에 동사를 포함할 필요가 없습니다.
# Good - nouns as resources
GET /users # Get all users
GET /users/123 # Get user 123
POST /users # Create a new user
DELETE /users/123 # Delete user 123
# Bad - verbs in URLs
GET /getUsers
POST /createUser
DELETE /deleteUser/123컬렉션에는 복수형 사용
컬렉션 엔드포인트에는 항상 복수 명사를 사용하세요. 이렇게 하면 컬렉션 전체나 그 안의 단일 리소스를 참조할 때 일관성이 유지됩니다.
# Good - consistent plurals
GET /users # Collection
GET /users/123 # Single resource in collection
GET /products # Collection
GET /products/456 # Single resource
# Bad - inconsistent or singular
GET /user # Ambiguous: one user or all users?
GET /user/123
GET /product-list관계를 위한 중첩 리소스
중첩을 사용하여 리소스 간의 관계를 표현하되, 2단계 이상 깊이 들어가지 마세요.
# Good - clear parent-child relationship (max 2 levels)
GET /users/123/orders # Orders belonging to user 123
GET /users/123/orders/456 # Specific order of user 123
# Bad - too deeply nested
GET /users/123/orders/456/items/789/reviews
# Better: use a direct resource endpoint
GET /order-items/789/reviews
# Or: flatten with query parameters
GET /reviews?order_item_id=789URL 패턴: 좋은 예 vs 나쁜 예
| 좋은 예 | 나쁜 예 | 이유 |
|---|---|---|
| GET /users | GET /getUsers | HTTP method already implies the action |
| GET /users/123 | GET /user?id=123 | Use path parameters for resource identity |
| POST /users | POST /createUser | POST already means create |
| PUT /users/123 | POST /updateUser | PUT/PATCH for updates, use resource path |
| DELETE /users/123 | POST /deleteUser | Use DELETE method, not POST with verb |
| GET /users/123/orders | GET /getUserOrders?userId=123 | Use nested resources for relationships |
| GET /products?category=electronics | GET /electronics/products | Use query params for filtering |
| GET /users?status=active&sort=name | GET /active-users-sorted-by-name | Use query params, not encoded URLs |
| PATCH /users/123 | POST /users/123/update | Use HTTP methods, not URL verbs |
| GET /users/123/avatar | GET /getAvatarForUser/123 | Keep it resource-oriented |
HTTP 메서드
각 HTTP 메서드에는 특정 의미가 있습니다. 올바르게 사용하면 API가 예측 가능해집니다.
| 메서드 | 목적 | 멱등성 | 요청 본문 | 예시 |
|---|---|---|---|---|
| GET | Retrieve a resource or collection | Yes | No | GET /users/123 |
| POST | Create a new resource | No | Yes | POST /users |
| PUT | Replace a resource entirely | Yes | Yes | PUT /users/123 |
| PATCH | Partially update a resource | No* | Yes | PATCH /users/123 |
| DELETE | Remove a resource | Yes | Optional | DELETE /users/123 |
* PATCH can be idempotent depending on implementation. If the patch describes the final state (e.g., {"name": "John"}), it is idempotent. If it describes an operation (e.g., {"op": "increment", "path": "/count"}), it is not.
HTTP 상태 코드
올바른 상태 코드를 사용하면 클라이언트가 응답을 적절하게 처리할 수 있습니다.
| 코드 | 이름 | 사용 시기 |
|---|---|---|
| 200 | OK | Successful GET, PUT, PATCH, or DELETE. Return the resource or confirmation. |
| 201 | Created | Successful POST that created a new resource. Include Location header with the new resource URL. |
| 204 | No Content | Successful DELETE or PUT/PATCH when no response body is needed. |
| 400 | Bad Request | The request is malformed — invalid JSON, missing required fields, wrong data types. |
| 401 | Unauthorized | No valid authentication credentials provided. The client must authenticate first. |
| 403 | Forbidden | The client is authenticated but does not have permission for this action. |
| 404 | Not Found | The requested resource does not exist. Also use for hidden resources (instead of 403) for security. |
| 409 | Conflict | The request conflicts with the current state — duplicate email, version mismatch, etc. |
| 422 | Unprocessable Entity | The request is well-formed but fails validation — email format invalid, password too short. |
| 429 | Too Many Requests | Rate limit exceeded. Include Retry-After header with seconds until the client can retry. |
| 500 | Internal Server Error | An unexpected server error occurred. Log the details server-side but do not expose them to clients. |
| 503 | Service Unavailable | The server is temporarily unavailable — maintenance mode, overloaded. Include Retry-After header. |
오류 처리
일관된 오류 응답은 API를 더 쉽게 사용할 수 있게 합니다. 모든 오류는 구조화된 JSON 본문을 반환해야 합니다.
표준 오류 응답 형식
모든 오류 응답에 일관된 오류 봉투를 사용하세요.
// Standard error response
{
"error": {
"code": "RESOURCE_NOT_FOUND",
"message": "The user with ID 123 was not found.",
"status": 404,
"timestamp": "2026-01-15T10:30:00Z",
"request_id": "req_abc123def456",
"documentation_url": "https://api.example.com/docs/errors#RESOURCE_NOT_FOUND"
}
}
// Another example: authentication error
{
"error": {
"code": "TOKEN_EXPIRED",
"message": "The access token has expired. Please refresh your token.",
"status": 401,
"timestamp": "2026-01-15T10:30:00Z",
"request_id": "req_xyz789"
}
}필드 수준 유효성 검사 오류
422 유효성 검사 오류의 경우 필드 수준 오류 정보를 포함하세요.
// 422 Validation error with field-level details
{
"error": {
"code": "VALIDATION_FAILED",
"message": "The request body contains invalid fields.",
"status": 422,
"details": [
{
"field": "email",
"message": "Must be a valid email address.",
"rejected_value": "not-an-email"
},
{
"field": "password",
"message": "Must be at least 8 characters long.",
"rejected_value": "short"
},
{
"field": "age",
"message": "Must be a positive integer.",
"rejected_value": -5
}
],
"timestamp": "2026-01-15T10:30:00Z",
"request_id": "req_val456"
}
}인증 및 권한 부여
올바른 인증 전략 선택은 사용 사례에 따라 다릅니다. 가장 일반적인 세 가지 접근 방식의 비교입니다.
API 키
헤더나 쿼리 매개변수로 전달되는 간단한 문자열 토큰입니다. 서버 간 통신에 가장 적합합니다.
# API Key in header (recommended)
GET /api/users HTTP/1.1
Host: api.example.com
X-API-Key: sk_live_abc123def456ghi789
# API Key in query parameter (less secure - visible in logs)
GET /api/users?api_key=sk_live_abc123def456ghi789JWT Bearer 토큰
사용자 클레임을 포함하는 자체 포함 토큰입니다. 마이크로서비스에서의 무상태 인증에 이상적입니다.
# JWT Bearer Token in Authorization header
GET /api/users/me HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
# Token refresh flow
POST /api/auth/refresh HTTP/1.1
Content-Type: application/json
{
"refresh_token": "rt_abc123def456"
}
# Response
{
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "rt_new789xyz"
}OAuth 2.0
위임된 권한 부여의 업계 표준입니다. 서드파티 애플리케이션이 사용자를 대신하여 리소스에 접근할 수 있게 합니다.
# OAuth 2.0 Authorization Code Flow
# Step 1: Redirect user to authorization server
GET https://auth.example.com/authorize?
response_type=code&
client_id=your_client_id&
redirect_uri=https://yourapp.com/callback&
scope=read:users write:users&
state=random_csrf_token
# Step 2: Exchange authorization code for tokens
POST https://auth.example.com/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&
code=AUTH_CODE_FROM_CALLBACK&
client_id=your_client_id&
client_secret=your_client_secret&
redirect_uri=https://yourapp.com/callback
# Step 3: Use the access token
GET /api/users/me HTTP/1.1
Authorization: Bearer ACCESS_TOKEN_HERE인증 방식 비교
| 기능 | API Key | JWT | OAuth 2.0 |
|---|---|---|---|
| Complexity | Low | Medium | High |
| Stateless | Yes | Yes | Depends |
| User context | No | Yes | Yes |
| Token expiration | Manual rotation | Built-in (exp claim) | Built-in |
| Scope/permissions | Limited | Via claims | Fine-grained scopes |
| Third-party access | No | No | Yes (delegated) |
| Best for | Server-to-server | SPAs, Mobile apps | Third-party integrations |
| Revocation | Delete key | Blacklist needed | Revoke at auth server |
페이지네이션 패턴
리소스 목록을 반환하는 모든 엔드포인트는 페이지네이션을 지원해야 합니다.
오프셋 기반 페이지네이션
가장 간단한 접근 방식입니다. 페이지 번호와 제한 매개변수를 사용합니다.
# Offset-based pagination request
GET /api/users?page=2&limit=20
# How it works internally
SELECT * FROM users
ORDER BY created_at DESC
LIMIT 20 OFFSET 20; -- Skip first 20, return next 20
# Pros: Simple, allows jumping to any page
# Cons: Inconsistent with concurrent modifications,
# slow on large offsets (OFFSET 100000)커서 기반 페이지네이션
불투명한 커서 토큰을 사용하여 데이터셋 내 위치를 표시합니다. 실시간 데이터에 더 적합합니다.
# Cursor-based pagination request
GET /api/users?cursor=eyJpZCI6MTIzfQ&limit=20
# The cursor is an opaque token (often base64-encoded)
# that points to a specific position in the dataset
# How it works internally (cursor decodes to {"id": 123})
SELECT * FROM users
WHERE id > 123
ORDER BY id ASC
LIMIT 20;
# Pros: Consistent results, fast on large datasets
# Cons: Cannot jump to arbitrary pages,
# only next/previous navigation페이지네이션 응답 형식
모든 페이지네이션 응답에 메타데이터를 포함하세요.
// Offset-based pagination response
{
"data": [
{ "id": 21, "name": "Alice" },
{ "id": 22, "name": "Bob" }
// ... 18 more items
],
"meta": {
"current_page": 2,
"per_page": 20,
"total_pages": 15,
"total_count": 294
},
"links": {
"self": "/api/users?page=2&limit=20",
"first": "/api/users?page=1&limit=20",
"prev": "/api/users?page=1&limit=20",
"next": "/api/users?page=3&limit=20",
"last": "/api/users?page=15&limit=20"
}
}
// Cursor-based pagination response
{
"data": [
{ "id": 124, "name": "Charlie" },
{ "id": 125, "name": "Diana" }
// ... 18 more items
],
"meta": {
"has_more": true,
"next_cursor": "eyJpZCI6MTQzfQ",
"prev_cursor": "eyJpZCI6MTI0fQ"
},
"links": {
"next": "/api/users?cursor=eyJpZCI6MTQzfQ&limit=20",
"prev": "/api/users?cursor=eyJpZCI6MTI0fQ&limit=20&direction=prev"
}
}API 버전 관리 전략
API는 시간이 지남에 따라 진화합니다. 버전 관리를 통해 기존 클라이언트를 중단하지 않고 변경할 수 있습니다.
| 전략 | 예시 | 장점 | 단점 |
|---|---|---|---|
| URL Path | /v1/users, /v2/users | Explicit, easy to route and cache, clear in logs and docs | URL pollution, harder to sunset old versions |
| Custom Header | Accept-Version: v2 or X-API-Version: 2 | Clean URLs, version hidden from casual users | Harder to test (need tools to set headers), not cacheable by default |
| Query Parameter | /users?version=2 | Easy to test in browser, no URL path changes | Can be accidentally omitted, harder to route, pollutes query string |
| Content Negotiation | Accept: application/vnd.api.v2+json | Most RESTful approach, per-resource versioning | Complex, hard to test, poorly supported by many tools |
# URL Path versioning (recommended)
GET /v1/users/123 # Version 1
GET /v2/users/123 # Version 2 with breaking changes
# Express.js router example
const v1Router = express.Router();
const v2Router = express.Router();
v1Router.get('/users/:id', v1UserController.get);
v2Router.get('/users/:id', v2UserController.get);
app.use('/v1', v1Router);
app.use('/v2', v2Router);속도 제한
속도 제한은 API를 남용으로부터 보호하고 모든 클라이언트의 공정한 사용을 보장합니다.
속도 제한 헤더
클라이언트가 사용량을 모니터링할 수 있도록 모든 응답에 이 헤더를 포함하세요.
HTTP/1.1 200 OK
Content-Type: application/json
X-RateLimit-Limit: 1000 # Max requests per window
X-RateLimit-Remaining: 847 # Remaining requests in window
X-RateLimit-Reset: 1706176800 # Unix timestamp when window resets
X-RateLimit-Window: 3600 # Window duration in seconds (1 hour)
# Some APIs also include:
RateLimit-Policy: 1000;w=3600 # IETF draft standard format429 요청 과다 응답
클라이언트가 속도 제한을 초과하면 Retry-After 헤더와 함께 429 상태를 반환합니다.
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 45
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1706176800
{
"error": {
"code": "RATE_LIMIT_EXCEEDED",
"message": "You have exceeded the rate limit of 1000 requests per hour.",
"status": 429,
"retry_after": 45,
"documentation_url": "https://api.example.com/docs/rate-limiting"
}
}백오프 전략
클라이언트는 429 응답을 받을 때 지수 백오프를 구현해야 합니다.
// Exponential backoff with jitter
async function fetchWithRetry(url, options, maxRetries = 5) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
const response = await fetch(url, options);
if (response.status !== 429) {
return response;
}
// Use Retry-After header if available
const retryAfter = response.headers.get('Retry-After');
let delay;
if (retryAfter) {
delay = parseInt(retryAfter, 10) * 1000;
} else {
// Exponential backoff: 1s, 2s, 4s, 8s, 16s
const baseDelay = Math.pow(2, attempt) * 1000;
// Add jitter (random 0-1s) to prevent thundering herd
const jitter = Math.random() * 1000;
delay = baseDelay + jitter;
}
console.log(`Rate limited. Retrying in ${delay}ms (attempt ${attempt + 1})`);
await new Promise(resolve => setTimeout(resolve, delay));
}
throw new Error('Max retries exceeded');
}보안 헤더
보안 헤더는 일반적인 웹 취약점으로부터 API를 보호합니다.
| 헤더 | 권장 값 | 목적 |
|---|---|---|
| Access-Control-Allow-Origin | https://yourapp.com (specific origin) | CORS: Controls which domains can call your API. Never use * in production with credentials. |
| Access-Control-Allow-Methods | GET, POST, PUT, PATCH, DELETE | CORS: Specifies which HTTP methods are allowed for cross-origin requests. |
| Access-Control-Allow-Headers | Authorization, Content-Type | CORS: Specifies which request headers are allowed in cross-origin requests. |
| Strict-Transport-Security | max-age=31536000; includeSubDomains | HSTS: Forces browsers to use HTTPS for all future requests to your domain. |
| Content-Security-Policy | default-src 'none'; frame-ancestors 'none' | CSP: Prevents XSS and data injection attacks. For APIs, restrict everything. |
| X-Content-Type-Options | nosniff | Prevents browsers from MIME-type sniffing. Ensures responses are treated as their declared content type. |
| X-Frame-Options | DENY | Prevents your API responses from being embedded in iframes (clickjacking protection). |
| Cache-Control | no-store, no-cache, must-revalidate | Prevents caching of sensitive API responses. Adjust per endpoint as needed. |
// Express.js security headers middleware
const helmet = require('helmet');
app.use(helmet());
// Custom CORS configuration
const cors = require('cors');
app.use(cors({
origin: ['https://yourapp.com', 'https://admin.yourapp.com'],
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
allowedHeaders: ['Authorization', 'Content-Type'],
credentials: true,
maxAge: 86400 // Cache preflight for 24 hours
}));
// Additional security headers
app.use((req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('Cache-Control', 'no-store');
next();
});OpenAPI로 API 문서화
좋은 문서는 API 채택에 필수적입니다. OpenAPI는 REST API를 설명하는 표준입니다.
- 먼저 OpenAPI 사양을 작성한 다음(설계 우선 접근), 사양에 맞게 API를 구현합니다.
- 모든 엔드포인트, 매개변수 및 응답 스키마에 상세한 설명을 포함하세요.
- 모든 요청 및 응답 본문에 현실적인 예시 값을 제공하세요.
- 성공 사례뿐만 아니라 모든 엔드포인트의 오류 응답을 문서화하세요.
- 태그를 사용하여 관련 엔드포인트를 그룹화하세요.
- OpenAPI 사양을 API 코드와 함께 버전 관리하세요.
- 개발자가 직접 요청을 시도할 수 있도록 대화형 문서(Swagger UI 또는 Redoc)를 설정하세요.
# OpenAPI 3.1 specification example
openapi: 3.1.0
info:
title: User Management API
version: 1.0.0
description: API for managing users and their resources
servers:
- url: https://api.example.com/v1
paths:
/users:
get:
summary: List all users
tags: [Users]
parameters:
- name: page
in: query
schema:
type: integer
default: 1
- name: limit
in: query
schema:
type: integer
default: 20
maximum: 100
responses:
'200':
description: A paginated list of users
content:
application/json:
schema:
$ref: '#/components/schemas/UserListResponse'
example:
data:
- id: 1
name: "Alice Johnson"
email: "alice@example.com"
meta:
current_page: 1
total_pages: 10
'401':
$ref: '#/components/responses/Unauthorized'
post:
summary: Create a new user
tags: [Users]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateUserRequest'
example:
name: "Bob Smith"
email: "bob@example.com"
password: "securePassword123"
responses:
'201':
description: User created successfully
'422':
$ref: '#/components/responses/ValidationError'일반적인 REST API 실수
경험 많은 개발자도 이러한 실수를 합니다. API가 모범 사례를 따르는지 확인하세요.
| # | 실수 | 왜 문제인가 | 해결 방법 |
|---|---|---|---|
| 1 | Using verbs in URLs | REST is resource-oriented; HTTP methods express actions | Use nouns: /users not /getUsers |
| 2 | Returning 200 for errors | Clients rely on status codes for error handling | Use proper status codes: 400, 404, 422, 500 |
| 3 | Not paginating list endpoints | Returns grow unbounded, causing timeouts and memory issues | Always paginate with sensible defaults (limit=20) |
| 4 | Exposing internal errors | Stack traces reveal implementation details to attackers | Log details server-side, return generic error messages |
| 5 | Ignoring CORS headers | Browser-based clients cannot call your API | Configure CORS for allowed origins and methods |
| 6 | Not versioning the API | Breaking changes affect all clients simultaneously | Use URL path versioning: /v1/users |
| 7 | Using POST for everything | Loses HTTP method semantics, breaks caching and idempotency | Use GET, POST, PUT, PATCH, DELETE appropriately |
| 8 | Inconsistent naming conventions | Mixed case or formats confuse API consumers | Pick one (snake_case or camelCase) and be consistent |
| 9 | No rate limiting | API is vulnerable to abuse and DDoS attacks | Implement rate limiting with clear headers |
| 10 | Missing Content-Type header | Clients cannot parse responses correctly | Always set Content-Type: application/json |
관련 개발자 도구 사용해보기
자주 묻는 질문
REST와 RESTful의 차이점은 무엇인가요?
REST는 Roy Fielding이 정의한 아키텍처 스타일입니다. RESTful은 REST 제약을 준수하는 API를 설명하는 형용사입니다. 실제로 대부분의 "REST API"는 모든 REST 제약을 엄격히 따르지 않지만 용어는 호환적으로 사용됩니다.
리소스 업데이트에 PUT과 PATCH 중 어떤 것을 사용해야 하나요?
클라이언트가 리소스의 전체 교체를 보내는 경우 PUT을 사용합니다. 변경된 필드만 보내는 부분 업데이트에는 PATCH를 사용합니다.
API 버전 관리는 어떻게 처리해야 하나요?
URL 경로 버전 관리(예: /v1/users)가 가장 일반적이고 명시적인 접근 방식입니다. 이해, 라우팅, 캐싱이 쉽습니다.
REST API에서 인증을 처리하는 가장 좋은 방법은 무엇인가요?
대부분의 애플리케이션에서 JWT Bearer 토큰은 보안과 단순성의 좋은 균형을 제공합니다. 단기 액세스 토큰과 리프레시 토큰 순환을 사용하세요.
REST API에서 중첩된 리소스를 어떻게 설계해야 하나요?
중첩을 사용하여 부모-자식 관계를 보여줍니다: /users/123/orders. 중첩은 최대 2단계까지로 제한하세요.
REST API 응답에 null 필드를 포함해야 하나요, 생략해야 하나요?
계약에 따라 다릅니다. null 필드를 포함하면 스키마가 예측 가능해집니다. 생략하면 페이로드 크기가 줄어듭니다. 일관성을 유지하고 문서화하세요.