Devin.KR
로그인

자바스크립트 프로토타입과 클래스 - 상속이 체인으로 동작하는 방식 (JS 중급 11단원)

개발자 조회 1

이 단원에서 배우는 것

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 을 평가할 때 엔진은 이렇게 움직인다.

  1. book 자신에게 label 이 있는가 → 없다
  2. Book.prototype 에 있는가 → 있다. 이걸 쓴다
  3. (없었다면) Object.prototype 까지 올라가고, 거기도 없으면 undefined

여기서 헷갈리기 쉬운 두 이름을 구분해 둔다. prototype함수가 가진 프로퍼티이고, Object.getPrototypeOf(x)객체의 링크를 읽는 것이다. book.prototypeundefined 다. 이 둘을 섞어 쓰면 설명이 전부 꼬인다.

생성자 없이 링크만 직접 걸 수도 있다. 이쪽이 프로토타입의 본모습에 더 가깝다.

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)

labelBookItem.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(...)m1borrowed 를 만들지 않는다. 체인을 타고 올라가 프로토타입에 있는 그 배열을 찾아 거기에 넣는다. 반면 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' ...

파생 클래스에서는 thissuper() 가 만들어 준다. 그전까지 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 stocktrue 다. 체인까지 보기 때문이다. "이 객체가 직접 갖고 있는가"를 묻고 싶다면 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 값을 내보내야 할 때 쓰는 표준 통로다.

스스로 확인하기

  1. 다음 출력과 이유를 쓰라.
    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);
  2. BookItem 에 "대출 이력"을 추가하려 한다. 아래 코드는 모든 도서가 이력을 공유하는 버그가 있다. 원인을 설명하고 고쳐라.
    class BookItem {
      constructor(id, title) { this.id = id; this.title = title; }
      addLoan(memberId) { BookItem.prototype.history.push(memberId); }
    }
    BookItem.prototype.history = [];
  3. class 문법 없이 Object.create 와 생성자 함수만으로 EBook extends BookItem 과 같은 구조를 만들어라. ebook instanceof BookItemtrue 여야 한다.

정답

  1. B+A
    false true
    true
    greetB.prototype 에 있으므로 인스턴스 자신의 것이 아니다(Object.hasOwn 은 false). in 은 체인을 보므로 true. b 의 프로토타입은 B.prototype, 그 프로토타입이 A.prototype 이므로 마지막은 true. super.greet()A.prototype.greetthis = b 로 호출한다.
  2. 원인: 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'] []
  3. 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); // true
    class 한 줄이 이 다섯 줄을 대신한다. 오래된 라이브러리 소스를 읽을 때 이 패턴이 그대로 나온다.