Unity IAP Manager

박지예·2026년 4월 15일

공부2026

목록 보기
2/20
  • Init 함수
 public void Init(IReadOnlyList<IapProductDef> products)
 {
     if (IsReady)
     {
         Debug.Log("[IAP] Already initialized.");
         return;
     }

     if (products == null || products.Count == 0)
     {
         Debug.LogError("[IAP] Init failed: shopItems is null");
         return;
     }

     var module = StandardPurchasingModule.Instance();
     var builder = ConfigurationBuilder.Instance(module);

     int added = 0;

     for (int i = 0; i < products.Count; i++)
     {
         var p = products[i];

         // 핵심 ID는 ProductId
         var id = p.ProductId;
         if (string.IsNullOrWhiteSpace(id))
             continue;

         // 중복 등록 방지(클래스 필드 _registered가 있으면 그걸 써도 됨)
         if (_registered.Contains(id))
             continue;

         // Store별 ID 매핑(기존 프로젝트 방식)
         builder.AddProduct(id, p.Type, new IDs
         {
             { string.IsNullOrWhiteSpace(p.StoreIdGooglePlay) ? id : p.StoreIdGooglePlay, GooglePlay.Name },
             { string.IsNullOrWhiteSpace(p.StoreIdApple)     ? id : p.StoreIdApple,     AppleAppStore.Name },
         });

         _registered.Add(id);
         added++;
     }

     if (added <= 0)
     {
         Debug.LogWarning("[IAP] No IAP products to register. (all ProductId empty or duplicated)");
         return;
     }

     Debug.Log($"[IAP] Initializing... Products: {added}");
     UnityPurchasing.Initialize(this, builder);
 }

  • IStoreController

unity IAP 시스템의 중심적인 컨트롤러 역할을 한다.

사용자의 인앱구매를 관리하고, 실제로 구매를 요청하고 처리하는 등의 기능을 제공한다.

    private IStoreController _storeController;
    

  • IDetailedStoreListener ⇒ 구글 결제를 가능하게 해주는 인터페이스

https://docs.unity.cn/Packages/com.unity.purchasing@4.12//api/UnityEngine.Purchasing.IStoreListener.html#UnityEngine_Purchasing_IStoreListener_ProcessPurchase_UnityEngine_Purchasing_PurchaseEventArgs_

구현될 내부 메서드들

IStoreListener.OnInitializeFailed(InitializationFailureReason)

IStoreListener.OnInitializeFailed(InitializationFailureReason, string) ⇒ Init 실패

IStoreListener.ProcessPurchase(PurchaseEventArgs) ⇒ 구매 성공시

IStoreListener.OnPurchaseFailed(Product, PurchaseFailureReason) ⇒ 구매 실패시

IStoreListener.OnInitialized(IStoreController, IExtensionProvider) ⇒ Init 완료


1. OnInitializeFailed(InitializationFailureReason)

IAP Init 에 실패했을 때 호출되는 콜백

Debug.LogError 실행


2. StoreListener.ProcessPurchase(PurchaseEventArgs)

구매가 성공했을 때 호출하는 콜백

    public PurchaseProcessingResult ProcessPurchase(PurchaseEventArgs e)
    {
        var product = e.purchasedProduct; // add
        var id = e.purchasedProduct.definition.id;
        var receipt = e.purchasedProduct.receipt;

        // 스토어 승인 시점이니...
        _inProgress = false;

        if (!string.IsNullOrEmpty(_processingId) && id != _processingId)
            Debug.LogWarning($"[IAP] Mismatch processing={_processingId} purchased={id}");

        _processingId = null;

        // Deferred Confirm이면 pending 저장
        // Deferred Confirm 모드면: ConfirmPendingPurchase를 위해 pending 저장
        if (_useDeferredConfirm)
        {
            if (_pendingProducts.ContainsKey(id))
            {
                // 이미 pending인데 또 들어온 경우: 보통 이전에 Confirm을 못한 상황
                Debug.LogWarning($"[IAP] Duplicate pending purchase received: {id} (already pending)");
            }
//            _pendingProducts[id] = e.purchasedProduct;
            _pendingProducts[id] = product;

        }

        OnIapPurchaseSucceeded?.Invoke(id, receipt);

        return _useDeferredConfirm
            ? PurchaseProcessingResult.Pending
            : PurchaseProcessingResult.Complete;
    }

