3-1. PoC와 명세로 보낸 첫 9일 2026-09-26 ~ 10-04

첫 9일은 앱을 만든 기간이 아니다. "이 구조가 정말 성립하나"를 하나씩 찔러 본 기간이다. 경계 하나를 실험하고, 결과와 한계를 근거 문서로 남기고, 다음 경계로 넘어가는 순서를 거의 매일 반복했다.

9월 26일: V8을 무엇으로 감쌀까

첫 실험은 V8 언어 연결 비교였다. 같은 최소 기능을 네 방식으로 구현했다.

  1. V8에서 JavaScript 문자열을 실행한다.
  2. JS의 boson.createNode(1, "view")가 네이티브 콜백을 부른다.
  3. 네이티브가 이벤트를 JS 함수에 돌려주고, 그 함수가 다시 createNode(2, "text")를 부른다.
방식V8 연결
cpp_directC++에서 V8 API 직접 호출
cppC++ → 공통 C ABI → V8
rustRust → 공통 C ABI → V8
zigZig → 공통 C ABI → V8

공식 V8 소스를 고정 커밋으로 받아 macOS, iOS 시뮬레이터, Android 16 ARM64 에뮬레이터에서 네 방식 모두 같은 출력으로 끝났다. iOS 기기용은 JIT 없는 설정으로 링크까지만 했다. 연결된 기기가 없어 실행은 못 했다고 README에 그대로 적혀 있다. Android V8을 Linux CI에서 빌드하는 커밋도 두 개 있었지만, 바로 뒤 커밋(chore: keep Android V8 comparison local)에서 Android V8 비교를 로컬 빌드로 되돌렸다.

같은 날 밤에는 터치 → JavaScript → 네이티브 화면 PoC가 이어졌다. 네이티브 버튼을 실제로 누르면 노드 ID가 V8의 onEvent로 들어가고, JS가 setText("Taps: n")을 부르면 UILabel·TextView의 글자가 바뀐다. 네 방식 모두 두 플랫폼에서 Taps: 2까지 확인했다. 다만 버튼과 텍스트는 앱이 미리 만든 것이고, createNode()는 아직 로그만 남겼다.

9월 27일 새벽: Rust 트리가 화면을 만든다

다음 단계인 동적 UI 트리 PoC에서는 JS가 노드를 만들고 지우면 Rust 코어가 부모·형제 순서·텍스트·스타일을 들고 좌표를 계산했다. Android 실기기(Samsung SM-S731N)에서는 TextView·Button, iOS 시뮬레이터에서는 UILabel·UIButton으로 그렸다. 버튼을 누르면 상세 텍스트 노드가 생겼다가 사라지고 아래 행이 y=180 → 244 → 180으로 움직인다.

이어서 측정을 세 가지 했다.

  • 스레드 부하 — 버튼 콜백이 메인 스레드에서 V8과 레이아웃을 동기로 실행하는 구조가 프레임에 주는 영향. 메인 스레드 JS 계산을 40ms 넣으면 dispatch 중앙값도 그만큼(39.5ms) 늘었다.
  • Rust·C++·Zig 커널 비교 — 2. 기술 스택에 적었듯 언어 차이보다 선형 검색을 직접 접근으로 바꾸는 효과가 훨씬 컸다.
  • 실제 코어 ID 검색 A/B — 그래서 실제 Rust Tree에 HashMap 인덱스를 붙여 비교했다.

A/B 결과는 5000개 노드에서 코어 레이아웃만 보면 Android 실기기 약 17.6배, iOS 시뮬레이터 약 34배 짧아졌다. 그런데 네이티브 뷰 적용까지 포함한 render는 각각 약 17%, 38% 줄어드는 데 그쳤다. 화면 밖 5000개 뷰까지 매 이벤트마다 갱신하는 구조였기 때문이다. 같은 날 오후에는 iOS 실기기 실행 모드에 맞춰 JIT 없는 V8로 시뮬레이터 A/B를 다시 쟀다.

9월 27일 밤: Boson에서 스피논으로

밤 10시 47분 커밋 "스피논 명세와 구현 계획을 정리하고 앱 아이콘을 교체"에서 이름이 Spinon으로 바뀌고 방향이 정해졌다. README에 "공통 UI 코어는 Rust, 모바일 주 화면은 GPU 렌더러로 구현하기로 결정했습니다"가 들어갔다. 지금까지의 네이티브 뷰 PoC는 비교 자료로 남긴다.

