🤖

본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.

⚠️

본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.

이미지 로딩 중...

ML 프로젝트 구조 설계 완벽 가이드 - 슬라이드 1/9
A

AI Generated

2025. 11. 29. · 21 Views

ML 프로젝트 구조 설계 완벽 가이드

머신러닝 프로젝트를 체계적으로 구성하는 방법을 알아봅니다. 데이터 수집부터 모델 배포까지, 실무에서 검증된 폴더 구조와 설계 패턴을 초보자도 이해할 수 있게 설명합니다.


목차

  1. 프로젝트_디렉토리_구조
  2. 설정_파일_관리
  3. 데이터_파이프라인_설계
  4. 모델_학습_모듈화
  5. 실험_추적_관리
  6. 모델_저장과_버전_관리
  7. 테스트_코드_작성
  8. 로깅과_모니터링

1. 프로젝트 디렉토리 구조

신입 개발자 김개발 씨는 첫 번째 머신러닝 프로젝트를 맡게 되었습니다. 열심히 주피터 노트북에서 모델을 만들었는데, 선배가 한마디 합니다.

"이거 폴더 정리 좀 해야겠는데? 나중에 유지보수 어떻게 하려고?"

프로젝트 디렉토리 구조는 ML 프로젝트의 뼈대와 같습니다. 마치 잘 정리된 서재처럼, 각 파일이 제자리에 있어야 필요할 때 빠르게 찾을 수 있습니다.

체계적인 구조는 협업을 쉽게 만들고, 프로젝트가 커져도 관리가 수월해집니다.

다음 코드를 살펴봅시다.

ml_project/
├── data/
│   ├── raw/              # 원본 데이터 (절대 수정하지 않음)
│   ├── processed/        # 전처리된 데이터
│   └── external/         # 외부 데이터 소스
├── src/
│   ├── data/             # 데이터 로딩, 전처리 모듈
│   ├── features/         # 피처 엔지니어링 모듈
│   ├── models/           # 모델 정의 및 학습 코드
│   └── utils/            # 유틸리티 함수들
├── notebooks/            # 실험용 주피터 노트북
├── models/               # 학습된 모델 저장
├── configs/              # 설정 파일들
├── tests/                # 테스트 코드
└── requirements.txt      # 의존성 패키지 목록

김개발 씨는 입사한 지 2주 된 신입 데이터 사이언티스트입니다. 상품 추천 모델을 개발하라는 첫 미션을 받고 의욕에 넘쳤습니다.

노트북 파일 하나에 데이터 로딩부터 모델 학습까지 몽땅 넣어서 결과를 만들어냈습니다. 그런데 코드 리뷰 시간, 선배 박시니어 씨의 표정이 심상치 않습니다.

"개발 씨, 이 코드 다른 사람이 어떻게 이해하죠? 데이터는 어디 있고, 모델은 어디 저장되어 있어요?" 그제야 김개발 씨는 깨달았습니다.

혼자 작업할 때는 괜찮았지만, 팀 프로젝트에서는 체계가 필요하다는 것을요. 프로젝트 디렉토리 구조란 무엇일까요?

쉽게 비유하자면, 이것은 마치 대형 마트의 진열 방식과 같습니다. 과일은 과일 코너에, 생선은 생선 코너에 있어야 손님이 빠르게 찾을 수 있습니다.

만약 사과가 어느 날은 과일 코너에, 어느 날은 냉동식품 코너에 있다면 어떻게 될까요? 마찬가지로 ML 프로젝트에서도 데이터, 코드, 모델이 정해진 위치에 있어야 합니다.

구조가 없던 시절에는 어땠을까요? 개발자들은 "그 파일 어디 있더라?" 하며 폴더를 헤매야 했습니다.

같은 데이터를 여러 번 복사해두고 어떤 게 최신인지 모르는 일도 흔했습니다. 더 심각한 문제는 원본 데이터를 실수로 수정해버리는 경우였습니다.

바로 이런 문제를 해결하기 위해 표준화된 디렉토리 구조가 등장했습니다. data/raw 폴더에는 원본 데이터를 넣고 절대 건드리지 않습니다.

data/processed에는 전처리된 데이터를 저장합니다. 이렇게 하면 언제든 원본으로 돌아갈 수 있습니다.

src 폴더는 실제 파이썬 코드가 들어가는 곳입니다. 데이터 처리, 피처 엔지니어링, 모델 학습 코드를 기능별로 분리해둡니다.

notebooks 폴더는 실험과 탐색을 위한 공간입니다. 여기서 아이디어를 검증하고, 검증된 코드는 src로 옮깁니다.

models 폴더에는 학습이 완료된 모델 파일을 저장합니다. configs 폴더에는 하이퍼파라미터나 경로 설정을 담은 파일을 둡니다.

코드 수정 없이 설정만 바꿔서 다양한 실험을 할 수 있습니다. 실제 현업에서는 이 구조가 표준처럼 쓰입니다.

새로운 팀원이 합류해도 금방 프로젝트를 파악할 수 있기 때문입니다. Cookiecutter Data Science 같은 템플릿 도구도 이와 비슷한 구조를 제공합니다.

