blog logo
Published on

프로토타입

들어가며

현재 모던 자바스크립트 Deep Dive 책 완전 정복을 목표로 스터디를 하고 있다. 스터디 분들과 번갈아가며 맡은 내용에 대해 발표를 진행한다. 그 중에서 모던 자바스크립트 19장 프로토타입 발표를 맡았으며 발표 자료에 쓰기 위해 책을 읽고 정리한 글이다.

자바스크립트는 프로토타입 기반의 객체 지향 언어라고도 불린다. 그렇다면 왜 프로토타입 기반 언어인지, 프로토타입을 통해 어떤 결과를 얻을 수 있는지 딥하게 알아보려고 한다. 그리고 자바스크립트의 근본 구조인 프로토타입과 현재 ES6문법과 무엇이 다른지도 공부해 보려고 한다.

19.1 객체 지향 프로그래밍

책에서는 프로토타입에 대해 알아보기 전에 객체 지향 프로그래밍에 대해 설명한다. 이전에 10장 객체 리터럴에 대해 발표할 때 객체 지향 프로그래밍에 대해서 간단하게 다룬 적이 있다. 👉 이전 글 보러가기

자바스크립트는 객체 지향 언어와 절차 지향 언어 두가지 형태로 만들 수 있다고 설명했다. 하지만 객체 지향 프로그래밍은 절차적인 관점에서 벗어나 여러 개의 독립적인 단위, 즉 객체(Object)의 집합으로 프로그램을 표현하려는 프로그래밍 패러다임이다. 자바스크립트라는 언어의 개념과 비교 대상이 아니라 하나의 프로그래밍 패러다임일뿐이다. 그리고 이러한 패러다임은 철학적 사고를 접목하려는 시도에서 시작되었다.

그렇다면 객체 지향 프로그래밍과 오늘 알아보려고 하는 프로토타입과는 어떤 연관이 있을까? 실체라고 하면 우리는 특징이나 성질을 나타내는 속성을 가지고 있는 것을 뜻한다. 사람이라는 실체에 이름, 주소, 성별이라는 속성을 가지고 있는 것 처럼말이다. 그리고 여기서 이름과 성별에 관심이 있다면 속성에 관심이 있다고 표현할 수 있으며, 속성만 간추려 내어 표현하는 것을 추상화(abstraction)이라고 한다.

위에서 설명한 속성을 통해 여러 개의 값을 하나의 단위로 구성한 자료구조를 객체라고 하며, 이러한 객체의 집합으로 프로그램을 표현하려 하는 것을 객체 지향 프로그래밍이라고 한다.

우리는 객체 지향 프로그래밍에 대해서 알아봤다. 그러면 프로토타입에 대해서도 알아보면 이 둘의 연관점을 찾을 수 있다. 먼저 나는 프로토타입이 '왜' 나왔을까? 찾아보던 중 좋은 글을 찾게 되어 참고하면서 글을 쓰게 되었다.

프로토타입은 자바스크립트에서 상속을 지원하기 위한 방법이다. 근데 왜 Java나 다른 언어처럼 클래스가 아니고 프로토타입부터 나왔는가? 결론부터 이야기하면 클래스기반 OOP와 프로토타입 OOP는 상반된 개념이며, 객체를 바라보는 관점 자체가 다르다. 그리고 프로토타입을 이해하려면 클래스를 알아야 한다.

서양 철학은 다음 예시와 같이 이분법적 세계관을 갖고 있다.

  • 영혼 / 육체
  • 추상적 / 구체적
  • 이데아 / 프랙티스

눈 앞에 실제로 존재하는 사물이 있다면 반드시 그것의 본질이 존재한다는 플라톤의 주장이다. 간단한 예시로 의자를 들어보자. 의자에는 여러가지 형태가 있다. 휠체어, 원목의자, 미용실 의자 등등.. 이렇게 수많은 종류의 의자가 존재하고, 이러한 의자들의 본질은 곧 추상적인 '의자'라는 것이 존재한다. 이러한 본질의 세계를 이데아(Idea)라고 하며, 수 많은 종류의 의자들은 '이데아의 의자'를 모방한 의자들이라는 것이다.

조금 더 쉽게 영어로 설명하자면 다음과 같다.

  • chair: 이데아에 존재하는 본질적인(추상적인) 의자. 현실 세계에는 존재하지 않음.
  • a chair, the chair, chairs: 현실 세계에 존재하는 의자.