그날 밤과 이튿날 새벽 사이에 문서 골격이 한꺼번에 생겼다.

  • 스피논 명세 0.1 초안 — 범위와 적합성, UI 트리·이벤트, HTML·CSS·JS API, 실행·빌드·배포, 개발 도구, 라우팅
  • 공식 구현 상태 대장 — 상위 항목마다 API/인터페이스 명세와 동작 근거가 있어야 완료로 표시한다는 규칙
  • 기능별 청크 OTA 배포 계약 — 변경된 청크·에셋만 받는 OTA 목표
  • Rspress 문서 사이트 — 이 블로그와 같은 Rspress로 spec/을 정적 생성한다. 버전은 2.0.22에 고정했고, Pages 배포는 지금 수동 실행만 허용한다.
  • 프로젝트 규칙 — 문서는 한글, 커밋·PR 접두어는 영어, PR은 리베이스 머지, main 브랜치 보호

계획 전체를 스스로 반박해 보는 5회 적대적 검증도 이때 나왔다. 예를 들어 1회차는 "첫 앱이 React·Vue·Svelte, Vite·Rspack, Tailwind 전체, OTA까지 지원한다고 읽히면 어느 기능도 검증된 상태가 되기 어렵다"는 반례에서 출발해, 첫 앱 관문을 React·Vite 단일 번들과 제한된 HTML·CSS로 좁혔다.

9월 28일: 모노레포와 첫 부팅

새벽에 Cargo·Bun 모노레포가 들어오고, 아침에 Android·iOS V8 부트스트랩이 붙었다. Bun으로 묶은 JS, Rust FFI, 고정 V8, 앱 호스트를 한 번에 빌드해 Android 실기기와 iOS 시뮬레이터에서 같은 줄을 찍었다.

SPINON_BOOTSTRAP_RESULT=nodes=2 last_node=8 tag=text text=이벤트:7

부트스트랩 기록은 이게 "시작 경로만 확인"한 것이고 화면 렌더링·터치·GPU는 확인하지 않았다고 적는다.

같은 날 Rust UI 트리 코어(S01)가 들어왔다. PoC 파일에 섞여 있던 트리를 spinon-core로 떼어 노드 ID·트리·원자 변경 묶음·revision을 두고, 묶음이 실패하면 이전 상태를 보존한다. 상태 대장에서 처음으로 체크 표시가 붙은 항목 중 하나지만, 범위는 "Rust 라이브러리만"이다.

밤에는 Taffy R10 실험이 나왔다. 25개 노드 fixture로 외부 노드 ID 유지, 합성 텍스트 측정 콜백, RTL 방향, 배율별 픽셀 반올림, 부분 갱신과 전체 재생성의 결과 일치를 에뮬레이터·시뮬레이터에서 확인했다. 텍스트 폭은 실제 글꼴 측정이 아니라 콜백을 시험하기 위한 합성값이다. GPU 렌더러 계획과 JavaScript API 체크리스트도 이날 문서로 들어갔다.

9월 29일: GPU를 처음 켜다

처음에는 플랫폼 기본 경로로 GPU 사각형을 띄웠다. iOS는 MTKView·Metal, Android는 GLSurfaceView·OpenGL ES였다. GPU 사각형의 영역과 터치 판정, 접근성 버튼 경계를 같은 사각형으로 맞추고, 텍스트 입력은 네이티브 UI로 덮어 Android 한글 IME 조합까지 확인했다.

이어서 wgpu 경로 검증에서 같은 Rust wgpu 30.0.1 코드로 두 플랫폼에 단색 사각형을 그렸다. Android는 백엔드를 지정하지 않으면 Vulkan을 골랐고, OpenGL ES는 실험 인수로 강제해 비교했다. iOS는 Metal이다. 기록은 에뮬레이터의 Vulkan이 SwiftShader CPU 소프트웨어 렌더러였고 실기기 GPU나 성능을 대표하지 않는다고 적는다. R08은 여전히 미완료다.

생명주기와 GPU 복구 검증(R13)은 회전, 백그라운드 복귀, 표면 재생성, 분할 화면 크기 변경, 그리고 일부러 주입한 장치 손실에서 렌더러가 어떻게 복구되거나 실패를 보고하는지 Android 에뮬레이터와 iOS 시뮬레이터에서 확인했다. 실제 드라이버 손실은 검증하지 않았다. "자원 재생성 실패는 이전 화면을 성공한 새 프레임처럼 보고하지 않는다"는 원칙을 오류 코드로 나눠 검증한 셈이다. 같은 날 공통 호스트 계약 초안(R03)이 나왔다.

