JavaScript

프로토타입 톺아보기

GoJay 2024. 11. 13. 14:05

객체 지향과 프로토타입

객체 지향 프로그래밍(OOP; Objection Oriented Programming)은 프로그램을 속성(프로퍼티)과 동작(메서드)으로 이루어진 '객체'를 통해 구성하려는 패러다임이다. 하나의 서비스를 객체 간의 관계로 구조화하는 것을 목표로 하며, 아래의 네 가지 원칙을 통해 이러한 컨셉을 구현한다.

  • 추상화: 불필요한 세부사항을 숨기고 중요한 특징만을 드러내어 복잡성을 줄인다.
  • 캡슐화: 객체의 내부 상태를 숨기고, 외부에서는 정해진 방법(메서드)을 통해서만 접근할 수 있게 한다.
  • 상속: 기존 클래스(프로토타입)의 속성과 메서드를 다른 클래스(프로토타입)에서 물려받아 재사용할 수 있게 한다.
  • 다형성: 같은 이름의 메서드가 객체에 따라 다르게 동작할 수 있게 하여 유연성을 높인다.

객체 지향을 지원하는 언어에는 크게 두 종류가 있다. 바로 클래스 기반 객체 지향 언어(e.g. C++, Java 등)와 프로토타입 기반 객체 지향 언어(e.g. JavaScript)가 그것이다.

클래스 기반 객체 지향 언어는 객체 생성을 위한 템플릿으로 '클래스'를 사용한다. 그리고, 클래스를 통해 생성된 객체를 '인스턴스'라고 한다. (대부분의) 클래스 기반 객체 지향 언어에서는 생성된 클래스를 런타임 중에 수정할 수 없다. 즉, 한번 객체에 포함될 프로퍼티와 메서드가 정해지면 해당 클래스는 변경이 어렵고, 대신 클래스를 상속받은 인스턴스에서 필요한 프로퍼티-메서드를 추가하거나 또는 제거하는 식으로 사용할 수 있다. 한 번 정의된 클래스를 런타임 중 수정하기 어렵다는 점 때문에 안정성은 높지만 유연성이 떨어지는 방식이다.

반면, 프로토타입 기반 객체 지향 언어는 객체 생성을 위한 템플릿으로 '프로토타입'을 사용한다. 그리고, 프로토타입을 통해 생성된 객체를 마찬가지로 '인스턴스'르고 부른다. 프로토타입 기반 객체 지향 언어에서는 한번 정의된 프로토타입을 수정하는 것이 가능하다. 경우에 따라선 같은 프로토타입을 갖는 모든 인스턴스가 사용할 수 있게 프로퍼티 또는 메서드를 프로토타입에 주입시키기도 한다. 이러한 특징은 유연성이 높다는 장점이 있지만, 프로그램의 안정성은 떨어질 수 있다는 단점도 있다.

자바스크립트는 '프로토타입 객체 지향'을 추구한다. 자바스크립트에서 프로토타입이란 무엇이며, 어떤 식으로 활용되는지 한번 살펴보자.

프로토타입 톺아보기

자바스크립트의 모든 데이터는 프로토타입을 갖는다. 예를 들어, const myObj = {}라고 리터럴을 통해 객체를 생성한 다음 console.dir을 통해 속성들을 확인해보면 [[prototype]]: Object라는 프로퍼티가 확인된다. 즉, 객체의 프로토타입은 Object다.

내부를 살펴보면 hasOwnProperty, toString 등 여러 메서드가 있는 것이 확인된다. Object라는 프로토타입에 정의된 메서드들은 해당 프로토타입을 바라보고 있는 인스턴스들에서 자유롭게 사용이 가능하다.

const myObj = { name: 'Jay' };

myObj.hasOwnProperty('name') // true
myObj.toString() // '[object Object]'

이렇게, 특정 값은 그 값의 원형인 프로토타입을 갖는다. 이는 내부 슬롯 [[Prototype]]에 정의된다. 그리고, 해당 프로토타입에 있는 프로퍼티와 메서드를 상속받아 사용 가능하다.

자바스크립트에서는 기본형으로 정의된 데이터의 프로토타입들이 존재한다. 예를 들어, 배열은 Array, 함수는 Function, 문자열은 String, 숫자형은 Number라는 프로토타입을 갖는다. 이외에도 다양한 프로토타입이 기본 제공되며, 어떠한 값은 반드시 그 값에 대응되는 프로토타입과 연결된다.

기본 제공되는 프로토타입뿐만 아니라, 생성자 함수를 이용해 사용자가 직접 프로토타입을 정의할 수도 있다. 아래 예시를 보면, Person이라는 생성자 함수로 인스턴스 person1person2를 만들어주고 있다.

function Person({name, age}) {
  this.name = name;
  this.age = age;

  this.introduce = () => {
    console.log(`My name is ${this.name}`)
  }
}

const person1 = new Person({ name: 'Jay', age: 20 });
const person2 = new Person({ name: 'Ko', age: 25 });

console.log(person1, person2) // Person {name: 'Jay', age: 20}  Person {name: 'Ko', age: 25}

생성한 person1person2Person 생성자 함수에 정의된 값과 메서드를 참조할 수 있다. 예를 들어서, person1 객체에서 introduce()라는 메서드를 실행하면 My name is Jay 라는 결과가 콘솔로 확인된다.

person1.introduce(); // My name is Jay

