포스트

HP Trivia (11)

In-App 결제 기능 구현

개발자 멤버십을 새롭게 연장한 기념으로, 중단되었던 강의 내용을 다시 정리한다. 인앱 결제(IAP) 기능을 구현하기 위해서는 Apple 개발자 멤버십 가입이 필수적이다.

결제 기능을 구현할 때 핵심이 되는 프레임워크는 StoreKit이다.

1. StoreKit Configuration File 생성

실제 App Store Connect에 상품을 등록하기 전, 로컬 환경에서 결제 테스트를 진행하기 위해 설정 파일을 만든다.

image

  • Next를 누르면 나타나는 팀 설정 창은 지금 단계에서는 체크하지 않고 넘어가도 무방하다. image

2. 결제 상품(Product) 추가

파일이 생성되면 좌측 하단의 + 버튼을 눌러 결제 항목을 추가한다.

image image

우리는 한 번 결제하면 영구적으로 유지되는 Non-Consumable(비소모품) 유형을 선택한다.

Image

  • Reference Name: HP4
  • Product ID: hp4

하단의 Localizations 섹션을 더블 클릭하여 사용자에게 보일 상품 정보를 입력한다. Image

3. 상품 복사 및 확장

HP4부터 HP7까지 여러 상품이 필요하므로, 기존 항목을 Copy & Paste하여 빠르게 생성한다. 생성 후 Product IDLocalizations 정보를 각 상품에 맞게 수정한다.

Image

4. Edit Scheme 설정 적용

설정 파일을 만든 것만으로는 부족하다. Xcode의 Scheme 설정을 변경하여 앱 실행 시 해당 StoreKit 파일을 참조하도록 지정해야 한다.

Image

  • None으로 되어 있는 StoreKit Configuration 항목을 방금 만든 파일로 변경한다.

Image Image

이로써 로컬 환경에서 인앱 결제를 테스트하기 위한 모든 준비가 완료되었다.

ViewModel 작성하기

인앱결제를 하고 그에 상호작용하는 기능을 구현하기 위해 ViewModel을 만들어 주도록 한다.

1
2
3
4
5
6
7
8
import StoreKit

@MainActor
@Observable

class Store {
    
}

이때 가장 중요한건 StoreKit을 Import 해주는 것이다.

이 ViewModel은 selectbooks와 관련이 있기에 SelectBooksView에 가서 vm을 객체화 해준다.

Image

이렇게 selectbooks에서 결제시 해금을 하는 구조로 되어있기 때문이다.

1
2
3
4
5
6
7
8
9
struct SelectBooksView: View {
    // 생략

    private var store = Store() //new
    
    var activeBooks: Bool {
       // 생략
    }
}

이제 VM을 더 구체화 해보도록 한다.

Properties

Store 클래스의 속성들은 JSON Decoding 과정과 유사하게 구성된다. Apple 시스템으로부터 데이터를 받아와 우리 앱에 맞는 형태로 저장해야 하기 때문이다.

  1. products: [Product]
    • StoreKit에서 제공하는 Product 타입을 담는 배열이다. App Store(또는 Config 파일)로부터 로드된, 사용자가 구매 가능한 실제 상품 리스트가 저장된다.
  2. purchased: Set<String>
    • 구매 완료된 상품 ID를 저장한다. 이때 Array 대신 Set을 사용하는 이유는 다음과 같다.
      • 중복 방지: Set은 고유한 값만 저장한다. 이미 구매한 상품 ID가 다시 들어오더라도 중복 저장되지 않으므로 구매 상태 관리가 훨씬 안전하다.
      • 성능 최적화: 특정 상품의 구매 여부를 확인할 때, 배열보다 탐색 속도가 훨씬 빠르다.
  3. updates: Task<Void, Never>?
    • 결제 완료 후 App Store에서 전달되는 실시간 업데이트(트랜잭션 변화)를 감시하기 위한 속성이다.
    • ‘Ask to Buy(부모 승인)’나 앱 외부 구매와 같이 비동기적으로 발생하는 이벤트를 앱이 백그라운드에서 계속 관찰(Watch)할 수 있게 해준다.
1
2
3
4
5
6
7
8
9
10
class Store {
    // 구매 가능한 전체 상품 리스트
    var products: [Product] = []
    
