Rust 바인딩을 사용하는 Gtk4 애플리케이션을 깔끔하고 확장 가능하게 만들기 위한 MVPVM 아키텍처 접근법과 구현 예시를 소개한다.
URL: https://w-graj.net/posts/rust-gtk4-mvpvm/
Gtk4는 원래 C 툴킷이지만, 고품질 Rust 바인딩을 제공하여 Rust의 진화하는 GUI 생태계에서 더 성숙한 선택지 중 하나가 되었다. Rust 바인딩은 꽤 쾌적하게 사용할 수 있지만, 이를 실제로 어떻게 활용해 구조가 잘 잡힌 프로그램을 만들지에 대해서는 합의나 가이드가 없다. 따라서 이 글에서는 내가 깔끔하고 확장 가능한 GUI 앱을 만드는 데에 유용했던 접근법을 소개한다. 템플릿이 들어 있는 git 저장소는 여기에서 찾을 수 있다.
좋은 애플리케이션에는 좋은 아키텍처가 필요하다. 그리고 (혹은 그 악마 같은 이름 때문인지) Model-View-Presenter-ViewModel 디자인 패턴은 꽤 괜찮은 방식이다. 이 패턴은 마이크로소프트가 아직 괜찮은 소프트웨어를 만들던 시절인 2011년에 WPF를 위해 개척한 것으로 알려져 있다.
간단히 말해, MVPVM 앱은 다음 요소들로 구성된다:
async 작업을 수행해야 하며, 이는 Glib의 단일 스레드 async 런타임에 부담을 주지 않도록 별도의 tokio 런타임에서 실행해야 한다.각 페이지는 View, ViewModel, Presenter를 각각 하나씩 가져야 하지만, 필요하다면 더 세분화할 수도 있다.
Gtk4는 위젯을 프로그래밍 방식으로 만들 수 있으므로, UI 전체를 Rust로 구성할 수도 있다. 하지만 이는 대개 좋은 생각이 아니다. 큰 리팩터링이 어려워지고, 보일러플레이트가 많이 늘어나기 때문이다. 다행히도 cambalache 도구를 사용하면 최소한의 번거로움으로 UI를 그래픽으로 설계할 수 있다. 생성된 XML 파일은 Rust에서 로드할 수 있으며, 고유 ID(예: main_window 또는 duration_spin_button)를 부여한 위젯은 Rust에서 접근할 수 있다.

