[JavaScript 문법] 데이터 타입 심화(얕은 복사, 깊은 복사)

조아영·2024년 7월 24일

📌

자바스크립트에서 데이터 타입은 크게 기본형(Primitive Type)과 참조형(Reference Type)으로 구분됩니다. 기본형과 참조형의 구분 기준은 값의 저장 방식과, 불변성 여부입니다.

◼ 종류

1. 기본형(Primitive type)

Number, String, Boolean, null, undefined, Symbol

복제 방식(저장 방식)
값이 담긴 주소값을 바로 복제

불변성 여부
불변성을 띔(메모리의 관점에서 불변성을 판단해야 함)

2. 참조형(Reference type)

Object → Array, Function, Date, RegExp, Map, WeakMap, Set, WeakSet

복제 방식(저장 방식)
값이 담긴 주소값들로 이루어진 묶음을 가리키는 주소값을 복제

불변성 여부
불변성을 띄지 않음(메모리의 관점에서 불변성을 판단해야 함)


◼ 배경지식

1. 데이터, 메모리

비트(Bit)

  • 컴퓨터가 처리하는 가장 최소 단위
  • 한 자리 이진수를 의미
    예시) 1 0 1 1 0 0 0에서 비트는 1하나, 0하나

바이트(Byte)

  • 0과 1로만 능력을 나타내기엔 부족해 만든 단위
  • 1, 0의 8자리 모음 1011000 / 8bits = 1byte / 8개의 비트가 모여 1바이트를 이룸

메모리(memory)

  • (memo + ry) : byte 단위로 구성
  • 모든 데이터는 byte 단위의 식별자인 메모리 주소값을 통해서 서로 구분 된다.
    예시) 64bit = 8byte : 메모리에 저장 시 8개의 연속된 바이트에 저장

메모리 관리 방식

java, c는 javascript와 다른 메모리 관리 방식을 가지고 있습니다.
java, c언어가 등장했던 60년대에는 메모리의 크기가 넉넉하지 않아 개발자가 변수를 지정할때 변수의 메모리 크기도 고려해서 지정해야 했습니다.

예시) 변수 8을 저장하는 방법(feat. 정수형)

  • JS
    let a = 8(8byte 차지)
  • JAVA
    byte a = 8(1byte 차지)
    short a = 8(2byte 차지)
    int a = 8(4byte 차지)
    long a = 8(16byte 차지)

2. 식별자, 변수

var testValue = 3 일때,
testValue : 식별자 = 변수명
3 : 변수 = 데이터


◼ 변수 선언, 데이터 할당

1. 선언과 할당

/** 선언과 할당을 풀어 쓴 방식 */
var str;
str = "test!";

/** 선언과 할당을 붙여 쓴 방식 */
var str = "test!";

2. 값을 바로 변수에 대입하지 않는 이유

자유로운 데이터 변환

이미 입력한 문자열이 길어질 경우를 고려

예시
숫자는 항상 8byte로 고정이지만, 문자는 고정이 아닙니다(영문 : 1byte, 한글 : 2byte)
1003 주소에 할당된 데이터를 변환하고, 지금 가진 데이터보다 큰 데이터를 저장하려 한다고 가정하면
: 1004 주소 이후에 저장되어 있는 모든 데이터를 다 오른쪽으로 밀어야 합니다. 이런 경우가 생기기 때문에 값을 바로 변수영역 데이터(변수영역 주소값)에 대입하지 않고 데이터영역 데이터(데이터영역 주소값)에 입력합니다.

메모리의 효율적 관리

똑같은 데이터를 여러번 저장하는 경우를 고려

예시
1만개의 변수를 생성해서 모든 변수에 숫자 1을 할당하는 상황을 가정. 모든 변수를 별개로 인식한다고 한다면, 1만개의 변수 공간을 확보해야 합니다.

- 바로 변수영역에 대입하는 경우 (숫자형은 8바이트 고정)
변수영역 : 8byte * 1만개(변수명과 숫자값 1) = 총 8만 byte 사용

