0.7KB로 Vue와 같은 반응형 시스템 만들기


TOAST UI Grid는 현재 새로운 메이저 업데이트인 버전 4를 릴리스하기 위해 열심히 준비중이다. 버전 4는 BackbonejQuery를 사용해서 작성했던 기존의 코드를 모두 버리고 처음부터 새로 작성하는 대대적인 개편 작업이다. 이번 업데이트를 통해 불필요한 의존성을 줄이면서, 훨씬 더 가볍고 빠른 그리드가 되기를 기대하고 있다.

버전 4는 Backbone과 jQuery 대신, 직접 작성한 반응형 상태 관리자와 Preact를 기반으로 만들어졌다. 버전 4의 첫 번째 알파 배포를 기념하며, 이 글에서는 Backbone과 같은 이벤트 기반의 상태 관리 방식과 Vue, MobX와 같은 반응형 방식이 어떻게 다른지, 왜 반응형 상태 관리자를 직접 만들었는지, 그리고 반응형 시스템을 구현하기 위해 어떤 점들을 고려해야 하는지 등을 실제 소스 코드를 보며 자세히 설명하고자 한다.

반응형(reactivity) 시스템이란?

반응형이라는 단어는 많은 곳에서 범용적으로 사용되기 때문에, 먼저 용어를 명확하게 정의할 필요가 있을 것 같다. 이 글에서 말하는 반응형 시스템이란 Vue나 MobX에서 사용되는 방식을 말하며, 객체의 상태 변경을 자동으로 감지해서 그 객체를 사용하는 다른 객체의 상태를 변경해주거나, 관련된 뷰(View)를 자동으로 갱신해주는 시스템을 말한다. 즉, 기존의 이벤트 기반 방식에서 모든 상태 변경에 대해 이벤트를 발생시키고, 변경을 감지하기 위해서 해당 이벤트에 대한 리스너를 등록하던 일을 자동으로 해 주는 것이다.

사실 이런 방식은 Backbone 이후에 나온 대부분의 프레임워크에서 지원하는 방식이며, AngularJS가 처음 주목받을 때에는 데이터 바인딩이란 용어가 더 흔하게 사용되었다. 하지만 최근 Vue에서 반응형 (reactivity)라는 단어를 공식적으로 사용하면서 이제 Vue를 대표하는 특징적인 용어가 되었으며, 그로 인해 Vue에서 사용하는 특정 구현 방식과 연관되어 사용되는 경우도 많다. Vue에서는 반응형을 구현하기 위해 getter/setter를 사용해서 프록시를 걸어주는 방식을 사용하며, 현재 개발중인 Vue 3에서는 ES2015의 Proxy를 직접 사용해서 구현될 예정이다.

MobX는 Vue의 반응형 시스템과 거의 유사한 방식을 사용하며, 버전 4 이전까지는 getter/setter를, 버전 5부터는 Proxy를 사용해서 구현되어 있기 때문에 브라우저 지원 범위에 따라 적당한 버전을 선택해서 사용할 수 있다. 다만 MobX는 상태 관리만을 위한 라이브러리이므로, UI를 표현하기 위해서는 리액트와 같은 별도의 프레임워크를 같이 사용해야만 한다.

이벤트 방식 vs 반응형 방식

이제 간단한 예제를 살펴보면서 반응형 방식이 기존 이벤트 기반 방식에 비해 어떤 장점을 갖는지를 살펴보겠다. 이벤트 방식은 Backbone의 Model을, 반응형 방식은 MobX의 observable을 사용할 예정이며, 개념을 설명할 용도이므로 API에 익숙하지 않더라도 소스 코드를 읽는데 전혀 문제가 없을 것이다.

게임 플레이어 A,B가 있고, 이 두 플레이어의 점수 합계를 보여주는 보드가 있다고 생각해보자. 각 플레이어의 점수가 변경될 때마다 보드의 합계도 같이 변해야 한다. 이 기능을 BackBone을 사용해서 구현하면 다음과 같은 코드를 작성해야 한다.

import {Model} from 'BackBone';

const playerA = new Model({
  name: 'A',
  score: 10
});

const playerB = new Model({
  name: 'B',
  score: 20
});