이러한 사고 방식은 곧 코드로도 표현할 수 있다.

class chair {
  //...
}

const myChair = new chair();

이게 어떻게 플라톤의 주장과 같다고 표현할 수 있을까? 실제 class는 이데아에 존재하는 추상적인 개념과 같다. 이유는 코드상에는 존재하지만 실제 메모리 상에는 존재하지 않기 때문이다. new 키워드를 사용해야만 Heap 메모리 주소를 갖게되고 메모리상에서 구체적으로 존재하게 된다.(인스턴스화)

이데아에 존재하는 의자(class chair)를 인스턴스화(new chair)하는 것을 철학적 사고로 표현을 하면 분류(Classification)라고 한다. 그리고 class라는 단어는 여기서 나온 것이다. 조금 더 정리하자면 **개체의 속성이 동일한 경우 개체 그룹이 같은 범주에 속한다.**라는 개념이 분류라는 것이다. 그리고 여기서 말하는 속성은 클래스의 프로퍼티를 뜻한다.

그렇다면 프로토타입은 무엇일까? 앞서 설명했듯이 프로토타입은 클래스와 상반된 개념이다. 그리고 클래스, 분류의 개념을 반박하기 위해 나온 것이 바로 프로토타입이다. 그렇다면 어떠한 이론이 앞서 설명한 이 분류의 이론을 반박하는 것일까?

바로 비트겐슈타인의 의미사용이론이다. 쉽게 설명하면 단어의 쓰임새에 따라(누가, 무엇을, 어떻게 사용하는지에 따라) 의미가 결정된다는 이론이다. '물'이라는 단어를 예시로 들어보자.

  • (물을 마시고 싶을 때): 물을 꺼내다.
  • (변기통에 물이 없을 때): 물을 채우다.
  • (물이 튀었을 때): 물을 닦다.

물의 쓰임새에 따라 물의 의미가 결정된다. 그리고 또 다른 주장이 있다. 바로 가족 유사성이다. 앞서 설명한 분류(Classification)는 공통 속성(전통적인 속성)에 의해 분류를 한다면 지금 주장하는 이론은 가족 유사성을 통해 분류하게 된다는 뜻이다. 조금 더 자세히 알아보자.

가족 유사성은 같은 가족이더라도 공통된 속성은 없을 수 있다. 그럼에도 가족으로 분류하는 것을 가족 유사성이라고 한다. 즉 공통된 속성은 없지만 그 속성을 공유하는 것이라고 할 수 있다. 그리고 가족 유사성에서도 등급이 있으며 가장 최상위 등급을 원형(Prototype)이라고 한다.

'새'중에서도 '참새'를 원형(Prototype)으로 두고 예시를 들어보자.

프로토타입 참새 예시

'타조'같은 경우는 전통적인 속성을 통해 분류를 하자면 '새'가 되지만 프로토타입 이론에서는 '원형'에서 가장 멀리 떨어진 비전형적인 새가 된다. 즉, 특징이 다를수록 원형에서 멀리 떨어진 범주가 되는 것이다.

프로토타입 기반 언어의 특징은 어떠할까? 다음과 같다.

  • 객체 생성은 일반적으로 복사를 통해 이루어진다.
  • 확장(extends)은 클래스가 아니라 위임(delegation)
  • 프로토타입 프로그래밍은 일반적으로 분류하지 않고 유사성을 활용하도록 선택
  • 어휘, 쓰임새는 맥락(context)에 의해 평가된다. (렉시컬 환경)

그렇다면 다시 위의 참새 예시를 다시 도식화 해보자.

프로토타입 참새와 타조 예시

먼저 타조의 원형(Prototype)은 참새이다 그리고 위의 그림에서 타조에게 없는 속성(날개 개수 등)은 프로토타입 체인을 통해 참조된다. 또한 타조에게 날수 있음의 속성을 false로 변경해도 타조의 원형(Prototype), 즉 참새의 속성은 변하지 않는다. 자바스크립트의 문법으로 프로토타입 원형을 변경할 수 있지만 권장하지 않는 부분이다.

