READY_FOR_REVIEW는 «제출됨»이 아니다: App Store Connect 제출을 멱등하게 만들기
DailySudoku는 제가 만들고 있는 사이드 프로젝트입니다. 하루에 스도쿠 한 판을 푸는 앱이고, iOS와 Android와 Web을 함께 냅니다. 혼자 만드는 앱이라 릴리스도 자동화해 뒀습니다. 태그를 밀면 빌드가 올라가고 App Store Connect에 심사 제출까지 갑니다.
이 워크플로는 채널별로 다시 돌려서 복구한다는 전제로 설계했습니다. 웹 배포가 실패하면 웹만 다시, iOS 제출이 실패하면 iOS만 다시 돌리면 됩니다.
그런데 iOS 제출에서 그 전제가 깨져 있었습니다.
App Store Connect가 제출 요청을 받았는데 응답이 유실되면, 재실행이 중복 제출로 거부됩니다. 그러니까 복구하려고 누르는 재시도가 오히려 확실한 실패가 됩니다. 성공했는지 실패했는지 모르는 상태가, 다시 돌리는 순간 실패로 확정되는 셈입니다.
Android 쪽은 같은 지적을 받아 이미 고쳐져 있었고 iOS만 남아 있었습니다. 그래서 멱등 가드를 붙였습니다. 그리고 그 가드가 세 가지 방향으로 배신했습니다.
이 글은 그 세 번의 배신과, 결국 무엇을 계약으로 고정했는지를 정리한 기록입니다.
추측 대신 원본을 읽었습니다
가드를 짜려면 「지금 이 버전이 어떤 상태인가」를 조회해야 합니다. 그런데 상태 조회 API의 값 집합을 정확히 몰랐습니다.
여기서 추측했으면 틀렸을 겁니다. Gemfile.lock이 고정한 fastlane 버전의 spaceship 소스를 직접 읽었더니 이렇게 돼 있었습니다.
attr_accessor :app_store_state # Deprecated in App Store Connect API specification 3.3
attr_accessor :app_version_state # ← 현행
이름이 비슷한 접근자가 둘인데 하나는 deprecated입니다. 그리고 값 집합도 다릅니다.
| deprecated | 현행 |
|---|---|
READY_FOR_SALE |
READY_FOR_DISTRIBUTION |
PROCESSING_FOR_APP_STORE |
PROCESSING_FOR_DISTRIBUTION |
구버전 이름으로 분기를 짰다면 모든 상태가 「모르는 값」으로 떨어졌을 겁니다. spaceship 자신이 버전을 조회할 때 현행 필드로 필터한다는 것이 근거였습니다.
함정이 하나 더 있었습니다. 접근자는 스네이크 케이스입니다. appVersionState는 JSON 키이고, Ruby 쪽에서는 매핑이 그 이름을 없앱니다. 카멜 케이스로 쓰면 그냥 nil이 나오는데, Ruby는 nil에 대한 비교를 오류로 만들지 않으니 문법 검사로는 안 잡힙니다. 조용히 「모르는 상태」로 흐를 뿐입니다.
가드는 세 방향으로 배신했습니다
멱등 가드가 실패하는 방향은 하나가 아니었습니다. 세 번 다 형태가 달랐습니다.
① 「준비됨」을 「제출됨」으로 읽었습니다
READY_FOR_REVIEW라는 상태가 있습니다. 이름만 보면 「심사 준비 완료 = 제출됨」으로 읽힙니다. 그래서 처음에는 그렇게 분류했습니다.
틀렸습니다. 그 값은 *“제출할 준비가 됐다”*이지 *“심사 큐에 들어갔다”*가 아닙니다. 앞선 실행이 버전 준비와 제출 객체 생성까지 끝내고 최종 제출 호출 직전에 죽으면 정확히 이 값이 나옵니다.
그것을 「이미 제출됨」으로 읽으면 재시도가 제출 단계 앞에서 성공으로 빠져나갑니다. 그리고 제출되지 않은 버전을 제출했다고 보고합니다.
멱등성을 얻으려다 「조용한 미제출」을 만들 뻔한 것입니다. 재시도가 확실한 실패가 되는 것보다 나쁩니다. 실패는 눈에 보이지만 이건 안 보입니다.
여기에 딸린 문제가 하나 더 있었습니다. 같은 마케팅 버전이 다른 빌드로 이미 제출돼 있을 수 있습니다. 수동으로 제출했거나, 앞선 실행이 다른 번호로 올렸거나. 그대로 성공을 돌려주면 요청한 빌드가 아닌 바이너리가 심사 중인데 제출했다고 보고하게 됩니다.
「그 빌드가 App Store Connect 어딘가에 있다」와 「그 빌드가 이 버전에 선택돼 있다」는 다른 축입니다. 앞의 것만 확인하고 있었습니다.
② 불리언으로 접어 거부를 성공으로 만들었습니다
①을 고치고 나니 상태 하나만으로는 부족하다는 게 드러났습니다.
제출 도구가 실제로 하드 실패하는 조건은 버전 상태가 아니라 **앱 단위의 「진행 중 심사 제출」**이었습니다.
if app.get_in_progress_review_submission(platform:)
UI.user_error!("Cannot submit for review - A review submission is already in progress")
앞선 실행이 제출 객체를 만들고 죽은 경우가 정확히 「READY_FOR_REVIEW + 진행 중 제출」 조합입니다. 상태 하나만 보면 이 조합을 다룰 수 없습니다. 그래서 두 축을 함께 보게 바꿨습니다.
그런데 그 조회 결과를 nil인지 아닌지로 접었습니다. 이게 두 번째 배신입니다.
그 조회는 세 가지 상태를 함께 잡습니다 — 심사 대기, 심사 중, 그리고 **「심사가 문제를 제기함」**입니다. 불리언으로 접는 순간 세 번째가 사라집니다. 그러면 버전이 거부 상태여도 빌드와 릴리스 노트만 맞으면 「이미 제출됨」으로 빠져나가, 거부된 릴리스를 성공으로 보고할 수 있었습니다.
고친 방향은 접근자가 상태를 그대로 돌려주게 하고, 차단이 제출을 이기도록 순서를 구조에 박는 것이었습니다.
같은 라운드에서 분류 누락도 하나 나왔습니다. 상태 열거형에 값이 15개인데 14개만 분류하고 있었습니다. 빠진 것은 「승인됨」이었고, 승인 후 수동 출시를 기다리는 중에 재시도가 오면 그 자리에서 죽었을 겁니다.
여기서 상수를 하나 더 적는 것은 근본 수정이 아닙니다. 다음 누락도 똑같이 태그를 미는 순간에 드러날 테니까요. 그래서 self-test가 고정된 젬의 상태 열거형을 전수 열거해 미분류가 0인지 단언하게 했습니다. 젬을 올릴 때 상태가 추가돼 있으면 심사 큐 앞이 아니라 self-test에서 죽습니다.
③ 가드가 차단기로 뒤집혔습니다
세 번째는 방향이 반대입니다. 이번엔 가드가 너무 잘 막았습니다.
제출된 릴리스 노트가 이번에 보내려는 것과 같은지 대조하는 가드가 있습니다. 로케일별로 비교합니다. 그런데 그 검증이 절반만 보고 있었습니다. 테스트 표에 양쪽 로케일 키를 손으로 써넣어 두는 바람에, 「비교 함수가 맞게 도는가」만 확인하고 「우리 키와 API 응답이 같은 이름 공간인가」는 아무도 안 봤습니다.
철자가 어긋나면 어떻게 될까요. 모든 로케일이 「제출본에 없음」이 됩니다. 그러면 안전을 위해 멈추도록 만든 가드가 모든 재시도를 영구히 막는 차단기가 됩니다. 거짓 성공을 막으려던 것이 아무것도 성공하지 못하게 만드는 것입니다.
더 나쁜 것은 그때 나오는 메시지였습니다. 「릴리스 노트가 낡았다」라고 알려주니, 사람은 멀쩡한 App Store Connect의 노트만 들여다보게 됩니다. 진짜 원인인 키 이름 불일치는 화면 어디에도 없습니다.
고친 뒤에는 「전부 없음 + 제출본은 비어 있지 않음」이라는 조합을 따로 판별해서, 그 경우 양쪽 키 집합을 그대로 찍습니다. 사람이 봐야 할 것을 사람에게 보여주는 것이 수정의 핵심이었습니다.
결국 무엇을 고정했나
세 번을 거치고 나서 남은 계약은 네 가지입니다.
첫째, 판정 축을 분리합니다. 버전 상태와 앱 단위 진행 중 제출은 서로 다른 질문입니다. 하나로 합치면 앞선 실행이 중간에 죽은 시나리오를 표현할 수 없습니다.
둘째, 모르는 값은 fail-closed입니다. 알려진 상태를 전부 분류하고, 「그 밖」은 사람에게 넘깁니다. Apple이 상태를 추가했을 수 있고, 모르는 채로 제출에 흘리면 심사 큐를 눈감고 건드리는 셈입니다.
셋째, 차단이 제출을 이깁니다. 두 축 어느 쪽에서든 멈춰야 할 신호가 있으면 멈춥니다. 이 우선순위를 조건문 순서가 아니라 구조에 박아 뒀습니다.
넷째, 드라이런은 멈추지 않습니다. 검증 전용 모드에서는 상태를 보고만 합니다. 리허설 시점의 상태가 태그 시점의 상태와 같다는 보장이 없으니, 여기서 막으면 통과든 실패든 태그 시점에 대해 아무것도 보장하지 못한 채 리허설만 못 돌게 됩니다.
확인한 방법
이 글은 2026년 8월 16일 DailySudoku develop 브랜치의 iOS 릴리스 자동화를 기준으로 합니다.
제출 판정은 자격 증명 없이 도는 순수 함수로 빼뒀습니다. 그래서 self-test가 실제 App Store Connect를 건드리지 않고 진리표 전체를 잠급니다.
- 축1(버전 상태) 분류 표를 케이스별로 단언
- 축2(진행 중 제출) 표를 별도로 단언
- 고정된 젬의 상태 열거형을 전수 열거해 미분류 0 단언
대조군을 따로 넣은 것이 핵심입니다. 축1 표만으로는 두 번째 축을 아예 읽지 않는 구현도 통과합니다. 파라미터로 받고 무시하면 그만이니까요. 그래서 「같은 상태가 축에 따라 답이 갈리는가」를 검사하는 줄을 넣었고, 두 줄이 같은 답을 내면 실패합니다.
릴리스 노트 대조 쪽은 뮤테이션을 양방향으로 돌렸습니다. 비교 함수를 항상 거짓으로 바꾸면 구멍이 생기고, 항상 참으로 바꾸면 과잉 차단이 생깁니다. 둘 다 실제로 잡히는 것을 확인했습니다. 이 기법은 검증 스크립트의 자기검증 글에서 따로 정리했습니다.
못 잰 것
검증 기록이 덮는 것은 판정 로직까지입니다. 제출 요청은 도달했는데 응답만 사라지는 상황을 실제로 만들어 통과시켜 본 기록은 없습니다. 그 조건을 인위적으로 재현할 방법이 마땅치 않기 때문입니다. 가드의 정당성은 그 상황이 남기는 상태 조합을 정확히 읽는다는 데 기대고 있지, 그 상황을 통째로 밟아본 데 기대고 있지 않습니다.
상태 분류의 정확성은 고정된 젬 버전에 대해서만 검증됩니다. 전수 열거 단언이 잡아주는 것은 「젬이 아는 상태를 우리가 전부 분류했는가」이지 「젬이 App Store Connect의 현재 상태를 전부 아는가」가 아닙니다. 후자는 젬을 올리기 전까지 알 수 없습니다.
사이드 프로젝트라서 배운 것
멱등성은 「두 번 불러도 괜찮다」가 아니라 「무엇이 이미 일어났는가를 정확히 읽는다」입니다. 처음에 저는 앞의 정의로 접근했고, 그래서 「이미 제출됨처럼 보이면 넘어간다」를 짰습니다. 그 결과가 조용한 미제출이었습니다. 읽기가 틀리면 멱등 가드는 실패를 감추는 장치가 됩니다.
가드는 두 방향으로 고장 납니다. 안 막아야 할 때 막지 않는 것만 생각하기 쉽지만, 막지 말아야 할 때 막는 것도 같은 무게의 결함입니다. 특히 후자는 「안전하게 실패했다」처럼 보여서 더 오래 살아남습니다. 뮤테이션을 양방향으로 돌린 이유가 그것입니다.
이름을 믿지 말고 소스를 읽어야 할 때가 있습니다. READY_FOR_REVIEW는 이름만 보면 제출된 상태 같고, 접근자 두 개는 이름이 거의 같습니다. 둘 다 이름이 아니라 원본을 읽어서 갈렸습니다. 릴리스 파이프라인처럼 되돌리기 어려운 경로에서는, 소스를 확인하는 비용이 태그를 잘못 미는 비용보다 쌉니다.
그리고 이미 조사해 둔 것을 다시 조사했습니다. 두 번째 축의 존재는 이 저장소에 이미 조사 기록으로 남아 있었습니다. 저는 그 문서를 안 읽고 상태 하나만 보고 고쳤고, 그래서 한 라운드를 더 썼습니다. 혼자 만드는 프로젝트에서 과거의 기록은 남이 남긴 문서가 아니라 몇 달 전의 내가 남긴 문서인데, 그렇다고 더 잘 읽게 되지는 않더군요.
출처
이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.