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

KG·2021년 5월 20일
0

모던JS

목록 보기
13/47
post-thumbnail

Intro

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

네이티브 프로토타입

prototype 프로퍼티는 자바스크립트 내부에서 this와 마찬가지로 광범위하게 사용된다. 모든 내장 생성자 함수에서 prototype 프로퍼티를 사용한다.

1) Object.prototype

let obj = {};
console.log(obj);	// "[object Object]"

객체 obj는 아무 프로퍼티도 지정하지 않은 빈 객체로 선언했다. 그러나 콘솔창에 해당 객체를 출력할 때는 문자열로 변환된 [object Object]가 출력되는 것을 확인할 수 있다. 앞서 살펴보았듯이 객체를 생성하면 프로토타입 체인에 의해 Object.prototype을 참조하게 되고 해당 공간에 관련 메서드들이 정의되어 있다. new Object() 방식 또는 객체 리터럴 {...} 방식 모두, 새로이 생성된 객체의 [[Prototype]]이 앞서 살펴본 바와 같이 Object.prototype을 참조한다.

객체를 생성하는 방식은 크게 생성자 함수와 객체 리터럴로 구분할 수 있다. 두 방식은 프로토타입 체인 과정이 살짝 다르다.

  1. 객체 리터럴
let obj = { a: 1 };

// obj ---> Object.prototype ---> null
  1. new Function
function F() {};
let obj = new F();

// obj ---> Function.prototype ---> Object.prototype ---> null

두 방식 모두 상위에 Object.prototype이 연결되어 있으므로 관련 메서드를 모두 가져올 수 있다.

let obj = {};

// obj.__proto__ === Object.prototype
// obj.toString === obj.__proto__.toString
// obj.toString === obj.prototype.toString

2) 다른 내장 프로토타입

Array, Date, Function을 비롯한 여러 내장 객체들 역시 프로토타입에 메서드를 저장한다. 예를 들어 배열을 [1, 2, 3, ...]과 같이 선언해도 기본 new Array() 생성자가 내부에서 호출되기 때문에 Array.prototype이 선언된 배열 [1, 2, 3, ...]의 프로토타입이 된다. 또한 Array.prototype에는 여러 배열 내장 메서드들이 정의되어 있다. 이러한 내부 동작으로 메모리 효율을 높일 수 있다.

자바스크립트 명세서에 의하면 모든 내장 프로토타입의 꼭대기엔 Object.prototype이 있어야 한다고 규정하고 있다. 이러한 관계를 그림으로 나타내면 다음과 같다.

체인이 여러 단계에 걸쳐 형성되기 때문에 체인 상의 프로토타입에는 중복 메서드가 있을 수 있다. 이는 앞서 살펴보았듯이 가장 가까이에 위치한 메서드를 참고한다. 예를 들어 배열에서 toString 메서드를 호출하면, 이는 Array.prototype에 있는 toString을 호출하게 된다.

3) 원시값

문자열과 숫자, 불린값 등은 객체가 아닌 원시자료형으로 구분된다. 하지만 자바스크립트는 내장 생성자 String, Number, Boolean 등을 임시 래퍼 객체로 사용하여 원시값들이 프로퍼티에 접근할 수 있도록 지원하고 있다. 임시 래퍼 객체는 사용을 마친 후에는 자동으로 제거된다. 즉 자바스크립트 엔진이 관련 최적화를 담당한다.

nullundefined의 경우에는 대응하는 래퍼 객체가 없다. 따라서 프로토타입은 물론 메서드와 프로퍼티 모두 사용 불가하다.

4) 네이티브 프로토타입 변경

네이티브 프로토타입, 또는 빌트인 프로토타입을 개발자 임의로 수정이 가능하다. 이에 대해 문법적인 제약은 존재하지 않는다. 가령 String.prototype에 메서드를 개발자 마음대로 추가할 수 있다.

String.prototype.show = function () {
  alert(this);
};

"BOOM!".show();

그러나 이와 같이 기존 프로토타입에 새로운 내장 메서드를 추가하는 것은 추천하는 방법이 아니다.

