모바일 게임을 운영하다 보면 피할 수 없는 문제가 있다.
유저가 결제를 했을 때 상품을 안전하게 지급하는 일이다.
단순히 결제 버튼을 눌렀다고 바로 아이템을 주면 안 된다.
결제가 실제로 완료되었는지 검증하고, 중복 지급을 방지하며, 네트워크 문제로 실패했을 때 재시도할 수 있는 구조가 필요하다.
실제 운영 중인 모바일 게임에서 인앱 결제 시스템을 구축한 경험을 정리한다.
1. 전체 결제 플로우
핵심은 결제 성공을 확인할 때까지 대기 상태를 유지하는 것이다.
네트워크가 끊기거나 앱이 종료되어도 결제 내역이 사라지면 안 된다.
2. 클라이언트의 영수증 보관
결제 요청부터 영수증 보관까지는 클라이언트 몫이다.
결제 시작
public void Purchase(string productId)
{
PopupLoading.ShowLoading(LOADING_KEY_PURCHASING);
m_StoreController.InitiatePurchase(product);
}영수증 로컬 저장 (Pending 상태)
Unity IAP가 결제 성공 콜백을 호출하면, 영수증을 로컬에 저장한다.
public PurchaseProcessingResult ProcessPurchase(PurchaseEventArgs args)
{
// 영수증을 로컬에 pending 상태로 저장
_purchasingProductLocal.receipt = args.purchasedProduct.receipt;
SavePendingProductListToLocal();
// 서버에 검증 요청
Purchase_Success(itemType, productId, receipt);
return PurchaseProcessingResult.Pending;
}왜 Pending 상태를 유지할까?
- 서버 검증이 끝나기 전에 앱이 종료될 수 있다
- Pending 상태로 남겨두면 앱 재시작 시 Unity IAP가 자동으로
ProcessPurchase를 다시 호출한다 - 결제했는데 아이템을 못 받는 상황을 방지한다
3. 구글 영수증 검증
영수증 검증은 서버가 담당한다.
영수증 구조
구글 영수증은 다음과 같은 구조로 전달된다:
{
"Store": "GooglePlay",
"Payload": {
"json": "{\"orderId\":\"GPA.xxxx\", \"productId\":\"gem_100\", \"purchaseToken\":\"xxx\"}",
"signature": "Base64 RSA 서명"
}
}Google Play Developer API 호출
public async Task<(GooglePurchaseValidateType, ProductPurchase?)> GetProductPurchase(
string productId,
string purchaseToken)
{
try
{
var request = _androidPublisherService.Purchases.Products.Get(
_androidPublisherService.ApplicationName,
productId,
purchaseToken);
var res = await request.ExecuteAsync();
return ((GooglePurchaseValidateType)res.PurchaseState!, res);
}
catch (Exception)
{
return (GooglePurchaseValidateType.BadReceipt, null);
}
}PurchaseState 분기 처리
| PurchaseState | 의미 | 처리 |
|---|---|---|
| 0 | 결제 완료 (Purchased) | 상품 지급 진행 |
| 1 | 결제 취소 (Canceled) | 에러 응답 |
| 2 | 결제 보류 (Pending) | 재시도 응답 |
switch (status)
{
case GooglePurchaseValidateType.Complete:
// 상품 지급 진행
break;
case GooglePurchaseValidateType.InProgress:
throw new PaymentRetryException();
default:
throw new InvalidReceiptException();
}4. 애플 영수증 검증
JWT 토큰 생성
애플은 App Store Server API를 사용하며, JWT 인증이 필요하다.
private string MakeToken()
{
var handler = new JsonWebTokenHandler();
return handler.CreateToken(new SecurityTokenDescriptor
{
Issuer = _clientBase.IssuerId,
Audience = "appstoreconnect-v1",
NotBefore = DateTime.Now,
Expires = DateTime.Now.AddSeconds(_tokenLifeSecond),
Claims = new Dictionary<string, object> { { "bid", _clientBase.BundleId } },
SigningCredentials = new SigningCredentials(
GetEcdsaSecurityKey(),
SecurityAlgorithms.EcdsaSha256)
});
}Transaction 정보 조회
public async Task<(HttpStatusCode, TransactionInfo?)> GetTransactionInfo(string transactionId)
{
httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", MakeToken());
// Production 환경에서 먼저 시도
var result = await httpClient.GetAsync(
$"https://api.storekit.itunes.apple.com/inApps/v1/transactions/{transactionId}");
// 404면 Sandbox 환경에서 재시도
if (result.StatusCode == HttpStatusCode.NotFound)
{
result = await httpClient.GetAsync(
$"https://api.storekit-sandbox.itunes.apple.com/inApps/v1/transactions/{transactionId}");
}
return (result.StatusCode, /* 파싱된 정보 */);
}예전에는 buy.itunes.apple.com/verifyReceipt를 사용했지만, 현재는 deprecated 상태다.
새로운 App Store Server API는 JWT 인증으로 보안이 강화되었고, 거래별 세분화된 조회가 가능하다.
5. 상품 지급과 중복 방지
중복 지급 방지 로직
private async Task ValidatePurchaseStateAlreadyComplete(
string orderId,
string productId)
{
var purchaseState = await _purchaseRepository.FindByOrderId(orderId);
if (purchaseState?.PurchaseProcess == PurchaseProcess.COMPLETE)
{
// 이미 지급 완료된 결제 - 에러 처리
await _slackNotifier.SendAlert("중복 결제 시도 감지", orderId);
throw new AlreadyPurchasedException();
}
}상품 지급 플로우
public async Task ProductPurchaseProcess(...)
{
// 1. 상품 정보 조회
var productInfo = await GetProductInfo(productId);
// 2. 중복 지급 체크
await ValidatePurchaseStateAlreadyComplete(orderId, productId);
// 3. 우편으로 상품 지급
await SendRewardMail(user, productInfo.Rewards);
// 4. 구매 기록 저장 (COMPLETE 상태)
await SavePurchaseRecord(orderId, productId, PurchaseProcess.COMPLETE);
}6. 실패 시나리오 처리
영수증 검증 실패
잘못된 영수증은 에러 응답으로 처리하고, 관리자에게 슬랙 알림을 보낸다.
결제 보류 (Pending) 상태
case GooglePurchaseValidateType.InProgress:
// 클라이언트에게 재시도 요청
return new PaymentResponse { ShouldRetry = true };보류 상태는 클라이언트가 일정 시간 후 재시도하도록 응답한다.
상품 지급 중 서버 오류
try
{
await ProductPurchaseProcess(...);
}
catch (Exception ex)
{
// 트랜잭션 롤백
await transaction.RollbackAsync();
// 알림 발송 (수동 처리 필요)
await _slackNotifier.SendCriticalAlert("상품 지급 실패", orderId, ex);
throw;
}지급 중 오류는 트랜잭션을 롤백하고 관리자 알림을 보낸 뒤 수동 보상 절차로 넘긴다.
실제로 지급이 실패하면 알림만으로 끝나지 않는다.
CS팀이 관리 도구에서 수동으로 보상을 지급할 수 있도록 프로세스를 같이 만들어 두었다.
분쟁에 대비해 결제 로그도 상세하게 남긴다.
영수증 원본, 검증 결과, 응답 시간까지 기록해 두면 유저 문의나 환불 분쟁이 생겼을 때 근거 자료가 된다.
7. 결제 확정
서버에서 성공 응답을 받으면 클라이언트가 결제를 확정한다.
public void PurchaseResponseResult(ResultCode resultCode, ...)
{
if (resultCode == ResultCodes.Success)
{
// 1. 스토어에 결제 확정 알림
ConfirmPendingPurchase(productId);
// 2. UI에 아이템 지급 표시
ProcessPurchase_ReceiveItem(productItemType, productId, response);
}
}
private void ConfirmPendingPurchase(string productId)
{
// 로컬 pending 목록에서 제거
_pendingProductLocalList.Remove(_purchasingProductLocal);
// Unity IAP에 확정 알림
m_StoreController.ConfirmPendingPurchase(product);
}ConfirmPendingPurchase를 호출하지 않으면 결제가 계속 pending 상태로 남아서, 앱 재시작 시 다시 처리된다.
8. 테스트 환경 구성
구글 플레이 테스트
- Google Play Console에서 Gmail 계정을 테스터로 등록
- 테스터 계정으로 기기 로그인
- 내부 테스트 트랙으로 앱 배포
- 결제 시 테스트 영수증 발급 (PurchaseType: 0)
애플 앱스토어 테스트
- App Store Connect에서 Sandbox 테스터 계정 생성
- 기기에서 기존 Apple ID 로그아웃
- 인앱결제 시 Sandbox 계정으로 로그인
- Sandbox 환경 영수증 발급
서버에서는 Production API로 먼저 시도하고, 실패 시 Sandbox API로 재시도하는 방식으로 구현했다.
이렇게 하면 별도의 환경 변수 없이 두 환경을 모두 지원할 수 있다.
마무리
결제 시스템은 돈이 오가는 곳이라 다른 기능보다 신중하게 설계해야 한다.
영수증은 스토어 공식 API로 검증하고, 지급 상태는 orderId 기준으로 관리하며, 실패했을 때 재시도와 수동 보상으로 이어지는 경로를 미리 만들어 둔다.
이 세 가지가 이 시스템의 뼈대다.