#1 프로토타입

Jake Seo·2021년 9월 29일
4

프로토타입이 해결하려는 문제

객체지향을 배우다보면, 항상 나오는 핵심 개념으로 '상속'이라는 개념이 있다. '상속'이란 말 그대로 부모가 가진 특성을 자식이 그대로 이어받는 것을 말한다. 객체에는 크게 멤버와 메소드가 존재하는데, 자바와 같은 언어에서 부모 객체를 상속 받으면 멤버와 메소드가 그대로 자식 객체에 전달된다.

실제 예를 들자면 온라인 스토어 웹사이트에서 판매자(Seller)와 구매자(Buyer)는 모두 이용자(User)라는 카테고리 안에 들어갈 수 있다. 클린한 코드를 만들기 위해서 때때로 중요한 것은 이러한 공통사항을 추출하여 추상화하는 것이다.

그러면 먼저 가장 상위 부모 객체로 이용자(User)를 만들어놓고, 이용자가 가지는 공통적인 속성을 몇개 정의해보자.

  • 이용자 실명 (username)
  • 이용자 아이디 (id)
  • 이용자 패스워드 (password)
  • 이용자 이메일 (email)

간단하게 자바 클래스로 정의해보면 아래와 같을 것이다.

public class User {
  private String username;
  private String id;
  private String password;
  private String email;
}

만약에 여기서 판매자는 '판매 물품 정보'만 더 가지고 구매자는 '구매 물품 정보'만 더 가진다고 해보자. 간단하게 다음과 같이 정의할 수 있다.

public class Seller extends User {
  private ArrayList<String> sellItems;
}
public class Buyer extends User {
  private ArrayList<String> buyItems;
}

물론 사실 전통적 OOP 상속에 대한 몇가지 단점을 아는 사람도 있겠지만, 일단은 추상화로 인해 코드 자체가 매우 깔끔해졌다. 프로토타입도 이와 비슷한 기능을 제공하고 있다는 게 프로토타입의 시작이다.

사실 프로토타입 기반 언어인 자바스크립트

자바스크립트는 상속을 위해 prototype 오브젝트를 가질 수 있기 때문에 prototype-based language라고 불리기도 한다. prototype 오브젝트는 메소드와 프로퍼티를 상속하기 위한 템플릿 오브젝트쯤으로 보면 된다.

프로토타입 체인

그리고 이러한 prototype 오브젝트는 또 다른 prototype 오브젝트에서 메소드와 프로퍼티를 상속받을 수도 있다. 이렇게 prototype이 다른 prototype을 상속하는 것에 대하여 prototype chain이라는 용어를 사용한다.

객체 내부에 프로토타입이 존재하고, 그 프로토타입은 사실 다른 프로토타입의 자식 프로토타입으로서 동작하고 있을 수 있다.

자바 객체 상속과의 프로토타입 상속과의 차이

자바 객체 상속에서는 자바 객체에 상속한 모든 내용이 들어있는 반면에 자바스크립트의 프로토타입은 각 객체에 직접 들어있는 것이 아닌 객체 생성자의 prototype이라는 속성에 정의되어 있다.

Javascript에서는 오브젝트 인스턴스와 그 프로토타입 사이에 연결이 형성되고, 프로토타입 체인을 타고 올라가며 속성과 메소드를 찾는 일은 흔한 일이다. (생성자의 prototype 프로퍼티로부터 나온 __proto__ 프로퍼티가 해당 객체의 프로토타입이다.)

생성자 함수의 prototype 속성

한가지 알아두고 가면 좋은 것이 있는데, Object.getPrototypeOf(obj)함수 혹은 deprecated 된 __proto__ 속성으로 접근 가능한 객체의 프로토타입과 생성자의 프로토타입의 차이를 인지하는 것이 중요하다.

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

위와 같은 코드가 있을 때 생성자 함수인 Person은 자신만의 프로토타입 속성을 가지고 있고, 해당 프로토타입은 Object.getPrototypeOf(Person)으로 접근 가능하다. 그런데 사실 PersonPerson.prototype이라는 속성을 하나 더 갖고 있다. 이렇게 생성자 함수가 가지고 있는 prototype 속성은 이 생성자 함수의 인스턴스를 위한 청사진 역할을 한다.

