반응형
LinkedIn 개발자로 성장하면서 남긴 발자취들을 확인하실 수 있습니다.
Github WWDC Student Challenge 및 Cherish, Tiramisul 등 개발한 앱들의 코드를 확인하실 수 있습니다.
개인 앱 : Cherish 내 마음을 들여다보는 시간, 체리시는 디자이너와 PM과 함께 진행 중인 1인 개발 프로젝트입니다.
10년 후, 20년 후 나는 어떤 스토리 텔러가 되어 있을지 궁금하다. 내가 만약에 아직 조금 더 탐구하고 싶은 게 있고, 궁금한 게 있다면, 그게 설사 지금 당장의 내 인생에 도움이 안 되는 것 같더라도 경험해보자. 그 경험들을 온전히 즐기며 내 것으로 만들고, 내 일에 녹여내고... 그러다보면 그 점들이 모여 나란 사람을 그려내는 선이 될 테니까.

Recent Posts
Recent Comments
Total
관리 메뉴

꿈꾸는리버리

[인앱 결제하기 2] StoreKit2 코드 작성하기 본문

오뚝이 개발자/iOS

[인앱 결제하기 2] StoreKit2 코드 작성하기

rriver2 2024. 3. 6. 19:51
반응형

 💕 인앱 결제 구현 Intro 

인앱 결제를 위해서는 다음과 같은... 7가지의 단계가 필요하다 !
이번 포스팅은 이 중 4번의 내용을 다룰 예정이다.

[ 이전 포스팅 ]

  1. 유료 응용 프로그램 계약에 동의
    앱 내 구입을 제공하려면 멤버십 계정 소유자가 App Store Connect의 “계약, 세금 및 금융거래” 섹션에서 유료 응용 프로그램 계약에 동의해야 합니다.
  2. 앱 내 구입 디자인
    앱 내 구입 경험이 앱의 다른 부분과 부합하는지 확인하고 제품을 효과적으로 선보이려면 Human Interface Guidelines 및 App Store 심사 지침을 참고하십시오.
  3. App Store Connect에서 앱 내 구입 설정
    앱 내 구입을 생성하고 제품 이름, 설명, 가격 및 사용 가능 여부와 같은 메타데이터를 추가합니다. 또한 앱 내 구입 키를 생성하고 세금 카테고리를 설정해야 합니다. 이를 통해 Apple이 고객 거래에 적용되는 적절한 세금을 계산할 수 있습니다.

[ 현재 포스팅 ]

4. StoreKit 구현
Xcode에서 앱에 앱 내 구입 기능을 추가하여 Xcode의 번들 식별자 및 제품 식별자가 App Store Connect의 앱 및 앱 내 구입 식별자와 일치하는지 확인합니다.

[ 다음 포스팅 ]

5. 앱 내 구입 테스트
Apple은 “sandbox”라는 테스트 환경을 제공하고, 해당 환경에서 테스트 계정을 사용하여 추가 비용 없이 앱 내 구입을 테스트할 수 있습니다. 코드의 각 부분을 테스트하고 앱을 사용하여 앱 내 구입을 통한 코드가 올바르게 구현되었는지 확인합니다.
TestFlight 또는 Xcode를 사용하여 앱 및 앱 내 구입의 추가적인 테스트를 진행할 수 있습니다.

6. App Store Server 알림 사용
App Store 서버 알림은 거래 상태 및 앱 내 구입과 관련된 주요 이벤트(예를 들어, 환불, 구독 상태 변경 또는 “가족 공유” 액세스)의 업데이트를 실시간에 가깝게 제공합니다. 이러한 알림을 활용하려면 App Store Connect에서 프로덕션 및 sandbox 서버 환경의 URL을 입력해야 합니다.

7. 심사를 위해 앱 내 구입 제출
App Store에 앱 내 구입을 게시하기 전에 이를 심사를 위해 제출해야 합니다. 최초로 앱 내 구입을 제출하는 경우, 반드시 신규 버전의 앱을 제출해야 합니다. 제출하기 전에 필수 정보가 누락되지 않았는지 확인하십시오. 앱 내 구입의 진행 상태를 모니터링하여 앱 내 구입을 사용할 수 있는지 또는 주의가 필요한지 여부를 파악하십시오.

 

 💕 StoreKit 구현 

