본문 바로가기
프로그래밍 개발/React Native

React Native - 작동원리 그리고 UI 스레드 / JS 스레드에 대한 고찰

by Jinseok Kim 2023. 9. 22.
반응형

작동원리 그리고 UI 스레드 / JS 스레드에 대한 고찰

 

 

지금까지 React Native을 통해 개발을 하면서 알게 된 근본적인 작동원리 그리고 작동원리와 관련된 UI 스레드와 JS 스레드에 대한 고찰을 기록해보자고 합니다.

 

기존 OS별 Native 단에서 앱 개발을 했던 과거에 비하여 React Native라는 프레임워크를 통해 OS 둘 다 함께 개발이 가능하도록 하고 JS 언어와 ReactJs 문법을 사용하면서 개발 생산성을 매우 높게 해주었습니다.

 

ReactJs로 개발 공부를 시작한 저로서는 앱도 개발할 수 있는 능력을 갖추게 해 준 매우 고마운 존재이기도 합니다.

 

물론 Native로만 개발하면 React Native보다 생산성 말고는 성능적으로나 기능 구현에서는 더 앞서 나가는 건 사실이지만 엄청난 생산성을 무시할 수 없는 상황입니다.

그래서 한번 React Native가 어떻게 작동하는 원리와 앞으로 어떤 방향으로 React Native 개발을 해야 더 좋은 성능 최적화 개선을 할 수 있을지 알아보고자 합니다.

 

 

 

 

 

 

 

React Native의 작동원리


 

React Native으로 개발한 앱은 크게 두 가지 부분으로 구성되어 있습니다. Native부분과 JavaScript부분입니다.

Native부분에서 iOS에서는 Objective-C/Swift, Android에서는 Java/Kotlin이 담당하는 곳으로 UI를 랜더링 합니다. 즉, 이것을 UI 스레드(메인 스레드)라 불리는 것이 UI를 생성합니다.

 

반면 앱 개발을 ReactJs로 가능케 하는 JavaScript 부분에는 JS 스레드라고 불릴 수 있습니다. 이곳은 말 그대로 자바스크립트 엔진을 통해 자바스크립트 코드가 실행되는 곳이며 비즈니스 로직을 포함하여 뷰를 언제, 어떻게 표시할지와 같은 React관련 자바스크립트 코드가 실행되는 스레드입니다.

 

 

 

그럼 UI 스레드(메인 스레드)와 JS 스레드가 서로 상호작용하면서 작동하게 하는 구동 원리가 있을 겁니다. 바로 Native Bridge였습니다.

 

하지만 Native Bridge는 React Native의 초기 작동 아키텍처였고 더 개선된 아키텍처로 Fabric이라는 것으로 현재 랜더링 되고 있습니다.

 

일단 첫 아키텍처인 Native Bridge을 순서로 먼저 알아봅니다.

 

 

 

 

Native Bridge 구동 방식 ( 구 )

Native부분인 UI 스레드(메인 스레드)와 JavaScript부분인 JS 스레드는 Native Bridge라는 것을 통해 소통하였습니다. 이것은 클라이언트 <-> 서버가 통신하는 것과 비슷합니다. 클라이언트가 UI 스레드, 서버가 JS스레드이라고 볼 수 있습니다. 

 

앱이 실행되면 UI 스레드가 실행되며 UI 스레드는 JS스레드를 실행시키고 자바스크립트 번들을 로드합니다.

그리고 Native Bridge는 아키텍처 각 부분에서 전달해야 하는 정보인 자바스크립트 번들을 JSON object 형태로 변환하여 Shadow 스레드(Yoga)에 전달합니다.

 

JSON object 형태로 변환되어 정보를 전달받은 Shadow 스레드 layout을 계산을 합니다. Shadow 스레드는 계산한 값을 UI 스레드에 넘겨주고 UI 스레드는 실제 View를 그려주고 사용자가 어떠한 이벤트를 실행하면 UI 이벤트 정보들이 Native Bridge를 경유하여 JS스레드에 전달합니다.

 

JS 스레드에서 비즈니스 로직들이 실행되어 바뀐 layout을 React는 virtual DOM을 생성하고 diffing 하여 비교 후 변경된 부분만 Native Bridge에 넘겨주고 넘겨준 값을 Shadow 스레드에서 다시 계산하고 이를 UI 스레드에 넘겨주고 다시 UI 스레드는 새로운 View을 그립니다.

 