9월 30일: JS는 UI 스레드에서 돌리지 않는다

9월 29일 밤에 쓰고 30일 낮에 병합한 R06 스레드·소유권 위험 표로 기본값이 정해졌다. 앱 JavaScript와 일반 이벤트는 백그라운드에서 돌고, UI·GPU 경로는 일반 JS 완료를 동기로 기다리지 않는다. 첫날 PoC처럼 버튼 콜백에서 V8을 동기 실행하는 구조와 갈라선 지점이다.

V8 세션 우선순위 스케줄러는 세션마다 V8 Isolate를 소유하는 전용 스레드를 두고, 작업을 user-blocking·user-visible·background 세 등급으로 고르는 큐를 붙였다. 실제 V8 위에서 while (true) {} 작업을 돌리는 중에 여섯 작업을 일부러 낮은 등급부터 섞어 넣고, 무한 루프를 취소한 뒤 등급 순서와 등급 안 FIFO대로 실행되는지 Android 에뮬레이터·iOS 시뮬레이터에서 확인했다. 기아·공정성, 실기기, JIT 없는 실행은 남은 과제로 적혀 있다.

10월 1~2일: CSS를 브라우저와 맞춰 보기

10월 첫 이틀은 CSS 파이프라인을 하나씩 깔았다. 브라우저와 비교하는 단계의 기준값은 고정 버전 Chromium 154에서 뽑았다.

  • Taffy 레이아웃 모듈 — spinon-layout이 트리를 Taffy에 넣어 프레임을 돌려준다. 151.5 CSS px 폭의 소수 Flex fixture에서 세 자식이 50.5 CSS px씩 나뉘어, Headless Chrome 결과와 허용 오차 0.01 CSS px 안에서 일치했다.
  • HostDocument — 요소·텍스트가 섞인 자식 순서, 세대(generation)별 핸들, 분리·재삽입, 원자 묶음과 문서/표시 revision을 가진 문서 모델. 29개 코어 테스트로 확인한다.
  • Stylo DOM 어댑터(C03) — 불변 HostDocumentSnapshot을 Stylo의 DOM·selector trait에 연결했다.
  • 기본 UA 스타일 자원 내장과 CSS feature inventory(HTML 요소 9개, computed 값 19개로 시작)
  • Vite·Rspack 비교 — 같은 fixture의 CSS Modules, CSS import, 폰트·이미지 URL, 청크를 두 번들러로 뽑아 비교하고, 입력 JS 그래프·최종 ESM 그래프·CSS 자원을 한 그래프로 결합했다.

10월 2일에는 청크 OTA 호환 모델 초안(R15)도 들어왔다. runtimeId 정확 일치, SHA-256 객체, 기능·청크·자원 그래프와 diff 영향 범위를 정의한 설계 문서다. 상태 대장은 이걸 완료로 표시하지만 "API 해당 없음(설계 산출물)"이고, 실제 loader·server·전송은 구현하지 않았다.

10월 3일: HTML에서 GPU 화면까지 한 줄로

이날은 끊어져 있던 단계들이 처음 한 줄로 이어졌다.

  1. 기본 cascade(C04.1) — UA·author 출처, source order, specificity, !important, inline style, 상속, media query를 한 문서 revision에서 계산해 Chromium과 16개 요소 × 5개 속성, 80개 값을 문자열로 정확 비교했다.
  2. 계산 스타일 → Taffy(C04.2) — spinon-style-to-layout이 revision이 맞는 computed style만 LayoutStyle로 바꾼다. 지원 범위 밖 값이 하나라도 있으면 요청 전체를 실패시킨다.
  3. S04 정책 — 첫 GPU 화면은 불투명 background-color만 그리고, 1 CSS px = 1 Android dp / iOS point로 둔다.
  4. 정적 렌더 snapshot — Stylo 페인트 값과 Taffy 프레임을 플랫폼 중립 StaticRenderSnapshot으로 결합한다.
  5. Android·iOS wgpu 화면 — 그 snapshot을 두 플랫폼 표면에 그리고 GPU readback으로 42개 RGBA 표본을 확인했다.

교차 플랫폼 대조에서는 Android 에뮬레이터·iOS 시뮬레이터 캡처의 색상 영역을 CSS px로 환산해 Chromium 좌표와 비교했다. 301×40 CSS px fixture의 세 Flex 자식(48.5, 97, 145.5 CSS px)이 각 좌표 0.5 CSS px 허용 오차 안에 들어왔고, 최대 차이는 양쪽 모두 0.167 CSS px였다. 픽셀 RGB는 fixture 색과 정확히 같았다.