주의할 점이 있습니다. 처음부터 완벽한 구조를 만들려고 하지 마세요.

프로젝트가 진행되면서 필요에 따라 폴더를 추가하면 됩니다. 다만 원본 데이터 보존코드와 노트북 분리 원칙만큼은 처음부터 지켜야 합니다.

박시니어 씨의 조언을 들은 김개발 씨는 프로젝트를 다시 정리했습니다. "이렇게 하니까 제 코드도 훨씬 깔끔해 보이네요!"

실전 팁

💡 - 원본 데이터는 data/raw에 보관하고 읽기 전용으로 취급하세요

  • 노트북은 실험용으로만 사용하고, 검증된 코드는 src로 이동하세요
  • README.md 파일에 각 폴더의 역할을 간단히 설명해두면 협업이 수월해집니다

2. 설정 파일 관리

김개발 씨가 모델을 학습시키려는데 문제가 생겼습니다. "어?

분명 학습률을 0.001로 했는데, 지금 보니까 0.01이네?" 코드 곳곳에 흩어진 설정값들이 혼란을 일으킨 것입니다.

설정 파일 관리는 하이퍼파라미터와 경로 같은 설정값을 코드와 분리하는 방법입니다. 마치 요리 레시피에서 재료 목록을 따로 정리해두는 것처럼, 설정값을 한 곳에 모아두면 실험 재현과 수정이 훨씬 쉬워집니다.

다음 코드를 살펴봅시다.

# configs/config.yaml
data:
  raw_path: "data/raw/dataset.csv"
  processed_path: "data/processed/"
  test_size: 0.2

model:
  name: "random_forest"
  n_estimators: 100
  max_depth: 10
  random_state: 42

training:
  learning_rate: 0.001
  epochs: 50
  batch_size: 32

# src/utils/config.py
import yaml
from pathlib import Path

def load_config(config_path: str = "configs/config.yaml") -> dict:
    """설정 파일을 로드하여 딕셔너리로 반환합니다."""
    with open(config_path, "r") as f:
        config = yaml.safe_load(f)
    return config

# 사용 예시
config = load_config()
learning_rate = config["training"]["learning_rate"]

김개발 씨는 모델 성능을 높이기 위해 다양한 실험을 진행 중입니다. 학습률도 바꿔보고, 에폭 수도 조정해보고, 배치 크기도 변경해봤습니다.

그런데 문제가 생겼습니다. 가장 성능이 좋았던 조합이 뭐였더라?

코드 여기저기에 하드코딩된 숫자들을 하나씩 찾아다니며 "이게 그때 그 값이었나?" 고민하는 모습을 본 박시니어 씨가 다가왔습니다. "개발 씨, 설정 파일 써본 적 있어요?" 설정 파일이란 무엇일까요?

이것은 마치 요리할 때 레시피 카드를 사용하는 것과 같습니다. 재료의 양과 조리 시간을 레시피 카드에 적어두면, 같은 요리를 여러 번 만들어도 일관된 맛을 낼 수 있습니다.

ML 프로젝트에서 설정 파일은 바로 이 레시피 카드 역할을 합니다. 설정 파일이 없던 시절에는 어떤 일이 벌어졌을까요?

개발자들은 코드 안에 직접 숫자를 적어넣었습니다. learning_rate = 0.001 이런 식으로요.

실험할 때마다 코드를 수정해야 했고, 어떤 조합으로 학습했는지 기록하기도 어려웠습니다. 최악의 경우, 좋은 성능을 냈던 설정을 잃어버리기도 했습니다.

YAML 파일은 설정을 저장하는 가장 인기 있는 형식입니다. 사람이 읽기 쉽고, 계층 구조를 표현할 수 있습니다.

data, model, training 같은 카테고리로 설정을 묶어두면 관리가 편합니다. 코드를 살펴보겠습니다.

config.yaml 파일에는 데이터 경로, 모델 파라미터, 학습 설정이 모두 담겨 있습니다. load_config 함수는 이 파일을 읽어서 파이썬 딕셔너리로 변환합니다.

이제 코드 어디서든 config["training"]["learning_rate"]처럼 설정값을 가져올 수 있습니다. 실무에서는 환경별로 설정 파일을 분리하기도 합니다.

config_dev.yaml, config_prod.yaml처럼 개발용과 운영용을 나누는 것이죠. 실험할 때마다 설정 파일을 복사해두면 나중에 어떤 조건에서 좋은 결과가 나왔는지 쉽게 확인할 수 있습니다.

한 가지 주의할 점이 있습니다. 비밀번호나 API 키 같은 민감한 정보는 설정 파일에 넣지 마세요.

이런 정보는 환경 변수를 사용하는 것이 안전합니다. 김개발 씨는 모든 실험 설정을 YAML 파일로 정리했습니다.

"이제 지난주에 성능 좋았던 설정이 뭔지 바로 찾을 수 있어요!"

실전 팁

💡 - 실험할 때마다 설정 파일을 버전 관리하면 재현성이 높아집니다

  • 민감한 정보는 환경 변수로 관리하고, .env 파일은 .gitignore에 추가하세요
  • pydantic이나 hydra 같은 라이브러리를 사용하면 설정 검증도 자동화할 수 있습니다

