JavaScript

[Javascript] 프로토타입 체인을 통한 클래스 구현

Gaeun Lee 2024. 7. 5. 01:22

프로토타입 기반 언어인 자바스크립트는 상속 개념이 존재하지 않습니다. 그러나 ES6에 추가된 클래스 문법은 프로토타입을 일정부분 활용하고 있으므로, 프로토타입을 활용하여 클래스를 직접 구현해보며 클래스를 이해하는 시간을 가져봅시다!

 

01. 클래스와 인스턴스

계층, 집단, 집합의 의미를 가진 클래스(class)는 어떤 실존하는 개체들의 공통 속성을 모아 정의한 추상적인 개념을 뜻합니다.

그리고 인스턴스(instance)는 어떤 클래스의 속성을 지니는 실존하는 개체를 말합니다.

과일로 예를 들자면 음식, 과일, 귤류는 각각 공통 속성을 모아 정의한 클래스이고, 감귤, 자몽, 오렌지 등은 귤류의 속성을 지니는 인스턴스입니다.

여기서 상하관계를 엿볼 수 있는데, 상위 개념은 superclass, 하위 개념은 subclass라고 부릅니다.

과일은 귤류의 superclass이고, 귤류는 음식의 sub-subclass인 것이지요.

위 그림에 나타난 음식, 과일, 귤류의 속성을 보시면 하위로 갈수록 상위 클래스의 속성이 상속되며 더 구체적인 요건이 추가, 변경되는 클래스의 특징을 알 수 있습니다.

인스턴스는 다양한 클래스에 속할 수 있지만, 인스턴스를 생성할 때 호출 할 수 있는 클래스는 하나뿐이므로, 클래스는 인스턴스 입장에서 '직계존속'입니다.

 




 

02. 자바스크립트의 클래스

프로토타입과 클래스

'계층'의 의미를 갖는 클래스 관점에서 접근시 프로토타입은 클래스와 비슷하게 해석될 수 있습니다.

생성자 함수 Array를 new 연산자와 함께 호출하여 만든 인스턴스(new Array()) 는 클래스 관점에서, Array prototype 객체 내부 요소들이 인스턴스에 상속된다고 볼 수 있는 것이지요.

스태틱 메서드와 인스턴스 메서드

const arr = new Array(1, 2);
console.dir(Array);
console.dir(arr);

Array 내부 프로퍼티들 중 prototype 프로퍼티를 제외한 나머지는 인스턴스에 상속되어 있지 않습니다.

여기서 인스턴스에 상속된 멤버를 인스턴스 멤버(instance member), 그렇지 않은 멤버를 스태틱 멤버(static member)라고 부릅니다.

자바스크립트에서는 인스턴스에도 직접 메서드를 정의할 수 있기 때문에, '인스턴스 메서드'라는 용어를 사용하는 경우, 지칭하는 대상이 프로토타입과 인스턴스 둘 중 어느 곳에 정의한 메서드인지 헷갈리므로, 인스턴스에 상속된 메서드는 프로토타입 메서드라고 부르는 것이 주류입니다.

그럼 스태틱 메서드와 인스턴스 메서드의 차이를 예제를 통해 알아볼까요?

var Rectangle = function (width, height) {
   this.width = width;
   this.height = height;
};
Rectangle.prototype.getArea = function () {
   return this.width * this.height;
};
Rectangle.isRectangle = function (instance) {
   return instance instanceof Rectangle && instance.width > 0 && instance.height > 0;
};

var rect1 = new Rectangle(3, 4);
console.log(rect1.getArea()); // 12
console.log(rect1.isRectangle(rect1)); // Error
console.log(Rectangle.isRectangle(rect1)); // true

여기서 getArea()는 프로토타입 메서드이므로 인스턴스가 __proto__를 생략하고 호출할 수 있습니다.
이처럼 프로토타입 메서드는 인스턴스에서 직접 호출할 수 있는 메서드를 말합니다.

isRectangle()은 스태틱 메서드로, 생성자 함수를 this로 해야만 호출할 수 있습니다.
그러므로 14번째 줄은 인스턴스가 스태틱 메서드를 호출하였기 때문에 에러가 발생하였고, 15번째 줄은 정상적으로 출력되었습니다.
이처럼 스태틱 메서드는 인스턴스에서 직접 접근할 수 없는 메서드를 말합니다.

인스턴스가 사용할 메서드를 정의한 '틀'의 역할을 담당하는 목적을 가질 때의 클래스는 추상적인 개념이지만, 클래스 자체를 this로 해서 접근해야만 하는 스태틱 메서드를 호출할 때의 클래스는 그 자체가 하나의 개체로서 취급됩니다.

 


 

03 클래스 상속

이번 챕터에서는 프로토타입 체인을 활용해 클래스 상속을 구현해보겠습니다.

