최근 Vue 3를 공부하면서 가장 인상 깊었던 부분 중 하나는 바로 "상태 변화 감지 방식"이었습니다. 기존에 React만 사용했을 때는 상태를 변경하면 setState 혹은 useState의 setter 함수를 호출하는 게 너무 당연한 일이었는데, Vue에서는 단순히 값을 바꾸기만 해도 화면이 자동으로 업데이트되는 점이 무척 신기했습니다. 공부를 하다 보니 Proxy 객체에 대해 알게 되었고, 그에 대해 기록해보고자 합니다.
React의 상태 관리는 useState 훅을 통해 이루어집니다.
예를 들어, 다음과 같은 코드를 생각해보겠습니다.
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0); // 상태 변수와 setter
function handleClickWrong() {
// 잘못된 예: 상태를 직접 변경 (작동하지 않음)
// count = count + 1; // ❌ 직접 대입 (React는 이 변화를 감지하지 못함)
console.log('직접 변경 후 count:', count);
}
function handleClickCorrect() {
// 올바른 예: setter 함수를 통해 상태 갱신
setCount(count + 1);
}
return (
<>
<p>현재 카운트: {count}</p>
<button onClick={handleClickWrong}>증가 (잘못된 방법)</button>
<button onClick={handleClickCorrect}>증가 (올바른 방법)</button>
</>
);
}
상태를 직접 바꾸는 것(count = count + 1)은 동작하지 않습니다. 반드시 setCount(newValue)를 호출해야만 React는 해당 컴포넌트를 다시 렌더링합니다. Virtual DOM을 다시 계산하고, 실제 DOM과 비교(diff) 후 필요한 부분만 업데이트합니다. 이 과정이 너무 익숙해서, "상태 변경 = setter 함수 호출"이라는 사고 방식이 자연스럽게 자리잡았습니다.
Vue 3를 처음 접하고 ref와 reactive를 사용하면서 놀란 점은 바로 이 부분이었습니다. Vue에서는 상태를 ref(0)처럼 정의하고, 그 값을 단순히 count.value++처럼 변경하기만 하면 화면이 자동으로 갱신됩니다. 예를 들어 Vue에서는 이렇게 작성합니다.
<template>
<p>현재 카운트: {{ count }}</p>
<button @click="increment">증가</button>
</template>
<script setup>
import { ref } from 'vue';
// ref를 이용해 상태 선언
const count = ref(0);
// 값 변경만으로 상태 업데이트 (별도 setter 불필요)
function increment() {
count.value++;
}
</script>
count.value++ 만으로도 화면에 바인딩된 데이터가 자동으로 반응합니다. "어? setter 함수 안 쓰는데 왜 바뀌지?"라는 의문이 들었습니다. 알고 보니 Vue는 내부적으로 Proxy 객체를 사용해 값을 감지하고, 그 변화에 따라 컴포넌트의 렌더링을 다시 트리거합니다.
그렇다면 Vue는 어떻게 값 변경을 감지하고 자동으로 렌더링을 수행할까요? Vue 3의 핵심은 Proxy API를 활용한 반응형(Reactivity) 시스템입니다.
Proxy를 사용하면 객체에 대한 모든 접근과 변경을 가로채서 커스터마이즈할 수 있습니다. Vue는 이 Proxy 기능을 활용해, 컴포넌트의 반응형 상태 객체를 Proxy로 감싼 후 각 프로퍼티 접근 시와 프로퍼티 변경 시에 특별한 동작(추적 또는 알림)을 수행합니다. 이를 위해 Vue 3에는 반응형 핸들러(reactive handler)가 있으며, Proxy를 생성할 때 이 핸들러를 등록해 둡니다.
예를 들어, Vue 3의 reactive() 함수를 아주 단순화한 의사 코드는 다음과 같습니다.
function reactive(targetObj) {
return new Proxy(targetObj, {
get(target, key, receiver) {
// 프로퍼티 읽힐 때: '의존성 추적'
track(target, key); // 변경을 추적하도록 등록
return Reflect.get(target, key, receiver); // 실제 값 반환
},
set(target, key, value, receiver) {
// 프로퍼티 쓸 때: '업데이트 트리거'
const result = Reflect.set(target, key, value, receiver); // 값 설정
trigger(target, key); // 해당 프로퍼티를 사용하는 곳을 갱신
return result;
}
});
}
위 코드는 Vue 3의 반응형 시스템 아이디어를 보여줍니다. reactive(obj)로 객체를 감싸면 Proxy가 반환되고, 이 Proxy에는 get과 set 동작을 가로채는 트랩(trap)이 정의되어 있습니다. 객체 프로퍼티가 읽힐 때마다 get 트랩에서 track() 함수를 호출하여 "이 컴포넌트가 이 속성을 사용하고 있다"는 것을 기록해 둡니다. 그리고 객체 프로퍼티가 변경될 때마다 set 트랩에서 trigger() 함수를 호출하여 "이 속성이 변했으니 관련된 화면을 업데이트하자"고 알리는 것입니다. 이렇게 함으로써 Vue는 값의 변경을 가로채어 알림을 발송하고, 미리 등록된 "효과(effect)" 또는 "Watcher"들에게 다시 컴포넌트 렌더링을 실행시키도록 합니다.
참고로, Composition API의
ref는 내부적으로 조금 다른 방식으로 구현되어 있습니다.ref는 primitive 값(number, string 등)도 다룰 수 있도록 하기 위해 Proxy 대신 객체의 getter/setter로 구현되는데, 기본 개념은 비슷합니다.ref(0)로 만든 객체에는.value프로퍼티가 있고, 이 프로퍼티에 접근할 때와 설정할 때 위와 동일하게track()과trigger()가 호출되도록 되어 있습니다[vuejs.org]. Vue 3에서는reactive와ref를 상황에 맞게 사용하여 깊은 객체부터 단순 값까지 모두 반응형으로 관리할 수 있게 해줍니다.
Vue를 공부하면서 느낀 가장 큰 차이는 사고방식의 차이 입니다. React는 불변성과 명시적인 갱신을 중시합니다. 상태를 직접 바꾸지 않고, 새 값을 만들어 교체해야 합니다. 반면 Vue는 데이터를 마치 평범한 JavaScript 변수처럼 다룰 수 있고, 내부적으로 알아서 추적하고 감지합니다. 이 덕분에 더 직관적으로 개발할 수 있다고 느꼈습니다.
물론 React의 방식도 예측 가능성과 명확한 흐름이라는 장점이 있습니다. 그래서 두 프레임워크 모두 옳고 그름이 아니라 철학과 설계 방향의 차이라고 느꼈습니다.
Vue의 반응형 시스템을 접하면서 나는 단순히 새로운 문법을 배운 것이 아니라, 상태를 다루는 또 다른 관점을 배웠습니다. 앞으로 React를 쓸 때도 이 관점을 떠올리게 될 것 같고, Vue를 쓸 때는 왜 그렇게 동작하는지 내부 원리를 더 이해하게 되었습니다.
다음엔 watchEffect, computed의 내부 동작도 더 깊게 파고들어볼 예정입니다.