new Person()을 이용해 객체를 생성했을 때, 생성된 객체는 Person.prototype__proto__로 물려받게 된다.

위 코드의 결과에서 true를 보면 대략적으로 이해가 될 것이다.

프로토타입 오브젝트 이해하기

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

위에서 정의한 Person 생성자로 부터 예제 코드를 하나씩 돌려보며 프로토타입을 이해해보자.

콘솔에 person1이라는 이름을 갖는 새로운 Person 객체를 만들어놨다.

위와 같이 . 기호를 이용해 해당 오브젝트가 가지고 있는 메소드들을 볼 수 있는데, toString(), valueOf()와 같은 메소드들이 보인다. 이는 최상위 객체인 Object의 프로토타입인 Object.prototype을 상속한 것이다.

Object.prototype에는 아래와 같이 다양한 메소드가 존재한다.

그렇다면 만일 Object.prototype으로부터 물려받은 메소드를 실행해보면 어떤 결과가 나올까?

그냥 오브젝트가 그대로 결과로 나온다. 그리고 [[Prototype]]에는 Object가 들어있다. 결과는 별 거 없지만, 이 때 내부적으로 일어난 일은 다음과 같다.

  • 브라우저는 처음으로 person1 오브젝트가 valueOf() 메소드를 가지고 있는지 확인한다. 이전에 사용했던 생성자 Person()valueOf() 메소드가 있었다면, person1에도 valueOf() 메소드가 있었을테지만, 없었다.
  • 브라우저는 다음으로 person1의 프로토타입 오브젝트에 valueOf() 메소드가 있는지 확인한다. 이번에도 없다. 그 이후에 브라우저는 person1의 프로토타입 오브젝트의 프로토타입 오브젝트를 확인한다. 거기엔 있다. 발견된 메소드가 호출된다.


만일 위와 같이 this.valueOf가 내부에 함수로 존재했다면, 프로토타입을 찾아보기 이전에 이미 해당 함수를 찾아서 실행했을 것이다.

프로토타입이란, 상속 받은 멤버들이 정의된 곳이다.

상속 받는 것들과 그렇지 않은 것들

사실 Object 레퍼런스에는 아래에 보이듯, 수많은 속성과 메소드들이 존재한다.

사실 이전에 보았던 person1이 상속받은 것들은 그 중 일부일 뿐이다. 그러면 상속하는 것과 그렇지 않은 것은 어떻게 구분할까? 정답은 .prototype에 있는 것만 상속한다.

위에 보이는 것들만 상속이 된다.

여기서 다른 언어를 쓰다 온 사람은 뭔가 위화감을 느낄 것이다. "클래스 선언도 안하고 함수에서 생성자를 사용하고 멤버와 메소드도 정의한다고?" 그렇다. 자바스크립트에서는 함수도 그저 객체 중 하나일 뿐이다. Function() 생성자 레퍼런스를 확인해보자.

자바 표준 내장 객체의 프로토타입

자바 표준 내장 객체들의 프로토타입을 확인해보면 자바스크립트 전반에 걸쳐 프로토타입 체인 상속이 어떻게 구성되어 있는지 확인해볼 수 있다.

String을 생성자로 이용하면 위와 같이 어마어마한 메소드들이 프로토타입으로 연결될 것이다. 자바스크립트에서는 그냥 문자열 선언을 하면 자동으로 String이 프로토타입으로 붙는다. 그렇기에 우리는 .split(), .indexOf() 등 유용한 메소드를 마음대로 이용할 수 있는 것이다.

그 외에 다른 많은 객체들이 존재한다.

단, .__proto__.prototype은 다르다는 것을 주의해야 한다.