Diffing (Algorithm) 이란?

React에서 우리가 선언적으로 정의하는 UI는 실제 DOM노드가 아니라 경량화된 자바스크립트 객체인 virtual DOM이다. React는 이 virtual DOM을 사용하여 최소한의 변경사항을 파악하여 새롭게 렌더 하게 된다. 이때 사용하는 알고리즘이 diffing 알고리즘이다.

React에서는 트리의 레벨 별 비교 작업을 수행하며 이 작업의 시간복잡도는 O(n)에 가깝다고 한다. 보통 애플리케이션에서 컴포넌트 트리의 레벨이 변경되는 경우는 많이 없다는 점에 기인한 heuristic 한 방법을 사용한다.

또한 React에서 사용자가 정의한 컴포넌트를 사용하는 경우가 많은데, 이 경우에는 diffing알고리즘은 같은 클래스인 경우에만 수행되며 다른 클래스를 갖고 있다면 비교작업을 수행하지 않고 그 컴포넌트를 추가한다.

이로 인해 두 컴포넌트를 비교하기 위한 시간을 소모하지 않는다.

 

 

 

결국은 React Native앱에서 가장 중요하다고 볼 수 있는 부분이 Native Bridge입니다. 이 부분이 병목현상이 가장 많이 생기며 좋은 성능의 React Native앱을 위해서는 Native Bridge를 건너는 횟수를 최소한으로 해야 했었습니다.

 

 

구동 방식을 3가지로 요약하자면 아래와 같습니다.

UI 스레드(메인 스레드): 핸들 사용자 인터페이스에 대한 책임. UI를 업데이트하거나 기본 기능에 액세스해야 할 때마다 JS 스레드와 통신합니다.
 1-1) 네이티브 UI -기본 안드로이드 또는 ios의 UI 렌더링에 사용.
 1-2) 네이티브 모듈.

Shadow 스레드: 레이아웃이 계산되는 스레드. facebook의 자체 레이아웃 엔진인 Yoga를 사용하여 flexbox 레이아웃을 계산하고 UI 스레드로 다시 보냅니다.

JS 스레드: 모든 자바 스크립트 코드를 읽고, 컴파일 및 응용프로그램의 비즈니스 로직의 대부분이 일어나는 곳입니다. 

 

 

전체적인 작동 순서 요약을 해보자면 아래와 같습니다.

1. 사용자가 앱 아이콘을 클릭합니다.

2. UI 스레드가 실행되고 메인 스레드는 JS 스레드를 실행시키고 자바스크립트 번들을 로드합니다.


3. JS 스레드가 실행되면서 React는 virtual DOM을 생성하고 diffing 알고리즘을 통해 변경사항을 Native Bridge를 경유하여 Shadow 스레드로 전달합니다.

4. Shadow 스레드는 변경사항 메시지를 통해 화면의 레이아웃을 계산하고 계산이 끝난 레이아웃의 파라미터나 객체를 UI 스레드로 보냅니다.

5. UI 스레드가 UI를 화면에 표시합니다. 사용자가 화면에 입력한 UI 이벤트 정보들이 Native Bridge를 경유하여 UI 스레드로 보냅니다.

6. UI 이벤트 메시지를 활용하여 JS 스레드에서 비즈니스 로직들이 실행되고 React는 다시 virtual DOM을 생성하며 변경사항을 다시 Native Bridge를 경유하여 Shadow 스레드로 전달됩니다.

... 3, 4, 5, 6 과정이 반복됩니다.

 

 

 

 

하지만 Native Bridge는 Native와 js 간의 소통에 있어 Bridge를 통해 이루어지고, 이를 비동기로 소통을 한다는 문제가 있었습니다.

 

왜냐하면 모든 스레드는 Native Bridge를 통해 전송되는데 이에 대한 응답을 언제 올지 모르는 체로, 응답에 상관없이 계속해서 요청을 보낸다는 것이 혼잡함을 제공하고, 성능을 떨어트리게 되게 됩니다.

 

예를 들자면 네이트브 영역에서 onScroll event가 발생할 때마다 정보는 javascript 영역으로 비동기적으로 전송을 하고 네이티브 영역에서는 이를 기다리지 않고 다른 작업을 계속하게 됩니다.

