본문 바로가기
👩‍💻 Programming/JavaScript

프로토타입(prototypes)을 활용한 객체 지향_2. 상속(inheritance)과 클래스(classes) from Secrets of the JavaScript Ninja

by codingBear 2022. 4. 16.
728x90
반응형

이번 글은 'Secrets of the JavaScript Ninja'의 'Chapter 7. Object orientation with prototypes'를 바탕으로 작성하였습니다.


핵심 Concepts

  • 프로토타입에 대한 탐구
  • 함수를 생성자로서 사용하기
  • 프로토타입을 사용하여 객체 확장하기
  • 일반적인 문제 피하기
  • 상속을 활용한 클래스 작성

사전 지식 체크!

  • 객체가 특정 프로퍼티에 접근할 수 있는지 어떻게 시험하는가?
  • JavaScript에서 객체를 활용하여 작업할 때 프로토타입 체인(prototype chain)이 중요한 이유
  • ES6 클래스는 JavaScript가 객체와 작동하는 방식을 변경하는가?

Achieving inheritance

 상속(inheritance)는 새 개체가 기존 객체의 프로퍼티에 엑세스(access)할 수 있게끔 코드를 재사용하는 형태이다. 이를 통해 무분별한 코드 및 데이터의 반복을 피할 수 있다. JavaScript의 상속은 다른 객체 지향 언어와는 조금 다른 방식으로 작동한다. 상속을 어떻게 하면 구현할 수 있을지 아래 예제 코드를 보면서 생각해보자.

 

 

 함수의 프로토타입은 객체이기 때문에 상속에 영향을 끼치는 기능 복사를 하는 방법에는 여러가지(프로퍼티 혹은 메서드 등)가 있다. 이 예제에서는 우선 Person과 Ninja를 정의하였다. 그리고 Person의 속성(attribute)을 Ninja 프로토타입에 dance라는 프로퍼티를 만들어 그 안에 Person 프로토타입 메서드의 dance 프로퍼티를 복사하여 Ninja에게 상속하려고 시도했다. Ninja가 dance 프로퍼티를 상속받았을 줄 알았겠지만 테스트를 실행해보면 상속을 만드는 데 실패했다고 나온다. 이는 상속이 아니라 단순한 복사인 것이다.

 우리가 진짜로 달성하고자 하는 바는 바로 프로토타입 체인(prototype chain)이다. 이를 통해 Ninja는 Person이 되고 Person은 Mammal이 되고 Mammal은 다시 Animal이 되는 식으로 계속해서 객체가 되도록 하는 것이다. 이러한 프로토타입 체인을 만드는 최고의 기술은 다른 객체의 프로토타입으로서 객체의 인스턴스를 쓰는 것이다.

 

 

 이는 프로토타입 체인을 유지한다. 왜냐하면 SubClass 인스턴스의 프로토타입은 SuperClass의 인스턴스가 될 것이기 때문이다. 이 인스턴스는 SuperClass의 모든 프로퍼티를 가지는 프로토타입이며 자신의 슈퍼클래스(superclass)의 인스턴스를 가리키는 프로토타입을 계속해서 가질 것이다. 다음 예제에서는 listing 7.7을 조금 수정해보겠다.

 

 

 위 코드에서 유일하게 바뀐 부분은 Person의 인스턴스를 Ninja에 대한 프로토타입으로서 사용했다는 점이다. 새로운 ninja 객체를 생성했을 때 애플리케이션의 상태가 어떻게 되는지 다음 그림을 통해 살펴보자.

 

 

 위 그림에서 보듯 Person 함수를 정의할 때 생성자 프로퍼티를 통해 Person 함수를 참조하는 Person 프로토타입도 생성된다. 보통 추가적인 프로퍼티를 통해 Person 프로토타입을 확장 가능하며 위 예제의 경우 dance 메서드에 접근할 수 있는 Person 생성자와 함께 만들어지는 모든 person을 특정한다.

 

 

 그리고 프로토타입 객체를 가지는 Ninja 함수를 정의한다. 이 프로토타입 객체는 Ninja 함수를 참조하는 생성자 프로퍼티를 가진다.(function Ninja () {};)

 다음으로 상속을 구현하기 위해 새로운 Person 인스턴스로 Ninja 함수의 프로토타입을 대체한다. 이제 새로운 Ninja 객체를 만들 때 새롭게 생성된 ninja 객체의 내부 프로토타입 프로퍼티는 현재 Ninja 프로토타입 프로퍼티가 가리키는 이전에 만들어졌던 Person 인스턴스가 될 것이다.

 

 

  ninja 객체를 통해 dance 메서드에 액세스하려 할 때 JavaScript 런타임은 우선적으로 ninja 객체를 확인할 것이다. ninja 객체는 dance 프로퍼티를 갖고 있지 않으므로 해당 객체의 프로토타입인 person 객체가 탐색된다. person 객체 역시 dance 프로퍼티를 갖지 않으므로 해당 객체의 프로토타입이 탐색되어 찾으려던 프로퍼티가 마침내 발견된다. 이 과정이 바로 JavaScript에서 상속이 이루어지는 과정이다.

 이는 중요한 의미를 가진다. 우리가 instanceof 연산자를 활용할 때 함수가 프로토타입 체인 안에서 어느 객체의 기능을 상속하는지 판별할 수 있다.

 이러한 방식으로 프로토타입 상속을 구현했을 때의 이점은 모든 상속된 함수 프로토타입은 실시간으로 갱신된다는 점이다. 프로토타입에서 상속된 객체는 현재 프로토타입 프로퍼티에 대한 접근 권한을 언제나 갖는다.


