개요
지난 글에서는 코드가 번들링 되고 자바스크립트 엔진에서 실행되기까지 거치는 과정에 대해 살펴보았다. 이번 글에서는 React Native의 핵심 기능 중 하나인 네이티브 모듈이 어떻게 동작하는지 파헤쳐보자.
Native Modules
네이티브 모듈이란, 네이티브(Kotlin, Java, Swift, Objective-C)에서 구현한 클래스 인스턴스를 자바스크립트 런타임에 노출시켜 실행시킬 수 있도록 하는 React Native의 핵심 기능이다. 이를 통해 네이티브와 상호작용 하여 자바스크립트만으로 할 수 없는 동작을 구현할 수 있게 된다.
새로운 아키텍처가 릴리즈된 이후, Native Modules는 이제 레거시(Legacy)가 되었다. 다만 아직 Deprecated 된 것은 아니고, 새로운 아키텍처와 기존 아키텍처가 구분되어 있을 뿐이다. 수년간 이어진 React Native 생태계의 크기는 무시할 수 없는 수준이기에, 지금 당장 대체될 일은 없다고 본다.
지난 글에서 소개했던 다이어그램이다. 이번 글에서는 빨간색으로 강조한 네이티브 모듈 부분을 살펴보려고 한다.
주요 구성 요소는 네이티브 모듈과 이들을 관리하는 저장소(Registry)가 되겠다.
네이티브 모듈은 공식 문서에 잘 설명되어있기에 쉽게 구현할 수 있다. React Native 내에서는 구현한 모듈이 어떻게 처리되는지 살펴보도록 하자.
모듈 구현 관점에서 알아보고 싶다면, 공식 문서(Android, iOS)를 참조하길 바란다.
Android 모듈
React Native 의 Android 네이티브 모듈은 Kotlin 혹은 Java 언어를 통해 구현할 수 있다.
네이티브 모듈을 구현하는 방법은 생각보다 간단한데, ReactContextBaseJavaModule
클래스를 상속하여 요구되는 인터페이스를 구현하는 것이다. BaseJavaModule
클래스를 상속하거나, NativeModule
인터페이스를 구현하는 방법도 있으나, 공식 문서에서는 ReactContextBaseJavaModule
클래스를 상속하여 구현하는 것을 권장하고 있다.
아래는 Kotlin 기반의 네이티브 모듈 샘플 코드이다. Android 네이티브 모듈은 어떻게 구현하고 React Native에 등록되는지 살펴보도록 하자.
// ZendeskMessagingModule.kt
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
class ZendeskMessagingModule(private val reactContext: ReactApplicationContext) :
ReactContextBaseJavaModule(reactContext) {
private val module: ZendeskNativeModule = ZendeskNativeModule.getInstance()
private var initialized = false
override fun getName(): String {
return "ZendeskMessaging"
}
@ReactMethod
fun reset() {
...
}
}
// ZendeskMessagingPackage.kt
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ViewManager
class ZendeskMessagingPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
return listOf(ZendeskMessagingModule(reactContext))
}
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
return emptyList()
}
}
앞서 이야기 했던 것처럼 ReactContextBaseJavaModule
클래스를 상속하는 클래스를 정의하고 있는 것을 확인할 수 있다.
자바스크립트 컨텍스트로 특정 메소드를 노출시키고 싶다면, 모듈 메소드에 @ReactMethod 어노테이션을 추가해주기만 하면 된다.
네이티브 모듈과 메소드를 구현하는 것은 이게 전부다. 생각보다 간단하지 않은가?
네이티브에서 기능을 구현하고 React Native에서 제공하는 ReactPackage 형태로 래핑(wrapping)하면 된다.
iOS 모듈
React Native 의 iOS 네이티브 모듈은 Objective-C 기반으로 구현하게 된다. 물론 기능은 Swift로도 구현이 가능하지만 래핑 할 때에는 Objective-C 만 가능하다. Swift로 기능을 구현하고자 할 경우에는 XCode의 헤더 생성(Header Generation) 기능을 통해 Objective-C 에서 Swift 모듈을 참조하는 형태로 구현이 가능하다.
본론으로 돌아와서, iOS의 네이티브 모듈 또한 안드로이드와 마찬가지로 직접 하나부터 열까지 구현할 필요는 없고 RCTBridgeModule.h 에 사전 정의되어있는 매크로를 사용하여 네이티브 모듈 그리고 메소드를 쉽게 구현할 수 있다.
아래는 Objective-C 기반의 네이티브 모듈 샘플 코드이다. 한 번 해당 코드를 기준으로 살펴보도록 하자.
#import <React/RCTBridgeModule.h>
@interface RCT_EXTERN_MODULE(ZendeskMessaging, NSObject)
RCT_EXTERN_METHOD(reset)
...
@end
모듈 이름은 ZendeskMessaging
, 그리고 모듈 내에 포함된 메소드는 reset
하나로 구성되어있다.
네이티브 모듈과 메소드를 구현하기 위해 사용하는 매크로는 몇 가지가 있는데 이번 글에서는 RCT_EXTERN_MODULE 그리고 RCT_EXTERN_METHOD 를 기준으로 설명하려고 한다.
네이티브 모듈 구현은 인터페이스를 정의하는 것으로 시작하는데, RCT_EXTERN_MODULE
매크로에 사용하고자 하는 모듈의 이름을 추가하기만 하면 된다. 네이티브 메소드도 모듈과 동일하게 RCT_EXTERN_METHOD
매크로를 통해 정의하면 된다.
RCT_EXTERN_MODULE
과 RCT_EXTERN_METHOD
매크로 정의를 살펴보면 내부적으로 RCT_EXTERN_REMAP_MODULE, _RCT_EXTERN_REMAP_METHOD 매크로를 사용하고 있다.
앞서 살펴본 네이티브 모듈 코드는 컴파일 전처리 시점에 아래와 같이 변경된다.
여기서 살펴볼 부분은 초기화 코드에서 RCTRegisterModule
를 통해 구현한 모듈 클래스를 등록하는 부분(노란색)이다.
Native Module Registry
구현된 네이티브 모듈을 앱 내에서 사용하기 위해선 등록 과정이 필요하다. 과거에는 직접 Linking 과정을 진행했어야 하는데, 오늘날의 React Native는 Auto Linking을 지원하기 때문에 특별한 경우가 아니라면 추가적인 등록 과정을 진행하지 않아도 된다.
Auto Linking이 어떻게 동작하는지 궁금하다면 문서를 참조하길 바란다.
Android 모듈 등록
네이티브 모듈을 구현한 React Package(이하 패키지)는 ReactNativeHost 의 getPackages 를 통해 React Native로 전달된다.
일반적으론 Auto Linking 과정에서 통해 네이티브 모듈이 포함된 대상의 패키지가 자동으로 생성되며, 수동으로 네이티브 모듈 패키지를 추가할 수도 있다.
이렇게 등록된 패키지들은 ReactInstanceManager 인스턴스가 생성될 때 사용되며 ReactNativeHost
의 getPackages 메소드를 통해 전달된다.
ReactInstanceManager
는 ReactApplicationContext
를 생성하는 역할을 수행하는데, 이때 앞서 확인했던 패키지들을 초기화한다.
등록했던 패키지들을 NativeModuleRegistryBuilder 로 전달하여 처리하는 코드를 확인할 수 있다.
여기서 노란색으로 강조해 둔 부분을 잘 기억하도록 하고, NativeModuleRegistryBuilder
내에서 어떤 일들이 일어나는지 이어서 확인해 보자.
패키지 인스턴스로부터 ModuleHolder 를 가져오고 있는데, 이는 모듈을 지연 로딩(Lazy Loading) 가능하도록 돕기 위한 클래스이다. 패키지는 ModuleHolder
형태가 되어 NativeModuleRegistry 인스턴스를 생성할 때 사용된다.
이렇게 생성된 NativeModuleRegistry
인스턴스는 CatalystInstance 인스턴스를 생성할 때 전달되는데,CatalystInstance
는 Java에서 자바스크립트 런타임에 접근하거나, 반대로 자바스크립트에서 Java/Kotlin 에 접근할 수 있는 보다 고차원적인 API를 제공한다.
React Native에서 기본적으로 제공하는 모달(Modal)의 구현체를 확인해 보면, 아래와 같이 네이티브 모듈을 참조하고 있는 코드를 확인할 수 있는데, 코드의 흐름을 따라가 보면 ReactModalHostView -> ReactContext.getNativeModule -> CatalystInstance.getNativeModule -> NativeModuleRegistry.getModule
순서로 호출되는 것을 확인할 수 있다.
iOS 모듈 등록
앞서 살펴본 iOS 네이티브 모듈 코드에서 RCTRegisterModule
를 통해 모듈을 초기화하는 코드를 확인했었다.
__attribute__((constructor)) static void initialize_ZendeskMessaging()
{
RCTRegisterModule([ZendeskMessaging class]);
}
React Native 코드를 들여다보면 RCTRegisterModule 구현체를 발견할 수 있는데, 초기화된 네이티브 모듈이 RCTModuleClasses 배열에 모두 등록되는 것을 확인할 수 있다.
static NSMutableArray<Class> *RCTModuleClasses;
static dispatch_queue_t RCTModuleClassesSyncQueue;
NSArray<Class> *RCTGetModuleClasses(void)
{
__block NSArray<Class> *result;
dispatch_sync(RCTModuleClassesSyncQueue, ^{
result = [RCTModuleClasses copy];
});
return result;
}
RCTRegisterModule
을 통해 등록된 모듈들은 RCTModuleClasses
에 저장된다. 이는 브릿지 모듈이 초기화될 때 참조되는데 아래 코드를 살펴보자.
코드 흐름을 따라가 보면 먼저 초기화될 때 RCTGetModuleClasses
를 통해 등록된 모듈(RCTModuleClasses
)들을 참조한다.
등록된 모듈들은 _registerModulesForClasses 메소드 내에서 초기화가 이루어진 후 _moduleDataByName 딕셔너리에 저장되는데, 이때 key는 모듈의 이름이고 value는 RCTModuleData 이다.
RCTModuleData
에는 모듈의 이름, 모듈 내에 포함된 네이티브 메소드 ID 등이 포함되어 있고, 네이티브 모듈을 사용하기 위한 데이터들로 구성되어 있다.
이렇게 등록된 모듈은 moduleForName, moduleForClass 메소드를 통해 참조하게 되는데, 안드로이드에서 확인했던 것과 동일하게 React Native 모달 구현체인 RCTModalHostViewManager를 살펴보면 아래와 같이 모듈을 참조하고 있음을 확인할 수 있다.
Native Module Proxy
지금까지 네이티브 모듈의 등록 과정 그리고 내부적으로 접근하는 과정에 대해 알아보았다. 이어서 자바스크립트 런타임에서 등록된 네이티브 모듈에 접근하는 과정에 대해 살펴보도록 하자.
지난 글에서 자바스크립트 런타임에 대해 설명하면서 JSIExecutor
를 소개했다. 런타임 초기화 시 몇 가지 기능들을 자바스크립트 전역 컨텍스트에 노출시킨다고 이야기했는데, 그중 네이티브 모듈에 접근할 수 있도록 하는 nativeModuleProxy 를 노출시킨다.
nativeModules_
는 앞에서 살펴본 모듈 저장소(Module Registry)를 기반으로 생성된 JSINativeModules 인스턴스이다.
해당 인스턴스를 통해 NativeModuleProxy
인스턴스를 생성하고 자바스크립트 객체로 변환하여 자바스크립트 전역 속성 nativeModuleProxy
에 노출시키는 코드라고 볼 수 있다.
NativeModuleProxy
구현체를 살펴보면 getter 가 구현되어 있는데, JSINativeModules
의 getModule을 통해 네이티브 모듈을 가져온다. 구현되어 있는 코드를 한 번 살펴보자.
m_object에서 모듈 이름에 해당하는 모듈이 존재하면 해당 모듈을 반환하고 존재하지 않다면 createModule 을 호출하여 모듈을 생성하여 새로 할당한다. createModule
구현 코드를 한 번 살펴보자.
먼저, 자바스크립트 런타임의 전역 객체에서 __fbGenNativeModule
프로퍼티의 값을 함수로 가져와 m_genNativeModuleJS 멤버변수에 저장한다. 이렇게 저장된 함수는 모듈을 생성할 때 호출되며, 함수의 인자로는 모듈 생성을 위한 구성 정보와 모듈 ID에 해당하는 숫자 값이 전달된다.
네이티브에서 접근하는 __fbGenNativeModule
의 정체는 무엇일까?
해당 함수의 구현체는 자바스크립트 코드에서 확인 가능하며, NativeModules 모듈에서 확인 가능하다.
가장 아래를 보면 genModule
을 전역 속성의 값으로 할당하고 있다.
genModule
은 네이티브로부터 전달받은 구성(config
)과 모듈 ID(moduleID
)를 가지고 자바스크립트 객체 형태의 모듈을 생성하는 코드이다.
이를 통해 생성된 모듈은 아래와 같은 형태가 될 것이다.
{
"name": "ZendeskMessaging",
"reset": Function(...)
}
지금까지 살펴본 모듈 참조 과정을 한 번 정리해 보도록 하자. 순서대로 나열하면 아래와 같다.
nativeModuleProxy -> NativeModuleProxy.get -> JSINativeModules.getModule -> __fbGenNativeModule (최초 1회)
결론은 nativeModuleProxy
를 통해 네이티브 모듈에 접근하게 되며, 최초로 네이티브 모듈에 접근할 경우에는 자바스크립트 런타임의 전역 객체에서 __fbGenNativeModule
를 참조하여 초기화를 진행한다.
지금까지 여러 과정을 거쳐 자바스크립트 전역 컨텍스트에 노출된 nativeModuleProxy
는 NativeModule
모듈에서 참조되며, NativeModules
라는 이름으로 export 된다.
export 된 NativeModules
는 네이티브 모듈을 구현할 때 아래와 같이 참조한다.
NativeModules[moduleName]
형태로 접근하게 되는데, 내부에 구현된 getter를 통해 등록된 네이티브 모듈에 접근이 가능하다.
네이티브 모듈이 초기화될 때 네이티브 메소드도 함께 초기화되기 때문에 지금부터는 메소드에 접근하여 호출할 수 있게 된다.
genModule
함수를 살펴보자. 모듈 객체를 생성하는 부분을 자세히 살펴보면 메소드를 생성하는 genMethod를 확인할 수 있다.
메소드가 속한 모듈의 ID(moduleID
), 메소드 식별을 위한 ID(methodId
) 그리고 메소드의 동작 방식에 따른 유형(methodType
)이 전달된다.
단순히 메소드 동작 유형(async
, sync
, promise
)에 따라 함수로 래핑(wrapping)하고 있다.
동기로 동작하는 메소드(sync
)는 최종적으로 BatchedBridge.callNativeSyncHook
을 호출한다.
비동기로 동작하는 메소드(async
, promise
)는 콜백(Callback) 혹은 프로미스(Promise)로 래핑 되어있고, 최종적으로는 BatchedBridge.enqueueNativeCall
을 호출한다.
네이티브 모듈에 구현되어 있는 메소드 호출 처리는 BatchedBridge
에서 담당하게 된다. BatchedBridge
는 브릿지 동작에 있어 핵심적인 역할을 수행하는 모듈인데, 이에 대한 자세한 내용은 다음 글에서 이어서 정리하도록 하겠다.
마무리하며
지금까지 살펴본 내용을 정리해보자.
각 플랫폼에 맞게 구현된 네이티브 모듈은 앱 초기화 시점에 저장소에 등록된다. 등록된 네이티브 모듈은 프록시 객체를 통해 자바스크립트 런타임에 노출되고, 프록시 객체로부터 네이티브 모듈의 메소드가 호출되면 모듈 ID, 메소드 ID가 브릿지를 통해 네이티브로 전달된다. 최종적으로 저장소에서 ID에 해당하는 모듈을 탐색하고 해당하는 메소드를 호출하여 동작을 수행하게 된다.
네이티브 모듈의 구현체는 상당히 복잡하지만 전반적인 구조를 정리하고 코드를 잘 따라가 보면 어느 매커니즘으로 동작하는지 이해할 수 있을 것이다. 이렇게 감춰져 있는 부분들 덕분에 우리는 쉽게 네이티브 모듈을 구현하고 사용할 수 있게 된다.
다음 글에서는 자바스크립트 런타임과 네이티브 환경이 상호작용 할 수 있도록 하는 브릿지(Bridge)에 대해 집중적으로 살펴보도록 하겠다.