[모던JS: Core] 프로토타입과 프로토타입 상속 (1)

KG·2021년 5월 20일
0

모던JS

목록 보기
12/47
post-thumbnail

Intro

본 포스팅은 여기에 올라온 게시글을 바탕으로 작성되었습니다.
파트와 카테고리 동일한 순서로 모든 내용을 소개하는 것이 아닌, 몰랐거나 새로운 내용 위주로 다시 정리하여 개인공부 목적으로 작성합니다.
중간중간 개인 판단 하에 필요하다고 생각될 시, 기존 내용에 추가로 보충되는 내용이 있을 수 있습니다.

프로토타입 상속

객체 지향 프로그래밍 방식에서는 흔히 상속이라는 개념을 사용한다. 대표적으로 JAVA의 경우는 클래스를 사용하여 상속을 구현한다. 상속은 기존에 있는 기능을 가져와 확장하는 경우 유용하게 사용할 수 있다. 자바스크립트에서의 객체(형)은 프로토타입 속성을 이용하여 상속을 구현할 수 있다.

ES6(ES2015)에서 class 문법이 추가되었다. 따라서 이를 사용해서도 프로토타입을 사용하는 것과 동일한 기능을 구현할 수 있다. 다만 class는 문법적인 양념(Syntax Sugar)의 기능일뿐(내부적으로 프로토타입을 이용) 자바스크립트는 여전히 프로토타입 기반 언어이다. class에 대해서는 다음 챕터에서 자세히 살펴보자.

1) [[Prototype]]

자바스크립트의 객체는 명세서에 기재되어 있는 [[Prototype]]이라는 숨김 프로퍼티를 가지고 있다. 이 프로퍼티의 값(value)은 오직 null 또는 다른 객체만을 참조할 수 있다. 이때 해당 프로퍼티가 객체를 참조하는 경우에 이 참조 대상을 프로토타입(prototype)이라고 부른다.

프로토타입의 동작 방식은 앞서 다룬 렉시컬 환경과 유사한 점이 많다. 해당 객체에서 특정 프로퍼티를 읽으려고 할 때, 객체 내부에 해당 프로퍼티가 없는 경우엔 자동으로 프로토타입으로 올라가 프로퍼티를 탐색하기 때문이다. 이러한 동작 방식을 프로토타입 상속이라고 칭한다.

[[Prototype]] 프로퍼티는 내부 프로퍼티이면서 숨김 프로퍼티이지만 다양한 방법을 통해 개발자가 값을 설정할 수 있다. 이전부터 사용하던 방식은 주로 __proto__ 라는 키워드를 사용해 값을 설정했다.

사실 __proto__는 이전 챕터에서 다룬 [[Prototype]]용 접근자 프로퍼티(getter/setter)이다. 따라서 엄연히 [[Prototype]] 프로퍼티와 __proto__는 서로 다른 프로퍼티이다. 하위 호환성 때문에 여전히 __proto__를 사용할 수는 있지만 모던JS 에서는 __proto__ 대신 함수 Object.getPrototypeOf 또는 Object.setPrototypeOf를 지원한다. 이에 대한 이슈는 조금 있다 다시 살펴보자.

let animal = {
  eats: true
};

let rabbit = {
  jumps: tre
};

rabbit.__proto__ = aminal;

console.log( rabbit.eats );	// true
console.log( rabbit.jumps );	// true

위에서 객체 rabbit의 내부 프로퍼티는 jumps: true만 존재한다. 하지만 콘솔에서 rabbit.eats 역시 정상적으로 출력되는 것을 확인할 수 있다. 이는 앞서 말했듯이 해당 프로퍼티가 내부에 없고, 연결된 프로토타입이 있는 경우 해당 프로토타입에서 프로퍼티에 접근하기 때문이다.

객체의 프로퍼티는 함수도 지정 가능하다. 따라서 프로토타입 상속을 이용해서 객체 메서드 역시 상속받아 사용할 수 있다. 이처럼 프로토타입에서 상속받은 프로퍼티를 상속 프로퍼티라고 부른다. 또한 프로토타입 상속은 마치 체인처럼 연결되어 있다고 하여 프로토타입 체인이라는 용어로도 부른다.

let animal = {
  eats: true,
  walk() {
    console.log('walking...');
  }
}

let rabbit = {
  jumps: true,
  __proto__: animal,	// animal 객체 상속
}

