자바스크립트 프로토타입 정리 - 기초부터 완성까지, 프런트엔드 5장

khakiD·2022년 7월 6일
0


5장 목차

  1. 프로토타입
  2. 스코프
  3. 호이스팅
  4. 클로저
  5. 모듈




TL;DR

  • 자바스크립트의 상속 개념은 프로토타입이라는 것을 기반으로 구현된다.
  • 클래스 기반의 객체 지향 언어들과 달라서 생소하고 어려울 수 있다.
  • ES6부터는 syntatic sugar인 class 를 통해 직관적으로 구현할 수 있다. ⇒ 즉, 프로토타입의 원리는 이해하고 class를 통해 상속 개념을 구현하자.





5.1. 프로토타입

  • 자바스크립트에서는 프로토타입을 기반으로 객체 지향의 상속 개념을 구현한다.
  • 모든 객체는 자신의 부모 역할을 하는 프로토타입 객체의 참조 링크를 가진다.
  • 참조 링크를 통해 프로토타입으로부터 프로퍼티나 메소드를 상속받는다.
  • 또한, 프로토타입 역시 또 다른 상위 프로토타입으로부터 프로퍼티나 메소드를 상속받을 수 있다.

5.1.1. 프로토타입과 프로토타입 체인

객체의 프로토타입은 참조 링크 형태로 [[Prototype]] 내부 프로퍼티에 저장된다. 참조 링크 형태로 저장되기에 동일한 프로토타입을 상속한 객체는 모두 같은 프로퍼티와 메소드를 공유한다.

  • Obj1, Obj2는 동일 프로토타입 Parent.prototype을 상속받고 있다.
  • **Parent.prototype**의 정보를 [[Prototype]] 프로퍼티에 참조 링크 형태로 저장한다.
  • 참조 링크 형태로 저장되므로 Parent.prototype의 모든 변경 사항은 참조하고 있는 각 객체에 공유된다.

[[Prototype]]과 proto 프로퍼티

[[Prototype]]은 자바스크립트 엔진 내부에서만 사용하는 숨겨진 프로퍼티지만 크롬, 파이퍼폭스와 같은 모던 브라우저에서 ‘proto’ 라는 프로퍼티로 접근할 수 있다. 이것은 표준 명세가 아니고 공식적인 방법도 아니므로 가급적 사용하지 않는 것이 좋다.

만약 [[Prototype]]에 접근하고 싶다면 표준 메소드인 Object.getPrototypeOf()를 사용하는 것이 좋다. 추후 자바스크립트 엔진에서 proto 프로퍼티를 더 이상 지원하지 않을 가능성도 있으므로 프로토타입을 찾기 위해서는 위의 메소드 사용을 권장한다.



5.1.1.1. 프로토타입 체인

const obj = {
	name: 'javascript'
};
console.log(obj.toString()); // '[Object object]'

위의 예제의 obj 객체에서 toString() 메소드가 정상적으로 호출된다. obj 객체 내에 toString() 메소드가 없는데, 어떻게 함수 호출이 가능할까?

✍️ 이것은 프로토타입 체인이라는 개념 때문에 가능하다.

프로토타입 체인은 상위 프로토타입과 연쇄적으로 연결된 구조를 의미한다. 프로퍼티나 메소드에 접근하기 위해 이 연결 구조를 따라 차례대로 검색하는 것을 프로토타입 체이닝이라고 한다.


📝 위 예제는 아래와 같은 순서로 동작했다.

  1. obj 객체의 toString() 메소드를 호출하기 위해 obj 객체의 프로퍼티나 메소드를 검색
  2. 1번에서 메소드를 찾지 못했다면 프로토타입 체인을 통해 상위 프로토타입에서 toString() 메소드를 검색
  3. 상위 프로토타입에서 toString() 메소드를 찾았으므로 해당 메소드를 호출

obj 객체에 name 프로퍼티 외에 ‘__proto__’ 프로퍼티가 있음을 알 수 있다. 이것이 숨겨진 내부 [[Prototype]] 프로퍼티, 즉 프로토타입을 가리킨다. obj 객체에 toString() 메소드가 없기 때문에 프로토타입 체인을 통해 프로토타입에 있는 toString()을 찾아 호출했다.



5.1.1.2. 최상위 프로토타입

