# StoreKit 2 인터뷰: 구독 관리 및 영수증 검증 > StoreKit 2, 구독 관리, 영수증 검증, 인앱 구매 구현에 관한 iOS 인터뷰 질문을 실용적인 Swift 코드 예제와 함께 마스터하십시오. - Published: 2026-03-09 - Updated: 2026-04-29 - Author: SharpSkill - Tags: ios, storekit, in-app-purchase, swift, interview - Reading time: 14 min --- 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로 이 작업을 단순화합니다. ```swift // ProductService.swift import StoreKit actor ProductService { // App Store Connect에 구성된 제품 식별자 private let productIdentifiers: Set = [ "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는 다양한 제품 유형을 명확하게 구분합니다. 이러한 구분을 이해하는 것은 인터뷰에 필수적입니다. ```swift // ProductType+Extensions.swift 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의 델리게이트와 중첩된 콜백은 더 이상 필요하지 않습니다. ### 구매 시작 ```swift // PurchaseManager.swift 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(_ result: VerificationResult) 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`를 도입합니다. ```swift // TransactionObserver.swift import StoreKit actor TransactionObserver { private var updateTask: Task? 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(_ result: VerificationResult) 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`를 통해 활성 권한에 대한 직접 액세스를 제공합니다. ```swift // SubscriptionStatusChecker.swift 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 } } ``` 이 접근 방식은 서버 호출 없이 정확한 구독 상태를 제공합니다. ### 질문 2: 무료 평가판 및 프로모션 제공을 처리하는 방법은 무엇입니까? StoreKit 2는 제공 정보에 대한 액세스를 단순화합니다. 인터뷰어는 종종 다양한 제공 유형에 대한 이해를 테스트합니다. ```swift // OfferManager.swift 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 가이드라인에 따라 필수 기능입니다. ```swift // RestoreManager.swift 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)를 도입합니다. ### 서버용 트랜잭션 데이터 추출 ```swift // ServerValidation.swift 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는 실시간 이벤트 수신을 가능하게 합니다. ```swift // ServerNotificationTypes.swift 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의 일반적인 오류 ```swift // PurchaseErrorHandler.swift 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 } } ``` 명확한 오류 처리는 사용자 경험을 개선하고 디버깅을 단순화합니다. ### 오프라인 모드 지원 ```swift // OfflineCapabilityManager.swift 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 } } ``` 로컬 캐싱은 연결이 없어도 프리미엄 기능에 대한 액세스를 유지합니다. ## 프로덕션을 위한 모범 사례 ### 권장 아키텍처 ```swift // SubscriptionService.swift 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 = [] @Published private(set) var subscriptionStatus: SubscriptionStatus = .none private var transactionObserver: Task? 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 = [] 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()`: 콘텐츠 전달 후 필수 ✅ 오프라인 처리: 원활한 경험을 위한 권한의 로컬 캐싱 --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/ko/blog/ios/storekit-2-subscriptions-receipts-validation-interview