프로토타입

📚프런트엔드 레벨을 높이는 자바스크립트 퀴즈북📚 | Chapter07. 클래스와 프로토타입

자바스크립트는 프로토타입 기반 언어임.
따라서 모든 객체는 다른 객체를 참조하는 내부 링크를 가지며, 이를 통해 속성과 메서드를 상속받음.

자바스크립트를 프로토타입 기반 언어로 설계한 이유는,

  1. 웹스크립트 언어로서 단순한 설계가 필요했기 때문

    초기 자바스크립트는 Java 같은 복잡한 OOP언어가 아니라 웹페이지에 간단한 동적 기능을 추가하는 스크립트 언어를 목표로 했음.

    클래스 기반 언어의 특징은, 1. 클래스의 정의가 필요하고, 2. 타입 구조가 존재하며, 3. 상속 구조를 설계해야 하고, 4. 컴파일 단계가 존재한다는 점임.

    반면, 프로토타입 기반 언어의 특징은, 1. 객체만 있으면 되고, 2. 클래스를 정의하지 않아도 객체 생성이 가능하며, 3. 런타임에 구조 변경이 가능하다는 점임.

    즉, 프로토타입을 기반으로 하는 것이 웹 스크립트 언어에 더 적합한 단순한 모델이었기 때문임.

  2. Self 언어의 객체 상속 모델에 영향을 받았기 때문

    Self 언어는 1. 클래스가 없고, 2. 모든 것이 객체이며, 3. 객체가 다른 객체를 직접 상속한다는 특징을 가지는데,
    자바스크립트의 프로토타입 체인은 여기서 “객체 ← 객체” 형태의 상속구조를 착안함.

  3. 객체 중심 동적 언어 철학 때문

  4. 런타임에서의 구조 변경이 가능한 유연성 때문

  5. 메모리 효율 (메서드 공유) 때문

    프로토타입 체인은 메서드를 공유하기 때문에, 인스턴스를 많이 생성하더라도 메서드는 프로토타입 객체 하나에만 존재하기 때문에 메모리 사용에 효율적임.

프로토타입 (Prototype)

어떤 객체가 다른 객체로부터 속성과 메서드를 상속받기 위해 참조하는 부모 객체

프로토타입 체인 (Prototype Chain)

객체에서 속성/메서드를 찾을 때, 없으면 프로토타입을 따라 연결된 객체들을 순서대로 탐색하는 구조

자바스크립트 엔진은 객체의 속성이나 메서드를 찾을 때,

  1. 해당 객체에서 탐색
  2. 없으면 프로토타입 체인을 따라 [[Prototype]]객체(부모 객체)에서 탐색
  3. 찾지 못하고 null에 도달하면 undefined를 반환함.

이러한 방식 덕분에 객체는 다른 객체로부터 속성이나 메서드를 상속받아 공유하고 재사용할 수 있음.

const counter = {
	value: 3,
};
// counter 객체에는 hasOwnProperty라는 메소드가 명시되어 있지 않지만, 
// 리터럴로 생성한 객체는 언제나 Object.prototype 객체를 상속하므로 사용가능함.
console.log(Object.hasOwn(counter, "value")); // true
console.log(counter.hasOwnProperty("value")); // true
// 객체의 프로토타입은 Object.getPrototypeOf 정적 메서드로 가져올 수 있음.
const counterPrototype = Object.getPrototypeOf(counter); 
console.log(counterProtoType === Object.prototype)

프로토타입의 정적 메서드

프로토타입 메서드가 정적으로 구현된 경우는

  • Object.getProtytypeOf: 객체의 프로토타입을 가져올 수 있음.

  • Object.hasOwn, hasOwnProperty : 객체에 해당 속성이 직접 존재하는지 확인함.

  • Object.setPrototypeOf: 이미 생성된 객체의 프로토타입을 동적으로 변경할 때 사용함.

    const counter = {
    	value: 3,
    }
    console.log(Object.getPrototypeof(counter)); // ...
    Object.setPrototypeOf(counter, null);
    console.log(Object.getPrototypeof(counter)); // null
    console.log(counter.hasOwnProperty("value")) // TypeError
    console.log(Object.hasOwn("value")); // true
    

    그러나 자바스크립트 엔진의 최적화를 방해하거나, 다른 객체에 예상치 못한 영향을 줄 수 있기 때문에 프로토타입을 동적으로 변경하는 것은 지양해야 함.

  • Object.create: 객체를 생성할 때 프로토타입을 직접 설정할 수 있음.

    const counterProto = {
    	protoValue: 7,
    };
    const counter = Object.create(counterProto, {
    	// 속성명
    	value: {
    			// 속성 서술자 (Property Descriptor)
    			value: 3 // writable(bool), enumerable(bool), configurable(bool)
    		},
    });
    
  • Object.defineProperties: 객체에 새로운 속성을 정의하거나 기존의 속성을 수정함.

    const counter = {};
    Object.defineProperties(counter, {
    	value: {
    		value: 3,
    		writable: true,
    	}
    });
    
  • Object.assign: 하나 이상의 출처 객체(source)의 열거 가능한 직접 소유 속성을 대상 객체(target)에 복사하는 정적 메서드

