반응형 시스템 개선하기(feat. TOAST UI Grid)


TOAST UI Grid는 자체적인 반응형 시스템을 구축하여 데이터의 상태 관리를 하고 있다. 데이터가 변경되면 반응형 시스템이 변경을 감지하고 있는 다른 데이터 속성들도 자동으로 갱신해주기 때문에 데이터의 변경 관리를 편리하게 할 수 있었고, 간결하면서도 선언적인 코드를 사용할 수 있어 불필요한 코드도 줄일 수 있었다. 하지만 대용량 데이터를 반응형 데이터로 변경할 때 치명적인 성능 문제가 있었고, 그리드의 초기 렌더링 속도 저하를 유발했다(10만개의 데이터를 기준으로 초기 렌더링에 약 2.5초정도의 시간이 소요되었다). 이 글은 대용량 데이터에 대한 반응형 시스템의 성능 이슈 해결을 위해 그 동안 도입했던 방법들과 고민했던 점들을 실제 소스 코드와 함께 설명하는 내용이다(반응형 시스템의 개념과 동작 원리에 대해서는 이 글에서 자세히 다루지 않는다. 이 부분에 대해 알고 싶다면 0.7KB로 Vue와 같은 반응형 시스템 만들기를 꼭 먼저 읽어보길 바란다).

Lazy Observable

lazy observable반응형 데이터를 실제로 필요한 경우에만 실시간으로 생성해준다는 의미이다. 글의 서두에서 이야기했듯이 대용량 배열 데이터 전체를 반응형 데이터로 생성하는 것은 생각보다 매우 큰 비용이 드는 작업이었고, 이로 인해 그리드의 초기 렌더링 성능이 급격하게 저하되었다. 문제를 해결하기 위해 반응형 데이터 생성에 소요되는 시간을 줄이는 법을 계속 고민하였고, 초기에 전체 데이터를 반응형 데이터로 변경하지 않고 객체 범위를 한정하여 필요할 때마다 변경하는 건 어떨까? 란 생각을 하게 되었다. 그리고 최종적으로 반응형 데이터로 변경할 객체의 범위를 화면에서 보이는 데이터(스크롤 영역내의 데이터) 로 한정하여 lazy observable을 적용해보자는 결론을 얻을 수 있었다.

lazy observable에 대한 이해를 돕기 위해 그리드의 예시 이미지와 함께 설명하겠다.

images

위 이미지는 10만개의 대용량 데이터를 TOAST UI Grid에서 렌더링한 것이다. 이런 경우 모든 데이터가 반응형 데이터로 생성될 필요가 있을까? 실제로 필요한 데이터는 스크롤 영역내에 보이는 한정적인 데이터다. 그렇다면 스크롤 영역내 렌더링에 필요한 데이터 객체들만 반응형 데이터로 만들고 보이지 않는 데이터는 일반적인 객체 상태 그대로 유지하는 것이 훨씬 효율적일 것이다. 이것이 바로 lazy observable의 개념이다(그리드는 스크롤 영역을 기준으로 데이터를 한정했지만, 이 기준은 어플리케이션마다 다를 것이다).

그럼 이제부터 TOAST UI Grid에서는 어떻게 구현하였는지 코드를 살펴보자.

1. 반응형 데이터로 변경할 객체들의 목록을 구한다.

가장 먼저 해야 할 일은 배열 데이터에서 화면에 보여줄 데이터(반응형 데이터로 변경할 객체)의 범위를 구하는 것이다.

function createOriginData(data, rowRange) {
  const [start, end] = rowRange;

  return data.gridRows.slice(start, end).reduce(
    (acc, row, index) => {
      // 이미 반응형 데이터인 경우는 포함시키지 않는다.
      if (!isObservable(row)) {
        acc.rows.push(row);
        acc.targetIndexes.push(start + index);
      }

      return acc;
    },
    {
      rows: [],
      targetIndexes: []
    }
  );
}

TOAST UI Grid에서는 createOriginData 함수를 실행하여 반응형 데이터로 변경할 객체의 범위를 구한다. 화면에 보이는 로우 범위에 대한 정보를 가지고 있는 rowRange를 이용하여 전체 데이터(data.gridRows)를 기준으로 반응형 데이터로 변경되어야 하는 row 객체와 index 정보를 반환한다. 단, 이미 객체가 반응형 데이터인 경우는 새롭게 생성될 필요가 없기때문에 isObservable 함수를 이용하여 처리하였다.

