백엔드 개발자의 macOS 데스크톱 앱 도전기: Rust와 Tauri v2로 만든 Zenith 개발기
개발하다 쌓이는 파일들을 정리하려고 macOS 앱 Zenith를 만들었습니다. Rust와 Tauri v2를 사용하면서 백엔드 개발과 비슷하다고 느낀 점, 새로 알게 된 개념들, AI의 도움을 받은 경험을 정리했습니다.
백엔드 개발자의 macOS 데스크톱 앱 도전기: Rust와 Tauri v2로 만든 Zenith 개발기
개인적으로 개발 도구를 이것저것 사용하다 보니 맥북에 쌓이는 파일이 꽤 많아졌습니다. 처음에는 필요할 때마다 직접 정리했는데, 점점 귀찮아져서 아예 앱으로 만들어봤습니다.
이름은 Zenith입니다. Rust와 Tauri v2, Svelte를 사용했고, 만드는 과정에서 AI의 도움도 많이 받았습니다.
평소에는 주로 백엔드를 개발하다 보니 데스크톱 앱은 익숙하지 않았는데요. 생각보다 비슷한 점도 많았고, 직접 만들어보면서 신선하고 재밌다고 느낀 점도 있어서 정리해보려고 합니다.
왜 만들었나
로컬에서 Ollama 모델을 실행하거나 도커를 사용하고, 여러 프로젝트를 빌드하다 보면 사용하지 않는 파일들도 수백 MB에서 수 GB씩 자리를 차지하곤 했습니다. target, node_modules, 도커 볼륨도 그렇고, AI 코딩 도구가 임시 환경에서 작업하면서 만드는 파일들도 정리가 필요했습니다.
또 개인 구독으로 여러 LLM을 사용하고 있는데, 사용량을 한눈에 보고 싶었습니다. 특정 조건에서는 맥북이 쉬지 않고 계속 작업을 이어갔으면 했고요. 예를 들면 터미널에서 Codex가 동작 중이고 배터리가 충전 중인 경우입니다. 배터리 관리도 같이 하고 싶었습니다!
처음에는 여러 도구를 따로 사용하거나 스크립트를 짜서 정리했습니다. 이것도 매번 하려니 귀찮아져서 앱 구매를 고려했는데, 생각보다 비용이 들어서 직접 만들기(+ LLM 도움 받기)를 선택했습니다.
Rust와 Tauri v2를 선택한 이유
이전에 Go 기반의 데스크톱 프레임워크인 Wails로 간단한 앱을 만들어본 적이 있습니다. Go가 익숙하기도 했지만, 이번에는 Rust에서 macOS 네이티브 API를 다뤄보고 싶어서 Tauri를 선택했습니다. 화면은 기존에 사용하던 Svelte로 구성했습니다.
Electron도 있지만, Chromium과 Node.js 런타임을 함께 배포한다는 점이 조금 걸렸습니다. 제가 만들려는 건 백그라운드에 켜두는 맥 관리 앱이다 보니 앱 자체도 가벼웠으면 했거든요.
Tauri는 OS에서 제공하는 웹뷰를 사용합니다. macOS에서는 WKWebView를 사용하기 때문에 브라우저 엔진을 앱에 따로 포함하지 않아도 됩니다. 물론 실제 메모리 사용량은 구현에 따라 달라지겠지만, 제가 만들려는 앱에는 이 구성이 잘 맞겠다고 생각했습니다.
구조를 간단히 보면 아래와 같습니다.
Svelte UI (WebView)
- 스토리지 대시보드, 트레이 팝업
↕ IPC (invoke, event, channel)
Rust / Tauri
- 파일 스캔과 정리
- 시스템 상태 확인
- macOS 네이티브 API 호출
웹 개발과 비슷했던 점, 달랐던 점
OS 기능을 추상화해서 제공한다는 점
백엔드를 개발하다 보면 운영체제마다 다른 네트워크 I/O 방식을 접하게 됩니다. Linux의 epoll, macOS의 kqueue, Windows의 IOCP 같은 것들인데요. 보통은 런타임이나 라이브러리가 이를 추상화해주기 때문에 매번 OS별로 직접 구현할 필요는 없습니다.
Tauri도 이와 비슷하게 느껴졌습니다. 각 OS의 창 제어나 시스템 트레이 같은 기능을 공통 API로 제공해주고 있어서 비교적 쉽게 사용할 수 있었습니다.
데이터를 주고받는 부분도 익숙했습니다. Rust의 serde로 구조체를 직렬화하고 역직렬화하는 방식이 웹에서 JSON 요청과 응답을 다룰 때와 비슷했거든요. 웹뷰를 쓰다 보니 화면이나 컴포넌트를 구성할 때 기존 웹 라이브러리를 사용할 수 있다는 점도 좋았습니다.
물론 Tauri가 모든 기능을 제공해주는 건 아닙니다. macOS에 특화된 기능은 네이티브 API를 호출해야 하는데, 이때 FFI(Foreign Function Interface)를 접하게 됐습니다. Rust에서 다른 언어로 작성된 함수를 호출하는 경계라고 이해했습니다. 이 경계에서는 메모리나 포인터를 어떻게 다루는지도 따로 신경 써야 했습니다.
HTML 영역을 잡고 창을 움직이는 것
당연하지만 막상 구현하려니 낯설었던 부분도 있었습니다. 웹뷰에서 어떻게 데스크톱 창을 움직일지였는데요.
일반 웹페이지에서는 화면 안의 요소를 드래그한다고 브라우저 창 자체가 움직이지는 않습니다. 하지만 데스크톱 앱에서는 타이틀바를 잡고 끌면 창이 이동해야 합니다. 저도 다른 앱들처럼 마우스로 창을 옮기고 싶었습니다.
Tauri에서는 data-tauri-drag-region 속성으로 드래그할 영역을 지정할 수 있었습니다.
<div data-tauri-drag-region class="titlebar">
Zenith Dashboard
</div>
웹 화면에 지정한 영역이 네이티브 창 드래그 동작으로 이어지는 게 신기했습니다. 평소에 작성하던 HTML 속성 하나로 데스크톱 창을 움직인다는 게, 직접 해보니 꽤 재밌더라고요.
Tauri의 통신 방식 이해하기
Tauri는 웹뷰와 Rust 쪽이 분리되어 있어서 둘 사이에 통신이 필요합니다. 이 프로세스 간 통신을 IPC(Inter-Process Communication)라고 합니다.
처음에는 invoke, event, channel이 각각 언제 필요한지 헷갈렸습니다. 백엔드에서 사용하던 통신 방식에 빗대어 보니 조금 이해가 됐습니다.
invoke: 요청하고 결과 받기
invoke는 프론트엔드에서 Rust의 커맨드를 호출하고 결과를 받는 방식입니다. 웹에서 API를 호출할 때와 비슷하게 생각할 수 있습니다. 실제 HTTP 요청을 보내는 것은 아니지만, 인자를 전달하고 응답을 기다린다는 흐름은 익숙했습니다.
예를 들어 Rust에 등록된 get_system_metrics 커맨드를 프론트엔드에서는 이렇게 호출합니다.
import { invoke } from "@tauri-apps/api/core";
const metrics = await invoke<SystemMetrics>("get_system_metrics");
처음에는 Rust에서 값을 반환하는데 왜 JS에서는 Promise로 받는 건지 궁금했습니다. 같은 프로세스 안의 함수를 바로 호출하는 게 아니라, IPC로 요청을 보내고 결과가 돌아오기를 기다리기 때문이라고 이해했습니다. 프론트에서는 await로 결과를 받아 사용하면 됩니다.
다만 프론트에서 비동기로 기다린다고 Rust 쪽의 무거운 작업까지 알아서 분리되는 것은 아니었습니다. 이 부분은 뒤에서 다시 이야기하겠습니다.
event: 이벤트를 발행하고 구독하기
event는 Rust나 JS에서 이벤트를 보내면 등록된 리스너가 받는 방식입니다. 백엔드에서 Kafka나 NATS를 사용할 때의 발행/구독 구조가 떠올랐습니다. 물론 메시지 저장이나 재전송까지 같은 기능을 제공한다는 뜻은 아닙니다.
어떤 작업의 결과를 요청한 곳에 돌려주기보다는, 상태가 바뀌었다는 사실을 필요한 곳에 알릴 때 이해하기 쉬웠습니다. 트레이나 창에서 일어난 일을 다른 화면에 알려주는 식입니다.
channel: 작업 중간에도 데이터 받기
디스크 전체를 스캔하는 작업은 시간이 걸립니다. 끝날 때까지 기다렸다가 결과만 보여주면 사용자는 지금 얼마나 진행됐는지 알기 어렵습니다.
이때 channel을 사용하면 커맨드가 실행되는 동안에도 Rust에서 프론트엔드로 데이터를 계속 보낼 수 있습니다. 서버에서 스트리밍 응답을 받는 것과 비슷하게 느껴졌습니다.
WebView ── 스캔 요청 + 채널 전달 ──► Rust
WebView ◄── 스캔 중간 결과 ─────── Rust
WebView ◄── 다음 결과 ──────────── Rust
WebView ◄── 스캔 완료 ──────────── Rust
저도 파일 스캔에 채널을 사용했습니다. 스캔이 진행되는 동안 발견한 파일과 진행 상태를 화면에 보여줄 수 있었습니다. 요청의 최종 결과는 invoke로 받고, 중간에 필요한 데이터는 채널로 받는다고 생각하니 역할이 좀 더 명확해졌습니다.
무거운 작업은 어디서 실행할까
처음 소프트웨어 개발에 재미를 붙였던 건 안드로이드 앱을 만들 때였습니다. 그때 메인 스레드에서 네트워크 I/O나 무거운 작업을 하면 화면이 멈출 수 있다고 배웠는데, 이번에도 비슷한 문제를 만났습니다.
파일 스캔처럼 오래 걸리는 작업을 화면이나 창 이벤트를 처리하는 흐름에서 실행하면 사용자 입력에 제대로 반응하기 어렵습니다. Rust 쪽에 작성했다고 해서 이 문제가 저절로 해결되는 것도 아니고요.
Tauri는 비동기 런타임으로 Tokio를 사용합니다. 비동기 작업은 런타임에서 처리하고, 동기 파일 탐색처럼 스레드를 오래 붙잡는 작업은 tokio::task::spawn_blocking으로 별도의 블로킹 작업용 스레드 풀에 분리했습니다.
백엔드에서도 비동기 서버 안에서 블로킹 작업을 어떻게 처리할지 고민하게 되는데, 데스크톱에서도 비슷한 고민을 한다는 점이 반가웠습니다. async를 붙이는 것과 실제로 작업이 어디서 실행되는지는 구분해서 봐야 했습니다.
파일을 지우는 앱이라 신경 쓴 것들
기능 중에서 가장 조심스러웠던 건 파일 삭제였습니다. 제가 직접 사용할 앱이라도, 정리하려던 파일이 아닌 다른 파일을 지우면 안 되니까요.
스캔할 때와 삭제할 때의 파일이 같을까
이번에 처음 알게 된 개념이 TOCTOU(Time-of-Check to Time-of-Use)였습니다. 처음에는 이름만 보고 잘 이해가 안 됐는데, 파일 삭제 상황에 대입해보니 어떤 문제인지 알겠더라고요.
앱이 파일을 스캔한 뒤 사용자가 목록을 확인하고 삭제 버튼을 누르기까지는 시간이 있습니다. 그사이에 다른 프로그램이 파일을 수정하거나, 같은 경로에 다른 파일을 만들 수도 있습니다. 스캔할 때 확인한 정보가 삭제할 때도 그대로라는 보장이 없는 겁니다.
그래서 스캔 시점에 파일의 메타데이터를 백엔드에 기록하고, 삭제하기 전에 다시 비교하도록 했습니다. 경로만 같다고 같은 파일로 판단하지 않고, inode와 device ID도 확인하는 식입니다.
inode도 이 과정에서 찾아봤습니다. 파일 시스템 안에서 파일을 식별하는 번호인데, 파일 시스템이 다르면 같은 번호가 있을 수 있어서 device ID와 함께 봅니다. 다만 이 값만으로 파일 내용이 바뀌지 않았다고 보장할 수는 없고, 재검사와 실제 삭제 사이에도 시간차가 남습니다. 한 번 비교하는 것으로 모든 문제가 해결되는 건 아니었습니다.
백엔드에서도 프론트에서 받은 요청을 그대로 믿지 않고 실제 데이터와 상태를 다시 확인하는데요. 파일 삭제도 비슷하다고 느꼈습니다. UI에서 선택한 경로라고 바로 지우기보다는, 백엔드에서 삭제해도 되는 대상인지 확인해야 했습니다.
심링크를 따라가면 어떻게 될까
심링크는 다른 파일이나 디렉터리를 가리키는 링크입니다. 캐시 폴더 안에 다른 폴더를 가리키는 심링크가 있다면, 탐색 중에 그 링크를 따라가면서 원래 정리하려던 범위를 벗어날 수 있습니다.
여기서 링크 자체를 삭제하는 것과 링크가 가리키는 대상을 따라가서 삭제하는 것은 다릅니다. 심링크를 지운다고 대상 파일까지 함께 지워지는 것은 아닙니다. 제가 신경 써야 했던 건 파일을 탐색하고 삭제 대상을 정하는 과정에서 의도치 않게 링크를 따라가는 경우였습니다.
그래서 링크를 따라가지 않고 링크 자체의 정보를 읽는 symlink_metadata()로 파일 타입을 확인하고, 디렉터리를 가리키는 심링크의 내부를 탐색하지 않도록 했습니다.
처음에는 파일 목록을 보여주고 지우면 된다고 생각했는데, 이런 부분을 하나씩 보다 보니 삭제 기능에 신경 쓸 게 꽤 많았습니다.
Rust의 리소스 관리
이전에 Go를 사용할 때는 리소스를 할당한 뒤 정리하는 코드를 놓치지 않도록 신경 써야 했습니다. Rust에서는 소유권과 Drop, RAII(Resource Acquisition Is Initialization)를 통해 리소스의 생명주기를 관리하는 방식이 인상적이었습니다.
파일 핸들 같은 리소스를 객체가 소유하고, 그 객체가 정리될 때 리소스도 함께 해제하도록 구성할 수 있습니다. 그렇다고 모든 누수를 컴파일러가 막아주는 것은 아니지만, 언제 정리되는지를 코드 구조 안에서 생각하게 되는 점이 좋았습니다.
물론 Go를 쓸 때보다 컴파일 단계에서 시간을 많이 쓰기도 했습니다. 그래도 파일을 직접 다루고 장시간 켜둘 앱이라, 이번에는 이런 방식으로 관리해보는 게 괜찮겠다고 생각했습니다.
실제로 사용하는 화면
메뉴바 트레이
메뉴바에서는 스토리지와 메모리 상태, AI 코딩 도구의 사용량 등을 확인할 수 있게 했습니다. 따로 터미널을 열거나 여러 화면을 오가지 않고 볼 수 있어서 제가 자주 사용하는 부분입니다.

