Javascript prototype inheritance

최근 Javascript 는 그야말로 어디에나 쓰인다고 해도 과언이 아닌데, 그 범용성 만큼이나 문법이 변태같다 특이하다. 이는 functional 한 언어인 동시에 object oriented 코딩을 지원하기 때문에 더더욱 그렇다. 게다가 class 도 아니고 prototype 방식으로 OOP 를 구현하여 더더욱 알수 없는 물건이 되었다.

The 'new' keyword from FunFunFunction

Javascript 의 상속을 이해하기 위해서는 우선 new 키워드를 알아야 할 필요가 있다.

What's new?

new Person() 이 코드는 어떻게 동작할까? new 키워드는 우선 새로운 object 를 생성한다. 그리고 뒤에 오는 Person 함수의 prototype 을 해당 object 의 prototype 으로 설정한 뒤, apply 를 호출하여 object 를 함수의 this 로 바인딩하며 실행해준다. 말이 길다 코드를 보자 그래서 new 키워드는 다음과 같이 다시 쓸 수 있다.

function Person(saying) {  
  this.saying = saying
}

Person.prototype.talk = function() {  
  console.log('I say:', this.saying)
}

function spawn(constructor) {  
  var obj = {} // 우선 object 를 생성하고
  Object.setPrototypeOf(obj, constructor.prototype) // 프로토타입을 설정한 후
  var argsArray = Array.prototype.slice.apply(arguments)
  return constructor.apply(obj, argsArray.slice(1)) || obj // apply 로 this 에 obj 를 바인딩
}

var crockford = spawn(Person, 'SEMICOLANS!!!1one1')  
crockford.talk()  

제대로 상속 되었는지 확인해보면, 아래처럼 true 가 출력된다.

console.log(crockford instanceof Person)

> true

new 키워드 없이 프로토타입 함수를 사용한다면 어떻게 될까?

var eminem = Person('Slim Shady!')  
eminem.talk()

> TypeError: undefined is not an object (evaluating 'eminem.talk')

eminem 에는 talk 라는 메소드가 없다는 에러 메시지가 나온다. 일단 talk 는 그렇다 치고, 코드 상의 this.saying = saying 이 어딘가 사라진 것은 아닐텐데, 어떻게 된걸까?

console.log(window.saying)

> "Slim Shady!"

function 을 호출할때 객체를 바인딩 해주지 않았기 때문에 this === window 가 되어1, saying 은 글로벌 객체에 바인딩 되었다. 왠지 그럼 window.talk() 도 될것 같으니 한번 해보자.

window.talk()

> TypeError: window.talk is not a function. (In 'window.talk()', 'window.talk' is undefined)

아니 이건 또 왜 에러가 나오지 하겠지만, 사실 당연한 것이다. Person.prototype 에 talk 메소드를 만들어 주었지만, new 키워드가 없이는 prototype 이 window 객체에 바인딩 되지 않아서 object 검색 패스에서 찾을 수 없게 된다. 그렇다면 window.saying 을 함수로 호출하고 싶으면 어떻게 해야 할까?

Person.prototype.talk.call(window) // window 대신 this 를 사용해도 무방

> I say: "Slim Shady!"

혹은

var talk = Person.prototype.talk.bind(window)  
talk()

> I say: "Slim Shady!"

도대체 이게 뭔가 싶다.

__proto__prototype

우선 코드부터 보자.

Person.prototype.rap = function () {  
  console.log('Bling bling pot dolizzle fizzle shiz, ' + this.saying)
}

var nas = new Person('Nas is like')  
nas.rap()

> Bling bling pot dolizzle fizzle shiz, Nas is like

지금 이 상태로 쭉 코드를 작성해왔다고 가정했을때, nas 는 분명 rap() 을 할 수 있을 것 같은데, crockford 도 할 수 있을까?

crockford.rap()

> Bling bling pot dolizzle fizzle shiz, SEMICOLANS!!!1one1

정답은 그렇다 이다. 왜냐하면 이는 javascript 의 object property 검색 방식과 prototype 바인딩 구조 때문이다.

var jayZ = new Person('u huh')  

