zod로 스키마 만들기
Coddy Node.js 여정의 REST API 섹션에 포함된 레슨. 25개 중 11번째.
각 필드와 각 경로가 늘어날 때마다 수동 검사는 많아집니다. zod 스키마는 형태를 한 번 정의하고, safeParse는 값이 그 형태에 맞는지 검사합니다:
import { z } from 'zod';
const Event = z.object({
name: z.string().trim().min(1).max(80),
seats: z.number().int().min(1),
}).strict();
const result = Event.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ error: z.flattenError(result.error) });
}
// result.data에는 name의 앞뒤 공백이 제거된, 검증된 body가 들어 있습니다.strict()는 스키마에 나열되지 않은 필드를 거부합니다. Event.partial()은 모든 필드를 선택 사항으로 만드는데, 이는 PATCH에 필요한 동작입니다. Event.partial({ seats: true })는 seats만 선택 사항으로 만들고, const { seats = 1 } = result.data는 해당 값이 없을 때 1을 사용합니다. z.flattenError(result.error)는 문제를 { formErrors, fieldErrors }로 변환합니다. formErrors에는 알 수 없는 필드처럼 본문 전체와 관련된 문제가 담기고, fieldErrors에는 각 필드의 문제가 담깁니다. Event.parse도 같은 검사를 수행하지만 값이 잘못되면 예외를 발생시키며, 경로에서 예외가 발생하면 500 오류가 됩니다.
챌린지
쉬움이 단계에서는 Validating Input에서 만든 프로젝트 파일(routes/tasks.js 포함)이 아니라, 강의에서 준비한 작업 추적기 API 프로젝트를 엽니다. 이전에 작업한 내용은 해당 레슨에 그대로 남아 있습니다. routes/tasks.js를 수정하기 전에 제공된 파일을 읽어 보세요. 이 사본에는 작업 CRUD 경로와 직접 작성한 입력 검증 코드가 들어 있습니다.
직접 작성한 검사는 필드와 경로가 늘어날 때마다 함께 늘어납니다. zod 스키마로 작업을 한 번 정의하고, 본문을 받는 세 경로에서 모두 사용하세요.
routes/tasks.js에서z를zod에서 가져오고,Task라는 스키마를 작성하세요.title: 문자열이며, 앞뒤 공백을 제거한 뒤 길이가 1~100자여야 합니다.done:true또는false여야 합니다(z.boolean()).- 스키마는 엄격하므로, 여기에 나열하지 않은
color같은 필드는 거부됩니다.
제공된
titleError도우미(Validating Input에서 사용한 검증 코드의 강의 버전)를 삭제하세요.Task에서.partial()을 사용해 두 가지 버전을 만드세요. POST 버전에서는done만 선택 사항으로 만듭니다. PATCH 버전에서는 두 필드를 모두 선택 사항으로 만듭니다. PUT은Task자체를 사용하므로 두 필드가 모두 필요합니다.- POST, PATCH, PUT에서
safeParse로req.body를 검사하세요. 검사에 실패하면 상태 코드 400으로 응답하고,z.flattenError(result.error)로 만든 zod의 문제 목록을error아래에 넣으세요.{ "title": 123 }의 경우 결과는 다음과 같습니다.{ "error": { "formErrors": [], "fieldErrors": { "title": ["Invalid input: expected string, received number"] } } } - 검사에 통과하면
req.body가 아니라result.data에서 저장하세요.result.data에는 공백이 제거된 제목이 들어 있습니다. POST에서는done이 없는 본문으로 작업을 만들 때"done": false가 됩니다(누락된 필드는result.data에서undefined이며,??가 대체값을 제공합니다). PUT은 저장된 작업의id와createdAt을 그대로 유지합니다.
직접 해 보세요: { "title": " Order new chairs ", "color": "red" }를 /api/tasks로 POST하세요. 400 응답을 받고, formErrors에 Unrecognized key: "color"가 표시됩니다. color 없이 다시 보내면 "title": "Order new chairs"(공백 없음)와 "done": false가 포함된 201 응답을 받습니다. { "done": "yes" }를 사용해 PATCH /api/tasks/2를 요청하면 fieldErrors에 done 항목이 포함된 400 응답이 반환됩니다. { "title": "Book the big room", "done": false }를 사용해 PUT /api/tasks/2를 요청하면 작업 2의 기존 createdAt이 유지된 200 응답이 반환됩니다.
직접 해보기
import { Router } from 'express';
import { nextId, tasks } from '../store.js';
const router = Router();
// title을 저장할 수 없는 이유를 반환하고, 저장할 수 있으면 null을 반환합니다.
const titleError = (title) => {
if (typeof title !== 'string' || title.trim() === '' || title.length > 100) {
return 'title must be a string of 1 to 100 characters';
}
return null;
};
router.get('/', (req, res) => {
res.json(tasks);
});
router.get('/:id', (req, res) => {
const task = tasks.find((t) => t.id === Number(req.params.id));
if (!task) {
return res.status(404).json({ error: 'Task not found' });
}
res.json(task);
});
router.post('/', (req, res) => {
const { title } = req.body ?? {};
const problem = titleError(title);
if (problem) {
return res.status(400).json({ error: problem });
}
const task = { id: nextId(), title, done: false, createdAt: new Date().toISOString() };
tasks.push(task);
res.status(201).location(`/api/tasks/${task.id}`).json(task);
});
router.patch('/:id', (req, res) => {
const task = tasks.find((t) => t.id === Number(req.params.id));
if (!task) {
return res.status(404).json({ error: 'Task not found' });
}
const { title, done } = req.body ?? {};
if (title !== undefined && titleError(title)) {
return res.status(400).json({ error: titleError(title) });
}
if (done !== undefined && typeof done !== 'boolean') {
return res.status(400).json({ error: 'done must be true or false' });
}
if (title !== undefined) task.title = title;
if (done !== undefined) task.done = done;
res.json(task);
});
router.put('/:id', (req, res) => {
const index = tasks.findIndex((t) => t.id === Number(req.params.id));
if (index === -1) {
return res.status(404).json({ error: 'Task not found' });
}
const { title, done } = req.body ?? {};
if (title === undefined || done === undefined) {
return res.status(400).json({ error: 'PUT needs every field: title and done' });
}
const { id, createdAt } = tasks[index];
tasks[index] = { id, title, done, createdAt };
res.json(tasks[index]);
});
router.delete('/:id', (req, res) => {
const index = tasks.findIndex((t) => t.id === Number(req.params.id));
if (index === -1) {
return res.status(404).json({ error: 'Task not found' });
}
tasks.splice(index, 1);
res.status(204).end();
});
export default router;
REST API의 모든 레슨
직접 연습해 보세요: Express 플레이그라운드