# Part 8: FastAPI를 이용한 모델 서빙

**⬅️ 이전 시간: [Part 7.5: 강화학습 (Reinforcement Learning)](../07_deep_learning/part_7.5_reinforcement_learning.md)**
**➡️ 다음 시간: [Part 9: 프로덕션 레벨 API와 Docker](../09_production_ready_api/part_9_production_ready_api.md)**

---

## 1. 학습 목표 (Learning Objectives)

이번 파트가 끝나면, 여러분은 다음을 할 수 있게 됩니다.

- API의 개념을 '레스토랑 주문'에 비유하여 설명할 수 있습니다.
- FastAPI를 사용하여 기본적인 웹 서버를 구축하고 `uvicorn`으로 실행할 수 있습니다.
- `lifespan` 이벤트를 사용하여 서버 시작 시 머신러닝 모델을 효율적으로 로드할 수 있습니다.
- Pydantic을 사용하여 API의 요청/응답 데이터 형식을 정의하고 자동으로 검증할 수 있습니다.
- FastAPI의 자동 생성 문서(Swagger UI)를 통해 브라우저에서 직접 API를 테스트하고 디버깅할 수 있습니다.

## 2. 핵심 키워드 (Keywords)

`모델 서빙(Model Serving)`, `API`, `FastAPI`, `uvicorn`, `Pydantic`, `경로 매개변수(Path Parameter)`, `요청 본문(Request Body)`, `Swagger UI`, `lifespan`

## 3. 도입: 나만 알던 맛집 레시피, 세상에 공개하기 (Introduction)

지금까지 우리는 Scikit-learn과 PyTorch로 강력한 AI 모델, 즉 세상에 없는 '비법 레시피'를 개발했습니다. 하지만 이 레시피를 내 서랍 속에만 둔다면 아무도 그 맛을 볼 수 없습니다.

> **API 서버는 이 비법 레시피로 요리를 만들어 손님에게 판매하는 '레스토랑'을 차리는 과정입니다.**
> 손님(클라이언트)은 주방(모델 서버)이 어떻게 돌아가는지 몰라도, 메뉴판(API 문서)을 보고 주문하면 맛있는 요리(예측 결과)를 받을 수 있습니다.

이번 주차에는 파이썬의 최신 웹 프레임워크인 **FastAPI**를 이용해, 빠르고 세련된 'AI 레스토랑'을 여는 방법을 배워보겠습니다.

> [!TIP]
> 본 파트의 모든 예제 코드는 `../../source_code/part_8_fastapi_project` 폴더에서 직접 실행하고 수정해볼 수 있습니다.

---

## 4. FastAPI로 우리의 AI 레스토랑 개점하기

> **🎯 1-2일차 목표:** FastAPI와 uvicorn으로 서버를 실행하고, Pydantic으로 데이터 형식을 정의합니다.

### 4-1. 왜 FastAPI일까?
- **엄청난 속도**: 주문이 밀려들어도 막힘없이 처리하는 '베테랑 직원'처럼 매우 빠릅니다.
- **쉬운 메뉴 관리**: '레시피(코드)'가 간결하고 명확해서, 실수를 줄이고 새로운 메뉴를 빠르게 추가하기 쉽습니다.
- **스마트 메뉴판 자동 생성**: 코드를 짜면 손님들이 직접 보고 테스트할 수 있는 자동 대화형 문서(Swagger UI)를 공짜로 만들어줍니다.

### 4-2. 서버 실행과 데이터 형식 정의
먼저 FastAPI와 서버 매니저 `uvicorn`을 설치합니다.
```bash
pip install fastapi "uvicorn[standard]"
```
이제 `main.py` 파일에 레스토랑의 기본 틀을 잡고, 손님의 '주문서 양식'을 **Pydantic**으로 정의합니다.

