본문 바로가기

EXPERIENCE/iOS

[Xcode/iOS] Swift 앱스토어(AppStore) 인앱결제(In-App Purchase) 구현하기 (with. Sandbox 테스트)

728x90
728x90

 

 

 

이번 포스팅은 앱스토어(AppStore) 인앱결제(In-App Purchase)를 Swift로 구현하는 방법이다.

인앱결제 기능을 적용하기 위해서는 유료 앱 기능을 활성화해야하며 해당 방법은 아래 포스팅을 참고하면 된다.

 

 

유료 앱 활성화하기

 

 

[Xcode/iOS] Swift 앱스토어(AppStore) 인앱결제(In-App Purchase)를 위한 유료 앱 설정

인앱결제를 진행하고자 했는데 그 전에 진행해야 하는 것들이 많았다...ㅜㅜ 우선 사업자등록을 먼저 진행하고 Apple Store Connect에서 유료 앱 등록을 해야한다. 승인 절차 등이 있어 시간이 소요되

s-o-h-a.tistory.com

 

 

 

728x90

 

 

 

인앱결제 상품 등록
 

https://appstoreconnect.apple.com/

 

appstoreconnect.apple.com

 

1. Apple Store Connect 페이지 > 나의 앱 > 앱 내 추가 기능 > 앱 내 구입 메뉴 이동
2. 앱 내 구입 옆의 + 버튼 혹은 생성을 클릭

 

종류 설명
소모품 한번 사용하면 고갈되어 다시 구입해야 하는 제품입니다. 예시: 낚시 앱에서의 미끼.
비소모품 한 번 구입한 후 사용 시 만료되거나 수량이 감소하지 않는 제품입니다. 예시: 게임 앱의 레이스 트랙.
3. 유형 : 소모품 또는 비소모품 선택
4. 식별 정보 : 개발자 혹은 등록자가 알아볼 수 있는 상품명
5. 제품 ID : 해당 상품의 ID, ex) com.soha.example.product 와 같은 형태로 지정

 

++ 구독(갱신, 비갱신) ++

구독 형태의 상품은 구독 탭에서 추가가능한 것으로 보임

 

6. 상품에 대한 상세 내용을 입력 (가격, 현지화 등)
7. 하단 심사정보를 작성
8. 저장 클릭

 

9. 저장 후에 다시 앱 심사를 통해 제출을 해야함

 

 

 

인앱결제를 위한 IAPHelper 구현

 

  • 인앱 상품 식별자 로드
 

Apple Developer Documentation

 

developer.apple.com

 

  • 인앱 구매 제공, 완료 및 복원

 

 

Apple Developer Documentation

 

developer.apple.com

 

 

 

import StoreKit
IAP(In-App Purchase)를 도와주는 매니저 혹은 Helper를 구현하기 위해서는 StoreKit을 사용해야함

 

  • IAPHelper Class
public typealias ProductsRequestCompletionHandler = (_ success: Bool, _ products: [SKProduct]?) -> Void

class IAPHelper: NSObject {
    // 전체 상품
    private let productIdentifiers: Set<String>
    // 구매한 상품
    private var purchasedProductIDList: Set<String> = []
    private var productsRequest: SKProductsRequest?
    private var productsRequestCompletionHandler: ProductsRequestCompletionHandler?
    
    
    public init(productIds: Set<String>) {
        productIdentifiers = productIds
        self.purchasedProductIDList = productIds.filter { UserDefaults.shared.bool(forKey: $0) == true }
        super.init()
        // App Store와 지불정보를 동기화하기 위한 Observer 추가
        SKPaymentQueue.default().add(self)
    }
    
    // App Store Connect 인앱결제 상품 로드 요청
    func loadsRequest(_ completionHandler: @escaping ProductsRequestCompletionHandler) {
        productsRequest?.cancel()
        productsRequestCompletionHandler = completionHandler
        productsRequest = SKProductsRequest(productIdentifiers: productIdentifiers)
        productsRequest?.delegate = self
        productsRequest?.start()
    }
}

