객체 지향 프로그래밍

ECMAScript는 클래스라는 개념이 없습니다. 따라서 ECMAScript의 객체는 다른 클래스 기반 언어와 다릅니다.

이번 포스트에서는 자바 스크립트에서 어떻게 객체 지향 프로그래밍을 구현하는지 배워봅시다.

객체에 대한 이해

객체에 대해 설명을 시작하기 전에 객체를 만드는 방법 부터 한번 보고 갑시다.

var person = new Object();
person.name = "Nicholas";
person.age = 29;
person.job = "Software Engineer";

person.sayName = function(){
    alert(this.name);
}

위와같이 객체를 생성 후 프로퍼티와 함수를 추가해 주었습니다.
이 예제는 객체 리터럴 표기법을 이용하여 아래와 같이 수정할 수 있습니다.
객체 리터럴 패턴이 많이 쓰이는 추세이니 숙지해 줍시다

var person = {
    name : "Nicholas",
    age: 29,
    job: "Software Engineer",

    sayName: function(){
        alert(this.name);
    }
}

위와 동일하지만 생성 패턴만 다릅니다.

프로퍼티 타입

프로퍼티에는 데이터 프로퍼티와 접근자 프로퍼티가 있습니다.

데이터 프로퍼티

데이터 프로퍼티는 데이터 값에 대한 단 하나의 위치를 포함합니다.
이 위치에서 값을 읽고 쓰며, 그 행동을 설명하는 네 가지 속성이 있습니다.

  • [[Configurable]]
    해당 프로퍼티가 delete를 통해 삭제하거나, 속성을 바꾸거나, 접근자 프로퍼티로 변환할 수 있는지 나타냄.
    객체에서 직접 정의한 모든 프로퍼티에서 이 속성은 기본적으로 true이다.

  • [[Enumerable]]
    for-in 루프에서 해당 프로퍼티를 반환함을 나타냄
    객체에서 직접 정의한 모든 프로퍼티에서 이 속성은 기본적으로 true이다.

  • [[Writable]]
    프로퍼티 값을 바꿀수 있음을 나타냄
    객체에서 직접 정의한 모든 프로퍼티에서 이 속성은 기본적으로 true이다.

  • [[Value]]
    프로퍼티의 실제 데이터 값을 포함.
    프로퍼티의 값을 읽는 위치이며, 새로운 값을 쓰는 위치.
    이 속성의 기본값은 undefined이다.

위에 코드에서 처럼 객체에 프로퍼티를 명시적으로 추가 할 때는 [[Configurable]], [[Enumerable]], [[Writable]] 속성은 모두 true로 지정됩니다.
[[Value]] 속성에는 할당된 값이 지정됩니다.

var person = {
    name: "Nicholas";
}

위에서 name이라는 프로퍼티에 값 “Nicholas”를 할당 했습니다.
즉 [[Value]] 속성이 “Nicholas”로 지정되었으며, 값이 바뀐다면 이 위치에서 바뀝니다.

프로퍼티의 속성을 바꾸려면 아래와 Object.defineProperty() 메서드를 사용해야 합니다.
이 메서드는 프로퍼티를 추가하거나 수정할 객체, 프로퍼티 이름, 서술자(descriptor) 객체 세 가지를 매개 변수로 받으며, 서술자 객체의 프로퍼티는 내부 속성 이름과 1:1로 대응합니다.

var person = {};
Object.defineProperty(person, "name", {
    writable: false,
    value: "Nicholas"
});

alert(person.name);    //Nicholas
person.name = "Greg";
alert(person.name); //Nicholas

writable속성을 false로 바꿔줬기 때문에 기존에 할당된 값이 변경되지 않습니다.
스트릭트 모드일 경우에는 에러가 발생합니다.

Configurable속성을 변경한다면 위 설명과 같이 수정 할 수가 없습니다.
즉 Configurable를 false로 바꾸면 true로 다시 수정을 못합니다. 물론 이렇게 defineProperty같은 기능을 사용할 일이 드물겠지만 자바스크립트 객체를 잘 이해하려면 이 개념에 대해서 알아 두는게 좋습니다.

접근자 프로퍼티

접근자 프로퍼티에는 데이터 값이 들어있지 않습니다. 대신 getter, setter함수로 구성됩니다.
접근자 프로퍼티도 네가지 속성이 있습니다.

  • [[Configurable]]
    해당 프로퍼티가 delete를 통해 삭제하거나, 속성을 바꾸거나, 접근자 프로퍼티로 변환할 수 있는지 나타냄.
    객체에서 직접 정의한 모든 프로퍼티에서 이 속성은 기본적으로 true이다.

  • [[Enumerable]]
    for-in 루프에서 해당 프로퍼티를 반환함을 나타냄
    객체에서 직접 정의한 모든 프로퍼티에서 이 속성은 기본적으로 true이다.

  • [[Get]]
    프로퍼티를 읽을 때 호출할 함수.
    기본값은 undefined이다.

  • [[Set]]
    프로퍼티를 바꿀 때 호출할 함수.
    기본값은 undefined이다.

var book = {
    _year: 2004,
    edition: 1
}

Object.defineProperty(book, "year", {
    get: function(){
        return this._year;
    },
    set: function(newValue){
        if (newValue > 2004){
            this._year = newValue;
            this.edition += newValue - 2004;
        }
    }
});

book.year = 2005;
alert(book.edition); // 2