The problem of overriding the constructor property

 그림 7.14를 보면 새로운 Person 객체를 Ninja 생성자의 프로토타입으로 설정하면 원래의 Ninja 생성자에 의해 유지되던 Ninja 생성자와의 관계를 잃어버린다는 것을 알 수 있다. constructor 프로퍼티를 사용하여 객체를 만든 함수를 결정할 수 있기 때문에 이것은 문제가 된다. 위 예제 코드를 사용하는 사람은 아래의 테스트식이 통과되리라고 생각하기 십상이다.

 

 

 하지만 현재 애플리케이션 상태에서 테스트는 실패한다. 그림 7.14가 보여주듯 constructor 프로퍼티에서 ninja 객체를 검색하면 찾을 수가 없다. 따라서 ninja 객체의 프로토타입으로 넘어가 검색을 시행하고, 역시 검색이 안 되기 때문에 다음 작업으로 넘어가 마침내 Person 함수를 참조하는 constructor 프로퍼티를 가진 Person의 프로토타입 객체를 만나게 된다. 이러한 영향 아래 틀린 답이 나오는 것이다. 만약 함수가 생성한 ninja 객체를 요청하면 Person을 답으로 받는다. 이는 버그의 요인이 될 수 있다.

 본격적으로 이 문제를 해결하기에 앞서 JavaScript에서 어떻게 프로퍼티를 설정하는지부터 살펴보자.

 

Configuring object properties

