Skip to content

2편. effect / track / trigger 깊이 파보기

1편에서 큰 그림을 잡았다. 이제 실제 반응성 엔진의 핵심 3개를 코드 관점에서 뜯어보자.

  • effect(fn): 의존성을 수집하고 재실행되는 작업 단위
  • track(target, key): 읽기 시점에 “관심 등록”
  • trigger(target, key): 쓰기 시점에 “관심자 깨우기”

그리고 이들을 지탱하는 데이터 구조(WeakMap → Map → Set), 정리(cleanup), 중첩 효과(effectStack), 배치 스케줄러(queueJob) 까지 순서대로 설명한다.

0. 의존성 그래프: 왜 WeakMap → Map → Set인가

반응성 엔진은 "누가 무엇을 읽었는가"를 기억해야 한다. 가장 일반적인 구조는 아래와 같다.

WeakMap<object, Map<PropertyKey, Set<EffectFn>>>
   └── target(object)
        └── depsMap: Map<key, dep>
             └── dep: Set<effect>
  • WeakMap: key로 쓰인 target(원본 객체)이 더 이상 참조되지 않으면 GC가 수거할 수 있어 메모리 누수 방지.
  • Map<PropertyKey, Set<EffectFn>>: 각 속성(key) 별로 해당 속성을 읽었던 effect 집합을 빠르게 찾기 위함.
  • Set: 동일 effect의 중복 등록을 자연스럽게 방지.

의존성 역참조(deps)

각 effect는 자신이 등록된 dep(Set) 목록을 역참조로 들고 있다.

ts
interface EffectFn {
  (): any;
  deps: Set<EffectFn>[];
  scheduler?: () => void;
  active?: boolean;
}
  • cleanup 시 이 역참조를 활용해 O(등록 수) 로 빠르게 연결을 끊는다.

1. track: 읽을 때 의존성 등록

ts
function track(target: object, key: PropertyKey) {
  if (!activeEffect) return; // 효과 실행 중이 아닐 때는 등록하지 않음
  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()));

  if (!dep.has(activeEffect)) {
    dep.add(activeEffect);
    (activeEffect.deps ||= []).push(dep); // 역참조(정리 위해)
  }
}
  • activeEffect는 현재 실행 중인 effect를 가리킨다. 이게 없으면 의존성 수집을 하지 않는다(예: 단순한 함수 호출).
  • 같은 effect가 같은 key를 여러 번 읽어도 Set 덕분에 한 번만 등록된다.

중요: track은 "읽기 시점"에만 호출된다. Proxy get이나 ref의 get value()에서 이걸 해준다.

2. effect: 실행과 정리(cleanup), 그리고 중첩 처리

ts
export function effect(
  fn: () => any,
  options?: { scheduler?: () => void; lazy?: boolean }
) {
  const runner: EffectFn = function () {
    cleanup(runner); // 실행 전, 지난 의존성 정리
    try {
      activeEffect = runner; // 현재 실행 효과 지정
      effectStack.push(runner); // 중첩 효과 대비 스택에 쌓음
      return fn(); // 사용자 함수 실행(여기서 track들이 호출됨)
    } finally {
      effectStack.pop(); // 끝나면 스택에서 제거
      activeEffect = effectStack[effectStack.length - 1] || null;
    }
  } as EffectFn;

  runner.active = true;
  runner.scheduler = options?.scheduler;
  if (!options?.lazy) runner(); // 기본은 즉시 1회 실행(초기 렌더 포함)
  return runner; // 필요시 수동 호출 가능
}

function cleanup(e: EffectFn) {
  const deps = e.deps;
  if (!deps) return;
  for (const dep of deps) dep.delete(e); // 모든 dep(Set)에서 자신 제거
  deps.length = 0; // 역참조 비우기
}

왜 cleanup이 필요한가?

  • 효과가 재실행되면, 이번 실행에서 읽지 않은 키는 더 이상 이 effect의 관심사가 아니다.
  • cleanup으로 지난 연결을 끊지 않으면, 불필요한 재실행메모리 누수가 발생한다.

