GPUI와 egui의 상태 모델, 세분화된 반응성, 그리고 복잡한 데스크톱 애플리케이션을 위한 구성 요소 설계를 비판적으로 살펴봅니다.
최근 네이티브 Rust GUI 프레임워크를 사용해 구축되는 데스크톱 GUI 앱이 늘어나는 추세가 있습니다. 이제 잘 알려진 electron 앱조차 공식 재작성에 나서고 있습니다. Discord는 앱을 GPUI로 재작성하고 있다고 발표했습니다.1 한편, 공개되는 많은 LLM 클론은 egui를 선택하는 듯합니다. 이 글에서는 왜 이런 프레임워크들이 구축 기반으로 좋은 선택인지 확신하지 못하는지 설명하고자 합니다.
단도직입적으로 말하겠습니다. 저는 GPUI와 egui의 상태 모델 모두 복잡한 데스크톱 애플리케이션에는 막다른 길이라고 생각합니다. 둘 다 인상적인 프로젝트이며, 각자의 자리가 있습니다. Zed는 진정으로 빠르고 사용하기 좋게 느껴지며, 저도 bevy 게임의 디버그 인터페이스에 egui를 사용합니다. 하지만 이런 프레임워크들이 네이티브 데스크톱 앱의 기본 선택지가 되어서는 안 된다고 생각합니다.
저는 두 가지 주장을 하고자 합니다.
공개: 저는 GPUI의 현재 Linux 렌더러를 wgpu로 이식했고2, 아래에서 사용한 벤치마크 하니스를 구축했으며, 차트에 등장하는 신호 기반 Rust 프레임워크 zgui를 작업하고 있습니다.
Raph Levien은 Ron Minsky의 말을 인용합니다. “모든 UI 프레임워크 내부에는 어떤 종류의 증분화 프레임워크가 숨어 있다.”3 UI는 상태의 함수이며, 상태는 한 번에 조금씩 변합니다. 하지만 그 함수가 비싸지면, 증분 상태 변화로부터 화면 픽셀을 갱신하는 더 빠른 방법을 도출할 방법을 찾고 싶을 수 있습니다. 따라서 모든 프레임워크는 하나의 질문에 답해야 합니다. 값 하나가 바뀌면 무엇이 실행되는가?
| 모델 | 쓰기가 실행하는 것 | 변경을 찾는 주체 |
|---|---|---|
| 즉시형 (egui) | 다음 프레임의 모든 UI 코드 | 아무도 없음 |
| Elm (Iced) | 모든 view, 그다음 차이 계산 | 차이 계산 |
| 뷰 (GPUI) | 캐시가 없는 모든 뷰 | notify를 사용하는 개발자 |
| 신호, 재실행 (Freya, Compose) | 이를 읽는 구성 요소 | 프레임워크 |
| 신호, 한 번 실행 (Floem, Solid, zgui) | 이를 읽는 클로저 | 프레임워크 |
사람들이 처음 세 가지를 좋아하는 이유는 이해합니다. 동기화할 상태가 없고, 상태가 존재하는 장소가 하나이며, render 함수는 평범한 Rust이고, 매크로나 별도의 컴파일러도 없습니다. 저는 이러한 접근법이 확장되지 않는다는 점, 그리고 근본 문제를 고치면 이 모델들을 단순하고 이해하기 쉽게 만든 요소를 무너뜨린다는 점을 주장하려 합니다.
notify는 zone.js이다GPUI는 자신을 “하이브리드 즉시형 및 유지형 모드” 프레임워크라고 부릅니다.4 엔터티에 상태를 유지합니다. UI는 render 함수로 정의되며, 상태 변경은 재실행을 유발해야 합니다.
impl Render for Sidebar {
fn render(
&mut self,
_: &mut Window,
cx: &mut Context<Self>,
) -> impl IntoElement {
let inbox = self.inbox.read(cx);
div()
.child(format!("{} unread", inbox.unread()))
.children(inbox.threads.iter().map(|t| t.title.clone()))
}
}
이는 평범한 Rust입니다. if, for, 지역 변수를 사용할 수 있고, 이상한 래퍼도 없습니다. 상태 변경에는 명시적 알림이 필요합니다.
self.threads[i].unread = false;
cx.notify();
notify는 엔터티 식별자를 전달합니다.5 Rust에는 &mut에 대한 쓰기 장벽이 없으므로 GPUI는 어떤 필드가 바뀌었는지, 아니면 무엇이든 실제로 바뀌었는지조차 볼 수 없습니다. Angular 팀은 2023년에 zone.js에 관해 이렇게 썼습니다. 이는 “무언가가 일어났을 수도 있을 때 알릴 수 있을 뿐”이며, “이 접근법은 잘 확장되지 않았다.”6notify는 엔터티를 지정하므로 zone보다 정밀하지만, 궁극적으로는 같은 종류의 진술을 합니다. 그 식별자 아래에서 GPUI는 아무것도 모르므로 유효한 .cached() 경계 뒤에 있지 않은 모든 뷰를 다시 렌더링합니다. 그리고 실제로 GPUI는 흔히 모든 뷰를 다시 렌더링합니다. 애플리케이션 작성자가 .cached()를 좀처럼 사용하지 않고, 레이아웃 의미론에 일정한 제약을 부과하기 때문입니다.
GPUI는 “빠르다”는 평판이 있습니다. 상수는 상대적으로 낮을 수 있지만, 점근적으로는 많은 것을 놓치고 있습니다. 그래서 대규모 UI에서 희소한 상태 변화가 실제로 얼마의 비용을 초래하는지 특별히 시험하는 몇 가지 벤치마크를 실행했습니다.
행마다 캐시된 뷰를 둔 3,000개의 활성 행에서 레이블 하나를 바꾸는 데 8.4 ms가 걸립니다. 호버, 키 입력, 이전 값과 같은 값을 쓰는 작업은 모두 거의 같은 비용이 듭니다. 비용이 변화가 아니라 트리를 따르기 때문입니다. 활성 행 1,000개당 약 3 ms씩 증가합니다. 위 코드처럼 행을 일반 요소로 작성하면 같은 레이블에 92 ms가 걸립니다. 직접 작성한 동등성 가드는 같은 값 쓰기의 비용을 7.9 ms에서 0.9 µs로 낮춥니다. 이는 각 호출 지점이 찾아내야 하는 여지가 얼마나 큰지 보여 줍니다.