JavaScript에서 모든 객체 프로퍼티는 프로퍼티 설명자(property descriptor)를 통해 설명된다. 아래 키를 통해 설정 가능하다.

 

  • configurable: true로 설정하면 프로퍼티 설명자를 변경하거나 삭제 가능하다. false로 설정하면 둘 다 불가능하다.
  • enumerable: true로 설정하면 for-in 반복문으로 객체의 프로퍼티를 탐색하는 동안 프로퍼티가 나타난다.
  • value: 기본값은 undefined이다. 프로퍼티의 값을 설정한다.
  • writable: true로 설정하면 할당(assignment)를 통해 프로퍼티 값을 변경 가능하다.
  • get: 프로퍼티에 액세스했을 때 호출되는 getter 함수를 정의한다. value 및 writable과는 함께 정의할 수 없다.
  • set: 프로퍼티에 할당이 만들어질 때마다 호출되는 setter 함수를 정의한다. 역시 value 및 writable과는 함께 정의할 수 없다.

 

 아래 예처럼 간단한 할당을 통해 프로퍼티를 만들 수 있다.

 

 

 위 프로퍼티는 수정 가능하고(configurable), 열거 가능하며(enumerable), 쓰기 가능하다(writable). 값은 'Yoshi'로 설정되며 get 및 set 함수는 undefined이다.

 프로퍼티 설정을 조정하고 싶을 때 내장 메서드인 Object.defineProperty를 쓸 수 있다. 이 내장 메서드는 객체에다 정의될 프로퍼티, 프로퍼티의 이름, 프로퍼티 설명자 객체를 받는다. 아래 예제를 살펴보자.

 

 

 우선 빈 객체를 생성하여 name과 weapon이라는 프로퍼티 두 개를 추가한다. 다음 내장 메서드 Object.defineProperty를 사용하여 프로퍼티 sneaky를 정의한다. 이 프로퍼티는 수정 및 열거가 불가능하고 값은 true로 설정된다. writable이 true이기 때문에 값은 변경 가능하다.

 enumerable을 false로 설정함으로써 for-in 반복문을 사용해도 프로퍼티가 나타나지 않게 된다. 이때까지 살펴본 프로퍼티 수정법을 잘 인지한 채 원래 우리가 해결하려 했던 문제로 돌아가보자.

 

Finally solving the problem of overriding the constructor property

 Ninja로 Person을 확장하려고 할 때(혹은 Ninja를 Person의 서브클래스(subclass)로 만들 때) 다음과 같은 문제에 직면한다. 새로운 Person 객체를 Ninja 생성자에 대한 프로토타입으로 설정할 때 constructor 프로퍼티를 유지하던 원래 Ninja 프로토타입을 잃는다. constructor 프로퍼티는 객체 인스턴스를 생성하는 데 사용되는 함수를 결정할 때 유용하기 때문에 개발 작업을 할 때 이것을 잃고 싶지 않을 것이다.

 이 같은 문제를 해결하는 데 방금 얻은 지식을 활용할 수 있다. Object.defineProperty 메서드를 활용하여 새로운 Ninja.prototype에 새 constructor 프로퍼티를 정의하는 것이다. 다음 예제를 살펴보자.

 

 

 위 예제 코드를 실행해보면 모든 것이 완벽하다. ninja 인스턴스와 Ninja 함수 간의 연결을 재건하여 이들이 Ninja 함수에 의해 만들어졌음을 알 수 있다. 이 외에 Ninja.prototype 객체의 프로퍼티를 반복문으로 조회하려 하면 constructor 프로퍼티는 조회되지 않는다. 이로써 모든 문제는 완벽히 해결되었다.


The instanceof operator

 대부분의 프로그래밍 언어에서 객체가 클래스 지배 구조의 한 부분인지 확인하는 직관적인 방법은 instanceof 연산자를 활용하는 것이다. 예를 들어 Java에서 instanceof 연산자는 왼편의 객체가 오른편과 클래스가 같은지 혹은 클래스 형식의 서브클래스인지 확인함으로써 작동한다.

 허나 JavaScript에서는 약간 다르다. JavaScript의 instanceof 연산자는 객체의 프로토타입 체인에서 작동한다. 

 

 

 예를 들어 위 코드의 경우 instanceof 연산자는 Ninja 함수의 현재 프로토타입이 ninja 인스턴스의 프로토타입 체인 안에 있는지 확인함으로써 작동한다. 보다 구체적인 예를 살펴보자.

 

 

 예상대로 ninja는 Ninja임과 동시에 Person이다. 아래 그림을 통해 어떻게 이 같은 일이 일어나는지 자세히 살펴보자.

 

 

 ninja 인스턴스의 프로토타입 체인은 상속과 Person 프로토타입을 통해 new Person () 객체로 구성된다. 표현식 ninja instanceof Ninja를 평가할 때 JavaScript 엔진은 Ninja 함수의 프로토타입, new Person () 객체를 가져와 ninja 인스턴스의 프로토타입 체인 안에 들어 있는지 확인한다. new Person () 객체는 ninja 인스턴스의 직접적인 프로토타입이기 때문에 결과는 true로 나온다.

 ninja instanceof Person을 확인할 경우 Java Script 엔진은 Person 함수의 프로토타입, Person 프로토타입을 가져와 ninja 인스턴스의 프로토타입 체인 안에 들어 있는지 확인한다. Person 프로토타입은 new Person () 객체의 프로토타입이므로 ninja 인스턴스의 프로토타입이 되는 것이다.

 이로써 instanceof 연산자에 대해 살펴보았다. 이 연산자의 가장 일반적인 용도는 인스턴스가 특정 함수 생성자에 의해 생성되었는지 여부를 결정하는 명확한 방법을 제공하는 것이지만 정확히 그렇게 작동하지는 않는다.

 

