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

문제: 베타테스터가 스토어 프로모션 코드로 결제했는데 S코인을 받지 못했고, 영구히 실패하는 영수증은 앱을 켤 때마다 서버로 재전송됐다.
결정: 서버가 "다시 보내도 소용없다"는 신호(terminal)를 응답에 싣고 실패한 영수증을 거부 기록으로 남겨 재전송을 끊었다. 막힌 정당한 결제는 예외 규칙과 수동 적립으로 구제했다.
결과: 영구 실패 영수증을 재전송하면 그 다음부터 terminal 응답을 받고 앱이 더 보내지 않는 것을 확인했다.

Sleuth는 AI 용의자를 심문하는 추리 게임이고, S코인은 플레이어가 테마와 스토리를 사는 데 쓰는 게임 내 재화다. 결제 서버의 일은 스토어에서 돈을 낸 사람에게 S코인을 한 번만, 정확히 주는 것이다. 이 글은 그 일이 어긋났던 사례들을 다룬다.



1. 프로모션 보상을 받지 못한 베타테스터

출시를 앞두고 베타테스터에게 각 스토어의 프로모션 기능(스토어가 코드를 발급하고 사용자가 스토어 앱에서 리딤하는 방식)으로 결제를 시험해 보게 했다. 이때 일부 테스터가 코드를 사용했는데도 S코인이 지급되지 않았다. 5월 18일부터 사흘 동안 원인을 찾아 고쳤다.

원인은 서버의 소유권 검사였다. 서버는 결제가 "지금 로그인한 이 사용자의 것"인지 확인하려고, 결제를 시작할 때 클라이언트가 스토어에 실어 보내는 계정 식별자를 대조한다. 그런데 스토어에서 리딤한 프로모션 결제에는 이 식별자가 비어 있었고(정확히는 프로모션 링크로 유입된 유저들은 앱 회원가입 따로 없이 다운만 받은 상태에서 결제가 가능해서 발생했다), 우리 서버는 이를 "계정에 묶이지 않은 결제"로 보고 거부했다. 소유권 검사의 내용은 4절에서 설명한다.

이로 인해 두 번째 문제도 확인됐다. 서버가 거부한 영수증(식별자가 불일치해 튕겨낸 것)이 앱을 켤 때마다 다시 서버로 날아왔다. 스토어 SDK(결제 라이브러리)는 클라이언트가 결제 완료 처리(소모, 확인)를 하기 전까지 영수증을 "미완료"로 들고 있다가 앱을 켤 때마다 다시 내보낸다. 지급에 실패해도 결제가 사라지지 않게 하려는 규격이다. 상품 ID가 카탈로그에 없거나, 패키지명이 허용 목록에 없거나, 서명된 트랜잭션의 상품이 클라이언트가 말한 것과 다르면 몇 번을 보내도 성공하지 않는다. 그런데 초기 구현은 이런 영구 실패를 네트워크 오류나 스토어 API 타임아웃 같은 일시 실패와 똑같이 aborted 하나로 응답했다. 클라이언트는 둘을 구분하지 못해 앱을 켤 때마다 같은 영수증을 다시 보냈다.

문제 상황: 영구 실패 영수증의 재전송 루프.

단말 하나가 앱을 켤 때마다 서비스 전체가 공유하는 Google Play Developer API 호출 할당량을 쓰고, 재전송이 계속되는 동안 결제는 처리되지 않는다.




2. 검토한 선택지

반복 재전송을 막는 방안은 다섯 가지였다.

안판단
A모든 실패를 5xx로 응답해 SDK 기본 백오프(재시도 간격 늘리기)에 맡긴다기각. 간격만 늘어날 뿐 루프가 끝나지 않는다
B클라이언트가 실패 횟수를 세어 N회 뒤 포기한다기각. 재설치나 기기 변경으로 초기화되고, 서버만으로는 고칠 수 없다
C실패한 영수증을 서버에 기록해 즉시 거부하고, 클라이언트에 "끝났다"고 알린다채택
D스토어가 환불과 상태 변화를 서버로 푸시하는 알림(Google의 RTDN, Apple의 App Store Server Notifications V2)을 받는다보류. 알림 수신용 Pub/Sub 컨슈머와 재조회 파이프라인이 필요해 범위 밖으로 뺐다
ERevenueCat(결제 검증과 구독 관리를 대행하는 SaaS)에 맡긴다기각. 자체 검증 파이프라인이 이미 있고, 아래 같은 세부 재시도 정책을 표준 옵션으로 맞추기 어렵다


