객체의 이해

이효범·2022년 5월 2일
1
post-thumbnail

자바스크립트가 많은 참조 타입을 제공하고 있음에도 불구하고 개발자가 자신만의 객체를 만들어야 할 상황은 자주 찾아온다. 객체를 만들 때, 자바스크립트의 객체는 동적이기 때문에 언제라도 바꿀 수 있다는 사실을 명심해야 한다.

자바스크립트 프로그래밍 작업의 상당 부분은 이러한 객체를 다루는 것이므로 자바스크립트 전체를 이해하려면 객체의 동작 원리를 이해해야 한다.

프로퍼티 정의

객체를 만드는 방법은 두 가지가 있다. 첫 번째는 Object 생성자를 사용하는 방법이고, 두 번째는 객체 리터럴을 사용하는 방법이다. 다음 예시를 보자.

let person1 = {
 name: "Nicholas" 
}

let person2 = new Obejct();
person2.name = "Nicholas"

perseon1.age = "Redacted";
perseon2.age = "Redacted";

perseon1.name = "Greg";
perseon2.name = "Michael";

person1과 person2에는 둘 다 name이라는 프로퍼티가 있다. 위 예시의 아래쪽 코드에서 두 객체에 age라는 프로퍼티를 추가했다. 이 작업은 객체를 정의한 직후나, 한참이 지난 뒤에 언제든 할 수 있다. 작성한 객체는 일부러 막아두지 않는 이상 언제든 수정할 수 있다.
예제 마지막 부분에서는 각 객체 name 프로퍼티의 값을 수정했다. 프로퍼티의 값도 언제든 수정할 수 있다.

자바스크립트는 프로퍼티를 처음 객체에 추가할 때 객체에 있는 [[Put]] 이라는 내부 메소드를 호출한다. [[Put]] 메소드는 객체에 프로퍼티 저장 공간을 생성한다. 이 과정은 해시 테이블에 처름 키를 추가하는 것과 비슷하다. 이 동작은 수행하면 초기값은 물론 프로퍼티의 속성도 설정한다. 따라서 앞 예제에서는 각 객체에 name과 age 프로퍼티가 처음 정의될 때마다 [[Put]] 메소드가 호출된다.

[[Put]]을 호출하면 객체에 고유 프로퍼티(own property)가 만들어진다. 고유 프로퍼티는 객체의 특정 인스턴스에 속해있으며 인스턴스에 바로 저장된다. 또한 프로퍼티에 동작을 수행하려면 소유 객체를 거쳐야 한다.

고유 프로퍼티는 프로토타입 프로퍼티(prototype property)와 다르다. 두 프로퍼티의 차이점에 대해서는 다음 포스팅에서 다루도록 하겠다.

기존 프로퍼티에 새 값을 할당할 때는 [[Set]]이 호출된다. [[Set]]은 프로퍼티의 현재 값을 새 값으로 교체한다. 앞 예제에서는 두 번째로 name에 값을 할당할 때 [[Set]]이 호출된다.

첫 번째 부분에서는 객체 리터럴을 사용해 person1 객체를 생성했다. 이때 묵시적으로 [[Put]]이 실행되어 name 프로퍼티가 추가된다. person1.age에 값을 할당하면 [[Put]]이 실행되어 age 프로퍼티를 추가한다. 하지만 person1.name에 "Greg" 이라는 새 값을 할당할 때는 [[Set]]이 name 프로퍼티에 실행되어 프로퍼티의 기존 값을 새로운 값으로 대체한다.


프로퍼티 탐지

프로퍼티는 언제든 추가할 수 있기 때문에 객체에 프로퍼티가 있는지 확인해야 할 때도 있다. 자바스크립트에 익숙하지 않은 개발자는 종종 다음과 같은 방식으로 객체에 프로퍼티가 있는지 확인한다.

// 정확하지 않은 방식
if (person1.age) {
 // age를 사용해 무언가 실행한다. 
}

이 방식의 문제점은 자바스크립트의 타입 강제변환(coercion)이 결과에 영향을 끼친다는 것이다. if 조건문은 주어진 값이 참스러운 값(truthy vale: 객체, 비어있지 않은 문자열, 0이 아닌 숫자, true)이면 true로 취급하고 주어진 값이 거짓스러운 값(falsy value: null, undefined, 0, false, NaN, 빈 문자열)이면 false로 취급한다.
객체 프로퍼티에는 거짓스러운 값이 저장될 수도 있기 때문에 예제 코드와 같은 방식은 잘못 실행될 수 있다.
예를 들어 person1.age의 값이 0이라면 프로퍼티가 있어오 if 조건문은 실행되지 않을 것이다. 더 정확하게 프로퍼티의 존재 여부를 확인하려면 in 연산자를 사용하는 것이 좋다.

in 연산자는 특정 객체에 주어진 이름의 프로퍼티가 존재하는지 확인하고 존재하면 true를 반환한다. 사실 in 연산자는 해시 테이블에 주어진 키가 있는지 확인한다. 다음은 in 연산자를 사용해 person1 객체에 프로퍼티가 있는지 확인하는 예제이다.

console.log("name" in person1); // true
console.log("age" in person1); // true
console.log("title" in person1); // false

