운영 중인 FE_devtalk 스터디 2주차 주제가 릴리즈 노트 분석 훈련 - vue3.5로 선정되었습니다.
제가 선택한 항목은 reactivity: avoid infinite recursion when mutating ref wrapped in reactive 으로 vue의 reactive를 깊게 이해해야 분석 가능하기에 vue-ref를 먼저 정밀 분석하게 되었습니다.

Vue Ref 구현 상세 분석

 

Vue의 반응성 시스템을 좀 더 깊이 이해하고 싶다면, `ref`가 어떻게 만들어지고 동작하는지 알아두면 좋습니다. 이 글에서는 **`ref`의 생성 과정**, **내부 클래스(`RefImpl`)**, 그리고 **의존성 관리(`Dep`)**가 어떻게 연결되어 있는지 정리해 보겠습니다.


1. Ref 생성 과정

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);
}
  1. ref()
    • 제네릭(T)을 받고, createRef 함수를 호출합니다.
  2. createRef()
    • 이미 ref인 경우(isRef(rawValue)) 그대로 반환(중복 래핑 방지)
    • 아닌 경우 RefImpl 인스턴스 생성 후 반환

핵심 포인트

// 예시
const count = ref(1);       // 새로운 RefImpl 인스턴스 생성
const count2 = ref(count);  // 이미 ref이므로 그대로 반환
  1. 이미 ref인 경우 → 그대로 반환 (중복 래핑 방지)
  2. ref가 아닌 경우RefImpl 인스턴스를 새로 생성

2. 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 인스턴스
  • Getter (get value())
    1. dep.track(...) 호출로 의존성 등록
    2. 현재 _value를 반환
  • Setter (set value(...))
    1. 새 값(newValue)과 이전 값(_rawValue)을 비교
    2. 값이 바뀌었다면 _rawValue/_value를 갱신하고, dep.trigger(...)의존성 실행

2.1 Getter 동작

const count = ref(1);
effect(() => {
  console.log(count.value); // getter 호출
});
  • count.value를 읽을 때마다 dep.track()이 실행되어,
    현재 실행 중인 effectcount 값에 의존하고 있음을 등록합니다.

2.2 Setter 동작

count.value = 2; // setter 호출
  • 새 값기존 _rawValue를 비교(hasChanged)하여 달라졌다면,
    _value_rawValue를 갱신하고, dep.trigger()로 의존성(effect)을 재실행시킵니다.

RefImplvalue 프로퍼티를 통해 getter/setter 단에서 의존성 관리 로직을 삽입하는 구조입니다.


3. 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를 순회하며 실행
    // ...
  }
}

3.1 의존성 등록 (track)

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에 의해 소비되는지 추적하게 됩니다.

3.2 의존성 실행 (trigger → notify)

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();
  }
}
  1. trigger()
    • 버전(this.version)을 갱신하고, notify()를 호출
  2. 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)를 다시 실행하기 때문에
결과적으로 렌더 로직이나 콘솔 출력이 자동 갱신됩니다.


4. Effect와 배치 시스템

Vue의 반응성 로직에는 배치(batch) 개념이 있습니다.
이를 통해 짧은 시간 안에 여러 개의 변경(setter 호출 등)이 일어나도,
한 번에 처리하여 불필요한 계산이나 렌더링을 최소화합니다.

let batchDepth = 0;

export function startBatch(): void {
  batchDepth++;
}

export function endBatch(): void {
  // 배치 스코프를 벗어났을 때만 실제 작업 수행
  if (--batchDepth > 0) return;

  // 컴퓨티드 먼저 갱신
  // 일반 effect 갱신
  // ...
}
  • startBatch()
    • 배치 범위를 시작할 때 카운터(batchDepth)를 1 증가
  • endBatch()
    • 배치 범위가 끝나면 1 감소
    • 카운터가 0이 되었을 때, 컴퓨티드 → 일반 effect 순으로 실제 실행

4.1 배치 시스템이 필요한 이유

  1. 중첩된 업데이트 방지
    • 한 effect 안에서 count.value++ 여러 번 호출 시,
      매번 재실행하지 않고 마지막에 한 번 모아서 실행
  2. 정확한 실행 순서 보장
    • 컴퓨티드를 먼저 업데이트하고, 이어서 일반 effect 실행
    • 의존성 엉킴을 방지
  3. 코드 구조상 이점
    • 여러 effect/computed/watch를 한 번에 처리하기 쉬움
    • 최종 시점에 렌더링 실행

예시 상황

effect(() => {
  console.log(count.value);
});

startBatch();
count.value++;
count.value++;
endBatch(); 
// -> 실제 effect는 마지막에 한 번만 실행

즉, 짧은 시간에 많은 setter가 발생해도, 배치 시스템불필요한 재계산을 막아주고 순서를 관리합니다.


결론

Vue의 ref단순한 래퍼가 아니라, 정교한 반응성 로직이 작동합니다.

  1. ref(...) 호출 → RefImpl 생성 (이미 ref라면 그대로 반환)
  2. Getter/Setter (get value(), set value(...))로 의존성 등록/실행
  3. Dep 객체가 어떤 effect(또는 컴퓨티드)가 의존하는지 추적 & 실행
  4. 배치 시스템으로 여러 업데이트를 한 번에 처리 → 성능 최적화 & 순서 보장

결국, 사용자는 count.value처럼 간단한 API만 알면 되지만, Vue 내부에서는 트리거-이펙트, 배치 처리, 이중 연결 리스트 관리 등이 맞물려 반응형을 달성하고 있습니다.

 

이제 릴리즈 노트에서 언급된 “ref가 reactive로 감싸진 경우 발생하는 무한 재귀 이슈”를 톺아보러 가보겠습니다!

호이안

