빡세게 알아보는 자바스크립트의 얕은복사 깊은복사

CloudJun·2022년 6월 10일
2

개요

여러 언어에서도 얕은 복사와 깊은 복사를 지원하는데 특히나 자바스크립트의 얕은 복사와 깊은 복사는 땔 수 없는 구조를 가지고 있다. 0개 이상의 요소(프로퍼티)로 구성된 객체 기반 언어이기 때문이다. 그리고 이 주제는 특히나 면접 자리에서 자주 나오는데 이 자리에서는 자바스크립트 객채의 복사에 대해 같이 알아보는 시간을 가질것이다.

특히나 구글이나 네이버에 검색하면 얕은복사와 깊은 복사에 대해 수두루 빽빽한 글들이 나왔지만 최신 버전 문법이나 왜 이걸 쓰면 안되는지에 대한 설명이 없었다..
이 글을 통해 한번에 쫙 정리하는 마음으로 작성했다

변수 선언과 데이터 할당

얕은 복사와 깊은 복사를 이해하기 전에, 자바스크립트가 변수를 어떻게 관리하는지를 먼저 알아야한다.

자바스크립트에서 실제 변수가 어디에 할당되는지 주소값을 찾기 매우 어렵기 때문에 모든건 개념적으로 표현했다.

let a;

a라는 식별자를 가진 임의의 메모리 공간이 할당된다.

공간을 만들었으니 데이터를 바로 할당해주자.

let a;
a = 'abc111';

let a = 'abc111';

이 두가지의 코드는 둘 다 a라는 식별자에 ‘abc111’을 할당하는 코드다.

위 데이터 공간에 값 공간이 있으니 여기에 바로 ‘abc111’ 라는 데이터가 저장 될 것 같지만 실상은 아래와 같이 새로운 주소 영역을 만들고 거기에는 값만 저장한다.

변수 a는 ‘abc111’이 있는 데이터의 주소값만 가지고 있는다.

왜그럴까?

  • 자유로운 변환과 효율적인 메모리 관리
  • 해당 공간을 넘어가는 데이터로 변환 할 경우 공간을 늘리는 작업이 필요하다.
  • 앞 뒤의 여유공간이 없다면 모두 데이터를 이동시키는 작업이 필요하기 때문에 데이터 영역과 변수 영역을 나눈것.

이제 변수 ‘a’ 의 값을 다른 값으로 바꿔보자

let a = 'abc111';
a = 'abc222';

이러면 a의 식별자 속에 있는 데이터값에서 데이터영역의 주소값을 찾고, 주소값 속 데이터를 ‘abc222’로 바꿔줄것 같지만 그렇지 않다.

아까전 ‘abc111’은 그냥 나둔체 데이터 영역에 ‘abc222’라는 데이터를 새로이 할당한다.

변수 영역의 주소만 새로 할당된 영역의 주소로 바꿔준다.

이 과정에서 ‘abc111’를 가지고 있던 5004 주소는 더이상 참조할 수 없는 영역으로 남아서 GC 즉 가비지 컬렉터가 열심히 돌면서 청소해준다.

이제 여기서 기본형 데이터와 참조형 데이터를 구분해야하는데

우리가 기본적으로 쓰는 (Number , String, Boolean, Null, Undefined, Symbol)로 모두 불변값을 가지고 있다. 불변값은 데이터를 복사하는 과정을 거친다면 모두 새롭게 할당된 영역을 만들고 변수 영역에 주소값만 바꿔서 넣어준다.

let a = 1;
let b = a;

b = 2;

a와 b는 처음 동일한 주소값을 바라보고 있었으나, b가 2라는 데이터를 할당 받았기 때문에 새로운 데이터 영역의 주소를 받아오게 되면서 a에게 받은 데이터 영역 주소값 연결이 끊어진다.

불변값은 이렇게 매우 쉽다. 하지만 참조형 데이터인 가변값 데이터는 이렇게 쉽지 않다.

가변값 - 참조형 데이터

기본형 데이터와 다른 점은 데이터 영역의 데이터 부분이 또다시 한층 더 추가된 개념으로 이해하면 편하다.
즉 변수는 데이터 영역의 주소값을 가지고 있고, 데이터 영역은 객체 영역의 주소리스트를 가지고 있다는 것이다. 그러나 객체 영역은 데이터 영역의 주소값을 참조하여 데이터를 만들어낸다.

let obj1 = {
  a: 1,
  b: 'bbb'
};

