22. 1. 17 자바스크립트) 객체 프로퍼티 설정 / 프로토타입과 프로토타입 상속

divedeepp·2023년 1월 17일
0

JavaScript

목록 보기
8/11

프로퍼티 플래그와 설명자

프로퍼티 플래그

객체의 프로퍼티는 값 뿐만 아니라 플래그(flag)라 불리는 특별한 속성 세 가지를 갖는다.

  • writable : true이면 프로퍼티의 값을 수정할 수 있다.
  • enumerable : true이면 반복문을 사용해서 프로퍼티를 나열할 수 있다.
  • configurable : true이면 프로퍼티를 삭제하거나 플래그 수정이 가능하다.

지금까지 해왔던 방식으로 객체에 프로퍼티를 만들면 해당 프로퍼티의 플래그는 모두 true가 된다.

먼저 플래그를 얻는 방법을 알아보자. Object.getOwnPropertyDescriptor 메서드를 사용하면 특정 프로퍼티에 대한 정보를 모두 얻을 수 있다.

let descriptor = Object.getOwnPropertyDescriptor(obj, propertyName);

위 메서드를 호출하면 프로퍼티 설명자(descriptor)라 불리는 객체가 반환되는데, 여기에 프로퍼티 값과 세 가지 플래그에 대한 정보가 모두 담겨져있다.

let user = {
  name: "John"
};

let descriptor = Object.getOwnPropertyDescriptor(user, 'name');

alert( JSON.stringify(descriptor, null, 2 ) );
/* property descriptor:
{
  "value": "John",
  "writable": true,
  "enumerable": true,
  "configurable": true
}
*/

Object.defineProperty 메서드를 사용하면 프로퍼티의 플래그를 변경할 수 있다. 객체에 해당 프로퍼티가 있으면 인수로 넘겨받은 정보대로 플래그를 변경해준다. 만약 프로퍼티가 없으면 인수로 넘겨받은 정보를 이용해서 새로운 프로퍼티를 만든다. 이 때, 플래그 정보가 없으면 플래그값은 자동으로 false가 된다.

Object.defineProperty(obj, propertyName, descriptor)

아래 예시를 보면 새로운 프로퍼티 name이 만들어지고, 모든 플래그 값이 false가 된 것을 확인할 수 있다.

let user = {};

Object.defineProperty(user, "name", {
  value: "John"
});

let descriptor = Object.getOwnPropertyDescriptor(user, 'name');

alert( JSON.stringify(descriptor, null, 2 ) );
/*
{
  "value": "John",
  "writable": false,
  "enumerable": false,
  "configurable": false
}
 */

writable 플래그

writable 플래그를 사용해서 프로퍼티에 값을 쓰거나, 못쓰게 만들 수 있다.

// 이미 존재하는 프로퍼티 변경
let user = {
  name: "John"
};

Object.defineProperty(user, "name", {
  writable: false
});

user.name = "Pete"; // Error: Cannot assign to read only property 'name'

// 새로운 프로퍼티에 적용
let user = { };

Object.defineProperty(user, "name", {
  value: "John",
  // defineProperty를 사용해 새로운 프로퍼티를 만들 땐, 어떤 플래그를 true로 할지 명시해주어야 한다.
  enumerable: true,
  configurable: true
});

alert(user.name); // John
user.name = "Pete"; // Error

enumerable 플래그

enumerable 플래그 값을 이용해서 프로퍼티가 반복문에 나타나게(열거 가능) 혹은 나타나지 않게(열거 불가능) 만들 수 있다.

객체 내장 메서드 toString은 열거가 불가능하지만, 커스텀으로 만든 toString 메서드는 반복문에 나타난다.

let user = {
  name: "John",
  toString() {
    return this.name;
  }
};

//커스텀 toString은 for...in을 사용해 열거할 수 있습니다.
for (let key in user) alert(key); // name, toString

위에서 설명한 enumerable 플래그 값을 false로 설정해서 for in 반복문에 나타나지 않게 할 수 있다.

let user = {
  name: "John",
  toString() {
    return this.name;
  }
};

Object.defineProperty(user, "toString", {
  enumerable: false
});

// 이제 for...in을 사용해 toString을 열거할 수 없게 되었습니다.
for (let key in user) alert(key); // name

configurable 플래그

configurable 플래그를 통해 해당 프로퍼티를 객체에서 지우거나 지울 수 없게, 플래그 설정을 가능하게하거나 가능하지 않게 만들 수 있다.

기본적으로 false로 설정된 몇몇 내장 객체의 프로퍼티가 있다. 예를 들어 내장 객체MathPI 프로퍼티가 대표적인 예이다. 이 프로퍼티는 쓰기와 열거, 구성이 불가능하다.

