App Store 심사 제출을 다시 실행해도 안전한 판정 규칙

DailySudoku는 하루에 한 문제씩 스도쿠를 내는 앱이고, 혼자 개발해서 App Store, Google Play, 웹에 배포해요. 태그를 push하면 빌드 업로드부터 iOS 심사 요청까지 자동으로 진행돼요. 채널 하나가 실패하면 그 채널만 다시 실행해서 복구하도록 설계했어요.

이 설계에는 빈틈이 있었어요. 서버가 요청을 처리했는데 클라이언트가 응답을 받지 못한 채 끝나면, 다시 실행한 요청은 중복이라는 이유로 거절돼요. Android 쪽은 같은 문제를 먼저 고쳤고, 이번에는 iOS에 멱등 판정을 넣었어요. 아래 내용은 2026년 8월 16일 develop 브랜치의 iOS 자동화를 기준으로 해요.

상태 이름부터 달랐어요

판정을 만들려면 버전이 지금 어떤 상태인지 읽어야 해요. Gemfile.lock으로 고정한 fastlane의 spaceship 구현을 열어 보니 상태를 읽는 접근자가 둘이었어요.

spaceship의 앱 버전 모델에 있는 접근자 선언이에요.

attr_accessor :app_store_state      # Deprecated in App Store Connect API specification 3.3
attr_accessor :app_version_state    # ← 현행

두 접근자는 이름만 다른 것이 아니라 돌려주는 값도 달라요. 예전 쪽의 READY_FOR_SALE은 현재 쪽에서 READY_FOR_DISTRIBUTION이고, PROCESSING_FOR_APP_STORE는 PROCESSING_FOR_DISTRIBUTION이에요. spaceship이 버전을 검색할 때 쓰는 필터도 현재 필드를 써요. App Store Connect API의 앱 버전 리소스를 기준으로 판정하려면 현재 접근자를 읽어야 해요.

이름에도 함정이 있었어요. JSON에서는 appVersionState지만 Ruby로 매핑된 뒤의 접근자 이름은 달라요. 카멜 표기로 접근하면 nil이 나오고, nil과 비교하는 코드는 문법 검사에 걸리지 않아요. 그러면 오류 없이 «모르는 상태» 분기로 흘러가요.

버전 상태만 보면 틀리는 경우

처음에는 READY_FOR_REVIEW를 접수가 끝난 상태로 분류했어요. 이 판단이 틀렸어요. 버전 준비와 제출 객체 생성까지 마치고 마지막 API 호출 직전에 실행이 끊겨도 이 상태가 남기 때문이에요. 이 상태를 성공으로 처리하면, 재실행이 실제 심사 요청을 건너뛴 채 끝나요.

빌드도 따로 확인해야 했어요. 마케팅 버전이 같아도 수동 제출이나 이전 실행 때문에 버전에 선택된 빌드 번호가 다를 수 있어요. 업로드한 빌드가 존재하는지와 그 빌드가 버전에 연결돼 있는지는 서로 다른 조건이에요. 이 둘을 구분하지 않으면 다른 바이너리의 심사 상태를 이번 빌드의 성공으로 잘못 읽어요.

앱 전체의 제출 객체라는 두 번째 축

다시 조사해 보니 제출 도구가 요청을 거절하는 기준은 버전 상태가 아니었어요. 앱 전체에 진행 중인 review submission이 있는지를 봐요.

fastlane deliver가 심사 제출 전에 확인하는 분기의 일부예요.

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 상태와 끝나지 않은 제출 객체는 동시에 있을 수 있어요. 그래서 버전 상태 하나로는 판정할 수 없고, 제출 객체의 상태를 함께 읽어야 해요.

여기서도 한 번 더 틀렸어요. 조회 결과를 객체가 있는지 없는지로 줄여 버리면 대기, 심사 중, 문제 제기 같은 상태의 차이가 사라져요. 버전이 거절됐는데도 빌드와 노트가 일치한다는 이유로 성공이라고 판단할 수 있어요. 제출 객체의 상태를 그대로 보존하고, 차단 조건을 먼저 적용하도록 고쳤어요.

