2026년 8월 15일 · 8분 읽기
StoreKit 구독 상태 동기화: 서버와 앱 판정이 갈리던 6경로
콘솔은 pro인데 앱은 free로 보이던 증상 하나를 따라가 결함 아홉 개를 찾았습니다. 서버에서 구독을 읽는 경로 여섯 개를 하나로 모으고, 만료를 웹훅이 아니라 조회 시점에 판정하도록 바꿉니다.
2026년 8월 23일 업데이트
콘솔에는 pro인데 앱에서는 free로 보였습니다.
증상 하나를 고치려고 판정 경로를 따라갔습니다. 결함 아홉 개가 나왔습니다.
한눈에 보기
| 문제 | 콘솔에는 pro인데 앱에서는 free로 보였습니다. |
| 결정 | 관리자 구독 회수 엔드포인트를 만들었습니다. |
| 결과 | 로그아웃과 계정 전환 시 무효화하도록 고쳤습니다. |
| 제약 | 보고받은 것은 하나이고 나머지 여덟 개는 아직 아무도 겪지 않았습니다. |
서버는 맞게 내려줬고 앱이 버렸습니다
먼저 서버를 의심했습니다. 서버는 정상이었습니다.
entitlementResponse는 결제 수단과 무관하게 활성 구독의 등급을 내려주고 있었습니다. 문제는 앱에 있었습니다.
앱의 상태 병합 코드에 주석이 있었습니다.
사용량만 얹고 등급은 StoreKit 판정 그대로
의도가 있는 코드였습니다. 서버 응답이 어긋나도 Apple이 인정한 권한을 깎지 않겠다는 것입니다. 사용자가 결제했는데 서버 오류로 free가 되는 상황을 막습니다.
이 방어가 한쪽 방향만 보고 있었습니다.
관리자가 수동으로 부여한 구독은 StoreKit에 흔적이 없습니다. Apple을 거치지 않았으니 당연합니다.
그래서 서버가 pro라고 내려줘도 앱은 StoreKit 판정을 그대로 씁니다. StoreKit에는 아무것도 없으니 영원히 free입니다.
고침은 방향을 하나 더 여는 것이었습니다.
| 이전 | 이후 | |
|---|---|---|
| 서버 pro, StoreKit pro | pro | pro |
| 서버 free, StoreKit pro | pro (의도한 방어) | pro |
| 서버 pro, StoreKit 없음 | free (버그) | pro |
둘 중 높은 쪽을 택합니다. 원래 의도인 "Apple 권한을 깎지 않는다"는 유지되고, 서버만 아는 권한도 살아납니다.
서버에서 구독을 읽는 경로가 여섯이었습니다
앱을 고치고 서버를 훑었습니다. subscriptions 테이블을 읽는 독립 경로가 여섯 개였습니다.
| 경로 | 무엇을 결정하는가 |
|---|---|
entitlementResponse |
앱에 내려주는 등급 |
ResolveMessageLimit |
메시지 개수 한도 |
tierQuota |
저장 용량 한도 |
ocrTierEntitlement |
OCR 사용 권한 |
buildAdminMember |
콘솔의 회원 등급 표시 |
| 구독 상세 | 콘솔의 구독 패널 |
여섯 중 다섯이 정렬 없이 첫 행을 가져오는 방식이었습니다.
한 계정에 구독 행이 하나뿐이면 문제가 없습니다. 실제로는 여러 개가 될 수 있었습니다.
유니크 인덱스에 구멍이 있었습니다
인덱스가 (provider, original_transaction_id)로 걸려 있었습니다. Apple 구독은 이 값이 있으니 중복이 막힙니다.
수동 부여 행은 original_transaction_id가 비어 있습니다. 인덱스가 막지 못합니다.
그래서 수동 구독과 Apple 구독이 동시에 활성일 수 있었습니다.
이 상태에서 정렬 없이 첫 행을 가져오면 경로마다 다른 행을 볼 수 있습니다.
entitlementResponse → 수동 행(pro) → 앱은 pro
tierQuota → Apple 행(free) → 저장 한도는 free응답은 pro인데 실제 강제는 free가 됩니다. 도달 가능한 상태였습니다.
판정 함수를 하나로 만들었습니다
quota.EffectiveSubscription 하나를 만들고 여섯 경로가 전부 이것을 부르게 했습니다.
동작이 셋입니다.
1. status가 active이거나 in_grace인 행을 전부 로드합니다 (첫 행이 아니라 전부)
2. current_period_end가 지난 것을 걸러냅니다
3. tier.sort → quota_bytes → updated_at 순으로 순위를 매겨 하나를 고릅니다두 번째가 새로 생긴 방어입니다. 세 번째가 결정론을 만듭니다.
세 번째에 조건이 하나 더 붙어 있습니다. tier 관계가 끊어진 행은 후보에서 빼지 않고, 등급이 붙은 모든 행보다 아래에 둡니다. 데이터가 깨진 행 때문에 사용자가 권한을 잃지는 않고, 정상 행이 있으면 그것이 이깁니다.
순위를 매기는 함수는 열두 줄입니다. 앞의 키가 같을 때만 다음 키를 봅니다.
| 순위 키 | 비교 | 무엇을 보장하나 |
|---|---|---|
| tier 관계 유무 | 있는 쪽이 이김 | 깨진 행이 정상 행을 밀어내지 않는다 |
tier.sort |
큰 쪽 | 높은 등급이 이긴다 |
quota_bytes |
큰 쪽 | 같은 등급이면 용량이 큰 쪽 |
updated_at |
최신 | 위가 다 같으면 최근에 바뀐 행 |
마지막 키가 있어서 결과가 하나로 정해집니다. 앞의 셋이 같은 행이 둘 있어도 순서가 흔들리지 않습니다.
같은 계정에 행이 몇 개 있든, 어느 경로에서 부르든 같은 행이 나옵니다.
콘솔 상세는 예외를 하나 뒀습니다. 유효한 구독이 없을 때만 최신 행으로 폴백합니다. 그러지 않으면 만료된 구독을 가진 회원의 구독 패널이 비어 보입니다.
폴백에도 조건을 걸어서, 회원 등급과 구독 패널이 서로 다른 행을 가리키는 일은 생기지 않습니다.
만료를 조회 시점에 판정하게 바꿨습니다
두 번째 단계인 current_period_end 비교는 이전에 아무도 하지 않던 일입니다.
만료를 처리하는 유일한 코드가 Apple의 서버 알림 핸들러였습니다. Apple이 "이 구독 끝났다"고 알려주면 상태를 바꿨습니다.
여기에 두 가지 문제가 있었습니다.
웹훅이 유실되면 영구 pro입니다. 알림이 오지 않으면 상태가 그대로 남습니다. 네트워크 문제든 서버 재시작 타이밍이든, 알림 하나를 놓치면 그 계정은 영원히 유료입니다.
만료일을 넣은 수동 부여가 끝나지 않습니다. 관리자가 "한 달만"이라고 넣어도 Apple 알림이 올 일이 없습니다. 만료일이 지나도 아무도 확인하지 않습니다.
고침은 조회할 때마다 날짜를 비교하는 것입니다.
읽기 필터: current_period_end < now() 인 행은 유효하지 않음새벽 cron도 있습니다. 만료된 행의 status를 정리합니다.
역할이 다릅니다.
| 역할 | |
|---|---|
| 읽기 필터 | 실제 방어. 항상 정확합니다 |
| cron | 정리. status 컬럼을 현실과 맞춥니다 |
cron이 실패해도 판정은 맞습니다. 거꾸로 cron을 방어로 삼으면 cron이 죽은 동안 전부 유료가 됩니다.
cron은 새벽 1시 30분에 돕니다. 같은 파일에 등록된 다른 정리 작업이 3시와 3시 30분이고, 공유 만료는 15분마다입니다. 시각이 겹치지 않게 벌려 둔 것입니다.
유예 상태는 이 규칙을 거꾸로 씁니다
예외가 하나 있습니다. Apple이 GRACE_PERIOD 알림을 보내면 상태가 in_grace가 됩니다.
그 알림은 결제 기간이 끝나고 갱신 결제가 실패했기 때문에 옵니다. 상태를 쓰는 순간 이미 current_period_end가 과거입니다. 조회 시점 만료 규칙을 그대로 적용하면 Apple이 "카드를 다시 시도하는 동안 계속 서비스해 달라"고 부탁하는 바로 그 사용자의 권한을 끊습니다.
그래서 in_grace는 지난 기간을 살아남습니다. 대신 상한을 둡니다. 기간 종료로부터 28일입니다.
28일을 고른 근거가 주석에 있습니다. App Store Connect가 설정할 수 있는 유예 기간은 3일, 16일, 28일 셋입니다. 우리 앱의 설정이 무엇이든 정당한 유예를 자르지 않는 값은 가장 큰 28일입니다. 중간값 16일을 고르면 28일로 설정된 계정의 유예를 12일 일찍 끊습니다.
상한이 없으면 GRACE_PERIOD_EXPIRED 알림 하나를 놓친 계정이 영구 유료가 됩니다. 웹훅을 못 믿어서 만든 규칙을 예외에서 되살릴 수는 없습니다.
수동 부여를 되돌릴 수 있게 했습니다
관리자가 구독을 수동으로 줄 수 있었습니다. 되돌릴 방법이 없었습니다.
한 번 주면 데이터베이스를 직접 만져야 했습니다. 실수로 잘못된 계정에 줬다면 SQL을 써야 합니다.
관리자 구독 회수 엔드포인트를 만들었습니다.
행을 지우지 않습니다. revoked로 바꿉니다.
감사 로그도 짝을 맞췄습니다. 부여할 때 MANUAL_GRANT를 남겼으니 취소할 때 MANUAL_REVOKE를 남깁니다.
행을 지우면 기록이 사라집니다. 누가 언제 줬고 언제 뺐는지 남아야 합니다.
앱 쪽에서도 다섯 곳이 서버와 어긋나 있었습니다
서버를 정리한 뒤 앱의 나머지를 봤습니다. 다섯 곳이 나왔습니다.
오프라인 강등이 비대칭이었습니다
서버 응답이 실패하면 앱은 StoreKit 판정으로 돌아갑니다.
Apple 구독자는 문제가 없습니다. StoreKit에 기록이 있으니 pro가 유지됩니다.
수동 구독자만 free로 떨어집니다. StoreKit에 기록이 없습니다.
비행기 모드에서 유료 기능을 잃는 사람이 특정 결제 수단의 사용자로 한정되는 상태였습니다.
마지막 서버 판정을 계정 단위로 기억했다가, 서버 실패 시 그것을 유지하도록 고쳤습니다.
로그인 직후 StoreKit 기록을 서버에 재검증합니다
Apple ID로 구독하고 우리 계정으로 로그인하면 두 신원이 만납니다.
정책을 정했습니다. 구독은 Apple ID를 따라갑니다.
그래서 로그인 직후 StoreKit 트랜잭션을 서버로 보내 재검증합니다. Apple ID의 구독이 그 계정의 서버 권한으로도 등록됩니다.
이것이 없으면 기기를 바꾸거나 계정을 다시 만들 때 구독이 따라오지 않습니다.
위젯 캐시가 등급 변경 시에만 쓰였습니다
위젯은 앱 프로세스 밖에서 돕니다. 캐시를 읽습니다.
캐시를 등급이 바뀔 때만 썼습니다. 등급이 그대로면 쓰지 않습니다.
캐시에 7일 신선도 검사가 있습니다. 7일 넘으면 무효로 보고 free로 폴백합니다. 오래 오프라인인 기기가 만료된 유료 등급을 계속 보여 주는 것을 막는 장치입니다.
등급이 안 바뀌는 안정된 사용자의 위젯이 7일 뒤 free가 됩니다. 아무 일도 일어나지 않았기 때문에 캐시가 낡습니다.
판정에 성공할 때마다 쓰도록 고쳤습니다. 신선도 검사는 그대로 뒀습니다. 위젯은 StoreKit을 직접 조회할 수 없어서 앱이 판정한 결과를 App Group에서 읽는 것 말고는 방법이 없습니다.
공유 시트 경로가 개수 게이트를 우회했습니다
free 등급은 저장 개수에 한도가 있습니다. 정상 경로에서는 게이트를 통과합니다.
공유 시트에서 저장하는 경로가 다른 함수를 불렀습니다. 게이트를 지나지 않았습니다.
free 계정에서 공유 시트로 100건을 넘겨 저장하는 것이 성공했습니다.
같은 게이트를 쓰도록 통일했습니다.
정적 캐시에 계정 범위가 없었습니다
등급 판정 결과를 정적 변수에 캐시했습니다. 계정 정보가 캐시 키에 없었습니다.
계정 A로 로그인해 pro 판정을 받고 로그아웃한 뒤 계정 B로 로그인하면, B가 A의 등급을 씁니다.
로그아웃과 계정 전환 시 무효화하도록 고쳤습니다.
실패 시 pro로 여는 폴백은 유지했습니다. 판정을 못 했을 때 기능을 막지 않는다는 기존 판단입니다.
하나의 증상이 아홉 개를 데려왔습니다
시작은 "콘솔은 pro인데 앱은 free" 하나였습니다.
그 하나만 고칠 수도 있었습니다. 병합 로직 한 줄입니다.
경로를 따라간 것이 나머지 여덟 개를 찾았습니다. 그중 몇 개는 아직 사용자에게 도달하지 않은 문제였습니다.
| 결함 | 사용자에게 도달했는가 |
|---|---|
| 수동 구독이 앱에서 free | 도달 (보고받음) |
| 여섯 경로가 다른 행 | 도달 가능, 미보고 |
| 웹훅 유실 시 영구 pro | 도달 가능, 미보고 |
| 만료일 있는 수동 부여가 안 끝남 | 도달 가능, 미보고 |
| 수동 부여 취소 불가 | 운영 문제 |
| 오프라인 강등 비대칭 | 도달 가능 |
| 위젯 7일 후 free | 도달 가능 |
| 공유 시트 개수 우회 | 도달 가능 |
| 정적 캐시 계정 누출 | 도달 가능 |
보고받은 것은 하나이고 나머지 여덟 개는 아직 아무도 겪지 않았습니다.
증상 하나가 나왔을 때 그 증상만 고치면 나머지 여덟 개가 각각 언젠가 나옵니다. 그때는 또 각각 조사해야 합니다.
같은 판정을 여섯 곳에서 하고 있다는 것이 문제의 뿌리였습니다.