(번역) 리액트 폼 성능 개선

superlipbalm·2022년 6월 1일
37

FE 글 번역

목록 보기
1/3

원문: https://epicreact.dev/improve-the-performance-of-your-react-forms/
좋은 글을 작성해주신 Kent C. Dodds와 한국어 번역을 허락해 주신 Epic React 팀께 감사드립니다.

폼은 웹에서 큰 부분을 차지합니다. 사용자가 백엔드 데이터를 변경하기 위해 수행하는 모든 상호 작용은 form을 사용해야 합니다. 어떤 폼들은 간단하지만 현실에서는 빠르게 복잡해집니다. 사용자가 입력한 폼 데이터를 제출하고, 서버 오류에 응답하고, 입력할 때 사용자 입력의 유효성을 검사해야 하며(부디 입력창이 포커스를 잃기 전까지는 검사하지 마세요), 지원되지 않는 입력 유형(스타일을 적용할 수 있는 selects, date picker 등)에 대한 사용자 정의 UI 요소를 만들어야 하는 경우도 있습니다.

폼이 처리해야 할 모든 추가 작업을 위해서 사용자가 폼과 상호작용하는 동안 브라우저가 더 많은 자바스크립트를 실행하는 것이 필요합니다. 이는 보통 까다로운 성능 문제로 이어집니다. 때로는 분명한 문제를 갖고 있는 특정 컴포넌트가 있습니다. 그리고 이 문제 되는 컴포넌트를 고친다면 걱정 없이 그다음으로 넘어갈 수 있습니다.

그러나 대체로 하나 이상의 병목이 존재합니다. 자주 있는 문제는 모든 유저 상호작용이 성능 병목이 있는 모든 컴포넌트에 리렌더링을 발생시키는 것입니다. 많은 분들이 제게 이 문제에 대해 물었습니다. 폼 필드 컴포넌트가 실제로 변경되는 prop을 받기 때문에 메모이제이션은 도움이 되지 않습니다.

이 문제를 해결하는 가장 쉬운 방법은 사용자의 모든 상호 작용에 반응하지 않는 것입니다.(onChange를 사용하지 마세요). 불행히도 이 방법은 많은 경우 실용적이지 않습니다. 사용자가 제출 버튼을 눌렀을 때뿐만 아니라 폼과 상호 작용할 때도 사용자에게 피드백을 보여주길 원하기 때문입니다.

따라서 사용자의 상호 작용에 반응해야 한다고 가정한다면, 점점 나빠지는 성능에 고통받지 않을 수 있는 가장 좋은 해결책은 무엇일까요? 상태 함께 두기(colocation)입니다!

데모

조금 인위적일 수 있는 예제이지만 이를 통해 문제와 해결 방법을 보여 드리겠습니다. 위에서 얘기한 문제를 경험해 본 적이 있다면 이 예제를 과거의 실제 경험과 연결 지으실 수 있기를 바랍니다. 아직 이 문제를 경험하지 못했다면 앞으로 살펴볼 문제가 실제로 일어나는 문제이고, 이 해결책이 대부분의 상황에 유효하다는 것을 믿어주시기 바랍니다.

코드 샌드박스에서 전체 데모를 확인하실 수 있습니다. 다음은 이 데모의 스크린샷입니다.

아래는 <App /> 컴포넌트에 의해 렌더링 되는 요소들입니다.

function App() {
  return (
    <div>
      <h1>Slow Form</h1>
      <SlowForm />
      <hr />
      <h1>Fast Form</h1>
      <FastForm />
    </div>
  );
}

각 폼은 정확히 동일하게 작동하지만 <SlowForm />가 눈에 띄게 느립니다(아무 필드에 입력을 빠르게 해보세요). 렌더링 된 모든 필드들은 동일한 유효성 검사 로직이 적용되어 있습니다.

  • 소문자만 입력 가능합니다.
  • 문자열의 길이는 3~10자 사이어야 합니다.
  • 필드를 "터치" 하거나 폼이 제출된 경우에만 오류 메시지를 표시합니다.
  • 폼이 제출되면 필드에 입력된 모든 데이터가 콘솔에 기록됩니다.

파일의 상단에는 테스트를 위한 몇 가지 장치들이 있습니다.

window.PENALTY = 150_000;
const FIELDS_COUNT = 10;

FIELDS_COUNT는 렌더링 되는 필드 수를 제어합니다.

PENALTY<Penalty /> 컴포넌트에서 사용됩니다. 각 필드를 렌더링 하는데 더 많은 시간이 걸리도록 하기 위해 사용합니다.

