StoreKit 2 인터뷰: 구독 관리 및 영수증 검증

StoreKit 2, 구독 관리, 영수증 검증, 인앱 구매 구현에 관한 iOS 인터뷰 질문을 실용적인 Swift 코드 예제와 함께 마스터하십시오.

iOS StoreKit 2 구독 아키텍처 및 영수증 검증

StoreKit 2는 iOS에서 인앱 구매가 처리되는 방식의 근본적인 변화를 나타냅니다. iOS 15와 함께 도입된 이 최신 프레임워크는 구독 관리 및 트랜잭션 검증에 필요한 코드를 극적으로 단순화합니다. iOS 기술 인터뷰에서는 앱 수익화에 대한 개발자의 전문성을 평가하기 위해 이 주제를 정기적으로 다룹니다.

인터뷰 핵심 인사이트

StoreKit 2는 async/await를 사용한 네이티브 Swift API를 활용하여 복잡한 콜백과 클라이언트 측 영수증 검증의 필요성을 제거합니다. 인터뷰어는 StoreKit 1과의 근본적인 차이점을 명확하게 설명할 수 있는 후보자를 높이 평가합니다.

StoreKit 2 아키텍처 개요

StoreKit 2는 Swift Concurrency를 최대한 활용하는 최신 아키텍처를 기반으로 구축되었습니다. StoreKit 1과 달리 모든 작업은 비동기적이며 네이티브 Swift 타입을 사용합니다.

사용 가능한 제품 가져오기

첫 번째 단계는 App Store Connect에 구성된 제품을 로드하는 것입니다. StoreKit 2는 선언적 API로 이 작업을 단순화합니다.

ProductService.swiftswift
import StoreKit

actor ProductService {

    // App Store Connect에 구성된 제품 식별자
    private let productIdentifiers: Set<String> = [
        "com.app.subscription.monthly",
        "com.app.subscription.yearly",
        "com.app.premium.lifetime"
    ]

    // 반복적인 네트워크 호출을 피하기 위한 제품 캐시
    private var cachedProducts: [Product] = []

    func loadProducts() async throws -> [Product] {
        // 사용 가능한 경우 캐시 반환
        guard cachedProducts.isEmpty else {
            return cachedProducts
        }

        // App Store에 비동기 요청
        let products = try await Product.products(for: productIdentifiers)
        cachedProducts = products
        return products
    }

    func product(for identifier: String) async throws -> Product? {
        let products = try await loadProducts()
        return products.first { $0.id == identifier }
    }
}

actor를 사용하면 제품 캐시를 관리할 때 스레드 안전성이 보장됩니다.

StoreKit 2 제품 유형

StoreKit 2는 다양한 제품 유형을 명확하게 구분합니다. 이러한 구분을 이해하는 것은 인터뷰에 필수적입니다.

ProductType+Extensions.swiftswift
import StoreKit

extension Product.ProductType {

    var displayName: String {
        switch self {
        case .consumable:
            // 소비형 제품(크레딧, 라이프 등)
            return "Consumable"
        case .nonConsumable:
            // 영구 구매(기능 잠금 해제)
            return "Non-Consumable"
        case .autoRenewable:
            // 자동 갱신 구독
            return "Auto-Renewable Subscription"
        case .nonRenewable:
            // 갱신 없는 구독(임시 패스)
            return "Non-Renewable Subscription"
        @unknown default:
            return "Unknown"
        }
    }
}

자동 갱신 구독은 현대 iOS 애플리케이션의 지배적인 비즈니스 모델을 나타냅니다.

구매 트랜잭션 처리

StoreKit 2의 구매 프로세스는 async/await 덕분에 선형적이 됩니다. StoreKit 1의 델리게이트와 중첩된 콜백은 더 이상 필요하지 않습니다.

구매 시작

PurchaseManager.swiftswift
import StoreKit

