Skip to content

Commit 8603fb4

Browse files
committed
💳 Phase 2: StoreKit 2 튜토리얼 추가
DocC 튜토리얼 5개 챕터 + 리소스 9개 + 블로그
1 parent 60cb690 commit 8603fb4

19 files changed

Lines changed: 626 additions & 0 deletions
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<!DOCTYPE html>
2+
<html lang="ko">
3+
<head>
4+
<meta charset="UTF-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
6+
<title>💳 구독형 앱 만들기 — HIG Lab</title>
7+
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;600;700;900&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
8+
<style>
9+
:root{--bg:#fafafa;--surface:#fff;--text:#1d1d1f;--text-2:#6e6e73;--accent:#0071e3;--green:#34c759;--border:#d2d2d7;--code-bg:#1e1e2e;--radius:16px}
10+
*{margin:0;padding:0;box-sizing:border-box}body{font-family:'Noto Sans KR',-apple-system,sans-serif;background:var(--bg);color:var(--text);line-height:1.8}
11+
.top-bar{position:sticky;top:0;z-index:100;background:rgba(250,250,250,0.88);backdrop-filter:blur(20px);border-bottom:.5px solid var(--border)}
12+
.top-bar-inner{max-width:800px;margin:0 auto;padding:12px 24px;display:flex;justify-content:space-between}
13+
.top-logo{font-size:16px;font-weight:700;text-decoration:none;color:var(--text)}.top-logo span{color:var(--accent)}
14+
article{max-width:800px;margin:0 auto;padding:48px 24px 120px}
15+
h1{font-size:clamp(32px,5vw,44px);font-weight:900;letter-spacing:-1.5px;line-height:1.2;margin-bottom:16px}
16+
h2{font-size:26px;font-weight:800;margin:48px 0 16px}p{margin-bottom:16px}
17+
.code-block{background:var(--code-bg);border-radius:12px;margin:20px 0;overflow:hidden}
18+
.code-header{padding:10px 16px;background:rgba(255,255,255,0.05);border-bottom:1px solid rgba(255,255,255,0.08);display:flex;justify-content:space-between}
19+
.code-filename{font-size:13px;color:#cdd6f4;font-family:'JetBrains Mono',monospace}
20+
pre.code-body{margin:0;padding:16px;font-family:'JetBrains Mono',monospace;font-size:13px;line-height:1.6;color:#cdd6f4;white-space:pre;overflow-x:auto}
21+
.code-body .kw{color:#cba6f7}.code-body .type{color:#89b4fa}.code-body .str{color:#a6e3a1}.code-body .func{color:#89dceb}.code-body .prop{color:#f5c2e7}.code-body .comment{color:#6c7086}
22+
</style>
23+
</head>
24+
<body>
25+
<div class="top-bar"><div class="top-bar-inner"><a href="../index.html" class="top-logo">HIG <span>Lab</span></a></div></div>
26+
<article>
27+
<h1>💳 구독형 앱 만들기</h1>
28+
<p>StoreKit 2의 현대적인 async/await API로 구독 시스템을 구현합니다.</p>
29+
30+
<h2>📦 Product 로딩</h2>
31+
<div class="code-block"><div class="code-header"><span class="code-filename">StoreManager.swift</span></div>
32+
<pre class="code-body"><span class="kw">func</span> <span class="func">loadProducts</span>() <span class="kw">async</span> {
33+
products = <span class="kw">try await</span> <span class="type">Product</span>.products(<span class="kw">for</span>: productIDs)
34+
}</pre></div>
35+
36+
<h2>💰 구매 처리</h2>
37+
<div class="code-block"><div class="code-header"><span class="code-filename">Purchase.swift</span></div>
38+
<pre class="code-body"><span class="kw">let</span> result = <span class="kw">try await</span> product.purchase()
39+
<span class="kw">switch</span> result {
40+
<span class="kw">case</span> .success(<span class="kw">let</span> verification):
41+
<span class="kw">guard case</span> .verified(<span class="kw">let</span> transaction) = verification <span class="kw">else</span> { <span class="kw">return</span> }
42+
<span class="kw">await</span> transaction.finish()
43+
<span class="kw">case</span> .userCancelled, .pending:
44+
<span class="kw">break</span>
45+
}</pre></div>
46+
47+
<h2>🎨 페이월 UI (HIG)</h2>
48+
<p>Apple HIG 페이월 가이드라인: 기능 가치 먼저 보여주기, 명확한 가격, 쉬운 복원 경로</p>
49+
50+
</article>
51+
</body>
52+
</html>

tutorials/storekit/Package.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// swift-tools-version: 5.9
2+
import PackageDescription
3+
4+
let package = Package(
5+
name: "HIGStoreKit",
6+
platforms: [.iOS(.v17), .macOS(.v14)],
7+
products: [
8+
.library(name: "HIGStoreKit", targets: ["HIGStoreKit"]),
9+
],
10+
dependencies: [
11+
.package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0"),
12+
],
13+
targets: [
14+
.target(name: "HIGStoreKit"),
15+
]
16+
)
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# ``HIGStoreKit``
2+
3+
StoreKit 2를 활용한 인앱결제와 구독 시스템을 구현합니다.
4+
5+
## Overview
6+
7+
이 튜토리얼에서는 구독형 앱을 만들며 StoreKit 2의 핵심 개념을 학습합니다.
8+
9+
- Product 로딩 및 표시
10+
- 구매 플로우 구현
11+
- 구독 상태 관리
12+
- HIG 준수 페이월 UI
13+
14+
## Topics
15+
16+
### Tutorials
17+
18+
- <doc:tutorials/Table-of-Contents>
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import StoreKit
2+
3+
// MARK: - StoreKit 2 주요 타입
4+
5+
/*
6+
Product: 상품 정보 (가격, 설명 등)
7+
Transaction: 구매 트랜잭션
8+
Transaction.currentEntitlements: 현재 권한
9+
AppStore: 앱스토어 관련 유틸리티
10+
*/
11+
12+
// 상품 타입
13+
enum ProductType {
14+
case consumable // 소모품 (코인, 보석)
15+
case nonConsumable // 비소모품 (광고 제거, 프리미엄)
16+
case autoRenewable // 자동 갱신 구독 (월간/연간)
17+
case nonRenewable // 비자동 갱신 구독 (시즌 패스)
18+
}
19+
20+
// HIG 가이드라인
21+
/*
22+
1. 명확한 가치 전달: 구매 전 기능 미리보기
23+
2. 투명한 가격: 현지 통화로 명확히 표시
24+
3. 쉬운 복원: "구매 복원" 버튼 제공
25+
4. 구독 관리: 설정 > 구독으로 쉽게 이동
26+
*/
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import SwiftUI
2+
import StoreKit
3+
4+
struct ProductListView: View {
5+
@StateObject var store = StoreManager()
6+
7+
var body: some View {
8+
List(store.products) { product in
9+
ProductRow(product: product)
10+
}
11+
.task {
12+
await store.loadProducts()
13+
}
14+
}
15+
}
16+
17+
struct ProductRow: View {
18+
let product: Product
19+
20+
var body: some View {
21+
HStack {
22+
VStack(alignment: .leading, spacing: 4) {
23+
Text(product.displayName)
24+
.font(.headline)
25+
26+
Text(product.description)
27+
.font(.subheadline)
28+
.foregroundStyle(.secondary)
29+
}
30+
31+
Spacer()
32+
33+
// 현지 통화로 자동 포맷
34+
Text(product.displayPrice)
35+
.font(.headline)
36+
.foregroundStyle(.blue)
37+
}
38+
.padding(.vertical, 8)
39+
}
40+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import StoreKit
2+
3+
@MainActor
4+
class StoreManager: ObservableObject {
5+
@Published var products: [Product] = []
6+
@Published var purchasedProductIDs: Set<String> = []
7+
8+
// 상품 ID (App Store Connect에서 설정)
9+
let productIDs = [
10+
"com.example.premium.monthly",
11+
"com.example.premium.yearly"
12+
]
13+
14+
// MARK: - 상품 로딩
15+
16+
func loadProducts() async {
17+
do {
18+
products = try await Product.products(for: productIDs)
19+
20+
// 가격순 정렬
21+
products.sort { $0.price < $1.price }
22+
23+
print("상품 \(products.count)개 로딩 완료")
24+
} catch {
25+
print("상품 로딩 실패: \(error)")
26+
}
27+
}
28+
29+
// MARK: - 구매 상태 확인
30+
31+
func updatePurchasedProducts() async {
32+
for await result in Transaction.currentEntitlements {
33+
guard case .verified(let transaction) = result else { continue }
34+
purchasedProductIDs.insert(transaction.productID)
35+
}
36+
}
37+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import StoreKit
2+
3+
extension StoreManager {
4+
5+
// MARK: - 구매 처리
6+
7+
func purchase(_ product: Product) async throws -> Transaction? {
8+
let result = try await product.purchase()
9+
10+
switch result {
11+
case .success(let verification):
12+
// 서명 검증
13+
guard case .verified(let transaction) = verification else {
14+
throw StoreError.verificationFailed
15+
}
16+
17+
// 트랜잭션 완료 처리
18+
await transaction.finish()
19+
20+
// 구매 상태 업데이트
21+
purchasedProductIDs.insert(transaction.productID)
22+
23+
return transaction
24+
25+
case .userCancelled:
26+
return nil
27+
28+
case .pending:
29+
// 부모 승인 대기 등
30+
return nil
31+
32+
@unknown default:
33+
return nil
34+
}
35+
}
36+
}
37+
38+
enum StoreError: Error {
39+
case verificationFailed
40+
case purchaseFailed
41+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import StoreKit
2+
3+
extension StoreManager {
4+
5+
// MARK: - 트랜잭션 리스너
6+
// 앱 시작 시 호출해야 함
7+
8+
func listenForTransactions() -> Task<Void, Error> {
9+
Task.detached {
10+
// 백그라운드에서 트랜잭션 업데이트 청취
11+
for await result in Transaction.updates {
12+
do {
13+
let transaction = try self.checkVerified(result)
14+
15+
// 구매 상태 업데이트
16+
await self.updatePurchasedProducts()
17+
18+
// 트랜잭션 완료
19+
await transaction.finish()
20+
} catch {
21+
print("트랜잭션 처리 실패: \(error)")
22+
}
23+
}
24+
}
25+
}
26+
27+
func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
28+
switch result {
29+
case .unverified:
30+
throw StoreError.verificationFailed
31+
case .verified(let safe):
32+
return safe
33+
}
34+
}
35+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import StoreKit
2+
3+
extension StoreManager {
4+
5+
// MARK: - 구독 상태 확인
6+
7+
func checkSubscriptionStatus() async -> SubscriptionStatus? {
8+
// 모든 현재 권한 확인
9+
for await result in Transaction.currentEntitlements {
10+
guard case .verified(let transaction) = result else { continue }
11+
12+
// 구독 상품만 필터
13+
if transaction.productType == .autoRenewable {
14+
return SubscriptionStatus(
15+
isActive: true,
16+
productID: transaction.productID,
17+
expirationDate: transaction.expirationDate,
18+
willAutoRenew: transaction.revocationDate == nil
19+
)
20+
}
21+
}
22+
23+
return nil
24+
}
25+
}
26+
27+
struct SubscriptionStatus {
28+
let isActive: Bool
29+
let productID: String
30+
let expirationDate: Date?
31+
let willAutoRenew: Bool
32+
33+
var daysRemaining: Int? {
34+
guard let expiration = expirationDate else { return nil }
35+
return Calendar.current.dateComponents(
36+
[.day], from: Date(), to: expiration
37+
).day
38+
}
39+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import SwiftUI
2+
3+
struct SubscriptionStatusView: View {
4+
let status: SubscriptionStatus
5+
6+
var body: some View {
7+
VStack(spacing: 16) {
8+
// 구독 상태 배지
9+
HStack {
10+
Image(systemName: status.isActive ? "checkmark.seal.fill" : "xmark.seal")
11+
.foregroundStyle(status.isActive ? .green : .red)
12+
Text(status.isActive ? "프리미엄 활성" : "프리미엄 만료")
13+
.font(.headline)
14+
}
15+
16+
if let days = status.daysRemaining, days > 0 {
17+
Text("\(days)일 후 갱신")
18+
.font(.subheadline)
19+
.foregroundStyle(.secondary)
20+
}
21+
22+
// 구독 관리 버튼
23+
Button("구독 관리") {
24+
openSubscriptionManagement()
25+
}
26+
.buttonStyle(.bordered)
27+
}
28+
.padding()
29+
}
30+
31+
func openSubscriptionManagement() {
32+
if let url = URL(string: "itms-apps://apps.apple.com/account/subscriptions") {
33+
UIApplication.shared.open(url)
34+
}
35+
}
36+
}

0 commit comments

Comments
 (0)