본문으로 바로가기

프로토타입 체인(Prototype Chain)

자바스크립트는 Function() 인스턴스에 자동으로 prototype 이라는 속성을 만듭니다.

구체적으로 말하자면 prototype 속성은 new 키워드와 생성자 함수를 같이 사용해서 만든 객체에 연결됩니다.

인스턴스들은 생성자 함수의 prototype 속성을 통해 공통의 메소드와 속성을 공유하고 상속합니다.

중요한 사실은 이러한 공유가 속성을 검색하는 동안 일어난다는 것입니다.

즉, 우리가 어떤 객체의 속성을 찾거나 접근하면 자바스크립트는 그때마다 해당 객체는 물론 프로토타입 체인에서도 그 속성을 찾습니다.




함수는 모두 prototype 객체를 포함하고 있다. 

심지어 생성자 함수로 사용할 생각이 전혀없는 함수도 prototype 객체를 자동으로 포함한다.


다음은 Array() 생성자를 사용하여 배열을 하나 만든 후 배열의 join() 메소드를 호출하는 코드입니다.

javascript
var myArray = new Array('foo', 'bar');

console.log(myArray.join()); // 'foo, bar' 가 기록된다.


join() 메소드는 myArray 객체 인스턴스에 정의되어 있지 않은 속성입니다. 

하지만 사용자는 join() 메소드가 마치 원래 있던 것처럼 사용할 수 있었습니다.

분명한 것은 이 메소드는 어디엔가 정의되어 있을 것입니다.

하지만 어디에 정의되어 있을까?!

이 메소드는 Array() 생성자의 prototye 속성에 속성으로서 정의되어 있습니다.

배열 객체 인스턴스에서는 join() 메소드가 없기 때문에 자바스크립트는 프로토타입 체인에서 join() 이라는 메소드가 있는지 검색합니다.


왜 이런 식으로 동작하도록 만들었을까?

사실 이는 효율성과 재사용성에 대한 문제입니다.

배열 생성자 함수가 만드는 배열 인스턴스마다 언제나 똑같은 방식으로 동작하는 join() 메소드를 굳이 일일이 추가할 필요가 있을까?

모든 배열 인스턴스마다 함수를 새로 만드는 것보다는 하나의 join() 함수를 모든 배열에서 가져다 쓰는 편이 더 합리적일 것입니다.


자바스크립트에서는 prototype 속성, 프로토타입 결합(prototype linkage), 프로토타입 체인 검색을 통해 이런 효율성을 만들었습니다.

하지만 솔직히 말해 프로토타입 체인 상속이 실제로 어떻게 동작하는지 원리를 그냥 외우는 편이 더 나을지도 모릅니다.




prototype 속성이 왜 중요한가?!

prototype 속성은 네 가지 측면에서 중요합니다.

  1. 네이티브 생성자 함수(예: Object(), Array(), Function() 등)는 prototype 속성을 사용해 생성자 인스턴스가 메소드와 속성을 상속받도록 하고 있기 때문이다. 자바스크립트는 이러한 매커니즘을 사용해 객체 인스턴스가 생성자 함수의 prototype 속성을 상속받을 수 있도록 지원해 준다. 따라서 자바스크립트를 더 잘 이해하려면 자바스크립트가 prototype 객체를 어떻게 사용하는지 잘 알아야 한다.
  2. 사용자 정의 생성자 함수를 만들 때 자바스크립트 네이티브 객체와 동일한 방식을 통해 프로토타입 상속을 구현할 수 있다. 그러나 상속이 이루어지는 방식에 대한 이해가 반드시 수반되어야 한다.
  3. 프로토타입 상속을 선호하지 않거나 다른 상속 패턴을 더 좋아할 수도 있지만 현실적으로 볼 때 아마도 누군가 프로토타입 상속을 사용해 구현해놓은 코드를 수정하거나 조작해야 할 일이 있을 것이다. 이때 프로토타입 상속이 어떻게 동작하는지 알아야 다른 개발자가 만든 생성자 함수의 기능을 그대로 복제할 수 있을 것이다.
  4. 프로토타입 상속을 사용하면 동일한 메소드를 공유하는 여러 개의 효율적인 객체 인스턴스를 만들 수 있다. 앞서 언급했던 것과 같이 Array() 생성자의 인스턴스인 배열 객체는 굳이 인스턴스마다 join() 메소드를 추가해주지 않아도 된다. 모든 배열 인스턴스의 프로토타입 체인에는 join() 메소드가 저장되어 있으므로 모든 배열 인스턴스는 동일한 join() 메소드를 사용할 수 있다.




