쉽게 이해하는 자바스크립트 프로토타입 체인

🗓 2017-02-21

l

자바스크립트는 객체 지향 언어이자 함수를 1급 객체로 취급하기 때문에 함수형 프로그래밍도 가능한 멀티 패러다임 언어이다. 자바스크립트를 조금이라도 다뤄봤던 사람이라면 익히 알고 있겠지만 자바스크립트에는 클래스란 개념이 없어 객체 생성이나 상속이 다른 언어와 다르고 특히 OOP는 주로 프로토타입이란 메커니즘을 통해 이루어진다. 검색을 조금만 해보면 프로토타입을 다룬 아티클들은 많이 찾아볼 수 있다. 하지만 여기서는 프로토타입에서도 상속을 구현하는 핵심 메커니즘인 포로토타입 체인에 대해 집중적으로 다룬다.

객체

자바스크립트에서 객체를 만드는 방법은 두가지 방법이 있다. 객체리터럴을 이용하거나 혹은 생성자를 이용한다.

var objectMadeByLiteral = {};

var objectMadeByConstructor = new Object();

리터럴은 Object 타입의 객체를 만들어내는 일종의 숏컷이고 두 번째 라인의 생성자를 이용한 코드 역시 Object 생성자이기 때문에 사실 위의 예제는 리터럴이나 생성자나 모두 객체의 내용이나 프로토타입의 구조면에서 동일한 객체를 만들어낸다. 둘 다 Object 타입을 갖는 객체로 Object 타입의 메서드인 hasOwnPropertytoString, valueOf 등을 사용할 수 있다. Object 타입은 모든 객체의 최상위 타입이다. 사실 다른 객체지향언어의 관점에서 보면 위의 코드는 Object 객체의 인스턴스를 만든것에 불과하니 상속받았다고 표현하기 힘들다. 하지만 자바스크립트에서는 조금 다른 개념으로 생각해야 한다. 지금 만들어진 객체가 Object 타입의 인스턴스 객체인 것도 맞지만 프로토타입을 이용한 상속을 지원하는 자바스크립트에서는 Object 생성자의 프로토타입을 상속받은 객체라고 표현하는 게 더 정확한 표현이다. 사실 상속이라는 표현도 OOP의 관점에서 사용하는 단어로 표현하고자 한것일뿐 실제로는 링크드리스트 형태의 참조를 통한 객체끼리의 연결에 가깝고 클래스 메커니즘처럼 정적이지 않고 매우 동적이다. 이런 동적인 연결이 좋다는 뜻은 아니다. 상속 구조의 변경이 언어나 엔진 차원에서 아무 제약이 없다 보니 약속된 컨벤션 규칙 혹은 안티 패턴에 대한 이해가 없어 제대로 사용하지 않았을 때는 헬게이트가 열리게 되는 단점이기도 하다.

프로토타입

프로토타입을 이용하면 객체와 객체를 연결하고 한쪽 방향으로 상속을 받는 형태를 만들 수가 있다. 자바스크립트에서 객체와 객체를 연결해서 상속 받는다는 것은 다른 말로 객체와 객체를 연결해 멤버함수나 멤버변수를 공유 한다는 뜻이다. 이런 점을 이용해 자바스크립트에서는 상속과 비슷한 효과를 얻는 것이다.

var a = {
    attr1: 1
}

var b = {
    attr2: 2
}

a 하고 b 라는 객체가 있다. a 에는 attr1 이라는 멤버변수가 있고 b 에는 attr2 라는 멤버변수가 있다. 지금은 b 객체를 통해 a의 attr1 속성에 접근할 방법이 없다. b 객체에서도 a의 attr1 을 마치 b가 소유한 것처럼 사용하길 원한다면 __proto__ 라는 특수한 속성을 이용한다.

var a = {
    attr1: 'a1'
}

var b = {
    attr2: 'a2'
}

b.__proto__ = a;

b.attr1 // 'a1'