지금까지 클래스와 프로토타입이 각각 무엇이며, 객체를 어떻게 바라보는지 알아보았다. 프로토타입과 클래스의 만들어진 이론을 알게 되면 이 둘은 명백히 다른 개념이라는 것을 알 수 있다. 이제 프로토타입이 왜 나왔는지 알게 되었으니 책의 본문으로 넘어가보자.


19.2 상속과 프로토타입

상속은 객체 지향 프로그래밍의 핵심 개념으로, 어떤 객체의 프로퍼티 또는 메서드를 다른 객체가 상속받아 그대로 사용할 수 있는 것을 말한다. 그리고 이러한 프로토타입의 기반으로 상속을 구현하면 불필요한 중복을 제거하고, 코드 재사용성을 통해 개발 비용을 줄일 수 있다.

// 생성자 함수
function Circle(radius) {
  this.radius = radius;
  this.getArea = function () {
    return math.PI * this.radius ** 2;
  };
}

// 반지름이 10인 인스턴스 생성
const circle10 = new Circle(10);

// 반지름이 20인 인스턴스 생성
const circle20 = new Circle(20);

console.log(circle10.getArea === circle20.getArea); // false

Circle 생성자 함수는 radius 상태값과 getArea 메서드를 가지고 있다. 그리고 인스턴스화를 통해 circle10과 circle20에 각각 동일한 내용의 상태값과 메서드를 상속했다. 하지만 이 방식에는 큰 문제점이 있다. 바로 동일한 동작을 하는 메서드를 중복 생성한다는 것이다.

프로토타입 상속 안좋은 예시

위 그림과 같이 인스턴스를 통한 상속은 동일한 내용의 메서드를 사용하므로 상속할 메서드는 단 하나만 생성하여 모든 인스턴스가 공유해서 사용할 수 있도록 만드는 것이 바람직하다. 그렇다면 어떻게 하면 메서드는 하나만 만들면서 여러 인스턴스가 그 하나의 메서드를 공유할 수 있을까? 이때 나오는 것이 바로 프로토타입을 기반으로 한 상속이다.

// 생성자 함수
function Circle(radius) {
  this.radius = radius;
}

Circle.prototype.getArea = function () {
  return math.PI * this.radius ** 2;
};

// 반지름이 10인 인스턴스 생성
const circle10 = new Circle(10);

// 반지름이 20인 인스턴스 생성
const circle20 = new Circle(20);

console.log(circle10.getArea === circle20.getArea); // true
프로토타입 상속 좋은 예시

위 사진처럼 prototype 객체를 이용하여 getArea 메서드는 단 하나만 생성하고, circle10과 circle20의 인스턴스들은 그 메서드를 공유해서 사용하고 있고, 이러한 상속은 코드의 재사용 관점에서 매우 유용하다.


19.3 프로토타입 객체

프로토타입은 어떤 객체의 상위 객체의 역할을 하는 객체로서 다른 객체에 공유 프로퍼티(메서드 포함)를 제공한다. 프로토타입을 상속받은 하위 객체는 상위 객체의 프로퍼티를 자신의 프로퍼티처럼 자유롭게 사용할 수 있다.

모든 객체는 [[Prototype]]이라는 내부 슬롯을 가지며, 이 내부 슬롯의 값은 프로토타입의 참조다. 그리고 객체가 생성될 때 객체 생성 방식에 따라 프로토타입이 결정되고 [[Prototype]]에 저장된다. 그리고 가장 중요한 핵심은 모든 객체는 하나의 프로토타입을 갖는다. 그리고 모든 프로토타입은 생성자 함수와 연결되어 있다.

19.3.1 __prototype__ 접근자 프로퍼티

모든 객체는 [[Prototype]] 내부 슬롯에 접근자 프로퍼티를 통해 간접적으로 접근할 수 있다. 그리고 상위 객체는 하위 객체의 프로토타입에 접근할 때 __prototype__ 접근자 프로퍼티를 통해 간접적으로 접근할 수 있다.

모든 객체는 프로토타입의 계층 구조인 프로토타입 체인에 묶여 있다. 자바스크립트 엔진은 객체의 프로퍼티에 접근하려고 할 때 해당 객체에 접근하려는 프로퍼티가 없다면 __prototype__ 접근자 프로퍼티가 가리키는 참조를 따라 자신의 부모 역할을 하는 프로토타입의 프로퍼티를 순차적으로 검색한다. 프로토타입 체인의 최상위 객체는 Object.prototype이며, 이 객체의 프로퍼티와 메서드는 모든 객체에 상속된다.