일단 프로토타입은 전역으로 영향을 미치기 때문에 이를 조작하게 되면 충돌이 날 가능성이 높아진다. 충돌이 나는 경우에는 기존 선언된 메서드의 값이 나중에 선언된 메서드로 대체되기 때문에 원하는 값의 출력을 보장할 수 없다. 이러한 기법은 Monkey patching으로 불리며 캡슐화를 망가뜨리는 안 좋은 예이다.

그러나 모던 프로그래밍에서 네이티브 프로토타입 변경을 허용하는 경우가 딱 하나 있다. 바로 폴리필을 만드는 경우이다. 명세서에 정의되어 있으나 현재 사용하는 브라우저 및 환경이 최신 문법 또는 기능을 지원하지 않는다면 네이티브 프로토타입을 변형하여 폴리필을 구현할 수 있다.

// repeat 메서드가 구현이 안 되어 있는 경우
if (!String.prototype.repeat) {
  // String 내장 객체에 프로토타입 기능 추가
  String.prototype.repeat = function(n) {
    ...
  };
}

5) 프로토타입에서 빌려오기

앞서 call/apply 메서드를 다루며 메서드릴 빌려오는 내용을 다룬 적 있다. 종종 네이티브 프로토타입에 구현된 메서드를 이용하고 싶은 경우가 생길 수 있다. 다음과 같은 유사 배열 객체가 있을때 Array.prototype에서 메서드를 빌려와보자.

let obj = {
  0: 'hello',
  1: 'wolrd',
  length: 2,
};

obj.join = Array.prototype.join;

console.log( obj.join(',') );	// hello,world

이는 문제없이 동작하는 것을 확인할 수 있다. 내장 메서드 join은 배열을 대상으로 하는 것이 아니라, 인덱스와 length 프로퍼티를 가지고 있는지만 확인하기 때문이다. 즉 유사 배열도 해당 메서드의 대상이 된다.

다만 만약 obj 객체가 이미 다른 객체를 상속받고 있었다면 이 같은 방법은 불가하다. 자바스크립트는 오직 단일 상속만을 허용하기 때문이다.

프로토타입 메서드

이전 챕터에서 __proto__ 프로퍼티는 다소 구식이기 때문에 더는 사용하지 않는 것이 좋다. 표준에도 관련 내용이 명시되어 있다. 모던JS는 프로토타입에 접근하기 위해 다음과 같은 메서드를 지원한다.

  • Object.create(proto, [descriptors])
    • [[Prototype]]proto를 참조하는 빈 객체를 생성.
    • 이때 프로퍼티 설명자 descriptors를 추가로 전달 가능
  • Object.getPrototypeOf(obj)
    • obj[[Prototype]]을 반환
  • Object.setPrototypeOf(obj, proto)
    • obj[[Prototype]]proto가 되도록 설정

따라서 기존에 사용했던 __proto__ 프로퍼티는 위의 메서드로 완전히 대체할 수 있고, 이 방식을 추천한다.

let animal = {
  eats: true,
};

// 프로토타입이 animal인 rabbit 객체 생성
let rabbit = Object.create(animal);

// 설명자를 선택적으로 전달하는 것도 가능
let rabbit2 = Object.crate(animal, {
  jumps: {
    value: true,
  }
});
console.log( rabbit2.jumps );	// true
                         

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

console.log( Object.getPrototypeOf(rabbit) );	// animal

Object.setPrototypeOf(rabbit, {});  // 프로토타입 {}으로 변경

Object.create 메서드를 통해 for...in을 사용하는 것보다 더 효과적으로 객체 복사를 할 수 있다. 다만 이 복사 역시 얕은 복사이다.

let clone = Object.create(Object.getPrototypeOf(obj), Object.getOwnPropertyDescriptors(obj));

이와 같이 복제를 하는 경우엔 obj의 모든 프로퍼티를 포함한 사본이 만들어진다. 즉 열거 가능한 프로퍼티, 열거 불가한 프로퍼티, 데이터 프로퍼티, 접근자 프로퍼티 및 [[Prototype]]까지 모두 복제된다.

비하인드 스토리