```python
# main.py
from fastapi import FastAPI
from pydantic import BaseModel
from typing import List

# 1. Pydantic으로 '주문서 양식' 정의
class IrisFeatures(BaseModel):
    features: List[float]

    class Config: # 스마트 메뉴판에 보여줄 주문 예시
        json_schema_extra = {"example": {"features": [5.1, 3.5, 1.4, 0.2]}}

# 2. FastAPI 인스턴스 생성 (레스토랑 개점)
app = FastAPI()

# 3. API 엔드포인트 정의 (메뉴판에 메뉴 추가)
@app.get("/")
def read_root():
    return {"message": "어서오세요! 붓꽃 품종 예측 레스토랑입니다!"}

# 주문(POST 요청)을 받는 엔드포인트 - 아직 내용은 비어있음
@app.post("/predict")
def predict_iris(data: IrisFeatures):
    return {"주문 접수 완료": data.features}
```
- `@app.get("/")`: 손님이 가게 정문(`/`)으로 들어와 **"여기는 뭐하는 곳인가요?"(GET 요청)**라고 물어보면 응답합니다.
- `@app.post("/predict")`: `/predict` 창구에서는 **"이 재료로 요리해주세요"(POST 요청)**라는 주문을 받습니다.
- `data: IrisFeatures`: 손님의 주문(JSON)을 우리가 정의한 `IrisFeatures` 양식에 맞춰 자동으로 확인하고, 형식이 틀리면 FastAPI가 알아서 오류를 보냅니다.

이제 터미널에서 서버를 실행합니다. `--reload`는 코드가 바뀔 때마다 서버를 자동으로 재시작해주는 편리한 옵션입니다.
```bash
uvicorn main:app --reload
```
서버 실행 후, 웹 브라우저에서 `http://127.0.0.1:8000` 주소로 접속하여 환영 메시지를 확인하세요.

---

## 5. 예측 API 구현: 레스토랑의 시그니처 메뉴 만들기

> **🎯 3-4일차 목표:** 6주차에서 만든 붓꽃 예측 모델을 FastAPI에 탑재하여, 실제 예측 결과를 반환하는 API를 완성합니다.

### 5-1. 모델 로딩 최적화: `lifespan` 이벤트
무거운 주방기구(모델)를 손님이 올 때마다 켜는 것은 비효율적입니다. 서버가 시작될 때 **단 한번만** 모델을 메모리에 로드하도록 `lifespan` 이벤트를 사용합니다.

```python
# main.py (수정 및 추가)
from contextlib import asynccontextmanager
import joblib
import numpy as np

# ... (IrisFeatures 정의는 그대로) ...

MODELS = {} # 모델을 저장할 딕셔너리

@asynccontextmanager
async def lifespan(app: FastAPI):
    # 서버 시작 시 (가게 오픈)
    print("레스토랑 오픈: 모델을 준비합니다.")
    MODELS["iris_model"] = joblib.load("iris_model.pkl")
    print("모델 준비 완료!")
    
    yield # 여기서 애플리케이션이 실행됨

    # 서버 종료 시 (가게 마감)
    MODELS.clear()
    print("레스토랑 마감")

app = FastAPI(lifespan=lifespan)
# ... (@app.get("/") 정의는 그대로) ...
```

### 5-2. 예측 엔드포인트 완성
이제 `/predict` 창구에서 실제 요리(예측)를 하고 결과를 반환하도록 코드를 완성합니다.

```python
# main.py (@app.post("/predict") 수정)

@app.post("/predict")
def predict_iris(data: IrisFeatures):
    """
    붓꽃의 특징 4가지를 주문서로 받아, 예측된 품종과 신뢰도 점수를 반환합니다.
    """
    model = MODELS["iris_model"]
    
    # 주방(모델)이 알아듣는 형태로 재료 손질
    model_input = np.array(data.features).reshape(1, -1)
    
    # 요리(예측) 시작
    prediction_idx = model.predict(model_input)[0]
    prediction_proba = model.predict_proba(model_input)[0]

    # 결과 플레이팅
    class_names = ['setosa', 'versicolor', 'virginica']
    class_name = class_names[prediction_idx]
    confidence = prediction_proba[prediction_idx]

    return {
        "predicted_class": class_name,
        "confidence": float(confidence)
    }
```
> **[필수]** 6주차 실습에서 저장한 `iris_model.pkl` 파일을 `main.py`와 같은 폴더에 준비하고, `scikit-learn`, `joblib`을 설치해주세요.
> `pip install scikit-learn joblib numpy`

---

## 6. 캡스톤 프로젝트 연계 미니 프로젝트: 나만의 AI 모델 API로 서빙하기

이론은 충분합니다. 이제 직접 'AI 레스토랑'의 오너가 되어, 여러분이 직접 만든 모델을 세상에 선보일 시간입니다. 이번 미니 프로젝트는 6장, 7장에서 직접 만들었던 모델 중 하나를 골라, FastAPI를 사용해 실제 호출 가능한 API로 만드는 전체 과정을 경험합니다.

