[스타트업/기술] 인앱결제 Part 3 | 검증에 성공한 뒤에 시작되는 문제들

 Part 1에서 구글 플레이, Part 2에서 앱스토어의 서버 검증 아키텍처를 정리했었다. Part 1은 이 문장으로 끝났다. "이제 비로소 외부 요인에 흔들리지 않는, 단단하고 신뢰할 수 있는 결제 파이프라인이 완성되었다는 생각이 든다."

저 글도 실제 인앱 결제를 스토어에 올리기 전 테스트 단계에서 쓴 거긴 하지만, 절반만 맞는 말이었다. 두 글이 다룬 건 영수증이 진짜고 사용자가 정당하고 네트워크가 멀쩡할 때의 성공 경로다. 실제로 시간을 잡아먹은 건 검증에 실패한 뒤에 무슨 일이 일어나는가였다. 이 글에서는 그 바깥의 네 가지 문제를 어떻게 막았는지 정리해보겠다.


1. Problem) 성공 경로 이면의 일들

Part 1에서 안드로이드의 consumePurchase를 설명하며 "서버 검증과 재화 지급이 완전히 끝난 후" 클라이언트가 스토어에 소모를 알려야 한다고 썼다. 뒤집으면, 소모(혹은 acknowledge/finishTransaction) 전까지 스토어 SDK는 영수증을 "미완료"로 들고 있다가 앱을 켤 때마다 다시 던진다. Google Play Billing도 StoreKit도 같다. 지급에 실패했을 때 결제가 증발하지 않게 하는 스펙이다.

문제는 영구히 실패하는 영수증이다. 상품 ID가 카탈로그에 없거나, 패키지명이 허용 목록에 없거나, 서명된 트랜잭션의 상품이 클라이언트 주장과 다르면 몇 번을 보내도 성공하지 않는다. 그런데 초기 구현은 이걸 일시 실패(네트워크 오류, 스토어 API 타임아웃)와 똑같이 aborted 하나로 응답해서, 클라이언트가 둘을 구분할 수 없었다.

문제 상황

단말 하나가 앱을 켤 때마다 서비스 전체가 공유하는 Google Play Developer API 할당량을 태우고, 유저에게 결제는 영원히 "처리 중"이다 → CS 문의.

이 문제는 출시를 앞두고 베타테스터 신청을 한 유저들에게 각 스토어에서 제공하는 프로모션 기능을 시험해 보던 중 발생하였다. 유저가 회원가입 전(UID가 없는 상태)에서 스토어를 통해 프로모션 코드를 사용한 경우, 재화가 정상적으로 지급되지 못했던 것.


2. 검토한 선택지

위와 같은 문제로 유저가 앱을 실행시킬 때 마다 실패 로그가 지속적으로 쌓였다. 이걸 막고자 다음과 같은 방안을 정리해보았다.

내용기각 사유
A모든 실패를 5xx로 응답해 SDK 기본 백오프에 맡긴다백오프는 간격을 늘릴 뿐 루프를 끝내지 못한다
B클라이언트가 실패 횟수를 세어 N회 후 포기재설치·기기 변경으로 초기화되고, 클라이언트는 별도 저장소라 서버 혼자 못 고친다
C실패한 토큰을 서버에 기록해 즉시 거부 + 클라이언트에 "끝났다"고 알림채택
D스토어 서버 알림(RTDN / App Store Server Notifications V2)으로 서버가 상태 변화를 능동 감지방향은 맞지만 Pub/Sub 컨슈머와 재조회 파이프라인이 필요해 범위 밖. pull 모델로 먼저 막고 8절 과제로 미뤘다
ERevenueCat 같은 검증·구독관리 SaaS에 위임자체 검증 파이프라인이 이미 있고(Part 1·2), poison pill 같은 세밀한 재시도 정책은 표준 옵션으로 못 맞춘다. 전환 비용 대비 이득 부족

3. Solution) Poison Pill과 terminal 신호

