Skip to main content

🔧 REST API 서버 만들기

📖 정의

REST API는 HTTP 프로토콜을 사용하여 클라이언트와 서버가 데이터를 주고받는 웹 서비스 인터페이스입니다. Express.js는 Node.js 기반의 경량 웹 프레임워크로, REST API를 빠르고 쉽게 구축할 수 있게 해줍니다. CRUD 작업(생성, 조회, 수정, 삭제)을 HTTP 메서드(GET, POST, PUT, DELETE)로 구현합니다.

🎯 비유로 이해하기

레스토랑 시스템

REST API를 레스토랑에 비유하면:

레스토랑 = API 서버
├─ 웨이터 = API 엔드포인트
├─ 메뉴판 = API 문서
├─ 주문서 = HTTP 요청
└─ 요리 = HTTP 응답

손님(클라이언트)의 주문:
GET /menu → 메뉴판 보여주세요 (조회)
POST /orders → 주문할게요 (생성)
GET /orders/123 → 제 주문 어디까지 왔나요? (조회)
PUT /orders/123 → 주문 변경할게요 (수정)
DELETE /orders/123 → 주문 취소할게요 (삭제)

웨이터는 정해진 규칙대로만 응답!

도서관 시스템

도서관 = REST API
├─ 사서 = 서버
├─ 도서 목록 = 데이터베이스
└─ 대출증 = 인증 토큰

REST 원칙:
1. 무상태(Stateless)
└─ 사서는 이전 대화를 기억 안 함
└─ 매번 대출증(토큰)을 보여줘야 함

2. 자원 기반(Resource-based)
└─ /books/123 = 도서 번호 123번
└─ /users/456 = 회원 번호 456번

3. HTTP 메서드로 행동 표현
└─ GET = 책 찾기
└─ POST = 신규 등록
└─ PUT = 정보 수정
└─ DELETE = 폐기

⚙️ 작동 원리

1. HTTP 메서드와 CRUD

CRUD 작업 → HTTP 메서드

CREATE (생성)
└─ POST /api/users
Body: { "name": "김철수", "email": "kim@example.com" }

READ (조회)
├─ GET /api/users (전체 목록)
└─ GET /api/users/123 (특정 항목)

UPDATE (수정)
├─ PUT /api/users/123 (전체 수정)
└─ PATCH /api/users/123 (부분 수정)
Body: { "name": "김영희" }

DELETE (삭제)
└─ DELETE /api/users/123

2. 요청-응답 흐름

클라이언트                     서버
| |
| GET /api/users/123 |
|--------------------------->|
| | 1. 라우터 매칭
| | 2. 미들웨어 실행
| | 3. 컨트롤러 로직
| | 4. DB 조회
| | 5. 응답 생성
| |
| 200 OK |
| { id: 123, name: "김철수" }|
|<---------------------------|
| |

3. REST API 설계 규칙

URL 구조:
✅ /api/users (복수형 명사)
✅ /api/users/123 (ID로 특정)
✅ /api/users/123/posts (관계)
❌ /api/getUser (동사 사용 X)
❌ /api/user/delete (동사 사용 X)

HTTP 상태 코드:
200 OK - 성공
201 Created - 생성 성공
204 No Content - 삭제 성공 (본문 없음)
400 Bad Request - 잘못된 요청
401 Unauthorized - 인증 필요
403 Forbidden - 권한 없음
404 Not Found - 찾을 수 없음
500 Internal Server Error - 서버 오류

💡 실제 예시

환경 설정

# 1. Node.js 설치 확인
node --version
npm --version

# 2. 프로젝트 생성
mkdir todo-api
cd todo-api

# 3. package.json 초기화
npm init -y

# 4. 필요한 패키지 설치
npm install express cors dotenv
npm install --save-dev nodemon

# express: 웹 프레임워크
# cors: 크로스 오리진 요청 처리
# dotenv: 환경변수 관리
# nodemon: 자동 재시작 (개발용)

# 5. package.json 수정
cat > package.json << 'EOF'
{
"name": "todo-api",
"version": "1.0.0",
"description": "REST API for Todo App",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
},
"keywords": ["rest", "api", "express", "todo"],
"author": "Your Name",
"license": "MIT",
"dependencies": {
"express": "^4.18.2",
"cors": "^2.8.5",
"dotenv": "^16.3.1"
},
"devDependencies": {
"nodemon": "^3.0.1"
}
}
EOF

Step 1: 기본 서버 구축

// server.js
const express = require('express');
const cors = require('cors');
require('dotenv').config();

