

만다라트 페이지를 개발하던 중 셀 컴포넌트를 81개 사용하면서 성능 이슈가 발생했다. 문제의 원인은 모든 셀이 동일한 컴포넌트를 공유하고 있다는 점이었다.
const show = useFloatingSheetStore((state) => state.show);
// 플로팅 시트를 띄우는 이벤트 핸들러
const handleClick = () => {
show();
};
// Zustand 스토어 내부
show: () => set({ isVisible: true }),
셀을 클릭하면 위와 같이 show 함수가 호출되어 isVisible 상태가 false에서 true로 바뀌고, 해당 상태를 구독 중인 모든 셀이 리렌더링되었다.
즉, 하나의 셀만 클릭해도 81개의 셀이 전부 리렌더링되는 현상이 발생한 것이다.
이를 해결하기 위해 여러 가지 방법을 시도해보았다.
Zustand에서는 shallow를 사용하면 상태 객체 내 일부 필드만 바뀌는 경우, 불필요한 리렌더링을 방지할 수 있다.
const show = useShallow(useFloatingSheetStore((state) => state.show));
하지만 내 경우엔 show라는 단일 함수만 가져오는 상황이기 때문에 shallow를 사용할 필요가 없었다. shallow는 여러 상태 값을 객체로 묶어서 가져올 때 의미가 있고 단일 값에는 효과가 없다.
그래서 다음으로 시도한 방법은 React.memo를 통해 셀 컴포넌트를 메모이제이션하는 것이었다.
export default React.memo(Cell);
컴포넌트는 Zustand 스토어를 구독하고 있지만 show 함수만 구독 중이므로 memo를 적용해도 괜찮은 상황이었다.
하지만 문제는 props로 객체를 그대로 넘겨주고 있다는 점이었다. 객체는 참조값이 바뀌면 동일한 내용이어도 매번 새로운 것으로 인식되기 때문에 memo가 제대로 동작하지 않았다. 이를 해결하기 위해 props를 구조 분해해서 각각 개별 값으로 전달해보았다.
그 결과, 셀 전체가 리렌더링되지 않고 정상적으로 작동하는 것을 확인할 수 있었다. 그러나 props를 하나하나 나눠 전달하는 방식은 가독성이 떨어지고 컴포넌트 내부 사용도 불편했다.
그래서 더 나은 방법으로 useMemo를 활용해보기로 했다.
useMemo는 재렌더링 사이에 계산 결과를 캐싱하여 불필요한 연산을 방지할 수 있는 훅이다.
const memoizedCells = useMemo(() => {
const cells = Array(9).fill(null);
// 중앙 셀 구분
cells[4] = { ...info };
topics.forEach((topic: TopicType, idx: number) => {
// 중앙 셀은 건너뛰고 인덱스 지정
const pos = idx >= 4 ? idx + 1 : idx;
cells[pos] = { isCenter: false, ...topic };
});
return cells;
}, [topics, info]);
이렇게 topics와 info가 변경될 때만 새로 계산되도록 설정해주었다.
props로 넘길 객체 배열을 useMemo를 통해 미리 계산해두면 셀 컴포넌트에는 동일한 참조값이 전달되기 때문에 memo와 함께 사용할 때 불필요한 리렌더링을 방지할 수 있다.