생성자와 프로토타입

이효범·2022년 5월 3일
1
post-thumbnail

생성자와 프로토타입에 대해 잘 몰라도 자바스크립트를 사용하는 데 별 어려움이 없었을 것이다. 하지만 앞서 말한 두 개념을 모른다면 자바스크립트라는 언어의 진가를 알아볼 수 없다. 자바스크립트에는 클래스가 없기 때문에 똑같은 특성을 갖춘 여러 객체를 만들 때는 생성자와 프로포타입의 도움이 필요하다. 일부 패턴은 클래스와 비슷해 보이지만 그렇다고 같은 방식으로 동작한다는 뜻은 아니다. 이 장에서는 생성자와 프로토타입에 대해 자세히 살펴보고 자바스크립트에서 이들을 사용해 객체를 작성하는 방법에 대해 살펴보자.

생성자

간단히 말해 생성자(constructor)는 객체를 만들 때 new 연산자와 함께 사용하는 함수이다. 지금까지 우리는 Object, Array, Function과 같은 자바스크립트 내장 생성자를 살펴보았다. 생성자를 사용해서 얻을 수 있는 장점은 같은 생성자를 사용해 만든 객체는 같은 프로퍼티와 메소드를 가진다는 것이다. 비슷한 객체를 여러 개 만들고 싶다면 직접 생성자를 작성하여 자신만의 참조 타입을 만들면 된다.

생성자는 함수이므로 함수와 같은 방식으로 정의한다. 다른 점이 있다면 평범한 함수와 구분하기 위해 생성자의 이름은 보통 대문자로 시작한다는 것이다. 예를 들어 다음은 내용이 없는 Person 함수이다..

function Person() {
 // 내용은 일부러 비워두었음 
}

이 함수는 생성자지만 문법적으로는 다른 함수와 아무런 차이가 없다. 생성자라고 짐작할 만한 부분은 대문자로 시작하는 Person 이라는 이름뿐이다.

생성자를 정의했다면 다음과 같이 Person 객체를 두 개 작성할 수 있다.

let person1 = new Person();
let person2 = new Person();                         

생성자에 전달할 인수가 없다면 괄호를 생략할 수 있다.

let person1 = new Person;
let person2 = new Person;

Person 생성자에서 명시적으로 반환하는 값이 없음에도 불구하고 person1과 person2는 Person 타입의 새 인스턴스이다. new 연산자는 자동적으로 주어진 타입의 객체를 생성하고 반환하는데, 바꿔 생각하면 instanceof 연산자를 사용해 객체 타입을 추론할 수도 있다는 뜻이 된다. 다음은 새로 생성한 객체에 instanceof를 사용한 코드이다.

console.log(person1.instanceof Person); // true
console.log(person2.instanceof Person); // true

person1과 person2는 Person 생성자를 사용해 생성되었기 때문에 이 객체가 Person 타입인지 instanceof를 통해 확인하면 true를 반환한다.

인스턴스의 타입은 constructor 프로퍼티를 사용해 확인할 수도 있다. 모든 객체 인스턴스에는 자동으로 추가되는 constructor라는 프로퍼티가 있는데 이 프로퍼티는 인스턴스를 생성할 때 사용했던 생성자 함수를 참조한다. 기본(generic) 객체(Onject 생성자를 사용해 생성한 객체 인스턴스를 의미한다)의 경우 constructor는 Object를 참조한다. 직접 만든 생성자를 사용해 객체를 생성했다면 constructor는 그 생성자 함수를 가리키게 될 것이다. 예를 들어 다음 코드에서 person1과 person2의 constructor 프로퍼티는 Person을 가리킨다.

console.log(person1.constructor === Person); // true
console.log(person2.constructor === Person); // true

두 객체 모두 Person 생성자를 사용해 작성되었기 때문에 console.log 함수에서 출력하는 값은 둘 다 true가 된다.

하지만 인스턴스의 타입을 확인할 때는 위에서 본 것처럼 인스턴스와 인스턴스의 생성자 사이의 관계를 이용하기보다 instanceof 연산자를 사용하는 편이 더 좋다.
객체의 constructor 프로퍼티는 덮어쓸 수 있기 때문에 생성자를 비교하는 방법은 정확하지 않을 수 있다.

