저는 useMemo와 useCallback를 배우며 이 두개를 어느상황에서 써야하는지 헷갈리는 경우가 많았습니다. 이 문서는 해당 부분에 대하여 학습하기 위해 정리한 문서입니다.
React에서 특정한 조건이 충족될 경우 컴포넌트의 리렌더링이 발생하게됩니다.
이로 인해 불필요한 렌더링이 빈번하게 발생하게 되고 이 부분을 최적화 하지 않으면 어플리케이션의 성능 저하로 이어져 사용자 경험이 떨어짐은 물론 메모리 사용량과 트래픽 등의 리소스가 늘어나게 될 수 있습니다.
컴포넌트 최적화를 공부하기에 앞서 React의 화면 업데이트 과정과 렌더링 조건을 알아야합니다. 이 개념들을 이해하지 못한 상태에서 최적화를 시도하면 오히려 코드가 복잡해지고 성능이 개선되는 것이 아닌 저하되는 부작용이 발생할수도 있기 때문입니다.
graph LR
A[Trigger] ==> B[Render] ==> C[Commit]
트리거는 리액트에서 렌더링을 일으키는 조건이라고 생각하면 됩니다.
React의 공식 문서에 따르면 트리거 단계에서는 두 가지 이유로 렌더링이 발생합니다.
위에서 언급한 렌더링 조건 외에 컴포넌트가 리렌더링 되는 조건을 4가지로 정리할 수 있습니다.
리액트는 상태(state)가 업데이트 된 경우 컴포넌트를 리렌더링합니다. 단 setState를 호출했을 경우 Object.is로 비교하여 이전상태와 값이 차이나지 않는 경우에는 리렌더링 하지 않습니다.
Object.is
리액트는Object.is() 알고리즘
을 통해 새로운 값을 그 이전 값과 같은지 비교합니다.
값이 객체인 경우엔 객체에 대한 ‘참조 값’을 비교합니다.
사용예시: Object.is(value1, value2);
참고링크 :https://blog.bitsrc.io/understanding-referential-equality-in-react-a8fb3769be0
React는 컴포넌트를 호출하여 반환된 자식 컴포넌트를 재귀적으로 호출하기 때문에, 부모 컴포넌트가 리렌더링될 때마다 화면에 표시되어야 하는 내용을 모두 파악할 때 까지 재귀적으로 자식 컴포넌트를 리렌더링합니다. 두 가지의 경우에는 이 경우에서 제외됩니다.
컨텍스트(Context)가 업데이트 되면 React는 해당 컨텍스트를 useContext 훅으로 참조하는 모든 컴포넌트를 자동으로 리렌더링 합니다.
커스텀 훅 내부에 있는 모든 것(상태 등)은 커스텀 훅을 사용하는 컴포넌트에 속해있기 때문에 커스텀 훅의 상태 또는 컨텍스트가 업데이트 되면 훅을 사용하는 컴포넌트가 리렌더링 됩니다.
Rendering이란
React가 컴포넌트를 호출하는 것을 의미합니다. Render 단계에서 React는 컴포넌트를 렌더링(호출)하여 화면에 표시할 내용을 파악합니다.
”Rendering”
과“DOM을 업데이트 하는 것”
은 엄연히 다른 것
Render 라는 이름 때문에 오해할 수 있지만, Render 단계에서는 화면에 변경된 컴포넌트를 표시하는 것이 아니라는 점을 인식하고 있어야 합니다.
classComponentinstance.render()
를 함수형인 경우 FunctionCoomponent()
를 호출하고 렌더 결과물을 저장합니다.Reconciliation(재조정) 이란?
컴포넌트 트리에서 렌더 결과물을 모두 수집하고나서, 새로운 객체 트리(보통 가상 DOM으로 불림)와 비교해 실제 DOM에 적용시켜야할 모든 변경사항 목록을 계산해 수집하는 과정입니다.
컴포넌트를 렌더링(호출)한 후 변경 사항을 실제 DOM에 적용하는 단계 입니다.
커밋 단계를 거쳐서 DOM을 업데이트하고 나면 React는 요청된 DOM 노드와 컴포넌트 인스턴스를 가리키도록 모든 참조사항들을 업데이트합니다. 그 후 componentDidMount와 componentDidUpdate 클래스 생명주기 메소드 또는 useLayoutEffect 훅을 동기적으로 실행하게 됩니다.
앞의 과정이 이루어진 후 짧은 타임 아웃을 세팅하고, 타임 아웃이 끝나면 모든 useEffect 훅을 실행합니다.
커밋 단계에서 DOM을 업데이트하면 브라우저는 화면을 다시 그립니다. 이 작업을 브라우저 렌더링이라고 부릅니다.
이 패턴은 빈번한 상태 변경이 발생하지만 해당 상태가 하위의 전체 트리에 영향을 주지 않을 때 사용합니다. 특히 렌더링 비용이 큰 컴포넌트를 성능 저하 없이 상태 관리와 분리할 수 있다는 점이 특징이며 부모-자식 간 상태 의존성을 완전히 끊고 구조적으로 분리할 때 사용합니다.
const ComponentWithScroll = ({left, right}: { left: React.ReactNode, right: React.ReactNode }) => {
const [value, setValue] = useState({}); // 1. 리렌더링이 트리거됨
return (
<div onScroll={(e) => setValue(e) }> {/* 1. 리렌더링이 트리거됨 */ }
{left} {/* 2. props이므로 리렌더링되지 않음 */}
<Something />
{right} {/* 2. props이므로 리렌더링되지 않음 */}
</div>
);
};
const Component = () => {
return (
<ComponentWithScroll>
left={<SlowComponent1 />} {/* 2. 리렌더링의 영향을 받지 않음 */}
right={<SlowComponent2 />} {/* 2. 리렌더링의 영향을 받지 않음 */}
/>
);
};
이 패턴을 사용해야 하는 경우
children props는 리렌더링 되지 않는 이유
React.createElement(Child,null,null) // 첫번째 argument는 타입이며, 두번째, 세번째 argument는 각각 porps와 children
- React.createElement는 매번 새로운 object를 반환하는 함수이며, children은 그 결과가 반환된 object 입니다.
- children prop은 말 그대로 prop이고, 한번 전달된 prop은 상위 컴포넌트가 리렌더 되지 않는한 갱신되지 않고 유지됩니다.
- 이전 렌더 시점과 비교해서 react Element가 달라지지 않았다면 그 내용이 변경되지 않았다 판되어 렌더링 되지 않습니다.
주의할 점
children props를 무분별하게 사용하는 경우 중첩이 깊어져 코드 가독성이 떨어질 수 있으니 이 부분을 신경써 작업해야 합니다.
props 변화가 없을 경우 컴포넌트를 다시 렌더링하지 않도록 최적화하는데 사용됩니다. 즉, 컴포넌트가 특정 props에 의존할 때 사용됩니다.
이 패턴을 사용해야 하는 경우
예시
function MovieViewsRealtime({ title, releaseDate, views }) {
return (
<div>
<Movie title={title} releaseDate={releaseDate} />
Movie views: {views}
</div>
);
}
이 어플리케이션은 주기적(매초)으로 서버에서 데이터를 폴링(Polling)해서 MovieViewsRealtime
컴퍼넌트의 views
를 업데이트합니다.
<MovieViewsRealtime
views={0}
title="Forrest Gump"
releaseDate="June 23, 1994"
/>
// After 1 second, views is 10
<MovieViewsRealtime
views={10}
title="Forrest Gump"
releaseDate="June 23, 1994"
/>
// After 2 seconds, views is 25
<MovieViewsRealtime
views={25}
title="Forrest Gump"
releaseDate="June 23, 1994"
/>
views의 새로운 숫자가 업데이트 될 때 마다 MovieViewsRealtime 컴포넌트 또한 리렌더링 됩니다. 이 Movie 컴포넌트 또한 title이나 releaseData가 같음에도 불구하고 리렌더링 됩니다.
이 때가 Movie 컴포넌트에 메모이제이션을 적용할 적절한 케이스 입니다.
const Movie = React.memo(({ title, releaseDate }) => {
console.log("Movie component rendered!");
return (
<div>
<h2>{title}</h2>
<p>Released on: {releaseDate}</p>
</div>
);
});
주의할 점
memoization은 말 그대로 메모리 리소스를 활용하여 데이터를 저장해두는 작업입니다. 때문에 무분별한 사용은 앱 기능 저하를 유발시킬 수 있으므로 React 개발자 도구 profiler를 사용하여 성능 병목 지점을 파악한 뒤 꼭 필요한 부분에만 React.memo를 사용한 최적화를 진행해야 합니다
이 패턴을 사용해야 하는 경우
계산 비용이 큰 연산인지 확인하는 방법
일반적으로 수천개의 개체를 만들거나 반복하는 경우가 아니라면 비용이 많이 들지 않습니다.
이 때 조금 더 정확히 확인하고 싶다면 콘솔 로그를 추가하여 소요된 시간을 측정할 수 있습니다.
console.time('filter array'); const visibleTodos = filterTodos(todos, tab); console.timeEnd('filter array');
이렇게 되면 개발자 도구 창의 console에서 작업을 수행하는데 소요한 시간을 확인할 수 있습니다.
전체적으로 기록된 시간이 목표보다 클 때 해당 계산의 메모이제이션을 진행합니다.
이 패턴을 사용해야 하는 경우
이벤트 핸들러 함수가 자주 재생성 되는 경우
하위 컴포넌트에 props로 전달되는 함수가 자주 재생성되는 경우
예시
function ParentComponent() {
const [count, setCount] = useState(0);
const handleClick = useCallback(() => {
setCount(prevCount => prevCount + 1);
}, []);
return (
<div>
<ChildComponent onClick={handleClick} />
</div>
);
}
function ChildComponent({ onClick }) {
return (
<button onClick={onClick}>Click me</button>
);
}
handleClick 함수가 ParentComponent 컴포넌트에서 생성되어 ChildComponent 에 props로 전달되고 있습니다.
만약 useCallback을 사용하지 않으면 ParentComponent가 리렌더링 될 때마다 handleClick 함수가 새로생성되어 참조가 바뀌므로 ChildComponent는 props가 변경되는걸로 간주하고 불필요한 리렌더링을 발생시키게 됩니다. 이를 방지하기 위해 useCallback을 사용하여 handleClick 함수를 메모이제이션 합니다.
최적화 방식 | 사용 조건 | 장점 | 단점 |
---|---|---|---|
React.memo | - 자식 컴포넌트가 동일한 props로 자주 렌더링되는 경우 | - props 변경이 없으면 렌더링 방지 | - props가 객체나 배열일 경우 효과 없음 |
children props | - 자식 컴포넌트가 부모 상태와 무관하며 독립적일 때 | - 독립적인 UI 요소를 상태 변경 없이 유지 가능 | - 깊은 중첩으로 코드 가독성 저하 가능 |
useMemo | - 비용이 큰 계산 결과를 전달해야 하며, 부모 상태 변경이 자식에게 영향 미치지 않을 때 | - 계산 비용 절감 및 참조 무결성 유지 | - 모든 경우에 불필요하게 사용하게 될 가능성 |
useCallback | - 부모에서 자식으로 함수형 props를 전달하며, 함수 재생성이 성능 문제를 유발할 때 | - 자식 컴포넌트의 불필요한 리렌더링 방지 | - 참조 관리가 과도하면 코드 복잡성 증가 |