관리 메뉴

김종권의 iOS 앱 개발 알아가기

[iOS - swift] 2. StoreKit - IAP (In App Purchases, 인앱 결제) 적용 방법 (코딩 및 구현) 본문

iOS 응용 (swift)

[iOS - swift] 2. StoreKit - IAP (In App Purchases, 인앱 결제) 적용 방법 (코딩 및 구현)

jake-kim 2022. 6. 6. 23:27

1. StoreKit - IAP (In App Purchases, 인앱 결제) 사용 방법 (Sandbox, 인앱 결제 앱 등록)

2. StoreKit - IAP (In App Purchases, 인앱 결제) 적용 방법 (코딩 및 구현)

3. StoreKit - SwiftyStoreKit을 이용하여 IAP (In App Purchase) 쉽게 구현 방법

 

* 이전 포스팅 글, 인앱 결제 앱 등록 에서 알아보았던 이미 준비되어야 하는 것

  • Apple Developer에서 App ID 등록
  • App Store Connect > 앱 내 구입 > 상품 등록 완료

JK IAP Test 상품 등록

  • App Store Connect > 계정 및 액세스 > sandbox 계정 생성

  • App Store Connect > 계약, 세금 및 금융거래 > 유로 앱 > 약관 보기 및 동의하기 진행

코딩 - StoreKit을 이용한 결제 구현

  • StoreKit에서 제공하는 타입과 싱글톤 사용
    • SKProduct - 상품정보 (가격, 이름 등)
    • SKPayment - SKProduct 인수를 받아서 생성되는 지불 정보 (product의 id, request 데이터 등)
    • SKProductsRequest - 결제를 요청할때 사용하는 타입 (delegate로 성공했는지 실패했는지 알려줌)
    • SKPaymentQueue.default() - 데이터 가져오기에 사용
  • IAPService.swift 파일 생성
    • 내부 델리게이트를 통해 IAP에 성공했는지 실패했는지 알 수 있고, 외부에도 completion을 제공하는 식으로 구현하기 위해서, 따로 Completion을 정의
    • 이 completion은 외부에서 정의하고, 실행은 내부에서 불리도록 구현 (delegate 패턴)
// IAPService.swift

import StoreKit

typealias ProductsRequestCompletion = (_ success: Bool, _ products: [SKProduct]?) -> Void
  • 외부에서 필요한 메소드를 명시하기 위해서 protocol 정의
    • products 항목 가져오기 (getProducts)
    • product 구입하기 (buyProduct)
    • 구입했는지 확인하기 (isProductPurchased)
    • 구입한 목록 조회 (restorePurchased)
protocol IAPServiceType {
  var canMakePayments: Bool { get }
  
  func getProducts(completion: @escaping ProductsRequestCompletion)
  func buyProduct(_ product: SKProduct)
  func isProductPurchased(_ productID: String) -> Bool
  func restorePurchases()
}
  • StoreKit을 사용하려면 NSObject를 상속받고 IAPServiceType을 준수
  • 추가로 필요한 프로퍼티 선언
    • productIDs: 앱스토어에서 입력한 productID들 "com.jake.sample.ExInAppPurchase.shopping"
    • purchasedProductIDs: 구매한 productID
    • productsRequest: 앱스토어에 입력한 productID로 부가 정보 조회할때 사용하는 인스턴스
    • proeductsCompletion: 사용하는쪽에서 해당 클로저를 통해 실패 or 성공했을때 값을 넘겨줄 수 있는 프로퍼티 (델리게이트)
final class IAPService: NSObject, IAPServiceType {
  private let productIDs: Set<String>
  private var purchasedProductIDs: Set<String> = []
  private var productsRequest: SKProductsRequest?
  private var productsCompletion: ProductsRequestCompletion?
}
  • canMakePayments - queue를 확인하여 현재 결제가 되는지 확인
var canMakePayments: Bool {
  SKPaymentQueue.canMakePayments()
}
  • 상품 정보를 받아서 초기화
    • 앱스토어에서 입력한 productID들 "com.jake.sample.ExInAppPurchase.shopping"