actor PurchaseManager {

    enum PurchaseError: Error {
        case productNotFound
        case purchaseCancelled
        case purchasePending
        case verificationFailed
        case unknown
    }

    func purchase(_ product: Product) async throws -> Transaction {
        // 시스템 모달로 구매 시작
        let result = try await product.purchase()

        switch result {
        case .success(let verification):
            // 트랜잭션 서명 검증
            let transaction = try checkVerification(verification)

            // 트랜잭션을 완료된 것으로 표시
            await transaction.finish()

            return transaction

        case .userCancelled:
            // 사용자가 구매 모달을 취소함
            throw PurchaseError.purchaseCancelled

        case .pending:
            // 보류 중인 구매(부모 승인 등)
            throw PurchaseError.purchasePending

        @unknown default:
            throw PurchaseError.unknown
        }
    }

    private func checkVerification<T>(_ result: VerificationResult<T>) throws -> T {
        switch result {
        case .verified(let safe):
            // App Store에서 서명되고 검증된 트랜잭션
            return safe
        case .unverified(_, let error):
            // 잘못되었거나 손상된 서명
            throw PurchaseError.verificationFailed
        }
    }
}

finish() 메서드는 매우 중요합니다: 콘텐츠가 사용자에게 전달되었음을 App Store에 알립니다.

인터뷰 경고

콘텐츠 전달 후 transaction.finish()를 호출하는 것을 절대 잊지 마십시오. 완료되지 않은 트랜잭션은 다음 앱 실행 시 복원되어 예기치 않은 동작을 일으킵니다.

백그라운드 트랜잭션 수신

StoreKit 2는 앱 외부에서 발생하는 트랜잭션(갱신, 가족 공유 구매 등)을 발행하는 비동기 스트림인 Transaction.updates를 도입합니다.

TransactionObserver.swiftswift
import StoreKit

actor TransactionObserver {

    private var updateTask: Task<Void, Never>?

    func startObserving() {
        // 이전 관찰 취소
        updateTask?.cancel()

        updateTask = Task(priority: .background) {
            // 트랜잭션 업데이트의 무한 스트림
            for await result in Transaction.updates {
                do {
                    let transaction = try self.checkVerification(result)

                    // 유형에 따라 트랜잭션 처리
                    await self.handleTransaction(transaction)

                    // 항상 트랜잭션 완료
                    await transaction.finish()
                } catch {
                    // 디버깅을 위해 오류 기록
                    print("Transaction verification failed: \(error)")
                }
            }
        }
    }

    func stopObserving() {
        updateTask?.cancel()
        updateTask = nil
    }

    private func handleTransaction(_ transaction: Transaction) async {
        switch transaction.productType {
        case .autoRenewable:
            // 구독 상태 업데이트
            await updateSubscriptionStatus(transaction)
        case .nonConsumable:
            // 프리미엄 기능 잠금 해제
            await unlockFeature(transaction.productID)
        default:
            break
        }
    }

    private func checkVerification<T>(_ result: VerificationResult<T>) throws -> T {
        switch result {
        case .verified(let safe):
            return safe
        case .unverified:
            throw PurchaseError.verificationFailed
        }
    }

    private func updateSubscriptionStatus(_ transaction: Transaction) async {
        // 상태 업데이트 구현
    }

    private func unlockFeature(_ productID: String) async {
        // 기능 잠금 해제 구현
    }

    enum PurchaseError: Error {
        case verificationFailed
    }
}

트랜잭션 관찰은 업데이트를 놓치지 않도록 앱이 시작되자마자 시작되어야 합니다.

StoreKit 2 구독에 대한 일반적인 인터뷰 질문

iOS 인터뷰에는 구독 관리에 관한 구체적인 질문이 자주 포함됩니다. 다음은 가장 일반적인 질문과 자세한 답변입니다.

질문 1: 현재 구독 상태를 확인하는 방법은 무엇입니까?

StoreKit 2는 Transaction.currentEntitlements를 통해 활성 권한에 대한 직접 액세스를 제공합니다.

