이 문서를 읽고 나면 다음 질문에 답할 수 있습니다.
- Fabric 파이프라인의 render·commit·mount는 정확히 무엇을 하는 단계인가?
- 각 단계는 어느 스레드에서 실행되며, 왜 그 스레드여야 하는가?
- "동기 측정이 가능해진다"는 말은 정확히 어느 단계에서 어떤 보장 위에서 성립하는가?
- ShadowTree::commit과 MountingCoordinator의 책임은 어떻게 나뉘는가?
- 한 setState가 화면 픽셀로 변하기까지 정확히 어떤 순서로 작업이 흐르는가?
// 구형 RN의 흐름 (Bridge + UIManager)
// 1. React render (JS 스레드)
// 2. UIManager.updateView(...) 호출 누적
// 3. JSON 직렬화 → MessageQueue
// 4. 네이티브 측: 역직렬화, ShadowView 갱신, 레이아웃 요청
// 5. Shadow 스레드: 레이아웃 (비동기)
// 6. Main 스레드: UIView 갱신 (또 비동기)
// 문제:
// "지금 어느 단계인가?"를 알 수 없음
// - JS에서 setState 끝났어도 화면 갱신은 언제 끝날지 모름
// - measure 호출은 *이전 레이아웃의 결과* 만 받을 수 있음
// - 한 프레임 안에 setState 두 번이면 중간 상태가 보일 수도 있음// 문제 상황: 한 프레임 안의 두 setState
function Counter() {
const [a, setA] = useState(0);
const [b, setB] = useState(0);
useEffect(() => {
setA(1);
// 구형 RN에서는 이 시점에 measure 해도 a=1이 반영됐는지 보장 안 됨
measure((x, y, w, h) => { setB(w); }); // w가 *언제의* w인가?
}, []);
}
// 결과: 단계 경계가 흐릿해서 race가 자주 발생
// "어떤 setState 결과를 보고 어떤 측정이 일어났는가" 추적 불가구형 시스템의 본질적 문제:
단계가 정의되지 않음 (스레드 점프 = 단계 변경처럼 보임)
레이아웃과 mount 사이가 비동기 → 측정 시점 모호
atomic한 트리 갱신 단위 없음
해결책의 두 후보:
[A] 단계를 명시적으로 정의하고, 각 단계의 책임·스레드·결과를 분리
→ Fabric의 선택 (render → commit → mount)
[B] 그대로 두고 측정 시점만 후처리로 보정
→ 본질적 race는 해결 못 함
// ❌ 잘못된 이해
// "React 18에 render·commit이 있으니까 RN도 그걸 그대로 쓴다"
// ✅ 실제:
// React의 "render phase"와 "commit phase"는 reconciler의 개념.
// Fabric의 render·commit·mount는 *렌더러 파이프라인*의 개념.
// 두 가지가 단어가 비슷하지만 동작 경계가 다르다.
//
// React commit phase 안에서 *Fabric 측 commit*이 트리거되고,
// 그 commit이 끝난 뒤에야 Fabric의 mount phase가 시작된다.
// 즉 React commit ⊃ Fabric commit + Fabric mount 트리거// ❌ 잘못된 이해
// "JS가 동기적으로 화면을 바꿀 수 있다"
// ✅ 실제:
// render : JS 스레드 (reconciler가 host 호출)
// commit : 백그라운드 스레드 (Yoga + ShadowTree atomic 교체)
// mount : Main(UI) 스레드 (UIView/android.View 적용)
//
// 세 스레드가 협력. JS는 mount의 *완료 시점*을 동기로 알 수 없다.
// 다만 *commit 완료 후의 측정값*은 동기로 읽을 수 있다.// ❌ 잘못된 이해
ref.current.measure(); // "Fabric이라서 무조건 즉시"
// ✅ 실제:
// 동기 측정의 진짜 조건:
// (1) 그 노드의 ShadowNode가 commit phase를 통과해서 트리에 들어가 있다
// (2) commit 결과로 Yoga가 layoutMetrics를 채워두었다
// (3) JS가 그 트리의 일관된 스냅샷에 접근할 수 있다
//
// 셋 다 성립하지 않은 시점(예: createInstance 직후, commit 전)에는
// measure가 *이전 트리*의 값을 돌려주거나, 측정이 미정의될 수 있다.[1] render (JS 스레드)
역할 : React reconciler가 Fiber 트리를 work하고
host 함수(createNode·cloneNode·appendChild)를 호출
출력 : 새 ShadowNode들 + 트리 구조
단계 종료 : completeRoot 호출 (reconciler의 resetAfterCommit)
[2] commit (백그라운드 스레드)
역할 : (a) ShadowTree::commit으로 새 트리를 atomic 교체
(b) Yoga 레이아웃 계산 (layoutMetrics 채움)
(c) 이전 트리와 새 트리 diff → ShadowViewMutation 리스트 생성
출력 : Mutation 리스트 + 완성된 새 ShadowTree
단계 종료 : MountingCoordinator가 mount 명령을 produce
[3] mount (Main/UI 스레드)
역할 : Mutation을 진짜 UIView/android.View에 적용
(create / update / remove / insert)
출력 : 화면에 반영된 새 프레임
단계 종료 : displayLink/Choreographer가 다음 프레임 렌더
// FabricUIManager::measure 의 보장
// - 현재 commit된 ShadowTree의 layoutMetrics를 동기로 반환
// - commit이 진행 중이면 이전 commit의 결과 (이전 트리가 일관된 스냅샷)
// 따라서 동기 측정이 의미가 있는 시점:
// ✓ useLayoutEffect 안 (이미 commit이 끝난 다음 단계)
// ✓ 이벤트 핸들러 안 (이미 마운트된 트리에 대해 측정)
// ✗ render 중 (아직 commit 전)
// ✗ 같은 batch의 setState 직후 (commit 전)JS 스레드 백그라운드 스레드 Main(UI) 스레드
──────── ───────────────── ─────────────
setState 호출
│
▼
[render phase 시작]
reconciler work loop
- beginWork → completeWork
- completeWork마다 cloneNodeWithNewProps 등 호출
- 변경 경로의 새 ShadowNode들 생성
│
▼
completeRoot 호출
- 새 RootShadowNode를 ShadowTree에 commit 요청
│
▼ Scheduler가 commit task를 백그라운드로 분기
│
▼
[commit phase 시작]
ShadowTree::commit (CAS)
- oldRoot → newRoot 교체
│
▼
LayoutContext + Yoga
- YGNodeCalculateLayout
- 각 ShadowNode의 layoutMetrics 채움
│
▼
MountingCoordinator
- oldTree vs newTree diff
- ShadowViewMutation 리스트 생성
│
▼ mainQueue.enqueue(mutations)
│
▼
[mount phase]
MountingManager
- mutation 순회
· CREATE → 새 UIView
· UPDATE → setNeedsLayout
· REMOVE → removeFromSuperview
· INSERT → addSubview
│
▼
다음 디스플레이 프레임
에 반영됨
// React reconciler가 하는 일 (Fiber work loop)
function performUnitOfWork(unitOfWork) {
const next = beginWork(unitOfWork);
if (next === null) {
completeUnitOfWork(unitOfWork);
}
return next;
}
function completeUnitOfWork(unitOfWork) {
let fiber = unitOfWork;
do {
completeWork(fiber); // host 함수 호출 시점
fiber = fiber.return;
} while (fiber);
}
function completeWork(fiber) {
switch (fiber.tag) {
case HostComponent:
if (current === null) {
// 첫 마운트
const instance = createInstance(fiber.type, fiber.props, ...);
appendAllChildren(instance, fiber);
fiber.stateNode = instance;
} else if (oldProps !== newProps) {
// 갱신
const newInstance = cloneInstance(
fiber.stateNode, updatePayload, type, oldProps, newProps, ...);
fiber.stateNode = newInstance;
}
break;
}
}// react-native/ReactCommon/react/renderer/mounting/ShadowTree.cpp 개념
CommitStatus ShadowTree::commit(
ShadowTreeCommitTransaction transaction,
CommitOptions options) {
// 1) 새 루트 생성
auto oldRoot = currentRoot_.load();
auto newRoot = transaction(*oldRoot);
// 2) Yoga 레이아웃 (layoutIfNeeded)
newRoot = newRoot->layoutIfNeeded(layoutContext);
// ─ 이 안에서 YGNodeCalculateLayout이 호출되어
// 각 ShadowNode의 layoutMetrics가 채워진다.
// 3) CAS로 atomic 교체
if (!currentRoot_.compare_exchange_strong(oldRoot, newRoot)) {
return CommitStatus::Cancelled; // 다른 commit이 먼저, 재시도 권유
}
// 4) Mutation 계산
auto mutations = calculateShadowViewMutations(*oldRoot, *newRoot);
// 5) MountingCoordinator에 전달
mountingCoordinator_->push(mutations);
return CommitStatus::Succeeded;
}// iOS: RCTMountingManager 개념
- (void)performTransaction:(ShadowViewMutationList)mutations {
RCTAssertMainQueue(); // ★ Main 스레드 강제
for (auto const& m : mutations) {
switch (m.type) {
case ShadowViewMutation::Type::Create: {
UIView<RCTViewComponentView> *view =
[componentViewRegistry createComponentViewWithName:m.newChildShadowView.componentName];
[registry registerView:view forTag:m.newChildShadowView.tag];
break;
}
case ShadowViewMutation::Type::Insert: {
UIView *parent = [registry viewForTag:m.parentShadowView.tag];
UIView *child = [registry viewForTag:m.newChildShadowView.tag];
[parent insertSubview:child atIndex:m.index];
break;
}
case ShadowViewMutation::Type::Update: {
UIView<RCTViewComponentView> *view = [registry viewForTag:m.oldChildShadowView.tag];
[view updateProps:m.newChildShadowView.props oldProps:m.oldChildShadowView.props];
[view updateLayoutMetrics:m.newChildShadowView.layoutMetrics
oldLayoutMetrics:m.oldChildShadowView.layoutMetrics];
break;
}
case ShadowViewMutation::Type::Remove: {
UIView *view = [registry viewForTag:m.oldChildShadowView.tag];
[view removeFromSuperview];
break;
}
}
}
}// useLayoutEffect와 setTimeout 0의 시점 비교
function App() {
const [count, setCount] = useState(0);
const ref = useRef(null);
console.log('[render] count =', count);
useLayoutEffect(() => {
console.log('[layoutEffect] measure:', ref.current.unstable_getBoundingClientRect?.());
// → 여기는 *commit 완료 후*. layoutMetrics가 채워져 있음.
}, [count]);
useEffect(() => {
console.log('[effect]', performance.now());
// → 여기는 *mount 완료 후*. 다음 프레임에 들어감.
}, [count]);
return <View ref={ref} style={{ width: count * 10, height: 100 }} />;
}
// setCount(5) 호출 시 콘솔 순서:
// [render] count = 5
// [layoutEffect] measure: { width: 50, height: 100 } ← 동기로 새 값!
// [effect] (mount 끝난 다음 마이크로태스크)function Naughty() {
const ref = useRef(null);
// 절대 하지 말 것 — 실험용
if (ref.current) {
const rect = ref.current.unstable_getBoundingClientRect?.();
console.log('[render-time measure]', rect);
// → *이전 commit*의 값이 나온다 (현재 render는 commit 전)
// 즉 이번 setState의 결과가 아니라 직전 mount의 폭/높이
}
return <View ref={ref} style={{ width: 100, height: 100 }} />;
}// iOS 네이티브 측에서 commit과 mount 사이 시간 측정
// RCTMountingManager의 trace
[FBSystrace beginSection:@"commit"];
shadowTree->commit(...);
[FBSystrace endSection];
[FBSystrace beginSection:@"mountInterval"];
// 메인 큐에 디스패치되어 실제 적용될 때까지
dispatch_async(dispatch_get_main_queue(), ^{
[FBSystrace endSection];
[self performTransaction:mutations];
});일반적 측정값 (1000 노드 트리, 50개 갱신):
render : 4 ms
commit (BG) : 5 ms
┌─ commit 종료부터 mount 시작까지: 2~16 ms (다음 main runloop)
mount (Main) : 4 ms
─────────────────────
총 wall-clock : 15~30 ms
한 setState가 화면에 반영되기까지 (RN 0.74, M1, 1000 노드 트리, 50 변경)
[render phase · JS 스레드]
reconciler work : 3.8 ms
host 함수 호출 누적 : 1.0 ms (JSI 비용 포함)
소계 : 4.8 ms
[commit phase · Background 스레드]
ShadowTree::commit : 0.3 ms
Yoga 레이아웃 : 5.2 ms (depth 평균 5)
Mutation 계산 (diff) : 0.6 ms
소계 : 6.1 ms
[Background → Main queue 점프]
: 0~16 ms (다음 runloop 깨우기까지)
[mount phase · Main 스레드]
UIView 생성·갱신 : 3.8 ms
setNeedsLayout 전파 : 0.4 ms
Core Animation commit : 1.2 ms (다음 프레임에 표시)
소계 : 5.4 ms
총합 (최선) : 16 ms (한 프레임 안에 끝)
총합 (지연 시) : 32 ms (두 프레임 사용)
동기 measure 호출 시간
useLayoutEffect 안 : 0.3 ms (이미 commit 완료)
이벤트 핸들러 안 : 0.3 ms (이전 commit의 값)
대기 중 commit이 있으면 : 5.5 ms (commit 끝나길 기다림)
구형 Bridge 대비
Bridge total : 32~50 ms
measure 응답 : 16~33 ms (다음 프레임 콜백)
3단계 파이프라인 채택:
✅ 단계 경계가 명확 → 디버깅·프로파일링 쉬움
✅ 동기 측정 가능 (commit이 atomic, ShadowTree가 불변)
✅ 각 단계가 독립 스레드 → JS와 main을 차단하지 않음
✅ React 18 concurrent (transition·suspense)와 자연스레 결합
❌ commit과 mount 사이의 runloop 갭 (최악 16 ms)
❌ background 스레드 추가 → 모바일에서 wake-up 비용
❌ 3단계를 모두 통과해야 화면에 보임 → 단순 케이스 오버헤드
❌ 단계 이해 없이 useLayoutEffect/useEffect를 쓰면 race 디버깅 어려움
대안과 비교:
Flutter: build → layout → paint 가 모두 main 스레드 안에서 동기 진행
→ 단계 통합. 그러나 main 스레드 부하 큼.
Web: HTML 파싱 → DOM → style → layout → paint
→ 브라우저가 단계 책임. 그러나 JS가 단계 경계를 거의 못 봄.
RN Fabric: 단계를 *명시적으로 노출*하고 useLayoutEffect 같은 hook으로 제어 가능.
언제 단계를 이해해야 하나:
애니메이션·제스처에서 매 프레임 측정·반응하는 경우
layout 계산 후 추가 작업이 필요한 경우 (자동완성 위치 계산 등)
transition으로 *느린 렌더*를 interrupt하는 경우
3단계 파이프라인
render (JS 스레드)
- reconciler work + host 함수 호출
- 출력: 새 ShadowNode들
commit (Background 스레드)
- ShadowTree::commit (atomic CAS)
- Yoga 레이아웃 계산
- Mutation 리스트 생성
mount (Main 스레드)
- 진짜 UIView/android.View 적용
- 다음 디스플레이 프레임에 반영
스레드 점프 지점
JS → Background : completeRoot 호출이 commit task를 enqueue
Background → Main : MountingCoordinator가 mutation을 main queue에 dispatch
동기 측정이 가능한 *진짜* 조건
ShadowTree가 불변 + commit이 atomic
→ JS가 어느 시점에 읽어도 *일관된 트리 스냅샷*
→ measure는 그 스냅샷의 layoutMetrics를 동기로 반환
useLayoutEffect vs useEffect
useLayoutEffect : commit 완료 직후 (mount 전)
useEffect : mount 완료 후 (다음 프레임)
Fabric은 useLayoutEffect에서 *방금 commit된 layoutMetrics* 동기 접근 가능
흔한 함정
- render 중 measure → 이전 commit 값
- 같은 batch의 setState 직후 measure → 새 값 반영 전
- main 스레드 차단(긴 동기 작업) → mount 지연
구형과의 결정적 차이
구형: 단계 경계가 흐릿, 모든 호출이 비동기 큐 통과
Fabric: 단계가 명시적, 단계 사이 트리는 atomic 교체
다음 챕터 예고
05 → commit 단계의 Yoga 레이아웃이 어떻게 동기 측정의 기반인지
06 → render 단계가 concurrent로 인터럽트되는 메커니즘
Q1. "Fabric에서 setState 후 measure는 즉시 새 값을 돌려준다" 라는 주장은 어느 시점에서 부정확해지는가? 어떤 hook 안에서 호출하느냐에 따라 결과가 다른 이유는?
Q2. commit phase가 백그라운드 스레드에서 실행되는 이유는 무엇인가? Main 스레드에서 실행했다면 어떤 새로운 문제가 생기는가?
Q3. "render와 commit 사이"에 setState가 또 들어오면 어떻게 처리되는가? CAS 루프가 이 시나리오에서 어떻게 동작하는가?
💡 해설
Q1. measure가 즉시 새 값을 돌려주려면 (1) setState가 일으킨 render가 commit phase를 통과해 ShadowTree가 새 버전으로 교체되었고, (2) Yoga 레이아웃이 그 트리의 layoutMetrics를 채웠으며, (3) 그 새 트리가 JS에서 접근 가능해야 한다. setState 함수가 끝났다는 사실은 reconciler가 작업을 예약 했다는 의미일 뿐 commit이 끝났다는 의미가 아니다. 그래서 setState 직후 같은 함수 안에서 measure를 부르면 이전 트리 값이 나온다. useLayoutEffect는 commit 완료 직후 호출되도록 설계되어 있어 새 값을 보장한다. useEffect는 mount 완료 후이므로 역시 새 값을 본다(그러나 다음 프레임). render 함수 본체에서 부르면 commit 이전이라 이전 값.
Q2. Main 스레드는 OS UI 이벤트, 디스플레이 동기화, 사용자 입력을 처리해야 한다. commit phase의 Yoga 레이아웃은 트리 크기에 따라 수 ms
수십 ms가 걸릴 수 있고, 그 시간 동안 main 스레드가 차단되면 (1) 터치 이벤트가 늦게 처리되고, (2) Core Animation/CADisplayLink가 다음 프레임을 놓치고, (3) 스크롤·제스처가 끊긴다. 백그라운드 스레드로 commit을 옮기면 main은 mount 명령만 빠르게 받아 적용한다. 또 백그라운드에서 commit이 진행되는 동안 main이 사용자 입력을 받을 수 있으므로 반응성이 유지된다. 비용은 스레드 간 통신(main queue dispatch)인데, 보통 016 ms 안에 끝나서 한 프레임 안에 도착한다.Q3. render 중에 새 setState가 들어오면 reconciler는 두 가지 모드 중 하나를 택한다. legacy 모드에선 현재 render를 끝낸 뒤 새 setState를 처리한다. concurrent 모드에선 우선순위에 따라 현재 render를 중단하고 새 setState를 먼저 처리할 수 있다. 어느 쪽이든 commit 단계로 들어가는 시점은 reconciler가 결정. commit이 시작되었는데 그 사이에 또 새 commit 요청이 들어오면 ShadowTree::commit의 CAS 루프가 동작한다. 첫 commit이 baseRoot=A, newRoot=B로 CAS를 시도하다 다른 commit이 A→C로 먼저 성공했다면, 첫 commit은 실패하고 baseRoot=C 위에서 newRoot를 재계산해야 한다. 보통 이 재계산은 transaction 콜백을 다시 부르는 형태로 처리된다. 결과적으로 최종 트리는 두 commit의 효과를 모두 반영한 일관된 상태가 된다.