당연한 말이지만 앞에서 본 것과 같은 빈 생성자 함수는 별로 유용하지 않다. 생성자의 중요한 기능은 동일한 프로퍼티와 메소드를 가진 객체를 쉽게 대량으로 만들 수 있다는 것이다. 이를 위해서는 다음과 같이 생성자 내부에서 this에 원하는 프로퍼티를 추가하는 방법을 쓸 수 있다.

function Person(name) {
  this.name = name;
  this.sayName = function() {
   console.log(this.name); 
  };
}

위와 같이 조금 수정된 Person 생성자에는 name이라는 인수를 전달할 수 있으며 이 인수는 this 객체의 name 프로퍼티에 할당된다. 그 뒤에는 this 객체에 sayName() 메소드는 추가했다. this 객체는 생성자를 호출할 때 new 연산자가 자동으로 만들어내는 값인데 생성자 타입의 인스턴스를 참조한다(여기서 this는 Person의 인스턴스이다). new 연산자가 반환할 값을 자동으로 만들어내기 때문에 생성자 함수에서는 값을 반환하지 않아도 된다.

이제 Person 생성자를 사용해 객체를 생성하면서 name 프로퍼티도 초기화할 수 있게 됐다.

let person1 = new Person("Nicholas");
let person2 = new Person("Greg");

console.log(person1.name);  // "Nicholas"
console.log(person2.name);  // "Greg"

person1.sayName();  // "Nicholas" 출력
person2.sayName();  // "Greg" 출력

생성된 person1와 person2 객체에는 name이라는 고유 프로퍼티가 있으며 sayName()을 샐항하면 사용한 객체에 따라 다른 값을 출력한다.

생성자 내부에서 명시적으로 return을 호출할 수도 있다. 반환되는 값이 객체값이면 새로 만들어진 객체 인스턴스 대신 그 값이 반환한다. 반환되는 값이 원시 값이면 새로 생성된 객체가 반환되면 생성자 내부에서 반환한 원시 값은 무시된다.

생성자를 사용하면 타입의 인스턴스를 일관성 있는 방법으로 생성할 수 있으면 객체를 사용하기 전에 프로퍼티 전에 미리 설정해 둘 수 있다. 예를 들어 다음은 인스턴스를 초기화할 때 생성자 내부에서 Object.defineProperty()를 사용하는 코드이다.

function Person(name) {
 Object.defineProperty(this, "name", {
   get: function() {
     return name;
   },
   set: function(newName) {
     name = newName;
   },
   enumerable: true,
   configurable: true
 });
  
 this.sayName = function() {
   console.log(this.name);
 };
}

이 예제의 Person 생성자에서 name 프로퍼티는 name 인수에 값을 저장하는 접근자 프로퍼티이다. 이 방식은 인수가 지역 변수처럼 동작하기 때문에 문자가 없다.

생성자를 호출할 때는 반드시 new 연산자를 사용해야 한다. 그렇지 않으면 객체가 생성되는 대신 전역 컨텍스터 객체(global object)가 의도치 않게 수정되는 일이 발생할 수 있다.
다음 코드를 살펴보자.

let person1 = Person("Nicholas");  // 주의: "new"가 누락되었다.

console.log(person1 instanceof Person); // false
console.log(typeof person1);  // "undefined"
console.log(name);  // "Nicholas"

Person을 new 연산자 없이 함수처럼 호출하면 생성자 내부의 this는 전역 this 객체와 같다. Person 생성자는 new 연산자와 함께 사용해야 값을 반환하므로 person1 변수에는 아무런 값도 저장되지 않는다. 쉽게 말해 new가 없으면 Person은 그저 return문이 없는 함수일 뿐이다. Person에 전달된 name 인수의 값을 this.name에 할당하면 실제로는 전역 변수 name에 할당하는 꼴이 된다. 다음 글에서는 방금 살펴본 문제를 비롯해 더 복잡한 객체 구성 패턴에 대한 해결책을 알아볼 것이다.

엄격한 모드에서는 new 연산자 없이 Person 생성자를 호출하면 에러가 발생한다. 엄격한 모드에서는 this가 전영 객체를 참조할 수 없기 때문이다. 앞에서 본 예시를 엄격한 모드에서 실행하면 this의 값은 undefined가 되므로 undefined에 프로퍼티를 생성할 수 없어 에러가 발생한다.

