Skip to main content

Iced 데스크탑 앱 아키텍처 가이드

작성일: 2026-04-08 Last Updated: 2026-04-08 대상: Iced Native Desktop App (Windows / macOS / Linux) 공통 참조: ../common/ (인증, 보안, API 연동, 테스트 등은 공통 가이드 참조)


목차

  1. 아키텍처 개요 (The Elm Architecture)
  2. 프로젝트 구조
  3. 핵심 패턴: Application Trait
  4. 상태 관리 (Message / Update)
  5. UI 구성 (View / Widget)
  6. 비동기 작업 (Command / Subscription)
  7. 테마 및 스타일링
  8. 시스템 통합
  9. Laravel API 연동
  10. 빌드 및 배포

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();
}

9. Laravel API 연동

// services/api_client.rs
use reqwest::Client;
use serde::{Deserialize, Serialize};

#[derive(Clone)]
pub struct ApiClient {
client: Client,
base_url: String,
}

#[derive(Debug, Deserialize)]
pub struct User {
pub id: u64,
pub name: String,
pub email: String,
}

#[derive(Debug, Serialize)]
struct LoginRequest {
email: String,
password: String,
}

#[derive(Debug, Deserialize)]
struct TokenResponse {
access_token: String,
token_type: String,
}

impl ApiClient {
pub fn new() -> Self {
Self {
client: Client::new(),
base_url: "https://api.example.com".to_string(),
}
}

pub async fn login(&self, email: &str, password: &str) -> Result<(User, String), ApiError> {
let resp = self.client
.post(format!("{}/api/auth/login", self.base_url))
.json(&LoginRequest {
email: email.to_string(),
password: password.to_string(),
})
.send()
.await?;

let token: TokenResponse = resp.json().await?;

let user = self.client
.get(format!("{}/api/user", self.base_url))
.bearer_auth(&token.access_token)
.send()
.await?
.json::<User>()
.await?;

Ok((user, token.access_token))
}

pub async fn get_items(&self, token: &str) -> Result<Vec<Item>, ApiError> {
let resp = self.client
.get(format!("{}/api/items", self.base_url))
.bearer_auth(token)
.send()
.await?;

Ok(resp.json().await?)
}
}

10. 빌드 및 배포

크로스 컴파일

# Windows (from Linux/macOS)
cargo build --release --target x86_64-pc-windows-msvc

# macOS
cargo build --release --target x86_64-apple-darwin
cargo build --release --target aarch64-apple-darwin # Apple Silicon

# Linux
cargo build --release --target x86_64-unknown-linux-gnu

패키징 (cargo-bundle)

# Cargo.toml
[package.metadata.bundle]
name = "My App"
identifier = "com.example.myapp"
icon = ["assets/icons/app-icon.png"]
version = "1.0.0"
copyright = "Copyright (c) 2026"
category = "Utility"
cargo install cargo-bundle
cargo bundle --release
OS출력 형식위치
macOS.app 번들target/release/bundle/osx/
Linux.deb 패키지target/release/bundle/deb/
Windows.msi 설치 파일target/release/bundle/msi/

CI/CD (GitHub Actions)

name: Build
on:
push:
tags: ['v*']

jobs:
build:
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- run: cargo build --release
- uses: actions/upload-artifact@v4
with:
name: binary-${{ matrix.os }}
path: target/release/my-app*

Tauri vs Iced 선택 가이드

기준TauriIced
UI 기반WebView (HTML/CSS/JS)네이티브 (wgpu/GPU)
웹 코드 재활용✅ 가능❌ 불가
바이너리 크기~10MB (WebView 의존)~3-5MB (독립)
렌더링 성능웹 수준GPU 가속
OS 의존성WebView2 (Win), WebKit (macOS)없음 (자체 렌더링)
적합한 앱웹 기반 비즈니스 앱시스템 도구, 에디터, 모니터링

필수 Crate

Crate용도
icedUI 프레임워크
reqwestHTTP 클라이언트 (API 연동)
serde, serde_jsonJSON 직렬화
tokio비동기 런타임
chrono시간 처리
keyringOS 키체인 (토큰 저장)
tray-icon시스템 트레이
rfd파일 다이얼로그
global-hotkey글로벌 단축키
open기본 브라우저/앱 실행

체크리스트

  • Cargo.toml에 iced 의존성 추가 (features: tokio)
  • Application trait 구현 (new, title, update, view)
  • Message enum 설계 (화면별 분리)
  • 커스텀 테마 적용
  • API 클라이언트 구현 (reqwest + Bearer 인증)
  • 에러 처리 패턴 통일
  • 시스템 트레이 구현
  • 빌드 스크립트 (아이콘 임베딩)
  • 크로스 플랫폼 빌드 테스트
  • CI/CD 파이프라인 구성

Related: 공통 API 연동 | 공통 인증 | 공통 보안 | 공통 테스트