리액트를 쓰다 보면 귀에 못이 박히도록 듣는 말이 있다. 상태를 직접 바꾸지 말라는 것이다. 배열에 push를 쓰지 말고, 객체 속성에 값을 대입하지 말라고 한다. 처음엔 이게 무슨 결벽증인가 싶다. 값만 제대로 들어가면 되지 왜 굳이 새로 만드나. 이유는 하나다. 그렇게 안 하면 화면이 안 바뀐다.
리액트는 값이 아니라 참조를 본다. 상태가 바뀌었는지 판단할 때, 리액트는 예전 값과 새 값의 속을 하나하나 비교하지 않는다. 그건 너무 느리다. 대신 두 값이 같은 물건을 가리키는지, 즉 참조(reference)가 달라졌는지만 본다. 객체와 배열은 변수에 그 자체가 아니라 그게 놓인 자리를 가리키는 주소가 담긴다. 그래서 속을 아무리 바꿔도 주소가 그대로면, 리액트 눈엔 아무 일도 안 일어난 것이다.
const [items, setItems] = useState([1, 2, 3]);
function add() {
items.push(4);
setItems(items);
}
push는 items가 가리키는 배열에 4를 밀어넣는다. 값은 분명히 들어간다. 그런데 setItems에 넘긴 items는 여전히 같은 배열, 같은 주소다. 리액트는 이전 상태와 주소를 견줘 보고 똑같다고 판단해 리렌더를 건너뛴다. 데이터는 4개인데 화면엔 3개만 남는, 사람 미치게 하는 상황이 벌어진다.
새 값을 만들어 갈아끼운다. 해법은 원본을 건드리지 않고 새 배열이나 새 객체를 만들어 넘기는 것이다. 이걸 두고 불변성(immutability)을 지킨다고 말한다. 배열이면 펼침 연산자(spread, ...)로 기존 요소를 펼친 새 배열을 만든다.
setItems([...items, 4]);
[...items, 4]는 items의 요소를 그대로 옮겨 담고 뒤에 4를 붙인, 완전히 새로운 배열이다. 주소가 다르니 리액트가 변화를 알아채고 화면을 다시 그린다. 객체도 똑같다. 속성 하나를 바꾸려고 user.name = "김"이라고 대입하는 대신, 펼침으로 새 객체를 만든다.
setUser({ ...user, name: "김" });
얕은 복사의 함정을 조심하라. 펼침 연산자는 한 겹만 복사한다. 이걸 얕은 복사(shallow copy)라고 한다. 겉은 새 객체지만, 그 안에 든 객체나 배열은 여전히 원본과 같은 주소를 가리킨다. 중첩된 값을 이 사실을 모르고 건드리면 조용한 버그가 생긴다.
const [profile, setProfile] = useState({
name: "김",
address: { city: "서울" },
});
function move() {
const next = { ...profile };
next.address.city = "부산";
setProfile(next);
}
{ ...profile }로 겉껍데기는 새로 만들었다. 하지만 next.address는 원본 profile.address와 같은 주소를 공유한다. next.address.city를 바꾸면 원본 상태의 속까지 같이 바뀌어 버린다. 안쪽도 새로 만들어야 한다. 바꾸려는 경로를 따라 그 층마다 펼쳐 준다.
setProfile({
...profile,
address: { ...profile.address, city: "부산" },
});
바깥 객체도 새로, address도 새로 만들어 갈아끼운다. 깊이가 깊어질수록 손이 많이 가는데, 그래서 상태를 설계할 때 굳이 깊게 중첩하지 않는 편이 낫다. 평평한 구조가 불변 갱신을 편하게 만든다. 나는 서너 겹씩 파고든 상태를 만들었다가 갱신 코드가 괄호로 뒤덮여 읽기도 싫어진 뒤로, 처음부터 얕게 설계하는 버릇을 들였다.
배열은 원본을 바꾸는 메서드를 피한다. 자바스크립트 배열 메서드에는 원본을 그 자리에서 고치는 것과, 새 배열을 돌려주는 것이 섞여 있다. push, splice, sort, reverse는 원본을 바꾼다. map, filter, slice와 펼침 연산자는 새 배열을 만든다. 상태에는 뒤엣것을 쓴다. 특정 항목만 바꾸려면 map, 빼려면 filter다.
setItems(items.map((it) => (it.id === id ? { ...it, done: true } : it)));
setItems(items.filter((it) => it.id !== id));
배열 속 객체 하나만 바꾸기가 제일 헷갈린다. 목록의 특정 항목에서 필드 하나만 고치는 경우는 두 규칙이 겹친다. 배열도 새로, 그 안에서 바꿀 객체도 새로 만들어야 한다. map으로 배열을 새로 만들면서, 바꿀 항목만 펼침으로 갈아끼우고 나머지는 원래 것을 그대로 둔다.
setTodos(
todos.map((t) =>
t.id === id ? { ...t, done: !t.done } : t
)
);
id가 맞는 항목만 { ...t, done: !t.done }라는 새 객체로 바뀌고, 나머지는 t 그대로다. map이 새 배열을 만들었으니 배열의 참조도 달라진다. 바꾼 항목만 새 객체이므로, 리액트가 나중에 항목별로 비교할 때 손대지 않은 항목은 예전과 같다고 빠르게 넘길 수 있다.
이 고생을 왜 하냐면. 불변성을 지키면 딸려오는 이득이 있다. 리액트는 속을 뒤지지 않고 참조만 견주면 되니 변화 판단이 빠르다. 게다가 옛 상태를 뭉개지 않고 그대로 남겨두므로, 이전 값과 지금 값을 나란히 비교하거나 실행 취소 같은 기능을 붙이기도 쉬워진다. 이 원칙은 함수형 갱신과도 잘 맞는다. 이전 상태를 인자로 받아 새 값을 만들면, 낡은 값에 갇히는 실수를 피할 수 있다.
setItems((prev) => [...prev, 4]);
prev는 리액트가 넘겨주는 가장 최신 상태다. 바깥의 items를 직접 참조하는 대신 prev를 펼쳐 새 배열을 만들면, 짧은 시간에 여러 번 갱신하더라도 매번 최신 값 위에 얹힌다. 새 값을 만들어 돌려준다는 불변성의 습관이 여기서도 그대로 이어진다.
나를 가장 오래 붙잡은 건 sort였다. 목록을 정렬해 보여주려고 items.sort(...)를 상태에 그대로 썼는데, sort가 원본 배열을 그 자리에서 뒤섞어 버리는 바람에 화면은 안 바뀌면서 상태만 조용히 오염됐다. 정렬은 [...items].sort(...)처럼 먼저 복사본을 뜬 다음에 해야 한다. 어떤 메서드가 원본을 건드리는지 아닌지를 몸에 익혀두면 이런 사고를 미리 피한다.
바뀐 길만 새로 만들면 된다. 불변 갱신이라고 하면 상태 전체를 통째로 복제해야 하는 줄 아는 사람이 있는데, 그렇지 않다. 바꾸려는 값까지 이어지는 길목에 놓인 객체와 배열만 새로 만들고, 손대지 않은 가지는 원래 것을 그대로 재사용하면 된다. 앞의 프로필 예에서도 address는 새로 만들었지만, 만약 name 옆에 다른 중첩 객체가 또 있었다면 그건 건드리지 않고 그대로 뒀을 것이다. 바뀐 부분만 새 참조, 나머지는 옛 참조를 공유하는 이 방식 덕분에, 리액트는 어디가 진짜 달라졌는지 값싸게 알아낸다.
정리하면, 리액트는 참조로 변화를 판단하기 때문에 상태는 늘 새 값으로 교체해야 한다. 펼침 연산자로 새 객체와 배열을 만들되, 얕은 복사가 한 겹만 복사한다는 점을 기억하고 중첩된 부분은 그 층까지 새로 만든다. 원본을 바꾸는 배열 메서드는 상태에 직접 쓰지 않는다. 불변성은 리액트의 취향이 아니라, 화면을 제대로 갱신하기 위한 최소한의 약속이다.