GitHub + Docker + Python 통합 시스템 구축: 완벽 가이드|가나투데이
GitHub + Docker + Python 통합 시스템 구축: 완벽 가이드
왜 이 세 가지를 함께 사용해야 하는가?
현대적인 소프트웨어 개발은 단순히 코드를 작성하는 것을 넘어섭니다. 코드 버전 관리(GitHub) + 실행 환경 표준화(Docker) + 애플리케이션 로직(Python)이 하나의 시스템으로 통합되어야 진정한 효율성을 달성할 수 있습니다.
실제 문제 상황들
시나리오 1: 새로운 팀원 합류
- 통합 전: "환경 설정에 3일 걸렸습니다. Python 버전, 라이브러리, 데이터베이스 설정..."
- 통합 후:
git clone→docker compose up→ 5분 만에 개발 시작
시나리오 2: 버그 재현
- 통합 전: "제 컴퓨터에서는 안 되는데, 그쪽에서는 되나요?"
- 통합 후: 동일한 Docker 환경에서 즉시 재현 가능
시나리오 3: 배포
- 통합 전: 수동으로 서버 설정, 의존성 설치, 환경 변수 설정...
- 통합 후: GitHub에 코드 푸시 → 자동 빌드 → 자동 배포
전체 시스템 아키텍처 이해하기
단계별 시스템 구축 가이드
단계 1: GitHub 저장소 초기 설정
1-1. 새 프로젝트 생성
bash
# 로컬에 프로젝트 디렉토리 생성
mkdir my-python-app
cd my-python-app
# Git 초기화
git init
# GitHub에서 새 저장소 생성 후
git remote add origin https://github.com/yourusername/my-python-app.git
```
#### 1-2. 프로젝트 구조 설계
```
my-python-app/
├── .github/
│ └── workflows/
│ ├── ci.yml # 자동 테스트
│ ├── docker-build.yml # Docker 이미지 빌드
│ └── deploy.yml # 자동 배포
├── app/
│ ├── __init__.py
│ ├── main.py # 메인 애플리케이션
│ ├── config.py # 설정 관리
│ ├── models.py # 데이터 모델
│ └── utils.py # 유틸리티 함수
├── tests/
│ ├── __init__.py
│ ├── test_main.py
│ └── test_utils.py
├── scripts/
│ ├── entrypoint.sh # Docker 시작 스크립트
│ └── wait-for-it.sh # 서비스 대기 스크립트
├── .github/
├── .gitignore
├── .dockerignore
├── Dockerfile
├── docker-compose.yml
├── docker-compose.prod.yml
├── requirements.txt
├── requirements-dev.txt
├── Makefile
├── README.md
└── .env.example1-3. 필수 설정 파일 생성
.gitignore:
gitignore
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
env/
venv/
ENV/
*.egg-info/
dist/
build/
# Docker
.dockerignore
# 환경 변수
.env
.env.local
.env.*.local
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# 테스트
.pytest_cache/
.coverage
htmlcov/
.tox/
# 로그
*.log
logs/.dockerignore:
dockerignore
# Git
.git
.gitignore
.github
# Python
__pycache__
*.pyc
*.pyo
*.pyd
.Python
env/
venv/
# 테스트
.pytest_cache
.coverage
htmlcov/
# 문서
*.md
docs/
# Docker
docker-compose*.yml
Dockerfile*
# IDE
.vscode
.idea
# 환경
.env*.env.example:
bash
# 애플리케이션 설정
APP_NAME=my-python-app
APP_ENV=development
DEBUG=True
SECRET_KEY=your-secret-key-here
# 데이터베이스
DATABASE_URL=postgresql://postgres:password@db:5432/myapp
DB_POOL_SIZE=5
# Redis
REDIS_URL=redis://redis:6379/0
# API Keys (예시)
OPENAI_API_KEY=your-api-key
AWS_ACCESS_KEY_ID=your-access-key
AWS_SECRET_ACCESS_KEY=your-secret-key
# 로깅
LOG_LEVEL=INFO단계 2: Python 애플리케이션 작성
2-1. 기본 Flask API 애플리케이션
app/main.py:
python
from flask import Flask, jsonify, request
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
import redis
import os
import logging
from datetime import datetime
# 로깅 설정
logging.basicConfig(
level=os.getenv('LOG_LEVEL', 'INFO'),
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
# Flask 앱 초기화
app = Flask(__name__)
# 설정
app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv('DATABASE_URL')
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'dev-secret-key')
# 데이터베이스 초기화
db = SQLAlchemy(app)
migrate = Migrate(app, db)
# Redis 연결
redis_client = redis.from_url(os.getenv('REDIS_URL', 'redis://localhost:6379/0'))
# 모델 정의
class Task(db.Model):
__tablename__ = 'tasks'
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text)
completed = db.Column(db.Boolean, default=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def to_dict(self):
return {
'id': self.id,
'title': self.title,
'description': self.description,
'completed': self.completed,
'created_at': self.created_at.isoformat(),
'updated_at': self.updated_at.isoformat()
}
# 헬스체크 엔드포인트
@app.route('/health')
def health_check():
"""시스템 상태 확인"""
try:
# 데이터베이스 연결 확인
db.session.execute('SELECT 1')
db_status = 'healthy'
except Exception as e:
logger.error(f"Database health check failed: {e}")
db_status = 'unhealthy'
try:
# Redis 연결 확인
redis_client.ping()
redis_status = 'healthy'
except Exception as e:
logger.error(f"Redis health check failed: {e}")
redis_status = 'unhealthy'
status_code = 200 if db_status == 'healthy' and redis_status == 'healthy' else 503
return jsonify({
'status': 'healthy' if status_code == 200 else 'unhealthy',
'timestamp': datetime.utcnow().isoformat(),
'services': {
'database': db_status,
'redis': redis_status
}
}), status_code
# API 엔드포인트
@app.route('/api/tasks', methods=['GET'])
def get_tasks():
"""모든 작업 조회"""
try:
# 캐시 확인
cached_tasks = redis_client.get('tasks:all')
if cached_tasks:
logger.info("Returning cached tasks")
return jsonify({'tasks': eval(cached_tasks), 'from_cache': True})
# 데이터베이스에서 조회
tasks = Task.query.all()
tasks_data = [task.to_dict() for task in tasks]
# 캐시 저장 (60초)
redis_client.setex('tasks:all', 60, str(tasks_data))
return jsonify({'tasks': tasks_data, 'from_cache': False})
except Exception as e:
logger.error(f"Error fetching tasks: {e}")
return jsonify({'error': str(e)}), 500
@app.route('/api/tasks', methods=['POST'])
def create_task():
"""새 작업 생성"""
try:
data = request.get_json()
if not data or 'title' not in data:
return jsonify({'error': 'Title is required'}), 400
task = Task(
title=data['title'],
description=data.get('description', '')
)
db.session.add(task)
db.session.commit()
# 캐시 무효화
redis_client.delete('tasks:all')
logger.info(f"Created task: {task.id}")
return jsonify(task.to_dict()), 201
except Exception as e:
db.session.rollback()
logger.error(f"Error creating task: {e}")
return jsonify({'error': str(e)}), 500
@app.route('/api/tasks/<int:task_id>', methods=['GET'])
def get_task(task_id):
"""특정 작업 조회"""
task = Task.query.get_or_404(task_id)
return jsonify(task.to_dict())
@app.route('/api/tasks/<int:task_id>', methods=['PUT'])
def update_task(task_id):
"""작업 수정"""
try:
task = Task.query.get_or_404(task_id)
data = request.get_json()
if 'title' in data:
task.title = data['title']
if 'description' in data:
task.description = data['description']
if 'completed' in data:
task.completed = data['completed']
db.session.commit()
# 캐시 무효화
redis_client.delete('tasks:all')
logger.info(f"Updated task: {task_id}")
return jsonify(task.to_dict())
except Exception as e:
db.session.rollback()
logger.error(f"Error updating task: {e}")
return jsonify({'error': str(e)}), 500
@app.route('/api/tasks/<int:task_id>', methods=['DELETE'])
def delete_task(task_id):
"""작업 삭제"""
try:
task = Task.query.get_or_404(task_id)
db.session.delete(task)
db.session.commit()
# 캐시 무효화
redis_client.delete('tasks:all')
logger.info(f"Deleted task: {task_id}")
return '', 204
except Exception as e:
db.session.rollback()
logger.error(f"Error deleting task: {e}")
return jsonify({'error': str(e)}), 500
@app.route('/')
def index():
"""루트 엔드포인트"""
return jsonify({
'app': os.getenv('APP_NAME', 'my-python-app'),
'version': '1.0.0',
'environment': os.getenv('APP_ENV', 'development'),
'endpoints': {
'health': '/health',
'tasks': '/api/tasks',
'task': '/api/tasks/<id>'
}
})
# CLI 명령어
@app.cli.command()
def init_db():
"""데이터베이스 초기화"""
db.create_all()
print("Database initialized!")
@app.cli.command()
def seed_db():
"""샘플 데이터 추가"""
sample_tasks = [
Task(title='Docker 학습', description='Docker 기초 완벽 이해'),
Task(title='GitHub Actions 설정', description='CI/CD 파이프라인 구축'),
Task(title='Python API 개발', description='RESTful API 완성')
]
db.session.add_all(sample_tasks)
db.session.commit()
print(f"Added {len(sample_tasks)} sample tasks!")
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, debug=os.getenv('DEBUG', 'False') == 'True')
```
**requirements.txt:**
```
Flask==3.0.0
Flask-SQLAlchemy==3.1.1
Flask-Migrate==4.0.5
psycopg2-binary==2.9.9
redis==5.0.1
gunicorn==21.2.0
python-dotenv==1.0.0
```
**requirements-dev.txt:**
```
-r requirements.txt
pytest==7.4.3
pytest-cov==4.1.0
pytest-flask==1.3.0
black==23.12.0
flake8==6.1.0
mypy==1.7.1단계 3: Docker 설정
3-1. Dockerfile 작성 (프로덕션용)
Dockerfile:\
dockerfile
# 멀티 스테이지 빌드
# 1단계: 베이스 이미지
FROM python:3.11-slim as base
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
WORKDIR /app
# 시스템 의존성 설치
RUN apt-get update && apt-get install -y \
postgresql-client \
curl \
&& rm -rf /var/lib/apt/lists/*
# 2단계: 의존성 빌더
FROM base as builder
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt
# 3단계: 최종 이미지
FROM base as final
# 빌더에서 설치한 패키지 복사
COPY --from=builder /root/.local /root/.local
ENV PATH=/root/.local/bin:$PATH
# 애플리케이션 코드 복사
COPY ./app /app
COPY ./scripts /scripts
# 스크립트 실행 권한
RUN chmod +x /scripts/*.sh
# 비루트 사용자 생성
RUN useradd -m -u 1000 appuser && \
chown -R appuser:appuser /app
USER appuser
# 포트 노출
EXPOSE 5000
# 헬스체크
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD curl -f http://localhost:5000/health || exit 1
# 엔트리포인트 설정
ENTRYPOINT ["/scripts/entrypoint.sh"]
# 기본 명령어 (프로덕션용)
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "4", "--timeout", "60", "--access-logfile", "-", "--error-logfile", "-", "main:app"]3-2. 엔트리포인트 스크립트
scripts/entrypoint.sh:
bash
#!/bin/bash
set -e
echo "🚀 Starting application..."
# 환경 변수 확인
if [ -z "$DATABASE_URL" ]; then
echo "❌ ERROR: DATABASE_URL is not set"
exit 1
fi
echo "⏳ Waiting for database..."
# wait-for-it 스크립트 사용 또는 간단한 루프
max_attempts=30
attempt=0
until PGPASSWORD=${DATABASE_URL##*:} psql -h "$(echo $DATABASE_URL | sed 's/.*@\(.*\):.*/\1/')" -U "$(echo $DATABASE_URL | sed 's/.*\/\/\(.*\):.*/\1/')" -c '\q' 2>/dev/null; do
attempt=$((attempt + 1))
if [ $attempt -ge $max_attempts ]; then
echo "❌ Database is not available after $max_attempts attempts"
exit 1
fi
echo "⏳ Attempt $attempt/$max_attempts: Waiting for database..."
sleep 2
done
echo "✅ Database is ready!"
# 데이터베이스 마이그레이션
echo "🔄 Running database migrations..."
flask db upgrade || {
echo "⚠️ Migration failed, initializing database..."
flask db init
flask db migrate -m "Initial migration"
flask db upgrade
}
echo "✅ Database migrations completed!"
# 프로덕션 환경에서 정적 파일 수집 (필요한 경우)
if [ "$APP_ENV" = "production" ]; then
echo "📦 Collecting static files..."
# flask collect-static 같은 명령어가 있다면
fi
echo "🎉 Application is ready to start!"
# 전달받은 명령어 실행
exec "$@"3-3. Docker Compose 설정
docker-compose.yml (개발 환경):
yaml
version: '3.8'
services:
# 웹 애플리케이션
web:
build:
context: .
target: base
command: python main.py # 개발 모드
volumes:
- ./app:/app
- ./scripts:/scripts
ports:
- "5000:5000"
env_file:
- .env
environment:
- FLASK_ENV=development
- FLASK_DEBUG=1
- DATABASE_URL=postgresql://postgres:password@db:5432/myapp
- REDIS_URL=redis://redis:6379/0
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
networks:
- app-network
restart: unless-stopped
# PostgreSQL 데이터베이스
db:
image: postgres:15-alpine
environment:
POSTGRES_DB: myapp
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_INITDB_ARGS: "--encoding=UTF-8"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./scripts/init-db.sql:/docker-entrypoint-initdb.d/init.sql
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
networks:
- app-network
restart: unless-stopped
# Redis 캐시
redis:
image: redis:7-alpine
command: redis-server --appendonly yes
volumes:
- redis_data:/data
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
networks:
- app-network
restart: unless-stopped
# 데이터베이스 관리 도구
adminer:
image: adminer:latest
ports:
- "8080:8080"
environment:
ADMINER_DEFAULT_SERVER: db
depends_on:
- db
networks:
- app-network
restart: unless-stopped
# Redis 관리 도구
redis-commander:
image: rediscommander/redis-commander:latest
environment:
- REDIS_HOSTS=local:redis:6379
ports:
- "8081:8081"
depends_on:
- redis
networks:
- app-network
restart: unless-stopped
volumes:
postgres_data:
driver: local
redis_data:
driver: local
networks:
app-network:
driver: bridgedocker-compose.prod.yml (운영 환경):
yaml
version: '3.8'
services:
web:
build:
context: .
target: final
command: gunicorn --bind 0.0.0.0:5000 --workers 4 --timeout 60 main:app
volumes: [] # 볼륨 마운트 제거
environment:
- FLASK_ENV=production
- FLASK_DEBUG=0
restart: always
deploy:
replicas: 2
resources:
limits:
cpus: '1'
memory: 512M
reservations:
cpus: '0.5'
memory: 256M
db:
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD} # 환경 변수에서 가져오기
volumes:
- postgres_data:/var/lib/postgresql/data
deploy:
resources:
limits:
cpus: '2'
memory: 2G
# 개발 도구 제거
adminer: null
redis-commander: null단계 4: GitHub Actions CI/CD 설정
4-1. 자동 테스트 워크플로우
.github/workflows/ci.yml:
yaml
name: CI - Test and Lint
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
env:
PYTHON_VERSION: '3.11'
jobs:
test:
name: Run Tests
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15-alpine
env:
POSTGRES_DB: test_db
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: 코드 체크아웃
uses: actions/checkout@v4
- name: Python 설정
uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: 'pip'
- name: 의존성 설치
run: |
python -m pip install --upgrade pip
pip install -r requirements-dev.txt
- name: 코드 포맷 검사 (Black)
run: black --check app/ tests/
- name: Linting (Flake8)
run: flake8 app/ tests/ --max-line-length=100
- name: 타입 검사 (MyPy)
run: mypy app/ --ignore-missing-imports
continue-on-error: true
- name: 테스트 실행
env:
DATABASE_URL: postgresql://postgres:password@localhost:5432/test_db
REDIS_URL: redis://localhost:6379/0
SECRET_KEY: test-secret-key
APP_ENV: testing
run: |
pytest tests/ -v --cov=app --cov-report=xml --cov-report=html
- name: 코드 커버리지 업로드
uses: codecov/codecov-action@v3
with:
files: ./coverage.xml
flags: unittests
name: codecov-umbrella
- name: 커버리지 리포트 저장
uses: actions/upload-artifact@v3
with:
name: coverage-report
path: htmlcov/
security:
name: Security Scan
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Python 보안 스캔 (Bandit)
run: |
pip install bandit
bandit -r app/ -f json -o bandit-report.json
continue-on-error: true
- name: 의존성 보안 검사
run: |
pip install safety
safety check --json
continue-on-error: true4-2. Docker 이미지 빌드 워크플로우
.github/workflows/docker-build.yml:
yaml
name: Docker Build and Push
on:
push:
branches: [ main ]
tags:
- 'v*.*.*'
pull_request:
branches: [ main ]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: 코드 체크아웃
uses: actions/checkout@v4
- name: Docker 메타데이터 추출
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix={{branch}}-
type=raw,value=latest,enable={{is_default_branch}}
- name: QEMU 설정
uses: docker/setup-qemu-action@v3
- name: Docker Buildx 설정
uses: docker/setup-buildx-action@v3
- name: GitHub Container Registry 로그인
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker Hub 로그인
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Docker 이미지 빌드 및 푸시
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: |
${{ steps.meta.outputs.tags }}
${{ secrets.DOCKER_USERNAME }}/my-python-app:latest
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
BUILD_DATE=${{ github.event.head_commit.timestamp }}
VCS_REF=${{ github.sha }}
- name: 이미지name: Docker Build and Push
on:
push:
branches: [ main ]
tags:
- 'v*.*.*'
pull_request:
branches: [ main ]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: 코드 체크아웃
uses: actions/checkout@v4
- name: Docker 메타데이터 추출
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix={{branch}}-
type=raw,value=latest,enable={{is_default_branch}}
- name: QEMU 설정
uses: docker/setup-qemu-action@v3
- name: Docker Buildx 설정
uses: docker/setup-buildx-action@v3
- name: GitHub Container Registry 로그인
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker Hub 로그인
name: Docker Build and Push
on:
push:
branches: [ main ]
tags:
- 'v*.*.*'
pull_request:
branches: [ main ]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: 코드 체크아웃
uses: actions/checkout@v4
- name: Docker 메타데이터 추출
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix={{branch}}-
type=raw,value=latest,enable={{is_default_branch}}
- name: QEMU 설정
uses: docker/setup-qemu-action@v3
- name: Docker Buildx 설정
uses: docker/setup-buildx-action@v3
- name: GitHub Container Registry 로그인
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker Hub 로그인
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Docker 이미지 빌드 및 푸시
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: |
${{ steps.meta.outputs.tags }}
${{ secrets.DOCKER_USERNAME }}/my-python-app:latest
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
BUILD_DATE=${{ github.event.head_commit.timestamp }}
VCS_REF=${{ github.sha }}
- name: 이미지 if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Docker 이미지 빌드 및 푸시
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: |
${{ steps.meta.outputs.tags }}
${{ secrets.DOCKER_USERNAME }}/my-python-app:latest
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
BUILD_DATE=${{ github.event.head_commit.timestamp }}
VCS_REF=${{ github.sha }}
- name: 이미지
스캔 (Trivy) uses: aquasecurity/trivy-action@master with: image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest format: 'sarif' output: 'trivy-results.sarif' continue-on-error: true
- name: 스캔 결과 업로드
uses: github/codeql-action/upload-sarif@v2
with:
sarif_file: 'trivy-results.sarif'
continue-on-error: true
4-2. 자동 배포 워크플로우
4-3. 자동 배포 워크플로우name: Deploy to Production on: push: tags: - 'v*.*.*' workflow_dispatch: # 수동 트리거 jobs: deploy: name: Deploy to Server runs-on: ubuntu-latest environment: production steps: - name: 코드 체크아웃 uses: actions/checkout@v4 - name: SSH로 서버 배포 uses: appleboy/ssh-action@v1.0.0 with: host: ${{ secrets.SERVER_HOST }} username: ${{ secrets.SERVER_USER }} key: ${{ secrets.SSH_PRIVATE_KEY }} port: ${{ secrets.SERVER_PORT }} script: | cd /opt/my-python-app # 최신 코드 가져오기 git pull origin main # Docker 이미지 업데이트 docker compose pull # 서비스 재시작 (무중단 배포) docker compose up -d --no-deps --build web # 이전 이미지 정리 docker image prune -f # 헬스체크 sleep 10 curl -f http://localhost:5000/health || exit 1 echo "✅ Deployment completed successfully!" - name: Slack 알림 if: always() uses: 8398a7/action-slack@v3 with: status: ${{ job.status }} text: | 배포 ${{ job.status }} 커밋: ${{ github.sha }} 작성자: ${{ github.actor }} webhook_url: ${{ secrets.SLACK_WEBHOOK }}.github/workflows/deploy.yml:
yaml
단계 5: 로컬 개발 워크플로우
5-1. Makefile로 명령어 단순화
Makefile:
.PHONY: help build up down restart logs shell test clean migrate seed
# 기본 타겟
help:
@echo "사용 가능한 명령어:"
@echo " make build - Docker 이미지 빌드"
@echo " make up - 모든 서비스 시작"
@echo " make down - 모든 서비스 중지"
@echo " make restart - 서비스 재시작"
@echo " make logs - 로그 확인"
@echo " make shell - 웹 컨테이너 쉘 접속"
@echo " make test - 테스트 실행"
@echo " make migrate - DB 마이그레이션"
@echo " make seed - 샘플 데이터 추가"
@echo " make clean - 컨테이너 및 볼륨 삭제"
# 이미지 빌드
build:
docker compose build
# 서비스 시작
up:
docker compose up -d
@echo "✅ 서비스가 시작되었습니다!"
@echo "📊 애플리케이션: http://localhost:5000"
@echo "🗄️ Adminer: http://localhost:8080"
@echo "🔴 Redis Commander: http://localhost:8081"
# 서비스 중지
down:
docker compose down
# 서비스 재시작
restart:
docker compose restart
# 로그 확인
logs:
docker compose logs -f
# 특정 서비스 로그
logs-web:
docker compose logs -f web
logs-db:
docker compose logs -f db
# 웹 컨테이너 쉘 접속
shell:
docker compose exec web bash
# Python 쉘
python-shell:
docker compose exec web python
# 테스트 실행
test:
docker compose exec web pytest tests/ -v
# 커버리지 포함 테스트
test-cov:
docker compose exec web pytest tests/ -v --cov=app --cov-report=html
# DB 마이그레이션
migrate:
docker compose exec web flask db upgrade
# 마이그레이션 생성
migrate-create:
docker compose exec web flask db migrate -m "$(msg)"
# 샘플 데이터 추가
seed:
docker compose exec web flask seed-db
# DB 초기화
init-db:
docker compose exec web flask init-db
# 코드 포맷팅
format:
docker compose exec web black app/ tests/
# Lint 검사
lint:
docker compose exec web flake8 app/ tests/
# 전체 정리
clean:
docker compose down -v
docker system prune -f
# 운영 환경 배포
deploy-prod:
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
# 개발 환경 초기 설정
setup:
cp .env.example .env
docker compose build
docker compose up -d
sleep 5
docker compose exec web flask init-db
docker compose exec web flask seed-db
@echo "✅ 개발 환경 설정 완료!"
5-2. 개발 워크플로우 예시
# 1. 프로젝트 클론 및 초기 설정
git clone https://github.com/yourusername/my-python-app.git
cd my-python-app
make setup
# 2. 브랜치 생성하여 작업
git checkout -b feature/add-user-auth
# 3. 코드 수정 후 테스트
make test
# 4. 코드 포맷팅 및 Lint
make format
make lint
# 5. 커밋 및 푸시
git add .
git commit -m "feat: Add user authentication"
git push origin feature/add-user-auth
# 6. GitHub에서 Pull Request 생성
# → CI가 자동으로 테스트 실행
# 7. 리뷰 후 main 브랜치에 머지
# → Docker 이미지가 자동으로 빌드되어 레지스트리에 푸시
# 8. 태그 생성하여 배포
git tag -a v1.0.0 -m "Release version 1.0.0"
git push origin v1.0.0
# → 자동 배포 워크플로우가 실행됨
단계 6: 실전 테스트 작성
tests/test_main.py:
import pytest
from app.main import app, db, Task
@pytest.fixture
def client():
"""테스트 클라이언트 생성"""
app.config['TESTING'] = True
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:'
with app.test_client() as client:
with app.app_context():
db.create_all()
yield client
db.drop_all()
def test_health_check(client):
"""헬스체크 엔드포인트 테스트"""
response = client.get('/health')
assert response.status_code == 200
data = response.get_json()
assert 'status' in data
def test_index(client):
"""루트 엔드포인트 테스트"""
response = client.get('/')
assert response.status_code == 200
data = response.get_json()
assert 'app' in data
assert 'endpoints' in data
def test_create_task(client):
"""작업 생성 테스트"""
response = client.post('/api/tasks', json={
'title': 'Test Task',
'description': 'Test Description'
})
assert response.status_code == 201
data = response.get_json()
assert data['title'] == 'Test Task'
assert 'id' in data
def test_get_tasks(client):
"""작업 목록 조회 테스트"""
# 작업 생성
client.post('/api/tasks', json={'title': 'Task 1'})
client.post('/api/tasks', json={'title': 'Task 2'})
# 목록 조회
response = client.get('/api/tasks')
assert response.status_code == 200
data = response.get_json()
assert len(data['tasks']) == 2
def test_update_task(client):
"""작업 수정 테스트"""
# 작업 생성
create_response = client.post('/api/tasks', json={'title': 'Original'})
task_id = create_response.get_json()['id']
# 수정
response = client.put(f'/api/tasks/{task_id}', json={
'title': 'Updated',
'completed': True
})
assert response.status_code == 200
data = response.get_json()
assert data['title'] == 'Updated'
assert data['completed'] is True
def test_delete_task(client):
"""작업 삭제 테스트"""
# 작업 생성
create_response = client.post('/api/tasks', json={'title': 'To Delete'})
task_id = create_response.get_json()['id']
# 삭제
response = client.delete(f'/api/tasks/{task_id}')
assert response.status_code == 204
# 삭제 확인
get_response = client.get(f'/api/tasks/{task_id}')
assert get_response.status_code == 404
단계 7: 문서화
README.md:
# My Python App
Flask 기반 RESTful API 애플리케이션
## 기능
- ✅ CRUD API
- 🗄️ PostgreSQL 데이터베이스
- 🔴 Redis 캐싱
- 🐳 Docker 컨테이너화
- 🚀 GitHub Actions CI/CD
- 📊 헬스체크 및 모니터링
## 빠른 시작
### 사전 요구사항
- Docker & Docker Compose
- Git
### 설치 및 실행
```bash
# 저장소 클론
git clone https://github.com/yourusername/my-python-app.git
cd my-python-app
# 초기 설정
make setup
# 애플리케이션 접속
open http://localhost:5000
개발 가이드
로컬 개발
# 서비스 시작
make up
# 로그 확인
make logs
# 테스트 실행
make test
# 코드 포맷팅
make format
API 엔드포인트
GET /- API 정보GET /health- 헬스체크GET /api/tasks- 작업 목록 조회POST /api/tasks- 작업 생성GET /api/tasks/<id>- 특정 작업 조회PUT /api/tasks/<id>- 작업 수정DELETE /api/tasks/<id>- 작업 삭제
배포
# 운영 환경 배포
make deploy-prod
라이선스
MIT
## 완성된 시스템의 이점
이제 여러분은 다음을 얻게 됩니다:
1. **자동화된 개발 워크플로우**
- 코드 푸시 → 자동 테스트 → 자동 빌드 → 자동 배포
2. **일관된 환경**
- 로컬, 테스트, 운영 환경이 동일
3. **빠른 협업**
- 새 팀원이 5분 만에 개발 시작 가능
4. **안정적인 배포**
- 테스트를 통과한 코드만 배포
- 롤백 간단 (이전 Docker 이미지로 복원)
5. **확장 가능한 구조**
- 마이크로서비스로 쉽게 확장
- 클라우드 플랫폼으로 쉽게 이전
이제 실제로 위의 코드를 따라 해보시고, 여러분만의 프로젝트를 구축해보세요!
#가나 투데이 #ganatoday
그린아프로




