불변 객체와 immer


불변 객체

불변 객체가 뭘까? 간단히 말해서 생성 후에 상태를 바꿀 수 없는 객체를 말한다. 그럼 상태도 바꿀 수 없는 객체를 왜 사용할까? 여러 장점이 있지만 그중 몇 개만 적어보면 상태를 바꿀 수 없기 때문에 동시에 여러 곳에서 사용하더라도 해당 객체를 사용하는 쪽에서는 안전하게 사용할 수 있다. 또 불변 객체에 대해 작업하는 코드는 불변 객체를 사용하는 곳에 영향을 미치는 것을 고려하지 않아도 되고 불변 객체를 복사할 때 객체 전체가 아닌 참조만 복사해 메모리도 아끼고 성능도 향상시킬 수 있는 장점이 있다.

React에서도 이런 불변성(Immutability)을 이용해서 성능을 최적화하는 방식을 사용하고 있다. React의 불변성에 대해 조금 더 자세히 알아보자.

React의 불변성

React의 문서를 읽다 보면 불변성을 강조하는 부분 또는 State를 직접적으로 변경하지 말라는 말을 본 적이 있을 것이다. 왜 그런 걸까?

React는 기본적으로 부모로부터 내려받는 Props나 내부 상태인 State가 변경되었을 때 컴포넌트를 다시 렌더링 하는 리렌더링 과정이 일어난다. React는 이 PropsState의 변경을 불변성을 이용해서 감지한다. 객체의 참조를 복사한다는 점을 이용해 단순히 참조만 비교하는 얕은 비교를 이용해서 변경이 일어났는지 확인한다.

자바스크립트에서 참조 타입의 데이터인 객체의 경우 메모리 힙 영역에 저장이 되어 내부 프로퍼티를 변경해도 같은 참조를 갖고 있다. 따라서 객체의 특정 프로퍼티만 변경하는 작업을 수행하면 React에서는 변경이 일어나지 않았다고 인식하여 리렌더링이 일어나지 않는다. 따라서 리렌더링을 일으키려면 React에 이전의 참조와 다른 참조로 변경되었음을 알려야 한다.

참조를 바꾸는 방법은 여러 가지가 있지만 이 글에서는 간단하게 2가지 방법만 알아보겠다.

먼저 첫 번째 방법은 Object.assign() 메서드를 이용하는 방법이다.

const obj = { a: 1, b: 2 };
const obj2 = Object.assign({}, obj);

console.log(obj === obj2); // false

Object.assign() 은 첫 번째 객체 인자 이후의 모든 객체 인자를 합쳐주는 메서드이다. 첫 번째 인자인 빈 객체({})에 obj의 프로퍼티들을 담아 새로운 객체를 반환하기 때문에 기존 obj와 다른 것을 알 수 있다.

두 번째 방법은 스프레드 연산자를 이용하는 방법이다.

const obj = { a: 1, b: 2 };
const obj2 = { ...obj };

console.log(obj === obj2); // false

마찬가지로 새로운 객체에 obj의 프로퍼티를 담았기 때문에 기존 obj와 다른 참조를 가진 것을 확인할 수 있다.

그럼 이제 이 두 가지 방법을 쓰면 React의 리렌더링을 자유자재로 다룰 수 있을까? 다음은 방금 소개했던 두 방법으로 막을 수 없는 예제를 알아보자.

const obj = { a: 1, b: { c: 2 } };

이제는 obj가 프로퍼티로 객체를 가지고 있다.

const obj2 = Object.assign({}, obj);
const obj3 = { ...obj };

console.log(obj === obj2); // false
console.log(obj === obj3); // false

이 상태에서 두 방법을 사용하면 기존과 같이 동작하는 것처럼 보인다. 하지만 내부의 객체인 b 프로퍼티를 살펴보면 얘기가 다르다.

console.log(obj.b === obj2.b); // true
console.log(obj.b === obj3.b); // true

이유는 간단하다. Object.assign()과 스프레드 연산자 모두 객체의 프로퍼티를 얕은 복사를 수행하기 때문에 내부의 프로퍼티가 참조인 경우 참조를 그대로 복사하여 기존과 같은 참조를 지니게 되기 때문이다.

스프레드 연산자로 간단하게 해결할 수 있다.

const obj2 = { ...obj, b: { ...obj.b } };

