인앱 결제 샌드박스 네트워크 단절 테스트: 롤백·보상 지급 무결성 자동화
인앱 결제 샌드박스에서 검증·지급 중 네트워크가 끊길 때 중복 지급과 미지급을 막는 상태 머신, 멱등 원장, 롤백 규칙을 설명합니다. Unity 클라이언트와 서버의 장애 주입 지점, 재시도·보상 검증을 자동화하는 실무 체크리스트까지 단계별로 정리합니다.

핵심 요약
인앱 결제 샌드박스에서 네트워크 단절을 테스트할 때는 타임아웃을 단순 실패로 처리하지 말고 거래 상태를 Pending 또는 Unknown으로 보존해야 합니다. 서버는 transactionId를 멱등 키로 사용하고 지급 원장과 거래 상태를 하나의 트랜잭션으로 기록해야 중복 지급과 미지급을 동시에 막을 수 있습니다.
자동 테스트의 핵심은 검증 요청 전, 검증 성공 후, 지급 원장 커밋 후, 응답 반환 전처럼 경계별로 장애를 주입한 뒤 다음 불변식을 확인하는 것입니다.
네트워크 단절은 왜 인앱 결제 중복 지급을 만드는가?
인앱 결제 처리는 보통 다음 순서로 진행됩니다.
- 클라이언트가 결제 영수증과
transactionId를 서버에 전송한다. - 서버가 스토어 검증 API로 영수증을 검증한다.
- 서버가 재화나 아이템을 지급하고 거래 상태를 저장한다.
- 클라이언트에 성공 응답을 반환한다.
문제는 각 단계 사이의 네트워크 단절입니다. 서버가 지급을 완료했지만 응답이 클라이언트에 도착하지 않으면 클라이언트는 결제가 실패했다고 판단할 수 있습니다. 이후 같은 영수증을 다시 보내면 서버가 매번 지급할 경우 중복 보상이 발생합니다.
반대로 지급 전에 서버가 거래를 실패 처리하면 실제 검증은 성공했지만 플레이어가 상품을 받지 못하는 미지급이 발생합니다. 따라서 Timeout은 Rejected와 다른 상태로 다뤄야 합니다.
롤백과 보상 지급 무결성은 어떻게 정의할까?
먼저 결제 처리 상태와 지급 원장을 분리합니다.
| 상태 | 의미 | 허용되는 다음 처리 |
|---|---|---|
Pending | 요청을 받았지만 최종 결과를 확정하지 못함 | 재시도 또는 대사 |
Verified | 영수증 검증이 성공함 | 지급 트랜잭션 실행 |
Granted | 지급 원장과 거래 상태가 커밋됨 | 중복 요청에 멱등 응답 |
Rejected | 영수증이 유효하지 않거나 상품이 거절됨 | 지급 금지 |
CompensationPending | 검증은 성공했지만 지급이 불완전함 | 보상 지급 재시도 |
Reversed | 잘못 지급된 수량을 역분개함 | 추가 지급 금지 |
롤백은 데이터 행을 삭제하는 작업이 아닙니다. 이미 기록된 지급을 취소해야 한다면 Reversal 원장을 추가해 감사 추적을 남깁니다. 보상 지급은 검증된 거래에 대해 누락된 수량을 추가 지급하는 작업입니다. 두 작업 모두 동일한 transactionId와 별도의 작업 키를 사용해 멱등성을 보장해야 합니다.
검증된 거래 id의 최종 지급량은 다음 조건을 만족해야 합니다.
NetQuantity는 승인된 상품 수량을 초과해서는 안 되며 지급 원장과 플레이어 잔액 변경은 같은 데이터베이스 트랜잭션에서 커밋해야 합니다.
자동 테스트는 어떤 순서로 구현할까?
1단계: 장애 주입 지점을 먼저 정의한다
운영 네트워크를 실제로 끊었다가 복구하는 방식만 사용하면 테스트가 느리고 재현성이 낮습니다. 결제 검증기와 보상 지급기를 인터페이스로 감싸고 다음과 같은 결정적 장애 주입 옵션을 둡니다.
FailBeforeRequest: 요청을 보내기 전에 연결 실패TimeoutAfterVerification: 스토어 검증 성공 후 응답 타임아웃DropResponseAfterCommit: 지급 원장 커밋 후 응답 유실CrashBeforeLedgerCommit: 지급 트랜잭션 커밋 직전 프로세스 종료FailCompensationOnce: 보상 지급 첫 시도만 실패
이렇게 하면 동일한 입력과 동일한 장애 순서로 회귀 테스트를 반복할 수 있습니다.
2단계: 상태 머신과 멱등 키를 고정한다
서버의 모든 결제 요청은 transactionId를 기준으로 조회합니다. 이미 Granted 또는 Reversed인 거래를 다시 처리하지 않고 기존 결과를 반환해야 합니다.
public async Task<PurchaseResult> ProcessAsync(
PurchaseCommand command,
CancellationToken ct)
{
var existing = await purchases.FindAsync(command.TransactionId, ct);
if (existing?.State == PurchaseState.Granted)
return PurchaseResult.AlreadyGranted;
await purchases.UpsertPendingAsync(command.TransactionId, ct);
var verified = await verifier.VerifyAsync(command.Receipt, ct);
if (!verified.IsValid)
return PurchaseResult.Rejected;
await unitOfWork.ExecuteAsync(async tx =>
{
var row = await tx.Purchases.LockAsync(command.TransactionId, ct);
if (row.NetQuantity > 0)
return;
await tx.Ledger.AppendGrantAsync(
command.TransactionId,
verified.ProductId,
verified.Quantity,
ct);
await tx.Purchases.MarkGrantedAsync(
command.TransactionId,
ct);
}, ct);
return PurchaseResult.Granted;
}
위 코드는 실제 스토어 SDK 호출을 생략한 서버 의사코드입니다. 중요한 부분은 지급 원장 추가와 Granted 상태 변경을 분리된 작업으로 처리하지 않는 것입니다. 두 작업 사이에서 프로세스가 종료되면 대사 작업이 미완료 거래를 찾아 보정해야 합니다.
3단계: 결과가 아니라 불변식을 검증한다
테스트가 성공 응답을 받았다만 확인하면 응답 유실 뒤 중복 지급을 잡을 수 없습니다. 테스트 종료 후 다음 항목을 직접 조회해야 합니다.
- 같은
transactionId의Grant원장 수가 1개 이하인가? NetQuantity가 승인 수량을 초과하지 않는가?- 클라이언트 재시도 횟수와 무관하게 최종 잔액이 같은가?
- 보상 지급 재시도 후에도 보상 원장이 한 번만 적용되는가?
- 롤백 시 기존 원장을 삭제하지 않고 역분개 원장을 남기는가?
Pending거래가 대사 작업으로 최종 상태에 도달하는가?
어떤 장애 주입 지점을 테스트해야 하는가?
| 장애 위치 | 예상 서버 상태 | 예상 지급 결과 | 검증 포인트 |
|---|---|---|---|
| 영수증 검증 요청 전 | Pending | 0 | 재시도 가능하며 Rejected로 오판하지 않음 |
| 검증 성공 후 응답 전 | Pending 또는 Unknown | 0 또는 1 | 대사 후 승인 결과를 확정함 |
| 지급 원장 커밋 전 | Verified 또는 Pending | 0 | 재시도 시 정상적으로 1회 지급 |
| 지급 원장 커밋 후 응답 전 | Granted | 1 | 클라이언트 재요청에도 중복 지급 없음 |
| 잔액 변경 후 상태 저장 전 | 복구 대상 | 1 | 원장과 잔액 불일치를 탐지하고 보정함 |
| 보상 지급 응답 전 | CompensationPending | 0 또는 1 | 보상 재시도 후 최종 수량이 정확함 |
| 역분개 커밋 후 응답 전 | Reversed | 원래 지급량만큼 감소 | 역분개 중복 적용 방지 |
응답 유실 뒤 재시도 테스트는 어떻게 작성할까?
다음 테스트는 서버가 지급을 커밋한 직후 응답을 버리는 상황을 재현합니다. 클라이언트는 타임아웃을 받지만 대사 작업을 실행한 뒤 최종 지급량이 1개인지 확인합니다.
[Test]
public async Task ResponseLostAfterCommit_DoesNotDuplicate()
{
gateway.DropResponseAfterCommit = true;
await Assert.ThrowsAsync<TimeoutException>(
() => client.PurchaseAsync(command));
await reconciler.RunOnceAsync(command.TransactionId);
var ledger = await repository.FindAsync(command.TransactionId);
Assert.That(ledger.GrantQuantity, Is.EqualTo(1));
Assert.That(ledger.ReversalQuantity, Is.EqualTo(0));
Assert.That(ledger.GrantEventCount, Is.EqualTo(1));
}
실제 구현에서는 네트워크 장애뿐 아니라 프로세스 종료도 넣어야 합니다. 데이터베이스 커밋 전과 커밋 후에 각각 프로세스를 중지하고 재시작한 뒤 대사 작업의 결과를 비교합니다. 테스트 데이터베이스는 매 테스트마다 초기화하고 테스트용 상품 수량과 transactionId를 고정하면 실패 원인을 추적하기 쉽습니다.
롤백 및 보상 테스트의 CI 통과 기준
CI에서는 단일 성공 케이스보다 장애 조합을 반복 실행하는 편이 효과적입니다. 최소 기준은 다음과 같습니다.
- 동일 거래를 1회부터 여러 차례 재시도해도 순지급량이 승인 수량과 일치한다.
- 검증 성공과 지급 실패를 분리해 실행해도 거래가
CompensationPending에서 복구된다. - 보상 지급 요청을 동시에 여러 개 실행해도 보상 원장은 한 번만 적용된다.
- 롤백 후 잔액과 원장 합계가 일치한다.
- 대사 작업을 여러 번 실행해도 상태와 잔액이 더 이상 변하지 않는다.
- 모든 테스트 실패 시
transactionId, 장애 주입 지점, 상태 전이, 원장 합계를 로그로 출력한다.
특히 동시성 테스트에서는 같은 거래에 대한 요청을 병렬로 실행해야 합니다. 단순히 순차 재시도만 검증하면 데이터베이스 잠금이나 원자적 조건부 업데이트 문제를 놓칠 수 있습니다.
자주 묻는 질문 FAQ
네트워크 타임아웃이 발생하면 즉시 결제를 실패 처리해도 되나요?
안 됩니다. 타임아웃 시점에 스토어 검증이나 지급이 이미 성공했을 수 있으므로 Unknown 또는 Pending으로 저장한 뒤 재시도와 대사로 최종 상태를 확정해야 합니다.
롤백할 때 지급 내역을 삭제하면 안 되나요?
삭제하지 않는 편이 안전합니다. 기존 Grant 원장을 보존하고 동일 거래를 참조하는 Reversal 원장을 추가해야 감사 추적과 장애 분석이 가능합니다.
클라이언트에서 중복 지급을 막으면 충분한가요?
충분하지 않습니다. 클라이언트는 재설치, 변조, 오프라인 캐시, 응답 유실의 영향을 받습니다. 중복 방지와 최종 지급 판정은 서버의 transactionId 멱등 처리와 원장 트랜잭션이 담당해야 합니다.
정리
인앱 결제 샌드박스의 네트워크 단절 테스트는 연결이 끊겼는지만 확인하는 테스트가 아닙니다. 검증, 지급, 커밋, 응답, 재시도와 대사 사이의 모든 경계를 나누고 각 지점에서 Pending, Granted, CompensationPending, Reversed 상태가 올바르게 수렴하는지 검증해야 합니다.
가장 중요한 기준은 성공 응답이 아니라 최종 원장입니다. transactionId 기반 멱등성, 원장 중심의 역분개, 결정적 장애 주입, 불변식 검증을 함께 적용하면 샌드박스에서 발견한 결제 장애를 라이브 운영에서도 재현 가능한 자동 회귀 테스트로 전환할 수 있습니다.



