IAP5 로 전환하며 Pending 처리를 추가하자

박지예·2026년 7월 17일

공부2026

목록 보기
9/20

이전 블로그 링크

이전에 작업했을땐 IAP4 버전이였는데 2026년 6월 8일 이후로 IAP4 업데이트를 완전 중단한다기에 IAP5 로 회사에서 업그레이드를 진행했다.

그러면서 새로 추가된 PendingOrder 클래스로 기존 상점에서 비정상 결제 취소 시 (결제 진행 중에 게임을 강제 종료) Pending 된 물품을 결제 처리 하게 하는 작업을 추가하였다.

Before

기존 IAP4 에서는 ProcessPurchase -> 즉시 Confirm 였지만, IAP5에서는 스토어 승인 -> 서버 검증/보상 -> 수동 Confirm 인 3단계로 분리된다.


[스토어 결제 완료]
↓
OnPurchasePending() // IAP5 신규 콜백
↓
_pendingOrders에 캐시 // Confirm 전 보관
↓
OnIapPurchaseSucceded Invoke() // 외부에서 서버 검증 + 보상 처리
↓
ConfirmPendingPurchase() // 외부에서 명시적으로 호출
↓
_storeController.ConfirmPurchase(order) // 스토어에 최종 확정

주요 메서드 역할

OnPurchasePending(PendingOrder order)

  • IAP5에서 스토어 결제 완료 시 호출되는 진입점
  • 플랫폼 별 영수증 포맷팅 (AOS: 그대로/ iOS: JWS -> JSON 변환)
  • _useDeferredConfirm=true 면 _pendingOrders 에 캐시
  • OnIApPurchaseSucceeded 이벤트 발화 -> 외부(ShopManager 등)에서 서버 검증 시작
  • _useDeferredConfirm=false면 즉시 ConfirmPurchase
private void OnPurchasePending(PendingOrder order)
    {
        if (order == null)
        {
            _inProgress = false;
            _processingId = null;
            Debug.LogError("[IAP] OnPurchasePending failed: order is null");
            OnIapPurchaseFailed?.Invoke("Unknown", "PendingOrderNull");
            return;
        }

        var firstItem = order.CartOrdered?.Items()?.FirstOrDefault();
        var product = firstItem?.Product;

        if (product == null)
        {
            _inProgress = false;
            _processingId = null;
            Debug.LogError("[IAP] OnPurchasePending failed: product is null");
            OnIapPurchaseFailed?.Invoke("Unknown", "PendingOrderProductNull");
            return;
        }

        var id = product.definition.id;
        
        var internalReceipt = ""; // 우리 서버용
        // 플랫폼 공통으로 사용할 트랜잭션 ID 변수
        string finalTransactionId = product.transactionID; 
        
        // 플랫폼별 영수증 페이로드 추출 및 서버 맞춤형 포맷팅 분기
#if UNITY_ANDROID
        internalReceipt = product.receipt;
        
#elif UNITY_IOS
        string jwsToken = order.Info.Apple?.jwsRepresentation;
        
        if (string.IsNullOrEmpty(jwsToken))
        {
            jwsToken = product.receipt;
        }

        string tId = product.transactionID;
        
        if (string.IsNullOrEmpty(tId) && !string.IsNullOrEmpty(jwsToken))
        {
            tId = ExtractTransactionIdFromJWS(jwsToken);
        }

        finalTransactionId = tId;

        IosReceiptPayload payloadObj = new IosReceiptPayload
        {
            productId = id,
            transactionId = tId ?? "",
            signedTransaction = jwsToken
        };

        internalReceipt = JsonUtility.ToJson(payloadObj);
#else
        internalReceipt = product.receipt;
#endif
        
        _inProgress = false;

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

        _processingId = null;

        if (_useDeferredConfirm)
        {
            if (_pendingOrders.ContainsKey(id))
                Debug.LogWarning($"[IAP] Duplicate pending order received: {id} (already pending)");

            _pendingOrders[id] = order;
        }

        bool preventDuplicateReward =
            product.definition.type == ProductType.NonConsumable ||
            product.definition.type == ProductType.Subscription;

        bool alreadyRewarded = preventDuplicateReward && _rewardedProducts.Contains(id);

        if (!alreadyRewarded)
        {
            string safeTransactionId = string.IsNullOrEmpty(finalTransactionId) 
                ? $"UNKNOWN_TX_{System.Guid.NewGuid()}" 
                : finalTransactionId;
                
            string logKey = $"IAP_LOGGED_{safeTransactionId}";

            if (PlayerPrefs.GetInt(logKey, 0) == 0)
            {

            #if ANALYTICS
                string currency = product.metadata?.isoCurrencyCode;
                double price = 0;
                if (product.metadata != null)
                {
                    price = decimal.ToDouble(product.metadata.localizedPrice);
                }
                AnalyticsLogEventHelper.LogInAppPurchase(id, currency, price);
                
            #endif
                
                PlayerPrefs.SetInt(logKey, 1);
                PlayerPrefs.Save();
            }
            else
            {
                Debug.Log($"[IAP] Tracking logs already sent for transaction: {safeTransactionId}. Skipping duplicate logs.");
            }

            // 아이템 지급 처리는 로그 중복 여부와 무관하게 정상 진행
            OnIapPurchaseSucceeded?.Invoke(id, internalReceipt);

            if (preventDuplicateReward)
                AddRewardedProduct(id);
        }
        else
        {
            Debug.LogWarning($"[IAP] Reward already granted for: {id}");
        }

        if (!_useDeferredConfirm)
        {
            _storeController.ConfirmPurchase(order);
        }
    }

