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) - 커맨드 팝업 "선택 해제" 항목 추가
14 KiB
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 통과