포스트

머신러닝 실전 워크플로 (9) - 서빙: 배치와 온라인, FastAPI

학습이 끝난 모델을 예측 요청에 응답하게 만드는 서빙을 정리합니다. 예측을 미리 계산하는 배치와 요청 시점에 계산하는 온라인을 언제 쓰는지 구분하고, FastAPI로 최소 엔드포인트를 감싸며, 5편의 피처 정의를 그대로 재사용해 학습-서빙 편차를 막는 9편입니다.

머신러닝 실전 워크플로 (9) - 서빙: 배치와 온라인, FastAPI

머신러닝 실전 워크플로 시리즈의 9편입니다. 8편의 “모델 레지스트리와 아티팩트”에 이어집니다.

모델을 예측에 응답하게 만든다

8편까지 오면서 모델을 아티팩트로 저장하고, 어느 버전이 프로덕션인지까지 레지스트리로 관리하게 됐습니다. 8편은 mlflow.pyfunc.load_model("models:/taxi-demand@production")로 그 모델을 메모리에 불러오는 지점에서 끝났습니다. 서빙은 바로 그다음입니다. 불러온 모델이 실제 예측 요청에 응답하게 만드는 단계입니다.

서빙의 질문은 하나입니다. 예측을 언제, 어디서 계산해 소비자에게 어떻게 전달하는가. 모델 객체의 predict를 부르는 일 자체는 한 줄입니다. 실전에서 어려운 것은 그 앞뒤입니다. 예측을 미리 계산해 둘지 요청이 올 때 계산할지, 요청에서 모델이 먹을 피처를 어떻게 조립할지, 그리고 그 조립이 학습 때와 어긋나지 않게 어떻게 보장할지입니다.

예측을 언제 계산하는가: 배치와 온라인

서빙 방식은 예측을 계산하는 시점으로 갈립니다. 미리 계산해 저장해 두는 것이 배치 예측이고, 요청이 올 때 그 자리에서 계산하는 것이 온라인 서빙입니다.

배치 예측(batch prediction)은 예측 대상 전체를 주기적으로 한 번에 돌려 결과를 저장합니다. 매일 밤 전체 사용자의 이탈 확률을 계산해 테이블에 넣어두는 식입니다. 이렇게 하면 서빙 시점에 모델이 돌지 않습니다. 요청이 오면 미리 계산해 둔 값을 조회만 하면 되므로 서빙 자체는 단순한 데이터베이스 조회가 됩니다.

1
2
3
4
5
6
7
8
import pandas as pd, joblib
from features import build_features, feature_cols   # 5편에서 학습과 공유하는 정의

model = joblib.load("artifacts/model.joblib")
batch = pd.read_parquet("data/tomorrow_zones.parquet")   # 예측 대상 전체
X = build_features(batch)                                  # 학습과 같은 함수로 피처 조립
batch["demand"] = model.predict(X[feature_cols])
batch[["zone", "pickup_hour", "demand"]].to_parquet("out/predictions.parquet")  # 미리 저장

온라인 서빙(online serving)은 요청이 들어오는 순간에 예측을 계산해 즉시 응답합니다. 입력이 요청 시점에야 정해지거나, 최신 상태를 반영한 예측을 낮은 지연으로 돌려줘야 할 때 씁니다. 사기 탐지는 거래가 일어나는 그 순간의 값으로 판단해야 하고, 검색 순위나 추천은 지금 이 사용자의 맥락으로 계산해야 합니다. 미리 계산해 둘 수 없는 문제입니다.

둘 중 무엇을 쓸지는 세 가지 질문으로 정해집니다.

질문배치가 맞는 경우온라인이 맞는 경우
예측 대상을 미리 알 수 있는가대상이 정해져 있다(전체 사용자, 전체 지역)입력이 요청 시점에야 정해진다
얼마나 신선해야 하는가하루 전 예측으로 충분하다최신 상태를 즉시 반영해야 한다
지연 예산이 얼마인가초 단위나 그 이상 여유가 있다수십 밀리초 안에 응답해야 한다

한쪽이 항상 옳은 것은 아닙니다. 신선도 요구가 낮고 대상이 정해져 있으면 배치가 단순하고 싸고 안정적입니다. 모델이 무거워 실시간 추론이 부담스러울 때도 배치로 미리 돌려두는 편이 낫습니다. 반대로 입력이 실시간이거나 낮은 지연이 필요하면 온라인이 유일한 선택입니다. 실무에서는 두 방식을 섞기도 합니다. 무거운 피처는 배치로 미리 계산해 저장해 두고, 요청 시점에는 가벼운 온라인 추론만 하는 식입니다. 이 편의 나머지는 온라인 서빙을 FastAPI로 감싸는 쪽을 봅니다. 배치는 앞의 스크립트를 스케줄러로 주기 실행하는 것이 골격이고, 그 스케줄링은 11편에서 다룹니다.