Poison Pill, "처리할 때마다 서버/클라이언트의 실패를 유발하지만, 큐에서 제거되지 않고 계속 재처리되어 시스템을 마비시키는 특정 요청(메시지)"

해결 방안

여기에 대한 해법은 두 겹이다.

① 응답에 "다시 시도해도 소용없음"을 명시한다. 에러 코드를 재시도 가능/불가능으로 나누고, 재시도 가능한 쪽을 화이트리스트로 좁혔다.

// src/core/appError.js
// 클라이언트가 백오프 후 재시도해야 하는 코드. 나머지는 전부 terminal로 취급한다 —
// 같은 입력으로 같은 요청을 다시 보내도 성공하기 시작하지 않으므로,
// 클라이언트는 재전송 대신 SDK의 미완료 큐를 정리(acknowledge/finishTransaction)해야 한다.
const RETRYABLE_ERROR_CODES = new Set([
  "purchase_retry_later",   // 503
  "resource-exhausted",     // 429
  "internal",               // 500
  "UNKNOWN",                // 500
]);

function isTerminalErrorCode(code) {
  if (!code) return false;
  return !RETRYABLE_ERROR_CODES.has(code);
}

만약 블랙리스트로 처리했다면 새로 추가한 에러 코드가 기본으로 재시도 대상이 되어 루프가 조용히 되살아난다. 화이트리스트면 새 코드는 기본이 terminal이라 안전한 쪽으로 실패한다. 이 값은 에러 응답 바디의 error.terminal로 그대로 실린다.

Retry-After 헤더도 재시도 가능한 코드에만 붙이되, 429(resource-exhausted)에는 기본값을 쓰지 않는다. 

// src/api/middlewares/errorMiddleware.js
function resolveRetryAfterSeconds(error) {
  const explicit = explicitRetryAfterSeconds(error);
  if (explicit !== null) return explicit;
  if (error && RETRYABLE_CODES_WITH_DEFAULT_BACKOFF.has(error.code)) {
    return DEFAULT_RETRY_AFTER_SECONDS; // 60
  }
  // 호출자가 정확한 값을 줄 책임이 있다(예: 레이트리미터는 정확한 리셋 시각을 안다).
  // 오도하느니 헤더를 생략한다.
  return null;
}

② 실패를 박제해서 스토어 API를 다시 태우지 않는다. 구버전 앱이나 변조된 클라이언트는 terminal을 무시할 수 있으니 서버에도 방어를 둔다. 영구 실패한 영수증은 processed_purchases/{purchaseId}status: "rejected" 행으로 남고, 같은 토큰이 다시 오면 스토어 API를 호출하지 않고 캐시된 거부를 던진다.

// src/domain/services/purchaseRejection.js
txn.set(purchaseRef, {
  uid: uid || null,
  platform, provider,
  productId: productId || null,
  [purchaseIdField]: purchaseId,
  status: REJECTION_STATUS,          // "rejected"
  rejectionReason: reason,
  rejectionErrorCode: errorCode || null,
  rejectionMessage: errorMessage || null,
  rejectedAt: fieldValue.serverTimestamp(),
  rejectedAtIso: now().toISOString(),
  expiresAt: new Date(now().getTime() + REJECTION_TTL_MS),  // 30일
});


과잉 차단 피하기

처음엔 모든 거부를 "영원히 끝"으로 처리했는데, 그러면 타당한 재시도까지 막아버린다. 소유권 불일치(ownership_mismatch_account_token)는 "지금 요청한 사용자의 것이 아니다"라는 뜻이라 진짜 주인이 나중에 올 수 있지만(처음 문제 상황에서 uid가 발급되지 않은 경우), 카탈로그에 없는 상품(unknown_product)은 누가 와도 실패한다. 그래서 사유를 토큰에 묶는 것uid에 묶는 것으로 나눴다.

// 토큰 자체에 묶이는 사유 — 어떤 호출자도 이 영수증을 살릴 수 없다
const TOKEN_TERMINAL_REASONS = new Set([
  "package_not_allowed",
  "unknown_product",
  "invalid_purchase_state",
  "invalid_transaction_id",
  "invalid_product_id_mismatch",
  "original_transaction_id_missing",
  "account_token_backfill_not_configured",
  "account_token_missing",
]);

