JS - Class

박상준

2024년 12월 02일

1

JavaScript

자바스크립트에서 Class

클래스는 객체 지향 언어에서 사용하는 개념이다. 하지만 앞의 포스트에서도 얘기했듯이 자바스크립트는 객체 지향 언어가 아닌 프로토타입 기반 언어이다. 객체 지향이 아닌데도 클래스라는 개념이 있다니 헷갈리는 부분이다. 물론 진짜 클래스는 아니다. 많은 개발자들이 클래스가 존재하지 않는 자바스크립트에서 클래스를 흉내내기 위해 노력했고, 그 결과가 프로토타입에 의한 상속이다. 결국 ES6부터는 Class문법이 생기기도 했는데 클래스에 대해서 자세히 알아보자

클래스와 인스턴스의 이해

말로만 들었던 클래스에 대해서 자세히 알아보겠다. class는 사전적인 의미로 계급, 집단, 집합 등의 의미로 해석된다. 이 개념이 프로그래밍에서 어떻게 활용되는지 알아보기 전에 우리 주변에서 예시를 찾아보겠다. Image 마트에 가서 식료품코너의 과일코너에 갔다고 생각해보자. 마트에서 음식이라는 범주에 들어가는 상품만 모아둔 곳이 식료품코너다. 그리고 여러 종류의 음식들이 있지만 과일이라는 공통 분류에 해당하는 것을 모아둔 곳이 과일 코너이다. 이제 과일 코너에서는 각 과일들을 볼수가 있다.

여기에서 음식은 가장 상위에 있기 때문에 상위 개념, superClass가 되고, 하위에 있는 과일은 음식의 하위 개념, subClass가 된다. 만약 하위 개념에 또 다른 하위 개념이 생겨도 동일하다. Image 음식은 모든 개념의 상위 개념으로 super-superClass이다. 그리고 과일은 음식의 하위이며, 귤류의 상위 개념이므로 sub-superClass이다. 그리고 귤류는 subClass이다.

이처럼 상위의 개념에서 하위로 내려올수록 상위의 속성을 상속하면서 더 구체적인 개념이 된다. 그래서 Class는 어떤 조건을 의미한다. 하지만 계속 구체화를 하더라도 추상적인 개념에서 그친다.

먹을 수 있다. + 나무에서 열린다. + 껍질속에 과육이 들어있다.

이렇게 추상적인 개념이 존재할때 실존하는 구체적인 예시를 instance, 인스턴스라고 한다. 위에서는 귤류에 해당하는 귤이나 오렌지가 인스턴스가 되는 것이다.

우리가 살고 있는 세상에서는 이미 존재하는 것들을 나누고 분류하기 위해 클래스를 사용한다. Image 현실 세계에서의 나를 예시로 보겠다. 나는 남자이며, 20대이고, 한국인이다. 각 분류는 관계가 없는 분류체계이지만, 나를 기준으로 분류한 것이다. 이렇게 분류한 여러 기준으로 내가 어떤 기준에 해당하는 사람인지 알수 있는 것이다.

반대로 컴퓨터는 이렇게 이해할 수 없다. 이미 존재하는 인스턴스가 없기 때문에 미리 클래스를 정의해야 하며, 정의한 클래스를 기반으로 인스턴스를 생성하는 것이다. 이렇게 생성된 인스턴스에 클래스의 속성이 상속되는 것이다.

또한 여러개의 클래스를 가지지 못한다. a라는 인스턴스가 있을때 b, c, d라는 클래스를 동시에 가질수가 없다는 것이다. 만약 3개의 클래스가 합쳐진 단일 클래스라면 가능하지만 다중 클래스는 가질수가 없다는 것이다.

정리하자면 프로그래밍에서 클래스는 공통 요소를 지니는 집단을 분류하기 위한 개념이라는 점에서 현실과 동일하지만, 현실에서는 인스턴스에 의해 클래스가 나눠지는 반면 프로그래밍에서는 클래스가 정의되어야 인스턴스가 생성된다.

자바스크립트의 클래스

자바스크립트를 정리하면서 클래스를 얘기하고 있지만 자바스크립트에는 클래스가 없다. 하지만 프로토타입을 클래스 관점에서 접근하면 비슷하게 해석가능해진다.

var arr = new Array(1,2,3);

생성자 함수 Array와 new를 사용해서 배열을 생성해보자. 생성자 함수인 Array는 배열이 가지는 속성과 메서드를 가지기 때문에 일종의 클래스라고 할 수 있다. 이렇게 클래스를 통해 생성된 배열은 생성자의 prototype을 상속받은 인스턴스가 되는 것이다.

엄밀히 말하면 상속은 아니다. 프로토타입 체이닝을 통해 참조되는 것이지만 상속과 동일하게 동작하기 때문에 그렇게 이해하고 넘어가는게 낫다.

