# Part 9: 프로덕션 레벨 API와 Docker (9주차) **⬅️ 이전 시간: [Part 8: FastAPI를 이용한 모델 서빙](./part_8_model_serving_with_fastapi.md)** | **➡️ 다음 시간: [Part 10: 전문가로 가는 길](./part_10_expert_path.md)** --- ## 📜 실습 코드 바로가기 - **`part_9_production_ready_api`**: [바로가기](./source_code/part_9_production_ready_api) - 본 파트에서 다루는 프로덕션 레벨의 FastAPI 프로젝트 구조(`app` 폴더), `Dockerfile`, `docker-compose.yml` 등의 전체 코드를 확인하고 직접 실행해볼 수 있습니다. --- 지난 시간에는 FastAPI를 이용해 '1인 식당'처럼 빠르게 AI 모델 API를 만들어보았습니다. 프로토타입으로는 훌륭하지만, 메뉴가 다양해지고(기능 추가) 여러 셰프가 협업해야 하는(팀 개발) 실제 '프랜차이즈 레스토랑' 환경에서는 한계가 명확합니다. 이번 주차에는 `main.py` 하나에 모든 것을 담던 방식에서 벗어나, 유지보수와 확장이 용이한 **프로덕션 레벨의 프로젝트 구조**로 우리 레스토랑을 리팩터링합니다. 또한, 어떤 백화점에 입점하든 동일한 맛과 서비스를 보장하는 **'밀키트([Docker](./glossary.md#docker) 컨테이너)'** 기술을 배워, 우리 AI 서비스를 세상 어디에든 쉽고 안정적으로 배포할 수 있는 능력을 갖추게 될 것입니다. --- ### 📖 9주차 학습 목표 - **프로젝트 구조화**: '1인 식당'에서 '프랜차이즈 본사'로 확장하며, 유지보수와 협업에 용이한 전문적인 프로젝트 구조를 이해합니다. - **관심사 분리**: API의 각 기능(설정, DB, 모델, 스키마, 라우터)을 독립적인 파일로 분리하고, APIRouter로 엔드포인트를 기능별('한식/중식 코너')로 모듈화합니다. - **[Docker](./glossary.md#docker) 개념 이해**: "제 컴퓨터에선 되는데요..." 문제를 해결하는 컨테이너 기술의 원리를 이해하고, 이미지/컨테이너/도커파일의 개념을 익힙니다. - **[Dockerfile](./glossary.md#dockerfile) 작성**: 'AI 레스토랑 밀키트 레시피'인 Dockerfile을 직접 작성하여 우리 앱을 이미지로 만듭니다. - **[Docker Compose](./glossary.md#docker-compose) 활용**: API 서버, 데이터베이스 등 여러 서비스를 명령어 하나로 관리하는 `docker-compose.yml`의 원리를 익힙니다. --- ### 1-2일차: 프로덕션 레벨 프로젝트 구조 설계 #### **왜 구조를 바꿔야 할까?** `main.py` 파일 하나에 모든 코드가 있는 것은, 주방장이 혼자 주문받고, 요리하고, 서빙하고, 계산까지 하는 동네 분식집과 같습니다. 처음엔 빠르지만, 손님이 많아지면 금방 한계에 부딪힙니다. > **💡 비유: '1인 식당'에서 '프랜차이즈 레스토랑'으로** > > 우리는 이제 레스토랑을 프랜차이즈화할 것입니다. 이를 위해서는 역할 분담이 필수적입니다. > - **`settings.py`**: 본사의 운영 방침 (환경 설정) > - **`database.py`**: 식자재 창고와의 연결 통로 (DB 접속) > - **`models.py`**: 창고에 보관된 식자재의 형태 정의 (DB 테이블 구조) > - **`schemas.py`**: 모든 지점에서 통일된 '주문서 양식' (데이터 형식 정의) > - **`crud.py`**: 식자재를 창고에서 꺼내고 넣는 '표준 작업 절차' (DB 처리 로직) > - **`routers/`**: '한식 코너', '중식 코너' 등 메뉴별로 분리된 주방 (기능별 API) > - **`main.py`**: 모든 코너를 총괄하고 손님을 맞는 '총지배인' #### **1단계: 프로젝트 폴더 및 기본 설정 파일 생성** 먼저 아래 구조에 따라 `fastapi_project` 폴더 및 하위 폴더들을 만듭니다. ``` /fastapi_project ├── app/ │ ├── __init__.py │ ├── main.py │ ├── settings.py │ ├── database.py │ ├── models.py │ ├── schemas.py │ ├── crud.py │ └── routers/ │ ├── __init__.py │ └── predictions.py ├── requirements.txt └── iris_model.pkl ``` **`app/settings.py` (운영 방침)**: 환경변수로 설정을 관리합니다. ```python from pydantic_settings import BaseSettings class Settings(BaseSettings): DATABASE_URL: str = "sqlite:///./iris_prediction.db" class Config: env_file = ".env" settings = Settings() ``` **`app/database.py` (창고 연결)**: 데이터베이스 연결을 설정합니다. ```python from sqlalchemy import create_engine from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker from .settings import settings engine = create_engine( settings.DATABASE_URL, connect_args={"check_same_thread": False} ) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) Base = declarative_base() ``` - **[SQLAlchemy](./glossary.md#sqlalchemy)**: 파이썬 코드로 데이터베이스와 상호작용할 수 있게 해주는 ORM(Object-Relational Mapping) 라이브러리입니다. SQL 쿼리를 직접 작성하지 않고, 파이썬 객체(모델)를 통해 DB 테이블을 조작할 수 있습니다. #### **2단계: 데이터 모델과 로직 분리** **`app/models.py` (식자재의 형태 정의)**: DB 테이블 구조를 정의합니다. ```python from sqlalchemy import Column, Integer, String, DateTime, Float from .database import Base import datetime class PredictionLog(Base): __tablename__ = "prediction_logs" id = Column(Integer, primary_key=True, index=True) input_features = Column(String) predicted_class = Column(String, index=True) confidence = Column(Float) created_at = Column(DateTime, default=datetime.datetime.utcnow) ``` **`app/schemas.py` (표준 주문서)**: API가 주고받을 데이터 형식을 정의합니다. ```python from pydantic import BaseModel from typing import List import datetime class PredictionRequest(BaseModel): features: List[float] class PredictionResponse(BaseModel): id: int input_features: str predicted_class: str confidence: float created_at: datetime.datetime class Config: from_attributes = True ``` **`app/crud.py` (표준 작업 절차)**: 데이터베이스 처리 함수를 정의합니다. ```python from sqlalchemy.orm import Session from . import models, schemas import json def create_prediction_log(db: Session, request: schemas.PredictionRequest, predicted_class: str, confidence: float): features_str = json.dumps(request.features) db_log = models.PredictionLog( input_features=features_str, predicted_class=predicted_class, confidence=confidence ) db.add(db_log) db.commit() db.refresh(db_log) return db_log ``` --- ### 3일차: 기능별 API 라우터 분리 **APIRouter**를 이용하면 '한식 코너', '중식 코너'처럼 기능별로 API를 분리할 수 있습니다. **`app/routers/predictions.py` (예측 코너 주방)** ```python from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session import joblib import numpy as np from .. import crud, schemas from ..database import SessionLocal router = APIRouter( prefix="/predict", tags=["predictions"] ) # 모델 로드 try: model = joblib.load("iris_model.pkl") class_names = ['setosa', 'versicolor', 'virginica'] except FileNotFoundError: model = None # 의존성 주입: '주방 보조'가 DB 연결을 가져다 줌 def get_db(): db = SessionLocal() try: yield db finally: db.close() @router.post("/", response_model=schemas.PredictionResponse) def create_prediction(request: schemas.PredictionRequest, db: Session = Depends(get_db)): if model is None: raise HTTPException(status_code=503, detail="모델을 로드할 수 없습니다.") # 예측 model_input = np.array(request.features).reshape(1, -1) prediction_idx = model.predict(model_input)[0] confidence = model.predict_proba(model_input)[0][prediction_idx] predicted_class = class_names[prediction_idx] # DB에 로그 저장 (CRUD 함수 사용) return crud.create_prediction_log(db, request, predicted_class, confidence) ``` - **[`Depends(get_db)`](./glossary.md#의존성-주입dependency-injection-di) (의존성 주입)**: `create_prediction`이라는 '셰프'가 요리를 시작하기 전에, FastAPI라는 '총지배인'이 `get_db`라는 '주방 보조'를 시켜 DB 연결이라는 '깨끗한 프라이팬'을 가져다주는 것과 같습니다. 셰프는 프라이팬을 어떻게 가져왔는지 신경 쓸 필요 없이 요리에만 집중할 수 있습니다. #### **최종 통합 (`app/main.py`)** 분리된 모든 조각을 '총지배인' `main.py`에서 하나로 조립합니다. ```python from fastapi import FastAPI from .database import engine, Base from .routers import predictions # DB 테이블 생성 Base.metadata.create_all(bind=engine) app = FastAPI( title="AI 레스토랑 (프랜차이즈 ver)", description="프로덕션 레벨 구조로 개선된 붓꽃 예측 API입니다." ) # "예측 코너" 라우터 포함 app.include_router(predictions.router) @app.get("/") def read_root(): return {"message": "AI 레스토랑에 오신 것을 환영합니다."} ``` --- ### 4일차: Docker로 우리 앱 포장하기 #### **왜 Docker를 사용해야 할까?** > **💡 비유: '어디서든 똑같은 맛을 내는 밀키트'** > > 우리 레스토랑의 레시피와 재료를 완벽하게 준비해도, 지점마다 주방 환경(OS, 라이브러리 버전 등)이 다르면 음식 맛이 달라질 수 있습니다. "제 주방(컴퓨터)에서는 되는데요?" 라는 문제는 개발자들의 흔한 악몽입니다. > > **[Docker](./glossary.md#docker)**는 우리 레스토랑의 모든 것(코드, 라이브러리, 설정)을 하나의 **'밀키트(이미지)'** 로 완벽하게 포장하는 기술입니다. 이 밀키트는 어떤 주방(서버)에서 데우든 항상 똑같은 환경에서, 똑같은 맛의 요리를 만들어냅니다. #### **Dockerfile 작성하기** `Dockerfile`은 이 밀키트를 만드는 '레시피'입니다. 프로젝트 최상위 폴더에 `Dockerfile`이라는 이름으로 파일을 만듭니다. ```dockerfile # 1. 베이스 이미지 선택 (기본 주방 키트 가져오기) FROM python:3.9-slim # 2. 작업 디렉토리 설정 (조리대 위치 지정) WORKDIR /app # 3. 의존성 파일 먼저 복사 (소스가 바뀌어도 의존성이 같으면 캐시 재사용) COPY ./requirements.txt . # 4. 의존성 설치 (필요한 조미료 채우기) RUN pip install --no-cache-dir -r requirements.txt # 5. 소스 코드 복사 (메인 레시피와 도구들 넣기) COPY ./app /app/app COPY ./iris_model.pkl /app/ # 6. 실행 명령어 (서버 매니저에게 가게 오픈 지시) # 0.0.0.0 주소는 '어느 손님이든 다 받으라'는 의미 CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "80"] ``` #### **Docker 이미지 빌드 및 실행** 터미널에서 `Dockerfile`이 있는 폴더로 이동한 뒤, 아래 명령어를 실행합니다. ```bash # 1. '프랜차이즈 1호점' 밀키트(이미지) 만들기 docker build -t franchise-restaurant:1.0 . # 2. 밀키트를 데워 레스토랑(컨테이너) 열기 # -p 8000:80 -> 내 컴퓨터 8000번 문과 컨테이너 80번 문을 연결 docker run -d -p 8000:80 --name my-first-restaurant franchise-restaurant:1.0 ``` --- ### 5일차: Docker Compose로 여러 서비스 관리하기 #### **왜 Docker Compose가 필요할까?** > **💡 비유: '푸드코트 전체를 한 번에 오픈하기'** > > 지금까지는 '레스토랑' 하나만 Docker로 열었습니다. 하지만 실제 서비스는 API 서버 외에도 데이터베이스, 캐시 서버 등 여러 컴포넌트가 함께 동작하는 '푸드코트'와 같습니다. > > **[Docker Compose](./glossary.md#docker-compose)**는 이 푸드코트의 전체 설계도(`docker-compose.yml`)와 같습니다. 각 코너(컨테이너)의 종류, 위치, 연결 관계를 모두 정의해두면, **`docker-compose up`** 이라는 명령어 한 번으로 전체 푸드코트를 열고 닫을 수 있습니다. #### **docker-compose.yml 작성하기** 프로젝트 최상위 폴더에 `docker-compose.yml` 파일을 만듭니다. ```yaml version: '3.8' # 파일 형식 버전 services: # 여기에 우리 푸드코트에 입점할 가게들 목록을 정의 # 1. API 서버 (우리의 메인 레스토랑) api: build: . # 현재 폴더의 Dockerfile을 사용해 이미지를 빌드 container_name: franchise_api ports: - "8000:80" # 내 컴퓨터 8000번과 컨테이너 80번 포트 연결 volumes: - ./app:/app/app # 소스 코드를 실시간으로 동기화 (개발 시 유용) environment: - DATABASE_URL=postgresql://user:password@db/mydatabase # 2. 데이터베이스 서버 (PostgreSQL 식자재 창고) db: image: postgres:13 # PostgreSQL 공식 이미지 사용 container_name: franchise_db volumes: - postgres_data:/var/lib/postgresql/data/ environment: - POSTGRES_USER=user - POSTGRES_PASSWORD=password - POSTGRES_DB=mydatabase volumes: # 데이터 영구 보관 장소 postgres_data: ``` #### **Docker Compose 실행** 터미널에서 `docker-compose.yml` 파일이 있는 폴더로 이동하여 실행합니다. ```bash # 푸드코트 전체 오픈 (백그라운드에서 실행) docker-compose up -d # 푸드코트 전체 폐점 (컨테이너, 네트워크 등 모두 정리) docker-compose down ``` 이제 우리는 명령어 한 번으로 복잡한 멀티 컨테이너 애플리케이션을 손쉽게 관리할 수 있게 되었습니다! --- ## 📝 9주차 요약 - **프로젝트 구조화**: `main.py` 하나에 있던 코드를 역할(라우터, 스키마, CRUD 등)에 따라 여러 파일로 분리하여 재사용성과 유지보수성을 높였습니다. - **APIRouter**: 기능별로 API 엔드포인트를 모듈화하여 프로젝트를 체계적으로 관리했습니다. - **Docker**: '밀키트' 비유를 통해 "제 컴퓨터에선 되는데요?" 문제를 해결하는 컨테이너 기술을 배웠고, `Dockerfile`로 우리 앱을 이미지로 만들었습니다. - **Docker Compose**: '푸드코트' 비유를 통해 여러 서비스(컨테이너)를 `docker-compose.yml` 파일 하나로 손쉽게 관리하는 방법을 익혔습니다. 이제 여러분은 단순히 모델을 만드는 것을 넘어, 실제 서비스로 배포하고 운영할 수 있는 견고한 기반을 다졌습니다. ## 🔍 9주차 연습 문제 **문제 1: 라우터 분리 연습** - 8주차 연습 문제 2번에서 만들었던 책 조회 API(`GET /books/{book_id}`)를 `routers/books.py` 파일로 분리하고, `main.py`에 포함시켜 보세요. - `books.py` 라우터의 `prefix`를 `/api/v1/books`로 설정하고, 태그는 `books`로 지정해보세요. **문제 2: Dockerfile 개선** - 현재 `Dockerfile`은 소스 코드가 바뀔 때마다 `pip install`을 다시 수행할 수 있습니다. 어떻게 하면 의존성이 바뀌지 않았을 때 `pip install` 과정을 건너뛰고 캐시를 사용할 수 있을까요? (`COPY` 명령어의 순서를 조정해보세요.) **문제 3: Docker Compose에 Redis 추가하기** - `docker-compose.yml` 파일에 **Redis** 캐시 서버를 추가해보세요. - 서비스 이름은 `cache`, 이미지는 공식 `redis:alpine` 이미지를 사용합니다. - 포트는 내 컴퓨터의 `6379`번과 컨테이너의 `6379`번을 연결하세요. **➡️ 다음 시간: [Part 10: 전문가로 가는 길](./part_10_expert_path.md)**