서론
얕은 복사와 깊은 복사에 대해 정리하다보니 여러 개념이 얽혀있고, 그에 대한 이해가 필요하다는 것을 알게됐다.
나도 얕은 복사와 깊은 복사에 대해 굉장히 많이 헷갈렸었고, 지금도 가끔 헷갈리는 개념이므로 처음부터 정리하고자 한다.
원시타입과 참조타입
Javascript 에는 원시타입과 참조타입이 존재한다. 각각 타입에 무엇이 존재하는지 알아보고, 그 둘의 차이점을 안다면 이번 포스팅의 주제인 얕은 복사와 깊은 복사에 대해 이해하기 쉬워질 것이라 생각한다.
1. 원시타입
Javascript의 원시타입에 다음과 같은 타입들이 존재한다.
- string
- number
- bigint
- boolean
- undefined
- null
- symbol (ES6+)
원시타입은 메모리에 저장될 때 '불변성'을 가지고 있다. 일단 아래 코드를 보자.
let val = 0;
console.log(val); // 0
val = 1;
console.log(val); // 1
위의 코드를 보면 val은 number 타입이고, 즉 원시타입인데 값을 다시 할당해주면서 값이 변했다.
"아니 값이 변했는데 왜 불변성임?" <- 라고 할 수 있는데, 다음과 같은 그림을 보자.
위의 그림을 보면 val이라는 변수에 0을 할당 한다는것은, 메모리에 0을 담고 그 메모리의 주소를 val이라는 변수가 가리키게 하는것이다. 그런데 val을 다시 1로 재할당을 하면 0의 값은 없어지는것이 아니다.
새로운 메모리에 1이라는 값을 담고 val이라는 변수는 다시 그 1을 가리킬 뿐이다. 따라서 메모리엔 0과 1의 값 둘다 존재하게 된다. 그러므로 "한번 선언한 원시타입의 값은 변하지 않는다."는 불변성을 가지는 것이다.
그럼 더 나아가서 아래 코드와 같은 상황엔 어떻게 될까?
let val = 0;
console.log(val); // 0
val = 1;
console.log(val); // 1
let newVal = val;
console.log(newVal) // 1
val = 2;
console.log(newVal) // 1
일단 newVal은 val이 가리키는것을 가리키는 것이 아닌, val이 가리키는 값을 가져와서 새로운 메모리에 할당해 그 새로운 메모리를 가리키게 된다.
따라서 newVal과 val은 값은 같더라도 서로 다른 메모리를 가리키고 있기 때문에 val의 값을 바꿔도 newVal의 값은 변하지 않는다.
2. 참조타입
Javascript의 참조타입에는 원시타입을 제외한 모든 것이 참조타입이다. 배열, 객체, 심지어 함수까지도 참조타입이다.
예를 들어 참조타입 중 하나인 객체를 다음과 같이 선언한다고 해보자.
let obj = {
key1: value1,
key2: value2,
...
};
위 obj는 메모리에 어떻게 저장이 될까?
일단 메모리는 자신만의 공간과 그 공간만의 이름(주소)가 존재한다. 그리고 Javascript엔 Call Stack과 Heap 공간이 존재한다. 만약 선언한 변수가 참조타입일 경우엔 그 값들이, 즉 객체의 프로퍼티들은 Heap의 한 공간에 저장이 된다.
그리고 Heap의 한 공간을 나타내는 이름, 즉 주소는 Call Stack에 저장이 된다. 그리고 식별자, 즉 변수의 이름인 obj는
Heap에 저장이 된 값이 들어있는 주소를 값으로 가지는 Call Stack의 주소를 가리킨다... 헷갈려,,,
위에서 원시타입은 이미 선언된 변수를 가져다 다른 변수에 넣었을 경우엔 원래 값을 바꿔도 복사한 값은 바뀌지 않았다.
그러면 위와 같은 참조타입은 어떻게 될까? 아래와 같은 코드가 있다고 해보자.
let obj = {
key1: value1,
key2: value2,
...
};
let newObj = obj;
위와 같이 Call Stack을 참조하게 되는데, 그 value는 Heap에 저장되어 있는 우리가 선언한 값을 가리키는 주소값이다.
따라서 새로운 메모리에 값을 복사해서 새로 만드는 것이 아닌, 기존에 있는 공간의 참조에 대한 복사이므로 같은 값을 공유하게 된다. 따라서 원래 값을 바꾸면 newObj도 값이 같이 바뀌게 된다. (같은 값을 가리키고 있으니까!)
3. 차이
일단 가장 큰 차이는 원시타입은 크기가 정해져있고, 참조타입은 크기가 변할 수 있다는 것이다.
크기가 정해져있다는 것이 물론 최소, 최대 크기가 정해져 있겠지만 그 말이 아니라 위에서도 언급한 '불변성'의 특성상 크기가 정해져있다는 것이다.
하지만 참조타입은 우리가 흔히 배열, 객체를 사용할 때 보면 계속해서 값을 추가하고, 삭제하고 동적인 크기를 가진다.
3. 얕은 복사
위에서도 살펴봤듯이, 얕은 복사란 원본 값과 복사한 값이 같은 메모리 주소를 가리키고 있는 것을 말한다. 한마디로 같은 값의 공간을 공유하는것이다. 따라서 원본과 복사값 모두 같은 값을 공유하기 때문에 원본의 값을 바꾸면 복사값 또한 값이 바뀌게 된다.
let user = { name: 'John' };
let admin = user;
admin.name = 'Pete';
console.log(user); // { name: 'Pete' }
user.name = 'James';
console.log(admin); // { name: 'James' }
위와 같이 원본 객체를 바꿔도 사본 객체도 같이 바뀌고, 사본 객체를 바꿔도 원본 객체또한 같이 바뀌게 된다.
let obj1 = {};
let obj2 = {};
console.log(obj1 === obj2); // false
obj2 = obj1;
console.log(obj1 === obj2); // true
위의 경우에는 obj1과 obj2를 따로 선언을 해주었기 때문에 처음에는 서로 다른 메모리 주소를 가리키고 있을 것이다. 따라서 둘이 비교를 하면 false를 반환하게 된다. 하지만 후에 obj2에 obj1을 재할당을 해주었는데, 이때 obj2는 obj1과 같은 메모리 주소를 가르키게 된다. 이 말은 즉, 같은 메모리 공간을 공유하게 된다는 것이므로 true가 반환이 된다.
let obj1 = { key1: 1, key2: [] };
let obj2 = { key1: 1, key2: [] };
console.log(obj1 === obj2); // false
obj2 = obj1;
console.log(obj1 === obj2); // true
obj2 = { ...obj1 };
console.log(obj1 === obj2); // false
console.log(obj1.key2 === obj2.key2); // true
obj1.key2.push(1);
console.log(obj1.key2 === obj2.key2); // true
위와 같이 전개연산자로 복사할 수가 있다. 그러면 결국 obj1의 모든 요소들을 펼쳐서 새로운 객체 안에 넣는것이기 때문에 비교를 했을 때 false가 반환이 된다. 그런게 객체끼리의 비교가 false인데 객체 안의 key2인 배열을 비교하면 true가 반환이 된다. 왜 그럴까?
결국 배열도 참조타입이다. key1의 값은 원시타입인 Number 타입이라서 그대로 값을 가져와서 복사를 하였다. 그래서 당연히 둘이 비교하면 값을 비교하기 때문에 true이다. 하지만 배열은 참조타입이므로 주소값을 복사해왔다. 한마디로 같은 공간을 복사해왔다는 뜻이다. 따라서 obj1의 key2에 1을 push를 해도 obj2.key2의 값도 똑같이 변한다.
4. 깊은 복사
일단 원시타입들은 기본적으로 깊은 복사로 복사가 이루어진다. 따라서 참조타입의 깊은복사가 문제인데... 다음과 같은 방법들이 있다.
1) 반복문으로 직접 복사하기
let user = {
name: "John",
age: 30
};
let clone = {}; // 새로운 빈 객체
// 빈 객체에 user 프로퍼티 전부를 복사해 넣습니다.
for (let key in user) {
clone[key] = user[key];
}
// 이제 clone은 완전히 독립적인 복제본이 되었습니다.
clone.name = "Pete"; // clone의 데이터를 변경합니다.
alert( user.name ); // 기존 객체에는 여전히 John이 있습니다.
2) Loadash 라이브러리 사용
3) JSON.parse(JSON.stringify())
JSON.stringify()는 객체를 json 문자열로 변환한다. 이 과정에서 기존 참조가 모두 끊어지게 된다.
JSON.parse()는 다시 원래 객체로 만들어주는 역할을 한다. 따라서 정리하자면 기존 참조를 끊어버리고 새로운 객체에다가 넣는것이다.
(PS등에서 가장 많이 사용했던 방법이다.)
5. 리액트 hooks 에서의 참고사항
2023.03.05 - [Web/React] - React Hooks
( useMemo 의 글만 따로 가져왔습니다. 전체 예시가 궁금하다면 위 포스팅 참조바랍니다.)
import React, { useState, useMemo, useEffect } from 'react';
function App() {
const [number, setNumber] = useState(0);
const [isKorea, setIsKorea] = useState(true);
const location = isKorea ? 'korea' : 'foreign';
useEffect(() => {
console.log('Call useEffect');
}, [location]);
return (
<div>
<h2>How many meals do you eat a day?</h2>
<input
type="number"
value={number}
onChange={(e) => setNumber(e.target.value)}
/>
<hr />
<h2>what country are you in?</h2>
<p>country: {location}</p>
<button onClick={() => setIsKorea(!isKorea)}>get on a plane</button>
</div>
);
}
export default App;
버튼을 누르면 'korea'와 'foreign'가 왔다갔다 거리는 간단한 컴포넌트이다.
그리고 useEffect를 이용해서 location이 바뀔 때만 불리도록 해주었다. 따라서 number가 바뀌어도 useEffect는 불리지 않을 것이다.
지금처럼 의존성 배열에 들어간 값이 문자열처럼 원시 타입이라면 괜찮지만 객체같은 참조 타입이라면 원하는대로 동작하지 않을 수 있다.
import React, { useState, useMemo, useEffect } from 'react';
function App() {
const [number, setNumber] = useState(0);
const [isKorea, setIsKorea] = useState(true);
const location = { country: isKorea ? 'korea' : 'foreign' };
useEffect(() => {
console.log('Call useEffect');
}, [location]);
return (
<div>
<h2>How many meals do you eat a day?</h2>
<input
type="number"
value={number}
onChange={(e) => setNumber(e.target.value)}
/>
<hr />
<h2>what country are you in?</h2>
<p>country: {location.country}</p>
<button onClick={() => setIsKorea(!isKorea)}>get on a plane</button>
</div>
);
}
export default App;
이번엔 location을 객체 타입으로 바꾸어주었다. 그리고 { location } 또한 { location.country } 로 바꿔주었다.
그런데 이번엔 number를 바꿔도 useEffect가 호출이 된다. 그 이유는 객체 타입에 있다.
객체는 값이 같더라도 메모리에는 참조 주소값이 들어가있고, 두 객체는 다른 주소를 가리키기 때문이다.
따라서 number라는 state가 바뀌어서 App 컴포넌트가 re-render가 되고, location 또한 다시 초기화가 될 것이다.
값은 같지만 이전의 객체와 현재 새로운 객체의 주소가 다르기 때문에 useEffect가 불리게 되는것이다.
그래서 useMemo를 이용해서 아래와 같이 최적화 시킬 수 있다.
import React, { useState, useMemo, useEffect } from 'react';
function App() {
const [number, setNumber] = useState(0);
const [isKorea, setIsKorea] = useState(true);
const location = useMemo(() => {
return { country: isKorea ? 'korea' : 'foreign' };
}, [isKorea]);
useEffect(() => {
console.log('Call useEffect');
}, [location]);
return (
<div>
<h2>How many meals do you eat a day?</h2>
<input
type="number"
value={number}
onChange={(e) => setNumber(e.target.value)}
/>
<hr />
<h2>what country are you in?</h2>
<p>country: {location.country}</p>
<button onClick={() => setIsKorea(!isKorea)}>get on a plane</button>
</div>
);
}
export default App;
위와 같이하면 더이상 number state가 변하더라도 isKorea가 바뀔 때만 location.country의 값이 변하기 때문에 useEffect가 불리지 않게 될 것이다.
'Web > Vanilla JS' 카테고리의 다른 글
호이스팅(Hoisting) (2) | 2023.09.03 |
---|---|
Symbol (0) | 2023.04.06 |
Object Method (0) | 2023.03.30 |
Array Method (0) | 2023.03.30 |