console.log(obj.b === obj2.b); // false

obj의 프로퍼티들을 옮긴 후에 b 프로퍼티 또한 스프레드 연산자로 새 객체에 담았다.

하지만, b 프로퍼티 내부에 다시 객체가 있다면? 마찬가지로 다시 스프레드 연산자로 옮겨 담으면 되지만 그 안에 다시 객체가 있다면 매번 코드를 변경해야 하고 깊이가 깊어질수록 헷갈리기도 쉽다.

직접 구현한 복사 함수의 문제점

TOAST UI 캘린더 v2에서는 객체의 내부까지 복사하는 깊은 복사를 위한 deepMergedCopy 메서드를 직접 구현해서 객체의 깊은 복사를 할 수 있었다.

// deepMergedCopy
Object.keys(obj).forEach((prop) => {
  if (isObject(resultObj[prop])) {
    if (Array.isArray(obj[prop])) {
      resultObj[prop] = deepCopyArray(obj[prop]);
    } else if (resultObj.hasOwnProperty(prop)) {
      resultObj[prop] = deepMergedCopy(resultObj[prop], obj[prop]);
    } else {
      resultObj[prop] = deepCopy(obj[prop]);
    }
  } else {
    resultObj[prop] = obj[prop];
  }
});

deepMergedCopy의 내부 동작을 살펴보면 복사하려는 대상이 객체이면 객체의 타입에 따라 배열이나 객체를 복사하는 메서드를 호출하거나 재귀적으로 자신을 한 번 더 호출하는 것을 볼 수 있다.

다음은 팝업을 사용하기 위한 show 메서드의 모습이다.

show: <T extends PopupType>({ type, param }: ShowPopupParams<T>) =>
  set((state) => ({
    popup: deepMergedCopy(state.popup, { type, param }),
  })),

팝업의 상태를 deepMergedCopy를 이용해 복사하여 업데이트하고 있다. 문제는 객체의 프로퍼티가 캘린더에서 사용하는 TZDate의 인스턴스일 때 발생한다. TZDate는 캘린더에서 시간을 다루기 위해 직접 구현한 타입이다. 기존 TZDate의 인스턴스에 새로운 TZDate를 옮겨 담는 과정에서 d 프로퍼티만 옮겨 담은 일반 객체가 되어버렸다. 또한 이런 커스텀 타입이 늘어날수록 deepMergedCopy의 구현도 계속 변경되어야 하고 재귀적으로 모든 프로퍼티에 대해서 함수가 실행되어 성능적으로 손해가 생길 수밖에 없다.

TZDate는 여기서 확인할 수 있다.

screenshot

복사된 객체는 TZDate 인스턴스 내부의 d 프로퍼티만 옮겨 담은 새로운 객체일 뿐이다. 단순히 프로퍼티의 키값들에 대해서 옮기는 작업만 했기 때문에 커스텀 타입의 인스턴스가 아닌 단순 객체가 되어버리고 말았다.

이 문제를 개선하기 위해 불변성을 쉽게 유지하도록 도와주는 immer, 많은 자료구조와 기능을 제공하지만 그만큼 복잡한 API를 가진 immutable-js, 객체에 대해 전체 복사를 수행하는 lodash의 cloneDeep 등의 여러 라이브러리가 존재한다. TOAST UI 캘린더의 특징을 먼저 살펴보면 이미 스토어 내부의 자료구조를 기존 자바스크립트의 네이티브 자료구조를 사용하고 있고 많은 일정 데이터를 관리한다는 특징이 있다. 따라서 변경할 부분의 코드만 작성해서 코드가 간결해진다는 장점과 속도가 빠른 장점을 가진 라이브러리인 immer를 선택했다.

불변성 유지를 도와주는 immer

immer는 기본적으로 produce 함수를 호출하는 방식으로 사용한다. 파라미터의 개수에 따라 크게 2가지 방식으로 사용할 수 있다.

2개의 파라미터를 사용할 때는 1번째 파라미터에는 수정할 상태, 2번째 파라미터에는 이 상태를 어떻게 업데이트할 건지를 정의하는 recipe 함수이다. 변경된 새로운 상태가 반환된다.

import produce from 'immer';

const nextState = produce(baseState, (draft) => {
  draft[1].done = true;
  draft.push({ title: 'Tweet about it' });
});

