Skip to main content

클라이언트 앱 라이선스 관리 가이드

작성일: 2026-04-06 Last Updated: 2026-04-06 대상: Browser Extension, Tauri Desktop, Flutter Mobile


목차

  1. 라이선스 체계
  2. 트라이얼 기간 관리
  3. 라이선스 키 활성화/비활성화
  4. 오프라인 라이선스 검증
  5. 디바이스 수 제한
  6. SaaS 구독 연동
  7. 불법 복제 방지
  8. 은혜 기간 (Grace Period)

1. 라이선스 체계

1.1 플랜 구조

플랜가격대상주요 제한
Free무료개인 사용자기능 제한, 사용량 제한
Pro월/연 구독개인/소규모 팀전체 기능, 합리적 사용량
Enterprise커스텀기업전체 기능, 무제한, 전용 지원

1.2 플랜별 기능 매트릭스

기능FreeProEnterprise
핵심 기능OOO
고급 기능XOO
API 호출100/일10,000/일무제한
저장소 용량100MB10GB무제한
팀 멤버110무제한
우선 지원X이메일전담 매니저
SSO/SAMLXXO
감사 로그XXO
SLAX99.5%99.9%
오프라인 모드X7일30일
커스텀 브랜딩XXO

1.3 라이선스 모델 비교

모델설명적합
구독형 (Subscription)월/연 결제, 미갱신 시 Free로 전환SaaS 기본 모델
영구 라이선스 (Perpetual)1회 결제, 특정 버전 영구 사용데스크톱 앱
하이브리드구독 + 영구 (1년 구독 후 영구 전환)Tauri 앱 권장
시트 기반 (Per-Seat)사용자 수 기반 과금Enterprise

1.4 플랫폼별 라이선스 고려사항

항목Browser ExtensionTauriFlutter
스토어 인앱 결제해당 없음해당 없음필수 (iOS 30%)
외부 결제 허용OO제한적 (Android 허용, iOS Reader Rule 등)
자체 라이선스 서버OOO (서버 검증)
로컬 라이선스 파일불필요O불필요

iOS 주의: App Store Review Guideline 3.1.1에 따라 디지털 콘텐츠/서비스는 In-App Purchase를 사용해야 함. 외부 결제는 "Reader App" 예외 등 제한적 허용.


2. 트라이얼 기간 관리

2.1 트라이얼 유형

유형설명장점단점
시간 제한14일간 Pro 기능 체험간단, 명확충분히 체험 못 할 수 있음
기능 제한일부 Pro 기능만 무료영구 사용 가능전체 가치 체험 어려움
사용량 제한Pro 기능 + 사용량 제한유연함구현 복잡
하이브리드14일 무제한 + 이후 기능 제한최적구현 복잡

2.2 트라이얼 시작 조건

사용자 가입


[이전 트라이얼 이력 확인] ──(있음)──→ Free 플랜 적용
│ (없음)

[14일 Pro 트라이얼 자동 시작]

▼ (14일 후)
[트라이얼 만료]

├──→ 구독 전환 → Pro 유지

└──→ 미전환 → Free 다운그레이드

2.3 트라이얼 상태 관리 (서버)

// app/Models/Subscription.php
class Subscription extends Model
{
public function isOnTrial(): bool
{
return $this->trial_ends_at !== null
&& $this->trial_ends_at->isFuture();
}

public function trialDaysRemaining(): int
{
if (!$this->isOnTrial()) return 0;
return now()->diffInDays($this->trial_ends_at);
}
}

2.4 트라이얼 남용 방지

방지 수단방법효과
이메일 인증가입 시 이메일 확인
디바이스 핑거프린팅고유 디바이스 식별
결제 정보 요구트라이얼 시작 시 카드 등록높음
전화번호 인증SMS 인증높음
서버측 이력이메일/IP/디바이스 기반 중복 체크

2.5 트라이얼 알림 스케줄

시점알림 내용채널
시작"14일 Pro 체험이 시작되었습니다"인앱 + 이메일
7일 남음"체험이 7일 남았습니다"인앱
3일 남음"체험이 곧 종료됩니다"인앱 + 이메일
1일 남음"내일 체험이 종료됩니다"인앱 + 이메일
만료"체험이 종료되었습니다. 구독하세요"인앱 + 이메일
만료 3일 후"Pro 기능이 그리우신가요?"이메일

3. 라이선스 키 활성화/비활성화

3.1 라이선스 키 형식

형식: XXXXX-XXXXX-XXXXX-XXXXX-XXXXX
예: K9F2A-BH7M3-QW4E5-RT6Y8-ZX1C0

