드디어 최적화의 바다에 들어왔다.
강의를 듣고 공식문서를 읽으면서 사용해봐도 어쩐지 익숙해지지 않던 리액트 최적화 삼총사 React.memo, useMemo, useCallback를 이번 기회에 제대로 이해한 뒤 실행시켜 보고 싶었다.
React.memo는 React가 DOM을 업데이트하는 방법을 고려할 때 유용하다. DOM을 업데이트 할 때 React는 컴포넌트를 렌더링하고 현재 렌더링과 이전 렌더링을 비교한다. 다를 경우 새 렌더링으로 DOM을 업데이트하고, 그렇지 않은 경우 현재 렌더링을 버린다.
하지만 React가 DOM 업데이트 여부를 결정하려면 먼저 컴포넌트를 렌더링해야 한다. 비용이 많이 드는 경우, 발생하는 횟수를 최소화해야 할 것이다. React.memo를 사용하여 불필요한 렌더링을 우회하여 이를 수행할 수 있다.
컴포넌트를 React.memo로 래핑하면, React는 처음에 컴포넌트를 일반적으로 렌더링하지만 props도 기억한다. 다음 렌더링 전에 React는 새 props와 이전 props를 비교한다. 동일한 경우 다시 렌더링하는 대신 이전 결과를 반환한다.
컴포넌트가 다음과 같은 경우 React.memo를 사용할 수 있다.
React.memo가 언제 다시 렌더링할지 결정하기 위해 기본적으로 얕은 비교를 사용하는 이유가 있다. 그 이유는 우리가 접근해야 할 때마다 값이 메모되었는지 확인하는 데 추가적인 간접 비용이 있고 값의 데이터 구조가 복잡할수록 간접비용이 더 나빠지기 때문이다.
문자열, 숫자 및 날짜의 경우 값을 비교하는 것은 사소한 일이지만 객체의 경우 객체가 얼마나 복잡한 지에 따라 리소스가 많이 소모 될 수 있다.
따라서 컴포넌트가 자주 렌더링되지만 사용자 지정 동등성 검사 기능이 더 비싸면 성능이 향상되는 대신 더 나빠진다. 이것이 실제로 메모할 가치가 있는지 확인하기 위해 React.memo를 사용하기 전과 후에 컴포넌트를 벤치마킹하는 것이 중요한 이유이다.
React.memo를 시작하기 전에 컴포넌트가 콜백 함수를 허용하는지 확인하는 것이 중요하다. 이는 JavaScript의 함수가 해당 인스턴스와 동일하기 때문이다. 이것은 무엇을 의미할까?
이와 같은 컴포넌트에 prop을 전달하면 —
<BarGraph
data = {data}
onRenderFinish = { ( ) => showNotification ( subscribedListeners ) }
/ >
onRenderFinish는 각 렌더마다 다른 인스턴스를 가지며, 사용자 정의 동등성 검사 함수를 제공하지 않으면 React.memo는 기본적으로 동일한 함수이더라도 함수 인스턴스가 변경 될 때마다 다시 렌더링된다. React는 컴포넌트를 렌더링 할 때 더 많은 작업을 해야하기 때문에 성능이 저하 될 수 있다. 콜백 prop이 매번 다른 인스턴스를 가지기 때문에, 먼저 props 비교를 수행한 다음, 렌더링한다.
이 문제를 해결하는 방법은 여러 가지가 있지만 공식적으로 권장되는 방법은 다음과 같이 useCallback 훅에서 콜백 prop을 래핑하는 것이다.
const onBarGraphRenderFinish = useCallback(
() => showNotification(subscribedListeners),
[subscribedListeners]
);
<BarGraph
data={data}
onRenderFinish={onBarGraphRenderFinish}
/>
useMemo는 React에 내장 된 훅 중 하나이며 React.memo와 근본적으로 유사하지만 다른 작업을 수행한다. 값을 기억한다는 점에서 비슷하지만 훅이기 때문에 다르며 그 결과 사용 방법이 제한된다.
React.memo를 사용하여 그래프 컴포넌트를 최적화 한 경우, useMemo를 동일한 목적으로 사용할 수 없다. 대신, 파생 된 값이 모든 렌더링에서 계산하는 데 비용이 많이 드는 경우에 사용해야 한다. 이것은 무엇을 의미할까?
데이터를 가져 와서 prop의 문자열 키를 사용하여 암호화하는 컴포넌트 있다고 가정해보자.
const Encrypter = ({ dataToEncrypt, encryptKey }) => {
const encryptedData = encrypt(dataToEncrypt, secretKey);
...render method
}
이 컴포넌트가 렌더링 될 때마다 props 변경 여부에 관계없이 매번 전달되는 데이터를 암호화해야 한다. 만약 encrypt 함수가 이 작업을 수행하는 데 시간이 오래 걸린다면, 우리는 그것을 실행하는 횟수를 감소시킬 수 있다.
이것이 useMemo 빛나는 곳이다. 계산 비용이 많이 드는 컴포넌트 내부 변수가 있는 경우, useMemo로 계산된 변수를 메모하여 이러한 일이 발생해야 하는 횟수를 줄이는 데 사용할 수 있다.
이것은 다음과 같다.
const Encrypter = ({ dataToEncrypt, encryptKey }) => {
const encryptedData = useMemo(
() => encrypt(dataToEncrypt, secretKey),
[dataToEncrypt, encryptKey]
);
...render method
}
이제 Encrypter구성 요소가 렌더링 될 때마다 값비싼 encrypt함수 를 실행하기 전에, useMemo가 dataToEncrypt와 encryptKey 값이 렌더링 간 변경되었는지 확인한다.
그렇지 않은 경우 encrypt함수를 호출하지 않고 이전 렌더링의 결과를 반환한다. 변경되면 결과를 다시 계산한다.
React.memo에서와 같은 사용자 지정 동등성 검사 함수 대신 useMemo 종속성 배열(즉, 결과를 다시 계산하기 위해 변경해야 하는 값)을 사용하여 이전 결과를 "잊고", 다시 계산할 시기를 결정한다 (React가 이전 렌더링만 메모하거나 이전에 메모된 렌더링의 내부 캐시가 있는지 100% 확신할 수 없다.)
종속성 배열의 값이 객체 또는 함수인 useMemo의 경우 참조 동등성을 확인하여 결과를 다시 계산할지 여부를 결정한다. 즉, 객체 또는 함수 값에 대해 React는 실제 내용에 관계없이 메모리의 참조가 변경된 경우에만 변경된 값을 고려한다.
사용을 결정하기 전에 다음 사항을 확인하자.
이제 React.memo와 useMemo 둘 다에 대해 확실히 이해 했으므로 사용할 것을 결정하는 일이 간단해야 한다.
이러한 솔루션 중 하나를 선택하기 전에, 사용 사례의 적절성을 확인하지 않으면, 성능을 개선하는 대신 성능을 저하시킬 수 있다는 점을 다시 한 번 생각하자.
useCallback은 함수를 메모이제이션 하기 위해 사용되는 hook 함수이다. 첫번째 인자로 함수를, 두번째 인자로 함수가 의존하는 값을 배열로 받는데, 이 배열에 전달한 값이 변경되기 전까지 함수를 저장해놓고 재사용할 수 있게 해준다.
일반적으로 리액트 컴포넌트에 함수가 선언되어 있다면 해당 컴포넌트가 렌더링 될 때마다 새로운 함수가 생성된다. 하지만 useCallback을 사용하면 해당 컴포넌트가 렌더링 되어도 의존하는 값이 변경되지 않으면 기존 함수를 반환한다.
사실 컴포넌트가 렌더링 될 때마다 함수를 새로 선언하는 것은 자바스크립트가 브라우저에서 실행되는 속도를 생각해보면 성능상 큰 문제가 되지 않는다. 그렇기 때문에 모든 함수마다 useCallback을 달아주는 것은 의미가 없거나 오히려 성능을 저하시킨다. 따라서 useCallback이 필요한 상황을 정확히 알고 사용해야 한다.
자바스크립트에서 함수는 객체로 취급되어 메모리 주소에 의한 참조 비교가 일어난다. 그렇기 때문에 리액트 컴포넌트에서 렌더링 시 매번 새로 함수가 생성된다면 이는 모두 다른 함수로 취급된다. 그렇기 때문에 어떤 함수를 다른 함수의 인자로 넘기거나, 자식 컴포넌트의 prop으로 넘길 때 성능 문제로 이어질 수 있다.
예를 들어 useEffect() 함수는 두번째 인자인 의존성 배열의 값이 변경될 때 effect 함수를 실행한다.
만약 이 의존성 배열에 함수가 들어간다면, 우리의 의도는 함수가 변경될 때에만 effect 함수가 실행되는 것이다. 하지만 앞서 말한 함수 동등성 문제로 이 의존성 배열에 들어간 함수는 매번 새로운 참조값으로 변경되어 다른 함수로 취급되기 때문에, 의존성 배열에 넣은 의미 없이 렌더링마다 매번 effect 함수를 실행하게 된다(무한 루프).
이때 useCallback을 사용하여 해당 함수 내 의존 값이 변경되기 전까지는 함수의 참조값을 동일하게 유지시킴으로써 기존 함수를 재사용할 수 있게 만들 수 있다. 이렇게 하면 useEffect() 함수 역시 의존성 배열로 넣어준 함수의 의존값이 변경되지 전까지는 재호출 되지 않는다.
기존에 조회수와 좋아요수를 계산하던 코드는 다음과 같았다.
export const countSum = (arrayData: number[]): number => {
const countSumData = arrayData?.reduce((acc, cur) => acc + cur, 0);
return countSumData;
};
이 경우 렌더링될 때마다 arrayData.reduce가 실행된다.
검색 페이지에서는 탭을 클릭해 이동할 때마다, Input에서 검색어를 입력할 때마다 이벤트가 일어나는데, 실제 조회수나 좋아요수 값이 변경될 때만 렌더링될 수 있게 최적화가 필요해보였다.
const likeCounts = posts?.map((post: BlogPost) =>
post.likers ? post.likers.length : 0
);
const likeCountSum = useMemo(() => countSum(likeCounts), [likeCounts]);
const hits = posts?.map((post: BlogPost) => post.hits);
const viewCountSum = useMemo(() => countSum(hits), [hits]);
useMemo 훅을 사용하여 변경한 뒤에는 의존값이 변경될 때만 함수를 실행한다.
useCallback은 useEffect에서 의존성 배열에 함수가 들어가는 상황에서 useEffect를 함수 동등성 문제를 해결하여 유의미하게 작동시켜주기 위해 유용하게 사용되었다.
const checkIntersect = useCallback(
([entry]: IntersectionObserverEntry[]) => {
if (entry.isIntersecting) {
onIntersect();
}
},
[onIntersect]
);
이제 렌더링 시 onIntersect()란 함수가 의미없이 매번 실행되는 것이 아니라 해당 함수 내 의존값이 변경될 경우에만 실행된다.
공부를 할수록 '최적화'라는 단어가 주는 막연한 긍정적인 느낌에 기대 이를 남발해서는 안된다는 것을 깨달았다. 어떤 상황에서 최적화가 필요한지를 좀 더 고민하고, 최적화가 주는 이점이 최적화를 하지 않았을 때보다 확실히 클 때 사용해야 그 과정에서 들어가는 비용과 시간, 코드의 복잡성 등의 단점에 잡아먹히지 않을 것이다.
현재 프로젝트에 퍼포먼스의 문제를 일으킬 정도의 로직이 아직까지는 존재하지 않다고 생각해 올바른 최적화 방법에 대한 학습과 실전 적용을 해보는 것으로 마무리 했다.
References