let longEar = {
  earLength: 10,
  __proto__: rabbit,	// rabbit 객체 상속
}

rabbit.walk();			// walking...
longEar.walk();			// walking...
console.log( longEar.jumps );	// true

rabbitanimal 객체를 상속받아 walk() 메서드를 사용할 수 있다. 또한 longEarrabbit 객체를 상속받았기 때문에 가장 상위에 있는 walk() 메서드는 물론 jumps 프로퍼티 역시 사용할 수 있다. 이를 그림으로 나타내면 다음과 같다.

이때 프로토타입 체이닝에는 다음과 같은 제약사항이 있다.

  1. 순환참조(Circular Reference)는 허용되지 않는다.
  2. __proto__의 값은 항상 객체 또는 null만 가능하다. 다른 자료형은 무시된다.
  3. 객체엔 오직 하나의 [[Prototype]]만 있을 수 있다. 두 개의 객체를 동시에 상속받을 수 없다.

2) 프로토타입 쓰기

기본적으로 프로토타입은 프로퍼티를 읽을 때만 사용한다. 즉 프로퍼티를 추가, 수정 및 제거하는 연산은 해당 객체에 직접 수행해야 한다. 이는 객체 지향형 프로그래밍에서의 메소드 오버라이딩과 일맥상통한다.

let animal = {
  eats: true,
  walk() {
    console.log("뚜벅뚜벅");
  }
};

let rabbit = {
  __proto__: animal
};

rabbit.walk = function () {
  console.log("껑충껑충");
};

rabbit.walk();	// 껑충껑충

위와 같이 rabbit 객체에 직접 walk() 메서드를 할당하는 경우엔 rabbit 객체가 참조하고 있는 프로토타입 animal에서 메서드를 빌려오지 않고 자신의 메서드를 호출한다.

그러나 접근자 프로퍼티는 setter 함수를 통해서 프로퍼티에 값을 할당하기 때문에 이 규칙이 적용되지 않는다. 왜냐하면 접근자 프로퍼티에 값을 할당하는 것은 결국 함수를 호출하여 값을 변경하는 것과 동일하기 때문이다.

let user = {
  name: "KG",
  surname: "LEE",
  
  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);	// KG LEE

admin.fullName = "SJ WOO";

console.log(admin.fullName);	// SJ WOO
console.log(user.fullName);	// KG LEE

처음 admin에서 getter(fullName)을 호출했을 때는 해당 객체에서 getter 프로퍼티가 없으므로 상위 프로토타입에서 이를 빌려온다. 또한 첫 호출시에는 admin객체 내에 namesurname 프로퍼티가 없기에 역시 상위 프로퍼티의 값을 빌려오게 된다.

그러나 admin에서 setter를 호출하는 경우에는 admin 객체 내부에 namesurname 프로퍼티를 생성하게 된다. 이는 앞서 살펴보았듯이 this런타임에 결정되기 때문이다. 즉 this는 프로토타입에도 영향을 받지 않는다. 점(.) 앞에 사용된 객체가 admin 이기 때문에 setter가 호출된 시점에서의 thisadmin이 되며, 따라서 admin 객체 내부에서 namesurname 프로퍼티를 설정하게 된다. 메서드를 객체에서 호출했든 프로토타입에서 호출했든 상관없이 this는 언제나 .앞에 있는 객체가 된다는 점을 유의하자.

따라서 객체를 하나 만들고 해당 객체에 여러 메서드를 많이 구현해 놓은 다음, 여러 객체에서 이 커다란 객체를 상속받게 하는 경우로 활용할 수 있다. 이와 같이 사용하면 메모리 사용률을 조금 더 아낄 수 있는 장점이 있다. 프로토타입은 모든 객체가 공유하고 있어서 한 번만 만들어지기 때문이다.

let worker = {
  work() {
    console.log('일 하는 중...');
  },
  
  sleep() {
    console.log('자는 중...');
  },
  
  walk() {
    console.log('걷는 중...');
  }
}

let workerA = {
  name: 'KG',
  __proto__: worker
};

let workerB = {
  name: 'SJ',
  __proto__: worker
};

workerA.work();	// 일 하는 중...
workerB.work();	// 일 하는 중...

이처럼 여러개의 workerX 객체가 메서드의 집합체인 worker 객체를 상속받게 한다면 각각의 workerX 객체에서는 일일히 메서드를 구현하지 않고 상위의 메서드를 사용할 수 있기때문에 메모리 낭비를 막을 수 있다.

