(번역) 리액트 상호 작용 시간 4배 향상하기

Chanhee Kim·2022년 12월 5일
72

FE 글 번역

목록 보기
9/28
post-thumbnail

리액트 도구 및 훅으로 일반적인 성능 문제 해결하기

원문: https://www.causal.app/blog/react-perf

Causal은 복잡한 재무 모델과 데이터가 많은 프로젝트를 구축하고 협업할 수 있도록 해주는 클라우드 기반 스프레드시트입니다. 때로는 Causal 모델이 거대해져 프런트엔드와 백엔드 모두에서 속도를 유지하는데 많은 어려움이 발생합니다.

주요 문제 중 하나는 UI 상호 작용입니다. 아래의 동영상에서 볼 수 있듯 카테고리 탭을 열고 행을 채우려고 하면 UI가 상당히 느려집니다.

영상에서 앱이 느려지는 것을 확인하기 어려울 수 있습니다. 더 쉽게 확인하려면 왼쪽 상단 모서리에 있는 "프레임 속도(Frame Rate)" 팝업을 주시하세요. 빨간색 또는 노란색이면 메인 스레드가 정지된 것입니다. (이것은 Chrome에 내장된 개발자 도구(DevTools) 기능입니다! 개발자 도구 → 도구 더 보기(More Tools) → 렌더링(Rendering) → 프레임 렌더링 통계(Frame Rendering Stats)에서 활성화할 수 있습니다.)

이 글에서 Causal이 이 상호 작용 속도를 거의 4배 향상한 방법을 소개합니다. 거의 모든 최적화에서 단 몇 줄만 변경하면 됩니다.

목차

  1. 상호 작용 프로파일링
  2. AG Grid: 과도한 렌더링 수정
  3. AG Grid: 렌더링 제거
  4. useEffect 실행 빈도 줄이기
  5. 깊은 areEqual
  6. 아직 작업 중인 것
  7. useSelector vs. useStore
  8. 효과가 없었던 것
  9. 결과

상호 작용 프로파일링

상호 작용을 최적화하려면 무엇이 상호 작용을 느리게 만드는지 파악해야 합니다. 이를 확인하기 위한 도구는 Chrome 개발자 도구(Chrome DevTools)입니다.

devtools

Chrome 개발자 도구 열기 → 성능(Performance) 탭의 기록(record) 버튼을 클릭하고, 값을 하나 업데이트 합니다. 잠시 기다린 뒤 기록을 종료합니다.

기록된 내용이 많아 처음 보시는 분들은 헷갈리실 수 있습니다. 하지만 괜찮습니다! 저희가 주목해야할 것은 두 영역입니다.

devtools2

CPU 행은 페이지가 CPU를 사용중일 때를 보여줍니다. Main창에는 어떤 이유로 사용했는지 표시됩니다.

그래서 여기서 무얼 하면 될까요? 기록된 내용을 살펴보며 여러 개의 직사각형들을 클릭하면 다음과 같은 몇 가지 패턴을 찾을 수 있습니다.

그것은 많은 리액트 렌더링이 있다는 것입니다. 구체적으로 performSyncWorkOnRoot라는 이름의 모든 사각형은 (대략) 리액트 렌더링 주기를 시작하는 함수입니다. 그리고 그것들이 많이 있습니다.(정확히 1325개).

devtools3

함수 이름을 검색하려면 ⌘+F(macOS를 사용하지 않는 경우 Ctrl+F)를 누릅니다. 개발자 도구는 처음 발견한 일치 항목을 강조 표시하고, 다음 항목으로 넘어갈 수 있습니다.

대부분의 리액트 렌더링은 Ag Grid로 인해 발생합니다. AG Grid는 Causal이 테이블/그리드를 렌더링하는 데 사용하는 라이브러리입니다.

grid

performSyncWorkOnRoot 사각형을 찾은 다음 위로 스크롤하면 해당 함수가 실행된 원인(즉, 리액트 렌더링이 발생한 원인)을 알 수 있습니다. 대부분의 경우 AG Grid 코드입니다.

ag grid code

일부 코드는 여러번 실행됩니다. 예를 들어 기록의 시작 부분에서 GridApi.refreshServerSideGridApi.refreshCells를 각 행마다 두 번 호출합니다.

