본문으로 건너뛰기
Advertisement

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 2Vue 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"를 추가하는 것만으로 훌륭한 타입 추론을 제공합니다.

Advertisement