React Native앱은 크게 두가지 부분으로 구성되어 있다. Native부분과 JavaScript부분이 그것이다. Native부분은 iOS에서는 Objective-C/Swift, Android에서는 Java/Kotlin이 담당하는 곳으로 UI를 렌더한다. UI스레드(메인 스레드)라 불리는 것이 UI를 생성한다. 반면 JavaScript 부분에는 JS스레드가 있다. 이곳은 말그대로 자바스크립트 엔진을 통해 자바스크립트 코드가 실행되는 곳이다. 비지니스 로직을 포함하여 뷰를 언제, 어떻게 표시할지와 같은 React관련 자바스크립트 코드가 실행되는 스레드이다.
Native부분인 Main thread와 JavaScript부분인 JS thread는 Native Bridge라는 것을 통해 소통한다. 이것은 클라이언트 - 서버가 통신하는것과 비슷하다. 클라이언트가 메인 스레드, 서버가 JS스레드인 것이다. 아무튼, 각 부분에서 전달되는 정보는 JSON object형태로 변환되어서 전달된다.
결국은 React Native앱에서 가장 중요하다고 볼 수 있는 부분이 Native Bridge이다. 이 부분이 병목현상이 가장 많이 생기며 좋은 성능의 React Native앱을 위해서는 Native Bridge를 건너는 횟수를 최소한으로 해야한다.
아이폰의 경우 1초에 60프레임을 표시할 수 있다. 이 말은 1프레임을 표시하기 위해 최대 1/60초(16.67ms)가 필요하다. React Native앱은 16.67ms안에 하나의 프레임을 생성해야 사용자가 봤을 때 애니메이션이 끊기지 않는 것이다. 만약 JS스레드의 한 이벤트 루프동안 16.67ms안에 작업을 처리하지 못하면 Native Bridge에 병목현상이 생기게 되고 프레임이 뚝뚝 끊기게 된다. 안드로이드의 경우는 1초에 90프레임, 120프레임까지 표시하는 경우도 있으니 각각 11.11ms, 8.33ms안에 하나의 프레임을 생성해야 하니 deadline이 더 긴박하다.
React Native어플리케이션을 실행하기 위해 webpack과 비슷하게 JavaScript코드들을 하나의 번들 파일로 만든다.(main.bundle.js) 이후 JS스레드가 번들된 자바스크립트 코드를 실행하며 이벤트 루프가 끝날때마다 메인 스레드로 변경사항에 관한 정보를 전달한다. 전달되는 정보는 어떤 뷰를 표시할 건지와 같은 정보이다. ([ [2,3,[2,'Text',{...}]] [2,3,[3,'View',{...}]] ]
) 다행히도 React의 효율적인 Diffing 알고리즘 덕분에 각 배치마다 UI의 최소한의 변경사항 정보만 Native Bridge를 건너게 할 수 있다.
React에서 우리가 선언적으로 정의하는 UI는 실제 DOM노드가 아니라 경량화된 자바스크립트 객체인 virtual DOM이다. React는 이 virtual DOM을 사용하여 최소한의 변경사항을 파악하여 새롭게 렌더하게 된다. 이때 사용하는 알고리즘이 diffing 알고리즘이다.
React에서는 트리의 레벨 별 비교 작업을 수행하며 이 작업의 시간복잡도는 O(n)에 가깝다고 한다. 보통 어플리케이션에서 컴포넌트 트리의 레벨이 변경되는 경우는 많이 없다는 점에 기인한 heuristic한 방법을 사용한다.
또한 React에서 사용자가 정의한 컴포넌트를 사용하는 경우가 많은데, 이 경우에는 diffing알고리즘은 같은 클래스인 경우에만 수행되며 다른 클래스를 갖고 있다면 비교작업을 수행하지 않고 그 컴포넌트를 추가한다. 이로 인해 두 컴포넌트를 비교하기 위한 시간을 소모하지 않는다.
컴포넌트 안에서 setState
를 호출할 때마다 React는 이 컴포넌트를 dirty하다고 표시하고 매 event loop가 끝날때마다 dirty component를 확인하고 다시 렌더링 작업을 진행한다. 이 과정에서 자식 컴포넌트들에 대해 virtual DOM이 다시 만들어지며 비록 상태가 변경되지 않았더라도 render
메소드가 실행된다. 만약 이것이 실제 DOM이었다면 막대한 비용이 들었을 테지만 가상 DOM이기 때문에 비용이 그렇게 크지는 않다. 그리고 실제로 컴포넌트의 상태 변경이 루트 컴포넌트에서 진행되지 않으며 보통 일부 컴포넌트에서만 진행되는 '지역성'을 띄고 있기에 실제 비교횟수가 그렇게 많지도 않다.
만약 하위 컴포넌트들의 리렌더링을 직접 방지하고 싶다면 shouldComponentUpdate()
메서드를 사용하여 직접 React에 리렌더링 여부를 알려줘도 된다. 이로 인해 성능상의 이점을 챙겨갈 수 있다.
메인 스레드는 어플리케이션이 실행되자마자 시작된다. 앱을 로드하고 JS스레드를 실행시킨다. JS스레드의 매 이벤트 루프가 끝날때마다 Native Bridge를 통해 보내오는 메시지들을 받아 해석한 후 UI를 화면에 표시한다. 이 과정에서 shadow thread는 JS스레드로 부터 넘어오는 정보를 활용하여 화면의 layout을 계산한다. 웹에서의 reflow와 비슷하다. 또한 사용자들이 기기에 내리는 UI이벤트 명령들을 받고 Native Bridge를 경유해 JS스레드로 넘겨준다. 이때의 UI이벤트들은 press나 touch와 같은 이벤트이다.
위에서 살펴본 Main thread, JS thread, Native Bridge의 실행을 순차적으로 정리하면 다음과 같다.
Performance Overview · React Native
Performance Limitations of React Native and How to Overcome Them
글로 잘 정리해주셔서 정말 감사합니다.