수출 규정 신고값을 두 파일과 ASC에서 맞추는 검사

하루에 스도쿠 한 판을 내는 개인 앱 DailySudoku를 iOS, Android, Web에 함께 내고 있어요. 배포 채널은 App Store, Google Play, 웹 세 곳이에요. git 태그를 push하면 App Store Connect(ASC)에 심사를 요청하는 데까지 자동으로 진행돼요.

심사 제출을 다시 실행해도 안전하게 만드는 작업을 하던 중에 암호화 신고 검사를 더했어요. 그 과정에서 제가 만든 결함을 네 번 다시 고쳤어요. 코드는 2026년 8월 16일 develop 브랜치의 스토어 메타데이터 검사와 iOS 배포 자동화를 기준으로 해요.

신고값이 있는 자리

앱을 업로드할 때에는 암호화 사용 여부를 신고해야 해요. 이 값은 저장소 안의 두 곳에 있어요. 하나는 Info.plist의 ITSAppUsesNonExemptEncryption 키이고, 다른 하나는 자동화가 입력으로 쓰는 제출 선언 파일이에요.

두 값이 어긋나면 ASC가 추가 질문을 하고 파이프라인이 멈춰요. 선언 파일에는 false 값과 함께, 두 파일을 같이 고쳐야 한다는 주석이 있었어요. 그 주석을 실제로 검사하는 코드는 없었어요.

응답이 있다는 것과 응답이 맞다는 것

처음에는 값을 비교하지 않기로 했어요. 버전이 규정 응답을 기다리는 상태라면 어차피 수동 대응으로 넘어가니, 따로 비교할 필요가 없다고 판단했어요.

이 판단에는 빈틈이 있었어요. 응답이 있는지와 그 응답이 승인한 값과 같은지는 서로 다른 조건이에요. 사람이 ASC에서 직접 입력했거나 이전의 자동 실행이 다른 값을 넣은 뒤에도 상태는 «해결됨»이 돼요. 여기에 빌드, 릴리스 노트, 출시 방식까지 맞으면 재실행이 성공으로 판정될 수 있어요. 그러면 승인한 범위를 벗어난 신고로 심사가 진행돼요. 잘못된 신고는 심사 거절이나 정책 위반의 이유가 될 수 있어요.

첫 보완으로 ASC에 있는 실제 값을 승인값과 비교하고, 응답이 없으면 막도록 했어요.

30분 뒤에 찾은 회귀

이 보완이 곧바로 다른 문제를 만들었어요. 승인값을 넘겨주는 함수는 Ruby 심볼을 키로 쓰는데, 값을 꺼내 쓰는 코드는 문자열 키로 조회했어요.

같은 값을 두 가지 키로 조회한 결과예요.

문자열로 조회: nil     ← 옛 코드
심볼로 조회  : false   ← 고친 코드 (= 선언 파일의 승인값)

조회 결과가 false가 아니라 nil이어서, ASC의 값과 항상 다르다고 판정했어요. 그 결과 모든 재시도가 막혔어요.

항상 막아 버리는 결함은 이번이 세 번째였어요. 승격 비교, 로케일 식별자, 그리고 이번의 신고값 조회예요. 기존 self-test는 키의 개수와 값의 타입만 확인하고 있었어요. 그래서 값을 꺼내 쓰는 코드와 똑같은 키로 조회하는 단언을 더했어요.

같은 유형을 한 번 더 찾아봤어요

리뷰를 받기 전에 같은 유형의 차단 경로가 더 있는지 찾아봤고, 네 번째 후보가 나왔어요. 직전 커밋은 ASC의 신고값이 nil이면 응답이 없는 것으로 취급했어요.

그런데 앱 바이너리의 Info.plist 선언으로 규정 처리가 끝난 경우에는 Build의 신고 필드가 비어 있을 수 있어요. 이것은 제출 도구의 구현에서 끌어낸 추론이에요. 제출 도구인 fastlane deliver는 이 값이 nil일 때에만 값을 기록해요. 이 상태로 내보냈다면 정상적인 재시도까지 막혔을 거예요. 이번에는 배포하기 전에 찾았어요.

