나는 이렇게 일한다/업무

인앱결제 서버 구현 _ 앱스토어 편

daco2020 2023. 6. 18. 15:23

가이드의 목적

단 하나의 글로 서버의 '앱스토어 인앱결제'를 구현하는 것이 이 가이드의 목표입니다.

저는 인앱결제를 구현하는 과정에서 공식문서와 여러 글들을 참고했지만 어려움이 많았습니다. 공식문서는 구현보단 스펙 중심으로 나열되어 있었고, 다른 대부분의 글들은 일부 구현 사항만 다루거나 자세히 설명하는 경우가 적었습니다.
 
그래서 이 가이드를 작성하였습니다. 이 가이드를 통해 여러분은 빠르고 편하게 인앱결제를 구현하시기 바랍니다.
 
 

들어가며

- 인앱결제 로직을 '새 구매'와 '갱신', '만료', '취소/재개', '환불'로 나누어 설명합니다.
- 인앱 상품 유형은 크게 '소모품'과 '구독상품' 두 가지 종류가 있습니다.
- 구현 언어는 Python을 사용합니다.
 
 


 
 

새 구매

서버가 하는 일

인앱결제 과정에서 서버는 어떤 일을 수행할까요?
 
새 구매의 과정은 아래와 같습니다.
 
1. 유저가 앱을 통해 결제를 하면 클라이언트는 결제정보를 서버로 보냅니다.
2. 서버는 결제정보를 다시 인앱 플랫폼에게 보내어 실제 결제 건이 맞는지 확인합니다.
3. 인앱 플랫폼은 이에 대한 응답으로 영수증을 보내줍니다.
4. 서버는 영수증에 담긴 정보를 통해 유효성을 검증합니다.
5. 검증이 완료되었다면 유저의 구매정보를 저장하고 해당 상품을 유저에게 지급합니다.

새 구매의 sequenceDiagram

 
 
 

공유암호 생성

본격적인 구현에 앞서 앱스토어는 공유암호(sharedsecret) 가 필요합니다.
공유암호 생성은 공식문서 안내를 따라주세요.
 
 
 
 

영수증 요청

새 구매는 서버가 클라이언트로부터 결제정보를 받는 것으로 시작합니다.
이 글에서는 클라이언트를 다루지 않기 때문에 필수로 필요한 필드에 대해서만 다루겠습니다.
*필드 이름은 클라이언트에 따라 상이할 수 있습니다.
 
transactionId : 거래의 고유 식별자
appStoreReceipt : 영수증 정보를 담고 있는 암호화된 문자열입니다.

"transactionId" : "2000000277760217"
"appStoreReceipt": "MIIV8AYJKoZIhvc...PXQ=="

 
 
해당 필드 값을 사용해 앱스토어에게 영수증을 요청합니다.
 
요청은 앱스토어의 verifyReceipt 을 통해 이루어지며, 아래 첫 번째 URL로 requestBody 를 전송해야 합니다.
*두 번째 URL은 테스트 환경에서의 영수증 요청 시 사용합니다.
 
POST - https://buy.itunes.apple.com/verifyReceipt
POST - https://sandbox.itunes.apple.com/verifyReceipt
 
requestBody는 다음 세 가지로 구성합니다.

receipt-data : Base64로 인코딩 된 영수증 데이터입니다.
⇒ 위에서 언급한 appStoreReceipt 값입니다. 클라이언트에게 받은 그대로 담아주세요.
 
password : 16진수 문자열인 앱의 공유 암호입니다.
⇒ 위에서 생성한 공유 암호입니다. 환경변수 등을 통해 가져와 담아주세요.
 
exclude-old-transactions : 이전 거래내역을 제외하는지에 대한 불린 값입니다.
⇒ 특별히 최신 거래내역만 필요한 것이 아니라면 False로 전달합니다.
 
 
성공적으로 요청하였다면 인앱 플랫폼으로 부터 영수증을 받을 수 있습니다.
응답받은 영수증에서 주의 깊게 봐야 하는 필드는 다음 두 가지입니다.
 
