이 문서를 읽고 나면 다음 질문에 답할 수 있습니다.
- "TurboModule은 더 빠른 NativeModule이다" 라는 설명이 왜 절반만 맞는가?
- 구형 NativeModules는 왜 앱 시작 시간을 부풀렸는가? 정확히 무엇을 미리 했는가?
- TurboModuleManager가 lazy 로딩을 가능하게 하는 메커니즘은 무엇인가?
- 동기 메서드가 가능해진 의미는 단순히 "await가 없어도 된다"가 전부인가?
- 100개 모듈을 등록한 앱에서 TurboModule 전환은 시작 시간을 얼마나 줄이는가?
// 구형 Bridge 시절의 모듈 등록 흐름
// 앱이 시작되는 순간 RCTBridge가 했던 일:
class RCTBridge {
setUp() {
// [1] AppDelegate/MainApplication에 등록된 모든 ReactPackage를 수집
const packages = this.getReactPackages();
// [2] 각 패키지의 createNativeModules()를 호출 → 모든 모듈 인스턴스 생성
const allModules = packages.flatMap(pkg => pkg.createNativeModules());
// [3] 모듈마다 메서드 시그니처를 추출(reflection)해서 JS에 노출할 객체 생성
const moduleConfig = allModules.map(m => ({
name: m.getName(),
methods: extractMethodsViaReflection(m), // ← 무거운 작업
}));
// [4] 이 거대한 메타데이터를 JS 측에 한 번에 주입
this.jsContext.injectModuleConfig(moduleConfig);
}
}
// 결과: 앱이 첫 화면을 그리기 전에 100개 모듈을 전부 생성·반영해야 함
// 사용자가 영원히 안 쓸 BluetoothModule, CameraModule도 인스턴스화됨구형 NativeModules의 비용 구조:
앱 시작
├── ReactPackage A의 모듈 30개 인스턴스화 (그중 1개만 사용)
├── ReactPackage B의 모듈 25개 인스턴스화 (0개 사용)
├── ReactPackage C의 모듈 45개 인스턴스화 (3개 사용)
└── 100개 모듈의 시그니처 직렬화 → JSON 메타데이터
→ JS context에 주입
소요: 평균 200~500ms (모듈 수에 선형)
메모리: 사용 안 하는 객체 95개 × 평균 1~10KB
네이티브 메서드 호출:
JS → JSON 직렬화 → Bridge 큐 → 네이티브 역직렬화 → 메서드 실행
→ 결과 직렬화 → 큐 → JS 역직렬화 → Promise resolve
→ 동기 반환값은 불가능 (큐가 비동기)
해결책의 두 축:
[A] 모듈을 "이름으로 조회되는 시점에" 생성한다 → lazy
[B] 호출 자체를 큐에 넣지 않고 C++ 함수처럼 직접 부른다 → 동기 가능
→ 두 가지가 합쳐진 결과가 TurboModule
// ❌ 잘못된 이해
// "TurboModule은 NativeModule의 새 버전이다. 그냥 갈아끼우면 빨라진다"
// ✅ 실제:
// TurboModule은 "JSI HostObject로 노출된 네이티브 객체"다.
// 즉 JS 측에서 `TurboModuleRegistry.get('Foo')`를 처음 호출한 그 순간에야
// 네이티브 측 `getModule("Foo")`가 실행되고 그제서야 객체가 생성된다.
// 등록만 해두면 안 쓰는 모듈은 메모리에도 안 올라간다.
// 빠르다기보다 "필요할 때만 존재한다"가 본질.// ❌ 잘못된 이해
// "async/await 없이 쓸 수 있어서 편하다"
const value = NativeFoo.getValue(); // 동기로 받음
// ✅ 실제:
// 동기 가능성의 진짜 의미는 "측정/조회를 그 순간 결정할 수 있다"는 것.
// 예: 레이아웃 측정 시 디바이스 픽셀 밀도를 동기로 읽어야 다음 픽셀이 정해진다.
// Bridge에서는 이런 호출이 "비동기 강제" 때문에 불가능했고
// 한 프레임 미루거나 결과를 미리 캐싱해야 했다.
// 동기 호출은 편의가 아니라 "표현 가능한 패턴의 확장"이다.// ❌ 잘못된 이해
// "lazy 로딩 = dynamic import 같은 거"
// ✅ 실제:
// JS 측은 코드 변화 없음. import는 평소처럼.
// 달라지는 것은 "네이티브 객체 인스턴스화 시점"이다.
// TurboModuleRegistry.getEnforcing('Foo')가 호출되기 전까지
// FooModule의 Kotlin/Swift 생성자는 절대 실행되지 않는다.
// 또한 한 번 생성된 후에는 캐시되어 다음 호출부터 재사용된다.정의:
TurboModule = "JS의 TurboModuleRegistry가 이름으로 요청하면,
TurboModuleManager가 그제서야 생성해 JSI HostObject로
JS 컨텍스트에 노출하는 네이티브 객체"
핵심 4가지:
[1] 등록(register) 시점: 앱 시작 시 — 이름과 팩토리만 등록
[2] 인스턴스화 시점: JS가 처음 require할 때 — 팩토리가 호출됨
[3] 호출 메커니즘: JSI 직접 호출 — 직렬화 없음
[4] 호출 모드: 동기 가능 — 같은 스레드에서 즉시 반환
TurboModuleManager는 "JS의 이름 → 네이티브 객체"의 게이트키퍼다:
JS 코드:
const Foo = TurboModuleRegistry.getEnforcing<Spec>('FooModule');
C++ JSI 바인딩 내부:
Object turboModuleProxy(...) {
return Function::createFromHostFunction(rt, "getModule", [](...) {
std::string name = args[0].getString(rt).utf8(rt);
// TurboModuleManager에 요청 — 처음이면 생성, 두 번째부터 캐시 반환
auto module = turboModuleManager_->getModule(name);
return jsi::Object::createFromHostObject(rt, module);
});
}
// 올바른 사용 패턴 — Spec 파일 작성
// specs/NativeFooModule.ts
import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';
export interface Spec extends TurboModule {
// 동기 메서드 — 즉시 반환
getDeviceLocale(): string;
// 비동기 메서드 — Promise 반환
uploadFile(path: string): Promise<string>;
// 이벤트는 별도 EventEmitter로 (TurboModule 자체는 무상태 권장)
}
// 마지막 줄 — Codegen이 이 export를 보고 C++/Java 인터페이스 생성
export default TurboModuleRegistry.getEnforcing<Spec>('FooModule');앱 시작 (구형 vs Turbo):
구형:
AppStart → loadReactPackages() → for each module:
new ModuleInstance() ← 즉시 생성
reflectMethods() ← 메타데이터 추출
registerToJS()
→ 100개 × 평균 3ms = 300ms 누적
Turbo:
AppStart → ReactPackageTurboModuleManagerDelegate에 (name, factory) 등록만
→ 100개 × 0.05ms = 5ms 누적
→ 실제 모듈 생성은 JS 코드가 호출할 때까지 보류
[1] JS: const Foo = require('./specs/NativeFooModule').default;
└─ 내부적으로 TurboModuleRegistry.getEnforcing('FooModule') 호출
[2] JSI: __turboModuleProxy('FooModule')
└─ C++ HostFunction이 받음
[3] C++ TurboModuleBinding::getModule("FooModule")
└─ TurboModuleManager.getModule(rt, "FooModule")
[4] TurboModuleManager (Java/Kotlin or Obj-C++):
if (cache.has("FooModule")) return cache.get("FooModule");
// 등록된 ReactPackage들을 순회하며 팩토리 찾기
auto factory = packages.find("FooModule");
auto instance = factory.create(); // ← 여기서 처음 생성자 실행
auto jsiObject = wrapAsJsiHostObject(instance);
cache.set("FooModule", jsiObject);
return jsiObject;
[5] JS: Foo는 이제 JSI HostObject — Foo.getDeviceLocale()은 C++ 함수 직접 호출
구형 NativeModule (비동기 강제):
JS Thread Bridge Queue Native Thread
│ │ │
│ getValue() │ │
├─ JSON.stringify ───►│ │
│ await... ├─ deserialize ──────►│
│ │ ├─ execute()
│ │◄─ serialize ────────┤
│◄─ JSON.parse ───────┤ │
▼ ▼ ▼
수 ms 후 resolve 큐 대기시간 포함 다른 스레드에서 처리
TurboModule (동기 가능):
JS Thread (JSI Runtime)
│
│ Foo.getValue()
├─ HostObject.get → HostFunction.call
├─ C++ 함수가 같은 스레드에서 실행
├─ 반환값 jsi::Value를 즉시 받음
▼
수 μs 후 즉시 반환
// Android — FooModule.kt
class FooModule(reactContext: ReactApplicationContext)
: NativeFooModuleSpec(reactContext) {
init {
Log.d("TURBO", "FooModule constructed at ${System.currentTimeMillis()}")
}
override fun getDeviceLocale(): String {
return Locale.getDefault().toString()
}
}
// 결과 관찰:
// 앱 시작 로그에는 "FooModule constructed"가 나타나지 않음
// JS에서 처음 const Foo = require('./NativeFooModule').default; 한 순간에만 출력
// 두 번째 require는 캐시 사용 — 생성자 재호출 없음// 100개의 더미 TurboModule 등록
class HeavyPackage : TurboReactPackage() {
override fun getModule(name: String, ctx: ReactApplicationContext): NativeModule? {
return when {
name.startsWith("Dummy") -> DummyModule(ctx, name)
else -> null
}
}
override fun getReactModuleInfoProvider() = ReactModuleInfoProvider {
(0..99).associate { i ->
"Dummy$i" to ReactModuleInfo(
name = "Dummy$i",
className = "DummyModule",
canOverrideExistingModule = false,
needsEagerInit = false, // ← lazy 핵심
isCxxModule = false,
isTurboModule = true,
)
}
}
}
// 측정 코드 (앱의 onCreate에서):
val start = System.nanoTime()
SoLoader.init(this, false)
// ReactNativeHost 부트스트랩
val ms = (System.nanoTime() - start) / 1_000_000
Log.d("BOOT", "RN init: ${ms}ms")
// 결과 (Pixel 6, RN 0.74):
// 구형 NativeModules 100개: RN init ~480ms
// TurboModules 100개: RN init ~85ms// 같은 작업(간단한 문자열 반환)을 비교
import NativeFoo from './specs/NativeFooModule';
// 동기 호출 100,000회
const startSync = performance.now();
for (let i = 0; i < 100_000; i++) {
NativeFoo.getDeviceLocale();
}
console.log(`sync: ${performance.now() - startSync}ms`);
// 결과 예: ~120ms (호출당 ~1.2μs)
// 구형 Bridge 비동기 호출 100,000회 (참고)
const startAsync = performance.now();
for (let i = 0; i < 100_000; i++) {
await NativeModules.OldFoo.getDeviceLocale();
}
console.log(`async: ${performance.now() - startAsync}ms`);
// 결과 예: ~6800ms (호출당 ~68μs, 큐 라운드트립 포함)앱 시작 시간 (모듈 N개 등록, 사용 안 함) — Pixel 6, RN 0.74
모듈 10개:
구형 NativeModules : 시작 ~120ms, 힙 사용 ~2 MB
TurboModules : 시작 ~25ms, 힙 사용 ~0.1 MB
모듈 50개:
구형 NativeModules : 시작 ~280ms, 힙 사용 ~8 MB
TurboModules : 시작 ~55ms, 힙 사용 ~0.5 MB
모듈 100개:
구형 NativeModules : 시작 ~480ms, 힙 사용 ~14 MB
TurboModules : 시작 ~85ms, 힙 사용 ~0.9 MB
→ 모듈 수에 선형으로 차이가 벌어진다
→ 실제 RN 앱은 보통 30~80개의 라이브러리 모듈을 등록
→ 빈 화면을 띄우는 시간이 200~400ms 단축
호출 오버헤드 (단순 string 반환):
구형 NativeModules (Bridge):
JSON 직렬화 : ~15μs
큐 enqueue : ~5μs
스레드 점프 : ~20μs
역직렬화 : ~10μs
실행 : ~2μs
역방향 반환 : ~20μs
합계 : ~70μs / 호출
TurboModules (JSI 동기):
HostObject.get : ~0.3μs
HostFunction.call: ~0.5μs
실행 : ~0.2μs
jsi::Value 반환 : ~0.3μs
합계 : ~1.3μs / 호출
→ 약 50배 빠른 호출
→ 그러나 큰 데이터 전달 시 직렬화는 사라져도 변환 비용은 남음 (5장 참조)
TurboModule 채택:
✅ 앱 시작 시간 단축 (사용 안 하는 모듈은 메모리에 없음)
✅ 동기 메서드로 측정·조회 패턴이 자연스러워짐
✅ Codegen으로 타입 안전성 (JS Spec ↔ Native 인터페이스 일치 보장)
✅ 호출당 오버헤드가 50배 가량 작음
❌ 동기 메서드는 JS 스레드를 블로킹 — 무거운 작업은 여전히 Promise로
❌ Codegen 셋업·빌드 시간 증가
❌ ReactPackage 인터페이스 변경 — 구형 모듈과 코드가 다름
❌ JSI HostObject의 생명주기를 잘못 다루면 JS GC와 충돌
언제 TurboModule을 강하게 권하는가:
자주 호출되는 동기 조회 (로케일, 디바이스 정보, 상수)
레이아웃 측정과 결합되는 동기 API
라이브러리 작성자 — 사용자 앱의 시작 시간을 줄이는 책임
Codegen으로 타입 안전성을 가져가고 싶을 때
언제 그대로 둬도 되는가:
앱 생애 동안 한 번만 호출되는 모듈 (시작 시간 영향 미미)
JS Spec 작성 난이도가 가치 대비 큰 단순 모듈
타사 라이브러리가 아직 TurboModule 미지원이고 interop으로 충분할 때
TurboModule은 새 모듈 종류가 아니라 "JSI 위의 lazy HostObject"
등록: 이름 + 팩토리만 (앱 시작 시 가벼움)
생성: JS가 처음 요구할 때 (필요한 만큼만)
호출: JSI 직접 — 직렬화 없음, 같은 스레드, 동기 가능
TurboModuleManager가 핵심
JS 측 TurboModuleRegistry.getEnforcing()의 종착지
내부 캐시로 두 번째 호출부터는 즉시 반환
C++/Java/Obj-C에 각각 구현됨 (플랫폼별)
ReactPackage 변경
needsEagerInit = false 로 lazy 보장
isTurboModule = true 로 TurboModuleManager 경로 선택
ReactModuleInfoProvider가 메타데이터 빌드 타임에 알려줌
동기 가능성의 의미
편의가 아니라 "표현 가능한 패턴의 확장"
레이아웃 측정·디바이스 조회·즉시 계산이 한 프레임에 가능
다음 챕터 예고
02 → 구형 NativeModules와 호출 경로 비교
03 → Codegen이 TS Spec을 어떻게 C++/Java로 펴는가
04 → 실제 Kotlin/Swift로 TurboModule 작성
Q1. TurboModule의 lazy 로딩은 "처음 require할 때 생성"한다고 했다. 그런데 어떤 상황에서는 lazy가 오히려 사용자 경험을 나쁘게 만들 수 있다. 어떤 경우인가? 어떻게 대응해야 하는가?
Q2. "동기 메서드가 가능하다"는 점이 측정/조회 패턴을 확장한다고 했다. 그러나 동기 메서드를 남발하면 새로운 문제가 생긴다. 무엇이 문제이며 어떻게 가이드해야 하는가?
Q3. 구형 NativeModules는 앱 시작 시 모든 모듈을 인스턴스화했다. 그렇다면 구형도 "필요할 때 생성"하도록 lazy로 만들 수 있지 않았을까? 왜 그러지 못했는가? Bridge 설계의 어떤 부분이 lazy를 막았는가?
💡 해설
Q1. 첫 사용 시점이 사용자 인터랙션 직후일 때 문제가 된다. 예: 사용자가 카메라 버튼을 누른 그 순간에 CameraModule이 처음 인스턴스화된다면, 모듈 생성 비용(권한 체크·디바이스 초기화·콜백 등록)이 사용자 입력과 카메라 프리뷰 사이의 지연으로 직접 보인다. 사용자는 "터치 → 카메라 열림"의 응답성을 느끼는데 중간에 50~200ms의 가시적 지연이 끼게 된다. 해결책은 두 가지다. 첫째,
needsEagerInit = true로 마킹해서 특정 모듈만 시작 시 강제 생성. 둘째, JS 측에서 화면 진입 시 백그라운드로TurboModuleRegistry.get('Camera')를 미리 호출해서 워밍업. 라이브러리 사용자에게는 보통 워밍업 패턴이 권장된다. lazy의 이득은 "안 쓰는 모듈"에서 오는 것이지, 사용자 액션 경로에 있는 모듈에서는 워밍업이 필수다.Q2. 동기 메서드는 JS 스레드를 그 시간 동안 점유한다. 호출당 1~2μs인 단순 조회는 문제없지만, 네이티브 측에서 디스크 I/O·네트워크·암호화 같은 무거운 작업을 동기로 노출하면 JS 스레드가 그동안 멈춘다. JS 스레드가 멈추면 터치 이벤트 처리, 다음 프레임의 React 렌더, 다른 모듈 호출 모두 지연된다. Bridge 시절에는 비동기 강제가 이를 자동으로 막아줬지만 TurboModule은 막아주지 않는다. 가이드는 명확하다. (1) "수 μs 안에 끝나는 순수 계산·조회"만 동기로 노출. (2) 디스크/네트워크/암호화는 무조건 Promise 반환. (3) 라이브러리 Spec 작성 시 동기 메서드는 보수적으로. Codegen Spec에서 메서드 시그니처가
Promise<T>냐T냐로 이 결정이 박혀버리므로 처음부터 신중해야 한다. 동기 가능성은 "할 수 있다"는 능력이지 "해야 한다"는 권장이 아니다.Q3. Bridge 시절에 lazy를 시도하지 못한 본질적 이유는 "JS가 모듈 메타데이터를 미리 가지고 있어야" 호출이 가능했기 때문이다. Bridge는 호출 시 모듈명·메서드명·인자를 JSON으로 직렬화해서 큐에 넣는데, JS 측이 어떤 메서드가 동기/비동기인지, 인자 개수가 몇 개인지 모르면 직렬화 자체를 만들 수 없다. 그래서 앱 시작 시 모든 모듈의 시그니처를 reflection으로 추출해 JS 컨텍스트에 주입해야 했고, 시그니처를 추출하려면 모듈 인스턴스가 필요했다(Java/Obj-C reflection은 인스턴스 기반). 결국 "메타데이터 수집을 위해 인스턴스화 강제"의 사슬이었다. JSI는 이 사슬을 두 단계로 끊는다. 첫째, HostObject는 메서드 시그니처를 미리 알 필요 없이
get(name)시점에 동적으로 풀어낸다. 둘째, Codegen이 빌드 타임에 시그니처를 정적으로 생성해 JS와 네이티브 양쪽에 박아넣는다. 그러면 reflection도 필요 없고, 인스턴스화 시점을 자유롭게 늦출 수 있다. 즉 lazy는 단일 기능이 아니라 "JSI + Codegen + HostObject"가 함께 만들어낸 결과다.