개요
지난 글에서 구현된 네이티브 모듈이 어떻게 등록되고 런타임에서 접근할 수 있게 되는지 살펴보았다. 네이티브 모듈의 메소드가 호출되면 브릿지를 거치게 된다는 부분까지 살펴보았는데, 지난 내용에 이어 어떻게 네이티브로 전달되고, 자바스크립트 런타임에서는 결과를 어떻게 전달받게 되는지 살펴보려고 한다.
브릿지(Bridge)
React Native 개발자라면 브릿지(Bridge)에 대해 들어본 적이 있을 것이다. 브릿지는 자바스크립트 런타임과 네이티브 간의 상호작용을 담당하는 React Native의 핵심 기능 중 하나이다.
자바스크립트 런타임에서 네이티브 모듈에 접근하여 동작을 요청하거나, 네이티브에서 자바스크립트 콜백 함수를 호출하여 결과를 전달하는 역할을 수행하는 개념이 곧 브릿지이다.
브릿지는 내부에서 어떻게 구현되어 있는지 한 번 살펴보도록 하자.
네이티브 모듈은 애플리케이션 초기 실행 시 모듈 저장소(Registry)에 등록되고, 프록시 객체를 통해 접근한다는 것은 지난 글에서 알아보았다. 모듈 접근 후 네이티브 메소드를 호출하고, 결과를 수신할 때에는 브릿지를 거치게 된다. 위 그림에서 강조한 부분을 조금 더 자세히 살펴보자.
자바스크립트 런타임에서 네이티브 모듈에 접근하여 메소드를 호출할 때에는 JSIExecutor
에서 ExecutorDelegate 로 작업을 위임하며, 실제 동작은 JsToNativeBridge에서 처리된다.
반대로 결과를 네이티브에서 자바스크립트 런타임으로 전달할 때에는 NativeToJsBridge 에서 처리된다.
위의 그림을 잘 기억하며 글을 살펴보도록 하자.
네이티브 메소드 유형
네이티브 모듈 메소드의 유형은 총 3가지로 구분되며, 네이티브와 상호작용하는 로직은 NativeModules
에 의해 추상화되어있다는 것은 지난 글에서 간단히 살펴보았다.
네이티브 모듈의 메소드가 호출되면 내부적으로 BatchedBridge 로 네이티브 메소드 호출 요청이 전달되고 내부 구현에 따라 실제 네이티브 모듈로 전달되어 처리된다. 처리된 결과는 콜백 함수를 통해 자바스크립트로 전달된다.
자세히 알아보기에 앞서 네이티브 모듈이 호출되면 브릿지로 어떻게 전달되는지 내부를 한 번 들여다보도록 하자.
sync
공식 문서에 따라 아래와 같이 명시적으로 동기 메소드로 정의한 경우, 메소드가 호출되면 'sync'
유형으로 처리된다.
- Android: https://reactnative.dev/docs/native-modules-android#synchronous-methods
- iOS: https://reactnative.dev/docs/native-modules-ios#synchronous-methods
@ReactMethod(isBlockingSynchronousMethod = true)
fun myMethod() { ... }
RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(myMethod)
동기 메소드가 호출되면 최종적으로 BatchedBridge
의 callNativeSyncHook 메소드가 호출되고, 이때 메소드 인자와 모듈 정보가 함께 전달된다.
async
콜백 함수를 통해 동작하도록 구현한 경우, 'async'
유형으로 처리된다. 해당 타입의 메소드가 호출되면 최종적으로 BatchedBridge
의 enqueueNativeCall 메소드가 호출된다.
promise
프로미스를 통해 동작하도록 구현한 경우, 'promise'
타입으로 처리된다. 실제 동작은 'async'
유형과 동일하게 콜백으로 동작하는데, 이를 프로미스로 한 번 wrapping 했을 뿐이다.
이 역시 최종적으로 enqueueNativeCall 메소드가 호출된다.
BatchedBridge
BatchedBridge
는 MessageQueue 인스턴스이며 이를 통해 자바스크립트 런타임에서 네이티브 모듈의 메소드를 호출하고, 네이티브로부터 결과를 수신하게 된다.
네이티브 메소드가 호출되면 BatchedBridge
의 메소드(callNativeSyncHook
, enqueueNativeCall
)를 통해 네이티브 모듈 메소드를 호출하게 된다.
잠시 후 자세히 살펴볼 내용인데, BatchedBridge.callNativeSyncHook
은 전역에 정의되어 있는 nativeCallSyncHook 을 호출하고, BatchedBridge.enqueueNativeCall
은 전역에 정의되어 있는 nativeFlushQueueImmediate를 호출하게 된다.
이들은 런타임이 초기화될 때 JSIExecutor에 의해 전역 객체에 노출되는 함수이다.
BatchedBridge 를 확인해 보면 __fbBatchedBridge
라는 이름의 속성을 전역 객체에 정의하고 있는데, 네이티브에서 결과를 전달해 줄 때, 정의한 속성을 통해 BatchedBridge
에 접근하고 이를 통해 결과를 전달하는 메커니즘으로 동작한다.
브릿지가 동작하게 되는 흐름을 그림으로 정리해보면 아래와 같다.
네이티브 메소드가 호출되면 JSIExecutor
에 의해 정의된 nativeFlushQueueImmediate
, nativeCallSyncHook
함수가 호출되고, 네이티브 모듈 메소드의 결과는 __fbBatchedBridge
를 통해 전달되는 구조이다.
MessageQueue
BatchedBridge
의 실체는 MessageQueue라고 설명했다. MessageQueue
는 네이티브 모듈을 호출하기 전에 호출 정보를 관리하고, 결과를 수신해 주는 역할을 수행한다.
네이티브 모듈의 비동기(async, promise) 메소드가 호출되면 모듈 정보와 호출하고자 하는 메소드 정보가 큐(Queue)에 저장되고, 정해진 스케쥴링 조건에 따라 네이티브로 호출 정보를 전달하게 된다.
callNativeSyncHook
네이티브 모듈의 동기 메소드가 호출되면 callNativeSyncHook
메소드가 호출되는데 내부 구현을 살펴보면, 몇 가지 처리를 거친 후 JSIExecutor
로 부터 주입된 nativeCallSyncHook
함수가 호출된다.
nativeCallSyncHook
가 호출되기 전에 콜백 정보를 처리하고 있는데, 모든 네이티브 모듈의 메소드 호출에는 고유한 식별자(_callID
)가 존재하며 해당 식별자를 키로 갖는 콜백 정보를 _successCallbacks
, _failureCallbacks
에 기록해 둔다.
해당 식별자는 결과를 네이티브로부터 수신할 때 사용되며, 식별자를 통해 결과를 전달할 콜백을 참조하고, 해당 콜백을 호출하여 결과를 전달하게 된다.
여기서 주의 깊게 살펴볼 부분은 global.nativeCallSyncHook
의 인자이다. 순서대로 모듈 ID, 메소드 ID, 네이티브 함수로 전달할 인자값인데 잘 기억해 두자.
enqueueNativeCall
네이티브 메소드의 비동기(async, promise) 메소드가 호출되면 enqueueNativeCall
메소드가 호출되는데 내부 구현을 살펴보면 역시 몇 가지 처리를 거친 후 JSIExecutor
로 부터 주입된 nativeFlushQueueImmediate
함수가 호출된다.
동기 메소드와 동일하게 콜백 정보가 호출 식별자와 함께 기록되고, 네이티브 메소드 호출 정보가 큐에 저장되는 것을 확인할 수 있다.
큐에 저장되는 값의 형태는 아래와 같으며 호출된 네이티브 모듈과 메소드의 ID, 그리고 호출할 때 전달된 인자값이 여기에 저장된다.
_queue = [
[moduleId_1, moduleId_2, ...],
[methodId_1, methodId_2, ...],
[params_1, params_2, ...],
_id
];
네이티브 모듈의 비동기 메소드는 호출 즉시 정보가 전달되는 것이 아니라 MessageQueue
내부의 큐에 저장되어 있다가 스케쥴링 조건에 따라 네이티브로 일괄 flush
된다.
스케쥴링은 최소 MIN_TIME_BETWEEN_FLUSHES_MS 간격으로 호출 정보가 전달되도록 구현되어 있다. 즉, 네이티브 모듈의 비동기 메소드가 짧은 시간 안에 여러 번 호출되더라도 큐에 저장되어 있다가 5ms 이후에 flush 된다.
여기서도 주의 깊게 살펴볼 부분은 global.nativeFlushQueueImmediate
호출 시 전달되는 인자이다. 인자 값으로 큐 데이터를 전달하는데, 큐에 저장되는 데이터 형태를 잘 기억해 두도록 하자.
Bridge Binding
브릿지 동작은 MessageQueue
를 통해 처리된다고 설명했다. 네이티브 모듈의 메소드가 호출된 후 등록된 콜백을 통해 결과를 수신하기 위해 MessageQueue
가 네이티브에 바인딩되는데 이 부분을 한 번 살펴보자.
JSIExecutor
를 살펴보면 bindBridge 메소드가 구현되어 있다. 이를 통해 앞서 살펴본 __fbBatchedBridge
를 전역에서 참조한다.
네이티브 메소드 호출이 완료되어 결과를 전달할 시점이 찾아오면 네이티브에서는 바인딩된 MessageQueue의 invokeCallbackAndReturnFlushedQueue
메소드를 호출하여 등록한 콜백을 통해 값을 전달하게 된다.
먼저 네이티브 메소드가 호출되면 내부적으로 어떤 흐름으로 동작하게 되는지 살펴보자.
JS to Native - Sync
네이티브 메소드가 호출되면 MessageQueue
를 거쳐 메소드 유형에 따라 JSIExecutor
에서 주입한 함수를 호출하고 있는 것을 확인했다. 동기 네이티브 메소드는 BatchedBridge.callNativeSyncHook
를 거쳐 global.nativeCallSyncHook
함수가 호출된다.
여기서 호출되는 함수는 JSIExecutor
에 구현되어 있는데, 내부적으로 어떤 동작을 수행하는지 파헤쳐보도록 하자.
(함수 이름이 MessageQueue
의 메소드명과 유사하여 혼동될 수 있으니 주의하자)
nativeCallSyncHook
동기 네이티브 메소드가 호출되면 MessageQueue
를 거쳐 nativeCallSyncHook
함수가 호출된다.
해당 함수는 JSIExecutor
로부터 주입되고 있는데, 코드를 살펴보면 아래와 같다.
네이티브 상에 구현된 nativeCallSyncHook
을 호출하고 있고, 해당 함수의 반환 값을 JSI를 통해 다시 자바스크립트 런타임으로 전달한다. 동기 메소드이기 때문에 별다른 비동기 콜백 처리 없이 반환 값을 바로 전달하는 모습을 볼 수 있다.
네이티브의 nativeCallSyncHook
함수의 구현체는 JSIExecutor
내에서 확인할 수 있다.
MessageQueue
에서 global.nativeCallSyncHook
함수를 호출할 때 전달하는 인자를 기억하는가?
모듈 ID, 메소드 ID 그리고 네이티브 메소드로 전달할 인자값이었다.
이 순서에 따라 0, 1번 인덱스의 인자를 가져와 각각 모듈 ID, 메소드 ID로 사용하고, 네이티브 메소드를 호출할 때 2번 인덱스 값을 참조하여 전달하고 있다.
여기서 네이티브 메소드로 전달할 인자는 dynamicFromValue 메소드를 통해 JSI 호환 값으로 변환한다. 이전 글에서도 언급했지만 과거에는 이 부분에서 JSON 형태로 직렬화 처리 과정을 거쳤는데 지금은 그렇지 않다.
이러한 사전 작업을 거친 후 네이티브 메소드를 호출하게 되는데, 네이티브 메소드 호출 후 결과값이 존재하지 않다면 undefined
, 존재한다면 JSI 인터페이스에 맞게 변환하여 반환하는 코드도 확인 가능하다.
네이티브 메소드는 _delegate
(ExecutorDelegate
)의 callSerializableNativeHook 메소드를 통해 호출하고 있는데, 이때 네이티브에서 호환가능한 형태로 직렬화가 이루어진다. 한 번 세부 구현을 살펴보도록 하자.
callSerializableNativeHook
ExecutorDelegate
를 상속하고 있는 JsToNativeBridge 에서 실체를 확인할 수 있는데 구현된 코드는 아래와 같다.
인자로 전달한 모듈 ID, 메소드 ID 그리고 네이티브 메소드로 전달할 인자 총 세 값을 모듈 저장소(ModuleRegistry
)의 callSerializableNativeHook 메소드로 전달하고 있다.
구현된 코드를 살펴보면 모듈 ID에 대한 유효성 검증을 진행하고, 해당하는 모듈 ID를 참조하여 네이티브 모듈(CxxNativeModule
)의 callSerializableNativeHook 메소드를 호출하고 있는 모습을 확인할 수 있다.
네이티브 메소드의 유효성을 검증하고, 동기 함수(syncFunc)를 호출하고 있다. 이때, 전달된 네이티브 메소드의 인자 값을 함께 넘겨주는 모습을 확인할 수 있다.
동기 네이티브 메소드의 동작 순서를 정리하자면 아래와 같다.
BatchedBridge.callNativeSyncHook
->global.nativeCallSyncHook
->JsToNativeBridge.callSerializableNativeHook
->ModuleRegistry.callSerializableNativeHook
->CxxNativeModule.callSerializableNativeHook
각 메소드의 반환 값은 다시 거슬러 올라가 자바스크립트 런타임으로 전달될 것이다.
JS to Native - Async
비동기 네이티브 메소드는 BatchedBridge.enqueueNativeCall
를 거쳐 global.nativeFlushQueueImmediate
가 호출된다.
여기서 호출되는 함수 역시 JSIExecutor
에 구현되어 있는데, 내부적으로 어떤 동작을 수행하는지 파헤쳐보도록 하자.
nativeFlushQueueImmediate
global.nativeFlushQueueImmediate
의 실체는 JSIExecutor
에서 확인할 수 있으며 구현된 코드는 아래와 같다.
네이티브의 callNativeModules
를 호출하고 있는데, 인자로 MessageQueue
에서 전달한 큐 값을 전달한다.
비동기 메소드이기 때문에 자바스크립트 런타임으로부터 메소드 호출 요청을 수신한 후 즉시 undefined
를 반환하는 것을 확인할 수 있다.
callNativeModule
JSIExecutor
내에서 구현된 callNativeModules 를 확인할 수 있다.
동기 메소드와 유사하게 인자 값을 JSI 호환 값으로 변환한 후ExecutorDelegate
의 callNativeMethod 메소드를 호출하며 값을 전달하고 있다.
callNativeMethod
여기서부터 동기 메소드와 구현된 부분이 조금 다른데 먼저 인자 값을 MethodCall 형태로 변환한다.
MessageQueue
에서 global.nativeFlushQueueImmediate
를 호출할 때 전달한 인자 값을 기억하는가? 모듈ID, 메소드ID, 인자값이 결합된 2차원 배열 형태의 값이었다. 이를 개별적인 MethodCall
형태로 변환하여 모듈 저장소(ModuleRegistry
)의 callNativeMethod 를 호출하게 된다.
마지막 조건문은 배치 처리 상태를 관리하고 React Native 내부의 스케줄링 처리를 수행하기 위한 부분이다.
ModuleRegistry
의 callNativeMethod
에서는 동기 메소드와 동일하게 모듈 ID의 유효성을 확인한 후 네이티브 모듈의 invoke 메소드를 호출한다.
invoke
여러 유효성 검사 조건이 구현되어 있으나 코드가 길어지는 관계로 일부분 생략했다.
인자로 전달받은 ID를 통해 대상 메소드를 참조한다. 이후 조건에서 콜백 수를 확인하여 네이티브 메소드 호출 시 사용할 인자를 미리 정리하는데 콜백 함수가 하나인 경우(onSucess), 콜백 함수가 두 개인 경우(onSuccess, onError)에 따라 처리된다.
콜백 함수는 makeCallback 메소드를 통해 생성되며, 네이티브 메소드는 MessageQueueThread
를 통해 별도 스레드에서 호출되며 비동기적으로 처리된다.
지금까지 거쳐온 과정을 순서대로 정리하면 아래와 같다.
BatchedBridge.enqueueNativeCall
->global.nativeFlushQueueImmediate
->JSIExecutor.callNativeModules
->JsToNativeBridge.callNativeModules
->ModuleRegistry.callNativeMethod
->CxxNativeModule.invoke
Native to JS
동기 메소드는 별다른 처리 없이 JSI를 통해 동기적으로 결과 값이 반환되는 것을 확인했다. 비동기 메소드의 경우에는 MessageQueue
에서 등록된 콜백을 통해 결과 값이 전달된다.
네이티브 메소드가 호출될 때 makeCallback을 통해 결과를 전달하기 위한 람다 함수가 생성되는 것을 확인했다.
비동기 네이티브 메소드의 실행이 완료되면 생성된 람다 함수가 호출되는데, 람다 함수의 매개변수(folly::dynamic args
) 값은 캡처해 둔 Instnace
의 callJsCallback 메소드로 전달된다.
네이티브 메소드의 결과 값 뿐만 아니라 콜백 함수의 식별값도 함께 전달되는 것을 확인할 수 있다.
callJsCallback
이어서 NativeToJsBridge
의 invokeCallback 메소드를 호출하여 값을 전달한다.
invokeCallback
마지막으로 자바스크립트 런타임과 상호작용 하는 JSExecutor
의 invokeCallback 을 호출하여 값을 전달하게 된다.
JSIExecutor
내에서는 바인딩된 MessageQueue
의 invokeCallbackAndReturnFlushedQueue
를 호출하여 결과를 전달한다. 호출할 때에는 콜백 함수의 식별값(ID), 그리고 네이티브 메소드의 결과 값이 전달되며, 여기서도 JSON 역직렬화 과정 없이 JSI 기반 값으로 바로 전달된다.
invokeCallbackAndReturnFlushedQueue
JSI를 통해 네이티브로부터 전달받은 인자(콜백 ID, 결과 값)를 수신하고, 등록된 콜백을 호출하여 값을 전달한다.
비동기 네이티브 메소드의 결과가 자바스크립트 런타임으로 돌아오기까지 여러 과정을 거쳐왔다.
순서를 한 번 정리해 보면 아래와 같은 순서로 처리된다.
Instance.callJsCallback
->NativeToJsBridge.invokeCallback
->MessageQueue.invokeCallbackAndReturnFlushedQueue
마무리하며
네이티브 모듈의 메소드가 호출되는 순간 수많은 모듈들을 거쳐 네이티브 모듈의 메소드가 호출되고, 결과를 자바스크립트 런타임으로 전달하는 과정 또한 간단하지만은 않다.
큰 그림으로 보면 이전에 살펴본 런타임과 네이티브 모듈의 주요 구성 요소들을 거쳐 JSI를 통해 네이티브로 전달되고, 이들의 결과가 다시 JSI를 통해 자바스크립트 런타임으로 전달된다는 것이 브릿지의 전부일뿐이다.
이번 글에서 설명한 개념 외에도 여러 가지 처리들이 포함되어 있으나, 글에서 설명한 메커니즘 정도 이해했다면 나중에 React Native의 내부 코드를 들여다보았을 때 따라가는데 큰 어려움은 없을 것이다.
다음 글에서는 이러한 복잡한 구성 요소들로 묶여 있는 React Native 애플리케이션을 개발하는 데 있어 개발자들에게 제공되고 있는 기능들과 디버깅을 위한 기능들이 어떻게 동작하는지 간단히 살펴보려고 한다.