const Board = Model.extend({
  initialize(attrs, options) {
    this.playerA = options.playerA;
    this.playerB = options.playerB;
    
    this.listenTo(playerA, 'change:score', this._updateTotalScore);
    this.listenTo(playerB, 'change:score', this._updateTotalScore);

    this._updateTotalScore();
  },
  
  _updateTotalScore() {
    this.set('totalScore', this.playerA.get('score') + this.playerB.get('score'));
  }
});

const board = new Board(null, {playerA, playerB});

console.log(board.get('totalScore')); // 30

playerA.set('score', 20);
console.log(board.get('totalScore')); // 40

playerB.set('score', 30);
console.log(board.get('totalScore')); // 50

Board 클래스를 정의하는 코드에서 playerAplayerBscore 속성이 변경되는 것을 감지하기 위해 각 객체에 대한 change:score 이벤트를 구독하고 있다. 만약 다른 속성이 변경되는 것을 감지하려면 또 다른 이벤트에 대한 리스너를 만들어 주어야 할 것이다.

그럼, 반응형 시스템은 어떨까? MobX로 구현한 코드를 보자.

const {observable} = require('mobx');

const playerA = observable({
  name: 'A',
  score: 10
});

const playerB = observable({
  name: 'B',
  score: 20
});

const board = observable({
  get totalScore() {
    return playerA.score + playerB.score;
  }
})

console.log(board.totalScore); // 30

playerA.score = 20;
console.log(board.totalScore); // 40

playerB.score = 30;
console.log(board.totalScore); // 50

위의 코드에서 board 객체의 totalScore 속성값이 getter 함수로 정의된 것을 볼 수 있다. 이 속성값은 getter 함수 내부에서 참조하는 모든 관찰 가능한(observable) 값들의 변경을 감지하며, 그 중 하나라도 변경되면 getter 함수를 다시 실행하여 자신의 값을 갱신한다. 이런 속성값을 계산된(computed) 혹은 파생된(derived) 값이라 부르며, 이 특징이 반응형 시스템의 핵심이라고 말할 수 있다. MobX의 공식 문서에서도 자신의 설계 원칙을 다음과 같이 한 문장으로 요약하고 있다.

애플리케이션의 상태 중, 파생될(derived) 수 있는 모든 값은 파생되어야 한다. 자동으로.

기존 이벤트 기반 시스템에서는 보통 _updateTotalScore()와 같이 속성값을 변경하는 함수들에 의해 값이 결정되며, 이런 함수들이 여러군데 흩어져 있으면 한 눈에 데이터의 의존 관계를 파악하기가 어렵다. 반면 파생된 속성은 하나의 getter 함수에 의해서만 값이 결정되며, 보통 별도의 setter를 갖지 않기 때문에 getter 함수 내부만 보면 어떤 값들에 영향을 받아서 변경되는지를 한 눈에 파악할 수 있다.

즉, 기존의 이벤트 기반 시스템이 명령적(imperative) 방식이었다면, 반응형 시스템은 데이터의 구조를 선언적(declaritive)으로 정의할 수 있게 해 준다. 예제 코드에도 명확하게 드러나듯이, 선언적으로 정의된 코드는 명령적 방식에 비해 훨씬 간결하고 직관적이며, 데이터 간의 연결 관계를 한 눈에 파악할 수 있다.

반응형 시스템을 직접 구현하는 이유

TOAST UI Grid는 특정 UI 프레임워크에 종속되지 않는 라이브러리이다. 즉, 어떤 UI 프레임워크를 사용하든 문제 없이 사용할 수 있어야 한기 때문에 Vue는 애초에 고려대상에서 제외되었다. 하지만 MobX는 상태 관리만을 담당하기 때문에 Vue에 비해서 훨씬 작은 용량으로도 다양한 기능을 제공하고 있다. 일반 객체 뿐만 아니라 배열, 맵 등 다양한 타입의 객체를 반응형 객체로 만들 수 있고, 다양한 방식의 관찰자 함수를 제공하며, 세부적인 조작을 위한 intercept 및 observe 함수, 비동기 액션 등의 기능도 지원한다. 그러면 별도의 반응형 상태 관리자를 만드는 대신 그냥 MobX를 사용하면 되지 않을까?

