JavaScript는 왜 여전히 프로토타입 기반일까 — 객체 연결을 그림으로 이해하기

최근 『모던 자바스크립트 Deep Dive』의 프로토타입 예제를 하나씩 실행하며 서브 블로그를 정리했다. 생성자 함수의 prototype도 알고, 객체가 [[Prototype]]이라는 내부 슬롯을 가진다는 것도 알고 있었다. 그런데 아래 문장을 내 말로 설명하려니 자꾸 멈칫했다.

JavaScript는 프로토타입 기반 언어다.

함수를 주로 사용하는 React는 별개지만, 백엔드를 개발할때 나는 평소에 class를 쓴다. 그렇다면 프로토타입은 생성자 함수를 쓰던 시절의 흔적일까? 혹은 JavaScript의 class는 가짜라는 뜻일까?

이 글의 답을 먼저 요약해두면 이렇다. 프로토타입 체인은 객체가 원하는 프로퍼티를 직접 가지고 있지 않을 때 [[Prototype]]이 가리키는 다른 객체로 탐색을 넘기는 구조다. 메서드는 각 인스턴스에 복사되지 않고 이 연결을 통해 공유되며, JavaScript의 class 상속과 DOM API도 같은 체인 위에서 동작한다. 이 객체 모델은 Self의 영향을 받았고, 당시 Netscape의 제품 요구와 언어 설계 환경 속에서 JavaScript에 들어왔다. 아래에서 이 문장을 그림으로 하나씩 확인한다.

처음에는 구구절절 설명을 정리해보았지만, 결국 프로토타입은 코드보다 객체와 객체 사이의 연결을 먼저 봐야 하는 개념이라고 느꼈다.

 

설계도에서 시작할까, 객체에서 시작할까

이 글을 구상할 때 많이 참고한 임성묵님의 자바스크립트는 왜 프로토타입을 선택했을까를 읽으며 한 가지 질문을 붙잡게 됐다.

객체를 만들기 전에 범주를 먼저 정의해야 할까?

클래스 기반 객체지향을 설명할 때는 보통 설계도 비유를 사용한다. 먼저 클래스에 상태와 동작을 정의하고, 그 설계도로 인스턴스를 만든다. 개별 객체보다 범주가 먼저다.

반대로 프로토타입 기반 모델은 출발점이 다르다. 먼저 구체적인 객체가 있고, 다른 객체가 그 객체를 원형으로 연결한다. 필요한 차이는 새 객체가 직접 가지고, 공통 행동은 연결을 따라가며 사용한다.

하나의 설계도에서 인스턴스를 만드는 클래스 방식과 구체적인 객체끼리 연결되는 프로토타입 방식 비교 다이어그램

왼쪽에서는 Class가 A와 B보다 먼저 존재한다. 오른쪽에서는 A, B, C가 모두 구체적인 객체이고 선(엄밀히 체인)으로 관계를 맺는다. 모든 객체지향 언어의 사용법을 둘로 나눈 그림은 아니다. 클래스/인스턴스 모델과 프로토타입/위임 모델의 전형적인 출발점을 단순화한 비교다.

설계도는 안정적인 범주에 잘 어울린다

필요한 속성과 행동이 비교적 명확하다면 설계도를 먼저 만드는 방식이 자연스럽다. Toast라는 범주를 정하고 같은 구조를 가진 토스트 객체를 여러 개 만들어보자.

Toast 클래스에서 같은 구조의 인스턴스 세 개가 만들어지는 설계도 방식 다이어그램

반면 웹의 DOM은 실행 중에도 계속 변한다. 사용자의 클릭에 따라 노드가 생기고, 사라지고, 속성이 바뀐다. 처음 만든 설계도만 바라보기보다 지금 존재하는 객체를 직접 바꾸고 연결하는 일이 많다.

 

실행 중인 DOM 트리에 새 li 노드가 추가되는 동적 변화 다이어그램

JavaScript가 프로토타입을 선택한 이유를 DOM 하나로 설명할 수는 없다. 실제 역사에는 Netscape의 제품 요구와 Self의 영향이 함께 있었다. 여기서 DOM은 역사적 인과를 증명하려는 사례가 아니라, 이미 존재하는 객체가 실행 중에 바뀐다는 감각을 잡기 위해 내가 사용한 학습용 비유다.

 

프로토타입이 필요한 가장 기본적인 이유

프로토타입이 없다면 생성자 안에서 메서드를 만들 때 인스턴스마다 같은 함수가 생긴다. 반지름은 서로 달라야 하지만 넓이를 구하는 방법까지 매번 새로 가질 필요는 없다.

인스턴스마다 getArea 함수가 중복 생성되는 구조를 나타낸 다이어그램
JAVASCRIPT
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에 두면 두 인스턴스는 하나의 함수를 함께 사용한다.

 

두 Circle 인스턴스가 프로토타입의 getArea 메서드 하나를 공유하는 구조 다이어그램

small과 large 안으로 getArea가 복사된 것이 아니다. 자기에게 없는 프로퍼티를 같은 원형 객체에서 찾는다.

상태는 각 객체가 가지고, 같은 행동은 연결된 객체에서 공유한다.

 

.prototype과 [[Prototype]]은 다르다

프로토타입을 공부할 때 가장 헷갈렸던 건 비슷한 이름이 서로 다른 자리에 등장한다는 점이었다.

  • Toast.prototype은 함수 객체 Toast가 가진 프로퍼티다.
  • saved.[[Prototype]]은 프로퍼티 탐색을 넘길 다음 객체를 가리키는 내부 슬롯이다.
