Study JavaScript 0624 - 객체 프로퍼티 설정

변승훈·2022년 6월 24일
0

Study JavaScript

목록 보기
34/43

1. 프로퍼티 플래그와 설명자

객체에는 프로퍼티가 저장되고, 이는 key-value쌍으로 이루어진 것으로 우리는 알고 있었다.
오늘은 객체 프로퍼티의 추가적인 부분들인 추가 구성 옵션 몇 가지와 getter, setter함수를 만드는 법을 알아보자!

1-1. 프로퍼티 플래그

객체 프로퍼티는 value와 함께 플래그(flag)라는 특별한 속성 3 가지를 갖는다.

  • writable - true이면 값을 수정할 수 있다. 그렇지 않다면 읽기만 가능하다.
  • enumerable - true이면 반복문을 사용해 나열할 수 있다. 그렇지 않다면 반복문을 사용해 나열할 수 없다.
  • configurable - true이면 프로퍼티 삭제나 플래그 수정이 가능하다. 그렇지 않다면 프로퍼티 삭제와 플래그 수정이 불가능하다.

지금까지 해오던 평범한 방식으로 프로퍼티를 만들면 해당 프로퍼티의 플래그는 모두 true이며 이렇게 설정된 플래그는 언제든지 수정이 가능하다.

지금부터 플래그를 얻는 방법을 알아보자!

Object.getOwnPropertyDescriptor메소드를 사용하면 특정 프로퍼티에 대한 정보를 모두 얻을 수 있다.

let descriptor = Object.getOwnPropertyDescriptor(obj, propertyName);
  • obj: 정보를 얻고자 하는 객체
  • propertyName: 정보를 얻고자 하는 객체 내 프로퍼티

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

let user = {
  name: "Hun"
};

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

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

메서드 Object.defineProperty를 사용하면 플래그를 변경할 수 있다.

Object.defineProperty(obj, propertyName, descriptor)
  • obj, propertyName: 설명자를 적용하고 싶은 객체와 객체 프로퍼티
  • descriptor: 적용하고자 하는 프로퍼티 설명자

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

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

let user = {};

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

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

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

평범한 방식으로 객체 프로퍼티 user.name을 만들었을 때와 defineProperty를 이용해 프로퍼티를 만들었을 때의 가장 큰 차이점은 플래그에 있다.
defineProperty를 사용해 프로퍼티를 만든 경우, descriptor에 플래그 값을 명시하지 않으면 플래그 값이 자동으로 false가 된다. 플래그 값을 true로 설정하려면descriptortrue라고 명시해야 한다.

1-2. writable 플래그

writable 플래그를 사용해 ser.name에 값을 쓰지 못하도록(non-writable) 해보자!

let user = {
  name: "Hun"
};

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

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

이제 defineProperty를 사용해 writable 플래그를 true로 변경하지 않는 한 그 누구도 객체의 이름을 변경할 수 없게 되었다.

아래 예시는 위 예시와 동일하게 동작한다. 다만 아래 예시에선 defineProperty 메서드를 사용해 프로퍼티를 처음부터 만들어 보았다.

let user = { };

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

console.log(user.name); // Hun
user.name = "Pete"; // Error

1-3. enumerable 플래그

user에 커스텀 메서드 toString을 추가해보자.

객체 내장 메서드 toString은 열거가 불가능(non-enumerable)하기 때문에 for..in 사용시 나타나지 않는다. 하지만 커스텀 toString을 추가하면 아래와 같이 for..intoString이 나타난다.

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

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

그런데 특정 프로퍼티의 enumerable 플래그 값을 false로 설정하면 for..in 반복문에 나타나지 않게 할 수 있다. 커스텀 toString도 열거가 불가능하게 할 수 있다!

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

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

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

열거가 불가능한 프로퍼티는 Object.keys에도 배제된다.

console.log(Object.keys(user)); // name

1-4. configurable 플래그

구성 가능하지 않음을 나타내는 플래그(non-configurable flag)인 configurable:false는 몇몇 내장 객체나 프로퍼티에 기본으로 설정되어있다.

어떤 프로퍼티의 configurable 플래그가 false로 설정되어 있다면 해당 프로퍼티는 객체에서 지울 수 없다.

내장 객체 Math의 PI 프로퍼티가 대표적인 예시다. 이 프로퍼티는 쓰기와 열거, 구성이 불가능하다.

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

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

개발자가 코드를 사용해 Math.PI 값을 변경하거나 덮어쓰는 것도 불가능하다.

