자바스크립트의 프로토타입에 대해

객체지향 프로그래밍 언어하면 떠올리는 단어가 바로 “클래스”이고, 실제로 대부분의 객체 지향 성격을 갖는 언어들은 대부분 클래스를 지원한다. 그런데 자바스크립트는 클래스가 아닌 프로토타입 기반의 OOP를 사용한다. 오늘은 이 프로토타입에 대하여 살펴보도록 하겠다.

자바스크립트의 객체

어느 정도 고도화된 OOP 언어들은 클래스라는 객체의 청사진(blueprint)을 사용한다. 클래스는 어떤 객체가 갖추어야 할 요건들을 정의해놓은 설계도 같은 것이다. 객체를 생성한다는 것은 이 설계도에 따라 내부 구조를 구성하기 위한 메모리 공간을 할당하고, 정해진 방법에 따라 메모리 공간을 초기화하는 작업을 말한다. 그리고 이렇게 생성된 객체를 클래스와 대비하여 객체 인스턴스(instance)라 부른다.

정적인 객체 지향 언어에서는 어떤 객체는 클래스가 정의해놓은 속성들만 갖는다. 런타임에 특정한 객체에 속성을 추가할 수 있는 특징을 가진 언어들도 있다. 자바스크립트의 객체도 후자에 해당하는데, 클래스에 의해서 객체 내부의 구조가 정해지기 보다는 단순히 파이썬의 사전이나 루아의 테이블과 같이, 키와 값의 쌍으로 속성들이 객체에 들어가 있다고 보는 것이 맞다. 즉 자바스크립트에서의 객체는 (키, 값)의 쌍을 여러 개 가지고 있는 가방에 비유할 수 있다.

사실 이 비유는 다른 많은 클래스 기반 언어에서도 똑같이 적용될 수 있는 비유이다. 어떤 객체의 속성을 참조하는 것은 결국 그 객체 내에서 원하는 속성을 찾는 과정(property/attribtue lookup)을 거친다는 점에서는 차이가 없기 때문이다.

기본적으로 자바 스크립트에서 어떤 객체를 만드는 것은 객체 리터럴 문법에 의해 이루어진다. 객체 리터럴문법은 중괄호({ ... }) 속에 콜론으로 구분된 프로퍼티 키와 값의 쌍을 콤마로 구분하여 기술한다. 예를 들면 다음과 같은 식이다.

var anObj = {
  name: "Unnamed",
  numberValue: 1.0
};

클래스를 사용하는 OOP언어들은 특정한 클래스를 정의하고, 해당 클래스가 제공하는 인스턴스 생성자를 호출하여 새로운 객체를 생성한다. 그에 비해 자바스크립트의 객체 리터럴은 인스턴스를 생성한다기보다는 일종의 딕셔너리를 코딩한다는 느낌으로 객체를 만들고 있다는 점을 볼 수 있다. 이 점은 약간 당혹스러운 느낌이 들 수 있는데, 특정한 규격의 객체를 찍어낼 수 있는 붕어빵 틀이 없이 매번 빈 객체에서 시작해서 필요한 속성들을 일일이 초기화 해야하는 것일까? 예를 들어 연락처 앱 같은 것을 만들기 위해서 이름과 나이, 성별, 전화번호와 같은 필드를 담고 있는 객체를 계속해서 리터럴 문법으로 생성하는 것은 매우 귀찮은 일이 될 것이다. 그래서 생성자와 비슷하게 다음과 같이 함수를 정의하는 방법을 생각해 낼 수 있을 것이다.

function createPerson(name, telinfo) {
  var person = {
    name: name,
    telinfo: telinfo
  };
  return person;
}

이렇게 해서 객체의 생성자 비슷한 함수를 만들 수는 있지만, 여전히 부족한 것이 많다. 같은 클래스의 인스턴스인 객체들은 동종 타입과 같이 비슷한 속성을 자동으로 부여 받으며, 뭔가 공통적인 행동들을 할 수 있을 것인데, 여기서 만들어지는 객체들은 사실 서로 아무런 연관성이 없다. (단순히 우연하게 저 함수를 통해서 생성되었기 때문에 초기 상태의 프로퍼티 구성이 같을 뿐이다.)

같은 클래스로부터 만들어지는 객체들이 공통점을 갖는 것과 비슷한 효과를 내도록해주는 개념이 자바스크립트에서는 프로토타입이다.

