코어 자바스크립트 #7 클래스

신윤철·2022년 2월 14일
0

코어자바스크립트

목록 보기
7/8
post-thumbnail

클래스

프로토 타입을 공부하며 '클래스의 상속과 거의 같은게 아닌가?' 라는 생각을 했습니다.

그런데 제 생각을 어떻게 알았는지 이번 장에서 클래스와 프로토타입 체인은 비슷한 점이 있지만 왜 같다고 생각하면 위험한지에 대한 설명을 해주었습니다.

이번 장에선 클래스와 인스턴스의 개념과 자바스크립트 ES5에서의 클래스 작성법, ES6에서의 클래스 사용법등을 알아보겠습니다.

클래스와 인스턴스의 개념 이해

클래스의 일반적인 개념은 공통 속성의 정의라고 할 수 있습니다.

직업이라는 공통 속성 내에 IT라는 분야가 있을 수 있고 IT라는 분야 내에 개발자라는 분야가 있을 수 있습니다.

개발자의 내부에는 구체적인 직업들이 있을 수 있죠

여기서 직업, IT, 개발자라는 개념은 공통 속성을 모아 정의한 추상적인 개념이고
프론트엔드, 백엔드, 임베디드, 모바일 어플리케이션 등은 구체적인 개념에 해당합니다.

또한 직업은 IT의 상위 클래스(superClass)이고, IT는 개발자의 상위 클래스(superClass)이며
반대로 IT는 직업의 하위 클래스(subClass), 개발자는 IT의 하위 클래스(subClass)입니다.

클래스의 속성을 자세히 살펴보겠습니다.

  • 직업 : 일을 하여 돈을 벌 수 있음.
  • IT : 일을 하여 돈을 벌 수 있음 + IT 분야에서 일함
  • 개발자 : 일을 하여 돈을 벌 수 있음 + IT 분야에서 일함 + 요구사항에 맞춰 각종 소프트웨어, 웹, 어플리케이션등을 유지보수, 개발함

이처럼 하위 클래스는 상위 클래스의 속성을 상속하며 점차 구체적인 요건이 추가 또는 변경됩니다.

여기서 프론트엔드, 백엔드, 임베디드 등은 개발자 클래스의 속성을 지니는 구체적인 개체들입니다.

이처럼 클래스의 속성을 지니는 실존하는 개체들을 일컬어 인스턴스(instance)라고 합니다.

현실세계에서는 개체들이 먼저 만들어지고 서로 공통점을 묶어 상위 개념이 만들어집니다.
(ex. 기현, 병현, 수연 등 개인이 모여 가족을 이루고, 가족이 모여 동네를 만들고 동네가 모여 도시가 만들어집니다.)

하지만 프로그래밍 상에서는 클래스가 먼저 정의되고 점차 하위 클래스가 만들어지며 필요해의해 클래스를 바탕으로 구체적인 개체들이 만들어집니다.

이러한 차이때문에 클래스를 이해할때 약간의 헷갈림이 생길 수 있습니다.

자바스크립트의 클래스

일반적으로 프로토타입 기반의 언어는 클래스의 개념이 없다고 하지만 프로토타입 체이닝을 통해 상위 프로토타입의 내용을 참조한다는 점이 클래스의 개념과 약간 비슷하게 해석할 수 있습니다.

생성자 함수로 인스턴스를 생성할 경우 해당 인스턴스는 생성자 함수의 prototype 객체 내부 요소들을 참조합니다.
(클래스의 개념에선 상속이지만 프로토타입은 참조를 하므로 약간의 차이가 발생합니다. (결과적으론 동일하게 작동!))

하지만 앞서 6장에서 배웠듯이 인스턴스가 생성자 함수를 참조할때 모든 내용을 참조하는것은 아니고 구분할 수 있습니다.

  • 스태틱 멤버 : 생성자 함수에서만 정의되어 인스턴스에서 참조할 수 없음
  • 인스턴스 멤버 : 프로토타입에 정의되어 인스턴스에서 참조할 수 있음

그런데 여느 클래스 기반 언어와 달리 자바스크립트는 인스턴스에서도 직접 메서드를 정의할 수 있습니다. (메서드 오버라이딩)

// 6장에서 사용한 내용
var Person = function (name) {
  this.name = name;
};
Person.prototype.getName = function () {
  return this.name;
};

var myName = new Person('윤철');
myName.getName = function () {
  return '신' + this.name;
};
console.log(myName.getName());			// 신윤철

때문에 '인스턴스 멤버'의 '인스턴스 메서드'라는 명칭은 프로토타입에 정의한 메서드인지 인스턴스에 정의한 메서드인지 혼란스럽습니다.
(예제에선 Person.prototype.getName도 인스턴스 메서드이고 myName.getName도 인스턴스 메서드입니다.)

때문에 인스턴스 메서드는 프로토타입 메서드라고 부르는편이 좋습니다.
(Person.prototype.getName == 프로토타입 메서드, myName.getName == 인스턴스 메서드)

코드를 약간 수정하여 스태틱 멤버, 프로토타입 멤버에 대해 예제를 통해 알아보겠습니다.

