[MLOps 플랫폼 스터디] BentoML API부터 데이터 드리프트까지, WSL 환경에서 실습
(’26) 머신러닝 플랫폼 엔지니어링 책 Study에서 책 6-7장 내용을 기반으로 본인 환경에서 실습한 내용을 정리하였습니다.
Windows + WSL2 환경에서 MLOps 실습을 실행해보면서 정리해보았다.
| 91.11% 테스트 정확도 · 41/45 |
200 / 400 정상 요청 / 잘못된 입력 |
0 → 3 드리프트 피처 · 전체 4개 |
모델을 학습하고 정확도를 확인하는 일과, 다른 프로그램이 그 모델을 사용할 수 있도록 만드는 일 사이에는 여러 연결이 필요하다. 모델 파일을 저장하고, 예측 요청을 받아들이고, 학습 때와 같은 입력 형식을 지키며, 새로운 데이터의 변화를 확인해야 한다.
6–7장을 공부하면서 이 연결을 직접 확인해 보고 싶었다. Windows의 WSL2 환경에서 작은 Iris 분류 모델을 학습하고, BentoML로 HTTP API를 만든 뒤, Evidently로 데이터 드리프트 보고서를 생성하는 실습을 구성했다.
이번 블로그 포스트에서는 책 내용을 옮기기보다, 이 미니 실험을 통해 무엇을 확인했고 그 결과를 어떻게 해석했는지에 초점을 맞추었다. 책에 언급된 데이터셋을 기반으로 실습을 진행해보았다.
1. 실습 범위와 환경
실행 흐름은 단순하다.
Iris 데이터 → 모델 학습 → 모델 파일 저장 → HTTP 예측 API
↓
기준·비교 CSV 저장 → 별도 프로세스 → 드리프트 보고서
| 항목 | 실제 실행 환경 |
|---|---|
| 호스트 | Windows + Ubuntu WSL2 |
| 실행 방식 | WSL에서 Docker 컨테이너 실행 |
| 컨테이너 제한 | CPU 2개, 메모리 2GiB |
| Python | 3.12.16 |
| BentoML | 1.4.40 |
| Evidently | 0.7.23 |
| scikit-learn | 1.9.1 |
| pandas | 2.3.3 |
| 데이터·모델 | 내장 Iris 데이터, 표준화 + 로지스틱 회귀 |
GPU는 사용하지 않았다. Kubernetes나 Kubeflow 클러스터 배포는 이번 실행 범위에 포함하지 않았다. 7장의 내용 중 입력·출력 계약과 데이터 전달을 로컬 파일 예제로 연결했고, Kubeflow Notebooks와 Pipelines의 클러스터 동작은 개념 학습으로 구분했다.
이렇게 범위를 정하면 작은 환경에서도 모델 파일, API, 데이터, 보고서가 연결되는 과정을 끝까지 확인할 수 있다.
2. 학습 결과를 파일로 남기기
Iris 데이터 150개를 학습 105개와 테스트 45개로 나눴다. 클래스 비율을 유지하도록 분할하고, 분할 시드는 42로 고정했다. 전처리와 분류기를 하나의 scikit-learn Pipeline으로 묶어 저장했다.
이번 실행에서 보류한 테스트 데이터의 정확도는 약 91.11%, 45개 중 41개 정답이었다. 작은 데이터셋의 특정 분할에서 얻은 값이므로 실제 서비스 성능을 대표하는 숫자로 해석하지 않았다.
모델과 함께 다음 정보도 기록했다.
- 입력 피처의 이름과 순서
- 클래스 이름
- 학습·테스트 표본 수와 분할 시드
- 테스트 정확도
- 저장된 모델 파일의 SHA-256 해시
- 실제 Python 및 패키지 버전
학습 단계만 따로 실행하려면 이미지 빌드(7절) 후 실습 폴더에서 다음 명령을 쓴다.
docker run --rm \
--mount "type=bind,source=$PWD,target=/workspace" \
mlops-mini:ch6-ch7 python prepare.py
실행하면 학습 정보가 출력되고, 같은 내용이 artifacts/training.json에 저장된다. 아래는 이번 실행에서 기록된 내용이다. 배열만 한 줄로 줄였다.
{
"dataset": "scikit-learn built-in Iris",
"features": ["sepal_length", "sepal_width", "petal_length", "petal_width"],
"class_names": ["setosa", "versicolor", "virginica"],
"train_rows": 105,
"test_rows": 45,
"test_accuracy": 0.9111111111111111,
"model_sha256": "9c7836075dbb71e3a79f861028e06985e5132d00ac135103e2d2a640154fe785",
"random_state": 42,
"drift_scenario": "Synthetic offsets on three numeric features",
"control_scenario": "Reference rows shuffled; identical distributions"
}
읽는 순서는 이렇다. train_rows와 test_rows의 합이 150인지 확인하고, test_accuracy가 본문의 41/45와 같은 값인지 본다. features의 순서는 3절의 API 입력 순서와 같아야 하고, model_sha256은 3절의 API 응답에 다시 등장한다.
해시는 이후 API가 같은 모델 파일을 사용했는지 확인하는 데 활용했다. 모델 파일의 해시만으로 코드와 데이터까지 재현되는 것은 아니므로, 패키지 목록과 데이터 생성 코드도 함께 보관했다.
학습에서 중요한 것은 좋은 점수 하나를 남기는 것뿐 아니라, 그 점수가 어떤 모델과 환경에서 나왔는지 다시 찾을 수 있도록 만드는 일이었다.
3. BentoML로 예측을 HTTP 요청에 연결하기
API는 네 개의 숫자로 구성된 행들을 받도록 만들었다. 입력 순서는 다음과 같다.
sepal_length, sepal_width, petal_length, petal_width
숫자의 개수가 같아도 열 순서가 바뀌면 전혀 다른 의미의 입력이 될 수 있다. 따라서 서비스에서 요청을 DataFrame으로 바꿀 때 학습 때 사용한 열 이름과 순서를 적용했다.
요청 예시는 다음과 같다.
{
"rows": [
[5.1, 3.5, 1.4, 0.2],
[6.7, 3.1, 4.7, 1.5],
[6.5, 3.0, 5.2, 2.0]
]
}
직접 호출해 보려면 터미널 두 개가 필요하다. 첫 번째 WSL 터미널에서 서버를 띄운다. 모델 파일이 있어야 하므로 2절의 학습이나 7절의 전체 실행을 먼저 마친 상태여야 한다.
docker run --rm -it \
--cpus 2 --memory 2g \
--env DO_NOT_TRACK=1 \
-p 127.0.0.1:3000:3000 \
--mount "type=bind,source=$PWD,target=/workspace" \
mlops-mini:ch6-ch7 \
bentoml serve service:IrisService --host 0.0.0.0 --port 3000
두 번째 WSL 터미널에서 실습 폴더로 이동해 요청을 보낸다. data/request.json에는 위 요청 예시가 그대로 들어 있다.
curl -sS http://localhost:3000/predict \
-H 'Content-Type: application/json' \
--data-binary @data/request.json
이번 실행에서 기록된 응답은 다음과 같다. 보기 쉽게 줄만 바꿨다.
{
"class_ids": [0, 1, 2],
"class_names": ["setosa", "versicolor", "virginica"],
"model_sha256": "9c7836075dbb71e3a79f861028e06985e5132d00ac135103e2d2a640154fe785"
}
class_ids는 모델이 낸 숫자 레이블이고 class_names는 그것을 학습 때 기록한 클래스 이름으로 바꾼 값이다. model_sha256이 2절의 training.json과 같으므로, 응답을 만든 모델이 방금 학습한 그 파일임을 알 수 있다.
잘못된 입력도 보내 본다. 피처를 두 개만 넣은 요청이다.
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:3000/predict \
-H 'Content-Type: application/json' \
-d '{"rows": [[1.0, 2.0]]}'
상태 코드는 400이 나와야 한다. 서버 쪽 로그(artifacts/server.log)에는 두 요청이 이렇게 남았다. 추적 ID 등 뒷부분은 생략했다.
[INFO] ... (method=POST,path=/predict,type=application/json,length=76) (status=200,type=application/json,length=162) 22.638ms
[ERROR] ... Exception on /predict [POST]
bentoml.exceptions.InvalidArgument: Each row needs four finite numbers in the documented feature order
[INFO] ... (method=POST,path=/predict,type=application/json,length=22) (status=400,type=application/json,length=80) 6.310ms
로그에 [ERROR]와 Traceback이 보여도 서버가 죽은 것은 아니다. 서비스 코드가 입력 형태를 검사해 의도적으로 InvalidArgument를 던졌고, BentoML이 이를 HTTP 400으로 바꿔 돌려준 기록이다. 이 검사가 없으면 잘못된 형태의 요청은 모델 내부 오류로 이어져 호출자가 원인을 알기 어렵다.
세 입력에 대한 응답은 각각 setosa, versicolor, virginica였다. 이번 검증에서는 다음을 확인했다.
| 검증 | 실행 결과 |
|---|---|
정상 입력으로 /predict 호출 |
HTTP 200 |
| API 예측과 저장된 모델의 직접 예측 비교 | 일치 |
| 응답의 모델 해시와 학습 산출물 비교 | 일치 |
| 피처가 두 개뿐인 잘못된 입력 | HTTP 400 |
이 결과가 의미하는 것은 해당 요청들이 서비스의 입력 변환과 모델 호출을 통과했다는 것이다. 부하 테스트나 운영 환경의 지연 시간, 장애 복구까지 검증한 결과는 아니다.
모델을 API로 만든 뒤에는 “분류기가 동작하는가” 외에도 “잘못된 요청을 구분하는가”, “어떤 모델이 응답했는가”, “직접 실행한 결과와 같은가”라는 질문을 다루게 됐다.
4. Evidently로 데이터 분포의 변화를 확인하기
드리프트 실험에서는 기준 데이터와 비교 데이터를 명확하게 나눴다.
기준 데이터는 학습 입력 105개다. 정상 대조군은 그와 같은 행들을 순서만 섞어 만들었다. 따라서 대조군은 새로 수집한 운영 데이터가 아니라, 분포가 동일한 경우를 확인하기 위한 통제 실험이다.
변형 데이터는 대조군에서 다음 세 피처에 일정한 값을 더해 만들었다.
| 피처 | 인위적으로 더한 값 |
|---|---|
sepal_width |
+3.0 |
petal_length |
+4.0 |
petal_width |
+2.0 |
sepal_length는 그대로 뒀다. 이 값들은 현실의 꽃이나 운영 환경을 모사하려고 정한 것이 아니다. 탐지 동작을 관찰하기 위해 의도적으로 크게 바꾼 합성 데이터다.
이번 보고서에 기록된 피처별 방법은 K-S 검정의 p-value, 임계값은 0.05였다. 실제 결과는 다음과 같다.
| 비교 데이터 | 기준·비교 표본 수 | 드리프트로 탐지된 피처 | 피처 비율 |
|---|---|---|---|
| 같은 행을 섞은 대조군 | 각각 105개 | 0 / 4 | 0% |
| 세 피처에 오프셋을 더한 데이터 | 각각 105개 | 3 / 4 | 75% |
변경하지 않은 피처는 탐지되지 않았고, 인위적으로 바꾼 세 피처는 탐지됐다. HTML 보고서와 JSON 지표를 모두 저장해 화면에서 살펴보거나 코드로 결과를 확인할 수 있게 했다.
보고서 화면 읽기
보고서는 7절의 전체 실행으로 만들어지며, 이 단계만 다시 돌리려면 다음 명령을 쓴다. 생성된 HTML은 Windows 탐색기에서 artifacts 폴더를 열어 브라우저로 볼 수 있다.
docker run --rm \
--mount "type=bind,source=$PWD,target=/workspace" \
mlops-mini:ch6-ch7 python drift.py
Saved drift_normal.html and drift_normal.json
Saved drift_shifted.html and drift_shifted.json
아래 화면은 이번 실행에서 저장된 HTML 보고서를 브라우저로 연 모습이다.
먼저 대조군이다.

