Published on

자바스크립트 데이터 타입과 변수에 값이 할당되는 방식에 대한 고찰

Authors
  • avatar
    Name
    박준열 | Eric Park
    Twitter

자바스크립트의 데이터 타입은 크게 두가지로 볼 수 있다.

1. 기본형/원시형 타입 (Primitive Type)

  • Number
  • String
  • Boolean
  • null
  • undefined
  • Symbol

2. 참조형 타입 (Reference Type)

  • Object (객체)
    • Array
    • Function
    • Date
    • RegExp
    • Map, WeakMap
    • Set, WeakSet

컴퓨터는 모든 데이터를 0/1로 기억을 하는데, 이렇게 0/1을 표현하는 메모리 조각을 비트(bit) 라고 부른다.

각 비트는 고유한 식별자를 통해서 위치를 확인할 수 있는데, 고작 0/1밖에 표현을 못하는 비트를 하나의 단위로 보고 사용을 하면 되게 비효율적이다.

그럼 엄청 많은 수의 비트를 통으로 묶어서 보면 효율적이지 않을까 싶을 수가 있는데, 그렇게 되면 비트의 낭비가 발생한다. 예를 들어, 고작 4비트만 필요한 상황에서 64비트를 사용하고 있으면 60비트가 낭비가 되는 건데, 이건 전혀 효율적이지 못하다.

그러면 효율적이게 관리를 하려면 어떻게 해야할까 고민하다가 나온게 8비트를 묶어서 하나의 단위로 보는 방법이다. 8비트는 1바이트(Byte)이다.

아까 말했듯이 1비트는 0/1을 표현할 수 있기에, 8비트, 1바이트는 2^8개의 값을 표현할 수 있다.

C++, Java같은 언어는 메모리의 낭비를 최소화하기 위해 각 데이터 타입마다 메모리 사이즈를 구분해놓았다. 예를 들어 short이라는 데이터 타입은 16비트, 2바이트의 크기를 가진 정수형 타입이고, long long이라는 데이터 타입은 64비트, 8바이트의 크기를 가진 정수형 타입이다.

자바스크립트는 기술의 발전이 어느정도 되고난 후에 나온 언어이기 때문에 메모리 관리에 대한 압박이 크지 않았기에 각 데이터 타입에 대해 메모리 공간을 넉넉하게 할당을 해주었다.

다른 언어처럼 int/double로 구분하지 않고 자바스크립트는 Number라는 타입 하나만 있는 이유이기도 하다. Number 타입에는 64비트, 8바이트의 메모리 공간이 할당이 되어있다.

자바스크립트에서 변수를 선언하는 방법

var a;

a라는 식별자를 가진 변수를 위한 공간을 메모리에 확보하겠다는 뜻

여기선 호이스팅에 관한 얘기는 하지 않을 예정입니다

이렇게 변수를 선언을 하게 되면, 메모리에 특정 공간을 비워두고, 그 공간의 식별자를 a라고 지정을 하게 된다. 추후에 a 변수에 접근을 하게 되면, 컴퓨터가 메모리에서 a라는 식별자를 가진 주소를 검색하고, 그 데이터를 반환을 한다.

var a = 'abc';

그럼 이렇게 변수에 값을 할당하는 경우에는 어떻게 될까?

  1. 메모리에 변수를 위한 빈공간을 확보한다
  2. 확보된 빈공간에 a라는 식별자를 할당한다
  3. 메모리에 문자열 'abc'를 저장하기 위한 공간을 확보한다
  4. 확보된 빈공간에 문자열 'abc'를 저장한다
  5. 메모리에서 a라는 식별자를 가진 메모리 공간을 찾는다
  6. 발견한 공간에 문자열이 저장된 공간의 주소를 할당한다.

그니까 쉽게 생각하면, 변수를 위한 빈공간을 확보하고, 값을 위한 빈공간을 확보하고, 변수 공간에 값의 주소를 저장한다고 보면 된다.

절대 변수 공간 자체에 값을 저장하는게 아니다

왜 그렇게 하지 않을까?

