웹사이트를 개발할 때 성능 최적화는 고려할 사항 중 하나이다. 다양한 기기와 네트워크 환경에서 웹사이트가 원활하게 작동하도록 설계하는 일은 개발자가 해야할 중요한 부분이다. 컴퓨터와 스마트폰 사양이 높아지면서 이정도까지 신경을 써야하나 의문이 생길 수 있지만, 모든 사용자가 그러한 환경을 가지고 있지 않는 점에서 성능 최적화는 여전히 중요하다.
웹사이트의 성능 저하는 사용자 경험을 크게 떨어뜨리며, 페이지가 늦게 로드되거나 상호작용이 느려질수록 이탈률이 높아질 수 밖에 없다. 그렇기 때문에 우리들은 늘 최악의 경우를 염두에 두고, 가능한 빠르고 효율적인 환경을 제공해야 한다.
그러한 의미에서 성능 최적화 중 하나인 메모이제이션(Memoization)을 통해 성능을 어떻게 개선할 수 있는지 알아보려고 한다.
state와 const
개발을 하면서 리렌더링이 발생할 때 state는 재할당이 일어나지 않고 const와 같은 변수는 재할당이 일어난다.
import { useState } from "react";
const Memoization = () => {
let countLet = 0;
const [countState, setCountState] = useState(0);
const handleCountLetUp = () => {
countLet += 1;
};
const handleCountStateUp = () => {
setCountState((prev) => prev + 1);
console.log(countLet);
};
return (
<div>
<p>Count Let : {countLet}</p>
<button onClick={handleCountLetUp}>Count Let</button>
<p>Count State : {countState}</p>
<button onClick={handleCountStateUp}>Count State</button>
</div>
);
};
export default Memoization;
위 코드를 예를들어 보자!
countLet와 countState는 각각 변수와 state로 구성되어 있다. 그리고 handler를 사용해서 업데이트를 진행하면 어떻게 될까? 결과를 아는 사람이 더 많을 것이고 그 결과가 맞다.
- countLet 변수는 handleCountLetUp으로 업데이트해도 리렌더링이 일어나지 않으므로 화면에 변화가 없다.
- countState는 state이므로 업데이트할 때 리렌더링이 발생하며 화면에 변경된 값이 반영된다.
특히 다음과 같은 동작 차이가 있습니다:
const handleCountStateUp = () => {
setCountState((prev) => prev + 1);
console.log(countLet);
};
함수에서 console에서 업데이트된 값이 나오고 다시 이벤트를 발생 시키면 countLet은 초기화가 발생한다.
근데 왜 state는 값이 남아있고 변수는 값이 초기화가 되는 것일까??
이 동작의 이유는 다음과 같다:
- 일반 변수: 컴포넌트 내부의 일반 변수는 리렌더링 시마다 초기화된다. 컴포넌트가 리렌더링되면 새로운 힙 메모리 공간에 할당되고, 이전 값은 사라지게 된다.
- State 변수: 반면, state는 React가 관리하는 특수한 메모리 공간에 저장된다. state가 업데이트될 때 React는 기존 메모리 주소를 유지하며, 리렌더링 후에도 state 값을 유지한다.
이렇게 state는 리렌더링이 되어도 값을 유지하지만, 일반 변수는 재할당된다는 점이 핵심이다.
메모이제이션 (Memoization)
React에서 불필요한 리렌더링이 많아질수록 성능 저하가 발생하고, 이는 사용자 경험에 악영향을 줄 수 있다. 성능이 저하된 서비스는 사용자 이탈률을 높여 매출에도 부정적인 영향을 미칠 수 있으므로, 최적화된 렌더링 관리는 매우 중요하다.
따라서 리렌더링을 줄여줄 필요가 있다.
memo()
memo는 컴포넌트 단위의 리렌더링 방지 기능이다. 부모 컴포넌트가 리렌더링되어도 자식 컴포넌트의 props가 변경되지 않았다면, 자식의 렌더링을 방지한다.
import { useState } from "react";
import MemoizationChildPage from "./child";
const Memoization = () => {
console.log("컨테이너가 렌더링 됩니다.");
let countLet = 0;
const [countState, setCountState] = useState(0);
const onClickCountLet = () => {
console.log(countLet + 1);
countLet += 1;
};
const onClickCountState = () => {
console.log(countState + 1);
setCountState(countState + 1);
};
return (
<div>
<div>================</div>
<h1>이것은 컨테이너 입니다.</h1>
<div> 카운트(let): {countLet} </div>
<button onClick={onClickCountLet}> 카운트(let) +1 올리기! </button>
<div> 카운트(state): {countLet} </div>
<button onClick={onClickCountState}> 카운트(state) +1 올리기! </button>
<div>================</div>
<MemoizationChildPage />
</div>
);
};
export default Memoization;
기존 코드에서 수정이 조금 발생했지만 큰 차이는 없다. 다만 MemoizationChildPage 컴포넌트를 추가로 렌더링하고 있다. let Count는 업데이트가 반영되지 않을 것이고, state Count를 반영될 것이다.
const MemoizationChildPage = () => {
console.log("자식이 렌더링 됩니다.");
return (
<div>
<div>================</div>
<h1>이것은 자식입니다.</h1>
<div>================</div>
</div>
);
};
export default MemoizationChildPage;
자식 컴포넌트는 더 별거 없다. 단순하게 렌더링을 시키고 렌더링이 발생했을 때 콘솔을 찍어준다.
이때 버튼을 통해서 state 값을 변경하면 어떻게 될까?
useState를 제외한 모든 값이 다시 그려지면서 자식도 다시 렌더링이 발생한다. 변경이 일어나는 것은 부모 컴포넌트인데 굳이 자식 컴포넌트까지 리렌더링이 발생할 필요가 있을까?
import React from "react";
const MemoizationChildPage = () => {
console.log("자식이 렌더링 됩니다.");
return (
<div>
<div>================</div>
<h1>이것은 자식입니다.</h1>
<div>================</div>
</div>
);
};
export default React.memo(MemoizationChildPage);
부모 컴포넌트에서 리렌더링이 발생했을 때, 자식 컴포넌트에서 리렌더링이 발생하지 않게 하기 위해서 React의 memo 함수를 사용하면 된다.
컴포넌트를 memo로 감싸면 해당 컴포넌트의 memoized 버전을 얻을 수 있다.
memoized 버전의 컴포넌트는 일반적으로 부모 컴포넌트가 리렌더링 되어도 자신의 props가 변경되지 않았다면 리렌더링이 발생하지 않는다.
모든 곳에서 memo를 사용해야 할까?
이렇게 좋은 기능이 있다면 컴포넌트를 만들 때 memo를 사용해서 전부 감싸면 되는게 아닐까? props 변경이 발생하면 리렌더링이 발생할 것이고 변경이 발생하지 않으면 리렌더링이 발생하므로 성능상으로 손해를 볼 이유가 없을 것이다.
해답은 간단하게 얻을 수 있는데, 그랬다면 React에서 컴포넌트를 만드는 과정에서 memo를 넣었지 않았을까?
이렇게 이야기하면 의문이 계속 남기 때문에 공식문서를 참고하면 다음과 같다.
일반적으로 memoization는 불필요하다. memo로 최적화하는 것은 컴포넌트가 정확히 동일한 props로 자주 리렌더링 되고, 리렌더링 로직이 비용이 많이 드는 경우에만 유용하다. 컴포넌트가 리렌더링 될 때 인지할 수 있을 만큼의 지연이 없다면 memo가 필요하지 않는다....
useCallback(), useMemo()
- useMemo는 비싼 연산의 결과를 캐싱하여 리렌더링 시 해당 연산을 다시 하지 않도록 한다.
- useCallback은 함수의 재생성을 방지해 컴포넌트가 리렌더링될 때마다 동일한 함수가 재생성되지 않도록 한다.
자식 컴포넌트는 memo를 사용해 불필요한 리렌더가 더이상 일어나지 않도록 막아주었지만, 부모 컴포넌트는 지속적으로 렌더링이 일어나는 상황이다.
근데, 부모 컴포넌트에서도 굳이 리렌더링이 발생하지 않아도 되는 부분이 있다.
예를들어, stateCount를 변경 했을 때 letCount의 값이 지속적으로 다시 만들어지고 있는 상황이다.
이런 불필요한 값들이 지속적으로 다시 만들어지지 않도록 유지시켜주는 hooks가 바로 useMemo, useCallback이다.
const random = useMemo(() => {
return Math.random();
}, []);
console.log(`${random}는 더이상 안 만들어`);
random한 값을 사용하는 경우가 있는데, 이때 useMemo를 사용하지 않는다면 리렌더링이 발생할 때마다 값이 달라질 것이다. 재할당이 되니깐 당연한 결과이다.
하지만 useMemo를 사용하면 값이 변경되지 않는다.
const onClickCountState = useCallback(()=>{
setCountState((prev)=>prev+1)
},[])
함수 역시 변수이기 때문에 리렌더링이 발생하면 재할당이 일어난다.
이것을 useCallback을 사용하면 리렌더링이 발생하지 않는다.
memo와 마찬가지로 useMemo와 useCallback은 모든 함수, 변수에 사용하면 되는게 아닐까?
역시 공식문서에 나타나있다.
대부분의 상호 작용의 경우 메모이제이션이 필요하지 않습니다. 반면, 앱이 그림 편집기와 비슷하고 대부분의 상호작용이 세분화된 경우(도형 이동과 같은) 메모이제이션이 매우 유용할 수 있다.
... 특정 상황의 경우 외 useMemo로 감싸는 것에 대한 이득이 없습니다. 그러나 그렇게 한다고 해서 크게 문제가 되는 것도 아니므로 일부 팀에서는 개별 사례에 대해 생각하지 않고 가능한 한 많이 메모하는 방식을 선택합니다. 이 방식의 단점은 코드 가독성이 떨어진다는 것이다.
예상 외로 정~~~ 원한다면 써도 문제는 없다. 하지만 코드 가독성, 유지보수의 문제가 있을 수 있다.
또 여기서 말하는 비싼 연산인지는 어떻게 알 수 있을까? 이 역시 공식문서에 나와있다. ( 공식 문서 참 좋죠?? )
일반적으로 수천 개의 개체를 만들거나 반복하는 경우가 아니라면 비용이 많이 들지 않습니다. 조금 더 정확하게 확인하고 싶다면 콘솔 로그를 추가하여 코드에 소요된 시간을 측정할 수 있습니다.
...
console.time, console.timeEnd를 사용해서 전체적으로 기록된 시간이 클 때(예시: 1ms 이상) 해당 계산을 메모해 두는 것이 좋습니다.
옛날이었다면 useMemo와 useCallback을 남발하는 것을 추천하지 않았다.
하지만 이젠 정말~ 원한다면 해도 문제가 없다는 정도까진 알 수 있는 내용이다.
사실 이번 내용의 핵심은 useMemo와 useCallback, memo를 쓰는 방법이 핵심이 아니다.
어떤 경우에 memo, useCallback, useMemo를 사용할 수 있고, 남발해도 좋은지 안좋은지에 대한 업데이트이다.
memo, useCallback과 useMemo는 남발을 할 수 있다면 하는 것도 문제가 없다는 점은 꽤나 충격적이었고 (실제로 그렇게 쓰고 있다고 하니깐...?), 대신! 단점이 분명하게 있다는 것도 숙지를 하고 사용하면 유용하게 사용할 수 있지 않을까 생각해본다.
'React > 실험실' 카테고리의 다른 글
렌더링 (2) | 2024.10.23 |
---|---|
디바운싱 검색 (1) | 2024.10.19 |
CSS를 컴포넌트에 중복 호출하면 안되는 EU (1) | 2024.08.17 |
React Query 고려하기 - Request Waterfalls (1) | 2024.08.07 |
Table 컴포넌트 (0) | 2024.07.31 |