SubSovereign
All guides

SubSovereign iOS — 통합 가이드

이 가이드는 여러분의 애플리케이션(아이폰, 아이패드, 애플 TV 또는 맥)에서 "누가 내게 돈을 지불했는지 전혀 모르겠어" 상태에서 "내 앱이 올바른 사용자에게 올바른 기능을 해제해 주고, 내 서버에서 검증된 상태로 동작해" 상태로 전환하는 데 도움을 줍니다. 이 가이드는 처음부터 끝까지 따라갈 수 있도록 작성되었으며, 구독 도구에 대한 사전 경험은 필요 없습니다.

SubSovereign이 여러분을 위해 하는 일

여러분의 사용자는 앱 스토어를 통해 구독합니다. SubSovereign은 앱에 대해 한 가지 질문을 reliably하게 답합니다: 이 사용자가 실제로 무엇을 결제했나요?

  • 앱은 SubSovereign에 사용자의 엔타이틀먼트 — 즉, 사용자가 해제한 접근 권한 — 를 요청합니다.
  • 서버 측 수령 검증은 서버에서 직접(애플 앱 스토어 서버 API를 통해) 이루어지므로, 수정된 앱이 구독을 위조할 수 없습니다.
  • 페이월 — 즉, 사용자에게 구독을 제안하는 화면 — 은 원격으로 구성할 수 있어, 가격, 체험판, 설명문을 앱 버전을 새로 배포하지 않고도 변경할 수 있습니다.
  • EU 법적 철회 기능 — 즉, 법적 철회 제어 — 도 SDK에서 제공되며, 고객의 언어로 표시됩니다. 따라서 법적 고지가 마케팅용으로 부드럽게 표현되는 일이 없습니다.
  • 자가 호스팅입니다: SubSovereign은 여러분의 인프라에서 실행되며, 사용자의 데이터는 여러분과 함께 유지됩니다. 또한 수익 분배가 없습니다 — 사용자가 지불한 금액의 100%를 여러분이 보유합니다.

장치를 신뢰하지 마세요. 장치가 요청하고, 서버가 결정합니다. 동일한 코드가 iOS, tvOS(애플 TV), macOS에서 동작합니다.

시작하기 전에

다음 항목이 필요합니다:

  1. 실행 중인 SubSovereign 서버 (여러분의 자가 호스팅 배포) — SDK는 해당 URL을 가리킵니다.
  2. 대시보드에 등록된 앱 — 앱 ID(appId)와 API 키(앱이 서버와 통신하는 자격 증명)를 제공합니다.
  3. 액세스 레벨 생성 — 앱이 부여하는 액세스 레벨(티어, 예: pro)을 대시보드에서 생성하고, 각 레벨을 사용자가 구매하는 앱 스토어 상품 ID에 연결합니다.
  4. StoreKit 2 설정. SubSovereign은 구매를 검증하고 추적하지만, StoreKit을 대체하지 않습니다. 구매는 평소대로 StoreKit 2로 처리한 후, SubSovereign이 해당 트랜잭션을 검증하고 기록합니다.

코드 측면에서는 Swift 동시성(async/await)과 iOS 15 / tvOS 15 / macOS 12 이상의 배포 대상이 필요합니다.

1단계 — SDK 추가

SubSovereign을 Swift 패키지 매니저로 추가합니다. Xcode에서 *파일 ▸ 패키지 추가…*를 선택하고 SubSovereign SDK 패키지를 가리킨 다음, 가져옵니다:

import SubSovereign

2단계 — 앱 시작 시 한 번 구성

SDK를 한 번만 구성합니다. 좋은 위치는 App 초기화 또는 사용자 로그인 직후입니다. API 키, 앱 ID, 서버 URL, 이 사용자에 대한 안정적인 식별자, 그리고 사용자의 언어를 제공합니다.

SubSovereign.shared.configure(
    SubSovereignConfig(
        apiKey:  "YOUR_APP_API_KEY",                 // from the dashboard
        appId:   "your-app-id",                      // from the dashboard
        baseURL: "https://subs.yourdomain.com/api/v1", // YOUR self-hosted server
        userId:  currentUser.id,                     // your own stable user id
        locale:  Locale.current.identifier           // the user's language
    )
)