proto

모든 객체에 존재하며, 자신의 프로토타입을 가리키는 참조로 객체 리터럴에서 프로토타입을 정의할 때 사용함.

const counter = {
	value: 3,
	__proto__: {
		protoValue: 7,
	},
	hasOwnProperty () {
		return false;
	}
};
console.log(counter.value); // 3
console.log(counter.protoValue); // 7
console.log(Object.hasOwn(counter, "value")) // true
console.log(Object.hasOwn(counter, "protoValue")) // false
console.log(counter.hasOwnProperty("value")); // false

위의 코드는 proto 문법을 사용하여 protoValue라는 속성을 가진 객체를 counter 객체의 프로토타입으로 설정함.

생성자 함수

function Counter (value) {
	this.value = value
}
const counter = new Counter(3)

console.log(Object.getPrototypeOf(counter) === Counter.prototype))
console.log(counter.constructor === Counter)

console.log(Counter.prototype === counter.__proto__)

생성자 함수의 prototype 속성(Counter.prototype)은 생성자 함수로 생성된 객체들의 프로토타입이고, 생성자 함수 자체(Counter)의 프로토타입은 Function.prototype이므로 다름.

프로토타입과 상속

function Position (x, y) {
	this.x = x;
	this.y = y;
}
Position.prototype.toString = function () {
	return `${this.x}, ${this.y}`;
}

생성자의 상속

function Rect (x, y, w, h) {
	Position.call(this, x, y);
	this.w = w;
	this.h = h;
}
Object.setPrototypeof(Rect.prototype, Position.protoType)

const rect = new Rect(1, 2, 3, 4);
console.log(rect.toString()) // 1, 2

Position.call(this, x, y)를 호출하는 이유는 부모 생성자 함수를 실행하여 x, y값을 초기화 하기 위해서임.

  • 해당 코드가 없는 경우, rect.toString()은 'undefined, undefined’를 출력함.
  • 해당 코드를 this.x = x; this.y = y;로 대체한 경우, rect.toString()은 정상적으로 동작하지만 부모 생성자 함수의 로직을 중복 작성하게 되므로 비효율적인 코드임.

Object.setPrototypeof는 위에서 언급한 바와 같이 자바스크립트 엔진의 성능 이슈로 사용을 지양해야 하는데 Object.assign으로 대체할 수 있음.

// Object.setPrototypeof(Rect.prototype, Position.protoType)

// 1. 상속 전에 기존 prototype을 백업하고,
const rectPrototype = Rect.Prototype; 
// 2. 상속을 설정하고,
Rect.prototype = Object.create(Position.prototype);
// 3. 기존 prototype을 복사 후,
Object.assign(Rect.prototype, rectPrototype);
// 4. constructor 복사
Rect.prototype.constructor = Rect;

그러나 Object.assign 또한 얕은 복사dlau, enumerable 속성만 복사하기 때문에 getter/setter, non-enumerable 속성은 누락되는 문제가 있음.

이로 인해, ES6이후로는 그냥 extends를 사용함.

속성과 메서드의 상속

상위 프로토타입의 메서드를 하위 프로토타입에서 재정의하는 것을 “메서드 오버라이딩(method overriding)”이라고 하며, 이는 프로토타입 체인에서 Rect.protytpe의 toString 메서드가 Position.prototype의 toString 메서드를 가리는 현상인 속성 섀도잉을 이용한 것임.

  • 오버라이딩 (overriding)

    상위 프로토타입에서 메서드를 하위 프로토타입에서 재정의하는 것, 내부 동작은 속성 섀도잉으로 동작함.

  • 속성 섀도잉(shadowing)

    자식 객체가 부모 객체와 동일한 이름의 속성을 직접 소유할 때, 자식의 속성이 상위의 속성을 가리는 현상

