프로토타입에 대하여
정의한 적 없는 메서드가 호출된다
1const arr = [3, 1, 2];
2
3arr.sort(); // [1, 2, 3]
4arr.map((n) => n * 2); // [6, 2, 4]
5저는 자바스크립트를 처음 배울 때 이 코드가 좀 이상했습니다. arr은 그냥 대괄호로 만든 배열일 뿐인데, sort나 map을 따로 정의한 기억이 없거든요. 그런데 멀쩡히 호출됩니다.
조금 더 노골적인 예를 보겠습니다.
1function Animal(name) {
2 this.name = name;
3}
4
5const dog = new Animal('멍멍이');
6
7console.log(dog.hasOwnProperty('name')); // true
8Animal 생성자 안에는 name만 정의했습니다. hasOwnProperty라는 메서드는 어디에도 쓴 적이 없죠. 그런데 dog는 이 메서드를 호출할 수 있습니다. 객체가 자기 안에 없는 메서드를 어떻게 찾아내는 걸까요?
이 동작을 이해하려면 프로토타입을 알아야 합니다. 이 글에서는 그 원리인 프로토타입과 프로토타입 체인을 코드로 따라가며, class 같은 문법 뒤에서 객체가 어떻게 동작하는지를 예측할 수 있게 만드는 것이 목표입니다.
프로토타입(Prototype)이란?
프로토타입이란, 자바스크립트 객체가 속성과 메서드를 다른 객체와 공유하기 위한 메커니즘입니다.
거의 모든 객체는 자신과 연결된 또 다른 객체를 하나 가리키고 있습니다. 이 "가리키는 링크"가 프로토타입입니다. (단, 체인의 맨 끝인 Object.prototype이나 Object.create(null)로 만든 객체는 이 링크가 null입니다.) 객체에게 어떤 속성을 달라고 했는데 그 객체에 없으면, 자바스크립트 엔진은 빈손으로 돌아오지 않고 이 링크를 따라가서 연결된 객체에게 다시 물어봅니다. 거기에 있으면 그 값을 돌려주고, 거기에도 없으면 그 객체의 프로토타입에게 또 물어봅니다.
그래서 dog가 hasOwnProperty를 호출할 수 있었던 겁니다. dog 자신은 그 메서드를 갖고 있지 않지만, dog의 프로토타입(정확히는 Animal.prototype)을 거쳐 그 위의 Object.prototype까지 올라가면 hasOwnProperty가 있거든요.
헷갈리는 세 가지 이름: [[Prototype]], __proto__, prototype
프로토타입을 공부하다 보면 비슷하게 생긴 이름 셋이 한꺼번에 나와서 혼란스럽습니다. 저도 처음엔 prototype과 __proto__를 같은 것으로 착각했습니다. 셋은 서로 다릅니다.
[[Prototype]]— 모든 객체가 내부적으로 가진 "숨은 링크" 그 자체입니다. 명세상의 표기이고, 코드에서 직접 이 이름으로 접근하지는 않습니다.__proto__— 그 숨은 링크에 접근하기 위한 접근자(getter/setter)입니다. 정확히는Object.prototype에 정의된 접근자라서,Object.prototype을 상속하지 않는 객체(예:Object.create(null))에서는 동작하지 않습니다. 다만 지금은 웹 호환성을 위해 남겨둔 레거시 기능으로 분류되고, 프로토타입 오염 같은 위험도 있어서, 새 코드에서는Object.getPrototypeOf/Object.setPrototypeOf를 쓰는 편이 안전합니다.prototype— 오직 함수만 가지는 속성입니다. 그 함수를new로 호출해 만든 인스턴스의[[Prototype]]이 가리키게 될 객체입니다.
말로만 보면 어렵습니다. 관계를 코드로 정리하면 이렇습니다.
1function Animal(name) {
2 this.name = name;
3}
4const dog = new Animal('멍멍이');
5
6// dog의 숨은 링크([[Prototype]])는 Animal.prototype을 가리킨다
7Object.getPrototypeOf(dog) === Animal.prototype; // true
8
9// __proto__는 같은 링크에 접근하는 다른 통로일 뿐이다
10dog.__proto__ === Animal.prototype; // true
11prototype은 함수가 미리 들고 있는 "이 생성자로 만든 인스턴스들이 공유할 객체"입니다.new로 인스턴스를 만들면, 그 인스턴스의[[Prototype]]이 생성자의prototype을 가리키도록 자동으로 연결됩니다.- 인스턴스 쪽에서 그 링크를 읽을 때 쓰는 게
__proto__(또는Object.getPrototypeOf)입니다.
여기서 new가 무슨 일을 하는지 잠깐 짚고 가겠습니다. new Animal('멍멍이')는 (1) 빈 객체를 하나 만들고, (2) 그 객체의 [[Prototype]]을 Animal.prototype에 연결한 뒤, (3) 그 객체를 this로 삼아 생성자 본문을 실행하고 돌려줍니다. 위에서 말한 "자동으로 연결됨"이 바로 이 (2) 단계입니다.
프로토타입 체인
링크가 하나로 끝나지 않는다는 점이 핵심입니다. 프로토타입이 가리키는 객체에도 또 프로토타입이 있고, 그 프로토타입에도 프로토타입이 있습니다. 속성을 찾을 때 엔진은 이 링크를 한 칸씩 거슬러 올라가며 같은 질문을 반복합니다. 그러다 null을 만나면 멈춥니다. 이렇게 이어진 경로를 프로토타입 체인이라고 부릅니다.
"인스턴스에서 시작해 Object.prototype을 거쳐 null로 끝나는 프로토타입 체인 다이어그램"
위 그림처럼, dog에 아직 없는 speak 같은 속성을 읽으면 엔진은 dog → Animal.prototype → Object.prototype 순서로 speak를 찾습니다. 어느 단계에서 찾으면 거기서 멈추고, 끝까지 못 찾으면 그 값은 undefined입니다. (그 상태로 dog.speak()처럼 호출하려 하면, 그 값이 함수가 아니므로 dog.speak is not a function 형태의 TypeError가 납니다.) 사전에서 단어를 못 찾으면 상위 분류로 넘어가 다시 찾듯, 엔진도 링크를 한 칸씩 거슬러 오릅니다. 변수를 못 찾으면 바깥 함수로, 또 그 바깥으로 올라가는 스코프 체인과 구조가 닮았는데, 그쪽은 스코프를 거슬러 오르고 이쪽은 객체 링크를 거슬러 오른다는 차이가 있을 뿐입니다.
체인을 직접 따라 올라가며 출력해보면 감이 잡힙니다.
각 prototype 객체에는 자신을 만든 생성자를 가리키는 constructor 속성이 들어 있어서, current.constructor.name으로 단계 이름을 알아낼 수 있습니다.
1function chainOf(obj) {
2 const names = [];
3 let current = Object.getPrototypeOf(obj); // 첫 프로토타입으로 이동
4 while (current !== null) {
5 const ctor = current.constructor; // 이 단계를 만든 생성자
6 names.push((ctor && ctor.name ? ctor.name : '(anonymous)') + '.prototype'); // 단계 이름 기록
7 current = Object.getPrototypeOf(current); // 한 칸 위로
8 }
9 names.push('null'); // 체인의 끝은 항상 null
10 return names.join(' -> ');
11}
12
13const dog = new Animal('멍멍이');
14console.log(chainOf(dog));
15// Animal.prototype -> Object.prototype -> null
16constructor는 평범한 속성이라 보정되지 않았거나 위조되면 실제 단계와 다르게 찍힐 수 있습니다. 그래서 이 함수는 체인을 눈으로 확인하기 위한 용도일 뿐, 단계를 정확히 식별하는 신뢰할 만한 방법은 아닙니다.
- 객체 리터럴
{}로 만든 객체는 곧장Object.prototype으로 이어집니다. - 생성자 함수나
class로 만든 인스턴스는 그 사이에생성자.prototype이 한 칸 더 끼어듭니다. - 대부분의 객체는 종착지가
Object.prototype이고, 그 위는null입니다. (예외로Object.create(null)로 만든 객체는 처음부터 프로토타입이null이라Object.prototype을 거치지 않습니다.)
자기 속성과 상속 속성 구분하기
체인을 타고 올라가다 보면, 어떤 속성이 객체 "자신의 것"인지 "상속받은 것"인지 헷갈릴 때가 있습니다. 이걸 가려주는 게 hasOwnProperty입니다.
Animal.prototype도 결국 평범한 객체라, 아래처럼 속성을 직접 추가할 수 있습니다.
1Animal.prototype.legs = 4; // 프로토타입에 공유 속성을 둔다
2const cat = new Animal('야옹이'); // name은 인스턴스 고유, legs는 상속
3
4cat.hasOwnProperty('name'); // true — 자기 속성
5cat.hasOwnProperty('legs'); // false — 프로토타입에서 상속
6'legs' in cat; // true — in 연산자는 상속 속성까지 포함해 검사
7hasOwnProperty는 체인을 보지 않고, 오직 객체 자신이 직접 가진 속성만true로 칩니다.- 반면
in연산자는 체인 전체를 훑기 때문에, 상속받은 속성에도true를 돌려줍니다. - 그래서 객체를 순회하다 상속 속성을 걸러내고 싶을 때
hasOwnProperty를 자주 씁니다.
메서드는 프로토타입에 두면 인스턴스끼리 공유된다
프로토타입의 실용적인 쓸모는 메모리 절약입니다. 메서드를 생성자 안에서 this에 직접 붙이면, 인스턴스를 만들 때마다 똑같은 함수가 매번 새로 생깁니다. 반면 프로토타입에 한 번만 정의해두면 모든 인스턴스가 같은 함수 하나를 공유합니다.
여기서 한 발 더 나아간 사실이 있습니다. 인스턴스는 함수를 복사해 가지는 게 아니라 프로토타입을 "참조"만 하기 때문에, 인스턴스를 다 만든 뒤에 프로토타입에 메서드를 추가해도 기존 인스턴스가 곧바로 그 메서드를 쓸 수 있습니다.
1const before = new Animal('이미-만든-객체');
2typeof before.speak; // 'undefined' — 아직 speak가 없다
3
4// 인스턴스를 만든 뒤에 프로토타입에 메서드를 추가한다
5Animal.prototype.speak = function () {
6 return `${this.name}: 멍!`;
7};
8
9before.speak(); // '이미-만든-객체: 멍!' — 이미 만든 객체에도 즉시 반영된다
10before는speak가 추가되기 전에 만들어졌지만, 추가 직후 바로 호출할 수 있습니다.- 인스턴스가 프로토타입을 실시간으로 참조하기 때문입니다.
- 편리한 만큼 위험하기도 합니다.
Object.prototype같은 공용 프로토타입을 함부로 건드리면 모든 객체에 영향을 줍니다. 이른바 프로토타입 오염이고, 남의 코드와 충돌하기 쉬워 실무에선 피해야 합니다.
class extends는 결국 프로토타입이다
ES6의 class와 extends를 보면 자바와 비슷한 클래스 상속처럼 느껴집니다. 하지만 자바스크립트에는 별도의 클래스 상속 메커니즘이 따로 있는 게 아닙니다. class extends가 만들어 내는 인스턴스의 프로토타입 체인은, Object.create로 손수 엮은 것과 같습니다.
class가 화면 뒤에서 무엇을 대신 해주는지 보려면, 같은 결과를 프로토타입으로 직접 엮어보는 것이 가장 빠릅니다. 같은 상속 구조를 두 방식으로 나란히 만들어 보겠습니다.
손으로 엮을 때 핵심은 Object.create입니다. Object.create(proto)는 [[Prototype]]이 proto인 빈 객체를 만들어줍니다. 그래서 ManualChild.prototype = Object.create(ManualBase.prototype) 한 줄로, 자식의 프로토타입이 부모의 프로토타입을 거슬러 올라가도록 체인이 엮입니다.
1// (A) class 문법
2class Base {
3 hello() {
4 return 'hello from Base';
5 }
6}
7class Child extends Base {
8 hi() {
9 return 'hi from Child';
10 }
11}
12const a = new Child();
13
14// (B) Object.create로 손수 엮은 같은 구조
15function ManualBase() {}
16ManualBase.prototype.hello = function () {
17 return 'hello from Base';
18};
19function ManualChild() {}
20// ManualChild.prototype의 프로토타입을 ManualBase.prototype으로 연결
21ManualChild.prototype = Object.create(ManualBase.prototype);
22ManualChild.prototype.constructor = ManualChild; // 이 줄이 없으면 b.constructor가 ManualBase를 가리킨다
23ManualChild.prototype.hi = function () {
24 return 'hi from Child';
25};
26const b = new ManualChild();
27
28a.hi() === b.hi(); // true
29a.hello() === b.hello(); // true
30- (A)와 (B)는 인스턴스의 프로토타입 체인이 같습니다.
class는 이 연결과constructor보정 같은 번거로운 작업을 알아서 처리해줍니다. - 다만
class를 단순히 '문법적 설탕'이라고만 부르기엔 더 챙겨주는 게 있습니다. 정적 메서드 상속(인스턴스뿐 아니라Child클래스 자신도Base를 프로토타입으로 가짐:Object.getPrototypeOf(Child) === Base),super호출,new없이 부르면 에러, 메서드를for-in같은 순회에 끼지 않게 하는 비열거(non-enumerable) 처리 같은 동작은 (B)처럼 손으로 엮으면 기본 동작으로는 재현되지 않아 일일이 손봐야 합니다. (super의 내부 바인딩처럼 사실상 동일 재현이 불가능한 부분도 있습니다.) - 그래도 그 밑바탕이 프로토타입 연결이라는 점을 알아두면,
extends가 동작하지 않거나 메서드가 엉뚱하게 상속될 때 원인을 짚기 수월합니다.
직접 실행해본 결과
위 내용을 한 파일(playground/prototype-chain/src/index.mjs)에 모아 실제로 돌려봤습니다. 별도 의존성 없이 node src/index.mjs 한 줄로 실행되며, 콘솔 출력은 다음과 같습니다.
"프로토타입 예제를 실행한 콘솔 출력 화면"
class로 만든 인스턴스와 Object.create로 손수 엮은 인스턴스의 결과가 동일하게 나오고, 모든 체인이 Object.prototype → null로 끝나는 것을 직접 확인할 수 있습니다.
정리
- 프로토타입은 객체가 속성과 메서드를 공유하도록 잇는 "숨은 링크"입니다.
- 객체에 없는 속성은 이 링크를 따라 거슬러 올라가며 찾고,
null에서 멈춥니다. 이 경로가 프로토타입 체인입니다. hasOwnProperty는 자기 속성만,in연산자는 상속 속성까지 검사합니다.- 메서드를 프로토타입에 두면 인스턴스끼리 공유되어 메모리를 아낄 수 있지만, 공용 프로토타입 오염은 조심해야 합니다.
class extends와Object.create는 같은 프로토타입 연결을 만드는 두 가지 표기일 뿐입니다.
마치며
이번 포스트에선 프로토타입과 프로토타입 체인을 살펴봤습니다. 평소에는 class라는 깔끔한 문법 뒤에 가려져 잘 드러나지 않지만, 그 밑에는 객체끼리 이어진 링크가 있고 엔진은 그 링크를 따라 속성을 찾아 올라갑니다. 이 그림을 한 번 그려두면, 자바스크립트 객체의 동작이 한결 예측 가능해집니다. 긴 글 읽어주셔서 감사합니다.