[Core JavaScript] 1. 데이터 타입

Fe·2022년 8월 27일
0

Core Javascript

목록 보기
1/3
post-thumbnail

코어 자바스크립트를 읽고 공부하면서 이해하고 정리한 내용들을 기록으로 남기려 한다.

이 책을 고른 이유

  1. ES6에서 새롭게 등장한 내용뿐 아니라 ES5이하에서 중요한 내용들도 함께 설명한다.
  2. 동작 원리에 입각해서 깊이 있게 설명한다.
  3. 책의 분량이 부담스러울 정도로 많지 않다.

여러 책으로 자바스크립트를 공부하다 보니 책의 분량이 상당히 중요하다는 것을 깨닫게 되었다. 아무리 양질의 내용이어도 너무 많은 양을 담고 있다면 중간에 그만 둘 확률이 높다. 그런 점에서 이 책은 내용도 괜찮고 양도 적당하다. velog에서도 코어 자바스크립트 관련 스터디 글을 여럿 보았다.


데이터 타입


크게 두 가지로 분류된다. 기본형(원시형, primitive type)참조형(reference type)으로 분류되며 참조형은 객체(Object)가 있으며 나머지는 객체의 하위 분류에 속한다.

그렇다면 무슨 기준으로 이 둘을 나눈 것일까?

할당이나 연산 시,

  • 값이 담긴 주소값을 바로 복제한다: 기본형
  • 값이 담긴 주소값들로 이루어진 묶음을 가리키는 주소값을 복제한다: 참조형

이런 차이가 있으며, 기본형은 불변성(immutability)을 띈다. 이는 아래에서 살펴볼 것이다.


관련 배경지식

C/C++, Java 등의 정적 타입 언어는 메모리 낭비를 최소화하기 위해 데이터 타입 별로 할당되는 메모리 크기를 정해놓았다. (char, int, float, long 등)
하지만 자바스크립트는 이들에 비해 메모리 관리에 대한 압박으로부터 자유롭다. 숫자의 경우 정수형과 부동소수형을 구분하지 않고 8비트를 할당한다.

이런 모든 데이터는 메모리 주소값을 통해 서로 구분되고 연결된다.

변수 vs 식별자

변수와 식별자를 혼동해서 쓰는 경우가 많다.
변수: 변경 가능한 데이터가 담길 수 있는 공간이나 그릇
식별자: 어떤 데이터를 식별하는 데 사용하는 이름(즉, 변수명)

단어 자체의 뜻을 생각하면 쉽게 이해할 수 있다. 사람들이 이름으로 서로를 식별할 수 있는 것처럼 변수도 변수명으로 데이터를 식별한다.


데이터 할당

컴퓨터가 명령을 받아 메모리 영역에서 어떤 작업을 수행하는지 알아보자.

변수 선언과 접근

var a;

이를 말로 풀어서 쓰면, 변할 수 있는 데이터를 만든다. 데이터의 식별자는 a로 한다.가 된다.

  1. 메모리에서 비어있는 공간을 하나 확보한다.
  2. 공간의 이름을 a라고 지정한다.

여기까지가 변수 선언 과정이다.

만약 a에 접근하려고 한다면, 컴퓨터는 메모리에서 a라는 이름을 가진 주소를 검색해 해당 공간에 담긴 데이터를 반환할 것이다.

변수에 데이터 할당

var a;
a = 'abc';

var a = 'abc';

두 방법 모두 a라는 이름을 가진 주소를 검색해 그곳에 문자열 'abc'를 할당하는 코드이다.

하지만 실제로는 a 이름의 주소에 데이터가 직접 저장되지는 않는다. 데이터를 저장하는 별도의 공간을 확보해서 그곳에 데이터를 저장한다.

변수 선언 과정 이후에

  1. 데이터 영역의 빈 공간(@5004)에 문자열 'abc'를 저장한다.
  2. 변수 영역에서 a 식별자를 검색한다.
  3. 저장한 문자열의 주소 @5004를 @1003의 값에 대입한다.

과정까지가 데이터 할당 과정이다.

그렇다면 왜 데이터만을 저장하는 공간을 따로 두는 것일까?

