인앱결제 및 구독 구현 가이드. 이 문서를 읽고 StoreKit 2를 구현할 수 있습니다.
StoreKit 2는 Swift Concurrency 기반의 현대적인 인앱결제 프레임워크입니다. 구독, 소모성/비소모성 상품, 프로모션 등을 구현할 수 있습니다.
import StoreKit// 상품 ID로 조회
let productIDs = ["premium_monthly", "premium_yearly", "remove_ads"]
let products = try await Product.products(for: productIDs)
for product in products {
print("\(product.displayName): \(product.displayPrice)")
}func purchase(_ product: Product) async throws -> Transaction? {
let result = try await product.purchase()
switch result {
case .success(let verification):
// 영수증 검증
let transaction = try checkVerified(verification)
// 콘텐츠 제공
await deliverProduct(transaction)
// 트랜잭션 완료
await transaction.finish()
return transaction
case .userCancelled:
return nil
case .pending:
// 승인 대기 (가족 공유 등)
return nil
@unknown default:
return nil
}
}
func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
switch result {
case .unverified:
throw StoreError.verificationFailed
case .verified(let safe):
return safe
}
}// 앱 시작 시 호출
func listenForTransactions() -> Task<Void, Error> {
return Task.detached {
for await result in Transaction.updates {
do {
let transaction = try self.checkVerified(result)
await self.deliverProduct(transaction)
await transaction.finish()
} catch {
print("트랜잭션 실패: \(error)")
}
}
}
}import SwiftUI
import StoreKit
// MARK: - Store Manager
@Observable
class StoreManager {
var products: [Product] = []
var purchasedProductIDs: Set<String> = []
var isLoading = false
private var updateListenerTask: Task<Void, Error>?
init() {
updateListenerTask = listenForTransactions()
Task {
await loadProducts()
await updatePurchasedProducts()
}
}
deinit {
updateListenerTask?.cancel()
}
// MARK: - 상품 로드
func loadProducts() async {
isLoading = true
do {
products = try await Product.products(for: [
"premium_monthly",
"premium_yearly"
])
products.sort { $0.price < $1.price }
} catch {
print("상품 로드 실패: \(error)")
}
isLoading = false
}
// MARK: - 구매 상태 확인
func updatePurchasedProducts() async {
for await result in Transaction.currentEntitlements {
guard case .verified(let transaction) = result else { continue }
if transaction.revocationDate == nil {
purchasedProductIDs.insert(transaction.productID)
} else {
purchasedProductIDs.remove(transaction.productID)
}
}
}
// MARK: - 구매
func purchase(_ product: Product) async throws -> Bool {
let result = try await product.purchase()
switch result {
case .success(let verification):
let transaction = try checkVerified(verification)
purchasedProductIDs.insert(transaction.productID)
await transaction.finish()
return true
case .userCancelled, .pending:
return false
@unknown default:
return false
}
}
// MARK: - 구매 복원
func restore() async throws {
try await AppStore.sync()
await updatePurchasedProducts()
}
// MARK: - 프리미엄 여부
var isPremium: Bool {
!purchasedProductIDs.isEmpty
}
// MARK: - 트랜잭션 리스너
private func listenForTransactions() -> Task<Void, Error> {
Task.detached {
for await result in Transaction.updates {
if case .verified(let transaction) = result {
await self.updatePurchasedProducts()
await transaction.finish()
}
}
}
}
private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
switch result {
case .unverified:
throw StoreError.verificationFailed
case .verified(let safe):
return safe
}
}
}
enum StoreError: Error {
case verificationFailed
}
// MARK: - Paywall View
struct PaywallView: View {
@Environment(StoreManager.self) var store
@Environment(\.dismiss) var dismiss
var body: some View {
NavigationStack {
VStack(spacing: 24) {
// 헤더
VStack(spacing: 8) {
Image(systemName: "crown.fill")
.font(.system(size: 60))
.foregroundStyle(.yellow)
Text("Premium 구독")
.font(.largeTitle.bold())
Text("모든 기능을 무제한으로 사용하세요")
.foregroundStyle(.secondary)
}
.padding(.top, 40)
Spacer()
// 상품 목록
if store.isLoading {
ProgressView()
} else {
VStack(spacing: 12) {
ForEach(store.products) { product in
ProductCard(product: product)
}
}
.padding(.horizontal)
}
Spacer()
// 복원 버튼
Button("구매 복원") {
Task {
try? await store.restore()
}
}
.font(.footnote)
// 약관
Text("구독은 자동 갱신됩니다. 언제든 취소할 수 있습니다.")
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
.padding(.bottom)
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("닫기") { dismiss() }
}
}
}
}
}
struct ProductCard: View {
let product: Product
@Environment(StoreManager.self) var store
var body: some View {
Button {
Task {
try? await store.purchase(product)
}
} label: {
HStack {
VStack(alignment: .leading) {
Text(product.displayName)
.font(.headline)
Text(product.description)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Text(product.displayPrice)
.font(.title3.bold())
}
.padding()
.background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.buttonStyle(.plain)
}
}
// MARK: - App
@main
struct SubscriptionApp: App {
@State var store = StoreManager()
var body: some Scene {
WindowGroup {
ContentView()
.environment(store)
}
}
}// 현재 구독 상태
for await result in Transaction.currentEntitlements {
if case .verified(let transaction) = result {
print("활성 구독: \(transaction.productID)")
print("만료일: \(transaction.expirationDate ?? Date())")
}
}
// 특정 상품 구독 여부
func isSubscribed(to productID: String) async -> Bool {
for await result in Transaction.currentEntitlements {
if case .verified(let transaction) = result,
transaction.productID == productID {
return true
}
}
return false
}// 구독 관리 시트 (iOS 15+)
.manageSubscriptionsSheet(isPresented: $showManageSubscriptions)
// 환불 요청 시트
.refundRequestSheet(for: transactionID, isPresented: $showRefund)Xcode에서 테스트용 상품 정의:
- File > New > File > StoreKit Configuration File
- 상품 추가 (+ 버튼)
- Scheme > Edit Scheme > Options > StoreKit Configuration 선택
// 예시 상품 구조
{
"identifier": "premium_monthly",
"type": "Auto-Renewable Subscription",
"displayName": "월간 구독",
"description": "매월 자동 갱신",
"price": 4.99,
"subscriptionGroupID": "premium"
}- 실기기 테스트 필수: 시뮬레이터는 제한적
- Sandbox 계정: 테스트용 Apple ID 필요
- 영수증 검증: 서버 사이드 검증 권장
- Transaction.finish(): 반드시 호출 (안 하면 재구매 불가)
- currentEntitlements: 활성 구독/구매만 반환
- 앱 > 인앱 구입 > 상품 추가
- 구독 그룹 생성 (구독의 경우)
- 가격 및 가용성 설정
- 앱 내 구입 프로모션 (선택)