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 버전, 적용된 패치와 전체 빌드 로그까지 함께 봐야 합니다.
함께 읽기
- 모바일 앱 백업 설계: 암호화를 걷어내고 이미지 복원을 고친 과정영업 자료를 단말 안에 보관하는 앱에 백업 기능을 붙였습니다. 저장 대상은 멘트, 사진, 문서, 링크, 정보 카드였습니다. 처음에는 백업 파일 전체를 비밀번호로 암호화했습니다. 백업이 앱 밖으로 나가니 당연한 선택처럼 보였습니다.
- Liquid Glass 아이콘 직접 만들기: .icon 파일 구조와 librsvg 함정앱 아이콘 하나 만드는 데 하루를 썼습니다. 열 번 넘게 갈아엎었고 빌드를 네 번 중간에 끊었습니다. 그 과정에서 알아낸 것 중에 검색해도 잘 안 나오는 게 두 개 있어서 적어둡니다.
- Expo로 개발할 때 자주 쓰는 실행 명령어 모음Expo 앱을 개발하다 보면 명령어보다 “지금 다시 빌드해야 하나?”가 더 헷갈립니다. 화면 코드만 바꿨는데 Gradle 빌드를 다시 돌리기도 하고, 반대로 스플래시 이미지를 바꾼 뒤 Metro만 재시작해서 왜 그대로인지 한참 보기도 합니다.
- WebRTC 기반 AI 상담원: 딥페이크 영상 응답을 앱에서 재생한 방법AI 상담원에서 딥페이크 영상과 합성 음성은 생성만큼 전달 방식이 중요합니다. 사용자가 발화를 마친 뒤 화면이 오래 비거나, 음성과 입 모양이 어긋나면 응답 품질이 급격히 떨어집니다.
- Expo OTA 배포: Git 푸시와 프로덕션 릴리스를 분리한 이유앱 아이콘을 바꾸고 커밋하려다가 아주 기본적인 질문 앞에서 멈췄습니다.