자바스크립트 엔진은 객체를 일종의 키와 값을 가진 해쉬맵처럼 다룬다. 값에는 데이터와 함수가 들어갈 수 있고 엔진 내부에 필요한 데이터를 임의로 만들어 넣기도 한다. 물론 그게 자바스크립트단으로 노출이 될 수도 있고 안될 수도 있다. 프로토타입 체인의 핵심은 엔진이 사용하는 __proto__ 라는 속성이다. __proto__ 속성은 ECMAScript의 스펙 [[Prototype]] 이 자바스크립트로 노출된 것인데 예전 스펙이 legacy 처럼 남아있는 것이다. 모던 브라우저 개발자 도구에서도 디버깅 편의상 노출하고 있지만 개발 코드에서 직접적으로 접근하는 것은 피해야 한다. 여기서도 프로토타입의 이해를 돕기 위해 사용한다. __proto__ 이 참조한는 객체를 확인해야 하는 상황(예를 들면 프레임웍 개발)이라면 __proto__ 속성을 직접 사용하지 말고 Object.getPrototypeOf() 를 이용해서 참조하면 된다. b 의 __proto__ 속성이 a 객체를 가리키고(참조)있다. 이 말은 a의 멤버 변수나 메서드를 몇 가지 제약은 있지만 마치 b가 소유한 것처럼 사용할 수 있다는 것이다. 이 과정에서 상속과 비슷한 효과를 얻을 수 있게 된다. 다른 클래스를 통한 상속의 경우 클래스의 상속 정보를 이용해 상속 구조의 모습을 가진 새로운 객체를 찍어내는 반면 프로토타입을 통한 상속 구조는 존재하는 객체와 존재하는 객체의 동적인 연결로 풀어낸다. 그렇다 보니 이미 객체가 만들어진 상태라도 상속된 내용이 변경되거나 혹은 추가되기도 하고 아예 상속 구조를 바꿀 수도 있게 된다. 물론 대부분 안티 패턴이다.

var a = {
    attr1: 'a1'
}

var b = {
    attr2: 'a2'
}

b.__proto__ = a;

b.attr1 // 'a1'

a.attr1 = 'a000'; // 상속받은 객체의 내용 변경

b.attr1 // 'a000'

a.attr3 = 'a3' // 상속받은 객체의 내용이 추가
b.attr3 // 'a3'

delete a.attr1 // 상속받은 객체의 내용이 삭제
b.attr1 // undefined

일반적인 클래스개념에서는 상상도할 수 없는 일이다. 이런 방식이 좋다 나쁘다를 떠나서 자바스크립트에서는 이렇게 밖에 못한다. 사실 이런 성질을 이용해 클래스 메커니즘을 흉내내는것은 비교적 쉬운 반면 클래스 기반의 언어가 프로토타입 메커니즘을 흉내내기는 매우 어려울것이다. 아뭏든 객체와 객체의 연결을 통한 단방향 공유 관계를 프로토타입 체인이라고 한다. 자바스크립트의 프로토타입 상속은 사실 이 내용만 제대로 이해하면 90%이상 이해했다고 봐도 된다. 이런 연결을 직접적으로 코드에서 __proto__ 에 접근하지 않고 만들어야 하는데 그 방법은 몇가지가 있다. 하지만 그 전에 프로토타입을 통해 식별자를 찾는 과정을 조금 더 살펴보자.

프로토타입 식별자 룩업

자바스크립트에서 식별자를 룩업하는 방법은 두 종류의 메커니즘이 있다. 프로토타입 룩업과 스코프 룩업인데 여기서는 프로토타입 체인을 통해 식별자를 찾아가는 프로토타입 룩업을 살펴본다. 프로토타입 룩업이라는 과정은 클래스의 상속과 비교되는 특징 중 하나이다. 쉽게 이야기하면 클래스 상속은 객체를 만든 시점에 이미 이 객체가 상속구조를 통해 어떤 멤버들을 보유하고 있는지 결정된 반면 프로토타입 체인을 통한 상속의 경우 실행을 해봐야 객체가 해당 멤버를 가지고 있는지 알 수 있다. 물론 개발자는 알고 있겠지만 자바스크립트 엔진의 관점에서는 메서드를 실행할 때 동적으로 해당 메서드를 찾아서 실행한다는 의미다. 그래서 이미 만들어진 객체의 상속된 내용이 변경될 수 있는 것이다. 객체가 만들어 질 때가 아닌 실행할 때의 내용이 중요하니까 말이다. 이렇게 프로토타입 체인을 통해 객체의 메서스나 속성을 찾아가는 과정을 프로토타입 룩업이라고 한다.

var a = {
    attr1: 'a'
};

var b = {
    __proto__: a,
    attr2: 'b'
};

var c = {
    __proto__: b,
    attr3: 'c'
};

c.attr1 // 'a'

