본문으로 건너뛰기

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 연동 | 공통 인증 | 공통 보안 | 공통 테스트