알아두면 좋은 점:

  • userId는 여러분의 것입니다. 이미 로그인한 사용자에 대해 안정적인 ID를 사용하고, 매번 동일한 값을 사용하세요. 그러면 장치 간에 접근 권한이 동기화됩니다. 다른 사용자가 로그인하면 configure를 다시 호출하여 새로운 userId로 설정합니다.
  • baseURL여러분의 서버를 가리킵니다 — SDK 기본값은 자리 표시자이므로, 여러분의 배포 주소를 설정하세요.
  • SDK는 @MainActor로 주석 처리되어 있으므로, 메인 액터(예: SwiftUI 뷰 또는 .task)에서 호출하세요.

3단계 — 사용자가 접근할 수 있는 권한 확인

checkEntitlements()를 호출하여 사용자가 해제한 권한을 확인합니다. 앱 시작 시와 구매 직후에 다시 호출하세요. 이 함수는 throw할 수 있는 async 호출이므로 do/catch로 감싸세요:

do {
    let result = try await SubSovereign.shared.checkEntitlements()
    if result.hasAccess {
        unlockProFeatures()          // the user has paid access
    } else {
        showFreeExperience()         // free tier / show a paywall
    }
} catch {
    // Network hiccup or server error. Fail gracefully — usually keep the user on
    // whatever access they last had, and try again later.
    print("Entitlement check failed: \(error.localizedDescription)")
}

result.hasAccess는 이미 가져온 결과에 대한 빠른 yes/no 응답입니다. 한 줄로 기능을 제어하려면 동일한 이름의 메서드를 사용하세요. 이 메서드는 모든 오류에서 false를 반환하므로, 네트워크 장애 시 아무것도 열리지 않도록 차단합니다:

if await SubSovereign.shared.hasAccess(accessLevelName: "pro") {
    unlockProFeatures()
}

단일 기능에 대한 간단한 제어에 사용하세요. 앱 전체의 접근 결정에는 checkEntitlements()를 사용하고, 아래 오류 처리 섹션의 실패 시 안내를 따르세요. 서비스 중단이 발생하더라도 결제한 사용자를 마지막Known 접근 권한으로 유지하고, 잠금 해제하지 마세요.

앱에 여러 티어가 있는 경우 result.entitlements 내부를 확인하세요. 각 항목은 활성화된 액세스 레벨을 이름으로 명명합니다. 대시보드에서 레벨에 부여한 이름으로 일치시키세요. accessLevelId는 서버의 내부 ID이며, 해당 이름이 아닙니다:

let isPro = result.entitlements.contains { $0.isActive && $0.accessLevelName.lowercased() == "pro" }

모든 엔타이틀먼트에는 expiresAt, willRenew, 그리고 출처가 되는 store가 포함됩니다. 결과에는 서버가 자체 캐시에서 응답했는지 여부를 보고하는 fromCache도 포함됩니다. SDK 자체는 캐시를 보유하지 않으며, 모든 호출은 여러분의 서버로 전송됩니다.

4단계 — 구독 판매

페이월 표시

가격, 체험판, 설명문을 앱 버전을 새로 배포하지 않고도 변경할 수 있도록, 서버에서 페이월을 가져옵니다:

if let paywall = try? await SubSovereign.shared.getPaywallConfig() {
    renderPaywall(paywall)   // headline, features, products…
} else {
    renderFallbackPaywall()  // your built-in default
}

PaywallConfigheadline, subheadline, features 목록, 제공할 products(각각 displayPrice, period, trialDays, 선택적 badge 포함), 호출-to-액션 텍스트, 푸터 텍스트를 제공합니다. 실제 화면은 여러분이 구성합니다. SubSovereign이 무엇을 말해야 하는지 알려줍니다.

구매 완료 후 검증

StoreKit 2를 통해 평소대로 구매를 진행합니다. 검증된 Transaction을 받으면, SubSovereign에 전달하여 서버가 애플과 직접 검증하고 액세스 레벨을 부여하도록 합니다:

func buy(_ product: Product, accessLevelId: String) async throws {
    let result = try await product.purchase()
    guard case .success(let verification) = result else { return }   // cancelled, or pending approval
    guard case .verified(let transaction) = verification else {
        // .unverified means StoreKit could not trust the signature — a tampered receipt.
        // Never grant access, and never let this pass silently.
        print("Refused an unverified transaction")
        return
    }

    let granted = try await SubSovereign.shared.validateApplePurchase(
        transaction: transaction,
        accessLevelId: accessLevelId      // required; the SERVER grants whatever tier your
                                          // dashboard maps this product to, not this value
    )
    if granted {
        await SubSovereign.shared.finishTransaction(transaction)  // tell StoreKit it's done
        _ = try await SubSovereign.shared.checkEntitlements()      // re-check, then unlock
    }
}

신뢰 모델의 전부입니다: 구매는 서버가 애플과 직접 확인한 후에야 비로소 실재합니다.

5단계 — EU 법적 철회 기능(Compliance Passport)

EU 소비자에게 구독을 판매하는 경우, Directive (EU) 2023/2673에 따라 철회 기능 — 즉, 취소 제어 — 이 명확히 레이블링되어 있어야 합니다. SDK는 이를 미리 제작하여 제공하며, 두 부분으로 나뉩니다: 앱에 대한 설정을 가져온 후, 해당 설정을 사용하여 제어를 표시합니다.

struct CancelSubscription: View {
    let subscriptionId: String
    @State private var settings: WithdrawalConfig?

    var body: some View {
        Group {
            if settings?.enabled == false {
                EmptyView()                        // switched off for this app in the console
            } else {
                WithdrawalView(
                    subscriptionId: subscriptionId,
                    strings: settings?.strings,    // ← without this the notice is ENGLISH for everyone
                    appearance: .auto              // .light, .dark or .auto — and nothing else
                )
            }
        }
        .task {
            do {
                settings = try await SubSovereign.shared.getWithdrawalConfig()
            } catch {
                // Log it: a silent failure here is how you ship English to German customers
                // and never find out.
                print("Withdrawal settings unavailable: \(error.localizedDescription)")
            }
        }
    }
}

텍스트를 변경할 수 없습니다. 텍스트는 고객의 언어로 여러분의 SubSovereign 서버에서 제공되며, 자체 텍스트를 전달할 방법은 없습니다. label 매개변수는 과거에 자유 텍스트를 허용했지만, 이제는 무시되며 통합자에게 경고가 로그됩니다. 마케팅으로 부드럽게 표현할 수 있는 법적 고지는 마케팅으로 변질될 수 있으므로, 동일한 이유로 색상을 전달할 수도 없습니다.

🚩 strings를 전달하지 않으면, 법적 고지가 모든 고객에게 영어로 표시됩니다. 제어는 전달된 텍스트를 렌더링하며, 자체적으로 텍스트를 가져오지 않습니다. 동일한 계약이 모든 플랫폼(UI 구성 요소가 네트워크를 수행할 수 없는 플랫폼 포함)에서 유지되어야 합니다. 어떤 언어를 받을지는 2단계에서 구성한 locale에 의해 결정됩니다. 설정 호출이 해당 언어를 전송하기 때문입니다. 가져오기가 실패하더라도 제어는 영어로 표시됩니다. 철회 기능을 소비자에게 표시하지 않는 것은 위반이지만, 잘못된 언어로 표시하는 것은 위반이 아닙니다. 오류를 로그하세요. 고객에게는 절대 표시하지 마세요.

모양 — .light, .dark 또는 .auto(기본값)만 가능합니다. 제어는 자체 카드와 자체 텍스트를 렌더링하므로, 모든 화면에서 가독성이 보장됩니다. .auto는 아이폰, 아이패드 또는 맥에서는 기기의 외관을 따르며, 애플 TV에서는 항상 어두운 테마입니다. 의도적으로 색상을 전달할 수 있는 방법은 없습니다.

오늘날 애플의 두 가지 제한 사항. 콘솔에서 선택한 레이블은 아직 여기에 적용되지 않으며, 확인서의 날짜는 고객의 로캘이 아닌 기기의 로캘에 맞게 포맷됩니다.