Object.prototype은 프로토타입 체인의 최상위 프로토타입이다. 모든 객체가 가지고 있는 프로토타입 체인의 끝은 모두 Object.prototype이다.

  • Parent.prototype의 프로토타입은 최상위 프로토타입인 Object.prototype과 연결되어 있다.
  • 예제의 객체 뿐 아니라 배열, 함수 객체, 랩퍼 객체 등 모든 객체는 이러한 프로토타입 구조를 가진다.


프로토타입의 생성

객체의 부모가 되는 프로토타입은 객체가 생성되는 시점에 설정된다. obj와 같이 객체 리터럴(const obj = { key : value })로 생성한 모든 객체는 Object.prototype을 프로토타입으로 설정한다.



5.1.1.3. 다양한 객체의 프로토타입

객체 리터럴이 아닌 배열과 같이 내장된 객체의 프로토타입은 독특하게 각자 자신의 프로토타입을 따로 정의하고 있다.

const arr = [];

이는 Object.prototype과는 다른 것을 알 수 있다. 배열 객체는 프로토타입으로 **Array.prototype**이라는 고유의 객체가 설정된다. 배열의 내장 메소드(concat, filter, forEach…)들이 정의되어 있다. 배열 메소드(arr.length 등)를 호출할 수 있었던 것은 프로토타입 체인을 통해 Array.prototype의 메소드들이 검색되어 호출되었기 때문이다.

Array.prototype 내장 프로토타입 또한 최상위 프로토타입으로 Object.prototype을 가진다. 자바스크립트에는 배열 외에 랩퍼 객체, 함수, 정규식과 같은 내장 객체들이 있고, 이러한 객체들 역시 고유의 프로토타입을 가지고 있기에 다양한 메소드와 프로퍼티를 사용할 수 있다.

종점은 모두 Object.prototype이라는 것을 기억하자.



5.1.2. 프로토타입과 생성자 함수

함수에는 prototype이라는 프로퍼티가 있고, 일반적 함수에는 prototype 프로퍼티를 사용할 일이 없으나 new 키워드로 만든 생성자 함수에서는 특별한 역할을 한다. 여기서 주의할 점이 있다.

  • 객체의 프로토타입을 가리키는 링크 [[Prototype]]과 함수의 prototype 프로퍼티는 다르다.
  • 함수의 prototype 프로퍼티는 일반적인 객체의 프로퍼티이며, 참조 링크가 아니다.


5.1.2.1. 객체 생성과 함수의 prototype 프로퍼티

✍️ 생성자 함수로 생성된 객체는 생성자 함수의 prototype 프로퍼티가 프로토타입([[Prototype]])으로 설정된다. 아래의 Tobacco 생성자 함수의 예제를 확인하자.

function Tobacco(type) {
	this.type = type;
}
const tobacco = new Tobacco('ESSE');
console.log(Tobacco.prototype === tobacco.__prototype__); // **true**

✍️ tobacco 객체의 프로토타입은 Tobacco() 생성자 함수의 prototype 프로퍼티인 Tobacco.prototype을 참조 링크로 가리키고, 이 객체는 Object.prototype을 프로토타입으로 가리킨다. 모든 생성자 함수를 통해 생성된 객체는 위와 같이 상속을 구현한다.



5.1.2.2. 함수의 prototype 프로퍼티와 프로토타입의 관계

  • 함수의 prototype 프로퍼티는 constructor 프로퍼티 하나만 가진 객체이다.
  • constructor 프로퍼티는 자신과 연결된 생성자 함수를 가리킨다.
  • 생성자 함수와 생성자 함수의 prototype 프로퍼티는 서로 상호 참조하는 관계이다.

🤷‍♂️ 이 말인 즉슨,
Tobacco()의 prototype 프로퍼티Tobacco.prototype의 constructor() 프로퍼티서로 참조하고, tobacco 객체는 프로토타입 체인으로 인해 Tobacco.prototype의 cunstructor 프로퍼티로 접근이 가능하다.

위의 개발자 도구 화면에서 **Tobacco.prototype****constructor** 프로퍼티가 Tobacco() 생성자 함수를 참조하고 있는 것을 확인할 수 있다.



5.1.3. 프로토타입의 확장과 상속

객체의 부모인 프로토타입에 메소드가 프로퍼티를 추가하는 방법은 일반 객체처럼 동적으로 프로퍼티나 메소드를 추가 및 삭제하면 된다. 변경된 프로퍼티는 실시간으로 프로토타입 체인을 통한 검색에 반영된다.