어떤 값이 틈만 나면 바뀐다고 생각을 해보자. 만약 그 값 자체를 계속 수정을 해야한다면, 필요한 메모리 공간도 계속 달라지게 된다.

어떤 값이 주소 10031008를 차지하고 있다고 가정을 하자. 근데 값이 수정되면서 엄청 길어져서, 10031008로는 부족하게 됐다. 그러면 뒤에 빈 공간을 확보해서 10031013 같은 방식으로 저장을 할 수도 있겠지만, 만약 10081013이 이미 차지가 된 상태라면? 문제가 발생한다.

또 만약 10031008에 저장된 값이 바뀌었는데, 1005에 위치한 값이 바뀌었다고 가정을 해보자. 만약 1005에 막 엄청 큰 문자열이 추가가 되어서 10051009까지 공간이 필요하게 된다면, 기존에 1006~1008에 위치했던 값들을 뒤로 밀어서 저장을 해야하는데, 딱봐도 비효율적이라는 것을 알 수 있다. 시간복잡도 측면에서 봤을 때는 O(n)의 시간이 들거라고 생각할 수 있다.

이러한 이유들 때문에 변수에 값을 할당할 때에는, 값을 위한 메모리 공간을 새로 확보를 하고, 변수에는 그 값의 주소를 할당하는 것이다. 이렇게 했을 때는, 만약 값이 변경이 되었을 때, 새로운 메모리 공간에 새로운 값을 저장하고, 새로운 주소로 변경을 해준다고 보면 된다.

변수에 원시 값을 할당하고 변경하는 경우

앞서 얘기했던 방식이랑 동일하다고 보면 되지만, 객체 값과의 비교를 위해 다시 한번 설명을 하려고 한다.

var x = 'abc';

이렇게 x라는 식별자를 가진 변수를 선언하고, abc라는 문자열을 할당해주려고 한다고 가정해보자.

  1. 변수를 위해 메모리 공간(@1003)을 확보하고, 식별자 x를 확보된 공간에 할당한다
  2. 'abc'라는 문자열값을 저장하기 위해 또 다른 메모리 공간(@5001)을 확보한다.
  3. x라는 식별자를 가진 메모리 주소(@1003)를 찾고, 'abc'라는 문자열값이 저장된 메모리 주소(@5001)를 할당한다.
x += 'def'; //'abcdef'

아까 선언한 x라는 식별자를 가진 변수에 'def'라는 문자열을 추가를 한다고 가정을 해보자.

되게 당연하게도, "아까 'abc' 문자열 있었으니까 그냥 뒤에 추가해버리면 되지 않아?"라는 생각을 할 수가 있다. 하지만 아까 언급했듯이, 원시값은 새로 생성만 될뿐, 절대 변경이 되지 않는다.

그러면 어떤 단계를 거치는지 살펴보자.

  1. x라는 식별자의 메모리 공간(@1003)을 찾고, 할당된 값의 메모리 주소(@5003)를 찾는다
  2. 원래 값이 'abc'라는 것을 확인한 후, 추가하려는 문자열과 합친 값을 생성한다 'abcdef'
  3. 새로 생성된 문자열을 저장할 메모리 공간(@5004)을 확보하고, 값을 할당한다
  4. x라는 식별자를 가진 메모리 공간(@1003)을 찾고, 새로 확보된 메모리 주소(@5004)를 할당한다.

되게 중요한 사실이니까 다시 한번 반복을 하자면,

원시값은 절대로 변경되지 않는다. 원시값은 언제나 새로운 값을 생성한다.

변수에 객체를 할당하고 변경하는 경우

객체는 0 이상의 원시값들을 모아놓은 값이라고 생각하면 된다.

var obj = {
  a: 1,
  b: 'bbb'
}