자바스크립트에서 메소드는 함수를 참조하고 있는 프로퍼티이므로 메소드의 존재 여부도 같은 방식으로 확인할 수 있다. 다음은 person1에 sayName()이라는 새 함수를 추가하고 in 연산자를 사용해 함수가 있는지 확인하는 코드이다.

let person1 = {
  name: "Nicholas",
  sayName: function() {
  	console.log(this.name);
  }
};

console.log("sayName" in person1);  // true

대개 객체에 특정 프로퍼티가 있는지 확인할 때는 in 연산자를 사용하는 것이 가장 좋다. 이 방식을 사용하면 프로퍼티의 값을 확인하지 않는다는 이점도 있다. 프로퍼티의 값을 확인하는 과정에서 성능 문제가 생기거나 에러가 발생할 수 있기 때문이다.

그런데 때로는 프로퍼티의 존재 여부를 확인하는 것이 그치지 않고 해당 프로퍼티가 객체의 고유 프로퍼티인지도 확인해야 한다. in 연산자는 고유 프로퍼티와 프로토타입 프로퍼티 둘 다 찾기 때문에 고유 프로퍼티인지 확인하고 싶다면 다른 방법을 동원해야 한다. 모든 객체에 포함되어 있는 hasOwnProperty() 메소드는 주어진 프로퍼티가 객체에 존재하는 동시에 고유 프로퍼티일 때만 true를 반환한다. 예를 들어 다음 코드는 person1의 여러 프로퍼티에 대해 in 연산자를 사용했을 대와 hasOwnProperty()를 사용했을 때 결과가 어떻게 달라지는지 보여준다.

let person1 = {
  name: "Nicholas",
  sayName: function() {
   console.log(this.name); 
  }
};

console.log("name" in person1);  // true
console.log(person1.hasOwnProperty("name"));  // true

console.log("toString" in person1);  // true
console.log(person1.hasOwnProperty("toString"));  // false

이 예제에서 name은 person1의 고유 프로퍼티이므로 in 연산자와 hasOwnProperty()는 둘 다 true를 반환한다. 하지만 toString() 메소드는 모든 객체에 포함되어 있는 프로토타입 프로퍼티이므로 in 연산자는 true를 반환하지만 hasOwnProperty()는 false를 반환한다.
이러한 차이는 꽤 중요하므로 다음 포스팅에서 더 깊이 살펴보도록 하겠다.


프로퍼티 제거

객체에는 언제든 프로퍼티를 추가할 수 있고, 객체에 있는 프로퍼티는 언제든 제거할 수 있다. 프로퍼티의 값을 null로 바꾸는 것만으로는 프로퍼티가 객체에서 완전히 제거되지 않는다. 값을 null로 바꾸는 동작은 [[Set]] 내부 메소드를 null과 함께 실행하므로 앞에서 보았듯 프로퍼티의 값만 달라지게 된다. 객체에서 프로퍼티를 완전히 제거할 때는 delete 연산자를 사용해야 한다.

객체 프로퍼티에 delete 연산자를 사용하면 내부적으로 [[Delete]]가 호출된다.
이 동작은 해시 테이블에서 키/값 쌍을 없애는 것으로 볼 수 있다. delete 연산자는 무사히 실행을 마쳤을 때 true를 반환한다(일부 프로퍼티는 제거할 수 없는 경우가 있는데, 이에 대해서는 나중에 다루겠다). 다음은 delete 코드가 어떻게 동작하는지 보여주는 예제 코드이다.

let person1 = {
 name: "Nicholas" 
};

console.log("name" in person1); // true

delete person1.name;   // true - 출력되지는 않음
console.log("name" in person1); // false
console.log(person1.name);  // undefined

이 예제에서 name 프로퍼티는 person1 객체에서 삭제됐다. 삭제하고 난 후의 in 연산자를 사용해보면 false를 반환한다. 존재하지 않는 프로퍼티에 접근하면 undefined가 반환된다는 것도 주목해야 할 부분이다.
즉 "name" 프로퍼티를 제거(delete)하면 person1에서 프로퍼티가 완전히 사라진다.


열거

객체에 추가하는 프로퍼티는 기본적으로 열거(enumerable)가 가능하다.
즉 for-in 반복문을 사용해 훑을 수 있다. 열거 가능 프로퍼티에는 [[Enumerable]]이라는 내부 속성이 true로 설정되어 있다. for-in 반복문을 실행하면 객체에 있는 프로퍼티 중 열거 가능한 것을 훑는데 이때 프로퍼티의 이름을 변수에 할당한다. 다음은 반복문을 통해 객체의 이름과 값을 출력하는 예제이다.

let property;

for (property in object) {
  console.log("이름: " + property);
  console.log("값: " + object[property]);
}

for-in 반목문을 실행하는 동안 매 주기마다 property 변수에는 다음에 살펴볼 열거 가능 프로퍼티의 이름이 할당되며 이 동작은 프로퍼티를 전부 다 훑을 때까지 계속된다. 프로퍼티를 다 훑고 나면 반복문이 종료되고 그 뒤의 코드가 실행된다. 이 예제는 각괄호 표기법을 사용해 객체 프로퍼티의 값을 가져온 후 그 값을 콘솔에 출력했다. 자바스크립트에서 각괄호 표기법을 사용해야 할 대표적인 사례이다.