이것이 모든 GPUI 앱이 느리다는 뜻은 아니지만, 상태 모델의 한계를 보여 줍니다. 특히 레이아웃 비용이 더 클 경우 실제로 이 벽에 부딪힙니다. Zed는 빠르게 느껴질 수 있지만, 사실 Zed는 UI 관점에서 비교적 단순한 앱입니다. 앱의 크롬은 단순한 레이아웃 안의 몇 개 버튼, 사이드바, 텍스트 편집기로 이루어져 있으며, 텍스트 편집기는 GPUI의 레이아웃 및 뷰 의미론을 완전히 우회하는 사용자 정의 요소로 구현되어 있습니다. 그들 스스로의 설명에 따르면 창에는 “수십 개의 노드와 수천 개의 요소”가 있습니다.7
명백한 해결책은 더 많이 유지하는 것입니다. Zed에는 이를 위한 실험 브랜치가 있으며, Longbridge의 gpui-fast는 프레임 간 각 뷰의 하위 트리를 보관하는 포크입니다.89 이는 한계 내에서 작동합니다. 어느 쪽이든 재사용할 수 있는 가장 작은 단위는 뷰입니다. 따라서 사이드바를 다시 구축하지 않고 레이블 하나를 갱신하려면 그 레이블은 뷰여야 합니다.
pub struct UnreadBadge { unread: usize, _watch: Subscription }
impl UnreadBadge {
pub fn new(inbox: Entity<Inbox>, cx: &mut Context<Self>) -> Self {
let unread = inbox.read(cx).unread();
let _watch = cx.observe(&inbox, |this, inbox, cx| {
let unread = inbox.read(cx).unread();
if unread != this.unread {
this.unread = unread;
cx.notify();
}
});
Self { unread, _watch }
}
}
impl Render for UnreadBadge {
fn render(
&mut self,
_: &mut Window,
_: &mut Context<Self>,
) -> impl IntoElement {
format!("{} unread", self.unread)
}
}
그 소유자는 이를 생성하고 핸들을 유지해야 합니다.
let badge = cx.new(|cx| UnreadBadge::new(inbox.clone(), cx));
레이블 하나가 개수 하나에 의존한다는 사실을 표현하는 데 엔터티, Render 구현, 구독, 값의 저장된 복사본, 직접 작성한 가드, 소유자가 모두 필요합니다. 신호 프레임워크에서는 다음과 같습니다.
let unread = Memo::new(move |_| {
threads.with(|ts| ts.iter().filter(|t| t.unread.get()).count())
});
Label::derived(move || format!("{} unread", unread.get()))
GPUI API의 나머지 부분도 이에 맞서 작용합니다. Render는 속성을 받지 않으므로 부모는 자신의 렌더 중에 자식 엔터티로 값을 밀어 넣습니다. 버튼과 행에 해당하는 RenderOnce 구성 요소는 부모 뷰 내부에서 그려지므로 전혀 경계가 아닙니다. 목록 행은 클로저이므로 행 뷰를 사용하려면 키가 지정된 엔터티 맵을 직접 관리해야 합니다.
그리고 이는 상태가 우연히 뷰와 정렬되는 곳에서만 도움이 됩니다. gpui-fast 자체의 60패널 벤치마크를 다시 실행하여 각 프레임을 한 번 그린 뒤 상태를 이동했습니다.