2. 원본 데이터를 반응형 데이터로 변경한다.

원본 데이터는 반응형 데이터가 아니기 때문에 데이터의 변화를 감지하여 자동으로 갱신할 수 없다. 그러므로 이제 변경이 필요한 객체들의 정보를 이용하여 원본 데이터를 반응형 데이터로 변경해야 한다.

function createObservableData({ column, data, viewport }) {
  const originData = createOriginData(data, viewport.rowRange);

  if (!originData.rows.length) {
    return;
  }

  changeToObservableData(column, data, originData);
}

function changeToObservableData(column, data, originData) {
  const { targetIndexes, rows } = originData;
  // 반응형 데이터 생성
  const gridRows = createData(rows, column);

  for (let index = 0, end = gridRows.length; index < end; index += 1) {
    const targetIndex = targetIndexes[index];
    data.gridRows.splice(targetIndex, 1, gridRows[index]);
  }
}

changeToObservableData 함수에서는 originData(createOriginData 함수의 결과)를 이용하여 gridRows라는 반응형 데이터를 만든다. 그리고 기존 데이터(data.gridRows)를 splice 메서드를 이용하여 새로 생성된 반응형 데이터로 변경한다.

3. 렌더링 범위 변화 감지

마지막으로 해야 할 일은 렌더링된 데이터의 범위가 변경될 때, 즉, 스크롤을 이동하는 경우를 감지하여 자동으로 해당 범위의 데이터를 반응형 데이터로 변경해주는 작업을 하는 것이다.

observe(() => createObservableData(store));

createObservableData 함수를 observe 하면, 함수 내부에서 사용하는 rowRangegridRaws 등이 변경될 때마다 자동으로 재실행되어 반응형 데이터가 아닌 데이터를 동적으로 변경시켜 준다. 우리는 이미 observe 함수를 구현했었기 때문에 위 코드처럼 단 1줄의 코드로 이 부분을 자동화할 수 있었다.

완성된 코드를 살펴보자(실제 코드는 여기서 확인해볼 수 있다).

/**
 * 반응형 데이터로 변경할 객체들의 목록을 구한다.
 */
function createOriginData(data, rowRange) {
  const [start, end] = rowRange;

  return data.gridRows.slice(start, end).reduce(
    (acc, row, index) => {
      // 이미 반응형 데이터인 경우는 포함시키지 않는다.
      if (!isObservable(row)) {
        acc.rows.push(row);
        acc.targetIndexes.push(start + index);
      }

      return acc;
    },
    {
      rows: [],
      targetIndexes: []
    }
  );
}

/**
 * 원본 데이터를 반응형 데이터로 변경한다.
 */
function changeToObservableData(column, data, originData) {
  const { targetIndexes, rows } = originData;
  // 반응형 데이터 생성
  const gridRows = createData(rows, column);

  for (let index = 0, end = gridRows.length; index < end; index += 1) {
    const targetIndex = targetIndexes[index];
    data.gridRows.splice(targetIndex, 1, gridRows[index]);
  }
}

export function createObservableData({ column, data, viewport }) {
  const originData = createOriginData(data, viewport.rowRange);

  if (!originData.rows.length) {
    return;
  }

  changeToObservableData(column, data, originData);
}

/**
 * 렌더링 범위 변화를 감지하여 자동으로 반응형 데이터가 아닌 데이터를 동적으로 변경시켜 준다.
 */
observe(() => createObservableData(store));

실제 10만개의 데이터를 기준으로 lazy observable을 적용하기 전과 후 그리드의 초기 렌더링 속도는 2357ms, 99ms 로 무려 23배 정도의 속도 차이가 났다.

대용량 데이터를 다루는 어플리케이션에서 반응형 시스템을 사용하고 있다면, 반응형 데이터 생성의 최적화 방법에 대해 반드시 고려해야한다. 생각보다 반응형 데이터의 생성 비용이 크다는 점을 명심하자.

Batch Processing