3. 데이터 파이프라인 설계

"데이터 전처리 코드 좀 봐주세요." 김개발 씨가 보여준 코드는 500줄짜리 단일 함수였습니다. 데이터 로딩, 결측치 처리, 스케일링, 인코딩이 전부 한 덩어리로 뭉쳐 있었습니다.

데이터 파이프라인은 원본 데이터가 모델에 입력되기까지 거치는 일련의 처리 과정입니다. 마치 공장의 조립 라인처럼, 각 단계가 명확히 분리되어 있어야 문제가 생겼을 때 어디를 고쳐야 하는지 알 수 있습니다.

다음 코드를 살펴봅시다.

# src/data/pipeline.py
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer

def create_preprocessing_pipeline(
    numeric_features: list,
    categorical_features: list
) -> ColumnTransformer:
    """전처리 파이프라인을 생성합니다."""

    # 수치형 변수 처리: 결측치 -> 평균, 스케일링
    numeric_transformer = Pipeline(steps=[
        ("imputer", SimpleImputer(strategy="mean")),
        ("scaler", StandardScaler())
    ])

    # 범주형 변수 처리: 결측치 -> 최빈값, 원핫인코딩
    categorical_transformer = Pipeline(steps=[
        ("imputer", SimpleImputer(strategy="most_frequent")),
        ("encoder", OneHotEncoder(handle_unknown="ignore"))
    ])

    # 컬럼별로 다른 처리 적용
    preprocessor = ColumnTransformer(transformers=[
        ("num", numeric_transformer, numeric_features),
        ("cat", categorical_transformer, categorical_features)
    ])

    return preprocessor

김개발 씨의 전처리 코드는 솔직히 말해서 스파게티 면발처럼 얽혀 있었습니다. 데이터를 불러온 다음 바로 결측치를 채우고, 그 밑에서 스케일링하고, 또 그 밑에서 인코딩하고.

코드가 위에서 아래로 쭉 흘러가긴 하는데, 중간에 뭔가 수정하려면 전체를 다 건드려야 했습니다. 박시니어 씨가 물었습니다.

"만약 결측치 처리 방법만 바꾸고 싶으면 어떻게 해요?" 김개발 씨는 대답할 수 없었습니다. 코드가 너무 얽혀 있어서 결측치 처리 부분만 분리해내기가 어려웠기 때문입니다.

데이터 파이프라인은 마치 공장의 조립 라인과 같습니다. 자동차 공장을 생각해보세요.

차체 조립, 도색, 내장재 설치, 검수가 각각 분리된 라인에서 이루어집니다. 도색 품질에 문제가 있으면 도색 라인만 점검하면 됩니다.

데이터 파이프라인도 마찬가지입니다. 파이프라인이 없던 시절의 코드는 어땠을까요?

전처리 로직이 학습 코드와 섞여 있었습니다. 학습할 때 쓴 전처리를 예측할 때도 똑같이 적용해야 하는데, 코드를 복사 붙여넣기 하다가 실수하는 일이 잦았습니다.

학습 데이터와 테스트 데이터에 다른 전처리가 적용되어 성능이 이상하게 나오는 경우도 있었습니다. sklearn의 Pipeline은 이 문제를 우아하게 해결합니다.

여러 전처리 단계를 하나로 묶어서, fit과 transform을 일관되게 적용할 수 있습니다. 학습할 때 fit_transform을 호출하고, 예측할 때는 transform만 호출하면 됩니다.

코드를 살펴보겠습니다. numeric_transformer는 수치형 변수를 위한 파이프라인입니다.

먼저 결측치를 평균으로 채우고, 그 다음 표준화 스케일링을 적용합니다. categorical_transformer는 범주형 변수를 위한 파이프라인으로, 결측치를 최빈값으로 채우고 원핫인코딩을 적용합니다.

ColumnTransformer는 컬럼별로 다른 처리를 적용할 수 있게 해줍니다. 수치형 컬럼에는 numeric_transformer를, 범주형 컬럼에는 categorical_transformer를 적용합니다.

이렇게 만든 전처리기는 모델과 함께 저장해서 나중에 동일하게 사용할 수 있습니다. 실무에서는 이 파이프라인을 더 확장하기도 합니다.

피처 선택, 차원 축소, 이상치 제거 같은 단계를 추가할 수 있습니다. 각 단계가 독립적이기 때문에 필요한 부분만 교체하거나 순서를 바꾸기 쉽습니다.

주의할 점이 있습니다. 파이프라인 안에서 데이터 누수가 발생하지 않도록 해야 합니다.

예를 들어 스케일링의 평균과 표준편차는 학습 데이터에서만 계산해야 합니다. 다행히 sklearn Pipeline을 사용하면 이런 실수를 방지할 수 있습니다.

김개발 씨는 스파게티 코드를 깔끔한 파이프라인으로 리팩토링했습니다. "이제 결측치 처리 방법 바꾸려면 한 줄만 수정하면 되네요!"

실전 팁

