Please enable JavaScript to view the comments powered by Disqus.

[번역] 자바스크립트 반응성(Reactivity)에 대한 가장 좋은 설명

2018-09-12 · 읽기 13

Gregg Pollack의 The Best Explanation of JavaScript Reactivity 🎆를 번역한 글이다.

SPA 프레임워크에서 컴포넌트의 상태가 변경되면 렌더링이 다시 실행되는 것처럼, 객체의 속성이 변경될 때 어떤 코드가 자동으로 실행되게 한다면 그 어플리케이션은 반응적이라고 할 수 있다. 이를 구현하기 위해서 Object.defineProperty 함수를 사용해 getter, setter 함수를 다시 할당하는 방법을 설명하는 글이다.


많은 프론트엔드 자바스크립트 프레임워크(Angular, React, Vue, etc)는 자신만의 반응성(Reactivity) 엔진을 가지고 있다. 반응성이 무엇인지, 어떻게 동작하는지를 이해함으로써 당신의 개발 기술을 향상할 수 있고 자바스크립트 프레임워크를 보다 효율적으로 사용할 수 있다. 아래의 영상과 비디오를 통해 우리는 Vue 소스 코드에서 확인할 수 있는 것과 같은 종류의 Reactivity를 구현할 것이다.

글을 읽는 대신 비디오를 시청하고 싶다면 이 시리즈의 다음 영상을 보면서 Vue를 만든 Evan You와 함께 반응성과 프록시에 대해 토론하는 내용을 확인하길 바란다.

반응성 시스템

Vue의 반응성 시스템을 처음 보면 마치 마법처럼 느껴질 수 있다. 간단한 Vue 앱을 확인해보자.

code_1.png

code_2.png

Vue가 만약 price 값이 변했다는 사실을 알게 된다면 아래 3가지 일을 할 것이다.

  • 웹페이지의 price 값을 업데이트한다.
  • price * quantity 를 다시 계산하고 페이지를 업데이트한다.
  • totalPriceWithTax 함수를 다시 호출하고 페이지를 업데이트한다.

하지만 잠깐, 당신은 이런 의문을 가지게 될 것이다. Vue는 price가 변경되면 무엇을 업데이트해야 하는지 어떻게 알며, 어떻게 모든 것을 추적하고 있는가?

code_3.jpeg

Vue가 하는 일은 자바스크립트가 일반적으로 동작하는 방식이 아니다.

다시 말해, 우리가 의문을 가지고 있는 문제는 일반적인 프로그램 동작 방식이 아니라는 말이다. 예를 들어 만약 아래의 코드를 실행한다면:

code_4.png

무엇이 출력될 것으로 생각하는가? 보통의 자바스크립트 코드라서 10을 출력할 것이다.

code_5.png

Vue에서는 우리는 pricequantity가 업데이트되면 total도 업데이트되길 바란다. 바로 다음과 같이 출력되길 원한다:

code_6.png

하지만 불행히도 자바스크립트는 반응형이 아니라 절차적(procedural) 언어라서 우리가 기대하는 기능을 바로 제공하지 않는다. total 변수가 반응적이 되도록 만들기 위해서는 자바스크립트를 다른 접근 방식이 필요하다.

⚠️ 문제

total 값을 계산하는 코드를 저장해 둘 필요가 있고, pricequantity 값이 바뀔 때마다 그 코드를 다시 실행해야 한다.

✅ 해결책

우선 우리의 어플리케이션에게 다음의 메시지를 전달할 방법이 필요하다, “내가 실행하려는 코드가 하나 있는데, 이걸 저장해 둬. 나중에 네가 다시 실행할 필요가 생길거야”. 그런 일이 가능하다면 우리는 코드를 한번 실행한 후에, pricequantity 값이 바뀔 때 앞서 저장된 코드를 다시 실행하면 된다.

code_7.png

이는 함수를 기록하는 방법을 통해 구현할 수 있다.

code_8.png

target 변수에 익명 함수를 할당해서 재실행할 코드를 저장하고, record 함수를 호출했다. ES6 화살표 함수를 사용하면 아래처럼 작성할 수 있다.

code_9.png

함수 record는 간단히 아래처럼 정의한다.

code_10.png storage 배열에 target 함수를 저장한다.