위와같이 getter, setter를 지정할 수 있습니다.
setter을 지정하지 않으면 읽기전용이 되겠죠?

Object.defineProperty()를 지원하지 않은 브라우저에서는 수정방법이 없습니다.

다중 프로퍼티 정의

객체에서 프로퍼티를 수정 시, 여러개를 동시에 수정해야 할일이 더 많습니다.
Object.defineProperties() 메서드를 사용하시면 됩니다.

var book = {};

Object.defineProperties(book, {
    _year: {
        value: 2004
    },
    edition: {
        value: 1
    },====
    year: {
        get: function(){
            return this._year;
        },
        set: function(newValue){
            if(newValue > 2004){
                this._year = newValue;
                this.edition += newValue - 2004;
            }
        }
    }
});

이 코드는 _year, edition 두가지 데이터 프로퍼티를 추가 했습니다.
그리고 year접근자 프로퍼티를 선언 했습니다.
위에서 작성한 예제와 동일하나 다른점은 여러 프로퍼티를 동시에 생성했다는 것 뿐입니다.

프로퍼티 속성 읽기

위에서 프로퍼티 속성을 설정해보았습니다.
Object.getOwnPropertyDescriptor()는 속성을 확인 할 수 있는 메서드 입니다.

...
var descriptor = Object.getOwnPropertyDescriptor(book, "_year");
console.log(descriptor.value);            //2004
console.log(descriptor.configurable);    //false
console.log(typeof descriptor.get);        //undefined

위와같이 사용할 수 있습니다.
Object.getOwnPropertyDescriptor() 메서드는 자바스크립트의 모든 객체에 사용할 수 있습니다.
DOM이나 BOM객체도 물론 해당됩니다.

객체 생성

위에서도 Object생성을 해봤지만 위와같이 생성자나 객체 리터럴을 이용해 객체 생성 시 단점이 있습니다.
같은 인터페이스를 가진 객체를 여러개 만들 때는 중복된 코드를 많이 써야합니다.
이런 문제를 해결하기 위한 몇 가지 팩토리 패턴을 알아봅시다.

팩토리패턴 factory pattern

팩토리 패턴은 특정 객체를 생성하는 과정을 추상화 하는 것입니다.
ECMAScript에는 클래스를 정의할 수 있는 방법이 없으므로 개발자들은 다음과 같이 특정 인터페이스의 객체를 생성하는 과정을 함수로 추상화 했습니다.

function createPerson(name, age, job){
    var o = new Object();
    o.name = name;
    o.age = age;
    o.job = job;
    o.sayName = function(){
        alert(this.name);
    };
    return o;
}

var person1 = createPerson("kendrick", 27, "developer");
var person2 = createPerson("theo", 28, "developer");

Person객체를 만드는 함수를 만들었으며, 필요한 정보를 매개변수로 받아서 객체를 생성합니다.
이 팩토리 패턴을 쓰면 비슷한 객체를 여러 개 만들 때의 코드 중복 문제를 해결 할 수 있습니다.
하지만 생성한 객체가 어떤 타입인지 알 수 없습니다.
그 후 자바스크립트가 진화하면서 새로운 패턴이 등장했습니다.

생성자 패턴 constructor pattern

이전 예제를 생성자 패턴을 써서 아래와 같이 수정했습니다.

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

var person1 = new Person("kendrick", 27, "developer");
var person2 = new Person("theo", 28, "developer");

위 방식은 아래와 같은 특징을 가지고 있습니다.

  • 명시적으로 객체를 생성하지 않음
  • 프로퍼티와 메서드는 this객체에 직접적으로 할당
  • return문이 없음
  • 생성자 함수는 첫글자를 대문자로 시작하는 표기법 사용(널리쓰이는 표기법)

Person의 새 인스턴스를 만들때는 new연산자를 사용합니다.
생성자를 이런 식으로 호출하면 내부적으로는 아래와 같은 과정이 이루어집니다.

  1. 객체를 생성.
  2. 생성자의 this 값에 새 객체 할당, this가 새 객체를 가리킴
  3. 생성자 내부 코드 실행(객체에 프로퍼티 추가)
  4. 새 객체 반환.

앞 예제 마지막에 person1과 person2는 Person의 각각 다른 인스턴스로 채워집니다.
두 객체의 constructor 프로퍼티는 둘다 Person을 가리킵니다.
참고로 constructor는 객체의 타입을 파악하려는 의도로 있으나 타입을 알아내는 목적이라면 instanceof 연산자를 사용합시다.

new로 생성하지 않는다면.. 위 함수는 window 객체에 추가 합니다.

아래와 같이 다른 객체의 스코프에서 호출 할 수도 있습니다.

var o = new Object();
Person.call(o, "Kristen", 25, "Nurse");
o.sayName();    //Kristen

이렇게 하면 함수의 this값은 객체 o가 되며, 객체 o는 프로퍼티 전부와 sayName() 메서드를 할당 받습니다.

생성자의 문제점

생성자 패러다임은 유용하지만 단점이 있습니다.
인스턴스마다 메서드가 생성됩니다.
위에 person1, person2는 모두 sayName()이라는 메서드가 있지만, 이들 메서드는 Function의 같은 인스턴스는 아닙ㄴ디ㅏ.
새로 생성되고 있습니다.

console.log(person1.sayName == person2.sayName); // false

똑같은 일을 하는 Function이 계속 생성되는 것 입니다.
아래와 같이 구현하는 방법이 있습니다만..

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

