부제: 왜 [1,2,3] === [1,2,3]이 false인가? 자바스크립트 비교 연산 이해하기
you don't know JS Yet 을 읽고 난 뒤, 그 내용을 학습하기 위한 포스팅입니다.
이 책은 자바스크립트 자체에 대한 내용, 즉 자바스크립트 이름의 유래와 명세서, JS의 하위 호환성과 상위 호환성에 대한 내용을 시작으로 이야기를 풀어갑니다. (이 부분에 대해서는 독후감, 독서활동이 아닌 추후에 더 공부해서 다른 프로그래밍 언어와 비교해서 정리한 비교 포스팅을 작성해볼 계획입니다.)
이 책에서 가장 인상 깊었던 것은 챕터별로 딱딱 나눠져서 있는 느낌이 아니라 js는 어떻게 발전했고 자바스크립트는 어떻게 사용되고 인식되어져 왔으며, 어떠한 배경에서 es6 모듈이 나왔고 지금의 JS가 있을 수 있었는지 이런 식으로 자연스럽게 이어지는 흐름이 대단했습니다. 오늘은 제가 잘못 이해하고 있었던 개념에 대해서 중심적으로 파보는 시간을 가져보려고 합니다. 제가 오늘 다룰 주제는 다음과 같습니다.
포스팅은 3편에 나눠서 업로드 됩니다.
A. 자바스크립트 조망하기
1. 강제변환과 깊은비교, 일치비교와 동등비교,
2. js의 this 키워드와 프로토타입, 그리고 메모리주소
B. 추가 주제
1. es6 모듈에서 class를 사용하지 않는
A. 자바스크립트 조망하기
1. 강제변환과 깊은비교, 일치비교와 동등비교
자바스크립트를 공부하면서 가장 헷갈렸던 부분 중 하나가 바로 비교 연산이었다. 특히 배열이나 객체를 비교할 때 예상과 다른 결과가 나와서 당황했던 경험이 있다. "You Don't Know JS Yet"을 읽으면서 내가 왜 이런 부분을 잘못 이해하고 있었는지 깨닫게 되었다.
원시 타입 vs 참조 타입
먼저 내가 가장 헷갈렸던 코드 예시를 살펴보자:
var x = [1, 2, 3];
var y = x;
var a = [1, 2, 3, 4];
var b = [1, 2, 3, 4];
var c = {a: 1, b: 2};
var d = {a: 1, b: 2};
console.log(y === x); // true
console.log(a === b); // false
console.log(c === d); // false
console.log(y = [1, 2, 3]); // [1, 2, 3] (할당 연산의 결과)
console.log(x === [1, 2, 3]); // false
처음에는 단순히 "값이 같으면 같다고 판단하지 않나?"라고 생각했는데, 이는 자바스크립트의 참조 타입에 대한 이해가 부족했기 때문이다. 이제 제대로 정리해보자.
자바스크립트에서 데이터 타입은 크게 두 가지로 나눌 수 있다:
- 원시 타입(Primitive Types): string, number, boolean, null, undefined, symbol, bigint
- 참조 타입(Reference Types): object, array, function 등 (사실 이들은 모두 객체의 일종이다)
원시 타입은 값 자체가 변수에 할당되지만, 참조 타입은 메모리상의 주소값(참조)이 변수에 할당된다. 이 차이가 비교 연산에서 혼란을 일으키는 주요 원인이다.
일치 비교(===)와 동등 비교(==)
자바스크립트에서는 비교 연산자로 ===(일치 비교)와 ==(동등 비교)를 제공한다.
일치 비교(===)
일치 비교는 타입 변환 없이 값과 타입이 모두 같은지 비교한다. 참조 타입의 경우, 같은 객체를 참조하는지(즉, 같은 메모리 주소를 가리키는지) 확인한다.
var x = [1, 2, 3];
var y = x;
console.log(y === x);
var a = [1, 2, 3, 4];
var b = [1, 2, 3, 4];
console.log(a === b);
그래서 a === b가 false로 나오는 것이다! 내용이 같아도 서로 다른 객체(다른 메모리 주소)이기 때문이다.
동등 비교(==)
동등 비교는 타입이 다르면 타입 변환을 시도한 후 비교한다. 하지만 객체 간 비교에서는 ===와 마찬가지로 참조가 같은지만 확인한다.
console.log("1" == 1); // true: 문자열 "1"이 숫자 1로 변환됨
console.log([1, 2, 3] == [1, 2, 3]); // false: 다른 객체
강제 변환(Coercion)
자바스크립트의 또 다른 흥미로운 부분은 강제 변환이다. 강제 변환은 명시적(explicit)과 암묵적(implicit) 두 가지로 나뉜다.
명시적 강제 변환
개발자가 의도적으로 타입을 변환하는 경우다:
var a = "42";
var b = Number(a);
console.log(b);
암묵적 강제 변환
자바스크립트 엔진이 자동으로 타입을 변환하는 경우다:
var a = "42";
var b = a * 1;
console.log(b);
console.log("10" > 9);
여기서 내가 놀랐던 점은 비교 연산자 >=, <=, >, < 모두 ==와 같이 타입 변환을 수행한다는 것이다. 이런 암묵적 변환이 예상치 못한 결과를 가져올 수 있기 때문에 주의해야 한다.
특이 케이스: NaN
자바스크립트의 또 다른 특이한 점은 NaN(Not a Number)이다:
console.log(NaN === NaN);
NaN은 자기 자신과도 같지 않다! 이는 IEEE 754 부동소수점 표준을 따르기 때문인데, NaN을 확인하려면 isNaN() 함수나 ES6의 Number.isNaN()을 사용해야 한다.
깊은 비교(Deep Equality)
객체나 배열의 내용이 같은지 비교하려면 어떻게 해야 할까? 이를 위해 "깊은 비교"를 수행해야 한다:
- JSON을 활용한 방법:단, 이 방법은 객체에 함수, 심볼, undefined 등이 포함되어 있거나 순환 참조가 있는 경우 제대로 작동하지 않는다.
- JSON.stringify(obj1) === JSON.stringify(obj2)
- lodash나 underscore 같은 라이브러리 사용:
- _.isEqual(obj1, obj2) // lodash의 isEqual 메서드
- 직접 재귀 함수 작성:
- function deepEqual(obj1, obj2) { // 타입이 다르면 바로 false 반환 if (typeof obj1 !== typeof obj2) return false; // 원시 타입이거나 null이면 일치 비교 if (obj1 === null || typeof obj1 !== 'object') return obj1 === obj2; // 키 개수 비교 const keys1 = Object.keys(obj1); const keys2 = Object.keys(obj2); if (keys1.length !== keys2.length) return false; // 각 키의 값 재귀적으로 비교 return keys1.every(key => deepEqual(obj1[key], obj2[key])); }
하지만 실무에서 객체 비교에 대해 더 고민하면서 깨달은 점이 있다. 사실 객체 전체를 깊게 비교하는 것이 항상 필요한 것은 아니다. 종종 우리가 정말로 필요한 것은 특정 프로퍼티나 필드의 값만 비교하는 것이다.
예를 들어, 사용자 목록에서 특정 ID를 가진 사용자를 찾는 경우:
const findUserById = (users, id) => users.find(user => user.id === id);
또는 상품 객체들 중에서 가격이 특정 범위에 있는 것만 필터링하는 경우:
const affordableProducts = products.filter(product => product.price < 50);
이렇게 명확한 비교 기준(id, price 등)을 정하고 범위를 좁히는 접근법이 실제로 더 효율적이고 가독성도 좋다. 모든 필드를 일일이 비교하는 깊은 비교는 성능 비용이 크고, 실제로는 그런 완전한 비교가 필요한 경우가 생각보다 많지 않다는 것을 알게 되었다.
물론 두 객체가 정확히 같은 상태인지 테스트할 때는 깊은 비교가 필요하다. 하지만 그 전에 "내가 정말로 확인하고 싶은 것이 무엇인가?"를 먼저 생각해보는 것이 좋다.
React에서의 객체 비교와 렌더링
자바스크립트의 참조 타입 비교 특성은 React와 같은 프레임워크에서 특히 중요한 영향을 미친다. React의 렌더링 메커니즘을 이해하는 데 핵심이 되는 부분이다.
useState와 객체 상태의 문제점
React에서 useState를 사용해 객체 형태의 상태를 관리할 때, 객체 내부의 프로퍼티만 변경하면 컴포넌트가 재렌더링되지 않는 경우가 있다. 이는 앞서 설명한 참조 타입의 비교 방식 때문이다.
function UserProfile() {
const [user, setUser] = useState({
name: "Alice",
age: 30,
preferences: {
theme: "dark",
notifications: true
}
});
const updateTheme = () => {
user.preferences.theme = "light";
setUser(user);
};
return (
<div>
<p>Name: {user.name}</p>
<p>Theme: {user.preferences.theme}</p>
<button onClick={updateTheme}>Change Theme</button>
</div>
);
}
위 코드에서 updateTheme 함수는 user 객체의 내부 프로퍼티를 직접 수정한 후 같은 객체 참조를 setUser에 전달한다. React는 동일한 참조(메모리 주소)를 가진 객체라고 판단하여 상태가 변경되지 않았다고 간주하고 재렌더링을 수행하지 않는다.
불변성을 유지하는 올바른 방법
React에서 상태 업데이트 시 불변성(immutability)을 유지하는 것이 중요하다. 상태 객체를 직접 수정하지 않고, 새로운 객체를 생성해야 한다:
function UserProfile() {
const [user, setUser] = useState({
name: "Alice",
age: 30,
preferences: {
theme: "dark",
notifications: true
}
});
const updateTheme = () => {
setUser({
...user,
preferences: {
...user.preferences,
theme: "light"
}
});
};
return (
<div>
<p>Name: {user.name}</p>
<p>Theme: {user.preferences.theme}</p>
<button onClick={updateTheme}>Change Theme</button>
</div>
);
}
이 방식은 새로운 객체와 그 내부 객체들을 생성하므로, React는 참조가 변경되었음을 감지하고 적절히 재렌더링한다.
중첩 객체의 복사 문제
중첩이 깊은 객체의 경우, 스프레드 연산자만으로는 모든 레벨의 불변성을 유지하기 어렵다. 다음과 같은 해결책이 있다:
- 중첩 스프레드 연산자: 위 예제처럼 각 레벨마다 스프레드 연산자를 사용
- 구조 분해 객체 업데이트: 명확한 코드를 위해 변수를 사용
- const updateTheme = () => { const updatedPreferences = { ...user.preferences, theme: "light" }; setUser({ ...user, preferences: updatedPreferences }); };
- immer 라이브러리 사용: 복잡한 중첩 객체를 직관적으로 업데이트
- import produce from 'immer'; const updateTheme = () => { setUser(produce(user, draft => { draft.preferences.theme = "light"; // 불변성이 내부적으로 처리됨 })); };
React.memo와 객체 비교
React.memo로 컴포넌트를 최적화할 때도 객체 타입 props의 비교 방식을 이해해야 한다:
function ExpensiveComponent({ config, onAction }) {
return <div>...</div>;
}
function ParentComponent() {
const [count, setCount] = useState(0);
const config = { theme: "dark", size: "large" };
const handleAction = () => {
console.log("Action triggered");
};
return (
<>
<button onClick={() => setCount(count + 1)}>Count: {count}</button>
<ExpensiveComponent config={config} onAction={handleAction} />
</>
);
}
export default React.memo(ExpensiveComponent);
위 코드에서 React.memo는 얕은 비교를 수행하기 때문에, 매 렌더링마다 새롭게 생성되는 config 객체와 handleAction 함수가 이전과 다른 참조를 가지게 되어 최적화 효과가 사라진다.
해결책: useMemo와 useCallback
이 문제를 해결하기 위해 React는 useMemo와 useCallback 훅을 제공한다:
function ParentComponent() {
const [count, setCount] = useState(0);
const config = useMemo(() => ({ theme: "dark", size: "large" }), []);
const handleAction = useCallback(() => {
console.log("Action triggered");
}, []);
return (
<>
<button onClick={() => setCount(count + 1)}>Count: {count}</button>
<ExpensiveComponent config={config} onAction={handleAction} />
</>
);
}
이제 config와 handleAction은 컴포넌트가 재렌더링되어도 동일한 참조를 유지하므로, React.memo가 제대로 작동하여 불필요한 렌더링을 방지한다.
결론: 참조 타입 이해의 중요성
자바스크립트의 참조 타입 비교 특성을 이해하는 것은 React 애플리케이션의 성능 최적화에 필수적이다. 요약하자면:
- 상태 업데이트 시 불변성을 유지해야 React가 변경을 감지할 수 있다.
- 객체를 직접 수정하지 말고, 스프레드 연산자나 immer 같은 도구를 사용하여 새 객체를 생성하자.
- 함수형 업데이트를 활용하여 이전 상태를 기반으로 안전하게 업데이트하자.
// 이전 상태에 의존하는 안전한 업데이트setUser(prevUser => ({ ...prevUser, preferences: { ...prevUser.preferences, theme: "light" }}));
- 복잡한 중첩 객체를 다룰 때는 immer 같은 라이브러리 사용을 고려하자.
- useMemo와 useCallback을 활용하여 참조 일관성을 유지하고 불필요한 렌더링을 방지하자.
이러한 React의 렌더링 메커니즘과 최적화 기법은 결국 자바스크립트의 참조 타입 비교 원리에 기반하고 있다. 따라서 자바스크립트의 기본 개념을 제대로 이해하는 것이 React를 효율적으로 사용하는 첫걸음이라고 할 수 있다.