1️⃣ StoreKit Configuration 설정하기

이전 포스팅에서 App Store Connect에서 앱 내 구입 설정을 했다면, Xcode에서 해당 인앱 결제 프로덕트를 불러온다.

우측 그림과 같이 Sync this file with an app in App Store Connect 버튼을 클릭하면 해당 팀과 App을 불러올 수 있다

 

방금 생성한 파일에서 좌측 하단의 Synced 버튼을 클릭하면 앱스토어의 앱 내 구입에 추가해 뒀던 Product가 자동으로 연동되는 것을 확인할 수 있다.

⚠️ 앱스토어의 앱 내 구입을 변경 시 다시 Synced 버튼을 눌러서 연동시켜야 한다.

 

2️⃣ 코드 작성 전 드릴 말씀

우선 나는 Meet StoreKit 2 WWDC 영상을 보고 공부했고, 그 중에 일부 ( 소모품 )만 구현을 했다. 만약 다른 부분( 비소모품 등 )이 궁금하다면 WWDC 영상을 보고 따라 코드를 짜면서 모르는 부분을 공부하면 좋을 것 같아요.

해당 영상에 있는 코드는 여기서 다운 받아서 볼 수 있으니, 영상을 보면서 주석을 달거나 이것저것 실험해보는 용도로 사용하면 좋을 것 같다.

 

3️⃣ 소모품 구현 코드

우선 소모품의 경우에는 App Store Connect에서 Product를 불러오고 결제를 진행하는데, 이와 관련된 구매 사항은 App Store Connect에 저장되지 않는다. 그렇기 때문에 나 같은 경우는 사용자가 이전에 구매했던 내역을 확인할 수 있도록 Userdefault를 이용했다. 만약 값이 비싸고 민감한 내용이라면 서버를 이용해서 따로 값을 저장해야 한다.

 

주석에 설명을 추가해두었으니 코드를 보면서 어떤 내용인지 확인하면 좋을 것 같다.

1) 우선 StoreKit을 작업을 관리하는 ViewModel을 만들어줬다. 

class StoreKitViewModel: ObservableObject {
    
    // 앱스토어에서 불러온 Product를 저장하는 배열
    @Published private(set) var consumableProductList: [Product]
    
    // 트램젝션 관리를 위한 
    var updateListenerTask: Task<Void, Error>? = nil
    
    // plist에서 불러온 Product ID
    private let productIdToEmoji: [String: String]
    
    init() {
        
        productIdToEmoji = StoreKitViewModel.loadProductIdToEmojiData()
        consumableProductList = []
        updateListenerTask = listenForTransactions()
        
        Task {
            // App Store Connect에서 Product 불러오기
            await requestProducts()
            
            print("consumableProductList", consumableProductList)
        }
    }
    
    deinit {
        updateListenerTask?.cancel()
    }
    
    func listenForTransactions() -> Task<Void, Error> {
        return Task.detached {
            for await result in Transaction.updates {
                do {
                    let transaction = try self.checkVerified(result)
                    
                    await transaction.finish()
                } catch {
                    // 검증에 실패한 transaction이 있다면 사용자에게 전달하면 안 됨
                    print("Transaction failed verification")
                }
            }
        }
    }
}

 

참고 ) WWDC 예시 코드에서는 Product id 의 경우 아래와 같이 plist로 관리를 했다.

 

2) 앱 스토어에서 Product를 불러오는 코드이다. 

    // plist에서 Product ID 불러오기
    static func loadProductIdToEmojiData() -> [String: String] {
        guard let path = Bundle.main.path(forResource: "Products", ofType: "plist"),
              let plist = FileManager.default.contents(atPath: path),
              let data = try? PropertyListSerialization.propertyList(from: plist, format: nil) as? [String: String] else {
            return [:]
        }
        return data
    }
