Vue 3.5 신규 기능 파헤치기: Reactive Props Destructure와 useTemplateRef
안녕하세요 Lovefield입니다.
Vue 3.5가 정식으로 릴리스되면서 개발자 경험(DX)과 내부 런타임 성능 면에서 유의미한 변화가 있었습니다. 특히 그동안 수많은 논의와 실험을 거쳤던 Reactive Props Destructure(반응형 Props 구조 분해)가 안정화되어 기본 활성화되었고, 템플릿 참조를 다루는 방식인 useTemplateRef가 추가되었습니다.
이번 글에서는 Vue 3.5에서 달라진 핵심 기능들의 동작 원리와 실무에서 어떻게 적용할 수 있는지 정리해 보려 합니다.
Reactive Props Destructure
기존 방식의 아쉬운 점
Vue 3 Composition API에서 <script setup>을 사용할 때 가장 번거로웠던 부분 중 하나는 defineProps로 받아온 props를 구조 분해 할당(Destructuring)하면 반응성을 잃어버린다는 점이었습니다.
TypeScript
1
2
3
4
5
6
// ❌ Vue 3.4 이전: 반응성 유실
const { count, title } = defineProps<{ count: number; title?: string }>();
watch(() => count, () => {
// 부모가 전달한 count prop이 바뀌어도 이 콜백은 반응하지 않습니다.
});이 때문에 템플릿이나 스크립트 전반에 걸쳐 props.count 형태로 항상 네임스페이스를 붙여야 했고, 구조 분해를 위해 toRefs(props)를 사용해야만 했습니다. 한때 실험적 매크로로 논의되었던 Reactivity Transform($ref)은 표준 자바스크립트 문법과의 괴리감으로 인해 결국 폐기(Deprecated)되었습니다.
Vue 3.5: 컴파일러 기반 Getter 변환
Vue 3.5부터는 추가 설정 없이 defineProps를 바로 구조 분해 할당해도 반응성이 온전히 유지됩니다. 기본값(Default values) 할당 역시 자바스크립트 표준 디스트럭처링 문법으로 자연스럽게 작성할 수 있습니다.
Vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
<script setup lang="ts">
import { watch, computed } from 'vue';
interface Props {
count: number;
title?: string;
items?: string[];
}
// ✅ 구조 분해 할당과 기본값 정의를 동시에 수행
const {
count,
title = 'Default Title',
items = () => ['Vue', 'Nuxt']
} = defineProps<Props>();
// 1. watch에서 변수를 그대로 감시 가능
watch(() => count, (newCount) => {
console.log(`count changed: ${newCount}`);
});
// 2. computed 내부에서도 반응형 의존성이 자동으로 수집됨
const doubleCount = computed(() => count * 2);
</script>
<template>
<div class="counter-box">
<h2>{{ title }}</h2>
<p>Count: {{ count }} / Double: {{ doubleCount }}</p>
<ul>
<li v-for="item in items" :key="item">{{ item }}</li>
</ul>
</div>
</template>
컴파일러 내부 변환 원리
마법처럼 보이지만 자바스크립트 엔진 차원에서 원시값을 감시하는 것이 아닙니다. SFC 컴파일러(@vue/compiler-sfc)가 빌드 시점에 스코프 내 count 변수 접근을 __props.count 접근 형태(getter)로 자동 치환합니다.
JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 컴파일 결과 (개념적 코드)
export default {
setup(__props) {
_watch(() => __props.count, (newCount) => {
console.log(`count changed: ${newCount}`);
});
const doubleCount = _computed(() => __props.count * 2);
return (_ctx, _cache) => {
return _createVNode("p", null, `Count: ${__props.count}`);
};
}
}주의해야 할 점: 함수 인자로 넘길 때
구조 분해된 변수는 내부적으로 getter로 작동하기 때문에, 외부 유틸리티 함수 등에 매개변수로 직접 전달하면 원시값(Primitive)으로 평가되어 반응형 연결이 끊어집니다.
TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
function logValue(val: number) {
// 전달된 시점의 정적 숫자값만 유지됩니다.
console.log(val);
}
// ⚠️ 반응형 추적이 유지되지 않음
logValue(count);
// ✅ 반응형 유지가 필요하다면 getter 함수 형태로 감싸서 전달
function logReactiveValue(getVal: () => number) {
watch(getVal, (v) => console.log(v));
}
logReactiveValue(() => count);useTemplateRef
기존 네이밍 매칭 방식의 한계
기존 Vue 3에서는 템플릿의 ref 속성에 작성한 문자열과 <script setup>에 선언된 ref() 변수명이 같아야만 바인딩되는 관례(Naming convention)를 사용했습니다.
Vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!-- 이전 방식 -->
<script setup lang="ts">
import { ref, onMounted } from 'vue';
// 템플릿의 ref 속성값과 정확히 변수명이 같아야 함
const inputEl = ref<HTMLInputElement | null>(null);
onMounted(() => {
inputEl.value?.focus();
});
</script>
<template>
<input ref="inputEl" type="text" />
</template>
이 방식은 HTML 템플릿의 문자열과 스크립트 변수 사이의 결합이 느슨하여, 템플릿의 이름을 바꾸었을 때 컴파일 에러가 나지 않아 실수가 발생하기 쉬웠습니다.
useTemplateRef를 통한 명시적 바인딩
Vue 3.5에서 추가된 useTemplateRef는 참조할 대상을 문자열 식별자로 전달하며, 반환된 값은 해당 DOM 엘리먼트 또는 컴포넌트 인스턴스 타입을 안전하게 가집니다.
Vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<script setup lang="ts">
import { useTemplateRef, onMounted } from 'vue';
// ref 문자열 식별자를 명시적으로 전달
const searchInput = useTemplateRef<HTMLInputElement>('customInput');
onMounted(() => {
// searchInput.value는 HTMLInputElement | null 타입으로 안전하게 추론
searchInput.value?.focus();
});
</script>
<template>
<input ref="customInput" type="text" placeholder="검색어를 입력해 주세요" />
</template>
자식 컴포넌트 인스턴스를 참조할 때도 InstanceType<typeof MyModal>과 함께 사용하면 defineExpose로 노출된 메서드를 타입 안전하게 호출할 수 있습니다.
Vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<script setup lang="ts">
import { useTemplateRef } from 'vue';
import ConfirmModal from './ConfirmModal.vue';
const modalRef = useTemplateRef<InstanceType<typeof ConfirmModal>>('confirmModal');
const handleOpen = () => {
modalRef.value?.open();
};
</script>
<template>
<ConfirmModal ref="confirmModal" />
<button @click="handleOpen">열기</button>
</template>
내부 반응성 코어 최적화
이번 3.5 버전은 새로운 API 도입뿐만 아니라 내부 반응성 엔진의 대대적인 리팩터링이 진행되었습니다.
메모리 사용량 약 56% 감소: 반응형 의존성을 추적하는 자료구조를 더블 링크드 리스트(Double linked list) 기반으로 개편하여, 대규모 데이터셋을 다룰 때 메모리 점유율을 크게 낮췄습니다.
불필요한 Computed 재연산 방지: 의존성 그래프 순회 방식을 개선해 변경되지 않은 하위 computed 프로퍼티의 재연산을 방지했습니다.
지연 하이드레이션(Lazy Hydration): defineAsyncComponent에서 hydrate 옵션을 활용하여 뷰포트에 진입했을 때 수화(Hydration)를 진행하는 등 SSR 환경에서의 초기 렌더링 부하를 줄일 수 있는 옵션들이 보강되었습니다.
정리하며
Vue 3.5는 문법적으로 번거로웠던 toRefs 및 네이밍 기반 템플릿 ref 바인딩을 컴파일러 수준에서 깔끔하게 정돈해 주었습니다. 기존 프로젝트에서 Vue 3.5로 올릴 때 마이그레이션 부담이 거의 없으면서도 코드 가독성과 타입 안정성을 한층 높일 수 있으니, 점진적으로 교체해 보시는 것을 추천합니다.
해당 글은 AI를 통해 제작되었습니다.

실패가 허용되는 2년의 샌드박스, 'SK AIGEN' 1기 설명회 참관기
ETC
