4.1 자바스크립트의 데이터 타입

  • 원시 타입 (primitive type)

    • boolean
    • null
    • undefined
    • number
    • string
    • symbol
    • bigint
  • 객체 타입 (object/reference type)

    • object

4.1.1 원시 타입

객체가 아닌 모든 타입을 의미한다.
객체가 아니므로 이러한 타입들은 메서드를 갖지 않는다.
ES2022 기준 최신 자바스크립트에서는 총 7개의 원시 타입이 있다.

4.1.1.1 undefined

undefined는 선언한 후 값을 할당하지 않은 변수 또는 값이 주어지지 않은 인수에 자동으로 할당되는 값이다.

let foo;

typeof foo === 'undefined'; // true

function bar (hello) {
    return hello
}

typeof bar() === 'undefined'; // true

이따 살펴볼 원시값 nullundefined는 오직 각각 nullundefined라는 값만 가질 수 있으며, 그 밖의 타입은 가질 수 있는 값이 두 개 이상(booleantrue, false와 같이) 존재한다.

4.1.1.2 null

아직 값이 없거나 비어있는 값을 표현할 때 null을 사용한다.

typeof null === 'object'; // true

null이 가지고 있는 특별한 점 하나는 다른 원시값과 다르게 typeofnull을 확인했을 때 해당 타입이 아닌 object로 반환된다는 것이다.
이는 초창기 자바스크립트가 값을 표현하는 방식 때문에 발생한 문제로, 이후에 typeof null을 진짜 null로 표현하고자 하는 시도가 있었으나 이전 코드에서 작동할 수 없는 호환성이 깨지는 변경 사항(breaking change)이어서 받아들여지지 않았다.

  • undefined: 선언됐지만 할당되지 않은 값
  • null: 명시적으로 비어있음을 나타내는 값

4.1.1.3 boolean

truefalse만을 가질 수 있는 데이터 타입이다.
주로 조건문에서 많이 쓰는 데이터 타입이다.

한 가지 주목할만한 점은 true, false와 같은 boolean 형의 값 외에도 조건문에서 마치 truefalse처럼 취급되는 truthy, falsy 값이 존재한다는 것이다.

  • falsy: 조건문 내부에서 false로 취급되는 값을 말한다.
    • 값: false, 타입: boolean, false는 대표적인 falsy한 값이다.
    • 값: 0, -0, 0n, 0x0n, 타입: number, boolean, 0은 부호나 소수점 유무에 상관없이 falsy한 값이다.
    • 값: NaN, 타입: number, number가 아니라는 것을 뜻하는 NaN(Not a Number)은 falsy한 값이다.
    • 값: '', "", ``, 타입: string, 문자열이 falsy하기 위해서는 반드시 공백이 없는 빈 문자열이어야 한다.
    • 값: null, 타입: null, null은 falsy한 값이다.
    • 값: undefined, 타입: undefined, undefined는 falsy한 값이다.

falsy로 취급되는 값 이외에는 모두 true로 취급된다.
한 가지 유념할 점은 객체와 배열은 내부에 값이 존재하는지 여부와 상관없이 truthy로 취급된다는 것이다.
즉, {}, [] 모두 truthy한 값이다.

4.1.1.4 number

정수와 실수를 구분해 저장하는 다른 언어와 다르게, 자바스크립트는 모든 숫자를 하나의 타입에 저장했었다.
ECMAScript 표준에 따르면 -(253-1)과 253-1 사이의 값을 저장할 수 있다.
이후에 bigint가 등장하기 전까지는 이 범위 외의 값들을 다루기가 어려웠다.
다음 코드는 BitInt가 있기 전까지 number가 안전하게 처리할 수 있는 숫자 범위를 나타낸다.

const a = 1;

const maxInteger = Math.pow(2, 53);
maxInteger - 1 === Number.MAX_SAFE_INTEGER; // true

const minInteger = -(Math.pow(2, 53) - 1);
minInteger === Number.MIN_SAFE_INTEGER; // true

또한 2진수, 8진수, 16진수 등의 별도 데이터 타입을 제공하지 않으므로 각 진수별로 값을 표현해도 모두 10진수로 해석되어 동일한 값으로 표시된다.
다음 예제를 보자.

const 이진수_2 = 0b10; // 2진수(바이너리) 2
이진수_2 == (2).toString(2); // true (2)를 괄호로 선언한 이유는 2뒤에 점이 있으면 소수점으로 인식하기 때문

