Electron 데스크톱 애플리케이션에서 Microsoft Store 구독 및 영구 라이선스 연동 방법
Electron 데스크톱 애플리케이션에서 Microsoft Store 구독 및 영구 라이선스 연동 방법
여러분의 Electron 애플리케이션이 Microsoft Store에서 구독과 영구 라이선스를 판매하려 할 때, WinRT의 상용 API를 어떻게 깔끔하게 비즈니스에 통합할 수 있을까요? 이것은 오래된 과제로서, 우리는 HagiCode Desktop에서 여러 시행착오를 겪으며 최종적으로 이 계층형 솔루션을 도출했습니다. 이 경험을 공유하여 후발자들이 더 쉽게 접근할 수 있도록 합니다.
배경
HagiCode Desktop은 Microsoft Store를 통해 배포되는 Electron 애플리케이션입니다. 상용화 제품은 주로 두 가지로 나뉩니다. 하나는 Sponsor Plan(스폰서 구독, Store ID 9N0BTGWV23M1)으로 매월 또는 매년 갱신되며, 다른 하나는 TurboEngine(영구 라이선스 DLC, Store ID 9NSD809W18Z6)으로 일회성 구매입니다. 두 모델은 각기 다른 사용자 요구를 충족시키고 있습니다.
문제는 Electron 런타임 자체가 Microsoft Store 상용 API를 직접 호출할 수 없다는 점입니다. Store의 구매와 라이선스 조회는 WinRT의 Windows.Services.Store 네임스페이스에 전적으로 의존하며, 이 API는 원시 코드에서만 사용할 수 있습니다. 그러나 Electron 메인 프로세스는 Node.js 환경이므로 WinRT 타입을 import할 수 없습니다. 마치 달빛을 잡으려 해도 손바닥은 언제나 비어있는 것과 같습니다.
더 복잡한 문제는 상용화 상태가 단 한 번의 조회로는 안심할 수 없다는 것입니다. 사용자는 Store 클라이언트에서 구독을 취소하거나 갱신하고 장치를 변경할 수 있으며, 애플리케이션의 기능 스위치도 이에 따라 변경되어야 합니다. 매번 사용자가 직접 “새로고침”을 누르게 하면 사용자 경험이 저하됩니다. 하지만 너무 자주 조회하면 Store의 속도 제한에 부딪혀 네트워크 불안정 시 유효한 구독을 미구독으로 잘못 판단해 유료 사용자의 기능을 차단할 수 있습니다. 이런 실수를 하고 나서는 웃음으로 눈물을 감추어야 합니다.
또 하나 쉽게 간과되는 부분은 배포 채널마다 동작이 다르다는 점입니다. 비 Store 버전(예: 포터블 버전)은 Store 런타임이 없어 StoreContext를 호출하면 즉시 실패합니다. 이 경우 애플리케이션을 충돌시키거나 사용자가 구독을 가진 것처럼 가정해서는 안 됩니다. 명확한 “지원하지 않음” 상태를 제공해야 합니다. 결국, 거짓으로 가정하는 것보다 솔직하게 인정하는 것이 낫습니다.
이러한 문제를 해결하기 위해 계층형 아키텍처를 구축했습니다. 이 솔루션은 HagiCode의 두 가지 OpenSpec 제안으로 정제되었습니다: 구독 라이선스의 지속성, 표준화, 권한 파생을 위한 desktop-subscription-entitlements와 TurboEngine 영구 라이선스의 구매, 새로고침, DLC 주입을 위한 desktop-turboengine-msstore-license입니다. 자세히 설명해 드리겠습니다.
HagiCode 소개
본 글에서 공유하는 솔루션은 HagiCode 프로젝트에서의 실무 경험에서 나왔습니다. HagiCode는 Web, Desktop, CLI 등 여러 플랫폼을 아우르는 AI 코드 어시스턴트 프로젝트입니다. HagiCode Desktop 데스크톱 제품 라인이 바로 본 글의 주제이며 전체 소스 코드는 HagiCode-org/site에서 확인할 수 있습니다.
계층화가 핵심
Electron 메인 프로세스에 직접 Store 호출을 작성하면 매우 복잡해집니다. WinRT의 비동기 객체, COM 스레드 모델, 윈도우 핸들 전달 등이 비즈니스 로직과 섞이면 유지보수가 거의 불가능해집니다. 우리의 접근법은 전체 흐름을 네 계층으로 나누어 각 계층이 하나의 책임만 담당하도록 하는 것입니다:
렌더링 프로세스 (React) ↕ IPC bridgeElectron 메인 프로세스 (TypeScript) ↕ broker 인터페이스네이티브 Node addon (C++) ↕ WinRTWindows.Services.Store가장 아래 계층은 C++ 네이티브 애드온으로 hagicode_store_purchase_addon.node라는 이름입니다. 이는 두 가지 메서드만 노출합니다: requestPurchase(storeId, windowHandle)와 queryStoreStatus(storeId, productName, productKinds)입니다. 이들은 WinRT의 RequestPurchaseAsync, GetAssociatedStoreProductsAsync, GetUserCollectionAsync에 해당합니다. 애드온의 전체 작업은 WinRT 비동기 결과를 JSON으로 변환하고 Napi::ThreadSafeFunction을 통해 JavaScript 스레드로 전달하는 것입니다.
중간 계층은 TypeScript로 작성된 StoreLicenseService입니다. 이는 WinRT를 신경 쓰지 않고 비즈니스 의미에 집중합니다: 새로고침, 재시도, 캐싱, 권한 파생, 상태 브로드캐스트 등을 담당합니다. StoreLicensePlatformBroker 인터페이스를 통해 하위 계층과 통신하며, 이 인터페이스는 queryStatus(), purchase(), dispose() 세 가지 메서드만 갖습니다.
최상위 계층은 SubscriptionService와 TurboEngineLicenseService로, 이들은 실제로는 StoreLicenseService의 얇은 래퍼일 뿐이며 각각 구체적인 제품 구성(Store ID, 제품명, 권한 이름)을 바인딩합니다.
이러한 계층화의 직접적인 이점은 구독과 영구 라이선스가 동일한 엔진을 공유할 수 있다는 점입니다. StoreLicenseService는 제네릭 클래스로 스냅샷 타입과 권한 이름을 매개변수화합니다. 새 제품을 추가할 때는 StoreLicenseProductConfig만 작성하면 되며 서비스 전체를 복사할 필요가 없습니다. HagiCode가 향후 macOS의 StoreKit이나 다른 상용화 채널을 통합한다면 이론적으로 broker 구현만 교체하면 되며 비즈니스 계층은 한 줄도 수정할 필요가 없습니다. 이것이 계층화의 부드러움입니다.
표준화: Store의 더러운 데이터 정리
WinRT가 반환하는 데이터는 매우 “원시적”입니다. StoreProductQueryResult에 IVectorView와 IMap이 중첩되어 있고, SKU의 CollectionData.EndDate는 Windows DateTime 틱(1601년 시작, 100나노초 단위), 오류 코드는 HRESULT입니다. 이것들을 그대로 렌더링 프로세스에 전달하면 프론트엔드 코드가 작동하지 않을 것입니다.
따라서 broker 계층에서 표준화를 수행하여 원시 WinRT 객체를 RawStoreLicenseState로 평탄화합니다:
export interface RawStoreLicenseState { fetchedAt: string; availability: 'supported' | 'store-unavailable' | 'error'; appLicenseActive: boolean; product: RawStoreLicenseProduct | null; sku: RawStoreLicenseSku | null; license: RawStoreLicense | null; purchaseEligibility: 'licensable' | 'not-licensable' | 'license-action-not-applicable' | 'network-error' | 'server-error' | 'unknown'; errorCode: string | null; errorMessage: string | null;}여기 주목할 세부 사항이 있습니다: 조회는 실제로 두 번의 Store 호출을 사용합니다. 하나는 GetAssociatedStoreProductsAsync(현재 애플리케이션과 연관된 제품)이고, 다른 하나는 GetUserCollectionAsync(사용자가 이미 소유한 제품)입니다. 이유는 간단합니다. 구독 제품은 연관 목록에 나타날 수 있지만 사용자가 아직 구매하지 않았을 수 있으며, 이미 사용자 컬렉션에 있을 수도 있습니다. 두 결과를 교차 비교해야만 “소유 여부”를 정확히 판단할 수 있습니다. 이것은 거리를 두고 사람을 볼 때 두 각도에서 보아야 오해하지 않는 것과 같습니다.
틱을 ISO 날짜로 변환하는 코드는 주의 깊게 봐야 합니다:
const WINDOWS_EPOCH_OFFSET_MILLISECONDS = 11644473600000n;const HUNDRED_NANOSECONDS_PER_MILLISECOND = 10000n;
// 틱은 1601 시작의 100나노초 단위, 먼저 밀리초로 변환한 후 Windows/Unix 에포크 차이를 뺍니다const unixMilliseconds = ticks / HUNDRED_NANOSECONDS_PER_MILLISECOND - WINDOWS_EPOCH_OFFSET_MILLISECONDS;11644473600000은 1601-01-01부터 1970-01-01 사이의 밀리초 수입니다. 이 변환은 C++ 애드온에서도 수행됩니다(FileTimeToSystemTime 사용). 양쪽 결과가 일치하지 않으면 “메인 프로세스에서는 오늘, 애드온에서는 어제”라는 이상한 불일치가 발생합니다. 시간과 감정처럼 불일치하면 아무것도 설명할 수 없습니다.
상태 머신: “원시 데이터”에서 “비즈니스 상태”로
표준화 후 또 다른 추상화 계층이 필요합니다. 비즈니스 코드는 purchaseEligibility가 무엇인지 알 필요가 없으며 “구독이 유효한지”만 신경씁니다. normalize.ts의 deriveStatus 함수가 이 번역을 수행합니다:
function deriveStatus( raw: RawStoreLicenseState, productConfig: StoreLicenseProductConfig): StoreLicenseStatus { if (raw.availability !== 'supported') { return 'unknown'; }
const expirationDate = raw.license?.expirationDate ?? raw.sku?.collectionEndDate ?? null; const expirationTime = expirationDate ? Date.parse(expirationDate) : Number.NaN; const hasExpired = Number.isFinite(expirationTime) && expirationTime < Date.now(); const isOwned = Boolean( raw.license?.isActive || raw.sku?.isInUserCollection || raw.product?.isInUserCollection );
if (isOwned && !hasExpired) { return 'active'; } if (hasExpired) { return 'expired'; } // ...기타 분기: inactive / canceled / grace-period / pending}최종 비즈니스 상태는 일곱 가지입니다: active, inactive, expired, canceled, grace-period, pending, unknown. 렌더링 프로세스는 이 필드만 보며 원시 데이터는 건드리지 않습니다.
설계상의 절충이 있습니다: active 판정은 expirationDate 존재 여부를 확인하지 않습니다. 이유는 간단합니다 - 영구 라이선스(TurboEngine)는 만료 시간 개념이 없으며 Store가 반환하는 license.isActive가 true면 충분합니다. “만료 시간이 있어야 active”라고 강요하면 일회성 구매 사용자를 미구독으로 잘못 판단할 수 있습니다. 이 세부 사항은 spec에 명확히 나와 있습니다: 영구 라이선스는 만료 메타데이터가 없어도 active를 유지합니다.
오류 처리: 네트워크 불안정 시 구독 손실 방지
네트워크 불안정 시 Store API는 오류 또는 타임아웃을 반환합니다. 매번 실패할 때 상태를 비우면 유료 사용자의 권한이 자주 차단됩니다. HagiCode의 전략은 “실패 시 마지막으로 알려진 상태를 유지하고 stale로 표시”하는 것입니다.
StoreLicenseService.refresh 내부에 재시도 루프(기본 3회, 350ms 간격)가 있으며 “상태 회귀” 검사도 수행합니다. 이전이 active였는데 이번에 active가 아니면 임시 오류로 간주하여 재시도하며 퇴화된 결과를 직접 수락하지 않습니다:
private getRetryReason( snapshot: TSnapshot, recoverySnapshot: TSnapshot | null): 'store-unavailable' | 'status-regression' | null { if (snapshot.availability !== 'supported') { return 'store-unavailable'; } if (recoverySnapshot?.status === 'active' && snapshot.status !== 'active') { return 'status-regression'; } return null;}재시도가 모두 실패한 후에만 createStaleSnapshot으로 마지막 좋은 상태를 stale로 표시하여 반환하며 store-refresh-failed 진단을 첨부합니다. 렌더링 프로세스는 stale 상태에서 기능 비활성화 여부를 결정할 수 있습니다 - 보통은 계속 허용하여 사용자에게 버퍼를 제공합니다. 네트워크가 나쁜 날에 돈을 내고 산 것도 사용할 수 없는 것은 원하지 않을 테니까요.
또 다른 세부 사항은 refreshInFlight 중복 제거입니다. 새로고침이 이미 진행 중이면 새 refresh 호출은 동일한 Promise를 재사용하여 Store에 과도한 요청을 보내지 않습니다. 이것은 줄 서는 것과 같은 원리로, 뭉쳐서 들어가면 오히려 아무도 지나갈 수 없습니다.
권한 파생: 상태와 기능 스위치 분리
구독 상태는 “구독이 유효한지”에 답하지만 기능 스위치는 “사용자가 특정 기능을 사용할 수 있는지”에 관심이 있습니다. 이것들은 일대일로 대응하지 않습니다. active 구독은 여러 권한(스폰서 배지, 프리미엄 기능 스위치)에 해당할 수 있으며 향후 등급별로 구분할 수도 있습니다.
따라서 EntitlementEvaluator 계층이 추가됩니다:
evaluate(snapshot: TSnapshot): TEntitlement[] { if (snapshot.availability !== 'supported' || snapshot.status !== 'active') { return []; } return [...this.activeEntitlements];}구독 제품 구성에서 활성화 시 부여할 권한을 선언합니다:
export const subscriptionEntitlementNames = [ 'sponsorBadge', 'premiumFeatureGate',] as const;이렇게 하면 기능 코드는 entitlements 배열에만 의존하며 status를 직접 읽지 않습니다. 향후 등급을 추가하거나 권한을 분리하려면 구성과 evaluator만 수정하면 되며 소비자를 수정할 필요가 없습니다. 이러한 분리는 HagiCode와 같은 다중 제품 라인 프로젝트에서 특히 중요합니다. 구독과 영구 라이선스가 동일한 권한 모델을 공유하며 프론트엔드는 배열 하나만 조회하면 됩니다. 세상이 훨씬 깔끔해집니다.
런타임 격하: Store가 없으면 어떻게
비 Store 배포 버전(포터블 버전, 개발 환경)에서 애드온을 호출하면 실패합니다. HagiCode는 MicrosoftStoreSubscriptionBroker로 지연 초기화와 격하를 처리합니다:
private async initializeBroker(): Promise<StoreLicensePlatformBroker> { try { return this.setBroker( await this.adapterFactory(this.windowHandle, this.productConfig) ); } catch (error) { // Store 런타임을 찾지 못하면 "아것도 지원하지 않는" broker로 격하 return this.setBroker(new UnavailableSubscriptionPlatformBroker(error)); }}UnavailableSubscriptionPlatformBroker는 동일한 인터페이스를 구현하지만 queryStatus는 항상 store-unavailable을, purchase는 항상 not-supported를 반환합니다. 상위 계층은 완전히 인식하지 못하며 상태만 “지원하지 않음”이 되고 렌더링 프로세스는 “Microsoft Store를 통해 획득하세요”라는 안내를 표시합니다.
이 설계는 전체 상용화 모듈이 모든 배포 채널에서 안전하게 실행되며 Store 런타임이 없어도 충돌하지 않습니다. 다중 채널 배포 Electron 애플리케이션을 개발 중이라면 이 점을 특히 벤치마킹할 가치가 있습니다. “환경 지원 안 함”을 충돌로 만들지 마세요. 어떤 일은 인정하는 것이 더 품위 있습니다.
시작 프로세스와 IPC 채널
애플리케이션 시작 시 main.ts는 --desktop-subscription-enabled=1 매개변수로 구독 서비스 초기화 여부를 결정합니다. 이 매개변수는 Store 버전의 시작 명령에만 포함되어 비 Store 버전의 불필요한 로딩을 방지합니다:
function initializeSubscriptionService(): void { if (!subscriptionFeatureEnabled || subscriptionService) { return; }
subscriptionService = new SubscriptionService({ broker: new MicrosoftStoreSubscriptionBroker({ windowHandle: mainWindow?.getNativeWindowHandle() ?? null, }), entitlementEvaluator: new EntitlementEvaluator(), });
registerSubscriptionHandlers({ subscriptionService, getWindows: () => ElectronBrowserWindow.getAllWindows(), });}windowHandle은 mainWindow.getNativeWindowHandle()에서 가져오며, 이 Buffer는 bigint로 구문 분석되어 네이티브 애드온으로 전달되고 애드온은 이를 사용하여 IInitializeWithWindow::Initialize를 호출합니다. 이것은 데스크톱 애플리케이션(비 UWP)에서 Store API가 구매 대화상자를 표시하는 필수 단계이며, 그렇지 않으면 구매 창에 소유자가 없어 동작이 이상해집니다. 사람도 소속이 없으면 행동이 떠다니는 것처럼 창도 그렇습니다.
렌더링 프로세스는 preload로 노출된 브리지를 통해 메인 프로세스를 호출합니다:
const subscriptionBridge: SubscriptionBridge = { getSnapshot: (options) => ipcRenderer.invoke(subscriptionChannels.getSnapshot, options), verifyStartup: () => ipcRenderer.invoke(subscriptionChannels.verifyStartup), refresh: () => ipcRenderer.invoke(subscriptionChannels.refresh), purchase: () => ipcRenderer.invoke(subscriptionChannels.purchase), onDidChange: (callback) => { const listener = (_event, snapshot) => callback(snapshot); ipcRenderer.on(subscriptionChannels.changed, listener); return () => ipcRenderer.removeListener(subscriptionChannels.changed, listener); },};상태 변경은 broadcastSnapshotChanged를 통해 모든 창에 푸시됩니다. 구매 완료 후 completePurchase는 refresh('purchase')를 트리거하며 새 상태가 자동으로 브로드캐스트되고 렌더링 프로세스의 구독 UI가 실시간으로 업데이트됩니다.
또한 main.ts에는 백그라운드에서 동기화하는 setInterval이 있습니다(subscriptionService?.refresh('scheduled')). 이는 애플리케이션이 열려 있는 동안 사용자가 Store 클라이언트에서 수행한 갱신, 취소를 감지합니다. 빈도는 너무 높을 수 없으며(Store에 속도 제한이 있음), 코드에서는 분 단위 간격을 사용합니다. 너무 멀지도 너무 가깝지도 않습니다. 딱 맞습니다.
쉽게 빠질 수 있는 함정
첫째, 네이티브 애드온의 스레드 안전성. WinRT 비동기 작업 완료 후 콜백은 JavaScript 스레드에 있지 않습니다. 콜백에서 직접 Napi API를 호출하면 충돌합니다. 애드온은 Napi::ThreadSafeFunction::BlockingCall로 결과를 JS 스레드로 전달합니다:
auto const status = threadsafeFunction_.BlockingCall( payload, [self](Napi::Env env, Napi::Function, PurchaseCompletion* data) { std::unique_ptr<PurchaseCompletion> ownedData{ data }; self->ResolveOnJs(env, *ownedData); });BlockingCall은 WinRT 콜백 스레드를 차단하며 JS 스레드 처리가 완료될 때까지 기다립니다. 이 모드에서 콜백 스레드는 JS 스레드 자체일 수 없으며 그렇지 않으면 교착 상태가 발생합니다. 다행히 WinRT의 Completed 콜백은 일반적으로 STA 또는 스레드 풀에서 실행되어 이 조건을 충족합니다.
둘째, COM 초기화. Electron 메인 스레드는 이미 COM을 초기화했을 수 있습니다. 애드온에서 winrt::init_apartment 외부에 try-catch를 래핑하며 실패 시 무시합니다:
try { winrt::init_apartment(winrt::apartment_type::single_threaded);} catch (...) { // Electron이 이미 이 스레드에 대해 COM을 초기화했을 수 있으므로 무시}이것을 처리하지 않으면 반복 초기화가 예외를 throw하고 애드온 로딩이 실패합니다. 어떤 오류는 무시하는 것이 맞습니다.
셋째, 윈도우 핸들 정확도. getNativeWindowHandle()은 Buffer를 반환하며 길이는 4(32비트) 또는 8(64비트)일 수 있습니다. 애드온에서 0x로 시작하는 16진수 문자열로 포맷되며 C++에서 std::stoull로 다시 HWND로 구문 분석됩니다. 문자열을 사용하는 이유는 JS number의 정확도가 53비트만 되어 64비트 포인터의 정확도가 손실되기 때문입니다. 이 함정은 한 번 겪어보지 않으면 발견하기 어렵습니다. 어떤 일은 직접 경험해야 설명할 수 있습니다.
넷째, 상태 격리. 구독과 영구 라이선스의 상태는 별도로 저장해야 합니다. HagiCode spec은 TurboEngine의 지속화가 sponsor 상태를 덮어쓰지 않도록 명확히 요구합니다. 두 스냅샷은 다른 productKey(subscription과 turboengine)로 분리되어 한 제품의 새로고침이 다른 제품의 캐시를 덮어쓰지 않도록 합니다. 각자 각자의 일을 관리해야 세상이 평화로워집니다.
다섯째, 구매 후 새로고침 필수. 구매 완료 후 다시 새로고침해야 브로드캐스트됩니다. completePurchase에서 succeeded와 already-purchased 두 경우 모두 refresh('purchase')를 트리거합니다. Store 구매 결과는 거래 상태만 알려주며 현재 라이선스 세부 정보는 알려주지 않습니다. 라이선스 상태는 다시 조회해야 합니다. 약속과 현실 사이에는 확인 과정이 필요합니다.
요약
이 구현은 한동안 실행되었으며 전반적으로 안정적입니다. 가장 가치 있는 것은 특정 작은 기술이 아니라 이러한 계층화 방식입니다. “Store와 통신”이라는 더러운 작업을 broker와 애드온에 완전히 격리하고 상위 계층은 순수한 비즈니스 의미만 처리합니다.
핵심 경험은 다음과 같습니다:
- WinRT는 C++ 애드온에서만 다루며 애드온은 “비동기에서 JSON으로”만 수행하고 비즈니스 의미는 전혀 관여하지 않습니다.
- 표준화와 상태 머신을 두 계층으로 분리하며 원시 데이터와 비즈니스 상태를 혼합하지 않습니다.
- 네트워크 실패 시 마지막 좋은 상태를 유지하고 stale로 표시하며 유료 사용자의 권한을 차단하지 않습니다.
- 권한과 상태를 분리하며 기능 코드는
entitlements배열만 봅니다. - 비 Store 환경은 격하 broker를 사용하며 “지원하지 않음”을 충돌로 만들지 않습니다.
Electron 애플리케이션의 Store 상용화를 개발 중이라면 이 계층화가 함정을 피하는 데 도움이 되기를 바랍니다.
본 글에서 공유하는 이 솔루션은 HagiCode 개발 과정에서 실제로 겪은 함정을 바탕으로 실제로 최적화된 것입니다. 가치가 있다고 느끼신다면 우리의 엔지니어링 역량도 꽤 괜찮다는 것을 의미합니다. 그렇다면 HagiCode 자체도 한 번쯤 다시 보실 가치가 있습니다…
참고 자료
- HagiCode 공식 웹사이트
- HagiCode-org/site GitHub 저장소
- Windows.Services.Store 네임스페이스 - WinRT 문서
- Electron getNativeWindowHandle 문서
- Node-API ThreadSafeFunction 문서
요약
“Electron 데스크톱 애플리케이션에서 Microsoft Store 구독 및 영구 라이선스 연동 방법”에 대해, 더 안정적인 진행 방식은 핵심 구성, 의존성 경계 및 실현 경로를 단계적으로 통과한 후 최적화 세부 사항을 보완하는 것입니다.
목표, 단계 및 수락 기준이 명확해지면 이러한 솔루션은 일반적으로 실제 전달로 더 원활하게 진입할 수 있습니다.
开始使用 HagiCode
一次安装,几分钟上手
HagiCode for Windows 在 Microsoft Store 免费提供。打开商店即可安装并保持更新;也可以先对比各版本与定价,再决定从哪个渠道开始。