// 호출자 uid에 묶이는 사유 — 정당한 주인은 나중에 올 수 있다
const UID_TERMINAL_REASONS = new Set([
  "ownership_mismatch_account_token",
  "ownership_mismatch_purchase",
]);

function shouldShortCircuit({ rejectionData, uid }) {
  if (!isRejectionRow(rejectionData)) return false;
  const reason = rejectionData.rejectionReason;
  if (isTokenBoundReason(reason)) return true;                                   // 전원 차단
  if (isUidBoundReason(reason) && rejectionData.uid && rejectionData.uid === uid) return true;  // 같은 uid만 차단
  return false;                                                                   // 다른 uid는 통과시킨다
}

다른 uid가 같은 토큰으로 오면 통과시키되, 이미 다른 uid에게 성공 적립된 영수증은 processed_purchases선착순 잠금(assertExistingPurchaseOwner)이 막는다. 대가로 processed_purchases에 성공 행과 거부 행이 공존해, 이후 모든 조회가 status를 분기해야 한다.

박제 행은 30일 뒤 만료 시각(expiresAt)을 달아 Firestore TTL 정책으로 지운다. 거부 기록을 영구히 쌓아 둘 이유가 없다. 이 이원화가 어디서 틀리는지, 이 TTL이 왜 피해자를 구하지 못하는지는 6절에서 다룬다.



4. 구현에서 걸린 것

소유권 바인딩) 영수증은 진짜인데, 그 사람 것이 아니다

서버가 스토어와 교차 검증해도 "지금 로그인한 이 사람 것인가?"에는 답이 없다. 스토어 API는 토큰이 유효하고 결제가 완료됐다는 것까지만 알려줄 뿐, 그 계정이 어떤 Firebase uid인지는 모른다. 남의 영수증을 자기 uid로 보내면 그대로 통과한다.

두 스토어 모두 결제 시작 시 임의 식별자를 실어 보내는 필드가 있고, 검증 응답에 그대로 돌아온다(안드로이드 obfuscatedExternalAccountId, iOS appAccountToken). uid를 그대로 넣으면 내부 식별자가 스토어에 평문으로 남고 길이 제한 (안드로이드 64자)에도 걸리므로, uid에서 결정론적으로 유도한 UUID를 쓴다.

// src/core/storeAccountToken.js
const { v5: uuidv5 } = require("uuid");

const STORE_ACCOUNT_TOKEN_NAMESPACE = "6ba7b811-9dad-11d1-80b4-00c04fd430c8"; // RFC 4122 URL 네임스페이스
const STORE_ACCOUNT_TOKEN_PREFIX = "<service>:";

function resolveStoreAccountTokenForUid(uid) {
  if (!uid || typeof uid !== "string") return null;
  return uuidv5(`${STORE_ACCOUNT_TOKEN_PREFIX}${uid}`, STORE_ACCOUNT_TOKEN_NAMESPACE);
}

같은 uid는 늘 같은 값이 나오니 저장하지 않고 필요할 때마다 다시 계산한다. 매핑 테이블이 없어 동기화가 깨질 일도 없다. 검증 시퀀스에는 스토어 교차 검증(purchaseState === 0)과 트랜잭션 진입 사이에, 스토어가 돌려준 토큰을 이 값과 비교하는 단계가 들어간다.

원칙은 3줄짜리 비교다. 시간을 잡아먹은 건 이 검사를 통과할 수 없는 정당한 결제들이었다.

① Google Play 프로모 코드. 프로모 리딤은 BillingFlowParams.setObfuscatedAccountId를 거치는 일반 구매 플로우를 타지 않아 Google이 obfuscatedExternalAccountId채우지 않는다. 무조건 mismatch다. 그래서 purchaseType === 1(프로모)이면 iap.verify.promo_redemption_detected 이벤트만 남기고 검사를 건너뛰되, 구글이 값을 채워 보냈다면 그때는 검증한다. "프로모니까 통과"가 아니라 "확인할 수 있으면 확인한다"로 좁혔다.