The instanceof caveat

 JavaScript는 동적 언어이므로 생성자의 프로퍼티를 바꾸는 데 아무런 지장이 없다. 아래 예제를 살펴보자.

 

 

 위 예제에서 ninja 인스턴스를 생성하는 모든 기초적인 단계를 반복하고 첫 번째 테스트를 돌려보면 무사히 통과한다. 그러나 만일 ninja 인스턴스 생성 후에 Ninja 생성자 함수의 프로토타입을 변경하고 ninja가 Ninja의 인스턴스인지 다시 테스트 하면 상황이 바뀌었음을 볼 수 있다. 이는 instanceof 연산자가 특정 함수 생성자에 의해 만들어진 인스턴스인지 알려주는 역할을 한다고 여렴풋이 짐작하던 우리를 놀라게 한다. instanceof 연산자의 실제 의미는 취한다면 이 연산자는 오른쪽에 있는 함수의 프로토타입이 왼쪽에 있는 객체의 프로토타입 체인 안에 속하는지 확인하는 것이다. 아래 그림에 앞선 내용이 나와 있다.

 

 

 이로써 JavaScript에서 프로토타입이 어떻게 작동하는지, 그리고 상속을 구현하기 위해 생성자 함수로 프로토타입 병합을 어떻게 하는지 살펴보았다. 다음 장에서는 ES6 버전 JavaScript에 새롭게 추가된 개념인 클래스(classes)에 대해 아랑보겠다.


​Using JavaScript "classes" in ES6

 JavaScript가 프로토타입을 통해 상속 형식을 쓸 수 있게 하는 점은 훌륭하다. 그러나 전통적인 객체 지향 언어에 대한 배경지식을 가진 개발자 대다수는 자신들이 익숙한 형태로 JavaScript의 상속 시스템을 단순화하거나 추상화하는 것을 선호한다. 

 이는 결국 JavaScript가 원래부터는 제공하지 않았던 클래스의 영역으로 우리를 이끈다. 이러한 요구에 맞춰 전통적인 상속을 따라한 JavaScript의 여러 라이브러리(libraries)도 등장했으나 각 라이브러리마다 작성 방식이 제가끔이어서 ECMAScript 측에서 클래스 기반 상속을 따라한 문법을 표준화하였다. 여기서 "따라했다"는 말에 주목하자. 현재 JavaScript에서 class 키워드를 사용할 수 있으나 근본적인 개념은 프로토타입 상성에 기반을 두고 있다.


