IOS 모바일에서 결제를 터뜨리고 난 후 영수증을 받고 영수증을 서버에게 넘겨주면 검증하는 절차를 정리해보려고 한다.
위 링크 보다시피, transactionId를 통해서 구매검증을 진행할 수 있다. (기존에는 verifyReceipt api (deprecated) 를 사용했다.) 이 API를 호출하기 위해서 권한이 필요한데, JWT를 만들어야 한다.
필요한 JWT부터 만들어주겠다. JWT를 만드는데 필요한 변수는 여기서 얻을 수 있다. app store connect
사용자 및 액세스를 선택한 다음, 키 탭을 선택합니다.
Key Type(키 유형)에서 In-App Purchase(인앱 구매)를 선택합니다.
API 키 생성 또는 추가(+) 버튼을 클릭합니다.
implementation("io.jsonwebtoken:jjwt-api:0.12.6")
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.6")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.6")
얻어낸 privateKey 형식 중에서 "+" "/" "\n" 같은 특수문자가 포함되어있으면, 파싱해주는 과정이 필요하다. 이거 때문에 삽질 오래했으니, 이거 보는 사람은 나같은 삽질 안 하길 바란다.
이렇게 얻어낸 JWT를 Bearer Token으로 보내주면 인증과정은 끝났다.
appleClient.get()
.uri("/inApps/v1/transactions/${purchaseId}")
.accept(MediaType.ALL)
.header("Authorization", "Bearer $code")
.retrieve()
.toEntity(String::class.java)
이렇게 호출해서 응답을 받으면, signedTransactionInfo를 받을 수 있을 것이다.
이제 이 signedTransactionInfo는 JWT 형식이므로 Base64 디코딩해서 내가 원하는 정보를 손쉽게 얻을 수 있다.
정상적으로 디코딩 되었다면, 대략 아래와 같은 형태의 JSON 문자열로 변환할 수 있을 것이다.
{
"bundleId": "test_30943ac3bc74",
"currency": "test_d935a9f40f64",
"environment": "test_e10158055909",
"expiresDate": 42,
"inAppOwnershipType": "test_20450bb42e39",
"offerDiscountType": "test_7dbb7b7cd99c",
"offerType": 19,
"originalPurchaseDate": 17,
"originalTransactionId": "test_ccf6ed159e63",
"price": 22,
"productId": "test_f86e2d9e6492",
"purchaseDate": 6,
"quantity": 99,
"signedDate": 51,
"storefront": "test_6b13f0b6fb93",
"storefrontId": "test_7130b504bb19",
"subscriptionGroupIdentifier": "test_533b068c896a",
"transactionId": "test_5e41646f0e11",
"transactionReason": "test_a1f025ff952e",
"type": "test_a011716346be",
"webOrderLineItemId": "test_abb51a6690bd"
}
이제 우리가 할 일은 간단하다. 파싱된 expiresDate 를 보고 구독이 만료되었는지 아닌지 판단하는 것이다. 만료되지 않고, 파싱이 제대로 되었다면, 구매가 정상적으로 이루어졌다고 판단하고, DB에 반영하면 된다.
만약 구매가 이미 이루어졌는데, 서버에서 받지 못했고 뒤늦게 다시 터뜨릴 경우 Auto-renewal 결제 데이터가 아래와 같은 형태로 뽑혀져 나올건데, 이럴 경우도, originalTransactionId는 동일하다. 리뉴얼 결제들은 매 결제 건 마다 transctionId가 새로 부여되지만 originalTransactionId는 동일하다.
따라서 이 originalTransctionId 기준으로 모든 결제건을 보고 signedDate 를 가지고 가장 최신 결제건을 뽑아낸 다음, expiresDate로 만료여부를 판단하면 된다.
{
"bundleId": "test_3072f8d1fce1",
"currency": "test_641bdd31d52f",
"environment": "test_5d8bbfec06ea",
"expiresDate": 73,
"inAppOwnershipType": "test_475fa6a3198b",
"originalPurchaseDate": 3,
"originalTransactionId": "test_c1e9cf838e5e",
"price": 30,
"productId": "test_b8cacbec0a3d",
"purchaseDate": 98,
"quantity": 82,
"signedDate": 96,
"storefront": "test_9bda2e3e5ac0",
"storefrontId": "test_c429084cb0ad",
"subscriptionGroupIdentifier": "test_cbeae4080e67",
"transactionId": "test_c844381dec9b",
"transactionReason": "test_31a7d174dce2",
"type": "test_e3ac9f1a4de1",
"webOrderLineItemId": "test_73e5d7dd8e72"
}
최초 구독 결제냐, 아니면 재결제냐에 따라서 JSON Data 양식이 달라지기 때문에 파싱하는 부분을 신경써줘야 된다.
구독 검증 말고도 안전장치를 하나 더 마련해줘야 된다. Apple은 이를 위해서 Server to Server Notification 을 지원해준다.
App Store Connect > 나의 앱 > 앱 선택 > 일반 정보 > 앱 정보 > App Store 서버 알림
{
"signedPayload": ""
}
signedPayload 로 형태로 JWT 형식의 데이터가 올 건데, 마찬가지로 payload 부분만 Base64로 디코딩해주고, 데이터를 확인해보면 아래와 같은 형식으로 올 것이다. 이 정보를 가지고 (notificationType) 구독상태를 최신으로 반영해주면 된다. transactionId의 경우, signedTransactionInfo를 가지고 다시 디코딩해주면 얻을 수 있을 것이다.
{
"data": {
"appAppleId": 0,
"bundleId": "",
"bundleVersion": "",
"environment": "",
"signedRenewalInfo": "",
"signedTransactionInfo": "",
"status": 0
},
"expirationDate": 0,
"notificationType": "",
"notificationUUID": "",
"subtype": "",
"signedDate": 0,
"version": ""
}
주의할 점은, 결제 상태가 변경됨에 따라 Callback이 오는데 순서가 동기적이지 않다는 것이다. 따라서 , signedDate를 가지고 필터링해주는 과정이 필요하다.
다음은 구글 인앱 구독 결제 검증을 다뤄보겠다.
https://developer.apple.com/account/resources/authkeys/list
iOS 인 앱 구매 (In App Purchase) 구현 가이드라인