생성자를 사용하면 같은 프로퍼티를 가진 객체 인스턴스를 여러 개 만들 수 있지만 생성자만으로는 코드 중복까지 제거할 수 없다. 앞에서 살펴본 예레르 보면 sayName() 메소드의 내용이 완전히 똑같은데도 각 인스턴스는 각자 자신만의 sayName() 메소드를 가지고 있다. 다시 말해 객체 인스턴스가 100개 있다면 다루는 데이터만 다르고 동작은 완전히 똑같은 함수가 100개 만들어진다는 뜻이다.

이보다는 모든 인스턴스가 한 메소드를 공유하도록 만드는 것이 훨씬 더 효율적일 것이다. 메소드 안에서는 this.name을 사용해 적절한 데이터에 접근하도록 만들면 된다. 이를 위해 등장한 것이 프로토타입(prototype)이다.

프로토타입

프로토타입(prototype)은 객체를 위한 레시피 쯤으로 생각할 수 있다. 일부 내장 함수를 제외한 거의 모든 함수에는 새 인스턴스를 생성할 때 사용되는 prototype 프로퍼티가 있다. 해당 함수를 통해 생성된 모든 객체 인스턴스는 함수의 프로토타입을 같이 사용하며 프로토타입의 프로퍼티에도 접근할 수 있다. 예를 들어 hasOwnProperty() 메소드는 Object 프로토타입에 정의되어 있지만 어느 객체에서는 고유 객체처럼 접근할 수 있다.
다음 예제를 살펴보자.

let book = {
  title: "객체 지향 자바스크립트"
};

console.log("title" in book);  // true
console.log(book.hasOwnPropert("title"));  // true
console.log("hasOwnProperty" in book);  // true
console.log(book.hasOwnProperty("hasOwnProperty"));  // false 
console.log(Object.prototype.hasOwnProperty("hasOwnProperty"));  // true

book 변수에는 hasOwnProperty()가 정의되어 있지 않지만 Object.prototype에 정의 되어 있기 때문에 book.hasOwnProperty()를 통해 접근할 수 있다. 다시 한 번, in 연산자는 프로토타입 프로퍼티나 고유 프로퍼티 중 무엇에든 true를 반환한다는 사실을 기억하자.

프로토타입 프로퍼티 확인

어떤 프로퍼티가 프로토타입 프로퍼티인지 확인하는 함수는 다음과 같이 정의할 수 있다.

function hasPrototypeProperty(object, name) {
 return name in object && !object.hasOwnProperty(name); 
}

console.log(hasPrototypeProperty(book, "title"));  // false
console.log(hasPrototypeProperty(book, "hasOwnProperty"));  // true

먼저 in 연산자를 사용해 객체에 프로퍼티가 있는지 확인할 때 true를 반환하고 hasOwnProperty()를 사용할 때 false를 반환하면 이 프로퍼티는 프로토타입 프로퍼티로 볼 수 있다.

[[Prototype]] 프로퍼티

모든 인스턴스는 [[Prototype]]이라는 내부 프로퍼티를 통해 프로토타입의 변화를 추적한다. 이 프로퍼티는 인스턴스가 사용하고 있는 프로토타입 객체를 가리킨다. new 연산자를 사용해 새 객체를 생성할 때 생성자의 prototype 프로퍼티가 새로 생성된 객체의 [[Prototype]] 프로퍼티에 할당된다. 자바스크립트에서는 프로토타입을 사용해 코드 중복을 줄일 수 있다.

[[Prototype]] 프로퍼티의 값을 Object.getPrototypeOf() 메소드를 객체에 사용하면 읽을 수 있다. 다음은 빈 일반 객체의 [[Prototype]]을 확인하는 예제이다..

let object = {};
let prototype = Object.getPrototypeOf(object);

console.log(prototype === Object.prototype); // true

이와 같은 일반 객체의 [[Prototype]]은 언제나 Object.prototype을 참조한다.

자바스크립트 엔진에서는 모든 객체가 __proto __ 라는 프로퍼티를 가지고 있는데, 이 프로퍼티를 사용하면 [[Prototype]] 프로퍼티를 읽거나 쓸 수 있다.

