코어 자바스크립트(정재남 지음, 위키북스)를 읽고 정리한 내용입니다.

01 데이터 타입의 종류

자바스크립트의 데이터 타입은 크게 두 종류가 있다.

  1. 원시 타입(Primitie type)
    • 숫자(number), 문자열(string), 불리언(boolean), null, undefined, Symbol
  2. 참조 타입(Reference type)
    • 객체(Object), 배열(Array), 함수(Function), 날짜(Date), 정규표현식(RegExp), Map, WeakMap, Set, WeakSet

원시 타입과 참조 타입의 차이점은 ‘불변성(immutability)’ 에 있다.

02 데이터 타입에 관한 배경지식

불변성을 제대로 이해하기 위해 자바스크립트가 메모리 영역에서 어떻게 처리되는지 알아보자!

메모리와 데이터

컴퓨터가 메모리에 데이터를 저장하는 방법을 알아보기 이전에 이해해야할 부분은 아래와 같다.

0 또는 1만 표현할 수 있는 하나의 메모리 조각을 비트(bit)라고 한다. 각 비트는 고유한 식별자(unique identifier)를 통해 위치를 확인할 수 있다.

C/C++, 자바 등의 언어는 데이터 타입별로 할당할 메모리의 영역을 2바이트, 4바이트 등으로 정해놨다. 예를 들어, 정수형 타입(short)은 2바이트 크기이므로 표현할 수 있는 값의 범위가 0을 포함해 -32768 ~ +32767 이다. 만약 사용자가 위 범위에 벗어나는 숫자를 입력하는 경우 오류가 발생한다. 오류를 해결하기 위해서 사용자가 직접 4바이트 크기에 해당하는 정수형(int) 로 직접 형변환을 해줘야 한다.

자바스크립트가 등장할때는 보다 더 넉넉한 메모리 공간을 사용할 수 있게 되었다. 숫자의 경우 64비트, 즉 8바이트를 확보할 수 있게 됐다.

비트가 고유한 식별자로 위치를 확인할 수 있듯이 바이트는 ‘메모리 주솟갓(memory address)’ 를 통해 서로 구분하거나 연결할 수 있다.

식별자와 변수

변수와 식별자는 어떻게 다를까? 변수는 “변할 수 있는 데이터”, 식별자는 어떤 데이터를 식별하기 위해 사용하는 이름, 즉 변수명을 뜻한다.

03 변수 선언과 데이터 할당

변수 선언

변수란 변경 가능한 데이터가 담길 수 있는 공간 또는 그릇이라고 생각할 수 있다. 변수를 선언하면 컴퓨터는 메모리에서 비어있는 공간 하나를 확보하고, 이 공간의 이름을 식별자로 지정한다.

var a;

위의 변수 선언은 아래와 같이 메모리에 할당되는 것으로 개략적으로 표현할 수 있다.

주소 1002 1003 1004 1005
데이터     이름: a, 값:      

사용자가 a에 접근하고자 하면 컴퓨터는 메모리에서 a의 이름을 가진 주소를 검색하여 해당 공간에 담긴 데이터를 반환한다.

데이터 할당

하지만 실제로는 a라는 이름을 가진 주소에 데이터를 ‘직접’ 저장하지는 않는다. 데이터를 저장하기 위한 별도의 공간을 다시 확보한 뒤 데이터를 저장하고, 그 주소를 변수 영역에 저장하는 방식이다.

주소 1002 1003 1004 1005
데이터     이름: a, 값:@5004      
주소 5002 5003 5004 5005
데이터       ‘abc’    
  1. 변수 영역에서 빈 공간(@1003)을 확보한다.
  2. 확보한 공간의 식별자를 a로 지정한다.
  3. 데이터 영역의 빈 공간(@5004)에 문자열 ‘abc’를 저장한다.
  4. 변수 영역에서 a라는 식별자를 검색한다(@1003)
  5. 앞서 저장한 문자열의 주소(@5004)를 @1003의 공간에 대입한다.

왜 변수 영역에 데이터를 직접 대입하지 않고 한 단계 더 거쳐서 처리하는걸까? 만약 메모리 영역의 중간 데이터를 늘려야한다면 확보된 공간보다 커진 데이터를 처리하기 위해 컴퓨터가 처리해야 할 연산이 많아질 수 있다. 결국 변수와 데이터를 별도의 공간에 나누어 저장하는 것이 최적이다.