이로 인해 전보가 화면에 표시되기 전에 이벤트는 계속 이러나고 결국 UI상 공백을 이끌게 되고 애니메이션 성능과 같은 기능들이 제대로 작동하지 않으면서 사용성을 떨리어트게 되죠.

 

이러한 문제점을 가지고 있기 때문에 facebook팀은 새로운 아키텍처 시스템을 도입하게 되었습니다. 새로운 랜더링 시스템 구동 방식인 Fabric 랜더링 방식입니다.

 

 

 

 

 

Fabric 구동 방식 ( 신 )

기존의 Native Bridge 구동 방식은 Shadow Tree + JSON (Async) + Native Modules에 의해 처리되지만 Fabric을 통해 UI 관리자는 C++에서 직접 Shadow Tree를 만들 수 있으며, 이는 영역의 터널링(점프하는 횟수)을 줄여 프로세스의 신속성을 크게 향상해 줍니다.

 

즉, 유저들은 향상된 인터페이스의 응답성을 통해 좋은 경험을 할 수 있게 됩니다. 또한 Fabric은 JSI를 사용하여 UI 작업을 함수로 JS에 노출하므로, 새로운 Shadow Tree(JS 스레드에서 코딩 한 레이아웃 트리를 구성)는 두 영역 간에 공유되므로, 양쪽 끝에서 직접적인 상호 교환이 가능해집니다.

 

또한 TruboModules 접근 방식을 통해 JS 코드들은 실제로 필요할 때만 각 모듈들을 로드하고 모듈에 대한 직접 참조를 유지할 수 있으므로, 더 이상 기존 브리지에서 배치된 JSON 메시지를 사용하여 통신할 필요가 없습니다.

 

즉, 네이티브 모듈이 있는 응용프로그램을 기존보다 더 빠르게 시작할 수 있게 되었습니다.

 

 

구동 방식을 요약하자면 아래와 같습니다.

JSI: Native Bridge를 대체합니다. 실제로 JSI는 JS 런타임 엔진 (실행 시 js 코드가 실행됨)에 API를 제공하고 Native Bridge 없이 JS가 기본 기능과 개체를 직접 인식하도록 합니다. JS 스레드에서 네이티브로 또는 그 반대로 호출을 동기화함으로써 UI 메인 스레드를 직접 호출하여 사용하고 스레드 간에 데이터를 공유하여 빠르게 렌더링 합니다.

Fabric: 네이티브 측을 담당할 UIManager의 새 이름입니다. 이제 가장 큰 차이점은 브리지를 통해 JS 측과 통신하는 대신 JSI를 사용하여 기본 기능을 노출하므로 JS 측과 그 반대의 경우도 ref 함수를 통해 직접 통신할 수 있다는 것입니다. 더 좋고 효율적인 성능과 측면 간 데이터 전달하게 되었습니다.

TruboModules: 터보 모듈의 목적은 현재 아키텍처의 기본 모듈과 동일 하지만 구현되고 다르게 작동합니다. 처음에는 지연 로드 됩니다. 즉, 시작 시 모든 항목을 로드하는 대신 앱에 필요할 때만 로드됩니다. 또한 JSI를 사용하여 노출되므로 JS는 React Native JS lib 측에서 이를 사용하기 위한 참조를 보유하므로 특히 실행 시 성능이 향상됩니다.

CodeGen: JS를 JS의 정적 유형을 생성하는 데 도움이 되는 단일 소스로 만드는 데 사용됩니다. 그러면 네이티브 측(패브릭 및 터보 모듈)이 이를 인식하고 결과가 나올 때마다 데이터 유효성을 검사하지 않게 됩니다. 최소한의 시간 소비, 더 나은 성능 및 데이터 전달 중 실수 가능성 감소로 이어집니다.

 

 

전체적인 작동 순서 요약을 해보자면 아래와 같습니다.

1. 사용자가 앱 아이콘을 클릭합니다.

2. Fabric은 네이티브 측면을 직접 로드합니다. (네이티브 모듈 없음).

3. 이제 JS 스레드에 준비가 되었음을 알리고 이제 JS 측은 모든 js 및 반응 논리 + 구성 요소를 포함하는 모든 main.bundle.js를 로드합니다.

4. JS는 ref 네이티브 함수(JSI API를 사용하여 객체로 노출된 함수)를 통해 Fabric에 호출하고 섀도우 노드는 이전과 같이 트리를 생성합니다.

