깊은 복사와 얕은 복사에 대한 심도있는 이야기. 자바스크립트 개발자라면 반드시 알고 있어야하는 깊은 복사와 얕은 복사에… | by Moon | WATCHA | Medium

notion imagenotion image
notion imagenotion image

JavaScript

모든 프로그래머에게 어린아이 예절 교육하듯이 끊임없이 중요하다고 강조되고 있는 이야기, “데이터를 깊게 & 얕게 때에 따라 잘 복사한다”. 기업 면접에서 가장 빈번하게 나오는 주제이기도 한데, 과연 “복사”라는 행위에 대해 어디까지 알고 있는가?
언어마다 복사라는 행위는 다르게 정의되어 있을 수 있다. 그러나 적어도, 지금까지 누군가에게 자신이 자바스크립트 개발자라고 말하고 다니고 있었다면 지금 이 자리에서 잠시 눈을 감고 스스로에게 질문해보자. 나는 자바스크립트로 복사에 대해 어디까지 알고 있었는지.

읽기 전에

이 글은 여러분이 자바스크립트, 조금 더 명확하게는 ECMA-262 에 명시된 다음의 내용을 모두 알고 있다는 가정하에 작성되었다. 그리고, 이 글은 자바스크립트 명세서를 읽고 이해할 수 있을 정도의 수준은 되어야 제대로 이해할 수 있으니 난이도가 조금 있는 셈이다.
  • Spread Operator
  • Object.assign
  • JSON.stringify
  • Iterable
특히, 이터러블에 대해서 모른다면 해당 개념에 대해 먼저 공부하고 이 글을 읽기를 권한다.

설명을 시작하기에 앞서

우선, 여러가지 예제를 가지고 설명할텐데, 위의 checker 메소드로 입력받은 값의 같음을 비교할 예정이다. ramda 는 자바스크립트 함수형 라이브러리로 함수형 아키텍쳐를 구현할 때 많이 사용하는 유용한 라이브리다. checker 는 모든 입력 값이 같다면 true 를, 아니면 false 를 콘솔에 출력한다. 더 자세히 알고 싶다면 이 곳을 참조하기 바란다.

Array.prototype.slice

이 방식은 아마도 제일 널리 알려진 방식일텐데, 주로 배열을 깔끔하게 복사할 때 사용할 수 있다. Array.prototype.slicestart 부터 end 까지 대상 배열에서 꺼내와 새로운 배열을 만들어 값을 집어 넣는다. startend 가 주어지지 않으면 전체 배열을 복사한다.
const arr = [1, 2, 3]; const copied = arr.slice();checker(arr, copied); // truecopied.push(4); checker(arr, copied); // false
복사가 깔끔하게 이뤄졌다.
문제가 없어보이겠지만, Array.prototype.slice는 중첩 구조 복사를 제대로 수행할 수 없다는 단점이 있다.
const arr = [1, 2, [3, 4]]; const copied = arr.slice();checker(arr, copied); // truecopied[2].push(5); checker(arr, copied); // true ~~~~~~~ // This shouldn't return true anymore
copied 가 복사된 객체라 arr 과 아무런 연관이 없어야하지만 중첩된 구조를 변경하면 원본과 복사본 모두 영향을 받는다. 사실 Array.prototype.slice 는 얕은(shallow) 복사를 수행하기 때문에, 모든 값을 독립적으로 복사할 수 없다.

Spread Operator