예를 들어, 500개의 변수에 대해 5라는 숫자를 할당하는 상황을 가정해보자. 각 변수를 별개로 인식하기 위해 500개의 변수는 각각의 저장 공간을 확보해야 한다. 500개의 변수에 5라는 숫자를 각각의 공간에 할당하기 위해서는 총 4000바이트가 필요한다. (변수 500개 * 숫자형의 크기 8바이트) 위의 방식 대신에 5라는 숫자 데이터를 한 공간에만 저장하고, 해당 주소만 사용한다면, 예를 들어 주소 공간의 크기가 2바이트라고 가정한다면 1008 바이트만 사용하면 된다. (데이터 영역에 저장: 변수 500개 * 주소 공간의 크기 2바이트 + 변수 영역에 저장: 숫자형의 크기 8바이트)

따라서, 변수 영역과 데이터 영역을 분리하면 중복된 데이터에 대한 처리 효율이 높아진다.

주소 1002 1003 1004 1005
데이터     이름: a, 값:@5005      
주소 5002 5003 5004 5005
데이터       ‘abc’ ‘abcdef’  

만약 위와 같이 식별자 a의 데이터에 ‘abcdef’를 재할당하는 경우 기존에 ‘abc’가 저장되었던 공간에 ‘abcdef’를 할당하는 것이 아니라, ‘abcdef’라는 문자열을 새로 만들어 별도의 공간에 저장하고, 새로 저장된 공간의 주소를 변수 공간에 연결한다. 기존 문자열에 어떤 변환을 가하든(문자를 추가 및 제거) 상관 없이 무조건 ‘새로’ 만들어서 별도의 공간에 저장한다.

04 기본형 데이터와 참조형 데이터

불변값

변수(variable)와 상수(constant)를 구분하는 성질은 ‘변경 가능성’이다. 그리고 이 변경 가능성은 ‘변수 영역 메모리’에 대한 변경 가능성을 의미한다. 한 번 데이터 할당이 이뤄진 변수 공간에 대해 다른 데이터를 재할당할 수 있는지의 여부에 의해 변경 가능성을 판단한다.

반면에 불변성의 여부는 ‘데이터 영역 메모리’ 가 변경 가능성의 대상이 된다.

원시 타입에 해당하는 숫자, 문자열, boolean, null, undeinfed, Symbol은 모두 불변값이다.

var a = 'abc';
a = a + 'def';
var b = 5;
var c = 5;
b = 7;

앞서 살펴봤듯이 변수 a에 ‘abc’를 할당했다가 ‘def’를 추가하면 기존의 ‘abc’가 ‘abcdef’로 바뀌는 것이 아니다. 새로운 문자열 ‘abcdef’가 데이터 영역에 저장되고 해당 데이터의 주솟값이 변수 a에 저장된다. 즉, ‘abc’와 ‘abcdef’는 완전히 별개의 데이터이다.

변수 b에 5를 할당한 후에 변수 c에 또 숫자 5를 할당하려고 한다. 컴퓨터는 데이터 영역에서 5를 찾는다. 이미 5라는 데이터가 저장된 공간이 있기 때문에 해당 주소를 재활용하여 변수 c에 저장한다.

변수 b의 값을 7로 변경하려고 한다. 마찬가지로 기존에 데이터 영역에 저장되었던 5가 7로 변경되는 것이 아니라, 숫자 7을 저장하기 위한 별도의 공간이 생긴다.

위 예제에서 살펴볼 수 있듯이 데이터 영역에 저장된 문자열 ‘abc’, 숫자 5 자체를 다른 데이터로 변경하는 것이 아니다. 이것이 바로 ‘불변성’ 이다. 한번 만들어진 값은 가비지 컬렉팅을 당하지 않는 한 영원히 변하지 않는다.

가변값

원시 타입 데이터는 모두 ‘불변값’이다. 그렇다면 참조 타입 데이터는 모두 가변값일까? 기본적인 성질은 가변적이지만, 설정에 따라 불가능한 경우도 있고(Object.defineProperty, Object.freeze 등), 아예 불변값으로 활용할 수 있는 방안도 있다.

