React에서의 메모이제이션 제대로 사용하기(useMemo, useCallback, memo)
리액트에서 성능 개선하면 메모이제이션이 가장 먼저 떠오를 것이다.
최근 애플리케이션의 성능을 개선하길 원하며 수많은 함수들이 useCallback으로 래핑되어 있다.
하지만 모든 함수를 useCallback으로 감싼다고 해서 애플리케이션의 성능이 좋아지는 것일까?
메모이제이션이 왜 필요할까?
이는 자바스크립트에서의 값 비교와 관련이 있다. string이나 boolean같은 원시 값들은 비교가 매우 직관적이다.
const a = 1;
const b = 1;
a === b // true
보이는 값이 바로 그 값이다. 즉, 위의 예제에서 a와 b를 비교했을 때, 보이는 것과 같이 두 변수의 실제 값은 동일하다.
하지만 object와 array, function과 같은 참조 값들은 다르다.
어떤 변수에 객체를 할당했을 때 그 변수가 가지는 값은 객체 그 자체가 아니다.
객체는 메모리의 어느 한 곳에 저장되며 변수는 그 객체의 위치를 참조한다.
const a = { id: 1 };
const b = { id: 1 };
a === b // false
동일해 보이는 객체라도 두개의 변수에 각각 할당하면 두 객체가 메모리의 각각 다른 공간에 따로 저장된다.
따라서 위의 두 변수를 비교하면 다른 값이라는 것을 확인할 수 있다.
이것이 우리가 소위 말하는 얕은 비교, 또는 참조에 의한 비교이다.
그리고 이것은 리액트가 리렌더링시 어떤 값들을 비교할 때 사용되는 메커니즘이다.
(훅에서 의존성 배열의 변동여부를 체크할 때도 마찬가지이다.)
const Component = () => {
const submit = () => {};
useEffect(() => {
submit();
}, [submit]);
return ...;
};
위의 컴포넌트가 리렌더링된다면 어떻게 될까?
리렌더링은 단순히 리액트가 컴포넌트 함수를 재실행하는 것에 지나지 않는다.
컴포넌트 함수가 실행되면 컴포넌트 내부의 모든 함수들은 재생성된다.
위의 예제에서 submit 함수도 마찬가지로 재생성될 것이다.
그러면 submit이라는 변수의 참조 값도 새로운 참조 값을 가지게 될 것이고,
리액트에서 useEffect의 의존성배열 안에 있는 submit을 리렌더링 전과 비교했을 때 다른 값으로 변화했다고 인지할 것이다.
즉, 매 랜더링마다 useEffect 훅이 호출될 것이다.
따라서 우리는 리액트에게 submit 함수가 렌더링마다 재생성되지 않아야 한다는 것을 알려주어야 한다.
여기서 useCallback과 useMemo 훅이 등장한다.
const Component = () => {
const submit = useCallback(() => {}, []);
useEffect(() => {
submit();
}, [submit]);
return ...;
};
submit에 할당되는 함수를 useCallback으로 감싸주면 useCallback은 내부의 함수가 정확히 동일한 참조 값을 가지는 동일한 함수라는 것을 보장해준다.
useCallback 내부의 의존성배열이 빈 배열이기 때문에 useCallback은 언제나 같은 참조 값을 리턴한다.
따라서 몇번의 렌더링이 이루어지든지 useEffect 의존성 배열 내의 submit이 같은 참조 값을 가르킬 것이고,
불필요하게 useEffect가 트리거되지 않을 것이다.(최초 렌더링시 단 한 번만 실행된다.)
const Component = () => {
const submit = useMemo(() => {
return () => {};
}, []);
useEffect(() => {
submit();
}, [submit]);
return ...;
};
useMemo를 사용할 때도 동일하다.
useCallback과 useMemo 모두 함수를 첫 번째 인수로 받는다.
다른 점은 useCallback은 함수 자체를 메모이제이션 하고, useMemo는 함수를 실행하여 그 리턴 값을 메모이제이션한다는 것이다.
여기서 useMemo가 useCallback보다 성능면에서 낫다는 myth(미신?...잘못된 믿음?)가 나타난다.
그 배경은 useCallback은 매 랜더링마다 인수로 받은 함수를 재생성하고, useMemo는 그렇지 않다는 것이다.
앞서 myth라고 적었듯, 이것은 사실이 아니다.
hook은 무엇인가?
알다시피 컴포넌트는 리액트가 실행하는 하나의 함수에 불과하다.
hook도 마찬가지이다. hook또한 리액트가 실행하는 하나의 함수이다.
hook은 컴포넌트가 실행될 때마다 그 내부에서 실행되는 함수이다.
다르게 이야기하면 모든 hook은 컴포넌트가 렌더링 될 때마다 실행된다.
hook은 다른 함수들과 같이 인자를 받을 수 있다.
useCallback은 첫 번째 인자로 함수를 받기로 정의된 함수이다.
일반 자바스크립트에서 useCallback을 inline 익명함수와 함께 여러번 실행했을 때는 어떻게 될까?
const useCallback = (callback) => { ... };
useCallback(() => {});
useCallback(() => {});
useCallback(() => {});
useCallback을 실행할 때마다 각각 다른 참조 값을 가진 인자 함수가 생성된다.
우리는 메모이제이션을 잘못 사용하고 있었다. (useCallback과 useMemo 그리고 memo)
다시 리액트로 돌아와보면, 우리는 아래와 같이 useCallback 훅을 사용한다.
const Component = () => {
const submit = useCallback(() => {
}, []);
return ...;
};
Component가 리렌더링 될 때마다 useCallback함수가 호출될 것이고, 그 인자 함수 또한 재생성된다.
어떻게 매번 새로운 인자 함수가 생성되는데 동일한 참조 값이 리턴되는 것일까?
let cachedCallback;
const useCallback = (callback) => {
if(!cachedCallback) {
cachedCallback = callback;
}
if(!dependenciesEqual()) {
cachedCallback = callback;
}
return cachedCallback;
};
이는 리액트가 인자 함수로 전달된 최초의 함수를 캐싱하여 그 값을 항상 리턴하고, 새로 생성된 함수는 void처리하기 때문이다.
오직 의존성이 변화했을 때만 생성된 함수를 다시 캐싱하고 리턴한다.
이 부분에 있어서 위에서 언급한 myth는 사실이다.
useCallback에 전달되는 첫번째 인자 함수는 매 렌더링마다 재생성된다.
그렇다면 왜 myth라고 부르는 것일까?
useMemo도 정확히 똑같은 작업을 수행하기 때문이다.
const submit = useMemo(() => {
return () => {};
}, []);
useMemo도 첫 번째 인자로 함수를 받게 되는데 이는 useMemo 또한 매 렌더링마다 인자 함수를 재생성한다는 것을 의미한다.
let cachedValue;
const useMemo = (callback) => {
if(!dependenciesEqual()) {
cachedValue = callback();
}
return cachedValue;
};
다른 점이라면 useCallback은 함수를 캐싱하고, useMemo는 그 함수를 실행하고 리턴된 값을 캐싱한다는 점이다.
그 이외의 로직은 동일하다. 이것이 useMemo가 useCallback보다 성능면에서 낫다는 것을 myth라고 부른 이유이다.
다시 리액트로 돌아와서,
메모이제이션 훅을 사용할 때 의존성을 메모이제이션 하는 것 다음으로 많이 사용되는 케이스는 props를 메모이제이션하는 것이다.
하지만 대부분의 경우 이것이 잘못 사용되고 있다.
const Component = () => {
const onClick = useCallback(() => {
// do something
}, []);
return <button onClick={onClick}>click me</button>
};
위와 같이 useCallback을 작성하게 되는 경우가 많다. 이는 완전히 쓸모없는 패턴이다.
알다시피 어떤 컴포넌트가 리렌더링 되면 그 안의 모든 컴포넌트들이 함께 리렌더링 된다.
따라서 useCallback을 사용하든 사용하지 않든 똑같은 결과가 나타나며,
결과적으로 리액트에게 일을 좀 더 시키고, 코드의 가독성이 좀 더 떨어질 뿐이다.
즉, 무작정 props를 메모이제이션하는 것은 anti-pattern이다.
우리가 컴포넌트의 props를 메모이제이션해야 하는 실 사용 사례는 두 가지 경우 뿐이다.
첫 번째 경우는 props가 자식 컴포넌트의 다른 훅에서 의존성으로 사용되고 있을 경우이다.
const Parent = () => {
const fetch = () => {};
return <Child onMount={onMount} />;
};
const Child = ({ onMount }) => {
useEffect(() => {
onMount();
}, [onMount])
return ...
};
위의 예제에서 부모 컴포넌트가 리렌더링 되면 자식 컴포넌트의 useEffect를 불필요하게 재실행하게 된다.
두 번째 경우는 컴포넌트가 React.memo로 감싸져 있는 경우이다.
React.memo는 컴포넌트 자체를 메모이제이션할 수 있게 해주는 유용한 유틸이다.
하지만 이 유용성은 매우 제한적이다
const MemoChild = React.memo(Child);
const Parent = () => {
return <MemoChild />;
};
자식 컴포넌트가 React.memo로 감싸져 있고 부모 컴포넌트에 의해 리렌더링이 실행됐을 경우에,
리액트는 메모이제이션된 자식 컴포넌트를 리렌더링 하지 않을 것이다.
const MemoChild = React.memo(Child);
const Parent = () => {
return <MemoChild id="1" />;
};
만약 메모이제이션된 컴포넌트가 props를 가지고 있을 경우,
리액트는 리렌더링을 결정하기 전에 해당 props가 변경되었는지 먼저 체크할 것이다.
만약 props가 변경되지 않았다면 해당 컴포넌트는 리렌더링되지 않을 것이다.
const MemoChild = React.memo(Child);
const Parent = () => {
return <MemoChild id="1" data={{ id: 1 }} />;
};
위 예제는 상위에서 기술한 얕은 비교와 관련한 케이스와 비슷한 케이스이다.
props중 하나라도 변경이 되면 컴포넌트는 React.memo 래핑 여부와 상관없이 리렌더링된다.
이것이 useCallback과 useMemo가 유용하게 쓰일 수 있는 부분이다.
const MemoChild = React.memo(Child);
const Parent = () => {
const memoData = useMemo(() => ({ id: 1 }), [])
return <MemoChild id="1" data={memoData} />;
};
위와 같이 객체를 값으로 가지는 props를 메모이제이션하여 부모 컴포넌트로부터 야기된 리렌더링을 막아줄 수 있다.
하지만 모든 props를 메모이제이션 하는 것은 쉬운 일이 아니다.
const memoChild = React.memo(Child);
const Component = (props) => {
return <MemoChild {...props} />;
};
const Parent = (prop) => {
return <Component {...props} data={{ id: 1 }} />;
};
위의 예제에서 Component만 보았을 때는 자식 컴포넌트 Child가 안전하게 메모이제이션 된 것 처럼 보이지만,
props가 참조형으로 정의된 Parent 컴포넌트가 리렌더링 되면 메모이제이션된 MemoChild에 리렌더링이 일어나게 된다.
(React.memo가 의미 없어 짐..)
이러한 형식으로 nesting된 컴포넌트 형식에서는 메모이제이션이 필요한 위치를 알아채기 쉽지 않다.
const useForm = () => {
// lots of code
const submit = () => {
// do some submit stuff
};
return { submit };
};
const Component = () => {
const { submit } = useForm();
return <MemoChild onChange={submit} />;
};
위의 경우에도 MemoChild가 메모이제이션 되었다고 해도 부모 컴포넌트가 리렌더링되면 useForm 함수가 재생성 되면서 그 내부의 submit 함수도 재생성 될 것이므로 MemoChild에서 리렌더링이 일어난다.
const Component = () => {
return (
<MemoChild>
<div>Some text here</div>
</MemoChild>
);
};
위의 예제도 MemoChild가 안전하게 메모이제이션 될 것 처럼 보인다. props가 없어 보이기 때문이다.
하지만 여기에서도 메모이제이션은 의미가 없다.
위의 코드를 다르게 작성해보면 아래와 같다.
const Component = () => {
return (
<MemoChild
children={<div>Some text here</div>}
/>
);
};
⬇️
const Component = () => {
return (
<MemoChild
children={{ type: "div", ... }}
/>
);
};
div도 결국에는 자바스크립트 객체이다.
이는 MemoChild의 children props에 포함되며 참조 자료형태의 props 값이 된다.
이 div 객체는 매 렌더링마다 재생성될 것이고, MemoChild의 리렌더링을 야기할 것이다.
이러한 케이스를 제대로 메모이제이션하기 위해서는 아래와 같이 작성해야 한다.
const Component = () => {
const children = useMemo(() => <div>Some text here</div>, []);
return (
<MemoChild>{children}</MemoChild>;
);
};
⬇️
const Component = () => {
const children = useCallback(() => <div>Some text here</div>, []);
return (
<MemoChild>{children}</MemoChild>;
);
};
아래의 예제는 어떨까?
const MemoChild = React.memo(Child);
const MemoParent = React.memo(Parent);
const Component = () => {
return (
<MemoParent>
<MemoChild />
</MemoParent>
);
};
부모 컴포넌트와 자식 컴포넌트 모두 메모이제이션 처리 되어있어서 안전해보인다.
하지만 매 렌더링마다 MemoParent는 리렌더링된다.
바로 위의 예제와 마찬가지로 위의 예제를 다시 써보면 아래와 같다.
const MemoChild = React.memo(Child);
const MemoParent = React.memo(Parent);
const Component = () => {
return (
<MemoParent children={{ type: MemoChild }}></MemoParent>
);
};
Child 컴포넌트 자체는 MemoChild로 메모이제이션되었지만,
MemoParent의 children props에 해당하는 객체는 메모이제이션 된 객체가 아니기 때문이다.
(다른 객체로 감싸진 메모이제이션 객체...)
위 예제를 제대로 메모이제이션하려면 아래와 같이 작성해야 한다.
const MemoChild = React.memo(Child);
const MemoParent = React.memo(Parent);
const Component = () => {
const children = useMemo(() => <MemoChild />, []);
return (
<MemoParent>{children}</MemoParent>
);
};
reference
Mastering memoization in React - Advanced React course, Episode 5