// Express 앱 생성
const app = express();
const PORT = process.env.PORT || 3000;

// 미들웨어 설정
app.use(cors()); // CORS 허용
app.use(express.json()); // JSON 파싱
app.use(express.urlencoded({ extended: true })); // URL-encoded 파싱

// 요청 로깅 미들웨어 (직접 구현)
app.use((req, res, next) => {
const timestamp = new Date().toISOString();
console.log(`[${timestamp}] ${req.method} ${req.path}`);
next(); // 다음 미들웨어로
});

// 루트 엔드포인트
app.get('/', (req, res) => {
res.json({
message: 'Welcome to Todo API!',
version: '1.0.0',
endpoints: {
todos: '/api/todos',
health: '/health'
}
});
});

// Health check 엔드포인트
app.get('/health', (req, res) => {
res.status(200).json({
status: 'OK',
timestamp: new Date().toISOString()
});
});

// 404 핸들러
app.use((req, res) => {
res.status(404).json({
error: 'Not Found',
message: `Route ${req.method} ${req.path} does not exist.`
});
});

// 에러 핸들러
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({
error: 'Internal Server Error',
message: err.message
});
});

// 서버 시작
app.listen(PORT, () => {
console.log(`🚀 Server is running on http://localhost:${PORT}`);
console.log(`📝 Environment: ${process.env.NODE_ENV || 'development'}`);
});
# .env 파일 생성
cat > .env << EOF
PORT=3000
NODE_ENV=development
EOF

# .gitignore 생성
cat > .gitignore << EOF
node_modules/
.env
.DS_Store
*.log
EOF

# 서버 실행
npm run dev

# 출력:
# 🚀 Server is running on http://localhost:3000
# 📝 Environment: development

# 테스트
curl http://localhost:3000
# {"message":"Welcome to Todo API!",...}

curl http://localhost:3000/health
# {"status":"OK","timestamp":"2024-01-15T..."}

Step 2: Todo CRUD API 구현 (메모리 저장)

// routes/todos.js
const express = require('express');
const router = express.Router();

// 임시 데이터 저장소 (메모리)
let todos = [
{ id: 1, title: 'Learn Node.js', completed: false, createdAt: new Date() },
{ id: 2, title: 'Build REST API', completed: false, createdAt: new Date() },
{ id: 3, title: 'Deploy to Heroku', completed: false, createdAt: new Date() }
];

let nextId = 4; // 다음 ID

// GET /api/todos - 전체 목록 조회
router.get('/', (req, res) => {
// 쿼리 파라미터로 필터링
const { completed } = req.query;

let filteredTodos = todos;

// completed 필터 적용
if (completed !== undefined) {
const isCompleted = completed === 'true';
filteredTodos = todos.filter(todo => todo.completed === isCompleted);
}

res.json({
success: true,
count: filteredTodos.length,
data: filteredTodos
});
});

// GET /api/todos/:id - 특정 항목 조회
router.get('/:id', (req, res) => {
const id = parseInt(req.params.id);
const todo = todos.find(t => t.id === id);

if (!todo) {
return res.status(404).json({
success: false,
error: 'Todo not found'
});
}

res.json({
success: true,
data: todo
});
});

// POST /api/todos - 새 항목 생성
router.post('/', (req, res) => {
// 유효성 검사
const { title } = req.body;

if (!title || title.trim() === '') {
return res.status(400).json({
success: false,
error: 'Title is required'
});
}

// 새 Todo 생성
const newTodo = {
id: nextId++,
title: title.trim(),
completed: false,
createdAt: new Date()
};

todos.push(newTodo);

// 201 Created 상태 코드
res.status(201).json({
success: true,
data: newTodo
});
});

// PUT /api/todos/:id - 항목 수정 (전체)
router.put('/:id', (req, res) => {
const id = parseInt(req.params.id);
const todoIndex = todos.findIndex(t => t.id === id);

if (todoIndex === -1) {
return res.status(404).json({
success: false,
error: 'Todo not found'
});
}

const { title, completed } = req.body;

// 유효성 검사
if (!title || title.trim() === '') {
return res.status(400).json({
success: false,
error: 'Title is required'
});
}

// 전체 교체
todos[todoIndex] = {
...todos[todoIndex],
title: title.trim(),
completed: completed !== undefined ? completed : todos[todoIndex].completed,
updatedAt: new Date()
};

res.json({
success: true,
data: todos[todoIndex]
});
});