const 팔진수_8 = 0o10 // 8진수(octal) 8
팔진수_8 == (8).toString(8) // true

10 == (10).toString(10) // true

const 십육진수_16 = 0x10; // 16진수(hexadecimal) 16
십육진수_16 == (16).toString(16) // true

4.1.1.5 BigInt

앞서 number가 다룰 수 있는 숫자 크기의 제한을 극복하기 위해 ES2020에서 새롭게 나온 것으로, 최대 253-1을 저장할 수 있는 number의 한계를 넘어서 더 큰 숫자를 저장할 수 있게 해준다.

// 기존 number의 한계
9007199254740992 === 9007199254740993; // true // 마지막 숫자는 다른데 true가 나온다. 이는 더 이상 다룰 수 없는 크기이기 때문이다.

const maxInteger = Number.MAX_SAFE_INTEGER;
console.log(maxInteger + 5 === maxInteger + 6); // true

const bigInt1 = 9007199254740995n; // 끝에 n을 붙이거나
const bigInt2 = BigInt('9007199254740995'); // BitIng 함수를 사용하면 된다.

const number = 9007199254740992;
const bigInt = 9007199254740992n;

typeof number; // number
typeof bigInt; // bigint

number == bigInt;
number === bigInt; // false (타입이 달라서 false가 반환된다.)

4.1.1.6 string

