Node.js API Guide 07 — Bonus: 미션 해설로 CRUD 완성 (PATCH/DELETE) | 0xccffff LogArticleNode.js API Guide 07 — Bonus: 미션 해설로 CRUD 완성 (PATCH/DELETE)
TutorialsFeb 22, 2026Views: 5Updated: 2026-02-22Tested: Node 24 LTS, Windows 11/posts/node-api-07-bonus-crud
nodejsapiexpressCRUDrest-apibeginner
Related posts
지금까지 만든 API 서버 구조를 한 번에 정리하고, 초보자가 꼭 기억해야 할 실전 감각 3가지와 제작 비하인드를 전하며 시리즈를 마무리합니다.
검증/에러처리/간단 인증을 추가해, 초보 단계에서 실전에서 자주 막히는 지점을 한 번에 정리합니다.
TODO를 메모리 대신 todos.json 파일에 저장해, 서버를 재시작해도 데이터가 남도록 만듭니다.
GET/POST 요청으로 TODO API를 만들고, 브라우저와 PowerShell에서 JSON 응답을 확인합니다.
07 — 보너스 트랙: 미션 해설로 CRUD 완성 (PATCH/DELETE)#
이 글은 05편까지 만든 TODO API를 미션 해설 형태로 마무리하는 보너스 트랙입니다.
PATCH(수정)와 DELETE(삭제)를 붙이면, 우리가 만든 서버는 CRUD를 전부 갖추게 됩니다.
Note
CRUD는 보통 이렇게 묶어서 부릅니다.
- Create: 만들기 →
POST /todos
- Read: 읽기 →
GET /todos
- Update: 수정 →
PATCH /todos/:id
- Delete: 삭제 →
DELETE /todos/:id
Prerequisites
- 05편 완료 (검증/에러 처리/간단 인증까지 된 상태)
- 작업 폴더:
C:\Workspace\node-api
- Windows Terminal 기본 셸: PowerShell
0) 실습 준비 (이전 편과 동일)#
- VS Code →
File > Open Folder... → C:\Workspace\node-api
- VS Code →
Terminal > New Terminal
- 서버 실행:
node server.js
Expected result
정상이라면 Server running: http://localhost:3000가 출력됩니다.
1) 오늘 할 일 (2개)#
PATCH /todos/:id : 특정 TODO의 done을 수정합니다. (true/false)
DELETE /todos/:id : 특정 TODO를 삭제합니다.
2) 주소창의 숫자는 어떻게 읽을까요? → req.params#
예를 들어 이런 주소가 있다고 해봅시다.
여기서 3은 URL 안에 들어있는 값이고, Express에서는 이런 방식으로 받습니다.
app.patch("/todos/:id", (req, res) => {
// "/todos/3"으로 들어오면, req.params.id === "3"
});
:id는 “여기에 숫자가 들어올 거야”라는 자리 표시자입니다.
req.params.id는 문자열이라서, 숫자로 바꿔서 써야 합니다. (parseInt)
3) server.js에 PATCH/DELETE 조립하기#
이번 편에서 새로 추가되는 건 크게 2개입니다.
PATCH /todos/:id 라우트
DELETE /todos/:id 라우트
둘 다 “파일 저장”까지 이어져야 하니까, 마지막에 saveTodosToFile(todos)가 꼭 들어갑니다.
3-1) PATCH /todos/
추가하기 (done 수정)#
app.post("/todos", ...) 아래쪽에 붙여 넣으면 흐름이 자연스럽습니다.
// 새로 추가된 부분: PATCH /todos/:id - done 수정
app.patch("/todos/:id", requireApiKey, (req, res) => {
// 1) URL의 :id 읽기 (문자열 → 숫자)
const id = parseInt(req.params.id, 10);
if (Number.isNaN(id)) {
return fail(res, 400, "invalid_id", { example: "/todos/1" });
}
// 2) 대상 TODO 찾기
const
Note
이 코드가 하는 일 (3개만)#
/todos/3 같은 주소에서 3을 req.params.id로 받아서 숫자로 바꿉니다.
- 해당 id의 TODO를 찾아서
done을 수정합니다. (done이 없으면 토글)
- 바뀐 결과를
todos.json에 저장해서, 서버를 재시작해도 유지되게 합니다.
Warning
PATCH는 “일부만 수정”이란 뜻이라, 여기서는 done만 건드립니다.
(제목(title) 수정까지 하고 싶다면 나중에 확장하면 됩니다.)
3-2) DELETE /todos/
추가하기 (삭제)#
PATCH 아래쪽에 이어서 붙여 넣습니다.
// 새로 추가된 부분: DELETE /todos/:id - 삭제
app.delete("/todos/:id", requireApiKey, (req, res) => {
const id = parseInt(req.params.id, 10);
if (Number.isNaN(id)) {
return fail(res, 400, "invalid_id", { example: "/todos/1" });
}
const index = todos.
Note
이 코드가 하는 일 (3개만)#
/todos/:id에서 id를 읽고, 해당 TODO가 있는지 찾습니다.
splice로 배열에서 삭제합니다.
- 삭제 결과를
todos.json에 저장해서, 재시작 후에도 반영되게 합니다.
Note
최종 확인용 전체 코드 (server.js)#
헷갈리면 아래 코드로 통째로 교체해도 됩니다. (붙여넣고 Ctrl + S 저장)
server.jsconst express = require("express");
const fs = require
4) 서버 재시작 (중요)#
코드를 바꿨다면 서버를 재시작해야 적용됩니다.
- 서버가 켜진 터미널에서
Ctrl + C
- 다시 실행:
node server.js
5) 테스트 준비: TODO 하나 만들기#
PATCH/DELETE는 대상이 필요하니, 먼저 TODO를 하나 추가합니다.
(서버를 켜 둔 터미널 말고, 새 터미널을 하나 더 열어서 실행하세요.)
$headers = @{ "x-api-key" = "dev-secret" }
$body = @{ title = "PATCH/DELETE 테스트" } | ConvertTo-Json -Compress
Invoke-RestMethod -Method Post -Uri "http://localhost:3000/todos" `
-ContentType "application/json; charset=utf-8" -Headers $headers -Body $body
Expected result
응답에 id가 찍힙니다. 이제 그 id로 PATCH/DELETE를 해봅니다.
6) PATCH 테스트: done 바꾸기#
6-1) done을 true로 만들기#
$headers = @{ "x-api-key" = "dev-secret" }
$body = @{ done = $true } | ConvertTo-Json -Compress
Invoke-RestMethod -Method Patch -Uri "http://localhost:3000/todos/1" `
-ContentType "application/json; charset=utf-8" -Headers $headers -Body $body
/1 자리에 방금 만든 id를 넣으면 됩니다.
Expected result
정상이라면 item.done이 true로 바뀐 JSON이 돌아옵니다.
6-2) 다시 GET으로 확인#
브라우저에서 새로고침:
http://localhost:3000/todos
또는 PowerShell:
Invoke-RestMethod -Method Get -Uri "http://localhost:3000/todos"
7) DELETE 테스트: 삭제하기#
$headers = @{ "x-api-key" = "dev-secret" }
Invoke-RestMethod -Method Delete -Uri "http://localhost:3000/todos/1" `
-Headers $headers
Expected result
정상이라면 삭제된 TODO가 item으로 돌아옵니다.
그리고 GET /todos를 해보면 목록에서 사라져 있습니다.
8) 파일까지 확인 (진짜로 저장됐나?)#
VS Code에서 todos.json을 열어보세요.
- PATCH를 했다면
done 값이 바뀌어 있어야 하고
- DELETE를 했다면 해당 항목이 사라져 있어야 합니다.
Troubleshooting#
Troubleshooting
401 (unauthorized)가 떠요#
05편과 동일합니다.
PATCH/DELETE도 requireApiKey가 걸려 있어서, 헤더가 없으면 막힙니다.
- 헤더 이름:
x-api-key
- 값:
dev-secret
Troubleshooting
404 (todo_not_found)가 떠요#
id가 틀린 경우입니다.
GET /todos로 현재 목록을 보고
- 실제로 존재하는
id로 다시 시도하세요.
Troubleshooting
400 (invalid_id)가 떠요#
/todos/abc처럼 숫자가 아닌 값을 넣은 경우입니다.
/todos/1처럼 숫자를 넣어야 합니다.
한 번에 정리: 이제 진짜 CRUD가 됐습니다#
GET /todos : 목록 보기 (Read)
POST /todos : 추가하기 (Create)
PATCH /todos/:id : done 수정하기 (Update)
DELETE /todos/:id : 삭제하기 (Delete)
Tip
여기까지 오면, “서버는 요청을 받고 JSON을 돌려주는 프로그램”이라는 감각이 거의 고정됩니다.
이 다음은 DB를 붙이거나, 배포를 하거나, 인증을 제대로 붙이는 쪽으로 확장하면 됩니다.
todo
=
todos.
find
((
t
)
=>
t.id
===
id);
if (!todo) {
return fail(res, 404, "todo_not_found", { id });
}
// 3) done 값 적용 (true/false)
const done = req.body?.done;
// done이 없으면 토글(반전), 있으면 그 값대로 설정
if (done === undefined) {
todo.done = !todo.done;
} else if (typeof done === "boolean") {
todo.done = done;
} else {
return fail(res, 400, "done must be boolean", { example: { done: true } });
}
// 4) 파일 저장
saveTodosToFile(todos);
return res.json({ ok: true, item: todo });
});
findIndex
((
t
)
=>
t.id
===
id);
if (index === -1) {
return fail(res, 404, "todo_not_found", { id });
}
const deleted = todos.splice(index, 1)[0];
// 삭제도 저장해야 유지됩니다.
saveTodosToFile(todos);
return res.json({ ok: true, item: deleted });
});
(
"fs"
);
const path = require("path");
const app = express();
const PORT = 3000;
// JSON 본문(body) 파싱
app.use(express.json());
// (로컬 실습용) 간단 인증 키
const API_KEY = "dev-secret";
// todos.json 파일 경로 (server.js와 같은 폴더)
const DATA_PATH = path.join(__dirname, "todos.json");
function loadTodosFromFile() {
if (!fs.existsSync(DATA_PATH)) return [];
const raw = fs.readFileSync(DATA_PATH, "utf-8").trim();
if (raw.length === 0) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
throw new Error("todos.json must be an array like []");
}
return parsed;
}
function saveTodosToFile(todos) {
fs.writeFileSync(DATA_PATH, JSON.stringify(todos, null, 2), "utf-8");
}
function fail(res, status, error, extra) {
const payload = { ok: false, error };
if (extra && typeof extra === "object") {
Object.assign(payload, extra);
}
return res.status(status).json(payload);
}
function requireApiKey(req, res, next) {
const key = req.header("x-api-key");
if (key !== API_KEY) {
return fail(res, 401, "unauthorized", {
hint: 'Set header "x-api-key" to the correct value',
});
}
return next();
}
// 시작할 때 파일에서 읽어서 메모리로 올림
let todos = loadTodosFromFile();
// 저장된 TODO 중 가장 큰 id를 찾아서, 그 다음 번호부터 시작합니다.
let nextId = 1;
for (const t of todos) {
if (typeof t.id === "number" && t.id >= nextId) {
nextId = t.id + 1;
}
}
app.get("/health", (req, res) => {
res.json({ ok: true });
});
app.get("/todos", (req, res) => {
res.json({ items: todos });
});
// POST /todos - 하나 추가 (인증 필요)
app.post("/todos", requireApiKey, (req, res) => {
const title = req.body?.title;
// 검증 1) 타입/빈 값
if (typeof title !== "string" || title.trim().length === 0) {
return fail(res, 400, "title is required", {
example: { title: "물 마시기" },
});
}
// 검증 2) 길이 제한
if (title.trim().length > 100) {
return fail(res, 400, "title is too long", { max: 100 });
}
const todo = {
id: nextId++,
title: title.trim(),
done: false,
createdAt: new Date().toISOString(),
};
todos.push(todo);
saveTodosToFile(todos);
res.status(201).json({ ok: true, item: todo });
});
// 새로 추가된 부분: PATCH /todos/:id - done 수정
app.patch("/todos/:id", requireApiKey, (req, res) => {
const id = parseInt(req.params.id, 10);
if (Number.isNaN(id)) {
return fail(res, 400, "invalid_id", { example: "/todos/1" });
}
const todo = todos.find((t) => t.id === id);
if (!todo) {
return fail(res, 404, "todo_not_found", { id });
}
const done = req.body?.done;
if (done === undefined) {
todo.done = !todo.done;
} else if (typeof done === "boolean") {
todo.done = done;
} else {
return fail(res, 400, "done must be boolean", { example: { done: true } });
}
saveTodosToFile(todos);
return res.json({ ok: true, item: todo });
});
// 새로 추가된 부분: DELETE /todos/:id - 삭제
app.delete("/todos/:id", requireApiKey, (req, res) => {
const id = parseInt(req.params.id, 10);
if (Number.isNaN(id)) {
return fail(res, 400, "invalid_id", { example: "/todos/1" });
}
const index = todos.findIndex((t) => t.id === id);
if (index === -1) {
return fail(res, 404, "todo_not_found", { id });
}
const deleted = todos.splice(index, 1)[0];
saveTodosToFile(todos);
return res.json({ ok: true, item: deleted });
});
// (연습용) 강제로 에러를 내는 엔드포인트
app.get("/debug/error", (req, res) => {
throw new Error("debug error");
});
// 404: 없는 주소는 여기로 떨어짐
app.use((req, res) => {
return fail(res, 404, "not_found", { path: req.path });
});
// 500: 서버에서 예외가 터졌을 때
app.use((err, req, res, next) => {
console.error(err);
// JSON 파싱이 깨진 경우(잘못된 JSON)
if (err instanceof SyntaxError) {
return fail(res, 400, "invalid_json");
}
return fail(res, 500, "internal_error");
});
app.listen(PORT, () => {
console.log(`Server running: http://localhost:${PORT}`);
});