structuredClone()으로 깊은 복사하기

sejin kim·2022년 8월 15일
1
post-thumbnail

객체를 복사하려고 한다면 깊은 복사얕은 복사라는 개념에 대해 한 번쯤 고민하게 됩니다. 직관적으로 단순하게 생각했을 때에는 깊은 복사의 동작을 기대하기 마련이지만, 대개 실제로는 얕은 복사가 수행되기 때문일 것입니다.

JavaScript의 경우, 언어 자체의 구조적인 이유로 모든 복사는 기본적으로 얕은 복사를 구현하게끔 되어 있습니다. 아키텍처의 관점에서 깊은 복사가 정말로 필요하느냐 아니냐는 차치하고, 어쨌든 깊은 복사를 하고자 한다면 제한된 조건 하에서 일종의 우회 구현하거나 lodash 같은 라이브러리를 사용하는 경우가 일반적이었습니다.

하지만 Web APIStructuredClone이 2022년 즈음부터 지원되기 시작하면서, 선택지가 하나 더 늘어나게 되었습니다.






얕은 복사, Shallow Copy

깊은 복사와의 비교를 위해 잠시 얕은 복사를 먼저 언급하겠습니다. 얕은 복사는 원시primitive 자료형이라면 값을 그대로 복사하지만, 객체라면 참조reference를 복사하므로, 공유 참조가 되면서 원본과 복사된 객체 중 하나가 수정되면 다른 쪽에도 영향이 가게 됩니다. 사이드 이펙트, 즉 개발자의 실수로 의도하지 않은 변경이 일어날 여지가 존재하는 셈입니다.


JavaScript에서 원시 자료형이란 '객체가 아니며 메소드가 없는 데이터'로 정의됩니다. string, number, bigint, boolean, undefined, symbol, null이 해당합니다.



Spread Operator

흔히 사용되는 방법으로, 전개 연산자로 얕은 복사본을 만드는 예시입니다. 아래 코드와 같이, 중첩된 프로퍼티를 추가/수정하면 원본과 사본 모두 변경되는 모습을 볼 수 있습니다.


const original = {
    someProp: 'value',
    anotherProp: {
        withAnotherProp: true,
    }
};

const shallowCopy = { ...original };

shallowCopy.newProp = 'new value';
shallowCopy.anotherProp.newProp = 'new value';

console.log(original.newProp); // undefined
console.log(original.anotherProp.newProp); // 'new value'


Object.assign()

마찬가지로 자주 사용되는 Object.assign()의 경우에도 동일합니다. MDN 문서에서도 아래와 같이 설명하고 있습니다.


Object.assign()은 프로퍼티의 값을 복사하므로, 깊은 복사를 수행하려면 다른 방법을 사용해야 합니다. 만약 값이 객체에 대한 참조라면 참조 값만 복사합니다.


const original = {
    someProp: 'value',
    anotherProp: {
        withAnotherProp: true 
    }
};

const shallowCopy = Object.assign({}, original);

shallowCopy.anotherProp.withAnotherProp = false;

console.log(original.anotherProp.withAnotherProp); // false





깊은 복사, Deep Copy

얕은 복사와 달리, 복사본이 원본의 참조와 동일한 참조를 공유하지 않는 별개의 독립적인 복사본을 생성합니다. 객체의 참조가 아니라, 참조가 되는 모든 객체 자체에 대한 완전히 새로운 사본을 만드는 것입니다.

아래의 선택지 이외에도 재귀 함수를 직접 구현하는 방법으로로 구현할 수 있겠지만, 어느 정도 원리를 이해하고 있다면 굳이 바퀴를 다시 발명할 필요는 없을 것입니다.


어째서 JavaScript에는 깊은 복사를 구현하는 메소드가 없었는지는 아래 TC39의 이슈들을 통해서 알아볼 수 있습니다.



JSON.parse()

일종의 트릭으로, 직렬화가 가능하다면 JSON.stringify()로 객체를 JSON string으로 변환한 다음 다시 JSON.parse()로 변환하여 새로운 객체를 생성하는 방법입니다.


const deepCopy = JSON.parse(JSON.stringify(original));

JSON의 문법 구조가 JavaScript에 비하면 훨씬 단순하기 때문에, 높은 효율로 파싱이 가능하다는 점에서 최적화의 여지가 있어 종종 언급되는 트릭이기도 했지만 (특히 V8 엔진), 막상 벤치마크 결과 등을 참고해보면 성능적으로 그렇게 대단한 이득이 있는 것은 아닙니다.

또한 함수는 물론 Symbol, DOM, Map, Set, Date, ArrayBuffer, RegExp 등은 직렬화 과정에서 실패하거나 손실된다는 한계도 있습니다.



_.cloneDeep()

그래서 흔히 사용되는 lodash 라이브러리의 cloneDeep을 사용하는 경우가 많았습니다. 상대적으로 성능상 트레이드오프가 있긴 하지만, JSON 트릭보다 좀 더 의도에 부합하게(정확하게) 재귀적으로 깊은 복사를 수행할 수 있기 때문입니다.


const deepCopy = _.cloneDeep(original);


structuredClone()

이러한 필요 때문이었는지, 네이티브로 깊은 복사를 수행할 수 있는 방법이 추가되었습니다. MDN에 따르면 구조화된 복제 알고리즘을 사용하여 깊은 복사를 수행할 수 있습니다. JSON 트릭에 비하면 당연히 제한 사항도 더 적고, 성능도 좋습니다.


const original = { name: 'MDN' };

original.itself = original;

const clone = structuredClone(original);

console.assert(clone !== original);
console.assert(clone.name === 'MDN');
console.assert(clone.itself === clone);

호환성은 아래와 같습니다. 주요 브라우저들은 다소 제한적으로 지원하고 있고, Node.js의 경우에는 현재 글 작성 시점 기준으로는 LTS 버전에 포함돼 있지 않아 사용이 불가할 수 있습니다. 타입스크립트에서도 4.7 버전부터 지원합니다. 또한 폴리필도 존재합니다 : core-js, structured-clone



하지만 structuredClone() 역시 여전히 일부 제한 사항은 있습니다.

  • 클래스 인스턴스의 경우 프로토타입이 폐기되고 일반 객체가 반환됩니다.
  • DOM, 함수의 경우에는 복사에 실패하고 에러가 발생합니다.





참고 문서

profile
퇴고를 좋아하는 주니어 웹 개발자입니다.

0개의 댓글