
React 프로젝트에서 중첩된 객체 상태를 업데이트하다 보면 스프레드 연산자를 3~4번씩 써야 하는 상황이 온다. 그때마다 실수하기 쉽고 코드도 지저분해진다. Immer를 도입하면서 이 문제가 많이 해결됐는데, 복사와 비교의 기본 개념부터 정리해봤다.
얕은 복사 vs 깊은 복사
얕은 복사의 함정
스프레드 연산자로 복사하면 최상위 속성만 새로 생성된다. 내부 객체나 배열은 여전히 원본과 같은 참조를 공유한다.
const original = {
name: "John",
skills: ["JavaScript", "React"]
};
const shallowCopy = { ...original };
shallowCopy.name = "Jane";
shallowCopy.skills.push("Node.js");
console.log(original);
// { name: "John", skills: ["JavaScript", "React", "Node.js"] }
// skills 배열이 함께 변경됨
왜 이런 일이 생기는가
- name은 문자열(원시 타입)이라 값이 복사된다
- skills는 배열(참조 타입)이라 메모리 주소만 복사된다
- 두 변수가 같은 배열을 가리키고 있어서 한쪽을 수정하면 다른 쪽도 바뀐다
깊은 복사
모든 레벨을 재귀적으로 복사해야 완전히 독립적인 객체가 만들어진다.
function deepCopy(obj) {
if (typeof obj !== 'object' || obj === null) return obj;
const newObj = Array.isArray(obj) ? [] : {};
for (let key in obj) {
newObj[key] = deepCopy(obj[key]);
}
return newObj;
}
const original = {
name: "John",
skills: ["JavaScript", "React"]
};
const deepCopied = deepCopy(original);
deepCopied.skills.push("Node.js");
console.log(original.skills);
// ["JavaScript", "React"] - 원본은 그대로
JSON.parse(JSON.stringify())의 한계
간단한 방법이지만 함수, undefined, Symbol, 순환 참조를 처리하지 못한다. 실무에서는 lodash의 cloneDeep을 쓰거나 직접 구현하는 게 안전하다.
얕은 비교 vs 깊은 비교
React에서 자주 마주치는 얕은 비교
참조만 비교한다. 같은 메모리 주소인지만 확인한다.
const obj1 = { name: "John" };
const obj2 = { name: "John" };
const obj3 = obj1;
console.log(obj1 === obj2); // false
console.log(obj1 === obj3); // true
React.memo와 useEffect 의존성 배열이 기본적으로 얕은 비교를 쓴다. 그래서 객체를 props로 넘길 때 매번 새로 생성하면 불필요한 리렌더링이 발생한다.
// ❌ 매 렌더링마다 새 객체 생성 - 리렌더링 유발
<UserCard user={{ name: "John", age: 30 }} />
// ✅ useMemo로 참조 유지
const user = useMemo(() => ({ name: "John", age: 30 }), []);
<UserCard user={user} />
깊은 비교
모든 속성을 재귀적으로 비교한다.
function deepEqual(obj1, obj2) {
if (obj1 === obj2) return true;
if (typeof obj1 !== 'object' || obj1 === null ||
typeof obj2 !== 'object' || obj2 === null) {
return false;
}
const keys1 = Object.keys(obj1);
const keys2 = Object.keys(obj2);
if (keys1.length !== keys2.length) return false;
for (let key of keys1) {
if (!keys2.includes(key) || !deepEqual(obj1[key], obj2[key])) {
return false;
}
}
return true;
}
const obj1 = { a: 1, b: { c: 2 } };
const obj2 = { a: 1, b: { c: 2 } };
console.log(deepEqual(obj1, obj2)); // true
정확하지만 객체가 크면 느려진다. 꼭 필요할 때만 쓴다.
Immer를 쓰는 이유
중첩 구조 업데이트의 고통
// 3단계 중첩 - Immer 없이 불변성 유지
const state = {
users: [
{
id: 1,
profile: {
name: "John",
settings: { theme: "dark" }
}
}
]
};
// theme만 바꾸려는데...
const newState = {
...state,
users: state.users.map(user =>
user.id === 1
? {
...user,
profile: {
...user.profile,
settings: {
...user.profile.settings,
theme: "light"
}
}
}
: user
)
};
깊이가 깊어질수록 코드가 기하급수적으로 복잡해진다. 실수하기도 쉽다.
Immer 기본 사용법
import { produce } from 'immer'; // v10+ 방식
const state = {
users: [
{
id: 1,
profile: {
name: "John",
settings: { theme: "dark" }
}
}
]
};
const newState = produce(state, draft => {
const user = draft.users.find(u => u.id === 1);
user.profile.settings.theme = "light";
});
produce 안에서는 직접 수정하는 것처럼 작성하지만, 실제로는 새로운 불변 객체가 생성된다.
React에서 활용
import { useImmer } from 'use-immer';
function RobotControlPanel() {
const [robots, setRobots] = useImmer([
{
id: 1,
position: { x: 0, y: 0, z: 0 },
status: "idle"
}
]);
const moveRobot = (id, axis, value) => {
setRobots(draft => {
const robot = draft.find(r => r.id === id);
if (robot) {
robot.position[axis] += value;
robot.status = "moving";
}
});
};
// 기존 방식이었다면 스프레드 연산자 3번 중첩
}
GCS 프로젝트에서 로봇 위치 업데이트할 때 이 패턴을 많이 썼다. 코드가 직관적이고 실수할 여지가 줄어든다.
커링으로 재사용 가능한 함수 만들기
const updateRobotPosition = produce((draft, id, axis, value) => {
const robot = draft.find(r => r.id === id);
if (robot) {
robot.position[axis] += value;
}
});
// 어디서든 재사용
const newState = updateRobotPosition(currentState, 1, 'x', 10);
Patches로 변경 내역 추적
실행 취소/다시 실행이 필요한 경우 유용하다.
import { produce, applyPatches } from 'immer';
let patches = [];
let inversePatches = [];
const nextState = produce(
state,
draft => {
draft.robots[0].position.x = 100;
},
(p, ip) => {
patches = p;
inversePatches = ip;
}
);
// 되돌리기
const prevState = applyPatches(nextState, inversePatches);
성능 이슈
구조 공유로 최적화됨
Immer는 변경되지 않은 부분의 참조를 그대로 유지한다. 1000개 로봇 중 1개만 업데이트해도 나머지 999개는 같은 객체를 참조한다.
const state = { robots: [...1000개의 로봇] };
const newState = produce(state, draft => {
draft.robots[0].position.x = 100;
});
// robots[1]부터 robots[999]까지는 state.robots와 같은 참조
console.log(state.robots[1] === newState.robots[1]); // true
주의할 점
1. 불필요한 produce 호출 피하기
// ❌ 변경 없어도 produce 호출
const newState = produce(state, draft => {
if (shouldUpdate) {
draft.value = newValue;
}
});
// ✅ 변경 있을 때만
const newState = shouldUpdate
? produce(state, draft => { draft.value = newValue; })
: state;
2. Proxy 객체 제한
Object.keys(), Object.entries() 등이 예상대로 동작하지 않을 수 있다.
import { produce, current } from 'immer';
produce(state, draft => {
const keys = Object.keys(current(draft)); // current로 스냅샷
});
3. 단순 복사는 다른 방법 고려
// 단순 복사만 필요하면
const copied = structuredClone(original);
// 불변 업데이트가 필요할 때 Immer
const updated = produce(original, draft => {
draft.nested.value = newValue;
});
Redux와 함께 쓰기
Redux Toolkit은 내부적으로 Immer를 쓰지만, 순수 Redux에서도 리듀서를 간단하게 만들 수 있다.
import { produce } from 'immer';
const robotReducer = produce((draft, action) => {
switch (action.type) {
case 'MOVE_ROBOT':
const robot = draft.find(r => r.id === action.id);
if (robot) {
robot.position[action.axis] += action.value;
}
break;
case 'ADD_ROBOT':
draft.push(action.robot);
break;
}
});
정리
복사와 비교는 JavaScript의 기본이지만 제대로 이해하지 못하면 버그가 생긴다. 특히 React처럼 불변성이 중요한 환경에서는 더욱 그렇다.
Immer는 중첩된 상태 업데이트를 직관적으로 만들어준다. 하지만 단순한 상태나 작은 프로젝트에는 오버엔지니어링일 수 있다. 팀의 상황과 프로젝트 복잡도를 고려해서 도입 여부를 결정하면 된다.
'Frontend > Javascript' 카테고리의 다른 글
| JavaScript - 프로토타입과 상속 (0) | 2025.09.29 |
|---|---|
| Javascript - 이벤트 루프 (0) | 2025.09.26 |
| Javascript - Promise 비동기 처리 (0) | 2025.09.25 |
