(번역) 리액트 성능 최적화, 500ms 에서 1.7ms 까지 : 그 여정과 체크리스트

원문: 500ms to 1.7ms In React: A Journey And A Checklist

이 글은 중국어로도 번역되어 있습니다. (by Qlly) Link1 Link2

모든 앱에서, 그리고 모든 개발자의 특정 개발 단계에서 성능 개선이 필요한 시기는 반드시 발생합니다. 리액트에서 성능을 개선하는 방법에 대한 매주 좋은 글과 자원이 있으며, 이 글도 예외는 아닙니다. 저는 제가 겪은 성능 병목 현상을 500ms에서 1.7ms까지 줄였었던 여정과 결론을 공유하고자 합니다.

성능 개선 전:

성능 개선 후:

언제 성능이 감소할까요?

저는 성능 감소를 알아차렸을 때 성능 개선을 시작하는 것에 대해 동의합니다. 즉, 저는 평소에 useMemo()React.memo()를 매 상태마다 사용하지 않습니다. 메모이제이션이 필요하지 않은 몇 가지 사용 사례가 존재하기 때문입니다. 즉, 저는 “과도한 메모이제이션”을 지양하고 너무 이른 시기에 최적화하지 않는 것을 권장합니다.

제가 작업하고 있는 사례의 경우 UI가 매우 느릿느릿하게 느껴졌습니다. ─ 특히 0.5초의 대기시간이 눈에 띕니다. 첫 번째 의미 있는 페인트(First Meaningful Paint)라는 용어는 첫 번째가 아닐 때도 적용됩니다.🙂 주의력 지속 시간은 일반적으로 낮기 때문에 느린 UI는 사용자를 이탈시킬 수 있습니다.

설정 및 환경

이 성능 개선 여정에는 다음이 포함됩니다:

  1. React 17
  2. 기본 UI 라이브러리 차크라(Chakra)-UI
  3. 상태 관리 도구 리덕스 툴킷(Redux Toolkit)
  4. 성능 문제 원인 react-table v7

성능 문제

제 사용 사례의 테이블은 셀에 대한 커스텀 렌더러가 거의 없는 커스텀 그리드입니다. 테이블의 인스턴스는 다른 컴포넌트와 함께 부모 컴포넌트의 일부입니다. 첫 번째 렌더링은 때때로 잘 진행되었지만, 부모 컴포넌트 안의 훅 또는 부모 컴포넌트와 관련된 상태가 업데이트가 있을 때 테이블이 리렌더링하는 데 0.5초나 걸리는 느린 UI가 변경이 발생합니다. UX를 사용하면, UI가 잠시 정지된 뒤 약간의 움직임을 볼 수 있습니다. 테이블은 심지어 데이터(행과 열)가 전혀 변경되지 않은 경우에도 다시 렌더링 됩니다.

단계 1 - 큰 컴포넌트를 잘 정의된 작은 컴포넌트로 분해

react-table의 훅은 UI를 노출하지 않으므로 문서에 따라서 UI를 구현해야 합니다. 대부분의 예제는 잘 작동하는 단순한 테이블의 중첩되지 않은 플랫 버전을 보여줍니다. 테이블 컴포넌트는 약 200줄 이상의 중첩되거나 중첩되지 않은 jsx 컴포넌트로 구성되어 있습니다. 중첩되지 않은 jsx 중 일부는 행과 행 헤더를 렌더링하는 .map() 순회를 포함하고 있습니다.

이 시점에서, 그것을 더 작은 컴포넌트로 분해하는 리팩터링이 필요하다는 것이 명백했습니다. 래퍼 컴포넌트로 캡슐화될 수 있는 코드 블록을 쉽게 찾을 수 있었습니다. 예를 들어 <TableHeader/>,<TableRows/> 등이 있습니다. 이 단계는 테이블을 더 작고, 읽기 쉽게, 그리고 추론하기 쉽게 만듭니다. 몇 개의 블록을 컴포넌트에 캡슐화함으로써 얻을 수 있는 이점 중 하나는 프로퍼티가 원시 타입일때 불필요한 리렌더링을 줄일 수 있는 것입니다. 이 단계만으로도 0.5초의 멈춤을 150ms로 감소시킵니다. 이것은 훌륭하지만, 여전히 350ms는 느리게 느껴집니다.

단계 2 - React.memo() 컬렉션 컴포넌트

