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);
}
unity IAP 시스템의 중심적인 컨트롤러 역할을 한다.
사용자의 인앱구매를 관리하고, 실제로 구매를 요청하고 처리하는 등의 기능을 제공한다.
private IStoreController _storeController;
구현될 내부 메서드들
IStoreListener.OnInitializeFailed(InitializationFailureReason)
IStoreListener.OnInitializeFailed(InitializationFailureReason, string) ⇒ Init 실패
IStoreListener.ProcessPurchase(PurchaseEventArgs) ⇒ 구매 성공시
IStoreListener.OnPurchaseFailed(Product, PurchaseFailureReason) ⇒ 구매 실패시
IStoreListener.OnInitialized(IStoreController, IExtensionProvider) ⇒ Init 완료
IAP Init 에 실패했을 때 호출되는 콜백
Debug.LogError 실행
구매가 성공했을 때 호출하는 콜백
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 하여 추가로 중복 결제 예외사항을 처리하고 있다.
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 인지 판단 할 수 있다.
구매 실패 했을 때 .( 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}");
}
public void OnInitialized(IStoreController controller, IExtensionProvider extensions)
{
_storeController = controller;
_extensionProvider = extensions;
OnInitializedSuccess?.Invoke();
Debug.Log("[IAP] Initialized");
}
storeController 와 IExtensionProvider 형 함수 (특정 스토어 유형과 연결 가능. like apple. not android)를 셋팅해주면 된다.
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
구매 시도
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을 찾아올 수 있다.
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 양식은 회사 내부 서버 양식.
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번 참조)
잘 사용하는 중 👍🏻