// PATCH /api/todos/:id - 항목 수정 (부분)
router.patch('/:id', (req, res) => {
const id = parseInt(req.params.id);
const todoIndex = todos.findIndex(t => t.id === id);

if (todoIndex === -1) {
return res.status(404).json({
success: false,
error: 'Todo not found'
});
}

const { title, completed } = req.body;

// 제공된 필드만 업데이트
if (title !== undefined) {
if (title.trim() === '') {
return res.status(400).json({
success: false,
error: 'Title cannot be empty'
});
}
todos[todoIndex].title = title.trim();
}

if (completed !== undefined) {
todos[todoIndex].completed = completed;
}

todos[todoIndex].updatedAt = new Date();

res.json({
success: true,
data: todos[todoIndex]
});
});

// DELETE /api/todos/:id - 항목 삭제
router.delete('/:id', (req, res) => {
const id = parseInt(req.params.id);
const todoIndex = todos.findIndex(t => t.id === id);

if (todoIndex === -1) {
return res.status(404).json({
success: false,
error: 'Todo not found'
});
}

const deletedTodo = todos.splice(todoIndex, 1)[0];

// 204 No Content 또는 200 OK
res.json({
success: true,
message: 'Todo deleted successfully',
data: deletedTodo
});
});

// DELETE /api/todos - 전체 삭제
router.delete('/', (req, res) => {
const count = todos.length;
todos = [];
nextId = 1;

res.json({
success: true,
message: `${count} todos deleted successfully`
});
});

module.exports = router;
// server.js 업데이트 (라우터 추가)
const express = require('express');
const cors = require('cors');
require('dotenv').config();

const app = express();
const PORT = process.env.PORT || 3000;

// 미들웨어
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

// 로깅 미들웨어
app.use((req, res, next) => {
const timestamp = new Date().toISOString();
console.log(`[${timestamp}] ${req.method} ${req.path}`);
next();
});

// 루트
app.get('/', (req, res) => {
res.json({
message: 'Welcome to Todo API!',
version: '1.0.0',
endpoints: {
todos: '/api/todos',
health: '/health'
}
});
});

// Todo 라우터 연결
const todosRouter = require('./routes/todos');
app.use('/api/todos', todosRouter);

// Health check
app.get('/health', (req, res) => {
res.status(200).json({
status: 'OK',
timestamp: new Date().toISOString()
});
});

// 404 핸들러
app.use((req, res) => {
res.status(404).json({
error: 'Not Found',
message: `Route ${req.method} ${req.path} does not exist.`
});
});

// 에러 핸들러
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({
error: 'Internal Server Error',
message: err.message
});
});

app.listen(PORT, () => {
console.log(`🚀 Server is running on http://localhost:${PORT}`);
});

module.exports = app; // 테스트용

Step 3: API 테스트

# 1. 전체 목록 조회
curl http://localhost:3000/api/todos

# 응답:
# {
# "success": true,
# "count": 3,
# "data": [
# { "id": 1, "title": "Learn Node.js", "completed": false, ... },
# { "id": 2, "title": "Build REST API", "completed": false, ... },
# { "id": 3, "title": "Deploy to Heroku", "completed": false, ... }
# ]
# }

# 2. 특정 항목 조회
curl http://localhost:3000/api/todos/1

# 3. 새 항목 생성
curl -X POST http://localhost:3000/api/todos \
-H "Content-Type: application/json" \
-d '{"title": "Write documentation"}'

# 응답:
# {
# "success": true,
# "data": {
# "id": 4,
# "title": "Write documentation",
# "completed": false,
# "createdAt": "2024-01-15T..."
# }
# }

# 4. 항목 수정 (부분)
curl -X PATCH http://localhost:3000/api/todos/1 \
-H "Content-Type: application/json" \
-d '{"completed": true}'

# 5. 항목 삭제
curl -X DELETE http://localhost:3000/api/todos/1

# 6. 필터링 (완료된 항목만)
curl http://localhost:3000/api/todos?completed=true

# 7. 필터링 (미완료 항목만)
curl http://localhost:3000/api/todos?completed=false

Step 4: 고급 기능 - 미들웨어

// middleware/validate.js
// 유효성 검사 미들웨어

const validateTodo = (req, res, next) => {
const { title } = req.body;

if (!title || title.trim() === '') {
return res.status(400).json({
success: false,
error: 'Title is required and cannot be empty'
});
}

if (title.length > 100) {
return res.status(400).json({
success: false,
error: 'Title must be less than 100 characters'
});
}

// 유효성 검사 통과
next();
};