이유는 다음과 같다.

  • 데이터 변환을 자유롭게 할 수 있다.
  • 메모리를 더욱 효과적으로 관리할 수 있다.

자바스크립트의 문자열은 8바이트로 정해진 숫자형과 다르게 길이가 가변적이므로 미리 확보한 공간 내에서만 데이터 변환이 가능하다면 불필요한 연산이 늘어난다. 가령 메모리 공간 중간에 있는 데이터를 늘려야 한다면 해당 공간보다 뒤쪽에 있는 모든 데이터를 한 칸씩 미루고, 다시 주소값을 연결시켜야 한다. 이와 같은 일을 방지할 수 있기 때문에 변수와 데이터를 별도로 저장하는 것이 효율적이다.

변수의 데이터 변경

지금 저장되어 있는 'abc' 뒤에 'def'를 덧붙이고 싶거나, 마지막 c를 제거해서 'ab'를 만들고 싶을 때가 있을 것이다. 이럴 때 자바스크립트는 'abc'가 저장되어 있는 공간에 바뀐 값을 할당하는 대신 'abcdef', 'ab'라는 문자열을 새로 만들어 데이터 영역에 저장하고 그 주소를 변수 공간에 대입한다.

기존 문자열을 어떻게 바꾸든 상관없이 항상 새로운 문자열을 만들어서 별도의 영역에 저장한다.
@1003은 이제 더이상 @5004를 가리키고 있지 않다. @5004 입장에서 자신의 주소를 저장하는 변수가 하나도 없게 되면 가비지 컬렉터(Garbage Collector)의 수거 대상이 된다.

한 가지 예시로 500개의 변수에 5를 할당하는 상황을 들어보자. 만약 변수 영역과 데이터 영역이 분리되어 있지 않다면, 8바이트 공간을 500개 확보해야 한다. 이 경우 4000바이트가 필요하다.
만약 분리되어 있다면, 5를 한 번만 저장하고 그 주소값을 500번 가져다 사용하면 된다. 주소값이 2바이트라고 한다면 8+500*2=1008바이트만 필요하다. 이렇듯 변수 영역과 데이터 영역이 분리되면 중복 데이터에 대한 처리 효율이 높아진다.


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

불변값

앞에서 기본형 데이터는 불변성을 띄는 불변값이라고 언급했다. 불변값? 변하지 않는 값이라는 뜻이기에 우리가 아는 상수(constant)와 불변값이 같은 개념이라고 생각할 수 있다. 그렇지 않다.
변수와 상수를 구분하는 성질은 '변경 가능성'이다. 변수와 상수를 구분하는 변경 가능성은 변수 영역 메모리를 대상으로 한다. 반면에 불변성 여부를 구분하는 변경 가능성은 데이터 영역 메모리를 대상으로 한다.

기본형 데이터에는 숫자, 문자열, boolean, null, undefined, Symbol이 있는데, 이들은 모두 불변값이다.

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

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

위 코드의 1~2번째 줄에서는 바로 위 '변수의 데이터 변경'에서 설명했듯 데이터 영역에 저장되어 있는 'abc'가 'abcdef'로 바뀌는 것이 아니라 새로 만들어서 그 주소를 변수 a에 저장했다. 따라서 'abc'와 'abcdef'는 완전히 다른 데이터가 된다.

4번째 줄의 동작 과정은 다음과 같다.

  1. b를 할당한다.
  2. 데이터 영역에서 5를 찾고 없으므로 데이터 공간을 하나 만들어 5를 저장한다.
  3. 5를 저장한 주소를 b에 대입한다.

5번째 줄은 다음처럼 동작한다.

  1. c를 할당한다.
  2. 데이터 영역에서 5를 찾는데 윗 줄에서 저장한 5가 있으므로 그 주소를 재활용한다.

6번째 줄은 b의 값을 7로 바꾸는 과정이다.

  1. 데이터 영역에서 7을 찾는데 없으므로 데이터 공간을 하나 만들어 7을 저장한다.
  2. 7을 저장한 주소를 b에 대입한다.

이렇듯 한 번 만든 값은 다른 값으로 변경되지 않는다. 변경은 새로 만드는 과정을 통해서만 이루어진다. 변수의 값을 바꾸더라도 데이터 영역 어딘가에는 이전에 할당했던 값이 그대로 남아있다.

