RevenueCat을 사용하는 대신 나만의 IAP 시스템을 직접 구축한 이유
요약
RevenueCat 대신 직접 인앱 결제(IAP) 시스템을 구축하며 겪은 기술적 도전과 예외 케이스를 다룹니다. StoreKit 2를 활용한 서버 검증 방식과 클라이언트 구현 시 주의해야 할 보안 및 안정성 규칙을 설명합니다.
핵심 포인트
- 클라이언트는 결제 결정권이 없어야 하며 서버 검증이 필수임
- 서버에 구매 기록이 남기 전까지 프로세스를 종료하지 말 것
- 재전송 공격(Replay Attack) 방지를 위한 검증 로직 필요
- App Store Server API를 통한 영수증 직접 검증 권장
Apple은 15%를 가져갑니다. RevenueCat은 추적된 매출의 1%를 추가로 가져갑니다.
이는 6달러 중 1달러에 해당하며, 마지막 조각은 내 서버와 Apple 사이에서 JSON을 전달하는 서비스로 흘러 들어갑니다.
그래서 나는 Nutix를 위해 주말 동안 양쪽 플랫폼 모두에 대해 직접 구축했습니다. Claude Code가 구현의 대부분을 한 번에(one-shot) 처리했습니다 — 그리고 그것이 핵심입니다. 코드는 더 이상 어려운 부분이 아닙니다. 예외 케이스(edge cases)가 어려운 부분이며, 그것이 주말의 나머지 시간을 차지했습니다.
이 포스트는 바로 그 예외 케이스들에 관한 것입니다.
react-native-iap의 경우, 나는 이 포스트가 가장 유용했습니다: React Native IAP
직접 구축해야 할까요?
다음과 같은 경우 RevenueCat을 사용하세요: 매출이 발생하기 전이거나, 결제 관련 이슈로 인해 대기 상태(on call)로 있고 싶지 않은 경우입니다. 낮은 매출 단계에서는 1%가 반올림 오차 수준이며, 무료로 제대로 된 대시보드를 얻을 수 있습니다.
다음과 같은 경우 직접 구축하세요: 수수료가 실제 비용 항목(line item)으로 느껴지거나, 결제를 단순히 설정하는 것이 아니라 직접 이해하고 제어하고 싶은 경우입니다.
RevenueCat을 사용하는 일반적인 논거는 크로스 플랫폼(cross-platform) 지원, 즉 StoreKit과 Google Play 전체에 걸친 단일 권한(entitlement) 모델입니다. 나는 두 가지를 모두 직접 구현했습니다. 확실히 더 많은 작업이 필요하지만, 이는 한 분기(quarter)가 아닌 주말 정도의 작업량입니다.
단순화를 위해 아래 내용은 모두 Apple 측면을 다룹니다.
1. 클라이언트는 아무것도 결정하지 않는다
StoreKit 2는 JWS를 전달합니다. 앱은 이를 전달하고 기다립니다. 앱은 절대 이를 디코딩하지 않으며, expiresDate를 확인하지도 않고, 로컬에서 아무것도 잠금 해제하지 않습니다.
RNIap.purchaseUpdatedListener(async (purchase) => {
// StoreKit은 앱을 실행할 때마다 이전 트랜잭션을 다시 재생합니다. 이는 사용자의 의도가 아닙니다.
if (!pendingPurchaseRef.current && !pendingRedemptionRef.current) {
...
세 가지 규칙:
- 서버에 구매 기록이 남은 후에 종료하세요. 그전에 종료하면, 두 작업 사이의 크래시(crash)로 인해 결제한 사용자가 아무것도 받지 못하는 상황이 발생합니다.
- 재전송 공격(Replays)을 방지하세요. 이 검증이 없으면, 몇 주 전에 구매한 사람들에게 앱 실행 시 성공 화면이 뜨는 문제가 발생했습니다.
requestPurchase를 await 하지 마세요. 사용자가 시트(sheet)를 닫을 때 resolve되지 않습니다. 대신 리스너 쌍(listener pair)에서 resolve하세요. 현재 이를 await 하고 있다면, 프로덕션 환경에서 스피너(spinner)가 멈춰있는 현상을 겪게 될 것입니다.
2. 영수증을 믿지 말고 Apple에 물어보세요
originalTransactionId와 환경(environment)을 확인하기 위해 토큰을 디코딩(decode)하세요. 그런 다음 App Store Server API를 호출하여 실제 상태를 확인하세요.
주의사항: 인증 JWT는 60분 이상 유지될 수 없으며, 샌드박스(sandbox)는 다른 호스트를 사용합니다. 이는 디코딩을 거친 후에야 알 수 있습니다.
제 구현의 잘못된 점: Apple의 페이로드(payload)에는 Root CA까지 검증해야 하는 인증서 체인(certificate chain)이 포함되어 있습니다. 저는 이를 수행하지 않았습니다. 저는 디코딩만 하고 TLS에 의존합니다. API 경로에 대해서는 방어 가능하지만, 웹훅(webhook) 경로에 대해서는 그렇지 않습니다. 제 엔드포인트로 POST 요청을 보낼 수 있는 것이라면 무엇이든 파싱됩니다. app-store-server-library-node를 사용하여 저와 같은 실수를 피하세요.
3. originalTransactionId를 키(Key)로 사용하세요
여러분의 테이블은 캐시(cache)입니다. Apple이 진실입니다.
4. 대부분의 작업은 앱이 닫혀 있을 때 발생합니다
새벽 3시의 갱신(renewals). 결제 재시도(billing retries). 설정에서의 취소(cancellations). 환불(refunds). 이 중 어느 것도 클라이언트 호출을 발생시키지 않습니다. App Store Server Notifications V2가 필요합니다.
잘못 처리할 경우 비용이 발생하는 두 가지 서브타입(subtype)이 있습니다:
case 'DID_CHANGE_RENEWAL_STATUS':
// 유료 기간을 취소한다고 해서 액세스 권한이 바로 종료되지는 않습니다. Apple은 나중에 EXPIRED를 보냅니다.
if (subtype === 'AUTO_RENEW_DISABLED' && subscription.isTrialPeriod) {
...
유료 월의 3일째에 취소한 사용자는 30일째까지 프리미엄 상태를 유지합니다. 권한을 조기에 회수(revoke)하면 27일 치를 훔치는 셈이 됩니다.
핸들러(Handler)는 멱등성(idempotent)을 유지해야 하며, 빠르게 200을 반환해야 합니다. Apple은 그 외의 모든 상황에서 재시도(retry)를 수행합니다.
5. 프로모션 오퍼(promotional offers) 서명
Apple이 완벽하게 문서화해 두었지만 완전히 쓸모없게 만든 섹션이자, 생성된 코드가 확신을 가지고 틀렸던 유일한 지점입니다. 이를 잘못 구현하면 StoreKit은 어떤 필드가 잘못되었는지에 대한 힌트도 없이 일반적인 오류(generic error)를 내뱉으며 실패합니다.
const SEP = '\u2063'; // 보이지 않는 구분자 (INVISIBLE SEPARATOR). 문자가 아닌 이스케이프 시퀀스로 작성하세요.
const payload = [
bundleId,
...
네 개의 주석 모두 제가 직접 겪으며 조용히 실패했던 것들입니다. 구분자(separator)가 최악입니다. 제로 너비(zero-width) 문자이기 때문에, 렌더링된 문서에서 복사하여 붙여넣으면 살아남지 못합니다. 이것이 이 주제에 관한 포스팅의 절반이 틀린 이유이며, 아마도 그 포스팅들을 학습한 모델들이 틀리는 이유일 것입니다.
도입 오퍼(Introductory offers)는 이런 것이 전혀 필요하지 않습니다. Apple이 자격 요건(eligibility)을 처리하므로, 여러분은 그저 offerType: 1을 읽기만 하면 됩니다.
무엇이 망가졌나
샌드박스(Sandbox) 테스트는 비참합니다. 한 달 치 갱신이 5분 만에 이루어지고, 계정은 해제할 수 없는 상태에 빠지며, 알림(notifications)은 때때로 아예 도착하지 않습니다. 그래서 핸들러(handler)가 고장 난 것인지 이벤트(event)가 누락된 것인지 구분할 수 없습니다. 테스트를 위한 시간은 코드 작성을 위한 시간만큼이나 충분히 할당하십시오.
이것이 현재 이 프로젝트의 실제 모습입니다. 구현 코드를 생성하는 데는 오후 한나절이면 충분합니다. 하지만 생성된 코드 중 어떤 줄이 조용히 여러분의 돈을 낭비하게 만들지 파악하는 데는 주말 내내 걸립니다.
1%라는 수치는 핑계였습니다. 탭(tap)이 발생한 시점부터 제 데이터베이스의 행(row)에 기록되기까지 그 사이에 어떤 일이 일어나는지 알고 싶었던 것이 진짜 이유였습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기