let currentPenaltyValue = 2;
function PenaltyComp() {
  for (let index = 2; index < window.PENALTY; index++) {
    currentPenaltyValue = currentPenaltyValue ** index;
  }
  return null;
}

실제로 PENALTY는 각 필드에서 지수 연산을 수행하는 반복문이 실행되는 횟수를 제어합니다. PENALTYwindow에 있기 때문에 앱이 실행되는 동안에도 다른 제약에서 테스트하기 위해 변경할 수 있습니다. 이는 장치 속도를 조정하는데 유용합니다. 여러분의 컴퓨터와 제 컴퓨터는 서로 다른 성능 특성을 갖기 때문에 여러분의 측정값이 저와 조금 다를 수 있습니다. 모두 상대적입니다.

그럼, 설명은 이만하고 <SlowForm />을 먼저 살펴보겠습니다.

<SlowForm />

/**
 * 트리의 상단에서 상태를 관리하면 prop drilling도 필요하게 됩니다.
 * props를 FastInput 컴포넌트와 비교해 보세요.
 */
function SlowInput({
  name,
  fieldValues,
  touchedFields,
  wasSubmitted,
  handleChange,
  handleBlur,
}: {
  name: string;
  fieldValues: Record<string, string>;
  touchedFields: Record<string, boolean>;
  wasSubmitted: boolean;
  handleChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
  handleBlur: (event: React.FocusEvent<HTMLInputElement>) => void;
}) {
  const value = fieldValues[name];
  const touched = touchedFields[name];
  const errorMessage = getFieldError(value);
  const displayErrorMessage = (wasSubmitted || touched) && errorMessage;

  return (
    <div key={name}>
      <PenaltyComp />
      <label htmlFor={`${name}-input`}>{name}:</label> <input
        id={`${name}-input`}
        name={name}
        type="text"
        onChange={handleChange}
        onBlur={handleBlur}
        pattern="[a-z]{3,10}"
        required
        aria-describedby={displayErrorMessage ? `${name}-error` : undefined}
      />
      {displayErrorMessage ? (
        <span role="alert" id={`${name}-error`} className="error-message">
          {errorMessage}
        </span>
      ) : null}
    </div>
  );
}

/**
 * SlowForm 컴포넌트는 가장 일반적인 접근 방식으로 모든 필드를 제어하고
 * 상태를 리액트 트리의 위쪽에서 관리합니다.
 * 이는 모든 키 입력에 대해 모든 필드가 리렌더링 된다는 것을 의미합니다.
 * 일반적으로 이는 큰 문제가 되진 않습니다.
 * 하지만 리렌더링 비용이 조금 큰 컴포넌트들이 있고, 이 비용이 모두 합쳐지면 큰일이 날 겁니다.
 */

function SlowForm() {
  const [fieldValues, setFieldValues] = React.useReducer(
    (s: typeof initialFieldValues, a: typeof initialFieldValues) => ({
      ...s,
      ...a,
    }),
    initialFieldValues
  );
  const [touchedFields, setTouchedFields] = React.useReducer(
    (s: typeof initialTouchedFields, a: typeof initialTouchedFields) => ({
      ...s,
      ...a,
    }),
    initialTouchedFields
  );
  const [wasSubmitted, setWasSubmitted] = React.useState(false);
  function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const formIsValid = fieldNames.every((name) => !getFieldError(fieldValues[name]));
    setWasSubmitted(true);
    if (formIsValid) {
      console.log(`Slow Form Submitted`, fieldValues);
    }
  }
  function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
    setFieldValues({ [event.currentTarget.name]: event.currentTarget.value });
  }
  function handleBlur(event: React.FocusEvent<HTMLInputElement>) {
    setTouchedFields({ [event.currentTarget.name]: true });
  }
  return (
    <form noValidate onSubmit={handleSubmit}>
      {fieldNames.map((name) => (
        <SlowInput
          key={name}
          name={name}
          fieldValues={fieldValues}
          touchedFields={touchedFields}
          wasSubmitted={wasSubmitted}
          handleChange={handleChange}
          handleBlur={handleBlur}
        />
      ))}
      <button type="submit">Submit</button>
    </form>
  );
}

전 거기서 많은 일들이 일어나고 있는 것을 알고 있습니다. 시간을 들여 어떻게 작동하는지 알아보세요. 명심해야 할 점은 모든 상태가 <SlowForm /> 컴포넌트에서 관리되고 하위 필드들에 상태를 props로 넘겨준다는 것입니다.

