StoreKit 2 기반 인앱결제 / 구독 정리하기

2025. 11. 14. 17:44iOS

StoreKit 2를 이용해서 구독을 구현하기 위해 알아야하는 것들을 정리해보려고 합니다. 

 

StoreKit을 사용하면 앱에서 제공하는 구독 서비스와 상품들을 간편하고 안전하게 구입할 수 있다고 해요. 

원래 사용자가 결제를 하기 위해서 필요한 이것저것 인증들을 애플이 대신 해주기 떄문에 훨씬 쉽게 구현할 수 있어요. 


https://developer.apple.com/kr/storekit/

 

StoreKit - Apple Developer

StoreKit을 사용하면 모든 Apple 플랫폼에서 사용자가 앱의 디지털 상품 또는 서비스를 구입할 수 있는 간편하고 안전한 방법을 제공할 수 있습니다.

developer.apple.com

사전 지식

인앱 결제로 구입할 수 있는 상품은 크게 2가지 타입으로 나눌 수 있어요.

앱 내 구입 

  • 생성: AppStoreConnect의 수익화 섹션의 인앱결제 메뉴에서 생성할 수 있어요. 
  • 종류:
    nonConsumable: 영구 소유하는 상품 (ex. 광고 제거, 영구 프리미엄 업그레이드)
    consumable: 구매 후 소비하면 다시 구매 가능한 상품 (ex. 게임 내 코인, 포인트)
  • 환불 
    유저가 AppStore 또는 설정에서 환불 요청하고 Apple이 내부 기준에 따라 결제 금액을 환불해줘요. 
    만약 consuable상품으로 이미 사용한 상품인데 환불 요청을 했다면, 그에 따라 앱 설계가 필요해요.

구독 

  • 생성: AppStoreConnect의 수익화 섹션의 구독 메뉴에서 생성할 수 있어요. 
  • 종류: autoRenewable(자동갱신) / nonRenewable(갱신 취소된 구독)
  • 취소: 설정/앱스토어에서 유저가 직접 구독 해지

도메인 모델 

Transaction

단일 스토어 구매 영수증 트랜잭션 ID에 대한 스냅샷입니다. 상품이 구독이라면 갱신될 때마다 발생해서 id는 새로 생성되지만, originalID는 처음 발생했던 트랜잭션의 ID를 가지고 있어서, 이미 처리했었던 영수증이라는 연속성을 알 수 있습니다. 

 

아래는 주요 필드입니다. 

- id(PK): 트랜잭션 ID
- originalID: 첫 구매가 일어난 트랜잭션 ID
- productID: 구매한 상품 ID 
- originalPurchaseDate: 첫 구매 날짜 
- purchaseDate: 구매 날짜 (트랜잭션 발생 날짜) 
- expirationDate: 만료 날짜
- revocationDate: 환불 날짜
- isUpgraded: 업그레이드 여부 (true라면 다른 트랜잭션이 활성화됩니다.)

VerficationResult 

StoreKit에서 내려주는 트랜잭션 처리 결과입니다. 

public enum VerificationResult<SignedType> {
    case unverified(SignedType, VerificationResult<SignedType>.VerificationError)
    case verified(SignedType)
}

 

내부적으로 Apple 서명 검증을 이미 수행하기 때문에 개발자는 .verfied 여부만 확인해도 기본적인 보안 검증은 끝나요.

만약, 더 높은 수준이 필요하면, VerficationResult안에 들어있는 jwsRepresentation을 이용해 직접 서명 검증(서버 측 검증)을 수행할 수 있다고 해요. (jwsRepresentation을 서버에 보내 Apple 서명 데이터를 직접 검증할 수 있어요)

Transaction.currentEntitlements

유저가 구매한 각 상품에 대한 최신 트랜잭션의 스트림입니다. consumable 상품과 환불된 상품에 대해서는 포함되지 않아요. 만약 consumable 상품에 대한 트랜잭션을 불러오려면 Transaction.all 또는 Transaction.unfinished를 활용해야 합니다. 

 

주로유저가 구매한 상품들을 복구할 때 사용해요.

func refreshPurchasedProducts() async {
    for await verificationResult in Transaction.currentEntitlements {
        switch verificationResult {
        case .verified(let transaction):
            ...
        case .unverified(let unverifiedTransaction, let verificationError):
            ...
        }
    }
}

Transaction.updates

새로운 트랜잭션이 발생할 때 방출하는 스트림입니다. 앱이 사용중인 영수증을 실시간으로 관찰하고 있습니다. 

 

설정이나 앱스토어, 다른 기기 등과 같이 앱 외부에서 구매 정보를 바꿀 때 들어올 때 활용할 수 있어요.

구매를 구현해보면, updates 스트림을 구현해달라고 보라색 경고가 뜨니 구현이 필수인 듯 해요. 

func observeTransactionUpdates() {
    transactionUpdatesTask = Task(priority: .background) { [weak self] in
        logger.info("트랜잭션 업데이트 관찰 시작")
        for await update in Transaction.updates {
            guard let self else { return }
            guard let transaction = unwrapVerificationResult(update) else { continue }

            // 영수증 처리
        }
    }
}

 

Product.SubscriptionInfo.Status.updates

구독 상태가 변할 때 방출하는 스트림입니다. 현재 구독 상태 변화를 실시간으로 관찰하고 있습니다. 

 