SubscriptionStatusChecker.swiftswift
import StoreKit

struct SubscriptionStatusChecker {

    struct SubscriptionStatus {
        let isActive: Bool
        let expirationDate: Date?
        let willRenew: Bool
        let productID: String?
    }

    static func checkCurrentStatus() async -> SubscriptionStatus {
        // 모든 활성 트랜잭션 가져오기
        var latestTransaction: Transaction?

        for await result in Transaction.currentEntitlements {
            if case .verified(let transaction) = result {
                // 구독만 필터링
                if transaction.productType == .autoRenewable {
                    // 가장 최근 트랜잭션 유지
                    if latestTransaction == nil ||
                       transaction.purchaseDate > latestTransaction!.purchaseDate {
                        latestTransaction = transaction
                    }
                }
            }
        }

        guard let transaction = latestTransaction else {
            return SubscriptionStatus(
                isActive: false,
                expirationDate: nil,
                willRenew: false,
                productID: nil
            )
        }

        // 구독이 여전히 활성인지 확인
        let isActive = transaction.expirationDate ?? Date.distantPast > Date()

        // 갱신 정보 가져오기
        let renewalInfo = await getRenewalInfo(for: transaction.productID)

        return SubscriptionStatus(
            isActive: isActive,
            expirationDate: transaction.expirationDate,
            willRenew: renewalInfo?.willAutoRenew ?? false,
            productID: transaction.productID
        )
    }

    private static func getRenewalInfo(for productID: String) async -> Product.SubscriptionInfo.RenewalInfo? {
        guard let product = try? await Product.products(for: [productID]).first,
              let subscription = product.subscription else {
            return nil
        }

        // 갱신 상태 가져오기
        let status = try? await subscription.status.first
        return status?.renewalInfo
    }
}

이 접근 방식은 서버 호출 없이 정확한 구독 상태를 제공합니다.

iOS 면접 준비가 되셨나요?

인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.

질문 2: 무료 평가판 및 프로모션 제공을 처리하는 방법은 무엇입니까?

StoreKit 2는 제공 정보에 대한 액세스를 단순화합니다. 인터뷰어는 종종 다양한 제공 유형에 대한 이해를 테스트합니다.

OfferManager.swiftswift
import StoreKit

struct OfferManager {

    enum OfferType {
        case freeTrial(duration: String)
        case payAsYouGo(periods: Int, price: Decimal)
        case payUpFront(duration: String, price: Decimal)
        case none
    }

    static func getIntroductoryOffer(for product: Product) -> OfferType {
        guard let subscription = product.subscription,
              let offer = subscription.introductoryOffer else {
            return .none
        }

        switch offer.paymentMode {
        case .freeTrial:
            // 무료 평가판 기간
            return .freeTrial(duration: formatPeriod(offer.period))

        case .payAsYouGo:
            // 여러 기간에 걸친 할인 가격
            return .payAsYouGo(
                periods: offer.periodCount,
                price: offer.price
            )

        case .payUpFront:
            // 기간에 대한 단일 결제
            return .payUpFront(
                duration: formatPeriod(offer.period),
                price: offer.price
            )

        @unknown default:
            return .none
        }
    }

    static func checkEligibility(for product: Product) async -> Bool {
        // 사용자가 제공의 혜택을 받을 수 있는지 확인
        guard let subscription = product.subscription else {
            return false
        }

        // isEligibleForIntroOffer는 새로운 구독자인지를 나타냄
        return await subscription.isEligibleForIntroOffer
    }

    private static func formatPeriod(_ period: Product.SubscriptionPeriod) -> String {
        let value = period.value
        switch period.unit {
        case .day:
            return "\(value) day\(value > 1 ? "s" : "")"
        case .week:
            return "\(value) week\(value > 1 ? "s" : "")"
        case .month:
            return "\(value) month\(value > 1 ? "s" : "")"
        case .year:
            return "\(value) year\(value > 1 ? "s" : "")"
        @unknown default:
            return ""
        }
    }
}

