들어가기 전에

자바스크립트 개발자라면 알아야 할 33가지 개념 #18 자바스크립트 : 오브젝트 복사하기

오브젝트는 자바스크립트의 기반이 되는 개념 중 하나입니다. 오브젝트는 기본적으로 프로퍼티들의 컬렉션입니다 그리고 프로퍼티는 키와 값의 조합입니다. 거의 대부분의 자바스크립트 오브젝트는 프로토타입 체인의 top에 위치한 Object의 인스턴스입니다.

소개(Introduction)

let obj = {
  a: 1,
  b: 2,
};
let copy = obj;

obj.a = 5;
console.log(copy.a);
// Result 
// a = 5;

JS Bin에서 편집해보기

obj 변수는 초기화되는 새로운 오브젝트를 위한 컨테이너입니다. copy 변수는 같은 오브젝트를 가리키고 그 오브젝트를 가리키는 레퍼런스입니다. { a : 1, b : 2} 오브젝트는 "나에게 접근하는 방법은 2가지가 있지" 라고 말하는 중입니다. obj 변수 또는 copy 변수 둘 중 하나를 통해서 오브젝트에 접근할 수 있고 접근 후에 오브젝트에 변화를 주게 되면 두 방법 중 어떤 방법으로 접근했는지에 상관없이 오브젝트 자체에 변화가 일어날 것입니다.

call by reference

불변성은 요즘 화제입니다 그리고 이러한 흐름을 반드시 알아야 합니다! 이러한 방법을 사용하지 않으면 어떠한 불변성의 형태를 제거하고 코드의 다른 부분에서 원본 오브젝트가 사용되어, 버그를 만들 수도 있습니다.

오브젝트를 복사하는 원시적(naive)인 방법

오브젝트를 복사하는 원시적인 방법은 원본 오브젝트의 프로퍼티를 반복하고 각 프로퍼티를 복사하는 것입니다. 예제 코드를 봅시다.

function copy(mainObj) {
  let objCopy = {}; // objCopy will store a copy of the mainObj
  let key;

  for (key in mainObj) {
    objCopy[key] = mainObj[key]; // copies each property to the objCopy object
  }
  return objCopy;
}

const mainObj = {
  a: 2,
  b: 5,
  c: {
    x: 7,
    y: 4,
  },
}

console.log(copy(mainObj));

JS Bin에서 편집해보기

상속 이슈

  1. objCopymainObj 오브젝트 프로토타입 메소드와 다른 새로운 Object.prototype 메소드를 갖고 있습니다. 그건 우리가 원하는 것이 아닙니다. 우리는 원본 오브젝트의 정확한 복사본을 원합니다.
  2. 프로퍼티 기술자(descriptors)는 복사되지 않습니다. false 값으로 세팅되는 값을 가진 "작성 가능한" 기술자는 objCopy에서는 true가 될 것입니다.
  3. 위의 코드에서는 mainObjenumerable 프로퍼티만을 복사합니다.
  4. 만일 원본 오브젝트에서의 프로퍼티 중 하나가 오브젝트 그 자체라면, 그 오브젝트는 각각의 프로퍼티에서 같은 오브젝트를 참조하게 되며 복사본과 원본 사이에서 공유될 것입니다.

얕은(Shallow) 오브젝트 복사하기

소스 최상위 레벨 프로퍼티들이 어떠한 참조없이 복사될 때, 오브젝트는 얕게 복사된다고 합니다. 그리고 레퍼런스로 복사된 오브젝트 값을 가진 소스 프로퍼티가 존재하게 됩니다. 만일 소스 값이 오브젝트를 가리키는 레퍼런스라면, 결국 타겟 오브젝트를 가리키는 레퍼런스 값만 복사하게 됩니다.

얕은 복사는 최상위 레벨 프로퍼티들을 복사합니다. 하지만 중첩된 오브젝트들은 원본(original, source)과 복사본(copy, target)사이에서 공유됩니다.

Object.assign() 메소드 사용하기

Object.assign() 메소드는 하나 또는 그 이상의 원본 오브젝트로부터 복사본 오브젝트로 모든 enumerable한 프로퍼티의 값을 복사하기 위해 사용됩니다. 반환 값은 물론 복사본 오브젝트입니다.

let obj = {
  a: 1,
  b: 2,
};
let objCopy = Object.assign({}, obj);
console.log(objCopy);
// Result - { a: 1, b: 2 }

JS Bin에서 편집해보기

이 메소드도 지금까지는 제 역할을 잘 하고 있네요. 우리는 obj의 복사본을 만들었습니다. 이제 불변성이 존재하는지 확인해봅시다.

