최근 회사에서 MyStock을 실행하면서 조금 이상한 장면을 봤다.
로컬 DB가 없는 상태에서 프로그램을 실행했고, MyStock은 보유 종목을 확인한 뒤 필요한 초기 데이터를 다시 구성하고 있었다. 대부분의 ETF는 별다른 문제 없이 넘어가는 것처럼 보였는데, RISE 쪽에서만 XLS 파일을 가져오지 못했다는 식의 메시지가 눈에 들어왔다. 당시에는 업무 중이라 깊게 파고들지는 못했다. 다만 RISE는 예전부터 과거 구성종목의 실제 기준 날짜를 확정하기 애매해서 historical canonical 대상에서 제외해둔 상태였다. 그래서 일단 “아직도 그 문제인가?” 정도로 기억해뒀다. 퇴근 후 집에서 다시 확인하기로 했다. 목적은 단순했다.
RISE도 지정한 날짜의 구성종목을 실제로 가져올 수 있는지, 그리고 그 데이터를 30일치 DB로 안전하게 구성할 수 있는지 확인해보자.
기준일이 애매했던 RISE
기존 RISE provider도 날짜를 지정해서 조회하는 형태는 갖고 있었다. 그래서 처음에는 요청한 날짜로 데이터가 정상 반환된다면 그 날짜를 그대로 기준일로 사용해도 되지 않을까 생각했다.
요청 날짜: 2026-09-21
응답 데이터: 구성종목
그렇다면
as_of_date = 2026-09-21
로 저장하면 되지 않을까?
문제는 응답 자체에 “이 데이터의 실제 기준일은 2026-09-21이다”라고 확인할 수 있는 독립적인 정보가 없었다는 점이었다. 요청 날짜와 실제 데이터 기준일을 같은 것으로 간주하면, 주말이나 휴일처럼 자료가 없는 날짜에 서버가 어떤 데이터를 반환하는지 확인할 방법이 없었다. 그래서 MyStock에서는 그동안 보수적으로 RISE historical 저장을 막아두고 있었다. 데이터가 없는 것보다 잘못된 날짜의 데이터를 canonical DB에 넣는 쪽이 더 위험하다고 봤기 때문이다.
사라진 XLS endpoint
이번에는 감으로 판단하지 않고 실제 RISE 서버 동작을 확인했다. 나는 회사에서 본 현상을 계기로 RISE의 날짜 조회가 실제로 가능한지 다시 확인했고, Codex에는 현재 provider와 실제 응답을 비교해 기존 acquisition 경로를 조사하게 했다. 그 과정에서 예상보다 먼저 다른 문제가 발견됐다. MyStock이 사용하던 기존 RISE 구성종목 endpoint가 더 이상 XLS나 구성종목 HTML을 반환하지 않고 있었다.
https://www.riseetf.co.kr/prod/finder/productViewTabExcel3
↓
HTTP 302
↓
kbam.co.kr 새 홈페이지
기존 parser가 찾던 데이터 형식 자체가 사라진 상태였다. 회사에서 clean DB로 실행했을 때 봤던 XLS 관련 오류도 이 현상과 연결됐다. 특정 날짜 하나가 문제였던 것이 아니라, 기존 acquisition 경로 자체가 이미 폐기된 셈이었다. 여기서부터 조사 방향이 바뀌었다. “기존 방식에서 날짜를 어떻게 확정할 것인가”가 아니라 “새 홈페이지가 실제 구성종목을 어디서 가져오는가”를 확인해야 했다.
새로운 holdings JSON API
새 RISE 상품 페이지가 사용하는 요청을 따라가 보니 구성종목을 반환하는 JSON API가 따로 있었다.
GET https://kbam.co.kr/api/products/etfs/{product_id}/holdings
?base_dt=YYYYMMDD
현재 MyStock에서 보유 중인 RISE ETF인 0190C0은 product id 44K4로 조회할 수 있었다. 새로운 API 응답에는 예전에는 없던 정보가 있었다.
available_dates
base_dt
fund_cd
holding_count
items
total_count
특히 available_dates와 base_dt가 중요했다. base_dt는 provider가 직접 반환하는 실제 기준일이고, available_dates는 조회 가능한 날짜 목록이었다. 이제 요청 날짜를 기준일이라고 추측할 필요가 없어졌다.
요청 날짜를 그대로 믿으면 안 되는 이유
새 API가 생겼다고 바로 코드를 바꾸지는 않았다. 날짜별로 실제 응답을 비교해봤고, 영업일은 비교적 명확했다.
2026-09-29 요청
→ base_dt = 20260929
2026-09-30 요청
→ base_dt = 20260930
그런데 비가용 날짜를 넣었을 때 예상과 다른 동작이 나왔다. 예를 들어 9월 20일을 요청하면 직전 영업일 데이터가 아니라 최신 데이터가 반환될 수 있었다.
request = 2026-09-20
response
base_dt = 20260930
처음 생각했던 “요청한 날짜를 그대로 canonical date로 사용하자”는 방향을 적용했다면 다음과 같은 데이터가 만들어질 수 있었다.
as_of_date = 2026-09-20
payload = 2026-09-30 구성종목
이건 단순한 중복 저장 문제가 아니다. 최신 비중을 과거 날짜의 데이터처럼 backdate하는 셈이라 30일 구성종목 추이 자체를 왜곡한다. 그래서 requested-date 정책은 폐기했다.
available_dates와 base_dt
조사 결과를 보고 이 흐름이 맞다고 판단했고, Codex가 historical 조회를 다음 방식으로 수정했다.
target_date
↓
available_dates 중 target 이하 최신 날짜 선택
↓
선택 날짜로 다시 조회
↓
response.base_dt 검증
↓
base_dt를 canonical as_of_date로 저장
예를 들어 9월 20일을 조회하려고 할 때 available_dates에서 target 이하의 가장 가까운 날짜가 9월 18일이라면, 9월 18일을 다시 조회하고 응답의 base_dt가 실제로 20260918인지 확인한다.
target = 2026-09-20
available date = 2026-09-18
재조회
→ base_dt = 20260918
→ 2026-09-18 snapshot 저장
구현 단계에서는 Codex가 몇 가지 검증 조건도 함께 추가했다.
- base_dt가 없거나 날짜 형식이 잘못되면 저장하지 않는다.
- 선택한 available date와 실제 base_dt가 다르면 저장하지 않는다.
- base_dt가 원래 target보다 미래면 최신 fallback으로 보고 거부한다.
- 요청 날짜나 파일명은 canonical 날짜의 근거로 사용하지 않는다.
핵심은 단순하다. MyStock이 요청한 날짜가 아니라, RISE가 실제로 보고한 날짜만 DB의 사실로 인정한다.
RISE historical canonical 복구
날짜 계약이 명확해지면서 RISE도 다시 historical canonical 대상에 넣을 수 있게 됐다. 기존에는 clean DB 초기화에서 RISE가 historical basis date를 확정할 수 없다는 이유로 UNSUPPORTED로 끝났다. 새 API 전환 이후에는 다른 ETF와 동일하게 30 calendar-day 범위를 순회하면서 provider가 확인해주는 실제 날짜의 snapshot을 저장한다. 공통 30일 정책 자체는 바꾸지 않았다.
target_date - 30 calendar days
...
target_date
같은 base_dt가 여러 번 관찰되면 기존 canonical dedupe 계약에 따라 한 번만 저장한다. 부분 실패나 중단이 발생해도 이미 저장된 snapshot은 남고, 기존 resume 구조도 유지했다. 실제 RISE API와 임시 SQLite DB로 확인했을 때 31개의 요청 날짜를 처리해 21개의 distinct canonical snapshot이 저장됐고 baseline marker도 정상 완료됐다.
Current 조회도 같은 날짜 계약으로
historical만 고치고 current 조회는 옛 방식을 남겨두지 않았다. historical 경로만 바꾸는 것으로 끝내지 않고, Codex는 current 조회도 같은 JSON API와 base_dt 계약을 사용하도록 맞췄다.
requested date != canonical evidence
provider-reported base_dt
= canonical date evidence
기존 RISE 전용 “실제 구성 기준일 미확인” 예외도 더 이상 필요하지 않게 됐다.
이번 변경은 MyStock 0.3.10으로 반영했다. 처음 시작은 회사에서 clean DB로 실행했을 때 본 XLS 오류 한 줄이었다. 당시에는 원래 RISE의 날짜 근거가 애매해서 그런가 보다 하고 지나갔지만, 퇴근 후 집에서 30일치 데이터를 실제로 구성할 수 있는지 다시 확인하면서 상황이 완전히 달라졌다.
나는 문제를 발견하고 “지정한 날짜의 구성종목을 정말 받을 수 있는가”를 확인하는 방향을 잡았다. 이후 Codex가 기존 provider와 실제 RISE 응답을 조사하는 과정에서 기존 endpoint가 이미 폐기됐고, 새 API에는 오히려 available_dates와 base_dt라는 더 확실한 날짜 정보가 있다는 걸 확인했다.
결국 “RISE 과거 데이터를 저장할 수 있느냐”의 문제가 아니라 “어떤 날짜를 사실로 인정할 것인가”의 문제였다. 나는 요청 날짜를 그대로 믿는 방향은 버리고, provider가 직접 보고한 base_dt만 기준일로 인정하는 쪽을 선택했다.
