이 글은 누구를 위한 것인가
- 구독·프리미엄을 앱 안에서 파는 iOS·Android 팀
- "새 폰인데 유료 기능이 풀렸다"는 CS를 줄이고 싶은 PM·서버 엔지니어
ios-android-push-notification-reliability처럼 백그라운드·전달 보장과 마찬가지로 결제 상태 일관성이 고민인 사람
들어가며
인앱 결제는 클라이언트 라이브러리만 연동하면 끝이 아니다. 진실은 Apple·Google이 서명한 거래, 그리고 서버가 검증한 상태에 가깝다. 기기 로컬만 믿으면 재설치·기기 변경·가족 공유·스토어 측 환불 때 권한이 틀어진다.
이 글은 스토어별 세부 API 전체를 대체하지 않고, 아키텍처 패턴을 정리한다. (버전별 메서드명은 공식 문서를 참조한다.)
1. 단일 원칙: entitlements는 서버가 소유한다
클라이언트는 다음만 담당한다.
- 결제 플로 시작·완료
- 영수증/구매 토큰을 서버로 전달
- 서버가 내려준 entitlement(이 앱에서 허용되는 권한 집합) 캐시
"구매됨"을 로컬 UserDefaults만으로 판단하지 않는다. 로컬은 UX 캐시일 뿐이다.
2. 플로 개요
[앱] 구매 완료 → 트랜잭션 식별자·영수증 데이터 추출
↓
[서버] 스토어 검증 API 호출 → 원장 업데이트
↓
[서버] 사용자 계정(또는 익명 ID)과 entitlements 매핑
↓
[앱] 서버 응답으로 프리미엄 UI 반영
구독은 시작일·갱신일·유예 기간·취소 예정까지 서버에서 해석해 내려주면 클라이언트 분기가 단순해진다.
3. 영수증·서버 알림
Apple
- App Store Server API·서버 알림(버전별)로 구독 상태 변화를 밀어넣을 수 있다.
- 클라이언트 JWS·서버 간 검증 흐름을 문서에 맞게 구현한다.
- Play Developer API로 구매·구독 상태를 조회한다.
- **Real-time developer notifications(RTDN)**로 갱신·취소를 받는다.
양쪽 모두 비동기: 즉시 검증만으로는 부족하고, 웹훅 + 폴링 조합이 안전하다.
4. 계정 바인딩
익명 사용자가 구매 후 로그인하면 구독을 어떤 계정으로 합칠지 정책이 필요하다.
- 구매 직후 강제 로그인 vs 나중에 연결
- 중복 구독 방지(가족·기기 여러 대)
충돌 시 CS 규칙(환불 아닌 이전, 크레딧 부여 등)을 미리 문서화한다.
5. 복구 UX
앱 설정에 **"구매 복구"**를 두고:
- 서버에 최신 영수증 재전송
- 스토어
restore/queryPurchasesAsync등 플랫폼 API와 조합
복구 실패 시 에러 코드를 사용자 친화적으로 번역하고, 스토어 고객센터 링크를 안내한다.
6. 오프라인·경쟁 상태
- 마지막으로 서버가 준 entitlements TTL 캐시로 오프라인 읽기 허용
- 동시 로그인 기기 수 제한 정책이 있으면 서버에서만 판단
offline-first-architecture-sqlite-sync-conflict의 충돌 해결 원칙과 같이, 결제 원장은 서버 승자로 둔다.
7. 보안·운영
- 영수증을 다른 사용자에게 재사용하려는 시도 → 서버에서 거절·로그
- 검증 실패·스토어 API 장애 시 재시도 큐와 알림
- QA용 샌드박스 환경과 프로덕션 키 분리
8. Apple: StoreKit 2와 JWS, 서버 역할
최근 iOS 쪽 흐름은 Transaction 정보가 JWS(JSON Web Signature) 형태로 클라이언트에 전달되는 방식을 중심으로 설명되는 경우가 많다. 앱은 이 데이터를 서버로 보내 검증하고, 서버는 Apple이 제공하는 공개 키·체인 검증 절차에 따라 서명을 확인한다. 세부 알고리즘·엔드포인트는 매년 문서가 갱신되므로 공식 가이드를 단일 출처로 삼는다.
실무에서 빠지기 쉬운 것은 “검증했다”에서 끝나는 경우다. 검증 이후에 사용자 ID와 매핑, 구독 그룹 ID, 만료 시각을 원장에 넣어야 다른 기기·웹과 동기화된다. 트랜잭이 **소비형(consumable)**이면 추적 단위가 달라지므로, 제품 유형별 테이블을 나누지 않으면 나중에 쿼리가 꼬인다.
9. Google Play: 구독 상태와 Developer API
Play Billing은 구매 토큰·SKU·패키지 이름으로 상태를 조회한다. 구독은 유예(grace)·보류(hold)·재시도 같은 중간 상태가 있어, “ACTIVE만 보면 된다”로는 부족하다. 서버는 스토어가 내려주는 만료 시각·자동 갱신 여부를 해석해 앱에 내려줄 단순 플래그로 변환하는 편이 클라이언트를 단순하게 만든다.
**RTDN(실시간 개발자 알림)**을 받으면 폴링만으로 놓치기 쉬운 취소·환불·업그레이드를 더 빨리 반영할 수 있다. 페이로드는 버전별로 필드가 달라질 수 있으므로, 서버에서 스키마 버전을 함께 저장해 두면 디버깅이 빨라진다.
10. 웹훅·큐: 최소 한 번(at-least-once) 전달
Apple·Google 알림은 중복 전달되거나 순서가 뒤바뀔 수 있다. 주문·결제 도메인에서 이미 쓰는 것과 같이:
- 알림을 받으면 원본 페이로드 + 해시를 로그에 넣는다.
- 멱등 키(notification UUID 또는 해시)로 중복 처리를 막는다.
- 비즈니스 로직은 작업 큐에서 순차 처리해 경쟁 상태를 줄인다.
“갱신 알림이 환불보다 먼저 온 것처럼 보인다”는 이슈가 생기면, 서버는 최종 상태를 스토어에 재질의하는 안전장치를 둘 수 있다(비용·쿼터 감안).
11. 가족 공유·기기 이전
가족 공유가 켜진 상품은 구매 주체와 실제 사용자가 달라 CS가 복잡해진다. 앱은 “이 계정이 구독 혜택을 쓸 수 있는가”를 서버 entitlements로만 판단하고, 스토어가 허용하는 범위 안에서 토큰 검증 결과를 반영한다.
새 기기로 옮길 때 복구 버튼은 단순히 로컬 복원이 아니라 스토어에 질의 → 서버 재동기화의 사용자용 이름이다. 문구를 “구매 내역 다시 불러오기”처럼 구체적으로 쓰면 리뷰·CS 문의가 줄어든다.
12. 테스트: 샌드박스의 함정
샌드박스는 짧은 구독 주기로 빠르게 시나리오를 돌릴 수 있지만, 운영 환경과 다른 타이밍(갱신 알림 지연 등)이 있다. 자동화 테스트에서는:
- 영수증 검증 실패를 모의(mock)하는 케이스
- 네트워크 타임아웃 후 재시도 케이스
- 동일 계정 양쪽 로그인 시 entitlements 일관성
을 E2E에 넣어 두면, 스토어 리뷰 전 회귀에 도움이 된다.
13. 앱 스토어 심사·정책과의 정렬
인앱 결제 우회 링크, 가격 표시 누락, 복구 버튼 숨김 등은 심사 거절로 직결된다. 기술 구현과 별개로, 제품 카피에서 구독 조건·갱신 주기·취소 방법을 명확히 하는 것이 개발과 같이 가야 한다.
14. 영수증 데이터 모델링: 한 사용자·여러 플랫폼
iOS와 Android를 동시에 지원하면, 한 사용자가 두 스토어에서 각각 구독하는 엣지 케이스가 생긴다. 서버는 user_id 아래에 플랫폼별 entitlement 레코드를 두고, 제품 정책에 따라 우선순위(예: 둘 중 하나만 유효) 또는 합산 혜택을 정의한다. UI에서 “어느 쪽 결제로 혜택이 열리는지”를 사용자에게 숨기면 CS가 터진다.
소비형 아이템은 “몇 개 샀는지”가 중요하고, 구독은 “언제까지인지”가 중요하다. 테이블을 섞으면 환불 시 단위가 안 맞는 문의가 쌓인다.
15. 관측·알림: 스토어와 서버의 드리프트
주기적으로 스토어 API를 재조회해 원장과 비교하는 **정합성 작업(reconciliation)**을 돌리면, 웹훅 누락을 뒤늦게라도 발견할 수 있다. 다음 알림을 권장한다.
- 검증 실패율이 일정 임계를 넘을 때
- 특정 SKU의 활성 구독 수가 전일 대비 급변할 때 (오타·사기·버그)
로그에는 original_transaction_id·purchase_token 일부(마스킹)·HTTP 상태를 남겨 재현을 쉽게 한다.
16. 크로스 플랫폼 웹: 스토어 밖 결제와의 관계
모바일 외에 웹 결제를 허용하는 비즈니스 모델이 있다면, 스토어 정책과의 충돌을 피하기 위해 어디에서 무엇을 살 수 있는지를 제품 레벨에서 분리한다. 기술적으로는 동일한 entitlement 서비스 뒤에 두되, 결제 수단·영수증 형식만 다르게 들어오게 할 수 있다. 이 글의 범위를 넘지만, 아키텍처는 단일 entitlements가 유지보수를 쉽게 만든다.
맺음말
인앱 결제의 완성은 결제 버튼이 아니라 상태가 언제 어디서나 같아지는 시스템이다. 서버 검증, 웹훅, 복구 플로, 계정 정책까지 한 세트로 설계하면 CS와 개발이 같은 말을 하게 된다.