비즈니스 현실12 분 읽기

"7일 무료 체험" 배지는 그 계정이 체험을 받을 수 있는지 몰랐습니다

상품에 체험 오퍼가 설정돼 있으면 배지가 무조건 떴습니다. 계정 자격은 별도 조회로만 알 수 있는데 아무도 부르지 않았고, 체험을 소진한 사용자에게 '무료로 시작'이라고 써 두고 누르면 즉시 과금했습니다. iOS 8앱.

#ios#iap#verification#reality-check#gotchas
개념 도식: 상품 설정만 보는 체험 배지와, 부르지 않은 계정 자격 조회
글 내용을 요약한 개념 도식.

플릿 결제 감사에서 나온 결함 하나를 iOS 앱 8개에 고쳤습니다. 페이월의 "7일 무료 체험" 배지가 상품에 체험 오퍼가 설정돼 있으면 무조건 떠 있었습니다.

결과는 단순합니다. 체험을 이미 소진한 사용자에게 "무료로 시작"이라고 적어 두고, 누르면 즉시 과금됩니다.

질문 하나 드리겠습니다. 당신 페이월의 체험 배지는 지금 무엇을 근거로 떠 있습니까? 상품 설정입니까, 아니면 그 계정의 자격입니까?

1. 두 개가 서로 다른 것을 말합니다

storeProduct.introductoryDiscount상품 설정입니다. "이 상품에는 도입 할인이 붙어 있다"는 사실만 알려 줍니다. 그 계정이 이미 체험을 써 버렸는지와는 아무 관계가 없습니다.

계정 자격은 별도 조회(체험/도입 할인 자격 확인)로만 알 수 있습니다. 우리는 그것을 부르지 않고 있었습니다.

체험 표시가 사실상 가격이 됐던 이야기페이월이 없는 기능을 팔던 이야기와 같은 계열입니다. 페이월이 사실과 다른 것을 약속하는 형태들입니다.

2. 정본은 순수 함수 한 개입니다

static func trialEligibleIDs(_ statuses: [(String, IntroEligibilityStatus)]) -> Set<String> {
    Set(statuses.lazy.filter { $0.1 == .eligible }.map(\.0))
}

.eligible 광고합니다. 판정 실패(.unknown)와 "오퍼 없음"은 광고하지 않습니다. 자격을 모르면 약속하지 않는 쪽이 안전한 실패입니다. 조회 실패 · 미구성 · 빈 패키지도 전부 빈 집합입니다.

여기서 방향이 중요합니다. 이 게이트는 원래 무조건 보여 주던 배지 앞에 붙인 필터입니다. 배선을 틀리면 나오는 결과는 "배지가 안 뜬다"(과잉 억제) 하나뿐이고, "자격 없는데 뜬다"는 구조적으로 불가능합니다. 그래서 남은 리스크는 매출 손실이지 허위 표시가 아닙니다. 고칠 때 이 방향부터 확인하면 배포 결정이 훨씬 쉬워집니다.

3. 한 곳만 고치면 남는 자리가 실제로 있었습니다

8앱을 전수로 훑으니 같은 결함이 여러 벌로 살아 있었습니다.

  • 어떤 앱은 결제 SDK 오퍼링 경로 외에 스토어 API 직접 폴백 경로가 캡션을 따로 만들고 있었습니다. 게다가 행의 캡션만 지우면 약관 고지 문구가 여전히 체험을 약속합니다. 고지를 분기하고, 체험 절이 없는 문구를 5로케일에 추가했습니다.
  • 어떤 앱은 패키지 미로딩 시 return true였습니다. 주석에 "기본 CTA = 무료 체험"이라고 적혀 있었습니다. 스토어가 답하기 전에 체험을 광고하는 fail-open입니다.

가장 단단한 형태는 필드를 두 개로 가른 앱이었습니다.

offeredTrialDays   // 오퍼가 존재하는가 (상품 설정)
isTrialEligible    // 이 계정이 받을 수 있는가 (계정 자격)
freeTrialDays      // 계산 프로퍼티 — UI 3곳이 여기 한 자리에서 막힌다

나머지는 함수 진입부 게이트라 새 호출부가 생기면 또 샙니다. 같은 값을 두 이름으로 갈라 두면, 다음 사람이 잘못된 쪽을 집을 수 없습니다.

4. 안 고친 자리에도 근거를 남겼습니다

전부 고치는 게 항상 옳지는 않습니다.

  • 약관 안의 "체험의 미사용분은 소멸됩니다" 류는 자격 주장이 아니라 조건부 법적 고지이고, 문장을 쪼개면 그 키의 16개 로케일 번역이 날아갑니다.
  • 구매 후 알림처럼 "이미 보유한" 체험을 설명하는 문구도 그대로 뒀습니다.

