3-1. 아키텍처 설계와 WASM 코어 2026-02-25 ~ 02-26
4-Layer 파이프라인
처음부터 레이어를 명확하게 분리하는 게 중요했다. hwpjs에서 배운 교훈 중 하나가 "코어는 코어답게, 바인딩은 바인딩답게" 분리하는 것이었다.
왜 이렇게 나눴나
Layer 3(WASM)과 Layer 4(Canvas)를 분리한 이유가 핵심이다.
레이아웃 계산과 렌더링을 합치면 간단해지지만, 나중에 Web Worker로 WASM을 옮길 때 렌더링까지 같이 가야 한다. OffscreenCanvas가 아직 불안정하니까, 레이아웃 계산만 Worker로 보내고 렌더링은 메인 스레드에서 하는 구조가 더 현실적이었다.
그래서 WASM은 Float32Array 레이아웃 버퍼만 채우고, Canvas Renderer는 그 버퍼를 읽어서 그리기만 한다. 둘 사이의 인터페이스는 배열 하나뿐이다.
WASM 코어: Columnar Store
첫날 가장 먼저 만든 것이 ColumnarStore이다.
일반적인 테이블 라이브러리는 행(row) 단위로 데이터를 저장한다. [{name: "홍길동", age: 30}, {name: "김철수", age: 25}] 이런 식으로. 이건 직관적이지만, 특정 컬럼을 정렬하거나 필터할 때 모든 행을 순회하면서 해당 필드를 꺼내야 한다.
Columnar Store는 열(column) 단위로 저장한다:
name: ["홍길동", "김철수"]age: [30, 25]
이렇게 하면 정렬할 때 해당 컬럼 배열만 보면 되고, 숫자 컬럼은 Float64Array로 저장해서 WASM에 제로카피로 넘길 수 있다.
문자열은? StringTable
숫자는 TypedArray로 직접 넘기면 되는데, 문자열은 그럴 수 없다. WASM 선형 메모리에 문자열을 직접 넣으면 인코딩/디코딩 비용이 크다.
그래서 StringTable을 만들었다. JS 쪽에서 문자열을 intern하고, WASM에는 u32 인덱스만 넘긴다. 렌더링할 때 Canvas Renderer가 인덱스로 StringTable에서 문자열을 꺼내서 그린다.
WASM은 문자열을 절대 만지지 않는다. 이게 성능의 비결이다.
Taffy 레이아웃 엔진 통합
셀 내부에 여러 컴포넌트를 배치해야 할 때 Taffy가 등장한다.
예를 들어 하나의 셀에 [아바타] [이름] [뱃지]를 가로로 배치하려면 flexbox가 필요한데, Canvas에는 flexbox가 없으니 Taffy로 계산한다.
트러블슈팅: Taffy 노드 재사용
처음에는 프레임마다 Taffy 노드를 새로 만들었다. 스크롤할 때마다 수백 개의 노드를 생성/삭제하니까 GC 부담이 생겼다.
해결책은 노드 풀이었다. 뷰포트에 보이는 최대 셀 수만큼 노드를 미리 만들어두고, 스크롤할 때는 노드의 스타일만 업데이트한다. 노드 생성/삭제가 사라지니 프레임당 계산 시간이 눈에 띄게 줄었다.
가상 스크롤: updateViewportColumnar
WASM 코어의 핫패스는 updateViewportColumnar라는 함수 하나이다. 프레임마다 이 함수가 한 번 호출되고, 이 안에서 모든 것이 일어난다:
- 현재 스크롤 위치에서 보이는 행 범위 계산
- 정렬/필터된 인덱스 배열에서 해당 범위 슬라이스
- 각 셀에 대해 Taffy 레이아웃 계산
Float32Array레이아웃 버퍼에 결과 기록
Canvas Renderer는 이 버퍼를 MemoryBridge를 통해 제로카피로 읽는다. WASM 선형 메모리의 특정 오프셋을 Float32Array 뷰로 잡는 것이다.
Columnar Store + StringTable + Taffy 레이아웃 + 제로카피 버퍼의 조합으로, 스크롤 핫패스에서 JS 힙 할당이 거의 없는 WASM 코어를 만들었다.