앞서 살펴본 바와 같이 [[Prototype]]을 다룰 수 있는 방법은 매우 다양하다. 이는 역사적인 이유가 있다.

  • 생성자 함수의 prototype 프로퍼티는 아주 예전부터 기능을 수행하고 있었다.
  • 2012년 표준에 Object.create가 추가되었다. 그러나 주어진 프로토타입을 사용해 객체 생성은 가능했지만, 프로토타입을 얻거나 설정하는 것을 불가했다. 따라서 브라우저는 비표준 접근자인 __proto__ 프로퍼티를 구현했다.
  • 2015년(ES6)에 Object.setPrototypeOfObject.getPrototypeOf가 표준에 추가되며 비표준 접근자 __proto__의 기능을 대체할 수 있게 되었다. 그러나 이 시점에서 __proto__는 사실상 표준으로 이미 널리 쓰이고 있었다.

이전의 방식들은 하위 호환성을 위해 제거되지 않았고, 따라서 여러가지 방식으로 프로토타입에 접근 및 설정이 가능하게 되었다. 그렇다면 __proto__ 프로퍼티는 단순히 구식이기 때문에 사용을 권고하지 않는 것일까?

아주 단순한(순수 사전식) 객체

객체는 키-값 쌍을 저장할 수 있는 연관 배열이다. 이때 사소한 결함이 생기는데, 바로 키 값으로 __proto__를 사용할 수 없다는 점이다.

let obj = {};

let key = "__proto__";
obj[key] = 'some value';

console.log( obj[key] );	// [object Object]
// some value가 출력되지 않는다!

만약 사용자 혹은 개발자가 키 값으로 __proto__를 지정하는 경우엔 이 값이 제대로 할당되지 않는 것을 확인할 수 있다. 개발자의 입장에서는 __proto__ 프로퍼티가 특별한 값으로 취급되고 있다는 것을 알고 있기 때문에 심각한 버그라고 다가오지 않을 수 있다. 실제로도 위 예시에서는 그리 치명적이지 않다.

그러나 만약 기존에 할당된 프로토타입이 있었다면, __proto__ 접근자 프로퍼티에 의해 새로 입력된 some value값으로 프로토타입이 변경되게 된다. 프로토타입이 변경되면서 예측 불가한 치명적인 버그가 발생할 수 있다.

이러한 문제를 해결하기 위한 가장 간단한 방법은 바로 Map을 사용하는 것이다. Map에서는 __proto__가 키 값으로 들어와도 인덱스 밑에 키-값으로 저장되기 때문에 자체 프로퍼티인 __proto__와 충돌이 발생하지 않는다.

그러나 Map 역시 ES6(ES2015)에 추가된 비교적 최신 자료형이다. 이전에는 이와 같은 문제를 해결하기 위해 Object.create 메서드를 활용했다.

앞서 이야기한 바와 같이 __proto__는 객체의 프로퍼티가 아니라 Object.prototype의 접근자 프로퍼티이다.

그렇기 때문에 obj.__proto__를 읽거나 쓰는 동작은 이에 대응하는 getter/setter가 프로토타입에서 호출이 되고 [[Prototype]]을 가져오거나 설정하게 된다. 즉 __proto__[[Prototype]]에 접근하기 위한 방법이지 [[Prototype]] 그 자체가 아니라는 점을 다시 한번 주의하자.

Object.create(null)을 이용하면 프로토타입이 없는 빈 객체를 생성할 수 있다. 즉 [[Prototype]]null인 객체를 만든 것이다. 이 경우에는 __proto__ getter/setter를 당연히 상속받지 않는다. 때문에 이 시점에서 __proto__는 평범한 데이터 프로퍼티처럼 취급될 수 있다. 그러나 이러한 방식엔 단점이 있는데 바로 Object.prototype에 정의된 내장 메서드를 이용할 수 없다는 것이다. 이는 당연한 결과인데, 해당 객체는 Object.prototype을 상위 프로토타입으로 가지고 있지 않기 때문이다. 이러한 객체를 아주 단순한(very plain) 또는 순수 사전식(pure dictionary)객체라고 부른다.

연관 배열로만 사용한다면 이러한 단점은 크게 작용하지 않는다. 객체 관련 메서드는 대부분 내장 메서드로 접근하기 보다는 Object.keys(obj)와 같이 내장 객체를 사용하여 접근하는 경우가 많기 때문이다.

References

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

0개의 댓글