스토리지 대시보드
대시보드에서는 용량을 많이 차지하는 파일이나 개발 산출물을 분류해서 볼 수 있습니다. 스캔 결과를 확인하고 필요한 항목을 골라 정리하는 방식입니다. 개발 산출물로 분류되더라도 다시 만들 수 있는 파일인지, 지금 사용 중인 것은 아닌지 확인하고 지우고 있습니다.

AI의 도움을 받으면서
이번 개발에는 LLM의 도움을 많이 받았습니다. 익숙하지 않은 Rust 코드나 macOS API를 다룰 때 특히 그랬습니다.
그런데 코드가 빨리 만들어지는 것과 제가 그 코드를 이해하는 건 별개였습니다. 처음 보는 개념이 많다 보니, AI가 만들어준 PR을 읽고 이해하는 데 시간이 꽤 걸렸습니다. 오히려 이 부분이 병목이었던 것 같습니다.
그래서 /explain-diff 스킬을 적극적으로 사용했습니다. 변경된 코드가 어떤 흐름으로 동작하는지 설명을 보고, 잘 모르겠는 부분을 다시 물어보는 식이었습니다. 특히 파일을 삭제하는 조건이나 FFI에서 리소스를 정리하는 부분은 이해가 될 때까지 다시 확인하려고 했습니다.
설명을 읽었다고 코드를 다 이해한 건 아니라서 실제 변경 내용과 같이 봐야 했습니다. 그래도 낯선 코드를 처음부터 혼자 읽는 것보다는 이해하는 데 도움이 많이 됐습니다.
만들고 나서
아직 미비한 부분도 있고 사소한 버그들도 남아 있습니다. 그래도 직접 만든 앱을 사용하면서 필요했던 기능을 하나씩 붙이는 게 생각보다 만족도가 높았습니다.
백엔드와 비슷해서 금방 이해한 부분도 있었고, 창을 움직이거나 파일 시스템을 다루는 것처럼 새로 알아본 부분도 많았습니다. 특히 평소에 쓰던 웹 기술로 데스크톱 화면을 만들고, 그 화면에서 실제 맥북을 제어하는 경험이 재밌었습니다.
정리가 귀찮아서 시작했는데 오랜만에 만드는 것 자체에 재미를 느꼈던 프로젝트였습니다. 일단 제 맥북에서 계속 써보면서 불편한 부분들을 조금씩 고쳐보려고 합니다.