Ray Book
프레임워크의 철학

반응성, 변경을 어떻게 감지하는가

같은 count++가 React, Vue, Angular, Svelte에서 각각 어떻게 UI를 업데이트하는지, 반응성의 내부 메커니즘을 비교합니다

frameworkreactivityvirtual-domproxysignalscompiler

핵심 질문

count++를 했을 때, 화면의 숫자는 어떻게 바뀌는가?

버튼을 클릭하면 JavaScript 변수가 바뀝니다. 하지만 DOM은 JavaScript 변수를 모릅니다. 누군가가 "값이 바뀌었으니 화면을 업데이트하라"고 알려줘야 합니다.

이 "누군가"가 바로 프레임워크의 반응성 시스템 (Reactivity System) 입니다.

문제: 상태는 바뀌었는데 DOM은 모른다

let count = 0;
count++; // JavaScript 변수는 1이 됐다
// 하지만 화면에는 여전히 0이 표시된다

상태와 DOM 사이에는 간극이 있습니다. 이 간극을 메우는 방법이 프레임워크마다 다릅니다. 네 프레임워크가 택한 전략을 한 줄로 요약하면:

  • React : "다시 그려" ,함수 전체를 다시 실행하고, VDOM으로 차이만 적용
  • Vue : "추적해" ,Proxy로 누가 뭘 읽었는지 기억하고, 바뀌면 그것만 업데이트
  • Angular : "감시해" ,Zone.js로 비동기를 감시하고, 변경 감지 사이클을 돌림
  • Svelte : "컴파일해" ,컴파일러 + 런타임 시그널로 세밀한 업데이트를 수행

하나씩 살펴보겠습니다.

React의 반응성 ,다시 그리기

React의 모델은 단순합니다. 상태가 바뀌면 컴포넌트 함수를 다시 실행합니다. 새로운 결과 (Virtual DOM) 를 만들고, 이전 결과와 비교해서 차이만 실제 DOM에 적용합니다.

setState 호출1 / 4
State ChangesetState(count + 1)
Detection
Reconciliation
DOM Update
React
setState(count + 1)이 호출되면, React는 해당 컴포넌트를 dirty로 마킹합니다. 아직 DOM은 건드리지 않습니다.

핵심은 setState입니다. React는 상태 변경을 직접 감지하지 않습니다. 개발자가 setState를 명시적으로 호출해야 합니다. count = 1처럼 직접 할당하면? React는 모릅니다. 아무 일도 일어나지 않습니다.

이 설계에는 이유가 있습니다. 컴포넌트가 순수 함수 처럼 동작하기 때문에 예측 가능합니다. 같은 state와 props가 들어오면 항상 같은 UI가 나옵니다. 디버깅할 때 "이 상태에서 이 컴포넌트는 이렇게 보여야 한다"고 확신할 수 있습니다.

장점 : 멘탈 모델이 단순하고 예측 가능합니다. 상태 → UI의 흐름이 항상 한 방향입니다.

단점 : 상태가 바뀌면 컴포넌트 함수 전체가 다시 실행됩니다. 자식 컴포넌트도 같이 re-render됩니다. 실제 DOM 변경은 최소화되지만, 함수 실행과 VDOM 비교에 비용이 발생합니다. React.memo, useMemo, useCallback 등의 최적화 도구가 필요한 이유입니다.

Vue의 반응성 ,자동 추적

Vue는 JavaScript의 Proxy를 활용합니다. ref()reactive()로 감싼 값은 Proxy 객체가 됩니다. 이 값을 읽으면Vue가 "누가 이 값에 의존하는지" 기록하고, 쓰면 "의존하는 것들에게 알림"을 보냅니다.

Proxy set trap1 / 4
State Changecount.value++ → Proxy set
Detection
Reconciliation
DOM Update
Vue
count.value++를 실행하면, Vue의 Proxy가 set 트랩을 실행합니다. 값이 변경되었음을 감지합니다.

개발자가 명시적으로 "이 컴포넌트는 이 상태에 의존한다"고 선언할 필요가 없습니다. 렌더링 중에 count.value를 읽기만 하면 Vue가 자동으로 의존성을 수집합니다. 이것이 의존성 추적 (Dependency Tracking) 입니다.

// Vue 내부 동작 (간소화)
const count = new Proxy({ value: 0 }, {
  get(target, key) {
    track(target, key);  // 누가 읽었는지 기록
    return target[key];
  },
  set(target, key, value) {
    target[key] = value;
    trigger(target, key); // 의존하는 곳에 알림
    return true;
  }
});

Vue의 컴파일러도 중요한 역할을 합니다. 템플릿을 분석해서 정적인 노드와 동적인 노드를 미리 분류합니다. VDOM diff 시 동적인 부분만 비교하면 되므로 React보다 비교 범위가 좁습니다.

장점 : 세밀한 추적으로 필요한 컴포넌트만 업데이트합니다. 개발자가 최적화를 신경 쓸 일이 적습니다.

단점 : Proxy 기반이라 원시값은 .value로 감싸야 합니다. 디버깅 시 Proxy 래퍼 때문에 실제 값을 확인하기 어려울 수 있습니다.

Angular의 반응성 ,Zone.js와 변경 감지

Angular의 접근은 독특합니다. Zone.js 라는 라이브러리가 브라우저의 모든 비동기 API (addEventListener, setTimeout, Promise, fetch 등) 를 몽키패치합니다. 비동기 작업이 끝날 때마다 "뭔가 바뀌었을 수 있다"고 Angular에 알립니다.

