25편을 끝낸 사람의 다음 — "이 조각들을 어떻게 한 서비스로 합치지?". 마지막 챕터는 그 답. 인증 있는 Todo API 서버를 처음부터 끝까지 만들면서 Node 시리즈 모든 개념이 한 자리에서 만난다.
완성 결과 — 사용자가 회원가입·로그인하고, 본인 todo 를 생성·조회·수정·삭제할 수 있고, JWT 로 인증하며, Docker 컨테이너로 배포된 진짜 API 서버. 프론트엔드는 Next.js 26편의 블로그가 그대로 연결 가능.
1. 스펙과 기술 매핑
| 기능 | 기술 | 참조 챕터 |
| 회원가입·로그인 | bcrypt + jsonwebtoken | 19 |
| CRUD 라우트 | Express + Router | 13·14 |
| 입력 검증 | Zod | 15 |
| DB | node-postgres (pg) | 18 |
| 인증 가드 | requireAuth 미들웨어 | 14·19 |
| 에러 처리 | AppError + 미들웨어 | 16 |
| 로깅 | winston·morgan·요청 ID | 21 |
| 보안 | helmet·cors·rate-limit | 24 |
| 테스트 | Jest + supertest | 22 |
| 배포 | Docker + docker-compose | 25 |
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편 완결 🎉
풀스택 한 묶음. 본인 사이드 프로젝트 시작 시점. 화이팅.