그렇다면 __prototype__를 통해 프로토타입에 접근하려는 이유는 무엇일까? [[Prototype]] 내부 슬롯의 값에 접근하기 위한 이유는 상호 참조에 의해 프로토타입 체인이 생성되는 것을 방지하기 위해서다.

const parent = {};
const child = {};

child.__proto__ = parent;
parent.__proto__ = child; // TypeError: Cyclic __proto__ value
프로토타입 비정상적인 체인 예시

프로토타입은 단방향 링크드 리스트로 구현되어야 한다. 하지만 위의 사진처럼 구현했을 경우 양방향으로 흐르며, 비정상적인 프로토타입 체인이 된다. 이런식으로 순환이 된다면 프로토타입 체인의 종점이 없어지므로 무한 루프에 빠지게 된다. 그래서 TypeError: Cyclic proto value 라는 에러를 발생시키는 것이다.

그리고 접근자 프로퍼티를 코드 내에서 직접 사용하지 않는 것을 권장한다고 책에서 주장하고 있다. 이유는 모든 객체가 __prototype__ 접근자 프로퍼티를 사용할 수 있는 것이 아니기 때문이다. 또한 prototype을 상속받지 못하는 객체를 생성할 수도 있기 때문이다. 아래와 같은 코드가 이러한 예시이다.

// obj는 프로토타입 체인의 종점이다. 따라서 Object.__prototype__을 상속받을 수 없다.
const obj = Object.create(null);

console.log(obj.__prototype__); // undefined

//따라서 코드 내에서는 __prototype__ 보다는 getPrototypeOf 메서드를 권장한다.
console.log(getPrototypeOf(obj)); // null

19.3.2 함수 객체의 prototype 프로퍼티

함수 객체만이 소유하는 prototype 프로퍼티는 생성자 함수가 생성할 인스턴스의 프로토타입을 가리킨다.

function testFunc() {}
test.hasOwnProperty("prototype"); // true

// 일반 객체는 prototype 프로퍼티를 소유하지 않는다.
const testObj = {};
testObj.hasOwnProperty("prototype"); // false

그리고 또 한가지 유의할 점이 있다. 바로 ES6 문법인 화살표 함수도 prototype 프로퍼티를 소유하지 않는다.

const Person = (name) => {
  this.name = name;
};

Person.hasOwnProperty("prototype"); // false

모든 객체가 가지고 있는 (Object.prototype으로 부터 상속받은) __prototype__ 접근자 프로퍼티와 함수 객체만이 가지고 있는 prototype 프로퍼티는 결국 동일한 프로토타입을 가리킨다. 하지만 이들 프로퍼티를 사용하는 주체가 다르다.

구분소유사용 주체사용 목적
접근자 프로퍼티모든 객체프로토타입의 참조모든 객체객체가 자신의 프토토타입에 접근 또는 교체하기 위해 사용
prototype 프로퍼티constructor프로토타입의 참조생성자 함수생성자 함수가 자신이 생성할 객체(인스턴스)의 프로토타입을 할당하기 위해 사용

19.5 프로토타입의 생성 시점

객체는 리터럴 표기법 또는 생성자 함수에 의해 생성되므로 결국 모든 객체는 생성자 함수와 연결되어 있다. 프로토타입은 생성자 함수가 생성되는 시점에 더불어 생성된다. 프로토타입과 생성자 함수는 단독으로 존재할 수 없고 언제나 쌍으로 존재하기 때문이다. 생성자 함수는 사용자가 직접 정의한 사용자 정의 생성자 함수와 자바스크립트가 기본 제공하는 빌트인 생성자 함수로 구분할 수 있다. 각각의 생성자 함수에서 프로토타입 생성 시점을 알아보자.

19.5.1 사용자 정의 생성자 함수와 프로토타입 생성 시점

일반 함수로 정의한 함수 객체는 new 연산자와 함께 생성자 함수로서 호출할 수 있다. 생성자 함수로서 호출할 수 있는 함수, 즉 constructor는 함수 정의가 평가되어 함수 객체를 생성하는 시점에 프로토타입도 더불어 생성된다.

// 함수 정의(constructor)가 평가되어 함수 객체를 생성하는 시점에 프로토타입도 더불어 생성된다.
console.log(Person.prototype); // { constructor: ƒ}

