인스타그램을 리액트로 클론코딩해보는 것까지 마쳤으니,
최적화에 대해 더 알아보고 싶어졌다.
useMemo, useCallback, React.memo 에 대해서 정리하며 공부해보고, 어떻게 활용할 수 있는지 고민해보려고 한다.
2022.01.29 업데이트
의외인 부분.
이번에 알게 된 것은, 리액트나 자바스크립트에서는 사실 성능최적화가 크게 중요하지는 않다는 것이다.
그 이유는 ...
그러면, 성능최적화는 어디까지 고려해 사용하는 것이 적절할까?
물론, 프로젝트 규모가 커지거나 제대로 관리하지 않은 코드가 쌓여 불필요한 리렌더링이 많아질 경우 성능에 중요한 영향을 미칠 수 있다. 우리는 이를 방지하기 위해 성능을 해치는 나쁜 코드를 작성하지 않으면 된다.
불필요한 리렌더링을 발생시키지 않도록 코드를 관리하는 방법은 어떤 것들이 있을까?
리액트는 단방향 하향식 데이터 흐름을 가지고 있고, 데이터들의 변화는 컴포넌트를 리렌더링 시킨다.
1. state와 props의 변경을 최소화
2. state와 props의 변경에 의한 불필요한 하위 컴포넌트 리렌더링을 최소화
이런 방향을 위해 어떤 방법들을 사용할 수 있을까?
리액트는 특정 state가 변경되면 그 state가 선언된 컴포넌트와 그 하위 컴포넌트들을 모두 리렌더링한다.
객체가 크고 복잡한 구조인 경우 분할할 수 있는 만큼 최대한 분할 하는 것이 좋다.
해당 state에서 일부의 프로퍼티만 사용하는 하위 컴포넌트가 있다면, 그 컴포넌트는 해당 프로퍼티가 변경될 때에만 리렌더링 되는 것이 좋다.
왜일까?
분할하지 않고 통째로 객체 state를 사용하면, 하위컴포넌트는 일부의 프로퍼티만 사용하지만 다른 프로퍼티 값이 업데이트 될 때에도 리렌더링이 발생하게 되기 때문이다.
중간에 다른 요소가 삽입되었을 때 그 중간 이후의 요소들은 전부 인덱스가 다시 변경된다.
따라서 key값이 변경됨에 따라 리마운트가 일어난다.
또한 데이터가 key와 매치가 되지 않아 서로 꼬이는 부작용도 발생한다.
하위 컴포넌트의 props 값으로 객체를 넘겨주는 경우, 컴포넌트 안에서 생성자 함수나 객체 리터럴 등으로 새로 객체를 생성해 넘겨주는 것을 주의해야 한다.
선언된 props나 state에 참조하지 않고 아예 새로운 객체가 하위 컴포넌트로 전달된다면, 메모이제이션이 불가능하다. 새로 생성된 객체는 이전 객체와 다른 참조 주소를 가진 객체이기 때문이다.
따라서 state를 그대로 하위 컴포넌트에 넘겨준 다음 필요한 데이터 가공을 그 하위 컴포넌트에서 해주는 것이 좋다.
Memoization
이전 값을 메모리에 저장해 동일한 계산의 반복을 제거해 빠른 처리를 가능하게 하는 기술
앞으로 공부할 세가지는 이 Memoization을 기반으로 작동한다.
Highter-Order Components (HOC)로,
컴포넌트를 인자로 받아 새로운 컴포넌트를 반환하는 함수
컴포넌트가 같은 props를 받을 때 같은 결과를 렌더링한다면 React.memo를 사용해 불필요한 컴포넌트 렌더링을 방지할 수 있다.
이 때 리액트는 마지막에 렌더링된 결과를 재사용한다.
const CounterButton = ({ color }) => {
const [count, setCount] = useState(1);
return (
<button
color={color}
onClick={() => setCount(state => state.count + 1)}>
Count: {count}
</button>
);
}
export default React.memo(CounterButton);
이런 경우를 생각해보자.
Modal 컴포넌트에서 props로 전달된 title이 변경되면 해당 컴포넌트는 리렌더링된다.
이 때 하위 컴포넌트인 CoutnerButton 컴포넌트도 함께 리렌더링된다.
Counterbutton에 전달되는 color props는 바뀌지 않기 때문에, props값이 변한 것이 아니라면 CounterButton 컴포넌트도 함께 리렌더링 되지 않도록 React.memo를 사용해주었다.
const Modal = ({ title }) => {
const [btnColor, setBtnColor] = useState('yellow');
return (
<div>
<p>{title}</>
<button onClick={() => { setBtnColor('black') }}>Change Color!</button>
<CounterButton color={btnColor} />
</div>
);
}
props가 변경됐는지 어떻게 체크하길래 불필요한 렌더링이 발생할까?
state가 변경되거나 새로운 컴포넌트가 렌더링 되는 시점에서 shallow copy를 통해 같은 값인지 판단하고 렌더링 여부를 결정한다.
따라서 같은 값의 props라도 컴포넌트의 state가 변경되면 shallow copy에 의해 새로운 값으로 인식한다.
props가 함수나 객체, 배열같은 reference type이라면 같은 참조가 아니라면 새로운 값으로 판단한다. 이럴 경우 불필요하게 리렌더링이 발생하는 것이다.
React.memo, 언제 써야 할까?
react dev tools로 렌더링 상황을 보면서 적용여부를 결정하는 것이 좋겠다.
하위 컴포넌트로 함수 props를 전달해줄때를 생각해보자.
함수의 내용이 같더라도 참조값이 다르다면 리렌더링 될때마다 새로운 참조값을 갖게 된다.
앞서말한 이유로 참조값이 다르다면 React.memo를 사용했더라도 이것을 다른 props로 인식해 리렌더링한다.
즉, memoization을 했지만 리렌더링은 그대로 발생해 메모리를 낭비할 뿐 본래의 목적은 달성하지 못한다.
클래스형 컴포넌트에서는 React.memo와 같은 역할을 수행하는 것이 React.PureComponent이다.
얕은 비교를 통해 props와 state의 참조값이 같다면 리렌더링을 방지해준다.
class CounterButton extends React.PureComponent {
constructor(props) {
super(props);
this.state = { count: 1 };
}
render() {
함수의 결과 값을 memoize하여 불필요한 연산을 없앤다.
함수 호출 이후 리턴 값을 memoize하며, 두번째 파라미터인 의존성 배열의 요소가 변경 될 때마다 첫번째 파라미터의 callback 함수를 다시 생성한다.
왜 사용할까?
특정 상황에서만 동작되어야 하는 함수가, 컴포넌트의 렌더링 조건에 따라 지속적으로 실행되는 경우 이것을 막기 위해 사용한다.
useMemo는 메모이즈된 값을 리턴하는 훅이다.
인자로 함수와 의존 값을 받는데, 의존 인자중 하나라도 변경되면 값을 재 계산한다.
의존인자에 아무것도 전달되지 않는다면 렌더 시마다 항상 값을 새롭게 계산하여 리턴한다.
function NameTag(props) {
return useMemo(
() => <div>{props.name}</div>
,
[props.name]
)
}
사용 전
const increment1 = () => setCount1(c => c + 1);
사용 후
const increment1 = React.useCallback(() => setCount1(c => c + 1), []);
useMemo는 특정 값을 재사용하기 위한 훅이었다면,
useCallback은 특정 함수를 재사용하기 위한 훅이다.
자식 컴포넌트에 함수를 props로 줄 때는 useCallback을 사용하여 리렌더링이 안되도록 해주는 것이 좋다.
useCallback 함수를 통해 callback 함수를 동일한 callback 인스턴스로 설정해주면 위에서 언급한 문제가 해결된다.
이것은 항상 같은 함수 인스턴스를 반환하기 때문에 React.memo가 정상 기능을 수행하게 된다.