만약 객체나 배열인 변수인 state를 변경했음에도 state의 변경상태를 react가 감지하지 못했다면,java script 엔진의 변수 저장방식과 react의 불변성에 대해 제대로 이해를 못한 거다.
js 엔진은 3가지의 메모리 공간을 가진다.
code area
실행한 js 코드를 담는다.
call stack
실행 중인 함수를 추적하며 계산을 수행하고, 지역 변수를 저장한다. 변수들은 LIFO 형식으로 저장된다. 또한 원시 타입들이 이 곳에 저장된다.
Heap
참조 타입들이 할당되는 곳이다. 콜 스택과 달리, Heap의 메모리 할당은 랜덤하게 배치된다. 또한 메모리 누수를 방지하기 위해 JS 엔진의 메모리 관리자가 항상 관리한다.
원시타입 vs 참조타입
원시타입: Boolean, String, Number, null, undefined, Symbol
참조타입: Object, Array
원시타입은 콜스택의 value에 변수 값이 저장되지만 , 참조 타입의 경우 메모리 힙의 주소가 콜스택의 value에 저장됨 , 참조타입 변수의 값은 메모리의 value에 저장된다.
a. 변수 할당 | b. 변수값 변경 |
---|---|
원시타입의 변수는 변수값이 변경되면 기존 콜스택의 메모리 영역의 value를 변경하지 않고 (메모리 영역의 값을 변경할 수 없는 것을 "불변성"이라고 한다.) 새로운 메모리 영역에 변경된 변수값을 저장한다.
* 더 이상 참조되지 않는 데이터는 가비지 컬렉터에 의해 적절한 시점에 메모리에서 해제된다.
a.변수 할당 | b. 기존 변수 값 변경 |
---|---|
참조타입은 기존의 변수를 바꾸는 경우와 map, spread operator 등 기존의 변수를 변경한 새로운 변수를 반환하는 경우의 두가지로 나누어서 봐야한다.
우선 기존의 변수를 변경하는 경우를 살펴보면, 변수 값이 변경되면 콜스택의 변화는 없으며 메모리 힙의 value 값만 변경된다. 즉, 기존의 메모리 영역의 값이 변경되므로 불변성 유지가 되지 않는다.
기존의 변수를 변경한 새로운 변수를 반환하는 경우에는 새로운 메모리 영역이 생성되어서 불변성 유지가 된다.
원시타입( Boolean, String, Number, null, undefined, Symbol)의 변수들은 콜스택에 값이 저장되고 , value가 변경되면 콜스택에 변경된 값을 가지는 새로운 메모리 영역을 생성한다.
참조타입(Object, Array)의 변수의 값은 메모리 힙에 저장되고 콜스택에는 value 값이 저장된 메모리 힙의 주소가 저장된다. 따라서 변수의 값이 변경되면 메모리 힙의 값은 변경되지만 콜스택 값은 변경이 없다.
"불변성의 진짜 의미는 메모리 영역에서 값을 변경할 수 없다는 의미입니다."
리액트를 공부했다면 불변성을 지키는 것이 중요하다는 이야기를 많이 들었을 것이다.
근데 왜 불변성을 지켜야하는 걸까?
리액트는 콜스택의 주소값만을 비교하여 상태 변화를 감지한다.이를 "얕은 비교"라고 한다. 리액트의 빠른 state 변화 감지를 할 수 있도록 해주는 장점이자, 불변성을 지켜야하는 이유이다.
원시타입의 변화의 메모리 영역값이 변경하지 않는, 불변성을 유지한채로 새로운 메모리 영역에서 변경된 값이 저장 되기 때문에 콜스택 의 주소값의 변화가 감지되지만,
참조 타입은 콜스택에 메모리 힙의 주소만을 저장하고, 값은 메모리 힙에 저장,변경되기 때믄에 참조 타입의 값을 변경하면 콜스택의 주소값은 변경이 없어 react는 state의 변경이 없다고 감지하기 때문에 변경된 state는 재랜더링되지 않는다.
따라서 참조타입의 변경된 값을 react가 감지 할 수 있도록 불변성을 유지해야한다
불변성을 지키는 것은 기존의 메모리 영역에 변경을 가하지 않는 것으로
외부에 존재하는 원본 데이터를 직접 수정하지 않고, 원본데이터의 복사본을 만들어서 값을 사용한다는 것을 의미한다. 이는 기존 메모리 영역의 값이 변경할 경우에 기존 메모리 영역의 값을 사용하는 다른 코드에서 발생할 수 있는 오류를 사전에 방지 할 수 있으며 , 예기치 못한 오류를 해결할 코드를 추가적으로 만들지 않아도 된다는 이점도 있다.
spread operator, map, filter, slice ,reducer 등등 새로운 배열을 반환하는 메소드들을 활용한다.
splice, push 등 원본 데이터를 변경하는 메소드를 사용해야한다면, 기존의 배열을 복사한 새로운 객체를 만들고, 복사한 객체의 데이터를 변경한 후에 이걸 state에 넣어주는 것 도 생각해 볼 수 있다.
이는 state의 property 중 배열인 property에서 특정 원소의 데이트를 변경,삭제해야하는 경우에 매우 유용하다.
예를 들어 여러 개의 포도가 담겨 있는 상자(state) 속 포도 한 송이(state의 배열 객체)의 포도알(state의 배열 타입 객체의 원소)중 에 특정 포도알 변경해야 하는 상황을 가정해 보자.
(fruit) Box---grape ---grain ---grain ---grape ---grape
이 경우에는 변경된 포도알을 가지는 포도라는 새로운 배열 객체를 만들 필요 없이, 기존의 포도알의 값을 변경하는 메소드를 사용하면 포도에 변경된 포도알의 값이 업데이트 된다. 즉, 데이터를 변경하고자 하는 포도알이 들어있는 포도에서 변경하고자 하는 포도알을 내가 변경하고자 하는 데이터가 담긴 포도알로 바꾸면된다.
type Grap ={
grains:grain[ ];
grainsId: string[ ];
areaOfProduction:string;
.....
};
type Box ={
grapes: Grape[];
grapesId: string[ ];
};
function notion (state:Box =initialState , action :StateAction) :Box{
const grapes =[...state.grapes];
const grapesId=[...state.grapesId];
const grapeIndex= grapesId.indexOf(actoin.grapeId);
const targetGrape = grapes[grapeIndex];
....
case EDIT_GRAIN :
.....// FUNCTION //
const targetGrainIndex = targetGrape.grains.indexOf(action.targetGainId);
targetGrape.grains?.splice(targetGrainIndex, 1, action.newGrain);
.........
return {
grapes: grapes,
grapesId:grapesId
};
만약 다음과 같이 grain을 다른 component의 property로 받아서 사용하는 경우라면
const View=()=>{
...
return (
<div>
...
grape.grains.map((grain:GrainType)=>
<Grain grain={
grain]>
)
</div>
)
}
EDIT_GRAIN 에서 데이터 변경을 담은 새로운 grape를 만들어서 데이터 변경이 있는 grape 자리에 새로운 grape를 넣는 방식을 사용한다면 Grain component는 해당 데이터를 읽지 못할 가능성이 매우 높다(경험상으로는 데이터의 변경을 감지하지 못했음)
case EDIT_GRAIN:
const GRAINS=[...grape.grains];
const targetGrainIndex = GRAINS.indexOf(action.targetGainId);
GRAINS.splice(targetGainIndex,1,action.newGrain);
const editedGrape:Grape ={
...grape,
grains:GRAINS};
grapes.splice(grapeIndex,1,editedGrape);
setState, useState, useReducer를 사용하여 상태를 업데이트 할 수 있다.
<참고>
🧑💻 js 메모리 구조
🧑💻리액트의 불변성이란 무엇이고, 왜 지켜야할까?
🧑💻콜스택/메모리힙 구조, 데이터 저장/참조 원리
안녕하세요! 리액트 불변성에 대해 공부하다가 글을 봤는데 많은 도움이 되었습니다.
티스토리에 정리할 때 출처 밝히고 참고해도 될까요? :)