function sayName(){
    alert(this.name);
}

보시다 시피.. 바깥에 정의했기 때문에 전역 스코프를 어지럽히거나, 보기좋게 묶기가 힘듭니다.
이 문제는 프로토타입 패턴으로 해결할 수 있습니다.

프로토타입 패턴 prototype pattern

모든 함수는 prototype 프로퍼티를 가지고 있습니다.
이 프로퍼티는 해당 참조 타입의 인스턴스가 가져야 할 프로퍼티와 메서드를 담고 있는 객체입니다.
생서자를 호출할 때 생성되는 객체의, 문자 그대로, 프로토타입입니다.
프로토타입의 프로퍼티와 메서드는 객체 인스턴스 전체에서 공유되며 이것이 프로토타입의 장점입니다.
객체 정보를 생성자에 할당하는 대신 다음과 같이 직접적으로 프로토타입에 할당 가능합니다.

function Person(){
}

Person.prototype.name = "Kendrick";
Person.prototype.age = 27;
Person.prototype.job = "developer";
Person.prototype.sayName = function(){
    alert(this.name);
};

프로퍼티들과 sayName()메서드가 Person의 prototype으로 직접 추가 되었고 생성자 함수는 비워져 있습니다.
생성자 함수가 비워져있지만 생성자를 호출해 만든 객체에도 프로퍼티와 메서드가 존재합니다.
프로퍼티와 메서드를 모든 인스턴스에서 공유하므로 Person이 여러개 생성되어도 모두 동일한 프로퍼티나 함수를 공유합니다.

프로토타입은 어떻게 동작하는가

함수가 생성 될떄마다 특정한 규칙에 따라 생성됩니다.
모든 프로토타입은 자동으로 constructor 프로퍼티를 갖습니다.
위 예제의 Person.prototype.constructor는 Person을 가리킵니다.

Person 생성자는 Person.prototype에 Person Prototype을 가집니다.
Person Prototype은 constructor에 Person을 가집니다.
person1.[[Prototype]]은 Person Prototype을 가집니다.
person2.[[Prototype]]은 Person Prototype을 가집니다.
역시나.. 복잡한데 아래 그림을 참고 해봅시다.


[[Prototype]]은 구현 환경에 따라 접근 불가능할 수도 있지만 객체 사이에 프로토타입 연결이 존재하는지 isPrototypeOf()메서드를 통해 알 수 있습니다.
자신을 호출하는 프로토타입을 가리킨다면 true를 반환합니다.

alert(Person.prototype.isPrototypeOf(person1)); //true
alert(Person.prototype.isPrototypeOf(person2)); //true

alert(Object.getPrototypeOf(person1) == Person.prototype); //true
alert(Object.getPrototypeOf(person1).name); //Nicholas

위에 Object.getPrototypeOf() 메서드는 Prototype의 값을 반환합니다.
첫줄은 객체의 프로토타입이 맞는지 확인하며, 두번째 줄은 name값을 가져옵니다.
이 메서드를 쓰면 객체의 프로토타입을 쉽게 가져올 수 있습니다.

인스턴스에서는 프로퍼티를 읽을 때 인스턴스 자체에서 부터 찾기 시작합니다.
없을 경우에는 프로토타입으로 올려서 검색을 계속합니다.
따라서 sayName이 인스턴스에 없어도 프로토타입에서 찾게 되고 프로토타입의 sayName을 반환하게 됩니다.
그렇기 때문에 인스턴스에 존재할 경우 당연히 프로토타입으로 접근하지 않습니다.(프로토타입 보기전에 찾아버렸으니말이죠)

주의할 것은 인스턴스에 프로퍼티를 추가 해놓고 없애고 싶다면 delete를 사용해야 합니다.
없애고 시팓고 null로 설정해도 프로토타입으로 진입하지 않고 null을 반환하기 때문입니다.

...person1생성
person1.name = "이름!";

delete person1.name
console.log(person1.name); //프로토타입의 name접근

그러니 프로퍼티를 아예 제거하고싶다면 delete를 사용하시면 됩니다.

프로퍼티가 인스턴스에 존재하는지 프로토타입에 존재하는지 확인하는 방법은 아래메서드를 사용합시다.
hasOwnProperty();
위 메서드는 인스턴스에 존재하는 프로퍼티에 대해서 true를 반환하지만 프로토타입 프로퍼티에 대해서는 false를 반환하기 때문입니다.

프로토타입과 in 연산자

in 연산자는 두 가지 쓰임이 있습니다.
하나는 그 자체로 사용
하나는 for-in 루프에서 사용하는 경우입니다.

in연산자는 주어진 이름의 프로퍼티를 객체에서 접근할 수 있을 때, 즉 해당 프로퍼티가 인스턴스에 존재하든 프로토타입에 존재하든 모두 true를 반환 합니다.

console.log("name" in person1); // true

person1 인스턴스와 프로토타입에 존재하는 프로퍼티에 name이 존재하는지 확인 하여 값을 반환합니다.
전체 프로퍼티가 아닌 인스턴스나 프로토타입을 구분하고 싶으면 위에서 말씀드린 hasOwnProperty()를 사용하시면 됩니다.

for-in 루프를 사용할 때는 객체에서 접근 할 수 있게 나열가능한 프로퍼티를 반환합니다.
Object.keys() 메서드를 통해 객체 인스턴스에서 나열가능한 프로퍼티의 전체 목록을 얻을 수 있습니다.