또한 모든 객체에서 사용 가능한 isPrototypeOf() 메소드를 사용하면 어떤 객체가 다른 객체의 프로토타입인지 그렇지 않은지를 확인할 수 있다.

let object = {};

console.log(Object.prototype.isPrototypeOf(object));  // true

위 코드에서 object는 평범한 객체이므로 이 객체의 프로토타입은 Object.prototype이 된다. 따라서 isPrototypeOf()는 true를 반환한다.

객체 프로퍼티의 값을 가져올 때 자바스크립트 엔진은 먼저 해당 이름을 가진 고유 프로퍼티가 있는지 확인한다. 고유 프로퍼티가 있자면 프로퍼티의 값을 반환한다. 고유 프로퍼티를 찾지 못했다면 [[Prototype]] 객체에서 해당 프로퍼티를 검색하고, 해당 이름을 가진 프로토타입 프로퍼티가 있다면 그 프로퍼티의 값을 반환한다. 만약 프로토타입 프로퍼티에서도 이름을 찾을 수 없다면 undefined를 반환한다.

다음 예제를 살펴보자. 이 예제는 첫 번째 줄에서 고유 프로퍼티가 없는 객체를 생성한다.

let object = {};

console.log(object.toString());  // "[object Object]"

object.toString = function() {
  return "[object Custom]";
};

console.log(object.toString()); // "[object Custom]"

// 고유 프로퍼티 제거
delete object.toString;
console.log(object.toString());  // "[object Object]"

// 아무런 변화 없음 - delete는 고유 프로퍼티에만 사용할 수 있다.
delete object.toString;
console.log(object.toString); //  "[object Object]"

이 예제에서 toString() 메소드는 프로토타입에 속해 있고 "[object Object]"라는 기본 값을 반환한다. 그 다음 줄에서 toString()이라는 고유 프로퍼티를 정의하고 나면 그 다음부터는 toString()을 호출할 때마다 고유 프로퍼티가 사용된다. 고유 프로퍼티는 프로토타입 프로퍼티를 가리기 때문에 고유 프로퍼티와 이름이 같은 프로토타입 프로퍼티는 전혀 사용되지 않는다. 고유 프로퍼티를 제거하고나면 그때부터는 프로토타입 프로퍼티가 다시 사용된다. delete 연산자는 고유 프로퍼티에만 동작하기 때문에 프로토타입 프로퍼티는 제거할 수 없다는 사실을 잊지 말자.

인스턴스에서는 프로토타입 프로퍼티에 값을 할당할 수 없다. toString에 값을 할당하면 인스턴스에 새로운 고유 프로퍼티가 만들어지면서 프로토타입에 있는 프로퍼티는 연결되지 않는 상태가 된다.

고유 프로퍼티가 없는 객체는 프로토타입의 메소드만 가지고 있다. toString() 프로퍼티를 객체에 추가하면 다시 지우기 전까지는 새로 추가한 고유 프로퍼티가 프로토타입 프로퍼티를 대체한다.

생성자와 프로토타입 함께 사용하기

여러 객체에 공유된다는 프로토타입의 특성을 활용하면 타입이 같은 모든 객체가 같이 사용할 메소드를 한 번만 정의해도 된다. 메소드는 인스턴스에 상관없이 항상 같은 동작을 하므로 굳이 인스턴스마다 따로 메소드를 만들어야 할 이유가 없다.

따라서 메소드를 프로토타입에 두고 this를 사용해 현재 인스턴스에서 접근하는 것이 훨씬 효율적일 것이다. 예를 들어 다음과 같은 Person 생성자가 있다고 생각해보자.

function Person(name) {
 this.name = name; 
}

Person.prototype.sayName = function() {
 console.log(this.name); 
}; 

let person1 = new Person("Nicholas");
let person2 = new Person("Greg");

console.log(person1.name);  // "Nicholas"
console.log(person2.name);  // "Greg"

person1.sayName();  // "Nicholas" 출력
person2.sayName();  // "Greg" 출력

이 예제의 Person 생성자는 sayName()을 생성자가 아닌 프로토타입에 정의했다. 이제 sayName()은 고유 프로퍼티가 아닌 프로토타입 프로퍼티지만 객체 인스턴스는 앞서 보았던 예제와 똑같이 동작한다. sayName()을 호출할 때는 person1과 person2가 사용 객체가 되기 때문에 this의 값은 각각 person1과 person2가 된다.

