JavaScript

프로퍼티 톺아보기: 데이터 프로퍼티와 접근자 프로퍼티

GoJay 2024. 10. 17. 18:37

프로퍼티는 메서드인 프로퍼티와 메서드가 아닌 프로퍼티로 구분할 수 있다(포스트 참고). 메서드인 프로퍼티는 값으로 동작 가능한 함수를 갖고, 메서드가 아닌 프로퍼티는 그 이외의 값을 갖는다. 메서드인 프로퍼티도 프로퍼티이지만, 메서드가 아닌 프로퍼티와의 구분을 위해 보통 '메서드'라고 부른다.

데이터 프로퍼티와 접근자 프로퍼티

프로퍼티를 나누는 구분은 또 있다. 바로, 데이터 프로퍼티와 접근자 프로퍼티다. 둘에 대해서 한번 살펴보자.

데이터 프로퍼티

일반적으로 사용하는 객체의 프로퍼티들이 데이터 프로퍼티다. 어떠한 값이 오든 상관없이, 프로퍼티 키와 값으로 구성된 모든 프로퍼티는 데이터 프로퍼티다.

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

let person1 = new Person('Jay', 20, false, ['coding', 'soccer'], { country: 'Korea' })

console.log(person1)

// // 문자, 숫자, 불리언, 배열, 객체 등 모든 원시 타입 값이 할당된 프로퍼티는 데이터 프로퍼티다
// address: country: "Korea"
// age: 20
// hobby: Array(2) 0: "coding" 1: "soccer"
// marriage: false
// name: "Jay"

원시 타입 뿐만 아니라, 프로퍼티의 값이 함수더라도(메서드인 프로퍼티더라도) 역시 데이터 프로퍼티다.

person1.sayHi = () => console.log('Hi!');

console.log(person1.sayHi()) // Hi!

데이터 프로퍼티에 접근할 때 주로 두 방법 중 하나를 사용한다. 먼저, 대표적인 방법은 점 표기법(Dot Notation)이다. 점 표기법의 문법은 객체 이름 바로 뒤에 프로퍼티 접근 연산을 해주는 . 를 쓰고, 이어서 프로퍼티 이름을 작성해 주는 것이다. 점 표기법으로 프로퍼티에 접근하면 프로퍼티에 할당된 값을 확인할 수 있고, 필요시 변경도 가능하다.

console.log(person1.age); // 20

person1.age = 21;

console.log(person1.age); // 21

프로퍼티에 할당된 값이 다른 프로퍼티를 포함하고 있는 객체라면(JavaScript에선 배열도 객체다) 점 표기법을 연쇄적으로 사용해 필요한 값에 접근할 수 있다(점 표기법은 값으로 평가되는 표현식이다).

person1.address.city = 'Seoul'; // person1.address에 접근해서 city 프로퍼티에 'Seoul'을 할당한다.

console.log(person1.address) // {country: 'Korea', city: 'Seoul'}

점 표기법으로 메서드를 호출할 땐 {객체명}.{메서드 명} 뒤에 괄호 ()를 붙여주면 된다.

person1.sayHi(); // Hi!

프로퍼티에 접근하는 다른 방법으로 대괄호 표기법(Bracket Notation)이 있다. 대괄호 표기법은 {객체명}['{프로퍼티 명}'] 처럼 대괄호를 통해 프로퍼티를 표현하며, 표기법 이외에 실제 동작되는 방식은 점 표기법과 유사하다.

person1['age'] = 22;
console.log(person1[age]) // 22  프로퍼티에 접근, 조회, 값 수정 가능

person1['hobby'][2] = 'running' // 표기법 붙여서 사용 가능
console.log(person1[hobby]) // ['coding', 'soccer', 'running']

