resumate 개발일지 02. 기술 스택 선택 이유
프로젝트 요약: AI 캐릭터 에이전트(Nyx, Paws, Echo)가 데스크탑 독(dock) 위에 떠 있고, 클릭하면 작업 창이 열려 Claude와 대화하는 macOS 데스크탑 앱
1. 전체 기술 스택 한눈에 보기
┌─────────────────────────────────────────────────────────┐
│ macOS 데스크탑 앱 │
│ │
│ [Tauri v2 쉘 — Rust] │
│ ├── WebView "dock" ← React + Vite (캐릭터 독) │
│ └── WebView "main" ← React + Vite (작업 창) │
│ │ │
│ │ HTTP / SSE │
│ ▼ │
│ [Sidecar — Bun + Hono] │
│ │ │
│ │ stdin/stdout │
│ ▼ │
│ [Claude CLI] │
└─────────────────────────────────────────────────────────┘
프론트엔드: React 18, Zustand v5, Tailwind CSS v4
빌드 도구: Vite 6
백엔드 쉘: Rust (Tauri v2), SQLite
사이드카: Bun, Hono, TypeScript strict
AI 엔진: Claude CLI (Anthropic)2. Tauri v2 — Electron 대신 이걸 쓴 이유
배경: 데스크탑 앱에서의 선택지
웹 기술로 데스크탑 앱을 만들 때 가장 먼저 떠오르는 건 Electron이다. VS Code, Slack, Discord가 모두 Electron으로 만들어졌다. 그런데 이 프로젝트는 Tauri를 선택했다.
Electron vs Tauri 핵심 비교
| 항목 | Electron | Tauri v2 |
|---|---|---|
| 런타임 | Chromium 번들링 (앱에 포함) | OS 기본 WebView 사용 |
| 설치 파일 크기 | ~100–200 MB | ~5–15 MB |
| 메모리 사용 | ~200–500 MB | ~50–100 MB |
| 백엔드 언어 | Node.js | Rust |
| 보안 모델 | Node.js API 전체 노출 가능 | 명시적 허용 목록(capabilities) |
| macOS WebView | Chromium | WKWebView (Safari 엔진) |
| 개발 생태계 | 성숙함, 레퍼런스 많음 | 빠르게 성장 중 |
| 학습 곡선 | 낮음 (JS 개발자에게) | 중간 (Rust 필요) |
이 프로젝트에서 Tauri를 선택한 실제 이유
1. 투명 창 + 항상 위 표시 (Always-on-top)
// tauri.conf.json
{
"label": "dock",
"transparent": true,
"decorations": false,
"alwaysOnTop": true,
"width": 500,
"height": 300
}캐릭터 독이 화면 위에 항상 떠 있어야 하는 UX 요구사항에 Tauri의 네이티브 창 제어가 맞았다. Electron도 가능하지만 Tauri는 이런 설정이 JSON 한 줄이다.
2. 멀티 창 아키텍쳐
dock 창과 main 창이 완전히 독립적인 생명주기를 가져야 했다. main 창은 기본적으로 숨겨져 있다가 캐릭터 클릭 시에만 나타난다.
// src-tauri/src/lib.rs
tauri::Builder::default()
.plugin(tauri_plugin_sql::Builder::default().build())
.invoke_handler(tauri::generate_handler![find_free_port])
.run(tauri::generate_context!())3. 보안과 파일 크기
AI 에이전트 앱은 파일 시스템 접근, 셸 실행 등 민감한 작업을 한다. Tauri의 capabilities 시스템은 기본적으로 모든 것을 차단하고 필요한 것만 허용한다.
Tauri v2의 단점 (실제로 겪은 것들)
- macOS WKWebView 한계: Chromium이 아니라 Safari 엔진 기반이라 CSS/JS 지원이 조금 다르다
- Rust 진입 장벽: 백엔드 로직을 수정하려면 Rust를 알아야 한다
- 빌드 시간: Rust 컴파일이 느려서
cargo build가 처음엔 수분이 걸린다 - 생태계: Electron에 비해 플러그인/레퍼런스가 적다
3. Rust — 왜 백엔드가 Rust인가
Tauri를 쓰면 Rust는 딸려온다
Tauri 자체가 Rust로 만들어졌기 때문에, 네이티브 기능을 추가하려면 Rust를 써야 한다. 선택이 아니라 Tauri 선택의 결과다.
이 프로젝트에서 Rust가 하는 일
실제로 이 프로젝트의 Rust 코드는 매우 얇다 (thin shell 패턴):
// src-tauri/src/lib.rs — 핵심 로직 전체
#[tauri::command]
async fn find_free_port() -> Result<u16, String> {
// 사용 가능한 포트를 찾아서 사이드카 서버 실행
let listener = TcpListener::bind("127.0.0.1:0")?;
Ok(listener.local_addr()?.port())
}Rust가 하는 일:
- Tauri 앱 부트스트랩
- SQLite 플러그인 등록
- 사이드카용 포트 탐색 (
find_free_port)
무거운 AI 로직은 모두 사이드카(Bun)에 위임했다.
Rust의 실제 장단점
| 장점 | 단점 |
|---|---|
| 메모리 안전성 (GC 없음) | 학습 곡선이 가파름 |
| 빠른 실행 성능 | 컴파일 시간이 김 |
| 크로스 플랫폼 바이너리 | 생산성이 JS/Python보다 낮음 |
| Tauri 생태계와 자연스러운 통합 | 간단한 작업도 verbose |
이 프로젝트의 흥미로운 점: Dead Code
// src-tauri/src/ipc.rs — 등록되지 않은 코드
async fn fs_read(path: String) -> Result<String, String> { ... }
async fn shell_exec(cmd: String, args: Vec<String>) -> Result<...> { ... }파일 읽기, 셸 실행 등 풍부한 IPC 핸들러를 미리 구현해놨지만
lib.rs의 invoke_handler에 등록하지 않아 완전히 데드코드다.
초기 설계에서 "Rust IPC로 다 처리하자"는 방향이 있었다가
"사이드카에 다 맡기자"로 전략이 바뀐 흔적이다.
4. React 18 — 프론트엔드 선택의 이유
왜 React인가 (Vue, Svelte 말고)
| 항목 | React 18 | Vue 3 | Svelte 5 |
|---|---|---|---|
| 번들 크기 | 중간 | 작음 | 매우 작음 |
| 생태계 | 가장 큼 | 큼 | 성장 중 |
| TypeScript 지원 | 우수 | 우수 | 우수 |
| 3D 라이브러리 (@react-three/fiber) | O | 직접 구현 | 직접 구현 |
| Lottie 라이브러리 | O | O | 제한적 |
| Tauri 공식 템플릿 | O | O | O |
| 학습 자료 | 매우 많음 | 많음 | 적음 |
이 프로젝트에서 React를 선택한 핵심 이유:
1. @react-three/fiber — Three.js 통합
CharacterBlob 컴포넌트가 Three.js R3F를 사용한다 (현재는 미사용이지만 설계 의도에 있었다).
@react-three/fiber는 Three.js를 React 컴포넌트 방식으로 사용할 수 있게 해주는 렌더러다.
2. @lottiefiles/dotlottie-react — Lottie 애니메이션
캐릭터 애니메이션을 .lottie 포맷으로 구현. React 전용 래퍼가 잘 되어 있다.
3. 팀 친숙도와 생태계 대부분의 JS 개발자에게 가장 친숙한 프레임워크.
React 18의 실제 활용
// src/main.tsx — 창 라벨에 따라 다른 컴포넌트 렌더링
const label = await getCurrentWebviewWindow().label;
const root = createRoot(document.getElementById("root")!);
if (label === "dock") {
root.render(<CharacterDock />);
} else {
root.render(<App />);
}같은 React 앱이 두 창에 올라가지만, 창 라벨에 따라 완전히 다른 UI를 렌더링한다. 이 패턴 덕분에 Vite 빌드를 한 번만 하면 두 창이 모두 동작한다.
5. Vite 6 — 빌드 도구 선택의 이유
Webpack vs Vite
| 항목 | Webpack 5 | Vite 6 |
|---|---|---|
| 개발 서버 시작 | 수십 초 | < 1초 |
| HMR 속도 | 수백 ms ~ 수 초 | < 50ms |
| 설정 복잡도 | 높음 | 낮음 |
| Tauri 공식 지원 | O | O (권장) |
| Tailwind v4 통합 | 플러그인 별도 | @tailwindcss/vite 네이티브 |
| ESM 네이티브 지원 | 번들링 기반 | ESM 기반 |
이 프로젝트의 Vite 설정
// vite.config.ts
export default defineConfig({
plugins: [
react(), // @vitejs/plugin-react — React Fast Refresh
tailwindcss(), // @tailwindcss/vite — Tailwind v4 네이티브 통합
],
clearScreen: false, // Tauri 로그가 지워지지 않도록
server: {
port: 1420, // Tauri가 기대하는 포트 (tauri.conf.json과 일치)
strictPort: true, // 포트 충돌 시 에러 (조용히 다른 포트로 넘어가지 않음)
watch: {
ignored: ["**/.omc/**"], // omc 세션 파일 변경 시 HMR 무시
}
},
envPrefix: ["VITE_", "TAURI_"], // Tauri 환경변수도 프론트에서 접근 가능
});clearScreen: false는 Tauri + Vite 조합에서 중요하다.
Vite가 화면을 지우면 Tauri의 Rust 컴파일 로그도 같이 사라지기 때문.
Tailwind v4 + Vite 플러그인
Tailwind CSS v4는 기존의 tailwind.config.js 방식을 버리고
Vite 플러그인으로 완전히 통합됐다:
import tailwindcss from "@tailwindcss/vite";
// 설정 파일 없이 이 한 줄로 끝CSS 파일에서 직접 설계 토큰을 정의하는 방식으로 바뀌었다.
6. Zustand v5 — 상태 관리 선택의 이유
Redux vs Zustand vs Jotai vs Context API
| 항목 | Redux Toolkit | Zustand v5 | Jotai | Context API |
|---|---|---|---|---|
| 보일러플레이트 | 중간 | 매우 적음 | 적음 | 없음 |
| 번들 크기 | ~11KB | ~3KB | ~3KB | 0 (내장) |
| DevTools | 우수 | O | O | 없음 |
| 비동기 처리 | thunk/saga | 미들웨어 | 없음 | 없음 |
| 학습 곡선 | 높음 | 낮음 | 낮음 | 없음 |
| 멀티 스토어 | 보통 | 자연스러움 | 자연스러움 | 어려움 |
이 프로젝트의 Zustand 활용
스토어가 2개로 나뉘어 있다:
// src/store/agentStore.ts — AI 도메인 상태
const useAgentStore = create<AgentStore>((set, get) => ({
activeCharacter: "nyx",
conversations: { nyx: { messages: [] }, paws: { messages: [] }, echo: { messages: [] } },
loading: { nyx: false, paws: false, echo: false },
// 액션들...
}));
// src/store/workspaceStore.ts — UI/워크스페이스 상태
const useWorkspaceStore = create<WorkspaceStore>((set) => ({
showFileTree: false,
showTerminal: false,
showWorkWindow: false,
rootDir: null,
selectedSkills: [],
terminalOutput: [],
}));관심사가 다른 두 도메인을 분리한 것은 좋은 패턴.
agentStore는 AI 대화에 관한 것, workspaceStore는 UI 패널 상태에 관한 것.
멀티 윈도우에서 Zustand의 한계
가장 큰 문제: Tauri의 각 WebView는 독립된 JavaScript 컨텍스트를 가진다. 즉, dock 창과 main 창이 같은 Zustand 스토어 인스턴스를 공유하지 않는다.
// dock 창에서 activeCharacter를 "paws"로 바꿔도
// main 창의 Zustand 스토어는 여전히 "nyx"이 문제를 해결하기 위해 Tauri 이벤트 버스로 수동 동기화:
// CharacterDock.tsx
await emit("character-clicked", { id: character.id });
// App.tsx
await listen("character-clicked", (event) => {
setActiveCharacter(event.payload.id); // main 창 스토어도 업데이트
});이건 동작하지만 확장하기 어렵다. 상태가 늘어날수록 이벤트도 늘어난다.
7. Bun + Hono — 사이드카 서버 선택의 이유
왜 별도의 사이드카 서버가 필요한가
Claude CLI를 직접 Rust에서 spawn할 수도 있었다.
그런데 왜 Bun 서버를 별도로 띄웠을까?
핵심 이유: Claude CLI의 출력이 복잡하다
Claude 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로 파싱하면 훨씬 직관적:
// sidecar/claude.ts
for await (const line of rl) {
const event = JSON.parse(line);
if (event.type === "assistant") {
const text = event.message.content
.filter(b => b.type === "text")
.map(b => b.text).join("");
res.write(`data: ${JSON.stringify({ type: "text", text })}\n\n`);
}
}Bun 선택의 이유
| 항목 | Node.js | Bun |
|---|---|---|
| 시작 속도 | ~100ms | ~5ms |
| TypeScript 실행 | ts-node/tsx 필요 | 네이티브 지원 |
| 번들링 | 별도 도구 필요 | 내장 (bun build) |
| 패키지 설치 속도 | npm/pnpm 사용 | 매우 빠름 |
| 단일 실행 바이너리 | pkg 등 별도 도구 | bun build --compile |
Tauri 사이드카로 배포할 때 단일 실행 바이너리가 필요하다.
bun build --compile로 TypeScript 사이드카를 의존성 없는 단일 바이너리로 컴파일할 수 있다.
Hono 선택의 이유
// sidecar/index.ts — Hono 서버 전체 구조
const app = new Hono();
app.get("/health", (c) => c.json({ status: "ok" }));
app.get("/skills", (c) => c.json(getSkills()));
app.get("/list", (c) => c.json(listDirectory(c.req.query("path"))));
app.post("/chat", async (c) => {
// SSE 스트림 반환
return streamSSE(c, async (stream) => { ... });
});- 초경량: 번들 크기 < 15KB
- Web Standards: Fetch API 기반, Bun/Deno/Node 어디서나 동작
- SSE 지원:
streamSSE헬퍼가 내장 - 타입 안전: TypeScript 친화적
8. TypeScript — 왜 JS가 아닌가
이 프로젝트의 TypeScript 설정 수준
프론트엔드와 사이드카 모두 strict 모드:
// tsconfig.json (frontend)
{ "compilerOptions": { "strict": true } }
// sidecar/tsconfig.json
{ "compilerOptions": { "strict": true, "noUncheckedIndexedAccess": true } }noUncheckedIndexedAccess는 배열/객체 인덱스 접근 시 undefined 가능성을 강제로 처리하게 하는 옵션이다. 사이드카에서만 켜져 있는데, AI 이벤트 파싱처럼 외부 데이터를 다룰 때 유용하다.
타입 시스템이 실제로 잡아준 것들
예를 들어 CharacterId 타입:
// src/store/agentStore.ts
export type CharacterId = "nyx" | "paws" | "echo";
// 이 타입 덕분에 오타나 잘못된 캐릭터 ID를 컴파일 타임에 잡는다
function handleClick(id: CharacterId) { ... }
handleClick("npc"); // 컴파일 에러!단, 이 프로젝트에는 타입 불일치 버그가 있다:
사이드카의 CharacterId는 아직 "architect" | "builder" | "reviewer"로 남아 있어
런타임에 시스템 프롬프트가 undefined가 된다.
타입이 분리된 두 프로세스(frontend / sidecar)의 타입을 동기화하는 게 중요하다는 교훈.
9. Tailwind CSS v4 — CSS 전략
왜 Tailwind인가 (styled-components, CSS Modules 말고)
| 항목 | Tailwind v4 | styled-components | CSS Modules | 인라인 스타일 |
|---|---|---|---|---|
| 번들 크기 | 사용한 것만 | 런타임 오버헤드 | 작음 | 없음 |
| 설계 토큰 통합 | CSS 변수 기반 | JS 객체 | 없음 | 없음 |
| Tauri 투명 창 스타일링 | 쉬움 | 쉬움 | 쉬움 | 복잡 |
| 다크모드 | 클래스 기반 | theme 객체 | CSS 변수 | 어려움 |
| 타입 안전 | 없음 | O | O | O |
Tailwind v4의 변화
Tailwind v4는 tailwind.config.js를 없애고 CSS 파일 안에 설정을 넣는다:
/* 기존 v3: tailwind.config.js에 theme 설정 */
/* 새로운 v4: CSS 파일에 직접 */
@import "tailwindcss";
@theme {
--color-brand: oklch(60% 0.2 260);
--font-sans: "Pretendard", system-ui;
}Vite 플러그인으로 통합되어 postcss.config.js도 필요 없다.
10. SQLite (tauri-plugin-sql) — 데이터 저장 전략
데스크탑 앱에서의 데이터 저장 옵션
| 옵션 | 사용 사례 | 한계 |
|---|---|---|
| localStorage | 작은 설정값 | WebView마다 격리됨, 용량 제한 |
| IndexedDB | 브라우저 기반 구조적 데이터 | WebView마다 격리됨 |
| SQLite (Tauri 플러그인) | 영구적인 구조화 데이터 | 동기화 필요 |
| 파일시스템 (JSON) | 단순한 설정 파일 | 동시 쓰기 충돌 |
이 프로젝트는 SQLite를 선택했다:
// src/lib/db.ts
const db = await Database.load("sqlite:resumate.db");
// conversations 테이블: 캐릭터별 대화 내용 저장
await db.execute(`CREATE TABLE IF NOT EXISTS conversations (
character_id TEXT PRIMARY KEY,
messages TEXT NOT NULL DEFAULT '[]'
)`);장점: 두 WebView 창이 같은 SQLite 파일을 공유하므로 창 간 상태 공유의 일부를 DB를 통해 해결할 수 있다.
현재 버그: saveConversation()이 어디에서도 호출되지 않아
앱을 재시작하면 모든 대화가 사라진다.
11. 멀티 윈도우 아키텍쳐 — 왜 2개의 창인가
설계 의도
dock 창 (500×300, 투명, 항상 위)
→ 항상 화면에 떠 있는 캐릭터 위젯
→ 클릭해도 다른 앱이 가려지지 않음
main 창 (1280×800, 투명, 기본 숨김)
→ 실제 AI와 대화하는 작업 공간
→ 캐릭터 클릭 시에만 등장단일 창 vs 멀티 창
| 항목 | 단일 창 | 멀티 창 |
|---|---|---|
| 상태 공유 | 자연스러움 | 이벤트 버스 필요 |
| UX | 단순함 | 네이티브 독 느낌 |
| 독립적 생명주기 | 불가 | O |
| 개발 복잡도 | 낮음 | 높음 |
| 투명 오버레이 독 | 구현 어려움 | 자연스러움 |
멀티 창을 선택한 핵심 이유: 캐릭터가 다른 앱 위에 항상 떠 있어야 한다. 단일 창으로는 dock + 작업창을 동시에 구현하기 어렵다.
멀티 창의 핵심 문제: 상태 동기화
Zustand 스토어는 각 WebView의 JavaScript 컨텍스트 안에 있다. 두 창이 같은 Zustand 인스턴스를 공유하지 않는다.
현재 해결책 — Tauri 이벤트 버스:
// dock → main
await emit("character-clicked", { id: "nyx" });
await emit("window-state-changed", { visible: false });
// main → dock (없음 — main에서 dock으로 가는 이벤트는 미구현)더 나은 해결책 아이디어:
- 공유 SQLite: DB를 단일 진실 공급원으로 삼고 polling
- Zustand + broadcast 미들웨어:
BroadcastChannel로 창 간 스토어 동기화 - Rust IPC를 중앙 상태 관리자로: Rust가 상태를 들고 있고 양 창이 쿼리
12. SSE 스트리밍 — WebSocket 대신 이걸 쓴 이유
SSE vs WebSocket
| 항목 | SSE | WebSocket |
|---|---|---|
| 방향 | 서버 → 클라이언트 단방향 | 양방향 |
| 프로토콜 | HTTP/1.1 | 별도 프로토콜 |
| 재연결 | 자동 | 수동 |
| 구현 복잡도 | 낮음 | 높음 |
| AI 스트리밍 적합성 | 최적 | 가능하지만 과함 |
AI 채팅 스트리밍은 단방향이다. 사용자가 메시지를 보내면 서버가 토큰을 스트리밍해서 내려보낸다. 양방향 통신이 필요 없으므로 SSE가 WebSocket보다 적합하다.
// sidecar/index.ts — Hono SSE
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);
});
});
// src/lib/sidecar.ts — 프론트엔드 SSE 파싱
const response = await fetch(`${BASE_URL}/chat`, { method: "POST", body, signal });
const reader = response.body!.getReader();
// ReadableStream을 읽으면서 SSE 이벤트 파싱중단(abort) 처리가 자연스럽다:
AbortController로 fetch를 취소하면 서버의 request.signal도 연결되어
Claude CLI 프로세스도 함께 종료된다.
13. 전체 아키텍쳐 장단점 요약
장점
| 항목 | 이유 |
|---|---|
| 가벼운 앱 | Tauri + WKWebView로 Electron 대비 1/10 크기 |
| 네이티브 UX | 투명 창, 항상 위, 독 패턴이 macOS에 자연스럽게 녹아듦 |
| AI 통합 유연성 | 사이드카가 HTTP API이므로 Claude 외 다른 AI로 교체 쉬움 |
| 타입 안전성 | 프론트엔드 + 사이드카 모두 TypeScript strict |
| SSE 스트리밍 | AbortController 기반 취소, 실시간 토큰 스트림 |
| 관심사 분리 | Rust(OS) / React(UI) / Bun(AI) 각자의 역할 명확 |
단점
| 항목 | 이유 |
|---|---|
| 멀티 프로세스 복잡도 | Rust + React + Bun 3개를 동시에 띄워야 함 |
| 타입 동기화 어려움 | 프론트/사이드카가 같은 타입을 쓰려면 별도 공유 패키지 필요 |
| Zustand 멀티 윈도우 | 창 간 상태 동기화가 수동이고 fragile |
| Rust 학습 비용 | 간단한 네이티브 기능 추가에도 Rust를 알아야 함 |
| WKWebView 한계 | Safari 엔진 기반이라 Chromium과 동작이 조금 다름 |
| 하드코딩된 경로 | /opt/homebrew/bin/claude — macOS + Homebrew 전용 |
14. 배운 점 & 다음에 다르게 할 것들
배운 점
1. Tauri 멀티 윈도우는 강력하지만 상태 관리가 어렵다 단일 창에서 Zustand가 자연스럽게 동작하는 것과 달리, 멀티 창에서는 처음부터 상태 공유 전략을 설계해야 한다.
2. 사이드카 패턴은 AI 통합에 좋은 아키텍쳐다 Rust 안에 AI 로직을 넣지 않고 TypeScript 사이드카로 분리한 것은 개발 속도와 유연성 면에서 옳은 선택이다. AI SDK가 JavaScript/Python 생태계에 먼저 나오기 때문.
3. 프로세스 간 타입을 공유하는 메커니즘이 필요하다
Frontend와 Sidecar가 CharacterId 타입을 따로 정의하다 보니
불일치가 생겼다. 공유 타입 패키지나 OpenAPI 스키마 생성을 처음부터 적용해야 한다.
4. 버그는 "연결"에서 난다
saveConversation()— 함수는 있는데 아무도 안 부름CharacterId— 타입은 있는데 서로 다름- IPC 핸들러 — 구현은 있는데 등록 안 됨
아키텍쳐가 아무리 좋아도 "연결"이 끊어지면 동작하지 않는다.
다음에 다르게 할 것들
1. 공유 타입 패키지
monorepo 구조에서 packages/shared-types를 만들어
frontend와 sidecar가 같은 타입을 import
2. 상태 동기화 전략 선결
멀티 창 앱이라면 처음부터 BroadcastChannel + Zustand 미들웨어
또는 SQLite를 단일 진실 공급원으로 설계
3. CSP 활성화
개발 초기부터 보안 정책을 켜두고 필요한 것만 허용
4. 사이드카 경로 동적 해결
빌드 시 Claude 경로를 환경변수나 PATH 탐색으로 해결
5. 데이터 영속화 테스트
저장/로드를 E2E 테스트로 커버해서 "아무도 안 부르는 함수" 버그 방지참고: 버전 정보
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