이 글은 김민준(velopert)님의 리액트를 다루는 기술을 참조하였습니다.
얕은 복사(Shallow Copy)와 깊은 복사(Deep Copy)의 이론 개념보다는
중첩 객체의 깊은 복사하는 방법에 대해 포스팅할 것이다.
목차
1. Spread Operator의 문제점
2. immer
1. Spread Operator의 문제점
프로그래밍 언어를 하나라도 배워본 사람이라면 얕은 복사와 깊은 복사에 대한 차이를 알 것이다.
리액트에서 불변성의 중요성은 이전 포스팅에서 다뤘으므로 생략.
중요한 것은 객체의 경우에 아무 생각없이 다른 변수에 정보를 넘겨주려 하다가는
같은 주소를 공유하게 되어 원본 데이터가 변결될 수도 있다는 것이므로 깊은 복사를 해야 한다는 것이다.
JS를 공부할 때, 분명 참조형 자료형의 경우엔 Spread Operator로 깊은 복사를 할 수 있다고 처음에 배웠다.
그렇다면 "이것만 알고 있으면 되는 거 아닌가?"라고 할 수 있겠지만 다음의 경우를 살펴보자.
user1의 내용을 전개 연산자를 이용해 user2에게 넘겨주고 user2의 레벨을 변경했을 때, user1의 레벨은 그대로 유지된다.
그렇다면 객체 안에 객체가 중첩된 경우는 어떨까?
person 객체 정보를 cp_person 변수에게 전개 연산자로 복사시키고 나이를 변경했을 때는 상관이 없다.
그런데 한 단계 더 들어간 깊이에선 같은 주소를 가리키고 있다.
즉, 두 단계 이상의 depth부터는 깊은 복사가 이루어지지 않는다는 의미가 된다.
이걸 만약 전개 연산자로 해결하려고 한다면 두 번 사용하면 된다.
이번엔 서로 다른 참조값을 가지고 있음을 알 수 있다. 그런데 만약 depth가 10이라면?
미련하게 전개 연산자 10번 사용하는 건 가독성을 망치는 지름길이다.
이런 경우에 사용할 수 있는 방법을 알아보자.
2. immer
bash에 npm add immer을 입력해 설치해주자. 기본구조는 이렇게 생겼다.
import produce from 'immer';
const nextState = produce(originalState, draft => {
// 변경할 값 바꾸기
draft person.tech_stack[3] = "JavaScript";
})
produce 함수는 수정하고 싶은 상태와 업데이트 방법에 대해 정의한 함수를 파라미터로 전달받는다.
두 번째 파라미터의 내부에서 값을 변경하면, produce함수가 알아서 불변성 유지와 새로운 상태 생성을 관리해준다.
immer 라이브러리는 단순히 깊은 곳의 값을 바꾸는 것 뿐만 아니라, 배열을 처리할 때도 간편해진다.
import produce from 'immer';
const originalState = [
{
id: 1,
tech_stack: "C++",
checked: true,
},
{
id: 2,
tech_stack: "Python",
checked: true,
},
{
id: 3,
tech_stack: "Java",
checked: true,
}
]
const nextState = produce(originalState, draft => {
// id가 3이면 checked 값을 true로 고정
const tech_stack = draft.find(x => x.id === 3);
tech_stack.checked = true;
// 새로운 데이터 추가
draft.push({
id: 4,
tech_stack: "JavaScript",
checked: false,
});
// id 1인 항목 제거
draft.splice(draft.findIndex(x => x.id === 1), 1)
});
물론 그렇다고 해서 immer을 사용한다고 코드가 무조건 더 간결해지진 않는다.
언제나 그렇지만 닭 잡는데 소 잡는 칼을 쓰지말자.
useState 함수형 업데이트와 immer 함께 쓰기
우선, immer을 사용하지 않고 불변성을 유지하는 예를 보자.
import React, { useCallback, useRef, useState } from "react";
const App = () => {
const nextId = useRef(1);
const [form, setForm] = useState({ name: "", username: "" });
const [data, setData] = useState({
array: [],
uselessValue: null,
});
// input 수정을 위한 함수
const onChange = useCallback(
(e) => {
const { name, value } = e.target;
setForm({
...form,
[name]: value,
});
},
[form]
);
//form 등록을 위한 함수
const onSubmit = useCallback(
(e) => {
e.preventDefault();
const info = {
id: nextId.current,
name: form.name,
username: form.username,
};
//array에 등록
setData({
...data,
array: data.array.concat(info),
});
//form 초기화
setForm({
name: "",
username: "",
});
nextId.current += 1;
},
[data, form.name, form.username]
);
const onRemove = useCallback(
(id) => {
setData({
...data,
array: data.array.filter((info) => info.id !== id),
});
},
[data]
);
return (
<div>
<form onSubmit={onSubmit}>
<input
name="username"
placeholder="아이디"
value={form.username}
onChange={onChange}
/>
<input
name="name"
placeholder="이름"
value={form.name}
onChange={onChange}
/>
<button type="submit">등록</button>
</form>
<div>
<ul>
{data.array.map((info) => (
<li key={info.id} onClick={() => onRemove(info.id)}>
{info.username} ({info.name})
</li>
))}
</ul>
</div>
</div>
);
};
export default App;
아무래도 너무 기니까 immer만 사용해서 수정하면 다음과 같이 된다.
import React, { useCallback, useRef, useState } from "react";
import produce from "immer";
const App = () => {
const nextId = useRef(1);
const [form, setForm] = useState({ name: "", username: "" });
const [data, setData] = useState({
array: [],
uselessValue: null,
});
// input 수정을 위한 함수
const onChange = useCallback(
(e) => {
const { name, value } = e.target;
setForm(
produce(form, (draft) => {
draft[name] = value;
})
);
},
[form]
);
//form 등록을 위한 함수
const onSubmit = useCallback(
(e) => {
e.preventDefault();
const info = {
id: nextId.current,
name: form.name,
username: form.username,
};
//array에 등록
setData(
produce(data, (draft) => {
draft.array.push(info);
})
// {
// ...data,
// array: data.array.concat(info),
// }
);
//form 초기화
setForm({
name: "",
username: "",
});
nextId.current += 1;
},
[data, form.name, form.username]
);
const onRemove = useCallback(
id => {
setData(
produce(data, draft => {
draft.array.splice(draft.array.findIndex(info=>info.id === id),1);
})
// {
// ...data,
// array: data.array.filter((info) => info.id !== id),
// }
);
},
[data]
);
[ ...]
};
export default App;
그런데 사실 produce 함수는 첫 번째 파라미터가 함수 형태면 리턴 값으로 업데이트 함수를 반환한다.
그말은 즉슨 useState로 만들어진 중첩 객체를 수정할 때, immer와 함께 사용할 수가 있게 된다.
import React, { useCallback, useRef, useState } from "react";
import produce from "immer";
const App = () => {
const nextId = useRef(1);
const [form, setForm] = useState({ name: "", username: "" });
const [data, setData] = useState({
array: [],
uselessValue: null,
});
// input 수정을 위한 함수
const onChange = useCallback(
(e) => {
const { name, value } = e.target;
setForm(
produce((draft) => {
draft[name] = value;
})
);
},
[]
);
//form 등록을 위한 함수
const onSubmit = useCallback(
(e) => {
e.preventDefault();
const info = {
id: nextId.current,
name: form.name,
username: form.username,
};
//array에 등록
setData(
produce((draft) => {
draft.array.push(info);
})
// {
// ...data,
// array: data.array.concat(info),
// }
);
//form 초기화
setForm({
name: "",
username: "",
});
nextId.current += 1;
},
[form.name, form.username]
);
const onRemove = useCallback(
id => {
setData(
produce(draft => {
draft.array.splice(draft.array.findIndex(info=>info.id === id),1);
})
// {
// ...data,
// array: data.array.filter((info) => info.id !== id),
// }
);
},
[]
);
( ... )
};
export default App;