Electron 대체 프레임워크: Tauri·Wails·Neutralinojs를 고르는 기준
Electron을 무조건 버리거나 Tauri를 무조건 고르기 전에 봐야 할 기준을 정리했습니다. 번들 크기보다 WebView 일관성, 네이티브 경계, 팀 언어, 보안 모델, 배포·운영 비용을 중심으로 Tauri·Wails·Neutralinojs를 비교합니다.
Electron의 대안을 찾는 이유는 대개 하나로 시작합니다. 앱 파일이 크다. 메모리를 많이 쓴다. Node.js가 아닌 언어로 네이티브 코드를 쓰고 싶다. 혹은 “웹 코드베이스를 데스크톱으로도 내고 싶다”는 요구가 생겼다. 그런데 답을 프레임워크 이름 하나로 고르면, 대개 출시 뒤에 다른 비용을 만나게 됩니다.
Tauri·Wails·Neutralinojs는 모두 HTML·CSS·JavaScript로 화면을 만들 수 있고, 운영체제 기능은 별도 네이티브 계층에 맡깁니다. 하지만 그 경계를 설계하는 언어와 보안 모델, 실행 환경, 문제가 생겼을 때의 디버깅 방식은 꽤 다릅니다. 작은 설치 파일은 좋은 결과일 수 있지만, 선택의 출발점으로는 부족합니다.
이 글은 “Electron은 무겁고 Tauri가 가볍다”라는 결론을 반복하지 않습니다. 어떤 제품에서 renderer 일관성이 더 중요한지, Rust와 Go 중 어떤 운영 언어가 맞는지, 시스템 WebView를 받아들일 준비가 되었는지를 먼저 정리합니다. Electron 자체의 선택 기준은 Tauri vs Electron: SvelteKit 데스크톱 앱 선택 기준, 모바일까지 하나의 코드베이스로 넓히는 문제는 Capacitor vs Tauri: 하나의 웹 코드베이스를 모바일·데스크톱 앱으로 확장하는 선택 기준에서 이어서 볼 수 있습니다.
먼저 버리고 싶은 비용의 이름을 붙인다
Electron을 “대체”한다는 말에는 서로 다른 네 가지 요구가 섞여 있습니다.
| 실제 요구 | 먼저 확인할 질문 | 유력한 방향 |
|---|---|---|
| 설치 파일과 초기 메모리 | 브라우저 엔진을 앱마다 번들해야 하는가? | Tauri·Wails·Neutralinojs |
| 네이티브 기능의 처리 성능 | 무거운 작업을 어떤 언어와 라이브러리로 할 것인가? | Tauri(Rust)·Wails(Go) |
| 프론트엔드 렌더링의 일관성 | 모든 OS에서 같은 Chromium 동작이 꼭 필요한가? | Electron 유지 가능성 |
| Node 의존성 축소 | renderer 밖 로직을 무엇으로 옮길 수 있는가? | Tauri·Wails·Neutralinojs |
여기서 첫 번째와 세 번째는 종종 충돌합니다. Electron은 Chromium과 Node.js를 함께 번들하는 구조라서 앱 패키지가 커질 수 있습니다. 대신 앱이 실행하는 브라우저 엔진이 비교적 일관적입니다. 반대로 세 대안은 운영체제의 WebView를 활용하는 방식이어서 앱에 브라우저 엔진을 함께 넣지 않는 장점이 있습니다. 하지만 Windows·macOS·Linux에서 사용자가 실제로 가진 WebView 버전과 엔진 차이가 제품 품질에 영향을 줄 수 있습니다.
따라서 비교할 단위는 “프레임워크의 평균 벤치마크”가 아니라 내 제품의 위험입니다. WebRTC, 고급 Canvas, 최신 CSS, 웹 기반 에디터, 브라우저 확장 API처럼 엔진 차이에 민감한 기능이 있나요? 반대로 로컬 파일을 빠르게 처리하는 유틸리티, 사내용 입력 도구, 작은 생산성 앱이라면 번들 Chromium이 주는 일관성이 과한 비용일 수 있습니다.
세 프레임워크는 같은 WebView 앱이 아니다
| 항목 | Tauri | Wails | Neutralinojs |
|---|---|---|---|
| 네이티브 계층의 주 언어 | Rust | Go | C++ core + 확장 언어 자유 |
| 화면 엔진 | 시스템 WebView | 시스템 WebView | 시스템 WebView 또는 Chrome 모드 |
| 프론트엔드→네이티브 연결 | command·plugin·capability | Go 메서드 binding·runtime | WebSocket 기반 Native API·extension |
| 잘 맞는 팀 | Rust를 받아들일 수 있고 권한 설계를 중시하는 팀 | Go 서비스/백엔드 경험이 강한 팀 | 작은 데스크톱 도구를 JS 중심으로 빨리 실험하는 팀 |
| 가장 먼저 검증할 위험 | OS별 WebView와 capability 설정 | binding 표면과 Go 실행 모델 | native API allowlist와 extension·WebSocket 경계 |
표는 결론이 아니라 질문의 순서입니다. 셋 다 프론트엔드에는 React·Svelte·Vue 또는 순수 웹을 쓸 수 있어요. 진짜 차이는 UI 바깥에서 발생합니다. 파일 시스템, 트레이, 자동 업데이트, DB, 하드웨어, 긴 작업, OS 이벤트를 어떤 형태로 호출하고 테스트할지가 달라집니다.
Tauri: 권한 모델까지 제품 코드로 다루고 싶을 때
Tauri는 JavaScript 프론트엔드와 Rust 코어를 연결합니다. Tauri v2의 핵심 특징은 프론트엔드에 노출되는 API를 capability와 permission으로 제한할 수 있다는 점입니다. 기본적으로 번들된 앱 코드가 접근하는 범위를 전제로 하고, 창·WebView·origin마다 허용할 권한을 구성할 수 있습니다.
권한을 파일 하나에 모아 두면 처음에는 번거롭습니다. 그러나 원격 콘텐츠 창과 메인 앱 창이 섞이거나 플러그인을 붙이는 순간, “이 기능을 어느 화면에 노출해도 되는가”를 코드 리뷰 대상으로 올릴 수 있습니다.
// src-tauri/capabilities/main.json
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "main-window",
"description": "메인 앱 창에만 필요한 최소 권한",
"windows": ["main"],
"permissions": [
"core:default",
"dialog:allow-open",
"dialog:allow-save",
{
"identifier": "fs:allow-read-text-file",
"allow": [{ "path": "$HOME/Documents/**" }]
}
]
}
구체적인 permission 식별자와 schema 경로는 사용하는 Tauri·plugin 버전에 맞춰 공식 문서에서 생성된 값을 확인해야 합니다. 예시의 목적은 문법 암기가 아니라, “파일 시스템 전체”가 아닌 “사용자 문서 폴더의 텍스트 읽기”처럼 범위를 표현하는 습관입니다.
Rust command도 renderer가 호출할 수 있는 제품 API로 설계합니다. command 하나가 범용 shell이나 범용 파일 API가 되지 않게 하는 편이 좋습니다.
// src-tauri/src/lib.rs
#[tauri::command]
fn normalize_note_title(title: String) -> Result<String, String> {
let trimmed = title.trim();
if trimmed.is_empty() {
return Err("제목을 입력해 주세요.".into());
}
Ok(trimmed.chars().take(80).collect())
}
pub fn run() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![normalize_note_title])
.run(tauri::generate_context!())
.expect("error while running Tauri application");
}
// src/lib/native/notes.ts
import { invoke } from '@tauri-apps/api/core'
export function normalizeNoteTitle(title: string) {
return invoke<string>('normalize_note_title', { title })
}
Tauri가 맞는 이유는 Rust가 더 빠르다는 한 문장이 아닙니다. 파일 hashing, 이미지 처리, 로컬 동기화처럼 네이티브 작업이 제품의 핵심이고, 권한을 좁게 관리하고 싶고, Rust 코드의 리뷰·빌드·디버깅을 운영할 수 있을 때 설득력이 있습니다. 반대로 팀이 Rust를 전혀 유지할 수 없는데 설치 크기 하나만 보고 고르면, 네이티브 경계가 병목이 될 수 있습니다.
Wails: Go 서비스 모델을 데스크톱에 가져오고 싶을 때
Wails는 Go 메서드를 프론트엔드에서 호출할 수 있게 binding을 생성합니다. 이미 Go로 동기화 엔진, 로컬 서버, 암호화·파일 처리, 도메인 로직을 운영하거나 백엔드 팀이 Go에 익숙하다면 자연스러운 선택이 됩니다. frontend는 “Go 백엔드와 통신한다”는 모델로 이해하기 쉬워요.
// app.go
package main
import (
"context"
"fmt"
"strings"
)
type App struct {
ctx context.Context
}
func NewApp() *App { return &App{} }
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
}
func (a *App) NormalizeNoteTitle(title string) (string, error) {
value := strings.TrimSpace(title)
if value == "" {
return "", fmt.Errorf("제목을 입력해 주세요")
}
runes := []rune(value)
if len(runes) > 80 {
runes = runes[:80]
}
return string(runes), nil
}
Wails 개발 모드에서 생성되는 binding을 UI가 직접 가져다 쓰는 패턴은 편합니다. 다만 생성물의 경로를 UI 전체에 흩뿌리기보다, 작은 adapter로 감싸면 프레임워크 교체나 테스트가 쉬워집니다.
// frontend/src/lib/native/notes.ts
import { NormalizeNoteTitle } from '../../wailsjs/go/main/App'
export async function normalizeNoteTitle(title: string) {
return NormalizeNoteTitle(title)
}
Go binding이 있다고 해서 모든 exported 메서드를 UI에 노출할 필요는 없습니다. App 구조체는 프론트엔드 API의 표면이 될 수 있으므로, 데이터베이스 핸들·운영용 진단·환경 변수 접근 같은 메서드는 별도 내부 서비스에 두고 UI용 메서드만 좁혀 두는 편이 안전합니다. UI 호출에는 입력 검증, 현재 창·사용자 상태 확인, 파일 경로 제한 같은 서버 API의 습관을 그대로 가져오세요.
Wails는 “Tauri인데 Rust 대신 Go”라고만 보면 아쉬운 선택입니다. Go의 goroutine, 표준 라이브러리, 기존 서비스 코드, 배포 도구가 팀의 강점이라면 데스크톱 앱의 로컬 계층도 그 강점을 이어받을 수 있습니다. 반면 WebView의 OS별 차이와 배포 서명, 네이티브 기능의 플랫폼별 검증은 Electron을 떠난 이상 여전히 해야 합니다.
Neutralinojs: 작고 단순한 도구의 경계를 선명하게 할 때
Neutralinojs는 작은 core와 시스템 WebView를 사용하고, 내장 JavaScript client가 로컬 WebSocket을 통해 native API를 호출하는 구조입니다. 필요하면 extension을 다른 언어로 작성할 수 있어요. 작은 데스크톱 유틸리티, 내부 도구, 가벼운 실험처럼 UI와 몇 개의 OS 기능만 필요할 때 매력적입니다.
하지만 “JavaScript만 쓰니 권한 설계도 간단하다”는 뜻은 아닙니다. native API는 파일 시스템·프로세스·창처럼 강한 기능을 포함할 수 있습니다. allowlist와 blocklist를 제품 요구에 맞춰 명시해야 합니다.
// neutralino.config.json
{
"applicationId": "com.example.notes",
"defaultMode": "window",
"enableNativeAPI": true,
"tokenSecurity": "one-time",
"nativeAllowList": [
"app.*",
"window.*",
"filesystem.readFile",
"filesystem.writeFile",
"os.showOpenDialog",
"os.showSaveDialog"
],
"nativeBlockList": [
"os.execCommand",
"extensions.*"
]
}
이 구성에서 눈여겨볼 것은 os.execCommand를 막았다는 점입니다. 사용자가 “파일을 저장하고 열기”를 원한다는 요구가 곧 “renderer가 임의 명령을 실행해도 된다”는 요구는 아니에요. 필요한 native API만 켜고, 확장이 필요할 때도 extension의 입력·인증·로그를 따로 설계해야 합니다.
// resources/js/notes.ts
import '@neutralinojs/lib'
export async function saveNote(name: string, content: string) {
const safeName = name.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 80)
const selected = await Neutralino.os.showSaveDialog('메모 저장', {
defaultPath: `${safeName}.md`
})
if (!selected) return { saved: false }
await Neutralino.filesystem.writeFile(selected, content)
return { saved: true }
}
여기에서도 실제 제품은 파일 경로와 확장자·크기 제한을 별도로 검토해야 합니다. 핵심은 Neutralinojs를 선택했다는 사실이 안전한 기본값을 자동으로 보장하지 않는다는 것입니다. local WebSocket과 토큰, native API allowlist가 제품의 권한 모델이에요.
시스템 WebView는 비용을 없애지 않고 위치를 바꾼다
Tauri·Wails·Neutralinojs가 브라우저 엔진을 앱마다 번들하지 않는 것은 분명한 장점입니다. 그러나 시스템 WebView는 사용자의 OS 업데이트와 함께 움직입니다. 운영체제·WebView provider·Linux 배포판에 따라 CSS, 입력기, 미디어, GPU, Web API, 디버깅 도구에서 차이를 만날 수 있습니다.
이 차이를 과장할 필요는 없어요. 일반적인 폼, 목록, 설정, 로컬 CRUD 화면은 대체로 잘 맞습니다. 다만 제품의 핵심 경험이 웹 엔진의 최신 기능이나 픽셀 단위의 일관성에 기대고 있다면 PoC에서 반드시 실제 OS 조합을 확인해야 합니다.
| 기능 | PoC에서 반드시 할 일 |
|---|---|
| 한글·일본어·중국어 입력 | 조합 중 취소, 포커스 이동, 단축키 충돌을 실제 키보드로 확인 |
| 파일 드래그 앤 드롭 | 경로·권한·대용량 파일·취소를 OS별로 확인 |
| 오디오·비디오·WebRTC | 권한 팝업, 장치 전환, 백그라운드/절전 복귀를 확인 |
| Canvas·에디터 | 긴 문서, 고DPI, 확대·축소, GPU fallback을 측정 |
| 자동 업데이트 | 서명된 release 설치→업데이트→롤백을 각 플랫폼에서 수행 |
| 오프라인 시작 | 네트워크가 끊긴 상태에서 첫 화면과 로컬 데이터 복구를 확인 |
프레임워크를 바꾸기 전에는 “hello world 패키지 크기”보다 위 작업을 대표 화면으로 돌려 보세요. 제품의 네이티브 경계와 WebView 경계가 가장 많이 드러나는 테스트이기 때문입니다. Electron은 이 검증을 덜 하게 해 주는 선택일 수 있고, 대안 프레임워크는 그 대가로 설치·업데이트 비용을 줄이는 선택일 수 있습니다.
마이그레이션은 renderer부터 옮기지 않는다
기존 Electron 앱을 Tauri나 Wails로 옮길 때 가장 위험한 접근은 main process 코드를 한 번에 번역하는 것입니다. ipcRenderer.invoke('fs:write') 같은 범용 API를 Tauri command나 Go method로 그대로 옮기면, 프레임워크만 바뀌고 경계는 그대로 남습니다.
먼저 Electron의 bridge를 제품 동사로 정리하세요. 이미 좁은 window.desktop.saveExport()와 window.desktop.openSettings()가 있다면, renderer는 거의 손대지 않고 adapter만 교체할 수 있습니다.
// renderer가 의존할 공통 계약
export interface DesktopPlatform {
saveExport(input: { name: string; content: string }): Promise<{ saved: boolean }>
getAppVersion(): Promise<string>
openDocumentation(): Promise<void>
}
// electron-platform.ts
export const electronPlatform: DesktopPlatform = {
saveExport: (input) => window.desktop.saveExport(input),
getAppVersion: () => window.desktop.getAppVersion(),
openDocumentation: () => window.desktop.openDocumentation()
}
// tauri-platform.ts
import { invoke } from '@tauri-apps/api/core'
import { openUrl } from '@tauri-apps/plugin-opener'
export const tauriPlatform: DesktopPlatform = {
saveExport: (input) => invoke('save_export', { input }),
getAppVersion: () => invoke('app_version'),
openDocumentation: () => openUrl('https://docs.example.com')
}
이 구조의 장점은 이식성이 아니라 검증 가능성입니다. UI가 실제로 요구하는 데스크톱 기능 목록이 드러나고, 프레임워크별 구현이 그 목록보다 커지는 순간을 알 수 있습니다. PWA, Capacitor, Tauri, Electron을 함께 운영해야 하는 제품이라면 특히 유용합니다.
4주짜리 의사결정 PoC의 최소 범위
PoC는 새 프레임워크의 CLI를 써보는 프로젝트가 아닙니다. 배포 후 가장 비싼 실패를 미리 찾는 작업입니다.
- 첫 주 — 대표 흐름 하나를 고른다. 로그인, 로컬 파일 열기·저장, 긴 목록·에디터, 업데이트 확인 중 실제 제품에서 중요한 한 흐름을 구현합니다.
- 둘째 주 — 네이티브 경계를 만든다. 파일 권한, 키체인, 트레이, deep link, OS 알림 중 최소 두 가지를 실제 코드로 붙입니다. mock으로 넘기지 않습니다.
- 셋째 주 — 운영체제 매트릭스를 돌린다. 지원할 Windows·macOS·Linux 조합에서 설치, 오프라인 시작, 절전 복귀, 입력기, 로그 수집을 확인합니다.
- 넷째 주 — 배포와 장애를 연습한다. 서명, update, crash report, 기존 데이터 마이그레이션, downgrade 또는 rollback을 한 번씩 실행합니다.
성과 지표도 미리 정합니다. cold start, idle memory, 대표 작업 시간, 패키지 크기만 재지 말고, 빌드 시간과 CI 시간, 버그 재현 난이도, 플랫폼별 수정 횟수, 새 팀원이 네이티브 기능 하나를 추가하는 데 걸린 시간도 기록하세요. 이 수치들이 장기 비용을 더 잘 설명합니다.
선택을 위한 짧은 결정 트리
모든 OS에서 Chromium 기반의 같은 렌더링이 핵심인가?
├─ 예 → Electron을 유지하거나 별도 Chromium 전략을 검토한다.
└─ 아니오 → 시스템 WebView의 PoC를 한다.
│
├─ Rust로 로컬 작업을 다루고 capability 기반 권한을 명시하고 싶은가?
│ └─ 예 → Tauri
│
├─ Go 서비스 코드·인력이 있고 Go binding이 자연스러운가?
│ └─ 예 → Wails
│
└─ 작은 JS 중심 도구이며 제한된 native API만 필요한가?
└─ 예 → Neutralinojs
이 트리는 절대적인 처방이 아닙니다. Tauri에서도 Rust를 적게 쓸 수 있고, Wails에도 복잡한 네이티브 앱을 만들 수 있으며, Neutralinojs도 extension으로 확장할 수 있습니다. 다만 프레임워크의 장점을 가장 적은 억지로 얻는 경로를 보여 줍니다. 핵심 제품 역량과 프레임워크가 요구하는 운영 역량이 맞을수록, 설치 파일이 작아진 것보다 훨씬 큰 이득을 얻습니다.
마무리: 경량화보다 운영할 수 있는 경계를 고른다
Electron을 떠날 이유는 충분할 수 있습니다. 하지만 “가벼운 앱”의 정의를 패키지 하나의 MB 수치로 정하면, WebView 차이·권한 모델·빌드 툴체인·배포 책임을 나중에 발견하게 됩니다.
Tauri는 Rust와 capability를 통해 권한이 분명한 네이티브 경계를 만들고 싶은 팀에 잘 맞습니다. Wails는 Go로 이미 쌓은 서비스 역량을 데스크톱으로 자연스럽게 연결하고 싶은 팀에 맞습니다. Neutralinojs는 기능이 작고 경계가 선명한 데스크톱 도구를 JS 중심으로 만들 때 강점이 있습니다. 그리고 Chromium 일관성이 제품의 핵심이라면 Electron은 대체해야 할 실패가 아니라, 비용을 알고 선택할 수 있는 안정적인 기반입니다.
좋은 선택은 어느 프레임워크가 “승자”인지가 아니라, 내 앱의 가장 위험한 화면과 가장 강한 OS 권한을 출시 전에 실제로 검증하는 데서 나옵니다.