Redux는 각각의 state를 철저하게 통제하고, state를 변경할 때 원본값을 바로 수정하지 않고 값을 복제한 다음 수정하여 불변성을 유지한다.
이는 Redux의 불변성 유지를 위해 새로운 state를 만들기 앞서 기존에 존재하던 state를 복제해야하기 때문이다.
이전 글에서 Redux를 설명하면서 '불변성을 유지'해야 한다는 말은 몇 차례 언급한 바 있다. React의 불변성 유지는 React-Redux 또는 다른 상태 관리 라이브러리를 사용할 때 중요한 개념 중 하나로, 다음과 같은 이유로 인해 불변성을 유지하는 것은 상태 관리 라이브러리를 사용하면서 반드시 필요한 과정에 포함된다.
React는 Virtual DOM을 사용하여 UI 업데이트를 관리하며, 상태가 변경될 때마다 Virtual DOM을 비교하여 필요한 최소한의 변경만 실제 DOM에 반영한다. 이 때 이전 상태와 새 상태의 차이를 빠르게 계산하기 위해 불변성을 활용하는데, 이는 곧 React 컴포넌트의 성능 최적화에 도움이 된다.
상태가 변경될 때마다 새로운 객체가 생성되므로, 이전 상태와 현재 상태를 비교하여 문제를 파악하거나 버그를 찾기가 더 간단해진다.
불변성을 유지하면서 과거 상태가 보존되고, 상태 변화의 이력을 추적할 수 있어 문제를 해결하는데 큰 도움이 된다. (Time Travel)
설명은 거창하지만 불변성을 유지하는 방법은 아주 간단한다. ... 스프레드 연산자를 사용해주기만 하면 된다. 스프레드 연산자는 변성을 유지하는 가장 일반적인 방법 중 하나로 객체 또는 배열을 새로운 객체나 배열로 복사하여 원본 데이터를 변경하지 않고 새로운 데이터를 생성함으로써 이전 상태의 불변성을 유지한다.
const postReducer = (prevState, action) => {
switch (action.type) {
case 'ADD_POST':
return [...prevState, action.data];
default:
return prevState;
}
};
규모가 작은 코드라면 상관없지만, 규모가 거대해질 수록 스프레드 연산자를 사용하는 방법은 코드 가독성에 악영향을 미치게 한다. 객체나 배열이 중첩되어 있을 때 스프레드 연산자를 사용하게 되면 코드가 점차 지저분해지기 때문이다.
const originalObject = {
key1: 'value1',
key2: {
nestedKey1: 'nestedValue1',
nestedKey2: 'nestedValue2',
},
};
이런 중첩 객체가 있다고 할 때, 객체 내의 값을 업데이트한다면..
const updatedObject = {
...originalObject,
key2: {
...originalObject.key2,
nestedKey2: 'updatedNestedValue2',
},
};
이렇게 스프레드 연산자를 계속 사용해주어야 하는데, 이렇게 되면 중첩이 깊어지면 깊어질 수록 코드의 가독성과 유지보수성이 하락하는 것을 막을 수가 없다.
이 문제를 해결해주는 것이 바로 Immer. 불변성을 유지하면서 복잡한 데이터 구조를 수정하거나 업데이트하는 작업을 간편하게 처리할 수 있도록 도와주는 라이브러리이다.
Immer는 draft라 불리는 임시 작업 공간을 제공하여 원본 데이터를 수정할 수 있는 환경을 제공하여, 이 작업 공간에서 원본 데이터를 수정하는 것처럼 코드를 작성하면 Immer가 내부적으로 변경된 내용을 감지하고 불변성을 유지하면서 상태를 업데이트해주는 역할을 수행한다.
중첩 객체 업데이트 시 스프레드 연산자 사용 방식의 단점을 언급한 바 있는데, 이 예시에 Immer를 적용하면..
const updatedObject = {
...originalObject,
key2: {
...originalObject.key2,
nestedKey2: 'updatedNestedValue2',
},
};
이런 코드가..
const updatedObject = produce(originalObject, draft => {
draft.key2.nestedKey2 = 'updatedNestedValue2';
});
이렇게 축약이 된다. 중첩이 많이 이루어질 수록, 그 과정에서 수정해야하는 부분이 많을 수록 Immer의 장점은 더욱 빛이 나게 된다.
produce 함수?
Immer 라이브러리에서 가장 중요하고 핵심적인 함수. 함수 내부에서 수정 작업을 수행하면 Immer가 불변성을 자동으로 유지하면서 새로운 상태를 생성하게 된다.
draft?
produce 함수 내부에서 사용되는 파라미터. 원본 상태의 복사본으로, 이를 통해 상태를 수정하는 작업을 수행하며 새로운 상태를 생성하는 역할을 수행한다.
이런 이유로 인해 Immer는 React-Redux와 같은 상태 관리 라이브러리와 찰떡궁합이다. Reducer에서 State가 변화할 때 Immer를 사용하면 불변성 유지는 물론 코드의 가독성과 유지보수성까지 챙길 수 있는 것이다.
const userReducer = (prevState = initialState, action) => {
switch (action.type) {
case 'LOG_IN_REQUEST':
return {
...prevState,
data: null,
isLoggingIn: true,
};
default:
return prevState;
}
};
이런 코드를..
const userReducer = (prevState = initialState, action) => {
return produce(prevState, (draft) => {
switch (action.type) {
case 'LOG_IN_REQUEST':
draft.data = null;
draft.isLoggingIn = true;
break;
default:
break;
}
});
};
이렇게 줄여준다. 예시에서 다루는 코드가 짧아서 확 와닿지 않지만 프로젝트의 규모가 거대해지고, Reducer의 크기도 점점 커지게 될 때 비로소 Immer 사용의 이점을 깨달을 수 있다.