target (코드에서는 { total = price * quantity })을 저장했으니 나중에 다시 실행할 수 있다. 저장한 모든 함수를 실행할 replay 함수가 필요할 것이다.

code_11.png

replay 함수를 실행하면 storage 배열에 저장한 모든 익명 함수를 실행한다.

이제 간단히 다음처럼 하면 된다.

code_12.png

간단하지 않은가? 아래의 코드를 통해 전체 흐름을 다시 한 번 살펴보기 바란다.

code_13.png

⚠️ 문제

필요한 만큼 target을 기록해 둘 수 있지만, 앱의 확장성을 위해 보다 강력한 솔루션을 만드는 편이 좋을 것이다. target 목록을 유지하면서 우리가 필요할 때 재실행할 수 있도록 알려주는 클래스 같은 것 말이다.

✅ 해결책: 의존 클래스

이 문제를 해결할 수 있는 방법 하나는 위의 동작을 표준 프로그래밍 옵저버(observer) 패턴을 구현하는 의존 클래스로 캡슐화(encapsulation)하는 것이다.

그래서, target 같은 종속물(dependencies)을 관리하기 위해 자바스크립트 클래스를 만든다면 아래와 같은 형태가 될 것이다.

class Dep { // dependency의 약자
  constructor() {
    this.subscribers = []
    // subscribers는 클래스에 종속되어 있는 target 함수들을 저장하는 배열.
    // notify() 메소드가 호출되면 배열에 저장된 함수들이 실행되어야 한다.
  }

  depend() { // 앞서 작성한 record 함수를 대체한다.
    if (target && !this.subscribers.includes(target)) {
    // target이 subscribers에 저장되어 있지 않아야 한다.
      this.subscribers.push(target)
    }
  }

  notify() { // 앞서 작성한 replay 함수를 대체한다.
    this.subscribers.forEach(sub => sub()) // target 또는 observer를 실행한다.
  }
}

이제 storage 대신 subscribers에 익명 함수를 저장하며, record 함수 대신 depend 함수를 사용한다. 그리고 replay 함수 대신 notify 함수를 사용한다. 이 클래스를 작동시켜 보자.

const dep = new Dep() // 인스턴스 생성

let price = 5
let quantity = 2
let total = 0
let target = () => { target = price * quantity }

dep.depend() // subscribers에 target 함수를 추가한다.
target()     // target 함수를 실행한다.

console.log(total) // => 10 .. 원하는 결과가 맞다.
price = 20
console.log(total) // => 10 .. total이 자동으로 업데이트 되지 않았다.
dep.notify()       // subscribers에 등록된 함수들을 실행한다.
console.log(total) // => 40 .. 제대로 된 결과를 얻었다.

잘 작동한다. 이제 우리의 코드를 재사용할 수 있을 것 같다. 여전히 이상하게 느껴지는 건 target을 등록하고 실행하는 방식이다.

⚠️ 문제

개발이 진행되면 변수마다 Dep 클래스를 생성하게 될 것이다. 그리고 업데이트를 감지하는 역할을 하는 익명 함수(target)를 생성하는 과정을 캡슐화하면 좋을 것 같다. watcher 함수가 이런 역할을 할 수 있다.

그러면 아래의 코드를 실행하는 대신:

code_14.png

아래처럼 실행할 수 있다:

code_15.png

✅ 해결책: Watcher 함수

Watcher 함수 내부에서 몇가지 간단한 작업을 할 수 있다.

function watcher(myFunc) {
  target = myFunc  // 현재 처리하고 있는 target으로 myFunc을 설정한다.
  dep.depend()     // target을 종속물(=종속된 코드)로 추가한다.
  target()         // target을 실행한다.
  target = null    // target을 초기화한다.
}

확인할 수 있는 것처럼 watcher 함수는 myFunc를 인자로 전달받고, 전역 변수인 target으로 할당하고, dep.depend()를 호출해서 subscriber에 추가하고, target을 호출한다. 그리고 target를 초기화한다.

code_16.png

code_17.png

당신은 아마 왜 target을 함수에 직접 전달하지 않고 전역 변수로 설정했는지 궁금할 것이다. 이렇게 구현한 데에는 이유가 있으며, 이 글의 다 읽으면 그 이유가 명확해지고 좋은 방법이었다는 것을 알게 될 것이다.

