왜 이 글을 쓰나
팀 프로젝트를 하다 보면 API 설계 스타일이 제각각인 경우가 많다. 누군가는 /getUser를 쓰고, 누군가는 /user/fetch를 쓴다. 어떤 엔드포인트는 모든 응답을 200으로 내려보내고, 어떤 엔드포인트는 에러를 어떤 포맷으로 내려줄지 정해진 게 없다. 프론트엔드 개발자가 API 문서 없이 응답 구조를 유추해야 하는 상황이 생긴다.
REST API 설계에 정답은 없지만, 업계에서 통용되는 관례와 원칙이 있다. 이걸 팀이 공유하면 커뮤니케이션 비용이 줄고 코드 리뷰가 훨씬 편해진다.
이 글은 URL 네이밍부터 HTTP 메서드 올바른 사용, 상태코드 실전, 응답 구조 설계, 버전 관리, 쿼리 파라미터까지 REST API 설계에서 반복적으로 맞닥뜨리는 주제들을 정리한다.
URL 네이밍 규칙
REST에서 URL은 리소스를 나타낸다. 행위가 아니라 명사로 표현하는 게 원칙이다.
잘못된 예 vs 올바른 예
| 잘못된 URL | 올바른 URL | 이유 |
|---|---|---|
/getUsers |
/users |
동사 불필요. GET 메서드가 이미 행위를 나타냄 |
/createUser |
/users |
POST 메서드가 생성을 의미 |
/deleteUser/1 |
/users/1 |
DELETE 메서드가 삭제를 의미 |
/user (단수) |
/users (복수) |
컬렉션은 복수형이 관례 |
/Users |
/users |
소문자 사용 |
/user_list |
/users |
언더스코어 대신 하이픈 또는 그냥 복수형 |
/api/getUserPosts/1 |
/api/users/1/posts |
계층 구조로 중첩 표현 |
중첩 리소스
소유 관계가 명확한 리소스는 계층으로 표현한다.
GET /users/:id/posts # 특정 사용자의 게시글 목록
GET /users/:id/posts/:postId # 특정 사용자의 특정 게시글
POST /users/:id/posts # 특정 사용자의 게시글 작성
중첩은 2단계까지만 하는 게 실용적이다. 3단계 이상 깊어지면 URL이 너무 길어지고 의미도 불명확해진다. 예를 들어 /users/1/posts/5/comments/3/likes는 /likes/3 또는 /comments/3/likes로 분리하는 편이 낫다.
HTTP 메서드 올바른 사용
각 메서드의 역할
| 메서드 | 역할 | 요청 바디 | 응답 | 멱등성 | 안전성 |
|---|---|---|---|---|---|
| GET | 리소스 조회 | 없음 | 200 + 데이터 | O | O |
| POST | 리소스 생성 | 있음 | 201 + 생성 데이터 | X | X |
| PUT | 리소스 전체 교체 | 있음 | 200 or 204 | O | X |
| PATCH | 리소스 부분 수정 | 있음 | 200 or 204 | 조건부 | X |
| DELETE | 리소스 삭제 | 없음 | 204 | O | X |
멱등성(Idempotent): 같은 요청을 여러 번 해도 결과가 동일하다. GET, PUT, DELETE는 멱등하다. POST는 멱등하지 않다. 같은 POST 요청을 두 번 보내면 리소스가 두 개 생길 수 있다.
안전성(Safe): 서버 상태를 변경하지 않는다. GET만 안전하다.
PUT vs PATCH
이 둘을 혼용해서 쓰는 케이스가 많다.
// 현재 사용자 데이터
{
"id": 1,
"name": "김철수",
"email": "chulsoo@example.com",
"age": 30
}
// PUT: 전체 교체. 보내지 않은 필드는 null 또는 기본값이 됨
PUT /users/1
{
"name": "김영희",
"email": "yh@example.com",
"age": 25
}
// PATCH: 보낸 필드만 변경
PATCH /users/1
{
"name": "김영희"
}
PATCH로 이름만 바꾸면 나머지 필드는 그대로 유지된다. PUT으로 이름만 바꾸면 보내지 않은 email, age가 null이 되거나 에러가 발생할 수 있다.
실무에서는 부분 수정에는 PATCH를, 전체 교체에는 PUT을 쓰는 게 맞다. 단, PUT도 보내지 않은 필드를 null로 처리하지 않고 그대로 유지하는 구현도 있다. 팀에서 동작 방식을 명확히 정해두는 게 중요하다.
HTTP 상태 코드 실전
자주 쓰는 코드 총정리
| 코드 | 의미 | 사용 시점 |
|---|---|---|
| 200 OK | 성공 | GET, PUT, PATCH 성공 |
| 201 Created | 생성 성공 | POST로 리소스 생성 완료 |
| 204 No Content | 성공, 응답 바디 없음 | DELETE 성공, 수정 후 데이터 불필요 시 |
| 400 Bad Request | 잘못된 요청 | 필수 파라미터 누락, 형식 오류 |
| 401 Unauthorized | 인증 필요 | 로그인 안 됨, 토큰 없음/만료 |
| 403 Forbidden | 권한 없음 | 로그인은 됐지만 접근 권한 없음 |
| 404 Not Found | 리소스 없음 | 해당 ID의 데이터가 없음 |
| 409 Conflict | 충돌 | 이미 존재하는 이메일로 가입 시도 |
| 422 Unprocessable Entity | 처리 불가 | 형식은 맞지만 비즈니스 로직 검증 실패 |
| 429 Too Many Requests | 요청 초과 | Rate limit 초과 |
| 500 Internal Server Error | 서버 오류 | 예상치 못한 서버 에러 |
잘못 쓰는 사례
가장 흔한 실수는 모든 에러를 200으로 내려보내는 것이다.
// 잘못된 패턴: 상태 코드는 200, 에러 정보는 바디에
HTTP 200 OK
{
"success": false,
"error": "User not found"
}
이렇게 하면 프론트엔드에서 HTTP 상태 코드로 분기 처리가 불가능하다. Axios 인터셉터나 fetch 래퍼에서 에러를 일괄 처리하려면 바디를 매번 파싱해야 한다. 로그 모니터링 툴에서도 5xx, 4xx 에러가 잡히지 않는다.
// 올바른 패턴: 상태 코드로 에러 유형을 표현
HTTP 404 Not Found
{
"error": {
"code": "USER_NOT_FOUND",
"message": "해당 사용자를 찾을 수 없다"
}
}
401과 403도 자주 혼용한다. 401은 인증(Authentication) 문제, 403은 인가(Authorization) 문제다. 로그인하지 않은 상태에서 접근하면 401, 로그인은 됐지만 관리자 페이지에 일반 유저가 접근하면 403이다.
응답 구조 설계
일관된 응답 포맷을 정해두면 프론트엔드 개발이 훨씬 편해진다.
성공 응답 포맷
// 단일 리소스
{
"data": {
"id": 1,
"name": "김철수",
"email": "chulsoo@example.com"
}
}
// 컬렉션 (페이지네이션 포함)
{
"data": [
{ "id": 1, "name": "김철수" },
{ "id": 2, "name": "이영희" }
],
"meta": {
"total": 100,
"page": 1,
"perPage": 20,
"lastPage": 5
},
"pagination": {
"prev": null,
"next": "/api/v1/users?page=2"
}
}
에러 응답 포맷
// 단순 에러
{
"error": {
"code": "VALIDATION_ERROR",
"message": "입력값이 올바르지 않다",
"details": [
{ "field": "email", "message": "이메일 형식이 아니다" },
{ "field": "password", "message": "8자 이상 입력해야 한다" }
]
}
}
error.code는 클라이언트가 에러 유형을 프로그래밍적으로 처리할 수 있도록 고정된 문자열 키를 쓴다. error.message는 사람이 읽을 수 있는 설명이다. details는 폼 검증처럼 여러 필드의 에러를 한꺼번에 내려줄 때 사용한다.
Node.js/Express 구현 예시
// utils/response.js
function success(res, data, statusCode = 200) {
return res.status(statusCode).json({ data });
}
function paginated(res, data, meta) {
return res.status(200).json({
data,
meta,
pagination: {
prev: meta.page > 1 ? `?page=${meta.page - 1}` : null,
next: meta.page < meta.lastPage ? `?page=${meta.page + 1}` : null,
},
});
}
function error(res, statusCode, code, message, details = null) {
const body = { error: { code, message } };
if (details) body.error.details = details;
return res.status(statusCode).json(body);
}
module.exports = { success, paginated, error };
// routes/users.js
const { success, paginated, error } = require('../utils/response');
router.get('/:id', async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) {
return error(res, 404, 'USER_NOT_FOUND', '해당 사용자를 찾을 수 없다');
}
return success(res, user);
});
router.get('/', async (req, res) => {
const page = parseInt(req.query.page) || 1;
const perPage = parseInt(req.query.perPage) || 20;
const { users, total } = await User.findAll({ page, perPage });
const lastPage = Math.ceil(total / perPage);
return paginated(res, users, { total, page, perPage, lastPage });
});
API 버전 관리
API가 한 번 공개되면 클라이언트가 의존하기 시작한다. 응답 구조를 변경하거나 엔드포인트를 없애면 기존 클라이언트가 깨진다. 버전 관리는 하위 호환성을 유지하면서 API를 발전시키는 방법이다.
URL 버전 vs Header 버전
| 방식 | 예시 | 장점 | 단점 |
|---|---|---|---|
| URL 경로 | /api/v1/users |
직관적, 브라우저에서 테스트 쉬움 | URL이 길어짐 |
| 쿼리 파라미터 | /api/users?version=1 |
기존 URL 유지 | 비표준적, 잊어버리기 쉬움 |
| 요청 헤더 | Accept: application/vnd.myapi.v1+json |
REST 원칙에 더 부합 | 테스트 불편, 캐싱 어려움 |
| 커스텀 헤더 | X-API-Version: 1 |
유연함 | 비표준 |
실무에서는 URL 경로 방식이 가장 많이 쓰인다. 직관적이고 디버깅이 쉽다. Stripe, GitHub, Twitter 등 주요 API들이 URL 버전을 사용한다.
/api/v1/users # v1 엔드포인트
/api/v2/users # v2 엔드포인트 (응답 구조 변경)
버전을 올리는 시점은 하위 호환이 불가능한 변경이 생길 때다. 필드 추가는 버전 업 없이 가능하다. 기존 필드 삭제, 타입 변경, 엔드포인트 경로 변경은 버전 업이 필요하다.
쿼리 파라미터 설계
필터링
GET /api/v1/users?role=admin
GET /api/v1/posts?status=published&authorId=1
GET /api/v1/products?minPrice=10000&maxPrice=50000
정렬
GET /api/v1/users?sort=createdAt&order=desc
GET /api/v1/posts?sort=viewCount&order=asc
sort에 필드명, order에 방향(asc/desc)을 분리하는 게 명확하다. 일부는 -createdAt 형태(마이너스 접두사로 내림차순)를 쓰기도 한다. 팀에서 하나로 통일하면 된다.
페이지네이션
두 가지 방식이 있다.
오프셋 기반 (Offset-based)
GET /api/v1/posts?page=2&perPage=20
구현이 단순하고 특정 페이지로 바로 이동 가능하다. 단, 데이터가 자주 변하는 경우 페이지 간 데이터가 중복되거나 누락될 수 있다. 게시판처럼 페이지 번호가 필요한 UI에 적합하다.
커서 기반 (Cursor-based)
GET /api/v1/posts?cursor=eyJpZCI6MTAwfQ&limit=20
커서(보통 마지막 항목의 ID 또는 인코딩된 값)를 기준으로 다음 데이터를 가져온다. 데이터 변경에 영향을 받지 않고 성능이 일정하다. 무한 스크롤 UI에 적합하다.
// 커서 기반 응답 예시
{
"data": [...],
"meta": {
"hasNext": true,
"nextCursor": "eyJpZCI6MTIwfQ"
}
}
REST vs GraphQL vs gRPC 간단 비교
| 항목 | REST | GraphQL | gRPC |
|---|---|---|---|
| 프로토콜 | HTTP | HTTP | HTTP/2 |
| 데이터 형식 | JSON | JSON | Protocol Buffers (바이너리) |
| 요청 방식 | 엔드포인트 단위 | 단일 엔드포인트, 쿼리 언어 | 메서드 정의 기반 |
| Overfetching/Underfetching | 있음 | 없음 (필요한 필드만 요청) | 없음 |
| 학습 곡선 | 낮음 | 중간 | 높음 |
| 실시간 지원 | 제한적 (SSE, WebSocket 별도) | Subscription 지원 | Streaming 지원 |
| 도구/생태계 | 풍부 | 풍부 | 상대적으로 적음 |
| 적합한 상황 | 범용 API, 외부 공개 API | 복잡한 데이터 요구사항, 다양한 클라이언트 | 마이크로서비스 내부 통신, 고성능 |
대부분의 경우 REST로 충분하다. GraphQL은 모바일/웹 클라이언트마다 필요한 데이터가 다를 때 유용하다. gRPC는 마이크로서비스 간 내부 통신이나 성능이 극도로 중요한 케이스에서 쓴다.
삽질 기록
PUT vs PATCH 혼용으로 생긴 버그
PATCH 엔드포인트를 만들면서 내부적으로 PUT처럼 동작하게 구현한 적이 있다. 클라이언트에서 이름만 바꾸려고 { "name": "새이름" }을 PATCH로 보냈더니 다른 필드들이 전부 null이 됐다.
// 잘못된 PATCH 구현
router.patch('/users/:id', async (req, res) => {
await User.update(req.params.id, req.body); // body에 없는 필드는 null이 됨
});
// 올바른 PATCH 구현
router.patch('/users/:id', async (req, res) => {
const existing = await User.findById(req.params.id);
const updated = { ...existing, ...req.body }; // 기존 데이터에 변경사항만 병합
await User.update(req.params.id, updated);
});
DB 레벨에서도 UPDATE users SET name = ? WHERE id = ?처럼 변경할 컬럼만 명시하는 게 맞다. ORM을 쓴다면 save()가 아닌 변경 필드만 업데이트하는 메서드를 확인해야 한다.
상태 코드를 잘못 써서 생긴 클라이언트 혼란
초기에 API를 만들면서 모든 에러를 400으로 통일한 적이 있다. 인증 에러도, 권한 에러도, 리소스 없음도 전부 400이었다.
프론트엔드에서는 에러 메시지 내용을 파싱해서 분기 처리해야 했다. 로그인 만료인지, 권한 문제인지, 데이터가 없는 건지를 메시지 문자열로 구분했다. 나중에 에러 메시지를 한글에서 영어로 바꾸는 작업을 하면서 프론트엔드 조건문이 줄줄이 깨졌다.
HTTP 상태 코드를 제대로 쓰는 것의 중요성을 그 때 알았다. 상태 코드는 계약이다.
버전 관리 없이 API 변경했다가 생긴 문제
첫 번째 프로젝트에서 버전 관리를 하지 않았다. 응답 구조를 바꿔야 할 일이 생겼을 때 기존 클라이언트(모바일 앱)가 있어서 바꾸지 못했다. 새로운 구조로 마이그레이션하면서 클라이언트와 서버를 동시 배포해야 했고, 잠깐이라도 타이밍이 어긋나면 앱이 깨지는 상황이었다.
버전 관리를 처음부터 해뒀다면 /api/v2를 만들고 점진적으로 마이그레이션할 수 있었다. 버전 관리는 "변경이 있을 때" 시작하는 게 아니라 처음부터 URL 구조에 포함시켜두는 게 맞다.
페이지네이션 없이 API 만들었다가
사용자 목록 API를 처음에 페이지네이션 없이 만들었다. 개발 초기에는 데이터가 몇 건 없어서 문제가 없었다. 실제 서비스를 올리고 나서 데이터가 수천 건이 되자 API 응답이 수 초가 걸리기 시작했다.
나중에 페이지네이션을 추가했는데 기존 클라이언트는 전체 배열을 기대하고 있었다. 응답 구조 변경이 불가피해서 또 버전 업을 해야 했다.
처음 설계할 때 컬렉션 API에는 반드시 페이지네이션을 넣어두는 게 맞다. 데이터가 적어도 구조는 미리 갖춰두면 나중에 수정 비용이 없다.
마무리
REST API 설계에서 가장 중요한 건 일관성이다. 완벽한 REST API보다는 팀이 합의한 규칙을 일관되게 적용하는 것이 현실적으로 더 중요하다.
최소한 이 것들은 처음부터 정해두는 게 좋다.
- URL 네이밍 규칙 (명사 복수형, 소문자, 하이픈)
- HTTP 메서드 사용 기준 (특히 PUT vs PATCH 동작 방식)
- 상태 코드 사용 기준 (401 vs 403, 400 vs 422)
- 응답 포맷 (성공, 에러, 페이지네이션)
- URL 버전 관리 (
/api/v1/) - 페이지네이션 기본 포함
이것들이 정해지면 PR 리뷰에서 "이 엔드포인트 왜 /getUsers야?" 같은 기본적인 지적이 사라진다. 팀의 에너지를 비즈니스 로직에 집중할 수 있다.
기술 스택: REST API, HTTP, Node.js, Express, JSON
'개발 팁' 카테고리의 다른 글
| 웹 성능 최적화 기초 — Lighthouse 점수 올리는 실전 방법 (0) | 2026.03.19 |
|---|---|
| 개발 브랜치 전략 완전 정복 — Git Flow, GitHub Flow, Trunk-based 비교 (0) | 2026.03.19 |
| VS Code 설정 실전 — settings.json부터 디버깅까지 (0) | 2026.03.19 |
| CSS Flexbox & Grid 실전 치트시트 — 자주 쓰는 패턴 총정리 (0) | 2026.03.19 |
| 웹 보안 기초 — XSS, CSRF, SQL Injection 방어 실전 (1) | 2026.03.19 |