JavaScript · 5장 자료구조와 자료형
불변 업데이트: 상태를 바꾸지 않고 새로 만들기
"값을 바꿨는데 화면이 안 바뀌어요"는 React · Vue · Svelte 같은 화면 프레임워크를 처음 쓸 때 가장 많이 묻는 질문 중 하나예요. 원인은 대개 같아요. 객체나 배열을 직접 고쳤기 때문이에요. 4장과 5장에서 배운 참조 · 펼치기 · 배열 메서드를 모아, 상태를 고치지 않고 새로 만드는 법을 정리해요.
01. 왜 고치지 않고 새로 만들까
화면 프레임워크는 상태가 바뀌었는지 알아내려고 객체 안을 하나하나 비교하지 않아요. 그러면 너무 느리니까요. 대신 이전 상태와 새 상태가 같은 객체인지(===)만 봐요(객체 비교). 객체를 직접 고치면 안의 값은 바뀌어도 여전히 같은 객체라서 "바뀐 게 없다"고 판단해요.
아래는 그 판단 방식을 흉내 낸 작은 상태 저장소예요. 같은 객체를 넘기면 다시 그리지 않아요.
function createStore(initial) {
let state = initial;
return {
get: () => state,
set(next) {
if (next === state) { // 프레임워크도 이렇게 비교해요
console.log('같은 객체라 다시 그리지 않아요');
return;
}
state = next;
console.log('다시 그려요:', JSON.stringify(state));
},
};
}
const store = createStore({ count: 0 });
const s = store.get();
s.count++; // 직접 고쳤어요
store.set(s); // 같은 객체를 넘겼으니 화면은 그대로
store.set({ ...store.get(), count: store.get().count + 1 }); // 새 객체를 넘겼어요
두 번째에 찍힌 count가 1이 아니라 2인 것도 봐 두세요. 첫 번째에 고친 값이 화면 몰래 이미 상태 안에 들어가 있었어요. 직접 고치면 화면이 안 바뀔 뿐 아니라, 언제 무엇이 바뀌었는지 추적할 수 없게 돼요. 새로 만들면 이전 상태가 그대로 남아서, 되돌리기 · 변경 기록 · 비교가 쉬워져요.
02. 객체 상태 바꾸기: 펼쳐서 새로
객체의 속성 하나를 바꾸려면 { ...이전, 바꿀속성: 새값 }으로 새 객체를 만들어요. 뒤에 쓴 속성이 앞의 값을 덮어써요. 펼치기는 얕은 복사라서(얕은 복사), 안쪽 객체를 바꿀 때는 그 단계도 같이 펼쳐야 해요. 바꾸지 않은 안쪽 값(아래의 tags)은 새 객체와 이전 객체가 그대로 함께 써요. 다 복사하지 않으니 빠르고, 프레임워크도 그 부분은 안 바뀌었다고 알아요.
const user = { name: '하늘', address: { city: '서울', zip: '04524' }, tags: ['js'] };
const renamed = { ...user, name: '가람' };
const moved = { ...user, address: { ...user.address, city: '부산' } }; // 안쪽까지 펼쳐요
console.log(user.name, renamed.name); // 하늘 가람
console.log(user.address.city, moved.address.city); // 서울 부산 — 원본은 그대로
console.log(user.tags === moved.tags); // true — 안 바꾼 부분은 함께 써요
const { tags, ...withoutTags } = user; // 속성 빼기는 나머지 구조 분해로
console.log(Object.keys(withoutTags)); // ['name', 'address']
가장 흔한 실수는 바깥만 펼친 뒤 안쪽을 고치는 거예요. 복사본의 address는 원본과 같은 객체라서, 원본까지 바뀌어요.
const user = { name: '하늘', address: { city: '서울' } };
const copy = { ...user };
copy.address.city = '대구'; // 안쪽은 펼치지 않았어요
console.log(user.address.city); // 대구 — 원본이 바뀌었어요
03. 배열 상태 바꾸기: 고치는 메서드 대신 만드는 메서드
배열은 원본을 고치는 메서드(push · splice · sort · arr[i] = …)를 피하고, 새 배열을 돌려주는 방법을 써요(원본을 바꾸지 않는 메서드).
| 하고 싶은 일 | 고치는 방법 (피해요) | 새로 만드는 방법 |
|---|---|---|
| 끝에 넣기 | arr.push(x) | [...arr, x] |
| 앞에 넣기 | arr.unshift(x) | [x, ...arr] |
| 지우기 | arr.splice(i, 1) | arr.filter(it => it.id !== id) |
| 하나 바꾸기 | arr[i].done = true | arr.map(it => it.id === id ? { ...it, done: true } : it) |
| 정렬 · 뒤집기 | arr.sort() · arr.reverse() | arr.toSorted() · arr.toReversed() |
| 자리에 넣기 | arr.splice(i, 0, x) | arr.toSpliced(i, 0, x) |
const todos = [
{ id: 1, text: '장보기', done: false },
{ id: 2, text: '운동', done: false },
{ id: 3, text: '독서', done: true },
];
const added = [...todos, { id: 4, text: '청소', done: false }];
const removed = todos.filter(t => t.id !== 2);
const toggled = todos.map(t => (t.id === 1 ? { ...t, done: !t.done } : t));
console.log(added.length, removed.map(t => t.id)); // 4 [1, 3]
console.log(toggled[0].done, todos[0].done); // true false — 원본은 그대로
console.log(toggled[1] === todos[1]); // true — 안 바꾼 항목은 함께 써요
"하나 바꾸기"에서 map 안의 { ...t, done: !t.done }가 핵심이에요. 배열만 새로 만들고 항목 객체를 직접 고치면(t.done = true) 원본 항목이 바뀌어요. 배열과 바꾸는 항목 둘 다 새로 만들어야 해요.
04. 깊은 상태와 도구
상태가 여러 단계로 깊어지면 펼치기가 길게 이어져요. 실무에서는 세 가지로 대처해요.
- 상태를 평평하게 둬요. 목록 안의 목록 안의 객체처럼 깊게 두지 말고, id를 키로 하는 객체(또는 Map)로 나눠 두면 한두 단계 펼치기로 끝나요.
- 통째로 복사하고 고치기는 피해요.
structuredClone(state)뒤에 고치면 결과는 맞지만, 바꾸지 않은 부분까지 모두 새 객체가 되어 화면 전체가 다시 그려지고 느려져요. - Immer 같은 도구를 써요. 고치는 코드처럼 써도 속에서는 바뀐 부분만 새로 만들어 줘요. Redux Toolkit 안에도 들어 있어요.
// React에서: 고치지 말고 새 값을 set 함수에 넘겨요 (이 블록은 실행되지 않아요)
const [todos, setTodos] = useState(initialTodos);
function toggle(id) {
setTodos(prev => prev.map(t => (t.id === id ? { ...t, done: !t.done } : t)));
}
// Immer를 쓰면: 고치는 것처럼 쓰지만 결과는 새 객체예요
import { produce } from 'immer';
const next = produce(state, draft => {
draft.user.address.city = '부산';
});
Vue · Svelte 5처럼 프록시로 변경을 알아채는 프레임워크에서는 직접 고쳐도 화면이 바뀌어요. 그래도 같은 데이터를 여러 곳에서 함께 쓰거나, 이전 값과 비교해야 하는 곳(되돌리기, 캐시, 메모이제이션)에서는 불변 업데이트가 버그를 줄여 줘요.
할 일 목록 상태를 바꾸는 함수인데, 모든 동작이 원본 배열을 고쳐요. 그래서 이전 상태가 남지 않고, 프레임워크는 바뀐 걸 몰라요. 세 동작을 새 배열을 돌려주게 고쳐서 아래처럼 나오게 해 보세요.
1 2 1
false true
false true
function todosReducer(todos, action) {
switch (action.type) {
case 'add':
todos.push({ id: action.id, text: action.text, done: false });
return todos;
case 'toggle': {
const t = todos.find(t => t.id === action.id);
t.done = !t.done;
return todos;
}
case 'remove':
todos.splice(todos.findIndex(t => t.id === action.id), 1);
return todos;
default:
return todos;
}
}
const t0 = [{ id: 1, text: '장보기', done: false }];
const t1 = todosReducer(t0, { type: 'add', id: 2, text: '운동' });
const t2 = todosReducer(t1, { type: 'toggle', id: 1 });
const t3 = todosReducer(t2, { type: 'remove', id: 2 });
console.log(t0.length, t1.length, t3.length); // 단계마다 길이가 남아야 해요
console.log(t1[0].done, t2[0].done); // 토글 전후가 달라야 해요
console.log(t0 === t1, t1[1] === t2[1]); // 새 배열 · 안 바꾼 항목은 공유
해답 보기
push는 [...todos, 새항목], 찾아서 고치기는 map + 펼치기, splice는 filter로 바꿔요. 그러면 단계마다 다른 배열이 생겨 t0 · t1 · t2가 각자 그때의 상태로 남아요. 토글하지 않은 항목(id: 2)은 t1과 t2가 같은 객체를 함께 써서 마지막 줄이 true예요.
function todosReducer(todos, action) {
switch (action.type) {
case 'add':
return [...todos, { id: action.id, text: action.text, done: false }];
case 'toggle':
return todos.map(t => (t.id === action.id ? { ...t, done: !t.done } : t));
case 'remove':
return todos.filter(t => t.id !== action.id);
default:
return todos;
}
}
// 1 2 1
// false true
// false true
정리
- 화면 프레임워크는 이전 상태와 새 상태를
===로 비교해요. 직접 고치면 바뀐 걸 몰라요. - 객체는
{ ...이전, 속성: 새값 }, 안쪽을 바꾸면 그 단계도 펼쳐요. - 배열은
[...arr, x]·filter·map+ 펼치기 ·toSorted로 새로 만들어요. - 바꾸지 않은 부분은 새 상태와 이전 상태가 함께 써요. 깊은 상태는 평평하게 두거나 Immer를 써요.
state.items.push(item); setState(state);로는 왜 화면이 안 바뀔까요?
state가 이전과 같은 객체라서, ===로 비교하는 프레임워크는 바뀐 게 없다고 판단해요. setState({ ...state, items: [...state.items, item] })처럼 새 객체를 넘겨요.
const next = { ...state }; next.user.name = '가람';은 불변 업데이트일까요?
아니에요. 펼치기는 얕은 복사라 next.user와 state.user가 같은 객체예요. 원본의 이름까지 바뀌어요. { ...state, user: { ...state.user, name: '가람' } }처럼 바꾸는 단계까지 펼쳐요.
할 일 하나의 done만 바꿀 때 map 안에서 t.done = true; return t;로 쓰면 무엇이 문제일까요?
배열은 새것이지만 항목 객체는 원본을 직접 고쳐서, 이전 상태의 항목도 함께 바뀌어요. 바꾸는 항목은 { ...t, done: true }로 새로 만들어 돌려줘요.