# READY_FOR_REVIEW는 «제출됨»이 아니다: App Store Connect 제출을 멱등하게 만들기

> 응답이 유실되면 재실행이 중복 제출로 거부되어, 복구용 재시도가 오히려 확실한 실패가 됐습니다. 멱등 가드를 붙였더니 이번엔 그 가드가 세 가지 방향으로 배신했습니다.

- Canonical: https://blog.jaemyeong.com/posts/app-store-connect-idempotent-submission/
- Published: 2026.08.18
- Category: 기술
- Tags: #iOS, #CI-CD, #fastlane, #Automation, #App Store Connect

DailySudoku는 제가 만들고 있는 사이드 프로젝트입니다. 하루에 스도쿠 한 판을 푸는 앱이고, iOS와 Android와 Web을 함께 냅니다. 혼자 만드는 앱이라 릴리스도 자동화해 뒀습니다. 태그를 밀면 빌드가 올라가고 App Store Connect에 심사 제출까지 갑니다.

이 워크플로는 **채널별로 다시 돌려서 복구한다**는 전제로 설계했습니다. 웹 배포가 실패하면 웹만 다시, iOS 제출이 실패하면 iOS만 다시 돌리면 됩니다.

그런데 iOS 제출에서 그 전제가 깨져 있었습니다.

App Store Connect가 제출 요청을 받았는데 **응답이 유실되면**, 재실행이 중복 제출로 거부됩니다. 그러니까 복구하려고 누르는 재시도가 오히려 **확실한 실패**가 됩니다. 성공했는지 실패했는지 모르는 상태가, 다시 돌리는 순간 실패로 확정되는 셈입니다.

Android 쪽은 같은 지적을 받아 이미 고쳐져 있었고 iOS만 남아 있었습니다. 그래서 멱등 가드를 붙였습니다. 그리고 그 가드가 세 가지 방향으로 배신했습니다.

이 글은 그 세 번의 배신과, 결국 무엇을 계약으로 고정했는지를 정리한 기록입니다.

## 추측 대신 원본을 읽었습니다

가드를 짜려면 「지금 이 버전이 어떤 상태인가」를 조회해야 합니다. 그런데 상태 조회 API의 값 집합을 정확히 몰랐습니다.

여기서 추측했으면 틀렸을 겁니다. `Gemfile.lock`이 고정한 fastlane 버전의 spaceship 소스를 직접 읽었더니 이렇게 돼 있었습니다.

```ruby
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 어딘가에 있다」와 「그 빌드가 이 버전에 선택돼 있다」는 **다른 축**입니다. 앞의 것만 확인하고 있었습니다.

### ② 불리언으로 접어 거부를 성공으로 만들었습니다

①을 고치고 나니 상태 하나만으로는 부족하다는 게 드러났습니다.

제출 도구가 실제로 하드 실패하는 조건은 버전 상태가 아니라 **앱 단위의 「진행 중 심사 제출」**이었습니다.

```ruby
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 표만으로는 두 번째 축을 **아예 읽지 않는 구현**도 통과합니다. 파라미터로 받고 무시하면 그만이니까요. 그래서 「같은 상태가 축에 따라 답이 갈리는가」를 검사하는 줄을 넣었고, **두 줄이 같은 답을 내면 실패**합니다.

릴리스 노트 대조 쪽은 **뮤테이션을 양방향으로** 돌렸습니다. 비교 함수를 항상 거짓으로 바꾸면 구멍이 생기고, 항상 참으로 바꾸면 과잉 차단이 생깁니다. 둘 다 실제로 잡히는 것을 확인했습니다. 이 기법은 [검증 스크립트의 자기검증 글](/posts/verification-script-negative-control/)에서 따로 정리했습니다.

### 못 잰 것

**검증 기록이 덮는 것은 판정 로직까지입니다.** 제출 요청은 도달했는데 응답만 사라지는 상황을 실제로 만들어 통과시켜 본 기록은 없습니다. 그 조건을 인위적으로 재현할 방법이 마땅치 않기 때문입니다. 가드의 정당성은 그 상황이 남기는 상태 조합을 정확히 읽는다는 데 기대고 있지, 그 상황을 통째로 밟아본 데 기대고 있지 않습니다.

**상태 분류의 정확성은 고정된 젬 버전에 대해서만 검증됩니다.** 전수 열거 단언이 잡아주는 것은 「젬이 아는 상태를 우리가 전부 분류했는가」이지 「젬이 App Store Connect의 현재 상태를 전부 아는가」가 아닙니다. 후자는 젬을 올리기 전까지 알 수 없습니다.

## 사이드 프로젝트라서 배운 것

**멱등성은 「두 번 불러도 괜찮다」가 아니라 「무엇이 이미 일어났는가를 정확히 읽는다」입니다.** 처음에 저는 앞의 정의로 접근했고, 그래서 「이미 제출됨처럼 보이면 넘어간다」를 짰습니다. 그 결과가 조용한 미제출이었습니다. 읽기가 틀리면 멱등 가드는 실패를 감추는 장치가 됩니다.

**가드는 두 방향으로 고장 납니다.** 안 막아야 할 때 막지 않는 것만 생각하기 쉽지만, 막지 말아야 할 때 막는 것도 같은 무게의 결함입니다. 특히 후자는 「안전하게 실패했다」처럼 보여서 더 오래 살아남습니다. 뮤테이션을 양방향으로 돌린 이유가 그것입니다.

**이름을 믿지 말고 소스를 읽어야 할 때가 있습니다.** `READY_FOR_REVIEW`는 이름만 보면 제출된 상태 같고, 접근자 두 개는 이름이 거의 같습니다. 둘 다 이름이 아니라 원본을 읽어서 갈렸습니다. 릴리스 파이프라인처럼 되돌리기 어려운 경로에서는, 소스를 확인하는 비용이 태그를 잘못 미는 비용보다 쌉니다.

**그리고 이미 조사해 둔 것을 다시 조사했습니다.** 두 번째 축의 존재는 이 저장소에 이미 조사 기록으로 남아 있었습니다. 저는 그 문서를 안 읽고 상태 하나만 보고 고쳤고, 그래서 한 라운드를 더 썼습니다. 혼자 만드는 프로젝트에서 과거의 기록은 남이 남긴 문서가 아니라 **몇 달 전의 내가 남긴 문서**인데, 그렇다고 더 잘 읽게 되지는 않더군요.

## 출처

- [App Store Connect API - Apple Developer](https://developer.apple.com/documentation/appstoreconnectapi)
- [App Store Versions - App Store Connect API](https://developer.apple.com/documentation/appstoreconnectapi/app_store_versions)
- [Review Submissions - App Store Connect API](https://developer.apple.com/documentation/appstoreconnectapi/review_submissions)
- [fastlane deliver](https://docs.fastlane.tools/actions/deliver/)
- [fastlane spaceship](https://docs.fastlane.tools/advanced/Spaceship/)
- [DailySudoku - App Store](https://apps.apple.com/app/id1149229748)
- [DailySudoku - Google Play](https://play.google.com/store/apps/details?id=so.object.sudoku)
- [DailySudoku 웹](https://dailysudoku.app/ko/)
