개인 프로젝트를 진행하다 보니 불필요하게 리렌더링이 발생하는 부분이 해결해야겠다는 생각이 들었다.
이 글에서는 렌더링이 발생하는 이유와 내가 개인 프로젝트에서 최적화를 진행했던 부분에 대해 다루려고 한다.
따라서 모든 최적화에 대한 내용을 다루지는 않을 예정이다.
렌더링의 과정은 다른 글에서 작성했으니 이 글에서는 간단하게 원인만 파악할 예정이다.
검색어를 입력하는 창에 텍스트를 입력하게 되면 해당 컴포넌트(input)만 리렌더링 되어야 하는데 아직 변경이 필요하지 않은 게시물 리스트, 검색된 정보에 대한 텍스트 또한 리렌더링 되는 문제가 발생했다.
예시 화면은 개인 프로젝트에서 리렌더링이 발생하는 것을 캡처한 것입니다.
기존 코드를 확인하면 입력 창의 state가 상위 컴포넌트에 존재한다.
검색 창에 텍스트를 입력하면 onChange 핸들러가 실행되며 상위 컴포넌트에 존재하는 state가 갱신된다.
상위 컴포넌트의 state는 그대로 두고 검색창 컴포넌트 내부에 useState를 선언하여 onChange 이벤트 발생 시, 해당 컴포넌트 내의 state를 갱신하고
검색 요청이 발생했을 때만 상위 컴포넌트의 state를 갱신하도록 변경하였다.
일반적으로 useSelector를 사용하여 Redux 스토어의 State를 조회할 때, State가 변경되지 않았다면 리렌더링을 발생하지 않는다.
만약 아래와 같은 상황이 존재한다고 가정해 보자
const { number, title } = useSelector((state) => state.counter);
이 경우 발생하는 문제점은 매번 렌더링 될 때마다 새로운 객체가 생성되는 것이기 때문에 상태가 변경됐는지 아닌지 확인할 수 없어 불필요한 렌더링이 발생한다.
해당 내용은 useSelector 최적화를 통해 공부한 후 작성한 글이며 더 자세한 내용을 확인할 수 있다.
이 리렌더링에 대한 해결 방법은 두 가지가 존재하는데
1. useSelector 여러개 사용하기
const number = useSelector((state) => state.counter.number);
const title = useSelector((state) => state.counter.title);
import { useSelector, shallowEqual } from 'react-redux';
const { number, title } = useSelector((state) => state.counter, shallowEqual);
useSelector에 대한 기본 최적화를 알아봤으니 이제 나의 코드를 살펴보자
선택된 데이터가 달라지면 검색된 결과가 변경되지 않았음에도 리스트가 리렌더링 된다.
나의 경우 redux-toolik을 사용하고 있고 gyms gym을 받아오는 useSelector 내부의 gymsSelector는 redux-toolkit에서 제공하는 메서드를 createDraftSafeSelector를 호출하여 생성된 선택자이다.
export const gymSelector = createDraftSafeSelector(
(state: RootState) => state.gym.gyms,
(state: RootState) => state.gym.gym,
(gyms, gym) => ({
gyms,
gym,
})
);
이때 gym를 사용하는 컴포넌트는 gymSelector를 사용하여 gym을 조회하는데 gym이 변경되면 gyms를 조회하는 컴포넌트 또한 리렌더링 된다.
이는 위에서 살펴본 문제점과 동일하다. 매번 렌더링 될 때마다 새로운 객체가 생성되는 것이기 때문이다.
export const gymSelector = createDraftSafeSelector(
(state: RootState) => state.gym.gym,
(gym) => gym
);
export const gymsSelector = createDraftSafeSelector(
(state: RootState) => state.gym.gyms,
(gyms) => gyms
);
useSelector를 여러 개 사용하듯이 selector를 여러 개 생성하여 해결하였다.
상위 컴포넌트가 리렌더링 되면 하위 컴포넌트 또한 리렌더링이 발생하는데 상위 컴포넌트에서 전달하는 props의 변경이 없어도 하위 컴포넌트는 리렌더링 된다.
import React from 'react';
const Input = ({
name,
size = SizeType.DEFAULT,
type = InputType.TEXT,
value,
onChange,
placeholder,
disabled,
error,
...props
}: InputProps) => (
<InputContainer
value={value}
onChange={onChange}
inputsize={size}
placeholder={placeholder}
readOnly={disabled}
error={error}
name={name}
{...props}
/>
);
export default React.memo(Input);
props의 변경이 없는 하위 컴포넌트가 리렌더링되는 것은 불필요하다.
React.memo를 사용하여 컴포넌트를 감싸 props의 변경이 없다면 리렌더링을 방지할 수 있다.
하지만 React.memo는 감싼 컴포넌트가 useState, useReducer 또는 useContext 훅을 사용한다면, 여전히 state나 context가 변할 때 다시 렌더링 된다.
기존에 수행한 연산의 결괏값을 메모리에 저장해두고 동일한 입력이 들어오면 재활용하는 프로그래밍 기법을 말한다.
중복 연산을 피할 수 있기 때문에 메모리를 조금 더 쓰더라도 애플리케이션의 성능을 최적화할 수 있다.
재사용이 가능한 컴포넌트를 생성하다 보면 상위 컴포넌트에서 전달하는 props 만으로 렌더링 되는 컴포넌트가 존재한다.
const memoizedValue = useMemo((
list?: Array<{ isPermitted: boolean }>
): number => {
if (!list?.[0]) return 0;
const total = list.length;
const number = list.filter((f: { isPermitted: boolean }) => f.isPermitted).length || 0;
return Math.round((number / total) * 100);
}, [list]);
deps가 변경되면 useMemo 첫 번째 인자 () => func() 함수를 실행하고 그 값을 반환한다.
개인적으로 useMemo를 사용하지 않았을 때와 사용했을 때의 큰 차이는 느끼지 못했다.
이 부분은 좀 더 알아봐야 할 듯....
진행하고 있는 프로젝트는 NextJs를 사용하고 있다.
NextJs에서 routing이 발생하면 getStaticProps, getServerSideProps가 호출되는데 shalow routing을 하면 해당 함수가 호출되지 않으며 URL만 업데이트된다.
shalow routing을 하는 방법은 'next/router'에서 제공하는 useRouter의 .push .replace를 호출하는 것이다.
일부 컴포넌트 내부에서 검색이 발생했을 경우, shallow routing을 통해 검색한 텍스트나 선택한 탭 등을 URL에 적용해 주는데 이 부분에서 전체 페이지가 리렌더링이 발생했다.
'next/router'는 Context를 내부적으로 사용하기 때문에 useRouter에서 제공하는 메서드를 호출하는 순간 Context 값이 변경되며 페이지 컴포넌트가 리렌더링이 발생한다.
window.history.replaceState를 사용하여 페이지의 history에 URL을 업데이트한다.
Context나 State가 변경되는 것이 아니기 때문에 전체 페이지 컴포넌트가 업데이트되지 않는 것이다.
React-렌더링-성능-최적화하는-7가지-방법-Hooks-기준
nextjs-lesson-and-learn
리엑트 렌더링 최적화하는 8가지 방법과 고찰
onChange 이벤트 렌더링 최적화
useSelector 최적화
개인 프로젝트를 진행하다 보니 불필요하게 리렌더링이 발생하는 부분이 해결해야겠다는 생각이 들었다. Area Code