해당 포스트는 하루에 하나씩 FE 개발자 면접 문제 오픈 채팅방에서 매일 출제되는 퀴즈를 토대로 학습 및 토론내용을 정리한 게시글입니다.

 

1. Quiz 및 답변

퀴즈

안녕하세요~ 좋은 하루입니다! 오늘의 FE의 질문은!!

Q1. 자바스크립트의 event loop와 call stack의 동작 방식을 설명하고, microtask와 macrotask 큐의 차이점을 설명해 주세요. 이를 통해 비동기 코드가 실행되는 순서에 대해 설명해 주세요.

답변

 싱글스레드인 자바스크립트의 실행 방식을 보완하기 위하여 브라우전 엔진 및 노드등은 이벤트 루프와 같은 설계 방식을 선택하였습니다!
이벤트 루프는 자바스크립트의 콜스택이 비게되면 태스크큐에 있는 작업을 순차적으로 실행시켜 자바스크립트에서 비동기 로직을 가능하게 합니다. 이때 큐 하나로는 비동기 작업의 우선순위 및 효율성을 지키기 어려워 **매크로 태스크 큐** 와 **마이크로 태스크 큐** 두 가지 큐를 통하여 비동기 처리의 일관성을 유지합니다.
마이크로 태스크큐는 주로 프로미스나 뮤테이션 옵져버가 들어가며, 매크로 태스크 큐는 그 이외의 대부분 비동기 작업이 들어갑니다.

2. 토론

 대부분의 답변들이 콜스택과 태스크 큐를 강조하며, 태스크 큐와 마이크로 태스크 큐의 차이점, 그리고 각 큐에는 어떤 작업들이 들어가게 되는지를 설명해 주었습니다. 이 중 이슈가 되는 답변과 그에 대한 저의 생각들을 나열해보겠습니다.

 

Q.1 '🤔 자바스크립트가 동기적으로 처리한다는 말이 약간 애매하지않을까요 ?'

 

 저는 질문의 의도를 아래와 같이 생각하였습니다.

 

[사고의 맥락]
'자바스크립트는 비동기도 되니깐 동기라고 하기에는 조금 애매한거 같다' 라고 생각을 하시고 있으신가?. 라고 생각하였으며, 그에 대해 저는 '자바스크립트 자체는 싱글 스레드로 동기가 맞으며, 비동기를 가능하게 한 것 은 런타임 에서 이벤트루프와 같은 설계를 도입하여 싱글스레드를 보완 한 것이다' 와 같이 생각하였습니다.

 

 

[당시 답변]

이와 관련하여 '동기적인데 이벤트루프, 큐로 인해서 비동기처럼 보이는거 뿐일거로 알고 있어요' 와 같은 답변들도 있었으며, 해당 질문을 통하여 토론방에서 '자바스크립트는 어떻게 비동기를 실행할까' 에 대한 이상적인 토론이 오고갔습니다.


Q.2 console.log 랑 value값이 어떻게 찍힐지 예상해보세요~!

감사하게도 이와 관련된 재미있는 문제를 올려주신분이 있습니다.

 let value = 100;

const delay = () => {
  return new Promise((resolve, reject) => {
    console.log(0, value);
    setTimeout(() => {
      console.log(1, value);

      value = 200;
      console.log(2, value);
      resolve(value);
    });
  });
};

(async () => {
  const output = await delay();
  console.log("3", output);
})();

value = 300;

 

[사고의 맥락]

1️⃣ console.log(0, value); → 0 100

  • delay() 함수가 실행되면, 동기적으로 console.log(0, value);가 실행됩니다.
  • 이 시점에서 value는 100입니다.

2️⃣ value = 300;

  • 비동기 함수인 **await delay()**는 Promise가 처리될 때까지 일시 중단됩니다.
  • value는 300으로 변경됩니다.

3️⃣ setTimeout 콜백 실행 → 1 300

  • 이벤트 루프에 의해 **setTimeout()**이 실행되면, 변경된 **value = 300**이 출력됩니다.

4️⃣ value = 200; → 2 200

  • setTimeout 내부에서 **value**가 200으로 업데이트되고, **2 200**이 출력됩니다.

5️⃣ resolve(value);

  • Promise가 fulfilled 상태가 되어, await 다음 줄이 실행될 준비가 됩니다.
  • 이때 value는 200입니다.

6️⃣ console.log("3", output); → 3 200

  • **output**에는 **resolve(value)**로 전달된 200이 저장되어 출력됩니다.

[당시 답변]

0 100

1 300

2 200

3 200

 

 

[정답]

 

 

[공유]

Node.js와 V8의 이벤트 루프 차이

V8 엔진 (브라우저 환경) Node.js의 이벤트 루프
V8 엔진에서는 이벤트 루프가 매크로태스크(예: setTimeout)보다 항상 마이크로태스크(예: Promise, process.nextTick)를 우선 처리합니다. 이는 이벤트 루프의 각 반복에서 콜 스택이 비워질 때마다 마이크로태스크 큐를 우선적으로 비우는 구조입니다. Node.js는 V8 엔진 위에 구축된 환경이지만, 이벤트 루프의 동작 방식은 libuv 라이브러리에 의해 제어됩니다. Node.js 이벤트 루프는 여러 단계로 나뉘며, 각 단계가 끝날 때마다 마이크로태스크 큐를 비우지 않습니다. 대신, 특정 단계에서만 마이크로태스크(예: Promise의 후속 작업)가 처리됩니다.

3. 후기

이전에 블로그 포스팅을 했던 내용이라 꽤 관심있게 토론을 진행하였습니다. 다시금 자바스크립트 동작원리에 대해 공부할 수 있어 좋았고 다음에 더 흥미로운 주제로 찾아 뵙겠습니다!!

+ Recent posts