function Person(){
}

Person.prototype.name = "Kendrick";
Person.prototype.age = 27;
Person.prototype.job = "developer";
Person.prototype.sayName = function(){
    alert(this.name);
};

var keys = Object.keys(Person.prototype);
alert(keys); // name,age,job,sayName

var p1 = new Person();
p1.name = "Rob";
p1.age = 31;
var p1keys = Object.keys(p1);
console.log(p1keys); //name,age

keys변수는 프로터티 키값에 해당하는 배열을 가지고 있습니다.
나열 가능 여부와 관계없이 인스턴스 프로퍼티 전체 목록을 얻으려면 Object.getOwnPropertyNames() 메서드를 같은 방법으로 사용합니다.
위 메서드의 경우 나열 불가능한 constructor 프로퍼티도 포함되어 있습니다.

프로토타입 대체 문법

위 예제에서는 Person.prototype으로 매 프로퍼티와 메서드마다 일일히 기입했습니다.
아래와 같이 객체 리터럴로 프로토타입을 덮어써서 반복을 줄이고 프로토타입에 기능을 더 가독성 있게 캡슐화하는 패턴이 널리 쓰입니다.

function Person(){
}

Person.prototype = {
    name : "Kendrick",
    age : 23,
    job : "developer",
    sayName : function(){
        alert(this.name);
    }
};

Person.prototype 프로퍼티에 객체 리터럴로 생성한 객체를 씌워서 보기 편하게 생성하였습니다.
함수가 생성되면 prototype객체가 생성되고 constructor는 자동으로 할당됩니다.
결과적으로 constructor 프로퍼티는 함수 자체가 아니라 완전히 새로운 객체의 생성자(Object 생성자)와 같습니다.
constructor가 객체의 타입을 정확히 나타낼 수 없으니 instanceof 연산자는 여전히 올바르게 동작합니다.

프로토타입의 동적 성질

프로토타입에서 값을 찾는 작업은 적시(런타임) 검색입니다.
프로토타입이 바뀌면 그 내용이 즉시 인스턴스에도 반영됩니다.
프로토타입이 바뀌기전에 빠져나온 인스턴스도 바뀐 내용을 반영합니다.

var friend = new Person();

Person.prototype.sayhi = function(){
    alert("hi");
}

friend.sayHi(); //"hi" 동작

인스턴스와 프로토타입이 느슨하게 연결되어있기 때문에 위 예제와 같이 인스턴스 생성 후 프로토타입에 함수를 추가 했다 하더라도, 프로토타입에 추후에 추가한 함수에 인스턴스 접근이 가능합니다.

[[Prototype]] 포인터는 생성자가 호출될 때 할당되므로 프로토타입 객체를 다른 객체로 바꾸면 생성자와 원래 프로토타입의 연결이 끊어집니다. 인스턴스는 프로토타입을 가리키는 포인터를 가질뿐입니다.

네이티브 객체 프로토타입

위에서는 커스텀 타입 정의에 프로토타입을 알아보았습니다.
하지만 네이티브 참조 타입역시 프로토타입 패턴으로 구현되어있으므로 이 부분을 잘 이해해야 합니다.

console.log(typeof Array.prototype.sort); //function
console.log(typeof String.prototype.substring); //function

네이트브 객체도 프로토타입을 통해 기본 메서드 참조 및 새 메서드 정의가 가능합니다.
커스텀 객체의 프로토타입과 마찬가지로 메서드 수정, 추가도 할수 있습니다.

하지만 배포하는 코드에서는 가급적 피하길 권합니다.
네이티브 객체를 수정하면 혼란스럽기도 하며, 같은 이름의 메서드가 어떤 브라우저에서는 지원되고 다른 브라우저에서 지원되지 않는 상황에서 충돌이 발생할 수 있습니다.

프로토타입의 문제점

일례로 프로토타입 패턴은 초기화 매개변수를 생성자에 전달 할 수 없게 합니다.
즉 모든 인스턴스가 기본적으로 같은 프로퍼티값을 가지게 됩니다.
이 점도 불편하지만 이것보다 큰 문제는 공유 성질입니다.
프로토타입의 모든 프로퍼티는 모든 인스턴스에서 공유되는데 이런 특징은 함수에는 이상적입니다.
하지만 인스턴스에서 프로퍼티에 값을 할당하면 프로토타입 프로퍼티를 가리기 때문에 프로퍼티가 참조값을 포함한 경우 문제가 발생합니다.

프로토타입 프로퍼티에 [1, 2] 와같은 배열을 가지고 있는경우
인스턴스에서 해당 프로퍼티에 push를 하면 모든 인스턴스에 해당 프로퍼티 값이 변경되어 버립니다.
배열을 모든 인스턴스에서 공유할 의도였다면 최적이지만 그렇지 않은 경우라면 혼란스러운 버그를 야기할 것입니다.
이 때문에 프로토타입 패턴을 있는 그대로만 사용하는 경우는 드뭅니다.

생성자 패턴의 프로토타입 패턴의 조합

위에서 배운 생성자 패턴과 프로토타입 패턴을 잘 조합하는 방법입ㄴ다.
이 접근법을 사용하면 모든 인스턴스는 자신만의 인스턴스 프로퍼티를 가지며, 참조 방식을 통해 메서드는 공유하게 됩니다.
메모리 절약에 유용합니다.

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

