본문으로 건너뛰기

resumate 02. 기술 스택 선택 근거

·15 min read·2 / 8

만들려는 건 AI 캐릭터가 데스크톱 독 위에 떠 있고, 클릭하면 작업 창이 열려 Claude와 대화하는 macOS 앱이다. 이 요구사항 하나가 스택 선택의 대부분을 결정했다. 골라놓고 보니 왜 그렇게 정했는지를 어딘가 적어둬야 나중에 내가 헷갈리지 않을 것 같아서, 작업을 마치고 정리했다.

프론트엔드는 React 18, Zustand v5, Tailwind CSS v4에 빌드는 Vite 6. 백엔드 쉘은 Rust와 SQLite, 사이드카는 Bun과 Hono를 TypeScript strict 모드로 썼다.

Tauri: 진짜 이유는 크기가 아니라 창 제어였다

웹 기술로 데스크톱 앱을 만든다고 하면 Electron이 먼저 떠오른다. VS Code, Slack, Discord가 전부 Electron이다. 그런데도 Tauri를 골랐다.

항목ElectronTauri v2
런타임Chromium 번들링OS 기본 WebView
설치 파일 크기100~200 MB5~15 MB
메모리 사용200~500 MB50~100 MB
백엔드 언어Node.jsRust
보안 모델Node.js API 전체 노출 가능명시적 허용 목록(capabilities)
macOS WebViewChromiumWKWebView (Safari 엔진)
생태계성숙함성장 중

표만 보면 크기 때문에 고른 것 같지만, 실제로 결정적이었던 건 창 제어였다.

캐릭터 독이 다른 앱 위에 항상 떠 있어야 했다. Tauri는 이걸 설정 파일에서 끝낸다.

{
  "label": "dock",
  "transparent": true,
  "decorations": false,
  "alwaysOnTop": true,
  "width": 500,
  "height": 300
}

Electron으로도 되지만 Tauri는 JSON 몇 줄이다.

창을 두 개 쓰는 것도 컸다. dock 창과 main 창이 완전히 독립적인 생명주기를 가져야 했다. main 창은 기본적으로 숨어 있다가 캐릭터를 클릭할 때만 나타난다.

보안도 이유가 됐다. AI 에이전트 앱은 파일 시스템 접근과 셸 실행 같은 민감한 작업을 한다. Tauri의 capabilities는 기본적으로 전부 막아두고 필요한 것만 여는 방식이다.

쓰면서 걸린 것도 있다. macOS WKWebView는 Chromium이 아니라 Safari 엔진 기반이라 CSS와 JS 지원이 조금 다르다. 백엔드 로직을 고치려면 Rust를 알아야 하고, cargo build가 처음엔 수 분씩 걸린다.

Rust는 고른 게 아니라 딸려왔다

Tauri 자체가 Rust로 만들어져서, 네이티브 기능을 추가하려면 Rust를 써야 한다. Rust는 선택한 게 아니라 Tauri 선택의 결과다.

대신 Rust 코드를 최대한 얇게 유지했다. 앱 부트스트랩, SQLite 플러그인 등록, 사이드카용 빈 포트 탐색 셋이 전부다.

#[tauri::command]
async fn find_free_port() -> Result<u16, String> {
    let listener = TcpListener::bind("127.0.0.1:0")?;
    Ok(listener.local_addr()?.port())
}

무거운 AI 로직은 전부 사이드카로 넘겼다. 메모리 안전성과 실행 성능은 좋지만 학습 곡선이 가파르고 컴파일이 느려서, 간단한 작업까지 Rust로 끌어들일 이유가 없었다.

흔적이 하나 남아 있다. src-tauri/src/ipc.rs에 파일 읽기와 셸 실행 IPC 핸들러가 구현돼 있는데, lib.rsinvoke_handler에 등록되지 않아 아무도 부르지 않는 코드다. 초기에 "Rust IPC로 다 처리하자"였다가 "사이드카에 다 맡기자"로 바뀐 자국이다.

React는 3D와 Lottie 라이브러리 때문이었다

Vue나 Svelte도 후보였는데, 필요한 라이브러리가 React 쪽에만 제대로 있었다.

항목React 18Vue 3Svelte 5
번들 크기중간작음매우 작음
생태계가장 큼성장 중
3D (@react-three/fiber)있음직접 구현직접 구현
Lottie 라이브러리있음있음제한적

@react-three/fiber는 Three.js를 React 컴포넌트 방식으로 쓰게 해주는 렌더러다. CharacterBlob을 이걸로 만들 생각이었다(아직 화면에 붙이진 않았다). 캐릭터 애니메이션은 .lottie 포맷으로 만들었고 @lottiefiles/dotlottie-react 래퍼가 잘 돼 있었다.