ConfirmPendingPurchase(string productId)

  • 서버 검증/보상 완료 후 외부에서 명시적으로 호출하는 함수
  • _pendingOrders 에서 꺼내서 _storeController.ConfirmPurchase(order) 호출
    // 보상 완료 후 pending 주문을 최종 Confirm
    public bool ConfirmPendingPurchase(string productId)
    {
        if (!IsReady)
        {
            Debug.LogWarning($"[IAP] ConfirmPendingPurchase failed: NotReady ({productId})");
            return false;
        }

        if (string.IsNullOrWhiteSpace(productId))
        {
            Debug.LogWarning("[IAP] ConfirmPendingPurchase failed: productId is null or empty");
            return false;
        }

        if (_pendingOrders.TryGetValue(productId, out var order) && order != null)
        {

            try
            {
                // 1. 유니티 IAP 스토어에 최종 구매 확정 처리
                _storeController.ConfirmPurchase(order);
                _pendingOrders.Remove(productId);
                Debug.Log($"[IAP] ConfirmPendingPurchase success: {productId}");

                return true;
            }
            catch (Exception ex)
            {
                Debug.LogError($"[IAP] ConfirmPendingPurchase exception: {productId} / {ex}");
                return false;
            }
        }

        Debug.LogWarning($"[IAP] ConfirmPendingPurchase failed: PendingNotFound ({productId})");
        return false;
    }

OnPurchasesFetched(Orders orders)

  • Init() 완료 후 FetchPurchases() 응답 콜백
  • 앱 재시작/네트워크 단절로 Confiorm 못 한 미완료 주문을 복원
  • orders.PendingOrders 를 순회해서 RestorePendingOrder() 호출
    private void OnPurchasesFetched(Orders orders)
    {
        if (orders == null)
        {
            Debug.Log("[IAP] Purchases fetched: null");
            return;
        }

        int confirmedCount = orders.ConfirmedOrders?.Count ?? 0;
        int pendingCount = orders.PendingOrders?.Count ?? 0;
        int deferredCount = orders.DeferredOrders?.Count ?? 0;

        Debug.Log($"[IAP] Purchases fetched. Confirmed={confirmedCount}, Pending={pendingCount}, Deferred={deferredCount}");
        
        // 앱 재시작/네트워크 단절 등으로 Confirm되지 못한 미완료 거래 복원
        if (orders.PendingOrders != null)
        {
            foreach (var pending in orders.PendingOrders)
            {
                RestorePendingOrder(pending);
            }
        }
        
    }