// AppStore에서 Product 불러오기
@MainActor
    func requestProducts() async {
        do {
            // Products.plist를 이용해서 Product 불러오기
            let storeProducts = try await Product.products(for: productIdToEmoji.keys)
            
            var newConsumableProductList: [Product] = []
            
            // categories에 따라서 불러온 product 구분하기
            for product in storeProducts {
                switch product.type {
                    // 소모품인 경우에만 consumableProductList에 담기
                case .consumable:
                    newConsumableProductList.append(product)
             // case .autoRenewable, .nonConsumable, .nonRenewable 가 더 존재
                default:
                    print("Unknown product")
                }
            }
            
            consumableProductList = newConsumableProductList
        } catch {
            print("Failed product request from the App Store server: \(error)")
        }
    }

 

3) 트렌젝션이 유효한지 확인하는 코드이다.

func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
        switch result {
        case .unverified:
            throw StoreError.failedVerification
        case .verified(let safe):
            return safe
        }
}

 

위의 StoreKitViewModel을 생성하면 App Store Connet에서 Product를 불러와 consumableProductList 변수에 저장이 된다. 필요한 View에서 해당 Product를 사용하면 되는데, 맨 처음 사용자가 앱을 열 때는 네트워크 연결이 되어야 불러올 수 있는 것 같았다. 그래서 View에서 사용할 때 storeKitViewModel.consumableDonateList의 값이 비어 있으면 네트워크를 확인해 달라는 Alert을 띄웠다.

 

아래는 button을 클릭해서 결제를 하고, 성공한 경우 결제 완료 정보를 저장하는 코드를, 실패한 경우 에러를 처리하는 코드를 작성했다.

func buyButton(_ product: Product) -> some View {
        Button {
            Task {
                await purchase(product)
            }
        } label: {
            Text(product.displayPrice)
        }
}
    
@MainActor
func purchase(_ product: Product) async {
        do {
            if try await store.purchase(product) != nil {
                // 결제된 product이기 때문에, 필요한 결제 완료 정보를 userdefault에 저장시키기
            }
        } catch {
            // 에러 처리하기
        }
}

 

참고로 Product는 다음과 같이 displayName, description, displayPrice 등에 접근이 가능하다.

4️⃣ Xcode에서 StoreKit 활성화 하기

[전체코드]

View

import SwiftUI
import StoreKit

enum ProductKey: String {
    case product1 = "consumable.fuel.octane87"
    case product2 = "consumable.fuel.octane89"
    case product3 = "consumable.fuel.octane91"
}

struct ExempleView: View {
    @AppStorage(ProductKey.product1.rawValue) var product1  = 0
    @AppStorage(ProductKey.product2.rawValue) var product2  = 0
    @AppStorage(ProductKey.product3.rawValue) var product3  = 0
    
    @StateObject var store: StoreKitViewModel = StoreKitViewModel()
    
    var body: some View {
        VStack {
            if store.consumableProductList.isEmpty {
                Text("네트워크를 확인해주세요")
            } else {
                ForEach(store.consumableProductList, id: \.id) { product in
                    Text("product: \(product.description)")
                        .padding()
                    
                    buyButton(product)
                        .padding()
                    
                    Text("이전 구매 횟수: \(amount(product))")
                        .padding()
                    
                    Divider()
                }
            }
        }
    }
    
    func buyButton(_ product: Product) -> some View {
        Button {
            Task {
                await purchase(product)
            }
        } label: {
            Text(product.displayPrice)
        }
    }
    
    @MainActor
    func purchase(_ product: Product) async {
        do {
            if try await store.purchase(product) != nil {
                // 결제된 product이기 때문에, 필요한 결제 완료 정보를 userdefault에 저장시키기
                saveDonatedProduct(productId: product.id)
            }
        } catch {
            // 에러 처리하기
        }
    }
    
    fileprivate func amount(_ product: Product) -> Int {
        switch product.id {
        case ProductKey.product1.rawValue:
            return product1
        case ProductKey.product2.rawValue:
            return product2
        case ProductKey.product3.rawValue:
            return product3
        default: return 0
        }
    }
    
