학습 자료

불변성 — 왜 새로 만들어야 하나


Article

9강에서 규칙 하나를 던져뒀다. state를 직접 고치지 말고 새로 만들어라.

번거롭다. user.age = 28 한 줄이면 될 것을 { ...user, age: 28 }이라고 쓴다. 배열에 하나 넣는데 push 대신 [...items, item]을 쓴다.

왜 이래야 하는지 끝까지 파고든다.

React 는 주소만 본다

setState를 부르면 React는 새 값과 이전 값을 비교한다. 정말 바뀌었는지 확인해야 헛되이 다시 그리지 않는다.

비교 방법은 Object.is다. 자바스크립트 편 17강에서 본 ===와 거의 같다.

const a = { count: 1 };
const b = { count: 1 };

console.log(a === b);
콘솔
  • false

내용이 똑같은데 다르다고 나온다. 객체 비교는 내용이 아니라 주소를 보기 때문이다.

이 성질이 React에서는 이렇게 뒤집힌다.

const user = { name: "지원", age: 27 };
const same = user;
same.age = 28;

console.log(user === same);
콘솔
  • true

값을 고쳤는데 같다고 한다. 주소가 그대로니까.

직접 고쳤을 때 React 가 보는 것

이전 값과 새 값이 같은 주소를 가리킨다

prevState
0x1a2b
{ name: '지원', age: 28 }
nextState
0x1a2b
같은 객체다

React는 Object.is(prev, next)true이므로 "안 바뀌었네" 하고 리렌더링을 건너뛴다. 화면이 그대로다.

새로 만들면 알아챈다

setUser({ ...user, age: 28 });

{ ...user }새 객체를 만든다. 주소가 다르니 Object.isfalse를 주고, React가 다시 그린다.

새로 만들었을 때

주소가 다르므로 변화를 알아챈다

prevState
0x1a2b
{ name: '지원', age: 27 }
nextState
0x7f3c
{ name: '지원', age: 28 }

배열 갱신 패턴

자바스크립트 편 15강에서 원본을 바꾸는 메서드와 안 바꾸는 메서드를 구분했다. 그 표가 그대로 쓰인다.

하려는 일쓰면 안 되는 것써야 하는 것
끝에 추가arr.push(x)[...arr, x]
앞에 추가arr.unshift(x)[x, ...arr]
삭제arr.splice(i, 1)arr.filter(…)
수정arr[i] = xarr.map(…)
정렬arr.sort()[...arr].sort()
뒤집기arr.reverse()[...arr].reverse()

실제로 쓰면 이렇다.

const [todos, setTodos] = useState([]);

function add(text) {
  setTodos(prev => [...prev, { id: crypto.randomUUID(), text, done: false }]);
}

function remove(id) {
  setTodos(prev => prev.filter(todo => todo.id !== id));
}

function toggle(id) {
  setTodos(prev =>
    prev.map(todo =>
      todo.id === id ? { ...todo, done: !todo.done } : todo
    )
  );
}

toggle을 눈여겨본다. map 안에서 바꿔야 할 항목만 새 객체로 만들고, 나머지는 원래 객체를 그대로 돌려준다.

toggle 이 만드는 것
  1. 01

    새 배열이 만들어진다

    map 은 항상 새 배열을 반환한다. 배열의 주소가 달라진다.

  2. 02

    바뀐 항목만 새 객체

    조건에 맞는 하나만 { ...todo, done: … } 로 새로 만든다.

  3. 03

    나머지는 그대로

    같은 객체를 그대로 넣는다. 주소가 같으니 React 가 "이건 안 바뀌었네" 하고 건너뛴다.

    todo.id === id ? { ...todo, done: !todo.done } : todo

바뀌지 않은 항목의 주소를 유지하는 게 중요하다. 전부 새로 만들면 React가 전부 다시 그린다.

중첩이 깊어지면

여기가 진짜 골치다.

const [user, setUser] = useState({
  name: "지원",
  address: { city: "서울", zip: "04524" },
});

city만 바꾼다고 해보자.

setUser({ ...user, address: { ...user.address, city: "부산" } });

user도 새로, address도 새로 만들어야 한다. 자바스크립트 편 18강에서 본 얕은 복사의 결과다. { ...user }address의 주소만 복사한다.

세 겹이면 이렇게 된다.

setState({
  ...state,
  a: { ...state.a, b: { ...state.a.b, c: 1 } },
});

읽기 힘들고 틀리기 쉽다. 답은 문법을 익히는 것이 아니라 구조를 바꾸는 것이다.

중첩을 다루는 두 태도

스프레드를 겹쳐 쓴다

구조는 그대로

  • 깊어질수록 코드가 길어진다
  • 한 겹만 빠뜨려도 조용히 실패
  • 읽는 사람이 구조를 외워야 한다

state 를 평평하게

구조를 고친다

  • address 를 별도 state 로 분리
  • 목록은 id 를 키로 한 객체로
  • 갱신 코드가 한 줄이 된다

왜 이 규칙이 이득인가

번거로움을 지불하고 무엇을 얻는지 정리한다.

얻는 것설명
빠른 비교주소 하나만 보면 된다
예측 가능성이전 상태가 절대 안 바뀌므로 추적할 수 있다
최적화 가능React.memo 가 props 비교로 리렌더링을 건너뛴다
실행 취소이전 상태들을 그대로 보관하면 undo 가 공짜다

세 번째는 실전에서 크다. 자식 컴포넌트가 받은 props의 주소가 그대로면, React는 자식을 아예 다시 실행하지 않을 수 있다. 직접 고치는 방식에서는 불가능하다.

props 도 마찬가지다

5강에서 "props는 읽기 전용"이라고 했다. 같은 이유다.

function ProductCard({ product }) {
  product.price = 0;
}

부모의 객체가 진짜로 바뀌는데, 부모는 자기 state가 바뀐 줄 모르니 다시 그리지 않는다. 데이터와 화면이 어긋난다. 1강에서 React가 없애려던 바로 그 문제다.

정리하면

  • React는 내용이 아니라 주소를 비교한다. Object.is 하나로 끝내야 빠르기 때문이다.
  • 그래서 state를 직접 고치면 주소가 그대로라 못 알아챈다.
  • 항상 새 객체·새 배열을 만든다. push, splice, sort, reverse를 쓰지 않는다.
  • map으로 갱신할 때 바뀐 항목만 새로 만들고 나머지는 그대로 둔다.
  • 스프레드는 얕은 복사다. 중첩된 것도 각각 새로 만들어야 한다.
  • 세 겹 넘게 겹친다면 state 구조를 평평하게 고친다.
  • 불변성 덕분에 빠른 비교, 예측 가능성, 최적화, 실행 취소를 얻는다.

Part 3이 끝났다. 다음 강의부터 렌더링 원리다. 지금까지 "React가 다시 그린다"고만 했던 그 일이 정확히 무엇인지 본다.

불변성 — 왜 새로 만들어야 하나 — 디코드랩(DCODELAB)