state.user.address.city = "부산"처럼 직접 바꿨더니 화면이 안 바뀐 경험. 버그가 아니라 React가 참조로 비교하기 때문이다. 그 원리와, 올바른 불변 업데이트 방법을 정리한다.
1. React는 값이 아니라 "참조"로 비교한다
React는 리렌더 여부를 판단할 때, 이전 state와 새 state를 Object.is로 참조 비교한다. 객체 내부를 깊게 들여다보지 않는다.
const [user, setUser] = useState({ name: "수빈", age: 30 });
// ❌ 직접 수정 — 참조가 그대로라 React가 변화를 모름
user.age = 31;
setUser(user); // Object.is(prev, next) === true → 리렌더 안 됨
// ✅ 새 객체로 교체 — 참조가 바뀌어 리렌더됨
setUser({ ...user, age: 31 });
그래서 "기존 객체를 수정하지 말고, 새 객체로 교체한다" 가 React 상태 관리의 제1원칙이다.
2. 얕은 복사로 충분한 경우 (대부분)
바꾸려는 깊이까지만 새로 만들면 된다. 전체를 깊게 복사할 필요가 없다.
// 평평한 객체
setUser({ ...user, age: 31 });
// 배열에 추가 / 제거 / 수정
setList([...list, newItem]);
setList(list.filter((it) => it.id !== targetId));
setList(list.map((it) => (it.id === targetId ? { ...it, done: true } : it)));
map, filter, 전개 연산자는 모두 새 배열/객체를 반환하므로 불변성을 지킨다. push, splice, sort 같은 원본을 바꾸는 메서드는 금지다.
3. 함정 — 중첩 객체에서 얕은 복사만 하면
const [state, setState] = useState({
user: { name: "수빈", address: { city: "서울" } },
});
const copy = { ...state }; // 1단계만 복사
copy.user.address.city = "부산"; // ❌ address는 원본과 공유 → 원본 오염!
setState(copy); // user 참조도 그대로라 비교까지 꼬임
{ ...state }는 1단계만 복사하므로 user·address는 원본과 같은 객체다. 그걸 수정하면 원본 state를 직접 건드린 셈이 된다.
4. 정석 — 바뀌는 경로만 펼친다
변경되는 경로(state → user → address → city)를 따라 각 단계를 새로 만들고, 나머지는 참조를 재사용한다.
setState({
...state,
user: {
...state.user,
address: {
...state.user.address,
city: "부산", // 여기만 변경
},
},
});
이게 바로 구조적 공유(structural sharing) 다.
5. 구조적 공유 — 바뀐 것만 새로, 나머지는 공유
city 하나를 바꿨을 뿐인데 왜 state·user·address까지 새로 만들까? 그 노드들은 바뀐 값을 품고 있는 경로이기 때문이다. 자식이 바뀌면 그 자식을 가리키는 부모의 참조도 달라져야 하므로, 루트까지 새 객체가 되어야 React가 "바뀌었다"고 인식한다.
반대로 settings·name·zip은 건드리지 않았으니 이전 객체를 그대로 가리킨다(공유). 덕분에:
- 메모리 효율 — 안 바뀐 거대한 서브트리를 통째로 복사하지 않는다
- 참조 비교 최적화 유지 —
prev.settings === next.settings가 여전히true라,settings를 쓰는React.memo컴포넌트는 리렌더를 건너뛴다
6. 그래서 깊은 복사 / structuredClone은 손해다
중첩이 번거롭다고 통째로 깊은 복사를 하면 어떻게 될까?
const copy = structuredClone(state);
copy.user.address.city = "부산";
setState(copy);
동작은 한다. 하지만 두 가지를 잃는다.
- 성능 — 안 바뀐 부분까지 전부 새로 복사. 큰 상태면 낭비다.
- 참조 동일성 손실 —
settings·name처럼 안 바뀐 노드도 새 객체가 된다. 그러면prev.settings === next.settings가false가 되어, 변하지도 않은 컴포넌트가 불필요하게 리렌더된다.React.memo·useMemo의 최적화가 깨지는 것이다.
즉 깊은 복사는 "안전해 보이지만" 구조적 공유를 망가뜨려 React의 핵심 최적화와 충돌한다. 바뀌는 경로만 얕게 펼치는 게 기본이고, 깊은 복사는 최후의 수단이다.
7. 실무에서는 — Immer
중첩이 깊으면 펼치기 코드가 지저분해진다. 그래서 많이 쓰는 게 Immer다. 직접 수정하듯 작성해도 내부적으로 불변 업데이트를 해준다.
import { produce } from "immer";
setState(
produce(state, (draft) => {
draft.user.address.city = "부산"; // 직접 수정처럼 보이지만 안전
}),
);
Immer는 내부적으로 바뀐 경로만 새로 만들고 나머지는 참조를 유지하는 구조적 공유를 그대로 수행한다. 그래서 코드의 간결함과 성능을 모두 챙긴다. (Redux Toolkit도 내부에서 Immer를 쓴다.)
8. 정리
| 상황 | 방법 |
|---|---|
| 평평한 객체/배열 | 얕은 복사 — {...obj}, map, filter |
| 중첩 객체 일부 변경 | 바뀌는 경로만 펼치기 (정석) |
| 중첩이 너무 깊고 복잡 | Immer produce |
깊은 복사 / structuredClone | 지양 — 성능·참조 비교 손해 |
한 줄 정리 — React 상태는 직접 수정하지 말고 새 참조로 교체한다. 단, 전체를 깊은 복사하지 말고 바뀌는 경로만 얕게 펼쳐 새로 만들고 나머지는 공유하는 게(구조적 공유) 성능과 메모이제이션 모두에 유리하다.