본문 바로가기
iOS_Swift 앱개발👍

[iOS_Swift] IAP 인앱 결제 _ 34

by 개발하는윤기사 2023. 8. 7.
728x90
반응형

 

이번 포스팅에서는 iOS 인앱 결제(In App Purchase)에 대해 다뤄보겠습니다!

 

글에 들어가기 앞서 IAP를 위한 기본 설정 세팅은 완료가 되어야 하니, 세팅 후 읽어주시면 감사하겠습니다.

 

 

<기본 설정>

1. Apple Developer > App IDs 등록 

 


 

2. App Store Connect > 계약, 세금 및 금융거래 > 유료 앱 > 약관 보기 및 동의하기 > 활성화 상태

 


 

3. App Store Connect > 앱 등록 > 앱 내 추가 기능 > 앱 내 구입 OR 구독 상품 등록

앱 내 구입
구독

 

 


 

 

4. App Store Connect > 사용자 및 액세스 > 결제 테스트를 위한 Sandbox 테스터 등록

 

 


 

5. Xcode 프로젝트 내 Signing & Capabilities > In-App Purchase 추가

 

 


 

인앱 결제 (InAppPurchase)

- 2023.08 기준으로 "앱 내 구입"과 "구독"으로 카테고리가 나뉘어 있음.

 

1.  소모성

  • 여러 번 구입 가능한 제품.
  • 앱 내 결제 복원 시에는 복원되지 않는 항목이며 아이템은 앱이나 서버에서 관리해야 함. *Apple에서는 관리하지 않음
  • Ex. 게임 내 크리스탈, 다이아몬드 등 게임 머니, 패키지 아이템 등

2.  비소모성

  • Apple 계정당 한 번만 구입 가능한 제품.
  • 앱 내 결제 복원 시에는 추가 구매 없이 복원되어야 하며 구매 항목은 Apple에서 관리.
  • Ex. 광고제거, 썸원 프리미엄, 굿노트 프리미엄 등

3.  자동 갱신형 구독

  • 특정 주기별로 자동 결제되는 제품.
  • 앱 내 결제 복원 시에는 추가 결제 절차없이 복원되어야 하며 구매한 항목은 Apple에서 관리.
  • 사용자가 언제든지 앱을 통하지 않고서도 구독을 해지할 수 있음. (Apple 계정 → 구독 관리에서 해지 가능)
  • Ex. OTT 구독 서비스, 유튜브 프리미엄 등

4.  비갱신형 구독

  • 한정 기간동안만 상품을 이용할 수 있도록 한 번만 결제하는 제품이지만 만료되면 다시 결제해야 하는 제품. 결제 복원 여부는 앱이 결정하며, 구매 항목은 Apple에서 관리. 다시 구독하기 위해선 유저가 다시 수동으로 결제해야 함.
  • Ex. OTT 구독 서비스, 유튜브 프리미엄 등

 

 

<인앱결제 Flow Chart>

인앱결제 흐름은 크게 4단계로 구분할 수 있습니다!

 

1. 앱 내에 상품 ID를 정의

2. 인앱 상품 조회

3. 인앱 상품 구매

4. 결제 요청 및 승인

 

1번부터 4번까지 하나하나 로직과 함께 살펴보도록 하죠!

 

 

 

StoreKit Import

- 인앱결제는 StoreKit Framework를 사용합니다!! 바로 import 해줍니다!

import StoreKit

 

1. 상품 ID 정의

- App Store Connect에 등록한 제품 ID를 Enum타입으로 만듭니다.

- 인앱결제 기능을 구현하기 위한 protocol도 하나 선언하겠습니다! (상품 가져오기, 구매하기, 구매확인, 복원하기)

enum MyProducts {
    static let productID = "IAP20230713" //소모품
    static let productID_2 = "IAP20230714" //소모품
    static let productID_3 = "IAP20230715" //소모품
    static let productID_4 = "IAP20230716" //구독
    static let productID_5 = "IAP20230717" //비소모품
    static let iapService: IAPServiceType = IAPService(productIDs: Set<String>([productID, productID_2, productID_3, productID_4, productID_5]))
}

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

 

2. 인앱 구매 목록 요청 (인앱 상품 조회)

  • IAPService라는 Class를 하나 만들어줍니다. 
  • init()될 때, 1번에서 정의한 상품 ID를 이용해 구매내역을 확인하고, 리스트를 불러올 수 있습니다.
  • 인앱 결제에는 특정 앱에 대한 모든 상품 ID를 가져오는 메커니즘이 없기 때문에, 앱 내부에 상품 ID를 정의해야 합니다.
  • purchasedProductIDs라는 구매가 된 목록들도 따로 구별하기 위해 만들었습니다.
    • 유저디폴트의 productID의 유무에 따라 Bool 타입으로 반환 후 true인 것들만 filter 해서 가져옵니다.
