Fintech 회고 (1) - Prophet을 실제 프로덕트에 이식하기
Published:
Fintech 회고 시리즈
- Prophet 기획 ← 현재
- 전체 아키텍처 기획
- GPT Classifier 기획
- 운영 회고
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 계열도 후보였지만 접었다. 이유:
- 카테고리가 13개. ARIMA는
p, d, q를 13번 튜닝해야 함.auto.arima도 있지만 우리 데이터처럼 trend change가 있으면 큰 오차로 망가짐. - 튜닝할 때 넘겨받을 다음 사람이 비전공자. SSAFY 팀 구성상 “이 값을 왜 이렇게 뒀는지” 코드로 설명 가능해야 함.
changepoint_prior_scale=0.1이p=1, d=1, q=2보다는 설명 가능. - 데이터 빵꾸. 카페 안 가는 날, 교통 안 쓰는 날이 많음. Prophet은 결측치에 관대 (회귀라서).
정확도 벤치마크는 안 돌렸다. 솔직히 하고 싶었는데 스코프 밖이었다. 이게 Part 4의 “TODO” 중 하나.
카테고리별로 다른 Prophet 3세트
핵심 설계 결정. 13개 카테고리를 3가지 프로필로 나눴다. analysis/app/services/prophet_service.py 의 train_prophet_model 에 그대로 들어가있음.
| 프로필 | 대상 카테고리 | weekly | yearly | seasonality_mode | changepoint_prior_scale |
|---|---|---|---|---|---|
| A (food-like) | 식비, 카페, 마트/편의점 | True | False | additive | 0.1 |
| B (transport) | 교통/차량 | False | False | additive (+ 커스텀 monthly) | 0.05 |
| C (default) | 나머지 모든 카테고리 | True | False | multiplicative | 0.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-579 의 calculate_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 엔드포인트가 한 번 불릴 때마다:
- MinIO에서 CSV 당겨옴
- 13개 카테고리 × Prophet 학습 (현재월용)
- 11개월 × N개 카테고리 Prophet 학습 (baseline용, background로 밀림)
- 결과를 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가 아예 없음. 이건 단순히 시간 부족이 아니라 “이 제품을 누가 어떻게 쓸지” 를 초반에 명확히 안 잡고 간 설계 공백이다.
다시 만든다면 — 우선순위
| 순위 | 할 일 | 왜 지금 이 순서인가 |
|---|---|---|
| 1 | cross_validation() 자동 루프 + MAPE 기록 | 평가 없는 예측은 “얼마나 틀렸는지” 모름. 제일 싸게 큰 효과 |
| 2 | 한국 공휴일 통합 (add_country_holidays) | 설날/추석 있는 달 예측이 명백히 망가지는 게 눈에 보이는 문제 |
| 3 | 이상 예측 플래깅 (baseline vs actual 편차 기준) | analyst-in-the-loop의 실제 첫 걸음 |
| 4 | changepoint_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% 넘는 카테고리(교육, 경조사)는 이 효과가 큼.
다음 읽을거리
- Prophet 논문 리뷰 — Forecasting at Scale (이론 + 한계)
- Part 2 — 전체 아키텍처 기획 (이 Prophet 서비스가 어디 서비스의 어느 위치에 있나)
- Part 4 — 운영 회고 (ThreadPool, lock 얘기)
- IoT 파이프라인 회고 — 2025-10-03 (같은 “프로젝트 → 딥다이브” 순서의 회고)