.__proto__도 한국어로 하면 프로토타입이라고 부르기 쉽고 .prototype도 한국어로 하면 프로토타입 이라 불리기 쉽다. 그러나 .__proto__즉, Object.getPrototypeOf(object)로 얻을 수 있는 내용은 내가 상속받은 프로토타입의 내용인 것이고, .prototype으로 얻을 수 있는 내용은 내가 상속하는 객체에게 줄 프로토타입의 내용임을 인지하자.

Object.create() 메소드는 인자로 받은 객체를 프로토타입으로 만든다.

위와 같이 만일 Object.create(person1)이라는 구문으로 객체를 생성하게 되면, 상속받은 프로토타입이 위치하는 .__proto__에는 person1 객체가 그대로 들어가게 된다.

Object.create() 메소드의 공식문서에도 자세히 나와 있다.

생성자를 잘 모를 때, constructor 꼼수 사용하기

모든 자바스크립트 오브젝트는 constructor를 가지고 있다. object.constructor()를 이용하면 해당 오브젝트를 생성했던 생성자를 다시 불러올 수 있으므로, 어떤 생성자로 생성되었는지 모를 때 사용하면 유용하다.

이를테면 다음과 같은 것이 가능하다.

프로토타입으로 상속된 모든 오브젝트에 공통 메소드 추가하기

Array에 새 메소드 추가해보기

위와 같이 nums1nums2라는 숫자 배열 2개를 만들었다고 가정하자.

그리고 위와 같이 Array.prototype에 새로운 함수를 추가했다면,

모든 Array를 상속받은 곳에서 이용 가능하다.

Person에 새 메소드 추가해보기

위와 같이 내가 정의했던 생성자 함수의 프로토타입에도 추가할 수 있어서 용이하다.

Prototype에는 메소드는 추가해도 변수, 상수는 잘 추가하지 않는다.

만일 prototype에서 위와 같이 해당 객체가 가지고 있는 nameage를 출력시키려고 위와 같이 정의한다면, undefined가 나온다. 왜냐하면 함수 내부에서는 this가 해당 객체의 컨텍스트를 잘 가리키지만, 함수 밖에서는 window 객체를 가리키고 있기 때문이다.

물론 상수는 넣어도 되겠지만, 일반적으로 상수는 처음 function에서 this.로 추가해주는 편이 좋다.

자바스크립트 객체와 [[Prototype]] 다시보기

자바스크립트를 처음 배울 때는 잘 모르지만, 자바스크립트의 객체는 사실 이전에 살펴봤듯, [[Prototype]]이라는 숨김 프로퍼티를 갖는다. [[prototype]]에 물론 null이 들어올 수도 있다.

위와 같이 간단한 생성자 함수를 만들었을 때,

기본으로 [[Prototype]]에는 최상위 오브젝트인 Object를 참조하고 있다.

예제 코드 - [[Prototype]]을 이용해 프로토타입 체인 객체 상속해보기

우리는 .__proto__ 속성을 사용해서 [[Prototype]]에 들어갈 오브젝트를 직접 정해줄 수도 있다.

위 코드의 실행결과를 보면 이해가 한층 쉽다. rabbit.__proto__animals를 할당했을 때는 rabbit.eatstrue가 되고, rabbit.jumpstrue가 된다. 말 그대로 animals에 있던 속성을 그대로 상속받는데, rabbit.__proto__ = {}와 같이 빈 오브젝트를 다시 할당했더니 rabbit.eatsundefined가 되었다.

상속으로 인해 animals가 부모 클래스가 되고, rabbit이 자식 클래스인 구조가 되었다. rabbit이 물려받은 eats 프로퍼티는 '상속 프로퍼티'라고 부른다.

위와 같이도 이용 가능하다.

이렇게 위의 male을 상속하여 jake를 만들 수도 있다. 그러면 구조는 다음과 같아진다.

말 그대로 체인이 생긴다. JakeMale을 구현한 것이고, MaleHuman을 구현한 것이다.