위 코드에서는 productID 를 padding 하여 추가로 중복 결제 예외사항을 처리하고 있다.

  • OnIapPurchaseSucceeded Action 은 ?
private void HandleIapSuccess(string productId, string receipt)
{
    PassItemData item = null;

    // 1) 일반 진행 중 구매
    if (!string.IsNullOrEmpty(_pendingIapProductId) &&
        string.Equals(productId, _pendingIapProductId, StringComparison.Ordinal))
    {
        item = _pendingItem;
    }
    else
    {
        // 2) 앱 재실행 후 복구 구매
        item = FindPassItemByProductId(productId);

        if (item == null)
        {
            Debug.LogWarning($"[PassManager] Recovered IAP but item not found - productId:{productId}");
            return;
        }

        Debug.Log($"[PassManager] Recovered pending IAP - productId:{productId}");
    }

    Debug.Log(
        $"[PassManager] HandleIapSuccess - productId:{productId}, hasReceipt:{!string.IsNullOrEmpty(receipt)}, receiptLength:{(string.IsNullOrEmpty(receipt) ? 0 : receipt.Length)}");

    StartCoroutine(ProcessIapPurchaseAfterStoreSuccess(item, productId, receipt));
}

앞서 사용한 pedingId 여부로 정상 구매 process인지, 앱 재실행시 구매 복구 process 인지 판단 할 수 있다.


3. IStoreListener.OnPurchaseFailed(Product, PurchaseFailureReason)

구매 실패 했을 때 .( ex. 앱 강제 종료)

    // IDetailedStoreListener에 의해 요구되는 콜백들(이름 충돌 방지 위해 이벤트명은 OnIapPurchaseFailed 사용)
    public void OnPurchaseFailed(Product product, PurchaseFailureDescription failureDescription)
    {
        _inProgress = false;
        _processingId = null;

        var id = product?.definition?.id ?? "Unknown";
        var reason = $"{failureDescription.reason}:{failureDescription.message}";
        
        OnIapPurchaseFailed?.Invoke(id, $"{failureDescription.reason}:{failureDescription.message}");
    }
  • OnIapPurchaseFailed 는 각 Shop or 결제 UI 에서 구독한다

4. IStoreListener.OnInitialized(IStoreController, IExtensionProvider)

    public void OnInitialized(IStoreController controller, IExtensionProvider extensions)
    {
        _storeController = controller;
        _extensionProvider = extensions;

        OnInitializedSuccess?.Invoke();
        Debug.Log("[IAP] Initialized");
    }

storeController 와 IExtensionProvider 형 함수 (특정 스토어 유형과 연결 가능. like apple. not android)를 셋팅해주면 된다.


5. IStoreListener.OnInitializeFailed(InitializationFailureReason, string)

    public void OnInitializeFailed(InitializationFailureReason error)
    {
        var msg = $"InitFailed:{error}";
        OnInitializedFailed?.Invoke(msg);
        Debug.LogError($"[IAP] Initialize failed: {error}");

    }

    public void OnInitializeFailed(InitializationFailureReason error, string message)
    {
        var msg = $"InitFailed:{error}/{message}";
        OnInitializedFailed?.Invoke(msg);
        Debug.LogError("[IAP] " + msg);
    }

OnInitializedFailed 시에 Debug.Log


6. IStoreListener.ProcessPurchase(PurchaseEventArgs)

구매 시도

    public PurchaseProcessingResult ProcessPurchase(PurchaseEventArgs e)
    {
        var product = e.purchasedProduct; // add
        var id = e.purchasedProduct.definition.id;
        var receipt = e.purchasedProduct.receipt;

        // 스토어 승인 시점이니...
        _inProgress = false;

        if (!string.IsNullOrEmpty(_processingId) && id != _processingId)
            Debug.LogWarning($"[IAP] Mismatch processing={_processingId} purchased={id}");

        _processingId = null;

        // Deferred Confirm이면 pending 저장
        // Deferred Confirm 모드면: ConfirmPendingPurchase를 위해 pending 저장
        if (_useDeferredConfirm)
        {
            if (_pendingProducts.ContainsKey(id))
            {
                // 이미 pending인데 또 들어온 경우: 보통 이전에 Confirm을 못한 상황
                Debug.LogWarning($"[IAP] Duplicate pending purchase received: {id} (already pending)");
            }
//            _pendingProducts[id] = e.purchasedProduct;
            _pendingProducts[id] = product;

        }

        OnIapPurchaseSucceeded?.Invoke(id, receipt);

        return _useDeferredConfirm
            ? PurchaseProcessingResult.Pending
            : PurchaseProcessingResult.Complete;
    }

