1장에서는 기본형과 참조형으로 분류하여 간단히 자바스크립트에서 사용하는 데이터 타입에 대해서 배우게 됩니다. 또한, 기본형과 참조형 데이터의 차이점을 알아보고 왜 데이터의 불변성을 유지하는 것이 필요한 지 이유를 살펴봅니다.
자바스크립트는 데이터를 기본형과 참조형으로 구분합니다.
기본형 | 참조형 |
---|---|
number | object |
string | array |
boolean | function |
null | date |
undefined | RegExp |
symbol | map, weakMap, set, weakSet |
기본형과 참조형 데이터의 차이는 다음과 같습니다.
기본형
: 값을 저장한 공간의 주소값을 참조한다.참조형
: 데이터 그룹의 주솟값을 참조한다. 데이터 그룹의 각 요소들은 값을 저장한 공간의 주솟값을 참조한다.즉, 주솟값을 참조한다는 점에서는 동일하나, 값을 바로 참조하고 있는지, 데이터 그룹이라는 단계를 하나 더 가지고 있는 지에 따라서 두 데이터 타입을 구분할 수 있고, 이런 점은 값의 변경, 할당, 복사, 비교 동작 방식에 차이를 만들어 냅니다.
두 데이터 타입의 차이를 살펴보기 전에, 이해를 돕기 위해서 배경 지식을 채우고 가도록 하겠습니다.
식별자는 어떤 데이터를 식별하는 데 사용하는 이름으로 변수명이라고도 합니다. 변수는 식별자가 식별하는 것, 즉 데이터 자체를 의미합니다. 숫자도 데이터고, 문자열, 객체, 배열 모두 데이터입니다.
식별자
: 변수 이름. 값을 저장하기 위해 확보한 공간에 붙여진 이름변수
: 데이터, 값//식별자 = a, 변수 = "abc"
var a = "abc";
자바스크립트에서는 식별자를 통해 이름이 붙여진 공간과 실제 변수값이 저장되는 공간이 분리되어 있습니다. 이를 간단히 “변수영역”, “데이터 영역”이라고 구분짓겠습니다. 데이터 영역에서는 실제 변수값이 저장되고 변수 영역에서는 값이 저장된 데이터 영역의 주솟값이 저장됩니다.
기본형 변수의 선언과 데이터 할당이 어떻게 이뤄지는지 살펴보겠습니다.
var a = "abc";
1003
주소지 공간을 확보하고 식별자(이름)을 a
라고 한다.5004
주소지 공간을 확보하고 값 abc
을 저장한다.1003
에 앞서 저장한 데이터 영역의 주소지 5004
를 저장한다.참조형 즉, 객체, 배열의 경우 변수 선언과 데이터 할당은 어떻게 되는 지 살펴보면 다음과 같습니다.
var obj = {
a: 1,
b: "bbb"
}
1003
을 확보하고 obj
라는 이름을 지정한다.a
, b
)에 대한 따로 변수 영역 7103
과 7014
를 확보하고 obj
에 대한 데이터 영역 5001
에 해당 주소값을 저장한다.a
, b
에 저장할 값에 대해서 별도의 데이터 영역(5004
, 5005
)을 확보한 다음 1
과 bbb
를 저장한다.7103
과 7014
에 5004
와 5005
를 저장한다.이렇게 변수 영역과 데이터 영역으로 나누어서 값을 저장함으로써 2가지 이점을 얻을 수 있습니다.
데이터 변환이 자유롭습니다.
자바스크립트의 경우, 숫자형은 8바이트이지만 문자열은 특별한 제약이 없습니다. 만약 주소가 아닌 값 자체를 저장하게 된다면 문자열의 내용이 바뀔 때마다 메모리 상에서 변환된 데이터에 맞게 공간을 다시 확보해야 하고 이에 맞춰서 모든 데이터의 주소를 다시 재정렬해야 합니다.
데이터 영역을 따로 나누어 관리함으로써, 값이 변경될 때 새로 데이터 영역을 할당하고, 변수 영역에서는 주소값만 교체하는 것으로 데이터 변환을 할 수 있습니다.
메모리 관리를 효율적으로 할 수 있습니다.
500개의 변수를 생성하고 모두 5
라는 값으로 초기화할 때, 500개의 값에 대한 메모리 공간을 가지고 있는 것보다 동일한 데이터에 대해 같은 주소값을 참조하게 하는 것이 훨씬 효율적입니다.
기본형과 참조형의 차이를 더 확실히 비교하기 위해서 변수 생성 및 복사 이후에 복사된 데이터의 변경이 될 때 어떻게 이뤄지는지 살펴보겠습니다.
var a = 10;
var b = a;
var obj1 = {c: 10, d: "ddd"};
var obj2 = obj1;
우선 기본형 데이터a
의 메모리 할당 과정은 이전과 동일합니다. 따라서 변수 b
에 대해서 복사 과정을 살펴보면 다음과 같습니다.
1002
확보 및 b
라고 식별자 지정a
라는 이름의 변수 검색 및 저장하고 있는 값 @5001
을 저장.참조형 데이터obj1
의 할당 과정도 이전과 동일합니다. 다음은 obj2
의 복사 과정입니다.
1004
를 확보 및 obj2
라고 식별자 지정obj1
이라는 변수 이름 검색 및 저장하고 있는 값 @5002
저장. 그렇다면, 복사한 데이터의 값 즉, b
와 obj2
의 값을 변경하게 되었을 때 각각 참조하고 있는 주소값은 어떻게 다라질까요? 다음의 코드를 실행한다고 했을 때 메모리는 다음과 같습니다.
b = 15;
obj2.c = 20;
기본형 데이터인 변수 b
의 경우에는 5001
번지의 10
을 15
로 변경할 수 없기 때문에(불변성) 별도의 공간에 15를 할당한 후, 5004
번지 주소값을 새로 저장하게 됩니다.
obj2
의 경우에는 데이터 그룹의 변수 c
가 가리키는 주소값은 5005
로 바뀌었으나, 궁극적으로 obj2
가 가리키는5002
번지에서의 참조값은 기존의 @7103~
로 변경되지 않았습니다. 즉, 별도의 변수 영역이라는 단계를 하나 더 갖기 때문에 데이터 영역의 값이 변경되지 않았음에도(@7103~
) 이전과 다른 값인 20
으로 바꿀 수 있는 것입니다. 이를 가변성
이라고 합니다.
데이터가 불변한가, 불변하지 않은가(가변성)의 기준이 되는 것은 데이터 영역에서 저장하고 있는 값을 변경할 수 있는지에 대한 여부입니다.
앞서 기본형값을 저장하고 있는 b
의 경우, 저장한 값을 변경할 때는 데이터 영역을 가리키는 메모리 주소값에 변경이 일어납니다. 이는 기존에 저장하고 있던 값이 10에서 20으로 변경하는 것이 아니라 새로운 영역을 할당해 20을 저장하기 때문입니다. 즉 기존값은 변경이 불가능하기 때문에 다른 메모리 영역에 할당해서 새로운 주소값을 저장하는 것입니다.
객체 obj2
의 경우, 가리키고 있는 데이터 영역 5002번에 대해서는 전혀 변경이 없지만, c
프로퍼티의 값이 15에서 20로 바뀐 것을 알 수 있습니다. 그 내부의 c
의 값은 기본형으로 불변하다고 할 수 있지만, 객체를 저장하고 있는 obj
의 경우 기존 데이터 영역의 변화 없이 값이 변경되었음으로 불변하지 않다고 말할 수 있습니다(가변성)
기존의 객체와 같은 참조형 데이터는 값을 복사하더라도 참조하는 값이 동일하기 때문에 내부 프로퍼티의 변화를 잘 감지하기 힘들다거나, 복사된 객체에서의 변경이 원본 객체에도 영향을 미치는 등의 문제점이 있습니다.
만약, 원본 데이터는 값이 변경되지 않도록 두고(불변 객체), 내부 프로퍼티의 변경이 필요할 때는 새로운 객체를 만들어서 할당하게 한다면, 가변성으로 인한 여러 문제를 해결할 수 있습니다.
var obj2 = {...obj1, c: 20};
불변 객체
란, 생성 후 값을 변경할 수 없는 객체를 의미하는데, 불변 객체가 필요한 경우는 다음과 같습니다.
불변성을 지킴으로써 얻는 이점은 다음과 같습니다.
React의 경우, 상태 변화를 감지할 때 변경 전, 후의 props의 업데이트를 얕은 비교
를 통해서 감지합니다. 즉, 객체 속성 하나하나를 비교한는 것이 아니라 각 객체의 참조값(주소값)만을 비교하여 상태 변화를 감지하는 것입니다.
Todo 프로젝트를 진행한다고 해봅시다.
/*
type todo = {
id: string,
label: string
}
*/
// todoHome.js
onUpdate(todoId, label) {
this.todos.update(todoId, label);
}
// todos.js
update(todoId, label) {
var todo = this._todos.find((todo) => todo.id === todoId);
todo.update(label);
this.emit('update');
}
shouldComponentUpdate
는 컴포넌트가 리렌더링이 될지 여부를 판단하기 위해 호출되는 함수로, 새로 props나 state가 전달 혹은 변경될 때 호출됩니다. (class Component에서 구현 가능)
만약, todo
라는 prop을 받는 컴포넌트가 있다고 할 때, 새로운 todo
로 변경될 때마다 리렌더링이 이뤄져야 하기 때문에 하단의 코드와 같이 작성할 수 있습니다. 하지만, nextProps의 todo
와 현재 props의 todo
가 같은 참조를 하고 있면 해당 조건문은 변경의 유무와 상관없이 항상 거짓을 반환하고 리렌더링은 스킵되게 됩니다.
//todoItem.js
shouldComponentUpdate(nextProps, nextState) {
return (
nextProps.todo !== this.props.todo
);
}
따라서, todo
객체의 불변성을 확보해서 상태 변화를 감지할 수 있게 바꿔줘야 하고, 이를 익숙히 사용하는 함수형 컴포넌트에 대입해보면 간단히 다음과 같이 생각해볼 수 있습니다.
export default function App() {
const [todos, setTodos] = useState([
{ label: "a" },
{ label: "b" },
{ label: "c" }
]);
const handleButtonClickUpdate = () => {
const copy = todos;
//가변성
copy[0].label = "changed";
setTodos(copy);
//불변성
setTodos([{ label: "changed" }, ...copy.slice(1)]);
};
return (
<div className="App">
<button onClick={handleButtonClickUpdate}>click</button>
{todos.map((todo, i) => (
<TodoItem key={i} todo={todo} />
))}
</div>
);
}
undefined
와 null
은 자바스크립트에서 모두 없음
을 나타내는 값입니다. undefined
의 경우 자바스크립트 엔진에서 특정한 경우에 자동으로 반환해주는 값이기도 함으로 코드 구현에 있어 의미를 명확히 구분할 수 있도록 되도록이면 null
을 사용하는 것을 권장합니다.
undefined
가 반환되는 경우는 다음과 같습니다
즉, 값이 비어있음을 명시적으로 표현하기 위해 null
을 쓰게 되면, 자바스크립트 엔진에서 반환하는 undefined
에 대한 의미와 상황(ex) 값을 대입하지 않은 변수에 접근한 상황)들에 대해 명확히 구분지을 수 있습니다.