본문으로 바로가기

[Javascript] 불변성이란?

category 1. 웹개발/1_1_1 JavaScript 2023. 12. 23. 08:40

  
 
자바스크립트는 동적 타입 언어로, 변수에 저장되는 데이터의 타입은 런타임에 동적으로 결정됩니다. 자바스크립트의 데이터 타입은 크게 두 가지 범주로 나눌 수 있습니다. 원시 데이터 타입(Primitive Data Types)과 참조 데이터 타입(Reference Data Types)입니다.
 
자바스크립트에서 원시타입은 불변하고 참조타입은 가변적입니다. 자바스크립트에서 불변성은 "변하지 아니하는 성질", 말 그대로 바뀌지 않는다는 뜻이며 이 개념은 함수형 프로그래밍에서 중요하게 여겨지는 개념 중 하나입니다. 

원시타입 참조 타입
Number Array
String Object
Boolean Function
Undefined Date
Nul RegExp
Symbol Map, Set ...

 
 

[원시타입]

let a = 1; // 넘버타입
a = 2;

console.log(a); // 2

 
자바스크립트에서 원시타입은 불변하다고 하였는데, 보시다시피 원시타입 값이 바뀌었습니다. 어떻게 된 걸까요? 원시타입은 가변적인 것일까요? 정답은 아닙니다. 이 행위는 메모리상에 아래와 같이 저장되어 있습니다.

우선 a라는 변수는 메모리상 변수가 저장되는 영역 중 빈 곳에 a라는 값이 저장됩니다. 그리고 메모리상 데이터 영역의 빈 곳에 1이라는 값이 저장됩니다. a라는 변수에 숫자 1이 저장된 데이터 영역의 주소를 변수영역에 저장해 줍니다. 이제 변수 a에 할당된 값을 찾는 경우, 변수 a에 저장된 데이터 영역의 주소를 찾아가면 해당 데이터의 값을 확인할 수 있습니다.
 

이제 이 변수에 2라는 값을 할당해 보겠습니다. 이때 데이터 1이 저장되는 영역에 데이터 2가 덮어 씌워지는 게 아니라 새로운 빈데이터 영역에 데이터 2가 저장됩니다. 그리고 변수 a에서 데이터 1을 저장하고 있는 기존 주소값 대신 새롭게 데이터 2를 저장하고 있는 주소값에 대입하는 방식으로 작동하게 됩니다.
 
그리고 사용되지 않는 데이터는 가비지 컬렉터에의 데이터 영역에서 삭제됩니다.

 
그럼 다음과 같은 상황은 어떨까요?

let a = 1;
let b = a;

a = 2;

console.log(a);
console.log(b);

 
변수 b에 a를 할당하는 시점에 메모리상에서는 다음과 같이 동작합니다. 

 
변수 b에 변수 a의 주소값을 저장하는 것이 아닌 변수 a가 가리키고 있는 데이터 1의 주소값을 저장하게 됩니다.  그러면 변수 a의 값을 바꾸더라도 변수 b의 값은 바뀌지 않게 된다는 점을 이해할 수 있습니다.
 
 이와 같이 데이터 영역이 저장된 값은 바뀌지 않기 때문에 서로 다른 변수에 같은 값을 할당하더라도 같은 데이터 영역을 가리키는 형태가 가능하고 둘 중에 한 변수의 값을 바꾸더라도 다른 변수의 값이 바뀌지 않게 된다는 것도 앞서 확인하였습니다.
 
이러한 특징은 자바스크립트의 다른 원시타입에서도 동일하게 작용하고, 앞서 본 자바스크립트 원시타입은 불변하다는 의미는 구체적으로 자바스크립트 메모리상 데이터 영역에 저장되는 값들이 불변하다는 사실을 의미하는 것임을 알게 되었습니다.
 
 

[참조타입]

아래 객체는 메모리상에 어떻게 저장되어 있을까요?

const obj = {
  a: 1,
  b: 2
}

우선 빈 변수 영역에 변수 obj를 저장합니다. 그리고 변수 obj는 여러 속성들로 이루어진 데이터임을 확인하고 이 속성들을 위한 별도의 변수 영역을 새롭게 할당합니다. 그리고 새로 할당된 변수 영역에 대한 주소 범위를 데이터 영역에 저장합니다. 그와 동시에 변수 obj는 주소 범위가 기록된 데이터에 대한 주소가 저장됩니다. 이제 앞서 객체 obj를 위해 할당된 변수 영역에 내부 속성 변수인 a와 b를 새롭게 저장합니다. 그리고 변수 a와 b에 저장된 값이 원시타입임을 확인할 수 있고 해당되는 데이터를 할당하고 변수 영역에 주소값을 저장합니다.
 