② App Store Offer Code. 이쪽이 훨씬 복잡하다. Google의 식별자는 구매 건별이지만, Apple의 appAccountTokenoriginalTransactionId 계보를 따라 상속된다. 과거 uid_A로 구독하다 탈퇴하고 uid_B로 재가입해 Offer Code를 리딤하면, 그 트랜잭션은 uid_A의 uuidv5를 물고 온다. 현재 사용자는 uid_B이니 구조적으로 영구 mismatch다.

그래서 offerType ∈ {2, 3}(Promotional offer, Offer Code)은 소유권 강제를 완전히 해제했다. offerType === 1 (Introductory offer)은 일반 구매 플로우라 클라이언트가 토큰을 정상적으로 실으므로 강제를 유지한다. 해제한 자리에는 account_token_matches_uid판정 없이 기록만 해 실제로 얼마나 어긋나는지 데이터로 볼 수 있게 했고, 크로스 유저 보호는 선착순 잠금이 계속 맡는다.

이 두 예외를 찾는 시간이 원칙을 구현한 시간보다 훨씬 길었다. 


그래도 막히는 정당한 결제 → 구제 경로를 함께 설계하기

소유권 검사는 정확하기 때문에 정당한 사용자를 막는다. Offer Code 상속으로 영구 mismatch가 난 사용자, 구매 시점과 검증 시점의 로그인 계정이 다른 사용자, inAppOwnershipType === "FAMILY_SHARED"로 들어온 가족 공유 결제, Google이 정책과 다르게 프로모에 식별자를 채워 보내 strict 분기를 탄 경우. 이 사람들은 돈을 냈다.

"관리자가 코인 넣어주는 API"를 급히 만들면 결제 검증 전체를 우회하는 백도어가 생긴다. 그래서 수동 적립이 건너뛰는 건 assertStoreAccountOwnership 하나뿐이고 스토어 영수증 재검증은 그대로 강제한다. "영수증이 진짜인가"는 스토어가, "이 사람 것인가"만 사람이 판정한다. 위조 영수증은 관리자도 못 통과시키고, admin 권한 자체에도 제약을 걸었다.

  • 입력 검증: validateManualCreditInput은 admin uid나 대상 uid가 없거나, adminUid === targetUid이거나, 사유가 공백을 뺀 8자 미만이면 거부한다. 자기 적립은 다른 admin에게 위임해야 하고("혼자서 완결되는 경로" 제거), . 한 글자 사유도 통하지 않는다.
  • 중복 지급 가드 유지: 성공 적립 행이 있으면 수동 적립도 거부된다. 다만 박제된 status: "rejected" 행은 소유권 기록이 아니므로 덮어쓸 수 있다.
  • 감사 필드 영구 기록: 누가, 언제, 어떤 사유로 적립했는지가 지워지지 않는다.
  • 전용 레이트리밋: 일반 결제 경로와 분리된 제한을 건다.

런북도 썼다. 어떤 조건을 전부 만족할 때만 적립하는지, 무엇이 금지인지, 본인 확인과 Cloud Logging 차단 사유 조회는 어떻게 하는지.

resource.type="cloud_run_revision"
jsonPayload.event_name=~"iap.verify.(account_binding_mismatch|account_binding_missing|purchase_owner_mismatch)"
jsonPayload.uid="<USER_UID>"

누가 어떤 판단으로 버튼을 누르는지 정해져 있지 않으면 잘 만든 가드도 "일단 눌러주세요"로 무력화된다. 예외 처리 경로는 코드로 끝나지 않는다. 다만 이 런북에도 구멍이 있다. 6절의 purchaseState 문제다.


5. 그래서 정말 한 번만 처리되나

