저는 Vue를 주로 써왔는데 최근에 React로 프로젝트를 진행하면서 느꼈던 차이점을 이번에 정리해보려고 합니다! 비슷하면서도 꽤나 달랐던 부분이 많이 있었어요!
이번 글에서는 Vue의 Options API을 중심으로 작성했습니다. Vue3에서 추가된 Composition API도 있지만 명확한 차이점을 설명하기위해 Options API를 중심으로 설명해보겠습니다. 다음에 Vue에서 React로 변경할 때 복기하면 좋을 것 같습니다. ㅎ.ㅎ
가장 먼저 느꼈던 부분은 상태 관리 방식이였습니다. Vue에서는 당연하게 사용했던 직접 변경하는 방식이 React에서는 사용하면 안되는 패턴이였기 때문입니다.
export default {
data() {
return {
count: 0,
user: { name: "Vue", age: 20 },
items: []
}
},
methods: {
increment() {
this.count++; // 직접 변경 가능
},
updateUser() {
this.user.age++; // 중첩 객체도 직접 변경
},
addItem() {
this.items.push({ id: Date.now(), text: "새 아이템" }); // 배열 직접 변경
}
}
}
Vue는 반응형 시스템(Reactivity System) 시스템을 통해 데이터 변경을 자동으로 감지합니다.

Vue는 내부적으로 Proxy(Vue3) 또는 Object.defineProperty(Vue2)를 사용해서 데이터를 감지하고, 의존성 추적을 통해 필요한 부분만 효율적으로 업데이트합니다. 🔗 Vue 공식 문서 - 반응성 기본
function Counter() {
const [count, setCount] = useState(0);
const [user, setUser] = useState({ name: "React", age: 20 });
const [items, setItems] = useState([]);
const increment = () => {
setCount(count + 1); // 새로운 값으로 교체
};
const updateUser = () => {
setUser(prev => ({
...prev, // 스프레드 연산자로 복사
age: prev.age + 1
}));
};
const addItem = () => {
setItems(prev => [
...prev, // 기존 배열 복사
{ id: Date.now(), text: "새 아이템" }
]);
};
}

