관리 메뉴

bright jazz music

자바스크립트의 타입과 동등비교 본문

Language/Javascript

자바스크립트의 타입과 동등비교

bright jazz music 2023. 12. 29. 23:15

리액트에서의 의존성 배열이 작동하는 방식. 왜 의존성배열에 특정한 변수들을 채워야 하는가?

리엑트 컴포넌트의 렌더링이 일어나는 이유증 하나는 props의 동등비교에 따른 결과이다.

props의 동등비교는 객체의 얕은 비교를 기반으로 이루어진다. 이 얕은 비교가 리액트에서 어떻게 작동하는지 이해하지 못하면 렌더링 최적화에 어려움을 겪을 수 있다.

 

리액트의 가상 DOM과 실제 DOM의 비교, 리액트 컴포넌트가 렌더링할지를 판단하는 방법, 변수하 함수의 메모이제이션 등 모든 작업은 자바스크립트의 동등비교를 기반으로 한다.

 

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

 

자바스크립트의 모든 값은 데이터 타입을 갖고 있으며, 이 데이터 타입은 원시 타입과 객체 타입으로 나눌 수 있다.

 

1.1.원시타입(primitive type)

 

원시 타입은 객체가 아닌 모든 타입을 의미한다. 객체가 아니므로 원시타입등른 메서드를 갖지 않는다. ES2022와 같은 최신 JS에서는 총 7개의 원시 타입이 있다.

 

- boolean

- null

- undefined

- number

- string

- symbol

- bigint

 

 

 

 

1.1.1. undefined

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

let foo
typeof foo === 'undefined' // true

function bar(hello) {
	return hello
}

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

 

 

 

1.1.2. null

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

typeof null === 'object' // true?

 

null 은 typeof로 확인했을 떄 'object'라는 결과가 반환된다는 것이다. 이는 초창기 JS가 값을 표현하는 방식 때문에 발생한 문제이다. undefined는 '선언됐지만 할당되지 않은 값'이고, null은 '명시적으로 비어있음을 나타내는 값'으로 사용하는 것이 일반적이다.

 

원시값 중 null과 undefined는 오직 각각 null과 undefined라는 값만 가질 수 있으며, 그 밖의 타입은 가질 수 있는 값이 두 개 이상 존재한다. 이는 Boolean이 true와 false 두 가지 값을 가질 수 있고, Number가 다양한 숫자 값들을 가질 수 있으며 String이 다양한 조합의 문자들을 표현할 수 있는 것과 대조적이다.

 

 

 

1.1.3. boolean

true, false만을 가질 수 있는 데이터 타입이다. 주로 조건문에서 쓰인다.

 

truthy와 falsy 값은 마치 boolean 처럼 쓰일 수 있다. truthy에 하당하는 값들은 true, 해당하지 않는 값들은 false로 취급된다.

 

falsy 가 가능한 값의 실제 타입

타입 설명
false booelan 대표적인 falsy 값
0, -0, 0n, 0x0n Number, BigInt 0은 부호, 소수점 유무에 무관히 falsy 값이다.
Nan Number Not a Number
' ', " ", ` ` String 공백 없는 빈 문자열
null null null은 falsy이다.
undefined undefined undefined도 falsy이다.

 

 

falsy값 이외의 모든 값은 truthy로 취급된다. 그런데 객체와 배열은 내부 값의 존재 여부와 무관히 truthy로 취급된다. 

즉, {}, []는 모두 truthy이다.