이터러블을 모르면 이제 여기서부터 이해하기 어려울 수 있다.
펼침 연산자(Spread Operator)의 등장으로 값의 복사가 전반적으로 매우 편리해졌다. 펼침 연사자가 호출될 때, 내부적으로는 iterator-looping 액션을 수행한다. 조금 더 자세히 설명하자면, 어떤 객체에 [Symbol.iterator] 프로퍼티가 존재한다면 이터러블하다고 할 수 있고 반복문을 돌릴 수 있다.
모든 자바스크립트 객체는 반복이라는 행위를 수행하기 위해선 빨간색 네모에 해당하는 녀석을 갖고 있어야한다
notion imagenotion image
Symbol(Symbol.iterator) 는 콘솔에서 보이는 보여주기식 이름이고, 명세서엔 @@iterator 혹은 @@Symbol.iterator 라고 표기하고 있다.
const b = [1, 2, 3]; const a = [ ...b ];
와 같은 작업을 수행한다면 내부적으로는 대략적으로 다음과 같은 일이 일어난다.
if (!(Typeof(b) is Iterable)) { throw TypeError }var a = []; for (var i = 0; i < b.length; i += 1) { a.push(b[i]); }
위 과정이 이터러블 객체에 대한 복사인데, 객체가 이터러블임이 확인이되면 for-loop 과 같은 단순한 반복문을 통해 모든 요소를 하나씩 옮겨 담는다. 여기서 중요한 점은, 옮겨 담는 요소의 깊이(depth)는 철저히 1-dpt 라는 것.
위의 코드는 명세서의 흐름을 알아보기 쉽게 자바스크립트 문법으로 변환한 것이며 실제는 훨씬 더 많은 검사 과정이 들어가있다.
const arr = [1, 2, [3, 4]]; const copied = [ ...arr ];checker(arr, copied); // truecopied[2].push(5); checker(arr, copied); // true ~~~~~~~ // It should've been false
펼침 연산자 역시 이와 같은 이유로 중첩 구조를 복사하지 못한다.
가장 처음에 언급했던 Array.prototype.slice 역시 이제 어떻게 돌아가는지 감이 올거라 생각한다.
즉, 자바스크립트에서 “복사” 라고 알려져있는 거의 모든 기능은 이터러블 순회를 수행하도록 설계되어 있다.

Object.assign

가끔 Object.assign 으로 객체를 복사하는 개발자들을 봤는데, 시도는 좋았지만 역시나 결과는 “복사할 수 없다” 이다.
const arr = [1, 2, [3, 4]]; const copied = Object.assign([], arr);checker(arr, copied); // truecopied[2].push(5); checker(arr, copied); // true ~~~~~~~ // It should've been false
MDN 에는 Object.assign 을 사용해서 깊은 복사를 하는 것에 대해 다음과 같이 설명해놓았다.
“For deep cloning, we need to use alternatives because Object.assign() copies property values. If the source value is a reference to an object, it only copies that reference value.”

JSON.parse & JSON.stringify

이제 이 글의 진짜 주제에 대해 본격적으로 다뤄보기로 한다.
위의 내용은 사실 JSON.stringify 외에도 널리 알려진 다른 방식에 대한 내용을 다뤘었고, 이마저도 모두 “할 수 없음” 으로 정리할 수 있었다.
JSON.stringify 는 위에서 만났던 모든 예제들이 부딪혔던 단 하나의 높은 벽, 중첩 구조를 복사할 수 없는 이터러블 순회를 해결할 수 있다. 그것도 아주 깔끔하게. 입력 값으로 넘어온 데이터를 문자로 변형을 시켜주는 메소드인데, JSON.parse 는 이렇게 변형된 문자를 다시 원래 객체로 되돌려주는 역할을 한다.
const arr = [1, 2, [3, 4]]; const copied = JSON.parse(JSON.stringify(arr));checker(arr, copied); // truecopied[2].push(5); checker(arr, copied); // false
중첩구조 복사 역시 아주 깔끔하게 수행된 것을 볼 수 있는데, 이는 객체 순환을 통해 값을 옮겨담는 과정이 아닌, 문자열로 변경 후 그것을 다시 해석해 원본 객체로 변경하는 과정에서 자바스크립트의 문자열(string) 이라고 불리는 데이터 타입이 immutable primitive type, 즉 불변성의 형질을 띠는 원시 타입이기 때문이다.
const obj = { d: new Date() }; const copied = JSON.parse(JSON.stringify(obj));checker(obj, copied); // false
위의 예제는 원본을 복사한 후 바로 비교를 수행한 모습인데, 두 객체는 서로 동일하지 않다고 평가된 것을 볼 수 있다.

대체, 왜?