이렇게 obj라는 식별자를 가진 객체를 생성한다고 가정을 해보자.

  1. obj라는 식별자를 가진 변수를 위한 메모리 공간(@1002)을 확보하고, 식별자 obj를 할당한다.

  2. 객체를 위한 메모리 공간(@5001)을 새로 확보한다.

  3. 객체에 있는 값들을 위한 메모리 공간(@7103, @7104)을 각각 새로 확보하고, 각각의 식별자인 a, b를 할당한다.

  4. 1이라는 값을 저장한 메모리 공간(@5003)을 확보하고, a라는 식별자를 가진 메모리 공간(@7103)에 메모리주소(@5003)을 할당한다.

  5. 'bbb'라는 값을 저장한 메모리 공간(@5004)을 확보하고, b라는 식별자를 가진 메모리 공간(@7104)에 메모리주소(@5004)를 할당한다.

  6. 객체에 있는 값들의 메모리 공간(@7103, @7104)을 객체의 메모리 공간(@5001)에 할당을 한다.

  7. obj라는 식별자를 가진 변수의 메모리 공간(@1002)에 객체의 메모리 주소(@5001)을 할당을 한다.

객체는 이런 단계를 거쳐서 생성이 된다. 여기서 자세히 보면, 3~5단계는 변수에 원시값을 할당하는 것과 똑같다는 것을 알 수 있다. 차이점은 원시값이 여러개라는 점밖에 없다. 그러니 객체라고 값을 할당하고 선언하고 하는게 다르다고 생각할 필요가 없다. 근본적인 방식은 똑같다.

그럼 이제 만약 객체를 변경한다면 어떻게 될까

_obj = obj;
_obj.a = 2;

_obj라는 식별자를 가진 새로운 변수를 만들고, obj의 값을 할당한다. 그리고, _obj 객체의 a라는 키값의 프로퍼티를 2로 변경을 한다.

  1. _obj라는 식별자를 가진 변수를 위해 메모리 공간 (@1003)을 확보하고, 식별자 _obj를 할당한다.

  2. obj의 값(할당된 객체의 주소값(@5001))을 _obj의 메모리 공간(@1003)에 할당을 한다.

  3. _obj에 할당된 객체의 주소값으로 가서, a라는 식별자를 가진 메모리 공간(@7103)을 찾는다.

  4. 새로운 값인 2를 저장하기 위한 메모리 공간(@5005)를 확보하고, 2를 할당한다.

  5. a라는 식별자를 가진 메모리 공간(@7103)의 값에 새로 확보된 값의 메모리 주소(@5005)를 할당한다.

위에 코드가 성공적으로 실행이 됐다고 가정을 하고, 아래 코드의 실행 결과를 생각해보자.

console.log(obj === _obj);
console.log(obj.a === _obj.a);

결과는 바로 True, True이다.

이유는 되게 간단한데, === 연산자는 값이 동일한지 확인을 하는데, 두 값의 메모리 주소가 일치하는지 안하는지를 통해 확인한다.

obj의 값은 객체의 메모리 주소값인 @5001이 할당이 되어있고, _obj의 값도 객체의 메모리 주소값인 @5001이 할당이 되어있다.

따라서 _obj.a의 값이 변경이 된다는 것은, obj.a의 값 역시 변경이 된다는 것과 동일하다고 보면 된다.

하지만 이런 성질은 **불변성(immutability)**을 위반하기 때문에 좋지 않다고 볼 수 있다. _obj.a의 값이 변경된다고 obj.a의 값 역시 변경되는건 디버깅을 매우 어렵게 하기 때문이다.

그러면 어떻게 해야 불변성을 지키면서 _obj에 obj의 값을 할당할 수 있을까?

답은 바로 깊은 복사 (Deep Copy) 이다

얕은 복사, 깊은 복사

얕은 복사는 뭐고 깊은 복사는 뭔데? ?

얕은 복사(Shallow Copy)는 위에서 _obj에 obj값을 할당하는 것 처럼, 객체의 주소 그 자체를 전달하는 것이다.

만약 그 객체 자체가 변경이 된다면 원시값의 변경처럼 새로운 메모리 공간을 확보하고, 값을 할당하고, 메모리 주소를 변수에 할당하기 때문에 불변성이 지켜지지만, 만약 객체 내부에 있는 값을 변경하게 될 경우, 메모리 주소 측면에서 봤을 때는 객체는 바뀐 것이 전혀 없는 것 처럼 보이게 된다. 이런 경우 앞서 말한 불변성을 지키지 못하기 때문에 예상하지 못한 오류가 발생할 가능성이 높아진다.