가변값

참조형 데이터를 할당하는 경우

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

  1. 변수 영역의 빈 공간 @1002를 확보하고 이름을 obj1로 지정한다.
  2. 데이터 영역에 데이터를 저장하려고 하는데 여러 개의 프로퍼티로 이루어진 데이터 그룹이다. 프로퍼티들을 저장하기 위해 별도의 변수 영역을 마련하고 그 주소(@7103~?)를 @5001에 저장한다.
  3. @7103과 @7104에는 각각 이름으로 a, b가 지정된다.
  4. 데이터 영역에서 1을 검색하고 없으므로 임의로 @5003에 저장한 뒤 이 주소를 @7103에 저장한다. 'bbb' 역시 데이터 영역에 없으므로 @5004에 저장한 뒤 이 주소를 @7104에 저장한다.

기본형 데이터와의 차이는 객체의 변수(프로퍼티) 영역이 별도로 존재한다는 점이다. 하지만 위 그림을 보면 객체가 별도로 둔 영역은 데이터 영역이 아니라 변수 영역이다. 데이터 영역은 기존의 공간을 그대로 쓰고 있다. 데이터 영역의 값들은 모두 불변값이다. 하지만 객체의 변수 영역에는 다른 값을 얼마든지 대입(주소를 저장)할 수 있다. 이 때문에 참조형 데이터는 가변값이라고 하는 것이다.

참조형 데이터의 프로퍼티를 재할당하는 경우

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

마지막 줄을 살펴보자. 데이터 영역에서 2를 검색하는데 없으므로 @5005에 새로 만들고 그 주소를@7103에 저장한다. 이때 obj1이 가리키고 있는 주소는 @5001로 변하지 않았다. 따라서 새로운 객체가 만들어진 것이 아닌 기존의 객체 내부의 값만 바뀐 것이라는 것을 알 수 있다.

중첩 객체의 프로퍼티를 할당하는 경우

중첩 객체(nested object)란 참조형 데이터의 프로퍼티에 다시 참조형 데이터를 할당하는 경우를 말한다.

var obj = {
  x: 3,
  arr: [3, 4, 5]
};

할당 과정은 앞과 다른 것이 없다.

  • 저장해야 하는 값이 데이터 그룹이라면 별도의 변수 영역을 마련하고 그 영역의 주소를 데이터 영역에 저장한다.
  • 그리고 프로퍼티의 개수만큼 객체의 변수 영역에 할당해주고, 값은 데이터 영역에 저장한 뒤 그 주소값을 객체의 변수 영역에 저장한다.
  • 값이 데이터 영역에 있다면 주소를 재활용하고, 없다면 새로 만들어서 주소값을 가져온다.

위 코드의 경우 데이터 그룹이 2개(obj, arr)이므로 객체의 변수 영역이 2개 만들어진다. 데이터 그룹이 더 많아져도 원리는 같다.

obj.arr[1]을 검색하려고 하면 다음 과정을 거친다.
@1002 -> @5001 -> (@7103~?) -> @7104 -> @5003 -> (@8104~?) -> @8105 -> @5004 -> 4

여기서 값을 재할당한다면?

obj.arr = 'str';

데이터 영역에 'str'이 없으므로 @5006에 'str'를 저장하고 그 주소 @5006을 @7104에 저장한다. 그러면 @5103은 더 이상 자신을 참조하는 변수가 하나도 없게 된다. 어떤 데이터에 대해 자신의 주소를 참조하는 변수의 개수참조 카운트라고 한다. @5103은 참조 카운트가 1이었다가 'str'로 재할당하는 순간 0이 되는데, 참조 카운트가 0인 메모리 주소는 가비지 컬렉터의 수거 대상이 된다. 특정 시점이나 메모리 사용량이 포화에 임박하면 자동으로 수거된다. 수거된 메모리는 다시 빈 공간이 된다.
@5103이 가리키고 있는 @8104~@8106도 수거 대상이 되어 함께 사라질 것이다.

변수 복사 비교

이번엔 변수를 복사할 때 기본형 데이터와 참조형 데이터의 차이를 확인해보자.

var a = 10;
var b = a;

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