- 변수영역, 데이터영역을 나눠서 별도 저장하는 경우 (변수영역엔 2바이트를 저장한다고 가정)
변수영역 : 2byte 1만개(변수명) = 2만바이트
데이터영역 : 8byte
1개(숫자값 1) = 8바이트
= 총 2만 8바이트 사용


◼ 기본형 데이터, 참조형 데이터

메모리를 기준으로 생각해보는 주요 개념

변수 : 변수 영역 메모리를 변경할 수 있음(O)
상수 : 변수 영역 메모리를 변경할 수 없음(X)
불변하다 : 데이터 영역 메모리를 변경할 수 없음(X)
불변하지 않다 : 데이터 영역 메모리를 변경할 수 있음(O)

1. 기본형 데이터(불변값과 불변성)

변수 할당 과정

var a = "abc";

// 데이터를 변경합니다.
a = "abcdef";

a라는 변수에 'abc'를 'abcdef'로 변경해 보겠습니다.
"abc"라는 값이 데이터영역의 @5002주소에 들어갔다고 가정합니다.

a = a + "def" (X)
"def"라는 값이 @5002주소에 추가되는 것이 아닙니다.

a = "abcdef" (O)
@5003에 별도로 "abcdef"라는 값이 생기고 a라는 변수는 @5002 → @5003로 주소를 변경합니다.

이 때, @5002는 더 이상 사용되지 않기 때문에 가비지컬렉터의 수거 대상이 됩니다.

참조 카운트가 0인 메모리 주소의 처리
참조 카운트가 0인 객체는 더 이상 사용되지 않으므로, 가비지컬렉터에 의해 메모리에서 제거됩니다.

참조카운트
객체를 참조하는 변수나 다른 객체의 수를 나타내는 값입니다.

가비지컬렉터(GC, Garbage Collector)
더 이상 사용되지 않는 객체를 자동으로 메모리에서 제거하는 역할을 합니다. 자바스크립트는 가비지컬렉션을 수행함으로써 개발자가 명시적으로 메모리 관리를 하지 않아도 되도록 지원합니다. 자바스크립트 엔진에서 내부적으로 수행되며, 개발자는 가비지컬렉션에 대한 직접적인 제어를 할 수 없습니다.

불변성

변수영역 주소 : @1002 ,변수영역 데이터(값) : a/@5002 →변경됨→ a/@5003
데이터영역 주소 : @5002 ,데이터영역 데이터(값) : 'abc'
데이터영역 주소 : @5003 ,데이터영역 데이터(값) : 'abcdef' →새로운 데이터

변수영역 데이터에 데이터주소가 변경되었을 뿐,
데이터영역의 데이터가 변경되지는 않습니다.
즉, "변수 a는 불변하다." 라고 할 수 있습니다.

2. 참조형 데이터(가변값과 가변성)

변수 할당 과정

var obj1 = {
    a: 1,
    b: "bbb",
};

// 데이터를 변경합니다.
obj1.a = 2;

obj1.a의 값을 숫자 1에서 숫자 2로 변경해 보겠습니다.

변경할 값인 숫자 2는 데이터 영역에 없으므로 숫자 2를 추가합니다.
obj1을 위한 별도 영역(프로퍼티영역) @7103에 데이터를 a/@5001 → a/@5003로 변경합니다.

참조형 데이터는 별도 저장공간(변수영역, 데이터영역 이외의 객체의 변수(프로퍼티-a, b)을 위한 별도 공간)이 필요합니다.
이것이 기본형 데이터와 변수 할당 과정의 차이점입니다.

가변성

변수영역 주소 : @1001, 변수영역 데이터(값) : obj1/@7103~
데이터영역 주소 : @5001 ,데이터영역 데이터(값) : 1
데이터영역 주소 : @5002 ,데이터영역 데이터(값) : 'bbb'
데이터영역 주소 : @5003 ,데이터영역 데이터(값) : 2 →새로운 데이터
프로퍼티영역 주소 : @7103, 프로퍼티영역 데이터(값) : a/@5001 →변경됨→ a/@5003
프로퍼티영역 주소 : @7104, 프로퍼티영역 데이터(값) : b/@5002

