다음 토이프로젝트의 개발 기록이다.
리액트 컴포넌트를 잘 구현하는 것도 중요하겠지만, 얼마나 최적화가 잘 되었는지도 중요한 사항이다. Lighthouse, profiler 등을 사용해서 프로젝트의 성능을 분석해보고 최적화를 진행한 후 얼마나 개선되었는지 비교해보는 과정을 진행해보자.
Lighthouse, React Profiler(react devtools)는 Chrome 확장 프로그램이 제공하는 웹사이트 성능 측정 도구이다. 설치 후 개발자 도구(F12)를 열어서 화면을 녹화해 검사를 진행할 수 있다.
Lighthouse는 자체적으로 페이지를 로드하고 분석을 해 웹사이트의 성능을 분석할 수 있다.
사이트 성능을 평가하는 기준은 다음과 같다.
React Profiler는 React 애플리케이션의 성능을 분석하고 최적화할 수 있는 도구다.
컴포넌트의 렌더링 시간, 재렌더링 빈도, 그리고 렌더링이 발생한 이유 등을 추적하여 성능 병목 현상을 식별할 수 있다.
LightHouse와는 달리 렌더링 시간 측정, 재렌더링 빈도 추적, 렌더링 원인 분석 기능을 제공해서 React 내부의 성능을 최적화하는데 도움을 준다.
Lighthouse와 Profiler 둘 다 웹페이지의 성능을 분석할 수 있는 개발자 도구이고, 각자의 특성을 가지고 있다.
Lighthouse는 전체 웹페이지가 얼마나 빠르게 렌더링 되는지, 사용자의 경험을 중점으로 분석을 한다면 Profiler는 react 내부의 성능을 중점으로 분석해주는 차이점이 있다.
성능이 50점을 웃돌며 좋지 못한 것으로 평가받았다.
FCP, LCP, TBT, Speed Index를 개선해야 한다.
번들 파일의 코드를 분할하여, 모든 코드를 한 번에 불러오지 않고 사용자가 필요로 할 때에 필요한 코드만 불러오는 개념인 코드 분할을 dynamic import를 활용해 구현할 수 있다.
React.lazy() 는 import() 구문을 반환하는 콜백함수를 인자로 받는다. 동적 불러오기로 불러와지는 모듈은 ReactComponent를 포함하며 default export를 가진 모듈이어야 한다. 그리고 불러온 컴포넌트를 반환한다.
React.lazy로 불러온 컴포넌트는 단독으로 쓰일 수 없고, React.Suspense 컴포넌트로 하위에서 렌더링되어야 한다.
import React, { Suspense, lazy } from 'react';
import { createBrowserRouter } from 'react-router-dom';
import { LoadingState } from '@src/Component';
// import { MainPage, MapPage, ErrorPage } from '@src/Page';
import { Layout } from '@src/Component';
const MainPage = lazy(() => import('@src/Page/MainPage'));
const MapPage = lazy(() => import('@src/Page/MapPage'));
const ErrorPage = lazy(() => import('@src/Page/ErrorPage'));
const LazyComponent = ({ children }: { children: React.ReactNode }) => {
return <Suspense fallback={<LoadingState />}>{children}</Suspense>;
};
const BrowserRouter = createBrowserRouter([
{
path: '/',
element: <Layout />,
children: [
{
path: '/',
element: (
<LazyComponent>
<MainPage />
</LazyComponent>
),
},
{
path: '/live',
element: (
<LazyComponent>
<MapPage />
</LazyComponent>
),
},
{
path: '/error',
element: (
<LazyComponent>
<ErrorPage />
</LazyComponent>
),
},
],
},
]);
export default BrowserRouter;
불필요한 렌더링을 줄여 최적화를 진행해보자.
그러나 자기 자신의 props는 아무런 변화도 없는데 부모 컴포넌트가 변했다고 자식도 렌더링 되는 것은 불필요하다.
Memoization은 이전에 계산한 값을 메모리에 저장해 동일한 계산이 들어왔을 때 새로 계산하는 것이 아닌 저장한 값을 전달해 반복을 줄여 성능을 향상하는 기술이다.
React에서는 react.memo(), usememo, usecallback을 사용해 memoization을 구현할 수 있다.
react.memo는 고차 컴포넌트(HOC) 방식으로 컴포넌트를 검사하여 변경된 props이 없다면 렌더링을 하게 될 때 최근에 렌더링 된 값을 기억해서 재사용하게 된다.
단, 내부에서 useState 같은 훅을 사용하면 상태 변경 시 리렌더링된다.
props로 콜백함수가 전달되는 경우는 계속 동일한 값이 전달되더라도 함수의 인스턴스는 렌더링마다 매번 달라지므로 Memoization이 적절하게 수행되지 않는다. 그래서 useCallback(콜백함수, [])를 통해 해당 콜백함수 인스턴스를 보존해줘야 한다.
최적화를 위한 방법이지만, 이전 값을 비교하는데도 연산이 들어가기에 남발하는 것은 좋지 않다.
// example
const MyComponent = React.memo((props) => {
return (/*컴포넌트 렌더링 코드*/)}
);
// other way
const MyComponent = () => {
return ()
);
export default React.memo(MyComponent);
컴포넌트는 자신의 state가 변경되거나 부모로부터 물려받는 props가 변경될 때 리렌더링된다.
상위 컴포넌트에서 자식 컴포넌트로 함수를 props로 넘겨줄 때, 상위 컴포넌트가 리렌더링 될 때마다 상위 컴포넌트 안에 선언된 함수를 새로 생성하기 때문에 그때마다 새 참조 함수를 자식 컴포넌트로 넘겨주게 된다.
함수는 객체이고, 함수를 새로 생성하면 기존과는 다른 참조 값을 가지기 때문에 부모가 리렌더링 될 때마다 해당 함수를 props으로 받는 자식도 props이 변경되었기에 리렌더링을 하게 된다.
최적화를 해주지 않으면 props가 변경되지 않더라도 부모 컴포넌트가 리렌더링되면 자식도 리렌더링되며, 컴포넌트가 리렌더링될 때 내부에 있는 변수나 함수 등의 표현식들은 전부 다시 선언되고 할당되게 되는 것이다.
그러나 함수 선언단에서 useCallback을 감싸주면, useCallback의 종속 변수들이 변하지 않는 이상 이전에 있던 참조 변수들을 하위 컴포넌트로 전달하기 때문에 불필요한 렌더링을 방지하게 된다.
이 때, 변수나 함수가 다시 생성되어 할당되는 것을 방지하기 위한 훅이 바로 useMemo와 useCallback이다.
값을 memoize한다는 점은 똑같지만 useMemo는 값을 반환하고 useCallback은 함수를 반환한다.
그래서 특정 함수를 계산한 결과값을 받아 변수에 할당하는 방식이면 useMemo를,
특정 함수 자체를 사용하는 방식(명시적인 return값 없이 계산만 수행)이면 useCallback을 사용해주면 된다.
const someValue = useMemo(() => reusedCallback(a, b), [a, b]);
const someFunction = useCallback(() => reusedCallback(a, b), [a, b]);
useMemo는 계산해서 나온(함수 실행의 리턴) 결과값을 memoize하는데 사용된다.
import React, { useRef, useState, useMemo, useCallback } from 'react';
function countActiveUsers(users) {
console.log('활성 사용자 수를 세는중..');
return users.filter(user => user.active).length;
}
const count = useMemo(() => countActiveUsers(users), [users]);
useCallback은 함수의 구조를 memoize하는데 사용된다.
import React, { useRef, useState, useMemo, useCallback } from 'react';
const onChange = useCallback(
e => {
const { name, value } = e.target;
setInputs({
...inputs,
[name]: value,
});
},
[inputs],
);
const onRemove = useCallback(
id => {
setUsers(users.filter(user => user.id !== id));
},
[users],
);
const onToggle = useCallback(
id => {
setUsers(users.map(user => (user.id === id ? { ...user, active: !user.active } : user)));
},
[users],
);
다음 함수는 map이 생성되어도 존재하지 않는다며 계속 오류가 발생하는데, 그 이유는 단순했다.
useCallback은 함수를 memoize하면서 참조하는 변수의 값마저 memoize한 시점에 고정된다. 즉, map이 null인 시점에 memoize를 하게 되면 map 변수가 변경되어도 변경된 값을 추적하지 못하고 초기 시점만 저장하게 되는 것이다.
이를 방지하려면 함수 내부에서 변경된 값을 사용하려는 변수들을 의존성 배열 안에 배치해야 한다.
잘못된 코드
const onClickMarkerFooter = useCallback(
async (marker: KakaoMapMarkerType) => {
console.log('onClickMarkerFooter');
console.log('map : ', map);
if (!map) return;
const newMarker = {} as MarkerType;
newMarker.originalPosition = marker.position;
newMarker.content = marker.content;
const result = await transLocaleToCoord(marker.position);
if (!result) {
return;
}
const { nx, ny, province, city, code } = result;
if (currentMarkers) {
if (isSwapMarker(marker.content) !== 0) return;
}
const prasedPosition = { lat: ny, lng: nx };
Object.assign(newMarker, { province, city, code, position: prasedPosition, isBookmarked: false });
setCurrentMarkers([newMarker, ...currentMarkers]);
const image = { src: '/icons/search.svg', size: { width: 36, height: 36 } };
changeOnMapMarker({ image, position: marker.position, content: marker.content, status: 'search' });
},
[currentMarkers],
);
수정 코드
const onClickMarkerFooter = useCallback(
async (marker: KakaoMapMarkerType) => {
console.log('onClickMarkerFooter');
console.log('map : ', map);
if (!map) return;
const newMarker = {} as MarkerType;
newMarker.originalPosition = marker.position;
newMarker.content = marker.content;
const result = await transLocaleToCoord(marker.position);
if (!result) {
return;
}
const { nx, ny, province, city, code } = result;
if (currentMarkers) {
if (isSwapMarker(marker.content) !== 0) return;
}
const prasedPosition = { lat: ny, lng: nx };
Object.assign(newMarker, { province, city, code, position: prasedPosition, isBookmarked: false });
setCurrentMarkers([newMarker, ...currentMarkers]);
const image = { src: '/icons/search.svg', size: { width: 36, height: 36 } };
changeOnMapMarker({ image, position: marker.position, content: marker.content, status: 'search' });
},
[currentMarkers, map], // map 추가
);
Lighthouse로 Next.js 성능 44% 개선하기
📆 23.03.18 - 성능 개선 #1. LightHouse로 성능 파악해보기
성능 개선 #3. React Profiler로 컴포넌트 해부하기