본문 바로가기
Javascript

프로토타입 체인을 이용한 Javascript의 상속

by jewook3617 2021. 2. 21.

이전 글에서 javascript의 class와 프로토타입 체인에 대해 간단히 정리해봤습니다.

이번 글에서는 javascript가 프로토타입 체인을 이용해 어떻게 상속을 구현하는지에 대해 정리해보겠습니다.

 


TL;DR

  • javascript에서는 프로토타입 체인을 이용해 상속이 구현됨.
  • extends 키워드를 통해 클래스의 상속 관계를 만들 수 있음.
  • 상속 관계가 되면 자식 클래스의 프로토타입은 부모 클래스의 프로토타입을 가지게 됨으로써 프로토타입 체인이 만들어짐.
  • 인스턴스에서 프로퍼티에 접근하려고 할 때 프로토타입 체인을 따라 상위 프로토타입으로 거슬러 올라가면서 프로퍼티를 찾음.
  • 따라서 부모 클래스와 자식 클래스에 같은 이름의 프로퍼티가 있을 때 자식 클래스에 있는 프로퍼티에 접근하게 됨.(가려짐 효과)
  • 메서드가 아닌 프로퍼티는 프로토타입 체인에 포함되지 않기 때문에 super() 함수를 constructor() 의 최상단에서 호출함으로써 계층관계를 만들어 줌.

 

프로토타입 체인

const obj = {};

obj.name  // undefined

위 코드처럼 obj 객체의 name 프로퍼티에 접근하려고 할 때 javascript가 어떻게 name 프로퍼티를 찾는지 알아보겠습니다.

우선 obj 객체 내부에 name 프로퍼티가 존재하는지 찾습니다. obj 객체 내부에 name 프로퍼티가 존재하지 않으면 obj 객체의 프로토타입에서 name 프로퍼티를 찾습니다. obj 객체의 프로토타입에도 name 프로퍼티가 존재하지 않으면 obj 객체의 프로토타입의 프로토타입에서 name 프로퍼티를 찾습니다. 이런 방식으로 최상위 프로토타입까지 name 프로퍼티를 찾다가 name 프로나 메서드에 접근하려고 할 때 객체안에 해당 프로퍼티나 메서드가 존재하지 않으면 obj 객체에서 name 프로퍼티에 접근할 수 없다고 판단하고 obj.name의 값은 undefined가 됩니다.

이렇게 프로토타입이 연결되어 있는 것을 프로토타입 체인이라고 부릅니다. 프로토타입 체인이 있기 때문에 상위 프로토타입으로 계속 거슬러 올라가며 프로퍼티를 찾을 수 있는 것입니다.

우리가 배열을 선언하고 map, reduce 같은 array 메서드들을 따로 정의하지 않아도 사용할 수 있는 것도 프로토타입 체인 덕분입니다. 모든 배열은 Array.prototype을 프로토타입으로 가지고 있고 map, reduce 같은 array 메서드들은 Array.prototype에 정의되어 있기 때문에 모든 배열에서 array method 들을 사용할 수 있습니다.

 

javascript의 상속

class Vehicle {
  constructor() {
    this.passengers = [];
    console.log("Vehicle created");
  }
  addPassenger(p) {
    this.passengers.push(p);
  }
}

class Car extends Vehicle {
  constructor() {
    super();
    console.log("Car created");
  }
  deployAirbags() {
    console.log("BWOOSH!");
  }
}

const c = new Car();
// "Vehicle created"
// "Car created"

c.addPassenger("jony");

c.passengers;    // ["jony"]

javascript에서는 extends 키워드를 통해 부모 클래스와 자식 클래스의 관계를 만들 수 있습니다. 위 코드에서는 Car 클래스 뒤에 extends 키워드를 붙여 Vehicle 클래스를 상속받게 했습니다. 

위 코드를 실행시켰을 때 어떤 식으로 프로토타입 체인이 만들어지는지 그림으로 나타내보겠습니다.

우선 클래스가 생성되면 클래스의 프로토타입에는 클래스의 메서드가 저장됩니다. 그리고 new 키워드를 통해 인스턴스를 생성하면 클래스의 프로토타입이 인스턴스의 __proto__ 에 저장됩니다. 그리고 extends 키워드를 통해 상속을 받으면 상위 클래스의 프로토타입이 하위 클래스의 프로토타입의 __proto__에 저장됩니다. 이렇게 프로토타입 체인이 만들어지는 것입니다.

