prototype
자바스크립트는 프로토타입 기반(prototype-based) 언어로, 객체 지향 프로그래밍에서 클래스를 사용하는 대신 프로토타입을 활용해 객체를 상속한다. 모든 객체는 __proto__라는 숨겨진 프로퍼티를 통해 프로토타입 객체에 정의된 프로퍼티와 메서드를 상속받을 수 있다. 또한, 자바스크립트에서는 number, string, boolean 같은 원시 값도 객체처럼 동작하는데, 이는 자바스크립트 엔진이 자동으로 해당 값을 감싸는 래퍼 객체(Wrapper Object)를 생성하기 때문이다. 덕분에 원시 값도 String.prototype.toUpperCase() 같은 메서드를 사용할 수 있으며, 메서드 호출 후 래퍼 객체는 즉시 사라져 원시 값의 특성을 유지한다.
클래스 문법이 등장한 이후로도 프로토타입은 여전히 자바스크립트의 근본적인 동작 방식으로 남아 있다. class 문법은 프로토타입 기반 상속을 더 직관적으로 사용할 수 있도록 만든 문법적 설탕(syntactic sugar)일 뿐, 내부적으로는 여전히 프로토타입을 사용해 동작한다. 클래스에서 메서드를 정의하면 실제로는 해당 메서드가 prototype 객체에 추가되며, 인스턴스는 이를 프로토타입 체인을 통해 참조하게 된다. 따라서 클래스 문법을 사용하더라도, 자바스크립트의 객체 상속 구조를 깊이 이해하려면 여전히 프로토타입 개념을 알고 있어야 한다.
prototype in constructorFn
JavaScript에서 new 키워드를 사용하여 함수를 호출하면 해당 함수는 생성자 함수로 동작하며, 새로운 객체를 생성하고 this를 그 객체로 바인딩한다. 생성자 함수가 명시적으로 다른 객체를 반환하지 않는 한, new 키워드를 사용하여 호출된 함수는 자동으로 this를 반환한다. prototype은 이 this를 사용해 객체 내부의 변수에 접근할 수 있으므로 prototype 내의 메서드는 모두 public 멤버 변수를 사용하는 public 메서드이다.
참고로 아래의 예시 코드에서 나는 constructor를 지정했는데, 이에 대한 내용은 아래에 이어서 다루어보겠다.
function User(name, age) {
this.name = name;
this.age = age;
}
User.prototype = {
constructor: User,
getName: function() { return this.name; },
getAge: function() { return this.age; }
}
console.log(user.__proto__) // User {
// constructor: [Function: User],
// getName: [Function: getName],
// getAge: [Function: getAge].
// }
앞서 설명한 바와 같이 new 키워드를 사용하여 호출된 함수는 자동으로 this를 반환한다. 따라서 변수 선언자를 통해 선언된 변수의 경우 외부에서 접근할 방법이 없기에 자연히 private 멤버 변수가 된다. 이는 원리적으로 클로저와 동일하다. 프로토타입에서는 클로저에 접근할 수 없기 때문에, private 멤버 변수를 사용하는 메서드는 반드시 생성자 함수 내에 선언해야 한다. 마찬가지로 this 대신 function 키워드를 사용해 함수를 선언하면 private 메서드를 구현할 수 있다.
function User(name, age) {
const _name = name;
const _age = age;
this.getName = function() {
privateMethod();
return _name;
};
this.getAge = function() {
return _age;
};
function privateMethod() {
console.log("private method");
}
}
자바스크립트 함수가 1급 객체인 점을 활용하면 static 키워드를 구현할 수 있다. 자바스크립트의 모든 클래스는 name이라는 스태틱 멤버 변수를 갖는데, 아래와 같은 방법으로 이를 구현할 수 있다.
function User(name, age) {
const _name = name;
const _age = age;
}
User.name = "User"
// 대표적인 스태틱 메서드인 Date.now의 구현도 이렇게 가능하다
Date.now = function() {
...
}
prototypal inheritance
extends로 간편하게 상속할 수 있는 클래스 문법과 달리 생성자 함수와 프로토타입을 직접 사용하는 경우 아래와 같이 조금 더 번거로운 과정이 수반된다.
// 부모 클래스 정의
function Person(name, age) {
this.name = name;
this.age = age;
}
Person.prototype = {
constructor: Person,
getName: function() { return this.name; },
getAge: function() { return this.age; }
}
// 자식 클래스 정의
function Employee(name, age, jobTitle) {
Person.call(this, name, age); // 부모 클래스의 생성자 호출
this.jobTitle = jobTitle;
}
Employee.prototype = Object.assign(
Object.create(Person.prototype), // 자식 클래스의 프로토타입을 부모 클래스의 인스턴스로 설정
{
constructor: Employee,
getJobTitle: function() { // public method only for Employee
return this.jobTitle;
},
}
);
const employeeAyden = new Employee("ayden", 30, "developer");
console.log(employee instanceof Person) // true
console.log(employee.__proto__ === Employee.prototype) // true
console.log(employee.__proto__.__proto__ === Person.prototype) // true
__proto__
[[prototype]] 혹은 __proto__는 던더 프로토라고 읽으며, 생성자 함수의 프로토타입을 참조하는 특수한 프로퍼티이다. 던더 프로토가 특수한 프로퍼티인 까닭은 이를 생략할 수 있기 때문이다. 아래의 예시를 살펴보면 던더 프로토를 생략한 경우 getName을 호출한 주체는 user가 되기 때문에 문제 없이 "ayden"을 출력했다. 하지만 던더 프로토를 생략하지 않으면 getName을 호출한 주체는 user가 아닌 던더 프로토가 된다. 던더 프로토에는 name 프로퍼티가 존재하지 않으므로 undefined가 출력되는 것이다.
function User(name, age) {
this.name = name;
this.age = age;
}
User.prototype = {
constructor: User,
getName: function() { return this.name; },
getAge: function() { return this.age; },
}
const user = new User("ayden", 30)
user.getName() // "ayden"
user.__proto__.getName() // undefined
prototype in class
자바스크립트의 class 문법은 기존의 생성자 함수와 프로토타입 기반 상속을 더 직관적으로 표현하기 위한 문법적 설탕(syntactic sugar)일 뿐이다. 내부적으로 클래스는 여전히 생성자 함수로 변환되며, 메서드는 해당 클래스의 prototype 객체에 추가된다. 즉, class를 사용하더라도 객체는 여전히 프로토타입 체인을 따라 메서드와 프로퍼티를 상속받는다.
생성된 인스턴스의 이모저모를 콘솔에 찍어보면 이런 사실을 보다 확실하게 확인할 수 있다. 퍼블릭 메서드는 던더 프로토에서 확인할 수 있으며, 스태틱 메서드는 클래스 ─ 의 탈을 쓴 생성자 함수 ─ 의 직접적인 메서드로 처리된다.
class Person {
#name
constructor(name) {
this.#name = name;
}
public getName() { return this.#name; }
static namingRule = "이름은 2글자 이상이어야 합니다.";
};
const person = new Person('ayden')
console.log(person.__proto__) // {getName: ƒ}
console.log(Person.namingRule) // "이름은 2글자 이상이어야 합니다.";
화살표 함수로 메서드를 작성하면, 해당 메서드의 this 바인딩은 상위 스코프의 것을 그대로 물려받는다. new 키워드를 사용하여 호출된 함수는 자동으로 this를 반환하기에 화살표 함수로 작성된 메서드는 던더 프로토로 가지 않고 this를 따라 인스턴스에 포함되게 된다.
class Person {
#name;
constructor(name) {
this.#name = name;
}
getName = () => {
return this.#name;
}
};
const person = new Person('ayden')
console.log(person.getName()) // Person { getName: ƒ }
console.log(person.__proto__.getName()) // TypeError: person.__proto__.getName is not a function
프로토타입의 상속이 꽤나 번거로웠던 것을 생각해보면 클래스의 상속은 경악스러울 정도로 간단하다.
class Person { ... }
class Employee extends Person { ... }
Method Chaining
메서드 체이닝은 객체의 메서드가 자기 자신(this)을 반환함으로써 연속적으로 메서드를 호출할 수 있도록 하는 패턴이다. 이를 통해 코드를 더 간결하게 작성하고 직관적으로 읽을 수 있도록 한다. 아래의 코드를 보면 setName이 이름 변경을 수행한 뒤, this를 리턴하고 있다. 따라서 이후에 호출된 getName 함수를 호출한 주체는 person 인스턴스이다.
function Person(name) {
this.name = name;
};
Person.prototype = {
getName: function() { return this.name; }
setName: function(name) {
this.name = name;
return this
}
}
const person = new Person("John");
console.log(person.setName("Jane").getName()); // Jane
constructor
클래스 문법을 사용하면 프로토타입에 자동으로 constructor 프로퍼티가 추가되고, 생성자 함수를 사용할 경우 ─ prototype in constructorFn 꼭지에서 봤던 것처럼 ─ 이를 직접 프로토타입에 넣어주어야 한다. 이 프로퍼티는 단어 그대로 원래의 생성자 함수를 참조한다. 덕분에 인스턴스의 던더 프로토 내부에 존재하는 constructor를 사용해 원본 생성자 함수를 확인할 수 있을 뿐 아니라, 이를 사용하여 새로운 인스턴스를 찍어낼 수도 있다.
또한, 어떤 인스턴스가 특정 생성자 함수로부터 생성되었는지를 확인하는 데 사용되는 instanceof 연산자도 constructor를 사용한다. 이 연산자는 내부적으로 인스턴스의 프로토타입 체인을 따라가며, 지정된 생성자 함수가 그 체인에 존재하는지 여부를 검사한다.
const person = new Person("john");
const ayden = new person.__proto__.constructor("ayden");
console.log(ayden instanceof Person) // true
블로그의 정보
Ayden's journal
Beard Weard Ayden