이건 "고정 fixture 한 장을 시뮬레이터에서 그렸다"는 결과다. Android 어댑터는 llvmpipe CPU 렌더러였고, 제품 runtime이 이 경로를 소유하지도 않는다. PR 본문도 제품 renderer나 API 지원 완료가 아니라고 밝힌다.

같은 날 V8 쪽에서는 S03.1이 들어왔다. JS의 내부 함수 spinon.__internal.commitDocumentBatch가 생성·삽입·이동·분리·텍스트·속성 변경을 한 묶음으로 Rust HostDocument에 제출하고 영수증을 동기로 받는다. 자정을 넘긴 10월 4일 새벽 2시께에는 스타일·환경 revision을 레이아웃 결과까지 보존해, 오래된 입력으로 계산한 snapshot은 받지 않게 했다.

10월 4일: DOM 노드는 언제 지워지나

마지막 날은 DOM이었다. 제한 DOM façade 내부 시제품(S03.2)은 V8 전역에 document와 제한된 Document·Node·Element·Text API를 설치하고, 조회·변경을 Rust HostDocument에 동기로 연결했다. Android 에뮬레이터·iOS 시뮬레이터의 실제 V8에서 생성·이동·삽입·분리, 부모·형제 조회, wrapper 정체성, DOMException 동작을 확인했다. NodeList·selector·textContent setter, 화면 반영은 포함하지 않는다.

S03.2는 노드와 JS wrapper를 세션이 끝날 때까지 들고 있었다. 계속 만들고 지우는 앱이라면 지운 노드가 쌓인다. 그래서 같은 날 DOM 노드 수명 계획을 쓰면서 Chromium(Blink·Oilpan과 V8의 unified heap), React Native(JSI·Fabric), Lynx의 노드 수명 관리를 비교했고, 결론은 이렇다.

  • 노드 저장소와 mark-and-sweep은 Rust HostDocument가 소유한다.
  • V8 쪽은 콜백 없는 약한 Global handle을 두고, 실행이 끝난 안전 지점에서 비워진 handle을 검사해 Rust 회수기를 부른다.
  • V8 unified heap이나 별도 Rust tracing GC 의존성은 두지 않는다.

그날 밤 이 계약을 Rust 회수 경로와 V8 약한 wrapper 회수로 구현했다. 두 PR은 10월 4일 밤에 열어 자정을 넘긴 10월 5일 01시께 병합됐다. 강제 GC를 요청하는 검증 전용 빌드로 Android 에뮬레이터와 iOS 시뮬레이터에서 앱 루트에 붙은 노드와 JS가 잡고 있는 노드는 남고, 아무도 잡지 않은 노드는 회수되며, wrapper가 사라진 뒤 다시 조회하면 새 wrapper가 만들어지는 것을 확인했다. 반복 생성·회수 검증, 세션 종료 경합 처리, 대량 wrapper의 scan 비용, 이벤트 리스너 수명은 남아 있어 상태 대장에서 S03.3은 미완료다. 10월 5일 새벽 이후의 후속 작업은 이 편의 범위 밖이다.

9일 뒤의 위치

영역10월 4일 기준
JS 엔진고정 V8, 세션 전용 스레드와 우선순위 큐. iOS 실기기 미검증
문서 트리Rust HostDocument, 내부 변경 묶음, 제한 DOM façade 시제품, 노드 회수 경로
CSSStylo 어댑터, 고정 fixture cascade와 Chromium 비교
레이아웃Taffy Flex 일부, 계산 스타일 연결, revision 검사
GPUwgpu 단색·색상 상자 fixture를 시뮬레이터에서 표시. 텍스트·이미지 없음
공개 API·프레임워크 어댑터없음

첫날의 네이티브 버튼 PoC에서 9일 만에 "HTML 문서 → Stylo → Taffy → wgpu" 경로를 한 번 관통했다. 다만 그 경로는 아직 고정 fixture 위에만 있다. 상태 대장의 표현을 그대로 빌리면, 현재 제품 지원 완료는 없다.

1줄 요약

V8 연결 비교와 네이티브 뷰 PoC로 출발해, 9월 27일 "Rust 코어 + GPU 주 화면"으로 방향을 정하고, 명세·상태 대장을 먼저 세운 뒤 CSS → 레이아웃 → wgpu 화면과 DOM 노드 수명까지 고정 fixture·시뮬레이터 범위에서 하나씩 검증했다.