const validateId = (req, res, next) => {
const id = parseInt(req.params.id);

if (isNaN(id) || id < 1) {
return res.status(400).json({
success: false,
error: 'Invalid ID format'
});
}

req.todoId = id; // req 객체에 파싱된 ID 저장
next();
};

module.exports = { validateTodo, validateId };
// middleware/auth.js
// 간단한 API 키 인증

const authenticate = (req, res, next) => {
const apiKey = req.headers['x-api-key'];

// 환경변수에서 API 키 가져오기
const validApiKey = process.env.API_KEY || 'your-secret-api-key';

if (!apiKey) {
return res.status(401).json({
success: false,
error: 'API key is required',
message: 'Please provide X-API-Key header'
});
}

if (apiKey !== validApiKey) {
return res.status(403).json({
success: false,
error: 'Invalid API key'
});
}

// 인증 성공
next();
};

module.exports = authenticate;
// middleware/rateLimit.js
// 간단한 Rate Limiting (요청 제한)

const rateLimit = new Map();

const rateLimiter = (limit = 10, windowMs = 60000) => {
return (req, res, next) => {
const ip = req.ip || req.connection.remoteAddress;
const now = Date.now();

if (!rateLimit.has(ip)) {
rateLimit.set(ip, { count: 1, resetTime: now + windowMs });
return next();
}

const userData = rateLimit.get(ip);

// 시간 윈도우 리셋
if (now > userData.resetTime) {
rateLimit.set(ip, { count: 1, resetTime: now + windowMs });
return next();
}

// 제한 확인
if (userData.count >= limit) {
return res.status(429).json({
success: false,
error: 'Too many requests',
message: `Rate limit exceeded. Try again in ${Math.ceil((userData.resetTime - now) / 1000)} seconds.`,
retryAfter: Math.ceil((userData.resetTime - now) / 1000)
});
}

// 카운트 증가
userData.count++;
rateLimit.set(ip, userData);
next();
};
};

module.exports = rateLimiter;
// routes/todos.js 업데이트 (미들웨어 적용)
const express = require('express');
const router = express.Router();
const { validateTodo, validateId } = require('../middleware/validate');
const authenticate = require('../middleware/auth');
const rateLimiter = require('../middleware/rateLimit');

// 모든 라우트에 인증 적용 (선택사항)
// router.use(authenticate);

// Rate limiting 적용 (분당 20회)
router.use(rateLimiter(20, 60000));

let todos = [
{ id: 1, title: 'Learn Node.js', completed: false, createdAt: new Date() },
{ id: 2, title: 'Build REST API', completed: false, createdAt: new Date() },
{ id: 3, title: 'Deploy to Heroku', completed: false, createdAt: new Date() }
];

let nextId = 4;

// GET /api/todos - 미들웨어 없음
router.get('/', (req, res) => {
const { completed } = req.query;
let filteredTodos = todos;

if (completed !== undefined) {
const isCompleted = completed === 'true';
filteredTodos = todos.filter(todo => todo.completed === isCompleted);
}

res.json({
success: true,
count: filteredTodos.length,
data: filteredTodos
});
});

// GET /api/todos/:id - validateId 미들웨어 적용
router.get('/:id', validateId, (req, res) => {
const todo = todos.find(t => t.id === req.todoId);

if (!todo) {
return res.status(404).json({
success: false,
error: 'Todo not found'
});
}

res.json({
success: true,
data: todo
});
});

// POST /api/todos - validateTodo 미들웨어 적용
router.post('/', validateTodo, (req, res) => {
const { title } = req.body;

const newTodo = {
id: nextId++,
title: title.trim(),
completed: false,
createdAt: new Date()
};

todos.push(newTodo);

res.status(201).json({
success: true,
data: newTodo
});
});

// PATCH /api/todos/:id - validateId 미들웨어 적용
router.patch('/:id', validateId, (req, res) => {
const todoIndex = todos.findIndex(t => t.id === req.todoId);

if (todoIndex === -1) {
return res.status(404).json({
success: false,
error: 'Todo not found'
});
}

const { title, completed } = req.body;

if (title !== undefined) {
if (title.trim() === '') {
return res.status(400).json({
success: false,
error: 'Title cannot be empty'
});
}
todos[todoIndex].title = title.trim();
}

if (completed !== undefined) {
todos[todoIndex].completed = completed;
}

todos[todoIndex].updatedAt = new Date();

res.json({
success: true,
data: todos[todoIndex]
});
});