[[Prototype]]의 제약사항

  1. 순환참조는 허용되지 않는다. __proto__를 이용해 닫힌 형태로 다른 객체를 참조하면 에러가 발생
  2. [[Prototype]]을 정의할 수 있는 .__prototype__ 속성에는 objectnull만 들어갈 수 있다. 다른 타입은 넣어도 무시당한다
  3. 하나의 [[Prototype]]만 가능하다. 객체는 두개의 프로토타입을 상속받진 못한다.

값을 씌울 때는 프로토타입을 사용하지 않는다.

let animal = {
  eats: true,
  walk() {
    alert("usual animal walk");
  }
};

let rabbit = {
  __proto__: animal
};

rabbit.walk = function() {
  alert("it's not usual animal! it's Rabbit! Bounce-bounce!");
};

rabbit.walk(); // Rabbit! Bounce-bounce!

위와 같은 코드가 있을 때, rabbit은 다른 animal들과는 걸어다니는 방식이 다르기 때문에 rabbit만의 walk()메소드를 따로 추가해주고 싶다면, 프로토타입을 사용하는 것이 아니라 그냥 rabbit.walk 프로퍼티에 함수를 추가해주면 된다. 그러면, 자바스크립트에서 프로토타입을 뒤져보는 단계 전에 이미 내부 프로퍼티 메소드인 walk를 찾기 때문에 정상동작한다.

간혹 주의해야 할 것, 접근자 프로퍼티

위와 같이 프로퍼티에서 get, set을 붙여 접근자 프로퍼티를 만든 경우에는 name.fullName = ""를 수행한다고 해도 실제로 프로퍼티에 값을 넣는 행위가 실행되지 않고 지정한 getter 메소드가 실행되니 동작에 오해가 없도록 주의해야 한다.

알면 좋은것, 접근자 프로퍼티의 this

const name = {
  lastName: 'Seo',
  firstName: 'Jake',
  set fullName(value) {
    [this.firstName, this.lastName] = value.split(" ");
  },
  get fullName() {
    return `${this.firstName} ${this.lastName}`;
  }
}

const englishName = {
  isEnglish: true,
  __proto__: name
};

englishName.fullName = "Paul Seo";

위와 같은 코드를 실행시키면, englishName.fullNamesettername 객체의 lastName, firstName을 바꿀지 아니면 englishName객체의 lastName, firstName을 바꾸게 되는 것인지 잘 알아두는 것이 좋다.

setter는 프로토타입에 들어있더라도 프로퍼티를 할당하거나 불러올 때 대신 실행되는 함수일 뿐, 그 이상도 그 이하도 아니다. 그래서 this에서 실행 컨텍스트가 어떻게 될지 잘 안다면, 여기서는 당연히 englishName의 다음에 .fullName을 하였으므로, . 앞에 있는 것은 englishName이다.

그래서 englishNamelastName, firstName 프로퍼티가 바뀌게 된다.

this의 실행 컨텍스트는 프로토타입에 영향을 받지 않는다. 프로토타입은 메소드 자체는 공유하지만, 객체의 상태 공유와는 상관없다.

for...in은 프로토타입으로부터 상속받은 프로퍼티도 순회한다.

const animal = {
  eats: true
};

const rabbit = {
  jumps: true,
  __proto__: animal
};

alert(Object.keys(rabbit)); // jumps

for(let prop in rabbit) alert(prop); // jumps, eats
  • Object.keys(): 상속받은 프로퍼티를 제외하고, 객체에서 선언된 프로퍼티만을 순회
  • for ... in: 상속받은 프로퍼티와 객체에서 선언된 프로퍼티 모두를 순회

object.hasOwnProperty()를 이용하면 구분 가능하다.

const animal = {
  eats: true
};

const rabbit = {
  jumps: true,
  __proto__: animal
};

for(let property in rabbit) {
  const isOwnProperty = rabbit.hasOwnProperty(property);
  
  if(isOwnProperty) {
     alert(`상속받지 않은 프로퍼티: ${property}`); 
  } else {
     alert(`상속받은 프로퍼티: ${property}`); 
  }
}

상속받은 프로퍼티로 eats가 잘 나오고, 상속받지 않은 프로퍼티로 jumps가 잘 나온다.