5. Yoga는 Flexbox 기반 스타일에서 호스트 레이아웃으로 변환하는 레이아웃 계산을 수행합니다. Fabric이 작업을 수행하고 UI를 표시합니다.

 

 

 

 

 

 

 

React Native 성능 개선 최적화와 UI 스레드와 JS 스레드


기존 Native Bridge 아키텍처가 개선되었어도 사실상 React Native을 개발함에 있어 크게 달라진 것이 없어 코드를 마이그레이션을 하거나 그러지는 않습니다.

 

여기서 중요한 포인트는 아키텍쳐가 어떤 종류이든 결국 기존 순수 Native와 다르게 React Native 프레임워크는 UI 스레드와 JS 스레드는 서로 소통해야 한다는 중간점의 딜레이가 존재한다는 것입니다.

 

이 둘의 소통을 한다는 딜레이의 단점을 극복하고자 개발을 진행하면서 최적화를 고려해야 한다는 사실은 React Native로 개발하자고 한다면 피할 수 없다고 생각하고 있습니다.

 

 

그래서 React Native의 이러한 단점을 극복하고자 개선해야 할 점들을 크게 두 가지로 정리해 보았습니다.

1. React 랜더링 최적화를 통한 UI 스레드와 JS 스레드 간의 소통 최소화

2. JS스레드를 거치지 않고 UI 스레드를  사용하는  React Native Reanimated 애니메이션 라이브러리 사용

 

 

 

 

1. React 랜더링 최적화를 통한 UI 스레드와 JS 스레드간의 소통 최소화

 

React Native는 Native Bridge와 같은 아키텍처를 통해 UI 스레드와 JS 스레드 사이의 소통이 오고 가는 것이 특징인데 이 오고 가는 것이 점점 많아진다면 병목 현상이 발생하여 성능은 떨어질 수밖에 없습니다.

이때 이러한 UI 스레드와 JS 스레드 사이의 소통을 최소화하는 것이 React Native 앱의 성능을 최척 화하는 방법인데 위에서 아키텍처 관련 원리 설명에서 나온 것처럼  애니메이션 및 성능 집약적인 작업은 기본적으로 UI 스레드에서 처리하지만 전체 비즈니스 로직은 JS 스레드에서 실행됩니다.

 

즉, ReactJs 부분에서 사용자가 화면에 입력한 UI 이벤트 정보를 체크하고 있다가 브리지를 통해 UI 스레드와 소통을 시작하는데 이때 사용자가 이벤트를 많이 발생시켜 ReactJs에서 랜더링되는 UI 이벤트 부분이 많아진다면 브릿지와 소통해야할 UI 또한 많아져 성능을 더 많이 떨어트린다는 사실입니다.

 

예를 들자면 <View>, <Text> 등등 React Native에서 사용하는 내장 엘리먼트 태그들은 브릿지를 통해 UI 스레드 즉, Native에 전달합니다.

이때 ReactJs에서는 props의 얕은 동등성을 비교하고 마지막 렌더링의 props와 다른 경우에만 브리지를 통해 UI 스레드로 전달합니다.  ReactJs에서 
바르게 메모이제이션하지 않으면 렌더링 할 때마다 <View>, <Text>와 같은 태그들이 변경된 사항으로 인식하여 브리지와 소통되기 때문에 결국 브리지의 병목 형상의 가능성을 높이며 성능이 나빠지는 결과를 초래하게 됩니다.

 

 

랜더링 최적화가 이뤄지지 않은 상황은 아래 예시와 같이 인라인으로 스타일링하면 <View> 태그 자체가 재생성되어 랜더링 해야 하는 태그 엘리먼트로 인식하게 됩니다.

// Bad
return <View style={{background: 'red'}} />

 

 

아래와 같이 메모이제이션을 해줘야 랜더링을 인한 불필요한 객체 생성을 막고 브리지와 소통하지지 않게 됩니다.

// Good
const style = useStyle(() => ({background: 'red'}));
return <View style={style} />

 

 

더 자세한 최적화 방법들은 아래 Githib 커뮤니티를 참고하시면 더 좋을 거 같습니다.

 

Memoize!!! 💾 - a react (native) performance guide

Memoize!!! 💾 - a react (native) performance guide. GitHub Gist: instantly share code, notes, and snippets.

gist.github.com

 

 

 

 

 

 

 

2. JS스레드를 거치지 않고 UI 스레드를 사용하는  React Native reanimated 애니메이션 라이브러리 사용

 

