Expo에서 app.json 수정이 OTA 업데이트에 반영되지 않는 이유

Expo 앱의 app.json을 고친 뒤 저장소에 푸시했습니다. production 채널의 OTA 작업도 평소처럼 실행됐습니다. 배포 작업이 성공하면 변경 사항이 앱에 들어간 것처럼 느껴집니다. 이번에는 그렇지 않았습니다.

수정한 것은 iOS 권한 요청 문구였습니다. 사용하지 않는 카메라·마이크·Face ID 설명을 없애고, 실제로 사용하는 사진 보관함 설명을 구체적으로 바꿨습니다. OTA 작업은 끝났지만 App Store에 제출할 바이너리의 Info.plist는 이전 그대로였습니다.

app.json도 JSON이니 업데이트에 포함될 줄 알았습니다

처음에는 app.json이 JavaScript 프로젝트 안의 설정 파일이므로 OTA에 함께 실릴 거라고 생각했습니다. 화면 문구를 바꿀 때와 권한 문구를 바꿀 때 같은 커밋과 같은 배포 워크플로가 움직였기 때문입니다.

이 가설은 생성 결과를 확인하면서 깨졌습니다. Expo config plugin은 빌드 전에 app.json을 읽어 iOS의 Info.plist, Android의 manifest와 같은 네이티브 파일을 만듭니다. 권한 문구는 앱이 실행된 뒤 JavaScript가 읽어 보여 주는 텍스트가 아니라, 운영체제가 바이너리에서 읽는 네이티브 설정입니다.

즉 파일의 확장자가 JSON인지 아닌지는 기준이 아니었습니다. 그 설정이 최종적으로 JavaScript 번들에 들어가는지, 네이티브 바이너리에 구워지는지가 기준이었습니다.

Expo 앱에는 바뀌는 층과 고정된 층이 있습니다

Expo 공식 문서는 빌드를 두 층으로 설명합니다. 네이티브 코드는 앱 바이너리에 들어가며 OTA로 바꿀 수 없습니다. 그 위의 업데이트 층에는 호환되는 JavaScript 코드와 에셋을 배포할 수 있습니다.

제가 작업할 때는 다음처럼 구분합니다.

변경 내용 | OTA로 처리한 사례 | 새 빌드가 필요한 사례
화면 로직·문구·스타일 | JavaScript/TypeScript 수정 |
네이티브 권한 설명 | | Info.plist, Android manifest
라이브러리 | 기존 네이티브 모듈을 사용하는 JS 수정 | 새 네이티브 모듈 설치·업그레이드
앱 설정 | 런타임에서 읽는 업데이트 데이터 | config plugin이 생성하는 설정
시스템 통합 | | entitlement, URL scheme, 앱 아이콘 등

이 표의 목적은 “app.json은 전부 빌드”라고 외우는 것이 아닙니다. 같은 파일 안에서도 어떤 항목은 업데이트 메타데이터에 영향을 주고, 어떤 항목은 네이티브 설정을 생성합니다. 변경한 키가 어느 결과물로 가는지 확인해야 합니다.

runtimeVersion은 안전선이지 변환기가 아닙니다

EAS Update는 runtimeVersion으로 업데이트와 설치된 바이너리의 호환성을 판단합니다. 새 OTA가 기존 바이너리에 없는 네이티브 API를 호출하면 앱이 실행될 수 없기 때문입니다.

{
"expo": {
"runtimeVersion": {
"policy": "appVersion"
}
}
}

여기서도 오해할 수 있습니다. runtime version이 맞는다고 해서 OTA가 네이티브 설정을 바꿔 주지는 않습니다. 이것은 “이 업데이트를 이 바이너리가 실행해도 되는가”를 구분하는 호환성 표지입니다. 네이티브 코드나 설정을 바꿨다면 그 내용을 담은 새 빌드가 먼저 있어야 합니다.

Expo는 네이티브 변경을 자주 하는 프로젝트에서 fingerprint 정책도 안내합니다. 네이티브 런타임에 영향을 줄 수 있는 프로젝트 변경으로부터 값을 계산해 호환되지 않는 업데이트가 기존 빌드에 전달될 가능성을 줄이는 방식입니다. 어떤 정책을 쓰든 새 바이너리가 필요한 변경 자체를 OTA로 바꾸는 것은 아닙니다.

저는 생성 파일을 확인하고 빌드를 교체했습니다

이번 수정은 아래 순서로 처리했습니다.

app.json의 플러그인 옵션에서 사용하지 않는 권한 문구를 제거했습니다.

expo prebuild로 iOS 프로젝트를 다시 생성했습니다.

생성된 Info.plist에 실제 사용하는 사진 보관함 문구만 남았는지 확인했습니다.

iOS production 빌드를 새로 만들면서 빌드 번호를 올렸습니다.

TestFlight 처리가 끝난 뒤 App Store Connect에서 새 빌드를 선택해 심사에 다시 제출하도록 구분했습니다.

네이티브 생성 폴더가 Git에서 제외되어 있으면 저장소 상태가 clean으로 보여도 이상하지 않습니다. clean은 생성 파일이 바뀌지 않았다는 뜻이 아니라 추적 대상 파일에 변경이 없다는 뜻입니다. 그래서 prebuild 결과를 눈으로 확인하는 단계가 필요했습니다.

당시 앱 버전은 아직 출시되지 않았습니다. 사용자에게 보이는 버전은 유지하고 내부 빌드 번호만 올렸습니다. 심사에서 반려된 바이너리를 같은 버전의 새 빌드로 교체한 사례입니다. 이미 출시된 버전의 업데이트라면 App Store Connect의 버전 상태에 맞춰 별도로 판단해야 합니다.

EAS Submit 다음에도 버튼 하나가 남았습니다

eas build --auto-submit이나 eas submit의 “submit”은 공개 심사 제출과 같은 말처럼 보입니다. iOS 기본 동작은 바이너리를 App Store Connect에 업로드하고 TestFlight에서 사용할 수 있게 만드는 단계입니다. Expo 문서에도 공개 출시를 위해서는 App Store Connect에 로그인해 해당 빌드를 App Review로 제출해야 한다고 적혀 있습니다.

자동화 상태를 볼 때 저는 이제 세 단계를 따로 봅니다.

OTA 배포가 성공했는가

새 네이티브 바이너리가 빌드되고 TestFlight에 올라갔는가

App Store Connect에서 그 빌드를 버전에 연결해 심사 제출했는가

세 화면이 모두 초록색일 필요는 없습니다. JavaScript만 고쳤다면 첫 번째로 끝날 수 있습니다. 권한 문구를 고쳤다면 첫 번째의 성공 여부와 관계없이 두 번째와 세 번째가 필요했습니다.

다음에는 커밋 전에 변경된 파일 이름보다 변경 결과를 먼저 볼 생각입니다. Info.plist, manifest, entitlement, 네이티브 모듈, config plugin 출력 중 하나가 달라지면 새 빌드 쪽에 놓습니다. 화면 로직과 스타일처럼 기존 런타임 안에서 실행되는 변경만 OTA 후보로 둡니다.

참고한 공식 문서

Expo Runtime versions and updates

Expo Updates

EAS Submit으로 iOS 앱 제출하기

EAS 자동 제출의 기본 동작