모든 Funtion() 인스턴스에는 prototype 속성을 가지고 있다.

자바스크립트에서 함수는 Function() 생성자를 직접 호출하여 만들었든(예: var add = new Function('x', 'y', 'return x + y'); ) 함수 리터럴 표기법을 사용해 만들었든(예: var add = function(x, y) { return x + y }; ) 예외없이 모두 Function() 생성자로부터 만들어집니다.

함수 인스턴스를 만들면 그 인스턴스에는 항상 prototype 속성이 추가됩니다.

이때, prototype 속성은 그 자체로는 빈 객체와 같습니다.

다음 코드에서 myFn 이라는 함수를 정의한 후, 이 함수의 prototype 속성에 접근해 봅니다.

javascript
var myFn = function() {};
console.log(myFn.prototype); // Object() 가 기록된다.
console.log(typeof myFn.prototype); // 'object' 가 기록된다.


비록 위 코드에서는 생성자로 사용한 사용자 정의 함수가 하나밖에 없지만, Function() 생성자는 모든 함수 인스턴스에 prototype 속성을 부여합니다.



prototype 속성은 Object() 객체다

프로토타입에 대한 내용은 조금 어렵습니다.

엄밀히 말해, prototype은 Function() 인스턴스를 만들 때 자바스크립트가 인스턴스에 부여하는 이름이 "prototype" 이고 기본값이 빈 객체인 속성일 뿐입니다.

만약 이를 사용자가 직접 코드로 만든다면 다음과 같이 작성할 수 있을 것입니다.

javascript
var myFn = function() {};

// prototype 속성을 추가하고 속성값으로 빈 객체를 할당한다.
myFn.prototype = {};
console.log(myFn.prototype); // 빈 객체가 기록된다.

사실 자바스크립트가 이미 내부적으로 수행했던 일을 다시 코드로 작성한 것이고, 코드로 직접 작성한 위의 예제도 실행해보면 문제없이 동작합니다.


사실 prototype 속성의 값으로는 어떤 복합 객체(E.g : 배열)든 사용할 수 있다.

prototype의 속성값으로 원시값을 설정하려 하면 자바스크립트에서는 해당 코드를 무시한다.



생성자 함수를 통해 만든 인스턴스는 생성자 함수의 prototype 속성과 연결되어 있다

생성자 함수의 prototype 속성은 그 자체로만 놓고보면 객체일 뿐이지만 프로토타입 체인을 통해 인스턴스와 연결되는 특이한 속성입니다.

다시 말해, new 키워드와 생성자 함수를 사용해 객체를 만들면 생성자 함수의 prototype 속성과 새롭게 만들어진 객체 인스턴스 사이에는 일종의 숨겨진 연결고리가 생깁니다.

일부 브라우저에서는 이 연결고리가 인스턴스의 __proto__ 속성으로 나타나기도 합니다.

현재는 Firefox2+, Safari, Chrome, Android 브라우저에서만 지원되고 있는 속성입니다.

자바스크립트는 생성자 함수를 사용해 인스턴스를 만들 때 인스턴스 객체와 생성자 함수를 자동으로 연결해 두며, 이러한 연결 덕분에 프로토타입 체인이 형성되는 것입니다.


다음은 Array() 생성자의 prototype에 속성을 하나 추가한 후 배열 인스턴스의 __proto__ 속성을 사용해 추가한 속성에 접근하는 코드입니다.

javascript
// 이 코드는 __proto__ 를 사용할 수 있는 브라우저에서만 동작한다.
// 파이어폭스 2이상, 사파리, 크롬, 안드로이드에서만 동작한다.
Array.prototype.foo = 'foo';
var myArray = new Array();

console.log(myArray.__proto__.foo); // foo 가 기록된다.
// myArray.__proto == Array.prototype 이다

사실 __proto__ 속성은 ECMA 공식 표준이 아니기 때문에 다른 방법을 사용해야 합니다.

객체 인스턴스에서 constructor 속성을 사용하면 생성자를 구할 수 있고 이를 통해 인스턴스가 상속받은 프로토타입 객체도 구할 수 있습니다.


다음의 코드를 살펴봅니다.

javascript
Array.prototype.foo = 'foo'; // 이제 Array() 의 인스턴스는 모두 foo 속성을 상속받는다.

var myArray = new Array();

// .constructor.prototype을 사용하면 조금 번잡하지만 foo 속성을 구할 수 있다.
console.log(myArray.constructor.prototype.foo); // foo 가 기록된다.