Person.prototype = {
    constructor: Person,
    sayName : function(){
        alert(this.name);
    }
}

인스턴스 프로퍼티는 생성자에서 정의했습니다.
공유 프로퍼티 constructor와 메서드 sayName()은 프로토타입에서 정의했습니다.

이렇게 하면 공유되지않는 프로퍼티와 공유될 프로퍼티를 구분해서 생성 할 수 있습니다.
일반적으로 말해 참조 타입을 정의할 때는 기본적으로 이 패턴을 먼저 떠올리기를 권합니다.

동적 프로토타입 패턴

이 접근법은 모든 정보를 생성자 내부에 캡슐화 하여 생성자, 프로토타입의 구분 혼란을 해결하며, 필요한 경우에 프로토타입을 생성자 내부에서 초기화하여 생성자와 프로토타입을 모두 쓰는 장점을 취하려는 접근법입니다.
반드시 필요한 메서드가 있느냐에 따라 프로토타입을 초기화할지 여부를 결정하게 됩니다.

function Person(name){
    //프로퍼티
    this.name = name;

    //메서드
    if (typeof this.sayName != "function"){
        Person.prototype.sayName = function(){
            alert(this.name);
        };
    }
}

sayName 메서드가 존재하지 않는다면 추가합니다.
이 코드 블록은 생성자가 첫 번째로 호출된 다음에만 실행됩니다.
if (typeof this.sayName != “function”)
위 조건은 this.sayName이 없기 떄문에 프로토타입의 sayName을 확인하며 프로토타입에 생성되어있다면 패스, 없다면 생성해줍니다.

이 패턴을 쓰면 instanceof를 통해 객체가 어느 타입에서 만들어졌는지 확인할 수도 있습니다.

기생 생성자 패턴

이 패턴은 보통 다른 패턴이 실패할 때 폴백으로 씁니다.
일반적인 생성자처럼 보이지만 사실 다른 객체를 생성하고 반환하는 동작을 래퍼 생성자로 감싸는 겁니다.

function Person(name){
    var o = new Object();
    o.name = name;
    o.sayName = function(){
        alert(this.name);
    };
    return o;
}

Person생성자가 새 객체를 생성한 다음 프로퍼티와 메서드를 초기화 하여 반환합니다.
new 연산자를 써서 함수를 생성자로 호출하는점을 제외하면 팩토리 패턴과 같습니다.

이 패턴을 쓰면 메서드를 추가한 특별한 배열을 만들 수 있습니다.
Array 생성자에 직접 접근할 수는 없지만 이 패턴을 사용하면 이런 제한을 우회할 수 있습니다.

function SpecialArray(){
    var values = new Array();
    values.push.apply(values, arguments);
    values.toPipedString = function(){
        return this.join("|");
    };
    return values;
}

var colors = new SpecialArray("red", "blue", "green");
console.log(colors.toPipedString()); // red|blue|green

toPipedString 메서드가 추가된 배열을 사용할 수 있게됏습니다.
하지만 이 패턴은 반환된 객체와 생성자, 생성자의 프로토타입 사이에 아무 연결고리가 없습니다.
즉 instanceof 연산자로 이 객체의 타입을 알 수가 없습니다.
이런 문제가 있으니 다른 패턴으로 문제를 해결할 수 있을 때는 이 패턴을 쓰지 말아야합니다.

방탄 생성자 패턴

방탄(durable) 객체라는 용어가 있습니다.
공용 프로퍼티가 없고, this를 참조하지 않는 객체를 가리킵니다.
방탄 객체는 this나 enw의 사용을 금지하는 보안 환경, 매시업 애플리케이션 등에서 데이터를 써드파티 애플리케이션으로부터 보호하는데 가장 잘 어울립니다.

이전 예제의 Person 생성자는 다음과 같이 방탄 생성자 패턴으로 고쳐 쓸수 있습니다.

function Person(name){
    var o = new Object();

    o.sayName = function(){
        alert(name);
    };
    return o;
}

var friend = Person("kendrick");
friend.sayName();

객체에 등록된 메서드 sayName외에는 이 객체가 간직한 데이터에 접근할 방법이 전혀 없습니다.
써드파티 코드에서 이 객체에 메서드 데이터를 추가할 수는 있지만, 데이터에는 접근 할 수가 없습니다.
이런 보안성 때문에 방탄 생성자 패턴은 보안실행환경에 유용합니다.

상속

객체지향 프로그래밍과 관련하여 가장 자주 설명하는 개념입니다.
하지만 자바스크립트에서의 인터페이스 상속은 불가능합니다.
구현 상속만 지원하며, 구현 상속은 대게 프로토타입 체인을 이용합니다.

프로토타입 체인

프로토타입 개념을 이용하여 두 가지 참조 타입 사이에서 프로퍼티와 메서드를 상속하는 개념입니다.
모든 생성자에는 생성자 자신을 가리키는 프로토타입 객체가 있으며, 인스턴스는 프로토타입을 가리키는 내부 포인터가 있습니다.
그렇다면 프로토타입이 사실 다른 타입의 인스턴스라면..? 이런경우 A프로토타입 자체에 B프로토타입을 가리키는 포인터가 있을 것입니다.
B에는 또다른 생성자를 가리키는 포인터가 있을 것입니다.
B역시 다른 타입의 인스턴스라면? 계속 패턴이 연결되어 인스턴스와 프로토타입을 잇는 체인이 형성됩니다.

