14.1 Vue.js 소개 — Options API vs Composition API, Vue 3 주요 변경점
Vue.js란 무엇인가?
Vue.js 는 사용자 인터페이스를 구축하기 위한 진보적인 JavaScript 프레임워크입니다. "진보적(Progressive)"이라는 단어는 Vue를 기존 HTML 페이지에 점진적으로 도입할 수도 있고, 완전한 단일 페이지 애플리케이션(SPA)을 처음부터 구축하는 데도 사용할 수 있다는 의미입니다.
Vue는 Evan You가 2014년에 처음 발표했으며, 현재는 전 세계적으로 React, Angular와 함께 가장 인기 있는 프론트엔드 프레임워크 중 하나입니다.
Vue.js의 핵심 철학
| 특징 | 설명 |
|---|---|
| 반응형 데이터 바인딩 | 데이터가 바뀌면 UI가 자동으로 업데이트됨 |
| 컴포넌트 기반 | 재사용 가능한 독립적인 UI 단위로 개발 |
| 점진적 도입 | 기존 프로젝트에 부분적으로 적용 가능 |
| Single File Component(SFC) | .vue 파일에 템플릿, 스크립트, 스타일을 함께 작성 |
Vue 2 vs Vue 3 비교
Vue 3는 2020년에 정식 출시되었으며, Vue 2와 비교해 크게 개선되었습니다.
| 항목 | Vue 2 | Vue 3 |
|---|---|---|
| API 스타일 | Options API 위주 | Composition API 추가 |
| 성능 | 기준 | 최대 55% 렌더링 속도 향상 |
| 번들 크기 | ~20KB gzip | ~10KB gzip (tree-shaking 강화) |
| TypeScript | 제한적 지원 | 완전한 TypeScript 지원 |
| 다중 루트 요소 | 불가능 | Fragment 지원 (가능) |
| Teleport | 없음 | 내장 지원 |
| Suspense | 없음 | 실험적 지원 |
| v-model | .sync 수식어 | 다중 v-model 지원 |
Options API란?
Options API 는 Vue 2부터 사용해온 전통적인 방식입니다. 컴포넌트 로직을 data, methods, computed, watch 등 옵션(option) 으로 분류해 객체 안에 정의합니다.
기본 예제 — Options API
<!-- Counter.vue (Options API) -->
<template>
<div class="counter">
<h2>카운터: {{ count }}</h2>
<p>두 배 값: {{ doubled }}</p>
<button @click="increment">+1 증가</button>
<button @click="decrement">-1 감소</button>
<button @click="reset">초기화</button>
</div>
</template>
<script>
export default {
name: 'Counter',
// 반응형 데이터 정의
data() {
return {
count: 0,
}
},
// 계산된 속성
computed: {
doubled() {
return this.count * 2
},
},
// 메서드 정의
methods: {
increment() {
this.count++
},
decrement() {
this.count--
},
reset() {
this.count = 0
},
},
// 라이프사이클 훅
mounted() {
console.log('컴포넌트가 마운트되었습니다. 초기 count:', this.count)
},
// 데이터 감시
watch: {
count(newVal, oldVal) {
console.log(`count 변경: ${oldVal} → ${newVal}`)
},
},
}
</script>
<style scoped>
.counter {
padding: 1rem;
border: 1px solid #ddd;
border-radius: 8px;
}
button {
margin: 0.25rem;
padding: 0.5rem 1rem;
}
</style>
Options API는 구조가 명확해 처음 배우기 쉽다는 장점이 있습니다. 하지만 컴포넌트가 커질수록 같은 기능(feature) 과 관련된 코드가 data, computed, methods, watch 등 여러 곳에 흩어지게 되어 유지보수가 어려워집니다.
Composition API란?
Composition API 는 Vue 3에서 도입된 새로운 API 스타일입니다. 컴포넌트 로직을 옵션 블록이 아닌 함수(function) 단위로 구성합니다. React Hooks와 개념적으로 유사하지만 Vue만의 반응형 시스템 위에서 동작합니다.
기본 예제 — Composition API (setup() 함수 방식)
<!-- Counter.vue (Composition API, setup 함수) -->
<template>
<div class="counter">
<h2>카운터: {{ count }}</h2>
<p>두 배 값: {{ doubled }}</p>
<button @click="increment">+1 증가</button>
<button @click="decrement">-1 감소</button>
<button @click="reset">초기화</button>
</div>
</template>
<script>
import { ref, computed, watch, onMounted } from 'vue'
export default {
name: 'Counter',
setup() {
// ref: 단일 반응형 값
const count = ref(0)
// computed: 계산된 반응형 값
const doubled = computed(() => count.value * 2)
// 메서드는 일반 함수
const increment = () => { count.value++ }
const decrement = () => { count.value-- }
const reset = () => { count.value = 0 }
// 라이프사이클
onMounted(() => {
console.log('마운트됨. 초기 count:', count.value)
})
// watch
watch(count, (newVal, oldVal) => {
console.log(`count 변경: ${oldVal} → ${newVal}`)
})
// 템플릿에서 사용할 값과 함수를 반환
return { count, doubled, increment, decrement, reset }
},
}
</script>
기본 예제 — <script setup> 문법 (Vue 3.2+, 권장 방식)
<script setup>은 Composition API를 더 간결하게 작성할 수 있는 컴파일러 매크로입니다. return 없이도 템플릿에서 모든 변수와 함수를 사용할 수 있습니다.
<!-- Counter.vue (<script setup> 방식 — 현대적 표준) -->
<template>
<div class="counter">
<h2>카운터: {{ count }}</h2>
<p>두 배 값: {{ doubled }}</p>
<button @click="increment">+1 증가</button>
<button @click="decrement">-1 감소</button>
<button @click="reset">초기화</button>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
const count = ref(0)
const doubled = computed(() => count.value * 2)
const increment = () => { count.value++ }
const decrement = () => { count.value-- }
const reset = () => { count.value = 0 }
onMounted(() => {
console.log('마운트됨. 초기 count:', count.value)
})
watch(count, (newVal, oldVal) => {
console.log(`count 변경: ${oldVal} → ${newVal}`)
})
</script>
<style scoped>
.counter {
padding: 1rem;
border: 1px solid #ddd;
border-radius: 8px;
}
button {
margin: 0.25rem;
padding: 0.5rem 1rem;
}
</style>
Options API vs Composition API 심층 비교
코드 응집도 문제
아래는 "검색 + 페이지네이션" 기능을 가진 컴포넌트를 두 방식으로 구현했을 때 코드가 어떻게 분산되는지 보여주는 예시입니다.
Options API 방식 (같은 기능의 코드가 흩어짐):
┌──────────────────────────────────────┐
│ data() ← 검색어, 페이지 번호 등 │
│ computed ← 필터된 결과, 총 페이지수 │
│ methods ← 검색함수, 페이지이동함수 │
│ watch ← 검색어 변경 감지 │
└──────────────────────────────────────┘
↑ 기능 A의 코드가 4곳에 분산
Composition API 방식 (기능별 응집):
┌──────────────────────────────────────┐
│ // --- 검색 로직 --- │
│ const query = ref('') │
│ const results = computed(...) │
│ const search = () => { ... } │
│ watch(query, search) │
│ │
│ // --- 페이지네이션 로직 --- │
│ const page = ref(1) │
│ const totalPages = computed(...) │
│ const goToPage = (n) => { ... } │
└──────────────────────────────────────┘
↑ 기능별로 코드가 한 곳에 모임
컴포저블(Composable) — 로직 재사용
Composition API의 가장 큰 장점은 컴포저블 을 통해 상태 로직을 재사용할 수 있다는 점입니다.
// composables/useCounter.js — 재사용 가능한 카운터 로직
import { ref, computed } from 'vue'
export function useCounter(initialValue = 0) {
const count = ref(initialValue)
const doubled = computed(() => count.value * 2)
const increment = () => { count.value++ }
const decrement = () => { count.value-- }
const reset = () => { count.value = initialValue }
return { count, doubled, increment, decrement, reset }
}
<!-- 어떤 컴포넌트에서든 재사용 가능 -->
<script setup>
import { useCounter } from '@/composables/useCounter'
const { count, doubled, increment, decrement, reset } = useCounter(10)
</script>
Vue 3 주요 변경점 정리
1. createApp() API 변경
// Vue 2
import Vue from 'vue'
import App from './App.vue'
new Vue({ render: h => h(App) }).$mount('#app')
// Vue 3
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')
2. 전역 API 트리 쉐이킹(Tree-shaking)
Vue 3에서는 nextTick, ref, computed 등을 명시적으로 임포트해 번들 크기를 줄입니다.
// Vue 2 — 전역 Vue 객체 사용
Vue.nextTick(() => { /* ... */ })
// Vue 3 — 명시적 임포트
import { nextTick } from 'vue'
nextTick(() => { /* ... */ })
3. v-model 변경
<!-- Vue 2 -->
<MyComponent :value="title" @input="title = $event" />
<!-- 또는 -->
<MyComponent v-model="title" />
<!-- Vue 3: v-model의 기본 prop이 'modelValue'로 변경 -->
<MyComponent v-model="title" />
<!-- 또한 다중 v-model 지원 -->
<MyComponent v-model:title="title" v-model:content="content" />
4. Fragment(다중 루트 요소)
<!-- Vue 2: 루트 요소가 반드시 하나여야 함 -->
<template>
<div> <!-- 필수 래퍼 -->
<h1>제목</h1>
<p>내용</p>
</div>
</template>
<!-- Vue 3: 루트 요소가 여러 개 가능 (Fragment) -->
<template>
<h1>제목</h1>
<p>내용</p>
</template>
5. Teleport
<!-- 컴포넌트는 깊은 곳에 있어도, DOM은 body에 직접 렌더링 -->
<template>
<button @click="open = true">모달 열기</button>
<Teleport to="body">
<div v-if="open" class="modal">
<p>모달 내용</p>
<button @click="open = false">닫기</button>
</div>
</Teleport>
</template>
<script setup>
import { ref } from 'vue'
const open = ref(false)
</script>
6. Emits 옵션
<script setup>
// Vue 3에서 emit할 이벤트를 명시적으로 선언 (타입 안전성 + IDE 자동완성)
const emit = defineEmits(['change', 'submit'])
const handleChange = (value) => {
emit('change', value)
}
</script>
실전 예제 — Options API와 Composition API 나란히 비교
동일한 "할 일 목록(Todo List)" 컴포넌트를 두 방식으로 작성합니다.
Options API 버전
<template>
<div>
<input v-model="newTodo" @keyup.enter="addTodo" placeholder="할 일 입력..." />
<button @click="addTodo">추가</button>
<ul>
<li v-for="todo in filteredTodos" :key="todo.id">
<input type="checkbox" v-model="todo.done" />
<span :class="{ done: todo.done }">{{ todo.text }}</span>
<button @click="removeTodo(todo.id)">삭제</button>
</li>
</ul>
<p>완료: {{ doneCount }} / 전체: {{ todos.length }}</p>
<button @click="filter = 'all'">전체</button>
<button @click="filter = 'active'">미완료</button>
<button @click="filter = 'done'">완료</button>
</div>
</template>
<script>
export default {
data() {
return {
newTodo: '',
filter: 'all',
todos: [
{ id: 1, text: 'Vue 3 배우기', done: false },
{ id: 2, text: 'Composition API 익히기', done: false },
],
}
},
computed: {
doneCount() {
return this.todos.filter(t => t.done).length
},
filteredTodos() {
if (this.filter === 'active') return this.todos.filter(t => !t.done)
if (this.filter === 'done') return this.todos.filter(t => t.done)
return this.todos
},
},
methods: {
addTodo() {
if (!this.newTodo.trim()) return
this.todos.push({ id: Date.now(), text: this.newTodo.trim(), done: false })
this.newTodo = ''
},
removeTodo(id) {
this.todos = this.todos.filter(t => t.id !== id)
},
},
}
</script>
Composition API (<script setup>) 버전
<template>
<div>
<input v-model="newTodo" @keyup.enter="addTodo" placeholder="할 일 입력..." />
<button @click="addTodo">추가</button>
<ul>
<li v-for="todo in filteredTodos" :key="todo.id">
<input type="checkbox" v-model="todo.done" />
<span :class="{ done: todo.done }">{{ todo.text }}</span>
<button @click="removeTodo(todo.id)">삭제</button>
</li>
</ul>
<p>완료: {{ doneCount }} / 전체: {{ todos.length }}</p>
<button @click="filter = 'all'">전체</button>
<button @click="filter = 'active'">미완료</button>
<button @click="filter = 'done'">완료</button>
</div>
</template>
<script setup>
import { ref, reactive, computed } from 'vue'
const newTodo = ref('')
const filter = ref('all')
const todos = reactive([
{ id: 1, text: 'Vue 3 배우기', done: false },
{ id: 2, text: 'Composition API 익히기', done: false },
])
const doneCount = computed(() => todos.filter(t => t.done).length)
const filteredTodos = computed(() => {
if (filter.value === 'active') return todos.filter(t => !t.done)
if (filter.value === 'done') return todos.filter(t => t.done)
return todos
})
const addTodo = () => {
if (!newTodo.value.trim()) return
todos.push({ id: Date.now(), text: newTodo.value.trim(), done: false })
newTodo.value = ''
}
const removeTodo = (id) => {
const idx = todos.findIndex(t => t.id === id)
if (idx !== -1) todos.splice(idx, 1)
}
</script>
<style scoped>
.done { text-decoration: line-through; color: #999; }
</style>
고수 팁
언제 Options API를, 언제 Composition API를 써야 할까?
- Options API: Vue 2에서 마이그레이션하는 기존 프로젝트, 소규모 컴포넌트, Vue 입문자 교육용
- Composition API: 새로운 Vue 3 프로젝트 전반, 복잡한 상태 로직이 있는 컴포넌트, 로직 재사용이 중요한 경우, TypeScript를 사용하는 경우
Vue 공식 팀은 새 프로젝트에 Composition API +
<script setup>조합을 권장합니다.
Vue 3.5의 새로운 기능들
<script setup>
// Vue 3.5: useTemplateRef — 타입 안전한 템플릿 ref
import { useTemplateRef, onMounted } from 'vue'
const inputEl = useTemplateRef('myInput')
onMounted(() => {
inputEl.value?.focus()
})
</script>
<template>
<input ref="myInput" type="text" />
</template>
<script setup>
// Vue 3.5: defineProps의 구조분해 할당 (반응형 유지)
const { title, count = 0 } = defineProps(['title', 'count'])
// ↑ Vue 3.5 이전에는 반응형이 깨졌지만, 이제 완전히 지원
</script>
Mixins vs 컴포저블
// ❌ Vue 2의 Mixin — 출처 불명확, 이름 충돌 위험
const searchMixin = {
data() { return { query: '' } },
methods: { search() { /* ... */ } }
}
// ✅ Vue 3의 Composable — 명시적이고 타입 안전
// composables/useSearch.js
export function useSearch() {
const query = ref('')
const search = () => { /* ... */ }
return { query, search }
}
TypeScript와 함께 사용
<script setup lang="ts">
import { ref, computed } from 'vue'
interface Todo {
id: number
text: string
done: boolean
}
const todos = ref<Todo[]>([])
const doneCount = computed<number>(() => todos.value.filter(t => t.done).length)
const addTodo = (text: string): void => {
todos.value.push({ id: Date.now(), text, done: false })
}
</script>
Vue 3는 TypeScript로 완전히 재작성되었기 때문에, lang="ts"를 추가하는 것만으로 훌륭한 타입 추론을 제공합니다.