3. terminal 신호와 거부 기록

클라이언트에는 재시도 중단 신호를 보내고, 서버에는 영구 실패 기록을 남겼다. 이 방식의 이름은 큐에서 빠지지 않고 계속 재처리되어 시스템을 막는 메시지를 가리키는 Poison Pill에서 가져왔다. 여기서는 영구히 실패하는 영수증이 그 역할을 한다.

해결 방안: terminal 신호와 거부 기록.

첫째, 응답에 "다시 시도해도 소용없음"을 싣는다. 서버의 에러 코드를 retryable과 terminal로 나누고, 재시도 가능한 쪽만 목록으로 정했다. 목록은 일시적 서버 과부하, 호출 한도 초과, 내부 오류 같은 코드 몇 개이고 나머지는 모두 terminal이다. 반대로 terminal을 목록으로 만들면 새 에러 코드가 기본으로 재시도 대상이 되어 루프가 조용히 되살아난다. 이 값은 에러 응답의 error.terminal 필드로 내려가고, terminal을 받은 클라이언트는 영수증을 다시 보내지 않고 SDK의 미완료 큐를 정리한다.

둘째, 실패를 기록해 스토어 API를 다시 부르지 않는다. 구버전 앱이나 변조된 클라이언트는 terminal 신호를 무시할 수 있으므로 서버에도 방어를 둔다. 영구 실패한 영수증은 처리 완료 영수증을 모아 둔 컬렉션에 status: "rejected" 행으로 남긴다. 같은 영수증이 다시 오면 스토어 API를 부르지 않고 저장된 거부를 그대로 돌려준다. 거부 행은 30일 뒤 만료 시각을 달아 Firestore TTL 정책으로 지운다.

처음에는 모든 거부를 "영원히 끝"으로 기록했는데, 이러면 정당한 재시도까지 막는다. 거부 사유를 두 종류로 나눴다. 카탈로그에 없는 상품처럼 누가 보내도 실패하는 사유는 영수증에 묶어 모든 호출자를 막는다. 소유권 불일치는 지금 요청한 uid에 대해서만 성립한다. 구매할 때와 다른 계정으로 로그인했다면 진짜 주인이 나중에 올 수 있으므로, 이 사유는 같은 uid의 재요청만 막고 다른 uid는 통과시킨다. 다른 uid에게 이미 지급이 끝난 영수증은 먼저 기록한 쪽이 이기는 선착순 잠금이 막는다. 대가로 이 컬렉션에 성공 행과 거부 행이 함께 있게 되어, 이후 모든 조회가 status를 분기해야 한다.



4. 소유권 바인딩과 정당한 결제의 예외

스토어는 영수증이 진짜이고 결제가 끝났다는 것까지만 알려 준다. 그 결제가 어떤 Firebase uid의 것인지는 모른다. 남의 영수증을 자기 uid로 보내면 그대로 통과하는 구조다. 이를 막으려고 두 스토어가 제공하는 필드(안드로이드 obfuscatedExternalAccountId, iOS appAccountToken)에 결제 시작 때 임의 식별자를 실어 보내고, 검증 응답에서 돌아온 값을 서버가 대조한다.

식별자로 uid를 그대로 쓰면 내부 식별자가 스토어에 평문으로 남고 안드로이드의 길이 제한(64자)에도 걸린다. 그래서 uid에서 uuidv5(입력 문자열에서 항상 같은 UUID를 만들어 내는 방식)로 값을 만든다. 같은 uid는 늘 같은 값이 나오므로 저장하지 않고 필요할 때 다시 계산한다. 별도 매핑 테이블을 동기화할 필요는 없다.


소유권 바인딩 흐름과 정당한 결제의 두 예외

비교는 간단했지만, 이 검사를 통과할 수 없는 정당한 결제가 두 가지 있었다. 둘 다 1절의 베타테스터 사건과 같은 유형, 곧 스토어에서 코드를 리딤한 결제다. 이 예외를 찾는 데 원칙을 구현한 시간보다 더 걸렸다.

  • Google Play 프로모 코드. 프로모 리딤은 식별자를 실어 보내는 일반 구매 흐름을 타지 않아 Google이 필드를 채우지 않는다. 그래서 프로모 결제는 검사를 건너뛰되, Google이 값을 채워 보냈다면 그때는 검증한다.
  • App Store Offer Code. App Store에서 코드로 할인과 무료 이용을 주는 기능이다. Apple의 식별자는 과거 구독 계보를 따라 상속되어, 예전 계정으로 구독했다가 새 계정으로 Offer Code를 리딤하면 이전 계정의 식별자가 따라온다. 구조적으로 항상 불일치하므로 이 경우는 소유권 강제를 풀고, 다른 유저로의 중복 지급은 선착순 잠금이 계속 막게 했다.