문제는 변수 영역의 값은 데이터 영역의 주소값만 바꾸는 것이기 때문에, 객체의 변수 영역까지 새로 생성이 되지 않는 다는 것이다. 그대로 객체의 변수 영역을 바라보고 있게 된다.

이제 여기서부터 우리의 주제가 시작된다.


그래서 어떻게 복사해야하는데

바로 아래 주소값만 복사되어 해당 프로터디에 대한 원본과 사본이 동일한 참조형 데이터를 바라본다는 점.
사본을 바꿔도 원본이 바뀌고, 원본을 바꾸면 사본도 바뀐다.

하지만 일반적으로 복사를 할때 이러한 방법은 전혀 원하지 않는다. 나는 그저 완벽한 복사를 하고 싶으니까.

이러한 문제로 인해 자바스크립트 개발자들은 많은 시도를 한다

Object의 경우 펑션을 새로 만들어 새롭게 리턴

이 경우 새롭게 복사는 가능하지만

프로퍼티가 많아질수록 개발자가 고생해야하는 구조라 우리가 원하는 방법이 아니다

let user = {
  name: 'myungJun',
  gender: 'male'
};

const changeName = (user, newName) => {
  return {
    name: newName,
    gender: user.gender
  };
}

const user2 = changeName(user, 'soongsil');
console.log(user.name, user2.name); // myungJun soongsil

Array는 slice 방법 쓰기

object가 아닌 array (배열)을 복사하려고 할때 주로 코드에서 볼 수 있는 방법.

1deep만 보면 나쁘지 않은 방법. 딱 1댑스까지는 정말 정상적으로 복사가 된다.

const arr = [1, 2, 3];
const copied = arr.slice();

arr[0] = 2;
copied[0] = 99;

console.log(arr); // [2, 2, 3]
console.log(copied); // [99, 2, 3]

하지만..

const arr = [[22,33], 2, 3];
const copied = arr.slice();

copied[0][1] = 2;

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

2deep가 들어가자마자 복사가 제대로 안되는 모습을 보여주고 있다.

slice는 1 댑스만 복사하기 때문에 독립적으로 복사가 되지 않는다.

여기까지는 자바스크립트를 해보신 분이라면 충분히 대응책으로 생각하시는 단어들이 있을것이다.

구글 많은 블로그나 책에서 자바스크립트 deepCopy를 하려고 하면 두가지의 방법을 제시한다.

JSON.parse & JSON.stringify

이 펑션을 사용하면 Object를 JSON으로 변환하고, 변환한 JSON을 다시 원래 객체로 돌려주는 역활한다.
이 과정에서 Objec는 String으로 변환이 되고 아까전 이야기한 불변성 데이터 타입으로 전환되기 때문에 모든 체인이 끊기게되고 다시 JSON.parse를 통해 원본 객체로 돌아온다.

const test1 = {
  intTest: 1,
  arrayTest: [111,222,333],
	deep1: {
		deep2: {
			message: '하이 빵가루'
		}	
	}
} 

const copyTest1 = JSON.parse(JSON.stringify(test1));
copyTest1.deep1.deep2.message = '안녕~~';

console.log(`test1 --- `);
console.log(test1);

console.log(`copyTest1 --- `);
console.log(copyTest1);

매우 잘 된다.

이 과정을 오려고 이 시간을 소모했나 생각을 하실 수 있는데,

그러나 이 방법은 사용하면 안되는 방법이다.

자바스크립트를 잘 아시는 분이라면 이쯤에서 속도 문제 때문에 사용을 하지 말라고 하는건가? 라는 생각을 하실텐데 그 문제도 맞지만 핵심적인 부분은 따로 있다.

const test1 = {
  bigIntTest: BigInt(9007199254740994), // int의 기본 최대값은 900719925474092
}

const test1Copy = JSON.parse(JSON.stringify(test1));

BigInt는 자바스크립트 ECMA2020에서 새롭게 들어온 기능으로 JSON과 호환이 되지 않는다.

아에 컴파일 부터 동작하지 않는것을 볼 수 있다.

BigInt만 문제인가? 아니다.

const arr = [function(){}, () =>{}];
const copied = JSON.parse(JSON.stringify(arr));
checker(arr, copied); // false

함수도 JSON.stringify으로 바꿀 수 없는 객체이기 때문에 정상적으로 복사 되지 않는다.

자바스크립트의 객체 구조는 ECMAScript-262고 JSON는 ECMA-404로 명세서또한 다르며 아래와 같이 JSON은 이 값들만 JSON으로 표현 될 수 있는 값으로 명시하였다.

