본문 바로가기
front/vue

[뷰3 공식문서 번역] 반응형 심화 Reactivity in Depth

by juniKang 2022. 5. 3.

(공식문서) https://vuejs.org/guide/extras/reactivity-in-depth.html

 

뷰의 가장 두드러지는 특징중 하나는 잘 감춰진 반응형 시스템이다. 컴포넌트의 상태는 반응형 자바스크립트 객체다. 상태가 수정되면, 화면이 업데이트된다. 반응형이 상태 관리를 직관적이고 간단하게 만들어주지만, 동작원리를 모르면 흐린눈이 될 수 있기 때문에 이해하는게 중요하다. 이 장에서는 뷰의 반응형 시스템이 어떻게 동작하는지 조금 더 깊게 다룰 것이다.

 

반응형이란 무엇일까?

"반응형"은 프로그래밍에서 흔하게 등장하는데 어떤의미일까? 반응형(Reactivity)은 선언적인 방식으로 변경사항을 적용하도록 만드는 프로그래밍 패러다임이다. 엑셀 스프레드 시트는 흔하게 볼수 있는 표준적인 예시이다.

  A B C
0 1    
1 2    
2 3 (A0+A1)    

A2는 A0셀과 A1셀을 더한 값이다. 그래서 A0이나 A1 값을 바꾸면, 당연히 A2의 값도 변경된다.

 

그러나 자바스크립트는 이렇게 동작하지 않는다. 자바스크립트에서 위 예제를 그대로 작성하면 다음과 같을 것이다:

let A0 = 1
let A1 = 2
let A2 = A0 + A1

console.log(A2) // 3

A0 = 2
console.log(A2) // Still 3

A0을 2로 바꾸더라도, A2는 처음 결과값인 3에서 변하지 않는다.

 

그럼 자바스크립트에서는 어떻게 A2가 반응형으로 동작하도록 만들 수 있을까? 먼저, A2를 업데이트 하는 코드로 변경하기 위해 , A = A0 + A1을   update()  함수로 래핑해준다.

let A2

function update() {
  A2 = A0 + A1
}

그리고, 몇가지 용어를 짚고 넘어가자

 

-   update()  함수는 side effect(줄여서 effect)라고 부른다. 프로그램의 상태를 변화시키기 때문이다.

A0 과  A1 은 effect에 대한  dependencies  (의존성)이라고 부른다, effect를 일으키는데 사용되기 때문이다.

- effect는 이 dependencies에 대한  subscriber (구독자) 로 부른다. dependencies의 변화를 구독중이기 때문이다.

 

이제  A0 이나  A1  (dependencies)가 변경되었을 때,  update()  (effect) 를 호출하는 마법같은 함수가 필요하다. 그 함수는 다음과 같다:

whenDepsChange(update)

이 whenDepsChange() 함수가 하는일은 다음과 같다:

  1. 변수가 읽힐 때를 추적한다. 즉, A0 + A1이 평가될 때, A0 와 A1을 읽는다.
  2. 현재 실행중인 effect가 있을 때 변수를 읽으면, effect를 변수의 구독자(subscriber)로 만든다. 즉, update()를 실행중에 A0과 A1을 읽었기 때문에, update()는 호출 이후 A0 와 A1 에 대한 구독자가 되는 것이다. (그리고 A0과 A1은 update()에 대한 dependencies 이다.)
  3. 구독중인 변수의 상태변화를 감지한다. 즉, A0에 새로운 변수가 할당되면, A0을 구독중인 effects들은 re-run(재실행) 될 것이다.

 

뷰에서 반응형이 동작하는 방식

실은 자바스크립트에서, 변수가 읽히거나 쓰일 때를 추적할 수는 없다. 바닐라 자바스크립트에 그런 기능을하는 메커니즘이 없기 때문이다. 그 대신, 객체의 프로퍼티들이 읽히거나 쓰일 때 가로채는 방법을 쓸 수 있다.

 

