웹개발을 하는 경우 배열에 담긴 데이터를 map을 통해 컴포넌트로 반복 열거하는 작업을 매우 흔하게 접할 것이다. 그런데 부모컴포넌트에 스크롤이나 드래그에 관한 이벤트리스너가 있을 경우, 잦은 리렌더링이 일어나며 자식에도 영향을 미치게 된다.
부모인 Draggable 컴포넌트의 좌표값이 변하여 리렌더링되는것은 DnD 구현시에 당연하며 필수불가결하다. 오히려 그래야만이 Draggable 컴포넌트가 마우스를 따라 함께 움직여줄 것이다.
그러나 그 컴포넌트의 자식들은 본인들이 나타내는 내용과 부모로부터 받고 있는 프랍에 하등 차이가 없음에도 다 같이 리렌더링이 되고 만다.
이것을 방지할 수 있는 방법은 없을까.
이런 리렌더링 문제의 근본적 원리와 해결책인 React.memo에 대해 기록한다.
가장 대표적인 리렌더링 요인은 다음 세가지이다.
프랍 변경: 부모 컴포넌트가 자식 컴포넌트에게 전달하는 프랍 값이 변경되면, 자식 컴포넌트는 새로운 프랍 값을 받아들여 업데이트된다.
상태 변경: 부모 또는 자기 자신의 상태가 변경되면, 컴포넌트가 다시 렌더링된다. useState훅을 사용하여 상태를 갱신할 때마다 리렌더링이 트리거 되는 그것이다.
컨텍스트 변경: 컴포넌트가 컨텍스트를 구독하고 있다면, 컨텍스트 값이 변경될 때마다 해당 컴포넌트가 리렌더링될 수 있다.
provider로 감싸주어 값을 제공하는 useContext나 Jotai 같은 것이 그것이다.
자식 컴포넌트는 프랍의 유무에 관계없이 리렌더링이 일어난다.
검증을 위해 실험삼아 프랍을 전부 지우고 콘솔로깅해보아도 "rendered" 콘솔이 마찬가지로 찍히는 것을 볼 수 있었다.
function Parent() {
return (
<div>
<Child /> // 🧐 비교할 프랍이 없는데 왜 리렌더링이 트리거되는거지?
</div>
);
}
그 이유는 Babel의 컴포넌트 생성 원리에 있었다.
Parent에서 반환된 <Child />
는 Babel에 의해 React.createElement(Child, null)
로 컴파일되어져 {type: Child, props: {}}
이러한 형태의 ReactElement로 변환된다. 여기서 JS의 얕은 비교==
와 깊은 비교===
개념이 등장한다.
두 빈 프랍 객체 {}
의 주소값은 서로 다를 것이며 === 깊은 비교연산자를 통해 비교할 경우 결과는 false가 된다. 따라서, 프랍이 없는 컴포넌트라도 {} props로 인해 다른 값으로 간주되어 리렌더링이 트리거되는 것이다.
{children}으로 넘겨지는 자식컴포넌트는 리액트에 의해 부모컴포넌트가 렌더링 될 때 createElement함수가 작동하지 않으면서, {type: Child, props: {}}
생성 자체가 되지 않는다. 따라서 프랍 비교가 발생하지 않기 때문에 리렌더링이 발생하지 않는다.
React.memo가 입구에서 대기하면서 기존에 잡아 둔 샘플과 새로 들어오는 녀석을 얕은 비교를 해보고 "바뀌지 않았다"고 판단해준다. 그렇게 자식컴포넌트는 부모 컴포넌트가 아무리 리렌더링되어도 prop값이 같으면 리렌더링 없이 가만히 있게 된다.
이 개념을 미리 든 예시인 DnD에 적용해 생각해보자.
기존 상황에서 자식 컴포넌트는 프랍 값이 바뀌지 않아보이는데도 무수히 리렌더링이 일어나고 있었다. 디폴트 방식대로 깊은 비교를 하기 때문일 것이다.
React.memo로 감싸주게 되면 캐싱된 프랍값과 얕은 비교를 통해 값이 비슷한지만 따져 동등하다고 판단해준다. 깊은 비교를 하지 않으니 바뀌지 않았다고 판단해주어 리렌더링이 방지된다.
프랍이 없는 자식의 경우에도 빈 프랍객체인 {}
와 그 다음에 올 {}
가 동등하다고 판단해주어 리렌더링이 방지된다.
const Form = React.memo(
({ question, index, focused, setFocused, isLast }: FormProps) => {
// 나머지 로직
useMemo는 계산 비용이 많이 드는 연산의 결과를 캐시하고, 해당 값이 필요할 때마다 새로 계산하는 비용을 절약하기 위해 사용된다. 따라서 프랍을 캐싱하고 비교해주는 React.memo와는 다르다.
const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);
두번 째 매개변수인 dependency 배열에 a나 b를 넣어줌으로써 a나 b가 변경될 때만 computeExpensiveValue가 다시 실행되고, 그 외에는 이전에 계산된 값을 재사용한다. useEffect나 useCallback의 dependency와 흡사하다.
의도했던 대로 자식컴포넌트의 리렌더링이 일어나지 않게 된 모습이다.
이 밖에도 선배분께 컴파운드 컴포넌트를 활용한 최적화 방법이 있다고 소개를 받았다. 한번 토이 프로젝트를 통해 시도해보아야겠다.
레퍼런스:
https://velog.io/@eunbinn/when-does-react-render-your-component#%ED%9D%90%EB%A6%84%EB%8F%84