이 글은 hr][When Does React Render Your Component?
라는 기사를 참고했습니다.
컴포넌트 렌더링
React는 컴포넌트를 정확히 언제, 왜 렌더링하는 걸까?
React는 우선 다음과 같은 상황일 경우에 컴포넌트를 렌더링하게 됩니다.
- 컴포넌트에 예정된 상태 업데이트가 있는 경우
- 컴포넌트에서 사용된 커스텀 훅의 예정된 업데이트가 있는 경우도 포함
- 부모 컴포넌트가 렌더링되고, 리렌더링에서 제외되는 기준에 충족하지 않을 경우(단, 제외되는 기준은 다음의 네 가지 조건을 모두 동시에 충족해야 함)
- 컴포넌트가 이전에 렌더링, 즉 이미 마운트 되어있어야 한다.
- 변경된 props(참조)가 없어야 한다.
- 컴포넌트에서 사용하고 있는 context 값이 변경되지 않아야 한다.
- 컴포넌트에 예정된 상태 업데이트가 없어야 한다.
리렌더링 확인을 위한 흐름도
React는 성능 문제가 발생할 때까지 불필요한 리렌더링에 대해서는 걱정할 필요가 없습니다. 하지만 만약 성능 문제가 발생한 경우라면 다음의 흐름도를 참고하여 어떤 해결책을 적용해야 할지 선택할 수 있습니다.
렌더링과 업데이트
React의 컴포넌트란 “UI에서 업데이트를 예약할 수 있도록 React에 의해 강화된 함수”를 의미하는데, 컴포넌트는 스스로의 상태를 능동적으로 변화시킨 것이든, 다른 변화로 인한 것이든 상관없이 React에 의해 호출됩니다.
React의 핵심 디자인 원칙 중 하나로서, React는 UI 스케줄링 및 업데이트를 완전히 제어할 수 있는데, 이는 몇 가지 의미를 갖습니다.
- 컴포넌트가 만든 하나의 상태 업데이트가 반드시 하나의 렌더링, 즉 React에 의한 한 번의 컴포넌트 호출로 변환되는 것은 아니며, 그 이유는 다음과 같다.
- React는 컴포넌트의 상태에 의미 있는 변화가 없다고 생각할 수 있다.(object.is에 의해 결정)
- React는 상태 업데이트를 하나의 렌더 패스로 일괄 처리하려고 하지만, React는
promise
가resolve
되는 타이밍을 제어할 수 없기 때문에promise
에서의 상태 업데이트를 일괄 처리할 수 없으며,setTimeout
,setInterval
및requestAnimatonFrame
과 같이 별도의 이벤트 루프 콜 스택에서 실행되는 네이티브 이벤트 핸들러도 마찬가지이다. - React는 여러 렌더 패스로 작업을 분할할 수도 있다.
- React는 다양한 이유로 컴포넌트를 렌더링(함수 호출) 할 수 있기 때문에 컴포넌트를 한 번 렌더링 하는 것이 UI의 시각적 업데이트로 반드시 변환되지는 않는다.
React 17에서 일부 상태 업데이트는 일괄 처리를 할 수 없습니다.
React 17에서는 promise
에서의 업데이트 같은 일부 상태 업데이트는 일괄 처리할 수 없는데, React는 promise
가 resolve
되는 타이밍을 제어할 수 없기 때문입니다. 같은 맥락에서, 훨씬 나중에 완전히 별도의 이벤트 루프 호출 스택에서 실행되는 네이티브 이벤트 핸들러인 setTimeout
, setInterval
, requestAnimatonFrame
도 마찬가지입니다. 하지만 React 18에서는 모든 상태 업데이트를 자동 일괄 처리할 수 있습니다.
물론 React가 렌더링을 제어할 수 있다고 해서 컴포넌트를 렌더링 하는 시기나 이유에 대해 신경 쓰지 않아도 된다는 것은 아닙니다. React에 모든 것을 의존하는 대신 React가 컴포넌트를 렌더링 하는데 사용하는 기본 메커니즘을 이해한다면 성능 문제에 직면했을 때 쉽게 해결할 수 있습니다.
업데이트란?
“렌더링”이라는 용어와 함게 “업데이트”라는 용어 또한 많이 사용되는데, 여기서의 “업데이트”는 맥락에 따라서 의미가 달라질 수 있습니다.
예를 들어, “컴포넌트가 업데이트를 예약한다”라고 했을 때의 업데이트는 컴포넌트가 자체의 상태를 변경하고 React에 변경 내용을 UI에 반영하도록 요청함을 의미합니다. 여기서 업데이트는 React가 컴포넌트를 렌더링(호출) 하는 이유인데, React가 컴포넌트를 렌더링 할지 여부, React가 컴포넌트를 렌더링 하기로 결정한 횟수 그리고 렌더링 하기로 결정하는 데 발생한 지연 시간은 다양한 요인에 따라 달라질 수 있습니다.
또 다른 예로, “React가 UI를 업데이트한다”라고 했을 때의 업데이트는 React가 기존 DOM 노드를 변환하거나 DOM 트리의 내부 표현과 일치하도록 새로운 DOM 노드를 생성한다는 의미인데, 여기서의 업데이트는 컴포넌트를 렌더링 한 결과를 의미합니다.
React의 렌더링 프로세스
컴포넌트는 두 가지 유형의 렌더링이 발생할 수 있는데, 바로 능동적 렌더링과 수동적 렌더링으로 다음과 같이 정의할 수 있습니다.
- 능동적 렌더링은 컴포넌트 혹은 컴포넌트에서 사용한 커스텀 훅이 능동적으로 상태를 변경하기 위한 업데이트를 예약하고,
ReactDOM.render
를 직접 호출한다. - 수동적 렌더링은 부모 컴포넌트가 상태 업데이트를 예약하는데, 컴포넌트가 렌더링 제외 기준을 충족하지 않는다.
능동적 렌더링
능동적 렌더링이란 컴포넌트 자체 또는 컴포넌트가 사용하는 커스텀 훅이 업데이트를 예약하기 위해 능동적으로 상태를 변경하는 것을 의미합니다.
- 클래스 컴포넌트를 사용하는 경우,
Component.prototype.setState
(즉,this.setState
) - 함수형 컴포넌트를 사용하는 경우, 훅에 의해 발생한
dispatchAction
으로,useReducer
훅의dispatch
함수와useState
훅의 상태 업데이트 함수는 모두dispatchAction
을 사용한다.
업데이트를 능동적으로 예약하는 또 다른 방법은, ReactDOM.render
를 직접 호출하는 것인데, React 공식 문서에서 다음과 같은 예제를 볼 수 있습니다.
function tick() { const element = ( <div> <h1>Hello, world!</h1> <h2>It is {new Date().toLocaleTimeString()}.</h2> </div> ); ReactDOM.render(element, document.getElementById("root")); } setInterval(tick, 1000);
렌더링 단계에 대한 구현 상세
업데이트를 예약하기 위해 어떤 함수를 사용했는지에 관계없이 모든 함수는 재조정을 담당하는 reconciler
에서 scheduleUpdateOnFiber
를 사용하는데, 이름을 통해 알 수 있듯이 Fiber
의 업데이트를 예약합니다.
Fiber
는 React 16에서 도입되었는데, 새로운 조정 알고리즘이자 React 내부의 작업 단위를 나타내는 새로운 데이터 구조입니다. Reconciler
에 의해 ReactElement
에서 fiber
노드가 생성되는데, 일반적으로 모든 ReactElement
는 해당하는 fiber
노드가 있지만 몇 가지 예외가 있습니다. 예를 들면, Fragment
타입의 ReactElement
는 해당하는 fiber
노드가 없습니다.
Fiber
노드와 ReactElement
의 한 가지 중요한 차이점은 ReactElement
는 변경이 불가능하기 때문에 항상 다시 생성되는 반면, fiber
노드는 변경이 가능하고 재사용할 수 있습니다. 즉 React는 컴포넌트를 렌더링에서 제외할 때 새로운 노드를 만드는 대신 구성하고 있는 fiber
트리에서 현재 해당하는 fiber
노드를 재사용할 수 있는 것이죠.
수동적 렌더링
수동적 렌더링은 React가 일부 부모 컴포넌트를 렌더링 하고 컴포넌트가 렌더링 제외 기준을 충족하지 않았을 때 발생합니다.
function Parent() { return ( <div> <Child /> </div> ); }
위 코드에서 부모 컴포넌트가 React에 의해 렌더링 되면, 자식 컴포넌트는 props
에 참조 또는 아이덴디티가 변경된 것 외에 의미 있는 변경사항이 없더라도 렌더링이 되는데, 렌더링 단계에서 React는 재귀적으로 컴포넌트 트리를 탐색하여 컴포넌트를 렌더링하기 때문에, 만약 자식 컴포넌트에게 또 다른 자식 컴포넌트가 있다면 해당 자식 컴포넌트도 렌더링됩니다.
function Child() { return <GrandChild />; // 만약 `Child` 가 렌더링 되면 `GrandChild`도 렌더링 됨 }
그렇지만 컴포넌트 중 하나가 렌더링 제외 기준을 충족하는 경우 React는 해당 컴포넌트를 렌더링 하지 않습니다.
렌더링 제외 기준 예시
모든 자식 컴포너트가 동일하게 만들어지지 않은 경우
default function App() { return ( <Parent lastChild={<ChildC />}> <ChildB /> </Parent> ); } function Parent({ children, lastChild }) { return ( <div className="parent"> <ChildA /> {children} {lastChild} </div> ); } function ChildA() { return <div className="childA"></div>; } function ChildB() { return <div className="childB"></div>; } function ChildC() { return <div className="childC"></div>; }
위와 같은 코드에서 만약 Parent
의 업데이트가 예정되어 있다면, 어떤 컴포넌트가 리렌더링 될까요? 당연히 Parent
자체는 업데이트를 예약한 컴포넌트이기 때문에 React에 의해 리렌더링 되겠지만, 자식 컴포넌트들인 ChildA
, ChildB
, ChildC
도 리렌더링 되는 걸까요?
이를 확인하기 위해 우선 다음과 같이 setInterval
을 통해 일정 간격으로 리렌더링을 예약하는 useForceRender
훅을 만들고,
function useForceRender(interval) { const render = useReducer(() => ({}))[1]; useEffect(() => { const id = setInterval(render, interval); return () => clearInterval(id); }, [interval]); }
이 훅을 부모 컴포넌트 안에서 사용했을 때, 어떤 자식 컴포넌트가 리렌더링 되는지 확인해 볼 수 있습니다.
function Parent({ children, lastChild }) { useForceRender(2000); console.log("Parent is rendered"); return ( <div className="parent"> <ChildA /> {children} {lastChild} </div> ); }
ChildA
는 리렌더링 되었는데, 이는 부모 컴포넌트가 업데이트를 예약하고 리렌더링 되었다는 것을 알고 있기 때문입니다. 그렇지만 ChildA와 달리 ChildB
와 ChildC
는 리렌더링 되지 않습니다. 그 이유는 ChildB
와 ChildC
가 렌더링 제외 기준을 충족해서 React가 렌더링을 건너 뛰었기 때문입니다.
provider가 렌더링 될 때마다 렌더링 되는 context consumer
수동적 렌더링은 컴포넌트가 context consumer
일 때에도 발생할 수 있는데, 앞의 예제 코드를 조금 바꿔서 다음과 같이 Parent
컴포넌트를 context provider
로, ChildC를 context consumer
로 만들어 보면,
const Context = createContext(); export default function App() { return ( <Parent lastChild={<ChildC />}> <ChildB /> </Parent> ); } function Parent({ children, lastChild }) { useForceRender(2000); const contextValue = {}; console.log("Parent is rendered"); return ( <div className="parent"> <Context.Provider value={contextValue}> <ChildA /> {children} {lastChild} </Context.Provider> </div> ); } function ChildA() { console.log("ChildA is rendered"); return <div className="childA"></div>; } function ChildB() { console.log("ChildB is rendered"); return <div className="childB"></div>; } function ChildC() { console.log("ChildC is rendered"); const value = useContext(Context); return <div className="childC"></div>; }
다음과 같은 결과를 확인할 수 있습니다.
위와 같이 React는 Parent
를 렌더링 할 때마다 이전 contextValue
와 다른 참조 값을 갖는 새로운 contextValue
를 생성하는데, 결과적으로 context consumer
인 ChildC
는 다른 context value
를 갖게 되고, React는 변경 사항을 반영하기 위해 ChildC
를 리렌더링하게 됩니다.
만약 contextValue
가 숫자나 문자열 같은 원시 값이면 리렌더링 시 동등성이 변경되지 않기 때문에 ChildC
가 리렌더링 되지 않는다는 것에 주의해야 합니다.
각 컴포넌트 레벨에서 적용되는 렌더링 제외 기준
컴포넌트 중 하나가 렌더링 제외 기준을 충족할 경우 React는 해당 컴포넌트를 렌더링 하지 않습니다. 그렇지만 React는 해당 컴포넌트의 자식 컴포넌트에 업데이트가 필요한지 계속해서 확인을 하는데, 다음 코드의 경우 ChildA
와 ChildB
는 렌더링에서 제외되지만, 이들의 자손 컴포넌트인 ChildC
는 Parent
가 리렌더링 될 때마다 여전히 리렌더링되는 것을 확인할 수 있습니다.
function useForceRender(interval) { const render = useReducer(() => ({}))[1]; useEffect(() => { const id = setInterval(render, interval); return () => clearInterval(id); }, [interval]); } function App() { return ( <Parent> <ChildA /> </Parent> ); } function Parent({ children }) { useForceRender(1000); const contextValue = {}; console.log("Parent is rendered"); return ( <div className="parent"> <Context.Provider value={contextValue}>{children}</Context.Provider> </div> ); } function ChildA() { console.log("ChildA is rendered"); return ( <div className="childA"> <ChildB /> </div> ); } function ChildB() { console.log("ChildB is rendered"); return ( <div className="childB"> <ChildC /> </div> ); } function ChildC() { console.log("ChildC is rendered"); const value = useContext(Context); return <div className="childC"></div>; }
렌더링 제외 기준
실제 렌더링 제외 기준을 알아보고 전에, 런타임 중에 호출 스택을 확인하기 위해 성능 탭에서 앱을 프로파일링 해보는 것이 도움이 될 수 있습니다. 다음의 이미지는 APP
이 처음 마운트 되었을 때의 호출 스택을 캡쳐한 스크린샷입니다.
앱은 ReactDOM.render
에 의해 마운트 되어 scheduleUpdateOnFiber
를 통해 업데이트가 예약되었는데, 이것은 React가 컴포넌트를 처음 렌더링 하는지에 관계 없이 fiber 노드를 업데이트하는 시작 지점입니다.
관련된 세부 사항은 무척 많지만 공통적으로 확인할 수 있는 패턴은 React가 렌더링 하는 모든 컴포넌트는 beginWork
를 호출해야 한다는 겁니다.
관련 소스 코드는 4049라인으로 짜여진 아주 긴 함수인데, 이 함수는 current
, workInProgress
, renderLanes
의 세 가지 인수를 받습니다.
current
는 기존 fiber 노드에 대한 포인터이고, workInProgress
는 업데이트를 반영할 새로운 fiber 노드에 대한 포인터입니다. 이 두 개의 fiber 노드가 각 업데이트에 포함되어 있는 것을 이중 버퍼링이라고 하는데, 이는 체감 성능을 향상시키기 위한 최적화 기법입니다.
이 함수에는 많은 작업이 작성되어 있지만, React의 렌더링 제외 로직을 구현하고 있는 것은 다음 부분이야.
if (!checkScheduledUpdateOrContext(current, renderLanes)) { // 보류 중인 업데이트 또는 context가 없음. 여기서 렌더링을 제외함 workInProgress.lanes = current.lanes; return bailoutOnAlreadyFinishedWork( current, workInProgress, renderLanes, ); }
함수에서 이 라인에 도달하기 위해서는 다음의 조건이 충족되어야 하는데,
- current !== null
- oldProps === newProps
- hasLegacyContextChanged() === false
- hasScheduledUpdateOrContext === false
대략적으로 풀이하면 다음과 같다고 볼 수 있습니다.
- 컴포넌트가 이전에 렌더링, 즉 이미 마운트 됨
- 변경된 props가 없음
- 컴포넌트에서 사용되는 context 값 중 변경된 것이 없음
- 컴포넌트 자체에서 업데이트를 예약하지 않음
위의 4가지 중 첫 번째와 네 번째는 비교적 명확하기 때무에 쉽게 이해할 수 있을 겁니다. 하지만 두 번째와 세 번째 조건에 대해서는 조금 더 살펴보겠습니다.
props를 변경하지 않는 방법
컴포넌트의 props
는 React.createElement
에서 생성한 ReactElement
의 속성으로, ReactElement
는 불변이기 때문에 React가 컴포넌트를 렌더링(호출) 할 때마다 React.createElement
가 호출되어 새 ReactElement
가 생성됩니다.
function Parent() { return ( <div> <Child /> </div> ); }
따라서 컴포넌트의 props
는 리렌더링할 때마다 처음부터 생성되는 것이라고 할 수 있는데, 위 코드의 경우 Parent
에서 반환된 <Child />
는 Babel에 의해 React.createElement(Child, null)
로 컴파일되고 {type: Child, props: {}}
과 같은 형태의 ReactElement
가 생성됩니다.
props는 자바스크립트 객체이기 때문에 다시 생성될 때마다 참조가 변경되는데, React는 기본적으로 ===
를 사용하여 이전의 props
와 현재의 props
를 비교하기 때문에, props
가 리렌더링 되면 다른 값으로 간주됩니다. 그래서 Child
는 props
의 일부로 Parent
로부터 아무것도 받지 않지만, Parent
가 리렌더링 될 때마다 여전히 리렌더링 되고, React.createElement
는 Child
를 위해 호출되어 새로운 props
객체를 만들게 됩니다.
function App() { return ( <Parent> <Child /> </Parent> ); } function Parent({ children }) { return <div>{children}</div>; }
그런데 만약 위와 같이 Child
를 Parent
의 props
로 전달할 수 있다면 어떻게 될까요? 이 경우 React에 의해 Parent
가 렌더링 될 때 Child
에 대한 React.createElement
함수가 호출되지 않습니다. 즉 Child
의 새로운 props
가 생성되지 않고, 이는 위에서 언급한 네 가지 렌더링 제외 기준을 모두 충족시키게 됩니다.
즉 이것이 앞의 예시에서 살펴보았듯이 Parent
가 업데이트를 예약할 때마다 ChildA
만 리렌더링이 된 이유라고 할 수 있습니다.
function Parent({ children, lastChild }) { return ( <div className="parent"> <ChildA /> // ChildA만 리렌더링 됨 {children} // 리렌더링 제외 {lastChild} // 리렌더링 제외 </div> ); }
React가 props 변경을 탐지하는 데 사용하는 규칙 변경 방법
위에서 말했듯이, React는 기본적으로 ===
를 사용하여 이전의 props
와 현재의 props
를 비교하는데, 다행히 React는 컴포넌트를 PureComponent
로 만들어 React.memo
로 감쌀 경우, props
변경을 확인할 수 있는 다른 방법을 제공합니다.
이 경우 React는 ===
를 사용하여 참조가 변경되었는지 확인하는 대신, props
의 모든 property
에 얕은 비교를 수행하는데, 개념적으로는 Object.keys(prevProps).some(key => prevProps[key] !== nextProps[key])
와 유사하다고 볼 수 있습니다.
context 값을 변경하지 않는 방법
컴포넌트가 어떤 context
값의 consumer
인 경우, provider
가 리렌더링 되고 context
값이 변경됐을 때(참조적으로만), 컴포넌트는 리렌더링되기 때문에, 다음의 코드에서도 Parent
가 리렌더링 될 때마다 consumer
인 ChildC
도 리렌더링된다는 것을 알 수 있습니다.
const Context = createContext(); export default function App() { return ( <Parent lastChild={<ChildC />}> <ChildB /> </Parent> ); } function Parent({ children, lastChild }) { useForceRender(2000); const contextValue = {}; console.log("Parent is rendered"); return ( <div className="parent"> <Context.Provider value={contextValue}> <ChildA /> {children} {lastChild} </Context.Provider> </div> ); } function ChildC() { console.log("ChildC is rendered"); const value = useContext(Context); return <div className="childC"></div>; }
물론 위의 코드와 같은 형태도 나쁜 것은 아닙니다. 복합 컴포넌트 패턴은 context consumer
의 렌더링 동작에 의존하는데, 만약 provider
가 너무 많은 consumer
나 리렌더링 하기에 너무 무거운 consumer
가 있는 경우라면 성능 문제가 생길 수 있습니다.
이런 경우 가장 쉬운 해결 방법은 다음과 같이 비-원시 context
값을 useMemo
로 래핑하여 provider
컴포넌트의 리렌더 간에 참조적으로 동일하게 유지하는 겁니다.
function Parent({ children, lastChild }) { const contextValue = {}; const memoizedCxtValue = useMemo(contextValue); return ( <div className="parent"> <Context.Provider value={memoizedCxtValue}> <ChildA /> {children} {lastChild} </Context.Provider> </div> ); }
useMemo
를 사용하여 context
값을 래핑할 필요가 없는 예외가 하나 있는데, consumer
하위 트리가 큰 경우에 성능 최적화 기법으로 useMemo 내부에 context
값을 래핑할 수 있습니다.
그렇지만 여기에는 또 한 가지 예외가 있는데, context provider
컴포넌트가 컴포넌트 트리의 최상단에 있으면 수동 렌더링이 발생할 수 없기 때문에 context
값을 기억할 필요가 없습니다.
const ContextA = createContext(null); const Parent = () => { const [state, dispatch] = useReducer(reducer, initialState); const value = useMemo(() => [state, dispatch], [state]); return ( <ContextA.Provider value={value}> <Child1 /> </ContextA.Provider> ); };
Parent
가 컴포넌트 트리의 최상단에 있는 경우, 즉 다른 부모 컴포넌트가 없는 경우에 React가 Parent
를 리렌더링 하는 유일한 이유는 dispatch
가 호출되었을 때뿐입니다. 이 경우 useMemo
를 통해 적용한 메모이제이션은 어차피 소용이 없는데, 결과적으로 하위 트리가 리렌더링되기 때문에, 따라서 다음과 같이 값을 직접 전달하는 것이 좋습니다.
const ContextA = createContext(null); const Parent = () => { const [state, dispatch] = useReducer(reducerA, initialStateA); return ( <ContextA.Provider value={[state, dispatch]}> <Child1 /> </ContextA.Provider> ); };
마무리
앞에서 살펴본 렌더링 제외는 컴포넌트가 항상 컴포넌트 트리의 같은 위치에 렌더링 된다는 것을 전제로 하고 있는데, 다음과 같은 경우에 React는 전체 하위 트리를 파괴하고 처음부터 다시 빌드를 하게 됩니다. 즉 다음과 같은 경우에는 컴포넌트가 리렌더링 될 뿐만 아니라 해당 상태도 손실될 수 있습니다.
- 동일한 위치에서 다른 컴포넌트 간에 전환
- 같은 컴포넌트를 다른 위치에 렌더링
- 의도적으로 key를 변경
React는 다양한 이유로 컴포넌트를 리렌더링 하는데, UI 엔지니어링에서 가장 어려운 문제 중 두 가지는 앱 상태의 불일치와 부실을 피하는 것이라고 합니다. 결국 React의 리렌더링은 꼭 필요하지만, 과도한 리렌더링으로인해 응답성과 지연이 발생하는 경우에는 어디에서 불필요한 리렌더링이 일어나고 있는지 확인하고 최적화를 해야 할 필요가 있습니다.