feat(premium): StoreKit 2 결제 레이어 구현 (실결제 연결 직전 단계)

- StoreKitEntitlementProvider: Transaction.currentEntitlements 기반 실권한
  조회 + Transaction.updates 이벤트 반영. 앱 시작 시 PremiumManager.bootstrap
  으로 주입 (확장 타깃은 기존 PremiumGate 캐시 그대로)
- PremiumStore: 상품 로드/구매/복원 (월·년 구독 + 평생 이용권 3종)
- PremiumView: 실구매 UI (상품 목록·가격·구매 복원, 미로드 시 재시도 폴백)
- DEBUG 강제 프리미엄(-premium, 테스트 토글) 중에는 StoreKit 결과가
  덮어쓰지 않도록 보호
- HaruDanim.storekit 로컬 테스트 구성(월 5,000 / 년 50,000 / 일회성 80,000원,
  ko·en·ja 현지화) + 메인 스킴에 연결 → Xcode 실행으로 결제 흐름 테스트 가능
- 남은 것: App Store Connect에 productID 3종 등록만 하면 실결제 연결 완료

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
This commit is contained in:
songyc macbook 2026-07-10 14:49:32 +09:00
parent 0cdb566ad5
commit 1e62c082d3
7 changed files with 458 additions and 16 deletions

View File

@ -0,0 +1,132 @@
{
"identifier" : "F1A2B3C4-0001-4A2B-9C3D-5E6F7A8B9C0D",
"nonRenewingSubscriptions" : [
],
"products" : [
{
"displayPrice" : "80000",
"familyShareable" : false,
"internalID" : "F1A2B3C4-0002-4A2B-9C3D-5E6F7A8B9C0D",
"localizations" : [
{
"description" : "한 번 결제로 모든 프리미엄 기능을 평생 사용해요",
"displayName" : "평생 이용권",
"locale" : "ko"
},
{
"description" : "Pay once and use all premium features forever",
"displayName" : "Lifetime",
"locale" : "en"
},
{
"description" : "1回の購入ですべてのプレミアム機能をずっと利用できます",
"displayName" : "買い切り",
"locale" : "ja"
}
],
"productID" : "com.yechan.HaruDanim.premium.lifetime",
"referenceName" : "Premium Lifetime",
"type" : "NonConsumable"
}
],
"settings" : {
"_failTransactionsEnabled" : false,
"_locale" : "ko_KR",
"_storefront" : "KOR",
"_storeKitErrors" : [
]
},
"subscriptionGroups" : [
{
"id" : "F1A2B3C4-0003-4A2B-9C3D-5E6F7A8B9C0D",
"localizations" : [
],
"name" : "HaruDanim Premium",
"subscriptions" : [
{
"adHocOffers" : [
],
"codeOffers" : [
],
"displayPrice" : "5000",
"familyShareable" : false,
"groupNumber" : 1,
"internalID" : "F1A2B3C4-0004-4A2B-9C3D-5E6F7A8B9C0D",
"introductoryOffer" : null,
"localizations" : [
{
"description" : "모든 프리미엄 기능을 월 단위로 구독해요",
"displayName" : "월 구독",
"locale" : "ko"
},
{
"description" : "Subscribe to all premium features monthly",
"displayName" : "Monthly",
"locale" : "en"
},
{
"description" : "すべてのプレミアム機能を月単位で購読します",
"displayName" : "月額プラン",
"locale" : "ja"
}
],
"productID" : "com.yechan.HaruDanim.premium.monthly",
"recurringSubscriptionPeriod" : "P1M",
"referenceName" : "Premium Monthly",
"subscriptionGroupID" : "F1A2B3C4-0003-4A2B-9C3D-5E6F7A8B9C0D",
"type" : "RecurringSubscription",
"winbackOffers" : [
]
},
{
"adHocOffers" : [
],
"codeOffers" : [
],
"displayPrice" : "50000",
"familyShareable" : false,
"groupNumber" : 1,
"internalID" : "F1A2B3C4-0005-4A2B-9C3D-5E6F7A8B9C0D",
"introductoryOffer" : null,
"localizations" : [
{
"description" : "모든 프리미엄 기능을 년 단위로 구독해요",
"displayName" : "년 구독",
"locale" : "ko"
},
{
"description" : "Subscribe to all premium features yearly",
"displayName" : "Yearly",
"locale" : "en"
},
{
"description" : "すべてのプレミアム機能を年単位で購読します",
"displayName" : "年額プラン",
"locale" : "ja"
}
],
"productID" : "com.yechan.HaruDanim.premium.yearly",
"recurringSubscriptionPeriod" : "P1Y",
"referenceName" : "Premium Yearly",
"subscriptionGroupID" : "F1A2B3C4-0003-4A2B-9C3D-5E6F7A8B9C0D",
"type" : "RecurringSubscription",
"winbackOffers" : [
]
}
]
}
],
"version" : {
"major" : 4,
"minor" : 0
}
}

