XY는 파이썬에서 차트를 만들고, 그 차트를 브라우저·노트북·정적 파일(HTML/PNG/SVG/PDF) 어디서든 보여주는 시각화 라이브러리다. 겉보기 사용법은 Matplotlib이나 Plotly와 비슷하다. xy.line_chart(...) 한 줄로 선 그래프가 나온다. 하지만 내부는 완전히 다르게 설계됐다.
구글 지도는 지구 전체의 도로를 한꺼번에 그리지 않는다. 지금 화면에 보이는 영역·확대 배율에 맞는 타일(tile)만 골라 그린다. 확대하면 그때서야 골목이 나타난다. XY는 이 발상을 데이터 차트에 그대로 적용했다. 1억 개 점이 있어도, 화면이 200만 픽셀이면 200만 개어치의 정보만 계산해서 그린다. 확대하면 그 좁은 구간을 다시 계산해 진짜 점 하나하나를 드러낸다.
핵심 문장은 프로젝트 설계 문서(design dossier)의 첫 줄에 이렇게 적혀 있다: "Plotly의 비용은 데이터가 얼마나 많은가에 비례하고, 이 엔진의 비용은 화면에 픽셀이 몇 개인가에 비례한다." 이 한 번의 발상 전환이 XY의 전부다. 현재 버전은 0.0.4(2026-07-27 릴리스)로 알파 단계이며, 라이선스는 Apache-2.0, 만든 곳은 파이썬 웹 프레임워크 Reflex를 만든 reflex-dev 팀이다.
XY가 뜨는 이유는 단순하다. 측정 가능한 압도적 성능 격차를 벤치마크로 공개했기 때문이다. 아래 표는 "모든 점이 화면에 완전히 그려질 때까지 걸린 시간(초)"이다. 낮을수록 좋다. 벤치마크는 실제 브라우저에서, 각 라이브러리 고유의 입력 경로로, "캔버스가 정확하고 10프레임 연속 동일할 때"만 시계를 멈추는 엄격한 방식으로 측정됐다.
| 점 개수 | 10k | 100만 | 1000만 | 5000만 | 1억 |
|---|---|---|---|---|---|
| XY (density) | 0.071 | 0.084 | 0.083 | 0.076 | 0.081 |
| Matplotlib | 0.086 | 0.357 | 2.804 | 13.385 | ✕ 실패 |
| Plotly | 0.341 | 0.614 | 3.367 | ✕ 실패 | ✕ 실패 |
숫자를 뜯어보면: XY는 4자릿수 규모(1만~1억)를 넘나들어도 시간이 거의 평평하다. 반면 Matplotlib은 5000만 점에서 13초, Plotly는 5000만 점에서 아예 그림을 못 만든다. 5000만 점 기준 XY는 Matplotlib보다 177배 빠르다. 메모리도 마찬가지여서, 1억 점을 파이썬 쪽 2.58 GiB로 처리한다(density=False 순수 모드는 5.26 GiB).
기존 차트 라이브러리에서 "점 100만 개"는 사실상 한계선이었다. 그 이상은 브라우저가 멈추거나 몇 초씩 기다려야 했다. XY는 그 천장을 1000배(10억+) 위로 밀어올렸다. 유전체 데이터(수백만 변이), 천문 관측(수십만 별), 금융 틱 데이터, 지리정보 같은 "기존엔 다운샘플링해야만 봤던" 대용량 데이터를 원본 그대로 인터랙티브하게 탐색할 수 있게 됐다.
| 항목 | Matplotlib / Plotly (기존) | XY |
|---|---|---|
| 렌더 방식 | 점마다 SVG 노드 1개 / 메인스레드 | GPU 인스턴싱(WebGL2) |
| 데이터 전송 | 모든 값을 JSON 문자열로 | 이진 f32 버퍼(파싱 0) |
| 무거운 계산 | 파이썬(느림) 또는 브라우저 | 파이썬 프로세스 안의 네이티브 Rust |
| 데이터 양 | 전부 그림 → N에 비례해 느려짐 | 화면 픽셀 수만큼만(LOD) |
그리고 XY는 성능만 파는 게 아니다. Matplotlib 호환 모드(import xy.pyplot as plt)를 제공해 기존 코드를 거의 그대로 이식할 수 있고, Python·CSS·Tailwind로 모든 레이어를 커스터마이즈할 수 있으며, 자기네 웹 프레임워크 Reflex에 어댑터(reflex-xy) 하나로 끼워 넣을 수 있다. "성능과 커스터마이즈, 둘 다 포기 안 시킨다"가 이 프로젝트의 북극성(North Star)이다.
XY의 특이한 점은 하나의 라이브러리 안에 3개의 언어가 명확히 분업한다는 것이다. 각 언어가 자기가 제일 잘하는 일만 맡는다. Rust는 무거운 수치 계산, Python은 API와 오케스트레이션, TypeScript는 브라우저 GPU 렌더링이다.
.so/.dll 파일로 만든 것. 이렇게 하면 파이썬이 ctypes로 직접 불러 쓸 수 있고, 파이썬 버전마다 따로 빌드할 필요가 없다(플랫폼당 하나면 모든 CPython이 씀).
src/의 Rust 코드는 총 약 2만 줄이다. 외부 크레이트(라이브러리)를 극도로 아껴서, 의존성이 png 하나뿐이다. 릴리스 빌드는 LTO(전체 프로그램 최적화) + codegen-units=1 + strip으로 최대한 조여서 만든다. 주요 파일:
| 파일 | 줄 수 | 역할 |
|---|---|---|
| kernels.rs | 7,463 | 핵심 수치 커널 — 데시메이션·밀도 집계·존맵 |
| raster.rs | 4,142 | 정적 내보내기용 래스터화(PNG 등) |
| lib.rs | 3,602 | C ABI 진입점, 패닉 캐치, ABI 버전 |
| font.rs | 2,535 | 폰트 메트릭·글자 래스터(내장) |
| tiles.rs | 1,160 | 타일 피라미드(LOD 저장 구조) |
| simd.rs | 448 | SIMD 가속 경로 |
python/xy/가 실제 사용자가 import xy로 만지는 패키지다. 런타임 의존성은 단 2개: numpy(수치 배열)와 anywidget(주피터 위젯 표준). 내부 구조가 잘 분리돼 있다:
components.py — 유일한 공개 차트 빌드 API(scatter_chart/line_chart + 마크·축). reflex를 절대 import 하지 않는 "의존성 없는" 표면._native.py — ctypes로 Rust 코어를 바인딩. NumPy 폴백이 없어서, 네이티브 코어를 못 불러오면 명확한 ImportError를 던진다(성능이 타협되는 걸 막으려는 의도).marks.py — 모든 차트 종류의 단일 구현체. "패리티(동일성)는 관례가 아니라 정체성"이라는 원칙으로, 마크마다 몸통·시그니처·기본값이 하나뿐이다.columns.py — 원본 f64 데이터를 보관하는 ColumnStore. 캐논(정본) 데이터는 항상 CPU 쪽 f64로 유지된다.lod.py — 레벨 오브 디테일 판단 로직._payload.py — 데이터 없는 JSON 스펙 + 이진 버퍼로 전송 페이로드 조립.python/xy/pyplot/는 Matplotlib 흉내내기 층이다. plt.subplots(), ax.plot(), plt.show() 같은 익숙한 명령을 받아서 XY의 공개 API로 번역한다. Matplotlib 코드를 import 한 줄만 바꿔서 XY로 옮기게 해주는 "어댑터"다.
js/src/*.ts는 브라우저에서 GPU로 그림을 그리는 얇은 렌더 클라이언트다. 흥미롭게도 파일 이름이 00_header.ts, 40_gl.ts, 50_chartview.ts처럼 숫자 접두사로 되어 있다. 예전엔 하나로 이어붙이던(concat) 코드를 모듈로 쪼갠 흔적이다. node js/build.mjs가 타입 체크·셰이더 린트 후 vite로 번들·압축해서 python/xy/static/index.js(anywidget용)와 standalone.js(window.xy 전역용) 두 개를 만든다.
결정적으로 이 번들은 런타임 의존성이 0이다. vite·typescript·playwright는 전부 빌드/테스트 시점에만 쓰이고, 실제 배포되는 클라이언트에는 외부 JS 라이브러리가 하나도 안 들어간다. 주요 모듈:
| 모듈 | 역할 |
|---|---|
| 10_colormaps.ts | CVD(색각이상) 안전 컬러맵 — 256텍셀 LUT 텍스처 1회 생성 |
| 30_ticks.ts | 축 눈금 계산(반드시 f64로, f32 거치지 않음) |
| 40_gl.ts | WebGL2 헬퍼 + 셰이더 프로그램 |
| 45_lod.ts / 46_worker.ts | LOD 판단 / 커널 없는 페이지용 밀도 재비닝 워커 |
| 50_chartview.ts | 차트 뷰 본체(가장 큰 모듈) |
| 53_interaction.ts | 팬·줌·호버·박스/올가미 선택 |
빌드 시스템은 hatchling + uv-dynamic-versioning이다. 버전 번호를 코드에 안 적고 git 태그에서 자동 유도한다(git tag vX.Y.Z가 릴리스 액션 전부). Rust 코어와 JS 번들은 git에 커밋하지 않고 hatch_build.py가 패키징 시점에 빌드해 휠에 강제 포함한다. 파이썬 의존성 관리는 uv, 린트·포맷은 ruff, 타입 체크는 ty, 벤치마크는 CodSpeed를 쓴다.
XY의 전체 데이터 흐름은 5단계 파이프라인으로 요약된다. 각 단계가 "데이터를 얼마나 줄이느냐"의 승부처다.
Rust 코어는 매번 3가지 티어(tier) 중 하나를 고른다. js/src/45_lod.ts에 LOD_DIRECT_POINT_BUDGET = 200000이라는 상수가 있다. 즉:
화면 가로가 1000픽셀인데 데이터가 100만 점인 선 그래프가 있다고 하자. 한 픽셀 열에 1000개 점이 겹친다. 사람 눈엔 그 열의 가장 높은 값·가장 낮은 값·시작·끝 4개만 보이면 선 모양이 똑같이 나온다. M4는 픽셀 열마다 이 4개(Min·Max·First·Last, 그래서 이름이 M4)만 남긴다. 100만 → 4천 점으로 줄어도 그림은 픽셀 단위로 동일하다.
Plotly의 큰 병목 중 하나는 모든 숫자를 JSON 문자열로 만들어 보내고, 브라우저가 그걸 다시 숫자로 파싱하는 것이다. 숫자 3.14159265 하나가 문자 10글자가 되고, 1억 개면 GB 단위 텍스트가 된다. XY의 설계 불변식(invariant) 첫 줄이 바로 이것이다: "배선 위에 JSON 숫자는 없다. 데이터는 raw f32 버퍼로 이동한다(§29)."
또 다른 불변식: "정본 데이터는 CPU측 f64이고, 모든 GPU/파생 버퍼는 언제든 다시 만들 수 있는 캐시(cache)일 뿐이다(§27)." 이게 왜 중요하냐면, 밀도 표면으로 뭉뚱그려 그렸어도 사용자가 특정 영역을 호버하거나 선택하면 XY는 원본 f64에서 진짜 원래 행(row)을 그대로 되돌려준다. "시각적으론 요약, 데이터는 손실 없음"이 동시에 성립한다. 그리고 NaN(결측값)은 절대 정점 버퍼(vertex buffer)에 도달하지 않는다(§19) — GPU가 깨지지 않게 Rust 단계에서 걸러낸다.
파이썬 커널이 살아있는 환경(노트북·Reflex 앱)에서는 팬·줌 때마다 새 범위를 Rust에 보내 정제된 페이로드를 다시 받는다. 반대로 to_html()로 뽑은 커널 없는 정적 파일에서는, 46_worker.ts가 브라우저 안에서 밀도 격자를 재비닝(re-bin)하는 워커를 돌려서 커널 없이도 어느 정도 줌이 되게 한다. 같은 차트가 살아있는 앱에서도, 죽은 HTML에서도 동작하도록 두 경로를 다 갖춘 것이다.
spec/ 디렉토리가 의도된 동작의 단일 진실원(source of truth)이다. CLAUDE.md에 "코드 주석이 dossier 섹션(§16 등)을 인용하고, 스펙이 낡으면 그 변경은 미완성"이라고 못박아 놨다. 구현이 아니라 스펙이 최종 권위라는 규율은 큰 엔지니어링 프로젝트에서 드물게 철저한 사례다.
가장 배울 게 많은 부분. "파이썬은 느리다"의 해법으로 흔히 PyO3(러스트-파이썬 바인딩 프레임워크)를 쓰지만, XY는 일부러 순수 C ABI + ctypes를 택했다. 이유는 플랫폼당 .so 하나가 모든 CPython 버전에 통하기 때문(PyO3는 파이썬 버전마다 빌드 필요). src/lib.rs의 ABI_VERSION과 python/xy/_native.py를 시그니처 바뀔 때마다 같이 올려야 한다는 규칙에서, FFI 경계를 안전하게 버전 관리하는 법을 배울 수 있다.
js/src/40_gl.ts·50_chartview.ts에 정점/프래그먼트 셰이더(POINT_VS, DENSITY_FS 등)가 들어 있다. 인스턴스드 렌더링(같은 마커 모양을 한 번 정의하고 위치만 바꿔 수백만 개 찍기), LUT 텍스처로 컬러맵 적용, 밀도 표면을 프래그먼트 셰이더로 색칠하는 기법 등 실전 GPU 시각화 패턴을 볼 수 있다.
M4 데시메이션(시계열 픽셀 축약), 밀도 비닝(binning), 존맵(zone map) 같은 대용량 시각화의 정석 알고리즘이 Rust로 구현돼 있다. 설계 문서가 datashader·plotly-resampler 같은 선행 연구를 정직하게 인용("우리가 발명한 게 아니라 통합한 것")하는 태도도 배울 만하다.
"JSON 숫자 금지, 오프셋 인코딩 f32, f64는 CPU 유지" 같은 불변식(invariant)을 명문화하고 테스트로 지키는 방식. API 설계에서 "무엇을 절대 안 할지"를 먼저 못박는 규율을 배울 수 있다.
xy.pyplot이 Matplotlib API를 흉내내되, tests/pyplot/test_boundaries.py로 "pyplot이 공개 API 위로만 의존한다"는 단방향 규칙을 강제한다. 레거시 호환을 제공하면서도 아키텍처가 오염되지 않게 지키는 실전 기법이다.
1. pip install xy 후 README의 1억 점 나선 예제를 그대로 돌려서, 브라우저에서 줌하며 밀도 표면이 진짜 점으로 뚫리는 순간을 관찰하기.
2. xy.pyplot로 기존 Matplotlib 스크립트를 import만 바꿔 이식해보고, 어떤 함수가 아직 미지원인지 spec/matplotlib/compat.md와 대조하기.
3. js/src/45_lod.ts의 LOD_DIRECT_POINT_BUDGET 값을 바꿔 빌드하면 티어 전환 임계가 어떻게 달라지는지 실험하기(자세한 과제는 §8).
| 항목 | 요구사항 |
|---|---|
| Python | 3.11 이상 (필수) |
| 런타임 의존성 | numpy ≥ 1.24, anywidget ≥ 0.9 (단 2개) |
| 브라우저 | WebGL2 지원 브라우저(요즘 크롬·파폭·사파리 대부분) |
| GPU | 통합 그래픽으로도 동작 — 벤치마크는 Apple M5 Pro 기준 |
| 메모리 | 1억 점 직접 모드 ≈ 파이썬 5.26 GiB / density 모드 2.58 GiB |
| 정적 내보내기 | PNG/PDF는 헤드리스 Chromium(픽셀 프로브)이 필요할 수 있음 |
| OS | Rust 코어는 플랫폼당 cdylib — Linux/macOS/Windows 휠 제공 |
일반 사용자는 pip install xy만 하면 Rust 코어와 JS 번들이 이미 빌드된 채로 휠에 들어와 있어서 별도 컴파일이 필요 없다. 소스 체크아웃으로 개발할 때만 npm ci && node js/build.mjs(JS 번들)와 cargo build --release(Rust)를 한 번 돌려주면 된다. 데이터 규모가 수천~수십만이면 어떤 노트북에서도 가볍게 돌아가고, 억 단위는 넉넉한 RAM이 필요하다.
pip install xy numpy 후 README의 100M 나선 예제를 실행. 주피터에서 렌더한 뒤 마우스 휠로 확대하면서 밀도 표면 → 개별 점으로 바뀌는 "드릴다운" 순간을 직접 본다. zoom_size_factor·zoom_opacity 값을 바꿔 그 전환을 조절해본다.
기존에 있던 plt.plot()/plt.scatter() 스크립트에서 import matplotlib.pyplot as plt를 import xy.pyplot as plt로만 바꿔 실행. 되는 것/안 되는 것을 spec/matplotlib/compat.md 매트릭스와 대조해 "미지원 함수 목록"을 정리한다.
examples/fastapi/live_drilldown.py를 실행해 서버가 팬·줌 요청마다 어떤 페이로드를 되돌려주는지 네트워크 탭으로 관찰. "라이브 호스트가 어떻게 정제된 데이터를 재요청받는지"를 코드로 확인한다.
examples/real_world/의 Gaia 별 HR 다이어그램(25만 점)이나 NYC 택시 밀도(30만 픽업)를 열어, 공개 소스에서 데이터를 받아 렌더한다. Datashader/Plotly로 같은 데이터를 그려 렌더 시간을 직접 비교한다.
소스를 클론해 js/src/45_lod.ts의 LOD_DIRECT_POINT_BUDGET을 바꾸고 node js/build.mjs로 재빌드. 티어 전환점이 어떻게 달라지는지 scripts/bench.py(§12 하네스)로 측정한다. 여유가 되면 src/kernels.rs의 M4 데시메이션 경로를 읽고 픽셀 축약이 실제 어떻게 구현됐는지 추적한다.
| 주차 | 주제 | 목표 |
|---|---|---|
| 1주차 | NumPy & 컬럼형 데이터 | f32/f64 정밀도 차이, 배열 메모리 레이아웃, 왜 컬럼 저장이 빠른가 |
| 2주차 | Python ↔ C FFI | ctypes 기초, C ABI, cdylib 만들기, PyO3와의 트레이드오프 |
| 3주차 | Rust 입문 | 소유권·수명, cargo, 릴리스 최적화(LTO), 패닉을 C 경계에서 잡기 |
| 4주차 | WebGL2 기초 | 정점/프래그먼트 셰이더, 버퍼, 인스턴싱, 텍스처(LUT) |
| 5주차 | 시각화 알고리즘 | M4 데시메이션, 밀도 비닝, 타일 피라미드, LOD |
| 6주차 | 이진 프로토콜 | 오프셋 인코딩, 타입드 배열, anywidget comm, Arrow IPC |
| 7주차 | 웹 통합 | Reflex 앱 구조, 웹소켓 네임스페이스, 정적 HTML 내보내기 |
| 8주차 | 벤치마킹 규율 | CodSpeed, "정확+안정 프레임" 판정, 재현 가능한 증거 만들기 |
| 용어 | 뜻 |
|---|---|
| LOD | Level of Detail. 보이는 픽셀 수에 맞춰 정밀도를 조절하는 렌더 기법 |
| M4 데시메이션 | 픽셀 열마다 Min·Max·First·Last 4점만 남겨 선을 축약(모양은 동일) |
| density 모드 | 많은 점을 화면 격자의 밀도 표면으로 집계해 그리는 티어 |
| drill / drill-down | 밀도 표면을 확대하면 그 구간을 재계산해 진짜 점으로 뚫고 내려감 |
| ColumnStore | 원본 f64 컬럼을 CPU에 보관하는 정본 저장소(호버·선택 시 원행 반환) |
| C ABI / cdylib | Rust를 C처럼 호출 가능한 동적 라이브러리로 빌드 → 모든 CPython이 공유 |
| ctypes | 파이썬 표준 라이브러리로 C 함수를 직접 호출하는 FFI 방식 |
| 오프셋 인코딩 f32 | 큰 좌표에서 뷰포트 원점을 빼 작은 상대값으로 만든 뒤 f32로 전송 |
| 불변식(invariant) | 절대 깨지면 안 되는 설계 규칙(예: "JSON 숫자를 배선에 안 태운다") |
| WebGL2 인스턴싱 | 같은 마커 모양을 한 번 정의하고 위치만 바꿔 수백만 개를 GPU로 찍기 |
| anywidget | 주피터 위젯을 프레임워크 독립적으로 만드는 표준 — XY의 노트북 표시 경로 |
| pyplot shim | Matplotlib API를 흉내내 XY 공개 API로 번역하는 호환 층 |
| design dossier | 설계·경쟁 리서치·성능 추정·감사 로그를 한데 모은 XY의 단일 진실원 문서 |