Tessera v3의 `remember` 메커니즘이 상태 끌어올리기와 Clone Hell 문제를 해결하는 방식과, proc macro 기반의 위치 기반 메모이제이션 구현 원리를 설명합니다.
URL: https://tessera-ui.github.io/blog/positional-memoization-via-proc-macros.html
시작하기 전에 Tessera가 무엇인지 소개하겠습니다. Tessera는 Rust용 선언적(declarative) 즉시 모드(immediate-mode) UI 프레임워크로, 함수형 컴포넌트를 통해 친근한 개발 경험을 강조하면서도 확장성과 고성능을 지향합니다.
Tessera v3의 핵심 목표 중 하나는 remember 메커니즘을 도입하는 것입니다. 이를 통해 Tessera는 진정으로 상태를 가지는(stateful) 컴포넌트를 지원할 수 있게 되며, 전통적인 즉시 모드 UI에서 흔한 “무조건적인 상태 끌어올리기(state hoisting)” 문제를 피할 수 있습니다. 이 기능은 이제 완성되었고 API 설계도 확정되었습니다. 현재 제 주요 관심사는 완전한 MD3 컴포넌트 라이브러리를 포팅하는 것으로 옮겨갔는데, 이는 시간이 좀 걸릴 것입니다. 따라서 (제가 세부사항을 아직 또렷하게 기억하는 지금) remember가 Tessera에 가져올 변화에 대해 설명하기 좋은 시점입니다.
이전의 Tessera는 함수로 컴포넌트를 표현하는 즉시 모드 GUI(imgui)에 더 가까웠습니다. 컴포넌트 내부에 상태를 저장할 수 없었기 때문에, 모든 상태를 컴포넌트 밖으로 끌어올려야 했습니다.
그 당시 애니메이션 상태를 가진 컴포넌트의 시그니처는 다음과 같았습니다:
rustpub fn switch(args: impl Into<SwitchArgs>, state: SwitchState);
여기서 SwitchState는 스위치가 현재 켜져 있는지 여부, 애니메이션 진행도 등 모든 상태 필드를 담고 있는 구조체입니다. 사용자는 컴포넌트 밖에서 이 상태를 생성하고 유지해야 했습니다:
rustlet switch_state = state.switch_state.clone(); switch( SwitchArgs::default(), switch_state, );
이는 불편합니다. 대부분의 경우 부모 컴포넌트는 스위치의 내부 상태에 관심이 없고, 또한 그 상태를 조작해서도 안 됩니다. 부모에게 중요한 것은 스위치가 켜졌는지 여부와 스타일뿐입니다. 이는 SwitchArgs만으로 충분히 표현됩니다. 추가로 SwitchState를 전달하는 것은 부모 컴포넌트 입장에서는 실질적인 목적이 없습니다.
더 복잡한 예시를 보겠습니다. layer0 컴포넌트 안에 layer1 컴포넌트가 있고, 그 안에 switch 컴포넌트가 들어 있습니다.
ruststruct Layer1State { switch_state: SwitchState, } #[tessera] fn layer1(state: Layer1State) { let switch_state = state.switch_state.clone(); switch( SwitchArgs::default(), switch_state, ); } struct Layer0State { layer1_state: Layer1State, } #[tessera] fn layer0(state: Layer0State) { let layer1_state = state.layer1_state.clone(); layer1(layer1_state); }
위에서 보듯이 상태 끌어올리기는 보일러플레이트를 많이 유발합니다. 특히 컴포넌트가 깊게 중첩될수록 더 심해집니다. 부모 컴포넌트의 상태에 모든 자식 컴포넌트의 상태가 명시적으로 포함되어야 하므로 코드가 매우 장황해집니다.
더 나쁜 점은, 상태 끌어올리기가 컴포넌트 재사용을 큰 골칫거리로 만든다는 것입니다. 새 컴포넌트 인스턴스를 만들 때마다 그 상태에 대해 이름을 지어야 하기 때문입니다. 아래 장면은 컴포넌트 선언은 적지만 오히려 더 성가십니다:
ruststruct LayerState { switch1_state: SwitchState, switch2_state: SwitchState, switch3_state: SwitchState, // ... } #[tessera] fn foo(state: FooState) { let switch1_state = state.switch1_state.clone(); switch( SwitchArgs::default(), switch1_state, ); let switch2_state = state.switch2_state.clone(); switch( SwitchArgs::default(), switch2_state, ); let switch3_state = state.switch3_state.clone(); switch( SwitchArgs::default(), switch3_state, ); // ... }
컴퓨터 과학에서 어려운 일은 두 가지뿐이다: 캐시 무효화와 이름 짓기. 그리고 이것은 정확히 네이밍 문제입니다. 이제 우리는 모든 상태를 가진 컴포넌트에 대해 상태 변수 이름을 지어야 합니다. 그 상태는 부모 컴포넌트의 로직에서 어떤 역할도 하지 않고, 오직 자식에게 전달되기 위해 존재하는데도 말이죠. 그 이름이 무엇이어야 하는지, 혹은 상태를 난장판으로 만드는 것 외에 어떤 의미가 있는지 누구도 명확히 말하기 어렵습니다.
또 다른 문제는 “Clone Hell”입니다.
Tessera에서 컴포넌트를 화면에 렌더링하는 과정은 네 단계로 나뉩니다:
첫 세 단계 모두 컴포넌트 상태에 접근할 필요가 있습니다. 네 단계 전반에서 상태 접근을 가능하게 하면서도 Measure & Place 단계를 병렬화할 수 있도록, 일반적으로 컴포넌트 상태를 Arc<Lock<T>>로 감쌉니다. 하지만 Tessera는 함수형 UI 프레임워크이기 때문에 자식 컴포넌트 타입을 표현하는 데 FnOnce를 많이 사용합니다. FnOnce 클로저가 캡처한 변수는 클로저로 move되므로, 소유권 문제를 피하려면 move 전에 clone이 필요합니다.
예시를 보면 이해가 쉬울 것입니다. 클릭할 때마다 너비가 1픽셀씩 늘어나는 상태ful 애니메이션 컴포넌트가 있다고 해봅시다.
ruststruct AnimatedBoxState { width: Arc<AtomicUsize>, } #[tessera] fn animated_box(state: AnimatedBoxState) { let width = state.width.clone(); let width_for_measure = width.clone(); measure(Box::new(move |input| { let width = width_for_measure.load(Ordering::SeqCst); let width = Px::new(width as i32); Ok(ComputedData { height: Px::new(100), width, }) })); let width_for_input = width.clone(); input_handler(Box::new(move |input| { let pressed = // ... check if box is pressed ... if pressed { width_for_input.fetch_add(1, Ordering::SeqCst); } input.cursor_events.clear(); })); }
width_for_measure와 width_for_input 모두 width를 clone해야 합니다. 이제 column 안에서 animated_box 컴포넌트를 50개 사용하고, 각 아이템에 동일한 상태를 재사용하고 싶다고 상상해 보세요. 그러면 let animated_box_state_for_xxx = animated_box_state.clone()를 50번 적어야 합니다. 이것이 Clone Hell입니다.
remember의 역할은 프레임을 가로질러 지속되는 상태를 컴포넌트 내부에 생성하는 것입니다.
rust#[tessera] fn counter() { let count = remember(|| 0); count.with_mut(|c| *c += 1); }
이제 counter 컴포넌트가 처음 빌드될 때 remember는 초기값 0으로 상태를 생성합니다. 그리고 렌더링 때마다 1씩 증가시킵니다. 이 상태는 어떤 프레임에서든 해당 컴포넌트가 빌드되지 않을 때에만 파괴됩니다.
React나 Compose에 익숙한 독자라면 이 API가 매우 익숙하게 느껴질 수 있습니다. 실제로 설계는 React의 useState, Compose의 remember와 유사합니다. 하지만 React 훅과 달리 remember는 제약이 훨씬 적습니다. rules-of-hooks를 따를 필요가 없고, if, loop, match 같은 제어 흐름 안에서도 안전하게 사용할 수 있습니다.
다만 가상 리스트(virtual list) 같은 컴포넌트는 매 프레임 모든 자식 컴포넌트를 빌드하지 않을 수 있습니다. 이런 경우 remember를 순진하게 사용하면 상태가 다음으로 보이는 컴포넌트 인스턴스로 “밀려”갈 수 있습니다. 이런 상황에서는 스코프 내에서 안정적인 식별자를 지정하는 key를 사용하여, remember가 상태를 올바른 컴포넌트 인스턴스에 바인딩하도록 해야 합니다.
remember는 앞서 언급한 상태 끌어올리기 문제를 완벽하게 해결합니다. 이제 부모 컴포넌트와 무관한 상태는 자식 컴포넌트 내부에 직접 둘 수 있습니다. 즉, 이전의 layer0/layer1/switch 예시는 다음처럼 단순해집니다:
rust#[tessera] fn layer1() { switch(SwitchArgs::default()); } #[tessera] fn layer0() { layer1(); }
여러 개의 스위치 컴포넌트를 사용할 때의 네이밍 문제도 마찬가지로 해결됩니다.
rust#[tessera] fn foo() { switch(SwitchArgs::default()); switch(SwitchArgs::default()); switch(SwitchArgs::default()); }
또한 remember는 Clone Hell 문제도 해결합니다. 핵심은 반환값에 있습니다. remember는 Arc<Lock<T>>를 반환하는 것이 아니라 State<T>를 반환합니다. 이는 가벼운 Copy 핸들이므로, 소유권 이전이나 clone 없이도 FnOnce 클로저로 쉽게 move할 수 있습니다.
따라서 위의 animated_box 예시는 다음처럼 다시 쓸 수 있습니다:
rust#[tessera] fn animated_box() { let width = remember(|| Px::new(100)); measure(Box::new(move |input| { Ok(ComputedData { height: Px::new(100), width: width.get(), }) })); input_handler(Box::new(move |input| { let pressed = // ... check if box is pressed ... if pressed { width.with_mut(|w| { *w += Px::new(1); }); } input.cursor_events.clear(); })); }
remember 외에도, Tessera v3는 테마 시스템을 위한 context 기능처럼 비슷하지만 구별되는 기능들을 도입합니다. context는 remember와 직교(orthogonal)합니다. context는 컴포넌트 트리의 여러 레벨을 가로질러 데이터를 전달하는 데 쓰이지만 프레임을 넘어 상태를 지속할 수는 없습니다. 반면 remember는 컴포넌트 내부에서 프레임을 넘어 상태를 지속하지만, 컴포넌트 트리 레벨을 넘어 데이터를 전달할 수는 없습니다. 이 기능들을 결합하면 Tessera의 상태 관리 경험과 코드 간결성이 크게 개선됩니다.
remember 메커니즘은 본질적으로 **위치 기반 메모이제이션(positional memoization)**입니다. 제가 아는 한 Rust UI에서 이 메커니즘을 가장 이르게 탐구한 사례는 Raph Levien의 crochet입니다. crochet은 당시 새로 도입된 #[track_caller] 기능을 사용해 이를 구현했습니다(여기서는 Dioxus 같은 라이브러리가 사용하는 훅 메커니즘은 논의하지 않겠습니다. 이들 역시 broadly positional 메커니즘이긴 하지만 제약이 더 많습니다). #[track_caller]는 함수가 std::panic::Location::caller()를 통해 호출자의 소스 코드 위치를 얻을 수 있게 해주며, 이를 상태의 고유 식별자로 사용할 수 있습니다. 이는 가능하긴 하지만 중요한 한계가 있고, 궁극적으로는 std::panic의 일부로서 이 목적을 위해 설계된 것이 아닙니다.
따라서 Tessera의 remember 메커니즘은 #[track_caller]를 사용하지 않습니다. 대신 더 복잡하지만 신뢰할 수 있는 절차적 매크로(proc macro) 분석 메커니즘을 사용합니다. #[tessera] 절차적 매크로는 if, loop, match 같은 제어 흐름을 재작성하여 GroupGuard를 삽입합니다. 처리 후 다양한 제어 흐름은 대략 다음처럼 보입니다:
rustif condition { let _group_guard = ::tessera_ui::runtime::GroupGuard::new(#group_id); original_statement; } for item in iterator { let _group_guard = ::tessera_ui::runtime::GroupGuard::new(#group_id); original_statement; } match value { Pattern1 => { let _group_guard = ::tessera_ui::runtime::GroupGuard::new(#group_id_1); original_statement_1; } Pattern2 => { let _group_guard = ::tessera_ui::runtime::GroupGuard::new(#group_id_2); original_statement_2; } // ... } loop { let _group_guard = ::tessera_ui::runtime::GroupGuard::new(#group_id); original_statement; }
RAII 원리에 따라 _group_guard는 스코프가 끝나는 즉시 drop됩니다. Drop 구현은 Tessera에게 현재 제어 흐름 블록이 끝났음을 알립니다. 이 방식으로 Tessera는 각 remember 호출의 제어 흐름 위치를 정확히 추적할 수 있고, 실제 런타임 제어 흐름 위치를 기반으로 고유 식별자를 생성하여 신뢰할 수 있는 상태 메모이제이션을 달성합니다.
안타깝게도 매크로 확장 순서의 한계 때문에, Tessera의 remember 메커니즘은 현재 선언적 매크로(declarative macro) 내부에서의 사용을 완전히 지원하지 못합니다. 이 단계에서는 매크로가 아직 확장되지 않았기 때문에 tessera 절차적 매크로는 매크로 내부의 제어 흐름 구조를 알 수 없고, 결과적으로 remember 호출에 대해 올바른 식별자를 생성할 수 없습니다. 현재로서는 remember(혹은 이를 사용하는 컴포넌트 호출)를 매크로 내부에서 피해야 합니다. 단, 선언적 매크로가 생성하는 코드에 remember에 영향을 주는 제어 흐름이 없다면 사용할 수 있습니다. 이를 해결하려면 향후 Rust가 어떤 방식으로든 매크로 확장 순서에 영향을 줄 수 있게 지원해야 할 것입니다.
상태 저장, 즉 State<T>는 제어 가능한 Arena GC로 구현되어 있습니다. 유사한 개념은 gc-arena를 참고할 수 있습니다. Tessera는 remember 호출을 기반으로 매 프레임 상태를 live로 마킹하는 특화된 경량 구현을 사용합니다. 마킹되지 않은 상태는 프레임 끝에서 정리됩니다.