대조군 보고서: 4개 열 중 드리프트 0개
위에서부터 다음 세 가지를 중심으로 살펴보자.
- "Dataset Drift is NOT detected. Dataset drift detection threshold is 0.5": 데이터셋 전체의 판정이다. 드리프트된 열의 비율을 임계값 0.5와 비교해 데이터셋 드리프트 여부를 정한다. 여기서는 0 / 4이므로 탐지되지 않았다.
- "4 columns, 0 drifted columns, and 0.0 share of drifted columns": 가운데 숫자 세 개는 전체 열 수, 드리프트된 열 수, 그 비율이다.
- 아래 표는 열마다의 결과다.
Stat Test가 사용한 검정,Drift Score가 그 검정의 p-value다. 네 열 모두 p-value가 1이다. 같은 행을 순서만 섞었으니 두 분포가 완전히 같고, 그래서 가능한 가장 큰 값이 나왔다.
다음은 변형 데이터다.

변형 데이터 보고서: 4개 열 중 3개 드리프트
열 3개가 Detected로 바뀌었고 비율은 0.75로 임계값 0.5를 넘어 데이터셋 드리프트로 판정됐다. sepal_length만 p-value 1로 남았다. 손대지 않은 열이 그대로라는 점이 탐지 결과를 믿을 근거가 된다.
화면에 Drift Score가 0으로 보이는 것은 반올림된 표시다. JSON에 기록된 실제 p-value는 다음과 같다.
| 피처 | p-value | 판정 (임계값 0.05) |
|---|---|---|
sepal_length |
1.0 | 탐지 안 됨 |
sepal_width |
약 2.2e-62 | 탐지 |
petal_length |
약 4.9e-31 | 탐지 |
petal_width |
약 9.6e-38 | 탐지 |
p-value는 두 표본이 같은 분포에서 나왔다는 가정 아래, 관찰한 검정 통계량 이상으로 극단적인 차이가 나올 확률이다. 작은 값은 그 가정에 반하는 증거가 된다. 분포가 같을 확률이나 변화의 크기를 직접 뜻하는 값은 아니다. sepal_width가 가장 작은 이유는 데이터에서 확인된다. 기준 데이터의 범위는 2.0–4.4인데 변형 데이터는 5.0–7.4로, 두 범위가 전혀 겹치지 않는다.
한 가지 주의할 점이 있다. 표 안의 작은 히스토그램은 기준과 비교 데이터가 거의 같은 모양으로 보인다. 일정한 값을 더하면 분포의 모양은 그대로이고 위치만 옮겨지는데, 작은 그림은 각자의 값 범위에 맞춰 그려진 것으로 보인다. 그래서 썸네일만 보고 “변화가 없다”고 읽으면 안 되고, 행을 펼쳐 같은 축에서 비교해야 한다.
petal_length 행 왼쪽의 화살표를 눌러 펼친 뒤 DATA DISTRIBUTION 탭을 선택한 화면이다.

