[iOS] 인앱결제 구독, 손쉽게 StoreKit2로 구현하기

David·2024년 1월 23일
0
post-thumbnail

인앱결제를 구현하기 전 준비물

  1. 사업자 등록
  2. 통신판매업 신고 (유료 앱 등록 시 필요)

이 두가지는 인터넷에 찾아보면 잘 설명된 블로그가 많아 생략하겠습니다!

인앱결제 상품 종류

1.소모성 항목 (Consumable)
소모성 앱 내 구입은 한 번 사용하면 소모되며 다시 구입할 수 있습니다.
ex- 게임 내 포인트, 재화 등

2.비소모성 항목 (Non-consumable)
비소모성은 한 번 구입하면 만료되지 않습니다.
ex- 사진 앱의 필터 등

3.자동 갱신 구독 (Auto-renewable subscriptions)
이번 포스팅에서 구현할 기능이며, 앱의 콘텐츠, 서비스 또는 프리미엄 기능에 대해 구독 기능을 제공하여 원하는 기간마다 자동 결제되는 항목입니다.
ex- SaaS 등

4.비갱신형 구독 (Non-renewing subscriptions)
한정된 기간 동안 서비스 또는 콘텐츠에 대한 이용 권한을 제공합니다. 이 항목은 자동 갱신을 제공하지 않아 매번 구매를 다시 해야 합니다.
ex- 게임 내 콘텐츠의 정기권 등

앱스토어 설정


좌측에 구독 항목을 클릭합니다.

구독 그룹을 생성합니다.

원하는 구독 상품을 생성합니다. 저는 연간과 월간 두 가지를 생성했어요.


구독 기간과 구독 가격, 현지화, 스크린샷을 추가해줍니다.

StoreKit file Config

StoreKit 파일을 이용하면 하단과 같은 이점이 있습니다.

시뮬레이터에서 구매 플로우 테스트 가능
구매 플로우 unit & UI 테스트 가능
로컬에서 네트워크 손실 테스트 가능
실패, 갱신, 청구 문제 등 트랜잭션 테스트 가능


StoreKit 검색 후 하단의 체크박스를 선택합니다.

StoreKit Configuration 에서 생성한 파일을 선택해줍니다.

StoreKit2 구현

그럼 이제 StoreKit2를 이용한 인앱결제를 구현해보겠습니다.

import UIKit
import StoreKit

class PurchaseManager {
    
    static let shared = PurchaseManager()
    
    private let productIds = ["com.productName.premium.monthly", "com.productName.premium.yearly"]
    private(set) var products: [Product] = []
    private var productsLoaded = false
    
    private(set) var purchasedProductIDs = Set<String>()
    var hasUnlockedPro: Bool {
        return !self.purchasedProductIDs.isEmpty
    }
    
    private var updatesTask: Task<Void, Never>? = nil
    
    
    private init() {} // 싱글톤으로 구현
    
    func loadProducts() async throws {
        guard !self.productsLoaded else { return }
        self.products = try await Product.products(for: productIds)
        self.productsLoaded = true
    }
    
    func purchase(_ product: Product) async throws {
        let result = try await product.purchase()
        
        switch result {
        case let .success(.verified(transaction)):
            await transaction.finish()
            await self.updatePurchasedProducts()
        case let .success(.unverified(_, error)):
        	// 구매를 성공했으나, verified 실패
            break
        case .pending:
            // Transaction waiting on SCA (Strong Customer Authentication) or
            // approval from Ask to Buy
            break
        case .userCancelled:
            LoadingIndicator.hideLoading()
            break
        @unknown default:
            LoadingIndicator.hideLoading()
            break
        }
    }
    
    func updatePurchasedProducts() async {
        for await result in Transaction.currentEntitlements {
            guard case .verified(let transaction) = result else {
                continue
            }
            // 거래의 취소날짜가 없는 경우
            if transaction.revocationDate == nil {
                self.purchasedProductIDs.insert(transaction.productID)
                self.currentSubscriptionType = transaction.productID
            } else {
                // 거래가 취소된 경우
                self.purchasedProductIDs.remove(transaction.productID)
            }
        }
    }
    
    func startObservingTransactionUpdates() {
        updatesTask = Task(priority: .background) { [weak self] in
            for await _ in Transaction.updates {
                await self?.updatePurchasedProducts()
            }
        }
    }
    
