Study JavaScript 0628 - 프로토타입 메소드와 __proto__가 없는 객체, 함수의 prototype 프로퍼티

변승훈·2022년 6월 28일
0

Study JavaScript

목록 보기
36/43

1. 프로토타입 메서드와 proto가 없는 객체

어제 공부했던 __proto__는 브라우저를 대상으로 개발하고 있다면 다소 구식이기 때문에 더는 사용하지 않는 것이 좋다고 한다. 표준에도 관련 내용이 명시되어있다.

대신 아래와 같은 최신 메소드들을 사용하는 것이 더 좋다.

  • Object.create(proto, [descriptors]): [[Prototype]]proto를 참조하는 빈 객체를 만듭니다. 이때 프로퍼티 설명자를 추가로 넘길 수 있습니다.
  • Object.getPrototypeOf(obj): obj[[Prototype]]을 반환합니다.
  • Object.setPrototypeOf(obj, proto): obj[[Prototype]]proto가 되도록 설정합니다.

이제 아래의 예시처럼 위의 메소드들 사용해보자!

let animal = {
  eats: true
};

// 프로토타입이 animal인 새로운 객체를 생성합니다.
let rabbit = Object.create(animal);

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

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

Object.setPrototypeOf(rabbit, {}); // rabbit의 프로토타입을 {}으로 바꾼다

Object.create에는 프로퍼티 설명자를 선택적으로 전달할 수 있다. 설명자를 이용해 새 객체에 프로퍼티를 추가해보자!

let animal = {
  eats: true
};

let rabbit = Object.create(animal, {
  jumps: {
    value: true
  }
});

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

설명자는 프로퍼티 플래그와 설명자에서 배운 것과 같은 형태를 사용하면 된다. https://velog.io/@toffg6450/Study-JavaScript-0624-%EA%B0%9D%EC%B2%B4-%ED%94%84%EB%A1%9C%ED%8D%BC%ED%8B%B0-%EC%84%A4%EC%A0%95

Object.create를 사용하면 for..in을 사용해 프로퍼티를 복사하는 것보다 더 효과적으로 객체를 복제할 수 있다.

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

1. [[Prototype]]의 비하인드 스토리

[[Prototype]]을 다루는 방법은 다양하다. 이유가 무엇일까?
이는 역사적인 이유가 있다고 한다.

  1. 생성자 함수의 "prototype" 프로퍼티는 아주 오래전부터 사용되고 있었다.
  2. 그런데 2012년, 명세서에 Object.create가 추가되었다. Object.create를 사용하면 주어진 프로토타입을 사용해 객체를 만들 수 있긴 하지만, 프로토타입을 얻거나 설정하는것은 불가능했다. 그래서 브라우저는 비표준 접근자인 __proto__를 구현해 언제나 프로토타입을 얻거나 설정할 수 있도록 했다.
  3. 이후 2015년에 Object.setPrototypeOfObject.getPrototypeOf가 표준에 추가되면서 __proto__와 동일한 기능을 수행할 수 있게 되었다. 그런데 이 시점엔 __proto__를 사용하는 곳이 너무 많아서 __proto__는 사실상 표준(de-facto standard)이 되버렸다. 이 내용은 명세서의 부록 B(Annex B)에 추가되어 있다. 부록 B의 내용은 브라우저 이외의 호스트 환경에선 선택사항이라는것을 의미힌다.

이런 역사적인 이유 때문에 지금은 여러 방식을 원하는 대로 쓸 수 있게되었다고 한다.

※ 속도가 중요하다면 기존 객체의 [[Prototype]]을 변경하지 말자.
원한다면 언제나 [[Prototype]]을 얻거나 설정할 수 있다. 기술적 제약이 있는 건 아니지만 대개는 객체를 생성할 때만 [[Prototype]]을 설정하고 이후엔 수정하지 않는다.
javascript 엔진은 이런 시나리오를 토대로 최적화되어 있다.
Object.setPrototypeOfobj.__proto__=를 써서 프로토타입을 그때그때 바꾸는 연산은 객체 프로퍼티 접근 관련 최적화를 망치기 때문에 성능에 나쁜 영향을 미친다.

2. 아주 단순한 객체

알다시피 객체는 key-value 쌍이 있는 연관 배열로도 사용할 수 있다.

그런데 커스텀 사전을 만드는 것과 같이 사용자가 직접 입력한 key를 가지고 객체를 만들다 보면 사소한 결함이 발견되는데, 다른 문자열은 괜찮지만 "__proto__"라는 문자열은 키로 사용할 수 없다는 결함이다.

let obj = {};

let key = prompt("입력하고자 하는 key는 무엇인가요?", "__proto__");
obj[key] = "...값...";

console.log(obj[key]); // "...값..."이 아닌 [object Object]가 출력