이후 일부 코드는 getRows를 계속해서 호출하는 것으로 보입니다.

getRows

잘 됐습니다! 행마다 10번씩 실행되는 코드가 있을 때 한 번만 실행하도록 개선할 수 있고, 10배 향상될 수 있습니다. 그리고 이러한 실행 중 일부가 불필요해지면 모두 제거할 수 있습니다.

그럼 시작해봅시다.

AG Grid: 과도한 렌더링 수정

기록 전반에 걸쳐 GridApi.refreshServerSide로 시작하는 4개의 자바스크립트 청크가 있습니다.

GridApi.refreshServerSide

flame 차트에 따르면 이러한 자바스크립트 청크는 많은 리액트 리렌더링을 유발합니다. 렌더링 되는 컴포넌트를 파악하기 위해 아래로 스크롤 해 컴포넌트 이름을 찾아보겠습니다. (이는 컴포넌트를 렌더링하기 위해 리액트가 이를 호출하거나 클래스 컴포넌트의 경우 .render() 메서드를 호출하기 때문에 가능합니다.)

component

왜 리액트 프로파일러를 사용하지 않았을까요? 렌더링 되는 컴포넌트를 확인하는 또 다른 방법은 리액트 프로파일러(React Profiler)에서 추적(trace)을 기록하는 것입니다. 그러나 리렌더링이 많은 경우 해당 추적을 개발자 도구의 성능 추적과 일치시키기 어렵습니다. 실수로 잘못된 리렌더링을 최적화하게 될 수 있습니다.

기록에서 컴포넌트 이름을 클릭하면 모두 RowContainerComp 컴포넌트임을 알 수 있습니다. 이는 AG Grid의 컴포넌트입니다.

RowContainerComp

RowContainerComp 컴포넌트가 왜 렌더링 되는 걸까요? 이에 답하기 위해 리액트 프로파일러로 넘어가 이 컴포넌트를 찾아보겠습니다.

react profiler

리액트 프로파일러를 열고 "프로파일링하는 동안 각 컴포넌트가 렌더링 된 사유 기록(Record why each component rendered while profiling.)"을 활성화합니다. 그런 다음 "기록(Record)"를 클릭하고 → 변수를 업데이트하고 → 잠시 기다린 후 → 기록을 중지하고 → 기록에서 RowContainerComp를 찾습니다. 이는 완벽하진 않지만(잘못된 렌더링을 선택할 수 있습니다.) 대부분 정확합니다.

이번엔 왜 리액트 프로파일러를 사용했을까요? 이번엔 리액트 프로파일러를 사용하고 있습니다. 컴포넌트 이름을 알았으므로 더 이상 추적을 개발자 도구의 성능 탭과 일치시킬 필요가 없습니다. 컴포넌트가 리렌더링 되는 이유를 알아보는 몇 가지 다른 좋은 방법은 why-did-you-renderuseWhyDidYouUpdat입니다. 퍼스트 파티 코드에는 잘 동작하지만 서드 파티 코드(예: AG Grid 컴포넌트)에는 사용하기 어렵습니다.

보시다시피 RowContainerComp 컴포넌트는 훅 2가 변경되었기 때문에 리렌더링됩니다. 해당 훅을 찾기 위해 프로파일러의 컴포넌트 탭으로 이동해 각 훅에 해당하는 소스 코드와 매칭시킵니다.

component tab

왜 단순히 컴포넌트의 훅을 세지 않을까요? 이것이 가장 확실한 접근 방법이지만, 거의 효과가 없습니다. 리액트는 훅을 셀 때 useContext를 생략하기 때문입니다.(아마 useContext다른 훅과 다르게 구현되었기 때문일 것입니다.) 또한 리액트는 커스텀 훅을 추적하지 않습니다. 대신 내부의 모든 내장 훅(useContext 제외)을 셉니다. 예를 들어 컴포넌트가 Redux의 useSelector를 호출하고 useSelector가 내부에 4개의 리액트 훅을 사용하는 경우 리액트 프로파일러는 "커스텀 훅 1"을 찾아야 할 때 "훅 3 변경됨"을 표시할 수 있습니다.

