Vue 3: 반응성과 Composition API로 UI 만들기
화면을 만드는 자바스크립트 프레임워크를 고르다 보면 React의 JSX가 어색하게 느껴질 때가 있는데요. HTML을 쓰던 대로 쓰고, 값만 바꾸면 화면이 알아서 따라오면 좋겠다는 생각이 드는 거죠. 🤔
Vue는 바로 이런 감각을 살린 프레임워크입니다. 2020년 9월 18일에는 “One Piece”라는 이름으로 Vue 3가 정식 출시됐는데요. 2016년 Vue 2 이후 4년 만에 나온 메이저 버전으로, 코드베이스 전체를 TypeScript로 새로 쓰고 반응성(reactivity) 시스템과 컴포넌트 작성 방식까지 손봤습니다. 이번 글에서는 Vue 3 프로젝트를 띄우고 할 일 목록 앱을 직접 만들어 보면서 Vue의 기본기를 하나씩 익혀 보겠습니다.
Vue는 어떤 프레임워크인가요?
Vue는 Google에서 AngularJS를 쓰던 Evan You가 2014년에 공개한 UI 프레임워크입니다. 공식 사이트는 Vue를 “프로그레시브 프레임워크(progressive framework)“라고 소개하는데요. 기존 페이지에 스크립트 태그 하나로 붙여 일부만 동적으로 만들 수도 있고, 라우터와 상태 관리까지 갖춘 SPA(Single Page Application)로 키울 수도 있다는 뜻입니다. 필요한 만큼만 점진적으로 도입할 수 있다는 거죠.
Vue의 컴포넌트는 HTML 기반의 템플릿(template)으로 화면을 그립니다.
React처럼 자바스크립트 안에 마크업을 쓰는 대신, HTML 안에 {{ }} 보간과 v-if, v-for 같은 디렉티브(directive)를 섞어 쓰는 방식이에요.
HTML과 CSS에 익숙한 분이라면 진입 장벽이 낮게 느껴지는 이유가 여기에 있습니다.
Vue 3에서 달라진 점
Vue 3에서 가장 크게 바뀐 곳은 반응성 시스템입니다.
Vue 2는 Object.defineProperty()로 객체의 속성마다 getter와 setter를 심어서 값의 변화를 감지했는데요.
이 방식은 처음에 없던 속성을 나중에 추가하거나 배열의 인덱스로 값을 바꾸면 변화를 알아채지 못했습니다.
그래서 Vue 2에서는 Vue.set() 같은 별도의 API를 써야 했죠.
Vue 3는 ES6의 Proxy로 객체 전체를 감싸서 읽기와 쓰기를 가로챕니다.
속성 추가나 삭제, 배열 인덱스 변경까지 모두 감지되니 이런 예외를 신경 쓸 필요가 없어졌어요.
두 번째 변화는 Composition API입니다.
Vue 2의 Options API는 data, computed, methods, watch 같은 옵션별로 코드를 나눠 담았는데, 컴포넌트가 커지면 하나의 기능에 관련된 코드가 여러 옵션에 흩어지는 문제가 있었습니다.
Composition API는 기능 단위로 코드를 모으고, 그 로직을 함수로 뽑아 다른 컴포넌트에서 재사용할 수 있게 해 줍니다.
Options API도 여전히 지원하므로 기존 코드를 한꺼번에 바꿀 필요는 없습니다.
그 밖에도 루트 요소를 여러 개 둘 수 있는 프래그먼트(fragment), 컴포넌트의 일부를 DOM의 다른 위치에 그리는 <Teleport>, 쓰지 않는 기능을 번들에서 빼 주는 트리 셰이킹(tree shaking) 지원이 추가됐습니다.
프로젝트 만들기
예전에는 Vue CLI로 프로젝트를 만들었는데, 요즘 Vue 3 프로젝트는 공식 스캐폴딩 도구인 create-vue로 만듭니다. create-vue는 빌드 도구로 Vite를 쓰는데, Vite 역시 Evan You가 만든 도구라서 Vue와 궁합이 잘 맞습니다. Bun을 쓴다면 다음과 같이 TypeScript 프로젝트를 만듭니다.
bun create vue@latest vue-app --ts
┌ Vue.js - The Progressive JavaScript Framework
Scaffolding project in /tmp/vue-app...
│
└ Done. Now run:
cd vue-app
bun install
bun dev
npm을 쓴다면 다음 명령어로 같은 프로젝트를 만듭니다.
npm create가 어떤 원리로 동작하는지는 npm init과 npm create에서 자세히 다뤘어요.
npm create vue@latest vue-app -- --ts
--ts 옵션 없이 실행하면 라우터(Vue Router), 상태 관리(Pinia), 테스트 도구를 넣을지 대화형으로 물어봅니다.
처음에는 아무것도 고르지 않고 기본 구성으로 시작해도 충분합니다.
안내대로 의존성을 설치하고 개발 서버를 띄웁니다.
cd vue-app
bun install
bun run dev
VITE v8.3.4 ready in 418 ms
➜ Local: http://localhost:5173/
➜ Network: use --host to expose
➜ Vue DevTools: Open http://localhost:5173/__devtools__/ as a separate window
➜ Vue DevTools: Press Option(⌥)+Shift(⇧)+D in App to toggle the Vue DevTools
진입점인 src/main.ts를 열어 보면 createApp()으로 애플리케이션을 만들고 index.html의 #app 요소에 붙이는 코드가 있습니다.
import "./assets/main.css";
import { createApp } from "vue";
import App from "./App.vue";
createApp(App).mount("#app");
Vue 2에서는 new Vue()로 전역 인스턴스를 만들었는데, Vue 3에서는 createApp()이 앱마다 독립된 인스턴스를 돌려줍니다.
한 페이지에 Vue 앱을 여러 개 띄워도 플러그인이나 전역 설정이 서로 섞이지 않아요.
단일 파일 컴포넌트
.vue 확장자를 가진 파일을 단일 파일 컴포넌트(Single-File Component, 이하 SFC)라고 부릅니다.
SFC 하나에는 <script>, <template>, <style> 세 블록이 들어갑니다.
가장 간단한 카운터 컴포넌트를 만들어 볼까요?
<script setup lang="ts">
import { ref } from "vue";
const count = ref(0);
</script>
<template>
<button @click="count++">클릭: {{ count }}</button>
</template>
<style scoped>
button {
font-size: 1.25rem;
}
</style>
<script setup>은 Composition API를 가장 간결하게 쓰는 문법입니다.
이 블록에서 선언한 변수와 함수, 가져온 컴포넌트는 템플릿에서 바로 쓰면 돼요.
<style scoped>로 작성한 CSS는 이 컴포넌트의 요소에만 적용됩니다.
Vue가 빌드할 때 요소마다 고유한 속성을 붙이고 선택자를 그 속성으로 좁혀 주기 때문에, 다른 컴포넌트의 button에는 영향을 주지 않습니다.
ref로 반응형 상태 만들기
ref()는 값을 반응형 객체로 감싸서 돌려줍니다.
스크립트에서는 .value로 값을 읽고 쓰며, 템플릿에서는 Vue가 자동으로 풀어 주기 때문에 .value 없이 씁니다.
import { ref } from "vue";
const count = ref(0);
console.log(count.value); // 0
count.value++;
console.log(count.value); // 1
왜 숫자를 그대로 쓰지 않고 굳이 객체로 감쌀까요?
자바스크립트의 원시 값은 변수에 대입하는 순간 복사되기 때문에 Vue가 그 값의 변화를 추적할 방법이 없습니다.
그래서 값을 .value 속성을 가진 객체에 넣고, 이 속성을 읽을 때 “누가 이 값을 쓰는지”를 기록해 두고 쓸 때 “이 값을 쓰던 곳을 다시 그리라”고 알리는 것이죠.
React의 useState와 비교하면 차이가 분명해집니다.
React는 상태가 바뀌면 컴포넌트 함수 전체를 다시 실행하고 그 결과를 이전과 비교합니다.
반면 Vue의 <script setup>은 컴포넌트가 만들어질 때 한 번만 실행되고, 이후에는 바뀐 값을 쓰는 곳만 다시 그립니다.
덕분에 Vue에서는 리렌더링을 막으려고 useMemo처럼 값이나 함수를 메모이제이션(memoization)하는 일이 거의 없습니다.
객체를 다룰 때는 reactive()를 쓸 수도 있습니다.
다만 reactive()는 원시 값을 담을 수 없고, 구조 분해를 하면 반응성이 끊어지는 함정이 있어서 공식 문서도 ref()를 기본으로 쓰기를 권장합니다.
할 일 목록 만들기
이제 배운 내용을 바탕으로 할 일 목록 앱을 만들어 보겠습니다.
먼저 할 일 하나를 그리는 TodoItem 컴포넌트입니다.
<script setup lang="ts">
defineProps<{ text: string; done: boolean }>();
const emit = defineEmits<{ toggle: []; remove: [] }>();
</script>
<template>
<li>
<input type="checkbox" :checked="done" @change="emit('toggle')" />
<span :class="{ done }">{{ text }}</span>
<button @click="emit('remove')">삭제</button>
</li>
</template>
<style scoped>
.done {
text-decoration: line-through;
color: gray;
}
</style>
defineProps()는 부모에게서 받을 속성(props)을, defineEmits()는 부모에게 보낼 이벤트를 선언합니다.
둘 다 import 없이 쓰는 컴파일러 매크로인데요.
TypeScript의 타입 인자로 선언하면 부모가 잘못된 타입을 넘길 때 에디터가 바로 알려 줍니다.
Vue의 데이터 흐름은 단방향입니다.
자식은 받은 props를 직접 고치지 않고, 이벤트를 내보내서 부모에게 변경을 요청합니다.
체크박스를 눌렀을 때 done을 바꾸지 않고 toggle 이벤트만 보내는 이유가 여기에 있어요.
템플릿의 :checked는 v-bind:checked, @change는 v-on:change의 줄임말입니다.
v-bind는 속성에 자바스크립트 표현식을 연결하고, v-on은 DOM 이벤트에 핸들러를 연결합니다.
:class에 객체를 넘기면 값이 참인 키만 클래스로 붙어요.
다음은 이 컴포넌트를 쓰는 App.vue입니다.
<script setup lang="ts">
import { ref, computed, watch } from "vue";
import TodoItem from "./components/TodoItem.vue";
interface Todo {
id: number;
text: string;
done: boolean;
}
const newTodo = ref("");
const todos = ref<Todo[]>(JSON.parse(localStorage.getItem("todos") ?? "[]"));
const remaining = computed(
() => todos.value.filter((todo) => !todo.done).length,
);
function addTodo() {
const text = newTodo.value.trim();
if (!text) return;
todos.value.push({ id: Date.now(), text, done: false });
newTodo.value = "";
}
function removeTodo(id: number) {
todos.value = todos.value.filter((todo) => todo.id !== id);
}
watch(
todos,
(value) => localStorage.setItem("todos", JSON.stringify(value)),
{ deep: true },
);
</script>
<template>
<h1>할 일 목록</h1>
<form @submit.prevent="addTodo">
<input v-model="newTodo" placeholder="할 일을 입력하세요" />
<button>추가</button>
</form>
<p v-if="todos.length === 0">할 일이 없습니다.</p>
<ul v-else>
<TodoItem
v-for="todo in todos"
:key="todo.id"
:text="todo.text"
:done="todo.done"
@toggle="todo.done = !todo.done"
@remove="removeTodo(todo.id)"
/>
</ul>
<p>남은 할 일: {{ remaining }}개</p>
</template>
개발 서버가 떠 있는 브라우저에서 할 일을 몇 개 추가하고 하나를 체크해 보면 다음과 같이 동작합니다.