Part 1에서 트랜잭션 내부 체크를 설명하며 이렇게 썼다. "두 개의 쓰레드가 동시에 외부 체크를 통과하더라도, 트랜잭션 락(Lock)을 통해 오직 하나의 요청만 재화를 지급하고 영수증을 기록할 수 있게 강제하는 것이다." 그때 이건 보장한다고 믿는 것이었지 보장됨을 보인 것이 아니었다. 저장소에 있던 근거 둘은 이 질문에 답하지 못했다.

  • 단위 테스트 1건. 같은 토큰으로 구글 플레이 검증·지급을 Promise.all로 두 번 호출해 코인 증분 쓰기와 processed_purchases 쓰기가 각 1회인지 본다. 그런데 가짜 runTransaction이 트랜잭션을 Promise 큐로 한 줄로 실행해서, 실제 Firestore가 같은 문서에 동시에 몰린 트랜잭션을 어떻게 처리하는지는 확인하지 않는다.
  • k6 멱등성 스크립트. idempotency_violation_rate의 계산식은 !(HTTP 200 ∧ 응답 바디에 boolean success 필드 존재)이고 주석도 "protocol-level mismatch only"다. 잔액도 원장도 읽지 않으니 중복 지급이 나도 0이다. 이 자리에 실으려던 임계치 표는 실행 결과도 아니었다.

그래서 테스트 서버에서 경합을 직접 만들고, 판정은 응답이 아니라 Firestore를 읽어서 했다.

측정 조건

항목
대상운영과 분리된 테스트 프로젝트의 결제 서버(Cloud Run, us-central1), 인스턴스 1대 고정
경로S코인 차감 2종: 채팅 테마(100 S코인, 요청마다 다른 requestId)·스토리(280 S코인, entryType: purchase). 멱등키 users/{uid}/chat_theme_entitlements/{themeId}·users/{uid}/story_status/{storyCode}
부하한국 로컬 머신의 Node.js(fetch + Promise.all), 라운드마다 같은 uid 20건 동시 POST. 계정마다 /ping 20건 동시 × 2회 예열
표본계정 10 × 3라운드 × 2경로 = 시나리오당 60라운드·1,200요청, 각 3분 안팎
고정라운드마다 원장(wallet_logs)·권한 문서 삭제, 잔액 1,000 S코인. 레이트리밋 한도(pre-auth 포함) 1,000,000
판정종료 후 Firestore 조회: 원장 1건 ∧ 잔액 감소 = costCoins ∧ 과금 응답 1건 ∧ 권한 문서 존재. 원장·과금 응답 2건 이상 또는 감소량 초과는 이중 차감
서버 동시성billing.request.* 로그의 (종료 − latency_ms, 종료) 구간으로 같은 uid 최대 중첩 계산
지연클라이언트 지연엔 한국↔us-central1 왕복 약 200ms가 섞여 서버 latency_ms 기준
정리구매 이벤트 트리거 없음(알림 미발송). 측정 후 계정·데이터 삭제, 설정은 측정 전 스냅샷과 diff 없이 원복

첫 측정에는 경합이 없었다

시나리오 A는 리소스 설정(concurrency 1, 0.3 vCPU, Firestore 리미터)을 그대로 두고 한도만 올렸다. 클라이언트에서는 60라운드 모두 20건이 완전히 겹쳐 나갔고 결과는 60/60 exactly-once였다.

여기서 멈췄으면 "동시 요청 20건에서 중복 차감 0건"이라고 썼을 것이다. 그런데 서버 로그로 요청 구간을 복원하니 서버 측 최대 동시 처리가 1이었다(인접 요청 452쌍 중 겹침 0쌍). 인스턴스 1대·concurrency 1이라 요청은 한 건씩 처리됐고, 1,200건 중 736건(61%)은 빈 인스턴스가 없어 Cloud Run 429로 튕겼다. A가 증명한 건 "하나씩 들어온 중복 요청을 한 번만 과금한다"까지고, 트랜잭션끼리는 한 번도 부딪히지 않았다.

그래서 B에서 경합을 일부러 만들었다. concurrency 80·vCPU 1로 올리고, 같은 uid 요청을 구매 트랜잭션 앞에서 먼저 줄 세우는 Firestore 리미터(8절③) 대신 메모리 리미터를 썼다. 인스턴스는 1대로 유지했고, 이 조합은 인스턴스 수와 리전을 빼면 운영 설정과 같다.