자바스크립트의 프로퍼티 접근을 가로채는 방법은 두 가지가 있다 : getter/setter 그리고 Proxies 이다. 뷰2에서는 브라우저 호환의 한계로 인해 오직 getter/setter만 사용했다. 하지만 뷰3에서는,getter/setter는 refs로 사용하고, reactive object를 위해서는 Proxies가 쓰인다. 다음은 둘이 어떻게 동작하는지 설명하기 위한 유사코드이다:

function reactive(obj) {
  return new Proxy(obj, {
    get(target, key) {
      track(target, key)
      return target[key]
    },
    set(target, key, value) {
      trigger(target, key)
      target[key] = value
    }
  })
}

function ref(value) {
  const refObject = {
    get value() {
      track(refObject, 'value')
      return value
    },
    set value(newValue) {
      trigger(refObject, 'value')
      value = newValue
    }
  }
  return refObject
}

이 페이지에 있는 코드 단락들은 주요 개념을 설명하기 위해 가능한 간단하게 작성되어서, 많은 상세내용이 누락되고, 특이한 케이스는 무시되었다.

(참고) 다음은 이전 섹션에서 한 번 다뤘었던 reactive object의 제한 사항이다:

 

- reactive object의 프로퍼티를 변수에 할당할 때, 반응형이 사라진다. 왜냐하면, proxy에서 속성을 빼내서 할당했기 때문에, proxy에서 get / set을 할 때 가로채서 트리거(실행)하는 것이 불가능하기 때문이다.

 

- reactive()로 부터 리턴된 프록시는 오리지널 객체처럼 동작하지만, 오리지널 객체와 둘을 (===) 연산자로 비교 했을 때는 false가 나온다.

 

track()  안에서,  현재 실행중인 activeEffect를 확인한다. 실행중인 activeEffect가 있으면, 추적중인 프로퍼티를 구독하는 구독자 객체(Set의 형태로 저장된)를 찾아내고, 그 구독자 객체(Set)에 effect를 추가한다.

// activeEffect는 effect가 실행되기 직전에 할당된다.
// activeEffect에 대해서는 잠시 후에 다룬다.
let activeEffect

function track(target, key) {
  if (activeEffect) {
    const effects = getSubscribersForProperty(target, key)
    effects.add(activeEffect)
  }
}

프로퍼티를 구독중인 effect들은 전역 변수인 WeackMap<target, Map<key, Set<effect>>> 데이터 구조에 저장된다. 프로퍼티의 구독자 객체가 없으면(프로퍼티에 처음 track이 실행되면) 만든다. 이게 getSubscribersForProperty가 하는 일을 요약한 것이다. 디테일한 부분은 스킵하겠다.

 

trigger() 안에서, 우리는 다시 프로퍼티를 구독하는 effects를 검색한다. 이번에는 구독자 effects들을 실행시킨다.

function trigger(target, key) {
  const effects = getSubscribersForProperty(target, key)
  effects.forEach((effect) => effect())
}

이제 whenDepsChange() 함수로 돌아가보자:

function whenDepsChange(update) {
  const effect = () => {
    activeEffect = effect
    update()
    activeEffect = null
  }
  effect()
}

실제 update가 실행되기 전에, update를 current active effect로 설정하도록, update 함수를 effect로 감싼다. 이렇게 감쌈으로써, 변수의 track() 이 실행될 때, update를 activeEffect로 다룰 수 있게 된다. 

 

이제부터, dependencies(의존성)이 자동으로 추적되는 effect를 만들 수 있고, dependency가 변경되면 언제든지 re-run을 수행하게 된다. 이것을 Reactive Effect (반응성) 이라고 부른다.

 


예제 다시보기

위 예제를 재구성해서, 실행순서대로 살펴보자:

const A0 = ref(1)
const A1 = ref(2)
const A2 = ref()

function update() {
  A2.value = A0.value + A1.value
}

whenDepsChange(update)