// DELETE /api/todos/:id - validateId 미들웨어 적용
router.delete('/:id', validateId, (req, res) => {
const todoIndex = todos.findIndex(t => t.id === req.todoId);

if (todoIndex === -1) {
return res.status(404).json({
success: false,
error: 'Todo not found'
});
}

const deletedTodo = todos.splice(todoIndex, 1)[0];

res.json({
success: true,
message: 'Todo deleted successfully',
data: deletedTodo
});
});

module.exports = router;

Step 5: 데이터베이스 연동 (MongoDB 예제)

# MongoDB 라이브러리 설치
npm install mongoose
// config/database.js
const mongoose = require('mongoose');

const connectDB = async () => {
try {
const conn = await mongoose.connect(process.env.MONGODB_URI || 'mongodb://localhost:27017/todo-api', {
useNewUrlParser: true,
useUnifiedTopology: true
});

console.log(`✅ MongoDB Connected: ${conn.connection.host}`);
} catch (error) {
console.error(`❌ MongoDB Connection Error: ${error.message}`);
process.exit(1);
}
};

module.exports = connectDB;
// models/Todo.js
const mongoose = require('mongoose');

const TodoSchema = new mongoose.Schema({
title: {
type: String,
required: [true, 'Title is required'],
trim: true,
maxlength: [100, 'Title cannot exceed 100 characters']
},
completed: {
type: Boolean,
default: false
},
createdAt: {
type: Date,
default: Date.now
},
updatedAt: {
type: Date
}
});

// 업데이트 시 자동으로 updatedAt 설정
TodoSchema.pre('save', function(next) {
this.updatedAt = Date.now();
next();
});

module.exports = mongoose.model('Todo', TodoSchema);
// routes/todos.js (MongoDB 버전)
const express = require('express');
const router = express.Router();
const Todo = require('../models/Todo');

// GET /api/todos - 전체 목록 조회
router.get('/', async (req, res) => {
try {
const { completed } = req.query;
let query = {};

if (completed !== undefined) {
query.completed = completed === 'true';
}

const todos = await Todo.find(query).sort({ createdAt: -1 });

res.json({
success: true,
count: todos.length,
data: todos
});
} catch (error) {
res.status(500).json({
success: false,
error: error.message
});
}
});

// GET /api/todos/:id - 특정 항목 조회
router.get('/:id', async (req, res) => {
try {
const todo = await Todo.findById(req.params.id);

if (!todo) {
return res.status(404).json({
success: false,
error: 'Todo not found'
});
}

res.json({
success: true,
data: todo
});
} catch (error) {
res.status(500).json({
success: false,
error: error.message
});
}
});

// POST /api/todos - 새 항목 생성
router.post('/', async (req, res) => {
try {
const todo = await Todo.create(req.body);

res.status(201).json({
success: true,
data: todo
});
} catch (error) {
res.status(400).json({
success: false,
error: error.message
});
}
});

// PATCH /api/todos/:id - 항목 수정
router.patch('/:id', async (req, res) => {
try {
const todo = await Todo.findByIdAndUpdate(
req.params.id,
req.body,
{ new: true, runValidators: true }
);

if (!todo) {
return res.status(404).json({
success: false,
error: 'Todo not found'
});
}

res.json({
success: true,
data: todo
});
} catch (error) {
res.status(400).json({
success: false,
error: error.message
});
}
});

// DELETE /api/todos/:id - 항목 삭제
router.delete('/:id', async (req, res) => {
try {
const todo = await Todo.findByIdAndDelete(req.params.id);

if (!todo) {
return res.status(404).json({
success: false,
error: 'Todo not found'
});
}

res.json({
success: true,
message: 'Todo deleted successfully',
data: todo
});
} catch (error) {
res.status(500).json({
success: false,
error: error.message
});
}
});

module.exports = router;
// server.js (MongoDB 연결 추가)
const express = require('express');
const cors = require('cors');
require('dotenv').config();
const connectDB = require('./config/database');

const app = express();
const PORT = process.env.PORT || 3000;

// DB 연결
connectDB();

// 미들웨어
app.use(cors());
app.use(express.json());

// 라우터
const todosRouter = require('./routes/todos');
app.use('/api/todos', todosRouter);

// ... 나머지 코드 동일

app.listen(PORT, () => {
console.log(`🚀 Server is running on http://localhost:${PORT}`);
});
# .env 업데이트
cat >> .env << EOF
MONGODB_URI=mongodb://localhost:27017/todo-api
# 또는 MongoDB Atlas 사용
# MONGODB_URI=mongodb+srv://username:password@cluster.mongodb.net/todo-api
EOF

