문제 클라이언트가 "결제했다"고 알리면 그대로 재화를 주는 구조는 패킷 변조나 재전송 한 번으로 무너진다.
결정 클라이언트는 Purchase Token만 넘기고, 서버가 Google Play Developer API로 직접 검증한 뒤 Firestore 트랜잭션 밖·안 이중 체크로 한 번만 지급한다.
대가 결제마다 스토어 API 왕복이 한 번 늘고, 대기(Pending) 결제와 환불은 이 설계 범위 밖에 남았다.
결과 성공 경로는 코드와 단위 테스트로 막았다. 실제 동시 경합에서 한 번만 처리되는지는 이 글을 쓸 때 재지 않았다(Part 3에서 다른 경로로 측정했다).
이제 실제로 사용자에게 가치를 제공하고 그에 대한 정당한 대가를 받는 '결제 시스템'을 구축해야 할 시점이 왔다. 현재 개발 중인 서비스에 S-Coin이라는 재화 시스템을 도입하면서, 구글 플레이 스토어(Android)와 앱스토어(iOS)의 인앱결제(In-App Purchase) 프로세스를 연동하기로 했다.
이 글은 결제 시스템 구축기의 첫 번째로, 구글 플레이 스토어 인앱결제의 아키텍처와 검증 원리를 정리하고자 한다.
1. 클라이언트 주도 결제의 문제
결제 시스템을 설계할 때 가장 경계해야 할 것은 클라이언트의 응답을 신뢰하는 것이다.
클라이언트가 구글 플레이 스토어와 통신해 결제를 끝낸 뒤 서버에 "유저 A가 100 코인을 샀으니 지급해 주세요"라고만 요청한다고 해 보자. 악의적인 사용자는 중간에서 패킷을 가로채거나 앱을 변조해서, 결제가 없었는데도 지급 요청을 얼마든지 보낼 수 있다.
그래서 아키텍처의 대전제를 이렇게 잡았다.
클라이언트는 영수증(Purchase Token)만 전달하고, 결제의 진위 판단과 재화 지급은 서버가 구글 서버와 직접 교차 검증한 뒤에 한다.
2. 제약과 검토한 선택지
우리 팀은 백엔드를 내가 주로 맡게 되었고, 영속 저장소는 Firestore 하나였다. 초기 프로토타입 단계에서는 별도 백엔드 서버 없이 Firebase Cloud Functions(Callable Function)로 결제 검증을 빠르게 붙였으나, 콜드 스타트 지연과 향후 iOS 검증 파이프라인 확장 및 인프라 제어(동시성, 타임아웃)를 고려해 결제 도메인을 Cloud Run 기반의 독립 Node.js 서버로 분리했다.
| 안 | 내용 | 판단 |
|---|---|---|
| A | 클라이언트의 결제 완료 신호를 믿고 지급 | 기각. 1절의 이유 |
| B | 트랜잭션 안에서만 중복 확인 | 기각. 이미 처리된 영수증이 올 때마다 스토어 API를 한 번씩 더 부른다. (초기 Functions 코드에서 실제로 스토어 API를 트랜잭션 안에 넣었다가 경합 시간 증가와 쿼터 낭비 문제를 겪고 밖으로 분리했다) |
| C | RevenueCat 같은 검증·구독 관리 SaaS에 위임 | 기각. 자체 검증 파이프라인이 이미 있고, 재시도 정책 같은 세부 동작은 표준 옵션으로 맞추기 어려워 전환 이득이 작다고 봤다 |
| D | 스토어 서버 알림(RTDN)을 받아 서버가 상태 변화를 먼저 알기 | 보류. Pub/Sub 수신과 재조회 파이프라인이 필요해 범위 밖으로 뺐다 |
| E | 서버 검증 + 트랜잭션 밖·안 이중 체크 | 채택 |
C와 D의 판단은 출시 후 Part 3를 쓰면서 다시 정리한 것이다. D를 미룬 대가는 9절과 Part 3에 적었다.
3. 인앱결제 시스템 아키텍처 흐름도
이 원칙으로 설계한 전체 흐름을 시퀀스 다이어그램으로 그리면 다음과 같다.
Google Play 인앱결제 시퀀스. Step 4~6이 서버의 교차 검증 구간이다. |
중요한 구간은 클라이언트가 구글에서 받은 Purchase Token을 서버로 넘기고, 서버가 Google Play Developer API로 그 토큰의 상태를 직접 확인하는 부분(Step 4~6)이다.
4. 구글 플레이 인앱결제의 식별자와 검증 원리
구글 플레이 결제를 다루려면 식별자 세 개를 알아야 한다.
ProductId: 스토어에 등록된 상품의 고유 ID (예:coin.tier_01).PackageName: 앱의 고유 식별자 (예:com.XXXXXX.XXXXXX).PurchaseToken: 사용자가 상품을 구매했을 때 구글이 발급하는 영수증. 특정 사용자가 특정 상품을 샀다는 증명서 역할을 한다.
서버는 클라이언트에게서 이 세 값을 받아 구글의 purchases.products.get API를 호출한다. 구글 서버는 토큰의 현재 상태를 돌려주는데, 여기서 봐야 할 필드가 purchaseState다. 공식 문서 기준 값의 의미는 이렇다.
0: 구매 완료 (Purchased)1: 구매 취소됨 (Canceled)2: 결제 대기 중 (Pending)
서버는 오직 이 값이 0 (구매 완료)일 때만 정상적인 결제로 인정하고 재화 지급으로 넘어간다.
이 글을 쓸 때는 1과 2를 같은 에러(aborted, "Invalid purchase state")로 돌려보내는 것으로 끝냈다. 현재 클라이언트는 Pending 상태의 구매를 서버로 보내지 않고 건너뛴다. 대기 중이던 결제가 나중에 완료되면 SDK가 그때 다시 올려 보내는 구조다. 이 "같은 에러로 돌려보낸다"는 선택이 나중에 문제가 됐는데, 그 이야기는 9절과 Part 3에 있다.
5. 멱등성(Idempotency)과 무결성
구글에서 "정상 결제"라는 응답을 받았다고 끝이 아니다. 네트워크 문제나 클라이언트 오류로, 이미 처리한 영수증이 다시 올 수 있다(재시도 또는 재전송 공격).
같은 Purchase Token으로 재화가 두 번 지급되면 서비스 경제가 무너진다. 그래서 데이터베이스(Firestore) 수준에서 멱등성을 보장하기로 했다. 방법은 처리한 영수증을 처리 완료 영수증 컬렉션에 토큰 자체를 문서 ID로 기록하는 것이다. 문서 ID가 곧 멱등키라서, 조회와 잠금이 같은 문서 하나에 걸린다.
- 외부 체크 (Outside Check): 구글 API를 호출하기 전에 처리 완료 영수증 컬렉션에 해당 토큰이 있는지 본다. 목적은 비용이다. 이미 처리된 영수증이면 구글까지 다녀오지 않는다.
- 트랜잭션 내부 체크 (Inside Check): 구글 검증이 끝난 뒤, 실제로 재화를 지급하는 트랜잭션 안에서 같은 토큰을 다시 읽는다. 동시성 문제(Race Condition)로 두 개의 쓰레드가 동시에 외부 체크를 통과하더라도, 트랜잭션 락(Lock)을 통해 오직 하나의 요청만 재화를 지급하고 영수증을 기록할 수 있게 강제하는 것이다. (Node.js 서버라 정확히는 쓰레드가 아니라 같은 토큰을 든 두 요청이고, 인스턴스가 여러 대면 서로 다른 인스턴스에서 올 수도 있다.) (단일 스레드 이벤트 루프에서의 동시 요청 처리는 부록: FastAPI의 비동기 구조를 참고.)
두 체크는 역할이 다르다. 외부 체크는 비용을 줄이고, 정합성은 내부 체크가 맡는다. 외부 체크만 있으면 동시에 들어온 두 요청이 둘 다 통과할 수 있고, 내부 체크만 있으면 재전송마다 스토어 API를 부른다.
6. Node.js 서버 구현
6.1. 외부 체크와 Google Play API 호출
실제 순서는 카탈로그에서 상품을 먼저 찾고(없는 상품이면 바로 거부), 그다음 외부 체크, 그다음 구글 API 호출이다. 구글 API에는 호출 할당량(Quota)이 있어서, 이미 처리한 결제건이면 구글까지 가지 않고 바로 응답한다.
const purchaseRef = db.collection(PROCESSED_PURCHASES).doc(purchaseToken);
const outsideCheck = await purchaseRef.get();
// 1. Outside Check: 이미 처리된 영수증인지 확인
if (outsideCheck.exists) {
eventLogger.info({
event_name: "iap.verify.duplicate_outside",
uid,
request_id: requestId,
purchase_token_hash: hashValue(purchaseToken),
});
return { success: true, message: "Already processed" };
}
// 2. Google Play Developer API 호출
const verificationResult = await androidPublisher.purchases.products.get({
packageName,
productId,
token: purchaseToken,
});
// 3. 결제 상태 확인 (0: Purchased)
if (verificationResult.data.purchaseState !== 0) {
throw new AppError("aborted", "Invalid purchase state");
}
중복 요청에 에러가 아니라 성공(Already processed)을 돌려주는 이유는 클라이언트 쪽 흐름 때문이다. 서버가 지급까지 끝냈는데 응답이 유실되면, 클라이언트는 consume을 못 한 채 다음 실행에 같은 영수증을 다시 보낸다. 이때 성공을 받아야 클라이언트가 consume으로 넘어가 루프가 끝난다.
로그에는 토큰 원문 대신 해시를 남겼다. 토큰이 곧 결제 증빙이라서다(이 부분은 이후 키를 넣은 HMAC으로 바꿨다. Part 3).
6.2. 트랜잭션과 내부 체크 (Inside Check)
검증이 끝나면 재화를 지급한다. 경쟁 상태를 제어하려고 Firestore의 runTransaction을 쓴다. 두 요청이 동시에 외부 체크를 통과해도, 트랜잭션 안에서 영수증 존재 여부를 다시 확인해 중복 지급을 막는다.
await db.runTransaction(async (txn) => {
// 1. Inside Check: 트랜잭션 내부에서 한 번 더 확인
const insideCheck = await txn.get(purchaseRef);
if (insideCheck.exists) {
return; // 이미 다른 트랜잭션에서 처리됨
}
const userRef = db.collection("users").doc(uid);
// 2. 유저 재화(S-Coin) 증가
txn.set(userRef, { [COIN_FIELD]: fieldValue.increment(totalCoins) }, { merge: true });
// 3. 처리된 영수증 기록
txn.set(purchaseRef, buildPurchaseRecord({ ... }));
// 4. 지갑 로그(Wallet Log) 생성
const logRef = userRef.collection("wallet_logs").doc();
txn.set(logRef, { type: "BUY_SCOIN", deltaSCoin: totalCoins, ... });
});
잔액 증가, 영수증 기록, 지갑 로그 세 쓰기가 한 트랜잭션에 묶여 있어서, 셋 중 하나만 반영되는 상태는 생기지 않는다. 재화는 클라이언트가 계산한 값을 덮어쓰지 않고 서버에서 fieldValue.increment로 더한다. 지급량(base + bonus)도 클라이언트가 아니라 서버의 카탈로그에서 가져온다.
7. Flutter 클라이언트 구현
클라이언트는 서버 검증을 요청하고 그 결과에 따라 마무리한다. 소비성(Consumable) 아이템인 S-Coin은 소비(Consume) 처리가 흐름의 마지막 단계다.
Future<void> _onPurchaseUpdate(List<PurchaseDetails> purchases) async {
for (var p in purchases) {
if (p.status == PurchaseStatus.purchased || p.status == PurchaseStatus.restored) {
// 1. 서버에 검증 요청 (_verifyOnServer 내에서 REST API 호출)
final verified = await _verifyOnServer(p);
if (verified) {
// 2. 결제 완료 처리
if (p.pendingCompletePurchase) {
await _iap.completePurchase(p);
}
// 3. 안드로이드는 Consume 처리
if (Platform.isAndroid) {
final androidAddition = _iap.getPlatformAddition<InAppPurchaseAndroidPlatformAddition>();
await androidAddition.consumePurchase(p);
}
_onPurchaseSuccess?.call(p);
}
}
}
}
안드로이드에서 consumePurchase를 따로 부르는 이유가 있다. 구글 플레이는 한 번 산 상품을 '보유(Owned)' 상태로 취급하고, 보유 중인 상품은 다시 살 수 없다. 100 S-Coin을 사서 다 쓴 유저가 또 사려 해도, 이전 영수증이 소비되지 않았다면 스토어가 결제를 막는다. 그래서 서버 검증과 재화 지급이 완전히 끝난 후, 클라이언트가 스토어에 "이 아이템은 소모했으니 다시 살 수 있게 해 달라"고 알려야 한다.
반대로 보면, 소비 전까지 SDK는 영수증을 미완료로 들고 있다가 앱을 켤 때마다 다시 준다. 지급 도중 앱이 죽어도 결제가 사라지지 않는 이유가 이것이고, 6.1의 Already processed가 성공 응답이어야 하는 이유도 이것이다.
8. 무엇으로 확인했나
이 글을 쓸 때 "한 번만 지급된다"의 근거는 코드 구조와 단위 테스트 1건이었다. 테스트는 같은 토큰으로 검증·지급을 Promise.all로 두 번 부르고, 코인 증분 쓰기와 처리 완료 영수증 컬렉션 쓰기가 각각 1회인지 본다.
다만 이 테스트의 가짜 runTransaction은 트랜잭션을 Promise 큐로 한 줄씩 실행한다. 로직이 맞는지는 보여 주지만, 실제 Firestore가 같은 문서에 동시에 몰린 트랜잭션을 어떻게 처리하는지는 확인하지 않는다. 저장소에 있던 k6 멱등성 스크립트도 HTTP 상태와 응답 모양만 봐서 중복 지급을 잡지 못한다.
실제 경합 측정은 2026년 9월 테스트 서버에서 했다. 같은 uid로 20건을 동시에 보내 서버에서 20건이 실제로 겹친 상태로 60라운드를 돌렸고, 60라운드 모두 원장 1건·차감 1회였다. 단, 측정한 경로는 S코인으로 테마·스토리를 사는 차감 경로다. 같은 원칙(문서 ID 멱등키 + 트랜잭션 안 재확인)을 쓰지만, 이 글의 영수증 지급 경로는 유효한 스토어 토큰을 준비하지 못해 부하로 검증하지 않았다. 측정 조건은 Part 3에 정리했다.
9. 남은 한계
- 대기(Pending) 결제. 4절에서 적었듯
2를 취소와 같은 에러로 돌려보냈다. "아직"과 "끝"을 서버가 구분해야 하는 이유와 고칠 방향은 Part 3에서 다뤘다. - 환불·취소. 지급한 뒤의 환불과 취소를 재화에 반영하는 일은 이 설계의 범위 밖에 두었다. 스토어 서버 알림(RTDN)을 받는 방식은 위 선택지 표의 D안으로 남겨 두었다.
- 확인(acknowledge)과 지급의 정합성. 구글은 3일 안에 확인되지 않은 결제를 자동 환불하고, consume은 확인을 겸한다. 이 글에서는 확인을 클라이언트의 consume에 맡겼고, 서버 지급과 클라이언트 확인이 어긋나는 경우를 맞추는 일은 별도 과제로 남겼다.
- 영수증이 "이 사람 것"인지는 보지 않는다. 스토어 검증은 토큰이 진짜인지만 알려준다. 남의 영수증을 자기 uid로 보내는 경우는 이후 소유권 바인딩으로 막았다(Part 3).
- 비용. 첫 요청마다 스토어 API 왕복이 한 번 붙는다. 외부 체크가 줄여 주는 건 재전송 쪽뿐이다.
마치며
결제 아키텍처를 설계하면서 시스템은 언제든 실패할 수 있다는 가정을 깔고 가야 한다는 걸 배웠다. 통신은 끊어질 수 있고, 앱은 예기치 않게 종료될 수 있다.
Outside Check, Inside Check, 트랜잭션, 상태 코드 확인, 클라이언트의 Consume 처리까지 단계마다 안전장치를 두다 보니, 운영체제 수업에서 배운 동시성 제어가 실제 비즈니스 로직에서 이렇게 쓰이는구나 싶었다.
이제 비로소 외부 요인에 흔들리지 않는, 단단하고 신뢰할 수 있는 결제 파이프라인이 완성되었다는 생각이 든다. (출시 후 다시 보니 이 문장은 성공 경로에 대해서만 맞았다. 그 바깥 이야기는 Part 3에 썼다.)
다음 편에서는 애플 앱스토어의 결제 시스템에 대해 다루어 보겠다.
추천글
[스타트업/기술] Node.js & App Store 인앱결제 | 결제 시스템 아키텍처 (Part 2)
[스타트업/기술] 인앱결제 Part 3 | 검증에 성공한 뒤에 시작되는 문제들