버벅이는 화면 React.memo로 최적화하기

    📅 2022. 03. 11

    서론

    React는 기본적으로 사용자가 빠른 UI를 경험할 수 있도록 최적화되어 있습니다. 하지만 앱의 규모가 커져가면서 속도가 느려지는 순간이 찾아옵니다.

    사용자에게 가장 빠른 UI를 제공하는 건 프론트엔드 개발자의 중요한 덕목 중 하나라고 볼 수 있습니다.

    "지출 결의서를 쓰는데 화면이 버벅여요" "렉이 너무 심해요"

    어느 날 작성 폼에서 화면에 렉이 걸린다는 구성원분들의 제보를 받았습니다. 실제로 테스트를 해보니 테이블에 행을 추가할수록 값을 변경할 때 렌더링 시간이 길어졌습니다.

    React Dev Tools의 Profiler를 이용해서 살펴본 결과 테이블 안의 모든 컴포넌트가 한 글자 입력할 때마다 매번 렌더링 되고 있었습니다. 결과적으로 React.memo를 사용해서 렌더링 시간을 1/5로 줄일 수 있었습니다.

    React 성능 최적화 API 중 하나인 React.memo를 알아보고 실제 상황을 재현한 프로젝트를 통해 최적화를 어떻게 진행했는지 살펴 보도록 하겠습니다.

    React.memo?

    React.memo는 HOC입니다. 대상 컴포넌트를 한 번 감싸서 props 변경이 없으면 리렌더링을 방지합니다.

    아래는 React 레파지토리에서 가져온 memo API의 소스 코드입니다.

    export function memo<Props>(
      type: React$ElementType,
      compare?: (oldProps: Props, newProps: Props) => boolean,
    ) {

    첫 번째 인자는 컴포넌트를 받고, 두 번째 인자는 선택적으로 props를 비교할 함수를 받습니다. 두 번째 인자에 ?가 붙은 걸 보니 선택적으로 비교 로직을 짤 수 있는 것 같아 보입니다.

    컴포넌트 코드 예시를 보면서 실제로 어떻게 쓰이는지 알아보도록 하겠습니다. 아래는 상품의 가격을 표시해 주는 단순한 컴포넌트입니다.

    function DisplayPrice({ price }) {
      return (
        <div>{price}</div>
      );
    }
    
    DisplayPrice.propTypes = { price: PropTypes.number };
    
    export default React.memo(DisplayPrice);

    React.memo에 의해 감싸진 컴포넌트는 기본적으로 props의 변경이 없다면 리렌더링이 일어나지 않게 됩니다. 즉, price 값이 변하지 않는 한 이 컴포넌트는 가만히 있게 됩니다.

    React.memo로 대상 컴포넌트를 감싸면 얕은 비교를 통해 최적화가 이루어집니다. 다만 기본 로직에 의한 최적화는 primitive 값 비교는 잘하지만 object, array, function 타입의 props는 항상 areEqual? => false가 되기 때문에 매번 리렌더링이 일어납니다. '===' 동치 비교를 떠올리면 이해가 쉽습니다.

    당장 개발자 도구를 열어서 아래 구문을 실행해 봅시다.

    { key: 'value' } === { key: 'value' } // false

    여기서 아까 소스코드에서 봤던 compare?: (oldProps: Props, newProps: Props) => boolean 함수의 용도를 예상해 볼 수 있습니다. 복잡한 object props 비교는 개발자가 어떻게 구현하느냐에 달려있습니다.

    이론에 대한 설명은 여기까지 하고, 실제 예제를 통해 Memo를 썼을 때와 안 썼을 때 성능 차이가 얼마나 알아보겠습니다.

    문제의 상황 재현하기

    화면이 버벅였던 상황을 최대한 비슷하게 재현하기 위해 원본 프로젝트와 같은 Nextjs + Ant Design + Formik 기술 스택을 사용해서 구현하였습니다. 완성된 코드는 Full example Github Link에서 확인하실 수 있습니다.

    완성된 화면은 다음과 같습니다.

    / 페이지는 문제가 발생한 컴포넌트로 구성이 되있으며, /memoized 페이지는 React.memo를 사용해서 최적화된 컴포넌트로 구성이 되있습니다.

    아래는 최적화 전 페이지에서 총액 데이터를 입력한 영상입니다.

    영상을 보면 input에 값을 입력할 때 지연 시간이 있는 걸 확인할 수 있습니다.

    React Dev Tools를 통해 프로파일링을 해보면 1개의 Cell이 변경되었을 때 Table의 다른 나머지 Cell들도 다같이 리렌더링이 되는걸 확인할 수 있습니다. 이 부분을 React.memo를 사용해서 변경된 Cell만 리렌더링이 되도록 수정해 보겠습니다.

    예제 프로젝트의 컴포넌트 계층 구조를 그려보면 아래와 같습니다.

    <ProductVoucherForm> -- Formik <ProductTable> -- Antd Table <FormInput> <BasicField> <Input /> -- Antd Input </BasicField> </FormInput> </ProductTable> </ProductVoucherForm>

    <ProductVoucherForm /> 컴포넌트 부터 아래로 차근차근 내려가면서 memo를 적용할 수 있는 부분을 찾아보도록 하겠습니다.

    아래는 계층 구조 기준 가장 상위 컴포넌트인 <ProductVoucherForm /> 코드입니다.

    // path: optimization/components/ProductVoucherForm.tsx
    
    import { Formik, Form, FormikValues, FormikProps } from 'formik';
    import * as yup from 'yup';
    import { ProductVoucher } from 'types';
    import { Button, Space } from 'antd';
    import ProductTable from 'components/ProductTable';
    import FormInput from 'components/FormInput';
    
    const initialValues: ProductVoucher = {
      title: '',
      products: new Array(50).fill({}).map((n, index) => ({
        name: `name-${index}`,
        uid: `${index}`,
        amount: 0,
        vat: 0,
      })),
    };
    
    const schema = yup.object({
      title: yup.string().required(),
      products: yup.array(
        yup.object({
          name: yup.string(),
          uid: yup.string(),
          amount: yup.number(),
          vat: yup.number(),
        }).required(),
      ).required().min(1),
    });
    
    function ProductVoucherForm() {
      return (
        <Formik
          initialValues={initialValues}
          validationSchema={schema}
          onSubmit={(values, actions) => {
            alert('submit');
          }}
        >
          {(formik: FormikProps<FormikValues>) => {
            const { values, isSubmitting, } = formik;
    
            return (
              <Form className="ant-form ant-form-horizontal">
                <Space direction="vertical">
                  <FormInput
                    name="title"
                    label="제목"
                    type="string"
                    required={true}
                  />
                  <ProductTable
                    prefix="products"
                    products={values.products}
                  />
                  <Button type="primary" htmlType="submit" loading={isSubmitting}>
                    저장
                  </Button>
                </Space>
              </Form>
            );
          }}
        </Formik>
      );
    }
    
    export default ProductVoucherForm;

    initialValues는 Formik의 초기 데이터 상태를 정의합니다. 퍼포먼스 테스트를 위해 초기값으로 50개의 products 배열을 생성하는 코드를 작성했습니다. products 값은 <ProductTable />의 prop으로 전달되서 각 로우의 셀을 렌더링 하는데 사용됩니다.

    const initialValues: ProductVoucher = {
      title: '',
      products: new Array(50).fill({}).map((n, index) => ({
        name: `name-${index}`,
        uid: `${index}`,
        amount: 0,
        vat: 0,
      })),
    };

    초기화 된 값은 <ProductTable />products prop으로 전달됩니다.

    <ProductTable
                    prefix="products"
                    products={values.products}
                  />

    아래는 <ProductTable /> 컴포넌트 코드입니다.

    // path: optimization/components/ProductTable.tsx
    
    import { Table } from 'antd';
    import { ColumnsType } from 'antd/es/table';
    import { Product } from 'types';
    import FormInput from 'components/FormInput';
    
    interface ProductsProps {
      prefix: string;
      products: Product[];
    }
    
    function ProductTable({
      prefix,
      products,
    }: ProductsProps) {
      const columns: ColumnsType<Product> = [
        {
          title: '제품명',
          dataIndex: 'name',
          key: 'name',
          render: (value, record, index) => (
            <FormInput
              name={`${prefix}[${index}].name`}
              type="string"
              required={true}
            />
          )
        },
        {
          title: 'UID',
          dataIndex: 'uid',
          key: 'uid',
          render: (value) => value
        },
        {
          title: '총액',
          dataIndex: 'amount',
          key: 'amount',
          render: (value, record, index) => (
            <FormInput
              name={`${prefix}[${index}].amount`}
              type="number"
              required={true}
            />
          )
        },
        {
          title: '부가세',
          dataIndex: 'vat',
          key: 'vat',
          render: (value, record, index) => (
            <FormInput
              name={`${prefix}[${index}].vat`}
              type="number"
              required={true}
            />
          )
        },
      ];
    
      return (
        <Table
          columns={columns}
          dataSource={products}
          rowKey="uid"
          pagination={false}
        />
      );
    }
    
    export default ProductTable;

    <ProductTable />의 props를 보면 원시값 prefix와 복잡한 오브젝트 products를 갖고 있습니다. 배열을 prop으로 가진 경우 memo를 써도 성능 개선이 어렵기 때문에 아랫단의 컴포넌트에서 최적화 구간을 찾아보도록 합시다.

    <FormInput />type 속성에 따라 일반 input 혹은 숫자 입력 input을 렌더링 하는 HOC 컴포넌트입니다.

    // path: optimization/components/FormInput.tsx
    
    import { Field, getIn } from 'formik';
    import { FieldProps } from 'formik/dist/Field';
    import BasicField from 'components/BasicField';
    import { Input, InputNumber } from 'antd';
    
    interface FormInputProps {
      name: string;
      label?: string;
      type: 'string' | 'number';
      required: boolean;
    }
    
    function FormInput({ name, label, type, required = false, }: FormInputProps) {
      return (
        <Field name={name}>
          {({field, form}: FieldProps) => {
            const { errors, touched, values, setFieldValue, setFieldTouched } = form;
    
            return (
              <BasicField
                name={field.name}
                label={label}
                required={required}
                error={getIn(errors, name, '')}
                touched={getIn(touched, name, false)}
              >
                {(() => {
                  switch (type) {
                    case 'string':
                      return (
                        <Input
                          value={getIn(values, name, '')}
                          onChange={e => setFieldValue(name, e.target.value)}
                          onBlur={() => setFieldTouched(name, true)}
                        />
                      );
                    case 'number':
                      return (
                        <InputNumber
                          value={getIn(values, name, 0)}
                          onChange={value => setFieldValue(name, value)}
                          onBlur={() => setFieldTouched(name, true)}
                        />
                      );
                    default:
                      return <div />;
                  }
                })()}
              </BasicField>
            );
          }}
        </Field>
      );
    }
    
    export default FormInput;

    <Input />이 있는 제일 아랫단까지 내려왔습니다. 결론적으로 말씀드리면 <Field /> 혹은 <BasicField />를 memo하면 불필요한 렌더링을 방지할 수 있습니다. <FormInput /> 컴포넌트를 memo하면 되는 거 아닌가? 라고 생각하실 수도 있을텐데, <Field /> 컴포넌트의 useContext 로직이 props 변경과 상관없이 항상 리렌더링을 유발하기 때문에 성능 개선의 효과를 기대하긴 어렵습니다.

    리렌더링을 유발하는 hook에 대한 내용은 React 공식 문서에서 확인하실 수 있습니다.

    React.memo only checks for prop changes. If your function component wrapped in React.memo has a useState, useReducer or useContext Hook in its implementation, it will still rerender when state or context change.

    <Field /> 컴포넌트는 memo가 가능할까요? <FastField /> 컴포넌트를 사용하면 가능합니다. 하지만 "다른 필드로부터 독립적"이어야 한다는 조건이 있습니다.

    Formik은 memo가 적용된 FastField라는 컴포넌트를 제공합니다. 공식 문서에서 다음과 같이 설명하고 있습니다.

    '<FastField />' is meant for performance optimization. However, you really do not need to use it until you do. If a is "independent" of all other 's in your form, then you can use .

    For example, '<FastField name="firstName" />' will only re-render when there are:

    • Changes to values.firstName, errors.firstName, touched.firstName, or isSubmitting. This is determined by shallow comparison. Note: > dotpaths are supported.
    • A prop is added/removed to the <FastField name="firstName" />
    • The name prop changes

    저자는 <Field /> 컴포넌트가 모든 다른 필드로부터 독립적일 때 사용할 수 있다고 명시했습니다. 예를 들어 amount, vat가 있을 때 amount를 입력하면 vatamount의 10%로 자동 입력되는 상황을 떠올려 봅시다. 이때 vat 필드는 amount에 종속성이 생긴다고 볼 수 있습니다.

    제가 진행했던 프로젝트는 이런 요구사항들이 굉장히 많아서 <FastField /> 사용은 불가능했습니다. 그럼 더 아랫단 컴포넌트로 내려가서 최적화가 가능할지 살펴봐야겠네요.

    아래는 <Field />의 하위 컴포넌트인 <BasicField /> 컴포넌트입니다. 여기서 최적화가 가능할지 한 번 보도록 하겠습니다.

    // path: optimization/components/BasicField.tsx
    
    import React from 'react';
    import { Form } from 'antd';
    
    interface BasicFieldProps {
      label?: string;
      name: string;
      required?: boolean;
      error?: any;
      touched?: boolean;
      style?: object;
      labelCol?: object;
      wrapperCol?: object;
      children: React.ReactElement;
    }
    
    function BasicField({
      label,
      name,
      required = false,
      error,
      touched = false,
      style,
      labelCol,
      wrapperCol,
      children,
    }: BasicFieldProps) {
      return (
        <Form.Item
          label={label}
          name={name}
          required={required}
          hasFeedback={!!error}
          validateStatus={(error && touched && 'error') || ''}
          help={touched && error}
          {...(labelCol && { labelCol })}
          {...(wrapperCol && { wrapperCol })}
          {...(style && { style })}
        >
          <>
            {React.cloneElement(children, {
              id: name,
            })}
          </>
        </Form.Item>
      );
    }
    
    export default BasicField;

    <BasicField /> 컴포넌트는 Ant Design의 <Form.Item />으로 children을 감싸주는 HOC 컴포넌트입니다. useState, useContext, useReducer 등 강제 리렌더링을 유발하는 요소들이 없으니 안심하고 React.memo를 써볼 수 있을 것 같습니다.

    아래는 memo를 적용해서 새로 작성한 <MemoizedBasicField /> 코드입니다.

    // path: optimization/components/MemoizedBasicField.tsx
    import React from 'react';
    import { Form } from 'antd';
    import { getIn } from 'formik';
    import isEqual from 'react-fast-compare';
    
    function MemoizedBasicField({
      // <BasicField /> 컴포넌트와 동일
    });
    
    export default React.memo(
      MemoizedBasicField,
      (prevProps, nextProps) => {
        const name = nextProps.name || prevProps.name;
        const prevError = getIn(prevProps.error, name);
        const nextError = getIn(nextProps.error, name);
    
        if (prevError !== nextError) return false;
    
        const prevTouched = getIn(prevProps.touched, name);
        const nextTouched = getIn(nextProps.touched, name);
    
        if (prevTouched !== nextTouched) return false;
    
        // check diff primitive props
        ['label', 'name', 'required'].forEach(key => {
          const prevProp = getIn(prevProps, key);
          const nextProp = getIn(nextProps, key);
    
          if (prevProp !== nextProp) return false;
        });
    
        // check diff object props
        ['style', 'labelCol', 'wrapperCol'].forEach(key => {
          const prevProp = getIn(prevProps, key);
          const nextProp = getIn(nextProps, key);
    
          if (!isEqual(prevProp, nextProp)) return false;
        });
    
        return true;
      }
    );

    로직을 정리하면 순서대로 error, touched, 원시값 비교, 오브젝트 값 비교를 하고 변경된 게 없으면 areEqual? => true를 반환합니다.

    error, touched 오브젝트는 각각 해당 필드에 에러가 있는지, 사용자의 상호작용이 있었는지를 key-value로 저장합니다. FormikgetIn 함수를 이용하면 a[0].b.c와 같은 형식으로 쉽게 value에 접근할 수 있습니다.

    만약 error, touched에 변화가 없었다면 원시값 props들을 비교해 봅니다. 원시값은 단순하게 '==='로 빠른 비교가 가능하므로 오브젝트 비교보다 먼저 하는 것이 좋습니다. 만약 비교 결과가 같다면 react-fast-compare 라이브러리를 사용해서 오브젝트를 비교합니다.

    react-fast-compare는 깊은 비교를 빠르게 해주는 라이브러리입니다.

    React Fast Compare Benchmark

    벤치마크 결과를 보면 react-fast-comparelodash/isEqual보다 약 4배 이상 빠른 속도를 보여줍니다. 만약 오브젝트의 구조를 다 알고 있고 유지 보수가 가능하다면 직접 비교 로직을 최적화해보는 것도 좋습니다. 저는 react-fast-compare의 성능으로 이미 충분한 퍼포먼스 개선 효과를 보았고 비교 로직에 대한 개발 공수를 줄이기 위해 라이브러리를 사용했습니다.

    최적화 전, 후 성능 비교

    최적화 전

    최적화 후

    최적화 전 영상을 보시면 12345를 연달아 입력했을 때 바로 입력이 안되고 딜레이가 생긴 후에 값이 변화되는 모습을 볼 수 있습니다. 반면에 최적화 후 영상을 보시면 딜레이 없이 숫자가 자연스럽게 입력이 되는 걸 확인할 수 있습니다.

    최적화 전

    최적화 후

    React Profiler로 memo 적용 전, 적용 후를 측정한 결과입니다. 적용 전에는 모든 컴포넌트가 리렌더링 되는 반면, 적용 후에는 변경 사항이 없는 컴포넌트에서 리렌더링이 일어나지 않는 것을 확인하실 수 있습니다 (그래프의 회색).

    최적화 전

    최적화 후

    페이즈에서 가장 렌더링이 긴 시간을 대조해보면 109.2ms -> 27.1ms로 약 5배 정도 빨라진 것을 확인할 수 있습니다.

    결론

    • 화면이 버벅인다면 불필요한 렌더링이 일어나는지 확인해 보고 React.memo를 통해 개선할 수 있습니다.
    • React.memo를 사용하면 props 간의 비교를 통해 변화가 일어났을 때만 렌더링이 됩니다. 빌트인 API는 얕은 비교 로직을 사용하지만 두 번째 인자를 통해 원하는 비교 로직을 직접 구현할 수도 있습니다.
    • React.memo를 사용할 때 주의해야 할 점은 useState, useContext와 같은 hook을 사용하는 컴포넌트의 경우 비교 로직의 참 거짓 여부와 상관없이 리렌더링이 될 수 있다는 점입니다.
    • react-fast-compare를 사용하면 A, B의 빠른 속도의 깊은 비교를 간단하게 구현할 수 있습니다. 직접 알고리즘을 짜는 게 어렵거나 개발 공수를 줄이고 싶다면 라이브러리를 쓰는 것도 좋은 방법입니다.

    결과적으로 원본 코드를 재현해보았을 때 React.memo를 통해 약 5배 정도의 성능 향상을 얻을 수 있었습니다.

    이상으로 글을 마칩니다. 감사합니다.