KR ETF 구성종목 수집과 인증·기준일 관리
국내 ETF의 가격이 왜 움직였는지 설명하려면, 먼저 그 ETF가 어떤 종목을 담고 있는지부터 알아야 했다. ETF는 종목 하나가 아니라 수십에서 수백 개를 담은 바구니라서, 바구니가 오른 이유를 말하려면 안에 뭐가 몇 퍼센트씩 들었는지를 알아야 한다.
그래서 제일 먼저 한 게 ETF 구성종목(holdings) 수집이었다. 그런데 당시 확인한 국내 공식 API로는 필요한 구성종목 전량을 받을 수 없었다. 결국 KRX 정보데이터시스템 웹의 구성종목 조회(PDF) 화면을 긁기로 했는데, 그 화면이 로그인 뒤에 숨어 있었다.
처음 막힌 곳은 데이터가 아니라 인증이었다. 같은 쿠키를 붙여도, 로그인하지 않은 세션에는 문이 열리지 않았다.
로그인을 통과한 뒤에는 받은 데이터의 기준일을 확인해야 했다. 인증된 요청이 성공해도 그 응답이 실행일의 데이터라는 보장은 없었다.
구성종목은 날짜창이 아니라 전량 스냅샷으로 왔다
뉴스는 날짜 범위 안의 신규분만 받는 증분 수집이 가능하다. 구성종목은 그렇지 않다. ETF 하나를 부르면 trdDd 한 날짜의 바구니 전체가 통째로 온다. 어제와의 차이가 아니라, 매번 현재 바구니를 다시 받는 셈이다.
미국과 국내 소스를 나란히 놓으면 이렇다.
| 항목 | FMP 미국 ETF | KRX 국내 ETF |
|---|---|---|
| 호출 방식 | ETF 심볼로 GET | ISIN으로 POST |
| 엔드포인트 | /stable/etf/holdings | /comm/bldAttendant/getJsonData.cmd |
| 요청 단위 | ETF 1종당 1회 | ETF 1종당 1회 |
| 날짜창 | 없음 | 없음, trdDd 한 날짜를 지정 |
| 기준일 | 응답의 updatedAt | 요청의 trdDd를 raw의 trd_dd로 보존 |
| 인증 | API 키 | 로그인으로 승격한 세션 |
미국은 쉬웠다. FMP라는 벤더가 엔드포인트 하나로 구성종목 전량을 준다. 그래서 한국도 공식 API 하나 찾으면 될 줄 알았는데, 그게 아니었다.
파이프라인은 두 단계로 나눴다. 받은 값을 그대로 쌓는 raw와, 벤더별 필드를 공통 형태로 맞추는 canonical이다. 수집(raw)은 손대지 않고 보존하는 데까지만 책임진다. 응답 행에 “어느 ETF의 것인지”, “언제 받았는지” 같은 수집 메타만 붙여서 실행마다 따로 쌓아둔다.
받은 걸 손대지 않는다는 원칙은 이상한 값에서 더 중요해진다. 해외를 기초로 하는 ETF는 구성종목은 오는데 비중과 금액 필드가 -로 관측됐다. 이걸 수집 단계에서 0으로 채우거나 행을 버리지 않고, raw에는 대시 그대로 보존한다. 비중이 없다고 종목 행 자체를 버리면 바구니의 일부가 사라지고, 채워 넣는 판단은 canonical의 몫이다.
ETF 한 번의 요청은 구성종목 N행으로 갈라지고, 다음 실행도 현재 바구니 전량에서 다시 시작한다
문제 1. 같은 JSESSIONID인데 400 LOGOUT이 났다
공식 API부터 짚고 넘어가면, 국내는 두 곳이 다 안 됐다. 가격 수집에 쓰던 KIS로 ETF 구성종목을 실제로 호출해보니, 전량이 아니라 상위 일부만 왔고 기준일 필드도 없었다. 구성 스냅샷이 아니라 실시간 시세용 엔드포인트라 그렇다.
당시 확인한 KRX 공식 OpenAPI에서는 구성종목 서비스를 찾지 못했고 일별 시세만 받을 수 있었다. 둘 다 잘못 만든 게 아니라, 구성종목을 줄 목적으로 만든 API가 아니었다.
그래서 KRX 정보데이터시스템 웹의 구성종목 조회(PDF) 화면을 사용했다. 브라우저로 열면 전 종목 구성과 비중이 다 나온다. 브라우저가 그 화면을 열 때 보내는 요청을 확인했다.
개발자 도구를 켜고 보니 화면은 MDCSTAT05001이라는 서비스에 요청을 보내고 있었다. 요청 본문에 ETF의 12자리 표준코드(ISIN)인 isuCd, 6자리 단축코드인 isuCd2, 기준일인 trdDd를 넣고, Referer와 X-Requested-With 같은 헤더까지 붙여서 “나는 브라우저다”라고 흉내 내는 모양이었다.
시도 1 — 로더 페이지의 익명 세션으로 데이터만 요청했다
처음엔 단순하게 생각했다. 화면을 처음 열 때 서버가 쿠키(JSESSIONID)를 하나 심어주는데, 그 쿠키를 데이터 요청에 그대로 다시 붙이면 되지 않을까 싶었다.
쿠키 이름도 맞고 헤더도 맞췄는데 돌아온 건 400 LOGOUT이었다.
처음에는 쿠키를 더 정확히 재현하는 문제인 줄 알았다. 하지만 같은 이름의 쿠키라도 로그인 전 세션과 로그인된 세션은 서버가 다르게 취급했다.
로그인하면 서버가 그 세션에 “이 사람은 로그인했다”는 도장을 찍어주는데, PDF 데이터는 그 도장이 찍힌 세션에만 열렸다. 쿠키 값 자체가 권한을 담은 게 아니라, 그 값이 가리키는 서버 세션이 로그인 상태여야 했던 거다.
쿠키 이름이 같아도 로그인으로 승격된 세션만 구성종목 게이트를 통과한다
채택 — 익명 세션을 로그인 상태로 승격했다
그래서 데이터 요청이 아니라 로그인 자체를 코드로 재현하기로 했다. 브라우저에서 로그인하면서 요청을 뜯어보니 두 단계였다.
1
2
3
4
5
GET /contents/MDC/COMS/client/view/login.jsp?site=mdc
└─ 익명 JSESSIONID 확보
POST /contents/MDC/COMS/client/MDCCOMS001D1.cmd
└─ 같은 쿠키를 이어받아 mbrId·pw 전송 → 세션 승격
먼저 로그인 페이지를 열어 익명 세션을 하나 받고, 그 세션을 그대로 이어받아 아이디·비밀번호를 보낸다. 그러면 서버가 방금 그 세션을 로그인 상태로 바꾼다. 핵심은 두 요청이 같은 쿠키를 공유하게 만드는 거였다.
Set-Cookie를 직접 파싱해 옮기는 대신, CookieJar가 두 요청의 쿠키를 이어받게 했다. 로그인은 실행당 두 요청뿐이라, 공용 HTTP 클라이언트를 확장하지 않고 로그인 경로만 별도 opener로 분리했다.
로그인이 성공하면 응답 코드가 CD001로 온다. 이걸 확인하고, 승격된 세션을 데이터 요청에 붙이니 드디어 구성종목 전량이 왔다.
로그인은 실행할 때마다 한 번만 하고 세션을 재사용했다. ETF마다 로그인할 이유도 없고, 그렇게 하면 계정에 부담만 준다.
한계 — 계정 동시 세션은 코드로 못 없앤다
여기엔 코드로 어쩌지 못하는 제약이 하나 있었다. 이 계정은 동시에 한 세션만 로그인할 수 있다. 파이프라인이 로그인해 있는데 사람이 같은 계정으로 또 로그인하면, KRX가 중복 로그인(CD011)으로 막는다.
배치가 한밤중이 아니라 장 마감 직후에 도니까, 사람이 마침 그 계정을 열어보는 일이 실제로 겹칠 수 있었다. 그래서 이 계정은 파이프라인 전용으로 못 박고, 사람은 이 계정으로 로그인하지 않기로 규칙을 정했다. 코드가 풀 수 있는 문제가 아니라 운영으로 그은 경계였다.
문제 2. 주말 응답이 금요일과 바이트까지 같았다
인증을 통과했으니 이제 날짜만 잘 붙이면 될 것 같았다. 그런데 스냅샷에는 날짜창이 없다는 게 다른 함정을 팠다.
앞에서 봤듯 KRX는 요청에 trdDd, 즉 기준일을 우리가 직접 넣는다. 응답이 “이건 며칠 자료다”라고 알려주지 않는다. 그러니 수집기가 어떤 날짜를 골라 넣느냐가 그대로 그 스냅샷의 시간표가 된다.
시도 1 — 실행한 날짜를 그대로 기준일로 붙였다
제일 단순한 선택은 “오늘 실행했으니 오늘 날짜를 붙이자”였다. 거래일에는 이게 맞았다.
문제는 비거래일이었다. 정기 스케줄은 평일 15시 40분이라 주말엔 자동으로 돌지 않지만, 수동으로 검증하다 토요일에 받은 응답이 금요일 응답과 바이트 단위로 같다는 것을 발견했다.
당시 응답은 에러나 빈 배열이 아니라 금요일과 같은 구성종목 데이터였다. 실행일을 기준일로 붙이면 토요일 데이터로 잘못 표시되므로, 자동 실행되는 평일 휴장일에도 같은 라벨링 문제를 고려해야 했다.
여기에 오늘 날짜를 붙이면, 실제로는 금요일 데이터인데 “토요일 기준 구성종목”인 척하는 행이 남는다. 요청이 200으로 성공했는지만 봐서는 절대 못 잡는 종류의 오류다. 데이터는 멀쩡히 왔고, 틀린 건 거기 붙인 날짜뿐이니까.
버린 접근 — 빈 응답을 기다리며 날짜를 거꾸로 재시도하지 않았다
처음엔 비거래일이면 빈 응답이 올 거라고 봤다. 빈 배열이 나오면 날짜를 하루씩 뒤로 물려 재시도하는 방법을 생각했다. 그런데 6월 13일 토요일 응답은 비어 있지 않았다. 742행이 왔고, 금요일 응답과 md5가 같았다. 빈 응답을 신호로 삼는 재시도는 애초에 시작조차 안 되므로, 재시도로 우회하는 대신 기준일 라벨을 처음부터 바로잡기로 했다.
채택 — 거래일 달력으로 기준일을 역산했다
기준일을 실행한 날이 아니라 실제 거래일로 되돌렸다. 오늘부터 하루씩 뒤로 가면서 처음 만나는 거래일을 기준일로 쓴다.
1
2
3
4
5
for back in range(MAX_LOOKBACK_DAYS):
day = today - timedelta(days=back)
if is_trading_day(day):
return day
raise ValueError("OPS_KR_HOLIDAYS 주입 확인")
오늘이 거래일이면 오늘, 주말이나 휴장일이면 직전 거래일을 쓴다. 그러면 토요일에 돌아도 금요일 데이터에 금요일 날짜가 붙으니, 같은 걸 몇 번 다시 받아도 어긋나지 않는다. 뒤로 열흘까지 찾아도 거래일이 없으면, 아무 날짜나 고르지 않고 휴장일 설정이 잘못됐다고 판단해 그냥 실패시킨다.
직전 거래일 데이터를 실행일 기준일로 라벨하면 실제로 열리지 않은 날짜의 스냅샷이 생긴다
주말은 요일로 판정하고, 평일 휴장일은 목록(OPS_KR_HOLIDAYS)으로 주입받는다. 목록에 있으면 거래일이 아니라고 본다.
실행 계획을 짜는 쪽도 “오늘이 거래일인가”를 판단하므로 두 곳의 기준을 맞춰야 했다. 이 둘이 서로 다른 휴장일 목록을 보면, 한쪽은 휴장일이라 건너뛰는데 다른 쪽은 거래일로 라벨하는 모순이 생긴다. 그래서 두 쪽이 같은 목록을 보게 묶었다.
한계 — 휴장일 목록은 해마다 손으로 채워야 한다
주말은 코드가 요일로 알지만, 평일 휴장일은 OPS_KR_HOLIDAYS 목록으로 주입한다. 거래소 달력과 자동으로 연결된 구조가 아니라서, 목록을 갱신하지 않으면 실행 계획과 수집기가 나란히 같은 잘못된 날짜를 거래일로 판단한다. 게다가 목록에 오타가 있으면 그 날은 조용히 거래일로 되돌아간다. 달력을 공유한 건 두 판단이 서로 어긋나는 걸 막았을 뿐, 달력 자체를 최신으로 유지하는 문제까지 푼 건 아니다.
마무리
처음엔 로그인만 흉내 내면 끝나는 수집기라고 생각했다. 실제로는 인증과 기준일을 서로 다른 계약으로 읽어야 했다. 로그인 게이트를 통과한 뒤에도, 응답에 붙일 기준일은 요청 날짜와 거래일 달력을 함께 보고 내가 검증해야 했다.
두 문제의 공통점은, 요청이 200으로 성공했는지만 봐서는 아무것도 보장되지 않는다는 거였다. 400 LOGOUT은 쿠키가 아니라 세션 상태의 문제였고, 주말의 200 응답은 데이터가 멀쩡한데 날짜만 틀린 경우였다. 성공 코드 너머에서 무엇이 왔는지를 매번 따져야, 받아온 데이터를 믿을 수 있었다.