상속받는다고 클래스의 모든 속성를 상속받는 것은 아니다. 인스턴스에 상속되는 속성과 상속되지 않는 속성이 정해져있다. Image Array 생성자를 확인해보면 여러 배열 메서드들과 prototype을 확인해볼 수 있다. 여기에서 상속되지 않는 속성은 prototype에 정의된 메서드를 제외한 from, isArray, arguments 등으로 static member라고 한다. static memberstatic methodsstatic properties로 이뤄져 있다.

반면에 prototype의 메서드는 인스턴스에 상속되며 인스턴스에서도 Array의 메서드를 사용할 수 있게 된다. 이런 속성을 instance member라고 하며 instance/prototype methods로 이뤄져 있다.

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

생성자 함수를 만들고 static method와 prototype method를 추가해봤다. 생성자 함수를 통해 생성된 인스턴스는 static method인 isRectangle은 에러가 발생하고, prototype method인 getArea는 동작한다. static method인 isRectangle은 생성자 함수를 this로 지정해줬을때 사용이 가능하다.

이런 관점에서 봤을때 클래스는 추상적인 조건을 의미하기도 하지만 구체적인 개체가 될 수도 있다. 인스턴스를 생성할때는 조건을 가진 기준이지만, 클래스 자체를 this로 가지는 static method를 호출하면 개체로서 취급되어 메서드를 실행한다.

클래스 상속

현재 ES6에서는 Class 문법이 등장하면서 클래스를 상속하는 문제가 쉬워졌지만 Class문법 이전에는 프로토타입 체이닝을 활용해 클래스 상속을 구현하고 객체 지향 언어에서의 클래스와 비슷한 형태로 발전시키기 위해 노력을 많이 했다. 그래서 그 방법들을 알아보겠다.

프로토타입 체이닝으로 상속 부작용

var Grade = function(){
  var args = Array.prototype.slice.call(arguments);
  for(var i = 0; i < args.length; i++){
   this[i] = args[i]; 
  }
  this.length = args.length;
};
Grade.prototype = [];
var g = new Grade(100, 80);

arguments를 인덱스로 변환하고 length를 지정해 유사 배열을 만드는 생성사 함수를 만들었다. 이렇게 생성된 유사 배열을 배열과 똑같은 형태를 가지지만 결국에는 객체이기 때문에 생성자 함수의 prototype을 빈배열로 지정해주면서 배열의 메서드를 연결시켜줬다.

g.push(90);
console.log(g); // Grade{ 0:100, 1:80, 2:90, length: 3}
delete g.length;
g.push(70);
console.log(g); // Grade{ 0:70, 1:80, 2:90, length: 1}

그래서 유사 배열이지만 배열 메서드인 push를 동작하면 인덱스의 값과 length가 정상적으로 동작한다. 추가적으로 객체 메서드인 delete를 적용해도 된다. 왜냐하면 prototype을 배열로 지정한 것이지 결국 생성된 인스턴스는 객체이기 때문이다. 그럼 length 프로퍼티를 삭제하고 push하면 어떻게 될까?

push를 하면 length의 값을 참조해서 해당 인덱스에 값을 추가하고 length을 1증가시킨다. 그런데 여기에서 length의 프로퍼티를 삭제함으로 해당 프로퍼티가 존재하지 않게 된다. 이제 프로토타입 체이닝에 의해 g.__proto__Grade.prototype을 참조하게 되고, Grade.prototype은 빈 배열이기 때문에 length가 0이다. 그래서 해당 인덱스인 0번 인덱스에 값을 추가하고, length를 1증가시킴으로 위와 같은 결과가 나오는 것이다.

빈배열이 아닌 인덱스에 값이 있는 배열을 할당해도 같은 동작이 발생한다.

Grade.prototype = [1,2,3,4];
...
g.push(90);
delete g.length;
g.push(70);
console.log(g); // Grade{ 0:100, 1:80, 2:90, 4:70, length: 5}

prototype로 지정한 배열의 length를 참조하면서 3번 인덱스에 값이 추가되는 것이 아닌 4번 인덱스에 값이 추가되고 원래 배열의 length가 아닌 prototype의 length에서 1이 추가된 모습니다.

Image 생성자 함수로 생성된 인스턴스와 prototype의 관계를 나타낸 도표이다. 어떤 흐름으로 length가 추가되고 배열의 값이 추가됬는지 알 수 있다.

이렇게 클래스에 있는 값이 인스턴스의 동작에 영향을 미치는 것은 잘못된 것이다. 클래스란 추상성을 가지고 있어야 하는데 인스턴스에 직접적인 영향을 미치면 안되고 하나의 '틀'로서만 작용해야만 한다.

클래스가 구체적인 데이터를 가지지 않는법

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){
  this.width = width;
}
Square.prototype.getArea = function(){
  return this.width * this.width;
};
var sq = new Square(5);
console.log(sq.getArea()) // 25

직사각형과 정사각형을 만드는 생성자 함수를 만들었다. 이렇게 보니 중복되는 메서드가 있고 Square생성자도 height 프로퍼티가 없을뿐 width를 height 프로퍼티로 지정하면 동일하게 생성이 가능해진다.