let descriptor = Object.getOwnPropertyDescriptor(Math, 'PI');

alert( JSON.stringify(descriptor, null, 2 ) );
/*
{
  "value": 3.141592653589793,
  "writable": false,
  "enumerable": false,
  "configurable": false
}
*/

Math.PI = 3; // Error

// 수정도 불가능하지만 지우는 것 역시 불가능하다

configurable 플래그를 false로 설정하면 돌이킬 방법이 없다. defineProperty를 써도 값을 true로 돌릴 수 없다.

configurable 플래그를 false로 설정했을 때 만들어내는 구체적인 제약사항은 아래와 같다.

  • configurable 플래그를 수정할 수 없다.
  • enumerable 플래그를 수정할 수 없다.
  • writable 플래그의 값을 false에서 true로 바꿀 수 없다. 단, true에서 false로의 변경은 가능하다.
  • 접근자 프로퍼티 get / set을 변경할 수 없다. 단, 새롭게 만드는 것은 가능하다.
let user = { };

Object.defineProperty(user, "name", {
  value: "John",
  writable: false,
  configurable: false
});

// user.name 프로퍼티의 값이나 플래그를 변경할 수 없습니다.
// 아래와 같이 변경하려고 하면 에러가 발생합니다.
//   user.name = "Pete"
//   delete user.name
//   Object.defineProperty(user, "name", { value: "Pete" })
Object.defineProperty(user, "name", {writable: true}); // Error

Object.defineProperties

Object.defineProperties(obj, descriptors) 메서드를 사용하면 프로퍼티 여러 개를 한 번에 정의할 수 있다.

Object.defineProperties(obj, {
  propName1: descriptor1,
  propName2: descriptor2
  // ...
});

Object.defineProperties(user, {
  name: { value: "John", writable: false },
  surname: { value: "Smith", writable: false },
  // ...
});

Object.getOwnPropertyDescriptors

Object.getOwnPropertyDescriptors(obj) 메서드를 사용하면 모든 프로퍼티 설명자를 한 번에 가져올 수 있다.

해당 메서드와 Object.definedProperties 메서드를 함께 사용하면 객체 복제 시 플래그도 함께 복사할 수 있다.

let clone = Object.defineProperties({}, Object.getOwnPropertyDescriptors(obj));

위 방법을 사용하면 프로퍼티 뿐만 아니라 for in 반복문 방식으로는 복제 못하는 플래그 정보, 심볼형 프로퍼티도 복제할 수 있다.


프로퍼티 getter와 setter

객체의 프로퍼티는 두 종류로 나뉜다.

  • 데이터 프로퍼티(data property) : 지금까지 사용한 모든 일반적인 프로퍼티이다.
  • 접근자 프로퍼티(accessor property) : 본질은 함수이며, 값을 get하거나 set하는 역할을 담당한다. 외부 코드에서는 함수가 아닌 일반적인 프로퍼티처럼 동작한다.

getter와 setter

접근자 프로퍼티는 gettersetter 메서드로 표현된다. 객체 리터럴 안에서 getset으로 나타낼 수 있다.

let obj = {
  get propName() {
    // getter, obj.propName을 실행할 때 실행되는 코드
  },

  set propName(value) {
    // setter, obj.propName = value를 실행할 때 실행되는 코드
  }
};

getter 메서드는 obj.propName을 사용해서 프로퍼티를 읽으려고 할 때 실행되고, setter 메서드는 obj.propName = value으로 프로퍼티에 값을 할당하려 할 때 실행된다.

프로퍼티 name의 값이 "John"이고 surname의 값이 "Smith"인 객체 user를 만들어보자.

이 객체에 fullName이라는 프로퍼티를 추가할 때, 기존 값을 복사-붙여넣기 하지 않고 fullNameJohn Smith가 되도록 접근자 프로퍼티를 구현하자.

let user = {
  name: "John",
  surname: "Smith",
  
  get fullName() {
    return `${this.name} ${this.surname}`;
  }
};

alert(user.fullName);	// John Smith

객체 외부에서 프로퍼티를 호출할 때, 접근자 프로퍼티를 사용하면 함수처럼 호출하지 않고 일반 프로퍼티에서 값에 접근하는 것처럼 프로퍼티를 사용할 수 있다. 나머지 작업은 getter / setter 메서드 뒷단에서 처리해준다.

이어서, 위 예시의 fullNamegetter 메서드만 가지고 있기 때문에 user.fullName = value 처럼 사용해서 값을 할당하려고 하면 에러가 발생한다.