if (1) {	//true }

if (0) {	//false }
if (NaN) {	//false }

//조건문 외에도 truthy와 falsy를 Boolean()을 통해 확인 가능
Boolean(1) // true
Boolean(0) // false
Boolean(true) // true

 

 

1.1.4. Number

다른 언어와는 다르게 JS는 모든 숫자를 하나의 타입에 저장했었다. Number에는 -(2^53 - 1) 과 2^53 - 1 사이의 값을 저장할 수 있다. 이 범위 밖의 값들은 이후 bigInt가 등장하기 전까지는 다루기가 어려웠으며, 아래와 같이 숫자 범위를 확인하였다.

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진수로 해석되어 동일한 값으로 표시된다. 

//괄호 안의 값은 10진수이다. 그리고 toString(진수)를 사용하여 진법을 바꾼다.

const 이진수_2 = 0b10 //2진수(binary) 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

 

 

1.1.5. bigInt

number가 다룰 수 있는 숫자의 제한을 극복하기 위해 ES2020에서 새롭게 등장하였다.

//기존 number의 한계
9007199254740992 === 9007199254740993 //true. 마지막 숫자가 다르지만 number 범위 외이므로 true가 나온다

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

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

const number = 9007199254740992
const bigInt = 9007199254740992n

typeof number // number
typeof bigint // bigint

number == bigint // true
number === bigint //false(타입 다름)

 

 

1.1.6. String

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

 

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

// '\n 안녕하세요.\n'
const longText = `
	안녕하세요
`

//Uncaught SyntaxError : Invalid or unedpected token 오류발생
const longText = "
	안녕하세요.
"

 

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

const foo = 'bar'
console.log(foo[0]) // 'b'

foo[0] = 'a'
console.log(foo) // bar 변경이 반영되지 않았다.

 

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

 

 

1.2. 객체타입/참조타입 (object / reference type)

- object

 

1.2.1 객체 타입

객체 타입은 앞서 7가지 원시 타입 이외의 모든 것, 즉 JS를 이루고 있는 대부분의 타입이 바로 객체 타입이다.  여기에는 배열, 함수, 정규식, 클래스 등이 포함된다. 객체 타입은 참조를 전달한다고 해서 참조 타입(reference type)으로도 불린다. 여기서 JS의 동등비교의 특징이 나타난다.

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

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

const hello1 = function () {
}

const hello2 = function () {
}

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

 

 

2. 값을 저장하는 방식의 차이

 

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

 

원시 타입은 불변 형태의 값으로 저장된다. 그리고 이 값은 변수 할당 시점에 메모리 영역을 차지하고 저장된다.

let hello = 'hello world'
let hi = hello

console.log(hello === hi) //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를 반환하게 된다.값은 같지만 참조하는 곳이 다르다. 반면 참조를 전달하는 경우에는 이전에 원시값에서 했던 결과를 기대할 수 있다.

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

var hi = hello

console.log(hi === hello_ // true

 

따라서 JS 사용 시 객체 간 비교가 발생하면, 객체의 내용(값)이 같다고 하더라도 결과는 대부분 false라는 것을 인지해야 한다.

 

JavaScript의 객체는 메모리 공간에 분산되어 저장된 속성들의 주소들을 종합하여 하나의 고유한 값으로 표현되며, 이것이 객체의 참조이다. 객체의 참조는 실제로는 객체가 저장된 메모리 위치를 가리키는 것으로 생각할 수 있다. 따라서 객체를 변수에 할당하거나 함수에 전달할 때, 실제 객체가 아닌 객체의 참조(메모리 주소)가 전달되며, 이로 인해 객체의 내용을 수정하면 해당 객체를 참조하는 모든 변수나 함수에서 수정 사항이 반영된다. 이러한 동작은 JavaScript의 객체 모델을 이해하고 사용하는 데 중요한 개념 중 하나이다.

 

 

 

3. 자바스크립트에서의 또 다른 비교 공식 Object.is

 

JS에서는 비교를 위해 또 한 가지 방법을 제공한다. Object.is다.

Object.is는 두 개의 인수를 받으며, 이 인수 두 개가 동일한지 확인하고 반환한다. Object.is가 ==나  ===와 다른 점은 아래와 같다.

 

3.1. == vs. Object.is

== 비교는 같음을 비교하기 전에 양쪽의 타입이 다르다면 비교할 수 있도록 강제 형변환(type casting)을 한다.

5 == '5'와 같이 형변환 후에 값이 동일하다면 ==는 true를 반환한다. 하지만 Object.is는 이러한 형변환 작업을 하지 않는다. 즉, ===와 동일하게 타입이 다르면 false이다.

 

3.2. === vs. Object.is

=== 가 만족하지 못하는 특이한 케이스를 추가하기 위해 Object.is가 나름의 알고리즘으로 작동한다.

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

Number.NaN === NaN // false. Number.NaN과 NaN 모두 number 타입이지만 NaN은 특수한 숫자값으로 비교할 수 없다. 엄밀히는 다르다고 한다.
Object.is(Number.NaN, NaN) // true

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

 

그러나 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)에서 새롭게 도입된 비교 문법이다. ===가 가지는 한계를 극복하기 위해 만들어졌다. 그러나 객체 간 비교에 있어서는 JS의 특징으로 인해 ===와 동일하게 동작하는 것을 알 수 있다.

 

 

 

리액트에서는 Object.is를 사용하여 동등비교를 한다. Object.is는 ES6에서 제공하는 기능이기 때문에 이를 구현한 Polyfill을 함께 사용한다. objectIs 함수가 그것이다. polyfill은 호환성을 지원하기 위해 만들어지는 모방함수, 또는 모방 라이브러리라고 생각해도 좋다.

 

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

 

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

 

이것은 객체의 내부 속성까지 깊이 비교하지 않고, 객체가 동일한 참조(메모리 주소)를 가리키는 경우에만 두 객체를 같다고 판단한다는 것이다.

 

이러한 비교 방법은 값 비교의 다양한 측면을 다루기 위한 것(다른 종류를 두 번 하는 것)이며, 특히 React와 같은 라이브러리에서는 상태나 프롭스(props)의 변경 여부를 판단할 때 유용하게 사용된다. React에서 상태나 프롭스가 변경되었을 때 컴포넌트를 다시 렌더링할지 여부를 결정할 때 비교 연산을 수행하므로, 이러한 비교 방법은 성능 최적화에 중요한 역할을 한다. 즉, 이것은 값의 변경 여부를 효율적으로 확인하기 위한 방법으로, 깊은 내부 구조의 객체를 비교하지 않고 얕은 비교만으로 변경 여부를 빠르게 확인할 수 있게 도와주는 개념이다.

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

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

// 그러나 2뎁스까지 가면 비교할 방법이 없으므로 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 hell="hi!" />
}

 

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

 

 

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)
    }
    
    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가 작동하지 않는다.

 