### 프로젝트 목표
- 이전 챕터에서 직접 학습시킨 나만의 모델(Scikit-learn 또는 PyTorch)을 API로 서빙할 수 있습니다.
- 모델의 입력과 출력에 맞는 Pydantic 모델을 직접 설계하고 정의할 수 있습니다.
- `lifespan` 이벤트를 사용하여, Scikit-learn 모델(.pkl)과 PyTorch 모델(.pth)을 각각의 방식에 맞게 로드하는 코드를 작성할 수 있습니다.
- FastAPI의 자동 문서(`/docs`)를 사용하여, 직접 만든 API를 테스트하고 다른 사람에게 사용법을 공유할 수 있습니다.

### 선택 가능한 모델 (아래 중 1개 선택)
1.  **[6장] 붓꽃 품종 분류 모델 (`iris_model.pkl`)**: 가장 간단한 옵션. 본문 예제와 거의 유사합니다.
2.  **[7장] IMDB 영화 리뷰 감성 분석 모델 (`sentiment_model.pth`)**: PyTorch 모델을 서빙하는 실전 경험을 쌓을 수 있습니다.
3.  **[7.4장] GNN 친구 추천 모델 (GCN 모델)**: 조금 더 도전적인 과제. 그래프 데이터를 입력으로 받아 추천 목록을 반환하는 API를 설계해봅니다.

### 개발 과정 (IMDB 감성 분석 모델 기준)
1.  **프로젝트 구조 설정**:
    - `my_api/` 폴더를 만들고, 7장에서 학습시킨 `sentiment_model.pth`와 토크나이저(Tokenizer) 관련 파일(`vocab.pkl` 등)을 복사해 넣습니다.
    - `main.py` 파일을 생성합니다.

2.  **Pydantic 모델 정의 (`main.py`)**:
    ```python
    from pydantic import BaseModel

    class ReviewRequest(BaseModel):
        text: str

        class Config:
            json_schema_extra = {"example": {"text": "This movie was fantastic!"}}

    class SentimentResponse(BaseModel):
        sentiment: str
        score: float
    ```

3.  **PyTorch 모델 로딩 로직 구현 (`main.py`)**:
    - 7장에서 사용했던 PyTorch 모델 클래스(`SentimentClassifier`)와 토크나이저 로딩 코드를 가져옵니다.
    - `lifespan` 컨텍스트 매니저 안에 모델과 토크나이저를 전역 변수로 로드하는 코드를 작성합니다. `model.load_state_dict()`, `model.eval()`을 사용하는 것을 잊지 마세요.

4.  **예측 엔드포인트 구현 (`main.py`)**:
    ```python
    @app.post("/predict", response_model=SentimentResponse)
    def predict_sentiment(request: ReviewRequest):
        # 1. 입력 텍스트를 토크나이저로 전처리
        # 2. 전처리된 데이터를 PyTorch 텐서로 변환
        # 3. 모델에 입력하여 예측 수행 (with torch.no_grad():)
        # 4. 모델의 출력(logit)을 확률(softmax)로 변환하고, 긍정/부정 레이블 결정
        # 5. SentimentResponse 형식에 맞춰 결과 반환
        ...
        return SentimentResponse(sentiment=label, score=prob)
    ```

5.  **서버 실행 및 테스트**:
    - `uvicorn main:app --reload`로 서버를 실행합니다.
    - 브라우저에서 `http://127.0.0.1:8000/docs`에 접속합니다.
    - `/predict` 엔드포인트의 `Try it out` 기능을 사용하여, 여러 가지 영화 리뷰 텍스트를 입력하고 모델이 감성을 잘 예측하는지 실시간으로 테스트합니다.

### 캡스톤 프로젝트 연계 방안
이 미니 프로젝트는 15장 캡스톤 프로젝트의 2단계, **'핵심 기능 개발'**의 축소판입니다. 여기서 작성한 코드는 여러분의 최종 프로젝트에서 그대로 재사용되거나 확장될 수 있습니다. 예를 들어, 지금 만든 감성 분석 API에 사용자 인증 기능을 추가하거나, 데이터베이스와 연동하여 예측 결과를 저장하는 식으로 발전시킬 수 있습니다.

---