let user = {
  get fullName() {
    return `...`;
  }
};

user.fullName = "Test"; // Error (프로퍼티에 getter 메서드만 있어서 에러가 발생한다.)

user.fullNamesetter 메서드를 추가해서 에러가 발생하지 않도록 고쳐보자.

let user = {
  name: "John",
  surname: "Smith",

  get fullName() {
    return `${this.name} ${this.surname}`;
  },

  set fullName(value) {
    [this.name, this.surname] = value.split(" ");
  }
};

// 주어진 값을 사용해 set fullName이 실행된다.
user.fullName = "Alice Cooper";

alert(user.name); // Alice
alert(user.surname); // Cooper

이렇게 gettersetter 메서드를 구현하면 객체에는 fullName이라는 가상의 프로퍼티가 생긴다. 가상의 프로퍼티는 읽고 쓸 수는 있지만, 실제로 존재하지 않는다.

접근자 프로퍼티 설명자

데이터 프로퍼티의 설명자와 접근자 프로퍼티의 설명자는 다르다. 접근자 프로퍼티에는 설명자 valuewritable이 없는 대신에 getset이라는 함수가 있다.

  • get : 인수가 없는 함수로, 프로퍼티를 읽을 때 동작한다.
  • set : 인수가 하나인 함수로, 프로퍼티에 값을 쓸 때 호출된다.
  • enumerable : 데이터 프로퍼티와 동일하다.
  • configurable : 데이터 프로퍼티와 동일하다.

데이터 프로퍼티를 정의할 때 처럼 접근자 프로퍼티도 아래와 같이 defineProperty에 설명자 getset을 전달하면 접근자 프로퍼티를 설정할 수 있다.

let user = {
  name: "John",
  surname: "Smith"
};

Object.defineProperty(user, 'fullName', {
  get() {
    return `${this.name} ${this.surname}`;
  },

  set(value) {
    [this.name, this.surname] = value.split(" ");
  }
});

alert(user.fullName); // John Smith

for(let key in user) alert(key); // name, surname

객체의 프로퍼티는 접근자 프로퍼티나 데이터 프로퍼티 중 한 종류에만 속할 수 있다. 즉, get / set 메서드만 가지거나 value만 가질 수 있다. 한 프로퍼티에 get / setvalue를 동시에 설정하면 에러가 발생한다.

// Error: Invalid property descriptor.
Object.defineProperty({}, 'prop', {
  get() {
    return 1
  },

  value: 2
});

getter와 setter 똑똑하게 활용하기

gettersetter를 실제 프로퍼티 값을 감싸는 래퍼처럼 사용하면 실제 프로퍼티 값을 원하는 대로 통제할 수 있다.

아래 예시에서는 name 프로퍼티를 위한 setter를 만들어서 이름이 짧아지는 것을 방지하고 있다. 실제 값은 별도의 프로퍼티 _name에 저장된다.

let user = {
  get name() {
    return this._name;
  },

  set name(value) {
    if (value.length < 4) {
      alert("입력하신 값이 너무 짧습니다. 네 글자 이상으로 구성된 이름을 입력하세요.");
      return;
    }
    this._name = value;
  }
};

user.name = "Pete";
alert(user.name); // Pete

user.name = ""; // 너무 짧은 이름을 할당하려 함

객체 user의 이름은 _name에 저장되고, 프로퍼티에 접근하는 것은 gettersetter를 통해 이루어진다. 기술적으로 외부 코드에서 user._name을 사용해서 바로 접근할 수 있다. 하지만 _로 시작하는 프로퍼티는 객체 내부에서만 활용하고 외부에서는 건드리지 않는 것이 관습이다.

호환성을 위해 사용하기

접근자 프로퍼티는 어느 때나 gettersetter를 사용해서 데이터 프로퍼티의 행동과 값을 원하는 대로 조정할 수 있다는 점에서 유용하다.

데이터 프로퍼티 nameage를 사용해서 사용자를 나타내는 객체를 구현한다고 가정해보자.

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

let john = new User("John", 25);

alert( john.age ); // 25

그런데, 요구사항이 바뀌어서 age 대신에 birthday를 저장해야 한다고 하자.

function User(name, birthday) {
  this.name = name;
  this.birthday = birthday;
}

let john = new User("John", new Date(1992, 6, 1));

위 방식처럼 생성자 함수를 수정하면 기존 코드 중 프로퍼티 age를 사용하고 있는 코드를 모두 수정해야 한다. age가 사용되는 부분을 모두 찾아서 수정하는 것도 가능하지만, 시간이 오래 걸린다. 게다가 여러 사람이 age를 사용하고 있다면 모두 찾아 수정하는 것 자체가 힘들다.

