[인앱 결제] 구글/애플 영수증 검증부터 상품 지급까지의 시스템 구축
모바일 게임을 운영하다 보면 피할 수 없는 문제가 있다. "유저가 결제를 했을 때 상품을 안전하게 지급할 수 있을까?"
단순히 결제 버튼을 눌렀다고 바로 아이템을 주면 안 된다. 결제가 실제로 완료되었는지 검증하고, 중복 지급을 방지하며, 네트워크 문제로 실패했을 때 재시도할 수 있는 구조가 필요하다.
실제 운영 중인 모바일 게임의 인앱 결제 시스템 구축 경험을 정리한다.
1. 전체 결제 플로우
sequenceDiagram
participant Client
participant Server
participant Store as Google/Apple Store
Client->>Store: 1. 결제 요청
Store-->>Client: 2. 영수증 발급
Client->>Server: 3. 영수증 검증 요청
Server->>Store: 4. 영수증 유효성 확인
Store-->>Server: 5. 검증 결과
Server->>Server: 6. 상품 지급 + DB 기록
Server-->>Client: 7. 지급 완료 응답
Client->>Client: 8. 결제 확정 (pending 해제)핵심은 "결제 성공을 확인할 때까지 대기 상태를 유지"하는 것이다. 네트워크가 끊기거나 앱이 종료되어도 결제 내역이 사라지면 안 된다.
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. 실패 시나리오 처리
시나리오 1: 영수증 검증 실패
sequenceDiagram
Client->>Server: 영수증 검증 요청
Server->>Google/Apple: API 호출
Google/Apple-->>Server: 잘못된 영수증
Server-->>Client: InvalidReceipt 에러
Note over Server: Slack 알림 발송처리 방법: 에러 응답 + 관리자 슬랙 알림
시나리오 2: 결제 보류 (Pending) 상태
case GooglePurchaseValidateType.InProgress:
// 클라이언트에게 재시도 요청
return new PaymentResponse { ShouldRetry = true };처리 방법: 클라이언트가 일정 시간 후 재시도
시나리오 3: 상품 지급 중 서버 오류
try
{
await ProductPurchaseProcess(...);
}
catch (Exception ex)
{
// 트랜잭션 롤백
await transaction.RollbackAsync();
// 알림 발송 (수동 처리 필요)
await _slackNotifier.SendCriticalAlert("상품 지급 실패", orderId, ex);
throw;
}처리 방법: 트랜잭션 롤백 + 관리자 알림 → 수동 보상 처리
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로 재시도하는 방식으로 구현했다. 이렇게 하면 별도의 환경 변수 없이 두 환경을 모두 지원할 수 있다.
9. 얻은 것
설계 원칙
-
Pending 상태를 활용한다
- 결제 성공 확인 전까지는 확정하지 않는다
- 앱 재시작 시 자동 재처리가 가능하도록 설계
-
중복 지급을 원천 차단한다
- orderId로 지급 완료 여부를 반드시 체크
- DB 레코드로 상태 관리
-
실패 시나리오를 명확히 정의한다
- 각 실패 케이스별 대응 방안 수립
- 관리자 알림으로 빠른 대응
운영 노하우
-
슬랙 알림 연동
- 잘못된 영수증, 중복 결제 시도 등 즉시 알림
- 장애 대응 시간 단축
-
결제 로그 상세 기록
- 영수증 원본, 검증 결과, 응답 시간 등
- 분쟁 발생 시 증거 자료로 활용
-
수동 보상 프로세스
- 자동 지급 실패 시 CS팀이 수동 보상 가능하도록
- 관리 도구 연동
마무리
결제 시스템은 "돈이 오가는 곳"이기 때문에 다른 기능보다 더 신중하게 설계해야 한다. 핵심은:
- 영수증 검증: 구글/애플 공식 API로 반드시 검증
- 중복 방지: orderId 기반 상태 관리
- 실패 대응: 재시도 + 알림 + 수동 보상
완벽한 결제 시스템은 없다. 하지만 방어적으로 설계하고, 모니터링을 촘촘히 구성하면 대부분의 문제에 빠르게 대응할 수 있다.
Loading comments...