우리는 원시타입에서 데이터 영역의 값이 불변하다는 것을 확인하고 왔습니다. 객체에서도 결국 데이터 영역을 사용하고 데이터 영역에 저장되는 값들은 불변하게 됩니다. 그런데 왜 참조 타입은 가변적이라 하는 걸까요?

 
바로 변수의 값들이 가변적이기 때문입니다. 원시타입이 변수 영역에서 데이터 영역의 주소값을 바로 저장하는 형태와 달리, 참조타입은 아무리 깊이가 낮아도 "변수 영역 - 데이터 영역 - 변수 영역 - 데이터 영역" 형태로 구성됩니다. 변수 영역에는 다른 어떤 값이든 대입할 수 있습니다. 이러한 특징 때문에 참조 타입이 가변적이다라고 할 수 있습니다.
 
이와 관련된 예제 하나를 확인해 보겠습니다.

const obj1 = { a: 1, b: 2 };
const obj2 = obj1;

obj1.b = 3;

console.log(obj1);
console.log(obj2);

 
obj2에 obj1을 대입한 순간의 메모리 모습입니다.

 
변수 obj2에 변수 obj1에 저장된 @주소범위 데이터 주소를 저장합니다.
이제 obj1의 속성값을 바꾸게 되면 아래와 같은 형태로 바뀌게 되는 것을 알 수 있습니다.

 
그러면 변수 obj2가 같은 주소 범위 데이터를 참조하고 있기 때문에 obj1의 속성값을 변경하면 obj2의 속성값도 바뀌는 것을 확인할 수 있습니다.
 
마찬가지로 다른 참조타입도 앞서 본 것과 같은 방식으로 작동하게 됩니다. 자바스크립트에서 참조타입은 가변적이다라는 말은 참조타입의 데이터 영역에 저장되는 값들은 불변하지만 변수 영역의 속성들은 다른 데이터를 재할당할 수 있고 이 부분에서 가변적이다라는 의미입니다.
 
결국 데이터 영역은 불변성을 유지한다는 공통점이 있었지만, 변수 영역이 가변적이기 때문에 타입 종류에 따라 불변 및 가변 여부가 결정됨을 확인할 수 있습니다. 이제 여기까지 이해를 하셨다면 불변성이 어떻게 적용되는지는 알겠지만, 왜 이렇게 불변성이 유지되는 형태로 작동하는 걸까요? 그에 대한 이점을 알아봅시다.
 
 

[불변성 유지 방식의 이점]

1. 예측 가능성
앞서 데이터 영역은 불변하다고 설명해 왔습니다. 하지만 만약 데이터 영역의 값이 가변적이라면 어떻게 될까요? 앞서 원시타입 때 보았던 예제를 다시 가져왔습니다.

let a = 1;
let b = a;

a = 2;

console.log(a);
console.log(b);

데이터 영역이 가변적이라고 할 때 위의 예제에서 변수 a에 2를 저장하게 되면 기존에 데이터 1이 저장되어 있던 공간에 데이터 2가 덮어 씌워지게 됩니다.
 
그와 동시에 변수 b의 값도 바뀌게 되지만 우리는 이를 알아차리지 못합니다. 결국 이런 변화는 사이드 이펙트를 일으켜 버그를 유발하게 됩니다.
 
물론 이를 방지하기 위한 추가적인 과정을 더 할 수 있겠지만, 그 구조가 복잡해지기에 불변성을 유지하는 형태보다 효율성이 떨어지게 됩니다.
  
 
2. 성능 효율성
1) 메모리 영역 성능
데이터 영역이 가변적이고 한 변수의 값이 바뀌면서 다른 변수의 값이 바뀌는 문제를 방지하기 위해 변수마다 서로 다른 데이터 영역의 값을 할당한다고 해 봅시다.

 
이와 같은 식으로 구현하게 되면 데이터 사이드 이펙트 문제는 방지할 수 있겠지만, 만약 10억 개의 변수에 같은 값을 할당하게 된다면 어떻게 될까요? 데이터의 값이 동일함에도 불구하고 불필요하게 데이터 영역을 차지하게 됩니다. 이는 추가 메모리 할당을 유발하며 메모리 소비량에서 비효율적입니다.
 
2) 상태 변경 추적 성능
변수가 참조하고 있는 데이터의 위치에 변경 여부만을 확인함으로써 값을 비교할 수 있습니다. 그리고 이러한 방식의 비교는 복잡한 객체비교 과정에서의 오버헤드 부하를 방지하고 구성요소가 어떻게 업데이트되는지 파악하는 부담을 줄여줍니다.
 
 

[변수의 불변함]

