XLOG

[Swift] IAP, 인앱결제 StoreKit2 본문

Swift

[Swift] IAP, 인앱결제 StoreKit2

X_PROFIT 2024. 5. 19. 22:04

IAP 는 In-App-Purchase 의 약자이다.

우선 인앱결제를 하기 위해선 개발자계정, 상품등록된 앱, 또한 그 앱의 앱내구입 상품 등록이 필요하다.

앱스토어에 앱을 올리면 수익화 카테고리에 앱 내 구입 항목이 있다.

클릭하면 내 앱에 인앱결제를 할 수 있는 항목 추가가 가능하다.

위에 과정을 보면 알 수 있듯이 결제는 앱스토어 서버를 통해 이루어 진다.

우리의 앱은 앱스토어 서버를 통해 Transaction 을 받아서 인앱 결제 로직을 처리한다.

인앱결제 상품은 총 4가지가 있다.

  • 소모성 항목
  • 비소모성 항목
  • 자동 갱신 구독
  • 비갱신형 구독

그럼 In App Purchase 의 테스트 환경을 만들어보자. 실제 공부하는 사람들은 개발자 계정 및 출시한 앱이 없을 수 있다. 하지만 StoreKit에서는 StoreKit Configuration File을 제공함으로서 전반적인 플로우를 테스트할 수 있는 환경을 제공한다.

StoreKit Configuration 파일은 우리가 앱 테스트를 하는 환경에서 우리 앱의 앱내구입 상품을 등록한 것과 같은 가상환경을 제공해 준다고 생각하면 된다.

이 Configuration을 적용하기 위해선 Edit Scheme을 통해서 해당 Scheme에 옵션에 StoreKit Configuration 을 적용해주면 된다.

이 다음 코드를 통해 살펴보자.

import Foundation
import StoreKit

typealias Transaction = StoreKit.Transaction

class Store: ObservableObject {
	@Published private var products: [Products]
	
	var updateListenerTask: Task<Volid, Error>? = nil
	
	init() {
		self.products = []
		
		// 앱이 닫힐 때까지 Transaction 변화를 추적한다.
		updateListenerTask = listenForTransactions()
		
		Task {
			await self.requestProducts()
			await self.updateCustomerProductStatus()
		}
	}
	
	deinit() {
		updateListenerTask?.cancel()
	}
	
	// 상품 정보를 가지고 온다.
	@MainActor
	func requestProducts() async {
		do {
			let productsIDS: [String] = ["com.practice.ipa.products1"]
			
			// App Store 서버로 부터 상품 Id 값을 통해 내가 등록한 product 배열을 가져올 수 있다.
			let products = try await Product.products(for: productsIDS)
			self.products = products
		} catch {
			print("error")
		}
	}
	
	func listenForTransactions() -> Task<Void, Error> {
        return Task.detached {
            //Iterate through any transactions that don't come from a direct call to `purchase()`.
            for await result in Transaction.updates {
                do {
                    let transaction = try self.checkVerified(result)

                    //Deliver products to the user.
                    await self.updateCustomerProductStatus()

                    //Always finish a transaction.
                    await transaction.finish()
                } catch {
                    //StoreKit has a transaction that fails verification. Don't deliver content to the user.
                    print("Transaction failed verification")
                }
            }
        }
    }
	
	
	// 해당 Transaction 이 앱스토어에 해당 트랜젝션의 유효성을 검사한다.
  func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
    //Check whether the JWS passes StoreKit verification.
    switch result {
    case .unverified:
        //StoreKit parses the JWS, but it fails verification.
        throw StoreError.failedVerification
    case .verified(let safe):
        //The result is verified. Return the unwrapped value.
        return safe
    }
	}
	
	/// 구매 요청 시 호출하는 함수
  func purchase(_ product: Product) async throws -> Transaction? {
    //Begin purchasing the `Product` the user selects.
    let result = try await product.purchase()

    switch result {
    case .success(let verification):
        //Check whether the transaction is verified. If it isn't,
        //this function rethrows the verification error.
        let transaction = try checkVerified(verification)

        //The transaction is verified. Deliver content to the user.
        await updateCustomerProductStatus()
        
        
        await transaction.finish()
        return transaction

        //Always finish a transaction.
        await transaction.finish()

        return transaction
    case .userCancelled, .pending:
        return nil
    default:
        return nil
    }
  }
  // currentEntitlements 를 통해 AppStore의 구매내역과 현재 앱의 상태를 동기화를 시켜준다  
  func updatePurchasedProductStatus() async {

	  for await result in Transaction.currentEntitlements {
		  let transaction = try checkVefified(result)
		  // 유효하다면 해당 트랜젝션의 아이디값을 통해 구매한 상품에 대한 상태를 업데이트 해준다.
	  }
  }

}