파라미터를 하나만 사용하면 상태를 어떻게 업데이트할지 결정하는 recipe 함수만을 넘기며 이때는 해당 상태를 업데이트하는 함수가 반환된다.

import produce from 'immer';

const toggleTodo = produce((draft, id) => {
  const todo = draft.find((todo) => todo.id === id);
  todo.done = !todo.done;
});

const baseState = [ ... ];

const nextState = toggleTodo(baseState, 'Immer');

더 자세한 내용은 공식 Docs를 참고하면 된다.

immer의 원리

immer는 어떤 원리를 이용했길래 불변성을 유지해 주는 것일까?

immer의 핵심 원리는 Copy-on-write(이하 기록 중 복사)와 Proxy(이하 프록시)에 있다. 기록 중 복사란 자원을 공유하다가도 수정해야 할 경우가 발생하면 자원의 복사본을 쓰게 하는 개념이다. immer는 프록시 객체를 이용해서 원본 객체인 상태 객체 대신 프록시 객체를 대신 조작(변경) 하는 것이다.

프록시의 원리를 이용한 간단한 코드를 보며 immer가 어떻게 동작하는지 생각해 보자.

// immer의 produce 함수 예시
function produce(base, recipe) {
  const immutable = (obj) => {
    new Proxy(obj, {
      get(target, prop) {
        return typeof target[prop] === 'object'
          ? immutable(target[prop])
          : target[prop];
      },
      set(target, prop, value) {
        target[prop] = value;

        return true;
      }
    });
  });

  const draft = immutable(base); // 원본 객체의 프로퍼티를 순회하며 프로퍼티가 객체인 경우 프록시 객체로 변경한다.

  recipe(draft); // 인자로 받은 recipe 함수에 프록시 객체 전달

  return base;
}

예제 코드에서는 프록시 객체를 수정하면 똑같이 원본 객체도 수정을 하고 있다. 실제 immer에서는 객체를 트리로 생각해서 recipe 함수에서 변경을 가한 서브 트리 부분만 동일한 부분의 원본 객체를 변경하는 방식을 사용하고 있다. 따라서 immer를 이용하면 상태 객체에서 실제로 변경할 부분만 골라서 변경이 되고, 다른 부분은 기존 상태 객체와 동일한 것을 확인할 수 있다.

import { produce } from 'immer';

const obj = { a1: { b1: 1 }, a2: { b2: 2 }, a3: { b3: 3 } };
const newObj = produce(obj, (draft) => {
  draft.a2.b2 = 4;
});

console.log(obj === newObj);       // false
console.log(obj.a1 === newObj.a1); // true
console.log(obj.a2 === newObj.a2); // false
console.log(obj.a3 === newObj.a3); // true

실제로 변경을 가한 a2 프로퍼티 객체만 변경이 된 것을 확인할 수 있으며 해당 프로퍼티를 트리노드로 봤을 때의 상위 노드인 obj 또한 참조가 변경되어 newObj와 다른 것을 확인할 수 있다. 예제 코드는 이 codesandbox에서 확인해 볼 수 있다.

TOAST UI 캘린더에 적용하기

그럼 이제 immer를 캘린더에 적용해 보자.

먼저 직접 구현한 deepMergedCopy를 사용하는 기존의 상태 업데이트 메서드이다.

show: <T extends PopupType>({ type, param }: ShowPopupParams<T>) =>
  set((state) => ({
    popup: deepMergedCopy(state.popup, { type, param }),
  })),

이제 immer를 이용해서 더 직관적이고 간결한 코드로 바꿀 수 있다.

show: <T extends PopupType>({ type, param }: ShowPopupParams<T>) =>
  set(
    produce((state) => {
      state.popup.type = type;
      state.popup.param = param;
    })
  ),

immer의 사용 방법에서 소개한 2번째 방법을 사용해서 상태의 업데이트 함수를 생성하는 방식을 사용했다.

마무리

이 글에서는 불변 객체가 무엇인지 알아보고, 이를 도와주는 immer 라이브러리를 소개했다. React 등에서 불변 객체로 인해서 고통을 받고 있거나 직접 구현한 copy 함수가 한계에 다다라 이를 위한 라이브러리를 찾는데 이 글이 도움이 되었으면 좋겠다.


references

임재언2022.02.17
Back to list