기본형과 참조형
JS의 데이터 타입에는 크게 두 가지 유형이 있다. 기본형과 참조형으로, 이 둘의 구분 기준은 값의 저장 방식과 불변성 여부이다. 즉, 값이 어떻게 메모리에 저장되거나 복제되느냐, 또는 값이 변하느냐 변하지 않느냐로 나눌 수 있다.
- 기본형: 값이 담긴 주소값을 바로 복제, 불변성O
- Number
- String
- Boolean
- null
- undefined
- Symbol
- 참조형: 값이 담긴 주소값들로 이루어진 묶음을 가리키는 주소값을 복제, 불변성X
- 객체 Object
- Array
- Funtion
- Date
- RegExp
- Map, WeakMap
- Set, WeakSet
- 객체 Object
여기서 불변성은 메모리 관점으로 봐야한다. 만약 다음과 같은 변수를 선언했다고 하자.
var test;
test = 'hello'; // 식별자 = 변수
여기서 식별자는 변수명이고, 변수는 데이터이다.
이걸 메모리 영역으로 표현하자면 다음과 같다.
변수 영역 | 주소 | ... | 1002 | 1003 | 1004 | 1005 | ... |
데이터 | ... | 이름: test 데이터: 5004 |
... | ... | ... | ... | |
데이터 영역 | 주소 | ... | 5002 | 5003 | 5004 | 5005 | ... |
데이터 | ... | ... | ... | 'hello' | ... | ... |
위 표를 보면, 변수 영역에서 식별자를 지정하고, 데이터가 저장되어 있는 곳의 주소를 가져와 사용하는 것을 볼 수 있다.
데이터를 직접 변수 영역에 집어 넣지 않는 이유는 다음과 같다.
- 자유로운 데이터 변환
- 메모리의 효율적인 관리
JS에서 숫자는 항상 8 Byte로 고정이지만, 문자는 고정이 아니다. 그래서 1003에 할당된 데이터를 변환하려 할 때 훨씬 더 큰 데이터를 저장하려 한다면 1004 이후부터 저장되어 있는 모든 데이터를 오른쪽으로 미뤄야 한다.
또한 만약 똑같은 데이터를 여러번 저장해야 한다고 가정해보자.
1만개의 변수를 생성해서 모든 변수에 숫자 1를 할당할 때, 변수 영역에 모든 것을 저장한다면 총 8만 Byte가 필요할 것이다. 하지만 변수 영역과 데이터 영역으로 나눠서 저장하면 훨씬 더 효율적이다. 편의를 위해 변수 영역은 2 Byte라고 가정해보자.
먼저, 1만개의 변수를 생성해야 하므로 2만 Byte가 필요하다. 그리고 데이터 영역에 1을 저장해야하는데, 이때 필요한 공간은 8 Byte 밖에 안된다. 왜냐하면 데이터 영역의 주소를 변수 영역이 가져다 쓰기만 하면 되기 때문이다. 즉, 데이터 영역엔 굳이 1만개의 데이터 1을 저장할 필요가 없다. 이렇게 해서 얻은 공간은 총 2만 8 Byte이다. 8만 Byte였을 때 보다 더 적으니, 효율적이다.
다시 처음으로 돌아와, 불변성에 대해 이야기 해보자.
불변성은 메모리 관점으로 봐야한다고 했다. 여기서 봐야할 메모리는, 바로 데이터 영역이다. 불변하다는 것은 데이터 영역 메모리를 바꿀 수 없다는 뜻이고, 불변하지 않다는 건 데이터 영역 메모리를 바꿀 수 있다는 뜻이다.
이해를 위해 다시 메모리 영역으로 표현하자면 다음과 같다.
변수 영역 | 주소 | ... | 1002 | 1003 | 1004 | 1005 | ... |
데이터 | ... | 이름: test 데이터: 5005 |
... | ... | ... | ... | |
데이터 영역 | 주소 | ... | 5002 | 5003 | 5004 | 5005 | ... |
데이터 | ... | ... | ... | 'hello' | 'Hi' | ... |
위 메모리 영역은 변수명 test에 저장되어 있던 'hello'라는 값이, 'Hi'로 변경된 모습이다.
하지만 기존에 있던 'hello'는 여전히 5004에 저장되어 있다. 따라서 변수 test는 불변하다고 할 수 있다.
여기서 좀 더 첨언하자면, 더이상 사용되지 않는 5004는 가비지 컬렉터의 수거 대상이 된다.
그렇다면 참조형은 어떻게 진행될까?
가비지 컬렉터(GC, Garbage Collertor)
- 더 이상 사용되지 않는 객체를 자동으로 메모리에서 제거하는 역할을 한다. 이로 인해 JS에서는 개발자가 명시적으로 메모리 관리를 하지 않아도 되며, 개발자는 GC에 대한 직접적인 제어를 할 수 없다.
참조 카운트
- 객체를 참조하는 변수나 다른 객체의 수를 나타내는 값. 참조 카운트가 0인 객체는 더 이상 사용되지 않으므로, GC에 의해 메모리에서 제거된다.
참조형은 기본형과 다르게 별도 저장공간이 필요하다. 예를 들어 다음과 같은 참조형이 있다고 하자.
var obj1 = {
a: 1,
b: 'bbb,
};
변수 영역 | 주소 | 1001 | 1002 | 1003 | 1004 | 1005 | 1006 |
데이터 | 이름: obj1 데이터: 7103 ~ |
||||||
데이터 영역 | 주소 | 5001 | 5002 | 5003 | 5004 | 5005 | 5006 |
데이터 | 1 | 'bbb' |
obj1 별도 영역 |
주소 | 7103 | 7104 | 7105 | 7106 | 7107 | 7108 |
데이터 | 이름: a 데이터: 5001 |
이름: b 데이터: 5002 |
만약 여기서 obj1.a 가 2로 바뀌었다고 해보자.
변수 영역 | 주소 | 1001 | 1002 | 1003 | 1004 | 1005 | 1006 |
데이터 | 이름: obj1 데이터: 7103 ~ |
||||||
데이터 영역 | 주소 | 5001 | 5002 | 5003 | 5004 | 5005 | 5006 |
데이터 | 1 | 'bbb' | 2 |
obj1 별도 영역 |
주소 | 7103 | 7104 | 7105 | 7106 | 7107 | 7108 |
데이터 | 이름: a 데이터: 5003 |
이름: b 데이터: 5002 |
데이터 영역에 저장된 값은 여전히 바뀌지 않았지만, obj1을 위한 별도 영역은 변하고 말았다. 그리고 변수 영역은 obj1을 가리키고 있다.
불변하다와 불변하지 않는 걸 나누는 기준은 데이터 영역이다. 참조형 데이터의 데이터 영역은 데이터 영역 & 별도 영역이다. 따라서 별도 영역이 변경되어, 참조형은 불변하지 않다고 할 수 있다.
참고로 변수와 상수의 차이점은 변수 영역 메모리를 변경할 수 있는가 아닌가에 따라 나뉜다.
- 변수: 변수 영역 메모리를 변경할 수 있다.
- 상수: 변수 영역 메모리를 변경할 수 없다.
참조형에는 중첩객체라는 게 있는데, 중첩객체란 객체 안에 또 다른 객체가 들어있는 걸 뜻한다.
var obj = {
x: 3,
arr: [3, 4, 5],
}
변수 영역 | 주소 | 1001 | 1002 | 1003 | 1004 | 1005 | 1006 |
데이터 | 이름: obj1 데이터: 7103 ~ |
||||||
데이터 영역 | 주소 | 5001 | 5002 | 5003 | 5004 | 5005 | 5006 |
데이터 | 3 | 4 | 5 |
obj 별도 영역 |
주소 | 7103 | 7104 | 7105 | 7106 | 7107 | 7108 |
데이터 | 이름: x 데이터: 5001 |
이름: arr 데이터: 8104 |
arr 별도 영역 |
주소 | 8104 | 8105 | 8106 | 8107 | 8108 | 8109 |
데이터 | 이름: arr[0] 데이터: 5003 |
이름: arr[1] 데이터: 5002 |
이름: arr[2] 데이터: 5003 |
변수 복사는 말 그대로 기존에 있던 값을 그대로 복사하는 것이다. 이러한 과정은 다음과 같다.
var a = 10; //기본형
var obj1 = {
c: 10,
d: 'ddd'
}; //참조형
// 복사 수행
var b = a; //기본형
var obj2 = obj1; //참조형
변수 영역 | 주소 | 1001 | 1002 | 1003 | 1004 | 1005 | 1006 |
데이터 | 이름: a 데이터: 5001 |
이름: obj1 데이터: 7103 |
이름: b 데이터: 5001 |
이름: obj2 데이터: 7103 |
|||
데이터 영역 | 주소 | 5001 | 5002 | 5003 | 5004 | 5005 | 5006 |
데이터 | 10 | 'ddd |
obj1 별도 영역 |
주소 | 7103 | 7104 | 7105 | 7106 | 7107 | 7108 |
데이터 | 이름: c 데이터: 5001 |
이름: d 데이터: 5002 |
하지만 만약, 복사된 곳의 값을 변경하면 어떻게 될까?
변경된 값은 다음과 같다.
b = 15;
obj2.c = 20;
변수 영역 | 주소 | 1001 | 1002 | 1003 | 1004 | 1005 | 1006 |
데이터 | 이름: a 데이터: 5001 |
이름: obj1 데이터: 7103 |
이름: b 데이터: 5003 |
이름: obj2 데이터: 7103 |
|||
데이터 영역 | 주소 | 5001 | 5002 | 5003 | 5004 | 5005 | 5006 |
데이터 | 10 | 'ddd | 15 | 20 |
obj1 별도 영역 |
주소 | 7103 | 7104 | 7105 | 7106 | 7107 | 7108 |
데이터 | 이름: c 데이터: 5004 |
이름: d 데이터: 5002 |
하지만 여기서 문제가 있다.
a와 b는 서로 바라보는 데이터가 달라졌기에 아무런 영향이 없다. 하지만 obj1와 obj2는 현재 서로 같은 데이터를 바라보고 있다. obj1.c = 10이었고, 이를 복사한 obj2는 c를 20으로 변경했다. 서로 같은 데이터를 바라보고 있는 상태에서 값을 변경했기 때문에, 당연히 obj1도 영향을 받을 수밖에 없다.
즉, 기본형은 복사 이후 값을 변경해도 아무런 영향이 없지만, 참조형은 서로 바라보고 있는 곳이 같아 어느 한 곳을 변경해도 둘 다 동일한 값을 갖는다!
이를 방지하기 위해선 복사된 참조형이 데이터를 다르게 바라보도록 해야한다.
즉, 객체 자체를 변경하는 방식으로 값을 바꿔야 한다. 그래서 JS에는 얕은 복사와 깊은 복사라는 게 있다.
얕은 복사와 깊은 복사
이전에는 객체를 복사하고, 복사한 객체의 값을 바꾸면 원본의 값도 바뀌는 일이 있었다.
var a = 10; //기본형
var obj1 = {
c: 10,
d: 'ddd'
}; //참조형
// 복사 수행
var b = a; //기본형
var obj2 = obj1; //참조형
// 이전에 사용한 방식
b = 15;
obj2.d = 'aaa';
console.log("a: " + a + ", b: " + b);
// a: 10, b: 15
console.log("obj1.d: " + obj1.d + ", obj2.d: " + obj2.d);
// obj1.d: aaa, obj2.d: aaa
이는 서로 참조한 데이터 주소가 같아 생긴 일로, 해결하기 위해선 새로운 객체로 변경해야 한다.
// 이후 방식
obj2 = {
c: 10,
d: 'aaa'
};
console.log("obj1.d: " + obj1.d + ", obj2.d: " + obj2.d);
// obj1.d: ddd, obj2.d: aaa
하지만 이 방식은 속성이 많아질 수록 효율성이 떨어지니 다음과 같이 사용한다.
var copyObject = function (target) {
var result = {};
// for ~ in 구문을 이용하여, 객체의 모든 프로퍼티에 접근할 수 있다.
// 이 copyObject로 복사를 한 다음, 복사를 완료한 객체의 프로퍼티를 변경하면 된다.
for (var prop in target) {
result[prop] = target[prop];
}
return result;
}
var user = {
name: 'wonjang',
gender: 'male',
};
var user2 = copyObject(user);
user2.name = 'twojang';
이것을 얕은 복사라고 한다.
얕은 복사란, 아주 최소한만 복사를 하는 걸 말한다.
하지만 얕은 복사가 만능은 아니다. 왜냐하면 얕은 복사는 어디까지나 1차원적인 속성들만 복사를 해주기 때문이다. 중첩된 객체에서는 완벽한 복사를 할 수 없다. 즉, 객체의 내부 속성들은 원본과 같은 주소를 가리키게 된다.
var user = {
name: 'wonjang',
urls: {
portfolio: 'http://github.com/abc',
blog: 'http://blog.com',
facebook: 'http://facebook.com/abc',
}
};
var user2 = copyObject(user);
user2.name = 'twojang';
// 바로 아래 단계에 대해서는 불변성을 유지하기 때문에 값이 달라진다.
console.log(user.name === user2.name); // false
// 하지만 더 깊은 단계에 대해서는 불변성을 유지하지 못한다.
// 즉, 값을 바꾸면 원본도 바뀐다.
user.urls.portfolio = 'http://portfolio.com';
console.log(user.urls.portfolio); // http://portfolio.com
console.log(user2.urls.portfolio); // http://portfolio.com
user2.urls.blog = '???';
console.log(user.urls.blog); // ???
console.log(user2.urls.blog); // ???
그리고 이런 중첩된 객체까지 안전하게 변경하기 위해선 깊은 복사가 필요하다.
깊은 복사란, 객체 내부의 모든 값들을 하나하나 다 찾아서 모두 복사하는 방법을 말한다.
위의 코드에서 아래 코드만 추가하면 값이 바뀐 걸 확인할 수 있다.
user2.urls = copyObject(user.urls);
user.urls.portfolio = 'http://portfolio.com';
console.log(user.urls.portfolio); // http://portfolio.com
console.log(user2.urls.portfolio); // http://github.com/abc
user2.urls.blog = '???';
console.log(user.urls.blog); // http://blog.com
console.log(user2.urls.blog); // ???
하지만 이렇게 되면 또 중첩된 객체의 중첩된 객체는 또 원본과 동일한 주소를 가지기 때문에, 참조형 데이터는 다시 그 내부의 프로퍼티를 복사해야 한다. 즉, 자기 자신을 호출하여 반복적으로 실행되는 재귀적 수행이 필요하다
var copyObjectDeep = function(target) {
var result = {};
if (typeof target === 'object' && target !== null) {
for (var prop in target) {
// 오프젝트이면서 null이 아닌 경우 자기 자신을 호출한다.
result[prop] = copyObjectDeep(target[prop]);
}
} else {
result = target;
}
return result;
}
해당 내용들을 정리하자면 다음과 같다.
- 얕은 복사
- 객체의 최상위 속성들만 복사한다.
- 내부 속성의 경우, 참조값만 복사된다.
- 따라서 최상위 속성은 독립적이지만, 내부 속성은 서로 공유하게 된다.
- 깊은 복사
- 객체의 모든 속성이 재귀적으로 복사된다.
- 원본 객체와 복사된 객체는 완전히 독립적이다.
- 따라서 객체가 중첩된 구조일 때, 복사된 객체는 원본 객체와 아무런 참조 관계가 없다.
'언어 > JavaScript' 카테고리의 다른 글
24/08/16 - JavaScript(3 - 3): this (0) | 2024.08.16 |
---|---|
24/08/16 - JavaScript(3 - 2): 실행 컨텍스트, 호이스팅, 스코프 체인 (0) | 2024.08.16 |
24/08/14 - JavaScript(2 - 2): 일급 객체와 함수 (0) | 2024.08.14 |
24/08/14 - JavaScript(2 - 1): ES6 문법 (0) | 2024.08.14 |
24/08/13 - JavaScript(1): JS 언어의 역사, 특징 및 기본 문법 (0) | 2024.08.13 |