💡 - 파이프라인을 모델과 함께 pickle로 저장하면 예측 시 동일한 전처리를 보장할 수 있습니다

  • 커스텀 전처리가 필요하면 BaseEstimator와 TransformerMixin을 상속받아 클래스를 만드세요
  • 파이프라인 단계별 결과를 확인하고 싶으면 set_output(transform="pandas")를 활용하세요

4. 모델 학습 모듈화

김개발 씨가 랜덤 포레스트로 좋은 결과를 얻었습니다. "XGBoost도 써볼까?" 그런데 코드를 보니 랜덤 포레스트가 너무 깊숙이 박혀 있어서 모델을 바꾸려면 코드 전체를 뜯어고쳐야 했습니다.

모델 학습 모듈화는 모델 생성, 학습, 평가 로직을 유연하게 교체할 수 있도록 구조화하는 것입니다. 마치 레고 블록처럼 모델 부분만 떼어내고 다른 것으로 바꿔 끼울 수 있어야 다양한 실험이 가능해집니다.

다음 코드를 살펴봅시다.

# src/models/trainer.py
from abc import ABC, abstractmethod
from sklearn.ensemble import RandomForestClassifier
from xgboost import XGBClassifier
import joblib

class BaseModel(ABC):
    """모든 모델의 기본 인터페이스를 정의합니다."""

    @abstractmethod
    def train(self, X, y):
        pass

    @abstractmethod
    def predict(self, X):
        pass

    def save(self, path: str):
        joblib.dump(self.model, path)

    def load(self, path: str):
        self.model = joblib.load(path)

class RandomForestModel(BaseModel):
    def __init__(self, **params):
        self.model = RandomForestClassifier(**params)

    def train(self, X, y):
        self.model.fit(X, y)
        return self

    def predict(self, X):
        return self.model.predict(X)

class XGBoostModel(BaseModel):
    def __init__(self, **params):
        self.model = XGBClassifier(**params)

    def train(self, X, y):
        self.model.fit(X, y)
        return self

    def predict(self, X):
        return self.model.predict(X)

김개발 씨는 모델 비교 실험을 하고 싶었습니다. 랜덤 포레스트, XGBoost, LightGBM을 차례로 돌려보고 가장 좋은 것을 선택하려고요.

그런데 코드를 보니 model = RandomForestClassifier(...)가 학습 코드 한가운데 떡하니 박혀 있었습니다. 모델을 바꾸려면 그 줄을 직접 수정해야 했습니다.

박시니어 씨가 조언했습니다. "모델을 레고 블록처럼 교체할 수 있게 만들어봐요.

그러면 실험이 훨씬 수월해질 거예요." 모델 모듈화란 무엇일까요? 이것은 마치 멀티탭에 다양한 전자기기를 꽂는 것과 같습니다.

멀티탭의 콘센트 규격이 동일하기 때문에 어떤 기기든 꽂을 수 있습니다. 마찬가지로, 모든 모델이 동일한 인터페이스를 따르면 어떤 모델이든 같은 방식으로 사용할 수 있습니다.

모듈화가 안 된 코드는 어떤 문제가 있을까요? 모델을 바꿀 때마다 여러 곳을 수정해야 합니다.

실수로 한 군데를 빠뜨리면 버그가 생깁니다. 새로운 모델을 추가하려면 기존 코드를 건드려야 해서 이미 잘 돌아가던 부분에 영향을 줄 수 있습니다.

코드를 살펴보겠습니다. BaseModel은 추상 클래스로, 모든 모델이 반드시 구현해야 하는 메서드를 정의합니다.

train과 predict는 추상 메서드로, 각 모델 클래스에서 반드시 구현해야 합니다. save와 load는 공통 로직이므로 기본 클래스에서 구현해둡니다.

RandomForestModelXGBoostModel은 BaseModel을 상속받아 각자의 방식으로 train과 predict를 구현합니다. 인터페이스가 동일하기 때문에 호출하는 쪽에서는 어떤 모델인지 신경 쓸 필요가 없습니다.

실무에서는 이 구조를 더 발전시킵니다. 팩토리 패턴을 적용해서 설정 파일의 model_name에 따라 자동으로 적절한 모델 클래스를 생성하기도 합니다.

이렇게 하면 코드 한 줄 수정 없이 설정만 바꿔서 다른 모델로 실험할 수 있습니다. 한 가지 주의할 점이 있습니다.

인터페이스를 너무 복잡하게 만들지 마세요. train, predict, save, load 정도면 대부분의 경우에 충분합니다.

필요할 때 기능을 추가하는 것이 처음부터 모든 것을 고려하는 것보다 낫습니다. 김개발 씨는 모델을 모듈화한 후 기뻤습니다.

"이제 설정 파일에서 model_name만 바꾸면 다른 모델로 실험할 수 있어요!"

실전 팁

💡 - 모델 팩토리 함수를 만들어서 설정 파일 기반으로 모델을 생성하면 더 유연해집니다

  • 모델별 특수한 파라미터는 **params로 받아서 처리하세요
  • 평가 메트릭도 비슷하게 모듈화하면 다양한 지표를 쉽게 비교할 수 있습니다

5. 실험 추적 관리

김개발 씨는 지난 한 달간 수십 번의 실험을 진행했습니다. 그런데 상사가 물었습니다.