    // 구매가 완료된 상품 ID 모음 (중복 방지를 위해 Set 사용)
    var purchased = Set<String>()
    
    // App Store 트랜잭션 업데이트를 비동기로 감시할 Task
    private var updates: Task<Void, Never>? = nil
}

ViewModel 구현

이제 기능을 구현할 차례인데 Step은 다음과 같다.

  1. Load available products
  2. Purchase a product
  3. Check for purchased products
  4. Connect with App Store to watch for purchase and transaction updates

1. 사용 가능한 상품 리스트 로드 (Load available products)

1
2
3
4
5
6
7
8
9
10
    func loadProducts() async {
        do {
            products = try await Product.products(for: ["hp4", "hp5", "hp6", "hp7"])
            products.sort {
                $0.displayName < $1.displayName
            }
        } catch {
            print("unable to load products: \(error)")
        }
    }
  • 비동기 처리: try await를 사용하여 App Store에서 정보를 가져오는 동안 앱이 멈추지 않게 한다.

  • 데이터 정렬: 데이터 로드 시 순서가 보장되지 않으므로, 사용자 경험을 위해 표시 이름(displayName) 등을 기준으로 정렬하는 과정이 필요하다.

이제 SelectBooksView가 나타날 때 상품을 로드하도록 설정한다. SwiftUI의 .task 모디파이어를 활용한다

1
2
3
4
5
6
7
8
9
10
11
12
13
struct SelectBooksView: View {
    // 생략
     var body: some View {
        // 생략
        }
        .interactiveDismissDisabled(!activeBooks)
        .alert("You purchased a new question pack. Yay!", isPresented: $showTempAlert) {
            
        }
        .task { // new
            await store.loadProducts()
        }
}
  • Life Cycle: .task는 뷰가 나타날 때 비동기 작업을 시작하고, 뷰가 사라지면 작업을 자동으로 취소하여 자원 낭비를 막아준다.
  • 실시간 로드: 사용자가 책 선택 화면에 진입하는 즉시 최신 상품 정보를 불러와 화면에 반영한다.

2. 상품 구매 실행 (Purchase a product)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
 func purchase(_ product: Product) async {
        do {
            let result = try await product.purchase()
            
            switch result {
                // Purchase successful, but now we need to verify receipt and transaction
            case .success(let verificationResult):
                switch verificationResult {
                case .unverified(let signedType, let verificationError):
                    print("Error on \(signedType): \(verificationError)")
                case .verified(let signedType):
                    purchased.insert(signedType.productID)
                    
                    await signedType.finish()
                    
                }
                
                // User cancelled or parent disapproved child's purchase request
            case .userCancelled:
                break
                
                // Wating for some of approval
            case .pending:
                break
            @unknown default:
                break
            }
        } catch {
            print("Unable to purchase product: \(error)")
        }
    }

비동기 작업으로 구매를 진행하는 함수를 구현한다. 이때 파라미터로 받는 Product 타입은 StoreKit 프레임워크에서 제공하는 구조체로, 우리가 설정한 상품 정보를 담고 있다.

image

구매 결과 처리 (PurchaseResult) 구매 요청을 보내면 result 값을 반환받으며, 결과에 따라 로직이 달라지므로 기본적으로 Switch-case 양식을 사용한다.

  • .success: 구매 요청이 성공한 상태이다. 하지만 구입 후 영수증 검증(VerificationResult)이 되었느냐에 따라 다시 verifiedunverified로 나뉘게 된다.
  • .userCancelled: 사용자가 직접 취소했거나 부모가 승인을 거절한 경우이다.
  • .pending: 부모 승인(Ask to Buy) 등을 기다리는 대기 상태이다.

Image

영수증 검증 및 트랜잭션 종료

  • verified: 검증이 완료된 상태라면 purchased Set에 상품 ID를 추가하고, signedType.finish()를 호출하여 트랜잭션을 최종적으로 마무리한다.
  • unverified: 서명 오류 등이 발생한 경우이며, 에러 로그를 출력한다.

강의에서는 그냥 default를 쓰는 것과 @unknown default를 쓰는 것의 차이를 언급한다.