NSObject를 상속받아야한다
- productIdentifiers : 전체 상품 리스트
- purchasedProductIDList : 구매한 상품 리스트
위 설명과 같이 특정 앱에 대해 모든 상품 ID를 가져오는 메커니즘이 없기 때문에 앱 자체에 ID 리스트가 있어야 한다.
그리고 IAPHelper를 초기화 할 때 Set<String> 형식으로 상품 ID들을 받아 저장하고, UserDefaults를 이용하여 기 구매 내역을 확인하여 별도로 관리한다.

 

//MARK: - SKProductsRequestDelegate
extension IAPHelper: SKProductsRequestDelegate{
    // 상품 리스트 로드 완료
    public func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
        let products = response.products
        productsRequestCompletionHandler?(true, products)
        clearRequestAndHandler()
    }
    // 상품 리스트 로드 실패
    public func request(_ request: SKRequest, didFailWithError error: Error) {
        productsRequestCompletionHandler?(false, nil)
        clearRequestAndHandler()
    }
    //핸들러 초기화
    private func clearRequestAndHandler() {
        productsRequest = nil
        productsRequestCompletionHandler = nil
    }
}
- SKProductsRequestDelegate : App Store에 보낸 상품 리스트 요청에 대한 응답을 받는 delegate이다.

 

 

 

Apple Developer Documentation

 

developer.apple.com

//MARK: - 구매 이력
extension IAPHelper {
    // 구매이력 영수증 가져오기
    func getReceiptData() -> String? {
        if let appStoreReceiptURL = Bundle.main.appStoreReceiptURL,
            FileManager.default.fileExists(atPath: appStoreReceiptURL.path) {
            do {
                let receiptData = try Data(contentsOf: appStoreReceiptURL, options: .alwaysMapped)
                let receiptString = receiptData.base64EncodedString(options: [])
                return receiptString
            }
            catch {
                print("Couldn't read receipt data with error: " + error.localizedDescription)
                return nil
            }
        }
        return nil
    }
    // 구입 이력 복원
    func restorePurchases() {
        for productID in productIdentifiers {
            UserDefaults.shared.set(false, forKey: productID)
        }
        SKPaymentQueue.default().restoreCompletedTransactions()
    }
}
- getReceiptData() : 구현은 해두었으나 보안 서버를 통해 확인해야 하는 영수증이므로 사용할 일은 거의 없을 것 같다
- restorePurchases() : 나는 개인적으로 구매 이력 복원 전 각 상품 ID의 UserDefaults Bool값을 모두 false로 초기화 한 후,
이력을 복원하여 구매 된 것 만 Bool 값을 true로 변경하였다

 

//MARK: - 구매
extension IAPHelper {
    // 상품 구입
    func buyProduct(_ product: SKProduct) {
        SystemManager.shared.openLoading()
        let payment = SKPayment(product: product)
        SKPaymentQueue.default().add(payment)
    }
    // 이미 구매한 상품인가
    func isProductPurchased(_ productID: String) -> Bool {
        return self.purchasedProductIDList.contains(productID)
    }
}
- buyProduct() : 상품 구매 시, 오래 걸리는 경우가 있어 로딩 창을 띄워 다른 입력을 막았다.
- isProductPurchased() : purchasedProductIDList를 통해 구매 여부를 확인한다.

 