petal_length의 기준·비교 분포를 같은 축에 그린 화면
회색이 기준, 빨간색이 비교 데이터다. 기준 데이터는 1.1–6.9에 있고 비교 데이터는 5.1–10.9로 오른쪽으로 4만큼 옮겨졌다. 모양은 같고 위치만 다르다는 것이 한눈에 보인다.
같은 행의 DATA DRIFT 탭은 다른 방식으로 같은 사실을 보여 준다.

petal_length의 기준 평균·표준편차 범위와 비교 데이터 값
초록 선은 기준 데이터의 평균(약 3.78), 초록 띠는 평균에서 표준편차 1만큼의 범위다. 빨간 선인 비교 데이터가 대부분 띠 위쪽에 있다. 운영 데이터를 시간 순으로 넣었다면 이 그림에서 값이 언제부터 벗어났는지를 찾아볼 수 있겠지만, 이번 데이터는 순서를 섞은 것이라 가로축의 순서에는 의미가 없다.
여기서 가장 중요한 구분은 데이터 드리프트와 예측 성능 저하가 같은 결과는 아니라는 점이다. 이번 실험은 입력 분포의 변화를 확인했다. 변형된 입력의 실제 정답을 수집해 서비스 정확도가 얼마나 떨어졌는지는 검증하지 않았다.
드리프트 알림이 생기면 데이터 수집 경로, 단위, 전처리, 사용자 구성의 변화 등을 조사하고, 정답을 확보할 수 있다면 성능 지표를 함께 확인하는 흐름으로 이어갈 수 있다. 재학습 여부도 그런 조사 결과를 바탕으로 판단해야 한다.
5. 별도 프로세스에 데이터를 전달하며 배운 것
데이터 생성과 보고서 생성은 서로 다른 Python 프로세스에서 실행했다. 앞 단계가 CSV를 저장하고, 뒤 단계가 그 파일을 읽는다.
작은 예제지만 다음의 계약이 필요했다.
- 어느 경로에 파일을 저장할 것인가
- 뒤 단계가 기대하는 열 이름과 타입은 무엇인가
- 어떤 파일이 기준 데이터이고 어떤 파일이 비교 데이터인가
- 데이터가 어떤 조건으로 만들어졌는가
이번 실습에서는 Docker의 bind mount로 Windows 프로젝트 폴더를 컨테이너의 /workspace에 연결했다. 컨테이너가 종료돼도 이 폴더에 저장한 결과는 Windows에서 확인할 수 있었다.
이 동작은 로컬 폴더를 공유해 확인한 결과다. Kubeflow Pipelines에서 컴포넌트 사이에 아티팩트를 전달하는 기능을 실행한 것은 아니다. 클러스터에서는 각 실행 환경이 분리되므로, 앞 단계의 로컬 경로 문자열을 넘기는 것만으로 다른 컨테이너가 같은 파일을 읽을 수 있다고 가정하면 안 된다.
7장의 학습과 연결해서 보면, 함수의 계산 내용만큼이나 입력·출력의 형식과 저장 위치를 명확히 정의하는 일이 중요하다는 점을 작은 실험에서도 느낄 수 있었다.
6. 실제로 만난 문제: 패키지 호환성
첫 실행에서 모델 학습과 API 검증은 통과했지만 Evidently를 불러오는 단계가 실패했다. 당시 설치된 조합은 Evidently 0.7.23과 pandas 3.1.1이었다.
ImportError: cannot import name 'infer_dtype_from_object'
오류는 Evidently가 불러오는 내부 모듈이 pandas의 해당 함수를 가져오는 과정에서 발생했다. pandas를 2.3.3으로 고정한 뒤에는 모델 학습, HTTP 검증, 두 개의 드리프트 보고서 생성까지 전체 실행이 통과했다.
이후 주요 도구 버전과 전체 의존성 목록을 기록하고, Docker 빌드에 검증한 전체 패키지 목록을 사용하도록 정리했다. Python 베이스 이미지도 사용한 digest로 고정했다.
이 경험은 “설치 명령이 성공했다”와 “여러 도구가 연결된 작업이 끝까지 동작했다”가 별개의 확인이라는 점을 보여 줬다. 버전 목록과 작은 전체 실행 검증을 남겨 두는 이유가 여기에 있다.
7. 직접 실행
책에 나와있는 코드를 기반으로 다음과 같이 구성하여 실습을 진행하였다. 데이터 생성, API 서비스, 보고서 생성, 전체 실행 스크립트와 Dockerfile을 준비하고, 아래 명령어를 해당 폴더에서 WSL 터미널로 실행한다. WSL에서 Docker가 동작하는 환경을 전제로 한다.
docker build -t mlops-mini:ch6-ch7 .
docker run --rm \
--cpus 2 --memory 2g \
--env DO_NOT_TRACK=1 \
--mount "type=bind,source=$PWD,target=/workspace" \
mlops-mini:ch6-ch7
첫 번째 명령은 고정된 패키지 목록으로 이미지를 만든다. 처음에는 베이스 이미지와 패키지를 내려받느라 시간이 걸린다. 두 번째 명령은 현재 폴더를 컨테이너의 /workspace에 연결하고 run_lab.py를 실행한다.
자동 실행은 학습, 실제 HTTP 요청 검증, 보고서 생성 순서로 진행한다. 검증을 위해 띄운 API 프로세스는 작업 후 종료한다. 터미널에는 단계마다 다음 줄이 순서대로 나온다. 사이사이에 2절의 학습 정보와 3절의 요청·응답 JSON이 함께 출력된다.
STEP 1: training and writing data artifacts
STEP 2: starting local BentoML API and validating HTTP requests
STEP 3: reading CSV artifacts in another process and generating reports
Saved drift_normal.html and drift_normal.json
Saved drift_shifted.html and drift_shifted.json
LAB COMPLETE: reports and reproducibility evidence saved in artifacts/
마지막 줄이 나오면 모든 검증을 통과한 것이다. 스크립트는 API 예측이 모델의 직접 예측과 다르거나, 잘못된 입력에 400이 아닌 코드가 오거나, 드리프트 열 수가 대조군 0개·변형 데이터 3개가 아니면 오류로 중단하도록 작성했다. 중간에 멈췄다면 마지막으로 출력된 STEP 줄이 실패한 단계이고, API 문제라면 artifacts/server.log를 먼저 본다.
완료 여부는 파일로도 확인할 수 있다.
grep -E '"status"|"normal"|"shifted"' artifacts/result.json
"status": "completed",
"normal": 0.0,
"shifted": 3.0
| 산출물 | 용도 |
|---|---|
artifacts/training.json |
표본 수, 정확도, 입력 스키마, 모델 해시 |
artifacts/api-check.json |
실제 요청·응답과 검증 결과 |
artifacts/drift_normal.html |
대조군 보고서 |
artifacts/drift_shifted.html |
변형 데이터 보고서 |
artifacts/drift-summary.json |
두 비교의 지표 |
artifacts/environment.json |
실제 실행 환경 |
artifacts/result.json |
전체 완료 여부와 검증 요약 |
API를 계속 띄워 직접 호출하는 명령은 실습 README에 별도로 정리했다. 서버 포트는 호스트의 127.0.0.1에 연결하도록 구성했다.
명령은 이번 실습의 코드 폴더를 기준으로 한다. 재현에는 Python 스크립트, Dockerfile, 고정한 패키지 목록이 함께 필요하다. 모델과 보고서는 실행 과정에서 새로 생성된다.