AX 노트 · 사고 기록

20% 단계적 출시가 100%로 나갔다: Play Publisher API의 completed는 단계 출시로 되돌릴 수 없다

completed로 커밋된 릴리스는 20%로 돌아가지 않습니다. 단계적 출시는 commit 전에 status와 userFraction을 확인하십시오.

작성 SugarMount 스토어와 수익화 읽는 시간 12분 최종 업데이트

에이전트의 터미널에 찍힌 줄은 track production [('97 (1.6.029)', 'completed')]였고, 바로 아래에 commit 결과가 붙어 있었습니다. 2026년 10월 11일 15시 50분, 한글 띄어쓰기 1.6.029(versionCode 97)를 Google Play production 트랙에 20%만 내보내려던 명령이 전체 사용자 대상 출시로 커밋됐습니다. 35초 뒤 20%로 고치려는 요청은 HTTP 403으로 거부됐습니다. 원인은 에이전트가 만든 업로드 스크립트의 인자 개수 조건 하나였죠. 다음 1.6.030과 1.6.031은 내부 테스트 트랙을 거쳐 production 20%로 나갔고, 하루 지켜본 뒤 10월 18일과 19일에 100%로 올렸습니다.

근거는 한글 띄어쓰기 저장소의 1.6.029·1.6.030·1.6.031 출시 기록과 커밋 e64cd83, 그날 배포 세션의 명령 출력, Google Play Developer API 문서(edits.tracks 2026-09-28 갱신, APKs and Tracks 2025-12-18 갱신)입니다. 2026년 10월 21일에 다시 확인했습니다.

목차 (7)
  1. 20%를 받은 명령이 completed를 보냈다
  2. 20%로 되돌리는 PUT은 403 PERMISSION_DENIED였다
  3. 에이전트는 completed를 출력하고도 commit했다
  4. 내부 테스트에 먼저 올리고, 같은 빌드를 승급한다
  5. 지금 쓰는 승급 스크립트는 인자 위치로 비율을 읽지 않는다
  6. 지금 규칙과 아직 코드에 없는 확인
  7. 자주 묻는 질문

20%를 받은 명령이 completed를 보냈다

에이전트가 실행한 명령은 play3.py promote 97 "97 (1.6.029)" notes29.json production 0.2였습니다. 마지막 0.2가 단계적 출시 비율입니다. 스크립트는 그 비율을 이렇게 읽었습니다.

a=sys.argv; FR=None
if a[1] in ("upload","promote") and len(a)>(8 if a[1]=="upload" else 7): FR=a.pop()
...
**({"status":"inProgress","userFraction":float(FR)} if FR else {"status":"completed"})

위 명령의 sys.argv는 스크립트 이름까지 7개입니다. 조건은 7보다 커야 비율을 꺼내도록 되어 있었습니다. 하나 차이로 FR이 비었고, 비율이 없을 때의 기본값인 completed가 production 트랙에 들어갔습니다. 업로드 쪽 조건도 같은 방식으로 하나가 어긋나 있었습니다.

이 스크립트는 저장소 밖의 임시 파일이었습니다. 같은 세션에서 에이전트가 이전 스크립트를 sed로 고쳐 만들었고, 내부 테스트 업로드에는 비율을 넘기지 않아 그때까지 문제가 드러나지 않았습니다. (vc97은 그날 다시 만든 빌드였습니다. 오후 Galaxy S21 심층 확인에서 화면이 꺼진 채 앱을 띄우면 검은 스플래시에 멈추는 기존 버그가 나와, vc96을 버리고 고쳐서 다시 빌드했습니다.)

20%로 되돌리는 PUT은 403 PERMISSION_DENIED였다

에이전트는 곧바로 새 edit을 열어 production 트랙을 "vc97 inProgress 0.2, 이전 버전 vc94 completed"로 바꾸려 했습니다. Play의 응답은 이랬습니다.

before [('97 (1.6.029)', 'completed', ['97'], None)]
put [('97 (1.6.029)', 'inProgress', 0.2), ('94 (1.6.027)', 'completed', None)]
HTTP 403 {
  "error": {
    "code": 403,
    "message": "You cannot rollout this release because it does not allow any existing users to upgrade to the newly added APKs.",
    "status": "PERMISSION_DENIED"
  }
}

Google의 edits.tracks 문서는 completed를 "더 이상 바뀌지 않는 릴리스이며 모든 사용자에게 제공된다"고 정의합니다. userFraction은 status가 inProgress나 halted일 때만 설정할 수 있고 값은 0과 1 사이입니다. 이미 전체에 나간 릴리스에 비율을 다시 붙이는 방법도, 위 403 메시지를 설명하는 문단도 문서에서 찾지 못했습니다.

