📚 모던 리액트 Deep Dive의 내용을 기반으로 학습하고 정리합니다.
지난 글에서 직접 JSX를 가상DOM으로 변환하고 재조정을 통한 브라우저 렌더링까지 구현해 볼 수 있었다.
이번 글에서는 실제 리액트에서는 어떤 방식으로 렌더링이 이뤄지는지 알아볼 것이다. 그리고 해당 과정을 담당하는 리액트 파이버에 대해서도 깊게 알아보자!
React Fiber는 React 16부터 도입된 새로운 재조정(reconciliation) 알고리즘으로, UI 업데이트를 보다 유연하고 효율적으로 수행하기 위해 설계되었다. 기존 React는 렌더링을 한 번 시작하면 끝날 때까지 멈출 수 없는 구조였지만, Fiber를 통해 작업을 쪼개고 우선순위를 조정하며, 필요할 때 중단하거나 재개할 수 있는 방식으로 개선되었다.
기존 React는 상태가 변경될 때마다 전체 컴포넌트 트리를 즉시 다시 렌더링하는 방식으로 동작했다. 하지만 JavaScript는 단일 스레드(single-threaded) 로 동작하기 때문에, UI 렌더링뿐만 아니라 사용자 입력, 애니메이션, 네트워크 요청 등 모든 작업이 순차적으로 실행될 수밖에 없었다.
이런 구조에서 너무 많은 작업이 한 번에 실행되면 프레임이 끊기는 문제가 발생할 수 있다. 브라우저는 일반적으로 16ms(1초에 60프레임, 60FPS) 내에 화면을 갱신해야 하지만, React가 이보다 오래 걸리는 연산을 수행하면 렌더링 속도가 떨어지고 애니메이션이 부드럽지 않거나 사용자의 입력이 지연되는 현상이 발생한다.
따라서 이 문제를 해결하기 위해 리액트 팀이 스택 조정자 대신 도입한 것이 바로 React Fiber다!
실제 리액트 코드를 보며 파이버의 구조를 이해해보자. (리액트 19.0.0 기준)
function FiberNode(
this: $FlowFixMe,
tag: WorkTag,
pendingProps: mixed,
key: null | string,
mode: TypeOfMode,
) {
// Instance
this.tag = tag;
this.key = key;
this.elementType = null;
this.type = null;
this.stateNode = null;
// Fiber
this.return = null;
this.child = null;
this.sibling = null;
this.index = 0;
this.ref = null;
this.refCleanup = null;
this.pendingProps = pendingProps;
this.memoizedProps = null;
this.updateQueue = null;
this.memoizedState = null;
this.dependencies = null;
this.mode = mode;
// Effects
this.flags = NoFlags;
this.subtreeFlags = NoFlags;
this.deletions = null;
this.lanes = NoLanes;
this.childLanes = NoLanes;
this.alternate = null;
// 아래의 프로파일러와 __DEV__ 코드 생략
}
보다시피 단순한 자바스크립트 객체의 형태이다.
이전에 살펴본 객체 형태의 리액트 요소와 비슷하다고 생각할 수 있다. 하지만 파이버가 중요한 점은 렌더링마다 생성되는 리액트 요소와 달리 컴포넌트 최초 마운트로 생성되고나서 가급적이면 재사용된다는 점이다.
다음으로 파이버를 생성하는 함수들이다. 실제 코드에서는 내부 최적화를 위한 추가 코드가 존재하지만 이해를 위해서 필요한 함수들만 살펴보자.
function createFiberImplClass(
tag: WorkTag,
pendingProps: mixed,
key: null | string,
mode: TypeOfMode,
): Fiber {
return new FiberNode(tag, pendingProps, key, mode);
}
function createFiberImplObject(
tag: WorkTag,
pendingProps: mixed,
key: null | string,
mode: TypeOfMode,
): Fiber {
const fiber: Fiber = {
// 생략
}
return fiber;
}
const createFiber = enableObjectFiber
? createFiberImplObject
: createFiberImplClass;
export function createFiberFromElement(
element: ReactElement,
mode: TypeOfMode,
lanes: Lanes,
): Fiber {
let owner = null;
if (__DEV__) {
owner = element._owner;
}
const type = element.type;
const key = element.key;
const pendingProps = element.props;
const fiber = createFiberFromTypeAndProps(
type,
key,
pendingProps,
owner,
mode,
lanes,
);
if (__DEV__) {
fiber._debugOwner = element._owner;
if (enableOwnerStacks) {
fiber._debugStack = element._debugStack;
fiber._debugTask = element._debugTask;
}
}
return fiber;
}
export function createFiberFromTypeAndProps(
type: any, // React$ElementType
key: null | string,
pendingProps: any,
owner: null | ReactComponentInfo | Fiber,
mode: TypeOfMode,
lanes: Lanes,
): Fiber {
let fiberTag = FunctionComponent;
let resolvedType = type;
if (typeof type === 'function') {
if (shouldConstruct(type)) {
fiberTag = ClassComponent;
if (__DEV__) {
resolvedType = resolveClassForHotReloading(resolvedType);
}
} else {
if (__DEV__) {
resolvedType = resolveFunctionForHotReloading(resolvedType);
}
}
} else if (typeof type === 'string') {
fiberTag = HostComponent;
} else {
switch (type) {
case REACT_FRAGMENT_TYPE:
return createFiberFromFragment(pendingProps.children, mode, lanes, key);
case REACT_PROFILER_TYPE:
return createFiberFromProfiler(pendingProps, mode, lanes, key);
case REACT_SUSPENSE_TYPE:
return createFiberFromSuspense(pendingProps, mode, lanes, key);
// 다른 타입들 생략
default:
if (typeof type === 'object' && type !== null) {
switch (type.$$typeof) {
case REACT_PROVIDER_TYPE:
case REACT_CONTEXT_TYPE:
fiberTag = ContextProvider;
break;
case REACT_FORWARD_REF_TYPE:
fiberTag = ForwardRef;
if (__DEV__) {
resolvedType = resolveForwardRefForHotReloading(resolvedType);
}
break;
// 다른 타입들 생략
}
}
fiberTag = Throw;
pendingProps = new Error('Element type is invalid: ...');
resolvedType = null;
}
}
const fiber = createFiber(fiberTag, pendingProps, key, mode);
fiber.elementType = type;
fiber.type = resolvedType;
fiber.lanes = lanes;
if (__DEV__) {
fiber._debugOwner = owner;
}
return fiber;
}
createFiberFromElement
와 같은 함수이름 통해 파이버는 하나의 element에 하나가 생성되는 1:1 관계임을 유추해볼 수 있을 것이다.
실제 위 함수들은 아래의 순서로 실행된다.
jsx
, jsxs
(혹은 createElement
)를 통해 리액트 엘리먼트로 변환된다. 이 과정에서 리액트 엘리먼트는 실제 UI 컴포넌트나 DOM을 나타내는 객체로 변환된다.createFiberFromElement()
함수에 의해 Fiber 객체로 변환된다. 이때 각 엘리먼트는 그에 맞는 Fiber 구조로 변환되어, 리액트의 내부 작업에서 사용할 수 있게 된다.createFiberFromElement()
에서 호출되는 createFiberFromTypeAndProps()
는 엘리먼트의 type
과 props
를 기반으로 Fiber를 생성하는 핵심 함수다. 이 함수는 주어진 타입에 따라 적절한 Fiber 태그를 설정하고, 관련 정보를 할당한다.createFiberFromTypeAndProps()
는 createFiber()
를 호출하여 실제 Fiber 객체를 생성한다. 이때 최종적으로 반환된 Fiber는 React 렌더링 과정에서 사용될 준비가 된다.다음으로 파이버 객체에 선언된 주요 속성을 살펴보자
tag
파이버와 element가 1:1로 매칭된 정보를 가지고 있는 속성
tag가 가질 수 있는 값으로는 ReactWorkTags.js에서 확인이 가능하다. 익숙한 FunctionComponent, ClassComponent 외에도 div와 같은 요소를 의미하는 HostComponent 등 다양한 타입들이 존재한다.
key
같은 리스트 내에서 요소를 구별하기 위한 key 값
elementType
JSX에서 사용된 원래 요소의 타입 (예: 'div', Component 함수 등)
type
실제 렌더링할 컴포넌트 타입 (클래스 컴포넌트는 생성자, 함수 컴포넌트는 함수)
stateNode
파이버 자체에 대한 참조(reference) 정보
이 참조를 바탕으로 리액트는 파이버와 관련된 상태에 접근한다.
return, child, sibling, index
파이버 간의 관계 개념을 나타내는 속성들
리액트 컴포넌트 트리가 형성되는 것과 동일하게 파이버도 트리 형식을 가지며, 트리 구성을 위해 필요한 정보가 내부에 정의된다.
이때 리액트 컴포넌트 트리의 요소와 다르게 파이버는 children이 아닌 child만 존재하며, 파이버가 관계를 표현하는 방식은 다음과 같다.<ul> <li>하나</li> <li>둘</li> <li>셋</li> </ul>
위와 같이 구성되어 있을때, 파이버의 child은 항상 첫번째 자식 파이버 참조로 구성된다. 그리고 자식 파이버는 다음 형제를 가리키는 sibling으로 구성되며 index로 몇 번째 형제인지 표현한다. return은 부모 파이버로 작업이 완료되면 돌아가기 위해 존재한다.
const l3 = { return: ul, index: 2, } const l2 = { sibling: l3, return: ul, index: 2, } const l1 = { sibling: l2, return: ul, index: 0, } const ul = { // ... child: l1, }
위와 같이 관계도를 표현해 볼 수 있다.
pendingProps
아직 작업을 미처 처리하지 못한 props (처리할 예정)
memoizedProps
마지막으로 메모이즈된 props (렌더링 후 이전 상태)
pendingProps를 기준으로 렌더링이 완료된 이후에 pendingProps를 memoizedProps로 저장해 관리한다.
updateQueue
상태 업데이트, 콜백 함수, DOM 업데이트 등 필요한 작업을 담아두는 큐
아래와 같은 구조를 갖는다.type UpdateQueue = { first: Update | null last: Update | null hasForceUpdate: boolean callbackList: null | Array<Callback> // setState로 넘긴 콜백 목록 }
memoizedState
마지막으로 메모이즈된 state (마지막 렌더링에 사용된 state)
dependencies
이 Fiber에 의존하는 다른 Fiber들
mode
Fiber의 실행 모드. React의 동시성 모드와 같은 다양한 실행 모드를 나타낸다.
flags
이 Fiber에서 발생한 효과들을 추적하는 데 사용 (렌더링 이후의 변경 사항)
subtreeFlags
자식 Fiber들의 flag를 추적
부모 Fiber의 상태가 변경될 때 자식 Fiber들의 상태와 효과도 함께 추적해야 하기 때문에 사용한다.
deletions
삭제된 Fiber들을 추적하는 속성
lanes
fiber 작업의 우선순위를 설정
childLanes
자식 Fiber들의 작업 우선순위
alternate
이전 렌더링 상태와 비교할 수 있도록 하는 대체 Fiber
아래의 Fiber 트리 설명과 이어지는 개념이다. 반대편 트리 파이버를 가리킨다.
파이버 트리는 리액트 내부에 두 개가 존재한다.
트리를 두 개 사용하는 이유는 더블 버퍼링을 위해서이다. 현재 트리(화면에 렌더링된 상태)와 작업 중인 트리(업데이트된 상태)를 분리하여, 작업 중인 트리가 준비되면 한 번에 교체함으로써 화면에 깜빡임이나 불완전한 UI 변경을 방지한다.
즉 현재 렌더링된 상태인 current에서 업데이트가 발생하면 파이버는 새로 받은 데이터로 새로운 workInProgress 트리를 빌드하기 시작한다. 빌드 작업이 끝나면 다음 렌더링에 이 트리를 사용하며, 해당 트리가 UI에 최종적으로 반영이 완료되면 current가 workInProgress로 변경된다.
파이버 노드의 생성 흐름은 다음과 같다.
beginWork()
함수 실행으로 파이버 작업을 수행하며, 더 이상 자식이 없는 파이버를 만날 때까지 트리 형식으로 시작된다.completeWork()
함수를 실행해 파이버 작업을 완료한다.return
으로 돌아가 자신의 작업이 완료됐음을 알린다.위와 같은 과정을 거치며 루트 노드가 완성되는 순간, 최종적으로 commitWork()
가 수행되고 업데이트가 필요한 변경사항이 DOM에 업데이트 된다.
<A1>
<B1>파이버 작업 예제</B1>
<B2>
<C1/>
</B2>
</A1>
위의 JSX 코드를 파이버 트리로 표현하면 아래와 같다.
이때 setState
등으로 업데이트가 발생하면 workInProgress 트리가 빌드된다. 이 과정에서 workInProgress 트리의 각 파이버는 현재 트리와 비교된다. 동일한 파이버가 있으면 재사용되고, 상태나 props만 업데이트된다. 파이버가 다르거나 컴포넌트가 변경되면 새로운 파이버가 생성되어 교체된다. 최종적으로 workInProgress 트리는 현재 트리로 교체되어 UI에 반영된다.
기존에 동기식으로 처리했던 작업이 이제는 파이버 단위로 수행되면서 우선순위가 높은 다른 업데이트가 오면 현재 업데이트를 중단하거나 새롭게 만들거나, 폐기하는 비동기 작업이 가능해진 것이다! 또한 작업 단위를 나누어 우선순위를 할당하는 것 또한 가능해졌다.
이쯤에서 잠시 가상 DOM을 다시 언급해보자. 실제 브라우저에서의 반영은 동기적으로 일어나며, 처리해야 할 작업이 많으면 화면이 불완전하게 표시될 수 있다. 이를 해결하기 위해 리액트의 파이버처럼 여러 작업을 가상 환경에서, 즉 메모리 상에서 먼저 수행하고, 최종 결과물만 실제 브라우저 DOM에 적용하는 방식이 사용된다.
이처럼 가상 DOM은 리액트 내부에서 실존하는 인스턴스나 값, 기술이라기보다는 파이버를 통한 조정 과정으로, 리액트 엘리먼트와 실제 DOM을 동기화하기 위한 리액트 엘리먼트를 JS 객체 형태로 표현한 패턴이라고 보는 것이 더 적합하다.
브라우저의 렌더링과 혼동하는 것을 주의하자.
리액트에서의 렌더링은 리액트 애플리케이션 트리 안에 있는 모든 컴포넌트들이 현재 자신들이 가지고 있는 props와 state의 값을 기반으로 어떻게 UI를 구성하고 어떤 DOM 결과를 브라우저에 반영할지 계산하는 과정을 의미한다.
리액트 개발자라면 무조건 알아두어야 하는 파트이다. 면접 단골질문
리액트의 렌더링 과정은 크게 렌더 단계(Render Phase) 와 커밋 단계(Commit Phase) 로 나뉜다.
렌더 단계에서는 가상 DOM을 재조정(Reconciliation) 하여 변경 사항을 계산하고, 최소한의 업데이트만 수행하도록 준비한다.
이 과정에서 새로운 Fiber 트리가 생성되며, 실제 DOM을 변경하지 않는다.
Reconciliaion 단계는 파이버 작업 과정에서 알아본 beginWork와 completeWork로 나뉜다.
루트에서부터 Fiber 트리 순회 시작
beginWork()
함수가 호출되면서 루트부터 하위 노드를 순회한다.Diffing 알고리즘 적용
Placement
(새로운 요소 추가)Update
(기존 요소 변경)Deletion
(요소 삭제)completeWork()
호출completeWork()
를 호출한다.렌더 단계가 끝나면 commitWork()
를 호출하여 커밋 단계(Commit Phase) 로 넘어간다.
커밋 단계에서는 렌더 단계에서 계산된 변경 사항을 실제 DOM에 반영 하고, 컴포넌트의 라이프사이클 메서드를 실행한다.
이 과정은 다시 3개의 작은 단계로 나뉜다.
commitMutationEffects()
가 호출되어 effect list에 있는 변경 사항을 적용한다.useLayoutEffect()
가 실행된다.componentDidMount()
및 componentDidUpdate()
가 실행된다.import { useState } from 'react'
export default function A() {
return (
<div className="App">
<h1>Hello React!</h1>
<B />
</div>
)
}
function B() {
const [counter, setCounter] = useState(0)
function handleButtonClick() {
setCounter((previous) => previous + 1)
}
return (
<>
<label>
<C number={counter} />
</label>
<button onClick={handleButtonClick}>+</button>
</>
)
}
function C({ number }) {
return (
<div>
{number} <D />
</div>
)
}
const D() {
return <>리액트 재밌다!</>
}
위의 예제에서 B 컴포넌트의 버튼을 눌러 counter 변수를 업데이트한다면 어떻게 될까?
위 과정을 살펴보면 D와 같이 변경사항이 없음에도 리렌더링이 일어나는 경우가 있다.
불필요한 리렌더링을 없애고자 우리는 React.memo와 같은 고차컴포넌트를 활용한다.
// ...
const D = memo(() => {
return <>리액트 재밌다!</>
})
컴포넌트 비교 과정에서 memo
로 선언된 컴포넌트는 props가 변경되지 않으면 렌더링이 생략되며, 이에 따라 커밋 단계도 생략된다. 이외에도 useMemo
, useCallback
같은 훅을 사용해 불필요한 렌더링을 최소화할 수 있다.
메모이제이션이 항상 유리한 것은 아니다. 이전 결과를 메모리에 저장하고 다시 불러오는 과정에도 비용이 들기 때문에, 간단한 함수에 대해서는 오히려 비효율적일 수 있다.
다만, props에 대한 얕은 비교를 수행하는 메모이제이션보다 컴포넌트 결과물을 다시 계산하고 실제 DOM과 비교하는 작업이 더 무겁다. 따라서 지속적으로 성능을 측정하며 최적화하는 것보다, 먼저 메모이제이션을 적용하고 필요에 따라 조정하는 것이 더 유리할 수 있다.
리액트 렌더링의 핵심인 Fiber에 대해 학습하면서, 방대한 리액트 코드 속에서 막막함을 느끼기도 했지만, 여러 개발자분들의 아티클 덕분에 큰 도움을 받을 수 있었다.
특히 정리하는 과정에서 흐름이 복잡해 일부 놓치는 부분도 있었지만, 이를 통해 더욱 깊이 있는 이해가 필요함을 깨달았다. 앞으로도 관련 학습을 반복하며 개념을 더욱 정리하고, 이 글 역시 지속적으로 다듬어 나갈 예정이다.
모던 리액트 Deep Dive
https://d2.naver.com/helloworld/2690975
https://goidle.github.io/react/in-depth-react-intro/