프로퍼티 톺아보기: 프로퍼티 vs 메서드, 프로퍼티 vs 변수
Javascipt의 거의 모든 것은 객체다(원시 타입 데이터를 제외한 모든 값은 객체다). 그래서, 객체에 대해 잘 이해하는 게 중요하다. 객체는 프로퍼티(Property; 속성)와 어트리뷰트(Attribute; 값)로 구성되는데, 한번 자세히 살펴보자.
객체와 프로퍼티
객체는 여러 데이터의 묶음으로, 키(Key)를 통해 값(Value)에 접근하도록 도와주는 자료구조이다. 프로퍼티는 객체 내부에 정의된 어떠한 속성으로, 데이터 프로퍼티와 접근자 프로퍼티로 구분된다. 그리고, 특정 프로퍼티가 갖는 속성(메타 데이터)을 어트리뷰트(Attribute)라고 부른다. 어트리뷰트는 데이터 프로퍼티와 접근자 프로퍼티에 따라 각각 다르게 생성되며, 각각에 대해서는 별도의 포스트에 조금 더 자세히 남겨놓았다.
그러면, 객체를 한번 만들어보면서 특성에 대해 좀 더 자세히 살펴보자. 생성자 함수를 사용하면 객체를 정의할 수 있다.
function Pocketmon(type, weight, skills, sound) {
this.type = type;
this.weight = weight;
this.skills = skills;
this.sound = sound;
}
이제 Pocketmon()
생성자 함수를 사용해 pikachu
라는 인스턴스를 생성해보자(생성자 함수는 정의 그대로 '함수'이고, new
연산자와 함께 사용하지 않으면 함수를 호출하는 것으로 동작한다. Pocketmon()
함수 내에 리턴값을 지정하지 않았기 때문에, new
연산자 없이 사용하면 pikachu
에 undefined
가 할당된다).
pikachu = new Pocketmon('electricity', '6kg', ['1 million volts', 'body slam'], () => console.log('Pikka Pikka'));
Pocketmon
은 pikachu
의 프로토타입이다(정확하게는 Pocketmon.prototype
이 pikachu
의 프로토타입이다. 해당 프로토타입엔 pikachu.__proto__
프로퍼티로 접근할 수 있다). 프로토타입(Pocketmon.prototype
)은 인스턴스(pikachu
)가 가져야 할 프로퍼티(특성)를 사전에 정의해 둔다. 그리고, 인스턴스(pikachu
)는 프로토타입(Pocketmon
)에서 사전에 정의해 둔 각 프로퍼티에 대한 값(pikachu
만의 고유한 값)을 지정해서 자신만의 고유한 특성을 나타낸다. 만약에 Pocketmon()
생성자 함수에 water
, 9kg
, ['water canon', 'swimming']
이란 값을 인자(argument)로 전달해 주면 해당 객체는 피카츄가 아니라 꼬부기가 된다.
let kkobugi = new Pocketmon('water', '9kg', ['water canon', 'swimming'], () => console.log('KKobuk KKobuk'));
객체는 현실 세계의 관념적 분류 체계를 프로그래밍에 최대한 유사하게 적용할 수 있도록 해준다. 현실 세계의 분류 체계를 보더라도 '생물'이라는 것의 정의가 있고, 그 하위에 '동물'과 '식물'이 포함된다. 동물과 식물은 또한 각자의 고유한 정의와 특성이 있고, 동물의 하위 분류인 '포유류' '양서류' '조류' 어류' '파충류'도 각각의 고유한 특성을 갖는다. 하위분류 체계에 있는 것들은 상위 분류 체계에 있는 특성들을 모두 포함하면서, 추가적으로 각자가 가진 고유한 특성이 별도로 존재한다.
// Pocketmon을 상속받은 pikachu 인스턴스에 onwer로 Jiwoo를 추가해줬다.
// 전기 타입, 6kg, 백만 볼트와 몸통박치기라는 특성과 함께, 주인이 Jiwoo라는 선언을 통해 pikachu 인스턴스는 더욱 더 고유해졌다.
pikachu.owner = 'Jiwoo'
Pocketmon
이 pikachu
보다 상위 분류 체계이고, 상위 분류 체계에서 type
, weight
, skills
, sound
라는 특성이 필요하다는 정의가 있었기 때문에 pikachu
는 그에 대응되는 값을 프로퍼티로 갖는다. 그리고, 인스턴스인 pikachu
에 프로퍼티를 추가하면서 pikachu
의 주인이 Jiwoo
라는 추가적인 정보를 갖게 됐다. 이로써 pikachu
객체는 더욱더 고유한 특성을 가지게 된다.
이처럼, 프로퍼티는 객체가 가지는 특성을 나타낸다. 프로퍼티가 있기 때문에 객체는 어떠한 개념으로 정의될 수 있는 것이다.
메서드가 아닌 프로퍼티 vs 메서드인 프로퍼티
프로퍼티는 크게 메서드가 아닌 프로퍼티와 메서드인 프로퍼티로 구분할 수 있다. 메서드가 아닌 프로퍼티는 말 그대로 함수와 Symbol
을 제외한 값을 담는 프로퍼티를 의미한다(함수를 저장한 프로퍼티를 메서드라고 부르고, Symbol
은 프로퍼티 이름으로는 쓸 수 있지만 값으로는 사용할 수 없다). 위의 예시 코드에서 type
, weight
, skills
는 함수가 아닌 값을 담고 있기 때문에 '메서드가 아닌 프로퍼티'다(참고로, undefined
는 보통 선언된 식별자에 값이 담기지 않았을 때 엔진이 자동으로 값을 부여할 때 사용된다. 의미상 식별자가 '값을 갖지 않음'을 나타낼 땐 undfined
가 아닌 null
을 쓰는게 적절하다).
console.log(typeof pikachu.type); // string
console.log(typeof pikachu.weight); // string
console.log(typeof pikachu.skills); // object(array)
pikachu.age = 3;
console.log(typeof pikachu.age); // number
pikachu.isCute = true;
console.log(typeof pikachu.isCute); // boolean
pikachu.address = null;
console.log(typeof pikachu.address); // object(null은 원시타입이지만 javascript의 오류로 typeof 연산을 할 때 object가 나옴)
메서드는 값으로 함수를 갖는 프로퍼티를 의미한다(ES6 문법의 메서드는 조금 더 확장돼서, 객체 내에서 메서드 축약 표현으로 정의된 함수만을 의미한다. ES6 문법에서의 메서드는 생성자 함수로 사용할 수 없는 non-constructor 함수이다). 주로 객체 안에 선언된 다른 '메서드가 아닌 프로퍼티'를 참조하여 객체가 수행할 수 있는 어떠한 동작을 나타낸다. 예를 들어, pikachu
라는 객체는 sound()
라는 메서드를 사용해서 울음소리를 내는 동작을 할 수 있다(메서드인 프로퍼티는 메서드가 아닌 프로퍼티와 다르게 표현식 뒤에 ()
를 붙여서 해당 값이 함수임을 나타낸다).
pikachu.sound(); // Pikka Pikka undefined
sound
프로퍼티에 할당된 익명 함수는 울음 소리를 콘솔에 출력만 하고 값을 리턴하지는 않는다. 그래서, 브라우저 콘솔에 pikachu.sound()
메서드를 실행하면 먼저 콘솔에 Pikka Pikka
라는 울음소리가 출력되고, 그다음 undefined
가 나오는 게 확인된다(리턴 값이 없는 함수는 표현식의 평가 결과로 undefined
를 가진다).
흔히 명칭을 사용할 때 메서드가 아닌 프로퍼티를 '프로퍼티'라고 부르고, 메서드인 프로퍼티는 '메서드'라고 부르는 것 같다. 둘을 구분해서 부르기 위한 방식인 것 같은데, 개념 상 메서드인 프로퍼티도 프로퍼티이긴 하다. 하지만, 의미 상 구분을 위해 본 포스트에서도 메서드가 아닌 프로퍼티를 '프로퍼티', 메서드인 프로퍼티는 '메서드'라고 부르겠다.
프로퍼티는 객체의 상태를 나타내며, 메서드는 객체가 하는 동작을 나타낸다. 객체는 프로퍼티와 메서드를 통해 자신의 고유한 상태와 동작을 표현하여 독립적인 기능을 갖게 된다(동시에, 객체는 다른 객체와의 관계성을 갖기도 한다). 그리고, 자신만의 고유한 기능을 갖는 객체들의 조합으로 하나의 완성된 프로그램을 만들어가는 개발 방법론을 흔히 '객체 지향 프로그래밍'이라고 한다(하나의 완성된 프로그램을 작은 부품들로 쪼개고, 각 부품들이 완결성 있는 기능을 갖춘 다음, 부품을 조립해 프로그램을 만드는 컨셉이다. 여기서 '부품'이 '객체'에 해당한다).
프로퍼티(Property) vs 변수(Variable)
프로퍼티에 대해 가장 궁금했던 건 '프로퍼티와 변수의 차이'였다(사실, 이 포스트를 쓰게 된 계기에 가깝다). 차근차근 살펴보자.
프로퍼티와 변수의 공통점
아래는 객체가 값을 참조하는 방식을 대략적으로 표현해 본 이미지다(개념적인 이해를 위해 추상화한 이미지인 관계로 할당되는 메모리 크기, 메모리 점유 순서 등이 실제와 다를 수 있다). 먼저 변수 pikachu
로 접근하면 힙에서 객체 데이터를 저장한 메모리 주소인 0x024FF321
이 참조된다. 그리고, 각 프로퍼티마다 필요한 공간을 비선형적으로 확보한다(스택은 선형적으로, 차례대로 데이터를 쌓고, 힙은 비선형적으로 자유롭게 메모리 공간을 확보한다는 차이가 있다). 예를 들어 weight
프로퍼티는 0x024FF321
이라는 주소의 힙 메모리를 참조하고, 해당 공간에 데이터인 6kg
이 저장된다. weight
프로퍼티에 할당된 값은 6kg
이라는 원시 타입 데이터이기 때문에, 해당 데이터는 불변성을 갖는다.
추상화한 이미지를 실제 메모리 주소로 비교해보자. 아래 이미지는 위에서 선언한 pikachu
객체의 메모리 정보이다. 객체는 참조 타입이기 때문에 pikachu
라는 변수는 스택에 확보한 메모리 주소를 나타내고, 스택에는 pikachu
객체가 저장된 힙 메모리의 주소가 저장된다. 확인해 본 결과, pikachu
라는 식별자가 가리키는 스택 메모리는 @6605
메모리 주소를 참조하고 있다는 걸 알 수 있다. 그리고, pikachu
객체 하위에 있는 프로퍼티들에도 각각 메모리 주소가 부여된 것이 확인된다. 객체는 여러 개의 프로퍼티를 가질 수 있으며, 각 프로퍼티들은 별도의 메모리 공간을 확보해 값을 저장한다. 즉, 객체는 데이터들이 저장된 여러 메모리 주소들의 집합이고, 그중 weight
프로퍼티가 가리키는 메모리 주소도 확인된다.
이제, 객체 안에 있는 프로퍼티의 값을 수정해보겠다. 피카츄가 살이 쪄서 몸무게가 6kg에서 10kg으로 늘었다고 가정해 보자. 해당 정보를 최신화하려면 pikachu
객체에 있는 weight
프로퍼티로 접근해서 값을 변경해 주면 된다.
pikachu.weight = '10kg';
console.log(pikachu.weight); // 10kg
변경한 후 메모리 주소를 확인해보니, pikachu
변수가 참조하는 힙 메모리 주소는 @6605
로 동일하고, weight
프로퍼티가 가리키던 메모리 주소만 @15027
에서 @113973
으로 변경된 것이 확인됐다(weight
를 제외한 나머지 프로퍼티 주소들도 역시 동일하다).
해당 상황을 이미지로 시각화해보면 아래와 같다.
변수 pikachu
가 확보한 메모리 공간에는 pikachu
객체 데이터가 저장된 힙 메모리 공간 주소가 값으로 저장된다. 힙에서는 다시 각 프로퍼티들이 확보한 메모리 공간들이 있고(이미지에선 pikachu.weight
프로퍼티가 메모리 주소 0x024FF321
을 가리키고 있다), 해당 메모리 공간에 원래 pikachu.weight
값인 6kg
이 저장되어 있다. 해당 상황에서, 원시 타입인 pikachu.weight
의 값을 재할당했기 때문에, 별도의 공간인 0x124432AB
메모리 주소를 확보하고 값을 새로 저장한다(기존에 값이 저장돼 있던 0x024FF321
주소의 공간은 Garbage Collecting의 대상이 된다). 참고로, 힙은 메모리 공간을 비선형적으로 할당하고 관리하기 때문에 가변적인 객체 데이터를 저장하고 관리하기 유리하다.
다음으로, 참조 타입 데이터가 저장돼있는 skills의
값도 한번 바꿔보겠다(배열도 객체, 즉 참조 타입이다). 피카츄가 보유하고 있는 기술에 번개 치기를 추가해 보자. 값을 추가하는 데에는 배열 메서드인 push()
를 사용하겠다.
pikachu.skills.push('thunder') // push 메서드는 원본 배열의 값을 바꾸고, 추가된 배열의 길이를 리턴한다.
console.log(pikachu.skills); // ['1 million volts', 'body slam', 'thunder']
값을 추가한 후 메모릴 주소를 확인해보니, 피카츄의 기술 목록을 저장하고 있던 배열 pikachu.skills
의 메모리 주소값은 @111535
로 동일하고, 2번 인덱스에(배열도 객체이기 때문에, 다른 표현으론 '2번 프로퍼티에'라고 할 수도 있다) thunder
라는 값만 잘 추가된 게 확인된다.
해당 과정을 이미지로 표현해보면 아래와 같다.
원래 pikachu.skills
는 길이가 2인 배열이었다. 하지만, 배열은 참조 데이터이고, 가변적이다. 식별자인 pikachu
는 참조형 데이터이고, 원시 타입과 다르게 힙 공간 내에서 필요하다면 데이터의 변경을 가할 수 있기 때문에, thunder
라는 값을 추가하면 식별자 pikachu.skills[2]
에 해당하는 메모리 공간을 추가로 확보하여 값을 저장한다(참고로, pikachu.skills
또한 참조 타입이기 때문에 값으로 식별자 주소를 갖고, 인덱스에 값이 추가되거나 변경될 때마다 참조된 주소를 변경하며 값을 수정한다. 예를 들어 pikachu.skills[0]
은 원시 타입이기 때문에, 만약에 해당 식별자에 값을 재할당하면 pikachu.skills에 연결된 참조가 새롭게 확보된 메모리 공간으로 변경되고, 재할당된 값이 그 공간에 저장될 거다. 그리고, pikachu.skills[0]
은 Garbage collecting의 대상이 된다).
위와 같이 확인해본 결과, 원시 타입 데이터는 불변성을 유지하기 위해 재할당 시 메모리 공간을 추가로 확보한다거나, 참조 타입 데이터의 경우 메모리 경로를 메모리에 저장하고 필요한 데이터에 접근하기 위해 참조하는 등, 변수와 프로퍼티는 거의 유사하게 동작하는 걸 확인했다. 결국, 둘 다 '데이터를 저장한 메모리 공간을 가리키는 식별자'의 역할을 한다는 점에서 둘은 거의 유사하다. 또한, var
로 변수를 선언하면 실행 컨택스트의 '변수 객체'에 프로퍼티로 등록되고, 변수에 할당된 값이 프로퍼티의 값으로 지정된다는 점을 봤을 때(전역에서 var
로 생성한 변수는 전역 객체의 프로퍼티로 등록된다), 변수와 프로퍼티를 완전히 같다고 말하긴 어렵지만 그래도 거의 유사한 개념이라고 봐도 무방할 것 같다.
// 해당 코드는 브라우저에서 실행해야 동작함
var myNum = 10;
console.log(window['myNum']) // 10
window.myNum = 20;
console.log(myNum); // 20
프로퍼티와 변수의 차이점
프로퍼티와 변수는 많은 부분 유사한 부분점이 있지만, 차이점도 존재한다.
첫째, 변수는 실행 컨텍스트 내에서 호이스팅 되지만, 프로퍼티는 호이스팅 되지 않는다.
console.log(myNum1) // undefined
console.log(myNum2) // ReferenceError: myNum2 is not defined
console.log(myObj) // undefined
console.log(myObj.name) // Uncaught TypeError: Cannot read properties of undefined (reading 'name')
var myNum1 = 10;
let myNum2 = 20;
var myObj = { name: 'Jay' };
생각해보면 당연하다. 실행 컨텍스트에서 호이스팅 되는 건 myObj
객체 안에 있는 프로퍼티가 아닌, myObj
변수 자체이다. 그리고, var
로 선언된 myObj
는 호이스팅 시 undefined
로 초기화된다. 심지어, 처음 호이스팅 될 때 변수의 타입은 객체가 아니라 undefined
이기 때문에(undefined
는 원시 타입이다), 프로퍼티를 가질 수 조차 없는 거다.
아래 상황은 당연해 보이진 않는다. 변수 선언자 없이 함수 myFun 안에서 선언된 변수는 호이스팅의 대상에서 제외된다.
var x = 10;
function myFunc() {
console.log(x); // x가 호이스팅이 됐다면 undefined가 나와야겠지만, 실제론 10이 출력된다
x = 20; // 변수 선언자 없이 변수 선언
}
myFunc();
자바스크립트 엔진은 변수 선언자 없이 선언된 변수를 스코프 체인 상 상위 스코프에서 선언된 적이 있는지 탐색한다. 스코프 체인은 일방향 링크드 리스트이기 때문에, 탐색은 상위 스코프들을 하나씩 순차적으로 진행한다. 그러다, 최상위 스코프인 전역 객체(브라우저에서는 window, Node.js 환경에서는 global)까지 탐색하고, 스코프 체인 내에서 선언된 적이 없는 변수인 것이 확인된다. 그러고 나면, 변수 선언 없이 선언된 변수는 전역 객체의 프로퍼티인 것으로 간주되고 객체에 할당된다.
전역 객체를 전역에서 호출할 땐 {전역 객체}.{프로퍼티} 같은 호출 형식 중 전역 객체 부분이 생략 가능하기 때문이다(전역에서 x와 window.x는 같은 값이다). 그래서, 변수 선언자 없이 선언한 변수는 전역 객체의 프로퍼티로 할당되는 것이다. 그리고, 프로퍼티는 호이스팅 대상에서 제외되기 때문에 함수 내에서 실행한 변수 x의 호이스팅은 이뤄지지 않은 것이다.
둘째, 변수는 삭제가 불가능하지만, 프로퍼티는 삭제가 가능하다.
var myNum = 10;
var myObj = {
myNum: 10 // myObj.myNum은 myNum 변수와 다른 이름을 가진 식별자다
}
delete window.myNum; // false
console.log(window.myNum); // 10
delete myObj.myNum; // true
console.log(myObj, myObj.myNum); // {} undefined
var
로 선언한 변수는 실행 컨텍스트(전역에서는 전역 객체, 브라우저에서는 window
, Node.js 환경에서는 global
)의 변수 객체에 프로퍼티로 등록이 된다. 하지만, 변수 객체에 등록된 변수를 delete
연산자로 삭제해보려고 해도 지워지지 않는다(변수를 삭제하려고시도하면 Strict 모드에서는 SyntaxError가 발생한다). 한번 선언된 변수는 값의 참조가 없어 Garbage Collecting으로 처리되지 않는 한 삭제되지 않는다. 반면, 객체의 프로퍼티는 delete
연산자를 통해 삭제가 가능하다(이것도 불변성-가변성의 차이인 게 아닌가 추측한다).
셋째, 변수와 프로퍼티는 적용받는 식별자 네이밍 규칙에 차이가 있다. 보통 변수를 선언할 땐 첫 문자를 숫자로 하면 안 된다거나, 일부 특수문자($
, _
등)를 제외하고는 식별자 이름에 사용될 수 없다는 규칙이 적용된다. 하지만, 프로퍼티 이름으로는 별도의 규제 없이 모든 문자열이 사용될 수 있다(단, 특수 문자 사용 등 일부 예외 케이스에 대해선 따옴표(""
)로 감싸 문자열 처리를 해줘야 한다).
var --myNum = 10; // 특수문자 사용 금지(Uncaught SyntaxError: Unexpected token '--')
var 0 = 0; // 숫자로 변수명 시작 금지(Uncaught SyntaxError: Unexpected number)
var for = 20; // 예약어 사용 금지(Uncaught SyntaxError: Unexpected token 'for')
var myObj = {
"--myNum": 10; // 특수문자 사용 가능
0: 20; // 숫자를 첫 문자로 사용해 네이밍 가능
"for": 30; // 예약어 사용 가능
}
단, 프로퍼티 이름의 식별자 규칙을 변수명 네이밍 규칙의 예외 케이스에 해당하게 지었다면 값에 점 표기법이 아니라 괄호 표기법으로 접근해야 한다(동작은 가능하나, 가능하면 변수명 식별자 규칙을 지켜서 프로퍼티 명을 짓는 게 좋다).
console.log(myObj['--myNum'], myObj[0], myObj['for']); // 10 0 20
결론
프로퍼티는 변수와 유사하지만 다르다. 이 정도가 결론이다. 엄청난 인사이트를 얻진 않은 것 같지만, 그냥 궁금한 내용을 상세하게 뜯어본 데 만족한다.
여러 내용을 배워갈수록 변수 객체, 실행 컨텍스트(환경 레코드)에 대한 학습이 빨리 필요하다고 느낀다. var
로 변수를 선언할 때 실행 컨텍스트의 변수 객체(전역에서는 전역 객체)에 등록된다는 건 굉장히 신기한 동작이다. 이게 어떤 흐름으로 동작하고, 또 let
과 const
로 변수를 선언할 때의 동작 과정 등에 대해 빨리 알아보고 포스트도 남겨봐야겠다.