[Part 1] Bridge에서 New Architecture로 — RN 0.73 → 0.79 업그레이드
2026.03.28
1. Bridge가 한 일과 한계
React Native 0.73까지의 아키텍처를 한 줄로 요약하면 JavaScript 세계와 Native 세계는 완전히 분리되어 있고, 두 세계는 Bridge라는 메시지 큐를 통해서만 대화한다.
이 구조는 두 세계를 깔끔하게 격리해 준다. 대신 대가가 있다.
모든 통신이
-
JSON으로 직렬화되고
-
비동기로 큐를 통과해야 한다.
단순한 데이터 교환이라면 무시할 만한 비용이지만, RN 앱에서 가장 빈번한 통신이 “렌더링”이라는 게 문제였다.
렌더링이 Bridge를 어떻게 통과하는가
화면이 한 번 갱신되는 흐름을 따라가 보면 다음과 같다.
- JS 스레드에서 React 트리가 변경된다.
- 변경된 트리로부터 Shadow Tree(레이아웃 정보)를 만든다.
- 이 Shadow Tree를 JSON으로 직렬화해 Bridge에 던진다.
- Native 측이 받아서 Yoga 엔진으로 레이아웃을 계산한다.
- 결과를 다시 직렬화해 UI 스레드로 보낸다.
- UI 스레드가 실제 화면을 그린다.
이 과정 전체가 비동기다. JS가 “이 버튼 위치 어디야?”라고 Native에 동기적으로 물어볼 수 없다. Bridge에 메시지를 던지고 응답이 돌아올 때까지 다른 일을 해야 한다.
Bridge가 만들어낸 세 가지 병목
이 구조가 만든 한계는 한 가지가 아니라 세 가지다. 각각 New Architecture의 어느 축이 푸는지가 정해져 있어서, 이 구분을 먼저 해두면 다음 섹션이 훨씬 잘 들어온다.
(1) 앱 시작 시간 - 모듈 일괄 초기화
앱이 시작될 때 모든 NativeModule이 한꺼번에 초기화된다. 카메라, 위치, 블루투스, 결제 모듈… 당장 안 쓰는 것까지 전부. 한 번도 카메라 화면에 안 들어가는 사용자도 카메라 모듈 초기화 비용을 고스란히 낸다. RN 앱의 콜드 스타트가 무거운 가장 큰 이유 중 하나가 이것이다.
(2) 빈번한 호출에서의 누적 지연 - JSON 직렬화
onScroll은 초당 60번 발화한다. 매 호출마다 JSON 직렬화/역직렬화가 들어가면 이벤트 단가가 누적되어 지연이 보인다. 빠른 스크롤에서 프레임이 떨어지거나, 제스처 응답이 한 박자 늦게 오는 게 여기서 온다.
(3) 동기 응답 불가능 - 비동기 큐
이게 가장 본질적인 한계다. 터치/제스처처럼 “지금 즉시” 처리해야 하는 입력이 있어도, JS와 Native가 동기적으로 대화할 수 없다. 결과가 한 프레임 늦게 반영되거나, 우선순위가 다른 업데이트가 같은 큐를 공유해서 중요한 업데이트가 지연된다.
세 병목은 결이 다르다. 한 가지 방법으로 다 풀 수 없다는 뜻이다. New Architecture가 4축으로 설계된 이유다
JSI는 Bridge 자체를 걷어내고, TurboModules는 일괄 초기화를 필요할 때로 미루고, Fabric은 동기 렌더링을 가능하게 하고, Codegen은 위 셋이 안전하게 돌도록 빌드타임에 타입을 맞춘다.
2. JSI / TurboModules / Fabric / Codegen - 4축이 각각 푸는 문제
New Architecture는 Bridge 하나를 걷어내는 단순한 변경이 아니다. 네 가지 구성 요소가 서로 의존하면서, 세 병목을 각각 해결한다. 아래에서 위로 쌓이는 레이어 구조인 것이다.
JSI - 가장 밑바닥 기반 기술
JSI(JavaScript Interface)는 JS 엔진(Hermes, JSC)과 C++ 사이의 통역 계층이다. 더 정확히 말하면 통역사를 두지 않는 통역 계층이다.
기존 Bridge가 두 사람이 통역사한테 메모를 건네 전달받는 방식이었다면, JSI는 두 사람이 같은 언어를 배워서 직접 대화하는 방식이다. 기술적으로는 JS 코드에서 Native 함수를 호출할 때 JSON 직렬화 없이 메모리 상의 C++ 객체를 직접 참조한다. 이것이 가능해진 순간, Bridge가 풀어주던 세 병목 중 JSON 직렬화 비용과 비동기 큐의 토대가 사라진다.
JSI 자체는 기능이 아니다. 그 위에 올라가는 TurboModules, Fabric의 토대다. 그래서 JSI를 켜면 뭐가 빨라지나요?라는 질문은 약간 어긋나 있다. JSI는 그 자체로 이득을 주지 않고, JSI 위에 올라가는 것들이 이득을 만든다.
TurboModules - 모듈 일괄 초기화 문제를 푼다
TurboModules는 기존 NativeModules를 대체한다. 두 가지 변화가 있다.
첫째, 지연 로딩(lazy loading). 카메라 모듈은 사용자가 카메라 화면에 들어가는 그 순간에 초기화된다. 한 번도 안 들어가면 한 번도 초기화되지 않는다. RN 앱이 가진 100개 모듈을 시작 시점에 한꺼번에 부르는 비용이 사라진다.
둘째, JSI 위에서 직접 호출. 기존에는 NativeModules.CameraModule.takePhoto()가 Bridge를 통해 JSON으로 변환되어 전달됐지만, TurboModule에서는 JS가 C++ 인터페이스를 통해 Native 함수를 동기적으로도 호출할 수 있다.
→ 푸는 병목: 앱 시작 시간
Fabric - 동기 렌더링이 가능한 새 렌더러
Fabric은 기존 렌더러를 대체한다. 가장 큰 변화는 동기식 렌더링이 가능해진 것이다.
기존에는 JS 스레드 → Bridge → Shadow 스레드 → Bridge → UI 스레드의 비동기 파이프라인이었다. Fabric에서는 JSI 덕분에 JS에서 Shadow Tree를 직접 생성하고 조작할 수 있다. 결과적으로 우선순위 높은 UI 업데이트(터치, 제스처 등)를 동기적으로 처리할 수 있고, 사용자 체감 반응성이 좋아진다.
동기적으로 처리할 수 있다라는 표현이 중요하다. 모든 업데이트가 동기로 가는 게 아니라, 우선순위에 따라 동기/비동기를 골라 쓸 수 있게 됐다는 뜻이다.
→ 푸는 병목: 빈번한 호출에서의 누적 지연 + 동기 응답 불가능
Codegen - 안전벨트
위 셋은 직접 대화가 핵심이다. 그런데 직접 대화에는 위험이 따른다. Bridge에서는 JSON이라는 공통 포맷이 있어서 타입 불일치가 런타임 크래시로 나타났다. JSI에서는 JS와 C++이 직접 메모리를 참조하기 때문에, 타입이 안 맞으면 더 위험한 미정의 동작(undefined behavior)으로 이어진다.
Codegen은 이 위험을 빌드타임으로 옮긴다. JS(TS)에서 정의한 인터페이스 스펙을 바탕으로 Native 측 C++/Java/Objective-C 바인딩 코드를 빌드 시에 자동 생성한다. TurboModule 인터페이스와 Fabric 컴포넌트 props가 JS와 Native 양쪽에서 일치하는지 컴파일 시점에 검증된다.
스펙 파일(Native*.ts, *NativeComponent.ts)을 작성하면 빌드 과정에서 Codegen이 돌면서 네이티브 바인딩이 생성된다. 0.79에서는 별도 설정 없이 React Native Gradle Plugin이 알아서 처리하므로, 사용 측에서는 돌고 있다는 사실을 의식할 일이 거의 없다.
→ 푸는 병목: JSI/TM/Fabric의 안전성
의존 관계
네 축은 평등하지 않다. 의존 순서가 분명하다.
┌──────────────────────────────────────┐
│ Codegen │ ← 빌드타임 타입 검증
├──────────────────────────────────────┤
│ TurboModules │ Fabric │ ← lazy + 동기
├──────────────────────────────────────┤
│ JSI │ ← 토대
└──────────────────────────────────────┘
JSI 없이는 TurboModules도 Fabric도 못 만든다. Codegen은 위에서 안전벨트로 둘러싼다. 그래서 New Architecture를 켠다라는 건 사실 이 4축 전체를 활성화한다라는 의미고 호환성을 위해 Bridge도 살려둔 채 위 4축을 켜는 인터옵 모드가 따로 마련되어 있다. 우리가 0.79로 가면서 정확히 이 인터옵 모드를 골랐다.
3. 빌드 시스템 변화 - 개념이 빌드 파일에 어떻게 박히는가
4축이 각각 어떤 빌드 변화로 이어질까. 0.73에서 0.79로 올리면서 바뀐 빌드 파일은 의외로 일관된 흐름을 가지고 있다. Bridge 시대의 수동 등록을 걷어내고, New Arch 시대의 자동 통합으로 밀어 넣는다라는 것이다.
SoLoader 초기화 - JSI가 의존하는 .so 매핑
// MainApplication.java
- SoLoader.init(this, /* native exopackage */ false);
+ SoLoader.init(this, OpenSourceMergedSoMapping.INSTANCE);JSI가 C++ 객체를 직접 참조하는 인터페이스라고 했다. 그 C++ 객체들은 .so(공유 라이브러리)로 묶여 있고, 어느 .so가 어느 .so를 참조하는지의 의존 그래프가 New Architecture 들어와서는 명시적이 됐다. 두 번째 인자 false로 충분했던 이유는 Bridge 시절 의존 관계가 단순했기 때문이고, OpenSourceMergedSoMapping은 JSI/TurboModules/Fabric이 사용하는 라이브러리 의존 매핑을 담은 객체다.
C++20 — Fabric/JSI 자체가 C++로 작성되어 있다
# ios/Podfile
+ config.build_settings['CLANG_CXX_LANGUAGE_STANDARD'] = 'c++20'
+ config.build_settings['OTHER_CPLUSPLUSFLAGS'] = "$(inherited) -DFOLLY_NO_CONFIG -DFOLLY_MOBILE=1 ..."JSI와 Fabric의 내부 구현이 C++로 작성되어 있고, RN 0.77부터 C++20을 요구한다. Folly는 Facebook의 C++ 유틸리티 라이브러리로 JSI/Fabric 내부에서 쓰인다. 우리가 직접 C++ 코드를 작성할 일은 거의 없지만, 빌드가 깨지지 않으려면 표준을 맞춰줘야 한다.
Flipper 제거 - Bridge 기반 디버거였기에
// android/app/build.gradle
- debugImplementation("com.facebook.flipper:flipper:${FLIPPER_VERSION}")
- debugImplementation("com.facebook.flipper:flipper-network-plugin:${FLIPPER_VERSION}")
- debugImplementation("com.facebook.flipper:flipper-fresco-plugin:${FLIPPER_VERSION}")
+ // Flipper removed in RN 0.78# ios/Podfile
- flipper_config = ENV['NO_FLIPPER'] == "1" ? FlipperConfiguration.disabled : FlipperConfiguration.enabled
+ # Flipper removed in RN 0.78Flipper는 Bridge를 가로채서 메시지를 들여다보는 방식의 디버거였다. RN 0.78에서 공식 제거됐고, 디버깅은 React Native DevTools(Hermes 디버거)로 옮겨가야 한다.
feature flag - TurboModules / Fabric / Bridgeless
// MainApplication.java
@Override
public void onCreate() {
super.onCreate();
// 1) SoLoader 먼저
SoLoader.init(this, OpenSourceMergedSoMapping.INSTANCE);
// 2) New Architecture entry point — feature flag 설정
// ReactNativeHost 생성 전에 호출되어야 한다
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
DefaultNewArchitectureEntryPoint.load(
true, // turboModulesEnabled
true, // fabricEnabled
false // bridgelessEnabled — Bridge 모드 유지
);
}
}여기서 우리가 어떤 모드로 갔는지가 한 번에 보인다. TurboModules와 Fabric은 켰지만 Bridgeless는 끄고 갔다. 즉 New Architecture를 쓰되 Bridge도 호환성을 위해 살려둔다는 인터옵 모드다.
호출 순서가 중요하다. 주석에도 ReactNativeHost 생성 전에 호출되어야 함이라고 되어 있는데 feature flag가 먼저 세팅돼야 ReactNativeHost가 올바른 TurboModuleManagerDelegate로 초기화되기 때문이다. 이 순서 문제 때문에 우리 코드베이스에서 MMKV 초기화 에러가 발생했었다.
MainActivity - flag 통합
// MainActivity.java
- DefaultNewArchitectureEntryPoint.getFabricEnabled(),
- DefaultNewArchitectureEntryPoint.getConcurrentReactEnabled());
+ BuildConfig.IS_NEW_ARCHITECTURE_ENABLED);0.73에서는 Fabric 활성화와 Concurrent React 활성화를 따로 전달했지만, 0.77에서는 IS_NEW_ARCHITECTURE_ENABLED 하나로 통합됐다. New Arch를 켜면 둘 다 활성화되는 구조로 단순화된 셈이다.
Codegen - Gradle 플러그인에 통합됨
// android/app/build.gradle
apply plugin: "com.facebook.react" // ← Codegen 내장
react {
autolinkLibrariesWithApp() // ← 서드파티 Codegen 스펙도 자동 처리
}// android/build.gradle
plugins {
id("com.facebook.react.rootproject") // ← 루트에서 Codegen 태스크 등록
id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
}0.73 이전에는 package.json에 codegenConfig 블록을 명시적으로 적거나 react-native-codegen 패키지를 별도 설정해야 했다. 0.79에서는 Gradle 플러그인 안으로 흡수되어 명시적 설정 없이도 동작한다. 빌드 로그에서 :generateCodegenArtifactsFromSchema 태스크가 도는 걸 확인할 수 있다.
./gradlew assembleDebug --info | grep -i codegensettings.gradle - autolinking이 자동이 된 의미
// 기존 (Bridge 시대 — 수동 모듈 등록)
- include ':react-native-haptic-feedback'
- project(':react-native-haptic-feedback').projectDir = ...
- include ':lottie-react-native'
- project(':lottie-react-native').projectDir = ...
- apply from: file("../node_modules/@react-native-community/cli-platform-android/native_modules.gradle")
- applyNativeModulesSettingsGradle(settings)
// 신규 (New Architecture — 자동 링킹)
+ pluginManagement {
+ includeBuild("../node_modules/@react-native/gradle-plugin")
+ }
+ plugins {
+ id("com.facebook.react.settings")
+ }
+ extensions.configure(com.facebook.react.ReactSettingsExtension) { ex ->
+ ex.autolinkLibrariesFromCommand()
+ }기존에는 라이브러리마다 include로 등록하고 경로를 찍는 Bridge 시대 수동 등록 시스템이었다. 신규에서는 React Native Gradle Plugin이 autolinkLibrariesFromCommand()로 모든 라이브러리를 탐지하고 더 중요한 건 Codegen 스펙까지 자동으로 처리한다. app/build.gradle에서 apply from: ".../native_modules.gradle"도 함께 사라진다.
이 변경의 의미는 단순한 자동화가 아니다. Codegen이 모든 RN 라이브러리의 표준 통합 지점이 됐다는 것이다. 라이브러리 메인테이너 입장에서도 spec 파일만 잘 작성해 두면 사용 측에서는 별도 설정 없이 잡힌다.
4. 16KB page size 대응
16KB page size가 뭔가
운영체제가 메모리를 관리하는 단위가 페이지다. 기존 Android는 모든 기기가 4KB 페이지였다. 최신 ARM 프로세서와 Android 15+ 일부 기기(Google Pixel 9 등)는 16KB 페이지를 쓴다. 16KB 페이지의 장점은 TLB(Translation Lookaside Buffer) 효율이 높아져서 메모리 접근 성능이 향상된다는 것이다.
OS 레벨 변경이지 앱 개발자 영역이 아니다 라고 생각하면 함정이 있다.
왜 .so 파일 정렬이 깨지는가
문제는 네이티브 코드(.so 파일)의 ELF alignment에 있다. .so 파일의 PT_LOAD 세그먼트가 16KB 경계에 정렬되어 있지 않으면, 16KB 페이지 기기에서 OS가 .so를 메모리에 매핑하는 단계에서 실패한다. 로딩 자체가 안 되는 것이지 성능 문제가 아니다. 화면이 느려지는 것과는 달리, 앱이 시작 단계에서 죽거나 특정 모듈이 로드되는 화면에서 크래시한다.
기존에는 4KB 경계로 정렬되어 빌드된 .so 파일이 16KB 페이지 기기에서 로드 불가 상태가 된다. RN 앱이 이걸 피할 수 없는 이유 — Hermes 엔진(libhermes.so)을 비롯해 RN의 핵심이 .so로 묶여 있기 때문이다.
왜 .dex는 괜찮은가
같은 함정이 Java/Kotlin 코드에도 있냐 하면 없다. Java/Kotlin은 .class → .dex로 컴파일되고, .dex는 ART(Android Runtime) 가상머신 위에서 실행된다. ART는 OS의 페이지 크기와 무관하게 동작하므로 16KB와 상관이 없다.
이 구분이 중요한 이유는 우리가 쓰는 라이브러리 중 순수 Java/Kotlin인 것(예: FlareLane SDK)은 16KB 정렬 신경 안 써도 된다. C/C++로 짜인 NDK 라이브러리만 신경 쓰면 된다.
| 코드 종류 | 파일 형태 | 실행 환경 | 16KB 정렬 필요? |
|---|---|---|---|
| Java/Kotlin | .dex |
ART | 아니오 |
| C/C++ (NDK) | .so |
OS 위 직접 | 예 |
React Native 0.76+ 가 푼 부분
0.76부터 Hermes 엔진과 RN 핵심 네이티브 코드가 16KB 호환으로 빌드되기 시작했다. 0.73에서는 이 지원이 없어서 16KB 기기에서 RN 앱이 정상 동작하지 않을 위험이 있다. 즉 Pixel 9 같은 디바이스의 사용자 점유율이 늘기 전에 0.76 이상으로 올리는 것이 사실상 데드라인이 있는 작업이다. 성능 개선이 좋아서 가는 게 아니라 안 가면 깨진다.
빌드 설정 변경
// android/build.gradle
- ndkVersion = "25.1.8937393"
+ // NDK r27 for 16KB page size support
+ ndkVersion = "27.0.12077973"NDK r27이 .so 파일을 16KB alignment로 빌드할 수 있는 첫 안정 버전이다. RN 0.79가 요구하는 것이기도 하다.
# gradle.properties
+ # 16KB page size support
+ android.useFullClasspathForDexingTransform=trueGradle 측 16KB 관련 설정.
우리 코드가 직접 쓰는 .so가 아니어도 위험하다
여기서 함정 하나 — 우리가 직접 작성하는 .so가 없어도, 의존성 트리 안에 16KB 미지원 .so를 가진 서드파티 라이브러리가 하나라도 있으면 그 라이브러리가 로드되는 화면에서 앱이 죽는다. 0.79로 올린다고 모든 의존성이 자동으로 16KB 호환이 되는 건 아니다.
Before / After — 우리 코드베이스의 16KB 미지원 .so 분류
본 작업 시작 전(RN 0.73, app version 6.42.0)에 Google Play Console “App Bundle 탐색기”는 이 빌드의 메모리 페이지 크기를 “16KB를 지원하지 않음”으로 표시했다. 미지원 .so가 architecture당 약 75개. 출처를 기준으로 셋으로 나누면 어디까지 RN 업그레이드로 자동 해결되는지가 보인다.
그룹 1 - RN 코어 (~50개)libreact_*, libhermes*, libjsi*, libfabricjni, libyoga, librninstance, libruntimeexecutor, libturbomodulejsijni, libuimanagerjni, librrc_*(Fabric 컴포넌트), libmapbufferjni, libfolly_runtime, libfb*, libglog*, Fresco 계열(libimagepipeline, libnative-*)…
→ RN 0.76+에서 자체적으로 16KB 호환 빌드로 전환된다. RN 업그레이드 자체로 해결되는 영역.
그룹 2 - RN 생태계 라이브러리 (~10개)libreanimated, librnscreens, librnskia, libreactnativeblob, libreactnativemmkv, libreactnativequickcrypto, libmmkv(core)…
→ 각 라이브러리 메인테이너의 16KB 호환 릴리즈가 필요. 0.79 업그레이드 작업 중에 라이브러리 버전을 함께 올려야 하는 또 하나의 이유다.
그룹 3 - 서드파티 SDK (~15개)libcrashlytics-*(Firebase), libsentry-*(Sentry), libpdfiumandroid(PDF), libtobEmbedPagEncrypt(결제 PG), libpglarmor(보안), libapminsight*(APM), libnms(NaverMap), libcrypto(OpenSSL), libc++_shared(STL/NDK)…
→ RN 업그레이드와 무관. 각 SDK 벤더의 16KB 호환 빌드가 필요하다.
RN 올리면 16KB 자동 해결이 아니다.
5. 인터옵 모드라는 절충
DefaultNewArchitectureEntryPoint.load(
true, // turboModulesEnabled
true, // fabricEnabled
false // bridgelessEnabled
);세 번째 인자가 false다. 즉 TurboModules와 Fabric은 켰지만, Bridge는 살려뒀다. 이게 “인터옵 모드”다.
0.79에서 가능한 세 가지 모드
RN 0.79에서 동작 모드는 사실상 셋이다.
| 모드 | turboModules | fabric | bridgeless | Bridge | 위치 |
|---|---|---|---|---|---|
| Old (legacy) | false | false | false | 살아있음 | 0.73 시절 |
| 인터옵 (우리 위치) | true | true | false | 살아있음 | TM+Fabric on, Bridge fallback 유지 |
| Bridgeless | true | true | true | 제거됨 | New Arch 완전 활성화 |
이 세 모드는 단순한 on/off 단계가 아니다. Bridge라는 옛길이 살아있느냐 없느냐가 본질이다. 인터옵 모드는 New 4축을 다 켜되 옛길도 같이 살려둔다는 뜻이고, Bridgeless는 옛길까지 걷어낸다는 뜻이다.
인터옵 모드가 뭘 가능하게 하는가
interoperability(상호운용). 같은 앱 안에서 TurboModule과 옛 NativeModule이 공존할 수 있다.
코드 두 줄로 보면:
// (a) TurboModule로 마이그레이션된 모듈
import {NativeMyTurbo} from './NativeMyTurbo';
NativeMyTurbo.doSomething(); // → JSI를 통해 C++ 직접 호출
// (b) TurboModule 마이그레이션 안 한 모듈 (옛 NativeModules)
import {NativeModules} from 'react-native';
NativeModules.OldModule.doSomething(); // → Bridge를 통해 JSON 직렬화같은 앱이 동시에 두 경로로 통신한다. (a)는 JSI 직접 호출이라 빠르고, (b)는 옛 방식 그대로다. 우리 입장에서 중요한 건 우리가 쓰는 모든 라이브러리가 TurboModule 마이그레이션을 끝내지 않았어도 괜찮다는 것이다. 마이그레이션 안 된 라이브러리는 자동으로 Bridge 경로를 타고 동작한다.
그럼 세 병목은 어떻게 풀렸나
여기서 자연스럽게 의문이 든다. Bridge가 살아있다면, 처음에서 짚은 세 병목은 진짜 풀린 건가? Bridge가 살아있다는 건 Bridge fallback 경로가 추가로 존재한다는 뜻이지, 모든 통신이 Bridge로 간다는 뜻이 아니다. TurboModules와 Fabric이 활성화되면 그쪽 경로는 JSI 위에서 동작한다.
“you'll be able to still load your legacy modules even in bridgeless mode through the TurboModuleRegistry. ... If a module has not been migrated to the new architecture, how does it sometimes still work even in bridgeless mode? Because of the Interop Layer.”
— Nicola Corti(cortinico, RN core), reactwg/react-native-new-architecture #266
(위 인용은 Bridgeless 모드 케이스를 다루지만, 호환성 메커니즘이 legacy 모듈을 살리는 layer라는 framing이 똑같다. 우리가 있는 인터옵 모드에서는 Bridge가 그대로 살아있으니 더 단순하다. legacy 모듈은 Bridge로, TM/Fabric은 JSI로 흐른다.)
- 앱 시작 시간 - 부분 해결
TurboModule로 마이그레이션된 모듈만 lazy loading 이득을 본다. 사용 전까지 초기화되지 않는다. 옛 NativeModule은 ReactPackage.createNativeModules()에서 startup 시점에 등록되어 Bridge 시절 그대로 비용을 낸다.
(공식 문서가 “lazy는 TM에 한정”이라고 직접 진술하는 부분은 못 찾았다. RN 아키텍처상 inferred. 코드베이스의 의존성 중 TM 채택 비율만큼 startup 비용이 절감된다는 게 일반적 이해.)
- JSON 직렬화 누적 지연 - 대부분 해결
여기가 의외로 거의 풀리는 영역이다. RN 0.79에서 fabricEnabled=true이면 RN 코어 UI 컴포넌트(ScrollView, FlatList, View 등)는 모두 Fabric 컴포넌트로 동작한다.
“With Fabric, we moved component rendering off the bridge. With JSI, we can share memory between JavaScript and C++. We are not constrained anymore to serialize messages through a single component, ...”
— RN core team, reactwg #154
onScroll 같은 hot path는 RN 코어 컴포넌트 위에서 발생하므로 — bridgelessEnabled 값과 무관하게 JSI를 탄다. 직렬화가 남는 영역은 (a) legacy NativeModule 호출과 (b) Fabric 미지원 서드파티 컴포넌트뿐이다.
- 동기 응답 불가능 - 거의 완전 해결
이게 가장 깔끔하게 풀린다. Fabric/TM의 동기 호출 능력은 JSI runtime 위에 정의되고, Bridge는 옆에 추가로 있을 뿐 JSI를 회수하지 않는다. RN 0.74 changelog는 인터옵 모드에서 “Codegen, New Native Module system (Turbo Native Modules), New Renderer (Fabric)”가 모두 동작 가능하다고 명시한다(reactwg #174). 우선순위 높은 UI 업데이트(터치, 제스처)의 동기 처리는 bridgelessEnabled 값에 의존하지 않는다.
Bridge가 살아있는 것은 TurboModule 마이그레이션 안 한 라이브러리가 죽지 않게 해주는 호환성 보장이지, 4축의 이득을 막는 게 아니다. 인터옵 모드에서 2와 3은 거의 완전히 풀리고, 1은 TM 마이그레이션 비율에 비례해서 풀린다. Bridgeless의 추가 이득은 (1)의 마지막 마진과 Bridge 자체의 초기화 비용이다.
“With Bridgeless mode, we are moving everything else (...) off the bridge and no longer initializing the bridge.”
— RN core team, reactwg #154
인터옵을 끄면(=Bridgeless로 가면) 무엇이 달라지나
bridgelessEnabled=true로 바꾸면 변화는 “Bridge 제거” 한 가지가 아니다. 네 가지 영역에서 동시에 바뀐다.
- 통신 경로의 단일화
모든 모듈이 JSI 위에서만 동작해야 한다. TurboModule 마이그레이션을 안 한 라이브러리는 동작하지 않거나 호환 레이어(legacy interop)에 의존하게 된다. 이 호환 레이어조차 미지원인 라이브러리도 있다.
- 호스트 API 교체
ReactInstanceManager + CatalystInstance → ReactHost + ReactInstance로 바뀐다. 앱 라이프사이클에 직접 손댄 코드(복수 RN 인스턴스를 띄우는 임베디드 코드, 커스텀 ReactPackage 등록부 등)가 있으면 그 부분이 같이 옮겨가야 한다.
3 . Bridge 의존 API 제거
RCTBridge 같은 iOS/Android 측 Bridge API에 의존하는 코드는 사용 불가. 일부 오래된 네이티브 모듈, 일부 디버깅/계측 도구가 여기 걸린다.
- 시작 시간/메모리 추가 이득
Bridge가 더 이상 초기화되지 않는다. RN core team은 “no longer initializing the bridge”라고 명시한다(reactwg #154). 이로 인해 콜드 스타트와 메모리 풋프린트에서 추가 이득이 있다(외부 사례에서 보고되는 수치는 케이스별로 5~15% 수준).
다만 0.74에서 도입된 BridgelessCatalystInstance shim이 API 호환성을 위해 남아있어, “CatalystInstance가 통째로 사라진다”는 표현은 정확하지 않다(reactwg #174). 이득은 Bridge 자체의 초기화/lifetime 비용 절감에 한정된다. 그리고 이 이득은 인터옵 모드로 이미 얻은 이득 위에 더해지는 추가분이라는 점이 핵심이다.
Bridgeless 전환의 비용은 호환성 위험이고, 이득은 추가적 성능 마진이다.
그래서 우리는 왜 멈췄나
결정적 이유 react-native-code-push가 Bridgeless를 지원하지 않는다
우리 OTA 배포 인프라의 핵심은 react-native-code-push@9.0.1(self-hosted server)이다. 이 라이브러리의 메인테이너가 2024년 공식적으로 입장을 밝혔다.
“we are not going to support new architecture, as a workaround you can still opt out from new arch in v.076.”
— Microsoftreact-native-code-push메인테이너 (Issue #2774)
권고된 우회법이 “opt out from new arch”고, 우리 코드의 bridgelessEnabled=false가 정확히 그 opt-out에 해당한다. 즉 codepush를 운영에서 빼지 않는 한 — 인터옵 모드는 절충이 아니라 강제 선택이다.
2025년 5월 Microsoft 공식 repo는 archive(read-only)로 전환됐다. self-hosted 서버는 계속 동작하므로 우리 OTA 자체에는 영향 없으나, 클라이언트 라이브러리의 New Arch 마이그레이션은 영구적으로 일어나지 않는다.
ReactInstanceManager API 의존
codepush의 핵심 동작은 한 줄에 압축된다. Android에서 CodePushNativeModule.java의 loadBundle()이 instanceManager.recreateReactContextInBackground()를 호출해 다운로드한 JS 번들을 적용한다.
문제는 이 API가 옛 ReactInstanceManager 위에 정의되어 있다. Bridgeless 모드에서는 ReactInstanceManager가 ReactHost로 대체되고, 이 호출 경로가 끊긴다. 번들 다운로드는 성공하지만 reload가 실패하므로 롤백이 트리거되고, 사용자는 항상 옛 버전을 본다.
이 증상이 Issue #2781에 정확히 보고되어 있다.
“I have recently updated to the new architecture and I noticed that all the CodePush bundles are ‘broken’ on Android, meaning that I always get a rollback. ... When I disable the new architecture, everything works fine (exact same codebase).”
같은 이슈에서 한 사용자가 우회 패치를 공유했는데, 본질적으로 recreateReactContextInBackground() 대신 Intent.FLAG_ACTIVITY_NEW_TASK + Runtime.getRuntime().exit(0)로 전체 앱을 재시작하는 hack이다. 깔끔한 코드 패치가 아니라 안 되는 API를 우회하는 형태고, InstallMode.IMMEDIATE가 정상 동작하지 않는 등 부작용이 있다.
iOS는 사정이 약간 다르다. codepush 9.0.1에서 PR #2784로 iOS의 bundle reload API를 deprecated 메서드에서 다른 메커니즘으로 옮겼고, iOS에서는 NA 환경에서 “일단 동작한다”는 보고가 많다. 그러나 Microsoft 공식 입장은 여전히 “NA 미지원”이고, codepush의 Android 측은 손대지 않았다.
bridgelessEnabled=false를 유지하면 ReactInstanceManager가 살아있으므로 codepush 9.0.1은 그대로 동작한다. Bridgeless로 가는 순간 다음 셋 중 하나를 골라야 한다 .
- 커뮤니티 hack 패치 적용(IMMEDIATE 모드 깨짐 등 부작용 감수)
- codepush를 fork해서 직접
ReactHost마이그레이션 - 다른 OTA 솔루션으로 이전(EAS Update / Codemagic CodePush / Revopush 등).
셋 모두 별개 프로젝트 규모의 작업이다.
ReactHost 마이그레이션 비용
bridgelessEnabled=true로 가면 호스트 API가 ReactInstanceManager → ReactHost로 바뀐다. 우리 임베디드 코드(앱 초기화 시퀀스, 커스텀 ReactPackage 등록부, MMKV처럼 초기화 순서가 중요한 TurboModule)에 추가 영향이 있을 수 있다.
가장 결정적인 건 codepush다. 나머지는 codepush가 풀려도 따로 평가해야 할 항목들이지만, 지금 시점에서는 평가 의미가 없다. codepush가 Bridgeless를 못 받기 때문에 인터옵 모드가 강제된다.
인터옵은 종착점이 아니라 중간 정착지
이 결정은 Bridgeless로 절대 안 간다가 아니라 지금 이 시점에서는 인터옵이 강제된다라는 뜻이다. 다음 cycle에서 다시 봐야 할 항목들은 우선순위를 가진다.
선행 조건 - codepush 결정
- codepush를 다른 OTA로 마이그레이션할 것인가 (EAS Update / Codemagic CodePush / Revopush 등)
- 또는 codepush의 fork/대체 클라이언트를 직접 운영할 것인가
이 결정 없이는 Bridgeless 전환을 시작할 수 없다. 그리고 또 하나의 외부 데드라인이 있다. RN 0.82부터 New Architecture가 필수가 되면 opt-out 자체가 사라진다. 그 시점이 codepush 마이그레이션의 사실상 데드라인이다.
그 다음 항목들
- 의존성 트리의 Bridgeless 지원 상태가 어떻게 변했는가
- 우리 코드의 patches가 줄었는가 (진짜 패치 6건 + 빌드 아티팩트 4건이 어디까지 정리됐는가)
ReactHost로 마이그레이션할 임베디드 코드의 규모- 외부 RN 0.7x/0.8x 사례의 Bridgeless 안정성 보고
업그레이드는 한 번의 결정이 아니라 cycle을 두는 결정이고, OTA 인프라 결정과 RN 아키텍처 결정이 사실 한 묶음이라고 할 수 있다
결과 - 데이터로 본 업그레이드의 성과
크래쉬율, 앱 시작 시간, 프레임 성능 세 지표 모두 의미 있는 개선이 확인됐다.
크래쉬율
Mixpanel - First App Open → App Crashed by OS (첫 진입 1분 이내)
| 시점 | Android 첫 진입 크래쉬율 |
|---|---|
| Pre (RN 0.73) | 30 ~ 33% |
| Post (RN 0.79) | ~ 0% |
Play Console — 사용자 인식 비정상 종료 발생률 (OS 레벨, 전체 사용자)
| 시점 | 28일 이동평균 |
|---|---|
| Pre (12~2월) | ~3.5 ~ 4.3% |
| Post (5월) | ~0.5% |
앱 시작 시간
OS는 Sentry app.start.cold, Android는 OS 레벨인 Play Console 콜드 시작 지연을 본다
iOS - Sentry
| 지표 | Pre | Post | 변화 |
|---|---|---|---|
| p75 | 5,096 ms | 1,294 ms | −75% ✅ |
| p95 | 13,508 ms | 8,237 ms | −39% ✅ |
Android - Play Console 콜드 시작 지연
| 시점 | Slow cold start 비율 |
|---|---|
| Pre (12~2월) | ~0.27 ~ 0.28% |
| Post (5월) | ~0.18% |
Android - Play Console 과도하게 느린 프레임
| 시점 | 28일 이동평균 |
|---|---|
| Pre (12월 peak) | ~0.85% |
| Post (4~5월) | ~0.30 ~ 0.35% |