변수 jayZ 는 name 이라는 property 를 가지고 있는데, 재밌는 것은 jayZ.prototype 을 로그로 확인해보면 undefined 가 출력된다. prototype 은 또 어디로 증발했을까?

console.log(jayZ.__proto__)

> Person {talk: function, rap: function}

prototype 은 함수에만 존재하고, new 키워드로 객체가 되면 __proto__ 로 참조 되도록 구성된다. 즉 prototype 에 등록된 메소드는 jayZ 의 메소드가 되는 것이 아니라, jayZ 의 프로토타입의 원형이 되는 객체인 __proto__ 로 연결 된다.

객체의 프로퍼티에 접근하기 위해서는 ECMAscript 의 object property specification 에 따라 jayZ 자신의 객체를 확인 한 후 없으면 __proto__ 를 검색하게 되고, 여기에도 없으면 Person 의 prototype 인 Function 의 property 를 찾는 절차로 Look Up 이 이루어진다.2 드럽게 복잡하네~ (좀 더 자세한 내용은 MDN 객체 모델의 세부사항 항목을 참고하자.)

Prototype 의 inheritance

자 이제 그럼 상속을 구현해보자. 상속은 어떻게 구현해야 할까? 우선 다시 한번 Person prototype 을 다음과 같이 작성하자.

function Person () {  
  this.race = 'human'
}

console.log(Person.prototype)

> Person {}

Person 이라고 하는 함수의 prototype 은 Person 자신 인 것 까지는 알겠는데, 그 뒤를 보면 {} 라고 하는 object literal 이 표시 된다. 즉, 함수의 prototype 은 객체라는 것이다. 때문에 상속을 위해서는 함수의 prototype 은 객체 형태로 대입시켜야 한다. 상위 클래스를 extends 하기 위해서는 다음과 같이 코드를 작성하면된다.

function Person () {  
  this.race = 'human'
}

function Korean () {  
  this.lang = 'Korean'
}

Korean.prototype = new Person()  
var chulsoo = new Korean()

console.log(chulsoo.lang + ' is ' + chulsoo.race)

> Korean is human

일단 작동은 잘 하는것 같다. 다음 코드를 실행해보자.

console.log(chulsoo instanceof Person, chulsoo instanceof Korean)

> true true

그렇다면 부모 클래스의 생성자를 가져다 쓰는 경우는 어떨까?

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

function Korean () {  
  this.lang = 'Korean'
}

Korean.prototype = new Person()

var chulsoo = new Korean('Chul-Soo')  
console.log(chulsoo.name + ' speaks ' + chulsoo.lang)

> undefined speaks Korean

어라? 뭔가 잘못한 듯 잘한듯 모호한 코드가 되었는데 어쨋든 원치 않는 결과물이 나온 것 같다.

Korean prototype 이 Person 의 constructor3__proto__ 객체에 가지고 있을테니 이걸 호출하면 될것도 같다. 해보자.

chulsoo.__proto__.constructor('Chul-Soo')  
console.log(chulsoo.name + ' speaks ' + chulsoo.lang)

> Chul-Soo speaks Korean

되긴 되는데 뭔가 찝찝한 마음을 지울수가 없다. Korean 함수가 만들어질때 이 Person 의 constructor 를 호출 하게끔 해줄 순 없을까?

다음과 같이 코드를 써보자. 한~참 위로 올라가면 function 을 단순하게 호출하면 this 가 바인드 되어 있지 않기 때문에 global (window) 객체를 참조하게 되어있다고 언급하였다. Korean 함수를 new 로 호출하면 객체를 바인딩 해주는 것이니 이 this 를 Person 함수에 바인딩 해주면 어떨까? 코드는 다음과 같다.

function Korean(name) {  
  Person.call(this, name)

  this.lang = 'Korean'
}

Korean.prototype = new Person()

var chulsoo = new Korean('Chul-Soo')  
console.log(chulsoo.name + ' speaks ' + chulsoo.lang)

> Chul-Soo speaks Korean

이제야 비로소 제대로 작동한다. 물론 Korean 함수는 applyarguments 를 사용해서 다음과 같이 쓸 수도 있다.

function Korean() {  
  Person.apply(this, arguments)

  this.lang = 'Korean'
}

