GitHub + Docker + Python 통합 시스템 구축: 완벽 가이드|가나투데이

GitHub + Docker + Python 통합 시스템 구축: 완벽 가이드

왜 이 세 가지를 함께 사용해야 하는가?

현대적인 소프트웨어 개발은 단순히 코드를 작성하는 것을 넘어섭니다. 코드 버전 관리(GitHub) + 실행 환경 표준화(Docker) + 애플리케이션 로직(Python)이 하나의 시스템으로 통합되어야 진정한 효율성을 달성할 수 있습니다.

실제 문제 상황들

시나리오 1: 새로운 팀원 합류

  • 통합 전: "환경 설정에 3일 걸렸습니다. Python 버전, 라이브러리, 데이터베이스 설정..."
  • 통합 후: git clonedocker 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.example

1-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: bridge

docker-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: true

4-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. 자동 배포 워크플로우

.github/workflows/deploy.yml:

yaml

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 }}

단계 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

그린아프로