function Tobacco(type) {
	this.type = type;
}
Tobacco.prototype.inhale = function() {
	console.log('Puff Puff');
}

const tobacco = new Tobacco('ESSE');
console.log(tobacco.inhale()); // 'Puff Puff';

❗ Tobacco.prototype에 inhale() 메소드를 추가하여 tobacco 객체에서 inhale() 메소드를 호출할 수 있게 되었다.

📌 객체 생성 이후에 프로토타입의 프로퍼티 수정은 지양해야 한다. 모든 객체가 프로토타입을 공유하므로 수정 및 삭제하게되면 혼란과 버그를 초래할 수 있다. 즉, 동적으로 객체를 수정하는 것은 위험이 동반된다.

내장 프로토타입 Array.prototype 또는 Object.prototype도 수정이 가능은 하지만, 웬만해서는 절대 수정하지 않는 것을 권장한다.



5.1.3.1. 프로토타입을 사용한 상속 구현

생성된 객체와 부모 프로토타입의 링크에 영향을 주지 않게끔 구현해야 해서 까다로울 수 있다. 아래 예제의 Esse() 생성자 함수는 Tobacco() 생성자 함수를 상속받는다.

// 상속 구현 with Prototype
function Tobacco() {
	console.log('initialize Tobacco');
}

Tobacco.prototype.inhale = function() {
	console.log('Puff Puff');
}

Tobacco.prototype.exhale = function() {
	console.log('Paaaahhhh');
}

function Esse(type){
	this.type = type;
}

// 상속 함수 inherit 구현
function inherit(parent, child) {
	function F() {};
	F.prototype = parent.prototype;
	child.prototype = new F();
	child.prototype.constructor = child;
}

inherit(Tobacco, Esse);
esse = new Esse('ChangeOne');
console.log(esse);

📌 코드 설명

  • F() 생성자 함수의 prototype 프로퍼티로 부모 생성자 함수 Tobacco()의 prototype 프로퍼티를 설정한다.
  • F() 생성자 함수로 생성한 빈 객체로 자식 생성자 함수 Esse()의 prototype 프로퍼티를 설정한다.
  • 이러면 Esse() 생성자 함수를 통해 생성된 esse 객체에서 Tobacco.prototype에 접근 가능하다.
  • 자식 클래스의 prototype 프로퍼티가 F() 생성자 함수로 변경되었기 때문에, inherit() 함수의 마지막 줄에서 자식 클래스의 constructor 프로퍼티를 다시 설정하여 올바르게 참조토록 한다.

🤷‍♂️ 주의할 점

  • 왜 상위 클래스 객체를 바로 갖다 박지 않고, F() 생성자 함수를 만들어 상속할까?
    • 필요하지 않은 프로퍼티까지 상속을 받게 되니까다. 우리가 상속받고 싶은 것은 Tobacco.prototype에 정의된 프로퍼티나 메소드인데, 특정 tobacco 객체의 프로퍼티나 메소드가 아니다.
    • 위 코드의 inherit() 상속 함수를 사용하지 않고 다음과 같은 코드를 사용하면,
      const tobacco = new Tobacco()
      function Esse(type) { this.type = type; }
      Esse.prototype = tobacco;
      tobacco.newProperty = 'new property';
      console.log(Esse.prototype.newProperty); // 'new property'
      이처럼 상속받을 필요 없는 부모 클래스 특정 객체의 prototype 프로퍼티까지 상속받게 된다. 이러한 문제를 방지하고자 F() 생성자 함수를 사용해서 부모 클래스의 인스턴스와 자식 클래스의 인스턴스를 독립적으로 만들어 사용한다.

생성자 빌려쓰기

위의 예제 코드에는 ‘자식 클래스의 인스턴스를 생성할 때 부모 클래스(Tobacco()) 생성자 함수가 호출되지 않는다. ’는 문제점이 있다. 예제에서 console.log(initialize Tobacco); 코드가 실행되지 않는다는 뜻이다. 이 문제는 자식 생성자 함수에서 apply() 메소드를 사용하여 해결한다.

function Esse(type) {
	Tobacco.apply(this, arguments);
	this.type = type;
}
  • 이러면 새로 생성된 객체로 this 바인딩이 변경되므로 Tobacco() 생성자 함수에서 이 객체를 대상으로 동작을 수행하게 된다.
  • 이런 방식으로 자식 클래스의 인스턴스를 생성할 때 부모 클래스의 생성자를 호출하는 것을 생성자 빌려 쓰기라고 한다.