대괄호 내에는 문자열이 와야 해서, 일반 값을 사용할 땐 위의 예제에 'age' 'hobby'와 같이 따옴표를 필수로 붙여줘야 한다. 변수에 담긴 값을 객체의 프로퍼티로 추가하고 싶을 땐 대괄호 표기법을 사용해야 하며(대괄호 안의 변수는 암묵적으로 타입이 문자열로 조정된다), 점 표기법을 사용하면 변수에 담긴 값이 아니라 변수명 자체가 프로퍼티로 등록된다.

let property1 = 10; // 숫자 타입 데이터는 문자열로 형변환해야 프로퍼티로 사용할 수 있음
let property2 = true; // 불리언 타입 데이터는 문자열로 형변환해야 프로퍼티로 사용할 수 있음

person1[property1] = 0; // 변수로 프로퍼티 접근해야 변수의 값이 프로퍼티로 추가됨
person1.property2 = 0; // 점 표기법을 사용하면 property2 변수에 담긴 값이 아니라, property2라는 문자열이 프로퍼티가 됨

console.log(10 in person1) // true
console.log(property1 in person1) // true
console.log('property1' in person1) // false

console.log(true in person1) // false
console.log(property2 in person1) // false 
console.log('property2' in person1) // true

프로퍼티는 변수와 다르게 식별자 이름 네이밍 규칙이 조금 더 관대하다. 예를 들어 네이밍 첫 시작을 숫자로 할 수 있다거나, 변수에 포함 불가한 특수 문자를 사용할 수 있고, 예약어를 사용 가능하다는 점 등, 프로퍼티의 이름은 조금 더 관대한 네이밍 규칙이 적용된다. 단, 변수 네이밍 규칙에서 벗어나는 프로퍼티 명을 사용하기 위해서는 점 표기법이 아니라 괄호 표기법을 사용해야 한다. 점 표기법으로 변수명 네이밍 규칙에 어긋나는 프로퍼티 명을 호출할 시 에러가 발생한다.

// 점 표기법으로 변수명 네이밍 규칙을 지키지 않은 프로퍼티 접근 시 에러 발생
person1.1st = '1st' // Uncaught SyntaxError: Invalid or unexpected token
pseson1.- = '-' // SyntaxError: Unexpected token '-'

// 괄호 표기법으로 변수명 네이밍 규칙을 지키지 않은 프로퍼티 접근해도 정상적으로 동작
person1['1st'] = '1st'; // { 1st: '1st' }
person1['-'] = '-'; // { -: '-' }

// 예약어여서 변수명 표기 규칙에서 어긋나는 경우는 점 표기법, 괄호 표기법 다 사용 가능
person1.for = 'for'
person1.if = 'if'

접근자 프로퍼티

접근자 프로퍼티는 데이터 프로퍼티와 달리 자체적으로 값을 가지진 않는다. 접근자 프로퍼티의 목적은 데이터 프로퍼티의 값을 읽거나(Get), 값을 저장하는 것(Set)을 쉽게 하도록 처리해 주는 것이다. 접근자 프로퍼티는 해당 목적을 달성하기 위한 '접근자 함수'로 구성되어 있다. 아래 예제를 살펴보자(아래 예제는 모던 자바스크립트 딥다이브 224p 예제를 참고함).

const myAddress = {
  country: 'Korea',
  city: 'Seoul',

  // 아래는 접근자 함수 FullAddress()로 구성된 접근자 프로퍼티
  // get을 붙여서 getter 함수로 지정하기
  get FullAddress() {
    return `${this.country}, ${this.city}`
  },

  // set을 붙여서 setter 함수로 지정하기
  set FullAddress(address) {
    [this.country, this.city] = address.split(' ');
  }
};

접근자 함수는 getter 함수와 setter 함수라고도 부른다. ES5까지는 접근자 함수 이름명 앞에 get{프로퍼티명} 또는 set{프로퍼티명} 형태로 지정해야 했지만(e.g. getFullAddress, setFullAddress), ES6 버전부터는 함수명의 제약 없이 함수명 앞에 get 또는 set을 붙이는 것만으로 접근자 프로퍼티를 만들 수 있게 됐다(아래에서 살펴보겠지만, getset은 접근자 프로퍼티의 내부 슬롯에 있는 프로퍼티다).