만약 내부에 있는 개체까지 완벽히 비교하기 위해 재귀문까지 넣었다면, 특히 객체가 내부에 여러 개였다면 성능에 악영향을 미쳤을 것이다.

 

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

 

 

깊은 비교(Deep Comparison)는 객체의 각 속성값뿐만 아니라 객체 내부에 중첩된 다른 객체의 속성까지 재귀적으로 비교하는 것을 의미한다. 이것은 객체의 모든 속성과 중첩된 객체까지 모두 비교하여 두 객체가 완전히 동일한 구조와 값인지를 확인하는 것을 의미한다. 깊은 비교를 수행하면 객체의 모든 속성을 검사하고, 중첩된 객체의 경우에도 내부 속성들을 재귀적으로 검사한다. 이런 방식으로 모든 속성과 중첩 구조를 다루어야 하므로 깊은 비교는 얕은 비교보다 더 많은 계산이 필요하며, 복잡한 객체 구조에서는 성능 저하의 원인이 될 수 있다. 깊은 비교는 JavaScript의 표준 비교 연산자(== 또는 ===)로 수행할 수 없으며, 대신 사용자 정의 함수나 라이브러리를 통해 구현해야 한다. 일반적으로 두 객체의 모든 속성과 중첩된 객체까지 비교하는 깊은 비교를 수행하려면 재귀 함수를 사용하거나, 외부 라이브러리(예: Lodash의 _.isEqual)를 활용하는 것이 일반적이다.

 

참고:

https://product.kyobobook.co.kr/detail/S000210725203

 

모던 리액트 Deep Dive | 김용찬 - 교보문고

모던 리액트 Deep Dive | 요즘 프런트엔드 개발은 자바스크립트와 리액트부터 시작한다는 말이 있을 정도로 최근 몇 년간 프런트엔드 생태계에서 리액트의 비중은 날이 갈수록 커지고 있습니다.

product.kyobobook.co.kr

 

'Language > Javascript' 카테고리의 다른 글

자바스크립트 기본 문법  (0) 2024.02.26
이벤트 루프와 비동기 통신  (0) 2024.01.03
클로저(closure)  (0) 2024.01.03
자바스크립트 클래스  (0) 2024.01.01
함수 관련  (0) 2023.12.31
Comments