앞서 말한 pendingID 로 종료됐던 결제가 다시 진행중인지를 체크.

OnIapPurchaseSucceeded 에서는 다음과 같은 함수를 구독한다.

    private void HandleIapSuccess(string productId, string receipt)
    {
        ProductShopData item = null;

        // 1) 일반 진행 중 구매
        if (!string.IsNullOrEmpty(_pendingIapProductId) &&
            string.Equals(productId, _pendingIapProductId, StringComparison.Ordinal))
        {
            item = _pendingItem;
        }
        else
        {
            // 2) 앱 재실행 후 복구 구매
            item = FindShopItemByProductId(productId);

            if (item == null)
            {
                Debug.LogWarning($"[ShopManager] Recovered IAP but item not found - productId:{productId}");
                return;
            }

            // 복구 구매도 현재 처리 중인 구매로 다시 세팅
            _pendingItem = item;
            _pendingIapProductId = productId;
            
            Debug.Log($"[ShopManager] Recovered pending IAP - productId:{productId}");
        }

        Debug.Log(
            $"[ShopManager] HandleIapSuccess - productId:{productId}, hasReceipt:{!string.IsNullOrEmpty(receipt)}, receiptLength:{(string.IsNullOrEmpty(receipt) ? 0 : receipt.Length)}");

        ProcessIapPurchaseAfterStoreSuccess(item, productId, receipt);
    }

pending 된 ID 를 기준으로, 위 구매가 일반 진행 중 구매인지, 강제 종료후 일어난 복구 구매인지를 체크할 수 있다.

복구 구매시에는

    private ProductShopData FindShopItemByProductId(string productId)
    {
        if (string.IsNullOrEmpty(productId))
            return null;

        if (_iapItemMap.TryGetValue(productId, out var item))
            return item;

        Debug.LogWarning($"[ShopManager] FindShopItemByProductId failed - productId:{productId}");
        return null;
    }

위 함수로 매핑된 product item을 찾아올 수 있다.


7. void ProcessIapPurchaseAfterStoreSuccess(ProductShopData item, string productId, string receipt)

private void ProcessIapPurchaseAfterStoreSuccess(ProductShopData item, string productId, string receipt)
{
    if (item == null)
    {
        FailPurchase(null, ShopFailReason.Unknown, "PendingItemNull", true);
        return;
    }

#if UNITY_IOS
    string shop = "apple";
#else
    string shop = "google";
#endif

    string goodsId = productId; // 서버 규칙 확인 필요 [google / apple]

    Debug.Log(
        $"[ShopManager] IAP server request - goodsId:{goodsId}, shop:{shop}, hasReceipt:{!string.IsNullOrEmpty(receipt)}, receiptLength:{(string.IsNullOrEmpty(receipt) ? 0 : receipt.Length)}");

    OnPurchaseAction.Invoke(goodsId,
        receipt,
        shop, response =>
        {
            Debug.Log(response);
            Debug.Log($"[ShopManager] IAP purchase completed - productId:{productId}");
            CompletePurchase(item, true);
        });
}

OnPurchaseAction은 각 상점 UI 에서 구독한다.

각 기능에 맞춰( pass 구매, 상점 물품 구매 등등..) 회사 내부 서버와 연결한다.

위 requestBody 양식은 회사 내부 서버 양식.

8. void CompletePurchase(ProductShopData item, bool clearPending = false)

private void CompletePurchase(ProductShopData item, bool clearPending = false)
{
    HidePurchaseLoading();

    if (clearPending)
        ClearPendingIap();

    OnPurchaseCompleted?.Invoke(item);
}

기존에 pending 해놨던걸 clear 하고, OnPurchaseCompleted action을 호출한다. 위 action도 상점 UI 에서 구독한다.

보통 UI Refresh.


마무리 및 수정사항

IAP Manager의 전반적인 결제 흐름을 정리해보았다.

기존에는 ProcessIapPurchaseAfterStoreSuccess() 호출 시에 상점 통신 API 하나만 불렀는데, Pass 결제를 추가하면서 외부에서 Action을 구독하는 형태로 변환하였다. (7번 참조)

잘 사용하는 중 👍🏻

profile
게임 클라이언트 개발자

0개의 댓글