머신러닝 모델을 이야기하기 전에, 그 모델이 먹는 데이터가 어디서 어떻게 왔는지부터 정직하게 밝혀야 한다. 이 프로젝트에서 가장 많은 시간이 들어간 부분은 화려한 모델 튜닝이 아니라, "내가 정말 그 데이터를 갖고 있는가"를 확인하고 고치는 작업이었다.
무엇을 쌓았나
MariaDB에 stock이라는 데이터베이스를 만들고, R 스크립트로 여러 소스에서 데이터를 긁어 다음 테이블들을 채웠다.
price_daily (일봉 시세): 네이버 금융 API에서 2014년부터, 종목당 최대 약 11년치
market_info (PER/PBR/EPS/BPS/시가총액): 2015년부터. 다만 이건 실제 재무제표 값이 아니라 가격 기반 역산 근사치라는 걸 분명히 해야 한다
disclosure_dart (DART 전자공시): 2025년 9월부터 완전 백필, 필요하면 개별 기업 단위로 11.7년치까지 백필 가능함을 확인
news / news_sentiment (뉴스 원문 + 감성 점수): 2025년 9월부터
investor_flow (외국인·기관 수급): 여기서 큰 벽에 부딪혔다 — 아래 설명
short_selling / short_balance (공매도 거래·잔고): 2021년 9월부터, KRX의 봇 차단을 우회해서 5년치 확보
sentiment_daily: 가격모멘텀·거래량·수급·공매도·신용잔고·뉴스감성·시장전체 등 8개 카테고리를 종합한 심리 점수. 실제로는 확보된 카테고리만큼만 가중치를 동적으로 재분배해서 계산한다 (없는 데이터를 0으로 채우지 않는다는 원칙)
trading_calendar: KRX 개장일 캘린더. 이후 모든 자동화 크론이 "오늘이 개장일인가"를 판단하는 기준이 된다
수급 데이터의 구조적 한계 — 포기할 건 포기한다
외국인·기관 수급 데이터는 처음부터 야심 차게 장기 백필을 시도했다. 그런데 네이버가 제공하는 API 구조 자체가 종목당 최근 5영업일치만 준다는 걸 확인했다. KRX의 로그인 없는 접근 경로까지 뒤져봤지만, 개별 종목의 장기 투자자별 매매 동향은 KRX 공식 Open API에도 서비스 자체가 없었다.
결론은 "포기하고 매일 조금씩 쌓는다"였다. 장기 백필이 불가능하다는 걸 인정하고, 매일 크론으로 최근 5일치를 누적해서 시간이 지나면서 자연스럽게 이력을 쌓는 방식으로 전환했다. 이건 실패가 아니라 "안 되는 걸 억지로 되게 하려고 시간을 쓰지 않는다"는 결정이었다.
실제로 만난 버그들
BACKFILL_MONTHS 버그 — 최신 1행만 쌓이고 있었다
업로드 스크립트가 환경변수를 지정하지 않으면 기본적으로 "최신 1행만" DB에 올리도록 설계돼 있었다. 이 사실을 모른 채 몇 주가 지나서, 46개 종목 중 43개가 시세 데이터 딱 하루치만 갖고 있는 상태였다. 원인을 찾아 11년치 전체를 다시 적재했다.
뉴스가 페이지당 1건만 들어오던 버그
네이버 뉴스 API 응답이 [{total, items:[1건]}, {total, items:[1건]}, ...] 형태로, 기사마다 개별 wrapper를 갖는 구조였다. 그런데 파싱 코드가 js$items[[1]]로 첫 번째 항목만 꺼내고 있었다 — 페이지당 20건 중 딱 1건만 가져온 것이다. 이 버그 때문에 뉴스 테이블이 242건에 머물러 있었다. 고친 뒤 재수집하니 22,997건으로 늘었다.
KRX IP 차단 — 욕심이 부른 사고
공매도 순보유잔고 데이터를 대량으로 긁어오던 중, KRX가 "자동화 수단을 통한 비정상 대량조회"를 탐지해 IP를 1일간 차단했다. 날짜당 2회(코스피/코스닥)씩 약 1,200영업일을 0.15초 간격으로 연속 요청한 게 원인으로 보인다. 요청 간격을 늘리고, 차단 감지 시 즉시 중단하는 안전장치와 "이미 받은 데이터 다음부터 이어받는" 재개 로직을 추가했다.
12자리 ISIN 코드 체크디짓 하드코딩 버그
6자리 종목코드를 국제 표준 12자리 ISIN 코드로 변환할 때, 체크디짓(검증숫자)을 매번 "003"으로 고정해놓은 코드가 있었다. 삼성전자는 우연히 체크디짓이 3이라 통과됐지만, 한화오션을 비롯한 대다수 종목은 실제 체크디짓이 다른데도 조용히 0건으로 실패하고 있었다. Luhn 알고리즘으로 정확히 계산하도록 고쳤다.
지주사-자회사 뉴스가 서로 뒤섞이는 문제
news 테이블의 기본키가 기사ID 하나뿐이었다. 그런데 지주사와 자회사를 함께 언급하는 기사는 네이버가 여러 종목의 뉴스피드에 같은 기사ID로 노출하는데, DB에는 먼저 들어온 종목 하나에만 저장되고 나머지는 조용히 무시됐다. 실제로 아모레퍼시픽(090430)의 6개월 뉴스를 새로 수집했더니 1,290건 전부가 이미 다른 종목(대부분 지주사인 아모레퍼시픽홀딩스)에 귀속돼 있어서 신규로 남은 게 하나도 없었다. 아모레퍼시픽은 사실상 자체 뉴스 데이터가 거의 없는 상태였던 것이다.
왜 이 이야기부터 하는가
모델 튜닝 이야기를 먼저 하고 싶은 유혹이 있었지만, 순서를 지키기로 했다. 이 프로젝트에서 배운 첫 번째 교훈이 바로 이거다.
코드가 정상 종료됐다는 것과, 데이터가 맞게 들어갔다는 것은 다른 이야기다.
exit code 0을 보고 "완료됐다"고 믿었다가, 나중에 "사실 하루치만 있었다" "1건만 들어오고 있었다" "체크디짓이 틀려서 대부분 실패하고 있었다"는 걸 뒤늦게 발견한 경험이 여러 번 있었다. 이후 모든 검증 단계에서 "실행이 성공했다"와 "결과가 맞다"를 항상 분리해서 확인하는 습관이 여기서 생겼다.
다음 편에서는 이 데이터를 갖고 처음으로 randomForest 모델을 학습시킨 이야기, 그리고 애초에 왜 이 모델을 선택했는지를 다룬다.
(다음 편: 왜 randomForest인가 — 모델의 정의와 선택 이유)