3) for...in 반복문

for...in 반복문은 상속 프로퍼티 역시 순회대상에 포함시킨다. 이때 obj.hasOwnProperty(key)를 사용하면 상속 프로퍼티는 순회 대상에서 제외시킬 수 있다. 이 내장 메서드는 key에 대응하는 프로퍼티가 상속 프로퍼티가 아닌 직접 구현된 프로퍼티일 때 true를 반환하기 때문이다. 또한 키-값을 순회하는 내장 메서드 대부분은 상속 프로퍼티를 제외하고 동작한다. 따라서 Object.keys() 또는 Object.values()와 같은 메서드는 별다른 설정없이도 상속 프로퍼티는 제외하고 동작한다.

let animal = {
  eats: true,
};

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

for(let key of rabbit)
  console.log(key);	// jumps, eats

for(let key of rabbit) {
  if(rabbit.hasOwnProperty(key))
    console.log('myOwn :', key);	// jumps
  else
    console.log('상속 : ', key);		// eats
}

console.log(Object.keys(rabbit));	// jumps

이때 rabbit.hasOwnProperty(key) 와 같은 메서드는 어디서 오는 것일까? 이 역시 프로토타입 체인과 관련이 있다. 객체 리터럴 방식 또는 생성자 방식(생성자 방식은 객체 리터럴 방식과 살짝 다른 체이닝이 생기지만 자세한 내부 로직은 다음 챕터에서 다루어보자)으로 객체를 생성하게 되면, 이들은 별도로 프로토타입 지정을 하지 않는 이상 기본으로 Object를 상위의 프로토타입으로 참조하게 된다. 내장 객체인 Object에는 이미 구현된 여러 메서드가 존재하는데 그 중에 하나가 hasOwnProperty이다. 또한 모든 객체가 내장 메서드로 toString을 가지는 이유 또한 내장 객체 ObjecttoString이 구현되어 있고 이를 각 객체가 상위 프로토타입으로 참조하기 때문이다.

실제로 객체 리터럴 방식으로 객체를 생성하고 이를 콘솔창에 출력하면 __proto__ 프로퍼티에 Object가 프로토타입으로 지정되어 있는 것을 확인할 수 있다.

그런데 이들이 for...in 순회에 등장하지 않는 이유는 간단하다. 내장 객체 Object에 포함된 모든 메서드들의 enumerable 플래그 값이 false로 설정되어 있기 때문이다.

함수의 prototype 프로퍼티

객체를 생성하는 방식은 객체 리터럴 외에도 생성자 함수를 이용하는 방식이 있다. new Function()과 같이 객체를 생성할 때, 만약 Function.prototype이 객체라면 new 연산자는 Function.prototype을 사용해 새롭게 생성된 객체의 [[Prototype]]을 설정한다.

옛 버전 자바스크립트에서는 앞서 살펴본바와 같이 프로토타입에 직접 접근할 방법이 없었다. 그나마 사용할 수 있는 방법이 지금 다루는 생성자 함수의 prototype을 이용하는 방법이었기 때문에, 많은 스크립트가 해당 방법을 이용해 상속을 구현하는 것을 오늘도 살펴볼 수 있다.

이때 Function.prototype에서 prototype은 그저 Function에 정의된 일반 프로퍼티라는 점에 주의해야 한다. 앞서 다룬 프로토타입 객체와 동일한 용어를 사용하지만 생성자 함수에서 prototype은 그저 이름만 같은 일반 프로퍼티이다. 함수도 객체형이기 때문에 이와 같이 프로퍼티를 지정해줄 수 있고, 이는 그저 일반 프로퍼티로 인식되는 것이다. Function.prototype의 값은 객체 또는 null만 가능하다는 것은 동일하다.

let animal = {
  eats: true,
};

function Rabbit (name) {
  this.name = name;
}

Rabbit.prototype = animal;

let rabbit = new Rabbit("White Rabbit");
// 이 경우 rabbit.__proto__ == animal 이 된다.

console.log( rabbit.eats );	// true
                        

따라서 Rabbit.prototype = animalnew Rabbit을 호출해 만든 새로운 객체의 [[Prototype]]animal로 설정하라는 것과 동일한 의미이다.