상태를 각 패널이 소유할 때 변경 하나는 83%를 절약합니다. 공유 모델에서는 유지가 같은 프레임을 11% 더 느리게 만듭니다. 창과 편집은 동일합니다. 실제 Zed에서 Zed 팀은 유지가 “일반 워크로드에서 비활성화 상태보다 1–3% 느렸다”고 측정했으며, “실제 Zed에서의 이득 대부분”은 다른 작업에서 왔다고 발견했고, “마이크로벤치마크의 승리가 실제 Zed 결과를 예측하지 못했다”고 경고했습니다.8 gpui-fast는 실제 진전이며 채택 비용도 적습니다. 3,000행 레이블의 비용을 8.4 ms에서 3.4 ms로 낮춥니다. 그러나 그 프레임은 세 개의 뷰를 렌더링하고 요소 하나를 레이아웃할 뿐인데도, 여전히 전체 트리를 순회하므로 3.4 ms를 소비합니다.
웹은 이미 이 길을 걸었습니다. Redux의 스타일 가이드는 “더 많은 UI 구성 요소가 Redux 저장소를 구독하고 더 세분화된 수준에서 데이터를 읽도록 하는 편을 선호하라”고 말합니다.10 MobX는 “작은 observer 구성 요소를 많이 만들라”고 말합니다.11 둘 다 프레임워크가 이를 추론할 수 없으므로 투영마다 경계를 수동으로 만들라고 말합니다.
이 부분이 제가 가장 넘기 어려운 지점입니다. 사람들은 render가 상태의 평범한 함수이기 때문에 GPUI를 선택합니다. 각 투영이 구독과 가드를 가진 별도로 소유되는 엔터티인 UI는 더 이상 그렇지 않습니다. 이는 누락된 간선이 조용히 실패하는, 훨씬 무거운 부품으로 손수 구축한 신호 그래프입니다. Zed 자체 설계 노트는 “누락된 알림은 애플리케이션 버그”라고 말합니다.12 쾌적한 API를 유지하면 뷰 크기의 갱신을 얻습니다. 정밀함을 택하면 쾌적한 API를 잃습니다. 그 위에 신호 크레이트를 올려도 이 선택은 바뀌지 않습니다. 가장 작은 종착점이 여전히 뷰이기 때문입니다. render가 실행되지 않은 채 노드 하나에 도달하려면 프레임워크가 볼 수 있는 쓰기와 프레임보다 오래 사는 노드가 필요하며, 이는 새 상태 모델과 새 요소 계약을 뜻합니다.
통상 제시되는 규모에서 Elm 아키텍처는 기만적으로 단순한 UI 모델입니다. 상태값 하나, update 하나, view 하나, 그리고 모든 변경에는 이름이 있습니다. 대규모 애플리케이션도 이를 성공적으로 사용하지만,1314 저는 이 명료함은 작은 경우에 속하며, 복잡한 애플리케이션에서는 신호와 저장소가 논리적으로 이해하기 더 쉬울 수 있다는 점을 말하고 싶습니다.
같은 unread 개수를 보겠습니다. 모든 메시지 뒤에 정확해야 하므로, view가 모든 갱신에서 이를 다시 계산하거나 update가 이를 유지해야 합니다.
match message {
InboxMessage::MarkRead(i) => {
if self.threads[i].unread {
self.threads[i].unread = false;
self.unread -= 1;
}
}
InboxMessage::Delete(i) => {
if self.threads.remove(i).unread {
self.unread -= 1;
}
}
InboxMessage::Arrived(thread) => {
self.unread += thread.unread as usize;
self.threads.push(thread);
}
}
이제 unread의 정의는 특정한 한곳에 존재하지 않습니다. 스레드를 건드리는 모든 분기에 흩어져 있으며, 새 메시지마다 이를 기억해야 합니다. 컴파일러는 match가 빠짐없는지는 확인하지만 산술은 확인하지 않습니다. 위 메모에는 정의가 하나이고, 새 작성자가 이를 잊을 수 없습니다.
위치에서도 같은 일이 일어납니다. 호버 상태나 반쯤 입력한 초안은 어디에 존재해야 할까요? 단일 진실 공급원은 모델 안에 있어야 한다고 말하며, 메시지 유형은 아래로 전달되고 결과는 다시 위로 전달됩니다. Elm의 가이드는 이렇게 말합니다. “구성 요소를 적극적으로 만들려 하는 것은 Elm에서 재앙의 지름길이다.”15 Iced는 부모에게 Action을 반환하라고 권장합니다.16 Iced의 관리자는 이 아키텍처에서 구성 요소는 언제나 “고통으로 끝난다”고 말하며, 이를 피하는 것이 “데이터 모델에 대해 깊이 생각하도록 강제한다”는 규율을 위한 최선의 논거도 제시합니다.17 알겠습니다. 공정한 주장이라고 생각합니다. 하지만 cosmic-files에는 최상위 메시지 변형이 136개 있고 탭용으로 73개가 더 있으며, 부모가 해석할 명령을 반환하는 Tab::update도 있습니다.18 그 안의 모든 것은 명시적이지만, 어느 것도 단순하지 않습니다.
이 아키텍처에도 GPUI와 같은 문제가 있습니다. update 구현은 메시지가 무엇을 쓰는지는 알려 주지만, 누가 필드를 읽었는지는 알려 줄 수 없습니다. view 내부의 무엇이든 읽을 수 있습니다. Elm 아키텍처는 “무엇이 바뀌었는가?”에 답하지 않고 “무슨 일이 있었는가?”에 답합니다. 세션 내보내기는 일부 시나리오에서 유용할 수 있지만, 확장 가능한 성능 특성에는 유용하지 않습니다.19
저는 웹의 역사에서 이를 읽습니다. Redux는 이 설계의 가장 잘 알려진 친척이며, 그 문서는 현장 보고서처럼 읽힙니다. 저장소의 일부를 “마치 데이터베이스인 것처럼” 다루십시오.20 선택자는 “전달된 모든 액션 뒤에 다시 실행됩니다.”21 핵심 패키지는 “구식”이며, 상용구를 제거하기 위해 만들어진 도구 모음으로 대체되었습니다.22 React 밖에서 주류는 추적된 읽기로 이동했습니다. Angular, Vue, Svelte, Solid는 신호나 그와 동등한 것 위에 구축되고, Preact는 이를 제공하며, Svelte 팀은 이를 이렇게 표현했습니다. “우리는 Knockout이 줄곧 옳았다는 사실을 깨달았다.”23 React 안에서는 Jotai와 MobX가 라이브러리로 추적을 가져오며, Legend-State는 심지어 “한 번 렌더링” 스타일도 제공합니다.24 대부분의 애플리케이션은 여전히 React 위에 있고 React 팀은 이에 동의하지 않는데, 이는 아래에서 다시 다루겠습니다. 따라서 하나의 큰 예외를 제외하면 상태 모델을 중심으로 수렴하고 있습니다.
성능도 같은 흐름을 따릅니다. Iced는 메시지 묶음마다 view 전체를 다시 구축하므로, 3,000행에서 레이블 하나는 39,023개 위젯을 만들고 7.3 ms가 걸립니다. 모든 행에 Lazy를 적용해도 view가 키를 해시하기 위해 각 행을 여전히 방문해야 하므로 5.7 ms에 그칩니다. 공식 lazy 예제는 네 개의 update 분기가 손수 증가시키는 version: u8을 키로 사용합니다.25 이는 더 좋은 이름을 붙인 더티 플래그입니다. 공정하게 말하면 cosmic-files와 Halloy는 lazy를 전혀 사용하지 않으며, 가상화된 목록에서 Iced는 제가 측정한 가장 빠른 프레임워크 중 하나로 0.19 ms입니다. 제한된 화면은 저렴하게 다시 구축됩니다. 하지만 점근적 복잡도는 명확합니다. 그리고 GPUI와 마찬가지로 API 의미론은 필요한 정보를 단순히 표현할 수 없습니다.
실제로 이에 대한 선례가 있습니다. Vizia는 이 모델에 정밀함을 추가하면 무슨 일이 일어나는지 보여 줍니다. 2026년 3월 렌즈에서 신호로 이동하면서 Model과 이벤트 전달을 유지했습니다.26 다음은 축약한 카운터입니다.27
pub enum AppEvent {
Increment,
}
impl Model for AppData {
fn event(&mut self, _: &mut EventContext, event: &mut Event) {
event.map(|app_event, _| match app_event {
AppEvent::Increment => self.count.update(|count| *count += 1),
});
}
}
let count = Signal::new(0);
AppData { count }.build(cx);
Button::new(cx, |cx| Label::new(cx, "Increment"))
.on_press(|cx| cx.emit(AppEvent::Increment));
Label::new(cx, count).width(Pixels(50.0)).live(Live::Polite);
레이블은 신호를 직접 읽고, 버튼을 구축하는 같은 클로저가 신호를 만들었습니다. 열거형, 전달, 핸들러는 update 호출 하나를 전달하기 위해 존재합니다. Vizia는 뷰가 한 번만 구축되므로 엄격한 Elm은 아니었고, 신호는 합리적인 이동이었습니다. 필드별 쓰기를 얻었기 때문입니다. 하지만 Elm의 이유에서 무엇이 남았는지 보십시오. 상태는 가변 셀 그래프이고, Model은 핸들 모음이며, event는 &mut self 부수 효과입니다. 이제 무언가 바뀌었다고 말하는 시스템이 두 개가 됩니다. 그러므로 Elm 아키텍처에 신호를 추가하는 것에 대한 제 이의는 GPUI의 경우와 같습니다. 작동하지만, 여러분이 처음에 찾아온 것을 제거합니다.
egui의 제안은 이 분야 최고입니다. “앱 상태와 GUI 상태가 동기화되지 않는 일을 걱정할 필요가 없다.”28 도구라면 이를 이기기 어렵습니다.
for row in rows {
ui.horizontal(|ui| {
ui.label(&row.name);
ui.label(row.price.to_string());
if ui.button("+").clicked() {
row.price += 1;
}
});
}
대가는 무엇이 바뀌었는지와 무관하게 프레임 비용이 UI의 크기가 된다는 점입니다. 3,000행에서 모든 작은 편집에는 약 32 ms가 걸리며, 접근성 기능을 끄면 약 19 ms입니다. egui의 저자는 이 선택을 솔직하게 설명합니다. 즉시형 모드는 “사용하기는 더 쉽지만, 덜 강력하다.”28
애플리케이션이 커지면 처음의 매력은 사라집니다. 레이아웃 역설은 “근본적인 결함”입니다.28 긴 목록에는 고정된 행 높이 하나를 받는 show_rows가 필요합니다. 제가 아는 가장 큰 egui 애플리케이션인 Rerun은 “이전 프레임의 egui 상태를 검사하여” 쿼리를 제한합니다.29 비용이 큰 모든 것은 데이터로 키를 지정한 캐시 뒤로 갑니다. 데이터와 일치해야 하는 캐시는 즉시형 모드가 제거하려 했던 동기화 문제이며, 이제 애플리케이션 코드 안에서 한 프레임 늦게 발생합니다.
일부 프레임워크는 같은 엔진에서 거칠게도 세밀하게도 배선할 수 있으므로, 하니스는 이를 직접 보여 줄 수 있습니다.
몇몇 엔진을 두 가지 방식으로 벤치마크했습니다. 한 번은 가능한 가장 세분화된 방식으로, 또 한 번은 더 거친 방식으로 사용했습니다. 결과는 세분화된 반응성으로 얼마나 많은 이득을 얻을 수 있는지 보여 줍니다.

