React를 사용하는 우리는 원본 데이터를 직접 수정해서는 안된다는 걸 알고있다.
습관적으로 데이터를 복사해 수정한다.
const [list, setList] = useState([1, 2, 3]);
const addItem = () => {
// list.push(4); // ❌ 이렇게 사용하지 않는다. 왜?
const newList = [...list, 4];
setList(newList);
};
state는 setState로 변경해야하고, 원본 데이터의 불변성을 해치면 안된다.
불변성을 지키지 않으면 React가 UI 업데이트를 하지 않기 때문이다.
그런데 React는 왜 업데이트를 안하는가?
왜 불변성을 해치면 안되는걸까?
React는 UI를 효율적으로 업데이트한다.
가상돔/실제돔과 비교해 변경된 부분만 효율적으로 업데이트해 불필요한 리랜더링을 방지한다.
React를 사용하지 않았다면 우리는 매번 상태값을 관리하고 업데이트해야 할 것이다.
그럼 React는 어떻게 효율적으로 업데이트를 할까?
React는 전달받은 속성의 "값이 아닌 참조가 변경될 때"에 변경을 감지해 컴포넌트를 리렌더링한다.
let count = 0
let user = {name:"john", level: 1}
// user.name = "sara"
// user.level = 2
<Component count={count} user={user} />
이때 전달하는 속성 count
, user
는 값 변경시 각각 다르게 작동한다.
count가 1이 된다면 참조가 변경된다.
하지만 user.level을 2로 바꾸거나, name이 "sara"로 바꾼다면?
값은 변경되지만 참조는 변경되지 않는다.
왜일까?
여기서 말하는 참조의 변경이란 무엇인가?
📍 [mdn 문서] JavaScript data types and data structures
📍 [자료구조] 원시타입(Primitive Type)과 참조타입(Reference Type) 게시글을 참고하자
자바스크립트를 처음 공부할 때 원시 타입(Primitive Type)과 참조 타입 (Reference Type)을 배웠을 것이다.
각 타입별로 선언 시 메모리에 어떻게 저장되는지 알아보자.
const a = 10
a를 선언하면 메모리에 값이 저장된다.
메모리 위치 | 값 | |
---|---|---|
0x1000 | 10 | 👈 a |
a =
0x1000
-> 0x1000 위치에 저장되고 변수 a는 해당 위치를 저장한다.
(C언어의 포인터와 비슷하지만 포인터는 아니다)
const list = [1,2,3,"string", false]
// JavaScript의 배열은 동적 타입이기 때문에 다양한 자료형을 혼합할 수 있다
메모리 주소 | 값 | |
---|---|---|
0x1000 | [0x2000, 0x2001, 0x2002, 0x2003, 0x2004] | 👈 list |
0x2000 | 1 | |
0x2001 | 2 | |
0x2002 | 3 | |
0x2003 | "string" | |
0x2004 | false |
list =
0x1000
list는 배열 자체를 가리키며,
배열 내부에는 각 요소의 메모리 주소들이 저장되어있다.
이처럼 배열은
배열 객체가 요소들의 메모리 주소를 저장한다.
(요소가 순서대로 저장된다)
조금 다른 경우를 살펴보자. 객체는 어떻게 저장될까?
const user = {
name: 'john',
age: 20,
level: 1,
}
메모리 주소 | 값 | |
---|---|---|
0x1000 | { name: 0x2000, age: 20, level: 1 } | 👈 user |
0x2000 | john |
user =
0x1000
객체는 속성의 값을 저장한다.
원시 값(string, number, boolean)일 경우에는 그 값 자체를 객체 내부에 직접 저장하지만
문자열은 특성상 별도의 메모리 공간에 되어 그 주소를 저장한다.
문자열을 따로 저장하는 이유?
자바스크립트에서 문자열은 길이가 가변적이다.
메모리에서 동적으로 할당되기 때문에 길이가 동적으로 변할 수 있기 때문.
참조(Reference)는 데이터 자체가 아니라, 데이터가 저장된 메모리 위치를 가리키는 값을 의미한다.
React는 이 참조가 변경된 경우에만 업데이트를 하는데, 어떤 경우에 참조가 변경될까?
원시 타입은 불변(Immutable)한 값이다.
값을 변경해도 값은 바뀌지 않고 참조 자체가 변경된다.
이게 무슨 말일까? 간단한 예제로 알아보자.
let a = 10
메모리 주소 | 값 | |
---|---|---|
0x1000 | 10 | 👈 a |
a =
0x1000
a는 0x1000
이라는 메모리 위치를 저장한다.
이제 값을 변경해보자.
a = 20
메모리 주소 | 값 | |
---|---|---|
0x1000 | 10 | (참조하지 않음) |
0x1001 | 20 | 👈 a |
a =
0x1000
->0x1001
같은 메모리 주소에서 값이 직접 변경되지 않는다.
원시 타입은 불변(immutable)하므로 값이 변경될 때 새로운 메모리 공간이 할당된다.
0x1000에 저장된 10은 Garbage Collector가 수거한다.
이렇게 원시타입의 변경은 참조를 변경한다.
const list = [1,2,3,4]
메모리 주소 | 값 | |
---|---|---|
0x1000 | [0x2000, 0x2001, 0x2002, 0x2003] | 👈 list |
0x2000 | 1 | |
0x2001 | 2 | |
0x2002 | 3 | |
0x2003 | 4 |
list의 값을 변경해서 참조를 변경해보자.
list.push(5)
list =
0x1000
기존 배열을 수정하는 것이므로 참조는 그대로 유지된다.
따라서 list가 가리키는 주소는 변경되지 않는다.
(내부적으로 메모리 관리를 수행하는 JS 엔진이 미리 할당한 메모리를 초과한다면 참조를 변경할 수도 있지만 이 동작은 예측하기 어렵다)
const list = [1,2,3,4]
const newList = list
const onClick = () => {
list.push(5)
}
메모리 주소 | 값 | |
---|---|---|
0x1000 | [0x2000, 0x2001, 0x2002, 0x2003, 0x2004] | 👈 list 👈newList |
0x2000 | 1 | |
0x2001 | 2 | |
0x2002 | 3 | |
0x2003 | 4 | |
0x2004 | 5 |
list =
0x1000
newList =0x1000
newList는 list가 저장한 메모리의 주소를 가진다.
newList의 참조는 list와 동일하다.
const list = [1,2,3,4]
const newList = []
const onClick = () => {
newList = [...list, 5] // [1,2,3,4,5]와 동일
}
메모리 주소 | 값 | |
---|---|---|
0x1000 | [0x2000, 0x2001, 0x2002, 0x2003] | 👈 list |
0x2000 | 1 | |
0x2001 | 2 | |
0x2002 | 3 | |
0x2003 | 4 |
메모리 주소 | 값 | |
---|---|---|
0x3000 | [0x4000, 0x4001, 0x4002, 0x4003, 0x4004] | 👈 newList |
0x4000 | 1 | |
0x4001 | 2 | |
0x4002 | 3 | |
0x4003 | 4 | |
0x4004 | 5 |
list =
0x1000
newList =0x3000
newList는 새로운 메모리 공간을 할당받아 생성된 배열을 가리킨다.
list의 값을 가져와서 새로운 위치에 저장했기 때문에 list와 newList는 전혀 다른 참조를 가지게 되었다.
React는 상태(State)가 변경되었을 때 컴포넌트를 다시 렌더링하여 화면을 업데이트한다.
그러나 상태가 변경되었어도 참조(Reference)가 바뀌지 않으면 React는 변경되지 않았다고 판단하고 리렌더링을 하지 않는다.
실제 예제로 확인해보자.
const Parent = () => {
const [list, setList] = useState([1, 2, 3]);
const [newItem, setNewItem] = useState(0);
const addItem = () => {
list.push(newItem); // ❌ 기존 배열을 직접 변경
setList(list); // ❌ 같은 배열을 그대로 전달 → React가 변경 감지 못함
};
return (
<input value={newItem} onChange={(e) => setNewItem(e.target.value)} />
<button onClick={addItem}>add Item</button>
<Child list={list} />
)
}
list.push를 해도 list 참조는 변하지 않는다.
React는 setList를 호출했지만 list의 참조(메모리 주소)가 바뀌지 않았으므로,
React는 상태가 변경되지 않았다고 판단하고 컴포넌트를 다시 렌더링하지 않는다.
즉, Child 컴포넌트의 list UI는 업데이트되지 않는다.
const Parent = () => {
const [list, setList] = useState([1, 2, 3]);
const [newItem, setNewItem] = useState(0);
const addItem = () => {
setList([...list, newItem]); // ✅ 새로운 배열을 만들어 전달
};
return (
<>
<input value={newItem} onChange={(e) => setNewItem(e.target.value)} />
<button onClick={addItem}>Add Item</button>
<Child list={list} />
</>
);
};
[...list, newItem]
을 사용해 새로운 배열을 만들었다.
새로운 배열은 새로운 메모리 공간에 저장되고, 따라서 새로운 참조가 생긴다.
setList에 새로운 참조를 가진 배열을 전달했기 때문에 React가 변경을 감지하고 리렌더링을 트리거한다.
이제 Child 컴포넌트가 업데이트된 list를 받아 UI를 업데이트한다.
React 없이 JavaScript로 UI를 직접 관리한다면, 상태 변경 시 DOM을 수동으로 업데이트해야 한다.
let list = [1, 2, 3];
function addItem(newItem) {
list.push(newItem); // 배열에 새로운 값 추가
renderList(); // 수동으로 UI 업데이트
}
function renderList() {
const ul = document.getElementById("list");
ul.innerHTML = ""; // 기존 리스트 초기화
list.forEach((item) => {
const li = document.createElement("li");
li.textContent = item;
ul.appendChild(li);
});
}
addItem()이 호출될 때마다 renderList()를 직접 호출해야 한다.
React는 변경된 부분만 업데이트하지만, 위 코드는 리스트 전체를 다시 그린다.
이런 식으로 상태 관리와 UI 업데이트를 모두 수동으로 처리해줘야할 것이다.
그래서 우리는 상태관리와 업데이트를 대신 처리해주는 React를 사용한다.
React는 상태가 변경되었을 때 자동으로 UI를 업데이트해주므로 개발자가 직접 DOM을 조작할 필요가 없다.
즉, "상태를 변경하면 UI가 알아서 업데이트된다"는 원칙이 유지된다.
이를 가능하게 하는 것이 Virtual DOM과 참조 비교(Reference Comparison)이다.
따라서 React에서는 상태를 변경할 때 불변성을 유지하면서 새로운 참조를 만들어 주는 것이 중요하다.