변수 영역은 가변적이고 그로 인해 몇 가지 문제들이 발생하곤 합니다. 그리고 자바스크립트에서 이런 문제들을 해결하기 위한 방법 몇 가지가 존재하며 이들을 살펴보겠습니다.
 
1. const
const는 변수로 선언하는 방법 중 하나이며 변수를 상수와 같이 작동하게 만듭니다. 그래서 일반적으로 var, let으로 선언한 변수와 달리 const로 선언한 변수의 값은 재할당, 재선언할 수 없습니다. 하지만 이 문장에는 함정이 하나 있습니다.
 
원시타입의 경우에는 변수 영역에서 데이터영역으로 바로 이어지는 형태라 const로 선언된 변수값이 바뀌는 일이 없습니다. 하지만 참조타입은 어떨까요? 마찬가지로 참조타입 경우에도 변수에 할당된 객체에 재할당 및 재선언이 불가능합니다. 하지만 여기에 맹점이 하나 있습니다. 객체 속성의 값을 변경하면 어떨까요?

const obj = { a: 1, b: 2 };
obj.a = 3;

console.log(obj); {a: 3, b: 2}

 
아쉽게도 const만으로는 속성의 값을 변경하는 것까지 막아주지 못합니다. 결국 const로 선언해도 참조 타입의 경우에는 내부 속성들을 추가, 제거, 변경하는데 아무런 제약이 없습니다. 이런 개념에 앞서 본 예제를 보시면 이해하시기 쉬우실 겁니다.

 
다시 const의 정의를 보면 const는 값을 재할당, 재선언만을 막아주기 때문에 이러한 작동방식으로 인해 그 정의가 잘못된 것이 아님을 확인할 수 있습니다. 그래서 우리는 참조타입의 내부 속성 변경을 막아 주는 방법이 필요합니다. 그중 가장 대표적인 방법이 바로 Object.freeze()입니다.
 
2. Object.freeze()
Object.freeze()를 사용하면 객체 속성의 수정, 추가, 제거가 불가능합니다. 추가로 엄격 모드가 아닌 이상 속성에 대한 제어를 시도하면 변경이 일어나지 않음에도 불구하고 오류를 보여 주지 않기 때문에 이 점 조심해야 합니다. 

const obj = { a: 1, b: 2 };
Object.freeze(obj);

obj.a = 3;

console.log(obj); // { a: 1, b: 2 }

 
하지만 이 방법들도 아직까진 문제점이 있습니다. 바로 객체 내 객체 변경 방지를 해 주지 못한다는 점입니다. Object.freeze()는 얕은 동결 방식으로 작동합니다. 얕은 동결은 호출된 객체의 직속 속성에 대해서만 적용됩니다. 결국 깊은 동결을 하기 위해서는 재귀적으로 일일이 동결해 줘야 됩니다. 하지만 이런 방식은 결국 객체가 깊어질수록 성능저하를 유발하게 됩니다. 결국 이러한 문제로 여러 라이브러리들이 등장했는데 대표적인 라이브러리는 immutable.js가 있습니다.
 
3. Immutable.js
immutable.js는 자바스크립트 라이브러리로 객체 속성에 추가, 변경, 삭제, 복사와 같은 행위를 하면 해당 객체 내용이 변경되는 것이 아니라 변경된 복사본이 생성되고 반환됩니다. 그리고 기존 객체 속성은 바뀌지 않습니다. 이런 방식으로 원본 객체에 대한 불변성을 유지해 줍니다.

import { Map, List, fromJS} from "immutable";

// 객체 선언
const obj = Map({ a:1, b:2, c: Map({ x: 'hello', y: 'world'}) }); // 새로 선언
const obj2 = fromJS({ a:1, b:2, c: ({ x: 'hello', y: 'world'}) }); // 기존 객체 immutable 형태로 변환

// 객체 값 읽기
obj.get('a'); // 1

// 객체 값 변경하기
const obj3 = obj.set('b', 3); // 반드시 새 객체를 반환(기존 객체 변화 x)

 
4. 객체 복사방식

const obj2 = obj; // 한 변수의 속성이 바뀌면 다른 변수의 속성도 바뀐다.
Object.assign({}, obj}; // 얕은 복사. 현재 depth에 대해선 불변성 보장
JSON.parse(JSON.stringfy(obj)); // 깊은 복사

 
 

메모리상 변수 영역과 데이터 영역이 나뉘어 있는 이유는 데이터는 타입에 따라 필요한 영역의 크기가 다르고 만약 타입과 상관없이 데이터를 넣다가 기존 영역의 크기가 부족하면 메모리상 뒤에 있는 데이터들을 옮겨 줘야 하는 작업이 필요한데, 이 과정이 성능상 비효율적이기 때문입니다. 
 
 
 

References
[10분 테크톡] 인치의 불변성
https://immutable-js.com/