본 시리즈는 모던 자바스크립트 Deep Dive 책을 참고하여 작성하고 있습니다.
자바스크립트는 프로토타입 기반의 객체지향 프로그래밍 언어이다. 프로토타입은 클래스보다 더 효율적이며 강력한 객체지향 프로그래밍 능력을 가진다. ES6에서 클래스가 도입되었지만, 클래스도 함수이며 기존 프로토타입 기반 패턴의 문법적 설탕이라고 볼 수 있다. (그러나 생성자 함수와 다소 다르게 동작하기도 한다)
상속은 객체지향 프로그래밍의 핵심 개념으로, 어떤 객체의 프로퍼티나 메서드를 다른 객체가 상속받아 그대로 사용할 수 있게 하는 것을 말한다. 자바스크립트에서는 프로토타입을 통해 상속을 구현하고, 불필요한 중복을 제거한다.
function Circle(radius) {
this.radius = radius;
this.getDiameter = function () {
return this.radius * 2;
}
}
const circle_1 = new Circle(5);
const circle_2 = new Circle(8);
위 예시처럼 생성자 함수 안에서 메서드를 생성할 경우, Circle 생성자 함수로 새로운 인스턴스를 만들 때마다 새로운 getDiameter 메서드를 중복으로 생성한다. 이처럼 중복으로 메서드를 소유하게 되면 메모리를 불필요하게 낭비하게 되고 인스턴스를 생성할 때마다 메서드를 생성해야 하므로 성능에도 좋지 않다. 이 경우에는 getDiameter 메서드를 하나만 생성해서 모든 인스턴스가 공유하는 것이 바람직하다.
function Circle(radius) {
this.radius = radius;
}
Circle.prototype.getDiameter = function () {
return this.radius * 2;
}
const circle_1 = new Circle(5);
const circle_2 = new Circle(8);
프로토타입은 Circle 생성자 함수의 prototype 프로퍼티에 바인딩되어 있다. 프로토타입에 모든 인스턴스가 공유할 메서드를 추가하여 상속을 구현할 수 있다. Circle.prototype
은 Circle 생성자 함수로 생성한 인스턴스의 상위 객체 역할을 한다. getDiameter
메서드는 한 번만 생성되어 Circle.prototype
의 메서드로 할당된다. 모든 인스턴스가 공통으로 사용할 프로퍼티나 메서드를 프로토타입에 미리 구현해 두면, 생성자 함수로 생성된 모든 인스턴스가 별도 구현 없이 프로토타입의 자산을 공유해 사용할 수 있다.
프로토타입 객체를 줄여서 프로토타입으로 부른다. 이는 객체 간 상속을 구현하기 위해서 사용된다. 프로토타입은 상위 객체의 공유 프로퍼티를 제공한다. 하위 객체는 상위 객체의 프로퍼티를 자신의 프로퍼티처럼 사용 가능하다.
모든 객체는 [[Prototype]]
내부 슬롯을 가지고, 이 내부 슬롯이 프로토타입 객체의 참조를 가지고 있다. 객체의 생성 방식에 따라 프로토타입이 결정되어 [[Prototype]]
슬롯에 저장된다. 모든 객체는 하나의 프로토타입을 가지고, 모든 프로토타입은 생성자 함수에 연결되어 있다.
생성자 함수도 객체이다. 그리고 그 생성자 함수의 프로토타입도 객체이다. 생성자 함수와 프로토타입은 각각 prototype
, constructor
프로퍼티를 가져서 서로에 대해 알고 있다. 생성자 함수를 통해 어떤 인스턴스를 생성하면, 그 인스턴스는 [[Prototype]]
내부 슬롯에 프로토타입 객체의 참조를 가진다. 그러나 이 내부 슬롯에 직접 접근할 수는 없고, __proto__
접근자 프로퍼티를 통해 프로토타입 객체에 간접적으로 접근할 수 있다. 아래 그림이 이 문단의 내용을 나타낸다.
prototype
프로퍼티생성자 함수가 소유하는 prototype
프로퍼티는 함수가 생성할 인스턴스의 프로토타입을 가리킨다. 이 프로퍼티는 constructor 함수 객체만이 소유한다. 따라서 non-constructor인 화살표 함수나 ES6 메서드 축약 표현으로 정의한 메서드는 prototype 프로퍼티를 가지지 않는다.
생성자 함수의 prototype 프로퍼티는 생성된 인스턴스가 가지는 __proto__
와 동일한 프로토타입 객체를 가리킨다.
constructor
프로퍼티프로토타입 객체가 가지는 contructor
프로퍼티는 자신을 참조하고 있는 생성자 함수를 가리킨다. 생성자 함수 객체가 생성될 때 두 객체가 프로퍼티로 연결된다.
인스턴스는 프로토타입 객체를 상속받는다. 따라서 constructor
프로퍼티를 상속받아서 사용할 수 있다.
__proto__
의 특징__proto__
는 접근자 프로퍼티이다[[Prototype]]
은 내부 슬롯이고, 내부 슬롯은 프로퍼티가 아닌 의사 프로퍼티이다. 내부 슬롯은 직접 접근할 수 없고, 대신 일부 내부 슬롯은 간접적으로 접근 가능한 수단이 제공된다. [[Prototype]]
은 __proto__
접근자 프로퍼티를 통해 간접 접근할 수 있다.
즉, __proto__
접근자 프로퍼티의 getter/setter 접근자 함수를 통해 프로토타입에 접근하거나 할당할 수 있다. __proto__
프로퍼티에 새로운 프로토타입을 할당하면 인스턴스의 프로토타입을 교체할 수 있다.
여기서 Object → 생성자 함수이므로, prototype 프로퍼티를 통해 프로토타입 객체에 접근할 수 있다. 프로토타입 객체가 __proto__
프로퍼티를 가지고 있다.
접근자 프로퍼티: 자체적으로는 값을 가지지 않고, 다른 데이터 프로퍼티의 값을 읽거나 저장할 때 사용.
[[Get]] [[Set]]
프로퍼티 어트리뷰트로 구성
__proto__
접근자 프로퍼티는 상속을 통해 사용된다__proto__
프로퍼티는 객체가 직접 소유하고 있는 것이 아니고, Object.prototype
의 프로퍼티이다. 모든 객체의 프로토타입은 프로토타입 체인이라는 계층 구조에 묶여 있고, 이 프로토타입 체인의 최상위 객체가 Object
이다. (뒤에서 상술)
__proto__
접근자 프로퍼티로 프로토타입에 접근하는 이유상호 참조에 의한 프로토타입 체인이 생성되는 것을 방지하기 위해서이다. 즉, 프로토타입 체인은 단방향 링크드 리스트가 되어야 하기 때문에, 어떤 객체들이 서로의 프로토타입이 되는 순환 구조를 방지하기 위해서이다. 이처럼 순환 구조가 될 경우 프로토타입 체인에서 프로퍼티를 검색할 때 무한 루프에 빠지게 된다.
const obj_a = {};
const obj_b = {};
obj_a.__proto__ = obj_b;
obj_b.__proto__ = obj_a;
위 스크린샷처럼, 순환 참조가 발생하게 되면 __proto__
접근자 프로퍼티는 에러를 발생시킨다. 프로토타입을 교체하기 전에 문제가 없는지 체크를 거친 후 프로토타입을 교체하도록 되어 있기 때문이다.
__proto__
접근자 프로퍼티를 코드 내에서 직접 사용하는 것은 권장하지 않는다왜냐하면 모든 객체가 __proto__
접근자 프로퍼티를 사용할 수 있는 것이 아니기 때문이다. 직접 상속을 통해 Object.prototype
을 상속받지 않는 경우 __proto__
접근자 프로퍼티를 사용할 수 없다. 대신 Object.getPrototypeOf
(ES5 도입됨)와 Object.setPrototypeOf
(ES6 도입됨)를 사용하는 것이 권장된다.
프로토타입과 생성자 함수는 단독으로 존재할 수 없고, 항상 쌍으로 존재한다. 프로토타입은 생성자 함수와 함께 생성되며, 두 객체는 prototype, constructor
프로퍼티로 연결되어 있다.
생성자 함수를 통해서 객체를 생성하면, 그 객체의 constructor
프로퍼티가 가리키는 것은 생성자 함수 객체이다.
그런데 리터럴 표기법을 통한 객체 생성처럼, 생성자 함수 없이도 객체를 생성할 수 있다. 이 경우에도 프로토타입이 필요하다. 리터럴 표기법으로 생성된 객체도 프로토타입이 존재하지만, constructor
프로퍼티가 가리키는 값은 그 객체를 생성한 생성자 함수가 아닐 수도 있다.
Object
함수가 value 인자(선택)와 함께 호출될 경우, 아래 단계를 수행한다:
OrdinaryObjectCreate
를 호출Object.prototype
을 프로토타입으로 가지는 빈 객체를 생성ToObject(value)
추상 연산: ECMAScript 사양 내부 동작의 구현 알고리즘을 표현한 것. 의사 코드.
추상 연산 OrdinaryObjectCreate
를 호출하여 빈 객체를 생성한 후, 프로퍼티를 추가한다. 즉, Object.prototype
을 프로토타입으로 가진다. 이는 Object 생성자 함수-2와 동일한 방식이지만, new.target
의 확인이나 프로퍼티를 추가하는 처리에서 차이가 있다.
함수 선언문이나 함수 표현식이 평가된 경우에는 Function 생성자 함수로 함수 객체가 생성되는 것이 아니다. 그러나 constructor 프로퍼티를 통해 확인하면 함수 객체의 생성자 함수는 Function 생성자 함수이다.
프로토타입은 생성자 함수가 생성되는 시점에 더불어 생성된다. 프로토타입과 생성자 함수는 단독으로 존재할 수 없고 쌍으로 존재하기 때문이다.
constructor는 함수 정의가 평가되어 함수 객체를 생성하는 시점에 프로토타입도 함께 생성된다. constructor란, 내부 메서드 [[Construct]]
를 가져, new 연산자와 함께 생성자 함수로 호출 가능한 함수 객체이다. non-constructor는 프로토타입이 생성되지 않는다. 함수 선언문, 함수 표현식과 같은 일반 함수로 정의한 함수 객체가 constructor가 된다.
여기서 함수 선언문으로 정의한 함수는 호이스팅되어 런타임 이전에 실행된다. 이 시점에 프로토타입도 같이 생성된다.
Object, String, Number, ... 등의 빌트인 생성자 함수도 생성되는 시점에 프로토타입이 생성된다. 빌트인 생성자 함수는 전역 객체가 생성되는 시점에 생성된다. 전역 객체는 코드가 실행되기 이전에 자바스크립트 엔진에 의해 생성되며, 브라우저에서는 window
, node.js 환경에서는 global
객체이다.
이처럼, 생성자 함수와 프로토타입은 인스턴스(객체)가 생성되기 전에 객체화되어 존재한다. 생성자 함수나 리터럴 표기법에 의해 인스턴스가 생성되면, 그 객체의 [[Prototype]]
내부 슬롯에 프로토타입이 할당되어 프로토타입을 상속받는다.
객체를 생성하는 방법은 아래와 같다.
Object.create
메서드이 방법들은 결국 추상 연산 OrdinaryObjectCreate
에 의해 생성된다는 공통점이 있다. 이 추상 연산은 아래와 같이 행동한다.
[[Prototype]]
슬롯에 프로토타입을 할당한다OrdinaryObjectCreate
을 호출할 때, 프로토타입으로 Object.prototype
을 전달한다.OrdinaryObjectCreate
을 호출할 때, 프로토타입으로 Object.prototype
을 전달한다.OrdinaryObjectCreate
을 호출할 때, 프로토타입으로 생성자 함수의 prototype 프로퍼티에 바인딩된 객체를 전달한다.function Cat(name) {
this.name = name;
}
Cat.prototype.meow = function () {
console.log("meow");
}
const chunsik = new Cat("chunsik");
위 코드를 통해 생성되는 생성자 함수와 인스턴스 간의 다이어그램을 그려보면 아래 그림과 같다.
Cat.prototype
프로토타입 객체도 생성된다. Function.prototype
을 프로토타입으로 가진다.Object.prototype
을 프로토타입으로 가진다. [[Prototype]]
내부 슬롯에 할당되어 저장된다.Cat
생성자 함수를 통해 인스턴스인 chunsik
객체가 생성되면, 그 객체는 Cat.prototype
을 프로토타입으로 가진다.결과적으로 chunsik
객체는 Cat.prototype
객체를, Cat.prototype
객체는 Object.prototype
객체를 프로토타입으로 가지는 프로토타입 체인이 형성된다.
chunsik.hasOwnProperty('name');
chunsik 객체가 ‘name’이라는 프로퍼티를 가지는지 확인하기 위해 hasOwnProperty
메소드를 호출했다. 이때, chunsik 객체는 hasOwnProperty
메소드를 가지고 있지 않다. hasOwnProperty
메소드는 Object.prototype
가 가지고 있는 메소드이다. 이 메소드를 호출하기 위해 다음과 같은 과정을 거친다.
chunsik
객체에서 hasOwnProperty
메소드를 가지고 있는지 확인한다.chunsik
객체가 프로토타입으로 가지고 있는 Cat.prototype
객체에서 hasOwnProperty
메소드를 가지고 있는지 확인한다.Cat.prototype
객체의 프로토타입인 Object.prototype
이 hasOwnProperty
를 가지고 있는지 확인한다.Object.prototype
에는 hasOwnProperty
가 있기 때문에, Object.prototype.hasOwnProperty
메소드를 호출한다. 이때 해당 메소드의 this에는 chunsik
객체가 바인딩된다.Object.prototype
에도 접근하려는 프로퍼티가 없었다면, 에러를 발생시키지 않고 undefined
를 반환한다.이처럼, 자바스크립트 객체의 프로퍼티에 접근하려고 할 때 해당 객체에 접근하려는 프로퍼티가 없다면 **[[Prototype]]
내부 슬롯의 참조를 따라 상위 프로토타입을 순차적으로 검색한다. 이를 프로토타입 체인이라고 한다. 이를 통해 자바스크립트는 상속을 구현하며, 프로퍼티 검색**을 지원한다.
프로토타입 체인에서 항상 최상위에 있는 것은 Object.prototype
이다. 즉, 모든 객체는 Object.prototype
을 상속받는다. Object.prototype
의 프로토타입, 즉 [[Prototype]]
에 할당되는 값은 null
이다.
스코프 체인과 프로토타입 체인은 협력하여 동작한다. 스코프 체인은 함수의 중첩에 의해 계층적 구조를 이루며, 식별자 검색을 위한 매커니즘이다. 프로토타입 체인은 그 식별자의 프로퍼티를 검색하기 위한 매커니즘이다.
오버라이딩: 상위 클래스가 가진 메서드를 하위 클래스가 재정의하여 사용하는 것
오버로딩: 함수 이름은 동일하지만, 매개변수의 타입이나 개수가 다른 메소드를 구현하여 매개변수에 따라 메소드를 구별해 호출하는 것
// 즉시 실행 함수 형태로 생성자 함수를 묶어서 생성
const Cat = (function() {
function Cat(name) {
this.name = name;
}
Cat.prototype.meow = function() {
console.log("meow");
};
return Cat;
}());
const chunsik = new Cat("chunsik");
chunsik.meow = function() {
console.log("나는 고양이");
};
chunsik.meow(); // 나는 고양이
프로토타입은 동적으로 임의의 다른 객체로 교체할 수 있다. 즉, 동적으로 상속 관계를 변경할 수 있다. 프로토타입은 생성자 함수 혹은 인스턴스에 의해 교체할 수 있다.
생성자 함수의 prototype 프로퍼티를 통해 프로토타입에 접근할 수 있고, 프로토타입을 재할당할 수도 있다.
function Cat(name) {
this.name = name;
}
Cat.prototype = {
meow() {
console.log("meow");
}
};
위 예시 코드에서, Cat 생성자 함수의 프로토타입에 객체 리터럴을 할당했다. 객체 리터럴은 자바스크립트 엔진이 프로토타입으로 사용하기 위해 암묵적으로 생성한 객체가 아니기 때문에, constructor 프로퍼티를 가지고 있지 않다. 객체 리터럴의 프로토타입 슬롯에 있는 것은 Object.prototype
객체이고, 이것의 constructor
프로퍼티로 접근할 수 있는 생성자 함수는 Object
빌트인 생성자 함수이다.
생성자 함수가 정의되었을 시점
객체 리터럴로 프로토타입을 교체한 시점
오른쪽 그림처럼, Cat.prototype
은 constructor 프로퍼티를 가지지 않는다. 그래서 chunsik.constructor
을 호출하면 Cat 생성자 함수가 아니라 Object 생성자 함수를 얻게 된다. 프로토타입 체인을 통해 constructor 프로퍼티를 검색했을 때, 해당 프로퍼티는 Object.prototype
에서 찾을 수 있기 때문이다.
이처럼 프로토타입을 교체하면 constructor 프로퍼티와 생성자 함수 간의 연결이 파괴된다. 이를 복구하려면 교체된 프로토타입의 constructor 프로퍼티에 생성자 함수를 할당해 주어야 한다.
Cat.prototype = {
constructor: Cat,
meow() {
console.log("meow");
}
};
인스턴스의 __proto__
접근자 프로퍼티나 Object.getPrototypeOf
메소드를 통해 프로토타입에 접근할 수 있다. 또한 접근자 프로퍼티나 Object.setPrototypeOf
메소드를 통해 프로토타입을 교체할 수도 있다.
function Cat(name) {
this.name = name;
}
const chunsik = new Cat("jordi");
const newPrototype = {
meow() {
console.log("meow");
}
};
Object.setPrototypeOf(chunsik, newPrototype);
// 혹은
chunsik.__proto__ = newPrototype;
생성자 함수의 프로토타입을 교체하는 것과 마찬가지로, 이 경우에도 newPrototype
객체에는 constructor 프로퍼티가 없어 생성자 함수와의 연결이 끊어지게 된다. 따라서 chunsik.constructor === Object
이다.
생성자 함수의 프로토타입을 교체했을 때는 Cat.prototype
을 통해서 교체된 프로토타입 객체에 접근할 수 있었다. 그러나 인스턴스에서 프로토타입을 교체한 경우에는 Cat.prototype
을 통해서 교체된 프로토타입 객체에 접근할 수 없다. 이 경우 Cat.prototype
으로 얻어지는 객체는 처음에 생성자 함수가 생성되면서 함께 생성되었던 프로토타입 객체이다.
프로토타입을 직접 교체하면 prototype
, constructor
프로퍼티의 연결을 신경써야 하기 때문에 번거로우므로, 직접 교체는 피하는 것이 좋다. 상속 관계를 인위적으로 설정하려면 ‘직접 상속'을 하는 것이 더 편리하고 안전하며, 혹은 ES6에 도입된 클래스를 사용하면 간편하고 직관적이다.
instanceof
instanceof
연산자는 이항 연산자로, 아래와 같은 형태로 사용한다. 우변이 생성자 함수가 아니면 TypeError가 발생한다. 좌변의 객체 프로토타입 체인 상에 우변의 생성자 함수의 prototype에 바인딩된 객체가 존재하면 true로 평가된다. 즉, instanceof
는 생성자 함수의 prototype 프로퍼티에 바인딩된 객체가 좌변의 객체의 프로토타입 체인 상에 존재하는지 확인하는 연산자이다.
객체 instanceof 생성자 함수 // --> boolean
Object.create
Object.create
메서드를 통해 명시적으로 프로토타입을 지정해 새로운 객체를 생성할 수 있다. 첫 번째 매개변수에 전달되는 객체의 프로토타입 체인에 속하는 객체를 생성한다.
Object.create(프로토타입 객체, [생성할 객체의 프로퍼티를 갖는 객체]);
// ex)
const blank = Object.create(Object.prototype); // {}
function Cat (name) {
this.name = name;
}
const chunsik = Object.create(Cat, {
name: "chunsik",
meow() {
console.log("chunsik");
}
});
Object.create(null);
을 호출하여 프로토타입 체인의 종점에 위치하는 객체를 만들 수 있다. 프로토타입 체인의 종점에 위치하는 객체는 Object.prototype
의 빌트인 메소드를 사용할 수 없다. obj.hasOwnProperty, obj.isPrototypeOf
와 같은 빌트인 메소드를 사용할 수 없다는 뜻이다. 따라서 Object.prototype의 빌트인 메소드를 객체가 직접 호출하는 것은 권장되지 않는다. 대신에 아래 예제처럼 간접적으로 호출하는 것이 좋다.
const obj = Object.create(null);
obj.a = 'hi';
obj.hasOwnProperty('a');
// Uncaught TypeError: obj.hasOwnProperty is not a function
Object.prototype.hasOwnProperty.call(obj, 'a'); // true
__proto__
ES6에서는 객체 리터럴 안에서 __proto__
접근자 프로퍼티를 통해 직접 상속을 구현할 수 있다.
const myProto = { a: 1 };
const obj = {
b: 2,
__proto__: myProto
};
Object.getPrototypeOf(obj) === myProto;
정적 프로퍼티/메서드는 인스턴스를 생성하지 않아도 참조하거나 호출 가능한 프로퍼티/메서드이다. 생성자 함수도 객체이므로 프로퍼티와 메서드를 가질 수 있는데, 이처럼 생성자 함수가 가지고 있는 프로퍼티나 메서드가 정적 프로퍼티/메서드가 된다. 생성자 함수가 생성한 인스턴스는 정적 프로퍼티/메서드를 참조하거나 호출할 수 없다. 생성자 함수 객체는 프로토타입 체인에 속해 있지 않기 때문이다.
[constructor].[prop/func]
→ 정적 프로퍼티/메서드[constructor].prototype.[prop/func]
→ 프로토타입 프로퍼티/메서드function MyCon(x) {
this.x = x;
}
MyCon.staticLog = function() {
console.log('hi world');
};
MyCon.staticProp = 'hi';
MyCon.staticLog();
MyCon.staticProp;
const obj = new MyCon(2);
obj.staticLog();
// VM164:15 Uncaught TypeError: obj.staticLog is not a function
in
연산자key in object // -> 어떤 프로퍼티 키가 어떤 객체 표현식에 존재하는지 확인. boolean으로 평가됨
// ES6
Reflect.has(object, key); // -> in 연산자와 동일하게 동작
Object.prototype.hasOwnProperty
obj.hasOwnProperty('toString'); // false
인수로 전달받는 프로퍼티 키가 객체의 고유 키인 경우에만 true를 반환한다. 프로토타입 체인 상에 있더라도 고유 키가 아니면 false를 반환한다.
for ... in
객체 내 모든 프로퍼티를 순회하며 열거한다. 이 때, 프로토타입 체인 상의 Enumerable한 프로퍼티를 모두 열거한다. Enumerable 여부는 각 프로퍼티의 프로퍼티 어트리뷰트에 저장되는 값이다. 또, 프로퍼티 키가 심벌인 프로퍼티는 열거하지 않는다. 열거 시 순서는 보장되지 않는다.
for (변수선언문 in 객체) { ... }
const obj3 = {
x: 1,
y: 2
};
for (const key in obj3) {
console.log(key + ' ' + obj3[key]);
}
// x 1
// y 2
Object.keys/values/entries
객체 자신의 고유 프로퍼티만을 열거하기 위해서는 Object.keys/values/entries
가 더 권장된다.
Object.keys
→ 객체 자신의 열거 가능한 프로퍼티 키를 배열로 반환Object.values
→ 객체 자신의 열거 가능한 프로퍼티 값을 배열로 반환Object.entries
→ 객체 자신의 열거 가능한 프로퍼티 키와 값 쌍 배열을 배열에 담아 반환