let obj = {
  a: 1,
  b: 2,
};
let objCopy = Object.assign({}, obj);

console.log(objCopy); // result - { a: 1, b: 2 }
objCopy.b = 89;
console.log(objCopy); // result - { a: 1, b: 89 }
console.log(obj); // result - { a: 1, b: 2 }

JS Bin에서 편집해보기

위의 코드에서, 우리는 프로퍼티 objCopy 오브젝트 내에 존재하는 'b'의 값을 89로 바꾸고 변경된 objCopy 오브젝트를 콘솔에 출력해보았습니다. 변화는 오직 objCopy 오브젝트에만 적용됐습니다. 마지막 줄은 obj 오브젝트가 아직 그대로이고 변하지 않았는지 확인하는 코드입니다. 이것이 의미하는 것은 우리가 원본 오브젝트에서 참조 없이 성공적으로 오브젝트 복사에 성공했다는 것입니다.

Object.assign()의 함정

우리는 성공적으로 복사본을 만들었고 모든 것들이 정상적으로 돌아갔습니다. 우리가 얕은 복사에 대해서 논의했던 것에 대해 기억하시나요? 이 예제를 봅시다.

let obj = {
  a: 1,
  b: {
    c: 2,
  },
}
let newObj = Object.assign({}, obj);
console.log(newObj); // { a: 1, b: { c: 2} }

obj.a = 10;
console.log(obj); // { a: 10, b: { c: 2} }
console.log(newObj); // { a: 1, b: { c: 2} }

newObj.a = 20;
console.log(obj); // { a: 10, b: { c: 2} }
console.log(newObj); // { a: 20, b: { c: 2} }

newObj.b.c = 30;
console.log(obj); // { a: 10, b: { c: 30} }
console.log(newObj); // { a: 20, b: { c: 30} }

// Note: newObj.b.c = 30; Read why..

JS Bin에서 편집해보기

왜 obj.b.c = 30일까?

Object.assign()의 함정은 바로 얕은 복사만 가능하다는 것입니다. 위의 소스에서 newObj.bobj.b 둘 다 같은 레퍼런스를 공유합니다. 왜냐면 개개에 대한 카피는 이뤄지지 않았으니까요. 대신 오브젝트를 가리키는 레퍼런스만 복사됐습니다. 이 경우, 오브젝트의 프로퍼티를 변화시키면 오브젝트를 사용하는 모든 레퍼런스에 변화가 적용됩니다. 이걸 어떻게 고쳐야 할까요? 계속 읽어봅시다. 다음 섹션에서 고쳐보도록 할 겁니다.

알아둬야 할 것 : 프로토타입 체인의 프로퍼티 그리고 non-enumerable한 프로퍼티들은 복사될 수 없습니다. 다음을 보세요.

let someObj = {
  a: 2,
}

let obj = Object.create(someObj, { 
  b: {
    value: 2,  
  },
  c: {
    value: 3,
    enumerable: true,  
  },
});

let objCopy = Object.assign({}, obj);
console.log(objCopy); // { c: 3 }

JS Bin에서 편집해보기

  • someObj는 obj의 프로퍼티 체인에 있습니다 그래서 복사되지 않습니다.
  • property b는 non-enumerable 프로퍼티입니다.
  • property c는 enumerable 프로퍼티 기술자가 enumerable하게 만들어주고 있습니다. 그래서 이 프로퍼티는 복사됩니다.

오브젝트 깊은 복사하기

깊은 복사는 만나는 모든 오브젝트를 복사할 것입니다. 복사본과 원본 오브젝트는 어느 프로퍼티도 공유하지 않을 겁니다. 여기서 Object.assign()을 사용할 때 만나게 되는 문제점을 해결할 것입니다. 한번 살펴봅시다.

JSON.parse(JSON.stringify(object)) 사용하기

이러한 방식은 우리가 이전에 가졌던 이슈를 해결합니다. 이젠 newObj.b는 레퍼런스가 아닌 복사본을 갖습니다! 이게 깊은 복사를 하는 한가지 방법입니다. 다음 예제를 봅시다.

let obj = { 
  a: 1,
  b: { 
    c: 2,
  },
}

let newObj = JSON.parse(JSON.stringify(obj));

obj.b.c = 20;
console.log(obj); // { a: 1, b: { c: 20 } }
console.log(newObj); // { a: 1, b: { c: 2 } } (New Object Intact!)

불변성: ✓

JS Bin에서 편집하기

함정(Pitfall)

불행하게도, 이 메소드는 사용자 정의 오브젝트 메소드를 복사하는데 이용될 수는 없습니다. 아래를 보세요.