프롬프트 창에 __proto__를 입력하면 값이 제대로 할당되지 않는것을 확인할 수 있다. __proto__프로퍼티는 특별한 프로퍼티이기에 놀랄만한 일은 아닐 수 있다.

__proto__는 항상 객체이거나 null이어야 합니다. 문자열은 프로토타입이 될 수 없다.

다시 돌아와서 key가 무엇이 되었든, key-value 쌍을 저장하려고 하는데 key가 __proto__일 때 값이 제대로 저장되지 않는 건 명백한 버그다.

예시에선 이 버그가 그리 치명적이진 않지만 할당 값이 객체일 때는 프로토타입이 바뀔 수 있다는 치명적인 버그가 발생할 수 있다. 프로토타입이 바뀌면 예상치 못한 일이 발생할 수 있기 때문이다.

개발자들은 대개 프로토타입이 중간에 바뀌는 시나리오는 배제한 채 개발을 진행하며, 이런 고정관념 때문에 프로토타입이 중간에 바뀌면서 발생한 버그는 그 원인을 쉽게 찾지 못한다. 서버 사이드에서 javascript를 사용 할 땐 이런 버그가 취약점이 되기도 한다.

toString을 비롯한 내장 메서드에 할당을 할 때도 같은 이유 때문에 예상치 못한 일이 일어날 수 있다.

우리는 이 문제가 일어나지 않게 예방하기 위해 객체 대신 맵을 사용하자

그런데 javascript를 만든 사람들이 아주 오래전부터 이런 문제를 고려했기 때문에 객체를 써도 문제를 예방할 수 있다.

그렇다면 객체를 써서 문제를 예방하는 방법을 알아보자!

아시다시피__proto__는 객체의 프로퍼티가 아니라 Object.prototype의 접근자 프로퍼티이다.

그렇기 때문에 obj.__proto__를 읽거나 쓸때는 이에 대응하는 getter, setter가 프로토타입에서 호출되고 obj[[Prototype]]을 통해 getter와 setter에 접근한다.

이 절을 시작할 때 언급한 것처럼 __proto__[[Prototype]]에 접근하기 위한 수단이지 [[Prototype]] 그 자체가 아닌 것이다!

이제 간단한 트릭을 써 객체가 연관 배열의 역할을 다 할 수 있도록 해보자!

let obj = Object.create(null);

let key = prompt("입력하고자 하는 key는 무엇인가요?", "__proto__");
obj[key] = "...값...";

console.log(obj[key]); // "...값..."이 제대로 출력

Object.create(null)을 사용해 프로토타입이 없는 빈 객체를 만들어 보았다. [[Prototype]]null인 객체를 만든 것이다.

Object.create(null)로 객체를 만들면 __proto__ getter와 setter를 상속받지 않습니다. 이제 __proto__는 평범한 데이터 프로퍼티처럼 처리되므로 버그 없이 예시가 잘 동작하게 된다.

이렇게 프로토타입이 없는 빈 객체는 ‘아주 단순한(very plain)’ 혹은 ‘순수 사전식(pure dictionary)’ 객체라고 부른다. 일반 객체 {...} 보다 훨씬 단순하다.

참고로 아주 단순한 객체는 내장 메서드가 없다는 단점이 있다. toString같은 메서드를 사용할 수 없다.

let obj = Object.create(null);

console.log(obj); // Error: Cannot convert object to primitive value (toString이 없음)

객체를 연관 배열로 쓸 때는 이런 단점이 문제가 되진 않는다.

객체 관련 메서드 대부분은 Object.keys(obj) 같이 Object.something(...) 형태를 띈다. 이 메서드들은 프로토타입에 있는 게 아니기 때문에 '아주 단순한 객체’에도 사용할 수 있다.

let chineseDictionary = Object.create(null);
chineseDictionary.hello = "你好";
chineseDictionary.bye = "再见";

console.log(Object.keys(chineseDictionary)); // hello,bye

2. 함수의 prototype 프로퍼티

우리는 리터럴 뿐만 아니라 new F()와 같은 생성자 함수로도 새로운 객체를 만들 수 있다는 걸 배운 바 있다.

이번에는 생성자 '함수’를 사용해 객체를 만든 경우에 프로토타입이 어떻게 동작하는지에 대해 알아보자!
생성자 함수로 객체를 만들었을 때 리터럴 방식과 다른점은 생성자 함수의 프로토타입이 객체인 경우에 new 연산자를 사용해 만든 객체는 생성자 함수의 프로토타입 정보를 사용해 [[Prototype]]을 설정한다는 것이다.

생성자 함수(F)의 프로토타입을 의미하는 F.prototype에서 "prototype"F에 정의된 일반 프로퍼티라는 점에 주의하자.
F.prototype에서 "prototype"은 바로 앞에서 배운 '프로토타입’과 비슷하게 들리겠지만 이름만 같을 뿐 실제론 다른 우리가 익히 알고있는 일반적인 프로퍼티이다.