좋습니다. GridApi.refreshServerSide가 여러 RowContainerComp 컴포넌트를 렌더링하고, 이러한 컴포넌트가 훅 2가 변경되기 때문에 리렌더링된다는 것을 알아냈습니다.

이제 컴포넌트의 소스 코드를 살펴보면 훅 2가 useEffect 내에서 업데이트되었음을 알 수 있습니다.

update hook

그리고 useEffectrowCtrls 또는 domOrder 상태가 변경될 때 트리거됩니다.

deps

이는 최적이 아닙니다! AG Grid는 useEffect 내부에서 상태를 설정하고 있습니다. 즉, 다른 업데이트가 발생한 직후 새 업데이트를 예약합니다. 전체 이벤트 순서는 다음과 같습니다.

event

  1. 컴포넌트가 마운트되면 AG Grid 코어에 여러 함수를 노출합니다.
  2. 이후 AG Grid는 compProxy.setRowCtrls를 호출합니다.
  3. compProxy.setRowCtrlsRowCtrls 상태를 업데이트합니다.
  4. 상태가 변경되었기 때문에 컴포넌트가 리렌더링됩니다.
  5. RowCtrls 상태가 업데이트되었으므로 리액트는 useEffect 콜백을 실행합니다.
  6. useEffect 콜백 내에서 리액트는 setRowCtrlsOrdered를 호출해 훅 2를 업데이트합니다.
  7. 상태가 변경되었기 때문에 컴포넌트는 다시 리렌더링됩니다💥

보시다시피 훅 2를 업데이트하기 위해 컴포넌트를 두 번 리렌더링하고 있습니다! 이는 좋지 않습니다. AG Grid가 5단계가 아닌 2단계에서 바로 setRowCtrlsOrdered를 호출하면 과도한 렌더링을 피할 수 있습니다.

그렇다면 AG Grid가 이렇게 하도록 만들면 어떨까요? yarn patch를 사용해 @ag-grid-community/react 패키지를 패치해 과도한 렌더링을 제거해 보겠습니다.

patch

전체 패치입니다. 저희는 AG Grid에 이를 알렸지만 안타깝게도 그들은 커뮤니티의 PR를 받지 않습니다.

이것만으로도 리렌더링 횟수가 절반으로 줄고, RowContainerCompGridApi.refreshServerSide() 호출 외부에서도 렌더링되기 때문에 실행 시간이 약 15~20% 단축됩니다.

하지만 아직 AG Grid 최적화는 끝나지 않았습니다.

AG Grid: 렌더링 제거하기

RowContainerComp 컴포넌트는 그리드의 여러 부분에 대한 컨테이너입니다.

RowContainerComp

이러한 컴포넌트는 에디터에 입력할 때마다 렌더링됩니다. 이 렌더의 절반을 제거했지만 아직 나머지 절반이 남아 있으며 아마도 불필요할 것입니다. 이러한 컴포넌트에서 시각적으로 변경되는 것이 없기 때문입니다.

이러한 렌더링의 원인은 무엇일까요? 이전 섹션에서 배운 것처럼 RowContainerComps는 AG Grid가 compProxy.setRowCtrls를 호출할 때 리렌더링됩니다. 모든 호출에서 AG Grid는 새 rowCtrls 배열을 전달합니다. 배열이 어떻게 보이는지 확인하기 위해 로그 지점을 추가해 보겠습니다.

logpoint

그리고 콘솔 출력을 확인해봅시다.

array

와우, 모든 로그가 같아 보이지 않나요?

그리고 실제로 이를 조금 디버깅하면 다음을 알 수 있습니다.

  1. 컴포넌트가 전달받는 compProxy.setRowCtrls 배열은 항상 다릅니다.(AG Grid가 전달하기 전 .filter()를 사용해 다시 생성하기 때문입니다.)
  2. 해당 배열의 모든 항목은 리렌더링 간에 동일(===)합니다.

RowContainerComp 내에서 AG Grid는 rowCtrls 배열을 건드리지 않습니다. 해당 항목만 매핑합니다. 따라서 배열 항목이 변경되지 않았는데 왜 RowContainerComp가 리렌더링 해야 할까요?

