[JavaScript] 불변성

7 분 소요

참고: 생활코딩 JavaScript Immutability 강의


JavaScript의 Immutability

mutable이란 단어 자체가 갖는 뜻(변화 가능한)처럼, mutability변화 가능함을 의미한다. 정보 원본이 변경될 수 있음을 의미한다. immutable변화 가능하지 않은 이란 뜻이다. 따라서 immutability란, 정보 원본이 변경될 수 없음을 의미한다.


0. 원본이란?

어떠한 세계든, 정보의 세계에서 핵심을 이루는 것은 다음의 네 가지 작업이다.

  • Create
  • Read
  • Update
  • Delete


이러한 네 가지 작업들 중 가장 중요한 것은 생성(Create)과 읽기(Read)이다. 따라서 모든 정보는 그것이 존재하고 있다면, 생성이라는 수단과 읽기라는 목적을 갖는다. 이것을 다른 말로 원본이라고 한다.

이런 점에서 어떠한 정보 시스템을 만나든 가장 먼저 확인해야 할 것은, 이 분야에서 생성과 읽기는 어떠한 것인가이다. 이것을 이해해야 해당 분야 정보 시스템의 핵심을 알 수 있다. 생성과 읽기를 이해한 후, 수정과 삭제를 이해해야 한다. 수정과 삭제가 자유로울 때 발생하는 여러 문제점을 해결하기 위해 불변함(Immutability)에 대한 요구가 점차 높아지고 있다. 원본에 가해지는 무질서한 변화를 막을 수 있다면 사고를 막을 수 있다.

참고 : mutability는 안 좋은 것인가?

오해하지 말자. 가변성이 나쁘다는 의미가 아니다. 가변은 디지털의 특권이기도 하다. 다만, 어플리케이션에서 변할 필요가 없는 부분을 확실하게 잡아 놓는다면, 훨씬 더 안심할 수 있을 것이다.

mutability와 immutability를 유용하게 활용하여야 좋은 어플리케이션을 설계할 수 있다.


1. Immutability

불변함을 적용할 수 있는 대상은 크게 두 가지이다. 첫째, 값의 이름, 둘째, 값 자체이다.

immutable

그림 출처: 생활코딩 JavaScript Immutability 2강


이름

const를 통해 변수를 선언하면, 변수의 값이 바뀌었을 때 아래와 같이 TypeError가 발생한다.

immutable-2

변수는 변수의 이름이 가리키는 값이 계속해서 다른 값으로 바뀔 수 있다. 그러나 상수 변수(const)는 한 번 어떤 값을 가리키게 되면, 상수 변수가 가리키는 값을 변경하는 것이 금지된다. 따라서 이것을 시도할 때 위와 같은 에러가 발생하며 프로그램이 종료된다. 이를 통해, 부주의하게 값을 바꾸려는 시도를 할 수 없고, 그 시도를 했을 때 문제가 되는 행위를 했음을 파악할 수 있다.


이제 값을 불변하게 유지하는 방법을 살펴 보자. 이를 위해 JavaScript가 어떤 값을 가리킬 때 어떻게 값을 가리키는지 먼저 이해해야 한다.


변수의 할당 방식

JavaScript에는 여러 데이터 타입이 존재한다. 이는 원시 데이터 타입(Primitive Data Type)과 객체(Object)이다.


  1. 원시 데이터 타입

더 이상 쪼갤 수 없는 최소한의 데이터 타입이라고 이해하자. 다음과 같은 것들이 있다.

  • Number
  • String
  • Boolean
  • Null
  • Undefined
  • Symbol (ES6~)


  1. Object

포괄적으로 객체라고 부르는 것들이다. 원자적인 데이터 타입과는 달리, 복합적인 특성을 갖는, 연관되어 있는 정보를 정리정돈할 때 사용한다는 특성을 갖는다고 이해하자.

  • Object
  • Array: 객체에서 순서대로 정보를 정리한다는 기능이 추가된 자료형
  • Function: JavaScript에서는 함수도 값으로 사용될 수 있는 객체


따라서 JavaScript에서는 변수가 어떤 값을 가리킬 때, 그 값이 원시 데이터 타입이냐, 객체냐에 따라 동작 방식이 완전히 달라진다.


초기 값의 비교

메모리에 어떻게 값이 저장되는지 과정을 살펴 보자.

var p1 = 1;

위와 같이 p1이라는 변수를 선언 후, 이에 1이라는 값을 할당하면, 컴퓨터 내부적으로 다음과 같이 할당이 이루어지게 된다.

immutable-3


이 상태에서 p2라는 변수에 1이라는 값을 할당하자.

var p2 = 1;

이미 1이라는 값이 존재하므로, 아래와 같이 p2도 이미 있는 값을 가리키게 된다. (또 다른 1이라는 값을 생성할 때보다 메모리를 흥청망청 쓰지 않게 된다고…)

immutable-4