구성:
- 25자 영숫자 (5x5 그룹)
- 체크섬 포함 (마지막 2자리)
- Base32 인코딩 (혼동 문자 제외: 0/O, 1/I/L)

3.2 활성화 플로우

사용자: 라이선스 키 입력


[클라이언트: 형식 검증 (체크섬)]
│ (유효)

[서버: POST /api/licenses/activate]

├─ 키 유효성 확인
├─ 이미 활성화된 디바이스 수 확인
├─ 현재 디바이스 등록


[활성화 응답]

├─ 성공: 라이선스 정보 + 서명된 토큰 저장

└─ 실패: 에러 메시지 (만료, 디바이스 초과, 잘못된 키)

3.3 서버 API

활성화

POST /api/licenses/activate
// 요청
{
"licenseKey": "K9F2A-BH7M3-QW4E5-RT6Y8-ZX1C0",
"deviceId": "d4e5f6a7-b8c9-1234-5678-9abcdef01234",
"deviceName": "John's MacBook Pro",
"platform": "tauri_macos",
"appVersion": "2.3.1"
}
// 응답 (성공)
{
"success": true,
"license": {
"plan": "pro",
"expiresAt": "2027-04-06T00:00:00Z",
"features": ["advanced_export", "team_sync", "priority_support"],
"maxDevices": 3,
"activeDevices": 1,
"offlineGraceDays": 7
},
"token": "eyJhbGciOiJFZDI1NTE5IiwidHlwIjoiSldUIn0...",
"tokenExpiresAt": "2026-04-13T00:00:00Z"
}

비활성화

POST /api/licenses/deactivate
// 요청
{
"licenseKey": "K9F2A-BH7M3-QW4E5-RT6Y8-ZX1C0",
"deviceId": "d4e5f6a7-b8c9-1234-5678-9abcdef01234"
}

3.4 Laravel 백엔드 구현

// app/Models/License.php
class License extends Model
{
protected $casts = [
'features' => 'array',
'expires_at' => 'datetime',
'max_devices' => 'integer',
];

public function activations(): HasMany
{
return $this->hasMany(LicenseActivation::class);
}

public function isValid(): bool
{
return $this->status === 'active'
&& ($this->expires_at === null || $this->expires_at->isFuture());
}

public function canActivateNewDevice(): bool
{
return $this->activations()->where('active', true)->count() < $this->max_devices;
}

public function generateSignedToken(string $deviceId): string
{
$payload = [
'license_id' => $this->id,
'plan' => $this->plan,
'features' => $this->features,
'device_id' => $deviceId,
'expires_at' => $this->expires_at?->toIso8601String(),
'offline_grace_days' => $this->offline_grace_days,
'issued_at' => now()->toIso8601String(),
'token_expires_at' => now()->addDays(7)->toIso8601String(),
];

// Ed25519 서명 (서버 개인 키)
return JWT::encode($payload, config('licensing.private_key'), 'EdDSA');
}
}
// app/Http/Controllers/Api/LicenseController.php
class LicenseController extends Controller
{
public function activate(LicenseActivateRequest $request): JsonResponse
{
$license = License::where('key', $request->licenseKey)->first();

if (!$license || !$license->isValid()) {
return response()->json([
'success' => false,
'error' => 'invalid_license',
'message' => '유효하지 않은 라이선스 키입니다.',
], 400);
}

if (!$license->canActivateNewDevice()) {
return response()->json([
'success' => false,
'error' => 'device_limit_exceeded',
'message' => "최대 디바이스 수({$license->max_devices})를 초과했습니다.",
'maxDevices' => $license->max_devices,
], 400);
}

$activation = $license->activations()->updateOrCreate(
['device_id' => $request->deviceId],
[
'device_name' => $request->deviceName,
'platform' => $request->platform,
'app_version' => $request->appVersion,
'active' => true,
'last_seen_at' => now(),
]
);

$token = $license->generateSignedToken($request->deviceId);

return response()->json([
'success' => true,
'license' => [
'plan' => $license->plan,
'expiresAt' => $license->expires_at,
'features' => $license->features,
'maxDevices' => $license->max_devices,
'activeDevices' => $license->activations()->where('active', true)->count(),
'offlineGraceDays' => $license->offline_grace_days,
],
'token' => $token,
'tokenExpiresAt' => now()->addDays(7),
]);
}

public function deactivate(Request $request): JsonResponse
{
$license = License::where('key', $request->licenseKey)->first();

if ($license) {
$license->activations()
->where('device_id', $request->deviceId)
->update(['active' => false]);
}

return response()->json(['success' => true]);
}
}

