한눈에 보기
- 문제: 일봉 배치만 있는 플랫폼에 분봉과 실시간을 더하려 할 때, 어떤 규모로 설계하고 분산 엔진(Spark)이 필요한지 판단해야 했다.
- 원인: “전 종목 실시간”을 암묵적 목표로 두면 Spark급 엔진이 필요해 보인다. 그런데 요구사항을 명시적으로 캐물으니 그 전제가 무너졌다.
- 선택: 배치가 전 종목을 넓게 스캔해 후보를 찾고, 실시간은 관심종목(watchlist)만 감시하는 funnel 구조. Spark 없이 Python asyncio + Polars.
- 결과: 유일한 병목이던 패턴 지표는 프로파일링으로 O(n²)가 알고리즘이 아니라 복사 비용임을 밝혀 해결 경로를 찾았다.
- 핵심 교훈: 엔진 선택은 기술 문제가 아니라 요구사항 규모 확정의 함수다.
Spark에 대한 세 번의 판단
이 프로젝트에서 Spark 도입은 세 번 검토됐고, 세 번 다 다른 답이 나왔다. 답이 바뀐 건 변덕이 아니라 매번 전제가 달랐기 때문이다.
1차 (지표 백필 성능 문제): 백필이 CPU를 포화시키고 DB 쿼리가 취소되는 문제에서 Spark가 대안으로 올라왔다. 결론은 기각 — 단일 노드에서 JVM 오버헤드 2~3GB를 내고도 applyInPandas의 내부는 결국 같은 pandas 연산이다. 당시 규모(6,200심볼, 수백 MB)는 워커 수 조절과 중간 flush로 풀렸다. “클러스터 보유 또는 데이터 10배 성장 시 재검토”를 조건으로 남겼다.
2차 (분봉 도입 설계 초안): 분봉은 일봉의 약 390배 볼륨이다. 이 숫자가 조건(“데이터 10배”)을 초과하므로 Spark 람다(배치+Structured Streaming)로 가는 설계 초안이 만들어졌다. 이때 Spark는 로컬에 코드도 이미지도 전무한 전면 greenfield였다.
3차 (요구사항 인터뷰): 설계를 확정하기 전에 요구사항을 명시적으로 캐물었다. “전 종목을 실시간으로 봐야 하는가?” — 아니었다. 실제 사용 패턴은 “전 종목은 장 마감 후 배치로 스캔해서 후보를 찾고, 실시간으로 감시할 것은 그 중 관심종목 몇 개”였다. Spark를 정당화하던 축(전 종목 실시간 스트리밍)이 요구사항에서 사라지자, 도입 근거도 함께 사라졌다.
같은 시기의 설계 문서 두 개가 상반된 결론(Spark 확정 / Spark 불요)을 담게 됐는데, 이 충돌 자체를 문서에 남겨뒀다. 의사결정이 진화한 흔적을 지우면 “왜 Spark가 없지?”라는 질문에 답할 수 없게 된다.
브로커 API의 물리적 한계가 설계를 결정했다
“전 종목 실시간”이 요구사항이 아니게 된 데에는 취향 이상의 이유가 있다. 증권사 API의 한계가 그걸 어차피 허락하지 않는다.
- KIS WebSocket은 연결당 구독 40종목이 하드캡이다. 8,000종목 유니버스를 실시간 구독하려면 이 경로로는 불가능하다.
- 토스증권 API는 REST 전용이다(WebSocket은 “추후 지원 예정”). 게다가 client당 유효한 access token이 1개뿐이라 — 재발급하면 이전 토큰이 즉시 무효화된다 — 여러 프로세스가 토큰을 나눠 쓰는 구조를 만들 수 없다.
- 과거 분봉은 어느 소스로도 소급 확보가 불가능하다. KIS 국내 분봉은 당일치만, yfinance 1분봉은 최근 약 30일 롤링. 분봉 수집은 착수하는 날이 곧 데이터의 시작점이다.
이 제약들 위에서 성립하는 구조가 funnel이다. 넓은 입구(전 종목)는 장 마감 후 배치가 담당하고 — 분봉 수집 + 전체 지표/패턴 계산 — 그 출력이 좁은 출구(watchlist)를 갱신하면, 실시간 계층은 watchlist만 3개 브로커 계정으로 감시한다. 서빙은 과거(Iceberg)와 오늘(Redis)을 병합해 답한다. 어느 계층에도 Spark가 필요한 규모가 없다.
O(n²)는 알고리즘이 아니라 복사였다
funnel 구조의 공유 선행조건이 하나 있었다. 배치가 전 종목의 지표를 제시간에 계산할 수 있어야 한다. 벤치마크를 돌려보니 전체 지표 56컬럼 계산은 5년 분봉 기준 심볼당 0.49초 — 문제없다. 그런데 커스텀 패턴 지표 하나(빅브라더)만 64,000행에 144초, 5년 분봉으로 환산하면 심볼당 약 140분이었다.
O(n²) 곡선이었지만, 코드를 뜯어보니 알고리즘 자체는 bounded window(9/26/15일)만 보는 O(n)짜리였다. 이차 비용의 정체는 매 반복마다 일어나는 DataFrame 슬라이스와 reset_index 복사였다. 즉 “Spark로 분산해야 할 무거운 연산”이 아니라 “벡터화로 고칠 수 있는 구현 문제”였다. rolling 벡터화로 재작성하면 전 종목 5년 분봉 배치가 16코어에서 약 12분으로 추정됐다(측정 기반 추정치).
프로파일링 한 번이 분산 엔진 도입 논의를 종결시켰다. 느린 코드를 스케일아웃으로 덮는 것과 느린 이유를 아는 것은 다른 일이다.
있는 줄 알았던 실시간 파이프라인의 실상
이 설계 과정에서 기존 실시간 경로를 감사했는데, “돌고는 있는데 아무도 안 쓰는” 배선이 줄줄이 나왔다.
- Redis consumer가 구독하던 토픽은 어떤 producer도 쓰지 않는 죽은 토픽이었다. 라이브 데이터는 다른 토픽으로 흐르고 있었다.
- WebSocket 파서는 체결 데이터에서 가격만 뽑고 거래량 필드를 버리고 있었다 — docstring에는 그 필드 이름이 적혀 있는데 파싱 코드가 없었다.
- Redis 키 스키마가 writer마다 달라 두 종류가 공존했다.
컴포넌트가 “떠 있다”는 것과 데이터가 “흐른다”는 것은 별개다. 실시간 계층을 새로 설계하기 전에 기존 계층의 데이터 흐름부터 끝까지 추적한 것이 이 감사의 수확이었다.
분봉 저장: 소파일의 공포를 안고 설계하기
분봉 레이아웃은 쿼리 한 번에 104초 - 작은 파일 8만 개와의 싸움의 교훈 위에서 정해야 했다. 일봉만으로 8만 파일을 만들어 겪은 뒤라, 후보를 “연간 생성 파일 수”로 비교했다 — symbol×day는 연 200만 개(즉시 기각), symbol×month는 연 9.6만 개, symbol×year는 연 8,000개. symbol×year 단일 파일을 택했고, 파일당 2~4MB로 소파일 문제를 피한다.
기존 upsert 함수를 재사용할 때의 함정도 설계 문서에 미리 못박아 뒀다. dedup 키가 날짜 단위라서 그대로 쓰면 하루 390행의 분봉이 1행으로 붕괴한다. 아직 구현 전이지만, 이런 함정을 코드가 아니라 설계 문서 단계에서 잡아두는 것이 반복 검토 비용을 줄여준다.
정리와 한계
| 결정 | 근거 | 열어둔 조건 |
|---|---|---|
| Spark 미도입 | 실시간=watchlist로 확정되며 정당화 규모 소멸 | 벤더 피드 등으로 전 종목 실시간이 실제 목표가 되면 재검토 |
| funnel 람다 | 브로커 하드캡(WS 40종목, REST-only)과 사용 패턴 일치 | watchlist 밖 종목의 실시간 이벤트는 원천적으로 놓침 |
| 분봉 symbol×year | 소파일 회피 (연 8,000파일) | 일일 read-modify-write 비용 실측 후 month로 강등 가능 |
실시간 계층은 아직 설계 단계다. WS 파서의 거래량 확장, watchlist 갱신 주기 같은 숙제가 남아 있다. 그래도 이번 설계에서 가장 잘한 일을 꼽으라면 코드를 쓰기 전에 요구사항 인터뷰로 “전 종목 실시간”이라는 암묵적 가정을 죽인 것이다. 그 가정이 살아 있었다면 지금쯤 greenfield Spark 클러스터를 혼자 운영하고 있었을 것이다.