Vue 2 → Vue 3 주요 변경사항
Composition API 제외한 핵심 변경사항 정리.
아키텍처 / 인스턴스
new Vue() → createApp()
Vue 2에서는
Vue.use(),Vue.component()등이 전역 Vue 생성자를 직접 변경해, 한 페이지에 여러 앱 인스턴스가 존재할 때 서로 영향을 주는 문제가 있었다. 앱 인스턴스 단위로 격리함으로써 이 문제를 해결했다.
// Vue 2
new Vue({ el: '#app', render: h => h(App) })
// Vue 3
import { createApp } from 'vue'
createApp(App).mount('#app')
- 전역 설정이 앱 인스턴스 단위로 격리됨
Vue.prototype.xxx→app.config.globalProperties.xxxVue.use()→app.use()Vue.component()→app.component()
템플릿 변경
Fragment (다중 루트 노드) 지원
불필요한 wrapper
<div>가 DOM 구조를 오염시키고, CSS Flexbox/Grid 레이아웃에 의도치 않은 영향을 주는 경우가 잦았다.
<!-- Vue 2: 반드시 단일 루트 -->
<template>
<div> <!-- 필수 wrapper -->
<h1>제목</h1>
<p>내용</p>
</div>
</template>
<!-- Vue 3: 다중 루트 OK -->
<template>
<h1>제목</h1>
<p>내용</p>
</template>
v-model 동작 변경
Vue 2에서는
v-model과.sync가 유사한 역할을 하면서 별도 문법으로 공존해 일관성이 없었다. Vue 3에서는 두 패턴을v-model:propName형태로 통합했다.
<!-- Vue 2 -->
<!-- 기본: value prop + input event -->
<MyInput v-model="text" />
<!-- .sync 수식어로 다중 바인딩 -->
<MyInput :title.sync="title" />
<!-- Vue 3 -->
<!-- 기본: modelValue prop + update:modelValue event -->
<MyInput v-model="text" />
<!-- 다중 v-model 지원, .sync 제거 -->
<MyInput v-model:title="title" v-model:content="content" />
v-if vs v-for 우선순위 변경
Vue 2의
v-for우선 동작은 직관에 반해 "왜 숨겨진 항목도 순회되지?"라는 혼란과 불필요한 렌더링을 유발했다. 대부분의 사용 의도에 맞게v-if를 먼저 평가하도록 변경했다.
<!-- Vue 2: v-for가 우선순위 높음 -->
<!-- Vue 3: v-if가 우선순위 높음 (같이 쓰는 건 여전히 비권장) -->
key 위치 변경 (<template v-for>)
<template v-for>에서 key를 자식에 분산 배치하는 방식은 어느 자식이 반복의 기준인지 불명확했다. 반복 단위인<template>자체에 key를 두는 것이 의미적으로 더 정확하다.
<!-- Vue 2: key를 자식에 -->
<template v-for="item in list">
<div :key="item.id">...</div>
</template>
<!-- Vue 3: key를 template에 -->
<template v-for="item in list" :key="item.id">
<div>...</div>
</template>
반응성 시스템
Vue 2의 한계 (Object.defineProperty)
Vue 2의
Object.defineProperty는 객체의 동적 속성 추가/삭제를 감지할 수 없는 근본적인 한계가 있었다. Vue 3에서Proxy로 교체하면서 이 제약이 사라졌다.
Vue 2는 초기화 시점에 data 객체를 순회하면서 각 속성에 getter/setter를 심는 방식이었다. 초기화 시점에 존재하지 않은 것은 감지 불가능하다는 근본적인 한계가 있었다.
// Vue 2 내부 동작 (개념적 표현)
function defineReactive(obj, key, value) {
Object.defineProperty(obj, key, {
get() { return value }, // 의존성 수집
set(newValue) { value = newValue } // 변경 감지 → 리렌더링 트리거
})
}
Vue 2에서 발생하던 실제 문제들
// ① 동적 속성 추가 → 감지 안 됨
this.user.age = 20 // ❌ 화면 갱신 안 됨
this.$set(this.user, 'age', 20) // ✅ 우회 방법
// ② 속성 삭제 → 감지 안 됨
delete this.user.name // ❌
this.$delete(this.user, 'name') // ✅ 우회 방법
// ③ 배열 인덱스 직접 수정 → 감지 안 됨
this.items[0] = newItem // ❌
this.$set(this.items, 0, newItem) // ✅ 우회 방법
this.items.length = 0 // ❌
this.items.splice(0) // ✅ 우회 방법
// ④ 초기 data가 깊은 중첩 구조라면 앱 시작 시 전부 재귀 순회 → 초기 로딩 비용
Vue 3: Proxy 기반으로 전환
Proxy는 객체 자체를 감싸는 래퍼로, 어떤 조작이든 중간에서 가로챌 수 있다.
// Vue 3 내부 동작 (개념적 표현)
function reactive(obj) {
return new Proxy(obj, {
get(target, key) {
track(target, key) // 의존성 수집
return target[key]
},
set(target, key, value) {
target[key] = value
trigger(target, key) // 변경 트리거
return true
},
deleteProperty(target, key) {
delete target[key]
trigger(target, key) // 삭제도 감지!
return true
}
})
}
실제 구현에서 얻은 편의성
// ① $set / $delete 완전히 사라짐 → 그냥 JS처럼 쓰면 됨
this.user.age = 20 // ✅
delete this.user.name // ✅
// ② 배열을 자유롭게 조작
this.items[0] = newItem // ✅
this.items.length = 0 // ✅
// ③ Map, Set도 반응성 지원
const state = reactive({ userMap: new Map(), tagSet: new Set() })
state.userMap.set('id_1', { name: '철수' }) // ✅ 감지됨
state.tagSet.add('vue3') // ✅ 감지됨
// ④ Lazy하게 동작 → 초기화 성능 향상
// 실제로 접근한 중첩 속성만 그때그때 Proxy로 감쌈
const state = reactive({ a: { b: { c: { d: '깊은 값' } } } })
// state.a.b에 접근하는 순간 b도 Proxy로 감쌈
// ⑤ 존재하지 않는 속성 접근도 감지
const state = reactive({})
watchEffect(() => { console.log(state.someKey) })
state.someKey = 'hello' // ✅ watchEffect 재실행됨
| 항목 | Vue 2 | Vue 3 |
|---|---|---|
| 동적 속성 추가 | $set 필수 | 그냥 할당 |
| 속성 삭제 | $delete 필수 | 그냥 delete |
| 배열 인덱스 수정 | $set or splice | 그냥 할당 |
| Map / Set | 미지원 | 지원 |
| 초기화 비용 | 전체 재귀 순회 | Lazy (접근 시 처리) |
| 미존재 속성 감지 | 불가 | 가능 |
핵심은 "Vue의 규칙을 따르는 코드"에서 → "그냥 자연스러운 JS 코드"로 바뀐 것이다.
Vue 2.7 Composition API — 반응성은 그대로, 구조는 Vue 3
Vue 2.7의 Composition API는 Vue 2의 기존 반응성 시스템 위에 올린 호환 레이어다. reactive(), ref() 같은 문법은 Vue 3와 동일하지만, 그 밑에서 돌아가는 엔진은 Vue 2(Object.defineProperty) 그대로다.
// Vue 2.7에서 이렇게 써도
const state = reactive({ count: 0 })
const num = ref(0)
// 내부적으로는 결국 이걸 거침
Object.defineProperty(state, 'count', { get, set })
반응성 한계는 그대로 존재
const state = reactive({})
state.newKey = 'value' // ❌ 감지 안 됨 → 새 객체로 교체해야 함
state.list[0] = 'item' // ❌ → splice 또는 새 배열 할당
// 안전한 우회 방법
state.user = { ...state.user, age: 20 } // ✅ 새 객체로 교체
그럼에도 Vue 2.7 Composition API가 가져다준 이점
Vue 3 마이그레이션 전, 시간이 부족한 상황에서 코드 스타일만이라도 Vue 3에 맞춰두기 위한 브릿지 역할을 했다.
// Vue 2 Options API: 같은 기능의 코드가 data/methods/watch에 분산
export default {
data() { return { mouseX: 0, mouseY: 0, userList: [], loading: false } },
methods: { onMouseMove() {...}, fetchUsers() {...} },
mounted() {
window.addEventListener('mousemove', this.onMouseMove)
this.fetchUsers()
},
beforeDestroy() { window.removeEventListener('mousemove', this.onMouseMove) }
}
// Vue 2.7 Composition API: 기능별로 묶어서 분리
function useMouse() {
const x = ref(0), y = ref(0)
const onMove = (e) => { x.value = e.clientX; y.value = e.clientY }
onMounted(() => window.addEventListener('mousemove', onMove))
onBeforeUnmount(() => window.removeEventListener('mousemove', onMove))
return { x, y }
}
function useUserList() {
const list = ref([])
const loading = ref(false)
const fetch = async () => {
loading.value = true
list.value = await api.getUsers()
loading.value = false
}
onMounted(fetch)
return { list, loading }
}
export default {
setup() {
return { ...useMouse(), ...useUserList() }
}
}
- 로직을 기능 단위로 분리 가능해져 Options API의 파편화 문제 해소
- 작성한 composable을 Vue 3에서 그대로 재사용 가능 (반응성 엣지케이스만 수정)
setup()기반으로 먼저 적응해두면 Vue 3 전환 시 변경 포인트가 최소화됨
Vue 2.7 Composition API의 진짜 이점은 반응성 개선이 아니라 "코드 구조의 Vue 3 선행 적응" 이었다.
Vue 2.7에서 명시적으로 미지원된 기능들
공식 릴리즈 노트 기준으로 Vue 2.7에서 명시적으로 포팅되지 않은 항목들이다.
❌ createApp() — Vue 2는 앱 인스턴스 격리 구조 없음
❌ <script setup> 내 top-level await — Vue 2는 비동기 컴포넌트 초기화 미지원
❌ 템플릿 표현식 내 TypeScript 문법 — Vue 2 파서와 호환 불가
❌ Reactivity Transform — 당시 실험적 기능으로 미포팅
❌ expose 옵션 (Options 컴포넌트) — <script setup>의 defineExpose()는 지원
반면 Vue 2.7에서 공식 backport된 기능들
✅ Composition API (reactive, ref, computed, watch 등)
✅ <script setup>
✅ CSS v-bind() — style 블록에서 반응성 변수 바인딩 가능
✅ defineComponent() — Vue.extend 대비 향상된 타입 추론
✅ emits 옵션 — 런타임 동작은 없고 타입 체크 용도로만 지원
getCurrentInstance() 구조 차이
// Vue 3
const instance = getCurrentInstance()
instance.proxy
instance.emit(...)
// Vue 2.7: Vue 2 내부 구조 기반이라 반환 객체 구조가 다름
// composable 내부에서 getCurrentInstance()를 직접 쓰는 코드는
// 마이그레이션 시 수정 필요
| 항목 | Vue 2.7 + Composition API | Vue 3 |
|---|---|---|
| 반응성 엔진 | Object.defineProperty | Proxy |
| 동적 속성 추가 | ❌ 감지 안 됨 | ✅ |
| 배열 인덱스 수정 | ❌ | ✅ |
| Map / Set 반응성 | ❌ | ✅ |
CSS v-bind() | ✅ 지원 (공식 backport) | ✅ |
<script setup> | ✅ 지원 | ✅ |
| 템플릿 내 TS 문법 | ❌ 미지원 (파서 호환 불가) | ✅ |
top-level await | ❌ 미지원 | ✅ |
getCurrentInstance() | 구조 다름 | 표준 |
| Teleport / Suspense / Fragment | ❌ 미지원 | ✅ |
라이프사이클
destroy라는 단어가 데이터나 상태 자체를 파괴한다는 오해를 줄 수 있었다. 실제 동작은 컴포넌트가 DOM에서 분리(unmount) 되는 것이므로 이름을 의미에 맞게 수정했다.
| Vue 2 | Vue 3 Options API |
|---|---|
beforeCreate | beforeCreate |
created | created |
beforeMount | beforeMount |
mounted | mounted |
beforeUpdate | beforeUpdate |
updated | updated |
beforeDestroy | beforeUnmount |
destroyed | unmounted |
이벤트 관련
$on, $off, $once 제거
EventBus 패턴은 컴포넌트 간 의존 관계를 불명확하게 만들고, 핸들러 해제를 빠뜨리면 메모리 누수로 이어지는 문제가 있었다. Pinia 같은 명시적 상태관리 방식으로 유도하기 위해 제거됐다.
// Vue 2: EventBus 패턴
const bus = new Vue()
bus.$on('event', handler)
bus.$emit('event', data)
// Vue 3: 완전 제거됨
// → mitt 같은 외부 라이브러리 사용하거나 Pinia/Vuex로 대체
import mitt from 'mitt'
const emitter = mitt()
$listeners 제거
$attrs와$listeners가 분리되어 있어 fallthrough 처리 시 두 가지를 동시에 신경 써야 하는 번거로움이 있었다.$attrs하나로 통합해 단순화했다.
// Vue 2
this.$listeners // 부모가 전달한 이벤트 핸들러들
// Vue 3: $attrs에 통합됨
this.$attrs // class, style 포함한 모든 fallthrough attributes + listeners
필터(Filter) 제거
필터는 템플릿 안에서만 동작해 JS 코드에서 재사용이 불가능했고,
computed나 메서드로 대체 가능한 기능이 중복 문법으로 존재하는 형태였다. 불필요한 개념을 제거해 API를 단순화했다.
<!-- Vue 2 -->
{{ price | currency }}
<!-- Vue 3: 필터 완전 제거 -->
<!-- computed나 메서드로 대체 -->
{{ formatCurrency(price) }}
컴포넌트
emits 옵션 명시 권장
Vue 2에서는 컴포넌트가 어떤 이벤트를 emit하는지 외부에서 알 방법이 없어 유지보수가 어려웠다. 또한 선언되지 않은 이벤트가 native DOM 이벤트와 충돌해 이중 호출 버그가 발생하는 경우도 있었다.
// Vue 3
export default {
emits: ['submit', 'cancel'], // 명시적 선언
methods: {
handleClick() {
this.$emit('submit', data)
}
}
}
- 선언 안 하면
$attrs로 fallthrough됨 (이중 호출 버그 원인)
Teleport (구 Portal)
모달, 툴팁 같은 UI가 부모 컴포넌트의 DOM 안에 렌더링되면
z-index나overflow: hidden등 CSS 문맥에 갇히는 문제가 빈번했다. 렌더링 위치를 논리적 컴포넌트 위치와 분리하기 위해 도입됐다.
<!-- Vue 3 신규 빌트인 -->
<Teleport to="body">
<Modal />
</Teleport>
<Suspense> 빌트인 추가
비동기 컴포넌트의 로딩/에러 상태를 각 컴포넌트마다 직접
v-if로 관리해야 했던 반복 작업을 선언적으로 처리할 수 있게 했다.
<Suspense>
<template #default><AsyncComponent /></template>
<template #fallback><Loading /></template>
</Suspense>
상태관리 / 라우터
| Vue 2 | Vue 3 | |
|---|---|---|
| 상태관리 | Vuex 3 | Pinia (공식 권장) or Vuex 4 |
| 라우터 | Vue Router 3 | Vue Router 4 |
Vue Router 4 주요 변경
new VueRouter()방식이createApp()의 팩토리 함수 패턴과 일관성이 없었다. Vue 3의 인스턴스 격리 철학에 맞추어 라우터도 동일한 패턴으로 통일했다.
// Vue 2
const router = new VueRouter({ routes })
// Vue 3
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(),
routes
})
*(와일드카드) 라우트 제거 →:pathMatch(.*)*로 대체$route.params에서 존재하지 않는 params 접근 시 경고
빌드 도구
| Vue 2 | Vue 3 | |
|---|---|---|
| 공식 빌드 | Vue CLI (webpack) | Vite |
| 성능 | 느린 HMR | 매우 빠른 HMR (ESM 기반) |
TypeScript
Vue 2는 타입 추론을 위해 복잡한 class 기반 문법이나 별도 데코레이터가 필요했고, 타입 정확도도 낮았다. Vue 3를 TypeScript로 재작성하면서 별도 설정 없이도 정확한 타입 추론이 가능해졌다.
- Vue 3는 TypeScript로 재작성되어 타입 지원이 대폭 향상
defineComponent()래퍼로 Options API에서도 타입 추론 개선- Vue 2의
vue-class-component방식은 공식적으로 deprecated (2023년 3월 공식 선언, GitHub 레포 "no longer actively maintained" 명시)
실무 핵심 요약
가장 실무에서 마주치는 핵심만 꼽으면:
v-model동작 변경 (가장 많이 헷갈림)- EventBus 패턴 제거 (
$on/$off) - 필터 제거
- Proxy 기반 반응성 (
$set불필요) - 라이프사이클 이름 변경 (
destroyed→unmounted)