RESTful API 설계와 구현
리소스 URI와 HTTP 메서드·상태 코드·JSON 응답 규칙을 세우고 Express로 사용자 CRUD API를 구현합니다.
앞 절에서는 Node.js와 Express.js로 간단한 백엔드 서버를 구축하는 방법을 학습했습니다.
이제 서버가 클라이언트 요청을 받아 응답하는 기본 통신 흐름을 이해한 상태입니다.
현대의 웹 애플리케이션, 특히 SPA(Single Page Application)와 모바일 앱은 클라이언트와 서버 간에 데이터를 주고받을 때, 정해진 규칙과 표준을 따르는 방식으로 통신합니다.
이 규칙이 바로 API(Application Programming Interface)이며, 그중에서도 가장 널리 사용되는 것이 RESTful API입니다.
RESTful API는 프론트엔드와 백엔드가 서로 독립적으로 개발되면서도 효과적으로 협력할 수 있도록 돕는 핵심적인 개념입니다.
이번 장에서는 RESTful API 개념과 설계 원칙을 정리하고, Express.js로 직접 구현하는 방법을 다룹니다.
이를 통해 백엔드 서버를 프론트엔드에 필요한 데이터를 체계적으로 제공하는 데이터 허브로 구성할 수 있습니다.
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는 서버가 데이터를 저장하지 않는다는 뜻이 아니라 각 요청이 처리에 필요한 인증·문맥을 스스로 제공한다는 제약이다.
API란?
API (Application Programming Interface)는 "Application Programming Interface"의 약자로, 두 개의 소프트웨어 구성 요소가 서로 통신할 수 있도록 하는 규칙 집합입니다.
웹 개발에서는 주로 서버가 클라이언트에게 데이터를 제공하거나 특정 기능을 수행할 수 있도록 허용하는 인터페이스를 의미합니다.
- 비유: 식당에서 손님(클라이언트)이 종업원(API)에게 주문(요청)을 하면, 종업원이 주방(서버)에 전달하고, 주방은 주문을 처리하여 종업원을 통해 손님에게 음식(응답)을 가져다주는 것과 같습니다. 손님은 주방에서 음식이 어떻게 만들어지는지 알 필요 없이 종업원과 소통합니다.
API를 통해 프론트엔드(클라이언트)는 백엔드(서버)의 내부 구현을 몰라도, 정해진 규칙에 따라 데이터를 요청하고 받을 수 있습니다.
REST 개념
REST (Representational State Transfer)는 웹 서비스를 구축하기 위한 아키텍처 스타일 중 하나입니다.
RESTful API는 이 REST 아키텍처 스타일의 원칙을 따르는 API를 의미합니다.
REST의 핵심 아이디어는 웹의 기존 기술과 프로토콜(HTTP, URI)을 최대한 활용하여 효율적인 웹 서비스를 만드는 것입니다.
REST의 주요 원칙 (제약 조건)
RESTful API는 다음과 같은 원칙들을 따릅니다.
- 서버는 API를 제공하고, 클라이언트는 사용자 인터페이스와 비즈니스 로직을 담당합니다.
- 서로의 의존성을 줄여 독립적인 개발이 가능하게 합니다.
- 각 요청은 이전 요청과 완전히 독립적이어야 합니다. 서버는 클라이언트의 상태를 저장하지 않습니다.
- 클라이언트가 요청을 보낼 때 필요한 모든 정보(인증 토큰 등)를 요청 자체에 포함해야 합니다.
- 장점: 서버의 확장성 증가 (여러 서버 간 부하 분산 용이), 예측 가능한 동작.
무상태성은 자원 데이터를 저장하지 말라는 뜻이 아닙니다. 아래 users 배열이나 DB의 사용자 데이터는 요청 사이에 유지될 수 있으므로, POST로 Carol을 추가한 뒤 GET /api/users/3으로 조회할 수 있습니다. 다만 조회 대상을 “앞 요청에서 선택한 사용자”라는 서버의 대화 문맥에 맡기지 않고, 각 요청의 경로·필요한 인증 정보로 요청을 해석할 수 있어야 합니다. 이 원칙은 요청 순서에 따른 자원 변경을 금지하지 않으며, 메모리 배열의 유지와 재시작 뒤 DB의 영속성은 별개의 문제입니다. Fielding의 무상태 제약(5.1.3)과 자원 상태를 구별해 읽습니다.
- 클라이언트가 캐시 가능한 응답을 받을 수 있도록 하여, 네트워크 트래픽을 줄이고 응답 속도를 향상시킵니다.
- HTTP의 캐싱 메커니즘(HTTP 헤더의
Cache-Control,ETag등)을 활용합니다.
- 클라이언트는 서버와 직접 통신하는지, 중간에 프록시 서버나 로드 밸런서 등이 있는지 알 필요가 없습니다.
- 시스템의 유연성을 높이고 확장성을 제공합니다.
- REST 아키텍처의 핵심 원칙 중 하나입니다. 클라이언트가 특정 API에 대해 일관적인 방식으로 요청할 수 있도록 합니다.
- 이를 위해 다음의 서브 원칙들을 따릅니다.
- 자원 식별 (Identification of resources): 모든 자원(Resource)은 URI(Uniform Resource Identifier)로 명확하게 식별될 수 있어야 합니다. (예:
/users,/products/123) - 메시지를 통한 자원 조작 (Manipulation of resources through representations): 클라이언트가 서버의 자원을 조작하려면, 자원의 표현(Representation)을 HTTP 메시지 바디에 포함하여 전송합니다. (예: JSON, XML)
- 자체 서술적 메시지 (Self-descriptive messages): 응답 메시지 자체에 해당 자원을 어떻게 조작할 수 있는지(다른 API 경로 등)에 대한 정보가 포함되어야 합니다.
- 하이퍼미디어(HATEOAS - Hypermedia as the Engine of Application State): 서버가 제공한 표현의 링크·전이를 따라 애플리케이션 상태를 이동합니다. 이는 엄밀한 REST의 제약입니다. 아래 입문 JSON CRUD 예제는 이 제약까지 구현하지 않습니다.
- 자원 식별 (Identification of resources): 모든 자원(Resource)은 URI(Uniform Resource Identifier)로 명확하게 식별될 수 있어야 합니다. (예:
RESTful API 설계 규칙
RESTful API는 자원(Resource)을 URI로 표현하고, 해당 자원에 대한 행위(CRUD: Create, Read, Update, Delete)를 HTTP 메서드로 표현하는 것을 권장합니다.
자원 (Resource) 표현 (URI)
- 명사 형태를 사용하고, 복수형을 권장합니다.
- 계층 구조를 나타낼 때는
/를 사용합니다. - 하이픈(
-)은 URI 가독성을 높이는 데 사용합니다. - 언더스코어(
_)는 사용하지 않습니다. - 파일 확장자를 URI에 포함하지 않습니다. (예:
/users.jsonX,/usersO)
- 사용자 목록:
/users - 특정 사용자:
/users/{id}(예:/users/123) - 특정 사용자의 게시물 목록:
/users/{id}/posts - 특정 상품:
/products/{id}
행위 (CRUD) 표현 (HTTP 메서드)
HTTP 메서드를 사용하여 자원에 대한 어떤 행위를 수행할지 명확히 나타냅니다.
| HTTP 메서드 | 행위 (CRUD) | 설명 |
|---|---|---|
| GET | Read | 자원을 조회합니다. 서버의 데이터를 변경하지 않습니다. |
| POST | Create | 새로운 자원을 생성합니다. |
| PUT | Update | 자원 전체를 갱신합니다. (자원이 없으면 생성할 수도 있음) |
| PATCH | Update | 자원의 일부를 갱신합니다. |
| DELETE | Delete | 자원을 삭제합니다. |
| 목적 | 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": "삭제 성공"} |
응답 (Response) 형식
RESTful API의 응답은 주로 JSON(JavaScript Object Notation) 형식을 사용합니다.
JSON은 경량 데이터 교환 형식으로, 사람과 기계 모두 읽고 쓰기 쉬워서 웹에서 데이터를 주고받는 표준으로 자리 잡았습니다.
- 성공 응답:
HTTP 200 OK,201 Created등 적절한 상태 코드와 함께 JSON 데이터 포함. - 오류 응답:
HTTP 4xx (클라이언트 오류),5xx (서버 오류)등 적절한 상태 코드와 함께 오류 메시지를 JSON으로 제공. (예:{"error": "User not found"})
Express.js로 RESTful API 구현하기
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로 응답한다 | 현재 업무 조건이 실제 저장/응답을 결정한다 |
이제 앞서 배운 Express.js를 사용하여 간단한 users API를 구현해보겠습니다.
데이터베이스는 아직 연결하지 않으므로, 메모리에 간단한 사용자 데이터를 배열로 관리할 것입니다.
아래 app.js는 메서드와 응답을 연결하는 최초 입문 코드이며, 입력과 갱신 계약의 차이를 비교할 원문입니다. 입력 검증까지 포함한 실습은 뒤의 guarded-users.cjs 전체 파일을 선택하세요. 두 파일을 동시에 포트 3000에서 실행하지 않고 어느 하나만 실행합니다.
app.js (또는 server.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.js에 저장하고 node app.js (또는 nodemon app.js)로 서버를 실행합니다.
이제 Postman, Insomnia 같은 API 테스트 도구나, 웹 브라우저의 Fetch API를 사용하여 각 엔드포인트에 요청을 보내 테스트해 볼 수 있습니다. POST·PUT·PATCH의 JSON 본문을 보낼 때는 Content-Type: application/json을 지정합니다.
GET http://localhost:3000/api/usersGET http://localhost:3000/api/users/1POST http://localhost:3000/api/users(Body에{"name": "새로운 사용자", "email": "[email protected]"}JSON 데이터 포함)PUT http://localhost:3000/api/users/1(Body에{"name": "앨리스", "email": "[email protected]"}JSON 데이터 포함)PATCH http://localhost:3000/api/users/2(Body에{"name": "밥 (수정됨)"}JSON 데이터 포함)DELETE http://localhost:3000/api/users/1
숫자 변환과 입력 검증을 구별하기
원문 app.js는 메서드와 응답을 연결하는 입문 데모입니다. 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, 유효한 ID의 사용자가 없는 경우는 404로 나눕니다. 모든 API의 ID나 오류 코드가 반드시 같아야 한다는 뜻은 아닙니다.
아래 코드는 원문 뒤에 붙이는 조각이 아니라 별도 파일 전체입니다. 이전 절에서 Express를 설치한 프로젝트에 guarded-users.cjs로 저장하고 node guarded-users.cjs로 실행합니다. 원문의 app.js와 동시에 실행하지 않고 둘 중 하나만 포트 3000을 사용합니다. 같은 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');
});앞의 CRUD 보호 흐름 그림에서 ID·본문 입력 검사 실패는 이 보완 예제의 400에, 대상 존재 확인 실패는 404에 해당하며, 그림의 인증·권한 검사는 이 파일에서 구현하지 않았습니다.
Number로 변환한 값이 안전한 정수인지 확인하고, String(id)가 원래 입력과 같은지도 확인합니다. 따라서 숫자처럼 해석할 수 있다는 이유만으로 다른 문자열 표기까지 허용하지 않습니다. app.param('id', ...)는 위 :id 경로의 핸들러보다 먼저 검사합니다. 실패한 분기에서 응답을 반환하므로 사용자 조회나 삭제까지 진행하지 않습니다.
express.json()이 JSON을 읽었다고 필드의 타입까지 검증한 것은 아닙니다. 보완 파일은 POST의 본문이 배열이 아닌 객체인지, name과 email이 문자열인지 확인한 뒤 양끝 공백을 제거합니다. 비어 있으면 실패하며, 두 필드만 새 사용자에 넣습니다. email의 상세 형식·중복, 인증·권한, 데이터베이스 저장은 구현하지 않았습니다. Express의 req.body 설명도 입력을 검증한 뒤 사용하도록 안내합니다.
검사 실패는 users와 nextUserId를 바꾸지 않습니다. 통과한 요청만 사용자 추가와 ID 증가를 수행합니다. 원문은 nextUserId++ 뒤에 필수 필드를 검사하므로 실패한 POST 뒤에 ID가 건너뛸 수 있습니다. ID의 빈 구간 자체가 HTTP 오류인 것은 아니지만, 여기서는 검증과 상태 변경의 순서를 관찰하도록 보완 계약을 정했습니다.
완성 보완 파일은 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만 바꾸지 않습니다. 여기서는 application/json 객체의 허용된 필드에 새 값을 지정하는 간단한 부분 갱신 형식을 계약으로 정했습니다. JSON Patch나 JSON Merge Patch 전체 규격을 구현한 것은 아닙니다. HTTP PATCH 규칙은 부분 수정과 요청 전체 적용의 원칙을 설명합니다. 인증·권한, email의 상세 형식·중복, 동시 변경의 충돌 제어, 자동 405 응답은 이 예제의 구현 범위가 아닙니다.
같은 요청의 처리 순서 확인하기
아래 비교는 요청마다 Alice·Bob 초기 상태에서 시작한다고 가정합니다. JSON 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 | truthy 필드 검사 통과 → ID=3 사용자 생성 → 201 | 필드 타입 검사 실패 → ID·목록 유지 → 400 |
보완 파일에서 마지막 오류를 정상 문자열 이름으로 고쳐 다시 POST하면 ID=3이 할당됩니다. 실패한 요청은 조회·생성·삭제 중 어느 단계까지 갔는지와, 데이터가 바뀌었는지를 함께 확인해야 합니다. 204 응답에는 본문이 없으므로 JSON 응답을 읽으려 하지 않습니다.
검증 뒤 상태를 직접 따라가기
보완 파일을 새로 시작해 ID=1·2만 있다고 가정합니다. 다음 요청을 앞 요청의 응답을 받은 뒤 순서대로 보냅니다. 각 상태 코드와 최종 사용자 ID를 예측하고, 실패한 요청에서 ID나 목록이 바뀌는지 설명하세요.
- 숫자 이름
123과 문자열 email로 POST - 문자열 이름
Carol과[email protected]으로 POST DELETE /api/users/99DELETE /api/users/1.5DELETE /api/users/1DELETE /api/users/1을 다시 요청
입력 검사·존재 확인·변경·응답을 나눈 풀이
첫 POST는 문자열 타입 검사에서 400으로 종료하며 다음 ID는 3입니다. 둘째 POST는 검사 뒤 Carol(ID=3)을 추가해 201을 반환합니다. 유효한 ID=99는 대상이 없어 404이며, 1.5는 정수가 아니므로 조회·삭제 전에 400입니다. ID=1의 첫 삭제는 Alice를 제거하고 본문 없는 204를 반환합니다. 같은 ID의 두 번째 삭제는 이미 없으므로 404입니다.
최종 목록은 Bob(ID=2)과 Carol(ID=3)이며, 다음 생성 ID는 4입니다. 입력 형식은 올바르지만 대상이 없는 경우와, 입력 검사를 통과하지 못한 경우를 같은 실패로 합치지 않습니다. 이 결론은 해당 프로세스의 순차 요청 가정에 한정됩니다.
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도 이전 [email protected]입니다.
3번은 200으로 email만 [email protected]으로 바꾸고 name은 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을 한 번 더 보내도 이 표현은 동일합니다. 독립적으로 PATCH의 빈 객체, 숫자 name, 공백뿐인 email, 알 수 없는 role 필드를 시험해 보고, 하나의 필드 실패가 다른 제공 필드의 부분 저장으로 이어지지 않는 이유를 설명하세요.
REST API를 구현할 때는 URL, 메서드, 상태 코드, JSON 모양이 같은 계약을 가리키는지 함께 점검해야 합니다.
설계가 끝났다면 자원, 행위, 응답, 테스트가 같은 API 계약을 가리키는지 마지막으로 확인합니다.
RESTful API 설계 정리
이번 정리에서는 URI 규칙과 HTTP 메서드 매핑을 실제 자원 설계 기준으로 다시 점검합니다.
이번 장에서는 프론트엔드와 백엔드 간의 효율적인 통신을 위한 표준적인 방법론인 RESTful API의 개념과 설계 원칙을 깊이 있게 학습했습니다.
이번 절에서는 API 정의, REST 원칙, URI/HTTP 메서드 설계, JSON 응답 형식을 정리했습니다.
RESTful API는 자원을 URI로 표현하고, CRUD 행위를 HTTP 메서드로 구분해 인터페이스 일관성을 유지합니다.
마지막으로 Express.js에서 사용자 자원에 대한 GET, POST, PUT, PATCH, DELETE 요청을 처리하는 CRUD 라우트 구현 흐름을 확인했습니다.
이 절에서는 클라이언트와 서버의 요청·응답 흐름, API 경로 설계, 메서드 선택, 응답 형식, 오류 처리 기준을 정리했습니다.
REST API는 URL을 리소스 명사로 정하고, HTTP 메서드와 응답 코드를 행위와 결과에 맞춰 선택합니다.