😕 아니, 이건 너무 위험하잖아요.
새로운 시니어님이 오시고 많이 하신 이야기 중 하나는 ‘이건 말이 안되잖아요…’ 와 ‘이건 너무 위험하잖아요..!’ 였다. 🥲 (죄송)
🤦🏻♀️ 이건 말이 안되잖아요 시리즈 중 하나인 디버깅에 대한 내용은 지난 번에 정리를 하면서 지금 굉장히 잘 써먹고 있는데, 이번에는 🚨 이건 너무 위험하잖아요 시리즈 중 ‘불변성’에 대한 내용을 조금 정리해보고자 한다. 시니어님께서 말씀하신 불변성은 오늘 다루는 것과 완벽하게 일치하는 부분은 아니었고 다음에 다루려고 하는 또 다른 상태관리에 대한 내용이지만, 그 전에 리액트가 말하는 불변성을 지키는 것을 공부해보고 싶었다.
🤚🏻 들어가며.
리액트를 공부하며 많이 듣는 이야기들이 몇 가지가 있는데, 그 중에서 가장 많이 듣는 것 중 하나는 ‘불변성을 지켜주는 것’이다. 불변성을 지키라고? 👀 불변성이 뭔데? 그걸 왜 지키는데? 라는 생각을 많이 했는데, 어렴풋이 알던 개념에 대해 정리를 좀 해봤다.
불변성 값이나 상태를 변경할 수 없는 것.
음.. 값이 멋대로 변경되지 않도록 지켜주란 말이려나. 근데 왜? 값이 바뀌어야 리렌더링이 일어나고 화면이 변경되는 것 아닌가..? 왜??????????? 라는 생각을 했었다.
그래서 기술 매니저님께도 그걸 왜 지켜야하냐 물었는데 “데이터가 제멋대로 바뀌면 안되잖아요!” 라고 이야기를 해주셨던 기억이 난다. 그 때는 문자 그대로 받아들였지만, 가끔은 “불변성을 지켜주세요.”라는 말 자체가 너무 헷갈리게 다가올 때가 많았다.
🤦🏻♀️ 아니 그래서! 리액트가 말하는 불변성을 지키는건 도대체 무슨 의미일까. 뭐가 위험하단 건데!
지금부터는 여러 블로그에서 얻은 키워드들의 흔적을 따라 내 생각의 흐름대로 작성된 것들이 많아서 물음표 대잔치들이 펼쳐질 예정이다. 다소 어지러울 수 있음. 😕
📝 불변성에 대해 알아보기 위해, 우선 데이터를 돌아보자.
자바스크립트에서 데이터는 원시타입과 참조타입이 있다.
원시타입 : String, Number, Boolean, undefined, null, Symbol
참조타입 : Object, Array, Function
두 가지 타입으로 나뉘는 이유는 뭐 당연히 각각의 특성이 다르기 때문인데 이 특성은 아래 그림을 보면 이해하기가 쉽다.
음.. 지금부터는 뭔가 이해를 쉽게 하기 위해서 콜 스택을 하나의 아파트라고 생각을 해보자.
🏡 그럼, 원시값은 콜 스택 아파트 단지에서 메모리라는 하나의 주소를 배정받아 지내는 값이다. 이 놈은 한 번 집을 배정받고 나면 다른 녀석이 들어와서 집을 내놓으라는 소리를 듣지 않고 계속 그 집에서 살게 된다.
만약 특정 변수가 어떠한 “원시값을 할당받는다”라고 하면 해당 원시값이 차지하고 있는 콜스택의 주소값을 직접 바라본다는걸 의미한다. 그럼, “변수의 값이 바뀐다” 라고 한다면 원래 변수가 바라보고 있던 주소의 원시값이 다른 값에 의해 방을 빼앗긴다는게 아니라, 변수로 하여금 자기가 배정받은 값이 살고 있는 다른 주소값을 바라보게 한다는 것을 의미한다. 즉 한 번 메모리의 한 방을 차지한 원시값은 더 이상 변경되지 않는다.
위 표에서 "Harry Potter"
라는 값은 콜스택의 10FF 0001
이라는 주소를 받았으니, 이제는 계속 저기서 살 것이다. 그렇다. 원시값은 “불변성을 지킨다.”
🤷🏻♀️ 그럼 참조타입은? friends
, harry
, dobby
변수가 가리키고 있는 주소의 값을 보면 뭔가 또 다른 주소를 가리키고 있다. 즉 참조값은 콜 스택의 아파트에 그 값들이 직접적으로 살고 있는게 아니라 메모리 힙에 또 다른 살림을 차려서 살고 있다. 그래서 결론적으로 참조타입은 메모리 힙의 주소값을 보게 된다. 그런데, 메모리 힙에 있는 값들은 그 크기가 픽스가 되어있지 않아 변경이 가능하다. 그렇기 때문에 변경이 가능하다. 즉, 참조타입을 두고 불변성을 논한다면 “불변성을 지켜줘야 한다.”
이 데이터 측면에서 봤을 때, 불변성의 의미는 “메모리 영역에서 값이 변하지 않는다.”라는 것이다.
👩🏻🌾 만약 위 설명이 이해가 안간다면 아래 포스팅도 같이 보면 좋을 것 같다.
[ C언어 ] 메모리 누수의 발생과 해결방법, 그리고 자바스크립트
⚛️ 이제 리액트에서 불변성이 왜 지켜져야하는지 알아볼까.
불변성을 지켜라 ← 라는 것의 기본적인 의미를 알아봤으니 이제는 리액트로 들어갈 차례다.
리액트에서는 불변성에 대해 이야기를 할 때 “상태”가 항상 거론된다. 그럼 리액트에서 “상태”가 관리되는 측면에서 불변성이 뭔가가 영향이 있다는 것이니 상태가 어떻게 업데이트 되는지를 알아보자.
리액트는 상태를 업데이트할 때, 기존 값과 새로운 값에 대해 참조값(콜 스택의 주소값)에 대해서만 비교하는 “얕은 비교”를 한다. 그래서 객체 또는 배열의 각각의 속성들에 대해 하나 하나 비교하는 것이 아닌, 참조값에 대한 비교만 진행해 상태가 변했는지를 참조한다.
🤷🏻♀️ 그럼 불변성을 이야기하는 것에서 왜 얕은 비교가 나올까? 이것은 아무리 생각해봐야 답도 없으니 일단 뭐라도 만들어보고 이유를 찾아보자.
1️⃣ 상태를 업데이트 시켜주는 두 개의 버튼을 만들어봤다.
아래 짤을 한 번 확인해보자. 두 버튼은 모두 하나의 배열에 "pikachu"
라는 문자를 추가하는 버튼이다.
하지만 위 버튼은 아무리 클릭해도 렌더링이 일어나지 않고 아래 버튼은 클릭할 때 마다 렌더링이 일어난다. 또, 위 버튼을 연타하다가 아래 버튼을 한 번이라도 클릭하면 갑자기 위 버튼에서 추가되어야할 문자들이 한 번에 나타나는 이상한 현상이 일어난다.
📋 원본 배열을 변경시키는 첫 번째 버튼.
위 버튼의 경우에는 아래와 같이 함수가 걸려있다. list
라는 state
값의 주솟값을 copy
라는 변수에 할당을 하고 원본 배열에 "pikachu"
를 추가한다. 이 때, copy
라는 변수와 list
라는 변수는 같은 주소값을 공유하고 있기 때문에 copy
변수가 가지고 있는 배열에 문자를 추가하면 list
도 함께 변하게 된다.
다만 여기서 문제가 발생한다. 리액트는 참조값의 변경
을 감지해서 렌더링을 유발시키는데, 주소값은 그대로인데 그 주소값이 가진 값만 변경이 되었다. 그렇기 때문에 값은 변경 되었지만 화면은 변하지 않았다.
만약 이 상태에서 다른 트리거에 의해 화면이 변하게 된다면, 나도 모르는 사이 값이 변경된 list
값이 갑자기 화면에 튀어나오는 이상한 현상을 겪게될 것이다. 🤦🏻♀️ (그럴 일이 어딨어..? 라고 생각할 수 있지만, 어떤 경로든 간에 위험한 요소들은 존재하지 않는 것이 좋다.)
const Sample = () => {
const [list, setList] = useState<string[]>([]);
const onChangeList = () => {
let copy = list;
copy.push('pikachu');
setList(copy);
};
return (
<div>
<button onClick={onChangeList}>상태</button>
<p>{...list}</p>
</div>
)
}
🆕 새로운 참조값을 생성해주는 두 번째 버튼.
자 이번에는 두 번째 버튼이다. 이 버튼의 클릭 이벤트에 달린 함수에는 이전과 같이 copy
라는 변수가 있는데, 다만 이 친구는 전개 연산자에 의해 list
가 가지는 주솟값을 가지지 않고 새로운 주소값을 가지게 된다. 그리고 새로운 방에 있는 값에 "pikachu"
를 더해주고 이 새로운 값을 setter 함수에 전달했다.
그렇기 때문에 리액트는 주소값의 변경을 인지하고 화면을 렌더링 시키게 된다. 즉 태초(?ㅋㅋ)의 list
가 가지고 있던 주소값에 있던 값은 조금도 건드려지지 않았고 그저 이 친구가 다른 방에 있는 새로운 값을 가지게 되었다. 즉, 이전 값에 대한 불변성을 지켜냈다.
const Sample = () => {
const [list, setList] = useState<string[]>([]);
const onChangeList = () => {
let copy = [...list, 'pikachu'];
setList(copy);
};
return (
<div>
<button onClick={onChangeList}>상태</button>
<p>{...list}</p>
</div>
)
}
🙋🏻♀️ 예시를 하나만 더 볼까?
이번에는 객체를 가지고 테스트를 해봤다. name
과 age
라는 속성을 가진 객체를 가진 두 가지 상태를 만들어봤는데, 상단의 버튼은 아무리 클릭을 해도 아무 변화가 없다. 하지만 하단의 버튼은 누를 때 마다 화면이 잘 렌더링 된다.
또, 상단 버튼을 연타를 하다가 하단 버튼을 한 번이라도 클릭을 하면 위 버튼이 갑자기 많이 바뀌어 있는 현상이 일어난다. 위 예시와 아주 같은 현상이다.
📋 원본 객체를 변경시키는 첫 번째 버튼.
이 버튼은 sangtae
라는 상태값을 변화시키고 싶은데, copy
변수가 같은 주솟값을 바라보게끔 복사를 하고 그 원본 값을 직접적으로 변경을 시켰다. 그리고 이값을 그대로 setter 함수에 넣었다. 그 결과는? 리액트는 아무것도 인지하지 못한 채 코가 베여버렸지 뭘..
interface SangtaeType {
name: string;
age: number;
};
const Sample = () => {
const [sangtae, setSangtae] = useState<SangtaeType>({
name: "LEETRUE",
age: 20
});
const onChangeList = () => {
let copy = sangtae;
copy.age += 1;
setSangtae(copy);
};
return (
<div>
<button onClick={onChangeList}>상태</button>
<p>{sangtae.name}, {sangtae.age}</p>
</div>
)
}
🆕 새로운 참조값을 생성해주는 두 번째 버튼.
이번엔 전개 연산자로 새로운 참조값을 가지는 객체를 만들었고, 그 속성의 값을 수정해줬다. 그리고 이렇게 생성된 새로운 값을 setter 함수에 전달했다. 그 결과는 뭐, 리액트가 새로운 주솟값이 들어온걸 보고 화들짝 놀라 렌더링을 시켰겠지..!
interface SangtaeType {
name: string;
age: number;
};
const Sample = () => {
const [sangtae, setSangtae] = useState<SangtaeType>({
name: "LEETRUE",
age: 20
});
const onChangeList = () => {
const copy = {...sangtae, age: sangtae.age + 1};
setSangtae(copy);
};
return (
<div>
<button onClick={onChangeList}>상태</button>
<p>{sangtae.name}, {sangtae.age}</p>
</div>
)
}
위 두 가지 예시를 두고 생각을 해보면 뭔가 불변성을 지켜준다는 것의 의미에 대해 조금 감이 온다. 상태가 가지는 주소값의 원본 값 자체를 수정을 하면 렌더링이 되기 전까지는 알 수가 없다. 하지만 어떤 무언가에 의해 렌더링이 일어나면 숨어있던 수정된 값이 갑작스럽게 화면에 나온다.
🚫 위험하다.
시니어님께서 계속 말씀하시는 “이건 너무 위험하잖아요.”라는 말의 의미가 이것이 아니었을까. 내가 추적하지 못할 값들이 어딘가에 숨어있다는건 정말 위험하다. 디버깅을 하는 과정에서 어떤 것에 의해 어떻게 변화 하는지를 정확히 추적해나가야 하는데 이렇게 숨어있는 요소들이 있으면 우리는 오류가 나는 것들에 대해 찾아나가기가 쉽지가 않다. 이것이 불변성을 지켜야하는 이유가 아닐까.
👩🏻🌾 정리해보자.
위 두 가지 케이스를 만들어보며, 얻은 것은 아래 두 가지였다.
리액트는 얕은 비교를 수행해서 상태를 업데이트한다.
원본 데이터가 변경이 되면, 화면은 업데이트 되지 않고 추후에 혼란을 초래할 수 있다.
그렇다면, 리액트에서 “불변성을 지킨다.”라는 말은 “상태를 업데이트할 때, 🆕 새로운 참조값을 넣어서 렌더링이 일어날 수 있도록 하고, 😧 나도 모르게 데이터가 변경이 되는 오류가 심겨지는 것을 예방하는 것”이 아닐까.
즉, 상태를 업데이트할 때 사용하는 setter 함수에 참조타입의 데이터를 변경할 때는 새로운 객체와 배열을 넣어주자는 것이다.
😃 불변성을 지켜주는 방법!
전개 연산자,
map
,filter
,reduce
등의 새로운 배열을 반환하는 메소드 활용assign
메서드 사용, 전개 연산자 사용,immer
라이브러리 활용
👩🏻🌾 마무리.
오늘 이렇게 정리를 해보니, 불변성을 지킨다는 말의 의미가 이해가 간다. 뭔가 하나의 짧은 문장으로 변하지 않도록 지켜줘! 라는 말로만 끝날 것 같았지만 그 안에는 많은 의미가 숨어있던 것 같다.
오늘의 수확은 또 “위험한 것”에 대해서 생각을 했던 것인데, 추적을 하지 못할 무언가를 만드는 것은 개발에 있어서 정말 경계해야할 일인 것 같다. 숨어있는 작은 폭탄들을 쏙쏙 숨겨두는 일들이 될 수 있는데, 또 이게 어디있는지 찾지를 못한다는 것은 정말 위험하기에!
개발을 하면서도 디버깅을 잘 할 수 있도록, 그리고 이게 왜 이런 일이 일어나는지를 명확하게 보일 수 있게끔 짜려고 신경을 많이 써야겠다.
리액트에서는 불변성을 지켜서,
상태를 효율적으로 업데이트 하자!
이상한 오류가 생기는 것을 방지하자!