//IAPService.swift
private let productIDs: Set<String>
private var purchasedProductIDs: Set<String>
private var productsRequest: SKProductsRequest?
private var productsCompletion: ProductsRequestCompletion?

init(productIDs: Set<String>) {
   
    self.productIDs = productIDs
    self.purchasedProductIDs = productIDs
        .filter { UserDefaults.standard.bool(forKey: $0) == true }
    
    super.init()
    /// App Store와 지불정보를 동기화하기 위한 Observer 추가
    SKPaymentQueue.default().add(self)
}

 

  • App Store Connect에 등록한 인앱결제 상품들 가져오기
typealias ProductsRequestCompletion = (_ success: Bool, _ products: [SKProduct]?) -> Void

/// App Store Connect에서 등록한 인앱결제 상품들을 가져올 때
func getProducts(completion: @escaping ProductsRequestCompletion) {
    self.productsRequest?.cancel()
    self.productsCompletion = completion
    self.productsRequest = SKProductsRequest(productIdentifiers: self.productIDs)
    self.productsRequest?.delegate = self
    self.productsRequest?.start()
}

//ViewController.swift
MyProducts.iapService.getProducts { [weak self] success, products in
    print("불러온 상품들 : \(products ?? [])")
    guard let self else { return }
    if success, let products = products {
        DispatchQueue.main.async {
            self.products = products
            self.mainView.tableView.reloadData()
        }
    }
}

 

  • SKPaymentQueue - 구입, 복원, 인앱결제 가능 유무 등
//인앱 결제 가능 유무
var canMakePayments: Bool {
    SKPaymentQueue.canMakePayments()
}

//인앱결제 상품을 구입할 때
func buyProduct(_ product: SKProduct) {
    SKPaymentQueue.default().add(SKPayment(product: product))
}

//구입한 상품들을 가져올 때, UserDefaults에 true인 것들과 비교해서 가져옴
func isProductPurchased(_ productID: String) -> Bool {
    return self.purchasedProductIDs.contains(productID)
}

//구입 내역을 복원할 때
func restorePurchases() {
    SKPaymentQueue.default().restoreCompletedTransactions()
}

 

  • SKPaymentQueue에 대한 observer 동작 - SKProductsRequestDelegate
///인앱결제 상품 리스트를 가져오기.
func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
    let products = response.products
    self.productsCompletion?(true, products)
    self.clearRequestAndHandler()
    
    products.forEach { print("인앱 결제 상품들 : \\($0.productIdentifier) \\($0.localizedTitle) \\($0.price.floatValue)") }
}

///상품 리스트 가져오기 실패할 경우
func request(_ request: SKRequest, didFailWithError error: Error) {
    print("Error: \\(error.localizedDescription)")
    self.productsCompletion?(false, nil)
    self.clearRequestAndHandler()
}

private func clearRequestAndHandler() {
    self.productsRequest = nil
    self.productsCompletion = nil
}

 

 

3. 인앱 상품 구매

//상품 구매하기
func buyProduct(_ product: SKProduct) {
    SKPaymentQueue.default().add(SKPayment(product: product))
}

 

4. 결제 요청 및 승인

  • 상품을 결제했을 때, App Store 응답을 처리하는 Observer
  • 트랜잭션에 대한 결과값에 따라 NotificationCenter를 이용해 결과값(상품정보)을 알려줄 수 있습니다.
func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
    transactions.forEach {
        switch $0.transactionState {
        **//성공적으로 처리된 트랜잭션**
        case .purchased:
            print("completed transaction")
            complete(transaction: $0)
            
        **//실패한 트랜잭션**
        case .failed:
            print("failed transaction")
            fail(transaction: $0)
            
        **//사용자가 이전에 구매한 콘텐츠를 복원하는 트랜잭션**
        case .restored:
            print("restored transaction")
            restore(transaction: $0)

        **//대기열에 있지만 최종 상태가 구매 요청과 같은 외부 작업 보류 중인 트랜잭션**
        case .deferred:
            print("deferred")
            
        **///App Store에서 처리중인 트랜잭션**
        case .purchasing:
            print("purchasing")
            
        default:
            print("unknown")
        }
    }
}