창이 둘이라 진입점도 좀 특이해졌다. 같은 React 앱이 두 창에 올라가는데, 창 라벨을 보고 다른 컴포넌트를 렌더링한다.

const label = await getCurrentWebviewWindow().label;
const root = createRoot(document.getElementById("root")!);
 
if (label === "dock") {
  root.render(<CharacterDock />);
} else {
  root.render(<App />);
}

덕분에 Vite 빌드를 한 번만 해도 두 창이 다 동작한다.

Vite 설정 두 줄이 Tauri 조합에서 중요했다

빌드 도구는 Vite 6으로 갔다. 개발 서버 시작이 1초 안쪽이고 HMR도 50ms 안쪽이라 Webpack과 체감이 다르다.

export default defineConfig({
  plugins: [react(), tailwindcss()],
  clearScreen: false,
  server: {
    port: 1420,
    strictPort: true,
    watch: { ignored: ["**/.omc/**"] }
  },
  envPrefix: ["VITE_", "TAURI_"],
});

clearScreen: false가 Tauri 조합에서 중요하다. Vite가 화면을 지우면 Tauri의 Rust 컴파일 로그도 같이 사라진다.

strictPort: true는 포트가 겹칠 때 조용히 다른 포트로 넘어가지 않고 에러를 내게 한다. Tauri 설정에 적힌 포트와 어긋나면 앱이 빈 화면으로 뜨기 때문이다.

Tailwind는 v4를 썼다. v4부터 tailwind.config.js가 없어지고 CSS 파일 안에서 설정한다.

@import "tailwindcss";
 
@theme {
  --color-brand: oklch(60% 0.2 260);
  --font-sans: "Pretendard", system-ui;
}

Vite 플러그인으로 통합돼서 postcss.config.js도 필요 없다.

스토어를 둘로 나눴는데, 창이 둘이라 또 갈렸다

상태 관리는 Zustand v5로 갔다. Redux Toolkit은 보일러플레이트가 부담이었고, Context API는 리렌더 범위 제어가 어려웠다. Zustand는 3KB 정도에 멀티 스토어가 자연스럽다.

관심사에 따라 스토어를 둘로 나눴다. agentStore는 AI 대화 도메인, workspaceStore는 UI 패널 상태를 맡는다.

const useAgentStore = create<AgentStore>((set, get) => ({
  activeCharacter: "nyx",
  conversations: { nyx: { messages: [] }, paws: { messages: [] }, echo: { messages: [] } },
  loading: { nyx: false, paws: false, echo: false },
}));

여기까진 의도대로였는데, 멀티 윈도우에서 벽에 부딪혔다.

Tauri의 각 WebView는 독립된 JavaScript 컨텍스트를 가진다. dock 창과 main 창이 같은 Zustand 스토어 인스턴스를 공유하지 않는다는 뜻이다. dock에서 activeCharacter를 "paws"로 바꿔도 main 창 스토어는 여전히 "nyx"다.

일단 Tauri 이벤트 버스로 수동 동기화했다.

// CharacterDock.tsx
await emit("character-clicked", { id: character.id });
 
// App.tsx
await listen("character-clicked", (event) => {
  setActiveCharacter(event.payload.id);
});

동작은 하는데 확장이 어렵다. 공유할 상태가 하나 늘 때마다 이벤트가 하나씩 늘고, 지금은 main에서 dock으로 가는 방향은 아예 만들지도 않았다.

AI 로직을 Rust에 넣지 않은 이유

Claude CLI를 Rust에서 바로 spawn할 수도 있었다. 그런데 Bun 서버를 따로 띄웠다. CLI 출력이 복잡해서다.

--output-format stream-json으로 실행하면 여러 타입의 JSON 이벤트가 스트림으로 나온다.

{"type": "assistant", "message": {"content": [{"type": "text", "text": "안녕"}]}}
{"type": "tool_use", "name": "bash", "input": {"command": "ls"}}
{"type": "tool_result", "content": "file.txt"}
{"type": "result", "usage": {"input_tokens": 100, "output_tokens": 50}}

이걸 Rust에서 파싱하면 타입 정의가 금세 복잡해진다. TypeScript로는 훨씬 직관적이다.

Node.js 대신 Bun을 쓴 건 배포 때문이다. Tauri sidecar로 넘기려면 단일 실행 바이너리가 필요한데, bun build --compile이 TypeScript를 의존성 없는 바이너리 하나로 만들어준다. 시작 속도도 5ms 정도로 Node.js보다 빠르다.

서버 프레임워크는 Hono로 갔다. 번들이 15KB 미만이고 Fetch API 기반이라 Bun에서 그대로 돌아간다. streamSSE 헬퍼가 내장돼 있는 게 결정적이었다.

SSE로 충분했고 WebSocket은 과했다