__proto__는 특정 값의 프로토타입에 접근할 수 있는 접근자 프로퍼티이다(gettersetter 모두 설정되어 있다). 해당 접근자 프로퍼티로 Person 생성자 함수의 Prototype과 값을 비교해 보면 둘은 같은 값을 참조하고 있다는 것을 알 수 있다

person1.__proto__ === Person.prototype // true

이를 이미지로 시각화해보면 아래와 같다.

이미지에 표현됐듯이, 프로토타입은 생성자 함수와 밀접한 관련이 있다. 이에 대해 조금 더 자세히 살펴보자.

프로토타입과 생성자 함수

프로토타입은 생성자 함수가 정의되는 시점에 만들어진다. 그리고, 프로토타입과 생성자 함수는 반드시 쌍으로 존재한다. 위의 예시에서도, Person이라는 생성자 함수가 정의되는 시점에 그에 대응되는 프로토타입인 Person.prototype이 만들어진 것이다(Person은 함수이고, Person.prototype은 객체다. Person' 함수는 'Person.prototype이라는 객체를 가리키고, Person.prototype 객체는 Person 생성자 함수를 가리킨다. 생성자 함수와 프로토타입은 떼놓을 수 없는 쌍이다.

그런 의미에서, 프로토타입은 생성자인 함수에서만 생성된다. 만약에 caller이지만 constructor는 아닌 함수라면 이는 프로토타입을 생성하지 않는다. 바꿔서 얘기해 보면, 인스턴스를 만들어내는 함수는 해당 인스턴스와 연결된 프로토타입을 만들어낸다고 할 수 있다.

Person 생성자 함수로 만들어진 인스턴스 객체는 생성자 함수 Person과 쌍으로 존재하는 프로토타입인 Person.prototype을 참조한다. 그렇기 때문에, Person.prototype 객체에 프로퍼티와 메서드를 추가해주면 해당 프로토타입을 상속받는 person1person2는 추가된 값에 접근이 가능하다.

Person.prototype.isAlive = true;
Person.prototype.greeting = () => { console.log('Hi!') }

console.log(person1.isAlive); // true
person2.greeting(); // Hi!

Person 생성자 함수에 isAlivegreeting이 추가된 것은 아니다. 하지만, Person 생성자 함수로 만든 인스턴스가 Person.prototype을 참조하기 때문에, Person으로 만들어진 모든 인스턴스는 isAlive 프로퍼티와 greeting 메서드를 사용할 수 있다. 심지어, 인스턴스의 프로토타입 접근자 프로퍼티 __proto__를 통해 Person.prototype의 속성을 변경하는 것도 가능하며, 이는 같은 프로토타입을 바라보는 모든 인스턴스에게 영향을 미친다.

person1.__proto__.isAlive = false;
console.log(person2.isAlive) // false;

이처럼, 프로토타입에 접근이 쉽다는 점 때문에 자바스크립트는 객체 지향에 있어 엄청난 유연성을 갖는다. 하지만, 바꿔서 얘기하면 인스턴스에 영향을 주는 프로토타입이 어디서 어떻게 변할지 모른다는 의미이기도 하기 때문에 안정성이 크게 떨어진다는 단점도 있다. 그래서, 가급적 프로토타입에 직접 영향을 주는 변화는 지양하는 것이 바람직하다.

프로토타입 체인과 상속

프로토타입은 다른 프로토타입과 연결될 수 있다. 이를 '프로토타입 체이닝(Prototype Chaining)'이라고 하며, 프로토타입 체인을 통해 유연하게 상속을 구현할 수 있다.

자바스크립트의 거의 모든 것은 객체라고 할 수 있다. 그 이유는, 자바스크립트의 모든 것의 프로토타입 체인 최상단은 Object이기 때문이다. 자바스크립트의 모든 것은 Object.prototype을 상속하며, 심지어 원시 타입인 문자열, 숫자, 불리언 등도 String, Number, Boolean이라는 생성자 함수가 있기 때문에 대응되는 프로토타입이 존재하고, 그 프로토타입들은 Object.prototype에 체이닝 되어있다.

함수는 Function 생성자 함수로 만들 수 있으며, Function.prototype을 상속받는다. 그리고 Function.prototype의 프로토타입 접근자 프로퍼티 __proto__Object.prototype과 체이닝 되어있다. 그리고, constructorFunction은 인스턴스를 만들 수 있고, 그 인스턴스의 __proto__는 생성자 함수의 prototype과 연결되며, 이러한 구조를 그림으로 표현해 보면 아래와 같다.

프로토타입은 단방향 링크드 리스트 형태로 구현되어 있다. 따라서, 하위에 위치한 프로토타입은 상위 프로토타입의 프로퍼티와 메서드를 가져와 사용할 수 있다. 실행 컨텍스트-렉시컬 환경에서의 스코프 체이닝과 비슷한 컨셉이며, 마찬가지로 어떠한 값에서 특정 프로퍼티나 메서드를 호출했을 때 해당 값의 프로토타입에 없다면 프로토타입 체인을 타고 올라가 탐색을 수행한다. 이렇게, 위계가 있는 프로토타입 체인을 통해 객체 지향의 상속을 굉장히 유연하고 강력한 방식으로 구현해 낸다.

결론

프로토타입에 대해 책에서 읽은 내용을 이해한 대로 정리해봤다. 명확히 아는 것 같다가도, 구체적으로 설명하려니 모호한 지점이 많은 개념인 것 같다. 그래서 아직도 '안다'라는 표현을 쓰는게 참 조심스럽다. 프로토타입에 대해서도 좀 더 정확하게, 깊게 공부해볼 기회를 만들어봐야겠다.