REST API 설계는 한 번 잘못 굳어지면 되돌리기가 굉장히 까다롭습니다. 클라이언트가 이미 연동되어 있다면 URL 구조 하나를 바꾸는 것도 배포 협의, 버전 관리, 하위 호환 처리까지 줄줄이 따라오죠. 그래서 처음 설계할 때 기본기를 지키는 게 중요한데, 실무에서 보면 같은 실수가 생각보다 자주 반복됩니다.
이 글은 이미 API를 몇 개 만들어봤지만 설계 원칙을 명확히 짚어두고 싶은 분들을 위해 썼습니다. Node.js(Express)와 PHP 기준의 실제 코드 예시를 곁들여 이론이 아닌 실무 감각으로 읽히도록 구성했습니다.

URL과 리소스 설계에서 흔들리는 것들
1. URL에 동사를 쓰는 것
REST에서 URL은 “무엇(리소스)”을 가리키고, HTTP 메서드가 “무엇을 할 것인지”를 표현합니다. 그런데 실무에서 /getUser, /createOrder, /deletePost 같은 URL을 종종 볼 수 있습니다. 메서드와 URL이 같은 말을 두 번 하는 셈이어서 API가 커질수록 혼란스러워집니다.
http
❌ 동사 URL – 메서드와 의미 중복
GET /getUsers
POST /createUser
GET /deleteUser?id=5
✅ 명사 리소스 URL – HTTP 메서드로 행위 구분
GET /users # 목록 조회
POST /users # 생성
GET /users/5 # 단건 조회
PUT /users/5 # 전체 수정
PATCH /users/5 # 부분 수정
DELETE /users/5 # 삭제
2. 복수/단수 혼용
/user/1과 /users/1을 팀 내에서 섞어 쓰면 클라이언트 개발자가 헷갈립니다. 컬렉션 리소스는 복수형(/users, /orders)으로 통일하는 것이 가독성과 일관성 면에서 낫습니다.
3. 중첩 리소스를 과도하게 쌓는 것
관계를 표현하려다 /users/5/posts/12/comments/3/likes 같은 URL이 만들어지기도 합니다. 3단계를 넘어가면 클라이언트도, 서버 라우터 코드도 복잡해집니다.
http
❌ 너무 깊은 중첩
GET /users/5/posts/12/comments/3/likes
✅ 2단계 이하 + 별도 엔드포인트
GET /posts/12/comments # 댓글 목록
GET /comments/3/likes # 좋아요 목록
4. 버전 관리를 처음부터 빠뜨리는 것
“지금은 버전이 하나니까 괜찮다”는 생각이 나중에 가장 큰 기술 부채가 됩니다. 초반부터 /api/v1/ 접두어를 붙여두면 하위 호환성을 유지하면서 새 버전을 병행 운영할 수 있습니다.
HTTP를 제대로 쓰지 않는 경우들
5. 상태 코드를 200 하나로 퉁치기
성공이든 실패든 HTTP 상태는 항상 200을 반환하고, 응답 바디에 { "status": "error" }를 담는 패턴이 있습니다. 이렇게 하면 클라이언트가 에러를 감지하려면 매번 바디를 파싱해야 하고, 모니터링 도구나 게이트웨이도 에러를 정상 응답으로 인식합니다.
js
// Node.js (Express)
// ❌ 모든 응답에 200
app.get('/users/:id', (req, res) => {
const user = findUser(req.params.id);
if (!user) return res.status(200).json({ status: 'error', message: 'Not found' });
res.status(200).json({ status: 'ok', data: user });
});
// ✅ 상황에 맞는 상태 코드 사용
app.get('/users/:id', (req, res) => {
const user = findUser(req.params.id);
if (!user) return res.status(404).json({ message: 'User not found' });
res.status(200).json(user);
});
💡 자주 쓰는 상태 코드: 200 OK / 201 Created / 204 No Content / 400 Bad Request / 401 Unauthorized / 403 Forbidden / 404 Not Found / 409 Conflict / 422 Unprocessable Entity / 429 Too Many Requests / 500 Internal Server Error
6. PUT과 PATCH 혼용
PUT은 리소스 전체를 교체하고, PATCH는 일부만 수정합니다. 닉네임 하나만 바꾸는데 PUT을 쓰면 나머지 필드를 모두 보내야 하거나 빠진 필드가 null로 덮어씌워질 수 있습니다. 부분 수정에는 PATCH를 사용하세요.
7. GET 요청에 바디를 담는 것
일부 구현에서 GET 요청에 JSON 바디를 담아 조건을 전달하는 경우가 있습니다. 기술적으로 불가능하진 않지만, 많은 프록시·캐시·게이트웨이가 GET 바디를 무시하거나 제거합니다. 조건 조회는 쿼리 파라미터로 처리하는 것이 안전합니다.
8. 인가(Authorization) 누락
로그인은 되어 있는데, 다른 사용자의 리소스에도 접근할 수 있는 경우입니다. 인증(Authentication)과 인가(Authorization)는 다릅니다. 미들웨어에서 토큰 검증만 하고, 리소스 소유권 확인을 빠뜨리는 실수가 경험상 꽤 흔합니다.
php
// PHP
// ❌ 토큰 검증만 하고 소유권 미확인
function getOrder($orderId) {
$user = authenticate(); // 토큰 확인만
return Order::find($orderId); // 누구 주문인지 확인 안 함
}
// ✅ 소유권까지 확인
function getOrder($orderId) {
$user = authenticate();
$order = Order::find($orderId);
if (!$order || $order->user_id !== $user->id) {
return response()->json(['message' => 'Forbidden'], 403);
}
return $order;
}
⚠️ 보안 주의: IDOR(Insecure Direct Object Reference) 취약점의 대표적인 패턴입니다. 모든 리소스 조회/수정/삭제에서 소유권 또는 권한 확인을 빠뜨리지 마세요.
응답 설계와 운영에서 놓치는 것들
9. 에러 응답 형식이 엔드포인트마다 다른 것
어떤 에러는 { "error": "..." }, 어떤 에러는 { "message": "..." }로 온다면 클라이언트 개발자가 분기 처리를 여러 번 작성해야 합니다. 에러 응답 스키마를 프로젝트 초반에 통일하고 문서화해두세요.
json
// ✅ 통일된 에러 응답 구조
{
"error": {
"code": "VALIDATION_ERROR",
"message": "이메일 형식이 올바르지 않습니다",
"details": [{ "field": "email", "issue": "invalid format" }]
}
}
10. 페이지네이션 없이 전체 데이터 반환
개발 초반에는 데이터가 적어서 문제없지만, 레코드가 수만 건이 되면 응답 지연과 메모리 부족으로 이어집니다. 처음부터 page + limit 또는 커서 기반 페이지네이션을 설계해두세요.
json
// ✅ 커서 기반 페이지네이션 응답 구조
{
"data": [],
"pagination": {
"next_cursor": "eyJpZCI6MTAwfQ==",
"has_more": true
}
}
11. Rate Limiting 미설정
인증 없이 누구나 호출할 수 있는 엔드포인트에 Rate Limiting이 없으면 단순 반복 요청만으로도 서버가 다운될 수 있습니다. Node.js라면 express-rate-limit(대안: rate-limiter-flexible), Nginx라면 limit_req_zone 설정으로 기본 방어선을 만들어두세요.
nginx
# Nginx – Rate Limiting 설정 예시
http {
# 초당 10 요청 허용, IP 기준
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
server {
location /api/ {
limit_req zone=api_limit burst=20 nodelay;
limit_req_status 429;
}
}
}
12. API 문서를 코드와 따로 관리하는 것
별도 위키나 스프레드시트로 API 문서를 관리하면 코드가 바뀔 때 문서가 따라오지 못합니다. swagger-jsdoc(Node.js) 또는 L5-Swagger(Laravel) 같은 도구로 코드 주석에서 문서를 자동 생성하는 방식을 권장합니다.
실수 유형별 우선순위
| 실수 | 영향 범위 | 수정 난이도 | 우선순위 | 적용 시점 |
|---|---|---|---|---|
| 인가 누락 (#8) | 보안 전반 | 중간 | 🔴 즉시 | 운영 중이어도 즉시 |
| Rate Limiting 미설정 (#11) | 서버 안정성 | 낮음 | 🔴 즉시 | 운영 중이어도 즉시 |
| 버전 관리 누락 (#4) | 장기 유지보수 | 높음 | 🟠 설계 시 | 신규 API에만 적용 |
| 상태 코드 오용 (#5) | 클라이언트 연동 | 중간 | 🟠 설계 시 | 신규 엔드포인트부터 |
| 페이지네이션 누락 (#10) | 성능 | 중간 | 🟠 설계 시 | 데이터 증가 전 적용 |
| 동사 URL (#1) | 가독성/일관성 | 낮음 | 🟡 신규 시 | 기존 변경은 버전업 필요 |
| 에러 응답 불일치 (#9) | DX(개발자 경험) | 낮음 | 🟡 신규 시 | 신규 API에만 즉시 적용 |
| API 문서 분리 (#12) | 협업 효율 | 낮음 | 🟢 점진적 | 여유 있을 때 도입 |
💡 이미 운영 중인 API라면? 외부 클라이언트가 연동된 엔드포인트는 URL·응답 구조 변경이 하위 호환성을 깨뜨립니다.
/v2/를 새로 만들고/v1/은 Deprecation 헤더를 추가해 유지하는 방식이 현실적입니다.
자주 묻는 질문
Q. REST와 RESTful은 다른 건가요? REST는 아키텍처 스타일(원칙)이고, RESTful은 그 원칙을 잘 따른 API를 뜻합니다. HTTP를 사용한다고 해서 모두 RESTful은 아닙니다.
Q. PATCH가 PUT보다 항상 나은 건가요? 그렇지 않습니다. 리소스 전체를 교체하는 시나리오(예: 설정 파일 덮어쓰기)에서는 PUT이 의미상 더 정확합니다. 대부분의 수정 요청에는 PATCH가 적합하지만, API 설계 의도에 따라 선택하세요.
Q. 삭제 후 응답은 204와 200 중 무엇이 맞나요? 삭제된 리소스 정보를 바디에 돌려줄 필요가 있으면 200, 단순 확인만 하면 204 No Content가 표준적입니다. 팀 내 컨벤션을 정해 일관되게 적용하는 것이 중요합니다.
Q. JWT 토큰은 쿠키와 Authorization 헤더 중 어디에 담아야 하나요? 웹 브라우저라면 HttpOnly 쿠키가 XSS 공격에 더 안전합니다. 모바일 앱이나 서버 간 통신에서는 Authorization 헤더가 일반적입니다. 클라이언트 유형에 따라 하나를 선택하고 일관되게 유지하세요.
Q. 응답에 항상 data 키로 감싸야 하나요? 정해진 규칙은 없습니다. 다만 페이지네이션이나 메타데이터를 함께 반환할 때 data, meta 키로 분리해두면 구조 확장이 편합니다. JSON:API 스펙을 참고하는 것도 좋습니다.
Q. GraphQL이 있는데 REST가 여전히 필요한가요? 용도가 다릅니다. REST는 단순 CRUD, 캐싱이 중요한 공개 API, 파일 업로드에 유리합니다. GraphQL은 다양한 클라이언트가 각기 다른 필드를 요구하는 복잡한 데이터 요구에 적합합니다. 프로젝트 성격에 맞게 선택하거나 혼용할 수 있습니다.
오늘 바로 점검할 것들
12가지를 한꺼번에 다 고치려 하면 오히려 아무것도 못 바꾸게 됩니다. 보안과 안정성에 직결된 항목부터 하나씩 시작하는 것이 현실적입니다.
- 모든 리소스 수정/삭제 엔드포인트에 소유권 확인 로직이 있는가
- 공개 엔드포인트에 Rate Limiting이 설정되어 있는가
- 에러 응답 구조가 전체 프로젝트에서 동일한가
- URL에 동사가 섞여 있지 않은가
- 목록 조회 API에 페이지네이션이 적용되어 있는가
- API 버전이 URL 또는 헤더에 명시되어 있는가
- API 문서가 현재 코드와 일치하는가
설계 단계에서 이 체크리스트를 PR 리뷰 항목에 추가해두면, 팀 전체의 API 품질을 일정 수준 이상으로 유지하는 데 꽤 효과적입니다.