예시:

let animal = {
  eats: true
};

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

Rabbit.prototype = animal;

let rabbit = new Rabbit("흰 토끼"); //  rabbit.__proto__ == animal

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

Rabbit.prototype = animal은 "new Rabbit을 호출해 만든 새로운 객체의 [[Prototype]]animal로 설정하라."는 것을 의미한다.

그림으로 나타내면 다음과 같다.

여기서 가로 화살표는 일반 프로퍼티인 "prototype"을, 세로 화살표는 [[Prototype]]을 나타냅니다. 세로 화살표는 rabbitanimal을 상속받았다는 것을 의미한다.

F.prototypenew F를 호출할 때만 사용된다.
new F를 호출할 때 만들어지는 새로운 객체의 [[Prototype]]을 할당해준다.
새로운 객체가 만들어진 후에 F.prototype 프로퍼티가 바뀌면(F.prototype = <another object>) new F를 호출해 만드는 또 다른 새로운 객체는 another object를 [[Prototype]]으로 갖게 된다. 다만, 기존에 있던 객체의 [[Prototype]]은 그대로 유지된다.

1.함수의 디폴트 프로퍼티 prototype과 constructor 프로퍼티

모든 함수는 기본적으로 1"prototype"1 프로퍼티를 갖는다.

디폴트(지정하지 않아도 자동으로 선택되는 무언가) 프로퍼티 "prototype"constructor 프로퍼티 하나만 있는 객체를 가리키는데, 여기서 constructor 프로퍼티는 함수 자신을 가리킨다.

이 관계를 코드와 그림으로 나타내면 다음과 같습니다.

function Rabbit() {}

/* 디폴트 prototype
Rabbit.prototype = { constructor: Rabbit };
*/

예시를 실행해 직접 확인해보자!

function Rabbit() {}
// 함수를 만들기만 해도 디폴트 프로퍼티인 prototype이 설정된다.
// Rabbit.prototype = { constructor: Rabbit }

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

특별한 조작을 가하지 않았다면 new Rabbit을 실행해 만든 토끼 객체 모두에서 constructor 프로퍼티를 사용할 수 있는데, 이때 [[Prototype]]을 거친다.

function Rabbit() {}
// 디폴트 prototype:
// Rabbit.prototype = { constructor: Rabbit }

let rabbit = new Rabbit(); // {constructor: Rabbit}을 상속받음

console.log(rabbit.constructor == Rabbit); // true ([[Prototype]]을 거쳐 접근함)

constructor 프로퍼티는 기존에 있던 객체의 constructor를 사용해 새로운 객체를 만들때 사용할 수 있다.

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

let rabbit = new Rabbit("흰 토끼");

let rabbit2 = new rabbit.constructor("검정 토끼");

이 방법은 객체가 있는데 이 객체를 만들 때 어떤 생성자가 사용되었는지 알 수 없는 경우(객체가 서드 파티 라이브러리에서 온 경우 등) 유용하게 쓸 수 있다.

"constructor"를 이야기 할 때 가장 중요한 점은
javascript는 알맞은 "constructor" 값을 보장하지 않는다는 점이다.

함수엔 기본으로 "prototype"이 설정된다는게 전부다. "constructor"와 관련해서 벌어지는 모든 일은 전적으로 개발자에게 달려있다.

함수에 기본으로 설정되는 "prototype" 프로퍼티 값을 다른 객체로 바꿔 무슨일이 일어나는지 살펴보자. new를 사용해 객체를 만들었지만 이 객체에 "constructor"가 없는 것을 확인할 수 있다.

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

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

이런 상황을 방지하고 constructor의 기본 성질을 제대로 활용하려면 "prototype"에 뭔가를 하고 싶을 때 "prototype" 전체를 덮어쓰지 말고 디폴트 "prototype"에 원하는 프로퍼티를 추가, 제거해야 한다.

function Rabbit() {}

// Rabbit.prototype 전체를 덮어쓰지 말고
// 원하는 프로퍼티가 있으면 그냥 추가한다.
Rabbit.prototype.jumps = true
// 이렇게 하면 디폴트 프로퍼티 Rabbit.prototype.constructor가 유지된다.

실수로 "prototype"을 덮어썼다 하더라도 constructor 프로퍼티를 수동으로 다시 만들어주면 constructor를 다시 사용할 수 있다.

Rabbit.prototype = {
  jumps: true,
  constructor: Rabbit
};
// 수동으로 constructor를 추가해 주었기 때문에 우리가 알고 있던 
// constructor의 특징을 그대로 사용할 수 있다. 
profile
잘 할 수 있는 개발자가 되기 위하여

0개의 댓글