배열, 객체, 함수와 같은 원시 타입이 아닌 프로퍼티를 포함하는 컴포넌트의 경우, 더 자세히 살펴봐야 했습니다. React.memo를 사용하면서 리렌더링을 줄일 수 있었지만, 렌더에서 정적 객체 프로퍼티를 제거하는 것도 도움이 되었습니다.

const config = {
  headerHandlers: { isCustom: true },
  rowHandlers: { isCustomPadding: true },
  paginationConfig: {
    autoResetPage: false,
    autoResetGlobalFilter: false,
    initialState: { pageSize: 20 },
  },
}

<ReadMTable {...config} data={events} columns={eventColumns} />

.map()을 사용하여 배열을 통해 반복하는 컬렉션 컴포넌트의 경우, 각 항목이 컴포넌트에 매핑되고 고유한 키를 얻도록 했습니다. 이는 컴포넌트가 원시 타입의 프로퍼티를 포함할 때, 렌더링하지 않고 다시 사용할 수 있음을 의미합니다.

export const ReadMTableRows = React.memo(EventsTableRows);
export function EventsTableRows({
  rows,
  prepareRow,
  onClick,
  isRowDisabled,
}: TableRowsProps) {
  return rows.length > 0 ? (
    <>
      {rows.map(row => (
        <TableRow
          row={prepareRow(row)}
          key={`event-table-row-${row.id}`}
          onRowClick={onClick}
          disabled={isRowDisabled}
        />
      ))}
    </>
  ) : null;
}

위에 패턴에 따라서, 전체 0.5초300ms로 감소하였습니다. 꽤 기뻤지만, 개선의 여지가 더 보였습니다.

단계 3 - jsx의 const 변수를 컴포넌트로 변환

이 단계에서 테이블의 일부 프로퍼티를 사용하는 jsx 코드를 가리키는 몇 가지 상수가 있음을 확인했습니다. 다시 한 번, 리액트의 컴포넌트 렌더링 생명 주기에서 원시 타입 프로퍼티의 이점을 얻기 위해, 그것들을 아래와 같이 컴포넌트로 변환했습니다.

// BEFORE
const renderTableTitle = (title, totalRows) => {
  return (
    <Flex alignItems="center">
      <Heading as="h4" fontWeight="bold">
        {title}
      </Heading>
      {totalRows}
    </Flex>
  );
};
// 이것은 항상 호출됩니다.
{
  renderTableTitle('student statistics', page.length);
}

// AFTER
function TableTitle({ title, totalRows }) {
  return (
    <Flex alignItems="center">
      <Heading as="h4" fontWeight="bold">
        {title}
      </Heading>
      {totalRows}
    </Flex>
  );
}

// 제목(string) 또는 page.length(number)가 변경 될 때 다시 렌더링 됩니다.
<TableTitle title={title} totalRows={page.length} />;

title 또는 page.length가 변경될 때, <TableTitle /> 컴포넌트가 다시 렌더링 되는 것을 쉽게 볼 수 있으며, 둘 다 원시 타입 프로퍼티입니다. 컴포넌트로 사용할 경우, 앱은 리액트 컴포넌트 재조정 생명주기의 이점을 누릴 수 있고, props가 같은 값을 가지고 있는 한 컴포넌트는 리렌더링 되지 않습니다. 동일한 값으로, 원시 타입 값(number, string, boolean) 또는 동일한 참조 원시 타입이 아닌 값(function, object)을 참조하고 있습니다.(이것은 여러 전략에 의해 달성됩니다: 스토어의 참조, 메모 된 값 또는 정적 참조)

함수 호출은 항상 발생하고 jsx를 다시 빌드하기 때문에, jsx의 const 변수를 컴포넌트로 변환하는 것이 더 성능이 좋은 것으로 간주할 수 있습니다.

테이블 렌더 주기의 범위에서 이러한 컴포넌트가 각 셀에 렌더링 될 때, 이 전략은 많은 리렌더링을 절약합니다.

단계 4 - 하얀 토끼 쫓기

(번역자 주 : 영어로 chasing the white rabbit은 불가능, 환상, 꿈을 쫓는 것을 의미합니다. 여기서는 원인을 끝까지 알아낸다라는 의미에 가깝습니다.)

