[TIL]mutable객체들의 얕은/깊은 복사

TIL을 적기 이전에...

나에겐 잘못된 습관이 꽤 여러개 있는데(..) 그 중 고쳐지지 않는 문제점이 하나 있다.
바로 그날그날 배운것을 메모장에만 잘 정리해놓고, 블로깅을 하지 않는다는 것이다.

블로깅할 필요가 없다고 생각하고 넘어간것같다. 왜? 그날 메모장안의 할일들을 다시 보면,
분명 깊은 복사에 대해 블로깅 이라고 써있다. 나도 메모리 정리가 필요한것일까..? 내 머릿속 GC가 분명 과부하가 걸려서 아무거나 이미 처리된 일이라고 생각하고 처리해버린것 같다. ㅡ.,ㅡ ..

암튼! 오늘은 얕고,깊은 복사에 대해 잘 정리된 블로그를 또 발견했다. 발견하는것도 능력이랄지.. 아님 누구나 다 찾는것일진 모르지만 ㅋㅋㅋ 이 글을 인용하여 정리해보자. 내 머릿속의 GC에게 이건 처리하지 말라고 해야겠다.

많이들 사용하는 방식으로는 절대 mutable객체들의 깊은 복사는 할수없다.😢 조금 다른 방식들을 이용해서 깊은 복사가 가능하다고 하는데, 우선 둘다 예로 들면서 비교를 해보도록 하겠다.

다음은 기본적인 mutable객체의 메모리 참조방식이다.

'='연산자로 하는 참조 할당

객체의 경우


let arr = {a:1, b:2, e:{d:3}};
let copied = arr;
copied.e.d = 6; //arr.e.d = 6;
//=> 참고로 객체안의 객체속성에 할당할 시에, dot notation을 쓰지 않으면 안된다.
console.log(copied); //or console.log(arr);

//{a: 1, b: 2, e: {…}}
//a: 1
//b: 2
//e: {d: 6}

배열의 경우

let arr = [1, 2, [3, 4]];
let copied = arr;
arr[2].push(6); //copied[2].push(6);
console.log(arr); //console.log(copied);

//[1, 2, Array(3)]
//0: 1
//1: 2
//2: (3) [3, 4, 6]

객체는 기본적으로 참조타입(reference type)이기 때문에, 메모리 주소값을 복사하게 된다. 때문에 arr과 copied는 같은 메모리를 '사용'하게된다.(하지만 '공유'의 개념은 아니라고 한다. 왜..? 공부필요..)
때문에 이 경우, '==', 혹은 '==='연산자를 사용시, 주소가 같은지를 확인하는것이 된다.


얕은 복사

'Array.slice()'메소드를 사용하는 배열의 참조 할당

흔히 사용하는 방식이다. ()안에 startIndex고, endIndex고 아무것도 넣지 않으면 전체 배열을 복사할수있기 때문이다.

let arr = [1, 2, [3, 4]];
let copied = arr.slice(); //[1,2,[3,4]]

copied.push(5);
console.log(arr);
console.log(copied);


//(3) [1, 2, Array(2)]
//(4) [1, 2, Array(2), 5] 
//카피된 copied배열에만 푸쉬된 값이 들어간다. 
//음, 그럼 slice()쓰면 깊은복사가 되는건가? 독립적인 객체가 되는것인가??

❗❗그러나, 이 Array.prototype.slice()메소드는, 중첩 구조 복사를 할 수 없다는 단점이 있다.❗❗

let arr = [1, 2, [3, 4]];
let copied = arr.slice(); //[1,2,[3,4]]

copied[2].push(5);
console.log(arr);
console.log(copied);


// (3) [1, 2, Array(3)] //오잉? 원본배열도 변경이 되버린다..
// (3) [1, 2, Array(3)] 

copied배열은 복사된 객체라 arr과 아무런 연관이 없어야하지만, 중첩된 구조를 변경 시, 원본과 복사본 모두 영향을 받게된다.😥 왜냐하면 Array.prototype.slice()메소드는 얕은복사(shallow copy)를 수행하기 때문이다. 떄문에 모든값을 독립적으로 복사할 수 없다.


'Object.create()'메소드를 사용하는 객체의 참조 할당

