불변 객체가 뭘까? 간단히 말해서 생성 후에 상태를 바꿀 수 없는 객체를 말한다. 그럼 상태도 바꿀 수 없는 객체를 왜 사용할까? 여러 장점이 있지만 그중 몇 개만 적어보면 상태를 바꿀 수 없기 때문에 동시에 여러 곳에서 사용하더라도 해당 객체를 사용하는 쪽에서는 안전하게 사용할 수 있다. 또 불변 객체에 대해 작업하는 코드는 불변 객체를 사용하는 곳에 영향을 미치는 것을 고려하지 않아도 되고 불변 객체를 복사할 때 객체 전체가 아닌 참조만 복사해 메모리도 아끼고 성능도 향상시킬 수 있는 장점이 있다.
React에서도 이런 불변성(Immutability)을 이용해서 성능을 최적화하는 방식을 사용하고 있다. React의 불변성에 대해 조금 더 자세히 알아보자.
React의 문서를 읽다 보면 불변성을 강조하는 부분 또는 State를 직접적으로 변경하지 말라는 말을 본 적이 있을 것이다. 왜 그런 걸까?
React는 기본적으로 부모로부터 내려받는 Props
나 내부 상태인 State
가 변경되었을 때 컴포넌트를 다시 렌더링 하는 리렌더링 과정이 일어난다. React는 이 Props
와 State
의 변경을 불변성을 이용해서 감지한다. 객체의 참조를 복사한다는 점을 이용해 단순히 참조만 비교하는 얕은 비교를 이용해서 변경이 일어났는지 확인한다.
자바스크립트에서 참조 타입의 데이터인 객체의 경우 메모리 힙 영역에 저장이 되어 내부 프로퍼티를 변경해도 같은 참조를 갖고 있다. 따라서 객체의 특정 프로퍼티만 변경하는 작업을 수행하면 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는 여기서 확인할 수 있다.
복사된 객체는 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에서 확인해 볼 수 있다.
그럼 이제 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 함수가 한계에 다다라 이를 위한 라이브러리를 찾는데 이 글이 도움이 되었으면 좋겠다.