Rust GUI 앱 만들기: Tauri, egui, Slint, Iced
언젠가는 터미널에 글자만 찍는 게 아니라, 버튼이 달린 진짜 창을 만들고 싶어질 거예요. Rust로 그런 걸 만들 수 있을까요? 네, 만들 수 있어요. Rust GUI 앱은 빠르고, 작고, 안전해요. 쓸 만한 툴킷도 이미 여러 개 있어요. 다만 Rust의 GUI 생태계는 C#의 WPF나 JavaScript의 Electron 같은 세계보다 아직 젊어요. 그래서 하나를 고르기 전에 전체 지형을 알아 두면 도움이 돼요.
이 페이지에서는 Rust GUI의 현재 상황을 솔직하게 정리하고, 고르는 데 도움이 되는 표, 즉시 모드(immediate mode)와 유지 모드(retained mode)의 개념, 그리고 가장 인기 있는 툴킷들로 만든 작은 첫 프로그램을 보여 드릴게요.
코드는 어디서 실행할 수 있나요? GUI에는 진짜 창이 필요해요. 그래서 egui, Tauri, Slint, Iced 프로그램은 여러분의 컴퓨터에서 cargo run으로 실행해야 해요. 이 페이지의 egui와 Iced 프로그램은 현재 버전에 맞춰 cargo check로 컴파일을 확인했어요. 창을 그리는 대신 글자를 출력하는 순수 Rust로 GUI를 흉내 낸 섹션이 두 개 있는데, 이건 브라우저에서 바로 실행할 수 있어요. “Try it Yourself” 버튼을 찾아보세요.
Rust GUI의 솔직한 현재 상황
섹션 제목: “Rust GUI의 솔직한 현재 상황”커뮤니티 사이트 Are we GUI yet?(영어)는 한 줄로 이렇게 요약해요. “뿌리는 아직 깊지 않지만, 씨앗은 심어져 있다(The roots aren’t deep but the seeds are planted).” 지금도 꽤 정확한 말이에요.
- 좋은 점: 속도, 작은 앱 크기, 그리고 Rust의 안전성이에요. egui, Iced, Slint는 화면을 전부 직접 그리기 때문에, 코드 하나로 Windows, macOS, Linux에서 모두 돌아가요. Tauri는 웹 개발 기술을 그대로 쓸 수 있고, 시스템에 이미 있는 웹뷰를 쓰기 때문에 Electron보다 앱이 훨씬 작아요.
- 아직 자라는 중인 점: C++의 Qt나 C#의 WPF처럼 “표준”이라고 할 툴킷이 하나로 정해져 있지 않아요. 인기 있는 크레이트 중 몇몇은 아직 0.x 버전이라, 업데이트하면 코드가 깨질 수 있어요. 접근성, 오른쪽에서 왼쪽으로 쓰는 글자, 운영체제 고유의 모양과 느낌은 툴킷마다 수준이 달라요.
- 좋은 소식: 완벽한 툴킷을 찾을 필요는 없어요. 첫 앱이라면 아래 표에 있는 어떤 툴킷을 골라도 충분히 잘 돼요.
더 자세한 비교가 궁금하다면 2025 survey of Rust GUI libraries(영어)에서 여러 툴킷을 하나씩 직접 테스트한 결과를 볼 수 있어요.
무엇을 만들 수 있을까요
섹션 제목: “무엇을 만들 수 있을까요”지금 바로 살펴볼 수 있는 실제 앱들이에요.
- 로봇과 컴퓨터 비전 데이터를 살펴보는 도구인 Rerun Viewer는 egui로 만들어졌어요. (Rerun은 egui 개발을 후원하기도 해요.)
- System76이 만든 Linux 데스크톱 환경 COSMIC은 Iced로 만들어졌어요. IRC 채팅 클라이언트 Halloy도 마찬가지예요.
- 웹 스타일 화면을 가진 데스크톱 도구: 메모 앱, 대시보드, 작은 업무용 도구는 Tauri와 잘 맞아요.
- 기기에 달린 화면: Slint는 마이크로컨트롤러에서도 돌아가서, 기계나 가전제품의 터치스크린에 잘 어울려요.
어떤 크레이트를 고를까요?
섹션 제목: “어떤 크레이트를 고를까요?”버전은 2026년 10월 기준 최신 릴리스예요.
| 크레이트 | 버전 | UI를 작성하는 방법 | 실행 환경 | 이럴 때 고르세요 |
|---|---|---|---|---|
| Tauri | 2.12 (3.0은 알파) | 화면은 HTML, CSS, JavaScript로, 로직은 Rust로 | Windows, macOS, Linux, Android, iOS | 웹 개발을 할 줄 알거나, 화려하게 꾸민 화면을 원할 때 |
| egui + eframe | 0.37 | 순수 Rust, 즉시 모드 | Windows, macOS, Linux, 웹(Wasm) | 도구, 디버그 패널, 에디터, 그리고 순수 Rust로 가장 빨리 시작하고 싶을 때 |
| Slint | 1.18 | 자체 .slint 마크업, 로직은 Rust로 |
데스크톱, 임베디드, 모바일, 웹 | 완성도 높은 앱이나 임베디드 기기의 화면을 만들 때 |
| Iced | 0.14 | 순수 Rust, Elm 아키텍처(상태, 메시지, update, view) | Windows, macOS, Linux, 웹 | 모든 변경이 한곳에 모인 깔끔하고 예측 가능한 코드를 좋아할 때 |
| gtk-rs (gtk4) | 0.11 | GTK 4의 Rust 바인딩, 유지 모드 | Linux 우선. Windows와 macOS는 설정을 더 하면 동작 | Linux의 GNOME 데스크톱에 어울리는 네이티브 느낌의 앱 |
| Dioxus | 0.7 | rsx! 매크로를 쓰는 React 스타일 컴포넌트 |
웹, 데스크톱(웹뷰), 모바일 | React를 알고, 웹과 데스크톱을 코드 하나로 만들고 싶을 때 |
라이선스와 성숙도에 대해 솔직하게 몇 가지 덧붙일게요.
- Slint는 GPLv3로 무료로 쓸 수 있고, 로열티 없는 라이선스로도 클로즈드 소스 데스크톱, 모바일, 웹 앱에 무료로 쓸 수 있어요(Slint를 쓴다는 사실을 표시해야 해요). 클로즈드 소스 임베디드 제품에는 유료 라이선스가 필요해요. 출시하기 전에 slint.dev/pricing(영어)을 꼭 읽어 보세요.
- egui, Iced, Dioxus는 아직 0.x예요. 릴리스마다 작은 호환성 깨짐이 있다고 생각하고, 튜토리얼이 내 버전과 맞는지 확인하세요. 예를 들어 예전 egui 튜토리얼은
fn update(&mut self, ctx: &egui::Context, ...)를 쓰지만, eframe 0.37은fn ui(&mut self, ui: &mut egui::Ui, ...)를 써요. - Tauri, Dioxus 데스크톱, gtk-rs는 시스템 라이브러리가 필요해요. 아래의 플랫폼별 준비 사항을 보세요.
즉시 모드와 유지 모드
섹션 제목: “즉시 모드와 유지 모드”툴킷들은 한 가지 큰 질문에서 의견이 갈려요. 위젯을 누가 기억하느냐는 거예요.
유지 모드(gtk-rs, Slint, Tauri의 웹 페이지, 그리고 다른 언어의 툴킷 대부분)에서는 버튼을 한 번만 만들어요. 툴킷이 그 버튼을 위젯 트리에 보관해요. 여러분은 콜백을 연결해 두고, 사용자가 클릭하면 툴킷이 그 콜백을 불러 줘요. 나중에 버튼의 글자를 바꾸려면 그 버튼을 가리키는 핸들이 필요해요.
즉시 모드(egui)에서는 아무것도 보관하지 않아요. UI 함수가 프레임마다 다시 실행되면서, 지금 이 순간 무엇을 보여 줄지 그냥 설명해요. 버튼을 그리고 클릭됐는지 확인하는 일이 한 줄로 끝나요. if ui.button("+").clicked() { ... }처럼요. 콜백도 없고 핸들도 없어요. 앱의 상태는 여러분이 만든 구조체 안에 있고, UI는 항상 그 상태를 보고 그려지기 때문에, 화면과 데이터가 어긋날 일이 없어요.
대신 치르는 비용도 있어요. 툴킷이 매 프레임마다 UI 전체의 레이아웃을 계산하기 때문에, UI가 아주 크면 CPU 시간이 더 들어요. 그리고 어떤 레이아웃(예를 들어 아직 크기를 모르는 창을 가운데 정렬하기)은 더 어려워요. egui는 마우스 움직임처럼 무슨 일이 생겼을 때만 새 프레임을 그려서, 가만히 있는 앱은 CPU를 거의 쓰지 않아요.
여기 순수 Rust로 만든 아주 작은 가짜 즉시 모드 툴킷이 있어요. 반복문 한 번이 프레임 하나예요. clicks 배열이 마우스 역할을 해요.
// 이번 프레임에 무엇을 그렸는지 기록하는 가짜 "Ui"struct Ui { clicked: Option<&'static str>, // 이번 프레임에 마우스로 클릭한 버튼 drawn: Vec<String>,}
impl Ui { fn label(&mut self, text: &str) { self.drawn.push(text.to_string()); }
// 버튼을 그리고, 클릭됐는지도 바로 알려 줘요. fn button(&mut self, text: &'static str) -> bool { self.drawn.push(format!("[{text}]")); self.clicked == Some(text) }}
// 여러분의 UI 코드: 프레임마다 다시 호출돼요.fn draw(count: &mut i32, ui: &mut Ui) { ui.label(&format!("Count: {count}")); if ui.button("+") { *count += 1; } if ui.button("Reset") { *count = 0; }}
fn main() { let mut count = 0; // 앱의 상태는 UI 코드 바깥에 있어요 let clicks = [None, Some("+"), Some("+"), None, Some("Reset"), None];
for (frame, click) in clicks.into_iter().enumerate() { let mut ui = Ui { clicked: click, drawn: Vec::new() }; draw(&mut count, &mut ui); println!("frame {frame}: {}", ui.drawn.join(" ")); }}실행 결과:
frame 0: Count: 0 [+] [Reset]frame 1: Count: 0 [+] [Reset]frame 2: Count: 1 [+] [Reset]frame 3: Count: 2 [+] [Reset]frame 4: Count: 2 [+] [Reset]frame 5: Count: 0 [+] [Reset]frame 1을 보세요. “+“를 클릭했는데 라벨은 아직 0이에요. 버튼이 클릭을 알아채기 전에 라벨이 먼저 그려졌기 때문이에요. 새 값은 한 프레임 뒤에 나타나요. 진짜 egui는 이걸 알아서 처리해 줘요. 클릭이나 키 입력이 있으면 바로 다음 프레임을 그리기 때문에, 예전 숫자를 볼 일이 없어요.
첫 egui 앱: 카운터
섹션 제목: “첫 egui 앱: 카운터”eframe은 egui의 공식 “앱 프레임워크”예요. 창을 열고 그 안에서 egui를 실행해 줘요. 프로젝트를 만들고 eframe을 추가하세요.
cargo new countercd counter[dependencies]eframe = "0.37"src/main.rs를 다음 코드로 바꾸세요.
use eframe::egui;
fn main() -> eframe::Result { let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default().with_inner_size([300.0, 160.0]), ..Default::default() }; eframe::run_native( "Counter", options, Box::new(|_cc| Ok(Box::<CounterApp>::default())), )}
#[derive(Default)]struct CounterApp { count: i32,}
impl eframe::App for CounterApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { egui::CentralPanel::default().show(ui, |ui| { ui.heading(format!("Count: {}", self.count)); ui.horizontal(|ui| { if ui.button("-").clicked() { self.count -= 1; } if ui.button("+").clicked() { self.count += 1; } if ui.button("Reset").clicked() { self.count = 0; } }); }); }}cargo run으로 실행하세요. 첫 빌드에서는 크레이트 수백 개를 내려받아 컴파일하니까 몇 분 정도 기다려 주세요. 그다음 빌드부터는 빨라요. “Count: 0”과 버튼 세 개가 있는 작은 창이 열려요.
각 부분이 맞물리는 방식은 이래요.
CounterApp이 여러분의 상태예요.#[derive(Default)]덕분에count가 0에서 시작해요.run_native가 창을 열어요. 클로저는 앱을Box안에 한 번 만들어요(스마트 포인터 참고).- eframe은 프레임마다
ui를 호출해요.CentralPanel은 창 전체를 채우고,ui.horizontal은 버튼 세 개를 한 줄로 놓아요. - 각각의
if ui.button(..).clicked()는 앞 섹션에서 본 즉시 모드 패턴이에요. 버튼을 그리고, 확인하고, 상태를 바꿔요.
상태를 반드시 self에 두고, ui 안의 지역 변수에 두면 안 되는 이유도 바로 이거예요. 거기에 let mut count = 0;을 쓰면 프레임마다 다시 0부터 시작하거든요.
Tauri 커맨드: 웹 페이지 뒤의 Rust
섹션 제목: “Tauri 커맨드: 웹 페이지 뒤의 Rust”Tauri 앱은 두 부분으로 나뉘어요. 프런트엔드는 시스템의 웹뷰에서 돌아가는 평범한 웹 페이지예요(HTML, CSS, JavaScript, 또는 React나 Svelte 같은 프레임워크). 백엔드는 여러분의 Rust 코드예요. 프런트엔드는 커맨드(command)라고 부르는 Rust 함수를 호출해요.
가장 쉽게 시작하는 방법은 공식 프로젝트 생성기를 쓰는 거예요. 이름과 프런트엔드 템플릿을 물어보는데, 프레임워크가 없는 “vanilla” 템플릿이 처음 고르기에 좋아요.
cargo install create-tauri-app --lockedcargo create-tauri-app이미 Node.js를 쓰고 있다면 npm create tauri-app@latest도 있어요. 그다음 새 폴더로 들어가서, npm 템플릿이라면 npm install과 npm run tauri dev로, Rust만 쓰는 템플릿이라면 cargo install tauri-cli --version "^2.0.0" --locked와 cargo tauri dev로 앱을 시작해요. 무엇을 골랐는지에 맞는 명령을 도구가 출력해 줘요.
생성된 src-tauri/src/lib.rs에 첫 커맨드가 들어 있어요.
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/#[tauri::command]fn greet(name: &str) -> String { format!("Hello, {}! You've been greeted from Rust!", name)}
#[cfg_attr(mobile, tauri::mobile_entry_point)]pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .invoke_handler(tauri::generate_handler![greet]) .run(tauri::generate_context!()) .expect("error while running tauri application");}그리고 프런트엔드(vanilla 템플릿의 src/main.js, 여기서는 줄였어요)는 이렇게 호출해요.
const { invoke } = window.__TAURI__.core;
greetMsgEl.textContent = await invoke("greet", { name: greetInputEl.value });무슨 일이 일어나는지 볼까요.
#[tauri::command]는 속성 매크로예요(절차적 매크로 참고). JavaScript 호출을 평범한 Rust 호출로 바꿔 주는 연결 코드를 대신 써 줘요.- 인자는 JSON으로 도착해요. 그래서 인자의 타입은 모두 serde의
Deserialize를, 반환 타입은Serialize를 구현해야 해요(Serde로 JSON 다루기 참고). JavaScript에서는 인자 이름을 camelCase로 써요. Rust 매개변수invoke_message는{ invokeMessage: "..." }가 돼요. generate_handler![greet]가 커맨드를 등록해요. 새 커맨드를 여기에 추가하는 걸 잊으면 JavaScript에서 호출할 때 실패해요.- 에러를 알리려면
Result를 반환하세요.Ok는 JavaScript의 프로미스를 이행(resolve)하고,Err는 거부(reject)해요. 그래서.catch()로 처리할 수 있어요. 예를 들면fn load(path: String) -> Result<String, String>처럼요. main.rs는run()만 호출해요. 코드가lib.rs에 있는 이유는 같은 앱을 Android와 iOS용으로도 빌드할 수 있게 하기 위해서예요.
Slint: 전용 마크업 언어로 쓰는 UI
섹션 제목: “Slint: 전용 마크업 언어로 쓰는 UI”Slint는 HTML과 CSS를 합친 것과 조금 비슷한, 작은 선언형 언어로 화면을 설명해요. 로직은 Rust 코드가 맡아요. 공식 hello world를 볼게요. [dependencies] 아래에 slint = "1.18"을 추가하고 src/main.rs에 이 코드를 넣으세요.
slint::slint!{ export component HelloWorld { Text { text: "hello world"; color: green; } }}
fn main() { HelloWorld::new().unwrap().run().unwrap();}slint! 매크로는 빌드할 때 마크업을 컴파일해서 HelloWorld라는 Rust 타입을 만들어 줘요. 실제 앱에서는 마크업을 별도의 .slint 파일에 둬요. 다음 카운터는 공식 Slint Rust 템플릿(영어)에서 가져왔어요. ui/app-window.slint 파일이에요.
import { Button, VerticalBox } from "std-widgets.slint";
export component AppWindow inherits Window { in-out property <int> counter: 42; callback request-increase-value(); VerticalBox { Text { text: "Counter: \{root.counter}"; }
Button { text: "Increase value"; clicked => { root.request-increase-value(); } } }}그리고 Rust 쪽 src/main.rs는 이래요.
slint::include_modules!();
fn main() -> Result<(), Box<dyn std::error::Error>> { let ui = AppWindow::new()?;
let ui_handle = ui.as_weak(); ui.on_request_increase_value(move || { let ui = ui_handle.unwrap(); ui.set_counter(ui.get_counter() + 1); });
ui.run()?; Ok(())}Slint는 유지 모드예요. counter 프로퍼티는 UI 안에 있고, 버튼은 콜백을 호출해요. Slint가 마크업을 보고 get_counter, set_counter, on_request_increase_value를 만들어 줘요(대시는 밑줄로 바뀌어요). 클로저는 약한(weak) 핸들(as_weak())을 들고 있어요. 그래야 창과 창 자신의 콜백이 서로를 영원히 붙잡고 있지 않아요(Weak와 Cow 참고). .slint 파일을 쓰려면 [build-dependencies] 아래에 slint-build = "1.18"도 필요하고, 한 줄짜리 build.rs도 있어야 해요. slint_build::compile("ui/app-window.slint").expect("Slint build failed");
Iced와 Elm 아키텍처
섹션 제목: “Iced와 Elm 아키텍처”Iced는 Elm 언어에서 이름을 딴 Elm 아키텍처를 따라요. 모든 앱은 네 부분으로 이루어져요.
- 상태(State): 앱이 기억하는 모든 것을 담은 구조체예요.
- 메시지(Messages): 일어날 수 있는 모든 일을 나열한 열거형이에요(“+ 버튼이 눌렸다”).
- update: 상태와 메시지 하나를 받아서 상태를 바꿔요. 변경이 일어나는 유일한 곳이에요.
- view: 상태를 보고 위젯을 만들어요. 위젯은 스스로 아무것도 바꾸지 않고, 메시지만 보내요.
공식 Iced 카운터를 볼게요([dependencies]에 iced = "0.14"를 추가하세요).
use iced::widget::{button, column, text, Column};use iced::Center;
fn main() -> iced::Result { iced::run(Counter::update, Counter::view)}
#[derive(Default)]struct Counter { value: i64,}
#[derive(Debug, Clone, Copy)]enum Message { Increment, Decrement,}
impl Counter { fn update(&mut self, message: Message) { match message { Message::Increment => self.value += 1, Message::Decrement => self.value -= 1, } }
fn view(&self) -> Column<'_, Message> { column![ button("Increment").on_press(Message::Increment), text(self.value).size(50), button("Decrement").on_press(Message::Decrement), ] .padding(20) .align_x(Center) }}on_press(Message::Increment)는 아무 코드도 실행하지 않아요. “클릭되면 이 메시지를 보내라”고 말할 뿐이에요. 그러면 Iced가 그 메시지로 update를 호출하고, 새 상태를 보여 주려고 view를 다시 호출해요. 모든 변경이 match 하나를 거치기 때문에, 앱이 할 수 있는 모든 일을 한눈에 보고 테스트하기도 쉬워요. 열거형과 match를 잘 활용하는 좋은 예예요.
브라우저에서 Elm 아키텍처 체험하기
섹션 제목: “브라우저에서 Elm 아키텍처 체험하기”이 개념을 연습하는 데 Iced가 꼭 필요하지는 않아요. 다음 할 일 목록 모델도 똑같이 네 부분으로 되어 있어요. view는 위젯 대신 글자를 반환하고, 메시지 목록이 사용자의 타이핑과 클릭을 대신해요.
// 1. 상태: 앱이 기억해야 하는 모든 것#[derive(Default)]struct App { draft: String, todos: Vec<Todo>,}
struct Todo { title: String, done: bool,}
// 2. 메시지: 일어날 수 있는 모든 일#[derive(Debug)]enum Message { DraftChanged(String), AddPressed, Toggle(usize), ClearDone,}
impl App { // 3. update: 상태가 바뀌는 유일한 곳 fn update(&mut self, message: Message) { match message { Message::DraftChanged(text) => self.draft = text, Message::AddPressed => { let title = self.draft.trim(); if !title.is_empty() { self.todos.push(Todo { title: title.to_string(), done: false }); } self.draft.clear(); } Message::Toggle(index) => { if let Some(todo) = self.todos.get_mut(index) { todo.done = !todo.done; } } Message::ClearDone => self.todos.retain(|todo| !todo.done), } }
// 4. view: 상태를 사용자가 보는 것(여기서는 글자)으로 바꿔요 fn view(&self) -> String { let mut screen = format!("New: [{}]\n", self.draft); for (index, todo) in self.todos.iter().enumerate() { let mark = if todo.done { "x" } else { " " }; screen.push_str(&format!("{index}. [{mark}] {}\n", todo.title)); } screen }}
fn main() { let mut app = App::default();
// 키 입력과 버튼 클릭에서 왔다고 생각해 보세요. let messages = vec![ Message::DraftChanged("Buy milk".to_string()), Message::AddPressed, Message::DraftChanged("Call the bank".to_string()), Message::AddPressed, Message::DraftChanged("Water plants".to_string()), Message::Toggle(0), ];
for message in messages { println!("> {message:?}"); app.update(message); } print!("{}", app.view());
app.update(Message::ClearDone); println!("> after ClearDone:"); print!("{}", app.view());}실행 결과:
> DraftChanged("Buy milk")> AddPressed> DraftChanged("Call the bank")> AddPressed> DraftChanged("Water plants")> Toggle(0)New: [Water plants]0. [x] Buy milk1. [ ] Call the bank> after ClearDone:New: [Water plants]0. [ ] Call the bankview는 절대 아무것도 바꾸지 않고, update는 절대 아무것도 출력하지 않는다는 점을 눈여겨보세요. 이렇게 나누는 것이 핵심 비법의 전부예요. 이걸 진짜 Iced 앱으로 바꾸려면 view만 위젯을 반환하도록 다시 쓰면 돼요(DraftChanged를 보내는 text_input, AddPressed를 보내는 button). 상태, 메시지, update는 그대로예요.
플랫폼별 준비 사항
섹션 제목: “플랫폼별 준비 사항”GUI 크레이트는 운영체제와 직접 이야기하기 때문에, 순수 Rust보다 조금 더 필요한 게 있어요. 먼저 Rust 도구와 개발 환경 설정에서 기본 준비를 마치세요. 그다음은 이래요.
| 운영체제 | egui/eframe, Iced, Slint | Tauri는 추가로 필요해요 |
|---|---|---|
| Windows | MSVC C++ 도구 말고는 따로 필요 없어요 | WebView2. Windows 10(버전 1803 이상)과 Windows 11에는 이미 들어 있어요 |
| macOS | Xcode 명령줄 도구(xcode-select --install) |
데스크톱 앱이라면 더 필요 없어요. iOS용은 전체 Xcode가 필요해요 |
| Linux (Debian/Ubuntu) | eframe의 README에 나온 libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev libxkbcommon-dev libssl-dev |
webkit2gtk: libwebkit2gtk-4.1-dev와 Tauri 준비 사항 페이지(영어)에 나온 몇 가지 패키지 |
Linux에서 빌드하다가 pkg-config가 라이브러리가 없다는 메시지를 내며 실패하면, 그 라이브러리의 -dev 패키지를 설치하고 다시 빌드하세요. gtk-rs에는 GTK 4 개발 파일이 필요해요(Ubuntu에서는 libgtk-4-dev).
자주 하는 실수
섹션 제목: “자주 하는 실수”- UI 스레드를 막아 버리기. egui의
ui함수나 Iced의update안에서 다운로드나 오래 걸리는 계산을 하면, 끝날 때까지 창 전체가 멈춰요. 느린 작업은 다른 스레드나 비동기 작업으로 돌리고(동시성과 Async/Await 참고), 결과를 메시지로 UI에 돌려보내세요. - egui 상태를 지역 변수에 두기.
ui는 프레임마다 다시 실행되니까, 그 안의let mut은 매번 새로 시작해요. 상태는 앱 구조체에 두세요. - 등록하지 않은 Tauri 커맨드. 모든 커맨드는
tauri::generate_handler![...]안에 있어야 해요. 그리고lib.rs에서는 커맨드에pub을 붙이지 마세요. Tauri 문서에 따르면 그러면E0255“defined multiple times” 에러가 나요. - 오래된 튜토리얼 따라 하기. 0.x 크레이트에서는 작년 예제가 컴파일되지 않을 수도 있어요. 튜토리얼의 버전을 내
Cargo.toml과 비교하고, 내가 쓰는 버전의 크레이트 저장소에 있는examples폴더를 살펴보세요.
연습 문제
섹션 제목: “연습 문제”1. 0 아래로 내려가지 않는 카운터
섹션 제목: “1. 0 아래로 내려가지 않는 카운터”Increment, Decrement, Reset 메시지를 가진 Elm 스타일 카운터를 만들어 보세요. 사용자가 “-“를 계속 눌러도 값은 절대 0 아래로 내려가면 안 돼요. 메시지 목록을 update에 하나씩 넣고, 매번 view()를 출력하세요.
힌트: u32와 saturating_sub(1)을 쓰세요. 오버플로하지 않고 0에서 멈춰요(정수 오버플로 참고).
정답 보기
#[derive(Default)]struct Counter { value: u32,}
#[derive(Debug)]enum Message { Increment, Decrement, Reset,}
impl Counter { fn update(&mut self, message: Message) { match message { Message::Increment => self.value += 1, Message::Decrement => self.value = self.value.saturating_sub(1), Message::Reset => self.value = 0, } }
fn view(&self) -> String { format!("Count: {}", self.value) }}
fn main() { let mut counter = Counter::default(); let messages = [ Message::Increment, Message::Increment, Message::Decrement, Message::Decrement, Message::Decrement, Message::Increment, Message::Reset, ];
for message in messages { let name = format!("{message:?}"); counter.update(message); println!("{name:<9} -> {}", counter.view()); }}실행 결과:
Increment -> Count: 1Increment -> Count: 2Decrement -> Count: 1Decrement -> Count: 0Decrement -> Count: 0Increment -> Count: 1Reset -> Count: 0“절대 0 아래로 내려가지 않는다”는 규칙은 딱 한곳, update에만 있어요. 메시지 이름을 update를 호출하기 전에 문자열로 만들어 두는 이유는, update가 메시지를 값으로 받아서 이동(move)시키기 때문이에요. ({name:<9}처럼 칸을 맞춰 출력하기 때문에 출력 문자열은 영어로 두었어요.)
2. 실행 취소(Undo) 버튼 추가하기
섹션 제목: “2. 실행 취소(Undo) 버튼 추가하기”카운터에 Undo 메시지를 추가해 보세요. 값이 바뀔 때마다 예전 값을 기억해 두고, Undo를 받으면 한 단계 되돌려요. 기록이 없을 때의 Undo는 아무 일도 하지 않아요. view에 실행 취소할 수 있는 단계가 몇 개 남았는지도 보여 주세요.
힌트: 상태에 history: Vec<u32>를 두세요. 값을 바꾸기 전마다 예전 값을 push하고, Undo에서 pop하면 돼요.
정답 보기
#[derive(Default)]struct Counter { value: u32, history: Vec<u32>, // 예전 값들. 가장 최근 값이 맨 뒤에 있어요}
#[derive(Debug)]enum Message { Increment, Decrement, Reset, Undo,}
impl Counter { fn update(&mut self, message: Message) { let new_value = match message { Message::Increment => self.value + 1, Message::Decrement => self.value.saturating_sub(1), Message::Reset => 0, Message::Undo => { if let Some(previous) = self.history.pop() { self.value = previous; } return; } }; self.history.push(self.value); // 먼저 예전 값을 기억해 둬요 self.value = new_value; }
fn view(&self) -> String { format!("Count: {} (undo steps: {})", self.value, self.history.len()) }}
fn main() { let mut counter = Counter::default(); let messages = [ Message::Increment, Message::Increment, Message::Decrement, Message::Increment, Message::Reset, Message::Undo, Message::Undo, Message::Undo, ];
for message in messages { let name = format!("{message:?}"); counter.update(message); println!("{name:<9} -> {}", counter.view()); }}실행 결과:
Increment -> Count: 1 (undo steps: 1)Increment -> Count: 2 (undo steps: 2)Decrement -> Count: 1 (undo steps: 3)Increment -> Count: 2 (undo steps: 4)Reset -> Count: 0 (undo steps: 5)Undo -> Count: 2 (undo steps: 4)Undo -> Count: 1 (undo steps: 3)Undo -> Count: 2 (undo steps: 2)모든 변경이 이미 update를 거치고 있었기 때문에 Undo를 쉽게 추가할 수 있었어요. 진짜 Iced 앱이라면 Message::Undo를 보내는 버튼 하나만 더 달면 돼요.
- Rust GUI는 지금도 잘 동작하지만, 아직 표준 툴킷이 하나로 정해지지 않았고, 인기 있는 크레이트 몇몇은 아직 0.x예요.
- Tauri는 웹 프런트엔드와 Rust 백엔드를 짝지어요. egui는 순수 Rust로 쓰는 즉시 모드예요. Slint는 전용 마크업을 쓰고 임베디드 기기에서도 돌아가요. Iced는 Elm 아키텍처를 써요.
- 유지 모드는 위젯 트리를 보관하고 여러분의 콜백을 호출해요. 즉시 모드는 콜백 없이, 프레임마다 상태를 보고 UI를 다시 그려요.
- Elm 아키텍처는 앱을 상태, 메시지,
update,view로 나눠서, 변경을 따라가고 테스트하기 쉽게 만들어 줘요. - Tauri는 Windows에서는 WebView2가, Linux에서는 webkit2gtk가 필요해요. 기본 준비는 Rust 도구와 개발 환경 설정에서 확인하세요.
이해도 점검
섹션 제목: “이해도 점검”이해도 점검
1. egui에서 카운터 값을 ui 안의 지역 변수가 아니라 앱 구조체에 둬야 하는 이유는 무엇일까요?
2. Tauri 앱에서 #[tauri::command]는 무슨 일을 하나요?
3. Iced가 쓰는 Elm 아키텍처에서 앱 상태는 어디서 바뀌나요?
4. Linux에서 egui에는 필요 없지만 Tauri에는 필요한 것은 무엇일까요?
점수: 0 / 4
더 알아보기
섹션 제목: “더 알아보기”- Are we GUI yet? (영어): Rust GUI 크레이트를 정리한 커뮤니티 사이트예요. “immediate mode”, “reactive” 같은 태그와 최근 소식도 볼 수 있어요.
- Understanding immediate mode — egui 문서 (영어): egui가 어떻게 동작하는지, 장단점과 함께 만든 사람이 직접 설명해요.
- Calling Rust from the Frontend — Tauri (영어): 인자, 반환값, 에러, 비동기 커맨드, 상태까지 커맨드에 관한 모든 것을 다뤄요.
- Slint Rust 문서 (영어): Rust API,
slint!매크로, 그리고slint-build로.slint파일을 쓰는 방법을 설명해요. - The Iced book: Architecture (영어): 상태, 메시지, update, view를 친절하게 설명해요.