본문 바로가기

FrontEnd

Vue3 리렌더링 최적화 with ComputedEager

반응형

Vue3의 즉시 평가를 이용하여 렌더링을 최적화 하는 방법을 알아봅시다.

Vue3의 리렌더링 최적화?

Vue3은 리액트에 비해 최적화가 굉장히 잘 되어 있는 편이라, 렌더링 최적화에 대한 관싱이 별로 없는 것 같습니다.

React는 뷰모델을 새로 만들기 위해 자식 컴포넌트 전체를 리렌더링 하기 때문에 성능 손실이 있는 편이지만,

Vue3의 경우 뷰모델을 참조 형태로 저장하고 있기에 불필요한 자식 컴포넌트의 리렌더링이 불필요하기 때문이죠.

 

그렇다면 Vue3에서 리렌더링이 성능 이슈가 될 경우는 언제일까요?

바로 배치 처리의 이점을 활용하기 위해 리렌더링을 하는 것보다, 값 변경 연산 비용이 저렴할 때입니다.

 

@VueUse의 computedeager 예제를 봅시다.

리렌더링?

해당 컴포넌트에선 다음과 같은 일이 일어납니다.

1. 프롭이 아닌 공유 상태로 ref를 컴포넌트 내로 임포트 합니다.

import { ref } from 'vue-demi'

export const count = ref(0)

2. 버튼을 이용해 해당 ref의 값을 변경합니다.

  <div grid grid-cols-2>
    <div>
      <span text-primary font-bold>Lazy Computed</span>
      <div font-mono>
        <LazyDemo @update="lazyRenders++" />
        <div>
          Renders: {{ lazyRenders }}
        </div>
      </div>
    </div>
    <div>
      <span text-primary font-bold>Eager Computed</span>
      <div font-mono>
        <EagerDemo @update="eagerRenders++" />
        <div>Renders: {{ eagerRenders }}</div>
      </div>
    </div>
  </div>

3. Lazy 컴포넌트는 버튼을 누를 때마다 리렌더링이 일어납니다.

반면 eager 컴포넌트는 isOver5가 true, false 간 전환될 때만 리렌더링이 일어납니다.

어떻게 이럴 수가 있을까요?


Lazy Vs Eager

이를 이해하기 위해선 어떤 것이 리렌더링을 유발하는지 이해해야 합니다.

기본적으로 리렌더링은 Lazy 해야 합니다.

이 이유는 해당 글에 자세히 설명해 두었습니다.

한 문장으로 설몀하자면, 사용자의 인터랙션을 blocking 하지 않기 위해서 입니다.

Vue3은 lazy한 리렌더링을 위해 큐를 사용합니다.

 

이펙트 자체의 실행도 Lazy한 경우가 있습니다.

이를 위해 우리는 callback을 파라미터로 넘기며,

Vue3은 내부적으로 현재 이펙트와 해당 이펙트에 딸린 인스턴스를 추적하고 있습니다.

또한 결과적으로 해당 컴포넌트의 이펙트가 큐를 플러시하명 리렌더링이 발생합니다.

 

그렇다면 우리는

1. 상태 업데이트가 리렌더링을 트리거 하지 않거나,

2. 이펙트 실행이 이펙트 큐를 플러시 하지 않도록 해야 겠네요

그런 방법이 있을까요?

  1. 일단 computed는 반드시 Lazy 하기 때문에 불가능 합니다.
    • 내부적으로 더티 체킹 방법을 사용하며, 조회 여부만 파악하기에 어쩔 수 없습니다.
  2. reactive, ref는 상태 변화에 의한 리렌더링 트리거를 막을 수 있습니다.
    • reactivity/src/basehandler.ts 파일의 hasChanged 부분을 참조하세요
    • 즉, ref.value를 set하는 것만으로 반드시 이펙트가(렌더링 포함) 실행되는 것은 아니란 것입니다.

이를 통해 reactive나 ref와 유사한 무언가를 사용해야 한다는 것을 알았습니다.


리렌더링은 반드시 큐의 flush를 통해 발생합니다.

    // create reactive effect for rendering
    const effect = (instance.effect = new ReactiveEffect(
      componentUpdateFn,
      () => queueJob(update),
      instance.scope // track it in component's effect scope
    ))

그렇다면 큐를 플러시 하지 않는 효과가 뭐가 있을까요?

바로 watchEffect의 flush :'sync'입니다.

 

따라서 computedEager의 구현은 다음과 같이 되어 있습니다.

// ported from https://dev.to/linusborg/vue-when-a-computed-property-can-be-the-wrong-tool-195j
// by @linusborg https://github.com/LinusBorg

import type { Ref, WatchOptionsBase } from 'vue-demi'
import { readonly, shallowRef, watchEffect } from 'vue-demi'

export function computedEager<T>(fn: () => T, options?: WatchOptionsBase): Readonly<Ref<T>> {
  const result = shallowRef()

  watchEffect(() => {
    result.value = fn()
  }, {
    ...options,
    flush: options?.flush ?? 'sync',
  })

  return readonly(result)
}

// alias
export { computedEager as eagerComputed }

해당 코드를 설명하자면 다음과 같습니다.

  1. 큐를 플러시 하지 않는 watchEffect + sync 옵션을 사용합니다.
  2. 이전 값과 이후 값이 변하지 않으면 이펙트를 트리거하지 않는 reactive의 일종인 shallowRef를 사용합니다.
    • 해당 객체도 실제로 값이 바뀌어야만 리렌더링을 트리거 합니다.

위 코드는 useRef를 이용해 리렌더링을 최적화는 리액트의 트릭과 약간 유사하다 볼 수 있습니다.


정리 

computedEager를 사용하면

리렌더링보다 계산 연산이 가벼울 경우 효과를 볼 수 있습니다.

 

computed를 사용하면

지연 평가를 이용해 view model을 업데이트하는 비싼 연산의 실행을

마이크로 태스크 큐를 이용해 렌더링 시점과 최대한 가깝게 배치 처리할 수 있습니다.

중복 제거 및 캐싱 효과, 계산 비용 절감을 누릴 수 있습니다.

 

리렌더링 비용이 비싼지, 상태 업데이트 연산이 비싼지는 퍼포먼스 탭을 통해 벤치마킹 해봐야 알 것입니다.


참고

 

Vue: When a computed property can be the wrong tool

 

Vue: When a computed property can be the wrong tool

Exploring scenarios where computed properties might degrade your Vue component's performance.

dev.to

 

반응형