그렇다면 .hasOwnPropety()는 어디서 온 것일까?

위는 MDN 공식문서 중 Object.prototype.hasOwnProperty() 부분을 캡처한 것이다. 위에서 볼 수 있듯, hasOwnProperty()Object 프로토타입에 내장된 메소드 중 하나이다.

프로토타입 체인을 살펴보면 위와 같이 구성되어 있기 때문에 가능한 것이다.

animal은 객체 리터럴로 선언되었기에 Object 프로퍼티를 프로토타입 객체로 갖는다.

그렇다면 for...in에 hasOwnProperty()가 걸리지 않은 이유는?

for...in은 이전에 보았듯, 상속받은 프로퍼티도 모두 순회한다. 그런데 왜 hasOwnProperty()에 걸리지 않았을까? 그 이유는 enumerable 플래그에 있다.

object.propertyIsEnumerable() 메소드를 통해 해당 프로퍼티가 순회 가능한지 알아볼 수 있다.

enumerable 플래그를 설정하는 방법

enumerable 플래그를 설정하려면, Object.defineProperty()를 이용하면 된다.

위는 defineProperty() 메소드를 이용해서 enumerablefalse를 할당한 예이다. 이렇게 하면 for ... in에서 해당 프로퍼티를 순회하지 않는다.

정리

  • 자바스크립트의 모든 객체에는 숨김 프로퍼티 [[Prototype]]이 있는데, 이 객체에서는 다른 객체 혹은 null을 가리킨다.
  • [[Prototype]]이 참조하는 객체를 프로토타입 객체라고 한다.
  • 객체에서 메소드를 실행하면, 처음에 해당 객체의 프로퍼티부터 뒤져보고 없다면, 프로토타입과 프로토타입의 프로토타입을 순서대로 뒤진다.
  • accessor 프로퍼티라고 불리는 getter, setter 등은 프로토타입을 뒤지지 않고, 그냥 함수로서 실행된다.
  • accessor 프로퍼티가 프로토타입에 선언되어 있더라도 this의 실행 컨텍스트는 프로토타입에 영향을 받지 않는다.
  • for...in은 자신의 프로퍼티 뿐만 아니라 상속받은 프로퍼티까지 순회한다.
  • object.definePropertyenumerablefalse로 만들면 해당 프로퍼티는 순회하지 않는다.

프로토타입 예제코드 몇개로 복습하기

자바스크립트 객체가 값을 찾는 순서

let animal = {
  jumps: null
};
let rabbit = {
  __proto__: animal,
  jumps: true
};

alert( rabbit.jumps ); // ? (1)
delete rabbit.jumps;
alert( rabbit.jumps ); // ? (2)
delete animal.jumps;
alert( rabbit.jumps ); // ? (3)

위 코드의 결과는 순서대로 true, null, undefined가 나온다. 코드에서 rabbit.jumpsrabbit 객체의 jumps 프로퍼티를 찾는다는 뜻이고, 프로퍼티를 찾는 순서는 아래와 같다.

  1. 해당 객체가 직접 가지고 있는지
  2. 프로토타입에서 가지고 있는지

처음에는 1.로 찾았을 때 바로 나오는 케이스고, 그 뒤에는 rabbitjumps 속성을 지웠기 때문에 animaljumps 속성이 나오게 되고, 그 뒤에는 animaljumps 속성도 지우기 때문에 끝끝내 찾지 못해 undefined가 나온다.

프로토타입 체인 만들어보기

const _head = {
  glasses: 1
};

const _table = {
  pen: 3
};

const _bed = {
  sheet: 1,
  pillow: 2
};

const _pockets = {
  money: 2000
};

_pockets.__proto__ = _bed;
_bed.__proto__ = _table;
_table.__proto__ = _head;

