서론
파트 1에 이은 파트 2이다.
지난 파트의 절반 정도밖에 안 되는 분량이니 빨리 진행하도록 하겠다.
이번 일자의 프로그래머스 데브코스는 자바스크립트 기초 - 객체와 빌트인 객체 그리고 매커니즘이다.
객체 지향 프로그래밍 (Object-Oriented Programming; OOP)
객체 지향 프로그래밍은 실세계에 존재하고 인지하고 있는 객체를 소프트웨어의 세계에서 표현하기 위해 객체의 핵심적인 개념 또는 기능만을 추출하는 추상화를 통해 모델링하려는 프로그래밍 패러다임을 말한다. 즉, 우리가 주변의 실세계에서 사물을 인지하는 방식을 프로그래밍에 접목하려는 사상이다.
이러한 객체 지향 프로그래밍은 클래스 기반 언어와 프로토타입 기반 언어로 나뉜다.
클래스 기반 언어
클래스 기반 언어(Java, C++, C#, Python, PHP, Ruby 등)는 클래스로 객체의 자료구조와 기능을 정의하고 생성자를 통해 인스턴스를 생성하는 언어이다.
상세하게는 같은 종류의 집단에 속하는 속성과 행위를 정의한 것으로 객체지향 프로그램의 기본적인 사용자 정의 데이터형이라고 할 수 있다. 결국 클래스는 객체 생성에 사용되는 패턴 혹은 청사진일 뿐이며 new 연산자를 통한 인스턴스화 과정이 필요하다.
프로토타입 기반 언어
반면 프로토타입 기반의 언어로는 대표적으로 자바스크립트가 있다. 자바스크립트는 멀티 패러다임 언어로 명령형(imperative), 함수형(functional), 프로토타입 기반(prototype-based) 객체지향 언어다.
자바스크립트는 강력한 객체 지향 프로그래밍 능력들을 지니고 있다. 간혹 '클래스'(일단은 클래스 기반의 객체지향 언어와 비슷한)가 없어서 객체지향이 아니라고 생각하는 사람들도 있으나 프로토타입 기반의 객체지향 언어다.
11일차 파트1에서 알 수 있다시피, 객체 생성 방법은 여러 가지가 있다.
자바스크립트는 이미 생성된 인스턴스의 자료구조와 기능을 동적으로 변경할 수 있다는 특징이 있다. 객체 지향의 상속, 캡슐화(정보 은닉) 등의 개념은 프로토타입 체인과 클로저 등으로 구현할 수 있다.
그리고 본래는 클래스를 사용할 수 없었으나, ES6에서부터 클래스가 도입되었다. 물론 자바스크립트의 클래스는 새로운 객체지향 모델이라기보다는, 기존의 프로토타입 패턴을 읽기 쉽게 만든 함수에 가깝다.
프로토타입
여기서 프토토타입(Prototype)이 대체 무엇이냐라고 할 수도 있다.
일단 앞서 말했듯 클래스 기반 객체지향 프로그래밍 언어는 객체 생성 이전에 클래스를 정의하고 이를 통해 객체(인스턴스)를 생성한다. 반면 프로토타입 기반 객체지향 프로그래밍 언어는 클래스가 꼭 없어(Class-less)도 객체를 생성할 수 있다.
자바스크립트의 모든 객체는 자신의 부모 역할을 담당하는 객체와 연결되어 있다. 그리고 이것은 마치 객체 지향의 상속 개념과 같이 부모 객체의 프로퍼티 또는 메서드를 상속받아 사용할 수 있게 한다. 이러한 부모 객체를 프로토타입 객체 또는 줄여서 프로토타입이라 한다.
즉, 프로토타입 객체는 객체의 인스턴스를 만드는 부모 객체의 개념이라 할 수 있다.
자바스크립트의 모든 객체는 [[Prototype]]이라는 인터널 슬롯(internal slot; 내부 속성, 혹은 은닉 속성 정도로 이해. 사실 은닉 속성이 이해하기 편할 듯)를 가진다. 이러한 인터널 슬롯은 원칙적으로는 접근을 할 수 없는 private 특성을 취하고 있으나 경우에 따라 간접적인 접근이 가능하다.
아무튼, [[Prototype]]의 값은 null 또는 객체이며, 상속을 구현하는데 사용된다. [[Prototype]] 객체의 데이터 프로퍼티는 get 액세스(사용자가 값을 가져옴. 일명 반환.)를 위해 상속되어 자식 객체의 프로퍼티처럼 사용할 수 있다. 하지만 set 액세스(사용자가 값을 설정함. 일명 할당.)는 허용되지 않는다.
var parent = { x: 10 };
var child = Object.create(parent);
console.log(child.x); // [get 액세스] 10 (부모 객체(parent)의 x 프로퍼티 값)
child.x = 20; // [set 액세스] 자식 객체(child)의 x 프로퍼티를 새로 생성하고 값을 할당
console.log(child.x); // [get 액세스] 20 (자식 객체(child)의 x 프로퍼티 값)
console.log(parent.x); // [get 액세스] 10 (부모 객체(parent)의 x 프로퍼티 값은 변경되지 않음)
delete child.x; // [delete 액세스] 자식 객체(child)의 x 프로퍼티를 삭제
console.log(child.x); // [get 액세스] 10 (프로토타입 체인을 통해 부모 객체(parent)의 x 프로퍼티 값에 접근)
이는 위의 예제를 보면 알 수 있다. child.x를 통해 값을 가져오는 것은 get 액세스이고, child.x = 20을 통해 값을 설정하는 것은 set 액세스이다. 얼핏 set이 멀쩡하게 작동하는 것으로 보이기도 한다. 하지만 이건 자식 객체에 새로운 프로퍼티를 생성하고 값을 할당하는 것으로, 부모 객체의 프로퍼티를 직접적으로 변경하지 않는다.
뭐가 문제냐고 할 수 있지만, 앞서 말했듯 set 액세스는 허용되지 않는다. 정확하게는 부모 객체의 프로퍼티를 자식 객체에서 직접적으로 수정하는 것(set 액세스)를 허용하지 않는다. 이 때문에 부모 객체의 프로퍼티를 직접적으로 수정하지 않고, 자식 객체에서만 해당 프로퍼티를 수정하게 되는데, 이게 바로 프로토타입 체인(아래에 따로 설명)의 일부이다.
결국, 이와 같은 이유로 console.log를 사용해보면 부모 객체의 x 값은 여전히 10으로 유지된다. 반면 원래는 10을 출력하던 자식 객체는 child.x = 20; 이후로 20이 나와버린다. 즉, 새로운 프로퍼티로 항상 연결되어버리는 것이다.
이를 해결할 방법은 delete를 이용해 새로 만들어진 child.x의 프토퍼티를 삭제해야 한다. 그러면 child.x는 프토토타입 체인을 통해 부모 객체의 x 값에 접근하게 된다.
var people = {
name: 'Park',
age: 20,
level: 99
}
console.log(people.__proto__ === Object.prototype);
// true. 대충 프토토타입 객체가 맞다는 뜻이다.
[[Prototype]]의 값은 Prototype(프로토타입) 객체이며 __proto__ 접근자 프로퍼티(accessor property)로 접근할 수 있다. __proto__ 프로퍼티에 접근하면 내부적으로 Object.getPrototypeOf가 호출되어 프로토타입 객체를 반환한다.
[[Prototype]]와 prototype 프로퍼티
함수 타입에도 [[Prototype]] 인터널 슬롯이 존재한다. (반면 변수에는 없다.) 이는 함수도 어쨌거나 객체의 일부이기 때문이다. 그런데 함수 객체는 일반 객체와는 달리 prototype 프로퍼티도 소유하게 된다.
function Person(name, age, level) {
this.name = name;
this.age = age;
this.level = level;
}
var people1 = new Person('Park', 20, 99);
console.dir(Person); // prototype 프로퍼티가 있다.
console.dir(people1); // prototype 프로퍼티가 없다.
여기서 prototype 프로퍼티는 프로토타입 객체를 가리키는 [[Prototype]] 인터널 슬롯은 다르기에 주의해야 한다. prototype 프로퍼티와 [[Prototype]]은 모두 프로토타입 객체를 가리키지만 관점의 차이가 존재한다.
좀 더 구분을 해보자면 다음과 같다.
function Person(name, age, level) {
this.name = name;
this.age = age;
this.level = level;
}
var people1 = new Person('Park', 20, 99);
console.log(Person.__proto__ === Function.prototype); // [[Prototype]], true
console.log(Person.prototype === people1.__proto__); // prototype 프로퍼티 ,true
console.log(Person.prototype === Person.__proto__); // 둘을 비교, false
- [[Prototype]]
- 함수를 포함한 모든 객체가 가지고 있는 인터널 슬롯.
- 객체의 입장에서 자신의 부모 역할을 하는 프로토타입 객체를 가리킨다. 함수 객체의 경우 'Function.prototype'을 가리킨다.
- prototype 프로퍼티
- 함수 객체만 가지고 있는 프로퍼티(속성).
- 함수 객체가 생성자로 사용될 때 이 함수를 통해, 생성될 객체의 부모 역할을 하는 객체(프로토타입 객체)를 가리킨다.
constructor 프로퍼티
프로토타입 객체는 constructor 프로퍼티를 갖는다. 이 constructor 프로퍼티는 객체의 입장에서 자신을 생성한 객체를 가리킨다. 말 그대로 자기(객체임)를 만든 객체를 말하는 것이다.
예를 들어 Person2() 생성자 함수에 의해 생성된 객체를 people2라 하자. 반대로 이 people2 객체를 생성한 객체는 Person2() 생성자 함수이다.
이때 people2 객체 입장에서 자신을 생성한 객체는 Person2() 생성자 함수이며, people2 객체의 프로토타입 객체는 Person.prototype이다. 따라서 프로토타입 객체 Person.prototype의 constructor 프로퍼티는 Person2() 생성자 함수를 가리킨다.
프로토타입 체인
사실 위에서 이미 체인이 어떤 것인지 어렴풋이 나왔다. 객체의 속성을 참조할 시 속성이 없을 때 작동하는 것이 바로 프로토타입 체인이었다.
정확하게 설명을 해보자면, 특정 객체의 프로퍼티나 메서드에 접근하려고 할 때 해당 객체에 접근하려는 프로퍼티 또는 메서드가 없을 수도 있다. 이때 [[Prototype]]이 가리키는 곳을 따라 자신의 부모 역할(상위)을 하는 프로토타입 객체의 프로퍼티 및 메서드를 차례대로 올라가며 검색하는 것이다.
// 1번 예제
var people3 = {
name: 'Lee',
score: 90
}
// Object.prototype.hasOwnProperty()
console.log(people3.hasOwnProperty('name'));
// hasOwnProperty 메서드가 없으니 오류가 나야할 것 같지만, true가 나옴.
people3 객체는 hasOwnProperty 메서드를 가지고 있지 않다. 따라서 에러가 발생하여야 하나 정상적으로 결과가 출력되었다. 이는 people3 객체의 [[Prototype]]이 가리키는 링크를 따라가서 people3 객체의 부모 역할을 하는 프로토타입 객체(Object.prototype)의 메서드 hasOwnProperty를 호출하였기 때문에 가능한 것이다.
// 2번 예제
// 부모 객체
var parent = {
x: 10,
getX: function() {
return this.x;
}
};
// 자식 객체
var child = Object.create(parent);
// 자식 객체에만 있는 프로퍼티
child.y = 25;
// 자식 객체에만 있는 메서드
child.getY = function() {
return this.y;
};
// 아래부터 자식 객체에서 프로퍼티와 메서드에 접근
console.log(child.getX()); // [프로토타입 체인] 10 (부모 객체의 getX 메서드를 상속)
console.log(child.y); // 25 (자식 객체의 y 프로퍼티에 직접 접근)
console.log(child.getY()); // 25 (자식 객체의 getY 메서드를 호출)
여기서 child 객체는 parent 객체를 프로토타입으로 가지고 있다. child 객체에서 getX 메서드를 호출하면 해당 메서드가 child 객체에 없지만, 프로토타입 체인을 통해 parent 객체에서 찾아 호출된다. 마찬가지로, child 객체에 직접적으로 추가한 y 프로퍼티와 getY 메서드는 해당 객체에 존재하므로 바로 접근이 가능하다.
이를 통해 prototype 객체 속성과 메서드 정의 시, 인스턴스 객체에서 부모 메서드와 속성을 참조한다는 걸 알 수 있다.
// 3번 예제
// 부모 객체
var parent = {
commonProp: '난 부모야!',
sharedMethod: function() {
console.log('이 메서드는 공유됨.');
}
};
// 자식 객체1
var child1 = Object.create(parent);
child1.child1Prop = '나는 1번 자식이야!';
child1.child1Method = function() {
console.log('이 메서드는 1번 자식에게만 적용됨.');
};
// 자식 객체2
var child2 = Object.create(parent);
child2.child2Prop = '나는 2번 자식이야!';
child2.sharedMethod = function() {
console.log('이 메서드는 오버라이딩된 sharedMethod(공유 메서드)임.');
};
// 자식 객체3
var child3 = Object.create(child2);
child3.child3Prop = '나는 2번에게서 상속받은 3번 자식이야!';
// 자식 객체에서 프로퍼티와 메서드에 접근
console.log(child1.commonProp); // [프로토타입 체인] 난 부모야!
child1.sharedMethod(); // [프로토타입 체인] 이 메서드는 공유됨.'
console.log(child1.child1Prop); // 나는 1번 자식이야!
child1.child1Method(); // 이 메서드는 1번 자식에게만 적용됨.
console.log(child2.commonProp); // [프로토타입 체인] 나는 2번 자식이야!
child2.sharedMethod(); // 이 메서드는 오버라이딩된 sharedMethod(공유 메서드)임.
console.log(child2.child2Prop); // 나는 2번 자식이야!
console.log(child3.commonProp); // [프로토타입 체인] 나는 2번에게서 상속받은 3번 자식이야!
child3.sharedMethod(); // 이 메서드는 오버라이드된 sharedMethod(공유 메서드)임.
console.log(child3.child2Prop); // 나는 2번 자식이야!
이 예시에서는 parent 객체를 상속받은 child1과 child2 객체가 있고, child3 객체는 child2 객체를 상속받았다. 이렇게 구성된 프로토타입 체인에서 각 객체는 자신에게 없는 프로퍼티나 메서드를 상위 객체에서 찾아가며 찾는다. 또한, child2 객체에서 sharedMethod를 오버라이딩하고, child3 객체는 child2 객체를 상속받았기 때문에 child2의 프로토타입 체인을 따라간다.
해당 결과는 콘솔에서 어떻게 출력되는지를 통해 확인할 수 있다.
이러한 프로토타입 체인의 끝(종점)은 모든 객체의 부모인 Object.prototype이다. 만약 마지막 부모 객체( Object.prototype)에서까지 속성을 찾지 못할 경우 undfined를 반환한다.
그리고 객체와 프로토타입에 동일한 이름의 속성이나 메서드가 있는 경우에는, 객체의 속성이 먼저 참조된다. 즉, 위(부모)보다는 아래(자식)가 먼저라는 소리다. 프로토타입의 속성이 가려지는 현상을 property shadowing라고 하고, 프로토타입 메서드가 가려지는 현상을 method overriding이라고 한다.
클래스
위에서 언급했지만, 자바스크립트는 프로토타입 기반(prototype-based) 객체지향 언어다. 그래서 클래스 없이 객체지향 프로그래밍 스타일로 프로토타입 체인과 클로저 등으로 객체 지향 언어의 상속, 캡슐화(정보 은닉) 등의 개념을 구현할 수 있다. 그러나 ES6부터는 클래스가 등장했다.
이는 자바스크립트의 접근성을 높이기 위한 조치 중 하나로 여겨지고 있다. 그리고 진짜 클래스라기보다는 그냥 함수에 가깝다. 물론 생성자 함수와 클래스가 완전히 동일하게 돌아가지는 않는다.
클래스 형태
ES6 클래스는 class 키워드를 사용하여 정의한다. 클래스 이름은 생성자 함수와 마찬가지로 첫글자를 대문자(파스칼 케이스)로 하는 것이 일반적이다. 다만, 파스칼 케이스를 사용하지 않아도 에러가 발생하지는 않는다.
생성자 함수와 같이 new 연산자와 함께 클래스 이름을 호출하면 클래스의 인스턴스가 생성된다. 그리고 아래의 예시에서 new 연산자와 함께 호출한 CLA_People은 constructor(생성자)이다.
// 사실 11일차 파트1의 클래스 패턴에서 가져옴
class CLA_People {
constructor(name, age, level) {
this.name = name;
this.age = age;
this.level = level;
}
get peopleName() { //해당 값만 빼오기 가능
return this.name;
}
set peopleName(value) { //해당 값만 재설정 가능
this.name = value;
}
talk() {
console.log(`Class Pattern. ${this.name}님은 ${this.age}살이며, ${this.level} 레벨입니다.`);
}
}
var people2 = new CLA_People('Park', 20, level=99);
people2.talk();
people2.peopleName = 'Kim'; // setter 사용으로 이름 변경
console.log(`Class Pattern. 변경한 이름만 따로 출력: ${people2.peopleName}.`);
people2.talk();
여기서 constructor는 생성 시 최초 한 번만 실행되는 메서드로, 인스턴스를 생성하고 클래스 필드를 초기화하기 위한 특수한 메서드이기도 하다. 정확하게는 인스턴스의 생성과 함께 클래스 필드의 생성과 초기화를 실행하는 것이 constructor이다. 그렇기에 클래스 필드를 초기화해야 한다면 constructor 생략은 해서는 안 된다.
그리고 위의 예시에서 알 수 있다시피 클래스 내에 존재하는 메서드는 인스턴스 메서드이다. 메서드 내에서 클래스를 'this'로 접근할 수 있다. 클래스의 인스턴스 속성에도 접근할 수 있다.
constructor는 클래스 내에 한 개만 존재할 수 있다. 만약 클래스에 2개 이상의 constructor를 포함하면 문법 에러(SyntaxError)가 발생한다. 인스턴스를 생성할 때 new 연산자와 함께 호출한 것이 바로 constructor이며 constructor의 파라미터에 전달한 값은 클래스 필드에 할당된다.
constructor는 생략할 수 있다. constructor를 생략하면 클래스에 constructor() {}를 포함한 것과 동일하게 동작한다. 즉, 그냥 빈 객체를 생성한다.
class Example1 { }
var exa1 = new Example1();
console.log(exa1); // Example1 {}
// 프로퍼티 동적 할당과 초기화를 새로이 진행
exa1.x = 1;
exa1.y = 'abc';
console.log(exa1); // Example1 { x: 1, y: 'abc' }
따라서 인스턴스에 프로퍼티를 추가하려면 인스턴스를 생성한 이후, 프로퍼티를 동적인 방법으로 추가해야 한다.
이러한 클래스는 선언문 이전에 참조할 수 없다. 호이스팅 되지 않는 수많은 요소처럼 말이다. 그러나 클래스는 염연히 따지자면 호이스팅 자체는 발생한다. 이는 let과 const 키워드와 동작이 같아서 그렇다고 한다. 즉, 호이스팅은 발생하지만 클래스 선언문 이전에 TDZ에 빠져서 그렇게 보이는 것이다. 사실 이건 좀 말장난 같기도 하다.
참고로 일반적이지는 않지만, 표현식으로도 클래스를 정의할 수 있다.(어지간하면 쓰지 말자는 소리다.) 함수와 마찬가지로 클래스는 이름을 가질 수도 갖지 않을 수도 있다. 이때 클래스가 할당된 변수를 사용해 클래스를 생성하지 않고 기명 클래스의 클래스 이름을 사용해 클래스를 생성하면 에러가 발생한다. 이는 함수와 마찬가지로 클래스 표현식에서 사용한 클래스 이름은 외부 코드에서 접근 불가능하기 때문이다.
getter & setter
class Person {
constructor(firstName, lastName) {
this._firstName = firstName; // 밑줄(_)은 관례적으로 private 프로퍼티를 나타냄
this._lastName = lastName;
}
// Getter: firstName에 접근할 때 호출되는 메서드
get firstName() {
return this._firstName;
}
// Getter: lastName에 접근할 때 호출되는 메서드
get lastName() {
return this._lastName;
}
// Setter: firstName에 값을 할당할 때 호출되는 메서드
set firstName(value) {
this._firstName = value;
}
// Setter: lastName에 값을 할당할 때 호출되는 메서드
set lastName(value) {
this._lastName = value;
}
// Full Name을 반환하는 메서드
get fullName() {
return `${this._firstName} ${this._lastName}`;
}
}
// Person 클래스의 인스턴스 생성
var person = new Person("김", "철수");
// Getter를 통한 값의 조회
console.log(person.firstName); // 김
console.log(person.lastName); // 철수
console.log(person.fullName); // 김 철수
// Setter를 통한 값의 변경
person.firstName = "이";
person.lastName = "순자";
// 변경된 값 확인
console.log(person.fullName); // 이 순자
getter는 클래스 필드에 접근할 때마다 클래스 필드의 값을 조작하는 행위가 필요할 때 사용한다.
getter는 메서드 이름 앞에 get 키워드를 사용해 정의한다. 이때 메서드 이름은 클래스 필드 이름처럼 사용된다. 다시 말해 getter는 호출하는 것이 아니라 프로퍼티처럼 참조하는 형식으로 사용하며 참조 시에 메서드가 호출된다. getter는 이름 그대로 무언가를 취득할 때 사용하므로 반드시 무언가를 반환해야 한다.
setter는 클래스 필드에 값을 할당할 때마다 클래스 필드의 값을 조작하는 행위가 필요할 때 사용한다.
setter는 메서드 이름 앞에 set 키워드를 사용해 정의한다. 이때 메서드 이름은 클래스 필드 이름처럼 사용된다. 다시 말해 setter는 호출하는 것이 아니라 프로퍼티처럼 값을 할당하는 형식으로 사용하며 할당 시에 메서드가 호출된다.
정적 메서드
class Calculator {
// 정적 메서드: 두 수를 더하는 메서드
static add(x, y) {
return x + y;
}
// 정적 메서드: 두 수를 곱하는 메서드
static multiply(x, y) {
return x * y;
}
}
// 정적 메서드 호출
var sum = Calculator.add(5, 3);
console.log(`덧셈 결과: ${sum}`); // 덧셈 결과: 8
var product = Calculator.multiply(4, 6);
console.log(`곱셈 결과: ${product}`); // 곱셈 결과: 24
클래스의 정적(static) 메서드를 정의할 때 static 키워드를 사용한다. 정적 메서드는 클래스의 인스턴스가 아닌 클래스 이름으로 호출한다. 따라서 클래스 인스턴스를 생성하지 않아도 호출할 수 있다
이는 곧 this.를 사용할 수 없다는 걸 뜻한다. 일반 메서드 내부에서 this는 클래스의 인스턴스를 가리키며, 메서드 내부에서 this를 사용한다는 것은 클래스의 인스턴스의 생성을 전제로 하는 것이다.
달리 말하면 메서드 내부에서 this를 사용할 필요가 없는 메서드는 정적 메서드로 만들 수 있다. 정적 메서드는 Math 객체의 메서드처럼 애플리케이션 전역에서 사용할 유틸리티(utility) 함수를 생성할 때 주로 사용한다.
클래스 상속
자바스크립트에서 클래스 상속은 클래스 간에 코드를 공유하고 재사용하는 메커니즘이다. ES6(ECMAScript 2015)부터 클래스 기능이 도입되었으며, 이를 통해 클래스 간에 상속 관계를 만들 수 있다.
클래스 상속은 부모 클래스(슈퍼 클래스)로부터 특성(메서드 및 프로퍼티)을 상속받아 자식 클래스(서브 클래스)에서 사용할 수 있게 한다. 이를 통해 코드의 재사용성 증가와 구조화된 코드의 작성이 가능해진다.
extends 키워드
// 부모 클래스 (슈퍼 클래스)
class Animal {
constructor(name) {
this.name = name;
}
// 메서드
makeSound() {
console.log("그냥 소리!");
}
// 메서드
notCommand() {
console.log("명령을 못 알아듣는 모양이다!");
}
}
// 자식 클래스 (서브 클래스)
class Dog extends Animal {
// Dog 클래스는 Animal 클래스를 상속받음
// 자식 클래스에서 새로운 메서드 추가
bark() {
console.log(this.name + " 월월월월!");
}
// 오버라이딩(Overriding): 부모 클래스의 메서드를 자식 클래스에서 재정의
makeSound() {
console.log("컹컹컹컹!");
}
}
// 자식 클래스의 인스턴스 생성
var myDog = new Dog("poppi");
// 부모 클래스의 일반 메서드 호출
myDog.notCommand(); // 명령을 못 알아듣는 모양이다!
// 부모 클래스의 오버라이딩 메서드 호출
myDog.makeSound(); // 컹컹컹컹!
// 자식 클래스의 메서드 호출
myDog.bark(); // poppi 월월월월!
extends 키워드는 부모 클래스를 상속받는 자식 클래스를 정의할 때 사용한다. 예시에서의 notCommand가 부모에게서 상속받은 메서드이다. 반면 makeSound는 상속받았지만, 재정의를 하는 오버라이딩이 이루어졌다.
super 키워드
class Food {
constructor(name) {
this.name = name;
}
// 음식의 기본 정보를 출력하는 메서드
describe() {
console.log(`음식 명: ${this.name}`);
}
}
class Dessert extends Food {
constructor(name, flavor) {
// 부모 클래스의 생성자 호출 (생성자에서의 super 사용)
super(name);
// 자식 클래스의 추가적인 프로퍼티 설정
this.flavor = flavor;
}
// 오버라이딩된 describe 메서드
describe() {
// 부모 클래스의 메서드 호출 (메서드에서의 super 사용)
super.describe();
console.log(`맛: ${this.flavor}`);
}
}
// Dessert 클래스의 인스턴스 생성
var cake = new Dessert("민트 케이크", "맵다");
// Dessert 클래스에서 오버라이딩한 describe 메서드 호출
cake.describe();
super 키워드는 부모 클래스를 참조(Reference)할 때 or 부모 클래스의 constructor를 호출할 때 사용한다. 이러한 부모 클래스를 참조하거나 호출함으로써, 자식 클래스에서 this에 접근이 가능해진다.
참고로 자식 클래스에서 constructor 사용시 constructor 반환문 전에 사용되어야 한다.
이러한 super 키워드는 총 2가지 사용법으로 구분할 수 있다.생성자 사용과 메서드 사용이다.
생성자 사용의 경우 super(name);을 통해 부모 클래스인 Food 클래스의 constructor(생성자)를 호출하여 인수를 전달받는다. 그 뒤에 자식 클래스에서 flavor라는 부분을 통해 추가적인 프로퍼티를 설정한다는 걸 알 수 있다.
그리고 메서드 사용의 경우 Dessert 클래스의 describe 메서드에서 super.describe를 호출함으로써 부모 클래스인 Food 클래스의 describe 메서드를 호출한다는 걸 알 수 있다. 이를 통해 이후 추가적인 동작을 수행한다.
this
자바스크립트의 함수는 호출될 때, 매개변수로 전달되는 인자값 이외에, arguments 객체와 this를 암묵적으로 전달 받는다.
그리고 여기서 자바스크립트의 this 키워드는... 놀랍지만, 타 언어에서 자기 자신을 가리키는 것으로 통용되는 this와 다소 다르다. 나도 이걸 처음 봤을 때는 놀랐다. 아니, this를 그냥 평소 쓰듯 썼는데 그게 아니라고?
자바스크립트 말고, 자바에서는 this가 인스턴스 자신(self)을 가리키는 참조변수이다.
public class MyClass {
private int value;
public void setValue(int value) {
// 'this'는 현재 객체의 인스턴스 변수를 가리킴
this.value = value;
}
}
this가 객체 자신에 대한 참조 값을 가지고 있다는 뜻이다. 주로 매개변수와 객체 자신이 가지고 있는 멤버변수명이 같을 경우 이를 구분하고, 현재 객체의 인스턴스 변수를 참조하고자 사용된다.
하지만 자바스크립트의 경우 자바와 같이 this 키워드는 실행 컨텍스트에 따라 동적으로 결정된다. 주로 함수 호출 방식에 따라 다르게 동작한다.
다시 말해, 함수를 선언할 때 this에 바인딩할 객체가 정적으로 결정되는 것이 아니고, 함수를 호출할 때 함수가 어떻게 호출되었는지에 따라 this에 바인딩할 객체가 동적으로 결정된다.
함수 호출
function myFunction() {
console.log(this); // 전역 객체 (브라우저에서는 window)
}
myFunction();
전역 객체(Global Object)는 모든 객체의 유일한 최상위 객체를 의미한다. 일반적으로 Browser-side에서는 window, Server-side(Node.js)에서는 global 객체를 의미한다.
전역객체는 전역 스코프(Global Scope)를 갖는 전역변수(Global variable)를 프로퍼티로 소유한다. 글로벌 영역에 선언한 함수는 전역객체의 프로퍼티로 접근할 수 있는 전역 변수의 메서드이다.
기본적으로 this는 전역객체(Global object)에 바인딩된다. 전역함수는 물론이고 심지어 내부함수의 경우도 this는 외부함수가 아닌 전역객체에 바인딩된다. 함수에 내부 함수가 존재하더라도 this는 전역 객체에 바인딩된다. 콜백 함수의 경우에도 this는 전역 객체에 바인딩된다.
즉, 내부 함수는 일반 함수, 메서드, 콜백함수 어디에서 선언되었든 관게없이 this는 전역객체를 바인딩한다. 이러한 것은 자바스크립트 설계 단계의 결함이라고 할 수 있다. 내부 함수의 this가 전역 객체를 참조하는 것을 회피하고, 원하는 객체와 바인딩하게 하려면 약간의 꼼수를 써야만 한다.
변수에 this 할당
// 변수에 this 할당
function outerFunction1() {
const self1 = this; // 외부 함수의 this를 self 변수에 할당
function innerFunction1() {
console.log(self1); // 외부 함수의 this를 참조
}
innerFunction1();
}
outerFunction1();
내부 함수를 호출하기 전에 외부 함수의 this를 다른 변수에 할당하고, 그 변수를 내부 함수에서 사용한다.
내부 함수를 객체에 bind
// 내부 함수를 객체에 bind
var obj2 = { prop2: 'Custom Object' };
function outerFunction2() {
function innerFunction2() {
console.log(this.prop2);
}
const boundInnerFunction2 = innerFunction2.bind(obj2);
boundInnerFunction2();
}
outerFunction2();
Function.prototype.bind()를 사용하여 내부 함수를 원하는 객체에 묶는다.
화살표 함수 사용
// 화살표 함수 사용
function outerFunction3() {
const innerFunction3 = () => {
console.log(this); // 외부 함수의 this를 참조
};
innerFunction3();
}
outerFunction3();
화살표 함수는 자신만의 this를 가지지 않고 외부 함수의 this를 그대로 사용하므로 내부 함수에서 this를 참조할 때 유용하다.
화살표 함수는 항상 상위 스코프 this를 가리킨다. 객체의 메서드로 정의할 경우 내부의 this는 상위 스코프이므로 전역 객체로 참조가 이뤄진다. 그렇다보니 메서드에는 function 키워드로 작성된 함수를 할당하는 게 권장된다. 이는 프로토타입 메서드도 마찬가지이다. 그리고 생성자 함수에는 프로토타입 객체가 없기에 사용을 지양해야 한다.
메서드 호출
var person = {
name: '김씨',
greet: function() {
console.log(`안녕, ${this.name}!`);
}
};
person.greet(); // 메서드 호출. this는 person 객체를 가리킴
// 안녕, 김씨!
메서드 호출은 함수가 객체의 프로퍼티로 호출되는 경우를 의미한다. 이때 this는 해당 메서드를 호출한 객체를 가리킨다.
생성자 호출
자바스크립트의 생성자 함수는 말 그대로 객체를 생성하는 역할을 한다. 하지만 자바와 같은 객체지향 언어의 생성자 함수와는 다르게 그 형식이 정해져 있는 것이 아니다. 그냥 기존 함수에 new 연산자를 붙여서 호출하면 해당 함수는 생성자 함수로 동작한다.
이는 반대로 생각하면 생성자 함수가 아닌 일반 함수에 new 연산자를 붙여 호출하면 생성자 함수처럼 동작할 수 있다. 그리고 생성자 함수명은 첫문자를 대문자로 기술하여 혼란을 미연에 막아둔다.
function Person(name) {
this.name = name;
this.greet = function() {
console.log(`안녕, ${this.name}!`);
};
}
const john = new Person('김씨'); // 생성자 호출. this는 새로운 객체를 가리킴
john.greet();
// 안녕, 김씨!
생성자 함수를 new 키워드와 함께 호출할 때 this는 새로 생성되는 객체를 가리킨다.
이러한 생성자 함수를 호출하고 동작하는 순서는 다음과 같다.
먼저 빈 객체를 생성하고 this를 바인딩한다. 그리고 tihs는 빈 객체에 바인딩한다. 또한, this를 사용하여 동적으로 속성과 메서드를 생성한다. 마지막으로 생성된 객체를 반환한다. 이때 반환문이 없어도 빈 객체를 생성하고 this를 바인딩한 시점에서 생성되었던 객체가 반환된다.
apply / call / bind 호출
this에 바인딩될 객체는 함수 호출 패턴에 의해 결정된다. 이는 자바스크립트 엔진이 수행하는 것이다.
이러한 자바스크립트 엔진의 암묵적 this 바인딩 이외에 this를 특정 객체에 명시적으로 바인딩하는 방법도 제공된다. 이것을 가능하게 하는 것이 Function.prototype.apply, Function.prototype.call 메서드이다.
apply / call
// Person.apply(foo, [1, 2, 3]);
// Person.call(foo, 1, 2, 3);
function greet() {
console.log(`안녕, ${this.name}!`);
}
var person = {
name: '김씨'
};
greet.apply(person);
// 안녕, 김씨!
greet.call(person);
// 안녕, 김씨!
apply와 call은 함수를 호출할 때 this를 특정 값으로 지정할 수 있게 하는 메서드이다.
두 메서드의 유일한 차이는 함수에 인자를 전달하는 방식이다. apply는 배열로 인자를, call은 각각으로 인자를 전달받는다.
bind
function greet() {
console.log(`안녕, ${this.name}!`);
}
var person = {
name: '김씨'
};
var greetPerson = greet.bind(person);
greetPerson();
// 출력: 안녕, 김씨!
bind 메서드는 ES5에 추가된 Function.prototype.bind이다. 함수의 this를 영구히 바인딩하며, 새로운 함수를 반환한다. call과 apply 메서드처럼 함수를 실행하지 않기 때문에(바인딩과 함수 호출이 분리) 명시적으로 함수를 호출할 필요가 있다.
위의 예시는 bind를 사용하여 greet 함수를 person 객체와 영구적으로 바인딩한 새로운 함수 greetPerson을 생성한 것이다.. 이제 greetPerson을 호출할 때마다 this는 항상 person 객체를 가리킨다.
사족
슬프게도 이게 끝이 아니다. 아직 스코프, 컨텍스트, 클로저가 남았다. 즉, 나머지는 파트 3에서 작성될 예정이다.
아니, 뭐 이런 경우가 다 있나 싶기도 하지만, 파트1 일부처럼 강의 자료만 대충 베끼는 일이 없게 조금 신경을 쓰다보니 작성이 하염없이 길어지게 되었다. 특히 클래스와 this 부분이 그러했다. 그래도 다음 내용들은 분량이 진짜로 적으니 금방 적을 것 같다.
다만, 이 글을 올린 시점이 아침 6시 30분이다. 슬프게도 밤을... 센 상황이다. 일단 조금만 자고 일어난 다음 나머지를 써야겠다. 너무 피곤하다. 😞
'💻 종합 개발 주제 > 📚 웹앱 데브코스' 카테고리의 다른 글
12일차 데브코스 - 클라우딩 어플리케이션 엔지니어링 TIL (0) | 2024.01.17 |
---|---|
11일차 데브코스 pt.3 - 클라우딩 어플리케이션 엔지니어링 TIL (3) | 2024.01.13 |
11일차 데브코스 pt.1 - 클라우딩 어플리케이션 엔지니어링 TIL (2) | 2024.01.11 |
10일차 데브코스 - 클라우딩 어플리케이션 엔지니어링 TIL (4) | 2024.01.09 |
9일차 데브코스 - 클라우딩 어플리케이션 엔지니어링 TIL (6) | 2024.01.09 |