Node.js 교재 · 26편 · 완결

실전 미니 프로젝트 — Todo API 서버 완결

25편의 모든 개념이 한 프로젝트에서. 진짜 회사에서 쓸 만한 백엔드 한 세트.

Todo API 서버 완성 컨셉 일러스트

25편을 끝낸 사람의 다음 — "이 조각들을 어떻게 한 서비스로 합치지?". 마지막 챕터는 그 답. 인증 있는 Todo API 서버를 처음부터 끝까지 만들면서 Node 시리즈 모든 개념이 한 자리에서 만난다.

완성 결과 — 사용자가 회원가입·로그인하고, 본인 todo 를 생성·조회·수정·삭제할 수 있고, JWT 로 인증하며, Docker 컨테이너로 배포된 진짜 API 서버. 프론트엔드는 Next.js 26편의 블로그가 그대로 연결 가능.

1. 스펙과 기술 매핑

기능기술참조 챕터
회원가입·로그인bcrypt + jsonwebtoken19
CRUD 라우트Express + Router13·14
입력 검증Zod15
DBnode-postgres (pg)18
인증 가드requireAuth 미들웨어14·19
에러 처리AppError + 미들웨어16
로깅winston·morgan·요청 ID21
보안helmet·cors·rate-limit24
테스트Jest + supertest22
배포Docker + docker-compose25

2. 디렉토리 구조

todo-api/ ├── src/ │ ├── server.js ← 엔트리, graceful shutdown │ ├── app.js ← createApp() — 테스트와 공유 │ ├── env.js ← Zod 환경변수 검증 │ ├── db.js ← pg Pool │ ├── logger.js ← winston │ ├── security.js ← helmet+cors+rate-limit 묶음 │ ├── errors.js ← AppError·NotFound·Validation │ ├── middleware/ │ │ ├── requestId.js │ │ ├── requireAuth.js │ │ └── errorHandler.js │ └── routes/ │ ├── auth.js ← /auth/signup, /auth/login │ └── todos.js ← /todos CRUD ├── tests/ │ ├── auth.test.js │ └── todos.test.js ├── Dockerfile ├── docker-compose.yml ├── .env.example ├── .env.local ← .gitignore ├── package.json └── README.md

한 폴더 안에 모든 게 있되 책임 별로 파일 분리. Express 패턴의 표준 구조.

3. 핵심 파일 미리보기

src/app.js — 모든 미들웨어 + 라우트 조립

