이 글은 You Don't Know JS 서적을 참고하여 작성하였습니다.
자바스크립트 함수는 호출 가능한 특성이 있는 일급 객체이다.
객체는 선언적(리터럴) 방식과 생성자 형식으로 생성할 수 있다.
// 선언적(리터럴)
const obj = {}
// 생성자
const obj = new Object();
객체 내부는 프로퍼티로 이루어져 있는데, key-value 형태로 key를 통해서 프로퍼티 값이 있는 곳을 참조할 수 있다.
const obj = {
name: '철수'
}
obj['name'];
obj.name;
여기서 주의해야 할 점은 객체(배열) 프로퍼티명은 항상 문자열이라는 것이다. 만약 문자열 이외의 숫자를 쓰더라도 우선 문자열로 변환된다. 따라서, 객체또는 배열의 프로퍼티명을 숫자와 혼용해서 사용하는 것은 권장하지 않는다.
const obj = {};
obj[true] = 'foo';
obj[1] = 'bar';
console.log(obj['true']); // foo
console.log(obj['1']); // bar
그럼 프로퍼티명을 런타임에 동적으로 설정할 수 있는 방법은 없을까?
ES6 부터는 계산된 프로퍼티명
라는 기능이 추가되어, 객체 리터럴 선언의 프로퍼티명을 [표현식]
으로 구성해 활용할 수 있다.
const prefix = Math.floor(Math.random() * 100) % 2 === 1 ? 'a' : 'b';
const obj = {
// 계산된 프로퍼티명
[prefix + '_cal'] : '철수'
};
console.log('prefix', prefix);
console.log(obj['a_cal']);
console.log(obj['b_cal']);
객체 복사는 얕은 복사
, 깊은 복사
두 가지 방법이 존재한다.
얕은 복사
는 새로운 객체를 생성하고 기존 객체의 프로퍼티의 참조만 복사하는 방법이다. 프로퍼티의 경우에는 새로 생성하지 않고 동일한 레퍼런스를 참조하도록 한다.
깊은 복사
는 새로운 객체 생성과 동시에 기존 객체의 프로퍼티까지 새로운 객체로 생성해 복사하는 방법이다. 만약 객체간에 상호 참조하고 있는 경우에는 깊은 복사
가 환형 참조를 형태가 되어 무한 복사를 하게 되는 문제가 있다.
const a = {
name: 'a'
};
const b = {
name: 'b'
};
// 환영 참조
a.b = b;
b.a = a;
// 얕은 복사
function shallowCopy(obj) {
const result = {};
for (let [key, value] of Object.entries(obj)) {
result[key] = value;
}
return result;
}
// 깊은 복사
function deepCopy(obj) {
const result = {};
for (let [key, value] of Object.entries(obj)) {
if (typeof value === 'object') {
result[key] = deepCopy(value);
} else {
result[key] = value;
}
}
return result;
}
console.log(shallowCopy(a));
console.log(shallowCopy(b));
// { name: 'a', b: { name: 'b', a: { name: 'a', b: [Circular] } } }
// { name: 'b', a: { name: 'a', b: { name: 'b', a: [Circular] } } }
console.log(deepCopy(a));
console.log(deepCopy(b));
//function deepCopy(obj) {
// ^
//RangeError: Maximum call stack size exceeded
깊은 복사
를 직접 구현할 수도 있지만, JSON 객체를 이용해서 JSON 문자열로 만들고, 이를 다시 객체로 역직렬화하는 방법도 하나의 대안이 될 수 있다.
JSON.parse(JSON.stringify(obj), [출력할 프로퍼티 목록]);
const a = {
name: 'a'
};
const b = {
name: 'b'
};
a.b = b;
b.a = a;
function shallowCopy(obj) {
const result = {};
for (let [key, value] of Object.entries(obj)) {
result[key] = value;
}
return result;
}
const shallowCopyA = shallowCopy(a);
console.log(shallowCopyA);
console.log(shallowCopyA.b === b);
const deepCopyA = JSON.parse(JSON.stringify(a, ['name', 'b']));
console.log(deepCopyA);
console.log(deepCopyA.b === a.b);
// { name: 'a', b: { name: 'b', a: { name: 'a', b: [Circular] } } }
// true
// { name: 'a', b: { name: 'b' } }
// false
또한, ES6부터는 얕은 복사
를 위한 Object.assign()
메서드를 제공한다.
Object.assign(target, ...sources)
const target = {a: 1, b: 2};
const source = {b: 3, c: 4};
const obj = Object.assign(target, source);
console.log(target);
console.log(source);
console.log(obj);
console.log(target === obj);
// { a: 1, b: 3, c: 4 }
// { b: 3, c: 4 }
// { a: 1, b: 3, c: 4 }
// true
객체의 모든 프로퍼티
는 프로퍼티 서술자
로 구성된다.
프로퍼티 서술자
는 value
, writable
, enumerable
, configurable
네가지 특징을 가지고 있다.
Object.getOwnPropertyDescriptor()
메서드를 이용해서, 프로퍼티 서술자에 특성들의 값을 아래와 같이 확인할 수 있다.
const object = {
property: '철수'
};
console.log(Object.getOwnPropertyDescriptor(object, 'property'));
//{ value: '철수',
// writable: true,
// enumerable: true,
// configurable: true }
또한, 프로퍼티에 대한 프로퍼티 서술자
의 특성값들을 변경하거나 새로운 프로퍼티를 생성하는 경우에는 Object.defineProperty()
함수를 이용할 수 있다.
const object = {
name: '철수'
};
// 새로운 프로퍼티 추가
Object.defineProperty(object, 'age', {
value: 29,
writable: true,
enumerable: true,
configurable: true
});
// 기존 프로퍼티 수정
Object.defineProperty(object, 'name', {
value: '미애',
writable: true,
enumerable: true,
configurable: true
});
console.log(object);
// { name: '미애', age: 29 }
이제 프로퍼티 서술자
에서 value
는 값을 나타내는 것을 알 수 있고, 나머지 특성들에 대해서 하나씩 살펴보자.
writable
특성은 프로퍼티의 쓰기 가능 여부를 나타낸다.
쓰기가 금지된 프로퍼티(writable == false)를 변경하려는 경우, strict mode
에서는 에러가 발생하고 non strict mode
에서는 조용히 넘어간다.
'use strict';
const object = {};
Object.defineProperty(object, 'name', {
value: '철수',
writable: false,
enumerable: true,
configurable: true
});
object.name = '미애';
//object.name = '미애';
// ^
//TypeError: Cannot assign to read only property 'name' of object '#<Object>'
configurable
특성은 프로퍼티 서술자
변경 여부를 의미한다. 즉, defineProperty()
함수로 프로퍼티 서술자
를 변경할 수 있는지를 나타낸다.
const object = {};
Object.defineProperty(object, 'name', {
value: '철수',
writable: true,
enumerable: true,
configurable: false
});
object.name = '미애';
console.log(object); // { name: '미애' }
Object.defineProperty(object, 'name', {
value: '철수',
writable: false,
enumerable: true,
configurable: true // 서술자 변경하게 되면, TypeError 발생한다.
});
// Object.defineProperty(object, 'name', {
// ^
// TypeError: Cannot redefine property: name
위 예시와 같이, 프로퍼티 서술자
특성 변경을 막은 경우(configurable == false)에는 writable
, enumerable
, configurable
특성을 변경할 수 없다. (단, writable 특성이 true인 경우에는 false로 변경 가능하다.)
따라서, configurable
특성이 false 가 되면 특성 변경의 제한이 있으므로 유의해야 한다.
enumerable
특성은 열거 가능성을 의미하고 for...in
문처럼 프로퍼티를 열거하는 구문에서 해당 프로퍼티를 표현할 것인 지를 나타낸다. 즉, enumerable 특성을 false로 하면 for...in문에서 처리되지 않는다.
const object = {name: '철수'};
Object.defineProperty(object, 'age', {
value: 27,
writable: true,
enumerable: false, // 열거 가능성을 제거
configurable: true
});
Object.defineProperty(object, 'height', {
value: 166,
writable: true,
enumerable: true,
configurable: true
});
for(let key in object) {
console.log(key, object[key]);
}
// name 철수
// height 166
위 예시를 보면, age 프로퍼티가 for...in문에서 감춰진 것을 확인할 수 있다.
불변성은 얕은 불변성
, 깊은 불변성
두 가지로 구분해 생각할 수 있다.
얕은 불변성
은 객체 자신과 프로퍼티만 불변으로 만든다는 것을 의미한다. 즉, 프로퍼티가 가리키는 레퍼런스까지는 불변으로 만들지 못한다.
반면, 깊은 불변성
은 자신뿐 만 아니라 프로퍼티가 가르키는 레퍼런스까지 불변으로 만드는 것을 의미한다.
자바스크립트는 얕은 불변성
을 위한 기능만 지원하는데 하나씩 알아보자.
앞서 살펴본 프로퍼티 서술자
를 이용하면 상수처럼 사용할 수 있다.
const object = {};
Object.defineProperty(object, 'name', {
value: '철수',
writable: false, // 쓰기 제한
configurable: false,// 설정 변경 제한
enumerable: true
});
// 변경되지 않고 무시된다.
object.name = '미애';
console.log(object);
// { name: '철수' }
객체가 가진 현재 프로퍼티를 유지하고 더는 프로퍼티 추가할 필요가 없을 경우, Object.preventExtensions()
함수를 이용할 수 있다.
const object = {
name: '철수',
age: 23
};
// 확장 금지
Object.preventExtensions(object);
// 더는 object 객체에 프로퍼티가 추가되지 않고 무시된다.
object.weight = 89;
console.log(object);
// { name: '철수', age: 23 }
Object.seal()
함수를 이용해서 봉인된 객체를 생성한다.
봉인된 객체라 함은 프로퍼티 서술자
특성 변경할 수 없고 프로퍼티 확장이 불가한 객체를 의미한다.
Object.seal()
함수가 실행되면 Object.preventExtensions()
와 모든 프로퍼티대한 프로퍼티 서술자
의 configurable
설정이 false가 된다.
const object = {
name: '철수',
age: 23
};
// 봉인된 객체 생성
const sealedObject = Object.seal(object);
// 값 변경은 가능
sealedObject.name = '미애';
// 확장 불가되어 무시된다.
sealedObject.weight = 89;
console.log(sealedObject);
console.log(Object.getOwnPropertyDescriptors(sealedObject));
// { name: '미애', age: 23 }
// { name:
// { value: '미애',
// writable: true,
// enumerable: true,
// configurable: false },
// age:
// { value: 23,
// writable: true,
// enumerable: true,
// configurable: false } }
Object.freeze()
함수는 인자로 전달한 객체를 동결하여 반환한다.
동결한다는 것은 무엇일까?
Object.seal()
함수로 객체를 봉인하고, 모든 프로퍼티대한 writable
특성을 false로 변경해 객체 변화를 제한하는 것을 의미한다. 뿐만 아니라, 프로토타입
변경도 방지한다.
const object = {
name: '철수',
age: 23
};
// 봉인된 객체 생성
const freezeObject = Object.freeze(object);
// 확장 금지
freezeObject.height = 177;
console.log(freezeObject);
console.log(Object.getOwnPropertyDescriptors(freezeObject));
// { name: '철수', age: 23 }
// { name:
// { value: '철수',
// writable: false,
// enumerable: true,
// configurable: false },
// age:
// { value: 23,
// writable: false,
// enumerable: true,
// configurable: false } }
const obj = {
name: '철수'
}
obj.name;
obj.name
코드는 obj 객체의 name 프러퍼티에 접근한 것으로 보이지만, 실제로는 obj 객체가 [[Get]]
연산을 한 것이다.
구체적으로, [[Get]]
연산 과정은 객체에서 동일한 이름의 프로퍼티를 찾아 값을 반환하고 발견하지 못하면 Prototype Chainning
을 통해 상위 프로토타입에서 프로퍼티를 탐색한다.
[[Put]]
연산은 객체에 프로퍼티를 생성/수정하는 경우 발생하는데, 다음과 같은 과정을 거친다.
접근 서술자
라면 Setter
를 호출한다.쓰기 불가(writable: false)
라면 무시하거나 TypeError를 던진다.프로퍼티는 프로퍼티 서술자
또는 접근 서술자
중 하나의 서술자를 갖는다.
프로퍼티 서술자
는 value 특성으로 값을 가지고, 접근 서술자
는 value 없이 Getter/Setter 함수를 정의한다.
정리해보면, 프로퍼티 경우가
프로퍼티 서술자
라면 value에 접근하고,접근 서술자
라면 Getter/Setter에 접근한다.
접근 서술자
인 경우에는 value
, writable
특성없이, get
, set
, enumerable
, configuration
특성으로 구성된다.
MDN 문서에 따라 Getter
, Setter
함수의 정의를 살펴보자.
Setter
: 프로퍼티에 값을 설정하려고 할 때, 호출되는 함수로 바인딩(인자는 무조건 하나)Getter
: 프로퍼티를 가져올 때, 호출하는 함수로 바인딩(인자는 없어야됨)이제 실제 코드에서 어떻게 쓰이는지 살펴보자.
const object = {
// name getter 정의
// name 프로퍼티를 가져올 때 호출하는 함수로 바인딩
get name() {
console.log('Getter Call', this._name_);
return 'Hello! ' + this._name_;
}
};
Object.defineProperty(object, 'name', {
// name setter 정의
// name 프로퍼티에 값을 설정하려고 할 때 호출되는 함수로 바인딩
set: function (name) {
console.log('Setter Call', name);
this._name_ = name;
},
enumerable: true
});
object.name = '철수'; // [[Put]] 연산에서 Setter 함수 호출
// Setter Call 철수
console.log(object.name); // [[Get]] 연산에서 Getter 호출
// Getter Call 철수
// Hello! 철수
object.name = '미애'; // [[Put]] 연산에서 Setter 함수 호출
// Setter Call 미애
console.log(object.name); // [[Get]] 연산에서 Getter 호출
// Getter Call 미애
// Hello! 미애
위 예시를 보면, 리터럴 객체의 name 프로퍼티가 프로퍼티 서술자
가 아닌 접근 서술자
로 정의된 것을 확인할 수 있다.
따라서 object.name
코드의 [[Get]]
연산 과정에서 Getter
함수가 호출된 것을 알수있다.
이와 유사하게 Setter
함수가 정의되어 있다면, 프로퍼티 값 변경시 [[Put]]
연산에서 Setter
함수가 호출된다.
또한, 프로퍼티 서술자
와 유사하게 접근 서술자
에서도 아래와 같이 계산된 프로퍼티명
을 사용할 수 있다.
const computedProperty = Math.round(Math.random() * 100) % 2 === 1 ? 'a' : 'b';
const object = {
get [computedProperty]() {
return 'This is ' + this.target;
},
set [computedProperty](name) {
this.target = 'JS_' + name;
}
};
object[computedProperty] = '철수'; // [[Put]] 연산에서 Setter 함수 호출
console.log(object[computedProperty]); // [[Get]] 연산에서 Getter 호출
// This is JS_철수
객체 내부에 프로퍼티가 존재하는 지를 파악할 수 있는 두 가지 방법이 있다.
첫 번째로 in 연산자
는 해당 객체와 [[Prototype]]
체이닝을 통한 상위 프로토타입에 찾고자 하는 프로퍼티가 존재하는지 탐색한다.
두 번째로 hasOwnProperty()
함수는 오직 해당 객체에 프로퍼티가 존재하는 지만 탐색한다.
const foo = {
name: '철수'
};
const bar = Object.create(foo);
bar.age = 25;
console.log(bar.hasOwnProperty('name')); // true
console.log(bar.hasOwnProperty('age')); // false
console.log('name' in bar); // true
console.log('age' in bar); // true
for...in
반복문은 자신과 Prototype 체이닝
으로 접근 가능한 객체에 차례대로 접근하여 열거 가능한 프로퍼티를 순회한다.
const foo = {
name: '철수'
};
const bar = Object.create(foo);
bar.age = 25;
for(let key in bar) {
console.log(key, bar[key]);
}
// age 25
// name 철수
for...in
문의 불편한 점은 객체 프로퍼티의 이름을 기준으로 순회한다.
그럼 객체 프로퍼티의 값으로 순회할 수 있는 방법이 있을까?
ES6부터는 배열을 위한 for...of
반복문을 지원한다.
for...of
문은 객체 프로퍼티의 값을 대상으로 순회를 수행한다.
좀 더 자세히 살펴보면, for...of
문은 순회할 원소의 Iterator 객체
가 필요한데, 한 사이클당 한 번씩 Iterator 객체의 next()
함수를 호출해 연속적으로 순회한다.
@@iterator
(Iterator 객체
를 반환하는 함수)에 접근할 수 있다.const array = ['철수', '미애', '짱구']; // 배열에는 @@iterator가 내장되어 있다.
let iterator = array[Symbol.iterator]();
let entry;
do {
entry = iterator.next();
console.log(entry);
} while (!entry.done);
// { value: '철수', done: false }
// { value: '미애', done: false }
// { value: '짱구', done: false }
// { value: undefined, done: true }
이제 매 순회마다 호출하는 iterator
객체의 next()
는 { value, done }
형식의 객체를 반환한다.
value
는 현재 사이클에서의 프로퍼티 값을 의미하고, done
은 다음에 순회할 프로퍼티 값이 있는지 여부를 의미한다.
결과적으로, for...of
문에서 매번 iterator
객체의 next()
함수를 호출하면, 내부 포인터는 하나씩 증가하고 반환값의 done
을 이용해 다음 사이클 실행 여부를 결정한다.
좀 더 부연 설명하기 위해, 일반적인 for 문에서 iterator
객체를 사용해보면 아래와 같다.
const array = ['철수', '미애', '짱구'];
let iterator = array[Symbol.iterator]();
for(let entry = iterator.next(); entry.done !== true; entry = iterator.next()) {
console.log(entry);
}
// { value: '철수', done: false }
// { value: '미애', done: false }
// { value: '짱구', done: false }
위와 같이 for..of
문은 배열에서 유용하게 사용되지만, 일반 객체에는 @@iterator
가 없기 때문에 사용할 수가 없다. 일반 객체에도 필요한 경우에 따라 아래와 같이 @@iterator
를 구현할 수도 있다.
const object = {
foo: '철수',
bar: '미애',
baz: '짱구'
};
Object.defineProperty(object, Symbol.iterator, {
enumerable: false,
writable: false,
configurable: true,
value: function () {
const values = Object.values(this);
const {length} = values;
let index = 0;
return {
next: function() {
return {
value: values[index++],
done: index > length
}
}
}
}
});
const it = object[Symbol.iterator]();
console.log(it.next());
console.log(it.next());
console.log(it.next());
console.log(it.next());
// { value: '철수', done: false }
// { value: '미애', done: false }
// { value: '짱구', done: false }
// { value: undefined, done: true }
for(let value of object) {
console.log(value);
}
// 철수
// 미애
// 짱구
위 예제와 같이, 개발 상황에 따라 요구사항에 맞는 커스텀 @@iterator
를 구현할 수 있다.