NYC 택시 수요 예측 파이프라인 만들기 (7) FastAPI 서빙과 학습-서빙 편차
MLflow의 모델을 FastAPI로 서빙하면서, 학습과 서빙의 피처를 일치시키는 문제(train-serving skew)를 다룬 과정을 정리했습니다.
만들 것
시각과 지역을 받아 예측 수요를 돌려주는 API입니다.
1
2
curl -X POST http://localhost:8000/predict -H 'Content-Type: application/json' \
-d '{"pickup_hour": "2024-01-15T14:00:00", "pulocation_id": 161}'
모델을 불러와 predict를 호출하는 것 자체는 몇 줄이면 됩니다. 이번 글의 진짜 주제는 그 앞단에 있습니다. 모델에 넣을 피처를 서빙 시점에 어떻게 만들 것인가입니다.
학습-서빙 편차: 서빙의 고전적인 함정
학습 때는 전체 테이블에 shift와 rolling을 한 번에 걸어 lag 피처를 만들었습니다. 서빙에서는 요청받은 (시각, 지역) 단 한 건에 대해 같은 값을 만들어야 하는데, 전체 테이블이 없습니다.
이때 서빙용 피처 계산을 대충 새로 짜면 train-serving skew가 생깁니다. 학습 때의 lag_24h와 서빙 때의 lag_24h가 미묘하게 다른 값이 되는 것입니다. 무서운 점은 에러가 나지 않는다는 것입니다. API는 멀쩡히 숫자를 돌려주는데 정확도만 조용히 무너지고, 원인을 찾으려면 학습 코드와 서빙 코드를 한 줄씩 대조해야 합니다.
서빙 피처 재현: 필요한 과거 시각만 조회
접근은 이렇습니다. 피처 7개를 만드는 데 필요한 과거 시각은 정확히 8개뿐입니다.
1
2
3
4
5
def history_hours(pickup_hour):
"""1시간 전 + 지난 7일의 같은 시각."""
return [pickup_hour - pd.Timedelta(hours=1)] + [
pickup_hour - pd.Timedelta(hours=24 * k) for k in range(1, 8)
]
이 8개 시각의 수요를 6편에서 적재해둔 PostgreSQL hourly_demand 테이블에서 조회하고, 학습과 같은 정의로 재조립합니다. lag_1h는 1시간 전 값, lag_24h는 24시간 전 값, week_mean은 지난 7일 같은 시각 평균. 컬럼 순서와 타입, 지역 카테고리(1~263)까지 학습 시점과 동일하게 맞춥니다.
필요한 과거 시각이 DB에 없으면 그럴듯한 값으로 때우지 않고 422 에러로 명시적으로 실패합니다. 조용한 오답보다 시끄러운 실패가 낫다는 원칙은 서빙에서도 같습니다.
그리고 이 “학습과 서빙이 같은 피처를 만든다”는 계약은 테스트로 고정했습니다. 서빙 피처 함수의 출력 컬럼이 학습 피처 정의(FEATURE_COLUMNS)와 정확히 일치하는지, 값이 손으로 계산한 정답과 맞는지를 pytest가 검증합니다. 나중에 피처를 추가하면 이 테스트가 먼저 깨져서 서빙 쪽 수정을 잊을 수 없게 됩니다.
모델 로드: 피클 대신 네이티브 포맷
API가 뜰 때 MLflow에서 가장 최근의 LightGBM run을 검색해 모델을 내려받습니다. 이때 MLflow 표준 포맷 대신, 학습 때 함께 저장해둔 LightGBM 네이티브 텍스트 포맷을 씁니다.
1
2
local_path = mlflow.artifacts.download_artifacts(run_id=run_id, artifact_path="lgbm_booster.txt")
model = lgb.Booster(model_file=local_path)
이유는 역직렬화 문제입니다. 피클 계열 포맷은 저장한 환경과 불러오는 환경의 Python, 라이브러리 버전이 어긋나면 깨질 수 있고, 신뢰할 수 없는 피클을 여는 것은 보안 관점에서도 좋지 않은 습관입니다. 네이티브 포맷은 트리 구조를 텍스트로 기록한 것이라 이 문제에서 자유롭고, 덕분에 서빙 컨테이너에 학습용 패키지를 깔지 않아도 됩니다.
모델 로드는 FastAPI의 lifespan에서 서버 기동 시 한 번만 합니다. 요청마다 MLflow에 다녀오면 안 됩니다.
API 뼈대
1
2
3
class PredictRequest(BaseModel):
pickup_hour: datetime
pulocation_id: int = Field(ge=1, le=263)
- 입력 검증은 Pydantic에 위임: 존재하지 않는 지역 ID나 이상한 날짜 형식은 모델까지 오기 전에 걸러집니다.
GET /health: 서비스 상태와 현재 어떤 모델(run_id)이 실렸는지 반환합니다. 배포 후 “지금 어느 모델이 서빙 중이지?”에 바로 답할 수 있습니다.GET /metrics: Prometheus가 긁어갈 요청 수와 지연시간 메트릭입니다.prometheus-fastapi-instrumentator한 줄로 붙습니다. 다음 글의 모니터링이 여기 연결됩니다.
컨테이너로: 서빙 이미지는 최소로
서빙용 Dockerfile은 학습 환경과 분리했습니다.
python:3.11-slim베이스에 LightGBM 실행에 필요한libgomp1만 추가합니다.- MLflow는 전체 패키지 대신 mlflow-skinny(추적 클라이언트만 포함한 경량판)를 씁니다. 서버와 UI 의존성이 빠져 이미지가 가벼워집니다.
- torch, lightgbm 학습 스택, 노트북 관련 패키지는 들어가지 않습니다. 서빙에 필요한 것만 담는 것이 이미지 크기와 보안 양쪽에 좋습니다.
Docker Compose 네트워크 안에서는 서비스 이름이 곧 호스트명이라, API 컨테이너의 환경변수는 localhost 대신 postgres, mlflow를 가리킵니다. 같은 코드가 환경변수만 바꿔 로컬에서도 컨테이너에서도 돌아갑니다.
다음 글
이제 파이프라인의 각 조각이 전부 동작합니다. 마지막 글에서는 수동으로 돌리던 단계들을 Airflow DAG로 자동화하고, 드리프트 리포트와 서비스 메트릭 모니터링, 테스트와 CI까지 연결해 시리즈를 마무리합니다.