객체 프로퍼티의 목록을 가져와야 한다면 ECMAScript 5에서 도입된 Object.keys() 메소드가 유용하다. 이 메소드를 실행하면 다음에서 보듯 열거 가능한 객체의 이름을 배열로 구성하여 반환한다.

let properties = Object.keys(object);

// for-in 반목문의 동작을 흉내 내고 싶을 때
let i, len;

for (i = 0, len = properties.length; i < len; i++) {
   console.log("Name: " + properties[i]);
   console.log("Value: " + object[properties[i]]);
}

이 예제에서는 Object.keys()를 사용해 특정 객체에 있는 열거 가능한 프로퍼티의 목록을 가져왔다. 이 후 for 반복문을 사용해 프로퍼티 목록을 훑으며 프로퍼티의 이름과 값을 출력했다. 일반적으로 프로퍼티 이름 목록을 배열 형태로 다루고 싶을 때는 Object.keys()를 사용하고 굳이 배열이 필요 없을 때는 for-in을 사용한다.

for-in 반복문에서 반환하는 열거 가능한 프로퍼티와 Object.keys()에서 반환하는 열거 가능 프로퍼티는 조금 다르다. for-in 반복문에서는 열거 가능한 프로토타입 프로퍼티도 반환하지만 Object.keys()는 고유 프로퍼티만 반환한다. 프로토타입 프로퍼티와 고유 프로퍼티의 차이점은 다음 포스팅에서 다루겠다

모든 프로퍼티를 열거할 수 있는 것은 아니다. 사실 객체의 네이티브 메소드는 대부분 [[Enumerable]] 속성이 false로 설정되어 있다. 특정 프로퍼티가 열거 가능한지 확인할 때는 propertyIsEnumerable() 메소드를 사용하면 된다. 모든 객체에 포함되어 있다.

let person1 = {
 name: "Nicholas" 
}

console.log("name" in person1);  // true
console.log(person1.propertyIsEnumerable("name"));  // true

let properties = Object.keys(person1);

console.log("length" in properties);  // true
console.log(properties.propertyIsEnumerable("length"));  // false

이 예제에서 name은 person1에 직접 추가한 프로퍼티이므로 열거 가능하다. 반면 properties 배열의 length 프로퍼티는 Array.prototype의 내장 프로퍼티이므로 열거 가능하지 않다. 나중에 알게 되겠지만 프로퍼티는 대부분 기본적으로 열거 가능하지 않다.


프로퍼티 종류

프로퍼티는 데이터 프로퍼티와 접근자 프로퍼티로 구분한다.
데이터 프로퍼티는 앞에서 살펴본 예제의 name 프로퍼티처럼 값을 포함하고 있다. 데이터 프로퍼티는 [[Put]] 메소드의 기본 동작을 통해 생성되며 이 장에서 지금까지 살펴본 예제는 모두 데이터 프로퍼티를 사용한 것이었다. 접근자 프로퍼티는 값을 포함하지 않는 대신 프로퍼티를 읽었을 때 호출할 함수(게터(getter)라고 부른다)와 값을 설정할 때 호출할 함수(세터(setter)라고 부른다)를 정의한다. 접근자 프로퍼티는 게터 또는 세터 둘 중 하나만 사용할 수도 있고 둘 다 사용할 수도 있다.
다음 예제는 객체 리터럴을 사용해 접근자 프로퍼티를 정의하는 특수한 문법을 사용한다.

let person1 = {
  _name: "Nicholas",
  
  get name() {
  	console.log("name 읽는 중");
    return this._name;
  },
  
  set name(value) {
    console.log("name의 값을 %s로 설정하는 중", value);
    this._name = value;
  }
};

console.log(person1.name);  // "name 읽는 중" 출력 후 "Nicholas" 출력

person1.name = "Greg";
console.log(person1.name); // "name의 값을 Greg로 설정하는 중" 출력 후 "Greg" 출력

이 예제는 name이라는 접근자 프로퍼티를 정의하고 있다. 접근자 프로퍼티에서 사용할 실제 값은 _name이라는 데이터 프로퍼티에 저장한다(프로퍼티 이름을 언더스코어 문자로 시작하면 이를 비공개 프로퍼티처럼 취급하겠다는 뜻이 된다. 하지만 실제로는 공개 프로퍼티이다). name의 게터와 세터를 정의할 때는 function 키워드가 없다는 점만 제외하면 함수를 정의하는 문법과 비슷하다. get 또는 set이라는 특수한 키워드를 접근자 프로퍼티 이름 앞에 사용하고 이름 뒤에는 괄호와 함수 코드를 입력한다. 게터는 값을 반환해야 하고 세터는 프로퍼티에 할당할 값을 인수로 전달받을 수 있어야 한다.

예제에서는 프로퍼티 데이터를 저장하기 위해 _name을 사용했는데 사실 동작 자체는 다른 변수나 객체에 데이터를 저장하는 것과 크게 다르지 않다. 예제 코드는 프로퍼티를 다루는 한편 콘솔에 기록도 남기도록 작성되었다. 단순히 다른 프로퍼티에 데이터를 저장했다가 가져오는 정도만 수행한다면 대개는 접근자 프로퍼티 대신 해당 프로퍼티에 직접 데이터를 저장한다. 접근자 프로퍼티는 값을 할당할 때, 어떤 동작을 추가로 더 수행하고자 할 때, 또는 값을 읽을 때 추가적인 계산을 통해 반환 값을 만들어야 할 때 유용하다.

