스토어 집계와 운영 화면의 결제가 맞지 않았다
오늘은 화면이 하나도 바뀌지 않는 수정입니다. 시작은 구독·결제 정보가 App Store·Play 집계와 다르다는 데서였습니다. Pro 결제를 다시 확인해 보자고 통합 어드민을 뒤져 보니, 앱 DB 에 "광고 제거"를 가진 사람 수에 비해 결제 기록은 눈에 띄게 적었습니다. 운영 화면의 매출이 스토어 숫자와 맞지 않는 것도 같은 이유였습니다.
Utakoto 의 인앱 결제는 RevenueCat 이 받습니다. App Store 와 Google Play 결제를 한곳에 모아 주는 서비스이고, 결제나 환불이 일어나면 제 서버로 웹훅(서버에서 서버로 보내는 알림)을 보냅니다. 서버는 그 웹훅에 실린 고객 ID 로 "누가 샀는지"를 찾아 결제 기록을 남깁니다.
원인은 그 고객 ID 였습니다. 앱 시작 때 루트 레이아웃이 configureIAP() 를 사용자 ID 없이 부르고 있었습니다. 그러면 RevenueCat 은 기기마다 $RCAnonymousID:… 같은 익명 고객을 만듭니다. 서버는 이 ID 를 보고 어느 계정인지 알 방법이 없습니다.
기록을 거슬러 보면 이 구조는 6월에 IAP 를 처음 깔 때(24b1c06)부터 그대로였습니다. 바로 다음 커밋(00a77ac)에서 구매·복원 직후 앱이 직접 서버에 권한 상태를 올리는 sync-iap 를 붙였는데, 그 함수 주석에는 "웹훅이 정본"이라고 적어 두었습니다. 제가 기록을 이어 읽은 바로는, 앱이 올리는 쪽은 로그인된 요청이라 광고 제거가 계정에 붙었고, 정본이라던 웹훅은 처음부터 사람을 못 찾고 있었던 셈입니다. 쓰는 사람 입장에서는 광고가 잘 사라졌을 테니 드러나지 않았던 것 같습니다.
무엇을 바꿨나
세션 사용자가 정해지거나 바뀔 때마다 RevenueCat 에 그 사용자 ID 로 Purchases.logIn 을 부르는 함수를 하나 만들었습니다. Utakoto 는 처음 연 사람도 Supabase 익명 세션(게스트)으로 시작하기 때문에, 게스트에게도 사용자 ID 가 있습니다. 그래서 게스트도 똑같이 로그인시킵니다.
let identifiedAs: string | null = null;
export async function identifyIAP(userId?: string | null): Promise<void> {
if (!userId || !iapEnabled() || identifiedAs === userId) return;
identifiedAs = userId;
try {
configureIAP();
const { customerInfo } = await Purchases.logIn(userId);
if (hasAdFree(customerInfo) || hasPro(customerInfo)) await syncIAP(customerInfo);
} catch {
// retry on the next user change / launch
if (identifiedAs === userId) identifiedAs = null;
}
}
몇 가지를 이 안에 담았습니다. 키가 없는 빌드에서는 지금처럼 아무것도 하지 않습니다. 같은 사용자로 두 번 불리면 한 번만 로그인합니다. 실패하면 기억해 둔 ID 를 지워서, 다음 사용자 변경이나 다음 실행 때 다시 시도합니다. 그리고 로그인 결과에 광고 제거나 Pro 가 보이면 sync-iap 로 한 번 올립니다. 이미 익명 ID 로 산 사람은 업데이트 뒤 첫 실행에서 RevenueCat 이 익명 고객을 계정으로 합치는데, 그때 앱 DB 도 같이 맞추려는 것입니다.
부르는 자리는 인증 컨텍스트입니다. 사용자 ID 를 따라가는 effect 하나만 더했습니다.
// RevenueCat follows the account (guest too) so purchase webhooks find the user.
useEffect(() => {
identifyIAP(authedUser?.id);
}, [authedUser?.id]);
같은 커밋에 테스트도 네 개 넣었습니다. 같은 사용자로 두 번 불러도 configure 와 logIn 이 한 번씩만 불리고 광고 제거가 합쳐지면 sync-iap 를 부르는지, 다른 계정으로 바뀌면 따라가되 산 것이 없으면 동기화하지 않는지, 오프라인으로 실패한 뒤 같은 사용자로 다시 시도하는지, 사용자가 없으면 아무것도 하지 않는지입니다.
Utakoto 단어장 화면입니다. 이번 수정은 화면 변화가 없어서, 앱이 어떤 모습인지만 보여 드리는 스토어 스크린샷을 붙였습니다.
왜 이렇게 했나
intent 의 제약 중 판단이 들어간 건 로그아웃입니다. RevenueCat 에는 Purchases.logOut 이 있지만 쓰지 않기로 했습니다. 로그아웃하면 익명 고객이 새로 생기고, 그러면 오늘 고친 문제가 로그아웃한 사람에게서 다시 시작됩니다. Utakoto 의 로그아웃은 원래 Supabase 세션을 끊은 뒤 곧바로 새 게스트 세션을 여는 식이라, 사용자 ID 가 새 게스트 ID 로 바뀌고 위 effect 가 그 ID 로 logIn 합니다. RevenueCat 쪽에서도 "로그아웃"이 아니라 "다른 사람으로 로그인"이 되는 것입니다.
게스트를 빼지 않은 것도 같은 맥락입니다. 게스트도 광고 제거를 살 수 있고, 게스트가 나중에 가입하면 계정이 합쳐집니다. 그때 광고 제거를 옮기는 일은 백엔드 짝(revenuecat-user-id, 백엔드 main d7e67de5)이 맡았고, 같은 작업에서 웹훅이 사용자 uuid 를 해석하고 샌드박스 결제를 빼도록 했다고 커밋에 적혀 있습니다.
logIn 실패가 앱을 막지 않게 한 것도 제약에 적어 둔 그대로입니다. 한 번 놓쳐도 다음 사용자 변경 때 다시 시도하면 되니, 그걸 기다리느라 앱을 세울 이유가 없습니다. 네이티브 변경 없이 OTA(1.11.0)로만 내야 한다는 조건도 있었는데, react-native-purchases 에 logIn 이 이미 들어 있어서 가능했습니다.
반대로 기록에 이유가 남아 있지 않은 것도 있습니다. 루트 레이아웃의 configureIAP() 는 여전히 ID 없이 먼저 불리고, 사용자 ID 는 logIn 으로 나중에 붙입니다. configure 에 처음부터 ID 를 넘기는 방법을 따져 봤는지는 intent 에도 커밋에도 없습니다.
확인한 것과 남은 것
범위 밖으로 둔 것이 하나 있습니다. 이미 빠진 과거 결제 기록을 되살리는 일입니다. 이번 수정은 앞으로의 웹훅이 계정을 찾게 하는 것이고, 지난 기록은 비어 있는 채로 남습니다. 구매 화면도 건드리지 않았습니다.
출시는 같은 날 했습니다. intent 목록(aa05eeb)에 main 의 e3ebf97 로 OTA b4d1a2d6(1.11.0, Android·iOS)을 발행했다고 적혀 있습니다. 이 작업에는 spec·plan 이 따로 없어서, 테스트를 돌린 결과나 기기에서 확인한 항목은 기록에 남아 있지 않습니다.
판정 기준은 하나입니다. OTA 이후 새로 들어오는 구매의 웹훅이 사용자 uuid 로 오고, 통합 어드민에서 "결제 기록 있음"이 "구매함"과 같이 늘어나는지입니다(샌드박스 제외). 화면이 바뀌지 않는 수정이라, 맞았는지는 앞으로 쌓이는 결제 기록으로만 알 수 있습니다.
Utakoto 는 J-POP·애니송 가사로 일본어를 배우는 앱입니다. 가사마다 후리가나·한국어 발음·뜻이 붙고, 노래 속 단어가 JLPT 단어장으로 모여 복습할 수 있습니다. iOS·Android 에서 무료입니다.