자, 이제 이 폼과의 상호 작용을 프로파일링 해봅시다. 저는 이 코드를 프로덕션용으로 빌드 했습니다(프로파일링이 활성화된 상태로). 테스트의 일관성을 유지하기 위해 제가 수행할 상호 작용은 첫 번째 입력창을 선택하고 "a"를 입력한 후 해당 입력창을 "선택 해제"(바깥쪽을 클릭) 하는 것입니다.

속도가 느린 모바일 장치를 시뮬레이션하기 위해 브라우저의 개발자 도구에서 CPU 속도를 6배 낮춘 상태로 성능 프로파일링 세션을 시작하겠습니다.

프로파일은 다음과 같습니다.

와우. 이것 좀 보세요. 키 입력 이벤트를 처리하는 데 97 밀리초나 걸렸습니다. 자바스크립트를 실행하는데 우리에게 주어진 시간은 16 밀리초뿐임을 기억하세요. 그보다 더 길어지면 버벅거린다고 느끼기 시작합니다. 그리고 맨 아랫부분을 보면 입력창에 문자 하나를 입력하고 선택 해제했을 뿐인데 메인 스레드가 112 밀리초 동안 차단되었음을 알 수 있습니다. 끔찍하네요.

CPU 속도를 6배 낮췄다는 것을 잊지 마세요. 대부분의 사용자에겐 크게 나쁘지 않겠지만, 이는 여전히 심각한 성능 문제를 나타냅니다.

리액트 개발자 도구 프로파일러를 사용해 폼의 입력창 중 하나와 상호 작용할 때 리액트가 무엇을 하는지 관찰해 보겠습니다.

흠, 모든 필드가 리렌더링 되는 걸로 보입니다. 하지만 이럴 필요는 없습니다. 상호 작용 중인 단 하나의 필드만 리렌더링 되면 됩니다.

이 문제를 해결하기 위해 먼저 각 필드 컴포넌트들을 메모이제이션 하는 방법을 떠올릴 수 있습니다. 문제는 코드 베이스의 나머지 부분으로 거미줄처럼 빠르게 뻗어나갈 수 있는 props들을 모두 메모이제이션해 전달해야 한다는 것입니다. 그리고 그러기 위해선 원시 값 또는 메모이제이션 가능한 값만 전달하도록 props를 재구성해야 합니다. 저는 이런 이유로 가능하면 메모이제이션하지 않으려 노력합니다. 그리고 그건 가능합니다. 대신 상태 함께 두기(co-location)를 사용해 봅시다!

<FastForm />

다음은 완전히 똑같은 경험을 제공하지만, 각 필드 내부에 상태를 두도록 재구성되었습니다. 다시 한번 시간을 들여 코드를 읽고 어떻게 작동하는지 이해해 보세요.

/**
 * 우리는 이 컴포넌트에 많은 것을 넘겨줄 필요가 없습니다.
 * `name`은 폼이 제출되었을 때 form.elements에서 필드값을 찾는데 사용되기 때문에 중요합니다.
 * wasSubmitted는 이 필드가 터치되지 않았더라도 에러 메시지를 표시해야 하는지를 판단하는데 유용합니다.
 * 다른 모든 것들은 내부적으로 관리되므로 이 필드는 SlowInput 컴포넌트와 같은 불필요한 리렌더링을 경험하지 않습니다.
 */
function FastInput({ name, wasSubmitted }: { name: string; wasSubmitted: boolean }) {
  const [value, setValue] = React.useState('');
  const [touched, setTouched] = React.useState(false);
  const errorMessage = getFieldError(value);
  const displayErrorMessage = (wasSubmitted || touched) && errorMessage;

  return (
    <div key={name}>
      <PenaltyComp />
      <label htmlFor={`${name}-input`}>{name}:</label> <input
        id={`${name}-input`}
        name={name}
        type="text"
        onChange={(event) => setValue(event.currentTarget.value)}
        onBlur={() => setTouched(true)}
        pattern="[a-z]{3,10}"
        required
        aria-describedby={displayErrorMessage ? `${name}-error` : undefined}
      />
      {displayErrorMessage ? (
        <span role="alert" id={`${name}-error`} className="error-message">
          {errorMessage}
        </span>
      ) : null}
    </div>
  );
}