//MARK: - SKPaymentTransactionObserver
extension IAPHelper: SKPaymentTransactionObserver {
    func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
        for transaction in transactions {
            let state = transaction.transactionState
            switch state {
            case .purchased:
                complete(transaction: transaction)
            case .failed:
                fail(transaction: transaction)
            case .restored:
                restore(transaction: transaction)
            case .deferred, .purchasing:
                break
            default:
                SystemManager.shared.closeLoading()
            }
        }
    }
    // 구입 성공
    private func complete(transaction: SKPaymentTransaction) {
        deliverPurchaseNotificationFor(identifier: transaction.payment.productIdentifier)
        SKPaymentQueue.default().finishTransaction(transaction)
    }
    // 복원 성공
    private func restore(transaction: SKPaymentTransaction) {
        guard let productIdentifier = transaction.original?.payment.productIdentifier else { return }
        // 구매한 인앱 상품 키에 대한 UserDefaults Bool 값 변경
        purchasedProductIDList.insert(productIdentifier)
        print("restore = \(productIdentifier)")
        UserDefaults.shared.setValue(true, forKey: 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)")
        }
        deliverPurchaseNotificationFor(identifier: nil)
        SKPaymentQueue.default().finishTransaction(transaction)
    }
    // 구매 완료 후 조치
    private func deliverPurchaseNotificationFor(identifier: String?) {
        if let identifier = identifier {
            // 구매한 인앱 상품 키에 대한 UserDefaults Bool 값 변경
            purchasedProductIDList.insert(identifier)
            UserDefaults.shared.setValue(true, forKey: identifier)
            // 성공 노티 전송
            NotificationCenter.default.post(
                name: .IAPServicePurchaseNotification,
                object: (true, identifier)
            )
        } else {
            // 실패 노티 전송
            NotificationCenter.default.post(
                name: .IAPServicePurchaseNotification,
                object: (false, "")
            )
        }
        SystemManager.shared.closeLoading()
    }
}
- SKPaymentTransactionObserver : Payment Queue에 대한 Observer
- paymentQueue(SKPaymentQueue, updatedTransactions: [SKPaymentTransaction]) : 하나 이상의 트랜잭션이 업데이트되었을 때 실행되며 5가지의 상태로 나뉜다
- deliverPurchaseNotificationFor() : 구매 완료(실패&성공) 후 실행되는 함수로 NotificationCenter를 이용하여 알림을 보내도록 구현함

 

// IAP 노티 구독
private func addNoti() {
    NotificationCenter.default.addObserver(
      self,
      selector: #selector(handlePurchaseNoti(_:)),
      name: .IAPServicePurchaseNotification,
      object: nil
    )
}
@objc private func handlePurchaseNoti(_ notification: Notification) {
    guard let result = notification.object as? (Bool, String) else { return }
    let isSuccess = result.0
    if isSuccess {
        switch result.1 {
        case IAPCustomTab:
            moveCustomUITab()
        case IAPAdMob, IAPPremium:
            PopupManager.shared.openOkAlert(self, title: "알림", msg: "구매가 완료되었습니다\n앱을 종료하고 다시 실행해주세요") { action in
                UIApplication.shared.perform(#selector(NSXPCConnection.suspend))
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
                    exit(0)
                }
            }
        default:
            break
        }
    } else {
        PopupManager.shared.openOkAlert(self, title: "알림", msg: "구매 중 오류가 발생하였습니다\n다시 시도해주시기 바랍니다")
    }
}
- addNoti() : IAP 구매 관련 Notification 구독
- handlePurchaseNoti() : Notification 결과에 따른 핸들러

 

  • SKPaymentTransactionState
 

Apple Developer Documentation

 

developer.apple.com

상태 설명
purchasing App Store에서 처리 중인 트랜잭션
purchased 성공적으로 처리된 트랜잭션
failed 실패한 트랜잭션
restored 사용자가 이전에 구매한 콘텐츠를 복원하는 트랜잭션
deferred 대기열에 있지만 최종 상태가 구매 요청과 같은 외부 작업 보류 중인 트랜잭션

 

 

 

인앱결제 사용하기
class SystemManager {
    static let shared = SystemManager()
    private init() {
    	// IAPHelper 생성
        iAPManager = IAPHelper(productIds: Set<String>([IAPCustomTab, IAPAdMob, IAPPremium]))
    }
    
    private var iAPManager:IAPHelper
}

