자바스크립트는 프로토타입 기반 언어라서 '상속' 개념이 존재하지 않습니다. 이는 클래스 기반의 다른 언어에 익숙한 많은 개발자들을 혼란스럽게 했고, 따라서 클래스와 비슷하게 동작하게끔 흉내 내는 여러 기법들이 탄생했으며 이들 중 몇가지는 널리 알려져 있습니다. 이러한 니즈에 따라 결국 ES6에는 클래스 뭄법이 추가됐습니다. 다만 ES6의 클래스에서도 일정 부분은 프로토타입을 활용하고 있기 때문에, ES5체제 하에서 클래스를 흉내내기 위한 구현 방식을 학습한 것은 여전히 큰 의미가 있습니다.
클래스와 인스턴스의 개념 이해
객체지향 프로그래밍에서 거의 반드시 등장하는 제1요소인 클래스라는 단어의 의미는 일반적으로 쓰이는 의미와 거의 흡사합니다. 영어사전에서 class는 '계급, 집단, 집합' 등으로 번역합니다. 프로그래밍 언어적으로도 이와 동일한 개념에서 접근하면 됩니다.
프로그래밍 언어에서의 클래스를 다루기에 앞서 일반적인 개념부터 명확히 하고 넘어 갑시다. 예를 들어, 어떤 가게에 다양한 음식이 한 개씩 있다고 했을 때, '음식' 이라는 범주안에는 고기, 채소, 과일 등등 다양한 것들이 들어갈 수 있습니다. 이들 역시 다시 하위 각 분류에 속하는 대상들을 나열할 수 있을 것입니다. 과일 범주 아래에는 배, 사과, 바나나 등등이 포함되겠죠. 여기서 가게에 있는 배, 사과, 바나나 등은 직접 만질 수 있고 볼 수 있고 먹을 수 있는 구체적이고 실존하는 사물에 해당합니다. 반면 음식이나 과일은 어떤 사물들의 공통 속성을 모아 정의한 것일 뿐 직접 만질 수도 볼 수도 없는 추상적인 개념입니다. 한편 음식은 과일과의 관계에서 상위의 개념이고, 과일은 음식과의 관계에서 하위의 개념입니다. 이를 그림으로 표현하면 다음과 같습니다.
음식, 과일은 모두 집단, 즉 클래스 입니다. 음식은 과일보다 상위의 개념이고 과일은 음식보다 하위의 개념입니다. 여기서 앞의 super-, sub- 를 접목해서 상위 클래스/하위 클래스로 표현합니다. 그렇다면 과일 분류 하위에 또 다른 분류가 있을 경우에는 클래스 간의 관계는 어떻게 될까요 ?
음식은 과일의 superclass입니다. 과일은 음식의 subclass이면서 귤류의 superclass입니다. 귤류는 과일의 subclass입니다. 한편 음식은 귤류의 super - superclass입니다. 귤류는 음식의 sub-subclass입니다. 하위 개념은 상위 개념을 포함하면서 더 구체적인 개념이 추가됩니다. 최상위 분류인 음식 클래스는 '먹을 수 있다'정도라면, 하위의 과일 클래스는 '먹을 수 있다 + 나무에서 열린다'가 되고, 그 하위의 귤류 클래스는 '먹을 수 있다 + 나무에서 열린다 + 말랑한 껍질 속에 달고 신맛이 나는 과육이 들어있다'가 됩니다. 이처럼 클래스는 하위로 갈수록 상위 클래스의 속성을 상속하면서 더 구체적인 요건이 추가 또는 변경됩니다. 물론 하위 클래스가 아무리 구체화 되더라도 이들은 결국 추상적인 개념일 뿐입니다.
여기서 감귤, 자목, 천혜향 등은 음식에 속해 먹을 수 있고, 과일에 속해 나무에서 열리며 귤류에 속해 말랑한 껍질 속에 달고 신맛이 나는 과육이 들어있는 구체적인 개체들입니다. 앞의 클래스들의 속성을 지니는 실제로 먹을 수 있고 만질 수 있는 실존하는 개체이며 이를 인스턴스(instance)라고 합니다.
한편 현실세계에서는 개체들이 이미 존재하는 상태에서 이들을 구분짓기 위해 클래스를 도입합니다. 반면 프로그래밍 언어상에서는 접근 방식이 정반대입니다. 컴퓨터는 위와 같은 구분법을 알지 못하므로 사용자가 직접 여러가지 클래스를 정의해야 하며, 클래스를 바탕으로 인스턴스를 만들 때 비로소 어떤 개체가 클래스의 속성을 지니게 됩니다. 또한 한 인스턴스는 하나의 클래스만을 바탕으로 만들어 집니다. 어떤 인스턴스가 다양한 클래스에 속할 수 는 있지만 이 클래스들은 모두 인스턴스 입장에서는 '직계존속' 입니다. 다중상속을 지원하는 언어이든 그렇지 않은 언어이든 결국 인스턴스를 생성할 때 호출하 수 있는 클래스는 오직 하나뿐일 수 밖에 없기 때문입니다.
자바스크립트의 클래스
앞서 자바스크립트는 프로토타입 기반 언어이므로 클래스의 개념이 존재하지 않는다고 했습니다. 그렇지만 프로토타입을 일반적인 의미에서의 클래스 관점에서 접근해보면 비슷하게 해석할 수 있는 요소가 없지 않습니다.
생성자 함수 Array를 new 연산자와 함께 호출하면 인스턴스가 생성됩니다. 이때 Array를 일종의 클래스라고 하면 Array의 prototype객체 내부 요소들이 인스턴스에 '싱속'된다고 볼 수 있습니다.엄밀히는 상속이 아닌 프로토타입 체이닝에 의한 참조지만 결과적으로는 동일하게 동작하므로 이렇게 이해해도 무방합니다.한편 Array내부 프로퍼티들 중 prototype프로퍼티를 제외한 나머지는 인스턴스에 상속되지 않습니다.
인스턴스에 상속되는지 여부에 따라 스태틱 멤버와 인스턴스 멤버로 나뉩니다. 근데 여느 클래스 기반 언어와 달리 자바스크립트에서는 인스턴스에서도 직접 메서드를 정의할 수 있기 때문에 '인스턴스 메서드'라는 명칭은 프로토타입에 정의한 메서드를 지칭하는 것인지 인스턴스에 정의한 메서드를 지칭한 것인지에 대해 도리어 혼란을 야기합니다. 따라서 이 명칭에 대힌 자바스크립트의 특징을 살려 프로토타입 메서드라고 부르는 편이 더 좋습니다.
1 var Rectangle = function(width, height){
2 this.width = width;
3 this.height = height;
4 };
5 Rectangle.prototype.getArea = function(){
6 return this.width * this.height;
7 };
8 Rectangle.isRectangle = function(instance){
9 return instance instanceof Rectangle &&
10 instance.width > 0 && instance.height > 0;
11 }
12 var rect1 = new Rectangle(3,4);
13 console.log(rect1.getArea()); // 12
14 console.log(rect1.isRectangle(rect1)); // Error
15 console.log(Rectangle.isRectangle(rect1)); // true
프로토타입 객체에 할당한 메서드는 인스턴스가 마치 자신의 것처럼 호출할 수 있다고 했으니까 13번째 줄에서 호출한 getArea는 실제로는 rect1.__proto__.getArea에 접근하는 데 이때 __proto__를 생략했으므로 this가 rect1인 채로 실행될 테니까, 결과적으로는 rect1.width * rect1.height의 계산값이 반활될 것입니다. 이처럼 인스턴스에서 직접 호출할 수 있는 메서드가 바로 프로토타입 메서드 입니다.
한편 14번째 줄은 rect1 인스턴스에서 isRectangle이라는 메서드에 접근하고자 합니다. 우선 rect1에 해당 메서드가 있는지 검색했는데 없고, react1.__proto__에도 없으며 react1.__proto__.__proto__(Object.prototype)에도 없습니다. 결국 undefined를 실행하는 명령이므로, 함수가 아니어서 실행할 수 없다는 의미의 error가 발생합니다. 이렇게 인스턴스에서 직접 접근할 수 없는 메서드를 스태틱 메서드라고 합니다. 스태틱 메서드는 15번째 줄처럼 생성자 함수를 this로 해야만 호출할 수 있습니다.
클래스의 상속
1 var Grade = function () {
2 var args = Array.prototype.slice.call(arguments);
3
4 for (var i = 0; i < args.length; i++) {
5 this[i] = args[i];
6 }
7 this.length = args.length;
8 };
9
10 Grade.prototype = [];
11
12 var g = new Grade(1, 2);
다시 한번 강조하면 ES5까지의 자바스크립트에는 클래스가 없습니다. ES6에서 클래스가 도입됐지만 역시나 prototype을 기반으로 한 것입니다. 그러니깐 자바스크립트에서 클래스 상속을 구현했다는 것은 결국 프로토타입 체이닝을 잘 연결한 것으로 이해하면 되는 것입니다.
다만 '기본적으로는' 그렇다는 것이고, 세부적으로 완벽하게 superclass와 subclass의 구현이 이뤄진 것은 아닙니다. 위의 코드에서는 몇가지 큰 문제가 있습니다. length 프로퍼티가 삭제가능 하다는 점과 Grade.prototype에 빈 배열을 참조시켰다는 점이 그렇습니다.
문제점 중 우선 length에 대해 예제를 통해 살펴 보겠습니다.
...
13 g.push(90);
14 console.log(g); // Grade {0: 100, 1: 80, 2: 90, length: 3}
15
16 delete g.length;
17 g.push(70);
18 console.log(g); // Grade {0: 70, 1: 80, 2: 90, length: 1}
14번째 줄에는 원하는 대로 결과가 잘 나왔습니다. 그런데 16번째 줄에서 length 프로퍼티를 삭제하고 다시 push를 했더니, push 한 값이 0번째 인덱스에 들어갔고, length가 1이 됐습니다. 내장 객체인 배열 인스턴스의 length 프로퍼티를 삭제가능한 속성이 false라서 삭제가 불가능 하지만 Grade 클래스의 인스턴스는 배열 메서드를 상속하지만 기본적으로 일반 객체의 성질을 그대로 지니므로 삭제가 가능해서 문제가 됩니다.
한편 push했을 때 0번째 인덱스에 70이 들어가고 length가 다시 1이 될 수 있었던 까닭은 바로 g.__proto__, 즉 Grade.prototype이 빈 배열을 가리키고 있기 때문입니다. push 명령에 의해 자바스크립트 엔진이 g.length를 읽고자 하는데 g.length가 없으니깐 프로토타입 체이닝을 타고 g.__proto__.length를 읽어온 것이죠. 빈 배열의 length가 0이므로 여기에 값을 할당하고 length는 1만큼 증가시키라는 명령이 문제없이 동작할 수 있었던 것입니다.
그럼 만약 Grade.prototype에 요소를 포함하는 배열을 매칭시키면 어떻게 될까요 ?
...
11 Grade.prototype = ['a','b','c','d'];
12 var g = new Grade(100,80);
13 g.push(90);
14 console.log(g); // Grade {0: 100, 1: 80, 2: 90 length: 3}
15 delete g.length;
16 g.push(70);
17 console.log(g); // Grade {0: 100, 1: 80, 2: 90, __, 4: 70, length: 5}
이번에는 prototype에 length가 4인 배열을 할당했습니다. 10, 11번째 줄은 문제 없이 동작합니다. 그런데 12번째 줄에서 length를 삭제하고 나니 앞선 예제와 다르게 동작하는 것을 확인할 수 있습니다. g.length가 없으니까 g.__proto__.length를 찾고, 값이 4이므로 인덱스 4에 70을 넣고, 다시 g.length에 5를 부여하게 됩니다.
이처럼 클래스에 있는 값이 인스턴스의 동작에 영향을 줘서는 안되겠습니다. 사실 이런 영향을 줄 수 있는 사실 자체가 이미 클래스의 추상성을 해치는 것입니다. 인스턴스와의 관계에서는 구체적인 데이터를 지니지 않고 오직 인스턴스가 사용할 메서드만을 지니는 추상적인 '틀'로서만 작용하게끔 작성하지 않는다면 언젠간 어딘가에서 예기치 않은 오류가 발생할 것입니다.
그렇다면 직사각형, 정사각형 클래스를 활용한 다른 예를 한번 보겠습니다.
1 var Rectangle = function(width, height){
2 this.width = width;
3 this.height = height;
4 };
5 Rectangle.prototype.getArea = function(){
6 return this.width * this.height;
7 };
8 var rect = new Rectangle(3,4);
9 console.log(rect.getArea()); // 12
10 var Square = function(width){
11 this.width = width
12 };
13 Square.prototype.getArea = function(){
14 return this.width * this.width;
15 }
16 var sq = new Square(5);
17 console.log(sq.getArea()); // 25
이렇게 보니 Rectangle과 Square 클래스에 공통 요소가 보입니다. width라는 프로퍼티가 공통이고, getArea는 내용이 다르지만 비슷합니다. 만약 Square에서 width 프로퍼티를 사용하지 않고 heigth 프로퍼티에 width값을 부여하는 형태가 된다면 getArea도 동일하게 고칠 수 있겠습니다.
...
10 var Square = function(width){
11 this.width = width;
12 this.height = width;
13 };
14 Square.prototype.getArea = function() {
15 return this.width * this.height;
16 }
원래부터 정사각형은 직사각형에 '네 변의 길이가 모두 같다'라는 구체적인 조건이 하나 추가된 개념이죠. 위처럼 고치고 나니 이제는 소스상으로 Square를 Rectangle의 하위 클래스로 삼을 수 있을 것 같습니다. getArea라는 메서드는 동일한 동작을 하므로 상위 클래스에서만 정의하고, 하위 클래스에서는 해당 메서드를 상속하면서 height 대신 width를 넣어주면 되겠네요.
...
10 var Square = function(width){
11 Rectangle.call(this,width,width);
12 }
13 Square.prototype = new Rectangle();
11번째 줄에서는 Square의 생성자 함수 내부에서 Rectangle의 생성자 함수를 함수로써 호출했습니다. 이떄 인자는 height 자리에 width를 전달했습니다. 13번째 줄에서는 메서드를 상속하기 위해 Square의 프로토타입 객체에 Rectangle의 인스턴스를 부여했습니다. 이것만으로도 일단은 원하는 대로 동작합니다.
그러나 위 코드만으로 완벽한 클래스 체계가 구축됐다고 볼 수는 없습니다.
1. console.dir(sq);
첫 줄에서 Square의 인스턴스임을 표시하고 있고, width와 height에 모두 5가 잘 들어가 있습니다. __proto__는 Rectangle의 인스턴스임을 표시하고 있는데, 바로 이어서 width, height에 모두 undefined가 할당돼 있음을 확인할 수 있습ㄴ디ㅏ. Square.prototype에 값이 존재하는 것이 문제네요, 만약 이후에 임의로 Square.prototype.width에 값을 부여하고 sq.width의 값을 지워버린다면 프로토타입 체닝에 의해 엉뚱한 결과가 나오게 될 것입니다.
나아가 construction가 영전히 Rectangle을 바라보고 있다는 문제도 있습니다. sq.construction로 접근하면 프로토타입 체이닝에 따라 sq.__proto__.__proto__, 즉 Rectangle.prototype에서 찾게 되며 이는 Rectangle을 가리키고 있기 때문입니다.
이처럼 하위 클래스로 삼을 생성자 함수의 prototype에 상위 클래스의 인스턴스를 부여하는 것만으로도 기본적인 메서드 상속은 가능하지만 다양한 문제가 발생할 여지가 있어 구조적으로 안전성이 떨어집니다. 다음 절에서 이런 문제들을 해결하는 방안은 검토해 봅시다.
2. 클래스가 구체적인 데이터를 지니지 않게 하는 방법
클래스가 구체적인 데이터를 지니지 않게 하는 방법은 여러가지가 있는데, 그 중 가장 쉬운 방법은 일단 만들고 프로퍼티들을 일일이 지우고 더는 새로운 프로퍼티가 추가할 수없게 하는 것입ㄴ디ㅏ. 이정도로 깔끔하고 간단하게 목적하는 바를 충분히 이뤄낼 수 있습니다.
1 delete Square.prototype.width;
2 delete Square.prototype.height;
3 Object.freeze(Square.prototype);
다른 방법으로는 더글라스 그락포드가 제시해서 대중적으로 널리 알려진 방법으로 아이디어는 이렇습니다. subclass의 prototype에 직접 subclass의 인스턴스를 할당하는 대신 아무런 프로퍼티를 생성하지 않은 빈 생성자 함수(Bridge)를 하나 더 만들어서 그 prototype이 subclass의 prototype을 바라보게끈 한 다음, subclass의 prototype에는 bridge의 인스턴스를 할당하게 하는 것입니다. 비 함수에 다리 역할을 부여하는 것이죠.
1 var Rectangle = function(wiwdth, height){
2 this.width = width;
3 this.ehgith = height;
4 };
5 Rectangle.prototype.getArea = function() {
6 return this.width * this.height;
7 }
8 var Square = function(width) {
9 Rectangle.call(this,width,width);
10 };
11 var Bridge = function(){};
12 Bridge.prototype = Rectangle.prototype;
13 Square.prototype = new Bridege();
14 Object.freeze(Square.prototype);
Bridege라는 빈 함수를 만들고 Bridege.prototype이 Rectangle.prototye을 참조하게 한 다음, Square.prototype에 new Bridege()로 할당하면 인스턴스를 제외한 프로토타입 체인 경로상에는 더는 구체적인 데이터가 남아있지 않게 됩니다.
마지막으로 ES5에 도입된 Object.create를 이용한 방법을 소개합니다. 이 방법은 subclass의 prototype의 __proto__가 subclass의 prototype을 바라보되, superclass의 인스턴스가 되지는 않으므로 앞서 소개한 두 방법보다 간단하면서 안전합니다.
...
11 Square.prototype = Object.create(Rectangle.prototype);
12 Object.freeze(Square.prototype);
...
3. constructor 복구하기
위 세가지 방법 모두 기본적으로 상속에는 성공했지만 subclass 인스턴스의 constructor는 여전히 superclass를 가리키는 상태입니다. 엄밀히는 subclass 인스턴스에는 constructor가 없고, subclass.prototype에도 없는 상태입니다. 프로토타입 체인상에 가장 먼저 등장하는 superclass.prototype의 constructor에서 가리키는 대상,즉 superclass가 출력된 뿐이죠.따라서 위 코드들의 subclass.prototype.constructor가 원래subclass를 바라보도록 해주면 되겠습니다.
3가지 방법 중 2번째 방법인 빈 함수(Bridege)를 활용한 방법만 보겠습니다.
1 var extendClass = (function (){
2 var Bridge = function(){};
3 return function(superclass, subclass, subMethods){
4 Bridge.prototype = superclass.prototype;
5 subclass.prototype = new Bridge();
6 subclass.prototype.constructor = subclass;
7 }
8 })();
ES6의 클래스 및 클래스 상속
앞에서 수차례 언급했듯이 ES6에서는 본격적으로 클래스 문법이 도입됐습니다. 여기서는 ES5 체계에서의 생성자 함수 및 프로토타입과 ES6의 클래스 문법을 비교하며 소개해 보겠습니다.
1. ES5 문법
1 var ES5 = function(name) {
2 this.name = name;
3 }
4 ES5.staticMethod = function() {
5 return this.name + 'staticMethod';
6 }
7 ES5.prototype.method = function() {
8 return this.name + 'method';
9 };
10 var es5Instance = newES5('es5');
11 console.log(ES5.staticMethod()); // es5 staticMethod
12 console.log(es5Instance.method()); // es5 method
2. ES6 문법
1 var EES6 = class {
2 constructor(name){
3 this.name = name;
4 }
5 static staticMethod(){
6 return this.name + ' staticMethod';
7 }
8 method(){
9 return this.name + ' method';
10 }
11 };
12 var es6Instance = new ES6('es6');
13 console.log(ES6.staticMethod()); // es6 staticMethod
14 console.log(es6Instance.method()); // es6 method
static이라는 키워드 뒤에 staticMethod라는 이름이 등장했고 뒤이어 () {}가 등장합니다. static 키워드는 해당 메서드가 static 메서드임을 알리는 내용으로 ES5 체계에서 생성자 함수에 바로 할당하는 메서드와 동일하게 생성자 함수(클래스) 자신만이 호출할 수 있습니다.
method라는 이름은 자동으로 prototype 객체 내부에 할당되는 메서드 입니다. ES5.prototype.method와 동일하게 인스턴스가 프로토타입 체이닝을 통해 마치 자신의 것처럼 호출할 수 있는 메서드 입니다.
3. ES6의 클래스 상속
1 var Rectangle = class {
2 constructor(width,height) {
3 this.width = width;
4 this.height = height;
5 }
6 getArea() {
7 return this.width * this.height;
8 }
9 };
10 var Square = class extends Rectangle{
11 constructor(width){
12 super(width,width);
13 }
14 getArea(){
15 console.log('size is : ', super.getArea());
16 }
17 };
참고 : 코어 자바스크립트(Core JavaScript), 정재남
'javascript' 카테고리의 다른 글
[javascript] 클로저 (0) | 2020.05.08 |
---|---|
[javascript] this (0) | 2020.05.04 |
[javascript] 실행 컨텍스트 (0) | 2020.05.02 |
[javascript] undefined와 null (0) | 2020.05.02 |
[javascript] 데이터 타입 (0) | 2020.05.01 |