2026. 4. 15. 01:17ㆍ카테고리 없음
"과거 데이터를 모으는 건 쉬울 줄 알았습니다"
Passive를 만들 때 가장 먼저 부딪힌 벽은 모델이 아니었습니다. 데이터였습니다.
HMM이든 XGBoost든, 머신러닝 모델의 품질은 결국 학습 데이터에 달려있습니다. 미국 시장 레짐을 판별하려면 최소한 여러 경기 사이클이 포함된 데이터가 필요합니다. 2008년 금융위기, 2015년 유가 쇼크, 2020년 코로나, 2022년 인플레이션 — 이런 "다른 시장 얼굴"들이 학습 데이터에 있어야 모델이 다양한 상황을 구분할 수 있기 때문입니다.
그래서 목표를 잡았습니다. 2007년부터 현재까지, 18년치 데이터. 처음엔 "그냥 FRED에서 긁어오면 되는 거 아닌가?"라고 생각했습니다. 그게 첫 번째 오판이었습니다.
문제 1. "한 곳에서 다 주는 데이터가 아니다"
Passive가 쓰는 데이터 소스를 정리해보면 이렇습니다.
- FRED (미 연준): 거시지표, 하이일드 스프레드, 금리 구조
- Yahoo Finance: 주요 ETF 16종 OHLCV, 개별 종목
- CNN Fear & Greed: 투자자 심리 지수
- CBOE: VIX, 옵션 풋/콜 비율
이게 서로 다른 API이고, 서로 다른 갱신 주기, 서로 다른 타임존, 서로 다른 날짜 포맷을 쓴다는 걸 금방 알게 됐습니다. FRED는 날짜 문자열, Yahoo는 유닉스 타임스탬프, CNN은 자체 포맷. 하나로 합치려면 전부 UTC 기준 daily 인덱스로 정렬해야 하는데, 이 정규화 코드만 며칠을 잡아먹었습니다.
심지어 지표마다 업데이트 시점도 달랐습니다. FRED의 어떤 시리즈는 월말에, 어떤 건 주 단위로, 어떤 건 분기 단위로 발표됩니다. 분기 데이터를 일 단위로 쓰려면 forward-fill을 해야 하는데, 이게 학습 시점에 미래 데이터를 참조하는 look-ahead bias를 만들 수 있습니다. "2024년 3월 말에 발표된 2024년 1분기 GDP를 2024년 1월 1일부터 있었던 것처럼 쓰면" 모델은 미래를 보고 학습하게 됩니다. 백테스트는 잘 나오는데 실전에서는 무너지는 전형적인 함정입니다.
💡 배운 것 1: 데이터를 "언제 받았는지"가 "언제 발표됐는지"보다 중요하다. 발표 지연(release lag)을 무시하면 미래 정보가 과거로 새어 들어간다.
문제 2. "과거로 갈수록 데이터가 이상해진다"
18년치를 긁고 나서 이상한 점을 발견했습니다. 2010년 이전 데이터의 품질이 현재와 다릅니다.
예를 들면 이렇습니다.
- 2008년엔 아직 상장 안 된 ETF들 (예: 일부 섹터 ETF는 2010년대 중반에 상장)
- 2010년 이전엔 아예 집계 안 되던 지표들 (예: CNN Fear & Greed는 2011년부터)
- 데이터 제공사가 중간에 산출 방법을 바꾼 지표들
이 사실을 모르고 "2007년부터 학습"이라고 단순하게 돌리면, 모델 입장에서는 특정 구간에 NaN이 잔뜩 낀 이상한 데이터가 들어갑니다. XGBoost는 NaN을 어느 정도 처리할 수 있어서 학습은 되지만, HMM은 그렇지 못합니다. 그리고 NaN이 너무 많은 구간은 애초에 "정보가 없는 구간"이라 모델의 패턴 학습을 방해합니다.
해결 방법은 두 가지를 고려했습니다.
방법 A. 지표별로 학습 시작점을 다르게 잡기
- 2007년부터 있는 지표: 2007년부터 사용
- 2011년부터 있는 지표: 2011년부터 사용
- 모델은 "공통으로 존재하는 최소 구간"에서 학습
방법 B. 지표 티어를 나누기
- Core (2007년부터): 금리, VIX, S&P 500
- Secondary (2011년부터): Fear & Greed, 하이일드 스프레드
- Experimental (2015년부터): 특정 섹터 ETF 로테이션
결국 방법 B를 택했습니다. 핵심 모델(NoiseHMM)은 Core 지표만으로 돌아가고, 보조 모델(CrashSurge의 XGBoost)은 Secondary까지 활용하는 구조입니다. 단순해 보이지만, 이 구조를 잡기까지 두 번의 시행착오가 있었습니다.
💡 배운 것 2: "18년치 데이터"는 환상이다. 실제로는 "구간마다 다른 두께의 데이터"이고, 이걸 모델에 맞게 티어링해야 한다.
문제 3. "백필(backfill)과 증분(incremental)은 다른 코드다"
처음엔 이렇게 작성했습니다.
def fetch_data(start_date, end_date):
# FRED에서 데이터 가져와서 DB에 저장
...
함수 하나로 과거 18년치 백필도 하고, 3시간마다 돌아가는 증분 업데이트도 처리하려고 했습니다. 깔끔해 보였기 때문입니다.
그런데 운영을 시작하니 문제가 터지기 시작했습니다.
- 백필 중에 API rate limit에 걸려 중간에 끊기면, 어디까지 받았는지 모름
- 증분 업데이트에서 실패하면 전체 파이프라인이 멈춤
- 중복 저장 방지를 위한 UPSERT 로직이 두 케이스에서 다르게 동작해야 함
- 백필은 "한 번만 돌면 되는" 배치, 증분은 "안정적으로 반복되는" 스트림 — 성격이 완전히 다름
결국 분리했습니다.
def backfill_historical(start_date, end_date):
# 청크 단위로 끊어서 받기
# 체크포인트 저장 (어디까지 받았는지)
# 실패 시 재시작 가능
...
def incremental_update():
# 마지막 저장 시점부터 현재까지
# 재시도 로직 + 지수 백오프
# 실패해도 다음 주기엔 자동 복구
...
백필은 **멱등성(idempotency)**이 핵심이고, 증분은 **복원력(resilience)**이 핵심입니다. 같은 코드로 둘 다 만족시키려는 건 욕심이었습니다.
그리고 백필 함수에서 한 번 더 당한 문제가 있었는데, hy_spread(하이일드 스프레드) 같은 특정 지표가 중간에 누락되면 KeyError로 파이프라인 전체가 멈추는 문제였습니다. 이건 개별 지표별 try-except를 바깥 루프가 아니라 안쪽 루프에 넣는 것으로 해결했습니다. 이걸 찾느라 3일을 썼습니다.
💡 배운 것 3: 같은 데이터를 다루더라도, 배치 잡(백필)과 스트림 잡(증분)은 다른 코드 경로로 분리해라. 그리고 에러 격리는 가장 안쪽 루프에서 해라.
문제 4. "3시간마다 재학습, 그런데 학습 데이터는 매일만 바뀐다"
Passive의 모델은 3시간마다 자동 재학습됩니다. 이건 시장 상황이 바뀌면 빠르게 반영하기 위한 설계였습니다. 그런데 곰곰이 생각해보면 이상합니다.
- FRED 데이터는 일 단위로 업데이트됨 (대부분 전날 마감 기준)
- Yahoo OHLCV도 일 단위로 확정됨
- 그럼 3시간마다 재학습한다고 해서 실제로 새 데이터가 들어오는 건 아니지 않은가?
맞습니다. 사실 데이터가 바뀌지 않는 주기에는 재학습해봤자 같은 결과가 나옵니다. 그래서 구조를 이렇게 바꿨습니다.
학습 주기를 두 겹으로 나누기
- Heavy 재학습 (매일 1회, 새벽): 전날 마감 데이터 포함해서 모델 전체 재학습
- Light 추론 갱신 (3시간마다): 모델은 그대로 두고, 현재 시장 지표만 새로 받아서 추론 결과만 업데이트
이렇게 바꾸니 두 가지가 좋아졌습니다.
- 비용 절감: 3시간마다 재학습하던 것을 하루 1회로 줄이니 컴퓨팅 비용이 1/8로 감소
- 반응성 유지: 추론 갱신은 여전히 3시간마다 돌아서 사용자 입장에선 "최신 해석"이 보임
나중에 분 단위 데이터(예: 1분봉, 실시간 VIX)를 붙이면 그때 다시 설계를 바꿔야 하겠지만, 일 단위 데이터만 쓰는 현재 단계에선 이 구조가 적절하다고 판단했습니다.
💡 배운 것 4: "자주 재학습"이 좋은 게 아니라, "데이터 갱신 주기에 맞춰" 재학습하는 것이 맞다. 학습과 추론을 분리하면 비용과 반응성 둘 다 잡을 수 있다.
문제 5. "실시간 대시보드는 또 다른 파이프라인이다"
여기까지 만들고 나니 또 다른 문제가 생겼습니다. 사용자가 앱을 열었을 때 어떤 데이터를 보여줄 것인가?
선택지는 두 개였습니다.
A. 요청 시점에 DB 조회 + 실시간 계산
- 장점: 항상 최신
- 단점: 응답 느림, DB 부하, 여러 사용자가 동시에 몰리면 터짐
B. 미리 계산된 결과를 캐시에 저장, 사용자는 캐시만 조회
- 장점: 빠름, 부하 적음
- 단점: 신선도 관리 필요
당연히 B로 갔습니다. Supabase에 market_snapshot 테이블을 만들고, 10분마다 백엔드가 자동으로 계산 결과를 덮어씁니다. 사용자 요청은 이 테이블만 읽으면 되니 응답이 50ms 이내로 돌아옵니다.
그런데 여기서 또 한 번 실수를 했습니다. 처음엔 10분마다 전체 레코드를 INSERT하는 구조였습니다. 그러니 Supabase에 하루 144개, 1년이면 5만 개가 넘는 스냅샷이 쌓입니다. 한 달 만에 DB 용량 경고가 왔습니다.
해결은 이렇게 했습니다. 단일 row를 UPSERT로 덮어쓰고, 필요한 과거 스냅샷은 별도 market_history 테이블에 일 단위로만 저장. 현재 상태는 1 row, 역사는 365 row. 이 구조로 바꾸니 DB가 조용해졌습니다.
💡 배운 것 5: "프로덕션 데이터"와 "히스토리 데이터"는 테이블을 분리해라. 사용자가 조회하는 "지금 이 순간"과 분석가가 조회하는 "지난 1년"은 다른 쿼리 패턴이고, 다른 저장 전략이 필요하다.
돌아보면
이 파이프라인을 만드는 데 걸린 시간은 대략 두 달이었습니다. 처음엔 "API 붙이고 DB 넣으면 끝 아닌가?" 싶었는데, 실제로 부딪힌 건 전부 모델 외적인 문제들이었습니다.
- 타임존과 날짜 정규화
- 발표 지연과 look-ahead bias
- 지표별 가용 구간 불일치
- 백필 vs 증분의 코드 분리
- 학습 주기와 추론 주기의 분리
- 프로덕션 테이블과 히스토리 테이블의 분리
개인 개발자가 혼자 이걸 다 겪는 것이 효율적이냐 하면, 비효율적입니다. 회사에서 데이터 엔지니어 팀이 할 일을 혼자서 더듬거리며 배우는 셈이기 때문입니다. 그런데 이 과정을 거치고 나서 확실히 알게 된 것이 있습니다.
"머신러닝 모델의 성능은 모델 튜닝보다 데이터 파이프라인의 설계가 먼저 결정한다."
Kaggle 대회에서는 데이터가 이미 정제되어 주어지지만, 실제 제품에서는 데이터를 매일 받아와서 매일 갱신하고 매일 모델을 돌리는 구조를 만드는 것이 진짜 일입니다. 그리고 그 구조가 얼마나 탄탄한가가 모델의 실전 성능을 좌우합니다. Feature engineering보다 Pipeline engineering이 먼저라는 말을 이제야 체감합니다.