운영 중인 FE_devtalk 스터디 2주차 주제가 릴리즈 노트 분석 훈련 - vue3.5로 선정되었습니다.
제가 선택한 항목은 reactivity: avoid infinite recursion when mutating ref wrapped in reactive 으로 vue의 reactive를 깊게 이해해야 분석 가능하기에 vue-ref를 먼저 정밀 분석하게 되었습니다.
Vue의 반응성 시스템을 좀 더 깊이 이해하고 싶다면, `ref`가 어떻게 만들어지고 동작하는지 알아두면 좋습니다. 이 글에서는 **`ref`의 생성 과정**, **내부 클래스(`RefImpl`)**, 그리고 **의존성 관리(`Dep`)**가 어떻게 연결되어 있는지 정리해 보겠습니다.
Vue 코드에서 ref를 호출하면 아래와 같은 흐름을 거칩니다:
export function ref<T = any>(value: T): Ref<T> {
return createRef(value, false);
}
function createRef<T>(rawValue: T, shallow: boolean): Ref<T> {
if (isRef(rawValue)) {
return rawValue;
}
return new RefImpl(rawValue, shallow);
}
ref()T)을 받고, createRef 함수를 호출합니다.createRef()ref인 경우(isRef(rawValue)) 그대로 반환(중복 래핑 방지)RefImpl 인스턴스 생성 후 반환// 예시
const count = ref(1); // 새로운 RefImpl 인스턴스 생성
const count2 = ref(count); // 이미 ref이므로 그대로 반환
RefImpl 인스턴스를 새로 생성RefImpl 클래스ref()로부터 최종적으로 생성되는 객체는 RefImpl 클래스의 인스턴스입니다.
이 클래스는 Getter/Setter를 통해 의존성 추적과 값 변경 시 반응성을 처리합니다.
class RefImpl<T = any> {
_value: T;
private _rawValue: T;
dep: Dep = new Dep();
public readonly [ReactiveFlags.IS_REF] = true;
public readonly [ReactiveFlags.IS_SHALLOW]: boolean = false;
constructor(value: T, isShallow: boolean) {
this._rawValue = isShallow ? value : toRaw(value);
this._value = isShallow ? value : toReactive(value);
this[ReactiveFlags.IS_SHALLOW] = isShallow;
}
get value() {
// (1) 의존성 추적
if (__DEV__) {
this.dep.track({
target: this,
type: TrackOpTypes.GET,
key: 'value',
});
} else {
this.dep.track();
}
return this._value;
}
set value(newValue) {
// (2) 변경 감지 후 업데이트 & 의존성 실행
const oldValue = this._rawValue;
const useDirectValue =
this[ReactiveFlags.IS_SHALLOW] || isShallow(newValue) || isReadonly(newValue);
newValue = useDirectValue ? newValue : toRaw(newValue);
if (hasChanged(newValue, oldValue)) {
this._rawValue = newValue;
this._value = useDirectValue ? newValue : toReactive(newValue);
if (__DEV__) {
this.dep.trigger({
target: this,
type: TriggerOpTypes.SET,
key: 'value',
newValue,
oldValue,
});
} else {
this.dep.trigger();
}
}
}
}
constructor)isShallow 여부에 따라 toRaw/toReactive로 내부 값(_rawValue, _value)을 설정dep는 의존성 추적/실행을 담당하는 Dep 인스턴스get value())dep.track(...) 호출로 의존성 등록_value를 반환set value(...))newValue)과 이전 값(_rawValue)을 비교_rawValue/_value를 갱신하고, dep.trigger(...)로 의존성 실행const count = ref(1);
effect(() => {
console.log(count.value); // getter 호출
});
count.value를 읽을 때마다 dep.track()이 실행되어,count 값에 의존하고 있음을 등록합니다.count.value = 2; // setter 호출
_rawValue를 비교(hasChanged)하여 달라졌다면,_value와 _rawValue를 갱신하고, dep.trigger()로 의존성(effect)을 재실행시킵니다.즉 RefImpl은 value 프로퍼티를 통해 getter/setter 단에서 의존성 관리 로직을 삽입하는 구조입니다.
Dep 클래스 (의존성 관리)RefImpl 내부에서 의존성을 관리하는 핵심 객체는 Dep입니다.
여기서 dep.track(...), dep.trigger(...)로 의존성을 등록/실행합니다.
export class Dep {
version = 0;
activeLink?: Link = undefined;
subs?: Link = undefined;
subsHead?: Link;
map?: KeyToDepMap = undefined;
key?: unknown = undefined;
sc: number = 0;
constructor(public computed?: ComputedRefImpl | undefined) {
if (__DEV__) {
this.subsHead = undefined;
}
}
track(debugInfo?: DebuggerEventExtraInfo): Link | undefined {
// 의존성 등록 로직
// ...
}
trigger(debugInfo?: DebuggerEventExtraInfo): void {
// 의존성 실행 로직
// ...
}
notify(debugInfo?: DebuggerEventExtraInfo): void {
// 등록된 effect를 순회하며 실행
// ...
}
}
track(debugInfo?: DebuggerEventExtraInfo): Link | undefined {
if (!activeSub || !shouldTrack || activeSub === this.computed) {
return;
}
let link = this.activeLink;
if (link === undefined || link.sub !== activeSub) {
link = this.activeLink = new Link(activeSub, this);
// 이중 연결 리스트를 통해 effect와 dep를 연결
if (!activeSub.deps) {
activeSub.deps = activeSub.depsTail = link;
} else {
link.prevDep = activeSub.depsTail;
activeSub.depsTail!.nextDep = link;
activeSub.depsTail = link;
}
// Dep 내부에도 등록
addSub(link);
}
return link;
}
activeSub: 현재 실행 중인 effect(반응형 함수)Link: effect와 dep를 연결하는 이중 연결 리스트 노드addSub(link)를 통해 dep.subs 리스트에도 등록결과적으로, count.value 같은 반응형 데이터가 어떤 effect에 의해 소비되는지 추적하게 됩니다.
trigger(debugInfo?: DebuggerEventExtraInfo): void {
this.version++;
globalVersion++;
this.notify(debugInfo);
}
notify(debugInfo?: DebuggerEventExtraInfo): void {
startBatch();
try {
// (1) 개발 환경에서 onTrigger 훅 실행
if (__DEV__) {
// ...
}
// (2) 실제 의존성 실행 (역순)
for (let link = this.subs; link; link = link.prevSub) {
if (link.sub.notify()) {
(link.sub as ComputedRefImpl).dep.notify();
}
}
} finally {
endBatch();
}
}
this.version)을 갱신하고, notify()를 호출startBatch() / endBatch()로 감싸 배치 처리this.subs 연결 리스트를 역순으로 돌며, 각 effect를 실행// 예시
const count = ref(1);
effect(() => {
console.log(count.value);
});
count.value = 2;
// -> trigger 호출 -> notify 실행 -> effect 재실행
위 흐름에서 link.sub.notify()가 내부적으로 effect(또는 computed)를 다시 실행하기 때문에
결과적으로 렌더 로직이나 콘솔 출력이 자동 갱신됩니다.
Vue의 반응성 로직에는 배치(batch) 개념이 있습니다.
이를 통해 짧은 시간 안에 여러 개의 변경(setter 호출 등)이 일어나도,
한 번에 처리하여 불필요한 계산이나 렌더링을 최소화합니다.
let batchDepth = 0;
export function startBatch(): void {
batchDepth++;
}
export function endBatch(): void {
// 배치 스코프를 벗어났을 때만 실제 작업 수행
if (--batchDepth > 0) return;
// 컴퓨티드 먼저 갱신
// 일반 effect 갱신
// ...
}
startBatch()batchDepth)를 1 증가endBatch()count.value++ 여러 번 호출 시,effect(() => {
console.log(count.value);
});
startBatch();
count.value++;
count.value++;
endBatch();
// -> 실제 effect는 마지막에 한 번만 실행
즉, 짧은 시간에 많은 setter가 발생해도, 배치 시스템이 불필요한 재계산을 막아주고 순서를 관리합니다.
Vue의 ref는 단순한 래퍼가 아니라, 정교한 반응성 로직이 작동합니다.
ref(...) 호출 → RefImpl 생성 (이미 ref라면 그대로 반환)get value(), set value(...))로 의존성 등록/실행Dep 객체가 어떤 effect(또는 컴퓨티드)가 의존하는지 추적 & 실행결국, 사용자는 count.value처럼 간단한 API만 알면 되지만, Vue 내부에서는 트리거-이펙트, 배치 처리, 이중 연결 리스트 관리 등이 맞물려 반응형을 달성하고 있습니다.
이제 릴리즈 노트에서 언급된 “ref가 reactive로 감싸진 경우 발생하는 무한 재귀 이슈”를 톺아보러 가보겠습니다!
Vue 3.5 RC1에서는 중요한 반응성 관련 버그가 수정되었습니다.
기존에는 정상적으로 작동하던 ref와 reactive 조합이 Vue 3.5에서 무한 재귀 호출을 일으켜 Maximum call stack size exceeded 에러가 발생할 수 있었습니다.
이 글에서는 그 원인과 해결 과정을 Vue 내부 동작과 함께 차근차근 살펴보겠습니다.
Vue 공식 CHANGELOG에서도 해당 문제는 다음과 같이 명시되어 있습니다.
reactivity: avoid infinite recursion when mutating ref wrapped in reactive
commit 313e4bf
Closes #11696
<script setup>
import { reactive, ref, effect } from 'vue';
const a = reactive(ref(1));
effect(() => {
console.log(a.value);
});
a.value++;
</script>
<template>
<button>hi</button>
</template>
Vue에서 reactive()는 내부적으로 Proxy를 생성하고, 이를 제어하기 위한 핸들러 클래스를 지정합니다.
가장 일반적인 경우, Vue는 BaseReactiveHandler를 기반으로 한 MutableReactiveHandler를 사용합니다.
MutableReactiveHandler는 Proxy의 set()을 다음과 같이 오버라이드합니다.
set(
target: Record<string | symbol, unknown>,
key: string | symbol,
value: unknown,
receiver: object
): boolean
const result = Reflect.set(target, key, value, receiver);
여기서 핵심은 네 번째 인자인 receiver입니다. 이 인자는 setter가 실행될 때 this로 사용될 객체를 의미합니다.
문제는 receiver가 ref 객체일 때 발생했습니다. 이 경우 내부 setter에서 this가 Proxy로 바뀌며, ref의 기대 동작과 어긋나게 됩니다. ref는 내부적으로 다음과 같이 getter/setter를 갖습니다.
{
get value() {
track()
return _value
},
set value(newVal) {
_value = newVal
trigger()
}
}
만약 this가 실제 ref 인스턴스가 아니라 Proxy로 치환되면, 내부에서 참조하는 this._value 등이 기대한 형태가 아니게 되고, 그 과정에서 다시 reactive 처리가 중첩되어 무한 재귀가 발생하게 됩니다.
import { reactive, ref, effect } from 'vue';
const a = reactive(ref(1));
effect(() => {
console.log(a.value); // a.value를 반응형으로 추적
});
a.value++;
위 코드에서 Maximum call stack size exceeded가 발생할 수 있습니다. 흐름을 간단히 정리하면 다음과 같습니다.
effect(() => console.log(a.value)) 실행a.value에 의존성이 등록되어 값이 바뀌면 effect가 재실행되어야 합니다.a.value++ 실행set a.value = 2가 호출되면서 Proxy의 set 트랩이 실행되고, Vue 내부에서 trigger()가 호출됩니다.trigger()가 종속된 effect를 재실행합니다.a.value를 읽습니다(= getter 호출).왜 setter가 다시 트리거될까요?
this가 실제 ref가 아닌 Proxy로 바뀌면서, 내부 비교 로직이나 변경 판단이 어긋납니다. 그 결과 값이 항상 바뀐 것으로 인식되거나, 잘못된 대상이 트리거되어 proxy의 set 트랩 → setter 재호출이 반복되는 형태로 빠집니다.
Vue 팀은 MutableReactiveHandler 내부에서 Reflect.set() 호출 시 receiver의 컨텍스트를 정확히 보장하도록 다음과 같이 수정했습니다.
const result = Reflect.set(target, key, value, isRef(target) ? target : receiver);
즉, target이 ref라면 receiver로 target 자체를 넘겨
ref 내부 setter에서 this === ref가 확실히 되도록 보장합니다.
| 항목 | 설명 |
|---|---|
| 문제 버전 | Vue 3.5 RC 이전 |
| 증상 | ref를 reactive로 감쌌을 때 값 변경 시 무한 루프 |
| 원인 | Proxy의 set 내부에서 ref의 this 컨텍스트가 깨짐 |
| 해결 | Reflect.set()에서 isRef(target)인 경우 receiver를 target으로 지정 |
| 상태 | Vue 3.5 RC1에서 패치 완료 |