Expo config plugin이 반영되지 않는 이유: prebuild와 mod 순서
생명시계 앱에 Face ID 잠금을 붙였습니다. expo-local-authentication 을 설치하고 app.json 에 플러그인과 권한 문구를 적었습니다.
[
"expo-local-authentication",
{ "faceIDPermission": "저장해 둔 생년월일을 열 때 Face ID로 잠금을 해제합니다." }
]npx expo run:ios --device 를 돌렸고 Build Succeeded, 오류 0건으로 끝났습니다. 앱은 실기기에 설치됐습니다. 여기까지는 아무 문제가 없어 보였습니다.
권한 문구가 빠진 채 설치된 앱
습관처럼 설치된 앱의 Info.plist 를 열어 봤습니다.
plutil -extract NSFaceIDUsageDescription raw .../Debug-iphoneos/Dev.app/Info.plistCould not extract value, error: No value at that key path or invalid key path: NSFaceIDUsageDescription권한 문구가 없었습니다. iOS 에서 NSFaceIDUsageDescription 없이 생체 인증을 부르면 앱이 그 자리에서 죽습니다. 빌드는 성공했고, 설치도 됐고, 실행도 됩니다. 잠금을 켜는 순간에만 죽습니다.
이 확인을 건너뛰었다면 폰에서 크래시로 만났을 겁니다. 그리고 크래시 시점에는 원인이 app.json 이라는 단서가 화면에 하나도 없습니다.
prebuild 를 건너뛰는 조건
이유는 단순했습니다. ios/ 디렉터리가 이미 있었습니다.
expo run:ios 는 네이티브 프로젝트가 없을 때만 prebuild 를 돌립니다. 이미 있으면 그대로 컴파일합니다. 그런데 config plugin 은 prebuild 때만 적용됩니다. app.json 은 컴파일러가 읽지 않고, prebuild 가 그것을 Info.plist 와 .entitlements 로 펼쳐 놓아야 비로소 빌드에 들어갑니다.
그래서 플러그인을 추가한 뒤 expo run:ios 만 돌리면, 새 플러그인이 적용되지 않은 예전 ios/ 를 그대로 씁니다. 빌드는 아무 불평 없이 성공합니다. 넣은 설정이 어디에도 없을 뿐입니다.
이 구조 자체는 Expo 가 CNG 라고 부르는 방식이고 의도된 것입니다. ios/ 는 소스가 아니라 산출물입니다. 산출물을 다시 만들지 않으면 새 입력이 반영되지 않는 것은 당연한데, 빌드가 성공한다는 사실이 그 당연함을 가려 줍니다.
npx expo prebuild --platform ios 를 직접 돌리니 문구가 들어갔습니다.
쓰지도 않는 원격 푸시가 만든 빌드 실패
그 상태로 다시 빌드했더니 이번엔 실패했습니다.
❌ Provisioning Profile "iOS Team Provisioning Profile: *" does not support the Push Notifications capability.
❌ Entitlements file defines the value "aps-environment" which is not registered for profile.
CommandError: "xcodebuild" exited with error code 65.aps-environment 는 원격 푸시용 엔타이틀먼트입니다. 그런데 이 앱은 로컬 알림만 씁니다. 서버가 없어서 푸시를 보낼 주체가 아예 없고, 개인정보 문서에도 원격 푸시를 받지 않는다고 적어 두었습니다.
붙인 것은 expo-notifications 였습니다. 플러그인 소스를 열어 보면 조건 없이 넣습니다.
config = withEntitlementsPlist(config, (config) => {
if (!config.modResults['aps-environment']) {
config.modResults['aps-environment'] = mode;
}
return config;
});옵트아웃 옵션이 없습니다. 값이 비어 있으면 넣습니다.
앞의 빌드가 통과했던 이유도 여기서 설명됩니다. 그때는 prebuild 가 안 돌아서 .entitlements 가 예전 것이었고, 거기엔 이 값이 없었습니다. prebuild 를 제대로 돌리자 값이 들어왔고, 그제서야 프로비저닝 검사에 걸렸습니다. 첫 번째 문제를 고친 것이 두 번째 문제를 꺼낸 셈입니다.
선택지는 둘이었습니다. Apple 개발자 포털에서 App ID 에 Push Notifications 를 켜거나, 쓰지 않는 엔타이틀먼트를 빼거나. 쓰지 않는 기능 때문에 capability 를 켜는 것은 앞뒤가 맞지 않고, 바이너리가 푸시를 받겠다고 선언하는데 문서는 안 받는다고 적혀 있는 상태가 됩니다. 그래서 빼기로 했습니다.
const { withEntitlementsPlist } = require('expo/config-plugins');
module.exports = function withoutPushEntitlement(config) {
return withEntitlementsPlist(config, (mod) => {
delete mod.modResults['aps-environment'];
return mod;
});
};app.json 의 plugins 맨 뒤에 넣었습니다.
지운 값이 되살아난 이유
값이 그대로 남아 있었습니다.
ios.entitlements = {"aps-environment": "development"}처음에는 플러그인이 로드되지 않았다고 생각했습니다. 경로를 잘못 적었거나 module.exports 형태가 틀렸을 것이라고 봤습니다. 확인해 보니 아니었습니다. 지우는 대신 표식을 하나 넣어 봤습니다.
mod.modResults['zz-plugin-ran'] = 'yes';
delete mod.modResults['aps-environment'];{"zz-plugin-ran": "yes", "aps-environment": "development"}표식은 들어갔고 aps-environment 도 남아 있습니다. 플러그인은 돌았습니다. 지운 값을 누군가 다시 넣은 것입니다.
넣을 수 있는 것은 expo-notifications 하나뿐입니다. 저는 그것을 제 플러그인보다 앞에 등록했으니 먼저 돌 것이라고 생각했습니다. 반대였습니다. Expo 의 mod 는 등록 순서의 역순으로 실행됩니다. 뒤에 등록한 것이 먼저 돌고, 앞에 등록한 것이 나중에 돕니다.
app.json 에서 순서를 바꿔 제 플러그인을 expo-notifications 앞으로 옮겼습니다.
ios.entitlements = {}introspect 로 드러난 실행 순서
이 확인을 빌드로 했다면 왕복 한 번에 몇 분씩 걸렸을 것이고, 실패 메시지도 프로비저닝 오류라서 원인이 순서라는 힌트를 주지 않습니다.
expo config --type introspect 가 플러그인을 전부 적용한 최종 설정을 파일에 쓰지 않고 보여 줍니다.
npx expo config --type introspect --json | jq '.ios.entitlements'prebuild 도 빌드도 없이 결과만 나옵니다. 표식을 넣어 실행 여부를 확인한 것도 이 명령 위에서였습니다. 플러그인을 건드릴 때는 이쪽을 먼저 보는 편이 빠릅니다.
정리하면 이렇습니다.
| 확인할 것 | 명령 |
|---|---|
| 플러그인이 최종 설정에 반영됐나 | expo config --type introspect |
| 생성된 네이티브 프로젝트에 들어갔나 | expo prebuild 후 Info.plist, .entitlements 직접 열기 |
| 설치된 앱에 실제로 들어갔나 | plutil -extract, codesign -d --entitlements - |
세 번째까지 봐야 끝입니다. 첫 번째 문제는 두 번째 단계에서 갈렸고, 두 번째 문제는 첫 번째 단계에서 갈렸습니다.
위젯에 같은 문제가 기다리는 자리
다음으로 홈 화면 위젯을 검토하고 있습니다. 위젯은 React Native 가 아니라 SwiftUI 로 만들고, 앱과 다른 프로세스에서 돌기 때문에 앱 샌드박스의 데이터를 못 읽습니다. 데이터를 넘기려면 App Group 엔타이틀먼트가 필요합니다.
aps-environment 와 같은 계열입니다. 엔타이틀먼트를 하나 더 붙이는 일이고, 프로비저닝 프로파일이 그것을 지원해야 하며, config plugin 이 그것을 .entitlements 에 써 넣어야 합니다. 위젯 코드는 숫자 하나를 크게 그리는 정도라 짧을 텐데, 막히는 자리는 코드가 아닐 가능성이 높습니다.
아직 시작하지 않았습니다. 다만 이번에 배운 것 덕분에 어디서 시간이 갈지는 대충 압니다. Swift 를 쓰는 날이 아니라 Apple 개발자 포털과 introspect 를 오가는 날이 될 겁니다.
함께 읽기
- Expo에서 app.json 수정이 OTA 업데이트에 반영되지 않는 이유Expo 앱의 app.json을 고친 뒤 저장소에 푸시했습니다. production 채널의 OTA 작업도 평소처럼 실행됐습니다. 배포 작업이 성공하면 변경 사항이 앱에 들어간 것처럼 느껴집니다. 이번에는 그렇지 않았습니다.
- 쓰지 않는 iOS 권한 문구 때문에 App Store 자동 검사에서 반려되는 이유앱스토어 심사라고 하면 사람이 아이폰을 들고 앱을 하나씩 눌러 보는 장면부터 떠올리기 쉽습니다. 저도 그랬습니다. 그런데 iOS 앱을 제출한 뒤 받은 반려 메시지에는 automated analysis라는 표현이 적혀 있었습니다. 사람 심사관의 판단을 기다리기도 전에 업로드한 바이너리의 권한 문구가 자동 분석에서 걸린 경…
- Expo SDK 56 앱을 App Store와 Google Play에 배포하기Expo 앱을 배포한다고 하면 보통 eas build 명령 하나를 떠올립니다. 실제 운영 배포는 빌드, 앱 서명, 스토어 업로드, 베타 테스트, 심사, 공개 출시, OTA 업데이트가 서로 다른 단계입니다. 이 구분을 놓치면 CI가 성공했는데 스토어에는 앱이 없거나, JavaScript 수정이라고 생각해 OTA를 발행했는…
- Expo로 개발할 때 자주 쓰는 실행 명령어 모음Expo 앱을 개발하다 보면 명령어보다 “지금 다시 빌드해야 하나?”가 더 헷갈립니다. 화면 코드만 바꿨는데 Gradle 빌드를 다시 돌리기도 하고, 반대로 스플래시 이미지를 바꾼 뒤 Metro만 재시작해서 왜 그대로인지 한참 보기도 합니다.
- Expo SDK 56 iOS 빌드 오류와 Swift weak let 호환성저는 견적킷을 iPhone에서 실행하려다 xcodebuild 코드 65를 만났습니다. 빌드 화면에는 Reanimated와 Worklets 컴파일 로그가 길게 지나갔고, 마지막에는 오류 15개가 있다는 메시지만 남았습니다.