이 글은 WWDC 2014에서 발표된 Optimizing In-App Purchases의 내용을 단순 정리한 내용입니다. iOS에서는 StoreKit 을 사용하여 이 앱내 구매 기능을 구현할 수 있으며 다음과 같은 기능을 구현할 수 있습니다.
- In-App Purchase : 앱내 구매
- Consumable 및 Non-Consumable 상품 : 소진 가능한 상품 및 소진 불가능한 상품
- Subscriptions : 구독
- Store Product Sheet : 당신의 앱 내부에서 또다른 앱이나 컨텐츠를 아이튠즈 또는 앱스토어를 통해 판매할 수 있게 해주는 기능
- Receipt Renewal : 기존의 구매 내역을 복구하는데 사용, 비지니스 모델&불법 복제 방지등을 적용할 수 있는 강력한 방법
이 글에서는 우선 앱내 구매(In-App Purchase)에 대해서만 정리해 보았습니다.
앱내 구매 API에 새롭게 추가된 부분
앱내 구매에 대해 이야기 하기에 앞서 다음과 같은 기능들이 2014년에 새롭게 추가되었습니다.
- StoreKit Product Sheet는 제휴 프로그램을 지원하며, 기존에는 단순 다운로드 기능만을 제공하였지만 이제는 다운로드에 대한 커미션을 받을 수 있습니다.
- 새로운 구매 상태 Deffered가 추가되었습니다. 18살 미만의 사용자가 구매시에 가족의 동의를 받아야 하며 부모의 동의 이전에 받게 되는 상태입니다.
SKPaymentTransactionStateDeferred 상태는 아직 구매가 성공한 상태도, 실패한 상태도 아닙니다. 부모가 원격으로 구매에 대한 수락/거부의 결과에 따라 성공 또는 실패를 담은 최종 상태가 미래에 다시 도착하게 됩니다. 즉, 이 상태는 구매를 시작하고 구매가 완료되기까지의 중간 상태를 의미합니다. 여기서 중요한 부분은 이 상태를 수신한 사용자가 계속해서 앱을 이용할 수 있게끔 구현을 해주어야 한다는 점입니다. 또한 사용자가 이 아이템을 다시 구매하는것도 가능해야 합니다.
위의 그림은 18세 미만의 사용자가 앱내 구매를 사용할 경우 부모에게 구매 요청의사를 묻는 과정을 표시한 그림입니다. 구매 API를 호출하면 우선 SKPaymentTransactionStateDeferred가 떨어지게 됩니다. 이후에 부모가 수락할 경우 SKPaymentTransactionStatePurchased, 거부할 경우 SKPaymentTransactionStateFailed로 상태가 업데이트 됩니다.
앱내 결제 최적화 방안
앱내 구매를 진행하기 위해서는 위와 같은 플로우를 따릅니다. (왜인지 WWDC의 멋진 자료를 똑같이 그렸음에도 엄청 없어보이는군요.) 최초에 판매하는 인앱 상품들의 식별자를 로드하고 로컬라이징된 상품 정보를 가져옵니다. 이 정보를 가지고 결제 UI를 보여주고 결제를 호출하고 결제 과정이 진행된 후 상품을 지급하고 트랜젝션을 종료하는 과정을 거치게 됩니다.
위의 그림에서 빨간색으로 칠해진 영역이 있는데 이부분이 앱내 구매중에 문제가 발생하게 되는 부분입니다. 이 글에서는 이 부분에서 발생할 수 있는 문제를 해결하는 방법에 대해 알아볼 것입니다.
인앱 식별자 로드
상품 구매를 위해 결제 UI를 보여주어야 하는데 이때에 보여줄 정보를 가져오기 위해 먼저 식별자 세트를 관리하는 방법에 대해 고민해보아야 합니다. 판매하려는 앱내 상품의 앱의 라이프 사이클과 동일하게 변경될 여지가 없는 경우 앱내에 정적으로 보관되는것이 가장 간단한 방법입니다.
하지만 상황에 따라 상품의 변경이 있을 경우 서버로 부터 이 식별자 세트를 받아오는 방법을 취할 수 있습니다. 앱내 구매의 경우 개발자에게는 수익이 창출되는 순간이지만 사용자에게는 한순간의 즐거움(?)을 추구하기 위한 덧없는 순간일 수 있습니다. 이 순간에 딜레이가 발생하거나 오류가 발생하는것은 좋지 않은 결과를 낳게 될 것입니다. 그렇기 때문에 상품의 식별자 정보를 반환하는 서버는 신뢰성을 갖추고 있어야 하며 빠른 결과를 위해 적절한 캐시를 사용하는것이 좋습니다.
또한 아직 구매 가능한 상품 목록이 보여지기 이전에 위와 같은 스피너에 사용자들이 갇히지 않도록 해야 합니다. 위와 같은 화면은 사용자들로 하여금 답답함을 유발 할 수 있습니다. 결론적으로 이 과정은 최대한 문제가 발생하지 않게 빠르게 지나가도록 구현하는것이 중요합니다.
상품정보 가져오기
SKProductsRequest* request = [[SKProductsRequest alloc] initWithProductIdentifiers:identifierSet];
이제 획득한 상품의 식별자 세트를 이용하여 위와 같은 방법으로 상품 정보가 담긴 SKProduct 객체들을 획득합니다. 이때에는 개발자가 아이튠즈에 미리 등록한 로컬라이징된 상품 이름 및 설명과 가격 정보를 받을 수 있습니다. 또한 컨텐츠 호스트 기능을 사용할 경우 컨텐츠의 사이즈와 버전정보를 받을 수 있습니다. 이 요청은 어쩔 수 없이 딜레이가 발생합니다. 이점을 이해하고 미리 데이터를 예상하여 로드하거나 가능한한 빠르게 결제 화면을 보여줄 필요가 있습니다.
이 세상에는 한가지 상품에 매겨진 다양한 화폐 단위가 존재합니다. 가령 다음과 같은 화폐 단위가 존재할 수 있을것입니다.
1.234,56 € £1,234.56 €1,234.56 1.234,56 kn R$ 1.234,56 ฿1,234.56 $1,234.56
위의 예시 역시 세상에 존재하는 다양한 화폐 단위중에 매우 작은 예시에 불과합니다. 당신은 사용자들에게 가장 익숙한 화폐단위, 즉 사용자의 국가에 맞는 화폐 단위로 가격을 보여줄 필요가 있습니다. 하지만 일일이 국가별 화폐 단위로 보여주기 위해 신경을 쓰는것은 어려운 일입니다. NSNumberFormatter를 이용하여 SKProduct에 담겨있는 가격정보를 변환하여 보여줄 수 있습니다.
NSNumberFormatter *numberFormatter = [[NSNumberFormatter alloc] init]; [numberFormatter setNumberStyle:NSNumberFormatterCurrencyStyle]; [numberFormatter setLocale:product.priceLocale]; NSString *formattedString = [numberFormatter stringFromNumber:product.price];
위의 코드는 product라는 변수명의 SKProduct객체를 이용하여 화폐단위에 알맞는 가격 표기를 만들어내는 과정을 보여주는 코드입니다. SKProduct객체에는 사용자가 어떤 나라, 어떤 지역에 살고 있는지 알 필요 없이 앱스토어가 적절한 데이터를 내려주게 되어있습니다. 절대로 환율을 직접 계산하지 마십시오.
(주의) 많은 개발자들이 에러를 처리하는 부분에서 잘못하는것을 종종 발견하고 하는데 가장 중요한 핵심은 모든 에러가 동일하지 않다는 것입니다. In-App Purchase 프로그래밍 가이드를 살펴보면 처리 가능한 오류 코드들을 확인할 수 있습니다. 이러한 코드들에 적절한 처리를 분리하여 하시기 바랍니다.
결제 진행중에 발생하는 대부분의 오류 메시지들은 사용자들에게 보여주기 위한 메시지를 담고 있는것이 아니라 구현중인 개발자들에게 보여지기 위한 메시지들입니다. 그러므로 에러메시지를 얼럿창으로 띄워 유저에게 보여주는것은 좋지 않은 방법입니다. 많은 앱들이 “결제요청-터치아이디등을 이용하여 아이디 인증-정말아이템을 구매하시겠습니까? 팝업창-취소” 과정을 거칠 때 수많은 팝업들을 거쳤음에도 불구하고 “취소하셨습니다”라는 팝업을 또 띄우는것을 볼 수 있는데요 이것은 나중에 또다시 구매를 시도할 수 있는 사용자들에게 좀 더 불쾌한 경험이 될 수 있습니다. 사용자가 의도적으로 취소하였기 때문에 “취소하였습니다” 팝업을 또 띄우는것은 무의미합니다.
결제 생성
“결제 UI 보여주기” 단계에서 정말 멋진 결제 화면을 사용자에게 제공하였고 오류도 발생하지 않았습니다. 사용자는 구매하길 원하는 상품을 탭하여 구매를 진행하게 되었습니다. 이후의 과정을 설명해 보겠습니다.
결제의 구현은 실제로 페이먼트큐에 의해 중앙 집중형으로 관리가 됩니다. 페이먼트큐가 스스로 관리를 알아서 해주기 때문에 개발자는 결제 과정의 복잡한 상태를 일일이 체크하거나 캐싱 처리를 할 필요가 없습니다. 스스로 상태를 관리하려 하지 말고 단지 큐를 믿고 사용하시면 됩니다. 큐는 결제 트랜젝션의 진행 상황을 관리해주고 상태가 변할때마다 알려줍니다. 또는 컨텐츠 호스트를 이용할 경우 다운로드 상태를 제공해줍니다. 큐의 모든 트랜젝션들은 유효하고 실존합니다.
여기서 인증되지 않은 사용자의 무단 또는 사기성 구매가 있을 수 있다고 의심될 수 있지만 그것은 다른 문제입니다. 이런 경우는 결제가 완료된 후 영수증을 검증하는 방식으로 해결할 수 있습니다. 어찌되었던 큐를 믿어주시기 바랍니다.
(주의) 가능하면 앱이 구동되는 시점에 SKPaymentQueue의 addTransactionObserver를 실행해 주시기 바랍니다. 여기에 파라미터로 SKPaymentQueue 옵저버 프로토콜에 대응하는 앱내 구매 구현의 핵심이 되는 객체를 사용하게 됩니다.
[[SKPaymentQueue defaultQueue] addTransactionObserver:yourObserver];
이 프로토콜 객체들은 큐 안의 결제들이 어떻게 진행되고 있는지 상태를 전달 받을 수 있으며 에러나 UI 업데이트를 어떻게 할지를 결정할 수 있습니다.
트랜젝션 진행
결제가 진행됨에 따라 트랜젝션의 상태 변경을 델리게이트를 통해 이벤트를 전달 받을 수 있습니다. 결제 성공을 처리 하기 위해서는 다음과 같은 구현이 필요할 것입니다.
- (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray *)transactions { for (SKPaymentTransaction* transaction in transactions) { switch(transaction.transactionState) { case SKPaymentTransactionStatePurchasing: // 결제가 시작될때 필요한 처리, 결제 버튼이 비활성화 된다던가 // 따로 상태를 관리하며 UI를 변경하지 말고 여기서 처리하시기 바랍니다. ... break; case SKPaymentTransactionStatePurchased: NSURL* receiptURL = [[NSBundle mainBundle] appStoreReceiptURL]; NSData* receipt = [NSData dataWithContentsOfURL:receiptURL]; // 트랜젝션 처리 ... break; case SKPaymentTransactionStateFailed: // 결제 실패 ... break; case SKPaymentTransactionStateDeferred: // 부모의 수락/거부에 따른 다음 트랜젝션 업데이트가 일어날때까지 // 사용자가 계속해서 앱을 사용할 수 있도록 처리합니다. // "구매중..." 같은 팝업창으로 이용을 블로킹 하지 말아야 합니다. ... break; } }
(주의) 많은 개발자들이 다음과 같이 구현하는것을 볼 수 있는데 절대로 다음과 같은 처리를 하지 않기를 바랍니다.
case SKPaymentTransactionStatePurchased: // Get the local state for this transaction SKPayment *payment = myCachedPayments[transaction.payment.productIdentifier]; if (!payment) { // 이 트랜젝션이 왜 여기로 들어오는지 모르겠다! // 무시하자 ㅎㅎ continue; }
iOS 앱내 결제를 진행중에 수신받는 transaction 정보는 완벽하게 신뢰할 수 있는 데이터입니다. 그러므로 자체적으로 결제정보를 캐시하거나 저장하는것은 불필요합니다. 위의 코드는 앱이 크래시 되어 강제 종료되거나 결제 중간에 방해가 발생하거나 결제가 정상적으로 시작되지 않았을 경우 myCachedPayments 의 캐시 데이터의 신뢰성을 보장할 수 없습니다. 자체적으로 결제 정보를 추적하지 말고 transaction 객체를 신뢰하여 처리해 주시기 바랍니다.
상품 지급&트랜젝션 종료
정상적으로 SKPaymentTransactionStatePurchased 상태로 들어왔다면 구매한 상품의 잠금이 해제되거나 사용자에게 지급되어야 합니다. 이 과정을 수행하기 이전에 구매 영수증이 유효한지 검증하는것이 필요합니다. 이러한 영수증 검증에는 디바이스 자체의 검증을 수행하는 방법과 서버간 통신 API를 활용한 검증 방법이 있습니다.
디바이즈 자체 검증의 경우 앱내에 이미 보유중인 컨텐츠나 기능의 잠금을 해제하는 용도로 사용하기에 적합합니다. 서버간 API를 통한 검증은 접근이 제한적인 컨텐츠를 접근하여 아이템을 지급하거나 하는 용도에 적합합니다. 아마 한국의 대부분의 게임업체들의 중간화폐(Hard Currency)들은 이러한 서버간 검증을 통해 아이템을 서버에서 직접 지급하는 방식을 사용하고 있습니다.
이런 방식을 통해 아이템의 지급 작업이 끝나게 되면 앱스토어측에 이 트랜젝션의 과정이 완전히 끝났음을 다음의 API를 호출하여 알려주어야 합니다.
- (void)finishTransaction:(SKPaymentTransaction *)transaction
위의 API를 호출하게 되면 결제과정을 관리하고 있는 페이먼트큐에서 삭제가 되며 완전히 결제가 종료되게 됩니다. 여기서 중요한점은 결제가 성공하던, 실패하던 반드시 위의 코드를 실행해 주어야 한다는것입니다. 위의 호출을 하지 않을 경우 앱이 실행 될때나 혹은 앱이 실행중인 다른 상황에서 또다시 앱의 결제를 진행하려 할것이고 이는 앱의 실행을 원할하지 않은 것으로 보여지게 만들 문제의 소지가 있습니다.
(주의) 서버간 API를 디바이스에서 직접 호출하지 마십시오. 또한 iOS 6 SDK에서 제공하였던 영수증 검증 API는 Deprecated 되었습니다.
(팁) + (BOOL)canMakePayments API를 사용하여 사용자가 앱내 결제가 사용가능한지 아닌지 여부를 확인할 수 있습니다. 당신의 게임을 열심히 플레이 하여 결제를 할 정도로 빠져들었을 때 구매 시도시에 앱내 결제가 비활성화 되어있다고 오류가 발생하는것은 좋지 않은 사용자 경험이 될것입니다. 미리 디바이스에서 앱내 결제를 비활성화 한 사용자인지 여부를 판단하여 활성화 할것을 알려주는 형태로 구현하는 것이 좋을것입니다.
참고 : WWDC 2014 Videos