내용 발췌 : 우아한Tech [10분 테코톡] 엘버의 리액트 렌더링 최적화
브라우저의 렌더링은 무엇인지 먼저 알아보기
1. HTML을 파싱해서 DOM을 만들고 CSS을 파싱해서 CSSOM을 만든다.
2. DOM과 CSSOM으로 Render Tree를 만들어준다.
3. Layout과
4. Paint 과정을 거쳐서
5. 사용자가 화면을 보게 된다
그렇다면 리엑트에서의 렌더링이란?
리액트가 함수를 호출하는 것이다. 예를 들면,
function App() {
const handleClick = () => {
// 로직생략
};
return <h1 onclick={handleClick}>rendering is just calling function</h1>;
}
위의 App 컴포넌트가 실행되면서 내부 로직들이 실행이 되고 리턴문을 통해서 리액트 엘리먼트들을 반환하는 것이다.
예시코드
function Parent() {
const [valueForFirstChild, setValueForFirstChild] = useState(null);
const handleClick = () => {};
useEffect(()=>{
setTimeout(()=>{
setValueForFirstChild('changedValue')
}, 3000)
},[]);
return(
<>
<FirstChild value={valueForFirstChild} />
<SecondChild onClick={handleClick} />
</>
);
}
function FirstChild({ value }) {
return <div>{value}</div>;
}
function SecondChild({ onClick }) {
return (
<div onClick={onClick}>
{Array.from({ length: 1000 }).map((_, idx) => (
<GrandChild key={idx + 1} order={idx} />
))}
</div>
);
}
1. Parent 컴포넌트가 렌더링 되고 난 후, useEffect 내부의 로직들이 실행이 된다.
2. state의 내용이 변경이 되면서, Parent 컴포넌트에는 리렌더링이 일어나게 된다.
3. 리액트의 렌더링이란 함수를 호출하는 것이므로 Parent 컴포넌트가 호출이 되면서 FirstChild 컴포넌트와 SecondChild 컴포넌트도 리렌더링 된다.
4. Parent, FirstChild 컴포넌트의 경우 둘 다 값의 변경이 있으므로 리렌더링이 되는 것이 당연하다.
5. 하지만 SecondChild 컴포넌트는 변경된 값이 없는데 똑같은 정보를 보여주기 위해서 리렌더링이 되는 것은 불필요함.
6. 게다가 SecondChild 컴포넌트들이 리렌더링 되면 1000개의 GrandChild 컴포넌트들이 리렌더링 될 것이다.
Profiler(React Developer Tools), console.log를 이용해서 불필요한 렌더링 발생 지표를 확인한 결과 SecondChild의 1000번째 GrandChild 컴포넌트가 리렌더링 되는 것을 확인 할 수 있다.
그렇다면 SecondChild가 불필요하게 리렌더링 되는 것을 막는 방법은?
먼저 컴포넌트가 리렌더링이 되는 조건은 state가 바뀌었을 때, 또는 props가 바뀌었을 때이다.
그렇다면 Parent 컴포넌트로 부터 state를 props로 전달 받지 않는 SecondChild 컴포넌트가 리렌더링 되는 이유는 무엇인가?
매번 Parent 컴포넌트가 리렌더링이 되면서 handleClick이라는 함수가 재생성되고, 이때 재생성되는 handleClick은 이전과는 서로 다른 참조값을 가지게 되므로 서로 다른 함수이다. (이는 함수가 참조타입의 데이터이기 때문이다.)
따라서 서로 다른 handleClick이라는 함수를 props로 전달받는 SecondChild 컴포넌트가 'props가 바뀌었을 때는 리렌더링이 일어난다'는 조건을 만족하기 때문에 리렌더링이 일어나게 되는 것이다.
그렇다면 함수의 참조값이 바뀌지 않도록 해준다면, SecondChild 컴포넌트의 불필요한 렌더링을 방지해줄 수 있지 않을까?
함수의 참조값이 바뀌지 않게 하는 방법은?
함수를 메모이제이션 해주는 리액트 Hook인 useCallback을 사용하면 된다.
메모이제이션이란?
기존에 수행한 연산의 결과값을 어딘가에 저장 해두고, 필요할때 재사용하는 기법이다. useCallback을 사용하여 함수를 감싸주게 되면, 의존성 배열이 변하지 않는 이상, 컴포넌트가 리렌더링 될 때 마다, 변수에 같은 함수가 할당 된다.
useCallback을 Parent 컴포넌트에 적용해 보면,
function Parent() {
const [valueForFirstChild, setValueForFirstChild] = useState(null);
const handleClick = useCallback(() => {},[]);
useEffect(()=>{
setTimeout(()=>{
setValueForFirstChild('changedValue')
}, 3000)
},[]);
return(
<>
<FirstChild value={valueForFirstChild} />
<SecondChild onClick={handleClick} />
</>
);
}
handleClick을 useCallback으로 감싸주어서 컴포넌트가 리렌더링 되어도 변수는 같은 함수를 참조하게 되었다. 이번에 확인 해 보면 SecondChild는 리렌더링이 일어나지 않을까?
예상과는 달리, Profiler의 결과와 console.log를 통해 보았을 때, SecondChild 컴포넌트는 여전히 리렌더링이 일어난다.
그 이유는 다음과 같다.
Parent 컴포넌트를 Babel로 컴파일하게 되면, 다음과 같은 코드로 변환된다.
function Parent() {
const [valueForFirstChild, setValueForFirstChild] = useState(null);
const handleClick = () => {};
setTimeout(()=>{
setValueForFirstChild('changedValue')
}, 3000)
return React.createElement(
React.Fragment,
null,
React.createElement(FirstChild, {
value: valueForFirstChild,
}),
React.createElement(SecondChild, {
onClick: handleClick,
})
);
}
React.createElement란 새로운 React 컴포넌트를 생성하여 반환해주는것이다.
참고로, 아래의 두 코드는 서로 같은 코드이다. React의 JSX 문법을 활용하여 개발자가 보기 편하게 without JSX의 코드와 동일한 역할을 하는 코드를 작성 해줄수있다.
// without JSX
function TodoList() {
return React.createElement(
React.Fragment,
null,
React.createElement('hi', null, 'TodoList'),
React.createElement(
'ul',
null,
React.createElement('li', null, '1.javascript'),
React.createElement('li', null, '2.typescript'),
React.createElement('li', null, '3.play')
),
React.createElement('footer', null, React.createElement('span', null, 'footer'))
);
}
// with JSX
function TodoList() {
return (
<>
<h1>TodoList</h1>
<ul>
<li>1.javascript</li>
<li>2.typescript</li>
<li>3.play</li>
</ul>
<footer>
<span>footer</span>
</footer>
</>
);
}
다시 돌아와서, Parent 컴포넌트가 리렌더링되면, 내부의 로직들이 다 실행되며, SecondChild에 해당하는 React.createElement가 다시 실행되게 된다. 그래서 useCallback을 사용해서 함수가 같은 값을 메모이제이션하도록 해주었다 하더라도 리렌더링이 일어날 수 밖에 없는것이다.
그렇다고해서 useCallback을 사용했을 때, 렌더링 최적화에 아무 효과가 없는 것일까?
리액트 렌더링의 단계
재조정 과정이 포함된 render phase와 commit phase로 구성된 렌더링 프로세스를 거쳐 렌더링이 된다.
1. Render Phase
컴포넌트(함수)호출 -> React Element를 반환하고 새로운 Virtual Dom을 생성해준다.
이번이 처음 렌더링이 아니라면, 재조정(Reconciliation)의 과정(이전 Virtual Dom과 현재 Virtual Dom을 비교하는 것)을 거친 후, Real Dom에 변경이 필요한 목록을 체크함
2. Commit Phase
Render Phase에서 체크했던 변경이 필요한 Real Dom 목록을 Real Dom에 반영 해 주는 단계이다. 만약 변경이 필요한 부분이 없다면 commit phase는 skip된다.
따라서 useCallback을 사용하면 렌더링 최적화에 효과가 있다!
Render Phase는 실행되지만 useCallback을 활용해서 props 값을 이전과 같이 유지해주었기 때문에 commit phase는 실행하지 않는다. 하지만 여전히 SecondChild 컴포넌트는 불필요한 렌더링을 반복하고있다. 여기서 Render Phase를 막아줄 방법은 없는걸까?
React.memo를 사용하면 된다.
React.memo는 전달받은 props가 이전 props와 비교했을때 같으면 컴포넌트의 리렌더링을 막아주고 마지막에 렌더링된 결과를 재사용하는 고차 컴포넌트이다. React.memo가 props를 비교할때는 기본적으로 얕은 비교를 통해서 비교해준다.
얕은비교란?
원시타입의 데이터의 경우는 값이 다른지 비교하고, 참조타입의 데이터는 참조 값이 같은지를 비교하는 것이다.
function Component({ content }) {
return <p>{content}</p>;
}
export default React.memo(Component);
여기서 두번째 인자로 비교함수를 넣어주게 되면 해당 함수를 활용해서 비교를 해주도록 해줄수도 있다.
React.memo를 SecondChild 컴포넌트에 적용했을때의 모습을 살펴보면,
import GrandChild from './GrandChild';
function SecondChild({ onClick }) {
return (
<div onClick={onclick}>
{Array.from({ length: 1000}).map((_, idx)=>(
<GrandChild key={idx + 1} order={idx} />
))}
</div>
);
}
export default React.memo(SecondChild);
SecondChild 컴포넌트가 렌더링에 진입하기전에, props값인 onClick에 대해서 이전값과 현재값이 다른지를 비교해준다.
서로 같은 값이라면 SecondChild는 렌더링이 진행되지 않는다.
Profiler와 console.log로 확인해보면
SecondChild 컴포넌트가 렌더링 되지 않았기 때문에 자식 컴포넌트들인 GrandChild 컴포넌트들도 렌더링되지 않았음을 Profiler를 통해서 알 수 있다.
이번엔 SecondChild 컴포넌트가 객체를 props로 받게 된다면 어떻게 될까?
function Parent() {
const [valueForFirstChild, setValueForFirstChild] = useState(null);
const item = {
name: 'Thinkpad',
price: '1,000,000',
};
useEffect(() => {
setTimeout(() => {
setValueForFirstChild('changedValue');
}, 3000);
},[]);
return (
<>
<FirstChild value={valueForFirstChild} />
<SecondChild item={item} />
</>
);
}
function SecondChild({ item }) {
return (
<div>
{item.name}
{item.price}
{Array.from({ length: 1000 }).map((_, idx) => (
<GrandChild key={idx + 1} order={idx} />
))}
</div>
);
}
export default React.memo(SecondChild);
같은 상황이지만 props로 넘겨 받는 데이터가 함수에서 변하지않는 item이라는 객체로 바뀌었다. 이런 경우 부모컴포넌트가 리렌더링되어도 SecondChild는 React.memo때문에 리렌더링이 되지 않을까?
아쉽게도 리렌더링은 발생한다.
그 이유는, Parent 컴포넌트가 리렌더링되면서 item이라는 객체가 매번 새로 생성되기 때문에 SecondChild 컴포넌트에게 매번 다른 참조값을 가진 item을 props로 전달받아 React.memo가 정상적으로 작동하지 않는것이다.
이때는, 리액트 Hook인 useMemo를 사용하면 된다.
useCallback은 함수에 대한 메모이제이션을 제공하는 훅이고, useMemo는 값에대한 메모이제이션을 제공하는 훅이다.
function Component() {
const memoizedValue = useMemo(() => getSomevalue(), []);
// 생략
}
의존성 배열에 들어있는 값이 변경되지 않는 이상, 리렌더링 될 때마다, 같은 값을 반환해준다.
Parent 컴포넌트에 코드로 적용했을때도 마찬가지로, 의존성 배열의 값이 바뀌지 않는 이상, 이전에 사용했던 값을 반환하게 된다.
memoized된 item 객체를 SecondChild 컴포넌트에 props로 전달해주면, 매번 새로운 item을 전달해주지 않기때문에 정상적으로 React.memo가 동작하면서 불필요한 렌더링을 방지해 준다.
function Parent() {
const [valueForFirstChild, setValueForFirstChild] = useState(null);
const item = {
name: 'Thinkpad',
price: '1,000,000',
};
const memoizedItem = useMemo(() => item, []);
useEffect(() => {
setTimeout(() => {
setValueForFirstChild('changedValue');
}, 3000);
},[]);
return (
<>
<FirstChild value={valueForFirstChild} />
<SecondChild item={item} />
</>
);
}
useCallback, useMemo, React.memo를 모든 곳에서 사용해주면 좋은걸까?
이들도 하나의 코드이고 내부적으로 특정한 동작을 실행시켜 주어야하기 때문에, 하나 하나가 모두 비용이다. 올바른 곳에 충분히 고민하고 사용해야 한다.
근본적인 코드를 먼저 개선하자
최적화 도구들을 사용하기 전에 근본적인 코드를 먼저 개선하자. 불필요한 렌더링이 발생할 수 밖에 없는 코드에서 근본적인 문제를 해결하지 않은 상태에서 최적화 도구를 사용하는 것은 문제를 더 키우는 것이다.
그럼 어떻게 코드를 작성하라는 걸까? 아래와 같은 예시가 있다
function Component() {
const forceUpdate = useForceUpdate();
return (
<>
<button onClick={forceUpdate}>force</button>
<Consoler value="fixedValue" />
</>
);
}
부모 컴포넌트 Component가 리렌더링이 될 때, Consoler 컴포넌트도 불필요하게리렌더링이 되고있다. 이를 불필요하게 리렌더링이 되지 않도록 리팩토링 해준 코드는 아래와 같음
function Component({ childeren }) {
const forceUpdate = useForceUpdate();
return (
<>
<button onclick={forceUpdate}>force</button>
{children}
</>
);
}
function App() {
<div>
<Component>
<Consoler value="fixedValue" />
</Component>
</div>;
}
// 위 코드를 babel로 변환 시킨 코드는 다음과 같다
function Component({ childeren }) {
const forceUpdate = useForceUpdate();
return React.createElement(
React.Fragment,
null,
React.createElement(
'button',
{
onClick: forceUpdate,
},
'force'
),
children
);
}
예전에는 Consoler컴포넌트를 부모컴포넌트안에서 사용해 주었다면, 지금은 children을 사용해서 Consoler 컴포넌트를 주입해 주었다.
이렇게 컴포넌트를 주입해주면 Component가 리렌더링되더라도 Consoler컴포넌트는 리렌더링되지 않는다.
위 코드를 babel로 변환시킨 아래의 코드에서 return문에는 Consoler 컴포넌트에 대한 React.createElement가 존재하지 않기 때문에,
부모컴포넌트가 호출 되더라도 Consoler 컴포넌트는 호출되지 않는다. 그러니,
최적화가 필요할 때만 최적화를 하자
일단 이해가 쏙쏙되게 설명 해주셔서 머릿속에서 정리가 잘 되었다. useCallback과 useMemo 그리고 React.memo가 불필요한 렌더링을 막을수 있게 도와주는 역할들을 한다는 것을 예전에 한 번 블로그에 정리를 했던적이 있는데, 깊이 이해를 하지 못하고 있었다.
구체적인 예시를 통해서 어떤 상황에 어떤 최적화 도구를 써야하는지 정리해 볼 수 있었던 좋은 기회였다.
children만으로도 성능을 최적화 할 수 있다는 글을 스쳐지나가면서 읽은 적이 있는데, 드디어 children이 어떤 역할을 하는지 속이 션하게 이해할 수 있었다. 이번에 공부한 내용을 바탕으로 예전에 만들었던 투두리스트를 리팩토링 해보아야겠다. 코드가 너무너무 지저분하니깐...
'공부 정리' 카테고리의 다른 글
22.12.05: redux-saga 실습 해보기 (0) | 2022.12.05 |
---|---|
22.11.30: Redux + thunk, saga (0) | 2022.12.02 |
22.11.28: git commit message (0) | 2022.11.28 |
22.11.27 : CRA없이 프로젝트 생성하기-5 (webpack dev server, webpack.config.js 살펴보기) (0) | 2022.11.27 |
22.11.26 : CRA없이 프로젝트 생성하기-4 (code splitting 연습) (0) | 2022.11.26 |