최근 『모던 자바스크립트 Deep Dive』의 프로토타입 예제를 하나씩 실행하며 서브 블로그를 정리했다. 생성자 함수의 prototype도 알고, 객체가 [[Prototype]]이라는 내부 슬롯을 가진다는 것도 알고 있었다. 그런데 아래 문장을 내 말로 설명하려니 자꾸 멈칫했다.
JavaScript는 프로토타입 기반 언어다.
함수를 주로 사용하는 React는 별개지만, 백엔드를 개발할때 나는 평소에 class를 쓴다. 그렇다면 프로토타입은 생성자 함수를 쓰던 시절의 흔적일까? 혹은 JavaScript의 class는 가짜라는 뜻일까?
이 글의 답을 먼저 요약해두면 이렇다. 프로토타입 체인은 객체가 원하는 프로퍼티를 직접 가지고 있지 않을 때 [[Prototype]]이 가리키는 다른 객체로 탐색을 넘기는 구조다. 메서드는 각 인스턴스에 복사되지 않고 이 연결을 통해 공유되며, JavaScript의 class 상속과 DOM API도 같은 체인 위에서 동작한다. 이 객체 모델은 Self의 영향을 받았고, 당시 Netscape의 제품 요구와 언어 설계 환경 속에서 JavaScript에 들어왔다. 아래에서 이 문장을 그림으로 하나씩 확인한다.
처음에는 구구절절 설명을 정리해보았지만, 결국 프로토타입은 코드보다 객체와 객체 사이의 연결을 먼저 봐야 하는 개념이라고 느꼈다.
설계도에서 시작할까, 객체에서 시작할까
이 글을 구상할 때 많이 참고한 임성묵님의 자바스크립트는 왜 프로토타입을 선택했을까를 읽으며 한 가지 질문을 붙잡게 됐다.
객체를 만들기 전에 범주를 먼저 정의해야 할까?
클래스 기반 객체지향을 설명할 때는 보통 설계도 비유를 사용한다. 먼저 클래스에 상태와 동작을 정의하고, 그 설계도로 인스턴스를 만든다. 개별 객체보다 범주가 먼저다.
반대로 프로토타입 기반 모델은 출발점이 다르다. 먼저 구체적인 객체가 있고, 다른 객체가 그 객체를 원형으로 연결한다. 필요한 차이는 새 객체가 직접 가지고, 공통 행동은 연결을 따라가며 사용한다.
왼쪽에서는 Class가 A와 B보다 먼저 존재한다. 오른쪽에서는 A, B, C가 모두 구체적인 객체이고 선(엄밀히 체인)으로 관계를 맺는다. 모든 객체지향 언어의 사용법을 둘로 나눈 그림은 아니다. 클래스/인스턴스 모델과 프로토타입/위임 모델의 전형적인 출발점을 단순화한 비교다.
설계도는 안정적인 범주에 잘 어울린다
필요한 속성과 행동이 비교적 명확하다면 설계도를 먼저 만드는 방식이 자연스럽다. Toast라는 범주를 정하고 같은 구조를 가진 토스트 객체를 여러 개 만들어보자.
반면 웹의 DOM은 실행 중에도 계속 변한다. 사용자의 클릭에 따라 노드가 생기고, 사라지고, 속성이 바뀐다. 처음 만든 설계도만 바라보기보다 지금 존재하는 객체를 직접 바꾸고 연결하는 일이 많다.
JavaScript가 프로토타입을 선택한 이유를 DOM 하나로 설명할 수는 없다. 실제 역사에는 Netscape의 제품 요구와 Self의 영향이 함께 있었다. 여기서 DOM은 역사적 인과를 증명하려는 사례가 아니라, 이미 존재하는 객체가 실행 중에 바뀐다는 감각을 잡기 위해 내가 사용한 학습용 비유다.
프로토타입이 필요한 가장 기본적인 이유
프로토타입이 없다면 생성자 안에서 메서드를 만들 때 인스턴스마다 같은 함수가 생긴다. 반지름은 서로 달라야 하지만 넓이를 구하는 방법까지 매번 새로 가질 필요는 없다.
function Circle(radius) {
this.radius = radius;
}
Circle.prototype.getArea = function () {
return Math.PI * this.radius ** 2;
};
const small = new Circle(1);
const large = new Circle(2);
small.getArea === large.getArea; // true공유할 메서드를 Circle.prototype에 두면 두 인스턴스는 하나의 함수를 함께 사용한다.
small과 large 안으로 getArea가 복사된 것이 아니다. 자기에게 없는 프로퍼티를 같은 원형 객체에서 찾는다.
상태는 각 객체가 가지고, 같은 행동은 연결된 객체에서 공유한다.
.prototype과 [[Prototype]]은 다르다
프로토타입을 공부할 때 가장 헷갈렸던 건 비슷한 이름이 서로 다른 자리에 등장한다는 점이었다.
Toast.prototype은 함수 객체Toast가 가진 프로퍼티다.saved.[[Prototype]]은 프로퍼티 탐색을 넘길 다음 객체를 가리키는 내부 슬롯이다.
내부 슬롯에는 코드로 직접 접근하지 않는다. Object.getPrototypeOf(saved)로 확인할 수 있다.
function Toast(message) {
this.message = message;
}
const saved = new Toast('저장되었습니다');
Toast.prototype === Object.getPrototypeOf(saved); // true교재의 다이어그램에서 생성자 함수, 프로토타입, 객체가 삼각형으로 연결된 이유도 이것이었다. 세 요소가 합쳐진 한 덩어리가 아니라 각자 존재하는 객체가 링크로 연결된 구조다.
프로토타입 체인의 동작 원리 — 프로퍼티는 어디서 찾을까
이 연결의 성질은 런타임에 원형을 바꿨을 때 선명하게 드러난다.
class Toast {
constructor(message) {
this.message = message;
}
}
const saved = new Toast('저장되었습니다');
Toast.prototype.dismiss = function () {
return `[닫힘] ${this.message}`;
};
saved.dismiss(); // '[닫힘] 저장되었습니다'
saved를 먼저 만든 뒤 Toast.prototype에 dismiss를 추가했다.
이미 만들어진 객체가 미래의 메서드를 받았다고 표현하면 재미있지만, 정확히는 saved가 갱신된 것이 아니다. saved가 계속 바라보던 객체에 메서드가 추가됐고, 다음 조회가 바뀐 원형을 다시 읽었을 뿐이다.
![saved에서 시작해 [[Prototype]] 링크를 따라 dismiss를 찾고 this가 saved로 유지되는 프로퍼티 탐색 과정 애니메이션](/_next/image?url=https%3A%2F%2Fd2d6xftfoai8ia.cloudfront.net%2Fpost16_prototype_lookup_flow_a26e7e30e2.gif&w=2048&q=75)
탐색은 saved에서 시작한다. dismiss가 없으면 [[Prototype]] 링크를 따라 Toast.prototype으로 이동한다. 그곳에서 함수를 발견한 뒤 호출한다.
실제 엔진이 GIF처럼 천천히 움직이는 것은 아니다. 이 장면은 성능 측정이 아니라 탐색 순서를 펼쳐 보여주는 그림이다.
프로토타입 상속은 복사가 아니라 위임이다
프로토타입 체인의 동작을 흔히 상속이라고 부른다. 틀린 표현은 아니지만 처음에는 위임(delegation)이라고 생각하는 편이 더 잘 그려지는 것 같다. 객체가 부모의 프로퍼티를 복사해 갖는 게 아니라, 자기에게 없을 때 연결된 객체로 탐색을 넘기기 때문이다.
프로퍼티 섀도잉 — 가까운 프로퍼티가 뒤의 연결을 가린다
만약 saved가 같은 이름의 dismiss를 직접 가지면 탐색은 첫 객체에서 끝난다.
원형의 메서드가 삭제된 것은 아니다. 더 가까운 곳에서 같은 이름을 발견했기 때문에 뒤까지 가지 않을 뿐이다. 이것이 프로퍼티 shadowing이다.
사실 이 개념은 상속 문제보다 평범한 디버깅에서 더 자주 도움이 됐다. “원형의 메서드를 바꿨는데 왜 이 객체만 다르게 동작하지?”라는 상황이라면 Object.hasOwn()으로 같은 이름의 own property부터 확인할 수 있다.
프로토타입 메서드의 this는 호출 방식이 정한다
메서드를 원형에서 발견했다고 해서 함수 안의 this도 원형 객체가 되는 것은 아니다. jaeho.greet()처럼 호출했다면 수신자는 jaeho다.
const behavior = { greet() { 'use strict'; return this?.name; } };
const jaeho = Object.assign(Object.create(behavior), { name: '재호' });
jaeho.greet(); // '재호'
const greet = jaeho.greet;
greet(); // undefined프로퍼티 탐색은 behavior까지 올라가고, this는 출발 객체인 jaeho를 가리킨다. 하나의 메서드를 여러 객체가 공유하면서도 각자의 name을 읽을 수 있는 이유다.
정확히 말하면 this는 프로토타입 체인이 정하지 않는다. jaeho.greet()라는 호출 형태가 정한다. 메서드를 변수에 떼어낸 뒤 그냥 호출하면 이 수신자 연결도 사라진다. 호출 방식별 this 규칙은 this 바인딩 정리에 따로 정리해뒀다.
JavaScript class 아래에도 프로토타입 체인이 있다
여기까지 보면 “JavaScript에는 클래스가 없다”라고 정리하고 싶어진다. 나도 처음에는 그렇게 이해했다. 하지만 지금의 JavaScript를 설명하기에는 너무 강한? 문장이다.
class는 ECMAScript에 정의된 실제 문법이다. new 없이 호출할 수 없고, 메서드는 열거되지 않으며, super와 private element처럼 고유한 의미도 제공한다.
다만 class가 별도의 프로퍼티 상속 모델을 추가한 것은 아니다. extends는 실제로 두 개의 연결을 만든다.
위쪽 연결 덕분에 Dog.kingdom() 같은 static 메서드를 사용할 수 있다. 아래쪽 연결 덕분에 new Dog().speak() 같은 인스턴스 메서드를 사용할 수 있다.
JavaScript에는 class 문법이 있다. 그러나 객체의 프로퍼티 공유와 class 상속을 떠받치는 메커니즘은 여전히 프로토타입 체인이다.
class는 거짓말이 아니다. 중요한 건 class를 쓰지 않는 것이 아니라, 문제가 생겼을 때 그 아래의 객체 연결까지 내려갈 수 있는가다.
. DOM 프로토타입 체인 — addEventListener는 어디서 찾을까
프로토타입은 생성자 함수 시대의 유물이 아니다. 브라우저에서 만든 div도 자기 안에 모든 메서드를 들고 있지 않다.
div.addEventListener()를 호출하면 체인을 따라 EventTarget.prototype의 메서드를 사용한다. 배열의 map, 문자열의 includes도 겉모습은 달라도 “프로퍼티를 어디에서 찾는가”라는 질문으로 내려갈 수 있다.
프레임워크와 class 문법이 객체의 연결을 감춰주지만, 감춰졌다는 것과 사라졌다는 것은 다르다.
JavaScript는 왜 프로토타입 기반 언어가 되었을까
JavaScript가 짧은 시간에 만들어졌다는 이야기는 유명하다. 그래서 프로토타입도 클래스를 만들 시간이 없어서 남은 임시방편처럼 오해하기 쉽다. 실제 객체 모델에는 분명한 참조처가 있었다.
1995년 Netscape는 Java처럼 보이면서 웹 페이지에서 가볍게 사용할 언어를 원했다. Brendan Eich는 Scheme의 일급 함수와 Self에서 영향을 받은 프로토타입 기반 객체 모델을 언어에 담았다. 「JavaScript: The First 20 Years」도 Self의 영향을 기록한다.
Self는 클래스와 인스턴스를 먼저 나누기보다 구체적인 객체에서 출발한다. 새 객체는 기존 객체와의 연결을 통해 행동을 공유하고, 필요한 차이만 자기 상태로 가진다. 1986년 Henry Lieberman의 논문도 그보다 앞서 원형 객체와 위임을 통한 행동 공유를 다뤘다. ES2015에 class 문법이 들어온 뒤에도 그 아래의 체인은 유지됐다.
다만 “동적인 웹이라서 프로토타입을 선택했다”라는 한 문장만으로 역사를 닫을 수는 없다. 언어 설계에는 구현 시간, Java와의 경쟁, Netscape와 Sun의 제품 전략 같은 여러 이유가 함께 있었다. 동적인 DOM은 프로토타입을 이해하기 좋은 사례이지, 유일한 역사적 원인은 아니다.
처음의 saved.dismiss()도 이제 한 문장으로 설명할 수 있다. 과거의 인스턴스가 미래를 안 것이 아니다. 지금도 연결된 객체에서, 지금의 메서드를 찾았을 뿐이다.
JavaScript의 객체는 완성된 설계도에서 찍혀 나온 결과물이라기보다, 자기에게 없는 것을 다른 객체에서 찾으며 관계 속에서 동작하는 존재에 가깝다.
프로토타입 체인의 규칙만 짧게 복습하고 싶다면 notes의 프로토타입 체인 동작 원리 정리를 참고할 수 있다.
reference
- MDN, Inheritance and the prototype chain
- Allen Wirfs-Brock, Brendan Eich, JavaScript: The First 20 Years, 2020
- David Ungar, Randall B. Smith, Self: The Power of Simplicity, OOPSLA 1987
- Henry Lieberman, Using Prototypical Objects to Implement Shared Behavior in Object-Oriented Systems, OOPSLA 1986
- 임성묵, 자바스크립트는 왜 프로토타입을 선택했을까
- 이웅모, 『모던 자바스크립트 Deep Dive』 19장·25장