변수를 복사할 때 기본형 데이터와 참조형 데이터 모두 같은 주소를 바라보게 되는 점에서는 동일하다. 하지만 데이터 할당 과정이 애초에 다르기 때문에 이후의 동작에서 큰 차이가 발생한다.

객체의 프로퍼티를 변경하는 경우

b = 15;
obj2.c = 20;

객체의 프로퍼티를 변경하는 경우이다.
먼저 데이터 영역에 15가 없으므로 @5004에 저장하고, 그 주소를 들고 변수 영역에서 b를 찾는다. @1002의 값이 @5004로 바뀐다.
다음으로 데이터 영역에 20이 없으므로 @5005에 저장하고, 주소를 들고 obj2를 찾고 obj2의 값인 @5002가 가리키는 변수 영역에서 c를 찾아 그곳에 @5005를 저장한다. 주소를 따라가다 보면 전혀 어렵지 않다.

결과적으로 a와 b가 가리키는 주소는 각각 @5001, @5004로 달라졌지만, obj1과 obj2가 가리키는 주소는 @5002로 달라지지 않았다. 즉, a!==b, obj1===obj2이다.
obj2.c는 내부 프로퍼티를 바꿨고 b는 변수 자체를 바꿨으므로 어떻게 보면 당연한 일이기도 하다. 이번에는 같은 조건 하에서 비교해보자.

객체 자체를 변경하는 경우

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

이번에는 obj2 자체를 바꿔보자. 데이터 그룹을 만나는 상황이므로 데이터 영역의 새 공간에 새 객체가 저장되고 바뀐 주소가 obj2에 저장될 것이다.
이번 경우에서는 a와 b뿐 아니라 obj1과 obj2도 서로 가리키는 주소가 달라졌다.

여기서 결론! 참조형 데이터가 '가변'값이라 하는 것은 객체 자체를 변경하는 것이 아닌 객체 내부의 프로퍼티를 변경할 때에만 해당된다.


불변 객체 만들기

다시 정리를 하고 넘어가자.

  • 기본형 데이터는 불변값이다. -> 불변
  • 객체의 내부 프로퍼티 값을 변경할 때에는 변수에 다른 값을 넣는다. -> 가변
  • 객체 자체를 변경하려 하면 변수의 값을 바꾸는 것이 아닌 데이터 그룹을 새로운 공간에 할당하고 결국 객체가 가리키는 주소가 바뀌는 것이기 때문에 기존 데이터는 변하지 않는다. -> 불변

그렇다면 내부 프로퍼티를 변경하는 경우만 불변하도록 만들면 우리는 불변 객체를 사용할 수 있다.

어떻게 불변성을 확보할 수 있을까?

  • 내부 프로퍼티 변경 시에도 매번 새로운 객체를 만들기로 규칙을 정한다.
  • immutable.js, immer.js 등의 자동으로 새로운 객체를 만드는 라이브러리를 활용한다.

아니면 불변성이 필요할 때에만 불변 객체로 취급해주는 방법도 있다. 이번에는 매번 새로운 객체를 만드는 방법을 살펴보자.

다음 코드는 객체의 가변성 때문에 문제가 생긴다.

var user = {
  name: 'Jaenam',
  gender: 'male'
};

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

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

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

이름이 바뀌면 유저 정보가 변경되었다는 알림을 주고 싶었는데, 그 부분은 출력되지 않는다. 맨 아래 두 줄에서 확인해본 것처럼 user과 user2는 서로 같은 이름을 가리키고 있다. changeName 함수가 user 객체의 프로퍼티 값을 바꾼 것을 알 수 있다.(같은 주소값을 가리키는 상태.)

이 예시처럼 정보가 바뀔 때 알림을 보내야 하거나, 변경 이전과 이후를 함께 보여줘야 하는 경우가 있을 수 있으므로 ,changeName 함수가 기존 객체를 건드리지 않고 새로운 객체를 만들도록 코드를 바꿔보자.

var user = {
  name: 'Jaenam',
  gender: 'male'
};

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

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

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

함수 부분만 코드를 바꿨고, 나머지는 그대로다. 정보가 제대로 변경되었음을 확인했다.

하지만 이름만 바뀌는 것인데, gender를 설정하는 부분은 일일이 입력했다. 프로퍼티가 아주 많아진다면 전부 코딩하는 것은 시간 낭비다. 모든 프로퍼티를 복사하는 함수를 만들어보자.

