- Total
꿈꾸는리버리
[인앱 결제하기 2] StoreKit2 코드 작성하기 본문
💕 인앱 결제 구현 Intro
[인앱 결제하기 1] 사업자 등록증 + Appstore Connect 준비단계
[인앱 결제하기 3] 샌드박스 결제, 인앱 결제 심사 방법
[인앱 결제하기 5] 자동 갱신의 신규 특가
[인앱 결제하기 삽질 모음 Zip]
인앱 결제를 위해서는 다음과 같은... 7가지의 단계가 필요하다 !
이번 포스팅은 이 중 4번의 내용을 다룰 예정이다.
[ 이전 포스팅 ]
- 유료 응용 프로그램 계약에 동의
앱 내 구입을 제공하려면 멤버십 계정 소유자가 App Store Connect의 “계약, 세금 및 금융거래” 섹션에서 유료 응용 프로그램 계약에 동의해야 합니다. - 앱 내 구입 디자인
앱 내 구입 경험이 앱의 다른 부분과 부합하는지 확인하고 제품을 효과적으로 선보이려면 Human Interface Guidelines 및 App Store 심사 지침을 참고하십시오. - App Store Connect에서 앱 내 구입 설정
앱 내 구입을 생성하고 제품 이름, 설명, 가격 및 사용 가능 여부와 같은 메타데이터를 추가합니다. 또한 앱 내 구입 키를 생성하고 세금 카테고리를 설정해야 합니다. 이를 통해 Apple이 고객 거래에 적용되는 적절한 세금을 계산할 수 있습니다.
[ 현재 포스팅 ]
4. StoreKit 구현
Xcode에서 앱에 앱 내 구입 기능을 추가하여 Xcode의 번들 식별자 및 제품 식별자가 App Store Connect의 앱 및 앱 내 구입 식별자와 일치하는지 확인합니다.
[ 다음 포스팅 ]
6. App Store Server 알림 사용
App Store 서버 알림은 거래 상태 및 앱 내 구입과 관련된 주요 이벤트(예를 들어, 환불, 구독 상태 변경 또는 “가족 공유” 액세스)의 업데이트를 실시간에 가깝게 제공합니다. 이러한 알림을 활용하려면 App Store Connect에서 프로덕션 및 sandbox 서버 환경의 URL을 입력해야 합니다.
💕 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/
'오뚝이 개발자 > iOS' 카테고리의 다른 글
[Google Analytics 1 ] SwiftUI/ GA 이게 뭐고 어떻게 사용하나요? (0) | 2024.03.10 |
---|---|
[인앱 결제하기 3] 샌드박스 결제, 인앱 결제 심사 방법 (1) | 2024.03.06 |
[인앱 결제하기 1] 사업자 등록증 + Appstore Connect 준비단계 (1) | 2024.02.19 |
[가슴속 3천원] 회고록 (0) | 2023.10.15 |
[Error] iOS 외국 앱 이름 중복 (0) | 2023.09.10 |