모던 JS 튜토리얼 Part 1. 코어 자바스크립트 문서를 읽고 내용을 정리합니다.
앞서 배운 원시형(primitive type) 자료형은 오직 하나의 데이터(문자열, 숫자 등)만 담을 수 있다.
그러나 객체형은 원시형과 달리 다양한 데이터를 담을 수 있다. 키로 구분된 데이터 집합이나 복잡한 개체(entity)를 저장할 수 있다.
객체는 중괄호 {}를 이용해 만들 수 있고, 중괄호 안에는 ‘키(key): 값(value)’ 쌍으로 구성된 프로퍼티(property)
를 여러 개 넣을 수 있다.
키(key)엔 문자형, 값(value)엔 모든 자료형이 허용된다. 프로퍼티 키(key)는 ‘프로퍼티 이름’, '식별자'라고도 부른다.
서랍장 예시로 객체를 이해해보자.
서랍장에 파일이 꽂혀있을 때, 파일에 붙어있는 이름표가 객체의 키(key), 파일의 내용이 값(value)이라고 생각하면 된다. 서랍장에서 파일 이름을 보고 파일을 쉽게 찾을 수 있듯이, 객체에서는 키(key)를 이용하여 프로퍼티를 쉽게 찾는다.
//1. '객체 생성자' 문법
let user = new Object();
//2. '객체 리터럴' 문법
let user = {};
2번째 방법으로 객체를 선언하는 것을
객체 리터럴(object literal)
이라고 부른다. 객체를 선언할 때는 주로 이 방법을 사용한다.
중괄호 {} 안에는 "키:값" 쌍으로 구성된 프로퍼티가 들어간다.
let user = {
name: "John", //프로퍼티: name과 John, key: name, value: John
age: 30 //프로퍼티: age와 30, key: age, value: 30
}
프로퍼티 키(key)는 문자형만 올 수 있다. 단, 띄어쓰기가 있는 경우에는 아래와 같이 따옴표로 묶어줘야 한다. 프로퍼티 값(value)에는 모든 자료형이 올 수 있다. 아래와 같이 불린형도 가능하다.
let user = {
"likes birds": true
}
프로퍼티 값을 읽는 방법 2가지
alert(user.name); // John
alert(user.age); // 30
띄어쓰기가 있는 프로퍼티 키(key)의 경우 1번 표기법을 사용할 수 없다.
user.likes birds = true // 에러 발생
이럴 때 대괄호 표기법을 사용할 수 있다. 단, 대괄호 표기법 안에서 문자열을 사용할 땐 문자열을 따옴표로 묶어줘야 한다.
user["likes birds"] = true
아래와 같은 방법도 가능하다. 1번 표기법은 불가능하다.
let key = "likes birds";
user[key] = true;
프로퍼티 값 추가하기
단순히 아래와 같이 작성해주면 된다.
//점 표기법
user.isAdmin = true;
//대괄호 표기법
user["isAdmin"] = true;
프로퍼티 삭제하기
delete
연산자를 사용하면 프로퍼티를 삭제할 수 있다.
//점 표기법
delete user.age;
//대괄호 표기법
delete user["age"];
cf) 상수 객체는 수정될 수 있다!
const
로 선언된 객체는 수정될 수 없을 것 같지만, 수정될 수 있다.
const user = {
name: "John"
};
user.name = "Pete";
laert(user.name); // Pete
const
는 user의 값을 고정하지만, 그 내용은 고정하지 않는다. 즉 user=...
를 전체적으로 설정하려고 할 때만 오류가 발생한다.
객체 리터럴 안의 프로퍼티 키(key)가 대괄호로 둘러싸여 있는 경우, 이를 계산된 프로퍼티(computed property)
라고 부른다.
대괄호로 둘러싸인 프로퍼티 키(key)를, 대괄호 안에 있는 변수에서 가져오겠다는 것을 의미한다.
예시를 보자.
let fruit = prompt("어떤 과일을 구매하시겠습니까?");
let bag = {
[fruit]: 5 // 변수 fruit에서 프로퍼티 키(key)를 동적으로 받아온다.
};
alert(bag.apple);
사용자가 prompt 창에 apple이라고 입력하면, 결과는 5가 출력된다. 위 코드는 아래와 같은 코드이다.
let fruit = prompt("어떤 과일을 구매하시겠습니까?");
let bag = {};
bag[fruit] = 5;
한편, 대괄호 안에는 복잡한 표현식이 올 수도 있다.
let fruit = "apple";
let bag = {
[fruit + "Computers"]: 5 // bag.appleComputers = 5
}
대괄호 표기법은 프로퍼티 이름과 값의 제약을 없애주기 때문에 점 표기법보다 훨씬 강력하다. 그런데 작성하기 번거롭다는 단점이 있다.
프로퍼티 이름이 확정된 상황이고, 단순한 이름이라면 처음엔 점 표기법을 사용하다가 뭔가 복잡한 상황이 발생했을 때 대괄호 표기법으로 바꾸는 경우가 많다.
아래의 예시를 보자.
function makeUser(name, age) {
return {
name: name,
age: age,
};
}
이렇게 프로퍼티 키(key)와 값(value)의 이름이 동일할 경우, 코드를 아래와 같이 짧게 줄일 수 있다.
function makeUser(name, age) {
return {
name, // name: name 과 같음
age, // age: age 와 같음
};
}
프로퍼티 키(key)는 어떤 문자형이나 될 수 있다. (사실 심볼형도 되는데 이는 뒤에서 다룰 예정이다.)
예를 들어 아래처럼 객체 프로퍼티 키(key) 이름을 예약어로 설정할 수 있다!
// 예약어를 키로 사용해도 괜찮습니다.
let obj = {
for: 1,
let: 2,
return: 3
};
alert( obj.for + obj.let + obj.return ); // 6
만약 문자형이 아닌 다른 자료형으로 설정할 경우 문자열로 자동 형 변환된다.
let obj = {
0: "test" // "0": "test"와 동일합니다.
};
// 숫자 0은 문자열 "0"으로 변환되기 때문에 두 얼럿 창은 같은 프로퍼티에 접근합니다,
alert( obj["0"] ); // test
alert( obj[0] ); // test (동일한 프로퍼티)
__proto__
관련된 내용은 문서 참고2가지 방법이 있다.
undefined
반환 특징 이용하기undefined
값을 반환한다. 이러한 특징을 이용하면 프로퍼티 존재 여부를 확인할 수 있다.let user = {};
alert( user.noSuchProperty === undefined ); // true. true는 '프로퍼티가 존재하지 않음'을 의미합니다.
in
사용하기문법은 아래와 같다.
"key" in 객체
예시를 보자.
let user = { name: "John", age: 30 };
alert( "age" in user ); // user.age가 존재하므로 true가 출력됩니다.
alert( "blabla" in user ); // user.blabla는 존재하지 않기 때문에 false가 출력됩니다.
아래와 같이 변수로 접근해도 된다.
let user = { age: 30 };
let key = "age";
alert( key in user ); // true, 변수 key에 저장된 값("age")을 사용해 프로퍼티 존재 여부를 확인합니다.
프로퍼티 값(value) 자체에 undefined
가 할당된다면 1번이 원하는대로 동작하지 않을 수 있기 때문에, 2번이 존재한다. (자세한 것은 문서 참고)
for...in
반복문을 사용하면 객체의 모든 키(key)를 순회할 수 있다. for 반복문과는 완전히 다르다!
문법은 아래와 같다.
for (key in 객체) {
// 각 프로퍼티 키(key)를 이용하여 본문(body)을 실행합니다.
}
객체의 모든 프로퍼티 키와 값을 출력하는 예제를 보자.
let user = {
name: "John",
age: 30,
isAdmin: true
};
for (let key in user) {
// 키
alert( key ); // name, age, isAdmin
// 키에 해당하는 값
alert( user[key] ); // John, 30, true
}
여기서 key
는 단순한 변수명이다. 다른 변수명으로 바꿔도 된다.
객체와 객체 프로퍼티를 다루다 보면 "프로퍼티엔 순서가 있을까?"라는 의문이 생기기 마련이다. 특히 반복문은 프로퍼티를 추가한 순서대로 실행될지, 그리고 이 순서는 항상 동일할지 궁금해진다.
결론은, 정수 프로퍼티(integer property)는 자동으로 정렬되고, 그 외의 프로퍼티는 객체에 추가한 순서 그대로 정렬된다.
키(key)가 정수인 경우는 오름차순으로 자동 정렬된다.
let codes = {
"49": "독일",
"41": "스위스",
"44": "영국",
"1": "미국"
};
for (let code in codes) {
alert(code); // 1, 41, 44, 49
}
키(key)가 정수가 아닌 경우는 작성된 그대로 나열된다.
let user = {
name: "John",
surname: "Smith"
};
user.age = 25; // 프로퍼티를 하나 추가합니다.
// 정수 프로퍼티가 아닌 프로퍼티는 추가된 순서대로 나열됩니다.
for (let prop in user) {
alert( prop ); // name, surname, age
}
참조에 의해(by reference)
저장되고 복사된다.값 그대로
저장, 할당되고 복사된다.원시타입 예제를 보자.
let message = "Hello!";
let phrase = message;
이렇게 하면 message, phrase 변수에 각각 문자열 Hello!
가 저장된다.
반면 객체는, 객체가 그대로 저장되는 것이 아니라 객체가 저장되어 있는 메모리 주소인 객체에 대한 참조 값이 저장된다.
let user = {
name: "John"
}
객체는 메모리 내 어딘가에 저장되고, 변수 user엔 객체를 참조할 수 있는 값이 저장된다.
위의 내용에 따라, 객체가 할당된 변수를 복사할 때, 객체의 참조 값이 복사되는 것이지 객체 자체가 복사되는 것이 아니다.
let user = { name: "John" };
let admin = user; // 참조값을 복사함
그림을 보면 이해가 쉽다.
변수는 2개이지만 각 변수에는 동일 객체에 대한 참조 값이 저장된다.
아래의 예시도 보자.
let user = { name: 'John' };
let admin = user;
admin.name = 'Pete'; // 'admin' 참조 값에 의해 변경됨
alert(user.name); // 'Pete'가 출력됨. 'user' 참조 값을 이용해 변경사항을 확인함
객체를 서랍장에 비유하면 변수는 서랍장을 열 수 있는 열쇠라고 할 수 있다. 서랍장은 하나, 서랍장을 열 수 있는 열쇠는 두 개인데, 그중 admin 열쇠를 사용해 서랍장을 열어 정돈한 후, user 열쇠로 서랍장을 열면 내용이 정돈되어 있는 것을 확인할 수 있다.
앞에서 한 것과 달리 객체를 복제하고 싶다면 어떻게 해야 할까? 즉, 기존에 있던 객체와 똑같으면서 독립적인 객체를 만들고 싶다면 말이다.
방법은 있는데 자바스크립트는 객체 복제 내장 메서드를 지원하지 않기 때문에 조금 어렵다. 사실 객체를 복제해야 할 일은 거의 없다. 참조에 의한 복사로 해결 가능한 일이 대다수이다.
정말 복제가 필요한 상황이라면 2가지 방법을 사용하면 된다.
let user = {
name: "John",
age: 30
};
let clone = {}; // 새로운 빈 객체
// 빈 객체에 user 프로퍼티 전부를 복사해 넣습니다.
for (let key in user) {
clone[key] = user[key];
}
// 이제 clone은 완전히 독립적인 복제본이 되었습니다.
clone.name = "Pete"; // clone의 데이터를 변경합니다.
alert( user.name ); // 기존 객체에는 여전히 John이 있습니다.
Object.assign
사용하기문법을 보자.
Object.assign(dest, [src1, src2, src3...])
assign
메서드를 사용해 여러 객체를 하나로 병합하는 예시를 살펴보자.
let user = { name: "John" };
let permissions1 = { canView: true };
let permissions2 = { canEdit: true };
// permissions1과 permissions2의 프로퍼티를 user로 복사합니다.
Object.assign(user, permissions1, permissions2);
// now user = { name: "John", canView: true, canEdit: true }
목표 객체(user)에 동일한 이름을 가진 프로퍼티가 있는 경우엔 기존 값이 덮어씌워 진다.
let user = { name: "John" };
Object.assign(user, { name: "Pete" });
alert(user.name); // user = { name: "Pete" }
Object.assign을 사용하면 반복문 없이도 간단하게 객체를 복사할 수 있다.
let user = {
name: "John",
age: 30
};
let clone = Object.assign({}, user);
예시를 실행하면 user에 있는 모든 프로퍼티가 빈 배열에 복사되고 변수에 할당된다.
아래의 경우에는 어떻게 해야할까?
let user = {
name: "John",
sizes: { //중첩 객체
height: 182,
width: 50
}
};
alert( user.sizes.height ); // 182
clone.sizes = user.sizes
로 프로퍼티를 복사하는 것만으론 객체를 복제할 수 없다. user.sizes
는 객체이기 때문에 참조 값이 복사되기 때문이다. clone.sizes = user.sizes
로 프로퍼티를 복사하면 clone과 user는 같은 sizes를 공유하게 된다.
이 문제를 해결하려면 user[key]의 각 값을 검사하면서, 그 값이 객체인 경우 객체의 구조도 복사해주는 반복문을 사용해야 한다. 이런 방식을 깊은 복사(deep cloning)
라고 한다.
자바스크립트 라이브러리 lodash의 메서드인 _.cloneDeep(obj)
을 사용하면 이 알고리즘을 직접 구현하지 않고도 깊은 복사를 처리할 수 있다.
자바스크립트는 눈에 보이지 않는 곳에서 메모리 관리를 수행한다.
원시값, 객체, 함수 등 우리가 만드는 모든 것은 메모리를 차지하는데, 더는 쓸모 없어지게 된 것들은 찾아내 삭제해야 한다. 자바스크립트 엔진이 필요 없는 것을 찾아내 삭제하는 것이 가비지 컬렉션
이다.
자바스크립트는 도달 가능성(reachability) 이라는 개념을 사용해 메모리 관리를 수행한다.
가비지 컬렉터는 모든 객체를 모니터링하고, 도달할 수 없는 객체는 삭제한다. 도달 가능한 값은 메모리에서 삭제되지 않는다.
루트(root)
: 명백한 이유 없이는 삭제되지 않는다.let user = {
name: "John"
};
user
값을 다른 값으로 덮어쓰면 참조(화살표)가 사라진다.
user = null;
// user엔 객체 참조 값이 저장됩니다.
let user = {
name: "John"
};
let admin = user;
이번에도 user
의 값을 다른 값으로 덮어써 보자.
user = null;
1번 예시와 달리 전역 변수 admin을 통하면 여전히 객체 John에 접근할 수 있기 때문에 John은 메모리에서 삭제되지 않는다.
function marry(man, woman) {
woman.husband = man;
man.wife = woman;
return {
father: man,
mother: woman
}
}
let family = marry({
name: "John"
}, {
name: "Ann"
});
참조 2개를 지워보자.
delete family.father;
delete family.mother.husband;
외부로 나가는 참조는 도달 가능한 상태에 영향을 주지 않는다. 외부에서 들어오는 참조만이 도달 가능한 상태에 영향을 준다. 그 결과 아래와 같이 된다.
결론은, 객체들이 연결되어 섬 같은 구조를 만드는데, 이 섬에 도달할 방법이 없는 경우, 섬을 구성하는 객체 전부가 메모리에서 삭제된다.
mark-and-sweep
자바스크립트 엔진은 실행에 영향을 미치지 않으면서 가비지 컬렉션을 더 빠르게 하는 다양한 최적화 기법을 적용한다.
자세한 내용은 문서 참조!
객체의 프로퍼티에 함수를 할당해 객체에게 행동할 수 있는 능력을 부여해줄 수 있다. 그렇게 객체 프로퍼티에 할당된 함수를 메서드(method)
라고 한다.
let user = {
name: "John",
age: 30
};
user.sayHi = function() {
alert("안녕하세요!");
};
user.sayHi(); // 안녕하세요!
이미 정의된 함수를 이용해서 메서드로 만들어 줄 수도 있다.
let user = {
// ...
};
// 함수 선언
function sayHi() {
alert("안녕하세요!");
};
// 선언된 함수를 메서드로 등록
user.sayHi = sayHi;
user.sayHi(); // 안녕하세요!
// 아래 두 객체는 동일하게 동작합니다.
user = {
sayHi: function() {
alert("Hello");
}
};
// 단축 구문을 사용하니 더 깔끔해 보이네요.
user = {
sayHi() { // "sayHi: function()"과 동일합니다.
alert("Hello");
}
};
사실 일반적인 방법과 단축 구문을 사용한 방법이 완전히 동일하진 않고 미묘한 차이가 있는데, 우선은 중요하지 않으므로 넘어가자.
메서드 내부에서 this 키워드를 사용하면 현재 객체에 접근할 수 있다. this
는 메서드를 호출할 때 사용된 객체를 나타낸다.
let user = {
name: "John",
age: 30,
sayHi() {
// 'this'는 '현재 객체'를 나타냅니다.
alert(this.name);
}
};
user.sayHi(); // John
user.sayHi()가 실행되는 동안에 this
는 user
를 나타낸다.
자세한 내용은 문서를 참고하자.
자바스크립트의 this
는 다른 프로그래밍 언어의 this
와 달리 모든 함수에 this
를 사용할 수 있다.
아래와 같이 작성해도 에러가 발생하지 않는다.
function sayHi() {
alert( this.name );
}
this 값은 런타임에 결정된다. 즉, 컨텍스트에 따라 달라진다.
동일한 함수라도 다른 객체에서 호출했다면 'this’가 참조하는 값이 달라진다.
let user = { name: "John" };
let admin = { name: "Admin" };
function sayHi() {
alert( this.name );
}
// 별개의 객체에서 동일한 함수를 사용함
user.f = sayHi;
admin.f = sayHi;
// 'this'는 '점(.) 앞의' 객체를 참조하기 때문에
// this 값이 달라짐
user.f(); // John (this == user)
admin.f(); // Admin (this == admin)
admin['f'](); // Admin (점과 대괄호는 동일하게 동작함)
function sayHi() {
alert(this);
}
sayHi(); // undefined
엄격모드에서는 객체가 없으므로 this
에 undefined
가 할당된다. (런타임에서 할당될 객체가 없기 때문) 그리고 this.name
으로 name
에 접근하려고 하면 에러가 발생한다.
❗헷갈리지 말자! 객체가 없을 경우에 해당되는 얘기다. 프로퍼티가 아니라 !!
예시를 보자.
// 클래스와 모듈을 사용하지 않아 엄격모드가 자동으로 적용되지 않기 때문에 직접 적용함
"use strict";
let imObject = {
callThisObejct() {
alert(this);
},
generateProperty() {
this.a = "나는 this와 함께 생성된 a야.";
alert(this.a);
},
};
//1
imObject.callThisObejct(); // 결과 : [object Object]
//2
imObject.generateProperty(); // 결과 : 나는 this와 함께 생성된 a야.
여기서 imObject
라는 객체는 존재한다. 따라서 1번 결과에서 확인할 수 있듯 this
에 undefined
가 할당되지 않는다.
2번의 경우 a
라는 프로퍼티는 존재하지 않는다. 하지만 이것은 위에서 말한 내용과 전혀 관계가 없다. this
는 a
라는 프로퍼티를 자동으로 생성하고, 정상적으로 값도 출력한다.
다시 한번 헷갈리지 말자. 객체가 존재하지 않을 경우에 this
에 undefined
가 할당되는 것이다.
function func() {
alert(this);
}
func(); // 결과 : undefined
음? 근데 함수도 객체아닌가 이게 뭔소리지........???????엥??? 함수가 존재한다는 건 객체가 존재한단 뜻인데....... 그 객체가 그 객체가 아닌가? 어쩌라는거임?
👉 아무래도 프로퍼티가 존재하는.. 고러한 전형적인(?) 객체를 의미하는듯.
정리해보자면,
this
를 사용할 수 있다. 다만 함수가 호출되기 전까지 this
엔 값이 할당되지 않는다. 즉, 함수가 호출되는 순간에 this
에 값이 할당된다.객체명.method()
와 같이 메서드 형태로 호출하면 this
는 그때 해당 객체명에 해당되는 객체
를 참조한다.new
연산자와 생성자 함수
를 사용하면 유사한 객체 여러 개를 쉽게 만들 수 있다.
생성자 함수는 처음 나오는 개념이다.. 함수 파트 글 찾아보지마...
생성자 함수
와 일반 함수에 기술적인 차이는 없지만, 생성자 함수는
2가지의 컨벤션이 있다.
new
연산자를 붙여 실행!예시를 보자.
function User(name) {
this.name = name;
this.isAdmin = false;
}
let user = new User("보라");
alert(user.name); // 보라
alert(user.isAdmin); // false
new User(...)
를 써서 함수를 실행할 때 어떤 알고리즘이 동작하는지 알아보자.
this
에 할당한다. this
에 새롭게 할당된 프로퍼티를 추가하여 this
를 수정한다.this
를 반환한다.User() 함수 안에서 일어나는 일을 아래의 예시를 통해 보자.
function User(name) {
// this = {}; 1. 빈 객체가 암시적으로 만들어짐
// 2. 새로운 프로퍼티를 this에 추가함
this.name = name;
this.isAdmin = false;
// return this; 3. this가 암시적으로 반환됨
}
왜 생성자 함수를 사용하면 객체를 쉽게 만들 수 있다는 것일까?
아래의 예시를 통해 알아보자.
let user = new User("보라");
let user = {
name: "보라",
isAdmin: false
}
new User("보라")
이외에도 new User("호진")
, new User("지민")
등을 이용하면 손쉽게 사용자 객체를 만들 수 있다. 객체 리터럴 문법으로 일일이 객체를 만드는 방법보다 훨씬 간단하고 읽기 쉽게 객체를 만들 수 있다.
생성자의 의의는 바로 여기에 있다. 재사용할 수 있는 객체 생성 코드를 구현하는 것이다.
재사용할 필요가 없는데 복잡한 객체를 만들어야 할 때, 코드를 익명 생성자 함수로 감싸주는 방식을 사용할 수 있다.
let user = new function() {
this.name = "John";
this.isAdmin = false;
// 사용자 객체를 만들기 위한 여러 코드.
// 지역 변수, 복잡한 로직, 구문 등의
// 다양한 코드가 여기에 들어갑니다.
};
위 생성자 함수는 익명 함수이기 때문에 어디에도 저장되지 않는다. 처음 만들 때부터 단 한 번만 호출할 목적으로 만들었기 때문에 재사용이 불가능하다.
이렇게 익명 생성자 함수를 이용하면 재사용은 막으면서 코드를 캡슐화 할 수 있다.
new.target
프로퍼티를 사용하면 함수가 new
와 함께 호출되었는지 아닌지를 알 수 있다.
new
와 함께 호출하지 않고 일반적인 방법으로 함수를 호출하면 new.target
은 undefined
를 반환하고, new
과 함께 호출한 경우에는 함수 자체를 반환해준다.
function User() {
alert(new.target);
}
// 'new' 없이 호출함
User(); // undefined
// 'new'를 붙여 호출함
new User(); // function User { ... }
new.target
문법은 자주 쓰이지 않는다. 자세한 내용은 문서를 참고하자.
생성자 함수에는 보통 return
문이 없다. 반환해야 할 것들은 모두 this
에 저장되고, this
는 자동으로 반환도기 때문에 반환문을 명시적으로 써 줄 필요가 없다.
그러나 만약 return
문이 있다면, 아래의 규칙이 적용된다.
return
한다면 this
가 반환되는 대신 해당 객체가 반환된다.return
하거나 return
예약어만 쓰고 아무것도 반환하지 않는다면 return
문이 무시된다.1번 규칙이 적용되는 예시
function BigUser() {
this.name = "원숭이";
return { name: "고릴라" }; // <-- this가 아닌 새로운 객체를 반환함
}
alert( new BigUser().name ); // 고릴라
2번 규칙이 적용되는 예시
function SmallUser() {
this.name = "원숭이";
return; // <-- this를 반환함
}
alert( new SmallUser().name ); // 원숭이
사실
return
문이 있는 생성자 함수는 거의 없다. 특이 케이스이기 때문에 넘어가도 좋다.
생성자 함수를 사용하면 매개변수를 이용해 객체 내부를 자유롭게 구성할 수 있다. this
에 프로퍼티를 더해주는 것 말고도, 메서드를 더해주는 것도 가능하다.
예시를 보자.
function User(name) {
this.name = name;
this.sayHi = function() {
alert( "제 이름은 " + this.name + "입니다." );
};
}
let bora = new User("이보라");
bora.sayHi(); // 제 이름은 이보라입니다.
맨 아래 두 줄의 코드는 아래의 코드와 동일하게 동작한다.
bora = {
name: "이보라",
sayHi: function() { ... }
}
옵셔널 체이닝(optional chaining)
?.
을 사용하면 프로퍼티가 없는 중첩 객체를 에러 없이 안전하게 접근할 수 있다.
user.address.street
를 사용해 주소 정보에 접근하면 에러가 발생할 수 있다. 이렇게 에러가 발생하지 않아도 되는 상황에서 발생하는 에러를 막기 위해 옵셔널 체이닝이 필요하다. let user = {}; // 주소 정보가 없는 사용자
alert(user.address.street); // TypeError: Cannot read property 'street' of undefined
// querySelector(...) 호출 결과가 null인 경우 에러 발생
let html = document.querySelector('.my-element').innerHTML;
?.
은?.
앞의 평가 대상이undefined
나null
이면 평가를 멈추고undefined
를 반환한다.
예시를 보자.
let user = {}; // 주소 정보가 없는 사용자
alert( user?.address?.street ); // undefined, 에러가 발생하지 않습니다.
user?.address
로 주소를 읽으면 아래와 같이 user 객체가 존재하지 않더라도 에러가 발생하지 않는다.
let user = null;
alert( user?.address ); // undefined
alert( user?.address.street ); // undefined
위 예시를 통해 우리는 ?.은 ?. ‘앞’ 평가 대상에만 동작되고, 확장은 되지 않는다는 사실을 알 수 있다.
참고로 위 예시에서 사용된 user?.
는 user
가 null
이나 undefined
인 경우만 처리할 수 있다. user
가 null
이나 undefined
가 아니고 실제 값이 존재하는 경우엔 반드시 user.address
프로퍼티는 있어야 합니다. 그렇지 않으면 user?.address.street
의 두 번째 점 연산자에서 에러가 발생한다.
?.
는 존재하지 않아도 괜찮은 대상에만 사용해야 한다.
사용자 주소를 다루는 위 예시에서 논리상 user
는 반드시 있어야 하는데 address
는 필수값이 아니다. 그러니 옵셔널 체이닝 user.address?.street
를 사용하는 것이 바람직합니다.
실수로 인해 user
에 값을 할당하지 않았다면 바로 알아낼 수 있도록 해야 한다(즉 옵셔널 체이닝을 쓰면 안된다). 그렇지 않으면 에러를 조기에 발견하지 못하고 디버깅이 어려워진다.
?.
앞의 변수는 꼭 선언되어 있어야 한다.변수 user
가 선언되어있지 않으면 user?.anything
평가시 에러가 발생한다.
// ReferenceError: user is not defined
user?.address;
이렇게 옵셔널 체이닝은 선언이 완료된 변수를 대상으로만 동작한다.
?.
는 왼쪽 평가대상에 값이 없으면(null
이나 undefined
이면) 즉시 평가를 멈춘다. 참고로 이런 평가 방법을 단락 평가(short-circuit)
라고 부른다.
let user = null;
let x = 0;
user?.sayHi(x++); // 아무 일도 일어나지 않습니다.
alert(x); // 0, x는 증가하지 않습니다.
?.
은 연산자가 아니다. ?.
은 함수나 대괄호와 함께 동작하는 특별한 문법 구조체(syntax construct)
이다.
함수 관련 예시와 함께 존재 여부가 확실치 않은 함수를 호출할 때 ?.()
를 어떻게 쓸 수 있는지 알아보자.
let user1 = {
admin() {
alert("관리자 계정입니다.");
}
}
let user2 = {};
user1.admin?.(); // 관리자 계정입니다.
user2.admin?.();
두 상황 모두에서 user 객체는 존재하기 때문에 admin 프로퍼티는 .
만 사용해 접근했다.
그리고 난 후 ?.()
를 사용해 admin의 존재 여부를 확인했다. user1엔 admin이 정의되어 있기 때문에 메서드가 제대로 호출되는 반면, user2엔 admin이 정의되어 있지 않았음에도 불구하고 메서드를 호출하면 에러 없이 그냥 평가가 멈추는 것을 확인할 수 있다.
한편, ?.[]
를 사용할 수도 있다. 예시를 보자.
let user1 = {
firstName: "Violet"
};
let user2 = null; // user2는 권한이 없는 사용자라고 가정해봅시다.
let key = "firstName";
alert( user1?.[key] ); // Violet
alert( user2?.[key] ); // undefined
alert( user1?.[key]?.something?.not?.existing); // undefined
?.
은 delete
와 조합해 사용할 수도 있다.
delete user?.name; // user가 존재하면 user.name을 삭제합니다.
?.
은 할당 연산자 왼쪽에서 사용할 수 없다.
// user가 존재할 경우 user.name에 값을 쓰려는 의도로 아래와 같이 코드를 작성해 보았습니다.
user?.name = "Violet"; // SyntaxError: Invalid left-hand side in assignment
// 에러가 발생하는 이유는 undefined = "Violet"이 되기 때문입니다.
옵셔널 체이닝 문법 ?.은
세 가지 형태로 사용할 수 있다.
obj?.prop
: obj
가 존재하면 obj.prop
을 반환하고, 그렇지 않으면 undefined
를 반환함obj?.[prop]
: obj
가 존재하면 obj[prop]
을 반환하고, 그렇지 않으면 undefined
를 반환함obj?.method()
: obj
가 존재하면 obj.method()
를 호출하고, 그렇지 않으면 undefined
를 반환함?.
은 ?.
의 왼쪽 평가대상이 없어도 괜찮은 경우에만 선택적으로 사용해야 한다. 꼭 있어야 하는 값인데 없는 경우에 ?.
을 사용하면 프로그래밍 에러를 쉽게 찾을 수 없으므로 이런 상황을 만들지 말도록 하자.
자바스크립트는 객체 프로퍼티 키로 오직 문자형과 심볼형만을 허용한다.
지금까지는 프로퍼티 키가 문자형인 경우만 살펴봤다. 이번에는 프로퍼티 키로 심볼값을 사용해 보면서, 심볼형 키를 사용할 때의 이점에 대해 살펴보자.
심볼(symbol)
은 원시형 데이터로, 유일한 식별자(unique identifier)를 만들고 싶을 때 사용한다.Symbol()
을 사용하면 심볼값을 만들 수 있다.
// id는 새로운 심볼이 됩니다.
let id = Symbol();
심볼을 만들 때 심볼 이름
이라 불리는 '설명'을 선택적으로 추가할 수 있다. 심볼 이름은 디버깅 시 아주 유용하다.
설명이 같아도 심볼은 유일한 식별자이기 때문에, 동일 연산자(==)로 비교 시 false가 반환된다. 즉 이름이 같더라도 값이 항상 다른 것이다.
let id1 = Symbol("id");
let id2 = Symbol("id");
alert(id1 == id2); // false
symbol.description
프로퍼티를 이용하면 설명을 보여줄 수 있다.
let id = Symbol("id");
alert(id.description); // id
심볼형 값은 다른 자료형으로 암시적 형 변환(자동 형 변환)되지 않는다. 문자열과 심볼은 근본이 다르기 때문에 우연히라도 서로의 타입으로 변환돼선 안된다.
let id = Symbol("id");
alert(id); // TypeError: Cannot convert a Symbol value to a string
심볼을 반드시 출력해줘야 하는 상황이라면 아래와 같이 .toString()
메서드를 명시적으로 호출해주면 된다.
let id = Symbol("id");
alert(id.toString()); // Symbol(id)가 얼럿 창에 출력됨
심볼을 이용하면 ‘숨김(hidden)’ 프로퍼티를 만들 수 있다. 숨김 프로퍼티는 외부 코드에서 접근이 불가능하고 값도 덮어쓸 수 없는 프로퍼티이다.
user
라는 객체를 를 이용해 어떤 작업을 해야 하는 상황이라고 가정해보자.let user = { // 서드파티 코드에서 가져온 객체
name: "John"
};
let id = Symbol("id");
user[id] = 1;
alert( user[id] ); // 심볼을 키로 사용해 데이터에 접근할 수 있습니다.
왜 문자형 프로퍼티가 아니라 심볼형 프로퍼티를 사용했을까?
user
는 third party 코드에서 가지고 온 객체이므로 함부로 새로운 프로퍼티를 추가할 수 없다. 그런데 심볼은 third party 코드에서 접근할 수 없기 때문에, 심볼을 사용하면 third party 코드가 모르게 user
에 프로퍼티를 추가할 수 있다(식별자 부여).
user
를 다르게 식별해야 하는 상황에도 심볼을 이용할 수 있다. user
의 원천인 third party 코드, 현재 작성 중인 스크립트, 제3의 스크립트(자바스크립트 라이브러리 등)가 각자 서로의 코드도 모른 채 user
를 식별해야 하는 상황이 발생했다고 해보자.// ...
let id = Symbol("id");
user[id] = "제3 스크립트 id 값";
심볼은 유일성이 보장되므로 우리가 만든 식별자와 제3의 스크립트에서 만든 식별자가 충돌하지 않는다. 이름이 같더라도 충돌하지 않는다.
그럼 심볼 대신 문자형을 사용했다면 어떻게 될까? 충돌이 발생할 수 있다.
let user = { name: "John" };
// 문자열 "id"를 사용해 식별자를 만들었습니다.
user.id = "스크립트 id 값";
// 만약 제3의 스크립트가 우리 스크립트와 동일하게 문자열 "id"를 이용해 식별자를 만들었다면...
user.id = "제3 스크립트 id 값"
// 의도치 않게 값이 덮어 쓰여서 우리가 만든 식별자는 무의미해집니다.
객체 리터럴 {...}을 사용해 객체를 만들고 심볼형 프로퍼티를 넣어줄 경우, 대괄호를 사용해 심볼형 키를 만들어야 한다.
let id = Symbol("id");
let user = {
name: "John",
[id]: 123 // "id": 123은 안됨
};
키가 심볼인 프로퍼티는
for..in
반복문에서 배제된다.
let id = Symbol("id");
let user = {
name: "John",
age: 30,
[id]: 123
};
for (let key in user) alert(key); // name과 age만 출력되고, 심볼은 출력되지 않습니다.
// 심볼로 직접 접근하면 잘 작동합니다.
alert( "직접 접근한 값: " + user[id] );
Object.keys(user)
에서도 키가 심볼인 프로퍼티는 배제됩니다. 심볼형 프로퍼티 숨기기(hiding symbolic property)
라 불리는 이런 원칙 덕분에 외부 스크립트나 라이브러리는 심볼형 키를 가진 프로퍼티에 접근하지 못한다.
그런데 Object.assign은 키가 심볼인 프로퍼티를 배제하지 않고 객체 내 모든 프로퍼티를 복사한다.
let id = Symbol("id");
let user = {
[id]: 123
};
let clone = Object.assign({}, user);
alert( clone[id] ); // 123
객체를 복사하거나 병합할 때, 대개 id 같은 심볼을 포함한 프로퍼티 전부를 사용하고 싶어 할 것이라는 생각에서 설계되었다.
심볼은 이름이 같더라도 값이 항상 다르다(모두 별개로 취급된다). 그런데 이름이 같을 때 값도 같길 원한다면, 즉 이름이 같은 심볼이 같은 개체를 가리키길 원한다면, 전역 심볼 레지스트리(global symbol registry)
를 사용해야 한다.
전역 심볼 레지스트리 안에 심볼을 만들고 해당 심볼에 접근하면, 이름이 같은 경우 항상 동일한 심볼을 반환해준다.
레지스트리 안에 있는 심볼을 읽거나, 새로운 심볼을 생성하려면 Symbol.for(key)
를 사용하면 된다. 이 메서드를 호출하면 이름이 key
인 심볼을 반환한다. 조건에 맞는 심볼이 레지스트리 안에 없으면 새로운 심볼 Symbol(key)
을 만들고 레지스트리 안에 저장한다.
// 전역 레지스트리에서 심볼을 읽습니다.
let id = Symbol.for("id"); // 심볼이 존재하지 않으면 새로운 심볼을 만듭니다.
// 동일한 이름을 이용해 심볼을 다시 읽습니다(좀 더 멀리 떨어진 코드에서도 가능합니다).
let idAgain = Symbol.for("id");
// 두 심볼은 같습니다.
alert( id === idAgain ); // true
전역 심볼 레지스트리 안에 있는 심볼은
전역 심볼
이라고 불린다. 애플리케이션에서 광범위하게 사용해야 하는 심볼이라면 전역 심볼을 사용하자!
전역 심볼을 찾을 때 사용되는 Symbol.for(key)
에 반대되는 메서드도 있다. Symbol.keyFor(sym)
를 사용하면 이름을 얻을 수 있다.
// 이름을 이용해 심볼을 찾음
let sym = Symbol.for("name");
let sym2 = Symbol.for("id");
// 심볼을 이용해 이름을 얻음
alert( Symbol.keyFor(sym) ); // name
alert( Symbol.keyFor(sym2) ); // id
Symbol.keyFor
는 전역 심볼 레지스트리를 뒤져서 해당 심볼의 이름을 얻어낸다. 검색 범위가 전역 심볼 레지스트리이기 때문에 전역 심볼이 아닌 심볼에는 사용할 수 없다. 전역 심볼이 아닌 인자가 넘어오면 Symbol.keyFor
는 undefined
를 반환한다.
전역 심볼이 아닌 모든 심볼은 description
프로퍼티가 있다. 일반 심볼에서 이름을 얻고 싶으면 description
프로퍼티를 사용하면 된다.
let globalSymbol = Symbol.for("name");
let localSymbol = Symbol("name");
alert( Symbol.keyFor(globalSymbol) ); // name, 전역 심볼
alert( Symbol.keyFor(localSymbol) ); // undefined, 전역 심볼이 아님
alert( localSymbol.description ); // name
형 변환 챕터에선 객체의 형 변환은 다루지 않았습니다. 메서드와 심볼에 대한 지식을 갖추었으니 본격적으로 객체의 형 변환에 대해 알아보자.
객체에 대한 논리평가
객체는 논리 평가 시 예외없이 항상 true
를 반환합니다. 따라서 객체는 숫자형이나 문자형으로만 형 변환이 일어난다고 생각하면 된다.
숫자형으로의 형 변환
객체끼리 빼는 연산을 할 때나 수학 관련 함수를 적용할 때 일어난다.
문자형으로의 형 변환
문자형으로의 형 변환은 대개 alert(obj)
같이 객체를 출력하려고 할 때 일어난다.
객체 형 변환은 세 종류로 구분되는데, hint
라 불리는 값이 구분 기준이된다. hint
는 목표로 하는 자료형 정도로 이해하면 된다.
string
alert
함수같이 문자열을 기대하0는 연산을 수행할 때는(객체-문자형 변환), hint
가 string
이 된다.// 객체를 출력하려고 함
alert(obj);
// 객체를 프로퍼티 키로 사용하고 있음
anotherObj[obj] = 123;
number
hint
는 number
가 된다.// 명시적 형 변환
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) { ... };
크고 작음을 비교할 때 쓰이는 연산자 <
, >
역시 피연산자에 문자형과 숫자형 둘 다를 허용하는데, 이 연산자들은 hint
를 number
로 고정하여 hint
가 default
가 되는 일이 없다. 이는 하위 호환성 때문에 정해진 규칙이다.
하지만 실제 일을 할 때는 이런 사항을 모두 외울 필요는 없고, Date
객체를 제외한 모든 내장 객체는 hint
가 "default
인 경우와 number
인 경우를 동일하게 처리한다. 우리도 커스텀 객체를 만들 땐 이런 규칙을 따르면 된다.
‘boolean’ hint는 존재하지 않는다. 모든 객체는 그냥 true
로 평가된다.
객체에 obj[Symbol.toPrimitive](hint)
메서드가 있는지 찾고, 있다면 메서드를 호출한다. Symbol.toPrimitive
는 시스템 심볼로, 심볼형 키로 사용된다.
1에 해당하지 않고 hint가 string
이라면, obj.toString()
이나 obj.valueOf()
를 호출합니다(존재하는 메서드만 실행됨).
1과 2에 해당하지 않고, hint가 number
나 default
라면
obj.valueOf()
나 obj.toString()
을 호출한다(존재하는 메서드만 실행됨).
첫 번째 메서드부터 살펴보자. 자바스크립트엔 Symbol.toPrimitive
라는 내장 심볼이 존재하는데, 이 심볼은 아래와 같이 목표로 하는 자료형(hint)을 명명하는 데 사용된다.
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
이렇게 메서드를 구현해 놓으면 user
는 hint에 따라 문자열로 변환되기도 하고 숫자로 변환되기도 한다. user[Symbol.toPrimitive]
를 사용하면 메서드 하나로 모든 종류의 형 변환을 다룰 수 있다.
toString
과 ValueOF
메서드를 이용하면 '구식’이긴 하지만 형 변환을 직접 구현할 수 있다.
객체에 Symbol.toPrimitive
가 없으면 자바스크립트는 아래 규칙에 따라 toString
이나 valueOf
를 호출한다.
toString
-> valueOf
순. toString
이 있다면 toString
을 호출, toString
이 없다면 valueOf
를 호출한다.이 메서드들은 반드시 원시값을 반환해야한다. toString
이나 valueOf
가 객체를 반환하면 그 결과는 무시된다. toString
이나 valueOf
가 객체를 반환해도 에러가 발생하지는 않는다. 반면에 Symbol.toPrimitive
는 무조건 원시자료를 반환해야 한다. 그렇지 않으면 에러가 발생한다.
일반 객체는 기본적으로 toString과 valueOf에 적용되는 다음 규칙을 따른다.
toString
은 문자열 "[object Object]"
을 반환한다.valueOf
는 객체 자신을 반환한다.let user = {name: "John"};
alert(user); // [object Object]
alert(user.valueOf() === user); // true
Symbol.toPrimitive
를 사용한 위쪽 예시와 동일하게 동작하는 예시를 보자.
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
객체에 Symbol.toPrimitive
와 valueOf
가 없으면, toString
이 모든 형 변환을 처리한다.
let user = {
name: "John",
toString() {
return this.name;
}
};
alert(user); // toString -> John
alert(user + 500); // toString -> John500
3개 메서드의 반환 타입에 대해 자세한 내용은 공식 문서를 참고하자.
객체가 피연산자일 때는 다음과 같은 단계를 거쳐 형 변환이 일어난다.
let obj = {
// 다른 메서드가 없으면 toString에서 모든 형 변환을 처리합니다.
toString() {
return "2";
}
};
alert(obj * 2); // 4, 객체가 문자열 "2"로 바뀌고, 곱셈 연산 과정에서 문자열 "2"는 숫자 2로 변경됩니다.
그런데 이항 덧셈 연산은 위와 같은 상황에서 문자열을 연결한다.
let obj = {
toString() {
return "2";
}
};
alert(obj + 2); // 22("2" + 2), 문자열이 반환되기 때문에 문자열끼리의 병합이 일어났습니다.