얕은 복사 vs 깊은 복사

박민우·2023년 7월 28일
3

JavaScript

목록 보기
6/14
post-thumbnail

JS에서는 변수가 원시값을 갖는 지 참조값을 갖는 지에 따라, 복사 시 얕은 복사 또는 깊은 복사가 이루어진다. 얕은 복사와 깊은 복사가 어떻게 다른 지, 이러한 차이는 왜 발생하는 지 알아보자.


📌 원시값 vs 참조값

"자바스크립트에서 모든 것은 객체다" 라는 말이 있다. 하지만 실제로 모든 것이 객체는 아니다.

자바스크립트의 데이터 타입은 크게 원시 타입객체 타입으로 나눌 수 있고, 원시값(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 모두 동일한 객체를 가리킨다. 이는 두 개의 식별자가 하나의 객체를 공유한다는 것을 의미한다.

이러한 원시값과 참조값의 특징 때문에 원시 값을 복사할 때는 깊은 복사가, 참조값을 복사할 때는 얕은 복사가 일어난다. 이제 얕은 복사와 깊은 복사가 무엇인지 알아보자.


📌 얕은 복사 vs 깊은 복사

  • 얕은 복사 => 데이터의 참조값(주소값)을 복사

  • 깊은 복사 => 데이터의 실제 값을 복사

우리가 일반적으로 slice(), assign(), spread 연산자를 통해 변수를 복사할 때, 변수가 가리키는 메모리 공간에 저장되어 있는 값을 복사해서 새로운 변수를 만든다.

하지만 변수에 저장되어 있는 값은 원시값 또는 참조값일 수 있고

  • 원시값은 값 자체로 복사되어 전달되지만
    => 깊은 복사

  • 참조값은 주소 값이기 때문에 결국 객체의 값들이 전달되는 것이 아니라, 객체의 참조값이 전달되는 것이다.
    => 얕은 복사


얕은 복사의 특징은, 객체의 참조값이 전달되기 때문에 복사 후, 복사본의 내용을 수정하면 원본의 내용도 같이 수정된다는 것이다.

얕은 복사와 깊은 복사가 수행되는 예시를 살펴보자.


slice()

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라는 새로운 객체를 생성한 것이기에 originalcopy의 참조값은 같지 않다. 따라서, copy 객체에 새로운 프로퍼티를 추가하면, 이는 원본 객체에 반영되지 않는다.

copy.newProp = 1;
console.log(copy.newProp); // 1
console.log(original.newProp); // undefined

assign()

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()으로 생성된 새로운 객체인 copyobject와 동일하지 않지만(copy 객체 자체의 참조값은 object 객체의 참조값과 다르지만), copy 객체 안의 값들은 object 객체 안의 값들을 얕은 복사한 것이기 때문에 동일한 참조값을 가지고 있다.


spread 연산자

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 연산자를 통해 생성된 새로운 객체 안의 값들도 기본적으로는 얕은 복사를 수행하고, 원시값들은 깊은 복사가 이루어진다.


이렇게 객체 안에 객체가 중첩되어 있을 때, 깊은 복사가 이루어지지 않고 얕은 복사가 이루어지는 것을 볼 수 있다. 하지만 우리가 일반적으로 복사를 하는 목적은 기존 객체의 값만 복사본으로 가져와 별도로 활용하기 위함이 대부분일 것이다. 복사된 객체를 수정했는데 기존 객체의 값까지 같이 변경된다면 이것은 복사를 하는 목적에 벗어날 것이다.

따라서 객체 안에 객체가 있을 경우에도 원본과의 참조가 완전히 끊어지도록 깊은 복사를 해야할 때, 어떤 방법을 사용할 수 있는지 알아보자.


📌 깊은 복사 방법

1. JSON.parse() && JSON.stringify

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기본 객체, 배열 및 원시 타입만 다를 수 있다.


2. 재귀함수

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);

재귀 함수를 통해, 복사하려는 대상이 객체값이라면 그 안의 자식 요소들까지 모두 참조 형태가 아닌 원시 값 형태로 복사할 수 있도록 구현할 수 있다.


3. lodash

copy = _.cloneDeep(obj);

라이브러리를 사용하면 더 쉽고 안전하게 깊은 복사를 할 수 있지만, 라이브러리를 설치해야 한다는 단점이 있다.


4. structuredClone

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()을 이용한 복사에도 여전히 몇 가지 제한 사항이 있다.

  • 함수, DOM 노드, ERROR, 프로토타입, 프로퍼티 디스크립터(Property descriptors), 세터(setter), 게터(getter)는 복사할 수 없다.
  • structuredClone으로 복사될 수 있는 타입의 목록은 MDN에서 더 자세히 확인할 수 있다.

📌 정리

  • JS에서 일반적으로 복사를 실행할 때, 값이 원시값이면 깊은 복사, 참조값이면 얕은 복사가 수행된다.
  • 얕은 복사의 문제점은 복사 후, 복사된 값을 수정하면 원본의 값도 변한다는 것이다.
  • 깊은 복사를 위해서는 structuredClone() 함수를 사용하는 게 권장된다.

🙇🏻‍♂️ 참고

깊은 복사, 얕은 복사 - slice, assign, spread

MDN - StructuredClone

profile
꾸준히, 깊게

0개의 댓글