## ⚠️ What Could Go Wrong? (토론 주제)

FastAPI를 사용하면 매우 빠르게 API를 만들 수 있지만, 실제 프로덕션 환경에서는 간단한 예제 코드에서 보이지 않던 문제들이 발생합니다. 다음 상황들에 대해 함께 토론해보세요.

1.  **`async` 키워드의 환상**
    -   **상황**: 한 개발자가 무거운 머신러닝 모델의 예측 속도를 높이기 위해, 기존 `def predict(...):`를 `async def predict(...):`로 바꿨습니다. 그는 `async` 키워드만 붙이면 모델 예측이 자동으로 비동기 처리되어 서버의 다른 요청을 막지 않을 것이라고 기대했습니다. 하지만 여러 요청이 동시에 들어오자 서버는 여전히 응답이 느려졌습니다.
    -   **토론**:
        -   왜 `async` 키워드를 붙이는 것만으로는 CPU를 많이 사용하는 작업(CPU-bound, 예: `model.predict()`)이 빨라지지 않을까요? I/O-bound 작업과 CPU-bound 작업의 근본적인 차이는 무엇일까요?
        -   무거운 모델 예측이 전체 서버를 차단(blocking)하는 것을 막기 위한 현실적인 해결책은 무엇이 있을까요? (예: `FastAPI.run_in_threadpool`의 역할, Celery와 같은 별도 작업 큐(Task Queue)의 필요성)

2.  **모델 로딩 시점의 딜레마**
    -   **상황**: 새로운 대용량 언어 모델(LLM)을 `lifespan` 이벤트에 추가했더니, 서버가 시작되는 데 2분이 넘게 걸리기 시작했습니다. 이로 인해 개발 환경에서 코드를 수정할 때마다 재시작을 기다리는 시간이 길어졌고, 클라우드 배포 시스템의 헬스 체크(health check) 시간 초과로 배포가 실패하는 문제가 발생했습니다.
    -   **토론**:
        -   서버 시작 시 모델을 로드하는 방식(`lifespan`)의 장점과 위와 같은 상황에서의 단점은 무엇일까요?
        -   긴 모델 로딩 시간을 완화할 수 있는 전략들에는 어떤 것들이 있을까요? (예: 모델 최적화/양자화, 첫 요청 시 모델을 로드하는 'Lazy Loading' 패턴, Triton 같은 전문 추론 서버의 역할)

3.  **전역 변수와 동시성 문제**
    -   **상황**: 여러 버전의 모델을 서빙하기 위해, 실행 중에 모델을 교체하는 `/load_model`이라는 API 엔드포인트를 새로 만들었습니다. 이 엔드포인트는 전역 변수인 `MODELS` 딕셔너리의 모델 객체를 새로운 것으로 바꿉니다. 테스트 시에는 잘 동작했지만, 실제 서비스에서는 가끔 사용자들이 엉뚱한 버전의 모델로부터 예측 결과를 받는 문제가 발생했습니다.
    -   **토론**:
        -   웹 서버처럼 여러 요청이 동시에 처리되는 환경에서 `MODELS`와 같은 전역 변수를 직접 수정하는 것은 어떤 위험을 초래할까요? '경쟁 상태(Race Condition)'란 무엇일까요?
        -   여러 모델을 안전하게 관리하고 서빙하기 위한 더 나은 설계 패턴은 무엇이 있을까요? (예: `lifespan`에서 모든 모델을 불러두고 경로 매개변수로 선택, 모델별로 별도의 서버/컨테이너로 배포)

4.  **Pydantic을 생략했을 때의 비용**
    -   **상황**: 개발 속도를 높이기 위해, 한 팀원이 새로운 엔드포인트를 만들면서 Pydantic 모델 정의를 생략하고, 대신 `await request.json()`으로 원시(raw) 데이터를 직접 다루기로 결정했습니다. 얼마 후, 이 엔드포인트는 간헐적으로 `KeyError`나 `TypeError`를 발생시키며 프로덕션 환경에서 실패하기 시작했습니다.
    -   **토론**:
        -   Pydantic은 단순히 데이터 타입을 변환해주는 것 외에, 우리를 위해 어떤 '보이지 않는' 중요한 일들을 해주고 있었을까요? (예: 필수 필드 검사, 타입 강제, 상세한 오류 메시지 리포팅 등)
        -   Pydantic을 사용하여 명확한 데이터 계약(Data Contract)을 정의하는 것이 프론트엔드와 백엔드 개발자 간의 협업에 어떻게 도움이 될까요?