참조형 데이터를 변수에 할당하는 과정은 아래와 같다.

var obj1 = {
  a: 1,
  b: 'bbb
}
           
주소 1001 1002 1003 1004
데이터   이름: obj1, 값: @5001      
주소 5001 5002 5003 5004
데이터 @7103 ~ ?   1 ‘bbb’  


객체 @5001의 변수 영역

           
주소 7103 7104 7105 7106
데이터 이름: a, 값: @5003 이름: b, 값: @5004      
  1. 컴퓨터는 우선 변수 영역의 빈 공간(@1002)을 확보하고, 그 주소의 이름을 obj1로 지정합니다.
  2. 임의의 데이터 저장 공간(@5001)에 데이터를 저장하려고 보니 여러 개의 프로퍼티로 이뤄진 데이터 그룹입니다. 이 그룹 내부의 프로퍼티들을 저장하기 위해 별도의 변수 영역을 마련하고, 그 영역의 주소(@7103 ~ ?)를 @5001에 저장합니다. 객체의 프로퍼티들을 저장하기 위한 메모리 영역은 크기가 정해져 있지 않고 필요한 시점에 동적으로 확보합니다.
  3. @7103 및 @7104에 각각 a와 b라는 프로퍼티 이름을 지정합니다.
  4. 데이터 영역에서 숫자 1을 검색합니다. 검색 결과가 없으므로 임의로 @5003에 저장하고, 이 주소를 @7103에 저장합니다. 문자열 ‘bbb’ 역시 임의로 @5004에 저장하고, 이 주소를 @7104에 저장합니다.

원시 타입과 참조 타입의 차이는 ‘객체의 변수(프로퍼티) 영역’이 별도로 존재한다는 점이다. 위 테이블에서 볼 수 있듯이 객체가 별도로 할애한 영역은 변수 영역이고, ‘데이터 영역’은 기존의 메모리 공간을 그대로 활용하고 있다. 앞서 말했듯이 데이터 영역의 값은 모두 불변값이다. 하지만, 변수에는 다른 값을 얼마든지 대입할 수 있다. 이것이 바로 참조 타입은 불변하지 않다 (가변적이다) 라는 의미이다.

참조형 데이터의 프로퍼티 재할당

var obj1 = {
  a: 1,
  b: 'bbb'
};
abj1.a = 2;

obj1의 a 프로퍼티에 숫자 2를 할당하려 한다. 데이터 영역에서 숫자 2를 검색한다. 검색 결과가 없으므로 빈 공간인 @5005에 저장하고, 이 주소를 @7103에 저장한다. obj1이 바라보고 있는 주소는 @5001로 변하지 않는다. 즉 ‘새로운 객체’가 만들어진 것이 아니라 기존의 객체 내부의 값만 바뀐 것이다.

             
주소 1001 1002 1003 1004 1005
데이터   이름: obj1, 값: @5001        
주소 5001 5002 5003 5004 5005
데이터 @7103 ~ ?   1 ‘bbb’ 2  
주소 7103 7104 7105 7106 7107
데이터 이름: a, 값: @5005 이름: b, 값: @5004        

참조형 데이터의 프로퍼티에 다시 참조형 데이터를 할당하는 경우는 어떨까? (이를 중첩 객체, nested object 라고 한다.)

