JavaScript

11. 객체와 변경불가성(Immutability)

js0616 2024. 7. 9. 17:17

https://poiemaweb.com/js-immutability

 

Immutability | PoiemaWeb

함수형 프로그래밍의 핵심 원리이다. 객체는 참조(reference) 형태로 전달하고 전달 받는다. 객체가 참조를 통해 공유되어 있다면 그 상태가 언제든지 변경될 수 있기 때문에 문제가 될 가능성도

poiemaweb.com

 

 

 

Immutability(변경불가성)는 객체가 생성된 이후 그 상태를 변경할 수 없는 디자인 패턴을 의미한다.

Immutability은 함수형 프로그래밍의 핵심 원리이다.

 

 

문제 상황

객체는 참조(reference) 형태로 전달하고 전달 받는다.

객체가 참조를 통해 공유되어 있다면 그 상태가 언제든지 변경될 수 있다.

레퍼런스를 참조한 다른 객체에서 객체를 변경”하면 참조를 공유하는 모든 장소에서 그 영향을 받게 된다.

이로 인해서 의도하지 않은 객체의 변경이 발생하게 된다. 

 

해결 방법

  • 객체를 불변객체로 만들어 프로퍼티의 변경을 방지
  • 객체의 방어적 복사(defensive copy)를 통해 새로운 객체를 생성한 후 변경한다.
  • Observer 패턴으로 객체의 변경에 대처할 수도 있다.

 

불변 객체를 사용하면 복제나 비교를 위한 조작을 단순화 할 수 있고 성능 개선에도 도움이 된다.

하지만 객체가 변경 가능한 데이터를 많이 가지고 있는 경우 오히려 부적절한 경우가 있다.

 

ES6에서는 불변 데이터 패턴(immutable data pattern)을 쉽게 구현할 수 있는 새로운 기능이 추가되었다.


 

1. immutable value vs. mutable value
Javascript의 원시 타입(primitive data type)은 변경 불가능한 값(immutable value)이다.

  • Boolean
  • null
  • undefined
  • Number
  • String
  • Symbol (New in ECMAScript 6)


원시 타입 이외의 모든 값은 객체(Object) 타입이며 객체 타입은 변경 가능한 값(mutable value)이다. 

즉, 객체는 새로운 값을 다시 만들 필요없이 직접 변경이 가능하다는 것이다.

 


 


예를 들어 살펴보자. C 언어와는 다르게 Javascript의 문자열은 변경 불가능한 값(immutable value) 이다. 이런 값을 “primitive values” 라 한다. (변경이 불가능하다는 뜻은 메모리 영역에서의 변경이 불가능하다는 뜻이다. 재할당은 가능하다)

 
var str = 'Hello';
str = 'world';
 

 

1. 메모리에 문자열 ‘Hello’가 생성되고 식별자 str은 메모리에 생성된 문자열 ‘Hello’의 메모리 주소를 가리킨다.

2. 이전에 생성된 문자열 ‘Hello’을 수정하는 것이 아니라 새로운 문자열 ‘world’를 메모리에 생성하고 식별자 str은 이것을 가리킨다.

 

이때 문자열 ‘Hello’와 ‘world’는 모두 메모리에 존재하고 있다.

변수 str은 문자열 ‘Hello’를 가리키고 있다가 문자열 ‘world’를 가리키도록 주소가 변경되었을 뿐이다.

 


 

var statement = 'I am an immutable value'; // string은 immutable value

var otherStr = statement.slice(8, 17);

console.log(otherStr);   // 'immutable'
console.log(statement);  // 'I am an immutable value'

 

Stirng 객체의 slice() 메소드는 statement 변수에 저장된 문자열을 변경하는 것이 아니라 새로운 문자열을 생성하여 반환하게 된다.

 

기존의 statement 의 문자열은 immutable value 이므로 남아있게 된다.

 

 


var arr = [];
console.log(arr.length); // 0

var v2 = arr.push(2);    // arr.push()는 메소드 실행 후 arr의 length를 반환
console.log(arr.length); // 1

 

객체는 변경 가능한 값이기 때문에 배열(객체)의 메소드 push()는 직접 대상 배열을 변경한다.

객체인 arr은 push 메소드에 의해 update되고 v2에는 배열의 새로운 length 값이 반환된다.

 


 

