- Total
꿈꾸는리버리
[인앱 결제하기 4] 구독 결제 구현하기 본문
💕 인앱 결제 구현 Intro
[ 이전 포스팅 ]
[인앱 결제하기 1] 사업자 등록증 + Appstore Connect 준비단계
[인앱 결제하기 3] 샌드박스 결제, 인앱 결제 심사 방법
[인앱 결제하기 5] 자동 갱신의 신규 특가
[인앱 결제하기 삽질 모음 Zip][인앱 결제하기 삽질 모음 Zip]
💕 구독 결제 구현하는 방법
지난 포스팅에서는 소모품 결제를 지원했는데, 지금부터는 자동 갱신 구독 결제에 대해 다룰 예정이다.
이전 포스팅들을 이해하지 못하면 구독 결제를 이해하기 힘들 수도 있으니 전 포스팅을 읽고 오는 걸 추천한다.
내용은 이 공홈에 모든 것이 있다. 사실 너무 많아서 해야 하는 게 엄청 많은 것 같아서 압도 당했지만, 괜찮아.. 천천히 하낫둘 가보자구...
https://developer.apple.com/app-store/subscriptions/#ranking
💕 구독 가이드라인 이해
조금은 까탈스러운 애플을 어루만져(?)줘야 한다 ㅋㅋ 아래 두 개는 읽어보면 정말 좋은(?) 기본기 같은 애들인 것 같고 그 외에 프로모션이나 오퍼 코드 같은 거는 기본 구독을 출시한 다음에 좀 더 알아보면 좋을 듯 하다.
<앱 검토 지침> 같은 경우는 필수적으로 읽을 필요는 없지만 대충 읽어보면서 리젝될 사유들을 피할 수 있도록 준비하는 것을 추천한다.
예를 들면 이런 내용들이 들어간다. 읽다보면 사람들이 왜 그런 결제형 UI들을 만들었는지 알 수 있는 것들이 많다.
<HIG> 같은 경우에는 꼭 읽어보는 것을 추천한다. UI들을 예시로 보여주면서 어떻게 UI를 만들면 사람들이 많이 반응하는지에 대해 적혀있다.
💕 코드 구현
🙏🏻 코드 작성 전 드릴 말씀
우선 나는 Meet StoreKit 2 WWDC 영상을 보고 공부했고, 그 중에 일부 구독만 구현을 했다. 만약 다른 부분( 비소모품 등 )이 궁금하다면 WWDC 영상을 보고 따라 코드를 짜면서 모르는 부분을 공부하면 좋을 것 같아요. ( 소모품 관련 글 )
해당 영상에 있는 코드는 여기서 다운 받아서 볼 수 있으니, 영상을 보면서 주석을 달거나 이것저것 실험해보는 용도로 사용하면 좋을 것 같다.
1️⃣ 구독 결제 항목 추가
구독 그룹을 추가하고
해당 그룹 안에 구독을 넣는다. ( 현지화도 추가해야 함 )
그리고 Xcode에서 Sync 버튼을 누르면 appstore connect에 넣었던 것들이랑 동기화가 되고
Plist에도 해당 purchase의 key와 value 를 넣어준다.
2️⃣ 구독 결제 및 조회하기
지난번에 작성했던 코드에서 자동 갱신된 product만 case에 추가해주면 된다.
스토어킷의 product의 종류(소모성, 비소모성, 자동 갱신 등) 중 일부(자동 갱신)만 추가된 것이기 때문에, product를 불러오거나 지금 유효한 접근인지, 이미 결제한 내역인지 등을 확인하는 코드에서 해당 case만 추가적으로 확인해주면 된다.
class StoreKitViewModel: ObservableObject {
@Published private(set) var consumableDonateList: [StoreKit.Product] = []
// ✅ 추가된 코드
@Published private(set) var autoRenewableList: [StoreKit.Product] = []
@Published private(set) var purchasedConsumableDonateList: [StoreKit.Product] = []
// ✅ 추가된 코드
@Published private(set) var purchasedAutoRenewableList: [StoreKit.Product] = []
...
}
// AppStore에서 Product 불러오기
@MainActor
func requestProducts() async {
do {
let storeProducts = try await StoreKit.Product.products(for: productDict.values)
var newConsumableDonateList: [StoreKit.Product] = []
//✅ 추가된 코드 !
var newAutoRenewableList: [StoreKit.Product] = []
//Filter the products into categories based on their type.
for product in storeProducts {
switch product.type {
case .consumable:
newConsumableDonateList.append(product)
case .autoRenewable:
//✅ 추가된 코드 !
newAutoRenewableList.append(product)
case .nonConsumable:
// 아직 추가하지 않은 product
break
default:
//Unknown product
break
}
}
consumableDonateList = sortByPrice(newConsumableDonateList)
//✅ 추가된 코드 !
autoRenewableList = sortByPrice(newAutoRenewableList)
} catch {
print("Failed product request from the App Store server: \(error)")
}
}
func isPurchased(_ product: StoreKit.Product) async throws -> Bool {
// Determine whether the user purchases a given product.
switch product.type {
// ✅ 추가된 코드
case .autoRenewable:
return purchasedAutoRenewableList.contains(product)
case .nonConsumable, .consumable:
// 아직 추가하지 않은 product
return false
default:
return false
}
}
@MainActor
private func updateCustomerProductStatus() async {
var newPurchasedList: [StoreKit.Product] = []
// ✅ 추가된 코드
var newPurchasedAutoRenewableList: [StoreKit.Product] = []
for await result in Transaction.currentEntitlements {
do {
let transaction = try checkVerified(result)
switch transaction.productType {
case .consumable:
if let subscription = consumableDonateList.first(where: {$0.id == transaction.productID}) {
newPurchasedList.append(subscription)
}
// ✅ 추가된 코드
case .autoRenewable:
if let subscription = autoRenewableList.first(where: { $0.id == transaction.productID }) {
newPurchasedAutoRenewableList.append(subscription)
}
default:
break
}
await transaction.finish()
} catch {
print("failed updating product")
}
}
self.purchasedAutoRenewableList = newPurchasedAutoRenewableList
// ✅ 추가된 코드
self.purchasedConsumableDonateList = newPurchasedList
}
3️⃣ 구독 내역 확인하기
하지만 그 중에, "자동 구독"의 특징은 해당 결제를 계속해서 구독을 할 수 있기 때문에 유저는 당연히 자신이 현재 어떤 결제를 했는지, 언제가 구독 갱신일인지 알고 싶을 수가 있다. 그래서 다음과 같은 코드를 추가했다.
나는 나중에 유저가 오류 신고할 때 도움이 되었으면 하는 마음에 조금 디테일하게 작성해서, 해당 결제가 왜 접근이 안 되는 지 등도 트래킹을 할 수 있도록 텍스트를 넣어놨다.
extension StoreKitViewModel {
// 구독 만료일 가져오기
func getExpirationDate(for product: StoreKit.Product) async throws -> String? {
// 구독 상품의 상태 목록 가져오기
guard let statuses = try await product.subscription?.status else {
return nil
}
// 유효한 구독 상태 탐색
for status in statuses {
switch status.state {
case .expired, .revoked:
continue // 만료되거나 취소된 구독은 무시합니다.
default:
// 만료일 반환
guard case .verified(let renewalInfo) = status.renewalInfo,
case .verified(let transaction) = status.transaction else {
return "앱 스토어에서 구독 상태를 확인할 수 없습니다. 개발자에게 문의해주세요. (error 1)"
}
var description = ""
switch status.state {
case .subscribed:
description = product.displayName
case .expired:
if let expirationDate = transaction.expirationDate,
let expirationReason = renewalInfo.expirationReason {
description = expirationDescription(expirationReason, expirationDate: expirationDate, product: product)
}
case .revoked:
if let revokedDate = transaction.revocationDate {
description = "\(product.displayName) 구독제를 \(revokedDate.dateToString_MDY())에 앱 스토어에서 환불했습니다."
}
case .inGracePeriod:
description = gracePeriodDescription(renewalInfo, product: product)
case .inBillingRetryPeriod:
description = billingRetryDescription(product: product)
default:
break
}
if let expirationDate = transaction.expirationDate {
description += renewalDescription(renewalInfo, expirationDate, product: product)
}
return description
}
}
return nil // 유효한 구독이 없을 경우 nil 반환
}
fileprivate func renewalDescription(_ renewalInfo: RenewalInfo, _ expirationDate: Date, product: StoreKit.Product) -> String {
var description = ""
description += "\n결제 일자: \(expirationDate.dateToString_MDY())"
if renewalInfo.willAutoRenew {
// 구독 주기(기간)를 기반으로 다음 갱신일 계산
if let subscriptionPeriod = product.subscription?.subscriptionPeriod,
let nextBillingDate = calculateNextBillingDate(from: expirationDate, with:subscriptionPeriod) {
// 구독 주기(예: 1개월, 1년)를 사용해 다음 결제일 계산
description += "\n다음 결제일: \(nextBillingDate.dateToString_MDY())"
} else {
// 주기 정보가 없을 경우 기본 만료일 표시
}
}
func calculateNextBillingDate(from date: Date, with period: StoreKit.Product.SubscriptionPeriod) -> Date? {
// SubscriptionPeriod.Unit -> Calendar.Component 매핑
let calendarComponent = mapToCalendarComponent(period.unit)
// 예상 결제일 계산
let nextBillingDate = Calendar.current.date(byAdding: calendarComponent,
value: period.value,
to: date)
return nextBillingDate
}
/// Product.SubscriptionPeriod.Unit을 Calendar.Component로 매핑하는 함수
func mapToCalendarComponent(_ unit: StoreKit.Product.SubscriptionPeriod.Unit) -> Calendar.Component {
switch unit {
case .day:
return .day
case .week:
return .weekOfYear
case .month:
return .month
case .year:
return .year
@unknown default:
return .month // 기본값
}
}
return description
}
fileprivate func billingRetryDescription(product: StoreKit.Product) -> String {
var description = "\(product.displayName), App Store에서 다음에 대한 청구 정보를 확인할 수 없습니다. 개발자에게 문의해주세요. (error 2)"
description += "서비스를 재개하려면 청구 정보를 확인하십시오"
return description
}
fileprivate func gracePeriodDescription(_ renewalInfo: RenewalInfo, product: StoreKit.Product) -> String {
var description = "\(product.displayName), App Store에서 다음에 대한 청구 정보를 확인할 수 없습니다. 개발자에게 문의해주세요. (error 3)"
if let untilDate = renewalInfo.gracePeriodExpirationDate {
description += " 서비스를 재개하려면 청구 정보를 확인하십시오. \(untilDate.dateToString_MDY())"
}
return description
}
// Build a string description of the `expirationReason` to display to the user.
fileprivate func expirationDescription(_ expirationReason: RenewalInfo.ExpirationReason, expirationDate: Date, product: StoreKit.Product) -> String {
var description = ""
switch expirationReason {
case .autoRenewDisabled:
if expirationDate > Date() {
description += "\(product.displayName)에 대한 구독이 \(expirationDate.dateToString_MDY())에 만료됩니다."
} else {
description += "\(product.displayName)에 대한 구독이 \(expirationDate.dateToString_MDY())에 만료되었습니다."
}
case .billingError:
description = "청구 오류로 인해 \(product.displayName)에 대한 구독이 갱신되지 않았습니다."
case .didNotConsentToPriceIncrease:
description = "승인하지 않은 가격 인상으로 인해 \(product.displayName) 구독이 갱신되지 않았습니다."
case .productUnavailable:
description = "제품을 더 이상 사용할 수 없기 때문에 \(product.displayName)에 대한 구독이 갱신되지 않았습니다."
default:
description = "\(product.displayName)에 대한 구독이 갱신되지 않았습니다."
}
return description
}
}
4️⃣ 구독 취소하기
나는 갠적으로 이거 바로 갈 수 있도록 하는 게 프로덕트에는 도움이 안되지만,, 그래도 내가 유저로서 너무 화났던(?) 순간들이 많았어서...
나는 사용자가 스스로 구독을 취소할 수 있도록 앱 내에 구독 관리 링크를 제공하는 쪽으로 구현했다. 이는 Apple은 사용자가 직접 설정 앱에서 구독을 관리하도록 권장했기 때문..!
private func openAppStore() {
// 앱의 App Store 링크 생성
if let url = URL(string: "https://apps.apple.com/account/subscriptions"),
UIApplication.shared.canOpenURL(url) {
UIApplication.shared.open(url, options: [:], completionHandler: nil)
} else {
print("App Store 링크를 열 수 없습니다.")
}
}
추가적으로 환불의 경우에는
https://support.apple.com/ko-kr/118428
위 링크에서 할 수 있도록 가이드를 해줘야 하는데 이거는 좀 투머치 같아서 FAQ로 뺐다.
😡 애플아 내 돈 너무 들고 가지마...
암튼.. 이런 이유로 1년 이상 결제를 많이 장려(?) 해야 한다..!!
[참고]
'오뚝이 개발자 > SwiftUI' 카테고리의 다른 글
Position vs Offset 예시와 함께 비교 분석하기 (0) | 2024.11.25 |
---|---|
[Animation 뚜까파기 1] Animation의 기초 (3) | 2024.11.10 |
[Error] SPM 설치 중 체크박스가 누락 -> 수동 삭제하기 (4) | 2024.10.04 |
[iCloudKit 시리즈 4] 나도 백엔드 있다 - iCloud Noti 알아보기 (1) | 2024.10.01 |
[iCloudKit 시리즈 3] 나도 백엔드 있다 - Coredata -> iCloud로 변경하기 (0) | 2024.10.01 |