프로토타입 체인 구현을 위해 아래와 같은 코드패턴을 사용합니다

function SuperType(){
    this.property = true;
}

SuperType.prototype.getSuperValue = function(){
    return this.property;
};

function SubType(){
    this.subproperty = false;
}

//SuperType을 상속
SubType.prototype = new SuperType();
SubType.prototype.getSubValue = function(){
    return this.subproperty;
}

var instance = new SubType();
console.log(instance.getSuperValue());    //true

위 예제의 SueprType과 SubType은 프로퍼티와 메서드가 단 한개 씩있습니다.
SuperType을 생성하여 SubType.prototype에 할당합니다.
원래 프토로타입을 새 객체로 덮어썼으며 그로 인해서 SuperType의 존재할 프로퍼티와 메서드가 SubType.prototype에도 존재하게 됩니다.

프로토타입 체인은 이전에 설명한 프로토타입 검색 메커니즘을 확장합니다.
인스턴스 프로퍼티 검색 -> 프로토타입 프로퍼티 검색
위 메커니즘이 위 예제에서는 아래와 같이 적용되었습니다.
인스턴스 프로퍼티 검색 -> 프로토타입 프로퍼티 검색 -> 프로토타입의 프로토타입 프로퍼티 검색

위와같이 프로퍼티나 메서드를 검색할 때는 항상 프로토타입 체인의 끝까지 거슬러 올라갑니다.
그럼 그 끝은 어디일까요?

기본프로토타입

프로토타입 체인에 한단계가 더 존재합니다. 모든 참조 타입은 기본적으로 포로토타입 체인을 통해 Object를 상속합니다.
그렇기 때문에 Object가 가지고 있는 toString(), valueOf() 같은 기본 메서드를 상속해서 사용할 수 있는 것 입니다.

프로토타입과 인스턴스의 관계

아래 예제르 보고 관계를 대충 짐작 할 수 있습니다.

console.log(instance instanceof Object)     //true
console.log(instance instanceof SuperType)     //true
console.log(instance instanceof SubType)     //true

console.log(Object.prototype.isPrototypeOf(instance));    //true
console.log(SuperType.prototype.isPrototypeOf(instance));    //true
console.log(SubType.prototype.isPrototypeOf(instance));    //true

instanceof연산자와 isPrototypeOf()메서드를 통해서 보면 모두 true를 반환합니다.
instance객체는 프로토타입 체인관계에 의해서 Object, SuperType, SubType 모두의 인스턴스 입니다.

메서드

하위 타입에서 상위 타입의 메서드를 오버라이드하거나 존재하지 않는 메서드를 정의할 때는 반드시 프로토타입이 할당된 다음 필요한 메서드를 추가합시다.

function SuperType(){
    this.property = true;
}

SuperType.prototype.getSuperValue = function(){
    return this.property;
};

function SubType(){
    this.subproperty = false;
}

//SuperType을 상속
SubType.prototype = new SuperType();
SubType.prototype.getSubValue = function(){
    return this.subproperty;
}

//새 메서드
SubType.prototype.getSubValue = function() {
    return this.subproperty;
}

//기존 메서드 오버라이드
SubType.prototype.getSuperValue = function(){
    return false;
}

var instance = new SubType();
console.log(instance.getSuperValue());    //true

위 새 메서드, 기존 메서드 오버라이드 주석 추가부분을 봅시다.
먼저 프로토타입을 할당 하고 메서드 추가 또는 오버라이드 하는 부분을 확인 할 수 있습니다.

주의할 점은 위예제처럼 추가 및 오버라이드 할때 리터럴 표기법을 사용하면 안됩니다.
리터럴 표기법으로 생성하면 new로 새로운 객체를 만들어서 저장하는것과 같기 때문입니다.
아래처럼 하면 안됩니다!

//리터럴 방식으로 getSubValue함수가 존재하는 새로운 객체를 생성해서 넣어버린다. 주의하자
SubType.prototype = {
    getSubValue : function(){
        return this.subproperty;
    }
}

프로토타입 체인의 문제

이는 강력한 상속방법이지만 문제가 있습니다.
프로토타입 프로퍼티에 있는 참조값이 모든 인스턴스에서 공유된다는 것 입니다.
예를들어 상속한 객체의 프로퍼티 값이 원시값이 아니고 배열값이고.. 상속받은 인스턴스 중 하나가 이 프로퍼티 참조값을 수정한다면 모든 값이 변해버릴 것입니다.

function SuperType(){
    this.colors = ["red", "blue", "green"];
}

function SubType(){
}

//SuperType에서 상속
SubType.prototype = new SuperType();

var instance1 = new SubType();
instance1.colors.push("black");

var instance2 = new SubType();
console.log(instance2.colors); //red,blue,green,black

보시다시피 instance1.colors에서 push한 값이 instance2에서 노출이 됩니다.
이렇기 때문에 보통 프로토타입 체인만 단독으로만 쓰지 않습니다.

생성자 훔치기

바로 위에서 설명한 문제(참조 값에 상속문제)때문에 개발자들은 지금 소개할 테크닉을 쓰기 시작했습니다.
하위 타입 생성자 안에서 상위 타입 생성자를 호출합니다.
이전 포스트(참조 타입)에서 배운 call() 혹은 apply() 메서드를 이용하는 방법입니다.

function SuperType(){
    this.colors = ["red", "blue", "green"];
}

function SubType(){
    //SuperType에서 상속
    SuperType.call(this);
}

