본문 바로가기
  • soldonii's devlog
Javascript 공부/TIL

200129(수) : Deep copy vs. Shallow Copy

by soldonii 2020. 1. 29.

오늘은 자바스크립트에서 객체를 복사하는 방법에 대해서 포스팅하고자 한다. 그 동안 shallow copy, deep copy 같은 용어와 그 방법에 대해서 여기저기서 들어봤으나 한 번도 제대로 된 주제로 다뤄서 글을 쓴 적은 없었기에 작성한다.

 

1. '복사'란 간단하지 않다.

자바스크립트에서 복사는 참으로 간단한 듯 하지만 실은 매우 복잡한 문제라 생각한다. 데이터 타입이 원시값인 경우, 즉 string, number, undefined, null, boolean의 경우만 존재한다면 복잡할 것이 전혀 없다. 하지만 객체(배열 포함), 함수 등을 복사할 경우, 그리고 해당 객체 내에 nested 형태의 객체가 또 존재할 경우에는 문제가 복잡해진다.

 

단순히 stack overflow에만 봐도 자바스크립트의 객체 복사와 관련된 수 많은 질문이 있고, 저마다의 답변마다 심각한 논쟁들이 오가는 것을 보면 그리 간단한 문제가 아님을 쉽게 짐작할 수 있다.

 

그렇다면 '복사'라는게 왜 그렇게 중요한것일까?

 

2. '복사'가 중요한 이유

복사가 중요한 이유를 감히 내가 한 마디로 요약해보자면, '예측할 수 없는 버그를 최소화하기 위해서'이다. 원시값이 아닌 참조값의 경우, 객체나 함수를 변수에 저장하면 해당 변수에는 그 객체나 함수값 원본이 그대로 저장되는 대신, 그 원본이 저장되어 있는 메모리 주소를 참조하고 있다는 점은 대부분 알 것이다.

 

따라서 객체나 함수의 복사를 쉽게 생각해서 = 할당 연산자로 복사하는 등의 행위를 하고 복사된 자료를 다룰 경우, 원본은 그대로 유지되고 있고 개발자는 복사된 자료를 다루고 있다고 생각하게 되지만 실은 원본 값도 함께 바꾸고 있는 행위이기 때문에 원본 자료가 훼손된다. 하지만 본인은 원본이 훼손되었다는 생각조차 하기 힘들기 때문에 디버깅이 매우 어려워지게 된다.

 

뿐만 아니라, 함수형 프로그래밍에서 매우 중요하게 다뤄지는 개념 중 하나가 '순수함수'인데 자료를 제대로 복사하지 못할 경우 '순수함수'로서 제 기능을 못하게 된다. '순수함수'의 주요한 특징 중 하나가 바로 IMMUTABLE STATE, 즉 global state에 변형을 가하지 않는 것이다. 대신 다뤄야 하는 state를 복사하고 조작하여 이를 리턴시키면, 이후 실행되어야 하는 함수가 리턴된 state를 받아서 다뤄져야 하기 때문에 객체를 제대로 복사하지 않으면 언급했듯이 예측할 수 없는 버그가 발생한다.

 

3. 원시타입 복사

  • 원시타입 복사는 복잡할 것이 전혀 없다.
  • = 할당 연산자를 이용하여 복사하면 된다.
  • 데이터의 참조값을 가리키지 않고 새로운 데이터가 복사하고자 하는 대상 변수에 할당되므로 문제될 것이 전혀 없다.

 

4. 참조타입 복사 1 - Shallow Copy

참조타입의 복사는 크게 Shallow Copy와 Deep Copy로 나뉜다. 복사해야 하는 원본 데이터가 객체인데, 객체 내부의 데이터는 모두 원시값인 경우에는 shallow copy만으로 충분하다. 예를 들어 const original = {name: 'hyunsol', age: 30, company: undefined, isHappy = true } 와 같이 원시값으로만 이뤄진 original 객체를 복사해야 할 경우 shallow copy면 충분하다는 의미이다.

 

그럼 shallow copy 방법에 대해서 알아보자.

 

1) spread operator

가장 간단하게는 spread operator를 활용할 수 있다.

const me = {
  firstName: 'hyunsol',
  lastName: 'do',
  age: 30,
  location: 'Seoul'
};

const copyMe = {...me};
copyMe.age = 40;

console.log(copyMe.age); // 40
console.log(me.age); // 30

 

2) Object.assign()

실상 전개구문(spread operator)과 거의 유사한 작동방식이다.

const me = {
  firstName: 'hyunsol',
  lastName: 'do',
  age: 30,
  location: 'Seoul'
};

const copyMe = Object.assign({}, me);
copyMe.age = 40;

console.log(copyMe.age); // 40
console.log(me.age); // 30

 

배열의 경우도 spread operator를 사용하거나, slice() 메소드, 상황에 따라서 map() 등의 메소드를 이용할 수 있다.

// 1. 전개구문(spread operator) 활용
const fruits = ['apple', 'banana', 'grapes', 'avocado'];
const copyFruits = [...fruits];

copyFruits.push('kiwi');
console.log(fruits); // ['apple', 'banana', 'grapes', 'avocado']
console.log(copyFruits); // ['apple', 'banana', 'grapes', 'avocado', 'kiwi'];
// 2. slice() 메소드 활용
const fruits = ['apple', 'banana', 'grapes', 'avocado'];
const copyFruits = fruits.slice();

copyFruits.push('kiwi');
console.log(fruits); // ['apple', 'banana', 'grapes', 'avocado']
console.log(copyFruits); // ['apple', 'banana', 'grapes', 'avocado', 'kiwi'];
// 3. map() 메소드 활용
const fruits = ['apple', 'banana', 'grapes', 'avocado'];
const copyFruits = fruits.map(fruit => fruit);

