무엇을 하려던 작업이었나
어제(08-18) 스크린샷에 캡션을 넣는 것이 설치 전환에 영향을 주는지 재는 A/B 실험을 앱 5개에 걸어 심사에 넣었습니다. App Store의 제품 페이지 최적화(PPO) 기능입니다.
설계할 때 신경 쓴 건 하나였습니다. 변수를 캡션 하나로 고정하는 것. UI를 다시 찍으면 시드 데이터도 빌드도 달라지고, 그러면 전환 차이가 캡션 때문인지 화면이 바뀐 탓인지 못 가릅니다. 그래서 control로 쓰이는 라이브 스크린샷을 그대로 내려받아 캡션만 합성했습니다.
오늘 결과를 봤습니다. 4건 통과, 1건(끝말잇기 게임 앱) 거부. 사유는 이랬습니다.
Guideline 2.3.7 - Performance - Accurate Metadata
The screenshots for caption-v1 includes references to prices.여기서 잠깐 여쭙고 싶습니다. 당신이 실험 한 건에서 리젝을 받았다면, 가장 먼저 어디를 의심하시겠습니까? 저는 당연히 제가 새로 만든 캡션을 의심했습니다.
진짜 병목과 반전
반전 1 — 범인은 제가 바꾼 것이 아니라 손대지 않은 것이었습니다
Apple이 첨부한 문제 이미지 두 장은 제가 합성한 캡션 컷이 아니었습니다. 몇 달째 라이브에 나가 있던 페이월 스크린샷 그대로였습니다.
캡션은 결백했습니다. 실험이 한 일은 라이브 자산을 재심사대에 올려놓은 것뿐이고, 위반은 처음부터 제품 페이지에 있었습니다.
"변경분을 의심하라"는 디버깅의 기본기입니다. 여기서는 그 기본기가 정확히 오답을 가리켰습니다. 일본어 줄바꿈, 문구 창작 여부, 스크린샷 순서까지 되짚었는데 전부 깨끗했습니다. 재심사를 트리거하는 변경은 바꾸지 않은 부분까지 심사대에 올립니다. 변경분만 심사받는 게 아닙니다.
반전 2 — "무료"도 가격입니다
Apple 문구를 그대로 옮기면 이렇습니다.
references to free or discounted services are considered a price reference
즉 $34.99 만 걸리는 게 아닙니다. "7-day free trial", "Save 41%", "BEST VALUE", "Billed monthly" 가 전부 가격 언급입니다.
그리고 여기가 이상한 지점입니다. 앱 안에서는 3.1.2(c)가 청구 금액을 눈에 띄게 표시하라고 요구합니다. 같은 화면을 캡처해서 스토어에 올리면 그 정보가 위반이 됩니다. 같은 픽셀을 두 규칙이 반대 방향으로 요구합니다. 그래서 페이월을 성실하게 만든 앱일수록 스크린샷에서 걸리기 쉽습니다.
반전 3 — 라이브에 나가 있다는 건 안전하다는 뜻이 아닙니다
한 앱에서 걸렸으니 다른 앱은 어떤지 봐야 했습니다. 플릿 전체 라이브 스크린샷을 OCR로 훑었더니 가격 표기가 있는 앱이 8개 나왔습니다.
그중 둘은 똑같은 위반이 든 캡션 실험이 같은 날(08-18) 승인됐습니다. 한 심사자는 잡았고 다른 심사자는 통과시켰습니다.
집행이 확률적이라는 뜻입니다. 그리고 "지금까지 통과했으니 괜찮다"는 추론에는 근거가 없다는 뜻이기도 합니다. 라이브 상태는 규칙 준수의 증거가 아니라, 아직 안 걸렸다는 사실일 뿐입니다.
곁가지로 드러난, 더 나쁜 것
문제의 앱 iPad 세트를 열어 봤습니다. 가격만이 아니었습니다. 홈 화면 컷에는 페이월 시트가 겹쳐 Subscribe 버튼이 잘려 있었고, 단어장 컷은 93KB짜리 거의 빈 화면이 그대로 스토어에 걸려 있었습니다.
iPhone 컷은 여러 번 재작업했습니다. iPad 세트는 아무도 다시 안 봤습니다. 검사 도구도 iPhone 슬롯만 봅니다. 자동 캡처 파이프라인이 엉뚱한 화면을 찍어 넣는 사고는 전에 한 번 겪었는데, 이번엔 파이프라인이 아니라 아무도 보지 않는 슬롯이 문제였습니다.
어떻게 찾았나 — OCR 감사
44개 앱, 로케일 하나씩, 라이브 스크린샷 267장을 받아 텍스트를 뽑았습니다. 쓸 만한 OCR 도구가 없어서 Vision 프레임워크로 20줄짜리 CLI를 만들었습니다.
let req = VNRecognizeTextRequest()
req.recognitionLevel = .accurate
req.usesLanguageCorrection = false
req.recognitionLanguages = ["en-US", "ko-KR", "ja-JP"]xcrun swiftc -O ocr.swift -o ocrtool
ls live/* | xargs -n 8 ./ocrtool > ocr_live.jsonl문제의 컷에서 나온 텍스트는 이랬습니다.
Annual | BEST VALUE | $2.92 / mo • Save 41% | $34.99 | Monthly | Billed monthly |
$4.99 | 7-day free trial, then $34.99 per year | Subscribe탐지 정규식을 통화기호+숫자로 짜면 놓칩니다. 숫자 없이 "free trial"만 있는 컷이 실제로 있었습니다. 그래서 이렇게 넓혔습니다.
free trial|per year|per month|/\s?mo\b|billed|save \d|\d+%\s?off|best value|무료 체험|無料|…8개 앱에서 나온 값들입니다. $49.99/$7.99/Save 49%, $29.99/SAVE 50%, $19.99/$2.99/SAVE 44%, $39.99/Save 33%, $89.99/yr·$14.99/mo, $3.99/$29.99, Start Pro - $4.99 / month. 세 앱은 제가 만든 캡션 밴드 문구 자체에 "7-day free trial"이 들어가 있었습니다.
한 가지 구분은 필요합니다. 앱 콘텐츠로서의 금액은 위반이 아닙니다. 영수증 앱에 찍힌 지출 금액, 숙소 요금 비교 화면, 대화 속 "$62"는 그대로 둡니다. 판정 기준은 "이 앱이나 구독의 가격을 말하고 있는가"입니다.
수정 — 화면을 지우지 않고 스위치를 넣는 쪽
struct PaywallView: View {
var hidesPricing = false // 스토어 컷 전용. 프로덕션은 기본값 false 그대로
...
if !hidesPricing {
if pricesLoaded { planPicker; billingDisclosure; subscribeButton }
else { unavailableState }
}
}
// RootView: case "paywall": PaywallView(service: paywall, hidesPricing: true) {}가격 블록을 빼자 화면의 60%가 비었습니다. 그래서 스크린샷 전용 레이아웃을 따로 뒀습니다(중앙 정렬, 행간 24, 아래 여백 2배).
새 마케팅 문구는 만들지 않았습니다. Pro 혜택 목록을 상수로 뽑아 페이월과 스토어 컷이 같은 현지화 키를 쓰게 했습니다. 여기서 새 카피를 지으면 16로케일 번역 부채가 그대로 붙습니다.
캡처 파이프라인에서 걸린 것도 둘 있었습니다.
- 크기 가드 상한이 1.6MB인데 홈 화면 컷이 1.9MB였습니다. 정상 컷이 매 로케일 4번 재시도되고 로케일당 1분을 버렸습니다. 가드가 "품질"이 아니라 "우연한 바이트 수"를 검사하고 있었던 겁니다.
- 실행 중인 zsh 스크립트를 편집했습니다. zsh는 파일을 이어서 읽기 때문에 실행이 깨질 수 있습니다. 결국 죽이고 처음부터 다시 돌렸습니다. 하지 마세요.
결과: iPhone 6.9인치 16로케일 × 5컷 + iPad 5컷 재촬영, 85장 전량 OCR 가격 표기 0, 16로케일 모두 5장·COMPLETE·순서 정상, preflight blocker 0, 1.1.7 심사 대기.
자가진단 3개
지금 바로 확인해 볼 수 있는 것들입니다.
- 라이브 스크린샷을 텍스트로 뽑아 보셨습니까? 통화기호만 찾지 말고 "free trial", "save", "billed", "best value"까지 넣어 보세요. 숫자 없는 위반이 실제로 있습니다.
- iPad(또는 잘 안 쓰는 디바이스) 슬롯을 마지막으로 눈으로 본 게 언제입니까? 검사 도구가 어느 슬롯만 보는지도 같이 확인하세요.
- 재심사를 트리거하는 변경을 넣을 때, 그 변경이 함께 끌고 올라가는 자산 목록을 아십니까? 캡션 하나 바꾸는 실험이 제 페이월 컷을 심사대에 올렸습니다.
솔직한 부분
- 거부 사유를 API로 못 읽습니다.
reviewSubmissions는UNRESOLVED_ISSUES까지, 항목 상태는REJECTED까지만 줍니다. 항목 단건 GET은 403(CREATE/DELETE/UPDATE만 허용)이고,resolutionCenterThreads·reviewRejections는 공개 API에 없습니다. iris 경로는 세션 쿠키를 요구해 API 키로는 401입니다. 결국 사람이 웹 UI를 열어 원문을 붙여줘야 진단이 시작됐습니다. 자동화가 정확히 여기서 끊깁니다. - 사유를 모른 채 캡션 문구만 고쳐 재제출하고 싶은 유혹이 있었지만 하지 않았습니다. 추측 수정은 심사 사이클을 한 번 더 태웁니다. 결과적으로 캡션은 무죄였으니 판단은 옳았지만, 그 대가로 몇 시간을 사람 입력에 묶여 기다렸습니다.
- 한 앱만 고쳤습니다. 나머지 7개는 각자 다음 정기 릴리스로 미뤘습니다. 6개를 동시에 제출하면 심사 6건이 동시에 in-flight 되고, 그중 하나가 다른 이유로 막히면 전부 늦어집니다.
- 값싼 수정이 없는 지점이 있습니다. 라이브 스크린샷은 새 버전 없이 못 바꿉니다. 새 빌드 + 심사 한 사이클입니다. 그리고 실험 중인 앱은 손대면 안 됩니다 — control이 실험 도중 변하면 측정이 죽습니다. 그래서 위반 8개 중 2개는 실험이 끝나는 09-30, 11-16까지 고칠 수 없다는 걸 알면서 그냥 둡니다.
- 감사 표본이 얇습니다. 앱마다 로케일 하나(en-US 우선)만 OCR했습니다. 특정 로케일에만 남은 가격 표기는 못 잡고, 이미지로 렌더된 가격도 OCR이 놓칩니다.
- 실험의 해당 arm은 하루를 잃었고, 재제출·통과 후 판정까지 다시 54일이 필요합니다.
- 1.1.7의 릴리스 노트는 사실상 "유지 관리"입니다. 사용자에게 달라지는 게 없는 버전을 스크린샷 교체 하나 때문에 내보내는 비용은 그대로 지불했습니다.
결론
리젝 원인을 앱 안에서 찾고 있었는데 답은 리스팅에 있었던 지난번 4.3(b) 건과 구조가 같습니다. 심사는 제가 이번에 바꾼 것을 보는 게 아니라, 제 제품 페이지 전체를 지금 이 순간의 기준으로 봅니다.
그리고 같은 날 같은 실험을 감시하던 스크립트도 저에게 거짓말을 하고 있었습니다 — 그 이야기는 목록 API가 "시작 안 됨"이라고 말한 건에 따로 적었습니다.
혹시 지금 라이브에 나가 있는 스크린샷에 "free trial"이 찍혀 있진 않은지, 딱 한 앱만 확인해 보시겠습니까? 저는 8개였습니다.