
React를 사용할 때, 불필요한 렌더링을 방지하는 것이 성능 최적화의 핵심 요소이다.
처음 React를 접하면 state나 props가 변경되면 컴포넌트가 다시 렌더링된다고 배우기는 하지만
실제로는 React가 모든 컴포넌트를 무조건 다시 렌더링하지 않는다.
그럼 React는 어떤 기준으로 컴포넌트를 다시 렌더링하고 생략할까?
이번 글에서는 React의 Fiber 구조를 활용한 렌더링 최적화 원리와 재렌더링 생략 과정에 대해 자세히 알아보고,
이에 대한 이해를 바탕으로 자식 컴포넌트의 불필요한 재렌더링을 방지하는 방법까지 알아보자.
React는 성능 최적화를 위해 모든 컴포넌트를 다시 렌더링하지 않고 특정 기준을 충족하면 렌더링을 건너뛴다.
따라서 React의 내부 동작을 이해하면, 불필요한 렌더링을 방지하는 방법도 알 수 있게된다.
React는 Fiber 트리(Fiber Tree)를 기반으로 "렌더링이 필요한 컴포넌트만 렌더링" 한다.
이를 위해 React는 이전 렌더 트리(current)와 새로운 렌더 트리(workInProgress)를 비교하여 재조정(Reconciliation) 과정을 거친다.
React는 렌더링을 최적화하기 위해 Fiber 아키텍처를 사용한다. (React 18부터)
Fiber는 기존의 동기적 렌더링 방식보다 더 세밀하게 업데이트를 제어할 수 있도록 설계된 엔진이다.
(Fiber 에 대해서는 다음 글에서 좀더 자세히 다루어보도록 하겠다)
Fiber를 통한 React의 렌더링 과정은 크게 두 단계로 나뉜다.
1️⃣ Render Phase (렌더 단계)
beginWork() 실행: 어떤 컴포넌트가 다시 렌더링될지 결정workInProgress 트리에 저장2️⃣ Commit Phase (커밋 단계)
commitWork() 실행: 실제 DOM에 반영이 과정에서 렌더링 최적화는 beginWork() 에서 결정되며, 불필요한 렌더링을 방지하기 위해 bailoutOnAlreadyFinishedWork() 가 실행된다.
React는 렌더링을 최적화하기 위해 두 개의 Fiber 트리를 유지한다.
1️⃣ current (현재 트리)
현재 화면에 렌더링된 Fiber 트리. 즉, 지금 사용자에게 보이는 UI 상태
2️⃣ workInProgress (작업 트리)
새롭게 렌더링할 변경된 Fiber 트리. beginWork() 에서 변경이 필요할 때만 새롭게 생성됨
React는 이 두 트리를 비교하여 변경된 부분만 업데이트하는 Reconciliation(재조정)을 수행한다.
workInProgress를 만들지 않고 current를 그대로 복사 → 렌더링 생략(Bailout)workInProgress에서 수정 후 반영 → 렌더링 진행React는 beginWork() 단계에서 "이 컴포넌트를 다시 렌더링할 필요가 있는가?"를 판단한다.
만약 렌더링이 필요 없다고 판단되면, bailoutOnAlreadyFinishedWork()를 실행하여 기존 Fiber 트리를 그대로 복사하고 렌더링을 생략한다.
function bailoutOnAlreadyFinishedWork(
current: Fiber | null,
workInProgress: Fiber,
renderLanes: Lanes,
): Fiber | null {
cloneChildFibers(current, workInProgress); // 기존 노드를 복사
return workInProgress.child; // 자식 노드를 반환
}
즉, 이전 렌더 트리(current)를 그대로 복사하여 새로운 렌더 트리(workInProgress)에 반영하는 것이다. 이렇게 하면 해당 컴포넌트는 렌더링하지 않고 그대로 유지된다.
렌더링을 생략하기 위해서는 다음 4가지 조건을 만족해야 한다.
1. current !== null (기존에 렌더링된 적이 있어야 한다)
이전 렌더 트리(current)가 존재해야 한다. 즉, 최초 렌더링(Initial Render)에서는 항상 렌더링이 발생한다.
2. oldProps === newProps (Props가 변경되지 않아야 한다)
이전 props와 새로운 props가 같다면, 렌더링을 생략할 수 있다.
하지만 객체나 배열 같은 참조형 데이터는 비교할 때 신경 써야 한다.
props가 새로운 객체를 생성하면 참조가 달라지므로 재렌더링이 발생하기 때문이다.
따라서 불필요한 재렌더링을 방지하려면 useMemo나 useCallback을 활용해야 한다.
3. hasLegacyContextChanged() === false (Context 값이 변경되지 않아야 한다)
만약 useContext()를 사용하고 있고, context의 값이 변경되었다면 렌더링이 발생한다.
따라서, 불필요한 렌더링을 줄이려면 context를 분리하는 것이 좋다.
4. hasScheduledUpdateOrContext === false (업데이트가 예약되지 않아야 한다)
현재 컴포넌트가 업데이트를 예약하지 않았다면 렌더링을 생략할 수 있다.
👉🏻 결론적으로, props만 잘 관리해도 렌더링을 상당히 줄일 수 있다는걸 알 수 있다!
React는 렌더링을 생략하기 위해 beginWork() 함수에서 이전 렌더 트리(current)와 새로운 렌더 트리(workInProgress)의 props를 비교한다.
이 비교는 단순한 일치 연산자(===)를 사용한 얕은 비교(참조 비교) 방식이다.
객체나 배열 같은 참조형 데이터는 값이 아닌 참조(reference) 비교가 이루어진다.
JS에서 객체는 새로운 인스턴스가 생성될 때마다 참조가 달라진다.
부모 컴포넌트가 다시 렌더링되면, 내부적으로 createElement()가 다시 실행되고
이 과정에서 ReactElement(Fiber Node) 인스턴스가 다시 생성되므로,
props는 항상 새로운 객체가 되어 직전 props와의 참조는 당연히 달라진다.
이로 인해 oldProps === newProps 는 false 가 되어 위의 재렌더링 생략 조건 2번을 충족하지 못하므로 부모가 렌더링되면 자식도 함께 렌더링된다.
function Parent() {
return React.createElement(
"div",
{ className: "container" },
React.createElement(Child, null) // Child 컴포넌트도 함께 렌더링됨
);
}
Child 컴포넌트는 자기자신의 변경사항이 없어도 부모의 렌더링때문에 자꾸만 렌더링되어야하는데...
성능 최적화를 위해, 자식컴포넌트의 불필요한 렌더를 방지할 방법은 없을까?
자식 컴포넌트를 직접 JSX로 추가하는 대신, props로 전달하면 참조가 유지되므로 재렌더링을 방지할 수 있다.
각각의 경우에 대해 Parent 컴포넌트의 JSX 트랜스파일 결과를 비교하며 살펴보자.
Parent가 렌더링될 때마다 Child도 함께 렌더링된다.
// Parent
function Parent() {
return React.createElement("div", null, React.createElement(Child, null));
}
children을 그대로 사용하므로, Child 컴포넌트의 참조가 유지되어 Parent가 렌더링되더라도 Child는 렌더링되지 않는다.
부모의 렌더링 과정동안 진행되는 재귀적인 ReactElement 생성 과정에서 자식 컴포넌트가 제외되었기 때문이다.
일반 props로 전달할 경우도 동일한 원리가 적용된다.
function Parent({ children }) {
return <div>{children}</div>;
}
// 사용 예시
<Parent>
<Child />
</Parent>;
// JSX 트랜스파일 결과
function Parent({ children }) {
return React.createElement("div", null, Child);
}
부모 컴포넌트가 자식 컴포넌트에게 props와 children props를 넘겨주는 방식을 통해,
부모의 렌더링이 자식의 렌더링에 영향을 주지 않도록 만들수 있다는 것을 알게 되었다.
근데 현실적으로 성능 최적화를 한답시고 부모 컴포넌트에서 props로 모든 컴포넌트를 제어할 수는 없다.
그래서 React는 렌더링 최적화를 위한 memoization 메서드를 제공한다.
React.memo(Component)는 props가 변하지 않으면 컴포넌트의 재렌더링을 방지하는 고차 컴포넌트(HOC)이다.
내부적으로 props를 얕은 비교하여 변화가 없을 경우(참조가 같을 경우) 이전 렌더링 결과를 재사용한다.
const MemoizedChild = React.memo(Child);
React.memo()는 props가 원시 타입(숫자, 문자열, boolean 등)일 경우 제대로 동작하지만,
객체, 배열, 함수는 새로운 참조(Reference)로 인식되어 재렌더링이 발생할 수 있다.
(React는 기본적으로 컴포넌트 내부에서 정의한 함수와 객체를 매 렌더링마다 새로 정의하기 때문)
그렇다고 렌더링을 최적화하기 위해 모든 props를 원시 타입 값들로만 전달할 수는 없으니,
이를 해결하려면 useMemo() 또는 useCallback()을 함께 사용해야 한다.
useMemo()는 렌더링 간에 계산된 값을 캐싱하여 불필요한 연산을 줄이는 역할을 한다.
주로 객체나 배열을 props로 전달할 때, 참조가 변경되는 문제를 해결하기 위해 사용된다.
const memoizedValue = useMemo(() => expensiveCalculation(), [dependency]);
useCallback()은 리렌더링 간에 함수 정의를 캐싱하여 함수가 렌더링될 때마다 새로 생성되는 문제를 방지하기 위해 사용된다. useMemo()와 유사하지만, 함수를 반환한다는 점이 다르다.
많은 경우에 memoization 없어도 코드는 잘 동작한다.
상호작용이 충분히 빠르면 memoization을 사용하지 않아도 된다!