본문으로 건너뛰기
개발 머꼬
개발 노트Vue
hohyeon.dev17

watch가 한 번도 실행되지 않아서 보니 감시 대상이 값이었던 문제

  • #Common Pitfall
  • #Engineering Note
  • #Vue

문제 발생

watch를 걸었는데 콜백이 한 번도 실행되지 않았습니다.

const state = reactive({ count: 0 });

watch(state.count, (count) => {
  console.log(`Count is: ${count}`);   // 영원히 실행되지 않음
});

오류도 경고도 없이 조용했습니다.

원인 분석

watch의 첫 인자는 반응형 소스여야 합니다. 공식 문서 기준으로 가능한 것은 ref(계산된 ref 포함), reactive 객체, getter 함수, 그리고 이들의 배열입니다.

state.count는 이 중 아무것도 아닙니다. 이미 평가되어 숫자 0이 넘어간 것입니다. 문서도 이 형태를 두고 "reactive 객체의 속성을 이렇게 감시할 수는 없다"고 명시합니다. 숫자에는 추적할 것이 없으므로 아무 일도 일어나지 않습니다.

해법은 getter로 감싸는 것입니다. getter 자체가 반응형 소스이고, 그 안에서 state.count에 접근하는 순간 Vue가 의존성을 기록합니다.

여기서 함께 알아둘 것이 세 가지 더 있습니다.

reactive 객체를 그대로 감시하면 암묵적으로 깊은 감시가 됩니다. 중첩 속성 변경에도 콜백이 실행되고, 이때 newValueoldValue같은 객체입니다. 이전 값을 비교하려던 코드가 항상 같다고 판단하는 원인입니다.

깊은 감시는 비쌉니다. 문서가 경고합니다 — 중첩된 모든 속성을 순회해야 하므로 큰 데이터 구조에서는 비용이 큽니다. Vue 3.5부터는 deep에 숫자를 줘서 순회 깊이를 제한할 수 있습니다.

watchEffect는 비동기에서 의존성을 놓칩니다. 문서 기준 watchEffect동기 실행 중에만 의존성을 추적하므로, await 이전에 접근한 속성만 추적됩니다.

해결 방안

  1. 속성은 getter로 감쌉니다.
watch(
  () => state.count,
  (count, previous) => {
    console.log(`Count is: ${count} (이전 ${previous})`);
  },
);
  1. 중첩 변경까지 봐야 하면 deep을 명시합니다. 암묵적 깊은 감시에 기대지 않으면 의도가 코드에 남습니다.
watch(() => state.filters, onFiltersChanged, { deep: true });
watch(() => state.tree, onTreeChanged, { deep: 2 });   // 3.5+
  1. 이전 값이 필요하면 원시 값이나 사본을 감시합니다. 객체를 그대로 감시하면 newValue === oldValue입니다.
watch(() => state.filters.keyword, (next, previous) => { /* 둘이 다름 */ });
  1. await 이후의 값을 추적해야 하면 watch를 씁니다.
// ❌ todoId 변경이 반영되지 않을 수 있음
watchEffect(async () => {
  const response = await fetch(`/api/${todoId.value}`);
  data.value = await response.json();
});

// ✅ 감시 대상이 명시적
watch(todoId, async (id) => {
  const response = await fetch(`/api/${id}`);
  data.value = await response.json();
});
  1. 갱신된 DOM을 읽어야 하면 flush: "post"입니다. 기본값에서는 콜백이 해당 컴포넌트의 DOM 갱신 전에 실행됩니다.
watch(source, callback, { flush: "post" });
// 또는
import { watchPostEffect } from "vue";
watchPostEffect(() => { /* Vue가 DOM을 갱신한 뒤 실행 */ });
  1. flush: "sync"는 신중하게 씁니다. 문서가 경고하듯 배칭이 없어 반응형 변경마다 매번 실행되므로, 배열처럼 짧은 시간에 여러 번 바뀌는 데이터에는 쓰지 않습니다.

  2. 파생 값이면 watch가 아니라 computed입니다. 다른 값을 계산해 두는 것이 목적이라면 감시자는 과합니다.

const total = computed(() => items.value.reduce((sum, item) => sum + item.price, 0));

공식 문서

마지막 수정

좋아요북마크

댓글0

아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.