이번 글은 'Secrets of the JavaScript Ninja'의 'Chapter 7. Object orientation with prototypes'를 바탕으로 작성하였습니다.
핵심 Concepts
- 프로토타입에 대한 탐구
- 함수를 생성자로서 사용하기
- 프로토타입을 사용하여 객체 확장하기
- 일반적인 문제 피하기
- 상속을 활용한 클래스 작성
사전 지식 체크!
- 객체가 특정 프로퍼티에 접근할 수 있는지 어떻게 시험하는가?
- JavaScript에서 객체를 활용하여 작업할 때 프로토타입 체인(prototype chain)이 중요한 이유
- ES6 클래스는 JavaScript가 객체와 작동하는 방식을 변경하는가?
들어가며
프로토타입(prototype)은 특정 속성에 대한 검색을 위임할 수 있는 객체이다. 프로토타입은 프로퍼티를 정의하는 데 편리한 수단이며 다른 객체에 자동적으로 접근할 수 있는 기능을 가졌다. 프로토타입은 전통적인 객체 지향 언어의 클래스(classes)와 비슷한 목적을 가진다. JavaScript에서 프로토타입의 주된 쓰임새는 Java나 C#과 같은 클래스 기반 언어와 비슷한 방식으로 객체 지향 코드를 작성하는 것이다.
이번 장에서는 프로토타입의 작동원리와 생성자 함수와의 관계에 대해서 알아보겠다. 그리고 다른 객체 지향 언어에서 자주 쓰이는 일부 객체 지향 기능을 모방하는 방법도 알아볼 것이다. 또한 새롭게 추가된 class 키워드도 살펴보겠다. 이 class 키워드로써 전통적인 클래스와 상속(inheritance)를 따라할 수 있다.
Understanding prototypes
JavaScript에서 객체는 값을 가진 프로퍼티의 모음이다. 예를 들어 객체 리터럴 표기법(object-literal notation)을 사용하여 새로운 객체를 쉽게 생성할 수 있다.
예제 코드를 보면 객체 프로퍼티는 숫자, 문자열, 함수, 다른 객체와 같은 간단한 값들이다. 더불어 JavaScript는 매우 동적인(dynamic) 언어이므로 프로퍼티를 쉽게 할당할 수 있으며 수정 및 삭제도 아래 예제와 같이 자유자재로 가능하다.
개발 작업을 할 때 되도록이면 반복 작업을 피하기 위해 기존에 작성한 코드를 가능한 한 재사용한다. 이와 같은 재사용을 원할히 해주는 것이 바로 상속(inheritance)이다. 상속은 특정 객체의 특징을 다른 객체로 확장하는 기능을 뜻한다. JavaScript에서 상속은 프로토타이핑과 함께 실행된다.
프로토타이핑의 개념은 간단하다. 모든 객체는 자신의 프로토타입에 대한 참조를 가지며, 객체가 특정 프로퍼티를 갖고 있지 않을 경우 해당 프로퍼티에 대한 검색을 다른 객체에 위임할 수 있다. 이는 마치 퀴즈쇼에서 사회자가 나에게 질문을 했는데 답을 모를 경우 바로 옆사람에게 물어보는 경우와 같다.
위 내용에 대한 예제를 살펴보자.
위 예제에서 우선 'yoshi', 'hattori', 'kuma'라는 객체 3가지를 생성하고 시작한다. 각 객체에는 해당 객체에만 접근할 수 있는 하나의 특정 프로퍼티가 있다. yoshi는 skulk, hattori는 sneak, kuma는 creep이다.
객체가 특정 프로퍼티에 접근 가능한지 알아보기 위해 in 연산자를 사용할 수 있다. 예를 들어 skulk in yoshi는 true를 반환하고 sneak in yoshi는 false를 반환한다.
JavaScript에서 객체의 프로토타입 프로퍼티는 직접 접근할 수 없는 내재된 프로퍼티이다.([[prototype]]으로 적는다.) 대신 내장 메소드 Object.setPrototypeOf는 두 개의 객체 인수를 취하고 두 번째 객체를 첫 번째 객체의 프로토타입으로 설정한다. 예를 들어 Object.setPrototypeOf(yoshi, hattori)라고 작성하면 hattori를 yoshi의 프로토타입으로 설정한다는 뜻이다.
결과적으로 yoshi 객체가 갖고 있지 않은 프로퍼티를 부를 때마다 해당 프로퍼티에 대한 검색을 hattori에게 위임하는 것이다. 또한 hattori 객체의 sneak 프로퍼티를 yoshi를 통해 접근할 수 있다.
비슷한 일이 hattor 객체와 kuma 사이에도 일어난다. Object.setPrototypeOf 메소드를 사용하여 kuma를 hattori의 프로토타입으로 설정한다. 만약 hattori가 갖고 있지 않은 프로퍼티를 호출할 경우 검색은 kuma에게 위임된다. 이 경우 hattori는 kuma의 creep 프로퍼티에 접근할 수 있는 것이다.
이제까지 프로토타입이 기본적으로 어떻게 작동하는지 알아보았다. 다음으로 생성자 함수를 활용하여 새로운 객체를 생성할 때 프로토타입이 어떻게 작동하는지 알아보겠다.
Object construction and prototypes
새로운 객체를 생성하는 가장 간단한 방법은 아래와 같다.
위 코드는 새로운 빈 객체를 생성하고 할당 문구(assignment statements)를 통해 프로퍼티를 입력한다.
그러나 객체 지향 언어에 대한 배경 지식을 가진 사람들의 경우 초기 상태로 초기화하는 역할을 하는 함수인 클래스 생성자와 함께 제공되는 캡슐화 및 구조화를 놓칠 수 있다. 이후 객체에 형식이 같은 인스턴스(instances)를 생성하여 각자 프로퍼티를 할당하는 작업은 지겨울 뿐만 아니라 에러가 일어나기 쉽다. 따라서 객체의 클래스에 대한 프로퍼티와 메소드의 집합을 한 곳에 모을 필요가 생긴다.
JavaScript 역시 다른 언어들처럼 이와 같은 기능을 제공하지만 약간 다르다. Java와 C++ 같은 객체 지향 언어처럼 JavaScript는 생성자를 통해 새로운 객체를 생성할 때 new 연산자를 쓴다. 하지만 JavaScript에서 진정한 의미의 클래스 정의는 존재하지 않는다. 대신 생성자 함수에 적용된 new 연산자는 새로 할당된 객체를 생성한다.
앞선 글들에서 우리가 배우지 않은 것은 모든 함수는 해당 함수와 함께 생성되는 객체의 프로토타입을 자동적으로 프로토타입 객체로 가진다는 사실이다.
위 예제 코드에서 Ninja라는 아무 기능도 하지 않는 함수를 호출하는 방법에는 2가지가 있다. 하나는 const ninja1 = Ninja (); 라고 작성하여 일반적인 함수로 호출하는 방법이고 또 하나는 const ninja2 = new Ninja (); 라고 작성하여 생성자로 호출하는 방법이다.
함수가 만들어질 때 함수는 곧바로 해당 함수의 프로토타입 객체로 할당된 새로운 객체를 얻는다. 이 객체는 다른 객체와 마찬가지로 자유롭게 확장 가능하다. 위 예제의 경우 swingSword 메소드를 추가하였다.
그런 다음 함수를 정의한다. 첫 번째로 함수를 일반적인 방법으로 호출하여 변수 ninja1에 저장한다. 함수 본문을 보면 알다시피 아무런 값도 반환하지 않아 ninja1의 값은 undefined가 된다.
그리고 new 연산자를 통해 함수를 생성자로서 호출하는데 여기서 전혀 다른 일이 일어난다. 함수가 다시 한 번 호출되는데 이때는 새롭게 할당된 객체가 생성되어 함수의 컨텍스트로 설정된다.(this 키워드로 접근 가능하다.) new 연산자로부터 반환된 결과는 이 새로운 객체에 대한 참조이다. 그러고 나서 ninja2가 가진 새로 생성된 객체에 대한 참조가 있고 해당 객체에 호출할 수 있는 swingSword 메소드가 있는지 테스트한다.
아래 예제를 통해 여기까지 실행된 상태를 볼 수 있다.
그림에서 보다시피 함수는 생성될 때 해당 함수의 프로토타입 프로퍼티로 할당되는 새로운 객체를 갖는다. 이 프로토타입 객체는 처음엔 해당 함수를 다시 참조하는 constructor라는 프로퍼티 단 하나만을 갖는다.
함수를 생성자로 사용할 때(예를 들어 new Ninja ()를 호출하는 경우처럼) 새롭게 생성된 객체의 프로토타입은 생성자 함수의 프로퍼티에 의해 참조되는 객체로 설정된다.
위 예제에서 swingSword 메소드로 Ninja.prototype을 확장했고 ninja2 객체가 만들어질 때 ninja2 객체의 프로토타입 프로퍼티는 Ninja의 프로토타입으로 설정된다. 그러므로 ninja2의 swingSword 프로퍼티에 접근하려고 할 때 해당 프로퍼티에 대한 검색 작업은 Ninja 프로토타입 객체에게 위임된다. Ninja 생성자와 함께 생성되는 모든 객체들은 swingSword 메소드에 대한 접근 권한을 가진다. 따라서 재사용이 가능한 것이다.
swingSword 메소드는 Ninja 프로토타입의 프로퍼티이지 ninja 인스턴스의 프로퍼티가 아니다. 다음으로 인스턴스 프로퍼티와 프로토타입 프로퍼티의 차이점에 대해 알아보자.
Instance properties
new 연산자를 통해 함수가 생성자로서 호출될 때 해당 함수의 컨텍스트는 새로운 객체 인스턴스로서 정의된다. 프로토타입을 통해 프로퍼티를 노출하는 것 외에도 this 매개변수를 통해 생성자 함수 내에서 값을 초기화할 수 있다. 다음 예제를 통해 인스턴스 프로퍼티의 생성에 대해 알아보자.
위 예제 코드는 앞서 살펴보았던 예제 코드와 비슷하다. 생성자의 프로토타입 프로퍼티에다 swingSword 메소드를 추가함으로써 해당 메소드를 정의한다.
허나 생성자 함수 안에 똑같은 이름의 메소드를 하나 더 추가한다.
이렇게 정의된 두 메소드는 전혀 다른 값을 반환하는데 과연 어떤 값이 최종적으로 반환될까? 테스트를 수행하면 통과되는 것을 볼 수 있다. 이는 인스턴스 멤버가 프로토타입에 정의된 동일한 이름의 프로퍼티를 숨긴다는 사실을 보여준다.
생성자 함수 내에서 this 키워드는 새롭게 생성된 객체를 참조하므로 생성자 내에 추가된 프로퍼티는 새로운 ninja 인스턴스에 직접 생성된다. 이후 ninja의 프로퍼티 swingSword에 접근할 때 전체 프로토타입 체인을 훑을 필요가 없다. 생성자 내에 생성된 프로퍼티는 곧바로 발견되어 반환된다.
이러한 작업 과정은 흥미로운 부수 효과를 가진다. 우선 ninja 인스턴스를 만들었을 때 애플리케이션의 상태가 어떻게 되는지 아래 그림을 통해 알아보자.
그림을 보면 알 수 있듯 모든 ninja 인스턴스는 각자 생성자 내에서 만들어진 고유한 프로퍼티를 가져오지만 모두 동일한 프로토타입 프로퍼티에 접근할 수 있다. 이는 각 객체 인스턴스에 특정한 값 속성(swung)에 대해 문제가 없다. 하지만 몇몇 경우 메소드에 대해서는 문제를 일으킬 소지가 있다.
위 예제에는 모두 같은 기능을 하는 swingSword 메소드가 3가지 버전이 있다. 이는 객체를 몇 개만 만들 때는 문제되지 않으나 그 수가 많아질 때는 문제가 된다. 각 메소드 복사는 동일하게 작동해서 여러 복사본을 만드는 것은 더 많은 메모리만 소모하므로 의미가 없는 경우가 많다. 물론 JavaScript 엔진 자체적으로 최적화 기능을 제공하나 이것만을 믿을 수는 없다. 이 같은 관점에서 보면 함수의 프로토타입에만 객체 메소드를 배치하는 것이 합리적이다. 그렇게 하면 모든 객체 인스턴스가 공유하는 단일 메소드를 가질 수 있기 때문이다.
Side effects of the dynamic nature of JavaScript
JavaScript는 프로퍼티를 쉽게 추가하고 제거하고 수정할 수 있는 동적 언어이다. 함수 프로토타입과 객체 프로토타입 역시 같은 특징을 갖는다. 예제를 살펴보자.
위 예제에서도 역시 Ninja 생성자를 정의하여 객체 인스턴스를 만드는 데 사용하였다. 인스턴스가 만들어지고 난 후 프로토타입에 swingSword 메소드를 추가한다. 그런 다음 테스트를 실행하여 객체가 생성된 후 프로토타입에 대한 변경 사항이 적용되는지 확인한다.
이후 pierce 메소드를 가진 완전히 새로운 객체를 Ninja 함수의 프로퍼티에 할당함으로써 해당 프로퍼티를 덮어 쓴다. 이 같은 결과가 위 그림에 나와 있다. 예제에서 보듯 Ninja 함수는 이전 Ninja 프로토타입을 참조하지는 않으나 이전 프로토타입은 ninja1 인스턴스에 의해 여전히 유효해서 프로토타입 체인을 통해 swingSword 메소드에 접근 가능하다. 그러나 이렇게 프로토타입이 변한 후 새로운 객체를 만들면 애플리케이션의 상태는 아래와 같다.
객체와 함수의 프로토타입 간 참조는 객체가 인스턴스화될 때 설정된다. 새롭게 만들어진 객체는 새 프로토타입에 대한 참조를 가질 것이며 pierce 메소드에 대한 접근 역시 가진다. 반면 변경되기 이전의 프로토타입 객체는 원래 프로토타입을 유지해서 swingSword 메소드에 접근 가능하다.
Object typing via constructors
JavaScript에서 프로토타입은 정확한 프로퍼티의 참조를 찾는 데 쓰인다. 이는 또한 어느 함수 객체 인스턴스를 생성하는지 아는 데도 유용하다. 앞서 봤듯 객체의 생성자는 생성자 함수 프로퍼티의 생성자 프로퍼티를 통해 사용할 수 있다.
예를 들어 위 그림은 Ninja 생성자로 객체를 인스턴스화할 때 애플리케이션의 상태를 나타낸다. 생성자 프로퍼티를 사용함으로써 객체를 만드는 데 쓰인 함수에 접근 가능하다. 이러한 정보는 형식을 확인하는 데도 쓰일 수 있다. 아래 예제를 살펴보자.
우선 생성자를 정의하고 이 생성자를 활용하는 객체 인스턴스를 만든다. 그리고 typeof 연산자로 인스턴스의 형식을 검사한다. 모든 인스턴스는 객체이며 반환값 역시 object로 나오기 때문에 얻을 수 있는 정보는 아직 불충분하다. 더 주목할 만한 것은 instanceof 연산자로 특정 함수 생성자에 의해 생성된 인스턴스인지 판별하는 연산자이다.
또한 모든 인스턴스에 접근할 수 있는 생성자 프로퍼티를 해당 프로퍼티를 생성한 원래 함수에 대한 참조로 사용할 수 있다. 이러한 기능을 인스턴스의 출처를 확인하는 데 쓸 수 있다.(instanceof 연산자와 마찬가지로)
이것은 단지 원래 생성자에 대한 참조이기 때문에 이 인스턴스를 활용하는 새로운 Ninja 객체를 인스턴스화할 수 있다.
위 예제 코드에서 우선 생성자를 정의하고 해당 생성자를 사용하는 인스턴스를 생성한다. 이후 생성된 인스턴스의 생성자 프로퍼티를 사용하여 두 번째 인스턴스를 생성한다. 두 번째 assert문은 이렇게 생성된 변수 ninja2와 ninja가 똑같지 않음을 나타낸다.
여기서 흥미로운 점은 이러한 작업을 원래 함수에 대한 접근 없이 할 수 있다는 점이다. 원래 생성자가 스코프 안에 존재하지 않아도 참조를 활용할 수 있는 것이다.
함께 보기
- 프로토타입
https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Objects/Object_prototypes
Object prototypes - Learn web development | MDN
This article has covered JavaScript object prototypes, including how prototype object chains allow objects to inherit features from one another, the prototype property and how it can be used to add methods to constructors, and other related topics.
developer.mozilla.org
- typeof
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/typeof
typeof - JavaScript | MDN
The typeof operator returns a string indicating the type of the unevaluated operand.
developer.mozilla.org
- instanceof
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/instanceof
instanceof - JavaScript | MDN
The instanceof operator tests to see if the prototype property of a constructor appears anywhere in the prototype chain of an object. The return value is a boolean value.
developer.mozilla.org
- constructor 프로퍼티
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/constructor
Object.prototype.constructor - JavaScript | MDN
The constructor property returns a reference to the Object constructor function that created the instance object. Note that the value of this property is a reference to the function itself, not a string containing the function's name.
developer.mozilla.org
댓글