d3ro-voice/docs/phases/phase-11-monetization.md
Yun Chan a31f96bbb8 Phase 10~11 전체 구현: 킬러 피처 5종 + 수익화 시스템
Phase 10 킬러 피처:
- MemoService: 태그 CRUD + 마크다운 내보내기 (memo_tags DB)
- VoiceCommandService: 키워드→명령어 매칭, 프리셋 4종
- ScreenContextService: PowerShell 활성 윈도우 + Ctrl+C 선택 텍스트
- ChainService: LLM 명령어 순차 실행 파이프라인
- CaptionService: 6초 청크 연속 전사 + 시스템 오디오 루프백

VoiceModeService 파이프라인 통합:
- 녹음 시작 → 컨텍스트 캡처 → STT → 키워드 매칭 → LLM(체인/컨텍스트 주입) → 삽입

시스템 오디오 캡처:
- setDisplayMediaRequestHandler + audio: 'loopback' (IPC 브릿지)
- electron-audio-loopback 패키지 contextIsolation 호환 불가 → 직접 구현

Phase 11 수익화:
- LicenseService: Free/Pro/Pro+ 3티어, LemonSqueezy API
- Feature Gate: requireFeature/checkFeature/consumeFeature
- 일일 쿼터: Free dictation 20/일, LLM 10/일 (SQLite daily_usage)
- LicenseModal, ProBadge, UpgradePromptModal UI

디자인 보강:
- d3roTypo(13종), d3roShadow(10종), d3roRadius(7종) 토큰 시스템
- ScreenPanel, ButtonGroup DS 컴포넌트 신규
- PhosphorText 4→13종 변형, MetalDial conic-gradient 광택
- 공유 컴포넌트: EmptyStateCard, SearchInput, PageHeader, HistoryEntryCard

기타:
- 자막 핫키 SSOT 전체 연동 (Config→Hotkey→VoiceMode→Caption→Settings)
- StatusBar 자막 LED + 효과음, 자막 로딩 UI
- LLM 상태 이벤트 전파 수정 (폴링 제거 → onStatusChanged)
- 커맨드 팝업 "선택 해제" 항목 추가
2026-04-05 21:36:09 +09:00

14 KiB

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 열거형

/** 기능 게이팅 대상 */
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. 타입 정의

/** 라이센스 티어 */
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 스키마

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);
// 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 채널

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 인터페이스

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<ActivateLicenseResult>

  /** 라이센스 비활성화 */
  deactivate(): void

  /** 티어 비교표 */
  getTierComparison(): TierComparison[]

  /** 업그레이드 유도 이벤트 발생 (내부 + IPC 전파) */
  promptUpgrade(feature: Feature, reason: UpgradePromptEvent['reason']): void
}

쿼터 한도

const QUOTA_LIMITS: Record<LicenseTier, Partial<Record<Feature, number>>> = {
  free: {
    [Feature.DICTATION]: 15,
    [Feature.LLM_PROCESS]: 3,
  },
  pro: {},      // 무제한
  pro_plus: {}, // 무제한
}

티어별 기능 접근

const FEATURE_TIERS: Record<Feature, LicenseTier> = {
  // 모든 티어 (쿼터만 다름)
  [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',
}

히스토리 보존 정책

const HISTORY_RETENTION_DAYS: Record<LicenseTier, number> = {
  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 생성

import { machineIdSync } from 'node-machine-id'
const machineId = machineIdSync(true) // 해시된 하드웨어 ID

또는 electron-store에 저장된 UUID (하드웨어 변경에 더 관대):

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 래퍼 컴포넌트

<FeatureGate feature={Feature.LIVE_CAPTION} fallback={<LockedFeatureCard />}>
  <CaptionControls />
</FeatureGate>

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. 에러 코드

// 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 통과