리액트는 초기 렌더링과 리렌더링으로 화면을 업데이트합니다. 리렌더링은 4가지 경우에 일어납니다. (props가 변경될 때, state가 변경될 때, 부모 컴포넌트가 리렌더링될 때, context 내 state가 변경될 때) 이런 경우들이 계속 발생하면 불필요한 리렌더링을 유발시키기 때문에 리액트 렌더링을 최적화하는 것은 애플리케이션의 성능과 사용자 경험을 향상시키는 데 매우 중요합니다.
리액트 렌더링 최적화을 하는 방법이 여러 가지가 있지만, 이 글에서는 대표적으로 두 가지에 대해 소개해드리겠습니다.
메모이제이션(Memoization)은 프로그래밍에서 함수의 성능을 향상시키기 위해 이전에 계산된 결과를 캐시에 저장하고, 동일한 입력으로 다시 호출되면 캐시된 결과를 반환하는 최적화 기법입니다.
React에서는 memo, useCallback, useMemo와 같은 메모이제이션 기법을 통해 성능을 향상시키고 코드의 복잡성을 줄일 수 있습니다. 다만 메모이제이션은 메모리에 특정한 값을 저장하는 것이기 때문에 정말 필요하지 않은 경우에도 남용하면 오히려 성능을 저하시킬 수 있습니다.
부모 컴포넌트가 리렌더링을 하면 그 컴포넌트의 하위 컴포넌트들이 전부 리렌더링을 일어납니다. 혹은 props가 변경될 때 props를 받은 자식 컴포넌트는 리렌더링이 일어납니다. 이 두 가지 상황을 막기 위해 React.memo()
를 사용하여 메모이제이션으로 컴포넌트 최적화를 할 수 있습니다. 즉 React.memo()
를 사용하면, 새로운 props가 이전 props와 얕은 복사를 통해 비교하고 동일하다면 부모가 리렌더링될 때 새로운 props가 이전 props와 동일하면 리렌더링 되지 않는 컴포넌트를 만들 수 있습니다. 여기서 알 수 있는 사실은 얕은 복사이기에 배열, 객체, 함수에서는 React.memo는 동작하지 않는다는 사실임을 주의하여야 합니다.
const Greeting = memo(function Greeting({ name }) {
return <h1>Hello, {name}!</h1>;
});
export default Greeting;
사용 방법으로는 컴포넌트를 memo()로 감싸면 됩니다.
const Greeting = ({ name }) => {
return <h1>Hello, {name}!</h1>;
};
export default memo(Greeting);
이보다 더 간단하게 할 수 있는 방법이 있습니다. export default 뒤에 memo로 감싸주면 됩니다. 이러면 더 가독성이 좋아질 수 있습니다.
useMemo
는 재렌더링 사이에 계산 결과(값)를 캐싱할 수 있게 해주는 React Hook입니다. 주로 복잡하거나 무거운 연산을 해서 도출하는 값에 대해 최적화를 할 때 많이 사용됩니다.
import { useMemo } from 'react';
function TodoList({ todos, tab, theme }) {
const visibleTodos = useMemo(() => filterTodos(todos, tab), [todos, tab]);
// ...
}
useMemo
는 콜백 함수와 의존성 배열을 인자로 받고, 의존성 배열이 변경되기 전까지 재렌더링 사이의 계산 결과를 캐싱합니다. 위 코드에서는 todos과 tab이 마지막 렌더링 때와 동일한 경우, 앞서 언급한 것처럼 useMemo로 계산을 감싸면 이전에 계산된 visibleTodos를 재사용할 수 있습니다.
useCallback은 리렌더링 간에 함수 정의를 캐싱해 주는 React Hook입니다. useMemo와 다르게 값이 아닌 함수를 캐싱합니다. 그리고 useMemo의 조건과 유사하게 의존성 배열이 변경되기 전까지 함수를 캐싱합니다.
import { useCallback } from 'react';
export default function ProductPage({ productId, referrer, theme }) {
const handleSubmit = useCallback((orderDetails) => {
post('/product/' + productId + '/buy', {
referrer,
orderDetails,
});
}, [productId, referrer]);
지연 로딩(Lazy Loading)은 필요한 시점에만 리소스를 로드하여 애플리케이션의 초기 로딩 속도를 개선하는 최적화 기법입니다. 즉, 사용자가 실제로 필요로 할 때 데이터를 가져오거나 컴포넌트를 로드하는 방식입니다.
지연 로딩을 통해 초기 로드 시간 단축, 리소스 사용 최적화, 사용자 경험 향상의 장점을 얻을 수 있습니다.
React.lazy()
는 로딩 중인 컴포넌트 코드가 처음으로 렌더링 될 때까지 연기할 수 있습니다.
// 사용법은 lazy()로 로드를 지연시키고 싶은 컴포넌트를 아래와 같이 작성하면 됩니다.
import { lazy } from 'react';
const MarkdownPreview = lazy(() => import('./MarkdownPreview.js'));
Suspense와 React.lazy()를 통해 우아하게 비동기를 처리할 수 있습니다. 구체적으로 React.lazy()를 통해 컴포넌트를 코드 분할할 수 있어 필요한 시점에 컴포넌트를 로드할 수 있고, Suspense는 로딩 중인 컴포넌트를 처리하기 위해 사용합니다.
// 실제 예시
import { useState, Suspense, lazy } from 'react';
import Loading from './Loading.js';
const MarkdownPreview = lazy(() => delayForDemo(import('./MarkdownPreview.js')));
export default function MarkdownEditor() {
const [showPreview, setShowPreview] = useState(false);
const [markdown, setMarkdown] = useState('Hello, **world**!');
return (
<>
<textarea value={markdown} onChange={e => setMarkdown(e.target.value)} />
<label>
<input type="checkbox" checked={showPreview} onChange={e => setShowPreview(e.target.checked)} />
Show preview
</label>
<hr />
{showPreview && (
<Suspense fallback={<Loading />}>
<h2>Preview</h2>
<MarkdownPreview markdown={markdown} />
</Suspense>
)}
</>
);
}
// 로딩 상태를 확인하기 위해, 테스트를 위한 지연값을 추가합니다.
function delayForDemo(promise) {
return new Promise(resolve => {
setTimeout(resolve, 2000);
}).then(() => promise);
}
위 코드의 설명을 덧붙이자면 showPreview
로 인해 조건부 렌더링되는 MarkdownPreview
라는 컴포넌트를 lazy()
를 통해 지연 로딩할 수 있어서 초기 렌더링에서 제외가 됩니다. 그리고 Suspense
를 통해 로딩 중인 컴포넌트를 대체할 수 있게 fallback UI를 띄워주는 역할을 합니다.
리액트 렌더링 최적화은 성능과 사용자 경험을 개선하기 위한 방법입니다. 그 중에서 메모이제이션은 결코 공짜가 아니고 최적화를 포함한 모든 연산에는 비용이 들 수 있다는 사실을 잊으면 안 된다라는 교훈을 얻었습니다. 그리고 초기 렌더링 속도를 단축시켜주기 위해 lazy()와 Suspense에 대해 알아보았습니다. 이 같은 리액트 내장 기능을 통해 코드를 선언적으로 작성할 수 있어서 가독성이 더 좋아질 것 같습니다.