age 프로퍼티는 그대로 있어도 나쁠 것이 없다. 그래서 기존 코드들은 그대로 두고 코드를 수정해보자. 대신 age 프로퍼티를 위한 getter를 추가해서 문제를 해결해 보자.

function User(name, birthday) {
  this.name = name;
  this.birthday = birthday;

  // age는 현재 날짜와 생일을 기준으로 계산된다.
  Object.defineProperty(this, "age", {
    get() {
      let todayYear = new Date().getFullYear();
      return todayYear - this.birthday.getFullYear();
    }
  });
}

let john = new User("John", new Date(1992, 6, 1));

alert( john.birthday ); // birthday를 사용할 수 있다.
alert( john.age );      // age 역시 사용할 수 있습니다다.

프로토타입 상속

기존에 있는 기능을 가져와 확장해야 하는 경우에는 어떻게 할까? 예시를 살펴보자.

사람에 관한 프로퍼티와 메서드를 가진 user라는 객체가 있는데, user와 유사하지만 약간의 차이가 있는 adminguest라는 객체를 만들어야 한다고 가정하자.

이 때 프로토타입 상속을 이용하면 user의 메서드를 복사/붙여넣거나 다시 구현하지 않고, user에 약간의 기능을 얹어 adminguest 객체를 만들 수 있다.

[[Prototype]]__proto__

자바스크립트의 객체는 [[Prototype]]이라는 숨김 프로퍼티를 갖는다. 이 숨김 프로퍼티 값은 null이거나 다른 객체를 참조하는데, 참조 대상을 프로토타입이라 부른다.

어떤 객체에서 프로퍼티를 읽으려고 하는데 해당 프로퍼티가 객체 내에 없으면 자바스크립트는 자동으로 프로토타입에서 프로퍼티를 찾는다. 이러한 방식을 통해 프로토타입 상속을 구현할 수 있다.

[[Prototype]] 프로퍼티는 숨김 프로퍼티이지만 __proto__를 사용해서 개발자가 값을 설정할 수 있다.

let animal = {
  eats: true
};

let rabbit = {
  jumps: true
};

rabbit.__proto__ = animal;

__proto___[[Prototype]]과 다르다. __proto__[[Prototype]]의 getter이자 setter이다. 하위 호환성 때문에 여전히 __proto__를 사용할 수는 있지만, 최신 스크립트에서는 __proto__ 대신 함수 Object.getPrototypeOfObject.setPrototypeOf를 써서 프로토타입을 get하거나 set한다.

다시 예시로 돌아가면 객체 rabbit에서 프로퍼티를 얻고싶은데 해당 프로퍼티가 없다면, 자바스크립트는 자동으로 프로토타입인 animal 객체에서 프로퍼티를 얻는다.

let animal = {
  eats: true
};
let rabbit = {
  jumps: true
};

rabbit.__proto__ = animal; // (*)

// 프로퍼티 eats과 jumps를 rabbit에서도 사용할 수 있게 되었습니다.
alert( rabbit.eats ); // true (**)
alert( rabbit.jumps ); // true

이제 rabit의 프로토타입은 animal이다. 그리고, rabbitanimal을 상속받는다. 이렇게 프로토타입에서 상속받은 프로퍼티를 상속 프로퍼티라고 한다.

상속 프로퍼티를 사용해서 animal에 정의된 메서드를 rabbit에서 호출해 보자.

let animal = {
  eats: true,
  walk() {
    alert("동물이 걷습니다.");
  }
};

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

// 메서드 walk는 rabbit의 프로토타입인 animal에서 상속받았습니다.
rabbit.walk(); // 동물이 걷습니다.

프로토타입 체인은 계속 연결될 수 있다.

let animal = {
  eats: true,
  walk() {
    alert("동물이 걷습니다.");
  }
};

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

let longEar = {
  earLength: 10,
  __proto__: rabbit
};

// 메서드 walk는 프로토타입 체인을 통해 상속받았습니다.
longEar.walk(); // 동물이 걷습니다.
alert(longEar.jumps); // true (rabbit에서 상속받음)

프로토타입 체이닝에는 두 가지 제약사항이 있다.

  • 순환 참조는 허용되지 않는다. 즉, __proto__를 이용해서 닫힌 형태로 다른 객체를 참조하면 에러가 발생한다.
  • __proto__의 값은 객체null만 가능하다. 다른 자료형은 무시된다.

프로토타입은 읽기 전용이다

프로토타입은 프로퍼티를 읽을 때만 사용한다. 프로퍼티를 추가, 수정하거나 지우는 연산은 객체에 직접 해야 한다.

