상황
레거시 Vue 3 프로젝트에서 규칙 생성 화면을 손보던 중이었다.
유저가 라디오 버튼을 클릭해도 저장 시 서버로 나가는 payload에는 초기값만 찍혀 있는 상황이었다.
컴포넌트는 대충 이런 구조였다.
<template>
<input type="radio" :value="true" v-model="options.runAlways" />
</template>
<script setup lang="ts">
const props = defineProps<{ body: Body }>();
const options = ref<Options>(props.body?.options ?? { runAlways: false });
</script>
"아, local options ref가 별도 객체라서 스토어 반영이 안 되는 건가?" 라고 생각했다.
첫 번째 대응 — watch 5개 박기
스토어로 값을 밀어넣는 watch를 추가했다.
watch(
options,
() => {
ruleStore.setAlarmTask(props.id, { options: { ...options.value } });
},
{ deep: true }
);
이걸 알람 / 이메일 / SMS / 제어명령 / 외부서버 요청 5개 컴포넌트에 전부 추가했다. 뿌듯했다. "완벽하다."
그런데 수정이 끝나고 생각이 들었다. "근데 굳이 watch 필요했나?"
함정 1 — ref(obj)는 객체를 복제하지 않는다
const obj = { runAlways: false };
const myRef = ref(obj);
myRef.value.runAlways = true;
console.log(obj.runAlways); // true
ref(someObject)는 내부적으로 reactive()로 감싸 프록시를 만들지만, 결국 같은 메모리를 바라본다.
복제가 아니라 '반응형으로 감싸는 작업'이다.
그래서 ref(props.body.options)로 만든 local ref의 .value는 props.body.options와 같은 객체다.
한쪽을 바꾸면 다른 쪽도 바뀐다.
// 라디오 클릭 → v-model 동작
options.value.runAlways = true;
// 결과적으로...
props.body.options.runAlways === true; // ✅
store.task.body.options.runAlways === true; // ✅ (props가 store를 가리키므로)
watch 없이도 클릭 → 스토어 반영이 알아서 됐던 것이다. 반응형이 대신 일해줬다.
함정 2 — 진짜 버그는 || fallback이었다
원본 코드를 다시 살펴봤다.
const options = ref((props.body?.options || props.options) ?? { runAlways: false });
fallback이 2단으로 있었다.
첫 번째가 props.body.options (body 안쪽), 두 번째가 props.options (task 바깥쪽).
프로젝트 데이터 구조가 섞여 있어서 props.body.options가 undefined인 채로 두 번째 fallback으로 떨어지는 경우가 있었다. 그러면 local ref는 body 바깥 쪽 options를 감쌌고, 유저가 라디오를 클릭하면 그 잘못된 위치에 값이 반영됐다. 서버는 body 안쪽을 기대하니 당연히 못 읽고 기본값으로 처리했다.
즉, 버그는 "반응형 연결 문제"가 아니라 "데이터 위치 문제"였다. watch 추가는 오진이었다.
해결 방법
watch 대신, 스토어에 들어오고 나가는 지점에서 데이터 구조를 정규화했다.
- 로드 시: 서버에서
task.options가 task-level로 내려오면task.body.options로 옮기고 task-level은 제거 - 저장 시: 혹시 남아있는 task-level
options가 있으면 삭제 후 전송
이렇게 하면 local ref는 항상 props.body.options를 가리키게 되고, 반응형이 알아서 스토어까지 전파한다.
watch는 없어도 된다.
체크리스트 — "반응형이 안 되는 것 같다" 싶을 때
✓ local ref가 감싼 객체가 정말 원본 객체와 같은 참조인지 확인
✓ fallback ||이 여러 단 있다면, 어느 쪽으로 떨어지는지 런타임에 찍어보기
✓ ref.value = 새객체처럼 레퍼런스를 갈아끼우는 코드가 있는지 확인 (이럴 땐 원본 연결 끊김)
✓ 스토어 값을 콘솔이나 Vue Devtools로 먼저 확인 — 진짜 반영이 안 된 건지 아니면 엉뚱한 곳에 반영된 건지
교훈
ref(obj)는 원본 객체를 반응형 프록시로 감싸는 작업이지 복제가 아니다.
.value의 속성을 바꾸면 원본까지 자동 전파된다. 그래서 v-model로 객체 속성을 묶어두면, watch 없이도 스토어 반영이 된다.
"반응형이 안 되나?" 싶을 때 watch부터 박기 전에, 데이터가 어느 객체를 가리키고 있는지부터 확인하자. Vue 3 reactivity는 생각보다 똑똑하고, 과잉 방어 코드는 결국 가독성의 적이 된다.
'Dev > Vue' 카테고리의 다른 글
| [Vue3] canvas에 Figma 같은 정렬 가이드 만들기 - transform 위에서 정확히 그리는 법 (0) | 2026.05.08 |
|---|---|
| [Vue3] 캔버스 편집 모드 드래그가 자꾸 원위치로 튀는 이유 - reactive 재렌더와 jQuery UI 충돌 (0) | 2026.05.07 |
| #Vue #Generic 안녕하세요 Next에서 넘어왔습니다 (0) | 2026.03.06 |