프론트엔드 개발에서 렌더링 최적화는 웹 페이지나 애플리케이션을 로드하고 렌더링할 때 발생할 수 있는 성능 문제를 해결하고, 페이지가 빠르게 로드되고 사용자에게 더 나은 경험을 제공하기 위해 수행되는 기술적인 최적화 작업을 의미한다.
컴포넌트가 렌더링되는 시점은 자신의 state가 변경되었거나 부모에게서 받는 props가 변경되었을 때이다.
useMemo
useMemo는 계산된 값을 메모이제이션하여 반환하는데 사용된다. 메모제이션은 결과 값을 캐싱하여 동일한 계산이 반복되는 것을 방지하여 성능을 최적화하는 기술이다.
useMemo는 복잡한 계산의 결과 값을 저장하여, 해당 값이 필요할 때마다 계산하는 것이 아니라, 이전에 계산된 값을 캐싱하여 재사용할 수 있도록 한다. 즉, 불필요한 계산을 줄여 성능을 향상시킨다.
useMemo는 두 개의 인자를 받는다. 첫 번째 인자에는 계산 함수가 들어오는데, useMemo에는 이 함수의 반환 값이 메모이제이션된다. 두 번째 인자는 의존성 배열이다. 의존성 배열에는 useMemo가 메모이제이션된 값을 재계산해야 할 상황을 결정하는 변수나 상태 값들을 나열한다. 의존성 배열에 나열된 값들 중 하나라도 변경되면, useMemo는 새로운 값을 계산하고 반환한다. 만약 의존성 배열이 빈 배열이라면, useMemo는 오직 한 번만 계산하고 메모이제이션된 값을 계속해서 반환한다.
useCallback
useCallback은 함수를 메모이제이션하여 반환하는데 사용된다.
useCallback도 두 개의 인자를 받는다. 첫 번째 인자는 메모이제이션된 함수이다. 이 함수는 useCallback이 반환하는 새로운 함수를 호출할 때마다 메모이제이션된 값을 반환한다. 두 번째 인자는 의존성 배열이다. 의존성 배열은 useCallback이 메모이제이션된 함수를 재생성해야 하는 상황을 결정하는 변수나 상태 값들을 나열한다. 의존성 배열에 나열된 값들 중 하나라도 변경되면, useCallback은 새로운 함수를 생성하고 반환한다. 만약 의존성 배열이 빈 배열이라면, useCallback은 최초 한 번만 함수를 생성하고 메모이제이션된 값을 반환한다.
useCallback을 사용하여 메모이제이션된 함수는 일반적으로 자식 컴포넌트의 속성값으로 전달될 때 유용하다. 부모 컴포넌트에서 새로운 함수를 생성하지 않고 메모이제이션된 함수를 전달하여 자식 컴포넌트가 다시 랜더링될 때마다 불필요한 함수 생성을 방지할 수 있다.
React.memo
React.memo는 React에서 제공하는 고차 컴포넌트(HOC) 중 하나로, 컴포넌트를 감싸고 있는 또 다른 컴포넌트를 반환한다. 이 고차 컴포넌트는 함수형 컴포넌트를 메모이제이션하여 props가 변경되지 않은 경우 이전에 렌더링한 결과를 재사용함으로써 불필요한 렌더링을 방지한다. 즉 React.memo를 통해 동일한 결과를 호출하는 컴포넌트에 대해서 사용하면 효율성을 증가시킬 수 있다.
Code Splitting은 웹 페이지나 애플리케이션의 자원을 여러 개의 파일로 분리하여, 필요한 자원만 로드하여 사용자가 처음 페이지를 로드할 때 로드해야 할 자원의 양을 최소화하여 렌더링 성능을 개선하는 기술이다. 이를 위해서는 컴포넌트들을 작은 청크(chunk)로 나누어 불러오는 것이 좋다. Code Splitting을 사용하면 웹 페이지에서 특정 기능을 사용하기 위해 필요한 자바스크립트 파일을 미리 로드하지 않고, 해당 기능이 필요한 시점에서 동적으로 로드할 수 있다.
import()
이를 위해 JavaScript에서 제공하는 import 함수를 사용하여 동적으로 모듈을 불러올 수 있는데, 이를 Dynamic import라고 부른다. Dynamic import로 가져온 모듈은 bundle.js에 저장되는 것이 아니라, 별도의 chunk 파일로 저장된다. 이 chunk 파일은 해당 모듈이 필요한 시점에 비동기적으로 다운로드되고 실행된다. 이를 통해 초기 로딩 시간을 줄이고, 필요한 모듈만 불러와서 메모리를 절약할 수 있다. 이 방법을 사용하면, 앱의 로딩 속도를 개선할 수 있다.
💬 math.js
export default function add(a, b) { return a + b; }
💬 index.js
// export default로 내보내고 import() 함수로 가져올 때, module.add가 아니라, module.default로 가져와야 함! import("./math").then((math) => console.log(math.default(1, 2)));
React.lazy()
import() 함수만을 이용한 방법은 Promise를 직접 처리해야하기 때문에 코드 작성이 번거롭다. React에서는 이러한 코드 스플리팅 방법을 쉽게 구현할 수 있도록 React.lazy 함수를 제공한다. 이 함수는 동적으로 로딩되는 컴포넌트를 렌더링할 때 사용된다. 이 방법은 webpack, Parcel 등과 같은 모듈 번들러에서 자동으로 코드 분할을 처리하기 때문이다. 즉 Promise 처리를 직접 하지 않아도 되므로 코드가 더 간결해지고, 가독성도 높아진다. React.lazy를 사용할 때 로딩 중임을 표시하기 위해 Suspense 컴포넌트를 함께 사용한다.
import React, { lazy, Suspense } from "react";
const MyComponent = lazy(() => import("./MyComponent"));
function App() {
return (
<div>
<Suspense fallback={<div>Loading...</div>}>
<MyComponent />
</Suspense>
</div>
);
}
export default App;
React Suspense는 데이터 로딩이나 코드 분할 등과 같이 지연되는 작업을 처리하는 데 사용된다. Suspense는 React에서 데이터 로딩 및 코드 분할과 같은 비동기 작업을 처리할 때, 사용자가 로딩 상태를 기다리는 동안 대기 시간을 보낼 필요 없이 쉽게 처리할 수 있도록 한다.
Suspense를 사용하지 않는다면 "워터풀"을 겪을 수 있다. 워터폴이란 비동기 작업이 이전 작업의 완료를 기다리는 동안 발생하는 지연 현상을 말한다. 예를 들어, 어떤 컴포넌트에서 첫 번째 비동기 작업이 완료되기 전까지 두 번째 비동기 작업이 시작되지 않는 경우 워터폴이 발생하므로, 성능 저하가 일어난다. 그렇다면 Promise.all 등을 활용하여 병렬로 여러 개의 비동기 작업을 수행하고, 모든 작업이 완료될 때까지 기다린 후에 결과를 반환하는 방식을 사용할 수 있다. 이방식은 워터풀 문제는 해결할 수 있지만, 모든 데이터를 불러온 후에야 화면을 렌더링할 수 있기 때문에 대기 시간이 짧은 비동기 작업 역시 뒤늦게 처리된다는 단점이 있다.
Suspense를 사용하면 기존의 [Start fetching] - [Finish fetching] - [Start rendering] 방식을 [Start fetching] - [Start rendering] - [Finish fetching] 방식으로 변경할 수 있다. 즉 응답이 오는 것을 기다리지 않아도 되기 때문에, 로딩이 완료되지 않아도 컴포넌트가 렌더링을 시작한다. 렌더링 중 아직 불러온 데이터가 없다면 이 컴포넌트는 "정지"되고, 리액트는 이 컴포넌트를 스킵한 후 다른 컴포넌트의 렌더링을 시도한다.
React-Query는 Suspense를 이용한 개발을 지원하는 라이브러리이다. React-Query에서는 useQuery 훅을 사용하여 데이터를 불러올 수 있으며, 이 훅을 사용하면 데이터가 로딩 중인지, 성공적으로 로딩되었는지, 에러가 발생했는지 등의 상태를 손쉽게 다룰 수 있는데, 이와 함께 Suspense fallback 컴포넌트를 이용한다면 로딩 상태를 쉽게 핸들링할 수 있다.
아래 방식으로 React-Query에 Suspense를 세팅할 수 있다.
// 전역 세팅 const queryClient = new QueryClient({ defaultOptions: { queries: { suspense: true, }, }, });
// 개별 세팅 useQuery(queryKey, queryFn, { suspense: true });
Suspense Mode는 Suspense 컴포넌트를 사용하여 구현되며, Suspense 컴포넌트 내에서 비동기 작업을 처리한다. Suspense 컴포넌트의 fallback 속성은 로딩 상태를 처리한다. fallback 속성에는 일반적으로 로딩 상태를 보여주는 컴포넌트를 지정한다. 어떤 트리의 모든 컴포넌트를 렌더링해도 렌더링할 데이터를 찾지 못한다면, 해당 트리의 '위'에 존재하는 가장 가까운 Suspense 컴포넌트의 fallback 속성을 찾아 출력한다. 이렇게 위쪽에서 대체 컨텐츠를 찾는 특성은 컴포넌트 내부에서 처리할 코드를 줄여주는 장점이 있다.
<QueryClientProvider client={queryClient}> <Suspense fallback={<span>Loading...</span>}> <BrowserRouter> <Routes> <Route path="/" element={<Home />} /> <Route path="/edit" element={<Edit />} /> </Routes> </BrowserRouter> </Suspense> </QueryClientProvider>
useQuery는 기본적으로 병렬적으로 동작하지만 Suspense Mode로 사용하면 useQuery는 병렬적으로 동작하지 않는다. 이를 해결하기 위해 아래와 같이 useQuery 대신 useQueries를 통해 병렬 처리를 구현할 수 있다.
const results = useQueries([ { queryKey: ["@getUser"], queryFn: getUser, staleTime: Infinity }, { queryKey: ["@getPosts"], queryFn: getPosts, staleTime: Infinity }, ]);