얕은 복사와 깊은 복사 리-턴즈

hyunwoo Jin·2023년 4월 22일
0
post-thumbnail
post-custom-banner

들어가기에 앞서

가비지 컬렉션을 공부하던 중..
얕은 복사의 경우 원본 값이 바뀌어도 사본 값이 있기 때문에 가비지 컬렉터가 메모리를 삭제하지 않는다고 합니다..

'그러면 깊은 복사에서는 원본 값이 바뀌면 당연히 가비지 컬렉터가 메모리를 삭제하겠지?'

라는 별거 아닌듯한 의문에서 다시 한번 공부하게 되었습니다.
그리고 이 의문을 통해서 새로운 사실을 알게 되었습니다.
메모리에 대해 잘 알지 못했던 것이죠..
막연하게 하나의 메모리 주소에 변수명과 데이터 값이 저장되는 줄 알았습니다.
하지만 모든 상황에서 이렇지는 않았습니다. 원시값과 참조값에 차이가 있었구요. 저도 혼동이 있어서 잘못된 정보를 전달하지 않기 위해 글을 썼다 지우기를 반복했습니다.

원시값에 대한 복사

자바스크립트의 원시값에는 null undefined symbol string number 가 있습니다. 우선 원시값에 대한 복사가 어떻게 이루어지는 지 한번 보자구요.

let a = '응애'
let b = a;

a 에 할당된 값이 원시값이기 때문에 b에 a를 할당하면 a 가 가진 원시값 자체를 복사합니다. 그래서 식별자 a 와 b 각각의 메모리영역에 '응애' 라는 원시값(string)이 저장이 됩니다.

메모리주소0x000001 = {
  변수명: a,
  데이터: '응애',
}
메모리주소0x000002 = {
  변수명: b,
  데이터: '응애',
}

json 형태로 시각화를 해보자면 이런 느낌일 것 같네요.

let a = '응애'
let b = a;
b = '야호';

그럼 이 경우는 어떻게 될까요? 변수 b에 새로운 값을 할당했습니다.
b 에 저장된 데이터는 참조값이 아닌 원시값이기 때문에 a 의 값에는 영향을 미치지 않고 b 의 값만 변경됩니다. 메모리 구조 상 연관이 없는 개별적인 값이라는 거죠.

메모리주소0x000001 = {
  변수명: a,
  데이터: '응애',
}
메모리주소0x000002 = {
  데이터: '응애',
}
메모리주소0x000002 = {
  변수명: b,
  데이터: '야호',
}

json 으로 표현하자면 이런 느낌일 것입니다. 그리고 두번째 '응애' 값은 더이상 사용되지 않은 혹은 도달이 불가능한 값으로 가비지 컬렉터가 이를 감지하고 제거합니다. 가비지 컬렉터에 대한 내용도 블로그로 남겨보겠습니다.
여기서 말씀드린 참조값이 무엇인지 그리고 참조값을 복사할 때는 어떤 점이 달라지는 지 알아보겠습니다.

참조값

참조값 혹은 참조타입에는 객체,함수,배열 대표적인 세가지가 있습니다. 해당 형태들의 값은 식별자에 할당될 경우 메모리에 저장되는 방식이 다릅니다. 우선 변수영역과 데이터영역 두가지의 메모리영역을 사용하고 변수영역에는 변수명과 데이터가 존재하는 메모리 주소. 즉, 객체가 존재하는 메모리 주소를 가지고 있는 것입니다. 그리고 변수는 메모리 주소를 통해 실제 객체 값이 존재하는 메모리에 접근하여 값을 참조하여 할당합니다.
예시를 보겠습니다.

let a = {
  소리: '응애',
}

위 코드에 경우 변수 a 와 객체 {소리 : '응애'} 가 같은 메모리에 존재하는 것이 아닙니다. 변수가 존재하는 메모리에는 a 와 {소리 : '응애'} 가 존재하는 메모리주소 를 가지고 있습니다. 그리고 메모리 주소를 이용해 {소리 : '응애'} 가 존재하는 메모리에 접근하여 데이터를 참조합니다.

메모리주소0x000001 = {
  변수명: a,
  변수에 할당된 값이 있는 메모리주소: 0x100001,
}
메모리주소0x100001 = {
  데이터: {
    소리 : '응애',
  },
}

json 으로 표현하면 요래되겠죠?

얕은 복사

변수와 참조타입의 값이 메모리에 어떻게 저장되는 지 알아보았습니다. 다음은 참조값을 복사했을 때입니다.

let a = {
  소리: '응애',
}
let b = a;
b.소리 = '야호';
console.log(a.소리) // '야호'

말씀드렸 듯 변수 a 는 참조타입의 값인 객체를 할당받았습니다. 그래서 실제 값을 가지고 있는게 아니고 다른 메모리 영역의 값을 참조하고 있습니다. 그리고 b 는 a 의 값을 복사했습니다. 이 경우 b 는 a 와 같은 객체에 접근할 수 있는 메모리주소를 복사 받게 됩니다.

메모리주소0x000001 = {
  변수명: a,
  변수에 할당된 값이 있는 메모리주소: 0x100001,
}
메모리주소0x000002 = {
  변수명: b,
  변수에 할당된 값이 있는 메모리주소: 0x100001,
}
메모리주소0x100001 = {
  데이터: {
    소리 : '응애',
  },
}

그리고 이 후 b.소리 = '야호'; 에서 소리 프로퍼티 값을 '야호' 로 변경했습니다.
어떻게 될까요? 그렇습니다. 두 변수 a, b 모두 같은 메모리주소를 통해 객체를 참조하고 있기 때문에 a 가 가진 값(a.소리)도 '응애' 에서 '야호'로 변경됩니다.