코드가 짧지만 Vue에서 자주 쓰는 기능이 거의 다 들어 있으니 하나씩 짚어 보겠습니다.
템플릿 디렉티브
v-model은 입력 요소와 상태를 양방향으로 묶습니다.
입력란에 글자를 치면 newTodo가 바뀌고, 코드에서 newTodo.value = ""로 비우면 입력란도 비워지죠.
React였다면 value와 onChange를 각각 연결해야 했을 일을 디렉티브 하나로 처리합니다.
@submit.prevent의 .prevent는 이벤트 수식어(modifier)입니다.
핸들러 안에서 event.preventDefault()를 호출하는 것과 같아서, 폼을 제출해도 페이지가 새로 고쳐지지 않아요.
이 밖에도 .stop, .once, 키보드 이벤트용 .enter 같은 수식어가 있습니다.
v-if와 v-else는 조건에 따라 요소를 그리거나 아예 DOM에서 빼냅니다.
자주 켜고 끄는 요소라면 CSS의 display만 바꾸는 v-show가 더 가볍습니다.
v-for는 배열을 돌면서 요소를 반복해서 그립니다.
이때 :key에 항목마다 고유한 값을 넘겨야 Vue가 항목의 추가나 삭제를 정확히 추적하는데요.
배열의 인덱스를 키로 쓰면 중간 항목을 지울 때 뒤 항목이 엉뚱한 상태를 물려받기도 하니, 예제처럼 고유한 id를 쓰는 습관을 들이는 것이 좋습니다.
computed와 watch
remaining처럼 다른 상태에서 계산되는 값은 computed()로 만듭니다.
computed()는 계산 결과를 캐시해 두었다가, 계산에 쓰인 todos가 바뀔 때만 다시 계산합니다.
템플릿에 todos.filter(...).length를 직접 써도 결과는 같지만, 그러면 화면을 다시 그릴 때마다 필터링이 반복됩니다.
watch()는 값이 바뀔 때 화면 갱신 말고 다른 일을 해야 할 때 씁니다.
예제에서는 할 일 목록이 바뀔 때마다 웹 스토리지인 localStorage에 저장해서, 새로 고침을 해도 목록이 남도록 했어요.
{ deep: true } 옵션을 빼먹기 쉬우니 주의해야 합니다.
watch()는 기본적으로 todos.value 자체가 다른 배열로 바뀔 때만 반응합니다.
그래서 이 옵션이 없으면 removeTodo()처럼 배열을 새로 대입할 때는 저장되지만, push()로 항목을 넣거나 todo.done을 바꿀 때는 저장되지 않습니다.
빌드하기
배포할 때는 build 스크립트로 정적 파일을 만듭니다.
TypeScript 프로젝트라면 vue-tsc로 .vue 파일까지 타입 검사를 한 뒤 Vite가 번들링합니다.
bun run build
$ run-p type-check "build-only {@}" --
$ vue-tsc --build
$ vite build
vite v8.3.4 building client environment for production...
transforming...
✓ 16 modules transformed.
rendering chunks...
computing gzip size...
dist/index.html 0.42 kB │ gzip: 0.28 kB
dist/assets/index-zBAjc6DH.css 0.10 kB │ gzip: 0.11 kB
dist/assets/index-BHpqBsu7.js 66.54 kB │ gzip: 26.35 kB
✓ built in 210ms
Vue 런타임을 포함한 전체 자바스크립트가 gzip 기준으로 26KB 정도입니다.
Vue 3는 트리 셰이킹을 지원해서 <Transition>이나 <Teleport>처럼 쓰지 않은 기능은 번들에 들어가지 않습니다.
dist 디렉터리에 생긴 파일은 브라우저에서 자바스크립트로 화면을 그리는 SPA입니다.
검색 엔진 노출이 중요하다면 서버에서 HTML을 미리 만들어 내려주는 방식도 고려해 볼 만한데요.
렌더링 방식별 차이는 SPA, SSG, SSR 비교에서 정리해 두었습니다.
마치며
지금까지 Vue 3 프로젝트를 띄우고 할 일 목록 앱을 만들면서 Vue를 처음부터 훑어봤습니다.
ref()와 computed()로 상태를 선언하고, 템플릿에서 디렉티브로 상태를 화면에 연결하고, props와 이벤트로 컴포넌트끼리 대화하는 것이 Vue 개발의 기본 흐름입니다.
컴파일러로 반응성을 구현하는 Svelte와 비교해 보면 Vue가 런타임 반응성으로 같은 문제를 어떻게 푸는지 더 잘 보입니다. 앱이 커져서 여러 페이지와 서버 렌더링이 필요해지면 Vue Router와 Pinia를 붙이거나, Vue 기반의 메타 프레임워크인 Nuxt로 넘어가 보세요.
더 자세한 내용은 Vue 공식 문서를 참고하세요.
This work is licensed under CC BY 4.0