다른 언어에서 넘어온 사람은 자바스크립트로 객체를 만들 때 한 번쯤 멈칫한다. 클래스라는 틀이 먼저 있고 그 틀로 객체를 찍어내는 데 익숙한데, 자바스크립트는 오랫동안 그 틀 없이 객체를 다뤘다. 나도 클래스 기반 언어에서 넘어와서, 자바스크립트에 클래스가 있다길래 반가워하며 썼다가 뒤통수를 맞았다. 겉모습은 클래스인데 속은 전혀 다른 물건이었기 때문이다.


자바스크립트의 클래스는 프로토타입(prototype, 원형)이라는 오래된 뼈대 위에 씌운 껍데기다. 이 뼈대를 모르고 클래스만 외우면, 조금만 이상한 상황이 와도 왜 그런지 설명을 못 한다. 이번 편은 프로토타입이 뭔지, 클래스가 그 위에서 어떻게 도는지, 상속과 비공개 필드가 실제로 어떻게 동작하는지를 코드를 돌려가며 정리한다.


같은 메서드를 객체마다 복사하면 낭비다. 문제부터 보자. 사용자 객체를 100개 만드는데 각 객체가 인사하는 기능을 가져야 한다고 하자. 순진하게 짜면 객체를 만들 때마다 똑같은 인사 함수를 하나씩 복사해 넣게 된다. 함수 100개가 메모리에 그대로 쌓인다. 내용이 완전히 같은 함수를 100벌 들고 있는 셈이니, 이건 명백한 낭비다. 자바스크립트는 이 낭비를 프로토타입으로 푼다.


프로토타입은 공유 서랍이다. 모든 객체는 자기 뒤에 프로토타입이라는 다른 객체를 하나 매달고 있다. 어떤 속성을 객체에서 찾다가 없으면, 자바스크립트는 그 뒤에 매달린 프로토타입으로 넘어가 거기서 마저 찾는다. 이 연결이 계속 이어진 사슬을 프로토타입 체인(prototype chain, 원형 사슬)이라 부른다. 그러니 공유할 메서드를 프로토타입이라는 공동 서랍에 딱 하나만 넣어두면, 객체들은 자기한테 없어도 그 서랍을 열어 같은 함수를 꺼내 쓴다.


function User(name) { this.name = name; }
User.prototype.hello = function() { return "안녕 " + this.name; };

const u1 = new User("가");
const u2 = new User("나");
console.log(u1.hello === u2.hello);
console.log(u1.hello());


실행 결과다.


true
안녕 가


여기서 u1.hellou2.hello가 같다고(true) 나오는 게 핵심이다. 두 객체가 각자 함수를 복사해 가진 게 아니라, 프로토타입에 놓인 하나의 함수를 똑같이 가리키고 있다는 뜻이다. 함수는 한 벌인데 this만 부르는 객체에 따라 달라지니, u1.hello()는 자기 name을 붙여 제 이름을 낸다. 나는 이 구조를 이해하고 나서야 자바스크립트가 왜 함수를 프로토타입에 두라고 하는지 납득했다.


new가 하는 일은 정해져 있다. new User("가")를 부르면 뒤에서 네 가지가 순서대로 벌어진다. 먼저 빈 객체가 하나 만들어진다. 그 빈 객체의 프로토타입이 User.prototype으로 연결된다. 그다음 생성자 함수가 그 객체를 this로 삼아 실행되면서 this.name = name 같은 속성을 박는다. 마지막으로 그 객체가 돌려진다. 그래서 new 없이 User("가")를 그냥 부르면 이 과정이 통째로 빠지고, this가 새 객체가 아니게 돼서 엉뚱한 곳에 속성이 박히거나 에러가 난다. 대문자로 시작하는 함수는 new로 부르라는 관례가 이래서 생겼다.


class는 프로토타입 위의 사탕발림이다. 위 코드는 솔직히 좀 지저분하다. 생성자 함수와 프로토타입 할당이 따로 놀아서 한눈에 안 들어온다. 그래서 나중에 class 문법이 들어왔다. 중요한 건 이게 새로운 객체 모델이 아니라, 방금 본 프로토타입 방식을 보기 좋게 감싼 문법 설탕(syntactic sugar, 문법적 편의)이라는 점이다. 속에서 벌어지는 일은 똑같다. 클래스에 적은 메서드는 그대로 프로토타입에 얹힌다.