latest_receipt_info : 영수증을 담은 배열입니다. 해당 필드는 아래 새 구매 영수증 찾기에서 사용합니다.
status : 영수증의 상태를 알려줍니다. 해당 필드는 아래 영수증 유효성 확인에 사용합니다.
*더 자세한 필드를 알고 싶다면 공식문서를 참고해 주세요.
 
앱스토어로부터 응답받은 데이터는 아래와 같은 모습입니다.

{
    "receipt": {
        "receipt_type": "ProductionSandbox",
        "adam_id": 123456789,
        "app_item_id": 987654321,
        "bundle_id": "com.example.testapp",
        "application_version": "100",
        "download_id": 1122334455,
        "version_external_identifier": 9988776655,
        "receipt_creation_date": "2023-03-15 07:25:00 Etc/GMT",
        "receipt_creation_date_ms": "1677877900000",
        "receipt_creation_date_pst": "2023-03-14 23:25:00 America/Los_Angeles",
        "request_date": "2023-03-16 06:11:34 Etc/GMT",
        "request_date_ms": "1677959894062",
        "request_date_pst": "2023-03-15 22:11:34 America/Los_Angeles",
        "original_purchase_date": "2013-09-01 07:00:00 Etc/GMT",
        "original_purchase_date_ms": "1378340400000",
        "original_purchase_date_pst": "2013-09-01 00:00:00 America/Los_Angeles",
        "original_application_version": "2.0",
        "in_app": [
            {
                "quantity": "1",
                "product_id": "a1b2c3d4e5f6g7h8i9j10k11",
                "transaction_id": "3000000387632722",
                "original_transaction_id": "3000000387632722",
                "purchase_date": "2023-03-10 10:51:47 Etc/GMT",
                "purchase_date_ms": "1677458307000",
                "purchase_date_pst": "2023-03-10 02:51:47 America/Los_Angeles",
                "original_purchase_date": "2023-03-10 10:51:47 Etc/GMT",
                "original_purchase_date_ms": "1677458307000",
                "original_purchase_date_pst": "2023-03-10 02:51:47 America/Los_Angeles",
                "is_trial_period": "false",
                "in_app_ownership_type": "PURCHASED",
            },
        ],
    },
    "environment": "Sandbox",
    "latest_receipt_info": [
        {
            "quantity": "2",
            "product_id": "z1y2x3w4v5u6t7s8r9q10p11",
            "transaction_id": "3000000387760217",
            "original_transaction_id": "3000000387760217",
            "purchase_date": "2023-03-10 12:44:08 Etc/GMT",
            "purchase_date_ms": "1677465048000",
            "purchase_date_pst": "2023-03-10 04:44:08 America/Los_Angeles",
            "original_purchase_date": "2023-03-10 12:44:08 Etc/GMT",
            "original_purchase_date_ms": "1677465048000",
            "original_purchase_date_pst": "2023-03-10 04:44:08 America/Los_Angeles",
            "is_trial_period": "false",
            "in_app_ownership_type": "PURCHASED",
        },
    ],
    "latest_receipt": "MIIVQY...5rVpL9NlYh2/9l7rk0BcStXjQ==",
    "status": 0,
}

 
 
💡 구현 팁!

더보기

응답의 status 가 숫자 21007이라면, 이 영수증은 샌드박스환경(테스트)에서 생성된 영수증이라는 뜻입니다. 이 경우 '테스트 URL'로 다시 요청할 수 있습니다.

 

아래 샘플 코드를 참고해 주세요.

request_body = {...} # 요청 내용은 생략
purchase = requests.post(prod_url, json=request_body)

if purchase.json()["status"] == 21007:
    test_url = "https://sandbox.itunes.apple.com/verifyReceipt"
    test_purchase = requests.post(test_url, json=request_body)
    return test_purchase.json()

return purchase.json()

 
 
 
 

새 구매 영수증 찾기

