RESTful API 설계와 구현
리소스 URI와 HTTP 메서드·상태 코드·JSON 응답 규칙을 세우고 Express로 사용자 CRUD API를 구현합니다.
앞 글 Node.js와 Express.js 입문에서는 Express로 요청을 받아 응답하는 간단한 서버를 만들었습니다. SPA와 모바일 앱은 서버와 데이터를 주고받을 때 정해진 규칙을 따르는데, 이 규칙이 API이고 그 가운데 가장 널리 쓰이는 것이 RESTful API입니다. RESTful API는 프런트엔드와 백엔드가 따로 개발되면서도 서로 잘 맞물리게 해 주는 핵심 개념입니다.
이 글에서는 RESTful API의 개념과 설계 원칙을 정리하고, Express로 사용자 정보를 만들고 읽고 고치고 지우는 API를 직접 구현합니다. 그다음 입력을 검사하지 않는 처음 코드와 검사를 더한 코드를 비교하며, 같은 요청이 어떻게 다르게 처리되는지 따라가 봅니다.
API와 REST
API(application programming interface)는 두 소프트웨어가 서로 통신하는 규칙의 집합입니다. 웹에서는 주로 서버가 클라이언트에게 데이터를 주거나 기능을 실행하도록 열어 둔 창구를 말합니다. 식당에 비유하면, 손님(클라이언트)이 종업원(API)에게 주문(요청)을 하면 종업원이 주방(서버)에 전달하고, 주방이 만든 음식(응답)을 종업원이 가져다주는 것과 같습니다. 손님은 주방에서 음식을 어떻게 만드는지 몰라도 종업원과 이야기하면 되듯, 프런트엔드는 백엔드의 내부 구현을 몰라도 정해진 규칙대로 데이터를 요청하고 받을 수 있습니다.
REST(representational state transfer)는 웹 서비스를 만드는 아키텍처 스타일의 하나이고, RESTful API는 REST의 원칙을 따르는 API입니다. REST의 핵심은 HTTP와 URI 같은 웹의 기존 기술을 최대한 활용해 웹 서비스를 만드는 것입니다.
REST의 원칙
REST는 몇 가지 제약 조건을 따릅니다. 먼저 클라이언트-서버 구조로, 서버는 API를 제공하고 클라이언트는 화면을 맡아 서로의 의존을 줄입니다. 다음은 무상태성(stateless)입니다. 각 요청은 이전 요청과 독립적이어야 하고, 서버는 요청을 처리하는 데 필요한 정보를 그 요청 자체에서 얻어야 하므로 클라이언트는 인증 토큰 같은 정보를 요청마다 담아 보냅니다. 그 덕분에 여러 서버에 요청을 나눠 보내기 쉽고 동작을 예측하기 좋습니다.
무상태성은 서버가 데이터를 저장하지 말라는 뜻이 아닙니다. 아래 예제의 users 배열이나 데이터베이스의 사용자 정보는 요청 사이에 그대로 남으므로, POST로 사용자를 추가한 뒤 GET /api/users/3으로 조회할 수 있습니다. 다만 “앞 요청에서 고른 사용자” 같은 서버 쪽의 대화 문맥에 기대지 않고, 각 요청의 경로와 인증 정보만으로 요청을 해석할 수 있어야 한다는 뜻입니다. 요청의 순서에 따라 자원이 바뀌는 것을 막는 것도 아니며, 메모리 배열이 남아 있는 것과 재시작한 뒤에도 데이터베이스에 남는 것은 별개의 문제입니다(Fielding 논문의 무상태 제약 5.1.3).
응답은 캐시할 수 있어야 합니다. HTTP의 Cache-Control이나 ETag 같은 헤더로 클라이언트가 응답을 캐시하게 해 네트워크 사용량을 줄이고 응답을 빠르게 합니다. 시스템은 계층화될 수 있어, 클라이언트는 서버와 직접 통신하는지, 중간에 프록시나 부하 분산 장치가 있는지 몰라도 됩니다.
마지막으로 인터페이스가 일관되어야 하며, 이것이 REST의 가장 중요한 원칙입니다. 모든 자원은 /users, /products/123처럼 URI로 식별되고, 클라이언트는 JSON 같은 자원의 표현(representation)을 HTTP 메시지에 담아 자원을 다룹니다. 메시지는 스스로를 설명할 수 있어야 하며, 엄밀한 REST는 서버가 준 표현 안의 링크를 따라 다음 상태로 이동하는 하이퍼미디어(HATEOAS) 제약도 요구합니다. 이 글의 입문용 JSON CRUD 예제는 이 마지막 제약까지는 구현하지 않습니다.
RESTful API 설계 규칙
RESTful API는 자원(resource)을 URI로 표현하고, 그 자원에 대한 행위인 CRUD(create, read, update, delete)를 HTTP 메서드로 표현합니다.
DATA-FLOW · B99 WEB FUNDAMENTALS
REST 리소스 계약 구성 지도
리소스 URI를 중심으로 method 의미, request/response representation, status와 안정적인 error, collection pagination, related resource 링크를 한 계약으로 묶는다.
Resource identity
/users collection과 /users/{id} item을 명사 URI로 식별한다.
Method semantics
GET은 조회, POST는 생성, PUT/PATCH는 갱신, DELETE는 삭제의 계약을 갖는다.
Representation schema
요청·응답 JSON의 필수 필드, 타입, Content-Type, validation을 고정한다.
Status · stable error
200·201·204와 400·404 같은 상태, 기계가 읽는 error code와 사람용 message를 함께 정의한다.
Collection contract
cursor/limit, filter, sort, next cursor의 입력·출력도 목록 schema다.
Related resources
/users/{id}/posts처럼 관계를 링크나 중첩 URI로 탐색하게 한다.
stateless는 서버가 데이터를 저장하지 않는다는 뜻이 아니라 각 요청이 처리에 필요한 인증·문맥을 스스로 제공한다는 제약이다.
위 그림은 REST API의 설계를 이루는 요소들을 자원의 URI를 중심으로 묶은 지도입니다. 가운데의 자원 식별은 /users 같은 컬렉션과 /users/{id} 같은 개별 항목을 명사 URI로 나타냅니다. 그 주위로 다섯 가지 요소가 연결됩니다. 메서드의 의미는 GET이 조회, POST가 생성, PUT과 PATCH가 갱신, DELETE가 삭제라는 것이고, 표현은 요청과 응답 JSON의 필수 필드와 타입, Content-Type, 검사 규칙을 정해 두는 것입니다.
상태와 오류는 200, 201, 204나 400, 404 같은 상태 코드와 함께, 프로그램이 읽을 오류 코드와 사람이 읽을 메시지를 일정한 모양으로 정해 두는 것입니다. 컬렉션은 목록을 나눠 받는 페이지 나누기(pagination)와 필터, 정렬의 입력과 출력, 다음 페이지를 가리키는 커서까지 목록의 형식으로 정합니다. 관련 자원은 /users/{id}/posts처럼 관계를 링크나 중첩된 URI로 찾아가게 합니다. 그림의 화살표는 처리하는 순서가 아니라 URI와 각 요소의 관계를 뜻하며, 그림 아래의 설명처럼 무상태성은 서버가 데이터를 저장하지 않는다는 뜻이 아니라 각 요청이 필요한 인증과 문맥을 스스로 담는다는 제약입니다.
URI로 자원 표현하기
URI는 동사 대신 명사를 쓰고 복수형을 권합니다. 계층은 /로 나타내고, 읽기 쉽게 단어를 나눌 때는 하이픈(-)을 쓰며 밑줄(_)은 쓰지 않습니다. /users.json처럼 파일 확장자를 넣지 않고 /users로 씁니다.
예를 들어 사용자 목록은 /users, 특정 사용자는 /users/{id}(예: /users/123), 그 사용자의 게시물 목록은 /users/{id}/posts, 특정 상품은 /products/{id}입니다.
HTTP 메서드로 행위 표현하기
| HTTP 메서드 | 행위 (CRUD) | 설명 |
|---|---|---|
| GET | Read | 자원을 조회합니다. 서버의 데이터를 바꾸지 않습니다. |
| POST | Create | 새 자원을 만듭니다. |
| PUT | Update | 자원 전체를 교체합니다. 자원이 없으면 만들 수도 있습니다. |
| PATCH | Update | 자원의 일부를 고칩니다. |
| DELETE | Delete | 자원을 지웁니다. |
이 규칙으로 사용자 API의 엔드포인트를 정리하면 다음과 같습니다.
| 목적 | URI | HTTP 메서드 | 요청 본문 (예) | 응답 (예) |
|---|---|---|---|---|
| 모든 사용자 조회 | /users | GET | (없음) | [ { id: 1, name: 'A' }, ... ] |
| 새 사용자 생성 | /users | POST | { "name": "New User" } | { id: 4, name: 'New User' } (만든 자원) |
| 특정 사용자 조회 | /users/1 | GET | (없음) | { id: 1, name: 'A' } |
| 사용자 정보 전체 교체 | /users/1 | PUT | { "name": "Updated Name" } | { id: 1, name: 'Updated Name' } |
| 사용자 이름만 변경 | /users/1 | PATCH | { "name": "Partial Update" } | { id: 1, name: 'Partial Update' } |
| 특정 사용자 삭제 | /users/1 | DELETE | (없음) | HTTP 204 No Content 또는 {"message": "삭제 성공"} |
응답 형식
RESTful API의 응답은 주로 JSON을 씁니다. JSON은 사람과 프로그램 모두 읽고 쓰기 쉬운 가벼운 데이터 형식이라 웹에서 데이터를 주고받는 표준처럼 쓰입니다. 성공하면 200 OK나 201 Created 같은 알맞은 상태 코드와 함께 JSON 데이터를 보내고, 실패하면 4xx(클라이언트 오류)나 5xx(서버 오류) 상태 코드와 함께 {"error": "User not found"} 같은 오류 정보를 JSON으로 보냅니다.
더 깊이: 오류 응답에도 표준 형식이 있다
{"error": "..."} 같은 오류 형식은 API마다 제각각이라, 여러 API를 쓰는 클라이언트는 서비스마다 오류를 읽는 코드를 따로 만들어야 합니다. RFC 9457은 HTTP 응답에 오류의 자세한 내용을 프로그램이 읽을 수 있는 모양으로 담는 문제 세부 정보(problem details) 형식을 정해, 이런 수고를 줄입니다. JSON으로 보낼 때의 미디어 타입은 application/problem+json이며, 2023년에 나온 이 문서가 예전의 RFC 7807을 대신합니다.
표준이 정한 필드는 다섯 가지입니다. type은 문제의 종류를 가리키는 URI이고, 적지 않으면 about:blank로 보는데, 이는 HTTP 상태 코드 말고는 더 알려 줄 종류가 없다는 뜻입니다. title은 그 종류를 짧게 요약한 사람이 읽을 문장, status는 서버가 이 응답에 붙인 HTTP 상태 코드, detail은 이번에 일어난 문제를 설명하는 문장, instance는 이번 문제 자체를 가리키는 URI입니다. 예를 들어 사용자를 찾지 못했다면 {"type": "https://example.com/problems/user-not-found", "title": "사용자를 찾을 수 없습니다", "status": 404, "detail": "ID 7인 사용자가 없습니다"} 같은 본문을 보냅니다.
서비스에 필요한 정보는 확장 필드로 더할 수 있고, 클라이언트는 모르는 확장 필드를 무시해야 합니다. 그래서 입력 검사에 실패한 필드의 목록 같은 정보를 errors 같은 이름으로 함께 보내도 형식을 깨지 않습니다. 클라이언트가 분기에 쓸 값은 사람이 읽는 title이나 detail이 아니라 type으로 정해 두면, 문장을 바꾸거나 번역해도 클라이언트 코드는 그대로 둘 수 있습니다. 형식의 정의는 RFC 9457에 있습니다.
Express로 RESTful API 구현하기
앞에서 배운 Express로 간단한 users API를 만들어 봅니다. 아직 데이터베이스를 연결하지 않으므로 사용자 데이터는 메모리의 배열로 관리합니다. 다음 app.js는 메서드와 응답을 연결하는 입문용 코드로, 뒤에서 입력 검사를 더한 코드와 비교할 원본입니다.
const express = require('express');
const app = express();
const port = process.env.PORT || 3000;
// JSON 요청 본문을 파싱하기 위한 미들웨어
app.use(express.json());
// 임시 데이터 (데이터베이스 대신 메모리에서 관리)
let users = [
{ id: 1, name: 'Alice', email: '[email protected]' },
{ id: 2, name: 'Bob', email: '[email protected]' },
];
let nextUserId = 3; // 다음 사용자 ID를 위한 변수
// --- RESTful API 라우트 정의 ---
// 1. 모든 사용자 조회 (GET /api/users)
app.get('/api/users', (req, res) => {
res.json(users); // users 배열을 JSON 형태로 응답
});
// 2. 특정 사용자 조회 (GET /api/users/:id)
// ':id'는 URL 파라미터로, req.params.id로 접근 가능
app.get('/api/users/:id', (req, res) => {
const id = parseInt(req.params.id); // URL 파라미터는 문자열이므로 숫자로 변환
const user = users.find(u => u.id === id); // id에 해당하는 사용자 찾기
if (user) {
res.json(user); // 사용자 정보 응답
} else {
res.status(404).json({ message: 'User not found' }); // 404 Not Found 응답
}
});
// 3. 새 사용자 생성 (POST /api/users)
app.post('/api/users', (req, res) => {
if (!req.body || typeof req.body !== 'object') {
return res.status(400).json({ message: 'Name and email are required.' });
}
const newUser = {
id: nextUserId++, // ID 자동 증가
name: req.body.name,
email: req.body.email
};
// 필수 필드 검증 (간단하게)
if (!newUser.name || !newUser.email) {
return res.status(400).json({ message: 'Name and email are required.' });
}
users.push(newUser); // users 배열에 새 사용자 추가
res.status(201).json(newUser); // 201 Created 상태 코드와 함께 새로 생성된 사용자 정보 응답
});
// 4. PUT 경로 데모: 아래 구현은 기존 필드에 병합하며 완전 교체는 구현하지 않음
app.put('/api/users/:id', (req, res) => {
const id = parseInt(req.params.id);
const updatedUser = req.body;
let userFound = false;
users = users.map(user => {
if (user.id === id) {
userFound = true;
// id는 유지하고, 전달된 필드를 기존 사용자 정보에 병합
return { ...user, ...updatedUser, id: id };
}
return user;
});
if (userFound) {
const user = users.find(u => u.id === id);
res.json(user); // 갱신된 사용자 정보 응답
} else {
res.status(404).json({ message: 'User not found' });
}
});
// 5. 특정 사용자 정보 부분 갱신 (PATCH /api/users/:id)
app.patch('/api/users/:id', (req, res) => {
const id = parseInt(req.params.id);
const updates = req.body; // 업데이트할 필드만 포함
let userFound = false;
users = users.map(user => {
if (user.id === id) {
userFound = true;
// 기존 사용자 정보에 업데이트할 필드만 덮어씌움
return { ...user, ...updates, id: id };
}
return user;
});
if (userFound) {
const user = users.find(u => u.id === id);
res.json(user);
} else {
res.status(404).json({ message: 'User not found' });
}
});
// 6. 특정 사용자 삭제 (DELETE /api/users/:id)
app.delete('/api/users/:id', (req, res) => {
const id = parseInt(req.params.id);
const initialLength = users.length;
users = users.filter(u => u.id !== id); // id에 해당하지 않는 사용자만 남김
if (users.length < initialLength) {
res.status(204).send(); // 204 No Content (삭제 성공 시, 응답 본문 없음)
} else {
res.status(404).json({ message: 'User not found' });
}
});
// 서버 시작
app.listen(port, () => {
console.log(`RESTful API 서버가 http://localhost:${port} 에서 실행 중입니다.`);
console.log(`테스트 엔드포인트:`);
console.log(`- GET /api/users`);
console.log(`- GET /api/users/1`);
console.log(`- POST /api/users (Body: {"name": "New", "email": "[email protected]"})`);
console.log(`- PUT /api/users/1 (Body: {"name": "Updated", "email": "[email protected]"})`);
console.log(`- PATCH /api/users/2 (Body: {"email": "[email protected]"})`);
console.log(`- DELETE /api/users/1`);
});app.get('/api/users/:id', ...)의 :id는 URL 매개변수로, req.params.id로 읽습니다. URL 매개변수는 문자열이므로 숫자로 바꿔 사용자를 찾고, 없으면 404를 돌려줍니다. POST는 새 사용자를 만들어 201 Created와 함께 돌려주고, DELETE는 삭제에 성공하면 본문 없이 204 No Content를 돌려줍니다.
node app.js(또는 npx nodemon app.js)로 서버를 실행한 뒤, Postman이나 Insomnia 같은 도구, 또는 브라우저의 Fetch API로 각 엔드포인트에 요청을 보내 봅니다. POST, PUT, PATCH로 JSON 본문을 보낼 때는 Content-Type: application/json을 정합니다. GET /api/users와 GET /api/users/1, 본문에 {"name": "새로운 사용자", "email": "[email protected]"}을 담은 POST /api/users, {"name": "앨리스", "email": "[email protected]"}을 담은 PUT /api/users/1, {"name": "밥 (수정됨)"}을 담은 PATCH /api/users/2, 그리고 DELETE /api/users/1을 차례로 시험해 보세요.
Express CRUD 요청 보호 흐름 — 상태 추적
구체적인 guarded-users 요청에서 앞 검사가 실패하면 뒤 CRUD는 실행하지 않는다. 400/401/403/404/409와 단일 종료 응답의 도달/미도달 상태를 보여 줄 가치가 있다.
FLOWCHART · B99 WEB FUNDAMENTALS
Express CRUD 요청 보호 흐름
잘못된 요청은 CRUD 실행 전에 종료합니다. 기존 대상을 조회·수정·삭제할 때는 존재를 확인하고, 컬렉션 POST는 생성 조건을 확인합니다. 아래 최소 예제에 인증·검증 전체가 구현된 것은 아닙니다.
- route·body 검사
경로가 없으면 404, 해당 대상에서 method를 지원하지 않으면 405, body가 잘못되면 400이다.
- 인증·권한 검사
신원이 없으면 401, 행동 권한이 없으면 403으로 종료한다.
- 리소스 확인
조회·수정·삭제 대상이 없으면 404다. 컬렉션 POST는 새 대상을 만들 수 있다.
- CRUD 실행
모든 가드를 통과한 요청만 read·create·update·delete를 수행한다.
- 응답 종결
200·201·204 등 계약에 맞는 응답을 한 번 보내고 종료한다. 204는 body가 없다.
실패 경로는 응답 후 종료합니다. 405 응답과 생성 충돌 검사는 직접 설계해야 하며 Express가 자동 보장하지 않습니다. 생성 조건 충돌은 계약에 따라 409 등으로 매핑합니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 관계·조건 설명 | 세 검사를 통과한 요청만 CRUD와 단일 2xx 응답으로 진행하며 각 아니요 분기는 오류 응답에서 종료하는 동일한 보호 흐름 |
| 원도표 조건·주석 | route·body 검사: 경로가 없으면 404, 해당 대상에서 method를 지원하지 않으면 405, body가 잘못되면 400이다. |
| 원도표 조건·주석 | 인증·권한 검사: 신원이 없으면 401, 행동 권한이 없으면 403으로 종료한다. |
| 원도표 조건·주석 | 리소스 확인: 조회·수정·삭제 대상이 없으면 404다. 컬렉션 POST는 새 대상을 만들 수 있다. |
| 원도표 조건·주석 | CRUD 실행: 모든 가드를 통과한 요청만 read·create·update·delete를 수행한다. |
| 원도표 조건·주석 | 응답 종결: 200·201·204 등 계약에 맞는 응답을 한 번 보내고 종료한다. 204는 body가 없다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 입력과 인증 | 거절: 400 또는 401 / 업무 처리: 아직 없음 | 요청 형식과 로그인 여부를 검사한다 | 실패 조건은 handler 앞에서 종료한다 |
| 권한과 자원 | 거절: 403 또는 404 / 대상: 허용된 현재 자원 | 인증된 사용자의 접근 권한과 대상 존재를 확인한다 | 인증 성공만으로 대상 접근을 허용하지 않는다 |
| 업무 결과 | 충돌: 409 / 성공: CRUD 응답 계약 | 충돌은 409로, 성공은 지정한 2xx로 응답한다 | 현재 업무 조건이 실제 저장/응답을 결정한다 |
Express CRUD 요청 보호 흐름 — 상태 추적
구체적인 guarded-users 요청에서 앞 검사가 실패하면 뒤 CRUD는 실행하지 않는다. 400/401/403/404/409와 단일 종료 응답의 도달/미도달 상태를 보여 줄 가치가 있다.
FLOWCHART · B99 WEB FUNDAMENTALS
Express CRUD 요청 보호 흐름
잘못된 요청은 CRUD 실행 전에 종료합니다. 기존 대상을 조회·수정·삭제할 때는 존재를 확인하고, 컬렉션 POST는 생성 조건을 확인합니다. 아래 최소 예제에 인증·검증 전체가 구현된 것은 아닙니다.
- route·body 검사
경로가 없으면 404, 해당 대상에서 method를 지원하지 않으면 405, body가 잘못되면 400이다.
- 인증·권한 검사
신원이 없으면 401, 행동 권한이 없으면 403으로 종료한다.
- 리소스 확인
조회·수정·삭제 대상이 없으면 404다. 컬렉션 POST는 새 대상을 만들 수 있다.
- CRUD 실행
모든 가드를 통과한 요청만 read·create·update·delete를 수행한다.
- 응답 종결
200·201·204 등 계약에 맞는 응답을 한 번 보내고 종료한다. 204는 body가 없다.
실패 경로는 응답 후 종료합니다. 405 응답과 생성 충돌 검사는 직접 설계해야 하며 Express가 자동 보장하지 않습니다. 생성 조건 충돌은 계약에 따라 409 등으로 매핑합니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 관계·조건 설명 | 세 검사를 통과한 요청만 CRUD와 단일 2xx 응답으로 진행하며 각 아니요 분기는 오류 응답에서 종료하는 동일한 보호 흐름 |
| 원도표 조건·주석 | route·body 검사: 경로가 없으면 404, 해당 대상에서 method를 지원하지 않으면 405, body가 잘못되면 400이다. |
| 원도표 조건·주석 | 인증·권한 검사: 신원이 없으면 401, 행동 권한이 없으면 403으로 종료한다. |
| 원도표 조건·주석 | 리소스 확인: 조회·수정·삭제 대상이 없으면 404다. 컬렉션 POST는 새 대상을 만들 수 있다. |
| 원도표 조건·주석 | CRUD 실행: 모든 가드를 통과한 요청만 read·create·update·delete를 수행한다. |
| 원도표 조건·주석 | 응답 종결: 200·201·204 등 계약에 맞는 응답을 한 번 보내고 종료한다. 204는 body가 없다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 입력과 인증 | 거절: 400 또는 401 / 업무 처리: 아직 없음 | 요청 형식과 로그인 여부를 검사한다 | 실패 조건은 handler 앞에서 종료한다 |
| 권한과 자원 | 거절: 403 또는 404 / 대상: 허용된 현재 자원 | 인증된 사용자의 접근 권한과 대상 존재를 확인한다 | 인증 성공만으로 대상 접근을 허용하지 않는다 |
| 업무 결과 | 충돌: 409 / 성공: CRUD 응답 계약 | 충돌은 409로, 성공은 지정한 2xx로 응답한다 | 현재 업무 조건이 실제 저장/응답을 결정한다 |
위 그림은 잘못된 요청을 CRUD를 실행하기 전에 걸러 내는 흐름입니다. 요청이 들어오면 먼저 경로와 본문이 올바른지 봅니다. 경로가 없으면 404, 그 대상이 메서드를 지원하지 않으면 405, 본문이 잘못되면 400으로 끝냅니다. 그다음 인증과 권한을 확인해, 누구인지 모르면 401, 그 행동의 권한이 없으면 403으로 끝냅니다.
세 번째로 대상이 있는지와 생성 조건을 확인합니다. 조회, 수정, 삭제할 대상이 없으면 404이고, 컬렉션에 POST하는 경우에는 새 대상을 만들 수 있으며 조건이 충돌하면 409 등으로 끝냅니다. 세 검사를 모두 통과한 요청만 조회, 생성, 수정, 삭제를 실행하고, 200, 201, 204 같은 알맞은 성공 응답을 한 번 보낸 뒤 끝납니다. 204에는 본문이 없습니다.
그림의 아래쪽 문구처럼 요청 하나는 응답 하나로 끝나야 하며, 실패한 경로가 다음 처리로 흘러 내려가면 안 됩니다. 405 응답과 생성 충돌 검사는 직접 설계해야 하며 Express가 저절로 해 주지는 않습니다. 처음 app.js에는 이 그림의 검사가 거의 없고, 아래의 보완 예제가 경로와 본문 검사, 대상 확인을 구현합니다. 인증과 권한 검사는 두 예제 모두 구현하지 않습니다.
숫자로 바꾸는 것과 입력을 검사하는 것
처음 app.js는 parseInt()로 ID를 숫자로 바꿉니다. 그런데 parseInt()는 문자열 전체가 정수인지 검사하지 않고, 숫자로 읽을 수 있는 앞부분까지만 씁니다. 그래서 parseInt('1abc')와 parseInt('1.5')는 모두 1입니다(ECMAScript의 parseInt 규칙).
처음 사용자가 Alice(ID 1)와 Bob(ID 2)일 때 DELETE /api/users/1abc를 보내면, ID를 1로 읽어 Alice를 지우고 204를 돌려줍니다. 잘못된 ID라서 404로 끝날 것이라고 생각하면, 실제로 무엇이 바뀌었는지 놓치게 됩니다.
보완 예제는 ID를 양의 안전한 정수의 십진 문자열로만 받는다고 정합니다. 1은 허용하지만 1abc, 1.5, 01, 1e0은 거부하고, 형식이 잘못된 입력은 400, 형식은 올바르지만 사용자가 없는 경우는 404로 나눕니다. 모든 API가 반드시 이 규칙을 따라야 한다는 뜻은 아니며, 이 API가 정한 약속입니다.
다음 코드는 원본 뒤에 붙이는 조각이 아니라 별도의 전체 파일입니다. 앞 글에서 Express를 설치한 프로젝트에 guarded-users.cjs로 저장하고 node guarded-users.cjs로 실행합니다. 둘 다 포트 3000을 쓰므로 원본 app.js와 동시에 실행하지 말고 하나만 실행하며, 같은 Alice와 Bob 상태에서 비교하려면 파일마다 서버를 새로 시작합니다. 메모리 배열은 프로세스가 끝나면 사라집니다.
const express = require('express');
const app = express();
app.use(express.json());
let users = [
{ id: 1, name: 'Alice', email: '[email protected]' },
{ id: 2, name: 'Bob', email: '[email protected]' },
];
let nextUserId = 3;
app.param('id', (req, res, next, rawId) => {
const id = Number(rawId);
// 양의 안전한 정수이며, 원래 문자열도 같은 십진 표현이어야 합니다.
if (!Number.isSafeInteger(id) || id < 1 || String(id) !== rawId) {
return res.status(400).json({ message: 'Invalid user ID' });
}
req.userId = id;
next();
});
app.get('/api/users', (req, res) => res.json(users));
app.get('/api/users/:id', (req, res) => {
const user = users.find(user => user.id === req.userId);
if (!user) {
return res.status(404).json({ message: 'User not found' });
}
return res.json(user);
});
app.post('/api/users', (req, res) => {
const body = req.body;
if (body === null || typeof body !== 'object' || Array.isArray(body)
|| typeof body.name !== 'string' || typeof body.email !== 'string') {
return res.status(400).json({ message: 'Name and email must be strings' });
}
if (Object.keys(body).some(key => !['name', 'email'].includes(key))) {
return res.status(400).json({ message: 'Only name and email are allowed' });
}
const name = body.name.trim();
const email = body.email.trim();
if (!name || !email) {
return res.status(400).json({ message: 'Name and email are required' });
}
// 모든 입력 검사를 통과한 뒤에만 데이터와 다음 ID를 바꿉니다.
const user = { id: nextUserId, name, email };
users.push(user);
nextUserId += 1;
return res.status(201).json(user);
});
// PUT은 두 필드를 모두, PATCH는 제공한 필드를 각각 검사합니다.
function readUserFields(body, partial = false) {
if (body === null || typeof body !== 'object' || Array.isArray(body)) {
return null;
}
const keys = Object.keys(body);
if (keys.length === 0 || keys.some(key => !['name', 'email'].includes(key))) {
return null;
}
if (!partial && keys.length !== 2) {
return null;
}
const fields = {};
for (const key of keys) {
if (typeof body[key] !== 'string' || !body[key].trim()) {
return null;
}
fields[key] = body[key].trim();
}
return fields;
}
app.put('/api/users/:id', (req, res) => {
const fields = readUserFields(req.body);
if (!fields) {
return res.status(400).json({ message: 'PUT requires name and email only' });
}
const index = users.findIndex(user => user.id === req.userId);
if (index === -1) {
return res.status(404).json({ message: 'User not found' });
}
// 이전 객체에 병합하지 않고, 서버 ID와 전체 표현으로 교체합니다.
const user = { id: req.userId, name: fields.name, email: fields.email };
users[index] = user;
return res.json(user);
});
app.patch('/api/users/:id', (req, res) => {
const fields = readUserFields(req.body, true);
if (!fields) {
return res.status(400).json({ message: 'PATCH requires valid name or email only' });
}
const index = users.findIndex(user => user.id === req.userId);
if (index === -1) {
return res.status(404).json({ message: 'User not found' });
}
// 모든 제공 필드가 유효할 때만 한 번 병합해, 실패한 부분 갱신을 막습니다.
const user = { ...users[index], ...fields };
users[index] = user;
return res.json(user);
});
app.delete('/api/users/:id', (req, res) => {
const index = users.findIndex(user => user.id === req.userId);
if (index === -1) {
return res.status(404).json({ message: 'User not found' });
}
users.splice(index, 1);
return res.status(204).end();
});
app.use((error, req, res, next) => {
if (error.type === 'entity.parse.failed') {
return res.status(400).json({ message: 'Invalid JSON' });
}
next(error);
});
app.listen(3000, () => {
console.log('검증 보완 예제: http://localhost:3000/api/users');
});app.param('id', ...)는 :id가 들어간 경로의 핸들러보다 먼저 ID를 검사합니다. Number()로 바꾼 값이 양의 안전한 정수인지 보고, String(id)가 원래 문자열과 같은지도 확인해, 숫자로 해석할 수 있다는 이유로 다른 표기까지 허용하지 않습니다. 실패하면 그 자리에서 응답을 돌려주므로 조회나 삭제까지 가지 않습니다.
express.json()이 JSON을 읽었다고 필드의 타입까지 검증한 것은 아닙니다. 보완 예제의 POST는 본문이 배열이 아닌 객체인지, name과 email이 문자열인지 확인하고, 앞뒤 공백을 지운 뒤 비어 있으면 거부하며, 두 필드만 새 사용자에 넣습니다. Express의 req.body 문서도 입력을 검증한 뒤에 쓰라고 안내합니다. 검사에 실패하면 users와 nextUserId를 바꾸지 않고, 통과한 요청만 사용자를 추가하고 ID를 늘립니다. 원본은 nextUserId++ 다음에 필수 필드를 검사하므로, 실패한 POST 뒤에도 ID가 하나 건너뛸 수 있습니다.
보완 예제는 GET, POST, PUT, PATCH, DELETE의 입력을 같은 규칙으로 다룹니다. POST와 PUT은 name과 email이 모두 필요하고, PATCH는 둘 가운데 하나 이상을 받습니다. 넘긴 값은 공백만 있지 않은 문자열이어야 하고, id나 그 밖의 모르는 필드는 세 메서드 모두 400으로 거부합니다. ID는 경로와 서버가 만든 순서로 정하므로 본문으로 바꿀 수 없습니다.
원본의 PUT은 기존 객체에 필드를 합치지만, 보완 예제의 PUT은 { id, name, email } 새 객체로 전체를 교체하고, email을 빼면 기존 값으로 채우지 않고 400으로 끝냅니다. 이 API는 서버가 ID를 정하는 POST로만 사용자를 만들므로, 올바른 PUT이나 PATCH라도 대상이 없으면 404를 돌려주고 새로 만들지 않습니다(HTTP의 PUT 규칙).
PATCH는 넘기지 않은 필드는 그대로 두되, 넘긴 필드는 모두 검사합니다. name이 올바르더라도 함께 보낸 email이 빈 문자열이면 요청 전체를 거부하고, name만 먼저 바꾸지 않습니다. 여기서는 허용한 필드에 새 값을 넣는 간단한 부분 갱신 형식을 정했으며, JSON Patch나 JSON Merge Patch 규격 전체를 구현한 것은 아닙니다(HTTP의 PATCH 규칙). 인증과 권한, 이메일의 상세 형식과 중복, 동시에 고칠 때의 충돌 제어, 405 응답은 이 예제에서 구현하지 않았습니다.
같은 요청의 처리 비교
다음 비교는 요청마다 Alice와 Bob 상태에서 새로 시작한다고 가정하며, POST에는 Content-Type: application/json을 정합니다. 표는 코드를 따라가 예상한 결과입니다.
| 요청 | 원본 app.js | 보완 예제 |
|---|---|---|
DELETE /api/users/1abc | 1로 바꿈 → Alice 삭제 → 204 | ID 검사 실패 → 변경 없음 → 400 |
GET /api/users/99 | 대상 없음 → 404 | ID 검사 통과 → 대상 없음 → 404 |
| 이름이 숫자 123이고 email이 문자열인 POST | 참으로 평가되어 통과 → ID 3 사용자 생성 → 201 | 타입 검사 실패 → ID와 목록 그대로 → 400 |
보완 예제에서 마지막 요청의 이름을 올바른 문자열로 고쳐 다시 POST하면 ID 3이 배정됩니다. 실패한 요청을 볼 때는 조회, 생성, 삭제 가운데 어느 단계까지 갔는지와 데이터가 바뀌었는지를 함께 확인해야 합니다. 204 응답에는 본문이 없으므로 JSON으로 읽으려 하면 안 됩니다.
정리
- API는 소프트웨어끼리 통신하는 규칙이고, RESTful API는 자원을 URI로, 행위를 HTTP 메서드로, 결과를 상태 코드로 나타내는 REST 원칙을 따릅니다.
- 무상태성은 요청마다 필요한 인증과 문맥을 담으라는 뜻이지, 서버가 데이터를 저장하지 말라는 뜻이 아닙니다.
- URI는 복수형 명사와 계층 구조로 쓰고, GET은 조회, POST는 생성, PUT은 전체 교체, PATCH는 부분 수정, DELETE는 삭제에 씁니다.
- 요청은 경로와 본문, 인증과 권한, 대상과 생성 조건을 차례로 검사해, 통과한 요청만 CRUD를 실행하고 응답 하나로 끝냅니다.
parseInt()나express.json()은 값을 바꾸거나 읽을 뿐 검증하지 않으므로, 형식 오류는 400, 대상 없음은 404로 따로 검사합니다.- 모든 입력 검사를 통과한 뒤에만 데이터와 다음 ID를 바꿔야, 실패한 요청이 일부만 저장되거나 ID를 건너뛰지 않습니다.
연습 문제
1. 요청을 차례로 따라가기
보완 예제를 새로 시작해 ID 1과 2만 있다고 가정합니다. 다음 요청을 앞 요청의 응답을 받은 뒤 차례로 보냅니다. ① 숫자 이름 123과 문자열 email로 POST, ② 이름 Carol과 [email protected]으로 POST, ③ DELETE /api/users/99, ④ DELETE /api/users/1.5, ⑤ DELETE /api/users/1, ⑥ DELETE /api/users/1을 한 번 더. 각 상태 코드와 최종 사용자 ID를 예측하고, 실패한 요청에서 ID나 목록이 바뀌는지 설명해 보세요.
해설 보기
①은 문자열 타입 검사에서 400으로 끝나며 다음 ID는 여전히 3입니다. ②는 검사를 통과해 Carol(ID 3)을 추가하고 201을 돌려줍니다. ③은 형식이 올바른 ID 99이지만 대상이 없어 404이고, ④의 1.5는 정수가 아니라서 조회나 삭제 전에 400입니다. ⑤는 Alice를 지우고 본문 없는 204를 돌려주며, ⑥은 이미 없으므로 404입니다.
최종 목록은 Bob(ID 2)과 Carol(ID 3)이고, 다음에 만들 ID는 4입니다. 형식은 올바르지만 대상이 없는 경우와 입력 검사를 통과하지 못한 경우를 같은 실패로 합치지 않습니다. 이 결론은 한 프로세스에서 요청을 차례로 보낸다는 가정에서만 맞습니다.
2. PUT 교체와 PATCH 부분 수정
보완 예제를 Alice(ID 1)와 Bob(ID 2) 상태에서 시작해, 다음 요청을 차례로 보냅니다. 모두 Content-Type: application/json입니다. 각 실패 뒤에 GET /api/users/1로 이전 값이 남는지 확인하고, 성공한 PUT과 PATCH가 필드를 어떻게 다루는지 예측해 보세요.
| 순서 | 요청 | JSON 본문 |
|---|---|---|
| 1 | PUT /api/users/1 | { "name": "Alicia" } |
| 2 | PATCH /api/users/1 | { "email": "[email protected]", "name": "" } |
| 3 | PATCH /api/users/1 | { "email": "[email protected]" } |
| 4 | PUT /api/users/1 | { "name": "Alicia", "email": "[email protected]" } |
| 5 | PATCH /api/users/99 | { "name": "Missing" } |
| 6 | PATCH /api/users/1 | { "id": 2 } |
해설 보기
1번은 PUT에 email이 없어 400이고 Alice는 그대로입니다. 2번은 email이 올바른 문자열이어도 함께 보낸 name이 비어 있어 400입니다. 넘긴 필드를 모두 검사하기 전에는 저장된 객체를 건드리지 않으므로 email도 이전 값 그대로입니다.
3번은 200으로 email만 [email protected]으로 바꾸고 이름은 Alice로 둡니다. 4번은 200으로 ID 1의 전체를 { id: 1, name: 'Alicia', email: '[email protected]' }로 교체합니다. 5번은 본문 검사는 통과했지만 ID 99가 없어 404이고, 6번은 허용하지 않는 id 필드라서 400이며 ID를 2로 바꾸지 않습니다.
최종 사용자는 Alicia(ID 1)와 Bob(ID 2)이고 다음 ID는 3입니다. 4번 PUT을 한 번 더 보내도 결과는 같습니다. 같은 요청을 여러 번 보내도 결과가 같은 이 성질 덕분에, PUT은 응답을 못 받았을 때 다시 보내기 비교적 안전합니다.
3. URI 다시 설계하기
어떤 API가 /getUsers, /users/delete/3, /user_posts?userId=3이라는 주소를 씁니다. REST 규칙에 맞게 각각의 메서드와 URI를 다시 정해 보세요.
해설 보기
/getUsers는 동사를 URI에 넣었으므로 GET /users로 바꿉니다. /users/delete/3도 행위를 URI에 넣었으므로 DELETE /users/3으로 바꿉니다. 행위는 메서드가 나타내고, URI는 자원만 나타냅니다.
/user_posts?userId=3은 밑줄을 썼고, 사용자에 속한 게시물이라는 관계를 계층으로 나타내지 않았습니다. GET /users/3/posts로 바꾸면 3번 사용자의 게시물 목록이라는 뜻이 경로에 드러납니다. 여러 사용자의 게시물을 함께 거르는 검색이라면 GET /posts?userId=3처럼 쿼리 문자열로 거르는 방식도 쓸 수 있습니다.
다음 글 데이터베이스 기초 (SQL vs NoSQL)에서는 메모리 배열 대신 데이터를 오래 보관하는 데이터베이스를 살펴보고, 관계형 데이터베이스와 NoSQL 데이터베이스의 차이를 비교합니다.