"지난주 화요일에 돌렸던 모델 결과 좀 다시 볼 수 있어요?" 김개발 씨는 식은땀이 났습니다.

실험 추적 관리는 모든 실험의 파라미터, 메트릭, 결과물을 체계적으로 기록하는 것입니다. 마치 과학자가 실험 노트를 꼼꼼히 적어두는 것처럼, ML 엔지니어도 모든 실험을 기록해야 나중에 재현하고 비교할 수 있습니다.

다음 코드를 살펴봅시다.

# src/utils/experiment.py
import mlflow
from datetime import datetime
import json
from pathlib import Path

class ExperimentTracker:
    """실험을 추적하고 기록하는 클래스입니다."""

    def __init__(self, experiment_name: str):
        mlflow.set_experiment(experiment_name)
        self.run = None

    def start_run(self, run_name: str = None):
        """새로운 실험 실행을 시작합니다."""
        self.run = mlflow.start_run(run_name=run_name)
        return self

    def log_params(self, params: dict):
        """하이퍼파라미터를 기록합니다."""
        mlflow.log_params(params)

    def log_metrics(self, metrics: dict):
        """평가 지표를 기록합니다."""
        mlflow.log_metrics(metrics)

    def log_model(self, model, artifact_path: str):
        """학습된 모델을 저장합니다."""
        mlflow.sklearn.log_model(model, artifact_path)

    def end_run(self):
        """실험 실행을 종료합니다."""
        mlflow.end_run()

# 사용 예시
tracker = ExperimentTracker("product_recommendation")
tracker.start_run("rf_experiment_v1")
tracker.log_params({"n_estimators": 100, "max_depth": 10})
tracker.log_metrics({"accuracy": 0.85, "f1_score": 0.82})
tracker.end_run()

김개발 씨의 고민은 많은 데이터 사이언티스트가 겪는 문제입니다. 실험을 열심히 돌리다 보면 "그때 그 결과가 어땠더라?"라는 질문에 답하기 어려워집니다.

엑셀에 수동으로 기록하다가 빠뜨리기도 하고, 나중에 보니 어떤 코드로 돌린 건지 기억이 안 나기도 합니다. 박시니어 씨가 물었습니다.

"실험 추적 도구 써본 적 있어요?" 김개발 씨는 고개를 저었습니다. 실험 추적 관리는 마치 과학 실험 노트와 같습니다.

화학자가 실험할 때 시약의 양, 온도, 시간, 결과를 꼼꼼히 기록하는 것처럼, ML 엔지니어도 하이퍼파라미터, 데이터 버전, 평가 지표, 모델 파일을 기록해야 합니다. 그래야 나중에 "왜 그 실험이 잘 됐는지" 또는 "왜 안 됐는지" 분석할 수 있습니다.

실험 추적이 없던 시절에는 어땠을까요? 개발자들은 주피터 노트북에 결과를 출력하고 그냥 넘어갔습니다.

좋은 결과가 나오면 기뻐하다가, 일주일 후 다시 돌려보면 결과가 다르게 나왔습니다. 랜덤 시드를 고정 안 했거나, 데이터가 바뀌었거나, 파라미터가 달랐거나.

원인을 찾을 방법이 없었습니다. MLflow는 가장 널리 쓰이는 실험 추적 도구입니다.

파라미터, 메트릭, 모델 아티팩트를 자동으로 기록하고, 웹 UI에서 실험들을 비교할 수 있습니다. 로컬에서 무료로 사용할 수 있어서 시작하기도 쉽습니다.

코드를 살펴보겠습니다. ExperimentTracker 클래스는 MLflow를 감싸서 더 편리하게 사용할 수 있게 합니다.

start_run으로 실험을 시작하고, log_params로 파라미터를, log_metrics로 결과를 기록합니다. log_model은 학습된 모델을 저장합니다.

마지막에 end_run으로 실험을 마무리합니다. 실무에서는 CI/CD 파이프라인과 연동하기도 합니다.

코드가 커밋될 때마다 자동으로 실험이 돌아가고, 결과가 MLflow에 기록됩니다. 여러 사람이 동시에 실험해도 모든 기록이 한 곳에 모이므로 팀 협업이 수월해집니다.

주의할 점이 있습니다. 모든 것을 다 기록하려고 하면 오히려 복잡해집니다.

핵심 파라미터와 주요 메트릭에 집중하세요. 또한 민감한 데이터는 기록하지 않도록 주의해야 합니다.

김개발 씨는 MLflow를 도입한 후 달라졌습니다. "이제 상사가 물어봐도 바로 대답할 수 있어요.

실험 목록에서 검색하면 되니까요!"

실전 팁

💡 - 실험 이름에 날짜와 목적을 포함하면 나중에 찾기 쉽습니다

  • 데이터 버전도 함께 기록하면 재현성이 높아집니다
  • Weights & Biases, Neptune 같은 다른 도구도 비슷한 기능을 제공하니 팀에 맞는 것을 선택하세요

6. 모델 저장과 버전 관리

