Tauri 2.0으로 데스크톱 앱 만들기: 프로젝트 생성, Commands, 플러그인, 멀티 윈도우, 자동 업데이트·배포

이 글의 핵심

Tauri 2.0의 capability 권한 모델을 중심으로 IPC(invoke·이벤트), 플러그인, 저장소 선택, 트레이·사이드카, 멀티 윈도우, updater, 서명과 tauri-action 릴리스, iOS·Android 빌드, 번들 크기 줄이기, OS별 트러블슈팅까지 한 흐름으로 정리합니다. Electron 대신 Tauri를 고를 기준도 함께 다룹니다.

이 글의 핵심

Tauri 2.0은 웹 기술로 UI를 만들고 Rust로 OS·보안·성능에 민감한 부분을 처리하는 데스크톱(및 모바일) 프레임워크입니다. 1.x 대비 가장 큰 변화는 권한(ACL)과 런타임 권한 검사가 설계의 중심이 되었다는 점입니다. 프론트엔드에서 invoke로 부르는 모든 기능은 capability JSON에 허용 목록으로 등록되어야 하며, 플러그인은 식별자 기반 권한 집합으로 노출됩니다.


Tauri 2.0을 선택하는 이유

데스크톱 앱을 웹 스택으로 만드는 선택지는 여럿 있지만, Tauri는 Chromium을 앱에 포함하지 않고 OS가 제공하는 WebView를 쓰기 때문에 설치 파일이 작고, 대체로 메모리 사용량도 적습니다. 코어 로직은 Rust로 작성하므로 메모리 안전성과 동시성 모델을 활용하기 쉽고, 프론트는 팀이 익숙한 TypeScript·CSS로 유지할 수 있습니다.

2.0에서는 “권한은 기본적으로 거부”라는 원칙이 분명해졌습니다. 웹 콘텐츠가 어떤 창·어떤 오리진에서 어떤 API를 호출할 수 있는지를 선언적으로 적습니다. 이렇게 하면 공격 표면이 줄고, 배포 채널별로 다른 capability를 쓰는 구성도 가능해집니다.


Tauri 2.0의 주요 변화와 새 기능

권한·Capability·런타임 권한(ACL)

1.x에서도 allowlist 설정으로 기능을 켜고 끌 수 있었지만, 2.0에서는 permission이 명시적인 식별자를 가집니다. 코어 권한은 core:default, core:window:allow-create처럼 :로 구분된 이름을 쓰고, 플러그인 권한은 크레이트 이름에서 tauri-plugin- 접두사를 뺀 이름으로 시작합니다(tauri-plugin-shell이면 shell:allow-open). capability는 “어떤 창·웹뷰에 어떤 permission 묶음을 줄지”를 정의합니다.

런타임에는 Runtime Authority가 invoke 요청마다 (1) 호출한 창·오리진이 허용된 capability에 속하는지, (2) 해당 permission이 그 명령을 허용하는지를 검사합니다. 스코프가 있는 권한(예: 파일 경로 허용 목록)은 명령 구현에 주입되어 세부 제어에 쓰입니다.

플러그인 중심 구조

파일 시스템, 셸, 알림, HTTP 클라이언트 등 많은 기능이 코어 밖 플러그인으로 분리되었습니다. Cargo 의존성에 tauri-plugin-*를 추가하고 Rust 쪽에서 .plugin(...)으로 등록한 뒤, capability에 해당 플러그인의 allow-* 권한을 넣어야 프론트에서 사용할 수 있습니다. 플러그인을 설치했다고 해서 웹 콘텐츠가 자동으로 그 기능을 쓸 수 있는 구조가 아니라는 뜻입니다.

모바일 타깃과 버전 관리

Tauri 2.0은 2024년 10월에 정식 출시되면서 iOS·Android 빌드를 공식 지원하기 시작했습니다. 그 뒤로 2.x 마이너 버전이 꾸준히 나오고 있고, 마이너 버전 사이에도 플러그인 API가 조금씩 바뀌므로 tauri, tauri-build, @tauri-apps/api, 각 플러그인의 버전을 함께 올리는 것이 안전합니다. Rust 크레이트와 npm 패키지 버전이 어긋나면 빌드는 되는데 런타임에 오류가 나는 경우가 있어, 저는 업그레이드할 때 pnpm tauri info로 양쪽 버전을 먼저 확인합니다.

모바일은 데스크톱만큼 성숙하지는 않습니다. 트레이·전역 단축키·사이드카처럼 데스크톱 전용인 기능이 많고, 푸시 알림이나 인앱 결제 같은 모바일 고유 기능은 직접 플러그인을 작성하거나 커뮤니티 플러그인에 의존해야 합니다. 한 코드베이스로 데스크톱과 모바일을 함께 겨냥한다면, 초기부터 capability를 플랫폼별로 나눠 두는 것이 이후 포팅 비용을 줄입니다.

개발자 경험

설정은 src-tauri/tauri.conf.json에서 빌드·번들·identifier 등을 중심으로 관리합니다. 빌드할 때 src-tauri/gen/schemas/에 권한 스키마가 생성되므로, capability JSON의 $schema를 여기에 연결하면 IDE가 permission 식별자 오타를 잡아 줍니다. 프론트 패키지 @tauri-apps/api는 core, event, window, webviewWindow처럼 역할별 서브 경로로 나뉘어 있어 필요한 모듈만 가져오기 쉽습니다.