데이터영역에 저장된 값은 여전히 계속 불변값이지만,
obj1을 위한 별도 영역(프로퍼티영역)은 얼마든지 변경이 가능합니다.
이것 때문에 참조형 데이터를 흔히, "불변하지 않다(=가변하다)"라고 합니다.

중첩객체의 할당

중첩객체 : 객체 안에 또 다른 객체가 들어가는 것
객체는 배열, 함수 등을 모두 포함하는 상위개념이기 때문에 배열을 포함하는 객체도 중첩객체라고 할 수 있습니다.

var obj = {
	x: 3,
	arr: [3, 4, 5],
}

변수영역 주소 : @1001, 변수영역 데이터(값) : obj/@7103~
데이터영역 주소 : @5001 ,데이터영역 데이터(값) : 3
데이터영역 주소 : @5002 ,데이터영역 데이터(값) : 4
데이터영역 주소 : @5003 ,데이터영역 데이터(값) : 5
obj영역 주소 : @7103, obj영역 데이터(값) : x/@5001
obj영역 주소 : @7104, obj영역 데이터(값) : arr/@8104~
arr영역 주소 : @8104, arr영역 데이터(값) : 0/@5001
arr영역 주소 : @8105, arr영역 데이터(값) : 1/@5002
arr영역 주소 : @8106, arr영역 데이터(값) : 2/@5003

3. 변수 복사의 비교

선언

// STEP1. 선언
var a = 10; //기본형
var obj1 = { c: 10, d: "ddd" }; //참조형

변수영역 주소 : @1001, 변수영역 데이터(값) : a/@5001
변수영역 주소 : @1002, 변수영역 데이터(값) : obj1/@7103~
데이터영역 주소 : @5001 ,데이터영역 데이터(값) : 10
데이터영역 주소 : @5002 ,데이터영역 데이터(값) : 'ddd'
obj1영역 주소 : @7103, obj1영역 데이터(값) : c/@5001
obj1영역 주소 : @7104, obj1영역 데이터(값) : d/@5002

복사

// STEP2. 복사
var b = a; //기본형
var obj2 = obj1; //참조형

변수영역 주소 : @1003, 변수영역 데이터(값) : b/@5001(a가 가진 주소)
변수영역 주소 : @1004, 변수영역 데이터(값) : obj2/@7103~ (obj1이 가진 주소)

복사 이후 값 변경(객체의 프로퍼티 변경)

// STEP3. 복사 이후 값 변경(객체의 프로퍼티 변경)
b = 15;
obj2.c = 20;

변수영역 주소 : @1003, 변수영역 데이터(값) : b/@5001(a가 가진 주소) →변경됨→ b/@5003
변수영역 주소 : @1004, 변수영역 데이터(값) : obj2/@7103~(obj1이 가진 주소) →주소는 그대로지만 값이 변경됨
데이터영역 주소 : @5003 ,데이터영역 데이터(값) : 15
→새로운 데이터
데이터영역 주소 : @5004 ,데이터영역 데이터(값) : 20
→새로운 데이터
obj1영역 주소 : @7103, obj1영역 데이터(값) : c/@5001
→변경됨→_ c/@5004

복사 이후 값 변경(객체 자체를 변경)

객체의 프로퍼티(=속성)에 접근해서 값을 변경하는 것이 아니라 객체 자체를 변경하는 방식

//기본형
var a = 10; // STEP1. 선언
var b = a; // STEP2. 복사

//참조형
var obj1 = { c: 10, d: "ddd" }; // STEP1. 선언
var obj2 = obj1; // STEP2. 복사

// STEP3. 복사 이후 값 변경(객체 자체를 변경)
b = 15;
obj2 = { c: 20, d: "ddd" };
// 참고
// obj2.c = 20; // STEP3. 복사 이후 값 변경(객체의 프로퍼티 변경)