먼저 하단의 예제를 살펴보자.

let original = {
  a: 1,
  b: 2,
  c: {
    d: 3
  }
};

let copied = Object.create(original);
original.a = 1000;
original.c.d = 3000;

console.log(copied.a);	// 1000
console.log(copied.c.d);// 3000
//엇. 이것도 얼핏 봤을땐, copied 객체에 복사가 잘 되어 있는 것처럼 '보여진다'...

console.log(copied);

MDN에서 말하는 Object.create()메소드의 정의는,

'Object.create() 메서드는 지정된 프로토타입 객체 및 속성(property)을 갖는 새 객체를 만듭니다.'

라고 되어있다. 엄연히 복사를 위해 쓰는것이 아닌, 프로토타입 객체를 바라보는 새 객체를 만들뿐이지, 'original 객체의 속성들이 복사가 되어 copied에도 참조 할당되는 메소드가 아니란 말이다.'

자, copied를 콘솔에 찍어보면 다음과 같은 결과가 나온다.

console.log(copied);		// {}

original.hasOwnProperty('a');	// true
copied.hasOwnProperty('a');	// false

copied 객체에는 '아무것도 할당된 것이 없다'. 때문에 original 객체에 있는 속성이
copied 객체에는 없는것을 확인할 수 있다.

즉, 이렇게 복사도 안될 뿐 더러, original 객체는 단지 copied 객체의 프로토타입이 될 뿐이다.


'spread operator'를 이용한 참조 할당

그림 01. 출처)깊은 복사와 얕은 복사에 대한 심도있는 이야기

모든 JS의 객체는, '반복'이란 행위를 수행하기위해서는, 저 위의 빨간색 네모에 해당하는, [Symbol Iterator]라는 프로퍼티가 존재해야한다고 한다.

객체의 경우

let obj = {a:1, b:2, e:{d:3}};
let copied = {...obj}; //{a:1, b:2, e:{d:3}}

obj.a = 100;
console.log(obj);
console.log(copied);


//{a: 100, b: 2, e: {…}}
//{a: 1, b: 2, e: {…}}

배열의 경우

let arr = [1, 2, [3, 4]];
let copied = [ ...arr ]; //[1, 2, [3, 4]]

copied.push(5);
console.log(arr);
console.log(copied);


// (3) [1, 2, Array(2)]
// (4) [1, 2, Array(2), 5]

잘 들어가는 듯 보이지만, 역시 중첩구조를 변경 시..

let obj = {a:1, b:2, e:{d:3}};
let objCopied = {...obj}; //{a:1, b:2, e:{d:3}}
obj.e.d = 3000;

let arr = [1, 2, [3, 4]];
let arrCopied = [ ...arr ]; //[1, 2, [3, 4]]
arrCopied[2].push(5);

console.log('obj{}객체는 변경됐을까요? a:1-> a:'+obj.e.d);
console.log('objCopied{}객체는 변경됐을까요? a:1-> a:'+objCopied.e.d);
console.log('--------------------------');
console.log('arr[]배열은 변경됐을까요? ->'+arr);
console.log('arrCopied[]배열은 변경됐을까요? ->'+arrCopied);
// obj{}객체는 변경됐을까요?       a:1-> a:3000
// objCopied{}객체는 변경됐을까요? a:1-> a:3000
// --------------------------
// arr[]배열은 변경됐을까요?       ->1,2,3,4,5
// arrCopied[]배열은 변경됐을까요? ->1,2,3,4,5

위와 같이, 객체는 원본의 중첩구조를 건드리고, 배열의 경우는 복사본의 중첩구조를 건드렸는데, 객체와 배열 둘 다 원본과 복사본의 구조가 다 바뀌어버렸다. 깊은 복사를 하지 못한단 뜻이다.

글에는 설명되있진 않지만

  • 반복문으로 일일이 복사해주는 경우
  • Object.assign()을 사용하는경우(객체 한정)
    에도 마찬가지로 얕은 복사가 되어서,양쪽 모두 바뀌어버리는것을 확인했다.


깊은 복사

'JSON.parse' & 'JSON.stringify'를 이용한 깊은 복사