위와 같이 코드를 짜면 _pockets > _bed > _table > _head 순으로 프로토타입 체인이 완성된다. 각각의 객체가 [[Prototype]] 프로퍼티에 다음 객체를 프로퍼티 타입으로 가지게 되는 것이다.

  • 이렇게 체인을 구성하면 _pockets.glasses를 입력해도 1을 반환받을 수 있고, _head.glasses를 입력해도 1을 반환받을 수 있다. 다만 두 입력값 사이에는 프로토타입을 거치는지에 대한 차이가 있다.
  • 또한 프로토타입 체인을 통해 값을 가져온데도 첫 접근 이후에는 해당 프로퍼티가 발견됐던 곳을 캐싱하기 때문에 성능상의 차이도 거의 없이 엔진에서 자동적으로 최적화가 된다.

프로토타입과 this

let animal = {
  eat() {
    this.full = true;
  }
};

let rabbit = {
  __proto__: animal
};

rabbit.eat();

위 코드는 당연히 rabbit 객체의 full 프로퍼티를 true로 만들 것이다. 이전에도 나왔던 내용이지만, 프로토타입은 따로 실행 컨텍스트를 변경시키지 않는다. 프로퍼티를 찾는 것과 뭔가 실행하는 것은 다른 일로 보는 것이 맞다.

프로토타입과 this 2

let hamster = {
  stomach: [],

  eat(food) {
    this.stomach.push(food);
  }
};

let speedy = {
  __proto__: hamster
};

let lazy = {
  __proto__: hamster
};

// 햄스터 한 마리가 음식을 찾았습니다.
speedy.eat("apple");
alert( speedy.stomach ); // apple

// 이 햄스터도 같은 음식을 가지고 있습니다. 왜 그럴까요? 고쳐주세요.
alert( lazy.stomach ); // apple

위 코드의 경우에는 약간 예상과 다른 결과가 나올 수 있는데, 그 이유는 this.stomach.push() 부분에서 자바스크립트가 this.stomach를 프로토타입 체인을 통해 찾아다니기 때문이다. 그러면 speedylazy가 공통으로 사용하고 있는 프로토타입 객체 hamster가 가진 stomach를 공유하는 일이 벌어진다. 그러지 않기 위해서는 다음과 같은 코드를 작성하면 된다.

let hamster = {
  stomach: [],

  eat(food) {
    // this.stomach.push 대신에 this.stomach에 할당
    this.stomach = [food];
  }
};

let speedy = {
   __proto__: hamster
};

let lazy = {
  __proto__: hamster
};

// Speedy는 음식을 발견합니다.
speedy.eat("apple");
alert( speedy.stomach ); // apple

// Lazy의 stomach는 비어있습니다.
alert( lazy.stomach ); // <nothing>

위는 어떤 프로퍼티에 무언가를 할당할 때는 프로토타입 체인을 이용하지 않는 점을 활용한 코드이다. 위 경우에는 그냥 speedy.stomach[apple]이 들어가게 된다. 그러나, 위 방법으로는 딱 한가지 음식밖에 못담는다.

let hamster = {
  stomach: [],

  eat(food) {
    this.stomach.push(food);
  }
};

let speedy = {
  __proto__: hamster,
  stomach: []
};

let lazy = {
  __proto__: hamster,
  stomach: []
};

// speedy는 음식을 발견합니다.
speedy.eat("apple");
alert( speedy.stomach ); // apple

// lazy의 stomach은 비어있습니다.
alert( lazy.stomach ); // <nothing>

위와 같이 코드를 작성하면, 프로토타입을 뒤져보기 전에 이미 해당 객체에 stomach 배열이 존재하니 독립된 stomach를 이용하는 것이 가능해진다.

레퍼런스

https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Objects/Object_prototypes
https://ko.javascript.info/prototype-inheritance#ref-530
https://developer.mozilla.org/ko/docs/Learn/JavaScript/Objects/Object_prototypes#%ED%94%84%EB%A1%9C%ED%86%A0%ED%83%80%EC%9E%85_%EA%B0%9D%EC%B2%B4_%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0

profile
풀스택 웹개발자로 일하고 있는 Jake Seo입니다. 주로 Jake Seo라는 닉네임을 많이 씁니다. 프론트엔드: Javascript, React 백엔드: Spring Framework에 관심이 있습니다.

0개의 댓글