@unknown default: 문제가 터지기 전에 미리 알려주는 경고등 내가 이해한 바로는 이 둘은 ‘문제를 언제 발견하느냐’의 차이이다.

  • 그냥 default:
    • 현재 정의된 케이스 외에는 전부 이쪽으로 흐르게 된다.
    • 만약 나중에 애플이 새로운 결제 상태를 추가하더라도, 컴파일러는 아무런 말이 없다.
    • 결국 실제 사용자가 앱을 쓰다가 에러가 발생해야만 “아, 케이스 하나를 빼먹었구나” 하고 뒤늦게 깨닫게 되는 위험이 있다.
  • @unknown default:
    • 똑같이 나머지 케이스를 처리하지만, 미래에 새로운 케이스가 생기는 순간 Xcode가 노란색 경고(Warning)를 띄워준다.
    • “네가 처리 안 한 새로운 상황이 생겼으니 확인해 봐!”라고 미리 알려주는 셈이다.
    • 즉, 실제 구동 중에 문제가 터질 때까지 기다리는 게 아니라, 개발 단계에서 컴파일러의 도움을 받아 미리 대비할 수 있게 해주는 안전장치 역할을 한다.

결론적으로, 내 의도와 다르게 동작하는 엣지 케이스를 놓치고 싶지 않다면 @unknown default를 써서 실시간으로 피드백을 받는 것이 훨씬 마음 편한 작업 방식이 된다.

이건 다시한번 예시 코드를 하나 만들어서 정리를 할 예정.


3. 구매 완료 내역 확인 (Check for purchased products)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
    private func checkPurchased() async {
        for product in products {
            guard let stauts = await product.currentEntitlement else { continue }
            
            switch stauts {
            case .unverified(let signedType, let verificationError):
                print("Error on \(signedType): \(verificationError)")
            case .verified(let signedType):
                if signedType.revocationDate == nil {
                    purchased.insert(signedType.productID)
                } else {
                    purchased.remove(signedType.productID)
                }
            }
        }
    }

이미 구매한 내역이 있는지, 혹은 이전에 시도했던 결제 상태가 변했는지 확인하는 로직을 구현한다. 이 함수는 앱이 시작될 때나 특정 시점에 호출되어 사용자의 구매 상태를 최신화한다.

트랜잭션 확인의 흐름 모든 상품 리스트(products)를 하나씩 돌면서 각 상품의 현재 권한(currentEntitlement) 상태를 체크한다.

  1. 상태 확인 (guard let status):
    • 해당 상품에 대한 트랜잭션 데이터(영수증 정보)가 있는지 먼저 확인한다.
    • 데이터가 없다면 구매 시도 자체가 없었던 것이므로 continue를 통해 다음 상품으로 넘어간다.
  2. 검증 결과 대응 (Switch status):
    • 영수증 데이터가 있다면, 아까 구매 로직에서 했던 것과 마찬가지로 검증 여부(verified, unverified)를 따진다.

환불 및 권한 박탈 대응 (revocationDate) 이 로직에서 가장 눈여겨볼 점은 환불(Refund) 처리에 대한 대응이다. 모든 결제 관리는 Apple이 담당하기 때문에, 사용자가 Apple에 환불을 요청해 승인되면 트랜잭션 정보에 revocationDate(취소 날짜)가 찍히게 된다.

  • revocationDate == nil:
    • 취소 날짜가 비어있다는 것은 정상적으로 결제된 상태이며 환불되지 않았다는 뜻이다. 따라서 purchased 리스트에 상품 ID를 넣어준다.
  • else (취소 날짜가 있는 경우):
    • 사용자가 환불을 받았거나 어떤 이유로 권한이 취소된 상태이다. 이때는 purchased 리스트에서 해당 ID를 삭제(remove)하여 다시 상품이 잠기도록 처리한다.

이 기능이 필요한 이유

  • 부모 승인 대응: 자녀가 결제를 요청하고 앱을 껐더라도, 나중에 부모가 승인하면 이 로직을 통해 상품이 자동으로 열리게 된다.
  • 앱 외부 결제: 사용자가 앱 안이 아니라 App Store 앱에서 직접 결제했을 경우에도 대응할 수 있다.
  • 환불 처리: 결제 후 환불받은 사용자가 계속해서 유료 기능을 쓰는 것을 방지한다.