var Person = function (name) {
  this.name = name;
};
Person.isPerson = function (instance) {
  return instance instanceof Person;
};
Person.prototype.getName = function () {
  return this.name;
};

var myName = new Person('윤철');

console.log(myName.getName());				// 윤철
console.log(myName.isPerson(myName));		// (error) myName.isPerson is not a function
console.log(Person.isPerson(myName));		// true

프로토타입 객체에 할당한 메서드는 인스턴스에서 자신의 것처럼 (__proto__를 생략해서) 사용할 수 있다고 했고 실제로 myName.getName에서 잘 사용할 수 있습니다.

그리고 생성자 함수 Person에서 생성한 isPerson 메서드는 스태틱 메서드이므로 인스턴스에서 사용할 수 없고, 생성자 함수에서만 사용할 수 있습니다.

함수를 생성할 때 prototype을 붙히지 않고 생성하면 스태틱 멤버,
prototype을 붙히고 생성하면 프로토타입 멤버입니다.

앞서 클래스는 추상적인 개념이라고 설명했는데 Person.isPerson같이 클래스에서 스태틱 메서드를 호출할 때 클래스는 하나의 개체로서 취급됩니다.

클래스 상속

클래스 상속은 객체지향에서 가장 중요한 요소 중 하나입니다.

하지만 자바스크립트에선 클래스가 존재하지 않기 때문에 ES5까지는 최대한 프로토타입 체인을 활용하여 클래스와 비슷한 형태를 흉내냈습니다.

ES6에선 클래스 문법이 도입되었기 때문에 이러한 방식을 사용할 일은 없겠지만
ES6에서의 클래스 문법도 prototype을 기반으로 만들어진것이기 때문에 그 이전의 방식들을 배우는 것도 이해에 도움이 될 것입니다.
(책에선 너무 확실히 이해하려 하진 않아도 된다고 합니다.)

기본 구현

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

앞서 자바스크립트에서 클래스를 흉내내기 위해 prototype을 사용한다고 하였습니다.

다만 겉으로는 비슷해보여도 세부적으로 superClass와 subClass를 완변하게 구현한 것은 아닙니다.

위 코드에는 length 프로퍼티가 삭제 가능하단 점과 Grade.prototype이 빈 배열을 참조한다는 문제점이 있는데 자세히 알아보겠습니다.

문제점 1. length 프로퍼티 삭제가능, 상위 클래스의 값이 하위 클래스의 값에 영향을 줌

// 위 코드의 이어서 작성
g.push(90);
console.log(g)			// Grade(3) [100, 80, 90] length : 3

delete g.length;
g.push(70);
console.log(g);			// Grade [70, 1: 80, 2: 90] length : 1

g.push(90)을 통해 Grade의 마지막값에 90이 잘 추가되고 length도 정상적으로 늘어났습니다.

그런데 delete g.length를 통해 length를 한번 지우고 g.push(70)을 할 경우 Grade의 첫번째 행에 70이 추가되고, length도 1이 됩니다.

왜 그럴까요❓

바로 내장객체 Array의 length 프로퍼티는 configurable 속성이 false라서 삭제가 불가능하지만, Grade의 length 프로퍼티는 삭제가 가능하기 때문입니다.

실제 결과를 보니 약간 더 이해가 잘 가네요.

delete로 삭제한 결과 Grade의 length 프로퍼티가 삭제되었고 그 다음 g.push(70)을 실행한 결과 g.length가 없어졌기 때문에 프로토타입 체이닝을 통해 g.__proto__.length의 값을 찾아 +1을 한 것입니다.
(length가 삭제되어 기존의 값도 새로운 push값으로 대체됩니다.)

만약 Grade.prototype = ['a', 'b', 'c', 'd', 'e']; 처럼 빈 배열이 아닌 값을 할당하면 어떻게 될까요?

Grade.prototype = ['a', 'b', 'c', 'd', 'e'];
var g = new Grade(100, 80);

g.push(90);
console.log(g)			// Grade(3) [100, 80, 90] length : 3

delete g.length;
g.push(70);
console.log(g);			// Grade(6) [100, 80, 90, empty × 2, 70], length : 6

마찬가지로 g.length가 사라져서 프로토타입 체이닝을 통해 g.__proto__의 정보를 찾습니다.

그런데 해당 Grade.prototype의 length가 5라서 length + 1인 6이 되고, 비어있지 않은 인덱스 5에 70을 넣게됩니다. (인덱스 0~4는 'a','b','c','d','e' 가 들어있음)

이처럼 superClass의 값이 인스턴스의 동작에 영향을 미치는데 정상적인 클래스에서는 추상성을 해치는 일입니다.

클래스는 인스턴스에 구체적인 데이터를 주지 않고 추상적인 틀로서 역할을 해야하는데 이렇게 사용한다면 예기치 않은 오류가 발생할 가능성이 있습니다.

문제점 2. intance와 subClass의 constructor이 superClass의 constructor을 보고있음

g.constructorg.__proto__.__proto__.constructor, 즉 Array를 가리키고 있습니다.