A: concurrency 1 · 0.3 vCPU · Firestore 리미터B: concurrency 80 · 1 vCPU · 메모리 리미터
앱에 도달한 요청464 / 1,200 (736건은 Cloud Run 429)1,200 / 1,200
클라이언트 측 동시 전송20 (전 라운드 완전 중첩)20 (전 라운드 완전 중첩)
서버 측 같은 uid 최대 동시 처리120
exactly-once60 / 60 (테마 30 · 스토리 30)60 / 60 (테마 30 · 스토리 30)
이중 차감00
과금 외 응답이미 보유 201 · ALREADY_PURCHASED 203 · 429 736이미 보유 570 · ALREADY_PURCHASED 570
5xx00
서버 지연 p50 / p95 (max)86 / 195ms (582)262 / 1,726ms (2,617)

(서버 지연은 조회 시점에 적재된 앱 로그 기준이다 / A 453건, B 1,141건). exactly-once 판정은 로그가 아니라 Firestore 기준이다.)

같은 uid 요청 20건이 서버에서 동시에 처리되는 상태에서도 60라운드 모두 원장 1건·차감 1회였다. "한 번만 처리된다"를 실측으로 말할 근거는 이것 하나다.

대가는 지연이다. 서버 p95가 195ms에서 1,726ms로 뛰었다. 원인은 분리하지 않았다. 같은 문서에 몰린 트랜잭션의 경합·재시도와 vCPU 1개로 20건을 동시에 처리하는 비용이 섞여 있고, A의 지연엔 Cloud Run 앞 대기 시간이 빠져 있으며, B는 concurrency·vCPU와 리미터 저장소를 한꺼번에 바꿨다.

이 측정이 증명하지 않는 것

  • 이 글의 주제인 IAP 영수증 경로는 부하로 검증하지 않았다. 측정한 건 S코인 차감 경로(테마·스토리 구매)다. verify-grantprocessed_purchases 이중 체크도 같은 원칙(문서 ID 멱등키 + 트랜잭션 안 재확인)이지만 코드가 다르고, 경합 재현에 필요한 유효한 스토어 구매 토큰을 준비하지 못했다. IAP 지급의 exactly-once는 여전히 코드 구조와 단위 테스트 1건에 기대며, 이번 결과는 같은 원칙이 다른 경로에서 실제 경합을 버틴다는 간접 근거까지다.
  • 처리량은 측정하지 않은 것으로 둔다. 같은 날 걸어 본 k6 단계 부하는 인스턴스 1대 제한 때문에 20 req/s에서 요청의 41%가 Cloud Run 429였다. 테스트 설정의 천장이지 서버 처리량이 아니다.
  • 다중 인스턴스 경합은 없다. 운영은 최대 3대까지 뜨지만, 같은 uid 요청이 여러 인스턴스로 흩어지는 경우는 측정하지 않았다.

정합성은 응답이 아니라 저장소를 읽어야 판정할 수 있고, 서버에서 실제로 겹쳤는지 확인하지 않으면 경합 테스트가 경합 없이 통과할 수 있다.


6. "그럼 poison pill이 정당한 결제자도 막지 않나요?"

이 글의 1·3절은 하나의 아이디어 위에 서 있다. 영구히 실패하는 영수증은 박제해서 다시는 스토어 API를 태우지 않는다. 그런데 마감하려고 코드를 다시 감사하다가, 그 아이디어 자체가 정당한 결제자를 영구히 막을 수 있다는 걸 발견했다. 자랑하는 패턴의 허점이니 숨기지 않고 쓴다.

Google Play의 purchaseState는 0=Purchased(완료), 1=Canceled(취소), 2=Pending(대기) 세 값이다. Part 1에서도 이 셋을 나열하고 "오직 이 값이 0일 때만" 정상 결제로 인정한다고 썼다. 지급 조건으로는 맞다. 그런데 검증 코드는 이렇다.

