Fintech 회고 (1) - Prophet을 실제 프로덕트에 이식하기

Published:

Fintech 회고 시리즈

  1. Prophet 기획 ← 현재
  2. 전체 아키텍처 기획
  3. GPT Classifier 기획
  4. 운영 회고

SSAFY 핀테크 프로젝트에서 거래 내역 CSV를 받아서 “다음 달 카테고리별 지출”을 예측하는 기능을 만들었다. 엔진은 Facebook Prophet. 회고는 4편으로 쪼갰는데, 이 글은 첫 번째로 Prophet 기획 — 왜 골랐고, 어떻게 튜닝했고, 논문이 시키는데 안 한 게 뭔지.

Prophet 이론 자체는 논문 리뷰 에 따로 정리해뒀다. 이 글은 “내 코드 이 줄에서 왜 이 파라미터 값을 골랐나” 에 집중.

순서가 뒤집혀 있었다

논문을 먼저 읽고 Prophet을 고른 게 아니다. 프로젝트 요구사항이 “거래 내역에서 카테고리별 지출 예측” 이었고, 며칠 뒤에 Prophet이 후보에 올라왔다. 튜토리얼 두 개 훑어보고 Prophet().fit(df) 로 일단 돌아가게 만들고, 그다음에 논문을 읽으러 갔다. 돌려보고 나서 논문을 읽으니 “아, 그래서 이 파라미터가 여기 있는 거구나” 가 반대로 매핑되는 이상한 경험.

IoT 회고(2025-10-03)에서 “프로젝트 → Kafka 딥다이브” 순서로 간 것과 똑같은 리듬이다. 돌아가는 걸 먼저 만들고 나서 이론을 보면, 내가 이미 어느 결정을 내렸는지 가 보이는 장점이 있음.

왜 Prophet이었나 — ARIMA는 왜 아니었나

우리 데이터 (dummy.csv, 1,497개 거래)의 특성:

feature우리 데이터
다중 계절성주간 (주말 > 평일), 월간 (월초 지출 스파이크)
트렌드 변화카테고리별로 다름. 카페는 완만, 교통은 월 단위 plateau
이상치분명히 있음 (충동구매)
휴일 효과있지만 일단 보류 (후술)
히스토리 길이약 1년 분량

Prophet 논문이 말하는 비즈니스 시계열의 4가지 특징과 거의 그대로 매칭. ARIMA 계열도 후보였지만 접었다. 이유:

  1. 카테고리가 13개. ARIMA는 p, d, q 를 13번 튜닝해야 함. auto.arima 도 있지만 우리 데이터처럼 trend change가 있으면 큰 오차로 망가짐.
  2. 튜닝할 때 넘겨받을 다음 사람이 비전공자. SSAFY 팀 구성상 “이 값을 왜 이렇게 뒀는지” 코드로 설명 가능해야 함. changepoint_prior_scale=0.1p=1, d=1, q=2 보다는 설명 가능.
  3. 데이터 빵꾸. 카페 안 가는 날, 교통 안 쓰는 날이 많음. Prophet은 결측치에 관대 (회귀라서).

정확도 벤치마크는 안 돌렸다. 솔직히 하고 싶었는데 스코프 밖이었다. 이게 Part 4의 “TODO” 중 하나.

카테고리별로 다른 Prophet 3세트

핵심 설계 결정. 13개 카테고리를 3가지 프로필로 나눴다. analysis/app/services/prophet_service.pytrain_prophet_model 에 그대로 들어가있음.

프로필대상 카테고리weeklyyearlyseasonality_modechangepoint_prior_scale
A (food-like)식비, 카페, 마트/편의점TrueFalseadditive0.1
B (transport)교통/차량FalseFalseadditive (+ 커스텀 monthly)0.05
C (default)나머지 모든 카테고리TrueFalsemultiplicative0.05

daily_seasonality=False, interval_width=0.95 는 전 프로필 공통. 논문 리뷰에 써뒀듯 daily는 거래 데이터에선 너무 노이즈.

왜 식비/카페는 cps=0.1 인가