notion imagenotion image
왼쪽이 원본 / 오른쪽이 복사본
notion imagenotion image
Date 객체가 JSON.stringify 의 입력 값으로 전달되면 JSON.stringifyDate 객체의 toString 메소드를 실행한다. 그래서, 같은 시간 같은 날짜를 가리키는 데이터라고 해도 보여지는 방식이 달라졌기 때문에 두 값의 서로 다른 값으로 간주되는 것이다.
사실 문제는 여기서 끝나는게 아니다. ECMA2020 에 새롭게 들어온 기능인 BigInt 역시 JSON.stringify 가 처리할 수 없는 객체다. Date 보다 한 술 더 떠서 아예 오류를 뱉는다.
JSON.stringify(BigInt(1)); ~~~~~~~~~~~~~~ // Uncaught TypeError // Do not know how to serialize a BigInt
또 한가지 재밌는 점은, 위의 Array.prototype.slice 와 같은 예제에서 보여준 메소드들과는 다르게 JSON.stringify 는 무한 사이클을 갖는 구조를 받지 않는다.
const a = []; a[0] = a;JSON.stringify(a); // Uncaught TypeError // Converting circular structure to JSON
이 경우는 순회하지 않는 경우를 뜻하는 “acyclic” 이라는 단어가 아예 ECMA-262 명세서에 따로 언급되고 있다.
딱 하나만 더 예외를 보여주고 왜 그런것인지를 설명하면 될 듯하다.
함수 역시 JSON.stringify 가 문자로 바꿀 수 없는 객체 중 하나다.
const arr = [function(){}, () =>{}]; const copied = JSON.parse(JSON.stringify(arr));checker(arr, copied); // false
notion imagenotion image
왼쪽이 원본 / 오른쪽이 복사본
notion imagenotion image
앞에서 몇 번 언급했을 때 이미 눈치챈 사람도 있겠지만, 자바스크립트의 표준 국제 이름은 ECMAScript-262 다. 이는 ECMA 표준 국제 언어 중 262 라는 식별 번호를 가진 언어라도 이해할 수 있는데, 여기에는 순수하게 브라우저나 서버가 아닌 ECMAScript 라는 언어에 대해 기술해놓은 명세서가 존재하고, 이를 줄여서 보통 ECMA-262 라고 통칭한다.
마찬가지로, JSON 객체에 대한 명세서 역시 따로 존재하는데, 이 명세서도 ECMA 에서 관리되고 있으며 번호는 404 를 부여받았다. 그래서 ECMA-404 라고 통칭한다.
The image is from ECMA-404 documentation
notion imagenotion image
어쨌든, ECMA-404, 즉 JSON 명세서에는 다음과 같이 JSON 값으로 표현될 수 있는 종류를 명시해놓았는데 위의 그림과 같다. 오직 저 종류에 해당하는 값들만 JSON 값으로 인정하겠다는 뜻이다.
ECMA-262, 즉 자바스크립트의 명세서에는 JSON.stringify가 JSON 값에 해당하지 않는 경우를 입력 값으로 받았을 때 어떻게 대응할 것인지 명시해 놓은 부분이 있다.
Image source: ECMA-262
notion imagenotion image
요약을 하자면 이렇다. JSON 값으로 판단될 수 있는건 ECMA-404 에 따르면 object 부터 null 까지 모두 7 가지 종류만 포함되고 나머지는 JSON 으로 분류될 수 없다. 이 종류는 ECMA-262 에도 내장 객체 타입의 상세한 종류에 자세히 나와있는데, 더욱 자세하게 표현하자면 Object.prototype.toString.call 을 실행했을 때 나오는 두 번째 타입과 관련이 있다. 이 내용은 여기서 따로 설명하기엔 주제에 너무 벗어난 내용이니 다음에 기회가 있을 때 다뤄보도록 하겠다.
어쨌든, 자바스크립트에서 함수에 해당하는 객체는 JSON 값으로 간주될 수 없다. 왜냐면 저 목록에 함수가 없기 때문. 함수는 일반 함수나 애로우 함수 모두 같은 함수로 분류된다. 제너레이터, async 함수 역시 모두 같은 함수로 분류된다. 함수 외에도 undefined 역시 JSON 값이 될 수 없는데, 이렇게 JSON 값이 될 수 없는 경우엔 다음과 같은 경우의 수에 따라 다르게 처리한다.
  1. 배열을 넘길 경우 — 해당 값을 null 로 치환한다.
  1. 객체를 넘길 경우 — 해당 { key: value } 를 모두 삭제한다. 왜냐하면 JSON.stringify 에는 두 번째 인자로 함수를 넘길 수 있는데, 이 함수의 반환값이 fasly 인 경우엔 해당 { key: value} 를 결과 값에 포함시키지 않는다. 마찬가지로, 객체를 넘겼는데 그 안에 함수나 undefined 과 같은 값이 value 로 들어있다면 입력 받은 값의 평가 과정에서 해당 값이 falsy 로 간주되어 결과에 포함되지 않는다.
  1. 단일 값으로 넘길 경우 — undefined 로 처리한다
  1. 그 외에 기타 경우 — 에러 발생 (대표적으로 BigInt )
