개요
지난 글에서는 프로젝트 코드가 번들링 되는 과정에 대해 알아보았다.
이번 글에서는 만들어진 번들이 어떻게 실행되는지 살펴보도록 하겠다.
Architecture
React Native를 접해본 개발자라면 기본적인 아키텍처에 대해 여러 번 접한 적이 있을 것이다.
자바스크립트와 네이티브 간의 상호작용은 브릿지(Bridge)를 통해서 이루어진다는 것은 아주 오래전부터 이어져왔다. 하지만 브릿지에는 몇 가지 고질적인 성능 이슈가 있었는데 상호작용을 위해선 JSON으로 직렬화/역직렬화가 필요하고 모든 작업은 비동기적으로 처리된다는 점이다.
이러한 문제를 개선하기 위해서 React Native 팀에서는 새로운 아키텍처(New Architecture)에 대해 발표했고, 현재 릴리즈 된 상태이다. 새로운 아키텍처가 발표된 이후에는 브릿지를 JSI(JavaScript Interface)가 대체하게 될 것이라는 이야기가 주를 이루게 되었다.
여기서 잘못 착각하고 있는 경우가 종종 있는데, "새로운 아키텍처에서만 JSI 기반으로 상호작용 가능하다" 라는 부분이다.
현재 글을 작성하고 있는 시점의 React Native 버전의 경우 자바스크립트 런타임과 상호작용 하는 코어 로직이 JSI 기반으로 대체되었고, 여전히 브릿지는 유효하다. 많은 아키텍처 자료들에서는 "자바스크립트와 네이티브 간의 상호작용은 브릿지를 통해 처리"된다라고 상당히 추상적으로만 표현하고 있는데, 내부를 들여다보면 브릿지 또한 JSI 기반으로 동작하고 있다. 즉, 새로운 아키텍처에서만 JSI가 사용된다는 것은 잘못된 사실이다.
이에 대한 근거는 직접 React Native의 코드를 살펴보면 알 수 있다. 쉽게 설명하기 위해 nativeFlushQueueImmediate
구현부를 가져왔다. 이는 React Native에서 브릿지 동작을 처리하는 핵심 기능(함수) 중 하나이며 과거와 현재의 코드를 비교해 보면 이미 JSI 기반으로 변경된 것을 확인할 수 있다.
- Before: nativeFlushQueueImmediate
- After: nativeFlushQueueImmediate
또한, 브릿지에서 상호작용하는 값은 JSON 형태로 직렬화 & 역직렬화를 거치던 로직도 이미 오래 전부터 JSI 기반으로 대체되었다.
- Before: callNativeModules (JSON 직렬화 & 역직렬화 과정 존재)
- After: callNativeModules, dynamicForValue (JSI 값으로 처리 -> 직렬화 & 역직렬화 과정 없음)
이처럼 JSI 기반으로 변경된 로직 위에서 브릿지가 동작하고 있는 상태인데, 브릿지의 동작 메커니즘에 대한 변경은 없다. 즉, 브릿지가 갖고 있던 기존의 단점들은 아직 해결되지 않은 상황이다.
브릿지와 새로운 아키텍처의 오해를 정리하자면 아래와 같다. (React Native 0.60.x 이후부터)
기존 아키텍처의 브릿지와 새로운 아키텍처의 구성요소(Turbo Modules, Fabric)들은 모두 JSI 기반으로 동작하며, 이들은 추가적인 JSON 직렬화 과정 없이 JSI와 동기적(Turbo Modules, Fabric)으로 상호작용 하거나 비동기적(Bridge)으로 상호작용 한다.
브릿지의 단점을 해결하기 위해서는 Native Modules을 Turbo Modules로 마이그레이션 하여 브릿지를 대체하거나, C++(JSI) 기반으로 코드를 재작성하는 등의 작업이 필요해 보인다.
미래에는 JSI가 브릿지를 조금씩 대체할 것으로 기대하지만, 각종 3rd Party 라이브러리들의 호환성 그리고 라이브러리 개발자들의 느린 대응 등 여러 문제로 인해 단시간에 이루어지진 않을 것 같다는 것이 필자의 생각이다.
react-native-mmkv는 @react-native-async-storage/async-storage 의 대항마로, Key-Value 저장소 라이브러리이다.
이는 New Architecture 활성화를 요구하는 Turbo Module을 사용하고 있는 것은 아니지만, 코어 로직이 C++(JSI)로 구현되어 있기 때문에 상당히 빠르다.
필자 본인은 C++ 그리고 Rust를 배워보려고 한다. 본인은 브릿지 기반의 라이브러리를 몇 번 개발한 경험이 있는데 퍼포먼스를 위해 JSI 기반으로 구현하는 것을 목표로 한다. Rust는 왜?라고 생각할 수 있는데, JSI를 Rust에 바인딩하여 사용할 수 있도록 하는 프로젝트 또한 존재한다. 아직 완벽하진 않지만 가까운 미래에는 Rust 기반 모듈도 구현할 수 있지 않을까 생각해 본다.
JSI(JavaScript Interface)
자바스크립트 런타임(엔진)은 성능을 위해 대부분 C/C++ 기반으로 구현되어 있다.
JSI(JavaScript Interface)는 자바스크립트 엔진의 인터페이스를 추상화한 API인데, 이를 통해 복잡한 코드 구현 없이 JSI를 통해 자바스크립트 런타임과 상호작용 할 수 있다.
JNI(Java Native Interface)
JNI(Java Native Interface)는 Java와 네이티브(C/C++) 간의 상호 작용을 가능하게 하는 기술이다.
안드로이드의 경우 Java 혹은 Kotlin 언어를 기반으로 네이티브를 구현하는데, 이 언어들이 컴파일되고 실행되는 JVM(Java Virtual Machine)에서는 C/C++ 기반으로 작성된 네이티브 코드를 실행할 수 없다.
이러한 문제를 해결하기 위해 탄생한 기술이 JNI이라고 보면 된다.
Core Instance
이번 글의 주제인 런타임을 살펴보기에 앞서 React Native의 전체적인 코어 로직이 어떻게 구성되어 있는지 간략히 살펴보고 본 주제로 넘어가려고 한다. 전반적인 구조에 대해 미리 살펴보고 세부 구현 사항을 살펴보면 보다 쉽게 이해할 수 있을 것이다.
React Native에서 런타임을 구성하고, 번들을 실행시키고, 브릿지 동작을 처리하는 역할은 아래 다이어그램에 표기된 모듈들을 통해 이루어진다. (다이어그램의 복잡도를 최소화하기 위해 주요 모듈만 표기했으며, 의존 관계는 대부분 생략했다)
여러 가지 모듈들이 엮여있는 구조인데 간단히 분류하자면 아래와 같이 나눌 수 있다.
ModuleRegistry
,NativeModule
: 네이티브 모듈JSExecutor
: 자바스크립트 런타임NativeToJsBridge
,JsToNativeBridge
: 브릿지
이처럼 역할별로 모듈이 분리되어 있고 이들이 하나로 엮여 React Native 애플리케이션의 주요 동작을 책임진다. 애플리케이션은 파란색으로 표시해 둔 Instance로부터 시작되며, 이번 글에서는 런타임 파트와 관련된 내용(빨간색)을 소개할 예정이다.
Instance
는 런타임, 네이티브 모듈, 브릿지 등 여러 가지 역할을 수행하는 각 모듈들의 집합체로, 보다 더 높은 수준으로 추상화한 구현체이다. 즉, Instance
는 주요 동작을 처리할 수 있지만, 구체화된 동작은 개별 모듈 내에 구현되어 있다.
참고로 C++로 구현되어 있는 Instance
는 플랫폼에 맞게 사용되고 있는데, Android, iOS에서는 각각 CatalystInstance 그리고 RCTCxxBridge 에서 인스턴스를 생성하고 있다.
안드로이드의 경우 C++ 네이티브를 바로 사용할 수 없기에, JNI를 통해 CatalystInstanceImpl 클래스를 구현하여 사용한다.
지금 살펴본 Instance
는 React Native 애플리케이션 주요 동작의 시초가 된다. Instance
생성 후 런타임과 관련된 모듈들이 구성된다는 점을 잘 인지하고 있도록 하자.
자바스크립트 런타임(JavaScript Runtime)
앞에서는 기본적인 아키텍처와 JNI 그리고 JSI에 대해 간략히 알아보았다.
지금부터는 자바스크립트 코드가 실행되는 런타임(Runtime)에 대해 살펴보려고 한다. (여기에서 이야기하는 런타임은 자바스크립트 엔진에 해당한다)
지난 글에서 번들링에 대해 살펴보았다. 번들링 결과는 여러 가지 변환과 후처리를 거친 번들이라는 것은 모두가 알고 있는 사실이다. 번들은 자바스크립트 코드인데, 이를 실행시키기 위해서는 런타임이 필요하다.
React Native에서는 JSC(JavaScript Core) 혹은 Hermes 엔진을 애플리케이션 내부에 포함시키고 이를 통해 번들을 실행시킨다. 런타임의 종류 그리고 애플리케이션 실행 시 런타임이 어떻게 초기화되는지 간단히 들여다보도록 하자.
JSC(JavaScript Core)
JSC(JavaScript Core)는 사파리 브라우저에서 사용되고 있는 자바스크립트 엔진이다.
React Native 0.70.0 이전 버전에서는 기본 엔진으로 JSC가 사용되었으나, 이후부터는 Hermes가 기본 엔진으로 변경되었다.
JSC 런타임 구현체는 JSCRuntime.cpp에서 확인 가능하며, 초기화 시점에는 JSExecutorFactory 를 통해 런타임 인스턴스가 생성된다.
인스턴스 생성 시 runtimeInstaller
를 인자로 받고 있는 부분을 잘 기억해 두자.
Hermes
Meta(구 Facebook)에서 개발한 React Native를 위한 경량 엔진이다.
현재 시점의 React Native에서는 별다른 구성을 하지 않을 경우 기본 런타임으로 사용된다.
Hermes 역시 JSC와 동일하게 JSExecutorFactory 를 통해 런타임 인스턴스가 생성되며, 런타임 세부 구현은 Hermes GitHub에서 확인 가능하다
여기서도 동일하게 인스턴스 생성 시 runtimeInstaller
를 인자로 받고 있는 부분을 잘 기억해 두자.
Debugging Proxy
Hermes 디버깅을 위한 프록시(Proxy) 런타임도 존재한다.
이는 실제 애플리케이션 내에 존재하는 Hermes 엔진을 통해 구동하는 것이 아니라 말 그대로 프록시이기 때문에 개발 모드로 연결되어 있는 PC의 크롬에서 구동된다. 즉, V8이 런타임이 된다.
프록시를 통해 크롬에서 실행되기 때문에 개발자 도구 기능을 사용할 수 있다. 다만 Hermes와 V8 엔진은 서로 다른 부분(성능, 지원 기능 등)이 많기 때문에 말 그대로 디버깅 용도로만 사용되어야 한다.
이에 대한 자세한 내용은 이번 글에서 다루지는 않겠다.
JSIExecutor
먼저 런타임 초기화에 대해 알아보기에 앞서 JSIExecutor 에 대해 간략히 짚고 넘어가도록 하겠다.
앞서 소개한 다이어그램에서도 찾아볼 수 있는데, JSIExecutor
는 JSExecutor
를 상속하고 있다. 이는 자바스크립트와 네이티브 간의 상호작용을 위한 기능들이 구현되어 있으며, 런타임 초기화 시 몇 가지 기능들을 자바스크립트 전역 컨텍스트에 노출시킨다.
코드를 확인해 보면 알겠지만, 브릿지 처리 인터페이스, 네이티브 모듈 프록시, 네이티브 로깅 인터페이스 등 여러가지 기능들을 전역 컨텍스트에 노출시킨다. 이렇게 노출된 대상은 대부분 브릿지 처리를 위한 기능인데, 이 부분에 대해서는 다음 글에서 살펴볼 예정이다.
이어서 JSIExecutor
가 런타임과 무슨 연관이 있는지 살펴보도록 하자.
런타임 초기화
애플리케이션 실행 직후 초기화 과정을 거치는데, 이때 런타임이 초기화된다.
앞서 살펴본 내용을 되짚어보면 JSC, Hermes 모두 JSExecutorFactory
를 통해 런타임 인스턴스가 생성된다고 이야기했다.
해당 팩토리는 언제 사용되고, 런타임이 어떻게 생성되는지 한 번 살펴보도록 하자.
iOS
RCTAppDelegate.mm 의 jsExecutorFactoryForBridge 메소드 구현에 따라 런타임 팩토리를 사전 구성한다.
RCTAppDelegate
는 우리의 프로젝트에서 확인할 수 있는 AppDelegate
의 기반이다. 특정 프로젝트에서는 jsExecutorFactoryForBridge
를 오버라이드 하여 사용하고 있을 수도 있는데 이는 개발 중인 프로젝트에 따라 다른 부분이니 넘어가도록 하고, jsExecutorFactoryForBridge
를 확인해 보면 내부에서 RCT_NEW_ARCH_ENABLED
값에 따라 RCTAppSetupDefaultJsExecutorFactory 혹은 RCTAppSetupJsExecutorFactoryForOldArch 를 호출하고 있는 것을 확인할 수 있다.
RCT_NEW_ARCH_ENABLED
는 New Architecture를 활성화할 경우 1(True)이 되는데 프로젝트가 새로운 아키텍처 기반으로 동작하도록 구성한 경우에는 RCTAppSetupDefaultJsExecutorFactory
가 호출되고, 그게 아니라면 RCTAppSetupJsExecutorFactoryForOldArch
가 호출된다.
아키텍처 구성에 따른 동작 차이는 Turbo Modules 구성의 유무 정도이고, 현재 알아볼 부분은 이게 아니다.
RCTAppSetupUtils.mm
내에 정의되어 있는 RCTAppSetupDefaultJsExecutorFactory 혹은 RCTAppSetupJsExecutorFactoryForOldArch 의 내부 구현을 살펴보도록 하자.
RCT_USE_HERMES
는 Hermes 엔진을 사용하도록 구성한 경우 1(True)이 되는데, 구성에 따라 엔진이 결정되는 것을 볼 수 있다.
앞에서 확인했던 것처럼 JSExecutorFactory
는 인스턴스 생성 시 runtimeInstaller
를 인자로 받는데, 두 엔진 모두 JSIExecutor
를 런타임으로 사용하도록 구현된 모습을 볼 수 있다.
다시 jsExecutorFactoryForBridge
로 돌아가면, 이 메소드는 브릿지 초기화 시 호출되고, 이후 런타임은 여러분이 구성한 환경의 JSExecutorFactory
에 따라 결정된다.
Android
안드로이드도 iOS와 유사하게 동작한다. 다만, JVM에서는 C++(JSI) 네이티브 코드를 구동시킬 수 없다. 그렇기에 안드로이드는 JNI를 사용한다고 설명했는데, 한 번 살펴보도록 하자.
iOS와 유사하게 런타임 인스턴스는 JavaScriptExecutorFactory
를 통해 생성된다.
Factory 내에서 JSCExecutor, HermesExecutor 인스턴스를 생성하는 코드를 확인해 보면 구체적인 구현체는 온데간데없고, loadLibrary
메소드 내에서 리눅스 라이브러리 파일인 .so (shared object)
를 로드하고 있는 모습을 확인할 수 있다.
이는 네이티브(C/C++)로 구현된 대상을 빌드하고, JNI를 통해 참조하여 사용하는 코드이다.
그러면 이 JavaScriptExecutorFactory
는 어디서 사용되는 것일까?
거슬러 올라가자면 꽤 복잡한데 순서대로 살펴보면 된다.
ReactInstanceManager
의 멤버 필드JavaScriptExecutorFactory
는 ReactInstanceManagerBuilder 에서 인스턴스화됨ReactInstanceManagerBuilder
는 ReactNativeHost 에서 사용- 기본 구성인 경우 MainApplication에 존재하는 DefaultReactNativeHost 구성에 따름
순서대로 따라가 보면 이해가 될 것이다.
한 가지 참고할 점은 DefaultReactNativeHost(ReactNativeHost)
의 경우 기본적으로 getDefaultJSExecutorFactory
가 구현되어있지 않아 null
을 반환한다.
ReactInstanceManagerBuilder
에서 ReactInstanceManager
인스턴스를 생성할 때, JavaScriptExecutorFactory
가 null
그리고 jsEngineResolutionAlgorithm
이 null
일 때에는 아래의 코드가 실행된다.
FLog(Facebook Log) 메시지를 보면 명시적으로 JS 런타임을 지정하지 않을 경우에는 JSC를 먼저 로드하고, Hermes를 fallback으로 로드한다고 한다.
이로써 안드로이드의 런타임 구성과 초기화 과정을 간단히 살펴보았다.
마무리하며
지금까지 살펴본 내용을 정리하자면, 초기 앱 실행 시 런타임(JSC 혹은 Hermes)이 초기화 되고 이들과 상호작용 할 수 있는 기반이 마련되며, 상호작용은 프로젝트 구성에 따른 팩토리에서 생성된 JSExecutor
를 통해 이루어진다.
이처럼 번들을 실행시키기 위해서는 런타임이 반드시 필요한데, 런타임이 초기화되는 과정은 꽤나 험난하고 복잡하다.
React Native 개발함에 있어 본 글의 내용은 반드시 알고 있어야 하는 내용은 아니다. 다만 모종의 이유로 인해 내부를 들여다봐야 할 경우 이러한 내용을 알고 있다면 분명 도움이 될 것이다. (커스텀 런타임 구성 등)
다음 글에서는 네이티브 모듈과 브릿지에 대해 집중적으로 살펴보도록 하자.