5. 당신이라면 이 배포를 어떻게 검증하시겠습니까

게이트를 넣었습니다. 이제 실기기로 확인해야 하는데, 자격이 있는 계정에서는 배지가 원래처럼 뜨니 아무것도 증명되지 않습니다. 필요한 것은 자격 없는 계정입니다.

  • (a) 새 샌드박스 계정을 만들어 체험을 소진시킨다
  • (b) 마침 자격을 소진한 계정이 이미 있는지 찾아본다
  • (c) 시뮬레이터로 대신한다

(c)는 안 됩니다 — 시뮬레이터는 자격 조회에 .unknown을 줍니다. 저는 (b)였습니다. 그 기기의 샌드박스 계정이 전날 어떤 앱의 연간 체험을 소진해 둔 상태였습니다. 플릿에서 "자격 없음 → 배지 숨김"을 볼 수 있는 유일한 환경이었습니다. 결과: 연간 행에 배지가 없었습니다.

⚠️ 그런데 "배지 없음"을 그대로 증거로 쓰면 안 됩니다. 상품이 안 떠서 없는 것일 수도 있습니다. 기기의 결제 SDK 캐시를 열어 오퍼링이 실제로 로드됐음을 따로 확인했습니다 (월간 · 연간 두 상품이 매핑돼 있었습니다). 즉 두 상품을 렌더한 상태에서 배지만 없었습니다. 이 확인을 빼면 그냥 빈 화면을 성공이라고 부르는 것입니다.

6. 부작용 하나와 그 판정

게이트를 넣으면 시뮬레이터에서 자격 조회가 .unknown을 주므로 앞으로 캡처하는 페이월 컷에서 배지가 사라집니다.

판정은 "컷에는 배지를 유지"입니다. 컷의 독자는 신규 사용자이고 그들에게는 실제로 배지가 보입니다. 없으면 실제 경험을 과소 표시하는 것입니다. 우회는 게이트가 아니라 자격 집합을 채우는 지점에만 넣었습니다(런치 인자로만 켜지는 캡처 모드).

⚠️ 그 과정에서 주석 한 줄 때문에 잘못 추론했습니다. 8앱 주석에 "캡처 모드에서는 결제 SDK가 구성되지 않는다"고 써 뒀는데 사실이 아니었습니다. 구성되고 오퍼링까지 로드됩니다. 그 문장을 믿고 "패키지가 비어서 우회가 작동하지 않을 것"이라고 한 번 틀렸습니다. 주석도 검증 대상입니다.

7. 과장했던 것

처음에는 "앱마다 배선이 달라 7건이 남았다"고 적었습니다. 코드를 대조하니 8앱이 같은 한 문장이었습니다. 과장이었습니다.

남은 한계도 적어 둡니다.

  • 게이트 자체는 대개 private이고 결제 SDK 타입을 만들 수 없어 뷰 단위 유닛 테스트가 없습니다. 순수 함수만 8앱 전부 테스트가 있습니다.
  • 자격이 있는 계정에서 배지가 실제로 뜨는지(양성 경로)를 실기기로 못 봤습니다. 다만 그건 게이트 이전의 기존 동작입니다.

가는 길에 부수로 발견한 셋도 있습니다. 어떤 앱은 UI 테스트 타깃에 소스가 0개라 스킴의 테스트 액션이 유닛 통과 후에도 항상 실패로 끝나고 있었고, 어떤 앱은 체험 문구가 아예 현지화되지 않은 채 코드에서 영어를 조립하고 있었고, 어떤 앱의 카탈로그에는 참조 0인 고아 키가 있었습니다.

자가진단 3가지

  1. 체험 배지를 그리는 조건문을 열어 보십시오. 상품 객체의 필드만 보고 있다면, 그 배지는 계정을 모릅니다.
  2. 패키지가 아직 안 왔을 때 무엇을 그립니까? return true라면 스토어가 답하기 전에 약속하고 있는 것입니다.
  3. 같은 화면의 약관 · 고지 문구도 체험을 말합니까? 배지만 지우면 문장이 남습니다.

솔직한 부분

이 결함은 "코드가 틀렸다"기보다 두 개의 다른 개념에 같은 이름을 붙여 둔 것에 가깝습니다. 오퍼가 있다는 사실과 이 사람이 그것을 받을 수 있다는 사실은 다른 것인데, 변수 이름이 hasFreeTrial 하나였습니다. 이름이 하나면 코드는 그것을 하나로 다룹니다.

지금 딱 하나만 해 보세요. 페이월 코드에서 "trial"이 들어간 식별자를 전부 grep 해 보십시오. 그중 몇 개가 상품을 말하고 몇 개가 계정을 말하는지 세어 보시면 됩니다. 저는 세어 보고 나서야 필드를 갈랐습니다.

관련 글