[Javascript] Call by Value vs. Call by Reference와 얕은 복사 vs. 깊은 복사

싱클베어·2022년 1월 17일
2

배열을 다루는 알고리즘을 쭉 풀다보니 아래의 개념을 확실히 잡고 넘어가야 헷갈리지 않겠다는 생각에 작성해보았다.

Javascript 에서 주로 사용되는 데이터 타입을 먼저 살펴보면

  • 원시 자료형 타입(Primitive data type) : string, number, boolean, null, NaN, undefined
  • 객체(Object) : array, object, function

이렇게 사용되며 데이터를 전달할 때 원시 자료형 타입값(Value)을 복제하여 전달되고, 객체는 현재 object가 들어있는 주소값을 복제한 참조(Reference)가 전달된다. 각각 Call by Value, Call by Reference로 대표되는데 아래 내용으로 살펴보자.

Call by Value

let a = 50;
let b = a; 

console.log(b); // 50

let b = a 구문은, 사실 let b = 50 과 동일하다. a의 값(Value)을 복제하여 전달됐기 때문이다.

let b = a 구문 직후에 a = 10; 라인을 추가하면,

let a = 50;
let b = a; // a의 value '50' 복사
a = 10; // a의 값은 10으로 변경. b는 변화 없음
console.log(b); // a에서 가져온 Value 50이 그대로 있음
console.log(a); // 10

다른 예를 들어보자.

let name = 'jim';
function changeName(name) {
  name = 'david';
  return name;
}

console.log(changeName(name)); // 'david'
console.log(name); // 'jim'

changeName(name)에 들어가는 (name)은 'jim' 이라는 값을 복사해서 넘겨줬을뿐, 원본 변수인 name 에는 변경이 없다.

Call by Reference

const greetings = ["hi", "hello"];
const insa = greetings; // Array insa는 greetings의 값(Value)을 복제해왔을까요?

insa.push("annyeonghaseyo"); // insa 배열에 안녕하세요 추가

console.log(insa); // ['hi', 'hello', 'annyeonghaseyo']
console.log(greetings); // ['hi', 'hello', 'annyeonghaseyo']

greetings.push("guten tag"); // greetings 배열에 독일어 인사 추가

console.log(insa); // ['hi', 'hello', 'annyeonghaseyo', 'guten tag']
console.log(greetings); // ['hi', 'hello', 'annyeonghaseyo', 'guten tag']
// insa는 greetings

greetings["hi", "hello"] 배열의 메모리 주소를 참조(pointing)하고 있다.
const insa = greetings; 를 수행하면, insa 변수에 greetings의 배열 을 전체 복사해오는 것이 아닌 greetings가 현재 가리키고 있는 배열의 주소를 저장한다.

결과적으로, greetingsinsa 둘 다 동일한 배열을 가리키고 있는 주소값을 가지게 되고, insa 또는 greetings 를 통해 데이터를 넣거나 뺄 경우 원본 데이터도 변경된다.

let arr = [7,8,9];
let arr2 = [7,8,9];
console.log([10] === [10]) // false 
console.log(arr === arr2) // false

그렇기 때문의 위의 경우도 외형적인 생김새는 동일하지만, 실제로는 arrarr2가 각자 다른 메모리 주소에 저장되어 있기 때문에 동일하지 않다.

두 내용을 정리해보면 아래와 같다.

Call by reference로 동작하면, 컵의 참조(Reference) 값을 전달한다. 원본 주소로 접근해 값을 변경시키면 값이 변한다.

// call by reference : array object
let cup = ['empty cup'];
function fillCup(cup){
  cup.push('coffee');
  return cup;
}

console.log(cup); // ['empty cup']
console.log(fillCup(cup)); // ['empty cup', 'coffee']
console.log(cup); // ['empty cup', 'coffee']

Call by value 로 동작하면, 컵의 값(Value)을 전달한다. 컵 자체를 복사하게 되므로, 원본 값은 변하지 않는다.

