본문으로 건너뛰기
기술스택 · stack/javascript/불변 업데이트: 상태를 바꾸지 않고 새로 만들기

JavaScript · 5장 자료구조와 자료형

불변 업데이트: 상태를 바꾸지 않고 새로 만들기

"값을 바꿨는데 화면이 안 바뀌어요"는 React · Vue · Svelte 같은 화면 프레임워크를 처음 쓸 때 가장 많이 묻는 질문 중 하나예요. 원인은 대개 같아요. 객체나 배열을 직접 고쳤기 때문이에요. 4장과 5장에서 배운 참조 · 펼치기 · 배열 메서드를 모아, 상태를 고치지 않고 새로 만드는 법을 정리해요.

#5.12

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인 것도 봐 두세요. 첫 번째에 고친 값이 화면 몰래 이미 상태 안에 들어가 있었어요. 직접 고치면 화면이 안 바뀔 뿐 아니라, 언제 무엇이 바뀌었는지 추적할 수 없게 돼요. 새로 만들면 이전 상태가 그대로 남아서, 되돌리기 · 변경 기록 · 비교가 쉬워져요.

①왼쪽은 직접 고친 경우예요. 이전 상태와 새 상태가 같은 객체를 가리켜서 === 비교가 true, 바뀐 걸 알 수 없어요. 오른쪽은 새로 만든 경우예요. 바깥 상자는 새것이라 비교가 false가 되고, 바꾸지 않은 tags 배열은 새 상태와 이전 상태가 함께 써요.

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 = truearr.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 }로 새로 만들어 돌려줘요.

SIMPLE · 기술스택 / JavaScript — 5장 자료구조와 자료형