접근자 프로퍼티는 접근자 함수를 통해 객체에 있는 데이터 프로퍼티의 조회 및 값 세팅을 수월하게 할 수 있도록 돕는다(참고로, 접근자 프로퍼티는 메서드처럼 함수를 '호출'하는 것이 아니라, 프로퍼티처럼 값을 '참조'하고, 참조한 곳에 있는 접근자 함수 GetterSetter를 상황에 맞게 해당 시점에 호출하는 것이다. 그래서, 접근자 프로퍼티 사용 시 뒤에 괄호를 생략하고 프로퍼티 참조처럼 사용하면 된다).

// myAddress의 접근자 함수를 () 없이 호출하면 지정된 값이 조회된다
console.log(myAddress.FullAddress); // Korea, Seoul

// myAddress의 접근자 함수에 값을 할당하면 정해진 형식으로 값이 세팅된다.
myAddress.FullAddress = 'France Paris';

console.log(myAddress.FullAddress); // France, Paris

접근자 프로퍼티에 접근자 함수를 사전에 잘 설정해 두면 객체에 있는 값을 효과적으로 조회 및 수정할 수 있다. 특히, gettersetter 함수를 사용하면 객체에 잘못된 접근을 했을 때 오류를 발생시키는 등 필요한 로직 처리를 추가로 할 수 있다. 아래는 FullAddress 세터 함수의 인자로 띄어쓰기가 없는 값이 들어왔을 때 에러를 발생시키는 코드다(띄어쓰기로 split해서 값을 저장하기 때문에, 띄어쓰기가 없을 시 에러가 발생되는 게 이상적이다).

let myAddress = {
    country: 'Korea',
    city: 'Seoul',

    get FullAddress() {
        return `${this.country}, ${this.city}`;
    },

    set FullAddress(address) {
        if (address.includes(' ')) {
            [this.country, this.city] = address.split(' ');
        } else {
            alert('country와 city 사이를 공백으로 구분하세요');
          }
    }
};

myAddress.FullAddress = 'FranceParis' // alert 발생 및 setter 함수 실행 중지

getter와 setter는 JavaScript로 객체 지향을 구현하는 데 있어 자주 언급되는 개념인 것 같은데(getter와 setter 이용을 지양하자는 말이 있는 것 같긴 하다), 아직은 공부가 부족하다. 관련해선 JavaScript의 Class 문법과 객체 지향에 대해 좀 더 공부한 후 다시 포스팅해보겠다.

데이터 프로퍼티의 내부 슬롯

내부 슬롯과 내부 메서드는 개발할 때 직접 접근해서 조작할 순 없지만, 엔진이 프로그램을 처리할 때 필요한 정보와 동작을 정의해 둔 내용이다. 쉽게 얘기해서, 개발자가 자바스크립트로 코드를 짜면 해당 코드가 동작하는 과정에서 필요한 정보와 처리를 정의해 둔 것이라고 할 수 있다(개인적으로 이해한 내용을 말로 풀어 정리해 본 거고, 실제론 정확하지 않은 내용일 수 있습니다). 내부 슬롯과 메서드는 보통 이중 대괄호([[ ... ]])로 감싸는 형태로 표현한다.

예를 들어서, 숫자형 원시 타입 데이터의 래퍼 객체 역할을 수행하는 Number.prototype을 확인해 보면 내부 슬롯으로 [[Prototype]][[PrimitiveValue]]라는 값이 있는 게 확인된다(모든 객체는 [[Prototype]] 내부 슬롯을 갖고, 해당 값으로 프로토타입 체인을 구현한다).

해당 내부 슬롯은 개발자가 별도로 접근해서 값을 관리할 수 없다. 다만, 예외적으로 [[Prototype]] 내부 슬롯만 별도로 접근해 내용을 확인할 수 있고, __proto__라는 프로퍼티로 접근해 값을 임의로 추가할 수 있다.