FastAPI로 최소 예측 엔드포인트

온라인 서빙은 모델을 HTTP 엔드포인트 뒤에 두는 것입니다. FastAPI는 파이썬으로 이 엔드포인트를 만드는 가장 흔한 선택입니다. 요청 스키마를 Pydantic 모델로 선언하면 입력 검증이 자동으로 붙고, 타입 힌트만으로 문서까지 생성됩니다.

엔드포인트가 하는 일은 네 단계입니다. 요청을 받고, 피처를 조립하고, 예측하고, 응답합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
from contextlib import asynccontextmanager
from datetime import datetime
from fastapi import FastAPI
from pydantic import BaseModel, Field
import pandas as pd

from features import build_features, feature_cols   # 학습과 같은 모듈을 import
import joblib

ml = {}   # 로드한 모델을 담아둘 곳

@asynccontextmanager
async def lifespan(app: FastAPI):
    ml["model"] = joblib.load("artifacts/model.joblib")   # 기동 시 한 번만 로드
    yield
    ml.clear()

app = FastAPI(lifespan=lifespan)

class PredictRequest(BaseModel):          # 1) 요청 스키마
    pickup_hour: datetime
    zone: str
    temp: float = Field(..., description="해당 시각의 기온")

class PredictResponse(BaseModel):
    demand: float

@app.post("/predict", response_model=PredictResponse)
def predict(req: PredictRequest):
    df = pd.DataFrame([req.model_dump()])          # 2) 요청 한 건을 데이터프레임으로
    X = build_features(df)                          #    학습과 같은 함수로 피처 조립
    pred = ml["model"].predict(X[feature_cols])[0] # 3) 예측
    return PredictResponse(demand=float(pred))     # 4) 응답

몇 가지가 핵심입니다.

첫째, 모델 로드는 요청마다가 아니라 서버가 뜰 때 한 번만 합니다. FastAPI의 lifespan이 기동 시점에 모델을 불러 ml에 담아두고, 이후 모든 요청이 그 객체를 재사용합니다. 요청마다 디스크나 레지스트리에서 모델을 읽으면 지연이 커지고 처리량이 무너집니다.

둘째, 입력 검증을 Pydantic에 위임합니다. zone이 문자열이 아니거나 pickup_hour가 날짜 형식이 아니면 요청은 모델에 닿기 전에 422로 걸러집니다. Field로 범위나 제약을 걸면 이상한 값도 여기서 막힙니다. 잘못된 입력이 모델까지 내려가 조용히 이상한 예측을 내는 것보다, 경계에서 시끄럽게 실패하는 편이 낫습니다.

셋째, 예측에 쓰는 파이프라인은 8편에서 저장한 그 아티팩트입니다. model.joblib 안에는 전처리 통계와 모델 가중치가 함께 들어 있으므로(5편), 서빙은 스케일링이나 인코딩을 손으로 다시 계산하지 않습니다. 저장된 파이프라인에 넣으면 학습 때 fit한 그 통계로 변환이 자동 적용됩니다.

학습-서빙 편차: 피처 정의는 하나

서빙에서 가장 조용하고 위험한 함정이 학습-서빙 편차(training-serving skew)입니다. 학습 때와 서빙 때 같은 입력으로 피처를 계산하는데 값이 미묘하게 달라지는 것입니다. 무서운 점은 에러가 나지 않는다는 데 있습니다. API는 멀쩡히 숫자를 돌려주는데 정확도만 서서히 무너지고, 원인을 찾으려면 학습 코드와 서빙 코드를 한 줄씩 대조해야 합니다.

편차가 생기는 자리는 정해져 있습니다. 위 엔드포인트에서 예측 직전에 부른 build_features가 그 자리입니다. 만약 이 함수를 학습 스크립트에서 한 번 짜고 서빙 API에서 또 짜면, 두 구현이 벌어지는 순간 편차가 생깁니다. 학습은 시각을 사인과 코사인으로 바꾸는데 서빙은 반올림을 한 번 더 하거나 시간대 처리가 다르면, 같은 시각에서 다른 피처가 나옵니다.

