Express 로 라우트는 만들 줄 안다. 그런데 어떤 경로명을 쓰고, 어떤 status 를 돌려주고, 어떤 응답 모양이 표준인가 — 회사·팀마다 달라서 일관성이 안 나오면 프론트엔드 개발자가 매번 묻는다. 답이 REST.
REST 는 거창한 이론이지만 실전에선 5~6가지 규약만 지키면 90% 의 가치가 나온다. 이번 편이 그 핵심.
1. 리소스 명사 + HTTP 메서드 동사
REST 의 첫 번째 원칙. URL 은 명사, 동사는 HTTP 메서드.
| 의도 | RESTful | 안티패턴 |
| 글 목록 | GET /posts | GET /getAllPosts |
| 글 한 개 | GET /posts/:id | GET /getPostById |
| 새 글 | POST /posts | POST /createPost |
| 글 수정 | PUT /posts/:id | POST /updatePost |
| 글 삭제 | DELETE /posts/:id | POST /deletePost |
| 댓글 목록 | GET /posts/:id/comments | GET /getCommentsOfPost |
중첩 리소스도 같은 규칙 — /posts/:id/comments. 복수형을 일관되게 (/post X, /posts O). 동작은 URL 에 적지 말고 메서드로.
2. status 코드 — 5가지면 충분
HTTP status 가 70여 개 있지만 일상에선 6~7 개만 쓴다.
| 코드 | 의미 | 언제 |
| 200 OK | 성공 | GET·PUT·PATCH 성공 |
| 201 Created | 생성됨 | POST 성공 (새 리소스 생성) |
| 204 No Content | 성공, 본문 없음 | DELETE 성공 |
| 400 Bad Request | 입력 잘못 | 검증 실패 |
| 401 Unauthorized | 로그인 필요 | 인증 토큰 없음 |
| 403 Forbidden | 권한 없음 | 로그인은 됐지만 접근 불가 |
| 404 Not Found | 없음 | 리소스 없음 |
| 500 Internal | 서버 에러 | 예상 못한 예외 |
가장 자주 헷갈리는 게 401 vs 403. 401 = "당신 누구?" (인증 안 됨), 403 = "당신은 알지만 권한 없음" (인증은 됐는데 권한 부족).
3. 표준 응답 모양 — 일관성
응답 JSON 의 모양이 매번 다르면 프론트가 죽는다. 한 프로젝트 안에선 무조건 통일.
// 단일 리소스
{ "id": "abc", "title": "안녕", "body": "..." }
// 목록 (페이지네이션 포함)
{
"data": [...],
"page": 1,
"perPage": 20,
"total": 137,
"hasNext": true
}
// 에러
{
"error": {
"code": "VALIDATION_FAILED",
"message": "제목은 2자 이상",
"details": { "title": ["too short"] }
}
}
특히 에러 모양은 강력히 규약화 — 클라이언트가 err.error.code 만 검사하면 분기 가능. 한글 메시지는 message, 프로그램 분기용 키는 code (영문 enum).
4. 페이지네이션·필터·정렬 — 쿼리 표준
목록 API 의 3대 친구. 쿼리 파라미터로.
// 클라이언트 호출
GET /posts?page=2&perPage=20&author=jspark&sort=-createdAt
// 서버
router.get('/posts', async (req, res) => {
const page = Math.max(1, Number(req.query.page) || 1);
const perPage = Math.min(50, Number(req.query.perPage) || 20);
const skip = (page - 1) * perPage;
const where = {};
if (req.query.author) where.author = req.query.author;
// sort: 앞에 - 면 desc, 아니면 asc
const sortKey = (req.query.sort || '-createdAt').replace(/^-/, '');
const sortDir = String(req.query.sort).startsWith('-') ? -1 : 1;
const [items, total] = await Promise.all([
db.posts.find(where).sort({ [sortKey]: sortDir }).skip(skip).limit(perPage),
db.posts.count(where),
]);
res.json({ data: items, page, perPage, total, hasNext: skip + items.length < total });
});
관례 — page·perPage (offset 페이지네이션) 또는 cursor (커서 기반, 큰 데이터셋). 정렬 prefix - 는 GitHub/Stripe 가 쓰는 표준 패턴.
perPage 상한 필수 — ?perPage=1000000 으로 DB 와 메모리를 무릎 꿇리는 공격이 흔하다. 반드시 Math.min(50, ...) 같은 상한. 안 그러면 DoS.
5. 완전한 CRUD 예 — posts 리소스
// routes/posts.js
import { Router } from 'express';
import { z } from 'zod';
const router = Router();
const PostSchema = z.object({
title: z.string().min(2).max(100),
body: z.string().min(10),
});
// GET /posts — 목록
router.get('/', async (req, res) => {
const posts = await db.posts.findMany();
res.json({ data: posts });
});
// GET /posts/:id — 단일
router.get('/:id', async (req, res) => {
const post = await db.posts.findUnique({ where: { id: req.params.id } });
if (!post) return res.status(404).json({ error: { code: 'NOT_FOUND' } });
res.json(post);
});
// POST /posts — 생성
router.post('/', requireAuth, async (req, res) => {
const parsed = PostSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
error: { code: 'VALIDATION_FAILED', details: parsed.error.flatten() },
});
}
const post = await db.posts.create({ data: { ...parsed.data, authorId: req.user.id } });
res.status(201).json(post);
});
// PUT /posts/:id — 수정
router.put('/:id', requireAuth, requireOwner, async (req, res) => {
const parsed = PostSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const post = await db.posts.update({ where: { id: req.params.id }, data: parsed.data });
res.json(post);
});
// DELETE /posts/:id — 삭제
router.delete('/:id', requireAuth, requireOwner, async (req, res) => {
await db.posts.delete({ where: { id: req.params.id } });
res.status(204).end();
});
export default router;
이 한 파일이 표준 REST 리소스의 모범. 검증(Zod)·인증(requireAuth)·소유권 확인(requireOwner)·적절한 status 코드·일관된 응답 모양 다섯이 모두 들어있다. 14편의 미들웨어 패턴 응용.
요약 — 15편 좌표
여기까지 정리. REST 의 5규칙 — URL 은 명사·메서드는 동사, 복수형 일관, 중첩 리소스 같은 패턴, 표준 status 6~7개, 응답 모양 통일(data·error). 페이지네이션·필터·정렬은 쿼리 파라미터 + 상한 강제. CRUD 한 리소스에 검증·인증·소유권 확인이 미들웨어로 끼인다. 다음 편에서 진짜 에러 처리 — try/catch 와 Express 에러 미들웨어.
다음 편 예고 — 에러 처리
try/catch, Express 에러 미들웨어, 사용자 친화적 메시지. 16편.