그래서 아래와 같이 new Date도, 정규식도 제대로 복사 되지 않는다.

new Date는 문자열로 복사되어 더이상 펑션의 기능을 상실하였고, 정규식은 아에 나타나지도 않는다.

const test1 = {
  //bigIntTest: BigInt(9007199254740994), // int의 기본 최대값은 900719925474092
  intTest: 9007199254740993,
  date: new Date(),
  test2: new RegExp(/ab+c/, 'i'),
  arrayTest: [111,222,333]
}

const copyTest1 = JSON.parse(JSON.stringify(test1));

console.log(`test1 --- `);
console.log(test1);
console.log(`getDate: ${typeof test1.date}`);

console.log(`copyTest1 --- `);
console.log(copyTest1);
console.log(`getDate: ${typeof copyTest1.date}`);

그렇다면.. 검색해보면 나오는 블로그에서 추천하는 방법이 또 있다.

DeepCopy 재귀함수 펑션 만들기

const deepCopy = (obj) => {
  const clone = {};
  for (const key in obj) {
    if (typeof obj[key] == "object" && obj[key] != null) {
      clone[key] = deepCopy(obj[key]);
    } else {
      clone[key] = obj[key];
    }
  }
  return clone;
}

const test2 = {
  bigIntTest: BigInt(9007199254740994),
  intTest: 9007199254740993
}

매우 좋은 방법처럼 보인다.

아까전 문제중 하나였던 속도의 문제도 해결되고 BigInt 문제도 가뿐하게 돌아간다. 아무 문제 없어보인다.

const deepCopy = (obj) => {
  const clone = {};
  for (const key in obj) {
    if (typeof obj[key] == "object" && obj[key] != null) {
      clone[key] = deepCopy(obj[key]);
    } else {
      clone[key] = obj[key];
    }
  }
  return clone;
}

const test2 = {
  bigIntTest: BigInt(9007199254740994),
  intTest: 9007199254740993,
  test4: new RegExp(/ab+c/, 'i'),
  date: new Date(),
  arrayTest: [111,222,333]
}

이번에는 또다른 문제가 하나 더 생겼다. Array가 정상적인 Array가 아니고 Object 형태로 변화되었다. 그리고 이것도 마찬가지로 정규식을 제대로 이해하지 못하고 있고 펑션도 복사가 안된다.

이 두가지는 네이버 블로그, 구글 블로그에 최우선 순위로 뜨는 방법들이라 이 코드는 심심치 않게 자주 보이고 자주 사용하게 되는 코드들이다.


그럼 뭘 써야하는데?

자바스크립트는 structuredClone를 통해 이제 깊은 복사를 수행 할 수 있다.
https://developer.mozilla.org/en-US/docs/Web/API/structuredClone

var uInt8Array = new Uint8Array(1024 * 1024 * 16); // 16MB
for (var i = 0; i < uInt8Array.length; ++i) {
  uInt8Array[i] = i;
}

const transferred = structuredClone(uInt8Array, { transfer: [uInt8Array.buffer] });
console.log(uInt8Array.byteLength);  // 0

또는 과거 정석적인 방법중 하나인 라이브러리 Lodash 또는 ramda를 쓰면 된다.

switch (type(value)) {
    case 'Object':  return copy({});
    case 'Array':   return copy([]);
    case 'Date':    return new Date(value.valueOf());
    case 'RegExp':  return _cloneRegExp(value);
    default:        return value;
  }

모든 경우의 수나 또는 타입들을 모두 추상화 해두었기 때문에 정상적으로 deep 카피가 된다. 하지만 모든 경우의 수를 체크하기 때문에 하지만 우리가 처음에 만든 deepCopy 펑션보다는 어쩔수없이 느릴수밖에 없다.


결론

TC39 맴버가 답변한 깃 이슈

https://github.com/tc39/ecma262/issues/1319

자바스크립트는 구조적으로 DeepCopy를 수행 할 수 없다. 몇몇 라이브러리에서 낮은 퍼포먼스를 사용하더라도 깊은 복사를 지원하는 것은 효율성을 위해서이고 만약 깊은 복사가 필요한 경우라면 다른 방법을 찾아보는게 좋다.

이제 structuredClone를 node에서도 지원하니 더이상 지원하지 않는다는 말은 틀린 말이 되었다. (node는 17버전 부터 지원)

profile
짧고 굵게 살아가는 백엔드 개발자

0개의 댓글