init(productIDs: Set<String>) {
  self.productIDs = productIDs
  
  super.init()
}
  • IAPService에 SKPaymentQueue를 연결
init(productIDs: Set<String>) {
  self.productIDs = productIDs

  super.init()
  SKPaymentQueue.default().add(self) // <- 여기 추가
}
  • 상품 정보 조회
    • completion을 여기서 캡쳐해놓고 밑에 delegate에서 호출하는 형식
func getProducts(completion: @escaping ProductsRequestCompletion) {
  self.productsRequest?.cancel()
  self.productsCompletion = completion
  self.productsRequest = SKProductsRequest(productIdentifiers: self.productIDs)
  self.productsRequest?.delegate = self
  self.productsRequest?.start()
}

extension IAPService: SKProductsRequestDelegate {
  // didReceive
  func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
    let products = response.products
    self.productsCompletion?(true, products)
    self.clearRequestAndHandler()
    
    products.forEach { print("Found product: \($0.productIdentifier) \($0.localizedTitle) \($0.price.floatValue)") }
  }
  
  // failed
  func request(_ request: SKRequest, didFailWithError error: Error) {
    print("Erorr: \(error.localizedDescription)")
    self.productsCompletion?(false, nil)
    self.clearRequestAndHandler()
  }
  
  private func clearRequestAndHandler() {
    self.productsRequest = nil
    self.productsCompletion = nil
  }
}
  • 나머지 메소드 정의
func buyProduct(_ product: SKProduct) {
  SKPaymentQueue.default().add(SKPayment(product: product))
}
func isProductPurchased(_ productID: String) -> Bool {
  self.purchasedProductIDs.contains(productID)
}
func restorePurchases() {
  SKPaymentQueue.default().restoreCompletedTransactions()
}

IAPService를 wrapping하는 타입 정의

  • 앱스터에서 입력한 ProductID를 가지고 있는 Warpping 타입
enum MyProducts {
  static let productID = "com.jake.sample.ExInAppPurchase.shopping"
  static let iapService: IAPServiceType = IAPService(productIDs: Set<String>([productID]))
  
  static func getResourceProductName(_ id: String) -> String? {
    id.components(separatedBy: ".").last
  }
}

사용하는쪽

  • 기본적으로 필요한 property 선언
import UIKit
import StoreKit

class ViewController: UIViewController {
  private let restoreButton: UIButton = { ... }
  private let tableView: UITableView = { ... }
    
  private var products = [SKProduct]()

}
  • 상품 정보 가져오기
// in viewDidLoad

MyProducts.iapService.getProducts { [weak self] success, products in
  print("load products \(products ?? [])")
  guard let ss = self else { return }
  if success, let products = products {
    DispatchQueue.main.async {
      ss.products = products
      ss.tableView.reloadData()
    }
  }
}
  • 구입했던 목록 조회 
@objc private func restore() {
  MyProducts.iapService.restorePurchases()
}
  • 셀에 'buy' 버튼을 탭한 경우, 구매 요청
cell.buyButtonTap = { [weak product] in
  guard let product = product else { return }
  MyProducts.iapService.buyProduct(product)
}

(todo - 테이블 뷰에 product 보이는지 확인)

product가 안보이는 경우 체크리스트

  • 프로젝트의 BundleID와 Apple Developer에 등록된 BundleID가 일치하는지?
  • 앱스토어 입력한것과 코드에서 사용한 productID가 일치하는지 "com.jake.sample.ExInAppPurchase.shopping"
  • App Store Connect > 계약, 세금 및 금융거래 탭 > 유로 앱 > 활성 상태가 아니고 사용자 정보 대기 중이면 아직 사용 불가

사용 불가 - 사용자 정보 대기 중

구매 목록 조회

  • 구매 여부를 UserDefaults에 저장해두고 IAPService에서 ProductIDs를 받아올 때 초기화하여 사용
// in IAPService
private var purchasedProductIDs: Set<String>  
  
init(productIDs: Set<String>) {
  self.productIDs = productIDs
  self.purchasedProductIDs = productIDs
    .filter { UserDefaults.standard.bool(forKey: $0) == true }
    
  super.init()
  SKPaymentQueue.default().add(self)
}

