Memoization: 컴퓨터 프로그램이 동일한 계산을 반복해야 할 때, 이전에 계산한 값을 메모리에 저장함으로써 동일한 계산의 반복 수행을 제거하여 프로그램 실행 속도를 빠르게 하는 기술이다.
연산의 결과값을 메모리에 저장해 두고 동일한 연산을 시키면, 다시 연산하지 않고 기억 해 두었던 데이터를 반환 즉, 재사용하는 기법
UseMemo
성능 최적화를 위하여 연산된 값(연산 결과)을 useMemo라는 Hook 을 사용하여 재사용한다.
// CommentItem
const [clickCount, setClickCount] = useState(0);
const handleClick = () => {
onClick();
setClickCount((prev) => prev + 1); // 클릭 시 숫자 증가.
alert(`${title}누름`);
}
const rate = () => {
console.log('rate check');
return likes > 10 ? 'Good' : 'Bad';
}
CommentItem에 rate변수를 주고 새로운 상태인 clickCount를 handleClick에 추가해주었다. 로그를 확인해보니 클릭될 때 마다 클릭한 컴포넌트가 다시 그려지고 rate check또한 다시 했다. 상태가 변해서 새로 그려지는 건 괜찮지만 rate check까지 다시 할 필요는 없다. useMemo를 사용해보자.
const rate = useMemo(() => {
console.log('rate check');
return likes > 10 ? 'Good' : 'Bad';
}, [likes]);
likes의 변경이 있을때만 해당 함수가 실행되어 성능 최적화 해주었다.
const getDiaryAnalysis = useMemo(() => {
console.log("일기 분석 시작");
// 감정 상태가 3이상인 것만 filter하여 길이를 구함.(3이상인 감정 상태의 개수)
const goodCount = data.filter((it) => it.emotion >= 3).length;
const badCount = data.length - goodCount;
// goodCount의 비율
const goodRatio = (goodCount / data.length) * 100;
return { goodCount, badCount, goodRatio }; // 객체로 반환
}, [data.length]);
// 객체로 비구조 할당
const { goodCount, badCount, goodRatio } = getDiaryAnalysis; // 값의 형태
useMemo로 어떤 함수를 감싸고 dependency를 전달해서 함수를 최적화시키면 useMemo를 담는 변수(getDiaryAnalysis)는 함수가 아닌 값이 된다. 즉, useMemo는 콜백함수가 리턴하는 값을 리턴한다. 어떤 함수가 있고 그 함수가 어떠한 값을 리턴하고 있는데 그 리턴까지의 연산을 최적화하고 싶다면 useMemo를 사용해서 dependency array에 어떤 값이 변할 때만 연산을 다시 수행할 것인지 명시하여 해당 함수를 값처럼 사용하여 연산을 최적화할 수 있다.
React.memo()
- 컴포넌트 자체를 메모이제이션 즉, 컴포넌트를 재사용 한다.
- 자체적으로 props값을 비교해서 달라진 부분이 없다면 리액트 DOM에서 비교 작업이 들어가지 않는다.
props의 변화에만 영향을 준다.
Count의 state값이 업데이트 되었을 때 해당 state를 가진 컴포넌트인 App 컴포넌트는 리렌더링 된다. prop인 count 값 또한 바뀌며 <CountView /> 컴포넌트가 리렌더링되는데 <TextView /> 컴포넌트 또한 리렌더링 된다. 그 이유는 부모 컴포넌트가 리렌더링되면 자식들 또한 리렌더링 되기 때문에 상태 변화가 없는 <TextView /> 컴포넌트도 리렌더링 되는 것이다. 이러한 상황에서 연산의 낭비가 발생하게 된다. 이를 해결하기 위해 자식 컴포넌트에게 업데이트 조건을 걸어준다. <CountView />는 count가 변경될 때만 렌더링, <TextView />는 text가 변경될 때만 렌더링 이라는 업데이트 조건을 걸어 연산의 낭비를 막아서 성능 최적화 시켜준다.
함수형 컴포넌트에게 업데이트 조건을 걸어 성능을 최적화 시켜주는 React.memo에 대해 자세히 알아보자.
React.memo는 고차 컴포넌트(HOC, Higher Order Component) 입니다. - React Docs
고차 컴포넌트는 무엇인가?
고차 컴포넌트는 컴포넌트를 가져와 새 컴포넌트를 반환하는 함수이다.
const EnhancedComponent = higherOrderComponent(WrappedComponent);
함수를 호출하면서 매개변수로 컴포넌트를 전달했더니 더 강화된 컴포넌트가 만들어졌다. 라는 느낌이다.
HOC를 정리한 링크 (내용이 부실하기에 Docs와 함께 보자)
여기선 React.memo에 집중하자.
const MyComponent = React.memo(function MyComponent(props) {
/* props를 사용하여 렌더링 */
});
위 코드를 살펴보면 React.memo 함수가 고차 컴포넌트로써 호출되고 함수 안에 매개변수로 컴포넌트를 전달하게 되면 더 강화면 새로운 컴포넌트를 MyComponent 상수에 반환하게 된다.
컴포넌트가 동일한 props로 동일한 결과를 렌더링해낸다면, React.memo를 호출하고 결과를 메모이징(Memoizing)하도록 래핑하여 경우에 따라 성능 향상을 누릴 수 있습니다. 즉, React는 컴포넌트를 렌더링하지 않고 마지막으로 렌더링된 결과를 재사용합니다.
이 말을 간단하게 풀어서 설명해보자면, 똑같은 props를 받으면 똑같은 결과를 내놓겠다는 말이다. 즉, MyComponent에게 같은 props을 바뀐 것처럼 전달해도 다시 props를 계산하지 않는다. 리렌더링 하지 않는다는 것이다. React.memo 고차 컴포넌트로 리렌더링 되지 않았으면 하는 컴포넌트를 감싸서 반환하게 되면 props가 바뀌지 않으면 리렌더링 하지 않는 강화된 컴포넌트를 돌려주겠다는 것으로 이해하면 된다. 물론 자기 자신의 state가 바뀌면 리렌더링 된다. 부모 컴포넌트에 의한 리렌더의 동작이 강화되는 것이고 자기 자신의 state의 변화에는 리렌더 된다.
import React, { useEffect, useState } from "react";
// TextView 컴포넌트
const TextView = React.memo(({ text }) => {
useEffect(() => { console.log(`Update :: Text : ${text}`); });
return <div>{text}</div>;
});
// CountView 컴포넌트
const CountView = React.memo(({ count }) => {
useEffect(() => { console.log(`Update :: Count : ${count}`); });
return <div>{count}</div>;
});
// TextView와 CountView의 부모 컴포넌트
const OptimizeTest = () => {
const [count, setCount] = useState(1);
const [text, setText] = useState("");
// count++ 함수
const countUp = () => { setCount(count + 1); };
// input의 value받는 함수
const textChange = (e) => { setText(e.target.value); };
return (
<div style={{ padding: 50 }}>
<div>
<h2>count</h2>
// CountView에게 count를 prop로 넘겨줌
<CountView count={count} />
<button onClick={countUp}>+</button>
</div>
<div>
<h2>text</h2>
// TextView에게 text를 prop로 넘겨줌
<TextView text={text} />
<input value={text} onChange={textChange} />
</div>
</div>
);
};
export default OptimizeTest;
React.memo를 통해 부모의 상태 변화에 따른 리렌더가 선택적으로 변경되었다. count의 변화에는 CountView 컴포넌트만 리렌더되고, text의 변화에는 TextView만 리렌더되는 성능의 최적화가 이루어진 셈이다.
const CounterA = React.memo(({ count }) => {
useEffect(() => {
console.log(`CounterA Update - count: ${count}`);
});
return <div>{count}</div>;
});
const CounterB = React.memo(({ obj }) => {
useEffect(() => {
console.log(`CounterB Update - obj.count: ${obj.count}`);
});
return <div>{obj.count}</div>;
});
const OptimizeTest = () => {
const [count, setCount] = useState(1);
const [obj, setObj] = useState({
count: 1, // 객체의 형태
});
// 버튼 클릭
const numberCounter = () => {
setCount(count);
};
// 버튼 클릭
const objCounter = () => {
setObj({
count: obj.count,
});
};
CounterA 컴퍼넌트는 버튼 클릭에 의한 state의 변화가 없다. 그렇기에 리렌더링 되지 않는다.
CounterB 컴퍼넌트는 객체를 상태 값으로 가지고 있고 변화가 없는 것 같다. 하지만 React.memo를 사용했다 해도
버튼을 클릭할 때마다 새로운 메모리 주소를 가진 다른 객체가 생성되기에 리렌더링 된다.
자바스크립트가 객체나 함수, 배열 같은 비원시 타입을 비교할 때 값에 의한 비교가 아닌 주소에 의한 얕은 비교를 수행한다. 객체들은 생성되자마자 고유한 메모리 주소를 가진다. 두 메모리 주소는 다르기에 값이 같더라도 다르다고 판단한다.
let a = { count: 1 }
let b = { count: 1 }
a === b // false
let c = { count: 1 }
let d = c
d === c // true
= 키워드를 통해 얕은 복사를 하여 같은 메모리 주소를 가리키게 된다. 이 내용에 대해 자세히 다루지는 않겠다.
function MyComponent(props) {
/* props를 사용하여 렌더링 */
}
function areEqual(prevProps, nextProps) {
/*
nextProps가 prevProps와 동일한 값을 가지면 true를 반환하고, 그렇지 않다면 false를 반환
*/
}
export default React.memo(MyComponent, areEqual);
React.memo의 두번째 인자로 areEqual 함수를 받고 있다. areEqual 함수는 이전의 props와 이후의 props를 받고 동일한 값을 가지면 true를 반환하게 하여 기존의 얕은 비교에서 깊은 비교로 성능 최적화할 수 있다.
const CounterB = ({ obj }) => {
useEffect(() => {
console.log(`CounterB Update - obj.count: ${obj.count}`);
});
return <div>{obj.count}</div>;
};
const areEqual = (prevProps, nextProps) => {
if (prevProps.obj.count === nextProps.obj.count) {
return true; // 이전 props와 현재 props가 같다 -> 리렌더링 하지 않는다
}
return false; // 이전 props와 현재 props가 다르다 -> 리렌더링 한다
};
const MemoizedCounterB = React.memo(CounterB, areEqual);
obj의 값(count)을 비교하여 깊은 비교를 수행하였다. CounterB는 areEqual함수의 판단에 따라 리렌더 여부를 결정한다.
const areEqual = (prevProps, nextProps) => {
return prevProps.obj.count === nextProps.obj.count;
}
useMemo와 React.memo 공통점과 차이점
공통점
React.memo와 useMemo 모두 props가 변하지 않으면(이전 props와 동일하면) 인자로 넘긴 함수는 재실행되지 않고, 이전의 메모이즈된 결과를 반환한다.
차이점
1. React.memo는 HOC, useMemo는 hook이다.
2. React.memo는 HOC이기 때문에 클래스형 컴포넌트, 함수형 컴포넌트 모두 사용 가능하지만, useMemo는 hook이기 때문에 오직 함수형 컴포넌트 안에서만 사용 가능하다.
useCallback
const memoizedCallback = useCallback(
() => {
doSomething(a, b);
},
[a, b],
);
useCallback은 메모이제이션된 콜백을 반환한다. 즉, 인자로 전달한 콜백 함수를 계속 재사용할 수 있도록 도와주는 훅이다. useMemo는 연산된 값을 재사용하는 것이고 useCallback은 콜백 함수의 재사용이다.
const onCreate = useCallback((author, content, emotion) => {
const created_date = new Date().getTime();
const newItem = {
author,
content,
emotion,
created_date,
id: dataId.current,
};
dataId.current += 1;
// 상태 변화 함수인 setState함수에 함수를 전달: 함수형 업데이트
// dependency array를 비워도 항상 최신의 state를 인자를 통해 참조
setData((data) => [newItem, ...data]);
}, []);
useCallback을 이용한 함수의 재생성과 함수를 재생성하면서 항상 최신의 state를 참조할 수 있도록 도와주는 함수형 업데이트 예시
Profiler API
Profiler는 React 애플리케이션이 렌더링하는 빈도와 렌더링 “비용”을 측정한다. Profiler의 목적은 메모이제이션 같은 성능 최적화 방법을 활용할 수 있는 애플리케이션의 느린 부분들을 식별해내는 것이다.
Profiler를 사용하여 Memo를 사용했을때와 안했을 때를 체크해보자
import React, { useEffect, useState } from 'react'
import Comments from './Comments';
const CommentList = [
{ title: "comment1", content:"message1", likes: 1 },
{ title: "comment2", content:"message2", likes: 1 },
{ title: "comment3", content:"message3", likes: 1 }
]
export default function Memo() {
const [comments, setComments] = useState(CommentList);
useEffect(() => {
const interval = setInterval(() => {
setComments((prevComments) => [
...prevComments,
{
title: `comment${prevComments.length+1}`, content:`message${prevComments.length + 1}`, likes: 1
}
])
}, 1000)
return () => {
clearInterval(interval);
}
},[]);
return <Comments commentList={comments} />;
}
부모인 Memo이다. CommentList를 useState default값으로 설정하고 useEffect를 사용하여 이전의 Comments의 길이에 +1하여 1초마다 찍어냈다. 해당 내용을 Comments 컴포넌트에 리턴하고 commentList를 props로 내보냈다.
import CommentItem from './CommentItem';
export default function Comments({ commentList }) {
return (
<div>
{commentList.map((comment) => (
<CommentItem
key={comment.title}
title={comment.title}
content={comment.content}
likes={comment.likes}
/>
))}
</div>
);
}
부모에게 받은 내용을 map으로 반복하여 CommentItem에 주었다.
function CommentItem({title, content, likes}) {
function onRenderCallback(
id, // 방금 커밋된 Profiler 트리의 "id"
phase, // "mount" (트리가 방금 마운트가 된 경우) 혹은 "update"(트리가 리렌더링된 경우)
actualDuration, // 커밋된 업데이트를 렌더링하는데 걸린 시간
baseDuration, // 메모이제이션 없이 하위 트리 전체를 렌더링하는데 걸리는 예상시간
startTime, // React가 언제 해당 업데이트를 렌더링하기 시작했는지
commitTime, // React가 해당 업데이트를 언제 커밋했는지
interactions // 이 업데이트에 해당하는 상호작용들의 집합
) {
// 렌더링 타이밍을 집합하거나 로그...
console.log(`actualDuration(${title}: ${actualDuration})`);
}
return (
<Profiler id="CommentItem" onRender={onRenderCallback}>
<div className='CommentItem'>
<span>{title}</span>
<br />
<span>{content}</span>
<br />
<span>{likes}</span>
</div>
</Profiler>
)
}
export default CommentItem;
Profile을 사용하였다. Profiler 안에서 Item들을 뿌려주어 console로 체크해보았다. 해당 예시를 메모를 사용하지 않았다.
memo를 사용하지 않았더니 Item이 늘어날 때 마다 모든 아이템들이 다 그려지고 있다. 3개 4개 5개 이런 씩으로. 하나의 컴포넌트가 추가되는데 모든 컴포넌트가 다 새로 그려지는 비효율적인 코드가 탄생했다.
Comments 한테 props로 commentList가 넘어갔는데 Memo.jsx에서 List를 계속 바꾸어줬다. 부모의 props의 dependency가 바뀌면서 자녀들 마저 영향을 받았다. 자녀들 중에 memo를 해놓으면 이미 그려졌던 컴포넌트는 반복해서 사용하기 떄문에 비효율을 줄일 수 있다.
export default memo(CommentItem);
memo를 추가하고 실행해보니 효율적인 동작을 했다.
onRender 콜백
Profiler는 onRender 함수를 prop으로 요구합니다. React는 프로파일 트리 내의 컴포넌트에 업데이트가 “커밋”될 때마다 이 함수를 호출합니다. 이 함수는 무엇이 렌더링 되었는지 그리고 얼마나 걸렸는지 설명하는 입력값을 받게 됩니다.
export default function Comments({ commentList }) {
return (
<div>
{commentList.map((comment) => (
<CommentItem
key={comment.title}
title={comment.title}
content={comment.content}
likes={comment.likes}
onClick={() => console.log('Comment 누름')}
/>
))}
</div>
);
}
Comments에 자식인 CommentItem에게 onclick 을 넘겨주었다. 동작을 살펴보니 memo의 기능을 상실하였다. 그 이유는 memo는 props가 동일한 상태일 때를 말한다. 위 코드를 보면 Item에 인라인으로 함수를 만들어주었기 때문에 onClick함수가 새로 렌더링될 때 마다 새로운 함수가 만들어진다. 이 함수로 인해 props가 바뀌어서 아무리 memo를 해놨어도 Memoization을 사용할 수 없었다.
export default function Comments({ commentList }) {
const handleChange = () => {
console.log("Comments 눌림");
}
return (
<div>
{commentList.map((comment) => (
<CommentItem
key={comment.title}
title={comment.title}
content={comment.content}
likes={comment.likes}
onClick={handleChange}
/>
))}
</div>
);
}
handleChange로 함수를 빼놓아도 달라지는 건 없었다. Comments 자체가 리렌더 되기 때문이다. 받는 props인 commentList가 바뀌기 때문에 Comments가 리렌더 된다. 여기서 설정한 함수도 계속 리렌더 되는 것이다. 이럴때에 useCallback을 사용한다.
const handleChange = useCallback(() => {
console.log("Comments 눌림");
},[]);
해당 형식으로 작성하면 memo가 제대로 동작한다. CommentItem에 전달하는 handleChange라는 변수가 useCallback으로 이것 또한 Memoization되었기 때문에 전달을 받아도 리렌더 되지 않는다.
Profile MDN: https://ko.reactjs.org/docs/profiler.html
useMemo, useCallback MDN: https://ko.reactjs.org/docs/hooks-reference.html#usememo
'개발자의 공부 > React' 카테고리의 다른 글
[React]useReducer (0) | 2022.10.24 |
---|---|
[React]State(내용 업데이트) (0) | 2022.10.24 |
[React]Page Routing (0) | 2022.09.30 |
[React]Lifecycle (0) | 2022.09.24 |
[React]React가 무엇이고 왜 사용하는가? + 라이브러리와 프레임워크 (0) | 2022.09.23 |