Capability JSON의 역할

실제 프로젝트에서는 src-tauri/capabilities/ 아래 JSON이 창·웹뷰 식별자와 permission 배열을 연결합니다. 아래는 구조를 보여 주는 예시이며, $schema 경로와 사용할 permission은 프로젝트에 맞춰야 합니다.

{
  "identifier": "main-capability",
  "description": "메인 창에서 사용하는 권한 묶음",
  "windows": ["main"],
  "permissions": [
    "core:default",
    "core:window:allow-create",
    "shell:allow-open"
  ]
}

여기서 확인할 점은 두 가지입니다. 첫째, 웹 콘텐츠가 속한 창 레이블이 windows에 들어 있어야 합니다. 둘째, permissions 배열에 실제로 호출할 플러그인·코어 명령이 모두 포함되어야 invoke와 플러그인 API가 거절되지 않습니다. 스코프가 필요한 권한은 플러그인 문서에 정의된 scope 형식(허용·거부 경로 등)을 추가로 채웁니다.

1.x에서 옮길 때 확인할 점

설정 키 이름과 위치가 바뀌었으므로 tauri.conf.json을 v2 스키마에 맞게 옮기고, 생성된 스키마로 검증합니다. 공식 CLI의 tauri migrate가 설정과 1.x allowlist를 바탕으로 capability 초안을 만들어 주니, 이것으로 시작한 뒤 실제로 쓰는 권한만 남기는 순서가 빠릅니다. 옮긴 직후 “예전엔 되던 호출”이 거절된다면 대부분 capability 누락입니다. 또 파일 시스템·셸·대화상자 등 일부 API가 플러그인 패키지로 이동했으므로 package.json과 Cargo 양쪽 의존성을 함께 맞춰야 합니다.


프로젝트 생성과 프론트엔드 통합

CLI로 시작하기

공식 권장 흐름은 create-tauri-app입니다. Node가 있다면 다음과 같이 시작합니다(버전에 따라 프롬프트가 조금 다를 수 있습니다).

npm create tauri-app@latest

Rust 툴체인과 플랫폼별 WebView 의존성(Windows의 WebView2, Linux의 WebKitGTK 등)은 공식 사전 요구 문서를 따릅니다.

React·Vue·Svelte와의 관계

Tauri는 특정 프론트엔드 프레임워크에 묶이지 않습니다. 다만 생태계 관례상 Vite와 조합하는 경우가 많습니다.

스택특징Tauri와 맞물리는 지점
React생태계·라이브러리 규모invoke를 훅·서비스 레이어로 감싸기 쉬움
Vue단순한 학습 곡선·SFCComposition API로 백엔드 호출 래핑
Svelte컴파일 타임·가벼운 런타임스토어·runes로 UI 상태와 IPC 분리

UI 상태와 Rust 호출을 한 컴포넌트에 섞으면 테스트와 권한 경계가 흐려집니다. api/tauri.ts 같은 모듈에 invoke를 모아 두고, 컴포넌트는 그 함수만 호출하도록 나누는 편이 좋습니다.

개발 서버와 빌드 산출물

개발 시에는 tauri.conf.json의 build.devUrl(Vite 개발 서버 등)을 Tauri가 로드하고, 프로덕션 빌드에서는 build.frontendDist에 지정된 정적 파일을 묶습니다. 경로가 어긋나면 하얀 화면만 보이거나 asset 404가 납니다. tauri build는 실행 전에 build.beforeBuildCommand(보통 npm run build)를 먼저 돌리므로, CI에서는 이 명령과 frontendDist 경로가 실제 산출물 위치와 맞는지 확인하는 것이 중요합니다.

프레임워크와 무관한 IPC 레이어

프레임워크마다 문법은 다르지만, UI는 표시만 하고 Rust 호출은 한 모듈에 모은다는 규칙은 같습니다.

React에서는 마운트 시 초기화와 비동기 호출을 useEffect·useCallback으로 묶고, 실제 invoke는 services/tauri.ts에 둡니다.

// services/tauri.ts
import { invoke } from '@tauri-apps/api/core';

export async function fetchUserProfile(id: string) {
  return await invoke<{ name: string }>('get_user_profile', { id });
}

Vue에서는 useProfile() 같은 composable이 위 서비스 함수만 호출하게 하고, Svelte에서는 .svelte 파일에서 직접 invoke하기보다 profileStore.ts 등에 비동기 액션을 정의해 스토어로 UI에 반영하면 테스트가 쉬워집니다. Zustand·Pinia 같은 상태 관리 라이브러리를 쓸 때도 Rust 결과를 반영하는 곳을 액션 한 곳으로 제한하면, 권한이 바뀌었을 때 추적할 경로가 줄어듭니다.


IPC와 Commands

IPC가 필요한 이유