// 물론 체인을 사용해도 된다.
console.log(myArray.foo); // foo 가 기록된다.
// 프로토타입 체이닝을 통해 검색된다. Array.prototype.foo 가 검색된다.

이 코드에서 foo 속성은 prototype 객체에서 찾을 수 있었습니다. 

이는 Array() 인스턴스와 Array() 생성자의 프로토타입 객체(=Array.prototype) 간에 연결고리가 있었기 때문에 가능한 것입니다. 

간단히 말해서 myArray.__proto__(또는 myArray.constructor.prototype)는 Array.prototype 을 참조합니다.




프로토타입 체인의 끝은 Object.prototype 이다

prototype 속성은 객체이기 때문에 프로토타입 체인 또는 프로토타입 검색의 종점은 Object.prototype 입니다.


다음 코드를 살펴봅니다.

javascript
var myArray = [];
console.log(myArray.foo); // undefined 가 기록된다.

/**
 * myArray.foo, Array.prototype.foo, Object.prototype.foo에서 foo를 찾지 못하여
 * foo 속성은 undefined 가 된다.
 *-----------------------------------------------------------------------------
 */

이 코드에서는 myArray 라는 빈 배열을 만든 후 프로토타입 체인을 검색하도록 myArray에 정의되지 않은 속성에 접근해 보았습니다.

자바스크립트는 먼저 myArray 객체에서 foo 속성을 찾아보지만, 찾지 못하기 때문에 Array.prototype 에서 다시 검색을 시도할 것입니다.

하지만 여기서도 찾을 수 없으므로 마지막 종착지점인 Object.prototype에서 foo 속성을 찾아보는 것입니다. 

검색의 끝 지점인 여기에서도 찾을 수 없으므로 결국 foo 속성은 undefined가 되는 것입니다.

정리해 보면, 프로토타입 체인은 Object.prototype 에서 끝납니다. foo 속성이 있는지 가장 마지막에 찾아본 곳은 Object.prototype이었습니다.


Object.prototype에 속성을 추가하면 추가한 속성이 for in 반복문에 나타난다.




프로토타입 체인은 체인에서 제일 먼저 찾은 속성을 반환한다

스코프 체인과 마찬가지로 프로토타입 체인은 체인을 검색하다가 가장 먼저 발견한 값을 사용합니다.

바로 앞에서 사용했던 코드를 조금 수정하여 Object.prototypeArray.prototype 객체에 똑같은 속성을 추가한 후 배열 인스턴스에서 그 값에 접근해 보면 Array.prototype 객체의 값이 반환될 것입니다.

javascript
Object.prototype.foo = 'object-foo';
Array.prototype.foo = 'arry-foo';

var myArray = [];
console.log(myArray.foo); // Array.prototype.foo 에서 찾은 'array-foo' 가 기록된다.

myArray.foo = 'bar';
console.log(myArray.foo); // myArray.foo 에서 찾은 'bar' 가 기록된다.

이 코드에서 Array.prototype.foo의 foo 값은 Object.prototype.foo의 foo 값을 가려버린 결과를 가져온 것입니다. 

이렇게 프로토타입에서 속성을 검색할 때는 가장 먼저 발견한 속성을 사용하고 검색을 바로 종료합니다. 그리고 체인에 똑같은 이름의 속성이 몇 개나 더 있었는지는 상관없습니다.




prototype 속성을 새 객체로 대체하면 기본 constructor 속성이 삭제된다

prototype 속성의 기본값은 다른 값으로 대체할 수 있습니다. 

하지만 prototype 속성을 바꾸면 원래의 prototype 객체에서 볼 수 있었던 기본 constructor 속성이 사라지게 됩니다.


다음은 Foo 생성자 함수를 만들고 Foo의 prototype 속성을 빈 객체로 대체한 후 인스턴스의 constructor 속성이 사라졌는지 확인해 보는 코드입니다.

javascript
var Foo = function() {};
Foo.prototype = {}; // 빈 객체로 prototype 속성을 대체한다.

var FooInstance = new Foo();

console.log(FooInstance.constructor === Foo); // false 가 기록된다. 즉 참조가 사라진 것이다.
console.log(FooInstance.constructor); // Foo() 가 아닌 Object()가 기록된다.

위 코드는 이제 constructor 속성은 덜 유용한 Object() 생성자를 참조하게 됩니다.


아래 코드는 prototype 값을 대체하지 않은 경우입니다.

javascript
var Bar = function() {};

var BarInstance = new Bar();

console.log(BarInstance.constructor === Bar); // true 가 기록된다.
console.log(BarInstance.constructor); // Bar() 가 기록된다.

