쓰지 않는 iOS 권한 문구 때문에 App Store 자동 검사에서 반려되는 이유

앱스토어 심사라고 하면 사람이 아이폰을 들고 앱을 하나씩 눌러 보는 장면부터 떠올리기 쉽습니다. 저도 그랬습니다. 그런데 iOS 앱을 제출한 뒤 받은 반려 메시지에는 automated analysis라는 표현이 적혀 있었습니다. 사람 심사관의 판단을 기다리기도 전에 업로드한 바이너리의 권한 문구가 자동 분석에서 걸린 경우였습니다.

Apple이 모든 판단을 기계에 맡긴다는 뜻은 아닙니다. Apple은 모든 앱을 전문가가 검토한다고 설명하면서, 안전·보안·개인정보에 영향을 줄 수 있는 소프트웨어도 스캔한다고 밝히고 있습니다. 이번에 제가 받은 메시지로 확인할 수 있는 범위는 더 좁습니다. 이 빌드에서는 사람이 작성한 리뷰가 아니라 자동 분석이 Info.plist의 usage description을 문제로 분류했습니다.

자동 분석이 정확히 어떤 모델이나 규칙을 사용하는지는 공개되어 있지 않습니다. 생성형 AI가 심사했다고 단정할 근거도 없습니다. 바이너리 안에 들어 있는 키와 문자열을 읽고, 비어 있거나 템플릿처럼 보이는 문구를 찾는 정적 검사만으로도 이번 문제는 충분히 잡아낼 수 있습니다.

권한 팝업의 한 줄도 앱의 일부였습니다

iOS에서 카메라나 사진, 마이크처럼 민감한 기능에 접근하면 시스템 권한 팝업이 나타납니다. 앱 이름 아래에 표시되는 설명이 purpose string, 또는 usage description입니다. 개발자는 Info.plist에 권한별 키와 사용자에게 보여 줄 이유를 넣습니다.

<key>NSPhotoLibraryUsageDescription</key>
<string>메시지에 사진을 첨부하기 위해 사진 보관함을 사용합니다.</string>

저는 처음에 직접 작성한 사진 보관함 설명이 짧아서 반려된 줄 알았습니다. 그래서 그 문장만 길게 고치면 끝날 것 같았습니다. 하지만 반려 메시지는 placeholder 또는 insufficient purpose strings를 복수로 지적하고 있었습니다. 소스의 app.json만 보는 대신 실제 빌드에 들어가는 Info.plist를 다시 생성해 확인했습니다.

거기에는 제가 작성하지 않은 영어 문구가 세 개 더 있었습니다.

권한 | 바이너리에 들어간 기본 문구 | 실제 사용 여부
카메라 | 앱이 카메라에 접근하도록 허용 | 사용하지 않음
마이크 | 앱이 마이크에 접근하도록 허용 | 사용하지 않음
Face ID | 앱이 Face ID 생체 정보에 접근하도록 허용 | 사용하지 않음

문장 자체가 거짓말은 아니지만, 사용자가 “어느 화면에서 무엇을 하기 위해 필요한가”를 알 수 없습니다. 더 큰 문제는 앱이 그 권한들을 요청할 이유가 없었다는 점입니다.

소스 코드보다 생성된 Info.plist가 더 정확했습니다

사진 첨부 코드를 따라가 보니 앨범을 여는 launchImageLibraryAsync만 사용했습니다. 카메라를 직접 실행하는 launchCameraAsync 호출은 없었습니다. 녹음 기능도 없었습니다.

expo-secure-store에는 로그인 정보를 저장하고 있었지만 requireAuthentication: true를 사용하지 않았습니다. SecureStore를 쓴다는 사실과 Face ID 인증을 쓴다는 사실은 같지 않습니다. Expo 문서에서도 생체 인증은 requireAuthentication 옵션으로 활성화한다고 설명합니다.