두 가지가 두드러집니다. Iced의 배율은 뷰 함수가 여전히 실행되므로 작습니다. 그리고 Floem의 거친 경우는 볼 가치가 있습니다. 모든 셀은 전역 수정 신호 하나를 읽는데, 이는 신호 옷을 입은 notify입니다. 차트에서 가장 느린 결과입니다. 따라서 신호가 거친 상태를 구해 주지는 않습니다. React 팀도 반대쪽에서 같은 사실을 발견했습니다. “하나의 거대한 입력”을 가진 벤치마크에서 신호식 런타임은 기대에 미치지 못했고, Joe Savona는 데이터 모델이 알고리즘만큼, 어쩌면 그보다 더 중요하다고 결론지었습니다.30 상태는 읽기처럼 형성되어야 합니다. 이것이 제가 이를 상태 모델 문제라고 부르는 이유입니다. 좋은 API는 데이터 모델을 UI 투영과 쉽게 정렬할 수 있게 해야 합니다.
이 모든 것을 받아들인다고 가정합시다. 프레임워크가 읽기를 추적하기를 원하고, 상태가 그에 맞게 형성되기를 원합니다. 가장 단순한 API는 무엇일까요?
pub fn sidebar(threads: RwSignal<Vec<Thread>>) -> impl IntoView {
Runs once, when the sidebar mounts.
let unread = Memo::new(move |_| {
threads.with(|ts| ts.iter().filter(|t| t.unread.get()).count())
});
Stack::vertical((
Label::derived(move || format!("{} unread", unread.get())),
dyn_stack(move || threads.get(), |t| t.id, thread_row),
))
}
이것이 전부입니다. 본문은 한 번 실행됩니다. 프레임워크에 넘기는 클로저는 자신이 읽는 무언가가 바뀔 때 다시 실행됩니다. 지역 변수는 지역 변수입니다. 훅 규칙도, 의존성 배열도, 오래된 클로저도, 속성의 PartialEq도 없습니다. 행이 여전히 존재하므로 어느 것도 행의 정체성을 재구축하지 않습니다.
신호를 Freya의 모델인 재실행 구성 요소와 비교해 보겠습니다. 이는 잘 작동합니다. 3,000행에서 키 입력 하나의 비용은 1.2 ms입니다. 하지만 카운터를 보십시오.31
#[derive(PartialEq)]
struct Counter;
impl Component for Counter {
fn render(&self) -> impl IntoElement {
let mut count = use_state(|| 0);
}
}
use_state가 존재하는 이유는 그 줄이 다시 실행되기 때문입니다. 이제 두 모델을 모두 보유합니다. 읽기 추적은 어느 범위가 재실행될지 결정하고, 그 내부에는 훅, 속성 동등성, 차이 계산을 갖춘 재렌더 의미론이 적용됩니다. 더 세밀한 갱신에는 더 작은 구성 요소가 필요하며, 이는 훨씬 낮은 비용의 GPUI 문제입니다. 재실행 본문이 얻는 것은 평범한 제어 흐름이고, 이것이 진지한 반론입니다.
Dan Abramov는 설득력 있는 논거를 제시합니다.32 한 번 실행은 렌더링 로직을 클로저에 넣으므로 최상위 제어 흐름이 작동하지 않게 하고, 논리가 아닌 값을 중심으로 코드를 묶게 합니다. “React의 아름다움은 무언가를 ‘계산된 값’으로 만드는 일이 요구 사항이 아니라 최적화라는 점이다.” 그는 이 점에서 옳습니다. 다음 코드는 컴파일되지만 첫 줄은 절대 갱신되지 않습니다.
Label::new(count.get()), // read once; never updates
Label::derived(move || count.get()), // a hole; updates
반응형 if는 dyn_container가 되고, for는 dyn_stack이 되어야 합니다. Solid 2.0은 최상위 읽기에 대한 개발 경고를 추가해야 했습니다.33
하지만 같은 의견에는 제가 반대편에 서게 되는 이유도 담겨 있습니다. 그는 Solid의 재그룹화가 “useMemo로 최적화할 때 값을 중심으로 코드를 재구성해야 하는 방식과 비슷하다”고 말합니다. 정확합니다. 애플리케이션에 그것이 절대 필요하지 않다면 React의 모델이 더 단순할 수 있습니다. 필요하다면 어느 쪽이든 재그룹화 비용을 치르며, 유일한 선택은 시점입니다. 처음부터 할 것인가, 아니면 나중에 프로파일러 아래에서 할 것인가입니다. 제가 동의하는 Ryan Carniato의 답변은 이렇습니다. “Solid에서는 데이터를 파생된 것처럼 항상 작성하므로 최적화를 위해 리팩터링하지 않는다.”32 이 정밀함이 필요하다는 것을 알게 되면, 한 번 실행은 그와 다르게 가장하지 않는 API입니다. 실패 양상도 이를 선호합니다. 잊은 구멍은 작성한 자리에서 첫 갱신 시 오래된 값이 됩니다. 잊은 notify는 우연히 다시 그리게 된 다른 일에 의존합니다.
완전히 솔직히 말하면, 생태계의 더 넓은 수렴은 구체적으로 한 번 실행이 아니라 일반적인 읽기 추적에 있습니다. Angular는 바인딩별 갱신을 고려하고 문서로 남긴 뒤 뷰별 갱신을 선택했으며, 부분 갱신에서 Solid와 약 1 ms 차이까지 도달합니다.634 언어를 통제하는 Apple과 Google은 @Observable 및 Compose와 함께 재실행 본문을 유지했습니다.3536 구멍 자체가 재실행 범위이므로, 차이는 기본 범위의 크기입니다. 다만 여기서 두 가지를 짚고 싶습니다.
render는 임의의 함수이므로 이를 범위로 분할할 수 있는 패스가 없습니다. Rust가 저렴하게 표현할 수 있는 것은 클로저입니다.뷰 수준 재실행과 세분화된 신호는 같은 성능 상한을 가질 수 있습니다. 하지만 신호는 처음부터 세분화된 상태 투영과 메모이제이션을 장려하며, 뷰 수준 재실행에서는 그것이 더 많은 상용구로 이어지는 명시적 선택입니다.
세분화된 반응성은 그 자체로 앱을 빠르게 만들지 않습니다.

