JavaScript는 동적 타입 언어이다. 따라서 변수를 선언할 때 명시적으로 타입을 지정하지 않고, 런타임 시에 값에 따라 동적으로 타입이 결정(추론; Type Inference)된다. 이 특성은 JavaScript를 유연하게 사용할 수 있도록 해주지만, 예기치 못한 동작이 발생할 수 있는 원인이기도 하다. 원시 타입과 참조 타입의 구분은 이러한 동적 타입 결정의 특성을 더욱 강조한다. 자세히 살펴보자.
원시 타입
원시 타입은 실제 값이 변수에 직접 저장된다. 원시 타입은 변경 불가능한(immutable) 값이다.
- 원시 값을 가진 변수에 새로운 값을 다시 할당할 수는 있어도, 원시 값 자체를 변경하는 것은 아니다.
- 원시 값을 가진 변수를 다른 변수에 할당하면 원본의 원시 값이 복사되어 전달된다. (
= call by value
) - JavaScript의 원시 타입으로
Null
,Undefined
,Boolean
,Number
,BigInt
,String
그리고Symbol
이 있다.
불변성(immutable)
값(또는 상태)이 변경되지 않는 특성이다. 값에 대한 수정이 일어나도 메모리 값이 변경되는 것이 아니라, 새로운 메모리 공간을 확보하는 성질이다.
원시 타입의 값은 변경이 불가능하다고 했다. 여기서 헷갈릴 수 있는 점은 원시 값 자체를 변경할 수 없다는 것이지 변수의 값을 변경할 수 없다는 의미가 아니다. 변수는 언제든지 재할당을 통해 변수 값을 변경할 수 있다.
- 먼저 변수
score
가 선언될 때 메모리 공간이 할당된다. 이때 값은undefined
로 초기화된다. -
score
에80
이라는 원시 값을 할당한다. 이때 메모리 공간에는 값80
이 저장되고,score
는80
의 메모리 주소0x00001332
를 참조한다. score
에90
이라는 원시 값을 재할당한다. 이때 기존에80
이 저장된 메모리 공간에서 값을 수정(바꾸는 것)이 아니라, 새로운 메모리 공간에 값90
을 저장하고score
는90
의 메모리 주소0x0669F913
를 참조한다.
즉, 메모리 공간에는 80
과 90
이 모두 존재하고 있다. 원시 값은 불변성을 가지기 때문에 이전 메모리 공간에 생성된 값은 변경할 수 없다. 재할당한 값을 새로운 메모리 공간에 생성하고 score
가 참조하던 메모리 주소가 바뀌는 것이다. 따라서, 우리 눈에는 score
의 메모리 주소가 바뀌었기 때문에 값이 재할당된 것처럼 보이는 것이다.
값에 의한 전달(call by value)
원시 값을 가진 변수를 다른 변수에 할당하면 원본의 원시 값이 복사되어 전달된다.
let num1 = 42;
let num2 = num1; // num1의 값이 num2에 복사됨
console.log(num1); // 42
console.log(num2); // 42
num1 = 50; // num1의 값을 변경
console.log(num1); // 50
console.log(num2); // 42 (num2는 영향을 받지 않음)
변수 num1
에 42
라는 값이 할당된다. 그리고 변수 num2
에는 num1
의 값이 복사되어 할당된다. 따라서 처음에는 두 변수가 같은 값을 가지고 있다. 이를 값에 의한 전달이라고 한다. 여기서 중요한 점은 변수 num1
과 num2
의 값 42
는 서로 다른 메모리 공간에 저장되어 있다는 것이다.
그럼 num1
의 값을 50
으로 변경하게 되면 어떻게 될까? num1
의 값을 변경해도 num2
는 영향받지 않는다. 왜냐하면 각 변수에는 값의 복사본이 저장되어 있기 때문이다. 즉, 새로운 메모리 공간에 값 50
을 저장하고 num1
은 50
의 메모리 주소를 참조한다.
참조 타입
참조 타입은 JavaScript에서 객체(Object)를 나타내며, 변수에는 해당 객체의 메모리 주소(참조)가 저장된다. 참조 타입은 변경 가능한(mutable) 값이다.
- 객체는 생성된 후에 내부의 속성을 추가, 수정, 삭제할 수 있다. 이는 객체가 메모리에 할당된 후에도 객체의 상태가 변경될 수 있다는 것을 의미한다.
- 이 뜻은 곧 객체는 원시 값과 같이 확보해야 할 메모리 공간의 크기를 사전에 정해둘 수 없다는 말이다. 또, 객체는 복합적인 자료구조이므로 객체를 관리하는 방식이 복잡하며, 객체 생성 후 프로퍼티에 접근하는 것도 비용이 많이 든다.
- 객체를 참조하는 변수를 다른 변수에 할당하면 원본의 참조 값이 복사되어 전달된다. (
= call by reference
) - 원시 타입을 제외한 나머지는 참조 타입이라 할 수 있다. 대표적으로
Object
,Array
,Function
이 있다.
변경 가능한 값(mutable)
원시 타입은 실제 값이 변수에 저장되는 반면에, 참조 타입은 객체를 직접 저장하는 것이 아니라 메모리 공간에 대한 참조 값, 즉, 메모리 주소를 저장한다.
- 객체가 생성되고 메모리 공간에 저장된다.
name
속성을 갖는다. 이 객체의 메모리 주소는0x00001332
이다. person
변수가 선언되고 객체를 참조하는 참조 값(메모리 주소)가 변수에 할당된다.- 따라서, 변수
person
이 이 객체를 참조하는(가리키는) 것이기 때문에person
에 할당된 값은 해당 객체의 참조 값이다. - 객체를 할당한 변수가 기억하는 메모리 주소를 통해 메모리 공간에 접근하면, 참조 값(reference value)에 접근할 수 있다. 참조 값은 생성된 객체가 저장된 메모리 공간의 주소이다.
이제 변수 person
을 통해 객체의 속성에 접근할 수 있다. 예를 들어 person.name
은 실제로 person
변수가 가리키는 메모리 주소로 이동하여 해당 주소에 있는 객체의 name
속성에 접근한다. 객체는 변경 가능한 값이므로 메모리에 저장된 객체를 직접 수정할 수 있다.
person.name = ‘Kim’;
:person
객체의name
속성 값이 변경된다. 이제name
의 속성은‘Kim’
으로 업데이트 된다.person.address = ‘Seoul’;
:person
객체의address
속성이 추가됨과 동시에‘Seoul’
로 업데이트 된다.- 이렇게 참조 타입은 기존 속성을 업데이트하거나 새로운 속성을 추가할 수 있다. 이때 객체를 할당한 변수에 재할당을 하지 않았으므로 객체를 할당한 변수의 참조 값은 변경되지 않는다.
참조에 의한 전달(call by reference)
객체를 참조하는 변수를 다른 변수에 할당하면 원본의 참조 값이 복사되어 전달된다. 다시 말해, 참조 타입의 변수에 값을 할당할 때, 객체 자체가 복사되는 것이 아니라 해당 객체를 가리키는 참조 값이 복사되는 것을 의미한다.
var person = {
name: 'Son'
}
// person을 가리키는 참조 값이 copy 변수에 복사됨
var copy = person;
// 원복 객체 변경
person.name = 'Lee';
console.log(copy.name); // Lee
변수 copy
는 person
을 가리키는 참조 값을 복사한다. 그래서 person
의 name
속성을 ‘Lee’
로 변경하면 copy
변수를 통해서도 같은 변경이 반영된다. 이는 객체 자체가 복사되는 것이 아니라, 참조 값이 복사되어 두 변수가 같은 객체를 공유하기 때문에 발생하는 동작이다. 이를 참조에 의한 전달이라고 한다.
얕은 복사(shallow copy)와 깊은 복사(deep copy)
앞서 언급했듯이 객체를 생성하고 관리하는 방식은 매우 복잡하고 비용이 많이 든다. 객체를 변경할 때마다 원시 값처럼 이전 값을 복사해서 새롭게 생성한다면 명확하고 신뢰성이 확보되겠지만, 객체는 메모리 크기가 일정하지 않다. 만약, 객체의 속성이 또 객체라면? 복사(deep copy)해서 생성하는 비용이 더 많이 든다는 것이다. 이와 같이 메모리를 효율적으로 사용하기 위해, 객체를 복사해서 생성하는 비용을 절약해 성능을 향상시키기 위해 객체는 변경 가능한 값으로 설계되어 있다.
이는 객체가 가지는 구조적인 단점이라고 생각한다. 그리고 이 단점에 대한 부작용도 존재한다. 바로 원시 값과 다르게 여러 개의 식별자가 하나의 객체를 공유할 수 있다는 것이다.
원시 값, 참조 값의 복사
객체의 얕은 복사, 깊은 복사를 알아보기 전에 원시 값, 참조 값 복사 방식의 차이에 대해 다시 정리하고 넘어가자.
- 원시 값을 저장한 변수에는 실제 데이터 값이 저장된다. 원시 값을 복사할 때, 원본의 원시 값이 복사되어 전달된다. 복사된 값은 다른 메모리 공간에 할당하기 때문에 원래의 값과 복사된 값이 서로에게 영향을 미치지 않는다.
- 참조 값을 저장한 변수에는 실제 객체가 저장되는 것이 아니라, 객체가 저장된 참조 값(메모리 주소)이 저장된다. 참조 값을 복사할 때, 원본 객체의 참조 값이 복사되어 전달된다. 즉, 두 변수가 같은 객체를 공유하는 것이다.
객체의 얕은 복사, 깊은 복사
객체를 복사하는 두 가지 방법을 나타내는 얕은 복사와 깊은 복사의 주요 차이점은 객체 안에 중첩된 객체가 있을 때 발생한다. 객체를 복사하는 방법에는 Object.assign
, Spread Operator,
JSON.parse
, StructuredClone
등이 있는데, 각각의 예시와 함께 복사 유형(얕은 복사, 깊은 복사)을 알아보자.
얕은 복사
얕은 복사는 원본 객체의 최상위 레벨의 속성들만 새로운 객체로 복사하며, 중첩된 객체는 원본 객체의 참조 값을 복사한다. 결과적으로 얕은 복사한 객체는 최상위 레벨의 속성은 별도의 메모리에서 복사되지만 중첩된 객체는 원본과 동일한 객체를 참조하게 된다.
1️⃣ Object.assign
var original = { name: 'Son', age: 31 };
var copy = Object.assign({}, original);
copy.age = 32;
console.log(original); // { name: 'Son', age: 31 }
console.log(copy); // { name: 'Son', age: 32 }
첫 번째 코드에서 original
이 얕은 복사되어 copy
를 만들었다. copy
안에 age
속성을 변경하더라도 original
은 영향 받지 않는다.
var original = { name: 'Son', age: 31, team: { name: 'Spurs' } };
var copy = Object.assign({}, original);
copy.age = 32;
copy.team.name = 'City';
console.log(original); // { name: 'Son', age: 31, team: { name: 'City' } }
console.log(copy); // { name: 'Son', age: 32, team: { name: 'City' } }
두 번째 코드에서 original
에 team
이라는 중첩된 객체가 있다. 여전히 Object.assign({}, original)
을 통해 얕은 복사가 이루어졌지만, 중첩된 객체인 team
은 같은 참조를 공유하게 된다. 따라서 copy
에서 team
속성을 수정하면 original
도 같은 변경을 반영한다.
만약, copy
의 team
값만 변경하고 싶다면? 객체 자체를 재할당하면 된다.
var original = { name: 'Son', age: 31, team: { name: 'Spurs' } };
var copy = Object.assign({}, original);
copy.team = { name: 'City' };
console.log(original); // { name: 'Son', age: 31, team: { name: 'Spurs' } }
console.log(copy); // { name: 'Son', age: 31, team: { name: 'City' } }
2️⃣ Spread Operator
마찬가지로 얕은 복사이다.
/** 객체 복사 **/
var original = { name: 'Son', age: 31 };
var copy = { ...original };
copy.age = 32;
console.log(original); // { name: 'Son', age: 31 }
console.log(copy); // { name: 'Son', age: 32 }
/** 중첩된 객체 복사 **/
var original = { name: 'Son', age: 31, team: { name: 'Spurs' } };
var copy = { ...original };
copy.team.name = 'City';
console.log(original); // { name: 'Son', age: 31, team: { name: 'City' } }
console.log(copy); // { name: 'Son', age: 31, team: { name: 'City' } }
깊은 복사
깊은 복사는 원본 객체 및 해당 객체 내에 있는 모든 중첩된 객체들까지 모두 복사하는 것을 의미한다. 결과적으로 원본 객체와 깊은 복사된 객체는 완전히 독립적인 복사본이 되어 모든 속성과 중첩된 객체가 새로운 메모리 공간에 복사된다.
1️⃣ JSON.parse() & JSON.stringify()
복사할 객체를 String 객체로 변경하고, String 객체를 JSON 객체로 변경하면 깊은 복사가 가능하다. JSON.parse()
는 String 객체를 Json 객체로, JSON.stringify()
는 Json 객체를 String 객체로 변환한다.
var original = { name: 'Son', age: 31, team: { name: 'Spurs' } };
var copy = JSON.parse(JSON.stringify(original));
copy.team.name = 'City';
console.log(original); // { name: 'Son', age: 31, team: { name: 'Spurs' } }
console.log(copy); // { name: 'Son', age: 31, team: { name: 'City' }
original
을 깊은 복사하여 copy
를 만들었다. copy
는 name
, age
, team
모두가 완전히 새로운 값으로 복사되었고, 내부 객체인 team
도 완전히 새로운 객체를 참조한다. 깊은 복사는 중첩된 객체들까지 완전히 복사하기 때문에 변경 사항이 서로에게 영향을 미치지 않는다.
JSON.parse()
, JSON.stringify()
를 사용할 때 주의할 점이 있다. 이 방법은 원시 타입 값 외에 함수나 특별한 객체 속성 (예: undefined
, Symbol
등) 등을 다루지 못한다. 또한 성능이 느리다는 단점도 있다.
2️⃣ lodash 라이브러리
lodash
라이브러리의 _.cloneDeep()
함수를 사용하면 쉽고 안전하게 깊은 복사를 할 수 있다. 이 함수는 중첩된 객체까지 재귀적으로 복사하여 완전한 복사본을 생성한다.
const _ = require('lodash');
const original = { name: 'Son', age: 31, team: { name: 'Spurs' } };
const copy = _.cloneDeep(original);
console.log(original === copy); // false
console.log(original.name === copy.name); // false
마지막에 original
과 copy
의 비교한 결과가 false
이다. 이 결과는 lodash
의 _.cloneDeep()
함수가 객체를 깊은 복사하여 별도의 메모리 공간에 새로운 객체를 생성한 것을 의미한다. 따라서 두 객체는 독립적으로 변경될 수 있다.
정리
- 원시 타입은 변경이 불가능한(immutable) 값이다.
- 원시 타입은 실제 값이 변수에 직접 저장된다.
- 원시 값을 복사하며 데이터를 바꿔도 다른 데이터에 영향을 미치지 않는다.
- 참조 타입은 변경 가능한(mutable) 값이다.
- 참조 타입은 객체를 직접 저장하는 것이 아니라 메모리 공간에 대한 참조 값, 즉, 메모리 주소를 저장한다.
- 메모리 주소(참조 값)을 복사하며 데이터를 바꾸면 참조하는 모두에게 영향을 미친다.
- 얕은 복사는 중첩된 객체에 대해서는 참조값을 복사하므로 원본과 복사본이 중첩된 객체를 공유하게 된다.
- 깊은 복사는 중첩된 객체까지 모두 복사하므로 원본과 복사본은 완전히 독립적이다.