Tauri 데스크톱 앱 개발: Rust 백엔드 호출, 빌드·배포, Electron과 비교
이 글의 핵심
Tauri는 Electron처럼 웹 기술로 데스크톱 앱을 만들지만, Chromium과 Node.js를 앱에 넣지 않고 OS가 제공하는 WebView와 Rust 백엔드를 씁니다. 그래서 설치 파일이 작고 기본 권한이 좁은 대신, OS마다 렌더링 엔진이 다르다는 부담이 생깁니다. 이 구조와 실제 개발 흐름을 정리합니다.
Electron으로 만든 데스크톱 앱은 Chromium과 Node.js 런타임을 통째로 담기 때문에 간단한 도구도 설치 파일이 커지기 쉽습니다. Tauri는 운영체제에 이미 있는 WebView를 쓰고 백엔드를 Rust로 작성해 이 부담을 줄이는 프레임워크로, UI는 React·Vue·Svelte 같은 웹 기술로 그대로 만듭니다. 이 글은 프로젝트 생성과 권한 설정, 파일·알림·윈도우 API, invoke()로 Rust 함수를 호출하는 방법, 탭과 자동 저장이 있는 메모장 예제, 빌드·배포 시 주의점, Electron과의 차이를 차례로 다룹니다.
Tauri란?
Tauri는 2019년 시작된 오픈소스 프로젝트로, Rust + Web 기술로 네이티브 데스크톱 앱을 만드는 프레임워크입니다. 2022년에 1.0이, 2024년 10월에 모바일 지원과 새 권한 시스템을 담은 2.0이 나왔습니다.
⚠️ 버전 안내: 이 글의 API 예제(@tauri-apps/api/fs, @tauri-apps/api/dialog, tauri.conf.json의 allowlist)는 Tauri v1 기준입니다. 2024년 나온 Tauri v2는 각 API가 @tauri-apps/plugin-fs, @tauri-apps/plugin-dialog처럼 별도 플러그인 패키지로 분리됐고, allowlist 방식 권한 설정은 src-tauri/capabilities/*.json 기반 capability 시스템으로 완전히 바뀌었습니다. npm create tauri-app@latest로 새로 시작하면 기본이 v2이므로, 이 글의 코드를 그대로 복붙하면 import 경로부터 에러가 납니다 — 개념(파일 시스템 접근, Rust #[tauri::command] 호출 방식)은 그대로지만, 정확한 패키지명·설정 문법은 공식 마이그레이션 가이드로 다시 확인하는 게 안전합니다.
이 글의 코드를 v2 프로젝트에 옮길 때 가장 자주 바뀌는 부분만 정리하면 다음과 같습니다.
| 용도 | v1 (이 글의 코드) | v2 |
|---|---|---|
| Rust 커맨드 호출 | import { invoke } from '@tauri-apps/api/tauri' | import { invoke } from '@tauri-apps/api/core' |
| 파일 읽기·쓰기 | @tauri-apps/api/fs | @tauri-apps/plugin-fs + Rust 쪽 tauri_plugin_fs::init() 등록 |
| 대화상자 | @tauri-apps/api/dialog | @tauri-apps/plugin-dialog |
| 현재 창 | appWindow | getCurrentWindow() |
| 권한 설정 | tauri.conf.json의 allowlist | src-tauri/capabilities/*.json의 permission 목록 |
v2에서 플러그인 패키지를 npm으로만 설치하고 Rust 쪽 Builder에 .plugin(...) 등록을 빠뜨리면, 빌드는 되지만 호출 시점에 플러그인을 찾을 수 없다는 런타임 에러가 납니다. 반대로 등록은 했는데 capability 파일에 권한을 넣지 않으면 “not allowed” 계열의 권한 거부 에러가 납니다. v2로 옮긴 직후 파일 API가 동작하지 않는다면 이 두 곳을 먼저 확인하는 것이 좋습니다.
핵심 특징
브라우저 엔진을 싣지 않는 구조
Electron 앱은 설치 파일 안에 Chromium 렌더러와 Node.js 런타임이 함께 들어갑니다. 앱이 “Hello World” 한 줄이어도 브라우저 하나를 통째로 배포하는 셈이라, 설치 파일 크기의 대부분이 앱 코드가 아니라 엔진입니다. Tauri는 이 부분을 OS에 맡깁니다. Windows에서는 WebView2, macOS에서는 WKWebView, Linux에서는 WebKitGTK가 화면을 그리고, 앱이 직접 배포하는 것은 Rust로 컴파일된 바이너리와 프론트엔드 정적 파일뿐입니다. 그래서 설치 파일이 보통 훨씬 작습니다.
메모리 쪽은 조금 더 조심해서 말해야 합니다. Node.js 런타임이 없으니 그만큼은 확실히 줄지만, WebView도 결국 브라우저 엔진이라 화면이 무거우면 메모리를 씁니다. “Tauri는 항상 몇 분의 일”이라는 식의 수치는 앱마다 달라서 그대로 믿기 어렵습니다. 메모리는 시스템 WebView 프로세스(Windows의 WebView2 등)까지 합쳐서 봐야 공정한 비교가 되므로, 같은 UI를 두 프레임워크로 빌드해 작업 관리자에서 관련 프로세스를 모두 합산해 보는 것이 가장 정확합니다.
크로스플랫폼
- Windows (WebView2)
- macOS (WKWebView)
- Linux (WebKitGTK)
- iOS/Android (v2부터 정식 지원)
“크로스플랫폼”의 의미가 Electron과 조금 다르다는 점은 처음부터 알고 시작하는 것이 좋습니다. Electron은 모든 OS에서 같은 Chromium 버전을 싣고 가므로 한 곳에서 확인한 화면이 다른 OS에서도 거의 같습니다. Tauri는 OS마다 엔진이 다르고, 특히 macOS의 WKWebView는 사용자의 macOS 버전에 묶인 Safari 엔진 버전을 따릅니다. 오래된 macOS를 쓰는 사용자에게는 최신 CSS 기능이나 Web API가 없을 수 있어서, 지원할 최소 OS 버전을 정하고 그 버전의 Safari가 지원하는 기능 범위 안에서 프런트엔드를 작성해야 합니다. Linux의 WebKitGTK는 배포판 패키지 버전에 따라 동작 차이가 더 큽니다.
좁은 기본 권한
// 모든 API는 명시적 허용 필요 (v1 tauri.conf.json, 설명용 주석 포함)
{
"tauri": {
"allowlist": {
"fs": {
"readFile": true,
"writeFile": false // 명시적 거부
}
}
}
}
Electron이 “기본적으로 Node.js 전체 API에 접근 가능한 프런트엔드”에서 시작해 보안 설정을 나중에 덧붙이는 모델이라면, Tauri는 정반대로 기본적으로 아무 권한도 없는 상태에서 시작해 필요한 API만 하나씩 명시적으로 허용합니다. 위 예제에서 readFile: true인데 writeFile: false인 것처럼, “이 앱은 파일을 읽을 수는 있지만 쓸 수는 없다”를 설정 파일 한 줄로 강제할 수 있다는 게 실질적인 차이입니다 — 프런트엔드 코드에 악성 의존성이 하나 섞여 들어와도, 애초에 허용되지 않은 API는 호출 자체가 거부되므로 피해 범위가 원천적으로 제한됩니다.
다만 이 권한 모델이 보호하는 범위는 Tauri가 제공하는 내장 API까지입니다. #[tauri::command]로 직접 만든 Rust 함수 안에서 std::fs::write를 호출하는 것은 allowlist와 무관하게 실행됩니다. 아래 메모장 예제의 auto_save처럼 경로를 인자로 받아 파일을 쓰는 커맨드를 만들면, 프런트엔드에 주입된 스크립트가 그 커맨드를 통해 임의 경로에 파일을 쓸 수 있는 통로가 됩니다. 커스텀 커맨드에서는 경로가 허용된 디렉터리 안에 있는지 Rust 쪽에서 직접 검증해야 합니다.
웹 기술 사용
- React, Vue, Svelte, Angular 모두 지원
- HTML/CSS/JavaScript로 UI 구성
- Rust로 백엔드 로직 작성
Tauri 시작하기
사전 요구사항
Windows
# Microsoft C++ Build Tools 설치 ("C++를 사용한 데스크톱 개발" 워크로드)
# https://visualstudio.microsoft.com/downloads/
# Rust 설치: https://rustup.rs 에서 rustup-init.exe 실행
# WebView2 (Windows 11 기본 포함, Windows 10은 대부분 업데이트로 설치됨)
macOS
# Xcode Command Line Tools 설치
xcode-select --install
# Rust 설치
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
Linux (Ubuntu/Debian)
sudo apt update
sudo apt install libwebkit2gtk-4.0-dev \
build-essential \
curl \
wget \
libssl-dev \
libgtk-3-dev \
libayatana-appindicator3-dev \
librsvg2-dev
# Rust 설치
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
위 Linux 패키지 목록은 v1 기준입니다. v2는 libwebkit2gtk-4.1-dev를 요구하므로, v2 프로젝트를 빌드하다 webkit2gtk-4.1 pkg-config를 찾을 수 없다는 에러가 나면 이 패키지 이름을 먼저 확인합니다. Ubuntu 24.04 같은 최신 배포판은 반대로 4.0 패키지를 더 이상 제공하지 않아 v1 프로젝트 빌드가 막히는 경우가 있습니다. 설치 직후 첫 빌드가 오래 걸리는 것은 정상입니다. Rust 의존성 수백 개를 처음 컴파일하기 때문이며, 이후에는 증분 빌드로 훨씬 빨라집니다.
프로젝트 생성
# Tauri CLI 전역 설치 (선택 사항: 템플릿이 프로젝트 로컬 devDependency로 CLI를 넣어 줌)
npm install -g @tauri-apps/cli
# 새 프로젝트 생성 (React 템플릿)
npm create tauri-app@latest
# 프로젝트 이름: my-tauri-app
# 프론트엔드: React
# TypeScript: Yes
# Package Manager: npm
cd my-tauri-app
npm install
프로젝트 구조
my-tauri-app/
├── src/ # React 프론트엔드
│ ├── App.tsx
│ └── main.tsx
├── src-tauri/ # Rust 백엔드
│ ├── src/
│ │ └── main.rs # Rust 진입점
│ ├── tauri.conf.json # Tauri 설정
│ └── Cargo.toml # Rust 의존성
└── package.json
src/와 src-tauri/는 서로 다른 빌드 시스템(Vite 등 번들러와 Cargo)으로 빌드됩니다. 개발 모드에서는 WebView가 Vite 개발 서버(devPath, v2는 devUrl)에 접속하고, 릴리스 빌드에서는 번들러가 만든 정적 파일(distDir, v2는 frontendDist)이 바이너리에 포함됩니다. 개발 모드에서는 잘 되는데 빌드한 앱이 흰 화면만 띄운다면, 이 경로 설정이 번들러의 출력 폴더와 맞는지부터 확인하는 것이 순서입니다.
개발 서버 실행
# 개발 모드 (핫 리로드)
npm run tauri dev
# 빌드 (배포용)
npm run tauri build
Tauri API 사용하기
파일 시스템 읽기
프론트엔드 (React)
// src/App.tsx
import { open } from '@tauri-apps/api/dialog';
import { readTextFile } from '@tauri-apps/api/fs';
import { useState } from 'react';
function App() {
const [content, setContent] = useState('');
const handleOpenFile = async () => {
try {
// 파일 선택 대화상자
const selected = await open({
multiple: false,
filters: [{
name: 'Text',
extensions: ['txt', 'md']
}]
});
if (selected) {
// 파일 읽기
const text = await readTextFile(selected as string);
setContent(text);
}
} catch (error) {
console.error(error);
}
};
return (
<div>
<button onClick={handleOpenFile}>Open File</button>
<pre>{content}</pre>
</div>
);
}
export default App;
권한 설정 (src-tauri/tauri.conf.json)
{
"tauri": {
"allowlist": {
"dialog": {
"open": true
},
"fs": {
"readFile": true,
"scope": ["**"]
}
}
}
}
예제는 간단히 하려고 "scope": ["**"]를 썼지만, 이 설정은 앱이 읽을 수 있는 경로를 파일 시스템 전체로 여는 것이어서 앞서 말한 “좁은 기본 권한”의 이점을 스스로 없앱니다. 실제 앱에서는 "$DOCUMENT/**", "$APPDATA/**"처럼 경로 변수로 범위를 좁히는 것이 좋습니다. v1에서는 사용자가 대화상자로 직접 고른 파일이 자동으로 fs 스코프에 추가되므로, 스코프를 좁혀도 “열기 대화상자로 고른 파일 읽기” 흐름은 그대로 동작합니다. 스코프 밖의 경로를 읽으려 하면 path not allowed on the configured scope 류의 에러가 catch로 들어옵니다.
파일 저장
import { save } from '@tauri-apps/api/dialog';
import { writeTextFile } from '@tauri-apps/api/fs';
const handleSaveFile = async () => {
try {
const filePath = await save({
filters: [{
name: 'Text',
extensions: ['txt']
}]
});
if (filePath) {
await writeTextFile(filePath, content);
console.log('File saved!');
}
} catch (error) {
console.error(error);
}
};
시스템 알림
import { sendNotification } from '@tauri-apps/api/notification';
const handleNotify = async () => {
await sendNotification({
title: 'Tauri App',
body: 'Hello from Tauri!',
});
};
윈도우 제어
import { appWindow } from '@tauri-apps/api/window';
// 윈도우 최소화
await appWindow.minimize();
// 윈도우 최대화
await appWindow.maximize();
// 윈도우 닫기
await appWindow.close();
// 윈도우 숨기기
await appWindow.hide();
// 윈도우 크기 변경
await appWindow.setSize({ width: 800, height: 600 });
// 윈도우 타이틀 변경
await appWindow.setTitle('My App');
Rust 백엔드 함수 호출
invoke()로 Rust 함수를 부르는 것과 일반 JavaScript 함수를 부르는 것의 결정적인 차이는, 이게 실제로는 프로세스 경계를 넘는 IPC(프로세스 간 통신) 호출이라는 점입니다. 인자와 반환값은 매번 직렬화/역직렬화를 거치므로, 초당 수백~수천 번씩 호출해야 하는 실시간 로직(예: 마우스 움직임마다 좌표 계산)을 전부 Rust로 넘기면 오히려 오버헤드가 성능을 깎아먹을 수 있습니다. “무거운 계산만 Rust로, 자잘하고 빈번한 로직은 프런트엔드에” 정도로 경계를 나누는 게 실무에서 무난합니다.
Rust 함수 정의
// src-tauri/src/main.rs
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
// 프론트엔드에서 호출할 함수
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)
}
#[tauri::command]
fn add_numbers(a: i32, b: i32) -> i32 {
a + b
}
#[tauri::command]
async fn fetch_data(url: String) -> Result<String, String> {
// HTTP 요청 (예시)
match reqwest::get(&url).await {
Ok(response) => match response.text().await {
Ok(body) => Ok(body),
Err(e) => Err(e.to_string()),
},
Err(e) => Err(e.to_string()),
}
}
fn main() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![
greet,
add_numbers,
fetch_data
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
이 코드에서 짚을 부분이 세 가지 있습니다. 첫째, greet와 add_numbers처럼 async가 아닌 커맨드는 메인 스레드에서 실행됩니다. 메인 스레드는 창의 이벤트 루프를 돌리는 스레드이므로, 여기서 큰 파일을 처리하거나 네트워크를 기다리면 그동안 창이 응답하지 않습니다. 시간이 걸릴 수 있는 커맨드는 fetch_data처럼 async fn으로 만들거나 #[tauri::command(async)]를 붙여 별도 스레드 풀에서 돌게 해야 합니다.
둘째, 커맨드가 Result<T, E>를 반환하면 Err는 프런트엔드에서 invoke()가 반환한 Promise의 거부(reject)로 전달됩니다. E는 serde::Serialize를 구현해야 하므로 예제처럼 String으로 바꾸는 것이 가장 간단하고, 에러 종류를 구분하고 싶다면 직렬화 가능한 enum을 정의합니다. 커맨드 안에서 unwrap()이 panic을 일으키면 Promise가 영영 끝나지 않거나 앱이 종료될 수 있으므로, 커맨드 경계에서는 ?와 map_err로 에러를 값으로 돌려주는 편이 안전합니다.
셋째, fetch_data의 reqwest는 Tauri에 포함된 것이 아니라 src-tauri/Cargo.toml에 직접 추가해야 하는 크레이트입니다. 또 async 커맨드에서 &str 같은 빌린 인자를 쓰면 라이프타임 제약 때문에 컴파일 에러를 만나기 쉬워서, 예제처럼 String으로 소유권을 받는 형태가 무난합니다.
프론트엔드에서 호출
// src/App.tsx
import { invoke } from '@tauri-apps/api/tauri';
import { useState } from 'react';
function App() {
const [result, setResult] = useState('');
const handleGreet = async () => {
const message = await invoke<string>('greet', { name: 'Alice' });
setResult(message);
};
const handleAdd = async () => {
const sum = await invoke<number>('add_numbers', { a: 10, b: 20 });
setResult(`Sum: ${sum}`);
};
const handleFetch = async () => {
try {
const data = await invoke<string>('fetch_data', {
url: 'https://api.github.com/users/tauri-apps'
});
setResult(data);
} catch (error) {
setResult(`Error: ${error}`);
}
};
return (
<div>
<button onClick={handleGreet}>Greet</button>
<button onClick={handleAdd}>Add</button>
<button onClick={handleFetch}>Fetch</button>
<div>{result}</div>
</div>
);
}
invoke의 두 번째 인자는 키 이름으로 Rust 매개변수와 짝지어집니다. 이때 Tauri는 Rust의 snake_case 매개변수 이름을 JavaScript 쪽에서는 camelCase로 기대합니다. Rust에서 fn open_doc(file_path: String)으로 선언했다면 프런트엔드에서는 invoke('open_doc', { filePath })로 넘겨야 하고, { file_path }로 넘기면 필수 키 filePath가 없다는 에러가 납니다. 처음 Tauri를 쓸 때 가장 흔히 막히는 지점 중 하나이며, #[tauri::command(rename_all = "snake_case")]로 규칙을 바꿀 수도 있습니다. 또 invoke<string>의 제네릭은 타입 단언일 뿐 런타임 검증이 아니므로, Rust 쪽 반환 타입을 바꾸면 TypeScript는 에러 없이 컴파일되고 화면에서만 이상한 값이 나옵니다.
실전 프로젝트: 메모장 앱
기능 명세
- 파일 열기/저장
- 자동 저장
- 여러 파일을 오가는 탭
Rust 백엔드 (자동 저장)
// src-tauri/src/main.rs
use std::fs;
#[tauri::command]
fn auto_save(path: String, content: String) -> Result<(), String> {
fs::write(&path, content)
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
fn get_recent_files() -> Vec<String> {
// 최근 파일 목록 (실제로는 DB나 파일에 저장)
vec![
"/path/to/file1.txt".to_string(),
"/path/to/file2.txt".to_string(),
]
}
auto_save는 예제를 짧게 하려고 받은 경로에 그대로 쓰지만, 앞서 말했듯 이 커맨드는 fs 스코프의 보호를 받지 않습니다. 실제 앱이라면 사용자가 대화상자로 연 경로 목록을 Rust 쪽 상태(tauri::State)에 기록해 두고, 그 목록에 있는 경로만 쓰도록 검사하는 것이 좋습니다. 또 fs::write는 파일을 잘라낸 뒤 새 내용을 쓰므로, 쓰는 도중 앱이 죽거나 전원이 나가면 파일이 비거나 절반만 남을 수 있습니다. 자동 저장처럼 자주 반복되는 쓰기는 같은 디렉터리의 임시 파일에 먼저 쓰고 fs::rename으로 교체하는 방식이 원자적이라 안전합니다.
React 프론트엔드
// src/App.tsx
import { useState, useEffect, useRef } from 'react';
import { open, save } from '@tauri-apps/api/dialog';
import { readTextFile, writeTextFile } from '@tauri-apps/api/fs';
import { invoke } from '@tauri-apps/api/tauri';
interface Tab {
id: string;
path: string | null;
content: string;
saved: boolean;
}
function App() {
const [tabs, setTabs] = useState<Tab[]>([
{ id: '1', path: null, content: '', saved: true }
]);
const [activeTab, setActiveTab] = useState('1');
// ⚠️ 흔한 실수: 이 useEffect의 deps에 tabs를 넣으면, 사용자가
// 계속 타이핑할 때마다 tabs가 바뀌어 setInterval이 매번 초기화됩니다.
// 2초 안에 다음 키 입력이 들어오면 타이머가 2초를 채우기 전에 다시
// clearInterval → setInterval이 반복되어, 실제로는 "타이핑을 멈춘
// 뒤에야" 자동 저장이 실행됩니다 — "2초마다 저장"이라는 원래 의도와
// 다르게 동작합니다. ref로 최신 값을 참조하면 interval을 한 번만
// 만들고도 최신 tabs/activeTab을 읽을 수 있습니다.
const tabsRef = useRef(tabs);
const activeTabRef = useRef(activeTab);
tabsRef.current = tabs;
activeTabRef.current = activeTab;
useEffect(() => {
const interval = setInterval(async () => {
const tab = tabsRef.current.find(t => t.id === activeTabRef.current);
if (tab && tab.path && !tab.saved) {
try {
await invoke('auto_save', {
path: tab.path,
content: tab.content
});
updateTab(activeTabRef.current, { saved: true });
} catch (error) {
console.error('Auto-save failed:', error);
}
}
}, 2000);
return () => clearInterval(interval);
}, []); // 빈 deps — interval은 한 번만 생성하고 ref로 최신 상태 참조
const handleOpen = async () => {
const selected = await open({
multiple: false,
filters: [{ name: 'Text', extensions: ['txt', 'md'] }]
});
if (selected) {
const content = await readTextFile(selected as string);
const newTab: Tab = {
id: Date.now().toString(),
path: selected as string,
content,
saved: true
};
setTabs([...tabs, newTab]);
setActiveTab(newTab.id);
}
};
const handleSave = async () => {
const tab = tabs.find(t => t.id === activeTab);
if (!tab) return;
const filePath = tab.path || await save({
filters: [{ name: 'Text', extensions: ['txt'] }]
});
if (filePath) {
await writeTextFile(filePath, tab.content);
updateTab(activeTab, { path: filePath, saved: true });
}
};
// 함수형 업데이트: interval 콜백처럼 오래된 클로저에서 불려도 최신 tabs 기준으로 갱신
const updateTab = (id: string, updates: Partial<Tab>) => {
setTabs(prev => prev.map(t => t.id === id ? { ...t, ...updates } : t));
};
const currentTab = tabs.find(t => t.id === activeTab);
return (
<div className="app">
<div className="toolbar">
<button onClick={handleOpen}>Open</button>
<button onClick={handleSave}>Save</button>
<button onClick={() => setTabs([...tabs, {
id: Date.now().toString(),
path: null,
content: '',
saved: true
}])}>New Tab</button>
</div>
<div className="tabs">
{tabs.map(tab => (
<div
key={tab.id}
className={`tab ${activeTab === tab.id ? 'active' : ''}`}
onClick={() => setActiveTab(tab.id)}
>
{tab.path ? tab.path.split('/').pop() : 'Untitled'}
{!tab.saved && ' *'}
<button onClick={(e) => {
e.stopPropagation(); // 부모 div의 setActiveTab이 같이 실행되지 않도록
setTabs(tabs.filter(t => t.id !== tab.id));
}}>
×
</button>
</div>
))}
</div>
{currentTab && (
<textarea
value={currentTab.content}
onChange={(e) => updateTab(activeTab, {
content: e.target.value,
saved: false
})}
placeholder="Start typing..."
/>
)}
</div>
);
}
export default App;
이 컴포넌트에서 가장 잡기 어려운 버그는 updateTab에서 생깁니다. setTabs(tabs.map(...))처럼 현재 렌더링의 tabs를 직접 쓰면, useEffect(..., [])가 만든 interval 콜백은 첫 렌더링 때의 updateTab 을 계속 붙잡고 있고, 그 함수가 보는 tabs는 초기값인 빈 탭 하나뿐입니다. 사용자가 파일을 두 개 열고 편집한 뒤 2초가 지나 자동 저장이 성공하면, updateTab이 초기 tabs를 기준으로 새 배열을 만들어 열어 둔 탭이 전부 사라집니다. ref로 최신 탭을 읽도록 고친 부분과 짝을 맞춰 setTabs(prev => ...) 함수형 업데이트로 바꿔야 어느 클로저에서 불려도 최신 상태를 기준으로 갱신됩니다. React의 오래된 클로저(stale closure) 문제는 이렇게 “타이머나 이벤트 리스너 안에서 상태를 쓰는” 지점에서 거의 항상 나타납니다.
남아 있는 한계도 있습니다. invoke('auto_save')가 진행되는 동안 사용자가 더 타이핑하면, 저장이 끝난 뒤 saved: true가 설정되어 방금 입력한 내용이 저장되지 않았는데도 저장된 것으로 표시됩니다. 저장을 시작할 때의 content를 기억해 두었다가 완료 시점의 content와 같을 때만 saved: true로 바꾸면 해결됩니다. 또 활성 탭을 닫으면 activeTab이 존재하지 않는 id를 가리키게 되어 편집 영역이 사라지고, 탭 제목의 tab.path.split('/')는 Windows 경로(C:\Users\...)에서 구분자가 달라 전체 경로를 그대로 보여 줍니다. 실제 앱에서는 @tauri-apps/api/path의 basename()을 쓰는 편이 안전합니다.
빌드 및 배포
릴리스 빌드
npm run tauri build
결과물:
- Windows:
.msi(WiX), NSIS-setup.exe설치 파일 (src-tauri/target/release/bundle).target/release의.exe는 설치 파일이 아니라 앱 실행 파일 자체입니다. - macOS:
.app,.dmg(src-tauri/target/release/bundle) - Linux:
.deb,.AppImage(src-tauri/target/release/bundle)
여기서 중요한 함정 하나: Tauri는 크로스플랫폼 프레임워크지만 크로스 컴파일은 사실상 지원하지 않습니다. macOS에서 tauri build를 돌리면 macOS용 바이너리만 나오고, Windows용 설치 파일이나 Linux용 .deb를 같은 머신에서 만들 수는 없습니다(v2에서 Linux/macOS에서 Windows NSIS 설치 파일을 만드는 실험적 경로가 있지만 공식 권장 방식은 아닙니다). 세 플랫폼 전부 배포하려면 각 OS에서 각각 빌드하거나(로컬에 세 대의 머신/VM), GitHub Actions의 matrix 빌드(windows-latest, macos-latest, ubuntu-latest)로 CI에서 세 플랫폼을 동시에 빌드하는 게 사실상 표준 워크플로입니다.
배포 단계에서 부딪히는 또 하나의 벽은 코드 서명입니다. 서명하지 않은 Windows 설치 파일은 SmartScreen이 “Windows의 PC 보호” 경고를 띄우고, macOS에서는 Apple Developer ID로 서명하고 공증(notarization)하지 않은 앱을 Gatekeeper가 “확인되지 않은 개발자” 또는 “손상되었기 때문에 열 수 없습니다”라는 메시지로 막습니다. 내부 배포용 도구라면 감수할 수 있지만, 일반 사용자에게 배포하려면 인증서 비용과 CI에서의 서명 설정을 일정에 포함해야 합니다.
번들 설정
// src-tauri/tauri.conf.json
{
"tauri": {
"bundle": {
"identifier": "com.myapp.dev",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/icon.icns",
"icons/icon.ico"
],
"resources": [],
"copyright": "",
"category": "Utility",
"shortDescription": "My awesome app",
"longDescription": "Detailed description..."
}
}
}
identifier는 macOS 번들 ID와 앱 데이터 디렉터리 경로($APPDATA)를 결정하므로, 한 번 배포한 뒤에 바꾸면 기존 사용자의 설정 파일을 찾지 못하게 됩니다. 처음부터 역도메인 형식으로 신중하게 정해야 합니다.
바이너리 크기를 더 줄이고 싶다면 tauri.conf.json이 아니라 src-tauri/Cargo.toml의 릴리스 프로필을 조정합니다. Tauri 공식 문서도 아래와 같은 설정을 권합니다.
[profile.release]
codegen-units = 1 # 최적화 범위를 넓히는 대신 빌드가 느려짐
lto = true # 링크 타임 최적화
opt-level = "s" # 속도보다 크기 우선
panic = "abort" # 언와인딩 코드 제거
strip = true # 디버그 심볼 제거
panic = "abort"는 panic 시 스택을 풀지 않고 즉시 종료하므로, 앱 코드가 catch_unwind로 panic을 잡는 구조라면 쓰면 안 됩니다. strip = true는 배포 후 크래시 리포트의 스택 트레이스에서 함수 이름을 잃게 하므로, 심볼을 따로 보관하는 체계가 없다면 신중하게 켜는 편이 좋습니다.
자동 업데이트
// src-tauri/src/main.rs
use tauri::Manager;
fn main() {
tauri::Builder::default()
.setup(|app| {
let handle = app.handle();
tauri::async_runtime::spawn(async move {
// 업데이트 확인
match handle.updater().check().await {
Ok(update) => {
if update.is_update_available() {
update.download_and_install().await.unwrap();
}
}
Err(e) => println!("Failed to check for updates: {}", e),
}
});
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
이 코드는 v1의 updater 기능을 Rust에서 직접 호출하는 형태입니다. 동작하려면 tauri.conf.json의 updater 항목에 업데이트 정보를 받을 endpoints와 서명 검증용 pubkey를 설정해야 하고, 빌드 시 비밀 키로 업데이트 파일에 서명해야 합니다. Tauri 업데이터는 서명 검증을 끌 수 없게 되어 있어서, 비밀 키를 잃어버리면 이미 배포된 앱에 더 이상 업데이트를 보낼 수 없습니다. 키는 CI 비밀 저장소와 별도 백업에 반드시 보관해야 합니다. 예제의 unwrap()은 다운로드가 네트워크 문제로 실패하는 순간 백그라운드 태스크를 panic시키므로, 실제로는 에러를 로그로 남기고 다음 실행 때 다시 시도하도록 처리하는 편이 낫습니다. v2에서는 업데이터가 tauri-plugin-updater 플러그인으로 분리되었습니다.
Tauri vs Electron
| 항목 | Tauri | Electron |
|---|---|---|
| 번들 크기 | 작음 (엔진을 싣지 않음) | 큼 (Chromium·Node.js 포함) |
| 메모리 | Node.js 런타임 없음, WebView는 사용 | Chromium 멀티 프로세스 + Node.js |
| 언어 | Rust + Web | JavaScript + Web |
| WebView | 시스템 기본 (OS마다 다름) | Chromium 내장 (모든 OS 동일) |
| 보안 | 기본 권한 없음, 명시적으로 허용 | 격리 설정을 직접 챙겨야 함 |
| 크로스플랫폼 | 지원 | 지원 |
| 생태계 | 성장 중 | 성숙 |
표의 마지막 두 줄이 실제 선택에서 자주 결정적입니다. 모든 OS에서 렌더링이 똑같아야 하거나 Node 네이티브 모듈을 그대로 써야 한다면 Electron이 편하고, 설치 크기와 권한 모델이 중요하면 Tauri가 유리합니다.
Tauri Mobile (iOS/Android)
모바일 앱 개발
모바일 타깃은 Tauri v1에서는 알파·베타 단계였고, v2부터 정식으로 지원됩니다. 아래 명령도 v2 CLI 기준입니다.
# iOS 타겟 추가
npm run tauri ios init
npm run tauri ios dev
# Android 타겟 추가
npm run tauri android init
npm run tauri android dev
하나의 코드베이스로 지원하는 플랫폼:
- Windows
- macOS
- Linux
- iOS
- Android
실제 사용 사례
Tauri로 만든 공개 앱은 공식 저장소가 관리하는 awesome-tauri 목록에서 확인할 수 있습니다. 네트워크 프록시 GUI인 Clash Verge처럼 시스템 트레이에 상주하는 유틸리티, 개발자 도구, 가벼운 생산성 앱이 많은 편입니다. 반대로 Zed나 Lapce 같은 Rust 에디터는 Tauri가 아니라 자체 GPU UI 프레임워크를 쓰므로, “Rust로 만든 데스크톱 앱 = Tauri”로 생각하지 않도록 주의해야 합니다.
자주 묻는 질문 (FAQ)
Q1. Tauri 앱의 실제 성능은 어떤가요?
A: 설치 파일 크기는 구조상 차이가 분명합니다. Electron은 Chromium과 Node.js를 함께 배포하고 Tauri는 OS의 WebView를 쓰기 때문입니다. 시작 시간과 메모리는 프론트엔드 번들 크기, OS, WebView 버전에 따라 달라져서 일반화된 숫자를 드리기 어렵습니다. 도입을 검토 중이라면 가장 무거운 화면 하나를 양쪽으로 만들어 대상 OS에서 직접 측정해 보시길 권합니다.
Q2. 시스템 WebView를 사용하면 브라우저 차이 문제가 있지 않나요?
A: 맞습니다. 하지만:
- Windows: WebView2 (Chromium 기반, 자동 업데이트)
- macOS: WKWebView (Safari 기반, 최신 표준 지원)
- Linux: WebKitGTK (최신 버전 권장)
대부분의 모던 웹 기술은 문제없이 작동하지만, macOS·Linux의 WebKit 계열은 Chromium보다 새 Web API 지원이 늦는 경우가 있습니다. 개발은 Windows에서 하고 배포는 세 OS에 한다면, QA 목록에 macOS와 Linux 화면 확인을 꼭 넣어야 합니다.
Q3. Rust를 모르는데 Tauri를 배울 수 있나요?
A: 가능합니다. 대부분의 로직은 프론트엔드(React/Vue)에서 작성하며, Rust는:
- 템플릿이 만들어 주는 기본 코드로 시작
- 파일 처리나 무거운 계산처럼 필요한 경우에만
#[tauri::command]함수 작성 - 다만 커스텀 커맨드에서 컴파일 에러를 만나면 소유권·타입 개념을 어느 정도 알아야 해결할 수 있음
Q4. Electron에서 Tauri로 마이그레이션할 수 있나요?
A: 가능하지만 노력이 필요합니다.
- UI 코드: 대부분 그대로 사용 가능 (단, Chromium 전용 기능에 기대던 부분은 WebKit에서 다시 확인)
- Node.js API:
fs,child_process,ipcRenderer를 쓰던 코드는 Tauri API나 Rust 커맨드로 재작성 필요 - 네이티브 모듈: Rust로 재작성
실제로 가장 품이 드는 부분은 메인 프로세스 코드입니다. Electron의 메인 프로세스는 Node.js라서 npm 생태계를 그대로 쓰지만, Tauri에서는 그 역할을 Rust가 맡으므로 해당 로직을 Rust 크레이트로 옮기거나, 번들한 Node 실행 파일을 사이드카(sidecar)로 띄우는 우회로를 택해야 합니다. 사이드카를 쓰면 설치 크기 이점이 상당 부분 사라진다는 점도 계산에 넣어야 합니다.
Q5. Tauri의 단점은 무엇인가요?
A:
- 생태계: Electron보다 작음
- Rust 학습 곡선: 고급 기능은 Rust 지식 필요
- 디버깅: 프런트엔드는 WebView 개발자 도구로, 백엔드는 Rust 디버거로 따로 봐야 해서 Electron보다 번거로울 수 있음
- 플랫폼별 렌더링 차이: OS마다 WebView 엔진이 달라 QA 범위가 넓어짐