사실 일반적인 웹 애플리케이션을 만들 때는 MobX가 훌륭한 선택이 될 수 있다. 하지만 TOAST UI Grid와 같은 UI 라이브러리를 만들 때에는 외부 라이브러리 의존성, 번들 사이즈, 성능 등의 측면에서 좀 더 고려해야할 것이 많다. 다음은 MobX를 사용하지 않기로 한 몇 가지 이유이다.

1. 외부 라이브러리 의존성과 번들 사이즈

TOAST UI Grid 버전 4 업데이트의 중요한 목적 중의 하나는 기존 코드가 갖고 있던 의존성(Backbone, jQuery)을 제거하는 것이었다. 라이브러리를 사용하는 입장에서는 의존성이 늘어날수록 용량이나 성능 면에서 부담이 될 수 밖에 없으므로 가능하면 외부 의존성을 최소화하는 것이 좋다. 그런데 기존 의존성을 덜어내는 대신 새로운 의존성을 추가한다면 그 의미가 퇴색되게 된다.

또한 MobX의 번들 사이즈는 버전 4.9.4를 기준으로 최소화된 파일이 약 56KB(Gzip 압축 시 16KB) 이다. Backbone의 최소화된 파일이 약 25KB(Gzip 압축 시 8KB)인 것을 감안하면 거의 두 배 이상의 용량인 것이다. MobX가 제공하는 모든 기능이 다 필요하다면 얘기가 다르겠지만, MobX의 일부 기능만을 사용하려는 입장에서는 부담스러운 용량일 수 밖에 없다. 즉, MobX를 사용하면 혹 떼려다가 혹을 더 붙인 격이 될 수도 있는 상황이었다.

2. 대용량 데이터에 대한 성능 문제

모든 기술이 그렇듯이, 반응형 시스템도 만병 통치약이 아니다. 특히 그리드와 같이 대용량의 배열을 다루어야 하는 경우 반응형 시스템은 성능 면에서 많은 약점을 갖는다. 다음의 코드를 살펴보자.

import { observable } from 'mobx';

const data = observable({
  rawData: [
    { firstName: 'Michael', lastName: 'Jackson' },
    { firstName: 'Michael', lastName: 'Johnson' }
  ],
  get viewData()  {
    return this.rawData.map(({firstName, lastName}) => ({
      fullName: `${firstName} ${lastName}`
    }));
  }
});

console.log(data.viewData[1].fullName); // Michael Jackson

data.rawData[1].lastName = 'Bolton';
console.log(data.viewData[1].fullName); // Michael Bolton

data.rawData.push({firstName: 'Michael', lastName: 'Jordan'})
console.log(data.viewData[2].fullName); // Michael Jordan

위의 코드에서 data.viewDatadata.rawData가 변경될 때마다 갱신된다. 코드를 보면 data.rawData 배열 내부 객체의 속성값을 변경하거나 새로운 요소를 추가하는 등의 모든 변경에 대해서 data.viewData가 갱신되는 것을 확인할 알 수 있다. 하지만 문제는 모든 변경에 대해 매번 전체 배열을 순회하면서 새로운 배열을 만들어낸다는 점이다. 이 때 배열의 크기가 아주 크다면 성능에 문제를 일으킬 수 있다.

예를 들어 rawData가 10만 건의 데이터를 갖고 있다면, 배열 요소 중 하나의 데이터만 변경되어도 매번 10만 번을 순회하면서 새로운 viewData를 만들어낼 것이다. 이와 같은 문제를 피하기 위해서는 직접 observe 함수를 사용하여 변경된 타입에 따라 각각 다른 처리를 해 주어야 하는데, 이 경우 선언적으로 데이터를 정의할 수 있는 반응형의 장점이 많이 줄어들며, 개별 데이터를 직접 수정하는 것보다 오히려 코드가 더 복잡해질 수도 있다.

또한 배열을 반응형으로 만들 때 MobX는(버전 4 기준) 모든 배열 인덱스에 getter를 사용해서 프록시를 설정하는데, 이 또한 무시하지 못한 성능 저하를 일으킨다. 개발자 PC에서 간단히 테스트해본 결과 10만 건의 숫자 배열에 대해 약 150ms 가까운 시간이 소요되었으며, 속성을 30개 이상 갖고 있는 객체 배열인 경우 내부 객체를 모두 반응형으로 만들면서 전체 10초 이상의 시간이 소요되었다.