3.5 클라이언트 라이선스 저장

플랫폼저장 위치보안
Browser Extensionchrome.storage.local (암호화된 토큰)확장 샌드박스
TauriOS 키체인 (tauri-plugin-keychain) 또는 암호화된 로컬 파일OS 보안
Flutter (iOS)KeychainiOS 보안
Flutter (Android)EncryptedSharedPreferencesAndroid Keystore

4. 오프라인 라이선스 검증

4.1 오프라인 검증 메커니즘

앱 시작


[네트워크 상태 확인]
│ │
(온라인) (오프라인)
│ │
▼ ▼
[서버 라이선스 검증] [로컬 토큰 검증]
│ │
▼ ├─ [토큰 서명 검증 (공개 키)]
[토큰 갱신 + 로컬 저장] ├─ [토큰 만료일 확인]
├─ [오프라인 은혜 기간 확인]


[유효?]
│ │
(Yes) (No)
│ │
▼ ▼
[정상] [Free 모드 전환]

4.2 서명 기반 오프라인 검증

// 클라이언트: Ed25519 공개 키로 토큰 검증
import { verify } from '@noble/ed25519';

class OfflineLicenseValidator {
// 앱에 하드코딩된 서버 공개 키
private static readonly PUBLIC_KEY = 'MCowBQYDK2VwAyEA...';

async validate(token: string): Promise<LicenseInfo | null> {
try {
// 1. 토큰 서명 검증
const payload = JWT.verify(token, OfflineLicenseValidator.PUBLIC_KEY, {
algorithms: ['EdDSA'],
});

// 2. 토큰 만료 확인
if (new Date(payload.token_expires_at) < new Date()) {
return null; // 토큰 만료 → 온라인 갱신 필요
}

// 3. 오프라인 은혜 기간 확인
const issuedAt = new Date(payload.issued_at);
const graceDays = payload.offline_grace_days || 7;
const graceEnd = new Date(issuedAt);
graceEnd.setDate(graceEnd.getDate() + graceDays);

if (new Date() > graceEnd) {
return null; // 은혜 기간 만료
}

// 4. 디바이스 ID 확인
if (payload.device_id !== await getDeviceId()) {
return null; // 다른 디바이스
}

return payload as LicenseInfo;
} catch {
return null;
}
}
}

4.3 오프라인 기간별 동작

오프라인 기간동작알림
0 ~ 3일정상 동작없음
4 ~ 6일정상 동작"인터넷 연결 필요" 배너
7일 (기본 은혜)마지막 경고"내일부터 Free 모드 전환" 모달
8일+Free 모드 전환"라이선스 검증 필요" 안내
재연결 시즉시 라이선스 갱신"라이선스가 복원되었습니다"

5. 디바이스 수 제한

5.1 플랜별 디바이스 제한

플랜최대 디바이스비고
Free1단일 디바이스
Pro3개인용 (PC + 폰 + 태블릿)
Enterprise무제한사용자(시트) 단위 관리

5.2 디바이스 식별

플랫폼디바이스 ID 생성 방법비고
Browser Extensioncrypto.randomUUID()chrome.storage.local 저장재설치 시 새 ID
Tauri (macOS)IOPlatformUUID (시스템 UUID)안정적
Tauri (Windows)MachineGuid (레지스트리)안정적
Tauri (Linux)/etc/machine-id안정적
Flutter (iOS)identifierForVendor앱 삭제 시 변경 가능
Flutter (Android)Settings.Secure.ANDROID_ID공장 초기화 시 변경

5.3 디바이스 관리 UI

기능설명
디바이스 목록활성화된 디바이스 이름, 플랫폼, 마지막 접속
원격 비활성화웹 대시보드에서 특정 디바이스 라이선스 해제
자동 비활성화90일간 미접속 디바이스 자동 해제
디바이스 이름 변경식별 편의를 위한 이름 수정

5.4 디바이스 초과 시 처리

새 디바이스 활성화 시도


[활성 디바이스 수 < 최대?]
│ │
(Yes) (No)
│ │
▼ ▼
[활성화] [디바이스 목록 표시]
"최대 디바이스 수에 도달했습니다.
기존 디바이스를 비활성화하거나
플랜을 업그레이드하세요."

┌──────┼──────┐
▼ ▼ ▼
[비활성화] [업그레이드] [취소]

6. SaaS 구독 연동

6.1 아키텍처

[클라이언트 앱]

│ License Token (JWT)


[Laravel API 서버]

├── License 검증
├── 구독 상태 확인


