블로그 목록

React 상태 불변성과 구조적 공유 — 왜 깊은 복사를 쓰면 안 될까

2026-06-247 min read
React불변성Immutability구조적 공유Immer

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. 구조적 공유 — 바뀐 것만 새로, 나머지는 공유

새로 생성 (바뀐 경로)재사용 (공유)state새 객체user새 객체settings재사용name재사용address새 객체city"서울"→"부산"zip재사용바뀐 경로만 새 객체로 · 나머지 서브트리는 이전 참조를 그대로 공유 = 구조적 공유

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);

동작은 한다. 하지만 두 가지를 잃는다.

  1. 성능 — 안 바뀐 부분까지 전부 새로 복사. 큰 상태면 낭비다.
  2. 참조 동일성 손실 — 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 상태는 직접 수정하지 말고 새 참조로 교체한다. 단, 전체를 깊은 복사하지 말고 바뀌는 경로만 얕게 펼쳐 새로 만들고 나머지는 공유하는 게(구조적 공유) 성능과 메모이제이션 모두에 유리하다.