얕은 비교 검사를 수행해 이 과도한 렌더링을 방지할 수 있습니다.

shallow equality check

이는 시간을 많이 절약해줍니다. 모든 셀 업데이트에서 RowContainerComp 컴포넌트는 1568번(!) 리렌더링되므로 모든 렌더링을 제거하면 총 자바스크립트 비용의 15~30%가 추가로 절약됩니다.

useEffect 실행 빈도 줄이기

다음은 기록의 몇 가지 다른 부분입니다.

performance

이 부분에서는 gridApi.refreshCells()라는 함수를 호출합니다. 이 함수는 4번 호출되며 전체적으로 자바스크립트 비용의 약 5~10%를 차지합니다.

다음은 gridApi.refreshCells()를 호출하는 Causal 코드입니다.

// ⚠️ Hacky:
// autocompleteVariables이 변경되면 강제로 새로고침합니다. 이것은 ShowFormulas 뷰의 issue #XXX에 대한 해결 방법(workaround)입니다.
useEffect(() => {
  setTimeout(() => {
    // Note: 새 작업에서 refreshCells()를 예약하고 있습니다.
    // 이렇게 하면 이전의 모든 AG Grid 업데이트를 전파시킬 수 있습니다.
    gridApi.refreshCells({ force: true });
  }, 0);
}, [gridApi, autocompleteVariables]);

이는 유감스러운 핵입니다. 코드 편집기 자동 완성이 가끔 새 변수를 선택하지 않는 문제에 대한 해결 방법으로 모든 코드베이스에 존재하는 몇 안되는 핵 중 하나입니다.

해결 방법은 새 변수가 추가되거나 제거될 때 마다 실행되어야 합니다. 그러나 현재는 훨씬 더 자주 실행됩니다. autocompleteVariables는 값을 포함해 변수에 대한 여러 다른 정보가 포함된 깊이 중첩된 객체이기 때문입니다.

// autocompleteVariables 객체 (단순화됨)
{
  "variable-id": {
    name: "myVariable",
    type: "Variable",
    dimensions: [...],
    model: ...,
  },
  ...
}

셀에 입력하면 몇 가지 변수가 해당 값을 업데이트합니다. 이로 인해 autocompleteVariables가 몇 번 업데이트되고 매번 gridApi.refreshCells() 호출이 트리거됩니다.

이러한 gridApi.refreshCells() 호출은 필요하지 않습니다. 어떻게 이를 피할 수 있을까요?

자, 새 변수가 추가되거나 제거될때만 gridApi.refreshCells()를 호출할 방법이 필요합니다.

이를 위한 간단한 방법은 useEffect 종속성을 다음과 같이 다시 쓰는 것입니다.

useEffect(() => {
  // ...
}, [gridApi, autocompleteVariables]);

useEffect(() => {
  // ...
}, [gridApi, autocompleteVariables.length]);

대부분의 경우 동작합니다. 그러나 하나의 변수를 추가하고 다른 변수를 동시에 제거하면 이 해결 방법은 동작하지 않습니다.

이에 대한 적절한 방법gridApi.refreshCells()를 변수를 추가하거나 제거하는 코드(예: 해당 작업을 처리하는 Redux saga)로 이동하는 것입니다. 그러나 이것은 단순한 변경이 아닙니다. gridApi
를 사용하는 로직은 단일 컴포넌트에 집중되어 있습니다. Redux 코드에 getApi()를 노출하려면 여러 추상화를 깨트리고/변경해야 합니다. 이 문제를 해결하기 위해 노력하고 있지만 시간이 걸릴 것입니다.

대신 적절한 솔루션을 찾는 동안 조금 더 해킹해 볼까요?😅