import express from 'express'; import { applySecurity } from './security.js'; import { requestId } from './middleware/requestId.js'; import { errorHandler } from './middleware/errorHandler.js'; import authRouter from './routes/auth.js'; import todosRouter from './routes/todos.js'; import morgan from 'morgan'; import { logger } from './logger.js'; export function createApp() { const app = express(); applySecurity(app); // helmet·cors·rate-limit (24) app.use(express.json({ limit: '10kb' })); app.use(requestId); // 21 app.use(morgan('combined', { stream: { write: m => logger.http(m.trim()) } })); app.get('/health', (req, res) => res.json({ ok: true })); app.use('/auth', authRouter); app.use('/todos', todosRouter); app.use(errorHandler); // 16 return app; }

src/routes/todos.js — CRUD 한 묶음

import { Router } from 'express'; import { z } from 'zod'; import { pool } from '../db.js'; import { requireAuth } from '../middleware/requireAuth.js'; import { NotFoundError } from '../errors.js'; const router = Router(); router.use(requireAuth); // 모든 라우트 인증 const TodoSchema = z.object({ title: z.string().min(1).max(200), done: z.boolean().optional(), }); router.get('/', async (req, res) => { const { rows } = await pool.query( 'SELECT id, title, done, created_at FROM todos WHERE user_id = $1 ORDER BY created_at DESC', [req.user.id] ); res.json({ data: rows }); }); router.post('/', async (req, res) => { const data = TodoSchema.parse(req.body); const { rows: [todo] } = await pool.query( 'INSERT INTO todos (user_id, title) VALUES ($1, $2) RETURNING *', [req.user.id, data.title] ); res.status(201).json(todo); }); router.patch('/:id', async (req, res) => { const data = TodoSchema.partial().parse(req.body); const { rows: [todo] } = await pool.query( 'UPDATE todos SET title = COALESCE($1,title), done = COALESCE($2,done), updated_at = NOW() WHERE id = $3 AND user_id = $4 RETURNING *', [data.title, data.done, req.params.id, req.user.id] ); if (!todo) throw new NotFoundError('todo'); res.json(todo); }); router.delete('/:id', async (req, res) => { const { rowCount } = await pool.query( 'DELETE FROM todos WHERE id = $1 AND user_id = $2', [req.params.id, req.user.id] ); if (rowCount === 0) throw new NotFoundError('todo'); res.status(204).end(); }); export default router;

한 파일에 — 15편 REST 규약, 14편 미들웨어, 16편 에러, 18편 pg 바인딩, 19편 인증이 다 보인다.

tests/todos.test.js — supertest 통합 테스트

import { describe, it, expect, beforeEach } from '@jest/globals'; import request from 'supertest'; import { createApp } from '../src/app.js'; import { signAccessToken } from '../src/auth/tokens.js'; const app = createApp(); const token = signAccessToken('test-user-id'); const auth = `Bearer ${token}`; describe('POST /todos', () => { it('201 with valid input', async () => { const res = await request(app) .post('/todos') .set('Authorization', auth) .send({ title: '청소' }); expect(res.status).toBe(201); expect(res.body.title).toBe('청소'); }); it('400 when title empty', async () => { const res = await request(app) .post('/todos') .set('Authorization', auth) .send({ title: '' }); expect(res.status).toBe(400); }); it('401 when no token', async () => { const res = await request(app).post('/todos').send({ title: 'x' }); expect(res.status).toBe(401); }); });

22편 패턴 그대로. 매 PR 마다 자동 회귀 검사.

docker-compose.yml — 풀스택 한 줄

services: api: build: . ports: ['3000:3000'] env_file: .env.local depends_on: [db] restart: unless-stopped db: image: postgres:17-alpine environment: POSTGRES_PASSWORD: ${DB_PASSWORD} POSTGRES_DB: todoapi volumes: - dbdata:/var/lib/postgresql/data ports: ['5432:5432'] volumes: dbdata:
$ docker compose up -d $ curl -X POST http://localhost:3000/auth/signup \ -H "Content-Type: application/json" \ -d '{"email":"[email protected]","password":"password123"}'

한 명령으로 API + DB 가 같이 라이브. 25편 docker-compose 의 마지막 적용.

4. 다음 단계 — 어디로 갈지

이 Todo API 위에 얹을 만한 것들 — 학습 여정의 연료.

  • 프론트엔드 연결 — Next.js 26편의 블로그에서 이 API 호출. CORS·인증 흐름 진짜 경험.
  • Redis 캐시 — 자주 조회되는 todo 목록을 Redis 에. 응답 속도 5배.
  • BullMQ 큐 — 회원가입 환영 메일을 큐로 위임. 23편 worker_threads 의 진화판.
  • OpenAPI/Swagger — API 문서 자동 생성. 모바일·외부 팀과 협업 시 필수.
  • Kubernetes — 25편 PM2/Docker 의 다음. 대규모 운영의 표준.
26편을 끝낸 당신 — Node 의 단일 스레드 모델, Express 미들웨어 패턴, REST API 설계, PostgreSQL CRUD, JWT 인증, 보안 4종, 테스트·로깅, Docker 배포까지 — 회사 신입~주니어 백엔드 표준 역량 완비. 1년차 백엔드 면접에서 막힘 없이 답할 정도. 다음은 실제 서비스를 운영하면서 부족함을 메우는 단계.

마치며

52편 (Next.js 26 + Node.js 26) 풀스택 시리즈가 여기서 마무리. 끝까지 따라온 모두에게 진심으로 박수. 책으로 안 가는 길은 코드로만 익혀진다. 오늘 GitHub 에 Todo API 한 저장소 만들고 docker compose up 부터 누르길.

Node.js + Next.js 시리즈 52편 완결 🎉

풀스택 한 묶음. 본인 사이드 프로젝트 시작 시점. 화이팅.

© 2026 주나이테크(주) @JUNAITECH