불변성과 새 값으로 교체하기

불변성은 만든 값을 고치지 않고, 바뀐 내용을 담은 새 값을 만들어 교체하는 원칙이다. 변경 여부를 참조 비교 한 번으로 알 수 있게 되고, 바뀌지 않은 부분은 이전 값과 공유한다.

Seobway · · 7분

1. 문제 상황

할 일을 추가하는 버튼을 눌러도 화면이 바뀌지 않는다.

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

function add(text) {
  todos.push({ text });   // 기존 배열을 고친다
  setTodos(todos);        // 같은 배열을 다시 넘긴다
}

React 는 새 state 와 이전 state 를 Object.is 로 비교한다[4]. push 는 배열 내용을 바꾸지만 배열의 참조는 그대로이므로 Object.is(todos, todos) 는 true 다. React 는 바뀐 것이 없다고 판단하고 다시 렌더링하지 않는다.

function add(text) {
  setTodos([...todos, { text }]);   // 새 배열을 만들어 교체한다
}

새 배열을 넘기면 참조가 달라져 렌더링이 일어난다.


2. 정의

2.1 정의

만든 뒤 내용이 바뀌지 않는 값을 불변 값이라 한다. 자바스크립트의 원시 값은 언어 차원에서 불변이다. str.toUpperCase() 는 새 문자열을 반환하고 원래 문자열은 그대로다.

참조 타입인 객체와 배열은 가변이다. 그래서 객체에서 말하는 불변성은 언어가 보장하는 성질이 아니라 코드가 지키는 원칙이다. 객체를 고치는 대신 바뀐 내용을 담은 새 객체를 만들어 변수나 state 를 교체한다.

바꾸는 코드 (가변) 새 값을 만드는 코드 (불변)
arr.push(x) [...arr, x]
arr.sort(), arr.reverse() arr.toSorted(), arr.toReversed()
arr.splice(i, 1) arr.toSpliced(i, 1), arr.filter(…)
arr[i] = x arr.with(i, x)
obj.name = x { ...obj, name: x }

toSorted, toReversed, toSpliced, with 는 ES2023 에 추가된 메서드다. 원본은 두고 새 배열을 반환한다[3].

2.2 사양에서의 위치

객체를 실제로 바꿀 수 없게 만드는 기능은 Object.freeze 다. 사양에서 Object.freeze(O) 는 SetIntegrityLevel(O, frozen) 을 호출한다[2].

  1. O 에 [[PreventExtensions]] 를 호출해 새 프로퍼티를 추가할 수 없게 한다.
  2. O 의 자기 프로퍼티 각각의 [[Configurable]] 을 false 로 바꾸고, 데이터 프로퍼티면 [[Writable]] 도 false 로 바꾼다.

대상이 자기 프로퍼티뿐이므로 프로퍼티 값이 객체이면 그 객체는 얼지 않는다. 쓰기 불가 프로퍼티에 대입하면 [[Set]] 이 false 를 반환하고, 엄격 모드에서는 TypeError 가 난다. 비엄격 모드에서는 대입이 조용히 무시된다.

const 는 다른 성질이다. const 는 변수에 다른 값을 다시 대입하는 것을 막을 뿐, 변수가 가리키는 객체의 내용은 바꿀 수 있다.


3. 동작 원리

3.1 참조 비교로 변경을 감지한다

모든 변경이 새 객체를 만든다면 「참조가 같다」는 「내용이 같다」와 같은 뜻이 된다. 객체 내용을 하나씩 비교하지 않고 prev !== next 한 번으로 변경 여부를 알 수 있다. React 의 state 비교, React.memo, useMemo 의 의존성 비교가 이 성질에 기대고 있다.

반대로 객체를 직접 고치면 내용이 바뀌어도 참조는 같다. 참조 비교는 「바뀌지 않았다」고 답하고, 그 결과는 틀린다.

3.2 바뀐 경로만 새로 만든다

새 객체를 만든다고 전체를 깊은 복사하지 않는다. 바뀐 값까지 이어지는 경로의 객체만 새로 만들고, 나머지는 이전 객체를 그대로 참조한다. 이것을 구조적 공유라 한다.

const state = {
  user: { name: "a", address: { city: "서울" } },
  posts: [{ id: 1 }],
};

const next = { ...state, user: { ...state.user, name: "b" } };
구조적 공유
그림 1. user.name 을 바꾸면 state 와 user 두 객체만 새로 만든다. address 와 posts 는 이전 객체를 그대로 참조하므로 === 비교가 true 다.

인터랙티브 다이어그램: user.name 을 바꾼 새 state 만들기 (단계별로 재생할 수 있다)

  1. 이전 state 는 user, user.address, posts 세 하위 객체를 참조한다.
  2. { ...state } 로 새 최상위 객체를 만든다. posts 는 참조만 복사된다.
  3. 바꿀 값이 있는 user 는 { ...state.user, name: "b" } 로 새로 만든다.
  4. user 안의 address 는 바뀌지 않았으므로 참조를 복사한다.

4. 예제

4.1 기본

같은 변경을 두 방식으로 하고 참조 비교 결과를 본다. Object.freeze 가 얕게 적용되는 것과 sort 와 toSorted 의 차이도 확인한다.

"use strict";

// 1. 직접 고치기와 새 값 만들기
const prev = { count: 1 };
const mutated = prev;
mutated.count = 2;
console.log("직접 고침, prev !== mutated →", prev !== mutated, "(변경을 감지하지 못한다)");

const prev2 = { count: 1 };
const next2 = { ...prev2, count: 2 };
console.log("새 객체,   prev2 !== next2 →", prev2 !== next2, "/ prev2.count =", prev2.count);

// 2. Object.freeze 는 자기 프로퍼티에만 적용된다
const frozen = Object.freeze({ n: 1, inner: { m: 1 } });
try { frozen.n = 2; } catch (e) { console.log("frozen.n = 2   →", e.constructor.name); }
frozen.inner.m = 2;
console.log("frozen.inner.m →", frozen.inner.m, "/ isFrozen(inner) =", Object.isFrozen(frozen.inner));

// 3. 원본을 바꾸는 메서드와 바꾸지 않는 메서드
const scores = [3, 1, 2];
const copy = scores.toSorted();
console.log("toSorted       →", copy, "원본", scores);
const same = scores.sort();
console.log("sort           →", same, "원본", scores, "같은 배열?", same === scores);

frozen.n = 2 는 TypeError 가 나지만 frozen.inner.m 은 2 로 바뀐다. sort 는 원본을 정렬하고 같은 배열을 반환한다.

4.2 실무 패턴

React 에서 이전 state 를 바탕으로 새 state 를 만들 때는 업데이터 함수에서 새 객체를 반환한다.

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

map 은 항상 새 배열을 반환하고, 바뀐 항목만 새 객체로 만든다. 바뀌지 않은 항목은 이전 객체 그대로이므로, React.memo 로 감싼 항목 컴포넌트는 다른 props 도 같다면 다시 렌더링되지 않는다.


5. 자주 하는 실수


6. 정리

불변성은 값을 고치지 않고 새 값을 만들어 교체하는 원칙이다. 원시 값은 언어가 불변을 보장하고, 객체는 코드가 이 원칙을 지킨다. 바뀐 경로의 객체만 새로 만들면 나머지는 공유되고, 변경 여부는 참조 비교 한 번으로 알 수 있다.

참고

  1. [1] Object.freeze() — MDN Web Docs
  2. [2] SetIntegrityLevel — ECMAScript 사양
  3. [3] Array.prototype.toSorted() — MDN Web Docs
  4. [4] Updating Objects in State — React

관련 글