3-1. 기본 구현

var Grade = function () {
   //arguments 객체를 배열처럼 취급하여 slice 메서드를 호출한 후, 실제 배열로 변환
   var args = Array.prototype.slice.call(arguments);
   for (var i = 0; i < args.length; i++) {
      this[i] = args[i];
   }
   this.length = args.length;
};
// Grade 인스턴스들이 배열의 메서드들을 상속받을 수 있도록 하기 위하여 빈 배열 참조
Grade.prototype = [];
var g = new Grade(100, 80);
g.push(90);
console.log(g); // Grade { 0: 100, 1: 80, 2: 90, length: 3 }
delete g.length; // length 프로퍼티 삭제
g.push(70);
console.log(g); // Grade { 0: 70, 1: 80, 90, length: 1}
// push한 값이 0번째 인덱스에 들어가고, length가 1이 됨

이 구현의 문제는 다음과 같습니다

  • length 프로퍼티가 configurable (삭제 가능) 하다는 점
  • Grade.prototype에 빈 배열을 참조시켰다는 점

그래서 g.length 프로퍼티를 삭제 후 g.push(70)를 하였을 때 g._proto__가 빈 배열인 Grade.prototype을 가리키고 있으므로, 빈 배열이 length 0이므로, 0번째 index에 70을 넣고, length를 1이라고 한 것입니다.

이처럼 클래스에 있는 값은 인스턴스의 동작에 영향을 주는 것은 클래스의 추상성을 해치는 것이므로, 클래스는 오직 인스턴스가 사용할 메서드만을 지니는 틀로만 작용하게끔 작성해야 합니다.

이번에는 직사각형과 정사각형 클래스를 통한 상속 관계를 구현해보겠습니다.

var Rectangle = function (width, height) {
   this.width = width;
   this.height = height;
};
Rectangle.prototype.getArea = function () {
   return this.width * this.height;
};
var rect = new Rectangle(3, 4);
console.log(rect.getArea()); // 12
var Square = function (width) {
   Rectangle.call(this, width, width);
};
Square.prototype = new Rectangle();
var sq = new Square(5);
console.log(sq.getArea()); // 25

이는 앞서 Grade를 구현했던 것과 같은 방법으로 구현했기 때문에 클래스에 있는 값이 인스턴스에 영향을 줄 수 있 구조라는 동일한 문제를 가지고 있습니다.

콘솔을 찍어 확인하였을 때 sq의 width와 height가 5로 알맞게 입력된 것을 확인할 수 있습니다.

그러나 Square의 __proto__인 Rectangle이 width와 height 프로퍼티를 undefined로 갖고 있는 것을 확인할 수 있습니다.

이는 앞에서 Grade의 인스턴스인 g.__proto__의 length 프로퍼티를 삭제하였을 때, 프로토타입 체이닝에 의하여 g.__proto__.__proto__까지 참조하게 되어 빈 배열을 참조하고 있던 Grade.prototype의 length 값을 참조한 것과 같은 오류를 발생할 수 있습니다.

sq.__proto__.width를 삭제하면 sq.__proto__.__proto__인 Rectangle의 width 값 undefined를 출력할 수 있다는 것이지요.

 

그리고 아래와 같이 sq의 constructor이 Rectangle을 가리키고 있어 이 또한 문제가 됩니다.

이와 하위 클래스로 삼을 생성자 함수의 prototype에 상위 클래스의 인스턴스를 부여하는 것만으로도 기본적인 메서드 상속 가능하지만 다양한 문제가 발생할 여지가 있어 구조적으로 안정성이 떨어지는 것을 확인할 수 있습니다.

 

그렇다면 클래스가 구체적인 데이터를 지니지 않게 하는 방법은 무엇이 있을까요?

 

 

3-2. 클래스가 구체적인 데이터를 지니지 않게 하는 방법

아래와 같은 3가지 방법이 존재합니다.

[1] 인스턴스 생성 후 프로퍼티 제거

일단 만들고 나서 프로퍼티들을 일일이 지우고 더는 새로운 프로퍼티를 추가할 수 없게 하는 것입니다

delete Square.prototype.width;
delete Square.prototype.height;
Object.freeze(Square.prototype);

프로퍼티가 많을 경우 다음과 같이 범용적인 함수를 사용할 수 있습니다.

