객체를 복사하다

객체를 복사하는 방법들에 대해 알아보도록 하겠습니다.
다양한 복사방법들을 알아보고, 얕은 복사와 깊은 복사의 차이점과 한계점 그리고 해결법에 대해 알아보도록 하겠습니다.

리터럴 표기법을 활용한 복사

리터럴 표기법은 객체를 생성하는 가장 일반적인 방법입니다.
쉼표로 구분된 키-값 쌍의 요소들을 중괄호로 감싸서 선언합니다.

const Dog = {
  name: "앵두",
  age: 2,
  sound: "멍멍",
  bark() {
    console.log(this.sound);
  },
  events: [
    {
      no: 1,
      description: "거품토를 했다"
    }
  ]
};

우선, 이 리터럴 표기법을 활용해 객체를 복사하는 방법을 알아봅시다.

전개 구문을 이용해 객체 복사하기

전개 구문을 이용해 다른 객체의 내용을 복사할 수 있습니다.
아래의 코드에서 새로 생성된 cpDog는 Dog 객체와 동일한 내용을 가진 클론 객체가 됩니다.

const cpDog = {
  ...Dog
};

다른 객체를 기반으로 새로운 객체 생성

다른 객체의 내용을 기반으로 새로운 객체를 생성합니다.
아래의 SuperDog는 Dog와 동일한 내용을 가졌지만, sound 속성과 name 속성을 새로 선언한 값으로 덮어 씌웠습니다. (오버라이딩)

const SuperDog = {
    ...Dog,
    name: "광견",
    sound: "으르렁쾅쾅!!"
}

얕은 복사 문제

앞부분에 선언된 Dog 객체를 잘 보시면 events 속성의 값이 배열로 이루어진 것을 보실 수 있습니다.
전개 구문을 이용해 객체 복사를 할 경우 이 events 속성은 cpDog의 events 속성으로 참조 복사가 됩니다.

즉, cpDog의 events 속성에 새로운 사건을 추가하면 Dog 객체의 events 속성에도 동일한 수정사항이 발생합니다.

cpDog.events.push({
  no: 2,
  description: "초콜렛을 먹었다"
});

console.log(Dog.events[1]);
// 출력 : Object {no: 2, description: "초콜렛을 먹었다"}

이러한 것을두고 객체가 얕게 복사된다고 하며, 얕은 복사 문제라고도 합니다.

이는 의도치 않은 외부효과가 발생하여 버그의 원인이 될 수 있습니다.

Object.assign

  • Object.assgin([대상객체명], [원본객체명]])

이름 그대로 대상이 되는 객체로 원본 객체의 내용을 할당합니다.
앞서 리터럴 표기법 부분에서 전개 구문을 이용해 객체 복사하기 와 비슷한 기능을 수행합니다.

역시 얕은 복사가 진행이 되는 점에 유의하여 주십시오.

const cpDog = Object.assign({}, Dog); // Dog 객체의 클론 만들기

아래의 코드는 결과값이 어떻게 될까요?

const cpDog2 = {
    sound:"야옹"
};

Object.assign(cpDog2, Dog);

전개 구문을 통해 복사를 할 때는 복사를 받고자하는 객체(cpDog)에 복사 해주는 객체(Dog)와 동명의 프로퍼티(sound)를 삽입하게되면, 복사를 받고자하는 객체(cpDog)의 값으로 덮어쓰기(오버라이딩)가 되었던 것을 기억하실겁니다.

하지만, Object.assign의 경우엔 대상 객체인 cpDog2의 sound 속성을 원본 객체인 Dog의 sound 속성이 덮어 씌워버립니다.

즉 원본 객체의 값을 우선시하여 복사합니다.

구체적으로 말하자면, Object.assign은 원본 객체의 속성들을 다시 대상 객체로 '할당'하기 때문에, 당연히 원본 객체에 남아있던 기존 속성들을 덮어쓰기가 된다는 이야기입니다.

JSON을 활용한 깊은 복사

JSON은 JavaScript Object Notation의 의미를 가진 두문자어입니다.

그대로 번역하면 자바스크립트 객체 표기법 이 되는데,
JSON은 자바스크립트의 객체를 리터럴 표기법과 비슷한 모양새로 '문자열'로 변환하고, 변환된 문자열을 다시 객체로 되돌리는 기능을 제공합니다.

  • JSON.stringify([객체명]) - 객체를 JSON 문자열로 변환
  • JSON.parse([JSON문자열]) - JSON 문자열을 다시 객체로 변환
const Dog = {
  name: "앵두",
  age: 2,
  sound: "멍멍",
  bark() {
    console.log(this.sound);
  },
  events: [
    {
      no: 1,
      description: "거품토를 했다"
    }
  ]
};

const jsonDog = JSON.stringify(Dog); // Dog를 JSON 문자열로 변환
const cpDog = JSON.parse(jsonDog); // JSON 문자열을 객체로 변환

내부 동작

Dog 객체가 JSON 문자열로 바뀌면 아래와 같은 형태가 됩니다.

{"name":"앵두","age":2,"sound":"멍멍","events":[{"no":1,"description":"거품토를 했다"}]} 

보시다시피 events 속성 역시 내부의 값이 문자열로 변환되어 기록된 것을 확인할 수 있습니다.
(적합한 비유일지는 모르겠으나 마치 탁본을 뜬 것과도 같이 events 속성의 내용을 기록해뒀습니다.)

이 문자열을 해석해 마치 리터럴 표기법을 새로 작성하듯 객체를 생성하게 되니, 종전의 얕은 복사 문제는 일어나지 않게 됩니다.

이러한 복사를 깊은 복사 라고 부르며, 위 처럼 JSON을 활용한 방법이 가장 대중적인 것으로 알고 있습니다.

단, 이 방법으로는 내부의 메소드나 getter/setter 까지는 복사가 되지 않는다는 단점이 있습니다.

완전한 깊은 복사

아래는 필자가 직접 작성해본 객체의 깊은 복사 코드입니다.
중첩된 객체는 물론 메서드와 getter/setter 까지 완전한 복사가 가능합니다.

앞서서 배운 속성 설명자를 십분 활용하여 만든 것입니다.

/**
 * 객체를 깊이 복사한다
 * @param {Object} target 복사를 받을 대상이 되는 객체
 * @param {Object} source 복사를 해올 원본이 되는 객체
 **/
function ObjectDeepCopy(target, source) {
    if (Array.isArray(source)) return [...source];

    const props = Object.getOwnPropertyNames(source);
    const descriptors = props.map(name => [
        name,
        Object.getOwnPropertyDescriptor(source, name)
    ]);

    for (const [name, descriptor] of descriptors) {
        if (Array.isArray(descriptor.value)) {
            descriptor.value = [...descriptor.value];
        } else if (typeof descriptor.value === "object") {
            descriptor.value = ObjectDeepCopy({}, descriptor.value);
        }

        Object.defineProperty(target, name, descriptor);
    }

    return target;
}

위 코드에는 사실 부족한 부분이 몇 가지 있습니다.

힌트가 되는 키워드는 프로토타입과 상속인데, 여기에 관련된 내용은 이후 문서에서 다시 다뤄보면서 코드를 정정해보도록 하겠습니다.