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

Recent Posts
Recent Comments
Total
관리 메뉴

꿈꾸는리버리

[인앱 결제하기 4] 구독 결제 구현하기 본문

오뚝이 개발자/SwiftUI

[인앱 결제하기 4] 구독 결제 구현하기

rriver2 2024. 10. 20. 22:25
반응형

 💕 인앱 결제 구현 Intro 

[ 이전 포스팅 ]

[인앱 결제하기 1] 사업자 등록증 + Appstore Connect 준비단계

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

[인앱 결제하기 3] 샌드박스 결제, 인앱 결제 심사 방법

[인앱 결제하기 4] 구독 결제 구현하기

[인앱 결제하기 삽질 모음 Zip]

[인앱 결제하기 5] 자동 갱신의 신규 특가
[인앱 결제하기 삽질 모음 Zip]
[인앱 결제하기 삽질 모음 Zip]

 

 💕 구독 결제 구현하는 방법 

지난 포스팅에서는 소모품 결제를 지원했는데, 지금부터는 자동 갱신 구독 결제에 대해 다룰 예정이다.

이전 포스팅들을 이해하지 못하면 구독 결제를 이해하기 힘들 수도 있으니 전 포스팅을 읽고 오는 걸 추천한다.


내용은 이 공홈에 모든 것이 있다. 사실 너무 많아서 해야 하는 게 엄청 많은 것 같아서 압도 당했지만, 괜찮아.. 천천히 하낫둘 가보자구...

https://developer.apple.com/app-store/subscriptions/#ranking

 

Auto-renewable Subscriptions - App Store - Apple Developer

Provide a seamless experience for auto-renewable subscriptions in your apps. You’ll receive more revenue for qualifying subscriptions after one year, have greater pricing flexibility, and more.

developer.apple.com

 💕 구독 가이드라인 이해 

조금은 까탈스러운 애플을 어루만져(?)줘야 한다 ㅋㅋ 아래 두 개는 읽어보면 정말 좋은(?) 기본기 같은 애들인 것 같고 그 외에 프로모션이나 오퍼 코드 같은 거는 기본 구독을 출시한 다음에 좀 더 알아보면 좋을 듯 하다.

<앱 검토 지침> 같은 경우는 필수적으로 읽을 필요는 없지만 대충 읽어보면서 리젝될 사유들을 피할 수 있도록 준비하는 것을 추천한다. 

 

예를 들면 이런 내용들이 들어간다. 읽다보면 사람들이 왜 그런 결제형 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

 

Apple 구독을 취소하려는 경우 - Apple 지원 (KR)

Apple 구독 또는 App Store에서 앱으로 구입한 구독을 취소하는 방법에 대해 알아봅니다.

support.apple.com

위 링크에서 할 수 있도록 가이드를 해줘야 하는데 이거는 좀 투머치 같아서 FAQ로 뺐다.

 

 😡 애플아 내 돈 너무 들고 가지마...

암튼.. 이런 이유로 1년 이상 결제를 많이 장려(?) 해야 한다..!!

 

[참고]

https://developer.apple.com/help/app-store-connect/manage-subscriptions/offer-auto-renewable-subscriptions

 

Offer auto-renewable subscriptions - Manage subscriptions - App Store Connect - Help - Apple Developer

Manage subscriptions Offer auto-renewable subscriptions App Store Connect offers tools to help you run a subscription business on the App Store. How you configure your subscriptions in App Store Connect determine how users subscribe to your service, how of

developer.apple.com

https://developer.apple.com/app-store/subscriptions/

 

Auto-renewable Subscriptions - App Store - Apple Developer

Provide a seamless experience for auto-renewable subscriptions in your apps. You’ll receive more revenue for qualifying subscriptions after one year, have greater pricing flexibility, and more.

developer.apple.com

반응형
Comments