d3ro-voice/memory/project_status.md
윤찬 dd591d06e7 fix(desktop): AudioCaptureService.getDevices Mac 분기 — system_profiler CoreAudio 열거 (U7, 빅뱅 Phase 5 Part 6)
getDevices()가 powershell -NoProfile을 플랫폼 불문 호출해 Mac에서
"Failed to enumerate audio devices via PowerShell" 경고 + 1 device fallback이
발생하던 문제. process.platform 분기로 _getDevicesWindows / _getDevicesMac
helper로 분리하고, Mac은 system_profiler SPAudioDataType -json 을 파싱해
coreaudio_device_input > 0 인 항목만 input 디바이스로 추출.

macOS SoX 빌드는 실제 캡처 경로에서 -d(default) 고정이므로 deviceId는
'default'로 통일하고 label만 사용자 구분용으로 노출한다. Linux는 default
1개만 반환(pulseaudio/alsa 열거는 sox -d 로 충분).

Electron 재기동 후 로그: "Found 2 audio device(s) on darwin" 확인.
V2-5 플랫폼 감사 누락 항목 해소.
2026-04-11 23:18:18 +09:00

1136 lines
84 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# D3RO-VOICE 프로젝트 현황
> 마지막 갱신: 2026-04-11 (빅뱅 Phase 5 Part 6 — Voice Conversation 몰입 패널 구현)
> 규칙 13: 작업 완료 즉시 이 파일 갱신 의무
## 빅뱅 Phase 5 Part 6+ (2026-04-11) — U7 AudioCaptureService Mac 분기 ✅
`AudioCaptureService.getDevices()``powershell -NoProfile`만 호출하던 것을
`process.platform` 분기로 교체. Mac에서 `system_profiler SPAudioDataType -json`으로
CoreAudio input 디바이스 열거(`coreaudio_device_input > 0` 필터). macOS SoX는
실제 캡처 경로에서 `-d`(default) 고정이므로 `deviceId='default'`, `label`만 사용자
구분용으로 노출. 기존 "Failed to enumerate audio devices via PowerShell" 경고
+ 1 device fallback 해소.
**변경 파일 (1)**: `apps/desktop/src/main/services/AudioCaptureService.ts`
`_getDevicesWindows` / `_getDevicesMac` helper 추출 + 분기 디스패처.
**검증**: Electron 재기동 후 로그 `Found 2 audio device(s) on darwin` 확인 (경고 없음).
## 빅뱅 Phase 5 Part 6 (2026-04-11) — Voice Conversation UX 몰입 패널 ✅
Part 5-C 설계를 구현으로 완결. 사용자가 listening 상태에서 풀 몰입 계측기 모드 + 사운드 피드백 + 상태별 UI 분기를 실제로 경험할 수 있는 상태.
**변경 파일 (6)**
- `packages/core/src/ipc-channels.ts``VOICE_CONVERSATION.AUDIO_LEVEL` 채널 추가
- `apps/desktop/src/main/services/SoundEffectService.ts``SoundName``'chime'` 추가 (recording-stop.wav 재사용)
- `apps/desktop/src/main/services/VoiceConversationService.ts``_onAudioLevel` forwarding + 사운드 훅 4개(start/stop/chime/error) + Bug 13(빈 STT 피드백)
- `apps/desktop/src/preload/index.ts``voiceConversation.onAudioLevel` 구독 API
- `apps/desktop/src/renderer/components/voice-conversation/VoiceRecordingPanel.tsx`**신규** 9바 phosphor waveform(BAR_COUNT=9, cos 분포, 100ms, SMOOTHING=0.5, RANDOM_FACTOR=0.35) + REC LED + elapsed 타이머 + "SPEAK NOW" 힌트
- `apps/desktop/src/renderer/pages/VoiceConversationPage.tsx` — state 분기(listening=VoiceRecordingPanel / 그 외=메시지 리스트) + thinking 시 typing indicator 버블 + TextField listening 시 숨김·thinking/speaking 시 disabled
- `packages/i18n/src/locales/{ko,en}.json``conversation.recording.hint`, `conversation.thinking.placeholder`, `conversation.state.recording` 3개 키
**해소 이슈**
- U6 Voice Conversation UX 몰입 패널 구현
- U8 Bug 13 빈 STT 피드백 부재 (minBytes 미달 + VAD 무음 판정 양쪽 모두 `_emitError('stt', ...)` 훅 추가)
**검증**
- desktop `tsc --noEmit` EXIT 0
- Vite renderer HMR 자동 반영, Electron kill + nohup 재기동(PID 9635/9641)
- 사용자 녹음 테스트: 한국어 5.1초 오디오 전사 성공(`"에이에이에이에이 또 검중이래요"`) → 파이프라인 살아있음 확인
## Mac 환경 부트스트랩 (2026-04-11)
Windows → Mac 핸드오프 완료 (`memory/handoff-latest.md` 참조).
| 항목 | 상태 |
|---|---|
| Homebrew | ✅ 기존 설치 |
| Python 3.13 | ✅ 기존 설치 |
| Node 22.22.2 (brew) | ✅ 신규 설치 |
| sox 14.4.2 (brew) | ✅ 신규 설치 |
| Ollama | ❌ 미설치 (로컬 LLM 필요 시 `brew install ollama && ollama pull qwen3:4b`) |
| `npm install --force` | ✅ 1142 패키지 (rollup win32 옵셔널 dep 무시) |
| `@electron/rebuild@3 --version=33.4.11` | ✅ native 모듈 재빌드 완료 |
| desktop `tsc --noEmit` | ✅ |
| web `tsc --noEmit` | ✅ |
| web `next build` | ✅ (15 라우트, 5.8s compile) |
| api-client `vitest` | ✅ (19 passed, 283ms) |
| git working tree | ✅ clean (`package-lock.json` 포함 변경 없음) |
**남은 사용자 액션**
- OAuth Provider (Google/GitHub) 연결 — Supabase 대시보드 → Auth → Providers
- 외부 API 키 — ANTHROPIC / OPENAI / GOOGLE_CLOUD_STT / RESEND / STRIPE (아래 Edge Function Secrets 섹션)
- (선택) `brew install ollama` + `ollama pull qwen3:4b`
- (필요 시) `npm run dev`로 Electron 앱 수동 검증 — 첫 실행 시 Accessibility/마이크/Apple Events 권한 다이얼로그 승인
## Supabase 프로젝트 연결 (2026-04-11)
**프로젝트**: `yunchan8804-blip's Project` (ref: `llnocwyqvhgwpdjcqqyw`)
**URL**: `https://llnocwyqvhgwpdjcqqyw.supabase.co`
**CLI**: `supabase 2.84.2` (brew install supabase/tap/supabase)
| 단계 | 결과 |
|---|---|
| `supabase link --project-ref llnocwyqvhgwpdjcqqyw` | ✅ |
| `config.toml` db.major_version 15→17 | ✅ (원격 매치) |
| `supabase db push --include-all` | ✅ 8개 마이그레이션 전부 적용 |
| `supabase functions deploy` | ✅ 10개 Edge Functions 배포 |
| `supabase secrets list` | SUPABASE_* 자동 주입 4개만 존재 |
**배포된 Edge Functions** (10개)
`embed-chunks`, `llm-proxy`, `search-knowledge`, `send-push`, `stripe-checkout`, `stripe-portal`, `stripe-webhook`, `stt-proxy`, `team-accept`, `team-invite`
**로컬 env 파일** (git 정책으로 Claude가 직접 쓰지 못함 — 사용자가 수동 생성)
- `apps/web/.env.local` — NEXT_PUBLIC_SUPABASE_URL + ANON_KEY + SITE_URL + SERVICE_ROLE_KEY
- `server/supabase/.env.local` — REF/URL/KEY/DB_PASSWORD/DATABASE_URL 참조용
## Edge Function Secrets — 필요한 외부 키
지금 배포된 함수가 실제로 동작하려면 아래 키를 `supabase secrets set ...`로 주입해야 함. 키가 없으면 해당 함수는 400/500 반환.
| 키 | 쓰는 곳 | 필수? |
|---|---|---|
| `ANTHROPIC_API_KEY` | llm-proxy (채팅/액션/문서생성) | **필수** — 없으면 웹 /chat, /actions, meetings 문서 생성 불가 |
| `OPENAI_API_KEY` | embed-chunks, search-knowledge | **필수** — knowledge 시맨틱 검색에 필수 |
| `RESEND_API_KEY` | team-invite (이메일 발송) | 팀 초대 기능 쓸 때 |
| `RESEND_FROM` | team-invite 보낸이 | 기본값: `D3RO Voice <noreply@d3ro.dev>` |
| `SITE_URL` | team-invite 이메일 내 링크 | 기본값: `https://d3ro.dev` |
| `STRIPE_SECRET_KEY` | stripe-checkout/portal | 결제 기능 쓸 때 |
| `STRIPE_PRICE_PRO` / `STRIPE_PRICE_TEAM` | stripe-checkout | 각 요금제 Price ID |
| `STRIPE_WEBHOOK_SECRET` | stripe-webhook | 웹훅 서명 검증 |
| `GOOGLE_CLOUD_STT_KEY` | stt-proxy | 현재 스캐폴딩 상태 (index.ts 주석처리) |
주입 예:
```bash
cd server
SUPABASE_ACCESS_TOKEN=<ACCESS_TOKEN> supabase secrets set \
ANTHROPIC_API_KEY=sk-ant-... \
OPENAI_API_KEY=sk-proj-... \
RESEND_API_KEY=re_...
```
## 현재 단계
**V1 완료****V2 1차 전 페이즈 + 2차/3차/4차/5차 고도화 완료****Mac 부트스트랩 + Supabase 배포 완료**
V2 1차:
- Phase V2-1 ✅ (Monorepo 전환 a/b/c/d + minor 정리)
- Phase V2-2 ✅ 로컬 + 원격 배포 완료 (Supabase llnocwyqvhgwpdjcqqyw)
- Phase V2-3 ✅ (Web App MVP — Next.js 15, 12 라우트)
- Phase V2-4 ✅ (데스크톱 Supabase push 동기화)
- Phase V2-5 🟡 로컬 완료 (Mac 빌드), 사용자 검증 대기
- Phase V2-6 ✅ (Mobile MVP — Expo)
- Phase V2-7 ✅ (팀 기능 — Web /teams)
- Phase V2-8 ✅ (결제 스캐폴딩 — Stripe Edge Functions + /billing)
V2 2차 고도화 (2026-04-09):
- [A1] ✅ SettingsModal에 CloudSyncSection 통합
- [A2] ✅ web Sidebar 공유 layout (app/(app)/)
- [A3] ✅ 11개 locale 번역
- [B] ✅ V2-4b pull 동기화 — history/dictionary
- [C] ✅ V2-8b Stripe webhook HMAC-SHA256 + Customer Portal
- [D] ✅ V2-6b packages/ui-native 패키지
V2 3차 고도화 (2026-04-10):
- [E] ✅ V2-4b pull 확장 — meetings/meeting_memos/meeting_documents
- [F] ⚠️ Database 제네릭 — interface→type 전환 (4차에서 완전 복원)
- [G] ✅ apps/mobile UI를 @d3ro/ui-native로 실제 교체
- [H] ✅ V2-7b 이메일 invite flow (team_invites + Edge Functions + /accept-invite)
- [I] ✅ Realtime transcripts 구독 (LiveTranscriptList)
- [J] ✅ api-client 유닛 테스트 + web E2E 스모크 스캐폴딩
V2 4차 고도화 (2026-04-10):
- [K] ✅ @supabase/ssr 0.5→0.10, supabase-js 2.45→2.103 bump → Database 제네릭 완전 주입
- [L] ✅ V1 Voice Conversation → web /chat
- [M] ✅ V1 RAG → web /knowledge
- [N] ✅ team-invite Resend 이메일 발송
- [O] ✅ mobile Expo Push
- [P] ✅ meetings/[id] 문서 생성 버튼
V2 5차 고도화 (2026-04-10):
- [Q] ✅ pgvector + knowledge 시맨틱 검색 — embedding 컬럼(1536차원), ivfflat 인덱스, match_knowledge_chunks RPC, embed-chunks/search-knowledge Edge Functions, /knowledge 검색창
- [R] ✅ /chat SSE 스트리밍 — llm-proxy가 Anthropic stream 프록시, chat-panel에서 content_block_delta 파싱 후 progressive 렌더링
- [S] ✅ 회의 문서 편집 DocumentEditor — MUI Dialog + textarea 기반 MarkdownEditor, 저장/삭제
- [T] ✅ V1 VoiceAction → web /actions — 자연어 명령 LLM 파싱 (create_meeting/search_knowledge/create_memo/send_team_invite), 파싱 결과 확인 후 실행
- [U] ✅ Desktop CloudSyncService Realtime 구독 + web record 오디오 Storage 업로드 + 회의 메모 작성 UI (MemoForm)
- [V] ✅ 11개 locale에 nav.chat/knowledge/actions 키 추가, ko.json 정리, Sidebar Actions 메뉴
V2 6차 고도화 (2026-04-11):
- [W] 🐛 fix: AppRouterCacheProvider Emotion key `'d3ro-mui'``'mui'` (`d3ro` 안에 숫자 `3` → Emotion 검증 실패로 dev SSR 500)
- [A] ✅ Dark/Light/Theme 토글 — `theme-mode-context.tsx` (localStorage + prefers-color-scheme), Sidebar에 6종 + auto MUI Select, 12개 locale `theme.*` 키 추가
- [B] ✅ DocumentEditor Mermaid + Markdown 렌더 — `markdown-preview.tsx` (react-markdown + remark-gfm + lazy mermaid), Edit/Preview Tabs
- [C] ✅ 회의 오디오 재생 — `meeting-audio-player.tsx` (Storage signed URL 1h TTL → `<audio controls>`), `/meetings/[id]` AUDIO 카드
- [D] ✅ Dashboard 14일 추이 차트 — recharts LineChart (회의/문서 수 일별 집계), 이번 주 stat 자동 계산
- [E] 🐛 fix(desktop): macOS 단축키 표기 — `format-hotkey.ts` 신규 (플랫폼별 segment 변환). preload `electronAPI.platform` 노출. macOS는 `⌃ ⌥ ⇧ ⌘` (Apple HIG 순서) + 구분자 없음 (`⌘⇧1`), Win/Linux는 `Ctrl + Shift + 1`. SettingsModal/OnboardingModal/DashboardPage/HotkeyRecordModal 모두 `binding.displayLabel` 직접 참조 → `formatHotkeyLabel/Segments(binding)`로 교체. displayLabel split 버그(저장 시 `'+'` join, 표시 시 `' + '` split 불일치)도 함께 해소.
- [F] 🐛 fix(desktop): Mac sidecar 부팅 — 시스템 python3에 numpy 등 deps 없어서 매번 ModuleNotFoundError. `apps/desktop/sidecar/.venv/` 신규(gitignored), faster-whisper/fastapi/uvicorn/python-multipart/numpy 설치. `paths.ts.getSidecarCommand`가 venv python 우선 사용 (macOS `.venv/bin/python3`, Win `.venv/Scripts/python.exe`).
- [G] 🐛 fix(desktop): HotkeyRecordModal 칩 'WIN' 표기 잔존 — `getKeyName`이 KEY_DISPLAY_MAP(Windows 전용 91/92→'Win') 사용. `format-hotkey.ts``keyCodeToName/getPlatform` export → modal에서 호출. `'Shift + Win + 1 — 저장' ` 안내 텍스트도 mac에서 구분자 없이 결합.
- [H] 🐛 fix(desktop): `hideRecordingTip is not defined` ReferenceError — `bootstrap.ts`의 WindowManager import 목록에 `hideRecordingTip`/`updateRecordingTipState` 누락. session-cancelled/error 핸들러에서 호출 → ReferenceError로 RecordingTip 팝업이 닫히지 않는 누적 버그. import 추가.
- [I] 🐛 fix(desktop): 핫키 누를 때 macOS 시스템 beep — uiohook-napi는 글로벌 모니터링만 하고 OS swallow 안 함. 키가 받는 곳 없어 NSBeep 발생. `HotkeyService`에 Electron `globalShortcut` 통합. `bindingToAccelerator(binding)`이 Windows VK + modifier → Electron Accelerator string 변환 (mac은 `Cmd`, win은 `Super`). `globalShortcut.register(accel, noop)`로 OS swallow + uiohook은 그대로 hold/release 처리. `HotkeyConfig.acceleratorString` 필드 추가. unregister/stop에서 cleanup. 단일 modifier 키만 등록 시(Right Alt 단독 등) accelerator 변환 불가하면 globalShortcut 건너뛰고 uiohook만 사용.
- [J] 🐛 fix(desktop): sidecar 동적 포트 + orphan 충돌 — dev mode HMR 재시작 시 이전 sidecar python이 orphan으로 18765 점유. 새 main이 spawn하면 EADDRINUSE로 즉시 exit. `LocalSTTService._findFreePort(start, max=20)` 추가 (`net.createServer().listen(port, '127.0.0.1')`로 free 포트 탐색), spawn 진입 시 `this._port` 동적 갱신. health/load/transcribe URL이 자동으로 새 포트 사용.
- [K] 🐛 fix(desktop): macOS 텍스트 붙여넣기 — `TextInsertService._insertViaClipboard``nut.Key.LeftControl + V` 하드코딩이라 macOS에서는 paste 안 됨 (Cmd+V여야). `pasteModKey = process.platform === 'darwin' ? LeftSuper : LeftControl` 분기. 잘못된 클립보드 검증("text 그대로면 정상") 로직 제거 (macOS는 Ctrl+V가 noop이라 항상 거짓 양성). macOS는 paste 후 sleep 250ms.
- [L] 🐛 fix(desktop): qwen3 reasoning 39초 + 빈 응답 — qwen3:4b는 reasoning model이라 응답에 `<think>...</think>` 블록을 길게 출력, 본문이 비어 _completeSession("")로 paste 호출 안 됨. SYSTEM_PROMPTS 전체에 `/no_think` 토큰 추가 (qwen3 비활성화, 다른 모델은 무시). `stripReasoningBlocks()` 추가 — `<think>`/`<thinking>` 태그 안전망. `processText`가 stripped 결과 길이 0이면 원본 transcript fallback + warn 로그.
## SaaS 데스크톱 클라이언트 1차 (2026-04-11)
> 사용자 표현: "설계가 잘못된 것 같다"
> 실제 상황: 백엔드/웹/모바일은 100% multi-tenant SaaS, 데스크톱만 V1 single-user 잔재로
> 사용자가 Settings에서 Supabase URL/Key를 직접 입력해야 하는 UX였음.
> Notion/Linear/Slack 데스크톱 패턴으로 정정.
### SaaS [1] 빌드 타임 Supabase env 주입
- `electron.vite.config.ts`: `defineConfig(({ mode }) => ...)`로 변경, `loadEnv(mode, cwd, 'D3RO_')`
`.env.local` 자동 로드 → main 번들에 `define`으로 inline 박음
- `apps/desktop/.env.local` 생성 (gitignored): `D3RO_SUPABASE_URL`, `D3RO_SUPABASE_ANON_KEY`
- `apps/desktop/env.example` 템플릿 (committed)
- `ConfigService`:
- `BUILD_TIME_SUPABASE_URL` / `BUILD_TIME_SUPABASE_ANON_KEY` 상수 (process.env에서 읽음)
- `isSupabaseBuildTimeConfigured()` 헬퍼 export
- `configGet('supabaseUrl'/'supabaseAnonKey')`은 빌드 타임 값 절대 우선
- `configSet`는 빌드 타임 모드일 때 supabaseUrl/AnonKey 변경 거부
### SaaS [2] OAuth 첫 실행 강제 게이트 → **철회 (Phase 1.5에서 로컬 모드 entry point로 변경)**
- `CloudSyncState``saasMode: boolean` 필드 추가 (유지)
- `CloudSyncSection`: saasMode에서 OAuth 버튼만, legacy에서는 URL/Key 입력 UI (유지)
- ~~`LoginScreen.tsx` 강제 게이트~~ → 제거 (App.tsx에서 사용 안 함, 파일 자체는 유지)
- ~~`App.tsx` AuthGate 상태머신~~ → 제거 (항상 메인 UI 렌더)
**철회 이유** (2026-04-11 빅뱅 Phase 1.5):
사용자 피드백 "무료 로컬은 사용자 엔트리 포인트가 될 거구, 수익화는 클라우드 SaaS 서비스임".
로컬 STT/LLM이 이 앱의 킬러 피처 → 회원가입 없이 즉시 체험 가능해야 함 (onboarding funnel 입구).
OAuth provider도 아직 Supabase에 설정 안 된 상태. 강제 게이트는 철학 + 인프라 양쪽 모두 위배.
### SaaS [3] 사용자별 SQLite 격리 — ✅ **완료 (2026-04-11, 빅뱅 Phase 1)**
- **`apps/desktop/src/main/db/index.ts` 전면 재설계**
-`initDatabase()` / `closeDatabase()` 폐기 (이름 차원에서 제거)
- 새 API: `openForUser(userId)`, `closeCurrent()`, `getDatabase()`, `getCurrentUserId()`, `isDatabaseOpen()`, `getCurrentDbPath()`
- 경로: `userData/users/${userId}/d3ro.db` (userId path-separator/.. 가드)
- `applySchema(s)`로 테이블 + 인라인 ALTER 마이그레이션 추출 — 새 DB 생성 시마다 재실행
- 모듈 상태: `currentUserId | null`, `sqlite`, `db` — closeCurrent 후 모두 null
- 같은 userId 재호출 시 no-op, 다른 userId면 먼저 close 후 재오픈
- `openForUser` 실패 시 부분 상태 정리 + `D3ROError(DBOpenFailed)` throw
- **legacy DB 정책**
- V1 `userData/d3ro-voice.db`는 한 번도 정식 서비스 안 됨 → `archiveLegacyDbIfExists()`로 첫 `openForUser` 직전 silent rename (`d3ro-voice.legacy-${ISO timestamp}.db`)
- 파일 삭제 없음, dialog 없음, 데이터 이전 없음 ("클린하게" 요청)
- **`CloudSyncService` 인증 lifecycle 단일 진입점** (SSOT)
- `_onAuthenticated(session, { reason: 'restore' | 'signin' })`:
1. `this._session = session`
2. `openForUser(userId)` — 실패 시 세션 무효화 + `auth-changed(null)` + `sync-error` emit 후 return
3. `auth-changed(user)` emit (DB가 열린 뒤에)
4. `startRealtime()` 자동 시작
- `init()` 세션 복원, `handleAuthCallback(code)` 신규 로그인 모두 이 함수로 수렴
- `_onSignOut()`:
1. VoiceMode active → `cancelSession()` (녹음 진행 중이면 강제 취소)
2. MeetingMode active → `stopRecording()` (await)
3. Caption → `stop()` (await)
4. `stopRealtime()`
5. Supabase `auth.signOut()`
6. `_session = null` + `_clearStoredRefreshToken()`
7. `auth-changed(null)` emit — 렌더러 LoginScreen 복귀
8. `closeCurrent()` — 이 시점 이후 `getDatabase()`는 throw
- `signOut()` public 메서드는 `_onSignOut()` 위임
- `import` 동적 변경: VoiceMode/MeetingMode/Caption 모두 dynamic import로 순환 의존 회피
- **`bootstrap.ts` 순서 변경**
- `database` step 완전 제거 (critical 2번째였던 자리 삭제)
- `cloud-sync` step은 마지막 유지 — 세션 복원 성공 시 DB가 여기서 열림
- `initDatabase` import 삭제
- **검증**
- desktop `tsc --noEmit`
- desktop `npm run build` ✅ (main bundle 997kB, vite 경고는 기존 동적 import 관련 pre-existing)
- runtime (dev 띄워서 LoginScreen → OAuth → user DB 생성 확인) — 사용자 수동 검증 대기
- **남은 작업 (Phase 1 follow-up)**
- 사용자 A → signOut → 사용자 B 로그인 시 A 데이터 안 보이는지 실측
- `~/Library/Application Support/d3ro-voice/users/${uuid}/d3ro.db` 파일 생성 확인
- 기존 `d3ro-voice.db`가 있는 환경에서 archive rename 동작 확인
### SaaS [13] Voice Conversation 파이프라인 복구 + UX 개선 설계 (Phase 5 Part 5, 2026-04-11)
> **Voice Conversation이 Mac에서 한 번도 end-to-end 동작한 적 없었음을 발견.** 세 개의 독립된 버그가 합쳐져 있었고 진단 로그를 추가해 하나씩 분해해 해결. UX 개선은 사용자와 인터뷰로 설계서만 확정하고 구현은 다음 세션으로 이월.
**Bug 10.5 — TTS `spawn powershell ENOENT` (Mac)**
- 원인: `TTSPlaybackService.ts:105``spawn('powershell', ...)` 하드코딩. 파일 주석부터 "Windows SAPI (PowerShell) 기반" — V2-5 플랫폼 감사 때 이 파일 누락.
- Fix: `_speakOne`를 dispatcher로 바꾸고 `_speakOneMac`(macOS `say -r <wpm>`) / `_speakOneWindows`(기존 PowerShell) 분리. 공통 close/error 핸들러는 `_bindProcessHandlers`로 추출. `_getMacRate`(WPM 기준 180×speed) / `_getWindowsRate`(-10~10 SAPI) 분리.
- 파급: 음성 대화뿐 아니라 TTS를 쓰는 모든 경로가 Mac에서 벙어리였음. 이제 `/usr/bin/say`로 한/영/자동 판별 voice.
**Bug 11 — `finishListening` 오디오 버퍼 스냅샷 순서 (진짜 root cause, OS 무관)**
- 증상: 녹음 후 "생각 중" 진입은커녕 조용히 listening으로 복귀, STT 한 번도 안 탐. 진단 로그로 추적 시 `finishListening called: bufferCount=311, totalBytes=597120` 직후 1ms 뒤 `audio too short: 0 bytes`로 찍힘.
- 원인: `_stopListening()` 내부에 `this._audioBuffers = []` 리셋이 있는데 `finishListening`이 concat **이후**에 stop을 부르는 게 아니라 **이전**에 불러서, Buffer.concat 시점엔 이미 빈 배열. 19초짜리 오디오가 매번 날아감. Windows에서도 동일 버그였을 가능성 높음.
- Fix (`VoiceConversationService.ts:193`): `_stopListening()` **전**에 `const audioBuffer = Buffer.concat(this._audioBuffers)` 스냅샷 저장. `this._audioBuffers = []` 재설정도 제거(이미 stop에서 하므로 중복).
**Bug 12 — Whisper 모델 사전 로드 누락**
- 증상: Bug 11 fix 후 `finishListening`이 STT로 진입은 하지만 "모델 로딩 중, 오디오 버퍼에 적재"에서 영원히 pending. state가 'thinking'에 고정.
- 원인: `LocalSTTService.initialize()`가 모델을 로드하는데, MeetingMode/CaptionService는 startRecording 시 이걸 명시적으로 호출하지만 VoiceConversationService는 호출 안 함. 첫 transcribe가 내부 pending 큐에 쌓이지만 아무도 로드를 트리거하지 않아 deadlock.
- Fix (`VoiceConversationService.ts:84`): `startSession`에서 `void getLocalSTTService().initialize().catch(...)` fire-and-forget. initialize는 이미 Ready 같은 모델이면 즉시 return하므로 idempotent.
**검증**
- desktop `tsc --noEmit` EXIT=0
- Mac `/usr/bin/say -r 180` 직접 호출 시 스피커로 소리 확인 (사용자 증언 "오 들린다!")
- 사용자 재시연에서 Mic 클릭 → 말함 → ⏹ Stop → 전사 → LLM 스트리밍 → TTS 소리 → 자동 listening 복귀 연속 대화 성공
**Bug 13 (신규 발견, 미수정) — 빈 STT 결과 시 사용자 피드백 부재**
- 증상: 재시연 중 사용자가 ⏹ Stop 버튼 눌러도 "반응 없음"으로 느낌. 로그 추적 결과 finishListening → transcribe → `faster_whisper VAD filter removed 00:22.740 of audio` → 전사 `''` 빈 텍스트 → 조용히 listening 복귀 (에러 없음). 사용자 관점에선 버튼 먹통.
- 원인 조합:
1. Mac 시스템 마이크 입력 볼륨 너무 낮거나 `coreaudio input clipped` 왜곡으로 VAD가 전체 오디오를 무음 판정
2. `VoiceConversationService.finishListening`에서 빈 STT 결과를 조용히 삼키고 listening으로 복귀 — **사용자 피드백 없음**
- Fix 방향 (다음 세션): `_emitError('stt', 'No speech detected...')` 토스트 + 몰입 패널 내 "음성이 감지되지 않았습니다. 마이크 볼륨을 확인하세요." 힌트. 근본적으로는 UX 개선(몰입 패널)과 함께 처리.
- 워크어라운드: Mac 시스템 설정 → 사운드 → 입력 볼륨 조정 (중간 이상).
**UX 개선 설계 (구현 미완, 다음 세션 이월)**
사용자 피드백: 녹음 상태 불명확 / 텍스트 입력 방치 / 종료·응답 완료 피드백 부재. 인터뷰로 확정한 결정:
1. **녹음 모드 = 풀 몰입 계측기 패널** — 메시지 영역을 REC LED + 9바 phosphor waveform + 0:04 타이머 + "SPEAK NOW · PRESS ⏹ TO SEND" 힌트로 완전 대체 (listening 상태에서만)
2. **thinking/speaking 상태 = 메시지 리스트로 복귀** — 대화 맥락 유지. typing indicator 버블 추가. TextField는 disabled "응답 대기 중…"
3. **연속 대화** — 응답 후 자동 listening 복귀 (기존 코드 동작 유지)
4. **사운드 4개 전부** — recording-start/recording-stop/chime(recording-stop 재사용)/error
5. **Audio level IPC 신규 채널**`VOICE_CONVERSATION.AUDIO_LEVEL`로 AudioCaptureService 'audio-level' 이벤트 listening 상태에서만 forward
6. **SoundEffectService SoundName에 'chime' 추가** — 파일은 `recording-stop.wav` 재사용 (cancel=error 패턴과 동일). 나중에 전용 WAV로 교체 예정
설계 ASCII 와이어프레임 + 작업 리스트는 이번 세션 대화 말미에 존재. 다음 세션 우선 구현 대상.
**남은 미해결 이슈**
- U1 — Realtime TIMED_OUT (블로커 아님)
- U3 — Refresh token 간헐적 소진 (블로커 아님)
- U4 — `package-lock.json` optional dep 정리
- **Phase 3.2** — PREMIUM_LLM quota 게이트 (Anthropic/OpenAI key 주입 대기, 빅뱅 성공 기준 8번 중 마지막 1개)
- **Voice Conversation UX** — 위 설계 대로 다음 세션 구현
- **AudioCaptureService.getDevices()** — Mac에서 PowerShell 호출 실패 경고 로그 (1개 device fallback 동작, 블로커 아님)
---
### SaaS [12] /simplify 패스 — IME 가드 helper 추출 + 8곳 일괄 적용 (Phase 5 Part 5, 2026-04-11)
> **Bug 10의 한글 IME Enter 중복 가드를 `isImeComposingEvent` helper로 추출**. MeetingModePage 기존 인라인 가드 교체 + 7개 사이트 신규 적용 → 총 8곳 통합. Phase 3.3 훅 8곳은 품질 리뷰 결과 이미 최적 상태로 판정(수정 없음).
**신규 helper**`apps/desktop/src/renderer/utils/keyboard.ts`
```ts
export function isImeComposingEvent(e: KeyboardEvent): boolean {
return e.nativeEvent.isComposing || e.keyCode === 229
}
```
JSDoc에 Bug 10 원리(Chromium이 IME 조합 중 Enter를 2번 발화) + 권장 사용 패턴 포함.
**적용 8곳 (`isImeComposingEvent(e) return` 1줄 삽입)**
| 파일 | 라인 | 용도 | 변경 전 |
|---|---|---|---|
| `pages/MeetingModePage.tsx` | 164 | 회의 메모 입력 | 인라인 가드 4줄 (Bug 10 fix) |
| `pages/KnowledgeBasePage.tsx` | 84 | RAG 쿼리 | 가드 없음 |
| `pages/VoiceConversationPage.tsx` | 113 | 텍스트 채팅 | 가드 없음 |
| `components/meeting/MeetingChatPanel.tsx` | 111 | 미팅 챗 | 가드 없음 |
| `components/meeting/EditableSegment.tsx` | 70 | 전사 편집 (Enter+Esc) | 가드 없음 |
| `components/meeting/MeetingDetailTabs.tsx` | 242 | 미팅 타이틀 (인라인 arrow) | 인라인 `onKeyDown={(e)=>e.key==='Enter'&&...}` 1줄 → 블록 확장 |
| `components/shared/HistoryEntryCard.tsx` | 64 | 태그 추가 (Enter+Esc) | 가드 없음 |
| `pages/CommandsPage.tsx` | 336 | 키워드 추가 (Enter+Esc) | 가드 없음 |
**Phase 3.3 훅 품질 리뷰 결론 — 수정 없음**
- `HistoryService.create/updateTitle`, `DictionaryService.add/update`, `MeetingModeService.startRecording/addMemo/_runPostProcessing/generateDocument`, `MeetingSummaryService.generateAndSave` 8곳 전부 `void getCloudSyncService().pushOne(table, id)` + `// Phase 3.3: fire-and-forget` 주석 일관.
- `CloudSyncService.pushOne` 내부가 try-catch로 warn만 찍고 삼킴 → 호출자는 무조건 안전, 로컬 write 차단 금지 철학 준수.
- helper 추출 후보 없음(이미 1줄). 일관성 OK, DRY OK, 에러 흡수 OK → **현 상태가 최적, 리팩터 불필요**.
**검증**
- desktop `tsc --noEmit` ✅ EXIT=0
- Vite HMR 자동 반영(기존 dev 프로세스 살아있음) — 별도 재기동 없이 적용
- 대표 2곳 한글 Enter 실측 (VoiceConversationPage 채팅 / MeetingDetailTabs 타이틀) — 사용자 수동 검증 대기
**U5 해소, 남은 미해결 이슈**
- U1 — Realtime TIMED_OUT (블로커 아님)
- U3 — Refresh token 간헐적 소진 (블로커 아님)
- U4 — `package-lock.json` optional dep 정리
- Phase 3.2 — PREMIUM_LLM quota 게이트 (Anthropic/OpenAI key 주입 대기)
---
### SaaS [11] Meeting pre-push 직접 실증 + IME Enter 중복 addMemo 픽스 (Phase 5 Part 4, 2026-04-11)
> **U2 해소.** Meeting 모드 녹음을 직접 돌려 Fix 1(startRecording pre-push) + addMemo hook을 실시간 로그로 증명. 부수적으로 발견된 한글 IME Enter 중복 addMemo 버그(Bug 10)를 `isComposing` 가드로 픽스.
**직접 실증 타임라인 (session 78421e75, 21:51:24~21:51:50)**
```
21:51:24.937 pushOne meetings/78421e75 ok ← Fix 1 pre-push (813ms after startRecording)
21:51:30.499 pushOne meeting_memos/4ed7e59a ok ← addMemo #1 "메모가"
21:51:32.202 pushOne meeting_memos/de011c15 ok ← addMemo #2 "두번씩"
21:51:35.893 pushOne meeting_memos/3184fe5e ok ← addMemo #3 "입력되는걸 막아야합니다."
21:51:38.263 pushOne meeting_memos/00f041ef ok ← addMemo #4 "잘되는지 봅시다."
21:51:40.740 pushOne meeting_memos/0808c4d2 ok ← addMemo #5 "잘되는 것 같네요."
21:51:50.681 pushOne meetings/78421e75 ok ← _runPostProcessing hook
21:51:50.709 pushOne history/70388090 ok ← CaptionService → HistoryService.create
```
- **meetings/78421e75가 녹음 시작 + 종료 두 번 push** → Fix 1 완전 증명 (부모 row가 메모 push 시점에 이미 Supabase에 존재해서 자식 RLS/FK 통과)
- **메모 5건 전부 100ms 내 1:1 push** → addMemo hook 정상, RLS 에러 0
- **history push 출처 확인**: `CaptionService.ts:459`에서 회의 전사가 완료되면 전체 텍스트를 HistoryService에도 저장하는 기존 경로. Phase 3.3 훅이 `HistoryService.create()` 끝에 걸려 있어 자동으로 따라옴. 의도대로 동작.
**발견+픽스한 버그 — Bug 10: 한글 IME Enter 중복 addMemo**
- 증상: 첫 번째 시연 녹음(bd7f2367)에서 "메모 기능이 잘 작동하는지 봅니다"가 **2ms 간격으로 2번** 저장됨 (UUID c7f0db45 + a6e442cc, 로그 21:47:57.410/.412). 다른 메모들은 1회.
- 원인: 한글 IME 조합 중 Enter 키는 Chromium이 `keydown`을 두 번 발화 — (1) IME 조합 확정용(`isComposing=true`, keyCode=229) + (2) 실제 Enter 액션. 기존 `handleMemoKeyDown`(MeetingModePage.tsx:164-174)은 `e.key === 'Enter' && !e.shiftKey`만 검사하고 `isComposing`을 무시해 두 이벤트 모두에서 `handleAddMemo()` 발화 → IPC 2회 → `MeetingModeService.addMemo()` 2회 → DB insert 2건 → `pushOne meeting_memos` 2회. 중복이 다른 메모에서 안 난 이유는 해당 메모들은 IME 조합이 이미 확정된 상태(공백/한자 변환 등)에서 Enter를 쳤기 때문.
- Fix: `MeetingModePage.tsx:168`에 이중 가드 추가. `if (e.nativeEvent.isComposing || e.keyCode === 229) return``isComposing`은 모던 표준, `keyCode === 229`는 Chromium 엣지 케이스(조합 확정 시점에 isComposing이 false로 내려가면서 keyCode만 229로 주는 경우) 호환.
- 검증: 두 번째 녹음(78421e75)에서 한글 메모 5건 연속 입력 → **전원 1:1 매핑**, 중복 0. Vite HMR로 즉시 반영, 별도 재기동 없이 fix 유효.
**잠재 IME 버그 — 다른 Enter 핸들러 7곳 (다음 `/simplify` 때 일괄 처리 예정)**
| 파일 | 라인 | 용도 | 한글 위험도 |
|---|---|---|---|
| `KnowledgeBasePage.tsx` | 85 | RAG 쿼리 | 높음 |
| `VoiceConversationPage.tsx` | 114 | 텍스트 채팅 | 높음 |
| `components/meeting/MeetingChatPanel.tsx` | 113 | 미팅 챗 | 높음 |
| `components/meeting/EditableSegment.tsx` | 72 | 전사 세그먼트 편집 | 높음 |
| `components/meeting/MeetingDetailTabs.tsx` | 242 | 미팅 타이틀 | 중간 |
| `components/shared/HistoryEntryCard.tsx` | 65 | 태그 추가 | 중간 |
| `CommandsPage.tsx` | 337 | 키워드 추가 | 중간 |
(LicenseModal.tsx:196은 ASCII 전용이라 제외)
**검증**
- desktop `tsc --noEmit` ✅ (EXIT=0)
- Meeting 모드 녹음 1회차(bd7f2367): Fix 1 + addMemo hook 증명, IME 중복 1건 발견
- Meeting 모드 녹음 2회차(78421e75): IME fix 적용 후 메모 5건 전부 1:1 ✅
- Realtime 채널 상태: 여전히 U1 TIMED_OUT (블로커 아님, Phase 3.3 auto push가 대체 경로)
**빅뱅 성공 기준 상태 — 이번 세션 갱신 없음** (8개 중 7개 완료, Phase 3.2 PREMIUM_LLM만 잔존)
**다음 세션 권장 액션**
- `/simplify` 실행 — Phase 3.3 + Bug 10 fix 코드 품질 리뷰 + 위 7개 IME Enter 핸들러 일괄 가드 추가
- Phase 3.2 PREMIUM_LLM Edge Function 경로 (Anthropic/OpenAI key 주입 필요, 사용자 의사결정 대기)
- U1 Realtime TIMED_OUT 원인 조사 (선택)
- `package-lock.json` optional dep 정리 (Phase 5 Part 2부터 이월)
---
### SaaS [10] Phase 3.3 Auto push on write + 웹 transcript fallback + Dashboard Link 픽스 (Phase 5 Part 3, 2026-04-11)
> **수동 Push 버튼이 사라졌다.** 로컬 SQLite write가 일어나는 모든 경로(8곳)에서 `CloudSyncService.pushOne`이 fire-and-forget으로 즉시 실행되어 Supabase에 반영. 웹 Dashboard 새로고침 한 번이면 방금 녹음한 미팅/메모/전사까지 전부 보인다. 이번 세션 한 번에 end-to-end 실증까지 통과.
**실증 시나리오**
1. 데스크톱 dev 재기동 → `Restored session for user: yunchan8804@gmail.com` (cloud-sync.token 자동 복원) → 사용자 DB 재오픈
2. 신규 Meeting 모드 녹음 1건 (약 33초, 중간에 메모 2건 추가)
3. 로그에 즉시 나타난 push 이벤트:
- `pushOne history/4c860fe2-... ok`
- `pushOne meetings/6bb51eca-... ok`
4. 재기동 시 pushAll 캐치업으로 직전 실패한 메모 2건이 자동 재시도되어 성공 (`Sync complete: pushed=4 errors=0`)
5.`http://localhost:3000/meetings/6bb51eca-...` 새로고침 → **전사 + 메모 둘 다 렌더 확인** (사용자 "잘 전사와 메모가 올라왔어")
**구현 — Phase 3.3 Auto push on write**
1. `CloudSyncService.pushOne(table, id)` 신규 — 제네릭 단건 push. 로그인 상태 아니면 silent noop(로컬 write 절대 차단 금지), 실패 시 `logger.warn`만(throw 금지). `pushAll`과 공통 매퍼 공유 (`_mapHistoryPayload` / `_mapDictionaryPayload` / `_mapMeetingPayload` / `_mapMemoPayload` / `_mapDocumentPayload` 5개 private 메서드 추출, DRY).
2. Hook 삽입 위치 8곳 (전부 `void getCloudSyncService().pushOne(...)`, fire-and-forget):
- `HistoryService.create()` 끝 → history
- `HistoryService.generateTitle()` DB update 직후 → history (LLM 자동 타이틀)
- `MeetingSummaryService.generateSummary()` summaryText 저장 후 → history ("updatePolishedText" 상응)
- `DictionaryService.add()` 끝 → dictionary
- `DictionaryService.update()` 끝 → dictionary
- `MeetingModeService.startRecording()` 끝 → meetings (**부모 pre-push**, Fix 1 참조)
- `MeetingModeService.addMemo()` 끝 → meeting_memos
- `MeetingModeService._runPostProcessing()` 완료 상태 저장 직후 → meetings (raw_transcript 포함)
- `MeetingModeService.generateDocument()` DB insert 직후 → meeting_documents
**발견+픽스한 구조 버그 3건 (이번 세션)**
#### Bug 7: meeting_memos RLS violation — 부모 meeting row 미존재
- 증상: 녹음 중 메모 추가 → `pushOne meeting_memos/4a733d76-... failed: new row violates row-level security policy for table "meeting_memos"` (21:19:14에 2건 실패)
- 원인: RLS 정책이 `meeting_id`에 해당하는 부모 `meetings` row의 존재/소유권을 요구하는 것으로 추정 (정확한 정책 확인은 안 함). 기존 구현에서는 `_runPostProcessing()`에서 "completed" 상태 저장 시점에만 meeting이 push되므로, 녹음 중 addMemo → pushOne 시점에는 **부모 row가 Supabase에 아직 없음** → 자식 insert RLS 거부.
- Fix 1: `MeetingModeService.startRecording()` 끝에 `void getCloudSyncService().pushOne('meetings', sessionId)` 추가 — 녹음 시작 즉시 부모 row(status='recording', raw_transcript=null)를 Supabase에 upsert. 이후 addMemo/generateDocument의 자식 push가 FK/RLS를 통과.
- 검증: 재기동 직후 pushAll 캐치업이 이전 세션의 실패 메모 2건을 재시도 → `Sync complete: pushed=4 errors=0` (meeting 1 + memos 2 + history 1 추정) → 웹에서 메모 렌더 확인.
- 부수 효과(의도적): fire-and-forget 실패는 pushAll 캐치업으로 자동 복구. 실패가 영구 손실로 이어지지 않음.
#### Bug 8: 웹 meeting detail 페이지가 transcripts 테이블만 읽음 (meetings.raw_transcript 무시)
- 증상: 녹음 종료 후 웹 `/meetings/[id]` 상세 페이지의 TRANSCRIPT 섹션이 빈 상태로 표시.
- 원인: `apps/web/src/app/(app)/meetings/[id]/page.tsx``supabase.from('transcripts').select(...).eq('meeting_id', id)`만 읽고 `LiveTranscriptList`에 넘김. 데스크톱은 CaptionService 세그먼트를 인메모리에 버퍼링했다가 `meeting_sessions.rawTranscript` 단일 텍스트 필드에만 저장 — `transcripts` 테이블에는 **아무것도 쓰지 않음**. Realtime 스트림 설계(웹 실시간 캡션 뷰어용)와 데스크톱 오프라인 전사 플로우의 접점이 없었음.
- Fix 2 (웹 fallback): `transcripts`가 비어있고 미팅 상태가 `recording`이 아닐 때 `meeting.edited_transcript ?? meeting.raw_transcript``whiteSpace: 'pre-wrap'`의 Box로 렌더. 둘 다 없으면 "전사가 없습니다." placeholder. recording 상태거나 `transcripts` 행이 있으면 기존 `LiveTranscriptList` 유지(향후 실시간 캡션 뷰어 경로 보존).
- 왜 데스크톱을 고치지 않았나: MVP 스코프 고려. 세그먼트 per-row push는 쓰기 빈도가 폭증하고 RLS 정책을 별도로 다듬어야 함. Phase 3.3의 목표는 "완료된 미팅이 웹에서 읽혀야 한다"이고, 그건 meetings.raw_transcript 경로 하나로 충분. 실시간 캡션 동기화가 정식 기능으로 들어올 때 desktop → transcripts 쓰기를 추가.
#### Bug 9: Dashboard RECENT MEETINGS 카드 클릭 무반응 (U2)
- 증상: 지난 세션 말미 사용자 피드백 "웹에서 미팅 아이템이 들어가지질 않는다" 재확인 → Dashboard 카드 클릭 시 `/meetings/[id]` 이동 안 됨.
- 원인: `apps/web/src/app/(app)/dashboard/page.tsx:156``MetalCard``<Link>`로 감싸져 있지 않아 클릭 인터랙션 자체가 없었음.
- Fix 3: `next/link``Link``MetalCard`를 감싸고 `href={\`/meetings/${meeting.id}\`}`, `cursor: 'pointer'` + `&:hover { transform: translateY(-1px) }` hover 효과 추가, `textDecoration: 'none'`으로 링크 기본 스타일 억제.
- 검증: 새 기동 후 실증 녹음 전에 이미 웹에서 `GET /meetings/cd4df4f1-... 200` 확인됨.
**검증**
- desktop `tsc --noEmit`
- web `tsc --noEmit`
- dev 런타임 재기동 ✅ (`Restored session` → 직전 `Sync complete: pushed=4 errors=0` 캐치업 → Main window)
- 녹음 1건 fire-and-forget push ✅ (`pushOne history/... ok` + `pushOne meetings/... ok`, 21:19:38)
- **웹 Dashboard 새 미팅 렌더 ✅** (카드 + 상세 페이지 + 전사 + 메모 전부)
- **사용자 확인: "잘 전사와 메모가 올라왔어"**
- Realtime 채널 TIMED_OUT ❌ (알려진 U1, 블로커 아님 — Phase 3.3 auto push가 대체 일관성 경로)
**다음 세션 권장 액션**
- Fix 1의 재현 실증 (녹음 → 중간 메모 추가 시점마다 `pushOne meeting_memos/... ok` 실시간 발화 — 이번 세션은 pushAll 캐치업으로 통과해서 pre-push 효과를 직접 본 건 아님)
- U1 Realtime TIMED_OUT 원인 조사 (선택)
- Phase 3.2 PREMIUM_LLM Edge Function 경로 (Anthropic/OpenAI key 주입 필요)
- 24 → 25+ commits → git push 컨펌 (사용자 결정 시점)
- `package-lock.json` 정리 (`rollup-win32-x64-msvc` optional dep 이슈)
---
### SaaS [9] 로컬 ID UUID 통일 + Realtime publication 픽스 + 실증 A~E 전원 통과 (Phase 5 Part 2, 2026-04-11)
> **3 클라이언트 데이터 일원화 end-to-end 실증 완료.** Phase 5 Part 1에서 못 잡은 3개 구조 버그를 한 세션에 픽스하고 실증 A/B/C/D/E 전원 통과.
> 로컬→클라우드 push가 **처음으로 성공** (pushed=1, pushed=5), 데스크톱 녹음 데이터가 **웹 Dashboard에 그대로 반영**되는 것을 스크린샷 캡처까지 확인.
**실증 결과 (18:36~19:11)**
| # | 실증 | 결과 | 증거 |
|---|---|---|---|
| A | 세션 자동 복원 + RLS 픽스 검증 | ✅ | `Restored session for user: yunchan8804@gmail.com` + `Pull complete applied=0 errors=0` |
| B | 사용자별 DB + push 경로 (history 단건) | ✅ | `HistoryService created 56a767ac-0fbc-45c8-98f2-2e2d4afa70f9` + `Sync complete: pushed=1 errors=0` |
| C | 로그아웃 → 로컬 복귀 | ✅ | `Realtime 종료``Database closed (7da3dd02-...)``Reverted to local DB: users/_local/d3ro.db``continuing in local mode` |
| D | 재로그인 데이터 복원 | ✅ | 19:09:00 재로그인 → 같은 uuid DB가 `created=false`로 재오픈 (파일 보존 확인) |
| E | **웹 크로스 디바이스 (Meeting 경로)** | ✅ | Meeting Mode 녹음 → `Sync complete: pushed=5 errors=0` → 웹 `localhost:3000/dashboard` 새로고침 → RECENT MEETINGS에 "(제목 없음) 2026. 4. 11. 오후 7:09:21" 표시, "총 회의 1 / 이번 주 1 / ACTIVITY TREND 04/11 스파이크" 전부 일치 |
**실증 E 세부 플로우** (end-to-end 전체 데이터 경로 검증)
```
데스크톱 Meeting Mode (19:09:53 History created: 093d9d5d-...)
→ MeetingModeService가 meeting_sessions / meeting_memos / meeting_documents 로컬 write
→ users/7da3dd02-.../d3ro.db (SQLite, UUID PK)
→ 수동 Push 버튼 (19:11:21)
→ CloudSyncService.pushAll (history, dictionary, meetings, meeting_memos, meeting_documents 5개 upsert)
→ "Sync complete: pushed=5 errors=0"
→ Supabase `meetings` 테이블 upsert (RLS user_id=auth.uid() 통과)
→ 웹 Next.js dashboard getSupabaseServerClient().from('meetings').select(...)
→ 브라우저 렌더: "(제목 없음) 2026. 4. 11. 오후 7:09:21"
```
**발견+픽스한 구조 버그 3건**
#### Bug 4: 로컬 nanoid PK vs Supabase UUID PK 불일치 (실증 B 첫 시도에서 발견)
- 증상: `Push history failed: invalid input syntax for type uuid: "fvy6bIzrEtZ0vo96Dd-WZ"``Sync complete: pushed=0 errors=1`
- 원인: `apps/desktop/src/main/db/schema.ts`의 모든 PK는 `text('id').primaryKey()` + 서비스 코드는 `nanoid()` 생성 (예: `fvy6bIzr...`, 21자). 반면 Supabase `initial_schema.sql``id uuid PRIMARY KEY DEFAULT gen_random_uuid()`. Phase V2-4 설계 단계에서 "V2-4 MVP는 uuid로 새로 발급, 매핑 테이블 필요"라고 적혀 있었으나 실제 구현에선 매핑이 없어 push 경로 전체가 깨져 있었음. 결과적으로 **빅뱅 사이클 내내 push가 한 번도 성공한 적 없음** (지난 세션 `pushed=0`은 로컬 데이터 0건이라 에러가 안 난 거지 버그가 없어서가 아님).
- 사용자 결정: **로컬을 UUID로 통일** (방향 B). 이유 — 3 클라이언트(desktop/web/mobile) id 스펙 일관 + PostgreSQL UUID PK 관용 유지 + `crypto.randomUUID()` 표준 API + `nanoid` 외부 의존성 자체 제거. "땜질 아닌 근본 해결" 사용자 요청.
- 픽스:
- 14개 서비스 파일에서 `import { nanoid } from 'nanoid'` 제거 + `nanoid()``crypto.randomUUID()` 일괄 교체 (총 20곳): HistoryService / MeetingModeService / MemoService / DictionaryService / CaptionService / VoiceConversationService / VoiceCommandService / VoiceActionService / FileTranscriptionService / CustomInstructionService / MeetingDocTemplateService / DictationTemplateService / RAGService / ChainService
- `apps/desktop/package.json` dependencies에서 `nanoid: ^5.1.7` 제거
- `apps/desktop/electron.vite.config.ts` externalizeDepsPlugin exclude에서 `'nanoid'` 제거
- 기존 로컬 DB 2개(`users/_local`, `users/7da3dd02-...`) drop — dev 초기 nanoid row만 있었고 의미 있는 데이터 0건이라 cascade FK 마이그레이션 대신 재생성 선택
- drizzle schema(`text('id').primaryKey()`)는 건드릴 필요 없음 — SQLite text PK는 UUID 36자 문자열을 그대로 저장
- 검증: 재기동 후 신규 녹음 → `HistoryService created 56a767ac-0fbc-45c8-98f2-2e2d4afa70f9` (UUID v4) → Push → `pushed=1 errors=0` **최초 성공**
#### Bug 5: Supabase supabase_realtime publication 누락
- 증상: 로그인 직후 `Realtime 채널 상태: TIMED_OUT` (실증 A에서 재현, 재기동 후에도 재현)
- 원인: `20260409000001_initial_schema.sql:134``ALTER PUBLICATION supabase_realtime ADD TABLE public.transcripts` 한 줄만 실행. 데스크톱 `CloudSyncService.startRealtime()`이 실제 구독하는 `meetings` / `history` / `dictionary`는 publication에 **포함되지 않아** postgres_changes 스트림이 물리적으로 흐를 수 없었음.
- 픽스: `20260411000002_realtime_publication.sql` 신규 — `DO $$ ... LOOP ... ALTER PUBLICATION supabase_realtime ADD TABLE public.%I ... END $$;` 패턴으로 `meetings` / `meeting_memos` / `meeting_documents` / `history` / `dictionary` 5개 테이블을 pg_publication_tables 카탈로그 체크 후 조건부 추가. `supabase db push` 적용 완료.
#### Bug 6: persistSession:false 에서 realtime.setAuth 자동 전파 안 됨 (가설 — 부분 검증)
- 증상: Bug 5 migration 적용 후에도 TIMED_OUT 재발.
- 원인 (가설): `createClient(url, anonKey, { auth: { persistSession: false, ... } })` 구성에서 `setSession()` 호출이 onAuthStateChange를 통한 `realtime.setAuth()` 자동 전파 경로를 깨뜨리는 것으로 추정. 즉 realtime WebSocket이 anon key JWT로 join을 시도하고 RLS 필터(`user_id=eq.${userId}`)를 통과 못해 TIMED_OUT.
- 픽스: `CloudSyncService.startRealtime()``this._client.realtime.setAuth(this._session.access_token)`을 채널 구성 **이전**에 명시 호출 (try/catch로 안전하게). 에러 시 warn만.
- 현재 상태: ⚠️ **여전히 TIMED_OUT 재현**. Bug 5 migration은 확실히 적용됐고 setAuth 명시 호출도 들어갔으나 해결 안 됨. 후속 조사 필요 (task 8로 분리). 가능 가설 — (a) Supabase 프로젝트 Realtime 서비스 자체 disabled, (b) setAuth 타이밍 문제(WebSocket 이미 connect된 상태에서 setAuth 무효), (c) RLS 정책이 `user_id = auth.uid()` 외 다른 조건을 요구, (d) realtime 전용 RLS policy(`realtime.messages` 구독 권한)가 별도로 필요.
- **블로커 여부**: 아님. 주기적 pull + Phase 3.3 Auto push 경로로 최종 일관성 유지. Realtime은 "웹→데스크톱 5초 이내 반영" 편의 기능.
**픽스로 해결된 부수 이슈**
- `cloud-sync.token` 기반 자동 세션 복원 경로 ✅ (지난 세션 `Invalid Refresh Token: Already Used`는 당시 토큰이 소진된 일회성 이슈, 새 로그인 후 재시작하니 `Restored session for user: yunchan8804@gmail.com` 성공)
- `users/${uuid}/d3ro.db` 파일이 로그아웃 시 close만 되고 보존되어 재로그인 시 `created=false`로 복원되는 경로 ✅
- LicenseService 로컬 모드 리셋 경로 ✅ (로그아웃 후 익명 로컬 모드 정상 동작)
**검증**
- desktop `tsc --noEmit`
- dev 런타임 재기동 ✅ (`Main window shown``Restored session``DB opened for 7da3dd02``Initial sync pushed=0 pulled=0`)
- 핫키 녹음 → UUID row 생성 → Push → `pushed=1 errors=0` ✅ (**최초 성공**)
- 로그아웃 → Realtime 종료 → `_local` DB 복귀 ✅
**이 섹션에서 의도적으로 뺀 것**
- package-lock.json 정리: `npm install`이 기존부터 `rollup-win32-x64-msvc` optional dep로 실패. 당장 nanoid import는 이미 제거되어 런타임 영향 없음. lockfile은 후속 세션에서 `--omit=optional`로 정리.
- Realtime 근본 원인 — task 8 별건으로 분리
- 실증 E(웹 크로스 디바이스) — Phase 3.3 이후 수행
### SaaS [8] OAuth 로그인 완주 + RLS 재귀 픽스 (Phase 5 Part 1, 2026-04-11)
> **로그인 성공!** 빅뱅 Phase 5 실증 시작. Google OAuth 토큰 교환 성공, 사용자별 DB 생성 확인.
> 그러나 첫 pull에서 Supabase RLS 무한 재귀 + Realtime TIMED_OUT 발생 → 같은 세션에서 즉시 픽스.
**성공 이벤트 로그 (11:46:52 ~ 55)**
```
[Opening OAuth URL in external browser: google]
[deep-link] Implicit flow tokens detected, calling handleAuthTokens()
[CloudSyncService] Signed in (implicit): yunchan8804@gmail.com
[auth:signin] DB opened for 7da3dd02-9f2f-4ee9-a9b3-1c2c24875a93:
.../users/7da3dd02-.../d3ro.db (created=true)
[auth:signin] Initial sync starting — push then pull
[deep-link] handleAuthTokens completed successfully
[auth:signin] Initial push done: pushed=0, errors=0
```
**발견된 버그 + 픽스**
#### Bug 1: Supabase implicit flow 응답을 데스크톱이 무시
- 증상: 로그인 4회 시도, 매번 `auth-callback deep link had no 'code' parameter`
- 원인: supabase-js가 `persistSession: false`일 때 PKCE code_verifier 저장소가 없어 **implicit flow로 fallback**. Supabase가 fragment(`#access_token=...&refresh_token=...`)로 토큰 직접 전달. 우리 `handleDeepLink``?code=...`(PKCE)만 파싱.
- 픽스:
- `apps/desktop/src/main/index.ts`: `handleDeepLink`가 query(`?code=`)와 fragment(`#access_token=`) 둘 다 파싱. fragment에 토큰 있으면 `CloudSyncService.handleAuthTokens()` 호출. 에러 파라미터(`error=`, `error_description=`) 상세 로깅.
- `CloudSyncService.handleAuthTokens({accessToken, refreshToken})` 신규 — `client.auth.setSession()`으로 세션 복원 후 기존 `_onAuthenticated(session, {reason: 'signin'})` SSOT 진입점으로 수렴.
- 토큰 로깅은 host+path만, 토큰 값은 로그에 안 찍히도록 수정 (`query=yes/no, fragment=yes/no`)
#### Bug 2: Supabase RLS 무한 재귀
- 증상: `Pull meetings failed: infinite recursion detected in policy for relation "team_members"` × 3 테이블 + `Realtime 채널 상태: TIMED_OUT`
- 원인 (`20260409000002_rls_policies.sql`): `team_members` SELECT 정책이 **자기 테이블을 재조회**:
```sql
CREATE POLICY "team_members_read_same_team" ON team_members
FOR SELECT USING (
team_id IN (SELECT team_id FROM team_members WHERE user_id = auth.uid())
);
```
이 정책이 Postgres RLS 엔진에서 무한 재귀 탐지로 에러. `meetings` / `meeting_memos` / `meeting_documents` / `team_invites` 정책이 모두 `team_members` 서브쿼리를 경유해서 **전부 같이 터짐**. Realtime 구독도 같은 이유로 TIMED_OUT.
- 픽스 (`server/supabase/migrations/20260411000001_fix_team_members_rls_recursion.sql`):
- `SECURITY DEFINER` 함수 2개 신규 — Supabase 공식 권장 패턴으로 재귀 끊기
- `public.user_team_ids(uid uuid) RETURNS SETOF uuid`
- `public.user_admin_team_ids(uid uuid) RETURNS SETOF uuid`
- `search_path = public, pg_temp` 고정 + `REVOKE ALL` 후 `authenticated, service_role`에만 `GRANT EXECUTE`
- 기존 정책 재작성: `team_id IN (SELECT ... FROM team_members)` → `team_id IN (SELECT public.user_team_ids(auth.uid()))`
- 영향 정책: `team_members` × 4개, `meetings` × 2개, `meeting_memos` × 1개, `meeting_documents` × 2개, `team_invites` × 1개
- `supabase db push` 적용 완료
#### Bug 3: LoginScreen stale 번들
- 증상: Phase 1.5에서 LoginScreen import 제거했는데도 렌더러 화면에 LoginScreen 계속 뜸. HMR/vite cache 혼동 상태.
- 원인: 불명 (vite HMR이 어딘가에서 stale state 유지한 듯, grep으로는 import 사용처 0개 확인)
- 픽스: **`LoginScreen.tsx` 파일 자체 삭제**. vite가 컴파일할 대상이 아예 사라져서 stale이 불가능해짐.
**검증**
- desktop `tsc --noEmit` ✅
- desktop dev 런타임 ✅ (로그인 성공 확인)
- Supabase `db push` ✅ (migration 적용)
- pull / Realtime 재테스트 — 사용자가 Settings → Cloud Sync → Pull 눌러서 확인 필요
### SaaS [7] 3 클라이언트 일원화 검증 — **OAuth provider 연결 대기** (Phase 5)
> Phase 1~4+3이 로컬/클라우드 feature gate + sync 인프라를 모두 배선했으나,
> 실제 로그인을 통해 "데스크톱 ↔ 웹 ↔ 모바일 같은 데이터 관찰"을 검증하려면 Supabase OAuth provider 연결이 선행되어야 함.
**사용자가 해야 할 선행 작업 (Supabase 대시보드)**
1. https://supabase.com/dashboard/project/llnocwyqvhgwpdjcqqyw/auth/providers 접속
2. **Google provider 활성화**:
- Google Cloud Console에서 OAuth 2.0 Client ID 발급
- Application type: Web application
- Authorized redirect URIs: `https://llnocwyqvhgwpdjcqqyw.supabase.co/auth/v1/callback`
- Supabase 대시보드의 Google provider에 Client ID/Secret 입력
3. **(선택) GitHub provider**도 동일 패턴으로 활성화
4. 데스크톱 deep link scheme은 이미 `d3ro-voice://auth-callback`으로 설정됨
**OAuth 연결 후 검증 체크리스트 (다음 세션에서)**
- [ ] 데스크톱 Settings → Cloud Sync → Google 클릭 → 브라우저 동의 → 앱으로 deep link 콜백
- [ ] `[auth:signin] DB opened for ${uuid}` 로그 + `users/${uuid}/d3ro.db` 파일 생성
- [ ] `[auth:signin] Initial push done` + `Initial pull done` 로그
- [ ] `subscriptions` 테이블에 free 티어 row 자동 생성 (auth trigger)
- [ ] LicenseService tier가 'free'로 sync됨 (tier-changed 이벤트)
- [ ] 웹 앱(`apps/web`) 에서 같은 계정으로 로그인 → 데스크톱에서 생성한 history/meetings 즉시 조회
- [ ] 웹에서 meeting 편집 → 데스크톱에 Realtime으로 반영 (5초 이내)
- [ ] 모바일 Expo 앱에서 같은 계정 로그인 → 같은 데이터
- [ ] 데스크톱 signOut → `LicenseService.resetToFree()` + `users/_local/d3ro.db`로 복귀 → 익명 로컬 모드 계속 동작
- [ ] 로그아웃 상태에서 PREMIUM_LLM 호출 시도 시 `UpgradePromptModal` (login_required) 표시
### SaaS [6] 로컬 우선 sync + login_required UI (Phase 3, 2026-04-11)
> 빅뱅 Phase 3의 원래 설계("Supabase = source of truth")는 로컬 entry point 철학과 충돌하므로 재정의.
> **로컬이 source of truth, 클라우드는 로그인 시 mirror + 양방향 sync.**
- **CloudSyncService `_initialSync(reason)`**
- `_onAuthenticated` 끝에 fire-and-forget으로 호출
- 로그인 직후 `pushAll()` 이후 `pullAll()` 순차 실행
- signin → 익명 세션 로컬 데이터를 사용자 계정으로 업로드
- restore → 다른 기기 변경사항 반영
- 실패해도 warn만, 수동 Sync 버튼으로 재시도 가능
- **`FeatureAccess.reason` / `UpgradePromptEvent.reason` 확장**
- `'login_required'` 케이스 추가 (types.ts)
- `promptUpgrade(feature, 'login_required')` — `requiredTier: 'free'`로 emit
- **`UpgradePromptModal` login_required 케이스**
- Title: "X은(는) 로그인이 필요합니다"
- Desc: "클라우드 기능을 사용하려면 로그인하세요. 로컬 기능은 로그인 없이도 계속 사용할 수 있습니다."
- Primary button: "로그인하기" → `d3ro:open-settings` custom event (tab=cloud)
- **LicenseService.consumeQuota()** 에도 `login_required` 케이스 추가 — promptUpgrade + D3ROError(TierRequired)
- **i18n ko/en**: `license.loginRequired.title/desc/signIn`
- **검증**
- desktop `tsc --noEmit` ✅
- desktop `npm run build` ✅ (renderer 2,130kB)
- 실증은 OAuth provider 연결 후 Phase 5에서
**Phase 3에서 의도적으로 뺀 것 (후속 사이클로)**
- **auto push on write** — VoiceMode / MeetingMode session 완료 즉시 `pushOne()`. 현재는 수동 Sync 버튼 + Realtime debounced pull만. 실제 구현은 각 서비스에 hook 추가가 필요하므로 규모 큼.
- **오프라인 WriteQueue** — 네트워크 끊긴 상태에서 write → 디스크 큐 → 복귀 시 flush. 현재는 실패 시 logger.warn만.
- **PREMIUM_LLM 실제 호출 경로** — LocalLLMService에 cloud mode 추가해서 `llm-proxy` Edge Function 호출 + canUse(PREMIUM_LLM) gate. feature enum/policy는 Phase 4에서 준비 완료.
### SaaS [5] 티어 enforcement — 로컬 해방 + 클라우드 gate (Phase 4, 2026-04-11)
> 사용자 표현: "로컬 사용자는 풀어줘야해! 클라우드 동기화 같은 것과 고급 api가 막히는거지"
>
> 기존 LicenseService는 DICTATION 20/일, LLM_PROCESS 10/일 quota + LIVE_CAPTION/VOICE_MEMO/SCREEN_CONTEXT/... 대부분이 pro lock 이었음. 로컬 entry point 철학과 정면 충돌 → 전면 교체.
**정책 매트릭스 (Phase 4 확정)**
| Feature 종류 | 익명 로컬 `_local` | 로그인 free | pro | pro_plus |
|---|---|---|---|---|
| 로컬 기능 16종 | ✅ 무제한 | ✅ 무제한 | ✅ 무제한 | ✅ 무제한 |
| PREMIUM_LLM (신규) | ❌ login_required | 🔸 5/일 | 🔸 500/일 | ✅ 무제한 |
| CLOUD_SYNC (신규) | ❌ login_required | ✅ | ✅ | ✅ |
- `packages/core/src/types.ts`:
- `Feature` enum에 `PREMIUM_LLM`, `CLOUD_SYNC` 추가
- `FeatureAccess.reason`에 `'login_required'` 추가 (익명 로컬 모드에서 cloud feature 요청 시)
- `LicenseService.ts`:
- `QUOTA_LIMITS.free` — DICTATION/LLM_PROCESS 20/10 삭제, `PREMIUM_LLM: 5`만 남김
- `QUOTA_LIMITS.pro` — `PREMIUM_LLM: 500` (fair use cap)
- `FEATURE_MIN_TIER` — 기존 16개 feature 전부 `'free'`로 완화. 신규 `PREMIUM_LLM`/`CLOUD_SYNC`도 `'free'` (로그인만 하면 접근 가능, 티어는 quota로만 구분)
- `CLOUD_FEATURES: Set<Feature>` 신규 — 익명 로컬 모드 감지용
- `canUse()` — `CLOUD_FEATURES.has(feature) && isLocalMode()` → `login_required` 반환. 그 외는 기존 tier/quota 로직
- `syncFromCloud(tier)` 신규 — Supabase `subscriptions` 티어를 electron-store에 캐시 + `tier-changed` emit
- `resetToFree()` 신규 — 로그아웃 시 호출. licenseKey/activatedAt/lastVerifiedAt/offlineGraceUntil 모두 null로 리셋
- `db/index.ts`의 `isLocalMode()` import 추가
- `CloudSyncService.ts`:
- `_fetchSubscriptionTier(userId)` 신규 — `subscriptions` 테이블에서 `{tier, status: 'active'}` 조회. 없으면 free, 비정상 티어도 free 폴백
- `_onAuthenticated()`:
- DB 오픈 → **subscription tier fetch → LicenseService.syncFromCloud(tier)** → auth-changed emit → Realtime
- fetch 실패 시 warn만 찍고 현재 티어 유지 (오프라인 grace)
- `_onSignOut()`:
- Voice/Meeting/Caption stop → Realtime stop → auth.signOut → token clear → **LicenseService.resetToFree()** → closeCurrent → openLocal → auth-changed(null) emit
- **검증**
- desktop `tsc --noEmit` ✅
- desktop `npm run build` ✅ (main bundle 정상)
- 익명 로컬 사용자는 이제 기존 기능(LIVE_CAPTION/VOICE_MEMO/MEETING_SUMMARY/FILE_TRANSCRIPTION 등 16개) 전부 사용 가능 — **pro lock 완전 제거**
- 실측 검증 대기:
- 기존 녹음/후처리가 quota 메시지 없이 계속 작동
- PREMIUM_LLM 호출 경로는 Phase 3에서 추가 예정 → 지금은 feature 정의만
**Phase 4에서 의도적으로 뺀 것**
- PREMIUM_LLM 실제 호출 경로 — Phase 3(cloud-first)에서 Edge Function `llm-proxy` 호출 로직과 함께
- daily_usage를 Supabase로 동기화 — 현재는 로컬 SQLite `daily_usage` 테이블로만 추적 (사용자별 격리 DB라 문제 없음)
- UpgradePromptModal 텍스트 업데이트 — `login_required` 케이스용 새 메시지는 Phase 3/5에서
### SaaS [4] 회원가입 / 온보딩 (Phase 2, 2026-04-11)
> 기존 `OnboardingModal`을 로컬 모드 entry point에 맞게 정리 + Cloud Sync 티저 step 추가.
- **AppConfig 타입 정식화**
- `packages/core/src/types.ts` `AppConfig`에 `onboardingCompleted: boolean` 추가
- `ConfigService.CONFIG_DEFAULTS`에 `onboardingCompleted: false` 추가
- 타입 캐스팅(`as keyof AppConfig`, `as never`) 3곳 제거:
- `AppLayout.tsx` 첫 실행 감지: `cfg.onboardingCompleted` 직접 접근
- `OnboardingModal.tsx` handleFinish: `key: 'onboardingCompleted'` 리터럴
- `SettingsModal.tsx` "온보딩 다시 보기" 버튼: 동일
- **온보딩 Step 확장 (5 → 6 step)**
- 기존 flow: 환영 → 마이크 → 핫키 → Ollama → 완료
- 새 flow: 환영 → 마이크 → 핫키 → Ollama → **Cloud Sync(선택)** → 완료
- Cloud Sync step은 `saasMode === true`일 때만 노출:
- `getState()`로 saasMode 조회
- Ollama "다음" → `nextAfterOllama()`: saasMode면 step 4, 아니면 step 5로 분기
- Cloud Sync step 내용: tagline + 3개 benefit (동기화/Premium LLM/팀공유) + "설정에서 언제든지 로그인" 안내. **로그인 버튼 없음 — onboarding scope 밖** (Settings의 CloudSyncSection이 유일한 OAuth 진입점).
- **i18n ko/en 키 추가** (`packages/i18n/src/locales/`):
- `onboarding.cloud.title` / `tagline` / `benefit1~3` / `signInLater`
- 나머지 10개 locale은 fallback (Phase 2 scope 밖, 추후 일괄)
- **검증**
- desktop `tsc --noEmit` ✅
- desktop `npm run build` ✅ (renderer 2,129kB, +3kB)
- 수동 검증 대기: Settings → About → "온보딩 다시 보기" 눌러서 신규 시퀀스 확인
- **Phase 2에서 의도적으로 뺀 것** (추후 사이클)
- Supabase `profiles.onboarded_at` 컬럼 — OAuth provider(Google) 설정 전까진 의미 없음
- OAuth 로그인 후 "환영" 페이지 — provider 연결 후 Phase 4와 함께
- STT 모델 선택 step — faster-whisper sidecar가 첫 사용 시 자동 다운로드하므로 불필요
- 온보딩 키의 나머지 10개 locale 번역
### SaaS [3.5] 로컬 모드 = entry point (Phase 1.5, 2026-04-11)
> 비즈니스 모델: 무료 로컬 모드 = onboarding funnel 입구 (킬러 피처),
> 유료 클라우드 SaaS = 수익화 (OAuth 로그인 + pro 구독 + premium 모델 + sync).
> SaaS [2] OAuth 강제 게이트 철회.
- **`db/index.ts`**
- `LOCAL_USER_ID = '_local'` 상수 export
- `openLocal()` 헬퍼 — `openForUser('_local')` 래퍼. 경로: `users/_local/d3ro.db`
- `isLocalMode()` 헬퍼 — `currentUserId === LOCAL_USER_ID`
- **`bootstrap.ts`**
- `database` step 복원 (critical). `openLocal()` 호출로 앱 시작 시 로컬 DB 자동 오픈
- 순서: logger → config → **database (local)** → license → windows → tray → ipc → ... → cloud-sync
- **`CloudSyncService._onAuthenticated(session)`**
- `openForUser(userId)` — 내부에서 기존 로컬 DB close 후 사용자 DB 재오픈 (자동)
- auth-changed(user) emit 후 Realtime 시작
- **`CloudSyncService._onSignOut()`**
- VoiceMode cancel → MeetingMode stopRecording → Caption stop → Realtime stop → auth.signOut → token clear
- `closeCurrent()` → `openLocal()` — **로컬 모드 DB로 복귀** (앱은 계속 로컬 모드로 동작)
- auth-changed(null) emit
- **`App.tsx`**
- AuthGate 상태머신 완전 제거. `LoginScreen` import 제거.
- 항상 `<AppLayout /> <UpgradePromptModal />` 렌더링 (로그인/로그아웃 무관)
- **`LoginScreen.tsx`**
- 파일 유지 (dead code — 추후 Welcome 화면 재활용 가능)
- **검증**
- desktop `tsc --noEmit` ✅
- desktop `npm run build` ✅ (main 992kB, renderer 2,126kB)
- dev 런타임 ✅:
- `[bootstrap] local database opened: .../users/_local/d3ro.db (created=true)`
- `[bootstrap] database initialized` → 나머지 step 정상
- `Main window shown` — LoginScreen 없이 바로 메인 UI
- 파일시스템: `users/_local/d3ro.db` + WAL/SHM 정상 생성
## 빅뱅 다음 세션 목표 (2026-04-11 wrap-up)
데스크톱을 진짜 SaaS 클라이언트로 완성. 자세한 계획 + 단계별 prompt는
`memory/handoff-latest.md` 참조.
핵심:
1. 사용자별 SQLite 격리 (`userData/users/${userId}/d3ro.db`)
2. 회원가입/온보딩 흐름 — OAuth 첫 가입 시 환영 + 기본 설정
3. cloud-first 정책 — Supabase가 source of truth, 로컬 cache는 read-through
4. 멀티 디바이스 sync 강화 (Realtime + last-write-wins → CRDT 검토)
5. 결제/티어 enforcement (free 일일 quota, 데스크톱에서도 차단)
6. 회의/지식 web ↔ 데스크톱 일원화
## SaaS 아키텍처 진단 (2026-04-11)
> 사용자 우려: "single-user로 잘못 만들어진 것 같다"
> 결론: **잘못된 게 아님 — 100% multi-tenant SaaS로 이미 구축됨**
| 레이어 | 상태 |
|---|---|
| Supabase 스키마 | ✅ 모든 테이블에 `user_id` FK + RLS 활성화 (12 테이블) |
| RLS 정책 | ✅ `user_id = auth.uid()` 격리 + 팀 공유 패턴 (205줄) |
| Auth 트리거 | ✅ `handle_new_user`로 가입 시 profile + free subscription 자동 생성 |
| Edge Functions | ✅ 10개 전부 `requireUser(req)` JWT 검증 |
| 웹 앱 | ✅ Supabase OAuth + 미들웨어 라우트 가드 |
| 모바일 앱 | ✅ supabase-js + Expo Push 사용자별 |
| 결제 | ✅ Stripe + subscriptions 테이블 multi-tenant |
| 데스크톱 | ✅ SaaS의 fat client (Notion/Linear/Slack 패턴) — OAuth 로그인 후 user별 sync |
**오해 가능 지점**: 데스크톱 앱은 V1 single-user 출신이라 첫 실행 시 CloudSync가 disabled. Settings에서 Supabase URL/Key + OAuth 로그인을 해야 비로소 SaaS 클라이언트로 동작. 그 이전에는 로컬 SQLite만 쓰는 single-user처럼 보임. 이건 의도된 fat client 아키텍처지만 UX 개선 여지 있음:
**향후 데스크톱 SaaS UX 개선 후보 (잘못된 게 아닌 향상)**
1. 첫 실행 시 OAuth 로그인을 강제 (Notion 패턴) — 게스트 모드 분리
2. Supabase URL/Key를 빌드 타임 환경변수로 박기 — 사용자가 직접 입력하지 않게
3. 로컬 SQLite를 `userData/${user.id}/d3ro.db`로 사용자별 격리 — PC 공유 시 데이터 혼동 방지
**6차 검증**: web typecheck/dev 200, production build 15 라우트 (`dashboard` 1.6kB→111kB, `meetings/[id]` 4.8kB→55kB by chart/mermaid)
**다음 사이클 후보**: VoiceAction 카탈로그 확대, RAG → /chat 연동, Knowledge 파일 업로드(PDF/docx), Voice Input UI(/actions 마이크), 알림 센터, 팀 대시보드, recharts dynamic import로 dashboard split, Playwright browsers install + E2E 실 실행
## V1 완료 페이즈
| Phase | 내용 | 상태 |
|---|---|---|
| 1~9 | 기본 음성 어시스턴트 (STT/LLM/TTS/핫키/팝업) | ✅ |
| 10 | Killer features (메모, 보이스 커맨드, 스크린 컨텍스트, 체인, 캡션) | ✅ |
| 11 | Monetization (라이선스, 쿼터) | ✅ |
| 12 | Pro features (파일 전사, 회의 요약, 딕테이션 템플릿) | ✅ |
| 13 | 음성 대화, RAG, 보이스 액션 | ✅ |
| 14 | 회의 모드 | ✅ |
| 14.5 | 회의 고도화 (다중 문서 생성/편집/내보내기) | ✅ |
| 15 | Auto Polish + AI 채팅 + 마인드맵 + 공유 | ✅ |
| 15.5 | 화자 구분 (LLM 추정 + pyannote) | ✅ |
**추가 개선사항 (post-15.5)**
- 히스토리/회의 목록 반응형 그리드 + 카드 UI
- 설정 LLM 모델 드롭다운
- 오디오 테스트 실시간 레벨 미터
- Ollama 미실행 시 설치 경로 탐색 후 자동 스폰 (2026-04-08)
## V2 진행 상황
### Phase V2-1: Monorepo + Core 추출 — **진행 중**
| Sub-phase | 범위 | 상태 |
|---|---|---|
| V2-1a | npm workspaces + apps/desktop으로 V1 이동 | ✅ 완료 |
| V2-1b | packages/core 추출 (types, errors, ipc-channels, constants, utils) | ✅ 완료 |
| V2-1c | packages/ui 추출 (DS 컴포넌트 + theme + theme-vars) | ✅ 완료 |
| V2-1d | packages/i18n 추출 (locale JSON + 훅) | ✅ 완료 |
**V2-1d 완료 내역**
- `packages/i18n/` (@d3ro/i18n) 신규 생성:
- `src/locales/` 12개 JSON (ko/en/ja/zh/zh-TW/es/fr/de/pt/ru/vi/th)
- `src/index.tsx`: TFunction, Locale, LOCALE_META, resolveTranslation, createFormatDate/Number/RelativeDate/Time, I18nContext, useI18n, I18nProvider
- **Electron 독립성 확보**: `I18nStorage` interface 추가 (load/save 어댑터)
- 기존 `window.electronAPI.config` 직접 호출 → `storage` prop 주입 패턴
- 기본값은 noopStorage (메모리 한정)
- `apps/desktop/src/renderer/App.tsx`에 `electronI18nStorage` 어댑터 구현 후 `<I18nProvider storage={electronI18nStorage}>`로 주입
- apps/desktop 설정:
- `@d3ro/i18n: "*"` dep 추가
- tsconfig paths, vite alias, externalize exclude에 `@d3ro/i18n` 추가
- **일괄 치환**: `./i18n`, `../i18n`, `../../i18n` → `@d3ro/i18n` (29 파일)
- `apps/desktop/src/renderer/i18n/` 디렉토리 완전 제거
**V2-1d 검증**
- typecheck ✅
- build ✅
- dev 런타임 ✅ (DB/핫키/Ollama/Main window 모두 정상)
- Electron 의존이 완전히 apps/desktop에만 남아 monorepo 원칙 준수
(packages/i18n은 React에만 의존, 웹/모바일에서 그대로 재사용 가능)
**V2-1c 완료 내역**
- `packages/ui/` (@d3ro/ui) 신규 생성:
- `src/theme.ts` (d3roPalette/d3roTypo/d3roShadow SSOT 전체)
- `src/theme-vars.ts` (팝업/main 프로세스용 CSS 변수 맵)
- `src/components/ds/{CrtDisplay, InstrumentPanel, Led, MetalCard, MetalDial, PhosphorText, PhysicalButton, ScreenPanel, ButtonGroup, index}.tsx`
- `src/index.ts` barrel
- `packages/ui/package.json`: React/MUI/Emotion은 **peerDependencies**, `@d3ro/core`만 직접 dep
- `apps/desktop/src/shared/` 디렉토리 완전 제거 (theme-vars가 마지막이었음)
- subpath exports: `./theme`, `./theme-vars`, `./components/ds`
- DS 컴포넌트 내부 `../../theme` 상대 경로는 그대로 유효 (packages/ui 내부에서 해결)
- **일괄 치환**:
- `../theme`, `../../theme`, `./theme` → `@d3ro/ui/theme`
- `../components/ds`, `../../components/ds`, `./ds`, `../ds` → `@d3ro/ui/components/ds`
- `../ds/<Component>`, `../../ds/<Component>` → `@d3ro/ui/components/ds` (세부 파일 import는 barrel로 통합)
- `@shared/theme-vars` → `@d3ro/ui/theme-vars` (WindowManager 1건)
- `apps/desktop/tsconfig.node/web.json`: `@shared/*` paths 완전 제거, `@d3ro/ui`, `@d3ro/ui/*` paths 추가, `include`에서 `src/shared/**/*` 제거
- `electron.vite.config.ts`: `@shared` alias 제거, `@d3ro/ui` alias 추가, externalize exclude에 `@d3ro/ui` 추가
- `vitest.config.ts`: alias 교체
**V2-1c 검증**
- `typecheck` ✅
- `build` ✅
- `dev` 런타임 ✅ (DB/핫키/Ollama/Main window 정상)
- `apps/desktop/src/shared/` 디렉토리 자체가 사라져 `@shared/*`는 이제 프로젝트에 존재하지 않음
**V2-1b 완료 내역**
- `packages/core/` 패키지 신규: `@d3ro/core`, subpath exports (types/errors/ipc-channels/constants/utils/*), docx dep 포함
- 6개 파일 `git mv`로 `packages/core/src/`로 이동:
- `apps/desktop/src/shared/{types,errors,ipc-channels,constants}.ts`
- `apps/desktop/src/main/utils/{meeting-markdown,markdown-to-docx}.ts`
- `packages/core/src/index.ts` barrel export 추가
- `apps/desktop/package.json`에 `"@d3ro/core": "*"` dep 추가
- `apps/desktop/tsconfig.node.json`, `tsconfig.web.json`의 `paths`에 `@d3ro/core/*` 추가, `@shared/*`는 theme-vars용으로 유지
- `apps/desktop/electron.vite.config.ts` alias 3개 섹션에 `@d3ro/core` 추가 + **M1 수정**: `resolve('src/shared')` → `resolve(__dirname, 'src/shared')`
- `externalizeDepsPlugin` exclude에 `@d3ro/core` 추가 (main/preload 둘 다)
- `apps/desktop/vitest.config.ts`에 `@d3ro/core` alias 추가
- **일괄 치환**: `@shared/{types,errors,ipc-channels,constants}` → `@d3ro/core/*` (사용자 79개 파일, 167건)
- static import (`from '@shared/...'`)
- dynamic import (`import('@shared/...')`)
- type expression import (`keyof import('@shared/...').AppConfig`)
- `MeetingModeService.ts`의 상대 경로 utils import (`../utils/meeting-markdown`, `../utils/markdown-to-docx`)를 `@d3ro/core/utils/*`로 교체
- `packages/core` 내부 자기 참조: `meeting-markdown.ts`의 `@shared/types` → `../types` 상대 경로
**V2-1b 검증**
- `npm run typecheck` ✅
- `npm run build` ✅ (main+preload+renderer)
- `npm run dev` 런타임 실행 ✅ (DB/핫키/Ollama 모두 정상)
- `apps/desktop/src/shared/`에는 `theme-vars.ts` 1개만 남음 (V2-1c에서 이동 예정)
**V2-1a 완료 내역**
- 루트 `package.json`을 npm workspaces 루트로 재구성 (workspaces: apps/*, packages/*)
- `turbo.json`, `tsconfig.base.json` 추가 (Turborepo 자체 설치는 뒤로 미룸)
- V1 파일 전체를 `apps/desktop/`으로 `git mv`:
- src/, resources/, tests/, sidecar/, scripts/
- electron.vite.config.ts, electron-builder.yml, vitest.config.ts
- tsconfig.node.json, tsconfig.web.json (+ 새 tsconfig.json)
- `apps/desktop/package.json` 신규 (`name: "@d3ro/desktop"`, 기존 deps 그대로)
- `@electron/rebuild --version=33.4.11`로 native 모듈 재빌드 (better-sqlite3, uiohook-napi)
- 루트 tsconfig.json → apps/desktop references 형태
**V2-1a 결정사항**
- **패키지 매니저**: npm workspaces 유지 (pnpm 전환은 별도 sub-phase)
- 이유: Turborepo는 npm workspaces와 호환, 구조 이동과 PM 전환을 동시에 하면 롤백 불가
- **site/**: 루트 유지 (랜딩 페이지, V2 무관)
- **docs/**, **memory/**, **CLAUDE.md**: 루트 유지
- **productName: "d3ro-voice"** 필수 — package name을 `@d3ro/desktop`으로 바꾸면 Electron의 `app.getName()`이 바뀌어 userData 경로가 `%APPDATA%\@d3ro\desktop\`으로 이동(기존 DB/설정 유실). `productName`을 명시하면 `app.getName()`이 이를 우선 반환하여 `%APPDATA%\d3ro-voice\` 그대로 유지됨.
**검증 결과**
- `npm run typecheck`
- `npm run build` ✅ (electron-vite main+preload+renderer)
- `npm run dev` ✅ 실제 실행 → DB 경로 `d3ro-voice/`로 복원, 기존 핫키(Alt keyCode=2) 로드, Ollama 자동 실행 감지 모두 정상
- `npm run dist` (electron-builder installer)은 V2-1 완료 후 별도 검증
### Phase V2-2: Supabase 인프라 — 로컬 작업 완료
**작성된 파일** (`server/supabase/`)
- `config.toml` — Supabase CLI 설정 (로컬 + OAuth providers)
- `migrations/20260409000001_initial_schema.sql` — 12개 테이블 (profiles, teams, team_members, meetings, meeting_memos, meeting_documents, transcripts, history, dictionary, memo_tags, daily_usage, subscriptions) + 인덱스 + FK CASCADE
- `migrations/20260409000002_rls_policies.sql` — 개인/팀 이중 RLS 패턴
- `migrations/20260409000003_auth_triggers.sql``handle_new_user` (profiles + subscriptions 자동 생성), `moddatetime` (8개 테이블 updated_at 트리거), `increment_daily_usage` (service_role 전용 쿼터 RPC)
- `migrations/20260409000004_storage_buckets.sql` — audio/exports/avatars 버킷 + 경로 기반 정책
- `functions/_shared/` — cors.ts, auth.ts (requireUser), quota.ts (티어별 쿼터)
- `functions/stt-proxy/index.ts` — Google Cloud STT 래퍼 스캐폴딩 (placeholder 응답)
- `functions/llm-proxy/index.ts` — Anthropic Messages API 래퍼 스캐폴딩 (티어별 모델 정책)
- `seed.sql` — 개발 시드 (빈 상태)
- `.gitignore` — .branches/, .temp/, .env
**설계 문서**
- `docs/v2/phase-V2-2.md` — 상세 설계 (스키마/RLS/Edge Functions/실시간/Storage)
- `docs/v2/phase-V2-2-setup.md` — 사용자 액션 가이드 (Supabase 계정/OAuth/CLI/배포)
**사용자 액션 대기 중** (로컬 외 작업)
1. Supabase 프로젝트 생성 (https://supabase.com)
2. `supabase login && supabase link --project-ref <REF>`
3. `supabase db push` (4개 마이그레이션 적용)
4. Google/GitHub OAuth 앱 등록 + Supabase Auth Providers 설정
5. Google Cloud STT 키 + Anthropic API 키 발급
6. `supabase secrets set` + `supabase functions deploy`
**설계 결정사항**
- **UUID PK**: V1은 nanoid(text), V2는 PostgreSQL `gen_random_uuid()`. V2-4 (동기화)에서 기존 SQLite 데이터는 nanoid 유지, 새 데이터는 UUID.
- **timestamptz**: V1은 epoch ms(integer), V2는 `timestamptz`. V2-4 동기화 시 타입 변환 필요.
- **멀티테넌시**: 모든 테이블에 `user_id`. 회의 관련 테이블은 `team_id` nullable 추가.
- **RLS 이중 패턴**: 개인 전용(history/dictionary)은 단순 `user_id = auth.uid()`. 팀 공유(meetings)는 본인 OR `team_members` 조회 subquery.
- **쿼터 집계**: `increment_daily_usage` RPC는 `SECURITY DEFINER` + `service_role` only. 일반 유저는 읽기만.
- **Realtime**: `transcripts` 테이블만 `supabase_realtime` publication에 추가 (회의 중 세그먼트 동기화 용).
- **Edge Functions placeholder**: 인증/쿼터 파이프라인은 완성, 실제 AI API 호출은 주석 처리된 실제 구현 코드 포함. V2-3 (Web MVP) 진행 전까지 유지.
### Phase V2-5: Mac 빌드 — 로컬 작업 완료
**작성/수정된 파일**
- `apps/desktop/src/main/utils/paths.ts``EXE_SUFFIX` 상수 도입, sox/sidecar/ffmpeg 경로 플랫폼별 분기
- `apps/desktop/src/main/services/SoundEffectService.ts` — Mac `afplay`, Linux `aplay` 분기 추가 (execFile로 안전하게)
- `apps/desktop/src/main/services/ScreenContextService.ts``_getActiveWindowInfo`를 win32/darwin로 분기. macOS는 `osascript`로 frontmost process + 윈도우 타이틀 조회 (Accessibility 권한 필요)
- `apps/desktop/scripts/build-sidecar.py``IS_WINDOWS`/`IS_MACOS` 분기, `EXE_SUFFIX`, `--noconsole`은 Windows에서만, 출력 경로 플랫폼 통합
- `apps/desktop/scripts/install-sox.sh` — Mac/Linux에서 시스템 sox를 `resources/sox/`로 복사. macOS는 `otool`로 dylib 의존성 함께 복사 + `install_name_tool`로 rpath 변경
- `apps/desktop/electron-builder.yml``mac:` 타겟 추가 (dmg+zip, arm64+x64), `hardenedRuntime`, `entitlements`, `extendInfo`(권한 메시지), Linux AppImage placeholder
- `apps/desktop/build/entitlements.mac.plist` — 마이크/카메라/네트워크/Apple Events/JIT/dylib 권한
- `apps/desktop/resources/icons/README.md` — 플랫폼별 아이콘 생성 가이드 (sips/iconutil/imagemagick)
- `.github/workflows/build-mac.yml` — macos-14 runner, brew sox, sidecar 빌드, electron-builder dist (arm64/x64 매트릭스), CSC/notarize 환경변수, artifact 업로드
- `docs/v2/phase-V2-5-mac-guide.md` — Mac 사전 조건/dev 실행/dist 빌드/서명/공증/CI/트러블슈팅
**검증 결과 (Windows에서)**
- typecheck ✅ — Mac 분기 추가에도 회귀 없음
- build ✅ — Windows 번들 정상
- dev 런타임 ✅ — Ollama 자동 실행 + 모든 서비스 정상
**Mac에서 검증 대기 (사용자 액션)**
- `brew install sox node@22 python@3.11`
- `npm install && npx @electron/rebuild@3 --version=33.4.11`
- `npm run dev` → Accessibility/마이크/Apple Events 권한 허용
- 핫키, 녹음, STT, LLM, 화면 컨텍스트 동작 확인
- 결과 리포트 받아 미세 조정 필요할 수 있음
**감사 결과 vs 처리**
- C1 sox.exe/sidecar.exe/ffmpeg.exe 하드코딩 → ✅ 수정
- C2 아이콘 부재 → ✅ placeholder 디렉토리 + README
- C3 mac 타겟 부재 → ✅ 추가
- C4 SoX 바이너리 부재 → ✅ install-sox.sh + 가이드
- C5 ScreenContextService 미지원 → ✅ AppleScript 분기
- C6 SoundEffectService 미지원 → ✅ afplay 분기
- C7 build-sidecar.py Windows 전제 → ✅ 크로스 플랫폼
- M6/M7/M8/M9 (이미 분기 완료) → 변경 없음
### Phase V2-3: Web App MVP — 완료
**신규 패키지**
- `packages/api-client/` (@d3ro/api-client) — Supabase 래퍼
- `src/types.ts` — V2-2 스키마에 맞춘 수동 Database 타입 정의 (profiles/teams/meetings/transcripts/history/dictionary/daily_usage/subscriptions)
- `src/client.ts` — createD3roSupabaseClient 팩토리 (URL/KEY 주입형, env 없어도 placeholder로 작동)
- `src/auth.ts` — signInWithOAuth/signOut/getSession
- `src/meetings.ts` — CRUD 함수 (meetings, memos, documents, transcripts)
- `src/history.ts` — 음성 입력 이력 CRUD
- `src/usage.ts` — daily_usage 조회 (쿼터 UI)
- 루트 barrel은 types만 re-export (함수는 subpath로, 순환 의존 방지)
**신규 앱 — `apps/web/`**
- Next.js 15 App Router + MUI 7 + Emotion
- transpilePackages로 `@d3ro/core/ui/i18n/api-client` 소스 TypeScript 그대로 번들
- 라우트 (총 9개):
- `/` — auth 상태에 따라 리다이렉트
- `/login` — Google/GitHub OAuth (Supabase 미설정 시 경고)
- `/auth/callback` — OAuth code → session 교환 route handler
- `/dashboard` — 요약 카드 4개 + 최근 회의 5건
- `/meetings` — 회의 카드 그리드 리스트
- `/meetings/[id]` — 상세 (transcripts + memos + documents 탭)
- `/record` — 녹음 페이지 (getUserMedia + MediaRecorder + stt-proxy)
- Providers: ThemeProvider(MUI+AppRouterCacheProvider) / I18nProvider(localStorage 어댑터) / AuthProvider(Supabase session)
- 인증 가드: `/dashboard` layout + `/meetings`, `/record`, `/meetings/[id]` 개별 redirect
- Sidebar: Dashboard / Meetings / Record / Logout
- Recorder: 9바 웨이브폼 (Speakly 스타일), 실시간 레벨, elapsed timer, cancel/stop
**packages/ui 확장 (@d3ro/ui)**
- `MetalCard`: `BoxProps` 확장하여 `sx`/`onClick` 등 전달 가능
- `theme.ts``typoSx(key)` 헬퍼 추가 — d3roTypo를 MUI sx 친화 포맷으로 변환
- `theme.ts``d3roPalette.tag.blue` 추가 (M5 수정의 부산물)
- DS 컴포넌트 9개에 `'use client'` directive 추가 — RSC 경계 명시
- `MetalDial.tsx`의 미사용 import 3개 제거 (tsc strict 회귀)
**packages/i18n 확장**
- `ko.json`에 7개 새 키: `nav.meetings`, `nav.record`, `nav.logout`, `login.subtitle`, `login.google`, `login.github`, `login.terms`
- 다른 locale은 fallback 체인으로 ko/en 사용 (추후 번역 작업에서 채움)
**설계 결정사항**
- **Database 제네릭**: 현재 `SupabaseClient` 기본 타입 사용, V2-4에서 `supabase gen types typescript --local` 자동화 후 제네릭 주입 예정. 이유: 수동 Database 타입 정의가 @supabase/supabase-js 2.102의 복잡한 제약(PostgrestVersion, Views/Functions/Enums 형식)을 만족시키기 어려움.
- **api-client barrel 분리**: 루트에서는 types만 export. 함수는 subpath(`@d3ro/api-client/meetings`)로만 접근. 이렇게 하지 않으면 apps/web이 api-client 루트를 import할 때 내부 meetings/history 등이 모두 tsc 스캔 대상이 되어 타입 에러가 전파됨.
- **DS 컴포넌트 `'use client'`**: MUI + React 훅(CrtDisplay)을 쓰는 컴포넌트들은 Next.js RSC에서 쓰려면 client directive 필요. barrel 대신 각 파일마다 붙임.
- **Supabase env 없이도 빌드 성공**: placeholder URL/key로 createBrowserClient/createServerClient를 호출. 런타임에 `isSupabaseConfigured()` 체크로 UI 경고 표시.
**검증 결과**
- web typecheck ✅ (apps/web)
- desktop typecheck ✅ (회귀 없음)
- web `next build` ✅ — 9 라우트 (/ /login /auth/callback /dashboard /meetings /meetings/[id] /record /_not-found + root)
- desktop `npm run build` ✅ (main+preload+renderer)
- 실제 Supabase 연결은 V2-2 사용자 액션 이후 검증 가능
### Phase V2-4: 데스크톱 ↔ Supabase 동기화 — 완료
**핵심 컴포넌트**
- `apps/desktop/src/main/services/CloudSyncService.ts` — 싱글톤 + EventEmitter
- `init/startSignIn/handleAuthCallback/signOut/pushAll` + 4종 이벤트
- safeStorage 기반 refresh token 영속화
- upsert(onConflict: id) 방식 push, 매핑 테이블 없음
- `ipc/cloud-sync-handlers.ts` + `preload index.ts` cloudSync API
- `renderer/components/CloudSyncSection.tsx` — Settings 섹션
- `main/index.ts` deep link (`d3ro-voice://auth-callback`) 핸들러 추가
- `bootstrap.ts` cloud-sync 단계 추가
- `packages/core/src/ipc-channels.ts` CLOUD_SYNC 11종 채널
- `packages/core/types.ts` AppConfig + ConfigService defaults에 supabaseUrl/supabaseAnonKey/cloudSyncLastAt + hfToken/diarizationEnabled (Phase 15.5 누락분 보충)
**설계 결정**
- Local-first push only (V2-4b에서 pull 추가)
- 마지막 동기화 epoch ms 기준 변경분만 전송
- nanoid PK를 PostgreSQL text로 그대로 upsert
- macOS open-url + Windows second-instance argv 방식 deep link
### Phase V2-6: Mobile MVP — 완료
**구조** (`apps/mobile/`, npm workspace에서 **제외**)
- Expo SDK 51 + React Native + Expo Router
- `lib/supabase.ts` (AsyncStorage), `lib/auth-context.tsx`
- `app/_layout.tsx`, `app/index.tsx`, `app/login.tsx`
- `app/(tabs)/{_layout, meetings, record, profile}.tsx`
- `expo-av`로 녹음 → stt-proxy 호출
- `README.md` + setup 가이드
**설계 결정**
- workspace 제외 (Expo native deps 부담 회피)
- MUI 미사용, 인라인 RN 스타일 (V2-6b에서 `@d3ro/ui-native` 분리)
- Deep link `scheme: 'd3ro-voice'`
### Phase V2-7: 팀 기능 (Web) — 완료
- `apps/web/src/app/teams/page.tsx` — 가입한 팀 카드 그리드
- `apps/web/src/app/teams/[id]/page.tsx` — 팀 상세 (멤버 + 공유 회의)
- `components/teams/create-team-form.tsx` — 팀 생성 + owner 자동 등록
- `components/teams/invite-member-form.tsx` — user_id 직접 입력 (V2-7b에서 정식 invite flow)
- Sidebar에 Teams/Billing 메뉴 + ko.json 새 키
- V2-2의 teams/team_members RLS 활용
### Phase V2-8: 결제 스캐폴딩 — 완료
- `apps/web/src/app/billing/page.tsx` — Free/Pro/Team 가격표 + 현재 티어
- `components/billing/checkout-button.tsx` — Edge Function 호출
- `server/supabase/functions/stripe-checkout/index.ts` — JWT 인증, customer 조회/생성, Checkout Session
- `server/supabase/functions/stripe-webhook/index.ts` — checkout.session.completed, subscription.* 이벤트 → subscriptions 업데이트
- `config.toml`에 두 함수 등록 (webhook verify_jwt=false)
**사용자 액션 필요**
- `STRIPE_SECRET_KEY` / `STRIPE_PRICE_PRO` / `STRIPE_PRICE_TEAM` / `STRIPE_WEBHOOK_SECRET` 설정
- Stripe 대시보드에서 product + price 생성
**미구현 (V2-8b)**
- Webhook signature 정식 검증 (현재 placeholder)
- Customer Portal
- Per-seat billing
## 알려진 차단/이슈
- **@nut-tree-fork/nut-js 포크 사용** (V1 잔여 이슈)
- **Ollama 의존성**: 없으면 LLM 기능 사용 불가 (2026-04-08 자동 실행 기능 추가로 UX 개선)
## 디렉토리 구조 (V2-1a 목표)
```
d3ro-voice/ # 루트 (monorepo)
├── apps/
│ └── desktop/ # 기존 V1 전체
│ ├── src/
│ ├── resources/
│ ├── build/
│ ├── package.json # name: @d3ro/desktop
│ ├── electron.vite.config.ts
│ ├── electron-builder.yml
│ ├── tsconfig*.json
│ └── drizzle.config.ts
├── packages/ # V2-1b/c/d에서 채움
├── site/ # 랜딩 페이지 (루트 유지)
├── docs/ # 설계서/페이즈 문서
├── memory/ # 이 파일
├── pnpm-workspace.yaml # 또는 package.json "workspaces"
├── turbo.json
├── package.json # 루트 workspace
├── tsconfig.base.json
└── CLAUDE.md
```