본문 바로가기

Redux 기반의 폼 렌더링 성능 최적화

by mugglim 2023. 3. 8.

프롤로그

혹시, 라이브러리를 사용하지 않고 폼(Form)의 상태를 어떻게 관리하고 계신가요?

저는 주로 부모 컴포넌트에서 폼 상태를 관리하고, 자식 컴포넌트에게 상태를 변경하는 prop을 전달하곤 합니다. 아래 코드로 예를 들어보겠습니다.

const Child = ({ value, onChange }) => {
  return (
    <div>
      <h3>value: {value} </h3>
      <input onChange={onChange} />
    </div>
  );
};

const Parent = () => {
  const [value, setValue] = useState("");

  const handleChangeValue = (e) => {
    setValue(e.target.value);
  };

  return <Child value={value} onChange={handleChangeValue} />;
};

위 코드는 기능상으로는 문제가 없는 것 같습니다.

그런데, 여기서 자식 컴포넌트 Child가 더 늘어나면 어떻게 될까요? 아래 코드를 예로 들어보겠습니다. 아래 코드에서 value1이 변경되면, Child1, Child2, Child3이 리렌더링 됩니다. Child2, Child3의 value값은 변경되지 않았는데도 리렌더링이 발생하는 거지요. 만약 Child2과 Child3가 꽤나 무거운 녀석들이라면 성능상의 문제로 이어질 수 있습니다.

const Parent = () => {
  const [value1, setValue1] = useState('');
  const [value2, setValue2] = useState('');
  const [value3, setValue3] = useState('');

  return (
    <div>
      <Child1 value={value1} onChange={(e) => setValue1(e.target.value)} />
      <Child2 value={value2} onChange={(e) => setValue2(e.target.value)} />
      <Child3 value={value3} onChange={(e) => setValue3(e.target.value)} />
    </div>
  );
};

본 글에서는 이러한 문제점을 해결하기 위한, Redux 기반의 폼 성능 최적화를 위한 방법을 소개하고자 합니다.


최적화가 필요했던 이유

이전에 참여했던 프로젝트에서는 하나의 폼에 여러 input이 구성되어 있었습니다. 각각의 input에 사용자가 값을 입력하면 정규식 기반으로 유효성을 검사하여 실시간으로 에러를 표시해야 했습니다.

문제점은 특정 input에 값을 입력하면 약간의 버벅임이 발생했습니다. 원인을 파악하기 위해 React Developer Tools을 기반으로 디버깅을 해보니, 값을 입력한 input 이외에 다른 input 들도 리렌더링이 발생하고 있었습니다.

폼의 상태가 어떻게 관리되고 있는지 살펴보니, 최상위 부모 컴포넌트에서 useState 기반으로 관리되고 있었습니다. 상태를 변경하는 액션(핸들러)들은 부모 컴포넌트에서 정의 후, 자식 컴포넌트에게 prop으로 전달하고 있었습니다.

무엇이 문제였을까요? 

특정 input의 값이 변경되면 상태를 변경하는 액션(핸들러)들의 참조(주소) 값이 변경되게 됩니다. 액션(핸들러)들의 참조 값이 변경되게 되면, 자식으로 전달되는 prop의 값이 변경되므로 불필요한 리렌더링이 발생하게 되는거지요.

 

어떤 방법이 좋을까?

크게 4가지 방안을 고민해봤습니다.

  1. prop의 참조 값을 캐싱하자! 
    • useMemo + useCallback + React.memo
    • 참조 값을 캐싱하는 것 생각보다 까다로움(디펜던시 지옥..🤯🤯)
    • props drilling 문제
  2. Vanilla React로 전역 상태를 사용해보면 어떨까? 
    • useState + useReducer + Context API
    • 불변성 확보를 위한 코드의 가독성 저하
    • props drilling 해결
    • 1번과 동일하게 참조 캐싱의 복잡성을 가짐
  3. 비제어 컴포넌트
    • useRef
    • 상태 변경에 따른 흐름 추적이 어려움
    • props drilling 문제
    • 사용자 입력에 따른 실시간 상태 변경이 필요하므로 적합하지 않다고 판단
  4. Redux  Toolkit
    • Redux Toolkit
    • immer 기반의 불변성 확보가 쉬움
    • 프로젝트에서 사용중인 라이브러리
    • useSelector 기반의 리렌더링 최적화 도움
    • props drilling 해결

 