Step 6: 프론트엔드 연동 예제

// 프론트엔드 (React)에서 API 호출

// api/todos.js
const API_BASE_URL = 'http://localhost:3000/api';

// 전체 목록 조회
export const getTodos = async () => {
const response = await fetch(`${API_BASE_URL}/todos`);
const data = await response.json();
return data;
};

// 특정 항목 조회
export const getTodoById = async (id) => {
const response = await fetch(`${API_BASE_URL}/todos/${id}`);
const data = await response.json();
return data;
};

// 새 항목 생성
export const createTodo = async (title) => {
const response = await fetch(`${API_BASE_URL}/todos`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ title })
});
const data = await response.json();
return data;
};

// 항목 수정
export const updateTodo = async (id, updates) => {
const response = await fetch(`${API_BASE_URL}/todos/${id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(updates)
});
const data = await response.json();
return data;
};

// 항목 삭제
export const deleteTodo = async (id) => {
const response = await fetch(`${API_BASE_URL}/todos/${id}`, {
method: 'DELETE'
});
const data = await response.json();
return data;
};

// 사용 예시
import { getTodos, createTodo, updateTodo, deleteTodo } from './api/todos';

function TodoApp() {
const [todos, setTodos] = useState([]);

useEffect(() => {
// 전체 목록 불러오기
getTodos().then(result => {
if (result.success) {
setTodos(result.data);
}
});
}, []);

const handleCreate = async (title) => {
const result = await createTodo(title);
if (result.success) {
setTodos([...todos, result.data]);
}
};

const handleToggle = async (id, completed) => {
const result = await updateTodo(id, { completed: !completed });
if (result.success) {
setTodos(todos.map(todo =>
todo.id === id ? result.data : todo
));
}
};

const handleDelete = async (id) => {
const result = await deleteTodo(id);
if (result.success) {
setTodos(todos.filter(todo => todo.id !== id));
}
};

// ... JSX 렌더링
}

Step 7: 배포 (Heroku)

# 1. Heroku CLI 설치
# https://devcenter.heroku.com/articles/heroku-cli

# 2. 로그인
heroku login

# 3. Procfile 생성 (서버 시작 명령어)
echo "web: node server.js" > Procfile

# 4. package.json에 engines 추가
cat > package.json << 'EOF'
{
"name": "todo-api",
"version": "1.0.0",
"engines": {
"node": "18.x",
"npm": "9.x"
},
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
},
...
}
EOF

# 5. Git 커밋
git init
git add .
git commit -m "Initial commit"

# 6. Heroku 앱 생성
heroku create my-todo-api

# 7. 환경변수 설정
heroku config:set NODE_ENV=production
heroku config:set MONGODB_URI=mongodb+srv://...

# 8. 배포
git push heroku main

# 9. 확인
heroku open
# https://my-todo-api.herokuapp.com

# 10. 로그 확인
heroku logs --tail

API 문서화 (README.md)

# Todo API Documentation

Base URL: `http://localhost:3000/api`

## Endpoints

### Get All Todos

GET /todos


Query Parameters:
- `completed` (optional): `true` or `false`

Response:
```json
{
"success": true,
"count": 3,
"data": [
{
"id": 1,
"title": "Learn Node.js",
"completed": false,
"createdAt": "2024-01-15T10:00:00.000Z"
}
]
}

Get Todo by ID

GET /todos/:id

Create Todo

POST /todos

Body:

{
"title": "New todo"
}

Update Todo

PATCH /todos/:id

Body:

{
"title": "Updated title",
"completed": true
}

Delete Todo

DELETE /todos/:id

Error Responses

400 Bad Request:

{
"success": false,
"error": "Title is required"
}

404 Not Found:

{
"success": false,
"error": "Todo not found"
}

500 Internal Server Error:

{
"success": false,
"error": "Server error message"
}

## 🤔 자주 묻는 질문

### Q1. REST API와 GraphQL의 차이는?

**A:**

```javascript
// REST API - 여러 엔드포인트
GET /api/users/123
GET /api/users/123/posts
GET /api/posts/456/comments

// 특징:
✅ 간단하고 직관적
✅ 캐싱 쉬움
✅ HTTP 메서드 활용
❌ Over-fetching (불필요한 데이터)
❌ Under-fetching (여러 요청 필요)

// GraphQL - 단일 엔드포인트
POST /graphql

// Query (정확히 원하는 데이터만)
{
user(id: 123) {
name
posts {
title
comments {
text
}
}
}
}

// 특징:
✅ 정확한 데이터 요청
✅ 단일 요청으로 해결
✅ 강력한 타입 시스템
❌ 학습 곡선 높음
❌ 캐싱 복잡
❌ 오버헤드 큼 (간단한 앱엔 과함)

// 언제 사용?
REST: 간단한 CRUD, 전통적인 앱, 캐싱 중요
GraphQL: 복잡한 데이터, 모바일 앱, 유연성 필요

Q2. Express.js 외 다른 프레임워크는?

A:

// Node.js 프레임워크 비교

// 1. Express.js (가장 인기)
const express = require('express');
const app = express();
app.get('/users', (req, res) => res.json({ users: [] }));

장점: 간단, 유연, 생태계 방대
단점: 기본 기능 부족 (직접 구성 필요)

// 2. Fastify (빠름)
const fastify = require('fastify')();
fastify.get('/users', async (req, reply) => {
return { users: [] };
});

장점: Express보다 2배 빠름, 타입 안전
단점: 생태계 작음

// 3. Koa.js (현대적)
const Koa = require('koa');
const app = new Koa();
app.use(async ctx => {
ctx.body = { users: [] };
});

장점: async/await 기반, 모던한 구조
단점: 미들웨어 적음

// 4. NestJS (TypeScript)
@Controller('users')
export class UsersController {
@Get()
findAll() {
return { users: [] };
}
}

장점: TypeScript, Angular 스타일, 엔터프라이즈급
단점: 학습 곡선 높음, 무거움

// 추천:
초급: Express.js (이 가이드!)
중급: Fastify 또는 Koa
고급: NestJS (대규모 프로젝트)

Q3. 에러 처리 모범 사례는?

A:

// 1. 커스텀 에러 클래스
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.isOperational = true;

Error.captureStackTrace(this, this.constructor);
}
}