var extendClass1 = function (SuperClass, SubClass, subMethods) {
   // SubClass는 SuperClass의 프로토타입 메서드와 프로퍼티를 상속받습니다.
   SubClass.prototype = new SuperClass();
   for (var prop in SubClass.prototype) {
      // SubClass.prototype에 있는 모든 프로퍼티에 대해 반복문을 실행합니다.
      // hasOwnProperty 메서드를 사용하여 프로토타입 체인에 있는 상속된 프로퍼티가 아닌,
      // SubClass.prototype 자체에 직접 정의된 프로퍼티인지 확인합니다.
      if (SubClass.prototype.hasOwnProperty(prop)) {
         // 직접 정의된 프로퍼티를 삭제합니다.
         이는 상속 과정에서 불필요한 인스턴스 프로퍼티를 제거합니다.
         delete SubClass.prototype[prop];
      }
   }
   // subMethods 객체가 제공된 경우, SubClass.prototype에 추가할 메서드가 있는지 확인합니다.
   if (subMethods) {
      // subMethods 객체에 있는 모든 메서드에 대해 반복문을 실행합니다.
      for (var method in subMethods) {
         // subMethods 객체에 있는 메서드를 SubClass.prototype에 추가합니다.
         // 이를 통해 SubClass는 SuperClass를 상속받으면서도 추가적인 메서드를 가질 수 있습니다.
         SubClass.prototype[method] = subMethods[method];
      }
   }
   Object.freeze(SubClass.protype);
   return SubClass;
};

var Square = extendClass1(Rectangle, function (width) {
   Rectangle.call(this, width, width);
});

[2] 빈 함수 활용

SubClass의 prototype에 직접 SuperClass의 인스턴스를 할당하는 대신 아무런 프로퍼티를 생성하지 않는 빈 생성자 함수(Bridge)를 하나 더 만들어서 그 prototype이 SuperClass의 prototype을 바라보게끔 한 다음, SubClass의 prototype에는 Bridge의 인스턴스를 할당하게 하는 방법입니다.

var Rectangle = function (width, height) {
   this.width = width;
   this.height = height;
};
Rectangle.prototype.getArea = function () {
   return this.width * this.height;
};
var Square = function (width) {
   Rectangle.call(this, width, width);
};
var Bridge = function () {};
Bridge.prototype = Rectangle.prototype;
Square.prototype = new Bridge();
Object.freeze(Square.prototype);
  • 과정
    1. Bridge라는 빈 함수를 만듦
    2. Bridge.prototype이 Rectangle.prototype을 참조하게 함
    3. Square.prototype에 new Bridge()로 할당

이를 통해 인스턴스를 제외한 프로토타입 체인 경로상에는 더이상 구체적인 데이터가 남아있지 않게 됩니다.

범용성을 고려하여 아래와 같이 작성할 수 있습니다.

var extendClass2 = (function () {
   var Bridge = function () {};
   //subMethods에는 subClass의 prototype에 담길 메서드들을 객체로 전달하게끔 함
   return function (SuperClass, SubClas, subMethods) {
      Bridge.prototype = SuperClass.prototype;
      Subclass.prototype = new Bridge();
      if (subMethods) {
         for (var method in subMethods) {
            SubClass.prototype[method] = subMethods[method];
         }
      }
      Object.freeze(SubClass.prototype);
      return SubClass;
   };
})();

이 코드는 즉시실행함수 내부에서 Brige를 선언해서 이를 클로저로 활용함으로써 메모리에 불필요한 함수 선언을 줄였습니다.

클로저를 사용하여 Bridge 생성자가 한 번만 정의되므로 extendClass2 함수를 여러 번 호출하더라도 Bridge 생성자가 매번 새로 정의되지 않습니다. 즉, Bridge는 한 번 정의되고 모든 호출에서 재사용됩니다. 이를 통해 메모리 사용과 성능이 최적화됩니다.

[3] Object.cretae 이용

마지막으로 Object.create으로 이용하는 방법은 SubClass의 prototype의 __proto__가 SuperClass의 prototype을 바라보되, SuperClass의 인스턴스가 되지는 않으므로 앞서 소개한 두 방법보다 간단하면서 안전합니다.

// (...생략)
Square.prototype = Object.create(Rectangle.prototype);
Object.freeze(Square.prototype);
// (...생략)

 

 

3-3. constructor 복구하기

앞의 내용에서 기본적인 상속까진 성공했지만, SubClass 인스턴스의 constructor은 여전히 SuperClass를 가리키는 상태입니다.

(SubClass 인스턴스에는 constructor가 없고, SubClass.prototype에도 없는 상태입니다.)

SubClass.prototype.constructor가 원래의 SubClass를 바라보도록 하기 위해 위 세 가지 코드에

SubClass.prototype.constructor = SubClass;를 추가하였습니다.

[1] 인스턴스 생성 후 프로퍼티 제거

var extendClass1 = function (SuperClass, SubClass, subMethods) {
   SubClass.prototype = new SuperClass();
   for (var prop in SubClass.prototype) {
      if (SubClass.prototype.hasOwnProperty(prop)) {
         delete SubClass.prototype[prop];
      }
   }
   SubClass.prototype.constructor = SubClass;
   if (subMethods) {
      for (var method in subMethods) {
         SubClass.prototype[method] = subMethods[method];
      }
   }
   Object.freeze(SubClass.prototype);
   return SubClass;
};

