자바스크립트는 여러 가지 특성으로 객체지향 언어의 특징을 구현해낼 수 있습니다. 초창기 더글라스 크락포드가 소개한 상속과 캡슐화 등으로 자바스크립트가 객체지향 프로그래밍이 가능함이 알려졌고, 그 후 많은 개발자가 자바스크립트로 객체지향적인 구현 방법을 고민하기 시작했습니다. 이번 장에서는 다음에 나오는 객체지향 언어의 특성을 자바스크립트로 구현하는 방법을 살펴보겠습니다.
- 클래스, 생성자, 메서드
- 상속
- 캡슐화
클래스 기반의 언어는 클래스로 객체의 기본적인 형태와 기능을 정의하고, 생성자로 인스턴스를 만들어서 사용할 수 있습니다. 클래스에 정의된 메서드로 여러가지 기능을 수행할 수 있습니다. 우리에게 널리 알려진 Java, C++과 같은 언어가 이에 해당합니다.
이런 유형의 언어는 모든 인스턴스가 클래스에 정의된 대로 같은 구조이고 보통 런타임에 바꿀 수 없습니다. 반면에 프로토타입 기반의 언어는 객체의 자료구조, 메서드 등을 동적으로 바꿀수 있습니다. 이는 마치 정적 타입의 언어와 동적 타입의 언어의 차이와 거의 비슷하게 보입니다. 마찬가지로 장단점도 명확합니다. 정확성, 안전성, 에측성 등의 관점에서 클래스 기반 언어는 프로토타입 기반의 언어보다 좀 더 나은 결과를 보장합니다.
하지만 프로토타입 기반의 언어는 동적으로 자유롭게 객체의 구조와 동작 방식을 바꿀 수 있다는 장점이 있습니다. 자바스크립트는 프로토타입 기반의 언어입니다. 따라서 이번 장을 이해하려면 저번 포스팅을 했던 프로토타입을 잘 이해해야 합니다.
프로토타입은 자바스크립트로 객체지향적으로 구현하는 필수 요소이므로 확실히 이해하는게 좋습니다.
C++이나 Java와 같은 경우 class라는 키워드를 제공하여 프로그래머는 클래스를 만들 수 있습니다. 클래스와 같은 이름의 메서드로 생성자를 구현해냅니다. 하지만 자바스크립트에서는 이러한 개념이 없습니다. 게속해서 강조했듯이 자바스크립트는 거의 모든것이 객체이고, 특히 함수 객체로 많은 것을 구현해냅니다. 클래스, 생성자, 메서드 모두 함수로 구현이 가능합니다. 구체적으로 살펴보기에 다시한번 자바스크립트의 프로토타입과 new 연산자를 살펴보겠습니다.
function Person(arg) {
this.name = arg;
this.getName = function() {
return this.name;
}
this.setName = function(value) {
this.name = value;
}
}
var me = new Person("velog");
console.log(me.getName());
me.setName("junyoung");
console.log(me.getName());
출력: velog
출력: junyoung
위 예제에서 new 키워드로 새로운 객체 me를 만들었음을 주목합시다.
var me = new Person("velog");
이 형태는 기존 객체지향 프로그래밍 언어에서 한 클래스의 인스턴스를 생성하는 코드와 매우 유사합니다. 함수 Person이 클래스이자 생성자의 역할을 합니다. 자바스크립트에서 클래스 기반의 객체지향 프로그래밍은 기본적인 형태가 이와 같습니다. 클래스 및 생성자의 역할을 하는 함수가 있고, 사용자는 new 키워드로 인스턴스를 생성하여 사용할 수 있습니다. 예제에서 생성된 me는 Person의 인스턴스로서 name 변수가 있고, getName()과 setName() 함수가 있습니다.
하지만 이 예제는 문제가 많습니다. 정확히는 이 예제의 Person 함수의 구현이 바람직하지 못합니다. 이 Person을 생성자로 하여 여러 개의 객체를 생성한다고 가정해보겠습니다.
var me = new Person("me");
var you = new Person("you");
var him = new Person("him");
이와 같이 객체를 생성하여 사용하면 겉으로는 별 문제 없이 작동하는 것을 볼 수 있습니다. 하지만 각 객체는 자기 영역에서 공통으로 사용할 수 있는 setName() 함수와 getName() 함수를 따로 생성하고 있습니다. 이는 불필요하게 메모리에 올리고 사용함을 의미하고 자원 낭비를 가져옵니다. 이를 그림으로 표현하면 아래와 같습니다.
따라서 앞의 문제를 해결하려면 다른 방식의 접근이 필요한데, 여기서 활용할수 있는 자바스크립트의 특성이 함수 객체의 프로토타입입니다. 다음 코드를 살펴보겠습니다.
function Person(arg){
this.name = arg;
}
Person.prototype.getName = function() {
return this.name;
}
Person.prototype.setName = function(value) {
this.name = value;
}
var me = new Person("me");
var you = new Person("you");
console.log(me.getName());
console.log(you.getName());
출력: me
출력: you
위 예제 코드에서는 Person 함수 객체의 prototype 프로퍼티에 getName()과 setNmae() 함수를 정의하였습니다. 이 Person으로 객체를 생성한다면 각 객체는 각자 따로 함수 객체를 생성할 필요없이 getName()과 setNmae() 함수를 프로토타입 체인으로 접근할 수 있습니다. 이를 그림으로 표현하면 다음과 같습니다.
이와 같이 자바스크립트에서 클래스 안의 메서드를 정의할 때는 프로토타입 객체에 정의한 후, new로 생성한 객체에서 접근할 수 있게 하는 것이 좋습니다. 더글라스 크락포드는 다음과 같은 함수를 제시하면서 메서드를 정의하는 방법을 소개합니다.
Function.prototype.method = function(name, func) {
if(!this.prototype[name]) {
this.prototype[name] = func;
}
}
이 함수를 활용하면 예제는 다음과 같은 형태가 됩니다.
Function.prototype.method = function(name, func) {
this.prototype[name] = func;
}
function Person(arg) {
this.name = arg;
}
Person.method("setName", function(value)) {
this.name = value;
}
Person.method("getName", function() {
return this.name;
});
var me = new Person("me");
var you = new Person("you");
console.log(me.getName());
console.log(you.getName());
더글라스 크락포드는 함수를 생성자로 사용하여 프로그래밍하는 것을 추천하지 않습니다. 그 이유는 생성된 함수는 new로 호출될 수 있을 뿐만 아니라, 직접 호출도 가능하기 때문입니다. 여기서 문제는 new로 호출될 때와 직접 호출될 때의 this에 바인딩되는 객체가 달라진다는 것입니다. 크락포드는 이러한 문제 때문에, 일단 생성자로 사용되는 함수는 첫 글자를 대문자로 표기할 것을 권고하고 있습니다.
자바스크립트는 클래스를 기반으로 하는 전통적인 상속을 지원하지 않습니다. 하지만 자바스크립트 특성 중 객체 프로토타입 체인을 이용하여 상속을 구현해낼 수 있습니다. 이러한 상속 구현 방식은 크게 두 가지로 구분할 수 있는데, 하나는 클래스 기반 전통적인 상속 방식을 흉내내는 것이고, 다른 하나는 클래스 개념 없이 객체의 프로토타입으로 상속을 구현하는 방식입니다.
이를 프로토타입을 이용한 상속이라고 합니다. 자바나 C++에 익숙한 개발자는 전통적인 상속의 형태가 익숙할 것입니다. 클래스와 생성자 등의 개념이 들어가 있기 때문입니다. 하지만 프로토타입을 이용한 상속은 객체 리터럴을 중심으로 철저히 프로토타입을 이용하여 상속을 구현합니다.
다음 예제를 살펴보겠습니다.
function create_object(o) {
function F() {}
F.prototype = o;
return new F();
}
이 코드는 더글라스 크락포드가 자바스크립트 객체를 상속하는 방법으로 오래 전에 소개한 코드입니다. 조금 과장해서 말하면 이 세줄의 코드를 이해하면 자바스크립트에서 프로토타입 기반의 상속을 다 배운 것이나 다름없다고 합니다?... 전 아직도 모르겠습니다만
이 세줄의 코드는 언뜻 보면 쉬워 보이지만 이해하기 쉬운 코드는 아닙니다.
다음 그림을 보겠습니다.
create_object() 함수는 인자로 들어온 객체를 부모로 하는 자식 객체를 생성하여 반환합니다. 그림을 보면 새로운 빈 함수 객체 F를 만들고, F.prototype 프로퍼티에 인자로 들어온 객체를 참조합니다. 함수 객체 F를 생성자로 하는 새로운 객체를 만들어 반환합니다. 이렇게 반환된 객체는 부모 객체의 프로퍼티에 접근할 수 있고, 자신만의 프로퍼티를 만들수도 있습니다. 이렇게 프로토타입의 특성을 활용하여 상속을 구현하는 것이 프로토타입 기반의 상속입니다. 참고로 앞에서 소개한 Object() 함수는 ECMAScript 5에서 Object.create() 함수로 제공되므로, 따로 구현할 필요는 없습니다. 이 함수의 코드로 프로토타입 기반 상속의 이해를 돕고자 사용한 것입니다.
다음 예제는 앞에서 소개한 create_object() 함수를 이용하여 상속을 구현한 예제입니다.
var person = {
name: "junyoung",
getName: function() {
return this.name;
},
setName: function (arg) {
this.name = arg;
}
};
function create_object(o) {
function F() {};
F.prototype = o;
return new F();
}
var student = create_object(person);
student.setName("junyoung");
console.log(student.getName());
출력:junyoung
Person 객체를 상속하여 Student 객체를 만들었습니다. 프로토타입 기반 상속의 특징이 보이나요? 클래스에 해당하는 생성자 함수를 만들지도 않았고, 그 클래스의 인스턴스를 따로 생성하지도 않았습니다. 단지 부모 객체에 해당하는 person 객체와 이 객체를 프로토타입 체인으로 참조할수 있는 자식 객체 Student를 만들어서 사용하였습니다. 이와 같은 방식으로 상속의 개념을 구현하였습니다.
그림을 표현하면 다음과 같습니다.
지금까지는 부모 객체의 메서드를 그대로 상속받아 사용하는 방법을 살펴보았습니다.
여기에서 자식은 자신의 메서드를 재정의 혹은 추가로 기능을 확장시킬수 있어야 합니다.
student.setAge = function(age) { ... }
student.getAge = function() { .... }
단순히 앞과 같이 그 기능을 확장시킬 수는 있습니다. 하지만 이렇게 구현하면 코드가 지저분해지기 십상입니다. 보다 깔끔한 방법을 생각해봅시다. 자바스크립트에서는 범용적으로 extend()라는 이름의 함수로 객체에 자신이 원하는 객체 혹은 함수를 추가시킵니다. 여기서는 가장 유명한 자바스크립트 라이브러리 중 하나인 jQuery의 extend() 함수를 살펴보고 이를 활용하는 방법을 생각해봅시다. jQuery의 1.0의 extend 함수는 다음과 같이 구현되었습니다.
jQuery.extend = jQuery.fn.extend = function(obj, prop) {
if(!prop) { prop = obj; obj = this; }
for(var i in prop) obj[i] = prop[i];
return obj;
};
이 코드를 분석해봅시다.
jQuery.extend = jQuery.fn.extend = ...
jQuery.fn은 jQuery.prototype이다. 따라서 앞 코드가 의마하는 바는 jQuery 함수 객체와 jQuery 함수 객체의 인스턴스 모두 extend 함수가 있겠다는 말입니다. 즉, jQuery.extend()로 호출할 수도 있고, var elem = new jQuery(...); elem.extend(); 형태로도 호출할 수 있음을 뜻합니다.
var person = {
name: "junyoung",
getName: function() {
return this.name;
},
setName: function(arg) {
this.name = arg;
}
};
function create_object(o) {
function F() {};
F.prototype = o;
return new F();
}
function extend(obj, prop) {
if(!prop) { prop = obj; ojb = this; }
for(var i in prop) obj[i] = prop[i];
return obj;
}
var student = create.object(person);
var added = {
setAge: function(age) {
this.age = age;
},
getAge: function() {
return this.age;
}
};
extend(student, added);
student.setAge(25);
console.log(student.getAge());
위 예제에서는 얕은 복사를 사용하는 extend() 함수를 사용하여 student 객체를 확장시켰습니다. extend() 함수는 사용자에게 유연하게 기능을 확장을 할 수 있게 하는 주요 함수일 뿐만 아니라, 상속에서도 자식 클래스를 확장할 때 유용하게 사용되므로 반드시 기억하라. 객체지향 프로그래밍 응용 예제에서는 프로토타입 체인과 extend() 함수를 이용하여 사용자가 상속을 직관적으로 쉽게 구현할 수 있게 도와주는 함수를 소개합니다.
앞 절처럼 함수의 프로토타입을 적절히 엮어서 상속을 구현해냅니다. 다만 앞 절에서는 객체 리터럴로 생성된 객체의 상속을 소개했지만, 여기서는 클래스의 역할을 하는 함수로 상속을 구현했습니다.
function Person(arg) {
this.name = arg;
}
Person.prototype.setName = function(value) {
this.name = value;
};
Person.prototype.getName = function() {
return this.name;
};
function Student(arg) {
}
var you = new Person("junyoung");
Student.prototype = you;
var me = new Student("velog");
me.setName("jun");
console.log(me.getName());
앞 예제에서 Student 함수 객체를 만들어서, 이 함수 객체의 프로토타입으로 하여금 Person 함수 객체의 인스턴스를 참조하게 만들었습니다. 이렇게 하면 Student 함수 객체로 생성된 객체 me의 [[Prototype]] 링크가 생성자의 프로토타입 프로퍼티 Student.prototype인 you를 가리키고, new Person()으로 만들어진 객체의 [[Prototype]] 링크는 Person.prototype을 가르키는 프로토타입 체인이 형성됩니다. 따라서 객체 me는 Person.prototype 프로퍼티에 접근할 수 있고, setName()과 getName()을 호출할 수 있습니다.
하지만, 여기에 함정 카드가 있습니다. 먼저 me 인스턴스를 생성할 때 부모 클래스인 Person의 생성자를 호출하지 않습니다.
var me = new Student("velog");
이 코드로 me 인스턴스를 생성할 때 "velog"을 인자로 넘겼으나, 이를 반영하는 코드는 어디에도 없습니다. 결국 생성된 me 객체는 빈 객체입니다. setName() 메소드가 호출되고 나서야 me 객체에 name 프로퍼티가 만들어집니다. 이렇게 부모의 생성자가 호출되지 않으면, 인스턴스의 초기화가 제대로 이루어지지 않아 문제가 발생할 수 있습니다. 이를 해결하려면 Student 함수에 다음 코드를 추가하여 부모 클래스의 생성자를 호출해야 합니다.
function Student(arg) {
Person.apply(this, arguments);
}
Student 함수 안에서 새롭게 생성된 객체를 apply 함수의 첫 번째 인자로 넘겨 Person 함수를 실행시킵니다. 이런 방식으로 자식 클래스의 인스턴스에 대해서도 부모 클래스의 생성자를 실행시킬 수 있습니다. 클래스 간의 상속에서 하위 클래스의 인스턴스를 생성할 때, 부모 클래스의 생성자를 호출해야 하는데, 이 경우에 필요한 방식입니다.
여기서 조금만 더 발전시켜 봅시다. 현재는 자식 클래스의 객체가 부모 클래스의 객체를 프로토타입체인으로 직접 접근합니다. 하지만 부모 클래스의 인스턴스와 자식 클래스의 인스턴스는 서로 독립적인 필요가 있습니다. 다음 그림을 보면 이해가 쉬울 것 입니다.
위 그림에서 자식 클래스의 prototpye이 부모 클래스의 인스턴스를 참조합니다.
이 구조는 자식 클래스의 prototype에 메소드를 추가할 때 문제가 됩니다. 이는 부모 클래스의 인스턴스인 you와 자식 클래스의 인스턴스인 me가 독립적이어야 함을 의미합니다.
두 클래스 사이에 중개자를 하나 만들어보겠습니다.
function Person(arg) {
this.name = arg;
}
Function.prototype.method = function(name, func) {
this.prototype[name] = func;
}
Person.method("setName", function(value) {
this.name = value;
});
Person.method("getName", function(value) {
return this.name;
});
function Student(arg) {
}
function F() {};
F.prototype = new F();
Student.prototype = new F();
Student.prototype.constructor = Student;
Student.super = Person.prototype;
var me = new Student();
me.setName("velog");
console.log(me.getName());
위 예제의 프로토타입 체인 형성 과정은 프로토타입을 이용한 상속의 상속 방식과 매우 유사합니다. 어차피 함수의 프로토타입을 이용한 것이니 비슷할 수 밖에 없습니다. 여기에서도 빈 함수 F()를 생성하고, 이 F()의 인스턴스를 Person.prototype과 Student 사이에 두었습니다. 그리고 이 인스턴스를 Student.prototype에 참조되게 합니다. 다음 그림으로 이해해봅시다.
그림을 보면 자식 클래스의 prtotype 객체는 빈 객체 입니다. 따라서 이 곳에, 자식 클래스의 확장된 메소드와 데이터가 들어갈 수 있습니다.
각 클래스의 객체인 me와 you가 아무런 관계가 없이 독립적입니다.
그리고 보는 바와 같이 빈 함수 객체를 중간에 두어 Person의 인스턴스와 Student의 인스턴스를 서로 독립적으로 만들었습니다. 이제 Person 함수 객체에서 this에 바인딩되는 것이 Student의 인스턴스가 접근할 수 없습니다. 이 상속이 앞서 소개된 상속보다 좀 더 나은 코드입니다.
캡슐화는 객체지향 프로그래밍에서 상당히 중요한 부분을 담당합니다. 캡슐화란 기본적으로 관련된 여러 가지 정보를 하나의 틀 안에 담는 것을 의미합니다. 이를 응용하면 맴버 변수와 메서드가 서로 관련된 정보가 되고 클래스가 이것을 담는 하나의 큰 틀이라고 할 수 있습니다. 여기에서 중요한 것은 정보의 공개 여부입니다. 정보 은닉의 개념이 바로 이 부분을 담당합니다. C++이나 Java에서는 public, private 맴버를 선언함으로써 해당 정보를 외부로 노출시킬지 여부를 결정합니다. 하지만 자바스크립트는 이러한 키워드 자체를 지원하지 않습니다. 그렇다고 해서 자바스크립트에서 정보 은닉이 불가능한 것은 아닙니다. 다음 예제를 살펴보겠습니다.
var Person = function(arg) {
var name = arg ? arg : "velog";
this.getName = function() {
return name;
}
this.setName = function(arg) {
name = arg;
}
};
var me = new Person();
console.log(me.getName());
me.setName("junyoung");
console.log(me.getName());
console.log(me.name) // 출력값 undefined
위 예제에서 private 맴버로 name을 선언하고, public 메서드로 getName()과 setName()을 선언하였습니다. 앞서 배운것 처럼 this 객체의 프로퍼티로 선언하면 외부에서 new 키워드로 생성한 객체로 접근할 수 있습니다. 하지만 var로 선언된 맴버들은 외부에서는 접근이 불가능합니다. 그리고 public 메서드가 클로저 역할을 하면서 private 맴버인 name에 접근할 수 있습니다. 이것이 자바스크립트에서 할 수 있는 기본적인 정보 은닉 방법입니다.
var Person = function(arg) {
var name = arg ? arg : "velog";
return {
getName: function() {
return name;
},
setName: function(arg) {
name = arg;
}
}
}
var me = new Person();
console.log(me.getName());
위 예제는 Person 함수를 호출하여 객체를 반환받습니다. 이 객체에 Person 함수의 private 맴버에 접근할 수 있는 메서드들이 담겨있다. 사용자는 반환받는 객체로 메서드를 호출할 수 있고, private 맴버에 접근할 수 있습니다. 이렇게 메서드가 담겨있는 객체를 반환하는 함수는 여러 유명 자바스크립트 라이브러리에서 쉽게 볼 수 있는 구조입니다. 다만 한 가지 주의할 점이 있습니다. 접근하는 private 맴버가 객체나 배열이면 얕은 복사로 참조만을 반환하므로 사용자가 이후 이를 쉽게 변경할 수 있습니다. 다음 예제는 이러한 문제를 잘보여줍니다.
var ArrCreate = function(arg) {
var arr = [1, 2, 3];
return {
getArr: function() {
return arr;
}
};
}
var obj = new ArrCreate();
var arr = obj.getArr();
arr.push(5);
console.log(obj.getArr()); // 출력값 : [1, 2, 3, 5]
이와 같은 문제가 있으므로 프로그래머는 객체를 반환하는 경우 신중해야 합니다. 보통의 경우, 객체를 반환하지 않고 객체의 주요 정보를 새로운 객체에 담아서 반환하는 방법을 많이 사용합니다. 하지만 꼭 객체가 반환되어야 하는 경우에는 깊은 복사로 복사본을 만들어서 반환하는 방법을 사용하는 것이 좋습니다.
다시 예제로 돌아가보겠습니다. 이 예제에서 사용자가 반환받는 객체는 Person 함수 객체의 프로토타입에는 접근할 수 없다는 단점이 있습니다. 이는 Person을 부모로 하는 프로토타입을 이용한 상속을 구현하기가 용이하지 않다는 것을 의미합니다. 이를 보완하려면 객체를 반환하는 것이 아닌, 함수를 반환하는 것이 좋습니다.
var Person = function(arg) {
var name = arg ? arg : "velog";
var Func = function() {}
Func.prototype = {
getName: function() {
return name;
},
setName: function(arg) {
name = arg;
}
};
return Func;
}();
var me = new Person();
console.log(me.getName());
클로저를 활용하여 name에 접근할 수 없게 했습니다. 즉시 실행 함수에서 반환되는 Func이 클로저가 되고 이 함수가 참조하는 name 프로퍼티가 자유 변수가 됩니다. 따라서 사용자는 name에 대한 접근이 불가능합니다.
이와 같이 자바스크립트에서 캡슐화를 구현하는 방법 역시 다양합니다. 실제로 여기에 소개된 패턴은 많은 자바스크립트 라이브러리에서 사용되고 있으므로, 이들을 잘 분석하고 장단점을 잘 구분할 수 있다면 본인이 작성하는 코드를 보다 더 효율적으로 만들수 있습니다. 실제로 위 예제와 같은 패턴을 모듈 패턴이라고 하는데 꽤 유용한 패턴입니다.
프로토타입을 이용한 상속과 클래스 기반의 상속에서 소개한 내용을 바탕으로 기존 클래스와 같은 기능을 하는 자바스크립트 함수를 만들어 보겠습니다. 이 함수에서는 앞서 소개한 다음 세 가지를 활용해서 구현합니다. 함수의 이름은 subClass로 하겠습니다.
- 함수의 프로토타입 체인
- extend 함수
- 인스턴스를 생성할 때 생성자 호출
subClass는 상속받은 클래스에 넣을 변수 및 메서드가 담긴 객체를 인자로 받아 부모 함수를 상속받는 자식 클래스를 만듭니다. 여기서 부모 함수 subClass() 함수를 호출할 때 this 객체를 읨합니다.
예를들면 다음과 같습니다.
var subClass = subClass(obj);
var subClass = SuperClass.subClass(obj);
이처럼 SuperClass를 상속받는 subClass를 만들고자 할 때, SuperClass.subClass()의 형식으로 호출하게 구현합니다. 참고로 최상위 클래스인 SuperClass는 자바스크립트의 Function 함수를 상속받게 합니다.
함수 SubClass 구조는 아래와 같이 구성됩니다.
function subClass(obj) {
/* (1) 자식 클래스 (함수 객체) 생성 */
/* (2) 생성자 호출 */
/* (3) 프로토타입 체인을 활용한 상속 구현 */
/* (4) obj를 통해 들어온 변수 및 메서드를 자식 클래스에 추가 */
/* (5) 자식 함수 객체 변환 */
}
자식 클래스 생성 및 상속
function subClass(obj){
........
var parent = this;
var F = function() {};
var child = function() {
};
F.prototype = parent.prototype;
child.prototype = new F();
child.prototype.constructor = child;
child.parent = parent.prototype;
child.parent_constructor = parent;
.........
return child;
}
자식 클래스는 child라는 이름의 함수 객체를 생성함으로써 만들어졌습니다. 부모 클래스를 가르키는 parent는 this를 그대로 참조합니다. 그리고 프로토타입 체인 구성은 클래스 기반 상속에서 설명된 방식을 그대로 사용하였습니다.