Floem의 반응성은 약속한 일을 정확히 합니다. 레이블을 바꾸면 5개의 효과가 실행되고 반응형 작업에 0.01 ms를 소비한 다음, Taffy가 잎 하나만 이동했다는 사실을 모르기 때문에 레이아웃에 약 14 ms를 씁니다. Floem에는 해당 편집이 절대 다시 그려지지 않는 알려진 버그도 있으므로, 이를 하한으로 취급하십시오. Vizia는 4개의 바인딩을 실행한 다음 120,091개 엔터티를 모두 순회하여 46 ms가 걸립니다. Slint는 같은 값을 4 µs에 쓰지만, 약 30,000개 항목을 순회하는 패스에서 레이블 하나에 172 ms가 걸립니다. Freya는 5개의 변이를 생성한 다음 레이아웃 노드 57,050개를 방문하여 14개를 다시 계산합니다. 이 편집에서는 GPUI가 이들 대부분을 이깁니다.
이것이 논증이 의존하는 구분입니다. 그러한 선형 비용은 각각 엔진 내부에 있습니다. 애플리케이션 코드에서 어느 것도 요구하지 않으므로, 애플리케이션을 바꾸지 않고도 같은 API 뒤에서 고칠 수 있습니다. zgui는 이것이 어디까지 가능한지 보여 줍니다. 3,000행에서 레이블 하나에 0.69 ms, 행 1,000개당 약 0.26 ms로, GPUI보다 약 12배 더 평평합니다. 여전히 평평하지 않고, 제 프레임워크이며 이 하니스에 맞춰 조정했으므로 이 수치는 어느 정도 유보해서 보십시오. Iced와 egui에서는 선형 비용이 계약에 있습니다. API가 여러분의 코드가 실행된다고 말합니다. 어떤 엔진 작업도 이를 제거할 수 없고, 앱 코드가 바뀌어야 합니다. GPUI는 아마도 그 중간 어딘가에 있으므로 gpui-fast가 상황을 어느 정도 개선할 수 있습니다.
이 시점에서 더 상세한 벤치마크 행렬을 공유하고 싶습니다. 처음 다섯 행을 따라 재실행 열을 읽으면 편집에 의존하지 않으므로 거의 움직이지 않습니다. 신호 열은 기반 구현이 다르므로 많이 움직입니다.

