super
는 먼저 앞에서 배운 내용만으론 super가 제대로 동작하지 않는다.
내부에서 super
는 ‘어떻게’ 동작할까?. 객체 메서드가 실행되면 현재 객체가 this
가 된다. 이 상태에서 super.method()
를 호출하면 엔진은 현재 객체의 프로토타입에서 method
를 찾아야 한다.
그런데 이런 과정은 ‘어떻게’ 일어나는 걸까?
쉬워 보이지만 실제론 그렇지 않다. 엔진은 현재 객체 this
를 알기 때문에 this.__proto__.method
를 통해 부모 객체의 method
를 찾을 수 있을 것 같지만 아니다!
구체적인 내부 동작에 관심이 없으면 이 부분을 지나치고 [[HomeObject]]
로 바로 넘어가자! 지금부터 다룰 내용을 모르고도 [[HomeObject]]
내용을 이해할 수 있기 때문이다.
아래 예시의 rabbit.__proto__은 animal
이다. rabbit.eat()
에서 this.__proto__
를 사용해 animal.eat()
을 호출해보겠다.
let animal = {
name: "동물",
eat() {
console.log(`${this.name} 이/가 먹이를 먹습니다.`);
}
};
let rabbit = {
__proto__: animal,
name: "토끼",
eat() {
// 예상대로라면 super.eat()이 동작해야 합니다.
this.__proto__.eat.call(this); // (*)
}
};
rabbit.eat(); // 토끼 이/가 먹이를 먹습니다.
(*)
로 표시한 줄에선 eat
을 프로토타입(animal
)에서 가져오고 현재 객체의 컨텍스트에 기반하여 eat
을 호출한다. 여기서 주의해서 봐야 할 부분은 .call(this)
인데 this.__proto__.eat()
만 있으면 현재 객체가 아닌 프로토타입의 컨텍스트에서 부모 eat
을 실행하기 때문에 .call(this)
이 있어야 한다.
예시를 실행하면 예상한 내용이 console에 출력되는 것을 확인할 수 있다.
자 이제 체인에 객체를 하나 더 추가해보자. 이제 슬슬 문제가 발생하기 시작한다.
let animal = {
name: "동물",
eat() {
console.log(`${this.name} 이/가 먹이를 먹습니다.`);
}
};
let rabbit = {
__proto__: animal,
eat() {
// call을 사용해 컨텍스트를 옮겨가며 부모(animal) 메서드를 호출합니다.
this.__proto__.eat.call(this); // (*)
}
};
let longEar = {
__proto__: rabbit,
eat() {
// longEar를 가지고 무언가를 하면서 부모(rabbit) 메서드를 호출합니다.
this.__proto__.eat.call(this); // (**)
}
};
longEar.eat(); // RangeError: Maximum call stack size exceeded
예상과 달리 longEar.eat()
를 호출하니 에러가 발생한다.
원인이 석연치 않아 보이지만 longEar.eat()
이 호출될 때 어떤 일이 발생하는지 하나씩 추척하다보면 이유를 알 수 있다.
먼저 살펴봐야 할 것은 (*)
과 (**)
로 표시한 줄이다. 이 두 줄에서 this
는 현재 객체인 longEar
가 된다. 여기에 핵심이 있는데, 모든 객체 메소드는 프로토타입 등이 아닌 현재 객체를 this
로 갖는다.
따라서 (*)
과 (**)
로 표시한 줄의 this.__proto__
엔 정확히 같은 값, rabbit
이 할당된다. 체인 위로 올라가지 않고 양쪽 모두에서 rabbit.eat
을 호출하기 때문에 무한 루프에 빠지게 되버린다!
이를 그림으로 나타내면 다음과 같다.
(**)
로 표시한 줄에서 rabbit.eat
을 호출하는데, 이때 this
는 longEar
이다.// longEar.eat()안의 this는 longEar입니다.
this.__proto__.eat.call(this) // (**)
// 따라서 윗줄은 아래와 같아집니다.
longEar.__proto__.eat.call(this)
// longEar의 프로토타입은 rabbit이므로 윗줄은 아래와 같아집니다.
rabbit.eat.call(this);
rabbit.eat
내부의 (*)
로 표시한 줄에서 체인 위쪽에 있는 호출을 전달하려 했으나 this
가 longEar
이기 때문에 또다시 rabbit.eat
이 호출된다.// rabbit.eat()안의 this 역시 longEar입니다.
this.__proto__.eat.call(this) // (*)
// 따라서 윗줄은 아래와 같아집니다.
longEar.__proto__.eat.call(this)
// longEar의 프로토타입은 rabbit이므로 윗줄은 아래와 같아집니다.
rabbit.eat.call(this);
rabbit.eat
은 체인 위로 올라가지 못하고 자기 자신을 계속 호출해 무한 루프에 빠진다!이런 문제는 this
만으론 해결할 수 없다.
javascript엔 이런 문제를 해결할 수 있는 함수 전용 특수 내부 프로퍼티가 있다. 바로 [[HomeObject]]
다.
클래스이거나 객체 메소드인 함수의 [[HomeObject]]
프로퍼티는 해당 객체가 저장된다.
super
는 [[HomeObject]]
를 이용해 부모 프로토타입과 메소드를 찾는다.
예시를 통해 [[HomeObject]]
가 어떻게 동작하는지 일반 객체를 이용해 보자!
let animal = {
name: "동물",
eat() { // animal.eat.[[HomeObject]] == animal
console.log(`${this.name} 이/가 먹이를 먹습니다.`);
}
};
let rabbit = {
__proto__: animal,
name: "토끼",
eat() { // rabbit.eat.[[HomeObject]] == rabbit
super.eat();
}
};
let longEar = {
__proto__: rabbit,
name: "귀가 긴 토끼",
eat() { // longEar.eat.[[HomeObject]] == longEar
super.eat();
}
};
// 이제 제대로 동작합니다
longEar.eat(); // 귀가 긴 토끼 이/가 먹이를 먹습니다.
[[HomeObject]]
의 메커니즘 덕분에 메소드가 의도한 대로 동작하는 것을 확인해 보았다. 이렇게 longEar.eat
같은 객체 메소드는 [[HomeObject]]
를 알고 있기 때문에 this
없이도 프로토타입으로부터 부모 메소드를 가져올 수 있습니다.
javascript에서 함수는 대개 객체에 묶이지 않고 ‘자유롭다’. 이런 자유성 때문에 this
가 달라도 객체 간 메소드를 복사하는 것이 가능하다.
그런데 [[HomeObject]]
는 그 존재만으로도 함수의 자유도를 파괴하는데 이는 메소드가 객체를 기억하기 때문이다. 개발자가 [[HomeObject]]
를 변경할 방법은 없기 때문에 한 번 바인딩된 함수는 더이상 변경되지 않는다.
다행인 점은 [[HomeObject]]
는 오직 super
내부에서만 유효하다는 것이다. 그렇기 때문에 메소드에서 super
를 사용하지 않는 경우엔 메소드의 자유성이 보장되며 객체 간 복사 역시 가능하다. 하지만 메소드에서 super
를 사용하면 이야기가 달라진다.
객체 간 메서드를 잘못 복사한 경우에 super
가 제대로 동작하지 않는 경우를 살펴보자.
let animal = {
sayHi() {
console.log(`나는 동물입니다.`);
}
};
// rabbit은 animal을 상속받습니다.
let rabbit = {
__proto__: animal,
sayHi() {
super.sayHi();
}
};
let plant = {
sayHi() {
console.log("나는 식물입니다.");
}
};
// tree는 plant를 상속받습니다.
let tree = {
__proto__: plant,
sayHi: rabbit.sayHi // (*)
};
tree.sayHi(); // 나는 동물입니다. (?!?)
tree.sayHi()
를 호출하니 "나는 동물입니다."가 출력된다.
잘못 된 원인은 꽤 단순하다.
(*)
로 표시한 줄에서 메소드 tree.sayHi
는 중복 코드를 방지하기 위해 rabbit
에서 메소드를 복사해왔다.rabbit
에서 생성했기 때문에 이 메소드의 [[HomeObject]]
는 rabbit
이며, 개발자는 [[HomeObject]]
를 변경할 수 없다.tree.sayHi()
의 코드 내부엔 super.sayHi()
가 있다. rabbit
의 프로토타입은 animal
이므로 super
는 체인 위에있는 animal
로 올라가 sayHi
를 찾는다.일련의 과정을 그림으로 나타내면 다음과 같다.
[[HomeObject]]
는 클래스와 일반 객체의 메소드에서 정의된다. 그런데 객체 메소드의 경우 [[HomeObject]]
가 제대로 동작하게 하려면 메소드를 반드시 method()
형태로 정의해야 한다. "method: function()"
형태로 정의하면 안 된다!
개발자 입장에선 두 방법의 차이는 그리 중요하지 않을 수 있지만, javascript 입장에선 아주 중요하다.
메소드 문법이 아닌(non-method syntax) 함수 프로퍼티를 사용해 예시를 작성해 보면 다음과 같다. [[HomeObject]]
프로퍼티가 설정되지 않기 때문에 상속이 제대로 동작하지 않는 것을 확인할 수 있다.
let animal = {
eat: function() { // 'eat() {...' 대신 'eat: function() {...'을 사용해봅시다.
// ...
}
};
let rabbit = {
__proto__: animal,
eat: function() {
super.eat();
}
};
rabbit.eat(); // SyntaxError: 'super' keyword unexpected here ([[HomeObject]]가 없어서 에러가 발생함)