# Phase 11: 수익화 기반 — LicenseService + Feature Gating ## 목표 Freemium 모델 구현. Free/Pro/Pro+ 3단계 티어, 일일 사용량 제한, 라이센스 키 활성화, 업그레이드 유도 UI. 로컬 앱이므로 오프라인 우선 설계 — 키 검증은 최초 1회 온라인, 이후 로컬 검증. --- ## 1. 티어 정의 | 기능 | Free | Pro (₩39,000) | Pro+ (₩69,000) | |------|------|---------------|-----------------| | 받아쓰기 | 15회/일 | 무제한 | 무제한 | | LLM 다듬기 | 3회/일 | 무제한 | 무제한 | | 히스토리 보존 | 3일 | 무제한 | 무제한 | | 커스텀 명령어 | 프리셋만 | 무제한 생성 | 무제한 생성 | | 히스토리 내보내기 | X | O | O | | 실시간 자막 | X | O | O | | 스크린 컨텍스트 | X | O | O | | 음성 메모/태그 | X | O | O | | 음성 단축키 | X | O | O | | LLM 체인 | X | O | O | | 파일 전사 | X | X | O | | 음성 대화 모드 | X | X | O | | 딕테이션 템플릿 | X | X | O | | 회의록 자동 요약 | X | X | O | | 로컬 RAG | X | X | O | | OS 자동화 | X | X | O | --- ## 2. Feature 열거형 ```typescript /** 기능 게이팅 대상 */ export enum Feature { // 쿼터 제한 기능 (Free에서 횟수 제한) DICTATION = 'dictation', LLM_PROCESS = 'llm_process', // Pro 기능 (Free에서 잠금) HISTORY_UNLIMITED = 'history_unlimited', HISTORY_EXPORT = 'history_export', CUSTOM_INSTRUCTION_CREATE = 'custom_instruction_create', LIVE_CAPTION = 'live_caption', SCREEN_CONTEXT = 'screen_context', VOICE_MEMO = 'voice_memo', VOICE_COMMAND = 'voice_command', LLM_CHAIN = 'llm_chain', // Pro+ 기능 (Pro에서도 잠금) FILE_TRANSCRIPTION = 'file_transcription', VOICE_CONVERSATION = 'voice_conversation', DICTATION_TEMPLATE = 'dictation_template', MEETING_SUMMARY = 'meeting_summary', LOCAL_RAG = 'local_rag', OS_AUTOMATION = 'os_automation', } ``` --- ## 3. 타입 정의 ```typescript /** 라이센스 티어 */ export type LicenseTier = 'free' | 'pro' | 'pro_plus' /** 라이센스 정보 (electron-store에 저장) */ export interface LicenseInfo { tier: LicenseTier licenseKey: string | null activatedAt: number | null machineId: string /** 마지막 온라인 검증 시각 */ lastVerifiedAt: number | null /** 오프라인 유예 만료 (lastVerifiedAt + 30일) */ offlineGraceUntil: number | null } /** 일일 사용량 */ export interface DailyUsage { date: string // 'YYYY-MM-DD' dictationCount: number llmProcessCount: number } /** 쿼터 정보 */ export interface UsageQuota { feature: Feature used: number limit: number // -1 = 무제한 remaining: number // -1 = 무제한 resetAt: string // 다음 리셋 시각 (내일 00:00) ISO 8601 } /** 기능 접근 결과 */ export interface FeatureAccess { allowed: boolean reason?: 'ok' | 'quota_exceeded' | 'tier_required' | 'license_expired' requiredTier?: LicenseTier quota?: UsageQuota } /** 업그레이드 유도 이벤트 */ export interface UpgradePromptEvent { feature: Feature reason: 'quota_exceeded' | 'tier_required' currentTier: LicenseTier requiredTier: LicenseTier quota?: UsageQuota } /** 라이센스 활성화 파라미터 */ export interface ActivateLicenseParams { licenseKey: string } /** 라이센스 활성화 결과 */ export interface ActivateLicenseResult { success: boolean tier: LicenseTier message: string } /** 티어별 기능 비교 */ export interface TierComparison { feature: string featureLabel: string free: boolean | string pro: boolean | string proPlus: boolean | string } ``` --- ## 4. DB 스키마 ```sql CREATE TABLE daily_usage ( id INTEGER PRIMARY KEY AUTOINCREMENT, date TEXT NOT NULL, -- 'YYYY-MM-DD' feature TEXT NOT NULL, -- Feature enum value count INTEGER NOT NULL DEFAULT 0, UNIQUE(date, feature) ); CREATE INDEX idx_daily_usage_date ON daily_usage(date); ``` ```typescript // drizzle-orm 스키마 export const dailyUsage = sqliteTable('daily_usage', { id: integer('id').primaryKey({ autoIncrement: true }), date: text('date').notNull(), feature: text('feature').notNull(), count: integer('count').notNull().default(0), }, (table) => [ uniqueIndex('idx_daily_usage_date_feature').on(table.date, table.feature), index('idx_daily_usage_date').on(table.date), ]); ``` --- ## 5. IPC 채널 ```typescript LICENSE: { GET_INFO: 'license:getInfo', ACTIVATE: 'license:activate', DEACTIVATE: 'license:deactivate', CHECK_FEATURE: 'license:checkFeature', GET_USAGE: 'license:getUsage', GET_ALL_USAGE: 'license:getAllUsage', GET_TIER_COMPARISON: 'license:getTierComparison', // Main → Renderer events UPGRADE_PROMPT: 'license:upgradePrompt', TIER_CHANGED: 'license:tierChanged', } ``` | 채널 | 방향 | 파라미터 | 반환 | 설명 | |------|------|---------|------|------| | `license:getInfo` | handle | void | `LicenseInfo` | 현재 라이센스 정보 | | `license:activate` | handle | `ActivateLicenseParams` | `ActivateLicenseResult` | 키 활성화 | | `license:deactivate` | handle | void | void | 키 비활성화 (Free로 복귀) | | `license:checkFeature` | handle | `{ feature: Feature }` | `FeatureAccess` | 기능 접근 가능 여부 | | `license:getUsage` | handle | `{ feature: Feature }` | `UsageQuota` | 특정 기능 사용량 | | `license:getAllUsage` | handle | void | `UsageQuota[]` | 전체 기능 사용량 | | `license:getTierComparison` | handle | void | `TierComparison[]` | 티어 비교표 | | `license:upgradePrompt` | send | — | `UpgradePromptEvent` | 업그레이드 유도 이벤트 | | `license:tierChanged` | send | — | `LicenseInfo` | 티어 변경 알림 | --- ## 6. LicenseService 인터페이스 ```typescript interface ILicenseService { /** 현재 티어 */ readonly tier: LicenseTier /** 라이센스 정보 */ getInfo(): LicenseInfo /** * 기능 사용 가능 여부 확인. * 쿼터 기능: 남은 횟수 체크. * 티어 잠금 기능: 현재 티어로 접근 가능한지. */ canUse(feature: Feature): FeatureAccess /** * 기능 사용 소비 (쿼터 차감). * canUse 통과 후 실제 사용 시 호출. * 쿼터 초과 시 D3ROError(QuotaExceeded) throw. */ consumeQuota(feature: Feature): void /** 일일 사용량 조회 */ getUsage(feature: Feature): UsageQuota /** 전체 사용량 조회 */ getAllUsage(): UsageQuota[] /** 라이센스 키 활성화 */ activate(key: string): Promise /** 라이센스 비활성화 */ deactivate(): void /** 티어 비교표 */ getTierComparison(): TierComparison[] /** 업그레이드 유도 이벤트 발생 (내부 + IPC 전파) */ promptUpgrade(feature: Feature, reason: UpgradePromptEvent['reason']): void } ``` ### 쿼터 한도 ```typescript const QUOTA_LIMITS: Record>> = { free: { [Feature.DICTATION]: 15, [Feature.LLM_PROCESS]: 3, }, pro: {}, // 무제한 pro_plus: {}, // 무제한 } ``` ### 티어별 기능 접근 ```typescript const FEATURE_TIERS: Record = { // 모든 티어 (쿼터만 다름) [Feature.DICTATION]: 'free', [Feature.LLM_PROCESS]: 'free', // Pro 이상 [Feature.HISTORY_UNLIMITED]: 'pro', [Feature.HISTORY_EXPORT]: 'pro', [Feature.CUSTOM_INSTRUCTION_CREATE]: 'pro', [Feature.LIVE_CAPTION]: 'pro', [Feature.SCREEN_CONTEXT]: 'pro', [Feature.VOICE_MEMO]: 'pro', [Feature.VOICE_COMMAND]: 'pro', [Feature.LLM_CHAIN]: 'pro', // Pro+ 이상 [Feature.FILE_TRANSCRIPTION]: 'pro_plus', [Feature.VOICE_CONVERSATION]: 'pro_plus', [Feature.DICTATION_TEMPLATE]: 'pro_plus', [Feature.MEETING_SUMMARY]: 'pro_plus', [Feature.LOCAL_RAG]: 'pro_plus', [Feature.OS_AUTOMATION]: 'pro_plus', } ``` ### 히스토리 보존 정책 ```typescript const HISTORY_RETENTION_DAYS: Record = { free: 3, pro: -1, // 무제한 pro_plus: -1, // 무제한 } ``` --- ## 7. 라이센스 키 검증 ### 오프라인 우선 설계 ``` 최초 활성화 (온라인 필수): 1. 사용자가 키 입력 2. LemonSqueezy API 검증: POST /v1/licenses/activate 3. 응답에서 tier 추출 (meta.tier 또는 variant_id 매핑) 4. electron-store에 저장: { tier, key, activatedAt, machineId, lastVerifiedAt } 5. 오프라인 유예: lastVerifiedAt + 30일 이후 앱 실행 시: 1. electron-store에서 라이센스 정보 로드 2. tier !== 'free' → 로컬 검증 (machineId 일치, 유예 기간 내) 3. 30일 경과 → 백그라운드 재검증 시도 4. 재검증 실패 → 7일 추가 유예 후 Free로 다운그레이드 5. 재검증 성공 → lastVerifiedAt 갱신 ``` ### machineId 생성 ```typescript import { machineIdSync } from 'node-machine-id' const machineId = machineIdSync(true) // 해시된 하드웨어 ID ``` 또는 electron-store에 저장된 UUID (하드웨어 변경에 더 관대): ```typescript import { randomUUID } from 'crypto' // 최초 실행 시 1회 생성 후 저장 const machineId = store.get('machineId') ?? randomUUID() ``` --- ## 8. UI 명세 ### 8.1 UpgradePromptModal 쿼터 소진 또는 잠긴 기능 접근 시 표시. ``` ┌─────────────────────────────────────────────┐ │ 🔒 오늘의 받아쓰기를 모두 사용했습니다 │ │ │ │ Free: 15회/일 → Pro: 무제한 │ │ │ │ Pro로 업그레이드하면: │ │ ✓ 무제한 받아쓰기 │ │ ✓ AI 텍스트 다듬기 무제한 │ │ ✓ 실시간 자막 │ │ ✓ 히스토리 무제한 보존 │ │ │ │ [내일 다시 사용하기] [Pro 알아보기 →] │ └─────────────────────────────────────────────┘ ``` ### 8.2 UsageIndicator (StatusBar 또는 Dashboard) ``` [🔵🔵🔵🔵🔵🔵🔵🔵🔵🔵🔵🔵⚪⚪⚪ 12/15 받아쓰기] ``` ### 8.3 FeatureGate 래퍼 컴포넌트 ```tsx }> ``` ### 8.4 Settings License 탭 ``` ┌─ License ──────────────────────────────────────┐ │ │ │ 현재 플랜: FREE │ │ │ │ ┌─ 라이센스 키 ──────────────────────────────┐ │ │ │ [____________________________] [활성화] │ │ │ └────────────────────────────────────────────┘ │ │ │ │ ┌─ 플랜 비교 ────────────────────────────────┐ │ │ │ Free Pro Pro+ │ │ │ │ 받아쓰기 15회/일 무제한 무제한 │ │ │ │ AI다듬기 3회/일 무제한 무제한 │ │ │ │ 자막 ✗ ✓ ✓ │ │ │ │ 파일전사 ✗ ✗ ✓ │ │ │ │ 대화모드 ✗ ✗ ✓ │ │ │ │ ... │ │ │ └────────────────────────────────────────────┘ │ │ │ │ 오늘 사용량: │ │ 받아쓰기: ████████░░░░░░░ 8/15 │ │ AI 다듬기: ██░░░░░░░░░░░░ 1/3 │ └─────────────────────────────────────────────────┘ ``` --- ## 9. Feature Gating 통합 포인트 | 서비스 | 체크 위치 | Feature | |--------|----------|---------| | VoiceModeService.startSession() | 세션 시작 전 | DICTATION | | LocalLLMService.process() | 처리 시작 전 | LLM_PROCESS | | CaptionService.start() | 자막 시작 전 | LIVE_CAPTION | | ScreenContextService.capture() | 캡처 전 | SCREEN_CONTEXT | | MemoService.addTag() | 태그 추가 전 | VOICE_MEMO | | VoiceCommandService.match() | 매칭 전 | VOICE_COMMAND | | ChainService.execute() | 체인 실행 전 | LLM_CHAIN | | HistoryService.export() | 내보내기 전 | HISTORY_EXPORT | | CustomInstructionService.create() | 생성 전 | CUSTOM_INSTRUCTION_CREATE | --- ## 10. 에러 코드 ```typescript // 850-869: License LicenseKeyInvalid = 850, LicenseKeyExpired = 851, LicenseActivationFailed = 852, LicenseDeactivationFailed = 853, LicenseMachineIdMismatch = 854, LicenseOfflineGraceExpired = 855, LicenseVerificationFailed = 856, FeatureNotAvailable = 860, QuotaExceeded = 861, TierRequired = 862, ``` --- ## Speakly RE 참조 - Speakly는 클라우드 인증(AuthService) + 서버 라이센스 모델 사용 - D3RO는 오프라인 우선 → LemonSqueezy 라이센스 키 + 로컬 검증 - 쿼터 추적은 Speakly의 RecordStatsService 패턴 차용 ## 완료 조건 - [ ] LicenseService 싱글톤 구현 + 초기화 - [ ] Feature enum + 티어별 접근 매핑 - [ ] daily_usage 테이블 + 쿼터 추적 - [ ] 라이센스 키 활성화/비활성화 - [ ] IPC 핸들러 + preload API - [ ] UpgradePromptModal UI - [ ] Settings License 탭 - [ ] Dashboard 사용량 표시 - [ ] 기존 서비스에 canUse() 체크 통합 - [ ] i18n 키 추가 (ko/en) - [ ] typecheck 통과