Math.PI = 3; // Error

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

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

configurable:false가 만들어내는 구체적인 제약사항은 아래와 같다.

  1. configurable 플래그를 수정할 수 없음
  2. enumerable 플래그를 수정할 수 없음
  3. writable: false의 값을 true로 바꿀 수 없음(true를 false로 변경하는 것은 가능)
  4. 접근자 프로퍼티 get/set을 변경할 수 없음(새롭게 만드는 것은 가능)

이런 특징을 이용하면 아래와 같이 “영원히 변경할 수 없는” 프로퍼티(user.name)를 만들 수 있다.

let user = { };

Object.defineProperty(user, "name", {
  value: "Hun",
  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

※ "non-configurable"은 "non-writable"과 다르다!
configurable 플래그가 false이더라도 writable 플래그가 true이면 프로퍼티 값을 변경할 수 있다!
configurable: false는 플래그 값 변경이나 프로퍼티 삭제를 막기 위해 만들어진 것이며, 프로퍼티 값 변경을 막기 위해 만들어진 게 아니다.

1-5. Object.defineProperties

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

Object.defineProperties(obj, {
  prop1: descriptor1,
  prop2: descriptor2
  // ...
});

프로퍼티 여러 개를 한 번에 정의해보자!

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

1-6. Object.getOwnPropertyDescriptors

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

이 메서드를 Object.defineProperties와 함께 사용하면 객체 복사 시 플래그도 함께 복사할 수 있습니다.

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

지금까진 아래처럼 할당 연산자를 사용해 프로퍼티를 복사하는 방법으로 객체를 복사해 왔다.

for (let key in user) {
  clone[key] = user[key]
}

그런데 이 방법은 플래그는 복사하지 않는다. 플래그 정보도 복사하려면 Object.defineProperties를 사용하자!

위 샘플 코드처럼 for..in을 사용해 객체를 복사하면 심볼형 프로퍼티도 놓치게 된다. 하지만 Object.getOwnPropertyDescriptors는 심볼형 프로퍼티를 포함한 프로퍼티 설명자 전체를 반환한다.

1-7. 객체 수정을 막아주는 다양한 메소드

프로퍼티 설명자는 특정 프로퍼티 하나를 대상으로 한다.

아래 메소드를 사용하면 한 객체 내 프로퍼티 전체를 대상으로 하는 제약사항을 만들 수 있다.

  • Object.preventExtensions(obj): 객체에 새로운 프로퍼티를 추가할 수 없게 한다.
  • Object.seal(obj): 새로운 프로퍼티 추가나 기존 프로퍼티 삭제를 막아준다. 프로퍼티 전체에 configurable: false를 설정하는 것과 동일한 효과다.
  • Object.freeze(obj): 새로운 프로퍼티 추가나 기존 프로퍼티 삭제, 수정을 막아준다. 프로퍼티 전체에 configurable: false, writable: false를 설정하는 것과 동일한 효과다.

아래 메소드는 위 세 가지 메소드를 사용해서 설정한 제약사항을 확인할 때 사용할 수 있다.
단, 실무에서는 잘 사용되지는 않는 메소드들이다.

  • Object.isExtensible(obj): 새로운 프로퍼티를 추가하는 게 불가능한 경우 false를, 그렇지 않은 경우 true를 반환한다.
  • Object.isSealed(obj): 프로퍼티 추가, 삭제가 불가능하고 모든 프로퍼티가 configurable: false이면 true를 반환한다.
  • Object.isFrozen(obj): 프로퍼티 추가, 삭제, 변경이 불가능하고 모든 프로퍼티가 configurable: false, writable: false이면 true를 반환한다.

2. 프로퍼티 getter와 setter

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

  1. 데이터 프로퍼티: 지금까지 사용한 모든 프로퍼티는 데이터 프로퍼티이다.
  2. 접근자 프로퍼티: 새로운 종류의 프로퍼티이며 본질은 함수이다. 이 함수는 값을 획득(get)하고 설정(set)하는 역할을 담당한다.

2-1. getter와 setter

접근자 프로퍼티는 'getter(획득자)'와 ‘setter(설정자)’ 메소드로 표현된다. 객체 리터럴 안에서 getter와 setter 메서드는 getset으로 나타낼 수 있다.

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

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

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

프로퍼티 name과 surname이 있는 객체 user를 만들어보자!

let user = {
  name: "Hun",
  surname: "Seung"
};

이 객체에 fullName 이라는 프로퍼티를 추가해 fullName'Hun Seung'가 되도록 해보자. 기존 값을 복사-붙여넣기 하지 않고 fullName'Hun Seung'가 되도록 하려면 접근자 프로퍼티를 구현하면 된다.

let user = {
  name: "Hun",
  surname: "Seung",

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

console.log(user.fullName); // Hun Seung

바깥 코드에선 접근자 프로퍼티를 일반 프로퍼티처럼 사용할 수 있다. 접근자 프로퍼티를 사용하면 함수처럼 호출 하지 않고, 일반 프로퍼티에서 값에 접근하는 것처럼 평범하게 user.fullName을 사용해 프로퍼티 값을 얻을 수 있다. 나머지 작업은 getter 메소드가 뒷단에서 처리해준다.

한편, 위 예시의 fullName은 getter 메소드만 가지고 있기 때문에 user.fullName=을 사용해 값을 할당하려고 하면 에러가 발생한다.

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

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

user.fullName에 setter 메소드를 추가해 에러가 발생하지 않도록 고쳐보자!

let user = {
  name: "Hun",
  surname: "Seung",

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

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

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

console.log(user.name); // Alice
console.log(user.surname); // Cooper

이렇게 getter와 setter 메소드를 구현하면 객체엔 fullName이라는 '가상’의 프로퍼티가 생긴다. 가상의 프로퍼티는 읽고 쓸 순 있지만 실제로는 존재하지 않는다.

2-2. 접근자 프로퍼티 설명자

데이터 프로퍼티의 설명자와 접근자 프로퍼티의 설명자는 다르다.

접근자 프로퍼티엔 설명자 valuewritable가 없는 대신에 get과 set이라는 함수가 있다.

따라서 접근자 프로퍼티는 다음과 같은 설명자를 갖는다.

  • get – 인수가 없는 함수로, 프로퍼티를 읽을 때 동작함
  • set – 인수가 하나인 함수로, 프로퍼티에 값을 쓸 때 호출됨
  • enumerable – 데이터 프로퍼티와 동일함
  • configurable – 데이터 프로퍼티와 동일함
    아래와 같이 defineProperty에 설명자 getset을 전달하면 fullName을 위한 접근자를 만들 수 있다.
let user = {
  name: "Hun",
  surname: "Seung"
};

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

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

console.log(user.fullName); // Hun Seung

for(let key in user) console.log(key); // name, surname

프로퍼티는 접근자 프로퍼티(get/set 메서드를 가짐)나 데이터 프로퍼티(value를 가짐) 중 한 종류에만 속하고 둘 다에 속할 수 없다는 점을 기억하자!

한 프로퍼티에 getvalue를 동시에 설정하면 에러가 발생한다.

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

  value: 2
});

2-3. getter와 setter 똑똑하게 활용하기

getter와 setter를 ‘실제’ 프로퍼티 값을 감싸는 래퍼(wrapper)처럼 사용하면, 프로퍼티 값을 원하는 대로 통제할 수 있다.

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

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

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

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

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

user의 이름은 _name에 저장되고, 프로퍼티에 접근하는 것은 getter와 setter를 통해 이뤄진다.

기술적으론 외부 코드에서 user._name을 사용해 이름에 바로 접근할 수 있다. 그러나 밑줄 "_" 로 시작하는 프로퍼티는 객체 내부에서만 활용하고, 외부에서는 건드리지 않는 것이 관습이다.

2-4. 호환성을 위해 사용하기

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

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

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

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

console.log( Hun.age ); // 25

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

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

let Hun = new User("Hun", new Date(1996, 27, 2));

이렇게 생성자 함수를 수정하면 기존 코드 중 프로퍼티 age를 사용하고 있는 코드도 수정해 줘야 한다.

age가 사용되는 부분을 모두 찾아서 수정하는 것도 가능하지만, 시간이 오래 걸리며 여러 사람이 age를 사용하고 있다면 모두 찾아 수정하는 것 자체가 힘들다. 게다가 ageuser 안에 있어도 나쁠 것이 없는 프로퍼티이기도 하다.

기존 코드들은 그대로 두도록 하는 대신 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 Hun = new User("Hun", new Date(1996, 27, 2));

console.log( Hun.birthday ); // birthday를 사용할 수 있다.
console.log( Hun.age );      // age 역시 사용할 수 있다.
profile
잘 할 수 있는 개발자가 되기 위하여

0개의 댓글