5. 막힌 정당한 결제자 구제와 로그

스토어에서 제공한 계정 식별자가 현재 로그인 계정과 다르면, 결제가 유효해도 소유권 검사에서 거부될 수 있다. Offer Code 상속으로 영구 불일치가 난 사용자, 구매 때와 검증 때 로그인 계정이 다른 사용자, 가족 공유로 들어온 결제가 그렇다.

구제 수단으로는 관리자가 S코인을 직접 적립하는 기능(수동 적립)을 만들었다. 이 기능을 급하게 만들면 결제 검증 전체를 우회하는 백도어가 된다. 그래서 수동 적립이 건너뛰는 것은 소유권 확인 하나뿐이고, 스토어 영수증 재검증은 그대로 강제한다. 영수증이 진짜인지는 스토어가, 이 사람의 것인지는 사람이 판단하는 구조라 위조 영수증은 관리자도 통과시키지 못한다. 운영자가 따르는 절차 문서(런북)에는 어떤 조건을 모두 만족할 때만 적립하는지, 무엇이 금지인지, 본인 확인은 어떻게 하는지를 적었다.

결제 로그에 남는 영수증 토큰과 JWS(Apple이 발급하는 서명된 거래 데이터)는 결제 증빙 그 자체다. 원문을 지우면 같은 영수증의 이벤트를 이어 볼 수 없으므로, 키를 넣은 HMAC-SHA256 지문(원문 대신 남기는 일방향 해시 값)과 마스킹 값, 길이만 남기고 민감값은 헬퍼 하나를 거쳐서만 로그로 나가게 했다.



6. 한 번만 처리되는지 측정

Part 1에서 트랜잭션 내부 체크가 동시 요청 중 하나만 지급하게 한다고 설명했다. 이 설명이 실제 동시 처리에서도 성립하는지, 테스트 서버에서 경합을 직접 만들어 확인했다.

저장소에 있던 근거는 단위 테스트 1건과 k6 멱등성 스크립트였다. 단위 테스트의 가짜 트랜잭션은 요청을 한 줄로 세워 실행하므로 실제 Firestore의 동시 처리를 재현하지 않는다. k6 스크립트는 HTTP 상태와 응답 모양만 보기 때문에 중복 지급이 났는지는 판정하지 못한다. 그래서 이번에는 판정 기준을 응답이 아니라 Firestore에 남은 상태로 옮겼다.

항목값
대상운영과 분리된 테스트 프로젝트의 결제 서버(Cloud Run), 인스턴스 1대 고정. 경로는 S코인 차감 2종(채팅 테마 100 S코인, 스토리 280 S코인)
부하와 표본한국 로컬 머신에서 같은 uid 20건을 동시에 POST. 계정 10 × 3라운드 × 2경로 = 시나리오당 60라운드, 1,200요청
판정종료 후 Firestore 조회. 원장 1건, 잔액 감소량 = 상품 가격, 권한 문서 존재. 원장이나 과금 응답이 2건 이상이면 이중 차감

첫 측정(A)은 테스트 서버의 리소스 설정(동시 처리 1, 0.3 vCPU)을 그대로 두고 호출 한도만 올렸다. 클라이언트에서는 20건이 모두 겹쳐 나갔고 결과는 60/60 exactly-once였다. 그런데 서버 로그를 보니 서버 측 같은 uid 최대 동시 처리가 1이었다. 인스턴스 1대에 동시 처리 1이라 요청이 한 건씩 처리됐고, 1,200건 중 736건은 빈 인스턴스가 없어 Cloud Run이 429로 돌려보냈다. A가 보여준 것은 하나씩 들어온 중복 요청을 한 번만 과금한다는 점까지이고, 트랜잭션끼리 부딪히는 상황은 만들어지지 않았다.

그래서 B에서는 경합을 일부러 만들었다. 동시 처리를 80, vCPU를 1로 올렸고, 같은 uid 요청을 구매 트랜잭션 앞에서 먼저 줄 세우던 Firestore 기반 리미터를 메모리 리미터로 바꿨다. 인스턴스는 1대로 유지했다.

