오늘 자바스크립트의 얕은 복사(shallow copy), 깊은 복사(deep copy)에 관해서 써보고자 한다.

공부해보니 내가 알던 깊은 복사에 커다란 함정이 있었고, 내가 알던 깊은 복사의 기능이 맞는지 의구심이 들었다.

그래서 제목을 자바스크립트 깊은 복사의 함정으로 지었다.

내가 알던 얕은 복사와 깊은 복사

우선 이를 이해하려면 자바스크립트의 value-typereference-type을 이해해야한다.

https://codeburst.io/explaining-value-vs-reference-in-javascript-647a975e12a0

value typereference type에 대한 이해가 모자라다면 위 링크를 한번 읽어보기 바란다.

객체는 기본적으로 참조 타입(reference type)이다.

참조 타입의 동작은 어떻게 다를까? 다음 코드를 보자

let valueType1 = 1
let valueType2 = valueType1

valueType1 = 3
console.log(valueType2) // 1

let referenceType1 = {a: 1}
let referenceType2 = referenceType1

referenceType1.a = 3
console.log(referenceType2) // {"a": 3}

위 코드를 보면 =로 할당을 했을 때 값 타입의 경우 1이라는 값 자체를 복사했다.

그래서 원본 값이 수정되더라도 복사된 값은 변하지 않는다.

하지만 객체의 경우 똑같이 =을 사용해서 할당했고 원본 객체를 수정했다.

하지만 referenceType2의 값도 변경되었다.

왜 이런 동작이 발생할까?

바로 객체는 참조 타입(reference type)이기 때문이다.

객체를 =로 복사할 때 값 자체를 복사하는게 아닌, 메모리 주소값 참조를 복사한다.

결국 referenceType1referenceType2은 같은 메모리를 사용한다.

참고로 ==, ===로 객체를 비교할 땐 참조가 같은지 비교한다.

똑같이 {a: 3}라는 값을 가진 객체가 있더라도 메모리 참조가 다르면 다음 코드를 false를 출력한다.

다음 코드는 true를 출력한다.

console.log(referenceType1 === referenceType2)

나는 이렇게 객체를 단순히 =로 복사하는걸 얕은 복사(Shallow copy)라고 이해하고 있다.

그러면 객체를 참조가 아닌 완전히 복사하려면 어떻게 해야할까?

Javascript의 Object.prototype.assignspread operator를 사용하면 된다.

다음 코드를 보자.

let obj1 = {foo: 'bar'}
let obj2 = Object.assign({}, obj1)
let obj3 = {...obj1}

obj1.foo = 'hello'
console.log(obj2) // {"foo": "bar"}
console.log(obj3) // {"foo": "bar"}

console.log(obj1 === obj2) // false
console.log(obj1 === obj3) // false
console.log(obj2 === obj3) // false

Object.prototype.assign은 ES6에 새로생긴 문법이다. spread operator도 ES6 이후에 생긴 문법인데, 정확이 언제인진 잘 모르겠다.

이 두가지 외에도 객체를 스트링으로 바꿔서 할당 후 다시 객체로 바꾸는 방법도 ES5 이하에선 쓰였지만 이제는 추천하지 않는다.

두가지를 이용해서 객체를 복사하면 원본 객체인 obj1를 수정해도 obj2, obj3의 값은 변하지 않는다.

===로 비교해봐도 세가지 객체 모두 다른 메모리 참조값을 가지고 있는걸 확인할 수 있다.

나는 이렇게 참조값이 아닌 값 자체를 복사하는 걸 깊은 복사(Deep copy)라고 이해하고 있다.

함정..

함정에 빠지기 쉬운게 깊은 복사라도 완전히 객체의 모든 걸 복사하진 않는다.

다음 코드를 보자.

let obj4 = {
  a: 1,
  innerObj: {
    b: 3
  }
}
let obj5 = {...obj4}

obj4.a = 6
obj4.innerObj.b = 6
console.log(obj5) // { "a": 1, "innerObj": { "b": 6 } }

console.log(obj5 === obj4) // false
console.log(obj4.innerObj === obj5.innerObj) // true

obj4는 내부에 innerObj라는 객체가 있다.

이를 깊은 복사를 이용해도 innerObj 의 경우 같은 참조를 가리킨다.

왜 이럴까?

답은 간단하다. depth가 있는 객체도 결국은 참조타입이기 때문이다.

하위 객체는 완전히 복사된게 아니다.

이것을 깊은 복사(Deep copy)라고 할 수 있을까?

하위 객체까지 완전히 복사하는건 real deep copy라고 불러야하나?

다른 이 개념에 대해 다시 공부하면서

깊은 복사든 얕은 복사든 용어는 잊어버리기로 했다.

그냥 원래 기능만 생각하기로 했다.

실제 개발을 하다보면 depth가 깊게 들어간 객체를 은근 쉽게 만날 수 있다.

이를 어떻게 해결해야할까?

이론상으로 쉽다.

깊이가 얼마나 깊든 재귀적으로 깊은 복사를 하면 된다.

근데 자체적으로 재귀적인 깊은 복사 함수를 깊은 복사 함수를 만들어도

성능상 좋은지 잘 확인할 수 있을까?

간단하지만 작업이지만 자체 구현은 귀찮은 작업이기도 하다.

나는 이런 상황에 직면하면 오픈소스를 잘 활용하자는 생각이다.

lodash를 적극 추천한다.

lodash는 자바스크립트 고차함수 집합 및 함수형 라이브러리이다.

깊이가 얼마나 깊은 객체를 완전 복사하려면 lodash의 _.cloneDeep을 사용하길 추천한다.

lodash엔 이미 _.clone이 있는데, 이를 재귀적으로 수행해주는 함수다.

깊은 복사 외에도 유용한 함수가 많다.

Javascript 개발자라면 이미 많이 사용하고 있을것이다.

lodash는 아래 링크를 참고하길 바란다.

https://lodash.com/docs/4.17.11#cloneDeep

결론: 완전히 객체를 복사라혀면 lodash 같은 라이브러리의 cloneDeep 등의 함수를 이용하자. 또는 재귀적인 clone 함수를 직접 구현하자