해결은 5편에서 이미 세운 원칙 그대로입니다. 피처 정의는 한 곳에만 두고, 학습과 서빙이 같은 것을 import 합니다. 위 엔드포인트가 from features import build_features로 학습과 같은 모듈을 불러 쓰는 이유가 이것입니다. 학습이 쓴 함수를 서빙이 그대로 재사용하면, 피처 계산이 두 번 짜여 어긋날 여지 자체가 없습니다. 통계를 배우는 변환(스케일링, 인코딩)은 저장된 파이프라인이 재현하고, 결정적 변환(시각에서 파생하는 피처)은 공유 함수가 재현합니다.

학습-서빙 편차의 가장 흔한 원인은 서빙 코드가 피처 계산을 손으로 다시 구현하는 것이다. 학습이 만든 features.py의 함수와 저장된 파이프라인을 서빙이 그대로 import 해서 쓰면 이 편차는 원천적으로 생기지 않는다. 서빙 쪽에서 피처를 새로 짜야 할 것 같으면, 그 코드는 서빙이 아니라 학습과 공유하는 모듈로 옮긴다.

한 가지 온라인 서빙 고유의 문제가 남습니다. lag이나 이동평균처럼 과거 값이 필요한 피처는 요청 한 건만으로는 계산할 수 없습니다. 요청에는 지금 이 시각의 값만 있고 과거 값이 없기 때문입니다. 이때는 서빙이 필요한 과거 값을 저장소에서 조회해 학습과 같은 정의로 재조립해야 합니다. 여기서도 원칙은 같습니다. 조회한 값으로 만드는 lag의 정의가 학습 때의 정의와 정확히 일치해야 하고, 그렇지 않으면 편차가 됩니다. 이 계약은 테스트로 고정하는 것이 안전합니다. 서빙 피처 함수의 출력이 학습 피처 정의와 열, 타입, 값까지 일치하는지를 검증하는 테스트를 두면, 나중에 피처를 바꿀 때 그 테스트가 먼저 깨져 서빙 쪽 수정을 잊을 수 없게 됩니다.

NYC 택시에서는

이 편의 온라인 서빙을 택시 데이터로 끝까지 구현한 사례가 FastAPI 서빙과 학습-서빙 편차 편입니다. 시각과 지역을 받아 예측 수요를 돌려주는 FastAPI 엔드포인트를 만들고, 요청 한 건에 대한 lag 피처를 어떻게 재현하는지를 구체적으로 봅니다.

거기서 핵심 난점이 바로 위에서 말한 과거 값 문제였습니다. 학습 때는 전체 테이블에 shiftrolling을 한 번에 걸어 lag 피처를 만들었는데, 서빙에서는 요청받은 한 건에 대해 같은 값을 만들어야 하고 전체 테이블이 없습니다. 해법은 피처를 만드는 데 필요한 과거 시각이 정확히 몇 개인지 세어(직전 1시간과 지난 7일의 같은 시각), 그 시각들의 수요만 저장소에서 조회해 학습과 같은 정의로 재조립하는 것입니다. 필요한 과거 값이 없으면 그럴듯한 값으로 때우지 않고 명시적으로 실패시켜, 조용한 오답 대신 시끄러운 실패를 택합니다. 이 편이 왜와 무엇을 정리했다면, 그 글은 같은 서빙 단계를 하나의 데이터로 어떻게 굴리는지를 보여줍니다.

정리

개념한 줄 요약
서빙의 질문예측을 언제, 어디서 계산해 어떻게 전달하는가. predict는 한 줄, 앞뒤가 어렵다
배치 예측대상 전체를 미리 계산해 저장. 서빙은 조회만. 신선도 요구가 낮을 때
온라인 서빙요청 시점에 계산해 즉시 응답. 입력이 실시간이거나 낮은 지연이 필요할 때
선택 기준대상을 미리 아는가, 얼마나 신선해야 하는가, 지연 예산은 얼마인가
FastAPI 엔드포인트요청, 피처 조립, 예측, 응답. 모델은 기동 시 한 번 로드, 검증은 Pydantic에
학습-서빙 편차학습과 서빙이 피처를 따로 짜면 값이 어긋나 정확도가 조용히 무너진다
편차 방지피처 정의는 한 곳, 학습과 서빙이 같은 모듈을 import. 저장된 파이프라인을 재사용
과거 값 피처요청 한 건으로 못 만드는 lag은 과거 값을 조회해 학습과 같은 정의로 재조립

모델을 예측 요청에 응답하게 만들고, 그 응답이 학습과 어긋나지 않게 하는 데까지 왔습니다. 다음 편은 이 서빙 코드를 어디서 실행해도 같게 돌아가도록 컨테이너로 감싸는 이야기입니다.

다음 글: 머신러닝 실전 워크플로 (10) - 컨테이너와 배포: Docker

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.