var obj = {
  x: 3,
  arr: [3,4,5]
}
  1. 컴퓨터는 우선 변수 영역의 빈 공간(@1002)을 확보하고, 그 주소의 이름을 obj 로 지정합니다.
  2. 임의의 데이터 저장 공간(@5001)에 데이터를 저장하려고 보니 이 데이터는 여러 개의 변수와 값들을 모아놓은 그룹(객체)입니다. 이 그룹의 각 변수(프로퍼티)들을 저장하기 위해 별도의 변수 영역을 마련하고(@7103~?), 그 영역의 주소를 @5001에 저장합니다.
  3. @7103에 이름 x,를, @7104에 이름 arr를 지정합니다.
  4. 데이터 영역에서 숫자 3을 검색합니다. 없으므로 임의로 @5002에 저장하고, 이 주소를 @7103에 저장합니다.
  5. @7104에 저장할 값은 배열로서 역시 데이터 그룹입니다. 이 그룹 내부의 프로퍼티들을 저장하기 위해 별도의 변수 영역을 마련하고(@8104~?), 그 영역의 주소 정보(@8104~?)를 @5003에 저장한 다음, @5003을 @7104에 저장합니다.
  6. 배열의 요소가 총 3개이므로 3개의 변수 공간을 확보하고 각각 인덱스를 부여합니다.(0,1,2)
  7. 데이터 영역에서 숫자 3을 검색해서 (@5002) 그 주소를 @8104에 저장합니다.
  8. 데이터 영역에 숫자 4가 없으므로 @5004에 저장하고, 이 주소를 @8105에 저장합니다.
  9. 데이터 영역에 숫자 5가 없으므로 @5005에 저장하고, 이 주소를 @8106에 저장합니다.

만약 이 상태에서 다음과 같이 재할당 명령을 내리면 어떻게 될까?

obj.arr = 'str';

@5006에 문자열 ‘str’을 저장하고, 그 주소를 @7104에 저장한다. 그러면 @5003은 더이상 자신의 주소를 참조하는 변수가 하나도 없게 된다. (참조 카운트가 0이 된다.) 참조 카운트가 0인 메모리 주소는 가비지 컬렉터의 수거 대상이 된다. 가비지 컬렉터는 런타임 환경에 따라 특정 시점이나 메몬리 사용량이 포화 상태에 임박할 때마다 자동으로 수거 대상들을 수거한다. 수거된 메모리는 다시 새로운 값을 할당할 수 있는 빈 공간이 된다.

변수 복사 비교

원시 타입과 참조 타입의 메모리 영역에서의 동작 방식의 차이를 바탕으로 변수를 복사할 때의 변화를 알아보자.

var a = 10;
var b = a;
var obj1 = {
  c: 10,
  d: 'ddd'
};
var obj2 = obj1;

원시 타입 데이터의 복사 데이터 영역에 기존에 저장된 10이라는 값이 있으므로 10이라는 값을 저장한 공간의 주솟값을 b의 값으로 대입해준다.

참조 타입 데이터의 복사 데이터 영역에 기존에 저장된 obj의 값이 있기 때문에 그 값의 주솟값을 obj2의 값으로 대입해준다.

복사 과정은 동일하지만 데이터 할당 과정에서 이미 차이가 있기 때문에 변수 복사 이후의 동작에도 큰 차이가 발생한다. 아래는 객체의 프로퍼티 변경 예시이다.

var a = 10;
var b = a;
var obj1 = {
  c: 10,
  d: 'ddd'
};
var obj2 = obj1;

b = 15;
obj2.c = 20;

원시 타입 b의 값을 변경하면 각 식별자가 바라보던 ‘값’ 자체가 달라진다. 참조 타입 obj2의 프로퍼티 값을 변경하면 해당 식별자가 바라보던 값은 달라지지 않는다. 변수 obj1과 obj2는 여전히 같은 객체를 바라보고 있다. (obj2 객체 프로퍼티 값이 바라보는 대상은 달라진다.)

a !== b
obj1 === obj2

여기까지 읽으면서 알 수 있는 점은 자바스크립트의 모든 데이터 타입은 ‘참조형 데이터’라는 점이다.
원시 타입일지라도 변수에 데이터를 할당하기 위해 주솟값을 복사하기 때문이다. 다만, 원시 타입은 주솟값을 복사하는 과정이 한 번만 이뤄지고, 참조 타입은 한 단계를 더 거친다는 (기존의 데이터 영역을 사용하되 별도의 변수 영역이 새로 생긴다)는 차이가 있다.

만약 아래와 같이 객체 프로퍼티 값을 변경하는 것이 아니라, 객체 자체를 변경할때는 어떻게 동작할까?

var a = 10;
var b = a;
var obj1 = {
  c: 10,
  d 'ddd'
};
var obj2 = obj1;

b = 15;
obj2 = {
  c: 20,
  d: 'ddd'
};