열거형을 빠뜨린 것도 있었어요. 상태 15개 가운데 14개만 분류했는데, 빠진 하나가 승인 상태였어요. 승인된 뒤 수동 출시를 기다리는 동안 재실행하면 실패할 수 있는 구멍이에요. 지금은 고정한 젬의 enum 전체를 self-test가 순회하고, 분류되지 않은 값이 0개라고 단언해요. 젬을 올려서 새 값이 생기면 실제 제출 전에 검증 단계에서 먼저 실패해요.

제출 노트 비교에서 나온 세 번째 결함

로케일별 제출 노트를 비교하는 테스트에서는 키를 손으로 적었어요. 그 결과 테스트가 비교 함수만 시험했고, 제가 쓰는 로케일 키와 API가 돌려주는 키가 맞는지는 확인하지 않았어요. 키 철자가 다르면 모든 로케일이 누락으로 판정되고, 재실행이 통째로 막혀요.

오류 안내도 문제였어요. 기존 메시지는 노트가 오래됐다는 것을 원인으로 지목해서, 키가 맞지 않는 상황을 조사하는 데는 도움이 되지 않았어요. 그래서 모든 로케일이 누락인데 서버의 노트 집합은 비어 있지 않은 경우를 따로 처리했어요. 이때는 입력 쪽 키 목록과 서버 응답 쪽 키 목록을 각각 출력해요.

판정 규칙 네 가지

세 번의 수정을 거쳐 규칙을 네 개로 줄였어요.

  1. 버전 상태와 앱 전체의 제출 상태는 서로 독립된 축으로 유지해요.
  2. 모르는 값을 만나면 fail-closed로 멈추고 사람의 판단을 요청해요.
  3. 어느 축에서든 차단 신호가 나오면 제출보다 차단이 우선이에요.
  4. dry-run은 상태를 보고하기만 하고, 그 상태를 이유로 중단하지 않아요.

세 번째 규칙의 우선순위는 조건문의 순서에만 맡기지 않고 구조로 고정했어요. 마지막 규칙은 리허설과 실제 태그 실행 사이에 상태가 바뀔 수 있어서 넣었어요.

App Store Connect를 호출하지 않고 확인한 방법

판정은 인증 없이 실행할 수 있는 순수 함수로 분리했어요. 덕분에 App Store Connect API를 호출하지 않고 self-test만으로 확인할 수 있어요. 확인한 범위는 버전 상태별 표, 제출 객체 상태별 표, 젬 enum 전체 분류 검사예요.

두 번째 축을 무시하는 구현을 잡는 장치도 넣었어요. 버전 상태는 같게 두고 제출 객체 상태만 바꾼 대조 케이스 두 개가 같은 판정을 내면 테스트가 실패해요. 노트 비교에는 뮤테이션을 썼어요. 비교 함수를 항상 false를 돌려주는 것과 항상 true를 돌려주는 것으로 각각 바꿔서, 지나치게 허용하는 결함과 지나치게 막는 결함이 모두 검출되는지 확인했어요. 검사 도구 자체를 대조군으로 확인하는 방법은 검증 스크립트에 대조군을 적용한 글에서 따로 다뤘어요.

돌아보면 수정 라운드 하나는 피할 수 있었어요. 두 번째 판정 축을 조사한 문서가 이미 저장소에 있었는데 읽지 않고 시작했어요. 몇 달 전에 제가 남긴 기록부터 다시 확인하는 습관이 필요해요.

확인하지 못한 것도 분명히 적어 둬요. 서버가 처리를 마친 뒤 응답만 잃는 장애를 실제로 재현한 기록은 없어요. 재현할 수단을 마련하기 어려웠어요. 이번에 확인한 것은 상태 조합을 읽는 판정 로직이고, 장애 전체를 재현한 것이 아니에요. enum 전수 검사도 고정한 젬이 알고 있는 값만 보장해요. 서버의 최신 상태 집합을 젬이 모두 반영하는지는 젬을 갱신하기 전까지 알 수 없어요.