// call by value : string
let cup = 'empty cup';
function fillCup(cup){
  cup = cup + ' coffee';
  return cup;
}

console.log(cup); // 'empty cup'
console.log(fillCup(cup)); // 'empty cup coffee'
console.log(cup); // 'empty cup'

배열 복제 다루기

배열을 이용한 알고리즘을 풀다보면 주어진 숫자 배열을 원본과의 비교를 위해서, 또는 원본 배열의 정렬이 변경되면 안된다는 조건을 달성하기 위해서 등 여러가지 이유로 배열을 복사해야 될 때가 많다.

아래와 같이 복제를 시도하려고 했던 적이 있다.

const arr1 = [1,2,3];
const arr2 = arr1; // 얕은 복사

arr2.pop(); // 배열의 가장 뒤에 있는 값을 제거, 3 return

console.log(arr2); // [1,2]
console.log(arr1); // [1,2]

위에서 알아본 대로, 이는 배열의 참조를 받는 것이지 배열의 복제는 아니다.

arr2arr1의 배열 주소를 복사받았기 때문에, arr2를 수정하면 arr1과 동일한 주소에 접근하여 배열을 수정하는 것이므로 arr1의 값도 동일하게 변경된다.

복제를 위해 두 가지를 주로 사용하였다.

Array.slice()

slice의 경우 복사하는 배열의 요소(element)가 무엇이냐에 따라 동작이 달라진다. 배열 내 값이 숫자와 같은 원시 자료형 타입 - (string, number, boolean, null, NaN, undefined)로 이루어진 경우, Call by Value 이므로 원본 배열에서 값(Value) 을 복사해온다.

let arr1 = [1,3,5,7,9]
let arr3 = arr1.slice(); // slice() 내 인자를 지정하지 않을 경우 전체를 복사
arr3[4] = 100; 

console.log(arr3); // [1,3,5,7,100]
console.log(arr1); // [1,3,5,7,9] // 원본 데이터 변화없음

다른 예시를 들어보자.

let Animals = ['사자', '호랑이', '낙타', '오리'];
let newAnimals = Animals.slice(); // Animals 전체 복사
console.log(newAnimals); // ['사자', '호랑이', '낙타', '오리']

newAnimals[0] = '곰';
console.log(newAnimals); // ['곰', '호랑이', '낙타', '오리']
console.log(Animals); // ['사자', '호랑이', '낙타', '오리'] , 원본 데이터 변화없음

배열안에 들어있는 자료형이 string 이기 때문에 복사가 잘 되었다.

그런데 아래와 같이 Object로 바뀐다면, 값Value으로 복사가 되지 않고 Call by Reference 형식으로 동작한다.

let Animals = [{name:'사자'}, {name:'호랑이'}, {name:'낙타'}, {name:'오리'}];
let newAnimals = Animals.slice(); // Animals 전체 복사
console.log(newAnimals); // ['사자', '호랑이', '낙타', '오리']

newAnimals[0].name = '곰';
console.log(newAnimals); // [{name:'곰'}, {name:'호랑이'}, {name:'낙타'}, {name:'오리'}]
console.log(Animals); // [{name:'곰'}, {name:'호랑이'}, {name:'낙타'}, {name:'오리'}] , 원본 데이터도 변함

MDN의 Array.prototype.slice() 를 읽어보면 설명이 아래와 같이 서술되어 있다.

slice does not alter the original array. It returns a shallow copy of elements from the original array. Elements of the original array are copied into the returned array as follows:

이 문장이 굉장히 모순된다고 생각했다. original array(원본 배열)를 바꾸지 않는데, shallow copy(얕은 복사) 라니? 아래에 이어서 설명이 나온다.