프로토타입에는 어떤 종류의 데이터도 저장할 수 있지만 참조 값을 사용할 때는 주의가 필요하다. 프로토타입에 저장된 참조 값은 여러 인스턴스에 공유되기 때문에 다른 인스턴스에서 수정한 값에 의도하지 않은 영향을 받을 수도 있다.

다음은 참조 값을 주의하지 않고 사용했을 때 일어날 수 있는 일을 보여주는 예시이다.

function Person(name) {
 this.name = name; 
}

Person.prototype.sayName = function() {
 console.log(this.name); 
}; 

Person.prototype.favorites = [];

let person1 = new Person("Nicholas");
let person2 = new Person("Greg");

person1.favorites.push("pizza");
person2.favorites.push("quinoa");

console.log(person1.favorites);  // "pizza, quinoa"
console.log(person2.favorites);  // "pizza, quinoa"

favorites 프로퍼티는 프로토타입에 정의되어 있다. 다시 말해 person1.favorites와 person2.favorites는 둘 다 같은 배열을 참조하고 있다. 따라서 어느 인스턴스에서는 favorites 프로퍼티에 값을 추가하면 추가한 값은 프로토타입에 속한 배열의 원소가 된다. 원래 의도했던 동작은 아마도 달랐을 것이다. 따라서 프로토타입에 값을 정의할 때는 매우 주의해야 한다.

프로토타입에는 하나씩 값을 추가할 수도 있지만 다음과 같이 프로토타입을 객체 리터럴로 대체하는 조금 더 간결한 패턴을 사용하는 개발자도 많다.

function Person(name) {
 this.name = name; 
}

Person.prototype = {
  sayName: function() {
    console.log(this.name);
  },
  toString: function() {
    return "[Person " + this.name + "]"; 
  }
}; 

이 코드는 프로토타입에 sayName()과 toString() 이라는 메소드 두 개를 정의했다. 이 패턴은 Person.prototype을 여러 번 입력하지 않아도 돼서 상당히 널리 사용된다. 하지만 다음과 같은 부작용이 있으므로 주의해서 사용해야 한다.

let person = new Person("Nicholas");

console.log(person instanceof Person);  // true
console.log(person.constructor === Person);  // false
console.log(person.constructor === Object);  // true

객체 리터럴 표기법을 사용해 프로토타입을 덮어씌우는 방법은 constructor 프로퍼티도 바꿔버리기 때문에 예제에서는 constructor가 Person이 아닌 Object를 참조한다. 이 문제는 constructor 프로퍼티는 객체 인스턴스가 아닌 프로토타입에 정의되어 있기 때문에 발생한다. constructor 프로퍼티는 함수를 만들 때 함수의 prototype 프로퍼티에 정의되면서 만들어진 함수를 참조한다. 그런데 앞서 소개한 패턴을 사용하면 프로토타입 객체를 완전히 다시 작성하는 셈이기 때문에 Person.prototype을 대체하는 (일반) 객체의 constructor가 사용된다. 이런 문제를 피하려면 프로토타입을 덮어 씌울 대 constructor 프로퍼티의 값을 적절하게 설정해두면 된다.

function Person(name) {
 this.name = name; 
}

Person.prototype = {
  constructor: Person,
  
  sayName: function() {
    console.log(this.name);
  },
  toString: function() {
    return "[Person " + this.name + "]"; 
  }
}; 

let person1 = new Person("Nicholas");
let person2 = new Person("Greg");

console.log(person1 instanceof Person);  // true
console.log(person1.constructor === Person);  // true
console.log(person1.constructor === Object);  // false

console.log(person2 instanceof Person);  // true
console.log(person2.constructor === Person);  // true
console.log(person2.constructor === Object);  // false

이 예제는 프로토타입에 저장할 constructor 프로퍼티를 명시적으로 정해두고 있다. 예제처럼 constructor 프로퍼티를 프로토타입의 첫 번째 프로퍼티로 작성해두면 깜박하고 빠뜨리는 것을 예방할 수 있어 좋다.