A: 동시 처리 1 · 0.3 vCPUB: 동시 처리 80 · 1 vCPU
서버 측 같은 uid 최대 동시 처리120
exactly-once60 / 6060 / 60
이중 차감00
서버 지연 p50 / p9586 / 195ms262 / 1,726ms

같은 uid 요청 20건이 서버에서 동시에 처리되는 상태에서도 60라운드 모두 원장 1건, 차감 1회였다. 이 결과로 문서 ID 멱등키와 트랜잭션 안 재확인이 실제 경합에서 동작한다는 점을 확인했다.

지연은 늘었다. B의 p95는 1,726ms로, 같은 uid 요청 20건이 같은 문서를 두고 트랜잭션 경합을 벌이는 조건의 값이다. 다만 두 값은 같은 기준의 비교가 아니다. A는 동시 처리가 1이라 서버 안에는 항상 요청 1건만 있었고, 넘친 요청은 Cloud Run 앞단에서 429로 거절돼 지연 집계에 들어가지 않았다. B는 20건이 모두 서버 안으로 들어와 락 대기와 트랜잭션 재시도까지 지연에 포함됐다.



7. 남은 한계

Pending 결제의 오분류. Google Play의 purchaseState는 0(완료), 1(취소), 2(대기, Pending) 셋이다. 서버는 0이 아니면 모두 같은 aborted 에러로 돌려보내고, 3절의 분류기는 이를 영수증에 묶인 영구 거부로 기록한다. 취소(1)에는 맞는 처리다. 대기(2)는 취소가 아니라 "아직"이라는 뜻인데, 같은 취급을 받는다. 테스트에서도 purchaseState 값은 0이 16건, 1이 2건이고 2는 한 건도 없다.

대기 상태는 여러 경우에 생긴다. 편의점 결제나 은행 송금처럼 사용자가 나중에 돈을 내는 수단, 승인이 바로 나지 않는 통신사 결제, 카드 추가 인증(3DS)이나 은행 앱 승인을 기다리는 경우, 가족 그룹에서 보호자가 승인할 때까지 기다리는 자녀 계정 결제가 그렇다. 테스트 환경에서는 라이선스 테스터 계정이 "몇 분 뒤 승인되는 느린 테스트 카드"를 고르면 재현된다.

다만 현재 클라이언트는 Pending 결제를 서버로 보내지 않는다. 구매 상태 콜백에서 Pending을 만나면 그대로 넘어가고, 서버로 보내지도 사용자에게 알리지도 않는다. 결제가 나중에 완료되면 Google Play가 완료 상태로 같은 구매를 다시 알려 준다. 앱이 켜져 있으면 구매 스트림으로, 꺼져 있었다면 다음 실행 때 과거 구매 조회로 받아 그제야 서버에 보낸다. 그래서 서버가 처음 보는 영수증은 이미 완료 상태이고, 서버의 이 분류는 지금은 실행되지 않는 경로다. 클라이언트가 Pending을 보내도록 바뀌면 실제 결함이 된다. 그 경우 결제가 완료된 뒤 같은 영수증이 오면 서버는 저장된 거부를 terminal로 돌려주고, 클라이언트는 영수증을 소모 처리한다. Google의 3일 자동 환불은 소모 처리를 확인으로 인정해 멈추므로, 사용자는 돈을 냈는데 S코인도 받지 못하고 환불도 되지 않는다. 런북도 이 에러를 취소와 환불로만 적어 수동 적립을 금지하고 있어 정식 구제 경로가 없다.

Pending 결제가 거부로 기록되는 경로

서버 쪽 고칠 방향은 정해 두었다. purchaseState === 2를 재시도 가능한 코드로 분리해 거부 기록 대상에서 빼고, 런북의 해석도 "취소와 환불"과 "대기"로 나눈다. 다시 손댈 조건은 클라이언트가 Pending 결제를 서버로 보내도록 바꾸는 시점이고, 그 변경보다 먼저 서버 분기를 고친다.

Pending 동안 앱이 아무것도 알리지 않는다. 위 흐름에서 앱은 Pending 결제를 기록하지 않는다. 승인이 몇 분 이상 걸리는 결제라면 사용자는 결제를 진행해 놓고도 앱에서 아무 안내를 받지 못하고, 결제가 거절되면 구매가 그냥 사라져 앱에는 흔적이 남지 않는다. 클라이언트가 Pending을 다루도록 바꿀 때 사용자 안내도 함께 정한다.




추천글

hyeon_B

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

댓글 쓰기

다음 이전

POST ADS1

POST ADS 2