Study JavaScript 0627 - 프로토타입 상속

변승훈·2022년 6월 27일
0

Study JavaScript

목록 보기
35/43

프로토타입 상속

개발을 하다 보면 기존에 있는 기능을 가져와 확장해야 하는 경우가 생긴다.

사람에 관한 프로퍼티와 메소드를 가진 user라는 객체가 있는데, user와 상당히 유사하지만 약간의 차이가 있는 admin과 guest 객체를 만들어야 한다고 가정해 보자. 이때 user의 메소드를 복사하거나 다시 구현하지 않고 user에 약간의 기능을 얹어 admin과 guest 객체를 만들려면 어떻게 해야 할까?

javascript 언어의 고유 기능인 프로토타입 상속(prototypal inheritance) 을 이용하면 위와 같은 생각을 실현할 수 있다.

[[Prototype]]

javascript의 객체는 명세서에서 명명한 [[Prototype]]라는 숨김 프로퍼티를 갖는다. 이 숨김 프로퍼티 값은 null이거나 다른 객체에 대한 참조가되는데, 다른 객체를 참조하는 경우 참조 대상을 '프로토타입(prototype)'이라 부른다.

프로토타입의 동작 방식은 '신비스러운’면이(?) 있다. object에서 프로퍼티를 읽으려고 하는데 해당 프로퍼티가 없으면 javascript는 자동으로 프로토타입에서 프로퍼티를 찾기 때문이다.
프로그래밍에선 이런 동작 방식을 '프로토타입 상속’이라 부른다.
언어 차원에서 지원하는 편리한 기능이나 개발 테크닉 중 프로토타입 상속에 기반해 만들어진 것들이 많다.

[[Prototype]] 프로퍼티는 내부 프로퍼티이면서 숨김 프로퍼티이지만 다양한 방법을 사용해 개발자가 값을 설정할 수 있다.

아래 예시처럼 특별한 이름인 __proto__을 사용하면 값을 설정할 수 있다.

let animal = {
  eats: true
};
let rabbit = {
  jumps: true
};

rabbit.__proto__ = animal;

__proto__[[Prototype]]용 getter·setter이다!
__proto__[[Prototype]]과 다르다.
비교적 근래에 작성된 스크립트에선 __proto__대신 함수 Object.getPrototypeOfObject.setPrototypeOf을 써서 프로토타입을 획득(get)하거나 설정(set)한다.

객체 rabbit 에서 프로퍼티를 얻고싶은데 해당 프로퍼티가 없다면, javascript는 자동으로 animal이라는 객체에서 프로퍼티를 얻는다.

let animal = {
  eats: true
};
let rabbit = {
  jumps: true
};

rabbit.__proto__ = animal; // animal이 rabbit의 프로토타입이 되도록 설정

// 프로퍼티 eats과 jumps를 rabbit에서도 사용할 수 있게 되었습니다.
console.log( rabbit.eats ); // true (**)
// rabbit.eats 프로퍼티를 읽으려 했는데, rabbit엔 eats라는 프로퍼티가 없다. 이때 javascript는 [[Prototype]]이 참조하고 있는 객체인 animal에서 eats를 얻어낸다.
console.log( rabbit.jumps ); // true

이제 “rabbit의 프로토타입은 animal이다.” 혹은 "rabbit은 animal을 상속받는다."라고 말 할 수 있게 되었다.

프로토타입을 설정해 준 덕분에 rabbit에서도 animal에 구현된 유용한 프로퍼티와 메소드를 사용할 수 있게 되었다. 이렇게 프로토타입에서 상속받은 프로퍼티를 '상속 프로퍼티(inherited property)'라고 한다.

상속 프로퍼티를 사용해 animal에 정의된 메소드를 rabbit에서 호출해보자!

let animal = {
  eats: true,
  walk() {
    alert("동물이 걷습니다.");
  }
};

let rabbit = {
  jumps: true,
  __proto__: animal
};

// 메소드 walk는 rabbit의 프로토타입인 animal에서 상속받았다.
rabbit.walk(); // 동물이 걷습니다.