    private func saveDonatedProduct(productId: String) {
        switch productId {
        case ProductKey.product1.rawValue:
            product1 += 1
        case ProductKey.product2.rawValue:
            product2 += 1
        case ProductKey.product3.rawValue:
            product3 += 1
        default: return
        }
    }
}

 

ViewModel

import Foundation
import StoreKit

typealias Transaction = StoreKit.Transaction

public enum StoreError: Error {
    case failedVerification
}

class StoreKitViewModel: ObservableObject {
    
    // 앱스토어에서 불러온 Product를 저장하는 배열
    @Published private(set) var consumableProductList: [Product]
    
    // 트램젝션 관리를 위한 
    var updateListenerTask: Task<Void, Error>? = nil
    
    // plist에서 불러온 Product ID
    private let productIdToEmoji: [String: String]
    
    init() {
        
        productIdToEmoji = StoreKitViewModel.loadProductIdToEmojiData()
        consumableProductList = []
        updateListenerTask = listenForTransactions()
        
        Task {
            // App Store에서 Product 불러오기
            await requestProducts()
            
            print("consumableProductList", consumableProductList)
        }
    }
    
    deinit {
        updateListenerTask?.cancel()
    }
    
    // plist에서 Product ID 불러오기
    static func loadProductIdToEmojiData() -> [String: String] {
        guard let path = Bundle.main.path(forResource: "Products", ofType: "plist"),
              let plist = FileManager.default.contents(atPath: path),
              let data = try? PropertyListSerialization.propertyList(from: plist, format: nil) as? [String: String] else {
            return [:]
        }
        return data
    }
    
    
    func listenForTransactions() -> Task<Void, Error> {
        return Task.detached {
            for await result in Transaction.updates {
                do {
                    let transaction = try self.checkVerified(result)
                    
                    await transaction.finish()
                } catch {
                    // 검증에 실패한 transaction이 있다면 사용자에게 전달하면 안 됨
                    print("Transaction failed verification")
                }
            }
        }
    }
    
    @MainActor
    func requestProducts() async {
        do {
            // Products.plist를 이용해서 Product 불러오기
            let storeProducts = try await Product.products(for: productIdToEmoji.keys)
            
            var newConsumableProductList: [Product] = []
            
            // categories에 따라서 불러온 product 구분하기
            for product in storeProducts {
                switch product.type {
                    // 소모품인 경우에만 consumableProductList에 담기
                case .consumable:
                    newConsumableProductList.append(product)
             // case .autoRenewable, .nonConsumable, .nonRenewable 가 더 존재
                default:
                    print("Unknown product")
                }
            }
            
            consumableProductList = newConsumableProductList
        } catch {
            print("Failed product request from the App Store server: \(error)")
        }
    }
    
    func purchase(_ product: Product) async throws -> Transaction? {
        // 사용자가 결제할 시 product.purchase() 함수 호출하기
        let result = try await product.purchase()
        
        // result에 따라 처리하기
        switch result {
        case .success(let verification):
            // transaction이 verified한지 확인하기
            let transaction = try checkVerified(verification)
            
            //Always finish a transaction.
            await transaction.finish()
            
            return transaction
        case .userCancelled, .pending:
            return nil
        default:
            return nil
        }
    }
    
    func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
        switch result {
        case .unverified:
            throw StoreError.failedVerification
        case .verified(let safe):
            return safe
        }
    }
}

 

[참고]

https://developer.apple.com/videos/play/wwdc2021/10114/

 

Meet StoreKit 2 - WWDC21 - Videos - Apple Developer

StoreKit 2 delivers powerful, Swift-native APIs for in-app purchases and auto-renewable subscriptions. Learn how you can easily implement...

developer.apple.com

https://developer.apple.com/documentation/xcode/setting-up-storekit-testing-in-xcode/#Disable-StoreKit-Testing-in-Xcode

 

Setting up StoreKit Testing in Xcode | Apple Developer Documentation

Prepare your test environment to test in-app purchases with data you configure locally.

developer.apple.com

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

 

반응형
Comments