코드로 우주평화
인앱결제 서버 구현 _ 플레이스토어 편 본문
가이드의 목적
단 하나의 글로 서버의 '플레이스토어 인앱결제'를 구현하는 것이 이 가이드의 목표입니다.
저는 인앱결제를 구현하는 과정에서 공식문서와 여러 글들을 참고했지만 어려움이 많았습니다. 공식문서는 구현보단 스펙 중심으로 나열되어 있었고, 다른 대부분의 글들은 일부 구현 사항만 다루거나 자세히 설명하는 경우가 적었습니다.
그래서 이 가이드를 작성하였습니다. 이 가이드를 통해 여러분은 빠르고 편하게 인앱결제를 구현하시기 바랍니다.
들어가며
- 인앱결제 로직을 '새 구매'와 '갱신', '만료', '취소/재개', '환불'로 나누어 설명합니다.
- 인앱 상품 유형은 크게 '소모품'과 '구독상품' 두 가지 종류가 있습니다.
- 구현 언어는 Python을 사용합니다.
새 구매
서버가 하는 일
인앱결제 과정에서 서버는 어떤 일을 수행할까요?
새 구매의 과정은 아래와 같습니다.
1. 유저가 앱을 통해 결제를 하면 클라이언트는 결제정보를 서버로 보냅니다.
2. 서버는 결제정보를 다시 인앱 플랫폼에게 보내어 실제 결제 건이 맞는지 확인합니다.
3. 인앱 플랫폼은 이에 대한 응답으로 영수증을 보내줍니다.
4. 서버는 영수증에 담긴 정보를 통해 유효성을 검증합니다.
5. 검증이 완료되었다면 유저의 구매정보를 저장하고 해당 상품을 유저에게 지급합니다.
서비스 계정 키
본격적인 구현에 앞서 플레이스토어는 서비스 계정 키가 필요합니다.
서비스 계정 키 생성은 공식문서 안내를 따라주세요.
서비스 계정을 생성했다면 IAM 및 관리자 - 서비스 계정 페이지에서 생성한 서비스 계정을 선택합니다.
이미지를 참고하여 서비스 계정의 [키] 탭으로 이동합니다.
[키 추가]를 통해 새로운 키를 생성하고 JSON 파일로 다운로드하세요.
이렇게 얻은 서비스 계정 키는 영수증 요청 과정에서 androidpublisher 객체를 생성할 때 사용합니다.
영수증 요청
새 구매는 서버가 클라이언트로부터 결제정보를 받는 것으로 시작합니다.
이 글에서는 클라이언트를 다루지 않기 때문에 필수로 필요한 필드에 대해서만 다루겠습니다.
*필드 이름은 클라이언트에 따라 상이할 수 있습니다.
productId : 우리가 판매하는 인앱 상품 id 입니다.
packageName : 우리 애플리케이션의 패키지 이름입니다.
purchaseToken : 인앱 상품을 구매할 때 사용자 기기에 제공된 토큰입니다.
"packageName":"com.some.thing"
"productId":"com.some.thing.inapp1"
"purchaseToken":"incafcigbkclcgbnbk ... "
해당 필드 값을 사용해 플레이스토어에게 영수증을 요청합니다.
영수증 호출은 구글의 androidpublisher 객체를 통해 진행합니다.
androidpublisher 의 자세한 스펙이 알고 싶다면 공식문서를 참고해 주세요.
앞서 우리는 '서비스 계정 키'를 얻었습니다.
이를 사용해 androidpublisher 객체를 생성하겠습니다.
먼저 필요한 라이브러리를 설치합니다.
- oauth2client
- google-api-python-client
아래 구현코드를 참고해 주세요.
from oauth2client.service_account import ServiceAccountCredentials
from httplib2 import Http
from googleapiclient.discovery import build
scopes = ['https://www.googleapis.com/auth/androidpublisher']
credentials = ServiceAccountCredentials.from_json_keyfile_name(
service_account_key_json, # 사용자 계정 키(JSON) 파일 경로
scopes,
)
http_auth = credentials.authorize(Http())
androidpublisher = build('androidpublisher', 'v3', http=http_auth)
scopes 는 우리가 Google Play 개발자 API에 접근할 범위를 뜻합니다. Oauth 2.0 범위에서 scope 를 확인할 수 있습니다.
credentials 는 서비스 계정에 대한 인증 정보를 생성합니다.
http_auth 는 HTTP 객체를 생성하고 인증합니다.
androidpublisher 는 build 함수를 통해 androidpublisher 객체를 생성합니다. 이때 버전은 앞서 Oauth 2.0 범위에서 명시하고 있는 v3를 사용합니다.
androidpublisher 는 앱스토어와 달리 소모품과 구독상품을 별도의 인터페이스로 호출합니다.
영수증 요청 인터페이스를 통해 클라이언트가 준 값을 매개변수로 전달합니다.
아래 구현코드를 참고해 주세요.
# 소모품의 경우
consumalbe = (
androidpublisher.purchases()
.products()
.get(
productId=productId,
packageName=packageName,
token=purchaseToken,
)
.execute()
)
# 구독상품의 경우
subscription = (
androidpublisher.purchases()
.subscriptions()
.get(
subscriptionId=productId,
packageName=packageName,
token=purchaseToken,
)
.execute()
)
💡 구현 팁!
위의 구현 코드를 클래스 객체로 만들면 더 안전하고 편리하게 사용할 수 있습니다.
예를 들어 GoogleClient 라는 클래스를 만들어 androidpublisher 객체 생성과 영수증을 가져오는 purchases 메서드를 캡슐화시킬 수 있습니다.
영수증 요청에 대한 응답 값은 상품 종류에 따라 차이가 있습니다. 상세한 내용은 아래 공식문서를 참고해 주세요.
소모품 - ProductPurchase
구독상품 - SubscriptionPurchase
이제 서버는 소모품과 구독상품에 대한 영수증을 가져올 수 있습니다! 🥳
영수증 유효성 확인
이제 응답받은 영수증의 유효성을 확인할 차례입니다.
우리가 영수증에서 주의 깊게 봐야 하는 필드는 다음과 같습니다. 소모품과 구독상품은 영수증 필드가 다르기 때문에 공통, 소모품, 구독상품으로 나누어 설명하겠습니다.
*공통 (상품 종류(소모품/구독상품)와 상관없이 확인해야 하는 필드)
orderId : 고유 거래 식별자입니다. ex) GPA.3374-2691-3583-90384
kind : 상품의 종류를 나타내는 str 값입니다.
- kind 가 androidpublisher#productPurchase 라면 소모품을 나타냅니다.
- kind 가 androidpublisher#subscriptionPurchase 라면 구독상품을 나타냅니다.
acknowledgementState : 상품 승인 상태를 나타내는 int 값입니다.
- 0 이라면 아직 승인되지 않은 상태입니다.
- 1 이라면 승인된 상태입니다.
*소모품에서 봐야 하는 필드
purchaseState : 결제 상태를 나타내는 int 값입니다.
- 0 구매됨
- 1 취소됨
- 2 보류중
consumptionState : 상품의 소비 상태를 나타내는 int 값입니다.
- 0 아직 소비되지 않음
- 1 소비됨
purchaseTimeMillis : 구매한 시점의 밀리 초 str 값입니다.
*구독상품에서 봐야 하는 필드
paymentState : 구독 결제 상태를 나타내는 int 값입니다.
- 0 결제 보류 중
- 1 결제 완료
- 2 무료 평가판
- 3 업그레이드/다운그레이드 보류 중
startTimeMillis : 구독 시작 시간의 밀리 초 str 값입니다.
expiryTimeMillis : 구독 만료 시간의 밀리 초 str 값입니다.
위의 필드 내용을 바탕으로 영수증의 유효성 여부를 확인할 수 있습니다.
서버의 상품 승인
앞서 영수증의 공통 필드에 acknowledgementState 를 언급했습니다.
acknowledgementState 는 상품의 승인상태를 나타내며, 만약 값이 0 (아직 승인되지 않음)이라면 서버가 인앱 플랫폼에게 직접 승인을 요청해야 합니다. 만약 승인을 하지 않으면 거래가 자동 취소 및 환불이 될 수 있으니 유의해주세요.
영수증 요청과 마찬가지로 승인도 소모품과 구독상품을 구분하여 요청합니다.
전달하는 매개변수도 영수증 요청과 동일합니다.
아래 구현코드를 참고해 주세요.
# 소모품의 경우
androidpublisher.purchases().products().acknowledge(
productId=productId, packageName=packageName, token=purchaseToken
).execute()
# 구독상품의 경우
androidpublisher.purchases().subscriptions().acknowledge(
subscriptionId=productId, packageName=packageName, token=purchaseToken
).execute()
구매정보 저장
영수증의 유효성을 확인하였다면 유저의 구매정보를 저장해야 합니다.
우리는 이미 product_id(productId)를 알고 있습니다.
product_id로 DB에 저장되어 있는 상품정보를 가져옵니다.
상품정보와 영수증 내용을 토대로 비즈니스에 필요한 구매정보를 생성하고 이를 저장합니다.
예를 들어, 소모품과 구독상품을 유형별로 분류하고 상품의 개수나 가격, 일시, 결제 상태 등을 저장할 수 있습니다.
비즈니스 로직에 따라 유저에게 상품을 지급하고 마지막으로 클라이언트에게 성공을 반환하면 새 구매가 마무리됩니다!🥳
갱신, 만료, 취소/재개
구독상품
구독상품의 경우 새 구매에서 끝나는 것이 아니라 이후의 갱신, 만료까지 다룰 수 있어야 합니다.
구독 만료일이 되면 유저는 갱신을 통하여 자동 결제가 진행됩니다.
만약 유저가 자동갱신을 취소하거나, 혹은 지불할 금액이 없다면 결제에 실패할 수 있습니다.
서버는 유저의 인앱결제 상태를 확인하여 상품의 구독기간을 갱신(연장)하거나 만료처리를 진행해야 합니다.
그렇다면 서버는 유저의 결제 상태를 어떻게 확인하고 처리할 수 있을까요?
플레이스토어 서버 알림
서버는 구글 PubSub이 제공하는 알림을 통해 인앱 구매 이벤트를 실시간으로 모니터링할 수 있습니다.
구글 PubSub은 Push 구독과 Pull 구독이 있습니다.
Push 는 서버가 지정한 URL로 알림 데이터를 보내는 것입니다.
Pull 은 서버 내에서 PubSub 객체를 사용해 알림 데이터를 가져오는 것입니다.
두 방식에는 장단점이 있으니 공식문서를 통해 자세히 확인 후 결정하세요!
Push 는 알림 데이터를 받는 공개 API를 사용하며 Pull 에 비해 구현이 간단합니다.
이 글에서는 Push 구독을 다루겠습니다.
PubSub은 새 구매, 구독 갱신, 결제 문제 등등, 상태가 변경될 때마다 서버에게 알려줍니다.
서버가 PubSub으로부터 알림을 받으려면 알림에 대한 주제를 만들고 알림을 받을 URL을 지정해야 합니다.
주제 만들기 문서를 보고 주제를 만들고 실시간 개발자 알림 사용을 설정해 주세요.
push 인증을 위한 Pub/Sub 구성 문서를 참고하여 구독을 생성하고 엔드포인트 URL을 설정해 주세요.
이제 알림 데이터를 수신받기 위한 사전 준비가 완료되었습니다. 실제로 알림을 받고 거래 정보를 확인해 봅시다!
알림 유형 및 거래 정보 확인
PubSub 이 보내주는 알림 데이터(message)는 POST body 로 전달됩니다.
앞서 설정했던 엔드포인트 URL로 POST body를 받는 API를 구현해 주세요.
*API 구현은 프레임워크별로 방법이 상이하기 때문에 이 글에서는 다루지 않겠습니다.
알림 데이터는 아래 형태로 들어오며 여기서 우리가 주의 깊게 봐야 하는 필드는 data 입니다.
{
"subscription": "projects/myproject/subscriptions/mysubscription",
"message": {
"attributes": {"key": "value"},
"data": "eyAidmVyc2lvbiI6IHN0cmlu ... GlmaWNhdGlvbiB9",
"messageId": "136969346945"
}
}
data 필드는 base64로 인코딩 되어 있는 문자열입니다.
아래 구현코드를 참고하여 디코딩할 수 있습니다.
import base64
import json
decoded_data = base64.b64decode(message["data"]).decode("utf-8")
notification_data = json.loads(decoded_data)
디코딩이 완료된 notification_data 변수는 아래와 같은 형태입니다.
{
"version":"1.0",
"packageName":"com.some.thing",
"eventTimeMillis":"1503349566168",
"oneTimeProductNotification": OneTimeProductNotification,
"subscriptionNotification": SubscriptionNotification,
"testNotification": TestNotification
}
oneTimeProductNotification, subscriptionNotification, testNotification 필드는 알림의 대상(소모품, 구독상품, 테스트)에 따라 있을 수도 있고 없을 수도 있습니다. 필드에 대한 상세한 내용은 공식문서를 참고해 주세요.
예를 들어 구독상품에 대한 알림이라면 oneTimeProductNotification 와 testNotification 필드는 notification 에 담기지 않습니다.
우리는 구독상품에 대한 갱신과 만료 알림을 다룰 것이므로 subscriptionNotification 필드에 대해서만 다루겠습니다.
SubscriptionNotification은 다음 형태를 가집니다.
{
"version":"1.0",
"notificationType":2,
"purchaseToken":"PURCHASE_TOKEN",
"subscriptionId":"monthly001"
}
notificationType 으로 해당 알림 데이터가 어떤 유형인지 확인해 보겠습니다.
아래 표에 notificationType 을 정리해 두었습니다.
여기서는 갱신과 만료, 취소/재개를 구현할 것이므로 2, 3, 7, 13 만 다루겠습니다.
notificationType | 이름 | 설명 |
1 | SUBSCRIPTION_RECOVERED | 정기 결제가 계정 보류에서 복구되었습니다. |
2 | SUBSCRIPTION_RENEWED | 활성 정기 결제가 갱신되었습니다. |
3 | SUBSCRIPTION_CANCELED | 정기 결제가 자발적으로 또는 비자발적으로 취소되었습니다. 자발적 취소의 경우 사용자가 취소할 때 전송됩니다. |
4 | SUBSCRIPTION_PURCHASED | 새로운 정기 결제가 구매되었습니다. |
5 | SUBSCRIPTION_ON_HOLD | 정기 결제가 계정 보류 상태가 되었습니다(사용 설정된 경우). |
6 | SUBSCRIPTION_IN_GRACE_PERIOD | 정기 결제가 유예 기간 상태로 전환되었습니다(사용 설정된 경우). |
7 | SUBSCRIPTION_RESTARTED | 사용자가 Play > 계정 > 정기 결제에서 정기 결제를 복원했습니다. 정기 결제가 취소되었지만 사용자가 복원할 때 아직 만료되지 않았습니다. |
8 | SUBSCRIPTION_PRICE_CHANGE_CONFIRMED | 사용자가 정기 결제 가격 변경을 확인했습니다. |
9 | SUBSCRIPTION_DEFERRED | 구독 갱신 기한이 연장되었습니다. |
10 | SUBSCRIPTION_PAUSED | 구독이 일시중지되었습니다. |
11 | SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED | 정기 결제 일시중지 일정이 변경되었습니다. |
12 | SUBSCRIPTION_REVOKED | 정기 결제가 만료 시간 전에 사용자에 의해 취소되었습니다.(환불) |
13 | SUBSCRIPTION_EXPIRED | 정기 결제가 만료되었습니다. |
notificationType 을 확인하였다면 이제 알림 맞는 동작을 수행할 차례입니다. 이를 위해 알림에 해당하는 거래 영수증이 필요합니다.
PubSub 이 주는 알림 데이터에는 영수증 데이터가 없기 때문에 새 구매와 마찬가지로 androidpublisher 객체를 사용하여 영수증을 가져옵니다.
아래 구현코드를 참고해 주세요.
subscription = (
androidpublisher.purchases()
.subscriptions()
.get(
subscriptionId=subscriptionId,
packageName=packageName,
token=purchaseToken,
)
.execute()
)
전달할 매개변수는 notification_data 에서 얻을 수 있습니다.
*subscriptionId 는 우리의 상품 id(porduct_id) 이기도 합니다. 해당 id로 Product를 가져올 수 있습니다.
구독상품에 대한 영수증에서 우리가 주의 깊게 봐야 하는 필드는 다음과 같습니다.
*전체 필드 내용은 공식문서에서 확인할 수 있습니다.
orderId : 거래의 고유 식별자입니다. orderId를 구매정보에 저장했다면 해당 orderId로 구매정보를 조회할 수 있습니다.
startTimeMillis : 구독 시작 시간의 밀리 초 str 값입니다.
expiryTimeMillis : 구독 만료 시간의 밀리 초 str 값입니다.
앞서 확인한 notificationType 와 영수증 필드를 활용하여 갱신과 만료, 취소/재개를 구현할 수 있습니다.
갱신 (notificationType == 2)
갱신이므로 영수증의 startTimeMillis 과expiryTimeMillis 필드를 활용해 새로운 구매정보를 생성하고 상품의 구독 권한을 연장하도록 구현할 수 있습니다.
취소 (notificationType == 3)
기존 구매정보를 조회하고 상품의 구독취소(자동갱신 해지)를 확인할 수 있습니다.
재개 (notificationType == 7)
기존 구매정보를 조회하고 상품의 구독재개(자동갱신 재개)를 확인 할 수 있습니다.
환불 (notificationType == 12)
구독의 경우 환불은 알림을 통해 확인할 수 있습니다. 소모품 환불은 아래 '환불(소모품)'에서 설명합니다.
만료 (notificationType == 13)
만료이므로 기존 구매정보를 조회하고 상품의 구독 권한을 만료시키도록 구현할 수 있습니다.
💡 구현 팁!
최초 거래 식별자(originalOrderId) 알아내는 방법
플레이스토어는 갱신 시, originalOrderId 를 주지 않기 때문에 기존 구매정보를 알 수 없습니다. 대신 orderId 에는 일정한 규칙이 있기 때문에 이를 활용하여 originalOrderId 를 구할 수 있습니다.
갱신 시 플레이스토어가 주는 orderId 는 GPA.3382-9215-9042-70164..0 처럼 끝 3자리에 ..X 형태로 갱신된 횟수를 부여합니다. (첫 번째 갱신은 ..0, 두 번째 갱신은 ..1, …)
그러므로 마지막 접미사(..X) 3자리를 없애 기존 거래의 식별자를 얻을 수 있습니다. 즉, 위 예시에서는 GPA.3382-9215-9042-70164 가 originalOrderId 입니다.
환불(소모품)
앞서 알림을 받는 데 사용하였던 PubSub은 소모품의 환불 알림은 주지 않습니다.
때문에 서버는 voidedpurchases 메서드를 사용해 무효화된 거래건을 가져와야 합니다.
(플레이스토어는 환불이 아닌 무효라는 개념을 사용합니다)
💡 구현 팁!
서버가 직접 무효화된 거래를 가져오기 때문에 플레이스토어는 환불(무효)을 실시간으로 처리하기는 어렵습니다.
스케줄러를 사용해 일정한 주기마다 무효화된 거래를 호출할 수 있도록 구현하는 것을 권장합니다.
voidedpurchases 는 androidpublisher 와 package_name 을 통해 호출할 수 있습니다.
아래 구현코드를 참고해 주세요.
voidedpurchases = (
androidpublisher.purchases()
.voidedpurchases()
.list(packageName=package_name)
.execute()
)
voidedpurchases 변수는 해당하는 package 의 모든 환불(무효)된 거래 목록을 가지는 배열 형태입니다.
voidedpurchases 배열 내의 개별 객체는 다음의 필드를 가집니다.
{
"kind": string,
"purchaseToken": string,
"purchaseTimeMillis": string,
"voidedTimeMillis": string,
"orderId": string,
"voidedSource": integer,
"voidedReason": integer
}
우리는 orderId(거래의 고유 식별자)를 사용해 DB로부터 구매정보를 불러와 환불처리를 해야 합니다.
구매정보를 조회하고 지급된 상품을 회수하거나 혜택을 중지시킵니다.
마치며
드디어 기본적인 인앱결제 구현을 마쳤습니다!
가이드와 함께 구현하는 과정에서 인앱결제에 대한 전반적인 흐름을 익혔으리라 생각합니다. 이제는 각자의 비즈니스 요구사항에 따라 적정한 구현을 덧붙여 나가시기 바랍니다.
혹시 가이드에 모호한 부분이 있거나 잘못된 내용이 있다면 댓글 부탁드리겠습니다.🙏🏼
레퍼런스
Google Play 결제 시스템 개요 | Android Developers
Tollgate?! 완전정복 — 모바일 결제의 모든 것
'나는 이렇게 일한다 > 업무' 카테고리의 다른 글
제약 이론을 통해 시스템 개선하기 (0) | 2024.08.19 |
---|---|
인앱결제 서버 구현 _ 앱스토어 편 (2) | 2023.06.18 |
시장지표(지수/환율/원자재) 프로젝트 회고 (0) | 2022.09.04 |
저장소 패턴(Repository Pattern) 도입기 (0) | 2022.07.10 |