/// 구입 성공
private func complete(transaction: SKPaymentTransaction) {
    print(#function)
    deliverPurchaseNotificationFor(identifier: transaction.payment.productIdentifier)
    SKPaymentQueue.default().finishTransaction(transaction)
}

/// 복원 성공
private func restore(transaction: SKPaymentTransaction) {
    print(#function)
    guard let productIdentifier = transaction.original?.payment.productIdentifier else { return }
    deliverPurchaseNotificationFor(identifier: productIdentifier)
    SKPaymentQueue.default().finishTransaction(transaction)
}

/// 구매 실패
private func fail(transaction: SKPaymentTransaction) {
    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)
}

/// 구매한 인앱 상품 키에 대한 UserDefaults Bool 값을 변경
private func deliverPurchaseNotificationFor(identifier: String?) {
    guard let identifier = identifier else { return }
    self.purchasedProductIDs.insert(identifier)
    UserDefaults.standard.set(true, forKey: identifier)
    NotificationCenter.default.post(name: .iapServicePurchaseNotification, object: identifier)
}

 

PaymentQueue에 SKPaymentTransactionState에 따라서 처리가 되고 있는 모습을 확인할 수 있습니다!

 

 

 

🌟 인앱결제의 RawData

 

조금 더 깊게 들어가 보자면,

 

SKPaymentTransactionState에는 Enum타입으로 각각 아래와 같은 Int 타입의 RawValue를 가지고 있으니, 상태에 대한 처리를 할 수 있겠죠?

public enum SKPaymentTransactionState : Int, @unchecked Sendable {

    case purchasing = 0 

    case purchased = 1 

    case failed = 2 

    case restored = 3 

    @available(iOS 8.0, *)
    case deferred = 4 

}

 

 

비소모품(광고제거) 상품을 구입했을 때 인앱결제 관련 RawData는 어떻게 생겼을까요?

 

 

하나만 예로 들어보겠습니다. 

 

 

인앱 결제 목록 리스트를 조회하면 SKProduct 타입의 상품들을 확인할 수 있습니다.

 

SKProduct 타입에는 아래와 같이 들어있습니다! 상품 ID와 App Store Connect 설정한 상품 이름과 설명, 가격 등이 들어있죠!

 

[SKProduct]

productID : IAP20230717
localizedTitle : 광고제거
localizedDescription : 광고제거
price : 44000
priceLocale : ko_KR@currency=KRW (fixed)
contentVersion : 
subscriptionPeriod : nil
isDownloadable : false

 

사용자가 상품 구매를 시작하면 SKProduct가 SKPaymentQueue에 올라가고(add), 상품에 대한 SKPaymentTransaction 처리가 이루어집니다.

 

SKPaymentTransaction 타입에는 아래와 같이 들어있습니다!

 

 

[SKPaymentTransaction] - 구입 진행 중

- 트랜잭션 ID, 상품 ID, 상품 수량, 트랜잭션 상태, 에러 등이 들어있습니다.

transactionIdentifier : nil
payment.productIdentifier : IAP20230717
payment.quantity : 1
transactionState : 0 //purchasing
error : nil
original : nil
transactionDate : nil

 

 

구입에 성공하면 transactionState 값이 0(purchasing) -> 1(purchased) 상태로 바뀌고, 해당 트랜잭션의 ID값과, 날짜도 같이 확인할 수 있습니다!

 

[SKPaymentTransaction] - 구입 성공

transactionIdentifier : Optional("2000000370643782")
transactionState : 1 //purchased
transactionDate : Optional(2023-07-18 01:35:39 +0000)

 

 

만약 구입에 실패한다면, "error"에 오류 코드와 함께 에러처리를 할 수 있답니다!

 

마찬가지로 transactionState 값은 0(purchasing) -> 2(failed) 상태가 되며 error에 에러 코드를 담아 반환해 주는 것입니다!

 

[SKPaymentTransaction] - 구입 실패 시

transactionState : 2 //failed
error : Optional(Error Domain=SKErrorDomain Code=0 "(null)")

 

 

* 영수증 검증(결제 완료)

 

인앱결제는 영수증 검증이라는 것을 필요로 합니다.

 

사용자가 실제로 구매하지 않았으나 구매 처리가 되는 경우를 방지하기 위해 구입한 항목에 대해서 영수증 검증 처리가 있어야 하는 것이지요.

 

구매이력 영수증을 가져오기 위해선 NSBundle.main.appStoreReceiptURL > Data로 변환 > base64 Encoding을 거쳐 String 값으로 반환합니다.

 

이 영수증 정보를 서버로 보내서, 서버에 저장되어 있는 영수증 정보랑 같은지 비교하면 되겠죠? 

 

만약 같다면 그에 대한 처리를 해주면 될 것 같네요!!

// 구매이력 영수증 가져오기 - 검증용
public func getReceipt(transaction: SKPaymentTransaction, productIdentifier: String) -> String {
    
    let receiptFileURL = Bundle.main.appStoreReceiptURL
    let receiptData = try? Data(contentsOf: receiptFileURL!)
    let receiptString = receiptData?.base64EncodedString(options: NSData.Base64EncodingOptions(rawValue: 0))
    
    return receiptString
}

//영수증 정보 서버로 보내기
func sendTransactionToServer(transaction: SKPaymentTransaction) {

    var params = [String: Any]()
    
    let receiptData = PaymentTransactionObserver.shared.getReceipt()
    
    params["appleLatestReceipt"] = receiptData ?? ""
    
    // 서버로 전송
    insertTransactionResult(params, completion: { status in
        if status == 200 {
            print("성공")
        } else {
            print("실패")
        }
    })
}
728x90
반응형