3-1. 아키텍처 설계와 WASM 코어 2026-02-25 ~ 02-26

4-Layer 파이프라인

처음부터 레이어를 명확하게 분리하는 게 중요했다. hwpjs에서 배운 교훈 중 하나가 "코어는 코어답게, 바인딩은 바인딩답게" 분리하는 것이었다.

Layer 1: React Headless API  (상태 관리, JSX 인터페이스)
Layer 2: JS Adapter           (데이터 변환, 이벤트 처리)
Layer 3: WASM Core            (레이아웃 계산, 정렬/필터, 가상 스크롤)
Layer 4: Canvas Renderer      (실제 픽셀 그리기)

왜 이렇게 나눴나

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에서 문자열을 꺼내서 그린다.

JS: StringTable["홍길동"] → 0, StringTable["김철수"] → 1
WASM: [0, 1] (u32 배열만 다룸)
Canvas: index 0 → StringTable에서 "홍길동" 꺼내서 fillText

WASM은 문자열을 절대 만지지 않는다. 이게 성능의 비결이다.

Taffy 레이아웃 엔진 통합

셀 내부에 여러 컴포넌트를 배치해야 할 때 Taffy가 등장한다.

예를 들어 하나의 셀에 [아바타] [이름] [뱃지]를 가로로 배치하려면 flexbox가 필요한데, Canvas에는 flexbox가 없으니 Taffy로 계산한다.

// crates/core/src/layout.rs
pub struct LayoutEngine {
    taffy: TaffyTree<()>,
}

impl LayoutEngine {
    pub fn compute_layout(&mut self, columns: &[ColumnDef], viewport: &Viewport) -> LayoutBuffer {
        // 1. 뷰포트에 보이는 행만 계산 (가상 스크롤)
        // 2. 각 셀에 대해 Taffy 노드 생성
        // 3. Taffy가 flexbox 레이아웃 계산
        // 4. 결과를 Float32Array 버퍼에 기록 (stride 8: x, y, w, h + metadata)
    }
}

트러블슈팅: Taffy 노드 재사용

처음에는 프레임마다 Taffy 노드를 새로 만들었다. 스크롤할 때마다 수백 개의 노드를 생성/삭제하니까 GC 부담이 생겼다.

해결책은 노드 풀이었다. 뷰포트에 보이는 최대 셀 수만큼 노드를 미리 만들어두고, 스크롤할 때는 노드의 스타일만 업데이트한다. 노드 생성/삭제가 사라지니 프레임당 계산 시간이 눈에 띄게 줄었다.

가상 스크롤: updateViewportColumnar

WASM 코어의 핫패스는 updateViewportColumnar라는 함수 하나이다. 프레임마다 이 함수가 한 번 호출되고, 이 안에서 모든 것이 일어난다:

  1. 현재 스크롤 위치에서 보이는 행 범위 계산
  2. 정렬/필터된 인덱스 배열에서 해당 범위 슬라이스
  3. 각 셀에 대해 Taffy 레이아웃 계산
  4. Float32Array 레이아웃 버퍼에 결과 기록

Canvas Renderer는 이 버퍼를 MemoryBridge를 통해 제로카피로 읽는다. WASM 선형 메모리의 특정 오프셋을 Float32Array 뷰로 잡는 것이다.

// MemoryBridge: WASM 선형 메모리를 직접 참조
const layoutBuffer = new Float32Array(
  wasmMemory.buffer,
  bufferOffset,
  bufferLength
);
// 복사 없이 WASM이 채운 데이터를 바로 읽는다
1줄 요약

Columnar Store + StringTable + Taffy 레이아웃 + 제로카피 버퍼의 조합으로, 스크롤 핫패스에서 JS 힙 할당이 거의 없는 WASM 코어를 만들었다.