Iced 데스크탑 앱 아키텍처 가이드
작성일: 2026-04-08 Last Updated: 2026-04-08 대상: Iced Native Desktop App (Windows / macOS / Linux) 공통 참조:
../common/(인증, 보안, API 연동, 테스트 등은 공통 가이드 참조)
목차
- 아키텍처 개요 (The Elm Architecture)
- 프로젝트 구조
- 핵심 패턴: Application Trait
- 상태 관리 (Message / Update)
- UI 구성 (View / Widget)
- 비동기 작업 (Command / Subscription)
- 테마 및 스타일링
- 시스템 통합
- Laravel API 연동
- 빌드 및 배포
1. 아키텍처 개요 (The Elm Architecture)
Iced는 The Elm Architecture (TEA) 를 따릅니다:
┌─────────────────────────────────┐
│ Application │
│ │
│ State ──→ view() ──→ UI │
│ ↑ │ │
│ │ │ 사용자 │
│ │ │ 이벤트 │
│ │ ↓ │
│ update() ←── Message │
│ │ │
│ └── Command (비동기 작업) │
└─────────────────────────────────┘
| 개념 | 역할 | Iced 매핑 |
|---|---|---|
| State | 앱의 전체 상태 | struct App |
| Message | 상태 변경 이벤트 | enum Message |
| Update | 메시지 처리 → 상태 변경 | fn update() |
| View | 상태 → UI 렌더링 | fn view() |
| Command | 비동기 작업 (API, 파일 I/O) | Command<Message> |
| Subscription | 외부 이벤트 스트림 (타이머, WebSocket) | fn subscription() |
TEA의 장점
| 장점 | 설명 |
|---|---|
| 예측 가능 | 상태 변경은 오직 update()에서만 발생 |
| 디버깅 용이 | 모든 변경이 Message로 추적 가능 |
| 테스트 용이 | update()는 순수 함수에 가까움 |
| 동시성 안전 | Rust 소유권 + TEA = 데이터 레이스 불가 |
2. 프로젝트 구조
iced-app/
├── Cargo.toml # 의존성 관리
├── build.rs # 빌드 스크립트 (아이콘 임베딩 등)
├── assets/ # 정적 리소스
│ ├── icons/
│ │ ├── app-icon.png
│ │ └── app-icon.ico
│ └── fonts/
│ └── NotoSansKR.ttf
├── src/
│ ├── main.rs # 진입점 + Application 구현
│ ├── app.rs # App 상태 + Message + update + view
│ ├── message.rs # Message enum 정의
│ ├── style/ # 테마 및 스타일
│ │ ├── mod.rs
│ │ ├── theme.rs
│ │ └── colors.rs
│ ├── views/ # UI 화면별 모듈
│ │ ├── mod.rs
│ │ ├── dashboard.rs
│ │ ├── settings.rs
│ │ └── login.rs
│ ├── widgets/ # 커스텀 위젯
│ │ ├── mod.rs
│ │ └── sidebar.rs
│ ├── services/ # 외부 서비스 연동
│ │ ├── mod.rs
│ │ ├── api_client.rs # Laravel API 클라이언트
│ │ └── auth.rs # 인증 관리
│ └── config.rs # 앱 설정
├── landing/ # Astro 랜딩 페이지
└── → Admin: Laravel IcedAppAdmin 플러그인
3. 핵심 패턴: Application Trait
use iced::{Application, Command, Element, Settings, Theme};
fn main() -> iced::Result {
App::run(Settings {
window: iced::window::Settings {
size: iced::Size::new(1200.0, 800.0),
min_size: Some(iced::Size::new(800.0, 600.0)),
..Default::default()
},
..Default::default()
})
}
struct App {
screen: Screen,
user: Option<User>,
api_client: ApiClient,
theme: Theme,
}
#[derive(Debug, Clone)]
enum Message {
// 네비게이션
NavigateTo(Screen),
// 인증
LoginSubmit { email: String, password: String },
LoginResult(Result<User, ApiError>),
Logout,
// 데이터
DataLoaded(Result<Vec<Item>, ApiError>),
RefreshData,
// UI
ThemeChanged(Theme),
InputChanged(String),
}
impl Application for App {
type Message = Message;
type Theme = Theme;
type Executor = iced::executor::Default;
type Flags = ();
fn new(_flags: ()) -> (Self, Command<Message>) {
let app = App {
screen: Screen::Login,
user: None,
api_client: ApiClient::new(),
theme: Theme::Dark,
};
(app, Command::none())
}
fn title(&self) -> String {
String::from("My App")
}
fn update(&mut self, message: Message) -> Command<Message> {
match message {
Message::LoginSubmit { email, password } => {
let client = self.api_client.clone();
Command::perform(
async move { client.login(&email, &password).await },
Message::LoginResult,
)
}
Message::LoginResult(Ok(user)) => {
self.user = Some(user);
self.screen = Screen::Dashboard;
Command::none()
}
Message::LoginResult(Err(_)) => {
// 에러 처리
Command::none()
}
Message::Logout => {
self.user = None;
self.screen = Screen::Login;
Command::none()
}
Message::NavigateTo(screen) => {
self.screen = screen;
Command::none()
}
_ => Command::none(),
}
}
fn view(&self) -> Element<Message> {
match self.screen {
Screen::Login => self.view_login(),
Screen::Dashboard => self.view_dashboard(),
Screen::Settings => self.view_settings(),
}
}
fn theme(&self) -> Theme {
self.theme.clone()
}
}
4. 상태 관리 (Message / Update)
Message 설계 원칙
// ✅ 좋은 패턴: 화면별 + 기능별 그룹화
#[derive(Debug, Clone)]
enum Message {
// 각 화면의 메시지를 서브 enum으로 분리
Dashboard(DashboardMessage),
Settings(SettingsMessage),
Auth(AuthMessage),
// 글로벌 메시지
NavigateTo(Screen),
ThemeChanged(Theme),
Tick(chrono::DateTime<chrono::Utc>),
}
#[derive(Debug, Clone)]
enum DashboardMessage {
DataLoaded(Result<Vec<Item>, String>),
RefreshClicked,
ItemSelected(usize),
SearchChanged(String),
}
#[derive(Debug, Clone)]
enum AuthMessage {
EmailChanged(String),
PasswordChanged(String),
LoginSubmit,
LoginResult(Result<User, String>),
LogoutClicked,
}
Update 패턴
fn update(&mut self, message: Message) -> Command<Message> {
match message {
Message::Dashboard(msg) => self.update_dashboard(msg),
Message::Settings(msg) => self.update_settings(msg),
Message::Auth(msg) => self.update_auth(msg),
Message::NavigateTo(screen) => {
self.screen = screen;
Command::none()
}
Message::Tick(now) => {
self.last_tick = now;
Command::none()
}
_ => Command::none(),
}
}
5. UI 구성 (View / Widget)
기본 위젯
use iced::widget::{
button, column, container, row, text, text_input,
scrollable, Space, horizontal_rule,
};
fn view_login(&self) -> Element<Message> {
let content = column![
text("Welcome").size(32),
Space::with_height(20),
text_input("Email", &self.email)
.on_input(|s| Message::Auth(AuthMessage::EmailChanged(s)))
.padding(10),
Space::with_height(10),
text_input("Password", &self.password)
.on_input(|s| Message::Auth(AuthMessage::PasswordChanged(s)))
.password()
.padding(10),
Space::with_height(20),
button(text("Login"))
.on_press(Message::Auth(AuthMessage::LoginSubmit))
.padding([10, 30]),
]
.spacing(5)
.max_width(400)
.align_items(iced::Alignment::Center);
container(content)
.width(iced::Length::Fill)
.height(iced::Length::Fill)
.center_x()
.center_y()
.into()
}
레이아웃 패턴: 사이드바 + 메인
fn view_dashboard(&self) -> Element<Message> {
let sidebar = container(
column![
button("Dashboard").on_press(Message::NavigateTo(Screen::Dashboard)),
button("Settings").on_press(Message::NavigateTo(Screen::Settings)),
Space::with_height(iced::Length::Fill),
button("Logout").on_press(Message::Auth(AuthMessage::LogoutClicked)),
]
.spacing(5)
.padding(10)
)
.width(200)
.height(iced::Length::Fill)
.style(iced::theme::Container::Box);
let main_content = container(
self.view_main_content()
)
.width(iced::Length::Fill)
.height(iced::Length::Fill)
.padding(20);
row![sidebar, main_content].into()
}
6. 비동기 작업 (Command / Subscription)
API 호출 (Command)
fn update_dashboard(&mut self, msg: DashboardMessage) -> Command<Message> {
match msg {
DashboardMessage::RefreshClicked => {
self.loading = true;
let client = self.api_client.clone();
let token = self.auth_token.clone();
Command::perform(
async move {
client.get_items(&token).await
.map_err(|e| e.to_string())
},
|result| Message::Dashboard(DashboardMessage::DataLoaded(result)),
)
}
DashboardMessage::DataLoaded(Ok(items)) => {
self.loading = false;
self.items = items;
Command::none()
}
DashboardMessage::DataLoaded(Err(err)) => {
self.loading = false;
self.error = Some(err);
Command::none()
}
_ => Command::none(),
}
}
타이머/이벤트 스트림 (Subscription)
fn subscription(&self) -> iced::Subscription<Message> {
// 30초마다 데이터 갱신
iced::time::every(std::time::Duration::from_secs(30))
.map(|_| Message::Dashboard(DashboardMessage::RefreshClicked))
}
7. 테마 및 스타일링
커스텀 테마
use iced::{Color, Theme};
use iced::theme::Palette;
fn custom_theme() -> Theme {
Theme::custom(
"MyApp".to_string(),
Palette {
background: Color::from_rgb(0.04, 0.04, 0.05), // #09090b
text: Color::from_rgb(0.89, 0.89, 0.90), // #e4e4e7
primary: Color::from_rgb(0.39, 0.40, 0.95), // #6366f1
success: Color::from_rgb(0.06, 0.73, 0.51), // #10b981
danger: Color::from_rgb(0.94, 0.27, 0.27), // #ef4444
},
)
}
위젯 스타일 커스텀
use iced::widget::button;
use iced::theme;
// 커스텀 버튼 스타일
struct PrimaryButton;
impl button::StyleSheet for PrimaryButton {
type Style = Theme;
fn active(&self, _theme: &Theme) -> button::Appearance {
button::Appearance {
background: Some(iced::Background::Color(
Color::from_rgb(0.39, 0.40, 0.95)
)),
text_color: Color::WHITE,
border: iced::Border {
radius: 8.0.into(),
..Default::default()
},
..Default::default()
}
}
fn hovered(&self, theme: &Theme) -> button::Appearance {
let active = self.active(theme);
button::Appearance {
background: Some(iced::Background::Color(
Color::from_rgb(0.31, 0.28, 0.90)
)),
..active
}
}
}
8. 시스템 통합
시스템 트레이 (tray-icon)
// Cargo.toml
// [dependencies]
// tray-icon = "0.14"
use tray_icon::{TrayIconBuilder, menu::Menu};
fn setup_tray() {
let tray_menu = Menu::new();
let _tray = TrayIconBuilder::new()
.with_menu(Box::new(tray_menu))
.with_tooltip("My App")
.build()
.unwrap();
}
파일 다이얼로그 (rfd)
// [dependencies]
// rfd = "0.14"
use rfd::FileDialog;
async fn open_file() -> Option<std::path::PathBuf> {
FileDialog::new()
.add_filter("Documents", &["pdf", "doc", "txt"])
.pick_file()
}
글로벌 단축키
// [dependencies]
// global-hotkey = "0.5"
use global_hotkey::{GlobalHotKeyManager, hotkey::HotKey};
fn register_hotkeys() {
let manager = GlobalHotKeyManager::new().unwrap();
let hotkey = HotKey::new(
Some(global_hotkey::hotkey::Modifiers::CONTROL),
global_hotkey::hotkey::Code::KeyK,
);
manager.register(hotkey).unwrap();
}