//MARK: - IAP
extension SystemManager {
    // set IAP
    func initIAP() {
        iAPManager.loadsRequest({ [self] success, products in
            if success {
                guard let products = products else { return }
                productList = products
                // 구매 이력 복원
                iAPManager.restorePurchases()
            } else {
                print("iAPManager.loadsRequest Error")
            }
        })
    }
    // 구매 확인
    func isProductPurchased(_ productID: String) -> Bool {
        return iAPManager.isProductPurchased(productID)
    }
    // 구매
    func buyProduct(_ productID: String) {
        openLoading()
        for product in productList {
            if product.productIdentifier == productID {
                iAPManager.buyProduct(product)
                break
            }
        }
    }
}
싱글톤으로 사용되며 System관리 용도인 SystemManager에서 IAPHelper를 관리하도록 구현했다.
SystemManager 생성과 함께 IAPHelper가 생성된다
- initIAP() : App Store에 상품 리스트 요청 및 성공 시 SKProduct 형태의 상품 리스트 저장 후, 구매 이력을 복원한다.
- isProductPurchased() : 구매된 상품인지 확인
- buyProduct() : SKProduct 형태의 상품 리스트에서 전달받은 상품ID를 비교하여 해당 상품을 찾고 구매 요청을 한다.

 

class AppDelegate: UIResponder, UIApplicationDelegate {
	// IAP 세팅
	SystemManager.shared.initIAP()
}
AppDelegate에서 해당 함수를 실행시키도록 구현했다.

 

 

 

승인을 위한 구매항목 복원 메커니즘(Restor Mechanism)

번역하면 간단히 인앱 구매 항목의 복원 메커니즘은 어디에서 찾을 수 있습니까?이다.
앱 심사 시 구매항목 복원 메커니즘이 없을 시 심사에 문제가 생길 수 있다 ㅜㅜ

개인적으로 App Store에 상품 정보를 요청하고 성공할 경우 바로 구매 이력을 복원하도록 구현했는데,
외부적으로 노출되어 있지 않아서 인지 반려가 되었다...........

우선 스크린샷과 함께 회신을 보냈는데 내부적으로 구현한 것도 통과가 되는지 결과가 나오면 글을 수정할 예정이다!

 

++ 앱 심사 결과 ++

AppDelegate에서 자동으로 구매 이력 복원을 실행하는 메커니즘을 넣고 제출했으나
이로써는 심사를 통과할 수 없다는 것을 알았다 ㅎㅎ...
이후 직접 유저가 클릭하여 복원을 실행할 수 있도록 추가하고 심사에 통과할 수 있었다 :)

 

 

 

 

Sandbox 테스터 등록

1. Apple Store Connect 페이지 > 사용자 및 엑세스 > Sandbox 테스터 메뉴 진입
2. 상단의 + 버튼 혹은 신규 Sandbox 테스터 추가 클릭
3. 신규 테스터 등록
(실제 이메일이 아니어도 가능하며 실존하는 AppleID가 아니어야함)

 

4. 테스트할 기기의 설정 > 중간에 위치한 App Store 메뉴 진입
5. 하단의 샌드 박스 계정 로그인
(기존에는 본인의 AppleID로 로그인 되어 있을 수 있으니, 클릭하여 로그아웃 후 새로 로그인 진행)

 

6. 로그인 된 계정을 확인
7. 실제 구입이 가능한지 테스트 진행

 

 

 

 

처음엔 앞이 컴컴했지만 인앱결제도 생각보다 어렵지 않다는 걸 느꼈다!

다음에 서버를 제대로 도입하면 구독 관련 부분도 진행해보고자 한다.

인앱결제 구현을 하면서 아래 포스팅들이 아주 많은 도움이 되었다 :)

 

 

 

 

 

참고사이트

 

 

[Swift] 인앱 결제 구현

안녕하세요 에밀리 입니다

twih1203.medium.com

 

 

 

[iOS - swift] 2. StoreKit - IAP (In App Purchases, 인앱 결제) 적용 방법 (코딩 및 구현)

1. StoreKit - IAP (In App Purchases, 인앱 결제) 사용 방법 (Sandbox, 인앱 결제 앱 등록) 2. StoreKit - IAP (In App Purchases, 인앱 결제) 적용 방법 (코딩 및 구현) 3. StoreKit - SwiftyStoreKit을 이용하여 IAP (In App Purchase)

ios-development.tistory.com

 

 

 

728x90
728x90