프로토타입의 개념

사실 클래스를 사용하는 언어라고 해서 그러한 모든 언어들에서 클래스가 완전히 동일한 개념은 아니다. 파이썬의 경우 클래스는 붕어빵 틀이기도 하면서 동시에 클래스 자체가 객체이기도 하다.(사실 파이썬의 클래스 속성은 자바스크립트의 프로토타입과 비슷하다) 이 말은 자바 스크립트처럼 클래스가 없더라도 객체 인스턴스를 생성하고 적절하게 초기화 시키는 방법만 있다면 객체 지향 언어로 사용될 수 있다는 점을 시사하기도 한다.

자바스크립트에서 프로토타입의 개념을 이해하기 위해서는 우선 앞서 언급한 바와 같이 객체가 단순히 속성과 속성 이름들을 담고 있는 가방으로 보는 관점이 필요하다. 그리고 프로토타입은 다른 객체들에게 자신의 속성을 빌려주는 역할을 수행한다. 클래스를 사용하는 언어에서 어떤 클래스의 인스턴스 객체들은 그 클래스가 정의된 대로 공통된 특성을 갖는다. 반면 프로토타입 기반 언어인 자바스크립트에서는 인스턴스 객체가 특정한 속성을 요구받을 때, 객체 그 자신이 소유한 속성이 아니라면 해당 객체의 프로토타입으로부터 해당 속성을 가져오게 한다. 이렇게 “같은 부류”의 객체들은 공통의 프로토타입을 공유함으로써 같은 속성들을 공유하게 된다.

사실 이 개념은 파이썬과 같은 언어의 클래스 속성과 매우 비슷하다 할 수 있다. 파이썬에서 어떤 객체에 특정한 속성을 조회하려 할 때, 해당 객체 내의 그러한 이름의 속성이 없을 때에는 찾고자하는 속성의 이름이 클래스 속성인 것으로 간주한다. 그 클래스 객체에도 해당 이름의 속성이 없다면 부모 클래스를 통해서 속성 이름을 찾는 것이 연쇄될 것이다.

이렇듯 프로토타입은 어떤 객체들이 가지고 있어야 할 속성들을 정의해서 가지고 있는 가방이며, 이 매커니즘을 통해 클래스-인스턴스와 비슷한 관계를 구현할 수 있게 된다.

프로토타입과 객체 프로퍼티

따우리는 프로토타입과 객체 인스턴스와의 관계를 다음과 같이 정리할 수 있다.

  1. 객체의 구조는 단순한 이름:키의 짝들로 구성되는 프로퍼티 꾸러미이며, 여기에는 그 이상의 내용은 없다.
  2. 프로토타입은 메타타입으로서 자신을 프로토타입으로 가지는 객체들이 ‘요구받을지 모를’ 값들의 디폴트값을 정의해놓을 수 있다.
  3. 프로토타입이 정의한 값들은 실제 객체 인스턴스의 프로퍼티에 의해 셰도우잉된다.
  4. 따라서 각 객체 인스턴스가 키와 값을 세팅받으면, 프로토타입이 아닌 그 인스턴스 내의 프로퍼티가 된다.

즉 프로토타입은 그냥 객체인 동시에, 자신을 타입으로(?) 간주하는 인스턴스들에게 특정한 이름의 프로퍼티를 빌려줄 수 있는 객체인 셈이다. 그러면 일반 객체와 프로토타입이 되는 객체는 어떻게 서로 관계를 맺을 수 있을까?

함수의 prototype과 constructor 함수

자바스크립트에서 함수는 first citizen이다. 흔히 “일급 객체”라 번역되는 이 개념은 어떤 것이 1) 변수에 대입될 수 있고, 2) 함수의 인자가 될 수 있으며, 3) 함수의 리턴값이 될 수 있다는 것을 의미한다. 이런 성질들을 가진다는 것은, 메모리 영역 내에서 다른 것들과 구분되는 객체로 볼 수 있다는 점이다. 함수 그 자체를 객체로 다룰 수 있는 언어는 많이 있으니 이것은 그리 특이한 개념은 아닐 것이다.

그런데 자바스크립트의 객체는 프로퍼티를 넣고 다니는 주머니같은 것이라 했다. 따라서 자바스크립트의 함수는 그 자체가 어떤 속성을 가질 수 있고, 그것은 매우 당연한 것이다.