Car 클래스가 Vehicle 클래스를 상속 받았기 때문에 Vehicle 클래스의 프로토타입이 Car 클래스의 프로토타입의 __proto__ 에 저장이 됩니다. 그리고 new 키워드를 통해 인스턴스 c 를 생성하면 c.__proto__ 에 Car 클래스의 프로토타입이 저장됩니다. Car 클래스의 프로토타입과 Vehicle 클래스의 프로토타입은 프로토타입 체인으로 연결되어 있으므로 인스턴스 c 에서 Car 클래스의 프로토타입과 Vehicle 클래스의 프로토타입에 모두 접근할 수 있습니다. 참고로 프로토타입 체인의 최상위 프로토타입은 항상 Object.prototype 입니다.

그럼 c.addPassenger() 메서드를 호출할 때 어떤 일들이 일어나는지 알아보겠습니다. c.addPassenger() 메서드를 호출하면 우선 인스턴스 c 내부에 addPassenger() 메서드가 있는지 찾습니다. 인스턴스 c 내부에는 addPassenger() 메서드가 없기 때문에 c.__proto__ 에서 addPassenger() 메서드를 찾습니다. Car 클래스의 프로토타입인 c.__proto__ 내부에도 addPassenger() 메서드가 없기 때문에 c.__proto__.__proto__ 내부에서 addPassenger() 메서드를 찾습니다. Vehicle 클래스의 프로토타입인 c.__proto__.__proto__ 내부에는 addPassenger() 메서드가 있기 때문에 addPassenger() 메서드를 실행합니다. 이렇게 프로토타입 체인을 타고 상위 프로토타입으로 거슬러 올라가며 메서드를 찾기 때문에 Car 클래스의 인스턴스인 c에서 Vehicle 클래스의 메서드를 사용할 수 있게 된 것입니다.

만약 Car 클래스에도 addPassenger() 메서드가 있고 c.addPassenger() 메서드를 호출하면 어떻게 될까요? 인스턴스 c에서부터 addPassenger() 메서드를 찾기 시작할 때 Vehicle 클래스의 프로토타입보다 Car 클래스의 프로토타입을 먼저 찾아보기 때문에 Car 클래스의 addPassenger() 메서드가 실행되고 Vehicle 클래스의 addPassenger() 메서드는 실행되지 않습니다. 이것을 가려짐 이라고 부릅니다. 

이번엔 Car 클래스의 constructor() 내부에 있는 super() 에 대해 알아보겠습니다. class 내부에서 사용되는 super는 부모 클래스를 가리킵니다. 따라서 super() 함수를 실행하게 되면 부모 클래스의 constructor() 가 실행됩니다. Car 클래스의 constructor() 는 아래와 같은 코드가 되는 것입니다.

class Car extends Vehicle {
  constructor() {
    this.passengers = [];
    console.log("Vehicle created");
    console.log("Car created");
  }
}

그렇기 때문에 Vehicle 클래스에서 생성되는 passengers 프로퍼티가 인스턴스 c에 생기게 된 것입니다.

메서드가 아닌 프로퍼티들은 프로토타입에 저장되지 않기 때문에 프로토타입 체인에 속해있지 않습니다. 그래서 super() 함수를 항상 constructor() 최상단에서 실행시켜야합니다. 그렇지 않으면 프로퍼티 간의 계층관계가 모호해지기 때문입니다.

Car 클래스의 constructor() 내부에서도 passengers 프로퍼티를 생성한다고 가정해보겠습니다. 

class Vehicle {
  constructor() {
    this.passengers = [];
  }
}

class Car1 extends Vehicle {
  constructor() {
    super();
    this.passengers = null;
  }
}

class Car2 extends Vehicle {
  constructor() {
    this.passengers = null;
    super();
  }
}

const c1 = new Car1();
const c2 = new Car2();

c1.passengers;   // null
c2.passengers;   // []

super() 함수의 호출 위치를 정해놓지 않으면 위 코드처럼 super() 함수의 호출 위치에 따라 passengers 의 값이 달라집니다. Car1 클래스에서는 super() 의 passengers가 Car1 클래스의 passengers에 의해 가려졌고 Car2 클래스에서는 Car2의 passengers 가 super() 의 passengers에 의해 가려졌습니다. 우리는 부모 클래스의 프로퍼티가 자식 클래스의 프로퍼티에 의해 가려지길 기대하기 때문에 super() 함수는 항상 constructor() 의 최상단에서 호출되어야합니다.

이 글에 쓰인 코드들은 O'REILLY의 '러닝 자바스크립트' 책에 있는 코드를 참고했습니다.

'Javascript' 카테고리의 다른 글

Javascript의 실행환경과 동작방식  (0) 2021.04.25
Javascript의 iterable, iterator, generator  (0) 2021.04.06
Javascript의 class  (0) 2021.02.08
Javascript의 "this"  (0) 2021.01.21
클로저(closure)와 즉시실행함수(IIFE)  (0) 2020.01.26