이 제목은 낚시성의 제목이지만, 컴포넌트가 리렌더링 되는 이유를 이해하려고 할 때 느끼는 기분만큼 충분히 재미있었습니다. 다행히 리액트 개발 도구(devtool)에는 프로파일러(Profiler) 도구가 포함되어 있습니다. Record why each component rendered while profiling.(프로파일링 하는 동안 각 컴포넌트가 왜 렌더링 되었는지 기록) 항목이 체크되었는지 설정을 확인해야 합니다.(이 대화 상자를 표시하려면 톱니바퀴 아이콘을 클릭하세요.) 지금부터, 성능이 좋지 않다고 의심되는 시나리오를 프로파일링 하려면 아래 순서를 따르세요.

  1. 파란색 “프로파일링 시작” 버튼을 클릭한다.
  2. 브라우저에서 시나리오를 실행한다.
  3. 완료되면 동일한 버튼을 클릭하여 “프로파일링 중지”를 선택한다.

그러면 프로필에 선택할 수 있는 불꽃 그래프와 순위 차트가 표시됩니다. 보통 노란색 막대를 찾고 “왜 이것이 렌더링 되었는지?”라는 질문에 대한 힌트를 보기 시작합니다. 여기엔 리팩터링을 하거나 컴포넌트가 어떻게 구성되었는지를 다시 생각해 볼 수 있는 몇 가지 유용한 힌트가 있습니다.

  1. 변경 사항이 있는 특정 props를 가리킵니다.
  2. 변경 사항이 있는 훅을 가리킵니다. (슬프게도, 우리가 가진 것은 훅의 번호 뿐입니다.)
  3. 렌더링 된 부모 컴포넌트를 나타냅니다.
  4. 첫 번째 렌더링을 나타냅니다.

이제 추적이 시작됩니다. 저의 테이블 사례는 제가 만든 커스텀 훅에서 반환된 데이터 중에 일부를 메모해야 한다는 것을 깨닫게 해주는 유용한 힌트가 거의 없었습니다.

막대에 커서를 올리면 DOM의 실제 컴포넌트가 표시됩니다. 여기서 특정 컴포넌트가 다시 렌더링 되어야 하는지 여부를 쉽게 분석할 수 있습니다. 이제 렌더링을 해야 할 때는 구현을 자세히 살펴보고, props나 훅 또는 이와 관련된 다른 데이터 소스를 통해 렌더링의 원인이 되는 데이터를 찾아야 합니다.

props가 함수 콜백을 포함한 경우, useCallback()으로 감싸면 렌더링 주기를 줄이는 데 도움이 될 수 있습니다.

답을 찾는 것에 대해서는 마법의 규칙이 존재하지 않습니다. 때때로 React.memo()는 효과가 있지만, 그러기 위해서는 시행착오가 필요합니다. 분명히 하자면, 저는 해결책으로 React.memo()를 홍보하는 것이 아닙니다. 일반적으로 동일한 데이터에서 재생성되는 원시 타입이 아닌 객체를 찾기 위해 컴포넌트 트리를 더 파헤치는 것을 선호합니다. 이 경우에는 스토어를 사용 할 수 없는 경우에는 useMemo()를 사용하는 것을 선호합니다.

막대의 툴팁에서 “훅이 변경되었음”이 표시되면, 컴포넌트로 이동하여 연결된 훅을 주의 깊게 검토해야 합니다.

이 여정을 마무리하자면, 몇 가지 성능 병목 현상은 쉽게 발견하고 해결할 수 있습니다. 일부는 시행착오뿐만 아니라 리액트 컴포넌트 트리에서 심층적인 여정이 필요할 수 있습니다.

성능 프로파일링은 애플리케이션의 속도가 현저히 느려질 때 이루어져야 합니다. 가장 중요한 것은 근본 원인을 찾을 때까지 조사가 수행되어야 하는 것입니다. 때때로 jsx 빌드 방식을 변경해야 할 수도 있습니다. 만약 그렇다면 유지 관리해야 할 추가적인 코드를 추가하기 전에 신중하게 생각하는 것이 좋습니다.

리액트 성능에 대해서 더 자세히 알고 싶으신가요?

리액트 성능을 향상하는 방법에 대한 질문이나 아이디어가 있다면, 저에게 연락 하거나 트윗 해주세요.

이 글은 ReadM 애플리케이션의 사례를 기반으로 작성되었습니다. - ReadM 앱은 초보자나 아이들의 영어 학습을 빠르게 돕는 서비스입니다.

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


Written by@[Ykss]
고이게 두지 않고 흘려보내는 개발자가 되자.

GitHubInstagramLinkedIn