"프로덕션에 배포된 모델이 뭐예요?" 운영팀의 질문에 김개발 씨는 당황했습니다. models 폴더에 model_v1.pkl, model_v2.pkl, model_final.pkl, model_final_final.pkl이 뒤섞여 있었기 때문입니다.

모델 버전 관리는 학습된 모델을 체계적으로 저장하고 추적하는 방법입니다. 마치 소프트웨어의 버전 관리처럼, 어떤 모델이 언제 만들어졌고 어떤 성능을 보이는지 명확히 기록해야 안전하게 배포하고 롤백할 수 있습니다.

다음 코드를 살펴봅시다.

# src/models/registry.py
import mlflow
from mlflow.tracking import MlflowClient
from datetime import datetime

class ModelRegistry:
    """모델 레지스트리를 관리하는 클래스입니다."""

    def __init__(self):
        self.client = MlflowClient()

    def register_model(
        self,
        run_id: str,
        model_name: str,
        artifact_path: str = "model"
    ) -> str:
        """학습된 모델을 레지스트리에 등록합니다."""
        model_uri = f"runs:/{run_id}/{artifact_path}"
        result = mlflow.register_model(model_uri, model_name)
        return result.version

    def promote_to_production(
        self,
        model_name: str,
        version: str
    ):
        """특정 버전을 프로덕션으로 승격합니다."""
        self.client.transition_model_version_stage(
            name=model_name,
            version=version,
            stage="Production"
        )

    def load_production_model(self, model_name: str):
        """프로덕션 모델을 로드합니다."""
        model_uri = f"models:/{model_name}/Production"
        return mlflow.sklearn.load_model(model_uri)

# 사용 예시
registry = ModelRegistry()
version = registry.register_model(run_id, "recommendation_model")
registry.promote_to_production("recommendation_model", version)

김개발 씨의 models 폴더는 혼돈 그 자체였습니다. model.pkl, model_new.pkl, model_best.pkl, model_0601.pkl.

어떤 게 가장 좋은 모델인지, 어떤 게 현재 서비스 중인 모델인지 알 수 없었습니다. 급하게 롤백해야 하는 상황이 오면 어떤 모델로 돌아가야 할까요?

박시니어 씨가 한숨을 쉬며 말했습니다. "이건 마치 버전 관리 없이 코드를 개발하는 것과 같아요.

Git 없이 코딩한다고 생각해봐요." 모델 버전 관리는 마치 와인 셀러의 관리 시스템과 같습니다. 좋은 와인 셀러에서는 각 와인의 빈티지, 생산지, 입고 날짜가 꼼꼼히 기록됩니다.

어떤 와인이 숙성 중이고, 어떤 와인이 마실 준비가 됐는지 한눈에 파악할 수 있습니다. 모델 레지스트리도 마찬가지 역할을 합니다.

버전 관리가 없으면 어떤 문제가 생길까요? 새 모델을 배포했는데 성능이 오히려 떨어질 수 있습니다.

급하게 이전 모델로 돌아가야 하는데, 어떤 파일이 이전 모델인지 모릅니다. 또한 여러 팀원이 각자 모델을 만들면 어떤 게 공식 모델인지 혼란스러워집니다.

MLflow Model Registry는 이런 문제를 해결합니다. 모델을 등록하면 자동으로 버전 번호가 부여됩니다.

각 버전은 Staging, Production, Archived 같은 상태를 가질 수 있습니다. 프로덕션으로 승격할 때 승인 과정을 거치게 할 수도 있습니다.

코드를 살펴보겠습니다. register_model 메서드는 MLflow 실험의 결과물을 레지스트리에 등록합니다.

실행 ID와 모델 이름을 받아서 새 버전을 생성합니다. promote_to_production 메서드는 특정 버전을 프로덕션 상태로 변경합니다.

load_production_model은 현재 프로덕션 상태인 모델을 로드합니다. 실무에서 이 시스템의 장점은 명확합니다.

새 모델을 배포하기 전에 Staging에서 충분히 테스트할 수 있습니다. 문제가 생기면 이전 프로덕션 버전으로 즉시 롤백할 수 있습니다.

누가 언제 어떤 모델을 프로덕션으로 승격했는지 기록이 남습니다. 주의할 점이 있습니다.

모델만 저장하면 안 됩니다. 해당 모델을 만들 때 사용한 전처리 파이프라인도 함께 저장해야 합니다.

그래야 예측할 때 동일한 전처리를 적용할 수 있습니다. 김개발 씨는 모델 레지스트리를 도입했습니다.

"이제 '프로덕션 모델이 뭐예요?'라는 질문에 자신 있게 대답할 수 있어요!"

실전 팁

💡 - 모델과 전처리 파이프라인을 함께 저장하면 예측 시 일관성을 보장할 수 있습니다

  • 모델 메타데이터에 학습 데이터의 기간이나 버전도 기록해두세요
  • 프로덕션 승격 전 자동화된 테스트를 통과하도록 CI/CD를 구성하면 안전합니다

7. 테스트 코드 작성

새벽 2시, 김개발 씨의 폰이 울렸습니다. "추천 시스템이 이상한 결과를 내보내고 있어요!" 급하게 코드를 확인해보니 전처리 함수에서 예상치 못한 입력이 들어오면서 문제가 생긴 것이었습니다.