웹뷰에서 실행되는 JavaScript는 브라우저 샌드박스 안에 있습니다. 로컬 파일을 마음대로 읽거나 프로세스를 띄울 수 없으므로, Rust 쪽에서 허용한 연산만 노출해야 합니다. 그 다리가 IPC이고, Tauri에서는 주로 invoke로 명령을 호출합니다. Command는 Rust에 #[tauri::command]로 표시된 함수로, 직렬화 가능한 인자·반환 타입을 통해 프론트와 백엔드 사이의 계약을 만듭니다.

Rust: 커맨드 정의

// src-tauri/src/lib.rs
#[tauri::command]
fn greet(name: String) -> String {
    format!("Hello, {}!", name)
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![greet])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

앱이 커지면 커맨드를 모듈별로 나누고 generate_handler!에 나열합니다. 실패할 수 있는 커맨드는 Result를 반환하며, 프론트에서는 invoke가 반환한 Promise의 거부로 처리합니다.

커맨드는 AppHandle, State<T>, WebviewWindow 등을 인자로 받아 DB 풀·설정·캐시에 접근할 수 있습니다. 공유 가변 상태는 Mutex·RwLock으로 감싸되, 락을 쥔 채 오래 걸리는 작업을 하면 다른 커맨드가 막히므로 무거운 작업은 async 커맨드나 별도 스레드로 넘기는 것이 좋습니다. 참고로 async가 아닌 커맨드는 메인 스레드에서 실행되므로, 오래 걸리는 동기 커맨드는 UI를 멈추게 합니다.

프론트: invoke

Tauri 2에서는 @tauri-apps/api/core에서 invoke를 가져옵니다.

import { invoke } from '@tauri-apps/api/core';

async function loadGreeting(name: string): Promise<string> {
  return await invoke<string>('greet', { name });
}

이름 문자열('greet')이 Rust 함수명과 일치해야 하는데, 리팩터링 때 한쪽만 바꾸면 컴파일 오류 없이 런타임에서야 실패합니다. 상수로 묶거나 tauri-specta 같은 타입 생성 도구를 검토할 만합니다.

이벤트 채널

일방향 알림에는 이벤트가 적합합니다. 백엔드에서 상태 변화를 보내고 프론트에서 구독하는 패턴이 흔하며, 장시간 작업이라면 사용자가 중단할 수 있도록 취소 수단도 함께 설계하는 것이 좋습니다.

// Rust → JS: 로그를 한 줄씩 흘려보내기 (v2에서는 Emitter 트레이트를 가져와야 emit 사용 가능)
use tauri::Emitter;

#[tauri::command]
async fn start_log_stream(app: tauri::AppHandle) {
    tauri::async_runtime::spawn(async move {
        for i in 0..100 {
            let _ = app.emit("log", format!("line {i}"));
            tokio::time::sleep(std::time::Duration::from_millis(100)).await;
        }
    });
}
import { listen, emit } from '@tauri-apps/api/event';

const unlisten = await listen<string>('log', (e) => console.log(e.payload));
// React라면 useEffect cleanup에서 unlisten() — 빠뜨리면 화면을 오갈 때마다 리스너가 쌓여 로그가 여러 번 찍힘

await emit('user-clicked', { id: 42 }); // JS → Rust (Rust 쪽은 tauri::Listener의 app.listen으로 받음)

화면을 몇 번 오간 뒤 같은 로그가 두세 번씩 찍히기 시작하면 거의 항상 unlisten 누락입니다. 개발 모드의 React StrictMode는 effect를 두 번 실행하므로 이 문제가 처음부터 드러나는데, “개발 모드만 이상하다”고 넘기면 운영에서도 그대로 누적됩니다.

진행률·스트리밍은 Channel로

전역 이벤트는 기본적으로 모든 창의 리스너에 전달되고 페이로드가 JSON으로 직렬화되므로, 특정 호출 하나의 진행률이나 큰 데이터 조각을 흘려보내기에는 맞지 않습니다. 같은 이벤트 이름을 여러 작업이 공유하면 어느 작업의 진행률인지 구분하는 코드도 따로 필요합니다. Tauri 2는 이런 용도로 Channel을 제공합니다. 프론트에서 채널을 만들어 커맨드 인자로 넘기면, 그 호출 전용 통로가 됩니다.

use tauri::ipc::Channel;

#[derive(Clone, serde::Serialize)]
#[serde(tag = "event", content = "data", rename_all = "camelCase")]
enum Progress {
    Started { total: u64 },
    Chunk { done: u64 },
    Finished,
}

#[tauri::command]
async fn download(url: String, on_progress: Channel<Progress>) -> Result<(), String> {
    on_progress.send(Progress::Started { total: 100 }).map_err(|e| e.to_string())?;
    for done in (10..=100).step_by(10) {
        // 실제로는 다운로드한 바이트 수
        on_progress.send(Progress::Chunk { done }).map_err(|e| e.to_string())?;
    }
    on_progress.send(Progress::Finished).map_err(|e| e.to_string())?;
    Ok(())
}
import { invoke, Channel } from '@tauri-apps/api/core';

type Progress =
  | { event: 'started'; data: { total: number } }
  | { event: 'chunk'; data: { done: number } }
  | { event: 'finished' };

const onProgress = new Channel<Progress>();
onProgress.onmessage = (msg) => {
  if (msg.event === 'chunk') setPercent(msg.data.done);
};
await invoke('download', { url, onProgress });