isEligibleForIntroOffer 메서드는 사용할 수 없는 제공을 표시하는 것을 피하기 위해 필수적입니다.

질문 3: 이전 구매를 복원하는 방법은 무엇입니까?

구매 복원은 App Store 가이드라인에 따라 필수 기능입니다.

RestoreManager.swiftswift
import StoreKit

actor RestoreManager {

    struct RestoreResult {
        let restoredProducts: [String]
        let activeSubscriptions: [String]
    }

    func restorePurchases() async throws -> RestoreResult {
        var restoredProducts: [String] = []
        var activeSubscriptions: [String] = []

        // App Store와 동기화
        try await AppStore.sync()

        // 모든 사용자 트랜잭션을 반복
        for await result in Transaction.currentEntitlements {
            guard case .verified(let transaction) = result else {
                continue
            }

            switch transaction.productType {
            case .nonConsumable:
                // 영구 구매 복원
                restoredProducts.append(transaction.productID)

            case .autoRenewable:
                // 구독이 활성인지 확인
                if let expiration = transaction.expirationDate,
                   expiration > Date() {
                    activeSubscriptions.append(transaction.productID)
                }

            default:
                // 소비형은 복원할 수 없음
                break
            }
        }

        return RestoreResult(
            restoredProducts: restoredProducts,
            activeSubscriptions: activeSubscriptions
        )
    }
}

AppStore.sync()는 Apple 서버와의 동기화를 강제하며 재설치 후에 유용합니다.

서버 측 영수증 검증

중요한 구독이 있는 애플리케이션에는 서버 측 검증이 여전히 권장됩니다. StoreKit 2는 최신 검증을 위해 JWS(JSON Web Signatures)를 도입합니다.

서버용 트랜잭션 데이터 추출

ServerValidation.swiftswift
import StoreKit

struct TransactionPayload: Codable {
    let transactionID: String
    let originalTransactionID: String
    let productID: String
    let purchaseDate: Date
    let expirationDate: Date?
    let jwsRepresentation: String
}

actor ServerValidationService {

    private let apiEndpoint = "https://api.example.com/verify"

    func validateWithServer(_ transaction: Transaction) async throws -> Bool {
        // 서버용 페이로드 생성
        let payload = TransactionPayload(
            transactionID: String(transaction.id),
            originalTransactionID: String(transaction.originalID),
            productID: transaction.productID,
            purchaseDate: transaction.purchaseDate,
            expirationDate: transaction.expirationDate,
            jwsRepresentation: transaction.jwsRepresentation ?? ""
        )

        // JSON으로 인코딩
        let encoder = JSONEncoder()
        encoder.dateEncodingStrategy = .iso8601
        let jsonData = try encoder.encode(payload)

        // 서버로 전송
        var request = URLRequest(url: URL(string: apiEndpoint)!)
        request.httpMethod = "POST"
        request.httpBody = jsonData
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")

        let (data, response) = try await URLSession.shared.data(for: request)

        guard let httpResponse = response as? HTTPURLResponse,
              httpResponse.statusCode == 200 else {
            return false
        }

        // 서버 응답 디코딩
        let validationResponse = try JSONDecoder().decode(
            ValidationResponse.self,
            from: data
        )

        return validationResponse.isValid
    }
}

struct ValidationResponse: Codable {
    let isValid: Bool
    let subscriptionStatus: String?
}

jwsRepresentation에는 서버에서 검증할 수 있는 암호화 서명이 포함되어 있습니다.

App Store Server API

Apple은 이제 서버 측 검증을 위한 App Store Server API를 제공합니다. 이 최신 API는 더 이상 사용되지 않는 verifyReceipt 엔드포인트를 대체하고 서버 간 알림과 같은 고급 기능을 제공합니다.

서버 알림 구성

Server Notifications V2는 실시간 이벤트 수신을 가능하게 합니다.

