[React] React.memo와 useCallback
React.memo를 통해 불필요한 리렌더링 방지하기
function App() {
const [showParagraph, setShowParagraph] = useState(false);
const toggleParagraphHandler = () => {
setShowParagraph((prevShowParagraph) => !prevShowParagraph);
};
return (
<div className='app'>
<h1>Hi there!</h1>
<DemoOutput show={false} />
<Button onClick={toggleParagraphHandler}>Toggle Paragraph!</Button>
</div>
);
}
export default App;
상태, props, 컨텍스트가 변경되면 컴포넌트 함수가 재실행된다. 이 App.js에선 버튼을 클릭하면 상태가 변경되기 때문에 재실행된다.하지만 여기선 DemoOutput의 props 값으로 항상 false를 넘겨주는데 이 DemoOutput 컴포넌트가 재실행된다. 이유가 무엇일까?
모든 JSX 요소들은 컴포넌트 함수에 대한 함수 호출과 같기 때문에 부모 컴포넌트 함수가 재실행되면 자식 컴포넌트도 재실행되기 때문이다.
이때 컴포넌트가 받은 props가 변경되는 경우에만 그 컴포넌트를 실행하도록 리액트에 지시할 수 있는데, 그 방법은 props가 바뀌었는지 확인할 컴포넌트에 React.memo를 감싸주면 된다.
import React from 'react';
const DemoOutput = (props) => {
return <p>{props.show ? 'This is new!' : ''}</p>;
};
export default React.memo(DemoOutput);
React.memo는 인수로 넣은 컴포넌트에 어떤 props가 입력되는지 확인하고 입력되는 모든 props의 신규 값을 확인한 뒤 기존의 props 값과 비교하도록 리액트에게 전달하게 되고, props의 값이 바뀐 경우만 컴포넌트를 재실행 및 재평가하게 된다.
🧐 그렇다면 왜 이걸 모든 컴포넌트에 적용하지 않는 걸까?
> 최적화에는 비용이 따른다. memo 메서드는 변경이 발생할 때마다 그 컴포넌트로 이동해 기존 props 값과 새로운 값을 비교하게 된다. 그러려면 기존의 props 값을 저장할 공간이 필요하고, 비교하는 작업이 필요하다. 이 작업들은 개별적인 성능 비용이 필요하다.
따라서 어떤 컴포넌트를 최적화하느냐에 따라 달라지는데, 결국은 컴포넌트를 재평가하는 데 필요한 비용과 props를 비교하는 비용을 맞바꾸는 것이다.
만약 컴포넌트 트리가 매우 크다면 memo 메서드를 컴포넌트 트리의 상위에 위치시켜 불필요한 리렌더링을 막을 수 있기 때문에 유용하게 쓰일 수 있다. 하지만 부모 컴포넌트를 재평가할 때마다 컴포넌트의 변화가 있거나 props의 변화가 있는 경우라면 memo는 크게 의미를 갖지 못한다.
memo가 작동안한다?
위의 App 컴포넌트에서 Button 컴포넌트는 다시 변경되지 않기 때문에 재평가할 필요가 없어보인다.
<Button onClick={toggleParagraphHandler}>Toggle Paragraph!</Button>
이 버튼 컴포넌트에 React.memo를 적용해보자.
import React from 'react';
import classes from './Button.module.css';
const Button = (props) => {
console.log('Button RUNNING');
return (
<button
type={props.type || 'button'}
className={`${classes.button} ${props.className}`}
onClick={props.onClick}
disabled={props.disabled}
>
{props.children}
</button>
);
};
export default React.memo(Button);
버튼을 클릭하고 콘솔창을 확인하니 여전히 버튼 컴포넌트가 실행된다는 것을 확인할 수 있었다. 왜 그런것일까?
버튼을 클릭하면 App 컴포넌트의 상태가 바뀌기 때문에 App 컴포넌트가 다시 실행된다. 이 컴포넌트는 "함수"이기 때문에 일반적인 자바스크립트 함수처럼 실행된다. 따라서 toggleParagraphHandler 함수도 매번 재생성된다.
const toggleParagraphHandler = () => {
setShowParagraph((prevShowParagraph) => !prevShowParagraph);
};
즉, App 함수가 실행될때 마다 만들어지는 함수는 완전히 새로운 함수이기 때문에 이전과 같은 함수가 아니고 같은 기능을 하는 함수이다.
그렇다면 이 DemoOutput에서도 매번 false 값이 새로 만들어지는 것인데 왜 DemoOutput에서는 React.memo가 작동하는 것일까?
<DemoOutput show={false} />
그 이유는 바로 원시값과 객체의 차이때문이다. memo가 최종적으로 하는 일은 props의 값을 확인하고 이 현재 값과 이전 값을 비교하는 것이다.
props로 전달되는 값이 원시값이라면 그 값 자체를 비교하기 때문에 false === false 가 true가 되지만, props로 전달되는 값이 배열이나 객체, 함수라면 이 객체는 동일한 내용을 갖고 있다하더라도 자바스크립트에서 이 둘을 비교하면 동일하지 않다.
그러면 props를 통해 이러한 객체를 가져오는 컴포넌트는 memo를 사용할 수 없는 것일까? -> No! 객체를 생성하고 저장하는 방식을 변경해주면 된다. 그러한 작업을 도와주는 것이 useCallback 훅이다.
useCallback
컴포넌트 실행 전반에 걸쳐 함수를 저장할 수 있게 한다. 리액트에 매번 실행할 때마다 함수를 재생성할 필요가 없다고 알려준다.
동일한 함수 객체가 메모리의 동일한 위치에 저장되므로 이를 통해 React.memo에서 비교 작업을 할 수 있다.
- 첫 번째 인자: 저장하려는 함수(재사용할 함수)
- 두 번째 인자: 의존성 배열(이 배열에 넣은 값이 바뀌었을 때 함수를 새로 생성한다.)
const toggleParagraphHandler = useCallback(() => {
setShowParagraph((prevShowParagraph) => !prevShowParagraph);
}, []);
이렇게 useCallback에 의해 리액트 내부 저장 공간에 저장된 toggleParagraphHandler는 항상 같은 객체임이 보장된다.