제어는 레이블이 지정된 버튼을 표시하고 한 번 확인합니다(법이 요구하는 대로 사전 철회 오퍼 또는 설문 조사 없이), 멱등성 있게 제출합니다(실패 후 다시 시도를 누르면 동일한 ID가 재사용되어 재시도가 두 번째 철회를 생성하지 않음). 그리고 확인서와 날짜를 표시합니다. enabled에 대한 제어는 위에서와 같이 여러분이 직접 수행합니다. 제어 자체는 제출 외의 네트워크는 수행하지 않습니다.

6단계 — 개인정보 보호 및 GDPR

동의를 수집한 곳에서 기록하고, 애플의 데이터 권리 기대를 준수하세요. SDK는 동의, CCPA "판매하지 않음" 스위치, 삭제, 내보내기를 노출합니다:

try await SubSovereign.shared.recordConsent(purpose: "analytics", granted: true)

// CCPA "do not sell" / Global Privacy Control.
// `true` turns the customer's OPT-OUT on; pass `false` when they opt back in.
try await SubSovereign.shared.setDoNotSell(enabled: true)

// If the user asks to be forgotten / to get their data:
try await SubSovereign.shared.requestErasure()
let myData = try await SubSovereign.shared.exportMyData()

purposeanalytics, marketing, personalisation 또는 consumption_data_sharing 중 하나여야 합니다(모두 소문자). 다른 값은 .invalidArgument를 throw하며 아무것도 기록되지 않습니다. 동의 기록은 법적 기록이므로, 고객에게 요청한 적 없는 목적으로 동의 기록을 작성하지 않습니다. jurisdiction은 기본값으로 "GDPR"을, policyVersion"1.0"을 사용합니다. 다를 경우 직접 전달하세요.

오류 처리

모든 throw 호출은 타입화된 SubSovereignError로 실패합니다 — .notConfigured, .networkError(Error), .serverError(code, message) 또는 .invalidArgument(message). 크래시로 표면화되지 않도록 catch하여 처리하세요:

  • 성공 — 값을 사용하세요.
  • 실패 — 로그를 기록하고, 마지막 알려진 접근 권한을 유지한 채 나중에 다시 시도하세요. 순간적인 네트워크 장애로 결제한 사용자를 차단하지 마세요.

모범 사례

  • 앱 시작 시 확인. 앱 시작 시(예: SwiftUI .task에서) checkEntitlements()를 호출하여 잠금 기능이 사용자가 잠금 해제된 기능을 조우하기 전에 올바르게 설정되도록 합니다.
  • 구매 후 재확인. 성공적인 validateApplePurchase 직후 checkEntitlements()를 다시 호출하여 UI가 즉시 업데이트되도록 합니다.
  • 클라이언트를 절대 신뢰하지 마세요. 앱에 "프로 버전"을 저장하고 이를 진실로 간주하지 마세요. 서버에 문의하세요. 서버는 애플과 트랜잭션을 검증했습니다.
  • 실제 사용자당 하나의 userId. 장치 간에 접근 권한이 동기화되도록 안정적으로 유지하고, 로그인한 사용자가 변경되면 재구성하세요.

빠른 참조

여러분이 … 호출
SDK 설정 SubSovereign.shared.configure(config)
사용자가 해제한 권한 확인 try await checkEntitlements()EntitlementResult
원격 페이월 표시 try await getPaywallConfig()PaywallConfig
StoreKit 2 구매 검증 try await validateApplePurchase(transaction:accessLevelId:)
트랜잭션 완료 await finishTransaction(transaction)
기능 제어(fail-closed) await hasAccess(accessLevelName:)Bool
철회 설정 가져오기 try await getWithdrawalConfig()WithdrawalConfig
법적 철회 제어 그리기 WithdrawalView(subscriptionId:strings:appearance:)
GDPR 동의 기록 try await recordConsent(purpose:granted:)
CCPA "판매하지 않음" 요청 준수 try await setDoNotSell(enabled:)
사용자 데이터 삭제 / 내보내기 try await requestErasure() / exportMyData()

다음 단계