아래 그림과 같이 프로토타입(animal)에서 walk를 자동으로 상속받았기 때문에 rabbit에서도 walk를 호출할 수 있게 되었다.

프로토타입 체인은 지금까지 살펴본 예시들보다 길어질 수 있다.

let animal = {
  eats: true,
  walk() {
    alert("동물이 걷습니다.");
  }
};

let rabbit = {
  jumps: true,
  __proto__: animal
};

let longEar = {
  earLength: 10,
  __proto__: rabbit
};

// 메소드 walk는 프로토타입 체인을 통해 상속받았다.
longEar.walk(); // 동물이 걷습니다.
console.log(longEar.jumps); // true (rabbit에서 상속받음)

프로토타입 체이닝엔 두 가지 제약사항이 있다.

  1. 순환 참조(circular reference)는 허용되지 않는다. __proto__를 이용해 닫힌 형태로 다른 객체를 참조하면 에러가 발생한다.
  2. __proto__의 값은 객체나 null만 가능하며, 다른 자료형은 무시된다.

여기에 더하여 객체엔 오직 하나의 [[Prototype]]만 있을 수 있다는 당연한 제약도 있다. 객체는 두 개의 객체를 상속받지 못한다~

프로토타입은 읽기 전용이다

프로토타입은 프로퍼티를 읽을 때만 사용한다.

프로퍼티를 추가, 수정하거나 지우는 연산은 객체에 직접 해야한다.

객체 rabbit에 메소드 walk를 직접 할당해 보자!

let animal = {
  eats: true,
  walk() {
    /* rabbit은 이제 이 메소드를 사용하지 않습니다. */
  }
};

let rabbit = {
  __proto__: animal
};

rabbit.walk = function() {
  console.log("토끼가 깡충깡충 뜁니다.");
};

rabbit.walk(); // 토끼가 깡충깡충 뜁니다.

rabbit.walk()를 호출하면 프로토타입에 있는 메소드가 실행되지 않고, 객체 rabbit에 직접 추가한 메소드가 실행된다.

그런데 접근자 프로퍼티(accessor property)는 setter 함수를 사용해 프로퍼티에 값을 할당하므로 접근자 프로퍼티에 값을 할당((**))하면 객체(admin)에 프로퍼티(fullName)가 추가되는게 아니라 setter 함수가 호출되면서 위 예시와는 조금 다르게 동작한다.

아래 예시에서 admin.fullName이 의도한 대로 잘 작동하는지 확인해보자.

let user = {
  name: "John",
  surname: "Smith",

  set fullName(value) {
    [this.name, this.surname] = value.split(" ");
  },

  get fullName() {
    return `${this.name} ${this.surname}`;
  }
};

let admin = {
  __proto__: user,
  isAdmin: true
};

console.log(admin.fullName); // John Smith (*)

// setter 함수가 실행
admin.fullName = "Alice Cooper"; // (**)

console.log(admin.fullName); // Alice Cooper, setter에 의해 추가된 admin의 프로퍼티(name, surname)에서 값을 가져옴
console.log(user.fullName); // John Smith, 본래 user에 있었던 프로퍼티 값

프로토타입 user엔 getter 함수 get fullName이 있기 때문에 (*)로 표시한 줄에선 get fullName이 호출되었다. 마찬가지로 프로토타입에 이미 setter 함수(set fullName)가 정의되어 있기 때문에 (**)로 표시한 줄의 할당 연산이 실행되면 객체 user에 프로퍼티가 추가되는게 아니라 프로토타입에 있는 setter 함수가 호출된다.

this가 나타내는 것

this는 프로토타입에 영향을 받지 않는다.

메소드를 객체에서 호출했든 프로토타입에서 호출했든 상관없이 this는 언제나 . 앞에 있는 객체다.

admin.fullName=으로 setter 함수를 호출할 때, thisuser가 아닌 admin이 된다.

객체 하나를 만들고 여기에 메소드를 많이 구현해 놓은 다음, 여러 객체에서 이 커다란 객체를 상속받게 하는 경우가 많기 때문에 이런 특징을 잘 알아야 한다! 상속받은 메소드를 사용하더라도 객체는 프로토타입이 아닌 자신의 상태를 수정한다.

