Rust의 엄격한 소유권 모델과 잘 어울리도록 설계된 Zed의 커스텀 UI 프레임워크 GPUI에서, 애플리케이션 상태와 이벤트 흐름을 어떻게 구성하고 관리하는지 설명합니다.
Rust로 Zed의 사용자 인터페이스를 처음 만들 때 마주했던 도전 중 하나는 Rust의 엄격한 소유권 시스템이었습니다. Rust에서는 모든 객체가 하나의 유일한 소유자만을 가지며, 이는 순환 참조나 공유 소유 없이 모든 데이터를 트리 구조로 조직하도록 강하게 유도합니다. Zed를 만들기 전까지 제가 GUI 코드를 작성하던 경험의 대부분은 웹 기술에 기반해 있었고, JavaScript의 가비지 컬렉터 덕분에 소유권에 대해 깊이 생각할 필요가 거의 없었습니다. 예를 들어, DOM 노드에 마우스 이벤트 리스너를 붙이면서 this에 대한 참조를 캡처하는 일은 매우 쉽고, UI를 만드는 데 관한 저의 직관 대부분이 이 패러다임에 기반해 있었습니다. 그러나 Rust에서 이벤트 리스너 안에 self를 캡처하는 일은 정반대로 간단하지 않습니다.
그래서 2019년에 Zed를 시작했을 때, 우리는 웹과 다른 프레임워크에서 배운 많은 것들을 다시 생각해야 한다는 점이 분명했습니다. Rust와 잘 어울리는 시스템이 필요했지만, 동시에 실제 그래픽 인터페이스를 표현할 수 있을 만큼의 동적 특성도 필요했습니다. 예를 들어, Zed의 워크스페이스는 다양한 타입의 모달 다이얼로그를 표시할 수 있어야 하고, 이 다이얼로그들은 언제 닫혀야 하는지를 워크스페이스에 알리는 이벤트를 발행할 수 있어야 합니다. 또한 프로젝트 패널에서 파일 시스템이 변경될 때처럼 서브트리를 비동기적으로 갱신하는 것도 지원해야 했습니다. 이 밖에도 예시는 많지만, 우리는 애플리케이션 상태를 표현하기 위해 특이한 자료구조를 강제하지 않으면서 이 모든 문제를 다루고자 했습니다. 가능한 한 매크로를 피하고, 평범한 Rust 구조체를 사용하고 싶었습니다.
Rc 같은 내장 타입을 사용하려는 초기 시도는 잘 풀리지 않았고, 우리는 커스텀 UI 프레임워크인 GPUI에 지금까지도 이어지고 있는 접근을 실험하기 시작했습니다. GPUI에서는 애플리케이션의 모든 모델과 뷰가 실제로는 AppContext라고 불리는 단일 최상위 객체에 의해 소유됩니다. 새로운 모델이나 뷰(이를 통틀어 엔티티 라고 부릅니다)를 만들 때, 그 상태에 대한 소유권을 애플리케이션에 넘겨 다양한 앱 서비스에 참여하고 다른 엔티티들과 상호작용할 수 있도록 합니다.
이를 설명하기 위해 아래의 사소한 앱을 살펴보겠습니다. 우리는 run을 콜백과 함께 호출하여 앱을 시작하는데, 이 콜백에는 애플리케이션의 모든 상태를 소유하는 AppContext에 대한 참조가 전달됩니다. 이 AppContext는 창을 여는 것, 다이얼로그를 띄우는 것 등 모든 애플리케이션 레벨 서비스로의 관문 역할을 합니다. 또한 아래에서 호출하는 new_model 메서드를 통해 모델을 생성하고, 그 소유권을 애플리케이션에 넘길 수 있습니다.
rustuse gpui::{prelude::*, App, AppContext, Model}; struct Counter { count: usize, } fn main() { App::new().run(|cx: &mut AppContext| { let counter: Model<Counter> = cx.new_model(|_cx| Counter { count: 0 }); // ... }); }
new_model 호출은 모델 핸들 을 반환하는데, 이 핸들은 참조하는 객체의 타입에 기반한 타입 매개변수를 가집니다. 이 Model<Counter> 핸들 자체만으로는 모델의 상태에 접근할 수 없습니다. 이 핸들은 단지 비활성 식별자와 컴파일 타임 타입 태그에 불과하며, 앱이 소유한 실제 Counter 객체에 대한 참조 카운트를 유지합니다.
Rust 표준 라이브러리의 Rc와 마찬가지로, 이 참조 카운트는 핸들이 클론될 때 증가하고, 드롭될 때 감소하여 기반 모델에 대한 공유 소유를 가능하게 합니다. 그러나 Rc와는 달리, 이 핸들은 AppContext에 대한 참조가 있을 때만 모델의 상태에 접근할 수 있도록 해 줍니다. 핸들 자체가 상태를 진짜로 소유 하는 것은 아니지만, 진짜 소유자인 AppContext를 통해 상태에 접근하는 데 사용할 수 있습니다. 이제 예제를 이어서, 컨텍스트를 사용해 카운터를 증가시켜 봅시다. 간결함을 위해 일부 설정 코드는 생략하겠습니다.
rustApp::new().run(|cx: &mut AppContext| { let counter = cx.new_model(|_cx| Counter { count: 0 }); // `update`를 호출해 모델의 상태에 접근합니다. counter.update(cx, |counter: &mut Counter, cx: &mut ModelContext<Counter>| { counter.count += 1; }); });
카운터를 갱신하기 위해, 우리는 핸들에서 update를 호출하고, 컨텍스트 참조와 콜백을 전달합니다. 콜백에는 카운터에 대한 가변 참조가 주어지며, 이를 사용해 상태를 조작할 수 있습니다.
콜백에는 두 번째 인자로 ModelContext<Counter> 참조도 제공됩니다. 이 참조는 run 콜백에 제공된 AppContext 참조와 유사합니다. ModelContext는 실제로는 AppContext를 감싸는 래퍼이지만, 특정 모델(이 경우 우리의 카운터)과 결부되도록 추가적인 데이터를 포함합니다.
AppContext가 제공하는 애플리케이션 레벨 서비스 외에도, ModelContext는 모델 레벨 서비스에 접근하는 수단을 제공합니다. 예를 들어, 이를 사용해 이 모델의 상태가 변경되었음을 옵저버들에게 알릴 수 있습니다. 예제에 cx.notify() 호출을 추가해 보겠습니다.
rustApp::new().run(|cx: &mut AppContext| { let counter = cx.new_model(|_cx| Counter { count: 0 }); counter.update(cx, |counter, cx| { counter.count += 1; cx.notify(); // 옵저버에게 알림 }); });
이제 이러한 알림을 어떻게 관찰할 수 있는지 살펴보겠습니다. 카운터를 갱신하기 전에, 이를 관찰하는 두 번째 카운터를 하나 더 구성해 보겠습니다. 첫 번째 카운터가 변경될 때마다, 두 번째 카운터의 값은 첫 번째 카운터의 두 배가 되도록 하겠습니다. 두 번째 카운터에 속한 ModelContext에서 observe를 호출해, 첫 번째 카운터가 notify를 호출할 때마다 두 번째 카운터가 알림을 받도록 설정하는 방법에 주목하세요. observe 호출은 Subscription을 반환하고, 우리는 이 구독을 detach하여 두 카운터가 존재하는 동안 이 동작이 유지되도록 합니다. 또한 이 구독을 어딘가에 저장해 두었다가, 원하는 시점에 드롭하여 이 동작을 취소할 수도 있습니다.
observe 콜백에는 옵저버에 대한 가변 참조와 관찰 대상 카운터에 대한 핸들 이 전달되며, 관찰 대상의 상태는 read 메서드로 접근합니다.
rustApp::new().run(|cx: &mut AppContext| { let counter: Model<Counter> = cx.new_model(|_cx| Counter { count: 0 }); let observer = cx.new_model(|cx: &mut ModelContext<Counter>| { cx.observe(&counter, |observer, observed, cx| { observer.count = observed.read(cx).count * 2; }) .detach(); Counter { count: 0, } }); counter.update(cx, |counter, cx| { counter.count += 1; cx.notify(); }); assert_eq!(observer.read(cx).count, 2); });
첫 번째 카운터를 갱신한 후, 위에서 설정한 구독에 따라 옵저버 카운터의 상태가 유지되고 있음을 볼 수 있습니다.
observe와 notify는 엔티티의 상태 변경을 알리는 수단인 반면, GPUI는 subscribe와 emit도 제공하여 엔티티가 타입이 있는 이벤트를 발행할 수 있도록 합니다. 이 시스템을 사용하려면, 이벤트를 발행하는 객체가 EventEmitter 트레이트를 구현해야 합니다.
먼저 CounterChangeEvent라는 새로운 이벤트 타입을 도입한 다음, Counter가 이 타입의 이벤트를 발행할 수 있음을 표시해 보겠습니다.
ruststruct CounterChangeEvent { increment: usize, } impl EventEmitter<CounterChangeEvent> for Counter {}
이제 예제를 갱신하여, 관찰을 구독(subscription)으로 교체해 보겠습니다. 카운터를 증가시킬 때마다, 얼마나 증가하는지를 나타내는 Change 이벤트를 발행하겠습니다.
rustApp::new().run(|cx: &mut AppContext| { let counter: Model<Counter> = cx.new_model(|_cx| Counter { count: 0 }); let subscriber = cx.new_model(|cx: &mut ModelContext<Counter>| { cx.subscribe(&counter, |subscriber, _emitter, event, _cx| { subscriber.count += event.increment * 2; }) .detach(); Counter { count: counter.read(cx).count * 2, } }); counter.update(cx, |counter, cx| { counter.count += 2; cx.emit(CounterChangeEvent { increment: 2 }); cx.notify(); }); assert_eq!(subscriber.read(cx).count, 4); });
이제 GPUI의 내부로 조금 더 파고들어, 관찰과 구독이 어떻게 구현되어 있는지 살펴보겠습니다.
GPUI의 이벤트 처리 세부사항으로 바로 들어가기 전에, Atom 에디터에서 작업하던 시절의 교훈적인 경험을 하나 떠올리고 싶습니다. 그때 저는 JavaScript로 커스텀 이벤트 시스템을 구현하고 있었고, 이벤트 리스너들을 배열에 보관한 뒤 이벤트가 발행되면 각 리스너를 순차적으로 호출하는, 겉보기에는 단순한 이벤트 이미터를 설계했습니다.
그러나 이 단순함은 코드가 프로덕션에서 널리 사용될 때까지 눈에 띄지 않았던 미묘한 버그를 초래했습니다. 문제는 한 리스너 함수가 자신이 구독 중인 동일한 이미터에 이벤트를 발행했을 때 나타났습니다. 이는 의도치 않게 재진입(reentrancy)을 유발했고, 이미 실행 중이던 발행 함수가 완료되기도 전에 다시 호출되는 상황이 벌어졌습니다. 이 재귀적인 동작은 함수가 선형적으로 실행되리라는 우리의 기대를 깨뜨렸고, 예기치 않은 상태에 빠지게 했습니다. JavaScript의 가비지 컬렉터가 메모리 안전을 보장하긴 하지만, 언어의 느슨한 소유권 모델 때문에 이런 버그를 저지르기 쉬웠습니다.
Rust의 제약은 이런 순진한 접근을 훨씬 더 어렵게 만듭니다. Rust는 위에서 설명한 종류의 재진입을 방지하는 방향으로 우리를 강하게 이끕니다. GPUI에서는 emit이나 notify를 호출해도 즉시 리스너가 실행되지 않습니다. 대신, 데이터를 이펙트(effect) 큐에 푸시합니다. 각 업데이트의 마지막에 이펙트를 플러시하면서, 큐의 앞에서부터 이펙트를 꺼내 큐가 빌 때까지 처리한 뒤 이벤트 루프에 제어를 반환합니다. 어떤 이펙트 핸들러는 다시 더 많은 이펙트를 푸시할 수 있지만, 시스템은 결국 안정 상태에 도달합니다. 이렇게 하면 재진입 버그 없이, Rust와 잘 어울리는 run-to-completion(완료까지 실행) 의미론을 얻을 수 있습니다.
아래는 app.rs에 있는 이 접근의 핵심입니다. 이후에 설명하겠습니다.
rustimpl AppContext { pub(crate) fn update<R>(&mut self, update: impl FnOnce(&mut Self) -> R) -> R { self.pending_updates += 1; let result = update(self); if !self.flushing_effects && self.pending_updates == 1 { self.flushing_effects = true; self.flush_effects(); self.flushing_effects = false; } self.pending_updates -= 1; result } fn flush_effects(&mut self) { loop { self.release_dropped_entities(); self.release_dropped_focus_handles(); if let Some(effect) = self.pending_effects.pop_front() { match effect { Effect::Notify { emitter } => { self.apply_notify_effect(emitter); } Effect::Emit { emitter, event_type, event, } => self.apply_emit_effect(emitter, event_type, event), // 명확성을 위해 몇 가지 이펙트는 생략 } } else { for window in self.windows.values() { if let Some(window) = window.as_ref() { if window.dirty { window.platform_window.invalidate(); } } } break; } } } // 그 외 수많은 메서드들... }
AppContext::update 메서드는 재진입적 호출을 허용하기 위한 몇 가지 부가적인 관리 작업을 수행합니다. 가장 바깥층 호출에서 빠져나가기 전에 flush_effects를 호출합니다. flush_effects 메서드는 루프입니다. 매 회전마다, 우리는 드롭된 엔티티와 포커스 핸들을 해제하여, 참조 카운트가 0이 된 리소스의 소유권을 제거합니다. 그런 다음 큐에서 다음 이펙트를 꺼내 이를 적용합니다. 다음 이펙트가 없다면, 모든 윈도우를 순회하면서 더러운(dirty) 윈도우가 있다면 플랫폼 윈도우를 무효화하여 다음 프레임에서 그려지도록 스케줄합니다. 그리고 루프를 빠져나옵니다.
이제 AppContext::update를 사용해 update_model을 구현해 보겠습니다. 우선 시그니처를 논의할 수 있도록 아래처럼 골격을 잡아 두겠습니다.
rustimpl AppContext { fn update_model<T: 'static, R>( &mut self, model: &Model<T>, update: impl FnOnce(&mut T, &mut ModelContext<'_, T>) -> R, ) -> R { todo!() } }
이 메서드는 두 개의 가변 참조를 기대하는 콜백을 받습니다. 하나는 주어진 핸들이 가리키는 모델의 상태에 대한 것이고, 다른 하나는 ModelContext에 대한 것입니다. 앞서 언급했듯이 ModelContext는 실제로는 AppContext를 감싸는 래퍼입니다. AppContext가 모델을 소유하고 있기 때문에, 이는 처음 보기에는 동일한 데이터에 대한 여러 개의 가변 대여가 필요한 것처럼 보이고, Rust는 이를 금지합니다.
우리가 사용하는 우회 방법은 모델 상태를 AppContext로부터 임시로 "임대(lease)"하는 것입니다. 즉, 컨텍스트에서 상태를 제거하여 스택으로 옮깁니다. 콜백을 호출한 뒤, 임대를 종료하면서 소유권을 다시 컨텍스트에 복원합니다.
rustimpl AppContext { fn update_model<T: 'static, R>( &mut self, model: &Model<T>, update: impl FnOnce(&mut T, &mut ModelContext<'_, T>) -> R, ) -> R { self.update(|cx| { let mut entity = cx.entities.lease(model); let result = update(&mut entity, &mut ModelContext::new(cx, model.downgrade())); cx.entities.end_lease(entity); result }) } }
이 방식은 엔티티를 재진입적으로 갱신하려 할 경우 문제를 일으킬 수 있지만, 실제로는 이를 피하는 것이 꽤 수월했고, 오류를 저질렀을 때도 이를 빠르고 쉽게 감지할 수 있었습니다.
지금까지 GPUI에서 상태가 어떻게 관리되는지의 기본을 살펴보았습니다. 다음으로 다루어야 할 것은, 이 상태를 어떻게 뷰로 화면에 표시하는가입니다. 하지만 이는 다음 글로 미루어야겠습니다. 그때까지는 우리 소스 코드를 둘러보고, 오늘 Zed 안에서 진행되는 첫 Fireside Hack 라이브에 참여해 주세요. 마침 제 생일이기도 하고, 여러분과 함께 Zed에서 시간을 보내는 것보다 더 좋은 방법은 생각나지 않습니다.
Zed 팀의 비슷한 블로그 글도 확인해 보세요.
macOS, Windows, 또는 Linux에서 오늘 바로 Zed를 사용해 볼 수 있습니다. 지금 다운로드하세요!
이 블로그에서 다루는 주제들에 열정을 느끼신다면, 우리 팀에 합류하여 소프트웨어 개발의 미래를 함께 만들어 주시길 바랍니다.