변수영역 주소 : @1001, 변수영역 데이터(값) : a/@5001
변수영역 주소 : @1003, 변수영역 데이터(값) : b/@5001(a가 가진 주소) →변경됨→ b/@5003
변수영역 주소 : @1002, 변수영역 데이터(값) : obj1/@7103~obj2와 다른주소를 가짐
변수영역 주소 : @1004, 변수영역 데이터(값) : obj2/@8103~obj1과 다른주소를 가짐
데이터영역 주소 : @5001 ,데이터영역 데이터(값) : 10
데이터영역 주소 : @5002 ,데이터영역 데이터(값) : 'ddd'
데이터영역 주소 : @5003 ,데이터영역 데이터(값) : 15 →새로운 데이터
데이터영역 주소 : @5004 ,데이터영역 데이터(값) : 20 →새로운 데이터
obj1영역 주소 : @7103, obj1영역 데이터(값) : c/@5001
obj1영역 주소 : @7104, obj1영역 데이터(값) : d/@5002
obj2영역 주소 : @8103, obj1영역 데이터(값) : c/@5004
obj2영역 주소 : @8104, obj1영역 데이터(값) : d/@5002

  • obj2 변수는 참조형 데이터이고, 참조형 데이터의 값을 변경한 것임에도 불고하고 이전 케이스(객체의 프로퍼티 변경)와는 다르게 바라보는 데이터 영역의 값이 달라졌습니다.
  • 참조형 데이터의 ‘가변’은 참조형 데이터 자체를 변경(객체의 프로퍼티 변경)할 경우가 아니라, 그 내부의 프로퍼티를 변경(객체의 프로퍼티 변경)할 때 성립한다고 할 수 있습니다.

변수 복사 시 절차의 차이점

기본형

var a = 10;
var b = a;
b = 15;
a !== b; // 기본형 변수 복사의 결과는 다른 값

변수영역 주소 : @1001, 변수영역 데이터(값) : a/@5001
변수영역 주소 : @1003, 변수영역 데이터(값) : b/@5001(a가 가진 주소) →변경됨→ b/@5003
데이터영역 주소 : @5001 ,데이터영역 데이터(값) : 10
데이터영역 주소 : @5003 ,데이터영역 데이터(값) : 15 →새로운 데이터

  • 숫자 15라는 값을 데이터 영역에서 검색 후 없다면 생성
  • b의 데이터 주소를 새로 생성한 데이터 주소로 갈아끼움(b/@5001(a가 가진 주소) →변경됨→ b/@5003)
  • a와 b는 서로 다른 데이터 영역의 주소를 바라보고 있기 때문에 영향 없음

참조형

var obj1 = { c: 10, d: "ddd" };
var obj2 = obj1;
obj2.c = 20;
obj1 === obj2; // 참조형 변수 복사의 결과는 같은 값(원하지 않는 결과)

변수영역 주소 : @1002, 변수영역 데이터(값) : obj1/@7103~
변수영역 주소 : @1004, 변수영역 데이터(값) : obj2/@7103~(obj1이 가진 주소) →주소는 그대로지만 값이 변경됨
데이터영역 주소 : @5001 ,데이터영역 데이터(값) : 10
데이터영역 주소 : @5002 ,데이터영역 데이터(값) : 'ddd'
데이터영역 주소 : @5004 ,데이터영역 데이터(값) : 20 →새로운 데이터
obj1영역 주소 : @7103, obj1영역 데이터(값) : c/@5001 →변경됨→ c/@5004
obj1영역 주소 : @7104, obj1영역 데이터(값) : d/@5002

  • 숫자 20이라는 값을 데이터 영역에서 검색 후 없다면 생성
  • obj1 데이터의 주소를 새로 생성한 데이터 주소로 갈아끼움(c/@5001 →변경됨→ c/@5004)
  • obj1도 똑같은 주소를 바라보고 있기 때문에 obj1까지 변경이 됩니다.
    그렇기 때문에 가변하다는 것은 위험합니다.

◼ 불변 객체

객체의 속성에 접근해서 값을 변경하면 가변이 성립됩니다.
반면, 객체 데이터 자체를 변경(새로운 데이터를 할당)하면 기존 데이터는 변경되지 않습니다. 즉, 불변합니다.