에이전트는 completed를 출력하고도 commit했다

실수는 둘이었고, 첫째인 위의 하나 차이 조건보다 둘째가 더 큰 문제였습니다. 스크립트는 PUT 응답의 트랙 상태를 track production [('97 (1.6.029)', 'completed')]로 출력한 뒤, 그 값을 검사하지 않고 같은 실행 안에서 commit까지 갔습니다.

사고 뒤 에이전트가 스크립트 조건을 고치려 한 sed 명령은 Claude Code의 자동 권한 검사에 막혔습니다. 에이전트는 그 스크립트를 더 쓰지 않겠다고 운영자에게 알리고, 사고를 출시 기록에 적었습니다(커밋 e64cd83, 15시 52분).

출시 기록에는 "멈출 수단은 핫픽스뿐"이라고 적혔습니다. 문서에는 길이 하나 더 있습니다. Google의 APKs and Tracks 문서 "Halt a completed rollout" 절에 따르면, 트랙에 먼저 게시된 completed 릴리스가 있을 때는 completed 릴리스도 halted로 바꿀 수 있고, 그러면 새 사용자와 업데이트하려는 기존 사용자에게 그 이전 릴리스가 나갑니다. 1.6.029 직전 completed 릴리스는 vc94였습니다. 저희는 이 경로를 시도하지 않았으니, Play가 받아들였을지는 모릅니다. 대신 +1일·+3일 안정성 점검 행을 모니터링 문서에 넣었습니다. 10월 15일에 한 늦은 판정은 "회귀 신호 없음"이었습니다. 다만 Play 크래시 지표는 보고 API 권한이 모자라 보지 못했습니다.

타임라인: 10월 11일 15:50 vc97이 completed로 commit, 35초 뒤 20%로 되돌리는 PUT은 HTTP 403, 15:52 커밋 e64cd83, 10월 15일 회귀 신호 없음.
20%로 내보내려던 1.6.029는 completed로 commit됐고, 35초 뒤 되돌리려는 요청은 403으로 거부됐습니다.

내부 테스트에 먼저 올리고, 같은 빌드를 승급한다

이 사고 이틀 전에 규칙 하나가 이미 생겨 있었습니다. 10월 9일 1.6.026을 Publisher API로 production 100%에 바로 올리자, 운영자가 다음부터는 내부 테스트에 먼저 올리고 그것을 프로덕션으로 승급하자고 했습니다. 직접 설치한 APK로는 Play 서명, 결제, Play 자동 보호 경로를 끝까지 볼 수 없어서입니다. 업로드는 한 번만 하고, production에는 같은 versionCode를 새 edit으로 붙입니다.

1.6.029도 이 순서를 지켰지만, internal 커밋 뒤 production으로 승급하는 두 번째 단계에서 비율이 빠졌습니다. 그다음 두 버전은 비율까지 지켰습니다.

버전production 첫 단계100%판정 근거
1.6.029 (vc97)10-11, completed(의도는 20%)처음부터10-15 늦은 판정: 회귀 신호 없음, 크래시 지표는 못 봄
1.6.030 (vc102 → vc103)10-17, inProgress 0.210-18+1일: 크래시 0, 광고 코드 변경 없음
1.6.031 (vc105)10-18, inProgress 0.210-19+1일: 크래시 0, 검사 실패 0

1.6.030의 20% 하루는 비교할 만큼 실사용자를 모으지 못했습니다. GA4에 잡힌 1.6.030 사용자 대부분이 Play 사전 출시 보고서의 테스트 기기와 저희 개발 기기였거든요. 그래서 100% 판단은 크래시 0과 광고 코드 diff가 없다는 확인에 기댔습니다.

지금 쓰는 승급 스크립트는 인자 위치로 비율을 읽지 않는다

1.6.031부터는 버전별 폴더에 release_production.py를 두고 그것으로 승급합니다. 핵심만 줄이면 이렇습니다.

commit = '--commit' in sys.argv          # 없으면 드라이런
fraction = float(sys.argv[sys.argv.index('--fraction') + 1]) if '--fraction' in sys.argv else 0.2
edit = call('POST', f'{API}/edits', {})['id']
done = False
try:
    internal = call('GET', f'{API}/edits/{edit}/tracks/internal')['releases']
    new = [r for r in internal if r.get('versionCodes') == [NEW]]
    if len(new) != 1:
        sys.exit(f'internal 에 vc{NEW} 가 없다')
    prod = call('GET', f'{API}/edits/{edit}/tracks/production')['releases']
    old = [r for r in prod if r.get('versionCodes') == [OLD] and r['status'] == 'completed']
    if len(old) != 1:
        sys.exit(f'production 에 completed vc{OLD} 가 없다')
    call('PUT', f'{API}/edits/{edit}/tracks/production', {'track': 'production', 'releases': [
        {'versionCodes': [NEW], 'status': 'inProgress', 'userFraction': fraction, ...},
        old[0]]})
    call('POST', f'{API}/edits/{edit}:validate')
    if commit:
        call('POST', f'{API}/edits/{edit}:commit'); done = True