어떻게 리렌더링을 최적화 할 수 있을까?

1. 상태를 끌어올 때, shallowEqual 사용하여 리렌더링을 방지

react-redux의 useSelector 함수는 두번째 인자로 equalityFn을 제공합니다. 이 인자값은 useSelector로 얻어진 상태를 비교하여 리렌더링을 방지할 수 있습니다.

저는 equalityFn 인자값에 react-redux의 shallowEqual 함수를 적용했습니다. shallowEqual 함수는 값을 얕은 비교하여, 값이 변하지 않았다면 렌더링을 발생시키지 않습니다.

import { useSelector, shallowEqual } from "react-redux";

// name이 변경되지 않으면 렌더링이 발생하지 않음
const { name } = useSelector(({ rootReducer: state }) => {
  return {
    name: state.login.name,
  };
}, shallowEqual);

2. 컴포넌트를 최대한 잘게 나누어 리렌더링 범위를 줄이려고 노력

사용자가 input에 값을 입력할 때, 해당 input 값에 따른 에러 메시지 이외에는 리렌더링이 발생하면 안됩니다. 이를 위해 상태 변경이 필요한 컴포넌트를 최대한 잘게 나누어, 리렌더링(리플로우) 범위를 줄이려고 노력했습니다.

// AS-IS
const SomeComponent = () => {
  return (
    <div>
      <input onChange={handleNameChange} />
      {nameErrorMessage ?? <ErrorMessage text={nameErrorMessage} />}

      <input onChange={handleEmailChange} />
      {emailErrorMessage ?? <ErrorMessage text={emailErrorMessage} />}

      <input onChange={handleAddressChange} />
      {addressErrorMessage ?? <ErrorMessage text={addressErrorMessage} />}
    </div>
  );
};



// TO-BE
const NameField = () => {
  // 상태를 끌어오른 로직

  return (
    <>
      <input onChange={handleNameChange} />
      {nameErrorMessage ?? <ErrorMessage text={nameErrorMessage} />}
    </>
  );
};

const EmailField = () => {
  // 상태를 끌어오른 로직

  return (
    <>
      <input onChange={handleEmailChange} />
      {emailErrorMessage ?? <ErrorMessage text={emailErrorMessage} />}
    </>
  );
};

const AddressField = () => {
  // 상태를 끌어오른 로직

  return (
    <>
      <input onChange={handleAddressChange} />
      {addressErrorMessage ?? <ErrorMessage text={addressErrorMessage} />}
    </>
  );
};

const SomeComponent = () => {
  return (
    <div>
      <NameField />
      <EmailField />
      <AddressField />
    </div>
  );
};

최적화가 이루어진 정도

최적화를 검증하기 위해 테스크 케이스, 성능 측정 도구 등을 설정하여 테스트를 진행했습니다.

  • 테스트 케이스
    • input 컴포넌트에 사용자가 값을 빠르게 변경
    • 성능이 좋지 않는 컴퓨팅 리소스 환경을 반영하기 위해, Performance 탭의 CPU를 4x slowdown으로 설정
  • 성능 측정 도구
  • 테스트 결과
    • Render Duration이 기존 대비 100ms에서 8~10ms로 개선

글을 마무리 하며

간단하게 Redux 기반으로 폼 렌더링 성능 최적화에 대한 방법을 알아봤습니다. 불필요한 리렌더링을 방지하니, 명확하게 성능이 개선된 것을 확인할 수 있었습니다.

하지만 렌더링 성능 최적화가 항상 필요한 것은 아니라고 생각합니다. 

성능 최적화가 왜 필요한지, 어떤 부분에서 최적화가 필요한지 등 상황에 맞게 적용해보면 좋을 것 같습니다. (적정 기술)

읽어주셔서 감사합니다.

댓글