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

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로 이 작업을 단순화합니다.
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는 다양한 제품 유형을 명확하게 구분합니다. 이러한 구분을 이해하는 것은 인터뷰에 필수적입니다.
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의 델리게이트와 중첩된 콜백은 더 이상 필요하지 않습니다.
구매 시작
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를 도입합니다.
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를 통해 활성 권한에 대한 직접 액세스를 제공합니다.
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는 제공 정보에 대한 액세스를 단순화합니다. 인터뷰어는 종종 다양한 제공 유형에 대한 이해를 테스트합니다.
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 가이드라인에 따라 필수 기능입니다.
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)를 도입합니다.
서버용 트랜잭션 데이터 추출
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에는 서버에서 검증할 수 있는 암호화 서명이 포함되어 있습니다.
Apple은 이제 서버 측 검증을 위한 App Store Server API를 제공합니다. 이 최신 API는 더 이상 사용되지 않는 verifyReceipt 엔드포인트를 대체하고 서버 간 알림과 같은 고급 기능을 제공합니다.
서버 알림 구성
Server Notifications V2는 실시간 이벤트 수신을 가능하게 합니다.
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의 일반적인 오류
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
}
}명확한 오류 처리는 사용자 경험을 개선하고 디버깅을 단순화합니다.
오프라인 모드 지원
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
}
}로컬 캐싱은 연결이 없어도 프리미엄 기능에 대한 액세스를 유지합니다.
프로덕션을 위한 모범 사례
권장 아키텍처
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(): 콘텐츠 전달 후 필수
✅ 오프라인 처리: 원활한 경험을 위한 권한의 로컬 캐싱
연습을 시작하세요!
면접 시뮬레이터와 기술 테스트로 지식을 테스트하세요.
태그
공유
관련 기사

Swift Testing Framework 면접 2026: #expect와 #require 매크로 vs XCTest
iOS 면접을 위한 새로운 Swift Testing Framework를 마스터합니다: #expect와 #require 매크로, XCTest 마이그레이션, 고급 패턴 및 흔한 함정.

iOS 푸시 알림 면접 2026: APNs, 토큰, 트러블슈팅
Push Notifications, APNs, 토큰 관리, 트러블슈팅에 관한 iOS 면접 준비 완벽 가이드입니다. 자주 묻는 질문에 상세한 답변을 함께 담았습니다.

iOS 개발자를 위한 Swift 면접 질문 Top 25
iOS 면접에서 가장 자주 출제되는 Swift 질문 25개를 코드 예제와 함께 완벽 정리. 옵셔널, 클로저, ARC, 프로토콜, async/await 및 고급 패턴까지 체계적으로 다룹니다.