    func stopObservingTransactionUpdates() {
        updatesTask?.cancel()
        updatesTask = nil
    }
    
}
private func subscribeButtonTapped() {
        var selectedProductId = ""
        
        switch self.selectedButton {
        case "yearly":
            selectedProductId = "com.productName.premium.yearly"
        case "monthly":
            selectedProductId = "com.productName.premium.monthly"
        default:
            // 버튼 선택 alert
        }
        // 선택된 제품 ID에 해당하는 Product 객체 찾기
        if let selectedProduct = products.first(where: { $0.id == selectedProductId }) {
            Task {
                do {
                    try await self.purchaseManager.purchase(selectedProduct)
                    self.updateSubscribeStatus()
                } catch {
                    // 에러 처리
                    print("Purchase Error: \(error)")()                
                    }
            }
        } else {
            // 해당하는 제품을 찾을 수 없는 경우
            print("에러")
        }
    }

PurchaseManager라는 클래스를 싱글톤으로 구현하여 관리하였습니다.
productIds 배열에 앱스토어에서 정의한 제품 ID와 동일한 string을 넣어줍니다.

loadProducts

product id를 활용하여 앱스토어에 있는 상품의 정보(이름, 가격)를 가져올 수 있습니다.

purchase

Success – verified

-> 구매 성공

Success – unverified

-> 구매는 성공했지만, StoreKit의 유효성 검증 실패.

Pending

-> 다른 앱을 보니, 부모에게 구매 요청을 하는 기능이 있던데 그렇게 했을 경우 구매가 승인되거나 거부될 때까지 pending 상태로 유지됩니다. 승인이 완료되면 거래가 업데이트됩니다.

User Canceled

-> 사용자 취소

updatePurchasedProducts

앱 시작 시, 구매가 이루어진 후, 트랜잭션이 업데이트될 때 이 함수를 호출하여 구매 상태에 대한 동기화가 필요합니다.
저는 AppDelegate - didFinishLaunchingWithOptions에서 startObservingTransactionUpdates 함수를 실행하여 트랜잭션 업데이트 상태를 관찰하게 하였습니다.
그리고, applicationWillTerminate에서 stopObservingTransactionUpdates 함수를 실행하여 앱이 종료될 때, 트랜잭션 관찰을 멈추게 하였습니다.

Transaction.currentEntitlements에 대해 말씀을 드리면, StoreKit2에서는 currentEntitlements가 로컬로 캐시된 데이터를 반환한다고 합니다. 그래서 사용자가 네트워크가 끊기는 등 오프라인 상태여도 데이터를 가지고 있다가 온라인 상태일 때 푸시하여 앱이 최신 트랜잭션을 가지고 있을 수 있게 한다고 하네요.
또한 갱신, 취소 또는 청구 등의 상태를 반영하는 트랜잭션이 currentEntitlements에 업데이트가 되어서 구매 상태에 대해서도 따로 관리하지 않아도 괜찮은 것 같습니다.
그래서 저희는, 이러한 것들을 신경쓰지 않아도 된다는 게 큰 장점인 것으로 보여집니다.

심사 주의사항

심사 시 주의하여 작성하거나 확인해야 하는 것들이 있습니다.

1. 앱 설명

앱 설명 내 구독 관련 description이 필요합니다.

  • 구매 확인 시 결제는 애플 ID 계정에 청구됩니다. 구독은 현재 기간이 종료되기 적어도 24시간 전에 취소하지 않으면 자동으로 갱신됩니다. 구독은 현재 기간이 종료되기 24시간 이내에 갱신으로 청구됩니다. 구독 후 앱스토어 계정 설정에서 구독을 관리하고 취소할 수 있습니다.
    더 자세한 내용은 다음 링크에서 확인해주세요.
    이용약관: 링크 첨부

2. UI

이미지는 예시로 가져온 슬립루틴의 구독 페이지입니다.
보는 것처럼 이용약관, 개인정보 취급방침에 대한 링크가 필요하고 구매복원도 필요합니다.


또한, 무료 체험이 있다면 언제 무료 체험이 끝나는지, 끝난 후로는 얼마의 금액으로 구독이 되는지 명시가 필요합니다.

레퍼런스

https://www.revenuecat.com/blog/engineering/ios-in-app-subscription-tutorial-with-storekit-2-and-swift/
WWDC20 – Introducing StoreKit Testing in Xcode
WWDC22 – What’s new in StoreKit testing
WWDC21 - Meet StoreKit2
리젝 없이 iOS 구독앱, 한방에 출시하기
WWDC22 - Implement proactive in-app purchase restore

0개의 댓글