1. 불변 객체의 필요성

객체의 가변성에 따른 문제점

// user 객체를 생성
var user = {
    name: "wonjang",
    gender: "male",
};

// 이름을 변경하는 함수, 'changeName'을 정의
// 입력값 : 변경대상 user 객체, 변경하고자 하는 이름
// 출력값 : 새로운 user 객체
// 특징 : 객체의 프로퍼티(속성)에 접근해서 이름을 변경 -> 가변
var changeName = function (user, newName) {
    var newUser = user;
    newUser.name = newName;
    return newUser;
};

// 변경한 user정보를 user2 변수에 할당
// 가변이기 때문에 user1도 영향 있음
var user2 = changeName(user, "twojang");

// 결국 아래 로직 skip
if (user !== user2) {
    console.log("유저 정보가 변경되었습니다.");
}

console.log(user.name, user2.name); // twojang twojang
console.log(user === user2); // true

개선 방법

// user 객체를 생성
var user = {
    name: "wonjang",
    gender: "male",
};

// 이름을 변경하는 함수 정의
// 입력값 : 변경대상 user 객체, 변경하고자 하는 이름
// 출력값 : 새로운 user 객체
// 특징 : 객체의 프로퍼티에 접근하는 것이 아니라, 아에 새로운 객체를 반환 -> 불변
var changeName = function (user, newName) {
    return {
        name: newName,
        gender: user.gender,
    };
};

// 변경한 user정보를 user2 변수에 할당
// 불변이기 때문에 user1은 영향 없음
var user2 = changeName(user, "twojang");

// 결국 아래 로직 수행
if (user !== user2) {
    console.log("유저 정보가 변경되었습니다.");
}

console.log(user.name, user2.name); // wonjang twojang
console.log(user === user2); // false

개선한 방법이지만 이 방법에도 문제점이 있습니다.
changeName 함수는 새로운 객체를 만들기 위해 변경할 필요가 없는 gender 프로퍼티를 하드코딩으로 입력했습니다. 만일 이러한 속성이 10개라면 10개를 다 하드코딩으로 입력해야 합니다.

2. 얕은 복사(Shallow Copy)

더 나은 개선 방법으로 얕은 복사가 있습니다.

var shallowCopy = function (target) {
    var result = {};

    // for ~ in 구문을 이용하여, 객체의 모든 프로퍼티에 접근
    // 이 shallowCopy로 복사를 한 다음, 복사를 완료한 객체의 프로퍼티를 변경
    for (var prop in target) {
        result[prop] = target[prop];
    }
    return result;
};

var user = {
    name: "wonjang",
    gender: "male",
};

var user2 = shallowCopy(user);
user2.name = "twojang";

if (user !== user2) {
    console.log("유저 정보가 변경되었습니다.");
}

console.log(user.name, user2.name);
console.log(user === user2);

문제점

얕은 복사에도 문제점이 있습니다.
중첩된 객체(객체 안에 있는 객체)에 대해서는 완벽한 복사를 할 수 없습니다. 중첩된 객체의 경우 참조형 데이터가 저장된 프로퍼티를 복사할 때, 주소값만 복사합니다.
이것이 얕은 복사의 한계입니다.

var user = {
    name: "wonjang",
    urls: {
        portfolio: "http://github.com/abc",
        blog: "http://blog.com",
        facebook: "http://facebook.com/abc",
    },
};

var user2 = shallowCopy(user);

user2.name = "twojang"; // 1depth인 name은 복사 가능

// 1depth인 얕은 단계에서는 불변성을 유지하기 때문에 값이 다름
console.log(user.name === user2.name); // false

// 더 깊은 단계에서는 불변성을 유지 못하기 때문에 값이 같음
user.urls.portfolio = "http://portfolio.com";
console.log(user.urls.portfolio === user2.urls.portfolio); // true

user2.urls.blog = "";
console.log(user.urls.blog === user2.urls.blog); // true

3. 깊은 복사(Deep Copy)