Using the class keyword

 ES6는 프로토타입을 사용했을 때보다 객체 생성 및 상속을 보다 유려하게 할 수 있게 해주는 새로운 class 키워드를 도입했다. class 키워드를 사용하기는 의외로 간단하다. 아래 예제 코드를 살펴보자.

 

 

 위 예제 코드는 class 키워드를 활용하여 Ninja 클래스를 생성할 수 있음을 보여준다. ES6의 클래스를 생성할 때 Ninja 인스턴스가 인스턴스화될 때 호출되는 생성자 함수를 명시적으로 정의할 수 있다. 생성자의 본문에서는 새롭게 생성된 인스턴스에 this 키워드로 접근할 수 있으며, name과 같은 새로운 프로퍼티를 간단히 추가할 수 있다. 클래스의 본문 내에서는 모든 Ninja 인스턴스에서 접근 가능한 메소드를 정의할 수 있다. 위 예제에서는 true를 반환하는 swingSword 메소드를 정의하였다.

 

 

 다음으로 Ninja가 단순한 생성자 함수인 경우와 마찬가지로 키워드 new로써 Ninja 클래스를 호출하여 Ninja 인스턴스를 만들 수 있다. 

 

 

 마지막으로 ninja 인스턴스가 예상대로 잘 작동하는지 테스트한다.

 

 

Classes are syntatic sugar

 앞서 언급했듯 ES6가 class 키워드를 도입하긴 했어도 내부적으로는 여전히 좋은 프로토타입을 다루고 있다. 클래스는 JavaScript에서 클래스를 모방할 때 코드 작성을 편리하게 해주는 문구이다.

 앞선 예제 코드 Listing 7.13은 다음과 같이 ES5 형식으로 바꿀 수 있다.

 

 

 보면 알 수 있듯이 ES6 클래스라고 해서 별반 특별한 점이 없다. 기본 개념은 똑같으나 코드 작성을 더 간결히 할 수있다는 점만 다를 뿐이다.

 

Static methods

 앞선 예제를 통해 모든 객체 인스턴스에서 접근 가능한 객체 메소드(프로토타입 메소드)를 어떻게 정의하는지 살펴보았다. 이러한 메서드 외에도 Java와 같은 고전적인 객체 지향 언어는 클래스 수준에서 정의된 메서드인 정적(static) 메서드를 사용한다. 아래 예제를 확인해보자.

 

 

 우선 모든 ninja 인스턴스에서 접근 가능한 swingSword 메서드를 가지는 Ninja 클래스부터 생성한다. 또한 static 키워드를 메서드 이름 앞에 붙임으로써 정적 메서드인 compare도 정의한다. 여기서 compare 메서드는 인스턴스 수준이 아닌 클래스 수준에 정의된다. 따라서 위 예제의 경우 compare 메서드는 ninja 인스턴스가 아니라 Ninja 클래스에서 접근 가능한 것이다.

 그렇다면 ES6 이전 코드에서 "정적" 메서드가 어떻게 실행되었을까. 이를 이해하려면 단 한가지만 기억하면 된다. 바로 클래스는 함수를 통해서 실행된다는 점이다. 정적 메서드는 클래스 수준 메서드이기 때문에 함수를 일급 객체로 활용하고 생성자 함수에 메서드 프로퍼티를 추가하여 구현할 수있다. 아래 예를 보자.

 

 

 이제 다시 상속을 살펴보자.