이 상태에서 p1p2는 같은 값을 가리킨다. 동등비교 연산자(같은 값일 때만 참)를 통해 확인해 보자.

console.log(p1 === p2); // true


여기서 메모리 상에 존재하는 1은 원시 데이터 타입에 속한다. 문자도 그렇고, 불리언 값 등 원시 데이터 타입에 속하는 값들이 모두 그렇다.

그렇다면 조금 더 복합적인 데이터 타입인 객체의 경우 어떻게 달라질지 확인해 보자.


name이라는 property의 값이 kim인 객체를 생성하자. 메모리 상 어딘가에 객체에 대한 정보가 저장된다. 그리고 o1이라는 변수가 이 객체를 가리키도록 하자.

var o1 = {name:'kim'};

immutable-5


이제 똑같은 객체를 만들고, o2라는 변수에 할당하자.

var o2 = {name: 'kim'}

이전에 원시 데이터 타입의 경우에는 값이 같으면 같은 곳을 가리킨다고 했으나, Object의 경우는 그렇지 않다. o2는 별도의 데이터를 새로 생성하고, 그 새로운 값을 가리킨다.

immutable-6

동등 비교 연산자를 통해 비교할 경우, false가 나온다. 각각이 각자의 데이터라는 의미이다.

console.log(o1 === o2); // false


이것이 의미하는 바가 무엇일까? 원시 데이터 타입의 경우, 더 이상 쪼갤 수가 없다. 언제나 같은 값을 의미한다. 17이라고 할 수는 없다. 그래서 이렇게 더 쪼갤 수 없는 데이터 타입의 경우, 불변한 데이터 타입이라고 한다.

그러나 객체의 경우는 객체 안에 여러 property가 있고, 그 property가 가리킬 수 있는 값이 바뀔 수 있다. 따라서 각 객체를 별도로 생성해서 따로 보관하는 특성이 있다(고 이고잉님도 추정한다). 어쨌든, 객체의 경우 값이 바뀔 수 있는 가변성을 갖고 있기 때문에, 같은 값을 할당하더라도 각자 다른 메모리에 있는 값을 가리킨다고 이해하자.

참고

위의 경우에서, o1.nameo2.name이 같은지 비교하면 같다고 나온다. 아마도 o1.name의 값과 o2.name의 값이 같은 문자열이고, 문자열은 원시 데이터 타입이라 같은 주소에 있기 때문일 듯?

console.log(o1.name === o2.name); // true


객체의 가변성

그렇다면 원시 데이터 타입과 객체의 경우, 값을 바꾸려 할 때 어떤 차이가 있는지 알아 보자.


p3라는 변수를 p1에 할당하자. p1이 가리키는 값은 원시 데이터 타입이고, 이 값은 바뀔 수 없다. 따라서 컴퓨터의 메모리에서는 다음과 같이 p3가 기존에 존재하는 1을 가리키도록 한다.

var p3 = p1;

immutable-7


이제 p3의 값을 2로 바꿔 보자.

var p3 = 2;

이 상태에서는 메모리 상에 2라는 값이 존재하지 않는다. 따라서 메모리 상 다른 어딘가에 2라는 값을 만들고, p3는 이제 새로 만들어진 2를 가리키게 된다.

immutable-8


원시 데이터 타입의 경우, 생성하는 시점에 값이 같을 때는 같은 값을 가리키다가, 값이 달라졌을 때에야 다른 값을 가리키게 된다. 반대로 객체는, 생성하는 시점에서 값이 같다고 하더라도 별도의 값을 만들어서 그 값들을 참조한다.

그래서 원시 데이터 타입은 필요할 때까지는 새로 값을 만들지 않는다. 그러나 객체는 생성할 때마다 새로운 값을 만든다.


여기서 객체는 원시 데이터 타입과 달리 값 자체를 property를 통해 바꿀 수 있다는 특성이 있다.

o3 변수가 o1 값을 가리키도록 해 보자.

var o3 = o1;

o3o1은 같은 값을 가리킨다.

immutable-9


이제 o3name 값을 lee로 바꿔 보자.

o3.name = 'lee'; // o3의 name의 값을 바꾼다.

그러면 o3가 가리키는 값이 그림에서처럼 바뀐다.

immutable-10

그런데 이 때, o1이라는 변수가 가리키는 값도 바뀐다. o3가 바뀌니 o1이 가리키는 데이터도 바뀐다. 의도한 것이라면 편리하지만, 의도하지 않았다면 문제가 생길 수 있다.


객체의 복사

따라서 원본 데이터를 건들지 않고, o3의 내용만 수정하고 싶다는 생각이 생긴다.Object.assign을 통해 객체를 immutable하게 다룰 수 있다.


이전의 상태에서 다시 시작한다.

var o1 = {name:'kim'};
var o2 = o1;

immutable-11


o2의 값을 수정할 때 o1이 가리키는 값이 바뀌는 문제를 방지하기 위해, o1이 갖는 값을 복사하고, 그 복사된 값을 수정하여 o2가 갖도록 한다.