오브젝트 메소드 복사하기

메소드는 오브젝트의 함수 프로퍼티입니다. 지금까지의 예제에서, 우리는 오브젝트의 메소드를 복사했던 적은 없습니다. 이제 한번 시도해볼 것입니다. 그리고 복사를 위해 배웠던 메소드들을 사용할 것입니다.

let obj = {
  name: 'scotch.io',
  exec: function exec() {
    return true;
  },
}

let method1 = Object.assign({}, obj);
let method2 = JSON.parse(JSON.stringify(obj));

console.log(method1); //Object.assign({}, obj)
/* result
{
  exec: function exec() {
    return true;
  },
  name: "scotch.io"
}
*/

console.log(method2); // JSON.parse(JSON.stringify(obj))
/* result
{
  name: "scotch.io"
}
*/

JS Bin에서 직접 해보기

위의 소스의 결과는 Object.assign()이 메소드를 복사하는데 사용될 수 있다는 결과를 보여줍니다. 반면에 JSON.parse(JSON.stringify(obj))는 사용될 수 없습니다.

순환하는 오브젝트 복사하기

순환하는 오브젝트는 그들 자신을 참조하는 프로퍼티를 가진 오브젝트입니다. 여태까지 배운 오브젝트를 복사하는 메소드를 사용하여 순환하는 오브젝트를 복사해보고 실제로 작동하는지 확인합시다.

JSON.parse(JSON.stringify(object)) 사용하기

JSON.parse(JSON.stringify(object))를 시도해봅시다.

// circular object
let obj = { 
  a: 'a',
  b: { 
    c: 'c',
    d: 'd',
  },
}

obj.c = obj.b;
obj.e = obj.a;
obj.b.c = obj.c;
obj.b.d = obj.b;
obj.b.e = obj.b.c;

let newObj = JSON.parse(JSON.stringify(obj));

console.log(newObj); 

결과는 이렇습니다.

errorcircularobjcopy.webp

JSON.parse(JSON.stringify(object))는 순환하는 오브젝트를 복사할 수 없습니다.

Object.assign() 사용하기

Object.assign()를 시도해봅시다.

// circular object
let obj = { 
  a: 'a',
  b: { 
    c: 'c',
    d: 'd',
  },
}

obj.c = obj.b;
obj.e = obj.a;
obj.b.c = obj.c;
obj.b.d = obj.b;
obj.b.e = obj.b.c;

let newObj2 = Object.assign({}, obj);

console.log(newObj2); 

JS Bin에서 편집해보기

결과는 다음과 같습니다.

circularobjcopyres.webp

Object.assign()는 순환하는 오브젝트를 얕은 복사하는데 아무런 문제가 없지만 깊은 복사는 여전히 할 수 없습니다. 브라우저 콘솔에서 circular object tree를 마음편히 돌아보세요. 아마 거기에 재밌는 부분이 있을 거라 확신합니다.

Spread Elements 사용하기 (...)

ES6는 이미 배열 해체 할당과 구현된 어레이 리터럴을 위한 확장 엘리먼트를 가지고 있습니다. 배열에 대한 확장 엘리먼트 구현을 한번 보세요.

const array = [
  "a",
  "c",
  "d", {
    four: 4
  },
];
const newArray = [...array];
console.log(newArray);
// Result 
// ["a", "c", "d", { four: 4 }]

JS Bin에서 편집해보기

오브젝트 리터럴을 위한 확장 프로퍼티는 현재 ECMAScript의 Stage 3 Proposal에 올라있습니다. 오브젝트 이니셜라이저안의 확장 프로퍼티는 자체적인 enumerable 프로퍼티들을 원본에서 복사본으로 복사합니다. 아래의 예제는 만일 위의 proposal이 통과된다면 얼마나 편하게 오브젝트를 복사할 수 있는지에 대한 예시입니다.

let obj = {
  one: 1,
  two: 2,
}

let newObj = { ...z };

// { one: 1, two: 2 }

알아두세요: 오직 얕은 복사에만 적용됩니다.

결론

자바스크립트에서 오브젝트를 복사하는 것은 꽤나 겁나는 일입니다. 특히 자바스크립트 초보자이고 언어를 잘모른다면 더욱 그렇습니다. 이 아티클이 오브젝트 복사 방법을 적절히 이해하는데 도움이 되어 당신이 나중에 오브젝트를 복사할 때 함정에 빠지지 않았으면 합니다. 만일 더욱 좋은 결과를 낼 수 있는 라이브러리나 코드가 있다면 커뮤니티와 함께 공유해주시기 바랍니다. 즐거운 코딩 되세요!