SwiftUI 인앱결제 완벽 가이드 — StoreKit 2로 광고 제거, 구독, 구매 복원 구현하기
무료 앱을 운영하면서 Google AdMob으로 광고 수익을 얻고 있다면 한 가지 고민이 생깁니다.
광고를 보고 싶지 않은 사용자에게는 어떤 선택지를 제공해야 할까요?
가장 일반적인 방법은 광고 제거 상품을 제공하는 것입니다.
사용자가 한 번 결제하면 광고가 영구적으로 제거되는 방식입니다. 여기에 클라우드 동기화나 고급 편집 기능이 있다면 월간 또는 연간 구독 모델도 고려할 수 있습니다.
Android에서는 Google Play Billing을 사용하지만, iOS에서는 Apple의 StoreKit을 사용합니다.
특히 StoreKit 2는 Swift Concurrency를 활용해 상품 조회, 구매, 거래 검증, 구매 복원 등을 구현할 수 있도록 설계되었습니다.
이번 글에서는 사진 콜라주 앱에 광고 제거 상품과 프리미엄 구독을 추가하는 상황을 가정하고 StoreKit 2를 구현하겠습니다.
1. 이번 글에서 구현할 기능
App Store Connect 인앱결제 상품 등록
StoreKit 2 상품 목록 조회
광고 제거 일회성 상품 구매
월간·연간 자동 갱신 구독
구매 결과 및 거래 검증
앱 재실행 후 구매 권한 확인
구매 복원
구독 만료 및 환불 반영
AdMob 광고 표시 여부 연결
StoreKit Configuration 테스트
Sandbox 및 TestFlight 테스트
App Store 심사 체크리스트
개발 환경
| 항목 | 내용 |
|---|---|
| 개발 언어 | Swift |
| UI | SwiftUI |
| 최소 지원 버전 | iOS 17 |
| 결제 프레임워크 | StoreKit 2 |
| 동시성 | Swift Concurrency |
| 상태 관리 | Observation |
| 테스트 | Xcode StoreKit Testing / Sandbox |
2. Android와 iOS 인앱결제 비교
Android에서 Google Play Billing을 사용해 본 개발자라면 StoreKit 2의 구조를 이해하기 어렵지 않습니다.
| Android | iOS | 역할 |
|---|---|---|
| Google Play Billing | StoreKit 2 | 인앱결제 |
| ProductDetails | Product | 상품 정보 |
| BillingClient | StoreKit API | 스토어 연동 |
| launchBillingFlow | Product.purchase() | 구매 시작 |
| Purchase | Transaction | 구매 거래 |
| 구매 확인 처리 | Transaction.finish() | 거래 처리 완료 |
| queryPurchasesAsync | Transaction.currentEntitlements | 현재 이용 권한 확인 |
| 구독 상태 조회 | SubscriptionInfo / Transaction | 구독 관리 |
단, Android의 구매 확인 처리와 StoreKit의 finish()는 완전히 동일한 개념은 아닙니다.
StoreKit에서는 검증된 거래에 대해 필요한 권한 부여 및 처리 작업을 수행한 후 finish()를 호출해야 합니다.
3. 인앱결제 상품 유형
Apple의 인앱결제에는 여러 상품 유형이 있습니다.
| 상품 유형 | 특징 | 예시 |
|---|---|---|
| Consumable | 사용하면 소진 | 게임 코인 |
| Non-Consumable | 한 번 구매하면 지속적으로 이용 | 광고 영구 제거 |
| Auto-Renewable Subscription | 주기적으로 자동 갱신 | 월간 프리미엄 |
| Non-Renewing Subscription | 정해진 기간 동안 이용 | 기간제 콘텐츠 이용권 |
이번 예제에서는 두 가지 유형을 사용합니다.
광고 영구 제거: Non-Consumable
프리미엄 월간·연간: Auto-Renewable Subscription
광고 제거만 제공한다면 일회성 상품 하나로 충분할 수 있습니다.
반면 지속적인 서버 운영 비용이나 새로운 유료 기능이 있는 경우에는 구독 모델을 고려할 수 있습니다.
4. App Store Connect 상품 등록
먼저 App Store Connect에서 상품을 생성해야 합니다.
https://appstoreconnect.apple.com/
앱을 선택한 뒤 다음 메뉴로 이동합니다.
App Store Connect
→ Apps
→ 앱 선택
→ Monetization
→ In-App Purchases광고 제거 상품은 Non-Consumable 유형으로 생성합니다.
예시 상품 ID는 다음과 같습니다.
com.example.photocollage.removeads상품 이름은 다음과 같이 지정할 수 있습니다.
광고 영구 제거이후 가격, 현지화 정보, 심사 관련 정보를 설정합니다.
상품 ID는 코드에서 사용하므로 처음부터 일관된 규칙으로 작성하는 것이 좋습니다.
Apple은 생성한 인앱결제 상품의 Product ID와 상품 유형을 나중에 변경할 수 없도록 제한하고 있습니다.
5. 월간·연간 구독 등록
구독 상품은 App Store Connect의 Subscriptions 메뉴에서 생성합니다.
먼저 구독 그룹을 생성합니다.
Subscription Group
└── Photo Collage Premium
├── Monthly
└── Yearly예시 상품 ID는 다음과 같습니다.
com.example.photocollage.premium.monthlycom.example.photocollage.premium.yearly월간과 연간 상품이 동일한 프리미엄 기능을 제공한다면 같은 구독 그룹으로 관리하는 것이 일반적입니다.
구독 그룹과 등급 설정은 업그레이드·다운그레이드·구독 전환 동작에 영향을 주므로 실제 제공 기능에 맞춰 설계해야 합니다.
6. 유료 앱 계약 확인
실제 인앱결제를 제공하려면 App Store Connect의 유료 앱 관련 계약 상태도 확인해야 합니다.
대표적으로 다음 사항을 점검합니다.
Paid Apps Agreement
세금 정보
은행 계좌 정보
상품 가격
판매 국가 및 지역
상품 메타데이터
심사 제출 상태
특히 Paid Apps Agreement가 유효하지 않으면 인앱결제 심사 및 판매 과정에서 문제가 발생할 수 있습니다.
7. StoreKit 2 가져오기
StoreKit 2는 Apple이 제공하는 프레임워크입니다.
별도의 외부 패키지를 설치할 필요가 없습니다.
import StoreKit이제 상품 정보를 조회하고 구매를 시작할 수 있습니다.
8. 상품 ID 관리
상품 ID를 여러 화면에 직접 작성하면 오타가 발생하기 쉽습니다.
별도의 파일에서 관리하겠습니다.
StoreProductID.swift
import Foundation
enum StoreProductID {
static let removeAds =
"com.example.photocollage.removeads"
static let monthly =
"com.example.photocollage.premium.monthly"
static let yearly =
"com.example.photocollage.premium.yearly"
static let all: Set<String> = [
removeAds,
monthly,
yearly
]
static let subscriptions: Set<String> = [
monthly,
yearly
]
}실제 앱에서는 com.example.photocollage 부분을 자신의 앱에 등록한 상품 ID로 교체해야 합니다.
9. StoreKitManager 설계
인앱결제 코드를 SwiftUI View에 모두 작성하면 관리하기 어려워집니다.
따라서 별도의 StoreKitManager를 만들겠습니다.
역할은 다음과 같습니다.
상품 조회
구매 요청
거래 검증
현재 이용 권한 확인
거래 업데이트 감시
구매 복원
광고 제거 여부 제공
이번 예제에서는 @Observable을 사용하겠습니다.
10. StoreKitManager 구현
StoreKitManager.swift
import Foundation
import StoreKit
import Observation
@MainActor
@Observable
final class StoreKitManager {
private(set) var products: [Product] = []
private(set) var purchasedProductIDs:
Set<String> = []
private(set) var isLoading = false
private(set) var isPurchasing = false
private(set) var errorMessage: String?
@ObservationIgnored
private var updatesTask: Task<Void, Never>?
init() {
observeTransactions()
}
deinit {
updatesTask?.cancel()
}
var hasRemovedAds: Bool {
purchasedProductIDs.contains(
StoreProductID.removeAds
)
}
var hasPremiumSubscription: Bool {
!purchasedProductIDs
.isDisjoint(
with: StoreProductID.subscriptions
)
}
var shouldHideAds: Bool {
hasRemovedAds || hasPremiumSubscription
}
func loadProducts() async {
isLoading = true
defer {
isLoading = false
}
do {
let loaded = try await Product.products(
for: StoreProductID.all
)
products = loaded.sorted {
$0.id < $1.id
}
} catch {
errorMessage =
"상품 정보를 불러오지 못했습니다."
}
}
func purchase(
_ product: Product
) async {
guard !isPurchasing else {
return
}
isPurchasing = true
defer {
isPurchasing = false
}
do {
let result = try await product.purchase()
switch result {
case .success(let verification):
let transaction = try checkVerified(
verification
)
await refreshEntitlements()
await transaction.finish()
case .pending:
// 가족 승인 등으로 거래 완료 대기
break
case .userCancelled:
// 사용자가 구매 취소
break
@unknown default:
break
}
} catch {
errorMessage =
"구매 처리 중 오류가 발생했습니다: "
+ error.localizedDescription
}
}
func refreshEntitlements() async {
var activeIDs = Set<String>()
for await result in Transaction.currentEntitlements {
guard case .verified(let transaction) = result
else {
continue
}
guard transaction.revocationDate == nil else {
continue
}
if let expirationDate = transaction.expirationDate,
expirationDate <= Date() {
continue
}
activeIDs.insert(
transaction.productID
)
}
purchasedProductIDs = activeIDs
}
func restorePurchases() async {
do {
try await AppStore.sync()
await refreshEntitlements()
} catch {
errorMessage =
"구매 복원에 실패했습니다: "
+ error.localizedDescription
}
}
private func observeTransactions() {
updatesTask = Task {
for await result in Transaction.updates {
guard case .verified(let transaction) = result
else {
continue
}
await refreshEntitlements()
await transaction.finish()
}
}
}
private func checkVerified<T>(
_ result: VerificationResult<T>
) throws -> T {
switch result {
case .verified(let value):
return value
case .unverified:
throw StoreError.failedVerification
}
}
}
enum StoreError: LocalizedError {
case failedVerification
var errorDescription: String? {
"구매 거래 검증에 실패했습니다."
}
}이 구현은 StoreKit 2의 핵심 흐름을 하나의 Manager로 묶은 예제입니다.
실제 서비스에서는 구매 결과를 서버 계정과 연결하거나, 서버에서 권한을 관리하는 경우 추가 검증 및 동기화 처리가 필요합니다.
또한 거래 검증에 실패한 경우 권한을 부여하지 않아야 합니다.
11. 구매 결과 이해하기
product.purchase()의 결과는 여러 상태로 구분됩니다.
let result = try await product.purchase()| 결과 | 의미 |
|---|---|
| success | 거래 결과 반환 |
| pending | 거래 완료 대기 |
| userCancelled | 사용자가 구매 취소 |
중요한 점은 success가 반환되었다고 해서 무조건 유료 기능을 활성화해서는 안 된다는 것입니다.
반드시 VerificationResult를 확인해야 합니다.
case .success(let verification):
let transaction = try checkVerified(
verification
)검증된 거래에 대해서만 이용 권한을 반영해야 합니다.
12. Transaction.finish()란?
StoreKit 2에서는 처리한 거래에 대해 finish()를 호출합니다.
await transaction.finish()이 메서드는 앱이 해당 거래의 필요한 처리를 완료했음을 StoreKit에 알립니다.
일회성 광고 제거처럼 기기에서 현재 이용 권한을 재조회할 수 있는 상품은 비교적 단순하게 처리할 수 있습니다.
하지만 게임 재화처럼 소모성 상품을 제공하는 경우에는 지급 내역을 안전하게 기록하고, 중복 지급을 방지한 뒤 거래를 완료해야 합니다.
거래를 검증하고 필요한 권한을 부여하기 전에 무조건 finish()를 호출하는 방식은 피해야 합니다.
13. 현재 구매 권한 확인
사용자가 앱을 재설치하거나 다른 iPhone에서 실행할 수 있습니다.
따라서 구매 성공 여부를 단순히 UserDefaults에 저장하는 방식은 적절하지 않습니다.
StoreKit 2에서는 현재 이용 권한을 조회할 수 있습니다.
for await result in Transaction.currentEntitlements {
guard case .verified(let transaction) = result
else {
continue
}
print(transaction.productID)
}currentEntitlements는 현재 권한을 제공하는 비소모성 상품과 구독 거래 등을 조회할 때 사용할 수 있습니다.
환불 또는 철회된 상품은 현재 권한 목록에서 제외됩니다.
소모성 상품은 별도의 지급·소비 내역 관리가 필요합니다.
14. 광고 제거 여부 연결
지난 글에서 AdMob 광고를 구현했습니다.
이제 인앱결제 상태를 이용해 광고 표시 여부를 결정하겠습니다.
if !storeKitManager.shouldHideAds {
AdaptiveBannerView(
adUnitID: bannerAdUnitID
)
}광고 표시 조건은 다음과 같습니다.
광고 제거 상품 구매
또는
프리미엄 구독 활성
│
▼
광고 숨김광고 제거 상품을 구매한 사용자는 구독이 만료되더라도 광고 제거 권한을 유지해야 합니다.
반대로 프리미엄 구독으로만 광고 제거 혜택을 받은 사용자는 구독 권한이 종료되면 다시 광고가 표시될 수 있습니다.
이러한 정책은 구매 화면에서 명확하게 안내해야 합니다.
15. SwiftUI 구매 화면 만들기
PurchaseView.swift
import SwiftUI
import StoreKit
struct PurchaseView: View {
let store: StoreKitManager
var body: some View {
NavigationStack {
List {
Section {
if store.shouldHideAds {
Label(
"광고 제거 혜택 이용 중",
systemImage: "checkmark.seal.fill"
)
.foregroundStyle(.green)
}
}
Section("구매 가능한 상품") {
if store.isLoading {
ProgressView("상품 불러오는 중...")
} else {
ForEach(
store.products,
id: \.id
) { product in
productRow(product)
}
}
}
Section {
Button("구매 복원") {
Task {
await store.restorePurchases()
}
}
}
}
.navigationTitle("프리미엄")
.task {
await store.loadProducts()
await store.refreshEntitlements()
}
.alert(
"구매 안내",
isPresented: Binding(
get: {
store.errorMessage != nil
},
set: { _ in }
)
) {
Button("확인", role: .cancel) {}
} message: {
Text(store.errorMessage ?? "")
}
}
}
private func productRow(
_ product: Product
) -> some View {
HStack {
VStack(
alignment: .leading,
spacing: 6
) {
Text(product.displayName)
.font(.headline)
Text(product.description)
.font(.caption)
.foregroundStyle(.secondary)
Text(product.displayPrice)
.font(.subheadline)
}
Spacer()
Button("구매") {
Task {
await store.purchase(product)
}
}
.disabled(
store.isPurchasing
|| store.purchasedProductIDs
.contains(product.id)
)
}
}
}이 예제는 상품 정보를 목록으로 표시하고 구매 버튼을 제공합니다.
실제 앱에서는 구매 화면에 구독 기간, 자동 갱신 조건, 개인정보처리방침, 이용약관 등을 명확하게 표시해야 합니다.
또한 errorMessage를 사용자에게 표시한 뒤 초기화하는 메서드를 Manager에 추가하는 것이 좋습니다.
16. 상품 가격을 직접 입력하면 안 되는 이유
구매 화면에 가격을 다음과 같이 하드코딩하는 것은 피해야 합니다.
Text("월 3,900원")국가와 지역에 따라 가격 및 통화가 달라질 수 있기 때문입니다.
StoreKit의 Product에서 제공하는 값을 사용하는 것이 좋습니다.
Text(product.displayPrice)이렇게 하면 App Store에서 제공하는 현지화된 가격 정보를 표시할 수 있습니다.
17. 앱 전체에서 StoreKitManager 공유
구매 상태는 여러 화면에서 사용됩니다.
예를 들어 다음 화면이 모두 영향을 받습니다.
프로젝트 목록
사진 편집기
광고 영역
프리미엄 구매 화면
설정 화면
따라서 StoreKitManager를 앱 상위에서 생성하고 하위 화면에 전달하는 것이 좋습니다.
import SwiftUI
@main
struct PhotoCollageApp: App {
@State private var store =
StoreKitManager()
var body: some Scene {
WindowGroup {
MainView()
.environment(store)
.task {
await store.refreshEntitlements()
}
}
}
}하위 화면에서는 다음과 같이 접근할 수 있습니다.
@Environment(StoreKitManager.self)
private var store이 구조를 사용하면 구매 상태가 변경될 때 관련 SwiftUI 화면을 갱신하기 쉽습니다.
18. 구독 만료 처리
구독은 일회성 상품과 다르게 만료될 수 있습니다.
예를 들어 월간 구독을 사용하던 사용자가 자동 갱신을 해지했다고 가정하겠습니다.
자동 갱신을 해지했다고 해서 즉시 프리미엄 기능을 차단하는 것은 일반적으로 적절하지 않습니다.
이미 결제한 이용 기간이 남아 있다면 해당 기간 동안 권한을 유지해야 합니다.
StoreKit 2에서는 거래의 만료일과 현재 구독 상태를 확인할 수 있습니다.
if let expirationDate = transaction.expirationDate {
if expirationDate > Date() {
print("현재 이용 기간이 남아 있습니다.")
}
}다만 만료일만으로 모든 구독 상태를 판단해서는 안 됩니다.
청구 유예 기간, 구독 그룹 전환, 환불, 철회 등의 상황을 함께 고려해야 합니다.
19. 구독 상태 상세 조회
구독 상품의 상태는 Product.SubscriptionInfo를 이용해 확인할 수 있습니다.
guard let subscription = product.subscription else {
return
}
let statuses = try await subscription.status
for status in statuses {
switch status.state {
case .subscribed:
print("구독 중")
case .inGracePeriod:
print("청구 유예 기간")
case .inBillingRetryPeriod:
print("결제 재시도 기간")
case .expired:
print("구독 만료")
case .revoked:
print("구독 철회")
default:
break
}
}구독 상태에 따라 앱에서 제공할 권한을 결정해야 합니다.
예를 들어 청구 유예 기간에는 사용자의 프리미엄 이용 권한이 유지될 수 있습니다.
반면 결제 재시도 기간은 별도의 상태이므로 앱의 서비스 정책과 StoreKit 권한 정보를 함께 확인해야 합니다.
20. 구독 관리 화면 제공
사용자는 구독을 취소하거나 변경할 수 있어야 합니다.
SwiftUI에서는 시스템 구독 관리 화면을 제공할 수 있습니다.
import SwiftUI
import StoreKit
struct SubscriptionSettingsView: View {
@State private var showingManageSubscriptions = false
var body: some View {
Button("구독 관리") {
showingManageSubscriptions = true
}
.manageSubscriptionsSheet(
isPresented: $showingManageSubscriptions
)
}
}이 기능을 사용하면 사용자가 Apple의 구독 관리 인터페이스로 이동할 수 있습니다.
구독을 취소하는 기능을 앱에서 직접 임의로 구현하기보다 Apple이 제공하는 구독 관리 기능을 활용하는 것이 좋습니다.
21. 구매 복원이 필요한 이유
사용자는 다음과 같은 상황에서 구매 복원을 시도할 수 있습니다.
iPhone 교체
앱 삭제 후 재설치
동일 Apple 계정으로 다른 기기 사용
구매했지만 앱에서 권한이 표시되지 않음
StoreKit 2에서는 현재 이용 권한을 자동으로 확인할 수 있습니다.
하지만 사용자가 직접 복원을 요청할 수 있는 버튼을 제공하는 것도 중요합니다.
try await AppStore.sync()이 메서드는 사용자의 명시적인 구매 복원 요청과 연결하는 것이 적합합니다.
앱 실행 때마다 불필요하게 호출하지 않는 것이 좋습니다.
22. 환불과 구매 철회 처리
사용자가 광고 제거 상품을 구매한 뒤 환불받을 수 있습니다.
이 경우 영구적인 광고 제거 권한을 계속 유지할지 여부는 실제 거래 상태에 따라 결정해야 합니다.
StoreKit 거래에는 철회 정보를 확인할 수 있는 속성이 있습니다.
transaction.revocationDate철회된 거래라면 현재 권한을 다시 계산해야 합니다.
Transaction.currentEntitlements를 이용하면 현재 유효한 이용 권한을 다시 확인할 수 있습니다.
따라서 구매 성공 시 UserDefaults에 다음과 같이 저장하고 영구적으로 신뢰하는 방식은 피해야 합니다.
// 권장하지 않는 권한 판정 방식
UserDefaults.standard.set(
true,
forKey: "isPremium"
)로컬 저장값은 UI 표시를 위한 캐시로 활용할 수 있지만, 최종 이용 권한은 검증된 StoreKit 거래 상태를 기준으로 판단해야 합니다.
23. Transaction.updates가 중요한 이유
구매 결과는 항상 사용자가 구매 버튼을 누른 직후에만 도착하는 것은 아닙니다.
예를 들어 가족 승인 요청이 필요한 구매는 대기 상태가 될 수 있습니다.
이후 승인이 완료되면 거래 업데이트가 전달될 수 있습니다.
StoreKit 2에서는 다음과 같이 거래 업데이트를 감시할 수 있습니다.
for await result in Transaction.updates {
guard case .verified(let transaction) = result
else {
continue
}
// 이용 권한 갱신
await transaction.finish()
}앱에서 거래 업데이트를 지속적으로 관찰하면 지연된 구매 완료나 구독 관련 변경사항을 반영하는 데 도움이 됩니다.
24. StoreKit Configuration 파일 만들기
실제 결제를 하지 않고 Xcode에서 인앱결제를 테스트할 수 있습니다.
Xcode에서 다음 메뉴를 선택합니다.
File
→ New
→ File
→ StoreKit Configuration File예를 들어 파일명을 다음과 같이 지정합니다.
PhotoCollage.storekit이 파일에 테스트 상품을 추가합니다.
removeads
→ Non-Consumable
premium.monthly
→ Auto-Renewable Subscription
premium.yearly
→ Auto-Renewable Subscription상품 ID는 Swift 코드에서 사용하는 값과 정확히 일치해야 합니다.
25. StoreKit 테스트 환경 연결
Xcode에서 Scheme 설정을 엽니다.
Product
→ Scheme
→ Edit Scheme
→ Run
→ Options
→ StoreKit Configuration생성한 .storekit 파일을 선택합니다.
이제 로컬 테스트 환경에서 구매 흐름을 확인할 수 있습니다.
이 방식은 실제 App Store Connect 상품 등록을 완료하기 전에도 UI와 거래 처리 로직을 테스트하는 데 유용합니다.
26. Sandbox 테스트
App Store Connect에서는 Sandbox 테스트 계정을 생성할 수 있습니다.
Sandbox 환경에서는 실제 비용을 청구하지 않고 인앱결제 거래를 테스트할 수 있습니다.
다음 상황을 확인하는 것이 좋습니다.
일회성 상품 구매
구독 구매
구매 취소
구매 대기
구매 복원
구독 자동 갱신
구독 만료
환불
구매 중단
다른 국가 또는 지역의 상품 가격
Sandbox는 실제 App Store 인프라를 활용하므로 로컬 StoreKit Configuration 테스트와 함께 사용하는 것이 좋습니다.
27. TestFlight에서 결제 테스트
TestFlight로 배포한 앱에서도 인앱결제 테스트를 수행할 수 있습니다.
TestFlight의 인앱결제는 테스트 환경에서 처리되며 실제 요금이 청구되지 않습니다.
따라서 실제 배포와 유사한 환경에서 다음 항목을 확인할 수 있습니다.
상품 조회
구매 화면 표시
거래 검증
권한 부여
앱 재실행 후 복원
구독 갱신
광고 제거 상태 반영
단, TestFlight 테스트가 정상이라고 해서 실제 판매 설정과 심사 요건이 모두 완료되었다는 의미는 아닙니다.
28. 인앱결제 심사 준비
Apple에 인앱결제를 제출하기 전에 다음 사항을 확인해야 합니다.
상품 ID와 유형이 올바른가?
상품 이름과 설명이 실제 기능과 일치하는가?
상품 가격이 StoreKit에서 정상 표시되는가?
구매 버튼이 정상 동작하는가?
구매 취소 후 앱이 정상 동작하는가?
구매 복원 버튼이 있는가?
구독 기간과 자동 갱신 조건이 명확한가?
이용약관과 개인정보처리방침을 제공하는가?
유료 기능을 구매하지 않아도 무료 기능이 정상 동작하는가?
심사자가 구매 기능을 확인할 수 있는가?
첫 인앱결제 상품은 앱 버전 심사와 함께 제출해야 하는 경우가 있으므로 App Store Connect의 제출 절차를 확인해야 합니다.
29. 광고 제거 상품과 구독을 함께 운영할 때
이번 예제에서는 광고 제거 상품과 프리미엄 구독을 모두 제공했습니다.
하지만 실제 서비스에서는 사용자에게 혼란을 주지 않도록 상품 혜택을 명확하게 구분해야 합니다.
예를 들어 다음과 같이 구성할 수 있습니다.
| 기능 | 무료 | 광고 제거 구매 | 프리미엄 구독 |
|---|---|---|---|
| 기본 사진 콜라주 | 가능 | 가능 | 가능 |
| 배너 광고 | 표시 | 제거 | 제거 |
| 전면 광고 | 표시 가능 | 제거 | 제거 |
| 기본 템플릿 | 가능 | 가능 | 가능 |
| 고급 템플릿 | 제한 | 제한 | 이용 가능 |
| 클라우드 동기화 | 미지원 | 미지원 | 이용 가능 |
이 경우 광고 제거 상품은 일회성 결제로 제공하고, 프리미엄 구독은 지속적인 기능과 서비스를 제공하는 방식입니다.
구독을 종료하면 고급 템플릿과 클라우드 기능에 대한 권한은 종료될 수 있지만, 별도로 구매한 광고 제거 상품은 유지되어야 합니다.
30. 앱 수익화 설계 시 고려할 사항
무료 앱을 운영하면서 광고와 인앱결제를 함께 사용하는 경우 다음 사항을 고려해야 합니다.
광고 제거 가격
사용자가 광고를 제거하는 데 지불할 가치가 있는 금액인지 검토해야 합니다.
구독 기능의 지속적인 가치
월간 구독은 지속적인 서비스나 기능 제공이 있을 때 설득력이 높습니다.
무료 기능 유지
앱의 기본 목적을 수행하는 데 필요한 기능까지 과도하게 제한하면 사용자 경험이 나빠질 수 있습니다.
구매 상태 복원
앱을 다시 설치한 사용자도 구매한 혜택을 정상적으로 사용할 수 있어야 합니다.
환불 및 구독 만료
결제 후 권한을 영구적으로 고정하지 않고 현재 거래 상태에 맞춰 갱신해야 합니다.
31. 실전 테스트 체크리스트
App Store Connect 상품 등록
Non-Consumable 광고 제거 상품
월간 구독 등록
연간 구독 등록
구독 그룹 설정
StoreKit 상품 목록 조회
현지화된 가격 표시
구매 성공 처리
구매 취소 처리
구매 대기 처리
거래 검증
Transaction.finish() 처리
앱 재실행 후 권한 복원
구매 복원 버튼
광고 제거 상태 연결
구독 만료 처리
청구 유예 기간 확인
환불 및 철회 처리
구독 관리 화면
StoreKit Configuration 테스트
Sandbox 테스트
TestFlight 테스트
App Store 심사 정보 확인
32. 마무리
StoreKit 2를 사용하면 SwiftUI 앱에서 인앱결제와 구독 기능을 구현할 수 있습니다.
Android의 Google Play Billing에 익숙한 개발자라면 상품 조회, 구매 요청, 거래 확인이라는 기본 구조는 비슷하게 느껴질 것입니다.
하지만 iOS에서는 StoreKit의 거래 검증과 현재 이용 권한 확인을 올바르게 구현하는 것이 중요합니다.
이번 글의 핵심 내용을 정리하면 다음과 같습니다.
광고 영구 제거에는 Non-Consumable 상품을 사용할 수 있습니다.
월간·연간 프리미엄 기능에는 Auto-Renewable Subscription을 사용할 수 있습니다.
상품 정보는
Product.products(for:)로 조회할 수 있습니다.구매는
Product.purchase()로 시작합니다.구매 성공 결과를 받은 뒤 거래 검증이 필요합니다.
거래 처리가 완료되면
Transaction.finish()를 호출합니다.현재 이용 권한은
Transaction.currentEntitlements로 확인할 수 있습니다.Transaction.updates로 지연된 거래와 변경사항을 감시할 수 있습니다.AppStore.sync()로 사용자 요청에 따른 구매 복원을 수행할 수 있습니다.StoreKit Configuration과 Sandbox로 실제 비용 없이 결제를 테스트할 수 있습니다.
인앱결제 구현의 핵심은 구매 버튼을 만드는 것이 아니라, 사용자가 결제한 권한을 정확하게 부여하고 앱 재설치·구독 만료·환불 이후에도 일관되게 관리하는 것입니다.
공식 참고 자료
StoreKit 2
https://developer.apple.com/storekit/Product
https://developer.apple.com/documentation/storekit/productTransaction
https://developer.apple.com/documentation/storekit/transactionTransaction.currentEntitlements
https://developer.apple.com/documentation/storekit/transaction/currententitlementsApp Store Connect 인앱결제 관리
https://developer.apple.com/help/app-store-connect/manage-in-app-purchases/App Review Guidelines
https://developer.apple.com/app-store/review/guidelines/
태그: iOS, Swift, SwiftUI, StoreKit, StoreKit 2, 인앱결제, 광고 제거, 구독, 자동 갱신 구독, 구매 복원, Transaction, App Store Connect, Sandbox, TestFlight, 앱 수익화