생성자, 프로토타입, 인스턴스의 관계에서 아마도 가장 흥미로운 부분은 인스턴스와 생성자 사이에 직접적인 연결이 없다는 것이다. 하지만 인스턴스와 프로토타입, 프로토타입과 생성자는 서로 직접적으로 연결되어 있다.

생성자와 인스턴스는 프로토타입을 통해 연결되어 있다. 이러한 관계 때문에 인스턴스와 프로토타입 사이의 연결이 끊어지면 생성자와 인스턴스 간의 연결도 끊어지게 된다.

프로토타입 체이닝

특정 타입의 인스턴스는 모두 같은 프로퍼티를 공유하기 때문에 언제든 한꺼번에 기능을 추가할 수 있다. [[Prototype]] 프로퍼티는 프로토타입을 참조할 뿐이고 프로토타입의 변화는 프로토타입을 참조하고 있는 모든 인스턴스에 즉시 적용된다는 사실을 떠올려보자. 이는 프로토타입에 문자 그대로 언제든 새 프로퍼티나 메소드를 추가할 수 있으며 이러한 변화는 이미 만들어진 인스턴스에도 반영된다는 뜻이다. 다음 예제를 살펴보자.

function Person(name) {
 this.name = name; 
}

Person.prototype = {
  constructor: Person,
  
  sayName: function() {
    console.log(this.name);
  },
  toString: function() {
    return "[Person " + this.name + "]"; 
  }
}; 

let person1 = new Person("Nicholas");
let person2 = new Person("Greg");

console.log("sayHi" in person1); // false
console.log("sayHi" in person2); // false

// 새 메소드 추가
Person.prototype.sayHi = function() {
  console.log("Hi");
};

person1.sayHi();  // "Hi" 출력
person2.sayHi();  // "Hi" 출력

이 코드에서 처음 Person 타입에 있던 메소드는 sayName()과 toString() 두 개뿐이었다. Person 타입의 인스턴스 두 개를 만든 후에 sayHi() 메소드를 프로토타입에 추가했다. 이 시점에서 두 인스턴스는 모두 sayHi()에 접근할 수 있다. 프로퍼티나 메소드를 검색할 때마다 프로토타입에 접근하기 때문에 마치 원래 있던 메소드처럼 자연스럽게 사용할 수 있는 것이다.

언제든 프로토타입을 수정할 수 있다는 사실은 봉인된 객체와 동결된 객체에 재미있는 영향을 미친다. 객체에 Object.seal()이나 Object.freeze()를 사용하면 객체 인스턴스와 고유 프로퍼티를 있는 그대로만 사용할 수 있다. 동결된 객체에는 새로운 고유 프로퍼티를 추가할 수도 없고 기존 고유 프로퍼티의 값을 바꿀 수도 없지만, 동결된 객체의 프로토타입에는 값을 추가할 수 있다. 따라서 다음 예제에서 보는 것처럼 객체에 기능을 추가하는 것도 가능하다.

let person1 = new Person("Nicholas");
let person2 = new Person("Greg");

Object.freeze(person1);

Person.prototype.sayHi = function() {
  console.log("Hi");
};

person1.sayHi();  // "Hi" 출력
person2.sayHi();  // "Hi" 출력

이 예제에는 Person 타입의 인스턴스가 두 개 있다. 첫 번째 인스턴스 person1은 동결되어 있고 두 번째 인스턴스 person2는 평범한 객체이다. 프로토타입에 sayHi() 메소드를 추가하면 person1과 person2 객체 모두 새 메소드를 사용할 수 있게 되는데 겉보기에는 person1의 동결 상태와 모순되는 것처럼 보인다. [[Prototype]] 프로퍼티는 인스턴스의 고유 프로퍼티라 볼 수 있는데 프로퍼티 자체는 동결되어도 프로퍼티의 값(객체)는 동결되지 않는다.

자바스크립트 개발을 할 때 이 방식으로 프로토타입을 다룰 일은 거의 없을 것이다. 하지만 객체와 프로토타입 사이에 존재하는 관계는 반드시 이해해야 한다. 앞에서 보았던 이상한 예제는 이 개념을 이해하는데 도움이 될 것이다.

내장 객체의 프로토타입