batch processing은 대량 반복 작업이나 데이터 변경에 대한 작업을 효율적으로 처리하기 위해 널리 사용되는 방법이다. 일괄 작업 처리를 위한 방법으로 우리는 반응형 시스템에서 한 개의 데이터 업데이트로 인해 연쇄되는 작업들을 하나의 batch의 단위로 묶어 한번에 처리했다.

batch processing을 반응형 시스템에 적용했을 때 어떤 장점이 있는지 간단하게 알아보자.🤔

업데이트 작업의 효율적인 관리

반응형 시스템에서 특정한 데이터가 변경되면 데이터를 감지하고 있는 다른 데이터 속성과 계산된(computed) 속성들도 연쇄적으로 변경된다. 만약 데이터의 업데이트가 화면의 레이아웃 작업이나 리페인팅 작업을 유발한다면, 매 업데이트마다 개별적으로 수행하는 것보다는 하나의 작업 단위로 묶어 불필요한 렌더링을 유발하는 업데이트를 제거하는 방법이 더 효율적일 것이다.

중복된 업데이트 제거

반응형 시스템의 단점이 있다면, 하나의 업데이트로 인해 파생되는 업데이트 중 중복된 작업이 있어도 쉽게 알아차리기 힘들고 개선하기 힘들다는 점이다. 하지만 업데이트 작업을 batch 단위로 묶어 작업하게 된다면, 적어도 batch 단위별로는 중복된 업데이트를 제거할 수 있다(batch 단위의 정의는 도입부에서 설명하였다).

그럼 이제부터는 TOAST UI Grid의 실제 batch processing 구현을 보며 설명해보겠다(여기서 observe 함수의 자세한 구현은 다루지 않을 것이며 batch processing의 구현만 중점적으로 볼 것이다. observe 함수의 구현이 궁금하다면 여기를 참조바란다).

function callObserver(observerId) {
  observerIdStack.push(observerId);
  observerInfoMap[observerId].fn();
  observerIdStack.pop();
}

function run(observerId) {
  callObserver(observerId);
}

function observe(fn) {
  // do something
  run(observerId);
}

위 코드는 기존 observe 함수의 예시이다. observe 함수가 호출되면 먼저 실행 순서를 관리하기 위해 observerId를 내부적으로 관리하는 스택(observerIdStack)에 쌓는다. 그리고 observer 함수를 실행한다. 이 과정에서 스택의 최상단에 있는 observerId와 관련된 또 다른 observer 함수들이 호출되어 스택에 쌓일 것이며, 연관 작업들이 모두 끝나면 스택에서 해당 observerId를 순차적으로 제거한다.

여기에 batch processing을 적용하여 하나의 작업 단위로 묶어보자.

let queue = [];
let observerIdMap = {};

function batchUpdate(observerId) {
  if (!observerIdMap[observerId]) {
    observerIdMap[observerId] = true;
    queue.push(observerId);
  }
}

function run(observerId) {
  batchUpdate(observerId);
}

변경된 코드를 보면 run 함수에서 observer 함수를 바로 실행하는 것이 아니라, batchUpdate 함수를 호출하여 queueobserverId 를 넣고 있다. 즉, queue를 하나의 batch 단위로 볼 수 있다. 그리고 여기서 눈여겨 볼 점은 observerIdMap이란 객체를 이용하여 이미 queue에 들어간 observerId 는 중복해서 넣지 않도록 처리하고 있는 것이다. 이 한 줄의 코드로 중복된 업데이트를 방지할 수 있다.

queue에 담긴 observer 함수들을 실행하는 flush란 함수를 만들어 아래처럼 최종 코드를 완성할 수 있다.

let queue = [];
let observerIdMap = {};
let pending = false;

function batchUpdate(observerId) {
  if (!observerIdMap[observerId]) {
    observerIdMap[observerId] = true;
    queue.push(observerId);
  }
  if (!pending) {
    flush();
  }
}

function callObserver(observerId) {
  observerIdStack.push(observerId);
  observerInfoMap[observerId].fn();
  observerIdStack.pop();
}

function clearQueue() {
  queue = [];
  observerIdMap = {};
  pending = false;
}

function flush() {
  pending = true;

  for (let index = 0; index < queue.length; index += 1) {
    const observerId = queue[index];
    observerIdMap[observerId] = false;
    callObserver(observerId);
  }

  clearQueue();
}