function Rect (x, y, w, h) {
	Position.call(this, x, y);
	this.w = w;
	this.h = h;
}
Object.setPrototypeof(Rect.prototype, Position.protoType)
Rect.prototype.toString = function () {
	return `${this.x}, ${this.y}, ${this.w}, ${this.h}`;
};

const rect = new Rect(1, 2, 3, 4);
console.log(rect.toString()) // 1, 2, 3, 4

정적 속성과 정적 메서드의 상속

객체의 프로토타입 체인을 정의하는 것만으로는 정적 속성이 상속되지 않음. 따라서 정적 속성을 상속하려면 자식 생성자 함수의 프로토타입을 부모 생성자 함수로 정의해야 함.

  • 정적 속성(static property)

    객체의 프로토타입이 아닌 생성자 함수 자체에 직접 정의된 속성

Position.ZERO_POSITION = new Position(0, 0);
Position.toArray = function () {
	return [this.x, this.y]
}

function Rect (x, y, w, h) {
	Position.call(this, x, y);
	this.w = w;
	this.h = h;
}
// Rect가 Position을 상속
Object.setPrototypeof(Rect.prototype, Position.prototype)
// Position의 정적 속성까지 상속
Object.setProtoTypeOf(Rect, Position)

클래스 (Class)

프로토타입 기반 상속을 더 명확하고 간결하게 표현하기 위한 Syntactic Sugar로, 내부적으로는 여전히 프로토타입으로 동작함.

Q. 자바스크립트에 클래스 문법이 있음에도, 프로토타입을 다뤄야 하는 경우?
기본적인 OOP는 클래스로 충분함.

class User {  
  constructor(name) {  
    this.name = name;  
  }  
  
  sayHi() {  
    console.log(this.name);  
  }  
}  

클래스로 위와 같이 선언했을 때, 내부적으로는

function User(name) {  
  this.name = name;  
}  
  
User.prototype.sayHi = function () {  
  console.log(this.name);  
};  

이와 같이 동작함. 즉, 클래스는 프로토타입 wrapper와 같음.

그럼에도 객체 위임 패턴(Object.create), 팩토리 함수 기반 객체 생성, 프로토타입 없는 객체 생성(Object.create(null)), 그리고 라이브러리나 메타 프로그래밍과 같은 상황에서는 클래스 없이도 프로토타입 기반 상속 구조를 직접 사용하는 경우가 있음.

  1. 객체 상속을 직접 만들 때

    객체 합성 (Object Composition)이나, 프프로토타입 위임 (Prototype Delegation)의 상황

  2. 기존 객체의 프로토타입을 확인할 때

Object.getPrototypeOf(obj)  
  1. built-in 객체를 확장할 때
Array.prototype.last = function () {  
  return this[this.length - 1];  
};  
// 혹은  
String.prototype.capitalize = function () {  
  return this[0].toUpperCase() + this.slice(1);  
};  
  1. 라이브러리나 프레임워크 내부에서

    프레임워크 내부 구현이나 라이브러리 설계, polyfill, proxy 같은 곳에서 많이 등장함.

  2. 함수 기반의 상속 속성을 이해해야 할 때 (레거시 코드에 대한 이해가 필요할 때)

인스턴스 (Instance)

클래스로 생성한 객체, 인스턴스 또한 생성자 함수와 마찬가지로 new 키워드를 붙여 호출하여 생성하며, new 키워드 없이 호출하면 에러남.

const position = new Position(1, 2); // Error, TDZ
class Position {
	constructor (x, y) {
		this.x = x;
		this.y = y;
	}
}
console.log(Position instanceof Function); // true

const position = new Position(1, 2); 
console.log(position.x, position.y); // 1 2

클래스의 인스턴스 속성은, 해당 클래스의 인스턴스마다 독립적으로 가지는 속성이기 때문에 각 인스턴스에서 개별적으로 값을 변경할 수 있음.

반면, 프로토타입 속성은, 클래스의 모든 인스턴스가 공유하는 속성이기 때문에 프로토타입 속성이 변경되면 해당 프로토타입을 참조하는 다른 모든 인스턴스에서도 변경된 값을 참조함.

클래스와 생성자 함수

/* ES5 생성자 함수 방식 */
function Animal(name) {
	this.name = name;
}