내부의 모든 값들을 하나하나 다 찾아서 모두 복사하는 방법
객체의 프로퍼티 중, 기본형 데이터는 그대로 복사 + 참조형 데이터는 다시 그 내부의 프로퍼티를 복사 → 재귀적 수행(함수나 알고리즘이 자기 자신을 호출하여 반복적으로 실행되는 것)을 해야 합니다.

var deepCopy = function (target) {
    var result = {};
    // 복사한 것이 object이면서 null이 아닌 경우(참조형인 경우)
    if (typeof target === "object" && target !== null) {
        // for ~ in 구문을 이용하여, 객체의 모든 프로퍼티에 접근
        for (var prop in target) {
            // 자기 자신을 수행(재귀적 수행)
            result[prop] = deepCopy(target[prop]);
        }
    } else {
        result = target;
    }
    return result;
};

var obj = {
    a: 1,
    b: {
        c: null,
        d: [1, 2],
    },
};
var obj2 = deepCopy(obj);

obj2.a = 3;
obj2.b.c = 4;
obj2.b.d[1] = 3;

console.log(obj.a === obj2.a); // false
console.log(obj.b.c === obj2.b.c); // false
console.log(obj.b.d[1] === obj2.b.d[1]); // false

4. JSON(JavaScript Object Notation)

불변성을 유지하는 또 다른 방법은 JSON을 이용하는 방법이 있습니다.

장점

  • JSON.stringify() 함수를 사용하여 객체를 문자열로 변환한 후, 다시 JSON.parse() 함수를 사용하여 새로운 객체를 생성하기 때문에, 원본 객체와 복사본 객체가 서로 독립적으로 존재합니다. 따라서 복사본 객체를 수정해도 원본 객체에 영향을 미치지 않습니다.
  • JSON을 이용한 깊은 복사는 다른 깊은 복사 방법에 비해 코드가 간결하고 쉽게 이해할 수 있습니다.

단점

  • JSON을 이용한 깊은 복사는 원본 객체가 가지고 있는 모든 정보를 복사하지 않습니다. 예를 들어, 함수나 undefined와 같은 속성 값은 복사되지 않습니다.
  • JSON.stringify() 함수는 순환 참조(Recursive Reference)를 지원하지 않습니다. 따라서 객체 안에 객체가 중첩되어 있는 경우, 이 방법으로는 복사할 수 없습니다.

결론

따라서 JSON을 이용한 깊은 복사는 객체의 구조가 간단하고, 함수나 undefined와 같은 속성 값이 없는 경우에 적합한 방법입니다. 만약 객체의 구조가 복잡하거나 순환 참조가 있는 경우에는 다른 깊은 복사 방법을 고려해야 합니다.


◼ undefined, null

1. undefined

개발자가 직접 지정할 수도 있지만 일반적으로는 자바스크립트 엔진에서 값이 있어야 할 것 같은데 없는 경우, 자동으로 부여합니다.

  1. 변수에 값이 지정되지 않은 경우, 데이터 영역의 메모리 주소를 지정하지 않은 식별자에 접근할 때
  2. .이나 []로 접근하려 할 때, 해당 데이터가 존재하지 않는 경우
  3. return 문이 없거나 호출되지 않는 함수의 실행 결과
var a;
console.log(a); // (1) 값을 대입하지 않은 변수에 접근

var obj = { a: 1 };
console.log(obj.a); // 1
console.log(obj.b); // (2) 존재하지 않는 property에 접근
// console.log(b); // 오류 발생

var func = function () {};
var c = func(); // (3) 반환 값이 없는 function
console.log(c); // undefined

2. null

"없다"를 명시적으로 표현할 때 사용합니다.

주의사항
console.log(typeof null)
typeof null이 object인 것은 유명한 javascript 자체 버그입니다. 주의해야합니다.

var n = null;
console.log(typeof n); // object

//동등연산자(equality operator)
console.log(n == undefined); // true
console.log(n == null); // true

//일치연산자(identity operator)
console.log(n === undefined); // false
console.log(n === null); // true

0개의 댓글