// 사용
throw new AppError('Todo not found', 404);

// 2. 에러 핸들러 미들웨어
const errorHandler = (err, req, res, next) => {
let error = { ...err };
error.message = err.message;

// 로그 출력 (프로덕션에선 파일/서비스로)
console.error(err);

// Mongoose bad ObjectId
if (err.name === 'CastError') {
const message = 'Resource not found';
error = new AppError(message, 404);
}

// Mongoose duplicate key
if (err.code === 11000) {
const message = 'Duplicate field value entered';
error = new AppError(message, 400);
}

// Mongoose validation error
if (err.name === 'ValidationError') {
const message = Object.values(err.errors).map(e => e.message);
error = new AppError(message, 400);
}

res.status(error.statusCode || 500).json({
success: false,
error: error.message || 'Server Error'
});
};

// 3. Async 핸들러 (try-catch 제거)
const asyncHandler = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};

// 사용
router.get('/todos', asyncHandler(async (req, res) => {
const todos = await Todo.find();
res.json({ data: todos });
// try-catch 불필요!
}));

// 4. 프로덕션 vs 개발 에러
const errorHandler = (err, req, res, next) => {
if (process.env.NODE_ENV === 'development') {
// 개발: 전체 스택 트레이스
res.status(err.statusCode || 500).json({
success: false,
error: err.message,
stack: err.stack
});
} else {
// 프로덕션: 최소 정보
res.status(err.statusCode || 500).json({
success: false,
error: err.isOperational ? err.message : 'Something went wrong'
});
}
};

Q4. API 보안은 어떻게 강화하나요?

A:

// 1. Helmet (보안 헤더)
npm install helmet

const helmet = require('helmet');
app.use(helmet());

// 2. Rate Limiting (요청 제한)
npm install express-rate-limit

const rateLimit = require('express-rate-limit');
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15분
max: 100 // 최대 100 요청
});
app.use('/api', limiter);

// 3. CORS 설정 (허용 도메인)
const cors = require('cors');
app.use(cors({
origin: ['https://myapp.com', 'https://admin.myapp.com'],
credentials: true
}));

// 4. 입력 검증 및 새니타이제이션
npm install express-validator

const { body, validationResult } = require('express-validator');

router.post('/todos',
body('title').trim().isLength({ min: 1, max: 100 }).escape(),
(req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
// ...
}
);

// 5. JWT 인증
npm install jsonwebtoken

const jwt = require('jsonwebtoken');

// 토큰 생성
const token = jwt.sign(
{ userId: user.id },
process.env.JWT_SECRET,
{ expiresIn: '1h' }
);

