당신의 스토어 리스팅에서 "한 번 써두면 그대로 남아 있다"고 믿는 필드가 무엇입니까?
저는 그게 전부 남아 있는 줄 알았습니다. 어제 세어봤더니 앱 40개에서 648칸이 빈 값이었습니다.
시작은 죽은 감시 봇이었습니다
앱 심사 상태를 두 시간마다 폴링하는 봇이 있습니다. 앱 46개의 버전 상태·이벤트·커스텀 페이지를 훑어 지난 스냅샷과 비교하고, 달라진 것만 알립니다.
아침에 관제판을 열었더니 그 봇만 붉게 떠 있었습니다. 마지막 성공이 전날 밤 10시 44분, 그 뒤로 11시간 무소식.
로그의 마지막 줄은 이랬습니다.
requests.exceptions.ReadTimeout:
HTTPSConnectionPool(host='api.appstoreconnect.apple.com', port=443):
Read timed out. (read timeout=30)앱 46개 × 요청 3종 = 한 번 돌 때 130여 번의 API 호출입니다. 그중 한 번이 30초를 넘겼고, 실행 전체가 죽었습니다. 재시도가 없었으니까요.
이게 이 글의 주제는 아닙니다. 다만 다음에 나올 것을 이 봇이 데려왔습니다.
고친 자리는 호출부가 아니라 그 위였습니다
requests.get이 코드에 네 군데 있었습니다. 네 군데를 각각 try/except로 감싸는 건 네 배로 틀릴 기회를 만드는 일입니다. 재시도는 모든 호출이 지나가는 한 곳에 둡니다.
SESS = requests.Session()
SESS.mount("https://", HTTPAdapter(max_retries=Retry(
total=4, connect=4, read=4, backoff_factor=2,
status_forcelist=(429, 500, 502, 503, 504),
allowed_methods=frozenset(["GET"]))))호출부는 requests.get을 SESS.get으로 바꾼 것이 전부입니다. 고쳐서 한 번 돌렸더니 34초 만에 끝나면서, 11시간 동안 아무도 몰랐던 것을 뱉었습니다.
[purro] staging 1.3.1 IN_REVIEW -> READY_FOR_SALE앱 하나가 승인돼 라이브가 돼 있었습니다. 감시 봇이 죽으면 승인도 놓칩니다. 이 봇이 스케줄 없이 방치돼 승인을 사흘 늦게 안 적이 있어서, 이번엔 바로 리스팅을 열어봤습니다.
열어보니 홍보 문구가 0자였습니다
App Store Connect의 promotionalText는 특별한 필드입니다. 심사 없이 즉시 반영되는 유일한 리스팅 필드입니다. 새 빌드를 올리지 않고도 오늘 바꾼 문구가 오늘 스토어에 뜹니다.
그 필드가 5개 로케일 전부 0자였습니다.
이상한 건, 사흘 전에 제가 그걸 손으로 채웠다는 겁니다. 버전별로 훑어봤습니다.
| 버전 | 승인일 | promo가 채워진 로케일 |
|---|---|---|
| 1.3.1 | 09-02 | 0 / 5 |
| 1.3 | 09-01 | 0 / 5 |
| 1.2 | 08-31 | 5 / 5 |
1.2까지는 살아 있고 1.3부터 없습니다. 새 버전을 만들면 애플이 이전 버전의 promotionalText를 물려주지 않습니다. 설명문·키워드·부제는 따라오는데 이것만 빈 값으로 태어납니다.
손으로 채운 문구가 사흘 뒤 두 번의 릴리스에 지워진 겁니다.
당신이라면 어떻게 하겠습니까
여기서 갈림길이 있습니다. 5칸을 다시 손으로 채우고 넘어가겠습니까, 아니면 나머지 앱을 전부 세어보겠습니까?
저는 09-01에 첫 번째를 골랐습니다. 그래서 09-04에 같은 자리에 다시 서 있었습니다.
이번엔 세어봤습니다. 계정의 앱 46개 중 라이브 버전이 있는 것들을 전부 훑어, 로케일별로 promotionalText 길이를 찍었습니다.
promo 빈 칸: 648 / 앱 40개kjh 19칸, flara 19칸, pawport 19칸… 한 앱의 문제가 아니었습니다. 릴리스를 몇 번 냈든, 마지막 릴리스 이후로 전부 비어 있었습니다.
왜 아무도 눈치채지 못했는가
같은 스캔에서 다른 필드도 같이 봤습니다. 설명문, 키워드, 릴리스 노트.
description 빈 칸: 0
keywords 빈 칸: 0
whatsNew 빈 칸: 16 (전부 어떤 앱의 1.0 — 첫 버전엔 원래 없음)구멍이 하나도 없습니다. 이유는 단순합니다. 그 필드들은 비면 심사에서 걸립니다. 설명문이 빈 채로는 제출이 안 되고, 심사자가 봅니다.
promotionalText는 심사를 안 거칩니다. 그게 이 필드의 장점이자 — 아무도 검사하지 않는 이유입니다.
심사가 보는 필드는 심사가 지켜줍니다. 심사가 안 보는 필드는 당신이 검사하지 않으면 아무도 안 봅니다.
이건 앱스토어만의 이야기가 아닙니다. 배포 파이프라인에서 게이트를 통과해야 하는 값은 게이트가 지켜주고, 게이트 밖의 값은 조용히 썩습니다. 저는 크롤러가 인라인 하드코딩된 옛 문구를 읽고 있던 것도, 저장소 버전이 스토어보다 뒤처져 있던 것도 같은 방식으로 놓쳤습니다. 전부 "아무도 검사하지 않는 자리"였습니다.
고친 방법 — 손으로 채우지 않기
같은 실수를 세 번째로 하지 않으려면 손을 떼야 합니다. 규칙은 이겁니다.
라이브 버전의 어떤 로케일이 비어 있고, 직전 버전의 같은 로케일에 문구가 있으면, 그대로 물려준다. PATCH한 뒤 되읽어 비교한다.
되읽기는 습관이 아니라 필수입니다. 외부 API의 200은 "저장했다"는 뜻이 아닙니다 — 제목 필드가 조용히 버려진 적이 있어서 이제 쓰기는 전부 되읽습니다.
r = SESS.patch(url, json={"data": {..., "attributes": {"promotionalText": text}}})
r.raise_for_status()
back = SESS.get(url).json()["data"]["attributes"].get("promotionalText") or ""
ok = back == text # 200이 아니라 이게 성공 판정세 가지를 일부러 안 했습니다.
- 문구를 새로 짓지 않습니다. 사람이 쓴 이전 버전 문구를 옮길 뿐입니다. LLM에 "홍보 문구 써줘"를 시키면 없는 기능을 광고하게 됩니다.
- 로케일을 넘나들지 않습니다. es-ES 문구를 es-MX에 자동으로 밀어넣지 않습니다. 같은 스페인어라도 어휘가 다르고, 자동으로 틀린 걸 퍼뜨리는 게 빈칸보다 나쁩니다.
- 일회성 스크립트로 두지 않았습니다. 이미 돌고 있는 2시간 폴링에 붙였습니다. 빈 칸이 없으면 아무것도 안 하므로 멱등이고, 다음 릴리스가 필드를 비우면 두 시간 안에 다시 채워집니다.
648칸을 채웠고 되읽기 불일치는 0이었습니다. 다시 전수 조사하니 남은 빈 칸은 7개였습니다.
물려받을 게 없던 7칸
이 7칸은 성격이 다릅니다. 최근 20개 버전을 전부 뒤져도 그 로케일에는 문구가 한 번도 없었습니다. 잃어버린 게 아니라 처음부터 안 쓴 것입니다.
여기서만 사람이 씁니다. 다만 백지에서 짓지 않았습니다. 그 로케일의 자기 설명문(심사를 통과한 문장)에 실제로 적힌 것만 한 줄로 줄였습니다. 예를 들어 노래 맞히기 게임의 포르투갈어 칸은, 그 로케일 설명문에 있는 "1초 → 2초 → 4초, 최대 여섯 번, 곡과 아티스트 둘 다"를 그대로 압축했습니다.
이렇게 하면 카피가 제품보다 앞서 나가지 않습니다.
자가진단 3개
- 당신의 배포에서 "심사·검증을 안 거치는 값"이 무엇입니까? 그건 지금 비어 있거나 낡아 있을 가능성이 가장 높은 값입니다.
- 그 값을 마지막으로 실제로 조회해본 게 언제입니까? 대시보드가 아니라 API로. 저는 "사흘 전에 채웠다"는 기억을 근거로 채워져 있다고 믿었습니다.
- 배포 한 번이 지운 것을 배포 다음 번이 되살립니까? 아니라면 그건 사람의 기억에 의존하는 파이프라인입니다.
솔직한 부분
이 문구가 매출을 올린다는 증거는 없습니다. 저는 홍보 문구 노출 → 설치 전환을 그 수준으로 계측하고 있지 않고, 스토어 유입 자체가 병목이 아니라는 것도 이미 알고 있습니다. 648칸을 채웠다고 내일 매출이 달라지지는 않을 겁니다.
제가 확실히 말할 수 있는 건 0자인 칸은 확실히 0을 판다는 것, 그리고 이 일의 진짜 산출물은 문구가 아니라 "심사가 안 보는 필드는 아무도 안 본다"는 규칙 하나라는 것입니다. 그 규칙은 앱스토어 밖에서도 씁니다.
그리고 하나 더. 이 전부를 데려온 건 죽은 감시 봇이었습니다. 봇이 안 죽었으면 승인 알림만 보고 리스팅은 안 열어봤을 겁니다. 어제도 매번 찍히던 로그 한 줄이 범인이었는데, 이번엔 죽은 봇이 안내자였습니다.
지금 딱 하나만 해보세요. 당신 앱 하나의 라이브 버전 로케일을 API로 조회해서, 심사를 안 거치는 필드의 길이를 찍어보십시오. 0이면 저와 같은 자리에 계신 겁니다.