ML 테스트 코드는 데이터 전처리, 피처 엔지니어링, 모델 예측 로직이 예상대로 동작하는지 검증합니다. 마치 자동차의 안전 점검처럼, 테스트를 통해 문제를 사전에 발견하고 프로덕션 장애를 예방할 수 있습니다.

다음 코드를 살펴봅시다.

# tests/test_preprocessing.py
import pytest
import pandas as pd
import numpy as np
from src.data.pipeline import create_preprocessing_pipeline

class TestPreprocessing:
    """전처리 파이프라인 테스트 클래스입니다."""

    @pytest.fixture
    def sample_data(self):
        """테스트용 샘플 데이터를 생성합니다."""
        return pd.DataFrame({
            "age": [25, 30, None, 45],
            "salary": [50000, 60000, 70000, None],
            "category": ["A", "B", None, "A"]
        })

    def test_handles_missing_values(self, sample_data):
        """결측치가 올바르게 처리되는지 테스트합니다."""
        pipeline = create_preprocessing_pipeline(
            numeric_features=["age", "salary"],
            categorical_features=["category"]
        )
        result = pipeline.fit_transform(sample_data)

        # 결측치가 없어야 함
        assert not np.isnan(result).any()

    def test_output_shape(self, sample_data):
        """출력 형태가 올바른지 테스트합니다."""
        pipeline = create_preprocessing_pipeline(
            numeric_features=["age", "salary"],
            categorical_features=["category"]
        )
        result = pipeline.fit_transform(sample_data)

        # 행 수는 유지되어야 함
        assert result.shape[0] == len(sample_data)

새벽에 장애 콜을 받은 김개발 씨는 정신이 번쩍 들었습니다. 추천 시스템이 빈 문자열을 입력으로 받으면서 예상치 못한 동작을 한 것입니다.

"이런 경우를 왜 생각 못했을까?" 자책하며 급하게 핫픽스를 배포했습니다. 다음 날, 박시니어 씨가 물었습니다.

"테스트 코드 작성하고 있어요?" 김개발 씨는 머리를 긁적였습니다. "ML 프로젝트도 테스트를 해야 하나요?" ML 테스트 코드는 마치 비행기의 사전 점검 체크리스트와 같습니다.

비행기가 이륙하기 전에 조종사는 수십 개의 항목을 점검합니다. 엔진, 연료, 계기판이 모두 정상인지 확인해야 안전하게 비행할 수 있습니다.

ML 시스템도 마찬가지입니다. 데이터 처리, 피처 생성, 모델 예측이 모두 예상대로 동작하는지 점검해야 합니다.

테스트가 없던 ML 프로젝트는 어땠을까요? 개발자들은 노트북에서 눈으로 결과를 확인했습니다.

"음, 이 정도면 괜찮아 보이네." 하지만 예상치 못한 입력이 들어오면 시스템이 무너졌습니다. 결측치가 갑자기 많아지거나, 새로운 카테고리가 등장하거나, 데이터 형식이 바뀌면 문제가 생겼습니다.

코드를 살펴보겠습니다. pytest는 파이썬에서 가장 널리 쓰이는 테스트 프레임워크입니다.

@pytest.fixture 데코레이터는 테스트에 필요한 데이터를 준비합니다. sample_data 픽스처는 결측치가 포함된 테스트용 데이터프레임을 생성합니다.

test_handles_missing_values 테스트는 결측치가 올바르게 처리되는지 확인합니다. 파이프라인을 통과한 결과에 NaN이 없어야 합니다.

test_output_shape는 출력 형태가 올바른지 검증합니다. 전처리 후에도 행 수는 유지되어야 합니다.

실무에서는 더 다양한 테스트를 작성합니다. 모델의 예측 범위가 유효한지, 특정 입력에 대해 기대한 출력이 나오는지, 학습과 예측에 걸리는 시간이 적절한지 등을 검증합니다.

데이터 검증 테스트도 중요합니다. 입력 데이터의 분포가 학습 데이터와 크게 다르면 경고를 발생시킵니다.

주의할 점이 있습니다. ML 테스트는 일반 소프트웨어 테스트와 다른 점이 있습니다.

모델의 정확한 출력값을 테스트하기는 어렵습니다. 대신 출력의 범위, 형태, 일관성을 테스트합니다.

또한 랜덤성을 제어하기 위해 시드를 고정해야 합니다. 김개발 씨는 테스트 코드를 작성하기 시작했습니다.

시간이 좀 더 걸리지만, 새벽에 장애 콜을 받는 것보다 훨씬 낫습니다. "테스트가 실패하면 배포가 안 되니까, 프로덕션에서 문제가 생길 일이 줄었어요!"

실전 팁

💡 - 엣지 케이스(빈 입력, 극단값, 모든 결측치)를 반드시 테스트하세요

  • CI/CD 파이프라인에 테스트를 포함시켜 자동으로 실행되게 하세요
  • Great Expectations 같은 데이터 검증 도구를 활용하면 데이터 품질 테스트를 쉽게 할 수 있습니다

8. 로깅과 모니터링