JSON.stringify(function() {}) // undefined JSON.stringify([function() {}]) // [null] JSON.stringify({f: function() {}) // {} JSON.stringify({f: () => {}) // {} JSON.stringify({d: undefined}) // {}

Lodash 와 Ramda 를 이용한 비교

하늘이 무너져도 솟아날 구멍이 있다고 하던가. 아예 방법이 없는 것은 아니다. 대표적으로 lodash 와 ramda 가 완전한 깊은 복사를 구현해놓았는데, 그 둘을 비교해보자.
notion imagenotion image
왼쪽이 loadsh / 오른쪽이 ramda
notion imagenotion image
코드의 구성은 전부 똑같고, 중첩 구조, undefined , BigInt 등 모두 예외에 걸릴만한 데이터로 실험했다.
실험 결과는 두 라이브러리 모두 성공적으로 완전하게 깊은 복사를 수행했다고 보여진다. 이는 지금까지 내가 위에서 설명한 모든 내용을 전부 부정하는 꼴인데 이게 어떻게 가능한 것일까?
The cloning method in Lodash
lodash 에서 구현해놓은 cloneDeep 인데, 눈여겨 볼 부분은 아래 이미지다.
notion imagenotion image
Object.prototype.toString.call 을 실행했을 때 나오는 경우의 수를 모두 기록해놓은 거라고 봐도 좋다. lodash 는 단순 무식하게 발생가능한 모든 경우를 저장해놓고 브루트포스 방식으로 처리하듯이 무식하게 처리한다.
The cloning method in Ramda
그에 비해 ramda 는 추상화가 상대적으로 매우 잘되어 있는 편이다.
notion imagenotion image
허나 lodash 역시 어느정도 경우의 수를 두고 체크를 하기에, 결국 lodash 와 ramda 는 복사라는 행위의 퍼포먼스가 떨어지더라도 정확성에 의미를 두고 있다는 것을 알 수 있다.
그러나 깊은 복사를 수행하기 위해 항상 lodash 와 ramda 를 프로젝트에 설치해야한다는 의미는 아니다.
자바스크립트에 왜 깊은 복사를 수행할 수 있는 메소드가 존재하지 않냐는 의문이 들 수 있는데, 이는 TC39 멤버인
가 설명해놓은 부분으로 대신 답변하겠다.
notion imagenotion image
관심이 있다면 아래 이슈 역시 확인해보면 좋다.

결론

자바스크립트에서 주로 알려져있는 깊은 복사를 하는 방식은 사실 모두 깊은 복사를 수행할 수 없다. 내부적으로 그렇게 모델링 되어있지 않기때문. 그렇기 때문에 일부 라이브러리들은 사용자의 요구를 채워주기 위해 다소 낮은 퍼포먼스를 띠더라도 정확하게 모든 요소를 복사하는 메소드를 구현해 놓았다.
그러나 역시 가장 베스트는, 깊은 복사를 하기 전에 과연 깊은 복사를 무리하면서까지 해야하는지 아키텍쳐 관점에서 다시 한 번 생각해봄이 좋지 않을까 하고 생각한다.

추가)

BigIntJSON.stringify 를 원래 통과해야 정상이지만, JSON.parse 로 다시 원상복귀할 때 어떻게 값을 원복시킬 것인지에 대한 코드 처리가 없기때문에 JSON.stringify 역시 현재는 이용할 수 없다.

Resources