JS에서는 변수가 원시값을 갖는 지 참조값을 갖는 지에 따라, 복사 시 얕은 복사 또는 깊은 복사가 이루어진다. 얕은 복사와 깊은 복사가 어떻게 다른 지, 이러한 차이는 왜 발생하는 지 알아보자.
"자바스크립트에서 모든 것은 객체다" 라는 말이 있다. 하지만 실제로 모든 것이 객체는 아니다.
자바스크립트의 데이터 타입은 크게 원시 타입과 객체 타입으로 나눌 수 있고, 원시값(string, number, boolean, undefined, null, symbol)을 제외한 나머지 값(함수, 배열, 정규 표현식 등)은 모두 객체이다.
원시 타입 변수의 값을 원시값, 객체 타입 변수의 값을 참조값이라고 한다. 원시값과 참조값의 차이점을 알아보자.
가질 수 있는 값
변경 가능 여부
할당 연산자 =
를 통해 변수에 원시 값을 갖는 변수를 할당하면, 원시 값이 복사되어 전달된다.
이 때, score 변수와 copy 변수는 숫자 값 80을 갖는다는 점에서는 동일하지만 그림에서 볼 수 있 듯이, 두 변수의 값 80은 서로 다른 메모리 공간에 저장된 별개의 값이다.
할당 연산자 =
를 통해 변수에 객체 값을 갖는 변수를 할당할 때는, 그 객체의 참조값이 전달된다.
var person = {
name: "Lee"
};
var copy = person;
console.log(person === copy); // true
즉, 원본 person을 사본 copy에 할당하면 원본 person의 참조 값을 복사해서 copy에 저장한다. 원본 person과 사본 copy에 저장된 메모리 주소는 다르지만 동일한 참조 값을 갖는다.
다시 말해, 원본 person과 사본 copy 모두 동일한 객체를 가리킨다. 이는 두 개의 식별자가 하나의 객체를 공유한다는 것을 의미한다.
이러한 원시값과 참조값의 특징 때문에 원시 값을 복사할 때는 깊은 복사가, 참조값을 복사할 때는 얕은 복사가 일어난다. 이제 얕은 복사와 깊은 복사가 무엇인지 알아보자.
얕은 복사 => 데이터의 참조값(주소값)을 복사
깊은 복사 => 데이터의 실제 값을 복사
우리가 일반적으로 slice()
, assign()
, spread
연산자를 통해 변수를 복사할 때, 변수가 가리키는 메모리 공간에 저장되어 있는 값을 복사해서 새로운 변수를 만든다.
하지만 변수에 저장되어 있는 값은 원시값 또는 참조값일 수 있고
원시값은 값 자체로 복사되어 전달되지만
=> 깊은 복사
참조값은 주소 값이기 때문에 결국 객체의 값들이 전달되는 것이 아니라, 객체의 참조값이 전달되는 것이다.
=> 얕은 복사
얕은 복사의 특징은, 객체의 참조값이 전달되기 때문에 복사 후, 복사본의 내용을 수정하면 원본의 내용도 같이 수정된다는 것이다.
얕은 복사와 깊은 복사가 수행되는 예시를 살펴보자.
slice()
는 배열로부터 특정 범위를 복사해 새로운 배열을 만드는 함수이다. slice(begin, end)
에서 begin
인덱스에서부터 end
인덱스까지의 요소들을 복사하고, 두 인자가 모두 생략되면 배열 전체를 복사한다.
slice()
가 새로운 배열을 만들고, 각 기존 배열 아이템의 복사를 수행할 때, 원시 값의 복사는 기본적으로 깊은 복사이기 때문에, 만약 원시값만을 가지고 있는 1차원 배열을 slice()
하게 되면 깊은 복사를 수행한 것처럼 동작한다.
const original = ['A', 1, true, 2, "str"];
const copy = original.slice(); // 각 배열 아이템을 복사한 새로운 객체를 생성
console.log(original === copy); // false(copy는 새로운 배열이기 때문에 새로운 참조값을 가지고 있음)
console.log(JSON.stringify(original) === JSON.stringify(copy)); // true
copy.push(10);
console.log(JSON.stringify(original) === JSON.stringify(copy)); // false
console.log(original); // ['A', 1, true, 2, "str"]
console.log(copy); // ['A', 1, true, 2, "str", 10]
하지만 1차원 배열에 객체, 배열 등의 참조값이 있거나, 2차원 배열을 slice()
하게 되면 그 값들은 참조값이기 때문에 얕은 복사가 수행된다.
const original =
[
{
a: 1,
b: 2,
},
true,
];
const copy = original.slice();
console.log(original === copy); // false
console.log(JSON.stringify(original) === JSON.stringify(copy)); // true
// 복사된 배열의 값들을 변경
copy[0].a = 99;
copy[1] = false;
console.log(JSON.stringify(original) === JSON.stringify(copy)); // false
console.log(original);
// [ { a: 99, b: 2 }, true ]
console.log(copy);
// [ { a: 99, b: 2 }, false ]
객체 안의 값들은 얕은 복사가 이루어지지만, slice()
를 통해 copy
라는 새로운 객체를 생성한 것이기에 original
과 copy
의 참조값은 같지 않다. 따라서, copy
객체에 새로운 프로퍼티를 추가하면, 이는 원본 객체에 반영되지 않는다.
copy.newProp = 1;
console.log(copy.newProp); // 1
console.log(original.newProp); // undefined
const object = {
a: "a",
number: {
one: 1,
two: 2,
},
};
const copy = Object.assign({}, object);
copy.number.one = 3;
console.log(object === copy); // false =>
console.log(object.a === copy.a); // true => 원시값 : 깊은 복사
console.log(object.number === copy.number); // true => 참조값 : 얕은 복사
console.log(object.number.one === copy.number.one); // true => 참조값 : 얕은 복사
Object.assign()
으로 생성된 새로운 객체인 copy
는 object
와 동일하지 않지만(copy
객체 자체의 참조값은 object
객체의 참조값과 다르지만), copy
객체 안의 값들은 object
객체 안의 값들을 얕은 복사한 것이기 때문에 동일한 참조값을 가지고 있다.
const object = {
a: "a",
number: {
one: 1,
two: 2,
},
};
const copy = { ...object };
copy.number.one = 3;
console.log(object === copy); // false
console.log(object.a === copy.a); // true
console.log(object.number === copy.number); // true
console.log(object.number.one === copy.number.one); // true
spread 연산자를 통해 생성된 새로운 객체 안의 값들도 기본적으로는 얕은 복사를 수행하고, 원시값들은 깊은 복사가 이루어진다.
이렇게 객체 안에 객체가 중첩되어 있을 때, 깊은 복사가 이루어지지 않고 얕은 복사가 이루어지는 것을 볼 수 있다. 하지만 우리가 일반적으로 복사를 하는 목적은 기존 객체의 값만 복사본으로 가져와 별도로 활용하기 위함이 대부분일 것이다. 복사된 객체를 수정했는데 기존 객체의 값까지 같이 변경된다면 이것은 복사를 하는 목적에 벗어날 것이다.
따라서 객체 안에 객체가 있을 경우에도 원본과의 참조가 완전히 끊어지도록 깊은 복사를 해야할 때, 어떤 방법을 사용할 수 있는지 알아보자.
const obj =
str: "str",
num: {
num1: 1,
num2: 2,
},
arr: [1, 2, [3, 4]],
};
const copy = JSON.parse(JSON.stringify(obj));
copy.num.num1 = 3;
copy.arr[2].push(5);
console.log(obj === copy); // false
console.log(obj.num.num1 === copy.num.num1); // false
console.log(obj.arr === copy.arr); // false
JSON.stringify()
는 객체를 json 문자열로 변환하고, 이 과정에서 원본 객체와의 참조가 모두 끊어진다. 객체를 json 문자열로 변환 후, JSON.parse()
를 이용해 다시 원래 객체로 만들어준다.
이 방법은 굉장히 간단하고 쉽지만 다른 방법에 비해 느리다는 것과 객체가 function일 경우, undefined로 처리한다는 단점이 있다. 또한, 특정 타입에 대해서는 우리가 원하는 복사가 이루어지지 않는다.
const obj = {
name: "Minwoo",
date: new Date("2023-07-28"),
};
// JSON.stringify는 date 객체 값을 문자열로 변환합니다.
console.log(obj);
console.log(typeof obj.date);
const copy = JSON.parse(JSON.stringify(obj));
console.log(copy);
console.log(typeof copy.date);
JSON은 객체를 문자열로 인코딩하는 형식이다. 직렬화를 사용하여 객체를 문자열로 변환하고, 역직렬을 통해 문자열을 객체로 변환한다.
Date
객체는 JSON.stringify()
를 통해 문자열로 변환되고, 이를 JSON.parse()
를 사용해도 이후에 다시 Date
객체로 변환되지 않고 문자열로 취급된다.
즉, Date
객체에 대한 우리가 기대한 복사가 수행되지 않는다. 이러한 특성 때문에 JSON.stringify
는 기본 객체, 배열 및 원시 타입만 다를 수 있다.
function deepCopy(obj) {
// 원시 값일 때,값을 할당해줌
if (obj === null || typeof obj !== "object") {
return obj;
}
// 객체인지 배열인지 판단
const copy = Array.isArray(obj) ? [] : {};
for (let key of Object.keys(obj)) {
copy[key] = deepCopy(obj[key]);
}
return copy;
}
const copy = deepCopy(obj);
재귀 함수를 통해, 복사하려는 대상이 객체값이라면 그 안의 자식 요소들까지 모두 참조 형태가 아닌 원시 값 형태로 복사할 수 있도록 구현할 수 있다.
copy = _.cloneDeep(obj);
라이브러리를 사용하면 더 쉽고 안전하게 깊은 복사를 할 수 있지만, 라이브러리를 설치해야 한다는 단점이 있다.
22년도부터 JS 자체에서Web API
로 깊은 복사를 위한 structuredClone API를 제공한다.
const copy = structuredClone(original);
structuredClone()
은 내부적으로 structured clone 알고리즘을 이용해 깊은 복사를 수행하고, 이는 비교적 뛰어난 성능을 보여주며, 위에서 보았던 JSON.parse(JSON.stringify( x ))
를 통한 깊은 복사의 문제점을 해결할 수 있다.
console.log(obj);
console.log(typeof obj.date);
const copy = structuredClone(obj);
console.log(copy);
console.log(typeof copy.date);
그러나 structuredClone()
을 이용한 복사에도 여전히 몇 가지 제한 사항이 있다.
structuredClone()
함수를 사용하는 게 권장된다.