원시 타입과 참조 타입
JavaScript의 데이터 타입은 원시 타입(Primitive Type)과 참조 타입(Reference Type)으로 구분된다. 원시 타입에는 문자열(String), 숫자(Number), 불리언(Boolean; 논리 연산의 결과를 True 또는 False로 표현), Undefined(값이 할당되지 않은 변수에 엔진이 자동으로 부여하는 값), Null(명시적으로 값이 없다는 것을 나타냄), 심볼(Symbol; ES6에서 추가된 타입으로, 보통 객체의 프로퍼티 키를 고유하게 생성하기 위해 사용)이 있고, 참조 타입에는 객체(Object)가 있다.
배열 등도 참조형 데이터 타입으로 생각될 수 있다. 실제로 특정 프로그래밍 언어에서 배열은 별도의 데이터 타입으로 분류된다. 하지만, JavaScript에서의 참조 타입은 객체가 유일하다. 즉, JavaScript에선 배열도 객체다(배열 뿐만 아니라, 원시 타입을 제외한 모든 데이터는 객체다). 아래 코드처럼 Array 생성자로 만든 배열의 값을 console.log()
로 확인해 보면 프로토타입에 Object
가 포함돼 있는 게 확인된다(변수에 배열인 값을 직접 할당해도 동일한 결과가 나온다).
const myArr = new Array(1, 2, 3); // Array 생성자로 배열 생성
console.log(myArr);
배열의 프로토타입은 객체의 프로토타입을 상속받기 때문에(프로토타입 체인을 통해 연결된다) 객체의 메소드를메서드를 활용할 수 있다. 즉, 배열은 객체에 일부 별도의 속성과 메서드를 추가한 것이고, 크게 보면 객체라고도 할 수 있다. 그리고, JavaScript의 거의 대부분의 데이터는 마찬가지로 Object.prototpye
을 상속받는다(JavaScript의 데이터 거의 대부분은 객체다).
원시 타입과 참조 타입의 차이
원시 타입과 참조 타입의 데이터에는 몇 가지 차이점이 있다.
메모리 관리 방식
자바스크립트는 메모리를 스택(Stack)과 힙(Heap), 두 개의 영역에 나눠서 관리한다. 스택과 힙 둘 다 결국은 메모리 자원이지만, 상황에 맞게 서로 다른 자료구조를 적용하는 '개념 상의 구분'이다. 스택과 힙의 영어 단어 의미를 보면 두 공간의 차이를 대충 짐작할 수 있다. 스택은 '쌓다'라는 의미에 맞게, 메모리에 데이터를 차곡차곡 쌓는다. 힙은 '더미'라는 의미대로, 메모리에 순서 상관없이 필요한 크기만큼의 공간을 더미로 할당한다.
스택은 '순서대로' 데이터 타입에 맞게 필요한 크기의 공간을 할당하고(데이터를 쌓고), 만약 이전 순서(먼저 점유된 메모리 주소)에 점유된 메모리 공간의 크기가 다음 순서(다음 점유된 메모리 주소)에 점유된 메모리 공간을 넘어설 경우 에러가 발생한다(이름하여 Stack Overflow). 반면, 힙은 순서의 개념이 없기 때문에, 뒷 순서에 저장된 데이터의 메모리 영역을 침범하지 않아서 유연하게 메모리 크기를 변경할 수 있다(엄청 많은 데이터가 힙 영역의 메모리 공간을 점유하고, 자원을 반환하지 않으면, 결과적으론 Heap Overflow가 발생할 수도 있다. JavaScript는 엔진이 자체적으로 스택과 힙에서 사용되지 않는 데이터들을 Garbage Collecting 하기 때문에 스택과 힙 영역 모두에서 그런 상황이 잘 발생하지 않긴 하다).
힙이 조금 더 유연하게 메모리 공간을 사용할 수 있기 때문에 자유도가 높지만, 스택에 비해 메모리를 효율적으로 사용하긴 어렵다. 반대로 스택은 좀 더 효율적인 메모리 관리를 할 수 있지만, 동적으로 유연하게 할당된 메모리의 크기를 변경하는 게 어렵기 때문에 자유도가 낮다. JavaScript 엔진은 두 방식의 장단점을 절충하기 위해 메모리 관리에 두 가지 방식을 모두 활용한다.
원시 타입과 참조 타입은 데이터를 메모리에서 관리할 때 스택과 힙을 활용하는 방식에 차이가 있다. 먼저, 변수에 값이 선언되고 할당되면 값의 데이터 타입을 평가하여 변수가 가리키는 스택 메모리의 주소(변수는 데이터가 저장된 메모리의 시작 주소를 가리키는 식별자다)에 해당 데이터 타입을 저장하기 위한 메모리 공간을 확보한다(정적 타입 언어는 변수를 선언할 때 데이터 타입을 결정하지만, 동적 타입 언어인 JavaScript는 변수가 선언될 때가 아닌 값이 할당될 때 해당 값의 종류에 따라 데이터 타입이 결정된다).
만약에 할당된 값이 원시 타입이라면, 값은 스택 영역에 아래와 같이 바로 저장된다.
let myNum1 = 10;
let myNum2 = 20;
JavaScript에서 숫자 타입 데이터는 실제론 총 64비트(8바이트)의 메모리 공간을 할당받지만, 위의 예시 이미지는 추상화된 개념을 설명하기 위해 4개 바이트만을 점유한 것으로 표현했다. 아무튼, 중요한 것은 원시 타입은 스택에 직접 할당된 값을 저장하고, 변수로 스택의 메모리 주소를 바로 접근해서 해당 공간에 저장된 데이터를 활용한다는 것이다.
이와 다르게, 참조 타입은 실제 값은 힙 영역에 필요한 크기의 더미 공간을 할당해 저장하고, 변수가 가리키는 스택 영역의 메모리 주소엔 힙 영역에 데이터가 저장된 공간의 주소를 참조한다. 참조 타입은 값을 스택 메모리에 직접 담지 않고 힙 영역에 저장한 후 경로를 '참조'한다는 특징이 있고, 이 부분이 원시 타입과 참조 타입의 차이를 만드는 중요한 부분이다.
let myObj1 = {
name: 'Peter',
age: 24,
city: 'Seoul'
}
let myObj2 = {
name: 'Sam',
hobby: 'Coding'
}
불변성과 가변성
JavaScript의 원시 타입으로 할당된 값은 불변(Immutable)한다. 즉, 최초에 선언된 변수에 할당된 값은 절대로 바뀌지 않는다는 뜻이다. 이게 '한번 값이 할당된 변수의 값이 변할 수 없다'는 것은 아니다(변수는 이름의 의미 자체가 '변하는 수'이기 때문에, 저장하는 값은 변할 수 있는 게 맞다).
let myNum = 10; // 최초 선언 및 할당
console.log(myNum); // 10
myNum = 20; // 다른 값을 재할당
console.log(myNum) // 20, 할당된 값이 바뀜
불변성의 진짜 의미는 '한번 점유된 메모리 공간에 저장된 값은 변하지 않는다'이다. 위에 예시의 myNum
도 처음 점유된 메모리 공간의 값이 10
에서 20
으로 바뀐 게 아니라, 재할당을 할 때 메모리의 다른 주소 공간을 필요한 만큼 새로 점유해 값을 다시 저장한다.
myNum
에 값을 재할당하면 새롭게 저장할 값을 스택의 별도 메모리 공간에 저장하고, 변수가 참조하는 메모리 주소도 바뀐다. 그리고, 최초에 점유하여 숫자 타입 데이터 10
을 저장했던 공간은 어떠한 곳에서도 사용되지 않게 되기 때문에 Garbage Collecting의 대상이 된다(JavaScript는 엔진이 메모리 관리를 직접 하고, 특정 주기마다 Garbage Collector를 동작시켜 사용되지 않고 있는 메모리 공간의 점유를 해제한다). 위의 예시를 살펴봤을 때, let myNum
에 담긴 값에는 변화가 있지만, 최초에 할당됐던 원시 타입 값인 10
은 불변하였다. 한번 메모리에 저장된 원시 타입 값은 변경이 불가능하고 고유하다.
이러한 원시 타입의 불변하는 특징 때문에 유사 배열인 문자열(String)은 인덱싱을 통해 값을 변경하는 게 불가하다. 문자열(Stirng
)은 유사 배열로, 프로퍼티에 length
가 포함되고, 인덱싱을 통해 값에 접근하는 게 가능하다.
하지만, 배열에서는 가능한 인덱싱을 통한 값 변경은 불가능하다. 배열은 참조 타입이라 값이 가변적이지만, 원시 타입인 문자열은 최초 할당된 값의 변경이 불가하다.
let myStr = 'Hello';
console.log(myStr[1]); // 'e', 배열처럼 인덱싱이 가능
console.log(myStr.slice(0, 4)); // 'Hell', 배열 메소드인 slice도 사용 가능
myStr[1] = 'a' // 에러가 발생하진 않음
console.log(myStr) // 1번 엔덱스 값을 변경해서 'Hallo'가 나오길 기대하지만, 결과는 여전히 'Hello'
myStr
에 할당된 값 Hello
는 타입이 문자열이고, 문자열은 원시 타입이기 때문에 불변성을 갖는다. 즉, 한번 스택 메모리의 지정된 주소 공간에 저장된 값은 변경할 수 없고, 배열 인덱싱 형태로 값을 지정해 변경하려고 해도 결과적으로 값이 바뀌진 않는다. 때문에, 위의 예시에서도 최종 결과는 Hallo
가 아니라 Hello
다.
반면, 참조 타입의 값은 가변적이다. 이는 변수가 가리키는 메모리 주소 공간에 데이터가 저장된 힙 영역의 주소가 저장되고, 해당 참조는 바뀌지 않기 때문이다. 해당 내용은 브라우저 콘솔로 직접 확인해보겠다(브라우저 개발자 도구에 'Memory' 탭을 이용하면 메모리 정보를 확인할 수 있다).
function Person(name, hobby) {
this.name = name;
this.hobby = hobby;
}
let Sam = new Person('Sam', 'Coding); // 생성자 함수를 사용해 Person을 상속받는 객체 생성
위와 같이 실행하면 힙 영역에 name
과 hobby
에 대한 정보가 담긴 객체 Sam
이 생성되고, 스택엔 관련 정보가 담긴 힙의 메모리 주소가 저장된다.
Person
프로토타입을 상속받은 Sam
객체의 스택 메모리 주소는 @99771
로 확인되고, 프로퍼티인 name
의 정보는 힙 메모리 주소 @15685
에, hobby
의 정보는 힙 메모리 주소 @15393
에 저장돼있다. 해당 객체에서 hobby
프로퍼티의 값을 soccer
로 바꿔보자.
Sam.hobby = 'soccer';
console.log(Sam); // Person {name: 'Sam', hobby: 'soccer'}
hobby
의 값이 soccer
로 변경됐고, 프로퍼티 hobby
의 값이 저장된 메모리의 주소도 @15393
에서 @142935
로 변경됐다(객체의 프로퍼티를 변수명이라고 하면, 값은 원시 타입인 문자열로 생성된 셈이다. 원시 타입인 데이터는 값이 불변하는 속성이 있기 때문에, 프로퍼티 hobby
가 가리키는 메모리의 주소를 변경하고 새로운 공간에 재할당한 데이터를 저장하였다). 반면, Sam
객체의 스택 메모리 주소는 동일하게 @99771
로 유지되었다.
스택을 기준으로 봤을 때, 스택 메모리에 저장된 값(힙 영역의 메모리 주소)은 변경되지 않았지만, 저장된 값은 변경됐다. 이는 참조 타입 데이터가 갖는 '가변성'이라는 특징이다. 기본적으로 스택 메모리에 한번 저장된 값은 변하지 않았지만(이 부분에 있어선 원시 타입과 동일하다), 참조된 값이 변경될 수 있기 때문에, 참조 타입인 객체는 가변적인 것으로 본다.
데이터 복제 방식
값이 할당된 변수를 복제하는 상황을 살펴보자. 먼저 원시 타입인 문자열 데이터를 복제해보겠다.
let myStr1 = 'Hello';
let myStr2 = myStr1; // myStr1의 값으로 myStr2에 복제
console.log(myStr1, myStr2) // Hello Hello
복제한 myStr2
는 myStr1
과 같은 값인 Hello
를 갖는다. 이때, myStr2
에 복제된 것은 myStr1
에 할당된 값 Hello
가 아니라 myStr1
이 가리키고 있는 메모리 주소다. 예를 들어서, myStr1
이 메모리 주소 0x00000000
을 가리키고 있고, 해당 주소에 할당된 값 Hello
가 저장된 거라면, myStr2
는 별도의 메모리 공간을 할당해서 값인 Hello
를 복제해 오는 게 아니라 메모리 주소 0x00000000
을 복제해 온다. 즉, myStr1
과 myStr2
는 완전히 같은 값을 향하고 있다.
해당 상황에서 myStr2
에 새로운 값을 할당하면, 해당 시점에서 메모리에 새로운 공간이 점유되고 값이 저장된다(원시 타입은 불변성을 가지고 있고, 한번 점유된 메모리에 할당된 값이 변경되지 않는다.
myStr2 = 'Hi';
console.log(myStr1, myStr2) // Hello Hi
JavaScript 엔진의 설계에 따라서 복제되는 변수가 선언 및 최초 할당될 시 다른 메모리 공간을 확보하고, 스택 메모리에 할당된 원시 값 자체를 복사해 오는 경우도 있다. 그러한 상황을 예로 들면, 위의 경우 myStr2를 선언하면서 myStr1을 할당하는 시점에 바로 별도 메모리 공간을 확보하고, 해당 메모리 공간에 'Hello'라는 문자열을 저장하게 된다.
이러한 방식은 완벽하게 동일한 값을 서로 다른 메모리 공간에 저장하기 때문에 메모리 공간의 효율이 나쁘다는 단점이 있다. 하지만, 결과론적으론 원시 타입 값이 할당된 변수를 복제한 변수가 다른 값을 갖게 됐을 때 별도 메모리 공간이 할당되어 데이터가 저장되고, 원시 타입의 '불변성'은 지켜진다는 점에서, 방식의 차이일 뿐이지 결론 상 동일한 동작을 하는 셈이다. 중요한 것은 어떤 방식이든 '원시 타입의 불변성이 지켜진다'는 점이다.
실제로 객체의 프로퍼티에 원시 타입의 값을 저장한 이후 복제를 해보면 두 프로퍼티는 같은 주소를 가리키긴 한다(변수에 직접 할당과 객체 프로퍼티로 접근 상황에 따라 차이가 존재할 수 있긴 하지만, 알고 있기론 변수와 프로퍼티는 거의 유사한 동작을 한다. 그래서, 원시 타입 값을 변수에 담아 복제할 때에도 비슷하게 동작하지 않을까 추측한다).
// Person 생성자 함수
function Person(name) {
this.name = name
}
let person1 = new Person('Jay');
let person2 = new Person();
person2.name = person1.name;
// person1.name과 person2.name은 같은 메모리 주소를 가리킨다
console.log(person1.name, person2.name) // Jay Jay
원시 타입은 '불변성'이라는 특징 때문에 다른 변수에 데이터를 복제할 때 전혀 문제가 되지 않는다. 하지만, 참조 타입의 데이터는 변수 간 데이터를 복제할 때 다른 방식으로 동작하게 된다. 일단, 위와 같은 방식으로 참조 타입 데이터를 복제해 보자.
let myObj1 = {
name: 'Sam'
}
let myObj2 = myObj1; // 참조 타입 데이터 복제
console.log(myObj1, myObj2); // {name: 'Sam'} {name: 'Sam'}
복제를 하니 같은 값이 나오는 게 잘 확인된다. 참조 타입 복제도 원시 타입 복제와 마찬가지로 값이 아닌 가리키는 메모리 주소를 복제한다.
해당 상황에서 myObj2
의 값을 변경해 보면, 예상과 다르게 myObj1
의 값도 함께 바뀐 게 확인된다. 만약에 두 변수의 값을 모두 바꾸는 게 의도였다면 문제가 되지 않지만, myObj2
만 값을 변경하고 싶었던 거라면 의도와 다르게 동작한 것이 된다.
myObj2.name = 'Tom';
console.log(myObj1, myObj2) // {name: 'Tom'} {name: 'Tom'}
참조 타입은 값을 변경할 때 스택의 별도 메모리 영역을 확보하여 새로운 값을 저장하는 게 아니라, 원래 참조돼 있던 0x00000000
으로 접근하고, 해당 위치에 저장된 힙 메모리 주소를 타고 가서 해당 위치에 있는 값을 변경한 것이다. 참조 타입 데이터의 가변적인 특성으로 인해 이러한 동작이 생긴 것이다.
이러한 특성 때문에, 참조 타입의 데이터를 복제할 땐 좀 더 복잡한 방식을 사용해야 한다. 흔히 '깊은 복사(Deep Copy)'라고 하는 방식이다. 위의 방식처럼 단순히 스택 메모리 주소 참조를 복제하는 방식을 '얕은 복사(Shallow Copy)'라고 한다면, 깊은 복사는 복제하려는 값이 담긴 기존 변수의 경로가 아닌 실제 값을 복제하여 다른 메모리 주소에 해당 값을 할당하도록 만드는 방식이다. 깊은 복사를 처리하는 방식에는 여러 접근이 있으며, 해당 내용은 별도 포스트로 다뤄봐야겠다.
결론
원시 타입과 참조 타입 데이터의 특성을 정확하게 이해하고 사용하자. 원시 타입의 값은 불변이고, 참조 타입은 복제를 할 시 '깊은 복사'를 해야 문제없이 동작할 수 있다.
추가적으로, 이번 포스트를 쓰면서 두 가지 추가적인 궁금증이 생겼다.
- 문자열(
String
)이나 숫자(Number
)는 원시 타입이고, 객체가 아닌 타입인데, 사용 가능한 메서드가 있다. '메소드'라는 게 객체 내에 선언된 함수를 의미하는 걸로 알고 있는데, 객체가 아닌 데이터 타입에 사용 가능한 메소드가 있다는 게 이상한다. 이 부분에 대해 좀 더 알아보고 포스트로 남겨봐야겠다. - 깊은 복사를 구현하는 방식에도 여러 가지가 있는 걸로 알고 있다. 가능하면 직접 구현도 해보고, 실무에서 많이 쓰는 방식도 파악해서, 실제 개발을 할 때 적절하게 사용해야겠다.
공부를 하면 할수록 모르는 게 더 쌓여가는 느낌이다. 겸손하게 하나씩 차근차근 잘 알아가 봐야겠다.
'JavaScript' 카테고리의 다른 글
프로퍼티 톺아보기: 데이터 프로퍼티와 접근자 프로퍼티 (0) | 2024.10.17 |
---|---|
프로퍼티 톺아보기: 프로퍼티 vs 메서드, 프로퍼티 vs 변수 (1) | 2024.10.14 |
얕은 복사(Shallow Copy)와 깊은 복사(Deep Copy) (3) | 2024.10.11 |
원시 타입의 메서드(Method) 사용 (0) | 2024.10.10 |
var, let, const의 차이: var 사용을 지양해야 하는 이유 (1) | 2024.10.07 |