불변성과 새 값으로 교체하기
불변성은 만든 값을 고치지 않고, 바뀐 내용을 담은 새 값을 만들어 교체하는 원칙이다. 변경 여부를 참조 비교 한 번으로 알 수 있게 되고, 바뀌지 않은 부분은 이전 값과 공유한다.
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].
O에[[PreventExtensions]]를 호출해 새 프로퍼티를 추가할 수 없게 한다.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" } };
인터랙티브 다이어그램: user.name 을 바꾼 새 state 만들기 (단계별로 재생할 수 있다)
- 이전 state 는 user, user.address, posts 세 하위 객체를 참조한다.
- { ...state } 로 새 최상위 객체를 만든다. posts 는 참조만 복사된다.
- 바꿀 값이 있는 user 는 { ...state.user, name: "b" } 로 새로 만든다.
- 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장의
push후setTodos(todos)가 렌더링을 일으키지 않는 이유를 설명할 수 있다 -
const와Object.freeze가 각각 무엇을 막는지 구분할 수 있다 -
Object.freeze가 중첩 객체를 얼리지 않는 이유를 사양 기준으로 말할 수 있다 - 중첩된 값 하나를 바꿀 때 새로 만들어야 하는 객체를 고를 수 있다
- 원본을 바꾸는 배열 메서드와 바꾸지 않는 메서드를 구분할 수 있다
참고
- [1] Object.freeze() — MDN Web Docs
- [2] SetIntegrityLevel — ECMAScript 사양
- [3] Array.prototype.toSorted() — MDN Web Docs
- [4] Updating Objects in State — React
관련 글
- 얕은 복사와 깊은 복사 → (선행 개념)
- 참조 타입과 참조 복사 →
- 고차 함수와 콜백 → (원본을 두고 새 배열을 반환하는 map, filter)
- 순수 함수와 부수 효과 → (다음 개념, 바깥을 바꾸지 않는 함수)
- javascript
- immutability
- Object.freeze
- toSorted
- react
- 불변성