흐름을 보면 간단하다.

  1. AppStore Server 로 부터 내가 등록한 상품리스트를 받아온다.
  2. 해당 상품 구매 요청은 Product.purchase() 를 통해 시작한다.
  3. Purchase 가 반환하는 purchaseResult 를 통해 유효성 검사를 진행한다.
  4. 유효성 검사가 완료되면 앱스토어서버와 앱의 상태를 동기화 시켜준다
  5. Transaction 을 종료하여 반환한다.

https://developer.apple.com/documentation/storekit/in-app_purchase/implementing_a_store_in_your_app_using_the_storekit_api

위에 StoreKit Sample 코드를 보면 더 많은 작업에 대한 확인이 가능하다.

단순히 앱만 보면 모든 프로세스는 크게 어렵지 않았다. 하지만 회사 CTO 의 요청사항은 단순히 App Store 와 통신하는 것이 아닌 결재가 완료 되었을 때 우리 회사에서 운영하는 서버에도 해당 사항을 반영하기를 원했다.

이전 StoreKit 1에서는 AppStore Transaction을 받아 영수증정보를 받고, 그 영수증 정보를 우리 서버에 전달한다. 우리 서버는 그 영수증 데이터를 통해 앱스토서버에 유효한지 아닌지를 확인하고, 그 결과값을 통해 우리 서버는 해당 상태를 업데이트 할 수 있었다.

하지만 StoreKit2 에서는 영수증 데이터를 사용하는 것이 아닌 originalTransactionID 값을 대체해서 사용하면 된다.

보안상의 이슈로 변경이 되었다고 했지만…아직 그 부분까지 정확하게 확인이 되진 않았다.

또한 여기서의 문제점에 대한 고민이 생겼다.

앱스토어상의 결제는 완료 되었으나, 우리 서버상태로 인하여 완료 처리를 하지 못했을 경우의 문제이다. 이미 결제는 완료 된 상태일텐데…그렇다면 결제를 취소해야 하는지…아니면 해당 Transaction 정보를 암호화하여 저장하여, 서버와의 통신이 끝날때까지 백그라운드에서 처리를 지속적으로 해줘야 하는 것인지였다..회사 운영상 결제를 취소하는 것 보단 당연히 어떻게든 결제처리를 완료하는 것이 맞지만, 이런 경우 해당 상품 구매에 대한 처리를 우리 서버에 저장된 데이터로 할 지, 앱스토어 서버의 currentEntitlements로 해야하는지…이다….

경험이 없다보니, 여러 케이스는 예상이 되기 때문에 더더욱 어떤처리가 맞는지 헷갈린다. 인앱결제는 앱스토어 결제이기 때문에 해당 디바이스 앱스토어 아이디로 구매 여부를 확인하는 것이라는 생각이 들었고, 그렇다면 우리 앱을 한 사람이 여러아이디를 사용할 때 구매여부를 따지는 것은 부족하다는 생각이 들기 때문이다.

애초에 인앱결제를 하기 전 우리 서버 상태를 확인한 후 진행을 해야하는 것이 가장 안전한 방법이 아닐까 생각이 드는데….이런 저런 상황들이 다 나의 생각속에서만 이루어지다 보니 정답을 내리는것에 있어 더 어려움을 느끼는 것 같다…..

참고자료

https://developer.apple.com/wwdc21/10114

https://developer.apple.com/videos/play/wwdc2023/10140/

'Swift' 카테고리의 다른 글

Fatal error: Duplicate keys of type 'Node' were found in a Dictionary 해결  (0) 2024.08.16
[Swift] CoreData  (0) 2024.06.08
[Swift] Concurrency  (0) 2024.05.19