식비·카페는 트렌드가 자주 바뀐다. 팀 구성원 점심값이 오르면 식비 기준선이 한 달 만에 점프. 계절학기 시작하면 카페가 급증. 이런 slope 변화에 Prophet이 민감하게 반응하길 원했다.

Prophet의 changepoint 자동 탐지는 Laplace prior 로 하는데, scale이 커질수록 trend가 유연 → 자주 구부러진다. 식비만 0.1 로 올려 유연하게 두고, 나머지는 기본값 0.05 로 경직되게 남겼다.

multiplicative 가 기본인가

Default 프로필만 multiplicative. 나머지 두 개는 additive. 이유 — 지출은 “금액이 일정하게 더해지는 구조” 보다 “크기에 비례해서 흔들리는 구조” 에 가깝다. 교육비가 기준선 5배로 뛰면 계절성 진폭도 5배가 되는 게 자연스럽다.

식비/카페를 왜 additive 로 뒀는지는 솔직히 직관이다. 주간 편차(주말 +α)가 절대 금액으로 일정하게 붙는 느낌이 강해서. 명시적 근거 없이 “눈으로 봤을 때 그렇게 보였다” 인데 Part 1 회고 관점에서 보면 여기가 약점이다. AB 실험해봐야 아는 건데 안 해봤음.

교통은 왜 혼자만 weekly=False, yearly=False 인가

elif category in ['교통 / 차량', 'Transportation']:
    model = Prophet(
        daily_seasonality=False,
        weekly_seasonality=False,   # ← 껐다
        yearly_seasonality=False,
        seasonality_mode='additive',
        changepoint_prior_scale=0.05,
        interval_width=PROPHET_INTERVAL_WIDTH
    )
    model.add_seasonality(name='monthly', period=30.5, fourier_order=5)

교통비 = 지하철 정기권 + 주유. 내 데이터에서는 “월 초·중반에 한 번 크게 나가고 끝” 패턴. 요일 효과는 거의 없음. 그래서 weekly 꺼버리고 add_seasonality(period=30.5, fourier_order=5) 로 월 단위 주기를 커스텀 추가.

period=30.5 는 평균 월 길이. fourier_order=5 는 월 내부 구조 (초/중/말 분리 정도)를 얼마나 세밀히 잡을지. 5면 월 내부를 대략 5단계로 구분할 수 있음. 더 올리면 과적합, 3 밑으로 내리면 월 내 디테일 소실이라는 게 몇 번 돌려본 감.

yearly=False 로 둔 이유는 단순함 — 1년치 데이터밖에 없어서 연간 계절성을 학습할 사이클이 모자람. 이건 데이터가 쌓이면 켜야 할 knob.

베이스라인 백테스팅 — SHF의 간소화 버전

Prophet 논문의 Simulated Historical Forecasts (SHF) 를 간소화해서 구현했다. 우리 요구사항:

“지금 이번 달에 이 금액 쓰고 있는데, 원래는 얼마 썼어야 하나?”

즉 과거 11개월 각각에 대해 “만약 그 달 초에 예측했다면 얼마라고 했을까?” 를 재구성. 코드는 prophet_service.py:419-579calculate_baseline_predictions.

시간축: ─────────────────────────────────────────────────────────▶

M-11 : [==학습(전전년+이전 1달 제외)==][==예측==]
M-10 :       [=====학습(더 많음)=====][==예측==]
M-9  :             [======학습=======][==예측==]
...
M-1  :                           [======학습=======][==예측==]

BASELINE_MONTHS_COUNT = 11 (constants.py:18). 현재월은 빼고 과거 11개월만. 현재월은 부분 월이라 backtesting 대상이 되면 편향 발생.

각 target month 직전일을 cutoff_date 로 잡고 train_data = csv_data[csv_data['date'] <= cutoff_date] 로 자른 뒤 Prophet 학습 → target month 예측. 논문이 말하는 SHF 와 정확히 같은 구조인데, cutoff 개수가 11로 고정이라는 점만 다르다. 논문은 일정 간격으로 cutoff 여러 개를 돌리는데 우리는 월 단위 고정.

