ch19. 가장 단순하게 가려다 가장 복잡해진 언어 — 프로토타입과 일급 객체
JavaScript는 “최대한 단순하게” 가려던 선택이 역설적으로 가장 복잡한 축에 드는 언어를 낳았다.
객체지향이라는 유행, 그리고 내가 당연하게 여긴 것
JavaScript가 태어난 1990년대 중반은 객체지향이 패러다임의 왕좌에 있던 시기였어요. 새 언어라면 객체지향을 품는 게 당연한 분위기였죠.
그런데 제가 지금껏 “객체지향"이라고 생각해온 모델은 사실 클래스 기반이었어요. C++이나 Java처럼요. 그 모델에선 이렇게 합니다.
- 먼저 클래스(타입) 를 선언한다 — “Circle이란 이런 것이다”
- 그 클래스로부터 인스턴스 를 찍어낸다 —
new Circle(5)
여기서 이번에 새삼 깨달은 게 있어요. 클래스와 인스턴스는 언어가 “다른 시점에, 다르게 처리하는” 별개의 존재라는 거예요.
클래스는 컴파일 시점에 다뤄지는 타입이고, 인스턴스는 실행 시점에 만들어지는 값이에요. C++이라면 컴파일할 때 클래스 정보로 타입 검사를 하고 메서드 배치(vtable)까지 짜둔 다음, 실행 중에는 그 구조 그대로 인스턴스만 찍어냅니다. 즉 상속 구조가 컴파일 시점에 고정되고 이후엔 바뀌지 않아요. Dog extends Animal이라고 쓰면 그 관계는 컴파일이 끝나는 순간 박제됩니다.
그런데 JavaScript는 브라우저에서 곧장 돌아가는 스크립트 언어예요. 코드를 미리 컴파일해 배포하는 단계가 없으니, “컴파일 시점에 타입을 확정한다"는 그 모델 자체를 쓸 수가 없었어요. 모든 게 런타임에 결정돼야 했죠. 클래스라는 컴파일 타임 개념을 그대로 들여오는 대신, 다른 길을 택한 이유예요.
JavaScript의 선택: “클래스 없이, 전부 객체로”
JavaScript는 위의 “두 세계(타입 vs 값)“를 아예 없애기로 했어요. 타입이라는 별도 개념을 두지 말고, 세상엔 객체만 있다고 하자. 이게 프로토타입 기반 객체지향의 출발점이에요.
그럼 공통 동작(메서드)은 어디에 둘까요? 클래스가 없으니 “타입에 둔다"는 선택지가 사라졌어요. 대신 JavaScript의 답은 이거예요.
공통 동작을 다른 객체에 두고, 그 객체로 위임(delegation)한다.
그 위임 대상 객체가 바로 프로토타입이에요. 책에 나오는 Circle 예제를 보죠.
function Circle(radius) {
this.radius = radius;
}
Circle.prototype.getDiameter = function () {
return this.radius * 2;
};
const c1 = new Circle(5);
const c2 = new Circle(10);
console.log(c1.getDiameter()); // 10
console.log(c2.getDiameter()); // 20
여기서 관계를 정확히 그리는 게 중요해요. 헷갈리기 쉬운데, Circle(생성자 함수)은 “자식"이 아니에요.
Circle (생성자) ──.prototype──▶ Circle.prototype ◀──[[Prototype]]── c1, c2 (인스턴스)
객체를 찍는 "공장" 공유 메서드가 사는 "부모" 진짜 "자식들"부모 역할을 하는 건 Circle.prototype이라는 객체고, 자식은 인스턴스들이에요. getDiameter를 프로토타입에 다는 건 “자식이 부모 메서드를 정의"하는 게 아니라 모든 인스턴스가 위임해서 찾아 쓸 공유 객체에 메서드를 얹는 것이죠.
.prototype에 메서드를 거냐? JavaScript가 박아둔 기계적 규칙 때문이에요. 모든 함수는 생성될 때 자동으로 prototype 객체를 하나 달고 나오고, new로 만든 인스턴스의 [[Prototype]]은 그 객체를 가리킨다. 그래서 부모 객체를 따로 만들 필요 없이, 생성자가 이미 들고 있는 .prototype을 공유 부모로 재활용하는 거예요.위임 모델의 장점 — 그리고 양날의 검
이 모델의 첫 번째 특징은 공유예요. 인스턴스마다 메서드를 복제해 갖는 게 아니라, 하나의 함수를 모두가 위임해서 씁니다.
function Circle(radius) {
this.radius = radius;
}
Circle.prototype.getDiameter = function () {
return this.radius * 2;
};
const c1 = new Circle(5);
const c2 = new Circle(10);
// 두 인스턴스가 "같은 하나의 함수"를 공유한다 (복제 X)
console.log(c1.getDiameter === c2.getDiameter); // true
// c1 자신에는 메서드가 없다 → 부모(프로토타입)에 위임해서 찾는다
console.log(Object.getOwnPropertyNames(c1)); // [ 'radius' ]
console.log(Object.getPrototypeOf(c1) === Circle.prototype); // true
// 런타임에 부모를 수정하면 이미 만들어진 인스턴스도 즉시 영향받는다 (라이브 위임)
Circle.prototype.getArea = function () {
return Math.PI * this.radius ** 2;
};
console.log(c1.getArea().toFixed(2)); // 78.54
마지막 줄이 핵심이에요. 이미 만들어진 c1도, 나중에 부모에 추가한 getArea를 즉시 씁니다. 메서드 탐색이 컴파일 타임에 박제되는 게 아니라 런타임에 살아있는 객체 체인을 걸어 올라가며 일어나기 때문이에요.
상속 구조 자체를 실행 중에 바꿀 수도 있습니다.
function Animal() {}
Animal.prototype.speak = function () {
return "...";
};
function Dog() {}
const d = new Dog();
console.log(typeof d.speak); // undefined — 아직 부모에 없음
// 실행 중에 Dog의 부모를 Animal로 갈아끼움
Object.setPrototypeOf(Dog.prototype, Animal.prototype);
console.log(d.speak()); // "..." — 이미 만든 d도 즉시 상속받음
// 부모를 통째로 다른 객체로 교체
Object.setPrototypeOf(Dog.prototype, { speak: () => "멍멍" });
console.log(d.speak()); // "멍멍"
C++/Java였다면 Dog의 부모는 컴파일 시점에 고정되어 이런 변경이 절대 불가능해요. JavaScript에선 [[Prototype]]이 그냥 객체 참조라서 실행 중 언제든 갈아끼울 수 있습니다.
정리하면 이래요.
| 빌드타임 결정 (C++/Java) | 런타임 결정 (JS 프로토타입) | |
|---|---|---|
| 상속 구조 확정 시점 | 컴파일 시점에 고정, 이후 불변 | 객체 참조라 실행 중 언제든 변경 |
| 메서드 탐색 | vtable 등 정적 바인딩 | [[Prototype]] 체인을 런타임에 탐색 |
| 결과 | 안정·예측 가능, 툴링 친화 | 유연하지만 추적이 어려움 |
런타임 유연성은 분명 강력해요. 하지만 표의 오른쪽 아래를 잘 보세요. “추적이 어려움” — 이게 양날의 검의 다른 쪽 날이에요.
Object.setPrototypeOf로 실행 중에 부모가 교체될 수 있으니까요. 유연함과 추적 불가능함은 같은 동전의 양면입니다.단순함이 부른 복잡함
JavaScript의 코어 설계는 정말 단순해요. “세상엔 객체뿐, 메커니즘은 위임 하나.” 타입 시스템도, 컴파일 단계도 필요 없죠.
막상 이 모델을 쓰려면 알아야 하는 개념이 줄줄이 딸려와요.
- 생성자 함수,
prototype,__proto__,[[Prototype]],constructor— 이 다섯 개념이 얽힌 삼각관계 - 메서드 안에서
this가 무엇을 가리키는지 (호출 방식에 따라 달라지는this바인딩) - 방금 본 것처럼, 상속 구조가 런타임에 바뀔 수 있어 코드만 봐선 부모를 확신할 수 없는 추적의 어려움
함수마저 객체로 — 일급 객체라는 숨은 토대
Circle.prototype.getDiameter = function () { ... } 이 줄을 다시 보세요. 프로토타입(객체)의 프로퍼티에 함수를 “할당"하고 있어요. 이게 가능한 이유는 JavaScript에서 함수가 곧 객체(값)이기 때문이에요. 18장의 “일급 객체(first-class object)“가 바로 이 이야기예요.
함수가 일급 객체라는 건 함수를 값처럼 다룰 수 있다는 뜻이고, 구체적으로 네 가지를 만족해요.
- 무명의 리터럴로 런타임에 생성할 수 있다
- 변수나 객체·배열에 담을 수 있다
- 다른 함수의 인수로 넘길 수 있다
- 다른 함수의 반환값이 될 수 있다
여기서 두 번째 — “객체의 프로퍼티에 담을 수 있다"는 성질이 없었다면, 애초에 프로토타입에 메서드를 얹는 일 자체가 불가능했어요. 즉 18장(일급 객체)은 19장(프로토타입 메서드 공유)의 숨은 전제 조건이었던 거예요.
결국 “객체 하나로 통일하자"는 같은 단순화가, 인스턴스에는 프로토타입 위임을, 함수에는 일급 객체라는 성질을 동시에 안겨준 셈이에요. 둘은 따로 노는 두 챕터가 아니라 한 뿌리에서 나온 두 가지였죠.
흔한 오해 / 주의점
- 오해: “프로토타입에 메서드를 정의하는 건 자식이 부모를 만드는 것이다.” 아니에요. 부모는
Circle.prototype객체이고, 자식은 인스턴스예요. 생성자 함수는 부모가 아니라 인스턴스를 찍는 공장일 뿐이에요. - 주의: 상속 구조는 런타임에 바뀔 수 있다. 코드를 정적으로 읽는 것만으로는 어떤 객체의 부모를 확신할 수 없어요. 이게 프로토타입 모델의 유연함이자 함정이에요.
정리
- 클래스 기반 객체지향은 타입과 인스턴스라는 두 세계를 전제하고, 상속 구조를 빌드타임에 고정한다.
- JavaScript는 브라우저에서 바로 도는 스크립트 언어라 컴파일 단계가 없었고, 그래서 두 세계를 없애고 “객체 하나로 통일” 했다. 공통 동작은 타입이 아니라 다른 객체(프로토타입)에 두고 런타임에 위임한다.
- 이 위임은 라이브여서, 이미 만든 인스턴스의 동작도 상속 구조도 실행 중에 바꿀 수 있다 — 강력하지만 추적은 어렵다.
- 언어 입장의 단순함이 사용자에겐 큰 복잡함으로 전가될 수 있다 — 이번 19장 앞부분의 가장 큰 배움이다.
- 그리고 그 “객체로 통일하자"는 단순화가, 함수마저 객체로 만든 일급 객체(18장) 와 한 뿌리였다.