이전에 작업했을땐 IAP4 버전이였는데 2026년 6월 8일 이후로 IAP4 업데이트를 완전 중단한다기에 IAP5 로 회사에서 업그레이드를 진행했다.
그러면서 새로 추가된 PendingOrder 클래스로 기존 상점에서 비정상 결제 취소 시 (결제 진행 중에 게임을 강제 종료) Pending 된 물품을 결제 처리 하게 하는 작업을 추가하였다.
기존 IAP4 에서는 ProcessPurchase -> 즉시 Confirm 였지만, IAP5에서는 스토어 승인 -> 서버 검증/보상 -> 수동 Confirm 인 3단계로 분리된다.
[스토어 결제 완료]
↓
OnPurchasePending() // IAP5 신규 콜백
↓
_pendingOrders에 캐시 // Confirm 전 보관
↓
OnIapPurchaseSucceded Invoke() // 외부에서 서버 검증 + 보상 처리
↓
ConfirmPendingPurchase() // 외부에서 명시적으로 호출
↓
_storeController.ConfirmPurchase(order) // 스토어에 최종 확정
_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);
}
}
_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;
}
Init() 완료 후 FetchPurchases() 응답 콜백 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);
}
}
}
_pendingOrders에 있으면 중복 발화 방지로 skip 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);
}
_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));