개발자 혼자 만드는 데스크톱 앱도 보안 경고 없이 배포하는 방법
TuBrief 편집팀
2026년 7월 15일
0
컴퓨터/소프트웨어원본 영상을 바탕으로 AI의 도움을 받아 작성했습니다. 원본 영상이 기준입니다.
커뮤니티의 다른 글
댓글 (0)
Log in to leave a comment
아직 작성된 글이 없습니다
원본 영상을 바탕으로 AI의 도움을 받아 작성했습니다. 원본 영상이 기준입니다.
Log in to leave a comment
아직 작성된 글이 없습니다
로컬에서 잘 돌아가는 데스크톱 앱을 완성했다면 개발은 절반만 끝난 셈입니다. 진짜 고비는 다운로드 링크를 누른 사용자 화면에 "손상된 파일"이라거나 "컴퓨터가 보호되었습니다" 같은 붉은색 경고창이 뜨는 순간부터 시작됩니다.
OS 보안 장벽을 넘고, 빌드 속도를 제어하며, 사용자가 켜둘 때마다 알아서 최신 버전으로 판올림되는 자동 업데이트 체계를 만드는 일은 생각보다 피곤합니다. 일렉트론보다 가볍다는 이유로 Tauri v2를 선택했더라도 배포 과정의 현실적인 문제는 그대로 남습니다. 1인 개발자나 소규모 팀이 불필요한 비용과 시간을 낭비하지 않고 제품을 깔끔하게 배포하는 방법을 정리했습니다.
코드 서명(Code Signing)이 없는 데스크톱 앱은 운영체제 수준에서 악성코드로 취급당합니다. 사용자 PC에 보안 경고를 띄우지 않으려면 돈과 서류 작업이 필요합니다.
macOS 배포를 하려면 연간 99달러의 Apple Developer Program 가입이 필수입니다. 계정을 확보했다면 Tauri 웹뷰가 제대로 돌 수 있도록 메모리 보안 예외 권한을 정의한 src-tauri/Entitlements.plist를 만들어야 합니다. 이 설정이 누락되면 앱이 실행 즉시 튕깁니다.
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
</dict>
</plist>
이 파일을 src-tauri/tauri.conf.json의 bundle 옵션에 지정합니다.
{
"bundle": {
"macOS": {
"signingIdentity": "Developer ID Application: Your Name (TEAMID)",
"entitlements": "./Entitlements.plist",
"minimumSystemVersion": "11.0",
"dmg": {
"appPosition": { "x": 180, "y": 170 },
"applicationFolderPosition": { "x": 480, "y": 170 }
}
}
}
}
Windows의 SmartScreen 필터를 통과하려면 과거에는 연간 400~700달러에 달하는 EV(Extended Validation) 인증서를 물리 USB 토큰 형태로 발급받아야 했습니다. 돈도 돈이지만 개인이 관리하기에는 지나치게 번거롭습니다.
대안은 마이크로소프트의 클라우드 기반 서명 서비스인 Azure Trusted Signing(ATS)입니다. 월 9.99달러 수준의 구독 비용만 내면 마이크로소프트가 관리하는 HSM 클라우드 내부에서 서명을 처리하므로 물리 키를 보관할 필요가 없습니다.
AZURE_TENANT_ID, CLIENT_ID, CLIENT_SECRET)와 ATS 정보를 입력합니다.sign-tool을 실행하여 Tauri가 컴파일한 MSI나 EXE 파일에 디지털 서명을 적용합니다.이렇게 서명된 앱은 첫 다운로드 시점부터 Windows SmartScreen 경고를 회피하므로, 설치 단계에서 이탈하는 사용자를 잡을 수 있습니다.
Tauri는 가볍지만 빌드 과정에서 Rust 컴파일러와 각 OS별 네이티브 도구 체인을 통째로 굴려야 합니다. 내 컴퓨터에서는 잘 돌아가도 다른 팀원 컴퓨터에서 링커 에러가 나거나, 로컬 환경의 종속성 오염 때문에 배포본이 깨지는 일은 흔합니다. 배포용 빌드는 반드시 격리된 CI/CD 파이프라인에서 돌려야 안전합니다.
문제는 GitHub Actions의 기본 호스팅 러너가 Rust를 빌드하기엔 사양이 부족하다는 점입니다. 매번 디펜던시를 새로 받고 빌드하는 구조라면 릴리스 빌드 하나에 10분 이상 걸리기 십상입니다.
이때 단순히 파일들을 압축해서 클라우드로 주고받는 actions/cache 대신, NVMe 고성능 스토리지를 타겟으로 작동하는 전용 캐시 플러그인(swatinem/rust-cache)이나 전용 호스팅 러너(Namespace, Depot 등)를 결합하면 속도가 달라집니다.
음악 플레이어 오픈소스 프로젝트인 spotify-player 빌드 로그 기준으로 일반 GitHub 러너와 로컬 볼륨 캐시를 적용한 전용 러너의 성능 비교 결과는 다음과 같습니다.
| 플랫폼 및 캐시 구성 | 일반 GitHub 러너 소요 시간 | 캐시 최적화 적용 시 소요 시간 | 빌드 시간 단축률 |
|---|---|---|---|
| Ubuntu Linux | 9분 31초 | 34초 | 94.0% 단축 |
| macOS Darwin | 9분 31초 | 27초 | 95.2% 단축 |
| Windows MSVC | 9분 31초 | 44초 | 92.2% 단축 |
| 워크플로우 비용 | 1회 실행당 $0.44 | 1회 실행당 $0.074 | 83.1% 절감 |
지속 보존되는 볼륨 캐시 인프라를 연동하는 것만으로 개발팀의 빌드 대기 시간이 최소 40% 이상 줄어듭니다.
배포 자동화 설정은 .github/workflows/publish.yml에 다음과 같이 지정합니다.
jobs:
build-binaries:
strategy:
matrix:
platform: [macos-latest, windows-latest]
runs-on: ${{ matrix.platform }}
# ... 빌드 단계 진행 후 tauri-action 호출
tauri-apps/tauri-action을 워크플로우 맨 마지막에 넣어두면 새 태그를 푸시할 때마다 두 OS용으로 서명 완료된 인스톨러가 GitHub Release Draft에 알아서 등록됩니다.
Windows용 인스톨러를 패키징할 때 웹뷰 엔진인 WebView2 설치 방식을 결정해야 합니다. 인터넷 연결이 보장되고 다운로드 파일 크기를 극단적으로 줄여야 하는 환경이라면 번들 크기가 늘어나지 않는 downloadBootstrapper 방식이 무난합니다. 반대로 폐쇄망이나 오프라인 환경을 타겟팅한다면 설치 파일에 약 127MB가 더해지더라도 offlineInstaller를 동봉하는 쪽이 안전합니다.
Tauri 기반 앱을 운영할 때 데이터를 다루는 방식도 중요합니다. 브라우저 저장소인 IndexedDB나 LocalStorage에 마냥 데이터를 넣어두는 일은 위험합니다.
실제로 Tauri v1에서 v2로 넘어올 때 Windows 환경의 웹뷰 도메인 스키마가 [https://tauri.localhost](https://tauri.localhost)에서 [http://tauri.localhost](http://tauri.localhost)로 바뀌는 내부적인 변경사항이 있었습니다. 이 때문에 브라우저 캐시 경로가 강제로 전환되면서 기존 데이터를 다 잃어버리는 일들이 많았습니다.
배포 후 데이터가 초기화되는 대참사를 막으려면 핵심 정보는 웹뷰 저장소 대신 네이티브 파일 시스템 영역에 SQLite 파일로 직접 보관해야 합니다. Tauri v2의 appDataDir API를 쓰면 운영체제 규격에 맞는 안전한 샌드박싱 경로를 자동으로 찾아줍니다.
C:\Users\<UserName>\AppData\Roaming\<BundleIdentifier>/Users/<UserName>/Library/Application Support/<BundleIdentifier>Tauri v2 백엔드인 Rust 코드(src-tauri/src/lib.rs) 내부에서 앱 라이프사이클에 개입해 SQLite 데이터베이스를 안전 영역에 바인딩하고 스키마 마이그레이션을 돌리는 예시는 다음과 같습니다.
use std::fs;
use tauri::Manager;
use tauri_plugin_sql::{Migration, MigrationKind};
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let database_migrations = vec![
Migration {
version: 1,
description: "initialize_user_profiles_table",
sql: "CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE
);",
kind: MigrationKind::Up,
}
];
tauri::Builder::default()
.setup(|app| {
let local_app_dir = app.path().app_data_dir()
.expect("Critical: Could not resolve target operating system app data path.");
if !local_app_dir.exists() {
fs::create_dir_all(&local_app_dir)
.expect("Critical: Failed to establish persistent storage directory structure.");
}
Ok(())
})
.plugin(
tauri_plugin_sql::Builder::default()
.add_migrations("sqlite:users.db", database_migrations)
.build()
)
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
이렇게 구성해 두면 자동 업데이트나 재설치로 인해 일렉트론이나 크로뮴 웹뷰의 내부 캐시가 싹 날아가도 실제 사용자 데이터베이스는 안전하게 보존됩니다.
사용자에게 매번 홈페이지에 와서 새 버전을 다시 받으라고 유도하는 방식은 이탈률을 높입니다. 클라우드 오브젝트 스토리지와 CDN을 엮어 조용히 업데이트 파일을 서빙하는 구조를 잡아야 합니다.
배포 서버로는 Cloudflare R2와 AWS CloudFront의 조합이 효율적입니다. Cloudflare R2는 데이터 송출료(Egress Fees)가 없어 대규모 업데이트 파일을 릴리스할 때 발생하는 네트워크 트래픽 비용을 0원으로 묶어둘 수 있습니다.
클라이언트가 새로운 버전이 나왔는지 확인할 때 조회하는 메타데이터 파일(latest.json)은 CDN이나 브라우저에 캐싱되면 안 됩니다. 응답 헤더에 아래 정책을 명시해야 합니다.
Cache-Control: no-cache, no-store, must-revalidate
반면 실제 설치 바이너리 파일들은 고유 해시값이 포함된 불변(Immutable) 상태이므로 CDN에서 최대한 오래 들고 있도록 설정하여 원본 서버의 트래픽 부담을 덜어줍니다.
Cache-Control: public, max-age=31536000, immutable
Tauri v2에서는 업데이트 관련 옵션들의 위치가 plugins.updater 블록 밑으로 이동했습니다. 아래는 tauri.conf.json 설정 명세입니다.
{
"bundle": {
"createUpdaterArtifacts": true
},
"plugins": {
"updater": {
"active": true,
"endpoints": [
"https://cdn.myapp.com/releases/latest.json"
],
"dialog": false,
"pubkey": "dW5zaWduZWQgYm91bmRmaXg...",
"windows": {
"installMode": "passive"
}
}
}
}
Windows 환경에서 사용자가 귀찮은 확인 창을 누르지 않고 업데이트하게 만들려면 installMode를 passive나 quiet로 잡아야 합니다. passive 모드는 설치 마법사 창 대신 얌전한 게이지 바만 보여준 뒤 조용히 교체를 완료합니다.
설정이 끝났다면 프론트엔드 영역에서 @tauri-apps/plugin-updater와 @tauri-apps/plugin-process를 연동하여 앱 실행 시점에 신규 패치를 체크하고 재부팅을 유도하는 로직을 붙입니다.
import { check } from "@tauri-apps/plugin-updater";
import { ask } from "@tauri-apps/plugin-dialog";
import { relaunch } from "@tauri-apps/plugin-process";
export async function runBackgroundUpdater(): Promise<void> {
try {
const updatePayload = await check();
if (updatePayload && updatePayload.available) {
const userResponse = await ask(
`새 버전 [v${updatePayload.version}]을 다운로드할 수 있습니다. 지금 업데이트하고 앱을 재실행하시겠습니까?`,
{
title: "소프트웨어 자동 업데이트 안내",
kind: "info",
okLabel: "업데이트 설치 및 재구동",
cancelLabel: "나중에 적용"
}
);
if (userResponse) {
await updatePayload.downloadAndInstall();
await relaunch();
}
}
} catch (error) {
console.error("자동 업데이트 확인 과정에서 예외 발생:", error);
}
}
이 함수를 최상위 리액트 컴포넌트나 뷰의 초기 마운트 단계에 심어두는 것만으로 사용자는 직접 홈페이지를 뒤적거릴 필요 없이 늘 최신 상태의 소프트웨어를 쓰게 됩니다.
appDataDir 경로 하위에 SQLite 데이터베이스 파일을 두고 장기적인 스키마 마이그레이션을 돌려야 앱 업데이트 중 데이터가 꼬이지 않습니다.