재밌는 것은 경우에 따라 다를 수 있겠지만 Korean.prototype = new Person() 을 제거한 다음 코드도 잘 작동한다.4

function Korean(name) {  
  Person.apply(this, arguments)

  this.lang = 'Korean'
}

var chulsoo = new Korean('Chul-Soo')  
console.log(chulsoo.name + ' speaks ' + chulsoo.lang)

> Chul-Soo speaks Korean

다만, 이 경우에는 instanceof 연산자로 Person 을 확인하면 false 를 리턴하게 된다.

console.log(chulsoo instanceof Person, chulsoo instanceof Korean)

> false true

ES6 의 class

이처럼 prototype 방식의 상속이 복잡할 뿐만 아니라 방법 또한 다양하다. 따라서 ES6 표준에서는 syntactic sugarclass 를 소개하고 있는데, 사용방식이 훨씬 더 직관적이다.

class Person {  
  constructor(name) {
    this.name = name
  }
}

class Korean extends Person {  
  constructor(name) {
    super(name)

    this.lang = 'Korean'
  }
}

var chulsoo = new Korean('Chul-Soo')  
console.log(chulsoo.name + ' speaks ' + chulsoo.lang)

> Chul-Soo speaks Korean

아니면 위와 유사하게 es6 의 spread operator 를 사용하여 다음과 같이 작성할 수도 있다.

class Korean extends Person {  
  constructor() {
    super(...arguments)

    this.lang = 'Korean'
  }
}

Babel이 보편적으로 사용되고 있는 지금 굳이 javascript 에서 OOP 를 구현해야 한다면 class 를 쓰는 것이 당연하겠지만, prototype 의 특성을 이해하지 못하고서는 실제 어떤식으로 작동되는지 단편적인 사실 밖에는 알 수 없다. 솔직히 굳이 알아야 하나 싶긴 하지만...

Object.create

마지막으로 Object.create 로 객체를 생성하는 방법에 대해서 알아보자. Object.create 는 EC5 에서 도입된 방식으로 프로로타입 객체를 첫번째 인수로 넘겨주고, 그 뒤에는 object 형태로 값을 대입해주면 된다. 말보단 코드~~

var person = {  
  name: 'Chul-Soo'
}

var chulsoo = Object.create(person, {  
  lang: {
    value: 'Korean'
  }
})

console.log(chulsoo.name + ' speaks ' + chulsoo.lang)

> Chul-Soo speaks Korean

Object.create 를 사용하기 위해 구현 방식을 일부 수정해보았다. 이러한 방식으로 객체를 생성하면 instanceof 사용 방식이 달라진다.

console.log(chulsoo instanceof person.constructor)

> true

person 은 객체 이므로 prototype 이 없는데다가 __proto__ 는 객체이기때문에, constructor 를 비교해야 제대로 어디서 상속받은 객체인지 제대로 확인할 수 있다. 다른 방법으로는 isPrototypeOf 메소드를 사용해도 된다.

console.log(person.isPrototypeOf(chulsoo))

> true

대부분의 개발자들이 Javascript 에서 상속 방식으로 Object.create 를 추천하는데, 이는 가독성도 좋고, 사용하기 편리하기 때문이다. prototype inheritance 는 언급한 것 이외에도 Object.setPrototypeOf 등 다양한 방식으로 구현할 수 있고, 이에 따라 특성도 조금씩 달라지기 때문에, 가급적이면 Object.create 를 추천한다.

... 지만

그냥 함수형으로 composition 을 사용하는 편이 더 낫다. 다음번에는 그걸 사용해보자.

참고자료

  1. Javascript 에서는 closure 로 감싸지 않은 경우 var, function 키워드 등으로 선언한 변수(혹은 함수)는 window (global) 객체에 property 로 할당된다. 때문에 this 를 호출하면 window 객체가 불려지는 것이다.

  2. 표준 문서를 찾지 못해서 정확하진 않으나 대체로 유사한 방식으로 동작한다고 보면 된다.

  3. __proto__ 의 prototype function 이 바로 이 constructor 다.

  4. prototype 으로 주입해준 메소드나 프로퍼티가 있다면 물론 액세스가 불가능하게 될 것이다.

comments powered by Disqus