그림 1: Cambalache에서의 UI 설계
View는 GUI에 무엇을 표시할지를 책임지는 단순한 구조체다. 생성자와 시그널을 연결하는 메서드를 제외하면, Presenter가 수행하고 싶어 하는 GUI 동작(예: 알림 다이얼로그 표시)을 추상화하는 메서드만 포함해야 한다. 사용자가 수행하는 모든 UI 액션은 Gtk의 connect_* 메서드를 사용하여 View에서 Presenter로 바로 라우팅된다.
Gtk는 composite template 덕분에 이런 구조체의 필드를 자동으로 채워줄 수 있지만, View는 아주 단순하게 유지하는 것이 목적이므로 대개는 그만한 가치가 없고 오히려 번거롭다.
rust#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)] pub struct MainView { pub window: ApplicationWindow, pub duration: SpinButton, pub sleep: Button, } impl MainView { pub fn new(builder: >k::Builder) -> Self { Self { window: builder.object("main_window").unwrap(), duration: builder.object("duration_spin_button").unwrap(), sleep: builder.object("sleep_button").unwrap(), } } pub fn connect_signals(&self, presenter: &Rc<RefCell<MainPresenter>>) { self.sleep.connect_clicked(glib::clone!( #[weak] presenter, move |_| { presenter.borrow().on_sleep_clicked(); } )); } pub fn success(&self) { self.window.alert("Success", ""); } }
ViewModel은 View의 모든 데이터에 쉽게 접근할 수 있게 해준다. 그렇지 않으면 위젯 자체의 메서드를 호출해서 데이터를 읽고/써야 한다. Gtk 관련 보일러플레이트가 꽤 많지만 핵심은 다음과 같다. 내부 ViewModel 구조체는 읽기/쓰기를 가능하게 만들고 싶은 UI의 각 요소(여기서는 duration 스핀 버튼)에 대한 필드를 가진다. 그런 다음 bind_property로 ViewModel 필드를 GUI에 바인딩하여, Presenter에 의해 변경될 때마다 항상 동기화되도록 한다.
rustmod imp { use std::cell::RefCell; use glib::{ prelude::ObjectExt, subclass::{object::ObjectImpl, prelude::DerivedObjectProperties, types::ObjectSubclass}, Properties, }; #[derive(Properties, Default)] #[properties(wrapper_type = super::MainViewModel)] pub struct MainViewModel { #[property(get, set)] pub duration: RefCell<u64>, } #[glib::object_subclass] impl ObjectSubclass for MainViewModel { const NAME: &'static str = "MainViewModel"; type Type = super::MainViewModel; fn new() -> Self { Self::default() } } #[glib::derived_properties] impl ObjectImpl for MainViewModel {} } glib::wrapper! { pub struct MainViewModel(ObjectSubclass<imp::MainViewModel>); } impl MainViewModel { pub fn new(view: &MainView) -> Self { let this: Self = glib::Object::builder().build(); this.bind_property("duration", &view.duration, "value") .flags(BindingFlags::SYNC_CREATE | BindingFlags::BIDIRECTIONAL) .build(); this } }
Presenter는 View와 Model을 조율하는 “접착제”다. 생성자, Model로부터의 async 콜백을 처리하는 메서드, 그리고 View가 호출하는 사용자 액션 핸들러를 포함한다. 모든 비즈니스 로직은 Model로 넘기며, Gtk가 단일 스레드이기 때문에 메인 스레드를 막지 않도록 Model은 별도의 tokio 런타임에서 비동기로 실행할 수 있다.
Gtk는 Rust의 소유권 규칙을 꽤 느슨하게 취급하므로, Presenter는 Rc<RefCell<_>>에 들어가고 Model은 Arc<Mutex<_>>에 들어가는 것을 볼 수 있다. 성능이 문제가 되기 전까지(대개는 되지 않는다) Model이 사용할 수 있는 모든 것은 Arc<Mutex<_>>에 넣어라(어느 스레드에서든 실행될 수 있으므로). 그리고 View, ViewModel, Presenter만 쓰는 것들은 Rc<RefCell<_>>에 두면 된다.
rust#[derive(Clone, Debug)] pub struct MainPresenter { view_model: MainViewModel, view: MainView, model: Arc<Mutex<Model>>, } impl MainPresenter { pub fn new(view: &MainView, model: Arc<Mutex<Model>>) -> Rc<RefCell<Self>> { let this = Rc::new(RefCell::new(Self { view_model: MainViewModel::new(&view), view: view.clone(), model, })); view.connect_signals(&this); this } pub fn process_event(&self, event: &Event) { match event { Event::Slept => { self.view.success(); } _ => {} } } pub fn on_sleep_clicked(&self) { let duration = Duration::from_secs(self.view_model.duration()); model::spawn!(self.model, async move |model: &mut Model| model.sleep(duration).await); } }
Model은 모든 비즈니스 로직이 존재하는 곳이다. GUI와 분리된 async 작업을 수행할 수 있으므로, 채널을 사용해 이러한 async 연산의 결과를 Presenter에게 보낸다. model::spawn! 매크로의 “마법” 덕분에 에러도 포함할 수 있다.
rust#[derive(Debug)] #[non_exhaustive] pub enum Event { Error(Error), Slept, } #[derive(Clone, Debug)] pub struct Model { pub send: mpsc::Sender<Event>, } impl Model { pub const fn new(send: mpsc::Sender<Event>) -> Self { Self { send } } pub async fn sleep(&mut self, duration: Duration) -> Result<()> { if duration.as_secs() >= 3 { error!("Duration too long"); bail!("Duration too long"); } tokio::time::sleep(duration).await; self.send.send(Event::Slept).await?; Ok(()) } }
Glib은 log 크레이트와 매우 잘 통합되는 로깅 인프라를 제공한다. 이를 위해 GlibLogger를 제공하며, 이는 모든 로그를 Glib의 로깅 시스템으로 라우팅한다.
rustconst LOGGER: GlibLogger = GlibLogger::new(GlibLoggerFormat::Structured, GlibLoggerDomain::CrateTarget); log::set_logger(&LOGGER).unwrap();
model::spawn! 매크로앞서 설명했듯이, Model의 비동기 작업은 Glib의 런타임 대신 별도의 tokio 런타임에서 스폰되어야 한다. 또한 대부분의 비즈니스 로직 작업은 실패 가능(fallible)하며, 그 에러는 GUI까지 전파되어야 한다. model::spawn! 매크로는 이를 달성하기 위해 사용할 수 있다.
rustmacro_rules! spawn { ($model:expr, $f:expr) => { #[allow(clippy::significant_drop_tightening)] $crate::runtime().spawn(glib::clone!( #[strong(rename_to = model)] $model, async move { let mut model = model.lock().await; let Err(e) = ($f)(&mut model).await else { return; }; model.send.send(Event::Error(e)).await.unwrap(); } )); }; } model::spawn!(self.model, async move |model: &mut Model| model.sleep(duration).await);
glib::clone! 매크로Gtk는 라이프타임을 회피하고 모든 것을 참조 카운팅되는 힙 할당 컨테이너에 저장하기 때문에, Gtk 애플리케이션 전반에서 glib::clone! 매크로를 광범위하게 사용하는 것이 좋다. 따라서 참조 사이클이 생기거나, 영원히 살아 있는 참조를 만들지 않도록 주의해야 하며, 그래야 더 이상 필요 없을 때 리소스를 해제할 수 있다.
아래 예시에서 #[weak]는 Presenter(Rc<RefCell<MainPresenter>>)에 대해 약한 참조를 잡도록 지정한다. 즉, 이 클로저에서 사용 중이더라도 Presenter는 해제될 수 있으며, 그 경우 클로저는 단순히 실행되지 않는다.
rustself.sleep.connect_clicked(glib::clone!( #[weak] presenter, move |_| { presenter.borrow().on_sleep_clicked(); } ));
이 글에서 보았듯 MVPVM 패턴은 Rust Gtk 애플리케이션에 꽤 잘 맞으며, Rust의 GUI 생태계가 계속 개선되는 와중에도 Gtk가 Rust를 위한 실용적인 GUI 툴킷임을 보여주길 바란다.