
리액트가 화면 업데이트 작업을 더 잘게 쪼개서, 우선순위를 나누고, 중간에 멈췄다가, 다시 이어서 할 수 있게 만든 내부 구조.
왜 하필 fiber라는 이름일까? (한국어로 ‘섬유’)
아주 가벼운 실행 단위/ 촘촘한 연결구조라는 느낌을 주기 위해서다.
업데이트를 한 덩어리로 처리하지 않고 작은 단위의 작업들로 쪼개서 관리하고싶었고, 그 작은 단위가 fiber인것.
노드 : 데이터를 담는 단위 객체
포인터 : 다른 노드로 이어지는 참조/연결 정보
예 )
[철수] ----친구----> [영희]
철수 : 노드
영희를 가리키는 정보 : 포인터
DOM : 문서 전체를 객체 구조로 표현한 것 (나무 전체 느낌)
DOM 노드 : 그 DOM을 이루는 개별 요소 하나하나 (나무의 가지, 잎, 줄기 하나하나 느낌)
우선 트리 구조 예시 :
A
├─ B
│ ├─ D
│ └─ E
└─ C
└─ F
A는 루트, B,C는 A의 자식, D,E는 B의 자식, F는 C의 자식.
트리 순회란 트리 안의 노드들을 어떤 순서로 방문할지 정하는 것.
재귀는 함수가 자기 자신을 다시 호출하는 방식.
그래서 재귀 트리 순회는 이런 방식으로 동작 : A를 처리하고, 자식들을 각각 다시 같은 방식으로 처리.
예시 : 부모먼저, 자식 나중 식으로 순회하면 A - B - D - E - C - F 순서가 됨
포인터 : 다른 노드를 가리키는 참조/연결 정보 . 예를 들어 A -> B -> C 여기서 화살표가 포인터 느낌.
포인터 구조란, 노드들이 서로를 가리키는 구조.
Current tree : 지금 화면에 반영된 현재 트리
work-in-progress tree : 다음 화면을 만들기 위해 작업 중인 트리
즉 지금 보이는 UI는 건드리지않고 뒤에서 다음 버전을 준비해두는 느낌.
작업이 다 끝나면 둘을 바꿔치기하듯 교체함.
노드들이 포인터(참조)로 서로 연결된 자료구조.
배열처럼 한 덩어리로 쭉 들어있는게 아니라, 각 칸이 자기 다음 칸이 누군지 알고 있는 구조라고 보면 됨
[A] -> [B] -> [C] -> null
리액트 업데이트는 크게 2단계로 생각하면 됨. Render phase, commit phase.
Render phase 는 어떤 변경이 필요한지 계산하고, 새 fiber 트리 만들거나 비교하고, 어떤 DOM 변경이 필요한지 결정함.
Commit phase는 실제 DOM 반영하고, ref 적용하고, lifecycle / effect 처리함.
Commit phase단계는 보통 끊지 않고 한 번에 처리됨.
예를들어 화면에 엄청 큰 리스트가 있고 상태가 바뀌어서 전부 다시 계산해봐야한다고 하면.
렌더링 시작 -> 끝날 때까지 계속 작업 -> 그동안 브라우저는 다른일 처리 못함
이렇게 됨. 그러면 사용자는 ‘입력이 늦게 먹히네’ ‘스크롤이 버벅이네’ 라고 느낌 .
이걸 바꾸기 위해서 작업을 작은 단위로 나누고, 급한 작업을 먼저 처리하고, 필요하면 하던 작업 잠시 멈추고, 나중에 다시 이어서 처리하는 식으로 만들게 됨
이덕에 작업 우선순위 관리, time slicing, interruptible rendering, concurrent rendering, suspense같은 비동기 친화적 렌더링 모델을 지원할 수 있게됨
(interruptible rendering : 렌더링 작업을 하다가 중간에 멈출 수 있는 것)
fiber는 추상적인 개념이 아니라 리액트가 각 컴포넌트를 표현하는 ‘작업 단위 객체’ 같은 것임.
리액트는 컴포넌트 트리를 그대로 다루는게 아니라 그걸 내부적으로 fiber 노드들의 트리로 관리함.
예를 들어 이런 컴포넌트가 있다면
<App>
<Header />
<Main>
<Sidebar />
<Content />
</Main>
</App>
리액트 내부에서는 이런식의 fiber 트리로 관리함.
App fiber
* Header fiber
* Main fiber
* Sidebar fiber
* Content fiber
렌더링 단위마다 대응되는 fiber 노드가 있다고 보면 됨.
fiber노드는 단순히 ‘이 컴포넌트가 있다’만 저장하는게 아니고 렌더링에 필요한 정보들을 많이 들고있음.
예를들면 이 노드의 타입은 뭔지, 어떤 prop를 받았는지, 어떤 state를 갖고있는지, 부모/자식/형제 노드가 누군지, 이번 업데이트에서 해야할 작업이 뭔지, DOM에 반영해야할 변경사항이 뭔지.
즉 렌더링 작업을 위한 메모장 + 연결 리스트 + 작업 큐 역할을 같이 한다고 보면 됨.
🙋♀️ 그렇담 reconciliation도 여기서 하는건가? 라는 궁금증이…
(Reconciliation: 이전 UI와 다음 UI를 비교해서 뭐가 바뀌었는지 판단하는 과정)
Reconciliation도 규모가 커지면 무거워지는데, fiber는 그 reconciliation을 더 잘게 나눠서 처리할 수 있게 만든 기반 구조임.
즉 reconciliation이 무엇이 바뀌었는지 비교하는 과정이라면 fiber architecture는 그 비교와 업데이트를 유연하게 수행하기 위한 내부엔진이라고 보면 됨.
reconciliation은 트리 전체에 대해 수행되지만, 그 비교 작업은 fiber node를 방문하면서 node 단위로 진행됨.
JSX :
<App>
<Header />
<Main>
<Sidebar />
<Content />
</Main>
</App>
트리 :
App
├─ Header
└─ Main
├─ Sidebar
└─ Content
리액트 내부에서는 이걸 Fiber node들의 연결 구조로 관리함.
일반적인 ‘트리 객체 안에 children 배열’ 느낌으로만 관리하는게 아니라 linked list 비슷한 방식으로 관리함.
Fiber node의 핵심 연결 포인터는 이 3개임 :
child : 첫번째 자식
Sibling : 다음 형제
Return : 부모
여기서 return은 예약어처럼 보여도 리액트 내부 필드 이름이 진짜 return임 !
의미는 부모로 돌아가는 포인터.
아까의 트리를 fiber 식으로 연결하면 대충 이런 느낌 :
App
└─ child -> Header
Header
└─ sibling -> Main
Main
└─ child -> Sidebar
Sidebar
└─ sibling -> Content
그리고 각 노드는 자기 부모를 가리키는 return도 가지고 있음
App
child = Header
return = null
Header
sibling = Main
return = App
Main
child = Sidebar
return = App
Sidebar
sibling = Content
return = Main
Content
return = Main
이런 식으로!
이렇게 하면 리액트가 트리를 순회하고 작업 단위를 쪼개기가 쉬워짐.
예를들어 리액트는 렌더링할 때 대충 이런 흐름으로 돌아다닐 수 있음 :
현재 노드 방문 -> child 있으면 child로 내려감 -> child 없으면 sibling으로 감 -> sibling도 없으면 return 타고 부모로 올라감 -> 올라가다가 부모의 sibling있으면 거기로 감
즉 재귀 트리 순회같은 걸 더 명시적인 포인터 구조로 들고있는 느낌
App
├─ Header
└─ Main
├─ Sidebar
└─ Content
이걸 fiber 포인터로 움직이면
App에서 child로 Header감
Header에서는 child 없으니까 sibling으로 Main감
Main에서는 child로 Sidebar 감
Sidebar는 child 없으니까 sibling으로 Content감
Content는 sibling 없으니까 return으로 Main 올라감
Main도 sibling 없으니까 return으로 App 올라감
App도 끝
핵심 포인트: child는 “첫 번째 자식”만 가리킴
child는 모든 자식을 배열처럼 들고있는게 아니라 첫번째 자식 하나만 가리킴.
나머지 자식들은 sibling 체인으로 연결됨
<ul>
<li>A</li>
<li>B</li>
<li>C</li>
</ul>
이런게 fiber 라면
ul.child = li(A)
li(A).sibling = li(B)
li(B).sibling = li(C)
li(C).sibling = null
즉, 부모는 첫째만 알고있고 형제들은 서로 다음 형제들을 알고있음
alternate는 현재 fiber와 그에 대응하는 반대편 fiber를 연결하는 포인터
리액트는 보통 두 트리를 생각함 (current tree /지금 화면에 반영된 트리, workInProgress tree/다음 렌더 준비중인 트리)
그래서 같은 컴포넌트라도 이 두 트리 안에 각각 fiber node가 하나씩 있을 수 있음
이 둘을 연결하는게 alternate.
Current App Fiber <--> WorkInProgress App Fiber
alternate alternate
즉 alternate는 같은 컴포넌트의 현재버전/작업 중 버전을 연결하는 선으로 보면 됨
작업 중간에 실패하거나 멈춰도 지금 사용자에게 보이는 화면은 안전하게 보이게 하기 위해서.
그래서 현재 화면은 current, 다음 화면 계산은 workInProgress이렇게 따로 들고가고
둘을 alternate로 연결해서 비교하거나 재사용함.
이 덕분에 리액트는 이전 상태를 참조하고, 새 작업 트리를 생성하고, 완료되면 트리를 교체하는게 가능한거임
Type : 어떤 컴포넌트/태그인지
stateNode : 실제 DOM 노드나 클래스 인스턴스
pendingProps : 새로 들어온 props
memoizedProps: 이전에 반영된 props
memoizedState: 현재 state
flags: 이 노드에서 어떤 작업을 해야 하는지
Child
Sibling
Return
Alternate
리액트가 reconciliation하다가 이 노드는 DOM 생성해야해, 이 노드는 업데이트해야해, 이 노드는 삭제해야해 이런걸 표시해두는 곳.
여기 있는걸 commit phase에서 실제 반영함.
Fiber node : React 내부의 작업용 노드
DOM node : 브라우저가 실제 화면을 위해 갖고 있는 노드
React는 자기 내부에서 Fiber tree를 관리하고, 브라우저는 자기 내부에서 DOM tree를 관리함.
그리고 React가 필요할 때 Fiber를 바탕으로 DOM node를 만들거나 수정함.
Fiber tree <----연결/참조----> DOM tree
이런 느낌.
JSX :
function App() {
return (
<div>
<h1>Hello</h1>
</div>
);
}
React :
Fiber(App)
child -> Fiber(div)
Fiber(div)
child -> Fiber(h1)
stateNode -> 실제 <div> DOM node
Fiber(h1)
stateNode -> 실제 <h1> DOM node
브라우저 :
DOM <div>
└ DOM <h1>
└ DOM Text("Hello")
즉 Fiber tree가 있고, DOM tree가 있고, 일부 Fiber가 대응되는 DOM node를 참조함
하지만 모든 Fiber node가 DOM node를 가지는 것(1:1 대응)은 아님.
함수 컴포넌트 Fiber나 Fragment Fiber 이런건 직접 대응되는 DOM node가 없을 수 있음.
function MyComponent() {
return <div>Hello</div>;
}
이런 경우는 MyComponent용 Fiber가 있지만 MyComponent 자체에 대응되는 DOM node는 없음.
실제 DOM node는 < div > 쪽에만 있음
Fiber(MyComponent) // DOM node 없음
child -> Fiber(div)
Fiber(div)
stateNode -> DOM <div>
Fiber node가 공사 설계/작업 관리 카드라면 DOM node는 실제 건물 부품임.
즉 설계 카드 안에 건물 전체가 들어있는 게 아니라, 그 카드가 ‘이 부품은 이거야’하고 실제 부품을 가리킬 수 있는것.
DOM도 있고 DOM node도 있고 Fiber node도 있고 Virtual DOM도 있고…
헷갈림
DOM : 브라우저가 실제 화면을 표현하기 위해 갖고 있는 객체 트리
DOM node : 그 DOM 트리의 개별 노드
Virtual DOM : React가 ‘다음 UI가 어떻게 생겨야 하는지’ 표현하는 js 객체 개념
Fiber node : React가 렌더링 작업을 수행하기 위해 쓰는 내부 작업 단위 노드
즉 실제 화면 쪽은 DOM, React 내부 계산/관리 쪽은 Virtual DOM, Fiber
DOM 예시 :
<div id="app">
<h1>Hello</h1>
</div>
브라우저는 이걸 객체 트리로 바꿔서 관리하고, div,h1,”Hello”하나하나가 DOM node
Document
└ html
└ body
└ div#app
└ h1
└ "Hello"
Virtual DOM 예시 :
<div>
<h1>Hello</h1>
</div>
->
{
type: 'div',
props: {
children: {
type: 'h1',
props: {
children: 'Hello'
}
}
}
}
실제 브라우저 DOM이 아니라 React가 UI를 설명하기 위해 만든 가상 표현.
Virtual DOM은 UI를 표현하는 개념/객체
Fiber는 그 UI를 렌더링하기 위해 React가 내부적으로 쓰는 작업 노드 구조
컴포넌트 :
function App() {
return (
<div>
<h1>Hello</h1>
</div>
);
}
Virtual DOM :
App returns:
div
└ h1
└ "Hello"
Fiber :
Fiber(App)
child -> Fiber(div)
Fiber(div)
child -> Fiber(h1)
stateNode -> DOM <div>
Fiber(h1)
stateNode -> DOM <h1>
DOM :
DOM <div>
└ DOM <h1>
└ DOM Text("Hello")
Virtual DOM : React가 UI를 설명하는 가상 표현 (설명서)
Fiber : 그 UI를 업데이트하기 위해 쓰는 작업 구조 (작업 관리자)
DOM : 최종 실제 화면 구조 (실물)
<div>
<h1>{text1}</h1>
<h2>{text2}</h2>
<input type="text" />
</div>
이 경우
Text1, text2가 바뀌고 React가 새 렌더 결과를 계산하기 시작하고, work-in-progress tree를 만들고 있음, text1까지 만들었고 아직 commit은 안함. 그 사이 input에 타이핑이 들어오면 어떻게 되는가?
입력 반영이 더 중요해서 현재 진행 중이던 render work를 중단하고, input 관련 render/commit을 먼저 처리될 수 있음.
그 뒤 나머지 text1, text2 쪽 작업을 이어서 하거나 다시 시작함.
text1까지 만들었는데 어떻게 되나? -text1까지 work-in-progress를 만들어놨다고 해도 commit 전이라면 작업 중인 초안이기 때문에, 잠깐 보류할 수도 있고 필요하면 버리고 다시 계산할 수도 있음. 즉 render phase에서 계산한 내용은 commit 전까지는 확정이 아님.
No. fiber는 concurrent 기능을 가능하게 만든 기반 구조고, concurrent rendering은 그 위에서 동작하는 렌더링 방식임