경고 주석은 있는데 대조는 없었다: ITSAppUsesNonExemptEncryption 두 원천 검증
DailySudoku는 제가 만들고 있는 사이드 프로젝트입니다. 하루에 스도쿠 한 판을 푸는 앱이고, iOS와 Android와 Web을 함께 냅니다. 태그를 밀면 App Store Connect에 심사 제출까지 자동으로 갑니다.
그 자동 제출을 멱등하게 만드는 작업을 하다가 곁가지로 붙은 것이 수출 규정 선언 검증입니다. 곁가지인 줄 알았는데 네 번을 고쳤고, 네 번 다 제가 만든 결함이었습니다.
수출 규정은 App Store에 앱을 올릴 때 암호화 사용 여부를 신고하는 절차입니다. 그리고 이 신고값이 두 곳에 있습니다.
Info.plist의ITSAppUsesNonExemptEncryption- 제출 자동화가 읽는 선언 파일
두 값이 어긋나면 App Store Connect가 매 제출마다 되묻고, 자동 제출이 거기서 멈춥니다. 그래서 선언 파일에는 이런 주석이 달려 있었습니다.
Info.plist의ITSAppUsesNonExemptEncryption이 false다. 둘이 어긋나면 ASC가 되묻는다 — 한쪽만 고치지 말 것.
경고는 있었습니다. 그런데 그 대조를 아무도 하고 있지 않았습니다.
이 글은 그 사실을 알아채기까지 네 번 잘못 고친 기록입니다.
처음 구현은 이랬습니다
제출 판정에는 이미 상태 검사가 있었습니다. 버전 상태에 「수출 규정 응답 대기」가 있고, 그 상태면 사람에게 넘깁니다.
그래서 처음에는 이렇게 정리했습니다. 「신고값은 상태 기계가 덮는다.」 상태가 대기 중이면 멈추니, 별도 대조는 필요 없다고 본 것입니다.
첫 번째 오류 — 「해결됨」은 「우리 값과 일치」가 아닙니다
논거가 틀렸습니다.
「수출 규정 응답 대기」 상태는 「아직 답하지 않았다」만 잡습니다. 답이 내가 승인한 값과 같은지는 말하지 않습니다. 두 질문이 다른데 하나로 읽은 것입니다.
그러면 이런 경로가 열립니다. 사람이 App Store Connect 화면에서 직접 답했거나, 앞선 자동 실행이 다른 값을 넣었다고 해봅시다. 상태는 「해결됨」이 됩니다. 그리고 빌드도 릴리스 노트도 출시 방식도 전부 맞으면, 제출 판정은 성공을 돌려줍니다.
승인하지 않은 신고로 심사가 진행되는데 성공이 보고됩니다. 잘못된 신고는 리젝이나 정책 위반 사유가 됩니다.
그래서 실제 신고값을 읽어 승인값과 대조하도록 고쳤습니다. 미응답도 통과시키지 않았습니다.
두 번째 오류 — 키 하나가 가드를 차단기로 만들었습니다
30분 뒤, 방금 넣은 그 가드가 모든 재시도를 영구히 막고 있다는 것을 알게 됐습니다.
승인값을 읽어 오는 함수가 키를 심볼로 담고 있었는데, 저는 문자열로 조회했습니다. Ruby에서 이 둘은 다른 키입니다.
문자열로 조회: nil ← 옛 코드
심볼로 조회 : false ← 고친 코드 (= 선언 파일의 승인값)
승인값이 항상 nil이니 App Store Connect가 무슨 값을 갖고 있든 **「다르다」**가 됩니다. 거짓 성공을 막으려던 가드가 다시 영구 차단기가 됐습니다.
이게 같은 작업에서 세 번째로 같은 형태였습니다. 앞서 승격 대조에서 한 번, 로케일 식별자에서 한 번, 그리고 이번입니다. 가드를 붙일 때마다 그 가드가 상시 발동하는 경로를 함께 만들고 있었던 것입니다.
여기서 self-test에 키 타입 계약을 넣었습니다. 종전 검사는 키 개수와 값 타입만 봤습니다. 어떤 키로 꺼내는지는 아무도 안 봤습니다. 그래서 실제로 쓰는 그 키로 꺼내 보는 단언을 추가했습니다.
세 번째 오류 — nil이 정상 상태였습니다
여기서 리뷰를 기다리지 않고, 이 작업이 세 번 만든 형태(가드가 영구 차단기)를 스스로 훑어봤습니다. 네 번째 후보가 나왔습니다.
앞 커밋에서 저는 App Store Connect 쪽 신고값이 nil이면 실패로 봤습니다. 미응답이니 막아야 한다고 본 것입니다.
그런데 이 앱은 Info.plist에 ITSAppUsesNonExemptEncryption을 구워서 냅니다. 그러면 App Store Connect가 바이너리에서 규정 준수를 해결하고, Build 레코드의 신고 필드는 채워지지 않을 수 있습니다.
근거는 제출 도구 자신에게 있었습니다. 그 도구도 이 값을 nil일 때만 쓰려고 합니다. 즉 nil이 오류가 아니라 정상 상태입니다.
그대로 뒀다면 정당한 재시도가 전부 막혔을 겁니다. 네 번째 영구 차단기를, 이번엔 배포 전에 잡은 셈입니다.
그래서 런타임 대조는 App Store Connect가 값을 갖고 있을 때만 하도록 좁혔습니다. 규정 준수가 정말 미해결이면 버전 상태가 「응답 대기」라 판정이 이미 멈춥니다.
그럼 그 축은 누가 보나
런타임 대조를 좁히고 나니 질문이 남았습니다. 두 선언이 어긋나는 상황은 이제 누가 잡나요?
두 선언의 정적 대조
진짜 원천은 App Store Connect의 응답이 아니라 저장소 안의 두 파일이었습니다. Info.plist와 제출 선언 파일. 어긋나는 순간은 둘 중 하나만 고칠 때고, 그건 코드를 커밋하는 시점에 이미 결정됩니다.
그래서 네트워크 없이 도는 정적 대조를 넣었습니다.
plist_value = plist[key] # Info.plist
declared = decl[field] # 제출 선언 파일
assert plist_value == declared, (
"수출 규정 선언이 두 곳에서 어긋난다 — ASC가 되묻거나 잘못된 신고로 심사가 진행된다. "
"**한쪽만 고치지 말 것**(선언 파일 주석)."
)
주석이 경고하던 그 대조입니다. 경고를 읽고 사람이 지키기를 기대하는 대신, 매 PR에서 기계가 확인하게 했습니다.
문자열로 읽으면 조용히 통과합니다
Info.plist를 읽는 방법도 함정이었습니다.
plutil로 값을 뽑으면 <string>false</string>도 문자열 "false"로 돌려줍니다. 그걸 == "true" 같은 비교로 접으면 어떻게 될까요. Boolean이 아닌 선언이 조용히 통과합니다.
App Store Connect는 이 키를 Boolean으로 요구합니다. 그래서 표준 라이브러리 plistlib로 읽어 타입까지 봅니다.
assert isinstance(plist_value, bool), (
f"Info.plist 의 {key} 가 Boolean 이 아니다: {plist_value!r}. "
"ASC 는 이 키를 Boolean 으로 요구한다 — <true/> 또는 <false/> 여야 한다"
)
키가 아예 없는 경우도 막습니다. 없으면 App Store Connect가 매 제출마다 되묻고 자동 제출이 거기서 멈춥니다.
검사를 어디에 두느냐가 검사의 성립 조건입니다
이 대조를 처음에는 iOS 릴리스 워크플로의 self-test에 넣었습니다. 그리고 그 배치가 틀렸습니다.
그 워크플로의 PR 트리거는 apps/ios/** 경로로 걸려 있습니다. 그런데 제출 선언 파일은 그 경로에 없습니다. 즉 선언 파일만 바꾼 PR에서는 이 검사가 아예 돌지 않습니다.
이 검사가 잡으려던 바로 그 변경이, 검사를 우회하는 배치였습니다.
그래서 모든 PR과 push에서 도는 스토어 메타데이터 검증기로 옮겼습니다. 그 검증기는 두 파일 다 자기 주제로 다루고 있어서, 자리로도 더 맞았습니다.
확인한 방법
이 글은 2026년 8월 16일 DailySudoku develop 브랜치의 스토어 메타데이터 검증기와 iOS 릴리스 자동화를 기준으로 합니다.
- 정적 대조는 네트워크 없이 돌아, 스토어 메타데이터 검증 잡이 모든 PR과 push에서 실행합니다.
- 뮤테이션으로 검증했습니다.
Info.plist의 값을 반대로 뒤집으면 이 단언이 실제로 잡습니다. 뮤테이션으로 검증 코드 자체를 확인하는 방법은 검증 스크립트의 자기검증 글에서 정리했습니다. - 키 타입 계약도 self-test가 잠급니다 — 실제로 쓰는 그 키로 꺼내 봅니다.
- 이 작업이 참조한 젬 접근자와 상수를 고정된 소스와 1:1로 전수 대조했습니다. 반복된 결함의 뿌리가 **「이름을 확인 없이 참조」**였으므로 그 목록을 한 번에 닫았습니다.
못 잰 것
두 선언이 어긋난 상태로 실제 제출을 시도해 본 기록은 없습니다. App Store Connect가 되묻는 화면까지 밟아보려면 실제로 잘못된 신고를 올려야 하는데, 심사 큐를 상대로 할 실험이 아닙니다. 이 검사의 정당성은 선언 파일 주석에 적힌 경험과 API 문서의 요구사항에 기대고 있습니다.
nil이 정상이라는 판단의 근거는 제출 도구의 코드입니다. 도구가 그 값을 nil일 때만 쓴다는 것을 소스에서 확인했지만, App Store Connect가 어떤 조건에서 Build 레코드를 채우는지는 문서로 확인하지 못했습니다.
사이드 프로젝트라서 배운 것
경고 주석은 검사가 아닙니다. 선언 파일에는 「한쪽만 고치지 말 것」이라고 분명히 적혀 있었습니다. 문제는 그 문장이 사람이 읽어야만 작동한다는 것입니다. 주석이 정확할수록 오히려 위험합니다. 정확한 경고를 보면 「이건 관리되고 있구나」라고 느끼게 되니까요. 주석에 적을 만큼 중요한 계약이라면, 그건 대체로 단언으로 적을 수 있는 계약입니다.
같은 실수를 세 번 하고 나서야 패턴이 보였습니다. 가드를 붙일 때마다 그 가드가 상시 발동하는 경로를 함께 만들고 있었는데, 세 번째에 가서야 「이게 반복되는 형태구나」를 알아챘습니다. 그리고 그때 리뷰를 기다리지 않고 같은 형태로 스스로 훑어본 것이 네 번째를 잡았습니다. 결함을 고치는 것과 결함의 형태를 아는 것은 다른 일이고, 후자가 훨씬 오래 갑니다.
검사의 자리가 검사의 성립 조건입니다. 경로 필터 뒤에 둔 검사는 그 경로를 안 건드리는 변경에 대해 존재하지 않는 것과 같습니다. 검사를 추가했으면 「이 검사가 잡으려는 변경이 이 검사를 돌게 하는가」를 한 번 물어야 합니다. 저는 안 물었고, 그래서 잡으려던 변경이 검사를 우회하는 배치를 만들었습니다.
그리고 네 번 다 제가 만든 결함이었습니다. 원래 있던 버그를 고친 게 아니라, 고치는 과정에서 만든 것을 다시 고쳤습니다. 혼자 만드는 프로젝트에서는 이게 흔한 모양입니다. 안전장치를 붙이는 작업이 가장 위험한 작업이 되는 이유는, 그 장치가 실패하는 방향이 막으려던 방향보다 많기 때문인 것 같습니다.
출처
이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.