// src/domain/services/googlePlayBilling.js
if (verificationResult.data.purchaseState !== 0) {
  eventLogger.warn({ event_name: `${eventPrefix}.provider_state_invalid`, /* ... */ });
  throw new AppError("aborted", "Invalid purchase state");
}

0이 아니면 무조건 같은 에러다. 이 메시지("Invalid purchase state")는 4절의 분류기가 invalid_purchase_state로 매핑하고, 이 사유는 TOKEN_TERMINAL_REASONS에 속한다(모든 uid에 대해 영구 차단).

취소(1)라면 맞는 처리다. 그런데 대기(2)는 취소가 아니라 "아직"이다. 현금 결제처럼 나중에 완료되는 결제 수단은 Pending 상태를 먼저 거친다. 이 Pending 영수증이 서버에 한 번이라도 검증 요청으로 올라오면, poison pill은 그 시점에 status: "rejected" 행을 박는다. 나중에 결제가 진짜로 완료되어 같은 토큰이 다시 와도, 4절의 단락 로직이 스토어 API를 호출하기도 전에 캐시된 거부를 던진다. "아직"과 "끝"을 같은 코드로 뭉뚱그린 게 원인이다.

현재 클라이언트에서는 평상시 이 경로를 타지 않는다. 앱 코드를 확인해 보니 구매 스트림과 앱 시작 시의 과거 구매 조회 모두 Pending 상태를 건너뛰고 서버로 보내지 않는다. 박제가 실제로 일어나려면 Pending을 그대로 보내는 호출자(예전 앱이나 변조된 클라이언트)가 있어야 한다. 하지만 Part 1의 대전제는 "클라이언트를 믿지 않는다"였다. 이 버그를 막는 방어선이 지금은 클라이언트 한 곳뿐이다.

그리고 한 번 박히면 풀리지 않는다.

  1. 결제가 완료된 뒤 같은 토큰이 다시 오면, 서버는 캐시된 거부를 terminal: true로 돌려준다.
  2. 클라이언트는 4절의 계약대로 루프를 끊는다. 실제 앱 코드도 terminal을 받으면 completePurchaseconsumePurchase를 호출한다.
  3. 구글은 3일 안에 확인(acknowledge)되지 않은 결제를 자동 환불하고, Pending이었던 결제는 완료로 바뀐 시점부터 그 3일을 센다. 그런데 소비(consume) 처리는 확인 요건을 충족하므로, 2단계에서 그 안전장치가 꺼진다. 사용자는 돈을 냈고, 재화는 받지 못했고, 자동 환불도 되지 않는다.
  4. 4절의 30일 TTL은 운영 Firestore에서 실제로 켜져 있다. 하지만 30일 뒤 거부 행이 지워져도, 이미 소비 처리되어 SDK 큐에서 빠진 영수증을 클라이언트가 다시 보낼 일이 없다. TTL은 이 사람을 구하지 못한다.

사람은 구할 수 있을까. 도구로서는 가능하다. 5절의 수동 적립은 거부 행을 덮어쓰도록 만들었고, 재검증 시점에 결제가 이미 완료되어 있으면 purchaseState 검사도 통과한다. 막고 있는 건 절차다. 런북은 수동 적립을 허용하는 차단 사유를 소유권 불일치 두 가지(와 그 사유로 박제된 경우)로 한정했고, 실패 대응표에는 이렇게 적었다.

409 | aborted | "Invalid purchase state" | Google Play purchaseState 비정상 (취소 / 환불됨 등). 수동 적립 금지

런북조차 이 에러를 "취소·환불"로만 읽는다. 코드의 분류 실수가 운영 문서에 그대로 복제된 셈이다. CS가 이 사용자를 만나면 절차대로라면 거절해야 한다. 정식 구제 경로가 없다.

테스트로도 확인된다. purchaseState 값을 쓰는 테스트를 전부 세어보면(grep 실측) 0(성공 케이스)이 16건, 1(취소, poison pill 검증용)이 2건이다. purchaseState: 2를 검증하는 테스트는 0건. 이 저장소 안에서는 Pending이 들어오면 무슨 일이 생기는지 한 번도 확인된 적이 없었다.