React Native는 Native Bridge와 같은 아키텍처를 통해 앱의 기능들을 구현하다 보니 애니메이션 또한 서로 Native Bridge 통해 소통하는 것이 과도해지면 병목이 생기고 뚝뚝 끊기는 사용성 저하 현상이 발생할 가능성이 높아졌습니다.

 

아래 이러한 성능 저하, 사용성 저하에 대하여 React Native에서의 애니메이션 성능의 Deadline이라는 의미의 현상으로 대해 자세히 기록해 보았습니다.

Deadline

아이폰의 경우 1초에 60 프레임을 표시할 수 있습니다. 이 말은 1 프레임을 표시하기 위해 최대 1/60초(16.67ms)가 필요하다는 것입니다.

React Native앱은 16.67ms안에 하나의 프레임을 생성해야 사용자가 봤을 때 애니메이션이 끊기지 않고 만약 JS스레드의 한 이벤트 루프동안 16.67ms안에 작업을 처리하지 못하면 Native Bridge에 병목현상이 생기게 되고 프레임이 뚝뚝 끊기게 됩니다.

안드로이드의 경우는 1초에 90 프레임, 120 프레임까지 표시하는 경우도 있으니 각각 11.11ms, 8.33ms안에 하나의 프레임을 생성해야 하니 Deadline이 더 긴박합니다.

 

웹에서 단순하게 CSS로 접근하여 처리할 수 있던 애니메이션들이 React Native에서는 할 수 없다는 특징이 있습니다.

CSS에 기댈 수 없는 React Native 특성상 웹의 requestanimationframe을 이용하여 애니메이션을 만드는 것처럼 함수 호출을 통해서 애니메이션을 컨트롤해야 합니다.

 

기본적으로 React Native 자체에서 제공되는 Animated와 관련된 함수를 사용하게 된다면, UI 스레드JS 스레드를 동시에 활용되어 적용됩니다.

 

하지만 위에서 얘기 했듯이 스레드끼리 통신을 위해서 결국은 Native Bridge와 같은 아키텍처 과정을 통해야 하는데 결국 JS에서 처리돼야 할 콘텍스트와 UI에서 처리될 콘텍스트끼리의 통신 사이가 시간이 걸리기 때문에 성능상 이점이 없으면 성능이 떨어질 가능성이 높아집니다.

 

예를 들자면 React Native에서 기본적으로 제공되는 gesture와 Animated API를 사용하면 애니메이션의 계산을 UI 스레드와 JS 스레드의 브리지를 통한 소통에 의존해야 합니다. 그리고 두 스레드 간의 통신은 비동기로 이루어지기 때문에 response가 16 밀리초 이내에 오는 것을 보장할 수 없게 됩니다.

 

그래서 이 단점을 극복하기 위해 UI 스레드에서 화면에 그려내기 위한 작업을 모두 처리하도록 개발된  Reanimated가 존재합니다.

 

GitHub - software-mansion/react-native-reanimated: React Native's Animated library reimplemented

React Native's Animated library reimplemented. Contribute to software-mansion/react-native-reanimated development by creating an account on GitHub.

github.com

 

 

Reanimated는 모든 애니메이션 관련 로직을 UI Thread에서 실행함으로써 JS Thread가 무거운 작업으로 인해 병목현상이 발생하더라도 프레임 드랍없이 애니메이션을 실행할 수 있도록 해줍니다.

 

간단한 작업의 애니메이션은 괜찮을지 모르겠지만 조금만 복잡해진다면 Reanimated 라이브러리의 도움을 받는 것은 피할 수 없다고 생각합니다. 더 긴박한 데드 라인을 가지고 있는 Android 계열 디바이스에 대응해야 한다고 생각하면 더 필요성을 느끼게  됩니다.

 

 

 

 

 

마무리하며...


사실 이론적인 부분만 쭉 살펴보고 기록하였고 그 외 기술적인 최적화를 위한 이론들은 따로 더 알아봐야 하는 필요성이 있습니다.

 

그래도 위와 같은 이론적인 면에 대한 철저한 이해와 숙지를 통해 React Native의 근본적인 원리와 그리고 장단점을 알게 되면 앞으로 개발을 하거나 최적화, 고도화를 하기 위한 기술적인 적용을 진행할 때 많은 도움이 되지 않을까 생각합니다.

 

감사합니다.

 

 

반응형

댓글