copyFruits.push('kiwi');
console.log(fruits); // ['apple', 'banana', 'grapes', 'avocado']
console.log(copyFruits); // ['apple', 'banana', 'grapes', 'avocado', 'kiwi'];

어떤 방식을 사용하던지 간에 결과물은 동일하다.

 

5. 참조타입 복사 2 - Deep Copy

하지만 저렇게 간단한 원시타입 복사나 shallow copy는 이 글의 핵심이 아니다. Deep Copy가 중요하다.

흔히 Deep Copy를 하는 가장 간편한 방법으로는 객체, 배열 상관없이 JSON.parse()JSON.stringify()가 거론된다.

 

// 1. 객체 shallow copy
const me = {
  name: {
    first: 'hyunsol',
    last: 'do'
  },
  age: 30,
  location: 'Seoul'
};

const copyMe1 = {...me};
copyMe1.name.last = 'kim';

console.log(me.name); // {first: 'hyunsol', last: 'kim'}
console.log(copyMe1.name); // {first: 'hyunsol', last: 'kim'}
// shallow copy를 했기 때문에 객체 me 내부의 name 객체는 여전히 pointer에 불과하다.
// 따라서 pointer가 가리키는 값은 me와 copyMe1 모두 동일하므로 둘 모두에서 값이 'kim'으로 변경됐다.

// 2. 객체 deep copy
const me = {
  name: {
    first: 'hyunsol',
    last: 'do'
  },
  age: 30,
  location: 'Seoul'
};

const copyMe2 = JSON.parse(JSON.stringify(me));
copyMe2.name.last = 'kim';

console.log(me.name); // {first: 'hyunsol', last: 'do'}
console.log(copyMe2.name); // {first: 'hyunsol', last: 'kim'}
// `JSON.parse`와 `JSON.stringify`를 이용해서 deep copy를 했기 때문에 이제 원본은 보존되고, 복사본의 값만 변경됐다.

 

// 1. 배열 shallow copy
const fruits = ['apple', 'banana', 'grapes', {name: 'pineapple', weight: '1kg'}];

const copyFruits1 = [...fruits];
copyFruits1[3].weight = '100kg';

console.log(fruits[3]); // {name: 'pineapple', weight: '100kg'}
console.log(copyFruits1[3]); // {name: 'pineapple', weight: '100kg'}

// 2. 배열 deep copy
const fruits = ['apple', 'banana', 'grapes', {name: 'pineapple', weight: '1kg'}];

const copyFruits2 = JSON.parse(JSON.stringify(fruits));
copyFruits2[3].weight = '100kg';

console.log(fruits[3]); // {name: 'pineapple', weight: '1kg'}
console.log(copyFruits2[3]); // {name: 'pineapple', weight: '100kg'}

그런데 JSON을 활용한 방식이 모든 deep copy의 답은 아니다. Date나 함수, undefined, regExp, Infinity가 객체 내에 존재할 경우에는 JSON 방식은 원하는 결과를 주지 못한다.

 

const a = {
  string: 'string',
  number: 123,
  bool: false,
  nul: null,
  date: new Date(),
  undef: undefined,
  inf: Infinity,
};

const clone = JSON.parse(JSON.stringify(a));
console.log(clone);
// clone = {
//   string: 'string',
//   number: 123,
//   bool: false,
//   nul: null,
//   date: '2020-02-02T07:21:49.8602',
//   inf: null
// }

 

  • date는 실행 시점의 new Date()의 return값이 stringified 된 값이 담겼다.
  • undef는 undefined가 할당되는 대신 아예 사라져버렸다.
  • inf는 Infinity 대신 null로 할당되어 복사됐다.

 

이처럼 JSON을 이용한 deep copy의 방식도 일정 부분 한계가 있다.

또한 복사하고자 하는 객체의 상위 prototype에 존재하는 프로퍼티(메소드)는 복사되지 말아야 하고 또한 숨겨진 프로퍼티들, 예를 들면 prototype 객체나 __proto__ 같은 속성은 숨겨져있기 때문에 올바르게 복사가 되지 않을 수 있다.

 

따라서 stack overflow에는 아래와 같은 솔루션들 또한 제안되어 있다.

function clone(obj) {
  if (obj === null || typeof obj !== "object") return obj;

  const copy = obj.constructor();
  for (const attr in obj) {
    if (obj.hasOwnProperty(attr)) {
      copy[attr] = obj[attr];
    }
  }

  return copy;
}

 

  • 만약 객체가 null이거나 객체가 아닐 경우에는 원시값이므로 그냥 return 시키고,
  • 그렇지 않은 경우에는 우선 obj.constructor()를 통해 원본 객체를 복사한 후,
  • 원본을 순회하면서 프로토타입 체인의 상위가 아닌 해당 원본 객체만이 가지고 있는 attribute를 새로운 copy에 할당하는 방식을 취하고 있다.

 

이처럼 객체 복사란 것이 간단한 문제가 아니기 때문에, 잘 만들어진 util 함수들을 가져다 쓰는 경우가 많은 것 같다.(lodash 라이브러리 등)

 

'Javascript 공부 > TIL' 카테고리의 다른 글

200131(금) : CORS  (0) 2020.01.31
200130(목) : Fetch API  (0) 2020.01.30
200125(토) : Merge Sort, Quick Sort  (0) 2020.01.25
200124(금) : Bubble Sort, Insertion Sort  (0) 2020.01.24
200118(토) : 비동기 - 콜백과 프라미스  (0) 2020.01.18

댓글