'당신이 놓친 자바스크립트' 시리즈는 모던 Javascript 튜토리얼를 공부하며 필자가 자바스크립트에 대해 몰랐던 점이나 헷갈렸던 점들을 정리한 시리즈입니다. 모든 출처는 위 사이트에 있습니다.
본 포스팅에서는 이전 포스팅에 이어서 객체의 기본적인 개념과 관련해 쉽게 놓칠 수 있는 부분들을 다룹니다. 본 포스팅의 내용들 중 '심볼'과 관련된 내용은 필자도 실무에서 잘 사용되는지 장담할 수 없으므로, 가볍게 개념만 알아가는 정도로 알아두시면 좋을 것입니다.
옵셔널 체이닝(optional chaining)인 ?.을 사용하면, 프로퍼티가 없는 중첩 객체를 에러 없이 편하게 접근할 수 있습니다.
옵셔널 체이닝이라는 개념이 존재하지 않았을 때에는, 어떤 객체가 존재하는지 확인하고 존재한다면 객체의 특정 프로퍼티에 접근하는 로직을 작성하기 위해 주로 && 연산자를 사용했습니다. 물론 이 방법도 좋은 방법이지만, AND 연산자를 연결해서 사용하면 코드가 한 줄로 아주 길어진다는 단점이 있습니다.
let user = {};
alert( user && user.address && user.address.street ); // undefined
// user.address가 존재하지 않으므로 user.address.street까지 코드가 도달하지 않아 에러가 발생하지 않습니다.
옵셔널 체이닝 ?.은 ?. 앞의 평가 대상이 undefined이거나 null이면 평가를 즉시 멈추고 undefined를 반환합니다. 이러한 평가 방법을 단락 평가(short-circuit)라고 부릅니다.
이러한 평가 방식으로 동작하기 때문에, 함수 호출을 비롯하여 ?. 오른쪽에 있는 부가 동작은 ?.의 평가가 멈췄을 때 더는 일어나지 않습니다.
let user = {};
alert( user?.address?.street ); // undefined
// user.address가 undefined이기 때문에 user?.address에서 평가가 끝납니다.
// 따라서, street까지 코드가 도달하지 않습니다.
user = null;
let x = 0;
user?.sayHi(x++); // user?.에서 평가가 끝나기 때문에, sayHi가 호출되지 않습니다.
옵셔널 체이닝은 존재하지 않아도 괜찮은 대상에만 사용해야 합니다. 예를 들어, 위의 예제에서 user라는 객체는 반드시 존재해야 하지만 address는 필수값이 아니라면, user?.address?.street가 아니라 user.address?.street를 사용하는 것이 바람직합니다.
옵셔널 체이닝을 남용하면 user가 반드시 존재해야 하는데도 불구하고 존재하지 않을 때 에러가 발생하지 않아, user가 존재하지 않음을 조기에 발견하지 못하고 디버깅이 어려워집니다.
?.은 연산자가 아니라, 함수나 대괄호와 함께 동작하는 특별한 문법 구조체입니다. 따라서, 존재 여부가 확실하지 않은 함수를 호출할 때나, 대괄호 []를 사용해 객체 프로퍼티에 접근하는 경우에도 ?.를 사용할 수 있습니다.
------ 옵셔널 체이닝을 이용한 함수 호출 ------
let user1 = {
admin() {
alert("관리자 계정입니다.");
}
}
let user2 = {};
user1.admin?.(); // user1에는 admin 메서드가 존재하므로 정상적으로 호출됩니다.
user2.admin?.(); // user2에는 admin 메서드가 없으므로(undefined) 함수 호출이 무시됩니다.
------ 옵셔널 체이닝을 이용한 객체 프로퍼티 접근 -----
let user1 = {
firstName: "Violet"
};
let user2 = null;
let key = "firstName";
alert( user1?.[key] ); // Violet
alert( user2?.[key] ); // undefined. user2가 null이므로 [key]는 평가되지 않습니다.
alert( user1?.[key]?.something?.not?.existing); // undefined
// user1?.[key]의 결과가 "Violet"이라는 문자열이므로, something 프로퍼티를 가질 수 없습니다.
// 따라서, .not부터는 평가되지 않습니다.
delete user?.name; // user가 존재하면 user.name을 삭제합니다.
user?.name = "Violet"; // SyntaxError 발생. 옵셔널 체이닝을 쓰기에 사용할 수 없기 때문입니다.
심볼형은 자바스크립트의 원시 자료형 중 하나입니다. 자바스크립트는 객체 프로퍼티의 키로 오직 문자형과 심볼형만을 허용합니다. 심볼(Symbol)은 유일한 식별자(unique identifier)를 만들고 싶을 때 사용합니다.
아래와 같이 Symbol()을 사용하면 심볼값을 만들 수 있습니다. 심볼을 만들 때 심볼 이름이라 불리는 설명을 붙일 수도 있습니다. 심볼은 유일성이 보장되는 자료형이기 때문에, 이름이 동일한 심볼을 여러 개 만들어도 각 심볼값은 다릅니다. 심볼 이름은 어떤 것에도 영향을 주지 않는 이름표 역할만을 합니다.
let id1 = Symbol(); // id는 새로운 심볼이 됩니다.
let id2 = Symbol("id"); // "id"라는 심볼 이름을 가지는 새로운 심볼을 만듭니다.
let id3 = Symbol("id"); // id2와 심볼 이름이 "id"로 같지만, 심볼 값은 다릅니다.
alert(id2 === id3); // false
alert(id2 == id3); // false
let id = Symbol("id");
alert(id); // TypeError. id를 문자열로 자동 형 변환 할 수 없습니다.
alert(id.toString()); // "Symbol(id)"라는 문자열이 출력됩니다.
alert(id.description); // 심볼 이름인 "id"가 출력됩니다.
심볼을 이용하면 '숨김(hidden)' 프로퍼티를 만들 수 있습니다. 숨김 프로퍼티는 동일한 심볼을 가지고 있지 않는 한 외부 코드에서 접근이 불가능하고, 값도 덮어쓸 수 없는 프로퍼티입니다.
이러한 숨김 프로퍼티는 외부 스크립트나 라이브러리와 같은 서드파트 코드에서 가져온 객체에 고유한 식별자를 붙여주는 용도 등으로 사용할 수 있습니다. 서드파티 코드에서 가져온 객체에는 함부로 새로운 프로퍼티를 추가할 수 없는데, 심볼은 서드파티 코드에서 접근할 수 없기 때문에 서드파티 코드가 모르게 객체에 나만의 식별자를 부여할 수 있습니다.
심볼은 '심볼형 프로퍼티 숨기기'라는 원칙으로 인해, 프로퍼티의 키로 사용되었을 때 for...in 반복문과 Object.keys()의 결과에서 배제됩니다. 따라서, 프로퍼티를 더더욱 '은밀하게' 숨길 수 있습니다.
// 객체 user가 서드파티에서 가져온 객체라고 가정
let user = {
name: "John"
};
let id = Symbol("id");
user[id] = 1; // 서드파티 객체에 심볼형 프로퍼티 추가
alert( user[id] ); // 심볼을 키로 사용해 데이터에 접근할 수 있습니다.
for (let key in user) {
alert(key); // "John"만 출력되고 심볼을 키로 가진 프로퍼티의 값 1은 출력되지 않습니다.
}
let id = Symbol("id");
let user = {
[id]: 123
};
let clone = Object.assign({}, user);
alert( clone[id] ); // 123. clone 객체에도 심볼형 프로퍼티가 복사되었습니다.
지금까지 배운 바로는, 심볼은 이름이 같더라도 모두 별개로 취급됩니다. 그런데, 이름이 같은 심볼이 같은 개체를 가리키길 원하는 경우도 있을 것입니다. 이러한 경우를 위해 전역 심볼 레지스트리가 만들어졌습니다. 전역 심볼 레지스트리 안에 심볼을 만들고 해당 심볼에 접근하면, 이름이 같은 경우 항상 동일한 심볼을 반환해줍니다.
레지스트리 안에 있는 심볼을 읽거나, 새로운 심볼을 생성하려면 Symbol.for(key)를 사용하면 됩니다. 이 메서드를 호출하면 이름이 key인 심볼을 반환하며, 조건에 맞는 심볼이 레지스트리 안에 없으면 새로운 심볼 Symbol(key)를 만들고 레지스트리 안에 저장합니다. 전역 심볼 레지스트리 안에 있는 심볼은 전역 심볼이라고 불립니다.
// 전역 레지스트리에서 심볼을 읽습니다.
let id = Symbol.for("id"); // 심볼이 존재하지 않으면 전역 레지스트리에 새로운 심볼을 만듭니다.
// 동일한 이름을 이용해 심볼을 다시 읽습니다.
let idAgain = Symbol.for("id");
// 두 심볼은 같습니다.
alert( id === idAgain ); // true
let globalSymbol = Symbol.for("name");
let localSymbol = Symbol("name");
alert( Symbol.keyFor(globalSymbol) ); // name, 전역 심볼
alert( Symbol.keyFor(localSymbol) ); // undefined, 전역 심볼이 아님
alert( localSymbol.description ); // name
시스템 심볼(system symbol)은 자바스크립트 내부에서 사용하는 심볼이며, Symbol.*로 접근할 수 있습니다. 시스템 심볼을 이용하면 내장 메서드 등의 기본 동작을 미세하게 변경할 수 있으며, 잘 알려진 시스템 심볼에는 다음과 같은 심볼들이 존재합니다.
각 심볼들이 어떤 기능을 하는지는 이후 챕터들에서 차근차근 배워볼 것입니다. 이들 중 객체의 형 변환에 사용되는 Symbol.toPrimitive는 바로 아래에서 살펴볼 것입니다.
사실, 심볼을 완전히 숨길 수는 없습니다. 내장 메서드인 Object.getOwnPropertySymbols(obj)를 사용하면 모든 심볼을 볼 수 있고, 메서드 Reflect.ownKeys(obj)는 심볼형 키를 포함한 객체의 모든 키를 반환해줍니다. 하지만, 대부분의 라이브러리나 내장 함수에서는 이런 메서드를 사용하지 않습니다.
obj1 + obj2처럼 객체끼리 더하는 연산을 하거나, obj1 - obj2와 같이 객체끼리 빼는 연산, 그리고 alert(obj)로 객체를 출력할 때와 같이 원시값을 기대하는 내장 함수나 연산자를 사용할 때 객체는 원시 자료형으로 자동 형변환됩니다.
객체 형 변환은 세 종류로 구분되는데, 'hint'라고 불리는 값이 구분 기준이 됩니다. hint는 '목표로 하는 자료형' 정도로 이해하면 됩니다. hint에 따른 객체 형 변환의 각 종류는 다음과 같습니다. 아래의 사항들을 모두 기억할 필요는 없습니다. Date 객체를 제외한 모든 내장 객체는 hint가 default인 경우와 number인 경우를 동일하게 처리하기 때문입니다.
// 객체를 출력하려고 함
alert(obj);
// 객체를 프로퍼티 키로 사용하고 있음. 프로퍼티 키는 일반적으로 문자열.
anotherObj[obj] = 123;
// 명시적 형 변환
let num = Number(obj);
// (이항 덧셈 연산을 제외한) 수학 연산
let n = +obj; // 단항 덧셈 연산
let delta = date1 - date2;
// 크고 작음 비교하기
let greater = user1 > user2;
default: 아주 드문 경우로, 연산자가 기대하는 자료형이 확실치 않을 때 hint는 default가 됩니다. 이항 덧셈 연산자 +는 피연산자의 자료형에 따라 문자열을 합치는 연산을 할 수도 있고, 숫자를 더해주는 연산을 할 수도 있습니다. 따라서, +의 인수가 객체일 때에는 hint가 default가 됩니다.
동등 연산자 ==를 사용해 객체-문자형, 객체-숫자형, 객체-심볼형끼리 비교할 때도, 객체를 어떤 자료형으로 바꿔야 할지 확신이 안 서므로 hint는 default가 됩니다.
// 이항 덧셈 연산은 hint로 `default`를 사용합니다.
let total = obj1 + obj2;
// obj == number 연산은 hint로 `default`를 사용합니다.
if (user == 1) {
...
};
obj[Symbol.toPrimitive] = function(hint) {
// 이 메서드는 반드시 원시값을 반환해야 합니다.
// hint는 "string", "number", "default" 중 하나가 될 수 있습니다.
}
----- 사용 예시 -----
let user = {
name: "John",
money: 1000,
[Symbol.toPrimitive](hint) {
alert(`hint: ${hint}`);
return hint == "string" ? `{name: ${this.name}"}` : this.money;
}
}
alert(user); // hint가 string이므로 {name: "John"} 출력
alert(+user); // hint가 number이므로 1000 출력
alert(user + 500); // hint가 default이므로 1500 출력
위에서 obj[Symbol.toPrimitive](hint) 메서드가 없다면 toString이나 valueOf 메서드가 호출된다고 설명했습니다. 이러한 메서드에 익숙한 분들도 계시겠지만, 이들은 심볼이 생기기 이전부터 존재해왔던 평범한 메서드입니다. 객체의 형 변환 시 toString이나 valueOf가 호출될 때는 아래의 규칙을 따릅니다.
이 메서드들은 반드시 원시값을 반환해야 하며, 만약 toString이나 valueOf가 객체를 반환하면 그 결과는 무시되어 마치 메서드가 처음부터 없었던 것 처럼 동작합니다.
일반 객체는 일반적으로 toString과 valueOf에 적용되는 다음 규칙을 따릅니다.
"[object Object]"는 자바스크립트를 다뤄보신 분들이라면 한 번 쯤은 보셨을 거라고 생각합니다. 이러한 이유 때문에 alert에 일반 객체를 넘기면 아래와 같이 "[object Object]"가 출력됩니다.
let user = {name: "John"};
alert(user); // [object Object]
alert(user.valueOf() === user); // true
let user = {
name: "John",
money: 1000,
// hint가 "string"인 경우
toString() {
return `{name: "${this.name}"}`;
},
// hint가 "number"나 "default"인 경우
valueOf() {
return this.money;
}
};
alert(user); // toString -> {name: "John"}
alert(+user); // valueOf -> 1000
alert(user + 500); // valueOf -> 1500
let obj = {
// 다른 메서드가 없으면 toString에서 모든 형 변환을 처리합니다.
toString() {
return "2";
}
};
alert(obj * 2); // 4, 객체가 문자열 "2"로 바뀌고, 곱셈 연산자가 문자열 "2"를 숫자 2로 변경합니다.