Google Play IAP 백엔드 설정
Google Play Console을 이용하여 안드로이드 앱에 결제 시스템(IAP)을 붙일 수 있다. 하지만 대부분의 서비스는 이러한 결제 정보를 따로 관리하는 백엔드 시스템을 구축해야한다. 이 작업이 왜 필요할까? 예를 들어 어떤 게임 유저가 결제를 통해 얻은 아이템이 있다고 해보자. 게임을 하다가 실수로 게임 앱을 지웠다가 다시 깔면 어떻게 될까? 따로 백엔드 서버거 없는 클라이언트 게임 앱의 경우, 결제 데이터가 남아있지 않을 것이다. 그러므로 백엔드 서버가 이러한 게임 데이터에 대한 내용을 모두 기록하고 있도록 하고, 다시 앱을 설치할 때 그 데이터를 로드하도록 해야한다. 하지만 이렇게 백엔드를 구축할 때 반드시 해주어야 하는 작업이, 어떤 클라이언트로부터 결제 내역에 대한 데이터를 받았을 때 그 결제 내역이 거짓정보인지 아닌지 확인하는 것이다. 이 작업을 인증 작업이라 하는데, 이 과정이 없다면 거짓 결제 데이터가 서버에 기록되게 될 것이다. 그러므로 클라이언트로 부터 받은 결제 정보를 백엔드 서버가 다시 구글 플레이 서버에 보내 확인하는 작업이 필요한 것이다. 그렇다면 클라이언트와 Google Play 사이에 결제 인증 내용을 어떻게 백엔드 서비스에 전달할 수 있을까? 다음과 같이 시나리오를 생각해 볼 수 있다.
(여기서 중요한 점은 구글 플레이 로그인과 관계없이 Google Play IAP를 이용할 수 있다는 점이다.)
1. 클라이언트 앱 -> 구글 플레이로 결제 요청
2. 구글 플레이 -> 클라이언트 앱으로 결제 승인
3. 클라이언트 앱 -> 백엔드 서버로 인증 요청
4. 백엔드 서버 -> 구글 플레이로 인증 요청
5. 구글 플레이 -> 백엔드 서버로 인증 승인
6. 백엔드 서버 -> 클라이언트 앱으로 인증 승인
이 포스팅은 다음 작업들이 이미 완료되었음을 가정한다.
1. Google Play Console에 클라이언트 앱 등록
2. Google Cloud Platform을 통해 클라이언트 앱 인증 등록
3. Google Play Console에 결제 시스템 세팅
4. 클라이언트 앱 -> 구글 플레이 결제 시스템 구축 (결제가 성공적으로 되어야 함)
5. 자체 백엔드 서버 구축
In App Purchase
이 라이브러리는 Node JS 인앱결제 라이브러리로 Google, Amazon, Apple 등의 여러 IAP를 편하게 사용할 수 있도록 래핑해두었다. 백엔드 서버에서는 이 라이브러리를 이용하여 Google Play에 결제 내역을 인증할 수 있다.
https://github.com/voltrue2/in-app-purchase
Google Cloud Platform 서비스 계정 등록, 키 생성
백엔드 서버가 구글 플레이의 API를 이용하도록 하기 위해서는 Google Play Console에서 추가로 세팅해주어야 하는 작업이 있다. 먼저 Google Cloud Platform의 사용자 인증 정보에서 서비스 계정을 추가해야한다. 이 서비스 계정은 백엔드 서버가 이용할 계정이다. (붉은 박스에 들어간 계정)
계정이 생성되었다면 키도 하나 생성한다. 이 키는 백엔드 서버에서 Google Play 서버에 인증요청을 보낼 때 이용된다.
Google Play Console API 엑세스 및 프로젝트 연결
다음으로 Google Play Console의 메인 페이지에서 설정 > API엑세스 메뉴로 이동한다. 이 메뉴에서 Google Cloud Platform에 게시된 프로젝트를 연결시켜주어야 한다. 다른 프로젝트가 연결되어 있다면 프로젝트 연결 해제 후, 프로젝트 검색을 통해 CardTowerDefenseTest1 프로젝트를 찾고 연결시켜준다. 그러면 아래 서비스 계정 목록에 CardTowerDefenseTest1 Google Cloud Platform에서 추가된 서비스 계정 목록이 업데이트 될 것이다.
여기까지 작업이 되었다면 이제 백엔드 서버가 직접 Google Play에 API를 보낼 수 있다.
이제 클라이언트 앱에서 구글 플레이 결제를 시도하고 받은 응답 데이터를 백엔드 서버가 받도록 작업을 해야한다. 백엔드 서버에서는 이 데이터를 통해 Google Play에 인증을 시도하고 그 결과를 보고 클라이언트에 대한 데이터를 DB에 기록하면 된다. 다음 코드는 위에서 언급한 iap를 이용한 결제 요청 코드이다.
var iap = require('in-app-purchase');
iap.config({
/* Configurations for HTTP request */
requestDefaults: { /* Please refer to the request module documentation here: https://www.npmjs.com/package/request#requestoptions-callback */ },
/* Configurations for Amazon Store */
amazonAPIVersion: 2, // tells the module to use API version 2
secret: 'abcdefghijklmnoporstuvwxyz', // this comes from Amazon
// amazonValidationHost: http://localhost:8080/RVSSandbox, // Local sandbox URL for testing amazon sandbox receipts.
/* Configurations for Apple */
appleExcludeOldTransactions: true, // if you want to exclude old transaction, set this to true. Default is false
applePassword: 'abcdefg...', // this comes from iTunes Connect (You need this to valiate subscriptions)
/* Configurations for Google Service Account validation: You can validate with just packageName, productId, and purchaseToken */
googleServiceAccount: {
clientEmail: '<client email from Google API service account JSON key file>',
privateKey: '<private key string from Google API service account JSON key file>'
},
/* Configurations for Google Play */
googlePublicKeyPath: 'path/to/public/key/directory/', // this is the path to the directory containing iap-sanbox/iap-live files
googlePublicKeyStrSandBox: 'publicKeySandboxString', // this is the google iap-sandbox public key string
googlePublicKeyStrLive: 'publicKeyLiveString', // this is the google iap-live public key string
googleAccToken: 'abcdef...', // optional, for Google Play subscriptions
googleRefToken: 'dddd...', // optional, for Google Play subscritions
googleClientID: 'aaaa', // optional, for Google Play subscriptions
googleClientSecret: 'bbbb', // optional, for Google Play subscriptions
/* Configurations for Roku */
rokuApiKey: 'aaaa...', // this comes from Roku Developer Dashboard
/* Configurations for Facebook (Payments Lite) */
facebookAppId: '112233445566778',
facebookAppSecret: 'cafebabedeadbeefabcdef0123456789',
/* Configurations all platforms */
test: true, // For Apple and Googl Play to force Sandbox validation only
verbose: true // Output debug logs to stdout stream
});
iap.setup()
.then(() => {
// iap.validate(...) automatically detects what type of receipt you are trying to validate
iap.validate(receipt).then(onSuccess).catch(onError);
})
.catch((error) => {
// error...
});
function onSuccess(validatedData) {
// validatedData: the actual content of the validated receipt
// validatedData also contains the original receipt
var options = {
ignoreCanceled: true, // Apple ONLY (for now...): purchaseData will NOT contain cancceled items
ignoreExpired: true // purchaseData will NOT contain exipired subscription items
};
// validatedData contains sandbox: true/false for Apple and Amazon
var purchaseData = iap.getPurchaseData(validatedData, options);
}
function onError(error) {
// failed to validate the receipt...
}
이 코드는 크게 config 부분과 validate 부분으로 나눌 수 있다. 먼저 config 부분에서는 인증에 필요한 기초 세팅에 대한 내용이다. 여기에서 googleServiceAccount를 채워주어야 한다.
googleServiceAccount: {
clientEmail: '<client email from Google API service account JSON key file>',
privateKey: '<private key string from Google API service account JSON key file>'
},
clientEmail은 위에 Google Cloud Platform 에 등록된 서비스 계정을 넣어주면 된다. 그리고 privateKey에는 서비스 계정에서 생성한 키를 넣어주면 된다.
다음으로 validate 부분에서는 receipt 데이터를 넘겨주어야 한다. 여기서는 유니티 클라이언트를 이용한 receipt 데이터를 보낼 것이므로 데이터 포맷은 다음과 같다. 이 데이터를 유니티 클라이언트로부터 받아야 한다.
{
Store: 'The name of the store in use, such as GooglePlay or AppleAppStore',
TransactionID: 'This transaction's unique identifier, provided by the store',
Payload: 'Varies by platform, see [Unity Receipt Documentation](https://docs.unity3d.com/Manual/UnityIAPPurchaseReceipts.html)',
Subscription: true/false // if the receipt is a subscription, then true
}
유니티 클라이언트 영수증 데이터
이제 유니티 클라이언트에서 결제 후 receipt 데이터를 넘겨주는 부분을 작업해야한다. 다음 코드는 일반적인 클라이언트 결제에 대한 코드이다.
하지만 지금 백엔드를 이용한 인증 프로세스를 구축하려면 이 코드에서 추가적인 작업이 필요하다. 다음 그림을 참고하자. (이 그림에 대한 링크는 아래에 있다.)
이 그림처럼 작업을 수행하려면 ProcessPurchase(PurchaseEventArgs args) 함수에서 Complete을 반환하지 말고 Pending을 반환하도록 해야한다. 그러면 클라이언트는 백엔드에서의 인증결과를 대기하게 되고, 인증 결과를 받고 난 후에 ConfirmPendingPurchase를 통해 프로세스를 넘길 수 있다. (ConfirmPendingPurchase를 따로 호출하지 않으면 IAP request가 쌓이는 문제가 생긴다.)
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Purchasing;
using UnityEngine.Networking;
// Placing the Purchaser class in the CompleteProject namespace allows it to interact with ScoreManager,
// one of the existing Survival Shooter scripts.
// Deriving the Purchaser class from IStoreListener enables it to receive messages from Unity Purchasing.
public class Purchaser : MonoBehaviour, IStoreListener
{
private static IStoreController m_StoreController; // The Unity Purchasing system.
private static IExtensionProvider m_StoreExtensionProvider; // The store-specific Purchasing subsystems.
// Product identifiers for all products capable of being purchased:
// "convenience" general identifiers for use with Purchasing, and their store-specific identifier
// counterparts for use with and outside of Unity Purchasing. Define store-specific identifiers
// also on each platform's publisher dashboard (iTunes Connect, Google Play Developer Console, etc.)
// General product identifiers for the consumable, non-consumable, and subscription products.
// Use these handles in the code to reference which product to purchase. Also use these values
// when defining the Product Identifiers on the store. Except, for illustration purposes, the
// kProductIDSubscription - it has custom Apple and Google identifiers. We declare their store-
// specific mapping to Unity Purchasing's AddProduct, below.
public static string kProductIDConsumable = "com.company.project.consumabletest";
public static string kProductIDNonConsumable = "com.company.project.nonconsumabletest";
public static string kProductIDSubscription = "com.company.project.subscriptiontest";
// Apple App Store-specific product identifier for the subscription product.
private static string kProductNameAppleSubscription = "com.unity3d.subscription.new";
// Google Play Store-specific product identifier subscription product.
private static string kProductNameGooglePlaySubscription = kProductIDSubscription;
void Start()
{
// If we haven't set up the Unity Purchasing reference
if (m_StoreController == null)
{
// Begin to configure our connection to Purchasing
InitializePurchasing();
}
}
public void InitializePurchasing()
{
// If we have already connected to Purchasing ...
if (IsInitialized())
{
// ... we are done here.
return;
}
// Create a builder, first passing in a suite of Unity provided stores.
var builder = ConfigurationBuilder.Instance(StandardPurchasingModule.Instance());
// Add a product to sell / restore by way of its identifier, associating the general identifier
// with its store-specific identifiers.
builder.AddProduct(kProductIDConsumable, ProductType.Consumable);
// Continue adding the non-consumable product.
builder.AddProduct(kProductIDNonConsumable, ProductType.NonConsumable);
// And finish adding the subscription product. Notice this uses store-specific IDs, illustrating
// if the Product ID was configured differently between Apple and Google stores. Also note that
// one uses the general kProductIDSubscription handle inside the game - the store-specific IDs
// must only be referenced here.
builder.AddProduct(kProductIDSubscription, ProductType.Subscription, new IDs(){
{ kProductNameAppleSubscription, AppleAppStore.Name },
{ kProductNameGooglePlaySubscription, GooglePlay.Name },
});
// Kick off the remainder of the set-up with an asynchrounous call, passing the configuration
// and this class' instance. Expect a response either in OnInitialized or OnInitializeFailed.
UnityPurchasing.Initialize(this, builder);
}
private bool IsInitialized()
{
// Only say we are initialized if both the Purchasing references are set.
return m_StoreController != null && m_StoreExtensionProvider != null;
}
public void BuyConsumable()
{
// Buy the consumable product using its general identifier. Expect a response either
// through ProcessPurchase or OnPurchaseFailed asynchronously.
BuyProductID(kProductIDConsumable);
}
public void BuyNonConsumable()
{
// Buy the non-consumable product using its general identifier. Expect a response either
// through ProcessPurchase or OnPurchaseFailed asynchronously.
BuyProductID(kProductIDNonConsumable);
}
public void BuySubscription()
{
// Buy the subscription product using its the general identifier. Expect a response either
// through ProcessPurchase or OnPurchaseFailed asynchronously.
// Notice how we use the general product identifier in spite of this ID being mapped to
// custom store-specific identifiers above.
BuyProductID(kProductIDSubscription);
}
void BuyProductID(string productId)
{
// If Purchasing has been initialized ...
if (IsInitialized())
{
// ... look up the Product reference with the general product identifier and the Purchasing
// system's products collection.
Product product = m_StoreController.products.WithID(productId);
// If the look up found a product for this device's store and that product is ready to be sold ...
if (product != null && product.availableToPurchase)
{
Debug.Log(string.Format("Purchasing product asychronously: '{0}'", product.definition.id));
// ... buy the product. Expect a response either through ProcessPurchase or OnPurchaseFailed
// asynchronously.
m_StoreController.InitiatePurchase(product);
}
// Otherwise ...
else
{
// ... report the product look-up failure situation
Debug.Log("BuyProductID: FAIL. Not purchasing product, either is not found or is not available for purchase");
}
}
// Otherwise ...
else
{
// ... report the fact Purchasing has not succeeded initializing yet. Consider waiting longer or
// retrying initiailization.
Debug.Log("BuyProductID FAIL. Not initialized.");
}
}
// Restore purchases previously made by this customer. Some platforms automatically restore purchases, like Google.
// Apple currently requires explicit purchase restoration for IAP, conditionally displaying a password prompt.
public void RestorePurchases()
{
// If Purchasing has not yet been set up ...
if (!IsInitialized())
{
// ... report the situation and stop restoring. Consider either waiting longer, or retrying initialization.
Debug.Log("RestorePurchases FAIL. Not initialized.");
return;
}
// If we are running on an Apple device ...
if (Application.platform == RuntimePlatform.IPhonePlayer ||
Application.platform == RuntimePlatform.OSXPlayer)
{
// ... begin restoring purchases
Debug.Log("RestorePurchases started ...");
// Fetch the Apple store-specific subsystem.
var apple = m_StoreExtensionProvider.GetExtension<IAppleExtensions>();
// Begin the asynchronous process of restoring purchases. Expect a confirmation response in
// the Action<bool> below, and ProcessPurchase if there are previously purchased products to restore.
apple.RestoreTransactions((result) => {
// The first phase of restoration. If no more responses are received on ProcessPurchase then
// no purchases are available to be restored.
Debug.Log("RestorePurchases continuing: " + result + ". If no further messages, no purchases available to restore.");
});
}
// Otherwise ...
else
{
// We are not running on an Apple device. No work is necessary to restore purchases.
Debug.Log("RestorePurchases FAIL. Not supported on this platform. Current = " + Application.platform);
}
}
//
// --- IStoreListener
//
public void OnInitialized(IStoreController controller, IExtensionProvider extensions)
{
// Purchasing has succeeded initializing. Collect our Purchasing references.
Debug.Log("OnInitialized: PASS");
// Overall Purchasing system, configured with products for this application.
m_StoreController = controller;
// Store specific subsystem, for accessing device-specific store features.
m_StoreExtensionProvider = extensions;
// iterate all products
//Product[] products = controller.products.all;
//foreach (var p in products)
//{
// Debug.Log("id : " + p.definition.id + " type : " + p.definition.type + " availableToPurchase : " + p.availableToPurchase + " hasReceipt : " + p.hasReceipt);
// Debug.Log("receipt : " + p.receipt);
//}
}
public void OnInitializeFailed(InitializationFailureReason error)
{
// Purchasing set-up has not succeeded. Check error for reason. Consider sharing this reason with the user.
Debug.Log("OnInitializeFailed InitializationFailureReason:" + error);
}
public PurchaseProcessingResult ProcessPurchase(PurchaseEventArgs args)
{
// A consumable product has been purchased by this user.
if (String.Equals(args.purchasedProduct.definition.id, kProductIDConsumable, StringComparison.Ordinal))
{
Debug.Log(string.Format("ProcessPurchase: PASS. Product: '{0}' Receipt: '{1}'",
args.purchasedProduct.definition.id, args.purchasedProduct.receipt));
SendRequest(args.purchasedProduct);
}
// Or ... a non-consumable product has been purchased by this user.
else if (String.Equals(args.purchasedProduct.definition.id, kProductIDNonConsumable, StringComparison.Ordinal))
{
Debug.Log(string.Format("ProcessPurchase: PASS. Product: '{0}' Receipt: '{1}'",
args.purchasedProduct.definition.id, args.purchasedProduct.receipt));
SendRequest(args.purchasedProduct);
}
// Or ... a subscription product has been purchased by this user.
else if (String.Equals(args.purchasedProduct.definition.id, kProductIDSubscription, StringComparison.Ordinal))
{
Debug.Log(string.Format("ProcessPurchase: PASS. Product: '{0}' Receipt: '{1}'",
args.purchasedProduct.definition.id, args.purchasedProduct.receipt));
SendRequest(args.purchasedProduct);
}
// Or ... an unknown product has been purchased by this user. Fill in additional products here....
else
{
Debug.Log(string.Format("ProcessPurchase: FAIL. Unrecognized product: '{0}'", args.purchasedProduct.definition.id));
}
// Return a flag indicating whether this product has completely been received, or if the application needs
// to be reminded of this purchase at next app launch. Use PurchaseProcessingResult.Pending when still
// saving purchased products to the cloud, and when that save is delayed.
return PurchaseProcessingResult.Pending;
}
public void OnPurchaseFailed(Product product, PurchaseFailureReason failureReason)
{
// A product purchase attempt did not succeed. Check failureReason for more detail. Consider sharing
// this reason with the user to guide their troubleshooting actions.
Debug.Log(string.Format("OnPurchaseFailed: FAIL. Product: '{0}', PurchaseFailureReason: {1}", product.definition.storeSpecificId, failureReason));
}
private void SendRequest(Product product)
{
StartCoroutine(CoSendRequest(product));
}
IEnumerator CoSendRequest(Product product)
{
//List<IMultipartFormSection> formData = new List<IMultipartFormSection>();
//formData.Add(new MultipartFormDataSection("field1=foo&field2=bar"));
//formData.Add(new MultipartFormFileSection("my file data", "myfile.txt"));
const string url = "https://your-backendserver-url";
UnityWebRequest www = new UnityWebRequest(url, "POST");
byte[] jsonToSend = new System.Text.UTF8Encoding().GetBytes(product.receipt);
www.uploadHandler = new UploadHandlerRaw(jsonToSend);
www.downloadHandler = (DownloadHandler)new DownloadHandlerBuffer();
www.SetRequestHeader("Content-Type", "application/json");
Debug.Log("SendRequest, request to web server.");
yield return www.SendWebRequest();
if (www.result == UnityWebRequest.Result.ConnectionError || www.result == UnityWebRequest.Result.ProtocolError)
{
Debug.Log("error : " + www.error);
}
else
{
Debug.Log("SendRequest, response from web server.");
System.Text.StringBuilder sb = new System.Text.StringBuilder();
foreach (System.Collections.Generic.KeyValuePair<string, string> dict in www.GetResponseHeaders())
{
sb.Append(dict.Key).Append(": \t[").Append(dict.Value).Append("]\n");
}
// Print Headers
Debug.Log("[Header]\n" + sb.ToString());
// Print Body
Debug.Log("[Body]\n" + www.downloadHandler.text);
Debug.Log("ConfirmPendingPurchase");
m_StoreController.ConfirmPendingPurchase(product);
}
}
}
참고
https://learn.unity.com/tutorial/unity-iap#5c7f8528edbc2a002053b46e
https://docs.unity3d.com/kr/2017.4/Manual/UnityIAPProcessingPurchases.html
시행착오
1. 구글 플레이 상품 중 non-consumable 상품을 구매한 후 환불했을 때, 이 상품을 다시 구매할 수 없는 문제가 있다.
다음은 링크는 이 이슈에 대한 내용이다.
이 내용은 구글의 환불 정책과도 연관이 있다.
https://support.google.com/googleplay/answer/2479637?hl=ko
'게임 엔진 > Unity' 카테고리의 다른 글
[Unity] C# Event 컨벤션 정리 (2) | 2023.12.20 |
---|---|
[Unity] 유니티 설계 경험 기록 (22) | 2021.11.12 |
[Google Play] 구글 플레이 게임 서비스 세팅 (2) | 2021.08.07 |
[Unity] [Example] 유니티에서 데이터 관리 방법(ScriptableObject) (0) | 2021.07.12 |
[Unity] [Example] 평면 연산 방법 (Plane) (0) | 2020.07.28 |