
InAppManager.cs는 Unity IAP(Unity In-App Purchasing)를 이용한 인앱 결제 기능을 처리하며,
결제 후 영수증 검증을 뒤끝 SDK를 통해 수행하고,
보류(Pending) 상태의 결제를 별도로 감지 및 재검증할 수 있도록 설계된 결제 전용 매니저 클래스입니다.
단순 결제 처리뿐만 아니라, 앱 재시작 시 보류 중이던 결제를 재검증하여 자동 보상 처리까지 포함합니다.
public static InAppManager I;
public void MakeSingleton()
MakeSingleton()을 통해 게임 전체에서 하나의 인스턴스로 유지됩니다.DontDestroyOnLoad()로 씬 이동 시 파괴되지 않음.public async Task<OperationResult<bool>> InitAsync()
UnityServices.InitializeAsync()로 UGS 초기화UnityPurchasing.Initialize()로 Unity IAP 초기화OperationResult로 래핑하여 반환FakeStore 사용 가능[SerializeField] private string[] productIds;
ProductType.Consumable로 등록됨public void BuyProduct(string productId, Action onSuccess = null, Action<string> onFail = null)
onSuccess, onFail)ProcessPurchase()에서 처리 흐름 시작public PurchaseProcessingResult ProcessPurchase(PurchaseEventArgs args)
VerifyAndConfirm()을 통해 영수증 검증Complete 반환Pending 상태 유지private async Task VerifyAndConfirm(PurchaseEventArgs args)
Backend.Receipt.IsValidateGooglePurchase()로 검증 요청ConfirmPendingPurchase() 호출 + 보상 처리앱 재시작 시 또는 특정 시점에 팬딩 상태의 결제를 검출 및 처리할 수 있습니다.
public List<string> GetPendingProducts()
ConfirmPendingPurchase()되지 않았고PlayerPrefs에 구매 기록이 없는 상품을 조회팬딩(Pending) 상태는 "결제는 완료되었지만, 영수증 검증이 아직 성공하지 못한 상태"입니다.
public async Task RetryVerifyAllPendingProductsAsync()
GetPendingProducts() 호출VerifyAndConfirm() 수행public void RegisterPendingCallback(string productId, Action onSuccess, Action<string> onFail)
RetryVerifyAllPendingProductsAsync() 호출 전에 콜백을 등록private async void Start()
{
var pendingList = InAppManager.I.GetPendingProducts();
foreach (var pid in pendingList)
{
InAppManager.I.RegisterPendingCallback(
pid,
onSuccess: () => PopupManager.I.Show("지급 완료", $"{pid} 상품이 정상 지급되었습니다."),
onFail: reason => PopupManager.I.Show("처리 실패", $"{pid} 상품 오류: {reason}")
);
}
await InAppManager.I.RetryVerifyAllPendingProductsAsync();
}
private class PurchaseCallbackData
{
public Action onSuccess;
public Action<string> onFail;
}
private Dictionary<string, PurchaseCallbackData> purchaseCallbacks;
private Dictionary<string, PurchaseCallbackData> pendingCallbacks;
BuyProduct) → purchaseCallbackspendingCallbacks내부에서 InvokeSuccessCallback(), InvokeFailCallback()을 통해 콜백 실행
text
복사편집
[1] BuyProduct() 호출
↓
[2] Unity IAP 구매 성공 → ProcessPurchase()
↓
[3] VerifyAndConfirm() → 뒤끝 SDK로 영수증 검증
↓
[4] 검증 성공 → ConfirmPendingPurchase()
→ 보상 지급 및 콜백
text
복사편집
[5] 앱 재시작 시
↓
[6] GetPendingProducts()
↓
[7] RetryVerifyAllPendingProductsAsync()
↓
[8] RegisterPendingCallback()으로 콜백 등록
↓
[9] 검증 및 보상 처리
이 구조는 단순한 결제 성공/실패 처리에서 한발 더 나아가
"지연된 결제"나 "네트워크 문제" 등의 예외 상황까지 안정적으로 처리할 수 있게 설계되어 있습니다.
이후 구조 확장을 원한다면 다음과 같은 개선도 고려할 수 있습니다:
PlayerPrefs 대신 보안이 강화된 저장 방식 도입using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using BackEnd;
using BackEnd.Quobject.SocketIoClientDotNet.Client;
using Newtonsoft.Json.Linq;
using Unity.Services.Core;
using UnityEngine;
using UnityEngine.Purchasing;
using UnityEngine.Purchasing.Extension;
namespace Resetia.Server
{
public class InAppManager : MonoBehaviour, IDetailedStoreListener
{
public static InAppManager I;
[Header("등록된 상품 ID 목록")]
[SerializeField] private string[] productIds;
private IStoreController storeController;
private IExtensionProvider extensionProvider;
private bool isInitialized = false;
private Action onInitSuccess;
private Action<string> onInitFail;
// ✅ 콜백 구조 개선
private class PurchaseCallbackData
{
public Action onSuccess;
public Action<string> onFail;
}
// 기존 콜백 딕셔너리: 실시간 구매용
private Dictionary<string, PurchaseCallbackData> purchaseCallbacks = new();
// 팬딩된 상품 검증용 콜백
private Dictionary<string, PurchaseCallbackData> pendingCallbacks = new();
#region 초기화
public async Task InitializeUGSAsync()
{
var options = new InitializationOptions();
await UnityServices.InitializeAsync(options);
}
public async Task<OperationResult<bool>> InitAsync()
{
try
{
var tcs = new TaskCompletionSource<bool>();
await InitializeUGSAsync();
var module = StandardPurchasingModule.Instance();
#if UNITY_EDITOR
module.useFakeStoreAlways = true;
#endif
var builder = ConfigurationBuilder.Instance(module);
foreach (string id in productIds)
{
if (!string.IsNullOrWhiteSpace(id))
builder.AddProduct(id, ProductType.Consumable);
}
DebugManager.I.LogSuccess("UGS 로딩 성공");
onInitSuccess = () => tcs.TrySetResult(true);
onInitFail = (reason) => tcs.TrySetException(new Exception(reason));
UnityPurchasing.Initialize(this, builder);
await tcs.Task;
DebugManager.I.LogSuccess("[InApp] 초기화 완료");
return OperationResult<bool>.Success(true);
}
catch (Exception ex)
{
DebugManager.I.LogError($"[InApp] 초기화 예외: {ex.Message}");
return OperationResult<bool>.Fail(ErrorCode.PurchaseFailed, ex.Message);
}
}
#endregion
#region 싱글톤
public void MakeSingleton()
{
if (I == null)
{
I = this;
DontDestroyOnLoad(gameObject);
}
else
{
Destroy(gameObject);
}
}
#endregion
#region 구매
public void BuyProduct(string productId, Action onSuccess = null, Action<string> onFail = null)
{
var product = storeController.products.WithID(productId);
if (product != null && product.availableToPurchase)
{
DebugManager.I.LogSuccess($"[InApp] 구매 시도: {productId}");
purchaseCallbacks[productId] = new PurchaseCallbackData
{
onSuccess = onSuccess,
onFail = onFail
};
storeController.InitiatePurchase(product);
}
else
{
DebugManager.I.LogWarning($"[InApp] 구매 불가: {productId}");
onFail?.Invoke("상품을 구매할 수 없습니다.");
}
}
#endregion
#region 구매 상태
public bool IsPurchased(string productId)
{
return PlayerPrefs.GetInt($"iap_{productId}", 0) == 1;
}
private void SetPurchased(string productId)
{
PlayerPrefs.SetInt($"iap_{productId}", 1);
PlayerPrefs.Save();
}
public List<string> GetPendingProducts() //Pending 상태인 것을 처리하기 위한 함수
{
var pendingList = new List<string>();
if (storeController == null) return pendingList;
foreach (var product in storeController.products.all)
{
if (product.hasReceipt && !IsPurchased(product.definition.id))
{
pendingList.Add(product.definition.id);
}
}
return pendingList;
}
#endregion
#region 유틸 콜백 함수
private void InvokeSuccessCallback(string productId)
{
if (purchaseCallbacks.TryGetValue(productId, out var cb) || pendingCallbacks.TryGetValue(productId, out cb))
{
cb?.onSuccess?.Invoke();
purchaseCallbacks.Remove(productId);
pendingCallbacks.Remove(productId);
}
}
private void InvokeFailCallback(string productId, string reason)
{
if (purchaseCallbacks.TryGetValue(productId, out var cb) || pendingCallbacks.TryGetValue(productId, out cb))
{
cb?.onFail?.Invoke(reason);
purchaseCallbacks.Remove(productId);
pendingCallbacks.Remove(productId);
}
}
public void RegisterPendingCallback(string productId, Action onSuccess = null, Action<string> onFail = null)
{
pendingCallbacks[productId] = new PurchaseCallbackData
{
onSuccess = onSuccess,
onFail = onFail
};
}
#endregion
#region Unity IAP 콜백
public void OnInitialized(IStoreController controller, IExtensionProvider extensions)
{
storeController = controller;
extensionProvider = extensions;
isInitialized = true;
onInitSuccess?.Invoke();
}
public void OnInitializeFailed(InitializationFailureReason error, string message)
{
isInitialized = false;
onInitFail?.Invoke($"IAP 초기화 실패: {error}, {message}");
}
public void OnInitializeFailed(InitializationFailureReason error)
{
isInitialized = false;
onInitFail?.Invoke($"IAP 초기화 실패: {error}");
}
private readonly Queue<string> pendingPurchasesQueue = new();
public PurchaseProcessingResult ProcessPurchase(PurchaseEventArgs args)
{
string productId = args.purchasedProduct.definition.id;
// 사전등록 보상 상품일 경우: 즉시 보상 처리
if (productId == "reservation_product")
{
DebugManager.I.LogSuccess("[IAP] 사전등록 보상 상품 감지");
if (!IsPurchased(productId))
{
// 실제 보상 지급 함수 호출 필요
// RewardManager.GivePreRegisterReward();
SetPurchased(productId);
}
storeController.ConfirmPendingPurchase(args.purchasedProduct);
return PurchaseProcessingResult.Complete;
}
// 일반 상품: 서버 검증 후 수동 확정 방식으로 처리
_ = VerifyAndConfirm(args);
return PurchaseProcessingResult.Pending;
}
#region 검증 및 보상 처리
private async Task VerifyAndConfirm(PurchaseEventArgs args)
{
string productId = args.purchasedProduct.definition.id;
try
{
var root = JObject.Parse(args.purchasedProduct.receipt);
var payloadStr = root["Payload"]?.ToString();
if (string.IsNullOrEmpty(payloadStr))
{
DebugManager.I.LogWarning("[IAP] Payload 없음");
InvokeFailCallback(productId, "Payload 정보가 없습니다.");
return;
}
// Unity Editor 테스트 시 즉시 성공 처리
if (payloadStr == "ThisIsFakeReceiptData")
{
DebugManager.I.LogSuccess("[IAP] 테스트 영수증 감지");
SetPurchased(productId);
storeController.ConfirmPendingPurchase(args.purchasedProduct);
InvokeSuccessCallback(productId);
return;
}
var payload = JObject.Parse(payloadStr);
var jsonStr = payload["json"]?.ToString();
var json = JObject.Parse(jsonStr);
string token = json["purchaseToken"]?.ToString();
string parsedProductId = json["productId"]?.ToString();
var bro = await Task.Run(() =>
Backend.Receipt.IsValidateGooglePurchase(parsedProductId, token, "검증", false));
if (bro.IsSuccess())
{
// 검증 성공 시 즉시 보상 처리
DebugManager.I.LogSuccess("[IAP] 영수증 검증 성공");
SetPurchased(productId);
storeController.ConfirmPendingPurchase(args.purchasedProduct);
InvokeSuccessCallback(productId);
ServerManager.I.InsertPurchaseLog(productId, "Success", "영수증 검증 성공 및 상품 지급 완료");
}
else
{
// 검증 실패 또는 네트워크 불안정 등으로 처리 불가
DebugManager.I.LogWarning($"[IAP] 검증 실패 또는 지연: {bro.GetMessage()}");
// 이후 재시도나 메인씬 진입 시 처리하기 위해 큐에 보관
if (!pendingPurchasesQueue.Contains(productId))
pendingPurchasesQueue.Enqueue(productId);
InvokeFailCallback(productId, $"영수증 검증 실패: {bro.GetMessage()}");
ServerManager.I.InsertPurchaseLog(productId, "fail", $"영수증 검증 실패: {bro.GetMessage()}");
}
}
catch (Exception ex)
{
DebugManager.I.LogError($"[IAP] 예외 발생: {ex.Message}");
InvokeFailCallback(productId, $"예외 발생: {ex.Message}");
ServerManager.I.InsertPurchaseLog(productId, "fail", $"영수증 검증 실패: {ex.Message}");
// 예외 발생 시에도 큐에 넣어 재시도 기회 보존
if (!pendingPurchasesQueue.Contains(productId))
pendingPurchasesQueue.Enqueue(productId);
}
}
public async Task VerifyAndConfirm(Product product)
{
string productId = product.definition.id;
try
{
var root = JObject.Parse(product.receipt);
var payloadStr = root["Payload"]?.ToString();
if (string.IsNullOrEmpty(payloadStr))
{
DebugManager.I.LogWarning("[IAP] Payload 없음");
InvokeFailCallback(productId, "Payload 정보가 없습니다.");
return;
}
// Unity Editor 테스트
if (payloadStr == "ThisIsFakeReceiptData")
{
DebugManager.I.LogSuccess("[IAP] 테스트 영수증 감지");
SetPurchased(productId);
storeController.ConfirmPendingPurchase(product);
InvokeSuccessCallback(productId);
return;
}
var payload = JObject.Parse(payloadStr);
var jsonStr = payload["json"]?.ToString();
var json = JObject.Parse(jsonStr);
string token = json["purchaseToken"]?.ToString();
string parsedProductId = json["productId"]?.ToString();
var bro = await Task.Run(() =>
Backend.Receipt.IsValidateGooglePurchase(parsedProductId, token, "검증", false));
if (bro.IsSuccess())
{
DebugManager.I.LogSuccess("[IAP] 영수증 검증 성공");
SetPurchased(productId);
ServerManager.I.InsertPurchaseLog(productId, "Success", "영수증 검증 성공 및 상품 지급 완료");
storeController.ConfirmPendingPurchase(product);
InvokeSuccessCallback(productId);
}
else
{
DebugManager.I.LogError($"[IAP] 검증 실패: {bro.GetMessage()}");
ServerManager.I.InsertPurchaseLog(productId, "fail", $"영수증 검증 실패: {bro.GetMessage()}");
InvokeFailCallback(productId, $"영수증 검증 실패: {bro.GetMessage()}");
}
}
catch (Exception ex)
{
DebugManager.I.LogError($"[IAP] 예외: {ex.Message}");
ServerManager.I.InsertPurchaseLog(productId, "fail", $"영수증 검증 실패: {ex.Message}");
InvokeFailCallback(productId, $"예외 발생: {ex.Message}");
}
}
public Product GetProductById(string productId)
{
if (storeController == null) return null;
return storeController.products.WithID(productId);
}
#endregion
#region 보류 큐 처리
public async Task RetryVerifyAllPendingProductsAsync()
{
if (storeController == null) return;
foreach (var product in storeController.products.all)
{
if (product.hasReceipt && !IsPurchased(product.definition.id))
{
DebugManager.I.LogWarning($"[IAP] 팬딩 상품 재검증 시도: {product.definition.id}");
await VerifyAndConfirm(product);
}
}
}
#endregion
public void OnPurchaseFailed(Product product, PurchaseFailureDescription failureDescription)
{
DebugManager.I.LogWarning($"[InApp] 구매 실패: {product.definition.id}, 사유: {failureDescription.message}");
InvokeFailCallback(product.definition.id, failureDescription.message);
}
public void OnPurchaseFailed(Product product, PurchaseFailureReason failureReason)
{
DebugManager.I.LogWarning($"[InApp] 구매 실패: {product.definition.id}, 사유: {failureReason}");
InvokeFailCallback(product.definition.id, failureReason.ToString());
}
#endregion
}
}
벨로그 포스팅 이거 또 gpt 돌리신 것 같네요?