[Stripe / Paddle / 인앱결제]

├── 웹훅으로 구독 이벤트 수신
├── subscription.created / updated / cancelled


[License 자동 업데이트]

6.2 결제 제공자 연동

제공자용도장점
Stripe웹 결제, 데스크톱강력한 API, 유연성
Paddle웹 결제 (Merchant of Record)세금 처리 자동화, VAT 관리
Apple IAPiOS 앱App Store 필수
Google Play BillingAndroid 앱Play Store 필수

6.3 구독 → 라이선스 동기화

// app/Listeners/SubscriptionEventListener.php
class SubscriptionEventListener
{
public function handleCreated(SubscriptionCreated $event): void
{
$subscription = $event->subscription;
$user = $subscription->user;

// 라이선스 자동 생성 또는 업그레이드
$license = License::updateOrCreate(
['user_id' => $user->id],
[
'plan' => $this->mapPlanFromSubscription($subscription),
'status' => 'active',
'expires_at' => $subscription->current_period_end,
'features' => $this->getFeaturesForPlan($subscription->plan),
'max_devices' => $this->getMaxDevicesForPlan($subscription->plan),
]
);

// 키 미발급 시 생성
if (!$license->key) {
$license->update(['key' => License::generateKey()]);
}
}

public function handleCancelled(SubscriptionCancelled $event): void
{
$subscription = $event->subscription;

// 구독 기간 종료까지는 유지, 이후 다운그레이드
License::where('user_id', $subscription->user_id)
->update([
'status' => 'cancelling',
'expires_at' => $subscription->current_period_end,
]);
}

public function handleExpired(SubscriptionExpired $event): void
{
$subscription = $event->subscription;

License::where('user_id', $subscription->user_id)
->update([
'plan' => 'free',
'status' => 'expired',
'features' => $this->getFeaturesForPlan('free'),
'max_devices' => 1,
]);
}
}

6.4 인앱 결제 연동 (Flutter)

// 인앱 결제 → 서버 영수증 검증 → 라이선스 활성화
class IAPService {
Future<void> handlePurchase(PurchaseDetails purchase) async {
if (purchase.status == PurchaseStatus.purchased) {
// 서버에서 영수증 검증
final response = await api.post('/api/iap/verify', {
'platform': Platform.isIOS ? 'apple' : 'google',
'receipt': purchase.verificationData.serverVerificationData,
'productId': purchase.productID,
});

if (response.success) {
// 라이선스 활성화
await licenseService.activate(response.license);
}
}
}
}

6.5 영수증 서버 검증

// app/Http/Controllers/Api/IAPController.php
class IAPController extends Controller
{
public function verify(Request $request): JsonResponse
{
$validator = match ($request->platform) {
'apple' => new AppleReceiptValidator(),
'google' => new GoogleReceiptValidator(),
};

$result = $validator->verify($request->receipt, $request->productId);

if (!$result->isValid()) {
return response()->json(['success' => false, 'error' => 'invalid_receipt'], 400);
}

// 구독 기록 + 라이선스 업데이트
$subscription = Subscription::createFromIAP($request->user(), $result);
$license = License::syncFromSubscription($subscription);

return response()->json([
'success' => true,
'license' => $license->toClientArray(),
]);
}
}

7. 불법 복제 방지

7.1 방어 전략 (최소한의 보호)

원칙: 정직한 사용자에게 불편을 주지 않으면서, 캐주얼 해킹을 방지하는 수준. 완벽한 방지는 불가능하며, 과도한 DRM은 역효과를 낳음.

레이어방법효과구현 비용
1. 서버 검증주기적 라이선스 서버 통신높음낮음
2. 서명 토큰Ed25519 서명된 라이선스 토큰낮음
3. 기능 서버화핵심 로직을 서버 API로높음
4. 코드 난독화최소한의 난독화낮음낮음
5. 무결성 검사앱 바이너리 해시 검증

7.2 서버 기능 의존 전략

클라이언트 (무료 기능) ←→ 서버 (유료 기능의 핵심 로직)

예:
- AI 분석: 서버에서 처리 → 결과만 전달
- 고급 내보내기: 서버에서 파일 생성 → 다운로드
- 팀 동기화: 서버 필수
기능 유형위치불법 복제 영향
UI/UX클라이언트복제 가능
데이터 처리 (로컬)클라이언트복제 가능
AI/ML 추론서버복제 불가
데이터 동기화서버복제 불가
고급 내보내기서버복제 불가

7.3 라이선스 검증 주기