위의 코드에서는 객체를 세개 만들고 각 객체의 __proto__ 속성을 이용해 c -> b-> a 로 연결했다. c.attr1 로 c 객체에는 없는 attr1이라는 속성에 접근하면 자바스크립트 엔진은 아래와 같은 작업을 수행한다.(물론 엔진의 최적화에 따라 단계는 축소된다.)

  1. c객체 내부에 attr1 속성을 찾는다. -> 없다.
  2. c객체에 __proto__ 속성이 존재하는지 확인한다. -> 있다.
  3. c객체의 __proto__ 속성이 참조하는 객체로 이동한다. -> b객체로 이동
  4. b객체 내부에 attr1 속성을 찾는다. -> 없다.
  5. b객체에 __proto__ 속성이 존재하는지 확인한다. -> 있다.
  6. b객체의 __proto__ 속성이 참조하는 객체로 이동한다. -> a객체로 이동
  7. a객체 내부에 attr1 속성을 찾는다. -> 있다.
  8. 찾은 속성의 값을 리턴한다.

단순히 표현하면 __proto__ 의 연결을 따라 링크드리스트를 탐색하듯 탐색해서 원하는 키값을 찾는것이다. 여기서 어떤 객체에도 존재하지 않는 속성인 attr0 을 찾게 되면 7번부터 다른 과정을 거치게 된다.

(7번 부터)

  1. a객체 내부에 attr0 속성을 찾는다. -> 없다.
  2. a객체에 __proto__ 속성이 존재하는지 확인한다. -> 있다.
  3. a객체의 __proto__ 속성이 참조하는 객체로 이동한다. -> Object.prototype 로 이동
  4. Object.prototype 에서 attr0 속성을 찾는다. -> 없다.
  5. Object.prototype 에서 __proto__ 속성을 찾는다. -> 없다.
  6. undefined 리턴

모든 프로토타입 체인의 끝은 항상 Object.prototype 이다. 그래서 Object.prototype__proto__ 속성이 없다. attr0 라는 속성은 프로토타입의 마지막 단계인 Object.prototype 에 존재하지 않고 Object.prototype 에는 __proto__ 속성이 존재하지 않으니 탐색을 종료하고 undefined를 리턴한다. 크롬과 Node.js의 자바스크립트 엔진인 V8은 이 과정을 최적화해 탐색 비용을 줄여 퍼포먼스를 향상시켰다. 단일 링크드리스트 형태로 한쪽 방향의 연결이다 보니 상속이란 개념을 얼추 적용할 수 있다. c에서 a의 속성은 접근할 수 있지만 a에서 c의 속성은 접근할 수 없다.

var a = {
    attr1: 'a'
};

var b = {
    __proto__: a,
    attr2: 'b'
};

var c = {
    __proto__: b,
    attr3: 'c'
};

a.attr3 // undefined

이런 점을 이용해 메서드 오버라이드를 구현할 수 있다.

var a = {
    method1: function() { return 'a1' }
};

var b = {
    __proto__: a,
    method1: function() { return 'b1' }
};

var c = {
    __proto__: b,
    method3: function() { return 'c3' }    
};

a.method1() // 'a1'
c.method1() // 'b1'

c 객체에서 method1() 메서드를 실행하면 프로토타입 룩업을 통해 c 에서 b 로 이동하고 b 에 이미 해당 메서드가 있기 때문에 a 까지 안올라가고 b 의 method1() 이 실행된다. 자바스크립트에서는 이런 상황을 a 의 method1() 메서드를 b 가 오버라이드 했다고 한다. 이제 이런 프로토타입을 이용해 객체를 만드는 방법을 살펴보자.

생성자

생성자를 이용해 객체를 생성하면 생성된 객체는 생성자의 프로토타입 객체와 프로토타입 체인으로 연결된다. 기본적인 생성자 사용 방법에 대한 이해는 있다는 가정하에 이 글을 쓰고 있기 때문에 생성자의 기초적인 내용은 다루진 않고 프로토타입 체인을 설명하기 위한 부분만 다룬다.

//constructor
function Parent(name) {
    this.name = name;
}

Parent.prototype.getName = function() {
    return this.name;
};

var p = new Parent('myName');

매우 간단한 생성자 예제이다. Parent 라는 생성자가 만들어내는 객체는 p 는 name 이라는 속성을 가지고 있고 getName 이라는 프로토타입 메서드를 사용할 수 있다. p 객체가 Parent 의 프로토타입 메서드에 접근할 수 있는 이유는 p 객체의 __proto__ 속성이 Parent.prototype 을 가리키고 있기 때문이다. 이 과정은 생성자를 new 키워드와 함께 사용할 때 엔진 내부에서 연결해준다. 실제로 엔진은 아래와 같은 행동을 한다. (이해를 돕기 위한 코드이다)

var p = new Parent('myName');

// 엔진 내부에서 하는 일
p = {}; // 새로운 객체를 만들고
Parent.call(p, 'myName'); // call이용해 Parent함수의 this를 p로 대신해서 실행해주고
p.__proto__ = Parent.prototype; // 프로토타입을 연결한다.