// 생성자 함수
function Person(name) {
  this.name = name;
}

const Person = (name) => {
  this.name = name;
};

생성자 함수로서 호출할 수 없는 함수, 즉 non-constructor는 프로토타입이 생성되지 않는다.

// non-constructor는 프로토타입이 생성되지 않는다.
const Person = (name) => {
  this.name = name;
};

console.log(Person.prototype); // undefined

19.5.2 빌트인 생성자 함수와 프로토타입 생성 시점

Object, String, Number, Function, Array, Date, Promise, RegExp 등과 같은 빌트인 생성자 함수도 일반 함수와 마찬가지로 빌트인 생성자 함수가 생성되는 시점에 프로토타입이 생성된다. 모든 빌트인 생성자 함수는 전역 객체가 생성되는 시점에 생성된다. 생성된 프로토타입은 빌트인 생성자 함수의 prototype 프로퍼티에 바인딩 된다.

Object 생성자 함수와 프로토타입

이처럼 객체가 생성되기 이전에 생성자 함수와 프로토타입은 이미 객체화되어 존재한다. 이후 **생성자 함수 또는 리터럴 표기법으로 객체를 생성하면 프로토타입은 생성된 객체의 [[Prototype]] 내부 슬롯에 할당된다. 이로써 생성된 객체는 프로토타입을 상속받는다.


19.7 프로토타입 체인

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

// 프로토타입 메서드
Person.prototype.sayHello = function () {
  console.log(`Hi, My name is ${this.name}`);
};

const me = new Person("Useong Lee");

// hasOwnProperty는 Object.prototype이다.
console.log(me.hasOwnProperty("name")); // true

위의 코드에서 true값이 나온 이유는 무엇일까? 모든 생성자 함수에 의해 생성된 객체는 Object.prototype의 메서드인 hasOwnProperty를 호출할 수 있다. 즉, me 객체는 Person.protype과 Object.prototype를 상속받았다는 것을 뜻한다. 그래서 prototype 프로퍼티를 가지고 있으며, me 객체의 프로토타입은 Person.prototype이다.

자바스크립트는 객체의 프로퍼티(메서드 포함)에 접근하려고 할 때 해당 객체에 접근하려는 프로퍼티가 없다면 [[Prototype]] 내부 슬롯의 참조를 따라 자신의 부모 역할을 하는 프로토타입의 프로퍼티를 순차적으로 검색한다. 그리고 이를 프로토타입 체인이라고 한다.

me.hasOwnProperty("name"); // true

위의 코드에서 프로토타입의 프로퍼티를 순차적으로 어떻게 검색해서 접근했는지 알아보자.

먼저 hasOwnProperty 메서드를 호출한 me 객체에서 hasOwnProperty 메서드를 검색한다. me객체에는 hasOwnProperty 메서드가 없으므로 프로토타입 체인을 따라 [[Prototype]] 내부 슬롯에 바인딩되어 있는 프로토타입으로 이동하여 hasOwnProperty 메서드를 검색한다.

Object.prototype에는 hasOwnProperty 메서드가 존재한다. 자바스크립트는 프로토타입 체인으로 인해 Object.prototype.hasOwnProperty 메서드를 호출한다. 이때 Object.prototype.hasOwnProperty 메서드의 this에는 me 객체가 바인딩된다.

자바스크립트 엔진은 프로토타입에 따라 프로퍼티/메서드를 검색한다. 하지만 식별자는 스코프 체인에 의해 스코프의 계층적 구조를 인식하고 찾아낸다.

me.hasOwnProperty("name"); // true

다시 이 코드를 살펴보자. 먼저 스코프 체인에서 me 식별자를 검색한다. me도 함수를 할당한 변수이며 식별자이다. 식별자는 전역에 선언되었다. 그렇다면 전역 스코프에서 검색이 될 것이다. me 식별자를 검색한 후, me 객체의 프로토타입 체인해서 hasOwnProperty 메서드를 검색한다. 여기서 말하고자하는 핵심은 스코프 체인과 프로토타입 체인은 서로 협력하여 식별자(me)와 프로퍼티(hasOwnProperty)를 검색하는 데 사용한다.

Object.prototype.hasOwnProperty.call(me, "name");



관련 글 추가 예정