페이지네이션
Coddy Node.js 여정의 REST API 섹션에 포함된 레슨. 25개 중 17번째.
작업이 1만 개나 되는 목록은 한 번의 응답에 담기에는 너무 깁니다. 페이지 단위로 전송하세요. 클라이언트가 ?page=2&limit=20을 요청하면 해당 부분과 함께 "5페이지 중 2페이지"를 표시하는 데 필요한 정보도 받습니다.
{ "items": [ ...books 21 to 40... ], "page": 2, "total": 95, "pages": 5 }const start = (page - 1) * limit; // page 1은 0에서 시작합니다
const items = list.slice(start, start + limit);
const pages = Math.ceil(list.length / limit);클라이언트가 요청한 목록의 개수를 나타내므로 필터를 적용한 뒤 total을 세세요. 쿼리 문자열에 page가 없으면 변환하기 전에 기본값으로 1을 사용하고, limit에는 10을 사용하세요. Number(undefined)는 NaN이기 때문입니다. 그런 다음 범위 내의 정수가 아닌 값은 모두 400 오류로 거부하세요. limit에 최댓값을 설정하면 클라이언트가 한 번에 모든 항목을 요청하는 것을 막을 수 있습니다.
챌린지
쉬움작업이 만 개나 되는 목록을 한 번의 응답으로 보내기에는 너무 깁니다. 목록을 페이지별로 보내 클라이언트가 "Page 1 of 2"를 표시할 수 있도록 하세요.
routes/tasks.js에서 목록 경로가 반환하는 값을 배열 대신 객체로 바꾸세요. 시작 작업을 기준으로GET /api/tasks?limit=2를 요청하면 다음을 반환합니다:{ "items": [ { "id": 1, "title": "Write the project brief", "done": true, "createdAt": "2026-03-02T09:00:00.000Z" }, { "id": 2, "title": "Book the meeting room", "done": false, "createdAt": "2026-03-03T10:30:00.000Z" } ], "page": 1, "total": 3, "pages": 2 }items에는 현재 페이지의 작업이 들어가고,page는 페이지 번호이며,total은 필터에 일치하는 작업의 수이고,pages는 전체 페이지 수입니다.- 쿼리 문자열에서
page와limit을 읽으세요:page는 1 이상의 정수입니다. 지정하지 않으면 1을 사용하세요.limit는 1에서 50 사이의 정수입니다. 지정하지 않으면 10을 사용하세요.
변환하기 전에 기본값을 사용하세요.
Number(undefined)는NaN이기 때문입니다. 다른 값(page=0,page=abc,page=1.5,limit=0,limit=51)을 지정하면 상태 코드 400을 반환합니다. limit 값이 잘못된 경우 메시지는limit must be a whole number from 1 to 50입니다:{ "error": { "code": "invalid_query", "message": "page must be a whole number from 1" } } - 마지막에 페이지네이션을 적용하세요. 먼저 필터링하고 정렬한 다음 페이지에 해당하는 항목을 추출하세요.
total은 필터링 후 남은 작업 수를 집계하므로,?done=false&limit=1을 지정하면"total": 2와"pages": 2가 반환됩니다.
이렇게 하면 응답의 구조가 바뀌므로, 배열을 예상하던 클라이언트도 변경해야 합니다. 버전 관리 강의에서 이를 처리하는 방법을 살펴봅니다.
직접 해보기: GET /api/tasks?limit=2는 작업 1과 2를 반환하며, "page": 1, "total": 3, "pages": 2를 포함합니다. GET /api/tasks?page=2&limit=2는 작업 3만 반환합니다. 매개변수 없이 GET /api/tasks를 요청하면 작업 3개 모두를 반환하며, "page": 1과 "pages": 1을 포함합니다. GET /api/tasks?page=0은 코드 invalid_query와 함께 상태 코드 400을 반환합니다.
직접 해보기
import { Router } from 'express';
import { z } from 'zod';
import { sendError, sendInvalid } from '../errors.js';
import { nextId, tasks } from '../store.js';
const router = Router();
// PUT이 전송하는 완전한 task입니다. .strict()는 명시되지 않은 필드를 허용하지 않습니다.
const Task = z
.object({
title: z.string().trim().min(1).max(100),
done: z.boolean(),
})
.strict();
const NewTask = Task.partial({ done: true });
const TaskChanges = Task.partial();
const SORT_FIELDS = ['title', 'createdAt'];
// 대소문자에 관계없이 다른 task가 이미 이 title을 가지고 있으면 True입니다.
const titleTaken = (title, exceptId) =>
tasks.some((t) => t.id !== exceptId && t.title.toLowerCase() === title.toLowerCase());
router.get('/', (req, res) => {
const { done, q, sort } = req.query;
let list = tasks;
if (done !== undefined) {
if (done !== 'true' && done !== 'false') {
return sendError(res, 400, 'invalid_query', 'done must be true or false');
}
list = list.filter((t) => t.done === (done === 'true'));
}
if (q !== undefined) {
const words = String(q).toLowerCase();
list = list.filter((t) => t.title.toLowerCase().includes(words));
}
if (sort !== undefined) {
const field = String(sort).startsWith('-') ? String(sort).slice(1) : String(sort);
if (!SORT_FIELDS.includes(field)) {
return sendError(
res,
400,
'invalid_query',
'sort must be title or createdAt, with - in front to reverse',
);
}
const direction = String(sort).startsWith('-') ? -1 : 1;
list = list.toSorted((a, b) => a[field].localeCompare(b[field]) * direction);
}
res.json(list);
});
router.get('/:id', (req, res) => {
const task = tasks.find((t) => t.id === Number(req.params.id));
if (!task) {
return sendError(res, 404, 'not_found', 'Task not found');
}
res.json(task);
});
router.post('/', (req, res) => {
const result = NewTask.safeParse(req.body);
if (!result.success) {
return sendInvalid(res, result.error);
}
const { title, done = false } = result.data;
if (titleTaken(title)) {
return sendError(res, 409, 'conflict', 'A task with this title already exists');
}
const task = { id: nextId(), title, done, 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 sendError(res, 404, 'not_found', 'Task not found');
}
const result = TaskChanges.safeParse(req.body);
if (!result.success) {
return sendInvalid(res, result.error);
}
if (result.data.title !== undefined && titleTaken(result.data.title, task.id)) {
return sendError(res, 409, 'conflict', 'A task with this title already exists');
}
Object.assign(task, result.data);
res.json(task);
});
router.put('/:id', (req, res) => {
const index = tasks.findIndex((t) => t.id === Number(req.params.id));
if (index === -1) {
return sendError(res, 404, 'not_found', 'Task not found');
}
const result = Task.safeParse(req.body);
if (!result.success) {
return sendInvalid(res, result.error);
}
const { id, createdAt } = tasks[index];
if (titleTaken(result.data.title, id)) {
return sendError(res, 409, 'conflict', 'A task with this title already exists');
}
tasks[index] = { id, ...result.data, 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 sendError(res, 404, 'not_found', 'Task not found');
}
tasks.splice(index, 1);
res.status(204).end();
});
export default router;
이 레슨에는 짧은 퀴즈가 포함되어 있습니다. 레슨을 시작해 문제를 풀고 진행 상황을 기록하세요.
REST API의 모든 레슨
직접 연습해 보세요: Express 플레이그라운드