React 컴포지션 패턴을 활용해 렌더링 성능 개선하기

캔버스의 메인 영역에 사용자가 위치 이동을 자유롭게 할 수 있는 툴바가 있다. 개발 초기에는 별다른 문제가 없었지만 어느 순간부터 툴바를 이동시킬 때마다 약간의 프레임 드랍이 느껴졌다. 현재는 개발 환경에서만 문제가 발생하고 있었지만 툴바에 기능이 더 추가된다면 배포 환경에서도 문제가 발생할 가능성이 있기 때문에 이 문제를 무시하고 넘어갈 수 없었다.

const Toolbar = () => {
  const [pos, setPos] = useState({ x: 20, y: 20 })

  return (
    <div style={{ transform: `translate(${pos.x}, ${pos.y})` }}>
      <DraggableAnchor onDrag={setPos} />
      <MenuButton1 />
      <MenuButton2 />
      <MenuButton3 />
      <MenuButton4 />
    </div>
  )
}

`<Toolbar />`에서 `<DraggableAnchor />`를 끌어서 움직이면 MouseMove 이벤트에서 툴바 컨테이너의 위치를 업데이트한다. 매 이벤트마다 변경된 위치로 툴바를 렌더링하기 위한 Task에 15~20ms 정도가 소요된다. Frames 영역을 봐도 프레임 드랍이 빈번하게 발생하는 것을 알 수 있다. 

아래 그림은 하나의 Task(MouseMove 이벤트)에 대한 React DevTools의 Profiler 결과이다. 툴바의 위치가 달라질 때마다 툴바에 있는 모든 아이템 컴포넌트들이 재렌더링되어 전체 렌더링 시간이 늘어나게 된 것을 볼 수 있다.

방법 1 MouseMove 이벤트에 디바운스를 적용하는 것도 한 방법이 될 수 있겠으나 사용자 입장에서는 드래그가 끊기는 듯한 느낌을 줄 수 있고, 단순히 이벤트 발생 횟수를 줄이는 방법이기 때문에 적절한 방법이 아니다.

 

방법 2 개별 아이템 컴포넌트 하나마다 1~2ms 정도 렌더링 시간이 소요되는데다가 각각은 툴바의 위치 상태를 알 필요가 없기 때문에 `React.memo()`로 메모이제이션하는 방법도 가능할 것이다. 그렇게 되면 개별 컴포넌트에 전달해주는 콜백 함수와 일반적인 값들도 하나하나 `useCallback`, `useMemo`를 통해 감싸줘야 하는데 메모이제이션의 자체적인 오버헤드는 차치하더라도 `useMemo`를 사용하기 위해 부모 컴포넌트에서 props로 전달해주는 값을 또 `useMemo`로 처리해주고, ... 하는 끝나지 않는 메모이제이션과의 사투를 벌여야 할 수도 있다.

 

방법 2가 현재로서는 가장 적합한 방법이겠지만 메모이제이션 훅을 사용하기 전에 컴포넌트 구조를 변경함으로써 렌더링 성능을 개선할 수 있는 방법이 있다.    

 

 

 

1. Composition Pattern

interface MyComponentProps {
  children?: RectNode
}

const MyComponent = ({ children }: MyComponentProps) => <div>{children}</div>

React를 사용해본 사람이라면 `children` 속성을 prop으로 받는 컴포넌트를 작성해본 경험이 있을 것이다. React에서는 이렇게 ReactNode를 prop으로 받아 해당 컴포넌트 트리 내부의 어디든지 위치시킬 수 있다. 이러한 컴포지션 패턴은 성능과 밀접한 관계가 있는 방법은 아니다. 하지만 State가 변경되면 메모이제이션된 자식 컴포넌트를 제외한 모든 자식 컴포넌트가 재렌더링된다는 사실과 연결시켜 보면 prop으로 받은 ReactNode는 더이상 자식 컴포넌트가 아니기 때문에 컴포넌트의 응집성을 높여주는 동시에 성능 상의 이점까지 제공한다. 

// children 대신 임의의 prop으로 ReactNode를 받아 렌더링할 수 있다 
const Counter = ({ left, right }: CounterProps) => {
  const [count, setCount] = useState(0)
  
  return (
    <div>
      {left}
      <Button onClick={setCount} />
      {right}
    </div>
  )
}

`<Counter />`에 props로 전달되는 `children`은 `<Counter />`의 자식 컴포넌트가 아니기 때문에 버튼을 눌러 `<Counter />`가 재렌더링되더라도 `children`으로 전달된 컴포넌트는 재렌더링되지 않는다. 

 

 

 

2. 적용

이제 컴포지션 패턴을 문제 상황에 적용해보자.

const Toolbar = () => {
  const [pos, setPos] = useState({ x: 20, y: 20 })

  return (
    <div style={{ transform: `translate(${pos.x}, ${pos.y})`}}>
      <DraggableAnchor onDrag={setPos} />
      <MenuButton1 />
      <MenuButton2 />
      <MenuButton3 />
      <MenuButton4 />
    </div>
  )
}

드래그 관련 State가 필요한 부분은 컨테이너 요소인 `<div />`와 `<DraggableAnchor />`뿐이다. 이 둘을 합쳐서 `<DraggableContainer />`라는 컴포넌트로 분리해보자. 여기서는 다른 코드는 생략했기 때문에 컴포넌트를 분리하는 게 간단하지만 `children`외에 다른 값이 필요하다면 props로 받아서 처리할 수 있다.

interface DraggableContainerProps {
  children?: RectNode
}

const DraggableContainer = ({ children }: DraggableContainerProps) => {
  const [pos, setPos] = useState({ x: 20, y: 20 })

  return (
    <div style={{ transform: `translate(${pos.x}, ${pos.y})`}}>
      <DraggableAnchor onDrag={setPos} />
      {children}
    </div>
  )
}

그러면 `<Toolbar />` 컴포넌트는 아래와 같이 변경할 수 있다.

const Toolbar = () => {
  /* ... */

  return (
    <DraggableContainer>
      <MenuButton1 />
      <MenuButton2 />
      <MenuButton3 />
      <MenuButton4 />
    </DraggableContainer>
  )
}

메모이제이션 훅을 사용해 명시적으로 최적화를 해주지 않았더라도 `<Toolbar />`의 자식 컴포넌트였던 `<MenuButton />`들이 렌더링 대상에서 제외됐기 때문에 툴바 렌더링 시간이 15~20ms에서 ~1ms로 거의 1/10 수준까지 줄어들었다.

 

 

 

3. 결론

컴포지션 패턴을 활용한 최적화 방법은 컴포넌트의 어떠한 State가 일부 자식 컴포넌트에서만 사용되는 경우에 유용하다. 빈번한 상태 업데이트가 일어나는 컴포넌트에서 `React.memo()`, `useCallback`, `useMemo`를 사용해 최적화를 하기 전에 컴포넌트 구조 변경을 통해 불필요한 렌더링를 줄일 수 있는지 먼저 고려해보자.

 

 

 

 

 

 

 

[참고]

One simple trick to optimize React re-renders

Before You memo()