얕은 복사 함수

var copyObject = function (target) {
  var result = {};
  for (var prop in target) {
    result[prop] = target[prop];
  }
  return result;
}

copyObject는 target 객체의 프로퍼티들을 result에 복사하는 함수이다. for-in 문법으로 모든 프로퍼티를 순회한다.

위 코드에서 var user2 = changeName(user, 'Jung') 대신에

var user2 = copyObject(user);
user2.name = 'Jung'

과 같은 방식으로 객체를 복사, 수정할 수 있다.

하지만 곧 문제가 생겼다. 중첩 객체의 경우에는 얕은 복사 함수로 완벽하게 복사되지 않는 것이다!

var user = {
  name: 'Jaenam',
  urls: {
    portfolio: 'http://github.com/abc',
    blog: 'http://blog.com',
    facebook: 'http://facebook.com/abc'
  }
};
var user2 = copyObject(user);

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

user.urls.portfolio = 'http://portfolio.com';
console.log(user.urls.portfolio === user2.urls.portfolio); // true

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

내부 프로퍼티(blog, facebook)의 경우에는 원본 객체를 그대로 참조하고 있다. urls객체도 따로 복사를 해줘야 한다. 이것을 한 번에 할 수 있는 깊은 복사 함수를 살펴보자.

깊은 복사 함수

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;
};

몇 개의 객체가 중첩되어 있든 상관 없이, 재귀 호출을 이용해서 내부 프로퍼티를 모두 순회한다. 객체거나, null(typeof 결과가 object로 나오기 때문)인 경우엔 재귀 호출하고, 기본값 프로퍼티의 경우 그대로 지정한다. 이렇게 하면 원본 객체와 복사된 객체는 서로 완전히 다르게 된다.


undefined와 null

undefined

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

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

var obj = {a: 1};
console.log(obj.a); // 1
console.log(obj.b); // undefined: 존재하지 않는 프로퍼티에 접근
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]

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

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

빈 배열을 만들고 크기를 3이라고 하자 undefined 조차도 할당되지 않고 empty로 보인다.

new 연산자와 Array 생성자 함수로 만든 배열 인스턴스에서도 아무것도 할당되어 있지 않다.

마지막으로 사용자가 직접 배열에 undefined를 넣은 경우에는 그것이 값이 된다.

이것이 비어있는 요소undefined의 차이점이다.'

빈 요소가 있는 배열을 순회할 때는 어떻게 될까?

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

arr1.forEach((v, i) => {console.log(v, i)}); // undefined 0 / 1 1
arr2.forEach((v, i) => {console.log(v, i)}); // 1 1

arr1.map((v, i) => v + i); // [NaN, 2]
arr2.map((v, i) => v + i); // [empty, 2]

arr1.filter(v => !v); // [undefined]
arr2.filter(v => !v); // []

arr1.reduce((p, c, i) => p + c + i); // undefined011
arr2.reduce((p, c, i) => p + c + i); // 11

코드를 적고 나니 배열을 순회하는 forEach, map, filter, reduce 함수를 따로 정리해서 공부해봐야겠다.

본론으로 다시 돌아와서, arr1에 대해서는 배열의 모든 요소를 순회하지만 arr2에 대해서는 빈 요소에 대해 어떤 일도 하지 않고 그냥 넘어갔다는 것을 알 수 있다. 이것으로 다시 한 번 사용자가 할당하는 undefined와 자바스크립트 엔진이 자동으로 반환하는 undefined는 다르다는 것을 확인했다.

엔진에서 자동으로 undefined를 반환하므로 우리는 혼란을 피하기 위해 값으로 undefined를 할당하지 않으면 된다. '값이 없다'를 표현하고 싶다면 undefined가 아닌 애초에 그런 목적으로 만들어진 null을 쓰도록 하자.

null

null비어있음을 명시적으로 나타내준다. 하지만 버그가 하나 있는데, typeof null === object 이다. 따라서 어떤 값이 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는 같다고 판단된다. 일치 연산자 ===를 사용해서 정확히 판별하자.

profile
하고 싶은 게 많은 사람

0개의 댓글