본문 바로가기
JavaScript

[JavaScript] 깊은 복사 / 얕은 복사

by 도전하는 린치핀 2024. 1. 24.

들어가기에 앞서 얕은 복사의 경우 주소 값을 복사하고, 깊은 복사의 경우 실제 값을 복사한다고 생각하자.

 

깊은 복사와 얕은 복사에 대해 알아보기 전 주소 값, 실제 값의 의미를 파악하기 위해 JavaScript의 데이터 타입에 대해 알아보자.

JavaScrpit의 데이터 타입의 경우 크게 기본형(원시형, Primitive) 타입, 참조형(Reference) 타입 2종류로 분류할 수 있다.

 

1. 데이터 타입

  • 기본형(원시형, Primitive type) 타입 
    1. Number
    2. String
    3. Boolean
    4. null
    5. undefined
    6. Symbol(ES 6에서 추가)
  • 참조형(Reference) 타입 → Object(객체)
    1. Array
    2. Function
    3. Date
    4. RegExp
    5. Map, WeakMap
    6. Set, WeakSet

기본형과 참조형을 구분하는 방법

  • 기본형 : 할당이나 연산 시 값이 담긴 주소값을 바로 복제
  • 참조형 : 할당이나 연산 시 값이 담긴 주솟값들로 이루어진 묶음을 가리키는 주소값을 복제

이게 무슨 소리인가....

쉽게 말하자면 할당이나 연산 시 값을 복사하면 기본형이고, 값을 복사할 때 참조하면 참조형이다.

 

이 내용을 이해하기 위해서는 할당이란 무엇인지 또 알아봐야 한다.

var a = 10;

 

위의 코드에서는 ①변수를 선언한뒤, ②식별자(변수명)를 a로 주었으며 ③number 타입의 값인 10을 할당했다.

이 코드가 실행되는 단계를 살펴보면 메모리의 한 공간을 확보한 뒤, 식별자를 붙인다. 그리고 10의 값을 바로 할당하는 것이 아니라 데이터를 저장하기 위한 다른 메모리 공간을 하나 더 확보하여 그곳의 주소를 식별자가 저장한 곳의 값으로 집어넣는다.

 

즉 변수는 값의 위치(주소)를 기억하는 메모리 공간으로, 값의 주소는 해당 값을 찾을 수 있는 메모리 상의 주소를 의미한다.

다시 말하자면 변수는 값이 위치하고 있는 메모리 주소에 접근하기 위해 사람이 이해할 수 있는 언어로 지정한 식별자라고 할 수 있다.

 

그렇다면 값을 가져오는 방법은 어떻게 진행될까? 아주 다행이도 값을 저장하는 과정의 반대로 실행된다.

 

이처럼 기본형 타입의 경우 주소가 한번에 연결되어 있다. 이따가 나오는 기본형 타입의 불변성에 대해 알아보면 기본형 타입 변수에 대해 더 잘 이해할 수 있을 것이다.

 

그렇다면 참조형 데이터의 경우 어떤 식으로 저장되고 찾을 수 있는지 알아보자.

var obj1 = {
	a : 10,
	b : 'abc',
};

 

위와 같은 코드는 어떤 식으로 데이터를 저장할까?

위의 그림처럼 데이터가 저장되는데 순서에 맞게 알아보자.

  1. obj1 이라는 식별자를 가진 변수를 선언하고 참조형 타입인 객체 하나를 할당했다.
    이때 기본형 타입과 동일하게 메모리 공간(위의 그림에서 @1002)을 확보하고 obj1이라는 식별자를 준다
  2. 그 후 값을 저장하기 위해 다른 메모리 공간(@5001)을 확보하였지만 기본형 타입을 여러 개 저장해야 한다.
  3. 여러 개의 프로퍼티(값)을 저장하기 위해 방금 확보한 메모리 공간(@5001) 외 프로퍼티의 개수에 맞게 메모리 공간(@7103, @7104)를 확보하고 식별자를 지정한다.
  4. 그 후, 식별자의 실제 데이터가 저장될 메모리 공간(@5003, @5004)에 실제 값을 저장한다.
  5. @7103, @7104에 방금 저장한 곳의 각각의 메모리 주소를 저장 후, @5001에 그룹의 주소를 저장한다.
  6. 마지막으로, 아까 확보했던 메모리 공간(@5001)에 값이 저장된 (@7103, @7104)의 공간 주소를 저장한다.