var user = {
  name: 'Lee',
  address: {
    city: 'Seoul'
  }
};

var myName = user.name; // 변수 myName은 string 타입이다.

user.name = 'Kim';
console.log(myName); // Lee

myName = user.name;  // 재할당 , 객체 user.name 의 참조가 아닌 Kim 문자열을 참조 
console.log(myName); // Kim

 

 

user.name의 값을 변경 (Kim) 했지만 변수 myName의 값은 변경되지 않았다.

 

이는 변수 myName에 user.name을 할당했을 때 user.name의 참조(주소)를 할당하는 것이 아니라

immutable한 값 ‘Lee’가 메모리에 새로 생성되고 myName은 이것을 참조하기 때문이다.

따라서 user.name의 값이 변경된다 하더라도 변수 myName이 참조하고 있는 ‘Lee’는 변함이 없다.

 

만약 user.name 의 주소를 넣고 싶다면 객체 user 자체를 할당해야한다. 

 

var user1 = {
  name: 'Lee',
  address: {
    city: 'Seoul'
  }
};

var user2 = user1; // 변수 user2는 객체 타입이다.

user2.name = 'Kim';

console.log(user1.name); // Kim
console.log(user2.name); // Kim

 

user2의 name 프로퍼티에 새로운 값을 할당하면 객체 user2는 변경된다.

그런데 변경하지도 않은 객체 user1도 동시에 변경된다.

이는 user1과 user2가 같은 어드레스를 참조하고 있기 때문이다.

 

 

이것이 의도한 동작이 아니라면 참조를 가지고 있는 다른 장소에 변경 사실을 통지하고 대처하는 추가 대응이 필요하다.

(글의 시작부분에서 언급한 문제 상황의 예시)

 


 

2. 불변 데이터 패턴(immutable data pattern)

 

2.1 Object.assign
Object.assign은 타킷 객체로 소스 객체의 프로퍼티를 복사한다. 

이때 소스 객체의 프로퍼티와 동일한 프로퍼티를 가진 타켓 객체의 프로퍼티들은 소스 객체의 프로퍼티로 덮어쓰기된다. 

리턴값으로 타킷 객체를 반환한다. 

ES6에서 추가된 메소드이며 Internet Explorer는 지원하지 않는다.

 

// Syntax
Object.assign(target, ...sources)

 

 

// Copy
const obj = { a: 1 };
const copy = Object.assign({}, obj);  // 빈 객체를 만들어서 복사 후 반환
console.log(copy); // { a: 1 }
console.log(obj == copy); // false

// Merge
const o1 = { a: 1 };
const o2 = { b: 2 };
const o3 = { c: 3 };

const merge1 = Object.assign(o1, o2, o3); // o1 을 타겟객체로 o2 o3 복사 후 o1 을 반환 

console.log(merge1); // { a: 1, b: 2, c: 3 }
console.log(o1);     // { a: 1, b: 2, c: 3 }, 타겟 객체(o1) 가 변경된다!

// Merge
const o4 = { a: 1 };
const o5 = { b: 2 };
const o6 = { c: 3 };

const merge2 = Object.assign({}, o4, o5, o6);

console.log(merge2); // { a: 1, b: 2, c: 3 }
console.log(o4);     // { a: 1 }

 

Object.assign을 사용하여 기존 객체를 변경하지 않고 객체를 복사하여 사용할 수 있다.

 

Object.assign은 완전한 deep copy를 지원하지 않는다.

-> 객체 내부의 객체(Nested Object)는 앝은 복사(Shallow copy, 참조복사) 된다. 

 

const user1 = {
  name: 'Lee',
  address: {
    city: 'Seoul'
  }
};

// 새로운 빈 객체에 user1을 copy한다.
const user2 = Object.assign({}, user1);
// user1과 user2는 참조값이 다르다.
console.log(user1 === user2); // false

user2.name = 'Kim';
console.log(user1.name); // Lee
console.log(user2.name); // Kim

// 객체 내부의 객체(Nested Object)는 Shallow copy된다.
console.log(user1.address === user2.address); // true

user1.address.city = 'Busan';
console.log(user1.address.city); // Busan
console.log(user2.address.city); // Busan

 