⚠️ 문제

우리는 하나의 Dep 클래스를 가지고 있다. 하지만 우리가 정말로 원하는 것은 변수마다 그만의 Dep 클래스를 가지는 것이다. 더 진행하기 전에 값들을 객체의 속성으로 옮기도록 하자.

code_18.png

각각의 속성(pricequantity)이 각각 Dep 클래스를 가지고 있다고 잠시동안 가정해보자.

code_19.png

이제는 아래의 코드를 실행한다.

code_20.png

watcher에 전달된 익명 함수(target) 내부에서 data.price 값이 사용되었다. 재실행할 코드에서 data.price 값이 사용되고 있으니 price 속성에 연결된 Dep 클래스의 subscriber 배열(저장소)에 이 익명 함수를 추가(dep.depend())하고 싶다. 익명 함수 내부에서 data.quantity 값도 사용되었으니 data.quantity에 연결된 Dep 클래스에도 같은 작업을 하고 싶다.

code_21.png

만약 data.price를 참조하는 또 다른 익명 함수를 가지고 있다면, 그 함수는 price 속성의 Dep 클래스에만 추가하고 싶다.

code_22.png 추가되는 watcher는 속성들 중 하나에만 적용될 것이다

어느 시점에 price의 subscriber에 연결된 dep.notify()가 호출되어야 할까? 나는 그 순간이 price 속성에 값이 할당되었을 때가 되길 바란다. 모든 것이 구현되면 콘솔에서 아래와 같은 출력을 보고 싶다.

code_23.png

우리는 data 속성(pricequantity)에 원하는 동작을 연결할(hook into) 어떤 방법이 필요하다. 그러면 target 함수 내부에서 어떤 속성이 참조되면 그 속성의 subscriber 배열에 target을 저장할 수 있고, 속성이 변경되면 subscribers 배열에 저장된 함수를 실행하도록 만들 수 있다.

✅ 해결책: Object.defineProperty()

ES5에서 제공하는 Object.defineProperty 함수에 대해 공부할 필요가 있다. 이 함수는 우리에게 객체 속성의 getter와 setter 함수를 정의할 수 있도록 한다. 이 함수를 Dep 클래스에 적용하기 전에 기본적인 사용법을 살펴보자.

let data = { price: 5, quantity: 2 }

Object.defineProperty(data, 'price', { // data의 price 속성에 대해서만 정의한다.
  get() { // get 함수 생성. price의 값을 참조하는 역할
    console.log('I was accessed')
  }

  set(newValue) { // set 함수 생성. price에 새로운 값을 할당하는 역할
    console.log('I was changed')
  }
})

code_24.png

code_25.png

보이는 바와 같이 단지 2줄이 로그에 표시된다. 하지만 실제로는 어떤 값도 가져오거나(get) 할당하지(set) 않는다. 우리가 get, set 함수를 새로 덮어씌웠기 때문이다. 그러니 다시 필요한 코드를 추가하도록 하자. get() 함수는 값을 반환해야 하고, set() 함수는 값을 갱신해야 한다. 그를 위해 intervalValue라는 변수에 현재 price값을 저장한다.

code_26.png internalValue에서 실제 값을 관리한다.

이제 우리의 get, set 함수는 제대로 동작한다. 콘솔에는 무엇이 출력될까?

code_27.png

이렇게 값을 가져오고(get) 할당할 때(set) 알림을 받을 수 있게 되었다. 그리고 재귀(recursion)를 조금 사용하면 모든 속성에서 이 기능이 작동하도록 할 수 있을 것이다.

code_28.png Object.keys 함수를 사용해서 data 객체에 존재하는 모든 속성에 같은 기능을 적용했다

이제 모든 속성이 getter와 setter 함수를 가지게 된 것을 콘솔에서 확인할 수 있다.

code_29.png

🛠 지금까지의 아이디어를 합치기

code_30.png

위의 코드처럼 price의 값을 사용(get)한다면, 위의 코드(target)가 price 값에 의존하고 있다는 사실을 기록해두고 싶다. 그렇게 해 둔다면 price 값의 변경이나 새로운 값의 할당이 저장해둔 코드를 재실행할 수 있는 계기(trigger)가 되도록 만들 수 있다. 우리의 어플리케이션은 어떤 코드가 price에 의존하고 있는지 알고 있기 때문이다. 따라서 getter와 setter를 아래처럼 동작하도록 생각할 수 있다.