TOAST UI Grid는 10만 건의 데이터라도 500ms 내외의 성능을 내는 것이 목표였기 때문에, 사실상 MobX의 observable을 그대로 사용하기가 어려운 상태였다. 즉, 배열의 일부만 반응형으로 만들어야 하거나, 데이터 추가/삭제/변경 여부에 따라 세부적인 조작이 필요한 경우가 많았다. 이처럼 성능에 민감한 애플리케이션인 경우 반응형 시스템을 직접 구현하는 것이 성능 문제를 좀 더 유연하게 대처할 수 있을 거라 판단되었다.

반응형 시스템의 기초: getter/setter 이해하기

그럼 본격적으로 반응형 상태 관리자를 만들어보자. 앞서 직접 작성하게 된 이유를 설명하긴 했지만, 사실 내부 매커니즘이 굉장히 복잡했다면 굳이 시도하지 않았을 것이다. 하지만 의외로 반응형 시스템의 기본 원리는 아주 간단하며, 적은 양의 코드로도 쓸만한 상태 관리자를 만들 수가 있다.

앞서 언급했듯이 반응형 시스템을 만드는 방법은 두 가지가 있다. 하지만 ES2015의 Proxy는 인터넷 익스플로러 등의 구형 브라우저에서 지원하지 않으며, 트랜스파일러나 폴리필을 통해서도 완벽하게 지원할 수 없다. TOAST UI Grid에서는 브라우저 호환성을 위해 getter/setter를 사용했으며, 이 글에서도 getter/setter 방식을 설명한다.

반응형 시스템의 기본 구성 단위는 observable 객체와 observe 함수이다. observe 함수에 인자로 넘긴 관찰자(observer) 함수를 실행할 때, 내부에서 접근하는 모든 observable 객체의 속성들에 해당 관찰자 함수를 리스너로 등록해 두었다가, observable 객체의 속성값이 변경될 때마다 등록된 관찰자 함수들을 재실행해 주는 것이다.

라이브러리마다 이름은 조금씩 다르겠지만, 이 글에서는 Mobx의 API와 유사한 이름인 observable과 observe를 사용한다. 참고로 RxJS의 Observable과는 관계가 없으니 유의하기 바란다.

구현에 앞서 사용법을 간단히 살펴보자.

const player = observable({
  name: 'A',
  score: 10
});

observe(() => {
  console.log(`${player.name} : ${player.score}`);
});
// A : 10

player.name = 'B';  // B : 10
player.score = 20;  // B : 20

예제 코드를 보면 observe 함수에 인자로 넘긴 관찰자 함수가 처음에 한 번 실행된 이후 player 객체의 속성값이 변경될 때마다 계속해서 실행되는 것을 볼 수 있다.

이 기능을 구현하기 위해서는 각 객체의 속성값에 접근할 때마다 현재 observe 함수가 실행 중인지를 먼저 알 수 있어야 한다. 먼저 observe 함수를 구현해보자.

let currentObserver = null;

function observe(fn) {
  currentObserver = fn;
  fn();
  currentObserver = null;
}

observe는 인자로 넘겨받은 함수를 모듈 스코프에 currentObserver라는 이름으로 등록한 다음 실행한다. 이제 currentObserver가 있을 때, getter 호출될 때마다 이 함수를 관찰자 배열에 등록해두고, setter가 호출될 때마다 등록된 관찰자 함수를 모두 실행해주기만 하면 된다. 단, observe 함수 내에서 같은 속성을 여러번 참조할 수도 있으므로, 기존에 등록된 관찰자 함수는 중복해서 등록하지 않도록 한다.

function observable(obj) {
  Object.keys(obj).forEach((key) => {
    const propObservers = [];
    let _value = obj[key];

    Object.defineProperty(obj, key, {
      get() {
        if (currentObserver && !propObservers.includes(currentObserver)) {
          propObservers.push(currentObserver);
        }
        return _value;
      },
      set(value) {
        _value = value;
        propObservers.forEach(observer => observer());
      }
    });
  });

  return obj;
}

