🔧 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 서버를 만들었다면, 다음을 학습해보세요:
- 환경변수 관리 - API 키와 비밀 정보 안전하게 관리하기
- 첫 웹사이트 배포하기 - API 서버 배포하고 프론트엔드 연동
- 에러 로깅과 모니터링 - 프로덕션 환경에서 에러 추적하기
더 나아가기
# 고급 주제
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에서 시작해서, 점진적으로 기능을 추가하며 성장시켜보세요. 실전 경험이 최고의 학습입니다!