[2] 빈 함수 활용

var extendClass2 = (function () {
   var Bridge = function () {};
   return function (SuperClass, SubClas, subMethods) {
      Bridge.prototype = SuperClass.prototype;
      Subclass.prototype = new Bridge();
      SubClass.prototype.constructor = SubClass;
      if (subMethods) {
         for (var method in subMethods) {
            SubClass.prototype[method] = subMethods[method];
         }
      }
      Object.freeze(SubClass.prototype);
      return SubClass;
   };
})();

[3] Object.cretae 이용

var extendClass3 = function (SuperClass, SubClass, subMethods) {
   SubClass.prototype = Object.create(SuperClass.prototype);
   SubClass.prototype.constructor = SubClass;
   if (subMethods) {
      for (var method in subMethods) {
         SubClass.prototype[method] = subMethods[method];
      }
   }
   Object.freeze(SubClass.prototype);
   return SubClass;
};

 

 

3-4 상위 클래스에의 접근 수단 제공

이번에는 하위 클래스의 메서드에서 상위 클레스의 메서드 실행 결과를 바탕으로 추각적인 작업을 수행하고 싶은 경우, 하위 클래스에서 상위 클래스의 프로토타입 메서드에 접근하기 위한 별도의 수단이 필요하므로 super()를 흉내내면 다음과 같습니다.

var extendClass3 = function (SuperClass, SubClas, subMethods) {
   Subclass.prototype = Object.create(SuperClass.prototype);
   SubClass.prototype.constructor = SubClass;
   /* 추가된 코드 시작 */
   SubClass.prototype.super = function (propName) {
      var self = this;

      // 인자가 비어있을 경우 SuperClass 생성자 함수에 접근
      if (!propName)
         return function () {
            SuperClass.apply(self, arguments);
         };
      var prop = SuperClass.prototype[propName];

      // SuperClass의 prototype 내부의 propName에 해당하는 값이 함수가 아닌 경우에는 해당 값을 그대로 반환
      if (typeof prop !== 'function') return prop;

      // 힘수인 경우 : 클로저를 활용해 메서드 접근
      return function () {
         return prop.apply(self, arguments);
      };
   };
   /* 추가된 코드 끝 */
   if (subMethods) {
      for (var method in subMethods) {
         SubClass.prototype[method] = subMethods[method];
      }
   }
   Object.freeze(SubClass.prototype);
   return SubClass;
};

var Rectangle = function (width, height) {
   this.width = width;
   this.height = height;
};
Rectangle.prototype.getArea = function () {
   return this.width * this.height;
};
var Square = extendClass(
   Rectangle,
   function (width) {
      this.super()(width, width);
   },
   {
      getArea: function () {
         console.log('size is :', this.super('getArea')());
      },
   }
);
var sq = new Square(10);
sq.getArea(); // size is : 100
console.log(sq.super('getArea')()); // 100
  • 구현한 super 메서드 사용법
    • SuperClass의 생성자 함수에 접근시 this.super() 사용
    • SuperClass의 프로토타입 메서드에 접근하고자 할 때는 this.super(propName) 사용




 

04 ES6의 클래스 및 클래스 상속

저희가 프로토타입을 활용하여 직접 구현해봤던 클래스는
ES6의 class를 통해 간편히 사용할 수 있습니다!

ES5와 ES6의 클래스 문법 비교

var ES5 = function (name) {
   this.name = name;
};
ES5.staticMethod = function () {
   return this.name + ' staticMethod';
};
ES5.prototype.method = function () {
   return this.name + ' method';
};
var es5Instance = new ES5('es5');
console.log(ES5.staticMethod()); // es5 staticMethod
console.log(es5Instance.method()); // es5 method

var ES6 = class {
   constructor(name) {
      // 생성자 함수
      this.name = name;
   }
   static staticMethod() {
      // 생성자 함수(클래스) 자신만이 호출 가능
      return this.name + ' staticMethod';
   }
   method() {
      // 자동으로 prototype 객체 내부에 할당되는 메서드로, 인스턴스가 프로토타입 체이닝을 통해 마치 자신의 것처럼 호출 가능
      return (this.name = ' method');
   }
};
var es6Instance = new ES6('es6');
console.log(ES6.staticMethod()); // es6 staticMethod
console.log(es6Instance.method()); // es6 method

ES6의 클래스 상속

var Rectangle = class {
    constructor (width, hegith) {
        this.width = width;
        this.height= height;
    }
    getArea () {
        return this.width * this.height;
    }
}
var Square class extends Rectangle {
    constructor (width) {
        super(width, width);
    }
    getArea() {
        console.log('size is:', super.getArea());
    }
}