Get => 이 익명 함수를 기억해라, 값이 변경될 때 다시 실행할 것이다.

Set => 저장된 익명 함수를 실행해라. 값은 방금 변경되었다.

Dep Class의 경우에는

Price가 사용되었다 (get) => dep.depend() 함수를 호출해서 현재 target을 저장해라.

Price가 할당되었다 (set) => price의 dep.notify()를 해서 모든 targets를 재실행해라.

이 두가지 아이디어를 조합한 최종 코드를 살펴보자.

let data = { price: 5, quantity: 2 }
let target = null

// 앞에서 선언한 Dep 클래스와 동일하다.
class Dep {
  constructor() {
    this.subscribers = []
  }

  depend() {
    if (target && !this.subscribers.includes(target)) {
      // target이 존재하며 subscribers에 포함되어 있지 않을 때만 추가한다.
      this.subscribers.push(target)
    }
  }

  notify() {
    this.subscribers.forEach(sub => sub())
  }
}

// data 객체가 가지고 있는 모든 속성에 적용한다
Object.keys(data).forEach(key => {
  let internalValue = data[key]

  // 각각의 속성은 자기만의 Dep 클래스 인스턴스를 가진다.
  const dep = new Dep()

  // data 객체 속성의 getter, setter를 재설정한다.
  Object.defineProperty(data, key, {
    get() {
      // getter가 호출된 시점의 target을 기억하도록 한다.
      dep.depend()
      return internalValue
    },
    set(newVal) {
      internalValue = newVal
      dep.notify() // 저장된 함수(target)를 재실행
    }
  })
})

// watcher는 더 이상 dep.depend를 호출하지 않는다.
// 왜냐하면 get 메소드 안에서 자동으로 호출되기 때문이다.
function watcher(myFunc) {
  target = myFunc
  target() // 할당된 코드는 무조건 1번은 실행되어야 한다.
  target = null
}

// watcher에 전달된 이 익명함수에서 price와 quantity의 getter 함수가 실행된다.
// watcher가 실행된 시점에서 전역 변수 target에는 watcher의 인자로 전달된 익명 함수가 할당된다
watcher(() => {
  data.total = data.price * data.quantity
})

이제 코드를 실행하면 콘솔에 무엇이 출력되는지 확인해보자.

code_32.png

우리가 하고 싶었던 것과 정확히 일치한다! pricequantity 모두 확실히 반응한다! watcher 함수에 전달한 익명 함수는 price 또는 quantity 값이 변경될 때마다 재실행되고 있다.

Vue 문서에서 가져온 이 그림은 이제 이해가 될 것이다.

code_33.png

Data와 getter, setter를 가지고 있는 아름다운 보라색 동그라미가 보이는가? 아마 익숙하게 보일 것이다! 모든 컴포넌트 인스턴스는 getter를 통해 종속물을 수집하는(붉은 점선) watcher 인스턴스(파란색)를 가지고 있다. setter가 호출되면 Data는 watcher에게 알림(Notify)을 보내서 컴포넌트 re-render로 이어지게 한다. 아래는 내가 주석을 추가한 그림이다.

code_34.png

어떤가, 이쪽이 우리가 지금까지 살펴본 코드를 떠올리게 하면서 더 잘 와닿지 않는가??

사실 Vue의 반응성 엔진은 이것보다 훨씬 더 복잡하다. 하지만 당신은 이제 기초를 알게 되었다.

⏪ 지금까지 학습한 내용

  • 종속물(종속된 코드, dependencies)를 수집하는(depend) Dep class를 어떻게 선언하고, 어떻게 의존 코드를 재실행하는지(notify)
  • 종속물로 추가될 수 있는 실행 코드(target)를 관리하기 위해 watcher를 어떻게 생성하는지.
  • getter와 setter를 생성하기 위해 Object.defineProperty()를 어떻게 사용하는지

이 다음은?

이 글을 통해 배운 내용에 흥미를 느꼈다면 다음 단계는 Reactivity with Proxies다. 그리고 내가 Vue 개발자인 Evan You와 함께 이 주제에 대해서 이야기하는 무료 동영상도 VueMastery.com에서 꼭 확인해 보길 바란다.