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.xxxapp.config.globalProperties.xxx
  • Vue.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 2Vue 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 awaitVue 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 APIVue 3
반응성 엔진Object.definePropertyProxy
동적 속성 추가❌ 감지 안 됨
배열 인덱스 수정
Map / Set 반응성
CSS v-bind()✅ 지원 (공식 backport)
<script setup>✅ 지원
템플릿 내 TS 문법❌ 미지원 (파서 호환 불가)
top-level await❌ 미지원
getCurrentInstance()구조 다름표준
Teleport / Suspense / Fragment❌ 미지원

라이프사이클

destroy라는 단어가 데이터나 상태 자체를 파괴한다는 오해를 줄 수 있었다. 실제 동작은 컴포넌트가 DOM에서 분리(unmount) 되는 것이므로 이름을 의미에 맞게 수정했다.

Vue 2Vue 3 Options API
beforeCreatebeforeCreate
createdcreated
beforeMountbeforeMount
mountedmounted
beforeUpdatebeforeUpdate
updatedupdated
beforeDestroybeforeUnmount
destroyedunmounted

이벤트 관련

$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-indexoverflow: hidden 등 CSS 문맥에 갇히는 문제가 빈번했다. 렌더링 위치를 논리적 컴포넌트 위치와 분리하기 위해 도입됐다.

<!-- Vue 3 신규 빌트인 -->
<Teleport to="body">
  <Modal />
</Teleport>

<Suspense> 빌트인 추가

비동기 컴포넌트의 로딩/에러 상태를 각 컴포넌트마다 직접 v-if로 관리해야 했던 반복 작업을 선언적으로 처리할 수 있게 했다.

<Suspense>
  <template #default><AsyncComponent /></template>
  <template #fallback><Loading /></template>
</Suspense>

상태관리 / 라우터

Vue 2Vue 3
상태관리Vuex 3Pinia (공식 권장) or Vuex 4
라우터Vue Router 3Vue 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 2Vue 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" 명시)

실무 핵심 요약

가장 실무에서 마주치는 핵심만 꼽으면:

  1. v-model 동작 변경 (가장 많이 헷갈림)
  2. EventBus 패턴 제거 ($on/$off)
  3. 필터 제거
  4. Proxy 기반 반응성 ($set 불필요)
  5. 라이프사이클 이름 변경 (destroyedunmounted)