Rust 인자 이름 on_progress가 JS에서는 onProgress로 바뀐다는 점에 주의하세요. Tauri는 커맨드 인자를 기본적으로 camelCase로 받기 때문에, snake_case 그대로 넘기면 “missing required key onProgress” 오류가 납니다. 이 규칙은 모든 커맨드 인자에 적용되며, Rust 쪽에서 #[tauri::command(rename_all = "snake_case")]로 바꿀 수 있습니다.

에러 타입과 프론트 계약

Rust 쪽에서 Result<T, E>를 쓸 때 E가 serde::Serialize를 구현해야 프론트에서 의미 있는 값을 받을 수 있습니다. thiserror로 공통 에러 열거형을 정의하고, 민감한 내부 정보는 로그에만 남긴 채 UI에는 코드와 사용자용 문구만 넘기는 방식이 안전합니다.

#[derive(Debug, serde::Serialize, thiserror::Error)]
pub enum AppError {
    #[error("not found")]
    NotFound,
    #[error("io: {0}")]
    Io(String),
}

#[tauri::command]
fn load_config() -> Result<String, AppError> {
    // ...
    Ok(String::new())
}

위처럼 derive하면 프론트는 "NotFound" 또는 { "Io": "..." } 형태의 값을 받습니다. invoke가 거부될 때 이 값을 타입 좁히기해 토스트·모달에 매핑합니다. 네트워크가 아닌 로컬 IPC이므로 재시도보다는 입력 검증과 권한 안내가 중심이 됩니다.


플러그인 시스템

왜 플러그인인가

기능을 코어에 모두 넣으면 바이너리 크기와 보안 검토 범위가 커집니다. 플러그인으로 나누면 필요한 것만 의존성과 capability에 넣을 수 있습니다.