var Square = function(width){
  Rectangle.call(this, width, width);
};
Square.prototype = new Rectangle();
var sq = new Square(10);

이제 Square는 Rectangle 생성자에 의해 생성되며 prototype도 Rectangle의 prototype을 상속받아 getArea메서드도 사용할 수 있게 되었다. 이렇게 만들어진 두 클래스는 앞에서 다뤘던 프로토타입 체이닝에 의해 인스턴스에 영향을 끼칠수 있다. 단순히 프로퍼티에만 그치는 것이 아니라, 생성된 인스턴스인 sq의 constructor로 접근하게 되면 Rectangle이 참조되어 엉뚱한 결과가 발생한다.

클래스가 데이터를 지니지 않게 하는 가장 쉬운 방법은 만들고나서 일일히 클래스의 프로퍼티를 지워주는 것이다.

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

하지만 이 방법은 일일히 프로퍼티를 제거해주는 방법에서 확실하긴 하지만 번거롭다. 그래서 더글라스 크락포드가 제시한 방법을 알아보겠다.

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);

Bridge라는 빈 함수를 만들고 prototype이 Rectangle.prototype을 참조한 후에 Square.prototype에 new Bridge를 할당하는 것이다. 즉, 인스턴스를 제외한 프로토타입 체인 경로상에는 구체적인 데이터가 남아있지 않는 것이다. Image 그림으로 나타내면 이렇다. 좀 복잡하게 되어있지만 보기 쉽게 정리하면 Image Bridge가 중간 다리 역할을 하면서 인스턴스를 제외한 곳에서는 구체적인 데이터가 존재하지 않게 된다.

마지막으로 ES5에서 추가된 Object.create를 활용해보겠다.

Object.create() : 메서드는 지정된 프로토타입 객체 및 속성(property)을 갖는 새 객체 생성

Square.prototype = Object.create(Rectangle.prototype);
Object.freeze(Square.prototype);
// Square {width: 10, height: 10}
// [[Prototype]] :  Rectangle

이 방법은 SubClass의 prototype의 __proto__가 SuperClass의 prototype을 바라보되, SuperClass의 인스턴스가 되지 않는 방법이다.

이렇게 세가지 방법을 통해 Class상속을 할수가 있지만, 위에서 얘기한데로 constructor는 아직 SubClass가 아닌 SuperClass를 바라볼수 밖에 없다. 그래서 완전한 상속을 위해선 constructor까지 변경해줘야 한다.

...
SubClass.prototype.constructor = SubClass;
...

이렇게까지 해주면 SuperClass와 SubClass의 속성이 인스턴스에 영향을 주지 않도록 상속이 이뤄진다.

ES6 Class

이렇게 매번 사용자들이 상속을 구현하고, 상속을 편하게 하기 위해 다양한 라이브러리가 등장했다. 그래서 본격적으로 클래스 문법이 등장했다.

var ES6 = class {
  constructor(name){
    this.name = name;
  }
  static staticMethod(){
    return this.name + '  staticMethod';
  }
  method(){
    return this.name + '  method';
  }
};
var es6Instance = new ES6('es6');
console.log(ES6.staticMethod()); // ES6  staticMethod
console.log(es6Instance.method()); // es6  method

기존 방법은 constructor와 staticMethod, prototypeMethod를 지정해줘야 해서 상당히 번거로운 작업들이였지만, ES6의 클래스 문법은 한번에 지정해서 생성이 가능하기 때문에 간편하게 클래스 상속을 해줄수가 있다.

마무리

클래스 문법이 존재한다는 것은 알았지만 실제로 활용해서 코드를 작성해본 경험이 없어서 해당 지식이 전무했다. 이 기회에 새로운 방법을 알게 되어서 한단계 성장했다고 생각한다. 앞으로 코드를 작성하면서 더 많은 데이터를 생성하고 관리하게 될텐데 자주 써보고 활용방법을 익혀봐야겠다.

드디어 코어자바스크립트를 마쳤다. 책을 산게 9월인데 이제서야 끝을 내다니 참 부끄럽다. 끝냈다고 하기에도 애매하다. 정리만 한것이지 과연 전부 내것이 되었는가에 대한 의문이 남는다. 앞으로 복습을 꾸준히 할 생각이다. 내가 정리한 글이기에 완벽하지 않을 것이다. 하지만 오히려 내가 쓴글이니까 수정을 거듭해 더 나은 글이 되지 않을까 싶다.

이제 다른 책을 공부할까 싶다. 지금 리액트를 계속 쓰고 있는데 핵심 동작 원리나 세부적인 기술들은 제대로 이해하고 있지는 않다고 느껴진다. 그냥 배운대로 쓴다는 느낌. 그래서 리액트 딥다이브를 공부할 예정이다. 빠른 시일내에 블로그로 복귀해보자.

개의 댓글