중첩 효과(effectStack)

  • effect 내부에서 또 다른 effect를 실행할 수 있다(예: 컴포넌트 트리 렌더 중 자식 컴포넌트 렌더).
  • effectStack을 사용해 현재(activeEffect) 를 올바르게 복원한다.

3. trigger: 쓸 때 관련자 깨우기

ts
function trigger(target: object, key: PropertyKey) {
  const depsMap = targetMap.get(target);
  if (!depsMap) return;
  const dep = depsMap.get(key);
  if (!dep) return;

  const effects = new Set(dep); // 순회 중 변경 안전
  for (const e of effects) {
    if (e === activeEffect) continue; // 자기 자신은 건너뛰어 무한 루프 방지
    e.scheduler ? e.scheduler() : queueJob(e); // 스케줄러 우선, 없으면 배치 큐
  }
}
  • 변경된 key에 실제로 관심 등록(track)했던 effect만 대상으로 한다.
  • scheduler 옵션이 있으면 이를 사용해 재실행 타이밍을 제어할 수 있다.

4. 배치 스케줄러: 한 번에 몰아서 실행

여러 번의 상태 변경을 마이크로태스크로 모아 한 번에 실행한다.

ts
const jobQueue = new Set<() => any>();
let isFlushing = false;

function queueJob(job: () => any) {
  jobQueue.add(job); // Set: 중복 제거
  if (!isFlushing) {
    isFlushing = true;
    Promise.resolve().then(flushJobs); // microtask에서 비우기
  }
}

function flushJobs() {
  try {
    for (const job of jobQueue) job();
  } finally {
    jobQueue.clear();
    isFlushing = false;
  }
}

왜 microtask인가?

  • 동일 tick 내 동기 변경을 하나의 렌더 사이클로 묶어 리렌더 횟수를 줄인다.
  • DOM 업데이트 타이밍을 제어해 시각적 안정성을 높인다.

구현에 따라 flush:'pre' | 'post' | 'sync' 같은 스케줄 선택지를 둘 수 있다. 기본은 post(렌더 이후), 측정/애니메이션은 pre, 정말 즉시 필요하면 sync를 선택한다.

5. render도 effect다: 최초 렌더와 재렌더

컴포넌트를 마운트할 때 일반적으로 다음과 같은 흐름을 갖는다.

ts
function mountComponent(vnode, container, anchor) {
  const instance = {
    /* ... state/props/subTree ... */
  };

  const updateComponent = () => {
    const nextTree = instance.render(/* ctx with proxyRefs */);
    if (!instance.isMounted) {
      patch(null, nextTree, container, anchor); // 최초 렌더
      instance.isMounted = true;
    } else {
      patch(instance.subTree, nextTree, container, anchor); // 재렌더
    }
    instance.subTree = nextTree;
  };

  // 핵심: render를 구동하는 것도 effect
  instance.update = effect(updateComponent);
}
  • 최초 렌더: effect(updateComponent)가 즉시 1회 실행되면서 화면이 그려진다.
  • 재렌더: reactive/ref가 바뀌면 해당 key에 등록된 dep를 타고 trigger가 updateComponent를 다시 스케줄한다.

이 관점에서 렌더러는 반응성 엔진 위에 얹힌 클라이언트이며, 렌더 함수 역시 "상태를 읽는 effect"에 불과하다.

요약

  • track읽을 때 등록, trigger쓸 때 알림, effect재실행 단위다.
  • 이 셋은 WeakMap → Map → Set 그래프로 결속되어 정확히 필요한 컴퓨테이션만 다시 돌린다.
  • 렌더 역시 effect이므로, 최초 렌더도 effect로 시작, 변경 후 재렌더도 effect로 귀결된다.

3편에서는 ref / reactive / computed / watch가 이 엔진 위에서 어떻게 동작하는지, 그리고 최종적으로 main.ts의 데모가 어떤 경로로 업데이트되는지 끝까지 추적한다.