"모델 성능이 떨어진 것 같아요." 운영팀의 보고에 김개발 씨가 물었습니다. "언제부터요?

어떻게 알았어요?" 아무도 대답할 수 없었습니다. 모니터링 시스템이 없었기 때문입니다.

로깅과 모니터링은 ML 시스템의 동작 상태를 지속적으로 기록하고 감시하는 것입니다. 마치 병원의 환자 모니터처럼, 시스템의 건강 상태를 실시간으로 파악해야 문제가 커지기 전에 대응할 수 있습니다.

다음 코드를 살펴봅시다.

# src/utils/monitoring.py
import logging
from datetime import datetime
import numpy as np

# 로거 설정
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
    handlers=[
        logging.FileHandler("logs/ml_system.log"),
        logging.StreamHandler()
    ]
)
logger = logging.getLogger(__name__)

class ModelMonitor:
    """모델 예측을 모니터링하는 클래스입니다."""

    def __init__(self, model_name: str):
        self.model_name = model_name
        self.predictions = []

    def log_prediction(self, input_data: dict, prediction, latency_ms: float):
        """예측 결과를 로깅합니다."""
        logger.info(
            f"Model: {self.model_name} | "
            f"Prediction: {prediction} | "
            f"Latency: {latency_ms:.2f}ms"
        )
        self.predictions.append(prediction)

    def check_drift(self, threshold: float = 0.1) -> bool:
        """예측 분포의 드리프트를 감지합니다."""
        if len(self.predictions) < 100:
            return False

        recent = self.predictions[-100:]
        baseline_mean = 0.5  # 기준값 (실제로는 학습 시점의 평균)
        current_mean = np.mean(recent)
        drift = abs(current_mean - baseline_mean)

        if drift > threshold:
            logger.warning(f"Drift detected! Difference: {drift:.4f}")
            return True
        return False

김개발 씨의 추천 모델은 조용히 성능이 떨어지고 있었습니다. 사용자들의 취향이 바뀌었는데, 모델은 여전히 옛날 패턴으로 추천을 하고 있었습니다.

문제는 아무도 이 사실을 몰랐다는 것입니다. 클릭률이 서서히 떨어지다가 어느 날 마케팅팀에서 "요즘 추천이 좀 이상한 것 같아요"라고 말할 때가 되어서야 알게 되었습니다.

박시니어 씨가 말했습니다. "모니터링 없는 ML 시스템은 계기판 없이 비행하는 것과 같아요." 로깅과 모니터링은 마치 건강 검진과 같습니다.

정기적으로 혈압, 혈당, 콜레스테롤을 측정해야 건강 문제를 조기에 발견할 수 있습니다. 증상이 나타난 후에 병원을 찾으면 이미 늦을 수 있습니다.

ML 시스템도 마찬가지입니다. 예측 분포, 응답 시간, 에러율을 지속적으로 측정해야 합니다.

모니터링이 없으면 어떤 일이 벌어질까요? 첫째, 모델이 잘못된 예측을 해도 알 수 없습니다.

둘째, 시스템이 느려져도 사용자가 불평할 때까지 모릅니다. 셋째, 데이터 드리프트가 발생해 모델 성능이 떨어져도 감지할 수 없습니다.

코드를 살펴보겠습니다. 파이썬의 logging 모듈로 기본적인 로깅을 설정합니다.

파일과 콘솔 두 곳에 로그를 출력합니다. ModelMonitor 클래스는 모델 예측을 추적합니다.

log_prediction 메서드는 각 예측의 결과와 응답 시간을 기록합니다. check_drift 메서드가 핵심입니다.

최근 예측들의 평균이 기준값과 많이 차이 나면 드리프트를 감지합니다. 예를 들어 이진 분류 모델에서 긍정 예측 비율이 학습 때는 50%였는데 갑자기 80%로 올라가면 뭔가 잘못된 것입니다.

실무에서는 더 정교한 모니터링 시스템을 구축합니다. PrometheusGrafana로 메트릭 대시보드를 만들고, 임계값을 넘으면 Slack 알림이 오도록 설정합니다.

입력 데이터의 분포 변화를 감지하는 데이터 드리프트 모니터링도 중요합니다. 주의할 점이 있습니다.

너무 많은 것을 로깅하면 오히려 중요한 정보를 놓칠 수 있습니다. 핵심 메트릭 몇 개를 정해서 집중적으로 모니터링하세요.

또한 로그 파일이 무한정 커지지 않도록 로테이션 설정도 필요합니다. 김개발 씨는 모니터링 시스템을 구축했습니다.

이제 대시보드에서 모델의 상태를 실시간으로 확인할 수 있습니다. "예측 분포가 이상하면 알림이 와서, 문제가 커지기 전에 대응할 수 있어요!"

실전 팁

💡 - 예측 지연시간, 에러율, 예측 분포를 핵심 메트릭으로 모니터링하세요

  • 임계값 기반 알림을 설정해서 이상 징후를 조기에 감지하세요
  • Evidently, Whylogs 같은 ML 모니터링 전문 도구도 고려해보세요

이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!

#Python#MLOps#ProjectStructure#DataPipeline#ModelDeployment#Data Science

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.