기본형 데이터와의 차이는 참조형 데이터는 안에 있는 기본형 데이터를 저장하기 위해 기본형 데이터의 주소를 담은 공간을 새로 생성했다는 점이다.

 

되게 귀찮고 번거로운 일 같지만 JavaScript의 경우 다른 언어와 다르게 var, let, const 이렇게 세개의 데이터 타입 밖에 없기 때문에 효율적으로 데이터를 저장하고 사용하기 위해 그렇게 하는 것 같았다..

 

2. 불변값

불변값의 경우 말 그대로 변하지 않는 값이라는 뜻이다.

위에서 확인한 데이터 타입 중 기본형 타입은 불변값, 참조형 타입은 대체로 가변값으로 분류할 수 있다.

 

먼저 기본형 타입의 불변성을 아래 코드와 함께 알아보자

var a = 10;
a = 20;
console.log(a); // 20

 

처음에 a에 10의 값을 주고 20을 준 뒤 출력하면 20이 나온다.

엥?????? 불변이라면서!!!!!!!! 라고 할 수 있지만 JavaScript 입장으로 확인해보자

  • 하나의 메모리 공간 확보 후 다른 메모리 공간에 10이라는 값을 할당했다.
  • 10이 저장된 주소를 처음 확보한 메모리 공간에 넣는다.
  • 이후, a에 20을 재할당할 때, 10이 저장되어 있는 메모리 공간을 20의 값으로 변경하는 것이 아니라, 20을 저장하는 메모리 공간을 추가로 확보한 뒤 처음 확보한 메모리 공간에 20을 저장하는 주소 값을 저장하는 것이다.

쉽게 말해 10이 저장되어 있는 메모리 공간에 10을 삭제한 뒤 20을 저장하는 방식이 아니라, 20이라는 메모리 공간을 추가로 확보하고 a의 값을 20의 주소로 변경하는 방식이다.

참조 카운트가 0이 된 10의 메모리의 경우 가비지컬렉터(GC)가 수거해간다. 참조 카운트, 가비지 컬렉터는 나중에 더 자세히 알아보자.

 

그렇다면 참조형 타입은 어떤지 알아보자.

var obj = {
	a : 10,
	b : 'abc',
}
var obj.a = 20;

 

 

obj의 프로퍼티 값을 재할당하면, obj가 가지고 있는 메모리 주소는 변경되지 않고 obj.a가 가지고 있는 주소가 변경된다.

즉, '새로운 객체'가 만들어진 것이 아니라 기존 객체 내부의 값만 바뀐 것이다.

 

 

3. 깊은 복사

여기서 복사는 쉽게 동일한 내용을 그대로 가지고 다른 곳에 베껴넣어 사용하는 것을 의미한다.

 

그러면 깊은 복사는 한 문장으로 정의해보자.

깊은 복사 : 기존 값의 모든 참조가 끊어지는 것. 특히 참조형 타입 값에서 내부에 있는 모든 값이 새로운 값이 되는 것.

 

이해를 쉽게 하기 위해 깊은 복사의 경우도 기본형 타입, 참조형 타입 두개의 타입으로 나누어서 생각해보자

 

3-1. 기본형 타입의 깊은 복사

기본형 타입의 경우 할당 연산자(=)를 사용하여 바로 복사가 가능하다.

var a = 10;
var b = a; // 할당 연산자를 통한 기본형 타입 복사
console.log(a); // 10
console.log(b); // 10

 

위의 코드를 통해 b에 10을 직접 할당하지 않아도 a를 할당하여 10이라는 값을 할당할 수 있다.

하지만 정확하게 이해하자면 a가 가지고 있는 주소값을 b에도 넣어 서로 같은 대상을 바라보게 하는 것으로 이해할 수 있다.

만약 b의 값이 20으로 변경한다면 a의 값도 20으로 변경되는 것이 아닌 b의 값에 20이 저장되어 있는 메모리 값을 가리키게 만들어 a와 b가 서로 달라진다.

