API를 처음 만들려고 하면 의외로 아주 작은 곳에서 막힙니다.
사용자 정보를 가져오는 주소를 만든다고 해보겠습니다.
/getUser
/getUserInfo
/users
/user
넷 중 어떤 주소가 맞을까요?
처음에는 별 차이가 없어 보입니다. 실제로 서버도 네 방식 모두 동작하게 만들 수 있습니다.
문제는 API가 10개, 50개, 100개로 늘어났을 때 시작됩니다. 누군가는 /getUser라고 만들고, 다른 사람은 /users라고 만들고, 회원을 삭제하는 기능은 /deleteMember가 됩니다. API 하나하나는 작동하는데 전체를 보면 규칙이 없습니다.
좋은 API 설계가 필요한 이유는 바로 이것입니다. API를 어렵게 만드는 것이 목적이 아니라, 나중에 처음 보는 사람도 다음 주소와 동작을 어느 정도 예상할 수 있게 만드는 것입니다.
이 글은 그 원칙 7가지를 다룹니다. 그리고 한 가지 약속을 하겠습니다. 이 글에 나오는 요청과 응답은 머릿속 예시가 아니라, 실제로 서버를 만들어 실행한 결과입니다. 글 후반부에서 그 서버를 직접 만들고, 모든 상태 코드가 진짜로 그렇게 나오는지 확인합니다.
API는 도대체 무엇을 설계하는 걸까?
API(Application Programming Interface)를 아주 간단하게 표현하면 프로그램과 프로그램이 서로 요청하고 답하는 방법입니다.
쇼핑몰을 예로 들어보겠습니다. 사용자가 상품 페이지를 열었습니다. 화면은 서버에 이렇게 요청할 수 있습니다.
GET /products/123
뜻은 대략 이렇습니다. “123번 상품 정보를 보여줘.”
서버는 상품 정보를 찾아 응답합니다.
{
"id": 123,
"name": "무선 키보드",
"price": 59000
}
화면과 서버가 마음대로 대화하는 것이 아니라, 미리 정해둔 요청 방법을 통해 대화하는 것입니다. 그 약속을 이해하기 쉽고 일관되게 만드는 일이 API 설계입니다.
1. URL에는 행동보다 ‘대상’을 적는다
REST API를 처음 설계할 때 가장 먼저 기억하면 좋은 원칙입니다.
사용자를 조회한다고 해보겠습니다.
GET /getUser/123
처음 보면 꽤 자연스럽습니다. “사용자를 가져온다(get user)”라는 의미니까요. 하지만 REST 스타일에서는 보통 이렇게 표현합니다.
GET /users/123
왜 get을 없앴을까요? 이미 GET이 조회한다는 행동을 표현하고 있기 때문입니다.
그래서 다음처럼 만들 수 있습니다.
GET /users/123
POST /users
PATCH /users/123
DELETE /users/123
URL은 계속 /users를 사용합니다. 대신 앞의 HTTP 메서드가 행동을 바꿉니다.
- GET → 조회
- POST → 생성이나 처리 요청
- PUT → 자원 전체를 교체하는 방식으로 사용
- PATCH → 자원의 일부 변경
- DELETE → 삭제
쉽게 말하면 URL은 명사, HTTP 메서드는 동사 역할을 한다고 생각하면 이해하기 쉽습니다.
그래서 /createUser, /getUsers, /updateUser, /deleteUser 같은 주소는 한 번 더 검토하는 것이 좋습니다. 대신 /users, /users/123 구조를 먼저 고려합니다.
2. URL만 보고 무엇인지 짐작할 수 있어야 한다
다음 두 방식을 비교해보겠습니다.
/api/a/17
/api/b/31
/users/17
/orders/31
서버는 두 방식 모두 처리할 수 있습니다. 하지만 개발자가 처음 봤을 때 느끼는 차이는 큽니다. 두 번째는 설명서를 열어보지 않아도 어느 정도 예상할 수 있습니다. /users/17은 17번 사용자, /orders/31은 31번 주문.
좋은 API는 기억력 테스트가 되어서는 안 됩니다. 가능하면 주소 자체가 설명서의 일부가 되어야 합니다.
그래서 이름을 정할 때 멋있는 단어보다 일관성이 중요합니다. 프로젝트에서 사용자를 users라고 정했다면, 같은 개념을 다른 곳에서 /members, /customers로 바꾸지 않는 것이 좋습니다. 실제로 다른 개념이라면 구분해야 하지만, 같은 개념이라면 하나의 이름을 유지합니다.
3. URL을 너무 깊게 만들지 않는다
쇼핑몰의 특정 사용자가 작성한 리뷰를 표현한다고 해보겠습니다. 처음에는 이렇게 만들고 싶을 수 있습니다.
/users/123/orders/456/products/789/reviews/10
논리적으로 틀린 주소는 아닙니다. 문제는 읽기가 점점 힘들어진다는 것입니다.
API 구조가 실제 데이터베이스 구조를 그대로 따라갈 필요는 없습니다. 리뷰 자체에 고유 ID가 있다면 단순하게 접근할 수도 있습니다.
/reviews/10
특정 사용자의 리뷰 목록이 필요하다면 이런 방식도 생각할 수 있습니다.
/users/123/reviews
핵심은 “무조건 몇 단계 이하”라는 규칙이 아닙니다. 주소가 깊어질수록, 정말 그 관계를 URL에 모두 표현해야 하는지 다시 확인하는 것입니다. URL은 데이터베이스의 폴더 구조가 아니라, 사용자가 자원을 이해하고 접근하기 위한 인터페이스입니다.
4. 같은 행동에는 같은 HTTP 메서드를 사용한다
API가 여러 개가 되기 시작하면 일관성이 중요해집니다. 조회 API를 이렇게 섞어 쓴다고 생각해보겠습니다.
GET /users/123
POST /orders/search
GET /products/find
POST /articles/get
각각 따로 보면 동작합니다. 하지만 사용하는 사람은 매번 문서를 확인해야 합니다. 가능한 범위에서 비슷한 작업은 비슷한 방식으로 만드는 편이 좋습니다.
단순한 목록 조회라면:
GET /products
조건이 필요하면 쿼리 파라미터를 사용할 수 있습니다.
GET /products?category=keyboard
GET /products?category=keyboard&minPrice=30000
다만 검색 조건이 매우 복잡하거나 요청 본문이 필요한 경우에는 별도의 검색 API 설계를 선택할 수도 있습니다. 중요한 것은 “REST에서는 무조건 이것만 가능하다”가 아니라, 예외가 생겼을 때도 왜 예외인지 설명할 수 있는 규칙을 갖는 것입니다.
5. 상태 코드는 ‘숫자 장식’이 아니다
API 요청이 끝나면 서버는 결과를 알려줘야 합니다. 이때 사용하는 것이 HTTP 상태 코드입니다.
200 OK— 정상 처리201 Created— 새 자원 생성됨400 Bad Request— 요청 자체에 문제401 Unauthorized— 인증 정보가 없거나 유효하지 않음403 Forbidden— 인증은 됐지만 권한이 없음404 Not Found— 대상이 존재하지 않음500 Internal Server Error— 서버 내부 오류
여기서 초보자가 많이 하는 실수가 있습니다. 실패했는데도 모든 응답을 200 OK로 보내고, 본문에만 이렇게 적는 것입니다.
{
"success": false,
"message": "사용자를 찾을 수 없습니다."
}
이 방식은 클라이언트가 결과를 판단하기 어렵게 만듭니다. HTTP가 이미 가지고 있는 의미를 활용하는 편이 좋습니다. 이 상태 코드들이 실제로 어떻게 동작하는지는 잠시 뒤 직접 만든 서버에서 하나씩 확인합니다.
6. 오류 메시지는 사람과 프로그램 모두 이해할 수 있게 만든다
이런 오류를 받아본 적이 있을 겁니다.
{
"error": "ERROR"
}
틀린 말은 아니지만 아무 도움도 되지 않습니다. 무슨 오류인지, 왜 발생했는지, 무엇을 수정해야 하는지 알 수 없기 때문입니다.
조금 더 유용하게 만들면:
{
"code": "INVALID_EMAIL",
"message": "올바른 이메일 주소를 입력해주세요."
}
필요하다면 어느 필드가 문제인지도 알려줄 수 있습니다.
{
"code": "VALIDATION_ERROR",
"message": "입력값을 확인해주세요.",
"errors": [
{ "field": "email", "message": "올바른 이메일 형식이 아닙니다." }
]
}
프로그램은 code로 오류 종류를 판단하고, 사람은 message로 원인을 이해합니다. 좋은 오류 응답은 문제를 알려주는 데서 끝나지 않고 문제를 찾는 시간을 줄여줍니다.
실제 서비스는 오류를 어떻게 알려줄까 — GitHub API에서 관찰한 것
이 글을 쓰면서 GitHub의 공개 API를 직접 호출해봤습니다. 원래는 정상 응답(200)을 보여드리려 했는데, 예상 못 한 일이 벌어졌습니다. 요청 한도에 걸린 것입니다. 그런데 이 실패가 오히려 좋은 관찰 자료였습니다.
요청:
GET https://api.github.com/users/octocat
실제로 받은 응답 (2026-08-14 호출):
HTTP/2 403
x-ratelimit-limit: 60
x-ratelimit-remaining: 0
{
"message": "API rate limit exceeded for [IP주소]. (But here's the good news: Authenticated requests get a higher rate limit. Check out the documentation for more details.)",
"documentation_url": "https://docs.github.com/rest/overview/resources-in-the-rest-api#rate-limiting"
}
이 응답에서 배울 점이 세 가지 있습니다.
첫째, 실패를 상태 코드로 표현했습니다. 본문만 보고 판단하게 만들지 않았습니다. 둘째, 메시지가 원인(“요청 한도 초과”)과 해결 방향(“인증하면 한도가 올라간다”)을 함께 알려줍니다. 셋째, 더 알아볼 수 있는 문서 링크(documentation_url)까지 줍니다. 헤더에는 한도가 60회이고 남은 횟수가 0이라는 것까지 나옵니다.
참고로 요청 한도 초과에는 429 Too Many Requests라는 전용 상태 코드도 있습니다(RFC 6585). GitHub는 이 경우 403을 반환했는데, 서비스마다 정책이 다를 수 있다는 것도 함께 기억해두면 좋습니다.
7. API를 만들기 전에 ‘미래의 나’가 사용할 모습을 생각한다
상품 목록이 필요해서 GET /products를 만들었습니다. 처음에는 상품이 20개뿐이라 아무 문제가 없습니다. 그런데 1년 뒤 상품이 10만 개가 됐습니다. 10만 개를 한 번에 보내는 것은 좋은 방법이 아닙니다.
그래서 목록 API를 설계할 때는 이런 가능성을 미리 생각할 수 있습니다.
GET /products?page=2&size=20
GET /products?sort=price
GET /products?category=keyboard
그렇다고 처음부터 발생하지도 않은 모든 문제를 대비해 거대한 API를 만들라는 뜻은 아닙니다. 오히려 반대입니다. 현재 필요한 만큼 단순하게 만들되, 앞으로 확장하기 어렵게 만드는 선택은 피하는 것. 이 균형이 중요합니다.
API 버전은 처음부터 /v1을 붙여야 할까?
API 설계를 공부하다 보면 /api/v1/users 같은 주소를 자주 보게 됩니다. 그래서 모든 API에 반드시 v1을 붙여야 한다고 생각하기 쉽지만, 그렇지는 않습니다.
URL에 버전을 표현하는 것은 여러 버전 관리 전략 중 하나입니다. 외부 개발자에게 공개하는 API라면 기존 사용자를 깨뜨리지 않고 새 API를 제공해야 하므로 버전 전략이 중요합니다. 반대로 작은 내부 서비스라면 상황이 다를 수 있습니다.
중요한 질문은 이것입니다. API가 변경됐을 때 기존 사용자를 어떻게 보호할 것인가? 버전 번호 자체보다 호환성 관리 정책이 먼저입니다.
PUT과 PATCH는 뭐가 다른가?
처음 공부할 때 특히 헷갈리는 부분입니다. 사용자의 이름만 바꾸고 싶다고 해보겠습니다.
{ "name": "김철수" }
HTTP 표준(RFC 9110)에서 PUT은 대상 자원을 요청한 내용으로 교체하는 의미이고, PATCH(RFC 5789)는 자원의 일부만 변경하는 의미입니다. 그래서 일부 수정에는 일반적으로 PATCH를 사용합니다.
PATCH /users/123
다만 실제 서비스에서는 API 정책에 따라 사용 방식이 달라지기도 합니다. “PUT = 수정, PATCH = 수정”이라고 뭉뚱그려 외우기보다, 우리 API에서 두 메서드의 의미를 어떻게 정의했고 일관되게 쓰고 있는지가 더 중요합니다.
초보자가 자주 하는 실수 5가지
1. URL에 동사를 계속 붙인다 — /getProducts, /createProduct 대신 /products, /products/123 구조로 표현할 수 있는지 먼저 확인합니다.
2. 모든 응답을 200으로 처리한다 — 성공과 실패를 상태 코드로 표현하면 사용하는 쪽에서 결과를 판단하기 쉬워집니다.
3. API마다 응답 구조가 다르다 — 한 API는 data, 다른 API는 result, 또 다른 API는 response로 감싸면 쓰는 사람이 힘듭니다. 어떤 구조가 절대 정답이라기보다, 프로젝트 전체에서 같은 규칙을 유지하는 것이 중요합니다.
4. 오류 메시지만 보고 원인을 찾을 수 없다 — {"message": "실패했습니다."}보다는 오류 종류(code)와 원인을 구분할 수 있는 구조가 좋습니다.
5. 데이터베이스 구조를 API에 그대로 노출한다 — 데이터베이스가 복잡하다고 API까지 복잡해야 하는 것은 아닙니다. API는 데이터베이스를 보여주는 창이 아니라, 사용자가 필요한 기능을 이용하기 위한 인터페이스입니다.
직접 만들어서 전부 확인해보자
여기까지의 원칙이 실제로 동작하는지 확인할 차례입니다. 간단한 할 일 관리 API를 Node.js와 Express로 만들어 실행했습니다. 아래 결과는 전부 실제 실행에서 나온 그대로입니다.
필요한 기능은 다섯 가지입니다.
- 할 일 목록 보기 →
GET /todos - 할 일 하나 보기 →
GET /todos/123 - 할 일 만들기 →
POST /todos - 할 일 수정하기 →
PATCH /todos/123 - 할 일 삭제하기 →
DELETE /todos/123
전체 코드입니다. 그대로 복사해서 실행할 수 있습니다.
// 파일명: server.js
// 실행 방법: npm install express 후 node server.js
const express = require('express');
const app = express();
app.use(express.json());
let todos = [
{ id: 1, title: 'REST API 원칙 읽기', completed: true },
{ id: 2, title: '직접 서버 만들어보기', completed: false },
];
let nextId = 3;
// 목록 조회 (완료 여부 필터 지원)
app.get('/todos', (req, res) => {
const { completed } = req.query;
let result = todos;
if (completed !== undefined) {
result = todos.filter(t => String(t.completed) === completed);
}
res.status(200).json(result);
});
// 하나 조회
app.get('/todos/:id', (req, res) => {
const todo = todos.find(t => t.id === Number(req.params.id));
if (!todo) {
return res.status(404).json({ code: 'TODO_NOT_FOUND', message: '해당 할 일을 찾을 수 없습니다.' });
}
res.status(200).json(todo);
});
// 생성
app.post('/todos', (req, res) => {
const { title } = req.body;
if (!title) {
return res.status(400).json({
code: 'VALIDATION_ERROR',
message: '입력값을 확인해주세요.',
errors: [{ field: 'title', message: 'title은 필수입니다.' }],
});
}
const todo = { id: nextId++, title, completed: false };
todos.push(todo);
res.status(201).json(todo);
});
// 일부 수정
app.patch('/todos/:id', (req, res) => {
const todo = todos.find(t => t.id === Number(req.params.id));
if (!todo) {
return res.status(404).json({ code: 'TODO_NOT_FOUND', message: '해당 할 일을 찾을 수 없습니다.' });
}
const { title, completed } = req.body;
if (title !== undefined) todo.title = title;
if (completed !== undefined) todo.completed = completed;
res.status(200).json(todo);
});
// 삭제
app.delete('/todos/:id', (req, res) => {
const idx = todos.findIndex(t => t.id === Number(req.params.id));
if (idx === -1) {
return res.status(404).json({ code: 'TODO_NOT_FOUND', message: '해당 할 일을 찾을 수 없습니다.' });
}
todos.splice(idx, 1);
res.status(204).send();
});
app.listen(3000, () => console.log('server on :3000'));
이제 하나씩 호출한 실제 결과입니다.
목록 조회 — 200
$ curl -i localhost:3000/todos
HTTP/1.1 200 OK
[{"id":1,"title":"REST API 원칙 읽기","completed":true},{"id":2,"title":"직접 서버 만들어보기","completed":false}]
없는 ID 조회 — 404 (원칙 5, 6)
$ curl -i localhost:3000/todos/999
HTTP/1.1 404 Not Found
{"code":"TODO_NOT_FOUND","message":"해당 할 일을 찾을 수 없습니다."}
200에 실패 메시지를 담지 않고, 상태 코드가 결과를 말합니다. 본문의 code는 프로그램용, message는 사람용입니다.
생성 — 201 (원칙 1: URL은 그대로, 메서드가 행동을 바꾼다)
$ curl -i -X POST -H "Content-Type: application/json" \
-d '{"title":"API 공부하기"}' localhost:3000/todos
HTTP/1.1 201 Created
{"id":3,"title":"API 공부하기","completed":false}
잘못된 요청 — 400 (원칙 6: 어느 필드가 왜 문제인지)
$ curl -i -X POST -H "Content-Type: application/json" \
-d '{}' localhost:3000/todos
HTTP/1.1 400 Bad Request
{"code":"VALIDATION_ERROR","message":"입력값을 확인해주세요.","errors":[{"field":"title","message":"title은 필수입니다."}]}
일부 수정 — PATCH로 completed만 변경
$ curl -i -X PATCH -H "Content-Type: application/json" \
-d '{"completed":true}' localhost:3000/todos/3
HTTP/1.1 200 OK
{"id":3,"title":"API 공부하기","completed":true}
삭제 — 204, 그리고 삭제 확인
$ curl -i -X DELETE localhost:3000/todos/3
HTTP/1.1 204 No Content
$ curl -i localhost:3000/todos/3
HTTP/1.1 404 Not Found
{"code":"TODO_NOT_FOUND","message":"해당 할 일을 찾을 수 없습니다."}
삭제가 성공하면 반환할 본문이 없으므로 204를 썼고, 같은 ID를 다시 조회하면 404가 나옵니다. 상태가 실제로 바뀌었다는 것을 응답만으로 확인할 수 있습니다.
이제 새로운 요구사항이 들어왔다고 해보겠습니다. “완료한 할 일만 보고 싶습니다.”
새 주소를 만들기 전에, 기존 목록 API를 확장할 수 있는지 먼저 생각합니다(원칙 4, 7).
$ curl -i "localhost:3000/todos?completed=true"
HTTP/1.1 200 OK
[{"id":1,"title":"REST API 원칙 읽기","completed":true},{"id":3,"title":"API 공부하기","completed":true}]
기능이 하나 추가될 때마다 새 주소를 만드는 것이 아니라, 기존 자원 구조 안에서 자연스럽게 표현할 방법을 먼저 찾는 것. REST 설계의 중요한 사고방식입니다.
한 가지 알아둘 점: 이 서버는 데이터를 메모리에만 저장합니다. 서버를 껐다 켜면 할 일 목록이 처음 상태로 돌아갑니다. 실제 서비스에서는 데이터베이스에 저장해야 하는데, 그것은 다음 단계의 주제입니다.
API를 만들기 전에 이 7가지만 확인해도 많이 달라진다
API 하나를 만들 때마다 이렇게 물어보면 좋습니다.
- 이 API에서 다루는 자원은 무엇인가?
- URL에 불필요한 동사를 넣지는 않았는가?
- 기존 API와 이름 규칙이 같은가?
- 적절한 HTTP 메서드를 사용했는가?
- 성공과 실패를 적절한 상태 코드로 표현했는가?
- 오류가 발생했을 때 원인을 찾을 수 있는가?
- API가 늘어나도 지금의 규칙을 계속 사용할 수 있는가?
이 질문에 대부분 답할 수 있다면 설계 방향은 크게 벗어나지 않았을 가능성이 높습니다.
RESTful API는 규칙을 많이 외우는 문제가 아니다
REST API를 처음 공부하면 URL 규칙, HTTP 메서드, 상태 코드, 버전 관리 등 외워야 할 것이 끝없이 보입니다. 하지만 하나씩 떼어보면 결국 같은 문제를 해결하려는 원칙들입니다.
API를 처음 보는 사람도 쉽게 이해하고 예측할 수 있게 만드는 것.
GET /getUser 대신 GET /users/123을 사용하는 이유도, 상태 코드를 제대로 사용하는 이유도, 이름을 일관되게 만드는 이유도 결국 여기에 있습니다.
좋은 API는 화려하지 않습니다. 오히려 사용하다 보면 이런 생각이 드는 API에 가깝습니다.
“다음 API가 어떻게 생겼을지 알 것 같다.”
그 정도라면 꽤 잘 설계된 API입니다.
자주 묻는 질문
REST와 RESTful은 같은 말인가요?
REST는 웹 서비스를 설계하는 아키텍처 스타일을 의미합니다. 일반적으로 REST의 원칙을 따르도록 설계된 API를 RESTful API라고 표현합니다. 다만 현실의 많은 서비스는 REST 원칙을 정도의 차이를 두고 적용합니다.
URL은 무조건 복수형을 써야 하나요?
/users, /products처럼 복수형을 사용하는 관례가 널리 쓰이지만, 그것 자체가 REST의 절대 규칙은 아닙니다. /user를 썼다고 API가 작동하지 않는 것도 아닙니다. 더 중요한 것은 하나의 프로젝트에서 규칙을 정하고 일관되게 사용하는 것입니다.
DELETE가 성공하면 반드시 204를 반환해야 하나요?
아닙니다. 삭제 후 반환할 본문이 없다면 204 No Content를 사용할 수 있고(위 실행 예시가 이 경우입니다), API 설계에 따라 다른 성공 응답을 쓸 수도 있습니다. 특정 코드를 기계적으로 외우기보다, 응답이 전달하려는 의미와 HTTP 의미가 맞는지 확인하는 것이 좋습니다.
REST API를 만들 때 가장 먼저 정해야 할 것은 무엇인가요?
URL보다 먼저, 어떤 자원을 다루는 API인지 정하는 것이 좋습니다. 사용자, 주문, 상품처럼 핵심 자원을 정하고 그 자원에 어떤 동작이 필요한지 살펴보면 URL과 메서드 구조도 훨씬 쉽게 정리됩니다.
다음 단계
이 글의 서버를 직접 실행해보는 것이 가장 좋은 다음 행동입니다. 코드를 복사해 server.js로 저장하고, npm install express 후 node server.js로 실행한 뒤, 위의 curl 명령을 하나씩 따라 해보세요. 글에서 본 상태 코드가 본인 터미널에서 그대로 나오는지 확인하는 순간, 원칙이 지식에서 경험으로 바뀝니다.
그다음 주제는 자연스럽게 두 가지로 이어집니다. 서버를 재시작해도 데이터가 남도록 데이터베이스 연결하기, 그리고 이 글에서 코드로 다루지 않은 401/403 — 인증과 권한 구현하기입니다.
참고 자료·검증 환경
- RFC 9110 — HTTP Semantics (메서드·상태 코드의 표준 의미): https://www.rfc-editor.org/rfc/rfc9110
- RFC 5789 — PATCH Method for HTTP: https://www.rfc-editor.org/rfc/rfc5789
- RFC 6585 — Additional HTTP Status Codes (429 포함): https://www.rfc-editor.org/rfc/rfc6585
- MDN — HTTP 상태 코드: https://developer.mozilla.org/ko/docs/Web/HTTP/Status
- GitHub REST API 문서: https://docs.github.com/rest
이 글의 모든 요청·응답은 2026-08-14에 Node.js v22.22.2, Express 5.2.1 환경에서 실제 실행해 확인했습니다. GitHub API 응답도 같은 날 실제 호출 결과입니다.