[Unity6] InAppManager

a-a·2025년 7월 31일

알쓸신잡

목록 보기
26/26

1. 개요

InAppManager.cs는 Unity IAP(Unity In-App Purchasing)를 이용한 인앱 결제 기능을 처리하며,

결제 후 영수증 검증을 뒤끝 SDK를 통해 수행하고,

보류(Pending) 상태의 결제를 별도로 감지 및 재검증할 수 있도록 설계된 결제 전용 매니저 클래스입니다.

단순 결제 처리뿐만 아니라, 앱 재시작 시 보류 중이던 결제를 재검증하여 자동 보상 처리까지 포함합니다.


2. 주요 구성

2.1 싱글톤 초기화

public static InAppManager I;
public void MakeSingleton()
  • MakeSingleton()을 통해 게임 전체에서 하나의 인스턴스로 유지됩니다.
  • DontDestroyOnLoad()로 씬 이동 시 파괴되지 않음.

2.2 초기화 흐름

public async Task<OperationResult<bool>> InitAsync()
  • UnityServices.InitializeAsync()로 UGS 초기화
  • UnityPurchasing.Initialize()로 Unity IAP 초기화
  • 결과를 OperationResult로 래핑하여 반환
  • 유니티 에디터에서는 FakeStore 사용 가능

2.3 상품 등록

[SerializeField] private string[] productIds;
  • Unity IAP 상품 ID 목록을 인스펙터에서 설정
  • 모두 ProductType.Consumable로 등록됨

2.4 구매 함수

public void BuyProduct(string productId, Action onSuccess = null, Action<string> onFail = null)
  • 사용자가 상품을 클릭하면 호출
  • 결제 시도 및 콜백 등록 (onSuccess, onFail)
  • 결제 완료 시 ProcessPurchase()에서 처리 흐름 시작

2.5 결제 처리

ProcessPurchase()

public PurchaseProcessingResult ProcessPurchase(PurchaseEventArgs args)
  • 일반 상품: VerifyAndConfirm()을 통해 영수증 검증
  • 사전예약 상품: 별도 로직으로 보상 후 Complete 반환
  • 그 외는 Pending 상태 유지

VerifyAndConfirm()

private async Task VerifyAndConfirm(PurchaseEventArgs args)
  • 영수증 JSON 파싱
  • Backend.Receipt.IsValidateGooglePurchase()로 검증 요청
  • 성공 시 ConfirmPendingPurchase() 호출 + 보상 처리
  • 실패 시 사용자 알림

3. 보류 결제 처리 (Pending Purchase Recovery)

앱 재시작 시 또는 특정 시점에 팬딩 상태의 결제를 검출 및 처리할 수 있습니다.

3.1 팬딩 상품 조회

public List<string> GetPendingProducts()
  • 아직 ConfirmPendingPurchase()되지 않았고
  • PlayerPrefs에 구매 기록이 없는 상품을 조회

3.2 팬딩 상품 재검증

팬딩(Pending) 상태는 "결제는 완료되었지만, 영수증 검증이 아직 성공하지 못한 상태"입니다.

public async Task RetryVerifyAllPendingProductsAsync()
  • 내부에서 GetPendingProducts() 호출
  • 각 상품에 대해 VerifyAndConfirm() 수행

3.3 팬딩 콜백 등록

public void RegisterPendingCallback(string productId, Action onSuccess, Action<string> onFail)
  • RetryVerifyAllPendingProductsAsync() 호출 전에 콜백을 등록
  • 결과에 따라 팝업, 보상 등 처리 가능

4. 예제: 메인 씬에서 팬딩 상품 처리

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();
}

5. 콜백 구조 설명

private class PurchaseCallbackData
{
    public Action onSuccess;
    public Action<string> onFail;
}

private Dictionary<string, PurchaseCallbackData> purchaseCallbacks;
private Dictionary<string, PurchaseCallbackData> pendingCallbacks;
  • 실시간 구매(BuyProduct) → purchaseCallbacks
  • 보류된 결제 재검증 → pendingCallbacks

내부에서 InvokeSuccessCallback(), InvokeFailCallback()을 통해 콜백 실행


6. 결제 처리 흐름 요약

text
복사편집
[1] BuyProduct() 호출
    ↓
[2] Unity IAP 구매 성공 → ProcessPurchase()
    ↓
[3] VerifyAndConfirm() → 뒤끝 SDK로 영수증 검증
    ↓
[4] 검증 성공 → ConfirmPendingPurchase()
           → 보상 지급 및 콜백
text
복사편집
[5] 앱 재시작 시
    ↓
[6] GetPendingProducts()
    ↓
[7] RetryVerifyAllPendingProductsAsync()
    ↓
[8] RegisterPendingCallback()으로 콜백 등록
    ↓
[9] 검증 및 보상 처리

7. 마무리

이 구조는 단순한 결제 성공/실패 처리에서 한발 더 나아가

"지연된 결제"나 "네트워크 문제" 등의 예외 상황까지 안정적으로 처리할 수 있게 설계되어 있습니다.

이후 구조 확장을 원한다면 다음과 같은 개선도 고려할 수 있습니다:

  • PlayerPrefs 대신 보안이 강화된 저장 방식 도입
  • 서버에서 지급 여부 확인 후 보상 동기화
  • 비소모성(Non-Consumable) 및 구독(Subscription) 확장

8. 전체 코드

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
    }
}
profile
"게임 개발자가 되고 싶어요."를 이뤄버린 주니어 0년차

10개의 댓글

comment-user-thumbnail
2025년 8월 3일

벨로그 포스팅 이거 또 gpt 돌리신 것 같네요?

답글 달기
comment-user-thumbnail
2025년 8월 3일

Time is the coin of your life. It is the only coin you have, and only you can determine how it will be spent. Be careful lest you let other people spend it for you.

답글 달기
comment-user-thumbnail
2025년 8월 3일

여론 조작 하시면 안됩니다

답글 달기
comment-user-thumbnail
2025년 9월 10일

이 사람 죽었나요?

답글 달기
comment-user-thumbnail
2025년 9월 10일

네버랜드 갔나요?

2개의 답글
comment-user-thumbnail
2025년 11월 9일

아아, 이 서늘하고도 묵직한 감각.
3개월만이구만.

답글 달기
comment-user-thumbnail
2025년 11월 9일

"게임 개발자가 되고 싶어요."를 이뤄버린 주니어 0년차" 흐음 머릿말 인사가 맘에 안드네요...
예전 머릿말이 더 아련하고 있어 보였는디...

1개의 답글