batchUpdate 함수가 실행되면 pending 이란 변수를 보고 flush 함수를 실행한다. 만약 동일한 batch 내에서 이미 flush 함수가 실행 중인 경우(pending 변수의 값이 true)에는 queueobserverId 만 넣어주고 flush 함수는 실행하지 않는다. 이 부분이 중요하다. 파생된 업데이트들이 누락되지 않고 모두 올바르게 실행되려면, pending 상태일 때도 observerIdqueue에 넣어야 한다. 그리고 flush 함수내의 for문에서는 동적으로 증가되는 queue의 길이를 반영하여 observer 함수들을 실행한다.

사실 최근 많이 사용하고 있는 프레임워크 또는 라이브러리(React, Vue, Preact 등)에서 이미 DOM 렌더링과 관련된 최적화를 해주고 있다. 하지만 batch processing 을 추가하면 렌더링 최적화 이전에 불필요한 연산을 방지할 수 있다.

Monkey Patch Array

TOAST UI Grid는 배열 데이터를 반응형 데이터로 생성하지 않고 있다. 앞서 설명했듯이 반응형 데이터의 생성 비용은 생각보다 크고, 배열이 클 경우 이런 작업은 매우 부담되기 때문이다. 기존 TOAST UI Grid에서는 배열 데이터의 갱신이 필요할 때마다 notify 란 함수를 직접 호출하여 배열의 속성과 연관된 observe 함수들을 강제로 호출하도록 처리하고 있었다. 초기에는 notify 함수만으로 문제가 없었으나, 새로운 기능을 추가할 수록 notify 함수를 호출하는 중복 코드가 많아지는 문제가 생겼다. 중복 코드가 많다는 것은 코드의 품질이 떨어진다는 의미이기도 하다. 우리는 코드 품질 향상을 위해 notify 함수 호출 없이 배열 데이터와 관련된 observe 함수들을 자동으로 호출하는 방법에 대해 고민하였다. 그리고 monkey patch 방법을 활용하여 문제를 해결해보고자 했다(monkey patch는 특정 객체의 속성이나 메서드를 동적으로 변경한다는 의미로 자세한 설명은 링크를 참조바란다).

이제 monkey patch을 활용하여 어떻게 문제를 개선하였는지 살펴보자(TOAST UI Grid의 소스 코드와 함께🤓).

1. monkey patch를 적용하기 전 배열 데이터 갱신 코드

TOAST UI Grid는 갱신해야하는 배열 데이터가 많아질 수록 관련된 observe 함수들을 호출하기 위해 notify 함수를 실행한다. 아래 예시 코드를 보자.

function appendRow(store, row) {
  // do something
  rawData.splice(at, 0, rawRow);
  viewData.splice(at, 0, viewRow);
  heights.splice(at, 0, getRowHeight(rawRow, dimension.rowHeight));

  // notify 호출 부분
  notify(data, 'rawData');
  notify(data, 'viewData');
  notify(data, 'filteredRawData');
  notify(data, 'filteredViewData');
  notify(rowCoords, 'heights');
}

위 코드를 보자마자 notify 함수 호출이 반복된다는 것을 느낄 것이다. 성능에는 전혀 문제가 없지만 코드가 중복되고 있으며, appendRow 같이 여러 배열 데이터의 변경이 있는 함수가 많아질 수록 중복 코드는 더 추가될 것이다.

2. 배열 메서드를 랩핑하는 monkey patch 코드

monkey patch의 대상은 배열 데이터이며, 목적은 배열 데이터의 갱신을 자동으로 감지하고 관련된 observe 함수를 실행되도록 하는 것이다. 그러므로 배열 데이터의 갱신을 발생시키는 특정 메서드들(splice, push, pop 등)만 랩핑 대상이 되며, 이외에 단순 조회나 새로운 배열 객체를 반환하는 메서드들에 대해서는 작업을 할 필요가 없다.

갱신을 일으키는 메서드들을 어떻게 랩핑했는지 코드와 함께 살펴보자.

const methods = ['splice', 'push', 'pop', 'shift', 'unshift', 'sort'];

export function patchArrayMethods(arr, obj, key) {
  methods.forEach(method => {
    const patchedMethods = Array.prototype[method];
    // 기존 메서드들을 patch 함수로 monkey patch 한다.
    arr[method] = function patch(...args) {
      const result = patchedMethods.apply(this, args);
      notify(obj, key);
      return result;
    };
  });

  return arr;
}

