React Native 앱에서 딥링크를 구현한 뒤, OTA 방식으로 업데이트를 배포했더니 실기기 TestFlight 앱에서 딥링크가 전혀 작동하지 않는 문제가 발생했다. 시뮬레이터에서는 정상적으로 동작했기 때문에 원인을 찾는 데 꽤 시간이 걸렸는데, 결론부터 말하면 OTA 업데이트의 근본적인 한계 때문이었다.
프로젝트에서 Universal Links/App Liknks를 활용한 딥링크를 구현했다. React Navigation의 linking config를 설정하고, iOS 네이티브 쪽에서는 AppDelegate에 딥링크 핸들러를 추가하고, entitlements에 Associated Domains를 등록하고, AASA 파일도 서버에 올려두었다.
개발 중 시뮬레이터에서 테스트했을 때는 딥링크가 잘 동작했다. 그래서 OTA 업데이트로 배포를 진행했다. 그런데 TestFlight로 설치한 실기기 앱에서 딥링크를 눌러도 아무 반응이 없었다. 메일 앱에 링크를 저장해두고 탭하는 방식으로 테스트했기 때문에 Safari 주소창 직접 입력 문제도 아니었다.
처음에는 AASA 파일 문제인가 싶어서 서버의 apple-app-site-association 파일을 확인했고, Apple CDN 캐시까지 조회해봤지만 전부 정상이었다. entitlements 설정도 맞고, Apple Developer Portal에서 Associated Domains도 활성화되어 있었다. 도대체 뭐가 문제인 건지 한참을 헤맸다.
원인을 이해하려면 먼저 React Native 앱의 구조를 알아야 했다.
React Native 앱은 크게 두 개의 층으로 구성되어 있다. 하나는 우리가 일상적으로 작성하는 React 컴포넌트, 네비게이션 설정, 비즈니스 로직 같은 JavaScript 코드이고, 다른 하나는 iOS의 AppDelegate, Info.plist, Entitlements, Android의 AndroidManifest, MainActivity 같은 네이티브 레이어다. 웹 개발에 비유하자면 JS 번들은 우리가 빌드해서 배포하는 프론트엔드 코드이고, 네이티브 레이어는 Nginx 설정이 SSL 인증서, DNS 설정 같은 인프라에 해당한다.
여기서 OTA 업데이트라는 개념이 등장한다. OTA는 Over-The-Air의 약자로, 앱 스토어를 거치지 않고 앱을 업데이트하는 방식이다. 이 프로젝트에서는 react-native-ota-hot-update라는 라이브러리를 사용하고 있었다. OTA 업데이트가 하는 일은 간단하다. 앱이 실행될 때 서버에 새로운 JS 번들이 있는지 확인하고, 있으면 다운로드해서 기존 JS 번들을 교체하는 것이다. 웹으로 치면 서버 인프라는 그대로 두고 dist 폴더에 있는 빌드 파일만 새로 올리는 것과 같다.
핵심은 OTA가 교체하는 건 JS 번들뿐이라는 점이다. 네이티브 레이어는 건드리지 못한다.
그런데 딥링크가 작동하는 흐름을 보면, 대부분의 과정이 네이티브 레이어에서 일어난다. 사용자가 링크를 탭하면, 먼저 OS가 앱이 해당 URL을 처리할 자격이 있는지 검증한다. iOS에서는 Entitlements에 등록된 Associated Domains와 서버의 AASA 파일을 대조하고, Android에서는 AndroidManifest의 intent-filter와 서버의 assetlinks.json을 대조한다. 검증이 통과되면 네이티브의 딥링크 핸들러가 URL을 수신하고, 이걸 React Native 브릿지를 통해 JS 쪽으로 전달한다. 그제서야 React Navigation의 linking config가 URL을 파싱해서 해당 화면으로 이동시킨다.
이 전체 흐름에서 JS가 담당하는 건 마지막 단계인 URL 파싱과 화면 이동뿐이다. 그 앞의 모든 과정, 즉 OS가 URL을 앱으로 보내줄지 말지를 결정하는 과정은 iOS든 Android든 전부 네이티브 레이어에서 처리된다.
결국 문제는 이거였다. 스토어에 올라가 있는 바이너리는 딥링크 관련 네이티브 코드가 추가되기 이전 버전이었다. 이후 딥링크 기능을 구현하면서 iOS에서는 AppDelegate에 핸들러를 추가하고 Entitlements를 설정했고, Android에서는 AndroidManifest에 intent-filter를 추가했고, 공통으로 React Navigation linking config를 작성했지만, 배포를 OTA로 했기 때문에 JS 코드만 업데이트된 것이다. 네이티브 레이어는 양 플랫폼 모두 옛날 버전 그대로였고, OS 입장에서는 이 앱이 딥링크를 처리할 수 있다는 걸 전혀 알지 못하는 상태였다.
해결 방법은 단순했다. Xcode에서 최신 코드로 다시 Archive하고 TestFlight에 새 바이너리를 업로드하면 된다. OTA가 아닌 일반 배포를 통해 네이티브 레이어까지 포함된 전체 앱을 다시 올리는 것이다.
OTA 업데이트는 빠르고 편리하지만, 그 범위가 JS 번들에 한정된다는 걸 반드시 인지하고 있어야 한다. 네이티브 레이어에 변경이 생기는 경우, 예를 들어 딥링크 설정, 새로운 네이티브 라이브러리 추가, 권한 설정 변경, 앱 아이콘 변경 같은 작업이 포함되어 있다면 OTA만으로는 절대 반영되지 않는다. 이런 변경사항이 있을 때는 반드시 새로운 바이너리를 빌드해서 TestFlight이나 App Store를 통해 배포해야 한다.