// prototype 내부 슬롯엔 접근이 가능하다
console.log(Object.prototype); // {__defineGetter__: ƒ, __defineSetter__: ƒ, hasOwnProperty: ƒ, __lookupGetter__: ƒ, __lookupSetter__: ƒ, …}

let obj = {};

// __proto__와 Prototype은 같은 값을 가리킨다
console.log(obj.__proto__ === Object.prototype)

// 프로토타입에 프로퍼티와 값을 임의로 등록할 수 있다
obj.__proto__.x = 10;
console.log(obj.x); // 10

[[Prototype]] 이외의 내부 슬롯은 접근이 불가하지만, Object.getOwnPropertyDescriptor() 메서드를 사용하면 원하는 값의 내부 슬롯과 내부 메서드를 확인할 수 있었다. 해당 메서드를 활용해서 객체의 데이터 프로퍼티에 정의된 내부 슬롯을 확인해 보자.

function Pocketmon(type, skills) {
  this.type = type;
  this.skills = skills;
}

let pikachu = new Pocketmon('electricity', ['1 million volts', 'body slam']);

// pikachu 객체 확인
console.log(pikachu); // Pocketmon {type: 'electricity', skills: Array(2)}

// pikachu 객체에 있는 프로퍼티 'type'의 내부 슬롯 확인
Object.getOwnPropertyDescriptor(pikachu, 'type'); 
// {value: 'electricity', writable: true, enumerable: true, configurable: true}

pikachu 객체의 type 프로퍼티의 내부 슬롯은 객체고, 프로터피로 value, writable, enumerable, configurable를 갖는다. value를 제외한 나머지 값들은 별도 메서드를 사용하지 않을 시 전부 true의 값을 기본으로 갖게 된다.

  • [[Value]]: 프로퍼티의 값에 저장된 값(필수 항목, 지정 안 하면 undefined로 초기화)
  • [[Writable]]: 프로퍼티 값의 변경 가능 여부(선택 항목, 기본값 true)
  • [[Enumerable]]: for ... in, Object.keys 등으로 객체의 프로퍼티를 나열하려고 할 때 포함할지 여부(선택 항목, 기본값 true)
  • [[Configurable]]: 프로퍼티의 모든 변경(삭제, 수정 등)의 가능 여부(선택 항목, 기본값 true)

value는 직관적으로 해당 프로퍼티에 저장된 값을 의미한다. 만약에 pikachu.type 형태로 프로퍼티의 값에 접근을 하면 자바스크립트 엔진 내부에서는 내부 슬롯에 저장된 정보에 pikachu.type.[[value]] 형태로 접근해서 값을 가져오는 것이다. 만약에 pikachu.type의 값을 바꾼다면 내부 슬롯의 [[Value]] 프로퍼티 값도 바뀌게 된다.

pikachu.type = 'fire' // 불 속성 피카츄의 탄생
console.log(Object.getOwnPropertyDescriptor(pikachu, 'type'));
// {value: 'fire', writable: true, enumerable: true, configurable: true}

메서드도 데이터 프로퍼티이기 때문에 동일한 내부 슬롯을 가지며, [[Value]]에 원시 값이 아닌 함수가 값으로 들어간다(아래 예시에선 익명 함수로 메서드를 선언했기 때문에 함수 이름이 들어가지 않지만, 함수명을 선언해 값을 주면 [[Value]]에 함수 이름이 들어간다).

내부 슬롯의 [[wirtable]] 프로퍼티는 프로퍼티의 값을 변경 또는 삭제할 수 있는지를 의미한다. 값으론 불리언 값을 갖는다. 만약 [[Writable]] 값이 false로 지정돼 있으면 해당 값은 수정이 불가능하다(내부 슬롯의 값을 변경하고 싶으면 Object.defineProperty() 메서드를 사용하면 된다). 즉, 읽기 전용인 프로퍼티가 되는 것이다.

