Proxy 기반 의존성 추적과 effect 스케줄러로 반응형의 원리를 파헤친다.
반응형 시스템의 내부 동작
입문편에서 ref와 reactive를 사용해 데이터가 바뀌면 화면이 자동으로 갱신된다는 사실을 배웠습니다. 그런데 Vue는 어떻게 "이 데이터가 바뀌면 저 컴포넌트를 다시 그려야 한다"는 사실을 알까요? 단순히 마법처럼 동작하는 것이 아니라, JavaScript의 Proxy와 정교한 의존성 그래프, 비동기 스케줄러가 맞물린 결과입니다.
이 레슨에서는 Vue 3의 반응형 코어(@vue/reactivity)가 내부적으로 어떻게 동작하는지를 파헤칩니다. 원리를 이해하면 반응성이 끊기는 흔한 실수를 사전에 방지하고, 성능에 영향을 주는 추적 범위를 의도적으로 제어할 수 있습니다.
학습 목표
- Proxy의
get/set트랩이 track과 trigger를 호출해 의존성을 관리하는 흐름을 설명할 수 있다. ref와reactive의 내부 구현 차이와 unwrapping 규칙을 이해한다.- effect 스케줄러와 비동기 업데이트 큐(
nextTick)의 관계를 파악한다. shallowRef,shallowReactive,markRaw로 추적 범위를 세밀하게 제어한다.toRef,toRefs,customRef로 반응성을 유지하면서 값을 분해하거나 직접 제어한다.
Proxy 기반 track/trigger와 의존성 그래프
Vue 3의 반응형은 ES2015 Proxy를 핵심 메커니즘으로 사용합니다. reactive(obj)를 호출하면 원본 객체를 감싸는 Proxy 핸들러가 생성되고, 두 가지 핵심 동작이 주입됩니다.
| 트랩 | 호출 시점 | 수행 작업 |
|---|---|---|
get | 속성을 읽을 때 | track(target, key) — 현재 실행 중인 effect를 의존성 맵에 등록 |
set | 속성에 쓸 때 | trigger(target, key) — 해당 key를 구독 중인 모든 effect를 재실행 |
내부적으로 의존성은 WeakMap<object, Map<key, Set<ReactiveEffect>>> 구조로 관리됩니다. 객체가 가비지 컬렉션되면 의존성도 함께 정리되므로 메모리 누수 걱정이 없습니다.
// Vue 내부 구조를 단순화한 의사 코드 (실제 소스와 유사)
type Dep = Set<ReactiveEffect>
const targetMap = new WeakMap<object, Map<string | symbol, Dep>>()
function track(target: object, key: string | symbol) {
if (!activeEffect) return // 실행 중인 effect가 없으면 추적 불필요
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()))
dep.add(activeEffect) // 현재 effect를 구독자로 등록
}
function trigger(target: object, key: string | symbol) {
const depsMap = targetMap.get(target)
if (!depsMap) return
const dep = depsMap.get(key)
dep?.forEach(effect => effect.run()) // 구독 중인 effect 재실행
}
실제로 Vue의 컴포넌트 렌더 함수 자체가 하나의 ReactiveEffect입니다. 렌더 함수가 실행되는 동안 읽힌 모든 반응형 속성은 track을 통해 그 렌더 effect의 구독자로 등록됩니다. 이후 속성이 바뀌면 trigger가 렌더 effect를 다시 실행해 DOM이 갱신됩니다.
💡 TIP
activeEffect는 전역 변수처럼 관리되는 스택입니다. effect 안에서 다른 effect가 실행될 때 중첩을 올바르게 처리하기 위해 스택 구조를 사용합니다.
ref vs reactive 내부 구현 차이와 unwrapping 규칙
두 API는 모두 반응형 데이터를 만들지만, 내부 구현 방식이 다릅니다.
reactive — 순수 Proxy
reactive(obj)는 객체를 Proxy로 직접 감쌉니다. 원시값(string, number 등)은 Proxy로 감쌀 수 없기 때문에 객체 타입에만 사용할 수 있습니다.
// reactive는 Proxy를 직접 반환
const state = reactive({ count: 0, user: { name: 'Vue' } })
// state 자체가 Proxy이므로 .value 없이 접근
console.log(state.count) // 0
ref — 클래스 기반 래퍼
ref(value)는 RefImpl 클래스의 인스턴스를 만듭니다. 내부에는 _value 필드가 있고, .value 접근자(getter/setter)가 track과 trigger를 직접 호출합니다. 값이 객체라면 _value는 reactive()로 변환됩니다.
// ref 내부 동작을 단순화한 의사 코드
class RefImpl<T> {
private _value: T
constructor(value: T) {
// 객체면 reactive로 변환, 원시값은 그대로
this._value = isObject(value) ? reactive(value) : value
}
get value() {
track(this, 'value') // ✅ 읽을 때 추적
return this._value
}
set value(newVal) {
this._value = isObject(newVal) ? reactive(newVal) : newVal
trigger(this, 'value') // ✅ 쓸 때 트리거
}
}
unwrapping 규칙
ref가 reactive 객체 안에 중첩되면 자동으로 unwrapping됩니다. 즉, .value 없이 접근할 수 있습니다.
const count = ref(0)
const state = reactive({ count }) // ref가 reactive 안에 포함
// ✅ reactive 안의 ref는 자동 unwrapping
console.log(state.count) // 0 (.value 불필요)
state.count++ // 내부적으로 count.value++와 동일
// ❌ 배열/Map의 ref는 자동 unwrapping 되지 않음
const arr = reactive([ref(0)])
console.log(arr[0].value) // .value 명시 필요
⚠️ 주의 템플릿에서는 최상위
ref만 자동 unwrapping됩니다. 중첩 객체 내부의ref는.value를 직접 써야 합니다.
effect 스케줄러와 비동기 업데이트 큐(nextTick)
반응형 데이터가 바뀔 때마다 컴포넌트를 즉시 다시 그리면 같은 tick에서 여러 속성이 변경될 경우 렌더링이 여러 번 발생합니다. Vue는 이를 방지하기 위해 스케줄러를 사용합니다.
// 나쁜 방식 — 변경마다 렌더링이 3번 발생한다면?
state.a = 1 // 렌더
state.b = 2 // 렌더
state.c = 3 // 렌더 → 실제로는 이렇게 동작하지 않음
Vue 3의 컴포넌트 렌더 effect는 스케줄러와 함께 등록됩니다. trigger가 호출될 때 effect를 즉시 실행하지 않고 **비동기 큐(flush queue)**에 추가합니다. 같은 microtask tick 안에서 동일한 effect가 여러 번 큐에 추가되어도 한 번만 실행됩니다(중복 제거).
// 스케줄러의 동작 원리 (단순화)
const queue: Set<ReactiveEffect> = new Set()
let isFlushing = false
function queueFlush() {
if (!isFlushing) {
isFlushing = true
// Promise.resolve()로 microtask 큐에 플러시 예약
Promise.resolve().then(flushJobs)
}
}
function flushJobs() {
queue.forEach(effect => effect.run())
queue.clear()
isFlushing = false
}
nextTick의 역할
nextTick은 이 플러시 큐가 비워진 직후에 콜백을 실행하도록 예약합니다. DOM이 실제로 갱신된 후에 조작이 필요할 때 사용합니다.
<script setup>
import { ref, nextTick } from 'vue'
const count = ref(0)
async function increment() {
count.value++
// ❌ 이 시점의 DOM은 아직 업데이트 전
console.log(document.querySelector('#count')?.textContent)
await nextTick()
// ✅ 이 시점에는 DOM이 갱신된 상태
console.log(document.querySelector('#count')?.textContent)
}
</script>
<template>
<span id="count">{{ count }}</span>
<button @click="increment">증가</button>
</template>
💡 TIP
nextTick은 Promise를 반환하므로await을 사용하거나.then()체이닝으로 사용할 수 있습니다. 테스트 코드에서도 DOM 변경 후await nextTick()을 자주 사용합니다.
shallowRef·shallowReactive·markRaw로 추적 범위 제어
기본 reactive는 객체를 깊게(deep) 추적합니다. 중첩된 객체도 모두 Proxy로 변환되어 추적됩니다. 이는 편리하지만 거대한 데이터 구조에서는 불필요한 오버헤드가 될 수 있습니다.
shallowRef — 얕은 ref
.value 자체의 교체만 추적하고, .value가 객체일 경우 내부 속성 변경은 추적하지 않습니다.
import { shallowRef } from 'vue'
const state = shallowRef({ count: 0, items: [] as string[] })
// ❌ 내부 속성 변경은 반응형 트리거가 발생하지 않음
state.value.count = 1 // UI 갱신 없음
// ✅ .value 자체를 교체해야 반응형 트리거 발생
state.value = { count: 1, items: [] } // UI 갱신됨
shallowReactive — 얕은 reactive
최상위 속성만 추적하고 중첩 객체는 일반 객체로 취급합니다.
import { shallowReactive } from 'vue'
const state = shallowReactive({
count: 0,
nested: { deep: 'value' }
})
state.count = 1 // ✅ 반응형 — 최상위 속성
state.nested.deep = 'new' // ❌ 비반응형 — 중첩 객체 내부
markRaw — 추적 제외
특정 객체를 반응형 시스템에서 완전히 제외합니다. reactive나 ref 안에 넣어도 Proxy로 변환되지 않습니다.
import { reactive, markRaw } from 'vue'
// 서드파티 라이브러리 인스턴스, 대용량 정적 데이터 등에 유용
const chart = markRaw(new ThirdPartyChart())
const state = reactive({
chart, // ✅ Proxy 변환 없이 원본 참조 유지
data: []
})
console.log(state.chart === chart) // true — 동일한 원본 객체
| API | 추적 깊이 | 주요 사용 사례 |
|---|---|---|
reactive | 무한 깊이 | 일반 상태 객체 |
shallowReactive | 1단계(최상위) | 대형 데이터, 외부 라이브러리 통합 |
ref | .value 내부 깊게 | 원시값 또는 교체 중심 상태 |
shallowRef | .value 교체만 | 통째로 교체되는 대용량 배열/객체 |
markRaw | 추적 없음 | DOM 노드, 차트 인스턴스, 정적 데이터 |
toRef·toRefs·customRef로 반응성 유지하며 분해/제어
toRef — 속성 하나를 ref로 연결
reactive 객체의 특정 속성을 독립적인 ref처럼 다루고 싶을 때 사용합니다. 원본 객체와 양방향으로 동기화됩니다.
import { reactive, toRef } from 'vue'
const state = reactive({ count: 0, name: 'Vue' })
const countRef = toRef(state, 'count')
countRef.value++ // ✅ state.count도 함께 변경됨
console.log(state.count) // 1
state.count = 99 // ✅ countRef.value도 함께 변경됨
console.log(countRef.value) // 99
toRefs — 모든 속성을 ref로 분해
composable 함수에서 reactive 객체를 구조 분해할 때 반응성이 끊기는 문제를 해결합니다.
import { reactive, toRefs } from 'vue'
function useMousePosition() {
const pos = reactive({ x: 0, y: 0 })
const onMove = (e: MouseEvent) => {
pos.x = e.clientX
pos.y = e.clientY
}
window.addEventListener('mousemove', onMove)
// ❌ 구조 분해하면 반응성이 끊김
// return pos → const { x, y } = useMousePosition() 시 문제
// ✅ toRefs로 감싸면 구조 분해해도 반응성 유지
return toRefs(pos)
}
// 사용하는 쪽
const { x, y } = useMousePosition()
// x.value, y.value가 모두 반응형 ref로 동작
⚠️ 주의
toRefs는 반환 시점에 존재하는 속성만 ref로 변환합니다. 나중에 추가된 속성은 포함되지 않습니다.
customRef — 완전한 제어권
track과 trigger를 직접 호출 시점을 제어하고 싶을 때 사용합니다. 디바운스된 반응형이나 외부 소스 동기화에 유용합니다.
import { customRef } from 'vue'
function useDebouncedRef<T>(value: T, delay = 300) {
let timeout: ReturnType<typeof setTimeout>
return customRef<T>((track, trigger) => ({
get() {
track() // 읽을 때 의존성 등록
return value
},
set(newValue) {
clearTimeout(timeout)
timeout = setTimeout(() => {
value = newValue
trigger() // delay 후에 구독자에게 알림
}, delay)
}
}))
}
// 사용 예 — 검색 입력에서 300ms 후에만 반응형 트리거 발생
const searchQuery = useDebouncedRef('', 300)
반응성이 끊기는 흔한 패턴과 디버깅(onTrack/onTrigger)
반응성이 끊기는 패턴
import { reactive, ref } from 'vue'
const state = reactive({ count: 0 })
// ❌ 패턴 1: reactive 객체를 구조 분해
const { count } = state // count는 일반 숫자, 반응성 없음
count // 변경해도 UI 갱신 안 됨
// ✅ 해결: toRefs 사용
const { count } = toRefs(state)
// ❌ 패턴 2: ref를 변수에 분리 저장 후 재할당
let myRef = ref(0)
myRef = ref(1) // 새 ref 인스턴스로 교체 → 기존 구독자는 새 ref 모름
// ✅ 해결: .value를 변경
myRef.value = 1
// ❌ 패턴 3: reactive 객체 자체를 재할당
let state2 = reactive({ count: 0 })
state2 = reactive({ count: 1 }) // 새 Proxy → 기존 참조는 구식 Proxy
// ✅ 해결: 속성을 갱신하거나 ref로 감싸기
state2.count = 1
// 또는
const stateRef = ref({ count: 0 })
stateRef.value = { count: 1 }
// ❌ 패턴 4: 비반응형 함수에 reactive 속성 전달
function process(count: number) { /* count는 값 복사 */ }
process(state.count) // 반응성 없는 원시값 전달
onTrack / onTrigger로 디버깅
watchEffect나 computed의 옵션에 onTrack과 onTrigger를 등록하면, 의존성이 추적되거나 트리거가 발생할 때 알림을 받을 수 있습니다. 개발 환경에서만 동작합니다.
import { watchEffect, reactive } from 'vue'
const state = reactive({ count: 0, name: 'Vue' })
watchEffect(
() => {
console.log(state.count) // count만 읽음
},
{
onTrack(e) {
// 어떤 속성이 의존성으로 등록됐는지 확인
console.log('추적됨:', e.key, '| 대상:', e.target)
// 출력 예: 추적됨: count | 대상: { count: 0, name: 'Vue' }
},
onTrigger(e) {
// 어떤 속성 변경이 effect를 재실행시켰는지 확인
console.log('트리거됨:', e.key, '| 새 값:', e.newValue, '| 이전 값:', e.oldValue)
}
}
)
state.count = 1 // onTrigger 호출됨
state.name = 'X' // effect가 name을 읽지 않으므로 onTrigger 호출 안 됨
💡 TIP
onTrack/onTrigger는 Vue DevTools 없이도 터미널에서 의존성 그래프를 분석할 수 있는 강력한 디버깅 수단입니다. 예상치 못한 속성이 추적되거나 트리거되는 경우 즉시 발견할 수 있습니다.
요약
- Vue 3 반응형 시스템은
Proxy의get/set트랩에서track/trigger를 호출해 의존성 그래프(WeakMap → Map → Set)를 유지한다. ref는RefImpl클래스의.value접근자로,reactive는 Proxy 핸들러로 각각 추적을 구현하며,reactive내부의ref는 자동 unwrapping된다.- 컴포넌트 렌더 effect는 비동기 큐에 모아 한 tick에 한 번만 플러시되며,
nextTick으로 DOM 갱신 후 시점을 제어할 수 있다. shallowRef,shallowReactive,markRaw로 추적 깊이를 제한해 불필요한 Proxy 변환 오버헤드를 줄일 수 있다.toRef/toRefs는 반응성을 유지하며 객체를 분해하고,customRef는track/trigger타이밍을 완전히 제어한다.- 반응성이 끊기는 주요 원인은 reactive 구조 분해, ref 인스턴스 재할당, reactive 객체 자체의 재할당이며,
onTrack/onTrigger로 디버깅한다.
연습문제
- 다음 코드에서
count가 변경되어도 UI가 갱신되지 않는 이유를 설명하고, 올바르게 수정하세요.
const state = reactive({ count: 0 })
const { count } = state
function increment() {
count++
}
힌트
toRefs를 사용하거나 직접state.count를 수정하세요.
customRef를 사용해 값 변경 시 로컬 스토리지에 자동 저장되는useLocalStorageRef함수를 작성하세요. key와 초기값을 인자로 받아야 합니다.
힌트
get에서localStorage.getItem을,set에서localStorage.setItem을 호출하세요.
- 아래 코드에서
nextTick이 필요한 이유를 설명하고,nextTick없이 같은 결과를 얻으려면 어떤 생명주기 훅을 사용해야 하는지 답하세요.
const isVisible = ref(false)
async function show() {
isVisible.value = true
await nextTick()
document.querySelector('.modal')?.focus()
}
힌트 Vue의 업데이트 큐는 비동기(microtask)로 플러시됩니다.
- 다음 중
shallowRef를 사용하기에 적합한 상황을 고르고 이유를 설명하세요.- (A) 사용자 입력 폼 데이터 (
{ name, email, age }) - (B) 서버에서 받아온 수천 개 항목의 상품 목록 배열
- (C) 카운터 숫자 (
ref(0))
- (A) 사용자 입력 폼 데이터 (
힌트 내부 속성 변경 추적이 필요한지, 아니면 배열 자체의 교체만 감지하면 되는지 생각해 보세요.
💡 연습문제 풀이
불러오는 중…
댓글 0
“Vue.js 심화” 강좌에 대한 댓글입니다.