그 격자의 아래쪽은 신호의 비용이 드러나는 곳이기도 합니다.
move 클로저에 의존합니다. Levien은 그러한 패턴이 “Rust에 잘 적응하지 못한다”고 생각합니다.3 저는 충분히 잘 적응한다고 생각하고, GPUI의 엔터티도 아레나 핸들입니다. 하지만 인정하건대 공짜 점심은 아닙니다.GPUI는 전체 프레임을 비교적 저렴하게 만들며, 따라서 많은 사용 사례에서는 아마도 충분히 좋습니다. 비용이 큰 표면을 손수 처리하는 편집기에는 분명 충분합니다. 대규모 애플리케이션에는 선택지가 남습니다. 사람들이 좋아하는 API를 유지하고 뷰마다 비용을 지불하거나, 엔터티로 신호 그래프를 작성해 정밀함을 얻는 것입니다. Elm과 즉시형 모드도 같은 문제가 있습니다. 각각 작은 경우에 가장 명료하며, 정밀함이 필요해질수록 선택했던 이유를 잃습니다. 제 개인적 경험으로는, 그 일은 사람들이 예상하는 것보다 더 빨리 일어납니다.
추적된 읽기는 어디서 시작했든 이 분야 대부분이 도달한 곳입니다. 우리가 그곳으로 가고 있다면, 저는 그곳에서 시작하여 구성 요소를 한 번 실행되는 함수로 만들어야 한다고 생각합니다.
ddc9eda925, gpui-fast는 86a8228229입니다. zgui는 082337db입니다. 이는 세 번의 실행 중 최고 결과로서 2026년 9월과 11월에 걸친 것입니다.retained_bench를 시험 모드에서 실행한 것이므로 GPU 작업은 포함하지 않습니다. 여기서 유지 비활성화도 여전히 gpui-fast입니다.