리액트 파이버를 통한 리랜더링 이해
우선 밝혀두자면 이 포스트는 단 하나의 궁금증으로부터 출발했다. '어떻게 input 태그는 state 없이 ─ 그리하여 리랜더링 없이 ─ value 값을 변경하지?' 보통은 input:value에 state를 달아 제어 컴포넌트로 만들지만, 그렇게 하지 않아도 input은 멀쩡히 동작한다. 당장 리액트 커뮤니티에서 가장 광범위하게 사용되는 form 관리 라이브러리 중 하나인 react-hook-form만 하더라도 ref를 기반으로 동작하니까.
state 없이도 DOM이 변경되는지를 확인해보기 위해 아래와 같은 테스트 코드를 작성해보았다. 버튼을 누르면 input:value의 값을 div의 innerText로 박아버리는 아주 간단한 동작을 처리한다.
export default function Page() {
const test = () => {
const input = document.getElementById("test") as HTMLInputElement;
const div = document.getElementById("div") as HTMLDivElement;
div.innerText = input.value;
}
return (
<>
<div id="div">test</div>
<input id="test" />
<button onClick={test}>Click me</button>
</>
);
}
예상했던 바와 같이 state가 존재하지 않지만 ─ 그리하여 컴포넌트 리랜더링은 발생하지 않았지만 ─ DOM의 내용이 변경된 것을 확인할 수 있다. 이를 통해 React의 리렌더링 메커니즘과 DOM의 상태 변화는 반드시 일치하지 않는다는 점을 확인할 수 있다. 다시 말해, 컴포넌트의 state가 변경되지 않아도 DOM은 얼마든지 사용자 입력에 의해 동적으로 바뀔 수 있다는 것이다.
그렇다면 이렇게 JS를 통해 직접 DOM을 변경한 상태에서 추가적으로 state를 변경하면 어떻게 될까? 나는 state가 변경되면서 리랜더링이 촉발되고, reconciliation이 진행되면서 직접 조작한 내용이 '취소'될 것으로 예상했다.
export default function Page() {
const [value, setValue] = useState(0);
const test = () => {
const input = document.getElementById("test") as HTMLInputElement;
const div = document.getElementById("div") as HTMLDivElement;
div.innerText = input.value;
}
return (
<>
<div id="div">test</div>
<input id="test" />
<button onClick={test}>Click me</button>
<p>Value: {value}</p>
<button onClick={() => setValue(value + 1)}>Increment</button>
</>
);
}
그러나 내 예상과는 달리, state를 변경해도 직접 조작한 DOM의 상태는 그대로 유지되었다. 즉, div.innerText에 수동으로 입력한 값은 컴포넌트가 리렌더링되었음에도 불구하고 초기 상태로 되돌아가지 않았다. 이처럼 가설과 실제 결과가 일치하지 않는다는 것은, 내가 React의 리렌더링 메커니즘 전반에 대해 아직 충분히 이해하지 못했거나, 어딘가 잘못 이해하고 있었다는 뜻이다.
react fiber
React Fiber는 React 16에서 도입된 내부 재조정(reconciliation) 엔진으로, 컴포넌트 트리의 변경 사항을 효율적으로 계산하고 실제 DOM에 반영하기 위한 혁신적인 아키텍처이다. 기존의 스택 기반 재조정 알고리즘과 달리, Fiber는 렌더링 작업을 작은 단위로 분할하고 우선순위를 부여함으로써, 작업을 중단하거나 나중에 재개할 수 있도록 설계되었다. 이를 통해 브라우저의 메인 스레드 점유 시간을 최소화하고, 보다 부드럽고 반응성 높은 사용자 경험을 제공할 수 있다.
이 아키텍처의 핵심은 FiberNode라는 연결 리스트 형태의 자료 구조이다. 각 FiberNode는 하나의 컴포넌트를 나타내며, 부모(return), 자식(child), 형제(sibling) 노드와의 연결을 통해 트리 구조를 유연하게 탐색할 수 있다. React는 이 구조를 바탕으로 상태 변화가 발생했을 때 변경이 필요한 노드만 선별적으로 처리하여, UI를 점진적으로 업데이트한다. 그 결과, 대규모 애플리케이션에서도 우수한 성능과 메모리 효율성을 동시에 확보할 수 있다.
아래는 FiberNode에서 눈여겨볼만한 프로퍼티를 정리한 것이다.
FiberNode {
// 1. 인스턴스 관련 정보
tag: number, // FiberNode의 역할
type: string | FunctionComponent | ClassComponent, // 해당 요소의 타입
key: null | string, // 형제 노드 간 구분을 위한 식별자
ref: null | Ref, // DOM 또는 컴포넌트 참조
stateNode: HTMLElement | Component, // 실제 DOM 요소 또는 클래스 컴포넌트 인스턴스
// 2. 트리 구조 (연결 리스트 형태)
return: FiberNode | null, // 부모 Fiber 노드
child: FiberNode | null, // 첫 번째 자식 Fiber 노드
sibling: FiberNode | null, // 다음 형제 Fiber 노드
index: number, // 형제들 중 위치 인덱스
// 3. 상태 관리
pendingProps: Props, // 현재 렌더링에 사용할 props
memoizedProps: Props, // 직전 렌더링에 사용된 props
memoizedState: any, // 컴포넌트의 이전 state 또는 훅 상태
// 4. 업데이트 관리
updateQueue: UpdateQueue | null, // setState 등으로 인해 발생한 업데이트들을 보관하는 큐
// 5. 효과 관리 (side effect tracking)
flags: Flags, // 어떤 변경 작업이 필요한지 나타내는 비트 마스크
subtreeFlags: Flags, // 자식 노드들의 플래그 집계
// 6. 스케줄링 정보
lanes: Lanes, // 이 작업의 우선순위
childLanes: Lanes, // 자식 노드들의 우선순위 집계
// 7. Context 구독 정보
dependencies: {
lanes: Lanes, // 이 Fiber가 어떤 Context 변경에 반응해야 하는지의 우선순위
firstContext: ContextDependency | null // 구독 중인 Context 목록의 시작점
} | null,
// 8. 대체 트리 연결
alternate: FiberNode | null // current <-> workInProgress 연결을 위한 필드
}
type은 FiberNode를 만든 게 무엇인지를 나타내는 반면, tag는 FiberNode가 어떤 타입인지를 숫자로 나타낸다. 각각의 숫자는 아래와 같은 의미를 갖는다. key는 일반적으로 map 등의 반복자를 사용할 때 쓰는 바로 그 key로 형제 요소들 사이에서 식별자 역할을 한다. useRef() 또는 createRef()로 생성된 객체가 FiberNode의 ref 프로퍼티에 저장되며, 실제 인스턴스에 대한 참조를 stateNode에 보관한다.
0: FunctionComponent (함수형 컴포넌트)
1: ClassComponent (클래스형 컴포넌트)
2: IndeterminateComponent (아직 타입이 결정되지 않은 컴포넌트)
3: HostRoot (루트 Fiber)
4: HostPortal (포털 컴포넌트 - createPortal)
5: HostComponent (일반 DOM 요소 - div, span 등)
6: HostText (텍스트 노드)
7: Fragment (React.Fragment)
8: Mode (StrictMode 등 모드 컴포넌트)
9: ContextConsumer (Context.Consumer)
10: ContextProvider (Context.Provider)
11: ForwardRef (React.forwardRef)
12: Profiler (React Profiler 컴포넌트)
13: SuspenseComponent (Suspense 컴포넌트)
14: MemoComponent (React.memo)
15: SimpleMemoComponent (단순화된 메모 컴포넌트)
16: LazyComponent (React.lazy)
17: IncompleteClassComponent (미완성 클래스 컴포넌트)
18: DehydratedFragment (SSR용 탈수화된 컨텐츠)
19: SuspenseListComponent (SuspenseList 컴포넌트)
21: ScopeComponent (실험적 Scope API)
22: OffscreenComponent (화면 밖 컴포넌트)
23: LegacyHiddenComponent (레거시 Hidden 컴포넌트)
24: CacheComponent (캐시 컴포넌트)
25: TracingMarkerComponent (추적 마커 컴포넌트)
26: HostHoistable (호이스팅 가능한 호스트 요소)
27: HostSingleton (싱글톤 호스트 컴포넌트)
앞서 FiberNode는 하나의 컴포넌트를 나타내며, 부모(return), 자식(child), 형제(sibling) 노드와의 연결을 통해 트리 구조를 유연하게 탐색할 수 있다고 했다. 이를 그림으로 간략히 나타내면 아래와 같다. 각각의 FiberNode는 자신의 바로 다음 형제 FiberNode만을 기억하지만, index를 통해 부모를 경유해 이전 형제 FiberNode들에도 접근할 수 있다.
┌────────┐
│ Page │◄───────────┬─────────────────┐
└──┬─┬───┘ │ │
│ ▲ │ │
│ │ │ │
│ │ │ │
child │ │ return │ return │ return
▼ │ │ │
┌──┴─┴──┐ sibling ┌───┴───┐ sibling ┌───┴────┐
│ div │────────►│ input │────────►│ button │
└───────┘ └───────┘ └────────┘
memoizedState 역시 연결 리스트 형태로 state 또는 훅 상태를 관리한다. 이것이 바로 리액트에서 조건부나 반복문 내에서 훅을 호출할 수 없는 까닭이다. 즉, memoizedState는 훅 호출 순서에 따라 연결 리스트 형태로 각 훅의 상태를 저장하며, 이후 리렌더링 시에도 동일한 순서로 훅을 실행함으로써 기존 상태를 정확히 매칭시킨다.
만약 조건문이나 반복문 내부에서 훅을 호출하게 되면 렌더링마다 훅의 호출 순서가 달라질 수 있고, 그 결과 React는 이전 렌더링의 훅 상태와 현재 렌더링의 훅 상태를 올바르게 연결하지 못하게 된다. 이는 곧 내부 상태 관리의 불일치를 초래하며, React의 동작을 예측할 수 없게 만들기 때문에 "훅은 항상 컴포넌트 최상단에서, 동일한 순서로 호출해야 한다"는 규칙이 생긴 것이다.
memoizedState {
memoizedState: any, // 현재 상태 값
baseState: any, // 병합되지 않은 이전 상태 값
baseQueue: Update[] | null, // 아직 처리되지 않은 업데이트
queue: {
pending: Update | null,
interleaved: Update | null,
lanes: Lanes,
dispatch: Function, // setState 함수
lastRenderedReducer: Function // useReducer일 경우 사용
},
next: memoizedState | null // 다음 Hook 상태
}
memoizedState.queue.dispatch는 useState가 리턴하는 튜플 중 setValue 함수 그 자체이다(메모리 주소가 동일하다). 또한 함수가 일급 객체라는 JS의 특징을 사용해 dispatch 함수는 boundArgs라는 튜플 형태의 프로퍼티를 가지고 있다. 이 튜플은 [FiberNode, Object] 형태를 띄는데, FiberNode는 현재 dispatch가 존재하는 컴포넌트의 인스턴스를 나타내며, 이 FiberNode는 해당 컴포넌트가 속한 트리 구조 내에서 상태 업데이트가 이루어져야 하는 정확한 위치를 식별한다.
Object는 상태를 업데이트하는 데 필요한 action 객체로, 이 객체는 상태 변경의 구체적인 내용을 담고 있으며, dispatch 함수가 호출될 때 이 값을 기반으로 상태 업데이트가 실행된다. 이로 인해, dispatch 함수는 해당 컴포넌트와 관련된 상태 변경 작업을 정확하게 추적하고 실행할 수 있게 되며, 컴포넌트 트리 내에서 각 상태 변경의 흐름을 일관되게 관리할 수 있게 된다.
reconciliation
이제 FiberNode를 통해 실제로 reconciliation이 어떻게 일어나는지 확인해보겠다. 아래는 Page 컴포넌트의 button을 눌러 setValue가 호출되었을 때 벌어지는 일을 아주 간략하게 도식화한 것이다. FiberNode는 현재 트리와 업데이트 중인 트리(WorkInProgress Tree) 사이의 관계를 추적하고, 어떤 노드가 변경되었는지, 혹은 리렌더링이 필요한지 등을 효율적으로 계산해낸다. 그 결과 리액트는 실제로 변경해야할 것은 Page 컴포넌트 전체가 아니라 p 태그에 해당하는 FiberNode라는 사실을 알게된다.
export default function Page() {
const [value, setValue] = useState(0);
const test = () => {
const input = document.getElementById("test") as HTMLInputElement;
const div = document.getElementById("div") as HTMLDivElement;
div.innerText = input.value;
}
return (
<>
<div id="div">test</div>
<input id="test" />
<button onClick={test}>Click me</button>
<p>Value: {value}</p>
<button onClick={() => setValue(value + 1)}>Increment</button>
</>
);
}
Current Tree WorkInProgress Tree
┌──────────┐ ┌──────────┐
│ Page │─────┐ alternate ┌────►│ Page │ ← value=1로 업데이트
└────┬─────┘ │ │ └────┬─────┘
│ │ │ │
▼ │ │ ▼
┌──────────┐ │ │ ┌──────────┐
│ div │─────┼──────────────┼────►│ div │
└────┬─────┘ │ │ └────┬─────┘
│ sibling │ │ │ sibling
▼ │ │ ▼
┌──────────┐ │ │ ┌──────────┐
│ input │─────┼──────────────┼────►│ input │
└────┬─────┘ │ │ └────┬─────┘
│ sibling │ │ │ sibling
▼ │ │ ▼
┌──────────┐ │ │ ┌──────────┐
│ button │─────┼──────────────┼────►│ button │
└────┬─────┘ │ │ └────┬─────┘
│ sibling │ │ │ sibling
▼ │ │ ▼
┌──────────┐ │ │ ┌──────────┐
│ p │─────┼──────────────┼────►│ p │ ← 변경 감지 (태그 Update)
└────┬─────┘ │ │ └────┬─────┘
│ sibling │ │ │ sibling
▼ │ │ ▼
┌──────────┐ │ │ ┌──────────┐
│ button │─────┼──────────────┼────►│ button │
└──────────┘ │ │ └──────────┘
└──────────────┘
이를 통해 리액트는 p 태그에 해당하는 FiberNode만 변경 사항을 반영하게 되며, 나머지 div, input, button 요소들은 그대로 유지한다. 이러한 방식으로 React는 불필요한 리렌더링을 방지하고 성능을 최적화하는 것이다.
Single Source of Truth
이제 처음으로 돌아와서 JS를 통해 직접 DOM을 변경한 상태에서 추가적으로 state를 변경할 때 리액트와 DOM에서 일어나는 일을, FiberNode와 reconciliation 관점에서 다시 해석해보자.
- 버튼을 누르면 div의 innerText에 input:value의 값이 들어온다. 이것은 '실제 DOM'에서 이루어지는 행위다.
- 따라서 리액트 FiberNode에는 어떠한 변경도 존재하지 않는다.
- 이제 버튼을 눌러서 setValue를 호출하면 '상태'의 변경이 발생하고 FiberNode가 변경되며 reconciliation이 일어난다.
- 이 과정에서 리액트는 '상태'를 참조하는 p 태그에 해당하는 FiberNode만 변경 사항을 반영하게 되며 나머지 div, input, button 요소들은 그대로 유지한다.
- 결국 실제 DOM과 가상 DOM에서 div의 innerText는 영원히 일치하지 않는다.
아래의 이미지를 보면 FiberNode의 memorizedProps 프로퍼티에는 div 태그의 children이 "test"라고 되어있으나 실제 DOM에서는 "test replace"로, 실제 DOM과 가상 DOM에서 div의 innerText가 불일치하는 것을 직접 확인할 수 있다.
이처럼 직접 DOM을 조작하는 방식은 가상 DOM과 실제 DOM 사이에 불일치를 초래하고, 이는 디버깅을 어렵게 만들거나 예기치 않은 버그로 이어질 수 있다. 때문에 input 태그를 사용할 때 일반적으로 제어 컴포넌트 방식이 권장되는 것이다.
제어 컴포넌트는 React가 '단일 진실 공급원(Single Source of Truth)' 원칙을 실천하는 대표적인 방식이다. 이 방식에서는 DOM 요소의 값이 React 상태에 의해 완전히 제어되며, 모든 사용자 입력은 먼저 React 상태를 업데이트한 후 해당 상태 변화가 UI에 반영된다. 예를 들어 input 요소의 경우, value 속성에 상태 변수를 바인딩하고 onChange 이벤트에서 상태를 업데이트함으로써, React가 UI의 모든 변화를 파악하고 관리할 수 있게 된다. 이렇게 함으로써 상태의 일관성과 예측 가능성을 유지하고, 디버깅이나 유지보수 시 혼란을 줄일 수 있다.
부록 : 리액트 19버전에서의 ref
FiberNode를 살펴보다가 한 가지 재미있는 사실을 확인할 수 있었다. 리액트 19 버전이 출시될 때 최고의 화잿거리는 단연 ref의 props화라고 할 수 있는데, 이러한 변경을 FiberNode에서도 찾아볼 수 있었다. 아래의 스크린샷은 동일한 컴포넌트를 리액트 18 버전과 19 버전에서 랜더링 후 FiberNode를 비교한 결과이다.
export default function Page() {
const inputRef = useRef<HTMLInputElement>(null);
return <input id="test" ref={inputRef} />
}
리액트 18에서는 ref가 FiberNode의 필드로서 별도로 존재하며, props와는 독립적으로 처리되었지만, 리액트 19에서는 ref가 pendingProps와 memoizedProps 내부에 포함되어 처리되고 있다. 이는 리액트 내부에서 ref를 더 이상 특수한 속성으로 분리하지 않고, 일반 props처럼 통일성 있게 다루겠다는 방향성을 보여준다.
블로그의 정보
Ayden's journal
Beard Weard Ayden