반드시 게터와 세터를 둘 다 정의할 필요는 없으며, 둘 중 하나만 정의해도 상관없다. 게터만 정의하면 읽기 전용 프로퍼티가 되므로 이 프로퍼티에 값을 할당하려고 하면 엄격하지 않은 모드에서는 조용히 아무 일도 일어나지 않으며 엄격한 모드(strict mode)에서는 에러가 발생한다. 세터만 정의하면 쓰기 전용 프로퍼티가 되므로 값을 가져오려고 하면 엄격하지 않은 모드에서는 조용히 아무 일도 일어나지 않으며 엄격한 모드에서는 에러가 발생한다.


프로퍼티 속성

ECMAScript 5 명세 전에는 프로퍼티의 열거 가능 여부를 설정할 수 있는 방법이 없었다.
사실 프로퍼티의 내부 속성에 접근할 방법 자체가 없었다는 말이 더 정확하다. ECMAScript 5에서는 프로퍼티 속성을 바로 다룰 수 있는 방법이 몇 가지 추가되었고 여러 기능을 지원할 수 있는 속성도 몇 가지 추가되었다. 덕분에 이제는 자바스크립트의 네이티브 프로퍼티와 똑같이 동작하는 프로퍼티도 작성할 수 있다. 이 글에서는 데이터 프로퍼티와 접근자 프로퍼티의 속성에 대해 자세히 알아볼 것이다. 먼저 두 프로퍼티가 공통적으로 가진 속성에 대해 살펴보자.

공통 속성

데이터 프로퍼티와 접근자 프로퍼티의 공통 속성은 두 가지이다. 하나는 [[Enumerable]]인데 프로퍼티가 열거 가능한지 정하는 속성이고 다른 하나는 [[Configurable]]로서 프로퍼티를 변경할 수 있는지 정하는 속성이다. [[Configurable]] 속성이 있는 설정 가능 프로퍼티(configurable property)는 delete 연산자를 사용해 언제든 제거할 수 있고 프로퍼티의 속성도 언제든 변경할 수 있다. 다시 말해 설정 가능 프로퍼티는 데이터 프로퍼티를 접근자 프로퍼티로 바꾸거나 그 반대로 바꾸는 것도 가능하다. 객체의 모든 프로퍼티는 따로 설정을 하지 않는 한 열거 가능하며 설정 가능하다.

프로퍼티 속성을 바꾸고 싶을 때는 Object.defineProperty() 메소드를 사용할 수 있다. Object.defineProperty() 메소드에는 인수를 세 개 전달하는데 첫 번째 인수는 프로퍼티를 소유하고 있는 객체이고 두 번째 인수는 프로퍼티 이름, 세 번째 인수는 설정할 프로퍼티 속성 값을 포함하고 있는 프로퍼티 서술자(property descriptor)객체이다. 서술자 객체에는 설정할 내부 속성의 이름을 각괄호 없이 사용하면 된다. 따라서 enumerable을 사용하면 [[Enumerable]]을 설정하고 configurable을 사용하면 [[Configurable]]을 설정한다. 다음은 열거 불가능하며 설정 불가능한 객체 프로퍼티를 작성하는 예제이다.

let person1 = {
 name: "Nicholas" 
}

Object.defineProperty(person1, "name", {
  enumerable: false
});

console.log("name" in person1);  // true
console.log(person1.propertyIsEnumerable("name"));  // false

let properties = Object.keys(person1);
console.log(properties.length); // 0

Object.defineProperty(person1, "name", {
  configurable: false
});

// 프로퍼티 제거 시도
delete person1.name;
console.log("name" in person1);  // true
console.log(person1.name);  // "Nicholas"

Object.defineProperty(person1, "name", {   // 에러 !
  configurable: true
});

name 프로퍼티는 평법하게 정의되었지만 이후 [[Enumerable]] 속성을 false로 설정했다. 따라서 propertyIsEnumerable() 메소드는 새로 변경된 [[Enumerable]]의 값을 참조하여 false를 반환한다. 그 후에는 name 프로퍼티를 설정 불가능하도록 만들었다. 이제 name 속성은 수정할 수 없는 프로퍼티이기 때문에 제거하려고 해도 제거할 수 없다. 따라서 person1에는 name이 그대로 남아있게 된다. 이때는 name에 Object.defineProperty()를 다시 실행해도 프로퍼티의 속성을 변경할 수 없다. name은 person1의 프로퍼티로 사실상 고정되어버린 셈이다.

마지막 부분에서는 name을 다시 설정 가능한 프로퍼티로 바꾸려 했으나, 이때 name은 설정 불가능한 프로퍼티이기 때문에 설정 가능하도록 바꾸려고 시도하면 에러가 발생한다. 이때는 데이터 프로퍼티를 접근자 프로퍼티로 바꾸거나 접근자 프로퍼티를 데이터 프로퍼티로 바꾸려 해도 에러가 발생한다.