때문에 아래와 같은 코드가 성립하게 됩니다.

var g2 = new g.constructor(10, 20);
console.log(g2);			// [10, 20]

클래스의 관점에서 instance에서 상위 클래스의 constructor에 접근하여 새로운 instance를 생성하는것은 구조적으로 안전성이 떨어집니다.

문제점 1을 해결한 후 하위 개체들이 상위 클래스의 constructor을 바라보지 못하게 만들겠습니다.

문제점 1 해결 => 클래스가 구체적인 데이터를 지니지 않게 만들기

첫번째 방법으로 클래스가 구체적인 데이터를 지니지 않게 하는 가장 간단한 방법은 프로퍼티들을 일일이 지우고 freeze하여 더는 추가할 수 없게 만드는 것입니다.

delete superClass.prototype.method;			// 상위 클래스의 메서드를 지
Object.freeze(superClass.prototype);		// freeze로 더는 추가할 수 없게 만듬

두번째 방법은 더글라스 크락포드가 제시한 방법으로

subClass의 prototype에 직접 superClass의 instance를 할당하는 대신
아무런 프로퍼티를 생성하지 않는 빈 생성자 함수(Bridge)를 하나 더 만드는 것 입니다.

var Bridge = function () {};
Bridge.prototype = superClass.prototype;
subClass.prototype = new Bridge();
Object.freeze(subClass.prototype);


즉, superClass와 subClass 사이에 Bridge 추가

그런데 이때 subClass.prototype을 freeze하므로써 상위 클래스의 prototype을 참조할 수 없게 만듭니다.
(subClass.prototype이 빈 값이므로)

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


세번째 방법은 ES5에서 도입된 Object.create를 이용한 방법입니다.

subClass.prototype = Object.create(superClass.prototype);
Object.freeze(subClass.prototype);

Object.create는 아무 내용이 없는 prototype을 생성합니다.
때문에 위의 코드를 실행하고 freeze할시 subClass의 prototype에는 아무 prototype이 남아있지 않게 됩니다.

구체적인 코드로 살펴보기위해 앞서 "클래스 상속 부분의 기본 구현"의 코드로 비교해보겠습니다.

//Object.create(superClass.prototype)을 구현한 코드
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 = Object.create(Array.prototype);
Object.freeze(Grade.prototype);

var g = new Grade(100, 80);
console.log(g);

결과를 보니 Grade의 기본 length값이 사라져서 상위 클래스의 값에 영향을 받지 않게 된것을 확인할 수 있습니다.

문제점 2 해결 => instance와 하위 클래스가 상위 클래스의 constructor을 가리키는 문제 해결

이 문제의 해결법은 간단합니다.

subClass.prototype.constructor = subClass;
Object.freeze(subClass.prototype);

이처럼 subClass의 prototype에 직접 subClass를 담는것 입니다.

이 예시도 위에서 사용한 코드에 추가하여 살펴보겠습니다.

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 = Object.create(Array.prototype);
Grade.prototype.constructor = Grade;				// 이 부분 추가
Object.freeze(Grade.prototype);

var g = new Grade(100, 80);
console.log(g);

그 결과 constructor에 Grade가 잘 들어간 것을 확인할 수 있습니다.
이로써 하위 클래스는 자신의 constructor을 제대로 가리키게 됩니다.


ES6의 클래스 및 클래스 상속

앞서 언급했든 ES6부터는 클래스 문법이 도입되었습니다.

ES5와 ES6의 클래스 문법을 비교하여 소개해보겠습니다.

// ES5에서 유사 클래스 문법
var ES5 = function (name) {
  this.name = name;
};
ES5.staticMethod = function () {
  return this.name + ' staticMethod';
};
ES5.prototype.prototypeMethod = function () {
  return this.name + ' prototypeMethod';
};
var es5Instance = new ES5('es5');
console.log(es5Instance);
console.log(ES5.staticMethod());
console.log(es5Instance.prototypeMethod());

// ES6에서 클래스 문법
var ES6 = class {
  constructor(name) {
    this.name = name;
  }
  static staticMethod() {
    return this.name + ' staticMethod';
  }
  prototypeMethod() {
    return this.name + ' prototypeMethod';
  }
};
var es6Instance = new ES6('es6');
console.log(es6Instance);
console.log(ES6.staticMethod());
console.log(es6Instance.prototypeMethod());

ES5는 일일이 staticMethod, prototypeMethod, 프로퍼티등을 하나하나 작성하였습니다.

하지만 ES6에서는 class로 묶어 static 메서드와 prototype 메서드등을 한번에 정의하는 것을 확인할 수 있습니다.

그럼 결과를 살펴보겠습니다.

ES5와 ES6의 console.log 결과는 같게 나옵니다.

하지만 내부 구조를 살펴보면 많은 차이가 있습니다.

가장 큰차이는 ES5의 constructor은 함수, ES6의 constructor은 class라는 점입니다.

그 외에도 prototypeMethod, staticMethod 등도 차이를 보입니다. (코드를 작성해서 직접 구조를 보는 것을 추천)

profile
기본을 탄탄하게🌳

0개의 댓글