Implementing inheritance

 ES6 이전 버전에서는 상속을 구현하기 매우 까다롭다. 다음 예를 다시 한 번 더 살펴보자.

 

 

 위 예제에서 염두에 두어야 할 점이 많다. 우선 모든 인스턴스에서 접근 가능한 메서드는 dance 메서드나 Person 생성자를 보면 알 수 있듯 반드시 생성자 함수의 프로토타입에 직접 추가되어야 한다. 상속을 구현하고 싶다면 파생된 클래스의 프로토타입을 기본 클래스의 인스턴스로 설정해야 한다. 위 예제에서는 Person의 새로운 인스턴스를 Ninja.prototype에 할당하였다. 그러나 이는 constructor 프로퍼티를 꼬이게 만들어서 Object.definceProperty로 일일이 수정해줘야 한다. 상속을 보다 간단하고 널리 쓰이는 방식으로 구현하려면 알아둬야 할 사항들이다. ES6에서는 이러한 작업이 대폭 단순화되었다. 예제를 살펴보자.

 

 

 위 예제는 ES6에서 어떻게 상속을 구현하는지 보여준다. 이때 다른 클래스에서 상속 받기 위해 extends 키워드를 쓴다. 예제에서는 각 Person 인스턴스에 name을 할당하는 생성자를 활용하여 Person 클래스를 만든다. 또한 모든 Person 인스턴스에서 접근 가능한 dance 메서드도 정의한다. 다음으로는 Person 클래스를확장하는 Ninja 클래스를 정의한다. Ninja 클래스는 추가적으로 weapon 프로퍼티와 wieldWeapon 메서드를 가진다.

 파생된 Ninja 클래스의 생성자에는 super 키워드를 통해 기본 생성자인 Person을 호출한다. 객체 지향 언어를 다루어봤다면 이러한 코드 작성이 익숙할 것이다.

 계속해서 person 인스턴스를 생성하고 이 인스턴스가 name과 dance를 가지는 Person 클래스의 인스턴스인지 확인을 한다. 또한 person이 Ninja인지, person에 wieldWeapon 메서드가 포함되어 있는지도 확인한다.

 이어서 ninja 인스턴스를 만들고 이 인스턴스가 Ninja의 인스턴스인지 또 wieldWeapon 메서드를 가지는지 확인한다. 모든 ninja는 Person이기도 하기에 ninja가 name을 가지는 Person의 인스턴스인지 확인한다. 또한 dance 메서드도 갖고 있는지 확인한다.

 이러한 방식으로 상속을 구현하면 프로토타입에 대해 생각할 필요도 없고 프로퍼티가 덮어 써지는 건 아닌지 걱정하지 않아도 된다. 클래스를 정의하고 extends 키워드로 그들의 관계를 정해주기만 하면 된다. 이처럼 ES6 환경 하에서는 Java 및 C#의 객체 지향 코드를 따라서 작성하는 것이 가능하다.


요점 정리

  • JavaScript 객체는 값을 가진 프로퍼티들의 단순한 집합이다.
  • JavaScript에서는 프로토타입을 사용한다.
  • 모든 객체는 객체 내에 찾고자하는 프로퍼티가 없다면 특정 프로퍼티에 대한 탐색을 위임한 객체인 프로토타입에 대한 참조를 갖는다. 객체의 프로토타입은 자신만의 고유한 프로토타입을 가져 프로토타입 체인을 형성한다.
  • Object.setPrototypeOf 메서드를 활용하여 객체의 프로토타입을 정의할 수 있다.
  • 프로토타입은 생성자 함수와 밀접한 관련이 있다. 모든 함수는 해당 함수가 인스턴스화될 때 객체의 프로토타입으로 설정되는 prototype 프로퍼티를 갖는다.
  • 함수의 prototype 객체는 함수 자신을 가리키는 constructor 프로퍼티를 갖는다. 이 프로퍼티는 해당 함수로써 인스턴스화되는 모든 객체에서 접근 가능할 뿐만 아니라 일정한 제약 아래 객체가 특정 함수에 의해 생성되었는지 확인하는 데 쓰인다.
  • JavaScript에서 객체 및 함수의 프로토타입을 포함하여 거의 모든 것은 변경 가능하다.
  • Ninja 생성자 함수에 의해 생성된 인스턴스가 Person 생성자 함수에 의해 생성된 인스턴스에 액세스할 수 있는 속성을 "상속"(더 정확히는 액세스 권한을 부여)하려는 경우 Ninja 생성자의 프로토타입을 Person 클래스의 새로운 인스턴스로 설정한다. 
  • JavaScript에서 프로퍼티는 configurable, enumerable, writable과 같은 속성을 갖는다. 이러한 프로퍼티들은 Object.define.Property 내장 메서드를 사용하여 정의된다.
  • JavaScript ES6에서는 class 키워드를 사용하여 다른 객체 지향 언어의 클래스를 흉내낼 수 있다. 하지만 여전히 프로토타입 역시 잘 작동한다.
  • extends 키워드는 상속을 구현하는 데 매우 유용하다.
728x90
반응형

댓글