프로젝트에 인앱결제를 넣으며 죽음의 며칠을 보냈던 적이 있다... 왜냐면 인앱결제에 대한 정보가 구글링을 해도 정말 적게 나왔기 때문...
이제는 어느정도 정상화가 되고 결제도 원활히 되고 있다!!
사실 iOS의 인앱결제는 생각보다 클라이언트가 할 일이 없다. (사실상 앱스토어와 서버가 다 해주는 격)
클라이언트가 처리 해야할 프로세스는 다음과 같다
- App Store Connect에 등록된 상품 불러오기
- 받아온 상품에 대한 정보를 자체 서버로 보내주기 (결제한 transactionID를 보내주며 서버가 해당 결제가 진짜인지 가짜인지 검증 과정을 거쳐서 클라에게 결과를 보내줌)
- 검증 완료 후 App Store에게 결제 요청
생각보다 복잡하진 않다.. 그럼에도 쉽지는 않음 ㅋㅋ
사실 인앱결제를 넣기 위해서는 사업자 등록이 되어 있어야 한다.
사업자 등록 후, App Store Connect에서 계약, 금융 및 세금 거래에 들어간다.
유료 앱과 무료 앱 모두 메타데이터를 넣어 진행 상태를 활성 상태로 만들어둔다.
다시 나의 앱으로 돌아와서 앱 내 추가 기능에 있는 앱 내 구입과 구독에 원하는 상품을 등록한다.
앱 내 구입은 일회성으로 구매하는 소모품/비소모품을 등록할 수 있고, 구독은 알다시피 정기적으로 결제를 하여 혜택을 줄 수 있도록 하는 상품이다. 각자의 상황에 맞기 금액과 이름, 설명, 스크린샷 등을 등록해서 구입 및 구독 상품에 대한 심사를 넣는다.
프로젝트로 들어와 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를 만들어서 통신에 사용한다!
...
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["여기에 프로덕트 인덱스 번호])
얘를 원하는 함수에 넣어주면 끝!!!!