프로토타입을 사용하면 자바스크립트 엔진에서 기본으로 제공해주는 내장 객체도 수정할 수 있다. 자바스크립트의 내장 객체는 모두 생성자를 가지고 있으므로 프로토타입을 사용해 변경할 수 있다. 예를 들어 Array.prototype을 간단하게 수정하면 모든 배열에서 사용할 새 메소드를 추가할 수 있다.

Array.prototype.sum = function() {
  return this.reduce(function(previous, current) {
   return previous + current; 
  });
};

let numbers = [1, 2, 3, 4, 5 ,6];
let result = numbers.sum();

console.log(result);  // 21

이 예제는 배열의 원소를 모두 더한 결과를 반환해주는 sum() 메소드를 Array.prototype에 추가한다. numbers 배열은 자동으로 프로토타입을 통해 sum() 메소드에 접근한다. sum() 메소드의 내부를 살펴보면 this를 통ㅎ래 Array의 인스턴스인 numbers를 참조하고 있으므로 reduce() 같은 다른 배열 메소드처럼 어느 배열에서든 편리하게 사용할 수 있다.

앞서 문자열, 숫자, 불리언도 원시 값을 객체처럼 사용할 때 임시로 생성하는 내장 원리 래퍼 타입이 있다고 했었다. 따라서 다음 예제처럼 원시 래퍼 타입의 프로토타입을 수정하면 원시 값에 새로운 기능을 추가하는 셈이 된다.

String.prototype.capitalize = function() {
  return this.charAt(0).toUpperCase() + this.substring(1);
};

let message = "hello world";
console.log(message.capitalize()); // "Hello world!"

이 코드는 문자열에 capitalize()라는 새 메소드를 추가한다. String 타입은 문자열을 위한 원시 래퍼티므로 String 타입의 프로토타입을 수정하면 자동으로 모든 문자열에 변경사항이 적용된다.

내장 객체를 수정해 기능을 추가하는 것은 분명 재미있고 흥미로운 기술이지만 실제 어플리케이션에는 적용하지 않는 편이 좋다. 개발자는 내장 객체가 정확하게 원래 있던 그대로 동작할 것이라 생각한다. 일부러 내장 객체를 수정하면 이러한 기대를 뒤엎는 셈이 되어 다른 개발자에게 혼란을 줄 수 있다.

요약

생성자는 평범한 함수지만 new 연산자와 함께 호출한다는 점이 다르다. 동일한 프로퍼티를 가진 객체를 여러 개 작성하고 싶을 때는 직접 생성자를 정의해서 사용하면 된다. 객체를 만들 때 사용한 생성자는 instanceof 연산자를 사용하거나 constructor 프로퍼티를 비교해서 확인할 수 있다.

모든 함수에는 prototype 프로퍼티가 있는데, 이 프로퍼티는 특정 생성자를 사용해 생성된 객체들이 공유할 프로퍼티를 정의한다. 일반적으로 프로토타입에는 공통으로 사용할 메소드와 원시 값 프로퍼티를 정의하고 그 밖의 프로퍼티는 생성자 안에서 정의한다. constructor 프로퍼티는 모든 객체 인스턴스가 공유하는 프로퍼티이므로 프로토타입에 정의되어 있다.

객체의 프로토타입은 내부적으로 [[Prototype]]이라는 프로퍼티에 저장된다. 이 프로퍼티는 복사 값이 아닌 참조 값이다. 자바스크립트의 프로퍼티 탐색 방식 때문에 프로토타입을 변경하면 변경된 사항이 모든 인스턴스에 적용된다. 객체의 프로퍼티에 접근하면 처름에는 해당 이름을 가진 고유 프로퍼티가 있는지 객체에서 탐색해보고 있으면 프로퍼티를 반환하지만 없으면 프로토타입에서 찾는다. 이 같은 탐색 방식 때문에 객체 인스턴스에서 참조하는 프로토타입이 변경되면 변경사항이 인스턴스에도 즉시 반영된다.

내장 객체도 프로토타입을 가지고 있으며 이를 수정해 기능을 추가할 수 있다. 실제 어플리케이션에는 사용하지 않는 것이 좋지만 새 기능을 검증할 때는 유용하게 사용할 수 있다.

profile
I'm on Wave, I'm on the Vibe.

0개의 댓글