자바스크립트는 엄격한 모드로 실행할 때는 설정 불가능한 프로퍼티를 제거하려고 하면 에러가 발생한다. 엄격하지 않은 모드에서도 동작은 실패하지만 대신 에러도 나타나지 않는다.

데이터 프로퍼티 속성

데이터 프로퍼티에는 접근자 프로퍼티에 없는 두 종류의 내부 속성이 있다. 첫 번째는 [[Value]] 인데 프로퍼티의 값을 저장하고 있다. 객체에 프로퍼티를 만들면 자동으로 이 속성에 값이 저장된다. 프로퍼티의 값은 심지어 값이 함수일 때도 모두 [[Value]]에 저장된다.

두 번째 속성은 [[Writable]] 이다. 이 속성은 프로퍼티에 값을 저장할 수 있는지 정의하는 불리언 값이다. 따로 설정하지 않으면 객체의 모든 프로퍼티는 프로퍼티에 값을 저장할 수 있도록 설정된다.

앞서 살펴본 두 속성과 Object.defineProperty()를 사용하면 데이터 프로퍼티를 정의할 수 있으며, 이 방식은 프로퍼티가 실제로 존재하지 않는 경우에도 사용할 수 있다. 다음과 같은 코드가 있다고 생각해보자.

let person1 = {
 name: "Nicholas" 
}

계속 보아왔던 이 예제는 person1이라는 새로운 객체를 만들면서 name 프로퍼티를 정의하고 프로퍼티의 값을 설정한다. 다음은 위 코드와 실행 결과가 똑같지만 조금 더 복잡해 보이는 코드이다.

let person1 = {};

Object.defineProperty(person1, "name", {
  value: "Nicholas",
  enumerable: true,
  configurable: true,
  writable: true
});

Object.defineProperty()를 실행하면 먼저 해당 프로퍼티가 있는지 확인한다. 프로퍼티가 없다면 새 프로퍼티를 추가하고 프로퍼티 서술 객체에서 정의한 대로 속성을 설정한다. 이 예제에서 name은 person1의 프로퍼티가 아니었기 때문에 새로 작성된다.

Object.defineProperty()를 사용해 새 프로퍼티를 정의할 때 서술자에 없는 속성은 모두 false로 설정된다. 따라서 필요한 속성은 반드시 서술자에 포함시켜 두어야 한다. 다음 예제는 Object.defineProperty()를 호출할 때 명시적으로 true라고 설정한 속성이 없었기 때문에 열거 불가능, 설정 불가능, 쓰기 불가능한 name 프로퍼티를 만든다.

let person1 = {};

Object.defineProperty(person1, "name", {
  value: "Nicholas",
});

console.log("name" in person1);  // true
console.log(person1.propertyIsEnumerable("name"));  // false

delete person1.name;
console.log("name" in person1);  // true

person1.name = "Greg";
console.log(person1.name); // "Nicholas"

이 코드에서 name 프로퍼티로는 값을 읽어오는 것만 할 수 있다. 다른 작업은 모두 속성이 차단했기 때문이다. 이미 존재하는 프로퍼티는 미리 수정할 수 있도록 설정해둔 프로퍼티만 바꿀 수 있다.

엄격한 모드에서 쓰기 불가능한 프로퍼티의 값을 바꾸려고 하면 에러가 발생한다. 엄격하지 않은 모드에서는 값도 바뀌지 않고 아무런 일도 일어나지 않는다.

접근자 프로퍼티 속성

접근자 프로퍼티에만 필요한 속성도 두 가지가 있다. 접근자 프로퍼티는 저장할 값이 없으므로 [[Value]]나 [[Writable]] 속성은 필요 없는 대신 각각 게터 함수와 세터 함수를 나타내는 [[Get]] [[Set]] 속성이 필요하다. 게터와 세터를 리터럴 형식으로 정의할 때처럼 프로퍼티를 생성할 때는 두 속성 중 하나만 정의해도 상관없다.

한 프로퍼티에 데이터 프로퍼티 속성과 접근자 프로퍼티 속성을 둘 다 설정하려고 하면 에러가 발생한다.

접근자 프로퍼티를 정의할 때 객체 리터럴 형식 대신 접근자 프로퍼티 속성을 사용하면 기존에 있던 객체에도 프로퍼티를 추가할 수 있다는 장점이 있다. 접근자 프로퍼티를 정의할 때 객체 리터럴 형식을 사용하려면 객체를 생성할 때 프로퍼티도 같이 만드는 수밖에 없다.

데이터 프로퍼티와 마찬가지로 접근자 프로퍼티도 설정 가능 여부와 열거 가능 여부를 설정할 수 있다. 앞서 살펴봤던 예제를 떠올려보자.

let person1 = {
  _name: "Nicholas",
  
  get name() {
  	console.log("name 읽는 중");
    return this._name;
  },
  
  set name(value) {
    console.log("name의 값을 %s로 설정하는 중", value);
    this._name = value;
  }
};

이 코드는 다음과 같이 작성할 수 있다.

let person1 = {
  _name: "Nicholas"
};

Object.defineProperty(person1, "name", {
  get: function() {
  	console.log("name 읽는 중");
    return this._name;
  },
  set: function(value) {
    console.log("name의 값을 %s로 설정하는 중", value);
    this._name = value;
  },
  enumerable: true,
  configurable: true,
});