예시를 통해 좀 더 알아봅시다. ‘메소드 저장소’ 역할을 하는 객체 animalrabbit이 상속받게 해보자~

rabbit.sleep()을 호출하면 객체 rabbitisSleeping프로퍼티가 추가된다.

// animal엔 다양한 메소드가 있다.
let animal = {
  walk() {
    if (!this.isSleeping) {
      alert(`동물이 걸어갑니다.`);
    }
  },
  sleep() {
    this.isSleeping = true;
  }
};

let rabbit = {
  name: "하얀 토끼",
  __proto__: animal
};

// rabbit에 새로운 프로퍼티 isSleeping을 추가하고 그 값을 true로 변경한다.
rabbit.sleep();

console.log(rabbit.isSleeping); // true
console.log(animal.isSleeping); // undefined (프로토타입에는 isSleeping이라는 프로퍼티가 없다.)

위 코드를 실행한 후, 객체의 상태를 그림으로 나타내면 다음과 같다.

rabbit 말고도 bird, snake 등이 animal을 상속받는다고 해보자. 이 객체들도 rabbit처럼 animal에 구현된 메소드를 사용할 수 있다. 이때 상속받은 메소드의 thisanimal이 아닌 실제 메소드가 호출되는 시점의 점(.) 앞에 있는 객체가 된다. 따라서 this에 데이터를 쓰면 animal이 아닌 해당 객체의 상태가 변화한다.

이를 통해 우리는 메소드는 공유되지만, 객체의 상태는 공유되지 않는다고 결론 내릴 수 있다.

for…in 반복문

for..in은 상속 프로퍼티도 순회대상에 포함시킨다.

let animal = {
  eats: true
};

let rabbit = {
  jumps: true,
  __proto__: animal
};

// Object.keys는 객체 자신의 키만 반환합니다.
console.log(Object.keys(rabbit)); // jumps

// for..in은 객체 자신의 키와 상속 프로퍼티의 키 모두를 순회합니다.
for(let prop in rabbit) console.log(prop); // jumps, eats

obj.hasOwnProperty(key)를 이용하면 상속 프로퍼티를 순회 대상에서 제외할 수 있다. 이 내장 메소드는 key에 대응하는 프로퍼티가 상속 프로퍼티가 아니고 obj에 직접 구현되어있는 프로퍼티일 때만 true를 반환한다.

obj.hasOwnProperty(key)를 응용하면 아래 예시에서처럼 상속 프로퍼티를 걸러낼 수 있고, 상속 프로퍼티만을 대상으로 무언가를 할 수도 있다.

let animal = {
  eats: true
};

let rabbit = {
  jumps: true,
  __proto__: animal
};

for(let prop in rabbit) {
  let isOwn = rabbit.hasOwnProperty(prop);

  if (isOwn) {
    console.log(`객체 자신의 프로퍼티: ${prop}`); // 객체 자신의 프로퍼티: jumps
  } else {
    console.log(`상속 프로퍼티: ${prop}`); // 상속 프로퍼티: eats
  }
}

위 예시의 상속 관계를 그림으로 나타내면 다음과 같다. rabbitanimal을, animalObject.prototype을, Object.prototypenull을 상속받고 있다.
참고로 animalObject.prototype를 상속받는 이유는 animal을 객체 리터럴 방식으로 선언하였기 때문이다.

그림을 보면 for..in 안에서 사용한 메소드 hasOwnPropertyObject.prototype.hasOwnProperty에서 왔다는 것을 확인할 수 있다.

그런데 상속 프로퍼티인 eats는 console 창에 출력되는데, hasOwnProperty는 출력되지 않았다.
그 이유는 hasOwnProperty는 열거 가능한(enumerable) 프로퍼티가 아니기 때문이다.

※ key-value를 순회하는 메소드 대부분은 상속 프로퍼티를 제외하고 동작한다.
프로토타입에서 상속받은 프로퍼티는 제외하고, 해당 객체에서 정의한 프로퍼티만 연산 대상에 포함한다.

profile
잘 할 수 있는 개발자가 되기 위하여

0개의 댓글