데이터 타입

기본형과 참조형 타입이 서로 다르게 동작하는 이유를 중점으로 보자.

데이터 타입의 종류

데이터 타입에는 크게 두 가지가 있다.

  1. 기본형 : Number, String, Boolean, null, undefined, Symbol
  2. 참조형 : Object, Array, Function, Date, RegExp, Map, WeakMap, Set, WeakSet

굵게 칠해진 타입은 ES6에서 추가된 타입이다.

기본형과 참조형을 구분하는 기준은 무엇인가?

기본형은 값이 담긴 주솟값을 바로 복제하지만, 참조형은 값이 담긴 주솟값들로 이루어진 묶음을 가리키는 주솟값을 복제한다는 점이 다르다.

개발자분들이 기본형은 불변성을 띄지 않는다고 생각하는 경우도 있다. 그러나 기본형 타입은 불변성을 띈다.

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

  • 변수 : 변할 수 있는 무언가.
  • 식별자 : 어떤 데이터를 식별하는 데 사용하는 이름(변수명).

변수 선언과 데이터 할당

변수 선언

var a;

위의 코드를 해석하게 되면 변할 수 있는 데이터를 만들었다. 이 데이터의 식별자는 a로 한다.이다. 이렇게 보면 변수는 변경 가능한 데이터가 담길 수 있는 공간 또는 그릇이다.

데이터 할당

var a; // 변수 선언
a = 'abc'; // 변수 a에 데이터 할당

var a = 'abc'; // 변수 선언과 할당을 한 문장으로 표현

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

불변값

변수와 상수를 구분하는 성질은 변경 가능성이다. 바꿀 수 있으면 변수, 바꿀 수 없으면 상수라는 것이다.

상수와 불변성은 같은 개념이 아니다.

변수와 상수를 구분 짓는 변경 가능성의 대상은 변수 영역 메모리다. 한 번 데이터 할당이 이루어진 변수 공간에 다른 데이터를 재할당할 수 있는지가 관건이다. 반면, 불변성 여부를 구분할 때의 변경 가능성의 대상은 데이터 영역 메모리다.

결국, 기본형 데이터인 Number, Strubg, Boolean, null, undefined, Symbol 모두 불변값이다.

var a = 'abc';
a = a + 'def';

var b = 5;
var c = 5;
b = 7

변수 a에 문자열 abc를 할당했다가 뒤에 def를 추가하면 기존의 abcabcdef로 바뀌는 것이 아니라 새로운 문자열 abcdef를 만들어 그 주소를 변수 a에 저장한다.

abcabcdef는 완전히 별개의 데이터다.

가변값

참조형 데이터는 가변값이다.

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

기본형 데이터와의 차이는 객체의 변수(프로퍼티) 영역이 별도로 존재한다는 점이다.

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

obj1.a = 2;

위와 같이 obj1a에 값을 재할당하게 되면 2가 들어갈 공간을 새로 만들어서 데이터를 넣어주고, 식별자 a 값에 새로 만든 2가 들어가 있는 공간의 주솟값을 넣게 된다. 이렇게 되면 a는 값만 바뀌는 것이다.

참조 카운트가 0이 되는 메모리 주소는 가비지 컬렉션의 수거 대상이 된다. 가비지 컬렉터는 런타임 환경에 따라 특정 시점이나 메모리 사용량이 포화 상태에 임박할 때마다 자동으로 수거 대상들을 수거한다. 수거된 메모리는 다시 새로운 값을 할당할 수 있는 빈 공간이 된다.

변수 복사 비교

기본형과 참조형의 복사를 비교해보자.

var a = 10;
var b = a;

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

먼저 a10이 들어가 있는 데이터 영역의 주소를 값으로 가진다. b는 이런 a를 값을 그대로 가져왔으므로 같은 값을 가지게 된다.

참조형도 같다. obj1에 들어가는 데이터 영역과 변수 영역의 주솟값을 obj1의 값으로 넣게 되고 obj2는 obj1의 값을 똑같이 가지게 된다.

여기서 중요한 건 값을 바꿀 때 일어난다.

var a = 10;
var b = a;

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


b = 15
obj2.c = 20;

b에 새로운 값을 할당하려고 한다. 그렇다면 데이터 영역에 값이 15인 값이 있는 곳을 찾고 없다면 새로운 공간을 만들며 해당 공간의 주솟값을 b에 재할당한다.

obj2.c의 값을 재할당하게 되면 객체의 변수 영역인 c의 공간의 값을 재할당하는 일이 이루어진다. 그러나 obj2 자체의 값을 바뀌지 않는다. 이같이 되면 obj1 값과 obj2의 값은 여전히 동일하다.

console.log(a !== b) // true
console.log(obj1 === obj2) // true

일반적으로 '기본형도 결국 주솟값을 참조한다'

객체 자체를 변경하는 경우

var a = 10;
var b = a;

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


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

위와 같이 obj2의 새로운 객체를 할당했다. 이렇게 되었을 경우 obj1obj2를 비교하게 되면 같지 않다고 나온다.

불변 객체

객체의 가변성에 따른 문제점이 있다.

var user = {
  name: 'josh',
  gender: 'male'
}

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

var user2 = changeName(user, 'snyung')

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

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

위와 같은 문제점이 발생하였다. 이름을 바꾸었지만 실제로 객체의 값은 변하지 않아서 변하지 않은 것으로 인식하며, 객체의 값도 같게 된다.

위의 코드를 해결해보자.

var user = {
  name: 'josh',
  gender: 'male'
}

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

var user2 = changeName(user, 'snyung')

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

console.log(user.name, user2.name); // josh snyung
console.log(user === user2) // false

