Skip to content

Chapter 02. Vue 3 반응형 엔진의 밑바닥 까보기 (Reactivity Deep Dive) ​

"1장에서 완성한 BoardApp.vue는 어떻게 변수 대입만으로 화면을 스스로 다시 그렸을까? 프레임워크의 마법을 걷어내면 그 자리에는 순수한 ES6 Proxy 아키텍처가 남는다."


2.1 자바스크립트는 원래 반응형(Reactive)이 아니다 ​

1장에서 직접 코딩한 BoardApp.vue의 삭제 로직을 다시 살펴봅시다:

typescript
// BoardApp.vue의 삭제 로직
posts.value = posts.value.filter(p => p.id !== id);

우리는 분명 단순한 자바스크립트 배열 변수의 참조를 바꿨을 뿐입니다.
그런데 웹 브라우저는 어떻게 해당 <tr> 행이 화면에서 사라져야 한다는 것을 알고 스스로 다시 그렸을까요?

자바스크립트 언어 자체는 본래 반응형 언어가 아닙니다:

typescript
let price = 1000;
let quantity = 2;
let total = price * quantity; // 2000

price = 2000;
console.log(total); // 🚨 여전히 2000! (자동으로 4000이 되지 않는다)

자바스크립트는 명령형(Imperative) 언어입니다. price = 2000으로 바꿨다고 해서 연관된 계산이나 화면 갱신이 자동으로 일어나는 마법은 브라우저 자바스크립트 엔진(V8)에 존재하지 않습니다.


2.2 Vue 3 반응형의 핵심: ES6 Proxy ​

Vue 3는 이 문제를 해결하기 위해 모던 자바스크립트 표준 사양인 Proxy 객체를 전면 도입했습니다.

Proxy는 "원본 객체를 감싸는 투명한 대리인(Wrapper)" 역할을 수행합니다.
객체의 속성을 읽거나(get), 변경할 때(set) 그 동작을 가로채서(Interception) 추가 작업을 수행할 수 있습니다.


2.3 [핸즈온 실습] 50줄로 만드는 초경량 Vue 3 반응형 엔진 ​

이 메커니즘을 온전히 체화하는 가장 좋은 방법은 Vue 라이브러리 없이 순수 TypeScript로 반응형 엔진을 직접 밑바닥부터 만들어 보는 것입니다.

📁 실습 준비: 파일 생성 위치 및 실행 방법 ​

  • 작성할 파일 경로: frontend/src/practice/mini-reactivity.ts
  • 실행 명령어: 터미널을 열고 frontend/ 디렉터리에서 아래 명령어를 실행하면 브라우저 없이 Node.js 콘솔에서 TypeScript 코드가 즉시 실행됩니다:
bash
  cd frontend
  npx tsx src/practice/mini-reactivity.ts
  # (또는 package.json에 등록된 단축 명령어):
  npm run practice:reactivity

[Step 1] 의존성 저장소(targetMap)와 포인터(activeEffect) 선언 ​

에디터로 frontend/src/practice/mini-reactivity.ts를 열고, 기존 내용을 비운 뒤 1단계부터 타이핑해 봅시다:

typescript
// frontend/src/practice/mini-reactivity.ts

// 1. 현재 실행 중인 부수 효과(렌더링 함수 등)를 기억할 전역 포인터
let activeEffect: (() => void) | null = null;

// 2. 의존성 저장소:
// Map 구조: Target(객체) -> Key(속성명) -> Set(이 속성을 바라보는 Effect 함수들)
const targetMap = new WeakMap<object, Map<string | symbol, Set<() => void>>>();

왜 WeakMap을 쓸까요?
대상 객체(target)가 메모리에서 해제(Garbage Collection)될 때, 해당 객체와 관련된 반응형 의존성 정보도 자동으로 함께 메모리에서 증발하도록 만들기 위해 키를 약하게 참조하는 WeakMap을 사용합니다. 메모리 누수를 원천 방지하는 훌륭한 설계입니다.


[Step 2] 의존성 수집(track) 함수 구현 ​

객체의 프로퍼티를 읽는 순간(Getter), 현재 실행 중인 함수(activeEffect)를 해당 프로퍼티의 구독자 목록(Set)에 등록합니다:

