순수 TypeScript 기반 flex 레이아웃 엔진이 마지막 WASM‑Yoga 격차를 메우다

발행: (2026년 5월 23일 PM 06:06 GMT+9)
9 분 소요
원문: Dev.to

출처: Dev.to

TL;DR

나는 순수 TypeScript 로만 구현한 터미널 UI용 flex 레이아웃 엔진 Pilates 를 만들고 있다. 지난 주 기준으로, 내 벤치마크 스위트에 포함된 9가지 시나리오 모두에서 순수 TS 엔진이 Ink가 사용하는 WASM Yoga 보다 빠르다 — 구조 변형 작업(프레임당 행을 추가·제거)까지 포함해서, Yoga가 약 5배 빠르던 것이 15~17단계에서 차이를 좁힌 뒤, 이제는 순수 TypeScript 로 약 1.7배 빠른 Pilates 가 승리했다.
네이티브 바인딩도, WASM 포팅도 없다. 해결책은 알고리즘적인 것이었으며, 그 알고리즘 수정은 TS 에서도 그대로 동작했다.

Median latency, win32‑x64, Node 22, ~5초 tinybench 윈도우, 부트스트랩 CI95:

시나리오Pilatesyoga‑layout (WASM)비율
tiny (10 nodes)4.5µs19.0µs4.2× faster
realistic (~100)121µs328µs2.7× faster
stress (~1000)601µs1.94ms3.2× faster
big (~5000)3.32ms9.17ms2.8× faster
huge (~10000)8.62ms18.5ms2.1× faster
hot‑relayout16.3µs83.0µs5.1× faster
hot‑relayout + boundaries15.8µs77.8µs4.9× faster
hot‑relayout (text mutation)8.9µs90.6µs10× faster
hot‑structural71.3µs118.3µs1.7× faster

주의 사항: 9개의 직접 선정한 시나리오에 대한 결과이며, 보편적인 주장이라 할 수는 없다. pnpm bench 로 재현 가능 – 최신 머신에서 약 5분 정도 소요된다.

터미널 UI는 WASM 엔진에 매우 적합하지 않은 워크로드다. 트리는 작지만(10~10,000 노드) 업데이트가 빈번하다—키 입력 하나, 틱 하나, 프레임 하나. JS → WASM 로의 호출 비용이 지배적이다: Yoga의 호출당 커널은 몇 마이크로초 수준이지만, node.setWidth(N) 같은 JS→WASM 호출도 몇 마이크로초가 든다. 순수 TS 엔진은 이런 교차 비용이 전혀 없다.

이것이 시작점이었고, 15~17단계는 “트리를 미리 만든 뒤 구조 변형 레이아웃만 측정”하는 최악의 경우에도 이 가설이 유지된다는 증거다.

두 가지 알고리즘 변화가 핵심이었다.

  1. 메인 축 위치 규칙을 누적 합에서 선형 재귀식으로 바꿨다.
    이전 규칙 – 각 셀은 앞선 모든 형제의 크기를 읽는다.

// Old rule — every cell reads every prior sibling mainPos[N] = sum(siblings[0..N-1].mainSize + margin + gap)

*새 규칙* – 각 셀은 바로 앞 셀만 읽는다.  

// New rule — each cell only reads the previous one mainPos[N] = mainPos[N-1] + prev.mainSize + prev.marginEnd + me.marginStart + gap

`row-reverse`/`column-reverse` 같은 역방향에서는 누적 합을 그대로 사용한다. 재귀식은 “앞 셀의 이미 해결된 위치”에 의존하기 때문에 역방향에서는 적용되지 않는다.

2. **기본값 최적화**. 문법(grammar) 내 절반 정도의 입력 필드가 영원히 기본값(예: `margin: 0`, `minWidth: 0`, `maxWidth: undefined`)을 유지하고 있었다. 이들은 여전히 dirty‑flag 슬롯을 차지하고, 의존자에게 전파되며, 의존성 집합에 포함되었다.  
*Phase 17*에서는 이러한 기본값들을 컴파일 시점에 상수로 접어들게 했다. 각 셀 노드가 약 15개의 필드에서 7개 정도로 줄어들었다. `nodeSig`에 접어들기 판정 비트를 추가해, 기본값 → 비기본값으로 변할 때 구조 재구성을 올바르게 트리거한다.  