Object.defineProperty()에 전달된 객체에 있는 get키와 set키는 값이 함수인 데이터 프로퍼티이다. 이 객체에서는 객체 리터럴 형태의 접근자를 사용할 수 없다.

접근자 프로퍼티에도 [[Enumerable]]이나 [[Configurable]]과 같은 속성을 설정하여 프로퍼티의 작동 방식을 조정할 수 있다. 예를 들어 설정 불가능, 열거 불가능, 쓰기 불가능한 프로퍼티를 만들고 싶다면 다음과 같이 작성한다.

let person1 = {
  _name: "Nicholas"
};

Object.defineProperty(person1, "name", {
  get: function() {
  	console.log("name 읽는 중");
    return this._name;
  },
});

console.log("name" in person1);  // true
console.log(person1.propertyIsEnumerable("name")); // false
delete person1.name;
console.log("name" in person1);  // true
person1.name = "Greg";
console.log(person1.name);  // "Nicholas"

이 코드에서 name 프로퍼티는 게터만 정의된 접근자 프로퍼티이다. 세터도 없고 명시적으로 true라고 설정된 속성도 없으므로 이 프로퍼티는 읽기 전용으로만 사용할 수 있으며 값을 수정하거나 속성을 변경할 수 없다.

객체 리터럴 형식을 통해 접근자 프로퍼티를 정의할 때처럼 세터가 없는 접근자 프로퍼티에 값을 변경하려고 하면 엄격한 모드에서 에러가 발생한다. 엄격하지 않은 모드라면 아무런 변화가 없을 뿐이다. 반면 세터만 있는 접근자 프로퍼티의 값을 읽으면 항상 undefined가 반환된다.

여러 프로퍼티 정의하기

Object.defineProperty() 대신 Object.defineProperties()를 사용하면 동시에 여러 프로퍼티를 설정할 수 있다. Object.defineProperties() 메소드에는 인수를 두 개 전달한다. 첫 번째 인수는 대상 객체이고 두 번째 인수는 정의할 프로퍼티의 정보를 담고 있는 객체이다. 두 번째 인수의 키는 프로퍼티 이름이며 값은 해당 프로퍼티의 속성을 정의하는 프로퍼티 서술 객체이다. 다음은 프로퍼티 두 개를 정의하는 예제이다.

let person1 = {};

Object.defineProperties(person1, {
  // 데이터를 정의할 데이터 프로퍼티
  _name: {
   value: "Nicholas",
   enumerable: true,
   configurable: true,
   writable: true
  },
  
  // 접근자 프로퍼티
  name: {
    get: function() {
  	   console.log("name 읽는 중");
       return this._name;
    },
    set: function(value) {
       console.log("name의 값을 %s로 설정하는 중", value);
       this._name = value;
    },
    enumerable: true,
    configurable: true,
  }
});

이 예제는 정보를 저장할 _name 이라는 데이터 프로퍼티와 name이라는 접근자 프로퍼티를 정의한다. Object.defineProperties()를 사용하면 프로퍼티를 몇 개든 정의할 수 있으며 기존 프로퍼티 수정과 새 프로퍼티 추가를 동시에 수행할 수도 있다. 이 메소드는 Object.defineProperty()를 여러 번 실행한 것과 같은 효과를 낸다.

프로퍼티 속성 가져오기

프로퍼티 속성을 가져오고 싶을 때는 Object.getOwnPropertyDescription() 메소드를 사용한다. 이 메소드는 이름에서 보듯 고유 프로퍼티에만 사용할 수 있다. 이 메소드에 전달하는 인수는 두 개인데 첫 번째 인수는 대상 객체이고 두 번째는 정도를 가져올 프로퍼티의 이름이다. 인수로 이름을 전달한 프로퍼티가 존재하면 프로퍼티의 속성 정보를 포함한 객체가 반환되며 이 객체에는 configurable, enumerable을 비롯한 네 종류의 키가 설정되어 있다. 나머지 두 개는 프로퍼티의 종류에 따라 달라진다. 다음 코드는 프로퍼티를 생성한 후 이 프로퍼티의 속성을 확인한다.

let person1 = {
 name: "Nicholas" 
}

let descriptor = Object.getOwnPropertyDescription(person1, "name");

console.log(descriptor.enumerable);  // true
console.log(descriptor.configurable);  // true
console.log(descriptor.writable);  // true
console.log(descriptor.value);  // "Nicholas"

이 코드에서 name 프로퍼티는 객체 리터럴을 사용해 정의되었다. Object.getOwnPropertyDescription()를 호출하며 name 프로퍼티의 정보를 살펴보면
enumerable, configurable, writable, value 프로퍼티를 포함하고 있는 객체가 반환된다. Object.defineProperty()를 통해 명시적으로 설정하지 않은 속성인데도 기본 값을 사용해 설정되어 있는 것이다.


객체 수정 방지

객체에도 프로퍼티와 마찬가지로 객체의 동작을 제어하는 내부 속성이 있다. 속성 중 하나인 [[Extensible]]은 객체 자체의 수정 가능 여부를 가리키는 불리언 값이다. 우리가 작성하는 모든 객체는 기본적으로 이 속성이 켜져 있는 확장 가능한(extensible) 객체이다. 확장 가능하다는 말은 객체에 언제라도 새 프로퍼티를 추가할 수 있다는 뜻이다. 확장 가능한 객체는 이미 앞에서 여러 차례 살펴보았다. [[Extensible]]을 false로 설정하면 객체에 새 프로퍼티를 추가할 수 없다. 객체를 수정할 수 없도록 만드는 방법은 세 가지로 나누어진다.