// 토큰 검증 미들웨어
const protect = (req, res, next) => {
const token = req.headers.authorization?.split(' ')[1];

if (!token) {
return res.status(401).json({ error: 'No token provided' });
}

try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
} catch (error) {
res.status(401).json({ error: 'Invalid token' });
}
};

router.get('/protected', protect, (req, res) => {
res.json({ message: 'You have access!', user: req.user });
});

// 6. 환경변수 보호
// .env 파일에 비밀 정보
JWT_SECRET=super-secret-key-change-in-production
DATABASE_URL=mongodb+srv://...

// 절대 커밋하지 않기!
// .gitignore에 추가
.env
.env.local

Q5. API 테스트는 어떻게 하나요?

A:

// Jest + Supertest 사용

// 설치
npm install --save-dev jest supertest

// package.json
{
"scripts": {
"test": "jest --watchAll --verbose"
}
}

// tests/todos.test.js
const request = require('supertest');
const app = require('../server');

describe('Todo API', () => {
// GET /api/todos
describe('GET /api/todos', () => {
it('should return all todos', async () => {
const res = await request(app).get('/api/todos');

expect(res.statusCode).toBe(200);
expect(res.body.success).toBe(true);
expect(Array.isArray(res.body.data)).toBe(true);
});

it('should filter by completed', async () => {
const res = await request(app)
.get('/api/todos')
.query({ completed: 'true' });

expect(res.statusCode).toBe(200);
res.body.data.forEach(todo => {
expect(todo.completed).toBe(true);
});
});
});

// POST /api/todos
describe('POST /api/todos', () => {
it('should create a new todo', async () => {
const newTodo = { title: 'Test todo' };

const res = await request(app)
.post('/api/todos')
.send(newTodo);

expect(res.statusCode).toBe(201);
expect(res.body.success).toBe(true);
expect(res.body.data.title).toBe(newTodo.title);
});

it('should fail without title', async () => {
const res = await request(app)
.post('/api/todos')
.send({});

expect(res.statusCode).toBe(400);
expect(res.body.success).toBe(false);
});
});

// PATCH /api/todos/:id
describe('PATCH /api/todos/:id', () => {
it('should update todo', async () => {
const res = await request(app)
.patch('/api/todos/1')
.send({ completed: true });

expect(res.statusCode).toBe(200);
expect(res.body.data.completed).toBe(true);
});

it('should return 404 for invalid id', async () => {
const res = await request(app)
.patch('/api/todos/999')
.send({ completed: true });

expect(res.statusCode).toBe(404);
});
});

// DELETE /api/todos/:id
describe('DELETE /api/todos/:id', () => {
it('should delete todo', async () => {
const res = await request(app).delete('/api/todos/1');

expect(res.statusCode).toBe(200);
expect(res.body.success).toBe(true);
});
});
});

// 실행
npm test

// 출력:
// PASS tests/todos.test.js
// Todo API
// GET /api/todos
// ✓ should return all todos (50ms)
// ✓ should filter by completed (30ms)
// POST /api/todos
// ✓ should create a new todo (40ms)
// ✓ should fail without title (25ms)
// ...

🎓 다음 단계

REST API 서버를 만들었다면, 다음을 학습해보세요:

  1. 환경변수 관리 - API 키와 비밀 정보 안전하게 관리하기
  2. 첫 웹사이트 배포하기 - API 서버 배포하고 프론트엔드 연동
  3. 에러 로깅과 모니터링 - 프로덕션 환경에서 에러 추적하기

더 나아가기

# 고급 주제
1. 인증 시스템 (JWT, OAuth)
2. 파일 업로드 (Multer)
3. 웹소켓 실시간 통신 (Socket.io)
4. 마이크로서비스 아키텍처
5. Docker 컨테이너화
6. CI/CD 파이프라인
7. API 버저닝 (/api/v1, /api/v2)
8. GraphQL 전환
9. Redis 캐싱
10. 로드 밸런싱

🎬 마무리

REST API 서버 개발의 핵심:

  • Express.js: 간단하고 유연한 프레임워크
  • CRUD 작업: HTTP 메서드로 데이터 관리
  • 미들웨어: 요청 전처리와 공통 로직
  • 에러 처리: 안정적인 서버 운영
  • 보안: 인증, 검증, Rate Limiting
  • 테스트: 자동화된 품질 보증

이제 여러분만의 REST API를 만들고, 프론트엔드와 연동하여 완전한 풀스택 애플리케이션을 완성하세요! 🔧✨

작은 Todo API에서 시작해서, 점진적으로 기능을 추가하며 성장시켜보세요. 실전 경험이 최고의 학습입니다!