깊은 복사(Deep Copy)는 _obj에 obj의 값을 전달하는 것이 아닌, obj의 값인 객체의 주소로 가서, 객체 내부에 있는 모든 값들을 하나하나 다 복사를 따로 하고, 새로운 메모리 공간을 확보한 후에, 그 새로 확보된 메모리 공간의 주소를 _obj에 할당하는 것이라고 보면 된다. 이렇게 되면, _obj === obj를 했을 때, 메모리 주소가 다르기 때문에 false가 출력이 될 것이며, _obj.a의 값을 변경하더라도 obj.a에는 아무런 영향도 끼치지 않게 된다.

마지막으로 자바스크립트의 특이한 성질, undefined/null을 살펴보자.

undefined vs null

자바스크립트에는 없음을 나타내는 값이 두가지가 있다. undefined와 null이다. 하지만 이 두 값은 사용하는 목적이 다르다.

undefined: 정의되지 않은

null: 값이 없는

undefined는 말 그대로 정의되지 않은 무언가에 접근을 하려고 할 때 자바스크립트가 퉤퉤 내뱉는 값이라고 보면 된다. undefined가 반환되는 경우는 크게 세가지가 있다.

  1. 값을 대입하지 않은 변수, 즉 데이터 영역의 메모리 주소를 지정하지 않은 식별자에 접근할 때
    • var a;
    • console.log(a); // undefined
    • a라는 식별자를 가진 변수에는 아무 값도 정의되어있지 않다
    • 조금 더 자세히 얘기를 하면, 자바스크립트는 var로 변수를 선언할 때, 호이스팅 단계에서 변수가 생성됨과 동시에 undefined로 초기화를 한다. 자바스크립트의 독특한 특징
    • 따라서 실제로 없는 값에 접근한다는 느낌보단, 자바스크립트가 저장한 undefined값에 접근하는거라고 보면 된다.
  2. 객체 내부의 존재하지 않는 프로퍼티에 접근하려고 할 때
    • var obj = {a: 1};
    • console.log(obj.b) // undefined
    • 이 경우에는 존재하지 않는 값에 접근을 하려고 하기 때문에 undefined가 반환이 된다.
  3. return 문이 없거나 호출되지 않는 함수의 실행결과
    • var func = function () {};
    • console.log(func()); // undefined
    • func에 할당된 익명함수는 아무런 값도 반환을 하고 있지 않기에, 함수가 실행이 되더라도 어떤 값도 반환이 되지 않는다. 근데 아무런 값도 반환이 되지 않은 상황에서 그 값을 접근하려고 하니 undefined가 출력이 되는 것이다.

세가지의 경우 다 공통점이 있는데, 바로 "없는 값을 접근하려고 한다"라는 점이다.

하지만, 1번과 2,3번은 차이가 있는데, 1번은 undefined값 자체에 접근하는 것이라면, 23번은 값이 없기에 undefined가 반환이 되는 것이다.

이렇게 두가지 경우에 의해 undefined가 반환이 될 수 있다면, 디버깅을 할 때 정말 혼란스럽고 까다롭다. 내가 없는 값에 접근을 해서 undefined가 나오는건지, 아니면 값 자체가 undefined라서 undefined가 나오는건지를 구분할 수 없기 때문이다.

따라서 두개의 경우의 수를 하나로 줄이기 위한 방법이 null을 도입하는 것이다.

어떠한 변수의 값이 비어있다라고 표현을 하고 싶을 때에는, undefined를 할당하는게 아닌 null값을 할당해야한다. undefined를 할당하면 앞서 말한 경우의 수가 두가지가 되지만, null을 할당하면 undefined가 반환되는 경우의 수를 하나로 통제할 수 있기 때문이다.

값이 비어있다는 것을 표현하고 싶으면 null을 이용하자