머신러닝 실전 워크플로 (8) - 모델 레지스트리와 아티팩트
학습이 끝난 모델을 어떻게 저장하고 버전관리하는가. pickle의 함정에서 출발해 모델과 전처리, 메타데이터를 하나로 묶고 MLflow Model Registry의 스테이지와 승격으로 서비스에 나갈 모델을 관리하는 법을 정리한 8편입니다.
머신러닝 실전 워크플로 시리즈의 8편입니다. 7편의 “검증 전략과 누수 점검”에 이어집니다.
학습이 끝난 모델은 어디로 가는가
7편까지 오면서 검증 전략을 세우고, 누수를 점검하고, 그 결과로 모델 하나를 골랐습니다. 그런데 방금 고른 그 모델은 지금 메모리에 떠 있는 파이썬 객체일 뿐입니다. 노트북 커널을 끄거나 학습 스크립트가 끝나면 사라집니다. 이 객체를 파일로 남기는 일이 이번 편의 출발점입니다.
문제는 “저장” 한 단어로 끝나지 않는다는 점입니다. 저장 뒤에 반드시 따라오는 질문이 두 개 있습니다.
- 몇 달 뒤에 이 파일을 다시 불러서 그때와 똑같은 예측을 재현할 수 있는가.
- 서빙(9편)이 여러 후보 중 어느 모델을 서비스에 올릴지 어떻게 아는가.
첫 질문은 직렬화(serialization)와 재현의 문제이고, 두 번째는 버전관리의 문제입니다. 모델 파일 하나를 model.pkl로 떨궈두는 것만으로는 둘 다 풀리지 않습니다. 이번 편은 왜 안 풀리는지부터 보고, 모델 레지스트리가 무엇을 대신 풀어주는지로 넘어갑니다.
pickle의 함정
파이썬에서 학습된 모델을 저장하는 가장 손쉬운 방법은 pickle이나 joblib으로 객체를 통째로 직렬화하는 것입니다. scikit-learn은 넘파이 배열을 많이 담고 있어 joblib을 권장합니다.
1
2
3
4
import joblib
joblib.dump(pipe, "model.joblib") # 파이프라인 전체를 파일로 직렬화
loaded = joblib.load("model.joblib") # 다시 파이썬 객체로 복원
한 줄로 끝나서 편하지만, 이 방식에는 실전에서 발목을 잡는 함정이 세 가지 있습니다.
버전 의존: pickle은 객체가 참조하는 클래스 정의를 파일에 함께 담지 않습니다. 클래스 이름과 속성만 저장하고, 복원할 때 그 클래스를 다시 import해서 붙입니다. 그래서 저장할 때와 불러올 때의 라이브러리 버전이 다르면 문제가 생깁니다. scikit-learn 버전이 어긋나면 InconsistentVersionWarning이 뜨고, 운이 나쁘면 경고 없이 내부 동작이 달라지거나 로드 자체가 깨집니다. 파이썬 마이너 버전이 바뀌어도 마찬가지입니다. 모델 파일 하나만으로는 “어떤 환경에서 만들어졌는가”를 알 수 없다는 것이 핵심 약점입니다.
보안: pickle을 로드하는 행위는 데이터를 읽는 것이 아니라 코드를 실행하는 것입니다. 역직렬화 과정에서 객체의 __reduce__가 호출되므로, 조작된 pickle 파일은 로드하는 순간 임의의 코드를 실행시킬 수 있습니다. 출처를 신뢰할 수 없는 모델 파일을 joblib.load하는 것은 정체불명의 실행 파일을 그냥 실행하는 것과 같습니다.
1
loaded = joblib.load("model.joblib") # 신뢰할 수 없는 파일이면 이 한 줄이 원격 코드 실행이 된다
인터넷에서 받은 모델 가중치나 다른 팀이 준 pickle을 아무 확인 없이 로드하지 않는다. 신뢰 경계 밖의 pickle은 데이터가 아니라 실행 코드로 취급한다. 배포 파이프라인에서는 모델 파일의 출처와 해시를 고정해 두는 것이 최소한의 방어다.
메타데이터 부재: pickle 파일은 객체 하나만 담습니다. 어떤 데이터로, 어떤 코드로, 어떤 하이퍼파라미터로 학습했고 검증 점수가 얼마였는지는 파일 어디에도 없습니다. 파일 이름을 model_final_v2_real.joblib처럼 붙이는 습관이 여기서 나옵니다.
대안으로 모델별 전용 포맷(LightGBM의 booster.save_model은 텍스트로, 상호운용은 ONNX로)이나 이식성 있는 포맷이 있지만, 이들은 scikit-learn 파이프라인 같은 파이썬 쪽 껍데기를 그대로 담지 못하는 경우가 많습니다. 그래서 실전의 해법은 pickle을 버리는 것이 아니라 pickle을 감싸는 것입니다. 환경을 함께 고정하고 메타데이터를 붙여 하나의 묶음으로 만드는 것, 그것이 모델 레지스트리가 하는 일입니다.
모델만 저장하면 재현이 안 된다
재현의 관점에서 보면, 예측 하나를 그때와 똑같이 되살리는 데 필요한 것은 모델 객체 하나가 아닙니다. 최소한 세 가지가 함께 있어야 합니다.
- 모델과 전처리: 모델에 들어가기 전 데이터가 거치는 스케일링, 인코딩, 결측치 대치가 학습 때와 한 치도 다르지 않아야 합니다. ml-basics 13편에서 봤듯, 전처리와 모델을 하나의
Pipeline객체로 묶어 저장하면 이 둘이 절대 어긋나지 않습니다.pipe.predict(new_data)한 번이면 학습 때와 똑같은 변환이 자동으로 적용됩니다. 전처리 코드를 서빙 쪽에 따로 옮겨 적다가 생기는 학습-서빙 편차를 이 구조가 원천 차단합니다. - 메타데이터: 학습에 쓴 데이터의 버전(3편의 DVC), 코드의 git 커밋 해시, 하이퍼파라미터와 검증 지표(6편), 그리고 학습 시점의 라이브러리 목록입니다. 이 정보가 있어야 “이 예측이 어디서 나왔는가”를 역추적할 수 있습니다.
- 환경: 앞의 버전 의존 문제를 풀려면 파이썬과 패키지 버전을 함께 기록해 두고, 로드할 때 그 환경을 복원할 수 있어야 합니다.
이 세 가지를 모델과 함께 묶은 하나의 단위를 아티팩트(artifact)라고 부릅니다. 재현의 단위는 모델 파일이 아니라 이 아티팩트입니다.
MLflow가 묶어주는 것
6편에서 MLflow로 실험을 추적하며 run마다 파라미터와 지표를 기록했습니다. 모델 아티팩트는 바로 그 run에 얹힙니다. mlflow.sklearn.log_model이 파이프라인을 저장하면서 앞 절에서 말한 묶음을 자동으로 만들어 줍니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import mlflow
from mlflow.models import infer_signature
with mlflow.start_run():
pipe.fit(X_tr, y_tr)
preds = pipe.predict(X_val)
sig = infer_signature(X_val, preds) # 입력과 출력의 스키마를 함께 기록
mlflow.sklearn.log_model(
sk_model=pipe, # 전처리와 모델을 묶은 파이프라인 전체
name="model", # run 안에서의 아티팩트 경로
signature=sig,
input_example=X_val.iloc[:5], # 입력 예시 몇 행
registered_model_name="taxi-demand", # 레지스트리에 이름을 붙여 등록
)
이 한 호출이 run 아래에 만드는 것은 모델 파일 하나가 아니라 디렉터리입니다. 그 안에는 직렬화된 파이프라인, 로드에 필요한 패키지 목록(requirements.txt와 conda.yaml), 입력과 출력의 스키마를 적은 시그니처, 그리고 이 아티팩트가 어떤 형식인지 알려주는 MLmodel 파일이 함께 들어갑니다. 앞 절에서 나눠 이야기한 모델, 환경, 메타데이터가 한 곳에 모이고, 그것이 파라미터와 지표를 가진 run에 연결됩니다. pickle 파일 하나에 없던 맥락이 전부 채워지는 셈입니다.
시그니처는 모델이 기대하는 입력 열과 타입을 명시합니다. 서빙에서 엉뚱한 스키마의 데이터가 들어오면 조용히 이상한 예측을 내는 대신 걸러낼 수 있습니다. 학습-서빙 편차를 막는 첫 방어선이며, 자세한 서빙 쪽 이야기는 9편에서 다룹니다.
모델 레지스트리: 무엇이 프로덕션인가
실험을 돌리다 보면 run은 금세 수백 개가 됩니다. 그중 지금 서비스에 나가 있는 모델은 정확히 어느 run의 어느 아티팩트일까요. run 목록만으로는 이 질문에 답할 수 없습니다. 실험 추적은 “무엇을 시도했는가”의 기록이지, “무엇을 배포했는가”의 기록이 아니기 때문입니다.
모델 레지스트리는 이 빈자리를 메웁니다. 이름 붙은 모델(위 예시의 taxi-demand)과 그 밑에 쌓이는 버전들을 관리하는 카탈로그입니다. registered_model_name으로 등록할 때마다 taxi-demand의 버전 1, 2, 3이 차례로 붙습니다. 각 버전은 자기를 만든 run을 가리키므로, 어느 모델이든 그 뒤의 데이터와 코드, 지표까지 한 번에 거슬러 올라갈 수 있습니다.
레지스트리의 핵심 가치는 저장이 아니라 지목입니다. “지금 프로덕션에 있는 것은 taxi-demand의 버전 3이다”라는 단일한 사실을 한 곳에서 관리합니다.
스테이지와 승격
버전이 쌓이기만 해서는 부족합니다. 그중 어느 버전이 검증 중이고 어느 버전이 실제 서비스인지 구분해야 합니다. 이를 위해 각 버전은 단계를 거칩니다. 등록 직후에는 아무 단계도 아니다가, 검증을 진행하는 동안 staging으로, 서비스에 나가면 production으로, 물러나면 archived로 옮겨집니다.
여기서 중요한 것은 승격이 자동이 아니라는 점입니다. staging에서 production으로 올리는 조건은 7편에서 세운 검증을 통과하는 것입니다. 오프라인 지표가 기준을 넘고, 필요하다면 섀도 트래픽이나 소규모 A/B로 실서비스 데이터에서도 문제가 없음을 확인한 뒤에야 사람이 승격을 결정합니다. 레지스트리는 그 결정을 기록하고 되돌릴 수 있게 해 주는 장부입니다.
예전 MLflow는 스테이지를 직접 옮기는 API를 썼습니다.
1
2
3
4
5
from mlflow import MlflowClient
client = MlflowClient()
client.transition_model_version_stage(
name="taxi-demand", version=3, stage="Production") # 구버전 방식의 스테이지 전환
최근 MLflow는 고정된 스테이지 대신 별칭(alias)과 태그를 권장합니다. 스테이지 이름이 네 가지로 정해져 있던 것과 달리, 별칭은 팀이 원하는 이름을 원하는 버전에 자유롭게 붙입니다. 승격은 별칭이 가리키는 버전을 바꾸는 일이 됩니다.
1
2
3
4
5
6
from mlflow import MlflowClient
client = MlflowClient()
# 검증을 통과한 버전 3에 production 별칭을 붙여 승격한다
client.set_registered_model_alias(
name="taxi-demand", alias="production", version=3)
이 구조가 서빙과 만나면 배포가 단순해집니다. 서빙 코드는 특정 버전 번호를 박아두는 대신 별칭으로 모델을 로드합니다.
1
2
3
4
import mlflow
model = mlflow.pyfunc.load_model("models:/taxi-demand@production")
preds = model.predict(X_new)
서빙 코드는 그대로 두고, 새 모델을 내보낼 때는 production 별칭을 버전 4로 옮기기만 하면 됩니다. 문제가 생기면 별칭을 버전 3으로 되돌리는 것이 곧 롤백입니다. 어느 버전이 서비스 중인지, 어떻게 되돌리는지가 레지스트리 안에서 한눈에 관리됩니다. 이 로드 지점이 다음 편에서 다룰 서빙의 시작점입니다.
NYC 택시에서는
이 흐름을 택시 데이터로 끝까지 구현한 사례가 저장소와 MLflow 편입니다. 학습한 수요 예측 모델을 오브젝트 스토리지 백엔드에 저장하고, MLflow로 등록해 버전을 관리하며, 검증을 통과한 버전을 승격하는 과정을 구체적인 코드로 봅니다. 이번 편이 왜와 무엇을 정리했다면, 그 글은 같은 단계를 하나의 데이터로 어떻게 굴리는지를 보여줍니다.
정리
| 개념 | 한 줄 요약 |
|---|---|
| 직렬화와 재현 | 학습된 모델은 메모리 객체. 파일로 남기되 재현까지 되어야 한다 |
| pickle 버전 의존 | 클래스 정의를 담지 않아 라이브러리 버전이 어긋나면 깨지거나 조용히 달라진다 |
| pickle 보안 | 로드는 코드 실행이다. 신뢰할 수 없는 pickle은 원격 코드 실행 위험 |
| 아티팩트 | 모델, 전처리, 메타데이터, 환경을 하나로 묶은 재현 단위 |
| Pipeline 저장 | 전처리와 모델을 한 객체로 저장해 학습-서빙 편차를 막는다 |
| log_model | 모델에 스키마와 환경을 붙여 run에 연결한다 |
| 모델 레지스트리 | 이름 붙은 모델과 버전을 관리. 무엇이 프로덕션인지의 단일 출처 |
| 스테이지와 승격 | staging에서 production으로. 검증 통과가 조건이고 별칭으로 되돌린다 |
모델을 안전하게 저장하고 어느 버전이 서비스 중인지까지 관리하게 됐습니다. 다음 편은 그 모델을 실제로 예측 요청에 응답하게 만드는 서빙입니다. 배치와 온라인의 차이, FastAPI로 감싸는 법, 그리고 학습-서빙 편차를 봅니다.
다음 글: 머신러닝 실전 워크플로 (9) - 서빙: 배치와 온라인, FastAPI </content> </invoke>