객체 rabbit에 메서드 walk를 직접 할당해보자. rabbit.walk()를 호출하면 프로토타입에 있는 메서드가 실행되지 않고, rabbit에 직접 추가한 메서드가 실행된다.

let animal = {
  eats: true,
  walk() {
    /* rabbit은 이제 이 메서드를 사용하지 않습니다. */
  }
};

let rabbit = {
  __proto__: animal
};

rabbit.walk = function() {
  alert("토끼가 깡충깡충 뜁니다.");
};

rabbit.walk(); // 토끼가 깡충깡충 뜁니다.

this는 프로토타입에 영향을 받지 않는다

메서드를 객체에서 호출하던 프로토타입에서 호출하던지 상관없이 this는 언제나 . 앞에 있는 객체이다.

상속받은 메서드를 사용하더라도 객체는 프로토타입이 아닌 자신의 상태를 수정한다. 예시를 살펴보자. 메서드 저장소 역할을 하는 객체 animalrabbit이 상속받았다고 하자.

// animal엔 다양한 메서드가 있습니다.
let animal = {
  walk() {
    if (!this.isSleeping) {
      alert(`동물이 걸어갑니다.`);
    }
  },
  sleep() {
    this.isSleeping = true;
  }
};

let rabbit = {
  name: "하얀 토끼",
  __proto__: animal
};

// rabbit에 새로운 프로퍼티 isSleeping을 추가하고 그 값을 true로 변경합니다.
rabbit.sleep();

alert(rabbit.isSleeping); // true
alert(animal.isSleeping); // undefined (프로토타입에는 isSleeping이라는 프로퍼티가 없습니다.)

rabbit.sleep()을 호출하면 객체 rabbitisSleeping 프로퍼티가 추가된다. 이 때 상속받은 메서드의 thisanimal이 아닌 실제 메서드가 호출되는 시점의 . 앞에 있는 객체가 된다. 따라서 this에 데이터를 쓰면 animal이 아닌 해당 객체의 상태가 변화한다. 즉, 메서드는 공유되지만, 객체의 상태는 공유되지 않는다!

for in 반복문에 객체 사용하기

for in은 객체 자신의 프로퍼티 키 뿐만 아니라 상속받은 프로퍼티들의 키도 순회대상에 포함시킨다.

let animal = {
  eats: true
};

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

// Object.keys는 객체 자신의 키만 반환합니다.
alert(Object.keys(rabbit)); // jumps

// for..in은 객체 자신의 키와 상속 프로퍼티의 키 모두를 순회합니다.
for(let prop in rabbit) alert(prop); // jumps, eats

Object.hasOwnProperty(key)를 이용하면 상속 프로퍼티를 순회 대상에서 제외할 수 있다. 이 메서드는 key에 대응하는 프로퍼티가 상속 프로퍼티가 아니고 객체에 직접 구현되어있는 프로퍼티라면 true를 반환한다.

let animal = {
  eats: true
};

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

for(let prop in rabbit) {
  let isOwn = rabbit.hasOwnProperty(prop);

  if (isOwn) {
    alert(`객체 자신의 프로퍼티: ${prop}`); // 객체 자신의 프로퍼티: jumps
  } else {
    alert(`상속 프로퍼티: ${prop}`); // 상속 프로퍼티: eats
  }
}

위 예시의 상속 관계를 그림으로 나타내면 아래와 같다. 그림을 보면 for in 안에서 rabbit에 사용한 메서드 hasOwnProperty가 상속받은 메서드인 것을 확인할 수 있다.

근데 상속 프로퍼티인 eats는 출력되지만, hasOwnProperty는 출력되지 않았다. 왜일까? 이유는 간단하다. Object.prototype에 있는 모든 메서드의 enumerable 플래그는 false이고, for in은 열거 가능한 프로퍼티만 순회 대상에 포함하기 때문이다.


함수의 prototype 프로퍼티

생성자 함수에 new 연산자를 사용해 만든 객체는 생성자 함수의 프로토타입 정보를 사용한다.

생성자 함수 F의 프로토타입을 의미하는 F.prototype에서 prototypeF에 정의된 일반 프로퍼티이다. 다시 말하면 F.prototype에서 prototype은 앞 서 배운 숨김 프로퍼티인 [[Prototype]]과 다른 일반적인 프로퍼티이다.

F.prototype 프로퍼티는 new F를 호출할 때만 사용된다. new F를 호출할 때 만들어지는 새로운 객체의 [[Prototype]]을 할당해 준다.

아래 예시를 살펴보자.

let animal = {
  eats: true
};