AI 채팅 스트리밍은 단방향이다. 사용자가 메시지를 보내면 서버가 토큰을 내려보내는 게 전부다. 양방향 통신이 필요 없으니 WebSocket을 쓸 이유가 없었다. SSE는 HTTP 위에서 돌고 재연결도 자동이다.

app.post("/chat", async (c) => {
  return streamSSE(c, async (stream) => {
    const abortController = new AbortController();
    c.req.raw.signal.addEventListener("abort", () => abortController.abort());
 
    await runAgentLoop(body, (event) => {
      stream.writeSSE({ data: JSON.stringify(event) });
    }, abortController.signal);
  });
});

중단 처리가 특히 깔끔하게 나왔다. 프론트에서 AbortController로 fetch를 취소하면 서버의 request.signal까지 연결돼 Claude CLI 프로세스도 함께 종료된다.

TypeScript strict를 켰는데도 타입이 어긋났다

프론트엔드와 사이드카 모두 strict 모드로 켰다. 사이드카에는 noUncheckedIndexedAccess까지 켰다. 배열이나 객체 인덱스 접근에서 undefined 가능성을 강제로 처리하게 하는 옵션인데, AI 이벤트처럼 형태를 보장할 수 없는 외부 데이터를 다룰 때 쓸모가 있다.

CharacterId 같은 유니온 타입이 오타를 컴파일 시점에 잡아준다.

export type CharacterId = "nyx" | "paws" | "echo";
 
handleClick("npc"); // 컴파일 에러

그런데 여기서 구멍이 났다. 사이드카 쪽 CharacterId가 아직 "architect" | "builder" | "reviewer"로 남아 있다. 프론트에서 "nyx"를 보내면 사이드카가 시스템 프롬프트를 찾지 못해 런타임에 undefined가 된다.

두 프로세스가 각자 타입을 정의하니 컴파일러가 잡아줄 방법이 없다.

SQLite, 그리고 부르지 않는 저장 함수

대화를 어디에 저장할지도 정해야 했다. localStorage와 IndexedDB는 WebView마다 격리돼서 창이 둘인 이 앱에는 안 맞았다. 파일에 JSON으로 쓰면 두 창이 동시에 쓸 때 충돌한다. SQLite로 갔다.

const db = await Database.load("sqlite:resumate.db");
 
await db.execute(`CREATE TABLE IF NOT EXISTS conversations (
  character_id TEXT PRIMARY KEY,
  messages     TEXT NOT NULL DEFAULT '[]'
)`);

두 WebView가 같은 SQLite 파일을 보기 때문에, 창 간 상태 공유의 일부를 DB로 풀 수 있다는 점도 좋았다.

그런데 saveConversation()을 어디에서도 호출하지 않고 있다. 앱을 재시작하면 대화가 전부 사라진다.

발견한 버그 셋이 전부 같은 종류였다

saveConversation()은 함수가 있는데 아무도 안 부르고, CharacterId는 타입이 있는데 서로 다르고, IPC 핸들러는 구현이 있는데 등록이 안 됐다. 셋 다 각 조각은 멀쩡한데 연결이 끊겨 있다. 여기에 Claude 실행 경로가 /opt/homebrew/bin/claude로 하드코딩돼 있는 것과, 창 간 상태 동기화가 이벤트 버스 수동 연결이라 확장이 안 되는 문제까지 남아 있다.

지금 쓰고 있는 버전은 이렇다.

React:               18.3.1
Vite:                6.x
Tailwind CSS:        4.x
Zustand:             5.x
Tauri:               2.x
Rust:                1.77.2+
Bun:                 최신
Hono:                4.12.9
TypeScript:          5.6.2
@react-three/fiber:  8.x
three.js:            0.160.0
dotlottie-react:     0.18.8

Tauri와 WKWebView 덕에 Electron 대비 10분의 1 크기로 나왔고, 투명 창과 항상 위 표시가 macOS 독 패턴에 자연스럽게 붙었다. AI 로직을 HTTP API를 가진 사이드카로 뺀 것도 나중에 다른 모델로 갈아끼우기 쉬운 구조를 만들었다. 대신 Rust와 React와 Bun 세 프로세스를 동시에 띄워야 하고, 프론트와 사이드카가 타입을 공유할 방법이 없다는 비용이 따라왔다.

그래서 손볼 순서를 적어뒀다. packages/shared-types로 프론트와 사이드카가 같은 타입을 쓰게 하고, 멀티 윈도우 상태 동기화 전략을 나중이 아니라 처음에 정하고, Claude 경로를 환경 변수나 PATH 탐색으로 찾게 하고, 저장과 불러오기를 E2E 테스트로 덮는 것. 마지막 하나만 있었어도 오늘 버그 하나는 안 만들었다.