일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | ||||||
2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | 24 | 25 | 26 | 27 | 28 |
- JS
- swift
- IOS프로그래밍
- 우리를위한프로그래밍
- Python3
- 파이썬중급
- nodeJS
- 인프런파이썬강의
- SwiftUI
- 프로그래머스
- 인프런오리지널
- IOS
- 자바스크립트
- 카카오톡채팅봇
- 인프런강의
- 토플공부수기
- 파이썬웹크롤링
- 인프런파이썬
- 리프2기
- 노드JS
- 파이썬
- 토플
- 인프런
- 파이썬중급강의
- 유학토플
- 스위프트
- rxswift
- 웹크롤링
- 교환학생토플
- uikit
- Today
- Total
먹고 기도하고 코딩하라
StoreKit2으로 결제 모듈 마이그레이션하기 본문
이번에 회사 앱이 iOS 15.0을 minimum deployment로 올리게 되었다. 여러 가지 이유가 있는데, 이유 중 하나는 StoreKit1이 iOS 18.0 이상 버전에서 deprecated된다는 사실이었다.
대신 Swift 기반의 IAP 방식을 사용해서 인앱 결제를 구현하도록 권유하고 있다.
StoreKit1과 StoreKit2 사이 차이점 몇 가지는 다음과 같다:
- 더 이상 영수증 데이터 자체를 갖고 구매를 검증하지 않는다. transactionId와 구매 시도 환경만 필요하다.
- 구매 복원은 앱스토어 자체적으로 진행한다. (때문에 기존에 영수증을 갖고 서버 요청하는 절차가 있었다면, 이제 하지 않아도 된다)
- SKProductsRequest, SKProductResponse, SKPaymentTransaction, SKPaymentQueue 등 StoreKit1에서 사용하던 것을 더이상 사용하지 않는다.
기존에 결제를 하려면,
- SKProductsRequest(productIdentifiers:)를 요청해 상품 구매 요청 객체를 만든 후, delegate를 설정한 다음 request.start()를 해준다.
- 요청에 대한 응답이 오면 delegate로 구현한 메소드에서 [SKProduct]를 response로 받았다.
- 구매 파라미터로 넘어온 것들 중에 시그니쳐나 프로모션 코드가 있다면 프로모션 결제로 분기하고, 그렇지 않다면 일반적인 결제를 시도하게 된다. 이 과정에서는 SKMutablePayment(product:)로 결제 객체를 받아 SKPaymentQueue에 추가하게 되는데, 결제가 완료되면 SKPaymentTransaction을 받을 수 있었다.
- 그럼 이 transaction.transactionState에 따라 최종적으로 분기를 하면 된다. .purchased라면 구매 완료이므로 구매 후 멤버십이나 상품을 넣어주는 처리를 하면 되고, .failed, .deferred의 경우에는 결제 실패 및 지연이므로 그에 맞는 처리를 해주면 된다.
결제 흐름을 알면 그렇게 어려울 건 없지만 delegate를 해줄 객체가 필요하고, 결제 요청을 큐에 집어넣고 상태를 관찰하는 메소드 등을 추가로 작성해줘야 한다. 위에서 아래로 읽을 수 있는 코드가 아니다보니, 흐름이 탁 끊기는 것도 감수해야 한다.
StoreKit2에서는 async-await과 Task를 사용해 조금 더 읽기 쉽고 관리하기 쉬운 코드를 짤 수 있다. 지금부터 살펴보자.
StoreKit2로 마이그레이션하기
1. 상품 불러오기
StoreKit2에서 상품을 불러올 때는 상품 identifier가 필요하다. 이 identifier는 스토어에 미리 등록해둔 상품 코드와 동일하다. 상품은 여러 개 불러올 수도 있지만, 여기서는 단일 상품만 구매한다고 가정하자.
예전 StoreKit 코드에서는 매니저 클래스를 하나 생성해서 이 클래스가 상품 목록을 요청하고, 받아놓은 컴플리션 핸들러로 요청이 끝나면 request, response를 넘길 수 있다.
// 신버전 코드
let product = try await Product.products(for: [productId]).first
// 구버전 코드
class IAPManager: SKPaymentTransactionObserver, SKProductsRequestDelegate {
var products: [AnyHashable]?
var request: SKProductsRequest?
requestProductsHandler: completion method
func requestProducts(*completion method*) {
request = SKProductsRequest(productIdentifiers: productIdentifiers)
request?.delegate = self
requestProductsHandler = completion
request?.start()
}
func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
products = response.products
self.request = nil
requestProductsHandler?(request, response)
}
}
2. 상품 옵션 만들기
정상가 상품의 경우 옵션이 따로 필요하지 않지만, 프로모션 구매의 경우에는 옵션이 필요하다. iOS 15.0부터 사용할 수 있는 프로모션 구매에 필요한 옵션은 다음과 같은 파라미터를 갖는다.
// 신버전
let option: Set<Product.PurchaseOption> = [.promotionalOffer(
offerID: String,
keyID: String,
nonce: UUID,
signature: Data,
timestamp: Int
)]
// 구버전
let discountOffer = SKPaymentDiscount(
identifier: String,
keyIdentifier: String,
nonce: UUID,
signature: String,
timestamp: NSNumber
)
let payment = SKMutablePayment(product: SKProduct)
payment.paymentDiscount = discountOffer
프로모션 구매인 경우에는 각 항목을 채워서 옵션을 만들면 된다.
3. 구매 시도
// 신버전
guard let purchaseResult = try await product?.purchase(options: option) else { return }
// 구버전
// completion 메소드로 (SKProductsRequest?, SKProductsResponse?) -> Void를 넘길 수 있다
guard let product = IAPManager.shared.products?.first as? SKProduct else { return }
let payment = SKMutablePayment(product: SKProduct)
SKPaymentQueue.default().add(payment)
product.purchase(options: Set<Product.PurchaseOption>)으로 상품 구매를 시도한다.
구매 완료까지 시간이 조금 걸리므로, await를 걸어서 구매 성공/실패 여부에 따른 행위는 반드시 응답을 받고 나서 할 수 있도록 한다.
4. 구매 결과에 따른 분기
결과는 5가지로 나뉜다.
- 구매 성공 & 인증 성공 (.success(.verified(Transaction))
- 구매 성공 & 인증 실패 (.success(.unverified(Transaction, VerificationError)
- SCA, Ask to Buy(부모, 보호자 동의가 필요한 아동 계정의 구매)로 인한 대기 (.pending)
- 취소 (.userCancelled)
- 알 수 없음 (@unknown default)
switch문으로 나눠 작성해주면 되는데, .success라도 .unverified 경우에는 구매 인증에 실패한 것이므로 완전한 성공이 아니다. 그러므로 .success(.verified(Transaction))인 경우만 성공한 것으로 간주해 다음으로 진행하면 된다.
트랜잭션 완료를 위해 finish()를 호출한다.
// 신버전
switch purchaseResult {
case let .success(.verified(Transaction)):
await transaction.finish()
// 구매 성공 시의 처리
default: break
}
// 구버전
func handleTransaction(transaction: SKPaymentTransaction?) {
let transactionState = transaction?.transactionState
switch transactionState {
case .purchased: // 구매됨
case .purchasing: // 서버 큐에 트랜잭션이 올라가 있는 상태
case .failed: // 서버 큐에 트랜잭션이 올라가기 전 취소되거나 실패함
case .restored: // 유저의 구매 내역에서 트랜잭션이 복구됨
case .deferred: // 트랜잭션이 큐ㅜ에 있지만 대기 상태로 멈춤
}
}
5. 트랜잭션 리스너 추가
이론상으로는 이렇게 하면 결제가 완료되긴 하지만, 실제로 결제 테스트를 해봤을 때는 보라색 삼각형과 함께 경고문구가 나타난다.
Making a purchase without listening for transaction updates risks missing successful purchases. Create a Task to iterate Transaction.updates at launch.
트랜잭션 업데이트에 대한 리스너 없이 구매하는 건 구매 성공 업데이트를 놓칠 위험이 있으니, Transaction.updates로 순회할 Task를 만들라는 이야기다. → 즉, 트랜잭션 리스너 역할의 Task 하나를 만들어야 한다. 부모님 동의를 받아 구매를 해야 하는 아동용 계정의 경우라든가.. 주로 .pending 단계에 transaction이 머무를 때 문제가 된다.
트랜잭션 업데이트 관찰을 언제부터 할 것인가는 개발자의 선택에 달렸지만, 애플 문서에서는 앱을 시작하면서부터 관찰하도록 권장하고 있다. 만약 종료되지 않은 트랜잭션이 있다면, updates 리스너가 앱 구동되자마자 그 트랜잭션을 수신할 테니 그게 더 안전하다고 보는 것이다.
var transactionListener: Task<Void, Error>?
transactionListener = Task(priority: .background) {
for await update in Transaction.updates {
do {
let transaction = try verifyPurchase(update)
await transaction.finish()
} catch {
if let error = error as? Product.PurchaseError {
// 구매 에러 분기
} else if let error = error as? Product.VerificationResult<Transaction>.VerificationError {
// 인증 에러 분기
}
}
}
}
func verifyPurchase<T>(_ result: VerificationResult<T>) throws -> T {
switch result {
case .verified(let transaction):
return transaction
case .unverified(_, let error):
throw error
}
}
이렇게 태스크를 만들었다면, 트랜잭션 리스너를 갖고 있는 객체가 deinit될 때 태스크를 취소시켜야 한다. 그렇지 않으면 메모리 릭의 위험이 있다.
deinit {
transactionListener?.cancel()
}
6. 애플 서버로 트랜잭션 ID, 환경을 전송해 최종 검증하기
이걸로 끝난 건 아니고, 구매 결과인 트랜잭션 ID와 환경을 애플 서버로 다시 한 번 전송해 이 트랜잭션이 유효한지 확인해야 한다. 내 경우 이 작업은 서버가 대신해줬다. (클라이언트에서도 URLSession으로 시도할 수 있을지도.. 하지만 인앱결제가 있는 앱이 서버가 없을 것 같진 않다)
서버 개발자라면 이 문서가 도움이 될 것이다.
7. 구매 복원
인앱 결제를 지원하는 iOS 앱에는 구매 복원이라는 기능이 있고, 사용자에게 이것을 명시적으로 제공해야 한다. 이것은 구매 복원을 사실상 자동적으로 해주는 지금에도 유효하다. (웬만하면 사용자에게 “구매 복원을 했다”라는 느낌을 분명히 주도록 가이드)
복원은 따로 할 필요는 없으나 정 필요할 경우에는 다음과 같이 시도할 수 있다.
Task {
do {
try await AppStore.sync()
for await result in Transaction.currentEntitlements {
if case let .verified(transaction) = result {
await transaction.finish()
return
}
}
} catch {
// 에러 처리
}
}
func restoreReceipt(_ request: SKRequest) {
guard let url = Bundle.main.appStoreReceiptURL else { return }
if request is SKReceiptRefreshRequest, FileManager.default.fileExists(atPath: url.path) {
if let receiptData = try? Data(contentsOf: url) {
// 서버에 복원 요청
}
}
}
+ 번외: .updates / .currentEntitlements
막간을 이용해서, 결제를 관찰할 때는 .updates를 보는 것과 구매 복원 시에 .currentEntitlements를 보는 것 사이의 차이에 대해 간단히 보자면
.updates: Transaction.Transactions
시스템이 앱 바깥이나 다른 디바이스에서 일어나는 트랜잭션을 생성하거나 업데이트할 때, 그 트랜잭션을 방출하는 비동기 시퀀스
앱 실행 중에 새로운 트랜잭션들을 수신할 때 .updates를 사용할 수 있다. Ask to Buy 트랜잭션이나 구독 리딤 코드, 앱스토어에서 발생하는 구매 등의 트랜잭션을 방출하는 시퀀스이다. 다른 기기에서 앱 내 구매를 완료했을 때도 트랜잭션을 방출한다.
.currentEntitlements: Transaction.Transactions
인앱 구매와 구독에 대해 고객에게 권리를 주는 최근의 트랜잭션들을 담은 시퀀스
- 비소모성 인앱결제에 대한 트랜잭션
- Product.SubscriptionInfo.RenewalState가 .subscribed, .inGracePeriod인 자동갱신되는 구독에 대한 최근 트랜잭션
- 만료된 것을 포함해 갱신되지 않는 구독에 대한 최근 트랜잭션
앱스토어에서 환불했거나 더이상 유효하지 않은 상품은 current entitlements에 나타나지 않는다. 소모성 인앱 결제 역시 나타나지 않는다. 종료되지 않은 소모성 상품에 대한 트랜잭션을 얻으려면, Transaction의 .unfinished, .all 시퀀스를 사용하면 된다.
주로 구매한 상품들 정보를 다시 가져오거나 할 때 사용할 수 있다. 그러니까 .updates와는 사용 목적이 조금 다른 것이다.
References
'앱 > Swift' 카테고리의 다른 글
[SwiftUI] iOS 위젯에서 로컬 파일을 가져오기 (0) | 2024.10.25 |
---|---|
MacOS 15 업데이트 및 Xcode 16 업데이트 호환성 대응 (7) | 2024.09.24 |
UICollectionViewFlowLayout 설정으로 같은 줄의 셀 높이를 동일하게 맞추기 (0) | 2024.08.26 |
Swift Concurrency 속 Continuation의 쓰임 (feat. 컴플리션 핸들러 → async-await) (1) | 2024.07.22 |
Constructing an object of class type with a metatype value must use a ‘required’ initializer 해결하기 (0) | 2024.07.06 |