자바스크립트 환경에서 디버깅하면서 한번쯤 삽질해봤을 부분 중 하나로, 불변성이 지켜지지 않는 객체를 들 수 있을 것이다. 나는 분명 건든 적이 없는 객체라고 생각했는데, 어디선가 예기치 못한 곳에서 값이 바뀌어있을 때 코드 구석구석을 찾아보면서 디버깅하기란 쉽지 않은 일이다. 불변성을 지키지 않는다면 사용할 데이터가 어디서 어떻게 바뀌어가는지 흐름을 쫓아가기 어렵고, 이는 예기치 못한 버그나 사이드이펙트로 이어진다. 따라서 우리는 코드의 가독성을 높이고 버그를 예방하기 위해 불변성을 지켜야만 한다.
원시값과 참조값
불변성을 이해하기에 앞서, 자바스크립트에는 원시값과 참조값 두 가지 데이터 타입이 존재한다는 점을 짚고 가보자. 원시값이란, Number, String, Boolean, Null, Undefined 등과 같은 기본 자료형을 말한다. 이들은 불변하다. 불변하다는 말은, 메모리영역 안에서 변경이 불가능하며, 변수에 할당할 때 새로운 값이 만들어져 주소값이 재할당된다는 의미다. 따라서 복사를 한 뒤 값을 변경하더라도 기존 값에 전혀 영향을 주지 않는다.
let message = "Hello.";
let phrase = message;
phrase = "Hi there.";
console.log(message); // "Hello."
console.log(phrase); // "Hi there."
즉, 원시 타입은 불변하므로 안심하고 변경할 수 있다.
참조값은 메모리에 저장되는 객체다. 위에서 나열한 원시값을 제외한 모든 값은 객체(Object)이며, 변할 수 있다. 변할 수 있다는 말은, 새로운 값을 만들지 않고 메모리 영역 안에서 직접 변경이 가능하다는 의미다. 변수에는 객체가 그대로 저장되는 것이 아니라, 객체가 저장되는 메모리 주소인 객체에 대한 참조 값이 저장된다. 따라서 복사를 한 뒤 내부의 값을 변경할 경우 기존의 값에 영향을 주게 된다.
let user = { name: "John" };
let admin = user; // 참조값을 복사
admin.name = 'Pete'; // 참조 값에 의해 name의 값이 변경됨
console.log(user.name); // 'Pete'
위와 같이 객체가 참조를 통해 공유되어 있다면, 여러 곳에서 객체의 상태가 얼마든지 변경될 수 있다. 의도하지 않은 객체의 변경이 일어날 경우, 변경의 원인이 무엇인지 찾아가는 것은 상당히 어렵다.
그렇다면 기존의 객체에 영향을 주지 않으면서 새로운 곳에 객체를 복사하고 싶을 때 어떻게 해야 할까? 레퍼런스를 참조한 다른 객체에서 객체를 변경하지 않도록 해야 한다. 즉, 객체를 불변 객체로 만들고, 객체의 변경이 필요한 경우에는 레퍼런스를 참조하는 것이 아닌 복사를 통해 새로운 객체를 생성한 후 변경하여 사용해야 한다. JavaScript에서 불변성을 유지하는 방법에는 어떤 것들이 있을까?
Object.assign
기본적인 Syntax는 다음과 같다.
Object.assign(target, ...sources)
타깃 객체로 소스 객체의 프로퍼티를 복사하게 된다. 리턴 값으로는 타깃 객체가 반환된다. 다음과 같이 사용할 수 있다.
let user = { name: "John" };
let permissions1 = { canView: true };
let permissions2 = { canEdit: true };
// permissions1과 permissions2의 프로퍼티를 user로 복사
Object.assign(user, permissions1, permissions2);
user; // { name: "John", canView: true, canEdit: true }
타깃 객체(user)에 동일한 이름을 가진 프로퍼티가 있는 경우, 소스 객체의 프로퍼티로 값이 덮어쓰기된다.
let user = { name: "John" };
let user2 = { name: "Pete" };
Object.assign(user, user2);
user; // { name: "Pete" }
Object.assign을 통해 손쉽게 객체를 복사할 수 있다.
let user = {
name: "John",
age: 25
};
let clone = Object.assign({}, user);
그런데 객체 안에 객체까지는 복사할 수 없다.
let user1 = {
name: "Lee",
address: {
city: "Seoul"
}
};
// 새로운 객체에 user1을 복사한다.
const user2 = Object.assign({}, user1);
// 복사 완료! user1과 user2는 참조값이 다르다.
user1 === user2; // false
// 다만 객체 내부의 객체인 address는 참조값이 공유된다.
user2.address.city = "Busan";
user1.address.city; // "Busan"
user2.address.city; // "Busan"
즉, Object.assign은 객체 내부의 객체가 복사되지 않는다는 한계가 있다. 이러한 복사 방식을 Shallow Copy라고 한다.
Spread 연산자
Object.assign과 마찬가지로 얕은 복사(Shallow Copy)가 가능하다. 가장 간단해보이는 복사 방법이며, 아래와 같이 사용할 수 있다.
const user1 = {
name: "Lee",
address: {
city: "Seoul"
}
};
const user2 = {...user1};
// 얕은 복사 완료! user1와 user2는 참조값이 다르다.
user1 === user2; // false
// 다만 객체 내부 객체는 역시 참조값이 공유된다. 얕은 복사니까. (끄덕)
user2.address.city = "Busan";
user1.address.city; // "Busan"
user2.address.city; // "Busan"
역시 객체 내부에 객체가 존재할 경우 참조값이 공유된다는 한계가 있다. 이러한 방식의 얕은 복사는 객체의 depth가 하나일 때 까지만 유효하다고 볼 수 있다. 그렇다면 객체의 깊은 복사가 필요할 땐 어떻게 해야 할까?
JSON.parse(JSON.stringify(obj))
JSON 객체를 stringfy 메서드를 통해 직렬화(serialize)한 뒤, parse 메서드를 통해 이를 역직렬화(deserialize)하는 방식이다. JSON.stringfy()는 객체를 json 문자열로 변환하는데, 이 과정에서 원본 객체와의 참조가 모두 끊어지기 때문에 깊은 복사가 가능해진다. 이후 JSON.parse()를 통해 다시 자바스크립트 객체로 만들어주면 복사가 완료된다. 간단한 방법이지만, 객체 내에 함수가 있을 경우 함수를 처리하지 못한다는 단점이 있다. 즉, 간단한 형태의 객체일 때에만 동작한다.
const user1 = {
name: "Lee",
address: {
city: "Seoul",
},
};
const user2 = JSON.parse(JSON.stringify(user1));
user1.address === user2.address; // false
user1.address.city = "Busan";
user1.address.city; // Busan
user2.address.city; // Seoul
깊은 복사에 성공하려면, 위와 같이 객체의 구조가 단순해야 한다. 그러나 객체 안에 함수가 들어간다면 어떨까?
const componentInput = {
buttonTitle: "This is Button",
callback: () => {
console.log("Button clicked!");
},
};
const copiedComponentInput = JSON.parse(JSON.stringify(componentInput));
copiedComponentInput.callback; // undefined
콜백으로 들어가 있던 함수가 직렬화 → 역직렬화를 거치며 사라져버렸다. undefined가 되어버려 찾을 수 없게 된 콜백… 따라서 이 방법은 함수, 빌트인 타입(Date, Map, RegExp) 등이 포함되어있는 복잡한 객체에서는 정상적인 복사가 되지 않는다.
Lodash의 clonedeep
깊은 복사를 구현해주는 라이브러리들이 많다. 그 중 가장 유명한 것은 바로 Lodash! 로대쉬에 포함되어 있는 clonedeep 함수를 통해 복잡한 객체까지 깊은 복사를 안전하게 할 수 있다.
const deepCopy = require("lodash.clonedeep");
let originalObj = { a: 1, b: { c: 2 } };
let copiedObj = deepCopy(originalObj);
이밖에도 Immer, Immutable.js 등 불변 객체를 다룰 수 있는 라이브러리들이 많다. 다만, 라이브러리를 설치해야 한다는 단점이 있을 것이다.
structuredClone
기존 자바스크립트에서는 객체의 깊은 복사를 직접 구현하거나 라이브러리에 의존하는 등, 깊은 복사가 상당히 까다로웠다. 그런데 최근에 생긴 내장 기능! 자바스크립트에서도 이제 structuredClone 이라는 깊은 복사를 위한 내장 함수가 추가되었다. 모든 브라우저에서 이 API가 지원되며, Node 환경에서도 17버전 이상부터 지원되는 기능이다. 사용법은 아주 간단하다.
let originalObj = { a: 1, b: { c: 2 } };
let copiedObj = structuredClone(originalObj);
이제 더 이상 깊은 복사를 위해 외부 라이브러리에 의존할 필요가 없어졌다.
결론
객체는 참조에 의해 복사된다. 따라서 객체가 할당된 변수를 복사하거나 함수의 인자로 넘길 때, 객체의 불변성이 지켜지고 있는지 꼭 확인해보아야 한다. 불변성을 지키기 위한 수단으로 객체의 얕은 복사와 깊은 복사가 존재하는데, 얕은 복사로는 중첩된 객체를 완전하게 복사할 수 없다. 따라서 깊은 복사를 사용해야 하며, 현 시점에서는 복잡하게 깊은 복사를 구현하거나 외부 라이브러리에 의존할 필요 없이 Web API로 제공되는 structuredClone을 사용하면 해결된다.
Reference
https://ko.javascript.info/object-copy
https://developer.mozilla.org/en-US/docs/Web/API/structuredClone
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/assign
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/parse
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify
'Javascript' 카테고리의 다른 글
[JavaScript] 조금 특별한 자바스크립트 배열 (0) | 2022.02.16 |
---|---|
[JavaScript] 객체를 출력하는 방법 (0) | 2022.02.01 |
[JavaScript] i++ 와 ++i 의 차이 (0) | 2022.01.30 |
[JavaScript] switch문에서 break를 해줘야 하는 이유 (0) | 2022.01.12 |