이게 “예측” 이자 동시에 “평가” 역할을 한다. 각 월의 predicted 와 실제 actual 을 비교하면 사용자 눈에 “여기가 누수 구간” 이라고 보여줄 수 있음 → 이게 leak_analysis 테이블의 존재 이유. leak_amount = max(0, actual − predicted) 공식만 쓰고 있어서 “과소 지출” 은 전혀 안 잡음. 이것도 한계.

Ephemeral 모델 — pickle 안 한 이유

Prophet 모델은 pickle 안 한다. 요청마다 재학습. analyze 엔드포인트가 한 번 불릴 때마다:

  1. MinIO에서 CSV 당겨옴
  2. 13개 카테고리 × Prophet 학습 (현재월용)
  3. 11개월 × N개 카테고리 Prophet 학습 (baseline용, background로 밀림)
  4. 결과를 MySQL에 기록, 모델은 메모리에서 사라짐

CSV 한 건당 Prophet 학습이 150~300회 일어난다는 뜻. CPU는 맵다. 그래도 pickle 안 쓰는 이유:

이유설명
1. 데이터 드리프트 방어CSV는 사용자가 새로 올릴 때마다 바뀜. 캐시된 모델을 재사용해도 다시 fit 해야 됨 → 캐시 의미 없음
2. 일관성“같은 CSV → 같은 결과” 를 강제. 모델 상태가 어디에도 안 남아서 재현 버그 없음
3. 스토리지 안 씀모델 pickle이 파일당 수 MB × 수백 개 쌓이는 거보다 CSV 한 개 (~100KB) 가 훨씬 쌈
4. 라이브러리 pickle 이슈Prophet 모델은 Stan 컴파일 객체를 들고 있어서 버전 맞추기 민감. 안 저장하는 게 편함

Trade-off는 분명하다 — 한 번의 analyze 요청이 3~5초 걸림. 단일 사용자에겐 괜찮은데 동시 요청 수십 개 들어오면 ThreadPool 포화. 그래서 Part 4에서 다룰 ThreadPool 3-pool 분리 구조가 필요해졌다.

전처리 — 0 으로 채운 날짜

# prophet_service.py:72-82
if len(daily_spending) > 0:
    date_range = pd.date_range(
        start=daily_spending['ds'].min(),
        end=daily_spending['ds'].max(),
        freq='D'
    )
    full_df = pd.DataFrame({'ds': date_range})
    full_df = full_df.merge(daily_spending, on='ds', how='left')
    full_df['y'] = full_df['y'].fillna(0)
    return full_df

카테고리 필터 → daily groupby sum → 누락 날짜 0 으로 패딩. 이게 Prophet 입력이 되는 (ds, y) DataFrame.

0 으로 채우는 게 맞는 결정인가 — 거래 없는 날이 “지출 0” 인 건 자연스럽다. 하지만 이게 Prophet trend 추정에 평균을 끌어내리는 효과 를 준다. 카페처럼 주 3~4회만 가는 카테고리는 특히. 결과적으로 예측치도 실제보다 작게 나오는 경향. “안 간 날은 빼고 학습” 으로 바꿔볼 만한데 그럼 ds가 불연속이라 주간 계절성 학습이 망가짐 → 결국 0 으로 두는 게 최선이었음. 하지만 이건 모델이 “값이 작다” 고 말할 때 내가 “정말?” 이라고 의심해야 할 근거.

병렬 학습 — 같은 요청 안에서 13개를 돌리기

단일 요청 한 건에 13개 카테고리 Prophet이 돌아야 함. 순차로 돌리면 30초. 병렬로 돌리면 5초. 그런데 요청 여러 개가 동시에 들어오면? 같은 ThreadPool 안 겹치면 스레드 폭발.

해답은 클래스 레벨 공용 executor 3종. prophet_service.py:32-46:

self.main_executor     = ThreadPoolExecutor(max_workers=4, ...)  # 현재월용 (API 요청에 직결)
self.baseline_executor = ThreadPoolExecutor(max_workers=2, ...)  # 11개월 baseline (background)
self.category_executor = ThreadPoolExecutor(max_workers=min(cpu, 8), ...)  # 카테고리 병렬
  • main 이 현재월 예측을 받고 → category 로 13개 카테고리 병렬 fan-out
  • 같은 요청이 baseline 11개월도 계산해야 하면 → baseline 으로 background 태워버림 (응답 블로킹 금지)