위 코드에서, A0과 A1을 ref로 선언하고, A0과 A1을 더해서 A2를 변경하는 update()함수를 정의했다. A0과 A1이 변경되면 A2가 자동으로 변경되도록 하기 위해, update를 whenDepsChange 함수의 파라미터로 넘겨서 1회 실행했다. whenDepsChange함수는 다음과 같다:

function whenDepsChange(update) {
  const effect = () => {
    activeEffect = effect
    update()
    activeEffect = null
  }
  effect()
}

whenDepsChnage에서 activeEffect에 effect를 넣고 update()를 실행한다. update()가 끝나면 activeEffect를 비운다. update는 다음과 같다.

A2.value = A0.value + A1.value

A0과 A1의 getter가 실행된다. 다음은 Ref의 getter이다:

function ref(value) {
  const refObejct = {
    get value() {
      track(refObejct, 'value')
      return value
      ...

위 코드에서 호출하는 track()은 다음과 같았다:

function track(target, key) {
  if (activeEffect) {
    const effects = getSubscribersForProperty(target, key)
    effects.add(activeEffect)
  }
 }

위 코드에서 activeEffect 변수에 update가 effect로 담겨있기 때문에, activeEffect=true이고, getSubscribersForProperty로 가져온 구독자 객체(Set구조)에 update를 저장하게 된다. 즉, update가 A0과 A1의 구독자가 된다.

 

 


이제, 아래 코드를 실행하면 어떻게 되는지 살펴보자.

A0.value = 50

 

A0.value에 50을 할당하면 Ref의 setter가 실행된다.

function ref(value) {
  const refObject = {
    get value() {...},
    set value(newValue) {
      trigger(refObject, 'value')
      value = newValue
    }
  }
  return refObejct
}

trigger는 다음과 같다:

function trigger(target, key) {
  const effects = getSubscribersForProperty(target, key)
  effects.forEach((effect) => effect())
}

getSubscribersForProperty로 A0에 대한 구독자 객체(Set)를 불러낸다. 이 Set 안에는 update()함수가 저장되어 있어서, update()를 실행하게 된다. 

A2.value = 50 + 2

 


뷰 반응형 API

뷰는 반응형 effects를 만들 수 있도록 watchEffect()  API를 제공한다. 예제에서본 whenDepsChange()와 하는일이 비슷하다. 예제를 Vue API를 사용해서 재구성해보자:

import { ref, watchEffect } from 'vue'
const A0 = ref(0)
const A1 = ref(1)
const A2 = ref()

watchEffect(() => {
  // A0와 A1을 추적(track)
  A2.value = A0.value + A1.value
})

// effect를 실행(trigger)
A0.value = 2

ref를 상태변화 시키는 reactive effect를 사용하는건 가장 흥미로운 사용사례는 아니다. computed property가 좀더 선언적인 방법이다:

import { ref, computed } from 'vue'

const A0 = ref(0)
cosnt A1 = ref(1)
const A2 = computed(() => A0.value + A1.value)

A0.value = 2

내부적으로 computed 는 reactive effect를 사용해서 무효화와 재연산을 관리한다.

 

그럼 가장 효과적인 reactive effect의 활용법은 무엇일까? DOM을 업데이트하는것이다! 간단한 "reactive rendering"을 다음과같이 구현할 수 있다:

import { ref, watchEffect } from 'vue'

const count = ref(0)
watchEffect(() => {
  document.body.innerHTML = `count is: ${count.value}`
})

// DOM 업데이트
count.value++

실제로 이는 뷰 컴포넌트가 상태 및 DOM을 동기화 하는 방식과 매우 유사하다. 각 컴포넌트 인스턴스는 DOM을 렌더링하고 업데이트하는 반응형 effect를 생성한다. 물론, 뷰 컴포넌트는 innerHTML보다 DOM을 업데이트하기위한 더 효율적인 방법을 사용한다. 이건 Rendering Mechanism에서 다룬다.

 

댓글