3절에서 "화이트리스트로 짜서 새 코드가 안전한 쪽으로 실패하게 했다"고 썼고, 그건 지금도 맞다. 그런데 안전한 기본값은 분류 체계 자체의 실수까지 막아주지 않는다. 새 에러 코드가 아니라 기존 코드 하나가 애초에 잘못된 칸(TOKEN_TERMINAL_REASONS)에 들어가 있었다. 잘 설계된 방어 장치도 스스로 새로운 실패 모드를 만들어낼 수 있다.

고칠 방향은 명확하다. 서버에서 purchaseState === 2purchase_retry_later(503, retryable)로 분리하고 poison pill 대상에서 뺀다. 런북의 해석도 "취소·환불"과 "대기"로 나눈다. 그리고 그 위에 서버 알림(RTDN)을 수신해 상태 변화를 능동적으로 재조회한다. 이건 8절로 이어진다.



8. TODO

솔직하게 적어둔다. 아래는 아직 TODO인 상태다.

스토어 서버 알림(RTDN / App Store Server Notifications V2)을 받지 않는다. 7절의 Pending 문제가 가장 심각한 사례지만, 서버 푸시 부재는 그보다 넓다. 현재 구조는 전부 클라이언트가 영수증을 들고 와야 동작한다. 환불·취소가 대표적이다. 사용자가 스토어에서 환불받아도 서버는 모르고, 재화는 그대로 남는다. 7절이 "지급해야 할 걸 막는" 문제라면 이건 "지급한 걸 회수 못 하는" 문제다. 서버가 상태 변화를 능동적으로 감지하려면 Pub/Sub 기반 RTDN 컨슈머를 붙여야 한다. 지금은 클라이언트가 Pending을 보내지 않는다는 사실(7절)과 사용자가 앱을 다시 켜주기를 기대고 있을 뿐이다.
글을 작성하는 과정에 발견한 부분인데, 장기적으로 도입이 필요한 부분 중 하나다.



마치며

Part 1·2에서 만든 건 성공 경로 하나였고, 이번에 붙인 poison pill·소유권 바인딩은 전부 그 예외이다. 

멱등성은 맞게 동작할 때의 이중 지급을 막는다. 하지만 운영 시간의 대부분은 구버전 클라이언트, 스펙과 다른 스토어 응답, 계정 전환 같은 틀어진 상태이고, 그때도 어디가 틀어졌는지 남기며 안전한 쪽으로 실패하는가가 진짜 어려운 부분이었다. 그 질문은 이 글의 장치에도 돌아왔다. 경합 테스트는 경합 없이 통과할 뻔했고(5절), poison pill은 "아직"을 "끝"으로 박제했다(6절). RTDN은 환불이 실제로 발생하기 시작하면 가장 먼저 갚아야 할 부채이고, 그때 다시 정리하려 한다.



추천글

hyeon_B

안녕하세요! AI 기술을 이용해 더 나은 세상을 만들어 나가고 싶은 과기원생 Hyeon이라고 합니다. 저는 앞으로 인공지능 시대에는 지식을 '활용'하는 능력이 중요해질 것이라고 생각합니다. 대부분의 일들은 인공지능이 뛰어난 모습을 보이지만, 인공지능은 데이터로 부터 연관관계를 학습하기 때문에 지식들을 새로 통합해서 활용하는 능력이 부족합니다. 인공지능이 뉴턴 전에 만들어졌다면 사과가 떨어지는 이유에 대답하지 못했을 것이고, 아인슈타인 전에 만들어졌다면 중력이 어떻게 생기는지 설명하지 못했을 것입니다. 따라서 앞으로 우리는 '본질'을 탐구하고 그 본질로부터 다른 곳에 적용하며 인공지능을 현명하게 활용해야 할 것입니다. 함께 인공지능 시대를 준비합시다!

댓글 쓰기

다음 이전

POST ADS1

POST ADS 2