
"React Native는 JS와 네이티브가 브릿지로 JSON을 주고받는다." RN을 처음 공부할 때 가장 많이 보는 설명이지만, 2026년 기준으로는 틀린 설명이다. React Native 0.82부터는 New Architecture만 남았고, 끄는 옵션을 넣어도 무시된다. 지금의 RN에는 브릿지가 없다.
헷갈리는 이유가 있다. 공식 문서 안에도 브릿지 시절 설명이 군데군데 남아 있기 때문이다. 그래서 이 글은 공식 Architecture 문서와 릴리스 블로그를 근거로, 지금의 RN이 실제로 어떻게 화면을 그리는지 정리한다.
예전 RN에서 JS와 네이티브는 비동기 브릿지로만 대화했다. 컴포넌트를 그리거나 네이티브 함수를 부르려면, 호출을 JSON으로 직렬화해 큐에 넣고 반대편이 꺼내서 처리하는 식이었다.
// 브릿지 시절: 결과는 콜백으로만, 값은 직렬화 가능한 것만
nativeModule.getValue(value => {
nativeModule.doSomething(value);
});
모든 일이 비동기라 메인 스레드가 막히지 않는다는 장점은 있었다. 하지만 공식 블로그는 이 구조의 한계를 세 가지로 정리한다.
onLayout으로 한 프레임 늦게 고쳐야 했다.결국 사용자 입력에 즉시 반응하고, 급한 업데이트가 덜 급한 렌더링을 끊고 들어가는 React의 동시성 기능을 제대로 지원할 수 없었다. 이걸 해결하려고 2018년부터 시작한 재작성이 New Architecture다.
공식 블로그는 New Architecture를 새 네이티브 모듈 시스템, 새 렌더러, 이벤트 루프, 브릿지 제거의 네 축으로 설명한다. 이걸 실제로 마주치는 이름 기준으로 풀면 다음과 같다.
JSI(JavaScript Interface)는 JS가 C++ 객체의 참조를 직접 들고, 반대로 C++도 JS 값을 들 수 있게 하는 인터페이스다. 메모리 참조로 메서드를 바로 호출하니 직렬화 비용이 없다.
공식 문서는 카메라 라이브러리 VisionCamera를 예로 든다. 프레임 버퍼 하나가 약 30MB, 초당 약 2GB의 데이터인데, 브릿지로는 불가능했던 양을 JSI로는 실시간 처리한다.
네이티브 모듈이 JSI 위로 옮겨오면서 동기 호출이 가능해졌고, 반환값으로 네이티브 객체 참조도 넘길 수 있다.
// New Architecture: 동기 호출, 네이티브 객체 참조도 반환 가능
const value = nativeModule.getValue();
nativeModule.doSomething(value);
모듈은 처음 쓰일 때 로딩되고(lazy loading), 한 번 만들어지면 싱글턴으로 유지된다. 앱이 커져도 시작 시간이 늘지 않는 이유다.
모듈의 인터페이스는 TypeScript나 Flow 스펙으로 정의한다. Codegen이 빌드 시점에 이 스펙을 읽어 C++, Java, Objective-C++ 연결 코드를 생성한다. 스펙 파일 이름이 모듈은 Native*, 컴포넌트는 *NativeComponent여야 Codegen이 인식한다.
// specs/NativeLocalStorage.ts
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;
}
export default TurboModuleRegistry.getEnforcing<Spec>('NativeLocalStorage');
공식 블로그는 JS와 네이티브 경계에서 생기는 타입 오류를 크로스플랫폼 앱 크래시의 가장 흔한 원인 중 하나로 꼽는데, Codegen이 이걸 빌드 에러로 바꿔준다.
Fabric은 렌더링 로직을 C++ 하나로 통합한 새 렌더러다. 핵심은 화면 구조를 바꿀 수 없는(immutable) 트리로 다룬다는 점이다. 트리를 직접 고치지 않고 새 버전을 만들기 때문에, 여러 버전의 트리를 여러 스레드에서 안전하게 다룰 수 있다.
덕분에 급한 업데이트(사용자 입력)는 메인 스레드에서 바로, 덜 급한 업데이트(화면 전환)는 백그라운드에서 처리할 수 있다. 레이아웃도 어느 스레드에서든 동기로 읽을 수 있다. C++ 코어를 플랫폼끼리 공유하니 Android 전용이던 최적화(View Flattening)가 iOS에도 그대로 적용됐다.
JS 스레드의 작업 순서를 HTML 표준의 이벤트 루프 모델에 맞춰 정의했다. React가 렌더링 도중 끼어든 사용자 이벤트를 먼저 처리할 수 있게 된 기반이고, 마이크로태스크나 IntersectionObserver 같은 웹 API를 들여올 토대이기도 하다.
브릿지를 없애면서 전역 함수도 C++에서 바로 바인딩한다. 브릿지 초기화 과정이 사라져 앱 시작이 빨라졌다.
// JS 쪽 초기화 없이 C++에서 바로 setTimeout을 등록
runtime.global().setProperty(runtime, "setTimeout", createTimer);
아래 컴포넌트가 화면에 나오기까지를 따라가 보자.
function MyComponent() {
return (
<View>
<Text>Hello, World</Text>
</View>
);
}
공식 문서는 이 과정을 렌더 파이프라인이라 부르고, 세 단계로 나눈다.
<View>는 ViewShadowNode, <Text>는 TextShadowNode가 된다.<View>, <Text> 같은 호스트 컴포넌트에만 생긴다. MyComponent 자체에 대응하는 노드는 없다.createView, updateView, removeView 같은 작업 목록을 만든다.ViewGroup과 TextView, iOS라면 UIView가 만들어진다.Shadow Tree는 바꿀 수 없으니, 상태가 바뀌면 새 트리를 만든다. 그렇다고 전체를 복사하지는 않는다. 바뀐 노드에서 루트까지의 경로만 복제하고, 나머지 노드는 이전 트리와 공유한다.
예를 들어 형제 View 둘 중 첫 번째의 배경색만 빨강에서 노랑으로 바뀌면, 그 View와 부모, 루트만 복제된다. 두 번째 View는 이전 트리와 새 트리가 같이 쓴다. 최종적으로 Mount 단계에서 나오는 작업은 UpdateView(첫 번째 View, {backgroundColor: 'yellow'}) 하나뿐이다.
React로 컴포넌트를 쪼개다 보면, margin만 주고 아무것도 그리지 않는 "레이아웃 전용" View가 많이 생긴다. 이런 View를 그대로 네이티브 뷰로 만들면 성능이 떨어진다.
Fabric은 Mount 단계의 트리 비교 과정에서 이런 View를 부모에 합쳐 없앤다. 별도 단계가 아니라 diff 안에 들어 있어서 추가 비용도 없다. 공식 문서 기준으로 실제 앱의 Shadow Tree는 600~1000개 노드인데, View Flattening 후 약 200개로 줄어든다.
Fabric 렌더러가 쓰는 스레드는 둘이다.
가장 흔한 경우는 렌더 파이프라인 대부분이 JS 스레드에서 돌고, Mount만 UI 스레드에서 일어나는 것이다. 그런데 공식 문서의 스레드 모델에는 이것 말고도 몇 가지 경우가 더 있다.
ScrollView의 스크롤 위치처럼 React가 아닌 네이티브 쪽이 주인인 상태는 Render 단계를 건너뛰고 바로 Commit부터 진행한다.이게 가능한 이유는 앞에서 본 불변 트리 덕분이다. 트리를 고치지 않고 새로 만들기 때문에, 여러 스레드가 서로 다른 버전의 트리를 동시에 다뤄도 안전하다. 그리고 이 구조 위에서 startTransition과 useLayoutEffect가 제대로 동작한다.
// 레이아웃을 동기로 읽고, 같은 커밋 안에서 위치를 반영
useLayoutEffect(() => {
const rect = ref.current?.getBoundingClientRect();
setPosition(rect);
}, []);
0.82부터는 ref가 DOM과 비슷한 노드를 돌려줘서 getBoundingClientRect(), parentNode 같은 웹 API를 그대로 쓸 수 있다. 브릿지 시절처럼 onLayout을 기다렸다가 한 프레임 늦게 툴팁을 옮길 필요가 없다.
JS 코드를 실제로 실행하는 엔진은 Hermes다. RN에 최적화된 오픈소스 엔진으로, 0.70부터 기본값이다.
Hermes의 핵심은 빌드 시점의 바이트코드 컴파일이다. 릴리스 빌드에서 JS를 미리 바이트코드로 바꿔두기 때문에, 앱 실행 시 JS를 파싱할 필요가 없고 필요한 부분만 메모리에 올린다. 공식 문서는 JavaScriptCore 대비 시작 시간 단축, 메모리 사용량 감소, 앱 크기 감소를 장점으로 꼽는다.
0.69부터는 RN 버전마다 함께 빌드한 Hermes를 동봉한다(Bundled Hermes). Hermes와 RN이 같은 JSI 코드를 공유해서, 버전이 어긋나면 호환이 깨지기 때문이다.
그리고 0.84부터는 컴파일러와 VM을 개선한 Hermes V1이 기본 엔진이다. 실제 대형 앱인 Expensify에서 측정한 결과는 다음과 같다.
| 지표 | Android (저사양 기기) | iOS |
|---|---|---|
| 번들 로딩 시간 | 3.2% 단축 | 9% 단축 |
| 전체 TTI | 7.6% 단축 | 2.5% 단축 |
| 콘텐츠 TTI | 7.2% 단축 | 7.5% 단축 |
지금 쓰는 엔진이 Hermes인지는 이렇게 확인할 수 있다.
const isHermes = () => !!global.HermesInternal;
Expo는 RN을 대체하는 게 아니라 RN 위에 얹은 프레임워크다. RN 공식 문서도 Expo를 "React Native Framework"의 대표 예로 들고, 새 앱은 프레임워크로 시작하길 권장한다. 그래서 앞에서 본 Fabric, JSI, Hermes, Render → Commit → Mount가 Expo 앱에서도 그대로 돌아간다.
Expo SDK는 버전마다 특정 RN 버전에 묶여 있다. RN 기준의 변화를 Expo SDK 기준으로 읽으면 이렇다.
| Expo SDK | React Native | 의미 |
|---|---|---|
| 54 | 0.81 | Legacy Architecture를 켤 수 있는 마지막 버전 |
| 55 | 0.83 | New Architecture만 존재 |
| 56 | 0.85 | Hermes V1 기본, 새 애니메이션 백엔드 |
| 57 | 0.86 | 현재 최신 안정판 |
| 58 | 0.88 | 베타 중 |
체감되는 차이는 이 정도다.
inlineRequires 기본값: RN CLI 프로젝트는 require 호출을 자동으로 늦게 불러오지만, Expo는 기본으로 꺼져 있다. 필요하면 Metro 설정에서 켤 수 있다.정리하면 지금의 React Native는 이렇게 동작한다.
이 구조를 알고 나면 성능 문제를 볼 때도 "JS 스레드가 바쁜가, UI 스레드가 바쁜가", "불필요한 레이아웃 전용 View가 많은가"처럼 어디부터 봐야 할지가 보인다.