iOS 인앱 구매, 4부: 서버 측 구매 검증
Updated: 10월 14, 2023
11 min read
서버구매 검증이란 무엇입니까?
서버검증 (서버측 영수증 검증 (receiptvalidation))은구매의 진위 여부를 확인하는 방법입니다.장치기반 검증과 달리,서버검증은 — 기대하세요 — 서버 측에서 이뤄집니다.검증은기기 또는 서버가 Apple의서버에 요청하여 구매가 실제로 발생했는지 여부와유효한지 여부를 확인하는 것을 의미합니다.
구매를검증하는 이유는 무엇입니까?
서버검증은 필수가 아니라는 점에 유의해야 합니다.인앱구매는 검증 없이도 이루어집니다.그러나,다음과같은 몇 가지 이점이 있습니다.
- 고급 결제 분석은 구독에 특히 중요한데, 활성화 후에 발생하는 모든 일을 장치에서 처리하지는 않기 때문입니다. 서버 구매 처리가 없으면, 현재 구독 상태를 검색할 수 없고, 사용자가 구독을 갱신 또는 취소했는지, 결제 문제가 있는지 등의 여부를 알 수 없습니다.
- 구매의 진위 여부를 확인할 수 있음. 거래가 사기가 아니고 사용자가 실제로 제품에 대한 비용을 지불했음을 확인할 수 있습니다.
- 플랫폼 간 구독. 사용자의 구독 상태를 실시간으로 확인할 수 있는 경우, 다른 플랫폼과 동기화할 수 있습니다. 예를 들어 iOS 기기에서 구독을 구매한 사용자는 Android, 웹 및 기타 플랫폼에서 구독을 사용할 수 있습니다.
- 서버 측에서 콘텐츠 접근을 제어할 수 있게 되어, 단순히 서버에 대한 요청을 실행하는 것만으로 구독 없이 데이터에 접근하려는 사용자로부터 보호합니다.
경험에따르면,첫번째 이점만으로도 서버 구매 처리를 설정하기에충분합니다.
구매검증
일반적인iOS 영수증검증 절차는 다음과 같습니다.
공유암호 (sharedsecret) 생성
결제검증 요청을 보내려면,요청을승인하는 공유 암호를 포함시켜야 합니다.App Store Connect에서공유 암호를 생성할 수 있습니다.
공유암호는 특정 앱 (앱별암호)또는계정의 모든 앱 (주암호)에대해 생성할 수 있습니다.
앱별암호를 생성하려면 AppStore Connect에서앱 페이지를 열고 In-AppPurchases → Manage로이동하여 App-SpecificShared Secret을클릭합니다.열리는창에서 새 토큰을 생성하거나 기존 토큰을 복사할 수있습니다.
계정의모든 앱에 대한 암호를 받으려면 Usersand Access 페이지를열고 SharedSecret 탭을선택합니다.
결제검증 요청
공유암호를 받으면,Apple 서버에서검증받기 위해 영수증을 보낼 수 있습니다.이작업은 verifyReceipt요청을통해 이뤄집니다.POST 요청을https://buy.itunes.apple.com/verifyReceipt로보내야 합니다.요청의JSON 바디에서공유 암호를 password필드로,영수증은receipt-data필드로넘깁니다.또한exclude-old-transactions매개변수도선택할 수 있습니다.만약참값을 갖는다면,자동갱신 가능한 각 구독에 대해 전체 갱신 내역 대신 마지막거래만 받게 됩니다.
구매검증 요청의 페이로드는 다음과 같습니다.
{ "password": "f4d35830e3...52aae", "receipt-data": "MIIUVQY...4rVpL8NlYh2/8l7rk0BcStXjQ==", "exclude-old-transactions": false }
샌드박스(Sandbox)환경에서작업하는 경우 즉,구매를테스트하는 경우,https://sandbox.itunes.apple.com/verifyReceipt으로검증 요청을 보냅니다.공유암호,페이로드및 응답 포맷은 동일하게 유지됩니다.
샌드박스환경에서 생성된 영수증은 프로덕션 서버 (Productionserver)에서검증할 수 없으며,그반대의 경우도 마찬가지임을 꼭 알아두십시오.그렇기때문에 실제 시스템에서 가장 좋은 방법은,상태키가 21007오류코드를 반환한 경우,첫번째 요청을 프로덕션 서버로 보내고 샌드박스 서버로리다이렉트하는 것입니다.이동작은 Apple직원이구매를 테스트하는 동시에 앱의 실제 사용자가 구매할수 있게 하므로,앱을리뷰하는 중에 필수적입니다.
다른오류중에서도,잘못된암호를 사용하고 있음을 의미하는 21004오류코드가 중요합니다.사용자경험과 분석 정확도 모두에 영향을 미치므로,놓쳐서는안 됩니다.최악의경우,사용자가프리미엄 기능에 대한 비용을 지불한 후 접근 권한을받지 못하면,App Store에서앱이 제거될 수 있습니다.
검증에공한 경우 (status=0),응답에는사용자 거래의 세부 정보가 포함됩니다.
다음은결제 검증 요청 응답입니다.
{ "environment": "Production", "receipt": { "receipt_type": "Production", "adam_id": 123, "app_item_id": 123, "bundle_id": "com.adapty.sample_app", "application_version": "1", "download_id": 123, "version_external_identifier": 123, "receipt_creation_date": "2021-04-28 19:42:01 Etc/GMT", "receipt_creation_date_ms": "1619638921000", "receipt_creation_date_pst": "2021-04-28 12:42:01 America/Los_Angeles", "request_date": "2021-08-09 18:26:02 Etc/GMT", "request_date_ms": "1628533562696", "request_date_pst": "2021-08-09 11:26:02 America/Los_Angeles", "original_purchase_date": "2017-04-09 21:18:41 Etc/GMT", "original_purchase_date_ms": "1491772721000", "original_purchase_date_pst": "2017-04-09 14:18:41 America/Los_Angeles", "original_application_version": "1", "in_app": [ { "quantity": "1", "product_id": "basic_subscription_1_month", "transaction_id": "1000000831360853", "original_transaction_id": "1000000831360853", "purchase_date": "2021-04-28 19:41:58 Etc/GMT", "purchase_date_ms": "1619638918000", "purchase_date_pst": "2021-04-28 12:41:58 America/Los_Angeles", "original_purchase_date": "2021-04-28 19:41:58 Etc/GMT", "original_purchase_date_ms": "1619638918000", "original_purchase_date_pst": "2021-04-28 12:41:58 America/Los_Angeles", "expires_date": "2021-05-05 19:41:58 Etc/GMT", "expires_date_ms": "1620243718000", "expires_date_pst": "2021-05-05 12:41:58 America/Los_Angeles", "web_order_line_item_id": "230000397200750", "is_trial_period": "true", "is_in_intro_offer_period": "false", "in_app_ownership_type": "PURCHASED" } ] }, "latest_receipt_info": [ { "quantity": "1", "product_id": "basic_subscription_1_month", "transaction_id": "230001020690335", "original_transaction_id": "1000000831360853", "purchase_date": "2021-08-04 19:41:58 Etc/GMT", "purchase_date_ms": "1628106118000", "purchase_date_pst": "2021-08-04 12:41:58 America/Los_Angeles", "original_purchase_date": "2021-04-28 19:41:58 Etc/GMT", "original_purchase_date_ms": "1619638918000", "original_purchase_date_pst": "2021-04-28 12:41:58 America/Los_Angeles", "expires_date": "2021-08-11 19:41:58 Etc/GMT", "expires_date_ms": "1628710918000", "expires_date_pst": "2021-08-11 12:41:58 America/Los_Angeles", "web_order_line_item_id": "230000438372383", "is_trial_period": "false", "is_in_intro_offer_period": "false", "in_app_ownership_type": "PURCHASED", "subscription_group_identifier": "272394410" }, { "quantity": "1", "product_id": "basic_subscription_1_month", "transaction_id": "230001017218955", "original_transaction_id": "1000000831360853", "purchase_date": "2021-07-28 19:41:58 Etc/GMT", "purchase_date_ms": "1627501318000", "purchase_date_pst": "2021-07-28 12:41:58 America/Los_Angeles", "original_purchase_date": "2021-04-28 19:41:58 Etc/GMT", "original_purchase_date_ms": "1619638918000", "original_purchase_date_pst": "2021-04-28 12:41:58 America/Los_Angeles", "expires_date": "2021-08-04 19:41:58 Etc/GMT", "expires_date_ms": "1628106118000", "expires_date_pst": "2021-08-04 12:41:58 America/Los_Angeles", "web_order_line_item_id": "230000849023623", "is_trial_period": "false", "is_in_intro_offer_period": "false", "in_app_ownership_type": "PURCHASED", "subscription_group_identifier": "272394410" } ], "latest_receipt": "MIIUVQY...4rVpL8NlYh2/8l7rk0BcStXjQ==", "pending_renewal_info": [ { "auto_renew_product_id": "basic_subscription_1_month", "product_id": "basic_subscription_1_month", "original_transaction_id": "1000000831360853", "auto_renew_status": "1" } ], "status": 0 }
응답이상당히 복잡하여,AppStore 서버API새버전에서 단순화되었으나,현재의구현은 파악이 용이합니다.
구독상태 및 거래 내역 검색
사용자가앱의 프리미엄 기능에 접근 권한을 갖는지 확인하려면구독 상태를 확인할 방법이 필요합니다.현재버전의 API에는구독 상태를 검색하기 위한 전용 요청이 없으므로,어떤경우에든 거래 내역을 처리해야 합니다.
기본적으로latest_receipt_info어레이는앱 측에서 완료되는 소모품을 제외하고 특정 사용자의모든 인앱 구매 거래를 포함합니다.이렇게하면 사용자의 전체 구매 내역을 검색할 수 있습니다.이는분석과 현재 구독 상태를 파악하는 데 상당히 유용합니다.
거래는항상 최신순으로 정렬되어 있는 것 같습니다.그래도확실히 하기 위해,거래에대한 날짜별 정렬을 구현하는 것이 좋습니다.
거래페이로드:
{ "quantity": "1", "product_id": "basic_subscription_1_month", "transaction_id": "1000000831360853", "original_transaction_id": "1000000831360853", "purchase_date": "2021-04-28 19:41:58 Etc/GMT", "purchase_date_ms": "1619638918000", "purchase_date_pst": "2021-04-28 12:41:58 America/Los_Angeles", "original_purchase_date": "2021-04-28 19:41:58 Etc/GMT", "original_purchase_date_ms": "1619638918000", "original_purchase_date_pst": "2021-04-28 12:41:58 America/Los_Angeles", "expires_date": "2021-05-05 19:41:58 Etc/GMT", "expires_date_ms": "1620243718000", "expires_date_pst": "2021-05-05 12:41:58 America/Los_Angeles", "web_order_line_item_id": "230000397200750", "is_trial_period": "true", "is_in_intro_offer_period": "false", "in_app_ownership_type": "PURCHASED", "subscription_group_identifier": "272394410" }
현재구독 상태를 확인하려면,내역의최신 거래를 검색하여 expires_date속성을확인하기만 하면 됩니다.예외적인경우는 유예 기간으로,이내용은 뒤에 다룰 것입니다.
분석을위해 다음 속성을 저장하는 것이 좋습니다.
- product_id: 구매한 제품의 텍스트 식별자.
- transaction_id: 거래의 고유 숫자 식별자. 각 구매 또는 갱신에는 해당 거래가 이전에 처리되었는지 여부를 확인하는 데 사용할 수 있는 고유한 식별자가 있습니다.
- original_transaction_id: 거래 내역의 고유 숫자 식별자. 이 식별자는 구독이나 평가판 활성화 시의 transaction_id와 일치합니다. 그러나 추가 구독 갱신 시 transaction_id는 변경되는 반면, original_transaction_id는 그대로 유지됩니다. 이것은 구독이 갱신된 횟수를 추적하는 데 유용합니다.
- purchase_date & original_purchase_date: 거래일 및 최초 거래일. 이것은 이전 속성과 동일한 논리 하에 작동합니다.
- expires_date: 구독 만료일.
- cancellation_date: 이름을 보고 알기는 어렵지만, 구독 취소가 아닌 환불 날짜입니다. 응답에 이 필드가 있으면, 구독에 대한 사용자의 접근 권한을 만료시키고 분석에서 취소 처리할 수 있음을 의미합니다. 이 거래에서는 지불을 받지 않습니다.
- is_in_intro_offer_period: 구독 활성화 시 출시 할인 (intro offer)이 사용되었는지 여부를 나타내는 플래그.
- is_trial_period: 구독 활성화 시 평가판 기간이 있었는지 여부를 나타내는 플래그.
- offer_code_ref_name: 구독 활성화 시 사용된 할인 코드.
- promotional_offer_id: 현재 청구 기간을 입력할 때 사용된 프로모션 할인의 텍스트 식별자.
- in_app_ownership_type: 구독 소유권 유형. 사용자가 제품을 직접 구매했는지 아니면 가족 구독의 일부로 받은 것인지 표시합니다. 몇 가지 가능한 값은 다음과 같습니다.
- PURCHASED: 사용자가 제품을 직접 구매했습니다.
- FAMILY_SHARED: 사용자가 가족 구독의 일부로 제품을 받았습니다.
Apple이WWDC2021에서발표한내용을 보면거래 정보에 appAccountToken필드를추가할 계획입니다.여기에는시스템에 있는 사용자의 식별자가 포함됩니다.이식별자는 UUID형식이어야하고,구매가초기화될 때 앱 측에서 정의됩니다.정의된경우, 이내역의 모든 거래 (구독갱신 (subscriptionrenewal), 결제문제 (billingissue) 등)에서반환되므로,어떤사용자가 구매했는지 쉽게 파악할 수 있습니다.
또한subscription_group_identifier매개변수를잘 알고 있어야 합니다.사용자가이전에 활성 평가판이나 출시 할인을 받은 거래를했다면,동일한구독 그룹에 접근 권한을 갖지 않아야 합니다.이것은서버 측에서 추적되어야 합니다.
구독갱신 정보,유예기간 (graceperiod) 및결제 문제
pending_renewal_info어레이는구독 갱신 데이터를 저장합니다.다음청구 기간에 구독에 어떤 일이 발생할지 알 수 있게해줍니다.예를들어,사용자가자동 갱신 선택을 해제한 것을 알게된 경우,다른요금제로 전환하도록 제안하거나 프로모션 할인을제시할 수 있습니다.이러한이벤트는 서버 알림 (servernotifications)으로쉽게 추적할 수 있는데,이에대해서는 곧 다루겠습니다.
구독갱신 데이터:
{ "auto_renew_product_id": "basic_subscription_1_month", "product_id": "basic_subscription_1_month", "original_transaction_id": "1000000831360853", "auto_renew_status": "1" }
- product_id: 구매한 제품의 텍스트 식별자.
- auto_renew_product_id: 다음 결제 기간에 활성화될 제품의 텍스트 식별자. 현재 (product_id)와 다르다면, 사용자가 구독 유형을 변경했음을 의미합니다.
- auto_renew_status: 다음 청구 기간까지 구독을 계속할지 여부를 나타내는 플래그.
- expiration_intent: 구독 만료 이유. 몇 가지 가능한 값은 다음과 같습니다.
- 1 — 사용자가 직접 구독을 취소했습니다.
- 2 — 청구 문제로 인해 구독이 취소되었습니다.
- 3 — 사용자가 가격 인상에 동의하지 않았습니다,
- 4 — 구독 제품을 갱신할 수 없었습니다. (예: App Store Connect에서 제거된 경우)
- 5 — 알 수 없는 이유.
- Grace_period_expires_date: 앱에 유예 기간이 있는 경우, 유예 기간의 만료 일자. 이 경우, 사용자는 거래 자체의 만료일이 아니라 여기에 지정된 일자까지 프리미엄 기능에 접근 권한을 가져야 합니다. 이 키가 있는 경우, 사용자에게 카드 정보를 업데이트하거나 잔액을 충전해야 함을 알릴 수 있습니다.
- is_in_billing_retry_period: 구독이 결제 재시도 상태 (billing retry status)인지 여부를 나타내는 플래그. 구독이 취소되지 않았지만 Apple에서 갱신 결제를 청구할 수 없었으며, 60일간 계속 결제를 시도할 것임을 뜻합니다.
- offer_code_ref_name: 다음 결제 기간에 사용할 제안 코드.
- promotional_offer_id: 다음 결제 기간에 사용할 프로모션 할인의 텍스트 식별자.
- price_consent_status: 사용자가 향후 구독 가격 인상에 동의했는지 여부를 나타내는 플래그. 값이 0인 경우, 해당 사용자가 구독을 취소하지 않도록 다른 제품이나 프로모션 할인을 제공해야 합니다.
소모성,비소모성및 비갱신형 구독
사용자에게자동 갱신 가능 제품 (auto-renewableproduct)이없는 경우 latest_receipt_info그리고pending_renewal_info키는반환되지 않습니다.이경우,거래는receipt→ in_app에서찾을 수 있습니다.거래양식은 자동 갱신 거래와 유사하지만,자동갱신 거래에만 해당되는 만료,갱신,할인및 기타 속성에 대한 필드가 없습니다.
유의할점은 receipt→ in_app도자동 갱신 거래에 이를 수 있지만,더나은 방법은 latest_receipt_info를사용하는 것인데,여기에포함된 구독 데이터가 가장 최신 상태이기 때문입니다.
2024 subscription benchmarks and insights
Get your free copy of our latest subscription report to stay ahead in 2024.
서버거래 알림
얼마전까지는 구독 상태 변경을 추적하기 위해 복잡한시스템을 고안해야만 했습니다.예를들어,구독이갱신되었는지 여부를 파악하려면 구독 만료 24시간전부터 매시간 Apple서버에구독 상태 요청을 보내야 했습니다.Apple은시간이 지남에 따라 점점 더 많은 서버 알림을 추가하고있었고,이알림이 이제 커버하는범위는 거의 모든 구독 관련 중요 이벤트를 망라합니다.매우유용하죠.Apple측에서변경 사항이 있으면,귀하의자체 서버에서 이에 대한 알림을 받게 됩니다..즉,새로운구매,구독갱신,결제문제 등에 대한 알림을 받게 됩니다.이를통해 훨씬 더 정확한 분석을 수집할 뿐 아니라,구독상태를 훨씬 쉽게 관리할 수 있습니다.
AppStore Connect에서서버 알림을 활성화할 수 있습니다.앱페이지를 열고 General-> App information으로이동합니다.그런다음 AppStore Server Notifications 필드의URL에링크를 입력하고 변경 사항을 저장합니다.
서버알림:
{ "notification_type": "DID_RENEW", "password": "f4d35830e3...52aae", "environment": "PROD", "auto_renew_product_id": "basic_subscription_1_month", "auto_renew_status": "true", "unified_receipt": { "status": 0, "environment": "Production", "latest_receipt_info": [ { "quantity": "1", "product_id": "basic_subscription_1_month", "transaction_id": "230001020690335", "original_transaction_id": "1000000831360853", "purchase_date": "2021-08-04 19:41:58 Etc/GMT", "purchase_date_ms": "1628106118000", "purchase_date_pst": "2021-08-04 12:41:58 America/Los_Angeles", "original_purchase_date": "2021-04-28 19:41:58 Etc/GMT", "original_purchase_date_ms": "1619638918000", "original_purchase_date_pst": "2021-04-28 12:41:58 America/Los_Angeles", "expires_date": "2021-08-11 19:41:58 Etc/GMT", "expires_date_ms": "1628710918000", "expires_date_pst": "2021-08-11 12:41:58 America/Los_Angeles", "web_order_line_item_id": "230000438372383", "is_trial_period": "false", "is_in_intro_offer_period": "false", "in_app_ownership_type": "PURCHASED", "subscription_group_identifier": "272394410" }, { "quantity": "1", "product_id": "basic_subscription_1_month", "transaction_id": "230001017218955", "original_transaction_id": "1000000831360853", "purchase_date": "2021-07-28 19:41:58 Etc/GMT", "purchase_date_ms": "1627501318000", "purchase_date_pst": "2021-07-28 12:41:58 America/Los_Angeles", "original_purchase_date": "2021-04-28 19:41:58 Etc/GMT", "original_purchase_date_ms": "1619638918000", "original_purchase_date_pst": "2021-04-28 12:41:58 America/Los_Angeles", "expires_date": "2021-08-04 19:41:58 Etc/GMT", "expires_date_ms": "1628106118000", "expires_date_pst": "2021-08-04 12:41:58 America/Los_Angeles", "web_order_line_item_id": "230000849023623", "is_trial_period": "false", "is_in_intro_offer_period": "false", "in_app_ownership_type": "PURCHASED", "subscription_group_identifier": "272394410" } ], "latest_receipt": "MIIUVQY...4rVpL8NlYh2/8l7rk0BcStXjQ==", "pending_renewal_info": [ { "auto_renew_status": "1", "auto_renew_product_id": "basic_subscription_1_month", "product_id": "basic_subscription_1_month", "original_transaction_id": "1000000831360853" } ] }, "bid": "com.adapty.sample_app", "bvrs": "0" }
서버알림 포맷은결제 검증 응답과 유사합니다.거래상세 정보는 unified_receipt→ latest_receipt_info에저장됩니다.Password키는앱의 공유 암호를 저장하므로,요청의진위를 확인할 수 있습니다.Notification_type키는이벤트유형을저장합니다.이중 가장 유용하다고 생각되는 것들입니다.
- DID_CHANGE_RENEWAL_STATUS: 사용자가 구독 자동 갱신을 끄거나 켰음을 의미합니다 (후자는 훨씬 더 드뭅니다). 사용자가 자동 갱신을 선택 해제한 경우, 다시 활성 구독자로 가입하도록 권할 수 있습니다.
- DID_FAIL_TO_RENEW: 결제 문제로 인해 구독을 갱신할 수 없습니다. 구독이 자동으로 취소되지 않도록 사용자에게 알려야 합니다.
- DID_RENEW: 구독이 성공적으로 갱신되었습니다.
- REFUND: 구독이 환불되었습니다. 이 구매가 부여하는 프리미엄 기능에 대한 사용자의 접근 권한을 제한하고, 분석에서 환불 처리해야 합니다 (즉, 돈을 잃는 것이죠).
결론
서버검증은 앱에서 수집한 데이터에 대한 분석을 강화할수 있습니다.또한사기꾼이 프리미엄 콘텐츠에 접근하는 것을 더 어렵게만들고,플랫폼간 구독을 할 수 있게 해줍니다.동시에,특히매우 정확한 데이터가 필요한 경우,서버검증 구현에는 상당한 시간이 걸릴 수 있습니다.이를위해서는 구독 업그레이드,구독크로스그레이드,평가판기간,프로모션/출시할인,유예기간,환불,가족구독 등 다양한 부수적인 경우를 고려해야 합니다.또한,Apple은1년이상 정기적으로 갱신되는 구독에 대해 수수료를30%에서15%로낮추는 정책을 가지고 있는 등의 여러 세부적인 점들을알고 처리해야 합니다.