더 쉽게 객체의 프로토타입을 지정하는 방법들

  • Obejct.create() 메소드
  • Object.setPrototypeOf() 메소드
    위 메소드들은 굳이 inherit() 상속 함수를 직접 구현할 필요 없이 더 쉽게 객체의 프로토타입을 지정할 수 있게 도와준다.
    꼭 따로 살펴보도록 하자.


5.1.4. class

프로토타입을 통한 클래스와 상속의 구현은 직관적이지 않고 번거롭다. 이러한 문제를 해결하기 위해 ES6부터 class 키워드라는 새로운 syntatic sugar가 등장했다. class 키워드를 통해 좀 더 쉽고 편하게 클래스와 상속을 구현할 수 있다. 개꿀~

class Tobacco {
  constructor() {
    console.log('initialize Tobacco');
  }
  
  inhale() {
    console.log('Puff Puff');
  }
  
  exhale() {
    console.log('Paaaaaah');
  }
}

console.log(new Tobacco());

📌 주의할 점

  • class 몸체 내부에 기존 생성자 함수의 prototype 프로퍼티에 정의했던 확장 프로퍼티나 메소드, constructor() 생성자 메소드를 정의한다.
  • class 역시 함수이고, 내부적으로 프로토타입을 기반으로 동작하며 프로토타입 체인을 통해 프로퍼티를 검색한다.
  • prototype 프로퍼티 역시 존재한다.
  • 문법만 변경되었을 뿐, 동작 원리는 프로토타입을 통한 클래스와 상속 구현과 동일하다는 것을 기억하자.
  • class 문법과 생성자 함수를 사용한 인스턴스 생성은 다른 부분이 존재한다. -> ECMAScript2015의 Class 명세 참고


5.1.4.1. class 문법을 사용한 상속

class Tobacco {
  constructor() {
    console.log('initialize Tobacco');
  }
  
  inhale() {
    console.log('Puff Puff');
  }
  
  exhale() {
    console.log('Paaaaaah');
  }
}

class Esse extends Tobacco {
  cunstructor(type) {
    super();
    this.type = type;
  }
}

console.log(new Esse('ChangeOne'));
  1. 자식 클래스를 생성하면서 extends 키워드 뒤에 상속받을 부모 클래스를 정의해준다.
  2. constructor() 생성자 메소드에서 super()를 호출한다.

📌 주의할 점

  • constructor() 생성자 메소드에서 반드시 this를 사용하기 전에 super()를 먼저 호출해야한다.
    • 부모 클래스의 constructor() 메소드에서 반환한 객체를 자식 클래스에서 사용하기 때문에 super() 호출이 먼저다.
    • 그렇지 않을 경우 ReferenceError가 발생한다.


5.1.4.2. 정적 메소드와 private 접근 제한자

와 이제 뭔가 학교 다니면서 배웠던 객체 지향 프로그래밍 느낌이 난다...!

  • static 키워드를 사용해서 정적 메소드를 정의
  • 정적 메소드는 특정 인스턴스에 묶이는 것이 아니므로 this가 아닌 클래스 이름을 사용하여 접근 가능
  • 정적 메소드는 특정 형태의 인스턴스를 생성하는 팩토리 함수를 정의할 때 많이 사용
class Marlboro extends Tobacco {
  constructor(type) {
    super();
    this.type = type;
  }
  
  static CreateVista() {
    return new Marlboro('Vista');
  }
}

Private 접근 제한자

클래스의 프로퍼티와 메소드들은 기본적으로 모두 public이다.

  • # 이라는 접두사를 추가하면 private 클래스 필드를 선언할 수 있다.
  • 외부에서 private 필드에 접근하는 경우 SyntaxError가 발생한다.
class Marlboro extends Tobacco {\
  #company;
  constructor(type) {
    super();
    this.type = type;
    this.#company = 'Philip Morris';
  }
}
const marlboro_red = new Marlboro('Red');
console.log(marlboro_red.type); // 'Red';
console.log(marlboro_red.#company); // Uncaught SyntaxError: Private field '#company' must be declared ...
profile
(이해 못했음) (개인 블로그로 이전)

0개의 댓글