var add = function(x, y) { return x + y; };
add.description = "This is an add function. Return the sum of two given numbers.";

add(1, 2);
/// -> 3
console.log(add.description);
/// This is an add function. Return the sum of two given numbers.

자바스크립트에서 모든 함수는 prototype이라는 속성을 가지고 있다. 이것은 다음과 같은 이상한 특징을 가지고 있다.

  1. 함수의 프로토타입의 이름은 일단 함수의 이름과 같다.
  2. 그리고 그 함수를 통해서 생성되는 객체가 있는 경우, 그 생성된 객체의 프로토타입이 된다.

여기서 뭔가 불행이 시작되는데, somefunc.prototype이라는 속성은 그 함수의 프로토타입을 말하는 것이 아니다. 함수를 통해서 생성되는 객체의 프로토타입으로 지정될 객체를 말한다는 것이다. 이것이 어떻게 동작하는지를 보기 위해서 인스턴스 생성자 같은

function Person(name, age) {
  this.name = name;
  this.age = age;
  // 명시적인 리턴은 필요없다.
}

// 새로운 객체를 만드려면
let someone = new Person('Edward', 29');

Person()은 특정한 객체를 생성해서 그 결과를 리턴하는 것이 아니라, 마치 어떤 타입의 초기화 함수처럼 동작한다. 즉 이 함수의 내부에서는 this는 새로 생성된 객체가 되며, 그 값들을 초기화하고 있다. 이 함수를 사용하여 새로운 객체를 생성하고 초기화하는데에는 new 키워드가 사용되며, 단순히 함수를 호출하는 것으로는 어떠한 값도 얻을 수 없다.

이런 모양의 함수들을 자바스크립트에서는 특별히 constructor라 부르고 있다.constructor 함수들은 일반 함수처럼 호출하는 것이 아니라 new 라는 연산자를 통해서 사용한다.

let james = new Person('James', 20);
let mary = new Person('Mary', 18);

위와 같이 동일한 constructor 함수를 사용해서 생성한 두 객체는 다음의 측면에서 공통된 성질을 갖는다.

  1. 동일한 초기화 방식을 통해 구성되어 name, age라는 속성을 갖고 있다.
  2. 두 객체는 모두 같은 프로토타입을 참조한다.

Constructor 와 Prototype 그리고 메소드

모든 함수가 prototype이라는 프로퍼티를 가지고 있다는 사실과 비슷하게, 모든 객체는 constructor라는 프로퍼티를 가지고 있다. 또, constructor를 통해 생성된 모든 객체는 자신의 constructor의 prototype 속성을 통해서 자신의 프로토타입에 접근할 수 있다.

앞의 장에서 예로 들었던 Person() 함수를 통해서 생성된 객체들을 살펴보자. Person 함수에 의해 초기화되면서 각각의 객체 인스턴스들은 모두 저마다의 고유한 초기 속성들을 갖게 된다. 하지만 아직 이들에 대한 프로토타입은 지정되지 않았다. (빈 객체이다.) 따라서 이들 객체가 공통적으로 어떤 특성을 공유한다고 보기는 어렵다.

만약 Person이라는 타입의 모든 인스턴스에 대해서 favoriteFood 라는 속성을 추가하고 싶다고 하자. 기존에 생성된 모든 인스턴스를 찾아서 해당 프로퍼티를 추가하기보다는 Person이라는 프로토타입을 이용하면 보다 손쉽게 해당 프로퍼티를 동적으로 추가할 수 있다. new 키워드를 통해서 생성된 객체들의 프로토타입은 생성 함수의 prototype 속성으로 참조할 수 있고, 자신을 생성한 함수는 constructor로 찾을 수 있으니 다음과 같이 프로토타입을 조작할 수 있다.

참고로 위에서 만든 Person()의 프로토타입은 현재 텅 비어있는 프로퍼티 주머니이다. 여기에 새로운 공통 속성을 하나 추가해주자. 이렇게하면 같은 프로토타입을 공유하는 모든 객체들이 favoriteFood 라는 속성을 갖게 된다.

james.favoriteFood 
// -> undefined

james.constructor.prototype.favoriteFood = 'Donut';

james.favoriteFood
//-> 'Cheese'
mary.favoriteFood
//-> 'Cheese'

프로토타입이 제공하는 속성은 이른바 ‘디폴트’라 할 수 있다. 위 예에서 jamesfavoriteFood를 변경하면? 해당 객체에 새로운 속성이 정의되면서 그 이름에 대해서는 더 이상 프로토타입을 참조하지 않게 된다. mary는 이 동작에 대해서 영향을 받지 않는다.

james.favoriteFood = 'ice cream';
james.favoriteFood
// -> 'ice cream'
mary.favoriteFood
// -> 'Donut'

james.hasOwnProperty('favorteFood')
// true
mary.hasOwnProperty('favoriteFood')
// false

자바 스크립트에서는 어떤 객체의 특정한 속성이 그 객체의 고유한 것인지 프로토타입으로부터 빌려오는 것인지는 .hasOwnProperty() 메소드를 통해서 검사할 수 있다.

자 그럼 객체에 메소드를 정의하는 방법은 어떻게 될까 알아보자.

메소드를 정의하는 이상한 방법

자바스크립트에서 함수는 일급 객체라 하였으므로, 다른 객체의 속성으로 함수를 지정하는 것이 가능하다. 따라서 다음과 같이 익명함수를 이용해서 다음과 같이 메소드를 추가할 수 있다.

let ed = new Person('Edward', 29);
ed.greet = function() { return 'Hello, my name is ' + this.name + '.'; }
ed.greet();
// -> 'Hello, my name is Edward.'

하지만 이렇게 추가된 메소드는 단일 객체의 고유한 속성이므로, 다른 객체들과 공유되지 않는다. 이 방법을 그대로 Person 함수 내부로 가져가서 객체 생성시에 할당해주는 방법도 있겠다.

function Person(name, age) {
  this.name = name;
  this.age = age;
  this.greet = function(x) {
    return 'Hello, ' + x + '. My name is ' + this.name + '.'; 
  }
}

이 코드는 별 문제없이 잘 동작하는 것 같다. 클래스 기반의 여러 OOP 언어들에서 클래스 정의 코드 내에서 메소드를 정의하는 것과도 언뜻 매우 비슷하게 보인다. 하지만 이 코드는 한가지 문제가 있다. Person() 함수를 통해서 여러 개의 객체가 생성되면, Person()이 호출될 때마다 greet 속성을 초기화하기 위해서 똑같은 동작을 수행하는 여러 개의 함수가 매번 생성된다.

함수는 그 자체로 불변한 값으로 볼 수 있기 때문에, 이렇게 정의하는 것보다는 프로토타입을 사용해서 정의하는 것이 올바른 방법이다.

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

Person.prototype.greet = function(x) {
  return 'Hello, ' + x + '. My name is ' + this.name;
}

let jn = new Person('Jane', 24);
console.log(jn.greet('sooop'));
// Hello, sooop. My name is Jane.

다만 이 방법에서는

프로토타입에 대한 접근자, 그리고 프로토타입 체인

어떤 객체의 anObj의 프로토타입은 직접적인 접근자를 통하기 보다는 anObj.constructor.prototype 과 같은 식으로 해당 접근을 간접적으로 우회할 수만 있게 하고 있다. 사실 모든 객체는 내부적으로 [[prototype]] 이라는 외부에서는 액세스할 수 없는 프로퍼티를 가지고 있고, 이 프로퍼티에 의해 그 객체의 프로토타입을 참조할 수 있다.

모든 것을 런타임에 교체할 수 있는 자바스크립트의 특성상, 특정 객체 인스턴스의 프로토타입만큼은 런타임에 교체할 수 없게 하기 위한 디자인으로 보인다. 하지만 대부분의 브라우저 공급사들은 그러거나 말거나 해당 내부 프로퍼티에 접근할 수 있는 접근자를 만들어 두었으니, 그것은 바로 __proto__ 이다. (즉, 표준은 아니다.)

class 문법

ES2015에서는 클래스 기반 언어와 비슷한 방식으로 사용자 정의 타입을 클래스처럼 작성할 수 있게 한다. 이는 자바스크립트에 클래스 기능을 탑재하는 것이 아니라 class { .. }를 사용하는 표현을 내부적으로 이전 자바스크립트 문법으로 해석하여 동작하는 것이다.

class Person {
  constructor(name, age) {
    this.name = name;
    this.age = age;
  }

  greet(x) {
    return 'Hello, ' + x + '. My name is ' + this.name + '.';
  }

  // calculated property
  get intro() {
    return this.name + '(' + this.age + ')';
  }
}