var instance1 = new SubType();
instance1.colors.push("black");

var instance2 = new SubType();
console.log(instance2.colors); //red,blue,green,black

SuperType 생성자를 SubType의 인스턴스 컨텍스트에서 호출 했습니다.
이는 SuperType()에 있는 객체 초기화 코드 전체를 SubType 객체에서 실행하게 합니다.
—잘 이해가 안되시면 이전 포스트에서 call(this)메서드에 대해서 보고 오시길 바랍니다.—

매개변수 전달

위 패턴은 하위 타입의 생성자 안에서 상위 타입의 생성자에게 매개변수를 전달할 수 있습니다.

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

function SubType(){
    //SuperType에서 상속하되 매개변수 전달
    SuperType.call(this, "Kendrick");

    //인스턴스 프로퍼티
    this.age = 27;
}

var instance = new SubType();
console.log(instance.name);    //Kendrick
console.log(instance.age);    //27

하지만 메서드 생성자 내부에서만 정의해야하니 함수 재사용이 불가능해 보입니다.
다른이름을 넣을수가 없기 때문입니다.
하지만! 위에서 배운걸 이해 하셨다면 머리속으로 가능하지 않나? 라는 생각이 드실겁니다. 왜냐면 전에 배운것과 조합해서 사용한다면 단점을 보완할 수 있기 때문입니다.

조합 상속

프로토타입 체인과 생성자 훔치기 패턴을 조합해 두 패턴의 장점을 취하는 접근법입니다.
프로토타입 체인을 사용하여 프로토타입에 존재하는 프로퍼티와 메서드를 상속
생성자 훔치기 패턴으로 인스턴스 프로퍼티를 상속
위 두가지 요소를 각각 해주는것입니다.

function SuperType(name){
    this.name = name;
    this.colors = ["red", "blue", "green"];
}

SuperType.prototype.sayName = function(){
    alert(this.name);
}

function SubType(name, age){
    //프로퍼티 상속
    SuperType.call(this, name);

    this.age = age;
}

//메서드 상속
SubType.prototype = new SuperType();

SubType.prototype.sayAge = function(){
    alert(this.age);
}

var instance1 = new SubType("Kendrick", 27);
instance1.colors.push("black");
console.log(instance1.colors); //red,blue,green,black
instance1.sayName();    //Kendirck
instance1.sayAge();        //27

var instance2 = new SubType("Theo", 28);
console.log(instance2.colors); //red,blue,green
instance2.sayName();    //Theo
instance2.sayAge();        //28

이렇게 해준다면 프로토타입에 메서드를 정의해 함수 재활용이 가능하며, 각 인스턴스가 고유한 프로퍼티를 가질 수 있습니다.

프로토타입 체인과 생성자 훔치기 패턴의 단점을 모두 해결한 조합 상속은 자바스크립트에서 가장 자주 쓰이는 상속 패턴입니다.
조합 상속은 instanceof와 isPrototypeOf()에서도 올바른 결과를 반환합니다.

프로토타입 상속

2006년 더글러스 크록포드가 엄격히 정의된 생성자를 쓰지 않고 상속을 구현하는 방법을 소개했습니다.
크록포드가 소개한 함수는 다음과 같습니다.

function object(o){
    function F(){}
    F.prototype = o;
    return new F();
}

프로토타입을 써서 새 객체를 생성할때 반드시 커스텀 타입을 정의할 필요가 없다는데서 고안한 방식입니다.
object()함수는 임시 생성자를 만들어 주어진 객체를 생성자의 프로토타입으로 할당합니다.
그 다음 임시 생성자의 인스턴스를 반환합니다.

즉 object()는 매개변수로 받은 객체의 사본을 반환합니다. 아래 코드를 봅시다.

var person = {
    name: "Kendrick",
       friends: ["Theo","Dave","Chris"]
};

var aPerson = object(person);
aPerson.name = "Greg";
aPerson.friends.push("Rob");

var bPerson = object(person);
bPerson.name = "Linda";
bPerson.friends.push("Barbie");

console.log(person.friends);    //Theo,Dave,Chris,Rob,Barbie

person의 복제본을 2개 만든것과 같습니다.(aPerson, bPerson)
person.friends는 참조 값 프로퍼티기 때문에 person, aPerson, bPerson에서 공유하고 있습니다.

ESMAScript 5판에서는 위 프로토타입 상속의 개념을 공식적으로 수용하였고 Object.create() 메서드를 추가 했습니다.
이 메서드는 매개변수를 두 개 받습니다.
하나는 다른 객체의 프로토타입이 될 객체, 옵션인 다른 하나는 새 객체에 추가할 프로퍼티를 담은 객체입니다.

공식적으로 지원하는 메서드를 적용한 코드는 아래와 같습니다.

var person = {
    name: "Kendrick",
       friends: ["Theo","Dave","Chris"]
};

var aPerson = Object.create(person);
aPerson.name = "Greg";
aPerson.friends.push("Rob");

var bPerson = Object.create(person, {
    name: {
        value: "Linda"
    }
});
bPerson.friends.push("Barbie");

console.log(person.friends);    //Theo,Dave,Chris,Rob,Barbie

위 코드는 이전 코드와 동일합니다.
aPerson에 경우 하나의 매개변수로 개체를 복사 하였고,
bPerson에 경우 두번째 매개변수를 이용해서 name을 초기화 해주었습니다.

기생 상속