p.getName(); // 'myName'

위 코드는 생성자가 만들어 내는 객체가 어떻게 생성자의 =prototype=과 연결되는지 보여주고 있다. 위 과정을 통해서 프로토타입 룩업시 p -> Parent.prototype 의 탐색 과정을 만들어 낼 수 있다. 현재의 코드에선 Object 타입을 제외한다면 의도적으로 구현된 상속이란 개념은 아직 들어가있지 않다. Parent 타입을 상속받는 Child 타입라는 타입을 만들려 한다면 어떻게 해야할까? 프로토타입 체인의 연결 구조는 Child 의 인스턴스 -> Child.prototype -> Parent.prorotype 이런 연결 구조를 만들어야 한다. Child의 인스턴스 -> Child.prototype 으로의 연결은 바로 위 코드의 Parent의 경우와 다르지 않다. 여기서 중요한건 어떻게 Child.prototype과 Parent.prototype을 연결하는가이다. 이 과정이 프로토타입을 이해하는데 제일 애매한 부분이지만 이 과정만 이해한다면 몇 단계의 상속 구조도 쉽게 만들수 있다.

Object.create();

자식의 프로토타입과 부모의 프로토타입의 연결은 결국 객체와 객체를 연결하는 것을 말한다. 방법으로는 두가지가 있는데 첫 번째 방식은 꼼수에 지나지 않는 옛 방식이고 두 번째는 표준 API를 이용한 새로운 방식이다. 표준 API가 있는데 왜 꼼수를 이용하는가 궁금할 수 있는데 표준 API가 나온 시점이 늦은편이고 늦었다는건 브라우저의 지원이 좋지 않았기 때문이다(IE9이상부터 지원). 현재도 한국 환경에서는 IE8을 버리기가 힘들 수 있어 우선 API를 이용하는 방법으로 시작하고 후에 꼼수도 설명한다. 여기서 말하는 표준 API는 Object.create() 다. Object.create() 는 객체를 인자로 받아 그 객체와 프로토타입 체인으로 연결되어 있는 새로운 객체를 리턴해준다. 초반에 설명한 __proto__ 예제로 다시 설명한다.

var a = {
    attr1: 'a'
};

var b = {
    __proto__: a,
    attr2: 'b'
};

위 코드는 __proto__ 를 통해 프로토타입이 연결되는 과정을 설명하려고 작업한 실제로는 사용해선 안될 코드다. __proto__ 속성에 개발자가 직접 접근한 코드가 문제인데 Object.create() 을 이용해 __proto__ 속성에 직접 접근하지 않고 프로토타입 체인을 연결할 수 있다.

var a = {
    attr1: 'a'
};

var b = Object.create(a);

b.attr2 = 'b';

Object.create() 으로 인해 __proto__ 가 a를 참조하는 새로운 빈객체를 리턴하게되고 b 에 참조되어 b.attr1 이런식으로 a 객체의 맴버에도 접근할 수 있다. 위의 코드의 구현 내용은 사실 Object.create() 이 없어도 구현할 수 있다. 이제 꼼수가 나오게 된다. Object.create() 과 같은 API들이 생겨나는 것도 이런 꼼수들을 착안한것이라고 생각한다.

var a = {
    attr1: 'a'
};

function Ghost() {}
Ghost.prototype = a;

var b = new Ghost();

b.attr2 = 'b';

Object.create() 이 해주는 작업은 위 코드와 다를 게 없다. Ghost 라는 대리(혹은 임시) 생성자를 만들고 prototype 이 a 를 참조하게 한 뒤 객체를 만들게 되면 __proto__ 가 a 를 참조하고 있는 Ghost 의 인스턴스가 만들어진다. b 가 Ghost 의 인스턴스라는 것 빼고는 Object.create() 이 만들어낸 객체와 큰 차이가 없다. Ghost 의 인스턴스라는 것도 b.constructor 의 참조를 Object로 바꿔 숨길 수 있지만 이런 장황한 과정보다는 Object.create() 을 사용하는 것이 옳은 선택이다. 아마 지금까지의 내용만으로는 자식 프로토타입과 부모 프로토타입이 연결되는 것으로 바로 생각이 이어지지 않을 것이다. 이제 프로토타입끼리 연결해보자.

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

Parent.prototype.getName = function() {
    return this.name;
};

function Child(name) {
    Parent.call(this, name);

    this.age = 0;
}

Child.prototype = Object.create(Parent.prototype); // (1)
Child.prototype.constructor = Child;

Child.prototype.getAge = function() {
    return this.age;
};

var c = new Child(); // (2)