let arr = [1, 2, [3, 4]];
let copied = JSON.parse(JSON.stringify(arr)); //[1, 2, [3, 4]];

copied[2].push(5);
console.log('arr[]배열은 변경됐을까요? ->'+arr);
console.log('copied[]배열은 변경됐을까요? ->'+copied);
//   arr[]배열은 변경됐을까요? ->1,2,3,4
//copied[]배열은 변경됐을까요? ->1,2,3,4,5

원본이 변경되지않고, 각자 독립적으로 유지가 된 것을 볼 수있다. 물론 원본배열을 변경한 경우에도, 똑같다.

왜냐면 먼저 JSON.stringify()를 이용해서 객체순환을 통해 값을 옮겨담는것이 아닌, JSON문자열로 변환 하는데, 이때, String타입은 불변성의 형질을 띠는 원시타입이기 때문에 객체에 대한 참조가 없어지기 때문이다. 이를 JSON.parse()를 이용해서, 변형된 문자를 다시 객체로 되돌려주는 방식인 것이다.

그러나, 이 JSON객체는 전용 명세서인 ECMA-404에 의해 관리되는데,

그림 2. 참조) ECMA-404 documentation

위의 그림은 JSON값으로 표현될 수 있는 종류를 명시해놓은 것이다. 그러므로 저 종류에 포함되지 않는 값들은 JSON값들로 인정하지 않는다. 그러므로 함수라거나 bigInt등의 값 등은 변환할 수 없다는 단점,
또한 deep copy한 복사본과 원본을 비교하면 false(보여지는 방식이 달라져서)가 나온다는 희한한 점 때문에 문제가 좀 있다.

'Lodash' & 'Ramda'를 이용한 깊은 복사

'Lodash'는 JS의 함수형 라이브러리이며, 'Ramda'또한 많은 사람들이 쓰는 라이브러리 중 하나다.

Lodash

let a = require('lodash');
let arr = {a:1, b:2, e:{d:3}};
let deep = _.cloneDeep(arr);

deep.a = 1000;
deep.b.c = 2000;

console.log(deep.a);   // 1
console.log(deep.b.c); // 2

_.clone() 함수를 재귀적으로 실행해주는 원리라고 한다.

Ramda

let a = require('Ramda');
let arr = {a:1, b:2, e:{d:3}};
let deep = a.clone(arr);

deep.a = 1000;
deep.b.c = 2000;

console.log(deep.a);   // 1
console.log(deep.b.c); // 2

lodash와 문법은 비슷하다. 그러나 코드의 구동원리가 추상화가 상당히 잘 되어있다고 한다.


결론

깊은 복사 시, 라이브러리 몇개에 메소드를 잘 구현해놓았으니 이 라이브러리를 사용하면 될 것 같다. 이번 underbar과제에 사용한 JSON.stringify()메소드에 대해 깊이는 몰랐었는데, 이번에 정리하면서 좀더 잘 알게 되었다. 또한, 라이브러리의 이 함수들은 재귀적으로 실행되는것이므로, 우리도 재귀적으로 함수를 작성한다면 깊은 복사가 가능할것도 같다.

참조 문헌
1. JavaScript 깊은 복사의 함정 https://velog.io/@ashnamuh/Javascript-%EA%B9%8A%EC%9D%80-%EB%B3%B5%EC%82%AC%EC%9D%98-%ED%95%A8%EC%A0%95
2. 자바스크립트 객체 복사하기 https://junwoo45.github.io/2019-09-23-deep_clone/
3. 깊은 복사와 얕은 복사에 대한 심도있는 이야기 https://medium.com/watcha/%EA%B9%8A%EC%9D%80-%EB%B3%B5%EC%82%AC%EC%99%80-%EC%96%95%EC%9D%80-%EB%B3%B5%EC%82%AC%EC%97%90-%EB%8C%80%ED%95%9C-%EC%8B%AC%EB%8F%84%EC%9E%88%EB%8A%94-%EC%9D%B4%EC%95%BC%EA%B8%B0-2f7d797e008a

지적 환영합니다. 언제든지 의견 남겨주세요. 배우는 과정입니다.감사합니다.

좋은 웹페이지 즐겨찾기