기본 문구의 출처는 Expo config plugin이었습니다. expo-image-picker는 사진, 카메라, 마이크 권한 문구 설정을 제공하고 기본 문구도 갖고 있습니다. expo-secure-store 역시 Face ID 문구의 기본값을 제공합니다. JavaScript에서 해당 기능을 호출하지 않아도 빌드 시 플러그인이 네이티브 설정을 만들 수 있다는 뜻입니다.

여기서 한 번 더 헛짚을 뻔했습니다. 문구를 구체적으로 고쳐서 세 권한을 모두 남기는 방법도 생각했습니다. 그러면 자동 분석은 통과할지 몰라도 앱에는 필요 없는 권한 선언이 남습니다. Apple은 앱의 핵심 기능에 필요한 데이터만 요청하는 data minimization을 요구합니다. 쓰지 않는 권한은 그럴듯하게 설명하는 것보다 제거하는 쪽이 맞았습니다.

쓰는 것은 구체적으로, 쓰지 않는 것은 제거했습니다

사용 중인 Expo 플러그인 버전에서는 다음처럼 설정했습니다.

{
"expo": {
"plugins": [
[
"expo-image-picker",
{
"photosPermission": "메시지에 사진을 첨부하려면 사진 보관함 접근이 필요합니다. 예: 담당자와의 메시지에서 사진 버튼을 눌러 이미지를 선택할 때 사용합니다.",
"cameraPermission": false,
"microphonePermission": false
}
],
[
"expo-secure-store",
{
"faceIDPermission": false
}
]
]
}
}

사진 보관함은 실제 화면과 행동 예시를 넣어 다시 썼습니다. 카메라, 마이크, Face ID는 false로 막았습니다. 이 설정이 모든 Expo SDK 버전에서 같은 결과를 낸다고 가정하지 않고, expo prebuild로 네이티브 프로젝트를 다시 만든 뒤 생성된 Info.plist를 직접 확인했습니다. 최종 파일에는 사진 보관함 문구만 남았습니다.

이 확인이 중요했습니다. app.json은 입력이고 심사 대상은 빌드된 앱입니다. 여러 config plugin이 같은 키를 건드릴 수 있으므로 입력 파일이 깔끔하다는 사실만으로 최종 바이너리를 설명할 수는 없습니다.

수정했는데도 새 빌드가 필요했습니다

권한 문구는 JavaScript 화면의 텍스트가 아니라 네이티브 바이너리에 포함되는 Info.plist 설정입니다. 저장소에 수정 사항을 푸시하면서 OTA 업데이트도 실행됐지만, 기존 바이너리의 권한 문구는 바뀌지 않았습니다. 새 iOS production 빌드를 만들었습니다.

이번에 반려된 앱 버전은 아직 스토어에 출시된 적이 없었습니다. 그래서 사용자에게 보이는 버전은 유지하고, 내부 빌드 번호만 올려 새 바이너리를 만들었습니다. 이 선택은 당시 App Store Connect 상태에 따른 것이며, 이미 출시된 버전을 업데이트하는 상황과는 다릅니다.

EAS Submit의 자동 제출도 이름 때문에 혼동하기 쉬웠습니다. iOS의 기본 흐름에서 EAS Submit은 빌드를 App Store Connect로 업로드해 TestFlight에 보이게 합니다. 공개 App Store 심사 제출까지 대신 누르는 것은 아닙니다. 빌드 처리가 끝나면 App Store Connect에서 해당 버전에 새 빌드를 연결하고 다시 심사에 제출해야 했습니다.

다음부터는 권한 관련 반려를 보면 소스의 문구부터 고치지 않을 생각입니다. 실제 기능 호출, config plugin의 기본값, prebuild로 생성된 Info.plist를 같은 목록에 놓고 비교할 겁니다. 자동 검사는 사용자가 보지 못한 설정도 바이너리에서 먼저 읽습니다.

참고한 공식 문서

Apple App Review Guidelines

Apple App Review의 불명확한 데이터 접근 요청 안내

Expo ImagePicker 설정

Expo SecureStore 설정