수정한 뒤에는 ASC의 필드에 값이 있을 때에만 승인값과 비교해요. 실제로 해결되지 않은 건은 버전이 응답을 기다리는 상태인지를 보는 검사가 막아요. nil이라는 값과 미해결 상태를 같은 것으로 보지 않게 역할을 나눴어요.

두 파일을 비교하는 오프라인 검사

실행 중의 판정과 별개로, 저장소 안의 계약을 검사로 만들었어요. Info.plist와 제출 선언을 비교하는 검사예요. 한 파일만 고친 시점에 바로 충돌을 알 수 있어서 PR마다 잡을 수 있어요.

두 파일에서 읽은 값이 같은지 단언하는 부분이에요.

plist_value = plist[key]                    # Info.plist
declared = decl[field]                      # 제출 선언 파일
assert plist_value == declared, (
    "수출 규정 선언이 두 곳에서 어긋난다 — ASC가 되묻거나 잘못된 신고로 심사가 진행된다. "
    "**한쪽만 고치지 말 것**(선언 파일 주석)."
)

값의 타입도 함께 봐요. plutil로 값을 뽑으면 XML의 문자열 노드에 들어 있는 false도 문자열로 나와요. 이 문자열을 “true”와 비교한 결과만 Boolean으로 바꾸면 원본의 타입이 틀렸다는 것을 놓쳐요. 그래서 Python의 plistlib로 파싱한 뒤 bool인지 검사해요.

Info.plist에서 읽은 값이 Boolean인지 확인하는 단언이에요.

assert isinstance(plist_value, bool), (
    f"Info.plist 의 {key} 가 Boolean 이 아니다: {plist_value!r}. "
    "ASC 는 이 키를 Boolean 으로 요구한다 — <true/> 또는 <false/> 여야 한다"
)

키가 아예 없는 경우도 실패로 처리해요. 키가 빠지면 ASC가 다시 묻고 파이프라인이 멈추기 때문이에요.

검사가 도는 자리도 틀렸어요

이 검사를 처음에는 iOS 배포 워크플로의 self-test에 넣었어요. 그 워크플로는 PR에서 apps/ios/** 경로가 바뀔 때만 돌아요. 제출 선언 파일은 그 경로 밖에 있어서, 선언만 고친 PR에서는 검사가 실행되지 않았어요.

두 파일을 모두 담당하는 스토어 메타데이터 검사로 옮겼어요. 이 검사는 모든 PR과 push에서 돌아요.

확인은 세 가지로 했어요. Info.plist의 Boolean 값을 뒤집는 뮤테이션을 넣었을 때 단언이 실패하는지 봤어요. self-test에서는 실제로 값을 꺼내 쓰는 키로 조회해요. 그리고 코드에서 쓰는 젬의 접근자와 상수를 고정한 소스와 빠짐없이 하나씩 대조했어요. 이름을 확인하지 않고 참조한 것이 반복된 원인이었기 때문이에요. 검사에 실패하는 입력을 넣어 보는 방법은 검증 스크립트에 대조군을 적용한 글에서 다뤘어요.

주석으로 적은 약속은 읽는 사람이 관리되는 계약으로 착각하기 쉬우니 단언으로 바꿔야 해요. 결함 하나를 고칠 때 같은 유형의 실패 경로를 더 찾아보는 것이 효과가 있었어요. 검사가 지켜보는 변경이 그 검사의 실행 조건에도 들어 있는지 확인해야 해요. 안전장치를 더하는 작업도 새 오류를 만들 수 있다는 것을 이번에 겪었어요.

어긋난 선언으로 실제 제출을 해 본 적은 없어요. 잘못된 신고로 심사 큐를 시험할 수는 없었어요. 두 값이 충돌하거나 키가 빠졌을 때 ASC가 다시 묻는다는 것은 선언 파일의 주석에 남은 경험과 API의 요구사항에 기댄 설명이에요. nil 처리의 근거도 도구의 구현이고, ASC가 Build의 신고 필드를 채우는 정확한 조건은 문서에서 확인하지 못했어요.