이때 Function.prototype에서 prototype은 일반 프로퍼티이지만 new 연산자를 사용해 Function을 호출하는 경우, 즉 생성자 함수로 사용하는 경우에만 사용된다. new Function을 호출해 새롭게 만든 객체의 [[Prototype]]을 설정해주는 역할을 한다. 생성자 함수를 호출하는 경우 자바스크립트는 실제로 다음 작업을 수행한다.

let o = new Foo();

// 이는 다음의 과정으로 분해되어 실행된다.
let o = new Object();	// 먼저 객체를 생성
o.[[Prototype]] = Foo.prototype; // 함수의 prototype 연결
Foo.call(o);	// 함수에 생성된 객체를 this로 적용

만약 새로운 객체가 만들어진 후에 Function.prototype이 새로운 객체로 할당되고, 그 이후에 new Function으로 새 객체를 생성하는 경우, 새로 생성된 객체는 새롭게 할당된 객체를 [[Prototype]]으로 가지게 된다. 기존에 생성된 객체의 [[Prototype]]은 유지된다.

이때 새로운 객체로 대체되는 시점은 Function.prototype에 새로운 객체를 할당할 때만 해당된다. 즉 기존 메모리에 있는 참조를 끊고 새로운 값을 할당하는 경우이다. 만약 Function.prototype의 값을 수정만 하는 경우엔 기존에 생성된 객체들이 여전히 같은 참조를 바라보기 때문에 수정된 프로토타입을 그대로 가리키게 된다. 즉 수정과 재할당을 구분하여 생각해야 한다.

1) prototype 과 constructor 프로퍼티

개발자가 특별히 할당하지 않더라도 모든 함수는 prototype 프로퍼티를 갖는다. 이때 기본 프로퍼티인 prototypeconstructor 프로퍼티만 가지고 있는 객체를 가리키게 되는데, constructor 프로퍼티는 함수 자기 자신을 가리키고 있다.

function Rabbit () {};

/* 기본 prototype
Rabbit.prototype = { constructor: Rabbit }
*/

따라서 특별한 조작을 가하지 않았다면 Rabbit을 구현한 모든 객체에서 [[Prototype]]을 거쳐 constructor 프로퍼티를 사용할 수 있다.

function Rabbit () {};

let rabbit = new Rabbit();

console.log( rabbit.constructor === Rabbit );	// true

// 상위 프로토타입의 constructor를 빌려와 객체 생성
// new Rabbit()과 동일
let rabbit2 = new rabbit.constructor();

객체가 서드 파티 라이브러리에서 온 경우 등 객체를 만들때 어떤 생성자가 사용되었는지 명확히 파악할 수 없는 경우에 이와 같은 방식으로 유용하게 동일한 타입의 객체를 생성할 수 있다.

그러나 앞서 말했듯 prototype은 일반 프로퍼티이고, 따라서 자바스크립트는 알맞은 constructor값을 보장해주지 않는다. 함수에 기본적으로 prototype 값이 설정되는 것만 보장할 뿐, constructor에 벌어지는 모든 일은 전적으로 개발자에게 달려있다. 따라서 다음과 같이 prototype의 값을 다른 객체로 바꾼다면 이 객체는 constructor 프로퍼티가 존재하지 않으며 바꾸는 과정에서 에러 또한 발생하지 않는다.

function Rabbit () {}
Rabbit.prototype = {
  jumps: true
};

let rabbit = new Rabbit();
console.log( rabbit.constructor === Rabbit ); // false

이런 상황을 방지하고 알맞은 constructor를 유지하려먼 prototype 전체를 덮어쓰지 말고, 기본 prototype에 원하는 프로퍼티를 추가/제거 하는 방식으로 설정해야 한다. 또는 constructor 프로퍼티를 수동으로 다시 만들어주는 것 역시 대안이 될 수 있다.

function Rabbit () {}

Rabbit.prototype.jumps = true;
// 이 경우엔 Rabbit.prototype.constructor가 유지된다.

// 또는 수동으로 유지시킬 수 있다.
Rabbit.prototype = {
  constructor: Rabbit,
  jumps: true,
};

프로토타입 체인은 재귀탐색을 수행하여 모든 프로토타입 체인 전체를 탐색할 수 있다. 따라서 성능적인 이슈가 발생할 수 있는데 hasOwnProperty 메서드 등을 이용해 최적화 할 필요가 있다.

References

  1. https://ko.javascript.info/prototypes
  2. https://developer.mozilla.org/ko/docs/Web/JavaScript/Inheritance_and_the_prototype_chain
profile
개발잘하고싶다

0개의 댓글