두 변화를 합치면 `hot‑structural`이 ~450µs에서 ~70µs 로 크게 빨라졌다.  

알고리즘 작업을 시작하기 전에 엔진을 네이티브‑WASM 언어로 포팅하려고 고민했지만, 하지 않은 것이 다행이었다.  

Yoga의 강점은 산술 연산 속도가 아니라 구조 변형 알고리즘에 있었다. Yoga는 이 부분을 네이티브 수준으로 처리했지만, 순수 TS 엔진은 변형당 너무 많은 작업을 다시 수행하고 있었다.  
내가 만든 네이티브‑컴파일 포팅도 같은 알고리즘 구조를 물려받아 최선의 경우에만 동등해졌을 것이다. 결국 해결책은 알고리즘적인 것이었고, 그 수정은 TypeScript 에서도 그대로 작동했다. “순수 TS가 이 워크로드에서 네이티브 코드와 경쟁한다”는 것이 실제로 흥미로운 결과다.  

- 1,470개의 단위·통합 테스트 통과  
- 구조 차분 퍼저가 3,000회 실행에서 모두 성공  
- 33개의 Yoga 오라클 픽스처(셀‑대‑셀 비교)  
- 바이트 동일 캐시‑대‑콜드 차분 모드 833회 실행  

작은 사건 하나를 언급하자면, 2.0.0 배포 몇 시간 뒤 `fast-check` 프로퍼티 퍼저가 실제 버그를 잡았다. `createStyleDirtier` 가 전체 스타일이 접어들어 버린 노드에서 예외를 던졌는데, 내 분석에서는 이런 상황이 발생하지 않을 것이라고 판단했었다. 퍼저가 즉시 찾아냈고, 같은 날 2.0.1 로 수정 및 회귀 테스트를 추가했으며, 2.0.0 은 npm 에서 2.0.1 로 대체되었다.  

프로퍼티 기반 퍼징은 유지할 가치가 있다. 퍼저를 계속 유지할지 고민했었는데, 이번에 확실히 답을 얻었다.  

`public calculateLayout()` 은 1.x 와 2.x 사이에 바이트 단위로 동일하다. SemVer‑major 상승은 내부 API와 메모리 특성 변화 때문이다:

- Typed‑array 런타임 (`Field.id` 정수 + 배열 저장소가 `Map` 대체)  
- LayoutPool 무제한 성장 (`phase 15C` 에서 FinalizationRegistry 기반 재활용 시도했지만 2배 회귀 발생, 제거)  
- 단일 dirty bool 대신 per‑property dirty 비트마스크  
- 선형 재귀식 + 기본값 접기 (위에서 설명한 알고리즘 변화)

공개된 API만 사용한다면, 업그레이드 시 속도 향상이 투명하게 적용된다.  

```bash
git clone https://github.com/pilatesjs/pilates
cd pilates
pnpm install
pnpm bench   # ~5분

또는 엔진을 바로 설치한다:

npm install @pilates/core

전체 React 스택(리컨실러 + 위젯):

npm install @pilates/react @pilates/widgets react

적대적인 벤치마크를 언제든 환영한다 — 이 접근법이 무너지는 워크로드가 있다면 꼭 찾아보고 싶다. 현재 프로젝트가 가장 필요로 하는 피드백이다.

레포 (MIT): https://github.com/pilatesjs/pilates

npm: https://www.npmjs.com/package/@pilates/core

0 조회
Back to Blog

관련 글

더 보기 »

내 스킬

프로젝트를 위한 AI 지시문을 만들고, 설치하고, 관리하세요 — 코딩이 필요 없습니다. CREATE 이름을 정하고, 카테고리를 선택하고, 원하는 것을 설명하세요 — 마법사가 자동으로 구성합니다.