[iOS] In-App Purchase 인앱결제

chaentopia·2023년 11월 6일
0

들어가기에 앞서

프로젝트에 인앱결제를 넣으며 죽음의 며칠을 보냈던 적이 있다... 왜냐면 인앱결제에 대한 정보가 구글링을 해도 정말 적게 나왔기 때문...
이제는 어느정도 정상화가 되고 결제도 원활히 되고 있다!!

사실 iOS의 인앱결제는 생각보다 클라이언트가 할 일이 없다. (사실상 앱스토어와 서버가 다 해주는 격)
클라이언트가 처리 해야할 프로세스는 다음과 같다

  • App Store Connect에 등록된 상품 불러오기
  • 받아온 상품에 대한 정보를 자체 서버로 보내주기 (결제한 transactionID를 보내주며 서버가 해당 결제가 진짜인지 가짜인지 검증 과정을 거쳐서 클라에게 결과를 보내줌)
  • 검증 완료 후 App Store에게 결제 요청

생각보다 복잡하진 않다.. 그럼에도 쉽지는 않음 ㅋㅋ


App Store Connect에 상품 등록

사실 인앱결제를 넣기 위해서는 사업자 등록이 되어 있어야 한다.
사업자 등록 후, App Store Connect에서 계약, 금융 및 세금 거래에 들어간다.


유료 앱과 무료 앱 모두 메타데이터를 넣어 진행 상태를 활성 상태로 만들어둔다.


다시 나의 앱으로 돌아와서 앱 내 추가 기능에 있는 앱 내 구입과 구독에 원하는 상품을 등록한다.
앱 내 구입은 일회성으로 구매하는 소모품/비소모품을 등록할 수 있고, 구독은 알다시피 정기적으로 결제를 하여 혜택을 줄 수 있도록 하는 상품이다. 각자의 상황에 맞기 금액과 이름, 설명, 스크린샷 등을 등록해서 구입 및 구독 상품에 대한 심사를 넣는다.

Code

프로젝트로 들어와 Signing & Capabilities에서 In-App Purchase Capability를 추가한다.

//  MyProducts.swift
import Foundation

enum MyProducts {
    static let product1 = "product.one"
    static let product2 = "product.two"
    static let product3 = "product.three"
    static let product4 = "product.four"
    
    static let iapService: IAPServiceType = IAPService(productIDs: Set<String>([product1, product2, product3, product4]))
    
    static func getResourceProductName(_ id: String) -> String? {
        id.components(separatedBy: ".").last
    }
}

App Store Connect에 등록한 상품ID (상품마다 직접 설정할 수 있음) 를 저장하는 enum 값을 정리한다. (앱스토어와 통신하여 받아올 수도 있지만....... 하드코딩했다.)

// IAPService.swift

import StoreKit

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

typealias ProductsRequestCompletion = (_ success: Bool, _ products: [SKProduct]?) -> Void

protocol IAPServiceType {
    var canMakePayments: Bool { get }
    
    func getProducts(completion: @escaping ProductsRequestCompletion)
    func buyProduct(_ product: SKProduct)
    func isProductPurchased(_ productID: String) -> Bool
    func restorePurchases()
}

final class IAPService: NSObject, IAPServiceType {
    
    // App Store Connect에 등록된 productsID
    private let productIDs: Set<String>
    
    // App Store Connect에 등록된 product 중 구매한 productsID
    private var purchasedProductIDs: Set<String>
    
    private var productsRequest: SKProductsRequest?
    private var productsCompletion: ProductsRequestCompletion?
    private var processedTransactionIDs: Set<String> = Set()
    
    // 구매 가능 여부
    var canMakePayments: Bool {
        SKPaymentQueue.canMakePayments()
    }
    
    init(productIDs: Set<String>) {
        self.productIDs = productIDs
        
       // 이미 결제한 프로덕트 필터링, 이미 결제했다면 true로 변경

        self.purchasedProductIDs = productIDs
            .filter { UserDefaults.standard.bool(forKey: $0) == true }
        
        super.init()
        
        // App Store Connect와 동기화
        SKPaymentQueue.default().add(self)
    }
    