View File

@ -0,0 +1,82 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2660"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "725A9E832FFCC65000846FFB"
BuildableName = "Haru_Danim.app"
BlueprintName = "Haru_Danim"
ReferencedContainer = "container:Haru_Danim.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
queueDebuggingEnableBacktraceRecording = "Yes">
<StoreKitConfigurationFileReference
identifier = "../../HaruDanim.storekit">
</StoreKitConfigurationFileReference>
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "725A9E832FFCC65000846FFB"
BuildableName = "Haru_Danim.app"
BlueprintName = "Haru_Danim"
ReferencedContainer = "container:Haru_Danim.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "725A9E832FFCC65000846FFB"
BuildableName = "Haru_Danim.app"
BlueprintName = "Haru_Danim"
ReferencedContainer = "container:Haru_Danim.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@ -17,8 +17,11 @@ struct ContentView: View {
ZStack {
#if DEBUG
// : -widgetPreview YES( ) / lock( )
// -premiumPreview YES
if let mode = UserDefaults.standard.string(forKey: "widgetPreview") {
WidgetPreviewScreen(showLock: mode == "lock")
} else if UserDefaults.standard.bool(forKey: "premiumPreview") {
NavigationStack { PremiumView() }
} else {
MainTabView()
}

View File

@ -0,0 +1,129 @@
//
// Store.swift
// Haru_Danim
//
// StoreKit 2 (CLAUDE.md §1 , §7.2)
// - StoreKitEntitlementProvider: Transaction.currentEntitlements .
// PremiumManager.bootstrap .
// - PremiumStore: / / . PremiumView .
// - App Store Connect productID 3 .
// : HaruDanim.storekit .
//
import Foundation
import Observation
import StoreKit
// MARK: -
nonisolated enum PremiumProducts {
/// ( 5,000)
static let monthly = "com.yechan.HaruDanim.premium.monthly"
/// ( 50,000)
static let yearly = "com.yechan.HaruDanim.premium.yearly"
/// ( 80,000)
static let lifetime = "com.yechan.HaruDanim.premium.lifetime"
static let all: Set<String> = [monthly, yearly, lifetime]
///
static func sortOrder(_ id: String) -> Int {
switch id {
case monthly: return 0
case yearly: return 1
default: return 2
}
}
}
// MARK: - (PremiumManager )
/// StoreKit 2 EntitlementProvider
nonisolated struct StoreKitEntitlementProvider: EntitlementProvider {
func fetchIsEntitled() async -> Bool {
for await result in Transaction.currentEntitlements {
if case .verified(let transaction) = result,
PremiumProducts.all.contains(transaction.productID),
transaction.revocationDate == nil {
return true
}
}
return false
}
func entitlementUpdates() -> AsyncStream<Bool> {
AsyncStream { continuation in
let task = Task {
// ··
for await update in Transaction.updates {
if case .verified(let transaction) = update {
await transaction.finish()
}
continuation.yield(await fetchIsEntitled())
}
continuation.finish()
}
continuation.onTermination = { _ in task.cancel() }
}
}
}
// MARK: - ( )
@MainActor
@Observable
final class PremiumStore {
static let shared = PremiumStore()
private(set) var products: [Product] = []
private(set) var isLoading = false
private(set) var isPurchasing = false
/// (nil )
private(set) var lastErrorMessage: String?
/// App Store ( )
func loadProducts() async {
guard products.isEmpty, !isLoading else { return }
isLoading = true
defer { isLoading = false }
do {
let fetched = try await Product.products(for: PremiumProducts.all)
products = fetched.sorted {
PremiumProducts.sortOrder($0.id) < PremiumProducts.sortOrder($1.id)
}
lastErrorMessage = nil
} catch {
lastErrorMessage = String(localized: "상품 정보를 불러오지 못했어요. 네트워크를 확인해 주세요.")
}
}
/// . PremiumManager
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):
if case .verified(let transaction) = verification {
await transaction.finish()
}
await PremiumManager.shared.refresh()
lastErrorMessage = nil
case .userCancelled, .pending:
break
@unknown default:
break
}
} catch {
lastErrorMessage = String(localized: "구매를 완료하지 못했어요. 잠시 후 다시 시도해 주세요.")
}
}
/// ( · )
func restore() async {
try? await AppStore.sync()
await PremiumManager.shared.refresh()
}
}

View File

