Swift UIKit Non-Consumable 인앱 결제 구현하기

4가지 인앱 결제 방식

  • 인앱 결제란 이름 그대로 앱 내에서 결제하는 것을 말한다.
  • 이 글에서는 이 중에서 Non-Consumable 인앱 결제 구현 방법을 알아보겠다.
  • 애플 앱스토어의 인앱 결제에는 4가지 종류가 있다.
    • Consumable : 한 번 이상 구매가 가능하고 소비될 수 있다.
    • Non-Consumable : 한 번만 구매가 가능하고 영구적으로 소유된다.
    • Non-Renewing Subscription : 일정 기간 동안만 사용될 수 있다.
    • Auto-Renewing Subscription : 반복되는 구독 방식의 인앱 결제.

인앱 결제 기본 작업

  • 다음과 같이 인앱 결제를 위한 기본 작업을 해준다.
  • 애플 개발자 사이트에서 Apple ID를 만든다.
  • 개발자 Agreements를 확인하고 동의한다.
swift-in-app-purchaseswift-in-app-purchase
  • Feature에서 인앱 결제를 생성한다.
  • Users and Access → Sandbox → Testers에서 유저를 생성한다.

Non-Consumable 구현

  • In-App Purchase를 도와주는 아래 소스코드를 프로젝트에 추가한다.
// IAPHelper.swift

import StoreKit

public typealias ProductIdentifier = String
public typealias ProductsRequestCompletionHandler = (_ success: Bool, _ products: [SKProduct]?) -> Void

extension Notification.Name {
    static let IAPHelperPurchaseNotification = Notification.Name("IAPHelperPurchaseNotification")
}

open class IAPHelper: NSObject  {    
    private let productIdentifiers: Set<ProductIdentifier>
    private var purchasedProductIdentifiers: Set<ProductIdentifier> = []
    private var productsRequest: SKProductsRequest?
    private var productsRequestCompletionHandler: ProductsRequestCompletionHandler?
    
    public init(productIds: Set<ProductIdentifier>) {
        productIdentifiers = productIds

        for productIdentifier in productIds {
            let purchased = UserDefaults.standard.bool(forKey: productIdentifier)

            if purchased {
                purchasedProductIdentifiers.insert(productIdentifier)
                print("Previously purchased: \(productIdentifier)")
            } else {
                print("Not purchased: \(productIdentifier)")
            }
        }

        super.init()        
        SKPaymentQueue.default().add(self)
    }
}

extension IAPHelper {    
    public func requestProducts(_ completionHandler: @escaping ProductsRequestCompletionHandler) {
        productsRequest?.cancel()
        productsRequestCompletionHandler = completionHandler        
        productsRequest = SKProductsRequest(productIdentifiers: productIdentifiers)
        productsRequest!.delegate = self
        productsRequest!.start()
    }
    
    public func buyProduct(_ product: SKProduct) {
        print("Buying \(product.productIdentifier)...")        
        let payment = SKPayment(product: product)
        SKPaymentQueue.default().add(payment)
    }
    
    public func isProductPurchased(_ productIdentifier: ProductIdentifier) -> Bool {
        return purchasedProductIdentifiers.contains(productIdentifier)
    }
    
    public class func canMakePayments() -> Bool {
        return SKPaymentQueue.canMakePayments()
    }
    
    public func restorePurchases() {
        SKPaymentQueue.default().restoreCompletedTransactions()
    }
}

extension IAPHelper: SKProductsRequestDelegate {    
    public func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
        print("Loaded list of products...")        
        let products = response.products
        productsRequestCompletionHandler?(true, products)
        clearRequestAndHandler()
        
        for p in products {
            print("Found product: \(p.productIdentifier) \(p.localizedTitle) \(p.price.floatValue)")
        }
    }
    
    public func request(_ request: SKRequest, didFailWithError error: Error) {
        print("Failed to load list of products.")
        print("Error: \(error.localizedDescription)")        
        productsRequestCompletionHandler?(false, nil)
        clearRequestAndHandler()
    }
    
    private func clearRequestAndHandler() {
        productsRequest = nil
        productsRequestCompletionHandler = nil
    }
}

extension IAPHelper: SKPaymentTransactionObserver {    
    public func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
        for transaction in transactions {
            switch (transaction.transactionState) {
            case .purchased:
                complete(transaction: transaction)
                break
            case .failed:
                fail(transaction: transaction)
                break
            case .restored:
                restore(transaction: transaction)
                break
            case .deferred:
                break
            case .purchasing:
                break
            @unknown default:
                fatalError()
            }
        }
    }
    
    private func complete(transaction: SKPaymentTransaction) {
        print("complete...")        
        deliverPurchaseNotificationFor(identifier: transaction.payment.productIdentifier)
        SKPaymentQueue.default().finishTransaction(transaction)
    }
    
    private func restore(transaction: SKPaymentTransaction) {
        guard let productIdentifier = transaction.original?.payment.productIdentifier else { return }

        print("restore... \(productIdentifier)")        
        deliverPurchaseNotificationFor(identifier: productIdentifier)
        SKPaymentQueue.default().finishTransaction(transaction)
    }
    
    private func fail(transaction: SKPaymentTransaction) {
        print("fail...")
        
        if let transactionError = transaction.error as NSError?,
            let localizedDescription = transaction.error?.localizedDescription,
            transactionError.code != SKError.paymentCancelled.rawValue {
            print("Transaction Error: \(localizedDescription)")
        }
        
        SKPaymentQueue.default().finishTransaction(transaction)
    }
    
    private func deliverPurchaseNotificationFor(identifier: String?) {
        guard let identifier = identifier else { return }

        purchasedProductIdentifiers.insert(identifier)
        UserDefaults.standard.set(true, forKey: identifier)
        NotificationCenter.default.post(name: .IAPHelperPurchaseNotification, object: identifier)
    }
}
  • 이 IAPHelper 스위프트 파일이 사실 인앱 결제의 대부분을 차지한다.
  • 이제 구입을 원하는 Controller에 다음과 같은 구조체를 만든다.
public struct InAppProducts {
    public static let product = "(인앱 Product ID)"
    private static let productIdentifiers: Set<ProductIdentifier> = [InAppProducts.product]
    public static let store = IAPHelper(productIds: InAppProducts.productIdentifiers)
}
  • 그리고 원하는 구입 버튼 action에 다음과 같이 코드를 작성한다.
InAppProducts.store.buyProduct(product)
  • 이제 결제가 되는지 확인해볼 차례이다.
  • 참고로 인앱 결제는 시뮬레이터에서는 작동하지 않는다.
  • 아이폰 Settings → iTunes & App Store에서 로그아웃한다.
  • 결제 버튼을 눌러보면, 로그인 창이 뜬다.
  • 생성했던 Sandbox 유저 로그인 정보를 입력한다.
  • Sandbox 유저로 테스트 결제가 완료된다.
  • 복원 기능을 사용하기 위해서는 다음과 같이하면 된다.
InAppProducts.store.restorePurchases()