(1) 에서 Object.create() 을 이용해 Child의 prototype 객체를 교체했다. (1) 에서 만들어진 새 객체 즉 Child.prototype__proto__ 속성이 Parent.prototype 을 가르키게 되고 (2) 에서 Child의 인스턴스 c의 __proto__Child.prototype 을 참조하게 된다. 이렇게해서 c -> Child.prototype -> Parent.prototype 으로 연결되는 프로토타입이 만들어졌고 프로토타입 룩업시 우리가 의도한 탐색 경로로 식별자를 찾게 된다. 추가로 Child 생성자에서 Parent 생성자를 빌려써 객체를 확장한것은 생성자 빌려쓰기라는 오래된 기법으로 자바스크립트 상속에서 부모의 생성자를 실행하는 유일한 방법이다. 이렇게 해야 부모타입의 생성자의 내용도 상속 받는다.

Object.create() 을 사용하지 못하는 환경에서는 아까 다뤘던 꼼수를 이용해야 한다.

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

Parent.prototype.getName = function() {
    return this.name;
};

function Child(name) {
    Parent.call(this, name);

    this.age = 0;
}

// diff start
function Ghost() {};
Ghost.prototype = Parent.prototype;

Child.prototype = new Ghost();
// diff end
Child.prototype.constructor = Child;

Child.prototype.getAge = function() {
    return this.age;
};

var c = new Child();

바뀐 코드의 범위는 주석으로 표현했다. 코드만 봤을 때는 굳이 Ghost 란 임시 생성자를 사용할 필요 없이 Parent 생성자를 사용하면 되지 않을까 생각할 수 있다. 하지만 그 방식은 생성자가 만들어내는 속성 때문에 사용할 수 없다. 다시 말하면 Parent 생성자를 Ghost 대신 그대로 사용했다면 Parent 생성자를 통해 만들어진 객체는 name 이란 속성도 갖게 된다. 이 객체를 Child 의 프로토타입으로 사용하게 되면 name 이란 속성을 Child 의 인스턴스들이 같은 내용으로 공유하게 되는데 생성자에서 만든 속성들은 공유하는 게 아니라 인스턴스 별로 소유하게 만들어야 하 때문이다. 인스턴스 별로 name 속성을 만들기 위해 Child 생성자 안에서 Parent 생성자의 생성자 빌려 쓰기를 수행했던 것이다. Ghost 같은 임시 생성자를 이용하는 이유는 순수하게 프로토타입 체인만 연결된 빈 객체를 얻기 위함이다.

ES6

자바스크립트는 상속이 이루어지는 개념은 간단한데 그것을 구현하는 코드가 상당히 장황하다. Object.create() 을 사용해도 장황한 건 마찬가진데 이를 보완하기 위해 ECMAScript6에서 class 스펙이 추가되었다. 클래스라고는 하지만 새로운 개념이 아니고 상속의 구현 원리는 기존과 동일한 내용으로 장황했던 코드를 간결하게 하는 숏컷이 추가됐다고 생각하면 된다. 예제로 만들어봤던 ParentChild 는 ES6의 class 를 이용하면 아래와 같이 사용할 수 있다.

class Parent {
    constructor(name) {
        this.name = name;
    }

    getName() {
        return this.name;
    }
}

class Child extends Parent {
    constructor(name) {
        super(name); // 생성자 빌려쓰기 대신....super 함수를 이용 한다.
        this.age = 0;
    }

    getAge() {
        return this.age;
    }

}

코드가 간결해지고 이해하기 쉽게 바뀌었다. 코드는 달라졌다 하더라도 이를 통해 만들어진 객체의 프로토타입 체인 연결 구조는 기존과 동일하고 또한 동일한 방식의 프로토타입 룩업으로 식별자를 찾아간다.

정리

이 글에서는 프로토타입 체인이 어떤 모습인지와 프로토타입 룩업을 통해 어떻게 식별자를 찾아가는지 그리고 프로토타입 체인을 어떻게 만드는지에 대해 알아봤다. 자바스크립트 개발을 하면서 프로토타입 체인의 애매했던 부분을 이해하는데 조금이라도 도움이 되었으면 하는 마음에서 작성했다. 마지막에 다뤘던 class는 ES6의 스펙으로 아직은 브라우저 지원이 낮은 편이지만 Babel 을 이용해 트랜스파일링하면 ES6를 지원하지 않는 브라우저도 대응할 수 있다.

♥ Support writer ♥
with kakaopay

크리에이티브 커먼즈 라이선스이 저작물은 크리에이티브 커먼즈 저작자표시-비영리-변경금지 4.0 국제 라이선스에 따라 이용할 수 있습니다.

© Sungho Kim2023