비즈니스 현실12 분 읽기

패스를 산 뒤 2분 동안, 앱은 그 사실을 몰랐습니다

구매 성공 콜백과 권한 개방은 다른 사건입니다. 스토어 영수증 검증이 끝나기 전에 동기화를 부르면 서버는 아무것도 쓰지 않고, 그 2분 동안 사용자는 이미 산 것을 한 번 더 삽니다.

#iap#monetization#reality-check#verification#gotchas
개념 도식: 구매 성공 시각과 서버가 구독을 활성으로 기록한 시각 사이의 2분 공백
글 내용을 요약한 개념 도식.

한 안드로이드 앱에 새 사용자 두 명이 16분 사이에 결제했습니다. 그 앱의 누적은 46일 · 사용자 99명 · 거래 8건입니다. 그 16분에 거래가 3건 나왔습니다.

"어디서 온 사람들인가"를 알고 싶어서 두 사람의 전 경로를 서버 로그로 한 줄씩 따라갔습니다. 퍼널 이벤트, 크레딧 원장, 처리된 거래, 구독 상태. 유입 출처는 끝내 못 알아냈습니다. 대신 그날 실제로 잃은 것이 나왔습니다.

질문 하나를 먼저 드리겠습니다. 결제 SDK의 구매 성공 콜백이 돌아온 그 순간, 당신의 서버는 그 사용자를 유료로 알고 있습니까?

1. 한 명은 같은 것을 두 번 냈습니다

시각 사건
02:05:37 월간 패스 구매 성공(체험, 만료일 기록됨)
02:05:49 페이월이 다시 떴다
02:06:07 페이월
02:06:40 페이월
02:07:22 페이월 — 이 중 한 번은 패스를 다시 눌러 "이미 구매한 상품" 오류를 받았다
02:07:36 크레딧을 추가 구매
02:07:40 서버가 비로소 구독을 active로 기록
이후 리딩 0건, 이탈

그 사용자가 쓰려던 기능은 패스에 이미 포함돼 있었습니다. 서버가 모르는 2분 동안 그 기능이 유료로 굴러갔고, 사용자는 두 번 냈고, 아무것도 못 쓰고 나갔습니다.

2. 동기화를 "부르는 것"과 권한이 "열리는 것"은 다른 사건입니다

구매 성공 직후에 서버 동기화를 부르기는 했습니다. 코드만 보면 빈틈이 없습니다.

그런데 그 시점에는 스토어 영수증 검증이 아직 끝나지 않았습니다. 검증이 끝나지 않으면 서버는 아무것도 쓰지 않습니다. 응답은 200으로 돌아옵니다. 잔액도 정상적으로 옵니다. 다만 그 응답 안에 패스가 없을 뿐입니다.

코드는 두 사건을 같은 것으로 취급했습니다. 한 번 부르고, 돌아왔으니 됐다고 본 것입니다. 결제는 됐는데 Pro가 안 켜지던 이야기결제가 문제가 아니었던 이야기와 증상은 같은데 원인이 다릅니다. 저 둘은 권한 이름 매핑이 틀린 것이고, 이번 것은 검증이 아직 안 끝난 것입니다. 매핑을 아무리 봐도 안 나옵니다.

3. 그리고 페이월은 이미 산 것을 계속 팝니다

화면 쪽에도 결함이 하나 더 있었습니다. 페이월은 오퍼링의 패키지를 그대로 그립니다. 현재 활성 구독을 보지 않습니다.

그래서 서버가 뒤늦게 active를 기록한 뒤에도 같은 상품을 계속 팔 수 있는 구조였습니다. 2분의 공백은 그 결함을 네 번 노출시킨 계기였을 뿐입니다.

4. 다른 한 명은 아예 못 샀습니다

두 번째 사용자는 월간 패스를 3번 시도해 전부 구매 무효 오류를 받았습니다. 포기하고 크레딧을 샀습니다.

가설은 이렇습니다. 두 상품의 오퍼가 7일 체험 하나뿐이고, 그 오퍼의 대상이 "앱 내 구독 경험이 없는 신규"로 걸려 있습니다. 앱은 오퍼 토큰이 박힌 패키지만 구매하므로, 체험 자격이 없는 계정에는 살 수 있는 경로가 아예 없습니다.

여기서 헷갈리기 쉬운 지점이 있습니다. 익명 식별자는 재설치마다 새로 생깁니다. 그래서 우리 DB에 없는 사람도 스토어에서는 자격이 없을 수 있습니다. 우리 쪽 "신규"와 스토어 쪽 "신규"가 다른 것을 셉니다.

5. 당신이라면 어디부터 손대시겠습니까

로그는 여기까지입니다. 이제 고칠 차례인데, 후보가 셋입니다.

  • (a) 구매 직후 페이월을 무조건 닫는다
  • (b) 구매 성공 시 클라이언트가 권한을 낙관적으로 켠다
  • (c) 서버가 실제로 권한을 기록할 때까지 재시도하고, 그 구간에는 유료 사용을 붙잡아 둔다

