객체지향을 배우다보면, 항상 나오는 핵심 개념으로 '상속'이라는 개념이 있다. '상속'이란 말 그대로 부모가 가진 특성을 자식이 그대로 이어받는 것을 말한다. 객체에는 크게 멤버와 메소드가 존재하는데, 자바와 같은 언어에서 부모 객체를 상속 받으면 멤버와 메소드가 그대로 자식 객체에 전달된다.
실제 예를 들자면 온라인 스토어 웹사이트에서 판매자(Seller
)와 구매자(Buyer
)는 모두 이용자(User
)라는 카테고리 안에 들어갈 수 있다. 클린한 코드를 만들기 위해서 때때로 중요한 것은 이러한 공통사항을 추출하여 추상화하는 것이다.
그러면 먼저 가장 상위 부모 객체로 이용자(User
)를 만들어놓고, 이용자가 가지는 공통적인 속성을 몇개 정의해보자.
username
)id
)password
)email
)간단하게 자바 클래스로 정의해보면 아래와 같을 것이다.
public class User {
private String username;
private String id;
private String password;
private String email;
}
만약에 여기서 판매자는 '판매 물품 정보'만 더 가지고 구매자는 '구매 물품 정보'만 더 가진다고 해보자. 간단하게 다음과 같이 정의할 수 있다.
public class Seller extends User {
private ArrayList<String> sellItems;
}
public class Buyer extends User {
private ArrayList<String> buyItems;
}
물론 사실 전통적 OOP 상속에 대한 몇가지 단점을 아는 사람도 있겠지만, 일단은 추상화로 인해 코드 자체가 매우 깔끔해졌다. 프로토타입도 이와 비슷한 기능을 제공하고 있다는 게 프로토타입의 시작이다.
자바스크립트는 상속을 위해 prototype
오브젝트를 가질 수 있기 때문에 prototype-based language라고 불리기도 한다. prototype
오브젝트는 메소드와 프로퍼티를 상속하기 위한 템플릿 오브젝트쯤으로 보면 된다.
그리고 이러한 prototype
오브젝트는 또 다른 prototype
오브젝트에서 메소드와 프로퍼티를 상속받을 수도 있다. 이렇게 prototype
이 다른 prototype
을 상속하는 것에 대하여 prototype chain이라는 용어를 사용한다.
객체 내부에 프로토타입이 존재하고, 그 프로토타입은 사실 다른 프로토타입의 자식 프로토타입으로서 동작하고 있을 수 있다.
자바 객체 상속에서는 자바 객체에 상속한 모든 내용이 들어있는 반면에 자바스크립트의 프로토타입은 각 객체에 직접 들어있는 것이 아닌 객체 생성자의 prototype
이라는 속성에 정의되어 있다.
Javascript에서는 오브젝트 인스턴스와 그 프로토타입 사이에 연결이 형성되고, 프로토타입 체인을 타고 올라가며 속성과 메소드를 찾는 일은 흔한 일이다. (생성자의 prototype
프로퍼티로부터 나온 __proto__
프로퍼티가 해당 객체의 프로토타입이다.)
한가지 알아두고 가면 좋은 것이 있는데, Object.getPrototypeOf(obj)
함수 혹은 deprecated 된 __proto__
속성으로 접근 가능한 객체의 프로토타입과 생성자의 프로토타입의 차이를 인지하는 것이 중요하다.
function Person(name, age) {
this.name = name;
this.age = age;
}
위와 같은 코드가 있을 때 생성자 함수인 Person
은 자신만의 프로토타입 속성을 가지고 있고, 해당 프로토타입은 Object.getPrototypeOf(Person)
으로 접근 가능하다. 그런데 사실 Person
은 Person.prototype
이라는 속성을 하나 더 갖고 있다. 이렇게 생성자 함수가 가지고 있는 prototype
속성은 이 생성자 함수의 인스턴스를 위한 청사진 역할을 한다.
new Person()
을 이용해 객체를 생성했을 때, 생성된 객체는 Person.prototype
을 __proto__
로 물려받게 된다.
위 코드의 결과에서 true
를 보면 대략적으로 이해가 될 것이다.
function Person(name, age) {
this.name = name;
this.age = age;
}
위에서 정의한 Person
생성자로 부터 예제 코드를 하나씩 돌려보며 프로토타입을 이해해보자.
콘솔에 person1
이라는 이름을 갖는 새로운 Person
객체를 만들어놨다.
위와 같이 .
기호를 이용해 해당 오브젝트가 가지고 있는 메소드들을 볼 수 있는데, toString()
, valueOf()
와 같은 메소드들이 보인다. 이는 최상위 객체인 Object
의 프로토타입인 Object.prototype
을 상속한 것이다.
Object.prototype
에는 아래와 같이 다양한 메소드가 존재한다.
그렇다면 만일 Object.prototype
으로부터 물려받은 메소드를 실행해보면 어떤 결과가 나올까?
그냥 오브젝트가 그대로 결과로 나온다. 그리고 [[Prototype]]
에는 Object
가 들어있다. 결과는 별 거 없지만, 이 때 내부적으로 일어난 일은 다음과 같다.
person1
오브젝트가 valueOf()
메소드를 가지고 있는지 확인한다. 이전에 사용했던 생성자 Person()
에 valueOf()
메소드가 있었다면, person1
에도 valueOf()
메소드가 있었을테지만, 없었다.person1
의 프로토타입 오브젝트에 valueOf()
메소드가 있는지 확인한다. 이번에도 없다. 그 이후에 브라우저는 person1
의 프로토타입 오브젝트의 프로토타입 오브젝트를 확인한다. 거기엔 있다. 발견된 메소드가 호출된다.
만일 위와 같이this.valueOf
가 내부에 함수로 존재했다면, 프로토타입을 찾아보기 이전에 이미 해당 함수를 찾아서 실행했을 것이다.
사실 Object 레퍼런스에는 아래에 보이듯, 수많은 속성과 메소드들이 존재한다.
사실 이전에 보았던 person1
이 상속받은 것들은 그 중 일부일 뿐이다. 그러면 상속하는 것과 그렇지 않은 것은 어떻게 구분할까? 정답은 .prototype
에 있는 것만 상속한다.
위에 보이는 것들만 상속이 된다.
여기서 다른 언어를 쓰다 온 사람은 뭔가 위화감을 느낄 것이다. "클래스 선언도 안하고 함수에서 생성자를 사용하고 멤버와 메소드도 정의한다고?" 그렇다. 자바스크립트에서는 함수도 그저 객체 중 하나일 뿐이다. Function() 생성자 레퍼런스를 확인해보자.
자바 표준 내장 객체들의 프로토타입을 확인해보면 자바스크립트 전반에 걸쳐 프로토타입 체인 상속이 어떻게 구성되어 있는지 확인해볼 수 있다.
String
을 생성자로 이용하면 위와 같이 어마어마한 메소드들이 프로토타입으로 연결될 것이다. 자바스크립트에서는 그냥 문자열 선언을 하면 자동으로String
이 프로토타입으로 붙는다. 그렇기에 우리는.split()
,.indexOf()
등 유용한 메소드를 마음대로 이용할 수 있는 것이다.
그 외에 다른 많은 객체들이 존재한다.
.__proto__
와 .prototype
은 다르다는 것을 주의해야 한다..__proto__
도 한국어로 하면 프로토타입이라고 부르기 쉽고 .prototype
도 한국어로 하면 프로토타입 이라 불리기 쉽다. 그러나 .__proto__
즉, Object.getPrototypeOf(object)
로 얻을 수 있는 내용은 내가 상속받은 프로토타입의 내용인 것이고, .prototype
으로 얻을 수 있는 내용은 내가 상속하는 객체에게 줄 프로토타입의 내용임을 인지하자.
위와 같이 만일 Object.create(person1)
이라는 구문으로 객체를 생성하게 되면, 상속받은 프로토타입이 위치하는 .__proto__
에는 person1
객체가 그대로 들어가게 된다.
Object.create() 메소드의 공식문서에도 자세히 나와 있다.
모든 자바스크립트 오브젝트는 constructor
를 가지고 있다. object.constructor()
를 이용하면 해당 오브젝트를 생성했던 생성자를 다시 불러올 수 있으므로, 어떤 생성자로 생성되었는지 모를 때 사용하면 유용하다.
이를테면 다음과 같은 것이 가능하다.
위와 같이 nums1
과 nums2
라는 숫자 배열 2개를 만들었다고 가정하자.
그리고 위와 같이 Array.prototype
에 새로운 함수를 추가했다면,
모든 Array
를 상속받은 곳에서 이용 가능하다.
위와 같이 내가 정의했던 생성자 함수의 프로토타입에도 추가할 수 있어서 용이하다.
만일 prototype
에서 위와 같이 해당 객체가 가지고 있는 name
과 age
를 출력시키려고 위와 같이 정의한다면, undefined
가 나온다. 왜냐하면 함수 내부에서는 this
가 해당 객체의 컨텍스트를 잘 가리키지만, 함수 밖에서는 window
객체를 가리키고 있기 때문이다.
물론 상수는 넣어도 되겠지만, 일반적으로 상수는 처음 function에서 this.
로 추가해주는 편이 좋다.
자바스크립트를 처음 배울 때는 잘 모르지만, 자바스크립트의 객체는 사실 이전에 살펴봤듯, [[Prototype]]
이라는 숨김 프로퍼티를 갖는다. [[prototype]]
에 물론 null
이 들어올 수도 있다.
위와 같이 간단한 생성자 함수를 만들었을 때,
기본으로 [[Prototype]]
에는 최상위 오브젝트인 Object
를 참조하고 있다.
우리는 .__proto__
속성을 사용해서 [[Prototype]]
에 들어갈 오브젝트를 직접 정해줄 수도 있다.
위 코드의 실행결과를 보면 이해가 한층 쉽다. rabbit.__proto__
로 animals
를 할당했을 때는 rabbit.eats
가 true
가 되고, rabbit.jumps
도 true
가 된다. 말 그대로 animals
에 있던 속성을 그대로 상속받는데, rabbit.__proto__ = {}
와 같이 빈 오브젝트를 다시 할당했더니 rabbit.eats
도 undefined
가 되었다.
상속으로 인해 animals
가 부모 클래스가 되고, rabbit
이 자식 클래스인 구조가 되었다. rabbit
이 물려받은 eats
프로퍼티는 '상속 프로퍼티'라고 부른다.
위와 같이도 이용 가능하다.
이렇게 위의 male
을 상속하여 jake
를 만들 수도 있다. 그러면 구조는 다음과 같아진다.
말 그대로 체인이 생긴다. Jake
는 Male
을 구현한 것이고, Male
은 Human
을 구현한 것이다.
__proto__
를 이용해 닫힌 형태로 다른 객체를 참조하면 에러가 발생[[Prototype]]
을 정의할 수 있는 .__prototype__
속성에는 object
나 null
만 들어갈 수 있다. 다른 타입은 넣어도 무시당한다[[Prototype]]
만 가능하다. 객체는 두개의 프로토타입을 상속받진 못한다.let animal = {
eats: true,
walk() {
alert("usual animal walk");
}
};
let rabbit = {
__proto__: animal
};
rabbit.walk = function() {
alert("it's not usual animal! it's Rabbit! Bounce-bounce!");
};
rabbit.walk(); // Rabbit! Bounce-bounce!
위와 같은 코드가 있을 때, rabbit
은 다른 animal
들과는 걸어다니는 방식이 다르기 때문에 rabbit
만의 walk()
메소드를 따로 추가해주고 싶다면, 프로토타입을 사용하는 것이 아니라 그냥 rabbit.walk
프로퍼티에 함수를 추가해주면 된다. 그러면, 자바스크립트에서 프로토타입을 뒤져보는 단계 전에 이미 내부 프로퍼티 메소드인 walk
를 찾기 때문에 정상동작한다.
위와 같이 프로퍼티에서 get
, set
을 붙여 접근자 프로퍼티를 만든 경우에는 name.fullName = ""
를 수행한다고 해도 실제로 프로퍼티에 값을 넣는 행위가 실행되지 않고 지정한 getter
메소드가 실행되니 동작에 오해가 없도록 주의해야 한다.
const name = {
lastName: 'Seo',
firstName: 'Jake',
set fullName(value) {
[this.firstName, this.lastName] = value.split(" ");
},
get fullName() {
return `${this.firstName} ${this.lastName}`;
}
}
const englishName = {
isEnglish: true,
__proto__: name
};
englishName.fullName = "Paul Seo";
위와 같은 코드를 실행시키면, englishName.fullName
의 setter
는 name
객체의 lastName
, firstName
을 바꿀지 아니면 englishName
객체의 lastName
, firstName
을 바꾸게 되는 것인지 잘 알아두는 것이 좋다.
setter
는 프로토타입에 들어있더라도 프로퍼티를 할당하거나 불러올 때 대신 실행되는 함수일 뿐, 그 이상도 그 이하도 아니다. 그래서 this
에서 실행 컨텍스트가 어떻게 될지 잘 안다면, 여기서는 당연히 englishName
의 다음에 .fullName
을 하였으므로, .
앞에 있는 것은 englishName
이다.
그래서 englishName
의 lastName
, firstName
프로퍼티가 바뀌게 된다.
this
의 실행 컨텍스트는 프로토타입에 영향을 받지 않는다. 프로토타입은 메소드 자체는 공유하지만, 객체의 상태 공유와는 상관없다.
const animal = {
eats: true
};
const rabbit = {
jumps: true,
__proto__: animal
};
alert(Object.keys(rabbit)); // jumps
for(let prop in rabbit) alert(prop); // jumps, eats
Object.keys()
: 상속받은 프로퍼티를 제외하고, 객체에서 선언된 프로퍼티만을 순회for ... in
: 상속받은 프로퍼티와 객체에서 선언된 프로퍼티 모두를 순회const animal = {
eats: true
};
const rabbit = {
jumps: true,
__proto__: animal
};
for(let property in rabbit) {
const isOwnProperty = rabbit.hasOwnProperty(property);
if(isOwnProperty) {
alert(`상속받지 않은 프로퍼티: ${property}`);
} else {
alert(`상속받은 프로퍼티: ${property}`);
}
}
상속받은 프로퍼티로 eats
가 잘 나오고, 상속받지 않은 프로퍼티로 jumps
가 잘 나온다.
위는 MDN 공식문서 중 Object.prototype.hasOwnProperty() 부분을 캡처한 것이다. 위에서 볼 수 있듯, hasOwnProperty()
는 Object
프로토타입에 내장된 메소드 중 하나이다.
프로토타입 체인을 살펴보면 위와 같이 구성되어 있기 때문에 가능한 것이다.
animal
은 객체 리터럴로 선언되었기에Object
프로퍼티를 프로토타입 객체로 갖는다.
for...in
은 이전에 보았듯, 상속받은 프로퍼티도 모두 순회한다. 그런데 왜 hasOwnProperty()
에 걸리지 않았을까? 그 이유는 enumerable
플래그에 있다.
object.propertyIsEnumerable()
메소드를 통해 해당 프로퍼티가 순회 가능한지 알아볼 수 있다.
enumerable
플래그를 설정하려면, Object.defineProperty()
를 이용하면 된다.
위는 defineProperty()
메소드를 이용해서 enumerable
에 false
를 할당한 예이다. 이렇게 하면 for ... in
에서 해당 프로퍼티를 순회하지 않는다.
[[Prototype]]
이 있는데, 이 객체에서는 다른 객체 혹은 null
을 가리킨다.[[Prototype]]
이 참조하는 객체를 프로토타입 객체라고 한다.accessor
프로퍼티라고 불리는 getter
, setter
등은 프로토타입을 뒤지지 않고, 그냥 함수로서 실행된다.accessor
프로퍼티가 프로토타입에 선언되어 있더라도 this
의 실행 컨텍스트는 프로토타입에 영향을 받지 않는다.for...in
은 자신의 프로퍼티 뿐만 아니라 상속받은 프로퍼티까지 순회한다.object.defineProperty
로 enumerable
을 false
로 만들면 해당 프로퍼티는 순회하지 않는다.let animal = {
jumps: null
};
let rabbit = {
__proto__: animal,
jumps: true
};
alert( rabbit.jumps ); // ? (1)
delete rabbit.jumps;
alert( rabbit.jumps ); // ? (2)
delete animal.jumps;
alert( rabbit.jumps ); // ? (3)
위 코드의 결과는 순서대로 true
, null
, undefined
가 나온다. 코드에서 rabbit.jumps
는 rabbit
객체의 jumps
프로퍼티를 찾는다는 뜻이고, 프로퍼티를 찾는 순서는 아래와 같다.
처음에는 1.로 찾았을 때 바로 나오는 케이스고, 그 뒤에는 rabbit
의 jumps
속성을 지웠기 때문에 animal
의 jumps
속성이 나오게 되고, 그 뒤에는 animal
의 jumps
속성도 지우기 때문에 끝끝내 찾지 못해 undefined
가 나온다.
const _head = {
glasses: 1
};
const _table = {
pen: 3
};
const _bed = {
sheet: 1,
pillow: 2
};
const _pockets = {
money: 2000
};
_pockets.__proto__ = _bed;
_bed.__proto__ = _table;
_table.__proto__ = _head;
위와 같이 코드를 짜면 _pockets > _bed > _table > _head
순으로 프로토타입 체인이 완성된다. 각각의 객체가 [[Prototype]]
프로퍼티에 다음 객체를 프로퍼티 타입으로 가지게 되는 것이다.
_pockets.glasses
를 입력해도 1을 반환받을 수 있고, _head.glasses
를 입력해도 1을 반환받을 수 있다. 다만 두 입력값 사이에는 프로토타입을 거치는지에 대한 차이가 있다.let animal = {
eat() {
this.full = true;
}
};
let rabbit = {
__proto__: animal
};
rabbit.eat();
위 코드는 당연히 rabbit
객체의 full
프로퍼티를 true
로 만들 것이다. 이전에도 나왔던 내용이지만, 프로토타입은 따로 실행 컨텍스트를 변경시키지 않는다. 프로퍼티를 찾는 것과 뭔가 실행하는 것은 다른 일로 보는 것이 맞다.
let hamster = {
stomach: [],
eat(food) {
this.stomach.push(food);
}
};
let speedy = {
__proto__: hamster
};
let lazy = {
__proto__: hamster
};
// 햄스터 한 마리가 음식을 찾았습니다.
speedy.eat("apple");
alert( speedy.stomach ); // apple
// 이 햄스터도 같은 음식을 가지고 있습니다. 왜 그럴까요? 고쳐주세요.
alert( lazy.stomach ); // apple
위 코드의 경우에는 약간 예상과 다른 결과가 나올 수 있는데, 그 이유는 this.stomach.push()
부분에서 자바스크립트가 this.stomach
를 프로토타입 체인을 통해 찾아다니기 때문이다. 그러면 speedy
와 lazy
가 공통으로 사용하고 있는 프로토타입 객체 hamster
가 가진 stomach
를 공유하는 일이 벌어진다. 그러지 않기 위해서는 다음과 같은 코드를 작성하면 된다.
let hamster = {
stomach: [],
eat(food) {
// this.stomach.push 대신에 this.stomach에 할당
this.stomach = [food];
}
};
let speedy = {
__proto__: hamster
};
let lazy = {
__proto__: hamster
};
// Speedy는 음식을 발견합니다.
speedy.eat("apple");
alert( speedy.stomach ); // apple
// Lazy의 stomach는 비어있습니다.
alert( lazy.stomach ); // <nothing>
위는 어떤 프로퍼티에 무언가를 할당할 때는 프로토타입 체인을 이용하지 않는 점을 활용한 코드이다. 위 경우에는 그냥 speedy.stomach
에 [apple]
이 들어가게 된다. 그러나, 위 방법으로는 딱 한가지 음식밖에 못담는다.
let hamster = {
stomach: [],
eat(food) {
this.stomach.push(food);
}
};
let speedy = {
__proto__: hamster,
stomach: []
};
let lazy = {
__proto__: hamster,
stomach: []
};
// speedy는 음식을 발견합니다.
speedy.eat("apple");
alert( speedy.stomach ); // apple
// lazy의 stomach은 비어있습니다.
alert( lazy.stomach ); // <nothing>
위와 같이 코드를 작성하면, 프로토타입을 뒤져보기 전에 이미 해당 객체에 stomach
배열이 존재하니 독립된 stomach
를 이용하는 것이 가능해진다.
https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Objects/Object_prototypes
https://ko.javascript.info/prototype-inheritance#ref-530
https://developer.mozilla.org/ko/docs/Learn/JavaScript/Objects/Object_prototypes#%ED%94%84%EB%A1%9C%ED%86%A0%ED%83%80%EC%9E%85_%EA%B0%9D%EC%B2%B4_%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0