이전에 리액트의 내부 동작을 구현하기 위해 리액트 동작을 공부한 적이 있었다. 당시 시간이 부족해서 Fiber
의 개념에 대해 제대로 공부하지 않고 동작 구현을 할 때 Fiber 개념을 도입하지 않았었다.
최신 리액트에서 Fiber는 핵심 개념이고, 실제 최신 리액트의 내부 동작을 이해하기 위해 Fiber 개념 공부가 필요하다는 걸 깨달았다. 그래서 실제 Fiber 노드 코드도 확인해보고 도입한 이유도 공부해보았다. 그리고 실제 렌더링 과정이 어떻게 이루어지는지 이때 Fiber 노드를 어떻게 이용하는지도 공부해보았다.
목차
최신 리액트에서 도입한 Fiber
ㅤFiber란 무엇일까?
ㅤFiber를 생성하는 createFiber 함수
ㅤFiber를 도입하게 된 이유리액트의 렌더링 과정 살펴보기
ㅤ렌더링이란 무엇일까?
ㅤ이중 버퍼링 기법
ㅤ초기 렌더링
ㅤ리렌더링
ㅤ실제 코드로 알아보는 초기 렌더링과 리렌더링
리액트 16에서 새롭게 도입된 재조정 엔진이다. Fiber는 리액트 컴포넌트의 정보를 담고 있는 자바스크립트 객체다.
Fiber는 리액트의 작업 단위를 나타내는 특별한 객체다. 실제 DOM 노드, 컴포넌트의 인스턴스에 대응되며 렌더링 작업을 관리하는데 필요한 정보를 담고 있다. 웹 애플리케이션의 UI는 하나의 트리 구조인데, 리액트는 이 UI 트리의 각 노드에 대해 Fiber 노드를 만들어 관리한다.
그렇다면 Fiber 노드의 구조는 어떻게 되어있는지 실제 코드를 통해 알아보았다.
실제 코드에서
__DEV__
플래그가 있는 부분은 지웠다. 개발 모드와 프로덕션 모드를 구분하는 것인데, 개발 과정에서 디버깅, 문제해결을 돕고 성능 최적화에 도움이 되는 정보 제공 및 잠재적 버그 발견을 위해 있는 것이라 Fiber를 분석할 때 굳이 필요 없을거 같아 지웠다.
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;
}
Fiber 노드에는 위 코드와 같이 많은 정보를 담고 있다. 하나씩 살펴보자.
Fiber의 유형
을 나타낸다. 숫자값이 들어가는데 함수형 컴포넌트면 FunctionComponent, 클래스 컴포넌트라면 ClassComponent로 표현한다. elementType과 type의 차이는 무엇일까?
기본적인 경우 둘은 차이가 없다. 그러나 고차 컴포넌트(HOC)를 사용하면 차이가 발생한다.
function withLogging(WrappedComponent) { return function LoggedComponent(props) { return <WrappedComponent {...props}/>; } } const LoggedButton = withLogging(Button); <LoggedButton>클릭</LoggedButton> elementType === Button type === LoggedComponent
- 위 예시를 보면
elementType
은 Button이다. elementType은 우리가 사용하는 컴포넌트를 저장한다. elementType은 원본 컴포넌트의 참조를 유지하는 것이 목적이다.type
은 LoggedComponent를 저장하는데, 실제 렌더링되는 컴포넌트를 참조하는 것이 목적이다.elementType은 실제 원본 컴포넌트인 Button을 가리키고, type은 실제 렌더링을 담당하는 컴포넌트를 가리킨다.
왜 첫번째 자식만 저장할까?
A 컴포넌트의 자식이 1, 2, 3이 있다면 Fiber에서는 자식 1만 child에 저장한다. 모든 자식에 대해 배열을 유지하는게 아닌 최소한의 참조만 저장하는 것이다. 그렇다면 자식 1의 sibling을 통해 그 다음 자식을 찾을 수 있다.
최신 리액트에는
current 트리
와workInProgress 트리
2가지를 사용한다.
간단하게만 말하자면 현재 화면에 렌더링된 UI를 표현하는 건 current 트리이고, 모든 변경 작업을 하는 작업용 트리는 workInProgress 트리다. 즉, workInProgress 트리에서 작업이 끝나면 이를 current 트리로 바꾸어주게 된다.
이를 통해 현재 UI의 안정성을 보장하고, 불완전한 UI 노출이 방지된다.
위에서 FiberNode 생성자 코드를 보았는데, FiberNode 생성자로 바로 Fiber 노드를 생성하는게 아닌 CreateFiber를 호출해서 생성한다.
const createFiber = function(
tag: WorkTag,
pendingProps: mixed,
key: null | string,
mode: TypeOfMode,
): Fiber {
return new FiberNode(tag, pendingProps, key, mode);
};
function createFiberFromElement(
element: ReactElement,
mode: TypeOfMode,
lanes: Lanes,
): Fiber {
let owner = null;
const type = element.type;
const key = element.key;
const pendingProps = element.props;
const fiber = createFiberFromTypeAndProps(
type,
key,
pendingProps,
owner,
mode,
lanes,
);
return fiber;
}
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;
}
} else if (typeof type === 'string') {
if (supportsResources && supportsSingletons) {
const hostContext = getHostContext();
fiberTag = isHostHoistableType(type, pendingProps, hostContext)
? HostHoistable
: isHostSingletonType(type)
? HostSingleton
: HostComponent;
} else if (supportsResources) {
const hostContext = getHostContext();
fiberTag = isHostHoistableType(type, pendingProps, hostContext)
? HostHoistable
: HostComponent;
} else if (supportsSingletons) {
fiberTag = isHostSingletonType(type) ? HostSingleton : HostComponent;
} else {
fiberTag = HostComponent;
}
} else {
getTag: switch (type) {
case REACT_FRAGMENT_TYPE:
return createFiberFromFragment(pendingProps.children, mode, lanes, key);
case REACT_STRICT_MODE_TYPE:
fiberTag = Mode;
mode |= StrictLegacyMode;
if (disableLegacyMode || (mode & ConcurrentMode) !== NoMode) {
mode |= StrictEffectsMode;
if (
enableDO_NOT_USE_disableStrictPassiveEffect &&
pendingProps.DO_NOT_USE_disableStrictPassiveEffect
) {
mode |= NoStrictPassiveEffectsMode;
}
}
break;
case REACT_PROFILER_TYPE:
return createFiberFromProfiler(pendingProps, mode, lanes, key);
case REACT_SUSPENSE_TYPE:
return createFiberFromSuspense(pendingProps, mode, lanes, key);
case REACT_SUSPENSE_LIST_TYPE:
return createFiberFromSuspenseList(pendingProps, mode, lanes, key);
case REACT_OFFSCREEN_TYPE:
return createFiberFromOffscreen(pendingProps, mode, lanes, key);
case REACT_LEGACY_HIDDEN_TYPE:
if (enableLegacyHidden) {
return createFiberFromLegacyHidden(pendingProps, mode, lanes, key);
}
case REACT_VIEW_TRANSITION_TYPE:
if (enableViewTransition) {
return createFiberFromViewTransition(pendingProps, mode, lanes, key);
}
case REACT_SCOPE_TYPE:
if (enableScopeAPI) {
return createFiberFromScope(type, pendingProps, mode, lanes, key);
}
case REACT_TRACING_MARKER_TYPE:
if (enableTransitionTracing) {
return createFiberFromTracingMarker(pendingProps, mode, lanes, key);
}
default: {
if (typeof type === 'object' && type !== null) {
switch (type.$$typeof) {
case REACT_PROVIDER_TYPE:
if (!enableRenderableContext) {
fiberTag = ContextProvider;
break getTag;
}
case REACT_CONTEXT_TYPE:
if (enableRenderableContext) {
fiberTag = ContextProvider;
break getTag;
} else {
fiberTag = ContextConsumer;
break getTag;
}
case REACT_CONSUMER_TYPE:
if (enableRenderableContext) {
fiberTag = ContextConsumer;
break getTag;
}
case REACT_FORWARD_REF_TYPE:
fiberTag = ForwardRef;
break getTag;
case REACT_MEMO_TYPE:
fiberTag = MemoComponent;
break getTag;
case REACT_LAZY_TYPE:
fiberTag = LazyComponent;
resolvedType = null;
break getTag;
}
}
let info = '';
let typeString;
fiberTag = Throw;
pendingProps = new Error(
'Element type is invalid: expected a string (for built-in ' +
'components) or a class/function (for composite components) ' +
`but got: ${typeString}.${info}`,
);
resolvedType = null;
}
}
}
const fiber = createFiber(fiberTag, pendingProps, key, mode);
fiber.elementType = type;
fiber.type = resolvedType;
fiber.lanes = lanes;
return fiber;
}
createFiber, createFiberFromElement, createFiberFromTypeAndProps만 살펴볼 것이다.
createFiber
는 그냥 FiberNode 생성자를 호출해서 return해주는 역할을 한다. 실제 최초 렌더링 시에는 createFiberFromElement가 호출되고 그 안에서 createFiberFromTypeAndProps를 호출하고 이 안에서 createFiber를 호출한다.
createFiberFromElement
함수는 createFiberFromTypeAndProps
를 호출할 때, element의 타입과 키, pendingProps를 추출해서 전달한다.createFiberFromTypeAndProps
에서는 전달 받은 타입과 속성들에 따라 tag를 결정하는 로직을 가지고 있다. 그리고 마지막에는 결정한 정보를 바탕으로 createFiber
를 호출해서 생성한 Fiber 노드를 return한다.jsx 함수를 통해 리액트 엘리먼트가 만들어지면 다음과 같은 구조일 것이다.
const element = {
type: 'div',
props: { className: 'container', children: [] },
key: 'unique-key',
...
};
위 엘리먼트를 바로 createFiber를 호출해 Fiber 노드를 생성하려하면 tag를 직접 지정해서 호출해야한다. 즉, 어떤 태그인지 호출할 때 알아야하는 것이다.
그러나 createFiberFromElement는 위 엘리먼트를 전달해서 createFiberFromTypeAndProps를 호출해서 어떤 tag인지 정해준다. 함수를 이렇게 다 분리해둔 이유는 단일 책임 원칙을 따르기 위해서다.
이전 리액트에서는 스택 조정자
로, 하나의 스택에 렌더링에 필요한 작업이 쌓이면 이 스택에서 꺼내 동기적으로 작업이 이루어졌다. 대규모 컴포넌트 트리 조정 작업이 시작되면 이 작업이 완료될 때까지 메인 스레드가 차단되었다. 이로 인해서 프레임 드랍이 발생하고 사용자 입력에 대한 반응이 지연되었다.
프레임 드랍이란?
특정 순간에 I/O나 리소스 로드, 연산 처리 등으로 인해 평균 프레임 이하로 떨어져서 끊기는 듯한 현상을 말한다. 예를 들어 60fps는 1초에 60장의 정지 화면을 보여주는데 만약 위와 같은 이유로 평균 프레임 이하로 떨어져서 60장보다 적은 화면을 보여주게 되면 사용자는 화면이 끊기는 현상을 느끼게 된다.
이전에는 재귀적으로 처리해서 콜 스택이 비워질 때까지(모든 하위 컴포넌트를 처리할 때까지) 중간에 멈출 수 없었다. Fiber는 이 문제를 해결하기 위해 작업을 나중에 이어할 수 있는 자체 스케줄러를 사용한다.
이전에는 에러가 발생하면 한 컴포넌트의 에러가 전체 애플리케이션을 중단시켰다. 그러나 Fiber 아키텍처에서는 각 노드가 자체적 에러 상태를 추적할 수 있다. Fiber는 각 노드의 상태와 생명주기를 독립적으로 관리하기 때문에 가능하다.
리액트 Fiber 아키텍처에서는 작업을 작은 단위로 나누어 처리한다고 했다. 이 과정을 좀 더 자세히 알아보았다.
연결리스트
로 변환한다.이전 리액트에서는 스택 조정자 렌더링 시스템으로, 컴포넌트 트리를 순회할 때 각 컴포넌트 처리를 콜 스택에 추가했고 하위 컴포넌트가 처리될 때까지 이 프로세스는 계속 되었다.
이 방식은 렌더링 프로세스를 중단할 수 없었고, 브라우저 메인 스레드를 차단하는 방식이었기 때문에 사용자 입력이나 애니메이션에 즉각적 반응을 할 수 없게 만들었다.
Fiber 아키텍처가 도입된 리액트의 렌더링 과정은 어떻게 변화했을까? 이에 대해서도 공부해보았다.
렌더링은 리액트 컴포넌트의 현재 속성(props)과 상태(state)를 기반으로 UI를 계산하고 업데이트하는 전체 프로세스를 의미한다.
렌더링의 과정은 크게 3단계로 이루어진다.
초기 마운트
나 상태 업데이트
와 같은 이벤트에 의해 렌더링이 필요하다고 인식한다.
리액트가 컴포넌트를 호출해서 변경사항을 계산
한다. 이때 가상 DOM을 사용하여 실제 DOM에 적용할 변경사항을 결정한다. 이 단계는 비동기적으로 처리될 수 있고, 리액트는 필요한 경우 이 작업을 일시 중단하거나 폐기할 수 있다.
계산된 변경사항을 실제 DOM에 적용
한다. 이 단계는 동기적으로 처리되어 사용자 인터페이스의 일관성을 보장한다. DOM 업데이트가 완료된 후에는 useLayoutEffect와 같은 동기적 효과가 실행되고, 이어서 useEffect와 같은 비동기적 효과가 처리된다.
더 자세한 내용은 아래에 정리해두었다.
리액트에서는 2개의 트리를 사용하여 이중 버퍼링 기법을 구현한다.
current 트리
는 현재 화면에 보이는 UI를 나타내는 Fiber 트리다.workInProgress 트리
는 업데이트를 준비하는 작업용 트리다. 새로운 변경 사항이 생기면 workInProgress 트리에 변경 사항을 반영하고, 업데이트가 끝나면 current 트리와 교체한다. 이렇게 구현하여 사용자는 불완전한 UI를 보지 않게 된다.
리액트 렌더링 과정은 초기 렌더링과 리렌더링으로 나누어볼 수 있다. 초기 렌더링은 아무것도 없는 상태이기 때문에 트리를 생성하는 과정이지만, 리렌더링은 상태가 변경되어 변경된 부분을 찾아 그 부분만 업데이트하는 과정이다.
ReactDOM.createRoot(container).render(<App />);
위 코드가 호출되면 리액트는 애플리케이션의 기반 구조를 설정한다. 이때 2개의 중요한 객체가 생성된다.
FiberRoot
노드를 생성한다. HostRoot Fiber
노드가 생성된다.[DOM 레벨]
div#root (containerInfo)
│
[React 레벨]
FiberRoot
│
├─ current → HostRootFiber
│ │
│ ├─ stateNode → FiberRoot 참조
│ │
│ └─ child → AppFiber
│ │
└─ finishedWork └─ 하위 컴포넌트
FiberRoot
리액트 인스턴스 전체를 관리하는 컨테이너다. 렌더링 스케줄링과 우선순위를 관리하고, 현재 화면에 보이는 트리와 작업 중인 트리인 current 트리와 workInProgress 트리를 추적한다. 전역적인 상태 관리를 한다.
HostRootFiber
실제 렌더링 트리의 시작점이다. 첫 번째 실제 Fiber 노드로, 컴포넌트의 부모 역할을 하며, 업데이트의 시작점이다.
render()가 호출되면 리액트는 렌더링 프로세스를 시작한다.
이때 2가지의 중요한 작업이 이루어진다.
동기 모드
로 실행할지 동시 모드
로 실행할지 결정한다.workInProgress
트리 생성을 시작한다.renderRootSync와 renderRootConcurrent
리액트 렌더링 모드를 나타내는데,Sync 모드
는 전통적 방식으로 렌더링이 시작되면 완료될 때까지 중단 없이 진행된다.Concurrent 모드
는 렌더링 작업을 작은 단위로 나누어 처리하고 우선순위가 높은 작업이 들어오면 현재 렌더링을 잠시 중단하고 나중에 재개할 수 있다.
beginWork
단계에서 리액트는 컴포넌트 트리를 위에서 아래로 순회하며 각 컴포넌트를 처리한다.
각 컴포넌트에 대해서
이미 생성된 workInProgress 트리의 노드를 처리하는 함수다. 트리를 순회하며 각 노드의 자식을 처리하고 필요한 업데이트를 수행한다. 렌더링 로직을 실행하고 자식 Fiber 노드를 생성하거나 업데이트한다.
completeWork
단계에서는 실제 DOM 업데이트를 위한 준비가 이루어진다.
completeWork
단계에서는 DOM 업데이트 준비를 하는데, 실제 DOM을 수정하는 것이 아닌 커밋 단계에서 필요한 모든 정보를 수집한다.
- Fiber 노드에 해당하는 DOM 노드의 인스턴스를 생성하고 Fiber 노드의
stateNode
속성에 저장한다.- DOM 노드에 필요한
속성
인 className, style, 이벤트 리스너 등을 계산한다.- 필요한 DOM 조작을 나타내는 Update, Delete와 같은
flags
를 설정한다.
beginWork와 completeWork의 순서
beginWork
는 트리를 아래로 순회하며 컴포넌트 변경사항을 계산한다. 아래로 순회하는 이유는 부모 컴포넌트의 변경이 자식 컴포넌트에 어떤 영향을 미치는지 파악하기 위해서다.
completeWork
는 트리를 위로 올라가며 실행된다. 이는 자식 컴포넌트 변경사항이 모두 처리된 후 부모 컴포넌트의 DOM 업데이트를 준비하는 것이 효율적이기 때문이다.
- 한 번에 하나의 경로만 메모리에 유지하며, 효율적으로 메모리를 사용한다.
- 부모에서 자식으로 데이터 흐름을 보장한다.
- 변경사항을 배치로 처리하여 최적화된 DOM 업데이트가 가능하다.
commitRoot
단계에서는 모든 준비된 변경사항이 실제 DOM에 적용된다.
리액트의 리렌더링
은 아래와 같은 이유로 발생한다.
useState를 사용해 상태를 변경하거나, 부모 컴포넌트로부터 새로운 props를 받거나 상위 컴포넌트의 리렌더링 등의 이유가 있다.
리액트는 발생한 업데이트를 즉시 처리하지 않고, 우선 업데이트 큐
에 추가한다. 이때 각 업데이트에는 우선순위가 할당된다. 예를 들어 사용자 입력에 의한 업데이트는 높은 우선순위를, 데이터 가져오기 같은 백그라운드 작업은 낮은 우선순위를 받게 된다.
이때 진행되는 일을 정리하면 아래와 같다.
현재 화면에 표시되는 UI를 나타내는 current 트리
가 이미 존재하므로, 리액트는 이 트리를 기반으로 새로운 workInProgress 트리
를 생성한다.
완전히 새로운 트리를 만드는게 아니라 current 트리를 복제하여 시작한다.
beginWork
단계에서 리액트는 트리를 순회하며 각 컴포넌트의 변경사항을 확인한다.
Diffing 알고리즘
을 사용하여 최소한의 필요한 업데이트만을 계산한다. 예를 들어 props가 변경되지 않은 컴포넌트는 기존 결과를 재사용한다.
completeWork
단계에서는 실제 DOM 업데이트를 위한 준비가 이루어진다.
flags
가 설정된다.Fiber 노드에 대해서 노드를 새로 생성해야하는지, 업데이트해야 하는지, 삭제해야 하는지 등을
flags
에 설정한다.
commitRoot
단계에서 모든 준비된 사항이 실제 DOM에 적용된다. 이전 단계에서 설정된 flags를 기반으로 필요한 DOM 업데이트만 수행된다.
또한 useEffect와 같은 효과들도 이 단계에서 필요한 경우에만 실행된다.
위 페이지는 좋아요 버튼을 누르면 좋아하는 사람 옆의 숫자가 올라간다.
위 예시에서 초기 마운트 시 트리는 어떻게 구성되는지, 그리고 버튼을 누르면 리렌더링은 어떤 컴포넌트에 발생하는지 알아보았다. 코드는 아래와 같다.
export default function App() {
const [count, setCount] = useState(0);
return (
<div className="App">
<Title />
<Content count={count} />
<LikeButton setCount={setCount} />
</div>
);
}
Title
컴포넌트는 리액트 렌더링 알아보기 텍스트만 담겨있다.Content
에는 떡볶이 좋아하는 사람 텍스트와 좋아요 개수 정보를 담고 있다.LikeButton
은 좋아요를 누르면 setCount를 통해 count를 증가시킨다.왼쪽의 트리는 위에 작성한 코드를 트리 구조로 그렸다.
createRoot
가 호출된다.FiberRoot
객체와 HostRootFiber
가 생성된다.render
함수가 호출되면 리액트는 전달 받은 App 컴포넌트를 기반으로 새로운 Fiber 노드를 생성한다. render 함수가 호출되며 렌더링이 시작되고 App 컴포넌트를 기반으로 새로운 Fiber 노드가 생성되고 연결된다. 이때부터 HostRootFiber에 대한
beginWork
가 실행된다.
beginWork
과정에서 함수 컴포넌트가 실행된다.useState
훅이 호출되며 초기화가 이루어진다.Fiber 노드에는
memoizedState
라는 속성을 가지고 있는데, 이 속성에 훅의 상태가 저장된다. useState가 호출될 때 초기값을 memoizedState에 저장하고 useState는 상태 값과 상태를 업데이트하는 함수를 반환한다.
이런 초기화 과정은 초기 렌더링 시에만 발생하고 이후 리렌더링 시에는 memoizedState에 저장된 값을 사용한다.
beginWork
를 자식이 없을 때까지 내려가서 실행하고, 더 이상 자식이 없다면 completeWork
를 실행한다.
beginWork
는 내려가며 각 노드를 방문하면서 해당 컴포넌트 렌더링 로직을 실행하고, 자식 컴포넌트의 Fiber 노드를 생성하고 업데이트한다. 이 과정은 자식이 없는 노드 즉, 트리의 가장 끝 노드에 도달할 때까지 계속된다.
가장 끝 노드에 도달하면 리액트는completeWork
단계로 전환해서 올라가며 해당 노드에 필요한 DOM 업데이트 준비를 한다.
completeWork
가 끝나면 Title 컴포넌트의 형제 노드
인 Content 컴포넌트로 이동해서 beginWork
를 시작한다.beginWork
를 수행하고, completeWork
를 하며 트리 위로 올라온다.completeWork
를 수행한다.HostRootFiber까지 completeWork를 완료하게되면?
workInProgress 트리를 FiberRoot의
finishedWork
속성에 할당한다.
finishedWork는 작업이 완료된 workInProgress 트리다.
이는 렌더 단계에서 만들어진 새로운 Fiber 트리가 이제 커밋될 준비가 되었음을 나타낸다.
위 과정까지가 렌더 단계(Render Phase)
였고, 리액트는 이제 commitRoot
함수를 호출해 커밋 단계(Commit Phase)
를 시작한다.
이때 finishedWork
트리를 사용하여 3가지 단계를 수행한다.
export default function App() {
const [count, setCount] = useState(0);
return (
<div className="App">
<Title />
<Content count={count} />
<LikeButton setCount={setCount} />
</div>
);
}
레이아웃 효과
는 커밋 단계 마지막 부분에서 처리된다. DOM이 업데이트 되고, 그 다음 ref가 설정되고 마지막으로 useLayoutEffect 콜백이 실행된다. 이 순서는 레이아웃 관련 코드(DOM의 레이아웃인 배치나 크기, 위치와 관련된 계산이나 조작을 수행하는 코드)가 최신 DOM 상태에 접근할 수 있도록 보장한다.
커밋이 완료되면 finishedWork는 새로운 current 트리가 되고, 이전의 current 트리는 다음 업데이트를 위해 보관된다.
이제 위에 작성했던 코드로 리렌더링을 살펴볼 생각이다.
여기서 좋아요 버튼을 누르면 리렌더링되는 컴포넌트는 어떤 컴포넌트일까? 직접 코드를 실행해서 리렌더링 되는 부분이 어딘지 살펴보았다.
정답은 모든 컴포넌트가 리렌더링된다.
왜 모든 컴포넌트가 리렌더링되는지 정리해보았다.
Title, LikeButton 컴포넌트는 리렌더링을 막을 수 있지 않을까?
Title, LikeButton 변경되는 상태가 없는데 부모가 리렌더링되기 때문에 리렌더링된다. 이를 막기 위해서 대표적으로는 React.memo를 사용한다. props가 변경되지 않은 경우 컴포넌트 리렌더링을 건너뛸 수 있는 것이다.
React.memo를 사용하지 않고 위 코드에서 리렌더링을 막는 방법은 또 없을까?
상태를 필요한 곳에 가깝게 위치시키는 것이다.
Content
는 count를 props로 전달받아 리렌더링되고, LikeButton
도 부모인 CountSection이 리렌더링되므로 리렌더링 된다.React.memo
를 사용해서 props 변경이 없을 때 리렌더링을 막아주도록 한다. 📌
React.memo
,useCallback
을 사용해서 리렌더링을 막는게 항상 최선의 결과를 보장하는 것은 아니다. 무분별한 사용은 오히려 성능 저하를 일으킬 수 있다.
React.memo
는 이전 props와 새로운 props를 비교하는 작업이 필요하고,useCallback
은 함수를 메모이제이션하기 때문에 메모리를 사용한다.
- 컴포넌트가 자주 리렌더링되거나 props 비교가 복잡한 객체는 최적화가 오히려 성능 저하를 일으킬 수 있다.
- 작은 컴포넌트나 간단한 렌더링 로직의 경우는, 리액트의 기본적인 렌더링 성능이 충분히 최적화되어 있기 때문에 오히려 복잡성만 증가시킬 수 있다.
효과적인 최적화를 위해서는 성능 문제가 실제로 존재하는지 확인 후, React DevTools의 Profiler를 사용해서 렌더링 성능을 측정하고, 실제 최적화가 필요한 부분을 식별하는게 중요하다.
렌더링 최적화는 간단하게만 이렇게 정리하고 이제 기존 코드의 리렌더링 과정을 살펴볼 생각이다.
이전에 초기 렌더링을 통해 트리를 생성했고, current 트리는 위와 같다.
사용자가 좋아요 버튼을 눌렀을 때 리렌더링 과정은 어떻게 될까?
setCount
가 호출된다.업데이트 큐
에 추가하고 우선순위를 설정한다.workInProgress 트리
를 생성한다.beginWork
를 실행한다. 초기 렌더링에서 봤던 순서대로 진행된다.Fiber 노드의 memoizedState에 상태 값을 저장한다고 했다. 그렇다면 setCount를 호출해서 count 값을 변경한다면 이 값은 업데이트 큐에 저장하고 새로운 상태 값을 계산해 workInProgress Fiber 노드의 memoizedState에 저장한다.
커밋 단계
가 시작된다.커밋 단계는 초기 렌더링 과정에서 봤듯이 변경된 사항을 실제 DOM에 업데이트하는 과정으로, 초기 렌더링 과정에 적어둔 것과 같아 생략했다.
props나 상태 변경
이 있는지 확인하고 이에 따라 리렌더링 여부를 결정한다. Fiber
는 리액트 16에서 도입된 재조정 엔진으로, 각 리액트 컴포넌트의 작업 단위를 나타내는 자바스크립트 객체다.
렌더링
은 리액트 컴포넌트의 속성과 상태를 기반으로 UI를 계산하고 업데이트하는 과정이다. 초기 마운트 시와 상태 업데이트와 같은 이벤트에 의해 렌더링이 발생하게 된다.
리액트에서는 현재 UI를 표시해주는 current 트리
와 작업을 진행하는 workInProgress 트리
2가지를 사용한다. workInProgress 트리에 변경 사항을 반영해 사용자가 불완전한 UI를 보지 않도록 한다.
기가막히게 잘 정리된 글이네요 잘 보고 갑니다