코드에서 눈여겨볼 점은 _value 라는 별도의 변수를 함수 스코프에 정의해서 사용하고 있는 점이다. 이는 setter 함수에서 this[key] = value 와 같은 식으로 값을 지정할 경우 setter 함수가 무한 반복되는 것을 방지하기 위함이다. 그 외에는 특별할 것이 없는 20줄 남짓 되는 코드이므로 누구나 한 눈에 observable 함수가 하는 일을 파악할 수 있을 것이다.

지금까지 구현한 코드의 동작 방식을 그림으로 나타내면 다음과 같다.

Observer and Observable

물론 이외에도 추가해야 할 기능들이 많지만 이 코드만으로도 처음에 본 사용법 예제는 문제 없이 실행할 수 있다. observe 함수를 포함해 전체 30줄도 안되는 이 코드가 사실상 반응형 시스템의 핵심인 것이다.

파생된(derived) 속성값 구현하기

이제 파생된 속성값을 구현해보자. 파생된 속성값을 정의하는 방식은 여러가지가 있을 수 있다. MobX의 @computed 와 같이 데코레이터를 사용하거나, Vue 처럼 computed 객체를 별도로 정의할 수도 있다. 이 글에서는 별도의 API 없이 getter를 사용해서 정의된 속성을 파생된 속성값으로 처리하도록 하겠다. 먼저 사용법을 살펴보자.

const board = observable({
  score1: 10,
  score2: 20,
  get totalScore() {
    return this.score1 + this.score2;
  }
});

console.log(board.totalScore); // 30;

board.score1 = 20;
console.log(board.totalScore); // 40;

board.totalScore 값을 getter를 사용해서 정의했으며, board.score1board.score2가 변경될 때마다 자동으로 계산되어 할당된다.

좀 난이도가 올라간 기분이지만, 사실 기본적인 동작 방식은 앞서 작성했던 observe와 동일하다. 내부적으로 observe 함수를 실행해서 해당 속성값을 매번 갱신해주기만 하면 된다. 그러기 위해서는 먼저 객체의 속성을 순회할 때 해당 속성에 getter가 설정되어 있는지를 확인해야 하며, Object.getOwnPropertyDescriptor가 반환하는 객체의 get 속성을 가져오면 된다.

const getter = Object.getOwnPropertyDescriptor(obj, key).get;

이제 정의된 getter가 있는 경우에는 setter를 설정하는 대신 observe 함수를 실행하면서 관찰자 함수 내에서 getter를 실행한 결과값으로 내부 데이터를 변경해 주고, 등록된 관찰자 함수들을 호출해주면 된다. 단, getter 내부에서 this를 사용해서 객체에 접근하므로, call을 사용해서 해당 객체를 컨텍스트로 넘겨주어야 한다.

if (getter) {
  observe(() => {
    _value = getter.call(obj);
    propObservers.forEach(observer => observer());
  });
}

완성된 전체 코드는 다음과 같다.

function observable(obj) {
  Object.keys(obj).forEach((key) => {
    const getter = Object.getOwnPropertyDescriptor(obj, key).get;
    const propObservers = [];
    let _value = getter ? null : obj[key];

    Object.defineProperty(obj, key, {
      configurable: true,
      get() {
        if (currentObserver && !propObservers.includes(currentObserver)) {
          propObservers.push(currentObserver);
        }
        return _value;
      },
    });

    if (getter) {
      observe(() => {
        _value = getter.call(obj);
        propObservers.forEach(observer => observer());
      });
    } else {
      Object.defineProperty(obj, key, {
        set(value) {
          _value = value;
          propObservers.forEach(observer => observer());
        }
      });
    }
  });
  
  return obj;
}

이제 getter와 setter를 한 번에 지정하지 않기 때문에, getter를 등록할 때 configurabletrue로 설정해 주어야 나중에 setter를 추가할 수 있다. 그 외에는 기존 코드와 거의 동일하다. 파생된 속성값에도 사용자가 정의한 getter가 아닌 프록시용 getter가 걸려 있기 때문에 다음과 같이 연쇄적으로 파생된 값도 문제 없이 작동한다.

const board = observable({
  score1: 10,
  score2: 40,
  get totalScore() {
    return this.score1 + this.score2;
  },
  get ratio1() {
    return this.score1 / this.totalScore;
  }
});

console.log(board.ratio1); // 0.2