(a)는 증상만 가립니다. 다시 열면 또 팝니다. (b)는 검증에 실패한 결제까지 유료로 만들어 줍니다. 답은 (c)였는데, 여기에 함정이 하나 있습니다.

6. 고친 형태 — 서버 변경은 0줄이었습니다

동기화 응답이 처음부터 세 가지를 주고 있었습니다. 잔액, 적립분, 그리고 패스. 클라이언트가 잔액만 읽고 나머지를 버리고 있었습니다.

// 전: balanceOf(response)            → 패스 필드를 읽지 않는다
// 후: parseSyncPayload(response)     → { balance, credited, pass }

거기에 지수 백오프 재동기화(1 · 2 · 4 · 8 · 8초, 5회)를 붙이고, 그 구간에는 페이월 진입과 유료 사용을 붙잡아 둡니다.

⚠️ 정착은 반드시 끝나야 합니다. 5회가 끝나도 권한이 안 열리면 화면을 풀어야 합니다. 영구히 가리면 결제 수단이 통째로 사라져 앱을 못 쓰게 됩니다. 사용자를 지키려고 넣은 장치가 사용자를 가두는 형태가 여기서 제일 쉽게 나옵니다.

페이월 결함은 화면 결함이라 렌더 테스트로 고정했습니다. 고친 부분을 되돌려 RED를 먼저 확인했고, 겨냥한 2건만 실패했습니다(연간 구매와 크레딧 재구매는 통과). 과잉 차단이 아니라는 뜻입니다.

판정 로직은 스토어 · 결제 SDK 타입 의존이 0인 순수 함수로 떼어 유닛 테스트에 고정했습니다. 신규 26건 포함 62건, 실패 0.

7. 덤으로 잡힌 계측 결함 둘

로그를 읽다가 같이 나온 것들입니다. 둘 다 숫자를 거짓말로 만드는 종류입니다.

  • 취소 판정을 오류 메시지의 contains("cancel")로 하고 있었습니다. 시스템 언어가 한국어면 취소가 실패로 집계됩니다. 한국 사용자 비중이 큰 앱에서 "결제 실패율"이 부풀어 있었던 겁니다. 오류 코드로 바꿨습니다.
  • paywall_shown이 두 사람 35분에 15회 찍혔습니다. 이걸 분모로 쓰면 전환율이 거짓이 됩니다. 이번 사건 자체가 그 15회를 만든 원인이기도 합니다.

아직 모르는 것

정직하게 적습니다.

  • 유입 출처는 우리 데이터로 답할 수 없었습니다. 요청 로그에 식별자와 시각만 있고 국가 · 로케일 · 앱 버전 · 설치 출처 · 첫 실행이 어디에도 없습니다. 확실한 것은 둘 다 그날 첫 흔적이고, 같은 버전이고, 열 개 기능 중 하나만 썼다(17회 전부)는 것뿐입니다.
  • 구매 무효 오류의 근본 원인은 미확정입니다. 되돌림(체험 실패 → 기본 플랜)을 붙였지만, 자격 없는 실계정으로 왕복해 보기 전에는 사라졌다고 말할 수 없습니다. 그래서 실패 이벤트에 스토어 원문 응답과 오퍼 식별자를 함께 남겼습니다. 다음 사례에서 갈립니다.
  • 이 가설을 다른 앱에 심지 않았습니다. 다른 스토어는 자격 없는 오퍼를 응답에서 걸러 주기 때문에 같은 결함이 성립하지 않을 가능성이 큽니다.

자가진단 3가지

  1. 구매 성공과 권한 개방 사이의 시간을 재 보셨습니까? 두 이벤트의 타임스탬프 차이입니다. 0이라고 가정하고 짠 코드가 대부분입니다.
  2. 페이월이 현재 활성 구독을 보고 있습니까? 보지 않는다면, 이미 산 사람에게 같은 것을 파는 화면이 지금 라이브에 있습니다.
  3. 결제 실패를 어떻게 분류합니까? 오류 메시지 문자열로 분류한다면, 영어가 아닌 시스템 언어에서 그 분류가 뒤집힙니다.

솔직한 부분

이건 대단한 발견이 아닙니다. 사용자 두 명의 로그를 시각 순서로 늘어놓은 게 전부입니다. 그런데 그 두 명이 아니었으면 이 2분을 영영 못 봤을 겁니다. 대시보드에는 "거래 3건"으로만 찍혀 있었고, 그 숫자는 좋아 보였습니다.

실기기 실계정 구매 왕복은 아직 남아 있습니다. 그래서 이 글은 "고쳤습니다"가 아니라 "이렇게 생긴 구멍이 있습니다"에 가깝습니다.

오늘 딱 하나만 해 보세요. 최근 결제한 사용자 한 명을 골라, 구매 성공 이벤트와 서버가 권한을 기록한 이벤트의 시각을 나란히 찍어 보십시오. 그 사이에 페이월 노출이 몇 번 있는지도 같이요. 저는 네 번이었습니다.

관련 글