React 개발자가 본 Vuejs
React 개발자가 본 Vuejs
들어가며
프론트엔드 개발자라면 한 번쯤 "Vue도 배워야 하나?" 하는 고민을 해봤을 것입니다. 특히 React 생태계에 익숙하다면 더욱 그렇죠. 저 역시 React를 주력으로 사용하는 개발자로서, Vue.js가 왜 여전히 많은 개발자들에게 선택받는지 궁금했습니다.
이 글에서는 2025년 기준 Vue 3의 핵심 개념과 동작 원리를 깊이 있게 다루고, React 개발자의 관점에서 Vue를 이해하는 방법을 공유하겠습니다.
Vue.js란 무엇인가?
Vue.js는 Evan You가 2014년에 만든 프로그레시브 JavaScript 프레임워크입니다. "프로그레시브"라는 단어가 핵심인데, 이는 프로젝트의 필요에 따라 점진적으로 도입할 수 있다는 의미입니다.
<!-- 가장 간단한 Vue 사용: CDN으로 부분적 도입 --> <div id="app">{{ message }}</div> <script src="https://unpkg.com/vue@3"></script> <script> const { createApp } = Vue; createApp({ data() { return { message: "Hello Vue!", }; }, }).mount("#app"); </script>
이렇게 jQuery처럼 일부 영역에만 사용할 수도 있고, Nuxt.js와 함께 풀스택 프레임워크로 확장할 수도 있습니다. React가 라이브러리로 시작해서 생태계가 확장된 것과 비슷하지만, Vue는 처음부터 "확장 가능한 코어"로 설계되었습니다.
Vue 3의 핵심 개념
1. 반응성 시스템 (Reactivity System)
Vue의 가장 강력한 특징은 반응성 시스템입니다. 데이터가 변경되면 자동으로 UI가 업데이트되는데, 이 과정이 매우 직관적입니다.
import { ref, reactive, computed } from "vue"; // ref: 원시값을 반응형으로 만들기 const count = ref(0); console.log(count.value); // 0 count.value++; // 자동으로 UI 업데이트 // reactive: 객체를 반응형으로 만들기 const state = reactive({ user: { name: "zeromountain", role: "Frontend Developer", }, posts: [], }); // computed: 파생 상태 생성 const userInfo = computed(() => { return `${state.user.name} (${state.user.role})`; });
React의 useState와 비교하면 흥미롭습니다.
// React const [count, setCount] = useState(0); setCount(count + 1); // setter 함수 필요 // Vue const count = ref(0); count.value++; // 직접 변경
Vue는 값을 직접 변경하는 방식이고, React는 불변성을 유지하며 setter를 사용합니다. 어느 쪽이 더 낫다기보다는 철학의 차이입니다.
2. 단일 파일 컴포넌트 (SFC)
Vue의 독특한 특징 중 하나가 .vue 확장자를 가진 단일 파일 컴포넌트입니다.
<template> <div class="user-card"> <img :src="user.avatar" :alt="user.name" /> <h3>{{ user.name }}</h3> <p>{{ user.bio }}</p> <button @click="followUser"> {{ isFollowing ? 'Unfollow' : 'Follow' }} </button> </div> </template> <script setup lang="ts"> import { ref, computed } from 'vue' interface User { name: string avatar: string bio: string } const props = defineProps<{ user: User }>() const isFollowing = ref(false) const followUser = () => { isFollowing.value = !isFollowing.value // API 호출 로직... } </script> <style scoped> .user-card { padding: 1.5rem; border-radius: 0.5rem; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); } .user-card img { width: 100%; border-radius: 50%; } </style>
<script setup>은 Vue 3.2에서 도입된 문법으로, Composition API를 더욱 간결하게 사용할 수 있게 합니다. TypeScript 지원도 훌륭해서, props와 emits에 타입을 쉽게 지정할 수 있습니다.
3. Composition API vs Options API
Vue 3의 가장 큰 변화는 Composition API의 도입입니다.
<!-- Options API (전통적 방식) --> <script> export default { data() { return { count: 0, message: 'Hello' } }, computed: { doubled() { return this.count * 2 } }, methods: { increment() { this.count++ } }, mounted() { console.log('Component mounted') } } </script> <!-- Composition API (Vue 3 권장) --> <script setup> import { ref, computed, onMounted } from 'vue' const count = ref(0) const message = ref('Hello') const doubled = computed(() => count.value * 2) const increment = () => { count.value++ } onMounted(() => { console.log('Component mounted') }) </script>
Composition API는 React Hooks와 매우 유사한 철학을 가지고 있습니다. 로직을 기능별로 그룹화할 수 있어 코드 재사용성과 가독성이 향상됩니다.
Vue의 동작 원리 깊이 파헤치기
1. Proxy 기반 반응성 시스템
Vue 3는 JavaScript의 Proxy를 사용해 반응성을 구현합니다. Vue 2의 Object.defineProperty 방식보다 훨씬 강력합니다.
// Vue 내부 동작 원리 (단순화) function reactive(target) { return new Proxy(target, { get(target, key, receiver) { // 의존성 추적 track(target, key); const result = Reflect.get(target, key, receiver); // 중첩된 객체도 반응형으로 if (typeof result === "object" && result !== null) { return reactive(result); } return result; }, set(target, key, value, receiver) { const oldValue = target[key]; const result = Reflect.set(target, key, value, receiver); // 값이 실제로 변경되었을 때만 업데이트 트리거 if (oldValue !== value) { trigger(target, key); } return result; }, }); }
이 방식의 장점은:
- 배열의 인덱스 변경 감지 가능
- 객체에 새 속성 추가/삭제 감지 가능
- Map, Set 같은 컬렉션 지원
- 더 나은 성능
2. Virtual DOM과 컴파일러 최적화
Vue도 React처럼 Virtual DOM을 사용하지만, 컴파일 타임 최적화가 더 공격적입니다.
<template> <div> <h1>{{ title }}</h1> <p>This is static content</p> <button @click="increment">{{ count }}</button> </div> </template>
Vue의 컴파일러는 이 템플릿을 분석해서:
- 정적 콘텐츠(
<p>태그)를 식별하고 "hoisting" - 동적 부분만 업데이트 대상으로 표시
- 최적화된 렌더 함수 생성
// 컴파일 결과 (내부적으로 생성되는 코드, 단순화) const _hoisted_1 = /*#__PURE__*/ _createElementVNode( "p", null, "This is static content", -1 ); function render(_ctx) { return ( _openBlock(), _createElementBlock("div", null, [ _createElementVNode("h1", null, _toDisplayString(_ctx.title), 1), _hoisted_1, // 정적 노드는 재사용 _createElementVNode( "button", { onClick: _ctx.increment, }, _toDisplayString(_ctx.count), 9, ["onClick"] ), ]) ); }
3. 의존성 추적 메커니즘
Vue의 반응성 시스템이 어떻게 "어떤 컴포넌트가 어떤 데이터에 의존하는지" 알 수 있을까요?
// 전역 변수로 현재 실행 중인 effect 추적 let activeEffect = null; // 의존성 저장소 const targetMap = new WeakMap(); function track(target, key) { if (!activeEffect) return; let depsMap = targetMap.get(target); if (!depsMap) { targetMap.set(target, (depsMap = new Map())); } let dep = depsMap.get(key); if (!dep) { depsMap.set(key, (dep = new Set())); } // 현재 effect를 이 속성의 의존성으로 등록 dep.add(activeEffect); } function trigger(target, key) { const depsMap = targetMap.get(target); if (!depsMap) return; const dep = depsMap.get(key); if (!dep) return; // 이 속성에 의존하는 모든 effect 실행 dep.forEach((effect) => effect()); } // 컴포넌트 렌더링은 effect로 래핑됨 function watchEffect(fn) { activeEffect = fn; fn(); // 실행하면서 의존성 수집 activeEffect = null; }
실전 예제: Todo 앱 만들기
이론만으로는 부족하니, 실제로 동작하는 Todo 앱을 만들어보겠습니다.
<template> <div class="todo-app"> <h1>Vue Todo App</h1> <form @submit.prevent="addTodo"> <input v-model="newTodo" placeholder="What needs to be done?" class="todo-input" /> <button type="submit">Add</button> </form> <div class="filters"> <button v-for="filter in filters" :key="filter" @click="currentFilter = filter" :class="{ active: currentFilter === filter }" > {{ filter }} </button> </div> <ul class="todo-list"> <li v-for="todo in filteredTodos" :key="todo.id" :class="{ completed: todo.completed }" > <input type="checkbox" v-model="todo.completed" @change="saveTodos" /> <span>{{ todo.text }}</span> <button @click="removeTodo(todo.id)">×</button> </li> </ul> <p class="stats"> {{ remainingCount }} items left </p> </div> </template> <script setup lang="ts"> import { ref, computed, onMounted } from 'vue' interface Todo { id: number text: string completed: boolean } type Filter = 'All' | 'Active' | 'Completed' const newTodo = ref('') const todos = ref<Todo[]>([]) const currentFilter = ref<Filter>('All') const filters: Filter[] = ['All', 'Active', 'Completed'] // Computed properties const filteredTodos = computed(() => { switch (currentFilter.value) { case 'Active': return todos.value.filter(todo => !todo.completed) case 'Completed': return todos.value.filter(todo => todo.completed) default: return todos.value } }) const remainingCount = computed(() => { return todos.value.filter(todo => !todo.completed).length }) // Methods const addTodo = () => { if (newTodo.value.trim()) { todos.value.push({ id: Date.now(), text: newTodo.value, completed: false }) newTodo.value = '' saveTodos() } } const removeTodo = (id: number) => { todos.value = todos.value.filter(todo => todo.id !== id) saveTodos() } const saveTodos = () => { localStorage.setItem('vue-todos', JSON.stringify(todos.value)) } // Lifecycle onMounted(() => { const saved = localStorage.getItem('vue-todos') if (saved) { todos.value = JSON.parse(saved) } }) </script> <style scoped> .todo-app { max-width: 600px; margin: 2rem auto; padding: 2rem; background: white; border-radius: 8px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); } .todo-input { width: 100%; padding: 0.75rem; font-size: 1rem; border: 2px solid #e0e0e0; border-radius: 4px; margin-bottom: 1rem; } .filters { display: flex; gap: 0.5rem; margin-bottom: 1rem; } .filters button { padding: 0.5rem 1rem; border: 1px solid #ddd; background: white; cursor: pointer; border-radius: 4px; } .filters button.active { background: #42b983; color: white; border-color: #42b983; } .todo-list { list-style: none; padding: 0; } .todo-list li { display: flex; align-items: center; gap: 0.5rem; padding: 0.75rem; border-bottom: 1px solid #e0e0e0; } .todo-list li.completed span { text-decoration: line-through; color: #999; } .stats { margin-top: 1rem; color: #666; } </style>
Composable: Vue의 Custom Hooks
React의 Custom Hooks처럼, Vue도 로직을 재사용 가능한 함수로 추출할 수 있습니다.
// composables/useFetch.ts import { ref, Ref } from "vue"; interface UseFetchReturn<T> { data: Ref<T | null>; error: Ref<Error | null>; loading: Ref<boolean>; refetch: () => Promise<void>; } export function useFetch<T>(url: string): UseFetchReturn<T> { const data = ref<T | null>(null); const error = ref<Error | null>(null); const loading = ref(false); const fetchData = async () => { loading.value = true; error.value = null; try { const response = await fetch(url); if (!response.ok) throw new Error("Network response was not ok"); data.value = await response.json(); } catch (e) { error.value = e as Error; } finally { loading.value = false; } }; fetchData(); return { data, error, loading, refetch: fetchData, }; }
사용 예시:
<script setup lang="ts"> import { useFetch } from '@/composables/useFetch' interface User { id: number name: string email: string } const { data: users, loading, error, refetch } = useFetch<User[]>( 'https://api.example.com/users' ) </script> <template> <div> <button @click="refetch">Refresh</button> <div v-if="loading">Loading...</div> <div v-else-if="error">Error: {{ error.message }}</div> <ul v-else> <li v-for="user in users" :key="user.id"> {{ user.name }} </li> </ul> </div> </template>
React vs Vue: 실무 관점 비교
상태 관리
// React (zustand) import create from "zustand"; const useStore = create((set) => ({ count: 0, increment: () => set((state) => ({ count: state.count + 1 })), })); // Vue (pinia) import { defineStore } from "pinia"; export const useCounterStore = defineStore("counter", { state: () => ({ count: 0, }), actions: { increment() { this.count++; }, }, });
폼 처리
<!-- Vue: v-model로 간단 --> <input v-model="email" type="email" /> <!-- React: controlled component --> <input value={email} onChange={(e) => setEmail(e.target.value)} type="email" />
Vue의 v-model은 양방향 바인딩을 제공해서 폼 처리가 매우 간단합니다. React는 명시적인 단방향 데이터 플로우를 선호하죠.
조건부 렌더링
<!-- Vue: 여러 디렉티브 제공 --> <div v-if="type === 'A'">A</div> <div v-else-if="type === 'B'">B</div> <div v-else>C</div> <div v-show="isVisible">Toggle visibility</div> <!-- React: JavaScript 표현식 --> {type === 'A' ? <div>A</div> : type === 'B' ? <div>B</div> : <div>C</div>} {isVisible && <div>Conditional</div>}
리스트 렌더링
<!-- Vue: v-for --> <li v-for="item in items" :key="item.id"> {{ item.name }} </li> <!-- React: map --> {items.map(item => ( <li key={item.id}>{item.name}</li> ))}
Vue 생태계 (2025년 기준)
주요 라이브러리
- Nuxt 3 : Next.js와 같은 메타 프레임워크, SSR/SSG 지원
- Pinia : 공식 상태 관리 라이브러리 (Vuex 후속)
- Vue Router : 공식 라우팅 라이브러리
- Vite : 초고속 빌드 도구 (Vue 창시자가 만듦)
- Vitest : Vite 기반 테스트 프레임워크
- VueUse : 유용한 Composition 함수 모음
개발 도구
# Vite로 Vue 프로젝트 생성 npm create vite@latest my-vue-app -- --template vue-ts # Nuxt 3 프로젝트 생성 npx nuxi@latest init my-nuxt-app
성능 최적화 팁
1. 컴포넌트 지연 로딩
// 라우트 레벨 코드 스플리팅 const routes = [ { path: "/dashboard", component: () => import("@/views/Dashboard.vue"), }, ]; // 컴포넌트 지연 로딩 import { defineAsyncComponent } from "vue"; const AsyncComp = defineAsyncComponent( () => import("./components/HeavyComponent.vue") );
2. v-memo로 메모이제이션
<template> <div v-memo="[item.id, item.value]"> <!-- item.id나 item.value가 변경될 때만 재렌더링 --> <ExpensiveComponent :item="item" /> </div> </template>
3. KeepAlive로 컴포넌트 캐싱
<template> <KeepAlive> <component :is="currentTab" /> </KeepAlive> </template>
마치며
Vue.js는 2025년 현재도 강력하고 현대적인 프론트엔드 프레임워크입니다. React와는 다른 철학을 가지고 있지만, 둘 다 훌륭한 선택지입니다.
Vue를 선택하면 좋은 경우:
- 빠른 프로토타이핑이 필요할 때
- 템플릿 기반 접근이 익숙할 때
- 공식 생태계가 통합된 환경을 선호할 때
- 점진적 마이그레이션이 필요할 때
React를 선택하면 좋은 경우:
- 대규모 생태계와 커뮤니티가 필요할 때
- JSX와 JavaScript 중심 개발을 선호할 때
- React Native로 모바일 확장을 고려할 때
- 더 많은 라이브러리 선택지가 필요할 때
개인적으로는 두 프레임워크 모두 잘 알아두면 프로젝트 특성에 맞게 유연하게 선택할 수 있어 좋다고 생각합니다.
추가 학습 자료
- Vue 3 공식 문서
- Vue Mastery - 비디오 강좌
- VueUse - Composition 유틸리티
- Nuxt 3 문서