Toast 생성자와 Toast.prototype, 인스턴스를 잇는 prototype·constructor·[[Prototype]] 연결 다이어그램

내부 슬롯에는 코드로 직접 접근하지 않는다. Object.getPrototypeOf(saved)로 확인할 수 있다.

JAVASCRIPT
function Toast(message) {
  this.message = message;
}

const saved = new Toast('저장되었습니다');

Toast.prototype === Object.getPrototypeOf(saved); // true

교재의 다이어그램에서 생성자 함수, 프로토타입, 객체가 삼각형으로 연결된 이유도 이것이었다. 세 요소가 합쳐진 한 덩어리가 아니라 각자 존재하는 객체가 링크로 연결된 구조다.

 

프로토타입 체인의 동작 원리 — 프로퍼티는 어디서 찾을까

이 연결의 성질은 런타임에 원형을 바꿨을 때 선명하게 드러난다.

JAVASCRIPT
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 객체의 연결은 그대로인 채 Toast.prototype에 dismiss 메서드가 추가된 상태 다이어그램

이미 만들어진 객체가 미래의 메서드를 받았다고 표현하면 재미있지만, 정확히는 saved가 갱신된 것이 아니다. saved가 계속 바라보던 객체에 메서드가 추가됐고, 다음 조회가 바뀐 원형을 다시 읽었을 뿐이다.

 

saved에서 시작해 [[Prototype]] 링크를 따라 dismiss를 찾고 this가 saved로 유지되는 프로퍼티 탐색 과정 애니메이션

탐색은 saved에서 시작한다. dismiss가 없으면 [[Prototype]] 링크를 따라 Toast.prototype으로 이동한다. 그곳에서 함수를 발견한 뒤 호출한다.

실제 엔진이 GIF처럼 천천히 움직이는 것은 아니다. 이 장면은 성능 측정이 아니라 탐색 순서를 펼쳐 보여주는 그림이다.

 

프로토타입 상속은 복사가 아니라 위임이다

프로토타입 체인의 동작을 흔히 상속이라고 부른다. 틀린 표현은 아니지만 처음에는 위임(delegation)이라고 생각하는 편이 더 잘 그려지는 것 같다. 객체가 부모의 프로퍼티를 복사해 갖는 게 아니라, 자기에게 없을 때 연결된 객체로 탐색을 넘기기 때문이다.

프로퍼티 섀도잉 — 가까운 프로퍼티가 뒤의 연결을 가린다

만약 saved가 같은 이름의 dismiss를 직접 가지면 탐색은 첫 객체에서 끝난다.

인스턴스의 dismiss가 프로토타입의 같은 이름 메서드를 가리는 프로퍼티 섀도잉 다이어그램

원형의 메서드가 삭제된 것은 아니다. 더 가까운 곳에서 같은 이름을 발견했기 때문에 뒤까지 가지 않을 뿐이다. 이것이 프로퍼티 shadowing이다.

사실 이 개념은 상속 문제보다 평범한 디버깅에서 더 자주 도움이 됐다. “원형의 메서드를 바꿨는데 왜 이 객체만 다르게 동작하지?”라는 상황이라면 Object.hasOwn()으로 같은 이름의 own property부터 확인할 수 있다.

 

프로토타입 메서드의 this는 호출 방식이 정한다

메서드를 원형에서 발견했다고 해서 함수 안의 this도 원형 객체가 되는 것은 아니다. jaeho.greet()처럼 호출했다면 수신자는 jaeho다.

메서드는 behavior 객체에서 찾고 this는 호출한 jaeho 객체를 가리키는 관계 다이어그램
              
JAVASCRIPT
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는 실제로 두 개의 연결을 만든다.

extends가 만드는 생성자 체인과 프로토타입 체인 두 갈래 연결 다이어그램

위쪽 연결 덕분에 Dog.kingdom() 같은 static 메서드를 사용할 수 있다. 아래쪽 연결 덕분에 new Dog().speak() 같은 인스턴스 메서드를 사용할 수 있다.

JavaScript에는 class 문법이 있다. 그러나 객체의 프로퍼티 공유와 class 상속을 떠받치는 메커니즘은 여전히 프로토타입 체인이다.

class는 거짓말이 아니다. 중요한 건 class를 쓰지 않는 것이 아니라, 문제가 생겼을 때 그 아래의 객체 연결까지 내려갈 수 있는가다.

 

. DOM 프로토타입 체인 — addEventListener는 어디서 찾을까

프로토타입은 생성자 함수 시대의 유물이 아니다. 브라우저에서 만든 div도 자기 안에 모든 메서드를 들고 있지 않다.

div 요소에서 HTMLDivElement를 거쳐 EventTarget까지 이어지는 DOM 프로토타입 체인 다이어그램

div.addEventListener()를 호출하면 체인을 따라 EventTarget.prototype의 메서드를 사용한다. 배열의 map, 문자열의 includes도 겉모습은 달라도 “프로퍼티를 어디에서 찾는가”라는 질문으로 내려갈 수 있다.

프레임워크와 class 문법이 객체의 연결을 감춰주지만, 감춰졌다는 것과 사라졌다는 것은 다르다.

 

JavaScript는 왜 프로토타입 기반 언어가 되었을까

JavaScript가 짧은 시간에 만들어졌다는 이야기는 유명하다. 그래서 프로토타입도 클래스를 만들 시간이 없어서 남은 임시방편처럼 오해하기 쉽다. 실제 객체 모델에는 분명한 참조처가 있었다.

Lieberman 논문과 Self에서 JavaScript, ES2015 class로 이어지는 프로토타입 역사 연표

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

 

 

 

 

Comments