Deprecated 처리

Image

현재 currentEntitlement는 Deprecated 되었다고 warning이 뜬다.

currentEntitlements Docs를 바탕으로 코드를 직접 수정해보았다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// before
// currentEntitlement -> Deprecated
for product in products {
    guard let stauts = await product.currentEntitlement else { continue }
    
    switch stauts {
    case .unverified(let signedType, let verificationError):
        print("Error on \(signedType): \(verificationError)")
    case .verified(let signedType):
        if signedType.revocationDate == nil {
            purchased.insert(signedType.productID)
        } else {
            purchased.remove(signedType.productID)
        }
    }
}

// after
for await status in Transaction.currentEntitlements {
    switch status {
    case .verified(let signedType):
        if signedType.revocationDate == nil {
            purchased.insert(signedType.productID)
        } else {
            purchased.remove(signedType.productID)
        }
    case .unverified(let signedType, let verificationError):
        print("Error on \(signedType): \(verificationError)")
    }
}
  1. 방식의 변화
    • 기존 방식: hp4부터 hp7까지 각 상품 ID를 기준으로 하나씩 결제 내역이 있는지 일일이 확인하며 순회하는 방식.
    • 새로운 방식: 애플이 제공하는 결제 내역 통로를 통해, 사용자가 보유한 모든 권한(status)을 비동기 스트림으로 한 번에 받아와서 순회하는 방식.
  2. 데이터 타입 분석
    • 루프 내 status의 타입을 추적하면 Transaction.Transactions.AsyncIterator.Element로 확인된다.
    • 이는 시스템이 비동기 통로에서 데이터를 하나씩 추출해낸 결과물(VerificationResult<Transaction>)임을 의미한다.
    • Image
    • 이는 이전에 URL.lines를 다루며 정리했던 구조(이전글 링크)와 기술적으로 동일한 메커니즘이다.
    • 당시 파일의 텍스트 라인을 하나씩 추출해냈던 것처럼, 이번에도 비동기 반복자(Iterator)가 결제 데이터 알맹이(Element)를 하나씩 추출하여 전달해 주는 구조임을 알 수 있다.
  3. 장점 및 유지 로직
    • 코드 간결화: 특정 ID를 대조하며 하나하나 물어볼 필요 없이, 들어오는 status를 바로 파악해서 정리하면 되므로 로직이 단순해진다.
    • 무결성 검증: 데이터 수신 방식은 변경되었으나, .verified 체크 및 revocationDate를 통한 권한 부여/회수 로직은 동일하게 유지했다.

4. App Store 실시간 업데이트 감시 (Connect with App Store to watch for purchase and transaction updates)

1
2
3
4
5
6
7
8
9
10
11
12
    init() {
        updates = watchForUpdates()
    }

    // 생략
    private func watchForUpdates() -> Task<Void, Never> {
        Task(priority: .background) {
            for await _ in Transaction.updates {
                await checkPurchased()
            }
        }
    }
  1. 백그라운드 감시를 위한 Task 활용
    • 앱이 실행되는 동안 App Store의 변화(결제, 환불 등)를 실시간으로 감지하기 위해 백그라운드에서 비동기적으로 실행되는 Task를 사용한다.
    • Transaction.updates를 통해 외부 업데이트 신호가 올 때마다 로직이 자동 실행되도록 구성했다.
  2. 객체 생성과 동시에 감시 시작
    • SelectBooksView에서 Store를 객체화하는 시점에 init이 호출되므로, 앱 화면이 준비됨과 동시에 watchForUpdates 메서드가 즉시 가동된다.
    • 덕분에 사용자가 앱을 사용하는 내내 결제 상태의 변화를 놓치지 않고 추적할 수 있다.
  3. 프로퍼티를 통한 Task 관리
    • private var updates: Task<Void, Never>? 프로퍼티는 비동기로 실행되는 이 감시 작업(Task)을 참조하기 위해 사용한다.
    • 단순히 실행하고 끝내는 것이 아니라, 객체 내부에 Task를 담아둠으로써 해당 작업의 생명주기를 Store 클래스와 함께 관리할 수 있게 구현했다.
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.