Vue 3를 내부부터 이해하기: Proxy 반응형, Virtual DOM 패치, 컴파일러 최적화, 운영 패턴
이 글의 핵심
Vue 3는 템플릿만 써도 잘 돌아가지만, 대용량 목록에서 반응형 비용이 커지거나 watch가 예상과 다른 시점에 실행될 때는 내부 동작을 알아야 원인을 찾을 수 있습니다. 반응형 시스템과 스케줄러, 컴파일러가 바뀌지 않는 부분을 건너뛰게 만드는 방식을 설명하고, 그 지식을 markRaw·비동기 컴포넌트·Suspense 같은 선택으로 연결합니다.
Vue 3는 Composition API를 중심으로 TypeScript 지원을 강화했고, 런타임은 Proxy 기반 반응형, Virtual DOM 패치, 템플릿 컴파일러 최적화가 맞물려 동작합니다. 이 글은 API 사용법에서 출발해, 각 API가 내부에서 어떻게 동작하는지와 그것이 실제 코드의 선택(언제 ref를 쓰고 언제 markRaw를 쓰는지 등)에 어떻게 이어지는지를 다룹니다.
Vue 3의 구성
Vue는 선언적 UI와 반응형 데이터를 제공하는 프론트엔드 프레임워크입니다. Composition API는 상태와 로직을 함수 단위로 묶어 useXxx 형태의 composable로 재사용할 수 있게 하고, <script setup> 안에서 상태와 생명주기를 표현합니다. 반응형 시스템은 객체를 Proxy로 감싸 추적하고, 원시값은 ref라는 래퍼 객체로 추적합니다. 렌더링은 컴파일러가 템플릿을 렌더 함수로 바꾸고, 런타임이 렌더 함수가 만든 VNode 트리를 이전 트리와 비교해 DOM을 갱신(patch)하는 방식입니다. 라우팅은 Vue Router, 상태 관리는 Pinia, 빌드는 Vite가 공식 생태계를 이룹니다.
Options API도 계속 지원되지만, 공식 문서는 새 프로젝트에 Composition API와 <script setup>을 권장합니다. 타입 추론이 자연스럽고 로직을 컴포넌트 밖으로 꺼내 재사용하기 쉽기 때문입니다.
ref·reactive·computed·watch
ref
ref는 값을 .value 속성을 가진 반응형 래퍼 객체에 담습니다. 스크립트에서는 .value로 접근하고, 템플릿에서는 최상위 ref가 자동으로 unwrap되어 .value 없이 씁니다. 객체를 ref에 넣으면 내부적으로 그 객체를 reactive로 변환해 담으므로 깊은 반응형도 유지됩니다.
<script setup lang="ts">
import { ref } from 'vue';
const count = ref(0);
function increment() {
count.value++;
}
</script>
<template>
<p>Count: {{ count }}</p>
<button type="button" @click="increment">+1</button>
</template>
reactive
reactive는 객체 자체를 Proxy로 감싸 깊은 반응형으로 만듭니다. 주의할 점은 반응성이 Proxy를 거쳐서 읽을 때만 추적된다는 것입니다. const { count } = state처럼 꺼낸 count는 그 순간의 숫자 값일 뿐 이후 변경을 따라가지 않으므로, 구조 분해가 필요하면 toRefs(Pinia 스토어라면 storeToRefs)를 씁니다. 같은 이유로 state = reactive({...})처럼 변수를 새 객체로 다시 대입하면, 템플릿이 들고 있던 이전 Proxy와 연결이 끊어집니다. 객체를 통째로 교체할 일이 있다면 ref({...})로 만들고 .value를 바꾸는 편이 안전하며, 이것이 “ref로 통일하자”는 팀 규칙이 많은 이유이기도 합니다.
<script setup lang="ts">
import { reactive } from 'vue';
const state = reactive({
count: 0,
user: { name: 'Ada', email: '[email protected]' },
});
function increment() {
state.count++;
}
</script>
<template>
<p>{{ state.count }} — {{ state.user.name }}</p>
<button type="button" @click="increment">+1</button>
</template>
computed
computed는 다른 반응형 값에서 파생된 값을 정의합니다. getter가 읽은 값을 의존성으로 추적하고 결과를 캐시해 두었다가, 의존성이 바뀐 뒤 다시 읽힐 때만 재계산합니다. 템플릿에서 메서드를 호출하는 것과 달리 렌더링마다 다시 계산되지 않는다는 점이 차이입니다.
<script setup lang="ts">
import { ref, computed } from 'vue';
const firstName = ref('John');
const lastName = ref('Doe');
const fullName = computed(() => `${firstName.value} ${lastName.value}`);
const reversedName = computed(() =>
fullName.value.split('').reverse().join(''),
);
</script>
<template>
<input v-model="firstName" />
<input v-model="lastName" />
<p>{{ fullName }}</p>
<p>{{ reversedName }}</p>
</template>
watch와 watchEffect
watch는 감시 대상을 명시적으로 지정하고, watchEffect는 콜백이 실행 중에 읽은 반응형 값을 자동으로 추적합니다.
<script setup lang="ts">
import { ref, watch, watchEffect } from 'vue';
const count = ref(0);
const message = ref('hello');
watch(count, (newVal, oldVal) => {
console.log(`count: ${oldVal} → ${newVal}`);
});
watch([count, message], ([c, m]) => {
console.log('both:', c, m);
});
watchEffect(() => {
console.log(`effect sees count=${count.value}`);
});
</script>
watch의 첫 인자는 ref, reactive 객체, getter 함수, 또는 이들의 배열이어야 합니다. reactive 객체의 속성 하나를 감시하려고 watch(state.count, ...)처럼 쓰면 그 시점의 숫자 값을 넘긴 것이 되어 [Vue warn]: Invalid watch source: 0 A watch source can only be a getter/effect function, a ref, a reactive object, or an array of these types. 경고가 나고 감시가 동작하지 않습니다. watch(() => state.count, ...)처럼 getter로 감싸야 합니다.
실행 시점도 알아 둘 만합니다. 기본적으로 watch 콜백은 컴포넌트가 다시 렌더링되기 전(flush: 'pre')에 실행되므로, 콜백 안에서 DOM을 읽으면 아직 갱신되지 않은 값이 보입니다. 갱신된 DOM이 필요하면 { flush: 'post' } 옵션을 주거나 nextTick을 기다려야 합니다. 또 watch는 처음 한 번은 실행되지 않으므로, 초기값으로도 실행되어야 한다면 { immediate: true }를 줍니다. 반대로 watchEffect는 등록 즉시 한 번 실행되면서 의존성을 수집하는데, 콜백 안의 await 이후에 읽은 값은 추적되지 않는다는 함정이 있습니다. 비동기 작업이 끼는 감시라면 의존성을 명시하는 watch가 더 예측하기 쉽습니다.
Composables와 Pinia
composable은 useXxx 형태의 함수 안에서 ref, computed, watch를 조합해 상태와 로직을 묶고, 필요한 값을 반환하는 패턴입니다. Pinia는 Vue 3의 공식 상태 관리 라이브러리로, defineStore 안에서 같은 API를 써서 스토어를 정의합니다. 자세한 내용은 Pinia로 Vue 3 상태 관리하기에서 다룹니다. 내부 동작 관점에서는 스토어도 결국 반응형 객체이고, 컴포넌트의 렌더 effect가 그 객체를 읽으면서 의존성을 맺는다는 점만 기억하면 충분합니다.
내부 동작: Proxy 기반 반응형 시스템
Vue 3의 반응형은 ES2015 Proxy와 Reflect로 속성 읽기와 쓰기를 가로챕니다. Vue 2는 Object.defineProperty로 객체를 만들 때 존재하던 속성마다 getter/setter를 심었기 때문에, 나중에 추가하거나 삭제한 속성, 배열 인덱스로 직접 쓴 값의 변경을 감지하지 못해 Vue.set 같은 우회 API가 필요했습니다. Proxy는 객체 단위로 모든 접근을 가로채므로 이런 경우를 따로 처리하지 않아도 됩니다.
track과 trigger
반응형 시스템은 세 가지 개념으로 설명할 수 있습니다. effect는 반응형 데이터를 읽는 실행 단위로, 컴포넌트의 렌더 함수, watchEffect 콜백, computed getter가 여기에 해당합니다. track은 effect가 실행되는 동안 어떤 객체의 어떤 키를 읽었는지 의존성으로 기록하는 단계이고, Proxy의 get 트랩에서 일어납니다. trigger는 그 키에 값이 쓰였을 때 해당 키에 의존하는 effect들을 다시 실행하도록 예약하는 단계이고, set이나 deleteProperty 트랩에서 일어납니다.
React와 비교하면 이 자동 추적이 가장 큰 차이입니다. React는 상태가 바뀌면 컴포넌트를 다시 실행하고, 다시 계산을 피하려면 useMemo의 의존 배열을 손으로 적어야 합니다. Vue는 실행 중에 무엇을 읽었는지 기록하므로 의존성을 적을 필요가 없는 대신, 무엇이 반응형이고 무엇이 아닌지를 개발자가 설계해야 합니다.
ref와 reactive의 역할 분담
reactive는 객체를 Proxy로 감싸고, 중첩 객체는 읽히는 시점에 필요하면 다시 Proxy로 감쌉니다. 원시값은 Proxy로 감쌀 수 없으므로 ref가 .value의 getter/setter에서 같은 track/trigger를 호출하는 래퍼 객체를 만듭니다. readonly, shallowReactive, shallowRef는 이 과정에서 추적 깊이나 쓰기 가능 여부를 바꾼 변형입니다. 예를 들어 shallowRef는 .value 자체의 교체만 추적하고 그 안의 객체는 Proxy로 감싸지 않습니다.
Proxy 기반이라서 생기는 부작용도 있습니다. reactive(obj)가 돌려주는 것은 원본과 다른 객체라서 reactive(obj) === obj는 거짓이고, 원본 참조를 다른 곳에 보관했다가 Proxy와 비교하면 같은 객체인데도 일치하지 않습니다. 필요하면 toRaw()로 원본을 꺼내 비교합니다. 더 곤란한 경우는 외부 라이브러리의 클래스 인스턴스(지도, 차트, 3D 엔진 객체 등)를 반응형 상태에 넣을 때입니다. 깊은 반응형이 내부 필드 전체를 Proxy로 감싸면서 성능이 떨어지고, #field 같은 자바스크립트 private 필드를 쓰는 클래스는 Proxy를 거친 this로 접근하는 순간 TypeError: Cannot read private member ... from an object whose class did not declare it로 깨집니다. 이런 객체는 markRaw()로 표시하거나 shallowRef에 담아 Vue가 내부를 들여다보지 않게 해야 합니다.
스케줄링과 nextTick
trigger가 일어나도 렌더 effect는 바로 실행되지 않고 스케줄러의 큐에 들어갔다가 마이크로태스크에서 한꺼번에 처리됩니다. 그래서 같은 동기 코드 안에서 상태를 여러 번 바꿔도 컴포넌트는 한 번만 다시 렌더링됩니다. 그 대가로, 상태를 바꾼 직후의 동기 코드에서 DOM을 읽으면 아직 이전 화면이 보입니다. 상태 변경 직후 DOM 크기를 재거나 새로 나타난 입력창에 포커스를 줘야 할 때 await nextTick()이 필요한 이유가 이것입니다.
Virtual DOM과 패치 알고리즘
Vue는 템플릿을 렌더 함수로 컴파일하고, 렌더 함수가 실행될 때마다 VNode 트리를 만들어 이전 트리와 비교한 뒤 차이만 DOM에 반영합니다. 이 비교와 반영 과정을 patch라고 부릅니다.
VNode
VNode(Virtual Node)는 실제 DOM 노드를 대표하는 가벼운 자바스크립트 객체로, 태그명, props, 자식, 그리고 컴파일러가 남긴 패치 힌트를 담습니다. 컴포넌트를 나타내는 VNode는 DOM 노드 대신 컴포넌트 정의와 props, 슬롯을 담고 있다가, 패치 시점에 컴포넌트 인스턴스를 만들거나 갱신합니다.
patch와 자식 재조정
같은 위치의 두 VNode를 비교할 때 타입(태그나 컴포넌트)이나 key가 다르면 이전 노드를 언마운트하고 새 노드를 마운트하며, 같으면 props, 텍스트, 자식을 갱신합니다.
목록에서는 key가 형제 노드 사이의 정체성을 나타냅니다. 키가 있는 자식 목록을 비교할 때 Vue 3는 양 끝에서 같은 키를 먼저 맞춰 나간 뒤, 남은 가운데 구간에서 최장 증가 부분 수열(LIS)을 구해 상대 순서가 유지되는 노드들은 그대로 두고 나머지만 이동합니다. DOM 이동 횟수를 줄이기 위한 장치입니다.
인덱스를 key로 쓰면 안 되는 이유는 이 알고리즘보다 정체성 문제에 있습니다. 목록 앞에 항목을 하나 끼워 넣으면 모든 항목의 인덱스가 하나씩 밀리므로, Vue는 키가 같은 노드끼리 짝을 지어 기존 DOM과 컴포넌트 인스턴스를 재사용합니다. 그 결과 입력창에 입력하던 값이나 자식 컴포넌트의 내부 상태가 다른 항목에 붙어 있는 것처럼 보이는 버그가 생깁니다. 항목의 고유 ID를 키로 써야 하는 이유입니다.
블록 트리
컴파일러는 템플릿에서 내용이 바뀔 수 있는 노드(동적 바인딩이 있는 노드)를 알고 있습니다. 블록은 구조가 바뀌지 않는 템플릿 영역의 루트 노드로, 그 아래의 동적 노드들을 깊이와 상관없이 dynamicChildren이라는 평평한 배열로 모아 둡니다. 패치할 때는 트리 전체를 재귀로 비교하지 않고 이 배열만 순회하므로, 정적인 마크업이 많은 템플릿일수록 비교 비용이 크게 줄어듭니다. v-if나 v-for처럼 구조 자체가 바뀔 수 있는 지점은 새 블록을 열어 따로 처리합니다.
Composition API 구현 관점
setup(props, ctx)는 컴포넌트 인스턴스가 만들어진 뒤, 렌더 effect가 처음 실행되기 전에 한 번 호출되어 반응형 상태, 계산값, 감시자를 준비합니다. 반환한 객체는 렌더 컨텍스트에 노출됩니다. <script setup>은 빌드할 때 일반 setup 함수로 변환되며, 최상위에 선언한 변수와 import한 컴포넌트를 템플릿에서 바로 쓸 수 있게 해 줍니다. defineProps와 defineEmits는 import 없이 쓰는 컴파일러 매크로라서 런타임 함수 호출로 남지 않고 컴포넌트 옵션으로 변환됩니다.
provide한 값은 하위 트리 어디에서든 inject로 받을 수 있습니다. 내부적으로 각 컴포넌트 인스턴스의 provides 객체가 부모의 provides를 프로토타입으로 삼는 체인을 이루기 때문에, 가장 가까운 조상이 제공한 값이 먼저 찾아집니다. provide는 값을 그대로 넘기므로, 원시값을 넘기면 이후 변경이 하위에 전달되지 않습니다. 반응형으로 주입하려면 ref나 computed를 넘겨야 합니다.
컴파일러 최적화
정적 노드 재사용
바인딩이 하나도 없는 정적 노드는 렌더링마다 새로 만들 필요가 없습니다. Vue 3.4까지는 이런 VNode를 렌더 함수 밖(모듈 스코프)으로 끌어올리는 정적 호이스팅을 했고, 3.5부터는 컴포넌트의 렌더 캐시에 한 번 만든 VNode를 저장해 재사용하는 방식으로 바뀌었습니다. 어느 쪽이든 정적 노드는 처음 한 번만 만들어지고, 패치 때도 비교 대상에서 빠집니다. 연속된 정적 노드가 충분히 많으면 문자열 HTML로 묶어 한 번에 삽입하기도 합니다.
패치 플래그
컴파일러는 동적 노드마다 무엇이 바뀔 수 있는지를 비트 플래그로 표시합니다. 예를 들어 텍스트만 바인딩된 노드에는 TEXT, class만 바인딩된 노드에는 CLASS 플래그가 붙습니다. 런타임은 이 플래그를 보고 해당 부분만 비교하므로, props 객체 전체를 순회하며 비교하지 않아도 됩니다. 템플릿 대신 렌더 함수나 JSX를 직접 쓰면 이런 플래그가 없어 일반적인 전체 비교를 하게 됩니다.
v-once
v-once를 붙인 요소와 그 하위 트리는 처음 한 번만 렌더링되고, 이후 업데이트에서는 정적 콘텐츠처럼 건너뜁니다. 안에서 쓰는 데이터가 나중에 바뀌어도 화면에 반영되지 않으므로, 데이터가 정말로 고정일 때만 써야 합니다.
실무 패턴
대용량 데이터와 shallowRef, markRaw
서버에서 받아 표시만 하는 수만 행짜리 배열을 ref에 넣으면, 원소에 처음 접근할 때마다 Proxy가 만들어지고 렌더링 중 모든 읽기가 추적됩니다. 원소 하나하나의 변경을 감지할 필요가 없다면 shallowRef에 담고 배열을 통째로 교체하는 방식으로 갱신하면 됩니다. 외부 클래스 인스턴스는 앞에서 본 것처럼 markRaw로 반응형 변환에서 제외합니다. 기준은 “반응형이 필요한 최소 단위만 반응형으로 둔다”입니다.
v-memo
v-memo는 의존 배열의 값이 바뀔 때만 서브트리를 다시 렌더링하게 하는 메모이제이션입니다. 대표적인 쓰임은 수천 행짜리 목록에서 선택 상태만 바뀌는 경우입니다. v-for 항목에 v-memo="[item.id === selectedId]"를 두면, 선택이 바뀔 때 조건 값이 바뀐 두 행(이전 선택과 새 선택)만 다시 렌더링되고 나머지는 VNode 생성 자체를 건너뜁니다. 반대로 항목 안에서 item.name도 표시하면서 의존 배열에 넣지 않으면 이름이 바뀌어도 화면이 갱신되지 않는데, React의 useMemo 의존성 누락과 같은 종류의 버그입니다. 대부분의 목록은 v-memo 없이도 충분히 빠르므로, 프로파일러로 병목을 확인한 뒤에만 쓰는 편이 좋습니다. 수만 행을 한 번에 그려야 한다면 메모이제이션보다 화면에 보이는 행만 렌더링하는 가상 스크롤이 근본적인 해법입니다.
비동기 컴포넌트와 코드 분할
defineAsyncComponent(() => import('./HeavyChart.vue'))처럼 정의한 컴포넌트는 처음 렌더링될 때 청크를 내려받으므로, 초기 번들에서 빠집니다. 라우트 컴포넌트는 Vue Router의 지연 로딩으로 같은 효과를 얻습니다. loadingComponent, errorComponent, timeout 옵션을 함께 지정하면 네트워크가 느리거나 청크 로딩이 실패했을 때의 화면도 정할 수 있습니다.
Suspense와 에러 처리
<Suspense>는 하위 트리에 async setup이나 비동기 컴포넌트가 있을 때 모두 준비될 때까지 #fallback 슬롯을 보여 줍니다. Vue 3.x에서 Suspense는 아직 실험적 기능으로 표시되어 있으므로 API 변경 가능성을 감안해야 합니다. 렌더링이나 감시자에서 처리되지 않은 에러는 컴포넌트의 onErrorCaptured 훅을 따라 위로 전파되고, 마지막에는 app.config.errorHandler에 도달하므로, 이 핸들러에서 에러 수집 서비스로 보내도록 설정해 두는 것이 좋습니다.