구독자를 SubSovereign으로 마이그레이션하기
RevenueCat, Adapty, Qonversion 또는 자체 백엔드에서 옮겨 오시나요? 기존 구독자도 함께 옮겨집니다. 이 문서는 가져오기 자체와 — 그에 못지않게 중요한 — 전환 작업을 다룹니다. 실제 위험이 있는 쪽은 전환입니다.
먼저 읽어 주세요. 가져오기는 안전하고 반복 실행할 수 있습니다. 계획이 필요한 순간은 전환입니다. 스토어 알림을 저희 쪽으로 돌리기 전까지 갱신 알림은 여전히 이전 제공업체로 전달되기 때문입니다. §2를 실행하기 전에 §4를 계획하세요.
마이그레이션의 두 축 — 계획을 세우기 전에 읽어 주세요
모든 마이그레이션에는 두 가지 메커니즘이 있으며, 이 둘을 구분하는 것이 매끄러운 이전과 지킬 수 없는 약속을 가르는 차이입니다.
클라이언트 측 재검증이 보증입니다. 앱에 저희 SDK가 포함되면, 각 구독자의 기기는 다음 실행 시 현재 영수증을 다시 전송합니다. 식별자는 Apple 또는 Google 자신에게서 오므로 최신이고 정확하며 완전합니다. 모든 스토어에서 동작하고, 누구의 내보내기 파일도 필요 없으며, 오래된 식별자를 실어 나를 수 없습니다. 앱을 여는 모든 구독자는 구조적으로 올바르게 마이그레이션됩니다.
가져오기는 가속기입니다. 구독자가 앱을 다시 열기 전에 접근이 막히지 않도록, 그리고 첫날 리포트가 비어 있지 않도록 존재합니다. 이전 제공업체가 알고 있던 내용을 복사합니다.
둘 다 필요한 이유: 구독자가 앱을 여는 주기는 제각각입니다. 6주 동안 앱을 실행하지 않는 사람은 재검증만으로는 6주 내내 권한이 없는 것처럼 보이게 됩니다. 가져오기는 첫 순간부터 연속성을 주고, 재검증은 그것을 확정적인 사실로 만듭니다.
🚩 둘 중 하나만 택해야 한다면 재검증을 택하세요. 특정 스토어에 대해 대응 가능한 식별자를 제공하지 못하는 가져오기(아래 참조)는 해당 구독자에게 마이그레이션이 아니라 조용히 만료되는 기록일 뿐입니다.
이전 제공업체가 실제로 줄 수 있는 것
가져오기에는 originalTransactionId가 필요하며, 제공업체가 넘겨주는 값은 스토어마다 다릅니다. 계획을 세우기 전에 확인하세요:
| 제공업체 | Apple | |
|---|---|---|
| RevenueCat | ✅ 데이터 내보내기에 original_transaction_id가 포함됩니다. |
⚠️ 내보내기는 Order ID를 제공하는데, 이는 purchase token이 아닙니다 — 갱신 알림이 대조하지 못하는 다른 식별자입니다. v2 API는 더 나쁩니다. 가장 최근 트랜잭션 ID만 노출하며, 이 값은 갱신할 때마다 바뀝니다. |
| Adapty / Qonversion | 계획 전에 내보내기에 Apple의 original transaction ID가 있는지 확인하세요. | purchase token을 받는지, 주문/트랜잭션 참조를 받는지 확인하세요. 추측하지 말고 확인하세요. |
| Superwall | ⚠️ 계획 전에 확인하세요. 자체 권한·구매·영수증 검증 계층을 운영하므로 식별자는 그쪽에 존재합니다 — 관건은 내보내기가 Apple의 original_transaction_id를 주는지, 아니면 Superwall 자체 구독 참조만 주는지입니다. |
⚠️ 같은 질문, 같은 답: purchase token을 받는지 주문/트랜잭션 참조를 받는지 서면으로 확인한 뒤에 계획하세요. |
| 자체 백엔드 | 구매 시점에 저장해 둔 값 — 대개 올바른 값입니다. | 검증에 필요했으므로 이미 purchase token을 보유하고 있을 가능성이 높습니다. |
| Stripe | 해당 없음 | 해당 없음 — 저희가 Stripe 계정을 직접 읽으므로 내보내기가 필요 없습니다. §3b를 참조하세요. |
🚩 따라서 RevenueCat에서 옮겨오는 경우 Apple 쪽은 깔끔하게 가져올 수 있지만, Google 구독자는 대응 가능한 식별자로 가져올 수 없습니다. 이는 코드로 우회할 수 있는 문제가 아닙니다. 저희에게 필요한 식별자가 애초에 확보 가능한 데이터에 들어 있지 않습니다. 그 구독자들은 클라이언트 측 재검증으로 넘어옵니다 — SDK를 배포하고 알림을 전환(§4)하면, 각자가 앱을 다음에 열 때 올바르게 마이그레이션됩니다. 연속성을 위해 Apple 쪽을 가져오고, Google 쪽은 스스로 정리되도록 두세요.
1. 준비물
- 구독자 내보내기 파일. 활성 권한 하나당 한 행이어야 합니다(아래 형식 참조).
- 제품 매핑. 내보내기 파일의 모든 제품 ID를 먼저 접근 등급 중 하나에 연결해야 합니다 — 대시보드 → 해당 앱 → 제품. 🚩 가져오기는 어떤 등급을 부여할지 추측하지 않습니다. 매핑되지 않은 제품은 거부되고 보고됩니다. 추측한다면, 고객이 구매하지 않은 등급을 주장하지 못하도록 막는 바로 그 보호 장치를 우회하는 셈이기 때문입니다.
- 해당 앱을 소유한 조직의 관리자 계정. 뷰어(viewer)는 가져오기를 실행할 수 없습니다. 유료 접근 권한을 대량으로 부여하는 작업이기 때문입니다.
2. 형식
구독자 권한 하나당 JSON 객체 하나:
{
"userId": "user_12345",
"email": "person@example.com",
"store": "apple",
"productId": "pro_monthly",
"originalTransactionId": "1000000987654321",
"expiresAt": "2027-01-01T00:00:00Z",
"willRenew": true,
"status": "active"
}
| 필드 | 필수 | 비고 |
|---|---|---|
userId |
예 | 여러분의 사용자 식별자 — 앱이 저희 SDK에 전달하는 값과 동일해야 합니다. |
store |
예 | apple · google · amazon · roku · stripe |
productId |
예 | 제품 매핑에 존재해야 하며, 없으면 해당 행은 거부됩니다. |
originalTransactionId |
예 | 🚩 아래 참조. Apple의 original transaction ID, Google의 purchase token, 또는 제공업체의 구독 ID. |
status |
예 | active · trialing · grace_period · expired · lifetime · cancelled · refunded · billing_failed |
expiresAt |
아니요 | ISO 8601. lifetime인 경우 생략합니다. |
amazonUserId |
Amazon의 경우 필수 | 🚩 Amazon SDK에서 얻는 Amazon 자체 사용자 식별자이며, 여러분 앱의 사용자 식별자가 아닙니다. Amazon은 (그 식별자, receiptId) 조합으로 영수증을 조회하므로, 이 값이 없으면 권한을 다시 검증할 방법이 영영 없습니다. 이 값이 없는 Amazon 행은 거부됩니다. |
email, willRenew |
아니요 |
🚩 originalTransactionId가 성패를 가르는 필드입니다
저희 쪽 키에 그치지 않습니다. 몇 달 뒤 갱신이 해당 구독자를 다시 찾아내는 수단입니다. Apple이나 Google이 갱신에 대한 서버 알림을 보낼 때 대조하는 값이 바로 이 식별자입니다.
이 값 없이 가져온 행은 최악의 형태로 실패합니다. 첫날에는 모든 것이 정상으로 보이다가, 이후 구독자가 다음 갱신 시점에 조용히 접근 권한을 잃고, 어디에서도 오류가 발생하지 않습니다.
그래서 저희는 그런 행을 받아들이지 않고 거부합니다. 이 값 없이 가져오는 것은 지름길이 아니라 미뤄 둔 장애입니다.
🚩 모든 제공업체가 모든 스토어에 대해 이 필드를 줄 수 있는 것은 아닙니다 — 위 표를 참조하세요. 내보내기가 실제로 제공하지 못하는 경우(RevenueCat + Google이 알려진 사례), 임의의 값을 만들어 우회하지 마세요. 어떤 임시값이든 여기서 설명한 조용한 갱신 실패를 그대로 일으킵니다. 실제 식별자가 있는 스토어만 가져오고, 나머지는 클라이언트 측 재검증에 맡기세요.
3. 실행 — 언제나 시험 실행부터
이 절은 Apple, Google, Amazon, Roku에 사용하는 CSV / 내보내기 경로입니다. Stripe는 §3b로 건너뛰세요 — 저희가 계정을 직접 읽으므로 따로 만들 것이 없습니다.
가장 간단한 방법은 대시보드입니다: Subscriber Migration (Insights → Customers) → From an export. 내보내기를 CSV 또는 JSON으로 붙여넣거나 파일을 불러오세요. 이 화면은 먼저 행을 검사하고, 요청하기 전까지는 아무것도 기록하지 않습니다. 큰 파일은 알아서 나누어 처리하며, 거부된 모든 행을 행 번호와 함께 나열하므로 실제로 가지고 있는 파일에서 바로 고칠 수 있습니다. 스크립트로 처리하고 싶다면 아래 API가 정확히 같은 일을 합니다.
🚩 이 행들은 확인이 아니라 주장입니다. 내보내기는 이전 제공업체가 건네준 내용이므로, 가져온 행은 미검증 상태로 들어와 재확인 대기열(§6)에 합류하고, 이후 실제 스토어와 대조됩니다. Stripe에서 직접 읽은 행(§3b)은 다릅니다 — 이미 확인된 상태로 들어옵니다.
시험 실행이 기본값입니다. 무언가를 기록하려면 명시적으로 요청해야 합니다.
curl -X POST https://api.miimagineai.com/api/v1/apps/YOUR_APP_ID/import/subscribers \
-H "Authorization: Bearer $DASHBOARD_TOKEN" -H 'Content-Type: application/json' \
-d '{ "source": "revenuecat", "rows": [ … 최대 1000행 … ] }'
리포트가 돌아옵니다:
{
"dryRun": true, "received": 1000, "accepted": 987, "newUsers": 964,
"missingTransactionIds": 6,
"unmappedProducts": ["legacy_annual_v1"],
"rejected": [ { "index": 12, "code": "unmapped_product", "reason": "…" } ]
}
확정하기 전에 다음 세 숫자를 확인하세요:
missingTransactionIds— 갱신 시 아무것과도 연결되지 않을 행입니다. 내보내기를 고치고, 진행하지 마세요.unmappedProducts— 매핑한 뒤 다시 실행하세요.newUsers— 이번 작업으로 새로 생성될 구독자 수입니다. 숫자가 이상하다면userId필드가 잘못된 식별자일 가능성이 높습니다.
리포트가 깨끗하면 "dryRun": false를 추가해 다시 보내세요.
배치 처리와 재시작
요청당 최대 1000행을 보내세요. 대규모 마이그레이션은 여러 배치로 이루어집니다. 가져오기는 멱등적입니다 — 같은 배치를 다시 보내면 중복 생성 대신 갱신되므로, 중간에 죽은 스크립트는 그냥 처음부터 다시 실행하면 됩니다. 잘못된 행은 보고 후 건너뛰며, 옆에 있는 정상 행까지 버리는 일은 없습니다.
무엇을 거부하며, 왜 그런가
| 코드 | 의미 |
|---|---|
missing_transaction_id |
위 참조. 가장 중요한 항목입니다. |
unmapped_product |
제품을 먼저 매핑하세요. 등급을 추측하지 않습니다. |
transaction_owned_by_another_user |
해당 스토어 트랜잭션이 이 앱의 다른 계정에 이미 속해 있습니다. 트랜잭션 하나에 소유자 하나 — 보통 계정 병합이나 내보내기 데이터 오류입니다. |
invalid_store / invalid_status / invalid_expiry |
형식이 잘못된 값입니다. |
3b. Stripe — 계정을 직접 읽습니다
Stripe는 *"내 구독자는 전부 누구인가?"*에 답할 수 있는 유일한 스토어입니다. Apple, Google, Amazon, Roku는 *"이 영수증이 아직 유효한가?"*에만, 그것도 한 번에 하나씩 답합니다. 따라서 Stripe에서는 요청할 내보내기도, 만들 CSV도 없습니다. 대시보드의 Subscriber Migration(Insights → Customers)이 계정을 훑어 가져옵니다.
시작하기 전에 두 가지가 준비되어 있어야 하며, 준비되지 않았다면 화면이 알려 줍니다:
- Stripe 비밀 키. Apps → store credentials에 저장합니다. 플랫폼 차원의 대체 키는 없으며 앞으로도 없을 것입니다 — 키가 없으면 남의 데이터를 읽는 대신 가져오기가 차단된 채 실패합니다.
- 제품을 등급에 매핑. Price(
price_…) 또는 Product(prod_…) 중 어느 쪽이든 됩니다. 매핑되지 않은 제품은 추측하지 않고 거부하며, 이는 결함이 아니라 올바른 동작입니다.
그다음, 앱의 사용자 식별자가 Stripe의 어디에 있는지 알려 주세요 — 구독 메타데이터, 고객 메타데이터, 또는 Stripe 고객 ID 자체. 🚩 기본값은 없으며 저희는 추측하지 않습니다. Stripe는 고객을 cus_…로 알고 있습니다. 그것이 여러분의 어떤 사용자인지는 여러분만 알며, 잘못 지정해도 요란하게 실패하지 않습니다 — 한 구독자의 유료 접근 권한이 다른 사람의 계정에 부여될 뿐입니다.
먼저 미리보기. 미리보기는 아무것도 바꾸지 않으며, 무슨 일이 일어날지 정확히 보여 줍니다. 몇 건의 구독을 읽었는지, 몇 건이 가져와질지, 그리고 거부될 각 건을 평이한 언어로 된 사유와 함께 보여 줍니다. 가져오기 전에 거부 목록을 읽으세요. 그것은 잡음이 아니라 고쳐야 할 항목의 목록입니다.
계정이 크면 여러 요청에 걸쳐 자동으로 순회합니다. 화면은 요청 한도를 넘지 않도록 스스로 속도를 조절하며 진행 상황을 보여 줍니다.
무엇을 거부하며, 왜 그것이 옳은 답인가
| 코드 | 의미 |
|---|---|
never_paid |
구독이 incomplete 상태입니다 — 첫 결제가 완료된 적이 없습니다. 가져온다면 한 번도 결제하지 않은 사람에게 유료 접근 권한을 주는 셈입니다. |
access_expired |
상태값은 접근을 부여하지만 청구 기간이 이미 끝났습니다. 여기서 접근은 상태값으로 결정되므로, 가져오면 기한 없이 접근을 부여하게 됩니다. |
ambiguous_product |
한 구독의 항목들이 서로 다른 두 등급에 매핑됩니다. 저희는 고르지 않고 거부합니다 — 높은 쪽을 택하면 마이그레이션 경로로 들어온 등급 상승이 되고, 첫 번째를 택하면 임의적인 선택이 됩니다. |
no_user_id |
지정하신 위치에 앱 사용자 식별자가 없습니다. 위에서 설명한, 추측하면 위험한 바로 그 값입니다. |
unmapped_product |
제품을 매핑한 뒤 다시 실행하세요. |
🚩 발견되었지만 매핑되지 않은 제품은 아무것도 막지 않았더라도 보고됩니다. 지금은 아무것도 막지 않지만, 그중 하나를 매핑하는 순간 그 제품을 다른 매핑된 제품과 함께 지닌 구독은 모호해져 거부되기 시작합니다. 두 번째 실행에서 놀라는 것보다 지금 아는 편이 낫습니다.
Stripe 행이 이미 검증된 상태로 들어오는 이유
가져온 행은 보통 스토어에서 확인하기 전까지 주장으로 간주됩니다(§6). Stripe 행은 다릅니다. 여러분의 자격 증명으로 Stripe 자체 API를 통해 읽었기 때문에 이미 스토어 확인을 거친 상태로 들어옵니다. 이것은 출처에 대한 진술이지 Stripe에 대한 진술이 아닙니다 — 앞으로 저희가 직접 읽게 될 결제 처리업체도 똑같이 동작하며, 사람이 제출한 행은 어떤 스토어를 지칭하든 여전히 주장으로 남습니다.
API를 선호하신다면? 이 화면은 POST /apps/:appId/import/stripe의 클라이언트이며, 동일한 옵션은 dryRun, userIdSource, userIdKey, 그리고 페이지 이동을 위한 startingAfter입니다.
4. 🚩 전환 — 가져오기 전에 계획하세요
가져오기는 상태를 복사합니다. 미래를 바꾸지는 않습니다. 스토어 서버 알림을 SubSovereign으로 돌리기 전까지 갱신, 해지, 환불은 여전히 이전 제공업체로 갑니다.
권장 순서:
- 가져오기(이 문서). 이제 권한이 두 시스템 모두에 존재합니다.
- 병행 운영. 이전 제공업체를 계속 켜 두고 비교하세요. 아직 아무것도 옮겨지지 않았습니다.
- 저희 SDK를 사용하는 앱 빌드를 배포. 권한은 저희 쪽에서 결정되며, 동일한 트랜잭션 식별자를 가져왔기 때문에 기존 구독자는 아무 조치 없이 접근 권한을 유지합니다.
- 스토어 알림을 저희 웹훅 엔드포인트로 전환(Apple App Store Server Notifications, Google Real-Time Developer Notifications). 이것이 실제 전환 시점입니다.
- 가져오기를 다시 실행해 그 사이에 바뀐 내용을 반영하세요. 멱등적이므로 바로 이 단계를 위한 성질입니다.
- 갱신 주기 한 바퀴가 문제없이 지난 뒤에 이전 제공업체를 정리하세요.
5. 가져온 권한이란 무엇이고, 무엇이 아닌가
가져온 권한은 이전 제공업체의 말에 근거해 부여한 유료 접근 권한입니다. 저희가 Apple이나 Google에 직접 확인한 것은 아직 아닙니다.
저희는 이를 숨기지 않고 정직하게 기록합니다. 가져온 행에는 imported_at과 import_source가 남고, store_verified_at은 실제 스토어에서 확인될 때까지 비어 있습니다. 따라서 "이 중 어떤 것을 신뢰만으로 부여했는가?"는 언제든 정확한 답이 있는 질문입니다.
🚩 어떤 수치가 이상해 보일 때 이 구분이 중요해집니다. 가져온 권한과 검증된 권한은 같은 종류의 사실이 아니며, 이를 동일하게 취급한 감사는 오해를 낳습니다.
6. 가져온 내용을 스토어와 재검증하기
가져온 뒤에는 각 권한을 실제 스토어와 대조하도록 요청할 수 있습니다:
curl -X POST https://api.miimagineai.com/api/v1/apps/YOUR_APP_ID/import/verify \
-H "Authorization: Bearer $DASHBOARD_TOKEN" -H 'Content-Type: application/json' \
-d '{ "limit": 50 }'
각 행은 세 가지 결과 중 하나로 돌아옵니다:
| 결과 | 의미 |
|---|---|
| verified | 스토어가 확인했습니다. 가져온 값 대신 스토어의 만료일과 갱신 플래그를 채택합니다 — 스토어가 기준입니다. |
| mismatch | 스토어가 가져온 내용과 상충했습니다: 활성 구독이 없거나, 다른 제품에 대한 활성 구독이 있습니다. |
| unverifiable | 쓸 만한 답을 얻지 못했습니다 — 요청 한도, 자격 증명 문제, 일시적 오류 등. 이는 상충이 아닙니다. |
🚩 mismatch는 결코 접근 권한을 회수하지 않습니다. 권한 자체는 전혀 바뀌지 않으며, 검증 관련 열만 기록되고 불일치는 사람이 판단하도록 표시됩니다. *"스토어가 확인해 주지 않았다"*와 *"이 구독자에게 권한이 없다"*는 서로 다른 진술이며, 스토어 API는 여러분의 고객과 무관한 이유로 전자를 반환할 수 있습니다. 저희는 그런 근거로 결제 중인 구독자를 해지할 생각이 없습니다 — 더구나 방금 저희에게 옮겨 온 구독자라면 더욱 그렇습니다. DECISIONS.md #70을 참조하세요.
배치로 실행하세요(기본 50, 최대 200). 스토어 API에는 요청 한도가 있고 이 호출은 여러분의 스토어 자격 증명을 사용합니다. GET /apps/YOUR_APP_ID/import/status는 현재 상황을 보여 줍니다:
{ "imported": 4820, "verified": 4776, "awaiting": 44, "mismatch": 11, "unverifiable": 33 }
여기에는 아직 자동 스케줄이 없으며, 이는 의도적입니다 — 요청하지도 않은 타이머로 여러분의 스토어 자격 증명을 무인 상태에서 호출하고 싶지 않기 때문입니다. 편한 때에 실행하세요. 멱등적이며 재개할 수 있습니다.