인스턴스당 딱 이 3개 풀. 요청이 몇 개 오든 스레드 총량은 4 + 2 + 8 = 14 로 상한. 이게 Prophet 튜닝보다 오히려 운영 관점에서 더 중요한 결정이었음. 자세한 스토리는 Part 4에서.

논문이 시키는데 내가 안 한 것들

Prophet 논문이 제안하는 완전 프레임워크 기준으로 보면, 구멍이 꽤 있다.

논문내 구현왜 안 했나
cross_validation() + performance_metrics()없음스코프 미스. MAPE/RMSE 자동 평가 루프가 없음
add_country_holidays('KR')없음한국 공휴일 데이터 통합하려면 Prophet + holidays 버전 맞추기 + 음력 이슈. 시간 부족
grid search / hyperparameter opt없음13 × n × m grid는 스코프에서 잘림
analyst-in-the-loop UI없음프론트가 “예측 금액 보여주기” 까지만. 분석가가 파라미터 돌릴 인터페이스 없음
이상치 제거없음 (Prophet이 내부적으로 약간 완충)raw data 그대로 투입. 결제 클래시파이어가 1차로 정제한다고 가정

leak_analysis.analysis_data 가 JSON 컬럼인데 여기에 나중에 evaluation metric들을 쌓을 수 있게는 만들어뒀음. 지금은 비어있지만.

논문의 “analyst-in-the-loop” 철학 을 제대로 구현하려면 “이 예측이 이상한 것 같음” → “분석가가 파라미터 조정” 루프가 돌아야 한다. 내가 구현한 건 거기서 Modeling + Forecast Evaluation 단계만 자동화한 절반. “Surface Problems” 에 해당하는 이상 탐지 플래깅과 “Visually Inspect” UI가 아예 없음. 이건 단순히 시간 부족이 아니라 “이 제품을 누가 어떻게 쓸지” 를 초반에 명확히 안 잡고 간 설계 공백이다.

다시 만든다면 — 우선순위

순위할 일왜 지금 이 순서인가
1cross_validation() 자동 루프 + MAPE 기록평가 없는 예측은 “얼마나 틀렸는지” 모름. 제일 싸게 큰 효과
2한국 공휴일 통합 (add_country_holidays)설날/추석 있는 달 예측이 명백히 망가지는 게 눈에 보이는 문제
3이상 예측 플래깅 (baseline vs actual 편차 기준)analyst-in-the-loop의 실제 첫 걸음
4changepoint_prior_scale grid (카테고리별)수동 튜닝을 데이터 기반으로 대체
5외생변수 (기온/날씨/요일 공휴일 플래그)Prophet 단독의 천장을 뚫으려면 이거. 단 데이터 소스 확보부터

1번이 현실적으로 가장 ROI가 높다. Prophet 자체가 cross_validation(horizon='30 days') 한 줄이면 돌아가는 걸 안 넣은 건 변명의 여지가 없음.

뭘 놓쳤나 — 회고 포인트 3개

1. 벤치마크 없이 Prophet을 선택한 것 ARIMA / ETS / NeuralProphet 중 Prophet을 고른 근거가 “논문 읽어봤더니 우리 데이터랑 맞는 것 같다” 수준이었다. 평가 지표가 없으니 지금도 Prophet이 정말 최선인지 말할 수 없음. 최소한 seasonal naive 대비 얼마나 나은지는 숫자로 있어야 함.

2. multiplicative 를 직관으로 선택한 것 default 프로필만 multiplicative, 나머지 두 개는 additive. 이 선택은 그래프 몇 개 보고 감으로 내린 결정. Prophet 공식 문서가 “trend 대비 seasonality 가 작으면 additive” 라고 하는데 내가 그걸 확인하고 고른 게 아님.

3. y=0 으로 결측 패딩하는 부작용을 계량 안 한 것 fillna(0) 이 trend를 얼마나 끌어내리는지 한 번도 측정 안 함. 안 간 날이 전체의 50% 넘는 카테고리(교육, 경조사)는 이 효과가 큼.

다음 읽을거리