📋 학습 목표
- 풀스택 애플리케이션의 구조와 구성 요소를 이해한다
- Docker Compose로 복잡한 멀티 서비스 환경을 구축한다
- 프런트엔드, 백엔드, 데이터베이스를 통합한 완전한 시스템을 만든다
- 서비스 간 통신과 데이터 흐름을 설계한다
- 개발부터 배포까지의 전체 워크플로우를 구현한다
1. 풀스택 애플리케이션 아키텍처 이해
1.1 풀스택 애플리케이션이란?
🏗️ 풀스택 구성 요소
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Frontend │ │ Backend │ │ Database │
│ (React/Vue) │◄──►│ (Node.js) │◄──►│ (MySQL) │
│ Port: 3000 │ │ Port: 5000 │ │ Port: 3306 │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │ │
│ │ │
┌───────────────────────────────────────────────────────────────┐
│ Docker Network │
└───────────────────────────────────────────────────────────────┘
주요 구성 요소:
- Frontend: 사용자 인터페이스 (React, Vue, Angular)
- Backend: API 서버 (Node.js, Python, Java)
- Database: 데이터 저장소 (MySQL, PostgreSQL, MongoDB)
- Cache: 성능 향상 (Redis)
- Proxy: 리버스 프록시 (Nginx)
1.2 우리가 만들 풀스택 앱 - “할 일 관리 시스템”
🎯 프로젝트 개요
- Frontend: React 기반 SPA
- Backend: Node.js + Express API
- Database: MySQL
- Cache: Redis
- Proxy: Nginx
기능 요구사항
- 사용자 회원가입/로그인
- 할 일 CRUD (생성, 조회, 수정, 삭제)
- 실시간 업데이트
- 반응형 웹 디자인
2. 프로젝트 구조 설계
2.1 디렉토리 구조
fullstack-todo/
├── compose.yml # Docker Compose 설정
├── .env # 환경변수
├── nginx/
│ ├── Dockerfile
│ └── nginx.conf
├── frontend/
│ ├── Dockerfile
│ ├── package.json
│ ├── public/
| | |- index.html
│ └── src/
│ ├── App.js
│ ├── App.css
| |-- index.js
| |-- index.css
│ ├── components/
│ └── services/
├── backend/
│ ├── Dockerfile
│ ├── package.json
│ ├── src/
│ │ ├── app.js
│ │ ├── routes/
│ │ ├── models/
│ │ └── middleware/
│ └── init-db/
│ └── schema.sql
└── data/ # 데이터 지속성을 위한 폴더
2.2 Docker Compose 메인 설정
compose.yml
services:
# Nginx 리버스 프록시
nginx:
build: ./nginx
ports:
- "80:80"
depends_on:
- frontend
- backend
networks:
- app-network
restart: unless-stopped
# React 프런트엔드
frontend:
build:
context: ./frontend
target: development
volumes:
- ./frontend/src:/app/src
- ./frontend/public:/app/public
- /app/node_modules
environment:
- REACT_APP_API_URL=/api
- CHOKIDAR_USEPOLLING=true
networks:
- app-network
restart: unless-stopped
# Node.js 백엔드
backend:
build: ./backend
volumes:
- ./backend/src:/app/src
- /app/node_modules
environment:
- NODE_ENV=development
- DB_HOST=database
- DB_USER=${DB_USER}
- DB_PASSWORD=${DB_PASSWORD}
- DB_NAME=${DB_NAME}
- REDIS_HOST=redis
- JWT_SECRET=${JWT_SECRET}
depends_on:
database:
condition: service_healthy
redis:
condition: service_started
networks:
- app-network
restart: unless-stopped
# MySQL 데이터베이스
database:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
MYSQL_DATABASE: ${DB_NAME}
MYSQL_USER: ${DB_USER}
MYSQL_PASSWORD: ${DB_PASSWORD}
volumes:
- mysql_data:/var/lib/mysql
- ./backend/init-db:/docker-entrypoint-initdb.d
networks:
- app-network
restart: unless-stopped
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 30s
timeout: 10s
retries: 5
# Redis 캐시
redis:
image: redis:7-alpine
volumes:
- redis_data:/data
networks:
- app-network
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 30s
timeout: 10s
retries: 3
# Adminer (데이터베이스 관리 도구)
adminer:
image: adminer:latest
ports:
- "8080:8080"
networks:
- app-network
restart: unless-stopped
networks:
app-network:
driver: bridge
volumes:
mysql_data:
redis_data:
2.3 환경 변수 설정
.env
# 데이터베이스 설정
DB_ROOT_PASSWORD=rootpassword123
DB_NAME=todoapp
DB_USER=todouser
DB_PASSWORD=todopassword123
# JWT 시크릿
JWT_SECRET=your-super-secret-jwt-key-here
# 환경 설정
NODE_ENV=development
REACT_APP_API_URL=http://localhost/api
3. Nginx 리버스 프록시 설정
3.1 Nginx Dockerfile
nginx/Dockerfile
FROM nginx:alpine
COPY nginx.conf /etc/nginx/nginx.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
3.2 Nginx 설정 파일
nginx/nginx.conf
events {
worker_connections 1024;
}
http {
upstream frontend {
server frontend:3000;
}
upstream backend {
server backend:5000;
}
server {
listen 80;
server_name localhost;
# 정적 파일 설정
location / {
proxy_pass <http://frontend>;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket 지원 (React 개발 서버용)
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# 타임아웃 설정
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
# API 라우팅
location /api/ {
proxy_pass <http://backend/>;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# CORS 헤더
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS";
add_header Access-Control-Allow-Headers "Content-Type, Authorization";
# 타임아웃 설정
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
if ($request_method = 'OPTIONS') {
return 204;
}
}
# 헬스체크 엔드포인트
location /health {
access_log off;
return 200 "healthy\\n";
add_header Content-Type text/plain;
}
}
}
4. 백엔드 API 서버 구현
4.1 Backend Dockerfile
backend/Dockerfile
FROM node:18-alpine
WORKDIR /app
# 의존성 설치를 위한 package.json 복사
COPY package*.json ./
RUN npm ci
# 애플리케이션 코드 복사
COPY . .
EXPOSE 5000
# 개발 모드로 실행 (nodemon 사용)
CMD ["npm", "run", "dev"]
4.2 Package.json 설정
backend/package.json
{
"name": "todo-backend",
"version": "1.0.0",
"description": "Todo App Backend API",
"main": "src/app.js",
"scripts": {
"start": "node src/app.js",
"dev": "nodemon src/app.js",
"test": "jest"
},
"dependencies": {
"express": "^4.18.0",
"cors": "^2.8.5",
"mysql2": "^3.6.0",
"redis": "^4.6.0",
"bcryptjs": "^2.4.3",
"jsonwebtoken": "^9.0.0",
"joi": "^17.9.0",
"helmet": "^7.0.0",
"express-rate-limit": "^6.8.0",
"dotenv": "^16.0.0"
},
"devDependencies": {
"nodemon": "^3.0.0",
"jest": "^29.0.0"
}
}이미지 생성전에 npm install 실행
4.3 Express 애플리케이션
backend/src/app.js
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const mysql = require('mysql2/promise');
const redis = require('redis');
const app = express();
const PORT = process.env.PORT || 5000;
// 미들웨어 설정
app.use(helmet());
app.use(cors({
origin: process.env.FRONTEND_URL || '<http://localhost:3000>',
credentials: true
}));
app.use(express.json());
// Rate Limiting
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15분
max: 100 // 최대 100 요청
});
app.use(limiter);
// 데이터베이스 연결 설정
const dbConfig = {
host: process.env.DB_HOST || 'localhost',
user: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || 'password',
database: process.env.DB_NAME || 'todoapp',
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0
};
let db;
let redisClient;
// 데이터베이스 연결
async function initializeDatabase() {
try {
db = mysql.createPool(dbConfig);
console.log('✅ MySQL connected successfully');
} catch (error) {
console.error('❌ MySQL connection failed:', error);
process.exit(1);
}
}
// Redis 연결
async function initializeRedis() {
try {
redisClient = redis.createClient({
url: `redis://${process.env.REDIS_HOST || 'localhost'}:6379`
});
await redisClient.connect();
console.log('✅ Redis connected successfully');
} catch (error) {
console.error('❌ Redis connection failed:', error);
}
}
// 라우트 정의
app.get('/health', (req, res) => {
res.json({
status: 'healthy',
timestamp: new Date().toISOString(),
uptime: process.uptime()
});
});
// 할 일 목록 조회
app.get('/todos', async (req, res) => {
try {
// Redis 캐시 확인
const cached = await redisClient?.get('todos');
if (cached) {
return res.json(JSON.parse(cached));
}
// 데이터베이스에서 조회
const [rows] = await db.execute(
'SELECT id, title, description, completed, created_at, updated_at FROM todos ORDER BY created_at DESC'
);
// Redis에 캐시 저장 (5분)
await redisClient?.setEx('todos', 300, JSON.stringify(rows));
res.json(rows);
} catch (error) {
console.error('Error fetching todos:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// 할 일 생성
app.post('/todos', async (req, res) => {
try {
const { title, description = '' } = req.body;
if (!title || title.trim().length === 0) {
return res.status(400).json({ error: 'Title is required' });
}
const [result] = await db.execute(
'INSERT INTO todos (title, description) VALUES (?, ?)',
[title.trim(), description.trim()]
);
// 캐시 무효화
await redisClient?.del('todos');
res.status(201).json({
id: result.insertId,
title: title.trim(),
description: description.trim(),
completed: false,
created_at: new Date()
});
} catch (error) {
console.error('Error creating todo:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// 할 일 수정
app.put('/todos/:id', async (req, res) => {
try {
const { id } = req.params;
const { title, description, completed } = req.body;
const [result] = await db.execute(
'UPDATE todos SET title = ?, description = ?, completed = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
[title, description, completed, id]
);
if (result.affectedRows === 0) {
return res.status(404).json({ error: 'Todo not found' });
}
// 캐시 무효화
await redisClient?.del('todos');
res.json({ message: 'Todo updated successfully' });
} catch (error) {
console.error('Error updating todo:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// 할 일 삭제
app.delete('/todos/:id', async (req, res) => {
try {
const { id } = req.params;
const [result] = await db.execute('DELETE FROM todos WHERE id = ?', [id]);
if (result.affectedRows === 0) {
return res.status(404).json({ error: 'Todo not found' });
}
// 캐시 무효화
await redisClient?.del('todos');
res.json({ message: 'Todo deleted successfully' });
} catch (error) {
console.error('Error deleting todo:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// 에러 핸들링 미들웨어
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({ error: 'Something went wrong!' });
});
// 404 핸들러
app.use((req, res) => {
res.status(404).json({ error: 'Route not found' });
});
// 서버 시작
async function startServer() {
await initializeDatabase();
await initializeRedis();
app.listen(PORT, '0.0.0.0', () => {
console.log(`🚀 Server running on port ${PORT}`);
console.log(`📊 Health check: <http://localhost>:${PORT}/health`);
});
}
// Graceful shutdown
process.on('SIGTERM', async () => {
console.log('SIGTERM received, shutting down gracefully');
await db?.end();
await redisClient?.disconnect();
process.exit(0);
});
process.on('SIGINT', async () => {
console.log('SIGINT received, shutting down gracefully');
await db?.end();
await redisClient?.disconnect();
process.exit(0);
});
startServer().catch(console.error);
4.4 데이터베이스 스키마
backend/init-db/schema.sql
-- 할 일 테이블 생성
CREATE TABLE IF NOT EXISTS todos (
id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) NOT NULL,
description TEXT,
completed BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
-- 사용자 테이블 (향후 확장용)
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
name VARCHAR(100) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
-- 샘플 데이터 삽입
INSERT INTO todos (title, description, completed) VALUES
('Docker Compose 학습하기', 'Docker Compose로 풀스택 애플리케이션 구성 방법 익히기', false),
('React 앱 개발', '할 일 관리 시스템 프런트엔드 개발', false),
('API 서버 구축', 'Node.js와 Express로 REST API 구현', true),
('데이터베이스 설계', 'MySQL을 사용한 데이터 모델링', true);
-- 인덱스 생성
CREATE INDEX idx_todos_created_at ON todos(created_at);
CREATE INDEX idx_todos_completed ON todos(completed);
5. 프런트엔드 React 애플리케이션
5.1 Frontend Dockerfile
frontend/Dockerfile
# 개발 환경용 멀티스테이지 빌드
FROM node:18-alpine AS base
WORKDIR /app
# 의존성 설치
COPY package*.json ./
RUN npm ci
# 개발 스테이지
FROM base AS development
WORKDIR /app
COPY . .
EXPOSE 3000
ENV WATCHPACK_POLLING=true
CMD ["npm", "start"]
# 프로덕션 빌드 스테이지
FROM base AS builder
WORKDIR /app
COPY . .
RUN npm run build
# 프로덕션 스테이지
FROM nginx:alpine AS production
COPY --from=builder /app/build /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
5.2 Package.json 설정
frontend/package.json
{
"name": "todo-frontend",
"version": "1.0.0",
"private": true,
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-scripts": "5.0.1",
"axios": "^1.5.0",
"react-query": "^3.39.0",
"react-hot-toast": "^2.4.0"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"proxy": "<http://backend:5000>"
}
이미지 생성 전에 npm install 실행
5.3 메인 React 컴포넌트
public/index.html
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Docker Compose 풀스택 할 일 관리 애플리케이션"
/>
<title>할 일 관리 - Todo App</title>
</head>
<body>
<noscript>이 애플리케이션을 실행하려면 JavaScript를 활성화해야 합니다.</noscript>
<div id="root"></div>
</body>
</html>frontend/src/App.js
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import toast, { Toaster } from 'react-hot-toast';
import './App.css';
const API_URL = process.env.REACT_APP_API_URL || '/api';
// API 클라이언트 설정
const api = axios.create({
baseURL: API_URL,
timeout: 10000,
});
function App() {
const [todos, setTodos] = useState([]);
const [loading, setLoading] = useState(true);
const [newTodo, setNewTodo] = useState({ title: '', description: '' });
// 할 일 목록 조회
const fetchTodos = async () => {
try {
setLoading(true);
const response = await api.get('/todos');
setTodos(response.data);
} catch (error) {
console.error('Failed to fetch todos:', error);
toast.error('할 일 목록을 불러오는데 실패했습니다.');
} finally {
setLoading(false);
}
};
// 할 일 추가
const addTodo = async (e) => {
e.preventDefault();
if (!newTodo.title.trim()) {
toast.error('제목을 입력해주세요.');
return;
}
try {
const response = await api.post('/todos', newTodo);
setTodos([response.data, ...todos]);
setNewTodo({ title: '', description: '' });
toast.success('할 일이 추가되었습니다.');
} catch (error) {
console.error('Failed to add todo:', error);
toast.error('할 일 추가에 실패했습니다.');
}
};
// 할 일 완료 상태 변경
const toggleTodo = async (id, completed) => {
try {
const todo = todos.find(t => t.id === id);
await api.put(`/todos/${id}`, {
...todo,
completed: !completed
});
setTodos(todos.map(todo =>
todo.id === id ? { ...todo, completed: !completed } : todo
));
toast.success(completed ? '할 일을 미완료로 변경했습니다.' : '할 일을 완료했습니다.');
} catch (error) {
console.error('Failed to toggle todo:', error);
toast.error('할 일 상태 변경에 실패했습니다.');
}
};
// 할 일 삭제
const deleteTodo = async (id) => {
if (!window.confirm('정말로 삭제하시겠습니까?')) {
return;
}
try {
await api.delete(`/todos/${id}`);
setTodos(todos.filter(todo => todo.id !== id));
toast.success('할 일이 삭제되었습니다.');
} catch (error) {
console.error('Failed to delete todo:', error);
toast.error('할 일 삭제에 실패했습니다.');
}
};
// 컴포넌트 마운트 시 할 일 목록 조회
useEffect(() => {
fetchTodos();
}, []);
return (
<div className="App">
<Toaster position="top-right" />
<header className="App-header">
<h1>📝 할 일 관리</h1>
<p>Docker Compose로 구성한 풀스택 애플리케이션</p>
</header>
<main className="main-content">
{/* 할 일 추가 폼 */}
<section className="add-todo-section">
<h2>새 할 일 추가</h2>
<form onSubmit={addTodo} className="add-todo-form">
<div className="form-group">
<input
type="text"
placeholder="할 일 제목을 입력하세요"
value={newTodo.title}
onChange={(e) => setNewTodo({ ...newTodo, title: e.target.value })}
className="form-input"
/>
</div>
<div className="form-group">
<textarea
placeholder="상세 설명 (선택사항)"
value={newTodo.description}
onChange={(e) => setNewTodo({ ...newTodo, description: e.target.value })}
className="form-textarea"
rows="3"
/>
</div>
<button type="submit" className="btn btn-primary">
추가하기
</button>
</form>
</section>
{/* 할 일 목록 */}
<section className="todos-section">
<h2>할 일 목록 ({todos.length}개)</h2>
{loading ? (
<div className="loading">로딩 중...</div>
) : todos.length === 0 ? (
<div className="empty-state">
<p>할 일이 없습니다. 새로운 할 일을 추가해보세요!</p>
</div>
) : (
<div className="todos-grid">
{todos.map(todo => (
<div
key={todo.id}
className={`todo-card ${todo.completed ? 'completed' : ''}`}
>
<div className="todo-content">
<h3 className="todo-title">{todo.title}</h3>
{todo.description && (
<p className="todo-description">{todo.description}</p>
)}
<div className="todo-meta">
<span className="todo-date">
생성일: {new Date(todo.created_at).toLocaleDateString('ko-KR')}
</span>
{todo.updated_at !== todo.created_at && (
<span className="todo-date">
수정일: {new Date(todo.updated_at).toLocaleDateString('ko-KR')}
</span>
)}
</div>
</div>
<div className="todo-actions">
<button
onClick={() => toggleTodo(todo.id, todo.completed)}
className={`btn ${todo.completed ? 'btn-warning' : 'btn-success'}`}
>
{todo.completed ? '미완료' : '완료'}
</button>
<button
onClick={() => deleteTodo(todo.id)}
className="btn btn-danger"
>
삭제
</button>
</div>
</div>
))}
</div>
)}
</section>
</main>
<footer className="App-footer">
<p>🐳 Docker Compose 풀스택 애플리케이션 예제</p>
<div className="tech-stack">
<span>React</span>
<span>Node.js</span>
<span>MySQL</span>
<span>Redis</span>
<span>Nginx</span>
</div>
</footer>
</div>
);
}
export default App;
frontend/src/index.js
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);frontend/src/index.css
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #f5f7fa;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
* {
box-sizing: border-box;
}5.4 CSS 스타일링
frontend/src/App.css
.App {
text-align: center;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
.App-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 40px 20px;
border-radius: 15px;
color: white;
margin-bottom: 40px;
box-shadow: 0 10px 30px rgba(0,0,0,0.1);
}
.App-header h1 {
margin: 0 0 10px 0;
font-size: 2.5rem;
font-weight: 600;
}
.App-header p {
margin: 0;
opacity: 0.9;
font-size: 1.1rem;
}
.main-content {
max-width: 800px;
margin: 0 auto;
}
.add-todo-section {
background: white;
padding: 30px;
border-radius: 15px;
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
margin-bottom: 40px;
}
.add-todo-section h2 {
margin-bottom: 20px;
color: #333;
font-size: 1.5rem;
}
.add-todo-form {
display: flex;
flex-direction: column;
gap: 15px;
}
.form-group {
text-align: left;
}
.form-input,
.form-textarea {
width: 100%;
padding: 12px;
border: 2px solid #e1e8ed;
border-radius: 8px;
font-size: 16px;
font-family: inherit;
transition: border-color 0.3s ease;
}
.form-input:focus,
.form-textarea:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.form-textarea {
resize: vertical;
min-height: 80px;
}
.btn {
padding: 12px 24px;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
text-decoration: none;
display: inline-block;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.3);
}
.btn-success {
background: #28a745;
color: white;
}
.btn-success:hover {
background: #218838;
transform: translateY(-1px);
}
.btn-warning {
background: #ffc107;
color: #212529;
}
.btn-warning:hover {
background: #e0a800;
transform: translateY(-1px);
}
.btn-danger {
background: #dc3545;
color: white;
}
.btn-danger:hover {
background: #c82333;
transform: translateY(-1px);
}
.todos-section {
background: white;
padding: 30px;
border-radius: 15px;
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
}
.todos-section h2 {
margin-bottom: 25px;
color: #333;
font-size: 1.5rem;
}
.loading {
padding: 40px;
color: #666;
font-size: 18px;
}
.empty-state {
padding: 60px 20px;
color: #666;
}
.empty-state p {
font-size: 18px;
margin: 0;
}
```css
.todos-grid {
display: grid;
gap: 20px;
}
.todo-card {
background: #f8f9fa;
border: 2px solid #e9ecef;
border-radius: 12px;
padding: 20px;
display: flex;
justify-content: space-between;
align-items: flex-start;
transition: all 0.3s ease;
}
.todo-card:hover {
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
transform: translateY(-2px);
}
.todo-card.completed {
opacity: 0.7;
border-color: #28a745;
background: #f8fff8;
}
.todo-card.completed .todo-title {
text-decoration: line-through;
color: #666;
}
.todo-content {
flex: 1;
text-align: left;
}
.todo-title {
margin: 0 0 8px 0;
font-size: 1.2rem;
font-weight: 600;
color: #333;
}
.todo-description {
margin: 0 0 12px 0;
color: #666;
line-height: 1.5;
}
.todo-meta {
font-size: 0.85rem;
color: #999;
}
.todo-date {
margin-right: 15px;
}
.todo-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.todo-actions .btn {
padding: 8px 16px;
font-size: 14px;
}
.App-footer {
margin-top: 50px;
padding: 30px;
background: #f8f9fa;
border-radius: 15px;
color: #666;
}
.App-footer p {
margin: 0 0 15px 0;
font-size: 1.1rem;
}
.tech-stack {
display: flex;
justify-content: center;
gap: 15px;
flex-wrap: wrap;
}
.tech-stack span {
background: #667eea;
color: white;
padding: 6px 12px;
border-radius: 20px;
font-size: 14px;
font-weight: 500;
}
/* 반응형 디자인 */
@media (max-width: 768px) {
.App {
padding: 15px;
}
.App-header {
padding: 30px 20px;
}
.App-header h1 {
font-size: 2rem;
}
.add-todo-section,
.todos-section {
padding: 20px;
}
.todo-card {
flex-direction: column;
gap: 15px;
}
.todo-actions {
align-self: stretch;
}
.todo-actions .btn {
flex: 1;
}
.tech-stack {
gap: 10px;
}
}6. 서비스 간 통신 및 데이터 흐름
6.1 데이터 흐름 다이어그램
사용자 브라우저
↓ HTTP Request (포트 80)
Nginx (리버스 프록시)
↓ /api/* → Backend (포트 5000)
↓ /* → Frontend (포트 3000)
Node.js API 서버
↓ SQL Query (포트 3306)
↓ Cache Check (포트 6379)
MySQL Database ←→ Redis Cache
6.2 환경별 설정 파일
개발환경 오버라이드 (compose.override.yml)
# 개발 환경용 오버라이드 설정
services:
frontend:
build:
target: development
volumes:
- ./frontend/src:/app/src
- /app/node_modules
environment:
- FAST_REFRESH=false
- CHOKIDAR_USEPOLLING=true
backend:
volumes:
- ./backend/src:/app/src
- /app/node_modules
environment:
- NODE_ENV=development
command: npm run dev
database:
ports:
- "3306:3306" # 개발시 직접 접근 가능
environment:
MYSQL_GENERAL_LOG: 1
redis:
ports:
- "6379:6379" # 개발시 직접 접근 가능
프로덕션 설정 (compose.prod.yml)
# 프로덕션 환경 설정
services:
nginx:
restart: always
deploy:
resources:
limits:
cpus: '0.5'
memory: 256M
frontend:
build:
target: production
restart: always
deploy:
resources:
limits:
cpus: '0.5'
memory: 256M
backend:
environment:
- NODE_ENV=production
restart: always
deploy:
resources:
limits:
cpus: '1.0'
memory: 512M
# 개발용 볼륨 마운트 제거
database:
restart: always
deploy:
resources:
limits:
cpus: '1.0'
memory: 1G
volumes:
- /opt/mysql-data:/var/lib/mysql # 외부 스토리지
# 포트 노출 제거 (보안)
redis:
restart: always
deploy:
resources:
limits:
cpus: '0.3'
memory: 256M
# 포트 노출 제거 (보안)
adminer:
profiles:
- tools # 프로덕션에서는 기본적으로 비활성화
volumes:
mysql_data:
external: true
redis_data:
external: true
7. 실습 과제
📝 기본 실습
실습 1: 풀스택 애플리케이션 구축
목표: 제공된 코드로 완전한 할 일 관리 시스템 구축
# 1단계: 프로젝트 디렉토리 생성
mkdir fullstack-todo
cd fullstack-todo
# 2단계: 디렉토리 구조 생성
mkdir -p nginx frontend/src backend/src backend/init-db
# 3단계: 각 파일들 생성 (위에서 제공된 코드 사용)
# - compose.yml
# - .env
# - nginx/Dockerfile, nginx/nginx.conf
# - frontend/Dockerfile, frontend/package.json, frontend/src/App.js, frontend/src/App.css
# - backend/Dockerfile, backend/package.json, backend/src/app.js, backend/init-db/schema.sql
frontend와 backend 디렉토리에서 npm init -y , npm install 수행
# 4단계: 전체 스택 실행
docker compose up -d
# 5단계: 서비스 상태 확인
docker compose ps
curl <http://localhost/api/health>
curl <http://localhost/api/todos>
# 6단계: 브라우저에서 <http://localhost> 접속하여 기능 테스트
실습 2: 기능 확장
목표: 할 일 우선순위 및 마감일 기능 추가
-- 데이터베이스 스키마 수정
ALTER TABLE todos ADD COLUMN priority ENUM('low', 'medium', 'high') DEFAULT 'medium';
ALTER TABLE todos ADD COLUMN due_date DATE NULL;
-- 인덱스 추가
CREATE INDEX idx_todos_priority ON todos(priority);
CREATE INDEX idx_todos_due_date ON todos(due_date);
🚀 심화 실습
실습 3: 사용자 인증 시스템
목표: JWT 기반 회원가입/로그인 구현
// 백엔드에 인증 미들웨어 추가
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
// 회원가입 API
app.post('/auth/register', async (req, res) => {
const { email, password, name } = req.body;
try {
const hashedPassword = await bcrypt.hash(password, 10);
const [result] = await db.execute(
'INSERT INTO users (email, password_hash, name) VALUES (?, ?, ?)',
[email, hashedPassword, name]
);
const token = jwt.sign(
{ userId: result.insertId, email },
process.env.JWT_SECRET,
{ expiresIn: '7d' }
);
res.status(201).json({
token,
user: { id: result.insertId, email, name }
});
} catch (error) {
res.status(400).json({ error: 'Registration failed' });
}
});
// 로그인 API
app.post('/auth/login', async (req, res) => {
const { email, password } = req.body;
try {
const [users] = await db.execute(
'SELECT * FROM users WHERE email = ?',
[email]
);
if (users.length === 0) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const user = users[0];
const validPassword = await bcrypt.compare(password, user.password_hash);
if (!validPassword) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const token = jwt.sign(
{ userId: user.id, email: user.email },
process.env.JWT_SECRET,
{ expiresIn: '7d' }
);
res.json({
token,
user: { id: user.id, email: user.email, name: user.name }
});
} catch (error) {
res.status(500).json({ error: 'Login failed' });
}
});
8. 모니터링 및 로깅
8.1 헬스체크 구현
종합 헬스체크 엔드포인트
// backend/src/app.js에 추가
app.get('/health/detailed', async (req, res) => {
const healthCheck = {
uptime: process.uptime(),
timestamp: new Date().toISOString(),
status: 'healthy',
checks: {
database: 'unknown',
redis: 'unknown',
memory: 'unknown'
}
};
try {
// 데이터베이스 연결 확인
await db.execute('SELECT 1');
healthCheck.checks.database = 'healthy';
} catch (error) {
healthCheck.checks.database = 'unhealthy';
healthCheck.status = 'unhealthy';
}
try {
// Redis 연결 확인
await redisClient?.ping();
healthCheck.checks.redis = 'healthy';
} catch (error) {
healthCheck.checks.redis = 'unhealthy';
}
// 메모리 사용량 확인
const memUsage = process.memoryUsage();
const memUsagePercent = (memUsage.rss / 1024 / 1024 / 1024).toFixed(2);
healthCheck.checks.memory = memUsagePercent < 1 ? 'healthy' : 'warning';
healthCheck.memory = {
rss: `${memUsagePercent}GB`,
heapUsed: `${(memUsage.heapUsed / 1024 / 1024).toFixed(2)}MB`,
heapTotal: `${(memUsage.heapTotal / 1024 / 1024).toFixed(2)}MB`
};
const statusCode = healthCheck.status === 'healthy' ? 200 : 503;
res.status(statusCode).json(healthCheck);
});
8.2 모니터링 스택 추가
compose.monitoring.yml
# 모니터링 서비스 추가
services:
prometheus:
image: prom/prometheus:latest
ports:
- "9090:9090"
volumes:
- ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus_data:/prometheus
networks:
- app-network
restart: unless-stopped
grafana:
image: grafana/grafana:latest
ports:
- "3001:3000"
environment:
- GF_SECURITY_ADMIN_PASSWORD=admin123
volumes:
- grafana_data:/var/lib/grafana
networks:
- app-network
restart: unless-stopped
volumes:
prometheus_data:
grafana_data:
9. 운영 환경 배포
9.1 프로덕션 배포 스크립트
deploy.sh
#!/bin/bash
set -e
echo "🚀 Starting production deployment..."
# 환경 설정
ENVIRONMENT=${1:-production}
VERSION=$(git rev-parse --short HEAD)
# 1. 이미지 빌드
echo "🔨 Building production images..."
docker compose -f compose.yml -f compose.prod.yml build
# 2. 데이터베이스 백업 (기존 데이터가 있는 경우)
echo "💾 Creating database backup..."
if docker compose ps database | grep -q "Up"; then
docker compose exec database mysqldump -u root -p${DB_ROOT_PASSWORD} --all-databases > backup_$(date +%Y%m%d_%H%M%S).sql
fi
# 3. 프로덕션 배포
echo "🎯 Deploying to production..."
docker compose -f compose.yml -f compose.prod.yml up -d
# 4. 헬스체크
echo "❤️ Running health checks..."
sleep 30
for i in {1..5}; do
if curl -f <http://localhost/api/health>; then
echo "✅ Deployment successful!"
break
else
echo "⏳ Waiting for services... (attempt $i/5)"
if [ $i -eq 5 ]; then
echo "❌ Deployment failed! Rolling back..."
docker compose -f compose.yml -f compose.prod.yml down
exit 1
fi
sleep 10
fi
done
echo "🎉 Production deployment completed!"
10. 트러블슈팅 가이드
10.1 자주 발생하는 문제들
서비스 간 통신 문제
# 네트워크 연결 확인
docker compose exec frontend ping backend
docker compose exec backend ping database
# DNS 해결 확인
docker compose exec frontend nslookup backend
# 포트 확인
docker compose exec backend netstat -tulpn
데이터베이스 연결 문제
# MySQL 상태 확인
docker compose exec database mysqladmin -u root -p status
# 연결 테스트
docker compose exec database mysql -u todouser -ptodopassword123 todoapp -e "SELECT 1;"
# 로그 확인
docker compose logs database
10.2 종합 진단 스크립트
#!/bin/bash
# debug.sh
echo "🔍 Full Stack Application Diagnosis"
echo "================================="
# 1. 서비스 상태
echo "📊 Service Status:"
docker compose ps
# 2. 네트워크 연결
echo -e "\\n🌐 Network Connectivity:"
docker compose exec -T frontend ping -c 2 backend 2>/dev/null && echo "✅ Frontend → Backend" || echo "❌ Frontend → Backend"
docker compose exec -T backend ping -c 2 database 2>/dev/null && echo "✅ Backend → Database" || echo "❌ Backend → Database"
docker compose exec -T backend ping -c 2 redis 2>/dev/null && echo "✅ Backend → Redis" || echo "❌ Backend → Redis"
# 3. 헬스체크
echo -e "\\n❤️ Health Checks:"
curl -s <http://localhost/api/health> > /dev/null && echo "✅ API Health" || echo "❌ API Health"
curl -s <http://localhost> > /dev/null && echo "✅ Frontend" || echo "❌ Frontend"
# 4. 리소스 사용량
echo -e "\\n💾 Resource Usage:"
docker stats --no-stream --format "table {{.Container}}\\t{{.CPUPerc}}\\t{{.MemUsage}}"
# 5. 로그 오류 검사
echo -e "\\n📜 Recent Errors:"
docker compose logs --tail=20 | grep -i error || echo "No errors found"
11. 베스트 프랙티스
11.1 개발 효율성 팁
# 자주 사용하는 명령어 별칭
alias dcup='docker compose up -d'
alias dcdown='docker compose down'
alias dclogs='docker compose logs -f'
alias dcbuild='docker compose build'
alias dcrestart='docker compose restart'
# 전체 스택 재시작 스크립트
#!/bin/bash
# restart-stack.sh
echo "🔄 Restarting full stack..."
docker compose down
docker compose build
docker compose up -d
echo "✅ Stack restarted successfully!"
11.2 보안 고려사항
// 입력 검증 강화
const Joi = require('joi');
const todoSchema = Joi.object({
title: Joi.string().min(1).max(255).required(),
description: Joi.string().max(1000).allow(''),
completed: Joi.boolean(),
priority: Joi.string().valid('low', 'medium', 'high').default('medium')
});
// Rate Limiting 세분화
const createLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1시간
max: 10, // 생성은 시간당 10개로 제한
message: '너무 많은 할 일을 생성했습니다. 잠시 후 다시 시도해주세요.'
});
11.3 성능 최적화
// React 성능 최적화
import React, { memo, useCallback, useMemo } from 'react';
const TodoItem = memo(({ todo, onToggle, onDelete }) => {
const handleToggle = useCallback(() => {
onToggle(todo.id, todo.completed);
}, [todo.id, todo.completed, onToggle]);
const formattedDate = useMemo(() => {
return new Date(todo.created_at).toLocaleDateString('ko-KR');
}, [todo.created_at]);
return (
<div className={`todo-card ${todo.completed ? 'completed' : ''}`}>
{/* 컴포넌트 내용 */}
</div>
);
});
유용한 명령어 모음
# 개발 워크플로우
alias ddev='docker compose up -d && docker compose logs -f'
alias dprod='docker compose -f compose.yml -f compose.prod.yml up -d'
alias dtest='docker compose exec backend npm test && docker compose exec frontend npm test'
alias dclean='docker compose down -v && docker system prune -f'
# 디버깅
alias dlogs='docker compose logs -f --tail=100'
alias dshell-backend='docker compose exec backend sh'
alias dshell-frontend='docker compose exec frontend sh'
alias dmysql='docker compose exec database mysql -u todouser -ptodopassword123 todoapp'
💡 마무리
- ✅ 5-tier 아키텍처 (Nginx + React + Node.js + MySQL + Redis) 구축
- ✅ 서비스 간 통신과 데이터 흐름 설계 및 구현
- ✅ 개발과 운영 환경의 설정 분리
- ✅ 모니터링, 로깅, 헬스체크 시스템 구현