ServerNotificationTypes.swiftswift
enum NotificationType: String, Codable {
    case subscribed = "SUBSCRIBED"
    case didRenew = "DID_RENEW"
    case didFailToRenew = "DID_FAIL_TO_RENEW"
    case didChangeRenewalStatus = "DID_CHANGE_RENEWAL_STATUS"
    case expired = "EXPIRED"
    case gracePeriodExpired = "GRACE_PERIOD_EXPIRED"
    case offerRedeemed = "OFFER_REDEEMED"
    case priceIncrease = "PRICE_INCREASE"
    case refund = "REFUND"
    case renewalExtended = "RENEWAL_EXTENDED"
    case revoke = "REVOKE"
}

struct ServerNotification: Codable {
    let notificationType: NotificationType
    let subtype: String?
    let data: NotificationData

    struct NotificationData: Codable {
        let bundleId: String
        let environment: String
        let signedTransactionInfo: String
        let signedRenewalInfo: String?
    }
}

이러한 알림을 통해 백엔드와 동기화된 구독 상태를 유지할 수 있습니다.

오류 처리 및 엣지 케이스

인터뷰는 종종 오류 시나리오와 적절한 처리에 대한 이해를 테스트합니다.

StoreKit 2의 일반적인 오류

PurchaseErrorHandler.swiftswift
import StoreKit

struct PurchaseErrorHandler {

    enum UserFacingError {
        case networkError
        case paymentFailed
        case notAuthorized
        case productNotAvailable
        case unknown

        var message: String {
            switch self {
            case .networkError:
                return "Connection failed. Please check your internet connection."
            case .paymentFailed:
                return "Payment failed. Please verify your payment information."
            case .notAuthorized:
                return "In-app purchases are disabled on this device."
            case .productNotAvailable:
                return "This product is not currently available."
            case .unknown:
                return "An unexpected error occurred."
            }
        }
    }

    static func handle(_ error: Error) -> UserFacingError {
        // StoreKit 오류인지 확인
        if let storeKitError = error as? StoreKitError {
            switch storeKitError {
            case .networkError:
                return .networkError
            case .userCancelled:
                // 오류 표시 안 함, 사용자가 취소함
                return .unknown
            case .notAvailableInStorefront:
                return .productNotAvailable
            case .notEntitled:
                return .notAuthorized
            default:
                return .unknown
            }
        }

        // 구매 오류
        if let purchaseError = error as? Product.PurchaseError {
            switch purchaseError {
            case .invalidQuantity:
                return .unknown
            case .productUnavailable:
                return .productNotAvailable
            case .purchaseNotAllowed:
                return .notAuthorized
            default:
                return .unknown
            }
        }

        return .unknown
    }
}

명확한 오류 처리는 사용자 경험을 개선하고 디버깅을 단순화합니다.

오프라인 모드 지원

OfflineCapabilityManager.swiftswift
import StoreKit

actor OfflineCapabilityManager {

    private let userDefaults = UserDefaults.standard
    private let entitlementsKey = "cached_entitlements"

    func cacheCurrentEntitlements() async {
        var entitlements: [String: Date] = [:]

        for await result in Transaction.currentEntitlements {
            if case .verified(let transaction) = result {
                entitlements[transaction.productID] = transaction.expirationDate
            }
        }

        // 로컬에 저장
        if let data = try? JSONEncoder().encode(entitlements) {
            userDefaults.set(data, forKey: entitlementsKey)
        }
    }

    func getCachedEntitlements() -> [String: Date] {
        guard let data = userDefaults.data(forKey: entitlementsKey),
              let entitlements = try? JSONDecoder().decode(
                  [String: Date].self,
                  from: data
              ) else {
            return [:]
        }

        // 만료된 권한 필터링
        let now = Date()
        return entitlements.filter { _, expiration in
            expiration > now
        }
    }

    func hasValidEntitlement(for productID: String) -> Bool {
        let cached = getCachedEntitlements()
        return cached[productID] != nil
    }
}