만약 자바스크립트가 설정한 기본 prototype 속성을 대체할 생각(자바스크립트 객체지향 패턴에서 종종 사용되는 방식)이라면, 생성자 함수를 참조하는 constructor 속성을 원래대로 복원해주어야 할 것입니다.


다음은 앞의 코드를 조금 수정하여 constructor 속성이 원래의 생성자 함수를 올바르게 참조하도록 한 코드입니다.

javascript
var Foo = function Foo(){};

Foo.prototype = {constructor:Foo};

var FooInstance = new Foo();

console.log(FooInstance.constructor === Foo); // true가 기록된다.
console.log(FooInstance.constructor); // Foo()가 기록된다.




프로토타입에서 상속한 속성은 가장 최근의 값을 사용한다

인스턴스가 프로토타입에서 상속한 속성은 그 속성이 어떻게 만들어지고, 변경되고, 추가되었든 상관없이 항상 최근의 값을 사용합니다.

다음 코드는 Foo 생성자를 만들고 x라는 속성을 prototype에 추가한 후 Foo() 생성자의 인스턴스를 FooInstance라는 이름으로 만듭니다.

여기서 한번 x의 값을 기록한 후 생성자의 prototype 속성에 포함된 x의 값을 수정한 후 다시 한번 x의 값을 기록해 봅니다.

이를 통해 우리는 인스턴스가 prototype 객체에서 가져오는 값은 가장 마지막 값이라는 사실을 알 수 있을 것입니다.

javascript
var Foo = function Foo(){};

Foo.prototype.x = 1;

var FooInstance = new Foo();
console.log(FooInstance.x); // 1이 기록된다.

Foo.prototype.x = 2;
console.log(FooInstance.x); // 2가 기록된다. FooInstance 도 갱신되었다.

프로토타입 체인이 어떻게 동작하는지 이해했다면 이 같은 동작이 그리 놀랍진 않을 것입니다.

이런 현상은 기본 prototype 객체를 사용했든 또는 prototyp 객체를 새로운 객체로 대체했든 상관없이 똑같이 볼 수 있습니다.


다음 코드에서는 기본 prototype 객체 대신 새로운 객체를 사용해 봅니다.

javascript
var Foo = function Foo(){};
Foo.prototype = {x:1}; // 아래의 코드는 이전과 똑같이 동작한다.

var FooInstance = new Foo();
console.log(FooInstance.x); // 1이 기록된다.

Foo.prototype.x = 2;
console.log(FooInstance.x); // 2가 기록된다. FooInstance 도 갱신되었다.




prototype 속성을 새 객체로 대체하면 이전에 만든 인스턴스는 갱신되지 않는다

지금까지 알아본 바에 의하면 아마도 prototype 속성은 언제든 완전히 대체할 수 있고 그렇게 하면 모든 인스턴스가 갱신될 것이라 생각할 수 있습니다.

하지만 안타깝게도 이는 틀린 말입니다.

인스턴스를 만들면 인스턴스를 만들 때의 prototype과 인스턴스가 서로 묶여버리기 때문에 prototype 속성에 새 객체를 정의하면 이미 만들어진 인스턴스와 새로운 prototype 간의 연결고리가 끊어져 버리게 됩니다.


하지만 앞서 설명한 방법을 사용하면 인스턴스를 만들었던 시점의 prototype 객체를 찾아서 값을 수정하거나 추가할 수 있으며 이를 통해 이 prototype과 연결된 인스턴스를 갱신할 수 있습니다.

javascript
var Foo = function Foo(){};

Foo.prototype.x = 1;

var FooInstance = new Foo();

console.log(FooInstance.x); // 잠작한 대로 1이 기록된다.

// prototype 객체를 새로 만든 Object() object로 대체/재정의 해보자.
Foo.prototype = {x:2};

console.log(FooInstance.x); /* 1이 기록된다. 엥? 우리가 방금 prototype을 갱신했으니 2가 되어야하지 않나? */
/* FooInstance는 여전히 인스턴스로 만들어지던 시점의 prototype을 참조하고 있다. */

// Foo()의 인스턴스를 새로 만든다.
var NewFooInstance = new Foo();

// 새로 만든 인스턴스는 새로운 prototype 객체(={x:2};)와 묶여있다.
console.log(NewFooInstance.x); // 2가 기록된다.

여기서 알 수 있는 사실은 인스턴스를 만든 뒤에는 객체의 prototype 속성을 새 객체로 대체하면 안된다는 것입니다.

만약 새 객체로 대체해버리면 같은 생성자에서 만든 인스턴스라 해도 서로 다른 prototype을 참조하게 될 것입니다.