function Rabbit(name) {
  this.name = name;
}

Rabbit.prototype = animal;

let rabbit = new Rabbit("흰 토끼"); //  rabbit.__proto__ == animal

alert( rabbit.eats ); // true

Rabbit.protoype = animalnew Rabbit을 호출해서 만든 새로운 객체의 [[Prototype]]animal로 설정하라는 것을 의미한다.

위 그림에서 세로 화살표가 객체 rabbitanimal을 상속받았다는 것을 의미한다.

함수의 prototype 프로퍼티와 constructor 프로퍼티

모든 함수는 특별히 할당하지 않더라도 기본적으로 prototype을 갖는다.

prototype 프로퍼티는 constructor 프로퍼티 하나만 가지고 있는 객체를 가리키는데, 여기서 constructor 프로퍼티는 함수 자신을 가리킨다.

// 함수를 만들기만 해도 디폴트 프로퍼티인 prototype이 설정된다.
function Rabbit() {}	// Rabbit.prototype = { constructor: Rabbit };

alert( Rabbit.prototype.constructor == Rabbit ); // true

특별한 조작을 가하지 않았다면 new Rabbit을 실행해서 만든 모든 객체는 constructor 프로퍼티를 사용할 수 있는데, 이 때 [[Prototype]]을 거쳐간다.

function Rabbit() {}	// Rabbit.prototype = { constructor: Rabbit }

let rabbit = new Rabbit(); // {constructor: Rabbit}을 상속받음

alert(rabbit.constructor == Rabbit); // true ([[Prototype]]을 거쳐 접근함)

생성자 함수로 만들어진 객체의 constructor 프로퍼티를 이용하면 새로운 객체를 만들 때 사용할 수 있다. 이런 방법은 객체를 만들 때 어떤 생성자가 사용되었는지 알 수 없을 때 유용하게 쓸 수 있다.

function Rabbit(name) {
  this.name = name;
  alert(name);
}

let rabbit = new Rabbit("흰 토끼");

let rabbit2 = new rabbit.constructor("검정 토끼");

constructor에 가장 중요한 점은 constructor와 관련해서 벌어지는 모든 일은 전적으로 개발자에게 달려있다는 점이다.

함수에 기본으로 설정되는 prototype 프로퍼티 값을 다른 객체로 바꾸면 무슨일이 일어나는지 살펴보자.

function Rabbit() {}
Rabbit.prototype = {
  jumps: true
};

let rabbit = new Rabbit();
alert(rabbit.constructor === Rabbit); // false

이런 상황을 방지하고 constructor의 기본 성질을 제대로 활용하려면 prototype에 뭔가를 하고 싶을 때 prototype 전체를 덮어쓰지 말고, prototype에 원하는 프로퍼티만 추가 및 제거 해야 한다.

function Rabbit() {}

// Rabbit.prototype 전체를 덮어쓰지 말고
// 원하는 프로퍼티가 있으면 그냥 추가하자
Rabbit.prototype.jumps = true
// 이렇게 하면 디폴트 프로퍼티 Rabbit.prototype.constructor가 유지된다.

만약 실수로 prototype을 덮어썼다 하더라도 constructor 프로퍼티를 수동으로 다시 만들어주면 constructor를 다시 사용할 수 있다.

Rabbit.prototype = {
  jumps: true,
  constructor: Rabbit
};

내장 객체의 프로토타입

prototype 프로퍼티는 자바스크립트 내부에서도 광범위하게 사용된다. 모든 내장 생성자 함수에서 prototype 프로퍼티를 사용한다.

Object.prototype

빈 객체가 있다고 가정하자.

let obj = {};
alert( obj );	// "[object Object]"

obj는 비어있는데, "[object Object]" 문자열을 생성하는 코드는 어디에 있을까? obj = {}obj = new Object()와 같다. 여기서 Object는 내장 객체 생성자 함수인데, 이 생성자 함수의 prototypetoString을 비롯한 다양한 메서드가 구현되어있는 객체를 참조한다.

new Object()를 호출하거나 리터럴 문법을 사용해서 객체를 만들 때, 새롭게 생성된 객체의 [[Prototype]]Object.prototype을 참조한다.

따라서, obj.toString()이 호출되면 Object.prototype에서 해당 메서드를 가져오게 된다.

let obj = {};

alert(obj.__proto__ === Object.prototype); // true

alert(obj.toString === obj.__proto__.toString); //true
alert(obj.toString === Object.prototype.toString); //true

다양한 내장 객체의 프로토타입

Array, Date, Function을 비롯한 내장 객체들 역시 프로토타입에 메서드를 저장해 놓는다.