finally:
    if not done:
        call('DELETE', f'{API}/edits/{edit}')   # 드라이런이나 실패면 edit을 버린다

달라진 점은 네 가지입니다.

비교 그림: 왼쪽 play3.py는 비율을 위치 인자로 읽고 없으면 completed로 commit했고, 오른쪽 release_production.py는 status를 inProgress로 고정하고 기본이 드라이런이다.
지금 스크립트에서는 비율이 빠져도 completed로 떨어질 길이 없고, --commit 없이는 실제로 반영되지 않습니다.

PUT 본문 모양은 10월 11일에 403을 받은 요청과 같습니다. 새 릴리스 inProgress, 이전 릴리스 completed. 1.6.031 때는 새 릴리스가 아직 아무에게도 나가지 않은 상태였고, 이 요청이 validate와 commit을 통과했습니다.

지금 규칙과 아직 코드에 없는 확인

에이전트에게 production 출시 권한을 줄 거라면, 출시 비율을 위치 인자로 받는 스크립트는 쓰지 마십시오. 비율이 빠졌을 때 기본값이 completed인 코드는 실수 한 번을 전체 출시로 바꿉니다. 이제 업로드는 내부 테스트 트랙에만 하고, 운영자가 Play에서 설치해 확인한 뒤 같은 빌드를 승급합니다. 승급은 드라이런을 먼저 돌리고, production 20%에서 하루를 본 뒤 100%로 올립니다.

아직 코드에 없는 줄도 하나 있습니다. 프로젝트 메모에는 "commit 전에 PUT 응답의 status가 inProgress이고 userFraction이 맞는지 확인한다"고 적어 두었지만, 지금 스크립트는 PUT 본문을 직접 만들 뿐 응답을 읽지 않습니다. 다음 버전 스크립트에는 이 확인을 넣을 겁니다.

tr = call('PUT', f'{API}/edits/{edit}/tracks/production', body)
mine = [r for r in tr['releases'] if r.get('versionCodes') == [NEW]]
if len(mine) != 1 or mine[0]['status'] != 'inProgress' or mine[0].get('userFraction') != fraction:
    sys.exit(f'PUT 응답이 의도와 다르다: {mine}')   # commit 전에 멈춘다

halt 경로도 다음 단계 출시 때 문서대로 절차를 적어 둘 겁니다. completed가 실수로 나가도, 핫픽스 빌드를 기다리지 않고 새 설치를 이전 버전으로 돌릴 수 있는지 그때 확인합니다. 출시 전 검사 순서는 AI가 만든 Android 앱, 출시 전에 무엇을 검사하나에, 앱 소개는 한글 띄어쓰기 페이지에 있습니다.

자주 묻는 질문

completed로 나간 Play 릴리스를 단계적 출시로 되돌릴 수 있나요?

되돌리지 못했습니다. completed인 vc97을 inProgress 0.2로 바꾸는 PUT은 403 PERMISSION_DENIED로 거부됐습니다. 문서상 남은 길은 트랙에 이전 completed 릴리스가 있을 때 completed 릴리스 자체를 halted로 멈추는 것인데, 저희는 시도하지 않아 통하는지 모릅니다.

Publisher API에서 userFraction은 어떻게 설정하나요?

edits.tracks의 release에 status를 inProgress로 두고 userFraction에 0과 1 사이 값(20%면 0.2)을 넣은 뒤 edit을 commit합니다. userFraction은 status가 inProgress나 halted일 때만 설정할 수 있습니다. 나머지 사용자가 받을 이전 completed 릴리스도 같은 releases 배열에 함께 넣습니다.

단계적 출시 비율은 나중에 어떻게 100%로 올리나요?

새 edit에서 같은 릴리스의 status를 completed로 바꾸고 commit합니다. 한글 띄어쓰기는 production 20%에서 하루 동안 크래시와 검사 실패가 0인 것을 보고 올렸습니다(1.6.030은 10월 18일, 1.6.031은 10월 19일).

이 글은 SugarMount가 직접 측정한 값으로 쓰고, 초안 작성에 AI 코딩 에이전트를 썼습니다.

스토어와 수익화 글 전체 보기 · AX 노트 전체 보기