메모리 영역의 새로운 공간에 새 객체가 저장되고 그 주소를 변수 영역의 obj2 위치에 저장한다. 즉, 참조형 데이터가 ‘가변값’이라고 설명할때의 ‘가변’은 참조형 데이터 자체를 변경할 경우가 아니라 그 내부의 프로퍼티를 변경할 때만 성립하는 것이다.

05 불변 객체

불변 객체를 만드는 간단한 방법

위의 예시를 통해 참조 타입이더라도 데이터 자체를 변경하고자 하면 원시 타입과 마찬가지로 기존의 데이터는 변하지 않는다는 것을 알 수 있었다. 이러한 특성을 이용해서 참조 타입도 불변성을 확보할 수가 있겠다. 그런데 참조 타입은 어떤 경우에 불변성이 요구될까? 값으로 전달받은 객체에 변경은 가하더라도 원본 객체는 변하지 않아야 하는 상황에 불변 객체가 필요하다.

객체의 가변성에 따른 문제점을 아래 코드로 파악해보자.

var user = {
  name: 'Eden',
  gender: 'female'
};

var changeName = function(user, newName){
  var newUser = user;
  newUser.name = newName;
  return newUser;
}

var user2 = changeName(user, 'Jung');

if(user !== user){
  console.log('유저 정보가 변경되었습니다.');
}

console.log(user.name, user2.name); // Jung, Jung
console.log(user === user2) // true

앞서 살펴본 복사된 객체의 프로퍼티를 변경하는 예시와 동일하다. user과 user2는 같은 주솟값을 바라보고 있다. 해당 주솟값을 통해서 접근할 수 있는 변수 영역에서 식별자 c는 특정 데이터 영역의 값을 저장하고 있을 것이다. 그리고, 새롭게 저장된 ‘Jung’ 이라는 데이터 영역의 주솟값으로 변경될 것이므로 기존의 user 객체의 프로퍼티 값도 변경되는 것이다.

객체의 가변성에 따른 문제점을 해결하기 위해 아래와 같이 코드를 작성할 수 있다.

var user = {
  name: 'Eden',
  gender: 'female'
};

var changeName = function(user, newName){
  return {
    name: newName,
    gender: user.gender
  };
};

var user2 = changeName(user, 'Jung');
if(user !== user){
  console.log('유저 정보가 변경되었습니다.');
}

console.log(user.name, user2.name); // Eden, Jung
console.log(user === user2) // false

즉, ‘새로운 객체’를 반환하도록 하면 된다.

얕은 복사와 깊은 복사

얕은 복사는 중첩된 객체에서 참조 타입 데이터가 저장된 프로퍼티를 복사할때 그 주솟값만 복사하는 의미이다. 이렇게 하면 해당 프로퍼티에 대해 원본과 사본이 모두 동일한 참조 타입의 주소를 가리키게 된다. 즉, 사본을 바꾸면 원본도 바뀌고, 원본이 바뀌면 사본도 바뀐다.

어떤 객체를 복사할때 객체 내부의 모든 값을 복사해서 완전히 새로운 데이터를 만들려고 한다면 객체의 프로퍼티 중에서 그 값이 원시 타입인 경우에는 그대로 복사하면 되지만, 참조 타입의 데이터는 다시 그 내부의 프로퍼티들을 복사해야 한다.

이렇게 해야만 데이터 자체를 통째로 복사하는 깊은 복사를 수행할 수 있다. 복사된 두 데이터는 독립적이므로 원본과 사본이 서로 영향을 주고 받지 않는다.

06 undefined와 null

undefined와 null 모두 값이 ‘없음’을 나타내지만, 사용하는 목적과 의미가 다르다.

자바스크립트는 아래와 같은 상황에서 undefined 를 반환한다.

var a;
console.log(a); // undefined 값을 대입하지 않은 변수, 즉 데이터 영역이 메모리 주소를 지정하지 않은 식별자에 접근할때

var obj = {a: 1};
console.log(obj.a); // 1
console.log(obj.b); // 객체 내부의 존재하지 않는 프로퍼티에 접근하려고 할때
console.log(b); // ReferenceError: b is not defined

var func = function(){};
var c = func(); // 반환(return)값이 없으면 undefined를 반환한 것으로 간주
console.log(c); // undefined

배열에 대해서는 조금 특이한 동작을 확인할 수 있다.