로컬 캐싱은 연결이 없어도 프리미엄 기능에 대한 액세스를 유지합니다.

프로덕션을 위한 모범 사례

권장 아키텍처

SubscriptionService.swiftswift
import StoreKit
import Combine

@MainActor
final class SubscriptionService: ObservableObject {

    static let shared = SubscriptionService()

    @Published private(set) var products: [Product] = []
    @Published private(set) var purchasedProductIDs: Set<String> = []
    @Published private(set) var subscriptionStatus: SubscriptionStatus = .none

    private var transactionObserver: Task<Void, Never>?

    enum SubscriptionStatus {
        case none
        case active(expirationDate: Date)
        case expired
        case inGracePeriod
    }

    private init() {
        // 트랜잭션 관찰 시작
        startTransactionObserver()

        // 초기 상태 로드
        Task {
            await loadProducts()
            await updateSubscriptionStatus()
        }
    }

    private func startTransactionObserver() {
        transactionObserver = Task.detached(priority: .background) {
            for await result in Transaction.updates {
                if case .verified(let transaction) = result {
                    await transaction.finish()
                    await self.updateSubscriptionStatus()
                }
            }
        }
    }

    func loadProducts() async {
        do {
            let productIDs = [
                "com.app.subscription.monthly",
                "com.app.subscription.yearly"
            ]
            products = try await Product.products(for: productIDs)
        } catch {
            print("Failed to load products: \(error)")
        }
    }

    func purchase(_ product: Product) async throws {
        let result = try await product.purchase()

        switch result {
        case .success(let verification):
            if case .verified(let transaction) = verification {
                await transaction.finish()
                await updateSubscriptionStatus()
            }
        case .userCancelled:
            break
        case .pending:
            break
        @unknown default:
            break
        }
    }

    func updateSubscriptionStatus() async {
        var purchasedIDs: Set<String> = []
        var latestSubscription: Transaction?

        for await result in Transaction.currentEntitlements {
            if case .verified(let transaction) = result {
                purchasedIDs.insert(transaction.productID)

                if transaction.productType == .autoRenewable {
                    if latestSubscription == nil ||
                       transaction.purchaseDate > latestSubscription!.purchaseDate {
                        latestSubscription = transaction
                    }
                }
            }
        }

        purchasedProductIDs = purchasedIDs

        if let subscription = latestSubscription,
           let expiration = subscription.expirationDate {
            if expiration > Date() {
                subscriptionStatus = .active(expirationDate: expiration)
            } else {
                subscriptionStatus = .expired
            }
        } else {
            subscriptionStatus = .none
        }
    }

    deinit {
        transactionObserver?.cancel()
    }
}

이 중앙 집중식 아키텍처는 애플리케이션 전체에서 구매 상태 관리를 단순화합니다.

결론

StoreKit 2는 인앱 구매 관리를 현대적이고 안전한 개발 경험으로 변환합니다. 인터뷰에서 기억해야 할 핵심 요점:

✅ 네이티브 async/await API: StoreKit 1의 복잡한 델리게이트와 콜백은 더 이상 필요하지 않음

✅ 자동 검증: VerificationResult가 트랜잭션의 암호화 검증을 처리

Transaction.updates: 백그라운드 트랜잭션을 위한 비동기 스트림

Transaction.currentEntitlements: 사용자의 활성 권한에 직접 액세스

✅ 프로모션 제공: isEligibleForIntroOffer로 자격 확인

✅ 서버 검증: 향상된 보안을 위한 JWS와 App Store Server API

transaction.finish(): 콘텐츠 전달 후 필수

✅ 오프라인 처리: 원활한 경험을 위한 권한의 로컬 캐싱

연습을 시작하세요!

면접 시뮬레이터와 기술 테스트로 지식을 테스트하세요.

태그

#ios
#storekit
#in-app-purchase
#swift
#interview

공유

관련 기사