객체 불변성이 뭔지, 왜 필요한지, 어떻게 해야 객체 불변성을 유지할 수 있는지 이야기 하는 글.
객체가 불변하다는 것은 객체가 생성된 이후 변하지 않는 것이다.
즉, 원본이 그대로 유지되는 것이다.
많은 프로그래밍 언어들을 작성할 때 객체의 불변성은 중요한 개념이다. 의도치 않은 변경을 막기 위해서다.
예를 들어 해당 객체를 가리키고 있는 두 식별자가 있는데, 한 식별자를 사용해 객체 안의 값을 변경하게 되면 그 나머지 객체도 변경된 값을 가리키게 된다.
또한 객체 안의 값을 마구 조작하다 보면 어디서 어떻게 조작했는지, 디버깅하기 어려워진다.
떠라서 마치 전역변수를 사용했을 때와 같은 이유로 객체의 불변성을 지키려고 노력한다.
그러나 자바스크립트에서의 객체는 변경 가능(mutable) 하다. const 로 선언했다고 해도, const의 역할은 해당 식별자의 참조 값을 변경 할 수 없다 뿐이지 그 안의 속성들의 값은 바꿀 수 있다.
const a = {b : 'c'}
const a = {d : 'e'} // error! 참조값을 바꿀 수 없다
const a.b = 'd' // ok
따라서 객체를 조작할 때 객체의 불변성을 지키려면, 먼저 객체를 불변 객체로 만들어 값의 변경을 방지하는게 좋다.
또한 객체를 조작해야 할 필요가 있다면, 객체와 똑같은 clone을 만들고, 그 객체를 조작하는 방식이 바람직하다. 해당 객체를 사용하고 있는 다른 부분에서 의도치 않은 변경사항이 일어날 가능성을 막기 때문이다.
객체의 불변성을 유지하기 위해 객체를 불변 객체로 만드는 메서드로는, Object.freeze()
가 대표적이라고 할 수 있다.
(꽁꽁 얼어붙은 객체 위로 고양이가 걸어다닙니다 🐈)
Object.freeze()는 객체를 동결해 불변하도록 만들어준다.
// 객체 얼리기 🧊
Object.freeze(obj);
동결된 객체는 더 이상 변경할 수 없다. 새로운 속성을 추가하거나, 존재하는 속성을 제거하거나 변경하는 것을 방지한다.
또한 그 프로토타입이 변경되는 것도 방지한다.
const obj = {
prop: 42,
};
// 객체 얼리기 🧊
Object.freeze(obj);
// strict mode일 때 error을 throw, 아니면 보통 아무일도 일어나지 않음
obj.prop = 33;
// 42
console.log(obj.prop);
그렇지만 Object.freeze는 얕은 동결을 한다.
얕은 동결이란, freeze하는 object의 직속 속성에만 적용된다는 것이다. 만약 그 속성의 값이 또 다른 객체라면, 그 객체는 동결되지 않는다.
한 depth만 동결한다고 생각하면 된다. (살얼음..?)
const employee = {
name: "nyoung",
designation: "Frontend Developer",
address: {
street: "Gangnam",
city: "Seoul",
},
};
// 객체 얼리기 🧊
Object.freeze(employee);
employee.name = "dummy"; // 변경되지 않음
employee.address.city = "New York"; // 자식 객체의 속성은 수정 가능
console.log(employee.address.city); // 출력: "New York"
만약 객체 안의 모든 객체들 까지 전부 불변하게 만들고 싶으면(deep freeze), 재귀 함수를 사용해 더 이상 객체가 없을 때까지 재귀적으로 Object.freeze() 를 적용해서 깊은 동결을 할 수 있다.
아까 객체를 조작할 때, 복사 후 새로운 객체를 만들어 조작해야 한다고 했다.
객체를 복사하는 것도 얕은 복사, 깊은 복사로 나뉜다.
객체를 복사하는 방법에는 여러가지 방법이 있다.
Object.assign() 메서드는 출처 객체들의 모든 열거 가능한 자체 속성을 복사해 대상 객체에 붙여넣고, 복사된 객체를 반환한다.
// target에 빈 객체를 넣고, source에 복사할 객체를 넣으면 새로운 객체가 반환된다.
Object.assign(target, ...sources);
또한 얕은 복사를 진행한다. 얕은 복사는 얕은 동결과 마찬가지로, 직속 속성의 값만 복사하는 것이다.
const target = {};
const source = { a : 1, b : 2 };
const returnedTarget = Object.assign(target, source);
console.log(target);
// Expected output: Object { a: 1, b: 4, c: 5 }
console.log(returnedTarget === target);
// Expected output: true
const source = { a : 1, b : 2 };
const target = { ...source }
스프레드 연산자 역시 얕은 복사를 진행한다. 다만 문법이 직관적이어서, 얕은 복사를 진행할 때 이 연산자를 많이 쓴다.
이 경우들은 모두 얕은 복사를 진행하는 것으로, 깊은 복사를 진행하려면 재귀함수를 만들어야 한다.
그렇다면 깊은 복사를 한 번에 할 수 있는 법은 없을까?
JSON 객체를 stringify 메서드를 통해 직렬화 한 뒤, parse 메서드를 통해 역직렬화하는 방법이다. 약간의 우회적인 방법을 통한 느낌도 든다.
JSON.stringify()는 객체를 json 문자열로 변환한다. 문자열로 변환하기 때문에 객체 참조가 모두 끊어지고, JSON.parse를 통해 다시 객체로 변환되기 때문에 깊은 복사가 가능해진다.
이때 주의할 점은 함수나 내장 객체인 Map
, Set
, Date
등 직렬화하지 못하는 값이나이 이 속성에 들어있을 때 정상적인 복사가 불가능하는 것이다.
util 함수의 GOAT인 lodash. 로대쉬에 포함되어있는 clonedeep
함수를 통해서 깊은 복사가 가능하다. 외부 라이브러리를 사용해야 한다는 단점이 있지만 복잡한 객체까지 깊은 복사를 안전하게 진행할 수 있어서 실무에서 많이 쓰인다.
이 밖에도 다른 라이브러리들에서도 불변성을 유지하기 위한 함수들을 많이 제공한다.
import { deepCopy } from 'lodash';
const obj1 = { a: 1, b: { c: 2 } };
const target = deepCopy(originalObj);
2022년 부터, 외부 라이브러리를 사용하지 않고 깊은 복사를 할 수 있는 structuredClone()
이 나왔다. strunctured clone 알고리즘을 사용해, 깊은 복사를 생성한다.
전송가능한 객체
structuredClone(value)
structuredClone(value, options)
// 값과 스스로를 순환 참조하는 객체 생성
const original = { name: "MDN" };
original.itself = original;
// 복제
const clone = structuredClone(original);
console.assert(clone !== original); // 동일하지 않은 객체 (같지 않은 동일성)
console.assert(clone.name === "MDN"); // 같은 값을 가집니다.
console.assert(clone.itself === clone); // 순환 참조가 보존됩니다.
JSON 메서드를 사용하는 것과 비교해, structuredClone
은 Date
와 Map
등 내장 객체도 안전하게 변환합니다.
structuredClone()
의 한계