typescript
// 3. 의존성 수집: "누가 날 읽었지? 내 구독자 명단에 적어두자!"
function track(target: object, key: string | symbol) {
  if (!activeEffect) return; // 읽고 있는 함수가 없으면 무시

  let depsMap = targetMap.get(target);
  if (!depsMap) {
    depsMap = new Map();
    targetMap.set(target, depsMap);
  }

  let dep = depsMap.get(key);
  if (!dep) {
    dep = new Set();
    depsMap.set(key, dep);
  }

  // 현재 실행 중인 함수를 구독자로 추가!
  dep.add(activeEffect);
}

[Step 3] 의존성 통보(trigger) 함수 구현 ​

객체의 프로퍼티가 수정되는 순간(Setter), 아까 수집해 둔 구독자 목록(dep)을 꺼내어 모든 함수를 일괄 재실행합니다:

typescript
// 4. 의존성 통보: "내 값이 바뀌었으니 날 구독하던 함수들 모두 일어나!"
function trigger(target: object, key: string | symbol) {
  const depsMap = targetMap.get(target);
  if (!depsMap) return;

  const dep = depsMap.get(key);
  if (dep) {
    // 등록되어 있던 모든 Effect(렌더링 함수 등)를 다시 호출!
    dep.forEach(effect => effect());
  }
}

[Step 4] reactive() 함수로 Proxy 트랩 씌우기 ​

이제 자바스크립트 객체를 Proxy로 감싸, get 시점에 track()을, set 시점에 trigger()를 호출하도록 묶어줍니다:

typescript
// 5. reactive: Proxy로 객체를 감싸 get/set 트랩을 건다
export function reactive<T extends object>(target: T): T {
  return new Proxy(target, {
    get(target, key, receiver) {
      track(target, key); // 👈 1. 의존성 수집
      return Reflect.get(target, key, receiver);
    },
    set(target, key, value, receiver) {
      const oldValue = (target as any)[key];
      const result = Reflect.set(target, key, value, receiver);
      
      // 값이 실제로 바뀌었을 때만 화면 갱신 트리거!
      if (oldValue !== value) {
        trigger(target, key); // 👈 2. 변경 통보
      }
      return result;
    }
  });
}

[Step 5] effect() 함수로 자동 감시자 연결하기 ​

실행할 함수(컴포넌트의 렌더링 함수 등)를 activeEffect에 임시로 올려두고 즉시 실행시키는 러너 함수를 만듭니다:

typescript
// 6. effect: 콜백을 실행하고 반응형 의존성을 자동으로 엮어줌
export function effect(fn: () => void) {
  const effectRunner = () => {
    activeEffect = effectRunner;
    try {
      fn();
    } finally {
      activeEffect = null; // 실행 후 전역 포인터 초기화
    }
  };

  effectRunner(); // 최초 1회 즉시 실행 (의존성 수집 트리거)
}

[Step 6] 게시판 실시간 렌더링 시뮬레이션 및 터미널 검증 ​

이제 파일 최하단에 테스트 실행 코드를 덧붙여 봅시다:

typescript
// ==========================================
// 🧪 [테스트 실행 구역]
// ==========================================
console.log('🚀 [Chapter 02] 미니 반응형 엔진 테스트 시작!\n');

// 1장의 게시글 하나를 모방한 상태 객체
const post = reactive({
  title: 'jQuery 레거시의 종말',
  viewCount: 0
});

// Vue 컴포넌트의 가상 렌더링 함수 시뮬레이션
effect(() => {
  console.log(`[DOM 렌더링] 제목: "${post.title}" | 조회수: ${post.viewCount}`);
});
// 💡 최초 실행 시: effect 내부에서 post.title과 post.viewCount를 '읽었으므로',
// 두 속성의 targetMap 구독자 명단에 이 console.log 함수가 자동으로 등록됩니다!

console.log('\n--- 1. 조회수 증가 (post.viewCount++) ---');
post.viewCount++;

console.log('\n--- 2. 제목 변경 (post.title = ...) ---');
post.title = 'Vue 3 아키텍처 완성';

console.log('\n🎉 모든 반응형 트리거가 정상 작동했습니다!');

💻 터미널에서 실행하고 결과 확인하기 ​

파일을 저장하고, 터미널에서 아래 명령어를 실행해 보십시오:

bash
cd frontend
npx tsx src/practice/mini-reactivity.ts

[실제 콘솔 출력 결과]:

text
🚀 [Chapter 02] 미니 반응형 엔진 테스트 시작!

[DOM 렌더링] 제목: "jQuery 레거시의 종말" | 조회수: 0

--- 1. 조회수 증가 (post.viewCount++) ---
[DOM 렌더링] 제목: "jQuery 레거시의 종말" | 조회수: 1

--- 2. 제목 변경 (post.title = ...) ---
[DOM 렌더링] 제목: "Vue 3 아키텍처 완성" | 조회수: 1

🎉 모든 반응형 트리거가 정상 작동했습니다!

놀랍지 않습니까?
우리는 post.viewCount++와 post.title = ...라는 단순한 변수 수정을 했을 뿐인데, 아무도 부르지 않은 console.log 렌더링 함수가 마법처럼 2번이나 알아서 재실행되었습니다.
이것이 바로 Vue 3가 1장의 BoardApp.vue에서 테이블을 자동으로 다시 그린 핵심 엔진의 정체입니다!


2.4 그렇다면 ref는 왜 필요하고 어떻게 동작하는가? ​

1장의 BoardApp.vue를 보면 게시글 목록은 ref<Post[]>([])로 선언했고, 폼 입력값은 reactive({ ... })로 선언했습니다.

"Proxy로 다 해결된다면, 왜 ref라는 걸 또 만들어서 귀찮게 .value를 붙이게 만들었을까요?"

자바스크립트 원시값(Primitive)의 한계 ​

Proxy는 오직 **Object (객체, 배열 등)**에만 씌울 수 있습니다.
자바스크립트의 기본 원시값인 number, string, boolean에는 Proxy를 씌울 수가 없습니다:

typescript
// ❌ 자바스크립트 엔진 문법 에러!
new Proxy(0, { ... }); // TypeError: Cannot create proxy with a non-object

따라서 원시값을 반응형으로 다루려면 **"원시값을 .value라는 속성으로 감싸는 객체 래퍼(Wrapper)"**를 만들어야 합니다. 이것이 바로 ref의 정체입니다:

typescript
// ref의 내부 구현 메커니즘
class RefImpl<T> {
  private _value: T;

  constructor(value: T) {
    this._value = value;
  }

  get value() {
    track(this, 'value'); // .value를 읽을 때 의존성 수집!
    return this._value;
  }

  set value(newVal) {
    if (newVal !== this._value) {
      this._value = newVal;
      trigger(this, 'value'); // .value가 바뀔 때 화면 갱신 통보!
    }
  }
}

우리가 <script>에서 posts.value = ... 처럼 .value를 반드시 붙여야 하는 이유는, .value의 getter/setter 트랩을 건드려야만 Vue 엔진이 화면을 다시 그릴 수 있기 때문입니다.


2.5 BoardApp.vue의 상태 설계 기준 ​

실무에서 어떤 상태를 ref로 만들고 어떤 상태를 reactive로 만들어야 할지 헷갈린다면 아래 원칙을 적용하십시오:

구분적용 대상BoardApp.vue 적용 예시
ref모든 원시값(string, number, boolean) 및 재할당이 필요한 배열/객체const posts = ref<Post[]>([])
const isLoading = ref<boolean>(false)
reactive한 덩어리로 묶여 다니며 절대 통째로 재할당되지 않는 폼 객체const form = reactive({ title: '', author: '', content: '' })

2.6 다음 챕터로 이어지는 질문 ​

이제 우리는 BoardApp.vue의 반응형 엔진이 내부에서 어떻게 돌아가는지 완벽히 이해했습니다.
다음 단계로, 게시글 상세 보기 화면과 수정 폼을 BoardApp.vue에 추가해 볼 차례입니다.

그런데 여기서 실무 개발자들이 가장 많이 겪는 치명적인 실무 버그 1위가 터집니다:

"코드를 깔끔하게 쓰려고 const { title, content } = form 처럼 ES6 구조 분해 할당을 했더니, 반응성의 파이프가 끊어져 브라우저 화면이 요지부동 멈춰버렸습니다!"

이어지는 Chapter 03에서는 이 반응형 유실(Reactivity Loss) 현상을 브라우저에서 직접 터뜨려보고, toRefs 및 Vue 3.5 신규 표준으로 해결하는 실습을 진행하겠습니다.

Moon HomeLab Engineering Knowledge Hub