## 7. 되짚어보기 (Summary)

이번 주에는 FastAPI를 이용하여 머신러닝 모델을 API로 만드는 첫걸음을 떼었습니다.

- **AI 레스토랑**: 모델을 '레시피', API 서버를 '레스토랑'에 비유하여 모델 서빙의 개념을 이해했습니다.
- **FastAPI와 Uvicorn**: 파이썬 웹 서버를 구축하고 실행하는 방법을 배웠습니다.
- **Pydantic과 lifespan**: API의 데이터 형식을 강제하고, 서버 시작 시 모델을 효율적으로 로드하는 방법을 익혔습니다.
- **Swagger UI**: `/docs`의 '스마트 메뉴판'을 통해 API를 쉽고 빠르게 테스트하는 강력한 기능을 체험했습니다.

---

## 8. 트러블슈팅 (Troubleshooting)

- **`uvicorn` 실행 시 `ModuleNotFoundError: No module named 'main'` 오류가 발생하나요?**
  - `uvicorn main:app` 명령은 현재 터미널이 위치한 디렉토리에서 `main.py` 파일을 찾으라는 의미입니다. `main.py` 파일이 있는 디렉토리로 이동한 후 다시 명령을 실행해보세요.
- **`/docs` 접속 시 `{"detail":"Not Found"}`가 표시되나요?**
  - 이는 주로 URL 주소를 잘못 입력했을 때 발생합니다. `http://12.0.0.1:8000/docs` 가 맞는지, 끝에 `/`가 빠지지는 않았는지 다시 한번 확인해보세요.
- **`Try it out` 실행 시 `422 Unprocessable Entity` 오류가 발생하나요?**
  - FastAPI가 Pydantic 모델을 통해 요청 본문을 검증했지만, 형식이 맞지 않아 처리할 수 없다는 의미입니다. 예를 들어, `IrisFeatures` 모델은 `{"features": [1.0, 2.0, 3.0, 4.0]}` 형태의 JSON을 기대하는데, `{"feature": ...}` 와 같이 키 이름을 잘못 입력했거나 리스트의 요소 개수가 맞지 않는 경우 발생합니다.
- **모델을 로드할 때 `FileNotFoundError: [Errno 2] No such file or directory: 'iris_model.pkl'` 오류가 발생하나요?**
  - `joblib.load()`가 `iris_model.pkl` 파일을 찾지 못한 경우입니다. `main.py`를 실행하는 위치 기준으로 `iris_model.pkl` 파일이 같은 폴더에 있는지 확인하세요. 만약 다른 위치에 있다면, 절대 경로 또는 올바른 상대 경로를 지정해주어야 합니다.
- **서버 로그에 `ERROR: Traceback (most recent call last):` 와 함께 예상치 못한 오류가 표시되나요?**
  - `uvicorn`을 실행한 터미널에 출력되는 로그는 최고의 디버깅 도구입니다. 오류 메시지의 가장 마지막 줄과 그 위의 몇 줄을 자세히 읽어보면, 어떤 코드 라인에서 어떤 종류의 예외(e.g., `AttributeError`, `ValueError`)가 발생했는지에 대한 단서를 얻을 수 있습니다.

더 자세한 문제 해결 가이드나 다른 동료들이 겪은 문제에 대한 해결책이 궁금하다면, 아래 문서를 참고해주세요.

- **➡️ [Geumdo-Docs: TROUBLESHOOTING.md](../../../TROUBLESHOOTING.md)**

---

## 9. 더 깊이 알아보기 (Further Reading)
- [FastAPI 공식 튜토리얼](https://fastapi.tiangolo.com/tutorial/): FastAPI의 모든 것을 배울 수 있는 최고의 가이드
- [Pydantic 공식 문서](https://docs.pydantic.dev/latest/): 데이터 유효성 검사를 더 깊이 있게 활용하는 방법
- [Serving Machine Learning Models](https://www.manning.com/books/serving-machine-learning-models): 모델 서빙에 대한 전반적인 내용을 다루는 전문 서적

---

**➡️ 다음 시간: [Part 9: 프로덕션 레벨 API와 Docker](../09_production_ready_api/part_9_production_ready_api.md)**