/**
 * FastForm 컴포넌트는 비제어 방식을 사용합니다.
 * 모든 값을 추적하고 각 필드에 전달하는 대신 필드 자체에서 값을 추적하게 하고
 * 제출될 때 form.elements에서 값을 찾습니다.
 */
function FastForm() {
  const [wasSubmitted, setWasSubmitted] = React.useState(false);

  function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const formData = new FormData(event.currentTarget);
    const fieldValues = Object.fromEntries(formData.entries());

    const formIsValid = Object.values(fieldValues).every((value: string) => !getFieldError(value));

    setWasSubmitted(true);
    if (formIsValid) {
      console.log(`Fast Form Submitted`, fieldValues);
    }
  }

  return (
    <form noValidate onSubmit={handleSubmit}>
      {fieldNames.map((name) => (
        <FastInput key={name} name={name} wasSubmitted={wasSubmitted} />
      ))}
      <button type="submit">Submit</button>
    </form>
  );
}

아시겠죠? 다시 말씀드리지만, 가장 중요한 것은 상태가 부모가 아니라 폼 필드 자체에서 관리된다는 것입니다. 이제 여기에 성능 프로파일러를 사용해 보겠습니다.

좋습니다! 16 밀리초 안에 처리될 뿐만 아니라 메인 스레드가 차단된 시간이 총 0 밀리초라는 것을 눈치채셨을 겁니다! 112 밀리초보다 훨씬 낫습니다😅 그리고 CPU 속도를 6배 낮춘 상태이기 때문에 많은 사용자에게는 훨씬 더 나을 것입니다.

리액트 개발도구를 열어 상호 작용으로 인해 리렌더링 할 필요가 있는 요소만 리렌더링 되고 있는지 확인해 봅시다.

좋아요! 리렌더링이 필요한 컴포넌트만 리렌더링 되었습니다. 실제로 <FastForm /> 컴포넌트는 리렌더링 되지 않았기 때문에 결과적으로 다른 자식들 중 어느 것도 리렌더링 할 필요가 없었습니다. 때문에 메모이제이션 작업을 전혀 필요로 하지 않습니다.

미묘한 차이...

경우에 따라 유효성 검사를 위해 다른 필드의 값을 알아야 하는 필드가 있을 수 있습니다(예를 들어 "비밀번호 확인" 필드는 "비밀번호" 필드의 값을 알아야 값이 동일한지 확인할 수 있습니다). 이 경우 몇 가지 방법이 있습니다. 부모 컴포넌트로 상태를 올려보낼 수 있지만 이는 이상적이지 않습니다. 왜냐하면 상태가 변경될 때마다 모든 컴포넌트가 리렌더링 되므로 메모이제이션을 신경 써야 하기 때문입니다(리액트는 이에 대한 좋은 대안을 제공합니다).

또 다른 방법은 컴포넌트가 위치한 컨텍스트에 담아 상태 변경 시 컨텍스트 공급자와 소비자만 리렌더링 되도록 하는 것입니다. 이 방법으로 최적화할 수 있도록 구조를 만들어야 합니다. 그렇지 않으면 별로 나아지지 않을 것입니다.

세 번째 방법은 리액트를 벗어나 DOM을 직접 참조하는 것입니다. 관련된 컴포넌트는 부모 폼에 자신의 change 이벤트 리스너를 부착해 변경된 값이 유효성 검사가 필요한지 확인할 수 있습니다.

Brooks Lybrand는 이 두 가지 대안의 예제를 만들었습니다. 제 말이 무슨 뜻인지 더 잘 알고 싶으시면 이 예제들을 살펴보세요.

좋은 점은 이러한 여러 방법들을 시도해 보고 가장 마음에 드는(혹은 제일 덜 싫어하는 😅) 방법을 선택할 수 있다는 것입니다.

결론

여기서 직접 시연해 볼 수 있습니다.

크롬 개발자 도구의 성능 탭에서 프로파일링을 할때 프로덕션용으로 빌드 했는지 잊지 말고 확인하고, CPU 스로틀과 PENALTY 값을 갖고 놀아보세요.

결국 가장 중요한 것은 애플리케이션 코드입니다. 따라서 앱에서 이러한 프로파일링 전략들을 사용해 본 다음 성능을 향상시키기 위해 상태 함께 두기를 사용해 보는 것을 권합니다.

행운을 빕니다!

🚀 한국어로 된 프런트엔드 아티클을 빠르게 받아보고 싶다면 Korean FE Article(https://kofearticle.substack.com/)을 구독해주세요!

profile
FE 개발을 하고 있어요🌱

5개의 댓글