원문: https://sanjanahumanintech.medium.com/reactnative-new-vs-old-architecture-2eef751d9974
이 글에서는 가장 중요한 변경 사항을 다루고자 합니다.
New Architecture를 살펴보기 전에 Old Architecture의 작동 방식을 요약해 보겠습니다.
React Native는 Android와 iOS 모두에서 실행되는 모바일 앱을 만들 수 있는 JavaScript 라이브러리입니다. 그들은 "한 번 배우면 어디서나 쓸 수 있습니다."라는 슬로건을 내세우고 있습니다.
React Native를 사용하면 JavaScript와 React를 사용하여 모바일 앱을 만들 수 있지만 사용자 인터페이스의 실제 렌더링은 각 플랫폼(iOS 또는 Android)의 네이티브 컴포넌트를 사용하여 이루어집니다.
네이티브 뷰로 렌더링: React Native는 JavaScript 코드를 네이티브 코드(Android의 경우 Java, iOS의 경우 Objective C/Swift)로 직접 변환하지 않습니다. 대신 네이티브 뷰를 사용하여 UI를 렌더링합니다. 이 방식은 더 효율적이고 더 나은 사용자 경험을 제공합니다.
컴포넌트 간의 통신: React Native에서는 컴포넌트를 사용하여 UI를 빌드합니다. 컴포넌트는 프로퍼티(props)와 콜백을 통해 서로 통신합니다. 이는 단방향 흐름을 따르기 때문에 앱에서 데이터 흐름을 더 쉽게 관리할 수 있습니다.
데이터 전달: 부모 컴포넌트가 자식 컴포넌트로부터 무언가를 필요로 하는 경우, 콜백을 전달합니다. 마찬가지로, 자식 컴포넌트가 부모 컴포넌트로부터 무언가를 필요로 하는 경우, 프로퍼티를 전달받습니다. 이러한 단방향 흐름은 앱의 설계와 유지보수를 간소화합니다.
네이티브 코드 통합: React Native는 컴포넌트를 사용하여 UI를 빌드하는 것을 권장하지만 네이티브 컴포넌트(예: MapView)를 사용해야 하는 경우 이를 원활하게 통합할 수 있습니다. React Native는 JavaScript 코드와 네이티브 컴포넌트 간의 통신을 위한 Bridge를 제공합니다.
OS가 JavaScript를 이해하는 방법: OS는 JavaScript 코드를 직접 이해하지 못합니다. 대신 React Native 프레임워크가 중개자 역할을 합니다. 이 프레임워크는 JavaScript 코드를 해석하고 실행하며, Bridge를 통해 네이티브 컴포넌트와 통신하여 기기에서 UI를 렌더링합니다.
지금까지는 괜찮습니다. 그런데 OS는 Javascript 코드를 어떻게 이해할까요?
네이티브 모바일 앱은 해당 플랫폼에 특화된 프로그래밍 언어를 사용하여 개발됩니다. React Native로 개발할 경우, Android 및 iOS용 SDK만 제공하는 결제 서비스 제공업체를 통합하는 것과 같이 라이브러리에서 다루지 않는 작업을 수행해야 하는 경우가 아니라면 Objective C/Java 코드를 작성하지 않고도 거의 모든 작업을 수행할 수 있습니다.
그러나 모든 React Native 프로젝트에는 ios
디렉터리와 android
디렉터리가 있습니다. 이 디렉터리들은 각 플랫폼의 진입점 역할을 하며, 기본적으로 React Native를 부트스트랩합니다. 여기에는 각 플랫폼에 맞는 코드가 포함되어 있으며, 여기에서 각 플랫폼에 맞게 JS 코드가 Bridge됩니다.
앱을 시작하려면 일반적으로 yarn android
또는 yarn ios
를 실행한 다음 원하는 기기에서 앱이 마술처럼 열릴 때까지 기다립니다. 그런데 기다리는 동안에는 어떤 일이 일어날까요?
이러한 명령어 중 하나(각각 react-native run-android
및 react-native run-ios
)를 입력하는 즉시 패키저가 시작됩니다. 그러한 패키저 중 하나가 Metro입니다. 이 패키저는 모든 JS 코드를 main.bundle.js
라는 단일 파일에 넣습니다. 마침내 휴대폰에서 앱이 열리면 휴대폰은 익숙한 장소(android
또는 ios
디렉터리)를 찾습니다. 이것이 바로 위에서 언급한 네이티브 진입점입니다. 이 네이티브 진입점은 스레드에서 JavaScript 가상 머신을 시작합니다. 그러면 main.bundle.js
에 포함된 번들 코드가 이 스레드에서 실행됩니다.
이 JavaScript VM 스레드 내에서 실행 중인 코드는 React Native Bridge를 사용하여 네이티브 스레드와 통신합니다.
RN Bridge로 이동하여 RN 앱의 성능을 분석하기 전에 휴대폰에서 JavaScript 코드를 실행할 수 있는 방법을 알아보겠습니다.
JavaScriptCore는 모바일 기기에서 JavaScript 코드를 실행할 수 있게 해주는 프레임워크입니다. iOS 기기에서는 이 프레임워크가 OS에서 직접 제공됩니다. Android 기기에는 이 프레임워크가 없기 때문에 React Native는 앱 자체와 함께 번들로 제공합니다. 이렇게 하면 앱 크기가 약간 증가지만 거의 문제가 되지 않습니다.
디버깅 시간을 절약할 수 있는 한 가지를 언급하고 싶습니다. JavaScriptCore는 앱이 기기에서 실행될 때 JS 코드를 실행하는 데 사용됩니다. 하지만 앱을 디버깅하도록 선택하면 JS 코드가 Chrome 내부에서 실행됩니다. Chrome은 V8 엔진을 사용하고 네이티브 코드와 통신하기 위해 WebSockets을 사용하므로 올바른 형식의 로그와 어떤 네트워크 요청이 이루어지고 있는지 등의 중요한 정보를 확인할 수 있습니다. 다만 V8 엔진과 JavaScriptCore는 서로 다른 환경이기 때문에 디버거를 연결했을 때만 발생하고 기기에서 앱이 정상적으로 실행될 때는 발생하지 않는 버그가 발생할 수 있다는 점을 기억하세요!
RN Bridge는 Java/C++로 작성되어 있으며, 이것은 앱의 Main 스레드와 JavaScript 스레드 간의 통신을 허용합니다. 이 통신을 위해 사용자 정의 메시지 전달 프로토콜을 사용합니다.
JavaScript 스레드는 화면에 렌더링해야 할 내용을 결정합니다. Main 스레드에 "이봐, 버튼과 텍스트를 렌더링해야 해. 고마워." 이 메시지는 Bridge를 사용하여 전달됩니다. 메시지는 직렬화된 JSON으로 전송됩니다. 그러나 메시지는 화면에 렌더링해야 할 내용 외에 렌더링할 위치도 명시해야 합니다. 여기서 Shadow 스레드가 작동합니다. Shadow 스레드는 JavaScript 스레드와 함께 실행되며 뷰의 위치를 계산하는 데 도움이 됩니다. 결과는 앞서 언급한 메시지와 함께 전달되며, Bridge를 통해 Main 스레드로 전송됩니다.
사용자가 UI에 대해 수행하는 모든 작업은 Main 스레드에서 이루어집니다. 버튼을 탭하거나 스위치를 토글하는 등의 모든 작업은 직렬화되어 Bridge를 통해 JavaScript 스레드로 전송되어야 합니다. 여기서 앱의 모든 로직이 발생합니다.
지금까지 다룬 내용을 다시 한 번 살펴보겠습니다. 사용자는 버튼을 누릅니다. 이 동작은 Main 스레드에서 이해되어 JavaScript 스레드에 메시지로 전달됩니다. 여기서 일부 로직이 처리되고 이에 따라 UI가 변경되어야 합니다. Shadow 스레드는 이러한 변경 사항이 발생하는 위치를 결정한 다음, 업데이트를 네이티브 스레드로 메시지로 다시 전송합니다. 사용자가 화면을 너무 빠르게 탭하지 않기 때문에 일반적인 사용 시나리오에서는 성능 문제가 발생하지 않으며, Bridge는 통신을 매우 빠르게 처리합니다.
(Cordova와 같은 다른 플랫폼과 비교했을 때)React Native의 멋진 점은 WebView 안에서 코드를 실행하지 않는다는 것입니다. React Native는 네이티브 뷰를 사용합니다.
이 장점은 60 FPS로 실행할 수 있는 부드럽고 빠른 앱을 개발할 수 있다는 것을 의미합니다. 트리에서 매우 높은 위치에 있는 컴포넌트의 상태를 수정하는 경우(그리고 쓸데없는 리렌더링을 방지하기 위해 많은 시간을 할애하지 않았다면) 전체 컴포넌트 트리가 다시 렌더링됩니다. 대부분의 경우 이는 사용자에게 표시되지 않습니다. 그러나 이러한 하위 컴포넌트가 계산 비용이 많이 드는 경우 앱이 약간 버벅거리는 것을 느낄 수 있습니다.
RN 앱을 실행하면 모든 JavaScript 코드가 JS Bundle이라는 패키지에 함께 번들로 제공됩니다. 네이티브 코드는 별도로 보관됩니다.
React Native 앱의 실행은 세 개의 스레드에서 이루어집니다:
JS와 네이티브 스레드 간의 통신은 Bridge를 통해 전달됩니다. Bridge를 통해 데이터를 전송할 때는 데이터를 일괄 처리(최적화)하고 JSON으로 직렬화해야 합니다. 이 Bridge는 비동기 통신만 처리할 수 있습니다.
JavaScriptCore: React Native에서 JS 코드를 실행하는 데 사용하는 JavaScript 엔진의 이름입니다.
Yoga: 사용자 화면에서 UI element의 위치를 계산하는 데 사용되는 레이아웃 엔진의 이름입니다.
Old Architecture에서 React Native는 Bridge 모듈을 사용하여 JS 스레드와 네이티브 스레드 간의 통신을 가능하게 합니다. Bridge를 통해 데이터를 전송할 때마다 데이터를 JSON으로 직렬화해야 합니다. 반대편에서 데이터를 수신하면 데이터를 디코딩해야 합니다.
이는 JavaScript와 네이티브 세계가 서로를 인식하지 못한다는 것을 의미합니다.(즉, JS 스레드가 Native 스레드의 메서드를 직접 호출할 수 없습니다.)
또 다른 중요한 점은 Bridge를 통해 전송되는 메시지는 본질적으로 비동기적이기 때문에 대부분의 사용 사례에 적합하지만, JS 코드와 네이티브 코드가 동기화되어야 하는 경우가 있습니다.
JavaScript 스레드가 일부 네이티브 모듈(예: Bluetooth)에 액세스해야 하는 경우 네이티브 스레드에 메시지를 보내야 합니다. JS 스레드는 직렬화된 JSON 메시지를 Bridge로 전송합니다. Bridge는 이 메시지를 최적화하여 네이티브 스레드로 보냅니다. 메시지는 네이티브 스레드에서 디코딩되고, 마침내 필요한 네이티브 코드가 실행됩니다.
그러나 New Architecture에서 Bridge는 JavaScript 엔진이 네이티브 영역의 메서드를 직접 invoke/call하는 데 사용할 수 있는 C++로 작성된 가볍고, 범용 레이어인 JavaScript Interface라는 모듈로 대체될 예정입니다.
현재 아키텍처는 JavaScriptCore Engine을 사용하고 있습니다. Bridge는 이 특정 엔진과만 호환됩니다. 하지만 JSI는 그렇지 않습니다. JavaScript Interface는 엔진에서 분리될 것이고, 이는 새로운 아키텍처를 통해 Chakra, v8, Hermes 등과 같은 다른 자바스크립트 엔진을 사용할 수 있게 된다는 것을 의미합니다. 이것이 "범용"입니다.
네이티브 메서드는 JSI를 통해 C++ Host Objects를 거쳐 JavaScript에 노출됩니다. JavaScript는 이 객체들에 대한 참조를 가질 수 있습니다. 그리고 해당 참조를 사용하여 메서드를 직접 호출할 수 있습니다. 이는 웹에서 JavaScript 코드가 모든 DOM 요소에 대한 참조를 가지고 해당 요소의 메서드를 호출할 수 있는 것과 유사합니다. 예를 들어, 다음과 같이 코드를 작성할 떄:
const container = document.createElement(‘div’);
여기서 컨테이너는 JavaScript 변수이지만 아마도 C++에서 초기화되었을 DOM 요소에 대한 참조를 가지고 있습니다. "컨테이너" 변수에 있는 메서드를 호출하면, 해당 메서드는 DOM 요소에 있는 메서드를 호출합니다. JSI도 비슷하게 작동합니다.
Bridge와 달리 JSI는 JavaScript 코드가 Native Modules에 대한 참조를 가질 수 있게 해줍니다. 그리고 JSI를 통해 JavaScript는 이 참조의 메서드를 직접 호출할 수 있습니다.
JavaScript에는 네이티브 모듈에 대한 직접 참조가 있습니다
JavaScrip Interface를 통해 이 네이티브 모듈의 메서드를 호출합니다.
요약하자면, JSI는 다른 JavaScript 엔진의 사용을 가능하게 하고 스레드 간의 완전한 상호 운용성을 허용하며 JavaScript 코드는 JS 스레드에서 직접 네이티브 측과 통신할 수 있습니다. 이렇게 하면 JSON 메시지를 직렬화할 필요가 없어지고, Bridge의 혼잡 및 비동기 문제가 해결됩니다.
JSI의 또 다른 큰 장점은 C++로 작성되었다는 것입니다. C++의 강력한 성능으로 React Native는 스마트 TV, 시계 등과 같은 많은 시스템을 대상으로 할 수 있습니다.
Fabric은 현재 UI Manager를 대체할 렌더링 시스템입니다.
Fabric의 장점을 이해하기 위해 먼저 React Native에서 UI가 현재 어떻게 렌더링되는지 알아보겠습니다.
앱이 실행되면 React는 코드를 실행하고 JavaScript로 ReactElementTree를 생성합니다. 이 트리를 기반으로 렌더러는 C++로 ReactShadowTree를 생성합니다.
이 Shadow Tree는 레이아웃 엔진에서 호스트 화면에 대한 UI 요소의 위치를 계산하는 데 사용됩니다. 레이아웃 계산 결과가 나오면 Shadow Tree는 Native Elements로 구성된 HostViewTree로 변환됩니다. (예를 들어 React Native의 <View/>
element는 Android에서는 ViewGroup
으로, iOS에서는 UIView
로 각각 변환됩니다.)
아시다시피, 스레드 간의 모든 통신은 Bridge를 통해 이루어집니다. 이는 전송 속도가 느리고 불필요한 데이터 복사가 발생한다는 것을 의미합니다.
예를 들어, ReactElementTree Node가 <Image/>
인 경우 ReactShadowTree의 결과 노드도 이미지가 됩니다. 그러나 이 데이터는 두 노드 모두에 복제되어 별도로 저장되어야 합니다.
그게 다가 아닙니다. JS와 UI 스레드가 동기화되지 않기 때문에, 앱이 프레임을 떨어뜨려 느려 보일 수 있습니다. (예: 방대한 데이터 목록이 있는 FlatList 스크롤)
React Native 공식 문서에 따르면,
"Fabric은 React Native의 새로운 렌더링 시스템으로, 레거시 렌더링 시스템의 개념적 진화입니다."
이 글의 JSI 섹션에서 살펴보았듯이, JavaScript Interface는 네이티브 메서드를 JavaScript에 직접 노출하며, 여기에는 UI 메서드도 포함됩니다. 그 결과 JS와 UI 스레드가 동기화될 수 있습니다. 이렇게 하면 리스트, 네비게이션, 제스처 핸들링 등의 성능이 향상됩니다.
새로운 렌더링 시스템을 사용하면 스크롤, 제스처 등과 같은 사용자 상호 작용의 우선 순위를 지정하여 Main 스레드 또는 네이티브 스레드에서 동기적으로 실행할 수 있습니다. 반면, API 요청과 같은 다른 작업은 비동기적으로 실행됩니다.
이뿐만이 아닙니다. 새로운 Shadow Tree는 immutable
하며 JS와 UI 스레드 간에 공유되어 양쪽에서 바로 상호 작용이 가능합니다.
지금까지 살펴본 바와 같이, Old Architecture에서 React Native는 두 개의 계층/DOM 노드를 유지해야 했습니다. 하지만 이제 Shadow Tree가 영역 간에 공유되기 때문에 메모리 소모를 줄이는데 도움이 될 것입니다.
Old Architecture에서는 앱을 열기 전에 JavaScript에서 사용하는 모든 네이티브 모듈(예: Bluetooth, Geo Location, File Storage 등)을 초기화해야 했습니다. 즉, 사용자가 특정 모듈을 필요로 하지 않더라도 시작할 때 초기화해야 했습니다.
Turbo Modules은 이러한 기존 네이티브 모듈을 개선한 것입니다. 이 글의 이전 부분에서 보았듯이, 이제 JavaScript는 이러한 모듈에 대한 참조를 가질 수 있으므로, JS 코드는 필요할 때만 각 모듈을 로드할 수 있습니다. 이렇게 하면 React Native 앱의 시작 시간이 크게 개선됩니다.
Turbo Modules과 Fabric에 대한 이 모든 이야기는 유망하게 들리지만, JavaScript는 동적 타입 언어이고 JSI는 정적 타입 언어인 C++로 작성되었습니다. 따라서, 둘 사이의 원활한 의사소통을 보장할 필요가 있습니다.
이것이 바로 New Architecture에 CodeGen이라는 정적 타입 검사기가 포함되는 이유입니다.
타입이 지정된 Javascript를 진실 공급원(source of truth)으로 사용함으로써, CodeGen은 Turbo Modules와 Fabric에서 사용하는 인터페이스 요소를 정의합니다. 또한 런타임 대신 빌드 타임에 더 많은 네이티브 코드를 생성합니다.
모든 변경 사항을 종합하면 New Architecture는 다음과 같은 모습이 됩니다.
주요 내용은 다음과 같습니다.
Bridge가 JSI로 대체됩니다.
JavaScriptCore를 다른 엔진으로 교체할 수 있습니다.
모든 스레드 간의 완벽한 상호 운용성
웹과 유사한 렌더링 시스템
시간에 민감한 작업을 동시에 실행할 수 있습니다.
Turbo Modules의 Lazy Loading
정적 타입 검사로 JS와 Native Side 간의 호환성
이 새로운 구조는 React Native에 몇 가지 강력한 개선점을 제공할 것입니다.