[새싹 iOS] 21주차_In-App Purchase

임승섭·2023년 12월 5일
1

새싹 iOS

목록 보기
37/45

In-App Purchase (IAP)

IAP 특성 & 정책


  1. 개발자 계정의 모든 앱 기준, 최대 10,000개의 앱 내 구입 제품 생성 가능
  1. 사용자 경험을 위해, 국가/지역 설정 불가능. 특정 국가에만 보여주거나 숨기기 불가능
  1. 유니버셜의 경우에도 일부 플랫폼에 대해 인앱 상품 제거 불가능
    • 유니버셜 : AppStoreConnect에서 두 개 이상의 플랫폼 유형 선택
    • 동일한 앱 내 구입 상품을 여러 플랫폼에서 제공할 수 있어야 한다
  1. 자동 갱신 구독 및 비소모성 인앱 상품의 경우 가족 공유 설정이 가능하지만, 가족 공유 한 번 하고나면 비활성화 불가능
  1. 앱 내 구입 삭제는 최소 31일 전 공지가 필요하며, 프로모션에 인앱 상품이 포함되어 있다면 미리 종료해야 한다
  1. 인앱 상품 생성
    • 상품 종류 (소모성, 비소모성, 자동 갱신, 비자동 갱신) 선택 후 등록
    • Localization에 대한 추가 / 제거 가능
    • 프로모션 이미지 추가 및 제거 (iOS 11 이상)
      • 앱 내 구입 상품을 App Store에서 홍보 가능
      • 한 번에 최대 20개의 프로모션 이미지 가능
    • 앱 심사 정보 추가
      • 추가 정보와 인앱 상품이 포함된 스크린샷으로 심사 진행
      • 심사 시에만 반영되고, App Store에는 반영되지 않는다
  1. Sandbox 테스트 계정
    • 결제 테스트 가능 (iOS 14 이상)
    • 결제 실패 테스트 가능
    • "구독" 같은 기능 테스트할 때 유용함 자동 갱신 구독 테스트
      • 지속 기간 단축, 구독 해지 전까지 최대 5회 자동 갱신
      • (1주일: 3분, 1개월: 5분, ... 1년: 1시간)
    • 만약 Sandbox 계정이 아닌 실제 결제로 테스트를 할 경우, 구매자가 직접 애플에 환불 요청을 해야 한다
      • 개발자에게 환불 처리하는 것은 불가능
  1. AppStore 서버 알림 URL
    • AppStoreConnect [일반 정보] -> [앱 정보]
    • 사용자의 인앱 상품에 대한 변경 알림 (구독 중지, 구입 환불, ...)
  1. StoreKit 2 사용 가능
    • async / await 도입
    • StoreKit 1의 각종 제약 수정
    • 실시간으로 변하는 상태 체크가 쉽게 가능해짐


IAP 구현


  1. 유료 개발자 계정 생성 및 유료 응용 프로그램 계약 서명
  2. App Store Connect에서 앱 네 구입 설정
  3. Xcode로 앱 내 구입 활성화
  4. 앱 내 구입 디자인 및 제작
  5. 앱 내 구입 테스트
  6. App Store에 앱 및 앱 내 구입 출시

1. 유료 개발자 계정 생성 및 유료 응용 프로그램 계약 서명

2. App Store Connect에서 앱 내 구입 설정

3. Xcode 앱 내 구입 활성화

4. 앱 내 구입 디자인 및 제작 (코드 구현)

IAPService.swift

import StoreKit

  • SKProduct : 상품 정보 (price, name, ...)
  • SKPayment : 지불 정보 (product id, request data)
  • SKPaymentRequest : 결제 요청 시 사용 타입 (delegate로 성공/실패 알려줌)
  • SKPaymentQueue.default() : 데이터 가져오기에 사용

ProductRequestCompletionHandler

  • 내부 딜리게이트를 통해 IAP 성공/실패 알 수 있고, 외부에도 completion 제공하는 식으로 구현하기 위해 따로 completion 정의
  • completion은 외부에서 정의하고, 실행은 내부에서 불림 (delegate 패턴)
    // completion 정의. (외부에서 정의하고, 내부에서 실행)
    typealias ProductRequestCompletionHandler = (_ success: Bool, _ products: [SKProduct]?) -> Void

protocol IAPServiceType

  • 외부에서 필요한 메서드 명시

    • getProducts : products 항목 (상품 정보) 가져오기
    • buyProduct : product 구입
    • isProductPurchased : 구입했는지 확인
    • restorePurchased : 구입한 목록 확인
    // 프로토콜 -> 외부에서 필요한 메서드 명시
    protocol IAPServiceType {
        var canMakePayments: Bool { get }
    
        func getProducts(completion: @escaping ProductRequestCompletionHandler)
        func buyProduct(_ product: SKProduct)
        func isProductPurchased(_ productID: String) -> Bool
        func restorePurchases()
    }

