React Native의 전체 렌더링 흐름 — New Architecture 기준
React Native에서 작성한 JSX 코드가 실제 네이티브 화면에 표시되기까지, 내부적으로는 여러 레이어와 스레드를 거치는 복잡한 파이프라인이 동작합니다. 이 글에서는 Fiber 아키텍처부터 Shadow Tree, 그리고 최종 호스트 뷰까지 이어지는 전체 렌더링 흐름을 단계별로 분석합니다.
목차
- Web React와 React Native의 차이
- 핵심 개념: Fiber와 JSI
- 렌더 파이프라인 개요
- Phase 1 — Render
- Phase 2 — Commit
- Phase 3 — Mount
- 상태 업데이트 시의 렌더링 흐름
- 스레딩 모델
- 뷰 평탄화 최적화
- 정리
1. Web React와 React Native의 차이
Web React는 DOM을 렌더링 대상으로 사용합니다. ReactDOM.render()를 호출하면 재조정(Reconciliation) 결과가 브라우저의 DOM API를 통해 반영됩니다.
React Native는 DOM 대신 각 플랫폼의 네이티브 뷰를 렌더링 대상으로 사용합니다.
| 구분 | Web React | React Native |
|---|---|---|
| 렌더링 대상 | DOM (div, span) | 네이티브 뷰 (android.view.ViewGroup, UIView) |
| 렌더러 | ReactDOM | Fabric Renderer |
| 중간 표현 | Virtual DOM | Shadow Tree (C++) |
| 레이아웃 엔진 | 브라우저 레이아웃 엔진 | Yoga (C++) |
| JS ↔ Native 통신 | 불필요 | JSI (JavaScript Interface) |
핵심은 재조정기(Reconciler)는 공유하고 렌더러(Renderer)는 분리한다는 설계입니다. React 코어가 "무엇이 변경되었는지"를 계산하면, 각 플랫폼의 렌더러가 "어떻게 반영할지"를 담당합니다.
2. 핵심 개념: Fiber와 JSI
Fiber — 작업의 최소 단위
Fiber는 React의 재조정기를 재구현한 아키텍처입니다. 기존 React(Stack Reconciler)는 컴포넌트 트리를 재귀적으로 순회하면서 콜 스택이 빌 때까지 중단할 수 없었습니다. Fiber는 이 문제를 해결하기 위해 콜 스택을 가상으로 재구현합니다.
Fiber의 핵심 목표는 React가 스케줄링을 활용할 수 있게 하는 것입니다:
- 작업을 일시 중지하고 나중에 다시 시작
- 작업 유형별 우선순위 부여
- 이전에 완료된 작업 재사용
- 더 이상 필요 없는 작업 중단
각 Fiber 노드는 컴포넌트, 입력(props), 출력에 대한 정보를 담은 JavaScript 객체이며, 동시에 작업의 단위를 나타냅니다. 단일 Fiber를 가상 스택 프레임이라고 생각하면 됩니다.
Fiber 노드의 주요 필드
Fiber {
type — 컴포넌트 타입 (함수, 클래스, 또는 'View' 같은 문자열)
key — 재조정 시 재사용 판단 기준
child — 첫 번째 자식 Fiber
sibling — 다음 형제 Fiber
return — 부모 Fiber (스택 프레임의 반환 주소)
pendingProps — 실행 시작 시 설정
memoizedProps — 실행 종료 시 설정
alternate — 현재(current) ↔ work-in-progress 쌍
}
자식 Fiber들은 단방향 연결 리스트로 연결됩니다. 첫 번째 자식이 child, 나머지 자식들은 sibling으로 체이닝됩니다.
Parent
│ child
▼
Child1 ──sibling──▶ Child2 ──sibling──▶ Child3
│ │
▼ return ▼ return
Parent Parent
Current 트리와 Work-in-Progress 트리
어느 시점에서든 컴포넌트 인스턴스에는 최대 두 개의 Fiber가 대응합니다:
- Current Fiber — 현재 화면에 반영된 상태
- Work-in-Progress Fiber — 다음 렌더링을 위해 작업 중인 상태
두 Fiber는 alternate 필드로 서로를 참조합니다. 렌더링이 완료되면 Work-in-Progress가 새로운 Current가 됩니다. cloneFiber 함수는 기존 alternate가 있으면 재사용하여 메모리 할당을 최소화합니다.
JSI — Bridge를 대체하는 직접 호출
Old Architecture에서 JS와 Native는 Bridge를 통해 JSON 직렬화 기반의 비동기 메시지를 주고받았습니다. 이 방식은 직렬화 비용, 비동기 제약, 대량 데이터 처리 시 병목을 유발했습니다.
New Architecture의 JSI(JavaScript Interface) 는 이 경계를 근본적으로 제거합니다:
[Old] JS → JSON 직렬화 → Bridge(메시지 큐) → JSON 역직렬화 → Native
[New] JS → C++ 바인딩(JSI) → Native
JSI를 통해 JS에서 C++ 객체의 메서드를 직접 호출할 수 있으며, 동기·비동기 모두 가능합니다. 각 Fiber 노드가 JSI를 통해 대응하는 Shadow Node에 대한 C++ 포인터를 직접 보유하는 것이 이 구조의 핵심입니다.
3. 렌더 파이프라인 개요
React Native의 렌더링은 세 단계로 구성됩니다:
┌──────────────────────────────────────────────────────────────────┐
│ React Native 렌더 파이프라인 │
├────────────────┬────────────────────┬────────────────────────────┤
│ 1. Render │ 2. Commit │ 3. Mount │
│ │ │ │
│ React Element │ Layout 계산(Yoga) │ Tree Diff (C++) │
│ Tree 생성 │ │ Mutation 연산 목록 생성 │
│ │ 트리 승격 │ Host View 생성/업데이트 │
│ Shadow Tree │ (new → next) │ 트리 승격 (next → rendered)│
│ 생성 (C++) │ │ │
├────────────────┼────────────────────┼────────────────────────────┤
│ JS Thread │ Background Thread │ UI Thread │
└────────────────┴────────────────────┴────────────────────────────┘
각 단계를 관통하는 핵심 데이터 구조는 세 가지입니다:
- React Element Tree — JSX로부터 생성되는 JS 객체 트리
- React Shadow Tree — C++에서 생성되는 중간 표현 (레이아웃 정보 포함)
- Host View Tree — 실제 플랫폼의 네이티브 뷰 계층
4. Phase 1 — Render
"UI 구조를 생성하는 단계"
Render 단계에서는 React가 제품 로직(컴포넌트 함수)을 실행하여 React Element Tree를 생성하고, 이를 기반으로 렌더러가 C++에서 React Shadow Tree를 생성합니다.
단계별 흐름
다음 컴포넌트를 렌더링한다고 가정합니다:
const MyComponent = () => {
return (
<View>
<Text>Hello, World</Text>
</View>
);
};1) React Element Tree 생성
JSX는 React.createElement() 호출로 변환되어 JS 객체로 구성된 React Element Tree가 만들어집니다. React는 이 엘리먼트를 더 이상 축소할 수 없을 때까지 재귀적으로 호출하여 호스트 컴포넌트(View, Text 등)로 구성된 트리를 완성합니다.
2) Reconciliation (Fiber 기반 비교)
초기 렌더가 아닌 경우, React는 이전 Fiber Tree와 새로운 React Element를 비교합니다. 이때 적용되는 핵심 규칙은 두 가지입니다:
- 다른 타입의 컴포넌트는 다른 트리를 생성한다고 가정 → 비교하지 않고 교체
- 리스트 비교는
key를 사용 → key는 안정적이고, 예측 가능하며, 고유해야 함
3) Shadow Tree 생성 (C++)
Shadow Tree는 JS가 아니라 C++에서 생성됩니다. 각 React 엘리먼트가 호출될 때 렌더러는 동기적으로 대응하는 React Shadow Node를 생성합니다.
React Element Tree (JS) Shadow Tree (C++)
┌──────────────────┐ ┌───────────────────┐
│ MyComponent │ │ (생성되지 않음) │
│ │ │ │ │
│ View │ ──── JSI ───▶ ViewShadowNode │
│ │ │ │ │ │
│ Text │ ──── JSI ───▶ TextShadowNode │
└──────────────────┘ └───────────────────┘
주의할 점:
- 호스트 컴포넌트(
View,Text)만 Shadow Node가 생성됩니다 - 복합 컴포넌트(
MyComponent)는 Shadow Node가 생성되지 않습니다 - React Element Tree와 Shadow Tree 간의 부모-자식 관계는 동일하게 유지됩니다
- 각 Fiber 노드는 JSI를 통해 대응하는 Shadow Node에 대한 C++ 포인터를 보유합니다
- Shadow Tree는 불변(immutable) 입니다. 업데이트 시에는 복제(clone)를 통해 새 트리를 생성합니다
Shadow Tree가 완성되면 React Element Tree의 커밋을 트리거합니다.
5. Phase 2 — Commit
"계산하고 확정하는 단계"
Commit 단계에서는 두 가지 작업이 이루어집니다: 레이아웃 계산과 트리 승격.
1) 레이아웃 계산
React Native는 Yoga 엔진을 호출하여 각 Shadow Node의 위치와 크기를 계산합니다.
Shadow Node에 포함되는 레이아웃 정보:
├── x: 수평 위치
├── y: 수직 위치
├── width: 너비
└── height: 높이
계산에 필요한 입력:
- 각 Shadow Node의 스타일 정보 (JS에서 전달된 props 기반)
- Shadow Tree 루트의 레이아웃 제약 (화면의 사용 가능한 공간)
레이아웃 계산의 대부분은 C++ 내부에서 실행됩니다. 다만 Text, TextInput 등 일부 컴포넌트는 텍스트 크기와 위치가 플랫폼마다 다르기 때문에 호스트 플랫폼의 함수를 호출하여 계산합니다.
2) 트리 승격 (new tree → next tree)
레이아웃 계산이 완료된 Shadow Tree를 마운트될 "다음 트리(next tree)" 로 승격합니다. 이 승격은 해당 트리가:
- 마운트에 필요한 모든 정보를 갖추고 있으며
- React Element Tree의 최신 상태를 나타냄
을 의미합니다. "다음 트리"는 UI 스레드의 다음 tick에 마운트됩니다.
중요: Commit 단계에서는 네이티브 뷰를 생성하지 않습니다. 오직 "그릴 준비를 완료"하는 단계입니다.
이 작업들은 백그라운드 스레드에서 비동기적으로 실행됩니다.
6. Phase 3 — Mount
"실제 화면에 반영하는 단계"
Mount 단계에서는 레이아웃 정보가 반영된 Shadow Tree를 실제 Host View Tree로 변환하여 화면에 표시합니다. 세 가지 세부 단계로 구성됩니다.
1) Tree Diffing (C++)
"이전에 렌더링된 트리(previously rendered tree)"와 "다음 트리(next tree)" 사이의 차이를 C++에서 계산합니다. 그 결과로 호스트 뷰에 수행할 원자적 변이 연산 목록이 생성됩니다:
createView(node) — 새 뷰 생성
updateView(node, props) — 기존 뷰 props 업데이트
removeView(node) — 뷰를 부모에서 제거
deleteView(node) — 뷰 삭제
이 단계에서 뷰 평탄화(View Flattening) 가 함께 수행되어 불필요한 호스트 뷰 생성을 방지합니다.
초기 렌더 시에는 "이전 트리"가 비어 있으므로, 결과는 createView와 뷰 추가 작업만으로 구성됩니다.
2) 트리 승격 (next tree → rendered tree)
"다음 트리"를 "이전에 렌더링된 트리"로 원자적으로 승격합니다. 이후 마운트 단계에서 올바른 트리를 기준으로 diff를 계산할 수 있게 됩니다.
3) View Mounting
원자적 변이 연산을 대응하는 호스트 뷰에 적용합니다.
Shadow Node Host View (Android) Host View (iOS)
───────────── ──────────────────── ─────────────────
ViewShadowNode → android.view.ViewGroup → UIView
TextShadowNode → android.widget.TextView → UIView + NSLayoutManager
각 호스트 뷰는 Shadow Node의 props와 계산된 레이아웃 정보를 사용하여 구성됩니다.
이 단계는 호스트 플랫폼의 UI 스레드에서 동기적으로 실행됩니다. Commit 단계가 백그라운드 스레드에서 실행된 경우 Mount는 UI 스레드의 다음 tick에 예약됩니다. Commit이 UI 스레드에서 실행된 경우(우선순위가 높은 이벤트) Mount도 동일 스레드에서 동기적으로 실행됩니다.
7. 상태 업데이트 시의 렌더링 흐름
초기 렌더 이후 상태가 변경될 때의 흐름을 살펴봅니다.
const MyComponent = () => {
const [color, setColor] = useState<string>('red');
return (
<View>
<View style={{ backgroundColor: color, height: 20, width: 20 }} />
<View style={{ backgroundColor: 'blue', height: 20, width: 20 }} />
</View>
);
};setColor("yellow")이 호출되면:
Render 단계 — 구조적 공유
스레드 안전성을 보장하기 위해 React Element Tree와 Shadow Tree는 모두 불변입니다. 따라서 기존 트리를 수정하는 대신 새 복사본을 생성합니다.
이때 React Native 렌더러는 구조적 공유(Structural Sharing) 를 활용하여 불변성의 오버헤드를 최소화합니다:
이전 트리 (T) 새 트리 (T')
───────────── ─────────────
Node1 (root) Node1' (root) ← 복제됨
└─ Node2 └─ Node2' ← 복제됨
├─ Node3 (red) ├─ Node3' (yellow) ← 복제됨 (변경)
└─ Node4 (blue) ◄──────────────└─ Node4 (blue) ← 공유됨
변경의 영향을 받는 노드와 그 경로상의 부모만 복제됩니다. 영향을 받지 않는 Node4는 이전 트리와 새 트리가 동일한 참조를 공유합니다.
내부적으로 다음과 같은 연산이 수행됩니다:
CloneNode(Node3, {backgroundColor: 'yellow'})→ Node3'CloneNode(Node2)→ Node2'AppendChild(Node2', Node3')AppendChild(Node2', Node4)— 기존 참조 공유CloneNode(Node1)→ Node1'AppendChild(Node1', Node2')
Commit 단계
레이아웃 계산 시, 구조적 공유로 공유된 Shadow Node의 부모에 레이아웃 변경이 발생하면 해당 노드도 추가로 복제될 수 있습니다. 이후 새 트리가 "다음 트리"로 승격됩니다.
Mount 단계
이전 트리(T)와 새 트리(T')의 diff 결과:
UpdateView(Node3, {backgroundColor: 'yellow'})
Node3에 대응하는 호스트 뷰의 backgroundColor만 업데이트됩니다. 전체 트리를 다시 그리지 않습니다.
C++ 상태 업데이트
일반적인 React 상태 업데이트 외에, C++에서 직접 관리되는 상태도 있습니다. 대표적인 예가 ScrollView의 현재 스크롤 오프셋입니다.
일반 상태 업데이트: Render → Commit → Mount
C++ 상태 업데이트: (Render 건너뜀) → Commit → Mount
C++ 상태 업데이트는 React가 관여하지 않으므로 Render 단계를 건너뛰고, 메인 스레드를 포함해 어떤 스레드에서든 발생할 수 있습니다. 커밋 충돌 시에는 성공할 때까지 재시도하여 경쟁 조건을 방지합니다.
8. 스레딩 모델
React Native 렌더러는 스레드 안전하게 설계되었습니다. 이는 프레임워크 내부에서 불변 데이터 구조를 사용함으로써 보장됩니다(C++의 const correctness로 강제). 렌더러는 두 가지 주요 스레드를 사용합니다:
| 스레드 | 역할 |
|---|---|
| UI 스레드 (Main) | 호스트 뷰를 조작할 수 있는 유일한 스레드 |
| JS 스레드 | React의 Render 단계와 레이아웃 실행 |
시나리오별 스레드 사용
1) 일반적인 렌더 — JS 스레드
가장 흔한 시나리오입니다. 파이프라인 대부분이 JS 스레드에서 실행되고, Mount만 UI 스레드에서 실행됩니다.
JS Thread: ████ Render ████ Commit ████
↓
UI Thread: ████ Mount ████
2) 우선순위 높은 이벤트 — UI 스레드 동기 렌더
UI 스레드에서 우선순위가 높은 이벤트(예: 터치)가 발생하면, 모든 파이프라인을 UI 스레드에서 동기적으로 실행할 수 있습니다.
UI Thread: ████ Render ████ Commit ████ Mount ████
3) 렌더 단계 중단 — 연속 이벤트
낮은 우선순위의 연속 이벤트(예: 스크롤)가 발생하면, 진행 중인 Render 단계를 중단하고 이벤트 상태를 병합한 뒤 JS 스레드에서 재시작합니다.
JS Thread: ████ Render ███ (중단) ████ Render (재시작) ████
↑
UI Thread: ██ Event ██
4) 렌더 단계 중단 — 개별 이벤트
높은 우선순위의 개별 이벤트(예: 버튼 클릭)가 발생하면, 진행 중인 Render 단계를 중단하고 UI 스레드에서 동기적으로 새 렌더 파이프라인을 실행합니다.
JS Thread: ████ Render ███ (중단)
↑
UI Thread: ████ Render ████ Commit ████ Mount ████
이 설계 덕분에 React Native는 사용자 인터랙션의 우선순위를 보장하면서도 백그라운드 작업을 효율적으로 처리할 수 있습니다.
9. 뷰 평탄화 최적화
React의 컴포지션 기반 API는 직관적인 개발 경험을 제공하지만, 결과적으로 깊은 뷰 계층을 만들어냅니다. 이 중 상당수는 화면에 아무것도 렌더링하지 않고 레이아웃에만 영향을 주는 노드입니다.
const MyComponent = () => {
return (
<View>
<View style={{ margin: 10 }}>
<View style={{ margin: 10 }}>
<Image source={logo} />
<Text>This is a title</Text>
</View>
</View>
</View>
);
};위 코드에서 두 번째, 세 번째 View는 margin만 적용하는 "레이아웃 전용(Layout-Only)" 노드입니다. 이런 노드가 실제 호스트 뷰로 생성되면 불필요한 성능 비용이 발생합니다.
평탄화 전후 비교
평탄화 전 (Host View 5개) 평탄화 후 (Host View 3개)
───────────────────── ─────────────────────
View (1) View (1, margin 병합)
└─ View (2, margin:10) ├─ Image
└─ View (3, margin:10) └─ Text
├─ Image
└─ Text
뷰 평탄화 알고리즘은 Mount 단계의 디핑(Diffing) 과정에 통합되어 설계상 추가적인 CPU 사이클이 들지 않습니다. C++로 구현되어 모든 플랫폼에서 동일하게 적용됩니다.
실제 프로덕션 앱에서 Shadow Tree는 평탄화 전 약 600~1,000개 노드로 구성되며, 평탄화 후 약 200개 노드로 줄어듭니다.
10. 정리
React Native의 전체 렌더링 흐름을 하나의 다이어그램으로 요약합니다:
setState() / 초기 렌더
│
▼
┌─────────────── Render (JS Thread) ──────────────────┐
│ JSX → React Element Tree │
│ Fiber 기반 Reconciliation (이전 트리와 비교) │
│ Shadow Tree 생성 (C++, JSI를 통한 동기 호출) │
└─────────────────────────┬───────────────────────────┘
▼
┌─────────────── Commit (Background Thread) ──────────┐
│ Yoga를 사용한 레이아웃 계산 (x, y, width, height) │
│ 트리 승격: new tree → next tree │
└─────────────────────────┬───────────────────────────┘
▼
┌─────────────── Mount (UI Thread) ───────────────────┐
│ Tree Diff: 이전 렌더링 트리 vs 다음 트리 (C++) │
│ 뷰 평탄화로 불필요한 호스트 뷰 제거 │
│ Mutation 연산 목록 생성 (create/update/remove/delete)│
│ 트리 승격: next tree → rendered tree │
│ Host View에 변이 연산 동기 적용 │
└─────────────────────────────────────────────────────┘
│
▼
화면에 네이티브 뷰 표시
핵심 포인트
| 개념 | 설명 |
|---|---|
| Fiber | 작업을 중단·재개·우선순위 부여가 가능한 가상 스택 프레임 |
| JSI | JS와 C++ 간 직접 함수 호출을 가능하게 하는 인터페이스 |
| Shadow Tree | C++에서 관리되는 불변 중간 표현. 레이아웃 정보 포함 |
| 구조적 공유 | 변경된 노드만 복제하고 나머지는 참조 공유 |
| 뷰 평탄화 | 레이아웃 전용 노드를 병합하여 호스트 뷰 수를 줄이는 최적화 |
| 스레드 분리 | Render(JS), Commit(Background), Mount(UI)로 분리하여 병렬 처리 |
이 흐름을 이해하면 React Native 앱의 성능 병목 지점을 정확히 파악할 수 있습니다. JS Thread에서의 과도한 연산은 Render 단계를 지연시키고, Bridge/JSI 통신 빈도는 Shadow Tree 생성 속도에 영향을 미치며, UI Thread의 블로킹은 Mount 단계의 프레임 드롭을 유발합니다. 각 단계가 어디서 실행되는지를 알면, 최적화의 방향도 명확해집니다.