확장 방지

Object.preventExtensions()를 사용하면 확장 불가능한 객체를 만들 수 있다. 이 메소드에는 확장 불가능하게 만들 객체를 첫 번째 인수로 전달하며, 이 메소드를 실행하고 난 후에는 인수로 전달된 객체에 새 프로퍼티를 추가할 수 없다. Object.isExtensible()을 사용하면 [[Extensible]]의 값을 확인할 수 있다. 다음은 두 메소드의 사용법을 보여주는 예시이다.

let person1 = {
 name: "Nicholas" 
};

console.log(Object.isExtensible(person1));  // true

Object.preventExtensions(person1);
console.log(Object.isExtensible(person1));  // false

person1.sayName = function () {
 console.log(this.name);
}

console.log("sayName" in person1); // false

이 예제에서는 person1 객체를 만든 후에 객체의 [[Extensible]] 속성을 확인한 다음, 이 객체를 후정할 수 없게 만들었다. 이제 person1은 확장 불가능한 객체가 되었으므로 sayName() 메소드를 추가할 수 없다.

엄격한 모드에서 확장 불가능한 객체에 프로퍼티를 추가하려고 하면 에러가 발생한다. 엄격하지 않은 모드에서는 추가도 되지 않고 에러도 발생하지 않는다. 객체를 잘못 다루었을 대 문제가 있다는 사실을 알아챌 수 있도록 확장 불가능한 객체를 다룰 때는 엄격한 모드를 사용하는 것이 좋다.

객체 봉인

확장 불가능한 객체를 만드는 두 번째 방법은 객체를 봉인(seal)하는 것이다. 봉인된 객체는 확장 불가능하며 이 객체의 모든 프로퍼티는 설정 불가능 상태가 된다.
다시 말해 봉인된 객체에는 새 프로퍼티를 추가할 수도 없고 이미 있는 프로퍼티를 제거하거나 프로퍼티의 종류를 변경(데이터 프로퍼티를 접근자 프로퍼티로, 또는 그 반대로)하는 것조차 할 수 없다는 뜻이다. 봉인된 객체는 객체의 프로퍼티를 읽고 쓰는 것만 가능하다.

객체를 봉인할 때는 Object.seal() 메소드를 원하는 객체에 사용하면 된다. 이 메소드를 실행하면 객체의 [[Extensible]] 속성이 false로 설정되는 한편 모든 프로거티의 [[Configurable]] 속성이 false로 설정된다.
다음은 Object.isSealed()를 사용해 객체가 봉인되었는지 확인하는 예제이다.

let person1 = {
 name: "Nicholas" 
}

console.log(Object.isExtensible(person1)); // true
console.log(Object.isSealed(person1));  // false

Object.seal(person1);
console.log(Object.isExtensible(person1)); // false
console.log(Object.isSealed(person1));  // true

person1.sayName = function() {
  console.log(this.name);
};

console.log("sayName" in person1); // false

person1.name = "Greg"; 
console.log(person1.name); // "Greg"

delete person1.name;
console.log("sayName" in person1); // true
console.log(person1.name);  // "Greg"

let descriptor = Object.getOwnPropertyDescriptor(person1, "name");
console.log(descriptor.configurable); // false

이 코드는 person1 객체를 봉인해서 프로퍼티를 추가하거나 제거할 수 없게 만든다. 봉인된 객체는 확장 불가능하기 때문에 Object.isExtensible()을 person1에 사용해보면 false를 반환하며, sayName() 메소드를 추가하려고 해도 추가할 수 없다. person1.name의 값은 바꿀 수 있지만 프로퍼티 자체는 삭제할 수 없다.

자바나 C++에 익숙한 사람이라면 봉인 객체도 익숙할 것이다. 자바나 C++에서는 클래스를 기반으로 새 객체 인스턴스를 만들고 나면 새 객체에는 프로퍼티를 추가할 수 없다. 하지만 객체의 프로퍼티가 다른 객체를 포함하고 있다면 포함된 객체는 수정할 수 있다. 사실 봉인 객체는 클래스가 없는 자바스크립트에서 개발자가 다른 언어와 동일한 수준의 제어를 할 수 있도록 지원하는 자바스크립트 공유의 방식이다.

객체를 잘못 사용했을 때 알아챌 수 있도록 봉인된 객체를 다룰 때는 엄격한 모드를 사용하는 것이 좋다.

객체 동결

확장 불가능한 객체를 만드는 마지막 방법은 객체를 동결(freeze)시키는 것이다. 동결된 객체에는 프로퍼티를 추가하거나 제거할 수 없으면 프로퍼티의 종류를 변경할 수도 없고 데이터 프로퍼티에 값을 저장하는 것도 불가능하다. 간단히 말해 동결 객체는 봉인 객체의 성질에 더해 데이터 프로퍼티를 읽기 전용으로 만든 것이라고 볼 수 있다. 동결 객체는 다시 해제할 수 없기 때문에 동결된 객체는 동결될 때의 상태 그대로 보존된다. 객체를 동결 시킬 때는 Obejct.freeze()를, 동결되었는지 확인할 때는 Object.isFrozen()을 사용한다. 다음 예제를 보자.

