폴더를 열었더니 분석_최종.ipynb, 분석_최종_v2.ipynb, 진짜최종.ipynb 같은 파일이 16개 있었다. 전부 내가 만든 것이다. 실험할 때는 노트북만큼 편한 게 없었는데, 이걸 매주 돌려야 하는 처지가 되자 문제가 드러났다. “지난주 그 숫자 어떻게 나온 거예요?”라는 질문에 나조차 답을 못 했다. 오늘은 이 더미를 하나의 파이프라인으로 굳힌 과정을 남긴다.

ℹ️ 아래 코드·수치는 전부 합성/더미다. 실무 데이터나 고객 정보는 포함되지 않는다.

왜 노트북은 운영에 부적합했나?

한마디로 재현성(reproducibility)이 없었다. 노트북은 셀을 위에서 아래로 실행한다고 착각하기 쉽지만, 실제로는 내가 이 셀 저 셀을 손으로 눌러가며 만든 “실행 순서의 역사”가 결과에 박혀 있다.

flowchart TD
    A["① 노트북 16개<br/>(탐색용)"] --> B["② 셀 실행 순서<br/>사람 손에 의존"]
    B --> C["③ 숨은 전역 상태<br/>(메모리에만 존재)"]
    C --> D["④ 결과 재현 불가<br/>'그때는 됐는데'"]
    A --> E["⑤ 중복 코드<br/>전처리 6벌"]
    E --> F["⑥ 한 곳 고치면<br/>나머지 5곳 안 고쳐짐"]
    classDef bad fill:#fff0f0,stroke:#e03131,color:#b02020;
    classDef mid fill:#fff9db,stroke:#f08c00,color:#a05a00;
    class A,B,E mid
    class C,D,F bad

특히 무서웠던 건 숨은 상태(hidden state)다. 앞 셀에서 만든 변수가 메모리에 살아 있어서, 그 셀을 지워도 아래 코드가 멀쩡히 돌아간다. 커널을 재시작하는 순간 전부 무너진다.

항목노트북 더미파이프라인
실행 방식셀을 손으로 클릭python run.py 한 줄
순서 보장사람 기억에 의존DAG가 강제
전처리 코드6벌 복붙함수 1개 재사용
결과 재현”그때는 됐는데”매번 동일
리뷰diff 지옥(JSON)일반 .py diff

무엇부터 뜯어냈나 — 인벤토리와 공통 함수 추출

리팩터링의 첫 삽은 코딩이 아니라 정리(inventory)였다. 노트북 16개를 열어 “이 노트북은 무엇을 입력받아 무엇을 뱉는가”만 표로 적었다. 그러자 절반이 같은 전처리를 복붙하고 있다는 게 보였다.

flowchart LR
    subgraph OLD["① 리팩터링 전"]
        N1["nb_a"] --> P1["전처리 복붙"]
        N2["nb_b"] --> P2["전처리 복붙"]
        N3["nb_c"] --> P3["전처리 복붙"]
    end
    subgraph NEW["② 리팩터링 후"]
        M["src/clean.py<br/>clean_events()"]
        N4["nb_a"] --> M
        N5["nb_b"] --> M
        N6["nb_c"] --> M
    end
    OLD --> NEW
    classDef old fill:#fff9db,stroke:#f08c00,color:#a05a00;
    classDef new fill:#e7f5ff,stroke:#1c7ed6,color:#10548f;
    class N1,N2,N3,P1,P2,P3 old
    class M,N4,N5,N6 new

중복 전처리를 모듈 하나로 뽑았다. 노트북에 흩어져 있던 로직이 테스트 가능한 순수 함수(pure function)가 되는 순간이다.

# src/clean.py — 합성 예시
import pandas as pd
 
def clean_events(df: pd.DataFrame) -> pd.DataFrame:
    """원시 이벤트 로그를 표준 스키마로 정리한다.
    입력/출력이 명확하고 전역 상태에 의존하지 않는다."""
    out = df.copy()
    out["ts"] = pd.to_datetime(out["ts"], errors="coerce")
    out = out.dropna(subset=["ts", "user_id"])
    out["day"] = out["ts"].dt.floor("D")
    return out.reset_index(drop=True)

어떻게 실행 순서를 강제했나 — 노트북을 DAG로

핵심은 “사람이 순서를 기억한다”를 “코드가 순서를 강제한다”로 바꾸는 것이다. 각 노트북을 단계(stage) 함수로 만들고, 입력 파일이 있어야 다음 단계가 돌도록 의존성을 명시했다. 이게 곧 DAG(방향 비순환 그래프)다 — 화살표가 한 방향으로만 흐르고 되돌아오지 않는 작업 흐름.

flowchart TD
    R["⓪ raw/*.csv"] --> S1["① ingest<br/>수집·검증"]
    S1 --> S2["② clean<br/>clean_events()"]
    S2 --> S3["③ feature<br/>지표 계산"]
    S3 --> S4["④ aggregate<br/>일자별 집계"]
    S4 --> S5["⑤ report<br/>표·그래프 산출"]
    S2 --> S6["⑥ qa_check<br/>행수·널 검증"]
    S6 --> S5
    classDef st fill:#e6fcf5,stroke:#0ca678,color:#087f5b;
    classDef io fill:#f3f0ff,stroke:#7048e8,color:#5f3dc4;
    class S1,S2,S3,S4,S5,S6 st
    class R io

오케스트레이션은 거창한 도구 없이 시작했다. 얇은 러너 하나면 충분하다. 나중에 규모가 커지면 그때 전용 도구로 갈아타면 된다.

# run.py — 합성 예시
from src import ingest, clean, feature, aggregate, report
 
def main():
    raw = ingest.load("data/raw")        # ①
    events = clean.clean_events(raw)     # ②
    feats = feature.build(events)        # ③
    daily = aggregate.by_day(feats)      # ④
    report.render(daily, "out/")         # ⑤
    print("pipeline done:", len(daily), "rows")
 
if __name__ == "__main__":
    main()

이제 python run.py 한 줄이면 끝이다. 커널 상태도, 셀 클릭 순서도 개입할 여지가 없다.

굳히고 나서 무엇이 달라졌나 — 검증과 남은 노트북의 역할

파이프라인이 생기니 검증(QA)을 흐름 안에 넣을 수 있었다. 중간 단계에서 행 수와 널 비율을 확인하고, 기준을 벗어나면 아예 멈추게 했다. 조용히 틀린 숫자를 뱉는 것보다 시끄럽게 멈추는 게 낫다.

# src/qa_check.py — 합성 예시
def assert_sane(df, min_rows=100, max_null=0.05):
    assert len(df) >= min_rows, f"행 수 부족: {len(df)}"
    null_ratio = df.isna().mean().max()
    assert null_ratio <= max_null, f"널 비율 초과: {null_ratio:.2%}"

그렇다고 노트북을 버린 건 아니다. 역할을 나눴을 뿐이다.

목적도구재현성 요구
새 가설 탐색·시각화노트북(자유롭게)낮음
매주 돌리는 운영 산출.py 파이프라인높음
파이프라인 결과 검토노트북(읽기 전용)중간

정리하면, 노트북은 실험(exploration)의 언어이고 파이프라인은 운영(operation)의 언어다. 둘을 억지로 하나로 쓰려던 게 문제였다. 탐색은 노트북에서 자유롭게 하되, “매주 같은 결과가 나와야 하는 것”만 골라 스크립트로 굳히니 “그 숫자 어떻게 나왔어요?”라는 질문에 이제는 run.py를 가리키며 답할 수 있게 됐다.