patchArrayMethods 함수 내부의 반복문에는 배열의 데이터를 갱신한 후 자동으로 notify 함수를 호출하는 patch 함수가 선언되어 있다. 그리고 미리 정의한 methods 변수를 이용하여 배열(arr)의 속성을 patch 함수로 monkey patch 하는 작업을 하고 있다. Array.prototype을 직접 조작하여 변경해도 되지만 다른 어플리케이션에 의도치 않은 버그와 사이드 이펙트를 발생시킬 수 있기 때문에 이 방법보다는 각각의 배열 객체를 대상으로 monkey patch 하는 방법을 선택했다.

3. 배열 메서드를 랩핑하는 monkey patch 코드

이제 monkey patch을 적용한 코드를 보며 어떤 변화가 있었는지 확인해보자.

function setValue(storage, resultObj, observerIdSet, key, value) {
  if (storage[key] !== value) {
    if (Array.isArray(value)) {
      patchArrayMethods(value, resultObj, key);
    }
    storage[key] = value;
    Object.keys(observerIdSet).forEach(observerId => {
      run(observerId);
    });
  }
}

export function observable(obj) {
  // do something

  Object.keys(obj).forEach(key => {
    // do something
    if (isFunction(getter)) {
      observe(() => {
        const value = getter.call(resultObj);
        setValue(storage, resultObj, observerIdSet, key, value);
      });
    } else {
      storage[key] = obj[key];

      if (Array.isArray(storage[key])) {
        patchArrayMethods(storage[key], resultObj, key);
      }

      Object.defineProperty(resultObj, key, {
        set(value) {
          setValue(storage, resultObj, observerIdSet, key, value);
        }
      });
    }
  });

  return resultObj;
}

function appendRow(store, row) {
  // do something
  rawData.splice(at, 0, rawRow);
  viewData.splice(at, 0, viewRow);
  heights.splice(at, 0, getRowHeight(rawRow, dimension.rowHeight));
}

위 코드처럼 observable 함수에서 반응형 데이터의 속성 타입이 배열인 경우 patchArrayMethods를 호출하여 배열의 메서드를 랩핑한다면, 해당 배열 데이터가 갱신될 때 자동으로 연관된 observe 함수가 호출된다(observable 함수의 전체 소스코드는 여기서 확인할 수 있다). 강제적으로 observe 함수를 실행하기 위해 notify 함수를 호출할 필요가 없어진 것이다. appendRow 함수를 보면 중복되는 notify 함수가 모두 제거되고 훨씬 깔끔하게 수정된 것을 볼 수 있다.

TOAST UI Grid에서 반응형 시스템에 monkey patch를 적용한 이유와 방법을 간단하게 설명하였고, 코드와 함께 적용 과정을 보았다. 중복된 코드를 제거하여 훨씬 깔끔한 코드로 리팩토링할 수 있었고, 코드의 품질도 향상되었다.

📝 정리

lazy observable 에서 설명했던 코드를 기준으로 앞의 내용들을 정리해보자.

function changeToObservableData(column, data, originData) {
  const { targetIndexes, rows } = originData;
  // 반응형 데이터 생성
  const gridRows = createData(rows, column);

  for (let index = 0, end = gridRows.length; index < end; index += 1) {
    const targetIndex = targetIndexes[index];
    data.gridRows.splice(targetIndex, 1, gridRows[index]);
  }
}

export function createObservableData({ column, data, viewport }) {
  const originData = createOriginData(data, viewport.rowRange);

  if (!originData.rows.length) {
    return;
  }

  changeToObservableData(column, data, originData);
}

observe(() => createObservableData(store));

다시 한 번 간략하게 설명하자면, 위의 코드는 스크롤이 이동할 때, 화면 렌더링에 필요한 데이터를 반응형 데이터로 갱신해주는 코드이다.

위 코드에서 반복문 코드를 자세히 보자.

for (let index = 0, end = gridRows.length; index < end; index += 1) {
  const targetIndex = targetIndexes[index];
  data.gridRows.splice(targetIndex, 1, gridRows[index]);
}