let person1 = {
 name: "Nicholas" 
}

console.log(Object.isExtensible(person1)); // true
console.log(Object.isSealed(person1));  // false
console.log(Object.isFrozen(person1));  // false

Object.freeze(person1);
console.log(Object.isExtensible(person1)); // false
console.log(Object.isSealed(person1));  // true
console.log(Object.isFrozen(person1));  // true

person1.sayName = function() {
  console.log(this.name);
};

console.log("sayName" in person1); // false

person1.name = "Greg"; 
console.log(person1.name); // "Nicholas"

delete person1.name;
console.log("sayName" in person1); // true
console.log(person1.name);  // "Nicholas"

let descriptor = Object.getOwnPropertyDescriptor(person1, "name");
console.log(descriptor.configurable); // false
console.log(descriptor.writable); // false

이 예제에서 person1은 동결된 객체이다. 동결 객체는 확장 불가능하며 봉인된 것과 같으므로 Object.isExtensible()은 false를 반환하고, Object.isSealed()는 true를 반환한다. name 프로퍼티의 값을 수정할 수 없으므로 이 프로퍼티에 "Greg"를 할당할 수 없다. 따라서 그 후의 코드에서 name 프로퍼티의 값을 확인해보면 "Nicholas"가 반환된다.

동결 객체는 특정 시점에서 객체를 찍은 일종의 사진이다. 동결 객체는 용도가 매우 제한적이며 매우 드물게 사용된다. 다른 확장 불가능한 객체와 마찬가지로 동결 객체도 엄격한 모드에서 사용하는 편이 좋다.


요약

자바스크립트는 객체가 프로퍼티가 키/값 쌍으로 되어있는 만큼 해시 맵에 빗대어 생각하면 이해하기 쉽다. 객체 프로퍼티에 접근할 때는 점 표기법 또는 각괄호 표기법 중 무엇을 사용해도 상관없다. 프로퍼티에 값을 할당하면 언제든 객체에 새 프로퍼티를 추가할 수 있으며 delete 연산자를 사용하면 언제든 프로퍼티를 제거할 수 있다. 프로퍼티의 존재 여부는 프로퍼티 이름과 객체를 in 연산자와 함께 사용하면 알 수 있다. 이때 고유 프로퍼티만 확인하고 싶다면 모든 객체에 다 포함되어 있는 hasOwnPropert()를 사용하면 된다. 모든 객체 프로퍼티는 기본적으로 열거 가능하다. 열거 가능하다믄 말은 for-in 반복문이나 Object.keys()를 사용할 때 볼 수 있다는 뜻이다.

프로퍼티는 데이터 프로퍼티와 접근자 프로퍼티로 나누어진다. 데이터 프로퍼티는 값을 담아주기 위한 공간이므로 이 프로퍼티에서 값을 읽거나 저장할 수 있다. 데이터 프로퍼티에 함수를 담아두면 이 프로퍼티는 객체의 메소드로 취급된다. 데이터 프로퍼티와 달리 접근자 프로퍼티 자체에는 데이터를 저장할 수 없다. 접근자 프로퍼티는 게터와 세터를 조합하여 특정 동작을 수행한다. 데이터 프로퍼티와 접근자 프로퍼티 모두 객체 리터럴 표기법을 사용해 만들 수 있다.

모든 프로퍼티에는 관련된 내부 속성이 몇 가지 있으며 이러한 속성이 프로퍼티가 어떻게 동작하는지 결정한다. 데이터 프로퍼티와 접근자 프로퍼티에는 둘 다 [[Enumerable]][[Configurable]]이라는 속성이 있다. 데이터 프로퍼티에는 [[Writable]][[Value]]라는 속성이 추가로 있는 반면, 접근자 프로퍼티에는 [[Get]][[Set]] 속성이 더 있다. 모든 프로퍼티의 [[Enumerable]][[Configurable]] 속성은 true가 기본 값이며 데이터 프로퍼티의 [[Writable]] 속성 역시 true가 기본 값이다. Object.defineProperty() 또는 Object.defineProperties()를 사용하면 속성을 변경할 수 있으며 Object.getOwnPropertyDescriptor()를 사용하면 설정된 속성 값을 가져올 수 있다.

객체의 프로퍼티를 수정할 수 없게 만드는 방법은 세 가지가 있다. Object.preventExtensions()를 사용하면 객체에 프로퍼티를 추가할 수 없다.
Object.seal() 메소드를 사용하면 봉인된 객체를 만들 수 있는데 봉인된 객체는 확장 불가능해지며 객체의 프로퍼티는 설정 불가능해진다. Object.freeze()를 사용해 객체를 동결하면 객체가 봉인되는 것은 물론 데이터 프로퍼티에 값을 저장할 수도 없게 된다. 확장 불가능한 객체를 다룰 때는 잘못된 방식으로 객체에 접근했을 때 에러가 발생할 수 있도록 항상 엄격한 모드를 사용하는 것이 좋다.

profile
I'm on Wave, I'm on the Vibe.

0개의 댓글