Tauri로 30분 만에 3.8MB 데스크톱 앱 — SaaS를 접고 알게 된 것
SaaS로 풀려다 멈춰선 자리
친구들과 매달 나눠 내는 작은 구독료가 있습니다. 누가 얼마를 보냈는지 확인하고 매칭하는 일이 은근히 번거로워서 check-the-deposit-history라는 토이 프로젝트를 만들었습니다. NestJS 10이 API와 cron을 맡고, Vite 위에 올린 React 18이 화면을 그리고, MongoDB가 데이터를 받는 pnpm 모노레포였습니다. 농협 입금 알림 메일을 Gmail API로 받아 Puppeteer로 비밀번호 보호된 첨부 HTML을 파싱하고, MongoDB에 적재한 다음 Slack으로 알림을 보내는 흐름입니다.
한동안 머릿속으로는 이 작은 도구를 SaaS로 풀어볼 수 있지 않을까 막연히 굴려보고 있었습니다. 비슷한 정산 자동화가 필요한 다른 1인 사장님들에게도 쓸모가 있지 않을까 하는, 검토 이전 단계의 생각이었습니다. 그런데 그림을 구체적으로 그려보려고 할수록 도메인 자체가 그 방향을 자꾸 거부했습니다. 이 글은 그 거부의 신호를 인정하고 결국 Tauri 기반 데스크톱 앱으로 옮겨가, 30분 만에 3.8MB짜리 .dmg가 손에 쥐어지기까지의 기록입니다.
도메인이 SaaS와 맞지 않는다는 깨달음
SaaS로 풀어볼까 진지하게 검토할수록 한 가지 사실이 자꾸 걸렸습니다. 농협 입금 알림 메일은 결국 본인 Gmail 받은편지함에 들어옵니다. 멀티 유저로 확장한다는 말은 각자의 농협 거래내역 메일을 우리 서버가 가져와 보관한다는 뜻이고, 그 순간 개인 금융정보에 대한 책임이 따라붙습니다.
Slack 봇 토큰과 송금 받는 계좌도 본인 워크스페이스에 묶여 있습니다. 멀티 테넌시를 흉내 내려면 사용자마다 자기 워크스페이스에 봇을 설치하게 만들어야 하는데, 그 사용자 경험을 생각해보면 토이 프로젝트의 범위를 한참 넘는 일이었습니다. 곰곰이 생각해보니 "1인 사용 도구"가 이 도메인의 가장 자연스러운 결론이라는 생각이 들었습니다. 인정하고 나니 오히려 마음이 가벼워졌습니다.
데스크톱으로 선회하면서 Python을 떠올린 이유
1인 도구라면 굳이 서버에 띄울 이유가 없습니다. 자연스럽게 데스크톱 앱을 검토하기 시작했고, 1차 후보는 Python이었습니다. 이유는 단순합니다. "Python에 PyInstaller 붙이면 exe가 쉽게 나온다"는 통설을 한 번쯤 들어본 적이 있었고, 무엇보다 익숙한 언어였습니다. SQLite는 표준 라이브러리에 박혀 있으니 작은 데이터 저장도 손이 갈 일이 없을 것 같았습니다.
여기까지가 머릿속으로 그린 그림이었습니다. 사실 코드 한 줄 짜기 전, 조사 단계에서 그림이 무너졌습니다.
조사해보니 Mac은 다른 이야기였습니다
맥북을 메인으로 쓰는 입장에서 mac 배포 흐름을 먼저 확인해야 했습니다. 그런데 mac은 .app + .dmg + Gatekeeper + notarization이라는 절차가 사실상 필수에 가까웠습니다. py2app이나 PyInstaller로 .app을 만드는 것 자체는 됩니다. 다만 그렇게 만든 앱을 처음 실행하면 Gatekeeper가 막아섭니다. 우회를 강요하지 않으려면 Apple Developer ID(연 $99) 서명 + notarytool submit 통과가 사실상 전제 조건이었습니다.
Hardened runtime 옵션을 잘못 잡으면 Python의 dynamic import가 깨집니다. py2app은 의존성 자동 추적이 그렇게 신뢰가 가지 않는다는 평이 많았고, 누락된 모듈을 매번 손으로 채워 넣어야 한다는 글도 적지 않았습니다. 거기에 더해 Puppeteer를 Playwright의 Python 바인딩으로 옮긴다면 Chromium까지 같이 번들되어 결과물이 200MB를 가볍게 넘기게 됩니다.
결론은 다소 허무했습니다. "Python으로 mac .dmg를 만들면 가장 간단하다"는 통설은 적어도 제 케이스에서는 사실이 아니었습니다. 통설이 가리키던 풍경은 Windows의 exe였지, mac의 .dmg는 처음부터 다른 세계였습니다. 익숙한 언어라는 이유 하나로 Python을 1차에 두었던 결정을 돌이켜보니, 익숙함이 가장 큰 함정이 되기도 한다는 생각이 들었습니다.
Electron도 검토했지만
그러면 Electron은 어떨까 싶었습니다. 코드 재활용성 측면에서는 사실 최고의 선택이었습니다. 기존 NestJS 백엔드와 React 프론트엔드를 거의 그대로 옮겨갈 수 있을 정도였습니다. VS Code, Slack, Discord, Notion이 Electron을 쓰는 이유가 분명히 있다는 점도 알고 있었습니다.
다만 Hello World 수준의 앱이 80~150MB부터 시작하고, idle 상태에서도 RAM 200MB 이상을 잡아먹는 구조가 부담스러웠습니다. Chromium과 Node.js를 둘 다 번들로 들고 다니니 어쩔 수 없는 비용입니다. 메이저 앱이라면 충분히 정당화되는 무게지만, 친구들과 정산을 맞춰보려는 1인용 도구에는 과한 옷이었습니다.
Tauri로 옮겨가고, 30분 만에 .dmg가 나왔습니다
그래서 Tauri를 골랐습니다. 결정의 근거는 단순합니다. Tauri는 OS에 내장된 webview를 그대로 사용합니다. macOS에서는 WKWebView, Windows에서는 WebView2, Linux에서는 WebKitGTK가 그 역할을 합니다. Chromium을 번들하지 않으니 결과물의 크기가 한 자릿수 MB로 떨어집니다. 백엔드는 Rust로 짜고 단일 네이티브 바이너리로 묶입니다. Hello World 기준 5~15MB라는 숫자가 자주 언급됩니다.
물론 트레이드오프가 없는 선택은 없습니다. webview가 OS마다 다르므로 Safari와 Edge 사이에서 미세한 렌더링 차이가 발생할 수 있고, Rust의 러닝커브도 분명히 존재합니다. 다만 이 토이 프로젝트는 mac 한 곳만 잘 돌면 충분했고, Rust는 핑계 김에 한 번 익혀두고 싶은 언어이기도 했습니다.
실제로 30분 안에 한 일
이론은 충분히 들어봤으니 직접 손을 움직여보기로 했습니다. 결과부터 적자면 스캐폴드부터 .dmg 산출까지 30분 안에 끝났습니다. 그 과정을 거의 실제 순서대로 적어보겠습니다.
먼저 Rust 툴체인을 설치합니다. 2분 남짓 걸렸습니다.
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
다음으로 private 레포를 만들고 Tauri 2 + React-TS 템플릿으로 스캐폴드를 잡습니다.
gh repo create check-the-deposit-history-tauri --private
pnpm create tauri-app@latest check-the-deposit-history-tauri \
--template react-ts --manager pnpm \
--identifier com.jeongkichang.deposit-tracker -y
SQLite를 붙이기 위해 src-tauri/Cargo.toml에 rusqlite를 bundled 피처로 추가했습니다. 시스템 SQLite 라이브러리에 의존하지 않고 정적으로 묶기 위함입니다.
rusqlite = { version = "0.32", features = ["bundled"] }
그다음 lib.rs에 데이터베이스 연결 상태를 확인하는 ping_db 커맨드를 등록했습니다. 앱 데이터 디렉토리에 SQLite 파일을 만들고, 경로와 SQLite 버전을 함께 돌려주는 단순한 커맨드입니다.
#[tauri::command]
fn ping_db(app: tauri::AppHandle) -> Result<PingResult, String> {
let dir = app
.path()
.app_data_dir()
.map_err(|e| e.to_string())?;
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let db_path = dir.join("deposit.sqlite");
let conn = Connection::open(&db_path).map_err(|e| e.to_string())?;
let version: String = conn
.query_row("SELECT sqlite_version()", [], |row| row.get(0))
.map_err(|e| e.to_string())?;
Ok(PingResult {
path: conn.path().map(|p| p.to_string()),
version,
})
}
프런트엔드에서는 App.tsx에서 invoke로 이 커맨드를 호출했습니다.
import { invoke } from "@tauri-apps/api/core";
type PingResult = { path: string | null; version: string };
const result = await invoke<PingResult>("ping_db");
console.log(result.version, result.path);
마지막으로 빌드입니다.
pnpm tauri build
증분 빌드 기준 13.45초에 끝났고, 산출물 두 개가 떨어졌습니다.
src-tauri/target/release/bundle/macos/check-the-deposit-history-tauri.app
src-tauri/target/release/bundle/dmg/check-the-deposit-history-tauri_0.1.0_aarch64.dmg (3.8MB)
.dmg가 3.8MB라는 숫자를 보고 잠시 멍해졌습니다. Electron으로 시작했다면 두 자릿수 MB의 십수 배는 잡고 들어갔을 텐데, 같은 React 화면을 띄우는 셸이 이 정도로 가벼울 수 있다는 사실이 새삼스러웠습니다.
만난 함정 — rusqlite Connection::path의 시그니처 변경
모든 게 매끄럽지는 않았습니다. ping_db를 처음 작성했을 때 컴파일 에러를 만났습니다.
error[E0599]: no method named `to_string_lossy` found for reference `&str`
원인은 단순했지만 검색으로는 잘 잡히지 않았습니다. rusqlite 0.32부터 Connection::path()의 반환 타입이 바뀌었습니다. 이전에는 Option<&Path>를 돌려주었기 때문에 to_string_lossy()를 자연스럽게 호출할 수 있었지만, 0.32 이후에는 Option<&str>로 바뀌었습니다. 이미 문자열 슬라이스이므로 to_string_lossy()가 존재할 리 없었던 것입니다.
고치는 일 자체는 한 줄이었습니다.
// Before — 컴파일 에러
.map(|p| p.to_string_lossy().into_owned())
// After
.map(|p| p.to_string())
이런 종류의 함정은 공식 release notes를 깊이 들어가야 발견되는 경우가 많습니다. 익숙한 API라고 가볍게 옮겨 적었다가 한참을 들여다보게 되는 패턴이라, 작은 메모로라도 남겨두는 편이 다음 사람에게 도움이 될 듯합니다.
남은 과제와 한 줄 결론
30분짜리 스캐폴드는 어디까지나 출발선입니다. 기존 React 페이지 8개(Dashboard, Deposits, Orders, Settings, Reports, NotificationCenter, Landing, Integrations)는 지금 mock 데이터로만 그려져 있고, 이걸 그대로 이식해야 합니다. Gmail OAuth는 Rust 쪽 oauth2와 reqwest로 다시 짜야 하고, 농협 HTML의 비밀번호 해제는 Puppeteer 대신 Tauri의 hidden webview를 띄워 처리하면 의존성이 한층 깔끔해질 듯합니다. 외부 배포까지 가려면 Universal binary와 Developer ID 서명, notarization도 결국 한 번은 마주해야 할 일입니다.
돌이켜 생각해보면 이번 결정의 핵심은 두 가지입니다. 1인 사용이 자연스러운 도메인이라면 SaaS화를 검토 단계에서 일찍 접고 데스크톱으로 가는 게 솔직한 답이었고, 데스크톱이라면 Python 통설을 한 번쯤 의심해볼 가치가 있었습니다. 3.8MB로 끝나는 결과물이 실제로 가능하다는 사실은, 통설을 뒤늦게라도 확인해본 덕에 알게 된 작은 수확이었습니다.
관련 글
실행 파일은 왜 타깃 OS에서 빌드해야 할까 — PyInstaller·Electron 패키징 원리
PyInstaller, Node SEA, Electron은 코드를 번역하는 컴파일러가 아니라 런타임을 감싸는 포장기입니다. 인터프리터·네이티브 확장·부트로더가 모두 OS에 묶여 있어, 실행 파일을 타깃 OS에서 빌드해야 하는 이유를 정리했습니다.
Bun으로 갈아탈까? 실제 모노레포로 검증해본 결과
1633개 패키지를 가진 실제 프로덕션 모노레포에서 Bun 마이그레이션을 검토했습니다. 네이티브 모듈 호환성부터 체감 속도 예측, 워크트리 설치 시간 실측까지, 벤치마크 숫자가 아닌 현실적인 분석을 정리했습니다.
소규모 서비스 개발: MSA 대신 모놀리식 아키텍처를 선택하는 것이 합리적일 수 있다는 생각
소규모 서비스 개발 시 MSA 대신 모놀리식 아키텍처가 합리적일 수 있습니다. 낮은 트래픽과 간단한 기능, 효율적인 비용 관리를 위한 현명한 선택 이유를 알아봅니다.