Tauri 2.x 데스크톱 6개월 삽질: 정적 CRT / FFmpeg 사이드카 / Turbopack 심링크
Tauri 2.x 데스크톱 6개월 삽질: 정적 CRT / FFmpeg 사이드카 / Turbopack 심링크
결론부터: Tauri 2.x는 Electron 대비 바이너리 크기, 성능, 보안 모델에서 명확히 우위입니다. 하지만 “크로스플랫폼”(특히 Windows + macOS 병행) 간판 뒤에는 공식 문서가 말하지 않는 함정이 훨씬 많습니다. Gemini Desktop에서 6개월 출시하며 쌓인 7가지 대표 함정을 단발 fix가 아닌 진단 프로토콜 형태로 정리했습니다.
한 줄 요약: Tauri 2.x 함정의 80%는 Tauri 자체가 아니라 Rust 생태계(sherpa-onnx / portable-pty / scap)와 JS 생태계(Turbopack / tauri-plugin-store)의 이음새에서 발생합니다.
1. Windows MSVC CRT 정적 링크(+crt-static)
가장 큰 함정. Windows에서 로컬 음성 인식 모델 sherpa-onnx를 통합했습니다. 이 C++ 의존은 /MT(정적 CRT), 반면 openssl-sys, ring 같은 Rust crate는 기본 /MD(동적 CRT). 최종 링크 단계에서 LNK2038: 'RuntimeLibrary' mismatch 에러가 대량 발생.
처음엔 “1대1 수정”을 시도했습니다 — mismatch 나는 crate마다 /MT 강제. 실패. 이유:
- cc-rs 빌드의 C++ crate는 각자 자체 컴파일러 플래그 소유
- 각 -sys crate(openssl-sys, ring, libsqlite3-sys)의 build.rs 독립 작성
- 하나 고쳐도 다음 의존 업그레이드에서
/MD로 회귀
정답: 글로벌 +crt-static. 프로젝트 루트 .cargo/config.toml에 추가:
[target.x86_64-pc-windows-msvc]
rustflags = ["-C", "target-feature=+crt-static"]
Rust 코드 전체가 정적 CRT로 가며 sherpa-onnx의 앵커 제약이 만족되고 다른 crate들도 자동으로 맞춰집니다. 대가는 바이너리 +2~4 MB뿐, 허용 범위.
2. CRT mismatch 진단 프로토콜
LNK2038 'RuntimeLibrary' mismatch를 봤다면 코드로 뛰어들지 말고 프로토콜대로:
- cc-rs 의존 crate 열거
cargo tree -e build | grep cc - -sys crate 열거
cargo tree | grep -E "\-sys" - “움직일 수 없는 제약” 찾기 —
/MT또는/MD를 강제 요구하는 crate(보통 ML/오디오 모델, 저수준 C++ SDK) - 글로벌 앵커 선택:
/MT제약 존재 시+crt-static, 없으면 기본/MD - 검증:
cargo build --release --target x86_64-pc-windows-msvc통과,dumpbin /dependents로 외부 VCRUNTIME140.dll 의존 없음 확인
프로젝트에서 세 번(sherpa-onnx, libsodium, ffmpeg-next) 적용, 매번 20분 안에 해결됐습니다.
3. FFmpeg 사이드카 vs PATH
Tauri 2.x는 FFmpeg을 사이드카 바이너리로 번들하는 걸 권장. 방향은 맞지만 디테일에 함정.
처음 작성한 헬퍼:
fn get_ffmpeg_path() -> PathBuf {
// WRONG: dev 모드에서 플레이스홀더 반환
app_handle.path_resolver().resolve_resource("ffmpeg").unwrap()
}
문제: resolve_resource는 tauri dev 모드에서 플레이스홀더 경로를 반환하고 실제 사이드카 경로가 아님. Command::new(get_ffmpeg_path()).spawn()을 직접 호출하면 dev에선 시스템 PATH의 ffmpeg를 우연히 잡아 성공, release에선 못 찾음.
정답: 모든 ffmpeg 호출을 tauri::Command::new_sidecar("ffmpeg")로 통일, Tauri 런타임이 dev vs release 경로 해결을 처리:
use tauri::api::process::Command;
pub async fn run_ffmpeg_sidecar(args: Vec<String>) -> Result<String> {
let (mut rx, _child) = Command::new_sidecar("ffmpeg")?
.args(args)
.spawn()?;
// ... collect output
}
run_ffmpeg_sidecar로 유일한 진입점을 만들고 흩어져 있던 7곳의 직접 호출을 전부 교체했습니다.
4. Turbopack 심링크 제약
Tauri 프런트는 Next.js + Turbopack(monorepo의 workspace package). 이상한 함정: Turbopack은 프로젝트 루트 밖을 가리키는 심링크를 허용하지 않습니다.
구체 사례: 여러 worktree가 node_modules 공유를 위해 ln -s /main/repo/node_modules ./node_modules 시도 → Next 시작 시 에러:
Error: Resolved path is outside of project root
설정으로 해제 불가. 최종 방침: 각 worktree에서 bun install --frozen-lockfile 필수 실행, 심링크 지름길 금지. worktree당 2~3 GB 추가 소요되지만 이상한 resolver 에러는 피할 수 있습니다.
Tauri 쪽에서 할 수 있는 건 없지만 monorepo + Tauri + Next.js 삼중 겹침 시 안정적으로 재현됩니다.
5. portable-pty 0.8 핀 — 0.9 회귀
Windows에서 portable-pty로 shell을 띄웁니다. 0.9 업그레이드 후 Windows 콘솔 출력(백스페이스, 컬러 ESC)에서 크래시·행업.
타임라인:
- portable-pty 0.8.x: 안정
- portable-pty 0.9.0: 신규 Windows ConPTY 백엔드, 복잡한 ANSI 이스케이프에서 교착
- portable-pty 0.9.1/0.9.2: 회귀 미수정
대응: Cargo.toml에서 0.8.1로 고정, 코멘트 포함:
# Pinned to 0.8.1 due to Windows ConPTY deadlock in 0.9.x
# See: https://github.com/wez/wezterm/issues/XXXX
portable-pty = "=0.8.1"
교훈: Windows PTY가 엮인 의존 업그레이드는 반드시 Windows에서 수동 회귀 테스트. Linux CI 통과가 Windows 안전을 의미하지 않습니다.
6. tauri-plugin-store Rust 측 읽기/쓰기 전략
tauri-plugin-store의 Rust API는 “JS가 store, Rust는 플러그인 경유”를 전제로 설계됐습니다. 그런데 우리는 Rust 측 백그라운드 스레드(동기화, 스케줄)에서 settings를 자주 읽고 써야 했고, AppHandle + 비동기 경로는 지나치게 무겁습니다.
우회: Rust 측은 .settings.json을 직접 읽고 씀, 플러그인 거치지 않음:
use serde_json::{Value, from_reader, to_writer_pretty};
pub fn read_settings(path: &Path) -> Result<Value> {
let file = File::open(path)?;
Ok(from_reader(BufReader::new(file))?)
}
pub fn write_settings(path: &Path, v: &Value) -> Result<()> {
let tmp = path.with_extension("json.tmp");
to_writer_pretty(BufWriter::new(File::create(&tmp)?), v)?;
fs::rename(&tmp, path)?; // atomic swap
Ok(())
}
JS는 계속 tauri-plugin-store 사용, 양쪽이 동일 파일을 읽음. 쓰기는 atomic rename(tmp → rename → target)으로 중간 크래시 시 파손을 방지. “양쪽이 동일 추상화를 공유하지 않는” 아키텍처 비용을 감수하고 Rust 측을 대폭 단순화한 거래입니다.
7. scap vs CGDisplayCreateImage: macOS 화면 캡처 선택
2026년 macOS 화면 캡처 선택지:
| 옵션 | 장점 | 단점 |
|---|---|---|
| CGDisplayCreateImage(레거시) | 단순, 동기 API | macOS 15+ deprecated, 향후 제거 |
| ScreenCaptureKit(Apple 공식) | 권장, 미래 대비 | Swift 중심, Rust 바인딩 적음 |
| scap(Rust crate) | 크로스플랫폼, 활발 유지보수 | Windows 안정성 애매 |
판단: macOS는 scap, Windows는 당분간 화면 캡처 미구현. 이유:
- scap의 macOS 구현은 ScreenCaptureKit 래퍼로 가장 공식에 근접한 Rust 옵션
- scap의 Windows 구현은 Windows.Graphics.Capture 의존, 일부 GPU + 다중 모니터에서 검은 화면
- 화면 캡처는 P0 기능이 아니고 Windows 전용 2주 투입 가치 없음
Tauri 2.x 데스크톱을 만든다면 day 1에 “Windows + macOS 양쪽 화면 캡처”를 약속하지 마세요. scap의 Windows 문제는 생태계 이슈지 Tauri 이슈가 아닙니다. 데스크톱 AI 제품 결정은 Gemini Mac 앱에 부족한 6가지 기능에도 상술했습니다.
8. 새 Tauri 2.x 프로젝트를 위한 day-1 권장 7계명
지금 다시 Tauri 2.x 프로젝트를 시작한다면 day 1에 이 7가지를 바로 합니다:
- +crt-static 강제: Windows target용
.cargo/config.toml에 추가, 후속 CRT 반복 고통 차단 - FFmpeg 사이드카 유일 진입점 캡슐화: 모든 ffmpeg 호출이
run_ffmpeg_sidecar통과,Command::new금지 - 알려진 회귀 crate 버전 핀: portable-pty 0.8.1, tauri-plugin-store 안정판, 이유 코멘트
- monorepo에서 node_modules 심링크 금지: 각 worktree에서 제대로 설치
- PATH 외부 도구 의존 금지: ffmpeg, yt-dlp, ripgrep 전부 사이드카로 번들
- Rust와 JS가 config를 각자 읽기: Rust는 JSON 직접, JS는 tauri-plugin-store
- 화면 캡처는 단일 플랫폼 한정: macOS 또는 Windows 중 택일, 양쪽 반쪽짜리 금지
최신 정보는 Tauri 공식에서, 위 7계명은 2026년 4월에도 여전히 유효한 실전 경험입니다.
자주 묻는 질문
Q1: Electron과 Tauri 2.x 중 뭘 고르나요?
A: Electron은 함정이 적지만 바이너리 150 MB+, 메모리 무거움. Tauri 2.x는 함정이 많지만 바이너리 10~20 MB, 네이티브 성능. 팀에 Rust 경험이 있고 제품이 크기/성능 민감하면 Tauri, JS/TS 배경만 있고 기능 속도가 우선이면 Electron이 안전합니다.
Q2: +crt-static이 바이너리를 비대하게 만들지 않나요?
A: 실측 18 MB → 22 MB, +20%. 데스크톱에서 허용 가능, Electron 150 MB 베이스라인 앞에선 무시 가능합니다.
Q3: Tauri의 isolation pattern으로 사이드카 대체 가능한가요?
A: Isolation은 JS 계층 보안 샌드박스로 사이드카와 직교합니다. 사이드카는 “외부 도구 번들” 문제를 해결하지 보안 문제를 해결하지 않습니다. 둘 다 동시 사용 가능합니다.
Q4: portable-pty 0.9 회귀는 이슈 보고됐나요?
A: 여러 건 있습니다. 유지보수 인력은 제한적이고 ConPTY는 복잡합니다. 단기는 0.8.1 핀, 장기는 alacritty / wezterm 팀 ConPTY 개선 관찰이 현실적입니다.
마무리
Tauri 2.x는 매우 유망한 데스크톱 프레임워크지만 “크로스플랫폼” 약속을 실현하려면 각 플랫폼 고유의 함정을 직접 부딪혀야 합니다. 이 글은 Tauri 코어를 비판하는 게 아닙니다 — 코어는 안정적입니다. 어려운 건 Rust 생태계(오디오, PTY, 화면 캡처)와 JS 생태계(Turbopack, store 플러그인)의 이음새입니다. 함정마다 팀 내부 runbook으로 만들어 한 번 낸 비용을 재발시키지 않는 게 핵심입니다.