얕은 복사와 깊은 복사는 자바스크립트에서 객체(Object) 데이터 타입을 복사할 때, 객체의 타입 속성으로 인해 발생하는 복사 방식의 차이다. 이를 이해하기 위해선 우선 자바스크립트의 타입 속성에 대해 이해할 필요가 있다.
자바스크립트가 제공하는 데이터 타입은 String, Number, Boolean, null, undefined, symbol, BigInt, Object
로 총 8가지인데, 이 데이터 타입을 또 크게 두 분류로 나눌 수 있다.
각각 원시형(기본형, Primitive Type)과 참조형(Reference Type)이다. 원시형에는 Object
를 제외한 모든 데이터 타입이 속한다. 참조형 데이터는 Object
, 일반적으로 말하는 객체와 배열이 된다. 두 타입이 가지고 있는 특징적인 차이는 아래와 같다.
두 데이터 타입은 값에 대한 변경이 가능한지, 불가능한지 여부에 따른 특징적인 차이를 가지고 있다. 원시형은 변경 불가능한 값(immutable value)이고 참조형은 변경 가능한 값(mutable value)이다.
다만 여기서 주의해야 할 점은, 변수 재할당과 값의 변경은 서로 다른 얘기라는 것이다. 변수에 값을 할당하는 것은 특정 데이터 주소에 값을 넣어두고, 그 데이터 주소와 변수를 링크하는 것과 같다. 재할당은 "새로운 데이터 주소에 새 값을 넣어두고 그 데이터 주소와 변수를 새로이 링크하는 일"이고 값의 변경은 "기존에 넣어둔 데이터 주소 내부의 값을 변경하는 일"(즉, 데이터 주소가 변경되지 않음)이다.
원시형 데이터는 변경이 불가능하기 때문에 원시값을 할당받은 변수는 오직 재할당을 통해서만 변수 값을 변경할 수 있다. 이 특징을 뚜렷하게 알 수 있는 것이 문자열String
인데, 문자열과 배열Array
이 가지고 있는 차이점을 생각해보자.
String
과 Array
둘 다 내부 요소에 대괄호 표기법으로 접근이 가능하고, length
프로퍼티를 통해 길이를 탐색할 수 있다. indexOf()
, lastIndexOf()
메소드로 요소 탐색도 가능하고, 심지어 String
에도 for ... of
반복문을 사용할 수 있다.
하지만 String
은 변경 불가능한 원시값이기 때문에, Array
에 사용 가능한 다양한 메소드 중 "원본을 바꾸는" 메소드는 사용할 수 없다. 즉, slice()
메소드는 원본 배열을 바꾸지 않고 새 값을 반환하기 때문에 String
과 Array
둘 다 사용할 수 있다. splice()
메소드는 원본 배열도 함께 수정하기 때문에 String
에서는 사용이 불가능하다. 두 메소드 모두 인덱스를 이용한 메소드이고 String
과 Array
모두 인덱스를 통한 접근 및 요소 찾기가 가능하다는 점을 생각해본다면, 이 차이가 어디서 오는지 명확하게 알 수 있을 것이다.
원시형과 다르게 참조형은 값 자체의 내부 요소를 변경할 수 있다. 참조형 데이터를 변수에 선언하면, 해당 참조형 데이터는 데이터 주소 A에 저장되고, 변수는 이 데이터 주소 A의 주소값이 저장된 데이터 주소 B로 링크된다. 그렇기 때문에 변수에 링크된 데이터 주소 B 안의 데이터 주소 A를 통해 참조형 데이터에 접근하고 값을 수정할 수 있다. 이 때 데이터 주소 A를 참조 값(Reference value)
이라고 한다.
// 원시형 값의 복사
let immutableValue = 30;
let copy = immutableValue;
immutableValue = 50;
console.log(copy); // 30
원시형 값을 복사할 경우, 두 변수 모두 같은 값을 갖게 되지만 실질적으로는 서로 다른 데이터 주소에 배정된 값을 가지고 있는 것이다. 즉 immutableValue
는 데이터 주소 A에 담긴 30을 가지고 있고, copy
는 데이터 주소 B에 담긴 30을 가지게 된다. immutableValue
에 새로운 값인 50을 재할당하면 이제 데이터 주소 C에 값 50이 담기고, immutableValue
는 데이터 주소 C와 링크된 것이다. 두 변수의 원시 값은 서로 다른 공간에 저장된 별개의 값이므로 서로 간섭할 수 없다.
// 참조형 값의 복사
let mutableValue = [1, 2, 3, 4];
let copy2 = mutableValue;
mutableValue[0] = 5;
console.log(copy2); // [5, 2, 3, 4]
하지만 참조형 값의 복사는 구조때문에 여러 개의 변수가 하나의 참조형 데이터를 공유할 수 있다. 배열 [1, 2, 3, 4]
는 데이터 주소 A 공간에 저장된다. 그리고 mutableValue
는 데이터 주소 A가 저장된 데이터 주소 B와 링크된다. 이를 복사한 copy2
는 데이터 주소 A가 저장된 데이터 주소 C와 링크된다. 즉 mutableValue
와 copy2
가 최종적으로 가리키고 있는 값이 동일하게 된다.(데이터 주소 A에 담긴 [1, 2, 3, 4]
)
그렇기 때문에 mutableValue
의 0번 인덱스를 수정하면 데이터 주소 A에 담긴 값 내부의 0번 인덱스를 수정하게 되고, copy2
는 동일한 대상을 가리키고 있기 때문에 copy2
역시 수정된 값으로 나오게 된다.
이런 문제점 때문에, Object
타입의 값을 복사할 때 동일한 값을 가지면서도 원본에 간섭하지 않는 복사본을 만드는 방식이 바로 얕은 복사와 깊은 복사다.
다른 Object
를 프로퍼티 값 또는 엘리먼트로 갖는 Object
, 즉 다차원 객체를 복사할 때 그 복사의 depth에 따라 얕은 복사와 깊은 복사가 나뉘게 된다.
얕은 복사와 깊은 복사를 통해 생성된 Object
는 원본과는 다른 값이다. 즉 위에 언급한 예시와는 다르게, 각각의 변수가 가리키는 참조 값 자체가 달라지게 된다.
얕은 복사는 한 단계까지만 복사하는 것을 말한다. 즉 새로운 참조 값을 가지게 되지만, 내부에 중첩되어있는 Object
까지 복사하지는 않는다.
let obj = { a: { x: 1 } };
let copy = obj;
let shallowCopy = { ... obj};
console.log(obj === copy); // true
console.log(obj === shallowCopy); // false
console.log(obj.a === shallowCopy.a) // true
예시와 같이 shallowCopy
는 새로운 참조 값을 가지므로 obj
와의 일치 비교에서 false
값을 반환하지만, 내부의 a
프로퍼티 값 또한 Object
이므로 이 값에 대해선 동일한 참조 값을 가리키게 된다.
얕은 복사를 하는 방법은 Object.assign()
메소드나 ...
전개 연산자(spread)를 활용하는 방법이 있다.
깊은 복사는 얕은 복사와 다르게 내부에 중첩되어 있는 모든 Object
를 복사한다. 즉 내부의 모든 값들이 원본과 완벽한 독립성을 가지는 새로운 Object
를 만든다.
깊은 복사는 직접 재귀 함수를 만들어 사용할 수도 있지만, 일반적으로 lodash 나 JSON을 이용하는듯 하다.
lodash를 이용한 깊은 복사는 해당 라이브러리의 cloneDeep()
메소드를 사용하는 것이고, JSON을 이용한 깊은 복사는 JSON.stringify()
메소드를 통해 완전히 JSON 문자열로 변환한 다음 JSON.parse()
메소드로 이 문자열을 다시 Object
로 변환하는 방식이다.
var
, let
, const
는 모두 변수 선언시 사용하는 키워드이다. 세 가지의 차이점은 아래와 같다.
var string1 = 'hi';
var string1 = 'hello';
let string2 = 'hi';
let string2 = 'hello';
// SyntaxError: Identifier 'string2' has already been declared.
const string3 = 'hi';
const string3 = 'hello';
// SyntaxError: Identifier 'string3' has already been declared.
let
과 const
는 중복 선언이 문법적인 오류를 발생시켜 사용할 수 없지만, var
는 오류 발생 없이 중복 선언이 가능하다. 즉 한 변수에 대한 중복 선언이 가능한 키워드다. 언뜻 보면 편해보일 수 있지만, 이처럼 변수의 중복 선언이 가능할 경우 변수 이름의 중복 사용을 야기해 더 큰 오류를 발생시킬 수 있다. (오류 메세지가 뜨지 않으므로 무엇이 문제인지도 모른 채...!!)
여기서 중복 선언과 재할당을 오인하지 말도록 하자. 선언 !== 할당이며, var
와 let
은 재할당이 가능한 키워드이다. const
는 재할당시 오류가 발생하는 키워드이며 이러한 특징으로 변하지 않는 값인 상수를 선언할 때 사용된다. (또는 입력 전까지는 어떤 값이 올 지 알 수 없는 변수이지만, 입력 이후엔 고정된 값으로 사용되는 변수를 선언할 때도 const
키워드를 사용한다.)
// 함수 스코프
function printHi () {
var varA = 'Hi';
let letA = 'Hi';
const constA = 'Hi';
}
console.log(varA); // ReferenceError: varA is not defined
console.log(letA); // ReferenceError: letA is not defined
console.log(constA); // ReferenceError: constA is not defined
// 블록 스코프
if (a < 10) {
var varB = 'Hi';
let letB = 'Hi';
const constB = 'Hi';
}
console.log(varB); // Hi
console.log(letB); // ReferenceError: letB is not defined
console.log(constB); // ReferenceError: constB is not defined
변수의 스코프는 해당 변수의 유효 범위를 의미한다. 이 유효 범위는 일반적으로 블록문({}
중괄호로 감싼 코드)을 기준으로 블록문 내부에 선언된 변수는 해당 블록문 내부에서만 유효한 로컬 변수(지역 변수)라고 하고, 외부에 선언된 변수는 글로벌 변수(전역 변수)라고 하며 블록문 내/외부를 통틀어 사용 가능하다.
let
과 const
키워드는 블록 스코프를 가지고, 위에서 설명한 바와 같이 블록문 내/외부를 기준으로 삼는다. 이 블록문은 함수, 제어문 뿐만 아니라 단순 {}
중괄호로 구분된 모든 부분을 포함한다.
var
키워드는 예외적으로 함수 스코프가 적용되어, 함수 내부에서 선언된 변수만 해당 함수 스코프를 가진다. 즉 함수를 제외한 다른 블록문에서는 블록 내부에서 선언되더라도 글로벌 변수로 사용이 가능하다. var
키워드의 이런 특성 역시 다양한 변수를 활용하고 관리할 때 많은 오류를 발생시킬 수 있다.
호이스팅은 일반적으로 '끌어올림'이라고 표현하는데, 쉽게 말하자면 해당 코드의 위치에서는 아직 선언되지 않은 변수(또는 함수)를 가져와서 사용하는 것이다.
// var 키워드
console.log(sayHi); // undefined
var sayHi = 'Hi';
console.log(sayHi); // Hi
// let 키워드 (const도 동일)
console.log(sayHello);
let sayHello = 'Hi'; // ReferenceError: sayHello is not defined
역시 키워드계의 이단아인 var
만 호이스팅이 가능하다.
여기서 주의할 점은, 호이스팅 자체는 가능하지만 아직 선언되지 않은 변수의 값 자체는 사용할 수 없다는 것이다. 예시에서도 선언되기 전까지는 undefined
가 출력되는 것을 알 수 있다. 실제 값을 사용할 수 있는건 코드에서 선언이 이루어진 후다. var
키워드의 호이스팅이 가능한 특징은 단순히 "오류를 발생시키지 않음"임을 명심하자.