Zone.js 이벤트 가로채기1 / 4
State Changeclick → Zone.js 감지
Detection
Reconciliation
DOM Update
Angular
버튼 클릭 이벤트를 Zone.js가 가로챕니다. Zone.js는 모든 비동기 작업 (이벤트, setTimeout, Promise 등) 을 래핑하여 감지합니다.

Angular는 알림을 받으면 루트 컴포넌트부터 트리를 순회하며 각 컴포넌트의 바인딩을 확인합니다. 이것이 Change Detection 사이클 입니다. 이전 값과 현재 값을 비교해서 다르면 DOM을 업데이트합니다.

// Zone.js가 하는 일 (간소화)
const originalSetTimeout = window.setTimeout;
window.setTimeout = function(callback, delay) {
  return originalSetTimeout(() => {
    callback();
    angular.detectChanges(); // 비동기 작업 후 변경 감지 실행
  }, delay);
};

이 방식의 문제점은 명확합니다. 버튼 하나를 클릭했을 뿐인데 전체 트리를 순회합니다. OnPush 전략으로 범위를 줄일 수 있지만, 근본적인 한계는 남습니다.

그래서 Angular은 Signals 로 전환하고 있습니다. Signals는 Vue의 반응성과 유사하게 세밀한 추적을 제공하며, Zone.js 없이도 변경 감지가 가능합니다. Angular 16 (2023) 에서 도입되었고, v20.2에서 Zoneless가 안정화되었으며, v21부터는 Zone.js가 기본 번들에 포함되지 않습니다.

장점 : 개발자가 반응성에 대해 아무것도 신경 쓰지 않아도 동작합니다. 프레임워크가 모든 것을 감시합니다.

단점 : 불필요한 변경 감지가 성능을 먹습니다. Zone.js의 몽키패칭은 서드파티 라이브러리와 충돌할 수 있습니다. Signals 전환으로 개선 중입니다.

Svelte의 반응성 ,컴파일러 + 런타임 시그널

Svelte는 컴파일러와 런타임의 조합으로 반응성을 구현합니다. Svelte 5에서 도입된 Runes시스템은 이전 버전의 순수 컴파일 타임 접근에서 발전하여, 런타임 시그널 기반의 세밀한 반응성 을 제공합니다.

시그널 값 변경1 / 3
State Changecount++ → 시그널 업데이트
Detection
DOM Update
Svelte
count++를 실행하면, 컴파일러가 $state()를 시그널 객체로 변환해 둔 코드가 값 변경을 감지합니다. Svelte 5의 Runes는 런타임 시그널 기반입니다.

개발자가 작성하는 코드:

let count = $state(0);
count++;

내부적으로 $state(0)는 컴파일러에 의해 시그널 객체로 변환됩니다. 이전 버전 (Svelte 3/4) 에서는 count++$$invalidate(0, count++)로 컴파일되어 컴포넌트 단위로 dirty 체크를 했지만, Svelte 5는 SolidJS와 유사한 런타임 시그널을 사용합니다. 의존성을 표현식 수준에서 추적하여 정확히 필요한 DOM만 업데이트합니다.

Virtual DOM이 없습니다. diff 알고리즘이 없습니다. 컴파일러가 시그널과 DOM을 직접 연결하는 코드를 생성하고, 런타임에 시그널이 변경되면 해당 DOM 노드만 업데이트됩니다.

장점 : 런타임 오버헤드가 매우 적습니다. 번들 크기도 작고, 배열의 한 항목만 바뀌면 그 항목만 업데이트하는 세밀한 반응성을 제공합니다.

단점 : 컴파일러 의존도가 높습니다. 디버깅 시 실제 실행되는 코드는 개발자가 작성한 코드와 다르므로 소스맵에 의존해야 합니다.

코드 비교

같은 카운터를 반응성 관점에서 다시 보겠습니다. 각 프레임워크가 "상태 변경 → UI 반영"을 어떻게 표현하는지 비교해 보세요.

import { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);

  // 상태 변경 → 컴포넌트 전체 re-render
  return (
    <button onClick={() => setCount(c => c + 1)}>
      {count}
    </button>
  );
}

네 코드 모두 같은 동작을 합니다. 하지만 반응성의 메커니즘이 다릅니다.

  • React는 setCount를 호출해야 변경을 알 수 있습니다 (명시적)
  • Vue는 ref()로 감싼 값의 변경을 자동으로 감지합니다 (Proxy)
  • Angular는 signal()로 반응형 값을 만들고 update()로 변경합니다 (Signals)
  • Svelte는 $state()로 선언하면 count++만으로 충분합니다 (컴파일러 + 런타임 시그널)

트레이드오프 비교

ReactVueAngularSvelte
감지 방식명시적 setStateProxy 자동 추적Zone.js / Signals컴파일러 + 런타임 시그널
업데이트 범위컴포넌트 단위의존성 단위컴포넌트 트리표현식 단위
VDOM있음있음 (최적화)없음 (Ivy)없음
런타임 비용중간중간높음 → 낮음매우 낮음

어떤 방식이 "정답"이라고 할 수 없습니다. React의 명시적 모델은 대규모 팀에서 예측 가능성을 제공하고, Vue의 자동 추적은 개발 생산성을 높이고, Svelte의 컴파일러 접근은 런타임 성능에서 우위를 보입니다. Angular는 Signals 전환으로 기존의 약점을 적극적으로 보완하고 있습니다.


다음 글에서는 컴포넌트 모델 을 다룹니다. 각 프레임워크가 UI를 어떤 단위로 쪼개고, 컴포넌트 간 데이터를 어떻게 주고받는지 살펴보겠습니다.