React
렌더링 최적화 심화
🔎 렌더링 최적화
React 에서 렌더링을 최적화 하는 방식은 여러가지가 있다. useMemo, useCallback, 컴포넌트 분리 등... 인터넷에 자료를 찾아보면 많이 나온다.
개인 프로젝트를 개발할 때 나 또한 이러한 방식을 찾아서 최대한 최적화를 하며 진행하였다. 아래는 내가 참고한 렌더링 최적화 방식을 설명한 블로그이다.
하지만... 렌더링 최적화가 원하는만큼 잘 이루어지지는 않았다...
🔎 문제점
개인 프로젝트를 진행하며 Chrome 에서 제공하는 React 확장 프로그램을 사용하여 렌더링을 확인하였는데.. 예상치 못한 결과가 나왔다.
/* import modules ... */
const InputBox = React.memo(
styled(Box)<BoxProps>(() => ({
// ...
}))
);
const ContainerBox = React.memo(
styled(Box)<BoxProps>(() => ({
// ...
}))
);
const limit = 40;
const PokemonSkeletonList = () => {
return new Array(4).fill(0).map((_, idx) => (
<Grid item container key={`pokemon-skeleton-${idx}`} zero={3}>
<PokemonSkeleton />
</Grid>
));
};
const PokemonList = () => {
const [displayList, setDisplayList] = useState<PokeType[]>([]);
const [isInit, setIsInit] = useState(false);
const [isEnd, setIsEnd] = useState(false);
const [search, setSearch] = useState('');
const [openLoading, setOpenLoading] = useState(false);
const [openPokemonInfo, setOpenPokemonInfo] = useState(false);
const [infoPokeId, setInfoPokeId] = useState('');
const offset = useRef(0);
const root = useRef<HTMLInputElement>(null);
const target = useRef<HTMLInputElement>(null);
// functions ...
return (
<>
<BasicHeader>
<MiddleTypography
fontSize={{
zero: '1.2rem',
max: 'h4.fontSize',
}}
>
Pokemon
</MiddleTypography>
</BasicHeader>
<InputBox mb={2}>
<TextField
label='번호 or 이름'
size='small'
variant='outlined'
value={search}
onChange={onChangeSearch}
onKeyDown={onKeyDownSearch}
/>
<Button
variant='outlined'
color='inherit'
sx={{ ml: 1 }}
onClick={onClickSearch}
>
검색
</Button>
</InputBox>
<ContainerBox ref={root}>
<Grid container spacing={4}>
{!isInit ? (
<PokemonSkeletonList />
) : (
displayList.map((it) => (
<Grid item key={`pokemon-${it.name}`} zero={3}>
<PokemonCard pokemon={it} onClick={onOpenPokeModal} />
</Grid>
))
)}
</Grid>
<div style={{ marginBottom: '5px' }} ref={target}></div>
</ContainerBox>
<LoadingModal open={openLoading} />
<PokemonInfoModal
open={openPokemonInfo}
id={infoPokeId}
onClose={onClosePokeModal}
/>
</>
);
};
export default PokemonList;
input Text 에 값을 입력할 때 마다 수많은 불필요한 리렌더링이 이루어지는 것을 볼 수 있다. 처음엔 분명 각 포켓몬 카드 컴포넌트를 분리하였는데 왜 재렌더링이 일어났을까..?
사실 각 포켓몬 카드는 재렌더링 되지 않았다!
그럼 저 재렌더링으로 보이는 격자무늬는 무엇일까? 저 부분은 각 포켓몬 카드를 감싸고 있는 Grid 이다... Grid 자체는 상위 컴포넌트에 묶여있고 따로 분리되어있지 않다. 그래서 상위 컴포넌트에서 변경되는 state에 따라 Grid가 재렌더링 되고 있는 것이다.
타이틀 또한 마찬가지이다. 사실 타이틀(Header) 는 상태와 아무 관련 없지만 state 가 변경되는 상위 컴포넌트에 같이 묶여있기 때문에 재렌더링이 이루어진다.
🔎 해결방안
위 문제를 해결하기 위해 여러 자료조사와 고민을 하였는데 결론은 컴포넌트를 잘 분리해야한다는 것이다.
Header 컴포넌트를 통해 알아보자.
const PokemonList = () => {
// states ...
// functions ...
return (
<>
<BasicHeader>
<MiddleTypography
fontSize={{
zero: '1.2rem',
max: 'h4.fontSize',
}}
>
Pokemon
</MiddleTypography>
</BasicHeader>
<!-- ... -->
</>
);
};
기존 코드는 state 를 제어하는 컴포넌트에 같이 묶여있기 때문에 재렌더링이 발생하였다. 하지만 그렇다고 이런 모든 컴포넌트를 따로 빼서 관리하여야 할까? (공통으로 사용하는 것이 아닌 오직 논리적으로만 구분하기 위해)
그래서 나는 해당 PokemonList.tsx 내에서 컴포넌트를 분리하는 것으로 수정을 진행하였다.
/* import modules */
/*============================== Header ==============================*/
const Header = () => {
return (
<BasicHeader>
<MiddleTypography
fontSize={{
zero: '1.2rem',
max: 'h4.fontSize',
}}
>
Pokemon
</MiddleTypography>
</BasicHeader>
);
};
const MemoizedHeader = React.memo(Header);
// ...
/*============================== 상위 컴포넌트 ==============================*/
const PokemonList = () => {
return (
<>
<MemoizedHeader />
<MemoizedPokemonArea />
</>
);
};
export default PokemonList;
위와 같이 어차피 공통으로 사용되지 않고 PokemonList.tsx 파일 내에서만 분리되어 사용될 것이기 때문에 해당 파일 내에서 따로 분리하고 memoization 하여서 재렌더링을 방지하였다.
근데 자세히 보면 PokemonList 에 state 가 사라진 것을 볼 수 있다. 이는 Header 를 분리하면서 MemoizedPokemonArea 영역도 분리하였는데 이 영역에서만 사용될 것이 때문에 해당 컴포넌트에 state 를 선언하였다.
이렇게 컴포넌트를 분리하는 것으로 state 가 실제로 사용되는 영역을 더 세밀하게 분리할 수 있고 이를 통해 렌더링을 최적화 할 수 있었다.
🔎 결과
/* import modules */
const InputBox = React.memo(
styled(Box)<BoxProps>(() => ({
display: 'flex',
flexDirection: 'row',
justifyContent: 'flex-end',
alignItems: 'center',
}))
);
const ContainerBox = React.memo(
styled(Box)<BoxProps>(() => ({
height: '75vh',
overflow: 'auto',
'&::-webkit-scrollbar': {
width: '5px',
},
'&::-webkit-scrollbar-thumb': {
position: 'absolute',
left: '0px',
background: '#666666',
},
'&::-webkit-scrollbar-track': {
background: 'rgba(220, 220, 220, 0.4)',
},
}))
);
const limit = 40;
/*============================== Header ==============================*/
const Header = () => {
return (
<BasicHeader>
<MiddleTypography
fontSize={{
zero: '1.2rem',
max: 'h4.fontSize',
}}
>
Pokemon
</MiddleTypography>
</BasicHeader>
);
};
const MemoizedHeader = React.memo(Header);
/*============================== Search Area ==============================*/
const SearchArea = ({ onClick }: { onClick: (search: string) => void }) => {
const [search, setSearch] = useState('');
const onChangeSearch = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setSearch(e.target.value);
},
[]
);
const onKeyDownSearch = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.keyCode === 13) {
onClick(search);
}
},
[search, onClick]
);
const onClickSearch = useCallback(() => {
onClick(search);
}, [onClick, search]);
return (
<InputBox mb={2}>
<TextField
label='번호 or 이름'
size='small'
variant='outlined'
value={search}
onChange={onChangeSearch}
onKeyDown={onKeyDownSearch}
/>
<Button
variant='outlined'
color='inherit'
sx={{ ml: 1 }}
onClick={onClickSearch}
>
검색
</Button>
</InputBox>
);
};
const MemoizedSearchArea = React.memo(SearchArea);
/*============================== Pokemon Skeleton ==============================*/
const PokemonSkeletonGrid = () => {
return new Array(4).fill(0).map((_, idx) => (
<Grid item container key={`pokemon-skeleton-${idx}`} zero={3}>
<PokemonSkeleton />
</Grid>
));
};
const MemoizedPokemonSkeletonGrid = React.memo(PokemonSkeletonGrid);
/*============================== Pokemon 개별 카드 ==============================*/
const PokemonCardGrid = ({
pokemon,
onClick,
}: {
pokemon: PokeType;
onClick: (id: string) => void;
}) => {
return (
<Grid item key={`pokemon-${pokemon.name}`} zero={3}>
<PokemonCard pokemon={pokemon} onClick={onClick} />
</Grid>
);
};
const MemoizedPokemonCardGrid = React.memo(PokemonCardGrid);
/*============================== Pokemon Card List ==============================*/
const PokemonCardList = ({ onClick }: { onClick: (id: string) => void }) => {
const [displayList, setDisplayList] = useState<PokeType[]>([]);
const [isInit, setIsInit] = useState(false);
const [isEnd, setIsEnd] = useState(false);
const [openLoading, setOpenLoading] = useState(false);
const offset = useRef(0);
const root = useRef<HTMLInputElement>(null);
const target = useRef<HTMLInputElement>(null);
// pokeApi 호출
const callPokeApi = (offet: number, limit: number) =>
getPokeList(offet, limit)
.then((res) => {
setDisplayList((state) => [...state, ...res.data.items]);
if (!res.data.next) {
setIsEnd(true);
}
setIsInit(true);
setOpenLoading(false);
})
.catch(() => {
setIsEnd(true);
setOpenLoading(false);
});
useEffect(() => {
callPokeApi(offset.current, limit);
}, []);
// infinit Scroll 컨트롤
const handleObserver = useCallback(async (entries: any) => {
const target = entries[0];
if (target.isIntersecting) {
setOpenLoading(true);
offset.current += limit;
callPokeApi(offset.current, limit);
}
}, []);
const options = useMemo(
() => ({
root: root.current,
rootMargin: '5px',
threshold: 1.0,
}),
[]
);
useEffect(() => {
if (isInit && !isEnd) {
const observer = new IntersectionObserver(handleObserver, options);
if (target.current) observer.observe(target.current);
return () => observer.disconnect();
}
}, [handleObserver, isEnd, isInit, options]);
return (
<ContainerBox ref={root}>
<Grid container spacing={4}>
{!isInit ? (
<MemoizedPokemonSkeletonGrid />
) : (
displayList.map((it) => (
<MemoizedPokemonCardGrid
key={`pokemon-${it.name}`}
pokemon={it}
onClick={onClick}
/>
))
)}
</Grid>
<div style={{ marginBottom: '5px' }} ref={target}></div>
<LoadingModal open={openLoading} />
</ContainerBox>
);
};
const MemoizedPokemonCardList = React.memo(PokemonCardList);
/*============================== Pokemon Area ==============================*/
const PokemonArea = () => {
const [openPokemonInfo, setOpenPokemonInfo] = useState(false);
const [infoPokeId, setInfoPokeId] = useState('');
const onOpenPokeModal = useCallback((id: string) => {
setInfoPokeId(id);
setOpenPokemonInfo(true);
}, []);
const onClosePokeModal = useCallback(() => {
setInfoPokeId('');
setOpenPokemonInfo(false);
}, []);
const handleSearch = useCallback(
(search: string) => {
if (!search) {
alert('검색어를 입력해 주세요.');
return;
}
let id = search;
if (!parseInt(search)) {
const filteredJson = pokeJson.filter((it) => it.name === search);
if (filteredJson.length > 0) {
id = filteredJson[0].pokemon_species_id.toString();
}
onOpenPokeModal(id.toString());
} else {
onOpenPokeModal(id);
}
},
[onOpenPokeModal]
);
return (
<>
<MemoizedSearchArea onClick={handleSearch} />
<MemoizedPokemonCardList onClick={onOpenPokeModal} />
<PokemonInfoModal
open={openPokemonInfo}
id={infoPokeId}
onClose={onClosePokeModal}
/>
</>
);
};
const MemoizedPokemonArea = React.memo(PokemonArea);
/*============================== 상위 컴포넌트 ==============================*/
const PokemonList = () => {
return (
<>
<MemoizedHeader />
<MemoizedPokemonArea />
</>
);
};
export default PokemonList;