Immutability(변경불가성)는 객체가 생성된 이후 그 상태를 변경할 수 없는 디자인 패턴을 의미한다. Immutability는 함수형 프로그래밍의 핵심 원리.
객체는 참조 형태로 전달하고 전달 받는다.
객체가 참조 형태로 공유되어 있다면 그 상태가 언제든지 변경될 수 있기 때문에 문제가 될 가능성도 커지게 된다.
이는 객체의 참조를 가지고 있는 어떤 장소에서 객체를 변경하면 참조를 공유하는 모든 장소에서 그 영향을 받기 때문인데 이것이 의도한 동작이 아니라면 참조를 가지고 있는 다른 장소에 변경 사실을 통지하고 대처하는 추가 대응이 필요하다.
의도하지 않은 객체의 변경이 발생하는 원인의 대다수는 "레퍼런스를 참조한 다른 객체에서 객체를 변경" 하기 때문이다.
이 문제의 해결 방법은 객체를 불변객체로 만들어 프로퍼티의 변경을 방지하며 객체의 변경이 필요한 경우에는 참조가 아닌 객체의 방어적 복사(Defensive copy)를 통해 새로운 객체를 생성한 후 변경한다. 또는 Observer 패턴으로 객체의 변경에 대처할 수 있다.
불변객체를 사용하면 복제나 비교를 위한 조작을 단순화 할 수 있고, 성능 개선에 도움이 되지만 객체가 변경 가능한 데이터를 많이 가지고 있는 경우 부적절한 경우가 있다.
자바스크립트의 원시 타입(Primitive data type)은 변경 불가능한 값(Immutable value)이다.
Boolean
null
undefined
Number
String
Symbol
원시 타입 이외의 모든 값은 객체 타입이며 변경 가능한 값(Mutable value)이다.
즉, 객체는 새로운 값을 다시 만들 필요 없이 직접 변경이 가능하다.
자바스크립트에서 문자열은 변경 불가능한 값이고 이러한 값을 Primitive values라고 한다.
변경이 불가능하다는 것은 메모리 영역에서의 변경이 불가능하다는 것이고 재할당은 가능하다.
let str = "Hello";
str = "World";
위의 예제에서 1행이 실행되면 메모리에 문자열 "Hello"가 생성되고 식별자 str
은 메모리에 생성된 문자열 "Hello"의 메모리 주소를 가르킨다.
그리고 2행이 실행되면 이전에 생성된 문자열 "Hello"를 수정하는 것이 아니라 새로운 문자열 "World"를 메모리에 생성하고 식별자 str
은 이를 가르킨다.
이때 문자열 "Hello"와 "World"는 모두 메모리에 존재하고 있다.
let sentence = "Hello World!";
let newSentence = sentence.slice(6, 11);
console.log(newSentence); // World
위의 예제에서 String
객체의 slice()
메소드는 변수에 저장된 문자열을 변경하는 것이 아니라 사실 새로운 문자열을 생성해 반환한 것이다.
그 이유는 문자열은 변경할 수 없는 Immutable value이기 때문이다.
let arr = [];
console.log(arr.length); // 0
let newArr = arr.push(1); // arr.push()는 메소드 실행 후 arr의 length를 반환
console.log(arr.length); // 1
위의 예제에서 newArr
의 값이 만약 문자열과 같이 동작한다면 newArr
는 요소가 숫자 1인 값을 가지고 있는 새로운 배열일 것이다. 하지만 객체인 arr
은 push
메소드에 의해 업데이트되고 newArr에는 배열의 새로운 length
값이 반환된다.
처리 후 결과의 복사본을 리턴하는 문자열의 메소드 slice()
와 달리 배열(객체)의 push()
메소드는 직접 대상의 배열을 변경한다.
그 이유는 배열은 객체이고, 객체는 변경 가능한 Mutable value이기 때문이다.
let user = {
name : "Son",
age : 30,
};
let userName = user.name; // 변수 userName은 String 타입이다.
user.name = "Kane";
console.log(userName); // Son
userName = user.name; // 재할당
console.log(userName); // Kane
위의 예제에서 user.name
의 값을 변경했지만 userName
의 값은 변경되지 않았다.
이는 변수 userName
에 user.name
의 값을 할당 했을 때 user.name
의 참조를 할당하는 것이 아니라 immutable한 값 Son
이 메모리에 생성되고 userName
은 이를 참조하기 때문이다. 따라서 user.name
의 값이 변경된다 하더라도 변수 userName
이 참조하고 있는 Son
은 변함이 없다.
let user1 = {
name : 'Son',
age : 30,
};
let user2 = user1; // 변수 user2는 객체 타입
user2.name = 'Kane';
console.log(user1.name); // Kane
console.log(user2.name); // Kane
위의 예제 객체 user2
의 name
프로퍼티에 새로운 값을 할당하면 객체는 변경 가능한 값이므로 user2
는 변경된다. 이때 변경하지 않은 객체 name
도 동시에 변경된다.
이는 user1
와 user2
가 같은 어드레스를 참조하고 있기 때문이다.
의도하지 않은 객체의 변경이 발생하는 원인의 대다수는 "레퍼런스를 참조한 다른 객체에서 객체를 변경하기 때문" 이다.
이 문제의 해결 방법은 객체를 불변객체로 만들어 프로퍼티의 변경을 방지하며 객체의 변경이 필요한 경우에는 참조가 아닌 객체의 방어적 복사(Defensive copy)를 통해 새로운 객체를 생성한 후 변경한다.
Object.assign
은 타겟 객체로 소스 객체의 프로퍼티를 복사한다.
이때 소스 객체의 프로퍼티와 동일한 프로퍼티를 가진 타겟 객체의 프로퍼티들은 소스 객체의 프로퍼티로 덮어쓰기 된다. 리턴값으로 타겟 객체를 반환한다.
Object.assign(target, ...sources)
// Copy 복사
const obj = { a : 1 };
const copy = Object.assign({}, obj);
console.log(copy); // { a : 1 }
console.log(obj === coppy); // false
// Merge 병합
const obj1 = { a : 1 };
const obj2 = { b : 2 };
const obj3 = { c : 3 };
const merge1 = Object.assign(obj1, obj2, obj3);
console.log(merge1); // { a : 1, b : 2, c : 3 }
console.log(obj1); // { a : 1, b : 2, c : 3 } 타겟 객체가 변경됨
// Merge 병합
const obj4 = { d : 4 };
const obj5 = { e : 5 };
const obj6 = { f : 6 };
const merge2 = Object.assign({}, obj4, obj5, obj6);
console.log(merge2); // { d : 4, e : 5, f : 6 }
console.log(obj4); // { d : 4 }
Object.assign
를 사용하여 기존 객체를 변경하지 않고 객체를 복사하여 사용할 수 있다.
Object.assign
은 완전한 Deep copy
를 지원하지 않는다.
객체 내부의 객체(Nested Object)는 얕은 복사, Shallow copy
된다.
const user1 = {
name : 'Son',
address : {
city : 'London'
}
};
// 새로운 빈 객체에 user1을 복사한다.
const user2 = Object.assign({}, user1);
// user1과 user2는 참조 값이 다르다.
console.log(user1 === user2); // false
user2.name = 'Kane';
console.log(user1.name); // Son
console.log(user2.name); // Kane
// 객체 내부의 객체는 Shallow copy 된다.
console.log(user1.address === user2.address); // true
user2.address.city = 'Manchester';
console.log(user1.address.city); // Manchester
console.log(user2.address.city); // Manchester
user1
객체를 빈 객체에 복사하여 새로운 user2
객체를 생성했다.
user1
과 user2
는 어드레스를 공유하지 않으므로 한 객체를 변경하여도 다른 객체에 영향이 없다.
주의할 것은 user1
객체는 const
로 선언되어 재할당은 할 수 없지만 객체의 프로퍼티는 보호되지 않는다. 즉, 객체의 내용은 변경할 수 있다.
Object.freeze
를 사용하여 불변(immutable) 객체로 만들수 있다.
const user1 = {
name : 'Son',
address : {
city : 'London',
}
};
// Object.assign은 deep copy를 지원하지 않는다.
const ueser2 = Object.assign({}, ueser1, { name : 'Kane' });
console.log(ueser1.name); // Son
console.log(ueser2.name); // Kane
Object.freeze(user1);
user1.name = 'Kane'; // 무시
console.log(user1); // { name : 'Son', address : { city : 'London'} }
console.log(Object.isFrozen(user1)); // ture
// 하지만 객체의 내부 객체는 변경이 가능하다
user1.address.city = 'Manchester';
console.log(user1); // { name : 'Son', address : { city : 'Manchester'} }
내부 객체까지 변경 불가능하게 만드려면 Deep freeze를 하여야한다.
function deepFreeze(obj) {
const props = Object.getOwnPropertyNames(obj);
props.forEach((name) => {
const prop = obj[name];
if(typeof prop === 'object' && prop !=== null) {
deepFreeze(prop);
}
});
return Object.freeze(obj);
}
const ueser = {
name : 'Son',
address : {
city : 'London',
}
};
deepFreeze(user);
user.name = 'Kane'; // 무시
user.address.city = 'Manchester'; // 무시
console.log(user); // { name: 'Son', address: { city: 'London' } }
Object.assign
과 Object.freeze
을 사용하여 불변 객체를 만드는 방법은 번거롭고 성능 이슈가 있기 때문에 큰 객체에서는 사용하지 않는 것이 좋다.
Facebook이 제공하는 Immutable.js를 사용하는 방법이 있다.
참고 문헌
PoiemaWeb: 웹 프로그래밍 튜토리얼 https://poiemaweb.com
모던 JavaScript 튜토리얼 https://ko.javascript.info
벨로퍼트와 함께하는 모던 자바스크립트 https://learnjs.vlpt.us
MDN https://developer.mozilla.org/ko/