Hits

🌃 배경

영상에 나오는 ScheduleFanUp 모달을 열고 닫을 때, 해당 모달 컴포넌트(ScheduleFanUpModal 컴포넌트)만 렌더링되기를 원했지만, 모달을 감싸고 있는 컴포넌트(Calendar 컴포넌트)까지 함께 리렌더링 되었습니다.

코드는 아래와 같았습니다.

Calendar.tsx
const Calendar = () => {
  const { openSchduleModal } = useSelector<ReducerType, ArtistStore>(
    (state) => state.artistSlice
  );
 
  return (
    <CalendarWrapper>
      <h1 data-testid="title">LILHUDDY님의 일정</h1>
      <CalendarHeader />
      <CalendarBody />
      {openSchduleModal ? <ScheduleFanUpModal /> : null}
    </CalendarWrapper>
  );
};
Calendar.tsx
const Calendar = () => {
  const { openSchduleModal } = useSelector<ReducerType, ArtistStore>(
    (state) => state.artistSlice
  );
 
  return (
    <CalendarWrapper>
      <h1 data-testid="title">LILHUDDY님의 일정</h1>
      <CalendarHeader />
      <CalendarBody />
      {openSchduleModal ? <ScheduleFanUpModal /> : null}
    </CalendarWrapper>
  );
};

open 상태를 내부로

위 문제의 경우에는, Calendar 컴포넌트가 Redux Store의 openSchduleModal 상태를 구독하고 있어, 열고 닫을 때 렌더링이 발생되는 상황이라고 생각했습니다.

따라서, openSchduleModal 상태를 이용해, ScheduleFanUpModal 컴포넌트를 렌더링하는 로직을 ScheduleFanUpModal 컴포넌트 내부로 이동시켜 해결했습니다.

Calendar.tsx
const Calendar = () => {
  return (
    <CalendarWrapper>
      <h1 data-testid="title">LILHUDDY님의 일정</h1>
      <CalendarHeader />
      <CalendarBody />
      <ScheduleFanUpModal />
    </CalendarWrapper>
  );
};
Calendar.tsx
const Calendar = () => {
  return (
    <CalendarWrapper>
      <h1 data-testid="title">LILHUDDY님의 일정</h1>
      <CalendarHeader />
      <CalendarBody />
      <ScheduleFanUpModal />
    </CalendarWrapper>
  );
};
ScheduleFanUpModal.tsx
const ScheduleFanUpModal = () => {
  const { openSchduleModal } = useSelector<ReducerType, ArtistStore>(
    (state) => state.artistSlice
  );
 
  if (!openSchduleModal) return null;
  return <>...</>;
};
ScheduleFanUpModal.tsx
const ScheduleFanUpModal = () => {
  const { openSchduleModal } = useSelector<ReducerType, ArtistStore>(
    (state) => state.artistSlice
  );
 
  if (!openSchduleModal) return null;
  return <>...</>;
};

useSelector 최적화

다음으로 발생한 문제는 모달을 열고 닫을 때, CalendarHeaderCalendarBody 컴포넌트가 리랜더링된다는 것이었습니다.

CalendarHeader 컴포넌트와 CalendarBody 컴포넌트의 코드는 모두 Redux Store를 구독하고 있습니다.

CalendarHeader.tsx
const CalendarHeader = () => {
  const { calendarYear: year, calendarMonth: month } = useSelector<
    ReducerType,
    ArtistStore
  >((state) => state.artistSlice);
 
  console.log("calendarHeader Render");
  return <>...</>;
};
CalendarHeader.tsx
const CalendarHeader = () => {
  const { calendarYear: year, calendarMonth: month } = useSelector<
    ReducerType,
    ArtistStore
  >((state) => state.artistSlice);
 
  console.log("calendarHeader Render");
  return <>...</>;
};
CalendarBody.tsx
const CalendarBody = () => {
  const { calendarYear: year, calendarMonth: month } = useSelector<
    ReducerType,
    ArtistStore
  >((state) => state.artistSlice);
 
  console.log("CalendarBody Render");
  return <>...</>;
};
CalendarBody.tsx
const CalendarBody = () => {
  const { calendarYear: year, calendarMonth: month } = useSelector<
    ReducerType,
    ArtistStore
  >((state) => state.artistSlice);
 
  console.log("CalendarBody Render");
  return <>...</>;
};

react-redux 공식문어세 따르면, useSelector를 이용해 Redux Store의 데이터를 가져와 사용하는 컴포넌트는 useSelector의 인자로 받는 selector 함수가 반환하는 값의 변화 여부에 따라, 리렌더링 된다고 합니다. (공식문서)

CalendarHeaderCalendarBody에서는 모두 artistSlice에서 관리하는 state 전체를 반환하는 selector를 사용하고 있었습니다.

const { calendarYear: year, calendarMonth: month } = useSelector<
  ReducerType,
  StoreState
>((state) => state.artistSlice);
const { calendarYear: year, calendarMonth: month } = useSelector<
  ReducerType,
  StoreState
>((state) => state.artistSlice);

redux에서는 불변성을 위해 매번 새로운 state를 만들고, 이로 인해 state 전체를 반환하는 selector는 항상 반환값이 이전과 달라 리렌더링이 발생했었습니다.

이는 2가지의 방법으로 해결할 수 있습니다.

  1. 객체가 아닌 상태를 직접반환
  2. equalityFn 활용

1번을 이용한다면, 다음과 같이 각각 개별의 상태를 가져오도록 코드를 수정해야합니다.

const calendarYear = useSelector<ReducerType, number>(
  (state) => state.artistSlice.calendarYear
);
const calendarMonth = useSelector<ReducerType, number>(
  (state) => state.artistSlice.calendarMonth
);
const calendarYear = useSelector<ReducerType, number>(
  (state) => state.artistSlice.calendarYear
);
const calendarMonth = useSelector<ReducerType, number>(
  (state) => state.artistSlice.calendarMonth
);

이렇게도 문제가 해결되지만, 하나의 useSelector에서 상태를 반환하는 것이 코드의 양을 줄이고 가독성을 향상시키기 때문에, 2번 방법을 적용해보았습니다.

useSelector 는 두 번째, 매게변수로 equalifyFn을 받습니다.
equalifyFn 이전 상태와 현재 상태를 비교하는데 사용되는 함수입니다.

공식문서

이를 이용하면, useSelector를 한번만 이용해서, 객체를 반환 받을 수 있다. react-redux에서는 shallowEqual 이라는 메서드를 제공하고, 이 메서드는 객체 안의 가장 겉에 있는 값들을 모두 비교해줍니다.

const { calendarYear: year, calendarMonth: month } = useSelector<
  ReducerType,
  StoreState
>(
  ({ artistSlice }) => ({
    calendarYear: artistSlice.calendarYear,
    calendarMonth: artistSlice.calendarMonth,
  }),
  shallowEqual
);
const { calendarYear: year, calendarMonth: month } = useSelector<
  ReducerType,
  StoreState
>(
  ({ artistSlice }) => ({
    calendarYear: artistSlice.calendarYear,
    calendarMonth: artistSlice.calendarMonth,
  }),
  shallowEqual
);

결과적으로 목표였던 모달이 열고 닫히더라도 Calendar 컴포넌트가 렌더링되지 않도록 만들 수 있었습니다.