string은 텍스트 타입의 데이터를 저장하기 위해 사용된다.
한 쌍의 작은따옴표(')나 큰따옴표("), 또는 내장 표현식을 허용하는 문자열 리터럴 표현 방식인 백틱(`)으로도 표현할 수 있다.

백틱으로 표현하는 문자열은 앞선 작은따옴표나 큰따옴표와는 조금 차이점이 있다.
백틱을 사용해서 표현한 문자열을 템플릿 리터럴(template literal)이라고 하는데, 같은 문자열을 반환하지만 줄바꿈이 가능하고, 문자열 내부에 표현식을 쓸 수 있다는 차이가 있다.

// \n안녕하세요.\n
const longText = `
안녕하세요.
`;
// Uncaught SyntaxError: Invalid or unexpected token
const longText = "
안녕하세요.
"

자바스크립트 문자열의 특징 중 하나는 문자열이 원시 타입이며 변경 불가능하다는 것이다.
이것은 한번 문자열이 생성되면 그 문자열을 변경할 수 없음을 의미한다.

const foo = 'bar';

console.log(foo[0]); // 'b'

// 앞 글자를 다른 글자로 변경해 보았다.
foo[0] = 'a';

// 이는 반영되지 않는다.
console.log(foo); // bar

4.1.1.7 Symbol

Symbol은 ES6에 새롭게 추가된 7번째 타입으로, 중복되지 않는 어떤 고유한 값을 나타내기 위해 만들어졌다.
심벌은 심벌 함수를 이용해서만 만들 수 있다.
즉, 심벌을 생성하려면 반드시 Symbol()을 사용해야만한다.

// Symbol 함수에 같은 인수를 넘겨주더라도 이는 동일한 값으로 인정되지 않는다.
// 심벌 함수 내부에 넘겨주는 값은 Symbol 생성에 영향을 미치지 않는다.(Symbol.for 제외)
const key = Symbol('key');
const key2 = Symbol('key');

key === key2; // false

// 동일한 값을 사용하기 위해서는 Symbol.for를 활용한다.
Symbol.for('hello') === Symbol.for('hello'); // true

4.1.1.8 객체 타입

객체 타입을 간단하게 정의하면 앞서 7가지 원시 타입 이외의 모든 것, 즉 자바스크립트를 이루고 있는 대부분의 타입이 바로 객체 타입이다.
여기에는 배열, 함수, 정규식, 클래스 등이 포함된다.

여기서 한 가지 주목할 것이 객체 타입(object type)은 참조를 전달한다고 해서 참조 타입(reference type)으로도 불린다는 사실이다.
여기서 우리가 알아둬야 할 자바스크립트 동등 비교의 특징이 나타난다.

typeof [] === 'object'; // true
typeof {} === 'object'; // true

function hello() {}
typeof hello === 'function'; // true

const hello1 = function () {
    
}

const hello2 = function () {
    
}

// 객체인 함수의 내용이 육안으로는 같아 보여도 참조가 다르기 때문에 false가 반환된다.
hello1 === hello2; // false

4.1.1.8.1 값을 저장하는 방식의 차이

원시 타입과 객체 타입의 가장 큰 차이점이라고 한다면, 바로 값을 저장하는 방식의 차이다.
이 값을 저장하는 방식의 차이가 동등 비교를 할 때 차이를 만드는 원인이 된다.

먼저 원시 타입은 불변 형태의 값으로 저장된다.
그리고 이 값은 변수 할당 시점에 메모리 영역을 차지하고 저장된다.
다음 예제 코드를 살펴보자.

let hello = 'hello world';
let hi = hello;

console.log(hello === hi); // true

당연히 이 두 값을 배교하면 true가 나온다.
이는 hello의 hello world라는 값이 hi에 복사해 전달됐기 때문이다.
값을 비교하기 때문에, 값을 전달하는 방식이 아닌 각각 선언하는 방식으로도 동일한 결과를 볼 수 있다.

let hello = 'hello world'
let hi = 'hello world'

console.log(hello === hi); // true

반면 객체는 프로퍼티를 삭제, 추가, 수정할 수 있으므로 원시 값과 다르게 변경 가능한 형태로 저장되며, 값을 복사할 때도 값이 아닌 참조를 전달하게 된다.
다음 예제 코드를 살펴보자.

// 다음 객체는 완벽하게 동일한 내용을 가지고 있다.
var hello = {
    greet: 'hello, world',
}

var hi = {
    greet: 'hello, world',
}

// 그러나 동등 비교를 하면 false가 나온다.
console.log(hello === hi) // false

// 원시값인 내부 속성값을 비교하면 동일하다.
console.log(hello.greet === hi.greet) // true

객체는 값을 저장하는 게 아니라 참조를 저장하기 때문에 앞서 동일하게 선언했던 객체라 하더라도 저장하는 순간 다른 참조를 바라보기 때문에 false를 반환하게 된다.
즉, 값은 같았을지언정 참조하는 곳이 다른 셈이다.
반면 참조를 전달하는 경우에는 이전에 원시값에서 했던 것과 같은 결과를 기대할 수 있다.

한편, 다음 예제에서 hello와 hi 변수는 변수명 및 각 변수명의 주소가 서로 다르지만 value가 가리키는 주소가 동일하다.
즉, value의 값(여기서는 { greet: 'hello, world' })이 hello.greet = 'something'과 같이 변경된다 하더라도 hi와 hello 비교는 언제나 true를 반환한다.

var hello = {
  greet: 'hello, world',
}

var hi = hello;

console.log(hello === hi) // true

따라서 자바스크립트 개발자는 항상 객체 간에 비교가 발생하면, 이 객체 간의 비교는 우리가 이해하는 내부의 값이 같다 하더라도 결과는 대부분 true가 아닐 수 있다는 것을 인지해야 한다.

4.1.1.8.2 자바스크립트의 또 다른 비교 공식, Object.is

자바스크립트에서는 비교를 위한 또 한 가지 방법을 제공하는데, 바로 Object.is다.
Object.is는 두 개의 인수를 받으며, 이 인수 두 개가 동일한지 확인하고 반환하는 메서드다.
Object.is=====와 다른 점은 다음과 같다.

  • == vs. Object.is: == 비교는 같음을 비교하기 전에 양쪽이 같은 타입이 아니라면 비교할 수 있도록 강제로 형변환(type casting)을 한 후에 변경한다.
    따라서 5 == '5'와 같이 형변환 후에 값이 동일하다면 ==true를 반환한다.
    하지만 Object.is는 이러한 작업을 하지 않는다.
    즉, ===와 동일하게 타입이 다르면 그냥 false다.

  • === vs. Object.is 이 방법에도 차이가 있다. 다음 코드를 보면 알 수 있듯, Object.is가 좀 더 개발자가 기대하는 방식으로 정확히 비교한다.

-0 === +0 // true
Object.is(-0, +0) // false

Number.NaN === NaN // false
Object.is(Number.NaN, NaN) // true

NaN === 0 / 0 // false
Object.is(NaN, 0 / 0) // true

이렇듯 =====가 만족하지 못하는 몇 가지 특이한 케이스를 추가하기 위해, Object.is가 나름의 알고리즘으로 작동하는 것을 알 수 있다.
한 가지 주의해야 할 점은, Object.is를 사용한다 하더라도 객체 비교에는 별 차이가 없다는 것이다.
객체 비교는 앞서 이야기한 객체 비교 원리와 동등하다.

Object.is({}, {}) // false

const a = {
    hello: 'hi'
}

const b = a;

Object.is(a, b) // true
a === b // true

Object.is는 ES6(ECMAScript 2015)에서 새롭게 도입된 비교 문법으로, 위와 같이 몇 가지 특별한 사항에서 동등 비교 ===가 가지는 한계를 극복하기 위해 만들어졌다.
그러나 여전히 객체 간 비교에 있어서는 자바스크립트의 특징으로 인해 ===와 동일하게 동작하는 것을 알 수 있다.

4.1.1.8.3 리액트에서의 동등 비교

그렇다면 리액트에서는 동등 비교가 어떻게 이루어질까?
리액트에서 사용하는 동등 비교는 =====가 아닌 이 Object.is다.
Object.is는 ES6에서 제공하는 기능이기 때문에 리액트에서는 이를 구현한 폴리필(Polyfill)을 함께 사용한다.

다음은 리액트에서 실제로 값을 비교할 때 사용하는 코드다.

// 리액트에서 값을 비교하는 함수 - objectIs
// flow로 구현돼 있어 any가 추가돼 있다. flow에서 any는 타입스크립트와 동일하게
// 어떠한 값도 받을 수 있는 타입을 의미한다.
function is (x: any, y: any) {
    return (
            (x === y && (x !== 0 || 1 / x === 1 / y)) || (x !== x && y !== y) // eslint-disagble-line no-self-compare
    )
}

// 런타임에 Object.is가 있다면 그것을 사용하고, 아니라면 위 함수를 사용한다.
// Object.is는 인터넷 익스플로러 등에 존재하지 않기 때문에 폴리필을 넣어준 것으로 보인다.
const objectIs: (x: any, y: any) => boolean = typeof Object.is === 'function' ? Object.is : is

export default objectIs

리액트에서는 이 objectIs를 기반으로 동등 비교를 하는 shallowEqual이라는 함수를 만들어 사용한다.
shallowEqual은 의존성 비교 등 리액트의 동등 비교가 필요한 다양한 곳에서 사용된다.

// 리액트에서 값을 비교 - shallowEqual
import is from './objectIs'
// 다음 코드는 Object.prototype.hasOwnProperty다.
// 이는 객체에 특정 프로퍼티가 있는지 확인하는 메서드다.
import hasOwnProperty from './hasOwnProperty'

/**
 * 주어진 객체의 키를 순회하면서 두 값이 엄격한 동등성을 가지는지를 확인하고,
 * 다른 값이 있다면 false를 반환한다.  
 * 만약 두 객체 간에 모든 키의 값이 동일하다면 true를 반환한다.
 * */
// 단순히 Object.is를 수행하는 것 뿐만 아니라 객체 간의 비교도 추가돼있다.
function shallowEqual (objA: mixed, objB: mixed): boolean {
    if (is(objA, objB)) {
        return true
    }
    
    if (
        typeof objA !== 'object' ||
        objA === null ||
        typeof objB !== 'object' ||
        objB === null    
    ) {
        return false
    }
    
    // 각 키 배열을 꺼낸다.
    const keysA = Object.keys(objA)
    const keysB = Object.keys(objB)
  
    // 배열의 길이가 다르다면 false
    if (keysA.length !== keysB.length) {
        return false
    }
    
    // A의 키를 기준으로, B에 같은 키가 있는지, 그리고 그 값이 같은지 확인한다.
    for (let i = 0; i < keysA.length; i++) {
        const currentkey = keysA[i]
      
        if (
            !hasOwnProperty.call(objB, currentKey) ||
            !is(objA[currentKey], objB[currentKey])
        ) {
            return false
        }
    }
    
    return true
}

export default shallowEqual

리액트에서의 비교를 요약하자면 Object.is로 먼저 비교를 수행한 다음에 Object.is에서 수행하지 못하는 비교, 즉 객체 간 얕은 비교를 한 번 더 수행하는 것을 알 수 있다.
객체 간 얕은 비교란 객체의 첫번째 깊이에 존재하는 값만 비교한다는 것을 의미한다.
다음 코드를 살펴보자.

// Object.is는 참조가 다른 객체에 대해 비교가 불가능하다.
Object.is({ hello: 'world' }, { hellow: 'world' }) // false

// 반면 리액트 팀에서 구현한 shallowEqual은 객체의 1 depth까지는 비교가 가능하다.
shallowEqual({ hello: 'world' }, { hellow: 'world' }) // true

// 그러나 2 depth 까지 가면 이를 비교할 방법이 없으므로 false를 반환한다.
shallowEqual({ hello: { hi: 'world' } }, { hello: { hi: 'world' } }) // false

이렇게 객체의 얕은 비교까지만 구현한 이유는 무엇일까?
먼저 리액트에서 사용하는 JSX props는 객체이고, 그리고 여기에 있는 props만 일차적으로 비교하면 되기 때문이다.
다음 코드를 살펴보자.

type Props = {
    hello: string
}

function HelloComponent(props: Props) {
    return <h1>{hello}</h1>
}
function App() {
    return <HelloComponent hello="hi!" />
}

위 코드에서 props는 객체다.
그리고 기본적으로 리액트는 props에서 꺼내온 값을 기준으로 렌더링을 수행하기 때문에 일반적인 케이스에서는 얕은 비교로 충분할 것이다.
이러한 특성을 안다면 props에 또 다른 객체를 넘겨준다면 리액트 렌더링이 예상치 못하게 작동한다는 것을 알 수 있다.

// React.memo의 깊은 비교 문제 예시
import {memo, useEffect, useState } from "react";

type Props = {
    counter: number
}

const Component = memo((props: Props) => {
  useEffect(() => {
    console.log('Component has been rendered!')
  });
  
  return <h1>{props.counter}</h1>
})

type DeeperProps = {
    counter: {
        counter: number
    }
}

const DeeperComponent = memo((props: DeeperProps) => {
  useEffect(() => {
    console.log('DeeperComponent has been rendered!')
  });
  
  return <h1>{props.counter.counter}</h1>
})

export default function App() {
    const [, setCounter] = useState(0)
  
    function handleClick() {
        setCounter(prev => prev + 1)
    }
    
    // button 을 클릭했을 때, 실제로 Component에 전달한 props.counter는 값 변화가 없다.
    // 그렇기 때문에 Component를 리렌더링하지 않는다.
    // 하지만, props.counter.counter을 전달한 DeeperComponent는 1 depth 까지만 비교하는 얕은 비교를 하기 때문에
    // 이전 props.counter.counter와 button 을 클릭한 이후 props.counter.counter 가 서로 달라졌다고 판단(false return)하기 때문에 리렌더링한다. 
    // 즉, memo 효과가 없는 것이다.
    return (
        <div className="App">
          <Component counter={100} />
          <DeeperComponent counter={{counter: 100}} />
          <button onClick={handleClick}>+</button>
        </div>
    )
}

위와 같이 props가 깊어지는 경우, 즉 한 객체 안에 또 다른 객체가 있을 경우 React.memo는 컴포넌트에 실제로 변경된 값이 없음에도 불구하고 메모이제이션된 컴포넌트를 반환하지 못한다.
즉, Componentprops.counter가 존재하지만, DeeperComponentprops.counter.counterprops가 존재한다.
상위 컴포넌트인 App에서 버튼을 클릭해서 강제로 렌더링을 일으킬 경우, shallowEqual을 사용하는 Component 함수는 위 로직에 따라 정확히 객체 간 비교를 수행해서 렌더링을 방지해 주었지만, DeeperComponent 함수는 제대로 비교하지 못해 memo가 작동하지 않는 모습을 볼 수 있다.

만약 내부에 있는 객체까지 완벽하게 비교하기 위한 재귀문까지 넣었으면 어떻게 됐을까?
객체 안에 객체가 몇 개까지 있을지 알 수 없으므로 이를 재귀적으로 비교하려 할 경우 성능에 악영향을 미칠 것이다.

4.1.2 정리

지금까지 자바스크립트에 존재하는 데이터 타입은 무엇인지, 그리고 이 데이터 타입은 어떻게 저장되며 이 값의 비교는 어떻게 수행되는지 살펴봤다.

특히 자바스크립트에서 객체 비교의 불완전성은 스칼라나 하스켈 등의 다른 함수형 언어에서는 볼 수 없는 특징으로, 자바스크립트 개발자라면 반드시 기억해 두어야 한다.
이러한 자바스크립트를 기반으로 한 리액트의 함수형 프로그래밍 모델에서도 이러한 언어적인 한계를 뛰어넘을 수 없으므로 얕은 비교만을 사용해 비교를 수행해 필요한 기능을 구현하고 있다.

이러한 자바스크립트의 특징을 잘 숙지한다면 향후 함수형 컴포넌트에서 사용되는 훅의 의존성 배열의 비교, 렌더링 방지를 넘어선 useMemouseCallback의 필요성, 렌더링 최적화를 위해서 꼭 필요한 React.memo를 올바르게 작동시키기 위해 고려해야 할 것들을 쉽게 이해할 수 있을 것이다.