var o1 = {name:'kim'};
var o2 = Object.assign({}, o1);
console.log(o1 === o2); // false

먼저, Object.assign을 사용한다. 빈 객체와 뒤에 나오는 객체들을 병합해서 하나의 객체로 만들어서 반환한다. 메모리 상에 o1과 똑같은 객체가 만들어지고, o2가 가리키는 값은 새롭게 만들어진 객체이다.

immutable-12

동등비교 연산자를 통해 o1o2가 같은지 확인해 보면, 새롭게 만들어진 객체이므로 다르다.


o2.name = 'lee';
console.log(o1, o2, o1 === o2); // {name: 'kim'} {name: 'lee'} false

이제 o2name을 바꿔 보자. o2가 가리키는 값만이 변경되고, 원본인 o1이 가리키는 값은 변경되지 않는다. 이를 통해 원본 데이터에 대해 불변함을 유지할 수 있고, 동시에 복제본의 변경을 통해 가변성을 달성할 수 있다.

immutable-13


중첩된 객체의 복사

중첩된(nested) 객체란, 객체를 구성하고 있는 property의 값 중 하나가 또 객체인 객체를 의미한다.


아래와 같이 중첩된 객체를 만들고 o1에 할당하자.

var o1 = {name: 'kim', score: [1, 2]};

이 때, score의 값인 [1, 2]라는 배열은 어떤 식으로 메모리에 저장될까?

immutable-14

원시 데이터 타입인 String은 그대로 저장되지만, score는 별도의 공간에 독립적으로 저장되고, score의 값은 그 배열의 위치를 저장한다. reference를 저장하고 있다고 한다.


자 이제, 위와 같이 불변성을 위치하면서 복사하기 위해 o2 객체를 만들면서, o1에서 복제해서 사용하고 싶다. 위에서 했던 것과 같이 다음과 같이 코드를 작성한다.

var o1 = {name: 'kim', score: [1, 2]};
var o2 = Object.assign({}, o1);
console.log(o1 === o2); // o1과 o2는 다른 값을 가리킨다.
console.log(o1.score === o2.score); // o1과 o2의 score는 같은 값을 가리킨다.

이 때 컴퓨터 내부적으로 메모리에 어떻게 값이 할당되는지를 보자.

immutable-15

Object.assign을 통해 복제하면, 그 객체의 property들만 복사한다. 그런데, 그 property 중 value가 Object형인 경우, 그 값이 아니라 그 위치(reference)만을 복제한다.


이 상태에서 배열의 내장함수 push를 이용해 score에 3이라는 값을 추가해 보자.

o2.score.push(3);

immutable-16

o2의 입장에서는 o2score의 값을 잘 수정한다. 그런데, 그 score가 가리키는 게 [1, 2]라는 값이 아니라, 그 배열의 주소이기 때문에, 그 주소에 있는 값이 바뀌어 버린다. 즉, o1score가 가리키고 있는 배열도 바뀌어서, o1의 값도 바뀐다는 것이다.


o1의 입장에서 값이 바뀌지 않도록 하려면, 어떻게 해야 할까? property의 값이 객체이고, 그 값을 수정할 때 원본에 영향이 가지 않도록 하려면, 그 객체까지도 복제해야 한다.

위와 같이 배열인 경우에는, push가 아니라 concat이라는 배열 내장함수를 사용하면 된다. concat은 애초에 새로운 배열을 만들어 반환하기 때문에 더 이상 같은 값을 가리키지 않게 된다.

o2.score = o2.score.concat(); // o2.score에 o2.score가 가리키는 값을 concat하여 할당한다.
console.log(o1.score === o2.score); // false: 이제 o1.score와 o2.score는 같은 값을 가리키지 않는다.

immutable-17


참고 : 배열의 복제

배열의 내장함수 pushconcat은 둘 다 같은 기능을 하지만, push는 원본을 바꾸고, concat은 원본을 복제하고, 거기에 인자로 들어온 값을 추가한다. 인자로 값을 주지 않을 경우 복제만 한다. 배열을 복제한다.

특별히 배열의 경우는 Object.assign을 쓰지 않고 복제한다. 일단 지금은 해당 함수를 써서 배열을 복제했을 때 배열이 갖고 있는 특수한 기능이 사라진다고 이해해 두자. 배열을 복제할 때는 concat, slice, array.from() 등의 함수를 사용해야 한다.


이제, 위와 같은 상태에서 o2.scorepush를 하면 o1이 가리키는 score의 배열은 바뀌지 않는다. 원본에 대한 불변성을 유지할 수 있게 된다.

o2.score.push(3); // o2.score가 가리키는 값 원본을 변경한다. 
// 그러나 이제 o1의 score가 가리키는 값과 다른 배열이기 때문에 괜찮다.

immutable-18



hit count image

댓글남기기