Expo SDK 56 iOS 빌드 오류와 Swift weak let 호환성
저는 견적킷을 iPhone에서 실행하려다 xcodebuild 코드 65를 만났습니다. 빌드 화면에는 Reanimated와 Worklets 컴파일 로그가 길게 지나갔고, 마지막에는 오류 15개가 있다는 메시지만 남았습니다.
같은 Mac에서 Bible Map은 정상적으로 빌드되고 있었습니다. 두 앱 모두 Expo SDK 56을 쓰고 있었기 때문에 먼저 패키지 버전 차이를 의심했습니다.
실제 설치 버전 비교
package.json의 버전 범위가 아니라 lock 파일에 기록된 버전을 비교했습니다.
| 패키지 | 견적킷 | Bible Map |
|---|---|---|
| Expo | 56.0.19 | 56.0.19 |
| React Native | 0.85.3 | 0.85.3 |
| expo-modules-core | 56.0.23 | 56.0.23 |
| expo-modules-jsi | 56.0.12 | 56.0.12 |
| Worklets | 0.8.3 | 0.8.3 |
| Reanimated | 4.3.1 | 4.3.3 |
Reanimated만 패치 버전이 달랐습니다. 하지만 오류가 난 expo-modules-jsi를 비롯한 핵심 패키지는 같았습니다. 이번 빌드 실패를 Reanimated 4.3.1과 4.3.3의 차이로 설명하기는 어려웠습니다.
이름이 비슷한 패키지가 많아 각각의 역할도 다시 구분해 봤습니다.
| 패키지 | 하는 일 |
|---|---|
| Expo | React Native 앱의 설정과 빌드 도구, 네이티브 기능 패키지를 제공하는 SDK |
| React Native | JavaScript 코드와 iOS·Android 네이티브 UI를 연결하는 앱 실행 기반 |
| expo-modules-core | Expo 네이티브 모듈이 공통으로 사용하는 기반 구조 |
| expo-modules-jsi | Swift 네이티브 코드와 JavaScript 런타임을 연결하는 저수준 계층 |
| Worklets | UI 스레드 같은 별도 런타임에서 짧은 JavaScript 함수를 실행하는 엔진 |
| Reanimated | Worklets를 이용해 애니메이션을 UI 스레드에서 처리하는 라이브러리 |
오류가 난 곳은 expo-modules-jsi
전체 로그에서 처음 발생한 Swift 오류를 찾으니 expo-modules-jsi에 같은 메시지가 15번 나왔습니다.
'weak' must be a mutable variable, because it may change at runtimeXcode는 여러 빌드 대상을 병렬로 컴파일합니다. 화면에 Worklets나 Reanimated가 마지막으로 보였더라도 실제 실패 지점은 다른 모듈일 수 있습니다. 함께 출력된 Script has ambiguous dependencies도 스크립트가 빌드마다 실행될 수 있다는 경고일 뿐, 코드 65를 만든 오류는 아니었습니다.
문제가 된 Swift 코드는 다음과 같은 형태였습니다.
weak let runtime: JavaScriptRuntime?견적킷을 빌드한 환경은 Xcode 26.1.1과 Swift 6.2.1이었습니다. weak let은 Swift Evolution SE-0481에서 추가됐고 Swift 6.3부터 구현됐습니다. Swift 6.2에서는 weak 참조가 런타임에 nil로 바뀔 수 있다는 이유로 let을 허용하지 않습니다.
Expo SDK 56 공식 문서가 iOS 빌드에 Xcode 26.4 이상을 요구하는 이유가 여기에 있었습니다. 패키지는 같았지만 견적킷을 빌드하는 Swift 컴파일러가 SDK 56의 코드를 이해하지 못한 것입니다.
Bible Map의 patch-package
Bible Map에는 patch-package와 postinstall 설정이 들어 있었습니다. npm install이 끝날 때마다 expo-modules-jsi의 코드를 다음과 비슷하게 바꾸는 패치였습니다.
- weak let runtime: JavaScriptRuntime?
+ nonisolated(unsafe) weak var runtime: JavaScriptRuntime?일부 Sendable 타입에는 @unchecked Sendable도 적용돼 있었습니다. 그래서 Bible Map은 같은 Xcode와 Swift 버전에서도 컴파일됐습니다. 두 앱의 결정적인 차이는 Reanimated의 패치 버전보다 이 Swift 호환 패치였습니다.
다만 nonisolated(unsafe)와 @unchecked Sendable은 컴파일러의 동시성 안전 검사를 우회합니다. 코드가 안전하다는 책임을 개발자가 직접 지는 방식이므로 장기적인 해결책으로 삼기에는 부담이 있습니다.
공식 해법은 Xcode 26.4 이상
Expo 유지보수자도 관련 이슈에서 SDK 56은 Xcode 26.4 이상과 Swift 6.3을 전제로 한다고 설명합니다. weak var로 바꾸는 패치는 권장하지 않으며, 빌드 환경을 올리는 것이 공식 해결책입니다.
따라서 견적킷은 Xcode를 26.4 이상으로 올리는 것이 우선입니다. 당장 개발을 이어가야 한다면 Bible Map의 패치를 임시로 적용할 수 있지만, Xcode를 올린 뒤에는 패치를 제거하고 깨끗한 상태에서 다시 빌드하는 편이 안전합니다.
이번 오류에서는 패키지 버전표보다 빌드 도구와 postinstall이 더 중요한 단서였습니다. 두 Expo 앱의 동작이 다르다면 lock 파일뿐 아니라 Xcode·Swift 버전, 적용된 패치와 전체 빌드 로그까지 함께 봐야 합니다.
우리 서비스에 적용할 계획이 있으신가요?
모바일 앱 개발 살펴보기 →함께 읽기
- Expo SDK 56 앱을 App Store와 Google Play에 배포하기Expo 앱을 배포한다고 하면 보통 eas build 명령 하나를 떠올립니다. 실제 운영 배포는 빌드, 앱 서명, 스토어 업로드, 베타 테스트, 심사, 공개 출시, OTA 업데이트가 서로 다른 단계입니다. 이 구분을 놓치면 CI가 성공했는데 스토어에는 앱이 없거나, JavaScript 수정이라고 생각해 OTA를 발행했는…
- 쓰지 않는 iOS 권한 문구 때문에 App Store 자동 검사에서 반려되는 이유앱스토어 심사라고 하면 사람이 아이폰을 들고 앱을 하나씩 눌러 보는 장면부터 떠올리기 쉽습니다. 저도 그랬습니다. 그런데 iOS 앱을 제출한 뒤 받은 반려 메시지에는 automated analysis라는 표현이 적혀 있었습니다. 사람 심사관의 판단을 기다리기도 전에 업로드한 바이너리의 권한 문구가 자동 분석에서 걸린 경…
- Expo로 개발할 때 자주 쓰는 실행 명령어 모음Expo 앱을 개발하다 보면 명령어보다 “지금 다시 빌드해야 하나?”가 더 헷갈립니다. 화면 코드만 바꿨는데 Gradle 빌드를 다시 돌리기도 하고, 반대로 스플래시 이미지를 바꾼 뒤 Metro만 재시작해서 왜 그대로인지 한참 보기도 합니다.
- Expo config plugin이 반영되지 않는 이유: prebuild와 mod 순서생명시계 앱에 Face ID 잠금을 붙였습니다. expo-local-authentication 을 설치하고 app.json 에 플러그인과 권한 문구를 적었습니다.
- Expo의 역사: SDK 0에서 CNG와 개발 빌드까지Expo를 설명할 때 가장 자주 붙는 말은 "React Native를 쉽게 쓰게 해 주는 도구"입니다. 틀린 설명은 아니지만 지금의 Expo를 이해하기에는 범위가 너무 좁습니다. Expo는 미리 만들어진 앱에서 JavaScript를 실행하던 초기 경험을 출발점으로 삼았고, 지금은 네이티브 프로젝트 생성과 모듈 개발, 라…