
new architecture 이전에는 단순히 통신을 통해서 뷰를 그리는 native 의 wrapper 느낌이 강했다.
구조화된 데이터를 네이티브로 전송하여 그대로 그려주는, 물론 더 복잡하기야 하겠지만 bridge 라는 개념과 함께 있는 상태에서는 설명하기나 이해하기가 쉬웠다.
하지만 New architecture 로 오면서 그 대부분이 구조화되고 조금 어려워 졌지만 정돈이 되었다.
Fabric 은 New architecture 의 새로운 렌더러이다. 렌더러는 화면을 그리기 위한 요소이다.
Fabric 자체가 무언가를 그릴 수 있는것은 아니고, 렌더링을 위해 잘 설계된 하나의 파이프 라인이라고 보면 된다.
React 가 DOM 의 렌더링을 자체적인 로직으로 최적화를 하듯이, Fabric 또한 레이아웃을 그리기 위해 내부적으로 트리를 가지고 최적화 하고 레이아웃을 계산하고 실제로 렌더링을 할 수 있도록 돕는다. 또한 낭비될 수 있는 불필요한 중첩된 뷰 구조를 평면화 하는 최적화 작업들도 이루어진다.
Fabric 이전에는 이러한 작업들을 호스트 플랫폼(Android, iOS, MacOS, Windows) 별로 일일이 구현하고 알아서 레이아웃 엔진을 통합해야 했다면, 이제는 Fabric 을 사용하여 공통 로직으로 다양한 호스트 플랫폼에서 최적화된 그리기를 수행할 수 있다.
(하지만 실제 UI 를 그리는 부분은 호스트 플랫폼이 담당하기 때문에, 실제 뷰를 그리는 마운트 페이즈를 위한 스케쥴링,구현,실행 등을 위한 바인딩은 플랫폼마다 차이가 존재한다. 이 부분은 각 플랫폼의 MountingManager 구현부를 보면 된다.)
예를 들면 우리가 react 라는 라이브러리로 react-dom, react-native 과 같은 다양한 환경의 renderer 를 통해서, 동일한 react 코드로 앱을 작성할 수 있는것과 같다. (렌더러 참고: https://github.com/chentsulin/awesome-react-renderer)
실제 렌더링, 업데이트 등을 위한 트리 비교 및 조정에 관련된 로직들은 모두 react 내부에서 최적화 되어 처리가 되는것처럼 말이다.
실제 호스트 뷰를 렌더링을 하는것보다 살짝 높은 수준의 계층에 위치한다고 생각하면 되고, Fabric 이라는 동일한 시스템을 통해서 최적화된 그리기 명령이 이루어지고, 실제 그려지는것은 호스트 플랫폼(android, ios, macos, windows)의 뷰를 통해서 그려지게 된다.
React(JS) 는 React Element Tree 를 생성하여, JavaScript 코드 레벨에서 발생하는 논리적인 UI 업데이트를 최적화 한다.
Fabric 은 React 가 생성한 React Element Tree 를 기반으로 C++ 위에서 React Shadow Tree 를 생성하고 그리는 과정을 최적화 한다. 레이아웃을 계산하고, 뷰를 평면화 하고, 실제로 호스트 뷰가 어떻게 삭제/추가/업데이트 되어야 하는지에 대한 원자적 정보들을 통해 호스트 뷰의 마운트까지 원활하게 최적화되어 완료되도록 책임진다.
대부분의 UI 업데이트는 React 에서의 상태 변화로부터 이루어지기 때문에, 데이터는 React -> React-Native -> Host platform 와 같이 흐르는것이 일반적이다.
하지만 이와 반대로 React 에서는 처리하지 않음에도 불구하고 Host platform 의 UI 변화로까지 이어지는 경우들이 존재하는데
하나의 예로는 ScrollView 에서 현재 보고있는 영역의 위치인 offset 값이 그 중 하나이다. (스크롤을 하는 행위 자체는 UI 를 변화시키지만 React 의 상태 변화와는 무관하다.)
이러한 값들은 Host platform 위에서만 존재하기 때문에 Fabric 에서는 C++ 상태값으로 관리가 되고, React 의 렌더링 프로세스와는 전혀 연관이 없다. 따라서 C++ 상태의 변화는 React render(React Element Tree 업데이트) 이후의 스텝인 React Shadow Tree 의 업데이트에만 관여를 하고, 이 변화는 다시 레이아웃 계산과 호스트 뷰의 업데이트로 이어진다.
C++ 상태의 변화는 자바스크립트 스레드(React-Native 코드상에서 scrollToOffset 메소드로 스크롤 호출) 혹은 UI 스레드(사용자가 화면을 스크롤하여 UI 가 업데이트) 양방향에서 이루어질 수 있으므로, 변화가 발생한 Shadow Node 의 C++ 상태 변화는 한번에 하나만 반영이 되도록 설계가 되어있다.
만약 업데이트 도중 다른 코드블록에 의해서 값이 변경이 되었다면, 해당 시도는 실패하게 되고 성공할때까지 재시도를 진행하게 된다.
이 로직을 통해서 양방향에서의 업데이트를 thread safety 하게 처리할 수 있다.
그렇다면 React 에서의 변화는 어떻게 Native 까지 전달 되는 것일까? 이것은 React-Native 의 UIManager 로 전달되고 호스트 플랫폼까지 전달되어 처리된다.
업데이트에 대한 정보들은 모두 UIManager 를 통해서 전달되고, 이를 통해서 실제 업데이트까지 이루어진다.
React fiber -> React-Native fabric -> Host UI
그러면 반대로 Native UI 의 변화를 JS 에서 감지하는것은 어떻게 가능할까? 이것은 onLayout 과 같은 이벤트 리스너를 통해서 가능했지만, 브릿지를 사용할때는 비동기적으로 동작하기 때문에, useLayoutEffect 를 사용해서 정확한 업데이트 시점에 알아내기에 어려움이 존재했다.
Fabric 에서는 브릿지를 거치지 않고 자바스크립트의 변경을 C++ 를 거쳐 네이티브까지 동기적으로 전달할 수 있다. 양방향에서 동기적으로 로직을 실행하는것이 가능해졌는데, 이것을 가능하게 만드는것이 바로 JSI 이다.
브라우저 콘솔에서 Array.prototype.at 과 같은 메소드를 쳐보면 { [native code] } 라고 나오는것을 볼 수 있을것이다. JSI 는 바로 이 개념이다.
JavaScript 와 네이티브가 JSI(C++) 를 통해서 양방향으로 직접 소통이 가능해진다. 그것도 동기적으로.
그러면 이러한 변화가 어떠한 도움을 주게 되었을까?
기존의 아키텍쳐에서는 JavaScript 에서 생긴 변화를 UIManager 로 전송하게 되면, 브릿지를 통해서 처리가 된 이후에 ViewManager 를 통해서 특정 Shadow Node 를 생성하거나 업데이트를 직접적으로 발생시키고 실제 UI 의 반영까지 이루어졌다.
새로운 아키텍쳐에서는 JavaScript 에서 생긴 변화를 UIManager 로 전송하게 되면, JSI 를 통해서 동기적으로 1:1 매칭되는 Shadow Node 를 새롭게 생성하거나 재활용을 해서 복제본을 만든다. 실제로 그리는 단계는 react-reconciler 에 의해서 모든 작업들이 잘 스케쥴링 되어 commit 한 이후에 실제 UI 의 반영까지 이루어진다.
(이 과정에서 트리를 새롭게 생성하는데 발생하는 오버헤드를 줄이기 위해서, 변화하지 않은 노드들은 새롭게 생성하지 않고 재사용한다.)
이를 지원하기 위해서 react-conciler 에 새로운 모드가 추가됐다.
https://github.com/facebook/react/tree/v18.3.1/packages/react-reconciler#modes기존 아키텍쳐는 DOM 과 같이 ViewManager 에서 create/update/remove 처리를 바로 했기 때문에
mutation모드라는 이름으로 불리었고, 새로운 아키텍쳐는 React Element Tree 와 매칭되는 React Shadow Tree 의 불변성을 위해서persistent모드가 추가되었다고 한다.
react-reconciler 에서 persistent 모드일 때, 작업 단위의 완료 시점에 finalizeContainerChildren 를 호출하고 이는 UIManager 를 통해서 Shadow Tree 의 commit 호출로 이어진다.
이후에는 Shadow Tree 의 revision 을 생성하고 레이아웃을 계산하고, 실제 그리는데 필요한 기타 필요한 작업들을 진행한다.
이 작업들이 모두 성공적으로 완료가 되면, Scheduler 를 통해서 mount 를 스케쥴링 하고 current revision 을 업데이트한다. 이때 스케쥴링 과정에서는 mount 를 동기적으로 실행할지, 비동기적으로 실행할지 결정할 수 있다.
mount 는 MountCoordinator 에 의해서 실행되며, 이 단계에서 ShadowViewMutation 이라는 정보로 Shadow Node 를 어떻게 원자적으로 변경시킬것인지에 대한 정보들을 추출하고 MountingTransaction 로 저장한다.
이 모든 과정은 Fabric 에서 진행되고, 실제 업데이트 정보들이 담긴 transaction 을 가지고 UI 에 반영하는 작업은 각 호스트 플랫폼에서 구현한 MountingManager 를 통해서 처리한다.

단순히 글로만 보더라도 Fabric 에서는 최종적으로 UI 에 반영되기까지 굉장히 많은 스텝으로 세분화 됐고, 업데이트 된 노드를 반영할것인지 안할것인지, 반영한다면 이를 스케쥴링 하는 과정에서 비동기적으로 처리를 할것인지, 동기적으로 처리를 할것인지 등... 굉장히 많은 선택지가 주어져서, UI 가 그려지기 전에 개입을 할 수 있는 여지가 많아졌다.
이 맥락에 따라서 위에서 예시로 들었던 useLayoutEffect 에서도 Native UI 의 상태를 정확한 시점에 파악할 수 있게 되었다는 이야기이고
React 18 의 동시성이나 자동 배치같은 기능들도 JavaScript(React) 에서 발생된 업데이트를 작은 단위로 쪼개어 자유롭게 스케쥴링하고 제어할 수 있는가가 관건이었기 때문에, 이러한 새로운 기능들 또한 직관적이게 통합을 할 수 있게 되었다고 한다.
한가지 재미있는 사실은, 짐작컨데 measure 와 같이 뷰의 정보를 알아내는 API 들이 모두 콜백을 통해 값을 받도록 만들어진 이유 또한, 이전 아키텍쳐를 사용하면 비동기적으로 처리를 해야했기 때문일터이다.
Fabric 에서는 동기적으로 처리가 가능하기 때문에 콜백을 통하지 않아도 된다. 하지만 기존 API 스펙이 변경되면 breaking changes 가 되니 브라우저와 동일한 API 인 getBoudingClientRect 를 새롭게 추가를 한 것 같다.
처음에는 UIManager 에 추가 되었다가, 현재는 React DOM for Native 의 일환중 하나로 분류가 되어서 Native DOM API 로 따로 빠져있는 상태이다.
std::tuple<
/* x: */ double,
/* y: */ double,
/* width: */ double,
/* height: */ double>
NativeDOM::getBoundingClientRect(
jsi::Runtime& rt,
jsi::Value shadowNodeValue,
bool includeTransform) {
auto shadowNode = shadowNodeFromValue(rt, shadowNodeValue);
auto currentRevision =
getCurrentShadowTreeRevision(rt, shadowNode->getSurfaceId());
if (currentRevision == nullptr) {
return {0, 0, 0, 0};
}
auto domRect = dom::getBoundingClientRect(
currentRevision, *shadowNode, includeTransform);
return std::tuple{domRect.x, domRect.y, domRect.width, domRect.height};
}
위의 코드를 보면 current revision tree 에서 shadow node 를 가져와서 계산을 수행한다.
앞서 설명했듯이 Fabric 에서는 실제로 그리기 이전에 트리를 commit 하는 시점에 모든 레이아웃과 그리는데 필요한 정보를 계산한 뒤, 이를 revision 으로 만들기 때문에, 아래와 같은 로직이 가능해진다.
useLayoutEffect(() => {
const { width, height, x, y } = viewRef.current.getBoundingRect();
}, [])
DOMRect getBoundingClientRect(
const RootShadowNode::Shared& currentRevision,
const ShadowNode& shadowNode,
bool includeTransform) {
auto shadowNodeInCurrentRevision =
getShadowNodeInRevision(currentRevision, shadowNode);
if (shadowNodeInCurrentRevision == nullptr) {
return DOMRect{};
}
auto layoutMetrics = getRelativeLayoutMetrics(
*currentRevision,
shadowNode,
{.includeTransform = includeTransform, .includeViewportOffset = true});
if (layoutMetrics == EmptyLayoutMetrics) {
return DOMRect{};
}
auto frame = layoutMetrics.frame;
return DOMRect{
.x = frame.origin.x,
.y = frame.origin.y,
.width = frame.size.width,
.height = frame.size.height};
}
React DOM for Native 작업 중 하나인 react-strict-dom 그리고 이제 MutationObserver 와 IntersectionObserver 같은 DOM API 도 React-Native 에서 사용할 수 있다.
이제 새로운 아키텍쳐인 Fabric 이 대충 어떤식으로 구성이 되어있고, 어떻게 업데이트를 처리하여 호스트 뷰를 렌더링 하는지, 어떤 부분들이 개선되어서 어떤것들이 가능해진것인지 대략적으로 머릿속에 들어왔을것이라고 생각이 된다.
그렇다면 Fabric 은 C++ 로 작성된 멀티플랫폼 호스트 뷰를 지원하기 위해서 만들어진 것인데, 만약 호스트 뷰(Native UI)를 보여주기 위한 라이브러리를 만들어야 한다면 Fabric 을 이용해서 만들어야 하는걸까? 정답은 Yes 이다.
그렇다면 integration 을 위해서 Fabric 과 같은 C++ 코드들의 사용 방법을 알아야 하는것일까? 정답은 No 이다.
Fabric 을 사용해야 하지만 사용 방법을 몰라도 된다? 뭔 소린가 싶겠지만 바로 이것을 위해서 나온것이 codegen 이다.
라이브러리 관리자 입장에서는 시스템에 기능을 추가하는것이 아닌, 시스템 위에서 돌아갈 수 있는 모듈 하나를 만드는것이기 때문에 내가 만들 라이브러리의 로직만 잘 만들면 된다.
Codegen 입장에서는 라이브러리 내부 구현은 관심없고, 단순히 시스템 위에서 잘 돌아가도록 연결을 해주는것에 있다. 때문에 Codegen 을 사용하기 위해는 JS 와 Native 를 연결해주는 인터페이스, 연결을 위해 필요한 정보들만 잘 명시해주면 된다.
Turbo module 이라면 메소드들이 인터페이스의 예가 될 수 있고, Component module 이라면 props 가 인터페이스의 예가 될 수 있다.
import type {TurboModule} from 'react-native';
import {TurboModuleRegistry} from 'react-native';
export interface Spec extends TurboModule {
setItem(value: string, key: string): void;
getItem(key: string): string | null;
removeItem(key: string): void;
clear(): void;
}
export default TurboModuleRegistry.getEnforcing<Spec>(
'NativeLocalStorage',
);
Codegen 은 이렇게 JS 쪽에서 정의된 타입을 통해 네이티브 모듈에도 타입을 생성한다.
#import <Foundation/Foundation.h>
#import <NativeLocalStorageSpec/NativeLocalStorageSpec.h> // --> Spec
NS_ASSUME_NONNULL_BEGIN
@interface RCTNativeLocalStorage : NSObject
@interface RCTNativeLocalStorage : NSObject <NativeLocalStorageSpec>
@end
이제 라이브러리 개발자는 생성된 인터페이스(NativeLocalStorageSpec) 를 통해서 비즈니스 로직을 작성할 수 있다.
만들어진 라이브러리들은 빌드 시점에 Codegen 에 의해서 정의된 정보들을 바탕으로
복잡한 React-Native 의 C++ 레이어에 연결하는 코드들을 추출하고, 앱은 이 코드들을 통해 라이브러리들을 연결 및 빌드하고 실행이 된다.
[Library implementation] <-> [Spec interface] <-> [glue-code generated by codegen] <-> [JSI]
이렇게 Codegen 의 역할까지 알아보았다.
단순히 아키텍쳐를 갈아엎은것 뿐만이 아니라, 진정한 cross-platform 으로 가기 위한 기반을 만든것이라고 생각이 들었다.
Node.js 나 Chromium 도 핵심 로직을 C++ 레이어에 때려박아서 multi platform, cross platform 로 만든 것 처럼 실제로 그 구현이나 동작들도 어느정도 유사해진 것 같고, Well-defined-event-loop 와 같은 RFC 를 보면 그 방향성 또한 얼추 맞는게 아닐까 싶다. (물론 특정 호스트 플랫폼 위에서 돌아가게 만드려면, 바인딩을 해야하는 작업은 꽤나 많아 보인다.)
마이크로소프트를 비롯한 많은 파트너들이 있기는 하지만, 시장의 참여자(회사)를 추가로 끌어들일 수 있는 기반이 되지 않을까 한다.
8년간 React-Native 를 사용하면서 아주 많이 개선이 되었지만 여전히 크다고 생각이 드는 Pain point 는
여전히 version upgrade 를 비롯하여 native 에서 생기는 복잡한 변경사항들을 어떻게 처리하냐는 부분이었는데
New architecture 가 처음 public repo 에 올라온 이후, 많은 부분들이 모듈화 되어서 최대한 영향도를 줄이고 성능을 개선한것으로 보인다.
이와 동시에 frameworks 라는 명목 하에, Expo 를 공식적으로 밀기 시작하면서 엔드 유저 측면에서 대폭 개선된 DX 를 누릴 수 있게 하겠단 전략으로 보인다.
라이브러리를 만들면서 가장 어려운 문제가, 어느정도 수준까지 인터페이스를 추상화 하고 퍼블릭으로 오픈을 할 것이냐 인데
사용하기 쉽게 만들면 사용자들을 이해시키고 가이드를 하는 것은 쉬워진다.
하지만 반대로 사용자가 할 수 있는 일에는 제약이 생기고 자유도는 굉장히 낮아진다.
자유도를 높이는것에 초점을 맞추면, 사용자가 할 수 있는 일에는 제약이 없어진다.
하지만 사용자들이 사용하는것은 다소 복잡하고 어려워지고, 이를 가이드 하는 것 또한 어려워진다.
이 중간에서 타협하는것이 가장 어려운데, 개인적인 관점에서 현재의 Expo 는 이 중간 지점에 아주 잘 머물러있는 상태라고 생각된다.
- eject 가 사라지면서 네이티브 모듈 설치 측면에서 자유도를 얻었고, 잘 만들어진 라이브러리들을 통해서 사용이나 가이드를 하기 또한 쉽다.
- 또한 모든 코어 부분은 래핑이 되어있고 라이브러리들 또한 이 코어 부분들과 함께 디펜던시가 걸려있어 업데이트시에 따로 처리할 부분도 없다.
- 그리고 동시에 더 큰 자유도를 원한다면 언제든지 Expo 뒤에 있는 React-Native 의 코어를 거칠 수 있다.
이를 바탕으로 개발자들을 끌어들여 커뮤니티를 더 확대 시킬 수 있을거라 생각한다.
규모의 코드 베이스와 생태계에서 New architecture 전환의 완성도를 보면서 감탄했다.
이를 가능하게 만드는건 개발자 각각의 뛰어난 퍼포먼스, 전략, 커뮤니티의 지원도 있었겠지만, 아무래도 메타의 거대한 앱 생태계(facebook/instgram 를 기반으로 한 많은 내부용 앱, 예를들면 광고 관리자나 페이지 관리자) 와 그 많은 사용자들을 React-Native 의 테스트베드로 사용하고 있다는게 가장 인상적이다.
모든 변경사항들은 그들 내부에서 빌드되어 배포 되고, 테스트 되고, 개선 되고, 어느정도 안정화가 되면 오픈 소스로 릴리즈 할 준비를 한다.
사용하는 입장에서는 안정성이 확보되고, 거대한 커뮤니티가 있는 기술을 마다할 이유가 없다.
React-Native 의 엄청난 강점은 생산성에 있다. 그 생산성은 개인적으로 직관적인 스타일/레이아웃 시스템과 JSX 문법에서 오고, 이러한 직관적인 코드 작성법은 플랫한 UI 를 그리는데 최적화가 되어있다고 생각을 한다.
모바일, 데스크톱을 넘어 이제는 Native DOM 으로 브라우저로 가고 있다(...)
우리가 평면적인 UI 에서 벗어나는 그 시점이 오기 전까지는, React-Native 는 망하지 않을 것 같다.
좋은 글 감사합니다~