@ -16,6 +16,8 @@ struct Haru_DanimApp: App {
@Environment(\.scenePhase) private var scenePhase
init() {
// ( shared )
PremiumManager.bootstrap(provider: StoreKitEntitlementProvider())
SettingsMigration.runIfNeeded()
container = DataStore.shared
DataStore.ensureUniqueEntityIDs(context: container.mainContext)

View File

@ -7,6 +7,7 @@
import SwiftUI
import SwiftData
import StoreKit
import WidgetKit
struct SettingsView: View {
@ -246,6 +247,7 @@ struct SettingsView: View {
struct PremiumView: View {
private let premium = PremiumManager.shared
private let store = PremiumStore.shared
@AppStorage(DataStore.cloudSyncKey, store: AppGroup.defaults) private var cloudSync = false
var body: some View {
@ -269,16 +271,7 @@ struct PremiumView: View {
Text("iCloud로 아이패드 등 다른 기기와 데이터가 동기화돼요. 변경 사항은 앱을 완전히 종료했다가 다시 실행하면 적용돼요.")
}
} else {
Section {
Button {
// StoreKit Apple Developer Program (CLAUDE.md §2.3)
} label: {
Label("프리미엄 구매 (준비 중)", systemImage: "cart.fill")
}
.disabled(true)
} footer: {
Text("결제 기능은 App Store 배포 준비와 함께 제공될 예정이에요.")
}
purchaseSection
}
#if DEBUG
Section("개발용") {
@ -294,6 +287,80 @@ struct PremiumView: View {
.navigationTitle("프리미엄")
.navigationBarTitleDisplayMode(.inline)
.toolbar(.hidden, for: .tabBar)
.task {
await store.loadProducts()
}
}
// MARK: (StoreKit 2)
@ViewBuilder
private var purchaseSection: some View {
Section {
if store.products.isEmpty {
if store.isLoading {
HStack {
ProgressView()
Text("상품 정보를 불러오는 중...")
.font(.caption)
.foregroundStyle(.secondary)
}
} else {
// App Store Connect
Label("상품 정보를 불러올 수 없어요", systemImage: "wifi.exclamationmark")
.font(.callout)
.foregroundStyle(.secondary)
Button("다시 시도") {
Task { await store.loadProducts() }
}
}
} else {
ForEach(store.products) { product in
productRow(product)
}
}
Button {
Task { await store.restore() }
} label: {
Text("구매 복원")
.font(.callout)
}
.disabled(store.isPurchasing)
} header: {
Text("프리미엄 구매")
} footer: {
if let message = store.lastErrorMessage {
Text(message).foregroundStyle(.red)
} else {
Text("구독은 언제든 해지할 수 있고, 평생 이용권은 한 번 결제로 모든 기능을 계속 사용해요.")
}
}
}
private func productRow(_ product: Product) -> some View {
Button {
Task { await store.purchase(product) }
} label: {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(product.displayName)
.font(.body.weight(.semibold))
.foregroundStyle(.primary)
if !product.description.isEmpty {
Text(product.description)
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
Text(product.displayPrice)
.font(.body.weight(.bold))
.foregroundStyle(AppTheme.green)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(store.isPurchasing)
}
private func featureRow(_ symbol: String, _ title: String, _ detail: String) -> some View {

View File

@ -102,11 +102,27 @@ nonisolated enum PremiumGate {
@MainActor
@Observable
final class PremiumManager {
static let shared = PremiumManager()
static private(set) var shared = PremiumManager()
/// (StoreKitEntitlementProvider) .
/// (·) bootstrap Mock + PremiumGate .
static func bootstrap(provider: any EntitlementProvider) {
shared = PremiumManager(provider: provider)
}
private(set) var isPremium: Bool
@ObservationIgnored private let provider: any EntitlementProvider
#if DEBUG
/// (-premium · )
/// StoreKit
static let mockActiveKey = "debug.premiumMockActive"
private var isMockActive: Bool {
UserDefaults.standard.object(forKey: "premium") != nil
|| AppGroup.defaults.bool(forKey: Self.mockActiveKey)
}
#endif
init(provider: any EntitlementProvider = MockEntitlementProvider()) {
self.provider = provider
Self.migrateLegacyFlagIfNeeded()
@ -119,9 +135,9 @@ final class PremiumManager {
self.isPremium = PremiumGate.isPremium
Task { [weak self] in
guard let self else { return }
self.apply(await self.provider.fetchIsEntitled())
self.applyFromProvider(await self.provider.fetchIsEntitled())
for await value in self.provider.entitlementUpdates() {
self.apply(value)
self.applyFromProvider(value)
}
}
}
@ -147,13 +163,24 @@ final class PremiumManager {
// MARK:
/// / (StoreKit )
/// / ( )
func refresh() async {
apply(await provider.fetchIsEntitled())
applyFromProvider(await provider.fetchIsEntitled())
}
/// . StoreKit DEBUG
#if DEBUG
/// (DEBUG ).
func setMockPremium(_ value: Bool) {
AppGroup.defaults.set(true, forKey: Self.mockActiveKey)
apply(value)
}
#endif
/// (StoreKit) . DEBUG
private func applyFromProvider(_ value: Bool) {
#if DEBUG
guard !isMockActive else { return }
#endif
apply(value)
}