프로토타입 상속과 밀접히 연관된 인기있는 패턴입니다. —이 패턴 또한 크록포드가 만들었습니다—

기생 생성자나 팩토리 패턴과 비슷합니다.

상속을 담당할 함수를 만들고 어떤 식으로든 객체를 확장해서 반환합니다.
기본 패턴을 봅시다

function createAnother(original){
    var clone = Object.create(original);    //새 객체를 생성
    clone.sayHi = function(){            //객체 확장
        alert("hi");
    };
    return clone;                        //확장된 객체 반환
}

매개변수로 받은 기반 객체 original을 object()함수를 이용하여 clone에 할당합니다.
clone에 sayHi()메서드를 추가 한 후 객체를 반환합니다.

createAnother() 함수는 다음과 같은 방식으로 사용합니다.

var person = {
    name = "Kendrick",
    friends: ["Theo","Dave","Chris"]
};

var anotherPerson = createAnother(person);
anotherPerson.sayHi();    //hi

기생 상속 패턴은 객체를 주로 고려할 때 사용할 패턴입니다.
커스텀 타입과 생성자에 어울리진 않습니다.
이를 이용해 객체에 함수를 추가하면 생성자 패턴과 비슷한, 함수 재사용과 관련된 비효율 문제가 생기니 염두해 두어야합니다.

기생 조합 상속

위에서 배운 조합 상속을 떠올려 봅시다. 조합 상속은 가장 자주 쓰이는 상속 패턴이지만 비효율 적인 면도 존재합니다.
상위 타입 생성자가 두 번 호출된다는 점입니다.

아래 예제를 다시 봅시다.

function SuperType(name){
    this.name = name;
    this.colors = ["red", "blue", "green"];
}

SuperType.prototype.sayName = function(){
    alert(this.name);
}

function SubType(name, age){
    SuperType.call(this, name);        //SuperType() 두 번째 호출

    this.age = age;
}

SubType.prototype = new SuperType();    //SuperType()첫 번쨰 호출
SubType.constructor = SubType;
SubType.prototype.sayAge = function(){
    alert(this.age);
}

첫 번째 호출 때 생기는 SubType.prototype에 name과 colors 두가지 프로퍼티는 사용되지 않을것입니다.
SubType생성자 호출 시 SuperType를 상속받기 때문입니다.
이 문제를 해결하는 방법이 기생 조합 상속입니다.

기생 조합 상속은 생성자 훔치기를 통해 프로퍼티 상속을 구현하지만 메서드 상속에는 프로토타입 체인을 혼용합니다.

기생 조합 상속의 기본 패턴을 보시겠습니다.

function inheritPrototype(subType, superType){
    var prototype = Object.create(superType.prototype);    //객체 생성
    prototype.constructor = subType;                       //객체 확장
    subType.prototype = prototype;                         //객체 할당
}
  • 하위 타입 생성자와 상위 타입 생성자 두 가지를 매개변수로 받음
  • 함수 내부에서 일어나는 첫 단계는 상위 타입의 프로토타입 복제
  • constructor프로퍼티를 prototype에 할당해서 프로토타입 덮어쓸 때 기존 subType의 constructor프로퍼티가 사라지는 현상 대비
  • 하위 타입의 프로토타입에 새로 생성한 객체 할당

위 함수를 어떻게 사용하는지 보겠습니다.

function SuperType(name){
    this.name = name;
    this.colors = ["red","blue","green"];
}

SuperType.prototype.sayName = function(){
    alert(this.name);
};

function SubType(name, age){
    SuperType.call(this, name);

    this.age = age;
}

inheritPrototype(SubType, SuperType);

SubType.prototype.sayAge = function(){
    alert(this.age);
};

상위 객체의 prototype만을 복사해서 필요하지 않은 프로퍼티를 만들지 않으니 효과 적입니다.
또한 프로토타입 체인이 온전히 유지되므로 instanceof와 isPrototypeOf() 메서드도 정상 작동합니다.

기생 조합 상속은 참조 타입에서 가장 효율적인 상속 패러다임으로 평가 받습니다.

마치며

자바스크립트에서 인터페이스 없이 객체지향프로그래밍을 어떻게 지원하는지에 대해서 알아 보았습니다.
객체가 코드 실행도중 생성되고 확장되므로 엄격히 정의된 것이라기 보다는 동적인 성격이 강합니다.
클래스 대신 패턴을 사용하여 생성하며 그 패턴에 대해서 자세히 알아보았습니다.

객체 생성을 위한 패턴들과 상속을 위한 패턴들에 대해서 알아보았습니다.

작성 도중 파일 먹통으로 문서가 날아가서 중간에 약간 힘든시간이 있었지만, 그로 인해서 내용은 더 알차졌습니다.
—새로 작성하면서 이해한 부분을 토대로 다시 작성하니 내용이 더 짧아졌네요. 중구난방인 설명이 줄어들고 포인트에 더 근접하게 작성해서 전화위복이 된 것 같습니다.—



추가 - 2017-03-31

ReadTrend 글들을 보다가 좋은 글을 봐서 링크 추가 합니다.

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

이 포스트는 프론트엔드 개발자를 위한 자바스크립트(인사이트)에서 발췌한 내용이 포함되어 있습니다.
내용 전문이 아니기 때문에 자세하게 알고싶으신 분은 프론트엔드 개발자를 위한 자바스크립트(인사이트) 서적을 참고 하시길 바랍니다.