RestorePendingOrder(PendingOrder order)

  • 복원된 pending을 정상 경로로 재라우팅
  • 이미 _pendingOrders에 있으면 중복 발화 방지로 skip
  • NonConsumable/Scription이고 이미 보상 됐으면 Confirm만 처리하고 종료
  • 그 외엔 OnPurchasePending(order) 재호출 -> 보상 + Confirm 경로 재사용
 // FetchPurchases로 복원된 pending 주문을 정상 보상/Confirm 경로로 라우팅
 private void RestorePendingOrder(PendingOrder order)
 {
     if (order == null)
         return;

     var firstItem = order.CartOrdered?.Items()?.FirstOrDefault();
     var product = firstItem?.Product;
     if (product == null)
         return;

     var id = product.definition.id;

     // 이미 처리 중인 pending이면 중복 발화 방지
     if (_useDeferredConfirm && _pendingOrders.ContainsKey(id))
     {
         Debug.Log($"[IAP] RestorePendingOrder skip (already pending): {id}");
         return;
     }

     // 비소모성/구독은 이미 보상 지급됐다면 Confirm만 처리하고 종료
     bool preventDuplicateReward =
         product.definition.type == ProductType.NonConsumable ||
         product.definition.type == ProductType.Subscription;

     if (preventDuplicateReward && _rewardedProducts.Contains(id))
     {
         Debug.Log($"[IAP] RestorePendingOrder already rewarded, confirming only: {id}");
         try { _storeController.ConfirmPurchase(order); }
         catch (Exception ex) { Debug.LogError($"[IAP] Restore confirm exception: {id} / {ex}"); }
         return;
     }

     Debug.Log($"[IAP] RestorePendingOrder routing to OnPurchasePending: {id}");

     // 보상 지급 + 서버 검증 후 ConfirmPendingPurchase까지 기존 경로 재사용
     OnPurchasePending(order);
 }

RepublishPendingOrders()

  • ShopManager 호출 부 (ex. 상점 UI 등에서 호출)
  • _pendingOrders 에 캐시된 주문들의 OnIapPurchasesSucceeded 를 다시 발화
  • 결제는 됐지만 서버 요청이 실패했을 때(강제 게임 종료) 재시도 진입점으로 사용
    public void RepublishPendingOrders(System.Func<string, bool> filter = null)
    {
        if (_pendingOrders.Count == 0)
            return;

        // 순회 중 컬렉션 변경 가능성 대비 복사
        var snapshot = _pendingOrders.ToList();

        foreach (var kv in snapshot)
        {
            var id = kv.Key;
            var order = kv.Value;
            if (order == null)
                continue;

            // 필터 통과 못 하면 발화하지 않음 (상점 종류 불일치 등)
            if (filter != null && !filter(id))
                continue;
            
            var firstItem = order.CartOrdered?.Items()?.FirstOrDefault();
            var product = firstItem?.Product;
            if (product == null)
                continue;

            string internalReceipt;
#if UNITY_ANDROID
            internalReceipt = product.receipt;
#elif UNITY_IOS
        string jwsToken = order.Info.Apple?.jwsRepresentation;
        if (string.IsNullOrEmpty(jwsToken))
            jwsToken = product.receipt;
        string tId = product.transactionID;
        if (string.IsNullOrEmpty(tId) && !string.IsNullOrEmpty(jwsToken))
            tId = ExtractTransactionIdFromJWS(jwsToken);
        var payloadObj = new IosReceiptPayload
        {
            productId = id,
            transactionId = tId ?? "",
            signedTransaction = jwsToken
        };
        internalReceipt = JsonUtility.ToJson(payloadObj);
#else
        internalReceipt = product.receipt;
#endif

            Debug.Log($"[IAP] RepublishPendingOrders -> {id}");
            OnIapPurchaseSucceeded?.Invoke(id, internalReceipt);
        }

호출부 예시)

        // 구독 직후, 보류돼 있던 미완료 거래 복구 시도
        Managers.Instance?.IAP?.RepublishPendingOrders(
            pid => ShopManager.Instance.IsProductOfKind(pid, ShopManager.EShopKind.MidCash));
profile
게임 클라이언트 개발자

0개의 댓글