메모리주소0x000001 = {
  변수명: a,
  변수에 할당된 값이 있는 메모리주소: 0x100001,
}
메모리주소0x000002 = {
  변수명: b,
  변수에 할당된 값이 있는 메모리주소: 0x100001,
}
메모리주소0x100001 = {
  데이터: {
    소리 : '야호',
  },
}

다시 말해 다른 변수로써 사용은 되지만 결국 같은 출처의 값을 공유하고 있습니다. 이처럼 복사랍시고 했지만 실제 참조값(메모리)은 복사하지 않는 것을 얕은 복사라고 합니다.

깊은 복사

얕은 복사의 경우 데이터를 공유하기 때문에 다양한 상황에서 의도치 않게 변경이 일어날 수 있는 치명적인 단점이 있네요. 그렇다면 참조값, 즉 데이터영역의 메모리까지 복사하여 메모리 구조 상 독립적인 값을 만들 수 있을까요?

Object.assign()

Object 의 assign 메소드를 사용하는 방법입니다.
assign() 에 생성할 객체와 복사할 객체를 인자로 넣어 새로운 메모리영역에 복사할 객체를 넣어줍니다.

let a = {
  소리: '응애',
}
let b = Object.assign({}, a);

b.소리 = '야호';

console.log(a.소리) // '응애'
console.log(b.소리) // '야호'
console.log(a === b) // false

새로운 메모리에 a의 참조값을 복사했고 b 는 새로운 메모리에 할당된 객체를 참조합니다. 그로 인해 b의 참조값을 변경하더라도 a가 참조하는 객체와 전혀 무관한 작업이 되겠죠.

spread operator

두번째는 spread operator(전개연산자) 입니다. 이 역시 assign() 메소드와 똑같이 동작합니다.

let a = {
  소리: '응애',
}
let b = { ...a };

b.소리 = '야호';

console.log(a.소리) // '응애'
console.log(b.소리) // '야호'
console.log(a === b) // false

assign() 메소드에 비해 간단하게 사용할 수 있네요.

두 방법의 문제점

앞서 말씀드린 두가지 방법에는 문제가 있습니다. 복사가 1depth 만 허용이 된다는 것이죠.

let a = {
  소리: {
    아기: '응애',
  },
}
let a = [1,2,3.[4]]

위와 같이 depth가 2 이상일 경우에는 얕은 복사가 일어납니다. 저는 한가지 의문이 들었습니다.
'그럼 depth가 2 이상의 값이면 아예 깊은 복사를 시도하지 않는 건가?'
두 경우 다 깊은 복사는 일어납니다. 하지만 딱 1depth 의 값만 깊은 복사를 합니다. 그리고 2depth가 존재할 경우 복사가 아닌 참조를 하게 됩니다. 결국 두 방법 다 완벽한 복사는 아니였네요. 그렇다면 어떻게 완벽한 복사를 할 수 있을까요?

JSON.stringify() JSON.parse()

배열 혹은 json 타입의 객체일 경우 JSON(JavaScript Object Notation)의 JSON.stringify() JSON.parse() 두가지 메소드를 이용하여 완전한 깊은 복사를 수행할 수 있습니다.여기서 JSON.stringify()는 객체를 문자열로 변경시키고 JSON.parse() 는 문자열을 객체 형태로 변경시켜줍니다.

let a = {
  소리: {
    아기: '응애',
  },
}
let b = JSON.parse(JSON.stringify(a));

b.소리.아기 = '야호';

console.log(a.소리.아기) // '응애'
console.log(b.소리.아기) // '야호'
console.log(a === b) // false

위의 경우 JSON.stringify() 에서 a 의 객체를 문자열로 변경시킵니다. 문자열로 변경되었으니 a의 객체는 참조값이 아닌 원시값일테죠. 즉 새로운 메모리영역에 문자열의 a 객체가 할당되었다는 의미입니다.그리고 JSON.parse() 메소드가 이 문자열을 다시 객체형태로 변환시켜줍니다.이로써 a 객체가 새로운 메모리 영역에 완벽하게 복사된 것을 볼 수 있습니다. 하지만 이 방법의 경우도 속도가 느리다는 단점이 있습니다. 또한 함수에 대해서는 undefined 를 반환하여 실무에서도 사용되지 않는 방법이라고 합니다.

lodash

실제로 많은 사람들이 데이터 구조를 편하게 제어하기 위해 lodash 라이브러리를 사용하고 있습니다. 특히 front-end 단에서 서버에서 넘어온 데이터를 정렬할 때 많이 사용한다고 합니다.
어쨌든 본론으로 돌아와 lodash는 얕은 복사를 위한 .clone() 깊은 복사를 위한 .cloneDeep() 함수를 제공하고 있습니다.

let a = {
  소리: {
    아기: '응애',
  },
}
let b = _.cloneDeep(a);

위 예시처럼 깊은 복사를 수행할 수 있습니다. lodash의 단점이라면 물론 라이브러리를 설치해야한다는 단점이 있습죠?

마치며

얕은 복사와 깊은 복사에 대해 다시 한번 알아보고 공부해봤습니다. 예전에 한번 공부했었으나 두루뭉실하게 넘어갔었죠. 그런 것들을 바로 잡고 싶었고 드는 의문을 피하지 않고 곱씹어 봤습니다. 프로젝트를 진행하면서 내가 이런 것도 모르고 개발을 하니 오류 원인도 못 잡지 싶더라구요.. 내가 앞으로 평생 할 일 인데 전문성을 좀 갖춰야 하지 않나 라는 생각을 하게 된 것 같습니다. 모쪼록 제 글이 저처럼 공부하면서 의문이 생긴 분들에게 도움이 되길 바랍니다.

참고자료1 참고자료2 참고자료3

profile
꾸준함과 전문성
post-custom-banner

0개의 댓글