최종 확인용 전체 코드#
위 내용을 모두 합친 server.js 전체입니다.
헷갈리면 아래 코드로 통째로 교체하고 다음 단계로 넘어가도 됩니다.
server.jsconst express = require("express");
const fs = require("fs");
const path = require("path");
const app = express();
const PORT = 3000;
// JSON 본문(body) 파싱
app.use(express.json());
/**
* (로컬 실습용) 간단 인증 키
* - POST /todos는 이 키가 없으면 거절(401)됩니다.
* - 실전에서는 이렇게 하드코딩하지 않습니다. 지금은 흐름만 익히는 목적입니다.
*/
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 [];
// 1) 파일을 텍스트로 읽기
const raw = fs.readFileSync(DATA_PATH, "utf-8").trim();
// 파일이 비어있으면 빈 배열로 처리
if (raw.length === 0) return [];
// 2) 텍스트(JSON)를 JS 값(배열)로 변환
const parsed = JSON.parse(raw);
// 최소한의 형태 체크
if (!Array.isArray(parsed)) {
throw new Error("todos.json must be an array like []");
}
return parsed;
}
function saveTodosToFile(todos) {
// JS 값(배열)을 JSON 텍스트로 바꿔서 파일에 쓰기
fs.writeFileSync(DATA_PATH, JSON.stringify(todos, null, 2), "utf-8");
}
/**
* 실패 응답을 한 형태로 만들기
* - { ok: false, error: "...", ... }
*/
function fail(res, status, error, extra) {
const payload = { ok: false, error };
// extra는 선택: 있으면 응답에 같이 섞어서 보냅니다.
if (extra && typeof extra === "object") {
Object.assign(payload, extra);
}
return res.status(status).json(payload);
}
/**
* 간단 인증 미들웨어
* - 요청 헤더에 x-api-key가 있어야 합니다.
*/
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 });
});
/**
* (연습용) 강제로 에러를 내는 엔드포인트
* - 일부러 에러를 내서 500 응답이 어떻게 나오는지 확인합니다.
* - 실전 배포에서는 제거해야 합니다.
*/
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}`);
});