Primitive & reference
JS의 데이터 타입에는 기본형 혹은 원시형이라고 불리우는 Primitive type과 참조형 reference type이 있다.
위의 분류를 좀 더 정확하게 적자면 다음과 같습니다. 사실 Refence의 모든 타입들은 Object의 변형입니다. 편의를 위해 분리하여 적었을 뿐입니다.
Primitive : number string boolean null undefined (Symbol)
Reference : Object (Map, WeakMap, Set, WeakSet) Array Function Date Regex
(괄호 속의 자료형은 ES6에서 추가된 것을 말합니다.)
Primitive Type의 불변성 immutability
원시형과 참조형을 구별하는 가장 큰 특성은 깊은 복사와 얕은 복사입니다. 원시형은 깊은 복사를 하기 때문에 값 자체를 복사하는 반면 참조형은 참조, 즉 얕은 복사를 합니다. 얕은 복사란, 값이 아닌 주소를 참고한다는 의미입니다. 때문에 복사한 값을 수정하기 되면 원본 또한 수정이 됩니다.
간단히 예시를 들어보자면, 왼쪽의 이미지는 원시형 타입의 복사본을 수정할 경우 원본이 변화하지 않은 것을, 참조형인 배열의 경우 복사본을 수정하면 원본도 변하는 것을 확인할 수 있습니다. 이와 같은 원시형 타입의 속성을 "불변성"이라고 부릅니다. 모든 기본형 타입은 불변값입니다.
더 정확히는, a = 5를 할당한 후 2로 수정했을 때, a라는 식별자의 값 자체가 바뀌는게 아니라 2라는 숫자형을 담고 있는 새로운 데이터 공간을 만들어서 할당하여 덧붙입니다. 때문에 메모리의 어딘가에는 5를 담고 있는 공간과 2를 담고 있는 공간이 생깁니다.
따라서 변경은 값을 바꾼다기보다는 새로 만드는 동작을 통해서 이뤄지는 것이므로 이를 "불변"한다고 하는 것입니다. (이해가 안된다면 하단부의 메모리 부분을 읽고 옵시다)
이와 같은 immutable/mutable 함은 메모리 상에 변수가 저장되는 방법에 기인한 것입니다.
깊은 복사하기
json.stringify/parse과 같은 방법도 있으나 비추천합니다.
왜 비추천하나? don't block event loop를 읽어보시면 다음과 같은 문구가 있습니다.
JSON.parse and JSON.stringify are other potentially expensive operations. While these are O(n) in the length of the input, for large n they can take surprisingly long.
값비싼 메서드라는 거고, 가뜩이나 싱글 스레드인 이벤트 루프에서 코드가 돌면 blocking할 가능성이 있기 때문입니다. 단순 N이 깡으로 높아지면 무조건 blocking됩니다. lodash의 deepcopy 같은 메서드를 사용해보는 것이 좋습니다.
Nested Object
얕은 복사를 해준 후 객체 내부의 원소가 객체라면 mutable하게 바뀝니다.
const a = { name: "darren", item: [1, 3, 5] };
const b = { ...a }; // 얕은 복사를 해주었음
b.item.push(7);
console.log(a.item, b.item); // 원본 a의 item도 바뀜! [ 1, 3, 5, 7 ] [ 1, 3, 5, 7 ]
배열의 경우 concat을 하여 해결할 수 있지만 성능의 문제가 있으므로 lodash의 cloneDeep 메서드를 사용합시다.
const _ = require("lodash");
let test = {
a: "what",
b: {
c: "asdf",
d: "ttttt",
},
};
Object.freeze(test);
let test2 = _.cloneDeep(test);
test2.b.c = "aaaaa";
console.log(test.b); // { c: 'asdf', d: 'ttttt' }
console.log(test2.b); // { c: 'aaaaa', d: 'ttttt' }
객체를 아예 얼리는 방법도 있습니다. Object.freeze를 이용합시다.
const a = { first: 1, second: [1, 35] };
Object.freeze(a);
// freeze된 객체를 조작하고 싶다면 깊은 복사해서 이용해야 합니다.
const b = { ...a };
b.second = "what";
console.log(a, b); //{ first: 1, second: [ 1, 35 ] } { first: 1, second: 'what' }
함수의 원소로서 Object가 들어갔을 경우의 immutable
function fn(person) {
person.name = "darren";
}
const o1 = { name: "martin" };
// 바뀐다!
fn(o1);
console.log(o1);
function fn(person) {
// 받은 값을 얕은 복사해줍니다.
person = Object.assign({}, person);
person.name = "darren";
return person;
}
const o1 = { name: "martin" };
// 이제 원본은 안 바뀐다!
fn(o1);
console.log(o1, fn(o1)); // { name: 'martin' } { name: 'darren' }
function fn(person) {
person.name = "darren";
return person;
}
const o1 = { name: "martin" };
//애초에 값으로 새로운 객체를 준다!
const o2 = fn(Object.assign({}, o1));
console.log(o1, o2); //{ name: 'martin' } { name: 'darren' }
메모리와 데이터
메모리는 0/1로 표현되는 비트로 구성되어 있습니다. 그러나 비트 단위로 위치를 확인하는 것은 비효율적이므로 비트를 묶어 하나의 단위로 활용합니다. 검색 시간도 줄이고 표현할 수 있는 데이터의 갯수도 늘어나게 됩니다. 1비트 뿐이라면 0과 1만 존재하지만 8비트(1Byte)라면 2^8개 만큼의 데이터를 표현할 수 있을테니깐요. 상식적인 이야기입니다.
그러나 이렇게 많은 비트를 한 단위로 지정한 경우 낭비되는 비트가 생기기도 합니다. 적절한 크기를 논의한 결과 8비트를 하나의 단위로 묶은 1Byte라는 단위가 생겨나게 되었습니다. 물론 이 Byte를 각 데이터 타입에 얼마나 할당할 것인지에 대해서도 메모리 낭비를 최소화하기 위해 머리를 써야 합니다.
C/C++, JAVA와 같은 정적 타입 언어는 메모리의 낭비를 막기 위해 데이터 타입 별로 Byte를 할당해놓았습니다. 예시로 C언어의 기본 자료형의 메모리 크기를 가져와 보았습니다.
자료형 | 키워드 |
메모리 크기 | 값의 범위 |
문자형 | char | 1 Bytes | -128~127 |
정수형 | short | 2 Bytes | -32,768~32,767 |
int | 4 Bytes | -2,147,483,648 ~ 2,147,438,647 |
|
long | 4 Bytes | -2,147,483,648 ~2.147.483.647 |
|
부호없는 문자형 | unsigned char | 1 Bytes | 0~255 |
부호없는 정수형 | unsigned short | 2 Bytes | 0~65,535 |
unsigned int | 4 Bytes | 0~4,294,967,295 | |
unsigned long | 4 Bytes | 0~4,294,967,295 | |
부동 소수형 | float | 4 Bytes | 1.2E-38~3.4E38 |
double | 8 Bytes | 2.2E-308~1.8E308 | |
void형 | void | 0 Bytes | 값 없음 |
그러나 메모리 용량이 커짐에 따라 메모리 관리에 대한 압박에서 어느 정도 자유로워진 현재에 JS는 좀 더 메모리 공간을 넉넉하게 할당합니다. 예를 들어 JS에서는 정수형과 부동소수형을 구별하지 않고 Number에는 그냥 8Byte를 할당합니다. 이러한 넉넉한 메모리 공간 할당이 JS에서 깐깐하게 형을 할당하지 않아도 되는 이유입니다.
메모리 동작 방식
앞서 살펴본 메모리에 어떤 방식으로 데이터가 담기고 활용되는지를 알아보겠습니다.
var a = "Darren"
🎈 식별자와 변수
여기서 a는 '식별자'라고 부르며 a가 담기는 메모리 공간을 '변수'라고 부릅니다.
변수를 '그릇'이라고 비유하는 것은 이 때문입니다.
흔히 a를 변수라고 부르지만 이는 편의를 위한 것이며 엄밀히 말하면 틀린 것입니다.
데이터는 변수 영역의 빈 주소에서 식별자(이름)을 넣은 후, 데이터 영역의 빈주소에 문자열 "Darren"을 저장합니다. 그 이후에야 변수 영역에서 값을 담고 있는 주소를 지정합니다.
질문은 왜? 입니다. 직접 값을 담으면 될텐데 굳이 메모리를 이렇게 별도의 공간에 나누는 이유가 있을까요? 네. 정답은 상식적입니다. 당연히 데이터 변환 및 중복 데이터를 처리하기 위한 것입니다. 500개의 변수에 모두 "Darren"이란 문자열을 넣으려고 할 때 데이터 주소를 500개 할당하는 건 너무 바보 같은 짓입니다.
한편, Object는 다음과 같은 방식으로 메모리에 할당됩니다.
var obj = {
a: 1,
b: "Darren"
}
여기서 우리는 참조형 타입인 객체가 왜 (불변값이 아닌) 가변값을 가지는 지 알 수 있습니다. 새로운 값을 할당하게 되면 기본형 타입은 다른 데이터 공간을 할당한 후 값을 대입하겠지만 객체는 해당 원소들의 주소를 값(@7003 ~ ?)을 값으로 가지고 있으므로 바뀌지 않기 때문입니다.
🎈 가비지 컬렉터 (GC)
만약 위 변수에(기본형이든 참조형이든) 할당한 값을 다른 것으로 바꾸게 된다면 기존의 값은 쓰이지 않습니다. 참조 카운트가 0이라는 말입니다. 런타임 환경에 따라 특정 시점이나 메모리 사용량이 일정 이상이 된다면 참조 카운트가 0인 공간들을 수거(Collect)합니다. 즉, 가비지 컬렉터는 참조되지 않은 채 공간만 차지하는 데이터를 삭제하는 일을 하는 것입니다.
'Programming Language > 🟨 Javascript (Core)' 카테고리의 다른 글
this(2) : 명시적으로 this를 바인딩하기 (0) | 2020.04.15 |
---|---|
this(1) : this에 대한 모든 것 (0) | 2020.04.15 |
실행 컨텍스트(2) : 스코프, 스코프체인 (0) | 2020.04.15 |
실행 컨텍스트(1) : 실행 컨텍스트와 호이스팅 (0) | 2020.04.14 |
코어 자바스크립트 : 소개 (0) | 2020.04.14 |