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의 데모가 어떤 경로로 업데이트되는지 끝까지 추적한다.