리액트는 성능 최적화를 위한 다양한 기능과 도구를 제공한다. 성능 최적화는 곧 불필요한 렌더링을 최소화하는 것으로, 이번 아티클에서는 렌더링 횟수를 줄이고 불필요한 메모리 사용을 줄이는 것에 초점을 맞추어 어떻게 애플리케이션의 성능을 높일 수 있는지 알아볼 것이다.
시작 전에, 먼저 리액트에서 리렌더링이 일어나는 조건은 아래와 같다.
- 해당 컴포넌트의 state가 변경될 때
- 해당 컴포넌트가 받는 props의 값이 변경되었을 때
- 해당 컴포넌트의 부모 컴포넌트가 리렌더링되었을 때
1. Automatic batching (setState의 비동기 동작)
💡 batching이란?
- React가 더 나은 성능을 위해 여러 개의 state 업데이트를 하나의 리렌더링 (re-render)로 묶는 것
- React 개발자는 성능 최적화를 위해 배칭을 설계했다고 한다. 단 기간에 일어나는 상태변화를 매번 렌더링 시키지 않고, 일괄 처리함으로써 성능을 최적화한다.
function handleClick() {
setCount(c => c + 1);
setFlag(f => !f);
// 리액트는 이때 한번만 리렌더링 -> 이것이 배칭
}
React 17까지는 React가 클릭과 같은 브라우저 이벤트의 업데이트만 배칭을 해왔기 때문이고, 이 경우에 fetch 콜백에서 이벤트 핸들링이 완료된 이후에 state를 업데이트하기 때문에(또 리렌더링 발생) 배칭이 일관적으로 이루어지지 못하였다.
예를 들어, 데이터를 외부 소스로부터 가져와서 아래의 handleClick 함수 내부에서 state를 업데이트를 하고자 하면, React는 업데이트를 배칭하지 않고, 두 개의 독립적인 업데이트를 수행하였다.
function handleClick() {
fetchSomething().then(() => {
// React 17과 이전 버전에서는 이 업데이트들이
// 이벤트가 진행되는 중이 아닌, 완료된 후의 콜백에서 실행되기 때문에
// 배칭되지 않았다.
setCount((c) => c + 1); // 리렌더링을 발생시킨다.
setFlag((f) => !f); // 리렌더링을 발생시킨다.
});
}
그러나 React 18의 createRoot를 통해, 모든 업데이트들이 자동으로 배칭되게 된다. timeout, promise, native 이벤트 핸들러와 모든 여타 이벤트는 React에서 제공하는 이벤트와 동일하게 state 업데이트를 배칭할 수 있다.
리액트 18에서의 batching 🔽
setTimeout(() => {
setCount(c => c + 1);
setFlag(f => !f);
// 한번만 리렌더링 -> 배칭 작동
}, 1000);
fetch(/*...*/).then(() => {
setCount(c => c + 1);
setFlag(f => !f);
// 이때 한번만 리렌더링 -> 배칭 작동
})
이렇게 batching을 잘 이해하고 이용하면 불필요한 리렌더링을 줄이고 한번만 렌더링시킬 수 있다.
2. Windowing (목록, list 최적화)
다음은 공식문서에서 windowing에 대해 언급한 부분을 발취한 것이다.
애플리케이션에서 긴 목록(수백 또는 수천행)을 렌더링하는 경우 ‘windowing’이라는 기법을 사용하는 것을 추천합니다. 이 기법은 주어진 시간에 목록의 부분 목록만 렌더링하며 컴포넌트를 다시 렌더링하는 데 걸리는 시간과 생성된 DOM 노드의 수를 크게 줄일 수 있습니다.
💡 windowing이란?
- 간단히 말해, 전체 목록의 일부만 렌더링하는 기법으로, 사용자에게 보이는 것만(viewport 기준으로만) 렌더링하는 것이라고도 이해할 수 있다. 대규모 list들을 효율적으로 렌더링 시킬 수 있다.
- 스크롤 되면, 스크롤바 위치에 따라 어떤 요소를 디스플레이 해야 하는지를 계산하고, viewport에 들어오거나 나가는 DOM 요소들을 동적으로 생성 또는 삭제한다.
많은 행이 포함된 큰 테이블이나 목록을 표시하기 위해, 목록의 모든 항목을 로드하면 성능에 상당한 영향을 미칠 수 있다.
windowing 기법의 원리는, 처음에 렌더링되는 요소 수는 전체 목록의 매우 작은 하위 집합이며 사용자가 계속 스크롤하면 표시되는 콘텐츠의 창이 이동하도록 하여 목록의 렌더링 및 스크롤 성능을 개선한다.
창을 종료하는 DOM 노드는 재활용되거나 사용자가 목록을 아래로 스크롤할 때 즉시 최신 요소로 대체되므로, 렌더링된 모든 요소의 수가 창 크기에 따라 유지된다.
React에서는 Windowing을 사용할 수 있게 도와주는 다양한 라이브러리가 존재한다. (react-virtualized, react-window 등)
3. Memoization
위에서 언급한 리렌더링 조건의 3번(해당 컴포넌트의 부모 컴포넌트가 리렌더링되었을 때)의 경우에 집중해보자.
기본적으로 React의 부모 컴포넌트가 리렌더링되면 자식 컴포넌트도 리렌더링되는데, 이러한 동작 때문에 내가 현재 이벤트를 발생시킨 컴포넌트 이외의 컴포넌트도 의도치 않게 리렌더링되어 성능이 저하되는 경우가 빈번하다.
이러한 경우는 메모이제이션을 통해 최적화시킬 수 있다.
💡 memoization(메모이제이션)이란?
- 불필요한 연산을 줄이기 위해, 계산 결과를 메모리에 저장해 두고 동일한 계산이 요청될 때 다시 계산하지 않고 저장된 값을 반환하는 최적화 기법이다.
- React에서는 계산값 및 함수를 메모이제이션할 수 있는 hook과 컴포넌트를 메모이제이션할 수 있는 HOC을 자체적으로 제공하는데, useMemo, useCallback, React.memo가 그에 해당한다.
3-1) React.memo
앞서 말했듯 부모 컴포넌트가 리렌더링될 경우 하위 컴포넌트도 리렌더링이 발생한다. 하지만 props가 변하지 않는 이상 하위 컴포넌트가 리렌더링해야할 이유가 있을까?
React.memo는 리액트에서 제공하는 고차 컴포넌트(HOC)로, 컴포넌트의 렌더링 결과를 메모이징(저장)해 성능을 최적화한다.
memo는 해당 컴포넌트가 받는 props를 얕은 비교를 통해 검사하여, 부모가 리렌더링 되더라도 컴포넌트의 props가 변경되지 않는 한 리렌더링을 건너뛴다.
memo(Component, arePropsEqual?)
- 매개변수
- Component: 메모이징하려는 컴포넌트. 컴포넌트는 변경되지 않고 memoized된 컴포넌트를 새롭게 반환,
- arePropsEqual(optional한 인자): 컴포넌트의 이전 props와 새로운 props의 두가지 인자를 받는 함수, 일반적으로는 이 함수를 지정하지 않음 (리액트는 기본적으로 Object.is로 각 props를 비교)
사용법
React.memo를 해당 컴포넌트에 감싸주면 됨
import React from 'react';
const ComponentName = React.memo(function SomeComponent(props) {
// ...
});
사용 시 주의할 점
memo로 최적화하는 것은 컴포넌트가 정확히 동일한 props로 자주 리렌더링되고, 리렌더링 로직이 비용이 많이 드는 경우에만 유용하다.
얕은 비교만 수행하므로, 객체나 배열 같은 참조 타입의 props가 변경될 때 문제가 생길 수 있다.
객체, 배열, 함수는 참조 타입인데, prop으로 참조 타입 값을 받게 되면, 부모 컴포넌트가 리렌더링되면서 내용은 변하지 않더라도 참조가 변경되게 되므로 얕은 비교를 하는 React.memo에서 값이 변경되었다고 판단하고 리렌더링을 발생시킨다. 결국 메모이제이션으로 인한 메모리만 낭비하게 될 수도 있다.
이 상황을 방지하려면 참조값도 동일하게 유지해줄 필요가 있는데, React에서는 useMemo, useCallback 등의 훅을 제공해 참조값을 동일하게 유지하도록 한다.
3-2) useMemo
리액트에서 제공하는 메모이제이션 훅으로, 연산의 결과값을 메모이제이션한다. (배열이나 객체처럼 참조 타입의 '값'을 메모이제이션, 비용이 높은 로직의 재계산 생략)
const cachedValue = useMemo(calculateValue, dependencies)
사용법
첫번째 인자 - 메모이제이션할 연산
두번째 인자 - 의존성 배열
useMemo를 사용하여 값을 초기화하고, 의존성 배열에 특정 값을 지정해주면 의존성 배열 값이 변하지 않는 이상 재초기화가 되지 않도, 인스턴스가 새로 생성되지 않으므로 참조가 변경되지 않는다.
import { useMemo } from 'react';
function TodoList({ todos, tab }) {
const visibleTodos = useMemo(
() => filterTodos(todos, tab),
[todos, tab]
);
// ...
}
주의해야 할 점
의존성 배열을 통해 메모이제이션을 활용하기 때문에, 의존성 배열을 잘 설정해줘야 한다. 잘못 설정할 경우 의도와는 다르게 메모이제이션되어 메모리 사용량만 증가해 성능 저하로 이어질 수 있다.
3-3) useCallback
함수를 메모이징하여 최적화하는 훅이다. (리렌더링 간에 함수 정의를 캐싱)
const cachedFn = useCallback(fn, dependencies)
사용법
useMemo와 동일
첫 번째 인자 - 메모이징할 함수
두 번째 인자 - 의존성 배열
의존성 배열에 지정된 값이 변하지 않으면, 메모이징된 함수를 그대로 사용한다.
import { useCallback } from 'react';
export default function ProductPage({ productId, referrer, theme }) {
const handleSubmit = useCallback((orderDetails) => {
post('/product/' + productId + '/buy', {
referrer,
orderDetails,
});
}, [productId, referrer]);
cf)
useCallback에 넘겨주는 함수는 매 렌더마다 반드시 생성된다.
함수를 반드시 생성하고, 의존성 배열을 검사하여 값이 변했다면 새로 생성한 함수를 리턴하고, 변하지 않았다면 기존에 메모이징했던 함수를 재사용 한다. 그러나 매번 함수를 생성한다고 해서 비용이 아주 크진 않다. (함수 생성은 비용이 저렴하기에)
'리액트 심화 스터디' 카테고리의 다른 글
🏄♀️ 리심스 4주차 - 컴포넌트 패턴 (2) | 2024.11.19 |
---|---|
[Week 4] Compound Component Pattern, HOC, React Portal (2) | 2024.11.19 |
[Week 3] Automatic batching, windowing, useMemo, useCallback (2) | 2024.11.12 |
🏄♀️ 리심스 2주차 - 리액트 렌더링 (2) | 2024.11.05 |
[Week 2] 리액트 렌더링 프로세스, React fiber (2) | 2024.11.05 |