    // App Store Connect가 갖고 있는 products 조회
    func getProducts(completion: @escaping ProductsRequestCompletion) {
        self.productsRequest?.cancel()
        self.productsCompletion = completion
        self.productsRequest = SKProductsRequest(productIdentifiers: self.productIDs)
        self.productsRequest?.delegate = self
        self.productsRequest?.start()
    }
    
    // 구매
    func buyProduct(_ product: SKProduct) {
        SKPaymentQueue.default().add(SKPayment(product: product))
    }
    
    // 구매 여부 확인
    func isProductPurchased(_ productID: String) -> Bool {
        self.purchasedProductIDs.contains(productID)
    }
    
    // 복원
    func restorePurchases() {
        SKPaymentQueue.default().restoreCompletedTransactions()
    }
}

// 앱스토어에 보낸 상품 리스트 요청에 대한 응답을 받는 delegate
extension IAPService: SKProductsRequestDelegate {
    
    // 인앱결제 상품 리스트 로드 완료
    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)") }
    }
    
    // 인앱결제 상품 리스트 로드 실패
    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
    }
}

extension IAPService: SKPaymentTransactionObserver {
    func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
        transactions.forEach {
            switch $0.transactionState {
            case .purchased:
                // 구입 성공
                let productID = $0.payment.productIdentifier
                self.deliverPurchaseNotificationFor(id: productID, transaction: $0)
                print("completed transaction")
                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)
                NotificationCenter.default.post(name: Notification.Name("HideLoadingIndicator"), object: nil)
            case .restored:
                // 복원 성공
                print("failed transaction")
                self.deliverPurchaseNotificationFor(id: $0.original?.payment.productIdentifier, transaction: $0)
                SKPaymentQueue.default().finishTransaction($0)
            case .deferred:
                print("deferred")
            case .purchasing:
                print("purchasing")
            default:
                print("unknown")
            }
        }
    }
    
    private func deliverPurchaseNotificationFor(id: String?, transaction: SKPaymentTransaction) {
        guard let id = id else {
            print("productID is nil")
            return
        }
        
        let transactionID = transaction.transactionIdentifier ?? ""
        print("Transaction ID: \(transactionID)")
        // 중복 처리를 방지하기 위해 이미 처리한 트랜잭션인지 확인
        if !processedTransactionIDs.contains(transactionID) {
            // 중복 처리 방지를 위해 트랜잭션 ID를 추가
            processedTransactionIDs.insert(transactionID)
            
            self.purchasedProductIDs.insert(id)
            UserDefaults.standard.set(true, forKey: id)
            NotificationCenter.default.post(
                name: .iapServicePurchaseNotification,
                object: id,
                userInfo: ["transactionID": transactionID] // transactionID를 userInfo에 추가
            )
            print("Notification delivered for product ID: \(id)")
        }
    }
}

App Store Connect와 통신하는 Service를 만들어서 통신에 사용한다!

ViewController에서의 사용법

...
    private var products = [SKProduct]()
    private let productOrder: [String] = [
        MyProducts.product1,
        MyProducts.product2,
        MyProducts.product3,
        MyProducts.product4
    ]
    
    func getProducts() {
        MyProducts.iapService.getProducts { [self] success, products in
            print("load products \(products ?? [])")
            if success, let products = products {
                DispatchQueue.main.async {
                    // 배열을 사용하여 제품을 원하는 순서대로 정렬합니다.
                    self.products = self.sortProducts(products)
                    print(self.products)
                    self.setAddTarget()
                }
            }
        }
    }
    ...
MyProducts.iapService.buyProduct(self.products["여기에 프로덕트 인덱스 번호])

얘를 원하는 함수에 넣어주면 끝!!!!

profile
the pale blue dot

0개의 댓글