구독 만료/취소/결제실패 등과 같이 영수증 변화 없이도 구독 상태가 변화할 수 있기 때문에 구독 상태 변화 감지용으로 구현해주어야 해요. 

func observeStatusUpdates() {
    statusUpdateTask = Task { [weak self] in
        logger.debug("상태 업데이트 관찰 시작")
        for await status in SubscriptionStatus.updates {
            guard let self,
                  let transaction = await unwrapVerificationResult(status.transaction),
                  let groupIDRawValue = transaction.subscriptionGroupID,
                  let subscriptionGroupID = SubscriptionGroupID(rawValue: groupIDRawValue)
            else {
                continue
            }
            // 구독 상태 변화 처리 
        }
    }
}

 

유저 플로우 

앱에서 모든 인앱결제에 대한 처리하는 플로우입니다.

(서버에서 구독 상태를 관리해야 한다면 App Store Server API를 이용해야 해서 플로우가 달라져요.)

구매하기

func purchaseProduct(_ pid: String) async {
    do {
        guard let storeProduct = try await Product.products(for: [pid]).first else { return }
        let purchaseResult = try await storeProduct.purchase()

        switch purchaseResult {
        case let .success(transaction):
            // 영수증 처리 
        case .userCancelled:
            logger.info("구매 요청이 취소되었습니다.")
        case .pending:
            logger.info("구매 요청이 대기중입니다.")
        @unknown default:
            break
        }
    } catch {
        logger.info("구매 오류")
    }
}

 

success 콜백으로 영수증 처리를 할 수 있지만, 부모의 허락을 받아야하는 경우 바로 콜백으로 오지 않고 pending으로 떨어집니다.

그래서 updates도 꼭 구현이 필요합니다. (보라색 경고가 떠요)

func observeTransactionUpdates() {
    transactionUpdatesTask = Task(priority: .background) { [weak self] in
        logger.info("트랜잭션 업데이트 관찰 시작")
        for await update in Transaction.updates {
            guard let self else { return }
            guard let transaction = unwrapVerificationResult(update) else { continue }

            // 영수증 처리
        }
    }
}

 

복원하기

func restorePurchases() async throws {
    do {
        try await AppStore.sync()
        for await entitlement in Transaction.currentEntitlements {
            guard let transaction = unwrapVerificationResult(entitlement) else { continue }
            // 복원 성공 처리
            await processedTransaction.finish()
        }
    } catch {
        // 복원 실패 처리
    }
}

 

 

영수증을 AppStore에서 기기로 불러오면, 영수증에 변화가 있다면 Transaction.updates로 들어오는데 없다면 알 수 없습니다.

그래서 Transaction.currentEntitlements에서 가져와야 합니다. 

 

구독 갱신 / 환불 / 취소

private func processSubscriptionTransaction(_ transaction: Transaction) async {
    let productType = transaction.productType

    guard productType == .autoRenewable || transaction.productType == .nonRenewable
    else {
        return nil
    }

    if let _ = transaction.revocationDate {
        // 상태 - 환불
    } else if let expirationDate = transaction.expirationDate, expirationDate < Date() {
        // 상태 - 만료
    } else if transaction.isUpgraded {
        // 상태 - Upgraded
        // 다른 활성화된 트랜잭션이 존재합니다.
    } else {
        // 상태 - 구독 / 갱신 / 변경
    }

    await transaction.finish()
}

 

구독 갱신인 경우, 영수증이 변하기 때문에 Transaction.updates 스트림으로 새로운 Transaction이 들어옵니다.

구독 환불 또는 취소인 경우, 실시간으로 반영이 필요하지 않다면 앱을 실행할 때마다 영수증을 검사해서 위와 같은 로직으로 처리해줍니다. 실시간으로 반영이 필요하다면 Transaction.all / Transaciton.unfinished 스트림을 구독해서 별도 처리가 필요합니다.

 

상품 환불 

private func processNonConsumableTransaction(_ transaction: Transaction) async {
    guard transaction.productType == .nonConsumable else {
        return
    }

    if transaction.revocationDate == nil, transaction.revocationReason == nil {
        ownedNonConsumables.insert(transaction.productID)
    } else {
        ownedNonConsumables.remove(transaction.productID)
    }

    await transaction.finish()
}

 

 

인 앱 결제 플로우가 StoreKit 1 보다 훨씬 간단해졌어요. StoreKit 내부에서 영수증 검증도 모두 처리되고 앱 외부에서 처리한 구독/환불/취소에 대한 변경사항들을 더 쉽게 처리가 가능해졌어요. 또 콜백으로 async/await을 활용할 수 있어 코드도 더 swift스럽게 작성할 수 있을 것 같네요!

 

참고

https://developer.apple.com/documentation/storekit/transaction/currententitlements

 

currentEntitlements | Apple Developer Documentation

A sequence of the latest transactions that entitle a customer to In-App Purchases and subscriptions.

developer.apple.com

https://developer.apple.com/documentation/storekit/verificationresult

 

VerificationResult | Apple Developer Documentation

A type that describes the result of a StoreKit verification.

developer.apple.com

https://developer.apple.com/documentation/storekit/transaction/updates

 

updates | Apple Developer Documentation

The asynchronous sequence that emits a transaction when the system creates or updates transactions that occur outside the app or on other devices.

developer.apple.com

 

반응형