예를 들어, 배열 [1, 2, 3]을 만들면 new Array()의 내부 동작에 의하여 Array.prototype이 배열 [1, 2, 3]의 프로토타입이 되고, Array.prototype을 통해 배열 메서드를 사용할 수 있다. 이러한 내부 동작은 메모리 효율을 높여주는 장점이 있다.

모든 내장 프로토타입의 상속 트리 꼭대기에는 Object.prototype이 있다.

let arr = [1, 2, 3];

// arr은 Array.prototype을 상속받았나요?
alert( arr.__proto__ === Array.prototype ); // true

// arr은 Object.prototype을 상속받았나요?
alert( arr.__proto__.__proto__ === Object.prototype ); // true

// 체인 맨 위엔 null이 있습니다.
alert( arr.__proto__.__proto__.__proto__ ); // null

프로토타입 체인 상의 중복 메서드가 있을 때는 체인에 가장 가까운 곳에 있는 메서드가 사용된다. 예를 들어, Object.prototype에도 toString이 있고, Array.prototype에도 toString이 있는데, 가장 가까운 Array.prototypetoString 메서드가 사용된다.

let arr = [1, 2, 3]
alert(arr); // 1,2,3 <-- Array.prototype.toString의 결과

배열뿐만 아니라 함수 같은 다른 내장 객체들 또한 같은 방식으로 동작한다.

function f() {}

alert(f.__proto__ == Function.prototype); // true
alert(f.__proto__.__proto__ == Object.prototype); // true, 객체에서 상속받음

원시형

문자열과 숫자, 불린은 객체가 아니기에 다루기 까다롭다. 이런 원시 타입 값의 프로퍼티에 접근하려고 하면 내장 생성자 String, Number, Boolean을 사용하는 임시 래퍼(wrapper) 객체가 생성된다. 임시 래퍼 객체는 메서드를 제공한 후 사라진다.

명세서에는 각 자료형에 해당하는 래퍼 객체의 메서드를 프로토타입 안에 구현해 놓고 String.prototype, Number.prototype, Boolean.prototype을 사용해서 쓰도록 규정한다.

nullundefined에 대응하는 래퍼 객체는 없다. 따라서, 메서드와 프로퍼티뿐만 아니라 프로토타입도 사용할 수 없다.

내장 프로토타입 변경하기

내장 프로토타입은 수정할 수 있다. 하지만, 프로토타입은 전역으로 영향을 미치기 때문에 프로토타입을 조작하면 기존 코드와 충돌이 날 가능성이 크다. 따라서, 내장 프로토타입을 수정하는 것은 최대한 지양한다. 모던 자바스크립트에서 내장 프로토타입의 변경을 허용하는 경우는 폴리필을 만들 때 딱 하나뿐이다.

내장 프로토타입에서 메서드 빌려오기

개발을 하다 보면 내장 프로토타입에 구현된 메서드를 빌려야 하는 경우가 생긴다.

유사 배열 객체를 만들고 Array.prototype의 메서드를 복사해보자. join의 내부 알고리즘은 인덱스와 length 프로퍼티만 확인하기 때문에 아래 코드는 에러없이 동작한다.

let obj = {
  0: "Hello",
  1: "world!",
  length: 2,
};

obj.join = Array.prototype.join;

alert( obj.join(',') ); // Hello,world!

이처럼 메서드 빌리기는 여러 객체에서 필요한 기능을 가져와 섞는 것을 가능하게 해주기 때문에 유연한 개발을 가능하게 한다.


프로토타입 메서드와 __proto__가 없는 객체

__proto___는 브라우저를 대상으로 개발하고 있다면 다소 구식이기 때문에 더는 사용하지 않는 것이 좋다. 대신 아래와 같은 모던한 메서드들을 사용하는 것이 좋다.

  • Object.create(proto, [descriptors]) : [[Prototype]]이 매개변수 proto를 참조하는 빈 객체를 만든다. 프로퍼티 설명자를 추가로 넘길 수 있다.
  • Object.getPrototypeOf(obj) : obj[[Prototype]]을 반환한다.
  • Object.setPrototypeOf(obj, proto) : obj[[Prototype]]이 매개변수 proto가 되도록 설정한다.
let animal = {
  eats: true
};

// 프로토타입이 animal인 새로운 객체를 생성합니다.
let rabbit = Object.create(animal);

alert(rabbit.eats); // true

alert(Object.getPrototypeOf(rabbit) === animal); // true

Object.setPrototypeOf(rabbit, {}); // rabbit의 프로토타입을 {}으로 바꿉니다.

프로퍼티 설명자를 이용해 새 객체에 프로퍼티를 추가할 수도 있다.