class IAPService

  • NSObject 상속 (StoreKit 사용), IAPServiceType 채택
    class IAPService: NSObject, IAPServiceType {

  • 필요한 프로퍼티 선언 및 init

    • productIDs : 앱스토어 커넥트에 등록된 productID
    • purchaseProductIDs : 구매한 productID
    • productRequest : productID로 부가 정보 조회하기 위한 인스턴스
    • productsCompletionHandler : 사용하는 쪽에서 성공/실패 했을 때 completion을 통해 값을 넘겨줄 수 있다.
    // 필요한 프로퍼티
    private let productIdentifiers: Set<String>
    private var purchasedProducts: Set<String> = []
    private var productRequest: SKProductsRequest?
    private var productsCompletionHandler: ProductsRequestCompletionHandler?
    
    // 상품 정보 받아서 초기화 및 SKPaymentQueue 연결
    init(productIdentifiers: Set<String>) {
        // "com.blogPost.App.~~"
        self.productIdentifiers = productIdentifiers
    
        self.purchasedProducts = productIdentifiers.filter {
            // UserDefaults에 저장해둔 구매 여부
        	UserDefaults.standard.bool(forKey: $0) == true
        }
    
        super.init()
    
        SKPaymentQueue.default().add(self)
        // App Store와 지불정보를 동기화하기 위한 Observer
        // -> SKPaymentTransactionObserver 프로토콜 채택
    }

  • canMakePayment : 사용자의 디바이스가 현재 결제가 가능한지 확인
    var canMakePayments: Bool {
        return SKPaymentQueue.canMakePayments()
    }

  • 프로토콜 메서드 정의

    // 상품 정보 조회
    func getProducts(completion: @escaping ProductsRequestCompletionHandler) {
        self.productRequest?.cancel()
        self.productsCompletionHandler = completion
        self.productRequest = SKProductsRequest(productIdentifiers: self.productIdentifiers)
        self.productRequest?.delegate = self
        // -> SKProductsRequestDelegate 채택
        self.productRequest?.start()    // 인앱 상품 조회 시작
        // -> delegate 함수 실행 (SKProductsRequestDelegate)
    }
    
    // 상품 구입
    func buyProduct(_ product: SKProduct) {
        let payment = SKPayment(product: product)
        SKPaymentQueue.default().add(payment)
        // -> paymentQueue() 함수 실행
    }
    
    // 구입 확인
    func isProductPurchased(_ productID: String) -> Bool {
        return self.purchasedProducts.contains(productID)
    }
    
    // 구입 내역 복원
    func restorePurchases() {
        SKPaymentQueue.default().restoreCompletedTransactions()
    }

extension IAPService: SKProductsRequestDelegate

  • getProducts에서 completionHandler를 캡쳐해두고, 여기서 실행한다

    extension IAPService: SKProductsRequestDelegate {
    
        // App Store Connect에서 상품 정보 조회
        func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
    
            let products = response.products
    
            self.productsCompletionHandler?(true, products)
    
            self.clearRequestAndHandler()
    
            // 출력
            products.forEach { item in
                print("Found Product : \(item.productIdentifier) \(item.localizedTitle)")
            }
        }
    	
    	
        // fail
        func request(_ request: SKRequest, didFailWithError error: Error) {
    
            self.productsCompletionHandler?(false, nil)
    
            self.clearRequestAndHandler()
    
            // 출력
            print("Error : \(error.localizedDescription)")
        }
    
        // 초기화
        private func clearRequestAndHandler() {
            self.productRequest = nil
            self.productsCompletionHandler = nil
        }
    }