상황검증 주기실패 시
앱 시작항상오프라인 토큰 사용
유료 기능 사용24시간마다오프라인 은혜 기간
결제 갱신일즉시은혜 기간 적용
백그라운드7일마다다음 포어그라운드 시

7.4 탈옥/루팅 감지 (선택)

플랫폼감지 방법대응
iOSjailbreak_detection 패키지경고 표시 (차단은 권장하지 않음)
AndroidSafetyNet / Play Integrity API경고 표시
Tauri해당 없음-
Extension해당 없음-

주의: 루팅/탈옥 감지로 앱을 차단하면 App Store 심사에서 거부될 수 있음. 보안 경고 수준만 권장.


8. 은혜 기간 (Grace Period)

8.1 은혜 기간 유형

상황은혜 기간동작
구독 결제 실패7일Pro 기능 유지, 결제 재시도
구독 만료 (취소)구독 기간 종료까지Pro 기능 유지
오프라인 상태7일 (Pro), 30일 (Enterprise)로컬 토큰으로 동작
라이선스 서버 장애무제한 (토큰 유효 기간 내)기존 토큰으로 동작

8.2 결제 실패 은혜 기간 플로우

결제 실패 (카드 만료, 잔액 부족 등)

├─ 즉시: Stripe 자동 재시도 (#1)
├─ 3일 후: 자동 재시도 (#2) + 이메일 알림
├─ 5일 후: 자동 재시도 (#3) + 인앱 배너
├─ 7일 후: 자동 재시도 (#4) + 긴급 알림

▼ (7일 내 결제 성공)
[라이선스 유지, 정상 동작]

▼ (7일 초과 결제 실패)
[라이선스 다운그레이드 → Free]
[사용자 데이터는 보존 (90일)]
[구독 재개 시 Pro 복원]

8.3 은혜 기간 중 사용자 알림

일차인앱 알림이메일제한
1일배너 (무시 가능)결제 실패 안내없음
3일배너 (강조)결제 수단 업데이트 요청없음
5일모달 (1일 1회)긴급 안내새 데이터 생성 경고
7일모달 (닫기 불가)최종 경고읽기 전용 모드
8일+전체 화면다운그레이드 안내Free 기능만

8.4 은혜 기간 구현

// app/Services/GracePeriodService.php
class GracePeriodService
{
public function getEffectivePlan(User $user): string
{
$license = $user->license;

// 활성 구독
if ($license->status === 'active') {
return $license->plan;
}

// 결제 실패 은혜 기간
if ($license->status === 'past_due') {
$daysSinceDue = $license->payment_failed_at->diffInDays(now());
if ($daysSinceDue <= $license->grace_period_days) {
return $license->plan; // 은혜 기간 중 기존 플랜 유지
}
}

// 취소 대기 (기간 종료까지)
if ($license->status === 'cancelling' && $license->expires_at->isFuture()) {
return $license->plan;
}

return 'free';
}

public function getGraceStatus(User $user): ?GraceStatus
{
$license = $user->license;

if ($license->status !== 'past_due') {
return null;
}

$daysSinceDue = $license->payment_failed_at->diffInDays(now());
$daysRemaining = max(0, $license->grace_period_days - $daysSinceDue);

return new GraceStatus(
inGracePeriod: $daysRemaining > 0,
daysRemaining: $daysRemaining,
reason: 'payment_failed',
paymentRetryAt: $license->next_payment_retry_at,
);
}
}

부록: 라이선스 시스템 체크리스트

구현 체크리스트

항목상태
라이선스 키 생성/관리 시스템[ ]
활성화/비활성화 API[ ]
오프라인 검증 (서명 토큰)[ ]
디바이스 관리 (등록/해제)[ ]
트라이얼 기간 관리[ ]
은혜 기간 로직[ ]
결제 웹훅 → 라이선스 동기화[ ]
클라이언트 라이선스 저장 (보안)[ ]
관리자 라이선스 대시보드[ ]

보안 체크리스트

항목상태
Ed25519 키 쌍 생성 및 보관[ ]
토큰 서명 검증 구현[ ]
API 통신 HTTPS 전용[ ]
라이선스 키 해싱 저장 (DB)[ ]
Rate limiting (활성화 API)[ ]
비정상 활성화 패턴 모니터링[ ]

스토어 규정 체크리스트

항목상태
iOS: 인앱 결제 연동 (해당 시)[ ]
Android: Google Play Billing 연동 (해당 시)[ ]
구독 취소 용이성 (명확한 안내)[ ]
자동 갱신 명시[ ]
개인정보처리방침에 라이선스 데이터 수집 명시[ ]
무료 기능 범위 명확 (심사 기준)[ ]