React Immer로 불변성 유지하기
React에서 Immer를 사용하여 배열 또는 객체의 불변성을 유지하는 방법에 대해 알아보겠습니다.
1. 불변성
React의 컴포넌트에서 상태의 불변성을 유지하는 것은 매우 중요합니다. 불변성은 기존 값을 직접 수정하지 않으면서 새로운 값을 만들어내는 것을 의미합니다. 따라서 배열이나 객체의 값을 변경할 때는 새로운 배열이나 객체를 만들어 필요한 부분을 변경해주어야 합니다. 불변성이 유지되지 않으면 값이 변경되어도 감지하지 못하게 되고 렌더링 성능을 최적화하지 못하는 문제가 발생합니다.
주로 전개 연산자를 사용하여 배열이나 객체의 값을 복사합니다.
const arr = [1, 2, 3, 4, 5];
const nextArr1 = arr; // 같은 배열을 가리키도록 할당
const nextArr2 = [...arr]; // 새로운 배열에 값을 모두 복사
nextArr1[0] = 10;
console.log(arr === nextArr1); // true, 서로 같은 배열
nextArr2[0] = 10;
console.log(arr === nextArr2); // false, 서로 다른 배열
전개 연산자는 얕은 복사가 이루어져서 가장 바깥쪽 값만 복사됩니다. 따라서 객체 안에 객체가 있는 경우에 대해 다음과 같이 전개 연산자를 사용하면 불변성이 유지되지 않습니다.
const obj = {
checked: false,
innerObj: {
checked: false
}
};
const nextObj = {...obj};
nextObj.innerObj.checked = true;
console.log(obj.innerObj.checked === nextObj.innerObj.checked); // true
이러한 경우 전개 연산자는 다음과 같이 사용해주어야 합니다. 즉, 내부에 객체나 배열이 있는 경우엔 해당 객체나 배열에 대해서도 값을 복사해주어야 불변성이 유지됩니다.
const obj = {
checked: false,
innerObj: {
checked: false
}
};
const nextObj = {
...obj,
innerObj: {
...obj.innerObj,
checked: true
}
};
console.log(obj.innerObj.checked === nextObj.innerObj.checked); // false
2. Immer 사용하기
배열이나 객체의 구조가 복잡해지면 전개 연산자를 사용하여 불변성을 유지하는 것도 마찬가지로 복잡해집니다. 이러한 경우에 대해 편리하게 작업하기 위해 immer 라이브러리를 사용합니다.
프로젝트 내부에서 다음 명령어를 실행하여 immer를 설치해줍니다.
$ yarn add immer
yarn add v1.22.15
info No lockfile found.
[1/4] 🔍 Resolving packages...
[2/4] 🚚 Fetching packages...
[3/4] 🔗 Linking dependencies...
[4/4] 🔨 Building fresh packages...
success Saved lockfile.
success Saved 912 new dependencies.
info Direct dependencies
├─ @testing-library/jest-dom@5.14.1
├─ @testing-library/react@11.2.7
├─ @testing-library/user-event@12.8.3
├─ immer@9.0.6
├─ react-dom@17.0.2
├─ react-scripts@4.0.3
├─ react@17.0.2
└─ web-vitals@1.1.2
...
✨ Done in 189.86s.
컴포넌트 내부에서 immer를 사용할 경우 일반적으로 다음과 같이 사용합니다. produce 함수의 첫번째 파라미터는 상태이고 두번째 파라미터는 상태를 변경하는 함수입니다. 상태를 변경하면 produce 함수가 불변성을 유지하면서 새로운 상태를 생성해줍니다.
import produce from 'immer';
const obj = {
checked: false,
};
const [state, setState] = useState(obj);
const nextState = produce(state, draft => {
draft.checked = true;
});
다음의 예제를 실행하면 불변성을 유지해야되는 부분에 immer를 사용하여 간단하게 처리할 수 있는 것을 확인할 수 있습니다.
import React from 'react';
import Component from './Component';
function App() {
return (
<>
<Component/>
</>
);
}
export default App;
import React, {useState, useRef, useCallback} from 'react';
import produce from 'immer';
const initialDataState = {
id: 0,
title: '',
content: ''
};
const initialDataListState = {
list: []
};
function Component() {
////////////////////////////////////////
// States & Variables
////////////////////////////////////////
const nextId = useRef(1);
const [data, setData] = useState(initialDataState);
const [dataList, setDataList] = useState(initialDataListState);
////////////////////////////////////////
// Functions
////////////////////////////////////////
const onChangeTitle = useCallback((event) => {
const title = event.target.value;
setData(
produce(data, draft => {
draft.title = title;
})
);
}, [data])
const onChangeContent = useCallback((event) => {
const content = event.target.value;
setData(
produce(data, draft => {
draft.content = content;
})
);
}, [data])
const onSubmit = useCallback((event) => {
event.preventDefault();
const dataInfo = {...initialDataState};
dataInfo.id = nextId.current;
dataInfo.title = data.title;
dataInfo.content = data.content;
setDataList(
produce(dataList, draft => {
draft.list.push(dataInfo);
})
);
nextId.current = nextId.current + 1;
setData(initialDataState); // data 초기화
}, [data, dataList])
const onRemove = useCallback((id) => {
setDataList(
produce(dataList, draft => {
draft.list.splice(draft.list.findIndex(item => item.id === id), 1);
})
);
}, [dataList])
////////////////////////////////////////
// View
////////////////////////////////////////
return (
<>
<form onSubmit={onSubmit}>
<input
name="title"
placeholder="title"
value={data.title}
onChange={onChangeTitle}
/>
<br/>
<textarea
name="content"
placeholder="content"
value={data.content}
onChange={onChangeContent}
/>
<br/>
<button type="submit">Add</button>
</form>
<div>
<ul>
{
dataList.list.map(item => (
<li key={item.id}>
<b>{item.title}</b><br/>
{item.content}<br/>
<button onClick={() => onRemove(item.id)}>Remove</button>
</li>
))
}
</ul>
</div>
</>
);
}
export default Component;
또한 아래의 두 함수는 동일한 동작을 하는 함수인데 두번째와 같이 produce 함수의 첫번째 파라미터를 함수 형태로 사용하면 업데이트 함수를 반환합니다. 이를 이용하여 좀 더 간결하게 사용이 가능합니다.
const onChangeTitle = useCallback((event) => {
const title = event.target.value;
setData(
produce(data, draft => {
draft.title = title;
})
);
}, [data])
const onChangeTitle = useCallback((event) => {
const title = event.target.value;
setData(
produce(draft => {
draft.title = title;
})
);
}, [])
이상으로 React에서 Immer를 사용하는 방법에 대해 알아봤습니다.
※ Reference
- velog.io/@velopert, Redux (4) Immutable.js 혹은 Immer.js 를 사용한 더 쉬운 불변성 관리, https://velog.io/@velopert/20180908-1909-%EC%9E%91%EC%84%B1%EB%90%A8-etjltaigd1
- 『리액트를 다루는 기술』 길벗(2019) 김민준 지음, 11장 컴포넌트 성능 최적화 (p302 ~ p304)
- 『리액트를 다루는 기술』 길벗(2019) 김민준 지음, 12장 immer를 사용하여 더 쉽게 불변성 유지하기 (p312 ~ p322)