SKPaymentQueue 에서 처리되는 상태 체크 방법

  • SKPaymentQueue에서 처리되는 일
    • purchased
    • failed
    • restored
    • deferred
    • purchasing
  • SKPaymentTransactionObserver 델리게이트를 준수하여 처리
extension IAPService: SKPaymentTransactionObserver {
  func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
    
  }
}
  • 각 상태 처리
    • 거래가 성공, 실패, restore된 경우 finishTransaction을 실행
    • restore된 경우(구매 완료된 것 다시 조회) 구매했던 목록으로 추가 (UserDefaults)
extension IAPService: SKPaymentTransactionObserver {
  func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
    transactions.forEach {
      switch $0.transactionState {
      case .purchased:
        print("completed transaction")
        self.deliverPurchaseNotificationFor(id: $0.original?.payment.productIdentifier)
        SKPaymentQueue.default().finishTransaction($0)
      case .failed:
        if let transactionError = $0.error as NSError?,
           let description = $0.error?.localizedDescription,
           transactionError.code != SKError.paymentCancelled.rawValue {
          print("Transaction erorr: \(description)")
        }
        SKPaymentQueue.default().finishTransaction($0)
      case .restored:
        print("failed transaction")
        self.deliverPurchaseNotificationFor(id: $0.original?.payment.productIdentifier)
        SKPaymentQueue.default().finishTransaction($0)
      case .deferred:
        print("deferred")
      case .purchasing:
        print("purchasing")
      default:
        print("unknown")
      }
    }
  }
  
  private func deliverPurchaseNotificationFor(id: String?) {
    guard let id = id else { return }
    
    self.purchasedProductIDs.insert(id)
    UserDefaults.standard.set(true, forKey: id)
    // TODO: noti
  }
}

위 메소드 deliverPuechaseNotificationFor에서 노티 관리

  • 노티 관리를 위해 메시지 정의를 위해 파일 추가 "IAPService+Notification.swift"
//  IAPService+Notification.swift

import Foundation

extension Notification.Name {
  static let iapServicePurchaseNotification = Notification.Name("IAPServicePurchaseNotification")
}
  • PaymentQueue의 델리게이트 메소드에서 불리는 deliverPurchaseNotificationFor(id:) 메소드에 노티를 보내는 코드 추가 (post)
private func deliverPurchaseNotificationFor(id: String?) {
  guard let id = id else { return }
  
  self.purchasedProductIDs.insert(id)
  UserDefaults.standard.set(true, forKey: id)
  NotificationCenter.default.post( // <- 추가
    name: .iapServicePurchaseNotification,
    object: id
  )
}
  • 사용하는 쪽 ViewController에서 노티 구독
// in viewDidLoad

NotificationCenter.default.addObserver(
  self,
  selector: #selector(handlePurchaseNoti(_:)),
  name: .iapServicePurchaseNotification,
  object: nil
)
  • handlePurchaseNoti 정의
    • 해당 productID를 찾고 변경된 데이터의 index를 탐색하여, 해당 index 셀 업데이트 수행
@objc private func handlePurchaseNoti(_ notification: Notification) {
  guard
    let productID = notification.object as? String,
    let index = self.products.firstIndex(where: { $0.productIdentifier == productID })
  else { return }
  
  self.tableView.reloadRows(at: [IndexPath(index: index)], with: .fade)
  self.tableView.performBatchUpdates(nil, completion: nil)
}

Sandbox 계정으로 로그인

  • iPhone > Setting > iTunes 및 App Store

  • 로그인 버튼을 클릭하고 이전 포스팅 글에서 만든 sandbox 계정으로 로그인하면 완료

* 전체 코드: https://github.com/JK0369/ExIAP

 

* 참고

https://developer.apple.com/support/app-store-connect/

https://www.raywenderlich.com/5456-in-app-purchase-tutorial-getting-started

https://medium.com/better-programming/in-app-purchases-and-storekit-in-ios-14-aed2c3e58966

https://developer.apple.com/documentation/storekit/original_api_for_in-app_purchase/offering_completing_and_restoring_in-app_purchases

 

Comments