추가 절차

  1. Cargo.toml에 tauri-plugin-... 의존성 추가
  2. lib.rs에서 .plugin(tauri_plugin_...::init()) 등록
  3. capabilities/*.json에 해당 플러그인의 권한 추가
  4. 프론트에서 @tauri-apps/plugin-* 패키지 사용

pnpm tauri add <플러그인>을 쓰면 1, 2, 4단계와 기본 권한 추가를 한 번에 처리해 주므로, 손으로 할 때 빠뜨리기 쉬운 단계를 줄일 수 있습니다.

자주 쓰는 플러그인

  • 파일 시스템(fs): 읽기·쓰기·디렉터리 열거 등을 제공합니다. 접근 가능한 경로 스코프를 반드시 제한해야 합니다.
  • 셸(shell): URL·파일을 기본 앱으로 열거나 외부 프로그램을 실행합니다. 임의 명령 실행으로 이어질 수 있으므로 허용할 프로그램과 인자를 capability에서 좁혀야 합니다.
  • HTTP 클라이언트(http): Rust 쪽에서 요청을 보내므로 브라우저 CORS 제약을 받지 않는 대신, 접근 가능한 URL을 capability 스코프로 지정해야 합니다.
  • 업데이터(updater): 자동 업데이트를 담당합니다. 아래 자동 업데이트 절에서 다룹니다.

플러그인마다 permission 식별자가 다르므로, 다른 프로젝트에서 복사해 오기보다 해당 플러그인 문서의 권한 목록을 기준으로 작성하는 것이 안전합니다.

Cargo·Rust 쪽 등록 예시

# src-tauri/Cargo.toml (발췌)
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-shell = "2"
// src-tauri/src/lib.rs (발췌)
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_shell::init())
        .invoke_handler(tauri::generate_handler![greet])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

플러그인을 추가했다면 capability에 해당 권한을 넣고, 프론트 package.json에도 대응하는 @tauri-apps/plugin-* 패키지를 추가했는지 확인합니다. 한쪽만 맞으면 빌드는 되어도 런타임에서 권한 오류가 납니다.


멀티 윈도우

왜 창을 나누는가

설정 창, 미리보기 패널, 도구 창을 별도 웹뷰 창으로 분리하면 UX와 장애 격리에 유리합니다. Tauri 2에서는 창·웹뷰 레이블이 capability의 적용 단위이므로, 창마다 다른 권한 묶음을 줄 수 있습니다. 예를 들어 메인 창에는 파일 쓰기 권한을 주고, 읽기 전용 미리보기 창에는 주지 않는 식입니다.

레이블과 capability

각 창은 label 문자열로 식별됩니다. capability JSON의 windows 배열에 "main", "settings"처럼 레이블을 나열하면 해당 창에만 그 capability가 적용됩니다. windows에는 "editor-*" 같은 glob 패턴도 쓸 수 있으므로, 런타임에 동적으로 만드는 창은 레이블 규칙을 정해 패턴으로 묶어 두면 편합니다.

보조 창 열기

프론트에서는 @tauri-apps/api/webviewWindow의 WebviewWindow로 새 창을 만들 수 있습니다. URL·초기 크기·데코레이션 등을 옵션으로 넘기며, 창을 만드는 창에는 core:webview:allow-create-webview-window 권한이 필요합니다. Rust 쪽에서는 WebviewWindowBuilder로 기본값을 통일해 만드는 방법도 흔합니다.

IPC와 멀티 윈도우

invoke는 어느 창에서 호출하든 같은 핸들러로 들어가지만, 커맨드 인자로 WebviewWindow를 받으면 호출한 창을 구분할 수 있습니다. 이를 이용해 보조 창에서 온 특정 요청을 Rust에서 거절할 수도 있습니다. 다만 권한 모델로 막을 수 있는 것은 capability에 맡기고, 커맨드 내부 검증은 이중 확인 정도로 쓰는 것이 읽기 쉬운 설계입니다.

멀티 윈도우 운영 시 주의점

여러 창이 같은 앱 상태를 보려면 이벤트 브로드캐스트나 Rust State 같은 공유 저장소를 설계해야 합니다. 메모·할 일 앱처럼 인스턴스가 하나만 떠야 하는 경우에는 single-instance 플러그인을 검토합니다. macOS의 타이틀바·풀스크린 동작처럼 플랫폼마다 다른 창 동작은 QA 대상에 포함해야 합니다.


네이티브 기능: 파일 시스템과 시스템 트레이

파일 시스템

파일 접근은 가장 민감한 권한 중 하나입니다. 사용자 홈 전체를 열어 두기보다, 앱 데이터 디렉터리나 사용자가 대화상자로 고른 폴더만 허용하는 스코프를 설계합니다. 백업·캐시 경로를 Rust의 app.path() API(app_data_dir() 등)로 일관되게 구하면 Windows·macOS·Linux의 경로 차이를 신경 쓸 일이 줄어듭니다.

개발 중에는 권한을 넓게 열어 두었다가 프로덕션에서 막히는 경우가 많습니다. 개발 전용 capability는 별도 파일로 두고, 프로덕션 빌드에 포함되지 않도록 관리하는 편이 안전합니다.

시스템 트레이

트레이 아이콘은 백그라운드에서 동작하는 앱에 적합합니다. Tauri에서는 메뉴·이벤트와 연결해 창 표시·숨김을 제어합니다. 창 닫기 버튼을 눌러도 트레이에 남는 동작은 사용자가 “종료했다”고 생각하는 것과 충돌할 수 있으므로, 종료할지 트레이에 남을지를 설정에서 고를 수 있게 하는 것이 좋습니다.

use tauri::{menu::{Menu, MenuItem}, tray::TrayIconBuilder};

// setup 클로저 안에서 실행. 메뉴 항목은 with_id로 id를 직접 지정해야 이벤트에서 비교할 수 있음 (new는 id를 자동 생성)
let quit = MenuItem::with_id(app, "quit", "종료", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&quit])?;
TrayIconBuilder::new()
    .icon(app.default_window_icon().unwrap().clone())
    .menu(&menu)
    .on_menu_event(|app, event| {
        if event.id() == "quit" {
            app.exit(0);
        }
    })
    .build(app)?;

트레이 API를 쓰려면 Cargo의 tauri 의존성에 tray-icon feature를 켜야 합니다.

설정·데이터 저장: store, SQL, stronghold

저장소는 데이터 성격에 따라 플러그인을 고릅니다. 사용자 설정처럼 작은 키-값은 store, 목록·검색이 필요한 데이터는 sql(SQLite), 토큰·비밀번호 같은 비밀은 암호화 저장소인 stronghold가 맞습니다.

pnpm tauri add store
pnpm tauri add sql
cargo add tauri-plugin-sql --features sqlite --manifest-path src-tauri/Cargo.toml
pnpm tauri add stronghold
import { load } from '@tauri-apps/plugin-store';

const store = await load('settings.json');
await store.set('theme', 'dark');
await store.save(); // autoSave 기본 동작은 플러그인 버전에 따라 다르므로 중요한 값은 명시적으로 저장

알림은 notification 플러그인으로 보내며, OS 권한 확인을 먼저 거쳐야 합니다(isPermissionGranted → requestPermission → sendNotification).

사이드카: 외부 바이너리 함께 배포하기

ffmpeg처럼 Rust로 다시 짤 필요 없는 도구는 사이드카로 앱에 넣어 실행합니다. tauri.conf.json의 bundle.externalBin에 경로를 적고, 실제 파일은 binaries/ffmpeg-x86_64-pc-windows-msvc.exe처럼 타깃 트리플을 붙인 이름으로 플랫폼마다 준비합니다. 이 이름 규칙을 모르면 로컬에선 되는데 빌드할 때 “바이너리를 찾을 수 없음” 오류가 납니다. 현재 머신의 트리플은 rustc --print host-tuple(구버전은 rustc -vV의 host: 줄)로 확인합니다.

{ "bundle": { "externalBin": ["binaries/ffmpeg"] } }
use tauri_plugin_shell::ShellExt;

let (_rx, _child) = app.shell().sidecar("ffmpeg")?
    .args(["-i", "input.mp4", "output.mp3"])
    .spawn()?;

사이드카에 넘기는 인자에 사용자 입력이 들어간다면 경로와 옵션을 검증해야 합니다. 셸 플러그인 권한을 넓게 열어 두면 프론트 코드 한 줄로 임의 명령을 실행할 수 있게 되므로, capability에서는 허용할 사이드카와 인자 패턴만 지정합니다.


자동 업데이트

목표와 위협 모델

자동 업데이트는 편의 기능인 동시에 공급망 공격의 통로가 될 수 있습니다. Tauri updater는 서명된 아티팩트를 전제로, 클라이언트가 설정에 넣어 둔 공개키로 서명을 검증한 뒤에만 설치합니다. 서명 검증은 끌 수 없으므로 키를 잃어버리면 기존 사용자에게 더 이상 업데이트를 배포할 수 없다는 점을 기억해야 합니다. 비밀키는 반드시 안전한 곳에 백업합니다.

updater 플러그인과 설정 개요

tauri-plugin-updater를 Cargo와 npm 양쪽에 추가하고, tauri.conf.json에서 업데이트 엔드포인트와 공개키를 설정합니다. 업데이트 아티팩트를 만들려면 bundle.createUpdaterArtifacts를 켜고, 빌드할 때 TAURI_SIGNING_PRIVATE_KEY 환경 변수로 비밀키를 넘깁니다. 엔드포인트가 돌려주는 JSON(또는 정적 latest.json)에는 플랫폼별 버전·URL·서명이 들어 있어야 합니다. 정확한 키 이름과 형식은 공식 updater 문서를 프로젝트에서 쓰는 버전에 맞춰 참조하십시오.

릴리스 채널·롤백

안정(stable)과 베타 채널을 엔드포인트 URL로 분리하면 내부 테스터에게만 베타를 배포할 수 있습니다. Tauri updater는 매번 전체 패키지를 내려받는 방식이므로, 문제가 생긴 버전을 되돌리려면 이전 버전을 더 높은 버전 번호로 다시 배포하거나 엔드포인트가 이전 버전을 가리키게 해야 합니다. 이를 위해 이전 버전 아티팩트를 보존하고, 업데이트가 실패한 사용자를 위한 수동 다운로드 링크를 준비해 두는 것이 좋습니다. 기업 환경에서는 프록시·방화벽 때문에 업데이트 확인 자체가 실패하는 경우가 있으므로 실패 로그를 수집할 수 있게 해 두면 원인 파악이 빨라집니다.


빌드와 배포

로컬 빌드 흐름

tauri build는 beforeBuildCommand로 프론트엔드를 빌드한 뒤 Rust 바이너리를 릴리스 모드로 컴파일하고 번들을 만듭니다. 프론트 산출물 경로가 frontendDist와 일치해야 하며, 모노레포에서는 작업 디렉터리와 상대 경로를 CI에서 명시적으로 고정합니다.

산출물과 타깃

번들 형식은 OS마다 정해져 있습니다. Windows는 WiX 기반 .msi와 NSIS 설치 파일(-setup.exe), macOS는 .app과 .dmg, Linux는 .deb, .rpm, AppImage를 만듭니다. 크로스 컴파일은 도구체인 요구가 까다로워서 대개 각 OS에서 빌드합니다. 릴리스 자산은 GitHub Releases·S3·내부 저장소 등에 올리고, 버전 태그와 체크섬 파일을 함께 배포하는 것이 안전합니다.

코드 서명

Windows에서는 Authenticode 인증서로 서명하며, bundle.windows.certificateThumbprint 또는 signCommand로 빌드 과정에 서명을 넣을 수 있습니다. 서명하지 않은 설치 파일은 SmartScreen 경고가 뜹니다. Tauri 번들러가 직접 만드는 형식은 MSI와 NSIS이므로, MSIX나 Microsoft Store 배포가 필요하면 별도 도구를 써야 합니다.

macOS에서는 Developer ID Application 인증서 서명, Hardened Runtime, Apple 공증(notarization)을 거쳐야 Gatekeeper 경고 없이 실행됩니다. Tauri는 APPLE_CERTIFICATE, APPLE_ID 등 환경 변수가 있으면 빌드 중에 서명과 공증을 수행합니다. Apple의 요구사항은 자주 바뀌므로 현재 공식 문서를 기준으로 파이프라인을 유지해야 합니다.

Linux는 배포 형태마다 검증 모델이 다릅니다. 패키지 저장소로 배포한다면 GPG 키 관리가 핵심이고, AppImage는 별도 서명 도구를 씁니다.

CI/CD 통합

GitHub Actions에서는 cargo·pnpm 캐시, OS별 러너, 서명용 시크릿을 분리합니다. 태그 푸시 때만 서명 단계를 타게 하면 PR 빌드 비용과 키 노출 위험을 줄일 수 있습니다.

크로스 컴파일 대신 OS별 러너에서 각각 빌드하는 것이 가장 덜 고생하는 방법입니다. 공식 tauri-action이 빌드·서명·GitHub Release 업로드(업데이터용 메타데이터 포함)를 한 번에 처리합니다.

name: release
on:
  push:
    tags: ['v*']
permissions: { contents: write }
jobs:
  build:
    strategy:
      matrix:
        os: [macos-latest, windows-latest, ubuntu-22.04]
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20 }
      - uses: dtolnay/rust-toolchain@stable
      - uses: pnpm/action-setup@v4
        with: { version: 9 }
      - if: matrix.os == 'ubuntu-22.04'
        run: sudo apt-get update && sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
      - run: pnpm install
      - uses: tauri-apps/tauri-action@v0
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
          TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
        with:
          tagName: ${{ github.ref_name }}
          releaseName: 'Release ${{ github.ref_name }}'
          releaseDraft: true

releaseDraft: true로 두면 세 OS 빌드가 모두 끝난 뒤 사람이 확인하고 공개할 수 있어, 한 OS만 실패한 반쪽 릴리스가 사용자에게 업데이트로 배포되는 사고를 막을 수 있습니다. macos-latest는 Apple Silicon 러너이므로 Intel Mac용 빌드가 필요하면 --target x86_64-apple-darwin이나 universal 빌드를 따로 추가해야 합니다.

모바일 타깃 (iOS·Android)

Tauri 2부터 같은 프로젝트에서 모바일 빌드가 가능합니다. 초기화하면 네이티브 프로젝트가 src-tauri/gen/apple, src-tauri/gen/android에 생성됩니다.

pnpm tauri ios init && pnpm tauri ios dev          # macOS + Xcode 필요, 실기기는 Apple Developer 계정 필요
pnpm tauri android init && pnpm tauri android dev  # Android SDK·NDK 경로 설정 필요

데스크톱 전용 기능(트레이, 전역 단축키, 사이드카)은 모바일에서 동작하지 않으므로 @tauri-apps/plugin-os의 platform()으로 분기하고, capability의 platforms 필드로 데스크톱용과 모바일용을 나눠 두는 편이 깔끔합니다.

번들 크기 줄이기

Tauri 앱이 생각보다 크게 나오면 대부분 Rust 릴리스 프로파일이 기본값이거나 프론트 번들이 큰 경우입니다.

# src-tauri/Cargo.toml
[profile.release]
codegen-units = 1
lto = true
opt-level = "s"   # 크기 우선
panic = "abort"
strip = true

panic = "abort"는 패닉 시 스택 풀기 없이 바로 종료하므로, 패닉을 catch_unwind로 잡는 코드가 있다면 쓰면 안 됩니다. 그다음 프론트 번들을 분석(vite-bundle-visualizer 등)하고, 쓰지 않는 플러그인을 제거합니다. macOS에서 Intel·Apple Silicon을 하나로 배포하려면 --target universal-apple-darwin으로 빌드하되, 두 아키텍처의 바이너리가 함께 들어가므로 실행 파일 크기가 약 두 배가 됩니다.


실전: 생산성 앱 개발 패턴

메모, 할 일, 로컬 지식베이스, 클립보드 매니저 같은 생산성 앱은 Tauri와 잘 맞습니다. UI는 웹 스택으로 빠르게 반복하고, 데이터·동기화·OS 통합은 Rust에서 제어합니다.

데이터 계층

SQLite는 단일 파일에 트랜잭션을 지원해 로컬 우선 앱에 적합합니다. sql 플러그인으로 프론트에서 직접 쿼리할 수도 있지만, 스키마 마이그레이션과 비즈니스 규칙을 한곳에 두려면 Rust에서 sqlx나 rusqlite로 관리하고 커맨드만 노출하는 편이 낫습니다. 민감 데이터는 OS 키체인(자격 증명 저장소)과 연계하거나, 사용자 비밀번호에서 파생한 키로 암호화하는 방법을 검토합니다. 어느 쪽이 맞는지는 위협 모델에 따라 달라집니다.

OS 통합

앱이 백그라운드에 있어도 빠른 캡처·검색을 제공하려면 global-shortcut 플러그인과 트레이를 함께 설계합니다. 마감이나 동기화 완료를 OS 알림으로 알릴 때는 방해 금지 모드와 권한 요청 시점을 고려해야 합니다. 클립보드 매니저는 비밀번호 같은 개인정보가 그대로 저장될 수 있으므로, 클립보드 권한을 꼭 필요한 창에만 주고 내용을 로그에 남기지 않아야 합니다.

동기화와 오프라인

클라우드 동기화를 붙일 때는 충돌 해결 정책(마지막 쓰기 우선, CRDT, 수동 병합)을 먼저 정합니다. 오프라인 우선이라면 로컬 큐에 변경을 쌓아 두었다가 네트워크가 복구되면 재전송하는 방식이 안정적입니다.

UX와 성능

WebView는 OS마다 렌더링 특성이 다르므로 대용량 리스트는 가상 스크롤로 처리하고, 무거운 파싱·해시·압축은 Rust로 넘깁니다. 시작 시간이 중요하다면 스플래시 창과 지연 로딩을 쓰고, 실제로 어디서 시간이 걸리는지 측정한 뒤 손을 댑니다.

보안 기본기

원격 URL을 앱 창에 로드하지 않거나, 꼭 필요하면 capability의 remote 설정으로 허용할 도메인을 좁힙니다. 딥링크·커스텀 스킴으로 들어오는 값은 사용자 입력으로 취급해 검증합니다. 서드파티 임베드 콘텐츠는 최소화하고, tauri.conf.json의 app.security.csp로 콘텐츠 보안 정책을 설정합니다.


Electron vs Tauri 비교

관점ElectronTauri 2.0
렌더링Chromium 포함OS WebView
백엔드 런타임Node.jsRust
번들 크기상대적으로 큼상대적으로 작음
메모리앱마다 Chromium 프로세스일반적으로 더 적음
네이티브 연동Node 모듈·N-API 등Rust 크레이트·FFI
보안 모델Node 권한 경계를 직접 관리capability로 명시적 제한
팀 스킬JS/TS 중심Rust 학습 필요

Electron이 유리한 경우는 특정 Electron 전용 라이브러리나 Node 네이티브 확장에 깊게 의존하거나, Chromium 전용 기능 때문에 OS WebView별 차이를 감당하기 어려운 경우입니다. Tauri는 작은 설치 파일, 적은 메모리, Rust로 하는 시스템 연동, 명시적 권한이 중요한 앱에 맞습니다.

운영 관점에서의 차이

Electron 앱은 Chromium 프로세스 모델을 그대로 안고 가므로, 메모리 프로파일링이나 GPU 문제를 브라우저 디버깅과 비슷하게 다룹니다. 반면 Tauri는 WebView와 Rust로 나뉘어, 병목이 프론트인지 네이티브인지 구분하기 쉽습니다. 다만 WebView는 OS 버전별로 차이가 있어서 CSS·API 호환을 브라우저 한 종류로 가정할 수 없습니다.

Tauri를 쓰면서 가장 과소평가하기 쉬운 비용이 이 부분입니다. Windows는 Chromium 기반 WebView2라 크롬에서 개발한 그대로 동작하지만, macOS는 WKWebView(사파리 엔진), 리눅스는 WebKitGTK입니다. 크롬에서만 확인하고 배포했다가 macOS에서 특정 CSS 속성이나 최신 JS API가 동작하지 않는다는 제보를 받는 것은 Tauri 프로젝트에서 흔한 첫 번째 사고입니다. 특히 macOS의 WebView 버전은 앱이 아니라 사용자의 OS(사파리) 버전에 묶여 있어서, 오래된 macOS를 쓰는 사용자는 오래된 엔진으로 앱을 봅니다. 리눅스 WebKitGTK는 대용량 리스트 스크롤이나 CSS 필터·애니메이션 성능이 다른 두 플랫폼보다 떨어지는 경우가 많습니다. 저라면 개발 초기부터 CI나 실기기에서 세 엔진을 모두 확인하고, Vite의 build.target을 지원할 가장 오래된 사파리 버전에 맞춰 둡니다.

보안·컴플라이언스

Electron에서 Node와 웹 콘텐츠의 경계를 잘못 세우면 XSS가 원격 코드 실행으로 이어질 수 있습니다. Tauri에서도 웹 콘텐츠는 여전히 공격 대상이지만, 기본 거부 ACL 덕분에 XSS가 일어나도 공격자가 쓸 수 있는 기능이 capability에 허용된 범위로 제한됩니다. 규제 산업이라면 감사 로그를 Rust 측에 남길지, 원격 콘텐츠 로딩을 금지할지를 아키텍처 초기에 결정하는 것이 좋습니다.


트러블슈팅: 자주 보는 증상과 원인

증상흔한 원인점검 순서
invoke가 권한 오류로 실패capability에 커맨드·플러그인 권한 누락해당 창 레이블·permission 식별자·스코프
보조 창에서만 API 실패해당 창 레이블이 capability windows에 없음멀티 윈도우 capability 분리
빌드 산출물은 있는데 화면이 비어 있음frontendDist 경로 오류·base URL 불일치tauri.conf.json의 프론트 설정과 CI 산출물 경로
개발 모드만 되고 패키지에서 실패개발 전용 capability에만 권한이 있음dev/prod capability 분리 여부
업데이트가 항상 실패엔드포인트·서명·버전 메타 불일치HTTPS, 공개키, 릴리스 JSON
특정 OS에서만 WebView 오류WebView 런타임 미설치·버전 부족Windows WebView2, Linux 배포판별 요구사항
Linux(특히 NVIDIA GPU)에서 창이 하얗거나 비어 있음WebKitGTK의 DMA-BUF 렌더러와 드라이버 충돌WEBKIT_DISABLE_DMABUF_RENDERER=1로 실행해 재현 여부 확인, 확인되면 앱 시작 시 환경 변수 설정 검토
Linux 빌드가 webkit 관련으로 실패Tauri 2는 libwebkit2gtk-4.1 필요(4.0 아님)Ubuntu 22.04 이상, libwebkit2gtk-4.1-dev 설치
오프라인 PC에서 설치 후 실행 불가(Windows)기본 설치 모드가 WebView2를 인터넷에서 내려받음bundle.windows.webviewInstallMode를 offlineInstaller 또는 fixedRuntime으로
큰 데이터 전달이 느림JSON 직렬화 IPC로 대용량 전송스트리밍은 Channel API, 대용량 파일은 경로만 넘기고 Rust에서 직접 처리
macOS 서명·공증 실패Developer ID 인증서·공증 자격 누락인증서 종류(Developer ID Application), CI 시크릿

로그는 Rust 쪽 log 플러그인이나 tracing과 프론트 콘솔을 함께 보면, IPC 단에서 끊기는지 UI 단에서 끊기는지 빠르게 가려집니다.


같이 보면 좋은 글