/* ES6 클래스 방식 */
class Animal {
	// 샌성자
	constructor (name) {
		this.name = name;
	}
}

consturctor 메서드는 인스턴스가 생성될 때 객체를 초기화함.

생성자 내부에서 this는 생성될 인스턴스를 가리키며 아무것도 반환하지 않으면 this 가 자동으로 반환됨.

class Animal {
	constructor (name) {
		this.name = name;
		// return {}
	}
}

그러나 위의 코드와 같이 생성자 내부에서 빈 객체를 반환하는 경우 반환값은 this가 아니라 새로운 빈 객체가 됨.

클래스의 내부구조

class Animal {
	// 생성자
	constructor (name) {
		this.name = name;
	}
	// 공개 필드
	type = "animal";
	// 공개 메서드
	eat(food) {
		return `${this.name} eats ${food}.`;
	}
	// 비공개 속성
	#isCute = false;
	setIsCute(isCute) {
		this.#isCute = isCute;
	}
	#getEatVerb () {
		return this.#isCute ? "yumm" : "eats";
	}
	// 정적 속성
	static PLANET = "earth"; // 정적 필드 선언
	static isAnimal (something) { // 정적 ㅅ메서드 선언
		return something instanceof Animal;
	}
	static getPlanet() {
		return this.PLANET;
	}
	// 정적 초기화 블록
	static {
		this.PLANET = "mars";
	}
	
}
console.log(Object.keys(Animal.prototype)); // ["eat"]
  • 공개 필드 (pubic field)

    클래스 내부에서 필드를 선언할 수 있으며, 외부 접근이 가능한 필드

  • 공개 메서드 (public method)

    클래스 외부에서 접근 가능한 메서드

  • 비공개 속성 (private protperty)

    캡슐화하기 위한 필드 혹은 메서드

    • 캡슐화 (Encapsulation)

      외부에 공개할 피료악 없는 필드와 메서드를 숨겨서 추상화하는 개념

    필드와 메서드 앞에 #기호를 붙여 선언하며, 클래스 내에서 반드시 미리 선언되어 있어야 함.

  • 정적 속성

    인스턴스가 아닌 클래스 자체에 저장되는 속성

    static 키워드를 사용하여 정의하며, 인스턴스를 생성하지 않고도 접근할 수 있고, 인스턴스 간 공유되는 속성이나 메서드를 구현하는데 적합함.

  • 정적 초기화 블록 (static initialization block)

    클래스가 선언될 때 단 한번만 실행되는 코드 블록

    정적 속성값을 동적으로 설정하거나 초기화 로직이 필요한 경우 사용함.

클래스와 상속, extends & super

class Position {
	constructor(x, y) {
		this.x = x;
		this.y = y;
	}
	toString() {
		return `${this.x}, ${this.y}`;
	}
}

class Rect extends Position {
	constructor(x, y, w, h) {
		// this.w = w; // Error, super를 호출하기 전에는 this를 사용할 수 없음.
		super(x, y);
		this.w = w;
		this.h - h;
	}
	toString() {
		 const parent = super.toString();
		 return `${parent}, ${this.w}, ${this.y}`
	}
}

Rect 클래스의 생성자에서 supert를 호출하고 있는데, 이는 부모 생성자를 호출하여 인스턴스를 초기화하기 위함임.

여기서 extends는 내부적으로

Rect.prototype = Object.create(Position.prototype);
Object.setPrototypeOf(Rect, Posiiton);

의 작업을 수행함.

클래스의 강제 규칙

  1. new키워드 없이 호출할 수 없음.

    Animal("호랑이");  // ❌ TypeError
    new Animal("호랑이"); // ✅
    
  2. 호이스팅 안되는 것처럼 보임.

    호이스팅은 되지만 let, const로 선언한 변수들과 같이 선언 전에 접근하면 에러남.

    const a = new Animal(); // ❌ ReferenceError
    class Animal {}
    
  3. 항상 strict mode로 동작

    this가 자동으로 전역 객체를 가리키지 않고 undefined를 가리킴.

    class Strict {
      test() {
        console.log(this); // new 없이 호출하면 undefined (함수였으면 window)
      }
    }
    
  4. 프로토타입 메서드는 enumerable: false

    class Animal {
      speak() {}
    }
    for (const key in new Animal()) {
      console.log(key); // speak 안 나옴! (클래스 메서드는 열거 안 됨)
    }