Menu
CoddyTech

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 오류가 됩니다.

challenge icon

챌린지

쉬움

이 단계에서는 Validating Input에서 만든 프로젝트 파일(routes/tasks.js 포함)이 아니라, 강의에서 준비한 작업 추적기 API 프로젝트를 엽니다. 이전에 작업한 내용은 해당 레슨에 그대로 남아 있습니다. routes/tasks.js를 수정하기 전에 제공된 파일을 읽어 보세요. 이 사본에는 작업 CRUD 경로와 직접 작성한 입력 검증 코드가 들어 있습니다.

직접 작성한 검사는 필드와 경로가 늘어날 때마다 함께 늘어납니다. zod 스키마로 작업을 한 번 정의하고, 본문을 받는 세 경로에서 모두 사용하세요.

  1. routes/tasks.js에서 z를 zod에서 가져오고, Task라는 스키마를 작성하세요.
    • title: 문자열이며, 앞뒤 공백을 제거한 뒤 길이가 1~100자여야 합니다.
    • done: true 또는 false여야 합니다(z.boolean()).
    • 스키마는 엄격하므로, 여기에 나열하지 않은 color 같은 필드는 거부됩니다.

    제공된 titleError 도우미(Validating Input에서 사용한 검증 코드의 강의 버전)를 삭제하세요.

  2. Task에서 .partial()을 사용해 두 가지 버전을 만드세요. POST 버전에서는 done만 선택 사항으로 만듭니다. PATCH 버전에서는 두 필드를 모두 선택 사항으로 만듭니다. PUT은 Task 자체를 사용하므로 두 필드가 모두 필요합니다.
  3. 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"] } } }
  4. 검사에 통과하면 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 플레이그라운드