개인적으로 JavaScript를 학습하는 과정 중 가장 근본이 되는 내용이 아닌가 싶다.
JavaScript는 멀티 패러다임 언어이다. 말 그대로 함수형, 명령형, 객체지향 프로그래밍을 모두 지원한다.
여기서 객체지향 프로그래밍을 지원하는 방법이 프로토타입 방식이다. 이는 다른 언어와 다른 부분이다.
더군다나 JavaScript는 거의 모든 것이 객체로 이루어진 언어이다. 내가 객체지향 프로그래밍을 안한다 하더라도 이미 내가 함수, 객체 등을 사용하고 있는 순간 프로토타입이라는 괴생명체를 끼고 가는 것이다.
따라서 이 프로토타입이라는 친구를 알아야 내가 JavaScript를 학습하는데 있어서 도움이 될 것이고, 언어의 근본을 조금이나마 맛을 볼 수 있을 것이라 생각했다.
다양한 프로토타입 관련 글이 많지만 우선적으로 이 글은 모던 자바스크립트 Deep Dive 라는 책을 읽고 나만의 방식으로 정리를 하고 내가 새롭게 알게 된 내용을 정리해보려 한다.
우선 이 말이 무엇일까?
이 내용을 꼭 이해해야 프로토타입을 알 수 있는 것이 아니긴 하지만 알고 들어가야 프로토타입을 활용하는 방법을 더 알 수 있을 것이라 생각한다.
우선 책에서는 이렇게 요약을 한다.
객체지향 프로그래밍은 프로그램을 명령어 또는 함수의 목록으로 보는 전통적인 명령형 프로그래밍의 절차지향적인 관점을 벗어나 여러개의 독립적인 단위, 즉 객체의 집합으로 프로그램을 표현하려는 패러다임을 말한다.
뭔말인지 좀 어렵다.
여기서는 단순하게 알아보고 지금 간간히 읽어보고 있는 객체지향의 사실과 오해 책을 읽고 난 다음에 다른 글로 찾아보겠다.
우선 간단하게 보면
객체지향 프로그래밍은 어플리케이션이 제공할 기능을 나눠서 적절하게 객체에 나눠준다 생각하면 된다. 너는 커피 만드는 역할! 이라고 객체에게 부여하면 그 객체는 커피를 만드는 일을 전담하고 그 일에 책임이 있는 상태이다. 이렇게 기능을 나눈 객체들이 서로 협력해가면서 서비스를 제공하게 만드는 것이 객체지향 프로그래밍이다.
그러면 객체는 무엇이라 할 수 있을까?
객체는 어떠한 특징이나 성질을 나타내는 상태들, 그리고 그러한 속성들을 이용할 수 있는 행동들을 가지고 있는 복합적인 자료구조이다.
코드로 점프하는 사람 객체를 만들어보면 이해가 간다.
const jumpMan = {
name: "jump", // 이 객체의 이름 -> 상태
surgent: 30, // 이 객체가 한 번에 뛰는 높이 -> 상태
doJump() { // 이 객체가 뛰는 것 -> 동작
console.log(`${this.name} 가 ${this.surgent} 만큼 뛰었다!`);
},
}
이 객체는 지금 이름과 서전트 점프 높이라는 상태와 해당 상태를 이용하는 점프라는 동작으로 이루어져있다.
이처럼 객체는 상태 데이터와 동작을 하나의 논리적인 단위로 묶은 복합적인 자료구조를 말한다.
이때 객체의 상태 데이터를 프로퍼티(property), 동작을 메서드(method) 라고 부른다.
상속은 객체지향 프로그래밍의 핵심 개념으로, 어떤 객체의 프로퍼티 또는 메서드를 다른 객체가 상속받아 그대로 사용할 수 있는 것을 말한다.
그러면 왜 상속을 할까? 가장 큰 이유는 기존 코드의 재사용이다.
function JumpMan(name, surgent) {
this.name = name;
this.surgent = surgent;
this.doJump = function () {
console.log(`${this.name} 가 ${this.surgent} 만큼 뛰었다!`);
}
}
const man1 = new JumpMan('Yoo', 30);
const man2 = new JumpMan('Ji', 20);
console.log(man1.doJump === man2.doJump); // false
위 코드를 보면 JumpMan 이라는 생성자 함수로 두 인스턴스를 만들었다. 둘 다 아마 재 기능을 할 것이다. 하지만 이상한 부분이 있다.
바로 두 인스턴스의 doJump가 다른 것이다.
생각해보면 같은 동작인데 그냥 함수 하나 만들어놓고 각 인스턴스에서 공유를 하면 좋지 않을까? 그러면 더 효율적일 것 같은데? 라는 생각이 든다.
또 지금은 두 개의 인스턴스지만 더 많이 생성된다면 메모리 낭비가 있을 것 같다는 생각이 든다.
이럴 때 사용하는 것이 바로 상속이고, JavsScript에서는 상속을 프로토타입으로 구현한다.
function JumpMan(name, surgent) {
this.name = name;
this.surgent = surgent;
}
JumpMan.prototype.doJump = function () {
console.log(`${this.name} 가 ${this.surgent} 만큼 뛰었다!`);
};
const man1 = new JumpMan('Yoo', 30);
const man2 = new JumpMan('Ji', 20);
console.log(man1.doJump === man2.doJump); // true
JumpMan의 프로토타입에 doJump라는 메서드를 추가하고 이를 공유해서 사용하게 코드를 작성한 것이다. 이것을 동일한 코드의 사진은 아니지만 참고용 사진으로 확인해보면 아래와 같다.
(출처: https://poiemaweb.com/es6-class)
다음과 같이 prototype 객체를 통해서 두 객체가 참조를 하고 있는 것이다.
생각해보면 우리는 prototype 객체를 생성한 적이 없는데 언제 생긴건지 의아하다. 또 어느세 저렇게 인스턴스와 연결이 된 것인지도 모르겠다.
이제 저 prototype 객체에 대해서 알아보자.
프로토타입 객체(또는 줄여서 프로토타입)란 객체지향 프로그래밍의 근간을 이루는 객체간 상속을 구현하기 위해 사용된다. 프로토타입은 어떤 객체의 상위(부모) 객체 역할을 하는 객체로서 다른 객체에 공유 프로퍼티(메서드 포함)를 제공한다. 프로토타입을 상속받은 하위(자식) 객체는 상위 객체의 프로퍼티를 자신의 프로퍼티처럼 자유롭게 사용할 수 있다.
책에서는 프로토타입 객체 부분 글의 시작을 다음과 같은 문장들로 시작했다.
상속을 위해 사용한다는 것도 알았고, 상위 객체의 메서드나 프로퍼티를 사용할 수 있는 것도 코드와 그림으로 대충 알게 되었다.
그러면 저 프로토타입 객체는 어떻게 생성되는 것일까?
우선 JavaScript의 모든 객체는 [[Prototype]]이라는 내부 슬롯을 가진다. 이 내부 슬롯의 값은 프로토타입의 참조 값을 가진다.
이 [[Prototype]]에 저장되는 프로토타입은 객체 생성 방식에 의해 결정된다.
즉 객체가 생성될 때 생성 방식에 따라 프로토타입이 결정되고 [[Prototype]]에 저장된다.
뒤에서 더 다룰거지만 일단
라고 알아두자.
정리
- 프로토타입 객체: 어떤 객체의 상위 객체 역할을 하는 객체로서 다른 객체에 공유 프로퍼티를 제공하기 위해 존재한다.
- 간단하게 생각하면 부모, 조상, 원형 등으로 보면 된다. 객체는 모두 부모가 있고 이 부모는 자식이 탄생시 무조건적으로 연결된다.
- 자바스크립트의 모든 객체들은 부모(프로토타입)들이 있다.
없는 것도 있기는 함- 이 프로토타입은 객체가 생성이 된다고 하면 생성 방식에 따라서 알아서 자동으로 내부 슬롯에 저장된다.
- 이 내부 슬롯은 프로토타입의 참조 값을 가지고 있다.
- 프로토타입을 상속받은(연결된) 하위 객체는 상위 객체의 프로퍼티를 이용할 수 있고 이러한 방식으로 상속이 이루어진다.
개발자도구의 콘솔창에서 한번 간단한 객체 리터럴 방식 객체와 생성자 함수의 인스턴스를 찍어보도록 한다.
일단 객체 리터럴 방식이다.
내부에 내가 넣지도 않았는데 [[Prototype]] 이란 것이 생겼고 내부에 값이 들어가 있는 것을 볼 수 있다. 또 하나 확인할 수 있는게 __proto__ 이라는 것이 생긴 것을 볼 수 있다.
이는 JavaScript가 [[Prototype]] 내부 슬롯에는 직접 접근할 수 없게 막고 __proto__라는 접근자 프로퍼티를 통해서 자신의 프로토타입에 간접적으로 접근할 수 있게 해주기 때문이다.
아니 왜 이렇게 귀찮게 하는 것인가는 뒤에 더 알아본다.
위 방식은 생성자 함수 방식으로 객체를 생성하고 확인한 결과이다.
이전과 다르게 [[Prototype]]에 constructor가 있는 것을 알 수 있다. 프로토타입은 자신의 constructor 프로퍼티를 통해 생성자 함수로 접근할 수 있다. 그림으로 확인하고 싶으면 상속과 프로토타입 부분의 사진을 보면 된다.
그리고 [[Prototype]]의 [[Prototype]]이 있는 것을 볼 수 있다.
지금 다 알아보려면 머리가 아프니 나중에 보는 것으로 하자.
모든 객체는 __proto__ 접근자 프로퍼티를 통해 자신의 프로토 타입, 즉 [[Prototype]] 내부 슬롯이 가리키는 프로토타입에 간접적으로 접근할 수 있다. 그런데 이걸 왜 만들어둔 것일까?
간단하게 알고 시작하면 좋은 프로퍼티 어트리뷰트 개념
- 내부 슬롯, 내부 메서드:
- JavaScript 엔진의 구현 알고리즘을 설명하기 위해 ECMAScript 사양에서 사용하는 의사 프로퍼티와 의사 메서드이다.
- 내부 슬롯과 내부 메서드는 ECMAScript 사양에 정의된 대로 구현되어 JavaScript 엔진에서 실제로 동작하지만 개발자가 직접 접근할 수 있도록 공개된 객체의 프로퍼티는 아니다.
- 프로퍼티 어트리뷰트:
- 프로퍼티 어트리뷰트는 자바스크립트 엔진이 관리하는 내부 상태 값인 내부 슬롯이다. 따라서 프로퍼티 어트리뷰트에 직접 접근할 수 없지만 Object.getOwnPropertyDescriptor 메서드를 사용하여 간접적으로 확인할 수 있다.
- 데이터 프로퍼티, 접근자 프로퍼티
- 프로퍼티는 크게 데이터 프로퍼티와 접근자 프로퍼티로 구분할 수 있다.
- 데이터 프로퍼티: 키와 값으로 구성된 일반적인 프로퍼티
- 접근자 프로퍼티: 자체적으로는 값을 갖지 않고 데이터 프로퍼티의 값을 읽거나 지정할 때 호출되는 접근자 함수로 구성된 프로퍼티이다.
우선 __proto__에 대해서 알아보자
JavaScript는 원칙적으로 내부 슬롯과 내부 메서드에 직접적으로 접근하거나 호출할 수 있는 방법을 제공하지 않는다.
[[Prototype]] 도 마찬가지이다. 하지만 __proto__ 접근자 프로퍼티를 통해서 간접적으로 접근할 수 있게 열어준 것이다.
우리가 __proto__ 접근자 프로퍼티를 통해 프로토타입에 접근하면 내부적으로 __proto__ 접근자 프로퍼티의 getter 함수인 [[Get]]]이 호출되는 구조이다. 만약 프로토타입을 할당한다고 하면 setter 함수인 [[Set]]]이 호출된다.
__proto__ 알쓸신잡
__proto__ 접근자 프로퍼티는 객체가 직접 소유하는 프로퍼티가 아니라 Object.prototype의 프로퍼티이다. 모든 객체는 상속을 통해 Object.prototype.__proto__ 접근자 프로퍼티를 사용하는 것이다.const person = { name: 'Yoo' }; // person 객체는 __proto__ 프로퍼티를 소유하지 않는다. console.log(person.hasOwnProperty('__proto__')); // false // __proto__ 프로퍼티는 모든 객체의 프로토타입 객체인 Object.prototype의 접근자 프로퍼티이다. console.log(Object.getOwnPropertyDescriptor(Object.prototype, '__proto__')); // {get: f, set: f, enumerable: false, configurable: true} // 모든 객체는 Object.prototpye의 접근자 프로퍼티 __proto__를 상속받아 사용할 수 있다. console.log({}.__proto__ === Object.prototype); // true
그러면 왜 이렇게 귀찮게 접근자 프로퍼티를 통해서 접근하게 만들었을까?
이유는 상호 참조에 의해 프로토타입 체인이 생성되는 것을 방지하기 위해서이다.
프로토타입 체인이 무엇일까? 일단 검색을 순서대로 할 수 있게 만든 단방향 링크드 리스트라고 생각하면 된다.
const person = { name:'Yoo' };
const group = { groupName: 'Ji' };
// person의 프로토타입을 group으로 지정
person.__proto__ = group;
console.log(person.groupName); // 'Ji'
이 코드에서 출력이 어떻게 Ji가 나오는 것일까?
이는 위에서 설명한 프로토타입 체인 때문이다. 우리가 어떤 객체에 접근해서 특정 프로퍼티를 찾는데 없다면 JavaScript 엔진은 __proto__ 접근자 프로퍼티가 가리키는 참조를 따라서 자신의 부모 역할을 하는 프로토타입의 프로퍼티를 순차적으로 검색한다.
person.__proto__ = group;
group.__proto__ = person;
// 실제로는 아래 코드를 실행하면 TypeError: Cyclic __proto__ value 에러가 발생한다.
만약 그런데 위와 같다면 어떨까?
상상을 해보자 person.iDontKnow 라는 프로퍼티를 찾으려 한다면
이렇게 순환 참조 프로토타입 체인이 만들어진다면 프로토타입 체인 종점이 없기 때문에 무한 루프에 빠지게 된다.
따라서 아무런 체크 없이 무조건적으로 프로토타입을 교체할 수 없도록 __proto__ 접근자 프로퍼티를 통해 프로토타입에 접근하고 교체하도록 구현된 것이다.
이 긴 글을 감사하게도 쭉 읽어주신 분이면 한가지 이상한 기운이 느껴질 것이다.
function JumpMan(name, surgent) {
this.name = name;
this.surgent = surgent;
}
JumpMan.prototype.doJump = function () {
console.log(`${this.name} 가 ${this.surgent} 만큼 뛰었다!`);
};
이전 코드를 가져와서 다시 한번 보면 뭔가 찝찝하다.
'아니 여기에 있는 prototype은 또 뭐에요???'
이는 함수 객체만이 소유하는 prototype 프로퍼티이고, 생성자 함수가 생성할 인스턴스의 프로토타입을 가리킨다.
... 그럼 뭐가 다른 것일까? 이는 사용하는 주체가 다르다.
__proto__ 접근자 프로퍼티
prototype 프로퍼티
한마디로 간단하게 생각하면 생성자 함수가 생성할 인스턴스들의 프로토타입에 접근하기 위해서 사용하는 것이 생성자 함수의 prototype 프로퍼티인 것이다.
function Person(name, gender) {
this.name = name;
this.gender = gender;
this.sayHello = function(){
console.log('Hi! my name is ' + this.name);
};
}
const foo = new Person('Lee', 'male');
(출처: https://poiemaweb.com/js-prototype)
위 그림에서 초록색 Person() 생성자 함수 부분과 회색 Person.prototype, 파란색 객체 사이의 관계에만 집중하자. 위 두개는 앞으로 쓸 글에서 이어진다...
Person() 생성자 함수는 prototype 프로퍼티로 Person.prototype에, 생성자 함수로 생성된 객체는 [[Prototype]](실제로는 __proto__ 접근자 프로퍼티)로 Person.prototype 객체에 접근하는 것을 볼 수 있다.
내용이 너무 방대하다보니 한편에 끝내기가 어려운 것 같다.
지금까지 내용을 보면
이렇게 크게 두 가지 내용을 다루었다.
다음편에는 무엇을 다뤄야 할까 생각해보면 아래와 같은 내용이 있을 것 같다.
이렇게 크게 3가지 내용을 다뤄볼 예정이다. 또 해당 내용을 다루다 내용이 길어지면 또 다른 글을 작성할 예정이다.