흔히 React에서 성능최적화 기능을 말하라고하면 3가지 useMemo,useCallback,memo이다.
useMemo,useCallback : Hook
memo: API
useMemo는 리렌더링 중에 계산한 결과값을 캐시해주는 React Hook
const cachedValue = useMemo(calculateValue, dependencies)
import { useMemo } from 'react';
function TodoList({ todos, tab }) {
const visibleTodos = useMemo(
() => filterTodos(todos, tab),
[todos, tab]
);
// ...
}
Parameters
calculateValue: 캐시하고 싶은 값을 계산하는 함수
이 함수는 순수 함수(side effect 없이 동일한 입력에 대해 항상 동일한 출력이어야 함)여야 하고, 인자를 받지 않아야 하며, 아무 타입의 값이든 반환할 수 있다.
React는 이 함수를 초기 렌더링 시점에 한 번 호출합니다.
그 다음 렌더링들 중 의존성 배열(dependencies)이 이전과 동일하다면 이전에 저장해둔 값을 그대로 반환한다.
반대로 의존성이 바뀌었다면, React는 calculateValue를 다시 호출해서 새로운 값을 얻고, 그 값을 저장해두어 이후에 재사용할 수 있다.
요약하면 의존성 배열이 바뀌지 않았다면 이전에 사용하던 calculateValue를 그대로 가져와서 사용한다.
Returns
초기 렌더링에서 useMemo는 인수 없이 호출한 calculateValue를 반환한다.
Caveats
딱히 중요한 내용은 없어서 스킵
NOTE
반환 값을 캐싱하는 것을 memoization이라고 한다.
일반적으로 대부분의 계산은 매우 빠르기 때문에 문제가 되지 않으나 큰 배열을 필터링하거나 변환하거나 비용이 많이 드는 계산을 수행하는 경우 데이터가 변경되지 않았다면 다시 계산하지 않는 것이 좋다.
NOTE
useMemo는 성능 최적화를 위해서만 사용해야 한다.
만약 코드가useMemo없이 작동하지 않는다면 근본적인 문제를 먼저 찾아서 해결해야 한다.
그런 다음 성능 향상을 위해useMemo를 사용할 수 있다.
해당 계산 비용이 많이 드는지 측정할 수 있을까?
공식문서는 아래와 같이 설명한다.
일반적으로 수천 개의 객체를 생성하거나 반복하는 경우가 아니라면 비용이 많이 들지 않을 것이다.
확실하게 알기 위해선 console.log를 추가해 코드에서 소요된 시간을 측정할 수 있다.
console.time('filter array');
const visibleTodos = filterTodos(todos, tab);
console.timeEnd('filter array');
NOTE
console.time,console.timeEnd
Date()함수를 이용하여 시간차를 구해도 되지만 그보다는console의 메서드로 제공되는
console.time()과console.timeEnd()를 이용하는 것이 더 간편하다.
측정하려는 인터랙션(예: 입력창에 타이핑)을 수행해서 다음과 같은 결과를 확인할 것
→ Ex)filter array: 0.15ms
이렇게 출력된 전체 시간의 합이 의미 있는 수준(예: 1ms 이상)이라면
값을 memoization(useMemo) 하는 것이 도움이 될 수 있다.
측정한 인터렉션 값을 실험적으로 useMemo로 감싸본 뒤,
해당 인터랙션에 대해 전체 로그된 시간이 실제로 줄어드는지 확인해보면 좋다.
useMemo는 첫 번째 렌더링 시간을 줄여주는 도구가 아니다. 단지 이후 불필요한 업데이트 작업을 건너뛰기 해주는 도구다.
TEST TIP
여러분의 컴퓨터가 사용자 컴퓨터보다 더 빠를 가능성이 높으므로 인위적인 속도 저하를 통해 성능을 테스트해 보는 것이 좋다.
Ex) Chrome의 CPU 속도 조절 옵션
useMemo 남용에 대해 리액트는 다음과 같이 설명한다.
memoization이 불필요한 경우
memoization이 유용한 경우
useMemo최적화는 다음처럼 몇 가지 경우에만 가치가 있다(case)
인터렉션 속도가 느리며 의존성 배열이 거의 바뀌지 않는 경우
어떤 값을 memo로 감싼 컴포넌트에 prop으로 전달할 때
→ 값이 바뀌지 않았다면 굳이 리렌더링이 필요 없으므로 이럴 때 useMemo를 써주면 해당 값이 바뀌지 않는 한 리렌더링을 유발하지 않음
해당 값이 나중에 어떤 Hook(useEffect, useMemo, useCallback 등)의 의존성 배열에 쓰여질 예정이라면
값이 불필요하게 자주 바뀌지 않도록 useMemo를 통해 안정화 시켜주는게 좋다.
이 외의 경우에서는 크게 다른 점이 없어서 굳이 남용을 할 필요가 없다.
오히려 남용할 경우 코드의 가독성이 떨어진다.
다음 몇가지 원칙을 따르면 굳이 많은 memoization작업을 불필요하게 만들 수 있다.
컴포넌트가 다른 컴포넌트를 시각적으로 감싸는 역할이라면 자식 요소(JSX)를 children으로 감싸 줄 것
→ wrapper 컴포넌트가 자신의 상태만 업데이트해도 React는 자식들을 리렌더링할 필요가 없다는 걸 알 수 있다.
상태는 지역(local state)으로 유지하고 꼭 필요한 경우가 아니라면 위로 올리지 말 것
→ Ex) Form 입력 값, 마우스 오버 상태처럼 일시적인 state는
컴포넌트 최상단이나 전역 상태 관리 라이브러리에 둘 필요가 없다.
렌더링 로직은 순수하게 유지할 것
만약 컴포넌트가 리렌더링될 때 문제가 발생하거나 화면에 이상한 깜빡임 잔상 같은 시각적 문제가 생긴다면
그건 컴포넌트에 버그가 있는 것이다.
메모이제이션으로 덮지 말고 버그 자체를 고칠 것!
상태를 변경하는 불필요한 Effect는 피할 것
React 앱에서 성능 문제가 생기는 가장 흔한 원인은 Effect에서 상태 업데이트가 이어지면서 컴포넌트가 계속 리렌더링되는 경우이다.
의존성 배열에 불필요한 값을 넣는 것을 지양할 것
NOTE
1번 항목: 코드로 보는 렌더링 최적화 예제
컴포넌트가 다른 컴포넌트를 시각적으로 감싸는 역할이라면 자식 요소(JSX)를children으로 감싸 줄 것
→ wrapper 컴포넌트가 자신의 상태만 업데이트해도 React는 자식들을 리렌더링할 필요가 없다는 걸 알 수 있다.
아래는 children으로 감싸준게 아닌 코드
import React, { useState } from "react";
import "./App.css";
const Child = () => {
console.log("Child Component render-console");
return (
<React.Fragment>
<div>Child Components</div>
</React.Fragment>
);
};
const Parent = () => {
console.log("Parent Component render-console");
const [toggle, setToggle] = useState(false);
return (
<React.Fragment>
<Child />
<span>toggle: {toggle}</span>
<button onClick={() => setToggle((prev) => !prev)}>re-render</button>
</React.Fragment>
);
};
export default function App() {
return (
<div>
<React.Fragment>
<Parent />
</React.Fragment>
</div>
);
}
children을 통한 렌더링 최적화 코드
import React, { useState } from "react";
import "./App.css";
const Child = () => {
console.log("Child Component render-console");
return (
<React.Fragment>
<div>Child Components</div>
</React.Fragment>
);
};
const Parent = ({ children }: { children: React.ReactNode }) => {
console.log("Parent Component render-console");
const [toggle, setToggle] = useState(false);
return (
<React.Fragment>
{children}
<span>toggle: {toggle}</span>
<button onClick={() => setToggle((prev) => !prev)}>re-render</button>
</React.Fragment>
);
};
export default function App() {
return (
<div>
<React.Fragment>
<Parent>
<Child />
</Parent>
</React.Fragment>
</div>
);
}
JSX는 함수처럼 평가될 때마다 새 React 엘리먼트 객체를 생성하기에 <Child />를 직접 넣으면 매번 새롭게 생겨서 리렌더되고 children으로 전달하고 참조(reference)가 유지되면 React가 최적화해서 리렌더링을 건너뛴다.
→ React의 최적화된 reconciliation
useCallback는 리렌더링 중에 정의한 함수를 캐시해주는 React Hook
const cachedFn = useCallback(fn, dependencies)
Parameters
fn: 캐싱하고 싶은 함수
dependencies : 생략
Returns
fn 함수 자체를 그대로 반환Caveats
useCallback으로 저장한 캐시된 함수를 유지useCallback을 단순히 참조 유지용으로 쓴다면 차라리 state나 ref 같은 다른 방식이 더 적절할 수 있다.생각보다
useCallback은 특별한게 없어서 여기까지만 작성
keypoint : 함수를 캐싱한다.
useMemo 공식문서의 Memoizing a function이라는 제목이 있는데
여기 글을 좀 가져와서 작성한 내용임
내부에서 함수를 useMemo로 메모이제이션하려면useMemo 내부에서 "함수를 반환하는 함수"를 만들어야 함
useMemo 사용 Xfunction App () {
const handleClickFn = () => {
console.log("Clicked");
}
return //...
}
export default App;
useMemo를 통한 함수 캐싱function App () {
const memoizedHandleClick = useMemo(() => {
return () => {
console.log("Clicked");
}
},[])
return //...
}
export default App;
콜백이 두번이나 일어나서 가독성이 떨어짐 이걸 보완하기 위해 나온게 useCallback임
useCallback을 통한 함수 캐싱function App () {
const memoizedHandleClick = useCallback(() => {
console.log("Clicked");
}, []);
return //...
}
공식문서에 따르면 useMemo로 함수를 캐싱할때나 함수 캐싱 전용 훅인 useCallback을 사용할때나 문법적 차이 말고는 기능은 동일하다 함