아래의 코드를 통해 쉽게 이해해보자

var c = 20;
var d = c;
console.log(c === d); // true

d = 30;
console.log(c); // 20
console.log(d); // 30
console.log(c === d); // false

 

  • 기본형 타입의 값을 바라보는 주소값이 동일하기 때문에 c와 d는 동일하다고 생각할 수 있다.
  • d가 바라보는 주소값을 30이라는 데이터를 저장한 메모리 주소값으로 변경하였다.
  • c와 d는 서로 20과 30을 각각 바라보게 되어 다른 값이 되었다.

내부의 값은 없지만 복사했을 때, 서로의 주소가 달라졌다. 이것을 통해 우리는 기본형 타입의 깊은 복사를 알 수 있다.

 

3-2. 참조형 타입의 깊은 복사

참조형 타입도 코드를 함께 보며 이해해보자

// 깊은 복사 X
var obj1 = {
  a: 10,
  b: 'abc',
};
var obj2 = obj1;
console.log(obj1 === obj2); // true

obj2.a = 20;
console.log(obj1); // {a: 20, b: 'abc'}
console.log(obj2); // {a: 20, b: 'abc'}
console.log(obj1 === obj2); // true

 

  • obj1과 obj2가 가지고 있는 주소 값이 동일하기 때문에 첫 log에서는 true가 나온다.
  • obj2의 a 프로퍼티 값을 20으로 재할당했다.
  • obj1, obj2의 a 프로퍼티 값이 모두 20으로 변경되었다는 것을 확인할 수 있다.
  • 여전히 obj1과 obj2가 가지고 있는 주소 값이 동일하다는 것을 확인할 수 있다.

 

이러한 결과는 obj2의 프로퍼티를 변경하여 a가 바라보는 주소는 변경되었지만 obj1, obj2가 프로퍼티 그룹을 바라보는 주소가 변경되지 않은 것이다.

이렇게 객체(참조형 타입)의 경우 할당을 통해서는 깊은 복사가 이루어지지 않는다.

 

그렇다면 참조형 타입의 깊은 복사는 어떤 방법으로 이루어지는지 확인해보자.

 

3-2-1. 재귀함수를 이용한 깊은 복사

var deepCopy = function (obj) {
  var result = {};
  if (typeof obj === 'object' && obj !== null) {
    for (var prop in obj) {
      result[obj] = deepCopy(obj[prop]);
    }
  } else {
    result = obj;
  }
  return result;
};

 

deepCopy 함수는 함수 내부에서 자기 자신을 호출하는 재귀함수로, 객체안에 다양한 중첩된 객체가 있어도 프로퍼티의 개수만큼 재귀를 반복하며 result 객체에 새롭게 할당하여 깊은 복사가 이루어질 수 있다.

 

3-2-2. JSON.parse(JSON.stringify(obj))를 사용한 깊은 복사

var obj1 = {
  a: 10,
  b: 'abc',
};
var obj2 = JSON.parse(JSON.stringify(obj1));
obj2.b = 3;
console.log(obj1); // {a: 10, b: 'abc'}
console.log(obj2); // {a: 10, b: 3}

 

JSON.stringify()는 JavaScript내 값이나 객체를 JSON 문자열로 변환한다.

그리고 JSON.parse()는 JSON 문자열의 구문을 분석하고 JavaScript내 값이나 객체를 생성한다.

즉, 객체를 문자열로 변환 후 다시 객체를 만들면서 새로운 객체를 만들면서 기존 객체와의 참조가 모두 끊어져 깊은 복사가 이루어진다.

 

깊은 복사를 사용하면 복사했을 때 값은 동일하지만, 객체 내부의 값을 변경해도 서로 영향을 주지 않고 격리된 값을 보장한다.
원시 값과 달리 객체는 변경이 간으한 '가변값'이며 프로퍼티의 집합이다. 또한, 자바스크립트의 경우 객체 생성 이후에도 프로퍼티를 추가하거나 삭제할 수 있다.
그러므로 깊은 복사를 통해 객체를 복사하지 않는다면 객체 사이의 관계가 생성되어 원하지 않는 오류가 발생할 수 있으니 주의해야 한다.

 

4. 얕은 복사