반환값으로 새로운 객체를 만들어서 반환하도록 수정되었다. 이게 두 개의 객체의 값을 다르게 되어 정보가 바뀌었다고 인식을 하게 된다.

그러나 우리가 새로운 객체를 만들면서 변경할 필요가 없는 기존 객체의 프로퍼티를 하드코딩하고 있다. 이런식으로 작성하게 되면 정보가 많을수록 변경해야하는 정보가 늘어나게 된다. 그냥 모든 프로퍼티를 복사하는 함수를 만드는 것이 좋아 보인다.

var copyObjec = function (target){
  var result = {};
  for (var prop in target){
    result[prop] = target[prop];
  }
  return result;
};
var user = {
  name: 'josh',
  gender: 'male'
}

var user2 = copyObjec(user)
user2.name = 'snyung'

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

console.log(user.name, user2.name); // josh snyung
console.log(user === user2) // false

우리가 만든 함수는 위와 같은 예제에서는 제대로 작동하고 있다. 그러나 얕은 복사만 수행한다는 점이 아쉽다.

얕은 복사와 깊은 복사

얕은 복사는 바로 아래 단계의 값만 복사하는 방법이며, 깊은 복사는 내부의 모든 값을 하나하나 찾아서 전부 복사하는 방법이다.

얕은 복사를 하게 되면 한 단계 아래의 값들은 새로 만들어지기 때문에 불변성을 보장한다. 그러나 2단계 이상으로 들어가게 되면 기존의 데이터를 그대로 참조하고 있게 된다.

var user2 = copyObject(user);
user2.urls = copyObject(user.urls);

user.urls.profile = 'http://protfolio.com';
console.log(user.urls.protfolio = user2.urls.protfolio); // false

user2.urls.blog = '';
console.log(user.urls.blog === user2.urls.blog); // false

위의 결과를 보게 되면 모든 값을 새로 만들어서 할당하였더니 값이 다 다르다고 나오게 된다. 이에 우리는 기본형 데이터일 경우에는 그대로 복사하고, 참조형 데이터는 다시 그 내부의 프로퍼티들을 복사해야 한다는 것을 알게 되었다.

var copyObjectDeep = function(target) {
  var result = {};
  if (typeof target === 'object' && target != null){
    for (var prop in target){
      result[prop] = copyObjectDeep(target[prop])
    }
  } else {
    result = target
  }

  return result;
}

객체인 경우 재귀적으로 함수를 재귀적으로 호출하며, 객체가 아닌 경우 원본을 그대로 넣어주고 있다.

위와 같이 재귀적으로 호출하는 방식으로 DeepCopy를 할 수 있지만, JSON 객체로도 가능하다.

var copyObjectViaJSON = function (target){
  return JSON.parse(JSON.stringify(target))
}

undefined와 null

자바스크립트에서, 없음을 나타내는 값은 두 가지가 있다.

undefined는 사용자가 명시적으로 지정할 수도 있지만, 값이 존재하지 않을 때 자바스크립트 엔진이 자동으로 부여하는 경우도 있다.

자바스크립트 엔진이 undefined를 반환하는 경우는 아래와 같이 세 가지 경우다.

  • 값을 대입하지 않는 변수. 즉 데이터 영역의 메모리 주소를 지정하지 않는 식별자에 접근할 때
  • 객체 내부의 존재하지 않는 프로퍼티에 접근하려고 할 때
  • return 문이 없거나 호출되지 않는 함수의 실행 결과
var a;
console.log(a); // undefined

var obj = {a:1};
console.log(obj.a); // a
console.log(obj.b); // undefined
console.log(b); // Uncaught ReferenceError: b is not defined

var func = function() {};
var c = func();
console.log(c); // undefined

값을 대입하지 않는 경우에 대해 배열의 경우에는 조금 다르다.

var arr1 = [];
arr1.length = 3
console.log(arr1); // [empty × 3]

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

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

위의 결과를 보게 되면 1, 2는 같은 결과를 출력하나 undefined로 만들어서 출력한 것을 다르다는 것을 알 수 있다. 이처럼 비어있는 요소와 undefined를 할당한 요소는 출력 결과부터 다르다.

비어있는 요소는 순회와 관련된 많은 배열 메서드들의 순회 대상에서 제외된다.

var arr1 = [undefined, 1];
var arr2 = [];
arr2[1] = 1;

arr1.foreach(function(v, i) { console.log(v, i); });
arr2.foreach(function(v, i) { console.log(v, i); });

arr1.map(function (v, i) { return v+i; });
arr2.map(function (v, i) { return v+i; });

arr1.filter(function (v, i) { return !v; });
arr2.filter(function (v, i) { return !v; });

arr1.reduce(function (p, c, i) { return p + c + i;}, '');
arr2.reduce(function (p, c, i) { return p + c + i;}, '');

위의 결과가 다르게 나온다. 직접 undefined를 할당한 arr1에 대해서는 일반적으로 알고 있는 배열의 모든 요소를 순회해서 결과를 출력한다. 그러나 arr2에 대한 결과를 보면, 각 메서드들이 비어있는 요소에 대해서는 어떠한 처리도 하지 않고 건너뛰었다.

null은 주의해야 할 점이 있다. typeof nullObject라는 것이다. 이는 자바스크립트 자체 버그이다. 따라서 어떤 변수의 값이 null인지 여부를 판별하기 위해서는 typeof 대신 다른 방식으로 접근해야 한다.

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

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

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

동등 연산자로 비교할 경우 nullundefined가 서로 같다고 판단한다. 따라서 어떤 변수가 실제로 null인지 아니면 undefined인지는 동등 연산자(==)로 비교해서는 알 수 없다. 일치 연산자(===)를 써야만 정확히 판별할 수 있다.

Reference