SvelteKit + Tauri v2 시작하기: 정적 빌드·창·파일 권한까지
SvelteKit 앱을 Tauri v2 데스크톱 앱으로 옮길 때 필요한 정적 빌드, 창 구조, IPC, 파일 권한 설계를 단계별로 정리합니다.
웹 프로젝트를 데스크톱 앱으로 옮길 때, 먼저 바뀌는 것은 배포 방식이다
SvelteKit으로 만든 화면을 Tauri에 넣는 일은 겉보기보다 단순해 보입니다. 프런트엔드를 빌드하고, Tauri가 그 결과물을 띄우면 끝날 것 같아요. 실제로 첫 창을 띄우는 데까지는 빠릅니다. 다만 그 다음부터는 같은 SvelteKit 앱을 그대로 옮기는 일이 아니라, 서버가 없는 데스크톱 런타임에 맞춰 경계를 다시 정하는 일이 됩니다.
가장 먼저 받아들여야 할 전제는 이것입니다. Tauri는 SvelteKit의 Node 서버를 함께 운영하지 않습니다. 빌드된 HTML·CSS·JavaScript를 앱 안에서 제공하고, 필요한 운영체제 기능만 Rust와 플러그인으로 연결합니다. 그래서 +page.server.ts, form action, 서버 훅처럼 서버 프로세스가 전제인 기능을 데스크톱 번들 안에서 기대하면 구조가 꼬입니다.
이 글은 이미 SvelteKit을 써 본 사람이 Tauri v2로 데스크톱 앱을 시작할 때 필요한 최소 구조를 다룹니다. 정적 빌드, 창 설정, 프런트엔드와 Rust의 경계, 그리고 파일을 읽고 쓰는 권한까지 한 흐름으로 연결해 보겠습니다. Tauri와 Electron 중 무엇을 선택할지는 SvelteKit 데스크톱 앱 비교 글에서 먼저 정리했어요. 여기서는 Tauri로 결정한 뒤에 실패하지 않는 출발점을 만드는 데 집중합니다.
서버 기능을 버리는 것이 아니라, 서버 역할을 분리한다
SvelteKit은 SSR과 서버 라우트를 자연스럽게 품는 프레임워크입니다. 그래서 웹 서비스에서는 페이지의 load 함수가 서버에서 데이터를 가져오고, 로그인 처리는 form action으로, 비밀키를 쓰는 작업은 +server.ts로 두는 방식이 편합니다. 그런데 Tauri의 기본 배포물에는 이런 서버를 계속 실행할 Node 런타임이 들어 있지 않습니다.
이 차이는 기능을 포기하라는 뜻이 아닙니다. 역할을 옮기라는 뜻에 가깝습니다. 공개·공용 데이터는 기존 HTTPS API로, 사용자 기기 안의 파일과 창 제어는 Tauri 플러그인 또는 Rust 명령으로, 비밀값이 필요한 작업은 여전히 외부 백엔드로 보냅니다. 데스크톱 앱에 API 키를 넣어 서버 역할까지 떠안기는 순간, 편리해 보였던 구조가 배포물에서 바로 노출됩니다.
따라서 시작 전에 다음 세 가지를 분류해 두는 편이 좋습니다.
- 브라우저에서도 실행 가능한 UI·상태·API 호출
- 사용자 기기 안에서만 해야 하는 파일, 창, 알림, 트레이 작업
- 서버에 남아야 하는 인증, 결제, 비밀키, 권한 검증 작업
이 분류를 하지 않으면 window가 없다는 오류와 API 인증 실패를 고치느라 시간을 쓰게 됩니다. 반대로 분류가 되면 Tauri는 웹앱의 대체물이 아니라, 웹 프런트엔드에 얇고 명확한 로컬 기능을 붙이는 셸이 됩니다.
정적 빌드는 옵션이 아니라 데스크톱의 기본 형식이다
Tauri에서 SvelteKit을 쓸 때 핵심은 @sveltejs/adapter-static입니다. 앱이 읽을 수 있는 정적 산출물을 만들고, Tauri의 frontendDist가 그 디렉터리를 바라보게 합니다. 동적 URL이 있는 SPA를 염두에 둔다면 fallback: 'index.html'을 설정해 새로고침과 직접 진입도 클라이언트 라우터가 받도록 만듭니다.
npm install -D @sveltejs/adapter-static @tauri-apps/cli
npm install @tauri-apps/api
npm run tauri init
이미 있는 SvelteKit 프로젝트라면 위 명령은 출발점일 뿐입니다. 프로젝트의 패키지 매니저와 명령 체계에 맞추고, 생성된 src-tauri를 별도 네이티브 프로젝트처럼 취급해야 합니다. 프런트엔드 의존성과 Rust 의존성은 같은 앱을 구성하지만 갱신 주기와 실패 방식은 다릅니다.
svelte.config.js는 다음처럼 정적 결과물을 만들게 둡니다.
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({
fallback: 'index.html'
})
}
};
export default config;
여기서 fallback은 모든 문제를 해결하는 마법 설정이 아닙니다. 로그인 뒤의 /projects/123처럼 빌드 시점에 알 수 없는 경로를 클라이언트에서 처리할 수 있도록 index.html을 돌려주는 장치입니다. 해당 화면이 서버 전용 load나 form action에 의존한다면 fallback이 있어도 작동하지 않습니다. 그 데이터는 브라우저에서 호출 가능한 API로 바꿔야 합니다.
루트 레이아웃에는 SSR을 끕니다.
// src/routes/+layout.ts
export const ssr = false;
export const prerender = false;
ssr = false는 Tauri API처럼 window가 있어야 하는 코드를 조건문 없이 다루기 쉽게 해 줍니다. prerender = false는 fallback 기반 SPA 운영을 명시할 때 유용합니다. 다만 정적 문서, 공개 랜딩 화면처럼 모든 경로가 빌드 시점에 확정되는 앱은 prerender = true 전략도 가능합니다. 둘을 섞어 쓰기 전에 “이 화면은 누가, 언제, 어디서 데이터를 가져오는가”부터 문서화하는 편이 훨씬 안전합니다.
Tauri 설정은 빌드 경로를 하나로 합의시키는 일이다
개발 중에는 Vite dev server가 UI를 제공하고, 릴리스 빌드에서는 정적 디렉터리가 UI를 제공합니다. 이 둘의 경로가 맞지 않으면 개발 모드에서는 잘 열리지만 패키지 앱에서는 빈 창이 나오는 전형적인 문제가 생깁니다.
// src-tauri/tauri.conf.json
{
"$schema": "../gen/schemas/desktop-schema.json",
"productName": "Desk Notes",
"version": "0.1.0",
"identifier": "com.example.desknote",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:5173",
"beforeBuildCommand": "npm run build",
"frontendDist": "../build"
},
"app": {
"windows": [
{
"label": "main",
"title": "Desk Notes",
"width": 1200,
"height": 800,
"minWidth": 900,
"minHeight": 640,
"resizable": true
}
]
},
"bundle": {
"active": true,
"targets": "all"
}
}
SvelteKit 정적 어댑터의 기본 출력 경로와 위의 frontendDist는 프로젝트마다 다를 수 있습니다. 중요한 것은 관례가 아니라 실제 npm run build 뒤에 생성되는 디렉터리입니다. 한 번 빌드한 뒤 build/index.html이 있는지 보고, 없다면 어댑터 출력 경로 또는 Tauri 설정 중 하나만 고치세요. 두 곳을 동시에 추측으로 바꾸면 원인을 잃습니다.
개발과 패키지 검증은 반드시 분리합니다.
# 개발 서버 + Tauri 창
npm run tauri dev
# 정적 SvelteKit 빌드 + 네이티브 번들 생성
npm run tauri build
개발 모드에서는 devUrl의 서버가 모든 것을 대신해 주기 때문에, 파일 경로·CSP·앱 리소스 경로 같은 문제를 놓치기 쉽습니다. 기능이 하나 추가될 때마다 최소 한 번은 패키지된 결과에서 실행해 보는 습관이 필요합니다. 특히 상대 경로 이미지, 동적 라우트, 외부 OAuth 리디렉션은 릴리스에서 먼저 깨지는 편입니다.
창은 화면이 아니라 권한 경계가 될 수 있다
처음에는 창을 디자인 문제로만 생각하기 쉽습니다. 너비와 높이, 타이틀, 최소 크기를 넣고 끝내는 식입니다. 하지만 Tauri v2에서는 창 label이 권한을 묶는 단위가 될 수 있습니다. 사용자에게 보이는 title과 보안 설정에서 쓰는 label은 다른 값이라는 점이 중요합니다.
예를 들어 편집기가 메인 창에서만 로컬 파일을 저장하고, 도움말이나 로그인 창은 그 기능이 필요 없다고 해봅시다. 모든 창에 같은 capability를 붙이면 도움말 창에서 실행되는 프런트엔드도 파일 쓰기 권한을 함께 갖습니다. 보기에는 단순하지만 공격 면적은 불필요하게 커집니다.
새 창은 먼저 “무엇을 보여줄지”보다 “무엇을 할 수 있어야 하는지”를 정하고, 그 다음 label과 capability를 정하는 순서가 낫습니다. 특히 외부 URL을 띄우는 창이나 플러그인 인증을 위한 창은 기본 창보다 권한을 더 좁게 만드는 편이 좋습니다.
창을 코드에서 만들 필요가 생겼을 때도 label을 고정된 식별자로 다룹니다.
use tauri::{Manager, WebviewUrl, WebviewWindowBuilder};
fn open_preferences(app: &tauri::AppHandle) -> tauri::Result<()> {
if app.get_webview_window("preferences").is_none() {
WebviewWindowBuilder::new(
app,
"preferences",
WebviewUrl::App("/preferences".into())
)
.title("환경설정")
.inner_size(720.0, 560.0)
.resizable(false)
.build()?;
}
Ok(())
}
사용자 입력을 그대로 label에 넣어 창을 만들지 않는 것도 같은 이유입니다. 권한 설정은 title이 아니라 label과 연결되므로, 창 이름을 즉석에서 만들면 capability 설계가 추적하기 어려워집니다. 창 생성 자체를 모든 창에 열어둘 필요가 있는지도 따로 검토해야 합니다.
프런트엔드와 Rust는 얇은 계약으로 연결한다
Tauri에서 Rust를 처음 만날 때는 모든 로직을 Rust로 옮겨야 한다고 느끼기 쉽습니다. 대부분의 앱은 그렇지 않습니다. UI 상태, 폼 검증의 즉각적인 피드백, 화면 전환, 네트워크 응답 표현은 SvelteKit에 남겨도 됩니다. Rust 쪽에는 운영체제에 가까운 작업과 신뢰 경계 안에서 검증해야 하는 작업만 둡니다.
예를 들어 앱 데이터 폴더를 프런트에 알려 주는 명령은 짧고 목적이 분명합니다.
// src-tauri/src/lib.rs
#[tauri::command]
fn app_data_dir(app: tauri::AppHandle) -> Result<String, String> {
app.path()
.app_data_dir()
.map(|path| path.to_string_lossy().to_string())
.map_err(|error| error.to_string())
}
pub fn run() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![app_data_dir])
.run(tauri::generate_context!())
.expect("error while running Tauri application");
}
프런트에서는 그 명령을 앱 실행 환경에서만 호출합니다. 같은 컴포넌트를 브라우저 미리보기에서도 쓰고 싶다면 Tauri 여부를 얇은 어댑터로 감싸는 편이 좋습니다.
// src/lib/platform/desktop.ts
import { browser } from '$app/environment';
export async function getAppDataDir(): Promise<string | null> {
if (!browser || !('__TAURI_INTERNALS__' in window)) return null;
const { invoke } = await import('@tauri-apps/api/core');
return invoke<string>('app_data_dir');
}
<script lang="ts">
import { onMount } from 'svelte';
import { getAppDataDir } from '$lib/platform/desktop';
let directory: string | null = null;
onMount(async () => {
directory = await getAppDataDir();
});
</script>
{#if directory}
<p>로컬 데이터는 <code>{directory}</code>에 저장됩니다.</p>
{:else}
<p>브라우저 미리보기에서는 로컬 앱 경로를 표시하지 않습니다.</p>
{/if}
여기서 중요한 것은 invoke를 서비스 곳곳에서 직접 부르지 않는 것입니다. desktop.ts 같은 모듈을 통과시키면 웹 미리보기, 테스트, 향후 Capacitor 확장에서도 분기점을 한 곳에 둘 수 있습니다. 모바일 WebView를 같이 고민한다면 SvelteKit + Capacitor에서 마주치는 웹뷰 경계와 Capacitor와 Tauri의 선택 기준도 같은 관점에서 읽을 만합니다.
파일 선택과 파일 접근은 서로 다른 일이다
Tauri 앱에 “파일 열기” 버튼을 넣었는데 권한 오류를 만나면, 대부분 파일 선택과 파일 접근을 같은 기능으로 생각한 데서 문제가 시작합니다. dialog 플러그인은 사용자가 파일을 고르게 합니다. fs 플러그인은 그 경로를 읽고 쓰게 합니다. 둘은 서로 다른 API이고, fs 작업에는 명령 허용과 경로 scope라는 두 층의 제한이 필요합니다.
먼저 필요한 플러그인을 추가합니다.
npm run tauri add dialog
npm run tauri add fs
기본 창이 앱 데이터의 문서만 읽고 쓰게 한다면 capability는 구체적인 명령과 경로를 함께 적는 편이 낫습니다.
// src-tauri/capabilities/main-editor.json
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "main-editor",
"description": "Capability for the local note editor",
"windows": ["main"],
"permissions": [
"dialog:allow-open",
"fs:allow-read-text-file",
{
"identifier": "fs:allow-write-text-file",
"allow": [{ "path": "$APPDATA/notes/*.md" }]
},
{
"identifier": "fs:allow-read-text-file",
"allow": [{ "path": "$APPDATA/notes/*.md" }]
}
]
}
위 설정은 예시일 뿐이며, 실제 permission identifier는 설치한 플러그인 버전이 생성한 schema와 문서에서 확인해야 합니다. 이 점을 강조하는 이유는 fs:default를 먼저 넣어 문제를 없애려는 유혹이 크기 때문입니다. 파일을 한 개 저장할 기능에 홈 전체를 읽고 쓰는 권한을 주면, 동작은 빨라져도 설계는 나빠집니다.
프런트에서 선택과 저장을 분리하면 사용자 경험도 더 명확해집니다.
import { open } from '@tauri-apps/plugin-dialog';
import { readTextFile, writeTextFile } from '@tauri-apps/plugin-fs';
export async function importMarkdown(): Promise<string | null> {
const path = await open({
multiple: false,
directory: false,
filters: [{ name: 'Markdown', extensions: ['md', 'mdx'] }]
});
if (typeof path !== 'string') return null;
return readTextFile(path);
}
export async function saveDraft(path: string, content: string) {
await writeTextFile(path, content);
}
사용자가 고른 임의의 파일을 계속 편집해야 하는 앱이라면 “처음 선택한 외부 파일”의 라이프사이클을 별도로 설계해야 합니다. 복잡도를 낮추는 실전적인 방법은 import 시 앱 데이터 폴더로 복사하고, 이후에는 $APPDATA/notes 안에서만 편집하는 것입니다. 이 방식은 scope를 좁히고 백업·동기화·삭제 정책도 한 곳에 모읍니다. 반면 사용자가 원본 파일을 직접 편집해야 하는 제품이라면, 선택된 경로의 재접근과 플랫폼별 파일 권한을 테스트 케이스로 다뤄야 합니다.
권한 오류는 설정을 넓히기 전에 질문을 줄이는 신호다
개발 중 not allowed 또는 promise rejection을 보면 모든 권한을 여는 선택을 하기 쉽습니다. 하지만 이 오류는 대개 좋은 질문을 던집니다. 이 창이 정말 이 API를 써야 하는가? 읽기만 하면 되는가, 쓰기도 필요한가? 디렉터리 전체가 필요한가, 앱 데이터 하위 한 폴더면 되는가? 파일 경로를 UI가 직접 받는 대신 Rust 명령에서 검증할 편이 나은가?
Tauri v2의 capability·permission·scope는 뒤의 글에서 더 깊게 다룹니다. Tauri v2 최소 권한 설계: capability·permission·scope에서는 파일 시스템, 외부 URL, 여러 창을 어떤 단위로 나눌지와 감사 체크리스트를 이어서 정리합니다. 여기서는 우선 권한 오류를 기능 실패가 아니라 설계 검토 지점으로 받아들이는 것만 기억하면 충분합니다.
개발 모드에서 끝내지 않는 검증 순서
앱이 켜진다는 사실은 기능 검증의 시작일 뿐입니다. 웹 프런트엔드와 네이티브 셸이 만나는 지점은 개발 모드와 패키지 모드에서 다르게 행동할 수 있어요. 다음 순서로 확인하면 원인을 좁히기 좋습니다.
npm run build가 정적 산출물을 만들고, 설정한frontendDist에index.html이 있는지 확인합니다.npm run tauri dev에서 창·라우팅·브라우저 미리보기 분기가 기대대로 움직이는지 봅니다.npm run tauri build로 만든 앱을 실행해 직접 URL 진입, 새로고침, 동적 라우트를 확인합니다.- 파일 선택, 읽기, 저장을 각각 한 번씩 실행해 권한 오류가 어느 단계에서 나는지 구분합니다.
- 도움말·로그인·환경설정처럼 별도 창이 있다면 메인 창의 파일 기능이 그 창에서 호출되지 않는지 확인합니다.
- macOS, Windows, Linux 중 실제 지원 플랫폼마다 최소 한 번은 패키지 실행을 확인합니다. Tauri는 OS WebView를 쓰므로 CSS와 입력 동작을 한 플랫폼의 결과만으로 일반화하면 안 됩니다.
특히 배포 직전에는 ‘개발 중인 Mac에서 실행된다’와 ‘다른 사용자의 운영체제에서 설치된다’를 같은 통과 기준으로 보지 않는 게 좋습니다. 코드 서명, 운영체제의 보안 경고, WebView 준비 상태, 파일 접근 정책은 전혀 다른 종류의 운영 문제입니다.
좋은 시작점은 기능이 적은 앱이 아니라 경계가 적은 앱이다
SvelteKit + Tauri로 첫 앱을 만들 때는 기능 목록을 빨리 늘리고 싶어집니다. 하지만 초기에 가장 좋은 선택은 앱의 경계를 명확히 하는 것입니다. 프런트는 정적 번들로 실행되고, 서버가 해야 할 일은 서버에 남아 있으며, 로컬 기능은 명시적인 Tauri API를 통해서만 지나가고, 파일 권한은 필요한 경로만 갖습니다.
이 구조가 잡히면 이후에 트레이, 자동 업데이트, 창 상태 복원, 로컬 데이터베이스, 모바일 확장 같은 기능을 추가해도 어디에 놓아야 하는지 판단하기 쉬워집니다. 반대로 이 경계가 흐린 상태에서 기능만 늘리면, 작은 데스크톱 앱도 곧 웹·Rust·운영체제 권한이 서로 얽힌 앱이 됩니다.
Tauri를 선택한 이유가 가벼운 배포물이든 더 좁은 네이티브 권한이든, 출발점은 같습니다. 앱이 사용자 기기에 무엇을 설치하고, 어떤 일을 요청하며, 어디까지 접근할 수 있는지를 코드와 설정에서 같은 언어로 설명할 수 있어야 합니다.