// writable 값을 false로 지정해서 프로퍼티 추가
Object.defineProperty(pikachu, 'weight', {
    value: '6kg',
    writable: false,
    enumerable: true,
    configurable: true
});

console.log(Object.getOwnPropertyDescriptor(pikachu, 'weight');
// {value: '6kg', writable: false, enumerable: true, configurable: true}

pikachu.weight = '10kg' // [[writable]] false인 값 수정 시도, 에러는 발생하지 않음
console.log(pikachu.weight) // 6kg 변경되지 않음

[[Enumerable]]는 객체를 순회할 때 순회 값으로 포함시킬지 여부를 나타낸다. false로 지정하면 프로퍼티를 볼유한 객체를 순회하려고 할 때 순회 대상에서 제외된다. 아래 예제를 보면 for... in...으로 pikachu 객체를 순회할 때 [[Enumerable]] 값이 falseonwer 프로퍼티만 제외된 것이 확인된다.

// [[enumerable]] false로 프로퍼티 선언
Object.defineProperty(pikachu, 'owner', {
    value: 'Jiwoo',
    writable: true,
    enumerable: false,
    configurable: true
});

console.log(pikachu.owner) // Jiwoo 프로퍼티 정의가 잘 됨

for (let key in pikachu) {
    console.log(key); // type skills weight
}

[[Configurable]]은 프로퍼티의 재정의 가능 여부를 의미하며, 수정과 삭제 모두의 가능 여부를 정의하는 프로퍼티다. false로 정의된 프로퍼티는 수정과 삭제가 모두 불가능해진다. [[Writable]] 프로퍼티와 비슷한 기능을 하는 것 같지만, [[Writable]] 프로퍼티는 삭제를 불가능하게 할 수 없다는 점에서 [[Configurable]]와 차이가 있다. 또한, [[Configurable]]false이고 [[Writable]]true인 경우 [[Value]][[Writable]]은 변경 가능하고, 프로퍼티의 삭제는 불가능해지기 때문에, 수정 가능하지만 삭제는 불가능한 형태로 프로퍼티를 보호하고 싶을 때 유용하다.

// [[configurable]] 값만 false가 되도록 프로퍼티 생성
Object.defineProperty(pikachu, 'level', {
    value: 10,
    writable: true,
    enumerable: true,
    configurable: false
});

console.log(pikachu.level); // 10  프로퍼티가 잘 생성됨
console.log(++pikachu.level); // 11  프로퍼티 수정이 잘 됨(writable이 true이기 때문)

delete pikachu.level // false  값 삭제가 되지 않음
delete pikachu.weight // true  writable이 false여도 값 삭제는 가능

console.log(pikachu) // Pocketmon {type: 'electricity', skills: Array(2), level: 11, owner: 'Jiwoo'}

많은 사람들이 함께 사용하는 모듈이나 라이브러리에 포함된 객체를 유저가 쉽게 수정 또는 삭제할 수 있게 하면 안정성이 떨어지게 된다. 또한, 사용성 측면에서 객체 내부 동작을 위해 필요한 프로퍼티지만 실제 사용에는 필요하지 않은 프로퍼티라면 [[Enumerable]] 값이 false인 게 사용 측면에서 도움이 될 수 있다. 이렇게, 데이터 프로퍼티의 내부 슬롯 값은 상황에 맞게 적절하게 정의해서 사용하면 된다.

접근자 프로퍼티의 내부 슬롯

접근자 프로퍼티도 데이터 프로퍼티와 마찬가지로 네 가지 내부 슬롯을 가진다. 그중 두 가지([[Enumerable]], [[Configurable]])은 데이터 프로퍼티와 동일하며, 나머지 두 가지([[Get]], [[Set]])는 차이가 있다.

  • [[Get]]: 데이터 프로퍼티의 값을 읽기 위한 접근자 함수
  • [[Set]]: 데이터 프로퍼티의 값을 호출하기 위한 접근자 함수
  • [[Enumerable]]: 데이터 프로퍼티의 내부 슬롯과 동일
  • [[Configurable]]: 데이터 프로퍼티의 내부 슬롯과 동일

위의 예시를 다시 확인해 보겠다. 아래와 같이 FullAddress라는 Getter/Setter 함수가 정의된 객체가 있다.

let myAddress = {
    country: 'Korea',
    city: 'Seoul',

    get FullAddress() {
        return `${this.country}, ${this.city}`;
    },

    set FullAddress(address) {
        if (address.includes(' ')) {
            [this.country, this.city] = address.split(' ');
        } else {
            alert('country와 city 사이를 공백으로 구분하세요');
          }
    }
};

객체 내에서 getset이라는 명령어를 쓰고 그 뒤에 함수를 쓴 부분이 각각 Getter/Setter 함수다. 그리고, 직관적으로 사용된 getset에 접근자 프로퍼티의 내부 슬롯인 [[Get]][[Set]]을 의미한다는 것을 알 수 있다. 한번, 직접 확인해보겠다.

console.log(Obejct.getOwnPropertyDescriptor); 
// {enumerable: true, configurable: true, get: ƒ, set: ƒ}

[[Get]][[Set]]에 각각 get FullAddressset FullAddress라는 이름으로 정의된 함수가 참조되어 있는 것이 확인된다.

객체 외부에서 FullAddress라는 접근자 함수를 참조하면 객체에선 내부 슬롯인 [[Get]] 또는 [[Set]] 중 적절한 곳에 접근해 함수를 호출한다. 이때, 호출이 Get 요청인지 Set 요청인지 구분하는 건 파라미터에 값이 넘어왔는지 여부로 판단한다. 그래서, 객체의 Setter 함수는 파라미터 없는 형태의 함수로는 선언할 수 없다(실제로 선언해 보면 SyntaxError가 나오며, 에러 메시지에 Setter는 반드시 하나 이상의 파라미터를 가져야 한다는 안내가 나와있다. 그리고, Setter의 파라미터로는 하나의 값만 선언할 수 있다).

let example = {
    greeting: 'hi',

    get ExampleFunc() {
        console.log(this.greeting);
    },

    // 함수에 파라미터 없이 setter 선언
    set ExampleFunc() { 
        this.greeting = 'hello'
    }
}

// Uncaught SyntaxError: Setter must have exactly one formal parameter.

객체는 내부에서 프로퍼티가 선언될 때 해당 프로퍼티가 데이터 프로퍼티인지, 아니면 접근자 프로퍼티인지를 평가한다. 해당 평가를 위해 사용되는 게 get/set 명령어다. get 또는 set 없이 함수를 추가하면 객체의 접근자 프로퍼티인 게 아니라, 데이터 프로퍼티인 메서드가 된다({프로퍼티}: {값} 형태가 아니라, 함수를 선언해 주면 함수명을 암묵적으로 프로퍼티로 취급해 메서드로 만들어준다. 해당 문법은 ES6부터 추가됐다).

let example = {
    greeting: 'hi',

    // get 없이 추가하면 example.ExampleFunc() 메서드가 된다
    ExampleFunc() {
        console.log(this.greeting);
    },

    // set 없이 추가해서 메서드로 취급되고, 위에 선언한 메서드와 이름이 같아서 덮어 씌워진다.
    ExampleFunc(name) { 
        this.greeting = 'hello ' + name;
    }
}

console.log(example.ExampleFunc()) // 함수에서 리턴한 값이 없으므로 undefined
console.log(example) // {greeting: 'hello Jay', ExampleFunc: ƒ} 마지막 선언한 메서드가 동작해서 greeting 프로퍼티의 값이 바뀜

결론

혹시라도 프로퍼티의 내부 슬롯을 조정해야 하는 상황이 있을 때 잘 참고해야겠다. 그리고, 접근자 프로퍼티를 통해 Getter/Setter 함수에 접근해서 값을 조회/수정하는 것에 대한 내용과 활용적 측면을 잘 공부해 봐야겠다.