extension IAPService: SKPaymentTransactionObserver

  • SKPaymentQueue에서 처리되는 일

    • purchased, failed, restored, deferred, purchasing
    • 구매 성공 or 구매 복원 시 UserDefaults 업데이트 및 noti 해주기
    extension IAPService: SKPaymentTransactionObserver {
        func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
            print(#function)
    
            for transaction in transactions {
                let state = transaction.transactionState
    
                switch state {
                case .purchasing:
                    print("구매하는 중")
                case .purchased:
                    print("구매 완료")
                    completePurchase(transaction: transaction)
                case .failed:
                    print("구매 실패")
                    failPurchase(transaction: transaction)
                case .restored:
                    print("구매 복원")
                    restorePurchase(transaction: transaction)
                case .deferred:
                    print("deferred")
                @unknown default:
                    fatalError()
                }
            }
        }
    
        // 구매 완료 (성공)
        private func completePurchase(transaction: SKPaymentTransaction) {
            print(#function)
    
            guard let id = transaction.original?.payment.productIdentifier else { return }
            deliverPurchaseNotificationFor(id: id)
            SKPaymentQueue.default().finishTransaction(transaction)
        }
    
        // 구매 실패
        private func failPurchase(transaction: SKPaymentTransaction) {
            print(#function)
    
            if let transactionError = transaction.error as NSError?,
               transactionError.code != SKError.paymentCancelled.rawValue {
                print("TransactionError : \(transactionError.localizedDescription)")
            }
            SKPaymentQueue.default().finishTransaction(transaction)
        }
    
        // 구매 복원 성공
        private func restorePurchase(transaction: SKPaymentTransaction) {
            print(#function)
    
            guard let id = transaction.original?.payment.productIdentifier else { return }
            deliverPurchaseNotificationFor(id: id)
            SKPaymentQueue.default().finishTransaction(transaction)
        }
    
        private func deliverPurchaseNotificationFor(id: String?) {
            guard let id else { return }
    
            self.purchasedProducts.insert(id)
            UserDefaults.standard.set(true, forKey: id)
    
            NotificationCenter.default.post(
                name: .iapServicePurchaseNotification,	// 곧 정의
                object: id
            )
        }
    
        func paymentQueue(_ queue: SKPaymentQueue, removedTransactions transactions: [SKPaymentTransaction]) {
            print(#function)
        }
    }

extension Notification.Name

  • 노티 관리를 위한 메세지 정의
    extension Notification.Name {
        static let iapServicePurchaseNotification = Notification.Name("IAPServicePurchaseNotification")
    }

MyProducts.swift

enum MyProducts

  • Product ID를 가지고 있는 Wrapping 타입. (IAPService를 wrapping한다)

    enum MyProducts {
        static let productID = "com.blogPost.s_sub.heart100"
        static let iapService: IAPServiceType = IAPService(productIdentifiers: Set<String>([productID]))
    
        static func getResourceProductName(_ id: String) -> String? {
            id.components(separatedBy: ".").last
        }
    }

IAPTestViewController.swift

class IAPTestViewController

  • 구현한 내용 이용하기
class FinalIAPTestViewController: UIViewController {
    
    private let restoreButton = UIButton()
    private let buyButton = UIButton()
    private let productLabel = UILabel()
    
    private var products = [SKProduct]()
    
    func setting() {...}

    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        setting()
        getProducts()
    }
    
    // 상품 정보 조회
    func getProducts() {
        MyProducts.iapService.getProducts { [weak self] success, products in
            if success,
               let products = products {
                DispatchQueue.main.async {
                    self?.products = products
                    self?.productLabel.text = "\(products.first?.productIdentifier ?? "")\n \(products.first?.localizedTitle ?? "")"
                }
            }
        }
    }
    
    // 구매 버튼 클릭
    @objc func buyButtonClicked() {
        MyProducts.iapService.buyProduct(products.first!)
    }
    
    // 복구 버튼 클릭 (구입 목록 조회)
    @objc func restoreButtonClicked() {
        MyProducts.iapService.restorePurchases()
    }
    
    
    // 노티 세팅
    func setNotification() {
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(handlePurchaseNoti(_:)),
            name: .iapServicePurchaseNotification,
            object: nil
        )
    }
    
    @objc private func handlePurchaseNoti(_ notification: Notification) {
        // 노티 핸들 (뷰 업데이트)
    }
}

6. 앱 내 구입 테스트




영수증 유효성 검증


  • 위 코드 상에서는 따로 영수증 검증을 하지 않았지만, 사용자가 상품을 구매하려고 할 때 영수증 유효성에 대한 검증을 해야 한다.
  • 서버가 없는 경우 클라이언트 상에서 검증하고, 서버가 있을 때는 영수증 정보를 서버에 전달한다.
  • 영수증 검증이 완료된 후에 트랜잭션을 종료한다
    SKPaymentQueue.default().finishTransaction(transaction)

1. 영수증 정보

func getReceiptData(_ productIdentifier: String) -> String? {
    if let receiptURL = Bundle.main.appStoreReceiptURL,
       FileManager.default.fileExists(atPath: receiptFileURL.path) {
       
       do {
       		let receiptData = try Data(contensOf: receiptFileURL)
            let receiptString = receiptData.base64EncodedString(
                options: NSData.Base64EncodingOptions(rawValue: 0)
            )
            return receiptString
       
       } catch {
       		print("구매 이력 영수증 데이터 변환 과정에서 에러 : \(error.localizedDescription)")
            return nil
       }
       
    }
    
    print("구매 이력 영수증 URL 에러")
    return nil
}

2. 영수증 검증 (서버 x)

  1. 클라이언트 -> 애플 (production URL)
    • 애플 서버(production URL)로 영수증 정보를 POST로 보낸다
  1. 애플 -> 클라이언트
    - 애플 서버에서 응답값을 클라이언트에게 보낸다
    - status 값이 0이면, 유효한 영수증으로 판단한다
    - status 값이 21007이면, Sandbox 테스트용 영수증임을 의미한다
  1. 클라이언트 -> 애플 (sandbox URL)
    • 2에서 21007을 받았으면, 해당 정보를 sandbox URL로 다시 보낸다
  1. 애플 -> 클라이언트
    • status 값이 0이면, 유효한 영수증으로 판단한다.
  • 이 과정에서 만약 cancellation_date_ms 라는 키가 추가되어서 응답이 왔다면, 환불 영수증으로 판단한다
  • 유효한 영수증으로 판단될 경우, 구매한 인앱 상품에 대한 로직을 클라이언트에서 처리한다. (광고 제거, 테마 사용, ...)

3. 영수증 검증 (서버 o)

  • 서버가 있다면, 영수증 유효성 검증은 서버에서 진행한다.
  • 이 경우 클라이언트의 역할에 대해 보자

1. 클라이언트 -> 서버 : 인앱 구매 목록 요청 / 응답

  • 가장 먼저, 클라이언트에서 사용자가 이미 상품을 구매했는지 에 대한 판단을 해주어야 한다
  • 만약 비소모성 상품이라면, 더 이상 구매가 불가능하도록 처리해주어야 한다
  • 즉, 서버에게 해당 사용자의 인앱 구매 목록에 대한 데이터를 요청하고, 이미 상품을 구매했는지 체크한다.
  • 사용자가 해당 상품을 안드로이드 기기의 구글 플레이 스토어에서 다운받은 앱에서 구매했을 가능성도 생각해야 한다. 이 경우에도 역시 상품의 구매 상태를 유지시켜주어야 하기 때문에 항상 서버에게 사용자의 구매 목록에 대한 요청을 해야 한다.
  • 응답을 받으면 인앱 상품 표시 / 구매 상태에 반영한다

2. 클라이언트 -> 서버 : 구매 가능 여부 요청 / 응답

  • 진짜 구매가 가능한지에 대한 요청을 서버에게 보낸다. 구매 목록에 없는 걸 확인했더라도, 이 이후부터 구매 요청 사이에 사용자가 다른 기기에서 구매할 가능성을 고려한다. 이 찰나의 순간에 다른 디바이스에서 결제가 이루어질 수도 있기 때문이다

3. 클라이언트 -> 애플 : 인앱 결제 요청 / 응답

  • 애플 서버에 실제 결제에 대한 요청을 진행한다

4. 클라이언트 -> 서버 : 영수증 검증 요청 / 응답

  • 결제 정보에 대한 영수증 검증을 서버에 요청한다. 이 경우에는 서버에서 영수증 검증을 하고, 결과를 응답으로 보내준다.

5. 클라이언트 -> 서버 : 인앱 결제 완료 요청 / 응답

  • 최종적으로 결제에 대한 완료 요청을 진행한다



구매 복원 (restore)


  • 만약 내가 아이폰을 바꿨다고 하자. 이전 기기에서 사용하던 앱을 새롭게 다운받았는데, 이전 기기에서 해당 앱의 "광고 제거" 상품을 구매했다.
  • 이러한 경우에, 반드시 구매 복원을 해주어야 한다.
  • 구매 복원은 비소모성 상품에 대해 가능하고, 소모성 상품에 대해서는 불가능하다
  • 구매 복원에 대한 기능은 필수로 구현해야 하고, 없다면 리젝 사유가 될 수 있다.
  • 만약 서버가 존재해서 구매 복원에 대한 기능을 서버에서 자체적으로 해주고 있다면, 앱 내에서 구현하지 않아도 된다. (심사 제출 시 소명으로 언급해주면 된다)
  • 즉, 서버가 없다면 애플 계정에 따라 클라이언트 상에서 체크하는 과정이 필요하지만, 서버에서 사용자의 구매 내역을 저장해주고 있다면 굳이 클라이언트 상에서 이 기능을 구현할 필요는 없다.

3개의 댓글

comment-user-thumbnail
2024년 4월 15일

현재 구매 복원 관련해서 심사 거부를 당해서 담당자에게 서버에서 결제 정보를 수신한다고 했는데 그럼에도 구매 복원 버튼이 필요하다고 하더라구요.
마지막 구매복원 부분에서 "서버가 존재해서 구매 복원에 대한 기능을 서버에서 자체적으로 해주고 있다면, 앱 내에서 구현하지 않아도 된다." 라는 내용은 어디서 찾으신 건지 알 수 있을까요? 해당 자료를 토대로 문의를 해보고싶습니다!

1개의 답글