리액트의 핵심, 불변성
선언형과 함수형
리액트 공식 문서에서 우리는 선언형이라는 표현을 볼 수 있다.
How를 강조하는 명령형 프로그래밍과 달리, 선언형 프로그래밍은 What을 강조한다.
그리고 함수형 프로그래밍은 선언형 프로그래밍을 실현하는 방법 중 하나다.
함수형 프로그래밍의 대표적인 키워드 중 하나는 순수 함수다.
순수 함수란 외부의 값을 변경하는 사이드 이펙트가 없는 함수다.
순수 함수에 대한 더 자세한 내용은 [자바스크립트의 순수 함수와 비순수 함수
](https://www.jeong-min.com/62-pure-impure-function/)에서 확인할 수 있다.
외부의 값을 변경하지 않는다는 개념은 불변성과도 밀접한 관련이 있다.
불변성이란?
불변성이란 값이나 상태를 변경할 수 없는 것이다.
그리고 자바스크립트의 원시타입은 불변성을 가진다.
자바스크립트의 변수 타입
자바스크립트의 변수 타입에는 크게 원시타입과 참조타입이 있다.
원시타입으로는 string, number, bigint, boolean, null, undefined, symbol이 있고,
나머지는 참조타입으로 볼 수 있다. (object, array, function 등으로 대부분 object로 치환된다.)
원시타입이 변수에 할당될 때, 메모리의 고정 크기로 원시 값이 저장되고 저장된 값을 변수가 가리키게 된다.
(변수와 원시 값은 콜 스택에 함께 저장된다.)
원시타입은 값이 변하지 않는 불변성을 갖고 있기 때문에,
변수 재할당 시 기존 값이 변하는 것처럼 보이지만
실제로는 새로운 메모리에 값이 저장되고 변수가 가리키는 메모리가 달라지게 된다.
let a = 1;
a = 2;첫번째 줄이 실행되면 메모리에 1이 저장되고 변수 a는 1의 메모리 주소를 가리킨다.
두번째 줄이 실행되면 새로운 메모리에 2가 저장되고 변수 a는 2의 메모리 주소를 가리킨다.
let a = 1;
let b = a;
a = 2;두번째 줄이 실행되면 새로운 메모리에 a의 값이 그대로 복사되고 b는 그 메모리의 주소를 가리킨다. 따라서 a가 2로 재할당되더라도 b는 영향을 받지 않는다.
그렇다면 참조타입은 어떨까?
참조타입의 가장 큰 특징은 변수의 크기가 동적으로 변한다는 것이다.
따라서 참조 값의 메모리 주소는 콜 스택에 저장되지만, 실제 참조 값 자체는 힙에 별도로 저장하여 사용된다.
let arrA = [];
let arrB = arrA;
arrB.push("abc");
console.log(arrA); // ["abc"]arrB는 arrA의 데이터의 주소 값을 가리키게 되고,
arrB를 수정하면 arrA 또한 영향을 받게 된다.
이처럼 콜 스택에 할당된 값이 바뀌지 않는 원시타입과 달리,
참조타입의 경우 메모리 힙에 할당된 값이 바뀐다.
즉, 불변성은 메모리 영역에서 값이 변하지 않는 것이다.
리액트의 불변성
리액트는 기본적으로 얕은 비교를 사용하여 props와 state의 변경을 감지한다.
배열이나 객체의 속성 하나하나를 비교하지 않고, 이전 참조값과 현재 참조값만 Object.is로 비교하기에 효율적이다.
const [number, setNumber] = useState(1);
setNumber(2);
const [array, setArray] = useState([]);
setArray([...array, "abc"]);
const [object, setObject] = useState({});
setObject({ ...object, type: 'js' })따라서 불변성을 가진 원시타입의 경우 바로 업데이트해주어도 되지만,
참조타입의 경우 새로운 참조값을 가진 객체를 생성하여 의도적으로 불변성을 지켜주어야만 한다.
중첩된 객체는 어디까지 새로 만들어야 할까?
위 예시처럼 state가 한 단계짜리 배열이나 객체일 때는 spread 한 번으로 끝난다.
문제는 안에 객체가 또 들어 있을 때다.
const [user, setUser] = useState({
name: 'Dan',
address: { city: 'Seoul', zip: '04524' },
});
// ❌ 최상위만 새로 만들고, address는 직접 바꿨다.
user.address.city = 'Busan';
setUser({ ...user });
// ✅ 바뀌는 값까지 가는 경로를 전부 새로 만든다.
setUser({ ...user, address: { ...user.address, city: 'Busan' } });❌ 코드는 겉으로는 동작한다.
최상위 user를 새 객체로 만들었으니 Object.is 비교에 걸리고, 이 state를 가진 컴포넌트는 다시 렌더링된다.
하지만 기존 address를 직접 바꿨기 때문에, 이전 렌더가 들고 있던 그 address 객체가 새 값으로 덮어써진다.
리액트에서 state는 렌더마다의 스냅샷인데, 이전 스냅샷의 address와 현재의 address가 같은 객체이고 내용까지 같아진 것이다.
그러면 이전과 현재를 비교하는 모든 곳이 “변화 없음”을 본다.
리액트가 하는 비교부터 어긋난다.
const Address = React.memo(({ address }) => <p>{address.city}</p>);
// user.address의 참조가 같으니 Address는 리렌더링되지 않는다.
<Address address={user.address} />부모는 다시 그려졌는데 주소만 Seoul에 멈춰 있는 화면이 된다.
React.memo만 그런 게 아니다. useMemo(() => formatAddress(user.address), [user.address])도 다시 계산되지 않고, [user.address]를 의존성으로 둔 effect도 돌지 않는다.
이런 버그는 memo나 useMemo를 붙이기 전까지는 드러나지 않아서, 나중에 최적화를 하다가 갑자기 터진다.
도시가 바뀔 때만 이벤트를 보내려던 코드도 이전 값과 새 값이 둘 다 Busan이라 조건에 걸리지 않고, 실행 취소를 위해 이전 user를 들고 있었다면 되돌려도 Busan이 나온다.
그래서 규칙은 한 줄이다.
바뀐 값에서 state 루트까지 올라가는 경로는 전부 새 참조로, 그 밖의 가지는 기존 참조 그대로.
모든 것을 새로 만들어야 하는 건 아니다
규칙의 뒷부분이 중요하다.
불변성을 지키라고 하면 state 전체를 깊은 복사하는 경우가 있는데, 그건 불필요하고 오히려 해롭다.
user (새 객체)
├── name (기존 참조 그대로)
└── address (새 객체) ← 바뀐 경로
├── city (새 값)
└── zip (기존 참조 그대로)바뀐 city에서 루트 user까지 올라가는 경로에 있는 객체만 새로 만들고, name이나 zip처럼 경로 밖의 값은 기존 참조를 그대로 공유한다.
이걸 구조적 공유(structural sharing)라고 부른다.
여기서 spread가 얕은 복사라는 게 중요하다.
{ ...user }는 user의 한 단계만 새로 만들고, 안의 address는 같은 참조를 가리킨다.
그래서 ❌ 코드처럼 최상위만 spread하고 안을 직접 고치면 경로가 끊기고, ✅ 코드처럼 바뀌는 단계마다 spread를 이어가야 경로가 완성된다.
배열도 같다.
// 바뀐 항목만 새 객체가 되고, 나머지 항목은 같은 참조를 돌려준다.
setTodos((prev) =>
prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t)),
);map은 새 배열을 만들지만, id가 다른 항목은 t를 그대로 반환하니 참조가 유지된다.
덕분에 리스트가 천 개여도 객체를 하나만 새로 만들면 되고, 안 바뀐 항목을 그리는 React.memo 컴포넌트는 참조가 같으니 그대로 건너뛴다.
구조적 공유가 지켜져야 memo가 일을 한다. 매번 전부 새로 만들면 memo는 아무것도 걸러내지 못한다.
spread를 써도 깨지고, 대입을 해도 지켜진다
앞의 ❌ 코드는 spread를 썼는데도 불변성을 깨뜨렸다.
반대로 대입문을 쓰면서 불변성을 지키는 코드도 있다.
import { produce } from 'immer';
setUser(
produce((draft) => {
draft.address.city = 'Busan';
}),
);Immer는 draft.address.city = 'Busan'이라고 대입하지만, 결과는 바뀐 경로만 새 참조로 교체된 불변 업데이트다.
Redux Toolkit의 리듀서 안에서 상태를 직접 바꾸는 것처럼 보이는 코드도 같은 원리다.
그러니 어떤 문법을 썼는지만 보고는 불변성이 지켜졌는지 알 수 없다.
바뀐 값까지 가는 경로가 전부 새 참조인지, 경로 밖은 그대로 공유되는지, 결과를 봐야 한다.
이처럼 리액트는 참조값만 비교하여 상태 업데이트를 효율적으로 수행하고, 불변성을 지켜서 사이드 이펙트를 방지한다.
그 불변성이 지켜졌는지는 새 배열을 만들었는지가 아니라, 바뀐 값까지 가는 길이 전부 새로 이어졌는지로 판단하면 된다.
이 이야기를 패러다임 관점에서 더 넓게 다룬 글은 객체지향 vs 함수형? 싸울 일이 아닙니다에서 확인할 수 있다.
B
u
y
M
e
A
C
o
f
f
e
e
☕
️