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
이따 살펴볼 원시값 null
과 undefined
는 오직 각각 null
과 undefined
라는 값만 가질 수 있으며, 그 밖의 타입은 가질 수 있는 값이 두 개 이상(boolean
의 true
, false
와 같이) 존재한다.
4.1.1.2 null
아직 값이 없거나 비어있는 값을 표현할 때 null
을 사용한다.
typeof null === 'object'; // true
null
이 가지고 있는 특별한 점 하나는 다른 원시값과 다르게 typeof
로 null
을 확인했을 때 해당 타입이 아닌 object
로 반환된다는 것이다.
이는 초창기 자바스크립트가 값을 표현하는 방식 때문에 발생한 문제로, 이후에 typeof null
을 진짜 null
로 표현하고자 하는 시도가 있었으나 이전 코드에서 작동할 수 없는 호환성이 깨지는 변경 사항(breaking change)이어서 받아들여지지 않았다.
- undefined: 선언됐지만 할당되지 않은 값
- null: 명시적으로 비어있음을 나타내는 값
4.1.1.3 boolean
true
와 false
만을 가질 수 있는 데이터 타입이다.
주로 조건문에서 많이 쓰는 데이터 타입이다.
한 가지 주목할만한 점은 true
, false
와 같은 boolean
형의 값 외에도 조건문에서 마치 true
와 false
처럼 취급되는 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
는 컴포넌트에 실제로 변경된 값이 없음에도 불구하고 메모이제이션된 컴포넌트를 반환하지 못한다.
즉, Component
는 props.counter
가 존재하지만, DeeperComponent
는 props.counter.counter
에 props
가 존재한다.
상위 컴포넌트인 App
에서 버튼을 클릭해서 강제로 렌더링을 일으킬 경우, shallowEqual
을 사용하는 Component
함수는 위 로직에 따라 정확히 객체 간 비교를 수행해서 렌더링을 방지해 주었지만, DeeperComponent
함수는 제대로 비교하지 못해 memo
가 작동하지 않는 모습을 볼 수 있다.
만약 내부에 있는 객체까지 완벽하게 비교하기 위한 재귀문까지 넣었으면 어떻게 됐을까?
객체 안에 객체가 몇 개까지 있을지 알 수 없으므로 이를 재귀적으로 비교하려 할 경우 성능에 악영향을 미칠 것이다.
4.1.2 정리
지금까지 자바스크립트에 존재하는 데이터 타입은 무엇인지, 그리고 이 데이터 타입은 어떻게 저장되며 이 값의 비교는 어떻게 수행되는지 살펴봤다.
특히 자바스크립트에서 객체 비교의 불완전성은 스칼라나 하스켈 등의 다른 함수형 언어에서는 볼 수 없는 특징으로, 자바스크립트 개발자라면 반드시 기억해 두어야 한다.
이러한 자바스크립트를 기반으로 한 리액트의 함수형 프로그래밍 모델에서도 이러한 언어적인 한계를 뛰어넘을 수 없으므로 얕은 비교만을 사용해 비교를 수행해 필요한 기능을 구현하고 있다.
이러한 자바스크립트의 특징을 잘 숙지한다면 향후 함수형 컴포넌트에서 사용되는 훅의 의존성 배열의 비교, 렌더링 방지를 넘어선 useMemo
와 useCallback
의 필요성, 렌더링 최적화를 위해서 꼭 필요한 React.memo
를 올바르게 작동시키기 위해 고려해야 할 것들을 쉽게 이해할 수 있을 것이다.