응답받은 영수증은 새 구매에 대한 영수증뿐만 아니라 이전 거래에 대한 영수증을 포함하고 있습니다.
그래서 우리는 새 구매에 대한 영수증을 찾기 위한 추가 작업이 필요합니다.
 
응답받은 영수증 데이터 중에 latest_receipt_info는 거래가 완료된 소모품을 제외하고 유저의 모든 인앱 구매 거래를 포함합니다. 우리는 새 구매에 대한 영수증을 찾기 위해 latest_receipt_info을 사용하겠습니다.
 
응답받은 latest_receipt_info 에서 처음 클라이언트가 보내준 transactionId 와 일치하는 영수증을 찾습니다.
 
영수증은 아래와 같은 모습입니다.

"latest_receipt_info": [
    {
        "quantity": "1",
        "product_id": "213292cdad684767a536ff29a8474dfc",
        "transaction_id": "2000000277760217",
        "original_transaction_id": "2000000277760217",
        "purchase_date": "2023-02-15 12:44:08 Etc/GMT",
        "purchase_date_ms": "1676465048000",
        "purchase_date_pst": "2023-02-15 04:44:08 America/Los_Angeles",
        "original_purchase_date": "2023-02-15 12:44:08 Etc/GMT",
        "original_purchase_date_ms": "1676465048000",
        "original_purchase_date_pst": "2023-02-15 04:44:08 America/Los_Angeles",
        "expires_date": "2023-02-15 12:44:08 Etc/GMT",
        "expires_date_ms": "1676465048000",
        "expires_date_pst": "2023-02-15 04:44:08 America/Los_Angeles",
        "is_trial_period": "false",
        "in_app_ownership_type": "PURCHASED",
    },

 
영수증에서 우리가 다룰 필드는 다음과 같습니다.
 
product_id : 우리 서비스가 제공하는 상품의 아이디입니다.
transaction_id : 해당 거래의 고유 식별자입니다. 
original_transaction_id : 첫 거래에 대한 고유 식별자입니다. 
purchase_date_ms : 해당 거래일의 밀리 초입니다.
expires_date_ms : 해당 구독 만료일의 밀리 초입니다.
is_trial_period : 구독 활성화 시 평가판 기간이 있었는지 여부를 나타내는 불린 값입니다.
*소모품의 경우 일부 필드가 없을 수 있으니 각 상품유형에 맞게 필드를 다뤄주세요.
*더 자세한 필드를 알고 싶다면 공식문서를 참고해 주세요.
 
 
 
💡 구현 팁!

더보기

date 가 아닌 ms 필드를 사용하는 이유는 ms(밀리 초)가 더 쉽고 정확하게 시간을 다룰 수 있기 때문입니다. 또한, 형 변환 함수를 만들어두면 시간과 날짜를 더 쉽게 다룰 수 있습니다.

 

아래 샘플 코드를 참고해 주세요.

def datetime_to_msepoch(dt: datetime.datetime) -> int:
    return int(dt.timestamp() * 1000)

def msepoch_to_datetime(ms: int) -> datetime.datetime:
    return datetime.datetime.fromtimestamp(ms / 1000, tz=datetime.timezone.utc)

 
 
 
 
 
 

영수증 유효성 확인

앞서 인앱 플랫폼으로부터 응답받았던 데이터에는 status 필드가 포함되어 있습니다.
status의 값(int)을 통해 해당 거래가 유효한지 여부를 알 수 있습니다.
 
아래는 status에 대한 설명입니다. 

21000 App Store에 대한 요청은 HTTP POST 요청 방법을 사용하지 않았습니다.
21001 App Store는 더 이상 이 상태 코드를 보내지 않습니다.
21002 속성의 데이터 receipt-data형식이 잘못되었거나 서비스에 일시적인 문제가 발생했습니다. 다시 시도하십시오.
21003 시스템에서 영수증을 인증할 수 없습니다.
21004 제공한 공유 비밀이 계정 파일의 공유 비밀과 일치하지 않습니다.
21005 영수증 서버에서 일시적으로 영수증을 제공할 수 없습니다. 다시 시도하십시오.
21006 이 영수증은 유효하지만 구독이 만료된 상태입니다. 서버가 이 상태 코드를 수신하면 시스템도 응답의 일부로 영수증 데이터를 디코딩하고 반환합니다. 이 상태는 자동 갱신 구독에 대한 iOS 6 스타일의 거래 영수증에 대해서만 반환됩니다.
21007 이 영수증은 테스트 환경에서 받은 것이지만 확인을 위해 프로덕션 환경으로 보냈습니다.
21008 이 영수증은 프로덕션 환경에서 받은 것이지만 확인을 위해 테스트 환경으로 보냈습니다.
21009 내부 데이터 액세스 오류입니다. 나중에 다시 시도하십시오.
21010 시스템이 사용자 계정을 찾을 수 없거나 사용자 계정이 삭제되었습니다.
21100-21199 내부 데이터 액세스 오류입니다.
0 유효한 상태입니다.

 
 
status 가 0 이라면 유효한 상태이므로 그대로 진행하고, 0 이 아니라면 요구사항에 맞게 핸들링해 주세요.
*더 자세한 내용을 알고 싶다면 공식문서를 참고해 주세요.
 
 
 
 
 

구매정보 저장

영수증의 유효성을 확인하였다면 유저의 구매정보를 저장해야 합니다.
우리는 이미 앞서 영수증을 통해 product_id를 알고 있습니다.
 
product_id로 DB에 저장되어 있는 상품정보를 가져옵니다.
상품정보와 영수증 내용을 토대로 비즈니스에 필요한 구매정보를 생성하고 이를 저장합니다.
 
예를 들어, 소모품과 구독상품을 유형별로 분류하고 상품의 개수나 가격, 일시, 결제 상태 등을 저장할 수 있습니다.
비즈니스 로직에 따라 유저에게 상품을 지급하고 마지막으로 클라이언트에게 성공을 반환하면 새 구매가 마무리됩니다!🥳
 
 
 


 
 

갱신, 만료, 취소/재개, 환불

구독상품

구독상품의 경우 새 구매에서 끝나는 것이 아니라 이후의 갱신, 만료까지 다룰 수 있어야 합니다.
구독 만료일이 되면 유저는 갱신을 통하여 자동 결제가 진행됩니다.
 
만약 유저가 자동갱신을 취소하거나, 혹은 지불할 금액이 없다면 결제에 실패할 수 있습니다.
서버는 유저의 인앱결제 상태를 확인하여 상품의 구독기간을 갱신(연장)하거나 만료처리를 진행해야 합니다.
 
그렇다면 서버는 유저의 결제 상태를 어떻게 확인하고 처리할 수 있을까요?
 
 
 

앱스토어 알림

서버는 앱스토어가 제공하는 알림을 통해 인앱 구매 이벤트를 실시간으로 모니터링할 수 있습니다.
앱스토어는 새 구매, 구독 갱신, 결제 문제 등등, 상태가 변경될 때마다 서버에게 알려줍니다.
*더 자세한 내용을 알고 싶다면 공식문서를 참고해 주세요.
 
 
서버가 앱스토어로부터 알림을 받으려면 알림을 받을 URL을 앱스토어에게 알려주어야 합니다.
해당 공식문서에서 안내하는 내용을 따라 URL을 입력합니다.
 
알림을 받고자 하는 URL은 https여야 하며, 수신받고자 하는 알림 버전을 선택해야 합니다.
 
알림 버전은 v1과 v2가 있으며 v2 알림은 구독 만료, 쿠폰 사용 등을 포함하여 인앱 구매 수명 주기의 더 많은 이벤트를 다룹니다. 버전 별로 제공하는 데이터가 다르므로 아래 버전 별 응답 값을 확인하여 요구사항에 적합한 버전을 선택하세요.
 
v1 응답 값
v2 응답 값
 
앱스토어는 v2 버전을 권장하므로 이 글에서는 v2에 맞춰 설명하겠습니다.
 
 
 
 

알림 데이터 복호화

v2 알림은 signedPayload라는 이름의 암호화된 문자열로 들어옵니다.
signedPayload는 앱스토어에 의해 JSON Web Signature(JWS) 형태로 서명된 body 값입니다.
 
앞서 알림을 받도록 설정했던 서버 엔드포인트 URL로 POST body를 받는 API를 구현해야 합니다.
*API 구현은 프레임워크별로 방법이 상이하기 때문에 이 글에서는 다루지 않겠습니다.
 
JWS 가 생소하신 분들을 위해 간단한 예제로 설명하겠습니다.
 
예를 들어 JWS는 이런 형태를 가집니다.

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

 
 
이 JWS 문자열은 세 부분으로 나뉘며 각각 헤더, 페이로드, 서명을 나타냅니다. 각 부분은 점(.)으로 구분됩니다.
 
- 헤더(Header): eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
- 페이로드(Payload): eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ
- 서명(Signature): SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
 
위의 JWS 문자열의 헤더를 디코딩하면 다음과 같은 JSON을 얻을 수 있습니다.

{
  "alg": "HS256",
  "typ": "JWT"
}

 
마찬가지로 페이로드를 디코딩하면 다음과 같은 JSON을 얻을 수 있습니다.

{
  "sub": "1234567890",
  "name": "Daco kim",
  "iat": 1516239022
}

 
자 그렇다면 JWS 는 어떻게 복호화할 수 있을까요?
 
이 글에서는 Python 라이브러리를 통해 JWS를 복호화하겠습니다.
pyjwt 패키지를 추가하고 decode_jws 함수를 작성하여 알림 데이터를 딕셔너리 형태로 반환하겠습니다.
 
아래 구현코드를 참고해 주세요.

import jwt
from cryptography.x509 import load_pem_x509_certificate

def decode_jws(encoded_jws: str) -> dict[str, Any]:
    header = jwt.get_unverified_header(encoded_jws)

    cert_str = f"-----BEGIN CERTIFICATE-----{header['x5c'][0]}-----END CERTIFICATE-----".encode()
    cert_obj = load_pem_x509_certificate(cert_str)
    public_key = cert_obj.public_key()

    decoded_jws = jwt.decode(
        encoded_jws, key=public_key, algorithms=[header["alg"]], options={"verify_signature": True}
    )
    return decoded_jws

 
한 줄씩 설명하겠습니다.
 
header = jwt.get_unverified_header(encoded_jws)
인코딩 된 JWS 문자열에서 헤더를 추출합니다. 이때 헤더는 검증되지 않은 상태입니다.
 
cert_str = f"-----BEGIN CERTIFICATE-----{header['x5c'][0]}-----END CERTIFICATE-----".encode()
⇒ JWS 헤더에 포함된 x.509 공개키를 PEM 문자열로 변환합니다.
 
cert_obj = load_pem_x509_certificate(cert_str)
⇒ load_pem_x509_certificate 함수를 사용해 PEM 문자열을 인증서 객체로 변환합니다.
 
public_key = cert_obj.public_key()
⇒ cert_obj.public_key() : 인증서 객체에서 공개키를 추출한 변수입니다.

decoded_jws = jwt.decode(...)
⇒ jwt 라이브러리의 decode 메서드를 사용해 JWS를 디코딩합니다. 이때 공개키와 헤더의 alg 필드에 지정된 알고리즘을 사용해 서명을 검증합니다.
 
 
이로써 우리는 복호화된 알림 데이터를 얻을 수 있습니다!
 
 
💡 구현 팁!

더보기

JWS 검증과 원리에 대해 더 자세히 알고 싶다면 아래 글을 참고해 주세요.

 

앱스토어 인앱결제 서버 알림 Signature 검증 구현기 (feat. X.509 인증서란?)

 
 
이렇게 복호화가 끝난 줄 알았죠? 어림도 없습니다!
 
앱스토어가 주는 알림 데이터는 내부 객체도 JWS를 사용합니다.
알림으로 들어온 signedPayload 을 복호화하면 responseBodyV2DecodedPayload 을 얻습니다.
 
responseBodyV2DecodedPayload 에는 data 가 있으며 data 에는 signedRenewalInfo 와 signedTransactionInfo 가 있습니다.
*필드 내용에 대해 자세히 알고 싶다면 공식문서를 참고해 주세요.
 
우리가 필요로 하는 정보는 signedRenewalInfo 와 signedTransactionInfo 입니다.
객체의 이름이 signed… 로 시작합니다.
 
눈치채셨나요?
 
네 맞습니다. 이 객체들도 JWS 로 암호화되어 있으며 우리는 이를 복호화해야 합니다.
다행히 우리는 decoded_jws 함수를 만들었으므로 이를 쉽게 복호화할 수 있습니다.
 
 
아래 구현코드를 참고해 주세요.
*아래 코드는 이해를 돕기 위해 공식문서가 제시한 객체이름을 그대로 사용하였습니다. 실제 구현을 할 때에는 더 가독성 좋은 이름을 사용하시기 바랍니다.

responseBodyV2DecodedPayload = decoded_jws(encoded_jws)
signedRenewalInfo = responseBodyV2DecodedPayload["data"].get("signedRenewalInfo")
signedTransactionInfo = responseBodyV2DecodedPayload["data"].get("signedTransactionInfo")

responseBodyV2DecodedPayload["data"]["signedRenewalInfo"] = (
    decoded_jws(signedRenewalInfo) if signedRenewalInfo else None
)
responseBodyV2DecodedPayload["data"]["signedTransactionInfo"] = (
    decoded_jws(signedTransactionInfo) if signedTransactionInfo else None
)

 
data 에 signedRenewalInfo 와 signedTransactionInfo 값이 존재한다면 해당 값들을 복호화하여 다시 저장하였습니다. 이제 responseBodyV2DecodedPayload 는 우리가 활용할 수 있는 데이터가 되었습니다!
 
 
알림 데이터의 복호화가 끝났으니 이제는 알림 유형을 확인하고 거래 정보를 확인할 차례입니다.
 
 
 
 
 

알림 유형 및 거래 정보 확인

알림 데이터(responseBodyV2DecodedPayload) 에는 알림 유형을 확인할 수 있는 notificationType 과 subtype 필드가 있습니다.
 
notificationType 을 통해 앱스토어의 구매 이벤트를 확인할 수 있습니다.
subtype 은 구매 이벤트에 대한 추가 정보를 제공합니다.
 
이 글에서는 아래 네 가지 notificationType에 대해서만 다루겠습니다.
 
DID_RENEW : 구독이 성공적으로 갱신되었을 때 발생하는 알림입니다.
EXPIRED : 구독이 만료되었을 때 발생하는 알림입니다.
DID_CHANGE_RENEWAL_STATUS :  구독 갱신 상태를 변경했음을 나타내는 알림 유형입니다.
REFUND : 소모품 혹은 구독이 환불되었을 때 발생하는 알림입니다. 
 
해당 notificationType 을 통해 서버는 갱신과 만료, 취소/재개, 환불 상태를 알 수 있습니다.
*실제 비즈니스에서는 요구사항에 따라 더 많은 notificationType과 subtype를 다루어야 합니다.
*알림 유형에 대한 공식문서를 참고하여 필요한 유형을 확인하세요.
 
 
알림 유형을 알았다면 이제 해당 알림의 거래 정보를 확인할 차례입니다.
앞서 우리는 signedRenewalInfo 와 signedTransactionInfo 를 복호화하였습니다.
 
공식문서에서는 복호화된 값을 다음처럼 명시합니다.
 
signedRenewalInfo → JWSRenewalInfoDecodedPayload(구독 갱신 정보)
signedTransactionInfo → JWSTransactionDecodedPayload(거래 정보)
 
이 중에 우리는 JWSTransactionDecodedPayload(거래 정보)를 사용할 것입니다.
그 이유는 JWSTransactionDecodedPayload 가 expiresDate과 purchaseDate, transactionId 필드를 가지고 있기 때문입니다.
*JWSTransactionDecodedPayload 의 상세한 필드 정보는 공식문서를 참고해주세요.
 
 
우리가 JWSTransactionDecodedPayload 에서 주의 깊게 봐야 하는 필드는 다음과 같습니다.
 
productId : 구매한 상품의 식별자
expiresDate : 구독이 만료되거나 갱신되는 밀리 초
purchaseDate : 앱스토어에서 구매, 복원, 구독 또는 갱신에 대해 결제가 이루어진 밀리 초
transactionId : 거래의 고유 식별자
originalTransactionId : 첫 거래에 대한 고유 식별자 
 
 
해당 필드들을 활용하여 구독에 대한 갱신과 만료, 환불 등을 구현해 주세요.
 
갱신 (notificationType == DID_RENEW)
갱신이므로 새로운 구매정보를 생성하고 상품의 구독 권한을 연장하도록 구현할 수 있습니다.
 
만료 (notificationType == EXPIRED)
만료이므로 기존 구매정보를 조회하고 상품의 구독 권한을 만료시키도록 구현할 수 있습니다.
 
취소/재개 (notificationType == DID_CHANGE_RENEWAL_STATUS)
취소/재개는 subtype 을 통해 구독취소 혹은 구독재개 여부를 확인할 수 있습니다.
공식문서를 통해 subtype 을 확인하여 핸들링해 주세요.
 
구독취소는 환불과는 다른 개념으로 현재 구독은 유지하되 자동갱신을 하지 않는다는 의미입니다.
 
환불 (notificationType == EXPIRED)
만료, 갱신, 취소/재개가 구독상품만을 대상으로 한다면 환불은 소모품과 구독상품 모두 다룹니다.
환불 알림을 받았다면 해당 유저의 구매정보를 조회하고 지급된 상품을 회수하거나 구독 권한을 중지시켜야 합니다.
 
참고로, 인앱에서의 환불은 우리가 결정할 수 없습니다. 환불을 결정하는 주체는 우리가 아닌 인앱 플랫폼이기 때문에 우리는 앱스토어로부터 환불 알림을 수신받아 처리하는 것 외에는 관여하기 어렵습니다.
 
 
 


 

마치며

드디어 기본적인 인앱결제 구현을 마쳤습니다!
 
가이드와 함께 구현하는 과정에서 인앱결제에 대한 전반적인 흐름을 익혔으리라 생각합니다. 이제는 각자의 비즈니스 요구사항에 따라 적정한 구현을 덧붙여 나가시기 바랍니다.
 
혹시 가이드에 모호한 부분이 있거나 잘못된 내용이 있다면 댓글 부탁드리겠습니다.🙏🏼
 
 
 
레퍼런스
verifyReceipt | Apple Developer Documentation

 

verifyReceipt | Apple Developer Documentation

Send a receipt to the App Store for verification.

developer.apple.com

iOS 인앱 구매, 4부: 서버 측 구매 검증

 

iOS 인앱 구매, 4부: 서버 측 구매 검증

iOS 인앱 구매: 서버 측 영수증 검증 (receipt validation). 이 기사에서는 영수증 검증이 중요한 이유와 설정 방법에 대해 설명합니다

adapty.io

Tollgate?! 완전정복 — 모바일 결제의 모든것

 

Tollgate?! 완전정복 — 모바일 결제의 모든것

인앱 결제는 이 글 하나로 끝

blog.mathpresso.com

앱스토어 인앱결제 서버 알림 Signature 검증 구현기 (feat. X.509 인증서란?)

 

앱 스토어 인앱결제 서버 알림 Signature 검증 구현기 (feat. X.509 인증서란?)

안녕하세요, 오늘은 앱 스토어 인앱결제 시스템 구현을 위해 사용되어지는 App Store Server Notification에서 수신 받는 데이터를 검증하는 방법에 대해서 이야기 해보려고 합니다. https://developer.apple.co

leffept.tistory.com