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 으로 주입해준 메소드나 프로퍼티가 있다면 물론 액세스가 불가능하게 될 것이다.

Seokjun Kim

Read more posts by this author.

Subscribe to Make It Yourself

Get the latest posts delivered right to your inbox.

or subscribe via RSS with Feedly!
comments powered by Disqus