사용자 정의 생성자도 네이티브 생성자처럼 프로토타입을 상속할 수 있다

지금까지 자바스크립트에서 prototype 속성을 사용해 상속하는 법(E.g : Array.prototype)을 살펴보았습니다.

그리고 이와 같은 패턴을 네이티브가 아닌 사용자가 직접 만든 생성자에도 적용할 수 있습니다.


다음은 Person 객체를 가지고 자바스크립트의 상속 패턴을 흉내 내는 코드입니다.

javascript
var Person = function() {};

// 모든 Person 인스턴스는 legs, arms, countLimbs 속성을 상속한다.
Person.prototype.legs = 2;
Person.prototype.arms = 2;
Person.prototype.countLimbs = function() {return this.legs + this.arms;};

var chuck = new Person();

console.log(chuck.countLimbs()); // 4가 기록된다.

위 코드에서 Person() 생성자 함수를 만든 후 Person()의 prototype 속성에 몇 개의 속성을 추가하여 모든 인스턴스가 상속받도록 하였습니다.

이 코드는 자바스크립트 네이티브 객체를 상속할 때 사용했던 것과 똑같은 방법으로 프로토타입 체인을 사용한 것입니다.


전달된 매개변수가 없을 때 프로토타입에서 속성을 상속받는 생성자 함수를 만들어보면 이를 조금 더 잘 이해할 수 있을 것입니다.

다음 코드에서 Person() 생성자는 전달된 매개변수가 있으면 이를 사용해 인스턴스 속성을 추가하지만 전달된 값이 아예 없거나 한 개만 있으면 프로토타입에서 상속받은 값을 사용하도록 합니다. 인스턴스 속성이 있으면 상속된 속성이 사용되지 않는 것입니다.

따라서 어느 경우에든 속성을 문제없이 사용할 수 있을 것입니다.

javascript
var Person = function(legs, arms) {
    // 프로토타입에서 상속받은 값을 가린다.
    if (legs !== undefined) {this.legs = legs;}
    if (arms !== undefined) {this.arms = arms;}
};

Person.prototype.legs = 2;
Person.prototype.arms = 2;
Person.prototype.countLimbs = function() {return this.legs + this.arms;};

var chuck = new Person(0, 0);

console.log(chuck.countLimbs()); // 0이 기록된다.




상속 체인 만들기

프로토타입 상속은 전통적인 객체 지향 프로그래밍 언어에서 볼 수 있던 상속 패턴을 흉내 내기 위해 만들어졌습니다.

자바스크립트에서 인스턴스란 간단히 말해 다른 객체의 속성에 접근할 수 있는 객체입니다.

이를 위해 먼저 상속받고자 하는 부모 객체의 인스턴스를 만든 후 생성자의 prototype에 할당하면 부모 객체를 상속받을 수 있습니다.

prototype 속성에 부모 객체의 인스턴스를 할당하고 나면 부모 객체 생성자의 prototype과 상속받은 객체 사이에는 연결고리(즉, __proto__)가 생깁니다.


다음 코드에서 Chef 객체(=cody)는 Person()을 상속받습니다.

다시 말해, 어떤 속성을 Chef 객체에서 발견하지 못하면 그 다음에는 Person() 생성자 함수의 prototype에서 속성을 찾는다는 뜻입니다.

이렇게 상속 관계를 만들기 위해 할 일은 Chef.prototype의 값으로 Person() 객체의 인스턴스를 할당해 주는 것이 전부입니다.

즉, Chef.prototype = new Person()

javascript
var Person = function() { this.bar = 'bar' };
Person.prototype.foo = 'foo';

var Chef = function() { this.goo = 'goo' };
Chef.prototype = new Person();
var cody = new Chef();

console.log(cody.foo); // 'foo'가 기록된다.
console.log(cody.goo); // 'goo'가 기록된다.
console.log(cody.bar); // 'bar'가 기록된다.

위 코드에서 한 일은 네이티브 객체에서 원래 사용하고 있던 시스템을 빌려온 것 뿐입니다.

prototype 속성에 있어서 원래 사용하던 Object() 값이나 Person() 값은 다른 점이 없습니다.

다시 말해, 객체의 상속된 속성에 접근하면 객체를 만든 생성자 함수의 prototype(=Object.prototype) 속성에서 그 값을 찾을 것이라는 의미입니다.




Jaehee's WebClub



댓글을 달아 주세요

  1. 자바스크립트 2020.09.18 23:32

    우와 중간까지 이해했다가 죽 내려버렸어요 너무 어려워요 ㅠㅠ 흑흑