얕은 복사란 참조형 타입의 값의 바로 아래 단계의 값만 복사하는 방법이다.

아래의 코드를 통해 이해해보자.

var obj1 = {
  a: 1,
  b: {
    c: 2,
  },
};
var obj2 = { ...obj1 };
obj1.a = 3;
obj2.a = 5;
console.log(obj1); // {a : 3, b: { c: 2 } }
console.log(obj2); // {a : 5, b: { c: 2 } }
obj2.b.c = 10;
console.log(obj1); // {a : 3, b: { c: 10 } }
console.log(obj2); // {a : 5, b: { c: 10 } }
console.log(obj1 === obj2); // false
console.log(obj1.b === obj2.b); // true

 

위의 코드에서 obj1, obj2 두 객체는 다른 주소를 가지고 있지만, 객체 안 프로퍼티는 동일한 주소를 가지고 있다.

쉽게 말해 {} 이 껍데기는 서로 생성된 객체이며 새로운 주소를 갖게 되었고 spread 연산자로 풀어진 프로퍼티들은 처음 선언된 obj1의 프로퍼티들이 사용되었다.

  • obj1에 저장된 a값과 obj2에 저장된 a의 값은 기본형 타입의 깊은 복사가 이루어져 각각의 값이 변경되어도 서로 지장을 주지 않는다.
  • obj1의 객체 b에 저장된 c값과 obj2의 객체 b에 저장된 c의 값은 서로 얕은 복사가 이루어져 obj2의 c값이 변경되어도 obj1의 c값이 변경되는 것을 확인할 수 있다. 

이때 spread 연산자는 객체 뿐 아니라 배열에서도 동일하게 동작한다.

 

4-2-1. Object.assign()

assing은 '할당'이라는 뜻을 가지고 있으며 객체와 객체를 합쳐주는 메서드이다.

var obj1 = {
  a: 10,
  b: {
    c: 'abc',
  },
};
var obj2 = Object.assign({}, obj1);
obj2.a = 20;
obj2.b.c = 'def';

console.log(obj1); // { a: 10, b: { c: "def" } }
console.log(obj2); // { a: 20, b: { c: "def" } }

 

  • 첫번째 인자로 {}(빈 객체)가 들어갔기 때문에 껍데기가 obj1과 다른 객체가 반환 될 것이다.
  • obj1의 내용을 {}(빈 객체) 안에 복사해서 집어넣고 반환한다.
  • 프로퍼티 a는 기본형 타입의 값이 들어있기 때문에 변경하면 obj2의 a 프로퍼티만 변경된다.
  • 프로퍼티 b는 참조형 타입의 값이 들어있기 때문에 변경하면 obj1, obj2의 b가 가진 주소값이 동일하기 때문에 하나가 변경되면 서로 영향을 주게 된다.

4-2-2. for~in

var copyShallo = function (obj) {
  var result = {};
  for (var prop in obj) {
    result[prop] = obj[prop];
  }
  return result;
};

 

깊은 복사에서 사용되었던 재귀함수와 다르게 첫번째 프로퍼티만 새로 만든 result 객체에 담는 형태로 얕은 복사에 해당된다.

 


 

 - 요약

  • 얕은 복사는 참조형 타입의 값이 바로 아래 단계의 값만 복사하는 방법으로 예를 들어 객체(참조형 타입)속의 객체는 서로 영향을 줄 수 있다.
  • 깊은 복사는 참조형 타입 안의 모든 참조가 끊어져 예를 들어 객체(참조형 타입)속의 객체는 서로 영향을 주지 않는 전혀 다른 객체이다.
  • 얕은 복사는 한 단계만 복사하고 깊은 복사는 객체에 중첩된 객체까지 모두 복사한다.
  • 얕은 복사와 깊은 복사 모두 복사한 대상에 대해서 새로운 객체를 생성하여 기존 객체에는 영향을 주지 않는다.
  • 얕은 복사와 깊은 복사는 어느 수준까지 복사하느냐의 차이를 가진다.
  • 얕은 복사를 하면 한 단계만 복사하기 때문에 중첩된 객체에 대해서는 서로 영향을 주고, 깊은 복사는 중첩된 객체 역시 별개의 값으로서 서로 영향을 주지 않는다.