새로운 객체 {} 를 타겟으로 하여 복사를 하였지만 내부의 객체에 대해서는 공유하고 있다. 

 

또한 user1 객체는 const로 선언되어 재할당은 할 수 없지만 객체의 프로퍼티는 보호되지 않는다.

다시 말하자면 객체의 내용은 변경할 수 있다.

 


 

깊은 복사를 위한 대안
깊은 복사를 하려면 Object.assign()을 사용하는 것보다 깊은 복사를 수행하는 라이브러리나 방법을 사용해야 합니다. 

예를 들어, lodash 라이브러리의 _.cloneDeep() 메서드나 JSON을 이용한 방법이 있습니다:

 

const _ = require('lodash');

let obj1 = {
  a: 1,
  b: {
    c: 2
  }
};

let obj2 = _.cloneDeep(obj1);

obj1.b.c = 3;

console.log(obj2.b.c); // 출력: 2 (깊은 복사가 되어 변화가 없음)

 

let obj1 = {
  a: 1,
  b: {
    c: 2
  }
};

let obj2 = JSON.parse(JSON.stringify(obj1));

obj1.b.c = 3;

console.log(obj2.b.c); // 출력: 2 (깊은 복사가 되어 변화가 없음)

 

객체의 모든 속성을 재귀적으로 순회하며 복사하기 때문에 깊은 복사를 지원합니다.

따라서 객체 내부의 객체도 완전히 새로운 복사본으로 처리됩니다.


 

2.2 Object.freeze
Object.freeze()를 사용하여 불변(immutable) 객체로 만들수 있다.

 

const user1 = {
  name: 'Lee',
  address: {
    city: 'Seoul'
  }
};

// Object.assign은 완전한 deep copy를 지원하지 않는다.
const user2 = Object.assign({}, user1, {name: 'Kim'});

console.log(user1.name); // Lee
console.log(user2.name); // Kim

Object.freeze(user1);

user1.name = 'Kim'; // 무시된다!

console.log(user1); // { name: 'Lee', address: { city: 'Seoul' } }

console.log(Object.isFrozen(user1)); // true

 

 

객체의 변경이 불가능하게 할 수 있다. 하지만 객체 내부의 객체는 변경이 가능하다.

 

const user = {
  name: 'Lee',
  address: {
    city: 'Seoul'
  }
};

Object.freeze(user);

user.address.city = 'Busan'; // 변경된다!
console.log(user); // { name: 'Lee', address: { city: 'Busan' } }

 

내부 객체까지 변경 불가능하게 만들려면 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 user = {
  name: 'Lee',
  address: {
    city: 'Seoul'
  }
};

deepFreeze(user);

user.name = 'Kim';           // 무시된다
user.address.city = 'Busan'; // 무시된다

console.log(user); // { name: 'Lee', address: { city: 'Seoul' } }

 

객체의 프로퍼티를 props 라는 변수에 저장하고 

forEach 문으로 하나씩 가져와서 실제 속성 값이 객체인지 확인하고 객체일 경우 deepFreeze 함수를 호출하며

재귀적으로 객체를 순회하면서 모든 속성과 중첩된 객체들에 Object.freeze()를 적용하여 객체를 깊게 불변화

 

 


 

2.3 Immutable.js


Object.assign과 Object.freeze을 사용하여 불변 객체를 만드는 방법은 번거러울 뿐더러 성능상 이슈가 있어서 큰 객체에는 사용하지 않는 것이 좋다.

또 다른 대안으로 Facebook이 제공하는 Immutable.js를 사용하는 방법이 있다.
Immutable.js는 List, Stack, Map, OrderedMap, Set, OrderedSet, Record와 같은 영구 불변 (Permit Immutable) 데이터 구조를 제공한다.

 

npm을 사용하여 Immutable.js를 설치한다.

 

$ npm install immutable

 

Immutable.js의 Map 모듈을 임포트하여 사용한다.

const { Map } = require('immutable')
const map1 = Map({ a: 1, b: 2, c: 3 })
const map2 = map1.set('b', 50)
map1.get('b') // 2
map2.get('b') // 50

 

 

 

 

 

변경불가성 이란 ?

 

 

 

 

 

 

 

 

 

 

 

https://chatgpt.com/