그리드 내에서 배열 데이터는 반응형 데이터로 만들지 않았기 때문에 splice, push와 같은 함수로 데이터를 갱신하여도 notify와 같은 함수를 호출하지 않으면 업데이트가 발생하지 않는다. 눈치가 빠른 사람들은 여기서 어떤 이야기가 나올지 이미 예상하였을 것이다. Monkey Patching Array에서 봤듯이 배열 메서드를 랩핑하였기 때문에 자동으로 업데이트가 발생된다.

그럼 여기서 한가지 의문점이 생길 수 있다. for문 안에서 splice 메서드 호출로 인해 매번 배열 데이터가 갱신되고 파생된 업데이트가 실행되면 문제가 있지 않을까? 걱정하지 않아도 된다. batch processing을 구현했기 때문이다.

observe(() => createObservableData(store));

반응형 데이터를 생성(createObservableData)해주는 함수가 observe 함수 내에서 호출된다. 즉, for문내에서 파생되는 업데이트들은 모두 하나의 batch 작업 단위로 묶이게 되므로, 반복문마다 매번 즉각적으로 갱신이 되거나, 중복된 업데이트들은 발생하지 않는다.

앞에서는 각각의 방법들의 내용과 어떤 장점이 있는지 설명하였다면, 이번에는 전체적인 흐름이 어떻게 되는지 간단하게 살펴보았다. 이 외에도 설명에서 생략된 코드들을 보고 싶다면 github에서 확인할 수 있다.

❗주의할 점

반응형 시스템은 매우 편리하다. observe 함수를 이용해 자동으로 데이터와 뷰(View) 갱신을 해준다. 그러나 이런 편리함에 익숙해져 매번 갱신될 필요가 없는 데이터까지 반응형 데이터로 등록하여 개발을 하는 경우가 있다.

const obj = observable({
  start: 0,
  end: 0,
  get expensiveCalculation() {
    let result = this.start + this.end;
    // ... do expensive calculation
    return result;
  }
});

obj.start = 1;
obj.end = 1;

위의 예제에서 expensiveCalculation 라는 매우 복잡한 연산을 수행하는 계산된(computed) 속성이 반응형 데이터로 생성된다고 가정해보자. 이 속성은 start 또는 end 속성이 변경될 때마다 복잡한 연산을 동반하며, 자동으로 갱신되어야 한다. 이런 경우 expensiveCalculation이 매번 갱신될 만큼 자주 쓰이는 속성인지 생각해봐야한다. 그렇지 않다면 expensiveCalculation이 필요한 경우에만 별도의 함수로 분리하여 호출하는 것이 훨씬 효율적일 수 있다.

사실 당연한 내용이지만, 개발 초기에 생각보다 이 부분을 놓치고 반응형 시스템의 편리함에 기대어 시작하는 경우가 있다. 필자도 그러한 경험이 있었고 나중에서야 이 부분을 변경했던 기억이 있다.

대가없는 편리함은 없다. 항상 이 부분을 염두하고 개발하자.

🎀 맺음말

TOAST UI Grid처럼 대용량 데이터를 다루는 어플리케이션에서 성능은 매우 중요한 부분이다. 성능에 영향을 미치는 요인과 개선 방안은 여러가지가 있겠지만, 우리는 반응형 시스템의 성능 개선을 중점적으로 보았다. 그리고 최적화를 위한 세 가지 방법들을 실제 TOAST UI Grid의 적용 코드와 함께 살펴보았다. 이 방법들은 그리드에만 국한된 방법들이 아니며, 반응형 시스템 개선을 위해 어디든 적용 가능한 방법들이다. 이 글이 반응형 시스템 개선을 위해 고민하고 있거나 혹은 반응형 시스템에 대해 좀 더 깊게 이해하고 싶은 사람들에게 도움이 되었으면 한다.

TOAST UI Grid는 v4 메이저 배포 이후에도, 다양한 기능 추가와 성능 이슈 해결을 위한 많은 변화가 있었다. 앞으로도 TOAST UI Grid는 많은 사람들이 유용하게 사용할 수 있도록 더 나아갈 것이니 관심 가져주길 바란다. 만약 문의 사항이나 바라는 점이 있다면 github issue에 남겨주길 바란다.

그리고 마지막으로 좋은 어플리케이션을 만들기 위해 항상 같이 고민하며 도움을 준 팀원들에게 감사함을 표한다.😎