board.score1 = 60;
console.log(board.ratio1); // 0.6

추가로 고려해야 할 것들

지금까지 작성한 코드만으로도 일반적인 상황에서 사용하는 데에는 전혀 무리가 없지만, 몇가지 고려되지 못한 상황들이 있다. 이 글에서 전체 내용과 코드를 모두 설명하기에는 분량이 너무 많기 때문에, 중요한 내용만 정리해서 살펴보도록 하겠다.

1. observe 초기 실행에서 누락된 코드

observe 함수는 인자로 받은 관찰자 함수를 처음에 실행할 때에만 currentObserver로 설정한다. 즉, 모든 관찰자는 초기 실행 시에만 프록시 getter에 의해 감지된다. 그래서 다음과 같이 관찰자 함수 내부에 분기문이 있으면 처음에 실행되지 않는 코드는 observable 객체의 속성 관찰자 목록에 추가되지 않는다.

const board = observable({
  score1: 10,
  score2: 20
});

observe(() => {
  if (board.score1 === 10) {
    console.log(`score1 : ${board.score1}`);
  } else {
    console.log(`score2 : ${board.score2}`);
  }
});
// score1 : 10

board.score1 = 20; // score2 : 20;

board.score2 = 30; // 반응 없음

observe에 인자로 넘긴 관찰자 함수는 else문 내에서 board.score2에 접근하는데, 이 코드는 초기 실행 시에는 실행되지 않기 때문에 이후에 발생한 board.score2의 변경은 감지할 수 없게 된다. 이 문제를 해결하기 위해서는 관찰자 함수를 실행할 때마다 매번 currentObserver로 설정해 주어야 한다. 그러면 관찰자 중복을 제거하기 위해 매번 배열울 순회하는 것이 성능에 영향을 줄 수 있으므로, 관찰자 목록을 저장할 때 배열 대신 Set을 사용하는 것이 좋다. Set을 사용할 수 없는 환경이라면, 관찰자별로 고유한 아이디를 할당한 후 객체를 사용해서 관리해야만 한다.

또한 매번 관찰자가 있는지를 살펴야 하기 때문에, 연쇄된 파생 속성값을 사용하거나 관찰자 내부에서 객체를 수정해서 연쇄적인 관찰자 호출이 발생하면 currentObserver가 중간에 null로 초기화되는 문제가 발생할 수도 있다. 이 문제를 해결하기 위해서는 배열을 사용해서 currentObserver를 스택처럼 관리해야만 한다.

2. unobserve 함수

observe를 사용해 한 번 등록된 관찰자들은 관찰 대상이 메모리에서 사라지기 전까지는 영원히 실행된다. 이 경우 특정 상태를 관찰하고 있는 UI 컴포넌트들은 화면에서 제거되더라도 계속 메모리에 남아서 불필요한 작업을 반복하게 된다. 그렇기 때문에 observe 함수는 반드시 관찰을 해제할 수 있는 unobserve(혹은 dispose) 함수를 반환해야만 한다.

const board = observable({
  score: 10
});

const unobserve = observe(() => {
  console.log(board.score);
});
// 10

board.score = 20;  // 20

unobserve();
board.score = 30;  // 반응 없음

현재는 propObservers 배열이 함수 스코프 내부에 존재하기 때문에 외부에서 관찰자를 제거할 수 있는 방법이 없다. 또한 각 관찰자 함수별로 자신이 등록된 모든 observable 객체 속성의 관찰자 배열을 알고 있어야 하기 때문에, 이를 구현하기 위해서는 모듈 스코프 내에서 전체 관찰자 정보를 별도로 관리해야 한다.

3. 반응형 배열

반응형 시스템이 대용량 배열을 다룰 때 어떤 문제를 갖는지는 이미 설명한 바 있다. 다양한 방법을 시도해 보았지만, 결론적으로 TOAST UI Grid는 배열에 대해 반응형 시스템을 사용하지 않기로 결정했다. 대부분의 배열은 수십 개 정도의 요소만 갖고 있으며, 이런 배열을 매번 새로 생성하는 것에 대한 비용은 크기 않기 때문이다. 즉, 배열 객체가 반응형이 아니더라도 다음과 같이 배열이 속한 객체의 속성값을 매번 새로운 배열로 변경해 주면 관찰자가 반응하게 된다.