var arr1 = [];
arr1.length = 3;
console.log(arr1); // [empty × 3] 배열에 3개의 빈 요소를 확보했지만, 확보된 각 요소에는 어떠한 값도, 심지어 undefined도 할당돼 있지 않음을 의미한다.

var arr2 = new Array(3);
console.log(arr2); [empty × 2]

var arr3 = [undefined, undefined, undefined];
console.log(arr3); // [undefined, undefined, undefined]

‘비어있는 요소’와 ‘undefined를 할당한 요소’의 출력 결과는 다르다. ‘비어있는 요소’는 순회와 관련된 많은 배열 메서드들의 순회대상에서 제외된다. 자바스크립트의 배열도 결국에는 객체이기 때문에 존재하지 않는 프로퍼티에 대해 순회할 수 없는 것은 당연하다.

이런 점을 고려했을때 사용자가 명시적으로 부여한 undefined는 ‘비어있음’을 의미하는 것은 맞지만, 프로퍼티나 배열의 요소는 고유의 키값이 실존하게 되고, 따라서 순회의 대상이 될 수 있다. 하지만 자바스크립트에서 반환해준 undefined는 해당 프로퍼티 및 배열의 키값 자체가 존재하지 않음을 의미한다. 값으로써 어딘가에 할당된 undefined 는 실존하는 데이터인 반면, 자바스크립트 엔진이 반환해준 undefined는 문자 그대로 값이 없음을 나타낸다.

이처럼 사용자가 명시적으로 사용한 undefined와 자바스크립트 엔진이 반환해준 undefined 는 각각 다르게 동작할 수 있으므로 혼란을 가중시킬 수 있다.

따라서, ‘비어있음’을 명시적으로 나타내고 싶을 때는 undefined 대신에 null 을 사용하는 것을 권장한다. 이렇게 하면 undefined 는 오직 ‘값을 대입하지 않은 변수에 접근하고자 할때 자바스크립트 엔진이 반환해주는 값’으로서만 존재할 수 있기 떄문이다.

null과 관련하여 기억해야할 부분은 null의 타입이 Object 라는 점이다. (두둥!) 따라서, 어떤 변수의 값이 null인지의 여부를 판별하기 위해서는 typeof 대신 다른 방식(일치 연산자)을 사용해주는 것이 바람직하다.

var n = null;
console.log(typeof n); // object

console.log(n == undeinfed); // true null == undefined!
console.log(n == null); // true

console.log(n === undefined); // false
console.log(n === null); // true

07 정리

자바스크립트의 데이터 타입은 원시 타입, 참조 타입과 같이 크게 두가지로 나뉜다. 기본적으로 원시 타입은 불변값이고, 참조 타입은 가변값이다. (이때 불변값이라는 것은 데이터 영역의 가변성을 의미한다.)

원시 타입과 참조 타입의 차이점인 ‘불변성’ 은 주솟값을 복사하는 과정에서 확인할 수 있었다. 원시 타입은 주솟값을 복사하는 과정이 ‘한 번만’ 이루어진다. 따라서, 원시 타입에 새로운 데이터를 재할당하는 경우에 새로운 데이터 공간이 새로 생기고(해당 데이터 검색 결과가 없었을 경우), 해당 변수는 새로운 주솟값을 바라보게 된다. 참조 타입의 경우 기존의 데이터 영역은 동일하게 사용하되, 별도의 변수 영역이 새로 생긴다. 따라서, 별도의 변수 영역에서 값의 변경이 발생하는 경우 (객체 프로터티의 값을 변경하는 경우) 기존에 해당 식벽자가 바라보던 주솟값은 달라지지 않고, 프로퍼티 값만 변경되는 것이다. 하지만, 깊은 복사를 하거나, 라이브러리를 사용하여 참조 타입임에도 불변값으로 사용할 수 있는 방법도 있다.

없음을 나타내는 값은 null, undefined가 있는데 자바스크립트 엔진이 반환하는 undefined와 사용자가 명시적으로 반환한 undefined 는 서로 다르게 동작하므로 혼란을 야기할 수 있기 때문에 명시적으로 값이 없다는 것을 표현하기 위해서는 null을 사용하는 것이 권장된다.

댓글남기기