🐝매일 한입
개발일지📖 10분 읽기

Utakoto — 결제 웹훅이 계정을 못 찾던 이유, RevenueCat 을 사용자 ID 로 로그인시키기

RevenueCat 고객이 기기마다 익명 ID 로 만들어져 서버 웹훅이 결제를 계정에 남기지 못했습니다. 게스트까지 포함한 Supabase 사용자 ID 로 logIn 하고, 익명으로 산 광고 제거는 합쳐질 때 sync-iap 로 맞추는 OTA 를 냈습니다.

📖 10분 읽기
#Utakoto#RevenueCat#인앱 결제#웹훅#React Native#OTA#1인개발

스토어 집계와 운영 화면의 결제가 맞지 않았다

오늘은 화면이 하나도 바뀌지 않는 수정입니다. 시작은 구독·결제 정보가 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 단어장 화면, N5부터 N1까지 JLPT 레벨 필터와 노래 속 단어 목록, 단어마다 뜻이 붙어 있다 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 에서 무료입니다.

📚 관련 글

💬 댓글