let animal = {
  eats: true
};

let rabbit = Object.create(animal, {
  jumps: {
    value: true
  }
});

alert(rabbit.jumps); // true

Object.create를 사용하면 for in을 사용해서 프로퍼티를 복사하는 것보다 더 효과적으로 객체를 복제할 수 있다. 객체의 모든 프로퍼티를 포함한 완벽한 사본이 만들어진다. 사본에는 열거 가능한 프로퍼티와 불가능한 프로퍼티, 데이터 프로퍼티, getter, setter, [[Prototype]] 등 모든 프로퍼티가 복제된다. 단, 복제된 객체는 얕은 복사본이다.

let clone = Object.create(Object.getPrototypeOf(obj), Object.getOwnPropertyDescriptors(obj));

참고로, 원한다면 언제나 [[Prototype]]에 접근하거나 설정할 수 있다. 하지만 대개는 객체를 생성할 때만 설정하고 이후에는 수정하지 않는다. 자바스크립트 엔진은 이런 시나리오를 토대로 최적화되어 있다. 프로토타입을 그때그때 바꾸는 연산은 객체 프로퍼티 접근 관련 최적화를 망치기 때문에 성능에 나쁜 영향을 미친다. 따라서, [[Prototype]]을 바꾸는 것이 어떤 결과를 초래할지 확실히 알거나 속도가 전혀 중요하지 않은 경우라면 프로토타입을 바꾸지 말자.

proto 사용을 지양할까?

아래 예시를 살펴보자. 프롬프트 창에 __proto__를 입력하면 값이 제대로 할당되지 않는다. __proto__ 프로퍼티는 특별한 프로퍼티라는 것을 이미 알고 있기 때문이다. 참고로 __proto__는 항상 객체이거나 null이어야 한다.

let obj = {};

let key = prompt("입력하고자 하는 key는 무엇인가요?", "__proto__");
obj[key] = "...값...";

alert(obj[key]); // "...값..."이 아닌 [object Object]가 출력됩니다.

개발자가 위 예시와 같은 코드를 작성할 때는 이런 결과를 의도하면서 구현하지는 않는다. 다만 키가 무엇이 되었든, 키를 저장하는데 키가 __proto__일 때 값이 제대로 저장되지 않는 건 명백한 버그이다.

위 예시에서는 이 버그가 그리 치명적이지는 않다. 그런데 할당 값(obj[key]의 right side)이 객체 일 때는 프로토타입이 바뀔 수 있다는 치명적인 버그가 발생할 수 있다. 프로토타입이 바뀌면 예상치 못한 일이 발생할 수 있기 때문이다.

개발자들은 대개 프로토타입이 중간에 바뀌는 시나리오는 배제한 채 개발을 진행한다. 이런 고정관념 때문에 프로토타입이 중간에 바뀌면서 발생한 버그는 그 원인을 쉽게 찾지 못한다. 서버 사이드에서 자바스크립트를 사용할 때는 이런 버그가 취약점이 되기도 한다.

이러한 문제를 어떻게 예방할 수 있을까? 객체 대신 맵을 사용하면 된다. 또, 간단하게 그냥 객체를 사용할 수도 있다.

앞 서 봤듯이 __proto__는 객체의 프로퍼티가 아니라 Object.prototype의 접근자 프로퍼티이다.

그렇기 떄문에 obj.__proto__를 읽거나 쓸때는 이에 대응하는 getter, setter가 프로토타입에서 호출되고 obj[[Prototype]]을 통해 getter와 setter에 접근한다. 다시말해, __proto__[[Prototype]]에 접근하기 위한 수단이지 [[Prototype]] 그 자체가 아니다.

Object.create(null)을 사용해서 프로토타입이 없는 빈 객체를 만들면 해결할 수 있다.

let obj = Object.create(null);

let key = prompt("입력하고자 하는 key는 무엇인가요?", "__proto__");
obj[key] = "...값...";

alert(obj[key]); // "...값..."이 제대로 출력됩니다.

위 방법을 사용하면 객체는 더 이상 __proto___의 getter와 setter를 상속받지 않는다. 이제 __proto__는 평범함 데이터 프로퍼티처럼 처리되므로 버그 없이 예시가 잘 동작하게 된다.

이렇게 프로토타입이 없는 빈 객체는 아주 단순한 혹은 순수 사전식 객체라고 부른다. 이런 객체는 toString 같은 내장 메서드가 없다는 단점이 있다.


참고 문헌

https://ko.javascript.info/object-properties
https://ko.javascript.info/prototypes

profile
더깊이

0개의 댓글