blog

Vue deep watch에서 oldVal과 newVal이 같게 나오는 이유

Front-end / Vue#Vue#watch#반응성#deep

문제 상황

const selectBoxItems = ref([
  { label: 'A', isChecked: false },
  { label: 'B', isChecked: true },
]);

watch(
  selectBoxItems,
  (newVal, oldVal) => {
    console.log(oldVal, newVal);
  },
  { deep: true },
);

배열 안 객체의 isChecked만 바꿨더니 oldVal과 newVal이 똑같이 찍혔다.

원인

deep watch는 객체를 변경(mutate)했을 때 콜백을 호출하지만, 변경 전 값을 따로 복사해 두지 않는다. isChecked를 바꿔도 배열과 객체는 그대로이므로 oldVal과 newVal은 같은 객체를 가리킨다. Vue 공식 문서에도 "deep watcher가 mutation으로 트리거되면 새 값과 이전 값은 같은 객체"라고 명시되어 있다.

반대로 selectBoxItems.value = [...]처럼 배열 자체를 교체하면 두 값이 서로 다른 객체가 되어 정상적으로 비교된다.

해결

getter에서 깊은 복사본을 반환하면, 매번 새 객체가 만들어져 이전 값이 보존된다.

watch(
  () => JSON.parse(JSON.stringify(selectBoxItems.value)),
  (newVal, oldVal) => {
    const oldChecked = oldVal.find(({ isChecked }) => isChecked);
    const newChecked = newVal.find(({ isChecked }) => isChecked);
    console.log('이전 선택:', oldChecked, '현재 선택:', newChecked);
  },
);
  • JSON.stringify가 모든 중첩 속성을 읽으므로 의존성이 자동으로 수집된다. 이 경우 deep: true는 필요 없다.
  • structuredClone은 반응형 Proxy를 복제하지 못해 에러가 난다. 쓰려면 structuredClone(toRaw(...))처럼 원본 객체를 넘기되, 중첩 객체도 Proxy일 수 있다는 점에 주의한다. 날짜·Map 등이 없는 단순 데이터라면 JSON 방식이 간단하다.
  • 배열이 크면 변경마다 전체 복사 비용이 든다. 필요한 값만 뽑아 getter로 감시하는 방법이 더 가볍다.
watch(
  () => selectBoxItems.value.find(({ isChecked }) => isChecked)?.label,
  (newLabel, oldLabel) => {
    console.log(oldLabel, '→', newLabel);
  },
);

얕은 복사로는 안 되는 이유

() => [...selectBoxItems.value]는 배열은 새로 만들지만 내부 객체는 원본을 그대로 공유한다. isChecked가 바뀌면 이전 배열 안의 객체도 같이 바뀌어 있으므로 결국 같은 값이 찍힌다.