자바스크립트 프로토타입과 클래스 - 상속이 체인으로 동작하는 방식 (JS 중급 11단원)
이 단원에서 배우는 것
9단원에서 클로저로 상태를 감췄고, 10단원 끝에서 class 문법을 this 설명에 잠깐 썼다. 이번에는 그 class 가 실제로 무엇 위에 얹혀 있는지를 본다. 자바스크립트에는 클래스 기반 상속이 없다. class 는 프로토타입 체인이라는 훨씬 단순한 장치를 읽기 좋게 감싼 문법이다. 이 아래층을 알아야 "왜 hasOwnProperty 가 따로 있는지", "왜 프로토타입에 배열을 두면 안 되는지" 같은 질문에 답할 수 있다. 도서 대출 서비스의 도서 모델을 종이책과 전자책으로 나누면서 진행한다. 기준은 ES2022 이며, #private 필드와 static 블록은 ES2022 에서 표준이 됐다.
- 프로퍼티를 찾을 때 프로토타입 체인을 어떻게 거슬러 올라가는지 그림으로 그릴 수 있다.
class·extends·super가 프로토타입 조작으로 어떻게 번역되는지 대응시킨다.#private필드,static멤버, 접근자(getter/setter)를 실제 모델에 적용한다.
왜 필요한가
도서 객체가 수천 개다. 각 도서마다 "이름표 문자열을 만드는" 기능이 필요하다.
const b1 = { id: 'B001', title: '자바스크립트 완벽 가이드', label() { return `${this.id} ${this.title}`; } };
const b2 = { id: 'B002', title: '리팩터링', label() { return `${this.id} ${this.title}`; } };
// ... 이하 수천 개
같은 함수가 객체 수만큼 복제된다. 메모리도 문제지만 더 큰 문제는 수정이다. 이름표 형식을 바꾸려면 수천 군데를 고쳐야 한다.
해결책은 "공통 기능은 한 군데 두고, 개별 객체는 그걸 가리키기만 한다"는 것이다. 자바스크립트는 이 가리킴을 모든 객체에 기본으로 하나씩 달아 뒀다. 그것이 프로토타입 링크다. 어떤 객체에서 프로퍼티를 못 찾으면 이 링크를 타고 올라가 다시 찾는다. 상속이라는 말도 여기서 나온다. 자바나 C# 의 상속처럼 "설계도를 복사해 확장"하는 게 아니라, 실제 객체를 가리키는 링크를 따라가는 것이다.
문법과 예제
프로토타입 체인의 실제 모습
모든 객체에는 숨은 링크가 하나 있다. Object.getPrototypeOf(obj) 로 읽는다. 생성자 함수를 new 로 부르면, 만들어진 객체의 링크는 그 함수의 prototype 프로퍼티를 가리킨다.
function Book(id, title) {
this.id = id;
this.title = title;
}
Book.prototype.label = function () {
return `${this.id} ${this.title}`;
};
const book = new Book('B001', '자바스크립트 완벽 가이드');
console.log(book.label()); // B001 자바스크립트 완벽 가이드
console.log(Object.getPrototypeOf(book) === Book.prototype); // true
console.log(Object.hasOwn(book, 'label')); // false ← 자기 것이 아니다
console.log('label' in book); // true ← 체인에는 있다
book.label 을 평가할 때 엔진은 이렇게 움직인다.
book자신에게label이 있는가 → 없다Book.prototype에 있는가 → 있다. 이걸 쓴다- (없었다면)
Object.prototype까지 올라가고, 거기도 없으면undefined
여기서 헷갈리기 쉬운 두 이름을 구분해 둔다. prototype 은 함수가 가진 프로퍼티이고, Object.getPrototypeOf(x) 는 객체의 링크를 읽는 것이다. book.prototype 은 undefined 다. 이 둘을 섞어 쓰면 설명이 전부 꼬인다.
생성자 없이 링크만 직접 걸 수도 있다. 이쪽이 프로토타입의 본모습에 더 가깝다.
const bookProto = {
label() { return `${this.id} ${this.title}`; }
};
const b = Object.create(bookProto);
b.id = 'B009';
b.title = '수동 프로토타입';
console.log(b.label()); // B009 수동 프로토타입
// 프로토타입이 아예 없는 객체 — 순수 사전으로 쓸 때 유용하다
const stockMap = Object.create(null);
stockMap.B001 = 3;
console.log('toString' in stockMap); // false
class 는 위 코드를 읽기 좋게 쓴 것이다
class BookItem {
static #created = 0; // 정적 private 필드
#stock; // 인스턴스 private 필드
constructor(id, title, stock) {
this.id = id;
this.title = title;
this.#stock = stock;
BookItem.#created += 1;
}
get stock() { return this.#stock; } // 읽기 전용 접근자
get available() { return this.#stock > 0; }
lend() {
if (this.#stock <= 0) throw new Error(`${this.title}: 재고 없음`);
this.#stock -= 1;
return this.#stock;
}
label() { return `${this.id} ${this.title}`; }
static get created() { return BookItem.#created; }
}
const paper = new BookItem('B001', '자바스크립트 완벽 가이드', 1);
console.log(paper.label()); // B001 자바스크립트 완벽 가이드
console.log(paper.lend()); // 0
console.log(paper.available); // false
// paper.stock = 5; // setter 가 없으므로 조용히 무시(strict 에서는 TypeError)
label 은 BookItem.prototype 에 놓이고, id·title 은 인스턴스에 놓인다. 앞의 Book.prototype.label = ... 과 완전히 같은 구조다. 확인해 보면 이렇다.
console.log(Object.hasOwn(paper, 'label')); // false
console.log(Object.hasOwn(BookItem.prototype, 'label')); // true
console.log(Object.getPrototypeOf(paper) === BookItem.prototype); // true
#stock 은 클로저로 감췄던 상태를 문법으로 대체한 것이다. 9단원의 createLoanDesk 와 목적이 같다. 차이는 #stock 이 진짜로 접근 불가라는 점이다. 클래스 밖에서 paper.#stock 이라고 적으면 런타임 에러가 아니라 구문 오류라 파일 전체가 파싱되지 않는다.
extends 와 super
class EBook extends BookItem {
constructor(id, title, sizeMb) {
super(id, title, Infinity); // 반드시 this 보다 먼저
this.sizeMb = sizeMb;
}
lend() { return Infinity; } // 재고 개념이 없다 — 오버라이드
label() {
return `${super.label()} (전자책 ${this.sizeMb}MB)`;
}
}
const ebook = new EBook('E001', '전자책판 클린 코드', 12);
console.log(ebook.label()); // E001 전자책판 클린 코드 (전자책 12MB)
console.log(ebook instanceof EBook); // true
console.log(ebook instanceof BookItem); // true
// extends 가 실제로 한 일: 링크 두 줄
console.log(Object.getPrototypeOf(EBook.prototype) === BookItem.prototype); // true (인스턴스 메서드 체인)
console.log(Object.getPrototypeOf(EBook) === BookItem); // true (static 메서드 체인)
마지막 두 줄이 extends 의 전부다. 인스턴스 쪽 체인과 정적 멤버 쪽 체인, 두 개를 이어 준다. 그래서 EBook.created 처럼 부모의 static 도 그대로 쓸 수 있다.
instanceof 는 마법이 아니라 체인을 따라 올라가며 생성자.prototype 이 나오는지 보는 것이다. 그래서 Object.setPrototypeOf 로 링크를 바꾸면 instanceof 결과도 바뀐다.
실무형 예제: 대출 정책을 하위 클래스로 나눈다
class LoanPolicy {
static DEFAULT_DAYS = 14;
constructor(memberType) { this.memberType = memberType; }
get maxBooks() { return 5; }
dueDate(from = new Date()) {
const due = new Date(from);
due.setDate(due.getDate() + this.days);
return due;
}
get days() { return LoanPolicy.DEFAULT_DAYS; }
describe() { return `${this.memberType}: ${this.maxBooks}권 / ${this.days}일`; }
}
class StaffPolicy extends LoanPolicy {
constructor() { super('교직원'); }
get maxBooks() { return 20; }
get days() { return 30; }
}
class GuestPolicy extends LoanPolicy {
constructor() { super('외부인'); }
get maxBooks() { return 1; }
get days() { return 7; }
describe() { return `${super.describe()} (관내 열람 우선)`; }
}
for (const p of [new LoanPolicy('학생'), new StaffPolicy(), new GuestPolicy()]) {
console.log(p.describe());
}
// 학생: 5권 / 14일
// 교직원: 20권 / 30일
// 외부인: 1권 / 7일 (관내 열람 우선)
describe() 는 부모에만 정의돼 있는데 하위 클래스마다 다른 값을 낸다. 부모의 describe 안에서 this.maxBooks 를 읽는 순간, this 는 실제 인스턴스이므로 체인의 가장 아래부터 찾기 때문이다. 10단원의 "this 는 호출부가 정한다"가 여기서 상속과 맞물린다.
실무에서 자주 틀리는 것
1. 프로토타입에 배열이나 객체를 둬서 모든 인스턴스가 공유한다
function Member(name) { this.name = name; }
Member.prototype.borrowed = []; // ← 사고
const m1 = new Member('김');
const m2 = new Member('박');
m1.borrowed.push('B001');
console.log(m2.borrowed); // ['B001'] ← 박씨가 빌린 적 없는 책
m1.borrowed.push(...) 는 m1 에 borrowed 를 만들지 않는다. 체인을 타고 올라가 프로토타입에 있는 그 배열을 찾아 거기에 넣는다. 반면 m1.borrowed = [] 처럼 대입하면 그때는 m1 자신에게 새 프로퍼티가 생긴다. 읽기는 체인을 타지만 쓰기는 자기 자신에 한다는 이 비대칭이 원인이다.
규칙: 참조 타입(배열·객체·Map)은 반드시 생성자나 클래스 필드에서 인스턴스마다 만든다. 프로토타입에는 함수와 불변 상수만 둔다.
class Member {
borrowed = []; // 인스턴스마다 새 배열
constructor(name) { this.name = name; }
}
2. 파생 클래스 생성자에서 super() 보다 먼저 this 를 쓴다
class EBook extends BookItem {
constructor(id, title, sizeMb) {
this.sizeMb = sizeMb; // ReferenceError
super(id, title, Infinity);
}
}
// ReferenceError: Must call super constructor in derived class
// before accessing 'this' ...
파생 클래스에서는 this 를 super() 가 만들어 준다. 그전까지 this 는 존재하지 않는다(9단원의 TDZ 와 같은 원리다). 인자를 가공해야 하면 super() 호출 인자 안에서 하거나, 지역 변수를 쓴 뒤 super() 다음에 대입한다.
관련해서 초기화 순서도 알아 둔다. 파생 클래스의 클래스 필드는 super() 가 끝난 직후 초기화된다. 그래서 부모 생성자가 부르는 메서드에서 자식 필드를 읽으면 아직 undefined 다. 부모 생성자 안에서 오버라이드 가능한 메서드를 호출하는 설계는 피한다.
3. 사용자 데이터를 담은 객체에 hasOwnProperty 를 직접 부른다
const stock = JSON.parse('{"B001": 3, "hasOwnProperty": 0}');
// stock.hasOwnProperty('B001'); // TypeError: stock.hasOwnProperty is not a function
console.log(Object.hasOwn(stock, 'B001')); // true ← ES2022. 이걸 쓴다
console.log(Object.prototype.hasOwnProperty.call(stock, 'B001')); // true (예전 방식)
외부 JSON 이 프로토타입 메서드와 같은 이름의 키를 갖고 있으면 가려진다. Object.create(null) 로 만든 객체에도 hasOwnProperty 가 없다. ES2022 의 Object.hasOwn 은 이 두 경우를 모두 해결하므로 새로 쓰는 코드에서는 Object.hasOwn 을 기본으로 한다.
in 연산자와의 차이도 짚어 둔다. 'toString' in stock 은 true 다. 체인까지 보기 때문이다. "이 객체가 직접 갖고 있는가"를 묻고 싶다면 in 이 아니라 Object.hasOwn 이다.
4. 인스턴스를 JSON 으로 왕복시키면 클래스가 사라진다
const ebook = new EBook('E001', '전자책판', 12);
const restored = JSON.parse(JSON.stringify(ebook));
console.log(restored instanceof EBook); // false
console.log(typeof restored.label); // undefined
console.log(restored.sizeMb); // 12 ← 데이터만 남는다
JSON.stringify 는 자기 소유의 열거 가능한 프로퍼티만 직렬화한다. 프로토타입 링크도, #private 필드도, 메서드도 남지 않는다. 서버 응답(16단원)을 그대로 쓰면 늘 이 상태다. 클래스 인스턴스로 되살리려면 복원 함수를 명시적으로 둔다.
class EBook extends BookItem {
// ...
static fromJSON(raw) { return new EBook(raw.id, raw.title, raw.sizeMb); }
toJSON() { return { kind: 'ebook', id: this.id, title: this.title, sizeMb: this.sizeMb }; }
}
toJSON() 메서드가 있으면 JSON.stringify 가 그 반환값을 대신 직렬화한다. #private 값을 내보내야 할 때 쓰는 표준 통로다.
스스로 확인하기
- 다음 출력과 이유를 쓰라.
class A { greet() { return 'A'; } } class B extends A { greet() { return 'B+' + super.greet(); } } const b = new B(); console.log(b.greet()); console.log(Object.hasOwn(b, 'greet'), 'greet' in b); console.log(Object.getPrototypeOf(Object.getPrototypeOf(b)) === A.prototype); BookItem에 "대출 이력"을 추가하려 한다. 아래 코드는 모든 도서가 이력을 공유하는 버그가 있다. 원인을 설명하고 고쳐라.class BookItem { constructor(id, title) { this.id = id; this.title = title; } addLoan(memberId) { BookItem.prototype.history.push(memberId); } } BookItem.prototype.history = [];class문법 없이Object.create와 생성자 함수만으로EBook extends BookItem과 같은 구조를 만들어라.ebook instanceof BookItem이true여야 한다.
정답
B+A false true truegreet는B.prototype에 있으므로 인스턴스 자신의 것이 아니다(Object.hasOwn은 false).in은 체인을 보므로 true.b의 프로토타입은B.prototype, 그 프로토타입이A.prototype이므로 마지막은 true.super.greet()는A.prototype.greet를this = b로 호출한다.- 원인:
history배열이BookItem.prototype에 하나만 존재하고 모든 인스턴스가 그것을 가리킨다.addLoan이 명시적으로 프로토타입에 push 하고 있어 더 확실하게 공유된다. 참조 타입은 인스턴스마다 만들어야 한다.class BookItem { history = []; constructor(id, title) { this.id = id; this.title = title; } addLoan(memberId) { this.history.push(memberId); return this.history.length; } } const a = new BookItem('B001', '가'); const b = new BookItem('B002', '나'); a.addLoan('M01'); console.log(a.history, b.history); // ['M01'] [] function BookItem(id, title, stock) { this.id = id; this.title = title; this.stock = stock; } BookItem.prototype.label = function () { return `${this.id} ${this.title}`; }; function EBook(id, title, sizeMb) { BookItem.call(this, id, title, Infinity); // super(...) 에 해당 this.sizeMb = sizeMb; } EBook.prototype = Object.create(BookItem.prototype); // 인스턴스 체인 연결 EBook.prototype.constructor = EBook; // 되돌려 놓지 않으면 constructor 가 BookItem 이 된다 Object.setPrototypeOf(EBook, BookItem); // static 체인 연결 EBook.prototype.label = function () { return BookItem.prototype.label.call(this) + ` (전자책 ${this.sizeMb}MB)`; // super.label() 에 해당 }; const ebook = new EBook('E001', '전자책판', 12); console.log(ebook.label()); // E001 전자책판 (전자책 12MB) console.log(ebook instanceof BookItem); // true console.log(ebook.constructor === EBook); // trueclass한 줄이 이 다섯 줄을 대신한다. 오래된 라이브러리 소스를 읽을 때 이 패턴이 그대로 나온다.