React는 불변성(Immutability)의 원칙을 따릅니다. 상태를 직접 변경하는게 아니라 새로운 상태 객체를 만들어서 교체함으로써 변화를 감지합니다. 🔗 React 공식 문서 - Updating Objects in State
각 프레임워크가 두는 가치! 철학이 달랐기 때문이지요!
Vue는 점진적 프레임워크(Progressive Framework)입니다. 개발자가 편하게 코딩할 수 있도록 프레임워크가 복잡한 일을 대신합니다. Vue 공식 문서 - 소개
React는 예측 가능하고 디버깅하기 쉬운 코드를 작성할 수 있도록 합니다. React 공식 문서 - Thinking in React
처음에는 Vue에서 복잡한 객체 업데이트를 간편하게 업데이트 할 수 있었기 때문에 간소화 되던 방식 React에서는 코드가 길어지는 경우도 종종 생겼었는데요. 그래도 React의 명시적인 상태 변경 방식이 디버깅과 상태 추적에 더 유리한 것 같습니다.
Vue에서는 this.user.profile.settings.theme = "dark" 처럼 직접 변경하면 코드 여러 곳에서 같은 객체를 수정할 수 있어서 어디 함수에서 이 값을 변경했지?하고 찾기가 어려울 때가 있었는데 React에서는 setState로 상태를 바꿀 수 있어서 변경 지점이 한정적이고 불변성을 지켜야 하기 때문에 이전 상태와 새로운 상태를 비교하기도 쉬웠습니다!
Vue는 컴포넌트 라이프사이클이 명확한 훅으로 나뉘어져 있는 반면에, React는 useEffect가 웬만한 역할을 담당하고 있습니다.
export default {
// 인스턴스 생성 직후 (data, methods 접근 불가)
beforeCreate() {
console.log("컴포넌트 인스턴스가 방금 생성됨");
console.log(this.count); // undefined
},
// 반응형 데이터 설정 완료 (DOM은 아직 없음)
created() {
console.log("데이터 초기화 완료, DOM은 아직 없음");
console.log(this.count); // 0 - 접근 가능!
this.fetchUserData(); // API 호출하기 좋은 시점
},
// 템플릿 컴파일 완료, DOM 마운트 직전
beforeMount() {
console.log("곧 DOM에 마운트됩니다");
console.log(this.$el); // undefined - 아직 DOM 없음
},
// DOM 마운트 완료
mounted() {
console.log("DOM 마운트 완료!");
console.log(this.$el); // DOM 요소 접근 가능
this.$refs.input.focus(); // DOM 조작하기 좋은 시점
this.initializeChart(); // 차트 라이브러리 초기화
},
// 데이터 변경으로 인한 DOM 업데이트 완료
updated() {
console.log("화면 업데이트 완료");
this.scrollToBottom(); // 업데이트 후 스크롤 조정
},
// 컴포넌트 제거 직전
beforeUnmount() {
console.log("정리 작업 시작");
clearInterval(this.timer);
this.chart.destroy(); // 외부 라이브러리 정리
window.removeEventListener("resize", this.handleResize);
},
// 컴포넌트 완전 제거
unmounted() {
console.log("컴포넌트가 완전히 제거됨");
}
}
각 단계의 역할이 명확하게 나눠져 있기 때문에 원하는 시점에 원하는 로직을 배치하기가 쉽습니다! Vue 공식 문서 - 라이프사이클 훅
function MyComponent() {
const [count, setCount] = useState(0);
const [userData, setUserData] = useState(null);
// 컴포넌트 마운트 시에만 실행 (Vue의 mounted와 유사)
useEffect(() => {
console.log("컴포넌트가 마운트됨");
fetchUserData().then(setUserData);
const timer = setInterval(() => {
console.log("1초마다 실행");
}, 1000);
// cleanup 함수 (Vue의 beforeUnmount와 유사)
return () => {
console.log("컴포넌트 언마운트 시 정리");
clearInterval(timer);
};
}, []); // 빈 의존성 배열 = 마운트 시에만
// 특정 값 변경 시에만 실행 (Vue의 watch와 유사)
useEffect(() => {
console.log("count가 변경됨:", count);
document.title = `Count: ${count}`;
}, [count]); // count가 변경될 때만
// 매 렌더링마다 실행 (Vue의 updated와 유사)
useEffect(() => {
console.log("컴포넌트가 렌더링됨");
scrollToBottom();
}); // 의존성 배열 없음 = 매번 실행
return <div>Count: {count}</div>;
}
React는 부수효과(Side Effect) 중심의 사고 방식을 가집니다!
처음에는 mounted 시점에 실행하려면 어떻게 해야하지? 라고 고민했지만, React에서는 언제 실행하지 보다는 "무엇 때문에 실행될지"에 집중하고 있다는 점을 이해하게 됬습니다.
하지만 의존성 배열을 빼먹어서 무한 루프에 빠진 경험이 몇 번 있었기 때문에 Vue의 watch 보다는 신경써야 부분이 좀 있다고 생각했습니다..!
Vue는 이해하기 쉬운 인스턴스 기반 상태를 사용합니다.
export default {
data() {
return {
count: 0,
name: "Vue",
items: []
} // 이 객체가 컴포넌트 인스턴스에 저장됨
},
methods: {
increment() {
this.count++; // this를 통해 인스턴스 속성에 접근
}
}
}
function Counter() {
const [count, setCount] = useState(0);
const increment = () => {
setCount(count + 1);
};
return (
<div>
<p>{count}</p>
<button onClick={increment}>증가</button>
</div>
);
}
함수가 실행되고 난 뒤에도 값을 기억하는 방식입니다!
React는 Fiber 아키텍처를 사용하여 각 컴포넌트의 상태를 관리합니다.
1. 컴포넌트마다 Fiber 노드가 생성됨
2. 각 Fiber 노드는 훅들의 연결 리스트를 가짐
3. useState 호출 순서에 따라 훅이 저장됨
4. 리렌더링 시 같은 순서로 훅을 읽어옴
// React 내부 개념 (실제 구현은 더 복잡)
const componentFiber = {
hooks: [
{ state: 0, setter: setCount }, // 첫 번째 useState
{ state: "hello", setter: setName } // 두 번째 useState
]
};
Vue 방식에 익숙해서 상태들을 직접 변경하려고 했던 이런 실수를 자주 했습니다.... 왜 변경이 감지가 안될까? 어디 부분을 잘못했나,,,, 헤메기도 했었습니다.
function Component() {
const [user, setUser] = useState({ name: "", age: 0 });
// 자주 실수 했던 방법
const updateUserName = (newName) => {
user.name = newName; // 직접 변경하면 안 됨
setUser(user); // 같은 객체 참조라서 변경 감지 안 됨
};
// 올바른 방법
const updateUserNameCorrect = (newName) => {
setUser(prev => ({
...prev,
name: newName
}));
};
}
<template>
<!-- Vue - 실제 DOM 이벤트를 그대로 사용 -->
<button @click="handleClick">클릭</button>
<input @keyup.enter="handleEnter" @input="handleInput" />
<div @click.stop="handleClick">이벤트 버블링 방지</div>
<form @submit.prevent="handleSubmit">폼 제출</form>
</template>
<script>
export default {
methods: {
handleClick(event) {
console.log(event); // 실제 MouseEvent 객체
console.log(event.target); // 실제 DOM 요소
// 브라우저 네이티브 메서드 사용 가능
event.stopPropagation();
event.preventDefault();
}
}
}
</script>
Vue의 이벤트 수식어(.stop, .prevent)가 생각보다 편한 점이였었습니다. React에서는 직접 정석대로 메서드를 호출해야 했습니다!
function MyComponent() {
const handleClick = (event) => {
console.log(event); // SyntheticEvent 객체 (네이티브 이벤트 아님!)
console.log(event.nativeEvent); // 실제 DOM 이벤트에 접근하려면 이렇게
console.log(event.target); // 실제 DOM 요소 (동일)
// SyntheticEvent도 같은 메서드 제공
event.preventDefault(); // 동일하게 동작
event.stopPropagation(); // 동일하게 동작
};
const handleInputSafe = (event) => {
// 안전한 방법: 필요한 값을 미리 추출
const value = event.target.value;
setTimeout(() => {
console.log(value); // 안전
}, 1000);
};
return (
<div>
<button onClick={handleClick}>클릭</button>
<input onChange={handleInputSafe} />
</div>
);
}
<template>
<!-- 조건부 렌더링 - HTML을 확장한 느낌 -->
<div v-if="user.isAdmin">관리자 메뉴</div>
<div v-else-if="user.isPremium">프리미엄 메뉴</div>
<div v-else>일반 메뉴</div>
<!-- 리스트 렌더링 - 반복문을 HTML로 표현 -->
<ul>
<li v-for="item in items" :key="item.id">
<span>{{ item.name }}</span>
<span v-if="item.isNew" class="badge">New!</span>
</li>
</ul>
</template>
HTML 내에 v-if, v-for 디렉티브가 있어 동작을 직관적으로 파악할 수 있습니다.
function MyComponent({ user, items }) {
return (
<div>
{/* 조건부 렌더링 - JavaScript 삼항 연산자 */}
{user.isAdmin ? (
<div>관리자 메뉴</div>
) : user.isPremium ? (
<div>프리미엄 메뉴</div>
) : (
<div>일반 메뉴</div>
)}
{/* 리스트 렌더링 - JavaScript map 함수 */}
<ul>
{items.map(item => (
<li key={item.id}>
<span>{item.name}</span>
{item.isNew && <span className="badge">New!</span>}
</li>
))}
</ul>
</div>
);
}
여기서도 각 사고방식의 차이가 있습니다.
개인적으로는 복잡한 조건부 렌더링인 경우 React 방식이 더 자유롭다고 생각했습니다. Vue에서는 v-if v-else-if체인이 길어지면 가독성이 떨어지고 복잡한 로직은 computed나 methods로 빼야할 경우가 종종 생기곤 하는데, React는 JSX 안에서 바로 함수 호출하거나 즉시 실행 또는 삼항 연산자 중첩 등을 자유롭게 사용할 수 있어서 그렇게 느꼈습니다!
<!-- 부모 컴포넌트 -->
<template>
<UserForm
:user-data="currentUser"
@user-updated="handleUserUpdate"
@validation-failed="handleValidationError"
/>
</template>
<script>
export default {
methods: {
handleUserUpdate(updatedUser) {
this.currentUser = updatedUser;
},
handleValidationError(errors) {
console.log("유효성 검사 실패:", errors);
}
}
}
</script>
<!-- 자식 컴포넌트 -->
<script>
export default {
props: ['userData'],
methods: {
submitForm() {
if (this.validate()) {
this.$emit('user-updated', this.localUser);
} else {
this.$emit('validation-failed', this.errors);
}
}
}
}
</script>
// 부모 컴포넌트
function App() {
const [currentUser, setCurrentUser] = useState({ name: "", email: "" });
const handleUserUpdate = (updatedUser) => {
setCurrentUser(updatedUser);
};
const handleValidationError = (errors) => {
console.log('유효성 검사 실패:', errors);
};
return (
<UserForm
userData={currentUser}
onUserUpdated={handleUserUpdate}
onValidationFailed={handleValidationError}
/>
);
}
// 자식 컴포넌트
function UserForm({ userData, onUserUpdated, onValidationFailed }) {
const submitForm = (e) => {
e.preventDefault();
if (validate()) {
onUserUpdated(localUser);
} else {
onValidationFailed(errors);
}
};
return <form onSubmit={submitForm}>...</form>;
}
처음에는 ! 자식 컴포넌트에서 값이 바뀔 때 어떻게 전달하지..? 라고 고민하기도 했습니다 ㅜ.ㅜ! React의 콜백 패턴에 익숙해지고 나니까 데이터가 어떻게 흘러가는지 추적하기가 더 쉬워진 것을 느꼈습니다. Vue의 emit은 편리하지만 컴포넌트가 커지거나,... 프로젝트가 커지게 되면 이벤트가 어디서 발생하는지 추적하기가 어렵고.. 파악하는데 시간이 많이 소요될 때가 있었는데요! React는 콜백 함수를 따라가보다보면 데이터 흐름을 바로 파악할 수 있었습니다!
<template>
<div>
<input ref="nameInput" v-model="name" />
<UserModal ref="userModal" :user="selectedUser" />
<button @click="focusInput">포커스</button>
<button @click="openModal">모달 열기</button>
</div>
</template>
<script>
export default {
methods: {
focusInput() {
this.$refs.nameInput.focus(); // 간단!
},
openModal() {
this.$refs.userModal.open(); // 자식 컴포넌트 메서드 직접 호출
}
}
}
</script>
Vue는 ref를 사용해 DOM 요소나 컴포넌트에 자유롭게 접근할 수 있습니다.
import { useRef, forwardRef, useImperativeHandle } from 'react';
function App() {
const nameInputRef = useRef(null);
const userModalRef = useRef(null);
const focusInput = () => {
nameInputRef.current?.focus(); // .current를 항상 붙여야 함
};
const openModal = () => {
userModalRef.current?.open();
};
return (
<div>
<input ref={nameInputRef} />
<UserModal ref={userModalRef} />
<button onClick={focusInput}>포커스</button>
<button onClick={openModal}>모달 열기</button>
</div>
);
}
// 자식 컴포넌트에서 ref를 받으려면 forwardRef 필요
const UserModal = forwardRef((props, ref) => {
const [isOpen, setIsOpen] = useState(false);
// useImperativeHandle로 외부에 노출할 메서드 정의
useImperativeHandle(ref, () => ({
open: () => setIsOpen(true),
close: () => setIsOpen(false)
}));
return isOpen ? <div className="modal">...</div> : null;
});
useImperativeHandle을 통해 외부에 노출할 메서드를 명시적으로 정의하도록 강제합니다.React에서는 ref 보다는 상태와 props로 대부분의 문제를 해결하는 것을 권장하더군요..! 그래서 ref를 많이 사용하지 않는 방식으로 개발하게 되면서 이런 제약이 오히려 코드를 더 깔끔하고 선언적이게 되는 것 같아서 좋았습니다.
<component> 태그<template>
<div>
<!-- 컴포넌트 이름만 바꾸면 끝 -->
<component
:is="currentTab"
:user-data="userData"
@data-updated="handleDataUpdate"
/>
<nav>
<button @click="currentTab = 'Dashboard'">대시보드</button>
<button @click="currentTab = 'UserProfile'">프로필</button>
<button @click="currentTab = 'Settings'">설정</button>
</nav>
</div>
</template>
<script>
import Dashboard from './Dashboard.vue';
import UserProfile from './UserProfile.vue';
import Settings from './Settings.vue';
export default {
components: { Dashboard, UserProfile, Settings },
data() {
return {
currentTab: 'Dashboard' // 문자열로 컴포넌트 지정
}
}
}
</script>
Vue의 <component :is> 태그는 편리한 기능입니다. API 문서 페이지나 리모콘 메뉴에 많이 사용했던 것 같아요.
import Dashboard from './Dashboard';
import UserProfile from './UserProfile';
import Settings from './Settings';
function App() {
const [currentTab, setCurrentTab] = useState('Dashboard');
// 컴포넌트 맵 방식 (권장)
const COMPONENT_MAP = {
Dashboard,
UserProfile,
Settings
};
const renderDynamicComponent = () => {
const Component = COMPONENT_MAP[currentTab];
if (!Component) {
return <div>컴포넌트를 찾을 수 없습니다: {currentTab}</div>;
}
return <Component userData={userData} onDataUpdated={handleDataUpdate} />;
};
return (
<div>
{renderDynamicComponent()}
<nav>
<button onClick={() => setCurrentTab('Dashboard')}>대시보드</button>
<button onClick={() => setCurrentTab('UserProfile')}>프로필</button>
<button onClick={() => setCurrentTab('Settings')}>설정</button>
</nav>
</div>
);
}
React에서는 이런 패턴들로 해결책을 제시하고 있습니다!
React.lazy와 Suspense 활용Vue는 거의 신경쓰지도 않았던 렌더링 최적화를 React에서는 직접 관리하고 개선해야 한다는점이 신기하기도하고, 제일 어려운 부분이였습니다. 사실은 아직도 메모이제이션이 제일 어려워요 ....ㅠ^ㅠㅠ
<template>
<div>
<h1>{{ title }}</h1> <!-- title 변경 시에만 업데이트 -->
<span>{{ user.name }}</span> <!-- user.name 변경 시에만 -->
<ExpensiveComponent :result="expensiveComputation" />
</div>
</template>
<script>
export default {
computed: {
// Vue가 자동으로 의존성을 추적하고 캐싱
expensiveComputation() {
console.log('비싼 계산 실행됨'); // 관련 데이터가 변경될 때만 출력
return this.items
.filter(item => item.isActive)
.map(item => ({ ...item, score: this.calculateScore(item) }))
.sort((a, b) => b.score - a.score);
}
}
}
</script>
Vue는 세밀한 반응형 추적을 통해 변경된 부분만 자동으로 업데이트합니다.
function Dashboard() {
const [title, setTitle] = useState('Dashboard');
const [user, setUser] = useState({ name: 'John' });
const [items, setItems] = useState([]);
console.log('Dashboard 재렌더링'); // 어떤 상태가 바뀌어도 출력됨
// 기본적으로는 매번 재계산됨
const expensiveComputation = items
.filter(item => item.isActive)
.map(item => ({ ...item, score: calculateScore(item) }))
.sort((a, b) => b.score - a.score);
return (
<div>
<h1>{title}</h1>
<span>{user.name}</span>
<ExpensiveComponent result={expensiveComputation} />
</div>
);
}
function OptimizedDashboard() {
const [title, setTitle] = useState("Dashboard");
const [user, setUser] = useState({ name: "John" });
const [items, setItems] = useState([]);
// useMemo로 비싼 계산 캐싱
const expensiveComputation = useMemo(() => {
console.log('비싼 계산 실행됨'); // items 변경 시에만 출력
return items
.filter(item => item.isActive)
.map(item => ({ ...item, score: calculateScore(item) }))
.sort((a, b) => b.score - a.score);
}, [items]);
return (
<div>
<h1>{title}</h1>
<span>{user.name}</span>
<ExpensiveComponent result={expensiveComputation} />
</div>
);
}
React에서는 useMemo, useCallback, React.memo 등을 사용해 직접 최적화해야 합니다. 메모이제이션은 개발자의 숙제와 같은 것 같습니다..
간단한 계산에는 메모이제이션이 오히려 성능을 떨어뜨릴 수 있으므로 정말 필요한 곳에만 사용해야 합니다.
<!-- 최상위 App 컴포넌트 -->
<script>
export default {
provide() {
return {
currentUser: this.currentUser,
updateUser: this.updateUser,
notifications: this.notifications
}
}
}
</script>
<!-- 하위 컴포넌트 -->
<script>
export default {
inject: ['currentUser', 'updateUser', 'notifications']
// 이게 끝! 어떤 깊이의 컴포넌트든 접근 가능
}
</script>
간단하게 provide 한 후 필요한 곳에서 inject 하기만 하면 됩니다!
// 1. Context 생성
const UserContext = createContext();
// 2. Provider 컴포넌트 생성
function UserProvider({ children }) {
const [currentUser, setCurrentUser] = useState({});
const contextValue = useMemo(() => ({
currentUser,
setCurrentUser
}), [currentUser]);
return (
<UserContext.Provider value={contextValue}>
{children}
</UserContext.Provider>
);
}
// 3. 커스텀 훅 생성 (권장)
function useUser() {
const context = useContext(UserContext);
if (!context) {
throw new Error('useUser는 UserProvider 내에서 사용되어야 합니다');
}
return context;
}
// 4. App에서 Provider로 감싸기
function App() {
return (
<UserProvider>
<Dashboard />
</UserProvider>
);
}
// 5. 하위 컴포넌트에서 사용
function Dashboard() {
const { currentUser, setCurrentUser } = useUser();
return <div>사용자: {currentUser.name}</div>;
}
Context API는 더 많은 설정이 필요하지만 명시적인 의존성 관리와 타입 안전성을 제공합니다. 하나의 또 주의점은 Context 값이 변경되면 모든 Consumer가 리렌더링되므로 성능 최적화를 고려해야 합니다.
마지막으로 각각의 장단점을 비교해 본다면 ..!
실제로 두 프레임워크 모두 써보니까 정답은 없다는 걸 느꼈습니다! 상황에 맞는 적합한 선택을 하는게 맞는것 같아요~! 다만 React로 프로젝트를 진행하면서 JavaScript에 대한 이해도가 더 깊어진 것 같아서 개인적으로는 좋은 경험이었습니다. 😙
개인적으로 경험했던 차이점을 나름대로 정리해보았습니다! 혹시 틀린 부분이있거나 보완해야 할 점이 있다면 언제든지 알려주세요. 감사합니다. 🙂
vue에서 리액트로 넘어가려는 저에게 너무 유용한 글이었습니다 ㅠ0ㅠ
작성할땐 확실히 vue가 편한데, 유지보수면에서는 리액트가 나은가...? 싶기도 해요.
vue는 너무 사방팔방에서 값을 변경해서 오히려 어디서 변경된건지 알 수 없고요. (이벤트버스...)
쨋든, 호불호와 상관없이 리액트 공화국인 한국에서는 리액트 지식이 꼭 필요하다고 느껴지는데 정리를 정말 잘하셨네요~!
React로 개발을 시작해서 지금도 React의 바다를 헤엄치고 있는 저에게는 아주 흥미로은 내용이었습니다..!
예전에 신기하게도 vue로 사전과제를 진행한적이 있었는데, 진행하면서 느꼈던 게 개발 하면서 뭔가 형식이 짜여 있는 개발을 하는 듯한 느낌이 들었는데, 이 글을 보면서 고개를 끄덕이게 됐습니다..%%
React와 비교를 통해서 쭈욱 설명해주셔서 그 특징들과 차이점을 이해하는데 큰 도움이 됐습니다!
다음 글도 기대하겠습니다!!
저는 react로 시작해서 그런지 오히려 react가 라이브러리인 만큼 html/css/js 지식을 자연스럽게 사용하는 느낌이더라구요.. 반면 vue는 ":is, v-for" 같은 vue에서 사용하는 문법들이 오히려 확실히 프레임워크라는 느낌이 들었습니다!
react도 class형 컴포넌트때는 vue처럼 라이프사이클 메서드를 개별적으로 관리해야 했었지만, 함수형으로 넘어오면서 hook의 도입으로 useEffect, useLayoutEffect 등으로 라이프 사이클을 관리하게 되었다고 하더라구요!
글을 읽으면서 vue에 대해서 간접적으로 경험할 수 있어서 좋았습니다!