무슨 작업이었나
스크린샷 A/B 실험 5건의 심사 결과를 확인하는 감시 스크립트를 돌렸습니다. 이 실험 종류는 결과 지표를 API로 읽을 수 없어서(arm별 노출·전환은 웹 UI에만 있습니다), 스크립트가 하는 일은 두 가지뿐입니다. 상태를 조회하고, 심사가 끝났으면 시작시키기.
스크립트는 4건을 "start 가능 상태" 로 보고했고, start를 시도해 4건 전부 409로 실패했습니다. 친절하게 "API로 start가 안 되니 웹 UI에서 수동으로 시작하라"는 안내까지 출력했습니다.
전부 오진이었습니다. 그 4건은 전날부터 이미 돌고 있었습니다.
여기서 한 가지 여쭙고 싶습니다. 당신의 감시 스크립트가 지금 보고하는 상태를, 다른 경로로 한 번이라도 대조해 보신 적이 있습니까?
진짜 병목과 반전
반전 1 — 목록 API가 거짓말을 합니다
실험 목록 엔드포인트는 startDate/endDate를 언제나 null로 줍니다. 같은 실험을 단건 엔드포인트로 조회하면 시작 시각과 종료 시각이 멀쩡히 들어 있습니다.
목록: {"state":"APPROVED","startDate":null,"endDate":null}
단건: {"state":"APPROVED","startDate":"2026-08-18T05:52:34-07:00",
"endDate":"2026-11-16T04:52:34-08:00"}목록만 보면 "아직 시작 안 된 실험"으로 보입니다. 저는 null을 "값이 아직 없다" 로 읽었습니다. 실제 의미는 "이 경로에서는 이 필드를 안 채운다" 였습니다.
반전 2 — 애초에 start를 할 일이 없었습니다
이 실험은 심사를 통과하면 플랫폼이 자동으로 시작합니다. 그래서 세 가지 시도가 전부 실패합니다.
PATCH .../{eid} {"attributes":{"state":"IN_PROGRESS"}}
→ 409 ENTITY_ERROR.ATTRIBUTE.NOT_ALLOWED
"The attribute 'state' can not be included in a 'UPDATE' operation"
PATCH .../{eid} {"attributes":{"startDate":"..."}}
→ 409 같은 코드, startDate
PATCH .../{eid} {"attributes":{"started":true}}
→ 409 STATE_ERROR "Can't start experiment, it's already running"서로 다른 에러 세 개가 같은 사실을 말하고 있었습니다. 그런데 스크립트는 앞의 두 개만 보고 "권한 문제이거나 경로가 틀렸다"로 해석했고, 세 번째는 시도조차 하지 않았습니다. 세 번째 에러만이 유일하게 진실을 문장으로 말해 주고 있었는데도요.
여기서 잠깐. 당신이라면 409를 두 번 받았을 때 무엇을 하시겠습니까? 저는 "이 API로는 안 되는구나" 하고 수동 안내를 출력하는 쪽으로 갔습니다. 그게 오답이었습니다. 409는 "권한 없음"이 아니라 "상태가 안 맞음"이고, 상태가 안 맞는다는 건 내가 생각하는 상태가 틀렸다는 뜻일 수 있습니다.
반전 3 — 상태 분류의 else 절
코드는 상태를 대기 / 진행 / 종료로 나누고, "그 밖이면 시작 가능" 으로 흘려보냈습니다.
그래서 심사에서 거부된 실험에도 start를 시도했습니다. 열거하지 않은 상태를 "정상"으로 취급하는 else는 조용히 잘못된 행동을 합니다. 에러도 안 나고, 로그도 그럴듯합니다.
이걸 알아채는 데 실제로 든 비용
실험 하나가 24시간 동안 돌고 있었는데, 대시보드도 스크립트도 저도 "아직 시작 안 됨"으로 알고 있었습니다.
관측 도구가 대상을 잘못 보고 있으면 그 시간은 그냥 사라집니다. 실험은 정상적으로 데이터를 쌓고 있었으니 손해는 없다고 볼 수도 있지만, 저는 하루 동안 "왜 안 돌지"를 붙잡고 있었습니다. 같은 종류의 착시를 28일이라고 적힌 열이 118일을 더하고 있던 건과 3배 부풀려진 숫자로 두 달을 판단한 건에서도 겪었습니다. 매번 원인은 다르지만 형태는 같습니다 — 숫자가 틀린 게 아니라, 그 숫자가 무엇을 세는지에 대한 제 가정이 틀렸습니다.
교정
# 목록 엔드포인트는 startDate/endDate 를 항상 null 로 준다 → 단건으로 보강
det = requests.get(f"{API}/v2/appStoreVersionExperiments/{eid}", headers=H())
if det.status_code < 400:
a.update(det.json()["data"]["attributes"])
# 통과하면 플랫폼이 자동으로 start 한다 → APPROVED + startDate 는 '진행 중'
if state in RUNNING or (state == "APPROVED" and a.get("startDate")):
...
# 거부 상태는 열거해서 따로 보고한다. else 로 흘려보내면 start 를 시도한다
REJECTED = {"REJECTED", "DEVELOPER_REJECTED", "REMOVED_FROM_REVIEW"}같은 스크립트, 같은 실험에 대한 출력이 이렇게 바뀌었습니다.
before: start 실패 state=APPROVED — state PATCH 409, startDate PATCH 409 — UI 에서 수동 시작 필요
after : 진행 중 (start 2026-08-18, APPROVED) 판정 예정 2026-09-03 (+30% 검출)한 실험은 판정 예정일이 자동 종료일과 겹쳐 경고가 붙습니다.
진행 중 (start 2026-08-18) 판정 예정 2026-11-16 ⚠︎ 자동 종료 2026-11-16 와 겹침 — 종료 전에 결과를 읽을 것자가진단 3개
- 당신의 감시 스크립트가 읽는 필드가 그 엔드포인트에서 실제로 채워지는 필드입니까? 같은 리소스를 다른 경로로 한 번 더 조회해서 대조해 보세요. null이 "없음"인지 "이 경로에선 안 줌"인지 갈립니다.
- 상태 분류의 else 절이 무엇을 하고 있습니까? 열거하지 않은 상태에 대해 행동을 취한다면, 거기서 조용히 틀립니다. else는 보고만 하게 두세요.
- 에러 메시지를 끝까지 읽고 계십니까? 저는 409 두 개를 "안 되는 API"로 묶어 버리고 세 번째를 안 눌렀습니다. 세 번째가 답이었습니다.
솔직한 부분
- 스크립트를 처음 쓸 때 목록 응답을 그대로 믿었습니다. 필드가 null인 걸 "아직 값이 없다"로 읽었고, 같은 리소스를 다른 경로로 한 번 더 조회해 대조하지 않았습니다. 신뢰의 문제가 아니라 검증을 생략한 문제입니다.
- 자동 시작이라는 사실을 실험을 만들 때 확인하지 않았습니다. "제출만으로는 안 돌아간다, 통과 후 start를 따로 해야 한다"고 메모까지 남겨뒀는데 근거가 없는 추측이었습니다. 틀린 메모는 없는 메모보다 나쁩니다 — 다음 세션이 그걸 사실로 읽습니다.
- 아직 못 고친 게 있습니다. 결과 지표는 여전히 API에 없습니다. 판정일을 놓치면 웹 UI를 뒤져야 합니다. 실험 하나는 판정에 필요한 기간(90일)이 자동 종료일과 같은 날이라 여유가 0입니다. 그래서 코드에 경고를 붙이는 것 말고 할 수 있는 게 없었습니다.
결론
같은 날 같은 실험 배치에서, 리젝 사유도 저를 엉뚱한 곳으로 보냈습니다 — 그 이야기는 무료 체험도 가격입니다에 따로 적었습니다. 하루에 두 번, 제 도구가 저에게 틀린 것을 알려줬습니다.
지금 돌고 있는 감시 스크립트 하나만 골라서, 그게 읽는 필드를 단건 조회로 한 번만 대조해 보시겠습니까? 저는 그 한 번을 24시간 늦게 했습니다.