Array.slice() 에서 말하는 원본을 변경하지 않는다는 말은, pop, push, shift, unshift 처럼 원본 배열의 요소를 직접 변형시키는 동작을 하지 않는다고 이해하면 되겠다.

  • For objects, slice copies object references into the new array. Both the original and new array refer to the same object. If an object changes, the changes are visible to both the new and original arrays.
  • For strings, numbers and booleans (not String, Number and Boolean objects), slice copies the values into the new array. Changes to the string, number, or boolean in one array do not affect the other array.

즉, 위에서 봤었던 예제처럼 배열 내에 element가 string, number, boolean 등 원시 자료형 타입이 오면, 새 배열에 값을 복사하는 Call by Value 방식으로 동작하는 것이고, Object와 같은 객체가 있을 경우 Call by Reference 방식으로 동작하여 우리가 원했던 "배열을 새로운 배열에 복제하는 행위" 가 발생하지 않았던 것이다. 이를 얕은 복사 라고 말한다.

이와 같이 배열안에 요소로 배열, 객체가 들어있을 경우 깊은 복사 가 필요하다.

깊은 복사 (Deep Copy)

위에서 숫자로만 구성된 배열을 사용하다가 객체, 배열을 element 로 가지고 있는 객체를 복사하려 할 경우 깊은 복사 방식이 필요하다.

아래 두개의 방법을 시도하지만, 두 개 모두 제대로 동작하는 깊은 복사는 아니다.

Object.assign()

  • Object.assign()은 Object 형태의 데이터를 쉽게 병합할 수 있게 해주는 함수이다.
const originObj = {a:1};
const newObj = Object.assign({}, originObj); // 빈 Object에 originObj를 병합하여 반환.
// result
newObj; // {a:1} 
originObj === newObj; // false
newObj.a = 100;
console.log(newObj); // {a:100}
console.log(originObj); // {a:1}

빈 Object인 newObj에 originObj 를 병합한다. 내용은 originObj의 내용을 그대로 가져왔지만, call by reference 방식이 아니었기 때문에 newObj.a 값을 변화시켜도 originObj.a 값은 별개로 유지된다.

전개 연산자 (Spread Operation)

const Obj1 = {a:1, b:2};
const Obj2 = {...Obj1}; // Spread Operation
// result
Obj2 // {a:1, b:2}
Obj1 === Obj2 // false

...을 이용하여 배열이나 Object 형태의 element를 순차적으로 풀어서 저장하는 형태이다.

단, 위와 같이 Object.assign()Spread Operation 을 이용한 깊은 복사의 경우도 Object 내부에 Object가 있거나, 배열이 있는 등의 경우에는 온전히 복사해오지 못하고, 복사본 값이 변하면 원본도 변하게 되는 Call by reference 형태로 복사해오게 된다. 깊이가 현재에 있는 Depth - Level 1 만큼만 복사가 가능하다.

const Obj1 = { a: { b: 1 } };
const Obj2 = { ...Obj1 };
// result
Obj2 // { a: { b: 1 } }
Obj1 === Obj2 // false
Obj1.a === Obj2.a // true...!!
Obj2.a.b = 100;
Obj1.a.b; // Obj2.a.b 에서 바꾼 100 값으로 변경된다.

완벽한 깊은 복사를 하는 방법

  • 모든 깊이의 객체까지 복사하는, 직접 만든 커스텀 재귀 함수 사용
  • Lodash의 cloneDeep() 사용 (별도 패키지 설치)
  • JSON 객체의 메소드 이용 JSON.stringfy, JSON.parse

참고 URL

자바스크립트 개발자라면 알아야하는 핵심 컨셉 33개 #3. Value Types and Reference Types
[Stackoverflow] - Does Javascript slice method return a shallow copy?
MDN - Array.slice()
[Javascript] 얕은 복사과 깊은 복사
Javascript로 Deep Copy 하는 여러 방법
깊은 복사와 얕은 복사에 대한 심도있는 이야기

profile
안녕하세요.

0개의 댓글