useEffect(() => {
  // ...
}, [gridApi, autocompleteVariables]);
useEffect(() => {

useEffect(() => {
  // ...
}, [gridApi, Object.keys(autocompleteVariables).sort().join(',')]);

이렇게 변경하면 useEffectautocompleteVariables 내부의 구체적인 변수 ID에만 의존합니다. 변수 ID를 추가하거나 제거하지 않는 한 useEffect는 더 이상 실행되지 않습니다.(여기서는 변수 ID에 문자 , 가 포함되어 있지 않다고 가정합니다.)

끔찍한가요? 맞습니다. 일시적이고, 제한적이며, 삭제하기 쉽고, 최소한의 기술 부채가 있나요? 또한 맞습니다. 실제 문제를 해결하나요? 확실히 맞습니다. 현실 세계는 트레이드 오프에 대한 것이며 사용자의 삶을 더 좋게 만드는 경우 때로는 최적이 아닌 코드를 작성해야합니다.

이렇게 하면 자바스크립트 실행 시간의 5~10%를 더 절약할 수 있습니다.

깊은 areEqual

셀 업데이트의 성능 추적에는 다음과 같은 부분이 몇 가지 있습니다.

devtools

여기서 일어나는 일은 areEqual이라는 함수가 호출되고 있다는 것입니다. 이 함수는 areEquivalent라는 함수를 호출합니다. 그다음 areEquivalent가 자신을 여러 번 반복해 호출합니다. 이게 뭐처럼 보이시나요?

네, 깊은 동등 비교입니다. 그리고 2020 MacBook Pro에서는 이 작업에 ~90ms가 걸립니다.

areEqual 함수는 AG Grid에서 제공됩니다. 이는 다음과 같이 호출됩니다.

  1. AG Grid에서 컴포넌트가 리렌더링 될 때마다 리액트는 componentDidUpdate()를 호출합니다.
class AgGridReactUi {
  componentDidUpdate(prevProps) {
    this.processPropsChanges(prevProps, this.props);
  }
}
  1. componentDidUpdate()processPropChanges()를 호출합니다.
public processPropsChanges(prevProps: any, nextProps: any) {
  const changes = {};

  this.extractGridPropertyChanges(prevProps, nextProps, changes);
  this.extractDeclarativeColDefChanges(nextProps, changes);

  this.processChanges(changes);
}
  1. processPropChanges()extractGridPropertyChanges() 함수를 호출합니다.
  2. extractGridPropertyChanges()AgGridReactUi에 전달된 모든 prop에 대해 깊은 비교를 수행합니다.
// 단순화된 코드입니다
private extractGridPropertyChanges(prevProps: any, nextProps: any, changes: any) {
  Object.keys(nextProps).forEach(propKey => {
    if (\_.includes(ComponentUtil.ALL_PROPERTIES, propKey)) {
      const changeDetectionStrategy = this.changeDetectionService.getStrategy(this.getStrategyTypeForProp(propKey));

      // ↓ 여기
      if (!changeDetectionStrategy.areEqual(prevProps[propKey], nextProps[propKey])) {
          // ...
      }
    }
  });
}

이러한 props 중 일부가 크게 변화한다면, 깊은 비교는 많은 시간이 걸릴 것입니다. 불행하게도 저희가 현재 마주한 것입니다.

약간의 디버깅과 console.time()을 통해 비용이 큰 props는 context임을 알 수 있습니다. context는 그리드 컴포넌트에 전달해야하는 변수들을 포함하는 객체입니다. 객체가 변경되는 것은 괜찮습니다.

const context: GridContext = useMemo(
  (): GridContext => ({
    allDimensions,
    autocompleteVariables,
    // ↓ 모델의 일부 값이 변경됩니다.(필요시)
    // 이러면 `editorModel`과 `context` 객체가 재구축됩니다.
    editorModel,
    filteredDimensions,
    isReadOnly,
    modelId,
    scenarioId: activeScenario.id,
    showFormulas,
  }),
  [
    activeScenario.id,
    allDimensions,
    autocompleteVariables,
    editorModel,
    filteredDimensions,
    isReadOnly,
    modelId,
    showFormulas,
  ]
);

그러나 거대한 객체에 대해 깊은 비교를 사용하는 것은 좋지 않고 불필요합니다. 객체가 메모이제이션 되어 있으므로 변경 여부를 파악하기 위해 ===를 사용할 수 있습니다. 하지만 어떻게 해야 할까요?

AG Grid는 props에 대한 비교 전략을 지원합니다. 그 중 하나는 === 비교를 구현합니다.

export enum ChangeDetectionStrategyType {
  IdentityCheck = 'IdentityCheck', // ===를 사용해 객체 비교
  DeepValueCheck = 'DeepValueCheck', // 깊은 비교를 사용해 객체 비교
  NoCheck = 'NoCheck', // 항상 다른 객체로 간주
}

그러나 소스 코드에 기반해 rowData prop에 대해서만 전략을 변경할 수 있습니다.

// 이 함수는 주어진 prop을 비교하는 방법을 선택합니다.
getStrategyTypeForProp(propKey) {
  if (propKey === 'rowData') {
    if (this.props.rowDataChangeDetectionStrategy) {
      return this.props.rowDataChangeDetectionStrategy;
    }
    // ...
  }

return ChangeDetectionStrategyType.DeepValueCheck;
}

하지만 AG Grid를 패치하는데 방해가 되는 것은 없습니다. 그렇죠? 위에서 했던 것 처럼 yarn patch를 사용해 getStrategyTypeForProp()함수에 코드를 몇 줄 추가해 보겠습니다.

getStrategyTypeForProp(propKey) {
  // NEW
  if (this.props.changeDetectionStrategies && propKey in this.props.changeDetectionStrategies) {
    return this.props.changeDetectionStrategies[propKey];
  }
  // END OF NEW

  if (propKey === 'rowData') {
    if (this.props.rowDataChangeDetectionStrategy) {
      return this.props.rowDataChangeDetectionStrategy;
    }
  // ...
  }

  // 다른 모든 경우는 기본적으로 DeepValueCheck로 설정됩니다.
  return ChangeDetectionStrategyType.DeepValueCheck;
}

이 변경으로 context prop에 대한 커스텀 비교 전략을 지정할 수 있습니다.

import { ChangeDetectionStrategyType } from '@ag-grid-community/react/lib/shared/changeDetectionService';

// ...

그리고 마찬가지로 자바스크립트 비용의 3~5%를 추가로 절약할 수 있습니다.

아직 작업 중인 것

더 세분화된 업데이트

카테고리 값을 한 번 업데이트하면 데이터 그리드가 네 번 리렌더링됩니다.

devtools

4번의 렌더링 중 3번의 렌더링은 너무 과도합니다. UI는 사용자가 한 번 변경하는 경우 한 번만 업데이트 해야합니다. 그러나 이를 해결하는 것은 어렵습니다.

다음은 데이터 그리드를 렌더링하는 useEffect입니다.

useEffect(() => {
  const pathsToRefresh: { route: RowId[] }[] = [];
  for (const [pathString, oldRows] of rowCache.current.entries()) {
    // pathsToRefresh 객체를 채움 (코드 생략)
  }

  for (const refreshParams of pathsToRefresh) {
    gridApi?.refreshServerSide(refreshParams);
  }
}, [editorModel, gridApi, variableDimensionsLookup, activeScenario, variableGetterRef]);

useEffect가 다시 실행되는 원인을 파악하기 위해 useuseWhyDidYouUpdate를 사용하겠습니다.

console

이는 EditorModelvariableDimensionsLookup 객체가 변경되기 때문에 useEffect가 다시 실행됨을 알려줍니다. 하지만 어떻게 된걸까요? 약간 커스텀된 deepCompare 함수를 사용해 다음을 알 수 있습니다.

console

단일 카테고리 값을 69210에서 5로 업데이트하는 경우 EditorModel이 위와 같이 변경됩니다. 보시다시피 한 번 변경하면 4번의 업데이트가 연속으로 발생합니다. variableDimensionsLookup도 비슷하게 변경됩니다.(표시되지 않음)

하나의 카테고리 업데이트로 인해 4번의 EditorModel 업데이트가 발생합니다. 이러한 업데이트 중 일부는 최적화되지 않은 Redux saga(수정 중)로 인해 발생합니다. 업데이트 4와 같이 모델을 재구축하지만 아무것도 변경하지 않는 다른 부분들은 추가적인 메모이제이션 셀렉터(memoized selectors)나 비교 검사를 추가해 고칠 수 있습니다.

그러나 해결하기 더 어려운 더 깊고 근본적인 문제도 있습니다. 리액트와 Redux를 사용하면 기본적으로 작성하는 코드의 성능이 좋지 않습니다. 리액트와 Redux는 우리가 성공의 구렁텅이에 빠지도록 도와주지 않습니다.

코드를 빠르게 만들려면 컴포넌트(useMemouseCallback 사용)와 Redux 셀렉터(reselect 사용) 모두에서 대부분의 계산을 메모이제이션해야 합니다. 그렇지 않으면 일부 컴포넌트가 불필요하게 리렌더링됩니다. 작은 앱에서는 비용이 적게 들지만 앱이 커져감에 따라 확장성이 매우 떨어지게됩니다.

그리고 이러한 계산 중 일부는 실제로 메모이제이션 할 수 없습니다.

// `variableValues`가 다시 계산되는 것을 방지하는 방법
// `editorModel.variables`가 변경되었지만,=
// `editorModel.variables[...].value`가 그대로 같다면?
// ("깊은 비교"가 답이 될 수 있지만 큰 객체의 경우 비용이 많이 듭니다.)
const variableValues = useMemo(() => {
  return Object.values(editorModel.variables).map((variable) => variable.value);
}, [editorModel.variables]);

이는 위에서 본 useEffect에도 영향을 미칩니다.

useEffect(() => {
  // 데이터 그리드 리렌더링
}, [editorModel /* ... */]);
// ↑ `editorModel`이 변경될 때마다 실행
// 그런데 "`editorModel.variables[...].value`이 변경되었을 때만 다시 실행"하려면 어떻게 표현해야 할까요?

저희는 이러한 과도한 렌더링을 해결하기 위해 노력하고 있습니다.(예: useEffects에서 로직을 이동시킴) 테스트에서 자바스크립트 비용을 추가로 10~30% 절약할 수 있지만 이는 시간 좀 걸릴 것입니다.

사실, 리액트는 재계산을 줄일 수 있는 자동 메모이징 컴파일러도 작업하고 있습니다.

useSelector vs. useStore

Redux 스토어에 데이터가 있고 onChange 콜백에서 해당 데이터에 접근하려면 어떻게 해야 할까요?

가장 간단한 방법은 다음과 같습니다.

const CellWrapper = () => {
  const editorModel = useSelector((state) => state.editorModel);
  const onChange = () => {
    // editorModel을 사용해 작업 수행
  };

  return;
};

Cell을 리렌더링하는데 비용이 많이 들고 불필요하게 리렌더링 하지 않으려면 onChange 콜백을 useCallback으로 래핑할 수 있습니다.

const CellWrapper = () => {
  const editorModel = useSelector((state) => state.editorModel);
  const onChange = useCallback(() => {
    // editorModel을 사용해 작업 수행
  }, [editorModel]);

  return;
};

그러나 editorModel이 자주 변경되면 어떻게 될까요? useCallbackeditorModel이 변경될때마다 onChange를 재생성하고 Cell 매번 리렌더링됩니다.

이런 문제가 없는 대안은 다음과 같습니다.

const CellWrapper = () => {
  const store = useStore();
  const onChange = useCallback(() => {
    const editorModel = store.getState().editorModel;
    // editorModel을 사용해 작업 수행
  }, [store]);

  return;
};

이 접근 방식은 Redux의 useStore() 훅에 의존합니다.

  • useSelector()와 달리 useStore()는 전체 스토어 객체를 반환합니다.
  • 또한 useSelector()와 달리 useStore()는 컴포넌트 렌더링을 트리거할 수 없습니다. 하지만 그럴 필요도 없습니다! 컴포넌트 출력은 EditorModelstate에 의존하지 않습니다. onChange 콜백에만 필요하며 그때까지 editorModel 읽기를 안전하게 지연시킬 수 있습니다.

Causal에는 위와 같이 useCallbackuseSelector를 사용하는 많은 컴포넌트가 있습니다. 그러한 컴포넌트들은 이 최적화 방법의 이점을 누릴 수 있기 때문에 점진적으로 구현하고 있습니다. 저희는 최적화하고 있던 상호 작용에서 즉각적인 개선을 보지 못했지만 이로 인해 다른 몇 군데에서 리렌더링이 감소할 것으로 기대합니다.

향후 useCallback 대신 useEvent로 콜백을 래핑하면 이 문제를 해결하는데 도움이 될 수 있습니다.

효과가 없었던 것

다음 성능 추적의 또 다른 부분입니다.

performance trace

이 부분에서는 서버로부터 바이너리 인코딩된 모델을 수신하고 protobuf를 사용해 구문을 분석합니다. 이것은 독립적인 작업(단일 함수를 호출하고 400~800ms 후에 반환됨)이며 DOM에 접근할 필요가 없습니다. 따라서 Web Worker 사용을 고려할 수 있는 완벽한 후보가 됩니다.

Web 뭐라고요? Web Worker는 별도의 스레드에서 비용이 많이 드는 자바스크립트를 실행하는 방법입니다. 이를 통해 자바스크립트가 실행되는 동안 페이지의 응답성을 유지할 수 있습니다.

함수를 Web Worker로 이동시키는 가장 쉬운 방법은 comlink로 래핑하는 것입니다.

import * as eval_pb from 'causal-common/eval/proto/generated/eval_pb';
// ...
const response = eval_pb.Response.decode(res);

// worker.ts
import _as Comlink from "comlink";
import as eval_pb from "causal-common/eval/proto/generated/eval_pb";

const parseResponse = (res) => eval_pb.Response.decode(res);

Comlink.expose({
  parseResponse
});
// index.ts
import * as Comlink from 'comlink';

const worker = Comlink.wrap(new Worker(new URL('./worker.ts', import.meta.url)));
// ...
const response = await worker.parseResponse(res);

이는 webpack5의 내장 worker 지원에 의존합니다.

이렇게 하고 새 추적을 기록하면 구문 분석 작업이 worker 스레드로 성공적으로 이동되었음을 확인할 수 있습니다.

Worker

그러나 이상하게도 전체 자바스크립트 비용이 증가합니다. 이를 조사해보면 400~1200ms 길이의 자바스크립트 청크가 두 개 더 있음을 알 수 있습니다.

Worker thread

Worker 스레드.

Main thread

Main 스레드.

Web worker로 뭔가 옮기는 것은 공짜가 아닙니다. Web Worker와 데이터를 주고받을 때마다 브라우저는 데이터를 직렬화 및 역직렬화해야 합니다. 일반적으로 이에 대한 비용은 저렴합니다. 그러나 큰 객체의 경우 시간이 다소 걸릴 수 있습니다. 저희의 경우 모델이 크기 때문에 시간이 많이 걸립니다. 직렬화 및 역직렬화는 실제 구문 분석 작업보다 더 오래 걸립니다!

불행히도 이 최적화는 저희에게 효과가 없었습니다. 대신 실험으로 현재 선택적 데이터 로드(selective data loading) 작업을 진행하고 있습니다(전체 모델 대신 보이는 행만 가져오기). 이는 구문 분석 비용을 크게 줄입니다.

selective data loading

선택적 데이터 로드를 사용하면 구분 분석 비용이 500~1500ms에서 1~5ms로 감소합니다.

결과

그렇다면 이러한 최적화가 얼마나 도움이 되었을까요? 100개 이하의 카테고리가 있는 테스트 모델에서 최적화를 구현(및 선택적 데이터 로드 활성화)하면 자바스크립트 비용이 거의 4배 감소합니다 🤯

before

이전 (5회 실행의 중앙값).

after

이후 (5회 실행의 중앙값).

이러한 최적화를 통해 카테고리 셀 업데이트가 훨씬 더 원활해집니다.

여전히 노랑/빨강 청크가 기록에 존재하지만 훨씬 더 작고 파랑색과 얽혀 있습니다!

저희는 몇 가지 정확한 변경으로 상호 작용 응답성을 극적으로 낮출 수 있었지만 아직 갈길이 멉니다. 이 조사에 도움을 주신 3PerfIvan에게 감사드립니다. 100ms 미만의 상호 작용 시간에 도달하는 데 관심이 있다면 Causal 팀에 합류하는 것을 고려해주세요. - email lukas@causal.app!

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

profile
FE 개발을 하고 있어요🌱

0개의 댓글