class Animal {
constructor(name) { this.name = name; }
speak() { return this.name + "가 소리를 낸다"; }
}

const a = new Animal("나비");
console.log(a.speak());
console.log(a.hasOwnProperty("speak"));
console.log(Object.getPrototypeOf(a) === Animal.prototype);


실행 결과다.


나비가 소리를 낸다
false
true


두 번째 줄 false가 재밌다. hasOwnProperty("speak")는 이 객체가 speak를 자기 것으로 직접 가졌냐고 묻는 건데, 답이 false다. speak는 객체 자신이 아니라 프로토타입에 있기 때문이다. 세 번째 줄에서 a의 프로토타입이 Animal.prototype과 같다고(true) 나오는 게 그 증거다. 클래스로 짰어도 결국 메서드는 프로토타입에 있고, 객체는 그걸 빌려 쓰는 구조 그대로다.


상속은 프로토타입 체인을 한 칸 늘리는 것. extends로 하는 상속도 마법이 아니라 프로토타입 사슬을 한 칸 이어붙이는 일이다. 자식 인스턴스에서 메서드를 찾다 없으면 자식 프로토타입으로, 거기도 없으면 부모 프로토타입으로 넘어간다. super는 부모 생성자나 부모 메서드를 부르는 통로다.


class Dog extends Animal {
constructor(name) { super(name); this.legs = 4; }
speak() { return this.name + "가 멍멍 짖는다"; }
}

const d = new Dog("바둑이");
console.log(d.speak());
console.log(d instanceof Animal);
console.log(d instanceof Dog);


실행 결과다.


바둑이가 멍멍 짖는다
true
true


Dogspeak를 자기 프로토타입에 새로 정의했으니, d.speak()는 부모의 것 대신 자식 것을 먼저 찾아 쓴다. 이게 재정의(overriding, 덮어쓰기)다. 사슬을 따라 올라가며 처음 만난 걸 쓰니, 자식에 있으면 자식 게 이긴다. instanceof는 그 사슬 어딘가에 해당 클래스의 프로토타입이 끼어 있는지를 검사한다. dDog이면서 동시에 Animal이기도 하니 둘 다 true다. 나는 이 사슬 그림을 머리에 그리고 나서 상속이 헷갈리지 않게 됐다.


비공개 필드로 상태를 잠근다. 예전엔 자바스크립트에 진짜 비공개 변수가 없어서 클로저나 이름 앞에 밑줄 붙이는 관례로 흉내만 냈다. 지금은 필드 이름 앞에 #을 붙이면 클래스 밖에서 아예 접근이 막히는 진짜 비공개 필드가 된다.


class Counter {
#count = 0;
increment() { this.#count += 1; return this.#count; }
}

const c = new Counter();
console.log(c.increment());
console.log(c.increment());


실행 결과다.


1
2


increment를 통해서만 값이 오르고, 밖에서 c.#count로 직접 건드리면 문법 에러가 난다. 상태를 정해진 창구로만 바꾸게 잠그는 거다. 값이 어디서 바뀌는지 추적이 쉬워져서, 나는 상태를 품는 클래스엔 되도록 비공개 필드를 쓴다.


프로토타입을 실행 중에 함부로 갈아끼우지 마라. 내가 크게 데인 곳이 여기다. 옛 코드에서 __proto__로 객체의 프로토타입을 실행 도중에 바꿔치기하는 걸 봤고, 멋있어 보여서 따라 했다. 결과는 참사였다. 프로토타입을 도중에 갈면 자바스크립트 엔진이 해두던 최적화가 깨져서 성능이 뚝 떨어진다. 게다가 어느 객체가 무슨 프로토타입을 가졌는지 코드만 봐선 알 수가 없어서, 버그가 났을 때 원인 추적이 지옥이 된다. 반나절을 날린 뒤 나는 프로토타입은 class나 생성자로 처음 한 번만 정하고, 실행 중엔 절대 안 건드린다는 규칙을 세웠다.


정리하면 자바스크립트의 객체는 프로토타입이라는 뒤 서랍을 매달고 다니며, 자기한테 없는 속성은 그 서랍을 열어 찾는다. 클래스는 이 프로토타입 방식을 읽기 좋게 감싼 문법일 뿐, 속은 똑같다. 상속은 서랍 사슬을 한 칸 늘리는 것이고, instanceof는 그 사슬을 검사한다. 이 뼈대를 붙들고 있으면 클래스가 이상하게 굴 때도 왜 그런지 설명할 수 있다.