const data = obaservable({
  nums: [1, 2, 3],
  get squareNums() {
    return this.nums.map(num => num * num);
  }
});

console.log(squareNums); // [1, 4, 9]

data.nums = [...nums, 4];
console.log(squareNums); // [1, 4, 9, 16]

하지만 크기가 아주 큰 배열을 매번 새롭게 생성하는 것은 성능에 영향을 끼칠 수 있다. 이를 해결하기 위해 MobX나 Vue에서는 pushpop 처럼 배열을 변경하는 몇 가지 API를 수정해서 내부적으로 관찰자들을 호출해 주는 별도의 반응형 배열 객체를 생성한다. 또한 배열의 요소에 접근하는 것을 확인하기 위해 개별 인덱스 접근자에 모두 프록시 getter를 설정해주기도 한다. 이 방식은 세부적인 조작을 위해 많은 상황을 고려해야 하기 때문에 구현과 사용이 까다로우며, 성능에도 적지 않은 영향을 줄 수 있다.

TOAST UI Grid에서는 이런 문제를 해결하기 위해 배열 자체를 반응형으로 만들기 보다는, notify 함수를 만들어 특정 객체의 속성값에 걸려있는 관찰자 함수들을 강제로 호출하도록 처리하고 있다.

const data = observbable({
  nums: [1, 2, 3],
  get squareNums() {
    return this.nums.map(num => num * num);
  }
});

console.log(squareNums); // [1, 4, 9]

data.nums.push(4);
notify(data, 'num');

console.log(squareNums); // [1, 4, 9, 16]

notify 함수는 변경을 임의로 발생시키기 때문에 자연스러운 반응형이라고 할 순 없다. 하지만 이 함수를 사용할 일은 거의 없으며, 아주 큰 배열 몇 개에만 한정해서 사용하기 때문에 성능을 위한 예외처리로써 충분히 합리적인 선택이라 생각한다.

정리

지금까지 설명한 것이 사실상 TOAST UI Grid 내부에서 사용하고 있는 반응형 상태 관리 시스템의 전부이다. 이 외에도 캐쉬된 데이터를 사용하기 위한 처리나, 반응형이 아닌 순수 객체를 반환해주는 함수 등의 기능이 있지만, 몇 줄의 코드로 간단하게 구현할 수 있는 기능이라 별도의 설명은 생략했다.

추가로 고려해야 할 것들까지 모두 반영된 전체 소스 코드는 타입 스크립트로 작성되었으며 Github repository에서 확인할 수 있다. 타입 정보를 제외하면 100줄 정도의 코드이며, 최소화하면 약 1.3KB, Gzip으로 압축하면 0.7KB도 되지 않는다. 최소화된 파일이 56KB에 달하는 MobX와 비교했을 때 어마어마한 차이이다. 물론 MobX가 제공하는 다양한 기능에 비하면 부족할 수도 있지만, 현재 TOAST UI Grid의 버전 4는 이 작은 코드만으로도 문제 없이 잘 동작하고 있으며, 팀원들 모두 만족하면서 사용하고 있다.

프로그래밍 세계에는 "바퀴를 다시 발명하지 마라"라는 말이 있다. 하지만 이미 있는 바퀴들 중에 내가 원하는 바퀴가 없을 때 합리적인 비용으로 새로운 바퀴를 만들 수 있다면, 나만의 바퀴를 만드는 것이 가장 좋은 선택일 것이다. 개인적으로 새로운 라이브러리나 프레임웍을 찾아보고 사용법을 익히는 것도 물론 중요하지만, 그보다 더 중요한 것은 그 안에 있는 핵심 원리를 이해하는 것이라 생각한다. 핵심 원리를 이해하면 애플리케이션을 개발할 때 기술적으로 훨씬 더 다양한 가능성을 고려할 수 있으며, 어려운 문제를 만났을 때 더 유연하게 대처할 수 있다.

TOAST UI Grid는 버전 4의 정식 배포를 위해 열심히 달려가고 있다. 훨씬 더 가벼워지고 커스터마이징 기능이 강화된 그리드의 새로운 모습을 기대하며, 공식 위클리트위터를 통해 새로운 소식을 기다려주길 바란다!